Дерево улучшений вместо четырёх кнопок (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:
parent
9bcb5ca013
commit
3ebd1f96fc
@ -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)).
|
||||
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -279,35 +279,66 @@ stash saber plate vest smg bow charm lantern spurs
|
||||
Делать `NUM_AGENTS` динамическим не стали: это сто с лишним мест ради числа,
|
||||
которое всё равно упирается в пять.
|
||||
|
||||
## Во что вкладывается опыт
|
||||
## Во что вкладывается опыт: дерево
|
||||
|
||||

|
||||

|
||||
|
||||
Опыт копился с вехи 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
BIN
docs/ui-tree.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
32
src/game.cpp
32
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);
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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. Живёт в профиле, потому что это накопленный
|
||||
// результат, а не состояние вылазки: нанятый не теряется при вайпе, он
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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, один раз за шаг.
|
||||
|
||||
@ -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
143
src/sim/tree.cpp
Normal 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
163
src/sim/tree.h
Normal 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);
|
||||
};
|
||||
141
src/ui/menu.cpp
141
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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user