Материалы и мастерская: вещь можно доработать (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:
parent
53187a7cdd
commit
a8e19cd956
@ -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)).
|
||||
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
|
||||
|
||||
@ -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 # материалы: доход за вылазку против цены доводки
|
||||
```
|
||||
|
||||
Редактор комнат — отдельное приложение с собственными ключами:
|
||||
|
||||
@ -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)) |
|
||||
|
||||
## Что находилось по ходу и чинилось
|
||||
|
||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||
|
||||
@ -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. Как это не превращается в кашу
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Размер отряда. Проверяется цепочка «в профиле -> на этаже»: сколько
|
||||
// человек в профиле, столько и выходит, и ни одного лишнего тела рядом.
|
||||
|
||||
@ -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();
|
||||
|
||||
21
src/game.cpp
21
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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -140,6 +140,10 @@ struct Game
|
||||
float xp = 0.0f;
|
||||
int floorsCleared = 0;
|
||||
int lootTaken = 0;
|
||||
|
||||
// Материалы, добытые ЗА ЭТУ ВЫЛАЗКУ. В профиль уезжают только при
|
||||
// эвакуации, как и добыча: вайп не приносит ничего (sim/craft.h).
|
||||
int runMaterials[MATERIAL_COUNT] = {};
|
||||
// Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение
|
||||
// принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает,
|
||||
// иначе смена мира случалась бы посреди шага, между движением и стрельбой.
|
||||
|
||||
@ -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
245
src/sim/craft.cpp
Normal 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
183
src/sim/craft.h
Normal 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);
|
||||
@ -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)));
|
||||
|
||||
@ -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; // сколько вылазок начато
|
||||
|
||||
245
src/ui/menu.cpp
245
src/ui/menu.cpp
@ -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);
|
||||
|
||||
@ -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; // системный курсор скрыт (рисуем свой)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user