Дерево улучшений вместо четырёх кнопок (M32)

Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены
целиком. Выбора в них не было: купить в итоге можно было всё, порядок ни
на что не влиял, и через две вылазки экран превращался в кнопку
"продолжить".

Дерево: пять ветвей по восемь узлов, у каждого пять уровней. Ветви - это
пять способов закрыть разрыв из закона масштаба: стволы, клинки, стихии,
снабжение, отряд. Ветвь открывается цепочкой - узел доступен, когда взят
предыдущий в этой же ветви. Свободная сетка читалась бы как список;
цепочка читается как путь.

Ни один узел не заводит нового канала влияния: каждый кладёт своё число
туда, куда уже кладут снаряжение и аффиксы. Дерево будет расти, и первый
же "особенный" узел со своей веткой в коде превратил бы SyncLoadout в
свалку.

ЭКОНОМИКА. --tool economy поймал, что первая версия дерева стоит 9%
одного прохода 1..100: опыт растёт с глубиной квадратично, а дерево с
фиксированными ценами выкупается целиком за половину глубокого прохода, и
выбор кончается к сороковому этажу навсегда.

Введено общее удорожание: каждая вложенная точка поднимает цену всех
следующих (цена = база · 1.5^уровень · (1 + 0.15 · вложено)). Полное
дерево стоит 34 млн против 41 млн, заложенных в дизайне, то есть около
шести с половиной полных проходов.

Старые покупки из профиля читаются, но не применяются: потраченный на них
опыт возвращается в банк. Молча съесть чужие двадцать часов нельзя.

Проверка меряет ПРОВОДКУ по всем пяти ветвям, а не таблицу узлов:

  дерево: куплено уровней 45, закрытый узел закрыт
          урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22
          парирование 0.34 -> 0.49, свет 420 -> 620

Попутно: в проекте hollow бегуны и хваты уехали с первых этажей глубже -
там одиночка с ножом гиб на первом же этаже по той же причине, по которой
это было в основном проекте.

Формат профиля 7: строки tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 19:24:50 +03:00
parent 9bcb5ca013
commit 3ebd1f96fc
20 changed files with 731 additions and 201 deletions

View File

@ -88,6 +88,11 @@
«симуляция → эффекты», и в headless его нет вовсе: начни от него зависеть —
игра станет вести себя по-разному с окном и без. Триггеры срабатывают в месте
урона, а не по журналу ([21-damage.md](docs/21-damage.md)).
- **Дерево улучшений — данные, а не код.** Сорок узлов лежат таблицей в
[sim/tree.h](src/sim/tree.h), и каждый кладёт своё число в УЖЕ существующий
канал (статы бойца, множители оружия, ресурсы вылазки). Узел, которому нужен
свой канал, в дерево не берут, пока канал не появился сам
([17-extraction.md](docs/17-extraction.md)).
- **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а
обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции

View File

