squad-proto/src/sim/status.h

195 lines
9.6 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
#pragma once
// -----------------------------------------------------------------------------
// Статусы стихий и реакции между ними.
//
// Стихии сами по себе (sim/element.h) — это только множитель урона: «верный
// ствол бьёт втрое больнее». Этого мало, чтобы стихии стали ЗНАНИЕМ, ради
// которого они заводились: множитель работает одинаково у того, кто понимает
// систему, и у того, кто просто взял ствол посильнее.
//
// Статусы и реакции добавляют второй слой, который работает только у первого:
// наложить ДВЕ разные стихии на одну цель и получить реакцию — это решение о
// составе отряда, а не о характеристиках оружия.
//
// ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ОБЯЗАТЕЛЬНЫЕ (docs/21-damage.md §4.1):
//
// 1. потолок стаков — один стак не значит ничего, полный набор меняет бой;
// 2. внутренний откат реакции на цель — иначе очередь из сорока пуль даёт
// сорок взрывов за секунду;
// 3. запрет рекурсии — урон ОТ реакции не накладывает статусов и не может
// запустить следующую реакцию.
//
// Третий пункт не теоретический. Без него цепочка «реакция бьёт по площади →
// урон кладёт стихию → складывается новая пара → реакция» уходит в самоповтор
// на первом же скоплении, и кадр умирает. Именно так ломаются все игры с
// триггерами, и чинить это потом дороже, чем запретить сразу.
// -----------------------------------------------------------------------------
#include <algorithm>
#include <cstdint>
#include "sim/element.h"
// Сколько стаков держит статус и сколько живёт один стак.
struct StatusRule
{
int maxStacks;
float duration;
};
// Потолки подобраны под правило «полный набор меняет бой»: горение в пять
// стаков заметно, в один — нет.
inline StatusRule StatusRuleOf(Element e)
{
switch (e)
{
case Element::INC: return {5, 4.0f};
case Element::CRY: return {10, 3.0f};
case Element::TOX: return {8, 5.0f};
case Element::ARC: return {4, 3.0f};
case Element::RAD: return {6, 6.0f};
case Element::VOID: return {3, 4.0f};
default: return {0, 0.0f}; // KIN статуса не кладёт
}
}
// Состояние статусов на одной цели. Плоские массивы, без аллокаций: их столько
// же, сколько целей, и они трогаются каждый шаг.
struct StatusState
{
int stacks[ELEMENT_COUNT] = {};
float timer[ELEMENT_COUNT] = {}; // до сгорания ОДНОГО стака
// Откат реакции на этой цели. Один на все пары: две разные реакции подряд
// на одной твари за долю секунды — это тот же взрыв, только с двух сторон.
float reactionIcd = 0.0f;
// Отложенный урон от статусов за шаг: тики горения и кислоты. Копится
// здесь, а применяется там же, где обычный урон, — чтобы смерть от тика
// проходила через тот же путь, что смерть от пули.
float pendingDamage = 0.0f;
bool Has(Element e) const { return stacks[int(e)] > 0; }
void Clear()
{
for (int i = 0; i < ELEMENT_COUNT; ++i) { stacks[i] = 0; timer[i] = 0.0f; }
reactionIcd = 0.0f;
pendingDamage = 0.0f;
}
};
// -----------------------------------------------------------------------------
// Реакции. Пара разных стихий на одной цели.
// -----------------------------------------------------------------------------
enum class Reaction : uint8_t
{
NONE = 0,
SHATTER, // INC + CRY: всплеск урона на месте
IGNITE, // INC + TOX: облако, бьёт по площади
SUPERCOND, // ARC + CRY: срезает сопротивления
DECAY, // RAD + INC: заражает соседей
COUNT,
};
inline const char* ReactionName(Reaction r)
{
switch (r)
{
case Reaction::SHATTER: return "SHATTER";
case Reaction::IGNITE: return "IGNITE";
case Reaction::SUPERCOND: return "SUPERCOND";
case Reaction::DECAY: return "DECAY";
default: return "-";
}
}
// Какая реакция получается из пары. Порядок наложения не важен: «сначала холод,
// потом жар» и наоборот — это одно и то же событие.
inline Reaction ReactionOf(Element a, Element b)
{
const Element lo = (a < b) ? a : b;
const Element hi = (a < b) ? b : a;
if (lo == Element::INC && hi == Element::CRY) return Reaction::SHATTER;
if (lo == Element::INC && hi == Element::TOX) return Reaction::IGNITE;
if (lo == Element::CRY && hi == Element::ARC) return Reaction::SUPERCOND;
if (lo == Element::INC && hi == Element::RAD) return Reaction::DECAY;
return Reaction::NONE;
}
// Сила реакции считается ОТ ГЛУБИНЫ, а не от урона оружия.
//
// Это ключевое решение всей системы (docs/21-damage.md §3.1). Считай реакция от
// оружия — на сотом этаже она стала бы округлением, и весь слой стихий
// превратился бы в украшение. Считая от глубины, реакция растёт сама — но
// только у того, кто умеет её запускать.
inline float ReactionPower(int depth)
{
return 4.0f + 0.12f * float(std::max(1, depth));
}
// Множитель конкретной реакции к базовой силе.
inline float ReactionScale(Reaction r)
{
switch (r)
{
case Reaction::SHATTER: return 2.50f; // точечный всплеск
case Reaction::IGNITE: return 1.80f; // по площади, потому слабее
case Reaction::SUPERCOND: return 0.60f; // берёт не уроном, а срезом
case Reaction::DECAY: return 1.20f;
default: return 0.0f;
}
}
constexpr float REACTION_ICD = 1.2f; // откат реакций на одной цели
constexpr float REACTION_RADIUS = 2.6f; // площадь у тех, что бьют по площади
// Бьёт ли реакция по соседям.
inline bool ReactionIsArea(Reaction r)
{
return r == Reaction::IGNITE || r == Reaction::DECAY;
}
// -----------------------------------------------------------------------------
// Эффекты статусов, которые читают другие системы.
// -----------------------------------------------------------------------------
// Урон за секунду от горения: доля максимального здоровья цели за стак.
// От максимума, а не от урона удара: иначе горение на громиле не значило бы
// ничего, а на мелочи убивало бы её само.
constexpr float BURN_PER_STACK = 0.012f;
// Кислота: цель получает больше урона. В игре у тварей нет брони как числа,
// поэтому «снятие брони» из документа выражается прибавкой к получаемому
// урону — эффект тот же, а лишней сущности не заводится.
constexpr float CORRODE_PER_STACK = 0.035f;
// Дуга: то же самое, но слабее и короче — она берёт цепью, а не числом.
constexpr float CHARGE_PER_STACK = 0.020f;
// Холод: замедление за стак.
constexpr float CHILL_PER_STACK = 0.040f;
// Пустота: срез сопротивлений за стак.
constexpr float COLLAPSE_PER_STACK = 0.080f;
// Во сколько раз цель получает больше урона из-за статусов.
inline float VulnerabilityOf(const StatusState& st)
{
return 1.0f + float(st.stacks[int(Element::TOX)]) * CORRODE_PER_STACK +
float(st.stacks[int(Element::ARC)]) * CHARGE_PER_STACK;
}
// Насколько цель замедлена.
inline float SlowOf(const StatusState& st)
{
const float slow = float(st.stacks[int(Element::CRY)]) * CHILL_PER_STACK;
return std::max(0.35f, 1.0f - slow); // ниже трети не тормозим: это не паралич
}
// Срез сопротивлений от пустоты.
inline float ResistCutOf(const StatusState& st)
{
return float(st.stacks[int(Element::VOID)]) * COLLAPSE_PER_STACK;
}