Триггеры: четыре условных эффекта на аффиксах (M28)

ON_HIT поджигает цель, ON_KILL даёт рывок скорости стаками, ON_HURT
обжигает того, кто ударил, ON_DOWN даёт отряду ярость за павшего.
Отдельным тегом аффикса, то есть по тем же правилам, что и всё
остальное: платит по тиру, влезает в бюджет, катается и прививается.

СДЕЛАНО НЕ ТАК, КАК ПРЕДЛАГАЛ ДИЗАЙН. В docs/21-damage.md §4 стояло:
журнал боевых событий уже есть, вешайте триггеры на него. Звучит
правильно, и сделать так нельзя: sim/events.h это канал «симуляция ->
эффекты», односторонний по устройству, и в headless-прогонах его нет
вовсе. Начни правила боя его читать — игра станет вести себя по-разному
с окном и без, то есть --accept начнёт мерить не то, что меряет.

Триггеры срабатывают в месте урона. Опасение «два канала разойдутся»
снято иначе: второго канала никто и не завёл, есть четыре точки рядом с
тем уроном, который триггер обслуживает.

Четыре из девяти, и это не «пока»: каждый эффект обязан класть своё
число в уже существующий канал влияния. Крита в игре нет, площадного
урона нет, выдачи предметов на входе нет — заводить их ради строк
таблицы значит завести пять мёртвых веток. Причины выписаны в документе.

Три ограничителя проверяются числами в --descend:

  триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с
            (за него наложено 0), ON_KILL стаков 1, скорость 1.06
  рекурсия: за один шаг стаков прибавлялось максимум 1

Первая строка про проводку: аффикс с триггером легко сделать так, что он
лежит в предмете, показывается в подсказке и не делает ничего.

Проверка сперва брала targets[0] и проходила на descent по везению: на
hollow первым в пуле стоял ящик, а обстановка не горит и убийством не
считается. Теперь цель ищется по виду и ставится в свободную клетку с
прямой видимостью — иначе замеряется планировка этажа, а не триггеры.

Шаг симуляции 3.7 мкс из 16666. --accept PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 03:32:12 +03:00
parent a8e19cd956
commit 28a9e5b6e4
18 changed files with 560 additions and 29 deletions

View File

@ -80,6 +80,10 @@
крио — с мёрзлых, и это единственная причина возвращаться на средние глубины крио — с мёрзлых, и это единственная причина возвращаться на средние глубины
после рекорда. Протечка полос убивает весь контент, кроме последнего акта после рекорда. Протечка полос убивает весь контент, кроме последнего акта
([22-loot.md](docs/22-loot.md)). ([22-loot.md](docs/22-loot.md)).
- **Журнал событий (`sim/events.h`) правила боя НЕ читают.** Это канал
«симуляция → эффекты», и в headless его нет вовсе: начни от него зависеть —
игра станет вести себя по-разному с окном и без. Триггеры срабатывают в месте
урона, а не по журналу ([21-damage.md](docs/21-damage.md)).
- **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а - **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а
обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)). обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции - **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции

View File

