Статусы стихий и реакции (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>
This commit is contained in:
z.kirill 2026-08-15 05:45:17 +03:00
parent 3e1a921eb6
commit c1831ac370
14 changed files with 552 additions and 6 deletions

View File

@ -162,6 +162,7 @@ squad_proto.exe --rooms --view topdown # свой прое
```bash
squad_proto.exe --tool ttk # время убийства и разброс по стихиям
squad_proto.exe --tool enemies # здоровье и сопротивления тварей
squad_proto.exe --tool reactions # реакции и три ограничителя
squad_proto.exe --tool economy # доход опыта против цены дерева
```

View File

@ -73,6 +73,8 @@ src/
events.h кольцевой журнал боевых событий: единственный канал к эффектам
items.h каталог предметов и инвентарь бойца (docs/13-combat.md)
element.h стихии, сопротивления, единственная формула урона
status.h статусы стихий, реакции, три ограничителя
reactions.h/.cpp наложение стихий и запуск реакций
enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар
spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры
ai/

View File

@ -32,6 +32,8 @@
| **M21** | стихии: семь элементов, сопротивления видом, единственная формула урона; вторая поверхность инструментов `squad_proto --tool` (weapons, enemies, ttk, economy) | `--tool ttk` сперва показал, что стихии меняют урон на 436%, то есть механика декоративна; после правки содержимого все пять видов в коридоре 1.93.1x; `--accept` PASS без изменений — стихии есть только у находимого оружия ([21-damage.md](21-damage.md)) |
| **M22** | статусы стихий и четыре реакции (SHATTER, IGNITE, SUPERCOND, DECAY) с силой от ГЛУБИНЫ, а не от урона оружия; три ограничителя — потолок стаков, откат на цели, запрет рекурсии | `--tool reactions`: все четыре срабатывают, площадные задевают соседа вполовину, стаки гасятся, вторая пара внутри отката даёт 0.0, урон от реакции не кладёт статусов; шаг симуляции 2.9 мкс из 16666; `--accept` PASS ([21-damage.md](21-damage.md)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -247,6 +247,52 @@ BRUTE 46.5 62.4 44.2 18.0 14.4 12.1 3.06x
Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое, а в
таблице выше — целевое.
## 6.2. Статусы и реакции в коде (M22)
| Что | Где |
|---|---|
| правила статусов, потолки, реакции, числа | [src/sim/status.h](../src/sim/status.h) |
| наложение и запуск реакции | [src/sim/reactions.cpp](../src/sim/reactions.cpp) |
| тик горения и сгорание стаков | `UpdateStatuses`, зовётся из `UpdateTargets` |
| проверка | `squad_proto --tool reactions` |
Реализованы четыре реакции из десяти: SHATTER, IGNITE, SUPERCOND, DECAY.
Остальные ждут систем, которых пока нет (крит, воскрешение, притяжение), и
дописывать их «пока не работает» значило бы завести шесть мёртвых веток.
Два отступления от документа, оба сознательные:
- **кислота и дуга увеличивают получаемый урон**, а не снимают броню и не
вешают метку крита: у тварей в этой игре брони как числа нет, а крита нет
вовсе. Эффект тот же, лишней сущности не заводится;
- **SUPERCOND кладёт стаки пустоты** вместо отдельного «среза сопротивлений»:
пустота в этой игре и означает «защита не работает», и второе имя для того же
было бы просто вторым именем.
Горение считается от МАКСИМАЛЬНОГО здоровья цели, а не от урона удара. Иначе на
громиле оно не значит ничего, а мелочь дожигает само.
### Три ограничителя проверяются, а не подразумеваются
```
REACTION TARGET NEIGHBOUR STACKS
SHATTER 13.0 0.0 0
IGNITE 9.4 4.7 0
SUPERCOND 3.1 0.0 0
DECAY 6.2 3.1 0
откат: вторая пара внутри 1.2 c нанесла 0.0
рекурсия: урон от реакции наложил стаков 0
сила реакции: D1 4.1, D50 10.0, D100 16.0
```
Столбец STACKS — сколько стихий осталось после реакции. Ноль обязателен: не
погасив пару, следующий удар сложил бы её снова, и реакция стала бы постоянным
фоном вместо события.
Шаг симуляции после вехи — 2.9 мкс при бюджете кадра 16666: статусы на
шестидесяти четырёх целях ничего не стоят.
## 7. Свет как ресурс
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не

View File

@ -25,7 +25,10 @@
#include <string>
#include "engine/progression.h"
#include <vector>
#include "sim/element.h"
#include "sim/reactions.h"
#include "sim/items.h"
#include "sim/profile.h"
#include "sim/target.h"
@ -179,6 +182,107 @@ int CmdTtk()
return bad > 0 ? 1 : 0;
}
// -----------------------------------------------------------------------------
// Реакции. Проверяется не «срабатывает ли», а три ограничителя, без которых
// система триггеров вешает игру (docs/21-damage.md §4.1).
// -----------------------------------------------------------------------------
int CmdReactions()
{
std::vector<Target> targets;
auto spawn = [&](Vec2 at) {
Target t;
t.kind = EnemyKind::SHAMBLER;
const EnemyDef d = EnemyDefOf(t.kind);
t.hp = t.maxHp = 100000.0f; // мешок, чтобы не умер посреди опыта
t.radius = d.radius;
t.resist = d.resist;
t.pos = at;
t.alive = true;
targets.push_back(t);
};
// Цель и сосед в радиусе площадных реакций.
spawn({0.0f, 0.0f});
spawn({1.5f, 0.0f});
const int depth = 10;
int bad = 0;
std::printf("%-12s %10s %10s %8s\n", "REACTION", "TARGET", "NEIGHBOUR", "STACKS");
struct Case { const char* name; Element a, b; };
const Case cases[] = {
{"SHATTER", Element::INC, Element::CRY},
{"IGNITE", Element::INC, Element::TOX},
{"SUPERCOND", Element::CRY, Element::ARC},
{"DECAY", Element::INC, Element::RAD},
};
for (const Case& c : cases)
{
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
const float before = targets[0].hp;
const float beforeNb = targets[1].hp;
ApplyElementalHit(targets, 0, ElementMix::Of(c.a, 0.8f), depth, nullptr);
ApplyElementalHit(targets, 0, ElementMix::Of(c.b, 0.8f), depth, nullptr);
const float dealt = before - targets[0].hp;
const float dealtNb = beforeNb - targets[1].hp;
// После реакции обе стихии обязаны погаснуть: иначе следующий же удар
// сложит ту же пару снова, и реакция станет постоянным фоном.
const int left = targets[0].status.stacks[int(c.a)] + targets[0].status.stacks[int(c.b)];
std::printf("%-12s %10.1f %10.1f %8d%s\n", c.name, double(dealt), double(dealtNb), left,
(dealt <= 0.0f || left != 0) ? " <-- не сработала" : "");
if (dealt <= 0.0f || left != 0) ++bad;
}
// --- откат -----------------------------------------------------------------
// Вторая пара сразу после первой не должна дать ничего: иначе очередь из
// сорока пуль даёт сорок взрывов за секунду.
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr);
ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr);
const float afterFirst = targets[0].hp;
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr);
ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr);
const float duringIcd = afterFirst - targets[0].hp;
std::printf("\nоткат: вторая пара внутри %.1f c нанесла %.1f\n", double(REACTION_ICD),
double(duringIcd));
if (duringIcd > 0.0f)
{
std::printf(" [FAIL] откат не работает: очередь даст очередь взрывов\n");
++bad;
}
// --- запрет рекурсии -------------------------------------------------------
// Урон ОТ реакции не кладёт статусов, значит новую пару сложить не может.
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr, true);
int stacksFromReaction = 0;
for (int e = 0; e < ELEMENT_COUNT; ++e) stacksFromReaction += targets[0].status.stacks[e];
std::printf("рекурсия: урон от реакции наложил стаков %d\n", stacksFromReaction);
if (stacksFromReaction != 0)
{
std::printf(" [FAIL] реакция кладёт статусы — цепочка уйдёт в самоповтор\n");
++bad;
}
// --- сила от глубины -------------------------------------------------------
std::printf("\nсила реакции: D1 %.1f, D50 %.1f, D100 %.1f (растёт от ГЛУБИНЫ,\n"
"а не от урона оружия — иначе на сотом этаже это округление)\n",
double(ReactionPower(1)), double(ReactionPower(50)), double(ReactionPower(100)));
if (bad > 0) std::printf("\nпроблем: %d\n", bad);
return bad > 0 ? 1 : 0;
}
int CmdEconomy(int argc, char** argv)
{
const int maxDepth = (argc > 0) ? std::max(10, std::atoi(argv[0])) : 100;
@ -220,6 +324,7 @@ int RunGameTool(int argc, char** argv)
if (std::strcmp(cmd, "weapons") == 0) return CmdWeapons();
if (std::strcmp(cmd, "enemies") == 0) return CmdEnemies();
if (std::strcmp(cmd, "ttk") == 0) return CmdTtk();
if (std::strcmp(cmd, "reactions") == 0) return CmdReactions();
if (std::strcmp(cmd, "economy") == 0) return CmdEconomy(argc - 1, argv + 1);
std::printf("нет команды `%s`\n\n", cmd);

View File

@ -1,5 +1,7 @@
#include "game.h"
#include "sim/reactions.h"
// Перевод экранных осей ввода в мировые — единственное место,
// где симуляционный слой касается изометрии (см. docs/02-architecture.md).
#include "engine/view2d.h"
@ -609,7 +611,8 @@ void Game::Step(float dt)
UpdateMelee(ctx, dt);
// --- 9. пули ---------------------------------------------------------------
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events);
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events,
depth);
// --- 10. находки -----------------------------------------------------------
// После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге,
@ -980,9 +983,12 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
// Через ту же формулу, что и пуля: клинок отличается
// формой удара, а не правилами урона.
tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist);
tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist,
ResistCutOf(tg.status)) *
VulnerabilityOf(tg.status);
tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock);
ApplyElementalHit(targets, t, a.melee.elem, depth, &events);
// Щепки вместо крови и мимо счётчика ударов по врагу:
// разрубленный ящик не должен красить пол и накручивать
// MELEE HITS в метриках.

