Редкости и аффиксы: находка стала решением (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:
parent
ece906cbb3
commit
d3aa044cbe
@ -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)).
|
||||||
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
|
- **Глубина — первичное понятие.** Вся кривая сложности живёт в одной функции
|
||||||
|
|||||||
@ -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 # бюджет предмета и потолок аффиксов
|
||||||
```
|
```
|
||||||
|
|
||||||
Редактор комнат — отдельное приложение с собственными ключами:
|
Редактор комнат — отдельное приложение с собственными ключами:
|
||||||
|
|||||||
@ -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)) |
|
||||||
|
|
||||||
## Что находилось по ходу и чинилось
|
## Что находилось по ходу и чинилось
|
||||||
|
|
||||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||||
|
|||||||
@ -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 тает одинаково.
|
||||||
|
|
||||||
Расход сравнивается с числом из каталога, и первая версия проверки показала
|
Расход сравнивается с числом из каталога, и первая версия проверки показала
|
||||||
|
|||||||
@ -8,20 +8,36 @@
|
|||||||
|
|
||||||
## 1. Редкости
|
## 1. Редкости
|
||||||
|
|
||||||
| Редкость | Аффиксов | Доля дропа D1 | D50 | D100 |
|
Сделано в M25. Числа ниже — **замеренные** (`--tool drops 1 100`, по 10000
|
||||||
|---|---|---|---|---|
|
бросков на глубину), а не задуманные:
|
||||||
| Обычное | 0 | 78% | 34% | 12% |
|
|
||||||
| Меченое | 1–2 | 20% | 40% | 30% |
|
| Редкость | Аффиксов | D1 | D28 | D55 | D100 |
|
||||||
| Разработка | 3–4 | 2% | 20% | 34% |
|
|---|---|---|---|---|---|
|
||||||
| Реликт | 5–6 | — | 5.5% | 20% |
|
| Обычное | 0 | 96.9% | 67.5% | 38.4% | 12.3% |
|
||||||
| **Артефакт** | уникальное правило + 2 | — | 0.5% | 4% |
|
| Меченое | 1–2 | 2.9% | 21.3% | 23.5% | 21.9% |
|
||||||
|
| Разработка | 3–4 | 0.1% | 7.1% | 25.1% | 39.6% |
|
||||||
|
| Реликт | 5–6 | ~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)).
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
44
src/game.cpp
44
src/game.cpp
@ -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
283
src/sim/affix.cpp
Normal 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
160
src/sim/affix.h
Normal 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);
|
||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; // есть валидная линия огня прямо сейчас
|
||||||
|
|||||||
@ -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);
|
||||||
// Стихии удара ложатся статусами и могут сложиться в реакцию.
|
// Стихии удара ложатся статусами и могут сложиться в реакцию.
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
// Плевок твари. Летит до своей дальности и бьёт первого встречного бойца.
|
// Плевок твари. Летит до своей дальности и бьёт первого встречного бойца.
|
||||||
// Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих.
|
// Проверок «не задеть своих» здесь нет и быть не может: у противника нет своих.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
// Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак
|
// Суммарные модификаторы надетого. Оружие в руках тоже считается: тесак
|
||||||
// весит, нож — нет.
|
// весит, нож — нет.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
// Подбор за шаг. Возвращает, сколько предметов поднято.
|
// Подбор за шаг. Возвращает, сколько предметов поднято.
|
||||||
//
|
//
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()); }
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,12 +270,18 @@ 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;
|
||||||
t.Add("%s (%s)", d.name, KindName(d.kind));
|
// Редкость в заголовке, а не отдельной строкой: игрок читает подсказку
|
||||||
|
// сверху вниз и первое, что ему нужно знать про найденную вещь, — стоит ли
|
||||||
|
// читать дальше.
|
||||||
|
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("WEIGHT %.1f", double(d.weight));
|
t.Add("WEIGHT %.1f", double(d.weight));
|
||||||
|
|
||||||
if (d.armor > 0.0f) t.Add("ARMOR +%d%%", int(d.armor * 100.0f + 0.5f));
|
if (d.armor > 0.0f) t.Add("ARMOR +%d%%", int(d.armor * 100.0f + 0.5f));
|
||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user