Статусы стихий и реакции (M22)
Стихии сами по себе были только множителем урона: «верный ствол бьёт втрое больнее». Этого мало, чтобы они стали ЗНАНИЕМ, ради которого заводились — множитель одинаково работает у того, кто понимает систему, и у того, кто взял ствол посильнее. Статусы и реакции добавляют слой, который работает только у первого: наложить ДВЕ разные стихии на одну цель — это решение о составе отряда, а не о характеристиках оружия. sim/status.h правила, потолки стаков, реакции, числа sim/reactions.* наложение и запуск, единственное место UpdateStatuses тик горения и сгорание стаков, из UpdateTargets СИЛА РЕАКЦИИ СЧИТАЕТСЯ ОТ ГЛУБИНЫ, А НЕ ОТ УРОНА ОРУЖИЯ. Считай она от оружия — на сотом этаже стала бы округлением, и весь слой стихий превратился бы в украшение. От глубины она растёт сама, но только у того, кто умеет её запускать. ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ПРОВЕРЯЮТСЯ потолок стаков один стак не значит ничего, полный набор меняет бой откат на цели вторая пара внутри 1.2 c даёт 0.0 урона запрет рекурсии урон ОТ реакции не кладёт статусов Третий не теоретический: без него цепочка «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция» уходит в самоповтор на первом же скоплении. Именно так ломаются все игры с триггерами. Четыре реакции из десяти. Остальные ждут систем, которых нет (крит, воскрешение, притяжение), и дописывать их «пока не работает» значило бы завести шесть мёртвых веток. Два отступления от документа, оба сознательные: кислота и дуга увеличивают получаемый урон вместо снятия брони и метки крита (у тварей нет ни брони как числа, ни крита), SUPERCOND кладёт стаки пустоты вместо отдельного среза сопротивлений — пустота в этой игре и означает «защита не работает». Шаг симуляции 2.9 мкс из бюджета 16666: статусы на 64 целях ничего не стоят. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3e1a921eb6
commit
c1831ac370
@ -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 # доход опыта против цены дерева
|
||||
```
|
||||
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -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. Свет как ресурс
|
||||
|
||||
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не
|
||||
|
||||
@ -25,7 +25,10 @@
|
||||
#include <string>
|
||||
|
||||
#include "engine/progression.h"
|
||||
#include <vector>
|
||||
|
||||
#include "sim/element.h"
|
||||
#include "sim/reactions.h"
|
||||
#include "sim/items.h"
|
||||
#include "sim/profile.h"
|
||||
#include "sim/target.h"
|
||||
@ -179,6 +182,107 @@ int CmdTtk()
|
||||
return bad > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Реакции. Проверяется не «срабатывает ли», а три ограничителя, без которых
|
||||
// система триггеров вешает игру (docs/21-damage.md §4.1).
|
||||
// -----------------------------------------------------------------------------
|
||||
int CmdReactions()
|
||||
{
|
||||
std::vector<Target> targets;
|
||||
|
||||
auto spawn = [&](Vec2 at) {
|
||||
Target t;
|
||||
t.kind = EnemyKind::SHAMBLER;
|
||||
const EnemyDef d = EnemyDefOf(t.kind);
|
||||
t.hp = t.maxHp = 100000.0f; // мешок, чтобы не умер посреди опыта
|
||||
t.radius = d.radius;
|
||||
t.resist = d.resist;
|
||||
t.pos = at;
|
||||
t.alive = true;
|
||||
targets.push_back(t);
|
||||
};
|
||||
|
||||
// Цель и сосед в радиусе площадных реакций.
|
||||
spawn({0.0f, 0.0f});
|
||||
spawn({1.5f, 0.0f});
|
||||
|
||||
const int depth = 10;
|
||||
int bad = 0;
|
||||
|
||||
std::printf("%-12s %10s %10s %8s\n", "REACTION", "TARGET", "NEIGHBOUR", "STACKS");
|
||||
|
||||
struct Case { const char* name; Element a, b; };
|
||||
const Case cases[] = {
|
||||
{"SHATTER", Element::INC, Element::CRY},
|
||||
{"IGNITE", Element::INC, Element::TOX},
|
||||
{"SUPERCOND", Element::CRY, Element::ARC},
|
||||
{"DECAY", Element::INC, Element::RAD},
|
||||
};
|
||||
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
|
||||
|
||||
const float before = targets[0].hp;
|
||||
const float beforeNb = targets[1].hp;
|
||||
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(c.a, 0.8f), depth, nullptr);
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(c.b, 0.8f), depth, nullptr);
|
||||
|
||||
const float dealt = before - targets[0].hp;
|
||||
const float dealtNb = beforeNb - targets[1].hp;
|
||||
|
||||
// После реакции обе стихии обязаны погаснуть: иначе следующий же удар
|
||||
// сложит ту же пару снова, и реакция станет постоянным фоном.
|
||||
const int left = targets[0].status.stacks[int(c.a)] + targets[0].status.stacks[int(c.b)];
|
||||
|
||||
std::printf("%-12s %10.1f %10.1f %8d%s\n", c.name, double(dealt), double(dealtNb), left,
|
||||
(dealt <= 0.0f || left != 0) ? " <-- не сработала" : "");
|
||||
if (dealt <= 0.0f || left != 0) ++bad;
|
||||
}
|
||||
|
||||
// --- откат -----------------------------------------------------------------
|
||||
// Вторая пара сразу после первой не должна дать ничего: иначе очередь из
|
||||
// сорока пуль даёт сорок взрывов за секунду.
|
||||
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr);
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr);
|
||||
const float afterFirst = targets[0].hp;
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr);
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(Element::CRY, 0.8f), depth, nullptr);
|
||||
const float duringIcd = afterFirst - targets[0].hp;
|
||||
|
||||
std::printf("\nоткат: вторая пара внутри %.1f c нанесла %.1f\n", double(REACTION_ICD),
|
||||
double(duringIcd));
|
||||
if (duringIcd > 0.0f)
|
||||
{
|
||||
std::printf(" [FAIL] откат не работает: очередь даст очередь взрывов\n");
|
||||
++bad;
|
||||
}
|
||||
|
||||
// --- запрет рекурсии -------------------------------------------------------
|
||||
// Урон ОТ реакции не кладёт статусов, значит новую пару сложить не может.
|
||||
for (Target& t : targets) { t.status.Clear(); t.hp = t.maxHp; }
|
||||
ApplyElementalHit(targets, 0, ElementMix::Of(Element::INC, 0.8f), depth, nullptr, true);
|
||||
int stacksFromReaction = 0;
|
||||
for (int e = 0; e < ELEMENT_COUNT; ++e) stacksFromReaction += targets[0].status.stacks[e];
|
||||
|
||||
std::printf("рекурсия: урон от реакции наложил стаков %d\n", stacksFromReaction);
|
||||
if (stacksFromReaction != 0)
|
||||
{
|
||||
std::printf(" [FAIL] реакция кладёт статусы — цепочка уйдёт в самоповтор\n");
|
||||
++bad;
|
||||
}
|
||||
|
||||
// --- сила от глубины -------------------------------------------------------
|
||||
std::printf("\nсила реакции: D1 %.1f, D50 %.1f, D100 %.1f (растёт от ГЛУБИНЫ,\n"
|
||||
"а не от урона оружия — иначе на сотом этаже это округление)\n",
|
||||
double(ReactionPower(1)), double(ReactionPower(50)), double(ReactionPower(100)));
|
||||
|
||||
if (bad > 0) std::printf("\nпроблем: %d\n", bad);
|
||||
return bad > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
int CmdEconomy(int argc, char** argv)
|
||||
{
|
||||
const int maxDepth = (argc > 0) ? std::max(10, std::atoi(argv[0])) : 100;
|
||||
@ -220,6 +324,7 @@ int RunGameTool(int argc, char** argv)
|
||||
if (std::strcmp(cmd, "weapons") == 0) return CmdWeapons();
|
||||
if (std::strcmp(cmd, "enemies") == 0) return CmdEnemies();
|
||||
if (std::strcmp(cmd, "ttk") == 0) return CmdTtk();
|
||||
if (std::strcmp(cmd, "reactions") == 0) return CmdReactions();
|
||||
if (std::strcmp(cmd, "economy") == 0) return CmdEconomy(argc - 1, argv + 1);
|
||||
|
||||
std::printf("нет команды `%s`\n\n", cmd);
|
||||
|
||||
10
src/game.cpp
10
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 в метриках.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#include "sim/bullet.h"
|
||||
|
||||
#include "sim/reactions.h"
|
||||
|
||||
#include "debug/metrics.h"
|
||||
|
||||
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
|
||||
@ -57,7 +59,7 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
|
||||
void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
Agent* agents, int agentCount,
|
||||
std::vector<Target>& targets, float dt, Metrics& metrics,
|
||||
CombatEventLog* events)
|
||||
CombatEventLog* events, int depth)
|
||||
{
|
||||
std::vector<Bullet>& bullets = world.All<Bullet>();
|
||||
|
||||
@ -148,10 +150,16 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
{
|
||||
Target& t = targets[hitTarget];
|
||||
const bool prop = t.IsProp();
|
||||
// Единственное место, где стихии превращаются в число (sim/element.h).
|
||||
t.hp -= ResolveDamage(b.damage, b.elem, t.resist);
|
||||
|
||||
// Единственное место, где стихии превращаются в число
|
||||
// (sim/element.h). Пустота на цели срезает её сопротивления, а
|
||||
// кислота и дуга увеличивают получаемый урон.
|
||||
const float cut = ResistCutOf(t.status);
|
||||
t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut) * VulnerabilityOf(t.status);
|
||||
t.hurtTimer = g_tune.targetHurtTime;
|
||||
KnockTarget(t, dir, b.knockback);
|
||||
// Стихии удара ложатся статусами и могут сложиться в реакцию.
|
||||
ApplyElementalHit(targets, hitTarget, b.elem, depth, events);
|
||||
if (events)
|
||||
events->Push(prop ? CombatEventKind::HIT_PROP : CombatEventKind::HIT_TARGET,
|
||||
b.owner, impact, dir);
|
||||
|
||||
@ -65,4 +65,4 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
|
||||
void UpdateBullets(ecs::World& world, const Tilemap& map,
|
||||
Agent* agents, int agentCount,
|
||||
std::vector<Target>& targets, float dt, Metrics& metrics,
|
||||
CombatEventLog* events);
|
||||
CombatEventLog* events, int depth);
|
||||
|
||||
@ -57,6 +57,7 @@ enum class CombatEventKind : uint8_t
|
||||
// --- спуск ------------------------------------------------------------------
|
||||
LOOT_TAKEN, // боец подобрал предмет. owner = БОЕЦ, pos = где лежало
|
||||
ITEM_USED, // боец потратил расходник: бинт, стим, пачку патронов
|
||||
REACTION, // на цели сложились две стихии. owner = -1: реакция ничья
|
||||
DESCEND, // отряд дошёл до спуска. pos = точка спуска
|
||||
EXTRACT, // отряд дошёл до эвакуации. pos = лестница наверх
|
||||
};
|
||||
|
||||
144
src/sim/reactions.cpp
Normal file
144
src/sim/reactions.cpp
Normal file
@ -0,0 +1,144 @@
|
||||
#include "sim/reactions.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "tuning.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Урон реакции по одной цели. Идёт мимо сопротивлений: реакция — это не удар
|
||||
// стихией, а её распад, и «сопротивление растрескиванию» пришлось бы заводить
|
||||
// восьмым столбцом в каталоге ради одного случая.
|
||||
void ReactionDamage(Target& t, float amount)
|
||||
{
|
||||
if (!t.alive || t.IsProp()) return;
|
||||
t.hp -= amount * VulnerabilityOf(t.status);
|
||||
t.hurtTimer = g_tune.targetHurtTime;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
|
||||
CombatEventLog* events, bool fromReaction)
|
||||
{
|
||||
if (index < 0 || index >= int(targets.size())) return;
|
||||
|
||||
Target& t = targets[size_t(index)];
|
||||
if (!t.alive || t.IsProp()) return;
|
||||
|
||||
// Запрет рекурсии: урон от реакции не кладёт статусов, а значит и новую
|
||||
// пару сложить не может.
|
||||
if (fromReaction) return;
|
||||
|
||||
// 1. Что именно легло этим ударом. Кинетика статуса не кладёт.
|
||||
Element applied[ELEMENT_COUNT];
|
||||
int appliedCount = 0;
|
||||
|
||||
for (int e = 1; e < ELEMENT_COUNT; ++e)
|
||||
{
|
||||
if (mix.share[e] <= 0.0f) continue;
|
||||
|
||||
const Element el = Element(e);
|
||||
const StatusRule rule = StatusRuleOf(el);
|
||||
if (rule.maxStacks <= 0) continue;
|
||||
|
||||
StatusState& st = t.status;
|
||||
const bool wasEmpty = st.stacks[e] == 0;
|
||||
|
||||
st.stacks[e] = std::min(rule.maxStacks, st.stacks[e] + 1);
|
||||
st.timer[e] = rule.duration; // свежий стак продлевает весь набор
|
||||
|
||||
if (wasEmpty || true) applied[appliedCount++] = el;
|
||||
}
|
||||
if (appliedCount == 0) return;
|
||||
|
||||
// 2. Сложилась ли пара. Ищем среди УЖЕ висящих стихий ту, что даёт реакцию
|
||||
// с только что наложенной.
|
||||
if (t.status.reactionIcd > 0.0f) return;
|
||||
|
||||
for (int i = 0; i < appliedCount; ++i)
|
||||
{
|
||||
const Element fresh = applied[i];
|
||||
for (int e = 1; e < ELEMENT_COUNT; ++e)
|
||||
{
|
||||
const Element other = Element(e);
|
||||
if (other == fresh || !t.status.Has(other)) continue;
|
||||
|
||||
const Reaction r = ReactionOf(fresh, other);
|
||||
if (r == Reaction::NONE) continue;
|
||||
|
||||
// 3. Реакция состоялась: гасим обе стихии и ставим откат.
|
||||
t.status.stacks[int(fresh)] = 0;
|
||||
t.status.stacks[int(other)] = 0;
|
||||
t.status.reactionIcd = REACTION_ICD;
|
||||
|
||||
const float power = ReactionPower(depth) * ReactionScale(r);
|
||||
|
||||
if (r == Reaction::SUPERCOND)
|
||||
{
|
||||
// Срезает сопротивления вместо урона: кладёт стаки пустоты,
|
||||
// потому что именно она в этой игре означает «защита не
|
||||
// работает». Отдельной сущности для среза не заводим.
|
||||
const StatusRule vd = StatusRuleOf(Element::VOID);
|
||||
t.status.stacks[int(Element::VOID)] = vd.maxStacks;
|
||||
t.status.timer[int(Element::VOID)] = vd.duration;
|
||||
}
|
||||
|
||||
ReactionDamage(t, power);
|
||||
|
||||
if (ReactionIsArea(r))
|
||||
{
|
||||
for (size_t k = 0; k < targets.size(); ++k)
|
||||
{
|
||||
if (int(k) == index) continue;
|
||||
Target& other2 = targets[k];
|
||||
if (!other2.alive || other2.IsProp()) continue;
|
||||
if (DistanceSq(other2.pos, t.pos) > REACTION_RADIUS * REACTION_RADIUS) continue;
|
||||
|
||||
// Соседям достаётся половина и НИКАКИХ статусов: это урон
|
||||
// от реакции, он рекурсию не запускает.
|
||||
ReactionDamage(other2, power * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
if (events)
|
||||
events->Push(CombatEventKind::REACTION, -1, t.pos, Vec2{});
|
||||
return; // одна реакция за удар: две подряд — это тот же взрыв дважды
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UpdateStatuses(std::vector<Target>& targets, float dt)
|
||||
{
|
||||
for (Target& t : targets)
|
||||
{
|
||||
StatusState& st = t.status;
|
||||
|
||||
if (!t.alive || t.IsProp())
|
||||
{
|
||||
if (st.stacks[0] != 0 || st.reactionIcd > 0.0f) st.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
st.reactionIcd = std::max(0.0f, st.reactionIcd - dt);
|
||||
|
||||
// Стаки сгорают по одному: набор тает постепенно, а не исчезает разом.
|
||||
for (int e = 1; e < ELEMENT_COUNT; ++e)
|
||||
{
|
||||
if (st.stacks[e] <= 0) continue;
|
||||
|
||||
st.timer[e] -= dt;
|
||||
if (st.timer[e] <= 0.0f)
|
||||
{
|
||||
--st.stacks[e];
|
||||
st.timer[e] = StatusRuleOf(Element(e)).duration;
|
||||
}
|
||||
}
|
||||
|
||||
// Горение считается от МАКСИМАЛЬНОГО здоровья цели: иначе на громиле
|
||||
// оно не значило бы ничего, а мелочь дожигало бы само.
|
||||
const int burn = st.stacks[int(Element::INC)];
|
||||
if (burn > 0) t.hp -= t.maxHp * BURN_PER_STACK * float(burn) * dt;
|
||||
}
|
||||
}
|
||||
26
src/sim/reactions.h
Normal file
26
src/sim/reactions.h
Normal file
@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Наложение стихий и запуск реакций — единственное место, где это происходит.
|
||||
//
|
||||
// Отдельно от status.h намеренно: там правила и числа, здесь работа со списком
|
||||
// целей. Правила читают все, а трогать чужие цели имеет право только эта пара
|
||||
// функций.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <vector>
|
||||
|
||||
#include "sim/events.h"
|
||||
#include "sim/status.h"
|
||||
#include "sim/target.h"
|
||||
|
||||
// Наложить стихии удара на цель и, если сложилась пара, запустить реакцию.
|
||||
//
|
||||
// fromReaction — урон пришёл ОТ реакции. Такой урон статусов не кладёт и новых
|
||||
// реакций не запускает: это и есть запрет рекурсии, без которого цепочка
|
||||
// «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция»
|
||||
// уходит в самоповтор на первом же скоплении.
|
||||
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
|
||||
CombatEventLog* events, bool fromReaction = false);
|
||||
|
||||
// Тик статусов: горение и кислота, сгорание стаков, откат реакций.
|
||||
// Зовётся из UpdateTargets, один раз за шаг.
|
||||
void UpdateStatuses(std::vector<Target>& targets, float dt);
|
||||
194
src/sim/status.h
Normal file
194
src/sim/status.h
Normal file
@ -0,0 +1,194 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Статусы стихий и реакции между ними.
|
||||
//
|
||||
// Стихии сами по себе (sim/element.h) — это только множитель урона: «верный
|
||||
// ствол бьёт втрое больнее». Этого мало, чтобы стихии стали ЗНАНИЕМ, ради
|
||||
// которого они заводились: множитель работает одинаково у того, кто понимает
|
||||
// систему, и у того, кто просто взял ствол посильнее.
|
||||
//
|
||||
// Статусы и реакции добавляют второй слой, который работает только у первого:
|
||||
// наложить ДВЕ разные стихии на одну цель и получить реакцию — это решение о
|
||||
// составе отряда, а не о характеристиках оружия.
|
||||
//
|
||||
// ТРИ ОГРАНИЧИТЕЛЯ, И ВСЕ ОБЯЗАТЕЛЬНЫЕ (docs/21-damage.md §4.1):
|
||||
//
|
||||
// 1. потолок стаков — один стак не значит ничего, полный набор меняет бой;
|
||||
// 2. внутренний откат реакции на цель — иначе очередь из сорока пуль даёт
|
||||
// сорок взрывов за секунду;
|
||||
// 3. запрет рекурсии — урон ОТ реакции не накладывает статусов и не может
|
||||
// запустить следующую реакцию.
|
||||
//
|
||||
// Третий пункт не теоретический. Без него цепочка «реакция бьёт по площади →
|
||||
// урон кладёт стихию → складывается новая пара → реакция» уходит в самоповтор
|
||||
// на первом же скоплении, и кадр умирает. Именно так ломаются все игры с
|
||||
// триггерами, и чинить это потом дороже, чем запретить сразу.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
#include "sim/element.h"
|
||||
|
||||
// Сколько стаков держит статус и сколько живёт один стак.
|
||||
struct StatusRule
|
||||
{
|
||||
int maxStacks;
|
||||
float duration;
|
||||
};
|
||||
|
||||
// Потолки подобраны под правило «полный набор меняет бой»: горение в пять
|
||||
// стаков заметно, в один — нет.
|
||||
inline StatusRule StatusRuleOf(Element e)
|
||||
{
|
||||
switch (e)
|
||||
{
|
||||
case Element::INC: return {5, 4.0f};
|
||||
case Element::CRY: return {10, 3.0f};
|
||||
case Element::TOX: return {8, 5.0f};
|
||||
case Element::ARC: return {4, 3.0f};
|
||||
case Element::RAD: return {6, 6.0f};
|
||||
case Element::VOID: return {3, 4.0f};
|
||||
default: return {0, 0.0f}; // KIN статуса не кладёт
|
||||
}
|
||||
}
|
||||
|
||||
// Состояние статусов на одной цели. Плоские массивы, без аллокаций: их столько
|
||||
// же, сколько целей, и они трогаются каждый шаг.
|
||||
struct StatusState
|
||||
{
|
||||
int stacks[ELEMENT_COUNT] = {};
|
||||
float timer[ELEMENT_COUNT] = {}; // до сгорания ОДНОГО стака
|
||||
|
||||
// Откат реакции на этой цели. Один на все пары: две разные реакции подряд
|
||||
// на одной твари за долю секунды — это тот же взрыв, только с двух сторон.
|
||||
float reactionIcd = 0.0f;
|
||||
|
||||
// Отложенный урон от статусов за шаг: тики горения и кислоты. Копится
|
||||
// здесь, а применяется там же, где обычный урон, — чтобы смерть от тика
|
||||
// проходила через тот же путь, что смерть от пули.
|
||||
float pendingDamage = 0.0f;
|
||||
|
||||
bool Has(Element e) const { return stacks[int(e)] > 0; }
|
||||
|
||||
void Clear()
|
||||
{
|
||||
for (int i = 0; i < ELEMENT_COUNT; ++i) { stacks[i] = 0; timer[i] = 0.0f; }
|
||||
reactionIcd = 0.0f;
|
||||
pendingDamage = 0.0f;
|
||||
}
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Реакции. Пара разных стихий на одной цели.
|
||||
// -----------------------------------------------------------------------------
|
||||
enum class Reaction : uint8_t
|
||||
{
|
||||
NONE = 0,
|
||||
SHATTER, // INC + CRY: всплеск урона на месте
|
||||
IGNITE, // INC + TOX: облако, бьёт по площади
|
||||
SUPERCOND, // ARC + CRY: срезает сопротивления
|
||||
DECAY, // RAD + INC: заражает соседей
|
||||
COUNT,
|
||||
};
|
||||
|
||||
inline const char* ReactionName(Reaction r)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case Reaction::SHATTER: return "SHATTER";
|
||||
case Reaction::IGNITE: return "IGNITE";
|
||||
case Reaction::SUPERCOND: return "SUPERCOND";
|
||||
case Reaction::DECAY: return "DECAY";
|
||||
default: return "-";
|
||||
}
|
||||
}
|
||||
|
||||
// Какая реакция получается из пары. Порядок наложения не важен: «сначала холод,
|
||||
// потом жар» и наоборот — это одно и то же событие.
|
||||
inline Reaction ReactionOf(Element a, Element b)
|
||||
{
|
||||
const Element lo = (a < b) ? a : b;
|
||||
const Element hi = (a < b) ? b : a;
|
||||
|
||||
if (lo == Element::INC && hi == Element::CRY) return Reaction::SHATTER;
|
||||
if (lo == Element::INC && hi == Element::TOX) return Reaction::IGNITE;
|
||||
if (lo == Element::CRY && hi == Element::ARC) return Reaction::SUPERCOND;
|
||||
if (lo == Element::INC && hi == Element::RAD) return Reaction::DECAY;
|
||||
return Reaction::NONE;
|
||||
}
|
||||
|
||||
// Сила реакции считается ОТ ГЛУБИНЫ, а не от урона оружия.
|
||||
//
|
||||
// Это ключевое решение всей системы (docs/21-damage.md §3.1). Считай реакция от
|
||||
// оружия — на сотом этаже она стала бы округлением, и весь слой стихий
|
||||
// превратился бы в украшение. Считая от глубины, реакция растёт сама — но
|
||||
// только у того, кто умеет её запускать.
|
||||
inline float ReactionPower(int depth)
|
||||
{
|
||||
return 4.0f + 0.12f * float(std::max(1, depth));
|
||||
}
|
||||
|
||||
// Множитель конкретной реакции к базовой силе.
|
||||
inline float ReactionScale(Reaction r)
|
||||
{
|
||||
switch (r)
|
||||
{
|
||||
case Reaction::SHATTER: return 2.50f; // точечный всплеск
|
||||
case Reaction::IGNITE: return 1.80f; // по площади, потому слабее
|
||||
case Reaction::SUPERCOND: return 0.60f; // берёт не уроном, а срезом
|
||||
case Reaction::DECAY: return 1.20f;
|
||||
default: return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
constexpr float REACTION_ICD = 1.2f; // откат реакций на одной цели
|
||||
constexpr float REACTION_RADIUS = 2.6f; // площадь у тех, что бьют по площади
|
||||
|
||||
// Бьёт ли реакция по соседям.
|
||||
inline bool ReactionIsArea(Reaction r)
|
||||
{
|
||||
return r == Reaction::IGNITE || r == Reaction::DECAY;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Эффекты статусов, которые читают другие системы.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Урон за секунду от горения: доля максимального здоровья цели за стак.
|
||||
// От максимума, а не от урона удара: иначе горение на громиле не значило бы
|
||||
// ничего, а на мелочи убивало бы её само.
|
||||
constexpr float BURN_PER_STACK = 0.012f;
|
||||
|
||||
// Кислота: цель получает больше урона. В игре у тварей нет брони как числа,
|
||||
// поэтому «снятие брони» из документа выражается прибавкой к получаемому
|
||||
// урону — эффект тот же, а лишней сущности не заводится.
|
||||
constexpr float CORRODE_PER_STACK = 0.035f;
|
||||
|
||||
// Дуга: то же самое, но слабее и короче — она берёт цепью, а не числом.
|
||||
constexpr float CHARGE_PER_STACK = 0.020f;
|
||||
|
||||
// Холод: замедление за стак.
|
||||
constexpr float CHILL_PER_STACK = 0.040f;
|
||||
|
||||
// Пустота: срез сопротивлений за стак.
|
||||
constexpr float COLLAPSE_PER_STACK = 0.080f;
|
||||
|
||||
// Во сколько раз цель получает больше урона из-за статусов.
|
||||
inline float VulnerabilityOf(const StatusState& st)
|
||||
{
|
||||
return 1.0f + float(st.stacks[int(Element::TOX)]) * CORRODE_PER_STACK +
|
||||
float(st.stacks[int(Element::ARC)]) * CHARGE_PER_STACK;
|
||||
}
|
||||
|
||||
// Насколько цель замедлена.
|
||||
inline float SlowOf(const StatusState& st)
|
||||
{
|
||||
const float slow = float(st.stacks[int(Element::CRY)]) * CHILL_PER_STACK;
|
||||
return std::max(0.35f, 1.0f - slow); // ниже трети не тормозим: это не паралич
|
||||
}
|
||||
|
||||
// Срез сопротивлений от пустоты.
|
||||
inline float ResistCutOf(const StatusState& st)
|
||||
{
|
||||
return float(st.stacks[int(Element::VOID)]) * COLLAPSE_PER_STACK;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
#include "sim/target.h"
|
||||
|
||||
#include "sim/reactions.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
@ -302,6 +304,10 @@ void KnockTarget(Target& t, Vec2 dir, float speed)
|
||||
|
||||
void UpdateTargets(std::vector<Target>& targets, float dt)
|
||||
{
|
||||
// Статусы тикают первыми: горение обязано успеть добить цель до того, как
|
||||
// её посчитают живой в этом шаге.
|
||||
UpdateStatuses(targets, dt);
|
||||
|
||||
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
|
||||
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
|
||||
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user