View File

@ -1,5 +1,7 @@
#include "sim/bullet.h"
#include "sim/reactions.h"
#include "debug/metrics.h"
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
@ -57,7 +59,7 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events)
CombatEventLog* events, int depth)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
@ -148,10 +150,16 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
{
Target& t = targets[hitTarget];
const bool prop = t.IsProp();
// Единственное место, где стихии превращаются в число (sim/element.h).
t.hp -= ResolveDamage(b.damage, b.elem, t.resist);
// Единственное место, где стихии превращаются в число
// (sim/element.h). Пустота на цели срезает её сопротивления, а
// кислота и дуга увеличивают получаемый урон.
const float cut = ResistCutOf(t.status);
t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut) * VulnerabilityOf(t.status);
t.hurtTimer = g_tune.targetHurtTime;
KnockTarget(t, dir, b.knockback);
// Стихии удара ложатся статусами и могут сложиться в реакцию.
ApplyElementalHit(targets, hitTarget, b.elem, depth, events);
if (events)
events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET,
b.owner, impact, dir);

View File

@ -65,4 +65,4 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events);
CombatEventLog* events, int depth);

View File

@ -57,6 +57,7 @@ enum class CombatEventKind : uint8_t
// --- спуск ------------------------------------------------------------------
LOOT_TAKEN, // боец подобрал предмет. owner = БОЕЦ, pos = где лежало
ITEM_USED, // боец потратил расходник: бинт, стим, пачку патронов
REACTION, // на цели сложились две стихии. owner = -1: реакция ничья
DESCEND, // отряд дошёл до спуска. pos = точка спуска
EXTRACT, // отряд дошёл до эвакуации. pos = лестница наверх
};

