Стихии сами по себе были только множителем урона: «верный ствол бьёт втрое больнее». Этого мало, чтобы они стали ЗНАНИЕМ, ради которого заводились — множитель одинаково работает у того, кто понимает систему, и у того, кто взял ствол посильнее. Статусы и реакции добавляют слой, который работает только у первого: наложить ДВЕ разные стихии на одну цель — это решение о составе отряда, а не о характеристиках оружия. 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>
145 lines
5.5 KiB
C++
145 lines
5.5 KiB
C++
#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;
|
||
}
|
||
}
|