@ -50,6 +50,8 @@
| **M30** | личинка для первых этажей (бредущий уехал на третий), парирование клинком с ответным ударом и отбиванием плевков, инструмент `--wall` | `--wall` нашёл, что `PlaceSquad` воскрешал незанятые слоты: прежний замер «одиночка выжил на первом этаже» был боем впятером, а на самом деле одиночка с ножом проигрывал двум бредущим (210 здоровья против ножа); стена растёт с силой отряда — D7 у одиночки против D30 у пятерых; парирование проверено разменом целиком: нож 34% против тесака 8%, отбив снимает урон, ответ проходит, откат не сбрасывается, стоя столбом не отбивает; `--accept` PASS ([11-melee.md](11-melee.md)) |
| **M32** | дерево улучшений вместо четырёх покупок: пять ветвей по восемь узлов, пять уровней у каждого, цепочка открытия, цена растёт от вложенного; старые покупки возвращают опыт в банк | `--tool economy` поймал, что первая версия дерева стоит 9% одного прохода 1..100 — то есть выбор кончался к сороковому этажу; введено общее удорожание (каждая вложенная точка поднимает цену следующих), стало 34 млн против 41 млн заложенных; проводка проверена по всем пяти ветвям: урон 12.0 → 14.4, здоровье 100 → 150, скорость 1.06 → 1.22, парирование 0.34 → 0.49, свет 420 → 620, закрытый узел не покупается; `--accept` PASS ([17-extraction.md](17-extraction.md)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -279,35 +279,66 @@ stash saber plate vest smg bow charm lantern spurs
Делать `NUM_AGENTS` динамическим не стали: это сто с лишним мест ради числа,
которое всё равно упирается в пять.
## Во что вкладывается опыт
## Во что вкладывается опыт: дерево
![экран улучшений](ui-upgrades.png)
![дерево улучшений](ui-tree.png)
Опыт копился с вехи M12 и до сих пор был честной заглушкой: число, которое
ни на что не влияет. Теперь он тратится — на четыре улучшения отряда.
Четыре строки покупок (патроны, переноска, обзор, живучесть) удалены целиком.
Выбора в них не было: купить в итоге можно было всё, порядок ни на что не влиял,
и через две вылазки экран превращался в кнопку «продолжить».
| | что даёт | почему это |
Дерево ([sim/tree.h](../src/sim/tree.h)) — **пять ветвей по восемь узлов**, у
каждого узла пять уровней. Разница с прежним ровно одна: **выбор есть, потому
что всё не взять**.
| Ветвь | Про что | Примеры узлов |
|---|---|---|
| AMMO RESERVE | +20 патронов на вылазку | |
| CARRY LIMIT | +1.5 до перегруза | |
| SIGHT RANGE | +6% обзора | |
| VIGOR | +12 здоровья | |
| FIREARMS | огонь: урон, пробитие, боезапас | STEADY HANDS, HARD ROUNDS, EXECUTIONER |
| BLADES | ближний бой: урон, парирование, темп | SIDESTEP, DUELLIST, CLEAVE |
| ELEMENTS | реакции и статусы | CATALYST, DEEP BURN, CHAIN THEORY |
| SUPPLY | ресурсы вылазки | LONG WICK, PACK RAT, BLACK BOX |
| SQUAD | живучесть и люди | VIGOR, PLATING, LAST STAND |
Все четыре — **ресурсные**, и ни одного силового. Игра про исчерпание, и
вкладываться в ней надо в то, что кончается: отряд, который бьёт вдвое сильнее,
перестаёт бояться, а страх тут единственная механика, которую нельзя
восстановить балансом.
Ветвь открывается **цепочкой**: узел доступен, когда предыдущий в его ветви взят
хотя бы на уровень. Свободная сетка читалась бы как список; цепочка читается как
путь — «я иду в стихии» видно по дереву, а не по таблице цифр.
Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре остаётся
снаряжение, и второе личное измерение превратило бы сборы в бухгалтерию на
пятерых.
**Ни один узел не заводит нового канала влияния.** Каждый кладёт своё число туда,
куда уже кладут снаряжение и аффиксы: статы бойца, множители оружия, ресурсы
вылазки. Правило то же, что и у предметов, и здесь оно важнее вдвойне — дерево
растёт, и первый же «особенный» узел со своей веткой в коде превратил бы
`SyncLoadout` в свалку.
Опыт зачисляется только за **успешную эвакуацию** (`FinishRun`): вайп не
приносит ничего. Это та же ставка, что и с добычей.
### Цена растёт трижды
Приёмки это не касается вообще: улучшения живут в профиле, а `--accept` его не
подключает — там нет ни снаряжения, ни улучшений, и критерии меряются на том
же, на чём мерялись всегда.
```
цена = база(узла) · 1.5^уровень · (1 + 0.15 · всего_вложеноочек)
```
Третий множитель — главный, и без него дерево умирает. Опыт растёт с глубиной
квадратично (`--tool economy`: сотый этаж даёт 190 тысяч), и дерево с
фиксированными ценами выкупается целиком за половину одного глубокого прохода.
Первая версия так и вышла: **всё дерево стоило 9% одного прохода 1..100** — то
есть к сороковому этажу выбор кончался навсегда. С удорожанием полное дерево
стоит 34 млн против 41 млн, заложенных в [20-gdd.md](20-gdd.md), то есть около
шести с половиной полных проходов.
### Что стало со старыми покупками
Строка `upgrades` в профиле читается, но не применяется: потраченный на неё опыт
**возвращается в банк**. Игрок вложит его заново, уже в узлы. Молча съесть чужие
покупки нельзя — это те же двадцать часов.
Проверяется в `--descend` проводкой до боя, а не таблицей узлов:
```
дерево: куплено уровней 45, закрытый узел закрыт
урон ствола 12.0 -> 14.4, здоровье 100 -> 150, скорость 1.06 -> 1.22
парирование 0.34 -> 0.49, свет 420 -> 620
```
Узел, который красиво нарисован и ничего не делает, выглядит снаружи ровно как
работающий. На этом уже дважды попались вес и стихии.
## Итог забега

BIN
docs/ui-tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View File

@ -8,10 +8,10 @@
# никого. Это граница ИГРЫ, а не движка.
# --- твари -------------------------------------------------------------------
entity rusher class=monster depth=1 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
entity rusher class=monster depth=4 danger=1.3 radius=0.27 color=158,74,44 sprite=rusher_walk name=RUSHER
entity larva class=monster depth=1..4 danger=0.7 radius=0.28 color=132,120,78 sprite=shambler_walk name=LARVA
entity shambler class=monster depth=3 danger=1.0 radius=0.34 color=116,52,52 sprite=shambler_walk name=SHAMBLER
entity lurker class=monster depth=2 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
entity lurker class=monster depth=5 danger=2.6 radius=0.30 color=88,62,118 sprite=lurker_walk name=LURKER
entity spitter class=monster depth=3 danger=2.0 radius=0.31 color=96,132,72 sprite=spitter_walk name=SPITTER
entity brute class=monster depth=4 danger=4.5 radius=0.46 color=142,60,78 sprite=brute_walk name=BRUTE

View File

@ -11,6 +11,7 @@
#include "game.h"
#include "sim/affix.h"
#include "sim/craft.h"
#include "sim/tree.h"
#include "sim/enemy.h"
#include "sim/loot.h"
#include "sim/trigger.h"
@ -1239,7 +1240,7 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
// стояли в нулях весь прогон, и проверка «0 == 0» три вехи подряд
// подтверждала работу того, чего в файле не было вовсе.
const Profile saved = profile;
for (int u = 0; u < int(Upgrade::COUNT); ++u) profile.upgrade[u] = 1 + u % UPGRADE_MAX_LEVEL;
for (int i = 0; i < TREE_NODES; i += 7) profile.node[i] = 1 + (i % 3);
profile.squadSize = 3;
profile.runs = 11;
profile.extractions = 7;
@ -1295,8 +1296,8 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
same(back.wipes == profile.wipes, "счётчик вайпов");
same(int(back.bankedXp) == int(profile.bankedXp), "опыт в банке");
same(back.StashCount() == profile.StashCount(), "схрон");
for (int u = 0; u < int(Upgrade::COUNT); ++u)
same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u)));
for (int i = 0; i < TREE_NODES; ++i)
same(back.node[i] == profile.node[i], TreeNodeAt(i).name);
// Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через ItemId
// здесь молчаливо прошло бы: неявная конверсия видит только вид
// вещи, и реликт, доехавший до профиля обычным, выглядел бы как
@ -2173,6 +2174,108 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
}
}
// -------------------------------------------------------------------------
// Дерево улучшений. Проверяется ПРОВОДКА до боя, а не таблица узлов: узел,
// который красиво нарисован и ничего не делает, выглядит снаружи ровно как
// работающий. На этом уже дважды попались вес и стихии.
//
// Берётся по узлу из каждой ветви, вкачивается до потолка, и сверяются
// числа НА БОЙЦЕ и в вылазке — до и после.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;
const Profile saved = pr;
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a0 = game.squad.agents[0];
const float dmgBare = a0.gun.def.damage;
const float hpBare = a0.maxHp;
const float speedBare = a0.speedMul;
const float parryBare = a0.melee.parry;
const float lightBare = game.squad.lightCharge;
// Ветвь ствола до «глубоких карманов», ветвь клинка до парирования,
// снабжение до фонаря, отряд до здоровья. Цепочка соблюдается: узел
// открывается предыдущим, и проверка обязана идти тем же путём, каким
// идёт игрок.
pr.bankedXp = 1e9f;
int bought = 0;
const int path[] = {
TreeIndex(Branch::GUN, 0), TreeIndex(Branch::GUN, 1),
TreeIndex(Branch::GUN, 2), TreeIndex(Branch::BLADE, 0),
TreeIndex(Branch::BLADE, 1), TreeIndex(Branch::SUPPLY, 0),
TreeIndex(Branch::SQUAD, 0), TreeIndex(Branch::SQUAD, 1),
TreeIndex(Branch::SQUAD, 2),
};
for (const int n : path)
for (int r = 0; r < TreeNodeAt(n).maxRank; ++r)
if (pr.BuyNode(n)) ++bought;
// Закрытый узел не покупается ни за какие деньги: цепочка — это правило,
// а не подсказка.
const int locked = TreeIndex(Branch::ELEMENT, 5);
const bool boughtLocked = pr.BuyNode(locked);
game.NewRun(1);
game.Step(FIXED_DT);
const Agent& a1 = game.squad.agents[0];
const TreeBonus tb = pr.Tree();
std::printf("\nдерево: куплено уровней %d, закрытый узел %s\n"
" урон ствола %.1f -> %.1f, здоровье %.0f -> %.0f,"
" скорость %.2f -> %.2f\n"
" парирование %.2f -> %.2f, свет %.0f -> %.0f\n",
bought, boughtLocked ? "КУПИЛСЯ" : "закрыт", double(dmgBare),
double(a1.gun.def.damage), double(hpBare), double(a1.maxHp), double(speedBare),
double(a1.speedMul), double(parryBare), double(a1.melee.parry),
double(lightBare), double(game.squad.lightCharge));
if (bought <= 0)
{
std::printf(" [FAIL] ни один узел не купился при полном банке\n");
++bad;
}
if (boughtLocked)
{
std::printf(" [FAIL] закрытый узел купился — цепочка ветви не держит\n");
++bad;
}
if (a1.gun.def.damage <= dmgBare)
{
std::printf(" [FAIL] ветвь ствола не дошла до оружия\n");
++bad;
}
if (a1.maxHp <= hpBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до здоровья\n");
++bad;
}
if (a1.speedMul <= speedBare)
{
std::printf(" [FAIL] ветвь отряда не дошла до скорости\n");
++bad;
}
if (a1.melee.parry <= parryBare)
{
std::printf(" [FAIL] ветвь клинка не дошла до парирования\n");
++bad;
}
if (game.squad.lightCharge <= lightBare)
{
std::printf(" [FAIL] ветвь снабжения не дошла до фонаря\n");
++bad;
}
if (tb.gunDamage <= 0.0f || tb.health <= 0.0f)
{
std::printf(" [FAIL] сумма дерева пустая при купленных узлах\n");
++bad;
}
pr = saved;
game.NewRun(1);
}
// -------------------------------------------------------------------------
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
@ -2189,8 +2292,14 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
const int ammoBefore = int(g_tune.startAmmo);
pr.bankedXp = 10000.0f;
const bool boughtAmmo = pr.Buy(Upgrade::AMMO);
const bool boughtVigor = pr.Buy(Upgrade::VIGOR);
// Узлы: боезапас (ветвь ствола) и здоровье (ветвь отряда).
const int nodeAmmo = TreeIndex(Branch::GUN, 2);
const int nodeHealth = TreeIndex(Branch::SQUAD, 0);
// Цепочка: до «глубоких карманов» надо взять два узла над ними.
pr.BuyNode(TreeIndex(Branch::GUN, 0));
pr.BuyNode(TreeIndex(Branch::GUN, 1));
const bool boughtAmmo = pr.BuyNode(nodeAmmo);
const bool boughtVigor = pr.BuyNode(nodeHealth);
const float leftXp = pr.bankedXp;
// Улучшения раздаются вместе со снаряжением, то есть на новой вылазке.
@ -2296,9 +2405,10 @@ int RunUiCheck()
// Мало открыть его: покупка обязана работать с той же кнопки.
{
Ui ui;
const int lvlBefore = profile.UpgradeLevel(Upgrade::AMMO);
UiActivate(ui, game, UiPart::UPGRADE_ITEM, int(Upgrade::AMMO));
const bool bought = profile.UpgradeLevel(Upgrade::AMMO) > lvlBefore;
const int first = TreeIndex(Branch::GUN, 0);
const int lvlBefore = profile.NodeRank(first);
UiActivate(ui, game, UiPart::UPGRADE_ITEM, first);
const bool bought = profile.NodeRank(first) > lvlBefore;
std::printf("\nулучшения: покупка %s\n", bought ? "работает" : "НЕ работает");
if (!bought)

View File

@ -34,6 +34,7 @@
#include "sim/reactions.h"
#include "sim/items.h"
#include "sim/profile.h"
#include "sim/tree.h"
#include "sim/target.h"
#include "sim/weapon_defs.h"
@ -305,8 +306,18 @@ int CmdEconomy(int argc, char** argv)
// Цена: текущее дерево — четыре узла по пять уровней.
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));
// Считаем ЧЕСТНО, по порядку покупки: цена зависит от того, сколько уже
// вложено, и сумма «каждый узел по своей базовой цене» была бы враньём
// в десятки раз (sim/tree.h).
{
int total = 0;
for (int i = 0; i < TREE_NODES; ++i)
for (int r = 0; r < TreeNodeAt(i).maxRank; ++r)
{
tree += double(TreeNodeCost(i, r, total));
++total;
}
}
std::printf("\nодин проход 1..%d даёт %.0f опыта\n", maxDepth, total);
std::printf("всё нынешнее дерево стоит %.0f\n", tree);

