Триггеры: четыре условных эффекта на аффиксах (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:
parent
a8e19cd956
commit
28a9e5b6e4
@ -80,6 +80,10 @@
|
||||
крио — с мёрзлых, и это единственная причина возвращаться на средние глубины
|
||||
после рекорда. Протечка полос убивает весь контент, кроме последнего акта
|
||||
([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)).
|
||||
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
|
||||
|
||||
@ -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)) |
|
||||
|
||||
| **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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -127,21 +127,46 @@ reaction = k · APow(d) · (1 + reactionBonus)
|
||||
|
||||
## 4. Триггеры: on-hit, on-kill, on-death
|
||||
|
||||
Журнал боевых событий **уже существует** — `sim/events.h`, кольцевой буфер, по
|
||||
которому сейчас работают эффекты и свет. Триггеры вешаются на него, а не рядом:
|
||||
второй канал событий разошёлся бы с первым в первый же месяц.
|
||||
Сделано в M28, но **не так, как предлагал этот раздел**.
|
||||
|
||||
| Триггер | Когда | Пример эффекта |
|
||||
|---|---|---|
|
||||
| `on_attack` | начало выстрела/замаха | «каждый 5-й выстрел бесплатный» |
|
||||
| `on_hit` | попадание | «10% наложить CRY» |
|
||||
| `on_crit` | критическое попадание | «крит снимает 1 стак перезарядки» |
|
||||
| `on_kill` | цель умерла от тебя | «+8% скорости на 3 с, стак до 5» |
|
||||
| `on_death` | ЦЕЛЬ умерла (любая) | «труп взрывается RAD» |
|
||||
| `on_hurt` | по тебе попали | «20%: наложить INC атакующему» |
|
||||
| `on_down` | твой оперативник пал | «отряд получает +25% урона на 10 с» |
|
||||
| `on_floor` | вход на этаж | «+1 бинт, если сумка не полна» |
|
||||
| `on_extract` | эвакуация | «+15% материалов» |
|
||||
Здесь стояло: журнал боевых событий уже есть (`sim/events.h`), вешайте триггеры
|
||||
на него, второй канал разойдётся с первым. Звучит правильно, и сделать так
|
||||
нельзя.
|
||||
|
||||
`sim/events.h` — канал «симуляция → ЭФФЕКТЫ», односторонний по устройству:
|
||||
симуляция в него только пишет, а в headless-прогонах указателя на журнал нет
|
||||
вовсе. Начни правила боя его **читать** — и игра станет вести себя по-разному с
|
||||
окном и без окна, то есть `--accept` начнёт мерить не то, что меряет сейчас.
|
||||
|
||||
Поэтому триггеры срабатывают **там же, где случается событие**: в месте
|
||||
нанесения урона, в месте смерти цели, в месте попадания по бойцу. Журнал остался
|
||||
тем, чем был. Опасение из первой редакции («два канала разойдутся») снято иначе:
|
||||
второго канала событий никто и не завёл — есть четыре точки в коде, и все они
|
||||
рядом с тем уроном, который триггер и обслуживает.
|
||||
|
||||
Сделаны четыре, и это не «пока четыре»: каждый обязан класть свой эффект в уже
|
||||
существующий канал влияния, и остальные пять таких каналов не имеют.
|
||||
|
||||
| Триггер | Когда | Что делает | Откат |
|
||||
|---|---|---|---|
|
||||
| `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. Против взрыва комбинаций
|
||||
|
||||
@ -157,6 +182,21 @@ reaction = k · APow(d) · (1 + reactionBonus)
|
||||
Без третьего пункта одна цепь ARC на скоплении из сорока тварей вешает игру —
|
||||
и это не гипотеза, а то, как ломаются все игры с триггерами.
|
||||
|
||||
Все три проверяются в `--descend`, и проверяются ЧИСЛАМИ:
|
||||
|
||||
```
|
||||
триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с (за него наложено 0),
|
||||
ON_KILL стаков 1, скорость 1.06
|
||||
рекурсия: за один шаг стаков прибавлялось максимум 1
|
||||
```
|
||||
|
||||
Первая строка — про проводку: аффикс с триггером легко сделать так, что он лежит
|
||||
в предмете, показывается в подсказке и не делает ничего. Вторая — про рекурсию:
|
||||
поджиг от триггера сам урона не наносит и второй раз триггер не запускает,
|
||||
поэтому больше одного стака за шаг появиться не может.
|
||||
|
||||
Шаг симуляции после вехи — 3.7 мкс при бюджете кадра 16666.
|
||||
|
||||
## 5. Твари: сопротивления и роли
|
||||
|
||||
Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
#include "game.h"
|
||||
#include "sim/craft.h"
|
||||
#include "sim/trigger.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);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»:
|
||||
// каждая обязана менять предмет ровно так, как обещает подпись под ней, и
|
||||
|
||||
33
src/game.cpp
33
src/game.cpp
@ -1,6 +1,7 @@
|
||||
#include "game.h"
|
||||
|
||||
#include "sim/craft.h"
|
||||
#include "sim/trigger.h"
|
||||
#include "sim/reactions.h"
|
||||
|
||||
// Перевод экранных осей ввода в мировые — единственное место,
|
||||
@ -536,6 +537,14 @@ void Game::Step(float dt)
|
||||
buffs.hp = squad.hpBonus;
|
||||
buffs.carry = squad.carryBonus;
|
||||
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)
|
||||
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,
|
||||
buffs.lightOut ? DARK_AGGRO_MUL : 1.0f);
|
||||
buffs.lightOut ? DARK_AGGRO_MUL : 1.0f, &squad.rageTimer);
|
||||
|
||||
// --- 1. якорь и состояние отряда -----------------------------------------
|
||||
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
|
||||
@ -1075,6 +1084,28 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
|
||||
tg.hurtTimer = g_tune.targetHurtTime;
|
||||
KnockTarget(tg, dir, m.curKnock);
|
||||
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 в метриках.
|
||||
|
||||
@ -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,
|
||||
AffixTag::AMMO, AffixTag::SPEED};
|
||||
AffixTag::AMMO, AffixTag::SPEED, AffixTag::TRIGGER};
|
||||
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
|
||||
AffixTag::CARRY, AffixTag::AMMO};
|
||||
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
|
||||
AffixTag::SPEED, AffixTag::CARRY};
|
||||
AffixTag::SPEED, AffixTag::CARRY, AffixTag::TRIGGER};
|
||||
|
||||
} // namespace
|
||||
|
||||
@ -49,6 +49,7 @@ const char* AffixName(AffixTag t)
|
||||
case AffixTag::AMMO: return "ROUNDS";
|
||||
case AffixTag::CARRY: return "CARRY";
|
||||
case AffixTag::LIGHT: return "LIGHT";
|
||||
case AffixTag::TRIGGER: return "TRIGGER";
|
||||
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),
|
||||
int(a.value * 100.0f + 0.5f), int(a.tier));
|
||||
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:
|
||||
std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d",
|
||||
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::CARRY: carry += 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:
|
||||
{
|
||||
// Конверсии складываются между собой, а кинетика — это остаток.
|
||||
@ -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::TRIGGER) a.param = uint8_t(RandRange(rng, 1, TRIGGER_COUNT - 1));
|
||||
|
||||
it.affix[it.count++] = a;
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
|
||||
#include "sim/element.h"
|
||||
#include "sim/items.h"
|
||||
#include "sim/trigger.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::CARRY: return {0.30f, 0.55f};
|
||||
case AffixTag::LIGHT: return {18.0f, 30.0f};
|
||||
// Триггер — это ШАНС сработать. Дорогой аффикс с маленькими числами:
|
||||
// «изредка поджигает» меняет бой сильнее, чем «+7% урона».
|
||||
case AffixTag::TRIGGER: return {0.06f, 0.10f};
|
||||
default: return {0.0f, 0.0f};
|
||||
}
|
||||
}
|
||||
@ -134,6 +138,11 @@ struct AffixMods
|
||||
float carry = 0.0f;
|
||||
float light = 0.0f;
|
||||
|
||||
// Сила по видам триггеров. Ноль — такого триггера на бойце нет
|
||||
// (sim/trigger.h). Массивом, а не флагами: два предмета с одним видом
|
||||
// складываются, и это законно — сильнее, но не чаще, откат общий.
|
||||
float trigger[TRIGGER_COUNT] = {};
|
||||
|
||||
void Add(const Affix& a);
|
||||
void Add(const ItemInstance& it);
|
||||
};
|
||||
|
||||
@ -233,7 +233,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
|
||||
// скорости: движению всё равно, почему боец медленный. Разделять их
|
||||
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
|
||||
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);
|
||||
// Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и
|
||||
// купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел
|
||||
@ -282,8 +282,12 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
|
||||
// Урон множится, пробитие складывается, конверсия ЗАМЕНЯЕТ смесь стихий,
|
||||
// а не добавляется к ней: у оружия смесь одна, и «дробовик с жаром плюс
|
||||
// 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;
|
||||
|
||||
if (a.mods.convert.share[int(Element::KIN)] < 0.999f)
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
#include "sim/items.h"
|
||||
#include "sim/loadout.h"
|
||||
#include "sim/target.h"
|
||||
#include "sim/trigger.h"
|
||||
#include "tuning.h"
|
||||
#include "engine/tilemap.h"
|
||||
|
||||
@ -108,6 +109,11 @@ struct Agent
|
||||
AffixMods mods;
|
||||
float pen = 0.0f;
|
||||
|
||||
// Откаты триггеров и набранные стаки рывка (sim/trigger.h). Живут на бойце,
|
||||
// потому что триггер — его свойство: два бойца с одним и тем же оружием
|
||||
// считают откаты каждый свой.
|
||||
TriggerState trig;
|
||||
|
||||
// --- служебное (рендер/отладка/метрики), на правила не влияет ---
|
||||
Vec2 prevPos{}; // для интерполяции кадра
|
||||
bool hasLane = false; // есть валидная линия огня прямо сейчас
|
||||
@ -184,6 +190,10 @@ struct SquadBuffs
|
||||
float sight = 0.0f; // прибавка к множителю обзора
|
||||
float hp = 0.0f; // прибавка к запасу здоровья
|
||||
float carry = 0.0f; // прибавка к норме переноски
|
||||
// Ярость отряда за павшего (ON_DOWN). Отрядная, а не личная: эффект
|
||||
// называется «отряд получает +25% урона», и считать его на каждом бойце
|
||||
// отдельно значило бы, что павшего оплакивает только сосед.
|
||||
float rage = 0.0f;
|
||||
// Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане
|
||||
// перестаёт что-либо давать. По умолчанию false — песочница приёмки светит
|
||||
// всегда (sim/squad.h).
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#include "sim/bullet.h"
|
||||
|
||||
#include "sim/trigger.h"
|
||||
|
||||
#include "sim/reactions.h"
|
||||
|
||||
#include "debug/metrics.h"
|
||||
@ -162,6 +164,25 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
KnockTarget(t, dir, b.knockback);
|
||||
// Стихии удара ложатся статусами и могут сложиться в реакцию.
|
||||
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)
|
||||
events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET,
|
||||
b.owner, impact, dir);
|
||||
@ -171,6 +192,20 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
t.respawnTimer = t.RespawnDelay();
|
||||
// Разбитый ящик — не убитый враг: ни в KILLS, ни в напряжение.
|
||||
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)
|
||||
events->Push(prop ? CombatEventKind::PROP_BREAK : CombatEventKind::HIT_KILL,
|
||||
b.owner, t.pos, dir);
|
||||
|
||||
@ -171,6 +171,7 @@ CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth,
|
||||
a.tier = uint8_t(tier);
|
||||
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::TRIGGER) a.param = uint8_t(RandRange(rng, 1, TRIGGER_COUNT - 1));
|
||||
it.affix[it.count++] = a;
|
||||
break;
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
#include "sim/enemy.h"
|
||||
|
||||
#include "sim/status.h"
|
||||
#include "sim/trigger.h"
|
||||
|
||||
#include "sim/bullet.h"
|
||||
|
||||
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);
|
||||
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);
|
||||
if (down) events->Push(CombatEventKind::AGENT_DOWN, index, a.pos, dir);
|
||||
return down;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@ -139,7 +183,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
|
||||
t.radius + agents[i].bodyRadius)
|
||||
continue;
|
||||
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)
|
||||
@ -209,7 +253,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
|
||||
if (!agents[i].alive) continue;
|
||||
const Vec2 d = agents[i].pos - t.pos;
|
||||
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;
|
||||
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;
|
||||
if (Length(d) > t.attackRange + agents[i].bodyRadius) 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;
|
||||
break;
|
||||
|
||||
@ -36,9 +36,11 @@
|
||||
// единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h).
|
||||
// Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они
|
||||
// так же и ходят так же, просто находят раньше.
|
||||
// squadRage — таймер ярости отряда (sim/trigger.h). Указателем и с nullptr по
|
||||
// умолчанию: песочница приёмки триггеров не знает, и там его нет.
|
||||
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
||||
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
||||
float aggroMul = 1.0f);
|
||||
float aggroMul = 1.0f, float* squadRage = nullptr);
|
||||
|
||||
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
|
||||
int CountAggroEnemies(const std::vector<Target>& targets);
|
||||
|
||||
@ -128,6 +128,7 @@ enum class AffixTag : uint8_t
|
||||
AMMO, // +патроны на вылазку
|
||||
CARRY, // +норма переноски
|
||||
LIGHT, // +заряд фонаря
|
||||
TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h
|
||||
COUNT,
|
||||
};
|
||||
|
||||
|
||||
@ -43,7 +43,8 @@ const StarterKit STARTER[NUM_AGENTS] = {
|
||||
// бы разбор. Читаемость при этом сохранена — профиль правят руками при отладке,
|
||||
// и «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),
|
||||
"у каждого тега аффикса обязан быть свой токен в профиле");
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ void Squad::Init(ecs::World& world, Vec2 at)
|
||||
world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE});
|
||||
|
||||
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
|
||||
a.trig.rng = 1u + uint32_t(i) * 2654435761u; // свой поток на слот
|
||||
a.pos = SlotWorld(i);
|
||||
a.prevPos = a.pos;
|
||||
a.postPos = a.pos;
|
||||
|
||||
@ -61,6 +61,9 @@ struct Squad
|
||||
// то, на сколько минут хватило фонаря.
|
||||
float lightCharge = -1.0f;
|
||||
|
||||
// Ярость отряда за павшего: таймер общий на всех (sim/trigger.h).
|
||||
float rageTimer = 0.0f;
|
||||
|
||||
// Фонарь сел. Отрицательный заряд — это песочница, там темноты нет.
|
||||
bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; }
|
||||
|
||||
|
||||
155
src/sim/trigger.h
Normal file
155
src/sim/trigger.h
Normal 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); }
|
||||
Loading…
Reference in New Issue
Block a user