@ -44,6 +44,8 @@
| **M27** | материалы и мастерская: пять материалов со своими полосами добычи, пять операций над вещью, экран WORKSHOP; материалы — валюта, зачисляются только при эвакуации | `--tool craft` поймал, что материалы бесплатны: с твари падало по её danger, и вылазка на 80 этаже приносила тысячу ядер при цене доводки в шестьсот (0.6 вылазки на предмет вместо восьми) — правило заменено на «одно ядро с мелочи, два с крупной», стало 6.2; сам инструмент тоже считал неверно, беря доход из бюджета опасности, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей; залом замерен на тысяче попыток: 11.6% потерь при заявленных 12%, платят всегда, слот теряется всегда; `--accept` PASS ([22-loot.md](22-loot.md)) | | **M27** | материалы и мастерская: пять материалов со своими полосами добычи, пять операций над вещью, экран WORKSHOP; материалы — валюта, зачисляются только при эвакуации | `--tool craft` поймал, что материалы бесплатны: с твари падало по её danger, и вылазка на 80 этаже приносила тысячу ядер при цене доводки в шестьсот (0.6 вылазки на предмет вместо восьми) — правило заменено на «одно ядро с мелочи, два с крупной», стало 6.2; сам инструмент тоже считал неверно, беря доход из бюджета опасности, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей; залом замерен на тысяче попыток: 11.6% потерь при заявленных 12%, платят всегда, слот теряется всегда; `--accept` PASS ([22-loot.md](22-loot.md)) |
| **M28** | триггеры: четыре условных эффекта (ON_HIT поджигает, ON_KILL даёт рывок стаками, ON_HURT обжигает ударившего, ON_DOWN даёт ярость отряду) отдельным тегом аффикса; три ограничителя — откат по видам, бюджет предмета, запрет рекурсии | дизайн предлагал вешать триггеры на журнал событий — так делать нельзя: журнал это канал ЭФФЕКТОВ, в headless его нет вовсе, и правила боя стали бы зависеть от наличия окна; сделано срабатыванием в месте урона; проверка в `--descend`: аффикс доезжает до бойца, ON_HIT кладёт стак, внутри отката 0, ON_KILL даёт 1.06 скорости, за шаг не больше одного стака; проверка сперва брала `targets[0]` и падала на hollow, где первым в пуле стоял ящик; шаг симуляции 3.7 мкс; `--accept` PASS ([21-damage.md](21-damage.md)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -127,21 +127,46 @@ reaction = k · APow(d) · (1 + reactionBonus)
## 4. Триггеры: on-hit, on-kill, on-death ## 4. Триггеры: on-hit, on-kill, on-death
Журнал боевых событий **уже существует**`sim/events.h`, кольцевой буфер, по Сделано в M28, но **не так, как предлагал этот раздел**.
которому сейчас работают эффекты и свет. Триггеры вешаются на него, а не рядом:
второй канал событий разошёлся бы с первым в первый же месяц.
| Триггер | Когда | Пример эффекта | Здесь стояло: журнал боевых событий уже есть (`sim/events.h`), вешайте триггеры
|---|---|---| на него, второй канал разойдётся с первым. Звучит правильно, и сделать так
| `on_attack` | начало выстрела/замаха | «каждый 5-й выстрел бесплатный» | нельзя.
| `on_hit` | попадание | «10% наложить CRY» |
| `on_crit` | критическое попадание | «крит снимает 1 стак перезарядки» | `sim/events.h` — канал «симуляция → ЭФФЕКТЫ», односторонний по устройству:
| `on_kill` | цель умерла от тебя | «+8% скорости на 3 с, стак до 5» | симуляция в него только пишет, а в headless-прогонах указателя на журнал нет
| `on_death` | ЦЕЛЬ умерла (любая) | «труп взрывается RAD» | вовсе. Начни правила боя его **читать** — и игра станет вести себя по-разному с
| `on_hurt` | по тебе попали | «20%: наложить INC атакующему» | окном и без окна, то есть `--accept` начнёт мерить не то, что меряет сейчас.
| `on_down` | твой оперативник пал | «отряд получает +25% урона на 10 с» |
| `on_floor` | вход на этаж | «+1 бинт, если сумка не полна» | Поэтому триггеры срабатывают **там же, где случается событие**: в месте
| `on_extract` | эвакуация | «+15% материалов» | нанесения урона, в месте смерти цели, в месте попадания по бойцу. Журнал остался
тем, чем был. Опасение из первой редакции («два канала разойдутся») снято иначе:
второго канала событий никто и не завёл — есть четыре точки в коде, и все они
рядом с тем уроном, который триггер и обслуживает.
Сделаны четыре, и это не «пока четыре»: каждый обязан класть свой эффект в уже
существующий канал влияния, и остальные пять таких каналов не имеют.
| Триггер | Когда | Что делает | Откат |
|---|---|---|---|
| `on_hit` | попадание по твари | поджигает её (стак INC) | 0.9 с |
| `on_kill` | тварь умерла от тебя | +6% скорости, стак до 5, набор живёт 3 с | 0.5 с |
| `on_hurt` | по тебе попали | обжигает ТОГО, кто попал | 1.5 с |
| `on_down` | твой оперативник пал | +25% урона всему отряду на 10 с | 8 с |
Не сделаны и почему:
- `on_crit` — крита в игре нет;
- `on_death` (любая смерть) — некому платить: у трупа нет владельца, а «взрыв
трупа» это площадной урон, которого пока нет ни у чего;
- `on_attack`, `on_floor`, `on_extract` — эффекты вида «бесплатный выстрел» и
«+1 бинт» требуют своих каналов (боезапас в момент выстрела, выдача предметов
на входе), и заводить их ради одной строки таблицы значит завести пять мёртвых
веток. Так уже вышло с реакциями: шесть из десяти ждут систем, которых нет.
Ярость за павшего — **отрядная**, а не личная: эффект называется «отряд получает
+25% урона», и считать его на каждом бойце отдельно значило бы, что павшего
оплакивает только сосед.
### 4.1. Против взрыва комбинаций ### 4.1. Против взрыва комбинаций
@ -157,6 +182,21 @@ reaction = k · APow(d) · (1 + reactionBonus)
Без третьего пункта одна цепь ARC на скоплении из сорока тварей вешает игру — Без третьего пункта одна цепь ARC на скоплении из сорока тварей вешает игру —
и это не гипотеза, а то, как ломаются все игры с триггерами. и это не гипотеза, а то, как ломаются все игры с триггерами.
Все три проверяются в `--descend`, и проверяются ЧИСЛАМИ:
```
триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с (за него наложено 0),
ON_KILL стаков 1, скорость 1.06
рекурсия: за один шаг стаков прибавлялось максимум 1
```
Первая строка — про проводку: аффикс с триггером легко сделать так, что он лежит
в предмете, показывается в подсказке и не делает ничего. Вторая — про рекурсию:
поджиг от триггера сам урона не наносит и второй раз триггер не запускает,
поэтому больше одного стака за шаг появиться не может.
Шаг симуляции после вехи — 3.7 мкс при бюджете кадра 16666.
## 5. Твари: сопротивления и роли ## 5. Твари: сопротивления и роли
Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю

View File

@ -10,6 +10,7 @@
#include "game.h" #include "game.h"
#include "sim/craft.h" #include "sim/craft.h"
#include "sim/trigger.h"
#include "ui/menu.h" #include "ui/menu.h"
// Перевод экранных осей ввода в мировые — единственное место, // Перевод экранных осей ввода в мировые — единственное место,
@ -1385,6 +1386,178 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
} }
} }
// -------------------------------------------------------------------------
// Триггеры. Проверяется то же, что у реакций: не «срабатывает ли», а ТРИ
// ОГРАНИЧИТЕЛЯ, без которых система условных эффектов вешает игру
// (docs/21-damage.md §4.1).
//
// Отдельно — что триггер вообще доезжает до боя. Аффикс с триггером легко
// сделать так, что он лежит в предмете, показывается в подсказке и не
// делает ничего: ровно так дважды умирали вес и стихии.
// -------------------------------------------------------------------------
{
Agent& a = game.squad.agents[0];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv)
{
std::printf("\n [FAIL] у бойца нет инвентаря — триггеры не проверить\n");
++bad;
}
else
{
const Inventory savedInv = *inv;
const SquadBag savedBag = game.squad.bag;
// Оружие с гарантированным ON_HIT: шанс единица, чтобы мерить
// ПРОВОДКУ, а не везение.
ItemInstance rifle(ItemId::RIFLE);
rifle.rarity = Rarity::RELIC;
rifle.affix[rifle.count++] =
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_HIT), 1.0f};
rifle.affix[rifle.count++] =
Affix{AffixTag::TRIGGER, 5, uint8_t(TriggerKind::ON_KILL), 1.0f};
*inv = Inventory{};
inv->weapon = rifle;
EquipWeapon(game.world, a, inv->weapon);
game.Step(FIXED_DT);
const bool wired = a.mods.trigger[int(TriggerKind::ON_HIT)] > 0.0f &&
a.mods.trigger[int(TriggerKind::ON_KILL)] > 0.0f;
// 1. ON_HIT доезжает до цели: ставим тварь под ствол и смотрим,
// появились ли на ней стаки жара. Оружие кинетическое, поэтому
// жар взяться больше неоткуда.
ClearField(game);
// Цель ищется НЕ по нулевому индексу: первым в пуле может стоять
// ящик, а обстановка не горит и убийством не считается. Первая
// версия проверки брала targets[0] и проходила на descent просто
// потому, что там повезло, — на hollow она падала.
int victimIndex = -1;
for (int t = 0; t < int(game.targets.size()); ++t)
if (!game.targets[size_t(t)].IsProp() && !game.targets[size_t(t)].IsDummy())
{
victimIndex = t;
break;
}
if (victimIndex < 0)
{
std::printf("\n [FAIL] на этаже нет ни одной твари — триггеры не проверить\n");
++bad;
victimIndex = 0;
}
Target& victim = game.targets[size_t(victimIndex)];
victim.alive = true;
victim.respawnTimer = 0.0f;
victim.hp = 100000.0f; // чтобы не умерла раньше времени
victim.status.Clear();
// Ставим цель в СВОБОДНУЮ клетку рядом со стрелком и с прямой
// видимостью: иначе пуля уходит в стену, и проверка меряет
// планировку этажа, а не триггеры.
const Vec2 dirs[8] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1},
{1, 1}, {1, -1}, {-1, 1}, {-1, -1}};
victim.pos = a.pos + Vec2{1.5f, 0.0f};
for (const Vec2& d : dirs)
{
const Vec2 spot = a.pos + d * 2.0f;
if (game.map.IsWallAt(spot)) continue;
if (game.map.RaycastBlocked(a.pos, spot)) continue;
victim.pos = spot;
break;
}
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
for (int k = 0; k < 240 && victim.status.stacks[int(Element::INC)] == 0; ++k)
game.Step(FIXED_DT);
const int burned = victim.status.stacks[int(Element::INC)];
// 2. ОТКАТ. Сразу после срабатывания триггер обязан молчать: гасим
// стаки и проверяем, что за полсекунды они не вернулись.
victim.status.Clear();
const float icdLeft = a.trig.icd[int(TriggerKind::ON_HIT)];
for (int k = 0; k < 24; ++k) game.Step(FIXED_DT);
const int duringIcd = victim.status.stacks[int(Element::INC)];
// 3. ON_KILL: добиваем тварь и смотрим стаки рывка.
a.trig.killStacks = 0;
victim.hp = 1.0f;
for (int k = 0; k < 240 && a.trig.killStacks == 0; ++k) game.Step(FIXED_DT);
const int rush = a.trig.killStacks;
// Ещё один шаг ПЕРЕД замером скорости: стак прибавляется в момент
// смерти цели, а статы бойца пересчитываются в начале шага
// (SyncLoadout). Читать скорость сразу — значит читать её до того,
// как рывок вообще применился. Один кадр задержки в игре ни на что
// не влияет, а проверку сбивал.
game.Step(FIXED_DT);
const float speedWithRush = a.speedMul;
std::printf("\nтриггеры: аффикс доехал %s, ON_HIT поджёг стаков %d, откат %.1f с"
" (за него наложено %d), ON_KILL стаков %d, скорость %.2f\n",
wired ? "да" : "НЕТ", burned, double(icdLeft), duringIcd, rush,
double(speedWithRush));
if (!wired)
{
std::printf(" [FAIL] триггер с предмета не дошёл до бойца\n");
++bad;
}
if (burned <= 0)
{
std::printf(" [FAIL] ON_HIT не поджёг цель — эффект не доезжает до боя\n");
++bad;
}
if (duringIcd > 0)
{
std::printf(" [FAIL] ON_HIT сработал внутри отката: ограничителя нет\n");
++bad;
}
if (rush <= 0)
{
std::printf(" [FAIL] ON_KILL не дал рывка\n");
++bad;
}
if (rush > 0 && speedWithRush <= 1.0f)
{
std::printf(" [FAIL] стаки рывка есть, а скорость прежняя\n");
++bad;
}
// 4. ЗАПРЕТ РЕКУРСИИ. Поджиг от триггера сам урона не наносит и
// второй раз триггер не запускает, поэтому за один шаг стаков
// больше одного не появляется. Проверяем в лоб: гасим статусы,
// сбрасываем откат и делаем РОВНО ОДИН шаг с попаданием.
victim.status.Clear();
victim.hp = 100000.0f;
victim.alive = true;
a.trig.icd[int(TriggerKind::ON_HIT)] = 0.0f;
int perStepMax = 0;
for (int k = 0; k < 240; ++k)
{
const int before = victim.status.stacks[int(Element::INC)];
game.Step(FIXED_DT);
const int after = victim.status.stacks[int(Element::INC)];
perStepMax = std::max(perStepMax, after - before);
}
std::printf("рекурсия: за один шаг стаков прибавлялось максимум %d\n", perStepMax);
if (perStepMax > 1)
{
std::printf(" [FAIL] триггер запускает сам себя: %d стаков за шаг\n", perStepMax);
++bad;
}
*inv = savedInv;
EquipWeapon(game.world, a, inv->weapon);
game.squad.bag = savedBag;
game.squad.rageTimer = 0.0f;
a.trig = TriggerState{};
a.trig.rng = 1u;
game.Step(FIXED_DT);
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»: // Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и // каждая обязана менять предмет ровно так, как обещает подпись под ней, и

