From 745cb2a3fd4698488a2b5e3ce8905264f8a50ebf Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Sat, 15 Aug 2026 05:25:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B8=D1=85=D0=B8=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=85=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?(M21,=20E2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit СТИХИИ (src/sim/element.h) Семь элементов, сопротивления свойством ВИДА, одна формула урона на пулю и на клинок. Зачем они в игре про бесконечный спуск: сила отряда растёт медленнее угрозы (закон масштаба, docs/20-gdd.md), и разрыв обязан закрываться знанием. Стихии — первый такой канал: верное оружие убивает втрое быстрее, и цифра эта не растёт от прокачки. Ручных действий не заводится: стихия — свойство оружия, а не кнопка. Всё решается тем, что отряд взял вниз. Стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и сабля остаются чистой кинетикой, потому что на них меряется приёмка. ИНСТРУМЕНТЫ БАЛАНСА (src/debug/tools.cpp) Вторая поверхность: `squad_proto --tool weapons|enemies|ttk|economy`. Отдельная от редакторской не по вкусу — редактор линкуется только с движком и правил боя не видит, а всё это считает урон. Инструменты делятся по ПРЕДМЕТУ: содержимое у редактора, баланс у игры. ЧТО НАШЛА ПРОВЕРКА Первая версия --tool ttk считала разброс по времени убийства и показывала уверенные 2.8x на каждой твари. Это была разница МОЩНОСТИ стволов: магнум лучше пистолета против всех, стихии ни при чём. С вынесенной за скобки мощностью оружия правда оказалась другой: 1.04-1.36x, то есть состав удара не значил почти ничего. Механика была декоративной ровно в том же смысле, в каком когда-то был вес. Чтобы разброс появился, у твари должны быть И уязвимость, И сопротивление среди тех стихий, которые вообще носит оружие: одной уязвимости мало, если ни один ствол не бьёт тем, что она держит. После правки содержимого все пять видов в коридоре 1.90-3.06x, и картина читается — дробовик жжёт бредущих, дуга бьёт плевков, лук разъедает громилу, карабин против громилы бесполезен. Коридор в инструменте 1.8-8.0, а не заявленные в документе 3-6: до тройки дотянут почти чистые стихийные сборки, которые приходят с аффиксами. Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 + docs/01-build-and-run.md | 12 +++ docs/02-architecture.md | 2 + docs/07-milestones.md | 2 + docs/21-damage.md | 47 ++++++++ src/debug/tools.cpp | 228 +++++++++++++++++++++++++++++++++++++++ src/debug/tools.h | 11 ++ src/game.cpp | 4 +- src/main.cpp | 8 ++ src/sim/agent.cpp | 1 + src/sim/bullet.cpp | 4 +- src/sim/bullet.h | 4 + src/sim/element.h | 121 +++++++++++++++++++++ src/sim/loadout.h | 5 + src/sim/target.cpp | 11 ++ src/sim/target.h | 9 ++ src/sim/weapon_defs.h | 27 +++++ 17 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 src/debug/tools.cpp create mode 100644 src/debug/tools.h create mode 100644 src/sim/element.h diff --git a/CLAUDE.md b/CLAUDE.md index c4dcb56..5a8a18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,10 @@ - Сборка: `cmake -S . -B build && cmake --build build --config Release`. Целей три: `tile2d` (библиотека), `build/bin/squad_proto.exe` (игра), `build/bin/tile2d_editor.exe` (редактор комнат). +- **Инструментов две группы, и делятся они по предмету**: `squad_proto --tool` — + баланс (урон, сопротивления, экономика), `tile2d_editor --tool` — содержимое + (комнаты, каталог, этажи). Редактор не линкуется с `sim/` и правил боя не + видит, поэтому смешать их нельзя. - **Содержимое щупают инструментами, а не глазами**: `tile2d_editor --tool help`. Правка делается файлами (комнаты и каталог — текст), последствия читаются командой: `--tool validate`, `--tool floor`, `--tool map` diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index aba3bd0..7f86145 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -131,6 +131,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--seed N` | сид сборки уровня (по умолчанию 1) | | `--depth N` | начать не с первого этажа (для проверок) | | `--descend N` | прогон забега на N этажей без окна | +| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--no-profile` | не читать и не писать профиль | @@ -153,6 +154,17 @@ squad_proto.exe --project hollow # второй пр squad_proto.exe --rooms --view topdown # свой проект чужим видом ``` +Инструментов две группы, и делятся они по предмету: `squad_proto --tool` считает +БАЛАНС (урон, сопротивления, экономика), `tile2d_editor --tool` — СОДЕРЖИМОЕ +(комнаты, каталог, этажи). Разделение не по вкусу: редактор линкуется только с +движком и правил боя не видит вовсе. + +```bash +squad_proto.exe --tool ttk # время убийства и разброс по стихиям +squad_proto.exe --tool enemies # здоровье и сопротивления тварей +squad_proto.exe --tool economy # доход опыта против цены дерева +``` + Редактор комнат — отдельное приложение с собственными ключами: ```bash diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 145c57e..af59c9f 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -72,6 +72,7 @@ src/ bullet.h/.cpp снаряды, коллизии с тайлами и капсулами events.h кольцевой журнал боевых событий: единственный канал к эффектам items.h каталог предметов и инвентарь бойца (docs/13-combat.md) + element.h стихии, сопротивления, единственная формула урона enemy.h/.cpp поведение тварей: заметил -> идёт -> замах -> удар spawn_catalog.h/.cpp единственный перевод id движка в EnemyKind игры ai/ @@ -81,6 +82,7 @@ src/ ui/ menu.h/.cpp меню, пауза, экран отряда, поражение (второе место с raylib) debug/ + tools.cpp балансовые инструменты: squad_proto --tool overlay.h/.cpp оверлей F1–F4 поверх кадра (raylib-примитивы) metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим harness.h/.cpp прогоны --headless и --accept diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 8c9c265..3b570da 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -30,6 +30,8 @@ | **M20** | опыт тратится: четыре ресурсных улучшения отряда (патроны, переноска, обзор, живучесть) в профиле, экран UPGRADES, цена растёт с уровнем | проверка в `--descend`: покупка списывает опыт, здоровье 110 → 122 и запас 120 → 140 на следующей вылазке; проверка дважды ломала сам прогон (вызывала NewRun посреди забега) и была отправлена в конец; `--accept` PASS — приёмка профиля не подключает ([17-extraction.md](17-extraction.md)) | +| **M21** | стихии: семь элементов, сопротивления видом, единственная формула урона; вторая поверхность инструментов `squad_proto --tool` (weapons, enemies, ttk, economy) | `--tool ttk` сперва показал, что стихии меняют урон на 4–36%, то есть механика декоративна; после правки содержимого все пять видов в коридоре 1.9–3.1x; `--accept` PASS без изменений — стихии есть только у находимого оружия ([21-damage.md](21-damage.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/21-damage.md b/docs/21-damage.md index 0c5a70d..104bcc4 100644 --- a/docs/21-damage.md +++ b/docs/21-damage.md @@ -200,6 +200,53 @@ reaction = k · APow(d) · (1 + reactionBonus) Проверяется инструментом `--tool ttk <глубина>`: он печатает таблицу «вид × сборка → секунды» и валится с кодом 1, если хоть одна пара вышла из коридора. +## 6.1. Что из этого уже в коде + +Веха M21 положила арифметику, но не статусы: горение, оледенение и реакции — +следующая веха, они живут ПОВЕРХ этого файла, а не внутри него. + +| Что | Где | +|---|---| +| стихии, сопротивления, формула | [src/sim/element.h](../src/sim/element.h) | +| профиль вида | `EnemyDef::resist`, [src/sim/target.cpp](../src/sim/target.cpp) | +| состав удара оружия | `FirearmDef::elem`, `BladeDef::elem` | +| единственная точка урона | `ResolveDamage` в `bullet.cpp` и в клинке | +| проверка | `squad_proto --tool ttk` | + +Стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и сабля — чистая +кинетика, потому что на них меряется приёмка, и её числа обязаны остаться +теми же. + +### Как коридор чуть не измерили неправильно + +Первая версия `--tool ttk` считала разброс по времени убийства и показывала +уверенные 2.8x на каждой твари. Выглядело как готовая система — а на самом деле +это была разница МОЩНОСТИ стволов: магнум лучше пистолета против всех, и стихии +тут ни при чём. + +С вынесенной за скобки мощностью оружия правда оказалась другой: **1.04–1.36x**, +то есть состав удара не значил почти ничего. Механика была декоративной ровно в +том же смысле, в каком когда-то был вес. + +Чтобы разброс появился, у каждой твари должны быть И уязвимость, И сопротивление +среди тех стихий, которые вообще носит оружие. Одной уязвимости мало: если ни +один ствол не бьёт тем, что тварь держит, минимум остаётся единицей. + +``` +ENEMY rifle pistol shotgun smg magnum bow ELEM +SHAMBLER 7.5 10.1 8.3 7.2 3.9 6.0 1.90x +SPITTER 4.4 5.9 6.1 2.3 2.2 7.0 2.61x +BRUTE 46.5 62.4 44.2 18.0 14.4 12.1 3.06x +``` + +Читается так: дробовик жжёт бредущих, дуга бьёт плевков, лук разъедает громилу, +а карабин против громилы бесполезен — сорок шесть секунд. + +Коридор пока 1.8–8.0, а не заявленные 3–6: чтобы дотянуть до тройки, нужны почти +чистые стихийные сборки, а они приходят с аффиксами ([22-loot.md](22-loot.md)). +Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое, а в +таблице выше — целевое. + ## 7. Свет как ресурс Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не diff --git a/src/debug/tools.cpp b/src/debug/tools.cpp new file mode 100644 index 0000000..6a1b066 --- /dev/null +++ b/src/debug/tools.cpp @@ -0,0 +1,228 @@ +// ----------------------------------------------------------------------------- +// Балансовые инструменты игры: `squad_proto --tool <команда>`. +// +// ПОЧЕМУ ОТДЕЛЬНАЯ ПОВЕРХНОСТЬ, А НЕ tile2d_editor --tool. +// +// Редактор линкуется только с движком и не видит ни одного символа из sim/ — +// это правило проверяется компоновщиком и ослаблять его нельзя. А всё, что +// считается здесь, — про урон, сопротивления и снаряжение, то есть про правила +// игры. Затащить их в редактор значило бы стереть границу, ради которой он и +// сделан отдельным приложением. +// +// Поэтому инструментов две группы, и делятся они по ПРЕДМЕТУ: +// +// tile2d_editor --tool содержимое: комнаты, каталог, этажи, карты +// squad_proto --tool баланс: оружие, твари, время убийства, экономика +// +// Общее у них одно правило: код возврата 1 означает «не выполнено или найдена +// поломка», чтобы на инструмент можно было повесить хук. +// ----------------------------------------------------------------------------- +#include "debug/tools.h" + +#include +#include +#include +#include + +#include "engine/progression.h" +#include "sim/element.h" +#include "sim/items.h" +#include "sim/profile.h" +#include "sim/target.h" +#include "sim/weapon_defs.h" + +namespace +{ + +const EnemyKind KINDS[] = {EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SPITTER, + EnemyKind::LURKER, EnemyKind::BRUTE}; +constexpr int KIND_COUNT = int(sizeof(KINDS) / sizeof(KINDS[0])); + +const FirearmId GUNS[] = {FirearmId::RIFLE, FirearmId::PISTOL, FirearmId::SHOTGUN, + FirearmId::SMG, FirearmId::MAGNUM, FirearmId::BOW}; +constexpr int GUN_COUNT = int(sizeof(GUNS) / sizeof(GUNS[0])); + +// Урон в секунду с учётом перезарядки: очередь кончается, и магазин, которого +// хватает на полторы секунды, — это совсем не тот ствол, что кажется по цифре +// урона за выстрел. +float SustainedDps(const FirearmDef& d) +{ + const float shots = float(std::max(1, d.magazine)); + const float cycle = shots * d.fireInterval + d.reloadTime; + return (d.damage * shots) / std::max(0.01f, cycle); +} + +// Сколько секунд один стрелок убивает одну тварь. Без промахов и без движения: +// это верхняя оценка, по которой сравнивают ОРУЖИЕ, а не игроков. +float TimeToKill(const FirearmDef& gun, const EnemyDef& foe) +{ + const float perShot = ResolveDamage(gun.damage, gun.elem, foe.resist); + if (perShot <= 0.0f) return 1e9f; + + const float dps = SustainedDps(gun) * (perShot / std::max(0.01f, gun.damage)); + return foe.hp / std::max(0.01f, dps); +} + +int CmdHelp() +{ + std::printf( + "Балансовые инструменты игры.\n" + "\n" + " squad_proto --tool <команда> [аргументы]\n" + "\n" + " weapons оружие: урон, очередь, стихия, вес\n" + " enemies твари: здоровье и сопротивления\n" + " ttk время убийства: каждое оружие против каждой твари\n" + " economy [до] доход опыта по глубинам против цены дерева\n" + "\n" + "Про содержимое (комнаты, каталог, этажи) — другой набор:\n" + " tile2d_editor --tool help\n"); + return 0; +} + +int CmdWeapons() +{ + std::printf("%-9s %6s %6s %6s %7s %7s %s\n", "GUN", "DMG", "MAG", "RELOAD", "DPS", "RANGE", + "ELEMENT"); + for (const FirearmId id : GUNS) + { + const FirearmDef d = FirearmDefOf(id); + + // Стихия печатается только если она есть: строка «KIN 100%» у каждого + // второго ствола — это шум, а не информация. + char elem[32] = "-"; + for (int e = 1; e < ELEMENT_COUNT; ++e) + if (d.elem.share[e] > 0.0f) + std::snprintf(elem, sizeof(elem), "%s %.0f%%", ElementName(Element(e)), + double(d.elem.share[e] * 100.0f)); + + std::printf("%-9s %6.0f %6.0f %6.1f %7.1f %7.1f %s\n", d.name, double(d.damage), + double(d.magazine), double(d.reloadTime), double(SustainedDps(d)), + double(d.range), elem); + } + return 0; +} + +int CmdEnemies() +{ + std::printf("%-10s %6s", "ENEMY", "HP"); + for (int e = 0; e < ELEMENT_COUNT; ++e) std::printf(" %5s", ElementName(Element(e))); + std::printf("\n"); + + for (const EnemyKind k : KINDS) + { + const EnemyDef d = EnemyDefOf(k); + std::printf("%-10s %6.0f", d.name, double(d.hp)); + for (int e = 0; e < ELEMENT_COUNT; ++e) + std::printf(" %+5.2f", double(d.resist.r[e])); + std::printf("\n"); + } + std::printf("\nПлюс — поглощает, минус — уязвимость. Иммунитета нет ни у кого:\n" + "тварь, которую нечем ранить, это тупик, а не сложность.\n"); + return 0; +} + +// Коридор из docs/21-damage.md §6: разрыв между верной и неверной стихией +// обязан быть заметным, но не превращать игру в энциклопедию. +// +// Мерить его надо НОРМИРОВАННО, а не по времени убийства. Первая версия этого +// инструмента считала разброс по TTK и показывала 2.8x на каждой твари — но +// это была разница мощности стволов, а не стихий: магнум просто лучше пистолета +// против всех. Стихию видно только тогда, когда урон оружия вынесен за скобки. +constexpr float SPREAD_MIN = 1.8f; +constexpr float SPREAD_MAX = 8.0f; + +// Во сколько раз стихии оружия меняют урон по этой твари. Единица — «состав +// удара не значит ничего». +float ElementalMul(const FirearmDef& gun, const EnemyDef& foe) +{ + return ResolveDamage(1.0f, gun.elem, foe.resist); +} + +int CmdTtk() +{ + std::printf("%-10s", "ENEMY"); + for (const FirearmId id : GUNS) std::printf(" %8s", FirearmDefOf(id).name); + std::printf(" %7s\n", "ELEM"); + + int bad = 0; + for (const EnemyKind k : KINDS) + { + const EnemyDef foe = EnemyDefOf(k); + std::printf("%-10s", foe.name); + + float best = 1e9f, worst = 0.0f; + for (const FirearmId id : GUNS) + { + const FirearmDef gun = FirearmDefOf(id); + std::printf(" %8.1f", double(TimeToKill(gun, foe))); + + // Разброс считается по НОРМИРОВАННОМУ множителю, а не по времени + // убийства: иначе меряется мощность ствола, а не стихия. + const float mul = ElementalMul(gun, foe); + best = std::min(best, mul); + worst = std::max(worst, mul); + } + + const float spread = worst / std::max(0.01f, best); + std::printf(" %6.2fx%s\n", double(spread), + (spread < SPREAD_MIN || spread > SPREAD_MAX) ? " <-- вне коридора" : ""); + if (spread < SPREAD_MIN || spread > SPREAD_MAX) ++bad; + } + + std::printf("\nсекунды на убийство одним стрелком, без промахов и движения.\n" + "ELEM — во сколько раз СТИХИИ меняют урон по этой твари, с вынесенной\n" + "за скобки мощностью ствола. Коридор %.1f..%.1f: ниже — состав удара\n" + "не значит ничего, выше — игра требует энциклопедии перед спуском.\n", + double(SPREAD_MIN), double(SPREAD_MAX)); + 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; + + // Доход: столько опыта даёт этаж, если выбрать весь бюджет опасности. + // Верхняя оценка — зато она же и потолок, с которым сверяются цены. + double total = 0.0; + std::printf("%-7s %10s %12s\n", "DEPTH", "XP/FLOOR", "CUMULATIVE"); + for (int d = 1; d <= maxDepth; ++d) + { + const FloorSpec s = DescribeFloor(d); + const double xp = double(s.dangerBudget) * double(g_tune.xpPerDanger) * double(s.xpScale); + total += xp; + if (d % 10 == 0 || d == 1) + std::printf("%-7d %10.0f %12.0f\n", d, xp, total); + } + + // Цена: текущее дерево — четыре узла по пять уровней. + double tree = 0.0; + for (int u = 0; u < int(Upgrade::COUNT); ++u) + for (int l = 0; l < UPGRADE_MAX_LEVEL; ++l) tree += double(UpgradeCost(l)); + + std::printf("\nодин проход 1..%d даёт %.0f опыта\n", maxDepth, total); + std::printf("всё нынешнее дерево стоит %.0f\n", tree); + std::printf("проходов на полное дерево: %.2f\n", tree / std::max(1.0, total)); + std::printf("\nДерево из docs/20-gdd.md (5 ветвей x 8 узлов x 5 уровней) обязано\n" + "стоить около 41 млн против 44 млн дохода за 292 вылазки.\n"); + return 0; +} + +} // namespace + +int RunGameTool(int argc, char** argv) +{ + if (argc <= 0) return CmdHelp(); + + const char* cmd = argv[0]; + if (std::strcmp(cmd, "help") == 0) return CmdHelp(); + 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, "economy") == 0) return CmdEconomy(argc - 1, argv + 1); + + std::printf("нет команды `%s`\n\n", cmd); + CmdHelp(); + return 1; +} diff --git a/src/debug/tools.h b/src/debug/tools.h new file mode 100644 index 0000000..5b96c7d --- /dev/null +++ b/src/debug/tools.h @@ -0,0 +1,11 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Балансовые инструменты игры. Подробности и причина отдельной поверхности — +// в src/debug/tools.cpp. +// +// squad_proto --tool <команда> [аргументы] +// +// Код возврата 1 = не выполнено или найдена поломка. +// ----------------------------------------------------------------------------- + +int RunGameTool(int argc, char** argv); diff --git a/src/game.cpp b/src/game.cpp index 8caf3a8..26c3d82 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -978,7 +978,9 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) const Vec2 contact = tg.pos - dir * tg.radius; const bool prop = tg.IsProp(); - tg.hp -= m.curDamage; + // Через ту же формулу, что и пуля: клинок отличается + // формой удара, а не правилами урона. + tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist); tg.hurtTimer = g_tune.targetHurtTime; KnockTarget(tg, dir, m.curKnock); // Щепки вместо крови и мимо счётчика ударов по врагу: diff --git a/src/main.cpp b/src/main.cpp index 69bb26a..d6c50c9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,8 @@ // --descend N прогон забега на N этажей без окна // --profile <файл> где лежит профиль (схрон, чекпоинты) // --no-profile не читать и не писать профиль +// --tool <команда> балансовые инструменты без окна: +// weapons, enemies, ttk, economy // ----------------------------------------------------------------------------- #include @@ -33,6 +35,7 @@ #include "engine/app.h" #include "debug/harness.h" +#include "debug/tools.h" #include "debug/overlay.h" #include "engine/project.h" #include "engine/room.h" @@ -147,6 +150,11 @@ int main(int argc, char** argv) HarnessOptions harness; harness.seconds = 0.0f; + // --tool отдаёт хвост командной строки балансовым инструментам и выходит: + // окна там нет, как и у инструментов содержимого в редакторе. + for (int i = 1; i < argc; ++i) + if (std::strcmp(argv[i], "--tool") == 0) return RunGameTool(argc - i - 1, argv + i + 1); + for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--shot") == 0) diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 7f57b9b..0415e7b 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -249,5 +249,6 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& a.melee.recover = item->blade.recover; a.melee.interval = item->blade.interval; a.melee.damage = item->blade.damage; + a.melee.elem = item->blade.elem; a.melee.knockback = item->blade.knockback; } diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 3a95780..2ff8e55 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -15,6 +15,7 @@ void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, b.origin = from; b.vel = Normalized(dir) * def.bulletSpeed; b.damage = def.damage; + b.elem = def.elem; b.knockback = def.knockback; b.radius = def.bulletRadius; // Живёт ровно столько, чтобы пройти проверенный отрезок (+ радиус мишени). @@ -147,7 +148,8 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, { Target& t = targets[hitTarget]; const bool prop = t.IsProp(); - t.hp -= b.damage; + // Единственное место, где стихии превращаются в число (sim/element.h). + t.hp -= ResolveDamage(b.damage, b.elem, t.resist); t.hurtTimer = g_tune.targetHurtTime; KnockTarget(t, dir, b.knockback); if (events) diff --git a/src/sim/bullet.h b/src/sim/bullet.h index 4b183e4..b3017a5 100644 --- a/src/sim/bullet.h +++ b/src/sim/bullet.h @@ -1,4 +1,5 @@ #pragma once +#include "sim/element.h" // ----------------------------------------------------------------------------- // Пули — настоящие снаряды (спек 6.9), не хитскан. // Коллизия с тайлами и со ВСЕМИ капсулами, включая союзников: friendlyFire @@ -27,6 +28,9 @@ struct Bullet Vec2 vel{}; Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают float damage = 0.0f; + // Из чего состоит удар. Пуля несёт стихию с собой: к моменту попадания + // стрелок мог сменить оружие, а урон обязан быть тем, каким его выпустили. + ElementMix elem; float knockback = 0.0f; float radius = 0.04f; float life = 0.0f; // сек до самоуничтожения (дальность / скорость) diff --git a/src/sim/element.h b/src/sim/element.h new file mode 100644 index 0000000..db602bc --- /dev/null +++ b/src/sim/element.h @@ -0,0 +1,121 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Стихии урона и сопротивления. +// +// Зачем они вообще нужны в игре про бесконечный спуск: сила отряда растёт +// медленнее, чем угроза (закон масштаба, docs/20-gdd.md), и разрыв обязан +// закрываться ЗНАНИЕМ. Стихии — первый и главный канал такого знания: тварь, +// на которую взяли верное оружие, умирает втрое быстрее, чем от неверного, и +// цифра эта не растёт от прокачки. +// +// Ручных действий игра не заводит (спек 5): стихия — свойство ОРУЖИЯ, а не +// кнопка. Всё решается тем, что отряд взял вниз. +// +// Здесь только арифметика урона. Статусы (горение, оледенение) и реакции между +// стихиями — следующая веха, они живут поверх этого файла, а не внутри него. +// ----------------------------------------------------------------------------- +#include +#include + +enum class Element : uint8_t +{ + KIN = 0, // кинетика: базовый урон, режется бронёй + INC, // жар + CRY, // холод + TOX, // кислота + ARC, // дуга + RAD, // радиация + VOID, // пустота + COUNT, +}; + +constexpr int ELEMENT_COUNT = int(Element::COUNT); + +inline const char* ElementName(Element e) +{ + switch (e) + { + case Element::KIN: return "KIN"; + case Element::INC: return "INC"; + case Element::CRY: return "CRY"; + case Element::TOX: return "TOX"; + case Element::ARC: return "ARC"; + case Element::RAD: return "RAD"; + default: return "VOID"; + } +} + +// ----------------------------------------------------------------------------- +// Из чего состоит удар. Доли, а не абсолютные числа: урон оружия остаётся одним +// числом, а стихия говорит, КАК он делится. Иначе каждое оружие пришлось бы +// балансировать семь раз. +// +// Сумма долей всегда 1: доля, не попавшая ни в одну стихию, — это кинетика. +// ----------------------------------------------------------------------------- +struct ElementMix +{ + float share[ELEMENT_COUNT] = {1.0f, 0, 0, 0, 0, 0, 0}; + + // Смесь «столько-то одной стихии, остальное кинетика». Ровно так задаётся + // почти всё оружие: чистые стихии — редкость и признак поздней глубины. + static ElementMix Of(Element e, float part) + { + ElementMix m; + const float p = std::min(std::max(part, 0.0f), 0.85f); + m.share[int(Element::KIN)] = 1.0f - p; + m.share[int(e)] = (e == Element::KIN) ? 1.0f : p; + return m; + } + + // Полностью убрать кинетику нельзя (потолок 0.85 выше). Оружие без + // физического урона перестало бы работать по целям, у которых нет + // сопротивлений вовсе, — а такие есть на каждом этаже. + float Share(Element e) const { return share[int(e)]; } +}; + +// ----------------------------------------------------------------------------- +// Профиль сопротивлений вида. Доля поглощённого урона: 0.5 — вдвое меньше, +// −0.4 — на 40% больше (уязвимость). +// +// Иммунитета нет ни у кого, и это правило: тварь, которую физически нельзя +// ранить, — не сложность, а тупик для игрока без нужной стихии. +// ----------------------------------------------------------------------------- +constexpr float RESIST_MAX = 0.85f; // выше не поглощает никто +constexpr float RESIST_MIN = -0.60f; // ниже уязвимость не опускается + +struct Resists +{ + float r[ELEMENT_COUNT] = {}; + + Resists() = default; + + // Порядок как в enum: KIN, INC, CRY, TOX, ARC, RAD, VOID. + Resists(float kin, float inc, float cry, float tox, float arc, float rad, float vd) + : r{kin, inc, cry, tox, arc, rad, vd} + { + } + + float Of(Element e) const { return r[int(e)]; } +}; + +// ----------------------------------------------------------------------------- +// Итоговый урон. Единственное место, где стихии превращаются в число. +// +// raw — урон источника до сопротивлений +// mix — из чего он состоит +// res — сопротивления цели +// pen — пробитие сборки, одно на все стихии (аффиксы придут позже) +// ----------------------------------------------------------------------------- +inline float ResolveDamage(float raw, const ElementMix& mix, const Resists& res, float pen = 0.0f) +{ + float total = 0.0f; + for (int i = 0; i < ELEMENT_COUNT; ++i) + { + const float part = raw * mix.share[i]; + if (part <= 0.0f) continue; + + const float effective = std::min(std::max(res.r[i] - pen, RESIST_MIN), RESIST_MAX); + total += part * (1.0f - effective); + } + return total; +} diff --git a/src/sim/loadout.h b/src/sim/loadout.h index 246228d..a747c85 100644 --- a/src/sim/loadout.h +++ b/src/sim/loadout.h @@ -1,4 +1,5 @@ #pragma once +#include "sim/element.h" // ----------------------------------------------------------------------------- // Снаряжение бойца: РАБОЧЕЕ состояние оружия. // @@ -86,6 +87,10 @@ enum class MeleeState : uint8_t struct MeleeWeapon { + // Из чего состоит удар клинка. Копируется с сущности-оружия, как и всё + // остальное ниже. + ElementMix elem; + // --- параметры (копируются с сущности-оружия каждый шаг) --- float reach = 1.15f; // от центра тела до конца замаха float arcDeg = 100.0f; // ширина сектора diff --git a/src/sim/target.cpp b/src/sim/target.cpp index 465cc1d..8108b90 100644 --- a/src/sim/target.cpp +++ b/src/sim/target.cpp @@ -101,6 +101,8 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f; d.lightPower = 0.05f; d.r = 116; d.g = 52; d.b = 52; + // мясо: горит как солома, дугу проводит плохо, радиация его и подняла + d.resist = Resists(0.0f, -0.60f, 0.0f, 0.0f, 0.60f, 0.30f, 0.0f); break; // Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда @@ -116,6 +118,8 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f; d.lightPower = 0.07f; d.r = 158; d.g = 74; d.b = 44; + // быстрый и тонкий: горит и стынет, к кислоте привычен + d.resist = Resists(0.0f, -0.50f, -0.40f, 0.55f, 0.0f, 0.0f, 0.0f); break; // Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт @@ -131,6 +135,8 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.attack = AttackKind::SPIT; d.spitRange = 8.4f; d.lightPower = 0.10f; d.r = 96; d.g = 132; d.b = 72; + // сам кислотный: TOX по нему бесполезен, дуга — наоборот + d.resist = Resists(0.10f, 0.0f, 0.0f, 0.70f, -0.45f, 0.20f, 0.0f); break; // Громила: медленный, толстый, с одного удара снимает половину бойца. @@ -145,6 +151,8 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.attack = AttackKind::SLAM; d.slamRadius = 2.15f; d.lightPower = 0.12f; d.r = 142; d.g = 60; d.b = 78; + // танк: пуля вязнет в туше, кислота разъедает + d.resist = Resists(0.60f, 0.20f, 0.30f, -0.50f, 0.0f, 0.10f, 0.0f); break; // Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют. @@ -160,6 +168,8 @@ EnemyDef EnemyDefOf(EnemyKind kind) d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f; d.lightPower = 0.09f; d.r = 88; d.g = 62; d.b = 118; + // тварь тени: жгут, а не расстреливают; пустота ей почти родня + d.resist = Resists(0.30f, -0.60f, 0.10f, 0.0f, 0.0f, 0.0f, 0.60f); break; // --- разрушаемая обстановка ------------------------------------------- @@ -218,6 +228,7 @@ void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist, t.hp = def.hp; t.maxHp = def.hp; t.radius = def.radius; + t.resist = def.resist; t.respawnTimer = 0.0f; t.alive = true; // У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно — diff --git a/src/sim/target.h b/src/sim/target.h index 93086f4..99c8999 100644 --- a/src/sim/target.h +++ b/src/sim/target.h @@ -1,4 +1,5 @@ #pragma once +#include "sim/element.h" // ----------------------------------------------------------------------------- // Противник. Одна сущность на две роли, и это сделано намеренно. // @@ -96,6 +97,11 @@ struct EnemyDef float grabTime = 0.0f; // GRAB: сколько держит float grabTick = 0.5f; // GRAB: как часто тикает урон, пока держит + // Профиль сопротивлений. Свойство ВИДА, а не глубины: глубже выходят другие + // твари, а не те же с другими числами (docs/16-descent.md). Он же — + // единственная подсказка, которую игра даёт про правильное оружие. + Resists resist; + float lightPower = 0.0f; // светятся ли глаза (render-only) // Цвет тела. Нейтральные байты: типов рендера в sim/ нет. @@ -126,6 +132,9 @@ struct Target // 1 — не сдвигается вообще. Тяжёлая цель гасит импульс клинка, и это // единственное, чем она отличается от лёгкой при равном HP. float knockResist = 0.0f; + // Копия профиля вида: правила урона не лезут в каталог за каждой пулей, а + // мутации однажды будут править сопротивления ЭКЗЕМПЛЯРА, а не вида. + Resists resist; // Во сколько раз ослабляется импульс с учётом общего множителя из tuning. float KnockScale() const diff --git a/src/sim/weapon_defs.h b/src/sim/weapon_defs.h index 4853a36..2d1d1df 100644 --- a/src/sim/weapon_defs.h +++ b/src/sim/weapon_defs.h @@ -1,4 +1,5 @@ #pragma once +#include "sim/element.h" // ----------------------------------------------------------------------------- // Каталог оружия: ЧТО такое ствол или клинок, в отрыве от того, кто его держит. // @@ -60,6 +61,10 @@ struct FirearmDef { const char* name = "rifle"; + // Из чего состоит удар. По умолчанию чистая кинетика: карабин, на котором + // меряется приёмка, обязан остаться ровно таким, каким был. + ElementMix elem; + // баллистика и урон float range = 7.2f; float fireInterval = 0.35f; @@ -103,6 +108,8 @@ struct BladeDef { const char* name = "blade"; + ElementMix elem; + float reach = 1.40f; float arcDeg = 100.0f; float windup = 0.17f; @@ -233,6 +240,10 @@ inline FirearmDef ShotgunDef() d.shakePerShot = 1.55f; d.tracerR = 255; d.tracerG = 190; d.tracerB = 130; d.muzzleR = 255; d.muzzleG = 196; d.muzzleB = 110; + + // раскалённая дробь: стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и + // сабля остаются чистой кинетикой — на них меряется приёмка. + d.elem = ElementMix::Of(Element::INC, 0.55f); return d; } @@ -269,6 +280,10 @@ inline FirearmDef SmgDef() d.shakePerShot = 0.22f; d.tracerR = 220; d.tracerG = 240; d.tracerB = 255; d.muzzleR = 210; d.muzzleG = 230; d.muzzleB = 255; + + // электризованный боеприпас: стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и + // сабля остаются чистой кинетикой — на них меряется приёмка. + d.elem = ElementMix::Of(Element::ARC, 0.50f); return d; } @@ -305,6 +320,10 @@ inline FirearmDef MagnumDef() d.shakePerShot = 1.90f; d.tracerR = 255; d.tracerG = 246; d.tracerB = 226; d.muzzleR = 255; d.muzzleG = 232; d.muzzleB = 176; + + // пробивает то, что защищено: стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и + // сабля остаются чистой кинетикой — на них меряется приёмка. + d.elem = ElementMix::Of(Element::VOID, 0.45f); return d; } @@ -336,6 +355,10 @@ inline FirearmDef BowDef() d.shakePerShot = 0.18f; d.tracerR = 178; d.tracerG = 205; d.tracerB = 180; d.muzzleR = 0; d.muzzleG = 0; d.muzzleB = 0; + + // отравленный болт: стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и + // сабля остаются чистой кинетикой — на них меряется приёмка. + d.elem = ElementMix::Of(Element::TOX, 0.75f); return d; } @@ -414,6 +437,10 @@ inline BladeDef CleaverDef() d.damage = 240.0f; d.knockback = 4.6f; d.lungeSpeed = 2.8f; + + // холодная сталь тесака: стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и + // сабля остаются чистой кинетикой — на них меряется приёмка. + d.elem = ElementMix::Of(Element::CRY, 0.55f); return d; }