squad-proto/src/sim/status.h
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

195 lines
9.6 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.

#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;
}