squad-proto/src/sim/target.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00

356 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "sim/target.h"
namespace
{
// Кучки: 5 на арене (рядом со стартом), 3 в северной комнате,
// 3 в западной, 1 одиночная в восточной. Всего 12.
//
// Северная кучка стоит НАПРОТИВ прохода x=30..31: из коридора до неё есть
// чистая линия. Это тот самый тест-кейс из спека — в узком проходе правило
// «не стрелять сквозь своих» упирается в геометрию, и отряд обязан вытянуться
// в колонну: стреляет передний, задние молчат.
//
// Кучка обязана быть В ПРЕДЕЛАХ ДАЛЬНОСТИ от середины прохода (y≈14), иначе
// стрелять из коридора не по чему и тест-кейс проверяет пустоту. При weaponRange
// 7.2 ближняя мишень стоит в ~6.5 юнита: запас есть, но небольшой — если менять
// дальность, эти координаты надо пересчитывать вместе с ней.
const float kTargetSpots[NUM_TARGETS][2] = {
{30.5f, 20.5f}, {31.5f, 22.5f}, {29.5f, 23.5f}, {32.5f, 21.5f}, {30.5f, 24.5f},
{29.5f, 8.5f}, {31.5f, 7.5f}, {27.5f, 10.5f},
{ 7.5f, 40.5f}, { 9.5f, 42.5f}, { 6.5f, 43.5f},
{43.5f, 35.5f},
};
// Сопротивление отбросу по слотам. Не случайное: в каждой кучке есть кто-то
// тяжёлый, кого клинком не сдвинуть, и кто-то лёгкий, кто улетает от первого
// же удара. Иначе «отброс» и «сопротивление» — два числа, которых не видно.
const float kTargetKnockResist[NUM_TARGETS] = {
0.00f, 0.65f, 0.15f, 0.40f, 0.00f,
0.55f, 0.10f, 0.30f,
0.20f, 0.70f, 0.05f,
0.85f,
};
// Кто стоит в каждой точке в боевом режиме. Ближняя кучка — та, на которую
// отряд наткнётся первой: бегун, двое бредущих, плевок и ещё бегун. Громилы
// сидят в дальних комнатах: до них надо дойти.
const EnemyKind kTargetKinds[NUM_TARGETS] = {
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::RUSHER,
EnemyKind::SHAMBLER, EnemyKind::SPITTER, EnemyKind::BRUTE,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SPITTER,
EnemyKind::BRUTE,
};
// Твари СВЕРХ мишеней: расставлены по всей карте, а не только у старта.
// Без них зачистка первой кучки заканчивала вылазку, и карта стояла пустой.
// В песочнице приёмки их нет вообще — та расстановка не менялась.
const float kExtraSpots[NUM_EXTRA_ENEMIES][2] = {
{14.5f, 12.5f}, {18.5f, 16.5f}, {12.5f, 24.5f},
{36.5f, 12.5f}, {40.5f, 18.5f}, {38.5f, 27.5f},
{21.5f, 36.5f}, {25.5f, 41.5f}, {33.5f, 39.5f},
{16.5f, 32.5f}, {30.5f, 33.5f}, {41.5f, 43.5f},
};
// Хваты стоят ПОДАЛЬШЕ от старта, в глубине карты: захват — это ловушка для
// отряда, который уже разошёлся по позициям, а не встречающий у порога.
const EnemyKind kExtraKinds[NUM_EXTRA_ENEMIES] = {
EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::SHAMBLER, EnemyKind::RUSHER,
EnemyKind::LURKER, EnemyKind::BRUTE, EnemyKind::SPITTER,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::LURKER,
};
// Разрушаемая обстановка. Стоит ПАРАМИ и по краям комнат, а не по центру:
// брошенный скарб жмётся к стенам, и ровно поэтому он читается как обстановка,
// а не как расставленные мишени. Две штуки — у самого старта: игрок обязан
// узнать, что хлам ломается, до того как в него полетит первая тварь.
const float kPropSpots[NUM_PROPS][2] = {
{24.5f, 24.5f}, {23.5f, 25.5f}, // у старта: пара ящиков под рукой
{33.5f, 19.5f}, // ближняя кучка: укрытие сбоку
{28.5f, 6.5f}, {26.5f, 6.5f}, // северная комната, вдоль стены
{ 5.5f, 41.5f}, // западная комната
{44.5f, 33.5f}, {42.5f, 44.5f}, // восточная и юго-восточный угол
};
const EnemyKind kPropKinds[NUM_PROPS] = {
EnemyKind::CRATE, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::LOCKER, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::BARREL, EnemyKind::LOCKER,
};
} // namespace
EnemyDef EnemyDefOf(EnemyKind kind)
{
EnemyDef d;
switch (kind)
{
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
// шагом, — и ровно поэтому страшен только гурьбой.
case EnemyKind::SHAMBLER:
d.name = "SHAMBLER";
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
d.aggroRadius = 9.5f; d.attackRange = 0.85f; d.damage = 13.0f;
d.windup = 0.42f; d.interval = 1.25f;
// Взмах лапой: широкий сектор перед собой. Отходить от него назад
// бесполезно — он и так короткий; уходить надо вбок.
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f;
d.lightPower = 0.05f;
d.r = 116; d.g = 52; d.b = 52;
break;
// Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда
// стрелок остаётся с ним один на один.
case EnemyKind::RUSHER:
d.name = "RUSHER";
d.hp = 110.0f; d.radius = 0.27f; d.speed = 3.3f; d.knockResist = 0.0f;
d.aggroRadius = 12.0f; d.attackRange = 0.75f; d.damage = 14.0f;
d.windup = 0.28f; d.interval = 1.10f;
// Рывок: разрыв в три метра он закрывает за пятую долю секунды и
// сносит ВСЕХ по прямой. Замах длиннее прежнего удара намеренно —
// это единственное окно, в котором отряд успевает разойтись с оси.
d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f;
d.lightPower = 0.07f;
d.r = 158; d.g = 74; d.b = 44;
break;
// Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт
// по площадям — из-за него отряд не может просто встать в линию.
case EnemyKind::SPITTER:
d.name = "SPITTER";
d.hp = 110.0f; d.radius = 0.31f; d.speed = 1.15f; d.knockResist = 0.1f;
d.aggroRadius = 10.5f; d.attackRange = 6.2f; d.damage = 16.0f;
d.windup = 0.55f; d.interval = 1.60f;
d.ranged = true; d.projectileSpeed = 8.5f;
// Снаряд летит дальше, чем плевок «достаёт»: отойти от него на шаг
// мало, надо разрывать дистанцию или прятаться за стену.
d.attack = AttackKind::SPIT; d.spitRange = 8.4f;
d.lightPower = 0.10f;
d.r = 96; d.g = 132; d.b = 72;
break;
// Громила: медленный, толстый, с одного удара снимает половину бойца.
// Клинком его надо разбирать вдвоём, и это правильная цена.
case EnemyKind::BRUTE:
d.name = "BRUTE";
d.hp = 520.0f; d.radius = 0.46f; d.speed = 1.05f; d.knockResist = 0.85f;
d.aggroRadius = 8.5f; d.attackRange = 1.25f; d.damage = 46.0f;
d.windup = 0.95f; d.interval = 2.60f;
// Удар оземь: круг вокруг себя, а не выпад в жертву. Разбирать
// громилу вдвоём теперь стоит денег — оба стоят в круге.
d.attack = AttackKind::SLAM; d.slamRadius = 2.15f;
d.lightPower = 0.12f;
d.r = 142; d.g = 60; d.b = 78;
break;
// Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют.
// Опасен не уроном, а тем, что вырывает одного из строя: линия огня
// рушится, отряд разваливается, и остальные твари доедают.
case EnemyKind::LURKER:
d.name = "LURKER";
d.hp = 150.0f; d.radius = 0.30f; d.speed = 1.90f; d.knockResist = 0.15f;
d.aggroRadius = 11.0f; d.attackRange = 0.90f; d.damage = 9.0f;
d.windup = 0.50f; d.interval = 2.40f;
// Урон тикает, пока держит: 9 за тик каждые 0.45 с — само по себе
// терпимо, смертельна именно неподвижность.
d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f;
d.lightPower = 0.09f;
d.r = 88; d.g = 62; d.b = 118;
break;
// --- разрушаемая обстановка -------------------------------------------
// Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы
// UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем
// ни идти, ни бить. Единственное, что у них есть, — HP и радиус.
//
// Сопротивление отбросу единица: ящик от попадания не ездит по полу.
// Живучесть подобрана от ОДНОГО магазина: труху сносит очередью на ходу,
// шкафчик требует остановиться — и в этом вся разница между ними.
case EnemyKind::CRATE:
d.name = "CRATE";
d.hp = 60.0f; d.radius = 0.38f; d.knockResist = 1.0f;
d.r = 104; d.g = 76; d.b = 46;
break;
case EnemyKind::BARREL:
d.name = "BARREL";
d.hp = 95.0f; d.radius = 0.33f; d.knockResist = 1.0f;
d.r = 92; d.g = 84; d.b = 58;
break;
case EnemyKind::LOCKER:
d.name = "LOCKER";
d.hp = 140.0f; d.radius = 0.36f; d.knockResist = 1.0f;
d.r = 78; d.g = 88; d.b = 96;
break;
// Мишень приёмки: всё по нулям, поведения нет. Вид атаки ей выставлен
// формально — с нулевым уроном и нулевой дальностью он не сработает
// никогда, а UpdateEnemies до неё вообще не доходит.
default:
d.name = "TARGET";
d.hp = g_tune.targetHP;
d.radius = g_tune.targetRadius;
d.attack = AttackKind::SWIPE;
d.r = 120; d.g = 44; d.b = 44;
break;
}
return d;
}
const char* EnemyName(EnemyKind kind) { return EnemyDefOf(kind).name; }
void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist,
const Tilemap* map)
{
const EnemyDef def = EnemyDefOf(kind);
Target t;
t.self = world.Create();
t.kind = kind;
t.pos = pos;
t.home = t.pos;
t.vel = {};
t.hp = def.hp;
t.maxHp = def.hp;
t.radius = def.radius;
t.respawnTimer = 0.0f;
t.alive = true;
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —
// свойство вида. Отрицательное значение означает «взять из каталога».
t.knockResist = (knockResist >= 0.0f) ? knockResist : def.knockResist;
t.speed = def.speed;
t.aggroRadius = def.aggroRadius;
t.attackRange = def.attackRange;
t.damage = def.damage;
t.windup = def.windup;
t.interval = def.interval;
t.ranged = def.ranged;
t.projectileSpeed = def.projectileSpeed;
t.attack = def.attack;
t.swipeArcDeg = def.swipeArcDeg;
t.lungeDist = def.lungeDist;
t.lungeTime = def.lungeTime;
t.spitRange = def.spitRange;
t.slamRadius = def.slamRadius;
t.grabTime = def.grabTime;
t.grabTick = def.grabTick;
// Точки расставлены руками, а планировка генерируется: тварь, поставленная
// в стену, никогда бы оттуда не вышла — она сдвигается только когда идёт
// за кем-то, а из стены ей никого не видно.
if (map)
{
map->ResolveCircle(t.pos, t.radius);
t.home = t.pos;
}
world.Add<Target>(t.self, t);
}
void SpawnTargets(ecs::World& world, bool hostile, const Tilemap* map)
{
// Три полосы подряд в одном пуле: мишени, твари сверх них, обстановка.
// В песочнице приёмки живёт только первая — ни одной лишней сущности,
// и все замеры остаются сравнимыми с прежними.
const int total = hostile ? (NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS) : NUM_TARGETS;
for (int i = 0; i < total; ++i)
{
const int extraIdx = i - NUM_TARGETS;
const int propIdx = extraIdx - NUM_EXTRA_ENEMIES;
const bool extra = (extraIdx >= 0 && propIdx < 0);
const bool prop = (propIdx >= 0);
const EnemyKind kind = !hostile ? EnemyKind::DUMMY
: prop ? kPropKinds[propIdx]
: extra ? kExtraKinds[extraIdx]
: kTargetKinds[i];
const Vec2 pos = prop ? Vec2{kPropSpots[propIdx][0], kPropSpots[propIdx][1]}
: extra ? Vec2{kExtraSpots[extraIdx][0], kExtraSpots[extraIdx][1]}
: Vec2{kTargetSpots[i][0], kTargetSpots[i][1]};
MakeTarget(world, kind, pos, hostile ? -1.0f : kTargetKnockResist[i], map);
}
}
void KnockTarget(Target& t, Vec2 dir, float speed)
{
// Обстановка не ездит по полу от попаданий. Домой её никто не тянет (это
// свойство мишени приёмки), так что сдвинутый ящик уехал бы навсегда.
if (t.IsProp()) return;
t.vel += dir * (speed * t.KnockScale());
}
void UpdateTargets(std::vector<Target>& targets, float dt)
{
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt);
for (Target& t : targets)
{
t.hurtTimer = std::max(0.0f, t.hurtTimer - dt);
if (t.IsDummy())
{
// Мишень приёмки: ручки tuning продолжают крутить именно её.
t.radius = g_tune.targetRadius;
t.maxHp = g_tune.targetHP;
}
t.pos += t.vel * dt;
t.vel *= damp;
// Возврат на своё место — свойство МИШЕНИ, а не тела: тварь, которую
// тянет домой, не может ни догнать отряд, ни отойти от него.
if (t.IsDummy()) t.pos += (t.home - t.pos) * pull;
if (t.alive) continue;
// Разбитый хлам остаётся разбитым: RespawnDelay() возвращает ему
// отрицательный срок, и без этой проверки он воскресал бы в тот же кадр.
if (t.IsProp()) continue;
t.respawnTimer -= dt;
if (t.respawnTimer <= 0.0f)
{
const EnemyDef def = EnemyDefOf(t.kind);
t.alive = true;
t.hp = t.IsDummy() ? g_tune.targetHP : def.hp;
t.pos = t.home;
t.vel = {};
t.victim = -1;
t.aggro = false;
t.attackTimer = 0.0f;
t.windupTimer = 0.0f;
// Недоведённый рывок и незакрытый захват пережили бы смерть и
// вернулись бы вместе с тварью. Захваченного бойца при этом никто
// не держит: его таймер гаснет сам, пока держать некому.
t.lungeTimer = 0.0f;
t.lungeMask = 0;
t.grabbed = -1;
t.grabTimer = 0.0f;
t.grabTickTimer = 0.0f;
}
}
}
int CountAliveTargets(const std::vector<Target>& targets)
{
// Хлам здесь не считается: это знаменатель «по скольким целям отряд может
// работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает
// огневую позицию хуже и зачистку — незаконченной.
int n = 0;
for (const Target& t : targets)
if (t.alive && !t.IsProp()) ++n;
return n;
}