View File

@ -123,7 +123,8 @@ void Game::SalvageAfterWipe()
auto trySave = [&](const ItemInstance& it) {
if (it.Empty()) return;
if (roll() < SalvageChance(it.rarity) && profile->StashPush(it)) ++report.itemsBrought;
if (roll() < SalvageChance(it.rarity) + squad.tree.salvage && profile->StashPush(it))
++report.itemsBrought;
else ++report.itemsLost;
};
@ -163,7 +164,11 @@ void Game::FinishRun(RunOutcome outcome)
++profile->extractions;
// Опыт здесь НЕ начисляется: он уже зачислен по убийствам (AwardKillXp).
// Материалы — по-прежнему ставка: донёс или потерял.
for (int m = 0; m < MATERIAL_COUNT; ++m) profile->material[m] += runMaterials[m];
// Ветвь снабжения умножает добытое: узел «PROSPECTOR» обязан быть
// виден в цифре на выходе, а не только в описании (sim/tree.h).
const float matMul = 1.0f + squad.tree.materials;
for (int m = 0; m < MATERIAL_COUNT; ++m)
profile->material[m] += int(float(runMaterials[m]) * matMul + 0.5f);
}
else
{
@ -396,10 +401,14 @@ void Game::BuildFloor(const SquadCarry* carry)
float ammoBonus = 0.0f;
if (profile)
{
squad.carryBonus = UPGRADE_CARRY_STEP * float(profile->UpgradeLevel(Upgrade::CARRY));
squad.sightBonus = UPGRADE_SIGHT_STEP * float(profile->UpgradeLevel(Upgrade::SIGHT));
squad.hpBonus = UPGRADE_VIGOR_STEP * float(profile->UpgradeLevel(Upgrade::VIGOR));
ammoBonus = UPGRADE_AMMO_STEP * float(profile->UpgradeLevel(Upgrade::AMMO));
// Дерево кладёт свои числа в те же каналы, что и снаряжение
// (sim/tree.h): своих у него нет и быть не должно.
const TreeBonus tb = profile->Tree();
squad.carryBonus = tb.carry;
squad.sightBonus = tb.sight;
squad.hpBonus = tb.health;
ammoBonus = tb.ammo;
squad.tree = tb;
}
// Запас патронов выдаётся вместе со снаряжением и только в БОЮ.
@ -429,7 +438,8 @@ void Game::BuildFloor(const SquadCarry* carry)
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь
// смысл — на четвёртом этаже светить уже почти нечем.
// В песочнице приёмки света бесконечно (-1), как и патронов.
squad.lightCharge = combatMode ? (LIGHT_FULL + lightBonus) : -1.0f;
squad.lightCharge =
combatMode ? (LIGHT_FULL + lightBonus + squad.tree.light) : -1.0f;
}
lanes.Clear();
@ -610,6 +620,7 @@ void Game::Step(float dt)
buffs.hp = squad.hpBonus;
buffs.carry = squad.carryBonus;
buffs.lightOut = squad.LightOut();
buffs.tree = &squad.tree;
// Ярость живёт таймером и гаснет сама. Множитель урона от неё уезжает в
// SyncLoadout вместе с аффиксами: своего канала у триггера нет.
squad.rageTimer = std::max(0.0f, squad.rageTimer - dt);
@ -765,7 +776,7 @@ void Game::Step(float dt)
// --- 9. пули ---------------------------------------------------------------
UpdateBullets(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, metrics, &events,
depth);
depth, squad.tree.reaction);
// --- 10. находки -----------------------------------------------------------
// После пуль и клинка: предмет, выпавший из разбитого ящика на этом же шаге,
@ -1242,7 +1253,8 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
VulnerabilityOf(tg.status);
tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock);
ApplyElementalHit(targets, t, a.melee.elem, depth, &events);
ApplyElementalHit(targets, t, a.melee.elem, depth, &events, false,
squad.tree.reaction);
// Триггеры клинка — те же, что у ствола, и в том же месте:
// сразу за уроном (sim/trigger.h).
@ -1253,7 +1265,7 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
a.trig.Chance(onHit))
{
ApplyElementalHit(targets, t, ElementMix::Of(Element::INC, 1.0f),
depth, &events);
depth, &events, false, squad.tree.reaction);
TriggerFired(a.trig, TriggerKind::ON_HIT);
}

View File

@ -228,13 +228,16 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// Аффиксы кладутся в ТЕ ЖЕ четыре числа, что и снаряжение: у предмета
// не появляется нового канала влияния оттого, что на нём есть рулоны
// (sim/items.h).
a.armor = std::min(ARMOR_CAP, inv->Armor() + a.mods.armor);
const TreeBonus zero;
const TreeBonus& tb = buffs.tree ? *buffs.tree : zero;
a.armor = std::min(ARMOR_CAP, inv->Armor() + a.mods.armor + tb.armor);
// Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе
// скорости: движению всё равно, почему боец медленный. Разделять их
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
a.load = inv->Load() + bagShare;
a.speedMul = inv->SpeedMul() * a.mods.speedMul * a.trig.SpeedMul() *
(1.0f + a.merc.speed) *
(1.0f + a.merc.speed) * (1.0f + tb.speed) *
CarryMul(a.load, buffs.carry + a.mods.carry);
// Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и
// купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел
@ -288,10 +291,21 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// Ярость отряда множится СВЕРХ аффиксов и живёт ровно столько, сколько
// держится её таймер: у триггера нет своего канала урона, он кладёт число
// в тот же множитель (sim/trigger.h).
// Дерево множит урон отдельно по стволам и клинкам: ветви разные, и «взял
// ствольную — стал лучше бить ножом» было бы обманом (sim/tree.h).
const TreeBonus zeroTree;
const TreeBonus& tree = buffs.tree ? *buffs.tree : zeroTree;
const float dmg = a.mods.damageMul * (1.0f + buffs.rage);
a.gun.def.damage *= dmg;
a.melee.damage *= dmg;
a.pen = a.mods.pen;
a.gun.def.damage *= dmg * (1.0f + tree.gunDamage);
a.melee.damage *= dmg * (1.0f + tree.bladeDamage);
a.pen = a.mods.pen + tree.gunPen;
// Перезарядка и темп ударов — время, поэтому не множитель, а ДЕЛИТЕЛЬ:
// «+20% скорости» обязано означать «на 20% быстрее», а не «на 20% дольше».
a.gun.def.reloadTime /= (1.0f + tree.reload);
a.melee.interval /= (1.0f + tree.meleeSpeed);
a.melee.parry = std::min(0.75f, a.melee.parry + tree.parry);
if (a.mods.convert.share[int(Element::KIN)] < 0.999f)
{

View File

@ -18,6 +18,7 @@
#include "sim/loadout.h"
#include "sim/target.h"
#include "sim/merc.h"
#include "sim/tree.h"
#include "sim/trigger.h"
#include "tuning.h"
#include "engine/tilemap.h"
@ -199,6 +200,10 @@ struct SquadBuffs
// называется «отряд получает +25% урона», и считать его на каждом бойце
// отдельно значило бы, что павшего оплакивает только сосед.
float rage = 0.0f;
// Итог дерева улучшений (sim/tree.h). Указателем, а не копией: структура
// большая, а меняется раз в вылазку. nullptr — приёмка и dev-прогоны, где
// дерева нет вовсе.
const TreeBonus* tree = nullptr;
// Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане
// перестаёт что-либо давать. По умолчанию false — песочница приёмки светит
// всегда (sim/squad.h).

View File

@ -62,7 +62,7 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events, int depth)
CombatEventLog* events, int depth, float reactionBonus)
{
std::vector<Bullet>& bullets = world.All<Bullet>();
@ -189,7 +189,7 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
t.hurtTimer = g_tune.targetHurtTime;
KnockTarget(t, dir, b.knockback);
// Стихии удара ложатся статусами и могут сложиться в реакцию.
ApplyElementalHit(targets, hitTarget, b.elem, depth, events);
ApplyElementalHit(targets, hitTarget, b.elem, depth, events, false, reactionBonus);
// --- триггеры стрелка (sim/trigger.h) ---------------------------
// Срабатывают ЗДЕСЬ, а не по журналу событий: журнал — канал для
@ -205,7 +205,7 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
// значит может сложить реакцию. Рекурсии тут нет: сам поджиг
// урона не наносит и второй раз триггер не запустит.
ApplyElementalHit(targets, hitTarget, ElementMix::Of(Element::INC, 1.0f),
depth, events);
depth, events, false, reactionBonus);
TriggerFired(shooter.trig, TriggerKind::ON_HIT);
}
}

