diff --git a/CLAUDE.md b/CLAUDE.md index 6e0d2a1..432bb51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)). - **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 7d75f54..abd6eac 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -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)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/17-extraction.md b/docs/17-extraction.md index 40c098a..93f13a5 100644 --- a/docs/17-extraction.md +++ b/docs/17-extraction.md @@ -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 +``` + +Узел, который красиво нарисован и ничего не делает, выглядит снаружи ровно как +работающий. На этом уже дважды попались вес и стихии. ## Итог забега diff --git a/docs/ui-tree.png b/docs/ui-tree.png new file mode 100644 index 0000000..d4045ab Binary files /dev/null and b/docs/ui-tree.png differ diff --git a/projects/hollow/catalog/entities.txt b/projects/hollow/catalog/entities.txt index 591c439..832e4ca 100644 --- a/projects/hollow/catalog/entities.txt +++ b/projects/hollow/catalog/entities.txt @@ -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 diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 984b3b0..68e43b2 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -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) diff --git a/src/debug/tools.cpp b/src/debug/tools.cpp index 5eb0439..95cad2f 100644 --- a/src/debug/tools.cpp +++ b/src/debug/tools.cpp @@ -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); diff --git a/src/game.cpp b/src/game.cpp index 77bb24b..ed5f92e 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -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); } diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 7a9e9cc..d18687f 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -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) { diff --git a/src/sim/agent.h b/src/sim/agent.h index 477e0d4..07cc434 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -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). diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 3018c3b..13d0057 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -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& targets, float dt, Metrics& metrics, - CombatEventLog* events, int depth) + CombatEventLog* events, int depth, float reactionBonus) { std::vector& bullets = world.All(); @@ -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); } } diff --git a/src/sim/bullet.h b/src/sim/bullet.h index 1325e15..11eba30 100644 --- a/src/sim/bullet.h +++ b/src/sim/bullet.h @@ -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& targets, float dt, Metrics& metrics, - CombatEventLog* events, int depth); + CombatEventLog* events, int depth, float reactionBonus = 0.0f); diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index 4cf612b..a8c1c44 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -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) { diff --git a/src/sim/profile.h b/src/sim/profile.h index d41c7f1..823adc2 100644 --- a/src/sim/profile.h +++ b/src/sim/profile.h @@ -25,6 +25,8 @@ #include "sim/craft.h" #include "sim/merc.h" +#include "sim/tree.h" +#include #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. Живёт в профиле, потому что это накопленный // результат, а не состояние вылазки: нанятый не теряется при вайпе, он diff --git a/src/sim/reactions.cpp b/src/sim/reactions.cpp index 5b6ddb2..6254a46 100644 --- a/src/sim/reactions.cpp +++ b/src/sim/reactions.cpp @@ -20,7 +20,7 @@ void ReactionDamage(Target& t, float amount) } // namespace void ApplyElementalHit(std::vector& 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& 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) { diff --git a/src/sim/reactions.h b/src/sim/reactions.h index cc1e9fc..5820229 100644 --- a/src/sim/reactions.h +++ b/src/sim/reactions.h @@ -18,8 +18,12 @@ // реакций не запускает: это и есть запрет рекурсии, без которого цепочка // «реакция бьёт по площади -> урон кладёт стихию -> новая пара -> реакция» // уходит в самоповтор на первом же скоплении. +// reactionBonus — прибавка к силе реакций от дерева улучшений (sim/tree.h). +// Параметром, а не глобалом: реакции считаются и в инструментах, где никакого +// отряда нет вовсе. void ApplyElementalHit(std::vector& targets, int index, const ElementMix& mix, int depth, - CombatEventLog* events, bool fromReaction = false); + CombatEventLog* events, bool fromReaction = false, + float reactionBonus = 0.0f); // Тик статусов: горение и кислота, сгорание стаков, откат реакций. // Зовётся из UpdateTargets, один раз за шаг. diff --git a/src/sim/squad.h b/src/sim/squad.h index 5bd14d1..d9e3f24 100644 --- a/src/sim/squad.h +++ b/src/sim/squad.h @@ -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; diff --git a/src/sim/tree.cpp b/src/sim/tree.cpp new file mode 100644 index 0000000..5f27082 --- /dev/null +++ b/src/sim/tree.cpp @@ -0,0 +1,143 @@ +#include "sim/tree.h" + +#include + +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; + } +} diff --git a/src/sim/tree.h b/src/sim/tree.h new file mode 100644 index 0000000..547e4a9 --- /dev/null +++ b/src/sim/tree.h @@ -0,0 +1,163 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Дерево улучшений отряда. +// +// ЧТО БЫЛО И ПОЧЕМУ ЭТОГО МАЛО. До этой вехи опыт тратился на четыре строки: +// патроны, переноска, обзор, живучесть. Каждая — просто «+число за уровень». +// Выбора там не было вообще: купить в итоге можно всё, порядок ни на что не +// влияет, и через две вылазки экран улучшений превращался в кнопку «продолжить». +// +// Дерево устроено иначе, и разница ровно в одном: ВЫБОР ЕСТЬ, ПОТОМУ ЧТО ВСЁ НЕ +// ВЗЯТЬ. Узлы стоят дорого и дорожают, ветвь открывается по цепочке, и опыт — +// единственная валюта на все пять ветвей сразу. +// +// Пять ветвей — это пять способов закрыть разрыв из закона масштаба +// (docs/20-gdd.md §2): угроза растёт квадратично, сила отряда — линейно, и +// разрыв закрывается ЗНАНИЕМ. Ветви и есть виды знания: +// +// СТВОЛ — про огонь: урон, пробитие, боезапас; +// КЛИНОК — про ближний бой: урон, парирование, скорость; +// СТИХИИ — про реакции и статусы: то, чем берут защищённых; +// СНАБЖЕНИЕ — про ресурсы вылазки: свет, переноска, патроны; +// ОТРЯД — про живучесть и людей: здоровье, броня, ярость. +// +// НИ ОДИН УЗЕЛ НЕ ЗАВОДИТ НОВОГО КАНАЛА ВЛИЯНИЯ. Каждый кладёт своё число туда, +// куда уже кладут снаряжение и аффиксы (sim/affix.h): статы бойца, множители +// оружия, ресурсы вылазки. Это то же правило, что и у предметов, и оно тут +// важнее вдвойне — дерево растёт, и первый же «особенный» узел со своей веткой +// в коде превратил бы SyncLoadout в свалку. +// ----------------------------------------------------------------------------- +#include + +// Ветви. Порядок — порядок столбцов на экране. +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); +}; diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 87ae6f2..9b0bfe5 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -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; }