View File

@ -1,6 +1,7 @@
#include "game.h" #include "game.h"
#include "sim/craft.h" #include "sim/craft.h"
#include "sim/trigger.h"
#include "sim/reactions.h" #include "sim/reactions.h"
// Перевод экранных осей ввода в мировые — единственное место, // Перевод экранных осей ввода в мировые — единственное место,
@ -536,6 +537,14 @@ void Game::Step(float dt)
buffs.hp = squad.hpBonus; buffs.hp = squad.hpBonus;
buffs.carry = squad.carryBonus; buffs.carry = squad.carryBonus;
buffs.lightOut = squad.LightOut(); buffs.lightOut = squad.LightOut();
// Ярость живёт таймером и гаснет сама. Множитель урона от неё уезжает в
// SyncLoadout вместе с аффиксами: своего канала у триггера нет.
squad.rageTimer = std::max(0.0f, squad.rageTimer - dt);
buffs.rage = (squad.rageTimer > 0.0f) ? SQUAD_RAGE_DAMAGE : 0.0f;
// Откаты триггеров и стаки рывка тикают у КАЖДОГО бойца, включая павших:
// иначе набранное на прошлом этаже держалось бы вечно.
for (int i = 0; i < NUM_AGENTS; ++i) squad.agents[i].trig.Tick(dt);
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs); for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs);
for (int i = 0; i < NUM_AGENTS; ++i) for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt); squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt);
@ -550,7 +559,7 @@ void Game::Step(float dt)
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует // Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
// на то, что видит сейчас, а не на прошлый кадр. // на то, что видит сейчас, а не на прошлый кадр.
UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events, UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events,
buffs.lightOut ? DARK_AGGRO_MUL : 1.0f); buffs.lightOut ? DARK_AGGRO_MUL : 1.0f, &squad.rageTimer);
// --- 1. якорь и состояние отряда ----------------------------------------- // --- 1. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде. // Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
@ -1075,6 +1084,28 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
tg.hurtTimer = g_tune.targetHurtTime; tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock); KnockTarget(tg, dir, m.curKnock);
ApplyElementalHit(targets, t, a.melee.elem, depth, &events); ApplyElementalHit(targets, t, a.melee.elem, depth, &events);
// Триггеры клинка — те же, что у ствола, и в том же месте:
// сразу за уроном (sim/trigger.h).
if (!prop)
{
const float onHit = a.mods.trigger[int(TriggerKind::ON_HIT)];
if (tg.alive && TriggerReady(a.trig, TriggerKind::ON_HIT, onHit) &&
a.trig.Chance(onHit))
{
ApplyElementalHit(targets, t, ElementMix::Of(Element::INC, 1.0f),
depth, &events);
TriggerFired(a.trig, TriggerKind::ON_HIT);
}
const float onKill = a.mods.trigger[int(TriggerKind::ON_KILL)];
if (tg.hp <= 0.0f && TriggerReady(a.trig, TriggerKind::ON_KILL, onKill) &&
a.trig.Chance(onKill))
{
a.trig.AddKillStack();
TriggerFired(a.trig, TriggerKind::ON_KILL);
}
}
// Щепки вместо крови и мимо счётчика ударов по врагу: // Щепки вместо крови и мимо счётчика ударов по врагу:
// разрубленный ящик не должен красить пол и накручивать // разрубленный ящик не должен красить пол и накручивать
// MELEE HITS в метриках. // MELEE HITS в метриках.

