squad-proto/src/sim/enemy.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

296 lines
14 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/enemy.h"
#include "sim/bullet.h"
namespace
{
// Ближайший ЖИВОЙ боец. Упавшего тварь не добивает: иначе отряд, потерявший
// одного, теряет всех подряд, пока игрок ничего не может сделать.
int NearestAgent(Vec2 from, const Agent* agents, int count, float maxDist, float* outDist)
{
int best = -1;
float bestD = maxDist;
for (int i = 0; i < count; ++i)
{
if (!agents[i].alive) continue;
const float d = Distance(from, agents[i].pos);
if (d < bestD)
{
bestD = d;
best = i;
}
}
if (outDist) *outDist = bestD;
return best;
}
// Расталкивание тварей между собой. Без него толпа сливается в одну точку и
// вместо десяти тел отряд видит одно — а бьют его при этом десятью ударами.
Vec2 CrowdPush(const Target& self, int selfIndex, const std::vector<Target>& all)
{
Vec2 push{};
for (int i = 0; i < int(all.size()); ++i)
{
if (i == selfIndex) continue;
const Target& o = all[i];
if (!o.alive || o.IsDummy() || o.IsProp()) continue;
const Vec2 d = self.pos - o.pos;
const float gap = self.radius + o.radius;
const float len = Length(d);
if (len >= gap || len < 1e-4f) continue;
push += (d / len) * ((gap - len) / gap);
}
return push;
}
// Зазор, с которым плевок вылетает из пасти, а не из центра тела: стартуй
// снаряд внутри собственной капсулы — и он бы упирался в неё же. Это геометрия
// спавна, а не баланс, поэтому число живёт здесь, а не в каталоге видов.
constexpr float kSpitMuzzleGap = 0.12f;
// Урон бойцу от твари — в одном месте на все пять видов атак: множитель
// сложности, событие попадания и падение. Разложенное по веткам, это правило
// первым же дополнением разъехалось бы на пять слегка разных версий.
void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events)
{
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
if (!events) return;
events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir);
if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir);
}
} // namespace
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events)
{
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
// хвата — продлевать стало некому, и через enemyGrabHold боец свободен.
// В песочнице приёмки этот цикл ничего не делает: там никого не хватают.
for (int i = 0; i < agentCount; ++i)
agents[i].grabbedTimer = std::max(0.0f, agents[i].grabbedTimer - dt);
for (int ti = 0; ti < int(targets.size()); ++ti)
{
Target& t = targets[ti];
// Мишень приёмки не шевелится по определению, обстановка — тем более:
// у ящика нет ни скорости, ни агро, ни удара. Отсекаем их здесь, а не
// полагаемся на нули в каталоге.
if (t.IsDummy() || t.IsProp() || !t.alive) continue;
t.attackTimer = std::max(0.0f, t.attackTimer - dt);
// --- захват уже держит (GRAB) -----------------------------------------
// Пока держит — не ходит, не выбирает цель заново и не замахивается.
// Всё, что у неё есть, уже вцепилось в бойца.
if (t.grabbed >= 0)
{
Agent& held = agents[t.grabbed];
t.grabTimer -= dt;
t.facing = AngleOf(Normalized(held.pos - t.pos, FromAngle(t.facing)));
// Время вышло или боец упал: упавшего твари не добивают, держать
// его тем более незачем.
if (!held.alive || t.grabTimer <= 0.0f)
{
if (events) events->Push(CombatEventKind::ENEMY_RELEASE, t.grabbed, held.pos,
FromAngle(t.facing));
t.grabbed = -1;
t.grabTimer = 0.0f;
t.attackTimer = t.interval;
continue;
}
held.grabbedTimer = std::max(held.grabbedTimer, g_tune.enemyGrabHold);
t.grabTickTimer -= dt;
if (t.grabTickTimer <= 0.0f)
{
t.grabTickTimer += std::max(0.05f, t.grabTick);
HurtAgent(held, t.grabbed, t.damage, FromAngle(t.facing), events);
}
continue;
}
// --- рывок в полёте (LUNGE) -------------------------------------------
// Именно движение по времени, а не телепорт: бросок обязан быть виден,
// иначе непонятно, чем именно снесло бойца.
if (t.lungeTimer > 0.0f)
{
t.lungeTimer -= dt;
const Vec2 from = t.pos;
const float speed = t.lungeDist / std::max(0.02f, t.lungeTime);
t.pos += t.lungeDir * (speed * dt);
map.ResolveCircle(t.pos, t.radius); // сквозь стены не рвётся никто
// Урон всем, кого зацепило ПО ПУТИ, а не одной жертве: рывок —
// это отрезок, и стоять на нём не должно быть безопасно никому.
// Маска считается по индексам бойцов, поэтому и ограничена 32.
for (int i = 0; i < agentCount && i < 32; ++i)
{
if (!agents[i].alive) continue;
if (t.lungeMask & (1u << i)) continue; // каждого — один раз за рывок
if (DistancePointToSegment(agents[i].pos, from, t.pos) >
t.radius + agents[i].bodyRadius)
continue;
t.lungeMask |= (1u << i);
HurtAgent(agents[i], i, t.damage, t.lungeDir, events);
}
if (t.lungeTimer <= 0.0f)
{
t.lungeTimer = 0.0f;
t.attackTimer = t.interval; // откат считается от КОНЦА рывка
}
continue;
}
// --- кого преследуем --------------------------------------------------
float dist = 0.0f;
const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius, &dist);
if (victim >= 0) t.aggro = true; // заметил — уже не забудет
t.victim = victim;
if (victim < 0)
{
// Никого в радиусе: замах в пустоту не доводится.
t.windupTimer = 0.0f;
continue;
}
const Vec2 toVictim = agents[victim].pos - t.pos;
t.facing = AngleOf(Normalized(toVictim, FromAngle(t.facing)));
// --- замах доведён: бьём ----------------------------------------------
// Ось удара НЕ доводится: замахнулся — бьёт туда, куда замахнулся.
// Ровно это даёт игроку возможность увести бойца из-под удара.
if (t.Telegraphing())
{
t.windupTimer -= dt;
if (t.windupTimer > 0.0f) continue; // на замахе тварь стоит
t.windupTimer = 0.0f;
const Vec2 axis = FromAngle(t.facing);
switch (t.attack)
{
// Плевок: единственная атака, которую перекрывает стена, —
// снаряд летит и во что-нибудь упирается.
case AttackKind::SPIT:
SpawnHostileBullet(world, t.pos + axis * (t.radius + kSpitMuzzleGap), axis,
t.projectileSpeed, t.damage * g_tune.enemyDamageScale,
std::max(t.spitRange, t.attackRange));
if (events) events->Push(CombatEventKind::ENEMY_SPIT, ti, t.pos, axis);
t.attackTimer = t.interval;
break;
// Рывок: с этого шага тварь едет по прямой и откат ей встанет
// только в конце броска.
case AttackKind::LUNGE:
t.lungeDir = axis;
t.lungeTimer = t.lungeTime;
t.lungeMask = 0;
if (events) events->Push(CombatEventKind::ENEMY_LUNGE, ti, t.pos, axis);
break;
// Удар оземь: КРУГ вокруг себя. Под него попадает и тот, кто
// просто стоял рядом с жертвой, — разбирать громилу вдвоём
// теперь стоит здоровья обоим.
case AttackKind::SLAM:
{
if (events) events->Push(CombatEventKind::ENEMY_SLAM, ti, t.pos, axis);
for (int i = 0; i < agentCount; ++i)
{
if (!agents[i].alive) continue;
const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.slamRadius + agents[i].bodyRadius) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events);
}
t.attackTimer = t.interval;
break;
}
// Захват: промахнуться им можно — ушёл из вытянутых лап за время
// замаха, и хват остался ни с чем на целый интервал.
case AttackKind::GRAB:
{
if (dist <= t.attackRange + agents[victim].bodyRadius)
{
t.grabbed = victim;
t.grabTimer = t.grabTime;
t.grabTickTimer = 0.0f; // первый тик сразу: хватку видно
agents[victim].grabbedTimer =
std::max(agents[victim].grabbedTimer, g_tune.enemyGrabHold);
if (events) events->Push(CombatEventKind::ENEMY_GRAB, victim,
agents[victim].pos, axis);
}
else
{
t.attackTimer = t.interval;
}
break;
}
// Взмах: сектор перед собой. Достаёт всех, кто в него влез, —
// от такого удара уходят вбок, а не назад.
case AttackKind::SWIPE:
default:
{
const float half = t.swipeArcDeg * 0.5f * DEG2RAD_F;
for (int i = 0; i < agentCount; ++i)
{
if (!agents[i].alive) continue;
const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.attackRange + agents[i].bodyRadius) continue;
if (std::fabs(AngleDiff(t.facing, AngleOf(d))) > half) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events);
}
t.attackTimer = t.interval;
break;
}
}
continue;
}
// --- начать замах -----------------------------------------------------
// Телеграф обязателен для КАЖДОЙ атаки, но дистанция начала у них разная:
// рывок затевается ИЗДАЛЕКА (он для того и нужен, чтобы закрыть разрыв),
// остальные — по своей досягаемости.
const float reach = t.attackRange + agents[victim].bodyRadius;
const float trigger = (t.attack == AttackKind::LUNGE) ? std::max(reach, t.lungeDist)
: reach;
if (dist <= trigger && t.attackTimer <= 0.0f)
{
t.windupTimer = t.windup * std::max(0.05f, g_tune.enemyWindupScale);
if (events) events->Push(CombatEventKind::ENEMY_WINDUP, ti, t.pos,
FromAngle(t.facing));
continue;
}
// --- ход --------------------------------------------------------------
// Стрелок держит дистанцию: подходить вплотную ему незачем, а отряду
// нужен противник, до которого ещё надо дойти.
Vec2 dir{};
if (t.ranged && dist < t.attackRange * 0.55f) dir = Normalized(-toVictim);
else if (dist > reach * 0.9f) dir = Normalized(toVictim);
Vec2 move = dir * t.speed;
move += CrowdPush(t, ti, targets) * t.speed;
t.pos += move * dt;
map.ResolveCircle(t.pos, t.radius);
}
}
int CountAggroEnemies(const std::vector<Target>& targets)
{
int n = 0;
for (const Target& t : targets)
if (t.alive && !t.IsDummy() && t.victim >= 0) ++n;
return n;
}