Материалы и мастерская: вещь можно доработать (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`, и это удобно ровно до того места, где вещь копируют: присваивание в
`ItemId` молча теряет редкость и аффиксы. Подбор, перекладывание и запись `ItemId` молча теряет редкость и аффиксы. Подбор, перекладывание и запись
профиля обязаны работать с экземпляром целиком ([22-loot.md](docs/22-loot.md)). профиля обязаны работать с экземпляром целиком ([22-loot.md](docs/22-loot.md)).
- **Материал добывается только в своей полосе.** Био-ядра — с плотских этажей,
крио — с мёрзлых, и это единственная причина возвращаться на средние глубины
после рекорда. Протечка полос убивает весь контент, кроме последнего акта
([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`, и выход | | `--sprites` | что загрузчик видит в `build/sprites`, и выход |
| `--windowed` | окно 1440x810 вместо безрамочного на весь экран | | `--windowed` | окно 1440x810 вместо безрамочного на весь экран |
| `--sandbox` | прежняя песочница: мишени вместо тварей, отряд без снаряжения | | `--sandbox` | прежняя песочница: мишени вместо тварей, отряд без снаряжения |
| `--ui-shot <экран>` | снять кадр с `menu` / `squad` / `pause` / `defeat` | | `--ui-shot <экран>` | снять кадр с `menu` / `squad` / `pause` / `defeat` / `upgrade` / `workshop` |
| `--rooms [проект]` | играть в проект (по умолчанию свой, `descent`) вместо захардкоженной карты | | `--rooms [проект]` | играть в проект (по умолчанию свой, `descent`) вместо захардкоженной карты |
| `--project <имя>` | то же явно: имя каталога в `projects/` или путь к нему | | `--project <имя>` | то же явно: имя каталога в `projects/` или путь к нему |
| `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) | | `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
| `--seed N` | сид сборки уровня (по умолчанию 1) | | `--seed N` | сид сборки уровня (по умолчанию 1) |
| `--depth N` | начать не с первого этажа (для проверок) | | `--depth N` | начать не с первого этажа (для проверок) |
| `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль | | `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль |
| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix` | | `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix`, `craft` |
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
| `--no-profile` | не читать и не писать профиль | | `--no-profile` | не читать и не писать профиль |
@ -168,6 +168,7 @@ squad_proto.exe --tool reactions # реакции и три ограничит
squad_proto.exe --tool economy # доход опыта против цены дерева squad_proto.exe --tool economy # доход опыта против цены дерева
squad_proto.exe --tool drops 1 100 # кривая редкости находок по глубине squad_proto.exe --tool drops 1 100 # кривая редкости находок по глубине
squad_proto.exe --tool affix 40 # бюджет предмета и потолок аффиксов 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)) | | **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. Материалы ### 5.1. Материалы
Сделано в M27.
| Материал | Откуда | Куда | | Материал | Откуда | Куда |
|---|---|---| |---|---|---|
| Лом | разбитая обстановка, D1+ | базовый крафт, ремонт | | Лом | разбитая обстановка, любая глубина | перековка |
| Био-ядро | твари FLESH, D21+ | статусные аффиксы | | Био-ядро | твари на этажах FLESH, D21+ | прививка, возвышение |
| Крио-ядро | твари FROST, D36+ | защитные аффиксы | | Крио-ядро | твари на этажах FROST, D36+ | отсечение, возвышение |
| Осколок реактора | аномалии, D53+ | стихийные и реакционные | | Осколок реактора | твари, D53+ | возвышение |
| Пустотный шлак | Бездна, D73+ | залом, перековка тира | | Пустотный шлак | твари, 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. Операции ### 5.2. Операции
| Операция | Что делает | Цена | Риск | | Операция | Что делает | Цена | Риск |
|---|---|---|---| |---|---|---|---|
| Перековка | заново бросает ВСЕ рулоны | 4 лома × тир | — | | Перековка | заново бросает значения (теги и тиры на месте) | 4 лома × тир | — |
| Прививка | добавляет аффикс, если есть бюджет | 2 ядра × тир | — | | Прививка | добавляет аффикс НОВОГО тега, если есть бюджет | 2 био × тир | — |
| Отсечение | убирает выбранный аффикс | 1 ядро × тир | — | | Отсечение | убирает последний аффикс | 1 крио × тир | — |
| Возвышение | +1 тир случайному аффиксу | 6 ядер + осколок | — | | Возвышение | +1 тир СЛУЧАЙНОМУ аффиксу | 3 био + 3 крио × тир, 1 осколок | — |
| **Залом** | +2 тира и 1 бюджет ко всему | 3 шлака | **12%: предмет рассыпается** | | **Залом** | +2 тира всем, но на один аффикс меньше | 3 шлака | **12%: предмет рассыпается** |
Залом — единственная операция с риском, и он настоящий: предмет исчезает. Три уточнения, каждое против конкретного обхода:
- перековка меняет **только значения**. Меняй она ещё и теги — это был бы новый
бросок предмета, то есть способ обойти редкость: собери шесть слотов на чём
угодно и крути, пока не выпадет нужное;
- прививка не ставит тег, который уже есть: правило «один тег один раз»
руками не обходится;
- возвышается **случайный** аффикс. Выбирай игрок сам, возвышение стало бы
«+1 тир к урону» за фиксированную цену, и все предметы в игре сошлись бы
к одному.
Залом — единственная операция с риском, и он настоящий: предмет исчезает, а
плата снимается всё равно. Замерено на тысяче попыток: **рассыпалось 11.6% при
заявленных 12%**, платят всегда, слот теряется всегда. Проверять это иначе
нельзя — «у меня трижды не рассыпалось» не доказательство ни в какую сторону.
Именно поэтому лучшая вещь в игре всегда чуть хуже, чем могла бы быть, — почти Именно поэтому лучшая вещь в игре всегда чуть хуже, чем могла бы быть, — почти
никто не заламывает то, что собирал двадцать часов. Это и задумано: разрыв между никто не заламывает то, что собирал двадцать часов. Это и задумано: разрыв между
«хорошей сборкой» и «идеальной» держит лейтгейм живым. «хорошей сборкой» и «идеальной» держит лейтгейм живым.
### 5.3. Стоимость полной сборки ### 5.3. Стоимость полной сборки
Одна вещь тира 9 со всеми операциями ≈ 340 ломов, 90 ядер, 22 осколка, 6 шлаков. Считается инструментом, а не на глаз: `--tool craft 80`.
За вылазку на D80 добывается ≈ 40 ломов, 12 ядер, 4 осколка, 1 шлак — то есть
**около 8 вылазок на один предмет**. Слотов девять, предметов в сборке — пять ```
ключевых. Экономика материалов рассчитана на 6080 часов лейтгейма, и это ровно на глубине 80: ядер за вылазку 104, полная доводка вещи 648 ядер
акт VI из бюджета времени ([20-gdd.md](20-gdd.md) §5). то есть 6.2 вылазок на предмет (в документе заявлено около 8)
```
Доход **упирается в потолок расстановки**: на этаж влезает не больше
`MAX_LEVEL_SPAWNS` тварей, поэтому с сорокового этажа и глубже вылазка приносит
примерно одно и то же. Глубина даёт не количество материалов, а КАЧЕСТВО
предметов — а вместе с ним и цену доводки, которая растёт с тиром. Отсюда и
кривая: вещь сорокового этажа доводится за три вылазки, вещь сотого — за шесть с
лишним.
Первая версия расчёта брала доход из бюджета опасности этажа и завысила его
впятеро: бюджет уходит не в количество тварей, а в их силу.
## 6. Как это не превращается в кашу ## 6. Как это не превращается в кашу

View File

@ -4,10 +4,12 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <cstdarg> #include <cstdarg>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include "game.h" #include "game.h"
#include "sim/craft.h"
#include "ui/menu.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 int floorsDone = game.floorsCleared + 1;
const float xpEarned = game.xp; const float xpEarned = game.xp;
@ -1183,6 +1193,21 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
std::printf(" [FAIL] исход не эвакуация\n"); std::printf(" [FAIL] исход не эвакуация\n");
++bad; ++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) 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 <cstring>
#include <string> #include <string>
#include "engine/level_gen.h"
#include "engine/progression.h" #include "engine/progression.h"
#include <vector> #include <vector>
#include "sim/affix.h" #include "sim/affix.h"
#include "sim/craft.h"
#include "sim/element.h" #include "sim/element.h"
#include "sim/reactions.h" #include "sim/reactions.h"
#include "sim/items.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 } // namespace
int RunGameTool(int argc, char** argv) 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, "economy") == 0) return CmdEconomy(argc - 1, argv + 1);
if (std::strcmp(cmd, "drops") == 0) return CmdDrops(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, "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); std::printf("нет команды `%s`\n\n", cmd);
CmdHelp(); CmdHelp();

View File

@ -1,5 +1,6 @@
#include "game.h" #include "game.h"
#include "sim/craft.h"
#include "sim/reactions.h" #include "sim/reactions.h"
// Перевод экранных осей ввода в мировые — единственное место, // Перевод экранных осей ввода в мировые — единственное место,
@ -40,6 +41,7 @@ void Game::Reset()
xp = 0.0f; xp = 0.0f;
floorsCleared = 0; floorsCleared = 0;
lootTaken = 0; lootTaken = 0;
for (int m = 0; m < MATERIAL_COUNT; ++m) runMaterials[m] = 0;
} }
void Game::NewRun(int startDepth) void Game::NewRun(int startDepth)
@ -93,6 +95,8 @@ void Game::FinishRun(RunOutcome outcome)
{ {
++profile->extractions; ++profile->extractions;
profile->bankedXp += xp; profile->bankedXp += xp;
// Материалы — та же ставка, что и добыча: донёс или потерял.
for (int m = 0; m < MATERIAL_COUNT; ++m) profile->material[m] += runMaterials[m];
} }
else else
{ {
@ -719,13 +723,28 @@ void Game::AwardKillXp()
if (t.countedDead) continue; if (t.countedDead) continue;
t.countedDead = true; 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; float danger = 1.0f;
if (catalog) if (catalog)
if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger; if (const EntityDesc* d = catalog->Find(IdOfEnemyKind(t.kind))) danger = d->danger;
xp += danger * scale; 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; float xp = 0.0f;
int floorsCleared = 0; int floorsCleared = 0;
int lootTaken = 0; int lootTaken = 0;
// Материалы, добытые ЗА ЭТУ ВЫЛАЗКУ. В профиль уезжают только при
// эвакуации, как и добыча: вайп не приносит ничего (sim/craft.h).
int runMaterials[MATERIAL_COUNT] = {};
// Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение // Отряд дошёл до спуска / до эвакуации. Флаги ставит симуляция, а решение
// принимает игрок: сам Step ни этаж не меняет, ни забег не заканчивает, // принимает игрок: сам 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, "defeat") == 0) ui.screen = Screen::DEFEAT;
else if (std::strcmp(uiShot, "depth") == 0) ui.screen = Screen::DEPTH; 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, "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, "summary") == 0) ui.screen = Screen::SUMMARY;
else if (std::strcmp(uiShot, "pause") == 0) ui.screen = Screen::PAUSE; 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; } 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) if (t[0] == "squad" && t.size() >= 2)
{ {
p.squadSize = std::max(1, std::min(NUM_AGENTS, text::ToInt(t[1], 1))); p.squadSize = std::max(1, std::min(NUM_AGENTS, text::ToInt(t[1], 1)));

View File

@ -23,6 +23,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "sim/craft.h"
#include "sim/items.h" #include "sim/items.h"
#include "tuning.h" #include "tuning.h"
@ -118,13 +119,14 @@ const char* UpgradeEffect(Upgrade u);
// 2 — общая сумка, улучшения отряда // 2 — общая сумка, улучшения отряда
// 3 — размер отряда (найм): раньше бойцов всегда было пять // 3 — размер отряда (найм): раньше бойцов всегда было пять
// 4 — экземпляры предметов: редкость и аффиксы в токене после '@' // 4 — экземпляры предметов: редкость и аффиксы в токене после '@'
// 5 — материалы: строка mat
// //
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который // читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать // записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку. // всего лишь просит не запускать старую сборку.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 4; constexpr int PROFILE_VERSION = 5;
struct Profile struct Profile
{ {
@ -175,6 +177,10 @@ struct Profile
// другой вещью (sim/affix.h). // другой вещью (sim/affix.h).
std::vector<ItemInstance> stash; std::vector<ItemInstance> stash;
// Материалы. ВАЛЮТА: не занимают ячеек, не весят, в схроне не лежат
// (sim/craft.h). Зачисляются только при эвакуации — ставка одна на всё.
int material[MATERIAL_COUNT] = {};
// --- прогресс ------------------------------------------------------------- // --- прогресс -------------------------------------------------------------
int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж
int runs = 0; // сколько вылазок начато int runs = 0; // сколько вылазок начато

View File

@ -4,9 +4,11 @@
#include <cstdarg> #include <cstdarg>
#include <cstdio> #include <cstdio>
#include <cstring>
#include "game.h" #include "game.h"
#include "sim/affix.h" #include "sim/affix.h"
#include "sim/craft.h"
namespace 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_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 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». // Выбор чекпоинта: столбец кнопок «начать с этажа N».
constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10; constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10;
constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает
constexpr int SUM_Y0 = 214, SUM_STEP = 20, SUM_SIZE = 11; constexpr int SUM_Y0 = 214, SUM_STEP = 20, SUM_SIZE = 11;
const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD", "UPGRADES", "QUIT"}; const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD",
constexpr int MENU_COUNT = 5; "UPGRADES", "WORKSHOP", "QUIT"};
constexpr int MENU_COUNT = 6;
const char* const PAUSE_ITEMS[] = {"RESUME", "SQUAD", "MENU"}; const char* const PAUSE_ITEMS[] = {"RESUME", "SQUAD", "MENU"};
constexpr int PAUSE_COUNT = 3; constexpr int PAUSE_COUNT = 3;
@ -382,6 +393,15 @@ UiRect StashRect(int cell)
SQ_STASH_H}; 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, void AddButtons(Widgets& out, const Layout& l, UiPart part, const char* const* names, int count,
int y0, int step, int size) 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) if (game.profile)
{ {
for (int c = 0; c < SQ_STASH_CELLS; ++c) 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; 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; break;
case 2: ui.screen = Screen::SQUAD; break; case 2: ui.screen = Screen::SQUAD; break;
case 3: ui.screen = Screen::UPGRADE; break; case 3: ui.screen = Screen::UPGRADE; break;
case 4: ui.screen = Screen::WORKSHOP; break;
// QUIT — последний пункт, и попасть сюда можно только им. // QUIT — последний пункт, и попасть сюда можно только им.
// Раньше здесь стоял default, и любой НЕ перечисленный пункт // Раньше здесь стоял default, и любой НЕ перечисленный пункт
// выходил из игры: экран улучшений появился четвёртым, а // выходил из игры: экран улучшений появился четвёртым, а
// обработчик о нём не узнал — нажатие UPGRADES закрывало игру, // обработчик о нём не узнал — нажатие UPGRADES закрывало игру,
// и снаружи это выглядело как падение. // и снаружи это выглядело как падение.
case 4: ui.quit = true; break; case 5: ui.quit = true; break;
default: break; default: break;
} }
return; return;
@ -738,6 +781,44 @@ void UiActivate(Ui& ui, Game& game, UiPart part, int index)
ui.screen = Screen::GAME; ui.screen = Screen::GAME;
return; 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: case UiPart::SUMMARY_ITEM:
// Забег кончился, мира больше нет: «продолжить» ведёт только в меню. // Забег кончился, мира больше нет: «продолжить» ведёт только в меню.
ui.started = false; 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; if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU;
return true; 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: case Screen::DEPTH:
{ {
const int shown = w.count; const int shown = w.count;
@ -1297,6 +1394,148 @@ void Ui::Draw(const Game& game, const ScreenRect& fit) const
break; 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: case Screen::DEPTH:
{ {
Shade(l, COL_DIM); Shade(l, COL_DIM);

View File

@ -28,6 +28,7 @@ enum class Screen : uint8_t
SQUAD, // отряд: снаряжение, сумки и схрон SQUAD, // отряд: снаряжение, сумки и схрон
DEPTH, // с какого чекпоинта начать вылазку DEPTH, // с какого чекпоинта начать вылазку
UPGRADE, // во что вложить вынесенный опыт UPGRADE, // во что вложить вынесенный опыт
WORKSHOP, // мастерская: материалы и операции над вещью
SUMMARY, // итог вылазки: что вынесли и что потеряли SUMMARY, // итог вылазки: что вынесли и что потеряли
DEFEAT, // отряд полёг (устарел, ведёт на SUMMARY) DEFEAT, // отряд полёг (устарел, ведёт на SUMMARY)
}; };
@ -50,6 +51,8 @@ enum class UiPart : uint8_t
UPGRADE_ITEM, // строка улучшения: клик покупает следующий уровень UPGRADE_ITEM, // строка улучшения: клик покупает следующий уровень
DEPTH_ITEM, // кнопка выбора чекпоинта DEPTH_ITEM, // кнопка выбора чекпоинта
SUMMARY_ITEM, // кнопка на экране итога SUMMARY_ITEM, // кнопка на экране итога
CRAFT_ITEM, // строка операции мастерской
CRAFT_SLOT, // ячейка схрона в мастерской: что дорабатываем
}; };
// Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне // Прямоугольник в координатах КАДРА (480x270), а не окна: кадр лежит в окне
@ -76,6 +79,15 @@ struct Ui
int depthIndex = 0; // выбранный чекпоинт на экране глубины int depthIndex = 0; // выбранный чекпоинт на экране глубины
int stashScroll = 0; // прокрутка схрона: он длиннее видимой сетки int stashScroll = 0; // прокрутка схрона: он длиннее видимой сетки
// --- мастерская ----------------------------------------------------------
int craftIndex = 0; // какая вещь схрона лежит на верстаке
// Чем кончилась последняя операция. Показывается строкой под верстаком:
// «не хватило материалов» и «рассыпалось» — разные события, и молча
// проглотить их нельзя, особенно второе.
int craftMessage = 0; // 0 — нет, иначе CraftResult + 1
int craftOpDone = 0; // какая операция это была
float craftFlash = 0.0f; // сколько ещё держать сообщение
float blink = 0.0f; // общий таймер для мигания подсказок float blink = 0.0f; // общий таймер для мигания подсказок
bool cursorHidden = false; // системный курсор скрыт (рисуем свой) bool cursorHidden = false; // системный курсор скрыт (рисуем свой)