squad-proto/src/sim/enemy.cpp
z.kirill e3294cffd4 Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.

И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.

ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.

ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.

Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.

Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.

Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.

Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:04:35 +03:00

379 lines
19 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/element.h"
#include "sim/status.h"
#include "sim/trigger.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;
// Урон бойцу от твари — в одном месте на все пять видов атак: множитель
// сложности, событие попадания и падение. Разложенное по веткам, это правило
// первым же дополнением разъехалось бы на пять слегка разных версий.
// attacker — индекс твари, которая ударила, или -1. Нужен ON_HURT: обжечь надо
// того, кто попал, а не «ближайшего».
//
// squadRage — таймер ярости отряда, общий на всех (sim/trigger.h). Указателем, а
// не возвратом: ударов за шаг бывает несколько, и каждый обязан уметь его
// поднять.
bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events,
std::vector<Target>* targets = nullptr, int attacker = -1,
float* squadRage = nullptr)
{
// --- ПАРИРОВАНИЕ БЛИЖНЕГО БОЯ -------------------------------------------
// Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец должен сам быть в
// замахе или в ударе. Парирование «просто так» превратило бы клинок в щит,
// который работает, пока стоишь.
//
// Удавшееся парирование: урона нет, тварь получает удар бойца, и боец бьёт
// ЗАНОВО, не сбрасывая откат начальной атаки, — из чего и получается
// размен «отбил и сразу ответил» (docs/11-melee.md).
if (targets && attacker >= 0 && attacker < int(targets->size()) && a.IsMelee() &&
a.melee.Committed() && a.melee.parry > 0.0f && a.trig.Chance(a.melee.parry))
{
Target& foe = (*targets)[size_t(attacker)];
if (foe.alive && !foe.IsProp())
{
foe.hp -= ResolveDamage(a.melee.damage, a.melee.elem, foe.resist,
ResistCutOf(foe.status) + a.pen) *
VulnerabilityOf(foe.status);
foe.hurtTimer = g_tune.targetHurtTime;
// Повторный удар: фаза начинается сначала, откат не трогается.
a.melee.state = MeleeState::STRIKE;
a.melee.phase = a.melee.strike;
a.melee.hitMask = 0; // тот же взмах бьёт заново, иначе цель уже «задета»
if (events) events->Push(CombatEventKind::MELEE_HIT, index, a.pos, dir);
return false; // урона бойцу нет
}
}
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
// --- ON_HURT: обжечь того, кто ударил -----------------------------------
// Триггер СНАЧАЛА, событие потом: журнал — канал эффектов, и правила боя
// от него не зависят (sim/trigger.h).
const float onHurt = a.mods.trigger[int(TriggerKind::ON_HURT)];
if (targets && attacker >= 0 && attacker < int(targets->size()) &&
TriggerReady(a.trig, TriggerKind::ON_HURT, onHurt) && a.trig.Chance(onHurt))
{
Target& t = (*targets)[size_t(attacker)];
if (t.alive && !t.IsProp())
{
const StatusRule rule = StatusRuleOf(Element::INC);
t.status.stacks[int(Element::INC)] =
std::min(rule.maxStacks, t.status.stacks[int(Element::INC)] + 1);
t.status.timer[int(Element::INC)] = rule.duration;
TriggerFired(a.trig, TriggerKind::ON_HURT);
}
}
// --- ON_DOWN: ярость отряда за павшего -----------------------------------
if (down && squadRage)
{
const float onDown = a.mods.trigger[int(TriggerKind::ON_DOWN)];
if (TriggerReady(a.trig, TriggerKind::ON_DOWN, onDown))
{
// Шанс тут не бросается: оперативник гибнет не каждую секунду, и
// «сработало на 8%» означало бы, что эффект не видели ни разу.
*squadRage = SQUAD_RAGE_TIME;
TriggerFired(a.trig, TriggerKind::ON_DOWN);
}
}
if (!events) return down;
events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir);
if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir);
return down;
}
} // namespace
bool HurtAgentForTest(Agent& a, int index, Target& attacker, int attackerIndex,
std::vector<Target>& targets, float damage)
{
(void)attacker;
return HurtAgent(a, index, damage, FromAngle(a.facing), nullptr, &targets, attackerIndex,
nullptr);
}
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events,
float aggroMul, float* squadRage)
{
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
// хвата — продлевать стало некому, и через 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, &targets, ti, squadRage);
}
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, &targets, ti, squadRage);
}
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 * aggroMul, &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, &targets, ti, squadRage);
}
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, &targets, ti, squadRage);
}
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;
}