diff --git a/CLAUDE.md b/CLAUDE.md index ced2bed..c85908e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)). - **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции diff --git a/docs/07-milestones.md b/docs/07-milestones.md index ae352a5..2ef91f1 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.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)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/21-damage.md b/docs/21-damage.md index d08e1ec..d3342ca 100644 --- a/docs/21-damage.md +++ b/docs/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. Твари: сопротивления и роли Сопротивление — свойство ВИДА, из каталога проекта. Роль читается по профилю diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 2a5a2a9..78376cb 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -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(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); + } + } + // ------------------------------------------------------------------------- // Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»: // каждая обязана менять предмет ровно так, как обещает подпись под ней, и diff --git a/src/game.cpp b/src/game.cpp index 2acd4e0..ff67598 100644 --- a/src/game.cpp +++ b/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 в метриках. diff --git a/src/sim/affix.cpp b/src/sim/affix.cpp index 9276583..cfa0976 100644 --- a/src/sim/affix.cpp +++ b/src/sim/affix.cpp @@ -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; } diff --git a/src/sim/affix.h b/src/sim/affix.h index 74aa626..6acfc7e 100644 --- a/src/sim/affix.h +++ b/src/sim/affix.h @@ -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); }; diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index a8c967e..0a201e3 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -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) diff --git a/src/sim/agent.h b/src/sim/agent.h index 71d2b2b..7268d65 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -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). diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 05063bd..ed4aa6c 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -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); diff --git a/src/sim/craft.cpp b/src/sim/craft.cpp index 5daaf61..2dce73b 100644 --- a/src/sim/craft.cpp +++ b/src/sim/craft.cpp @@ -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; } diff --git a/src/sim/enemy.cpp b/src/sim/enemy.cpp index e745473..5d7b5ca 100644 --- a/src/sim/enemy.cpp +++ b/src/sim/enemy.cpp @@ -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* 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& 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; diff --git a/src/sim/enemy.h b/src/sim/enemy.h index f243368..39a41a5 100644 --- a/src/sim/enemy.h +++ b/src/sim/enemy.h @@ -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& targets, float dt, CombatEventLog* events, - float aggroMul = 1.0f); + float aggroMul = 1.0f, float* squadRage = nullptr); // Сколько тварей сейчас идёт к отряду. Для напряжения и HUD. int CountAggroEnemies(const std::vector& targets); diff --git a/src/sim/items.h b/src/sim/items.h index 517e3f6..8099501 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -128,6 +128,7 @@ enum class AffixTag : uint8_t AMMO, // +патроны на вылазку CARRY, // +норма переноски LIGHT, // +заряд фонаря + TRIGGER, // условный эффект (param = TriggerKind), sim/trigger.h COUNT, }; diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index 27103da..01e12a0 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -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), "у каждого тега аффикса обязан быть свой токен в профиле"); diff --git a/src/sim/squad.cpp b/src/sim/squad.cpp index c6c2136..5b7d800 100644 --- a/src/sim/squad.cpp +++ b/src/sim/squad.cpp @@ -87,6 +87,7 @@ void Squad::Init(ecs::World& world, Vec2 at) world.Add(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; diff --git a/src/sim/squad.h b/src/sim/squad.h index e79757a..5bd14d1 100644 --- a/src/sim/squad.h +++ b/src/sim/squad.h @@ -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; } diff --git a/src/sim/trigger.h b/src/sim/trigger.h new file mode 100644 index 0000000..f00bd72 --- /dev/null +++ b/src/sim/trigger.h @@ -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 + +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); }