Стихии и вторая поверхность инструментов (M21, E2)
СТИХИИ (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 <noreply@anthropic.com>
This commit is contained in:
parent
d60f9e0ce3
commit
745cb2a3fd
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -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. Свет как ресурс
|
||||
|
||||
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не
|
||||
|
||||
228
src/debug/tools.cpp
Normal file
228
src/debug/tools.cpp
Normal file
@ -0,0 +1,228 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// Балансовые инструменты игры: `squad_proto --tool <команда>`.
|
||||
//
|
||||
// ПОЧЕМУ ОТДЕЛЬНАЯ ПОВЕРХНОСТЬ, А НЕ tile2d_editor --tool.
|
||||
//
|
||||
// Редактор линкуется только с движком и не видит ни одного символа из sim/ —
|
||||
// это правило проверяется компоновщиком и ослаблять его нельзя. А всё, что
|
||||
// считается здесь, — про урон, сопротивления и снаряжение, то есть про правила
|
||||
// игры. Затащить их в редактор значило бы стереть границу, ради которой он и
|
||||
// сделан отдельным приложением.
|
||||
//
|
||||
// Поэтому инструментов две группы, и делятся они по ПРЕДМЕТУ:
|
||||
//
|
||||
// tile2d_editor --tool содержимое: комнаты, каталог, этажи, карты
|
||||
// squad_proto --tool баланс: оружие, твари, время убийства, экономика
|
||||
//
|
||||
// Общее у них одно правило: код возврата 1 означает «не выполнено или найдена
|
||||
// поломка», чтобы на инструмент можно было повесить хук.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include "debug/tools.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#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;
|
||||
}
|
||||
11
src/debug/tools.h
Normal file
11
src/debug/tools.h
Normal file
@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Балансовые инструменты игры. Подробности и причина отдельной поверхности —
|
||||
// в src/debug/tools.cpp.
|
||||
//
|
||||
// squad_proto --tool <команда> [аргументы]
|
||||
//
|
||||
// Код возврата 1 = не выполнено или найдена поломка.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
int RunGameTool(int argc, char** argv);
|
||||
@ -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);
|
||||
// Щепки вместо крови и мимо счётчика ударов по врагу:
|
||||
|
||||
@ -22,6 +22,8 @@
|
||||
// --descend N прогон забега на N этажей без окна
|
||||
// --profile <файл> где лежит профиль (схрон, чекпоинты)
|
||||
// --no-profile не читать и не писать профиль
|
||||
// --tool <команда> балансовые инструменты без окна:
|
||||
// weapons, enemies, ttk, economy
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <raylib.h>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; // сек до самоуничтожения (дальность / скорость)
|
||||
|
||||
121
src/sim/element.h
Normal file
121
src/sim/element.h
Normal file
@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
// -----------------------------------------------------------------------------
|
||||
// Стихии урона и сопротивления.
|
||||
//
|
||||
// Зачем они вообще нужны в игре про бесконечный спуск: сила отряда растёт
|
||||
// медленнее, чем угроза (закон масштаба, docs/20-gdd.md), и разрыв обязан
|
||||
// закрываться ЗНАНИЕМ. Стихии — первый и главный канал такого знания: тварь,
|
||||
// на которую взяли верное оружие, умирает втрое быстрее, чем от неверного, и
|
||||
// цифра эта не растёт от прокачки.
|
||||
//
|
||||
// Ручных действий игра не заводит (спек 5): стихия — свойство ОРУЖИЯ, а не
|
||||
// кнопка. Всё решается тем, что отряд взял вниз.
|
||||
//
|
||||
// Здесь только арифметика урона. Статусы (горение, оледенение) и реакции между
|
||||
// стихиями — следующая веха, они живут поверх этого файла, а не внутри него.
|
||||
// -----------------------------------------------------------------------------
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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; // ширина сектора
|
||||
|
||||
@ -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;
|
||||
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user