squad-proto/src/sim/reactions.cpp
z.kirill c1831ac370 Статусы стихий и реакции (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 05:45:17 +03:00

145 lines
5.5 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/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;
}
}