Редкости и аффиксы: находка стала решением (M25)

Предмет перестал быть строкой каталога. ItemInstance несёт редкость и до
шести аффиксов, и «карабин» с первого этажа больше не равен «карабину» с
сорокового. Четыре редкости, десять тегов, тир и бюджет от глубины.

Аффиксы не заводят новых каналов влияния: каждый тег ложится в то, что уже
есть — урон оружия, смесь стихий, пробитие, четыре числа бойца, ресурсы
вылазки. У предмета по-прежнему нет ручки к правилу «не бить своих», и
появиться ей неоткуда.

ItemInstance неявно приводится к ItemId, поэтому полторы сотни мест,
которым нужен ВИД вещи, остались как были. Цена решения — присваивание в
ItemId молча теряет рулоны; таких мест три (подбор, схрон, запись
профиля), и все они работают с экземпляром целиком.

Замеры поймали три расхождения дизайн-документа с самим собой:

  * формула редкости противоречила таблице рядом с ней: 2.7% находок с
    аффиксами на первом этаже против 22%. Выбрана формула, наклон поднят
    до схождения с таблицей на дне (12% обычных на сотом);
  * бюджет пускал восемь высших аффиксов вместо обещанных шести;
  * предельная вещь собиралась к семидесятому этажу вместо сотого.

Числа пересчитаны, документ исправлен: 6·cost(9) = 294 <= B(100) = 295 <
7·cost(9) = 343. Предельная вещь существует ровно на дне и ровно одна.

Проверка в --descend меряет не бросок, а ПРОВОДКУ до бойца — там, где
дважды умирали вес и стихии:

  аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25,
           пробитие 0.20, KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000

Последнее число не украшение: конверсия обязана переводить урон, а не
добавлять седьмую долю сверх единицы.

Попутно, на глубине 30 и только там:

  * «зачистка поля» в проверках не держалась — у твари есть откат
    воскрешения, и с нулевым таймером она оживала тем же шагом. Лечение
    вне схватки и расход света на стоянке молча мерили бой. На восьмом
    этаже тварей мало, и проверка проходила по удаче;
  * порог «стоянка бесплатна» стоял в единицах заряда, а тот же шаг стоит
    дороже с глубиной. Переведён в тайлы — и сразу показал, что отряд в
    строю проходит 0.8 тайла за две секунды «стоянки». Свет теперь
    списывается только в ADVANCING: топтание и драка бесплатны, тратит
    РАЗВЕДКА. Без этого таймер возвращался через чёрный ход.

Формат профиля 4: токен предмета вида rifle@relic+dmg7,0:0.63. Миграции
не требует — у старых токенов нет хвоста после '@'.

--accept PASS без изменений: в песочнице находок нет вовсе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
z.kirill 2026-08-16 02:35:00 +03:00
parent ece906cbb3
commit d3aa044cbe
22 changed files with 1210 additions and 113 deletions

View File

@ -67,6 +67,10 @@
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей). затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
Числа не должны спасать — иначе хоррор кончается на сороковом этаже Числа не должны спасать — иначе хоррор кончается на сороковом этаже
([20-gdd.md](docs/20-gdd.md)). ([20-gdd.md](docs/20-gdd.md)).
- **Предмет — это ЭКЗЕМПЛЯР, а не вид.** `ItemInstance` неявно приводится к
`ItemId`, и это удобно ровно до того места, где вещь копируют: присваивание в
`ItemId` молча теряет редкость и аффиксы. Подбор, перекладывание и запись
профиля обязаны работать с экземпляром целиком ([22-loot.md](docs/22-loot.md)).
- **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а - **Ни одного чистого улучшения.** Артефакт без цены становится обязательным, а
обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)). обязательный предмет — это вырезанный слот ([22-loot.md](docs/22-loot.md)).
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции - **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции

View File