View File

@ -27,11 +27,11 @@ int RandRange(uint32_t& s, int lo, int hi) // [lo, hi]
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на // каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
// таблицу, а не выводя из вида предмета. // таблицу, а не выводя из вида предмета.
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT, const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
AffixTag::AMMO, AffixTag::SPEED}; AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER};
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED, const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
AffixTag::CARRY, AffixTag::AMMO}; AffixTag::CARRY, AffixTag::AMMO};
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH, const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
AffixTag::SPEED, AffixTag::CARRY}; AffixTag::SPEED, AffixTag::CARRY, AffixTag::TRIGGER};
} // namespace } // namespace
@ -49,6 +49,7 @@ const char* AffixName(AffixTag t)
case AffixTag::AMMO: return "ROUNDS"; case AffixTag::AMMO: return "ROUNDS";
case AffixTag::CARRY: return "CARRY"; case AffixTag::CARRY: return "CARRY";
case AffixTag::LIGHT: return "LIGHT"; case AffixTag::LIGHT: return "LIGHT";
case AffixTag::TRIGGER: return "TRIGGER";
default: return "-"; default: return "-";
} }
} }
@ -71,6 +72,13 @@ void AffixLabel(const Affix& a, char* out, int cap)
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag), std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
int(a.value * 100.0f + 0.5f), int(a.tier)); int(a.value * 100.0f + 0.5f), int(a.tier));
break; break;
case AffixTag::TRIGGER:
// Триггер подписывается СОБЫТИЕМ и эффектом, а не числом: «6%»
// само по себе не говорит игроку ничего.
std::snprintf(out, size_t(cap), "%s %d%%: %s T%d", TriggerName(TriggerKind(a.param)),
int(a.value * 100.0f + 0.5f), TriggerEffectText(TriggerKind(a.param)),
int(a.tier));
break;
case AffixTag::CONVERT: case AffixTag::CONVERT:
std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d", std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d",
int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)), int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)),
@ -119,6 +127,12 @@ void AffixMods::Add(const Affix& a)
case AffixTag::AMMO: ammo += a.value; break; case AffixTag::AMMO: ammo += a.value; break;
case AffixTag::CARRY: carry += a.value; break; case AffixTag::CARRY: carry += a.value; break;
case AffixTag::LIGHT: light += a.value; break; case AffixTag::LIGHT: light += a.value; break;
case AffixTag::TRIGGER:
{
const int k = int(a.param);
if (k > 0 && k < TRIGGER_COUNT) trigger[k] += a.value;
break;
}
case AffixTag::CONVERT: case AffixTag::CONVERT:
{ {
// Конверсии складываются между собой, а кинетика — это остаток. // Конверсии складываются между собой, а кинетика — это остаток.
@ -267,6 +281,7 @@ ItemInstance RollItem(ItemId base, int depth, uint32_t& rng)
// Стихия конверсии — любая, кроме кинетики: «перевести урон в // Стихия конверсии — любая, кроме кинетики: «перевести урон в
// кинетику» не значит ничего. // кинетику» не значит ничего.
if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1)); if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1));
if (a.tag == AffixTag::TRIGGER) a.param = uint8_t(RandRange(rng, 1, TRIGGER_COUNT - 1));
it.affix[it.count++] = a; it.affix[it.count++] = a;
} }

