squad-proto/src/sim/reactions.cpp

145 lines
5.5 KiB
C++
Raw Normal View History

Статусы стихий и реакции (M22) Стихии сами по себе были только множителем урона: «верный ствол бьёт втрое больнее». Этого мало, чтобы они стали ЗНАНИЕМ, ради которого заводились — множитель одинаково работает у того, кто понимает систему, и у того, кто взял ствол посильнее. Статусы и реакции добавляют слой, который работает только у первого: наложить ДВЕ разные стихии на одну цель — это решение о составе отряда, а не о характеристиках оружия. sim/status.h правила, потолки стаков, реакции, числа sim/reactions.* наложение и запуск, единственное место UpdateStatuses тик горения и сгорание стаков, из UpdateTargets СИЛА РЕАКЦИИ СЧИТАЕТСЯ ОТ ГЛУБИНЫ, А НЕ ОТ УРОНА ОРУЖИЯ. Считай она от оружия — на сотом этаже стала бы округлением, и весь слой стихий превратился бы в украшение. От глубины она растёт сама, но только у того, кто умеет её запускать. ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ПРОВЕРЯЮТСЯ потолок стаков один стак не значит ничего, полный набор меняет бой откат на цели вторая пара внутри 1.2 c даёт 0.0 урона запрет рекурсии урон ОТ реакции не кладёт статусов Третий не теоретический: без него цепочка «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция» уходит в самоповтор на первом же скоплении. Именно так ломаются все игры с триггерами. Четыре реакции из десяти. Остальные ждут систем, которых нет (крит, воскрешение, притяжение), и дописывать их «пока не работает» значило бы завести шесть мёртвых веток. Два отступления от документа, оба сознательные: кислота и дуга увеличивают получаемый урон вместо снятия брони и метки крита (у тварей нет ни брони как числа, ни крита), SUPERCOND кладёт стаки пустоты вместо отдельного среза сопротивлений — пустота в этой игре и означает «защита не работает». Шаг симуляции 2.9 мкс из бюджета 16666: статусы на 64 целях ничего не стоят. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:45:17 +07:00
#include "sim/reactions.h"
#include <algorithm>
#include "tuning.h"
namespace
{
// Урон реакции по одной цели. Идёт мимо сопротивлений: реакция — это не удар
// стихией, а её распад, и «сопротивление растрескиванию» пришлось бы заводить
// восьмым столбцом в каталоге ради одного случая.
void ReactionDamage(Target& t, float amount)
{
if (!t.alive || t.IsProp()) return;
t.hp -= amount * VulnerabilityOf(t.status);
t.hurtTimer = g_tune.targetHurtTime;
}
} // namespace
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
CombatEventLog* events, bool fromReaction)
{
if (index < 0 || index >= int(targets.size())) return;
Target& t = targets[size_t(index)];
if (!t.alive || t.IsProp()) return;
// Запрет рекурсии: урон от реакции не кладёт статусов, а значит и новую
// пару сложить не может.
if (fromReaction) return;
// 1. Что именно легло этим ударом. Кинетика статуса не кладёт.
Element applied[ELEMENT_COUNT];
int appliedCount = 0;
for (int e = 1; e < ELEMENT_COUNT; ++e)
{
if (mix.share[e] <= 0.0f) continue;
const Element el = Element(e);
const StatusRule rule = StatusRuleOf(el);
if (rule.maxStacks <= 0) continue;
StatusState& st = t.status;
const bool wasEmpty = st.stacks[e] == 0;
st.stacks[e] = std::min(rule.maxStacks, st.stacks[e] + 1);
st.timer[e] = rule.duration; // свежий стак продлевает весь набор
if (wasEmpty || true) applied[appliedCount++] = el;
}
if (appliedCount == 0) return;
// 2. Сложилась ли пара. Ищем среди УЖЕ висящих стихий ту, что даёт реакцию
// с только что наложенной.
if (t.status.reactionIcd > 0.0f) return;
for (int i = 0; i < appliedCount; ++i)
{
const Element fresh = applied[i];
for (int e = 1; e < ELEMENT_COUNT; ++e)
{
const Element other = Element(e);
if (other == fresh || !t.status.Has(other)) continue;
const Reaction r = ReactionOf(fresh, other);
if (r == Reaction::NONE) continue;
// 3. Реакция состоялась: гасим обе стихии и ставим откат.
t.status.stacks[int(fresh)] = 0;
t.status.stacks[int(other)] = 0;
t.status.reactionIcd = REACTION_ICD;
const float power = ReactionPower(depth) * ReactionScale(r);
if (r == Reaction::SUPERCOND)
{
// Срезает сопротивления вместо урона: кладёт стаки пустоты,
// потому что именно она в этой игре означает «защита не
// работает». Отдельной сущности для среза не заводим.
const StatusRule vd = StatusRuleOf(Element::VOID);
t.status.stacks[int(Element::VOID)] = vd.maxStacks;
t.status.timer[int(Element::VOID)] = vd.duration;
}
ReactionDamage(t, power);
if (ReactionIsArea(r))
{
for (size_t k = 0; k < targets.size(); ++k)
{
if (int(k) == index) continue;
Target& other2 = targets[k];
if (!other2.alive || other2.IsProp()) continue;
if (DistanceSq(other2.pos, t.pos) > REACTION_RADIUS * REACTION_RADIUS) continue;
// Соседям достаётся половина и НИКАКИХ статусов: это урон
// от реакции, он рекурсию не запускает.
ReactionDamage(other2, power * 0.5f);
}
}
if (events)
events->Push(CombatEventKind::REACTION, -1, t.pos, Vec2{});
return; // одна реакция за удар: две подряд — это тот же взрыв дважды
}
}
}
void UpdateStatuses(std::vector<Target>& targets, float dt)
{
for (Target& t : targets)
{
StatusState& st = t.status;
if (!t.alive || t.IsProp())
{
if (st.stacks[0] != 0 || st.reactionIcd > 0.0f) st.Clear();
continue;
}
st.reactionIcd = std::max(0.0f, st.reactionIcd - dt);
// Стаки сгорают по одному: набор тает постепенно, а не исчезает разом.
for (int e = 1; e < ELEMENT_COUNT; ++e)
{
if (st.stacks[e] <= 0) continue;
st.timer[e] -= dt;
if (st.timer[e] <= 0.0f)
{
--st.stacks[e];
st.timer[e] = StatusRuleOf(Element(e)).duration;
}
}
// Горение считается от МАКСИМАЛЬНОГО здоровья цели: иначе на громиле
// оно не значило бы ничего, а мелочь дожигало бы само.
const int burn = st.stacks[int(Element::INC)];
if (burn > 0) t.hp -= t.maxHp * BURN_PER_STACK * float(burn) * dt;
}
}