Материалы и мастерская: вещь можно доработать (M27)

Аффиксы сделали находку решением, но решением одноразовым: выпало как
выпало. Материалы дают второй способ получить нужное — не «повезло», а
«доработал». Пять материалов, пять операций, экран WORKSHOP.

Главное правило экономики — материал добывается ТОЛЬКО В СВОЕЙ ПОЛОСЕ:
био-ядра с плотских этажей, крио с мёрзлых. Это единственная причина
возвращаться на средние глубины, когда рекорд ушёл вниз; без полос весь
контент, кроме последнего акта, умирает в день прохождения.

Материалы — валюта, а не предметы: не занимают ячеек, не весят, в схроне
не лежат. Но теряются как добыча: зачисляются только при эвакуации.

Три уточнения операций, каждое против конкретного обхода: перековка
меняет только значения (иначе это новый бросок предмета в обход
редкости), прививка не ставит уже стоящий тег (иначе «+урон» шесть раз),
возвышается случайный аффикс (иначе все предметы сойдутся к одному).

--tool craft поймал, что материалы бесплатны. С твари падало по её
danger, и вылазка на восьмидесятом этаже приносила тысячу ядер при цене
доводки вещи в шестьсот: 0.6 вылазки на предмет вместо восьми. Правило
заменено на «одно ядро с мелочи, два с крупной» — стало 6.2.

Сам инструмент при этом тоже считал неверно: брал доход из бюджета
опасности этажа, хотя на этаж влезает не больше MAX_LEVEL_SPAWNS тварей.
Бюджет уходит не в количество тварей, а в их силу, и доход упирается в
потолок расстановки с сорокового этажа. Глубина даёт не количество
материалов, а качество предметов — и вместе с ним цену доводки.

Залом замерен на тысяче попыток: рассыпалось 11.6% при заявленных 12%,
плата снимается всегда, слот теряется всегда. «У меня трижды не
рассыпалось» не доказательство ни в какую сторону.

Пункт меню WORKSHOP проверен --ui-check — той самой проверкой, которая
появилась вчера из-за пункта UPGRADES, молча закрывавшего игру.

Формат профиля 5: строка mat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 03:07:46 +03:00
parent 53187a7cdd
commit a8e19cd956
15 changed files with 1103 additions and 23 deletions

View File

@ -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)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции

View File

@ -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 <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
| `--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 # материалы: доход за вылазку против цены доводки
```
Редактор комнат — отдельное приложение с собственными ключами:

View File

@ -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)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -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 вылазок на один предмет**. Слотов девять, предметов в сборке — пять
ключевых. Экономика материалов рассчитана на 6080 часов лейтгейма, и это ровно
акт VI из бюджета времени ([20-gdd.md](20-gdd.md) §5).
Считается инструментом, а не на глаз: `--tool craft 80`.
```
на глубине 80: ядер за вылазку 104, полная доводка вещи 648 ядер
то есть 6.2 вылазок на предмет (в документе заявлено около 8)
```
Доход **упирается в потолок расстановки**: на этаж влезает не больше
`MAX_LEVEL_SPAWNS` тварей, поэтому с сорокового этажа и глубже вылазка приносит
примерно одно и то же. Глубина даёт не количество материалов, а КАЧЕСТВО
предметов — а вместе с ним и цену доводки, которая растёт с тиром. Отсюда и
кривая: вещь сорокового этажа доводится за три вылазки, вещь сотого — за шесть с
лишним.
Первая версия расчёта брала доход из бюджета опасности этажа и завысила его
впятеро: бюджет уходит не в количество тварей, а в их силу.
## 6. Как это не превращается в кашу

View File

@ -4,10 +4,12 @@
#include <filesystem>
#include <fstream>
#include <cstdarg>
#include <cmath>
#include <cstdio>
#include <cstring>
#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;
}
}
// -------------------------------------------------------------------------
// Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько
// человек в профиле, столько и выходит, и ни одного лишнего тела рядом.

View File

@ -24,10 +24,12 @@
#include <cstring>
#include <string>
#include "engine/level_gen.h"
#include "engine/progression.h"
#include <vector>
#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();

View File

@ -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];
}
}

View File

@ -140,6 +140,10 @@ struct Game
float xp = 0.0f;
int floorsCleared = 0;
int lootTaken = 0;
// Материалы, добытые ЗА ЭТУ ВЫЛАЗКУ. В профиль уезжают только при
// эвакуации, как и добыча: вайп не приносит ничего (sim/craft.h).
int runMaterials[MATERIAL_COUNT] = {};
// Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение
// принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает,
// иначе смена мира случалась бы посреди шага, между движением и стрельбой.

View File

@ -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;
}

245
src/sim/craft.cpp Normal file
View File

@ -0,0 +1,245 @@
#include "sim/craft.h"
#include <algorithm>
#include <cmath>
#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;
}

183
src/sim/craft.h Normal file
View File

@ -0,0 +1,183 @@
#pragma once
// -----------------------------------------------------------------------------
// Материалы и операции над предметом.
//
// Зачем это нужно ИГРЕ, а не списку фич: аффиксы (sim/affix.h) сделали находку
// решением, но решением ОДНОРАЗОВЫМ — вещь выпала такой, какая выпала, и
// сделать с ней нельзя ничего. Материалы дают второй способ получить нужное:
// не «повезло», а «доработал».
//
// Из этого следует и главное правило экономики: **материал добывается только в
// своей полосе глубин**. Именно поэтому отряд с рекордом на восьмидесятом этаже
// возвращается на тридцатый — за био-ядрами. Без полос весь контент, кроме
// последнего акта, умирает в тот день, когда игрок его прошёл
// (docs/22-loot.md §5).
//
// Материалы — ВАЛЮТА, а не предметы: они не занимают ячейки, не весят и не
// лежат в схроне. Иначе поход за материалами превращался бы в поход с сумкой,
// то есть в тот же лут, только с другим названием.
//
// НО теряются они так же, как добыча: заработанное за вылазку зачисляется
// только при эвакуации. Вайп не приносит ничего — ставка одна на всё.
// -----------------------------------------------------------------------------
#include <cstdint>
#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);

View File

@ -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)));

View File

@ -23,6 +23,7 @@
#include <string>
#include <vector>
#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<ItemInstance> stash;
// Материалы. ВАЛЮТА: не занимают ячеек, не весят, в схроне не лежат
// (sim/craft.h). Зачисляются только при эвакуации — ставка одна на всё.
int material[MATERIAL_COUNT] = {};
// --- прогресс -------------------------------------------------------------
int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж
int runs = 0; // сколько вылазок начато

View File

@ -4,9 +4,11 @@
#include <cstdarg>
#include <cstdio>
#include <cstring>
#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);

View File

@ -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; // системный курсор скрыт (рисуем свой)