View File

@ -31,6 +31,7 @@
#include "sim/element.h" #include "sim/element.h"
#include "sim/items.h" #include "sim/items.h"
#include "sim/trigger.h"
// Сами типы (Rarity, AffixTag, Affix, ItemInstance) объявлены в sim/items.h: // Сами типы (Rarity, AffixTag, Affix, ItemInstance) объявлены в sim/items.h:
// экземпляр предмета — это то, что боец несёт, и место ему там. Здесь — вся // экземпляр предмета — это то, что боец несёт, и место ему там. Здесь — вся
@ -103,6 +104,9 @@ inline AffixRange AffixBase(AffixTag t)
case AffixTag::AMMO: return {8.0f, 14.0f}; case AffixTag::AMMO: return {8.0f, 14.0f};
case AffixTag::CARRY: return {0.30f, 0.55f}; case AffixTag::CARRY: return {0.30f, 0.55f};
case AffixTag::LIGHT: return {18.0f, 30.0f}; case AffixTag::LIGHT: return {18.0f, 30.0f};
// Триггер — это ШАНС сработать. Дорогой аффикс с маленькими числами:
// «изредка поджигает» меняет бой сильнее, чем «+7% урона».
case AffixTag::TRIGGER: return {0.06f, 0.10f};
default: return {0.0f, 0.0f}; default: return {0.0f, 0.0f};
} }
} }
@ -134,6 +138,11 @@ struct AffixMods
float carry = 0.0f; float carry = 0.0f;
float light = 0.0f; float light = 0.0f;
// Сила по видам триггеров. Ноль — такого триггера на бойце нет
// (sim/trigger.h). Массивом, а не флагами: два предмета с одним видом
// складываются, и это законно — сильнее, но не чаще, откат общий.
float trigger[TRIGGER_COUNT] = {};
void Add(const Affix& a); void Add(const Affix& a);
void Add(const ItemInstance& it); void Add(const ItemInstance& it);
}; };

View File

@ -233,7 +233,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// скорости: движению всё равно, почему боец медленный. Разделять их // скорости: движению всё равно, почему боец медленный. Разделять их
// пришлось бы везде, где скорость читается, а смысла в этом ноль. // пришлось бы везде, где скорость читается, а смысла в этом ноль.
a.load = inv->Load() + bagShare; a.load = inv->Load() + bagShare;
a.speedMul = inv->SpeedMul() * a.mods.speedMul * a.speedMul = inv->SpeedMul() * a.mods.speedMul * a.trig.SpeedMul() *
CarryMul(a.load, buffs.carry + a.mods.carry); CarryMul(a.load, buffs.carry + a.mods.carry);
// Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и // Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и
// купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел // купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел
@ -282,8 +282,12 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// Урон множится, пробитие складывается, конверсия ЗАМЕНЯЕТ смесь стихий, // Урон множится, пробитие складывается, конверсия ЗАМЕНЯЕТ смесь стихий,
// а не добавляется к ней: у оружия смесь одна, и «дробовик с жаром плюс // а не добавляется к ней: у оружия смесь одна, и «дробовик с жаром плюс
// 20% холода» — это дробовик, у которого часть жара стала холодом. // 20% холода» — это дробовик, у которого часть жара стала холодом.
a.gun.def.damage *= a.mods.damageMul; // Ярость отряда множится СВЕРХ аффиксов и живёт ровно столько, сколько
a.melee.damage *= a.mods.damageMul; // держится её таймер: у триггера нет своего канала урона, он кладёт число
// в тот же множитель (sim/trigger.h).
const float dmg = a.mods.damageMul * (1.0f + buffs.rage);
a.gun.def.damage *= dmg;
a.melee.damage *= dmg;
a.pen = a.mods.pen; a.pen = a.mods.pen;
if (a.mods.convert.share[int(Element::KIN)] < 0.999f) if (a.mods.convert.share[int(Element::KIN)] < 0.999f)

View File

@ -17,6 +17,7 @@
#include "sim/items.h" #include "sim/items.h"
#include "sim/loadout.h" #include "sim/loadout.h"
#include "sim/target.h" #include "sim/target.h"
#include "sim/trigger.h"
#include "tuning.h" #include "tuning.h"
#include "engine/tilemap.h" #include "engine/tilemap.h"
@ -108,6 +109,11 @@ struct Agent
AffixMods mods; AffixMods mods;
float pen = 0.0f; float pen = 0.0f;
// Откаты триггеров и набранные стаки рывка (sim/trigger.h). Живут на бойце,
// потому что триггер — его свойство: два бойца с одним и тем же оружием
// считают откаты каждый свой.
TriggerState trig;
// --- служебное (рендер/отладка/метрики), на правила не влияет --- // --- служебное (рендер/отладка/метрики), на правила не влияет ---
Vec2 prevPos{}; // для интерполяции кадра Vec2 prevPos{}; // для интерполяции кадра
bool hasLane = false; // есть валидная линия огня прямо сейчас bool hasLane = false; // есть валидная линия огня прямо сейчас
@ -184,6 +190,10 @@ struct SquadBuffs
float sight = 0.0f; // прибавка к множителю обзора float sight = 0.0f; // прибавка к множителю обзора
float hp = 0.0f; // прибавка к запасу здоровья float hp = 0.0f; // прибавка к запасу здоровья
float carry = 0.0f; // прибавка к норме переноски float carry = 0.0f; // прибавка к норме переноски
// Ярость отряда за павшего (ON_DOWN). Отрядная, а не личная: эффект
// называется «отряд получает +25% урона», и считать его на каждом бойце
// отдельно значило бы, что павшего оплакивает только сосед.
float rage = 0.0f;
// Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане // Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане
// перестаёт что-либо давать. По умолчанию false — песочница приёмки светит // перестаёт что-либо давать. По умолчанию false — песочница приёмки светит
// всегда (sim/squad.h). // всегда (sim/squad.h).

