diff --git a/CLAUDE.md b/CLAUDE.md index bf080ed..ced2bed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,10 @@ `ItemId`, и это удобно ровно до того места, где вещь копируют: присваивание в `ItemId` молча теряет редкость и аффиксы. Подбор, перекладывание и запись профиля обязаны работать с экземпляром целиком ([22-loot.md](docs/22-loot.md)). +- **Материал добывается только в своей полосе.** Био-ядра — с плотских этажей, + крио — с мёрзлых, и это единственная причина возвращаться на средние глубины + после рекорда. Протечка полос убивает весь контент, кроме последнего акта + ([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 b681a94..71c25a7 100644 --- a/docs/01-build-and-run.md +++ b/docs/01-build-and-run.md @@ -125,14 +125,14 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64 | `--sprites` | что загрузчик видит в `build/sprites`, и выход | | `--windowed` | окно 1440x810 вместо безрамочного на весь экран | | `--sandbox` | прежняя песочница: мишени вместо тварей, отряд без снаряжения | -| `--ui-shot <экран>` | снять кадр с `menu` / `squad` / `pause` / `defeat` | +| `--ui-shot <экран>` | снять кадр с `menu` / `squad` / `pause` / `defeat` / `upgrade` / `workshop` | | `--rooms [проект]` | играть в проект (по умолчанию свой, `descent`) вместо захардкоженной карты | | `--project <имя>` | то же явно: имя каталога в `projects/` или путь к нему | | `--view ` | перебить вид отображения из манифеста проекта (отладочное) | | `--seed N` | сид сборки уровня (по умолчанию 1) | | `--depth N` | начать не с первого этажа (для проверок) | | `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль | -| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix` | +| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix`, `craft` | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--no-profile` | не читать и не писать профиль | @@ -168,6 +168,7 @@ squad_proto.exe --tool reactions # реакции и три ограничит squad_proto.exe --tool economy # доход опыта против цены дерева squad_proto.exe --tool drops 1 100 # кривая редкости находок по глубине squad_proto.exe --tool affix 40 # бюджет предмета и потолок аффиксов +squad_proto.exe --tool craft 80 # материалы: доход за вылазку против цены доводки ``` Редактор комнат — отдельное приложение с собственными ключами: diff --git a/docs/07-milestones.md b/docs/07-milestones.md index c6a7662..ae352a5 100644 --- a/docs/07-milestones.md +++ b/docs/07-milestones.md @@ -42,6 +42,8 @@ | **M26** | `--ui-check`: пункты меню проверяются кодом возврата, как всё остальное | нашлось по жалобе «игра крашится при открытии улучшений»: пункт UPGRADES не крашил, а ВЫХОДИЛ из игры — в обработчике стоял `default: quit`, а экран улучшений добавили четвёртым пунктом, и обработчик о нём не узнал; проверка сперва воспроизведена на сломанной сборке (код 1, «пункт UPGRADES не делает ничего») и только потом объявлена рабочей ([01-build-and-run.md](01-build-and-run.md)) | +| **M27** | материалы и мастерская: пять материалов со своими полосами добычи, пять операций над вещью, экран WORKSHOP; материалы — валюта, зачисляются только при эвакуации | `--tool craft` поймал, что материалы бесплатны: с твари падало по её danger, и вылазка на 80 этаже приносила тысячу ядер при цене доводки в шестьсот (0.6 вылазки на предмет вместо восьми) — правило заменено на «одно ядро с мелочи, два с крупной», стало 6.2; сам инструмент тоже считал неверно, беря доход из бюджета опасности, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей; залом замерен на тысяче попыток: 11.6% потерь при заявленных 12%, платят всегда, слот теряется всегда; `--accept` PASS ([22-loot.md](22-loot.md)) | + ## Что находилось по ходу и чинилось Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, diff --git a/docs/22-loot.md b/docs/22-loot.md index c8fc4c4..9b97eb4 100644 --- a/docs/22-loot.md +++ b/docs/22-loot.md @@ -135,40 +135,85 @@ share(KIN) = 1 − Σ конверсий, Σ конверсий ≤ 0.85 ### 5.1. Материалы +Сделано в M27. + | Материал | Откуда | Куда | |---|---|---| -| Лом | разбитая обстановка, D1+ | базовый крафт, ремонт | -| Био-ядро | твари FLESH, D21+ | статусные аффиксы | -| Крио-ядро | твари FROST, D36+ | защитные аффиксы | -| Осколок реактора | аномалии, D53+ | стихийные и реакционные | -| Пустотный шлак | Бездна, D73+ | залом, перековка тира | +| Лом | разбитая обстановка, любая глубина | перековка | +| Био-ядро | твари на этажах FLESH, D21+ | прививка, возвышение | +| Крио-ядро | твари на этажах FROST, D36+ | отсечение, возвышение | +| Осколок реактора | твари, D53+ | возвышение | +| Пустотный шлак | твари, D73+ | залом | Материал добывается только в своей полосе — это и есть причина возвращаться на средние глубины после того, как рекорд ушёл вниз. Без этого весь контент, кроме последнего акта, умирает. +Полосы проверяются в `--descend` прямым опросом таблицы: в игре протечка была бы +заметна часов через двадцать, когда выяснилось бы, что возвращаться незачем. + +``` + плоть на 25 лом 0, био 2, крио 0, осколок 0, шлак 0 + сталь на 25 лом 0, био 0, крио 0, осколок 0, шлак 0 + мороз на 40 лом 0, био 0, крио 2, осколок 0, шлак 0 +``` + +**Материалы — валюта, а не предметы:** не занимают ячеек, не весят, в схроне не +лежат. Иначе поход за материалами превратился бы в тот же поход с сумкой. Но +теряются они так же, как добыча: заработанное зачисляется только при эвакуации. + +С твари падает **одно ядро, с крупной — два**, и это не «сколько она стоит». +Первая версия роняла по её danger, и `--tool craft` показал, что вылазка на +восьмидесятом этаже приносит тысячу ядер при цене доводки вещи в шестьсот: +материалы не стоили ничего, а мастерская из ставки превращалась в кнопку. + ### 5.2. Операции | Операция | Что делает | Цена | Риск | |---|---|---|---| -| Перековка | заново бросает ВСЕ рулоны | 4 лома × тир | — | -| Прививка | добавляет аффикс, если есть бюджет | 2 ядра × тир | — | -| Отсечение | убирает выбранный аффикс | 1 ядро × тир | — | -| Возвышение | +1 тир случайному аффиксу | 6 ядер + осколок | — | -| **Залом** | +2 тира и −1 бюджет ко всему | 3 шлака | **12%: предмет рассыпается** | +| Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | — | +| Прививка | добавляет аффикс НОВОГО тега, если есть бюджет | 2 био × тир | — | +| Отсечение | убирает последний аффикс | 1 крио × тир | — | +| Возвышение | +1 тир СЛУЧАЙНОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | — | +| **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | **12%: предмет рассыпается** | -Залом — единственная операция с риском, и он настоящий: предмет исчезает. +Три уточнения, каждое против конкретного обхода: + +- перековка меняет **только значения**. Меняй она ещё и теги — это был бы новый + бросок предмета, то есть способ обойти редкость: собери шесть слотов на чём + угодно и крути, пока не выпадет нужное; +- прививка не ставит тег, который уже есть: правило «один тег один раз» + руками не обходится; +- возвышается **случайный** аффикс. Выбирай игрок сам, возвышение стало бы + «+1 тир к урону» за фиксированную цену, и все предметы в игре сошлись бы + к одному. + +Залом — единственная операция с риском, и он настоящий: предмет исчезает, а +плата снимается всё равно. Замерено на тысяче попыток: **рассыпалось 11.6% при +заявленных 12%**, платят всегда, слот теряется всегда. Проверять это иначе +нельзя — «у меня трижды не рассыпалось» не доказательство ни в какую сторону. Именно поэтому лучшая вещь в игре всегда чуть хуже, чем могла бы быть, — почти никто не заламывает то, что собирал двадцать часов. Это и задумано: разрыв между «хорошей сборкой» и «идеальной» держит лейтгейм живым. ### 5.3. Стоимость полной сборки -Одна вещь тира 9 со всеми операциями ≈ 340 ломов, 90 ядер, 22 осколка, 6 шлаков. -За вылазку на D80 добывается ≈ 40 ломов, 12 ядер, 4 осколка, 1 шлак — то есть -**около 8 вылазок на один предмет**. Слотов девять, предметов в сборке — пять -ключевых. Экономика материалов рассчитана на 60–80 часов лейтгейма, и это ровно -акт VI из бюджета времени ([20-gdd.md](20-gdd.md) §5). +Считается инструментом, а не на глаз: `--tool craft 80`. + +``` +на глубине 80: ядер за вылазку 104, полная доводка вещи 648 ядер +то есть 6.2 вылазок на предмет (в документе заявлено около 8) +``` + +Доход **упирается в потолок расстановки**: на этаж влезает не больше +`MAX_LEVEL_SPAWNS` тварей, поэтому с сорокового этажа и глубже вылазка приносит +примерно одно и то же. Глубина даёт не количество материалов, а КАЧЕСТВО +предметов — а вместе с ним и цену доводки, которая растёт с тиром. Отсюда и +кривая: вещь сорокового этажа доводится за три вылазки, вещь сотого — за шесть с +лишним. + +Первая версия расчёта брала доход из бюджета опасности этажа и завысила его +впятеро: бюджет уходит не в количество тварей, а в их силу. ## 6. Как это не превращается в кашу diff --git a/src/debug/harness.cpp b/src/debug/harness.cpp index 8fc84f4..2a5a2a9 100644 --- a/src/debug/harness.cpp +++ b/src/debug/harness.cpp @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include "game.h" +#include "sim/craft.h" #include "ui/menu.h" // Перевод экранных осей ввода в мировые — единственное место, @@ -1167,6 +1169,14 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t // которые начинают новую вылазку (найм, улучшения) и обнуляют счётчик // этажей: отчитываться цифрой, которую испортил измеряющий, уже приходилось // дважды — сперва с улучшениями, потом с размером отряда. + // Материалы, добытые за вылазку, обязаны зачисляться ТОЛЬКО при эвакуации. + // Кладём известные числа перед выходом и сверяем после: это та же ставка, + // что и с добычей, и проверяется она тем же способом. + const int matBefore[MATERIAL_COUNT] = {profile.material[0], profile.material[1], + profile.material[2], profile.material[3], + profile.material[4]}; + for (int m = 0; m < MATERIAL_COUNT; ++m) game.runMaterials[m] = 3 + m; + const int floorsDone = game.floorsCleared + 1; const float xpEarned = game.xp; @@ -1183,6 +1193,21 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t std::printf(" [FAIL] исход не эвакуация\n"); ++bad; } + + { + int credited = 0; + for (int m = 0; m < MATERIAL_COUNT; ++m) + if (profile.material[m] == matBefore[m] + 3 + m) ++credited; + + std::printf("материалы: зачислено видов %d из %d (%d/%d/%d/%d/%d)\n", credited, + MATERIAL_COUNT, profile.material[0], profile.material[1], profile.material[2], + profile.material[3], profile.material[4]); + if (credited != MATERIAL_COUNT) + { + std::printf(" [FAIL] добытое за вылазку не доехало до профиля\n"); + ++bad; + } + } // Вынесено считается по ВСЕМУ снаряжению, а не только по сумкам, поэтому // проверяем неравенством: меньше содержимого сумок быть не может. if (r.itemsBrought < bagsBefore) @@ -1360,6 +1385,200 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t } } + // ------------------------------------------------------------------------- + // Мастерская. Проверяются ПРАВИЛА операций, а не то, что они «есть»: + // каждая обязана менять предмет ровно так, как обещает подпись под ней, и + // списывать за это материалы. + // + // Отдельно и главное — залом. Единственная операция с риском, и риск обязан + // быть настоящим: и вещь теряется, и плата снимается. Меряется он на тысяче + // попыток, потому что «у меня трижды не рассыпалось» — не доказательство. + // ------------------------------------------------------------------------- + { + Profile& pr = *game.profile; + const int savedMat[MATERIAL_COUNT] = {pr.material[0], pr.material[1], pr.material[2], + pr.material[3], pr.material[4]}; + + // Заведомо хватает на всё: проверяются правила, а не бедность. + for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 100000; + + uint32_t rng = 12345u; + ItemInstance it(ItemId::RIFLE); + it.rarity = Rarity::PROTOTYPE; + it.affix[it.count++] = Affix{AffixTag::DAMAGE, 3, 0, 0.30f}; + it.affix[it.count++] = Affix{AffixTag::PEN, 2, 0, 0.10f}; + it.affix[it.count++] = Affix{AffixTag::SPEED, 2, 0, 0.08f}; + + // 1. Перековка: значения меняются, теги и тиры — нет. + const ItemInstance before = it; + const int scrapBefore = pr.material[int(MaterialKind::SCRAP)]; + const CraftResult rr = ApplyCraft(CraftOp::REFORGE, it, pr.material, 40, rng); + bool tagsKept = it.Count() == before.Count(); + bool valueMoved = false; + for (int i = 0; i < it.Count() && tagsKept; ++i) + { + tagsKept = it.affix[i].tag == before.affix[i].tag && + it.affix[i].tier == before.affix[i].tier; + if (std::fabs(it.affix[i].value - before.affix[i].value) > 1e-6f) valueMoved = true; + } + const bool paid = pr.material[int(MaterialKind::SCRAP)] < scrapBefore; + + // 2. Прививка: аффиксов стало больше, тег новый. + const int countBeforeGraft = it.Count(); + const CraftResult gr = ApplyCraft(CraftOp::GRAFT, it, pr.material, 40, rng); + bool grafted = it.Count() == countBeforeGraft + 1; + if (grafted) + { + const AffixTag fresh = it.affix[it.Count() - 1].tag; + for (int i = 0; i < it.Count() - 1; ++i) + if (it.affix[i].tag == fresh) grafted = false; // тег повторился + } + + // 3. Отсечение: аффиксов стало меньше. + const int countBeforeExcise = it.Count(); + const CraftResult er = ApplyCraft(CraftOp::EXCISE, it, pr.material, 40, rng); + const bool excised = it.Count() == countBeforeExcise - 1; + + // 4. Возвышение: у одного аффикса тир вырос ровно на единицу. + int tierSumBefore = 0; + for (int i = 0; i < it.Count(); ++i) tierSumBefore += it.affix[i].tier; + const CraftResult xr = ApplyCraft(CraftOp::EXALT, it, pr.material, 40, rng); + int tierSumAfter = 0; + for (int i = 0; i < it.Count(); ++i) tierSumAfter += it.affix[i].tier; + const bool exalted = tierSumAfter == tierSumBefore + 1; + + std::printf("\nмастерская: перековка %s, прививка %s, отсечение %s, возвышение %s\n", + (rr == CraftResult::OK && tagsKept && valueMoved && paid) ? "ok" : "ПОЛОМКА", + (gr == CraftResult::OK && grafted) ? "ok" : "ПОЛОМКА", + (er == CraftResult::OK && excised) ? "ok" : "ПОЛОМКА", + (xr == CraftResult::OK && exalted) ? "ok" : "ПОЛОМКА"); + + if (rr != CraftResult::OK || !tagsKept || !valueMoved || !paid) + { + std::printf(" [FAIL] перековка: результат %d, теги %s, значения %s, плата %s\n", + int(rr), tagsKept ? "целы" : "СБИТЫ", valueMoved ? "новые" : "ТЕ ЖЕ", + paid ? "снята" : "НЕ СНЯТА"); + ++bad; + } + if (gr != CraftResult::OK || !grafted) + { + std::printf(" [FAIL] прививка не добавила нового тега\n"); + ++bad; + } + if (er != CraftResult::OK || !excised) + { + std::printf(" [FAIL] отсечение не убрало аффикс\n"); + ++bad; + } + if (xr != CraftResult::OK || !exalted) + { + std::printf(" [FAIL] возвышение не подняло тир\n"); + ++bad; + } + + // 5. Залом на тысяче попыток: доля потерь, плата за неудачу и размен. + int destroyed = 0; + int paidTimes = 0; + int slotsLost = 0; + for (int k = 0; k < 1000; ++k) + { + ItemInstance probe(ItemId::VEST); + probe.rarity = Rarity::RELIC; + for (int a = 0; a < 5; ++a) + probe.affix[probe.count++] = Affix{AffixTag::HEALTH, 2, 0, 10.0f}; + + const int slagBefore = pr.material[int(MaterialKind::SLAG)]; + const int countBefore = probe.Count(); + const CraftResult fr = ApplyCraft(CraftOp::FRACTURE, probe, pr.material, 40, rng); + if (pr.material[int(MaterialKind::SLAG)] < slagBefore) ++paidTimes; + if (fr == CraftResult::DESTROYED) ++destroyed; + else if (probe.Count() == countBefore - 1) ++slotsLost; + } + + const float rate = float(destroyed) / 1000.0f; + std::printf("залом: рассыпалось %.1f%% при заявленных %.0f%%, платят всегда %s," + " слот теряется %s\n", + double(rate * 100.0f), double(FRACTURE_RISK * 100.0f), + (paidTimes == 1000) ? "да" : "НЕТ", + (slotsLost + destroyed == 1000) ? "да" : "НЕТ"); + + if (std::fabs(rate - FRACTURE_RISK) > 0.03f) + { + std::printf(" [FAIL] риск залома не тот, что обещан подписью\n"); + ++bad; + } + if (paidTimes != 1000) + { + std::printf(" [FAIL] за неудавшийся залом не платят — риска нет\n"); + ++bad; + } + if (slotsLost + destroyed != 1000) + { + std::printf(" [FAIL] залом не отнял слот: это улучшение без размена\n"); + ++bad; + } + + // 6. Бедность: без материалов не проходит ничего. + for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = 0; + ItemInstance poor(ItemId::RIFLE); + poor.rarity = Rarity::MARKED; + poor.affix[poor.count++] = Affix{AffixTag::DAMAGE, 2, 0, 0.12f}; + const CraftResult broke = ApplyCraft(CraftOp::REFORGE, poor, pr.material, 40, rng); + if (broke != CraftResult::NO_MATERIALS) + { + std::printf(" [FAIL] операция прошла без материалов\n"); + ++bad; + } + + for (int m = 0; m < MATERIAL_COUNT; ++m) pr.material[m] = savedMat[m]; + } + + // ------------------------------------------------------------------------- + // Полосы добычи. Правило «материал добывается только в своей полосе» — + // единственная причина возвращаться на средние глубины, когда рекорд ушёл + // вниз (docs/22-loot.md §5.1). Проверяется прямым опросом таблицы: в игре + // это заметно только часов через двадцать. + // ------------------------------------------------------------------------- + { + struct Case + { + int depth; + int theme; + const char* what; + }; + const Case cases[] = { + {1, 0, "первый этаж, сталь"}, {25, 2, "плоть на 25"}, {25, 0, "сталь на 25"}, + {40, 3, "мороз на 40"}, {60, 1, "ржавь на 60"}, {80, 2, "плоть на 80"}, + }; + + std::printf("\nполосы материалов (с твари danger 2):\n"); + int bandBad = 0; + for (const Case& c : cases) + { + const MaterialDrop d = KillDrop(c.depth, c.theme, 2.0f); + std::printf(" %-20s лом %d, био %d, крио %d, осколок %d, шлак %d\n", c.what, + d.amount[0], d.amount[1], d.amount[2], d.amount[3], d.amount[4]); + + // Ядра обязаны молчать вне своей темы и выше своей глубины. + if (c.depth < 21 && d.Total() > 0) ++bandBad; + if (c.theme != 2 && d.amount[int(MaterialKind::BIO)] > 0) ++bandBad; + if (c.theme != 3 && d.amount[int(MaterialKind::CRYO)] > 0) ++bandBad; + } + + const MaterialDrop prop = PropDrop(1); + std::printf(" %-20s лом %d\n", "разбитый ящик", prop.amount[0]); + if (prop.amount[int(MaterialKind::SCRAP)] <= 0) + { + std::printf(" [FAIL] обстановка не даёт лома — базовый материал недобываем\n"); + ++bandBad; + } + if (bandBad > 0) + { + std::printf(" [FAIL] полосы протекают: материал падает не со своих этажей\n"); + bad += bandBad; + } + } + // ------------------------------------------------------------------------- // Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько // человек в профиле, столько и выходит, и ни одного лишнего тела рядом. diff --git a/src/debug/tools.cpp b/src/debug/tools.cpp index 6eff0fd..5eb0439 100644 --- a/src/debug/tools.cpp +++ b/src/debug/tools.cpp @@ -24,10 +24,12 @@ #include #include +#include "engine/level_gen.h" #include "engine/progression.h" #include #include "sim/affix.h" +#include "sim/craft.h" #include "sim/element.h" #include "sim/reactions.h" #include "sim/items.h" @@ -448,6 +450,96 @@ int CmdAffix(int argc, char** argv) } +// ----------------------------------------------------------------------------- +// craft [глубина]: экономика материалов. +// +// Отвечает на единственный вопрос, который нельзя проверить игрой: СКОЛЬКО +// ВЫЛАЗОК стоит доработать вещь. Ответ «восемь» в документе взят из головы, а +// здесь считается из тех же чисел, по которым живёт генератор этажей. +// +// Доход считается по бюджету опасности этажа: материал падает с твари в +// количестве её danger, значит за зачищенный этаж отряд поднимает примерно +// весь бюджет этажа — но только по СВОЕЙ теме (sim/craft.h). +// ----------------------------------------------------------------------------- +int CmdCraft(int argc, char** argv) +{ + const int depth = (argc >= 1) ? std::atoi(argv[0]) : 80; + constexpr int FLOORS_PER_RAID = 5; + + std::printf("%-7s %9s %9s %9s %9s\n", "DEPTH", "БЮДЖЕТ", "ЛОМ/ВЫЛ", "ЯДРА/ВЫЛ", "ОСК/ВЫЛ"); + + for (int d = 10; d <= 100; d += 15) + { + const FloorSpec spec = DescribeFloor(d); + + // Считать доход по бюджету опасности НЕЛЬЗЯ: на этаж физически влезает + // не больше MAX_LEVEL_SPAWNS тварей, и глубокий бюджет уходит не в + // количество, а в качество. Первая версия этого не учла и завысила + // доход впятеро. + const float perFloor = std::min(float(MAX_LEVEL_SPAWNS), spec.dangerBudget / 2.8f); + const float perKill = 1.3f; // мелочь даёт одно ядро, громила два + + // Лом — с обстановки, её на этаже единицы; ядра — со своей четверти + // этажей (тем четыре), осколки — со всех, начиная с их глубины. + const float scrap = 4.0f * float(FLOORS_PER_RAID); + const float cores = perFloor * perKill * float(FLOORS_PER_RAID) * 0.25f; + const float shards = (d >= MaterialMinDepth(MaterialKind::SHARD)) + ? perFloor * perKill * float(FLOORS_PER_RAID) + : 0.0f; + + std::printf("%-7d %9.0f %9.0f %9.0f %9.0f\n", d, double(spec.dangerBudget), double(scrap), + double(cores), double(shards)); + } + + // Цена доработки вещи высшего тира. Берётся предмет с шестью аффиксами + // девятого тира — тот самый предельный, который существует ровно на дне + // (sim/affix.h). + ItemInstance top(ItemId::PLATE); + top.rarity = Rarity::RELIC; + for (int i = 0; i < MAX_AFFIXES; ++i) + top.affix[top.count++] = Affix{AffixTag::HEALTH, uint8_t(MAX_AFFIX_TIER), 0, 1.0f}; + + std::printf("\nцена операций на вещи T%d:\n", MAX_AFFIX_TIER); + for (int o = 0; o < int(CraftOp::COUNT); ++o) + { + const CraftCost c = CostOf(CraftOp(o), top); + std::printf(" %-10s", CraftOpName(CraftOp(o))); + for (int m = 0; m < MATERIAL_COUNT; ++m) + if (c.amount[m] > 0) std::printf(" %d %s", c.amount[m], MaterialToken(MaterialKind(m))); + std::printf("\n"); + } + + // Сколько вылазок стоит собрать одну вещь. «Собрать» — это довести шесть + // аффиксов до потолка возвышениями: перековки и прививки на их фоне мелочь. + const FloorSpec spec = DescribeFloor(depth); + const float coresPerRaid = std::min(float(MAX_LEVEL_SPAWNS), spec.dangerBudget / 2.8f) * 1.3f * + float(FLOORS_PER_RAID) * 0.25f; + const CraftCost exalt = CostOf(CraftOp::EXALT, top); + const float perExalt = float(exalt.amount[int(MaterialKind::BIO)] + + exalt.amount[int(MaterialKind::CRYO)]); + const float exalts = 12.0f; // шесть аффиксов, в среднем по два тира каждому + const float raids = (perExalt * exalts) / std::max(1.0f, coresPerRaid); + + std::printf("\nна глубине %d: ядер за вылазку %.0f, полная доводка вещи %.0f ядер\n" + "то есть %.1f вылазок на предмет (в документе заявлено около 8)\n", + depth, double(coresPerRaid), double(perExalt * exalts), double(raids)); + + int bad = 0; + if (raids < 2.0f) + { + std::printf(" [FAIL] вещь собирается за %.1f вылазки — материалы ничего не стоят\n", + double(raids)); + ++bad; + } + if (raids > 25.0f) + { + std::printf(" [FAIL] вещь собирается за %.0f вылазок — это не лейтгейм, а работа\n", + double(raids)); + ++bad; + } + return bad == 0 ? 0 : 1; +} + } // namespace int RunGameTool(int argc, char** argv) @@ -463,6 +555,7 @@ int RunGameTool(int argc, char** argv) 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); + if (std::strcmp(cmd, "craft") == 0) return CmdCraft(argc - 1, argv + 1); std::printf("нет команды `%s`\n\n", cmd); CmdHelp(); diff --git a/src/game.cpp b/src/game.cpp index 3606a02..2acd4e0 100644 --- a/src/game.cpp +++ b/src/game.cpp @@ -1,5 +1,6 @@ #include "game.h" +#include "sim/craft.h" #include "sim/reactions.h" // Перевод экранных осей ввода в мировые — единственное место, @@ -40,6 +41,7 @@ void Game::Reset() xp = 0.0f; floorsCleared = 0; lootTaken = 0; + for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] = 0; } void Game::NewRun(int startDepth) @@ -93,6 +95,8 @@ void Game::FinishRun(RunOutcome outcome) { ++profile->extractions; profile->bankedXp += xp; + // Материалы — та же ставка, что и добыча: донёс или потерял. + for (int m = 0; m < MATERIAL_COUNT; ++m) profile->material[m] += runMaterials[m]; } else { @@ -719,13 +723,28 @@ void Game::AwardKillXp() if (t.countedDead) continue; t.countedDead = true; - if (t.IsProp() || t.IsDummy()) continue; + // Материалы падают и с ОБСТАНОВКИ: лом — это разбитый ящик, и других + // источников у него нет. Опыт за неё по-прежнему не начисляется, + // иначе разбивание хлама стало бы самым выгодным занятием в игре. + if (t.IsProp()) + { + const MaterialDrop d = PropDrop(depth); + for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] += d.amount[m]; + continue; + } + if (t.IsDummy()) continue; float danger = 1.0f; if (catalog) if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger; xp += danger * scale; + + // Ядра падают только со «своих» этажей — тема и глубина решают, что + // именно (sim/craft.h). Это и есть причина возвращаться на средние + // глубины, когда рекорд ушёл вниз. + const MaterialDrop d = KillDrop(depth, int(level.floor.theme), danger); + for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] += d.amount[m]; } } diff --git a/src/game.h b/src/game.h index c558e4e..7adb56b 100644 --- a/src/game.h +++ b/src/game.h @@ -140,6 +140,10 @@ struct Game float xp = 0.0f; int floorsCleared = 0; int lootTaken = 0; + + // Материалы, добытые ЗА ЭТУ ВЫЛАЗКУ. В профиль уезжают только при + // эвакуации, как и добыча: вайп не приносит ничего (sim/craft.h). + int runMaterials[MATERIAL_COUNT] = {}; // Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение // принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает, // иначе смена мира случалась бы посреди шага, между движением и стрельбой. diff --git a/src/main.cpp b/src/main.cpp index 9c224e2..85280a8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -403,6 +403,7 @@ int main(int argc, char** argv) else if (std::strcmp(uiShot, "defeat") == 0) ui.screen = Screen::DEFEAT; else if (std::strcmp(uiShot, "depth") == 0) ui.screen = Screen::DEPTH; else if (std::strcmp(uiShot, "upgrade") == 0) ui.screen = Screen::UPGRADE; + else if (std::strcmp(uiShot, "workshop") == 0) ui.screen = Screen::WORKSHOP; else if (std::strcmp(uiShot, "summary") == 0) ui.screen = Screen::SUMMARY; else if (std::strcmp(uiShot, "pause") == 0) ui.screen = Screen::PAUSE; } diff --git a/src/sim/craft.cpp b/src/sim/craft.cpp new file mode 100644 index 0000000..5daaf61 --- /dev/null +++ b/src/sim/craft.cpp @@ -0,0 +1,245 @@ +#include "sim/craft.h" + +#include +#include + +#include "sim/affix.h" + +namespace +{ + +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) +{ + if (hi <= lo) return lo; + return lo + int(NextRand(s) % uint32_t(hi - lo + 1)); +} + +// Максимальный тир на предмете. По нему считается цена: платят за силу +// рулонов, а не за их количество. +int TopTier(const ItemInstance& it) +{ + int t = 1; + for (int i = 0; i < it.Count(); ++i) t = std::max(t, int(it.affix[i].tier)); + return t; +} + +bool Enough(const int* have, const CraftCost& cost) +{ + for (int m = 0; m < MATERIAL_COUNT; ++m) + if (have[m] < cost.amount[m]) return false; + return true; +} + +void Pay(int* have, const CraftCost& cost) +{ + for (int m = 0; m < MATERIAL_COUNT; ++m) have[m] -= cost.amount[m]; +} + +} // namespace + +MaterialDrop KillDrop(int depth, int theme, float danger) +{ + MaterialDrop d; + + // Мелочь даёт одно ядро, громила — два. НЕ «сколько стоит тварь»: первая + // версия роняла ядер по её danger, и на восьмидесятом этаже вылазка + // приносила тысячу ядер при цене доводки вещи в шестьсот. Материалы не + // стоили ничего, а мастерская из ставки превращалась в кнопку. + // + // Поймал это `--tool craft`: 0.6 вылазки на предмет против восьми в + // замысле. По игре такое не видно вообще — заметить можно было бы только + // через двадцать часов, когда собирать стало нечего. + const int base = 1 + int(danger) / 4; + + for (int m = 1; m < MATERIAL_COUNT; ++m) // лом с тварей не падает + { + const MaterialKind mat = MaterialKind(m); + if (depth < MaterialMinDepth(mat)) continue; + + const int want = MaterialTheme(mat); + if (want >= 0 && want != theme) continue; + + d.amount[m] = base; + } + return d; +} + +MaterialDrop PropDrop(int depth) +{ + MaterialDrop d; + (void)depth; // лом падает на любой глубине: обстановка везде одинаковая + d.amount[int(MaterialKind::SCRAP)] = 1; + return d; +} + +CraftCost CostOf(CraftOp op, const ItemInstance& it) +{ + const int t = TopTier(it); + CraftCost c; + + switch (op) + { + case CraftOp::REFORGE: + c.amount[int(MaterialKind::SCRAP)] = 4 * t; + break; + case CraftOp::GRAFT: + c.amount[int(MaterialKind::BIO)] = 2 * t; + break; + case CraftOp::EXCISE: + c.amount[int(MaterialKind::CRYO)] = 1 * t; + break; + case CraftOp::EXALT: + c.amount[int(MaterialKind::BIO)] = 3 * t; + c.amount[int(MaterialKind::CRYO)] = 3 * t; + c.amount[int(MaterialKind::SHARD)] = 1; + break; + default: // FRACTURE + c.amount[int(MaterialKind::SLAG)] = 3; + break; + } + return c; +} + +CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng) +{ + if (it.Empty() || !materials) return CraftResult::NOT_APPLICABLE; + + const CraftCost cost = CostOf(op, it); + if (!Enough(materials, cost)) return CraftResult::NO_MATERIALS; + + const int tierCap = MaxAffixTier(depth); + float budget = ItemBudget(depth); + for (int i = 0; i < it.Count(); ++i) budget -= AffixCost(it.affix[i].tier); + + switch (op) + { + case CraftOp::REFORGE: + { + if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; + + // Перебрасываются ЗНАЧЕНИЯ, а теги и тиры остаются. Перековка, + // меняющая ещё и теги, была бы тем же броском предмета заново, то + // есть способом обойти редкость: собери шесть слотов на чём угодно + // и крути, пока не выпадет нужное. + for (int i = 0; i < it.Count(); ++i) + { + const AffixRange base = AffixBase(it.affix[i].tag); + const float scale = std::pow(float(it.affix[i].tier), 1.35f); + it.affix[i].value = (base.lo + (base.hi - base.lo) * Frand(rng)) * scale; + } + break; + } + + case CraftOp::GRAFT: + { + if (it.Count() >= MAX_AFFIXES) return CraftResult::NOT_APPLICABLE; + + // Тег — любой подходящий виду и ЕЩЁ НЕ СТОЯЩИЙ на предмете: + // правило «один тег один раз» прививкой не обходится, иначе + // «+урон» шесть раз собиралось бы руками (sim/affix.h). + AffixTag pool[int(AffixTag::COUNT)]; + int poolCount = 0; + const ItemKind kind = ItemDefOf(it.id).kind; + for (int t = 1; t < int(AffixTag::COUNT); ++t) + { + const AffixTag tag = AffixTag(t); + if (!AffixFitsKind(tag, kind)) continue; + bool taken = false; + for (int i = 0; i < it.Count() && !taken; ++i) taken = it.affix[i].tag == tag; + if (!taken) pool[poolCount++] = tag; + } + if (poolCount == 0) return CraftResult::NOT_APPLICABLE; + + int tier = RandRange(rng, 1, tierCap); + while (tier > 1 && AffixCost(tier) > budget) --tier; + if (AffixCost(tier) > budget) return CraftResult::NOT_APPLICABLE; // бюджет полон + + const AffixTag tag = pool[RandRange(rng, 0, poolCount - 1)]; + const AffixRange base = AffixBase(tag); + const float scale = std::pow(float(tier), 1.35f); + + Affix a; + a.tag = tag; + a.tier = uint8_t(tier); + a.value = (base.lo + (base.hi - base.lo) * Frand(rng)) * scale; + if (tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1)); + it.affix[it.count++] = a; + break; + } + + case CraftOp::EXCISE: + { + if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; + --it.count; // убирается последний: выбор конкретного — дело UI + break; + } + + case CraftOp::EXALT: + { + if (it.Count() <= 0) return CraftResult::NOT_APPLICABLE; + + // Возвышается СЛУЧАЙНЫЙ аффикс, и это не лень: выбирай игрок сам, + // возвышение стало бы просто «+1 тир к урону» за фиксированную + // цену, и все предметы в игре сошлись бы к одному. + int candidates[MAX_AFFIXES]; + int n = 0; + for (int i = 0; i < it.Count(); ++i) + { + if (it.affix[i].tier >= tierCap) continue; + if (AffixCost(it.affix[i].tier + 1) - AffixCost(it.affix[i].tier) > budget) continue; + candidates[n++] = i; + } + if (n == 0) return CraftResult::NOT_APPLICABLE; + + const int pick = candidates[RandRange(rng, 0, n - 1)]; + Affix& a = it.affix[pick]; + const float before = std::pow(float(a.tier), 1.35f); + ++a.tier; + const float after = std::pow(float(a.tier), 1.35f); + a.value *= after / before; // рулон сохраняет своё место в диапазоне + break; + } + + default: // FRACTURE + { + Pay(materials, cost); + + // Плата снята ДО броска: за неудавшийся залом платят. Иначе риск + // ничего не стоил бы, а «12% потерять» без цены — это не риск, а + // бесплатная лотерея. + if (Frand(rng) < FRACTURE_RISK) + { + it = ItemInstance{}; + return CraftResult::DESTROYED; + } + + if (it.Count() <= 1) return CraftResult::OK; // ломать нечего, но заплачено + + // Один слот в жертву, остальным +2 тира. Именно так залом и + // задуман: не «улучшение с риском», а РАЗМЕН — меньше свойств, + // но каждое сильнее. + --it.count; + for (int i = 0; i < it.Count(); ++i) + { + Affix& a = it.affix[i]; + const int want = std::min(MAX_AFFIX_TIER, int(a.tier) + 2); + if (want == a.tier) continue; + const float before = std::pow(float(a.tier), 1.35f); + a.tier = uint8_t(want); + a.value *= std::pow(float(a.tier), 1.35f) / before; + } + return CraftResult::OK; + } + } + + Pay(materials, cost); + return CraftResult::OK; +} diff --git a/src/sim/craft.h b/src/sim/craft.h new file mode 100644 index 0000000..7407bbd --- /dev/null +++ b/src/sim/craft.h @@ -0,0 +1,183 @@ +#pragma once +// ----------------------------------------------------------------------------- +// Материалы и операции над предметом. +// +// Зачем это нужно ИГРЕ, а не списку фич: аффиксы (sim/affix.h) сделали находку +// решением, но решением ОДНОРАЗОВЫМ — вещь выпала такой, какая выпала, и +// сделать с ней нельзя ничего. Материалы дают второй способ получить нужное: +// не «повезло», а «доработал». +// +// Из этого следует и главное правило экономики: **материал добывается только в +// своей полосе глубин**. Именно поэтому отряд с рекордом на восьмидесятом этаже +// возвращается на тридцатый — за био-ядрами. Без полос весь контент, кроме +// последнего акта, умирает в тот день, когда игрок его прошёл +// (docs/22-loot.md §5). +// +// Материалы — ВАЛЮТА, а не предметы: они не занимают ячейки, не весят и не +// лежат в схроне. Иначе поход за материалами превращался бы в поход с сумкой, +// то есть в тот же лут, только с другим названием. +// +// НО теряются они так же, как добыча: заработанное за вылазку зачисляется +// только при эвакуации. Вайп не приносит ничего — ставка одна на всё. +// ----------------------------------------------------------------------------- +#include + +#include "sim/items.h" + +enum class MaterialKind : uint8_t +{ + SCRAP = 0, // лом: разбитая обстановка, любая глубина + BIO, // био-ядро: твари на плотских этажах + CRYO, // крио-ядро: твари на мёрзлых этажах + SHARD, // осколок реактора: глубина + SLAG, // пустотный шлак: самое дно + COUNT, +}; + +constexpr int MATERIAL_COUNT = int(MaterialKind::COUNT); + +inline const char* MaterialName(MaterialKind m) +{ + switch (m) + { + case MaterialKind::SCRAP: return "SCRAP"; + case MaterialKind::BIO: return "BIO CORE"; + case MaterialKind::CRYO: return "CRYO CORE"; + case MaterialKind::SHARD: return "SHARD"; + default: return "VOID SLAG"; + } +} + +// Короткое имя для профиля и таблиц. Отдельно от читаемого: в файле нужен +// токен без пробелов, на экране — слово. +inline const char* MaterialToken(MaterialKind m) +{ + switch (m) + { + case MaterialKind::SCRAP: return "scrap"; + case MaterialKind::BIO: return "bio"; + case MaterialKind::CRYO: return "cryo"; + case MaterialKind::SHARD: return "shard"; + default: return "slag"; + } +} + +// С какой глубины материал вообще встречается. Тема этажа проверяется отдельно: +// ядра падают только со «своих» этажей, и это и есть полоса. +inline int MaterialMinDepth(MaterialKind m) +{ + switch (m) + { + case MaterialKind::SCRAP: return 1; + case MaterialKind::BIO: return 21; + case MaterialKind::CRYO: return 36; + case MaterialKind::SHARD: return 53; + default: return 73; + } +} + +// Номер темы, с которой падает ядро. -1 — тема не важна. +// Темы: 0 STEEL, 1 RUST, 2 FLESH, 3 FROST (engine/theme.h). +inline int MaterialTheme(MaterialKind m) +{ + switch (m) + { + case MaterialKind::BIO: return 2; // FLESH + case MaterialKind::CRYO: return 3; // FROST + default: return -1; + } +} + +// Что падает с убитой твари на этом этаже. NONE-эквивалента нет: возвращает +// количество по каждому виду, и ноль — законный ответ. +struct MaterialDrop +{ + int amount[MATERIAL_COUNT] = {}; + + int Total() const + { + int n = 0; + for (int i = 0; i < MATERIAL_COUNT; ++i) n += amount[i]; + return n; + } +}; + +// danger — цена твари из каталога: с громилы падает больше, чем с бредущего. +// Это единственный множитель: заводить отдельную таблицу «кто что роняет» +// значило бы описывать содержимое дважды. +MaterialDrop KillDrop(int depth, int theme, float danger); + +// Разбитая обстановка. Лом и только он: ящик — это ящик. +MaterialDrop PropDrop(int depth); + +// ----------------------------------------------------------------------------- +// Операции. Пять, и ровно одна из них с риском. +// +// Цены растут с ТИРОМ предмета, а не с его редкостью: платят за силу рулонов, а +// не за их количество. Тир берётся максимальный на предмете — по нему и видно, +// с какой глубины вещь. +// ----------------------------------------------------------------------------- +enum class CraftOp : uint8_t +{ + REFORGE = 0, // перебросить ВСЕ значения, теги и тиры на месте + GRAFT, // добавить аффикс, если позволяет бюджет + EXCISE, // убрать выбранный аффикс + EXALT, // +1 тир случайному аффиксу + FRACTURE, // +2 тира всем, но на один аффикс меньше; 12% — предмет рассыпается + COUNT, +}; + +inline const char* CraftOpName(CraftOp op) +{ + switch (op) + { + case CraftOp::REFORGE: return "REFORGE"; + case CraftOp::GRAFT: return "GRAFT"; + case CraftOp::EXCISE: return "EXCISE"; + case CraftOp::EXALT: return "EXALT"; + default: return "FRACTURE"; + } +} + +inline const char* CraftOpEffect(CraftOp op) +{ + switch (op) + { + case CraftOp::REFORGE: return "REROLL EVERY VALUE"; + case CraftOp::GRAFT: return "ADD ONE AFFIX IF BUDGET ALLOWS"; + case CraftOp::EXCISE: return "REMOVE THE LAST AFFIX"; + case CraftOp::EXALT: return "RAISE ONE AFFIX BY A TIER"; + default: return "+2 TIERS, ONE SLOT LESS, 12% TO DESTROY"; + } +} + +// Шанс, что залом уничтожит вещь. Ровно поэтому лучшая вещь в игре всегда чуть +// хуже, чем могла бы быть: почти никто не заламывает то, что собирал двадцать +// часов (docs/22-loot.md §5.2). +constexpr float FRACTURE_RISK = 0.12f; + +// Цена операции для конкретного предмета. +struct CraftCost +{ + int amount[MATERIAL_COUNT] = {}; +}; + +CraftCost CostOf(CraftOp op, const ItemInstance& it); + +// Итог операции. Отдельным типом, потому что «не хватило материалов» и +// «рассыпалось» — разные события, и UI обязан их различать. +enum class CraftResult : uint8_t +{ + OK = 0, + NO_MATERIALS, + NOT_APPLICABLE, // нечего убирать, нечего возвышать, бюджет полон + DESTROYED, // залом не удался: вещи больше нет +}; + +// Применить операцию. Материалы списываются ТОЛЬКО при OK и DESTROYED: за +// неудавшийся залом платят, за невозможную операцию — нет. +// +// depth — глубина, по которой считается потолок тира при перековке и прививке. +// Это рекорд игрока: мастерская на базе, и доступное в ней — то, куда он уже +// доходил. +CraftResult ApplyCraft(CraftOp op, ItemInstance& it, int* materials, int depth, uint32_t& rng); diff --git a/src/sim/profile.cpp b/src/sim/profile.cpp index afdcc33..27103da 100644 --- a/src/sim/profile.cpp +++ b/src/sim/profile.cpp @@ -224,6 +224,13 @@ bool Profile::Load(const std::string& path, std::string* err) } if (t[0] == "maxdepth" && t.size() >= 2) { p.maxDepth = std::max(1, text::ToInt(t[1], 1)); continue; } + // mat <лом> <био> <крио> <осколок> <шлак> + if (t[0] == "mat") + { + for (int m = 0; m < MATERIAL_COUNT && size_t(1 + m) < t.size(); ++m) + p.material[m] = std::max(0, text::ToInt(t[size_t(1 + m)], 0)); + continue; + } if (t[0] == "squad" && t.size() >= 2) { p.squadSize = std::max(1, std::min(NUM_AGENTS, text::ToInt(t[1], 1))); diff --git a/src/sim/profile.h b/src/sim/profile.h index f4f7afd..c8f14b7 100644 --- a/src/sim/profile.h +++ b/src/sim/profile.h @@ -23,6 +23,7 @@ #include #include +#include "sim/craft.h" #include "sim/items.h" #include "tuning.h" @@ -118,13 +119,14 @@ const char* UpgradeEffect(Upgrade u); // 2 — общая сумка, улучшения отряда // 3 — размер отряда (найм): раньше бойцов всегда было пять // 4 — экземпляры предметов: редкость и аффиксы в токене после '@' +// 5 — материалы: строка mat // // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // читается вовсе. Второе важнее первого: молча обнулить профиль, который // записала более новая сборка, — это стереть десятки часов, а отказ читать // всего лишь просит не запускать старую сборку. // ----------------------------------------------------------------------------- -constexpr int PROFILE_VERSION = 4; +constexpr int PROFILE_VERSION = 5; struct Profile { @@ -175,6 +177,10 @@ struct Profile // другой вещью (sim/affix.h). std::vector stash; + // Материалы. ВАЛЮТА: не занимают ячеек, не весят, в схроне не лежат + // (sim/craft.h). Зачисляются только при эвакуации — ставка одна на всё. + int material[MATERIAL_COUNT] = {}; + // --- прогресс ------------------------------------------------------------- int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж int runs = 0; // сколько вылазок начато diff --git a/src/ui/menu.cpp b/src/ui/menu.cpp index 854fdb5..7c4aaba 100644 --- a/src/ui/menu.cpp +++ b/src/ui/menu.cpp @@ -4,9 +4,11 @@ #include #include +#include #include "game.h" #include "sim/affix.h" +#include "sim/craft.h" namespace { @@ -147,14 +149,23 @@ constexpr int SQ_STASH_X = 22, SQ_STASH_Y = 208, SQ_STASH_W = 52, SQ_STASH_H = 1 constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 8, SQ_STASH_ROWS = 2; constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS; +// Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка +// игрока дороже экономии места), справа пять операций строками с ценой. +constexpr int WS_SLOT_X = 16, WS_SLOT_Y = 74, WS_SLOT_W = 52, WS_SLOT_H = 18; +constexpr int WS_SLOT_GX = 4, WS_SLOT_GY = 3, WS_SLOT_COLS = 4, WS_SLOT_ROWS = 6; +constexpr int WS_SLOT_CELLS = WS_SLOT_COLS * WS_SLOT_ROWS; + +constexpr int WS_OP_X = 252, WS_OP_Y = 96, WS_OP_W = 212, WS_OP_H = 22, WS_OP_STEP = 25; + // Выбор чекпоинта: столбец кнопок «начать с этажа N». constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает constexpr int SUM_Y0 = 214, SUM_STEP = 20, SUM_SIZE = 11; -const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD", "UPGRADES", "QUIT"}; -constexpr int MENU_COUNT = 5; +const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD", + "UPGRADES", "WORKSHOP", "QUIT"}; +constexpr int MENU_COUNT = 6; const char* const PAUSE_ITEMS[] = {"RESUME", "SQUAD", "MENU"}; constexpr int PAUSE_COUNT = 3; @@ -382,6 +393,15 @@ UiRect StashRect(int cell) SQ_STASH_H}; } +UiRect CraftSlotRect(int cell) +{ + return UiRect{WS_SLOT_X + (cell % WS_SLOT_COLS) * (WS_SLOT_W + WS_SLOT_GX), + WS_SLOT_Y + (cell / WS_SLOT_COLS) * (WS_SLOT_H + WS_SLOT_GY), WS_SLOT_W, + WS_SLOT_H}; +} + +UiRect CraftOpRect(int op) { return UiRect{WS_OP_X, WS_OP_Y + op * WS_OP_STEP, WS_OP_W, WS_OP_H}; } + void AddButtons(Widgets& out, const Layout& l, UiPart part, const char* const* names, int count, int y0, int step, int size) { @@ -499,6 +519,7 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) // Схрон. Ячейки за концом списка тоже интерактивны: именно в них // предмет КЛАДЁТСЯ, и без них схрон можно было бы только разбирать. + // (продолжение ниже) if (game.profile) { for (int c = 0; c < SQ_STASH_CELLS; ++c) @@ -511,6 +532,27 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out) } return; } + + case Screen::WORKSHOP: + { + if (!game.profile) return; + + for (int c = 0; c < WS_SLOT_CELLS; ++c) + { + const int k = ui.stashScroll + c; + Widget& w = out.Add(UiPart::CRAFT_SLOT, k, CraftSlotRect(c)); + w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)] + : ItemInstance{}; + w.enabled = k < game.profile->StashCount(); + } + + for (int o = 0; o < int(CraftOp::COUNT); ++o) + { + Widget& w = out.Add(UiPart::CRAFT_ITEM, o, CraftOpRect(o)); + w.text = CraftOpName(CraftOp(o)); + } + return; + } } } @@ -714,12 +756,13 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) break; case 2: ui.screen = Screen::SQUAD; break; case 3: ui.screen = Screen::UPGRADE; break; + case 4: ui.screen = Screen::WORKSHOP; break; // QUIT — последний пункт, и попасть сюда можно только им. // Раньше здесь стоял default, и любой НЕ перечисленный пункт // выходил из игры: экран улучшений появился четвёртым, а // обработчик о нём не узнал — нажатие UPGRADES закрывало игру, // и снаружи это выглядело как падение. - case 4: ui.quit = true; break; + case 5: ui.quit = true; break; default: break; } return; @@ -738,6 +781,44 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index) ui.screen = Screen::GAME; return; + case UiPart::CRAFT_SLOT: + // Выбор вещи на верстак. Ничего не тратит и ничего не ломает: + // операция — это отдельный клик. + ui.craftIndex = index; + ui.craftMessage = 0; + return; + + case UiPart::CRAFT_ITEM: + { + if (!game.profile) return; + Profile& pr = *game.profile; + if (ui.craftIndex < 0 || ui.craftIndex >= pr.StashCount()) return; + + // Сид от состояния профиля: два одинаковых клика подряд не дают + // один и тот же бросок, но и от системного времени игра не зависит + // — прогоны обязаны повторяться. + uint32_t rng = uint32_t(pr.runs * 7919 + pr.StashCount() * 104729 + + int(pr.bankedXp) + ui.craftIndex * 31 + index * 17); + + ItemInstance& it = pr.stash[size_t(ui.craftIndex)]; + const CraftResult r = + ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng); + + // Рассыпавшаяся вещь убирается из схрона: пустая ячейка посреди + // списка выглядела бы как ошибка, а не как последствие риска. + if (r == CraftResult::DESTROYED) + { + pr.StashRemoveAt(ui.craftIndex); + if (ui.craftIndex >= pr.StashCount()) ui.craftIndex = pr.StashCount() - 1; + if (ui.craftIndex < 0) ui.craftIndex = 0; + } + + ui.craftMessage = int(r) + 1; + ui.craftOpDone = index; + ui.craftFlash = 3.0f; + return; + } + case UiPart::SUMMARY_ITEM: // Забег кончился, мира больше нет: «продолжить» ведёт только в меню. ui.started = false; @@ -885,6 +966,22 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt) if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU; return true; + case Screen::WORKSHOP: + craftFlash = std::max(0.0f, craftFlash - dt); + if (craftFlash <= 0.0f) craftMessage = 0; + + if (lmbDown && hot && hot->enabled) UiActivate(*this, game, hot->part, hot->index); + if (Pressed(KEY_Q)) stashScroll = std::max(0, stashScroll - WS_SLOT_COLS); + if (Pressed(KEY_E)) stashScroll += WS_SLOT_COLS; + if (game.profile) + { + const int cap = std::max(0, game.profile->StashCount() - WS_SLOT_CELLS); + stashScroll = std::min(stashScroll, cap); + craftIndex = std::min(craftIndex, std::max(0, game.profile->StashCount() - 1)); + } + if (Pressed(KEY_ESCAPE)) screen = Screen::MENU; + return true; + case Screen::DEPTH: { const int shown = w.count; @@ -1297,6 +1394,148 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const break; } + case Screen::WORKSHOP: + { + Shade(l, COL_DIM); + TextCentered(l, "WORKSHOP", 12, 16, COL_TEXT); + Rule(l, 32, 56); + + char line[96]; + + // Материалы строкой сверху. Они валюта, и место им там же, где у + // улучшений стоит запас опыта: игрок читает «на что мне хватает» + // до того, как выбирает вещь. + if (game.profile) + { + const Profile& pr = *game.profile; + std::snprintf(line, sizeof(line), "%d SCRAP %d BIO %d CRYO %d SHARD %d SLAG", + pr.material[0], pr.material[1], pr.material[2], pr.material[3], + pr.material[4]); + TextCentered(l, line, 40, 8, COL_FAINT); + } + + Text(l, "STASH", WS_SLOT_X, 60, 8, COL_FAINT); + Text(l, "BENCH", WS_OP_X, 60, 8, COL_FAINT); + + // --- ячейки схрона --- + for (int i = 0; i < w.count; ++i) + { + const Widget& c = w.item[i]; + if (c.part != UiPart::CRAFT_SLOT) continue; + DrawCell(l, *this, c, c.index == craftIndex); + } + + // --- что лежит на верстаке --- + const ItemInstance* bench = nullptr; + if (game.profile && craftIndex >= 0 && craftIndex < game.profile->StashCount()) + bench = &game.profile->stash[size_t(craftIndex)]; + + if (!bench) + { + Text(l, "STASH IS EMPTY", WS_OP_X, 74, 9, COL_FAINT); + } + else + { + const ItemDef d = ItemDefOf(bench->id); + const RarityColor rc = RarityColorOf(bench->rarity); + std::snprintf(line, sizeof(line), "%s %s", RarityName(bench->rarity), d.name); + Text(l, line, WS_OP_X, 72, 10, Color{rc.r, rc.g, rc.b, 255}); + + // Аффиксы столбиком слева от операций: игрок должен видеть, что + // именно он собирается менять, не наводя курсор. + for (int a = 0; a < bench->Count(); ++a) + { + char af[64]; + AffixLabel(bench->affix[a], af, int(sizeof(af))); + Text(l, af, WS_SLOT_X, 200 + a * 9, 8, COL_FAINT); + } + if (bench->Count() == 0) + Text(l, "NO AFFIXES", WS_SLOT_X, 200, 8, COL_DEAD); + } + + // --- операции --- + for (int o = 0; o < int(CraftOp::COUNT); ++o) + { + const CraftOp op = CraftOp(o); + const UiRect r = CraftOpRect(o); + const bool hover = (hoverPart == UiPart::CRAFT_ITEM && hoverIndex == o); + + bool afford = false; + CraftCost cost; + if (bench && game.profile) + { + cost = CostOf(op, *bench); + afford = true; + for (int m = 0; m < MATERIAL_COUNT; ++m) + if (game.profile->material[m] < cost.amount[m]) afford = false; + } + + if (hover && afford) Box(l, r, COL_HOVER); + BoxLines(l, r, afford ? COL_EDGE : COL_DEAD); + + // Залом красится кровью всегда: единственная операция, которая + // может уничтожить вещь, обязана выглядеть иначе остальных. + const Color nameCol = (op == CraftOp::FRACTURE) + ? COL_BLOOD + : (afford ? COL_TEXT : COL_FAINT); + Text(l, CraftOpName(op), r.x + 6, r.y + 3, 9, nameCol); + Text(l, CraftOpEffect(op), r.x + 6, r.y + 13, 7, COL_FAINT); + + // Цена справа: только те материалы, которые нужны. + char price[64] = {0}; + int used = 0; + for (int m = 0; m < MATERIAL_COUNT; ++m) + { + if (cost.amount[m] <= 0) continue; + char part[24]; + std::snprintf(part, sizeof(part), "%s%d %s", used ? " " : "", cost.amount[m], + MaterialToken(MaterialKind(m))); + std::snprintf(price + used, sizeof(price) - size_t(used), "%s", part); + used = int(std::strlen(price)); + } + if (used > 0) + Text(l, price, r.x + r.w - 6 - TextW(l, price, 7), r.y + 5, 7, + afford ? COL_PICK : COL_DEAD); + } + + // --- чем кончилась последняя операция --- + // Молчать нельзя ни в одном из четырёх случаев: «рассыпалось» — это + // потеря вещи, «не хватило» — несостоявшийся клик, а успех без + // ответа выглядит как неработающая кнопка. + if (craftMessage > 0) + { + const CraftResult r = CraftResult(craftMessage - 1); + const char* what = ""; + Color col = COL_TEXT; + switch (r) + { + case CraftResult::OK: + what = "DONE"; + col = Color{150, 220, 170, 255}; + break; + case CraftResult::NO_MATERIALS: + what = "NOT ENOUGH MATERIALS"; + col = COL_FAINT; + break; + case CraftResult::NOT_APPLICABLE: + what = "NOTHING TO CHANGE"; + col = COL_FAINT; + break; + default: + what = "THE ITEM CRUMBLED TO DUST"; + col = COL_BLOOD; + break; + } + std::snprintf(line, sizeof(line), "%s: %s", + CraftOpName(CraftOp(craftOpDone)), what); + TextCentered(l, line, 244, 9, col); + } + + TextCentered(l, "CLICK ITEM THEN OPERATION Q/E SCROLL ESC BACK", 258, 7, + COL_FAINT); + break; + } + case Screen::DEPTH: { Shade(l, COL_DIM); diff --git a/src/ui/menu.h b/src/ui/menu.h index d633398..c8945f9 100644 --- a/src/ui/menu.h +++ b/src/ui/menu.h @@ -28,6 +28,7 @@ enum class Screen : uint8_t SQUAD, // отряд: снаряжение, сумки и схрон DEPTH, // с какого чекпоинта начать вылазку UPGRADE, // во что вложить вынесенный опыт + WORKSHOP, // мастерская: материалы и операции над вещью SUMMARY, // итог вылазки: что вынесли и что потеряли DEFEAT, // отряд полёг (устарел, ведёт на SUMMARY) }; @@ -50,6 +51,8 @@ enum class UiPart : uint8_t UPGRADE_ITEM, // строка улучшения: клик покупает следующий уровень DEPTH_ITEM, // кнопка выбора чекпоинта SUMMARY_ITEM, // кнопка на экране итога + CRAFT_ITEM, // строка операции мастерской + CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем }; // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне @@ -76,6 +79,15 @@ struct Ui int depthIndex = 0; // выбранный чекпоинт на экране глубины int stashScroll = 0; // прокрутка схрона: он длиннее видимой сетки + // --- мастерская ---------------------------------------------------------- + int craftIndex = 0; // какая вещь схрона лежит на верстаке + // Чем кончилась последняя операция. Показывается строкой под верстаком: + // «не хватило материалов» и «рассыпалось» — разные события, и молча + // проглотить их нельзя, особенно второе. + int craftMessage = 0; // 0 — нет, иначе CraftResult + 1 + int craftOpDone = 0; // какая операция это была + float craftFlash = 0.0f; // сколько ещё держать сообщение + float blink = 0.0f; // общий таймер для мигания подсказок bool cursorHidden = false; // системный курсор скрыт (рисуем свой)