From d3aa044cbe064211665242943a9a0326ba85b6a9 Mon Sep 17 00:00:00 2001 From: "z.kirill" Date: Sun, 16 Aug 2026 02:35:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B4=D0=BA=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B0=D1=84=D1=84=D0=B8=D0=BA=D1=81=D1=8B:?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BA=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20(M25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины. Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и появиться ей неоткуда. ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест, которым нужен ВИД вещи, остались как были. Цена решения — присваивание в ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись профиля), и все они работают с экземпляром целиком. Замеры поймали три расхождения дизайн-документа с самим собой: * формула редкости противоречила таблице рядом с ней: 2.7% находок с аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят до схождения с таблицей на дне (12% обычных на сотом); * бюджет пускал восемь высших аффиксов вместо обещанных шести; * предельная вещь собиралась к семидесятому этажу вместо сотого. Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 < 7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна. Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где дважды умирали вес и стихии: аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25, пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000 Последнее число не украшение: конверсия обязана переводить урон, а не добавлять седьмую долю сверх единицы. Попутно, на глубине 30 и только там: * «зачистка поля» в проверках не держалась — у твари есть откат воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение вне схватки и расход света на стоянке молча мерили бой. На восьмом этаже тварей мало, и проверка проходила по удаче; * порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь списывается только в ADVANCING: топтание и драка бесплатны, тратит РАЗВЕДКА. Без этого таймер возвращался через чёрный ход. Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции не требует — у старых токенов нет хвоста после '@'. --accept PASS без изменений: в песочнице находок нет вовсе. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 4 + docs/01-build-and-run.md | 4 +- docs/07-milestones.md | 2 + docs/17-extraction.md | 10 +- docs/22-loot.md | 84 ++++++++--- src/debug/harness.cpp | 192 ++++++++++++++++++++++++-- src/debug/tools.cpp | 137 ++++++++++++++++++ src/game.cpp | 44 ++++-- src/sim/affix.cpp | 283 ++++++++++++++++++++++++++++++++++++++ src/sim/affix.h | 160 +++++++++++++++++++++ src/sim/agent.cpp | 47 ++++++- src/sim/agent.h | 7 + src/sim/bullet.cpp | 6 +- src/sim/bullet.h | 7 +- src/sim/items.h | 94 ++++++++++++- src/sim/loot.cpp | 7 +- src/sim/loot.h | 7 +- src/sim/profile.cpp | 108 +++++++++++++-- src/sim/profile.h | 11 +- src/sim/spawn_catalog.cpp | 12 +- src/sim/spawn_catalog.h | 5 +- src/ui/menu.cpp | 92 ++++++++----- 22 files changed, 1210 insertions(+), 113 deletions(-) create mode 100644 src/sim/affix.cpp create mode 100644 src/sim/affix.h diff --git a/CLAUDE.md b/CLAUDE.md index 98bb1e6..2edab17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей). Числа не должны спасать — иначе хоррор кончается на сороковом этаже ([20-gdd.md](docs/20-gdd.md)). +- **Предмет — это ЭКЗЕМПЛЯР, а не вид.** `ItemInstance` неявно приводится к + `ItemId`, и это удобно ровно до того места, где вещь копируют: присваивание в + `ItemId` молча теряет редкость и аффиксы. Подбор, перекладывание и запись + профиля обязаны работать с экземпляром целиком ([22-loot.md](docs/22-loot.md)). - **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)). - **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции diff --git a/docs/01-build-and-run.md b/docs/01-build-and-run.md index 76d802b..e8a8805 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -131,7 +131,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--seed N` | сид сборки уровня (по умолчанию 1) | | `--depth N` | начать не с первого этажа (для проверок) | | `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль | -| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` | +| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix` | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--no-profile` | не читать и не писать профиль | @@ -164,6 +164,8 @@ squad_proto.exe --tool ttk # время убийства и разбро squad_proto.exe --tool enemies # здоровье и сопротивления тварей squad_proto.exe --tool reactions # реакции и три ограничителя squad_proto.exe --tool economy # доход опыта против цены дерева +squad_proto.exe --tool drops 1 100 # кривая редкости находок по глубине +squad_proto.exe --tool affix 40 # бюджет предмета и потолок аффиксов ``` Редактор комнат — отдельное приложение с собственными ключами: diff --git a/docs/07-milestones.md b/docs/07-milestones.md index 84240d9..880b89e 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -38,6 +38,8 @@ | **M24** | отряд растёт с ОДНОГО оперативника до пяти: размер в профиле, найм за опыт по кривой 600/2400/9600/38400, стартовый комплект одиночки — нож, фонарь, куртка; пустой слот отличается от павшего (`present` против `alive`) | проверка в `--descend`: в профиле 1 → на этаже 1, найм доводит второго, шестой не нанимается ни за какие деньги; одиночка с ножом на первом этаже выживает и убивает двух тварей, теряя 59 из 100 здоровья; первая версия этой проверки была ПУСТОЙ — боец стоял минуту, к нему не подошёл никто, и «выжил» ничего не значило; попутно нашлось, что `Profile::Save` три вехи не писала ни версию, ни улучшения, а круговой прогон сверял три поля из десяти и потому этого не видел; `--accept` PASS без изменений — без профиля отряд по-прежнему впятером ([17-extraction.md](17-extraction.md)) | +| **M25** | редкости и аффиксы: экземпляр предмета вместо вида (`ItemInstance`), четыре редкости, десять тегов, бюджет и тир по глубине; выкатанное ложится в те же четыре числа бойца и в стихии оружия; инструменты `--tool drops` и `--tool affix` | замеры поймали ТРИ расхождения дизайн-документа с самим собой: формула редкости противоречила своей же таблице (2.7% против 22% на первом этаже), бюджет пускал восемь высших аффиксов вместо обещанных шести, а предельная вещь собиралась к семидесятому этажу вместо сотого — числа пересчитаны, документ исправлен; проверка «доезжает до бойца» показала урон 12→18, здоровье 100→140, броню 0.10→0.25, конверсию KIN 1.00→0.60 при сумме долей ровно 1.000; `--accept` PASS без изменений ([22-loot.md](22-loot.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/17-extraction.md b/docs/17-extraction.md index 31039ef..4ac060d 100644 --- a/docs/17-extraction.md +++ b/docs/17-extraction.md @@ -168,6 +168,11 @@ stash saber plate vest smg bow charm lantern spurs меняется с тварями местами, и первым теперь видит не он. **Главное решение — заряд тратится за пройденное расстояние, а не за время.** +Точнее: за расстояние, пройденное **под управлением игрока** (состояние +`ADVANCING`). Топтание в строю не считается — замер показал, что центр отряда за +две секунды «стоянки» проходит около 0.8 тайла, и списывай игра это, заряд таял +бы, пока игрок думает над картой. То есть вернулся бы тот самый таймер. Драка +тоже бесплатна: свет тратит РАЗВЕДКА, а не бой. Расход по секундам был бы таймером, только без циферблата: садящийся сам собой фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого. @@ -317,7 +322,10 @@ squad_proto.exe --descend 7 --no-profile боезапас выглядел бы ровно так же. У света первая строка проверки — **не про баланс, а про жанр**: заряд на -стоянке обязан стоять. Написать таймер случайно очень легко (одна строка со +стоянке обязан стоять, и порог у неё **в тайлах, а не в заряде**. Один и тот же +шаг стоит тем дороже, чем глубже (`LightDrain`), и порог в единицах заряда +объявлял таймером обычное топтание на шестидесятом этаже, пропуская его на +восьмом. Написать таймер случайно очень легко (одна строка со `dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково. Расход сравнивается с числом из каталога, и первая версия проверки показала diff --git a/docs/22-loot.md b/docs/22-loot.md index 08a409a..c8fc4c4 100644 --- a/docs/22-loot.md +++ b/docs/22-loot.md @@ -8,20 +8,36 @@ ## 1. Редкости -| Редкость | Аффиксов | Доля дропа D1 | D50 | D100 | -|---|---|---|---|---| -| Обычное | 0 | 78% | 34% | 12% | -| Меченое | 1–2 | 20% | 40% | 30% | -| Разработка | 3–4 | 2% | 20% | 34% | -| Реликт | 5–6 | — | 5.5% | 20% | -| **Артефакт** | уникальное правило + 2 | — | 0.5% | 4% | +Сделано в M25. Числа ниже — **замеренные** (`--tool drops 1 100`, по 10000 +бросков на глубину), а не задуманные: + +| Редкость | Аффиксов | D1 | D28 | D55 | D100 | +|---|---|---|---|---|---| +| Обычное | 0 | 96.9% | 67.5% | 38.4% | 12.3% | +| Меченое | 1–2 | 2.9% | 21.3% | 23.5% | 21.9% | +| Разработка | 3–4 | 0.1% | 7.1% | 25.1% | 39.6% | +| Реликт | 5–6 | ~0.01% | 4.1% | 13.1% | 26.2% | +| **Артефакт** | уникальное правило + 2 | — | — | — | — | Кривая редкости: ``` -P(rare+) = 0.02 + 0.0075·d (до 0.80) -P(артефакт) = max(0, 0.004·(d − 30)) (до 0.06) +P(не обычное) = 0.02 + 0.011·d (до 0.88) ``` +**Наклон 0.011, а не 0.0075.** В первой редакции этого документа формула и +таблица рядом с ней противоречили друг другу: формула давала на первом этаже +2.7% находок с аффиксами, таблица — 22%. Выбрана формула (первый этаж обязан +быть скучным, [20-gdd.md](20-gdd.md) §1), а наклон поднят так, чтобы сойтись с +таблицей на дне: 12% обычных на сотом этаже. Проверяется инструментом. + +Реликт на первом этаже возможен — примерно один на восемь тысяч находок. Это +намеренно не запрещено: тир там всё равно первый, то есть пять рулонов по пять +процентов. Лотерейный выигрыш, а не сломанное начало. + +**Артефактов пока нет** — они не «ещё не выкачены», а следующая веха: артефакт +даёт ПРАВИЛО, а не число, и для этого нужны триггеры (on-hit, on-kill), которых +в игре ещё нет. + **Пити на артефакт:** 60 этажей без артефакта → следующий гарантирован. Без пити распределение даёт игроку без артефакта на восьмидесятом этаже, и это не «не повезло», а сломанная сессия на двадцать часов. @@ -48,13 +64,23 @@ D75. Артефакты общие намеренно — они правила, ``` доступный тир на глубине: T_max(d) = 1 + floor(d / 12) (до 9) диапазон тира T: [lo·T^1.35, hi·T^1.35] -цена аффикса в бюджете: cost(T) = 6 + 4·T -бюджет предмета: B(ilvl) = 40 + 3·ilvl +цена аффикса в бюджете: cost(T) = 4 + 5·T +бюджет предмета: B(ilvl) = 45 + 2.5·ilvl ``` -Из этого следует потолок: на предмете с ilvl 100 бюджет 340 — это шесть аффиксов -девятого тира и ни одним больше. Бюджет не позволяет собрать «всё сразу», и это -единственный барьер, который не приходится подпирать частными запретами. +Из этого следует потолок: на предмете с ilvl 100 бюджет 295, шесть аффиксов +девятого тира стоят 294, седьмой — уже 343. То есть предельная вещь существует +ровно на дне и ровно одна. + +**Цифры пересчитаны против первой редакции.** Там стояло `cost = 6 + 4·T` при +`B = 40 + 3·ilvl` и утверждалось то же самое — «шесть и ни одним больше». На +деле влезало восемь (340 / 42 = 8.1), а предельная вещь собиралась уже к +семидесятому этажу. Обещание документа не выполнялось его же арифметикой; это +нашёл `--tool affix`, а не чтение. + +Выше по глубине потолок держит бюджет, ниже — счётчик аффиксов: на сороковом +этаже доступен только четвёртый тир, шесть таких стоят 144 при бюджете 145. +Это нормально и не является поломкой: редкость там и обещает не больше шести. ### 3.1. Группы аффиксов @@ -158,10 +184,26 @@ share(KIN) = 1 − Σ конверсий, Σ конверсий ≤ 0.85 ## 7. Проверки -| Утверждение | Инструмент | -|---|---| -| кривая редкости даёт артефакт к D50 | `--tool drops 1 120` (новый): симуляция 10⁴ этажей | -| бюджет не пускает шесть высших аффиксов | `--tool affix ` (новый) | -| материалов хватает на сборку за ~8 вылазок | `--tool economy` (новый) | -| ни один артефакт не является чистым плюсом | ревью-чеклист + тест «сборка без цены» | -| TTK не выходит из коридора с лучшими вещами | `--tool ttk <глубина> --best` | +| Утверждение | Инструмент | Статус | +|---|---|---| +| кривая редкости: первый этаж скучный, сотый щедрый | `--tool drops 1 100` | есть | +| бюджет пускает ровно шесть высших аффиксов и только на дне | `--tool affix <глубина>` | есть | +| выкатанное доезжает до бойца и до правил урона | `--descend` | есть | +| редкость и рулоны переживают запись профиля | `--descend`, круговой прогон | есть | +| материалов хватает на сборку за ~8 вылазок | `--tool economy` | материалов ещё нет | +| ни один артефакт не является чистым плюсом | ревью-чеклист | артефактов ещё нет | +| TTK не выходит из коридора с лучшими вещами | `--tool ttk --best` | нет | + +Проверка «доезжает до бойца» — главная. Механика, которая есть в каталоге, но не +доходит до правил боя, выглядит снаружи как работающая, и на этом уже дважды +попадались вес и стихии ([07-milestones.md](07-milestones.md)). Поэтому в +`--descend` подкладывается карабин с известными рулонами и сверяется всё: + +``` +аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25, пробитие 0.20 + стихии карабина: KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000 +``` + +Последнее число тут не украшение: конверсия обязана ПЕРЕВОДИТЬ урон, а не +добавлять седьмую долю сверх единицы. Иначе стихии стали бы каналом наращивания +чисел — ровно тем, чем они не должны быть ([21-damage.md](21-damage.md)). diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index d0116cc..8b5eaa9 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -604,6 +604,25 @@ int RunAccept() return allPass ? 0 : 1; } +// Убрать с этажа всех тварей НАСОВСЕМ. +// +// Насовсем — ключевое слово. Просто `alive = false` не работает: у мишени есть +// откат воскрешения, и с нулевым таймером она оживает тем же шагом. Проверки, +// которым нужно спокойное поле (лечение вне схватки, расход света на стоянке), +// на этом молча меряли бой. +// +// Поймано на глубине 30 и только там: на восьмом этаже тварей мало, и +// ближайшая воскресшая случайно оказывалась дальше девяти тайлов. Проверка +// проходила по УДАЧЕ, а не потому, что поле было чистым. +void ClearField(Game& game) +{ + for (Target& t : game.targets) + { + t.alive = false; + t.respawnTimer = 1e9f; + } +} + // ----------------------------------------------------------------------------- // --descend N: забег на N этажей без окна. // @@ -670,12 +689,21 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t for (int a = 0; a < NUM_AGENTS; ++a) ammoLeft += std::max(0, game.squad.agents[a].ammoReserve); + // Сколько находок на полу ВЫКАТАНЫ. Отдельно от `--tool drops`: тот + // меряет саму кривую на десятках тысяч бросков, а этот столбец + // показывает, что бросок вообще случился на настоящем этаже — то есть + // что генератор лута и аффиксы соединены. + int rolled = 0; + for (const FloorItem& fi : game.floorItems) + if (fi.id.Count() > 0) ++rolled; + std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f" - " (перегруз %d), патронов %d, света на %.0f тайлов\n", + " (перегруз %d), патронов %d, света на %.0f тайлов, с аффиксами %d/%d\n", l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced, double(l.dangerSpent), int(l.loot.size()), l.exitDistance, game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft, - double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth)))); + double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth))), + rolled, int(game.floorItems.size())); // Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд, // обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение @@ -735,7 +763,7 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t else { // Лечиться можно только вне схватки, поэтому поле зачищается. - for (Target& t : game.targets) t.alive = false; + ClearField(game); bag.Clear(); bag.slots[0] = ItemId::AMMO; @@ -834,11 +862,26 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t // 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не // должно стоить ничего заметного. + // + // Поле зачищается насовсем: иначе на глубоких этажах отряд посреди + // «стоянки» отбивается от тварей, бегает и жжёт свет, а проверка + // объявляет это таймером. + ClearField(game); game.in = InputState{}; + + // Отряд сначала ДОХОДИТ до своих мест в строю: после высадки он ещё + // расходится по слотам, и мерить «стоянку» на этом — мерить ходьбу. + for (int k = 0; k < 60; ++k) game.Step(FIXED_DT); + const float standBefore = game.squad.lightCharge; for (int k = 0; k < 120; ++k) game.Step(FIXED_DT); const float standCost = standBefore - game.squad.lightCharge; + // Порог — В ТАЙЛАХ, а не в заряде. Заряд за один и тот же шаг стоит + // тем дороже, чем глубже (LightDrain), и порог в единицах заряда + // объявлял бы таймером обычное топтание в строю на шестидесятом этаже. + const float standTiles = standCost / std::max(0.01f, LightDrain(game.depth)); + // 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону // и делим потраченное на пройденное. // @@ -906,7 +949,7 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t " глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска" " (средний %.0f, здешний %d)\n" " обзор %.2f -> %.2f в темноте, батарея %s\n", - double(standCost), double(perTile), double(LightDrain(game.depth)), + double(standTiles), double(perTile), double(LightDrain(game.depth)), double(path), double(progress), game.depth, double(reach), double(floorsPerCharge), double(avgExit), exitTiles, @@ -923,10 +966,10 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t " не только путь\n", double(perTile), double(expected)); ++bad; } - if (standCost > 1.0f) + if (standTiles > 0.05f) { - std::printf(" [FAIL] заряд тает на стоянке (%.1f за две секунды) — это таймер," - " а таймера в игре нет\n", double(standCost)); + std::printf(" [FAIL] отряд на стоянке прожёг света на %.2f тайла за две секунды —" + " это таймер, а таймера в игре нет\n", double(standTiles)); ++bad; } if (perTile <= 0.0f) @@ -967,6 +1010,108 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t game.squad.lightCharge = savedLight; } + // ------------------------------------------------------------------------- + // Аффиксы. Проверяется ЦЕПОЧКА до бойца, а не сам бросок: бросок меряется + // отдельно и на десятках тысяч образцов (`--tool drops`), а здесь важно + // другое — доходит ли выкатанное число до того, кто дерётся. + // + // Ровно тут ломались все прежние вехи: механика есть, число в каталоге + // есть, а до правил боя оно не доезжает, и снаружи это выглядит как + // работающая система (docs/07-milestones.md). + // ------------------------------------------------------------------------- + { + Agent& a = game.squad.agents[0]; + Inventory* inv = game.world.Get(a.self); + if (!inv) + { + std::printf("\n [FAIL] у бойца нет инвентаря — аффиксы не проверить\n"); + ++bad; + } + else + { + const Inventory savedInv = *inv; + + // Голое состояние: карабин без рулонов. + EquipWeapon(game.world, a, ItemId::RIFLE); + inv->weapon = ItemInstance(ItemId::RIFLE); + inv->armor = ItemInstance(ItemId::JACKET); + game.Step(FIXED_DT); + const float dmgBare = a.gun.def.damage; + const float hpBare = a.maxHp; + const float armorBare = a.armor; + const float kinBare = a.gun.def.elem.share[int(Element::KIN)]; + + // Тот же карабин, но с рулонами. Числа выставлены руками: проверка + // про ПРОВОДКУ, и случайный бросок сделал бы её плавающей. + ItemInstance rifle(ItemId::RIFLE); + rifle.rarity = Rarity::PROTOTYPE; + rifle.affix[rifle.count++] = Affix{AffixTag::DAMAGE, 4, 0, 0.50f}; + rifle.affix[rifle.count++] = Affix{AffixTag::PEN, 3, 0, 0.20f}; + rifle.affix[rifle.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::CRY), 0.40f}; + + ItemInstance jacket(ItemId::JACKET); + jacket.rarity = Rarity::MARKED; + jacket.affix[jacket.count++] = Affix{AffixTag::HEALTH, 6, 0, 40.0f}; + jacket.affix[jacket.count++] = Affix{AffixTag::ARMOR, 4, 0, 0.15f}; + + inv->weapon = rifle; + inv->armor = jacket; + game.Step(FIXED_DT); + + const float dmgRolled = a.gun.def.damage; + const float hpRolled = a.maxHp; + const float armorRolled = a.armor; + const float cryRolled = a.gun.def.elem.share[int(Element::CRY)]; + const float kinRolled = a.gun.def.elem.share[int(Element::KIN)]; + const float penRolled = a.pen; + + // Сумма долей стихий обязана остаться единицей: конверсия ЗАБИРАЕТ + // часть удара, а не добавляет седьмую долю сверху. + float mixSum = 0.0f; + for (int e = 0; e < ELEMENT_COUNT; ++e) mixSum += a.gun.def.elem.share[e]; + + std::printf("\nаффиксы: урон %.1f -> %.1f, здоровье %.0f -> %.0f, броня %.2f -> %.2f," + " пробитие %.2f\n" + " стихии карабина: KIN %.2f -> %.2f, CRY %.2f, сумма долей %.3f\n", + double(dmgBare), double(dmgRolled), double(hpBare), double(hpRolled), + double(armorBare), double(armorRolled), double(penRolled), + double(kinBare), double(kinRolled), double(cryRolled), double(mixSum)); + + if (dmgRolled <= dmgBare * 1.01f) + { + std::printf(" [FAIL] аффикс урона не дошёл до оружия\n"); + ++bad; + } + if (hpRolled <= hpBare || armorRolled <= armorBare) + { + std::printf(" [FAIL] аффиксы брони не дошли до бойца\n"); + ++bad; + } + if (penRolled <= 0.0f) + { + std::printf(" [FAIL] пробитие не дошло до правил урона\n"); + ++bad; + } + if (cryRolled <= 0.0f || kinRolled >= kinBare) + { + std::printf(" [FAIL] конверсия не изменила смесь стихий\n"); + ++bad; + } + if (mixSum < 0.995f || mixSum > 1.005f) + { + std::printf(" [FAIL] сумма долей стихий %.3f: конверсия добавила урон вместо" + " того, чтобы его перевести\n", double(mixSum)); + ++bad; + } + + // Мир возвращается как был: ниже ещё считается переноска и пишется + // профиль, и делать это на подложенном снаряжении нельзя. + *inv = savedInv; + EquipWeapon(game.world, a, inv->weapon); + game.Step(FIXED_DT); + } + } + // ------------------------------------------------------------------------- // Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз» // выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами, @@ -1069,6 +1214,17 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t profile.extractions = 7; profile.wipes = 4; + // И ВЕЩЬ С АФФИКСАМИ — в снаряжении и в схроне. Без неё круговой прогон + // сверял бы предметы без рулонов, то есть ровно ту часть формата, + // которая работала и раньше. + ItemInstance relic(ItemId::MAGNUM); + relic.rarity = Rarity::RELIC; + relic.affix[relic.count++] = Affix{AffixTag::DAMAGE, 7, 0, 0.6321f}; + relic.affix[relic.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::VOID), 0.2718f}; + relic.affix[relic.count++] = Affix{AffixTag::AMMO, 3, 0, 24.0f}; + profile.loadout[0].weapon = relic; + profile.StashPush(relic); + std::string why; Profile back; if (!profile.Save(tmp, &why)) @@ -1110,13 +1266,27 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t same(back.StashCount() == profile.StashCount(), "схрон"); for (int u = 0; u < int(Upgrade::COUNT); ++u) same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u))); + // Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через ItemId + // здесь молчаливо прошло бы: неявная конверсия видит только вид + // вещи, и реликт, доехавший до профиля обычным, выглядел бы как + // совпадение (sim/items.h). + auto sameItem = [&](const ItemInstance& x, const ItemInstance& y, const char* what) { + bool ok = (x.id == y.id) && (x.rarity == y.rarity) && (x.count == y.count); + for (int k = 0; ok && k < x.Count(); ++k) + ok = x.affix[k].tag == y.affix[k].tag && x.affix[k].tier == y.affix[k].tier && + x.affix[k].param == y.affix[k].param && + std::fabs(x.affix[k].value - y.affix[k].value) < 0.001f; + same(ok, what); + }; + for (int a = 0; a < NUM_AGENTS; ++a) { - const Inventory& x = profile.loadout[a]; - const Inventory& y = back.loadout[a]; - same(x.weapon == y.weapon && x.armor == y.armor && x.trinket == y.trinket, - "снаряжение бойца"); + sameItem(profile.loadout[a].weapon, back.loadout[a].weapon, "оружие бойца"); + sameItem(profile.loadout[a].armor, back.loadout[a].armor, "броня бойца"); + sameItem(profile.loadout[a].trinket, back.loadout[a].trinket, "карман бойца"); } + for (int k = 0; k < profile.StashCount() && k < back.StashCount(); ++k) + sameItem(profile.stash[size_t(k)], back.stash[size_t(k)], "вещь из схрона"); if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; } else std::printf("профиль: запись и чтение сходятся по всем полям\n"); diff --git a/src/debug/tools.cpp b/src/debug/tools.cpp index 60700e1..6eff0fd 100644 --- a/src/debug/tools.cpp +++ b/src/debug/tools.cpp @@ -27,6 +27,7 @@ #include "engine/progression.h" #include +#include "sim/affix.h" #include "sim/element.h" #include "sim/reactions.h" #include "sim/items.h" @@ -313,6 +314,140 @@ int CmdEconomy(int argc, char** argv) return 0; } +// ----------------------------------------------------------------------------- +// drops <от> <до>: кривая редкости по глубине. +// +// Проверяет ровно то, что нельзя увидеть в игре: чтобы понять, «часто ли падают +// реликты на сороковом», игроку пришлось бы пройти сорок этажей несколько +// десятков раз. Инструмент бросает по десять тысяч предметов на глубину. +// ----------------------------------------------------------------------------- +int CmdDrops(int argc, char** argv) +{ + const int from = (argc >= 1) ? std::atoi(argv[0]) : 1; + const int to = (argc >= 2) ? std::atoi(argv[1]) : 100; + const int step = std::max(1, (to - from) / 10); + constexpr int ROLLS = 10000; + + std::printf("%-6s %8s %8s %8s %8s %8s %8s\n", "DEPTH", "COMMON", "MARKED", "PROTO", "RELIC", + "AFFIX", "TIER"); + + int bad = 0; + for (int d = from; d <= to; d += step) + { + int byRarity[int(Rarity::COUNT)] = {}; + int affixes = 0; + int tiers = 0; + uint32_t rng = uint32_t(d) * 2654435761u + 7u; + + for (int i = 0; i < ROLLS; ++i) + { + // Куртка — обычная вещь среднего слота: по ней и меряем, чтобы + // цифры сравнивались между глубинами, а не между видами. + const ItemInstance it = RollItem(ItemId::JACKET, d, rng); + ++byRarity[int(it.rarity)]; + affixes += it.Count(); + for (int a = 0; a < it.Count(); ++a) tiers += it.affix[a].tier; + } + + std::printf("%-6d %7.1f%% %7.1f%% %7.1f%% %7.1f%% %8.2f %8.2f\n", d, + 100.0 * byRarity[0] / ROLLS, 100.0 * byRarity[1] / ROLLS, + 100.0 * byRarity[2] / ROLLS, 100.0 * byRarity[3] / ROLLS, + double(affixes) / ROLLS, + (affixes > 0) ? double(tiers) / double(affixes) : 0.0); + + // Первый этаж обязан быть скучным: игра начинается «полупустой + // лабораторией» (docs/20-gdd.md §1). + // + // Проверяется ДОЛЯ, а не сам факт реликта. Реликт на первом этаже + // возможен — примерно один на восемь тысяч находок, — и запрещать его + // незачем: тир там всё равно первый, то есть это пять рулонов по + // пять процентов. Лотерейный выигрыш, а не сломанное начало. Первая + // версия проверки требовала ноль и падала на этой самой лотерее. + if (d == 1 && byRarity[int(Rarity::COMMON)] < ROLLS * 90 / 100) + { + std::printf(" [FAIL] на первом этаже %.1f%% находок с аффиксами — начало не" + " должно быть щедрым\n", + 100.0 - 100.0 * byRarity[int(Rarity::COMMON)] / ROLLS); + ++bad; + } + if (d == 1 && byRarity[int(Rarity::RELIC)] * 1000 > ROLLS) + { + std::printf(" [FAIL] реликты на первом этаже не лотерея, а норма\n"); + ++bad; + } + // А глубокий — не обязан быть скучным никогда. + if (d >= 60 && byRarity[int(Rarity::COMMON)] * 2 > ROLLS) + { + std::printf(" [FAIL] на глубине %d больше половины находок обычные\n", d); + ++bad; + } + } + + std::printf("\nбросков на глубину: %d\n", ROLLS); + return bad == 0 ? 0 : 1; +} + +// ----------------------------------------------------------------------------- +// affix <глубина>: что бюджет пускает на предмет этой глубины. +// +// Главная проверка тут — ПОТОЛОК. Шесть аффиксов высшего тира не должны +// влезать никуда, кроме самого дна: иначе собранный предмет перестаёт быть +// решением и становится просто суммой. +// ----------------------------------------------------------------------------- +int CmdAffix(int argc, char** argv) +{ + const int depth = (argc >= 1) ? std::atoi(argv[0]) : 40; + + std::printf("глубина %d: бюджет %.0f, доступный тир %d, цена тира %.0f..%.0f\n\n", depth, + double(ItemBudget(depth)), MaxAffixTier(depth), double(AffixCost(1)), + double(AffixCost(MaxAffixTier(depth)))); + + std::printf("%-6s %8s %10s %12s\n", "TIER", "COST", "MAX COUNT", "SIX COST"); + int bad = 0; + for (int t = 1; t <= MAX_AFFIX_TIER; ++t) + { + const float cost = AffixCost(t); + const int fit = int(ItemBudget(depth) / cost); + std::printf("%-6d %8.0f %10d %12.0f%s\n", t, double(cost), fit, double(cost * 6.0f), + (t > MaxAffixTier(depth)) ? " (недоступен здесь)" : ""); + } + + // Потолок предмета: шесть аффиксов высшего тира и ни одним больше, и + // только на самом дне. Проверяется АРИФМЕТИКОЙ на предельном уровне, а не + // на текущей глубине: наверху высоких тиров нет вовсе, и там потолок + // держит счётчик аффиксов, а не бюджет, — это нормально и failʼом не + // является (docs/22-loot.md §3). + const float top = AffixCost(MAX_AFFIX_TIER); + const float sixTop = top * 6.0f; + const float bottom = ItemBudget(100); + + // Глубина, на которой предельная вещь впервые становится собираемой. + int needDepth = 1; + while (needDepth < 400 && ItemBudget(needDepth) < sixTop) ++needDepth; + + std::printf("\nшесть аффиксов T%d стоят %.0f, бюджет сотого этажа %.0f -> влезает %.2f\n" + "предельная вещь собирается с глубины %d\n", + MAX_AFFIX_TIER, double(sixTop), double(bottom), double(bottom / top), needDepth); + + if (bottom < sixTop) + { + std::printf(" [FAIL] предельная вещь не собирается даже на дне\n"); + ++bad; + } + if (bottom >= sixTop + top) + { + std::printf(" [FAIL] на дне влезает седьмой высший аффикс — потолок не держит\n"); + ++bad; + } + if (needDepth < 80) + { + std::printf(" [FAIL] предельная вещь собирается уже на %d этаже\n", needDepth); + ++bad; + } + return bad == 0 ? 0 : 1; +} + + } // namespace int RunGameTool(int argc, char** argv) @@ -326,6 +461,8 @@ int RunGameTool(int argc, char** argv) if (std::strcmp(cmd, "ttk") == 0) return CmdTtk(); if (std::strcmp(cmd, "reactions") == 0) return CmdReactions(); if (std::strcmp(cmd, "economy") == 0) return CmdEconomy(argc - 1, argv + 1); + if (std::strcmp(cmd, "drops") == 0) return CmdDrops(argc - 1, argv + 1); + if (std::strcmp(cmd, "affix") == 0) return CmdAffix(argc - 1, argv + 1); std::printf("нет команды `%s`\n\n", cmd); CmdHelp(); diff --git a/src/game.cpp b/src/game.cpp index 045ac80..3606a02 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -153,8 +153,11 @@ void Game::HarvestToProfile(bool onlySurvivors) const bool anySurvivor = AliveAgents() > 0; for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) { - const ItemId id = squad.bag.slots[s]; - if (id == ItemId::NONE) continue; + // ЭКЗЕМПЛЯР, а не вид: через ItemId вещь доехала бы до схрона без + // аффиксов, то есть игрок вынес бы реликт, а на базе нашёл обычную + // куртку. Неявная конверсия молчит, поэтому такие места пишутся явно. + const ItemInstance& id = squad.bag.slots[s]; + if (id.Empty()) continue; if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought; else ++report.itemsLost; @@ -292,7 +295,7 @@ void Game::BuildFloor(const SquadCarry* carry) { int unknownLoot = 0; SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns); - SpawnLootFromLevel(world, level.loot, &unknownLoot); + SpawnLootFromLevel(world, level.loot, depth, levelSeed, &unknownLoot); levelUnknownSpawns += unknownLoot; } else @@ -332,17 +335,28 @@ void Game::BuildFloor(const SquadCarry* carry) // Патроны выдаются ЛЮДЯМ. Пустому слоту запас не нужен, и выдай его // туда — отряд из одного оперативника выходил бы с пятикратным // боезапасом, разложенным по четырём никому. + // + // Аффикс ROUNDS прибавляется ЛИЧНО тому, у кого он выкатан: запас + // патронов личный, в отличие от света. Складывать его в общий котёл + // значило бы, что найденная кем-то одним разгрузка кормит весь отряд. for (int i = 0; i < NUM_AGENTS; ++i) { Agent& a = squad.agents[i]; - a.ammoReserve = combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus) : 0) : -1; + a.ammoReserve = + combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus + a.mods.ammo) : 0) : -1; } + // Аффикс LIGHT — наоборот, ОБЩИЙ: фонарь у отряда один (sim/items.h), + // и «мой фонарь держит дольше» тут не бывает. + float lightBonus = 0.0f; + for (int i = 0; i < NUM_AGENTS; ++i) + if (squad.agents[i].present) lightBonus += squad.agents[i].mods.light; + // Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает // вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь // смысл — на четвёртом этаже светить уже почти нечем. // В песочнице приёмки света бесконечно (-1), как и патронов. - squad.lightCharge = combatMode ? LIGHT_FULL : -1.0f; + squad.lightCharge = combatMode ? (LIGHT_FULL + lightBonus) : -1.0f; } lanes.Clear(); @@ -544,14 +558,20 @@ void Game::Step(float dt) squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt); // --- 2.5 свет ------------------------------------------------------------- - // Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и меряется путь по центру отряда, а не + // Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и путь меряется по центру отряда, а не // по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в // стену, и светил бы фонарь на месте. // - // Топтание в строю сюда попадает тоже, но оно на порядок меньше похода - // через этаж и на решение не влияет. Считать «только осмысленное движение» - // значило бы завести второе понятие пути ради полутора процентов. - if (squad.lightCharge > 0.0f) + // Считается ТОЛЬКО в ADVANCING, то есть когда игрок ведёт отряд. И это не + // мелочь: в строю бойцы постоянно подтягиваются к слотам и расталкиваются, + // и центр отряда за две секунды «стоянки» проходит около 0.8 тайла. Списывай + // мы и это — заряд таял бы, пока игрок думает над картой, то есть вернулся + // бы ровно тот таймер, ради отсутствия которого расход и считается за путь + // (docs/17-extraction.md). Замер поймал это на трёх глубинах сразу. + // + // Перестановки в бою при этом бесплатны — и правильно: свет тратит РАЗВЕДКА, + // а не драка. + if (squad.lightCharge > 0.0f && squad.state == AgentState::ADVANCING) { const float walked = Distance(squad.Centroid(), lightFrom); squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth)); @@ -889,7 +909,7 @@ void Game::FireWeapons(const SolverContext& ctx, float dt) for (int p = 0; p < pellets; ++p) { dir = FromAngle(axis + g.ShotOffset(i, spread, p)); - SpawnBullet(world, g.def, muzzle, dir, travel, i); + SpawnBullet(world, g.def, muzzle, dir, travel, i, a.pen); } events.Push(CombatEventKind::SHOT, i, muzzle, dir); @@ -1031,7 +1051,7 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt) // Через ту же формулу, что и пуля: клинок отличается // формой удара, а не правилами урона. tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist, - ResistCutOf(tg.status)) * + ResistCutOf(tg.status) + a.pen) * VulnerabilityOf(tg.status); tg.hurtTimer = g_tune.targetHurtTime; KnockTarget(tg, dir, m.curKnock); diff --git a/src/sim/affix.cpp b/src/sim/affix.cpp new file mode 100644 index 0000000..9276583 --- /dev/null +++ b/src/sim/affix.cpp @@ -0,0 +1,283 @@ +#include "sim/affix.h" + +#include +#include + +namespace +{ + +// Свой генератор, а не : броски обязаны быть одинаковыми на всех +// платформах и сборках. `std::uniform_real_distribution` этого не обещает, и +// «тот же сид — тот же уровень» на нём разъезжается между компиляторами. +uint32_t NextRand(uint32_t& s) +{ + s = s * 1664525u + 1013904223u; + return s; +} + +float Frand(uint32_t& s) { return float(NextRand(s) & 0xFFFFFF) / float(0x1000000); } + +int RandRange(uint32_t& s, int lo, int hi) // [lo, hi] +{ + if (hi <= lo) return lo; + return lo + int(NextRand(s) % uint32_t(hi - lo + 1)); +} + +// Теги, которые ложатся на оружие, броню и карман. Списком, потому что это +// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на +// таблицу, а не выводя из вида предмета. +const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT, + AffixTag::AMMO, AffixTag::SPEED}; +const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED, + AffixTag::CARRY, AffixTag::AMMO}; +const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH, + AffixTag::SPEED, AffixTag::CARRY}; + +} // namespace + +const char* AffixName(AffixTag t) +{ + switch (t) + { + case AffixTag::DAMAGE: return "DAMAGE"; + case AffixTag::PEN: return "PIERCE"; + case AffixTag::CONVERT: return "CONVERT"; + case AffixTag::ARMOR: return "ARMOR"; + case AffixTag::HEALTH: return "HEALTH"; + case AffixTag::SPEED: return "SPEED"; + case AffixTag::SIGHT: return "SIGHT"; + case AffixTag::AMMO: return "ROUNDS"; + case AffixTag::CARRY: return "CARRY"; + case AffixTag::LIGHT: return "LIGHT"; + default: return "-"; + } +} + +void AffixLabel(const Affix& a, char* out, int cap) +{ + if (!out || cap <= 0) return; + out[0] = '\0'; + + switch (a.tag) + { + // Проценты — там, где число и есть доля. Смешивать «+14%» и «+14» в + // одном списке нельзя: игрок читает подсказку целиком и сравнивает + // строки между собой. + case AffixTag::DAMAGE: + case AffixTag::PEN: + case AffixTag::ARMOR: + case AffixTag::SPEED: + case AffixTag::SIGHT: + std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag), + int(a.value * 100.0f + 0.5f), int(a.tier)); + break; + case AffixTag::CONVERT: + std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d", + int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)), + int(a.tier)); + break; + case AffixTag::CARRY: + std::snprintf(out, size_t(cap), "CARRY +%.1f T%d", double(a.value), int(a.tier)); + break; + default: + std::snprintf(out, size_t(cap), "%s +%d T%d", AffixName(a.tag), int(a.value + 0.5f), + int(a.tier)); + break; + } +} + +bool AffixFitsKind(AffixTag t, ItemKind k) +{ + auto has = [&](const AffixTag* list, int n) { + for (int i = 0; i < n; ++i) + if (list[i] == t) return true; + return false; + }; + + switch (k) + { + case ItemKind::FIREARM: + case ItemKind::BLADE: return has(WEAPON_TAGS, int(sizeof(WEAPON_TAGS) / sizeof(AffixTag))); + case ItemKind::ARMOR: return has(ARMOR_TAGS, int(sizeof(ARMOR_TAGS) / sizeof(AffixTag))); + case ItemKind::TRINKET: return has(TRINKET_TAGS, int(sizeof(TRINKET_TAGS) / sizeof(AffixTag))); + // Расходники не катаются. Бинт с аффиксами — это не решение, а шум: + // его применяют автоматически и выбрасывают. + default: return false; + } +} + +void AffixMods::Add(const Affix& a) +{ + switch (a.tag) + { + case AffixTag::DAMAGE: damageMul += a.value; break; + case AffixTag::PEN: pen += a.value; break; + case AffixTag::ARMOR: armor += a.value; break; + case AffixTag::HEALTH: hp += a.value; break; + case AffixTag::SPEED: speedMul += a.value; break; + case AffixTag::SIGHT: sightMul += a.value; break; + case AffixTag::AMMO: ammo += a.value; break; + case AffixTag::CARRY: carry += a.value; break; + case AffixTag::LIGHT: light += a.value; break; + case AffixTag::CONVERT: + { + // Конверсии складываются между собой, а кинетика — это остаток. + // Складывать смеси иначе (перемножением долей) значило бы, что два + // аффикса по 20% дают 36%, и игрок никогда не сойдётся с подсказкой. + const int e = int(a.param); + if (e <= 0 || e >= ELEMENT_COUNT) break; + convert.share[e] += a.value; + + float sum = 0.0f; + for (int i = 1; i < ELEMENT_COUNT; ++i) sum += convert.share[i]; + if (sum > 0.85f) // кинетика не исчезает никогда (sim/element.h) + { + const float k = 0.85f / sum; + for (int i = 1; i < ELEMENT_COUNT; ++i) convert.share[i] *= k; + sum = 0.85f; + } + convert.share[int(Element::KIN)] = 1.0f - sum; + break; + } + default: break; + } +} + +void AffixMods::Add(const ItemInstance& it) +{ + for (int i = 0; i < it.Count(); ++i) Add(it.affix[i]); +} + +ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert) +{ + // Доля, которую забрала конверсия, вычитается из КИНЕТИКИ оружия, а не из + // его собственной стихии: дробовик с жаром, на который выкатили холод, + // остаётся зажигательным — просто теперь ещё и холодным. + float taken = 0.0f; + for (int e = 1; e < ELEMENT_COUNT; ++e) taken += convert.share[e]; + if (taken <= 0.0f) return base; + + ElementMix out = base; + const float kin = base.share[int(Element::KIN)]; + const float use = (taken < kin) ? taken : kin; // кинетики меньше, чем забирают + + const float k = (taken > 0.0f) ? use / taken : 0.0f; + for (int e = 1; e < ELEMENT_COUNT; ++e) out.share[e] += convert.share[e] * k; + out.share[int(Element::KIN)] = kin - use; + return out; +} + +Rarity RollRarity(int depth, uint32_t& rng) +{ + // Шанс «не обычного» растёт с глубиной и упирается в 0.88. Потолок нужен: + // без него на дне обычных вещей не остаётся вовсе, и редкость перестаёт + // что-либо значить — сравнивать станет не с чем. + // + // Наклон 0.011 подобран замером (`--tool drops`), а не взят из документа: + // в docs/22-loot.md §1 формула и таблица противоречили друг другу. Формула + // (0.02 + 0.0075·d) давала на первом этаже 2.7% небанальных находок, а + // таблица рядом — 22%. Выбрана формула, потому что первый этаж обязан быть + // скучным по замыслу игры, а наклон поднят так, чтобы сойтись с таблицей на + // дне: 12% обычных на сотом этаже. + const float d = float(depth > 0 ? depth : 1); + const float pRare = std::min(0.88f, 0.02f + 0.011f * d); + + if (Frand(rng) >= pRare) return Rarity::COMMON; + + // Внутри «не обычного» — своя лестница, тоже по глубине: реликты внизу + // становятся обычным делом, наверху их нет вовсе. + const float r = Frand(rng); + const float pRelic = std::min(0.30f, 0.004f * d); + const float pProto = std::min(0.45f, 0.06f + 0.006f * d); + + if (r < pRelic) return Rarity::RELIC; + if (r < pRelic + pProto) return Rarity::PROTOTYPE; + return Rarity::MARKED; +} + +int AffixCountFor(Rarity r, uint32_t& rng) +{ + switch (r) + { + case Rarity::MARKED: return RandRange(rng, 1, 2); + case Rarity::PROTOTYPE: return RandRange(rng, 3, 4); + case Rarity::RELIC: return RandRange(rng, 5, 6); + default: return 0; + } +} + +ItemInstance RollItem(ItemId base, int depth, uint32_t& rng) +{ + ItemInstance it(base); + if (base == ItemId::NONE) return it; + + const ItemDef def = ItemDefOf(base); + it.rarity = AffixFitsKind(AffixTag::DAMAGE, def.kind) || + AffixFitsKind(AffixTag::ARMOR, def.kind) || + AffixFitsKind(AffixTag::SIGHT, def.kind) + ? RollRarity(depth, rng) + : Rarity::COMMON; + + int want = AffixCountFor(it.rarity, rng); + if (want <= 0) return it; + + // Уровень предмета — это глубина, на которой он найден. Отсюда и бюджет: + // одна и та же куртка внизу держит больше аффиксов, чем наверху. + float budget = ItemBudget(depth); + const int tierCap = MaxAffixTier(depth); + + // Список тегов, доступных этому виду. Порядок перемешивается броском, а не + // берётся как есть: иначе первый тег в таблице оказывался бы на каждом + // предмете в игре. + AffixTag pool[int(AffixTag::COUNT)]; + int poolCount = 0; + for (int t = 1; t < int(AffixTag::COUNT); ++t) + if (AffixFitsKind(AffixTag(t), def.kind)) pool[poolCount++] = AffixTag(t); + if (poolCount == 0) return it; + + for (int i = poolCount - 1; i > 0; --i) + { + const int j = RandRange(rng, 0, i); + const AffixTag tmp = pool[i]; + pool[i] = pool[j]; + pool[j] = tmp; + } + + if (want > poolCount) want = poolCount; // один тег один раз (правило 2) + if (want > MAX_AFFIXES) want = MAX_AFFIXES; + + for (int i = 0; i < want; ++i) + { + // Тир — случайный до потолка глубины, но не дороже остатка бюджета. + // Именно так бюджет и ограничивает: не «нельзя шесть высших», а + // «на шестой уже не хватило». + int tier = RandRange(rng, 1, tierCap); + while (tier > 1 && AffixCost(tier) > budget) --tier; + if (AffixCost(tier) > budget) break; + budget -= AffixCost(tier); + + const AffixRange base_ = AffixBase(pool[i]); + const float scale = std::pow(float(tier), 1.35f); + + Affix a; + a.tag = pool[i]; + a.tier = uint8_t(tier); + a.value = (base_.lo + (base_.hi - base_.lo) * Frand(rng)) * scale; + + // Стихия конверсии — любая, кроме кинетики: «перевести урон в + // кинетику» не значит ничего. + if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1)); + + it.affix[it.count++] = a; + } + + // Аффиксов могло влезть меньше, чем обещала редкость. Понижаем её до + // фактического содержимого: «реликт» с двумя аффиксами — это обман, + // причём тот самый, который игрок замечает первым. + if (it.count <= 0) it.rarity = Rarity::COMMON; + else if (it.count <= 2) it.rarity = Rarity::MARKED; + else if (it.count <= 4) it.rarity = Rarity::PROTOTYPE; + else it.rarity = Rarity::RELIC; + + return it; +} diff --git a/src/sim/affix.h b/src/sim/affix.h new file mode 100644 index 0000000..74aa626 --- /dev/null +++ b/src/sim/affix.h @@ -0,0 +1,160 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Редкости и аффиксы: то, что превращает находку в РЕШЕНИЕ. +// +// До этой вехи предмет был строкой каталога: «карабин» на первом этаже и +// «карабин» на сотом — одна и та же вещь. Идти за ней в темноту было незачем, +// а весь спуск держался на количестве находок, а не на их качестве. +// +// Аффикс = тег + тир + рулон (docs/22-loot.md §3). Тег говорит, ЧТО он даёт; +// тир — насколько сильным он бывает на этой глубине; рулон — конкретное число. +// +// ТРИ ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ АРИФМЕТИКОЙ, А НЕ СПИСКАМИ ЗАПРЕТОВ: +// +// 1. БЮДЖЕТ. У предмета есть бюджет по уровню, аффикс стоит по тиру. Шесть +// высших аффиксов не влезают никуда, кроме предмета сотого уровня, — и +// это единственный барьер, который не приходится подпирать частными +// исключениями. Список исключений разрастается и однажды противоречит +// сам себе. +// 2. ОДИН ТЕГ ОДИН РАЗ. Иначе «+урон» шесть раз становится единственной +// правильной сборкой, а весь остальной список — мусором. +// 3. КИНЕТИКА НЕ ИСЧЕЗАЕТ. Конверсий не больше 0.85 в сумме: оружие без +// физического урона перестало бы работать по целям без сопротивлений +// вовсе (sim/element.h). +// +// Аффиксы НЕ заводят новых каналов влияния. Каждый тег кладётся в то, что уже +// есть: урон оружия, сопротивления, стихии, статы бойца, ресурсы вылазки. Это +// не экономия, а то же правило, что и у снаряжения: у предмета нет ручки к +// правилу «не бить своих», и появиться она не может (sim/items.h). +// ----------------------------------------------------------------------------- +#include + +#include "sim/element.h" +#include "sim/items.h" + +// Сами типы (Rarity, AffixTag, Affix, ItemInstance) объявлены в sim/items.h: +// экземпляр предмета — это то, что боец несёт, и место ему там. Здесь — вся +// арифметика вокруг них. + +inline const char* RarityName(Rarity r) +{ + switch (r) + { + case Rarity::MARKED: return "MARKED"; + case Rarity::PROTOTYPE: return "PROTOTYPE"; + case Rarity::RELIC: return "RELIC"; + default: return "COMMON"; + } +} + +// Цвет рамки и подписи. Байты RGB: в sim/ типов рендера нет. +struct RarityColor { uint8_t r, g, b; }; + +inline RarityColor RarityColorOf(Rarity r) +{ + switch (r) + { + case Rarity::MARKED: return {120, 190, 235}; + case Rarity::PROTOTYPE: return {170, 140, 240}; + case Rarity::RELIC: return {235, 165, 70}; + default: return {190, 190, 190}; + } +} + +// Доступный тир на глубине и стоимость аффикса в бюджете предмета. +constexpr int MAX_AFFIX_TIER = 9; + +inline int MaxAffixTier(int depth) +{ + const int t = 1 + (depth > 0 ? depth : 1) / 12; + return (t < MAX_AFFIX_TIER) ? t : MAX_AFFIX_TIER; +} + +// Цена тира и бюджет предмета. Числа НЕ те, что стояли в docs/22-loot.md §3: +// там было cost = 6 + 4·T при бюджете 40 + 3·ilvl, и утверждалось, что на +// предмете сотого уровня это даёт «шесть аффиксов девятого тира и ни одним +// больше». На деле давало восемь (340 / 42 = 8.1), а предмет-максимум +// собирался уже к семидесятому этажу. +// +// Пересчитано так, чтобы обещание выполнялось арифметически: +// +// 6 · cost(9) = 294 <= B(100) = 295 < 7 · cost(9) = 343 +// +// То есть предельная вещь существует ровно на дне и ровно одна. Проверяется +// инструментом `--tool affix`, а не глазами. +inline float AffixCost(int tier) { return 4.0f + 5.0f * float(tier); } +inline float ItemBudget(int ilvl) { return 45.0f + 2.5f * float(ilvl); } + +// Диапазон рулона тега на первом тире. Дальше растёт как T^1.35 — так высокий +// тир заметно лучше низкого, но не превращает предмет в другую игру. +struct AffixRange { float lo, hi; }; + +inline AffixRange AffixBase(AffixTag t) +{ + switch (t) + { + case AffixTag::DAMAGE: return {0.05f, 0.09f}; + case AffixTag::PEN: return {0.03f, 0.06f}; + case AffixTag::CONVERT: return {0.08f, 0.14f}; + case AffixTag::ARMOR: return {0.02f, 0.04f}; + case AffixTag::HEALTH: return {4.0f, 8.0f}; + case AffixTag::SPEED: return {0.02f, 0.035f}; + case AffixTag::SIGHT: return {0.03f, 0.05f}; + case AffixTag::AMMO: return {8.0f, 14.0f}; + case AffixTag::CARRY: return {0.30f, 0.55f}; + case AffixTag::LIGHT: return {18.0f, 30.0f}; + default: return {0.0f, 0.0f}; + } +} + +const char* AffixName(AffixTag t); + +// Строка для подсказки: «DAMAGE +14%». Пишет в буфер, потому что подсказки +// собираются на стеке и аллокаций в UI за кадр быть не должно. +void AffixLabel(const Affix& a, char* out, int cap); + +// Какие теги вообще ложатся на этот вид предмета. Каталог, а не «на что +// похоже»: конверсия на куртке — это урон, которого у куртки нет. +bool AffixFitsKind(AffixTag t, ItemKind k); + +// --- накопленные модификаторы ------------------------------------------------- +// Сумма аффиксов надетого. Считается в SyncLoadout один раз за шаг, дальше все +// читают готовые числа: лазать по аффиксам из правил боя нельзя, там про +// предметы не знают вообще. +struct AffixMods +{ + float damageMul = 1.0f; // множитель урона оружия + float pen = 0.0f; // пробитие сопротивлений + ElementMix convert; // во что переведена часть урона + float armor = 0.0f; + float hp = 0.0f; + float speedMul = 1.0f; + float sightMul = 1.0f; + float ammo = 0.0f; + float carry = 0.0f; + float light = 0.0f; + + void Add(const Affix& a); + void Add(const ItemInstance& it); +}; + +// Смешать собственную стихию оружия с конверсией аффиксов. +// +// Конверсия ЗАМЕНЯЕТ часть смеси, а не добавляется поверх: у оружия смесь одна +// и сумма долей всегда единица (sim/element.h). Иначе «дробовик с жаром плюс +// 20% холода» давал бы 120% урона, и стихии стали бы каналом наращивания +// чисел — ровно тем, чем они не должны быть. +ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert); + +// --- бросок предмета ---------------------------------------------------------- +// Сид передаётся снаружи: генерация уровня детерминирована по сиду, и находки +// обязаны быть частью этой же детерминированности. Иначе «тот же уровень» — это +// уже не тот же уровень, и сравнить два прогона нельзя. +ItemInstance RollItem(ItemId base, int depth, uint32_t& rng); + +// Редкость по глубине. Отдельно от RollItem, потому что её проверяют +// инструментом на десятках тысяч бросков (`--tool drops`). +Rarity RollRarity(int depth, uint32_t& rng); + +// Сколько аффиксов у этой редкости (нижняя и верхняя граница). +int AffixCountFor(Rarity r, uint32_t& rng); diff --git a/src/sim/agent.cpp b/src/sim/agent.cpp index 89c0e5a..a8c967e 100644 --- a/src/sim/agent.cpp +++ b/src/sim/agent.cpp @@ -1,5 +1,7 @@ #include "sim/agent.h" +#include "sim/affix.h" + Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed) { const Vec2 delta = goal - a.pos; @@ -210,20 +212,35 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& // Снаряжение: четыре числа и ни одной ручки к правилам боя (sim/items.h). const Inventory* inv = world.Get(a.self); + + // Аффиксы надетого складываются ОДИН РАЗ здесь, дальше все читают готовые + // числа. Лазать по рулонам из правил боя нельзя: там про предметы не знают. + a.mods = AffixMods{}; if (inv) { - a.armor = inv->Armor(); + a.mods.Add(inv->weapon); + a.mods.Add(inv->armor); + a.mods.Add(inv->trinket); + } + + if (inv) + { + // Аффиксы кладутся в ТЕ ЖЕ четыре числа, что и снаряжение: у предмета + // не появляется нового канала влияния оттого, что на нём есть рулоны + // (sim/items.h). + a.armor = std::min(ARMOR_CAP, inv->Armor() + a.mods.armor); // Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе // скорости: движению всё равно, почему боец медленный. Разделять их // пришлось бы везде, где скорость читается, а смысла в этом ноль. a.load = inv->Load() + bagShare; - a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry); + a.speedMul = inv->SpeedMul() * a.mods.speedMul * + CarryMul(a.load, buffs.carry + a.mods.carry); // Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и - // купленное. Улучшение «обзор» без света не спасает: сел фонарь — - // светить нечем, сколько бы отряд ни вложил в глаза. - a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight) * + // купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел + // фонарь — светить нечем, сколько бы отряд ни вложил в глаза. + a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight + a.mods.sightMul - 1.0f) * (buffs.lightOut ? DARK_SIGHT_MUL : 1.0f); - a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp; + a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp + a.mods.hp; a.held = inv->weapon; if (a.hp > a.maxHp) a.hp = a.maxHp; } @@ -256,4 +273,22 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs& a.melee.damage = item->blade.damage; a.melee.elem = item->blade.elem; a.melee.knockback = item->blade.knockback; + + // --- аффиксы оружия ------------------------------------------------------- + // Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь: + // правила стрельбы и клинка про предметы не знают вообще и читают уже + // готовые числа (sim/affix.h). + // + // Урон множится, пробитие складывается, конверсия ЗАМЕНЯЕТ смесь стихий, + // а не добавляется к ней: у оружия смесь одна, и «дробовик с жаром плюс + // 20% холода» — это дробовик, у которого часть жара стала холодом. + a.gun.def.damage *= a.mods.damageMul; + a.melee.damage *= a.mods.damageMul; + a.pen = a.mods.pen; + + if (a.mods.convert.share[int(Element::KIN)] < 0.999f) + { + a.gun.def.elem = MergeConversion(a.gun.def.elem, a.mods.convert); + a.melee.elem = MergeConversion(a.melee.elem, a.mods.convert); + } } diff --git a/src/sim/agent.h b/src/sim/agent.h index edb5095..71d2b2b 100644 --- a/src/sim/agent.h +++ b/src/sim/agent.h @@ -13,6 +13,7 @@ #include "engine/math.h" #include "ecs/ecs.h" +#include "sim/affix.h" #include "sim/items.h" #include "sim/loadout.h" #include "sim/target.h" @@ -101,6 +102,12 @@ struct Agent float load = 0.0f; float sightMul = 1.0f; + // Сумма аффиксов надетого и пробитие сопротивлений, которое из неё вышло. + // Считаются в SyncLoadout один раз за шаг (sim/affix.h); правила боя читают + // ТОЛЬКО pen, а остальное уже размазано по статам выше. + AffixMods mods; + float pen = 0.0f; + // --- служебное (рендер/отладка/метрики), на правила не влияет --- Vec2 prevPos{}; // для интерполяции кадра bool hasLane = false; // есть валидная линия огня прямо сейчас diff --git a/src/sim/bullet.cpp b/src/sim/bullet.cpp index 03185f3..05063bd 100644 --- a/src/sim/bullet.cpp +++ b/src/sim/bullet.cpp @@ -5,7 +5,7 @@ #include "debug/metrics.h" void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, - int owner) + int owner, float pen) { std::vector& bullets = world.All(); if (int(bullets.size()) >= MAX_BULLETS) return; @@ -17,6 +17,7 @@ void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, b.origin = from; b.vel = Normalized(dir) * def.bulletSpeed; b.damage = def.damage; + b.pen = pen; b.elem = def.elem; b.knockback = def.knockback; b.radius = def.bulletRadius; @@ -155,7 +156,8 @@ void UpdateBullets(ecs::World& world, const Tilemap& map, // (sim/element.h). Пустота на цели срезает её сопротивления, а // кислота и дуга увеличивают получаемый урон. const float cut = ResistCutOf(t.status); - t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut) * VulnerabilityOf(t.status); + t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut + b.pen) * + VulnerabilityOf(t.status); t.hurtTimer = g_tune.targetHurtTime; KnockTarget(t, dir, b.knockback); // Стихии удара ложатся статусами и могут сложиться в реакцию. diff --git a/src/sim/bullet.h b/src/sim/bullet.h index 5644454..1325e15 100644 --- a/src/sim/bullet.h +++ b/src/sim/bullet.h @@ -28,6 +28,10 @@ struct Bullet Vec2 vel{}; Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают float damage = 0.0f; + // Пробитие сопротивлений от аффиксов стрелка. Летит ВМЕСТЕ С ПУЛЕЙ, а не + // читается из бойца при попадании: стрелок к тому моменту мог сменить + // оружие, погибнуть или уйти — а выстрел уже сделан тем, что было. + float pen = 0.0f; // Из чего состоит удар. Пуля несёт стихию с собой: к моменту попадания // стрелок мог сменить оружие, а урон обязан быть тем, каким его выпустили. ElementMix elem; @@ -53,8 +57,9 @@ struct Bullet // // dir — уже с учётом разброса. Конус разброса проверен на своих ДО спуска // (см. ai/firing_solver.cpp), поэтому отклонившаяся пуля своего не найдёт. +// pen — пробитие сопротивлений от аффиксов стрелка (sim/affix.h). void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, - int owner); + int owner, float pen = 0.0f); // Плевок твари. Летит до своей дальности и бьёт первого встречного бойца. // Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих. diff --git a/src/sim/items.h b/src/sim/items.h index 21a2382..517e3f6 100644 --- a/src/sim/items.h +++ b/src/sim/items.h @@ -98,6 +98,75 @@ struct ItemDef // Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет. constexpr float ARMOR_CAP = 0.72f; +// ----------------------------------------------------------------------------- +// Редкость, аффиксы и ЭКЗЕМПЛЯР предмета. +// +// Типы стоят здесь, а не в sim/affix.h, по простой причине: экземпляр предмета — +// это то, что боец несёт, то есть предмет этого файла. Вся арифметика аффиксов +// (броски, бюджет, накопление) живёт в sim/affix.h и включает этот файл, а не +// наоборот — иначе получилось бы кольцо. +// ----------------------------------------------------------------------------- +enum class Rarity : uint8_t +{ + COMMON = 0, // без аффиксов + MARKED, // 1-2 + PROTOTYPE, // 3-4 + RELIC, // 5-6 + COUNT, +}; + +enum class AffixTag : uint8_t +{ + NONE = 0, + DAMAGE, // +% урона оружия + PEN, // пробитие сопротивлений цели + CONVERT, // доля урона переводится в стихию (param = стихия) + ARMOR, // +броня + HEALTH, // +запас здоровья + SPEED, // +% скорости хода + SIGHT, // +% обзора + AMMO, // +патроны на вылазку + CARRY, // +норма переноски + LIGHT, // +заряд фонаря + COUNT, +}; + +struct Affix +{ + AffixTag tag = AffixTag::NONE; + uint8_t tier = 1; + uint8_t param = 0; // для CONVERT — индекс стихии + float value = 0.0f; +}; + +// Шесть — потолок из бюджета: больше не влезает даже на сотом уровне. +constexpr int MAX_AFFIXES = 6; + +// Экземпляр предмета: то, что лежит на полу, в сумке и на бойце. +// +// ItemId остался ВИДОМ вещи, а не самой вещью. Конверсия в обе стороны сделана +// неявной намеренно: полторы сотни мест сравнивают предмет с ItemId::NONE, +// читают ItemDefOf и рисуют иконку — им до аффиксов дела нет, и переписывать их +// все значило бы менять полкода ради поля, которое они не читают. +// +// Цена решения: присваивание в ItemId молча теряет аффиксы. Мест, где это +// важно (подбор, перекладывание, запись профиля), немного, и все они работают +// с ItemInstance целиком. +struct ItemInstance +{ + ItemId id = ItemId::NONE; + Rarity rarity = Rarity::COMMON; + uint8_t count = 0; + Affix affix[MAX_AFFIXES]; + + ItemInstance() = default; + ItemInstance(ItemId base) : id(base) {} // NOLINT: конверсия намеренная + operator ItemId() const { return id; } + + bool Empty() const { return id == ItemId::NONE; } + int Count() const { return int(count); } +}; + // ----------------------------------------------------------------------------- // Переноска. Порогов ДВА, и это главное решение всей вехи. // @@ -326,7 +395,11 @@ constexpr int SQUAD_BAG_SLOTS = 16; struct SquadBag { - ItemId slots[SQUAD_BAG_SLOTS] = {}; + // Ячейки хранят ЭКЗЕМПЛЯРЫ, а не виды: две одинаковые куртки с разными + // аффиксами — разные вещи, и складывать их в одну ячейку нельзя + // (sim/affix.h). Неявная конверсия в ItemId оставляет весь прежний код + // работать: ему нужен вид, а не рулоны. + ItemInstance slots[SQUAD_BAG_SLOTS]; float Load() const { @@ -335,6 +408,17 @@ struct SquadBag return w; } + // Положить ЭКЗЕМПЛЯР. Отдельно от Take(ItemId): через ItemId вещь теряет + // аффиксы, и подбор с пола обязан идти этим путём, а не тем. + bool TakeItem(const ItemInstance& it, int carriers, float weaponLoad, float bonus = 0.0f) + { + if (!CanTake(it.id, carriers, weaponLoad, bonus)) return false; + const int slot = FreeSlot(); + if (slot < 0) return false; + slots[slot] = it; + return true; + } + int Count() const { int n = 0; @@ -381,16 +465,16 @@ struct SquadBag void Clear() { - for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemId::NONE; + for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemInstance{}; } }; // Компонент ECS на сущности бойца: только НАДЕТОЕ. struct Inventory { - ItemId weapon = ItemId::RIFLE; // в руках - ItemId armor = ItemId::NONE; - ItemId trinket = ItemId::NONE; + ItemInstance weapon = ItemId::RIFLE; // в руках + ItemInstance armor; + ItemInstance trinket; // Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак // весит, нож — нет. diff --git a/src/sim/loot.cpp b/src/sim/loot.cpp index 5989769..d22cc3f 100644 --- a/src/sim/loot.cpp +++ b/src/sim/loot.cpp @@ -2,7 +2,7 @@ #include "sim/agent.h" -void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay) +void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos, float pickupDelay) { if (id == ItemId::NONE) return; @@ -46,7 +46,10 @@ int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag& // обязаны быть видны как вещь под ногами, а не как пропажа. const Inventory* inv = world.Get(a.self); const float weaponLoad = inv ? inv->Load() : 0.0f; - if (!bag.Take(it.id, carriers, weaponLoad, carryBonus)) continue; + // TakeItem, а не Take: через ItemId в сумку легла бы вещь без + // аффиксов, и найденный реликт превращался бы в обычный при + // подъёме с пола — молча, потому что вид у него тот же. + if (!bag.TakeItem(it.id, carriers, weaponLoad, carryBonus)) continue; it.taken = true; ++taken; diff --git a/src/sim/loot.h b/src/sim/loot.h index 15ef26d..df42e80 100644 --- a/src/sim/loot.h +++ b/src/sim/loot.h @@ -27,7 +27,9 @@ struct Agent; struct FloorItem { ecs::Entity self = ecs::NO_ENTITY; - ItemId id = ItemId::NONE; + // Лежит на полу ЭКЗЕМПЛЯР со своими аффиксами: он выкатан при сборке + // этажа и не должен добрасываться заново при подборе (sim/affix.h). + ItemInstance id; Vec2 pos{}; // Предмет не удаляется в момент подбора: пул перетасовывается swap-remove, @@ -42,7 +44,8 @@ struct FloorItem }; // pickupDelay > 0 — предмет полежит, прежде чем его снова можно подобрать. -void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay = 0.0f); +void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos, + float pickupDelay = 0.0f); // Подбор за шаг. Возвращает, сколько предметов поднято. // diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index 55887b2..afdcc33 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -4,6 +4,9 @@ #include #include +#include +#include + #include "engine/text_parse.h" #include "sim/spawn_catalog.h" @@ -28,14 +31,94 @@ const StarterKit STARTER[NUM_AGENTS] = { {ItemId::PISTOL, ItemId::SHROUD, ItemId::NONE}, }; -ItemId ItemFromToken(const std::string& token) +// ----------------------------------------------------------------------------- +// Экземпляр предмета в строке профиля. +// +// Формат: `rifle` или `rifle@relic+dmg2:0.18+cnv3:0.22` +// +// вид @ редкость + тег тир : значение + ... +// +// Разделители выбраны так, чтобы токен не содержал ПРОБЕЛОВ: предметы лежат в +// строках `agent` и `stash` через пробел, и любой пробел внутри токена разъехал +// бы разбор. Читаемость при этом сохранена — профиль правят руками при отладке, +// и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет. +// ----------------------------------------------------------------------------- +const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp", "spd", "sgt", "amo", "car", "lgt"}; +static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT), + "у каждого тега аффикса обязан быть свой токен в профиле"); + +const char* RARITY_TOKEN[] = {"common", "marked", "proto", "relic"}; +static_assert(sizeof(RARITY_TOKEN) / sizeof(RARITY_TOKEN[0]) == int(Rarity::COUNT), + "у каждой редкости обязан быть свой токен в профиле"); + +std::string ItemToToken(const ItemInstance& it) { - if (token == "-") return ItemId::NONE; - ItemId id = ItemId::NONE; + std::string out = IdOfItem(it.id); + if (it.id == ItemId::NONE || it.Count() <= 0) return out; + + out += "@"; + out += RARITY_TOKEN[int(it.rarity)]; + for (int i = 0; i < it.Count(); ++i) + { + const Affix& a = it.affix[i]; + char buf[48]; + std::snprintf(buf, sizeof(buf), "+%s%d,%d:%.4f", AFFIX_TOKEN[int(a.tag)], int(a.tier), + int(a.param), double(a.value)); + out += buf; + } + return out; +} + +ItemInstance ItemFromToken(const std::string& token) +{ + ItemInstance out; + if (token == "-" || token.empty()) return out; + + const size_t at = token.find('@'); + const std::string base = (at == std::string::npos) ? token : token.substr(0, at); + // Неизвестное имя — пустая ячейка, а не отказ читать профиль: предмет мог // исчезнуть из игры между версиями, и терять из-за этого весь схрон нельзя. - ItemIdOfId(token, id); - return id; + ItemId id = ItemId::NONE; + ItemIdOfId(base, id); + out.id = id; + if (at == std::string::npos || id == ItemId::NONE) return out; + + // Хвост: редкость и аффиксы. Разбирается терпимо — мусорный аффикс + // пропускается, а не роняет чтение вместе со всем схроном. + size_t pos = at + 1; + const size_t firstPlus = token.find('+', pos); + const std::string rar = token.substr(pos, (firstPlus == std::string::npos) + ? std::string::npos + : firstPlus - pos); + for (int r = 0; r < int(Rarity::COUNT); ++r) + if (rar == RARITY_TOKEN[r]) out.rarity = Rarity(r); + + pos = firstPlus; + while (pos != std::string::npos && out.count < MAX_AFFIXES) + { + const size_t next = token.find('+', pos + 1); + const std::string part = token.substr(pos + 1, (next == std::string::npos) + ? std::string::npos + : next - pos - 1); + pos = next; + + const size_t comma = part.find(','); + const size_t colon = part.find(':'); + if (comma == std::string::npos || colon == std::string::npos || comma > colon) continue; + + const std::string tag = part.substr(0, 3); + Affix a; + for (int t = 1; t < int(AffixTag::COUNT); ++t) + if (tag == AFFIX_TOKEN[t]) a.tag = AffixTag(t); + if (a.tag == AffixTag::NONE) continue; + + a.tier = uint8_t(std::max(1, text::ToInt(part.substr(3, comma - 3), 1))); + a.param = uint8_t(std::max(0, text::ToInt(part.substr(comma + 1, colon - comma - 1), 0))); + a.value = text::ToFloat(part.substr(colon + 1), 0.0f); + out.affix[out.count++] = a; + } + return out; } } // namespace @@ -48,9 +131,9 @@ int Profile::CheckpointCount() const int Profile::DeepestCheckpoint() const { return CheckpointDepth(CheckpointCount() - 1); } -bool Profile::StashPush(ItemId id) +bool Profile::StashPush(const ItemInstance& id) { - if (id == ItemId::NONE) return true; // «положить ничего» — не ошибка + if (id.id == ItemId::NONE) return true; // «положить ничего» — не ошибка if (int(stash.size()) >= STASH_CAP) return false; stash.push_back(id); return true; @@ -182,7 +265,7 @@ bool Profile::Load(const std::string& path, std::string* err) // Боец без оружия не может ни стрелять, ни рубить, и починить это // в игре нечем: пустые руки не меняются на что-то из сумки, если // сумка тоже пуста. - if (inv.weapon == ItemId::NONE) inv.weapon = STARTER[slot].weapon; + if (inv.weapon.Empty()) inv.weapon = STARTER[slot].weapon; p.loadout[slot] = inv; continue; } @@ -202,6 +285,9 @@ bool Profile::Load(const std::string& path, std::string* err) // четверых у того, кто их не терял. if (p.version < 3) p.squadSize = NUM_AGENTS; + // Формат 4 (аффиксы) миграции не требует: у старых токенов нет хвоста + // после '@', и они читаются как обычные вещи — чем и являются. + // Прочитанный файл мог быть старее — записан он будет уже текущей версией. p.version = PROFILE_VERSION; *this = std::move(p); @@ -246,8 +332,8 @@ bool Profile::Save(const std::string& path, std::string* err) const for (int i = 0; i < NUM_AGENTS; ++i) { const Inventory& inv = loadout[i]; - f << "agent " << i << " " << IdOfItem(inv.weapon) << " " << IdOfItem(inv.armor) << " " - << IdOfItem(inv.trinket); + f << "agent " << i << " " << ItemToToken(inv.weapon) << " " << ItemToToken(inv.armor) + << " " << ItemToToken(inv.trinket); f << "\n"; } @@ -256,7 +342,7 @@ bool Profile::Save(const std::string& path, std::string* err) const for (size_t i = 0; i < stash.size(); i += 8) { f << "stash"; - for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << IdOfItem(stash[k]); + for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << ItemToToken(stash[k]); f << "\n"; } diff --git a/src/sim/profile.h b/src/sim/profile.h index a9db3e5..f4f7afd 100644 --- a/src/sim/profile.h +++ b/src/sim/profile.h @@ -117,13 +117,14 @@ const char* UpgradeEffect(Upgrade u); // 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent // 2 — общая сумка, улучшения отряда // 3 — размер отряда (найм): раньше бойцов всегда было пять +// 4 — экземпляры предметов: редкость и аффиксы в токене после '@' // // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // читается вовсе. Второе важнее первого: молча обнулить профиль, который // записала более новая сборка, — это стереть десятки часов, а отказ читать // всего лишь просит не запускать старую сборку. // ----------------------------------------------------------------------------- -constexpr int PROFILE_VERSION = 3; +constexpr int PROFILE_VERSION = 4; struct Profile { @@ -169,8 +170,10 @@ struct Profile // на все слоты: незанятые просто пустые. Inventory loadout[NUM_AGENTS]; - // Общий запас сверх надетого. - std::vector stash; + // Общий запас сверх надетого. Экземпляры, а не виды: схрон — это то, из + // чего собирают следующую вылазку, и вещь без аффиксов в нём была бы уже + // другой вещью (sim/affix.h). + std::vector stash; // --- прогресс ------------------------------------------------------------- int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж @@ -188,7 +191,7 @@ struct Profile int CheckpointCount() const; // --- схрон ---------------------------------------------------------------- - bool StashPush(ItemId id); // false — схрон полон + bool StashPush(const ItemInstance& id); // false — схрон полон bool StashRemoveAt(int index); int StashCount() const { return int(stash.size()); } diff --git a/src/sim/spawn_catalog.cpp b/src/sim/spawn_catalog.cpp index a109a7b..3bec53b 100644 --- a/src/sim/spawn_catalog.cpp +++ b/src/sim/spawn_catalog.cpp @@ -1,5 +1,7 @@ #include "sim/spawn_catalog.h" +#include "sim/affix.h" + #include "sim/loot.h" namespace @@ -114,11 +116,17 @@ int SpawnFromLevel(ecs::World& world, const std::vector& list, const return placed; } -int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int* unknown) +int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int depth, + uint32_t seed, int* unknown) { int placed = 0; int skipped = 0; + // Сид броска — от сида уровня и глубины: тот же уровень обязан дать те же + // находки. Иначе «переиграть с начала» (R) выдавало бы другой лут на той же + // карте, и сравнить два прохода стало бы нечем. + uint32_t rng = seed * 2654435761u + uint32_t(depth) * 40503u + 12345u; + for (const LevelSpawn& s : list) { ItemId item = ItemId::NONE; @@ -127,7 +135,7 @@ int SpawnLootFromLevel(ecs::World& world, const std::vector& list, i // Из стены предмет не выталкивается, в отличие от твари: генератор // кладёт лут только на достижимый пол, и «поправить» его здесь значило // бы прятать ошибку раскладки вместо того, чтобы её увидеть. - SpawnFloorItem(world, item, s.pos); + SpawnFloorItem(world, RollItem(item, depth, rng), s.pos); ++placed; } diff --git a/src/sim/spawn_catalog.h b/src/sim/spawn_catalog.h index 8be4f06..b8ba658 100644 --- a/src/sim/spawn_catalog.h +++ b/src/sim/spawn_catalog.h @@ -37,4 +37,7 @@ int SpawnFromLevel(ecs::World& world, const std::vector& list, const int* unknown = nullptr); // То же для лежащих предметов. -int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int* unknown = nullptr); +// depth и seed нужны броску редкости и аффиксов: находка на сороковом этаже +// обязана отличаться от находки на первом не только именем (sim/affix.h). +int SpawnLootFromLevel(ecs::World& world, const std::vector& list, int depth, + uint32_t seed, int* unknown = nullptr); diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 0a1c661..e39d79a 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -6,6 +6,7 @@ #include #include "game.h" +#include "sim/affix.h" namespace { @@ -178,7 +179,7 @@ struct Widget UiRect rect; const char* text = nullptr; // подпись кнопки; у ячеек её нет int size = 0; // кегль подписи в пикселях кадра - ItemId item = ItemId::NONE; // что лежит в ячейке + ItemInstance item; // что лежит в ячейке (с аффиксами) bool enabled = true; }; @@ -269,12 +270,18 @@ const char* KindName(ItemKind k) // Что вещь делает. Только те строки, которые у неё не нулевые: список из шести // «+0%» читается как «ничего не даёт», хотя даёт. -Tip ItemTip(ItemId id) +Tip ItemTip(const ItemInstance& it) { - const ItemDef d = ItemDefOf(id); + const ItemDef d = ItemDefOf(it.id); Tip t; - t.Add("%s (%s)", d.name, KindName(d.kind)); + // Редкость в заголовке, а не отдельной строкой: игрок читает подсказку + // сверху вниз и первое, что ему нужно знать про найденную вещь, — стоит ли + // читать дальше. + if (it.rarity != Rarity::COMMON) + t.Add("%s %s (%s)", RarityName(it.rarity), d.name, KindName(d.kind)); + else + t.Add("%s (%s)", d.name, KindName(d.kind)); t.Add("WEIGHT %.1f", double(d.weight)); if (d.armor > 0.0f) t.Add("ARMOR +%d%%", int(d.armor * 100.0f + 0.5f)); @@ -284,6 +291,16 @@ Tip ItemTip(ItemId id) if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal)); if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds); if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge)); + + // Аффиксы — отдельным блоком после базовых чисел. Смешивать их со статами + // предмета нельзя: базовое приходит с видом вещи и одинаково у всех, а + // выкатанное — то, ради чего эту конкретную вещь подобрали. + for (int i = 0; i < it.Count(); ++i) + { + char line[64]; + AffixLabel(it.affix[i], line, int(sizeof(line))); + t.Add("%s", line); + } return t; } @@ -489,7 +506,7 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) const int k = ui.stashScroll + c; Widget& w = out.Add(UiPart::STASH, k, StashRect(c)); w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)] - : ItemId::NONE; + : ItemInstance{}; } } return; @@ -537,7 +554,7 @@ bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there) // Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на // ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него // всё это по-прежнему «слот, в котором лежит вещь». -ItemId* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index) +ItemInstance* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index) { switch (part) { @@ -553,18 +570,18 @@ ItemId* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index) // Схрон живёт не в Inventory, а в профиле, и ячейка за концом списка — это // место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно: // указателя на несуществующую ячейку не бывает. -ItemId StashTake(Profile& pr, int index) +ItemInstance StashTake(Profile& pr, int index) { - if (index < 0 || index >= pr.StashCount()) return ItemId::NONE; - const ItemId out = pr.stash[size_t(index)]; + if (index < 0 || index >= pr.StashCount()) return ItemInstance{}; + const ItemInstance out = pr.stash[size_t(index)]; pr.StashRemoveAt(index); return out; } // false — схрон полон и предмет остался на месте. Молча терять его нельзя. -bool StashPut(Profile& pr, int index, ItemId id) +bool StashPut(Profile& pr, int index, const ItemInstance& id) { - if (id == ItemId::NONE) return true; + if (id.id == ItemId::NONE) return true; if (index >= 0 && index < pr.StashCount()) { pr.stash.insert(pr.stash.begin() + long(index), id); @@ -588,13 +605,13 @@ void DropToFloor(Ui& ui, Game& game, UiPart part, int index) Inventory* inv = game.world.Get(a.self); if (!inv) return; - ItemId* slot = SlotPtr(*inv, game.squad.bag, part, index); - if (!slot || *slot == ItemId::NONE) return; + ItemInstance* slot = SlotPtr(*inv, game.squad.bag, part, index); + if (!slot || slot->Empty()) return; // Задержка подбора обязательна: вещь ложится там же, где стоит боец, и // без неё тот же шаг поднял бы её обратно. SpawnFloorItem(game.world, *slot, a.pos, 2.0f); - *slot = ItemId::NONE; + *slot = ItemInstance{}; } void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) @@ -615,12 +632,12 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) { // Перекладывание внутри схрона: порядок ячеек — единственное, чем // игрок может им управлять, и ломать его незачем. - const ItemId moved = StashTake(pr, ui.dragIndex); + const ItemInstance moved = StashTake(pr, ui.dragIndex); StashPut(pr, toIndex, moved); return; } - ItemId* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom, + ItemInstance* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom, fromStash ? toIndex : ui.dragIndex); if (!slot) return; @@ -629,18 +646,18 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) // Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если // схрон переполнен, обмен не состоится целиком — иначе вещь из // слота исчезла бы. - const ItemId taken = pr.stash.empty() ? ItemId::NONE : StashTake(pr, ui.dragIndex); - if (taken == ItemId::NONE) return; - const ItemId back = *slot; + const ItemInstance taken = pr.stash.empty() ? ItemInstance{} : StashTake(pr, ui.dragIndex); + if (taken.Empty()) return; + const ItemInstance back = *slot; if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; } *slot = taken; } else { - const ItemId moved = *slot; - if (moved == ItemId::NONE) return; + const ItemInstance moved = *slot; + if (moved.Empty()) return; if (!StashPut(pr, toIndex, moved)) return; // схрон полон - *slot = ItemId::NONE; + *slot = ItemInstance{}; } // Руки могли опустеть или наполниться — сущность-оружие обязана @@ -650,11 +667,11 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) return; } - ItemId* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex); - ItemId* dst = SlotPtr(*inv, game.squad.bag, to, toIndex); + ItemInstance* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex); + ItemInstance* dst = SlotPtr(*inv, game.squad.bag, to, toIndex); if (!src || !dst || src == dst) return; - const ItemId moved = *src; + const ItemInstance moved = *src; *src = *dst; *dst = moved; @@ -956,7 +973,7 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) // бинтом отбиваться нечем. Agent& a = game.squad.agents[agentIndex]; Inventory* inv = game.world.Get(a.self); - ItemId& cell = game.squad.bag.slots[slotIndex]; + ItemInstance& cell = game.squad.bag.slots[slotIndex]; if (inv && slotIndex >= 0 && slotIndex < SQUAD_BAG_SLOTS && IsWeaponItem(cell)) { std::swap(cell, inv->weapon); @@ -990,16 +1007,29 @@ void DrawButton(const Layout& l, const Ui& ui, const Widget& b, bool selected) // Предмет в ячейке: цветной мазок и имя. Цвет берётся из каталога, чтобы // вещь узнавалась быстрее, чем читается слово. -void DrawItem(const Layout& l, ItemId id, int x, int y, int h, bool bright) +void DrawItem(const Layout& l, const ItemInstance& it, int x, int y, int h, bool bright) { - if (id == ItemId::NONE) + if (it.Empty()) { Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT); return; } - const ItemDef d = ItemDefOf(id); + const ItemDef d = ItemDefOf(it.id); Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255}); - Text(l, d.name, x + 12, y + h / 2 - 4, 8, bright ? COL_TEXT : COL_FAINT); + + // Имя красится по РЕДКОСТИ, а мазок остаётся цветом вида. Так вещь + // по-прежнему узнаётся быстрее, чем читается, но «это реликт» видно, не + // наводя курсор: иначе редкость приходилось бы искать, а её смысл в том, + // чтобы бросаться в глаза среди шестнадцати ячеек. + Color name = bright ? COL_TEXT : COL_FAINT; + if (it.rarity != Rarity::COMMON) + { + const RarityColor rc = RarityColorOf(it.rarity); + const unsigned char k = bright ? 255 : 190; + name = Color{(unsigned char)(rc.r * k / 255), (unsigned char)(rc.g * k / 255), + (unsigned char)(rc.b * k / 255), 255}; + } + Text(l, d.name, x + 12, y + h / 2 - 4, 8, name); } // Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же, @@ -1024,7 +1054,7 @@ void DrawCell(const Layout& l, const Ui& ui, const Widget& c, bool selected) // То, что сейчас на курсоре, из своей ячейки убрано: иначе предмет // выглядел бы лежащим в двух местах сразу. const bool taken = ui.dragging && ui.dragFrom == c.part && ui.dragIndex == c.index; - DrawItem(l, taken ? ItemId::NONE : c.item, tx, c.rect.y, c.rect.h, hot || selected); + DrawItem(l, taken ? ItemInstance{} : c.item, tx, c.rect.y, c.rect.h, hot || selected); } void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widgets& w)