View File

@ -70,4 +70,4 @@ void SpawnHostileBullet(ecs::World& world, Vec2 from, Vec2 dir, float speed, flo
void UpdateBullets(ecs::World& world, const Tilemap& map,
Agent* agents, int agentCount,
std::vector<Target>& targets, float dt, Metrics& metrics,
CombatEventLog* events, int depth);
CombatEventLog* events, int depth, float reactionBonus = 0.0f);

View File

@ -177,28 +177,6 @@ void Profile::ResetLoadouts()
}
}
const char* UpgradeName(Upgrade u)
{
switch (u)
{
case Upgrade::AMMO: return "AMMO RESERVE";
case Upgrade::CARRY: return "CARRY LIMIT";
case Upgrade::SIGHT: return "SIGHT RANGE";
default: return "VIGOR";
}
}
const char* UpgradeEffect(Upgrade u)
{
switch (u)
{
case Upgrade::AMMO: return "+20 ROUNDS PER RAID";
case Upgrade::CARRY: return "+1.5 BEFORE SLOWDOWN";
case Upgrade::SIGHT: return "+6% VISION";
default: return "+12 HEALTH";
}
}
bool Profile::Load(const std::string& path, std::string* err)
{
std::ifstream in(path);
@ -278,12 +256,26 @@ bool Profile::Load(const std::string& path, std::string* err)
if (t[0] == "wipes" && t.size() >= 2) { p.wipes = text::ToInt(t[1], 0); continue; }
if (t[0] == "xp" && t.size() >= 2) { p.bankedXp = text::ToFloat(t[1], 0.0f); continue; }
// upgrades <патроны> <переноска> <обзор> <живучесть>
// Старые строки upgrades читаются, но НЕ применяются: система заменена
// деревом. Потраченный на них опыт возвращается в банк — игрок вложит
// его заново, уже в узлы. Молча съесть чужие покупки нельзя.
if (t[0] == "upgrades")
{
for (int u = 0; u < int(Upgrade::COUNT) && size_t(1 + u) < t.size(); ++u)
p.upgrade[u] = std::max(0, std::min(UPGRADE_MAX_LEVEL,
text::ToInt(t[size_t(1 + u)], 0)));
for (size_t u = 1; u < t.size(); ++u)
{
const int lvl = std::max(0, text::ToInt(t[u], 0));
for (int k = 0; k < lvl; ++k) p.bankedXp += 150.0f * float(k + 1);
}
continue;
}
// tree <узел> <уровень>
if (t[0] == "tree" && t.size() >= 3)
{
const int idx = text::ToInt(t[1], -1);
if (idx >= 0 && idx < TREE_NODES)
p.node[idx] = std::max(0, std::min(TreeNodeAt(idx).maxRank,
text::ToInt(t[2], 0)));
continue;
}
@ -365,13 +357,14 @@ bool Profile::Save(const std::string& path, std::string* err) const
f << "wipes " << wipes << "\n";
f << "xp " << int(bankedXp) << "\n";
// Улучшения не писались вовсе: купленное действовало до конца запуска и
// пропадало при следующем чтении. Проверка кругового прогона этого не
// ловила, потому что сверяла три поля из десяти — глубину, схрон и
// снаряжение. Теперь она сверяет всё.
f << "upgrades";
for (int u = 0; u < int(Upgrade::COUNT); ++u) f << " " << upgrade[u];
f << "\n";
// Дерево: пишутся только взятые узлы. Сорок нулей в файле никому не нужны,
// а профиль обязан читаться глазами.
//
// Писать это ОБЯЗАТЕЛЬНО. Прежние улучшения не писались вовсе, и купленное
// молча пропадало при следующем чтении: круговой прогон сверял три поля из
// десяти и три вехи подтверждал работу того, чего в файле не было.
for (int i = 0; i < TREE_NODES; ++i)
if (node[i] > 0) f << "tree " << i << " " << node[i] << "\n";
for (int i = 0; i < NUM_AGENTS; ++i)
{

View File

@ -25,6 +25,8 @@
#include "sim/craft.h"
#include "sim/merc.h"
#include "sim/tree.h"
#include <cstdint>
#include "sim/items.h"
#include "tuning.h"
@ -48,44 +50,11 @@ struct StarterKit
};
// -----------------------------------------------------------------------------
// Во что вкладывается вынесенный опыт.
//
// Улучшения РЕСУРСНЫЕ, а не силовые: больше патронов, больше переноски, дальше
// видно, больше запаса здоровья. Игра про исчерпание, и вкладываться в неё надо
// в то, что кончается, — иначе прокачка съедает сам жанр: отряд, который бьёт
// вдвое сильнее, перестаёт бояться, а страх тут единственная механика, которую
// нельзя восстановить балансом.
//
// Улучшения ОТРЯДНЫЕ, а не на бойца. Не из экономии: личным в этой игре
// остаётся снаряжение, и второе личное измерение превратило бы сборы перед
// вылазкой в бухгалтерию на пятерых.
// -----------------------------------------------------------------------------
enum class Upgrade : uint8_t
{
AMMO = 0, // запас патронов на вылазку
CARRY, // норма переноски до перегруза
SIGHT, // радиус обзора
VIGOR, // запас здоровья
COUNT,
};
constexpr int UPGRADE_MAX_LEVEL = 5;
// -----------------------------------------------------------------------------
// Наёмники. НАХОДЯТСЯ, а не покупаются.
//
// Покупка за опыт была первой версией и оказалась скучной ровно в том смысле,
// в каком скучна любая кнопка «стать сильнее»: она ничего не рассказывает и
// никуда не зовёт. Найденный человек — это место, до которого надо дойти,
// и риск, который надо пережить вместе с ним.
// Наёмники. НАХОДЯТСЯ, а не покупаются: капсула на этаже — это место, до
// которого надо дойти, и риск, который надо пережить вместе с ним.
//
// Встречается редко: примерно раз в сто этажей и не чаще, чем раз в двадцать.
// Редко намеренно — пятый оперативник обязан быть событием забега, а не
// строкой в списке покупок.
//
// У каждого свои числа. Не «класс» и не «профессия»: три небольшие прибавки,
// которые видно на экране отряда и которые делают ЭТОГО человека отличным от
// соседа. Роль и специализация — следующая веха, тут нарочно минимум.
// Пятый оперативник обязан быть событием забега, а не строкой в списке покупок.
// -----------------------------------------------------------------------------
constexpr int RESCUE_EVERY = 100; // средняя редкость встречи, в этажах
constexpr int RESCUE_COOLDOWN = 20; // ближе этого две капсулы не встречаются
@ -95,19 +64,10 @@ constexpr int RESCUE_COOLDOWN = 20; // ближе этого две капс
// оказываются одним и тем же человеком с другим именем.
MercStats RollMerc(uint32_t& rng);
// Шаг улучшения за уровень.
constexpr float UPGRADE_AMMO_STEP = 20.0f;
constexpr float UPGRADE_CARRY_STEP = 1.5f;
constexpr float UPGRADE_SIGHT_STEP = 0.06f;
constexpr float UPGRADE_VIGOR_STEP = 12.0f;
// Цена следующего уровня. Растёт линейно: первая покупка обязана случиться на
// второй-третьей вылазке, иначе опыт снова «просто число», а десятая — стоить
// заметно, иначе кривая кончается.
inline float UpgradeCost(int currentLevel) { return 150.0f * float(currentLevel + 1); }
const char* UpgradeName(Upgrade u);
const char* UpgradeEffect(Upgrade u);
// Прежние четыре строки покупок (Upgrade::AMMO/CARRY/SIGHT/VIGOR) удалены
// целиком: их заменило дерево (sim/tree.h). Держать обе системы значило бы
// платить дважды за одно и то же и объяснять игроку, чем строка «+20 патронов»
// отличается от узла «DEEP POCKETS».
// -----------------------------------------------------------------------------
// Версия формата профиля.
@ -122,36 +82,73 @@ const char* UpgradeEffect(Upgrade u);
// 4 — экземпляры предметов: редкость и аффиксы в токене после '@'
// 5 — материалы: строка mat
// 6 — наёмники: строка merc и глубина последней встречи
// 7 — дерево улучшений вместо четырёх строк покупок
//
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку.
// -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 6;
constexpr int PROFILE_VERSION = 7;
struct Profile
{
// Какой версией записан прочитанный файл. Новый профиль сразу текущей.
int version = PROFILE_VERSION;
// Уровни улучшений, по одному на каждое. Живут в профиле, а не в игре:
// это то, что переживает вылазку.
int upgrade[int(Upgrade::COUNT)] = {};
// Уровни узлов дерева. Живут в профиле, а не в игре: это то, что
// переживает вылазку (sim/tree.h).
int node[TREE_NODES] = {};
int UpgradeLevel(Upgrade u) const { return upgrade[int(u)]; }
bool CanBuy(Upgrade u) const
// Сколько всего точек вложено в дерево. По ней считается общее удорожание
// (sim/tree.h): каждая взятая точка поднимает цену следующих.
int TreeRanks() const
{
return upgrade[int(u)] < UPGRADE_MAX_LEVEL && bankedXp >= UpgradeCost(upgrade[int(u)]);
int n = 0;
for (int i = 0; i < TREE_NODES; ++i) n += node[i];
return n;
}
bool Buy(Upgrade u)
int NodeRank(int index) const
{
if (!CanBuy(u)) return false;
bankedXp -= UpgradeCost(upgrade[int(u)]);
++upgrade[int(u)];
return (index >= 0 && index < TREE_NODES) ? node[index] : 0;
}
// Узел открыт, если предыдущий в его ветви взят хотя бы на уровень. Цепочка,
// а не свободная сетка: ветвь обязана читаться как путь.
bool NodeUnlocked(int index) const
{
const int req = TreeNodeRequires(index);
return req < 0 || NodeRank(req) > 0;
}
bool CanBuyNode(int index) const
{
if (index < 0 || index >= TREE_NODES) return false;
if (!NodeUnlocked(index)) return false;
const int rank = NodeRank(index);
if (rank >= TreeNodeAt(index).maxRank) return false;
return bankedXp >= TreeNodeCost(index, rank, TreeRanks());
}
bool BuyNode(int index)
{
if (!CanBuyNode(index)) return false;
bankedXp -= TreeNodeCost(index, NodeRank(index), TreeRanks());
++node[index];
return true;
}
// Сумма всего взятого. Считается при сборке этажа, дальше читаются готовые
// числа: правила боя про дерево не знают.
TreeBonus Tree() const
{
TreeBonus b;
for (int i = 0; i < TREE_NODES; ++i)
if (node[i] > 0) b.Add(TreeNodeAt(i).effect, TreeNodeAt(i).perRank * float(node[i]));
return b;
}
// Сколько оперативников в отряде. Игра начинается ОДНИМ (docs/20-gdd.md),
// потолок — NUM_AGENTS. Живёт в профиле, потому что это накопленный
// результат, а не состояние вылазки: нанятый не теряется при вайпе, он

View File

@ -20,7 +20,7 @@ void ReactionDamage(Target& t, float amount)
} // namespace
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
CombatEventLog* events, bool fromReaction)
CombatEventLog* events, bool fromReaction, float reactionBonus)
{
if (index < 0 || index >= int(targets.size())) return;
@ -73,7 +73,7 @@ void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix
t.status.stacks[int(other)] = 0;
t.status.reactionIcd = REACTION_ICD;
const float power = ReactionPower(depth) * ReactionScale(r);
const float power = ReactionPower(depth) * ReactionScale(r) * (1.0f + reactionBonus);
if (r == Reaction::SUPERCOND)
{

View File

@ -18,8 +18,12 @@
// реакций не запускает: это и есть запрет рекурсии, без которого цепочка
// «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция»
// уходит в самоповтор на первом же скоплении.
// reactionBonus — прибавка к силе реакций от дерева улучшений (sim/tree.h).
// Параметром, а не глобалом: реакции считаются и в инструментах, где никакого
// отряда нет вовсе.
void ApplyElementalHit(std::vector<Target>& targets, int index, const ElementMix& mix, int depth,
CombatEventLog* events, bool fromReaction = false);
CombatEventLog* events, bool fromReaction = false,
float reactionBonus = 0.0f);
// Тик статусов: горение и кислота, сгорание стаков, откат реакций.
// Зовётся из UpdateTargets, один раз за шаг.

View File

@ -12,6 +12,7 @@
#include "ecs/ecs.h"
#include "sim/agent.h"
#include "sim/tree.h"
#include "sim/target.h"
#include "engine/tilemap.h"
@ -45,6 +46,10 @@ struct Squad
{
// Что добавили улучшения профиля. Ноль по умолчанию — в песочнице приёмки
// профиля нет вовсе, и там всё считается ровно как считалось.
// Итог дерева улучшений. Живёт на отряде, а не на бойце: дерево отрядное,
// как и всё, что покупается за опыт (sim/tree.h).
TreeBonus tree;
float carryBonus = 0.0f;
float sightBonus = 0.0f;
float hpBonus = 0.0f;

143
src/sim/tree.cpp Normal file
View File

@ -0,0 +1,143 @@
#include "sim/tree.h"
#include <cmath>
namespace
{
// -----------------------------------------------------------------------------
// Сорок узлов. Читается сверху вниз по ветвям, и это же порядок открытия.
//
// Числа подобраны по одному правилу: ПОЛНАЯ ВЕТВЬ — это примерно полуторный
// отряд, а не двойной. Дерево обязано закрывать разрыв не само по себе, иначе
// оно и станет игрой вместо спуска (docs/20-gdd.md §2, закон масштаба).
//
// Цена нижних узлов заметно выше верхних: ветвь должна ощущаться как ВЫБОР
// пути, а не как список, который добирают по мелочи.
// -----------------------------------------------------------------------------
const TreeNode NODES[TREE_NODES] = {
// --- СТВОЛЫ: урон, пробитие, боезапас --------------------------------------
{"STEADY HANDS", Branch::GUN, 0, NodeEffect::GUN_DAMAGE, 0.04f, 5, 120.0f},
{"QUICK HANDS", Branch::GUN, 1, NodeEffect::RELOAD, 0.06f, 5, 180.0f},
{"DEEP POCKETS", Branch::GUN, 2, NodeEffect::AMMO, 14.0f, 5, 320.0f},
{"HARD ROUNDS", Branch::GUN, 3, NodeEffect::GUN_PEN, 0.03f, 5, 780.0f},
{"MARKSMAN", Branch::GUN, 4, NodeEffect::GUN_DAMAGE, 0.06f, 5, 1500.0f},
{"BREACH ROUNDS", Branch::GUN, 5, NodeEffect::GUN_PEN, 0.05f, 5, 3200.0f},
{"GUNSMITH", Branch::GUN, 6, NodeEffect::RELOAD, 0.09f, 5, 7000.0f},
{"EXECUTIONER", Branch::GUN, 7, NodeEffect::GUN_DAMAGE, 0.10f, 5, 15000.0f},
// --- КЛИНКИ: урон, парирование, темп ---------------------------------------
{"HEAVY SWING", Branch::BLADE, 0, NodeEffect::BLADE_DAMAGE, 0.05f, 5, 120.0f},
{"SIDESTEP", Branch::BLADE, 1, NodeEffect::PARRY, 0.03f, 5, 180.0f},
{"FAST BLADE", Branch::BLADE, 2, NodeEffect::MELEE_SPEED, 0.05f, 5, 320.0f},
{"COUNTERPOISE", Branch::BLADE, 3, NodeEffect::PARRY, 0.04f, 5, 780.0f},
{"BUTCHER", Branch::BLADE, 4, NodeEffect::BLADE_DAMAGE, 0.08f, 5, 1500.0f},
{"WHIRL", Branch::BLADE, 5, NodeEffect::MELEE_SPEED, 0.07f, 5, 3200.0f},
{"DUELLIST", Branch::BLADE, 6, NodeEffect::PARRY, 0.05f, 5, 7000.0f},
{"CLEAVE", Branch::BLADE, 7, NodeEffect::BLADE_DAMAGE, 0.12f, 5, 15000.0f},
// --- СТИХИИ: реакции, статусы, конверсия -----------------------------------
{"CATALYST", Branch::ELEMENT, 0, NodeEffect::REACTION, 0.06f, 5, 140.0f},
{"LINGERING", Branch::ELEMENT, 1, NodeEffect::STATUS, 0.08f, 5, 200.0f},
{"PRIMER", Branch::ELEMENT, 2, NodeEffect::CONVERT, 0.04f, 5, 360.0f},
{"RESONANCE", Branch::ELEMENT, 3, NodeEffect::REACTION, 0.09f, 5, 860.0f},
{"DEEP BURN", Branch::ELEMENT, 4, NodeEffect::STATUS, 0.12f, 5, 1700.0f},
{"OVERCHARGE", Branch::ELEMENT, 5, NodeEffect::CONVERT, 0.06f, 5, 3600.0f},
{"CHAIN THEORY", Branch::ELEMENT, 6, NodeEffect::REACTION, 0.12f, 5, 7800.0f},
{"ALCHEMIST", Branch::ELEMENT, 7, NodeEffect::CONVERT, 0.08f, 5, 16500.0f},
// --- СНАБЖЕНИЕ: свет, переноска, добыча ------------------------------------
{"LONG WICK", Branch::SUPPLY, 0, NodeEffect::LIGHT, 40.0f, 5, 110.0f},
{"MULE", Branch::SUPPLY, 1, NodeEffect::CARRY, 0.60f, 5, 170.0f},
{"SCAVENGER", Branch::SUPPLY, 2, NodeEffect::MATERIALS, 0.10f, 5, 300.0f},
{"INSURANCE", Branch::SUPPLY, 3, NodeEffect::SALVAGE, 0.04f, 5, 660.0f},
{"BRIGHT CORE", Branch::SUPPLY, 4, NodeEffect::LIGHT, 70.0f, 5, 1400.0f},
{"PACK RAT", Branch::SUPPLY, 5, NodeEffect::CARRY, 1.00f, 5, 2900.0f},
{"PROSPECTOR", Branch::SUPPLY, 6, NodeEffect::MATERIALS, 0.18f, 5, 6500.0f},
{"BLACK BOX", Branch::SUPPLY, 7, NodeEffect::SALVAGE, 0.06f, 5, 14000.0f},
// --- ОТРЯД: живучесть, броня, люди ------------------------------------------
{"VIGOR", Branch::SQUAD, 0, NodeEffect::HEALTH, 10.0f, 5, 130.0f},
{"PLATING", Branch::SQUAD, 1, NodeEffect::ARMOR, 0.02f, 5, 190.0f},
{"LONG STRIDE", Branch::SQUAD, 2, NodeEffect::SPEED, 0.03f, 5, 340.0f},
{"NIGHT EYES", Branch::SQUAD, 3, NodeEffect::SIGHT, 0.05f, 5, 740.0f},
{"SECOND WIND", Branch::SQUAD, 4, NodeEffect::HEALTH, 18.0f, 5, 1600.0f},
{"SPITE", Branch::SQUAD, 5, NodeEffect::RAGE, 0.06f, 5, 3400.0f},
{"HARDENED", Branch::SQUAD, 6, NodeEffect::ARMOR, 0.03f, 5, 7200.0f},
{"LAST STAND", Branch::SQUAD, 7, NodeEffect::RAGE, 0.10f, 5, 15500.0f},
};
} // namespace
const char* NodeEffectName(NodeEffect e)
{
switch (e)
{
case NodeEffect::GUN_DAMAGE: return "GUN DAMAGE";
case NodeEffect::GUN_PEN: return "ARMOUR PIERCE";
case NodeEffect::AMMO: return "ROUNDS PER RAID";
case NodeEffect::RELOAD: return "RELOAD SPEED";
case NodeEffect::BLADE_DAMAGE: return "BLADE DAMAGE";
case NodeEffect::PARRY: return "PARRY CHANCE";
case NodeEffect::MELEE_SPEED: return "SWING SPEED";
case NodeEffect::REACTION: return "REACTION POWER";
case NodeEffect::STATUS: return "STATUS DURATION";
case NodeEffect::CONVERT: return "ELEMENTAL CONVERSION";
case NodeEffect::LIGHT: return "LANTERN CHARGE";
case NodeEffect::CARRY: return "CARRY LIMIT";
case NodeEffect::SALVAGE: return "SALVAGE ON WIPE";
case NodeEffect::MATERIALS: return "MATERIALS FOUND";
case NodeEffect::HEALTH: return "HEALTH";
case NodeEffect::ARMOR: return "ARMOUR";
case NodeEffect::SPEED: return "MOVE SPEED";
case NodeEffect::SIGHT: return "SIGHT RANGE";
default: return "SQUAD RAGE";
}
}
const TreeNode& TreeNodeAt(int index)
{
static const TreeNode empty;
if (index < 0 || index >= TREE_NODES) return empty;
return NODES[index];
}
float TreeNodeCost(int index, int currentRank, int totalRanks)
{
const TreeNode& n = TreeNodeAt(index);
if (currentRank >= n.maxRank) return 0.0f;
// Каждый следующий уровень узла дороже предыдущего в полтора раза, а каждая
// вложенная в дерево точка поднимает цену ВСЕГО остального. Второе и
// держит дерево живым все двести часов: к концу одна точка стоит три
// десятка первых.
const float rank = std::pow(1.5f, float(currentRank));
const float spent = 1.0f + TREE_ESCALATION * float(totalRanks > 0 ? totalRanks : 0);
return n.costBase * rank * spent;
}
void TreeBonus::Add(NodeEffect e, float amount)
{
switch (e)
{
case NodeEffect::GUN_DAMAGE: gunDamage += amount; break;
case NodeEffect::GUN_PEN: gunPen += amount; break;
case NodeEffect::AMMO: ammo += amount; break;
case NodeEffect::RELOAD: reload += amount; break;
case NodeEffect::BLADE_DAMAGE: bladeDamage += amount; break;
case NodeEffect::PARRY: parry += amount; break;
case NodeEffect::MELEE_SPEED: meleeSpeed += amount; break;
case NodeEffect::REACTION: reaction += amount; break;
case NodeEffect::STATUS: status += amount; break;
case NodeEffect::CONVERT: convert += amount; break;
case NodeEffect::LIGHT: light += amount; break;
case NodeEffect::CARRY: carry += amount; break;
case NodeEffect::SALVAGE: salvage += amount; break;
case NodeEffect::MATERIALS: materials += amount; break;
case NodeEffect::HEALTH: health += amount; break;
case NodeEffect::ARMOR: armor += amount; break;
case NodeEffect::SPEED: speed += amount; break;
case NodeEffect::SIGHT: sight += amount; break;
default: rage += amount; break;
}
}

163
src/sim/tree.h Normal file
View File

@ -0,0 +1,163 @@
#pragma once
// -----------------------------------------------------------------------------
// Дерево улучшений отряда.
//
// ЧТО БЫЛО И ПОЧЕМУ ЭТОГО МАЛО. До этой вехи опыт тратился на четыре строки:
// патроны, переноска, обзор, живучесть. Каждая — просто «+число за уровень».
// Выбора там не было вообще: купить в итоге можно всё, порядок ни на что не
// влияет, и через две вылазки экран улучшений превращался в кнопку «продолжить».
//
// Дерево устроено иначе, и разница ровно в одном: ВЫБОР ЕСТЬ, ПОТОМУ ЧТО ВСЁ НЕ
// ВЗЯТЬ. Узлы стоят дорого и дорожают, ветвь открывается по цепочке, и опыт —
// единственная валюта на все пять ветвей сразу.
//
// Пять ветвей — это пять способов закрыть разрыв из закона масштаба
// (docs/20-gdd.md §2): угроза растёт квадратично, сила отряда — линейно, и
// разрыв закрывается ЗНАНИЕМ. Ветви и есть виды знания:
//
// СТВОЛ — про огонь: урон, пробитие, боезапас;
// КЛИНОК — про ближний бой: урон, парирование, скорость;
// СТИХИИ — про реакции и статусы: то, чем берут защищённых;
// СНАБЖЕНИЕ — про ресурсы вылазки: свет, переноска, патроны;
// ОТРЯД — про живучесть и людей: здоровье, броня, ярость.
//
// НИ ОДИН УЗЕЛ НЕ ЗАВОДИТ НОВОГО КАНАЛА ВЛИЯНИЯ. Каждый кладёт своё число туда,
// куда уже кладут снаряжение и аффиксы (sim/affix.h): статы бойца, множители
// оружия, ресурсы вылазки. Это то же правило, что и у предметов, и оно тут
// важнее вдвойне — дерево растёт, и первый же «особенный» узел со своей веткой
// в коде превратил бы SyncLoadout в свалку.
// -----------------------------------------------------------------------------
#include <cstdint>
// Ветви. Порядок — порядок столбцов на экране.
enum class Branch : uint8_t
{
GUN = 0,
BLADE,
ELEMENT,
SUPPLY,
SQUAD,
COUNT,
};
constexpr int BRANCH_COUNT = int(Branch::COUNT);
constexpr int NODES_PER_BRANCH = 8;
constexpr int TREE_NODES = BRANCH_COUNT * NODES_PER_BRANCH;
inline const char* BranchName(Branch b)
{
switch (b)
{
case Branch::GUN: return "FIREARMS";
case Branch::BLADE: return "BLADES";
case Branch::ELEMENT: return "ELEMENTS";
case Branch::SUPPLY: return "SUPPLY";
default: return "SQUAD";
}
}
// Что узел даёт. Тег — адрес числа в уже существующих каналах, не «новая
// механика». Если для эффекта пришлось бы заводить новый канал, такому узлу
// в дереве не место, пока канал не появился сам по себе.
enum class NodeEffect : uint8_t
{
GUN_DAMAGE = 0, // множитель урона стволов
GUN_PEN, // пробитие сопротивлений
AMMO, // запас патронов на вылазку
RELOAD, // скорость перезарядки
BLADE_DAMAGE, // множитель урона клинков
PARRY, // шанс парирования
MELEE_SPEED, // темп ударов клинка
REACTION, // сила реакций стихий
STATUS, // длительность статусов
CONVERT, // доля урона в стихию оружия
LIGHT, // заряд фонаря
CARRY, // норма переноски
SALVAGE, // сколько добычи уцелевает при вайпе
MATERIALS, // сколько материалов приносит вылазка
HEALTH, // запас здоровья
ARMOR, // броня
SPEED, // скорость хода
SIGHT, // радиус обзора
RAGE, // ярость отряда за павшего
COUNT,
};
const char* NodeEffectName(NodeEffect e);
// Одна запись дерева. Данные, а не код: узлы правят таблицей, глядя на неё
// целиком, и добавление узла не требует трогать ни одного алгоритма.
struct TreeNode
{
const char* name = "-";
Branch branch = Branch::GUN;
int row = 0; // место в ветви, оно же порядок открытия
NodeEffect effect = NodeEffect::GUN_DAMAGE;
float perRank = 0.0f; // сколько даёт один уровень
int maxRank = 5;
float costBase = 0.0f; // цена первого уровня; дальше растёт
};
// Таблица всех узлов. Определена в tree.cpp.
const TreeNode& TreeNodeAt(int index);
// Индекс узла по ветви и месту в ней. Экран рисует сетку, а профиль хранит
// плоский массив — переводит одно в другое эта пара функций.
inline int TreeIndex(Branch b, int row) { return int(b) * NODES_PER_BRANCH + row; }
// Цена СЛЕДУЮЩЕГО уровня.
//
// Растёт трижды, и каждый множитель отвечает за своё:
// * место узла в ветви — нижние узлы сильнее и дороже, иначе выгодно было бы
// докупать верхние по мелочи и никуда не спускаться;
// * уровень узла — «добить до максимума» обязано быть отдельным решением;
// * СКОЛЬКО ВСЕГО УЖЕ ВЛОЖЕНО — каждая взятая точка поднимает цену всех
// следующих.
//
// Третий множитель — главный, и без него дерево умирает. Опыт растёт с глубиной
// квадратично (`--tool economy`: этаж 100 даёт 190 тысяч), а дерево с
// фиксированными ценами выкупается целиком за половину одного глубокого
// прохода. Первая версия так и вышла: всё дерево стоило 9% одного прохода
// 1..100, то есть к сороковому этажу выбор кончался навсегда.
constexpr float TREE_ESCALATION = 0.15f;
float TreeNodeCost(int index, int currentRank, int totalRanks);
// Требование: узел открывается, когда предыдущий в ЕГО ВЕТВИ взят хотя бы на
// первый уровень. Цепочка, а не свободная сетка, — потому что ветвь должна
// читаться как путь: «я иду в стихии» видно по дереву, а не по таблице цифр.
inline int TreeNodeRequires(int index)
{
const int row = index % NODES_PER_BRANCH;
return (row == 0) ? -1 : index - 1;
}
// -----------------------------------------------------------------------------
// Итог дерева: сумма всех взятых уровней. Считается один раз при сборке этажа,
// дальше читается как готовые числа — лазать по узлам из правил боя нельзя, там
// про дерево не знают вообще.
// -----------------------------------------------------------------------------
struct TreeBonus
{
float gunDamage = 0.0f; // доля, прибавка к множителю
float gunPen = 0.0f;
float ammo = 0.0f;
float reload = 0.0f; // доля, ускорение перезарядки
float bladeDamage = 0.0f;
float parry = 0.0f;
float meleeSpeed = 0.0f;
float reaction = 0.0f;
float status = 0.0f;
float convert = 0.0f;
float light = 0.0f;
float carry = 0.0f;
float salvage = 0.0f;
float materials = 0.0f;
float health = 0.0f;
float armor = 0.0f;
float speed = 0.0f;
float sight = 0.0f;
float rage = 0.0f;
void Add(NodeEffect e, float amount);
};

View File

@ -9,6 +9,7 @@
#include "game.h"
#include "sim/affix.h"
#include "sim/craft.h"
#include "sim/tree.h"
namespace
{
@ -120,17 +121,22 @@ constexpr int SQ_STAT_X = 40, SQ_STAT_Y = 158, SQ_STAT_STEP = 9;
// Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому
// что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы
// покупкой кота в мешке.
// Строк на экране улучшений пять: найм и четыре характеристики. Найм ПЕРВЫЙ
// строкой — он и стоит на порядок дороже, и меняет игру сильнее всех остальных
// покупок вместе взятых (sim/profile.h).
constexpr int UP_X = 60, UP_Y = 66, UP_W = 360, UP_H = 26, UP_STEP = 30;
// Дерево улучшений: пять столбцов-ветвей по восемь узлов (sim/tree.h).
//
// Сетка, а не свободная раскладка с кривыми связями: узлы читаются столбцами,
// ветвь видна целиком, и «куда я иду» понятно без легенды. Кривые связи из
// больших деревьев красивы на скриншоте и нечитаемы на 480x270.
constexpr int TR_X = 30, TR_Y = 66;
constexpr int TR_COL_W = 86, TR_ROW_H = 23;
constexpr int TR_NODE_W = 78, TR_NODE_H = 19;
// Индекс строки найма в списке виджетов. Стоит за последним улучшением, чтобы
// не переиндексировать Upgrade ради UI.
constexpr int UP_RECRUIT = int(Upgrade::COUNT);
inline UiRect TreeNodeRect(int index)
{
const int col = index / NODES_PER_BRANCH;
const int row = index % NODES_PER_BRANCH;
return UiRect{TR_X + col * TR_COL_W, TR_Y + row * TR_ROW_H, TR_NODE_W, TR_NODE_H};
}
// Строка i-го улучшения. Найм занимает нулевую, характеристики идут следом.
inline UiRect UpRowRect(int row) { return UiRect{UP_X, UP_Y + row * UP_STEP, UP_W, UP_H}; }
constexpr int SQ_STAT_LINES = 4;
// Сумка общая, и ячеек в ней шестнадцать вместо шести на бойца. В два столбца
@ -447,9 +453,13 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out)
return;
case Screen::UPGRADE:
out.Add(UiPart::UPGRADE_ITEM, UP_RECRUIT, UpRowRect(0)).enabled = false;
for (int i = 0; i < int(Upgrade::COUNT); ++i)
out.Add(UiPart::UPGRADE_ITEM, i, UpRowRect(i + 1));
for (int i = 0; i < TREE_NODES; ++i)
{
Widget& w = out.Add(UiPart::UPGRADE_ITEM, i, TreeNodeRect(i));
// Закрытый узел виден, но не нажимается: игрок должен видеть,
// КУДА ведёт ветвь, до того как в неё вложится.
w.enabled = game.profile && game.profile->NodeUnlocked(i);
}
return;
case Screen::DEPTH:
@ -738,10 +748,10 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index)
case UiPart::UPGRADE_ITEM:
// Покупка молча не проваливается: не хватило опыта — уровень
// остался прежним, и это видно на той же строке.
if (!game.profile) return;
// Нулевая строка — состав отряда, и она НЕ кнопка: оперативники
// находятся на этажах (sim/profile.h).
if (index != UP_RECRUIT) game.profile->Buy(Upgrade(index));
// Покупка узла дерева. Молча не проваливается: не хватило опыта
// или узел закрыт — уровень остался прежним, и это видно на самом
// узле (sim/tree.h).
if (game.profile) game.profile->BuyNode(index);
return;
case UiPart::MENU_ITEM:
@ -1330,60 +1340,85 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const
case Screen::UPGRADE:
{
Shade(l, COL_DIM);
TextCentered(l, "UPGRADES", 30, 16, COL_TEXT);
Rule(l, 52, 52);
TextCentered(l, "UPGRADE TREE", 12, 14, COL_TEXT);
Rule(l, 28, 60);
char line[80];
char line[96];
const float banked = game.profile ? game.profile->bankedXp : 0.0f;
std::snprintf(line, sizeof(line), "BANKED XP %d", int(banked));
TextCentered(l, line, 58, 8, COL_FAINT);
TextCentered(l, line, 34, 8, COL_FAINT);
// --- состав отряда: показ, а не покупка -------------------------
// Раньше здесь стояла строка «нанять оперативника» за опыт. Убрана
// намеренно: наёмники теперь НАХОДЯТСЯ в капсулах на этажах
// (sim/profile.h), а кнопка «стать сильнее» никуда не звала и
// ничего не рассказывала.
// Заголовки ветвей.
for (int b = 0; b < BRANCH_COUNT; ++b)
{
const int size = game.profile ? game.profile->squadSize : NUM_AGENTS;
const UiRect r = UpRowRect(0);
BoxLines(l, r, COL_DEAD);
Text(l, "SQUAD", r.x + 8, r.y + 4, 10, COL_FAINT);
Text(l, "OPERATIVES ARE FOUND IN CAPSULES, NOT HIRED", r.x + 8, r.y + 16, 8,
COL_FAINT);
for (int k = 0; k < NUM_AGENTS; ++k)
Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5},
(k < size) ? Color{200, 180, 120, 255} : COL_DEAD);
std::snprintf(line, sizeof(line), "%d / %d", size, NUM_AGENTS);
Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, COL_FAINT);
const UiRect r = TreeNodeRect(b * NODES_PER_BRANCH);
Text(l, BranchName(Branch(b)), r.x + 2, TR_Y - 10, 8, COL_PICK);
}
for (int i = 0; i < int(Upgrade::COUNT); ++i)
// Связи между узлами ветви: тонкая линия сверху вниз. Она и есть
// «цепочка» — узел открывается предыдущим.
for (int i = 0; i < TREE_NODES; ++i)
{
const Upgrade u = Upgrade(i);
const int lvl = game.profile ? game.profile->UpgradeLevel(u) : 0;
const bool maxed = lvl >= UPGRADE_MAX_LEVEL;
const bool afford = game.profile && game.profile->CanBuy(u);
const int row = i % NODES_PER_BRANCH;
if (row == 0) continue;
const UiRect a = TreeNodeRect(i - 1);
const bool open = game.profile && game.profile->NodeRank(i - 1) > 0;
Box(l, UiRect{a.x + TR_NODE_W / 2, a.y + TR_NODE_H, 1, TR_ROW_H - TR_NODE_H},
open ? Color{150, 130, 90, 255} : COL_DEAD);
}
const UiRect r = UpRowRect(i + 1);
const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == i);
if (hover && afford) Box(l, r, COL_HOVER);
BoxLines(l, r, afford ? COL_EDGE : COL_DEAD);
for (int i = 0; i < w.count; ++i)
{
const Widget& c = w.item[i];
if (c.part != UiPart::UPGRADE_ITEM) continue;
Text(l, UpgradeName(u), r.x + 8, r.y + 4, 10, afford ? COL_TEXT : COL_FAINT);
Text(l, UpgradeEffect(u), r.x + 8, r.y + 16, 8, COL_FAINT);
const TreeNode& n = TreeNodeAt(c.index);
const int rank = game.profile ? game.profile->NodeRank(c.index) : 0;
const bool maxed = rank >= n.maxRank;
const bool afford = game.profile && game.profile->CanBuyNode(c.index);
const bool hover = (hoverPart == c.part && hoverIndex == c.index);
if (hover && afford) Box(l, c.rect, COL_HOVER);
// Взятый узел заливается: сетка должна читаться одним взглядом,
// без вчитывания в цифры уровней.
if (rank > 0) Box(l, c.rect, Color{58, 46, 26, 200});
BoxLines(l, c.rect, !c.enabled ? COL_DEAD
: (afford ? Color{150, 130, 90, 255} : COL_EDGE));
Text(l, n.name, c.rect.x + 3, c.rect.y + 2, 7,
c.enabled ? (rank > 0 ? COL_TEXT : COL_FAINT) : COL_DEAD);
// Уровни точками: пять кружков читаются быстрее, чем «3/5».
for (int k = 0; k < UPGRADE_MAX_LEVEL; ++k)
Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5},
(k < lvl) ? Color{200, 180, 120, 255} : COL_DEAD);
for (int k = 0; k < n.maxRank; ++k)
Box(l, UiRect{c.rect.x + 3 + k * 5, c.rect.y + 12, 3, 3},
(k < rank) ? Color{210, 180, 110, 255} : COL_DEAD);
if (maxed) std::snprintf(line, sizeof(line), "MAX");
else std::snprintf(line, sizeof(line), "%d XP", int(UpgradeCost(lvl)));
Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10,
else std::snprintf(line, sizeof(line), "%d",
int(TreeNodeCost(c.index, rank,
game.profile ? game.profile->TreeRanks() : 0)));
Text(l, line, c.rect.x + TR_NODE_W - 3 - TextW(l, line, 7), c.rect.y + 11, 7,
maxed ? COL_FAINT : (afford ? COL_PICK : COL_DEAD));
}
TextCentered(l, "CLICK TO BUY ESC BACK", 232, 8, COL_FAINT);
// Подсказка по узлу под курсором: что он даёт и сколько уже дал.
if (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex >= 0 && hoverIndex < TREE_NODES)
{
const TreeNode& n = TreeNodeAt(hoverIndex);
const int rank = game.profile ? game.profile->NodeRank(hoverIndex) : 0;
const bool percent = n.perRank < 1.0f;
if (percent)
std::snprintf(line, sizeof(line), "%s: %+d%% PER RANK, NOW %+d%%",
NodeEffectName(n.effect), int(n.perRank * 100.0f + 0.5f),
int(n.perRank * float(rank) * 100.0f + 0.5f));
else
std::snprintf(line, sizeof(line), "%s: %+d PER RANK, NOW %+d",
NodeEffectName(n.effect), int(n.perRank + 0.5f),
int(n.perRank * float(rank) + 0.5f));
TextCentered(l, line, 252, 8, COL_TEXT);
}
TextCentered(l, "CLICK TO SPEND ESC BACK", 262, 7, COL_FAINT);
break;
}