@ -131,7 +131,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| `--seed N` | сид сборки уровня (по умолчанию 1) | | `--seed N` | сид сборки уровня (по умолчанию 1) |
| `--depth N` | начать не с первого этажа (для проверок) | | `--depth N` | начать не с первого этажа (для проверок) |
| `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль | | `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль |
| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` | | `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `reactions`, `economy`, `drops`, `affix` |
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты | | `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
| `--no-profile` | не читать и не писать профиль | | `--no-profile` | не читать и не писать профиль |
@ -164,6 +164,8 @@ squad_proto.exe --tool ttk # время убийства и разбро
squad_proto.exe --tool enemies # здоровье и сопротивления тварей squad_proto.exe --tool enemies # здоровье и сопротивления тварей
squad_proto.exe --tool reactions # реакции и три ограничителя 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 affix 40 # бюджет предмета и потолок аффиксов
``` ```
Редактор комнат — отдельное приложение с собственными ключами: Редактор комнат — отдельное приложение с собственными ключами:

View File

@ -38,6 +38,8 @@
| **M24** | отряд растёт с ОДНОГО оперативника до пяти: размер в профиле, найм за опыт по кривой 600/2400/9600/38400, стартовый комплект одиночки — нож, фонарь, куртка; пустой слот отличается от павшего (`present` против `alive`) | проверка в `--descend`: в профиле 1 → на этаже 1, найм доводит второго, шестой не нанимается ни за какие деньги; одиночка с ножом на первом этаже выживает и убивает двух тварей, теряя 59 из 100 здоровья; первая версия этой проверки была ПУСТОЙ — боец стоял минуту, к нему не подошёл никто, и «выжил» ничего не значило; попутно нашлось, что `Profile::Save` три вехи не писала ни версию, ни улучшения, а круговой прогон сверял три поля из десяти и потому этого не видел; `--accept` PASS без изменений — без профиля отряд по-прежнему впятером ([17-extraction.md](17-extraction.md)) | | **M24** | отряд растёт с ОДНОГО оперативника до пяти: размер в профиле, найм за опыт по кривой 600/2400/9600/38400, стартовый комплект одиночки — нож, фонарь, куртка; пустой слот отличается от павшего (`present` против `alive`) | проверка в `--descend`: в профиле 1 → на этаже 1, найм доводит второго, шестой не нанимается ни за какие деньги; одиночка с ножом на первом этаже выживает и убивает двух тварей, теряя 59 из 100 здоровья; первая версия этой проверки была ПУСТОЙ — боец стоял минуту, к нему не подошёл никто, и «выжил» ничего не значило; попутно нашлось, что `Profile::Save` три вехи не писала ни версию, ни улучшения, а круговой прогон сверял три поля из десяти и потому этого не видел; `--accept` PASS без изменений — без профиля отряд по-прежнему впятером ([17-extraction.md](17-extraction.md)) |
| **M25** | редкости и аффиксы: экземпляр предмета вместо вида (`ItemInstance`), четыре редкости, десять тегов, бюджет и тир по глубине; выкатанное ложится в те же четыре числа бойца и в стихии оружия; инструменты `--tool drops` и `--tool affix` | замеры поймали ТРИ расхождения дизайн-документа с самим собой: формула редкости противоречила своей же таблице (2.7% против 22% на первом этаже), бюджет пускал восемь высших аффиксов вместо обещанных шести, а предельная вещь собиралась к семидесятому этажу вместо сотого — числа пересчитаны, документ исправлен; проверка «доезжает до бойца» показала урон 12→18, здоровье 100→140, броню 0.10→0.25, конверсию KIN 1.00→0.60 при сумме долей ровно 1.000; `--accept` PASS без изменений ([22-loot.md](22-loot.md)) |
## Что находилось по ходу и чинилось ## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта, Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -168,6 +168,11 @@ stash saber plate vest smg bow charm lantern spurs
меняется с тварями местами, и первым теперь видит не он. меняется с тварями местами, и первым теперь видит не он.
**Главное решение — заряд тратится за пройденное расстояние, а не за время.** **Главное решение — заряд тратится за пройденное расстояние, а не за время.**
Точнее: за расстояние, пройденное **под управлением игрока** (состояние
`ADVANCING`). Топтание в строю не считается — замер показал, что центр отряда за
две секунды «стоянки» проходит около 0.8 тайла, и списывай игра это, заряд таял
бы, пока игрок думает над картой. То есть вернулся бы тот самый таймер. Драка
тоже бесплатна: свет тратит РАЗВЕДКА, а не бой.
Расход по секундам был бы таймером, только без циферблата: садящийся сам собой Расход по секундам был бы таймером, только без циферблата: садящийся сам собой
фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого. фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого.
@ -317,7 +322,10 @@ squad_proto.exe --descend 7 --no-profile
боезапас выглядел бы ровно так же. боезапас выглядел бы ровно так же.
У света первая строка проверки — **не про баланс, а про жанр**: заряд на У света первая строка проверки — **не про баланс, а про жанр**: заряд на
стоянке обязан стоять. Написать таймер случайно очень легко (одна строка со стоянке обязан стоять, и порог у неё **в тайлах, а не в заряде**. Один и тот же
шаг стоит тем дороже, чем глубже (`LightDrain`), и порог в единицах заряда
объявлял таймером обычное топтание на шестидесятом этаже, пропуская его на
восьмом. Написать таймер случайно очень легко (одна строка со
`dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково. `dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково.
Расход сравнивается с числом из каталога, и первая версия проверки показала Расход сравнивается с числом из каталога, и первая версия проверки показала

View File

@ -8,20 +8,36 @@
## 1. Редкости ## 1. Редкости
| Редкость | Аффиксов | Доля дропа D1 | D50 | D100 | Сделано в M25. Числа ниже — **замеренные** (`--tool drops 1 100`, по 10000
|---|---|---|---|---| бросков на глубину), а не задуманные:
| Обычное | 0 | 78% | 34% | 12% |
| Меченое | 12 | 20% | 40% | 30% | | Редкость | Аффиксов | D1 | D28 | D55 | D100 |
| Разработка | 34 | 2% | 20% | 34% | |---|---|---|---|---|---|
| Реликт | 56 | — | 5.5% | 20% | | Обычное | 0 | 96.9% | 67.5% | 38.4% | 12.3% |
| **Артефакт** | уникальное правило + 2 | — | 0.5% | 4% | | Меченое | 12 | 2.9% | 21.3% | 23.5% | 21.9% |
| Разработка | 34 | 0.1% | 7.1% | 25.1% | 39.6% |
| Реликт | 56 | ~0.01% | 4.1% | 13.1% | 26.2% |
| **Артефакт** | уникальное правило + 2 | — | — | — | — |
Кривая редкости: Кривая редкости:
``` ```
P(rare+) = 0.02 + 0.0075·d (до 0.80) P(не обычное) = 0.02 + 0.011·d (до 0.88)
P(артефакт) = max(0, 0.004·(d 30)) (до 0.06)
``` ```
**Наклон 0.011, а не 0.0075.** В первой редакции этого документа формула и
таблица рядом с ней противоречили друг другу: формула давала на первом этаже
2.7% находок с аффиксами, таблица — 22%. Выбрана формула (первый этаж обязан
быть скучным, [20-gdd.md](20-gdd.md) §1), а наклон поднят так, чтобы сойтись с
таблицей на дне: 12% обычных на сотом этаже. Проверяется инструментом.
Реликт на первом этаже возможен — примерно один на восемь тысяч находок. Это
намеренно не запрещено: тир там всё равно первый, то есть пять рулонов по пять
процентов. Лотерейный выигрыш, а не сломанное начало.
**Артефактов пока нет** — они не «ещё не выкачены», а следующая веха: артефакт
даёт ПРАВИЛО, а не число, и для этого нужны триггеры (on-hit, on-kill), которых
в игре ещё нет.
**Пити на артефакт:** 60 этажей без артефакта → следующий гарантирован. Без **Пити на артефакт:** 60 этажей без артефакта → следующий гарантирован. Без
пити распределение даёт игроку без артефакта на восьмидесятом этаже, и это не пити распределение даёт игроку без артефакта на восьмидесятом этаже, и это не
«не повезло», а сломанная сессия на двадцать часов. «не повезло», а сломанная сессия на двадцать часов.
@ -48,13 +64,23 @@ D75. Артефакты общие намеренно — они правила,
``` ```
доступный тир на глубине: T_max(d) = 1 + floor(d / 12) (до 9) доступный тир на глубине: T_max(d) = 1 + floor(d / 12) (до 9)
диапазон тира T: [lo·T^1.35, hi·T^1.35] диапазон тира T: [lo·T^1.35, hi·T^1.35]
цена аффикса в бюджете: cost(T) = 6 + 4·T цена аффикса в бюджете: cost(T) = 4 + 5·T
бюджет предмета: B(ilvl) = 40 + 3·ilvl бюджет предмета: B(ilvl) = 45 + 2.5·ilvl
``` ```
Из этого следует потолок: на предмете с ilvl 100 бюджет 340 — это шесть аффиксов Из этого следует потолок: на предмете с ilvl 100 бюджет 295, шесть аффиксов
девятого тира и ни одним больше. Бюджет не позволяет собрать «всё сразу», и это девятого тира стоят 294, седьмой — уже 343. То есть предельная вещь существует
единственный барьер, который не приходится подпирать частными запретами. ровно на дне и ровно одна.
**Цифры пересчитаны против первой редакции.** Там стояло `cost = 6 + 4·T` при
`B = 40 + 3·ilvl` и утверждалось то же самое — «шесть и ни одним больше». На
деле влезало восемь (340 / 42 = 8.1), а предельная вещь собиралась уже к
семидесятому этажу. Обещание документа не выполнялось его же арифметикой; это
нашёл `--tool affix`, а не чтение.
Выше по глубине потолок держит бюджет, ниже — счётчик аффиксов: на сороковом
этаже доступен только четвёртый тир, шесть таких стоят 144 при бюджете 145.
Это нормально и не является поломкой: редкость там и обещает не больше шести.
### 3.1. Группы аффиксов ### 3.1. Группы аффиксов
@ -158,10 +184,26 @@ share(KIN) = 1 Σ конверсий, Σ конверсий ≤ 0.85
## 7. Проверки ## 7. Проверки
| Утверждение | Инструмент | | Утверждение | Инструмент | Статус |
|---|---| |---|---|---|
| кривая редкости даёт артефакт к D50 | `--tool drops 1 120` (новый): симуляция 10⁴ этажей | | кривая редкости: первый этаж скучный, сотый щедрый | `--tool drops 1 100` | есть |
| бюджет не пускает шесть высших аффиксов | `--tool affix <ilvl>` (новый) | | бюджет пускает ровно шесть высших аффиксов и только на дне | `--tool affix <глубина>` | есть |
| материалов хватает на сборку за ~8 вылазок | `--tool economy` (новый) | | выкатанное доезжает до бойца и до правил урона | `--descend` | есть |
| ни один артефакт не является чистым плюсом | ревью-чеклист + тест «сборка без цены» | | редкость и рулоны переживают запись профиля | `--descend`, круговой прогон | есть |
| TTK не выходит из коридора с лучшими вещами | `--tool ttk <глубина> --best` | | материалов хватает на сборку за ~8 вылазок | `--tool economy` | материалов ещё нет |
| ни один артефакт не является чистым плюсом | ревью-чеклист | артефактов ещё нет |
| TTK не выходит из коридора с лучшими вещами | `--tool ttk --best` | нет |
Проверка «доезжает до бойца» — главная. Механика, которая есть в каталоге, но не
доходит до правил боя, выглядит снаружи как работающая, и на этом уже дважды
попадались вес и стихии ([07-milestones.md](07-milestones.md)). Поэтому в
`--descend` подкладывается карабин с известными рулонами и сверяется всё:
```
аффиксы: урон 12.0 -> 18.0, здоровье 100 -> 140, броня 0.10 -> 0.25, пробитие 0.20
стихии карабина: KIN 1.00 -> 0.60, CRY 0.40, сумма долей 1.000
```
Последнее число тут не украшение: конверсия обязана ПЕРЕВОДИТЬ урон, а не
добавлять седьмую долю сверх единицы. Иначе стихии стали бы каналом наращивания
чисел — ровно тем, чем они не должны быть ([21-damage.md](21-damage.md)).

View File

@ -604,6 +604,25 @@ int RunAccept()
return allPass ? 0 : 1; return allPass ? 0 : 1;
} }
// Убрать с этажа всех тварей НАСОВСЕМ.
//
// Насовсем — ключевое слово. Просто `alive = false` не работает: у мишени есть
// откат воскрешения, и с нулевым таймером она оживает тем же шагом. Проверки,
// которым нужно спокойное поле (лечение вне схватки, расход света на стоянке),
// на этом молча меряли бой.
//
// Поймано на глубине 30 и только там: на восьмом этаже тварей мало, и
// ближайшая воскресшая случайно оказывалась дальше девяти тайлов. Проверка
// проходила по УДАЧЕ, а не потому, что поле было чистым.
void ClearField(Game& game)
{
for (Target& t : game.targets)
{
t.alive = false;
t.respawnTimer = 1e9f;
}
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// --descend N: забег на N этажей без окна. // --descend N: забег на N этажей без окна.
// //
@ -670,12 +689,21 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
for (int a = 0; a < NUM_AGENTS; ++a) for (int a = 0; a < NUM_AGENTS; ++a)
ammoLeft += std::max(0, game.squad.agents[a].ammoReserve); ammoLeft += std::max(0, game.squad.agents[a].ammoReserve);
// Сколько находок на полу ВЫКАТАНЫ. Отдельно от `--tool drops`: тот
// меряет саму кривую на десятках тысяч бросков, а этот столбец
// показывает, что бросок вообще случился на настоящем этаже — то есть
// что генератор лута и аффиксы соединены.
int rolled = 0;
for (const FloorItem& fi : game.floorItems)
if (fi.id.Count() > 0) ++rolled;
std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f" std::printf("%-6d %-7s %7d %6.0f %5d %6d живых %d, в сумках %d, груз до %.1f"
" (перегруз %d), патронов %d, света на %.0f тайлов\n", " (перегруз %d), патронов %d, света на %.0f тайлов, с аффиксами %d/%d\n",
l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced, l.floor.depth, ThemeName(l.floor.theme), l.monstersPlaced,
double(l.dangerSpent), int(l.loot.size()), l.exitDistance, double(l.dangerSpent), int(l.loot.size()), l.exitDistance,
game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft, game.AliveAgents(), bags, double(maxLoad), overloaded, ammoLeft,
double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth)))); double(game.squad.lightCharge / std::max(0.01f, LightDrain(l.floor.depth))),
rolled, int(game.floorItems.size()));
// Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд, // Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд,
// обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение // обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение
@ -735,7 +763,7 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
else else
{ {
// Лечиться можно только вне схватки, поэтому поле зачищается. // Лечиться можно только вне схватки, поэтому поле зачищается.
for (Target& t : game.targets) t.alive = false; ClearField(game);
bag.Clear(); bag.Clear();
bag.slots[0] = ItemId::AMMO; bag.slots[0] = ItemId::AMMO;
@ -834,11 +862,26 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
// 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не // 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не
// должно стоить ничего заметного. // должно стоить ничего заметного.
//
// Поле зачищается насовсем: иначе на глубоких этажах отряд посреди
// «стоянки» отбивается от тварей, бегает и жжёт свет, а проверка
// объявляет это таймером.
ClearField(game);
game.in = InputState{}; game.in = InputState{};
// Отряд сначала ДОХОДИТ до своих мест в строю: после высадки он ещё
// расходится по слотам, и мерить «стоянку» на этом — мерить ходьбу.
for (int k = 0; k < 60; ++k) game.Step(FIXED_DT);
const float standBefore = game.squad.lightCharge; const float standBefore = game.squad.lightCharge;
for (int k = 0; k < 120; ++k) game.Step(FIXED_DT); for (int k = 0; k < 120; ++k) game.Step(FIXED_DT);
const float standCost = standBefore - game.squad.lightCharge; const float standCost = standBefore - game.squad.lightCharge;
// Порог — В ТАЙЛАХ, а не в заряде. Заряд за один и тот же шаг стоит
// тем дороже, чем глубже (LightDrain), и порог в единицах заряда
// объявлял бы таймером обычное топтание в строю на шестидесятом этаже.
const float standTiles = standCost / std::max(0.01f, LightDrain(game.depth));
// 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону // 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону
// и делим потраченное на пройденное. // и делим потраченное на пройденное.
// //
@ -906,7 +949,7 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
" глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска" " глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска"
" (средний %.0f, здешний %d)\n" " (средний %.0f, здешний %d)\n"
" обзор %.2f -> %.2f в темноте, батарея %s\n", " обзор %.2f -> %.2f в темноте, батарея %s\n",
double(standCost), double(perTile), double(LightDrain(game.depth)), double(standTiles), double(perTile), double(LightDrain(game.depth)),
double(path), double(progress), double(path), double(progress),
game.depth, double(reach), double(floorsPerCharge), double(avgExit), game.depth, double(reach), double(floorsPerCharge), double(avgExit),
exitTiles, exitTiles,
@ -923,10 +966,10 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
" не только путь\n", double(perTile), double(expected)); " не только путь\n", double(perTile), double(expected));
++bad; ++bad;
} }
if (standCost > 1.0f) if (standTiles > 0.05f)
{ {
std::printf(" [FAIL] заряд тает на стоянке (%.1f за две секунды) — это таймер," std::printf(" [FAIL] отряд на стоянке прожёг света на %.2f тайла за две секунды —"
" а таймера в игре нет\n", double(standCost)); " это таймер, а таймера в игре нет\n", double(standTiles));
++bad; ++bad;
} }
if (perTile <= 0.0f) if (perTile <= 0.0f)
@ -967,6 +1010,108 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
game.squad.lightCharge = savedLight; game.squad.lightCharge = savedLight;
} }
// -------------------------------------------------------------------------
// Аффиксы. Проверяется ЦЕПОЧКА до бойца, а не сам бросок: бросок меряется
// отдельно и на десятках тысяч образцов (`--tool drops`), а здесь важно
// другое — доходит ли выкатанное число до того, кто дерётся.
//
// Ровно тут ломались все прежние вехи: механика есть, число в каталоге
// есть, а до правил боя оно не доезжает, и снаружи это выглядит как
// работающая система (docs/07-milestones.md).
// -------------------------------------------------------------------------
{
Agent& a = game.squad.agents[0];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv)
{
std::printf("\n [FAIL] у бойца нет инвентаря — аффиксы не проверить\n");
++bad;
}
else
{
const Inventory savedInv = *inv;
// Голое состояние: карабин без рулонов.
EquipWeapon(game.world, a, ItemId::RIFLE);
inv->weapon = ItemInstance(ItemId::RIFLE);
inv->armor = ItemInstance(ItemId::JACKET);
game.Step(FIXED_DT);
const float dmgBare = a.gun.def.damage;
const float hpBare = a.maxHp;
const float armorBare = a.armor;
const float kinBare = a.gun.def.elem.share[int(Element::KIN)];
// Тот же карабин, но с рулонами. Числа выставлены руками: проверка
// про ПРОВОДКУ, и случайный бросок сделал бы её плавающей.
ItemInstance rifle(ItemId::RIFLE);
rifle.rarity = Rarity::PROTOTYPE;
rifle.affix[rifle.count++] = Affix{AffixTag::DAMAGE, 4, 0, 0.50f};
rifle.affix[rifle.count++] = Affix{AffixTag::PEN, 3, 0, 0.20f};
rifle.affix[rifle.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::CRY), 0.40f};
ItemInstance jacket(ItemId::JACKET);
jacket.rarity = Rarity::MARKED;
jacket.affix[jacket.count++] = Affix{AffixTag::HEALTH, 6, 0, 40.0f};
jacket.affix[jacket.count++] = Affix{AffixTag::ARMOR, 4, 0, 0.15f};
inv->weapon = rifle;
inv->armor = jacket;
game.Step(FIXED_DT);
const float dmgRolled = a.gun.def.damage;
const float hpRolled = a.maxHp;
const float armorRolled = a.armor;
const float cryRolled = a.gun.def.elem.share[int(Element::CRY)];
const float kinRolled = a.gun.def.elem.share[int(Element::KIN)];
const float penRolled = a.pen;
// Сумма долей стихий обязана остаться единицей: конверсия ЗАБИРАЕТ
// часть удара, а не добавляет седьмую долю сверху.
float mixSum = 0.0f;
for (int e = 0; e < ELEMENT_COUNT; ++e) mixSum += a.gun.def.elem.share[e];
std::printf("\nаффиксы: урон %.1f -> %.1f, здоровье %.0f -> %.0f, броня %.2f -> %.2f,"
" пробитие %.2f\n"
" стихии карабина: KIN %.2f -> %.2f, CRY %.2f, сумма долей %.3f\n",
double(dmgBare), double(dmgRolled), double(hpBare), double(hpRolled),
double(armorBare), double(armorRolled), double(penRolled),
double(kinBare), double(kinRolled), double(cryRolled), double(mixSum));
if (dmgRolled <= dmgBare * 1.01f)
{
std::printf(" [FAIL] аффикс урона не дошёл до оружия\n");
++bad;
}
if (hpRolled <= hpBare || armorRolled <= armorBare)
{
std::printf(" [FAIL] аффиксы брони не дошли до бойца\n");
++bad;
}
if (penRolled <= 0.0f)
{
std::printf(" [FAIL] пробитие не дошло до правил урона\n");
++bad;
}
if (cryRolled <= 0.0f || kinRolled >= kinBare)
{
std::printf(" [FAIL] конверсия не изменила смесь стихий\n");
++bad;
}
if (mixSum < 0.995f || mixSum > 1.005f)
{
std::printf(" [FAIL] сумма долей стихий %.3f: конверсия добавила урон вместо"
" того, чтобы его перевести\n", double(mixSum));
++bad;
}
// Мир возвращается как был: ниже ещё считается переноска и пишется
// профиль, и делать это на подложенном снаряжении нельзя.
*inv = savedInv;
EquipWeapon(game.world, a, inv->weapon);
game.Step(FIXED_DT);
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз» // Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами, // выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
@ -1069,6 +1214,17 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
profile.extractions = 7; profile.extractions = 7;
profile.wipes = 4; profile.wipes = 4;
// И ВЕЩЬ С АФФИКСАМИ — в снаряжении и в схроне. Без неё круговой прогон
// сверял бы предметы без рулонов, то есть ровно ту часть формата,
// которая работала и раньше.
ItemInstance relic(ItemId::MAGNUM);
relic.rarity = Rarity::RELIC;
relic.affix[relic.count++] = Affix{AffixTag::DAMAGE, 7, 0, 0.6321f};
relic.affix[relic.count++] = Affix{AffixTag::CONVERT, 5, uint8_t(Element::VOID), 0.2718f};
relic.affix[relic.count++] = Affix{AffixTag::AMMO, 3, 0, 24.0f};
profile.loadout[0].weapon = relic;
profile.StashPush(relic);
std::string why; std::string why;
Profile back; Profile back;
if (!profile.Save(tmp, &why)) if (!profile.Save(tmp, &why))
@ -1110,13 +1266,27 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
same(back.StashCount() == profile.StashCount(), "схрон"); same(back.StashCount() == profile.StashCount(), "схрон");
for (int u = 0; u < int(Upgrade::COUNT); ++u) for (int u = 0; u < int(Upgrade::COUNT); ++u)
same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u))); same(back.upgrade[u] == profile.upgrade[u], UpgradeName(Upgrade(u)));
// Предметы сверяются ЦЕЛИКОМ, с рулонами. Сравнение через ItemId
// здесь молчаливо прошло бы: неявная конверсия видит только вид
// вещи, и реликт, доехавший до профиля обычным, выглядел бы как
// совпадение (sim/items.h).
auto sameItem = [&](const ItemInstance& x, const ItemInstance& y, const char* what) {
bool ok = (x.id == y.id) && (x.rarity == y.rarity) && (x.count == y.count);
for (int k = 0; ok && k < x.Count(); ++k)
ok = x.affix[k].tag == y.affix[k].tag && x.affix[k].tier == y.affix[k].tier &&
x.affix[k].param == y.affix[k].param &&
std::fabs(x.affix[k].value - y.affix[k].value) < 0.001f;
same(ok, what);
};
for (int a = 0; a < NUM_AGENTS; ++a) for (int a = 0; a < NUM_AGENTS; ++a)
{ {
const Inventory& x = profile.loadout[a]; sameItem(profile.loadout[a].weapon, back.loadout[a].weapon, "оружие бойца");
const Inventory& y = back.loadout[a]; sameItem(profile.loadout[a].armor, back.loadout[a].armor, "броня бойца");
same(x.weapon == y.weapon && x.armor == y.armor && x.trinket == y.trinket, sameItem(profile.loadout[a].trinket, back.loadout[a].trinket, "карман бойца");
"снаряжение бойца");
} }
for (int k = 0; k < profile.StashCount() && k < back.StashCount(); ++k)
sameItem(profile.stash[size_t(k)], back.stash[size_t(k)], "вещь из схрона");
if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; } if (diff > 0) { std::printf(" [FAIL] профиль не сходится: %d расхождений\n", diff); ++bad; }
else std::printf("профиль: запись и чтение сходятся по всем полям\n"); else std::printf("профиль: запись и чтение сходятся по всем полям\n");

View File

@ -27,6 +27,7 @@
#include "engine/progression.h" #include "engine/progression.h"
#include <vector> #include <vector>
#include "sim/affix.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"
@ -313,6 +314,140 @@ int CmdEconomy(int argc, char** argv)
return 0; return 0;
} }
// -----------------------------------------------------------------------------
// drops <от> <до>: кривая редкости по глубине.
//
// Проверяет ровно то, что нельзя увидеть в игре: чтобы понять, «часто ли падают
// реликты на сороковом», игроку пришлось бы пройти сорок этажей несколько
// десятков раз. Инструмент бросает по десять тысяч предметов на глубину.
// -----------------------------------------------------------------------------
int CmdDrops(int argc, char** argv)
{
const int from = (argc >= 1) ? std::atoi(argv[0]) : 1;
const int to = (argc >= 2) ? std::atoi(argv[1]) : 100;
const int step = std::max(1, (to - from) / 10);
constexpr int ROLLS = 10000;
std::printf("%-6s %8s %8s %8s %8s %8s %8s\n", "DEPTH", "COMMON", "MARKED", "PROTO", "RELIC",
"AFFIX", "TIER");
int bad = 0;
for (int d = from; d <= to; d += step)
{
int byRarity[int(Rarity::COUNT)] = {};
int affixes = 0;
int tiers = 0;
uint32_t rng = uint32_t(d) * 2654435761u + 7u;
for (int i = 0; i < ROLLS; ++i)
{
// Куртка — обычная вещь среднего слота: по ней и меряем, чтобы
// цифры сравнивались между глубинами, а не между видами.
const ItemInstance it = RollItem(ItemId::JACKET, d, rng);
++byRarity[int(it.rarity)];
affixes += it.Count();
for (int a = 0; a < it.Count(); ++a) tiers += it.affix[a].tier;
}
std::printf("%-6d %7.1f%% %7.1f%% %7.1f%% %7.1f%% %8.2f %8.2f\n", d,
100.0 * byRarity[0] / ROLLS, 100.0 * byRarity[1] / ROLLS,
100.0 * byRarity[2] / ROLLS, 100.0 * byRarity[3] / ROLLS,
double(affixes) / ROLLS,
(affixes > 0) ? double(tiers) / double(affixes) : 0.0);
// Первый этаж обязан быть скучным: игра начинается «полупустой
// лабораторией» (docs/20-gdd.md §1).
//
// Проверяется ДОЛЯ, а не сам факт реликта. Реликт на первом этаже
// возможен — примерно один на восемь тысяч находок, — и запрещать его
// незачем: тир там всё равно первый, то есть это пять рулонов по
// пять процентов. Лотерейный выигрыш, а не сломанное начало. Первая
// версия проверки требовала ноль и падала на этой самой лотерее.
if (d == 1 && byRarity[int(Rarity::COMMON)] < ROLLS * 90 / 100)
{
std::printf(" [FAIL] на первом этаже %.1f%% находок с аффиксами — начало не"
" должно быть щедрым\n",
100.0 - 100.0 * byRarity[int(Rarity::COMMON)] / ROLLS);
++bad;
}
if (d == 1 && byRarity[int(Rarity::RELIC)] * 1000 > ROLLS)
{
std::printf(" [FAIL] реликты на первом этаже не лотерея, а норма\n");
++bad;
}
// А глубокий — не обязан быть скучным никогда.
if (d >= 60 && byRarity[int(Rarity::COMMON)] * 2 > ROLLS)
{
std::printf(" [FAIL] на глубине %d больше половины находок обычные\n", d);
++bad;
}
}
std::printf("\nбросков на глубину: %d\n", ROLLS);
return bad == 0 ? 0 : 1;
}
// -----------------------------------------------------------------------------
// affix <глубина>: что бюджет пускает на предмет этой глубины.
//
// Главная проверка тут — ПОТОЛОК. Шесть аффиксов высшего тира не должны
// влезать никуда, кроме самого дна: иначе собранный предмет перестаёт быть
// решением и становится просто суммой.
// -----------------------------------------------------------------------------
int CmdAffix(int argc, char** argv)
{
const int depth = (argc >= 1) ? std::atoi(argv[0]) : 40;
std::printf("глубина %d: бюджет %.0f, доступный тир %d, цена тира %.0f..%.0f\n\n", depth,
double(ItemBudget(depth)), MaxAffixTier(depth), double(AffixCost(1)),
double(AffixCost(MaxAffixTier(depth))));
std::printf("%-6s %8s %10s %12s\n", "TIER", "COST", "MAX COUNT", "SIX COST");
int bad = 0;
for (int t = 1; t <= MAX_AFFIX_TIER; ++t)
{
const float cost = AffixCost(t);
const int fit = int(ItemBudget(depth) / cost);
std::printf("%-6d %8.0f %10d %12.0f%s\n", t, double(cost), fit, double(cost * 6.0f),
(t > MaxAffixTier(depth)) ? " (недоступен здесь)" : "");
}
// Потолок предмета: шесть аффиксов высшего тира и ни одним больше, и
// только на самом дне. Проверяется АРИФМЕТИКОЙ на предельном уровне, а не
// на текущей глубине: наверху высоких тиров нет вовсе, и там потолок
// держит счётчик аффиксов, а не бюджет, — это нормально и failʼом не
// является (docs/22-loot.md §3).
const float top = AffixCost(MAX_AFFIX_TIER);
const float sixTop = top * 6.0f;
const float bottom = ItemBudget(100);
// Глубина, на которой предельная вещь впервые становится собираемой.
int needDepth = 1;
while (needDepth < 400 && ItemBudget(needDepth) < sixTop) ++needDepth;
std::printf("\nшесть аффиксов T%d стоят %.0f, бюджет сотого этажа %.0f -> влезает %.2f\n"
"предельная вещь собирается с глубины %d\n",
MAX_AFFIX_TIER, double(sixTop), double(bottom), double(bottom / top), needDepth);
if (bottom < sixTop)
{
std::printf(" [FAIL] предельная вещь не собирается даже на дне\n");
++bad;
}
if (bottom >= sixTop + top)
{
std::printf(" [FAIL] на дне влезает седьмой высший аффикс — потолок не держит\n");
++bad;
}
if (needDepth < 80)
{
std::printf(" [FAIL] предельная вещь собирается уже на %d этаже\n", needDepth);
++bad;
}
return bad == 0 ? 0 : 1;
}
} // namespace } // namespace
int RunGameTool(int argc, char** argv) int RunGameTool(int argc, char** argv)
@ -326,6 +461,8 @@ int RunGameTool(int argc, char** argv)
if (std::strcmp(cmd, "ttk") == 0) return CmdTtk(); if (std::strcmp(cmd, "ttk") == 0) return CmdTtk();
if (std::strcmp(cmd, "reactions") == 0) return CmdReactions(); if (std::strcmp(cmd, "reactions") == 0) return CmdReactions();
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, "affix") == 0) return CmdAffix(argc - 1, argv + 1);
std::printf("нет команды `%s`\n\n", cmd); std::printf("нет команды `%s`\n\n", cmd);
CmdHelp(); CmdHelp();

View File

@ -153,8 +153,11 @@ void Game::HarvestToProfile(bool onlySurvivors)
const bool anySurvivor = AliveAgents() > 0; const bool anySurvivor = AliveAgents() > 0;
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s) for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{ {
const ItemId id = squad.bag.slots[s]; // ЭКЗЕМПЛЯР, а не вид: через ItemId вещь доехала бы до схрона без
if (id == ItemId::NONE) continue; // аффиксов, то есть игрок вынес бы реликт, а на базе нашёл обычную
// куртку. Неявная конверсия молчит, поэтому такие места пишутся явно.
const ItemInstance& id = squad.bag.slots[s];
if (id.Empty()) continue;
if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought; if (anySurvivor && profile->StashPush(id)) ++report.itemsBrought;
else ++report.itemsLost; else ++report.itemsLost;
@ -292,7 +295,7 @@ void Game::BuildFloor(const SquadCarry* carry)
{ {
int unknownLoot = 0; int unknownLoot = 0;
SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns); SpawnFromLevel(world, level.spawns, map, &levelUnknownSpawns);
SpawnLootFromLevel(world, level.loot, &unknownLoot); SpawnLootFromLevel(world, level.loot, depth, levelSeed, &unknownLoot);
levelUnknownSpawns += unknownLoot; levelUnknownSpawns += unknownLoot;
} }
else else
@ -332,17 +335,28 @@ void Game::BuildFloor(const SquadCarry* carry)
// Патроны выдаются ЛЮДЯМ. Пустому слоту запас не нужен, и выдай его // Патроны выдаются ЛЮДЯМ. Пустому слоту запас не нужен, и выдай его
// туда — отряд из одного оперативника выходил бы с пятикратным // туда — отряд из одного оперативника выходил бы с пятикратным
// боезапасом, разложенным по четырём никому. // боезапасом, разложенным по четырём никому.
//
// Аффикс ROUNDS прибавляется ЛИЧНО тому, у кого он выкатан: запас
// патронов личный, в отличие от света. Складывать его в общий котёл
// значило бы, что найденная кем-то одним разгрузка кормит весь отряд.
for (int i = 0; i < NUM_AGENTS; ++i) for (int i = 0; i < NUM_AGENTS; ++i)
{ {
Agent& a = squad.agents[i]; Agent& a = squad.agents[i];
a.ammoReserve = combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus) : 0) : -1; a.ammoReserve =
combatMode ? (a.present ? int(g_tune.startAmmo + ammoBonus + a.mods.ammo) : 0) : -1;
} }
// Аффикс LIGHT — наоборот, ОБЩИЙ: фонарь у отряда один (sim/items.h),
// и «мой фонарь держит дольше» тут не бывает.
float lightBonus = 0.0f;
for (int i = 0; i < NUM_AGENTS; ++i)
if (squad.agents[i].present) lightBonus += squad.agents[i].mods.light;
// Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает // Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь // вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь
// смысл — на четвёртом этаже светить уже почти нечем. // смысл — на четвёртом этаже светить уже почти нечем.
// В песочнице приёмки света бесконечно (-1), как и патронов. // В песочнице приёмки света бесконечно (-1), как и патронов.
squad.lightCharge = combatMode ? LIGHT_FULL : -1.0f; squad.lightCharge = combatMode ? (LIGHT_FULL + lightBonus) : -1.0f;
} }
lanes.Clear(); lanes.Clear();
@ -544,14 +558,20 @@ void Game::Step(float dt)
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt); squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
// --- 2.5 свет ------------------------------------------------------------- // --- 2.5 свет -------------------------------------------------------------
// Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и меряется путь по центру отряда, а не // Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и путь меряется по центру отряда, а не
// по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в // по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в
// стену, и светил бы фонарь на месте. // стену, и светил бы фонарь на месте.
// //
// Топтание в строю сюда попадает тоже, но оно на порядок меньше похода // Считается ТОЛЬКО в ADVANCING, то есть когда игрок ведёт отряд. И это не
// через этаж и на решение не влияет. Считать «только осмысленное движение» // мелочь: в строю бойцы постоянно подтягиваются к слотам и расталкиваются,
// значило бы завести второе понятие пути ради полутора процентов. // и центр отряда за две секунды «стоянки» проходит около 0.8 тайла. Списывай
if (squad.lightCharge > 0.0f) // мы и это — заряд таял бы, пока игрок думает над картой, то есть вернулся
// бы ровно тот таймер, ради отсутствия которого расход и считается за путь
// (docs/17-extraction.md). Замер поймал это на трёх глубинах сразу.
//
// Перестановки в бою при этом бесплатны — и правильно: свет тратит РАЗВЕДКА,
// а не драка.
if (squad.lightCharge > 0.0f && squad.state == AgentState::ADVANCING)
{ {
const float walked = Distance(squad.Centroid(), lightFrom); const float walked = Distance(squad.Centroid(), lightFrom);
squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth)); squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth));
@ -889,7 +909,7 @@ void Game::FireWeapons(const SolverContext& ctx, float dt)
for (int p = 0; p < pellets; ++p) for (int p = 0; p < pellets; ++p)
{ {
dir = FromAngle(axis + g.ShotOffset(i, spread, p)); dir = FromAngle(axis + g.ShotOffset(i, spread, p));
SpawnBullet(world, g.def, muzzle, dir, travel, i); SpawnBullet(world, g.def, muzzle, dir, travel, i, a.pen);
} }
events.Push(CombatEventKind::SHOT, i, muzzle, dir); events.Push(CombatEventKind::SHOT, i, muzzle, dir);
@ -1031,7 +1051,7 @@ void Game::UpdateMelee(const SolverContext& ctx, float dt)
// Через ту же формулу, что и пуля: клинок отличается // Через ту же формулу, что и пуля: клинок отличается
// формой удара, а не правилами урона. // формой удара, а не правилами урона.
tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist, tg.hp -= ResolveDamage(m.curDamage, a.melee.elem, tg.resist,
ResistCutOf(tg.status)) * ResistCutOf(tg.status) + a.pen) *
VulnerabilityOf(tg.status); VulnerabilityOf(tg.status);
tg.hurtTimer = g_tune.targetHurtTime; tg.hurtTimer = g_tune.targetHurtTime;
KnockTarget(tg, dir, m.curKnock); KnockTarget(tg, dir, m.curKnock);

283
src/sim/affix.cpp Normal file
View File

@ -0,0 +1,283 @@
#include "sim/affix.h"
#include <cmath>
#include <cstdio>
namespace
{
// Свой генератор, а не <random>: броски обязаны быть одинаковыми на всех
// платформах и сборках. `std::uniform_real_distribution` этого не обещает, и
// «тот же сид — тот же уровень» на нём разъезжается между компиляторами.
uint32_t NextRand(uint32_t& s)
{
s = s * 1664525u + 1013904223u;
return s;
}
float Frand(uint32_t& s) { return float(NextRand(s) & 0xFFFFFF) / float(0x1000000); }
int RandRange(uint32_t& s, int lo, int hi) // [lo, hi]
{
if (hi <= lo) return lo;
return lo + int(NextRand(s) % uint32_t(hi - lo + 1));
}
// Теги, которые ложатся на оружие, броню и карман. Списком, потому что это
// каталог содержимого, а не правило: «что бывает на куртке» меняют, глядя на
// таблицу, а не выводя из вида предмета.
const AffixTag WEAPON_TAGS[] = {AffixTag::DAMAGE, AffixTag::PEN, AffixTag::CONVERT,
AffixTag::AMMO, AffixTag::SPEED};
const AffixTag ARMOR_TAGS[] = {AffixTag::ARMOR, AffixTag::HEALTH, AffixTag::SPEED,
AffixTag::CARRY, AffixTag::AMMO};
const AffixTag TRINKET_TAGS[] = {AffixTag::SIGHT, AffixTag::LIGHT, AffixTag::HEALTH,
AffixTag::SPEED, AffixTag::CARRY};
} // namespace
const char* AffixName(AffixTag t)
{
switch (t)
{
case AffixTag::DAMAGE: return "DAMAGE";
case AffixTag::PEN: return "PIERCE";
case AffixTag::CONVERT: return "CONVERT";
case AffixTag::ARMOR: return "ARMOR";
case AffixTag::HEALTH: return "HEALTH";
case AffixTag::SPEED: return "SPEED";
case AffixTag::SIGHT: return "SIGHT";
case AffixTag::AMMO: return "ROUNDS";
case AffixTag::CARRY: return "CARRY";
case AffixTag::LIGHT: return "LIGHT";
default: return "-";
}
}
void AffixLabel(const Affix& a, char* out, int cap)
{
if (!out || cap <= 0) return;
out[0] = '\0';
switch (a.tag)
{
// Проценты — там, где число и есть доля. Смешивать «+14%» и «+14» в
// одном списке нельзя: игрок читает подсказку целиком и сравнивает
// строки между собой.
case AffixTag::DAMAGE:
case AffixTag::PEN:
case AffixTag::ARMOR:
case AffixTag::SPEED:
case AffixTag::SIGHT:
std::snprintf(out, size_t(cap), "%s +%d%% T%d", AffixName(a.tag),
int(a.value * 100.0f + 0.5f), int(a.tier));
break;
case AffixTag::CONVERT:
std::snprintf(out, size_t(cap), "%d%% DAMAGE AS %s T%d",
int(a.value * 100.0f + 0.5f), ElementName(Element(a.param)),
int(a.tier));
break;
case AffixTag::CARRY:
std::snprintf(out, size_t(cap), "CARRY +%.1f T%d", double(a.value), int(a.tier));
break;
default:
std::snprintf(out, size_t(cap), "%s +%d T%d", AffixName(a.tag), int(a.value + 0.5f),
int(a.tier));
break;
}
}
bool AffixFitsKind(AffixTag t, ItemKind k)
{
auto has = [&](const AffixTag* list, int n) {
for (int i = 0; i < n; ++i)
if (list[i] == t) return true;
return false;
};
switch (k)
{
case ItemKind::FIREARM:
case ItemKind::BLADE: return has(WEAPON_TAGS, int(sizeof(WEAPON_TAGS) / sizeof(AffixTag)));
case ItemKind::ARMOR: return has(ARMOR_TAGS, int(sizeof(ARMOR_TAGS) / sizeof(AffixTag)));
case ItemKind::TRINKET: return has(TRINKET_TAGS, int(sizeof(TRINKET_TAGS) / sizeof(AffixTag)));
// Расходники не катаются. Бинт с аффиксами — это не решение, а шум:
// его применяют автоматически и выбрасывают.
default: return false;
}
}
void AffixMods::Add(const Affix& a)
{
switch (a.tag)
{
case AffixTag::DAMAGE: damageMul += a.value; break;
case AffixTag::PEN: pen += a.value; break;
case AffixTag::ARMOR: armor += a.value; break;
case AffixTag::HEALTH: hp += a.value; break;
case AffixTag::SPEED: speedMul += a.value; break;
case AffixTag::SIGHT: sightMul += a.value; break;
case AffixTag::AMMO: ammo += a.value; break;
case AffixTag::CARRY: carry += a.value; break;
case AffixTag::LIGHT: light += a.value; break;
case AffixTag::CONVERT:
{
// Конверсии складываются между собой, а кинетика — это остаток.
// Складывать смеси иначе (перемножением долей) значило бы, что два
// аффикса по 20% дают 36%, и игрок никогда не сойдётся с подсказкой.
const int e = int(a.param);
if (e <= 0 || e >= ELEMENT_COUNT) break;
convert.share[e] += a.value;
float sum = 0.0f;
for (int i = 1; i < ELEMENT_COUNT; ++i) sum += convert.share[i];
if (sum > 0.85f) // кинетика не исчезает никогда (sim/element.h)
{
const float k = 0.85f / sum;
for (int i = 1; i < ELEMENT_COUNT; ++i) convert.share[i] *= k;
sum = 0.85f;
}
convert.share[int(Element::KIN)] = 1.0f - sum;
break;
}
default: break;
}
}
void AffixMods::Add(const ItemInstance& it)
{
for (int i = 0; i < it.Count(); ++i) Add(it.affix[i]);
}
ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert)
{
// Доля, которую забрала конверсия, вычитается из КИНЕТИКИ оружия, а не из
// его собственной стихии: дробовик с жаром, на который выкатили холод,
// остаётся зажигательным — просто теперь ещё и холодным.
float taken = 0.0f;
for (int e = 1; e < ELEMENT_COUNT; ++e) taken += convert.share[e];
if (taken <= 0.0f) return base;
ElementMix out = base;
const float kin = base.share[int(Element::KIN)];
const float use = (taken < kin) ? taken : kin; // кинетики меньше, чем забирают
const float k = (taken > 0.0f) ? use / taken : 0.0f;
for (int e = 1; e < ELEMENT_COUNT; ++e) out.share[e] += convert.share[e] * k;
out.share[int(Element::KIN)] = kin - use;
return out;
}
Rarity RollRarity(int depth, uint32_t& rng)
{
// Шанс «не обычного» растёт с глубиной и упирается в 0.88. Потолок нужен:
// без него на дне обычных вещей не остаётся вовсе, и редкость перестаёт
// что-либо значить — сравнивать станет не с чем.
//
// Наклон 0.011 подобран замером (`--tool drops`), а не взят из документа:
// в docs/22-loot.md §1 формула и таблица противоречили друг другу. Формула
// (0.02 + 0.0075·d) давала на первом этаже 2.7% небанальных находок, а
// таблица рядом — 22%. Выбрана формула, потому что первый этаж обязан быть
// скучным по замыслу игры, а наклон поднят так, чтобы сойтись с таблицей на
// дне: 12% обычных на сотом этаже.
const float d = float(depth > 0 ? depth : 1);
const float pRare = std::min(0.88f, 0.02f + 0.011f * d);
if (Frand(rng) >= pRare) return Rarity::COMMON;
// Внутри «не обычного» — своя лестница, тоже по глубине: реликты внизу
// становятся обычным делом, наверху их нет вовсе.
const float r = Frand(rng);
const float pRelic = std::min(0.30f, 0.004f * d);
const float pProto = std::min(0.45f, 0.06f + 0.006f * d);
if (r < pRelic) return Rarity::RELIC;
if (r < pRelic + pProto) return Rarity::PROTOTYPE;
return Rarity::MARKED;
}
int AffixCountFor(Rarity r, uint32_t& rng)
{
switch (r)
{
case Rarity::MARKED: return RandRange(rng, 1, 2);
case Rarity::PROTOTYPE: return RandRange(rng, 3, 4);
case Rarity::RELIC: return RandRange(rng, 5, 6);
default: return 0;
}
}
ItemInstance RollItem(ItemId base, int depth, uint32_t& rng)
{
ItemInstance it(base);
if (base == ItemId::NONE) return it;
const ItemDef def = ItemDefOf(base);
it.rarity = AffixFitsKind(AffixTag::DAMAGE, def.kind) ||
AffixFitsKind(AffixTag::ARMOR, def.kind) ||
AffixFitsKind(AffixTag::SIGHT, def.kind)
? RollRarity(depth, rng)
: Rarity::COMMON;
int want = AffixCountFor(it.rarity, rng);
if (want <= 0) return it;
// Уровень предмета — это глубина, на которой он найден. Отсюда и бюджет:
// одна и та же куртка внизу держит больше аффиксов, чем наверху.
float budget = ItemBudget(depth);
const int tierCap = MaxAffixTier(depth);
// Список тегов, доступных этому виду. Порядок перемешивается броском, а не
// берётся как есть: иначе первый тег в таблице оказывался бы на каждом
// предмете в игре.
AffixTag pool[int(AffixTag::COUNT)];
int poolCount = 0;
for (int t = 1; t < int(AffixTag::COUNT); ++t)
if (AffixFitsKind(AffixTag(t), def.kind)) pool[poolCount++] = AffixTag(t);
if (poolCount == 0) return it;
for (int i = poolCount - 1; i > 0; --i)
{
const int j = RandRange(rng, 0, i);
const AffixTag tmp = pool[i];
pool[i] = pool[j];
pool[j] = tmp;
}
if (want > poolCount) want = poolCount; // один тег один раз (правило 2)
if (want > MAX_AFFIXES) want = MAX_AFFIXES;
for (int i = 0; i < want; ++i)
{
// Тир — случайный до потолка глубины, но не дороже остатка бюджета.
// Именно так бюджет и ограничивает: не «нельзя шесть высших», а
// «на шестой уже не хватило».
int tier = RandRange(rng, 1, tierCap);
while (tier > 1 && AffixCost(tier) > budget) --tier;
if (AffixCost(tier) > budget) break;
budget -= AffixCost(tier);
const AffixRange base_ = AffixBase(pool[i]);
const float scale = std::pow(float(tier), 1.35f);
Affix a;
a.tag = pool[i];
a.tier = uint8_t(tier);
a.value = (base_.lo + (base_.hi - base_.lo) * Frand(rng)) * scale;
// Стихия конверсии — любая, кроме кинетики: «перевести урон в
// кинетику» не значит ничего.
if (a.tag == AffixTag::CONVERT) a.param = uint8_t(RandRange(rng, 1, ELEMENT_COUNT - 1));
it.affix[it.count++] = a;
}
// Аффиксов могло влезть меньше, чем обещала редкость. Понижаем её до
// фактического содержимого: «реликт» с двумя аффиксами — это обман,
// причём тот самый, который игрок замечает первым.
if (it.count <= 0) it.rarity = Rarity::COMMON;
else if (it.count <= 2) it.rarity = Rarity::MARKED;
else if (it.count <= 4) it.rarity = Rarity::PROTOTYPE;
else it.rarity = Rarity::RELIC;
return it;
}

160
src/sim/affix.h Normal file
View File

@ -0,0 +1,160 @@
#pragma once
// -----------------------------------------------------------------------------
// Редкости и аффиксы: то, что превращает находку в РЕШЕНИЕ.
//
// До этой вехи предмет был строкой каталога: «карабин» на первом этаже и
// «карабин» на сотом — одна и та же вещь. Идти за ней в темноту было незачем,
// а весь спуск держался на количестве находок, а не на их качестве.
//
// Аффикс = тег + тир + рулон (docs/22-loot.md §3). Тег говорит, ЧТО он даёт;
// тир — насколько сильным он бывает на этой глубине; рулон — конкретное число.
//
// ТРИ ПРАВИЛА, КОТОРЫЕ ЗДЕСЬ ЗАЩИЩАЮТСЯ АРИФМЕТИКОЙ, А НЕ СПИСКАМИ ЗАПРЕТОВ:
//
// 1. БЮДЖЕТ. У предмета есть бюджет по уровню, аффикс стоит по тиру. Шесть
// высших аффиксов не влезают никуда, кроме предмета сотого уровня, — и
// это единственный барьер, который не приходится подпирать частными
// исключениями. Список исключений разрастается и однажды противоречит
// сам себе.
// 2. ОДИН ТЕГ ОДИН РАЗ. Иначе «+урон» шесть раз становится единственной
// правильной сборкой, а весь остальной список — мусором.
// 3. КИНЕТИКА НЕ ИСЧЕЗАЕТ. Конверсий не больше 0.85 в сумме: оружие без
// физического урона перестало бы работать по целям без сопротивлений
// вовсе (sim/element.h).
//
// Аффиксы НЕ заводят новых каналов влияния. Каждый тег кладётся в то, что уже
// есть: урон оружия, сопротивления, стихии, статы бойца, ресурсы вылазки. Это
// не экономия, а то же правило, что и у снаряжения: у предмета нет ручки к
// правилу «не бить своих», и появиться она не может (sim/items.h).
// -----------------------------------------------------------------------------
#include <cstdint>
#include "sim/element.h"
#include "sim/items.h"
// Сами типы (Rarity, AffixTag, Affix, ItemInstance) объявлены в sim/items.h:
// экземпляр предмета — это то, что боец несёт, и место ему там. Здесь — вся
// арифметика вокруг них.
inline const char* RarityName(Rarity r)
{
switch (r)
{
case Rarity::MARKED: return "MARKED";
case Rarity::PROTOTYPE: return "PROTOTYPE";
case Rarity::RELIC: return "RELIC";
default: return "COMMON";
}
}
// Цвет рамки и подписи. Байты RGB: в sim/ типов рендера нет.
struct RarityColor { uint8_t r, g, b; };
inline RarityColor RarityColorOf(Rarity r)
{
switch (r)
{
case Rarity::MARKED: return {120, 190, 235};
case Rarity::PROTOTYPE: return {170, 140, 240};
case Rarity::RELIC: return {235, 165, 70};
default: return {190, 190, 190};
}
}
// Доступный тир на глубине и стоимость аффикса в бюджете предмета.
constexpr int MAX_AFFIX_TIER = 9;
inline int MaxAffixTier(int depth)
{
const int t = 1 + (depth > 0 ? depth : 1) / 12;
return (t < MAX_AFFIX_TIER) ? t : MAX_AFFIX_TIER;
}
// Цена тира и бюджет предмета. Числа НЕ те, что стояли в docs/22-loot.md §3:
// там было cost = 6 + 4·T при бюджете 40 + 3·ilvl, и утверждалось, что на
// предмете сотого уровня это даёт «шесть аффиксов девятого тира и ни одним
// больше». На деле давало восемь (340 / 42 = 8.1), а предмет-максимум
// собирался уже к семидесятому этажу.
//
// Пересчитано так, чтобы обещание выполнялось арифметически:
//
// 6 · cost(9) = 294 <= B(100) = 295 < 7 · cost(9) = 343
//
// То есть предельная вещь существует ровно на дне и ровно одна. Проверяется
// инструментом `--tool affix`, а не глазами.
inline float AffixCost(int tier) { return 4.0f + 5.0f * float(tier); }
inline float ItemBudget(int ilvl) { return 45.0f + 2.5f * float(ilvl); }
// Диапазон рулона тега на первом тире. Дальше растёт как T^1.35 — так высокий
// тир заметно лучше низкого, но не превращает предмет в другую игру.
struct AffixRange { float lo, hi; };
inline AffixRange AffixBase(AffixTag t)
{
switch (t)
{
case AffixTag::DAMAGE: return {0.05f, 0.09f};
case AffixTag::PEN: return {0.03f, 0.06f};
case AffixTag::CONVERT: return {0.08f, 0.14f};
case AffixTag::ARMOR: return {0.02f, 0.04f};
case AffixTag::HEALTH: return {4.0f, 8.0f};
case AffixTag::SPEED: return {0.02f, 0.035f};
case AffixTag::SIGHT: return {0.03f, 0.05f};
case AffixTag::AMMO: return {8.0f, 14.0f};
case AffixTag::CARRY: return {0.30f, 0.55f};
case AffixTag::LIGHT: return {18.0f, 30.0f};
default: return {0.0f, 0.0f};
}
}
const char* AffixName(AffixTag t);
// Строка для подсказки: «DAMAGE +14%». Пишет в буфер, потому что подсказки
// собираются на стеке и аллокаций в UI за кадр быть не должно.
void AffixLabel(const Affix& a, char* out, int cap);
// Какие теги вообще ложатся на этот вид предмета. Каталог, а не «на что
// похоже»: конверсия на куртке — это урон, которого у куртки нет.
bool AffixFitsKind(AffixTag t, ItemKind k);
// --- накопленные модификаторы -------------------------------------------------
// Сумма аффиксов надетого. Считается в SyncLoadout один раз за шаг, дальше все
// читают готовые числа: лазать по аффиксам из правил боя нельзя, там про
// предметы не знают вообще.
struct AffixMods
{
float damageMul = 1.0f; // множитель урона оружия
float pen = 0.0f; // пробитие сопротивлений
ElementMix convert; // во что переведена часть урона
float armor = 0.0f;
float hp = 0.0f;
float speedMul = 1.0f;
float sightMul = 1.0f;
float ammo = 0.0f;
float carry = 0.0f;
float light = 0.0f;
void Add(const Affix& a);
void Add(const ItemInstance& it);
};
// Смешать собственную стихию оружия с конверсией аффиксов.
//
// Конверсия ЗАМЕНЯЕТ часть смеси, а не добавляется поверх: у оружия смесь одна
// и сумма долей всегда единица (sim/element.h). Иначе «дробовик с жаром плюс
// 20% холода» давал бы 120% урона, и стихии стали бы каналом наращивания
// чисел — ровно тем, чем они не должны быть.
ElementMix MergeConversion(const ElementMix& base, const ElementMix& convert);
// --- бросок предмета ----------------------------------------------------------
// Сид передаётся снаружи: генерация уровня детерминирована по сиду, и находки
// обязаны быть частью этой же детерминированности. Иначе «тот же уровень» — это
// уже не тот же уровень, и сравнить два прогона нельзя.
ItemInstance RollItem(ItemId base, int depth, uint32_t& rng);
// Редкость по глубине. Отдельно от RollItem, потому что её проверяют
// инструментом на десятках тысяч бросков (`--tool drops`).
Rarity RollRarity(int depth, uint32_t& rng);
// Сколько аффиксов у этой редкости (нижняя и верхняя граница).
int AffixCountFor(Rarity r, uint32_t& rng);

View File

@ -1,5 +1,7 @@
#include "sim/agent.h" #include "sim/agent.h"
#include "sim/affix.h"
Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed) Vec2 SeekForce(const Agent& a, Vec2 goal, float maxSpeed)
{ {
const Vec2 delta = goal - a.pos; const Vec2 delta = goal - a.pos;
@ -210,20 +212,35 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// Снаряжение: четыре числа и ни одной ручки к правилам боя (sim/items.h). // Снаряжение: четыре числа и ни одной ручки к правилам боя (sim/items.h).
const Inventory* inv = world.Get<Inventory>(a.self); const Inventory* inv = world.Get<Inventory>(a.self);
// Аффиксы надетого складываются ОДИН РАЗ здесь, дальше все читают готовые
// числа. Лазать по рулонам из правил боя нельзя: там про предметы не знают.
a.mods = AffixMods{};
if (inv) if (inv)
{ {
a.armor = inv->Armor(); a.mods.Add(inv->weapon);
a.mods.Add(inv->armor);
a.mods.Add(inv->trinket);
}
if (inv)
{
// Аффиксы кладутся в ТЕ ЖЕ четыре числа, что и снаряжение: у предмета
// не появляется нового канала влияния оттого, что на нём есть рулоны
// (sim/items.h).
a.armor = std::min(ARMOR_CAP, inv->Armor() + a.mods.armor);
// Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе // Снаряжение и НАГРУЗКА — разные вещи, но обе живут в одном множителе
// скорости: движению всё равно, почему боец медленный. Разделять их // скорости: движению всё равно, почему боец медленный. Разделять их
// пришлось бы везде, где скорость читается, а смысла в этом ноль. // пришлось бы везде, где скорость читается, а смысла в этом ноль.
a.load = inv->Load() + bagShare; a.load = inv->Load() + bagShare;
a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry); a.speedMul = inv->SpeedMul() * a.mods.speedMul *
CarryMul(a.load, buffs.carry + a.mods.carry);
// Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и // Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и
// купленное. Улучшение «обзор» без света не спасает: сел фонарь — // купленное, и выкатанное. Улучшение «обзор» без света не спасает: сел
// светить нечем, сколько бы отряд ни вложил в глаза. // фонарь — светить нечем, сколько бы отряд ни вложил в глаза.
a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight) * a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight + a.mods.sightMul - 1.0f) *
(buffs.lightOut ? DARK_SIGHT_MUL : 1.0f); (buffs.lightOut ? DARK_SIGHT_MUL : 1.0f);
a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp; a.maxHp = g_tune.agentHP + inv->HpBonus() + buffs.hp + a.mods.hp;
a.held = inv->weapon; a.held = inv->weapon;
if (a.hp > a.maxHp) a.hp = a.maxHp; if (a.hp > a.maxHp) a.hp = a.maxHp;
} }
@ -256,4 +273,22 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
a.melee.damage = item->blade.damage; a.melee.damage = item->blade.damage;
a.melee.elem = item->blade.elem; a.melee.elem = item->blade.elem;
a.melee.knockback = item->blade.knockback; a.melee.knockback = item->blade.knockback;
// --- аффиксы оружия -------------------------------------------------------
// Ложатся ПОВЕРХ статов, скопированных с сущности-оружия, и только здесь:
// правила стрельбы и клинка про предметы не знают вообще и читают уже
// готовые числа (sim/affix.h).
//
// Урон множится, пробитие складывается, конверсия ЗАМЕНЯЕТ смесь стихий,
// а не добавляется к ней: у оружия смесь одна, и «дробовик с жаром плюс
// 20% холода» — это дробовик, у которого часть жара стала холодом.
a.gun.def.damage *= a.mods.damageMul;
a.melee.damage *= a.mods.damageMul;
a.pen = a.mods.pen;
if (a.mods.convert.share[int(Element::KIN)] < 0.999f)
{
a.gun.def.elem = MergeConversion(a.gun.def.elem, a.mods.convert);
a.melee.elem = MergeConversion(a.melee.elem, a.mods.convert);
}
} }

View File

@ -13,6 +13,7 @@
#include "engine/math.h" #include "engine/math.h"
#include "ecs/ecs.h" #include "ecs/ecs.h"
#include "sim/affix.h"
#include "sim/items.h" #include "sim/items.h"
#include "sim/loadout.h" #include "sim/loadout.h"
#include "sim/target.h" #include "sim/target.h"
@ -101,6 +102,12 @@ struct Agent
float load = 0.0f; float load = 0.0f;
float sightMul = 1.0f; float sightMul = 1.0f;
// Сумма аффиксов надетого и пробитие сопротивлений, которое из неё вышло.
// Считаются в SyncLoadout один раз за шаг (sim/affix.h); правила боя читают
// ТОЛЬКО pen, а остальное уже размазано по статам выше.
AffixMods mods;
float pen = 0.0f;
// --- служебное (рендер/отладка/метрики), на правила не влияет --- // --- служебное (рендер/отладка/метрики), на правила не влияет ---
Vec2 prevPos{}; // для интерполяции кадра Vec2 prevPos{}; // для интерполяции кадра
bool hasLane = false; // есть валидная линия огня прямо сейчас bool hasLane = false; // есть валидная линия огня прямо сейчас

View File

@ -5,7 +5,7 @@
#include "debug/metrics.h" #include "debug/metrics.h"
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
int owner) int owner, float pen)
{ {
std::vector<Bullet>& bullets = world.All<Bullet>(); std::vector<Bullet>& bullets = world.All<Bullet>();
if (int(bullets.size()) >= MAX_BULLETS) return; if (int(bullets.size()) >= MAX_BULLETS) return;
@ -17,6 +17,7 @@ void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir,
b.origin = from; b.origin = from;
b.vel = Normalized(dir) * def.bulletSpeed; b.vel = Normalized(dir) * def.bulletSpeed;
b.damage = def.damage; b.damage = def.damage;
b.pen = pen;
b.elem = def.elem; b.elem = def.elem;
b.knockback = def.knockback; b.knockback = def.knockback;
b.radius = def.bulletRadius; b.radius = def.bulletRadius;
@ -155,7 +156,8 @@ void UpdateBullets(ecs::World& world, const Tilemap& map,
// (sim/element.h). Пустота на цели срезает её сопротивления, а // (sim/element.h). Пустота на цели срезает её сопротивления, а
// кислота и дуга увеличивают получаемый урон. // кислота и дуга увеличивают получаемый урон.
const float cut = ResistCutOf(t.status); const float cut = ResistCutOf(t.status);
t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut) * VulnerabilityOf(t.status); t.hp -= ResolveDamage(b.damage, b.elem, t.resist, cut + b.pen) *
VulnerabilityOf(t.status);
t.hurtTimer = g_tune.targetHurtTime; t.hurtTimer = g_tune.targetHurtTime;
KnockTarget(t, dir, b.knockback); KnockTarget(t, dir, b.knockback);
// Стихии удара ложатся статусами и могут сложиться в реакцию. // Стихии удара ложатся статусами и могут сложиться в реакцию.

View File

@ -28,6 +28,10 @@ struct Bullet
Vec2 vel{}; Vec2 vel{};
Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают Vec2 origin{}; // откуда вылетела — только для эффектов, правила не читают
float damage = 0.0f; float damage = 0.0f;
// Пробитие сопротивлений от аффиксов стрелка. Летит ВМЕСТЕ С ПУЛЕЙ, а не
// читается из бойца при попадании: стрелок к тому моменту мог сменить
// оружие, погибнуть или уйти — а выстрел уже сделан тем, что было.
float pen = 0.0f;
// Из чего состоит удар. Пуля несёт стихию с собой: к моменту попадания // Из чего состоит удар. Пуля несёт стихию с собой: к моменту попадания
// стрелок мог сменить оружие, а урон обязан быть тем, каким его выпустили. // стрелок мог сменить оружие, а урон обязан быть тем, каким его выпустили.
ElementMix elem; ElementMix elem;
@ -53,8 +57,9 @@ struct Bullet
// //
// dir — уже с учётом разброса. Конус разброса проверен на своих ДО спуска // dir — уже с учётом разброса. Конус разброса проверен на своих ДО спуска
// (см. ai/firing_solver.cpp), поэтому отклонившаяся пуля своего не найдёт. // (см. ai/firing_solver.cpp), поэтому отклонившаяся пуля своего не найдёт.
// pen — пробитие сопротивлений от аффиксов стрелка (sim/affix.h).
void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel, void SpawnBullet(ecs::World& world, const FirearmDef& def, Vec2 from, Vec2 dir, float travel,
int owner); int owner, float pen = 0.0f);
// Плевок твари. Летит до своей дальности и бьёт первого встречного бойца. // Плевок твари. Летит до своей дальности и бьёт первого встречного бойца.
// Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих. // Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих.

View File

@ -98,6 +98,75 @@ struct ItemDef
// Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет. // Броня складывается, но потолок жёсткий: полностью неуязвимых в отряде нет.
constexpr float ARMOR_CAP = 0.72f; constexpr float ARMOR_CAP = 0.72f;
// -----------------------------------------------------------------------------
// Редкость, аффиксы и ЭКЗЕМПЛЯР предмета.
//
// Типы стоят здесь, а не в sim/affix.h, по простой причине: экземпляр предмета —
// это то, что боец несёт, то есть предмет этого файла. Вся арифметика аффиксов
// (броски, бюджет, накопление) живёт в sim/affix.h и включает этот файл, а не
// наоборот — иначе получилось бы кольцо.
// -----------------------------------------------------------------------------
enum class Rarity : uint8_t
{
COMMON = 0, // без аффиксов
MARKED, // 1-2
PROTOTYPE, // 3-4
RELIC, // 5-6
COUNT,
};
enum class AffixTag : uint8_t
{
NONE = 0,
DAMAGE, // +% урона оружия
PEN, // пробитие сопротивлений цели
CONVERT, // доля урона переводится в стихию (param = стихия)
ARMOR, // +броня
HEALTH, // +запас здоровья
SPEED, // +% скорости хода
SIGHT, // +% обзора
AMMO, // +патроны на вылазку
CARRY, // +норма переноски
LIGHT, // +заряд фонаря
COUNT,
};
struct Affix
{
AffixTag tag = AffixTag::NONE;
uint8_t tier = 1;
uint8_t param = 0; // для CONVERT — индекс стихии
float value = 0.0f;
};
// Шесть — потолок из бюджета: больше не влезает даже на сотом уровне.
constexpr int MAX_AFFIXES = 6;
// Экземпляр предмета: то, что лежит на полу, в сумке и на бойце.
//
// ItemId остался ВИДОМ вещи, а не самой вещью. Конверсия в обе стороны сделана
// неявной намеренно: полторы сотни мест сравнивают предмет с ItemId::NONE,
// читают ItemDefOf и рисуют иконку — им до аффиксов дела нет, и переписывать их
// все значило бы менять полкода ради поля, которое они не читают.
//
// Цена решения: присваивание в ItemId молча теряет аффиксы. Мест, где это
// важно (подбор, перекладывание, запись профиля), немного, и все они работают
// с ItemInstance целиком.
struct ItemInstance
{
ItemId id = ItemId::NONE;
Rarity rarity = Rarity::COMMON;
uint8_t count = 0;
Affix affix[MAX_AFFIXES];
ItemInstance() = default;
ItemInstance(ItemId base) : id(base) {} // NOLINT: конверсия намеренная
operator ItemId() const { return id; }
bool Empty() const { return id == ItemId::NONE; }
int Count() const { return int(count); }
};
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Переноска. Порогов ДВА, и это главное решение всей вехи. // Переноска. Порогов ДВА, и это главное решение всей вехи.
// //
@ -326,7 +395,11 @@ constexpr int SQUAD_BAG_SLOTS = 16;
struct SquadBag struct SquadBag
{ {
ItemId slots[SQUAD_BAG_SLOTS] = {}; // Ячейки хранят ЭКЗЕМПЛЯРЫ, а не виды: две одинаковые куртки с разными
// аффиксами — разные вещи, и складывать их в одну ячейку нельзя
// (sim/affix.h). Неявная конверсия в ItemId оставляет весь прежний код
// работать: ему нужен вид, а не рулоны.
ItemInstance slots[SQUAD_BAG_SLOTS];
float Load() const float Load() const
{ {
@ -335,6 +408,17 @@ struct SquadBag
return w; return w;
} }
// Положить ЭКЗЕМПЛЯР. Отдельно от Take(ItemId): через ItemId вещь теряет
// аффиксы, и подбор с пола обязан идти этим путём, а не тем.
bool TakeItem(const ItemInstance& it, int carriers, float weaponLoad, float bonus = 0.0f)
{
if (!CanTake(it.id, carriers, weaponLoad, bonus)) return false;
const int slot = FreeSlot();
if (slot < 0) return false;
slots[slot] = it;
return true;
}
int Count() const int Count() const
{ {
int n = 0; int n = 0;
@ -381,16 +465,16 @@ struct SquadBag
void Clear() void Clear()
{ {
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemId::NONE; for (int i = 0; i < SQUAD_BAG_SLOTS; ++i) slots[i] = ItemInstance{};
} }
}; };
// Компонент ECS на сущности бойца: только НАДЕТОЕ. // Компонент ECS на сущности бойца: только НАДЕТОЕ.
struct Inventory struct Inventory
{ {
ItemId weapon = ItemId::RIFLE; // в руках ItemInstance weapon = ItemId::RIFLE; // в руках
ItemId armor = ItemId::NONE; ItemInstance armor;
ItemId trinket = ItemId::NONE; ItemInstance trinket;
// Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак // Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак
// весит, нож — нет. // весит, нож — нет.

View File

@ -2,7 +2,7 @@
#include "sim/agent.h" #include "sim/agent.h"
void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay) void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos, float pickupDelay)
{ {
if (id == ItemId::NONE) return; if (id == ItemId::NONE) return;
@ -46,7 +46,10 @@ int UpdateLootPickup(ecs::World& world, Agent* agents, int agentCount, SquadBag&
// обязаны быть видны как вещь под ногами, а не как пропажа. // обязаны быть видны как вещь под ногами, а не как пропажа.
const Inventory* inv = world.Get<Inventory>(a.self); const Inventory* inv = world.Get<Inventory>(a.self);
const float weaponLoad = inv ? inv->Load() : 0.0f; const float weaponLoad = inv ? inv->Load() : 0.0f;
if (!bag.Take(it.id, carriers, weaponLoad, carryBonus)) continue; // TakeItem, а не Take: через ItemId в сумку легла бы вещь без
// аффиксов, и найденный реликт превращался бы в обычный при
// подъёме с пола — молча, потому что вид у него тот же.
if (!bag.TakeItem(it.id, carriers, weaponLoad, carryBonus)) continue;
it.taken = true; it.taken = true;
++taken; ++taken;

View File

@ -27,7 +27,9 @@ struct Agent;
struct FloorItem struct FloorItem
{ {
ecs::Entity self = ecs::NO_ENTITY; ecs::Entity self = ecs::NO_ENTITY;
ItemId id = ItemId::NONE; // Лежит на полу ЭКЗЕМПЛЯР со своими аффиксами: он выкатан при сборке
// этажа и не должен добрасываться заново при подборе (sim/affix.h).
ItemInstance id;
Vec2 pos{}; Vec2 pos{};
// Предмет не удаляется в момент подбора: пул перетасовывается swap-remove, // Предмет не удаляется в момент подбора: пул перетасовывается swap-remove,
@ -42,7 +44,8 @@ struct FloorItem
}; };
// pickupDelay > 0 — предмет полежит, прежде чем его снова можно подобрать. // pickupDelay > 0 — предмет полежит, прежде чем его снова можно подобрать.
void SpawnFloorItem(ecs::World& world, ItemId id, Vec2 pos, float pickupDelay = 0.0f); void SpawnFloorItem(ecs::World& world, const ItemInstance& id, Vec2 pos,
float pickupDelay = 0.0f);
// Подбор за шаг. Возвращает, сколько предметов поднято. // Подбор за шаг. Возвращает, сколько предметов поднято.
// //

View File

@ -4,6 +4,9 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <cstdio>
#include <string>
#include "engine/text_parse.h" #include "engine/text_parse.h"
#include "sim/spawn_catalog.h" #include "sim/spawn_catalog.h"
@ -28,14 +31,94 @@ const StarterKit STARTER[NUM_AGENTS] = {
{ItemId::PISTOL, ItemId::SHROUD, ItemId::NONE}, {ItemId::PISTOL, ItemId::SHROUD, ItemId::NONE},
}; };
ItemId ItemFromToken(const std::string& token) // -----------------------------------------------------------------------------
// Экземпляр предмета в строке профиля.
//
// Формат: `rifle` или `rifle@relic+dmg2:0.18+cnv3:0.22`
//
// вид @ редкость + тег тир : значение + ...
//
// Разделители выбраны так, чтобы токен не содержал ПРОБЕЛОВ: предметы лежат в
// строках `agent` и `stash` через пробел, и любой пробел внутри токена разъехал
// бы разбор. Читаемость при этом сохранена — профиль правят руками при отладке,
// и «rifle@relic+dmg2» глазами разбирается, а «rifle|3|1,2,0,0.18» уже нет.
// -----------------------------------------------------------------------------
const char* AFFIX_TOKEN[] = {"-", "dmg", "pen", "cnv", "arm", "hp", "spd", "sgt", "amo", "car", "lgt"};
static_assert(sizeof(AFFIX_TOKEN) / sizeof(AFFIX_TOKEN[0]) == int(AffixTag::COUNT),
"у каждого тега аффикса обязан быть свой токен в профиле");
const char* RARITY_TOKEN[] = {"common", "marked", "proto", "relic"};
static_assert(sizeof(RARITY_TOKEN) / sizeof(RARITY_TOKEN[0]) == int(Rarity::COUNT),
"у каждой редкости обязан быть свой токен в профиле");
std::string ItemToToken(const ItemInstance& it)
{ {
if (token == "-") return ItemId::NONE; std::string out = IdOfItem(it.id);
ItemId id = ItemId::NONE; if (it.id == ItemId::NONE || it.Count() <= 0) return out;
out += "@";
out += RARITY_TOKEN[int(it.rarity)];
for (int i = 0; i < it.Count(); ++i)
{
const Affix& a = it.affix[i];
char buf[48];
std::snprintf(buf, sizeof(buf), "+%s%d,%d:%.4f", AFFIX_TOKEN[int(a.tag)], int(a.tier),
int(a.param), double(a.value));
out += buf;
}
return out;
}
ItemInstance ItemFromToken(const std::string& token)
{
ItemInstance out;
if (token == "-" || token.empty()) return out;
const size_t at = token.find('@');
const std::string base = (at == std::string::npos) ? token : token.substr(0, at);
// Неизвестное имя — пустая ячейка, а не отказ читать профиль: предмет мог // Неизвестное имя — пустая ячейка, а не отказ читать профиль: предмет мог
// исчезнуть из игры между версиями, и терять из-за этого весь схрон нельзя. // исчезнуть из игры между версиями, и терять из-за этого весь схрон нельзя.
ItemIdOfId(token, id); ItemId id = ItemId::NONE;
return id; ItemIdOfId(base, id);
out.id = id;
if (at == std::string::npos || id == ItemId::NONE) return out;
// Хвост: редкость и аффиксы. Разбирается терпимо — мусорный аффикс
// пропускается, а не роняет чтение вместе со всем схроном.
size_t pos = at + 1;
const size_t firstPlus = token.find('+', pos);
const std::string rar = token.substr(pos, (firstPlus == std::string::npos)
? std::string::npos
: firstPlus - pos);
for (int r = 0; r < int(Rarity::COUNT); ++r)
if (rar == RARITY_TOKEN[r]) out.rarity = Rarity(r);
pos = firstPlus;
while (pos != std::string::npos && out.count < MAX_AFFIXES)
{
const size_t next = token.find('+', pos + 1);
const std::string part = token.substr(pos + 1, (next == std::string::npos)
? std::string::npos
: next - pos - 1);
pos = next;
const size_t comma = part.find(',');
const size_t colon = part.find(':');
if (comma == std::string::npos || colon == std::string::npos || comma > colon) continue;
const std::string tag = part.substr(0, 3);
Affix a;
for (int t = 1; t < int(AffixTag::COUNT); ++t)
if (tag == AFFIX_TOKEN[t]) a.tag = AffixTag(t);
if (a.tag == AffixTag::NONE) continue;
a.tier = uint8_t(std::max(1, text::ToInt(part.substr(3, comma - 3), 1)));
a.param = uint8_t(std::max(0, text::ToInt(part.substr(comma + 1, colon - comma - 1), 0)));
a.value = text::ToFloat(part.substr(colon + 1), 0.0f);
out.affix[out.count++] = a;
}
return out;
} }
} // namespace } // namespace
@ -48,9 +131,9 @@ int Profile::CheckpointCount() const
int Profile::DeepestCheckpoint() const { return CheckpointDepth(CheckpointCount() - 1); } int Profile::DeepestCheckpoint() const { return CheckpointDepth(CheckpointCount() - 1); }
bool Profile::StashPush(ItemId id) bool Profile::StashPush(const ItemInstance& id)
{ {
if (id == ItemId::NONE) return true; // «положить ничего» — не ошибка if (id.id == ItemId::NONE) return true; // «положить ничего» — не ошибка
if (int(stash.size()) >= STASH_CAP) return false; if (int(stash.size()) >= STASH_CAP) return false;
stash.push_back(id); stash.push_back(id);
return true; return true;
@ -182,7 +265,7 @@ bool Profile::Load(const std::string& path, std::string* err)
// Боец без оружия не может ни стрелять, ни рубить, и починить это // Боец без оружия не может ни стрелять, ни рубить, и починить это
// в игре нечем: пустые руки не меняются на что-то из сумки, если // в игре нечем: пустые руки не меняются на что-то из сумки, если
// сумка тоже пуста. // сумка тоже пуста.
if (inv.weapon == ItemId::NONE) inv.weapon = STARTER[slot].weapon; if (inv.weapon.Empty()) inv.weapon = STARTER[slot].weapon;
p.loadout[slot] = inv; p.loadout[slot] = inv;
continue; continue;
} }
@ -202,6 +285,9 @@ bool Profile::Load(const std::string& path, std::string* err)
// четверых у того, кто их не терял. // четверых у того, кто их не терял.
if (p.version < 3) p.squadSize = NUM_AGENTS; if (p.version < 3) p.squadSize = NUM_AGENTS;
// Формат 4 (аффиксы) миграции не требует: у старых токенов нет хвоста
// после '@', и они читаются как обычные вещи — чем и являются.
// Прочитанный файл мог быть старее — записан он будет уже текущей версией. // Прочитанный файл мог быть старее — записан он будет уже текущей версией.
p.version = PROFILE_VERSION; p.version = PROFILE_VERSION;
*this = std::move(p); *this = std::move(p);
@ -246,8 +332,8 @@ bool Profile::Save(const std::string& path, std::string* err) const
for (int i = 0; i < NUM_AGENTS; ++i) for (int i = 0; i < NUM_AGENTS; ++i)
{ {
const Inventory& inv = loadout[i]; const Inventory& inv = loadout[i];
f << "agent " << i << " " << IdOfItem(inv.weapon) << " " << IdOfItem(inv.armor) << " " f << "agent " << i << " " << ItemToToken(inv.weapon) << " " << ItemToToken(inv.armor)
<< IdOfItem(inv.trinket); << " " << ItemToToken(inv.trinket);
f << "\n"; f << "\n";
} }
@ -256,7 +342,7 @@ bool Profile::Save(const std::string& path, std::string* err) const
for (size_t i = 0; i < stash.size(); i += 8) for (size_t i = 0; i < stash.size(); i += 8)
{ {
f << "stash"; f << "stash";
for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << IdOfItem(stash[k]); for (size_t k = i; k < stash.size() && k < i + 8; ++k) f << " " << ItemToToken(stash[k]);
f << "\n"; f << "\n";
} }

View File

@ -117,13 +117,14 @@ const char* UpgradeEffect(Upgrade u);
// 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent // 1 — до общей сумки: у каждого бойца свои шесть ячеек в строке agent
// 2 — общая сумка, улучшения отряда // 2 — общая сумка, улучшения отряда
// 3 — размер отряда (найм): раньше бойцов всегда было пять // 3 — размер отряда (найм): раньше бойцов всегда было пять
// 4 — экземпляры предметов: редкость и аффиксы в токене после '@'
// //
// Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не // Правило чтения: файл СТАРШЕ игры читается с миграцией, файл НОВЕЕ игры не
// читается вовсе. Второе важнее первого: молча обнулить профиль, который // читается вовсе. Второе важнее первого: молча обнулить профиль, который
// записала более новая сборка, — это стереть десятки часов, а отказ читать // записала более новая сборка, — это стереть десятки часов, а отказ читать
// всего лишь просит не запускать старую сборку. // всего лишь просит не запускать старую сборку.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
constexpr int PROFILE_VERSION = 3; constexpr int PROFILE_VERSION = 4;
struct Profile struct Profile
{ {
@ -169,8 +170,10 @@ struct Profile
// на все слоты: незанятые просто пустые. // на все слоты: незанятые просто пустые.
Inventory loadout[NUM_AGENTS]; Inventory loadout[NUM_AGENTS];
// Общий запас сверх надетого. // Общий запас сверх надетого. Экземпляры, а не виды: схрон — это то, из
std::vector<ItemId> stash; // чего собирают следующую вылазку, и вещь без аффиксов в нём была бы уже
// другой вещью (sim/affix.h).
std::vector<ItemInstance> stash;
// --- прогресс ------------------------------------------------------------- // --- прогресс -------------------------------------------------------------
int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж int maxDepth = 1; // самый глубокий ДОСТИГНУТЫЙ этаж
@ -188,7 +191,7 @@ struct Profile
int CheckpointCount() const; int CheckpointCount() const;
// --- схрон ---------------------------------------------------------------- // --- схрон ----------------------------------------------------------------
bool StashPush(ItemId id); // false — схрон полон bool StashPush(const ItemInstance& id); // false — схрон полон
bool StashRemoveAt(int index); bool StashRemoveAt(int index);
int StashCount() const { return int(stash.size()); } int StashCount() const { return int(stash.size()); }

View File

@ -1,5 +1,7 @@
#include "sim/spawn_catalog.h" #include "sim/spawn_catalog.h"
#include "sim/affix.h"
#include "sim/loot.h" #include "sim/loot.h"
namespace namespace
@ -114,11 +116,17 @@ int SpawnFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, const
return placed; return placed;
} }
int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int* unknown) int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int depth,
uint32_t seed, int* unknown)
{ {
int placed = 0; int placed = 0;
int skipped = 0; int skipped = 0;
// Сид броска — от сида уровня и глубины: тот же уровень обязан дать те же
// находки. Иначе «переиграть с начала» (R) выдавало бы другой лут на той же
// карте, и сравнить два прохода стало бы нечем.
uint32_t rng = seed * 2654435761u + uint32_t(depth) * 40503u + 12345u;
for (const LevelSpawn& s : list) for (const LevelSpawn& s : list)
{ {
ItemId item = ItemId::NONE; ItemId item = ItemId::NONE;
@ -127,7 +135,7 @@ int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, i
// Из стены предмет не выталкивается, в отличие от твари: генератор // Из стены предмет не выталкивается, в отличие от твари: генератор
// кладёт лут только на достижимый пол, и «поправить» его здесь значило // кладёт лут только на достижимый пол, и «поправить» его здесь значило
// бы прятать ошибку раскладки вместо того, чтобы её увидеть. // бы прятать ошибку раскладки вместо того, чтобы её увидеть.
SpawnFloorItem(world, item, s.pos); SpawnFloorItem(world, RollItem(item, depth, rng), s.pos);
++placed; ++placed;
} }

View File

@ -37,4 +37,7 @@ int SpawnFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, const
int* unknown = nullptr); int* unknown = nullptr);
// То же для лежащих предметов. // То же для лежащих предметов.
int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int* unknown = nullptr); // depth и seed нужны броску редкости и аффиксов: находка на сороковом этаже
// обязана отличаться от находки на первом не только именем (sim/affix.h).
int SpawnLootFromLevel(ecs::World& world, const std::vector<LevelSpawn>& list, int depth,
uint32_t seed, int* unknown = nullptr);

View File

@ -6,6 +6,7 @@
#include <cstdio> #include <cstdio>
#include "game.h" #include "game.h"
#include "sim/affix.h"
namespace namespace
{ {
@ -178,7 +179,7 @@ struct Widget
UiRect rect; UiRect rect;
const char* text = nullptr; // подпись кнопки; у ячеек её нет const char* text = nullptr; // подпись кнопки; у ячеек её нет
int size = 0; // кегль подписи в пикселях кадра int size = 0; // кегль подписи в пикселях кадра
ItemId item = ItemId::NONE; // что лежит в ячейке ItemInstance item; // что лежит в ячейке (с аффиксами)
bool enabled = true; bool enabled = true;
}; };
@ -269,11 +270,17 @@ const char* KindName(ItemKind k)
// Что вещь делает. Только те строки, которые у неё не нулевые: список из шести // Что вещь делает. Только те строки, которые у неё не нулевые: список из шести
// «+0%» читается как «ничего не даёт», хотя даёт. // «+0%» читается как «ничего не даёт», хотя даёт.
Tip ItemTip(ItemId id) Tip ItemTip(const ItemInstance& it)
{ {
const ItemDef d = ItemDefOf(id); const ItemDef d = ItemDefOf(it.id);
Tip t; Tip t;
// Редкость в заголовке, а не отдельной строкой: игрок читает подсказку
// сверху вниз и первое, что ему нужно знать про найденную вещь, — стоит ли
// читать дальше.
if (it.rarity != Rarity::COMMON)
t.Add("%s %s (%s)", RarityName(it.rarity), d.name, KindName(d.kind));
else
t.Add("%s (%s)", d.name, KindName(d.kind)); t.Add("%s (%s)", d.name, KindName(d.kind));
t.Add("WEIGHT %.1f", double(d.weight)); t.Add("WEIGHT %.1f", double(d.weight));
@ -284,6 +291,16 @@ Tip ItemTip(ItemId id)
if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal)); if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal));
if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds); if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds);
if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge)); if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge));
// Аффиксы — отдельным блоком после базовых чисел. Смешивать их со статами
// предмета нельзя: базовое приходит с видом вещи и одинаково у всех, а
// выкатанное — то, ради чего эту конкретную вещь подобрали.
for (int i = 0; i < it.Count(); ++i)
{
char line[64];
AffixLabel(it.affix[i], line, int(sizeof(line)));
t.Add("%s", line);
}
return t; return t;
} }
@ -489,7 +506,7 @@ void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out)
const int k = ui.stashScroll + c; const int k = ui.stashScroll + c;
Widget& w = out.Add(UiPart::STASH, k, StashRect(c)); Widget& w = out.Add(UiPart::STASH, k, StashRect(c));
w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)] w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)]
: ItemId::NONE; : ItemInstance{};
} }
} }
return; return;
@ -537,7 +554,7 @@ bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there)
// Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на // Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на
// ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него // ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него
// всё это по-прежнему «слот, в котором лежит вещь». // всё это по-прежнему «слот, в котором лежит вещь».
ItemId* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index) ItemInstance* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index)
{ {
switch (part) switch (part)
{ {
@ -553,18 +570,18 @@ ItemId* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index)
// Схрон живёт не в Inventory, а в профиле, и ячейка за концом списка — это // Схрон живёт не в Inventory, а в профиле, и ячейка за концом списка — это
// место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно: // место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно:
// указателя на несуществующую ячейку не бывает. // указателя на несуществующую ячейку не бывает.
ItemId StashTake(Profile& pr, int index) ItemInstance StashTake(Profile& pr, int index)
{ {
if (index < 0 || index >= pr.StashCount()) return ItemId::NONE; if (index < 0 || index >= pr.StashCount()) return ItemInstance{};
const ItemId out = pr.stash[size_t(index)]; const ItemInstance out = pr.stash[size_t(index)];
pr.StashRemoveAt(index); pr.StashRemoveAt(index);
return out; return out;
} }
// false — схрон полон и предмет остался на месте. Молча терять его нельзя. // false — схрон полон и предмет остался на месте. Молча терять его нельзя.
bool StashPut(Profile& pr, int index, ItemId id) bool StashPut(Profile& pr, int index, const ItemInstance& id)
{ {
if (id == ItemId::NONE) return true; if (id.id == ItemId::NONE) return true;
if (index >= 0 && index < pr.StashCount()) if (index >= 0 && index < pr.StashCount())
{ {
pr.stash.insert(pr.stash.begin() + long(index), id); pr.stash.insert(pr.stash.begin() + long(index), id);
@ -588,13 +605,13 @@ void DropToFloor(Ui& ui, Game& game, UiPart part, int index)
Inventory* inv = game.world.Get<Inventory>(a.self); Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv) return; if (!inv) return;
ItemId* slot = SlotPtr(*inv, game.squad.bag, part, index); ItemInstance* slot = SlotPtr(*inv, game.squad.bag, part, index);
if (!slot || *slot == ItemId::NONE) return; if (!slot || slot->Empty()) return;
// Задержка подбора обязательна: вещь ложится там же, где стоит боец, и // Задержка подбора обязательна: вещь ложится там же, где стоит боец, и
// без неё тот же шаг поднял бы её обратно. // без неё тот же шаг поднял бы её обратно.
SpawnFloorItem(game.world, *slot, a.pos, 2.0f); SpawnFloorItem(game.world, *slot, a.pos, 2.0f);
*slot = ItemId::NONE; *slot = ItemInstance{};
} }
void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex) void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
@ -615,12 +632,12 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
{ {
// Перекладывание внутри схрона: порядок ячеек — единственное, чем // Перекладывание внутри схрона: порядок ячеек — единственное, чем
// игрок может им управлять, и ломать его незачем. // игрок может им управлять, и ломать его незачем.
const ItemId moved = StashTake(pr, ui.dragIndex); const ItemInstance moved = StashTake(pr, ui.dragIndex);
StashPut(pr, toIndex, moved); StashPut(pr, toIndex, moved);
return; return;
} }
ItemId* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom, ItemInstance* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom,
fromStash ? toIndex : ui.dragIndex); fromStash ? toIndex : ui.dragIndex);
if (!slot) return; if (!slot) return;
@ -629,18 +646,18 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
// Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если // Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если
// схрон переполнен, обмен не состоится целиком — иначе вещь из // схрон переполнен, обмен не состоится целиком — иначе вещь из
// слота исчезла бы. // слота исчезла бы.
const ItemId taken = pr.stash.empty() ? ItemId::NONE : StashTake(pr, ui.dragIndex); const ItemInstance taken = pr.stash.empty() ? ItemInstance{} : StashTake(pr, ui.dragIndex);
if (taken == ItemId::NONE) return; if (taken.Empty()) return;
const ItemId back = *slot; const ItemInstance back = *slot;
if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; } if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; }
*slot = taken; *slot = taken;
} }
else else
{ {
const ItemId moved = *slot; const ItemInstance moved = *slot;
if (moved == ItemId::NONE) return; if (moved.Empty()) return;
if (!StashPut(pr, toIndex, moved)) return; // схрон полон if (!StashPut(pr, toIndex, moved)) return; // схрон полон
*slot = ItemId::NONE; *slot = ItemInstance{};
} }
// Руки могли опустеть или наполниться — сущность-оружие обязана // Руки могли опустеть или наполниться — сущность-оружие обязана
@ -650,11 +667,11 @@ void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
return; return;
} }
ItemId* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex); ItemInstance* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex);
ItemId* dst = SlotPtr(*inv, game.squad.bag, to, toIndex); ItemInstance* dst = SlotPtr(*inv, game.squad.bag, to, toIndex);
if (!src || !dst || src == dst) return; if (!src || !dst || src == dst) return;
const ItemId moved = *src; const ItemInstance moved = *src;
*src = *dst; *src = *dst;
*dst = moved; *dst = moved;
@ -956,7 +973,7 @@ bool Ui::Update(Game& game, const ScreenRect& fit, float dt)
// бинтом отбиваться нечем. // бинтом отбиваться нечем.
Agent& a = game.squad.agents[agentIndex]; Agent& a = game.squad.agents[agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self); Inventory* inv = game.world.Get<Inventory>(a.self);
ItemId& cell = game.squad.bag.slots[slotIndex]; ItemInstance& cell = game.squad.bag.slots[slotIndex];
if (inv && slotIndex >= 0 && slotIndex < SQUAD_BAG_SLOTS && IsWeaponItem(cell)) if (inv && slotIndex >= 0 && slotIndex < SQUAD_BAG_SLOTS && IsWeaponItem(cell))
{ {
std::swap(cell, inv->weapon); std::swap(cell, inv->weapon);
@ -990,16 +1007,29 @@ void DrawButton(const Layout& l, const Ui& ui, const Widget& b, bool selected)
// Предмет в ячейке: цветной мазок и имя. Цвет берётся из каталога, чтобы // Предмет в ячейке: цветной мазок и имя. Цвет берётся из каталога, чтобы
// вещь узнавалась быстрее, чем читается слово. // вещь узнавалась быстрее, чем читается слово.
void DrawItem(const Layout& l, ItemId id, int x, int y, int h, bool bright) void DrawItem(const Layout& l, const ItemInstance& it, int x, int y, int h, bool bright)
{ {
if (id == ItemId::NONE) if (it.Empty())
{ {
Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT); Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT);
return; return;
} }
const ItemDef d = ItemDefOf(id); const ItemDef d = ItemDefOf(it.id);
Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255}); Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255});
Text(l, d.name, x + 12, y + h / 2 - 4, 8, bright ? COL_TEXT : COL_FAINT);
// Имя красится по РЕДКОСТИ, а мазок остаётся цветом вида. Так вещь
// по-прежнему узнаётся быстрее, чем читается, но «это реликт» видно, не
// наводя курсор: иначе редкость приходилось бы искать, а её смысл в том,
// чтобы бросаться в глаза среди шестнадцати ячеек.
Color name = bright ? COL_TEXT : COL_FAINT;
if (it.rarity != Rarity::COMMON)
{
const RarityColor rc = RarityColorOf(it.rarity);
const unsigned char k = bright ? 255 : 190;
name = Color{(unsigned char)(rc.r * k / 255), (unsigned char)(rc.g * k / 255),
(unsigned char)(rc.b * k / 255), 255};
}
Text(l, d.name, x + 12, y + h / 2 - 4, 8, name);
} }
// Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же, // Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же,
@ -1024,7 +1054,7 @@ void DrawCell(const Layout& l, const Ui& ui, const Widget& c, bool selected)
// То, что сейчас на курсоре, из своей ячейки убрано: иначе предмет // То, что сейчас на курсоре, из своей ячейки убрано: иначе предмет
// выглядел бы лежащим в двух местах сразу. // выглядел бы лежащим в двух местах сразу.
const bool taken = ui.dragging && ui.dragFrom == c.part && ui.dragIndex == c.index; const bool taken = ui.dragging && ui.dragFrom == c.part && ui.dragIndex == c.index;
DrawItem(l, taken ? ItemId::NONE : c.item, tx, c.rect.y, c.rect.h, hot || selected); DrawItem(l, taken ? ItemInstance{} : c.item, tx, c.rect.y, c.rect.h, hot || selected);
} }
void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widgets& w) void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widgets& w)