View File

@ -1,5 +1,7 @@
#include "sim/bullet.h" #include "sim/bullet.h"
#include "sim/trigger.h"
#include "sim/reactions.h" #include "sim/reactions.h"
#include "debug/metrics.h" #include "debug/metrics.h"
@ -162,6 +164,25 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
KnockTarget(t, dir, b.knockback); KnockTarget(t, dir, b.knockback);
// Стихии удара ложатся статусами и могут сложиться в реакцию. // Стихии удара ложатся статусами и могут сложиться в реакцию.
ApplyElementalHit(targets, hitTarget, b.elem, depth, events); ApplyElementalHit(targets, hitTarget, b.elem, depth, events);
// --- триггеры стрелка (sim/trigger.h) ---------------------------
// Срабатывают ЗДЕСЬ, а не по журналу событий: журнал — канал для
// эффектов, и в headless-прогонах его нет вовсе.
if (!prop && b.owner >= 0 && b.owner < agentCount)
{
Agent& shooter = agents[b.owner];
const float onHit = shooter.mods.trigger[int(TriggerKind::ON_HIT)];
if (t.alive && TriggerReady(shooter.trig, TriggerKind::ON_HIT, onHit) &&
shooter.trig.Chance(onHit))
{
// Поджиг идёт по тому же пути, что и обычная стихия удара, —
// значит может сложить реакцию. Рекурсии тут нет: сам поджиг
// урона не наносит и второй раз триггер не запустит.
ApplyElementalHit(targets, hitTarget, ElementMix::Of(Element::INC, 1.0f),
depth, events);
TriggerFired(shooter.trig, TriggerKind::ON_HIT);
}
}
if (events) if (events)
events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET, events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET,
b.owner, impact, dir); b.owner, impact, dir);
@ -171,6 +192,20 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
t.respawnTimer = t.RespawnDelay(); t.respawnTimer = t.RespawnDelay();
// Разбитый ящик — не убитый враг: ни в KILLS, ни в напряжение. // Разбитый ящик — не убитый враг: ни в KILLS, ни в напряжение.
if (!prop) ++metrics.targetsKilled; if (!prop) ++metrics.targetsKilled;
// ON_KILL: рывок скорости стрелку. Стаки копятся, откат общий —
// очередь из десяти пуль в толпу даёт стак, а не десять.
if (!prop && b.owner >= 0 && b.owner < agentCount)
{
Agent& killer = agents[b.owner];
const float onKill = killer.mods.trigger[int(TriggerKind::ON_KILL)];
if (TriggerReady(killer.trig, TriggerKind::ON_KILL, onKill) &&
killer.trig.Chance(onKill))
{
killer.trig.AddKillStack();
TriggerFired(killer.trig, TriggerKind::ON_KILL);
}
}
if (events) if (events)
events->Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL, events->Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL,
b.owner, t.pos, dir); b.owner, t.pos, dir);

View File

@ -171,6 +171,7 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth,
a.tier = uint8_t(tier); a.tier = uint8_t(tier);
a.value = (base.lo + (base.hi - base.lo) * Frand(rng)) * scale; a.value = (base.lo + (base.hi - base.lo) * Frand(rng)) * scale;
if (tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1)); if (tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1));
if (tag == AffixTag::TRIGGER) a.param = uint8_t(RandRange(rng, 1, TRIGGER_COUNT - 1));
it.affix[it.count++] = a; it.affix[it.count++] = a;
break; break;
} }

View File