144
src/sim/reactions.cpp Normal file
View File

@ -0,0 +1,144 @@
#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;
}
}

26
src/sim/reactions.h Normal file
View File

@ -0,0 +1,26 @@
#pragma once
// -----------------------------------------------------------------------------
// Наложение стихий и запуск реакций — единственное место, где это происходит.
//
// Отдельно от status.h намеренно: там правила и числа, здесь работа со списком
// целей. Правила читают все, а трогать чужие цели имеет право только эта пара
// функций.
// -----------------------------------------------------------------------------
#include <vector>
#include "sim/events.h"
#include "sim/status.h"
#include "sim/target.h"
// Наложить стихии удара на цель и, если сложилась пара, запустить реакцию.
//
// fromReaction — урон пришёл ОТ реакции. Такой урон статусов не кладёт и новых
// реакций не запускает: это и есть запрет рекурсии, без которого цепочка
// «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция»
// уходит в самоповтор на первом же скоплении.
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
CombatEventLog* events, bool fromReaction = false);
// Тик статусов: горение и кислота, сгорание стаков, откат реакций.
// Зовётся из UpdateTargets, один раз за шаг.
void UpdateStatuses(std::vector<Target>& targets, float dt);

194
src/sim/status.h Normal file
View File

@ -0,0 +1,194 @@
#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;
}

View File

@ -1,5 +1,7 @@
#include "sim/target.h"
#include "sim/reactions.h"
namespace
{
@ -302,6 +304,10 @@ void KnockTarget(Target& t, Vec2 dir, float speed)
void UpdateTargets(std::vector<Target>& targets, float dt)
{
// Статусы тикают первыми: горение обязано успеть добить цель до того, как
// её посчитают живой в этом шаге.
UpdateStatuses(targets, dt);
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
const float damp = std::exp(-g_tune.targetKnockDamp * dt);

View File

@ -1,5 +1,6 @@
#pragma once
#include "sim/element.h"
#include "sim/status.h"
// -----------------------------------------------------------------------------
// Противник. Одна сущность на две роли, и это сделано намеренно.
//
@ -136,6 +137,10 @@ struct Target
// мутации однажды будут править сопротивления ЭКЗЕМПЛЯРА, а не вида.
Resists resist;
// Наложенные стихии. Живут на экземпляре: это состояние боя, а не описание
// вида (sim/status.h).
StatusState status;
// Во сколько раз ослабляется импульс с учётом общего множителя из tuning.
float KnockScale() const
{