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