@ -1,5 +1,8 @@
#include "sim/enemy.h" #include "sim/enemy.h"
#include "sim/status.h"
#include "sim/trigger.h"
#include "sim/bullet.h" #include "sim/bullet.h"
namespace namespace
@ -53,19 +56,60 @@ constexpr float kSpitMuzzleGap = 0.12f;
// Урон бойцу от твари — в одном месте на все пять видов атак: множитель // Урон бойцу от твари — в одном месте на все пять видов атак: множитель
// сложности, событие попадания и падение. Разложенное по веткам, это правило // сложности, событие попадания и падение. Разложенное по веткам, это правило
// первым же дополнением разъехалось бы на пять слегка разных версий. // первым же дополнением разъехалось бы на пять слегка разных версий.
void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events) // attacker — индекс твари, которая ударила, или -1. Нужен ON_HURT: обжечь надо
// того, кто попал, а не «ближайшего».
//
// squadRage — таймер ярости отряда, общий на всех (sim/trigger.h). Указателем, а
// не возвратом: ударов за шаг бывает несколько, и каждый обязан уметь его
// поднять.
bool HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* events,
std::vector<Target>* targets = nullptr, int attacker = -1,
float* squadRage = nullptr)
{ {
const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale); const bool down = DamageAgent(a, damage * g_tune.enemyDamageScale);
if (!events) return;
// --- ON_HURT: обжечь того, кто ударил -----------------------------------
// Триггер СНАЧАЛА, событие потом: журнал — канал эффектов, и правила боя
// от него не зависят (sim/trigger.h).
const float onHurt = a.mods.trigger[int(TriggerKind::ON_HURT)];
if (targets && attacker >= 0 && attacker < int(targets->size()) &&
TriggerReady(a.trig, TriggerKind::ON_HURT, onHurt) && a.trig.Chance(onHurt))
{
Target& t = (*targets)[size_t(attacker)];
if (t.alive && !t.IsProp())
{
const StatusRule rule = StatusRuleOf(Element::INC);
t.status.stacks[int(Element::INC)] =
std::min(rule.maxStacks, t.status.stacks[int(Element::INC)] + 1);
t.status.timer[int(Element::INC)] = rule.duration;
TriggerFired(a.trig, TriggerKind::ON_HURT);
}
}
// --- ON_DOWN: ярость отряда за павшего -----------------------------------
if (down && squadRage)
{
const float onDown = a.mods.trigger[int(TriggerKind::ON_DOWN)];
if (TriggerReady(a.trig, TriggerKind::ON_DOWN, onDown))
{
// Шанс тут не бросается: оперативник гибнет не каждую секунду, и
// «сработало на 8%» означало бы, что эффект не видели ни разу.
*squadRage = SQUAD_RAGE_TIME;
TriggerFired(a.trig, TriggerKind::ON_DOWN);
}
}
if (!events) return down;
events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir); events->Push(CombatEventKind::ENEMY_HIT, index, a.pos, dir);
if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir); if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir);
return down;
} }
} // namespace } // namespace
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events, std::vector<Target>& targets, float dt, CombatEventLog* events,
float aggroMul) float aggroMul, float* squadRage)
{ {
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение // Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили // бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
@ -111,7 +155,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
if (t.grabTickTimer <= 0.0f) if (t.grabTickTimer <= 0.0f)
{ {
t.grabTickTimer += std::max(0.05f, t.grabTick); t.grabTickTimer += std::max(0.05f, t.grabTick);
HurtAgent(held, t.grabbed, t.damage, FromAngle(t.facing), events); HurtAgent(held, t.grabbed, t.damage, FromAngle(t.facing), events, &targets, ti, squadRage);
} }
continue; continue;
} }
@ -139,7 +183,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
t.radius + agents[i].bodyRadius) t.radius + agents[i].bodyRadius)
continue; continue;
t.lungeMask |= (1u << i); t.lungeMask |= (1u << i);
HurtAgent(agents[i], i, t.damage, t.lungeDir, events); HurtAgent(agents[i], i, t.damage, t.lungeDir, events, &targets, ti, squadRage);
} }
if (t.lungeTimer <= 0.0f) if (t.lungeTimer <= 0.0f)
@ -209,7 +253,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
if (!agents[i].alive) continue; if (!agents[i].alive) continue;
const Vec2 d = agents[i].pos - t.pos; const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.slamRadius + agents[i].bodyRadius) continue; if (Length(d) > t.slamRadius + agents[i].bodyRadius) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events); HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events, &targets, ti, squadRage);
} }
t.attackTimer = t.interval; t.attackTimer = t.interval;
break; break;
@ -248,7 +292,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
const Vec2 d = agents[i].pos - t.pos; const Vec2 d = agents[i].pos - t.pos;
if (Length(d) > t.attackRange + agents[i].bodyRadius) continue; if (Length(d) > t.attackRange + agents[i].bodyRadius) continue;
if (std::fabs(AngleDiff(t.facing, AngleOf(d))) > half) continue; if (std::fabs(AngleDiff(t.facing, AngleOf(d))) > half) continue;
HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events); HurtAgent(agents[i], i, t.damage, Normalized(d, axis), events, &targets, ti, squadRage);
} }
t.attackTimer = t.interval; t.attackTimer = t.interval;
break; break;

View File

@ -36,9 +36,11 @@
// единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h). // единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h).
// Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они // Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они
// так же и ходят так же, просто находят раньше. // так же и ходят так же, просто находят раньше.
// squadRage — таймер ярости отряда (sim/trigger.h). Указателем и с nullptr по
// умолчанию: песочница приёмки триггеров не знает, и там его нет.
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, CombatEventLog* events, std::vector<Target>& targets, float dt, CombatEventLog* events,
float aggroMul = 1.0f); float aggroMul = 1.0f, float* squadRage = nullptr);
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD. // Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
int CountAggroEnemies(const std::vector<Target>& targets); int CountAggroEnemies(const std::vector<Target>& targets);

View File

@ -128,6 +128,7 @@ enum class AffixTag : uint8_t
AMMO, // +патроны на вылазку AMMO, // +патроны на вылазку
CARRY, // +норма переноски CARRY, // +норма переноски
LIGHT, // +заряд фонаря LIGHT, // +заряд фонаря
TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h
COUNT, COUNT,
}; };

View File

@ -43,7 +43,8 @@ const StarterKit STARTER[NUM_AGENTS] = {
// бы разбор. Читаемость при этом сохранена — профиль правят руками при отладке, // бы разбор. Читаемость при этом сохранена — профиль правят руками при отладке,
// и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет. // и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp", "spd", "sgt", "amo", "car", "lgt"}; const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp",
"spd", "sgt", "amo", "car", "lgt", "trg"};
static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT), static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT),
"у каждого тега аффикса обязан быть свой токен в профиле"); "у каждого тега аффикса обязан быть свой токен в профиле");

View File

@ -87,6 +87,7 @@ void Squad::Init(ecs::World& world, Vec2 at)
world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE}); world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE});
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f; a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
a.trig.rng = 1u + uint32_t(i) * 2654435761u; // свой поток на слот
a.pos = SlotWorld(i); a.pos = SlotWorld(i);
a.prevPos = a.pos; a.prevPos = a.pos;
a.postPos = a.pos; a.postPos = a.pos;

View File

@ -61,6 +61,9 @@ struct Squad
// то, на сколько минут хватило фонаря. // то, на сколько минут хватило фонаря.
float lightCharge = -1.0f; float lightCharge = -1.0f;
// Ярость отряда за павшего: таймер общий на всех (sim/trigger.h).
float rageTimer = 0.0f;
// Фонарь сел. Отрицательный заряд — это песочница, там темноты нет. // Фонарь сел. Отрицательный заряд — это песочница, там темноты нет.
bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; } bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; }

155
src/sim/trigger.h Normal file
View File

