diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index 7f86145..bd9b427 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -162,6 +162,7 @@ squad_proto.exe --rooms --view topdown # свой прое ```bash squad_proto.exe --tool ttk # время убийства и разброс по стихиям squad_proto.exe --tool enemies # здоровье и сопротивления тварей +squad_proto.exe --tool reactions # реакции и три ограничителя squad_proto.exe --tool economy # доход опыта против цены дерева ``` diff --git a/docs/02-architecture.md b/docs/02-architecture.md index af59c9f..c62a812 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -73,6 +73,8 @@ src/ events.h кольцевой журнал боевых событий: единственный канал к эффектам items.h каталог предметов и инвентарь бойца (docs/13-combat.md) element.h стихии, сопротивления, единственная формула урона + status.h статусы стихий, реакции, три ограничителя + reactions.h/.cpp наложение стихий и запуск реакций enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры ai/ diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 3b570da..48e8485 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -32,6 +32,8 @@ | **M21** | стихии: семь элементов, сопротивления видом, единственная формула урона; вторая поверхность инструментов `squad_proto --tool` (weapons, enemies, ttk, economy) | `--tool ttk` сперва показал, что стихии меняют урон на 4–36%, то есть механика декоративна; после правки содержимого все пять видов в коридоре 1.9–3.1x; `--accept` PASS без изменений — стихии есть только у находимого оружия ([21-damage.md](21-damage.md)) | +| **M22** | статусы стихий и четыре реакции (SHATTER, IGNITE, SUPERCOND, DECAY) с силой от ГЛУБИНЫ, а не от урона оружия; три ограничителя — потолок стаков, откат на цели, запрет рекурсии | `--tool reactions`: все четыре срабатывают, площадные задевают соседа вполовину, стаки гасятся, вторая пара внутри отката даёт 0.0, урон от реакции не кладёт статусов; шаг симуляции 2.9 мкс из 16666; `--accept` PASS ([21-damage.md](21-damage.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/21-damage.md b/docs/21-damage.md index 104bcc4..8108b9d 100644 --- a/docs/21-damage.md +++ b/docs/21-damage.md @@ -247,6 +247,52 @@ BRUTE 46.5 62.4 44.2 18.0 14.4 12.1 3.06x Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое, а в таблице выше — целевое. +## 6.2. Статусы и реакции в коде (M22) + +| Что | Где | +|---|---| +| правила статусов, потолки, реакции, числа | [src/sim/status.h](../src/sim/status.h) | +| наложение и запуск реакции | [src/sim/reactions.cpp](../src/sim/reactions.cpp) | +| тик горения и сгорание стаков | `UpdateStatuses`, зовётся из `UpdateTargets` | +| проверка | `squad_proto --tool reactions` | + +Реализованы четыре реакции из десяти: SHATTER, IGNITE, SUPERCOND, DECAY. +Остальные ждут систем, которых пока нет (крит, воскрешение, притяжение), и +дописывать их «пока не работает» значило бы завести шесть мёртвых веток. + +Два отступления от документа, оба сознательные: + +- **кислота и дуга увеличивают получаемый урон**, а не снимают броню и не + вешают метку крита: у тварей в этой игре брони как числа нет, а крита нет + вовсе. Эффект тот же, лишней сущности не заводится; +- **SUPERCOND кладёт стаки пустоты** вместо отдельного «среза сопротивлений»: + пустота в этой игре и означает «защита не работает», и второе имя для того же + было бы просто вторым именем. + +Горение считается от МАКСИМАЛЬНОГО здоровья цели, а не от урона удара. Иначе на +громиле оно не значит ничего, а мелочь дожигает само. + +### Три ограничителя проверяются, а не подразумеваются + +``` +REACTION TARGET NEIGHBOUR STACKS +SHATTER 13.0 0.0 0 +IGNITE 9.4 4.7 0 +SUPERCOND 3.1 0.0 0 +DECAY 6.2 3.1 0 + +откат: вторая пара внутри 1.2 c нанесла 0.0 +рекурсия: урон от реакции наложил стаков 0 +сила реакции: D1 4.1, D50 10.0, D100 16.0 +``` + +Столбец STACKS — сколько стихий осталось после реакции. Ноль обязателен: не +погасив пару, следующий удар сложил бы её снова, и реакция стала бы постоянным +фоном вместо события. + +Шаг симуляции после вехи — 2.9 мкс при бюджете кадра 16666: статусы на +шестидесяти четырёх целях ничего не стоят. + ## 7. Свет как ресурс Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не diff --git a/src/debug/tools.cpp b/src/debug/tools.cpp index 6a1b066..60700e1 100644 --- a/src/debug/tools.cpp +++ b/src/debug/tools.cpp @@ -25,7 +25,10 @@ #include #include "engine/progression.h" +#include + #include "sim/element.h" +#include "sim/reactions.h" #include "sim/items.h" #include "sim/profile.h" #include "sim/target.h" @@ -179,6 +182,107 @@ int CmdTtk() return bad > 0 ? 1 : 0; } +// ----------------------------------------------------------------------------- +// Реакции. Проверяется не «срабатывает ли», а три ограничителя, без которых +// система триггеров вешает игру (docs/21-damage.md §4.1). +// ----------------------------------------------------------------------------- +int CmdReactions() +{ + std::vector targets; + + auto spawn = [&](Vec2 at) { + Target t; + t.kind = EnemyKind::SHAMBLER; + const EnemyDef d = EnemyDefOf(t.kind); + t.hp = t.maxHp = 100000.0f; // мешок, чтобы не умер посреди опыта + t.radius = d.radius; + t.resist = d.resist; + t.pos = at; + t.alive = true; + targets.push_back(t); + }; + + // Цель и сосед в радиусе площадных реакций. + spawn({0.0f, 0.0f}); + spawn({1.5f, 0.0f}); + + const int depth = 10; + int bad = 0; + + std::printf("%-12s %10s %10s %8s\n", "REACTION", "TARGET", "NEIGHBOUR", "STACKS"); + + struct Case { const char* name; Element a, b; }; + const Case cases[] = { + {"SHATTER", Element::INC, Element::CRY}, + {"IGNITE", Element::INC, Element::TOX}, + {"SUPERCOND", Element::CRY, Element::ARC}, + {"DECAY", Element::INC, Element::RAD}, + }; + + for (const Case& c : cases) + { + for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; } + + const float before = targets[0].hp; + const float beforeNb = targets[1].hp; + + ApplyElementalHit(targets, 0, ElementMix::Of(c.a, 0.8f), depth, nullptr); + ApplyElementalHit(targets, 0, ElementMix::Of(c.b, 0.8f), depth, nullptr); + + const float dealt = before - targets[0].hp; + const float dealtNb = beforeNb - targets[1].hp; + + // После реакции обе стихии обязаны погаснуть: иначе следующий же удар + // сложит ту же пару снова, и реакция станет постоянным фоном. + const int left = targets[0].status.stacks[int(c.a)] + targets[0].status.stacks[int(c.b)]; + + std::printf("%-12s %10.1f %10.1f %8d%s\n", c.name, double(dealt), double(dealtNb), left, + (dealt <= 0.0f || left != 0) ? " <-- не сработала" : ""); + if (dealt <= 0.0f || left != 0) ++bad; + } + + // --- откат ----------------------------------------------------------------- + // Вторая пара сразу после первой не должна дать ничего: иначе очередь из + // сорока пуль даёт сорок взрывов за секунду. + for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; } + ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr); + ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr); + const float afterFirst = targets[0].hp; + ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr); + ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr); + const float duringIcd = afterFirst - targets[0].hp; + + std::printf("\nоткат: вторая пара внутри %.1f c нанесла %.1f\n", double(REACTION_ICD), + double(duringIcd)); + if (duringIcd > 0.0f) + { + std::printf(" [FAIL] откат не работает: очередь даст очередь взрывов\n"); + ++bad; + } + + // --- запрет рекурсии ------------------------------------------------------- + // Урон ОТ реакции не кладёт статусов, значит новую пару сложить не может. + for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; } + ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr, true); + int stacksFromReaction = 0; + for (int e = 0; e < ELEMENT_COUNT; ++e) stacksFromReaction += targets[0].status.stacks[e]; + + std::printf("рекурсия: урон от реакции наложил стаков %d\n", stacksFromReaction); + if (stacksFromReaction != 0) + { + std::printf(" [FAIL] реакция кладёт статусы — цепочка уйдёт в самоповтор\n"); + ++bad; + } + + // --- сила от глубины ------------------------------------------------------- + std::printf("\nсила реакции: D1 %.1f, D50 %.1f, D100 %.1f (растёт от ГЛУБИНЫ,\n" + "а не от урона оружия — иначе на сотом этаже это округление)\n", + double(ReactionPower(1)), double(ReactionPower(50)), double(ReactionPower(100))); + + if (bad > 0) std::printf("\nпроблем: %d\n", bad); + return bad > 0 ? 1 : 0; +} + int CmdEconomy(int argc, char** argv) { const int maxDepth = (argc > 0) ? std::max(10, std::atoi(argv[0])) : 100; @@ -220,6 +324,7 @@ int RunGameTool(int argc, char** argv) if (std::strcmp(cmd, "weapons") == 0) return CmdWeapons(); if (std::strcmp(cmd, "enemies") == 0) return CmdEnemies(); if (std::strcmp(cmd, "ttk") == 0) return CmdTtk(); + if (std::strcmp(cmd, "reactions") == 0) return CmdReactions(); if (std::strcmp(cmd, "economy") == 0) return CmdEconomy(argc - 1, argv + 1); std::printf("нет команды `%s`\n\n", cmd); diff --git a/src/game.cpp b/src/game.cpp index 26c3d82..8bda22a 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -1,5 +1,7 @@ #include "game.h" +#include "sim/reactions.h" + // Перевод экранных осей ввода в мировые — единственное место, // где симуляционный слой касается изометрии (см. docs/02-architecture.md). #include "engine/view2d.h" @@ -609,7 +611,8 @@ void Game::Step(float dt) UpdateMelee(ctx, dt); // --- 9. пули --------------------------------------------------------------- - UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events); + UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events, + depth); // --- 10. находки ----------------------------------------------------------- // После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге, @@ -980,9 +983,12 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) // Через ту же формулу, что и пуля: клинок отличается // формой удара, а не правилами урона. - tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist); + tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist, + ResistCutOf(tg.status)) * + VulnerabilityOf(tg.status); tg.hurtTimer = g_tune.targetHurtTime; KnockTarget(tg, dir, m.curKnock); + ApplyElementalHit(targets, t, a.melee.elem, depth, &events); // Щепки вместо крови и мимо счётчика ударов по врагу: // разрубленный ящик не должен красить пол и накручивать // MELEE HITS в метриках. diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 2ff8e55..03185f3 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -1,5 +1,7 @@ #include "sim/bullet.h" +#include "sim/reactions.h" + #include "debug/metrics.h" void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, @@ -57,7 +59,7 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo void UpdateBullets(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, std::vector& targets, float dt, Metrics& metrics, - CombatEventLog* events) + CombatEventLog* events, int depth) { std::vector& bullets = world.All(); @@ -148,10 +150,16 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, { Target& t = targets[hitTarget]; const bool prop = t.IsProp(); - // Единственное место, где стихии превращаются в число (sim/element.h). - t.hp -= ResolveDamage(b.damage, b.elem, t.resist); + + // Единственное место, где стихии превращаются в число + // (sim/element.h). Пустота на цели срезает её сопротивления, а + // кислота и дуга увеличивают получаемый урон. + const float cut = ResistCutOf(t.status); + t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut) * VulnerabilityOf(t.status); t.hurtTimer = g_tune.targetHurtTime; KnockTarget(t, dir, b.knockback); + // Стихии удара ложатся статусами и могут сложиться в реакцию. + ApplyElementalHit(targets, hitTarget, b.elem, depth, events); if (events) events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET, b.owner, impact, dir); diff --git a/src/sim/bullet.h b/src/sim/bullet.h index b3017a5..5644454 100644 --- a/src/sim/bullet.h +++ b/src/sim/bullet.h @@ -65,4 +65,4 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo void UpdateBullets(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount, std::vector& targets, float dt, Metrics& metrics, - CombatEventLog* events); + CombatEventLog* events, int depth); diff --git a/src/sim/events.h b/src/sim/events.h index 6417f21..76cd4ec 100644 --- a/src/sim/events.h +++ b/src/sim/events.h @@ -57,6 +57,7 @@ enum class CombatEventKind : uint8_t // --- спуск ------------------------------------------------------------------ LOOT_TAKEN, // боец подобрал предмет. owner = БОЕЦ, pos = где лежало ITEM_USED, // боец потратил расходник: бинт, стим, пачку патронов + REACTION, // на цели сложились две стихии. owner = -1: реакция ничья DESCEND, // отряд дошёл до спуска. pos = точка спуска EXTRACT, // отряд дошёл до эвакуации. pos = лестница наверх }; diff --git a/src/sim/reactions.cpp b/src/sim/reactions.cpp new file mode 100644 index 0000000..5b6ddb2 --- /dev/null +++ b/src/sim/reactions.cpp @@ -0,0 +1,144 @@ +#include "sim/reactions.h" + +#include + +#include "tuning.h" + +namespace +{ + +// Урон реакции по одной цели. Идёт мимо сопротивлений: реакция — это не удар +// стихией, а её распад, и «сопротивление растрескиванию» пришлось бы заводить +// восьмым столбцом в каталоге ради одного случая. +void ReactionDamage(Target& t, float amount) +{ + if (!t.alive || t.IsProp()) return; + t.hp -= amount * VulnerabilityOf(t.status); + t.hurtTimer = g_tune.targetHurtTime; +} + +} // namespace + +void ApplyElementalHit(std::vector& targets, int index, const ElementMix& mix, int depth, + CombatEventLog* events, bool fromReaction) +{ + if (index < 0 || index >= int(targets.size())) return; + + Target& t = targets[size_t(index)]; + if (!t.alive || t.IsProp()) return; + + // Запрет рекурсии: урон от реакции не кладёт статусов, а значит и новую + // пару сложить не может. + if (fromReaction) return; + + // 1. Что именно легло этим ударом. Кинетика статуса не кладёт. + Element applied[ELEMENT_COUNT]; + int appliedCount = 0; + + for (int e = 1; e < ELEMENT_COUNT; ++e) + { + if (mix.share[e] <= 0.0f) continue; + + const Element el = Element(e); + const StatusRule rule = StatusRuleOf(el); + if (rule.maxStacks <= 0) continue; + + StatusState& st = t.status; + const bool wasEmpty = st.stacks[e] == 0; + + st.stacks[e] = std::min(rule.maxStacks, st.stacks[e] + 1); + st.timer[e] = rule.duration; // свежий стак продлевает весь набор + + if (wasEmpty || true) applied[appliedCount++] = el; + } + if (appliedCount == 0) return; + + // 2. Сложилась ли пара. Ищем среди УЖЕ висящих стихий ту, что даёт реакцию + // с только что наложенной. + if (t.status.reactionIcd > 0.0f) return; + + for (int i = 0; i < appliedCount; ++i) + { + const Element fresh = applied[i]; + for (int e = 1; e < ELEMENT_COUNT; ++e) + { + const Element other = Element(e); + if (other == fresh || !t.status.Has(other)) continue; + + const Reaction r = ReactionOf(fresh, other); + if (r == Reaction::NONE) continue; + + // 3. Реакция состоялась: гасим обе стихии и ставим откат. + t.status.stacks[int(fresh)] = 0; + t.status.stacks[int(other)] = 0; + t.status.reactionIcd = REACTION_ICD; + + const float power = ReactionPower(depth) * ReactionScale(r); + + if (r == Reaction::SUPERCOND) + { + // Срезает сопротивления вместо урона: кладёт стаки пустоты, + // потому что именно она в этой игре означает «защита не + // работает». Отдельной сущности для среза не заводим. + const StatusRule vd = StatusRuleOf(Element::VOID); + t.status.stacks[int(Element::VOID)] = vd.maxStacks; + t.status.timer[int(Element::VOID)] = vd.duration; + } + + ReactionDamage(t, power); + + if (ReactionIsArea(r)) + { + for (size_t k = 0; k < targets.size(); ++k) + { + if (int(k) == index) continue; + Target& other2 = targets[k]; + if (!other2.alive || other2.IsProp()) continue; + if (DistanceSq(other2.pos, t.pos) > REACTION_RADIUS * REACTION_RADIUS) continue; + + // Соседям достаётся половина и НИКАКИХ статусов: это урон + // от реакции, он рекурсию не запускает. + ReactionDamage(other2, power * 0.5f); + } + } + + if (events) + events->Push(CombatEventKind::REACTION, -1, t.pos, Vec2{}); + return; // одна реакция за удар: две подряд — это тот же взрыв дважды + } + } +} + +void UpdateStatuses(std::vector& targets, float dt) +{ + for (Target& t : targets) + { + StatusState& st = t.status; + + if (!t.alive || t.IsProp()) + { + if (st.stacks[0] != 0 || st.reactionIcd > 0.0f) st.Clear(); + continue; + } + + st.reactionIcd = std::max(0.0f, st.reactionIcd - dt); + + // Стаки сгорают по одному: набор тает постепенно, а не исчезает разом. + for (int e = 1; e < ELEMENT_COUNT; ++e) + { + if (st.stacks[e] <= 0) continue; + + st.timer[e] -= dt; + if (st.timer[e] <= 0.0f) + { + --st.stacks[e]; + st.timer[e] = StatusRuleOf(Element(e)).duration; + } + } + + // Горение считается от МАКСИМАЛЬНОГО здоровья цели: иначе на громиле + // оно не значило бы ничего, а мелочь дожигало бы само. + const int burn = st.stacks[int(Element::INC)]; + if (burn > 0) t.hp -= t.maxHp * BURN_PER_STACK * float(burn) * dt; + } +} diff --git a/src/sim/reactions.h b/src/sim/reactions.h new file mode 100644 index 0000000..cc1e9fc --- /dev/null +++ b/src/sim/reactions.h @@ -0,0 +1,26 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Наложение стихий и запуск реакций — единственное место, где это происходит. +// +// Отдельно от status.h намеренно: там правила и числа, здесь работа со списком +// целей. Правила читают все, а трогать чужие цели имеет право только эта пара +// функций. +// ----------------------------------------------------------------------------- +#include + +#include "sim/events.h" +#include "sim/status.h" +#include "sim/target.h" + +// Наложить стихии удара на цель и, если сложилась пара, запустить реакцию. +// +// fromReaction — урон пришёл ОТ реакции. Такой урон статусов не кладёт и новых +// реакций не запускает: это и есть запрет рекурсии, без которого цепочка +// «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция» +// уходит в самоповтор на первом же скоплении. +void ApplyElementalHit(std::vector& targets, int index, const ElementMix& mix, int depth, + CombatEventLog* events, bool fromReaction = false); + +// Тик статусов: горение и кислота, сгорание стаков, откат реакций. +// Зовётся из UpdateTargets, один раз за шаг. +void UpdateStatuses(std::vector& targets, float dt); diff --git a/src/sim/status.h b/src/sim/status.h new file mode 100644 index 0000000..639b6a8 --- /dev/null +++ b/src/sim/status.h @@ -0,0 +1,194 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Статусы стихий и реакции между ними. +// +// Стихии сами по себе (sim/element.h) — это только множитель урона: «верный +// ствол бьёт втрое больнее». Этого мало, чтобы стихии стали ЗНАНИЕМ, ради +// которого они заводились: множитель работает одинаково у того, кто понимает +// систему, и у того, кто просто взял ствол посильнее. +// +// Статусы и реакции добавляют второй слой, который работает только у первого: +// наложить ДВЕ разные стихии на одну цель и получить реакцию — это решение о +// составе отряда, а не о характеристиках оружия. +// +// ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ОБЯЗАТЕЛЬНЫЕ (docs/21-damage.md §4.1): +// +// 1. потолок стаков — один стак не значит ничего, полный набор меняет бой; +// 2. внутренний откат реакции на цель — иначе очередь из сорока пуль даёт +// сорок взрывов за секунду; +// 3. запрет рекурсии — урон ОТ реакции не накладывает статусов и не может +// запустить следующую реакцию. +// +// Третий пункт не теоретический. Без него цепочка «реакция бьёт по площади → +// урон кладёт стихию → складывается новая пара → реакция» уходит в самоповтор +// на первом же скоплении, и кадр умирает. Именно так ломаются все игры с +// триггерами, и чинить это потом дороже, чем запретить сразу. +// ----------------------------------------------------------------------------- +#include +#include + +#include "sim/element.h" + +// Сколько стаков держит статус и сколько живёт один стак. +struct StatusRule +{ + int maxStacks; + float duration; +}; + +// Потолки подобраны под правило «полный набор меняет бой»: горение в пять +// стаков заметно, в один — нет. +inline StatusRule StatusRuleOf(Element e) +{ + switch (e) + { + case Element::INC: return {5, 4.0f}; + case Element::CRY: return {10, 3.0f}; + case Element::TOX: return {8, 5.0f}; + case Element::ARC: return {4, 3.0f}; + case Element::RAD: return {6, 6.0f}; + case Element::VOID: return {3, 4.0f}; + default: return {0, 0.0f}; // KIN статуса не кладёт + } +} + +// Состояние статусов на одной цели. Плоские массивы, без аллокаций: их столько +// же, сколько целей, и они трогаются каждый шаг. +struct StatusState +{ + int stacks[ELEMENT_COUNT] = {}; + float timer[ELEMENT_COUNT] = {}; // до сгорания ОДНОГО стака + + // Откат реакции на этой цели. Один на все пары: две разные реакции подряд + // на одной твари за долю секунды — это тот же взрыв, только с двух сторон. + float reactionIcd = 0.0f; + + // Отложенный урон от статусов за шаг: тики горения и кислоты. Копится + // здесь, а применяется там же, где обычный урон, — чтобы смерть от тика + // проходила через тот же путь, что смерть от пули. + float pendingDamage = 0.0f; + + bool Has(Element e) const { return stacks[int(e)] > 0; } + + void Clear() + { + for (int i = 0; i < ELEMENT_COUNT; ++i) { stacks[i] = 0; timer[i] = 0.0f; } + reactionIcd = 0.0f; + pendingDamage = 0.0f; + } +}; + +// ----------------------------------------------------------------------------- +// Реакции. Пара разных стихий на одной цели. +// ----------------------------------------------------------------------------- +enum class Reaction : uint8_t +{ + NONE = 0, + SHATTER, // INC + CRY: всплеск урона на месте + IGNITE, // INC + TOX: облако, бьёт по площади + SUPERCOND, // ARC + CRY: срезает сопротивления + DECAY, // RAD + INC: заражает соседей + COUNT, +}; + +inline const char* ReactionName(Reaction r) +{ + switch (r) + { + case Reaction::SHATTER: return "SHATTER"; + case Reaction::IGNITE: return "IGNITE"; + case Reaction::SUPERCOND: return "SUPERCOND"; + case Reaction::DECAY: return "DECAY"; + default: return "-"; + } +} + +// Какая реакция получается из пары. Порядок наложения не важен: «сначала холод, +// потом жар» и наоборот — это одно и то же событие. +inline Reaction ReactionOf(Element a, Element b) +{ + const Element lo = (a < b) ? a : b; + const Element hi = (a < b) ? b : a; + + if (lo == Element::INC && hi == Element::CRY) return Reaction::SHATTER; + if (lo == Element::INC && hi == Element::TOX) return Reaction::IGNITE; + if (lo == Element::CRY && hi == Element::ARC) return Reaction::SUPERCOND; + if (lo == Element::INC && hi == Element::RAD) return Reaction::DECAY; + return Reaction::NONE; +} + +// Сила реакции считается ОТ ГЛУБИНЫ, а не от урона оружия. +// +// Это ключевое решение всей системы (docs/21-damage.md §3.1). Считай реакция от +// оружия — на сотом этаже она стала бы округлением, и весь слой стихий +// превратился бы в украшение. Считая от глубины, реакция растёт сама — но +// только у того, кто умеет её запускать. +inline float ReactionPower(int depth) +{ + return 4.0f + 0.12f * float(std::max(1, depth)); +} + +// Множитель конкретной реакции к базовой силе. +inline float ReactionScale(Reaction r) +{ + switch (r) + { + case Reaction::SHATTER: return 2.50f; // точечный всплеск + case Reaction::IGNITE: return 1.80f; // по площади, потому слабее + case Reaction::SUPERCOND: return 0.60f; // берёт не уроном, а срезом + case Reaction::DECAY: return 1.20f; + default: return 0.0f; + } +} + +constexpr float REACTION_ICD = 1.2f; // откат реакций на одной цели +constexpr float REACTION_RADIUS = 2.6f; // площадь у тех, что бьют по площади + +// Бьёт ли реакция по соседям. +inline bool ReactionIsArea(Reaction r) +{ + return r == Reaction::IGNITE || r == Reaction::DECAY; +} + +// ----------------------------------------------------------------------------- +// Эффекты статусов, которые читают другие системы. +// ----------------------------------------------------------------------------- + +// Урон за секунду от горения: доля максимального здоровья цели за стак. +// От максимума, а не от урона удара: иначе горение на громиле не значило бы +// ничего, а на мелочи убивало бы её само. +constexpr float BURN_PER_STACK = 0.012f; + +// Кислота: цель получает больше урона. В игре у тварей нет брони как числа, +// поэтому «снятие брони» из документа выражается прибавкой к получаемому +// урону — эффект тот же, а лишней сущности не заводится. +constexpr float CORRODE_PER_STACK = 0.035f; + +// Дуга: то же самое, но слабее и короче — она берёт цепью, а не числом. +constexpr float CHARGE_PER_STACK = 0.020f; + +// Холод: замедление за стак. +constexpr float CHILL_PER_STACK = 0.040f; + +// Пустота: срез сопротивлений за стак. +constexpr float COLLAPSE_PER_STACK = 0.080f; + +// Во сколько раз цель получает больше урона из-за статусов. +inline float VulnerabilityOf(const StatusState& st) +{ + return 1.0f + float(st.stacks[int(Element::TOX)]) * CORRODE_PER_STACK + + float(st.stacks[int(Element::ARC)]) * CHARGE_PER_STACK; +} + +// Насколько цель замедлена. +inline float SlowOf(const StatusState& st) +{ + const float slow = float(st.stacks[int(Element::CRY)]) * CHILL_PER_STACK; + return std::max(0.35f, 1.0f - slow); // ниже трети не тормозим: это не паралич +} + +// Срез сопротивлений от пустоты. +inline float ResistCutOf(const StatusState& st) +{ + return float(st.stacks[int(Element::VOID)]) * COLLAPSE_PER_STACK; +} diff --git a/src/sim/target.cpp b/src/sim/target.cpp index 8108b90..ab5e3d9 100644 --- a/src/sim/target.cpp +++ b/src/sim/target.cpp @@ -1,5 +1,7 @@ #include "sim/target.h" +#include "sim/reactions.h" + namespace { @@ -302,6 +304,10 @@ void KnockTarget(Target& t, Vec2 dir, float speed) void UpdateTargets(std::vector& targets, float dt) { + // Статусы тикают первыми: горение обязано успеть добить цель до того, как + // её посчитают живой в этом шаге. + UpdateStatuses(targets, dt); + // Отдача гасится и мишень возвращается на своё место: расстановка уровня // захардкожена, и «уползание» мишеней ломало бы все замеры разом. const float damp = std::exp(-g_tune.targetKnockDamp * dt); diff --git a/src/sim/target.h b/src/sim/target.h index 99c8999..6dfac90 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -1,5 +1,6 @@ #pragma once #include "sim/element.h" +#include "sim/status.h" // ----------------------------------------------------------------------------- // Противник. Одна сущность на две роли, и это сделано намеренно. // @@ -136,6 +137,10 @@ struct Target // мутации однажды будут править сопротивления ЭКЗЕМПЛЯРА, а не вида. Resists resist; + // Наложенные стихии. Живут на экземпляре: это состояние боя, а не описание + // вида (sim/status.h). + StatusState status; + // Во сколько раз ослабляется импульс с учётом общего множителя из tuning. float KnockScale() const {