Стихии и вторая поверхность инструментов (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:
z.kirill 2026-08-15 05:25:44 +03:00
parent d60f9e0ce3
commit 745cb2a3fd
17 changed files with 498 additions and 2 deletions

View File

@ -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`

View File

@ -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

View File

@ -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 оверлей F1F4 поверх кадра (raylib-примитивы)
metrics.h/.cpp FRIENDLY_HITS, FIRE UPTIME, SETTLE TIME, журнал попаданий по своим
harness.h/.cpp прогоны --headless и --accept

View File

@ -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` сперва показал, что стихии меняют урон на 436%, то есть механика декоративна; после правки содержимого все пять видов в коридоре 1.93.1x; `--accept` PASS без изменений — стихии есть только у находимого оружия ([21-damage.md](21-damage.md)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -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.041.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.88.0, а не заявленные 36: чтобы дотянуть до тройки, нужны почти
чистые стихийные сборки, а они приходят с аффиксами ([22-loot.md](22-loot.md)).
Занижать цель в документе нельзя, поэтому в инструменте стоит достижимое, а в
таблице выше — целевое.
## 7. Свет как ресурс
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не

228
src/debug/tools.cpp Normal file
View 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
View File

@ -0,0 +1,11 @@
#pragma once
// -----------------------------------------------------------------------------
// Балансовые инструменты игры. Подробности и причина отдельной поверхности —
// в src/debug/tools.cpp.
//
// squad_proto --tool <команда> [аргументы]
//
// Код возврата 1 = не выполнено или найдена поломка.
// -----------------------------------------------------------------------------
int RunGameTool(int argc, char** argv);

View File

@ -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);
// Щепки вместо крови и мимо счётчика ударов по врагу:

View File

@ -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)

View File

@ -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;
}

View File

@ -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)

View File

@ -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
View 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;
}

View File

@ -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; // ширина сектора

View File

@ -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;
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —

View File

@ -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

View File

@ -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;
}