@ -0,0 +1,155 @@
#pragma once
// -----------------------------------------------------------------------------
// Триггеры: on-hit, on-kill, on-hurt, on-down.
//
// ПОЧЕМУ НЕ НА ЖУРНАЛЕ СОБЫТИЙ, КАК ПРЕДЛАГАЛ ДИЗАЙН.
//
// В docs/21-damage.md §4 сказано: журнал боевых событий уже есть, вешать
// триггеры надо на него, второй канал разойдётся с первым. Звучит правильно —
// и сделать так нельзя.
//
// `sim/events.h` — канал «симуляция -> ЭФФЕКТЫ», и он односторонний по
// устройству: симуляция в него только пишет, а в headless-прогонах указатель на
// журнал вообще равен nullptr. Начни правила боя его ЧИТАТЬ — и игра станет
// вести себя по-разному с окном и без окна, то есть `--accept` начнёт мерить не
// то, что меряет сейчас. Это ровно тот инвариант, который защищается во всех
// вехах: приёмка считает в песочнице, и ломать её нельзя.
//
// Поэтому триггеры срабатывают ТАМ ЖЕ, где случается событие: в месте
// нанесения урона, в месте смерти цели, в месте попадания по бойцу. Журнал
// остаётся тем, чем был.
//
// ТРИ ОГРАНИЧИТЕЛЯ, ВСЕ ОБЯЗАТЕЛЬНЫЕ (docs/21-damage.md §4.1):
//
// 1. ОТКАТ у каждого вида триггера. Без него очередь из сорока пуль даёт
// сорок срабатываний за секунду.
// 2. БЮДЖЕТ предмета. Триггер — это аффикс, и он платит по тиру, как все
// (sim/affix.h). Шесть сильнейших в один предмет не влезают.
// 3. ЗАПРЕТ РЕКУРСИИ. Эффект, сработавший от триггера, НЕ может запустить
// триггер снова: пометка fromTrigger идёт по всей цепочке урона.
//
// Третий пункт не теоретический: без него одна цепь на скоплении из сорока
// тварей вешает кадр. Так ломаются все игры с триггерами.
// -----------------------------------------------------------------------------
#include <cstdint>
enum class TriggerKind : uint8_t
{
NONE = 0,
ON_HIT, // попал по твари: поджигает её
ON_KILL, // тварь умерла от тебя: рывок скорости, копится стаками
ON_HURT, // по тебе попали: обжигает того, кто попал
ON_DOWN, // твой оперативник пал: ярость на весь отряд
COUNT,
};
constexpr int TRIGGER_COUNT = int(TriggerKind::COUNT);
inline const char* TriggerName(TriggerKind k)
{
switch (k)
{
case TriggerKind::ON_HIT: return "ON HIT";
case TriggerKind::ON_KILL: return "ON KILL";
case TriggerKind::ON_HURT: return "ON HURT";
case TriggerKind::ON_DOWN: return "ON DOWN";
default: return "-";
}
}
inline const char* TriggerEffectText(TriggerKind k)
{
switch (k)
{
case TriggerKind::ON_HIT: return "IGNITE THE TARGET";
case TriggerKind::ON_KILL: return "SPEED, STACKS UP TO 5";
case TriggerKind::ON_HURT: return "BURN THE ATTACKER";
case TriggerKind::ON_DOWN: return "SQUAD RAGE FOR 10s";
default: return "-";
}
}
// Откат по видам. Чем сильнее эффект, тем длиннее: ярость всего отряда за
// павшего не может срабатывать чаще, чем отряд теряет людей.
inline float TriggerIcd(TriggerKind k)
{
switch (k)
{
case TriggerKind::ON_HIT: return 0.9f;
case TriggerKind::ON_KILL: return 0.5f;
case TriggerKind::ON_HURT: return 1.5f;
case TriggerKind::ON_DOWN: return 8.0f;
default: return 1.0f;
}
}
// --- эффекты, у которых есть длительность ------------------------------------
constexpr int KILL_RUSH_MAX = 5; // потолок стаков рывка
constexpr float KILL_RUSH_TIME = 3.0f; // сколько живёт набор
constexpr float KILL_RUSH_PER_STACK = 0.06f; // +6% скорости за стак
constexpr float SQUAD_RAGE_TIME = 10.0f; // ярость за павшего
constexpr float SQUAD_RAGE_DAMAGE = 0.25f;
// Состояние триггеров на бойце. Плоское, без аллокаций: у каждого бойца своё,
// и трогается оно каждый шаг.
struct TriggerState
{
float icd[TRIGGER_COUNT] = {};
// Свой генератор на бойца. Не глобальный и не от времени: симуляция
// детерминирована, и «тот же прогон» обязан давать те же срабатывания —
// иначе ни сравнить два забега, ни воспроизвести жалобу.
uint32_t rng = 1u;
// Рывок от убийств: стаки и общий таймер набора.
int killStacks = 0;
float killTimer = 0.0f;
void Tick(float dt)
{
for (int i = 0; i < TRIGGER_COUNT; ++i)
icd[i] = (icd[i] > dt) ? icd[i] - dt : 0.0f;
if (killStacks > 0)
{
killTimer -= dt;
if (killTimer <= 0.0f)
{
killStacks = 0;
killTimer = 0.0f;
}
}
}
// Множитель скорости от набранных стаков. Читается в SyncLoadout вместе с
// остальными множителями: у триггера нет своего канала влияния на бойца,
// он кладёт число туда же, куда снаряжение и аффиксы.
float SpeedMul() const { return 1.0f + float(killStacks) * KILL_RUSH_PER_STACK; }
// Бросок «сработало или нет». Продвигает свой поток, поэтому два бойца с
// одинаковым оружием не срабатывают синхронно.
bool Chance(float p)
{
rng = rng * 1664525u + 1013904223u;
const float r = float((rng >> 8) & 0xFFFFu) / 65536.0f;
return r < p;
}
void AddKillStack()
{
if (killStacks < KILL_RUSH_MAX) ++killStacks;
killTimer = KILL_RUSH_TIME; // свежее убийство продлевает весь набор
}
};
// Готов ли триггер сработать. Проверяет ТОЛЬКО откат и наличие эффекта: сила и
// вероятность — дело вызывающего, а рекурсия обрывается флагом fromTrigger в
// самом месте урона.
inline bool TriggerReady(const TriggerState& st, TriggerKind k, float power)
{
return power > 0.0f && st.icd[int(k)] <= 0.0f;
}
// Отметить срабатывание: ставит откат.
inline void TriggerFired(TriggerState& st, TriggerKind k) { st.icd[int(k)] = TriggerIcd(k); }