Свет как расходник: заряд тратится за путь, а не за время (M23)
Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает
до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он
меняется с тварями местами. Батарея встаёт сама, как бинт.
Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия
считала по секундам, то есть была таймером, которого в игре нет по прямому
требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как
секундомер, только без циферблата, и думать над этажом стало бы дорого.
За расстояние получается тот же размен, что и с весом, но с другой стороны:
вес делает дорогим «унести всё», свет — «обшарить всё».
Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это
был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем
дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды.
Проверка в --descend меряет правило целиком, и первая её строка не про
баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже
был неправ — делил потраченное на смещение отряда, а не на пройденный
путь, и показывал 1.55 за тайл при 1.10 в каталоге.
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге
глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска
обзор 1.00 -> 0.35 в темноте, батарея встала сама
Хватает ли заряда на этаж считается в этажах и по средней длине забега:
путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы
удачу, а не баланс.
--accept PASS без изменений: в песочнице света бесконечно, как и патронов.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c1831ac370
commit
0fb6e31c2b
@ -54,6 +54,10 @@
|
|||||||
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
|
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
|
||||||
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
|
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
|
||||||
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
|
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
|
||||||
|
- **Таймера в игре нет и не заводить.** Фонарь тратит заряд за ПРОЙДЕННЫЙ ПУТЬ,
|
||||||
|
а не за время: стоять и думать бесплатно, дорого — обшарить этаж целиком.
|
||||||
|
Расход со `dt` вместо пути — это таймер, и `--descend` его ловит
|
||||||
|
([17-extraction.md](docs/17-extraction.md)).
|
||||||
- **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с
|
- **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с
|
||||||
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
|
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
|
||||||
Числа не должны спасать — иначе хоррор кончается на сороковом этаже
|
Числа не должны спасать — иначе хоррор кончается на сороковом этаже
|
||||||
|
|||||||
@ -130,7 +130,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
|
|||||||
| `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
|
| `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
|
||||||
| `--seed N` | сид сборки уровня (по умолчанию 1) |
|
| `--seed N` | сид сборки уровня (по умолчанию 1) |
|
||||||
| `--depth N` | начать не с первого этажа (для проверок) |
|
| `--depth N` | начать не с первого этажа (для проверок) |
|
||||||
| `--descend N` | прогон забега на N этажей без окна |
|
| `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль |
|
||||||
| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` |
|
| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` |
|
||||||
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
|
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
|
||||||
| `--no-profile` | не читать и не писать профиль |
|
| `--no-profile` | не читать и не писать профиль |
|
||||||
|
|||||||
@ -34,6 +34,8 @@
|
|||||||
|
|
||||||
| **M22** | статусы стихий и четыре реакции (SHATTER, IGNITE, SUPERCOND, DECAY) с силой от ГЛУБИНЫ, а не от урона оружия; три ограничителя — потолок стаков, откат на цели, запрет рекурсии | `--tool reactions`: все четыре срабатывают, площадные задевают соседа вполовину, стаки гасятся, вторая пара внутри отката даёт 0.0, урон от реакции не кладёт статусов; шаг симуляции 2.9 мкс из 16666; `--accept` PASS ([21-damage.md](21-damage.md)) |
|
| **M22** | статусы стихий и четыре реакции (SHATTER, IGNITE, SUPERCOND, DECAY) с силой от ГЛУБИНЫ, а не от урона оружия; три ограничителя — потолок стаков, откат на цели, запрет рекурсии | `--tool reactions`: все четыре срабатывают, площадные задевают соседа вполовину, стаки гасятся, вторая пара внутри отката даёт 0.0, урон от реакции не кладёт статусов; шаг симуляции 2.9 мкс из 16666; `--accept` PASS ([21-damage.md](21-damage.md)) |
|
||||||
|
|
||||||
|
| **M23** | свет как расходник: заряд фонаря общий на отряд и тратится за ПРОЙДЕННЫЙ ПУТЬ, а не за время; погас — обзор 35% и твари замечают в 1.35 раза дальше; батарея встаёт сама, как бинт | проверка в `--descend`: стоянка стоит 0.2 из 420 (то есть таймера нет), ход 1.10 за тайл при 1.10 в каталоге, заряда на 4.0 прямых пути до спуска, обзор 1.00 → 0.35, батарея сработала; первая версия расхода была ПОСЕКУНДНОЙ — то есть таймером, которого в игре нет по требованию к жанру, и переписана на путь; измеритель тоже был неправ (делил на смещение, а не на путь) и показывал 1.55 против 1.10; `--accept` PASS без изменений — в песочнице свет бесконечен ([17-extraction.md](17-extraction.md)) |
|
||||||
|
|
||||||
## Что находилось по ходу и чинилось
|
## Что находилось по ходу и чинилось
|
||||||
|
|
||||||
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,
|
||||||
|
|||||||
@ -182,7 +182,11 @@
|
|||||||
| SHROUD | 4% | 112% | обзор 92% |
|
| SHROUD | 4% | 112% | обзор 92% |
|
||||||
|
|
||||||
В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%).
|
В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%).
|
||||||
Расходники: BANDAGE, STIM.
|
Расходники: BANDAGE, STIM, AMMO, BATTERY.
|
||||||
|
|
||||||
|
Фонарь — единственная вещь с ЗАРЯДОМ: он садится за пройденный путь, и погасший
|
||||||
|
не даёт ничего. Заряд общий на отряд, батарея встаёт сама
|
||||||
|
([17-extraction.md](17-extraction.md)).
|
||||||
|
|
||||||
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`):
|
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`):
|
||||||
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
|
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
|
||||||
@ -217,6 +221,11 @@ HP, броню, скорость, обзор. `ENTER` меняет местам
|
|||||||
- уголок над карточкой — боец при цели;
|
- уголок над карточкой — боец при цели;
|
||||||
- карточка перечёркнута — боец упал.
|
- карточка перечёркнута — боец упал.
|
||||||
|
|
||||||
|
Исключение одно и на весь HUD — строка забега сверху: этаж, опыт, добыча и
|
||||||
|
заряд фонаря. Номер этажа формой не покажешь. Погасший фонарь там показан
|
||||||
|
СЛОВОМ `DARK`, а не полосой нулевой длины: темнота меняет правила, а
|
||||||
|
«полоски не видно» читалось бы как «полоски тут и не было».
|
||||||
|
|
||||||
## Смерть бойца
|
## Смерть бойца
|
||||||
|
|
||||||
Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по
|
Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по
|
||||||
|
|||||||
@ -161,6 +161,11 @@ entity plate class=loot tier=3 depth=9 radius=0.30 color=140,1
|
|||||||
запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с
|
запуск, и запись туда означала бы, что двенадцатый этаж подкрутил ручку, с
|
||||||
которой потом работает и первый этаж, и песочница приёмки.
|
которой потом работает и первый этаж, и песочница приёмки.
|
||||||
|
|
||||||
|
Глубина жмёт на свет ВТОРОЙ раз, уже через ресурс: `LightDrain(глубина)` делает
|
||||||
|
каждый шаг дороже по заряду фонаря, а этажи внизу длиннее. Один множитель —
|
||||||
|
сколько видно, второй — сколько ещё будет видно
|
||||||
|
([17-extraction.md](17-extraction.md)).
|
||||||
|
|
||||||
## Цифры на экране
|
## Цифры на экране
|
||||||
|
|
||||||
HUD прототипа был принципиально бессловесным (`render/hud.h`). Для спуска этого
|
HUD прототипа был принципиально бессловесным (`render/hud.h`). Для спуска этого
|
||||||
|
|||||||
@ -159,6 +159,42 @@ stash saber plate vest smg bow charm lantern spurs
|
|||||||
его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы
|
его сейчас: иначе боец с клинком в руках и запасным карабином в сумке таскал бы
|
||||||
найденную пачку до конца забега. Это нашла проверка, а не глаз.
|
найденную пачку до конца забега. Это нашла проверка, а не глаз.
|
||||||
|
|
||||||
|
### Свет: второй ресурс, и тратится он за ПУТЬ
|
||||||
|
|
||||||
|
Фонарь садится. Полного заряда (`LIGHT_FULL`, 420) хватает примерно на
|
||||||
|
**четыре прямых пути до спуска** — то есть на этаж с осмотром, но не на два
|
||||||
|
этажа подряд. Кончился — обзор падает до `DARK_SIGHT_MUL` (35%), а твари
|
||||||
|
начинают замечать отряд в `DARK_AGGRO_MUL` раз дальше. Отряд не слепнет: он
|
||||||
|
меняется с тварями местами, и первым теперь видит не он.
|
||||||
|
|
||||||
|
**Главное решение — заряд тратится за пройденное расстояние, а не за время.**
|
||||||
|
|
||||||
|
Расход по секундам был бы таймером, только без циферблата: садящийся сам собой
|
||||||
|
фонарь торопил бы игрока ровно так же, а думать над этажом стало бы дорого.
|
||||||
|
Здесь думать бесплатно. Стоять и разглядывать — бесплатно. Платить приходится
|
||||||
|
за то, что **обошёл этаж целиком**, а не за то, что задержался.
|
||||||
|
|
||||||
|
Получается тот же размен, что и с весом, только с другой стороны:
|
||||||
|
|
||||||
|
| ресурс | что он делает дорогим |
|
||||||
|
|---|---|
|
||||||
|
| вес | унести всё |
|
||||||
|
| свет | обшарить всё |
|
||||||
|
|
||||||
|
Прямая дорога к спуску дёшева обоими. Полная зачистка — дорога обоими сразу.
|
||||||
|
|
||||||
|
Чем глубже, тем дороже шаг (`LightDrain`): на сотом этаже заряда хватает вдвое
|
||||||
|
меньше, чем на первом, а сами этажи там длиннее. Заряд едет вниз вместе с
|
||||||
|
отрядом и **не обновляется на новом этаже** — иначе он был бы ресурсом этажа, а
|
||||||
|
не вылазки, и никуда бы не гнал.
|
||||||
|
|
||||||
|
Батарея (0.3 веса) — расходник по тем же правилам, что бинт и пачка: встаёт в
|
||||||
|
фонарь сама, в тот же шаг, когда свет погас. Держать её в сумке при погасшем
|
||||||
|
фонаре отряду незачем, а темнота ради темноты — не решение игрока.
|
||||||
|
|
||||||
|
Погасший фонарь не даёт **ничего**: его 35% обзора снимаются вместе с зарядом.
|
||||||
|
Иначе «сел» означало бы «стал слабее», а не «кончился».
|
||||||
|
|
||||||
## Во что вкладывается опыт
|
## Во что вкладывается опыт
|
||||||
|
|
||||||

|

|
||||||
@ -234,6 +270,9 @@ squad_proto.exe --descend 7 --no-profile
|
|||||||
```
|
```
|
||||||
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
|
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
|
||||||
перезарядка: запас 40 -> 16 (магазин 24)
|
перезарядка: запас 40 -> 16 (магазин 24)
|
||||||
|
свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге (прошли 12.7, продвинулись 1.6)
|
||||||
|
глубина 8: заряда на 383 тайлов, это 4.0 прямых пути до спуска (средний 95, здешний 155)
|
||||||
|
обзор 1.00 -> 0.35 в темноте, батарея встала сама
|
||||||
переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1
|
переноска: налегке 6.9 (скорость 100%), с грузом 12.9 (скорость 63%), плит влезло 1
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -241,6 +280,19 @@ squad_proto.exe --descend 7 --no-profile
|
|||||||
«патронов» в таблице стоит на месте — и без прямой проверки бесконечный
|
«патронов» в таблице стоит на месте — и без прямой проверки бесконечный
|
||||||
боезапас выглядел бы ровно так же.
|
боезапас выглядел бы ровно так же.
|
||||||
|
|
||||||
|
У света первая строка проверки — **не про баланс, а про жанр**: заряд на
|
||||||
|
стоянке обязан стоять. Написать таймер случайно очень легко (одна строка со
|
||||||
|
`dt` вместо пути), а заметить на глаз невозможно — цифра в HUD тает одинаково.
|
||||||
|
|
||||||
|
Расход сравнивается с числом из каталога, и первая версия проверки показала
|
||||||
|
1.55 за тайл при 1.10 в правилах. Расходился не свет, а измеритель: он делил
|
||||||
|
потраченное на **смещение** отряда, а отряд шёл не по прямой. Считать надо путь
|
||||||
|
пошагово — тогда сходится до сотой.
|
||||||
|
|
||||||
|
Хватает ли заряда на этаж — считается в **этажах**, а не в тайлах, и по средней
|
||||||
|
длине этажей забега: путь до спуска скачет вдвое от сида, и порог, выставленный
|
||||||
|
по одному этажу, ловил бы удачу, а не баланс.
|
||||||
|
|
||||||
```
|
```
|
||||||
7 RUST 13 19 3 149 живых 5, в сумках 30
|
7 RUST 13 19 3 149 живых 5, в сумках 30
|
||||||
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6
|
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6
|
||||||
|
|||||||
@ -295,16 +295,34 @@ DECAY 6.2 3.1 0
|
|||||||
|
|
||||||
## 7. Свет как ресурс
|
## 7. Свет как ресурс
|
||||||
|
|
||||||
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не
|
Сделано в M23, но **не по формуле, которая стояла здесь раньше**. В ней расход
|
||||||
садится. Должен садиться.
|
считался от времени:
|
||||||
|
|
||||||
```
|
```
|
||||||
заряд(t) = заряд₀ − drain·t, drain = 1/сек
|
заряд(t) = заряд₀ − drain·t, drain = 1/сек <- ТАК НЕ СДЕЛАНО
|
||||||
радиус = base · (0.35 + 0.65 · заряд/заряд₀)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Батареи — расходник (вес 0.3), заменяются автоматически при разряде, как бинты.
|
Это таймер. Игра заявлена без таймера намеренно
|
||||||
Кончились — отряд идёт в темноте: обзор 35%, твари замечают первыми.
|
([17-extraction.md](17-extraction.md)): давить обязано исчерпание, а не часы.
|
||||||
|
Фонарь, садящийся сам по себе, торопил бы игрока ровно как секундомер, только
|
||||||
|
без циферблата, — и думать над этажом стало бы дорого.
|
||||||
|
|
||||||
|
Как сделано:
|
||||||
|
|
||||||
|
```
|
||||||
|
заряд ← заряд − путь · drain(глубина), drain = 1 + 0.012·глубина
|
||||||
|
```
|
||||||
|
|
||||||
|
Путь — смещение центра отряда за шаг. Стоять бесплатно, идти дорого. Полного
|
||||||
|
заряда (420) хватает примерно на четыре прямых пути до спуска, то есть на этаж
|
||||||
|
с осмотром и не больше.
|
||||||
|
|
||||||
|
Радиус не тает плавно вместе с зарядом, как предполагала старая формула:
|
||||||
|
фонарь либо светит полностью, либо не светит вовсе (обзор 35%, твари замечают в
|
||||||
|
1.35 раза дальше). Плавное угасание — это «стало чуть хуже» пять минут подряд,
|
||||||
|
и заметить момент, когда пора наверх, по нему нельзя. Обрыв заметен сразу.
|
||||||
|
|
||||||
|
Батареи — расходник (вес 0.3), встают в фонарь сами при разряде, как бинты.
|
||||||
|
|
||||||
Это второй после патронов ресурс, который гонит наверх, и он же — причина, по
|
Это второй после патронов ресурс, который гонит наверх, и он же — причина, по
|
||||||
которой ветвь Разведки в дереве узлов не «приятная», а обязательная.
|
которой ветвь Разведки в дереве узлов не «приятная», а обязательная.
|
||||||
|
|||||||
@ -44,6 +44,9 @@ entity locker class=prop depth=2 radius=0.36 color=78,88,96 spri
|
|||||||
# не исчезают: бинт нужен всегда, он просто перестаёт быть удачей.
|
# не исчезают: бинт нужен всегда, он просто перестаёт быть удачей.
|
||||||
entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO
|
entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO
|
||||||
entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE
|
entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE
|
||||||
|
# Батарея — второй расходник первой необходимости после бинта: свет садится сам
|
||||||
|
# и на всех, поэтому нижним тиром и с первого этажа. Иконки пока нет.
|
||||||
|
entity battery class=loot tier=0 depth=1 radius=0.22 color=150,210,200 sprite=- name=BATTERY
|
||||||
entity pistol class=loot tier=0 depth=1 radius=0.24 color=190,180,168 sprite=item_icon_pistol name=PISTOL
|
entity pistol class=loot tier=0 depth=1 radius=0.24 color=190,180,168 sprite=item_icon_pistol name=PISTOL
|
||||||
entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE
|
entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE
|
||||||
entity jacket class=loot tier=0 depth=1 radius=0.26 color=122,112,96 sprite=item_icon_jacket name=JACKET
|
entity jacket class=loot tier=0 depth=1 radius=0.26 color=122,112,96 sprite=item_icon_jacket name=JACKET
|
||||||
|
|||||||
@ -22,6 +22,7 @@ entity barrel class=prop depth=1 radius=0.33 color=92,84,58 spri
|
|||||||
# двенадцать. Спуск здесь не успевает одеть отряд, и это замысел.
|
# двенадцать. Спуск здесь не успевает одеть отряд, и это замысел.
|
||||||
entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO
|
entity ammo class=loot tier=0 depth=1 radius=0.22 color=200,176,96 sprite=item_icon_ammo name=AMMO
|
||||||
entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE
|
entity bandage class=loot tier=0 depth=1 radius=0.22 color=216,208,196 sprite=item_icon_bandage name=BANDAGE
|
||||||
|
entity battery class=loot tier=0 depth=1 radius=0.22 color=150,210,200 sprite=- name=BATTERY
|
||||||
entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE
|
entity knife class=loot tier=0 depth=1 radius=0.22 color=214,222,232 sprite=item_icon_knife name=KNIFE
|
||||||
|
|
||||||
entity rifle class=loot tier=1 depth=2 radius=0.26 color=208,196,150 sprite=item_icon_rifle name=CARBINE
|
entity rifle class=loot tier=1 depth=2 radius=0.26 color=208,196,150 sprite=item_icon_rifle name=CARBINE
|
||||||
|
|||||||
@ -633,9 +633,16 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
|
|||||||
"EXIT", "SQUAD");
|
"EXIT", "SQUAD");
|
||||||
|
|
||||||
int bad = 0;
|
int bad = 0;
|
||||||
|
// Средний путь до спуска по всем этажам забега. Нужен свету: сравнивать
|
||||||
|
// заряд с длиной ОДНОГО этажа — значит мерить не баланс, а везение сида,
|
||||||
|
// а этажи одной глубины различаются вдвое.
|
||||||
|
int exitSum = 0;
|
||||||
|
int exitCount = 0;
|
||||||
|
|
||||||
for (int i = 0; i < floors; ++i)
|
for (int i = 0; i < floors; ++i)
|
||||||
{
|
{
|
||||||
const GeneratedLevel& l = game.level;
|
const GeneratedLevel& l = game.level;
|
||||||
|
if (l.exitDistance > 0) { exitSum += l.exitDistance; ++exitCount; }
|
||||||
|
|
||||||
if (!game.levelGenerated)
|
if (!game.levelGenerated)
|
||||||
{
|
{
|
||||||
@ -664,10 +671,17 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
|
|||||||
ammoLeft += std::max(0, game.squad.agents[a].ammoReserve);
|
ammoLeft += std::max(0, game.squad.agents[a].ammoReserve);
|
||||||
|
|
||||||
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\n",
|
" (перегруз %d), патронов %d, света на %.0f тайлов\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))));
|
||||||
|
|
||||||
|
// Свет обязан ехать вниз вместе с отрядом, как патроны и сумки: заряд,
|
||||||
|
// обновляющийся на каждом этаже, — это не ресурс вылазки, а украшение
|
||||||
|
// на этаж. Прогон спуска не шагает симуляцией, поэтому сам заряд тут не
|
||||||
|
// тратится: срезаем его руками на первом этаже и смотрим внизу.
|
||||||
|
if (i == 0) game.squad.lightCharge -= 60.0f;
|
||||||
|
|
||||||
if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; }
|
if (l.exitDistance <= 0) { std::printf(" [FAIL] спуск совпал со стартом\n"); ++bad; }
|
||||||
if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0)
|
if (l.spawnsDropped > 0 || l.spawnsUnreachable > 0)
|
||||||
@ -791,6 +805,168 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Свет. Проверяется ПРАВИЛО целиком: стоять бесплатно -> идти дорого ->
|
||||||
|
// погас, и обзор упал -> батарея из сумки встала сама.
|
||||||
|
//
|
||||||
|
// Первая проверка тут главная, и она не про баланс, а про жанр: заряд,
|
||||||
|
// тающий на стоянке, — это таймер, которого в игре нет. Написать такое
|
||||||
|
// случайно очень легко (одна строка в Step с dt вместо пути), а заметить
|
||||||
|
// на глаз невозможно: цифра в HUD тает одинаково.
|
||||||
|
//
|
||||||
|
// Столбец в таблице выше для этого не годится вовсе: прогон спуска не
|
||||||
|
// шагает симуляцией, и заряд в нём не тратится совсем.
|
||||||
|
//
|
||||||
|
// Мир возвращается как был: дальше проверяется эвакуация, и она обязана
|
||||||
|
// считать сумку отряда, а не сумку измеряющего.
|
||||||
|
{
|
||||||
|
Agent& a = game.squad.agents[0];
|
||||||
|
const SquadBag savedBag = game.squad.bag;
|
||||||
|
const float savedLight = game.squad.lightCharge;
|
||||||
|
const int exitTiles = game.level.exitDistance;
|
||||||
|
|
||||||
|
// 0. Доехал ли заряд, срезанный на первом этаже, до низа.
|
||||||
|
if (game.squad.lightCharge >= LIGHT_FULL)
|
||||||
|
{
|
||||||
|
std::printf("\n [FAIL] заряд обновился при спуске — свет не ресурс вылазки\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. СТОЯТЬ БЕСПЛАТНО. Ввода нет — отряд топчется в строю, и это не
|
||||||
|
// должно стоить ничего заметного.
|
||||||
|
game.in = InputState{};
|
||||||
|
const float standBefore = game.squad.lightCharge;
|
||||||
|
for (int k = 0; k < 120; ++k) game.Step(FIXED_DT);
|
||||||
|
const float standCost = standBefore - game.squad.lightCharge;
|
||||||
|
|
||||||
|
// 2. ИДТИ ДОРОГО, и цена считается за путь. Гоним отряд в одну сторону
|
||||||
|
// и делим потраченное на пройденное.
|
||||||
|
//
|
||||||
|
// Путь копится ПОШАГОВО, а не берётся как расстояние между началом и
|
||||||
|
// концом. Первая версия делила на смещение и показала 1.55 за тайл
|
||||||
|
// при 1.10 в каталоге: отряд шёл не по прямой (стена, строй), и
|
||||||
|
// инструмент мерил цену продвижения, а не цену шага. Число выглядело
|
||||||
|
// как расхождение с правилом, хотя расходился только измеритель.
|
||||||
|
// Гоняем по четырём сторонам, а не в одну: отряд стоит там, где его
|
||||||
|
// высадил спуск, и в любую отдельную сторону может упереться в стену
|
||||||
|
// через полтора тайла. Тогда вся выборка — это топтание в строю.
|
||||||
|
const Vec2 from = game.squad.Centroid();
|
||||||
|
const float walkBefore = game.squad.lightCharge;
|
||||||
|
|
||||||
|
float path = 0.0f;
|
||||||
|
Vec2 prev = from;
|
||||||
|
const Vec2 dirs[4] = {{1.0f, 0.0f}, {0.0f, 1.0f}, {-1.0f, 0.0f}, {0.0f, -1.0f}};
|
||||||
|
for (const Vec2& d : dirs)
|
||||||
|
{
|
||||||
|
game.in.moveScreen = d;
|
||||||
|
for (int k = 0; k < 150; ++k)
|
||||||
|
{
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
const Vec2 now = game.squad.Centroid();
|
||||||
|
path += Distance(now, prev);
|
||||||
|
prev = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const float walkCost = walkBefore - game.squad.lightCharge;
|
||||||
|
const float perTile = (path > 0.5f) ? walkCost / path : 0.0f;
|
||||||
|
const float progress = Distance(game.squad.Centroid(), from);
|
||||||
|
game.in = InputState{};
|
||||||
|
|
||||||
|
// 3. Погас — обзор падает. Меряется на живом бойце, а не по константе:
|
||||||
|
// константу можно завести и не подключить.
|
||||||
|
game.squad.bag.Clear();
|
||||||
|
game.squad.lightCharge = 0.0f;
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
const float sightDark = a.sightMul;
|
||||||
|
|
||||||
|
game.squad.lightCharge = LIGHT_FULL;
|
||||||
|
game.Step(FIXED_DT);
|
||||||
|
const float sightLit = a.sightMul;
|
||||||
|
|
||||||
|
// 4. Батарея из сумки встаёт сама, как бинт.
|
||||||
|
game.squad.lightCharge = 0.0f;
|
||||||
|
game.squad.bag.slots[0] = ItemId::BATTERY;
|
||||||
|
for (int k = 0; k < 8; ++k) game.Step(FIXED_DT);
|
||||||
|
const bool relit = game.squad.lightCharge > 0.0f;
|
||||||
|
const bool spentBattery = game.squad.bag.slots[0] == ItemId::NONE;
|
||||||
|
|
||||||
|
// Хватает ли заряда на этаж — та самая калибровка, без которой свет
|
||||||
|
// либо украшение, либо игра в темноте с первого этажа.
|
||||||
|
//
|
||||||
|
// Считается в ЭТАЖАХ, а не в тайлах: «заряда на 383 тайла» ни о чём не
|
||||||
|
// говорит, пока рядом нет длины этажа. И берётся СРЕДНЯЯ длина по
|
||||||
|
// забегу — на одном этаже путь до спуска скачет вдвое от сида, и
|
||||||
|
// порог, выставленный по одному, ловил бы удачу, а не баланс.
|
||||||
|
const float reach = LIGHT_FULL / std::max(0.01f, LightDrain(game.depth));
|
||||||
|
const float avgExit = (exitCount > 0) ? float(exitSum) / float(exitCount) : 0.0f;
|
||||||
|
const float floorsPerCharge = (avgExit > 1.0f) ? reach / avgExit : 0.0f;
|
||||||
|
|
||||||
|
std::printf("\nсвет: стоянка %.1f, ход %.2f за тайл при %.2f в каталоге (прошли %.1f,"
|
||||||
|
" продвинулись %.1f)\n"
|
||||||
|
" глубина %d: заряда на %.0f тайлов, это %.1f прямых пути до спуска"
|
||||||
|
" (средний %.0f, здешний %d)\n"
|
||||||
|
" обзор %.2f -> %.2f в темноте, батарея %s\n",
|
||||||
|
double(standCost), double(perTile), double(LightDrain(game.depth)),
|
||||||
|
double(path), double(progress),
|
||||||
|
game.depth, double(reach), double(floorsPerCharge), double(avgExit),
|
||||||
|
exitTiles,
|
||||||
|
double(sightLit), double(sightDark),
|
||||||
|
relit ? (spentBattery ? "встала сама" : "встала, но осталась в сумке")
|
||||||
|
: "НЕ встала");
|
||||||
|
|
||||||
|
// Расход обязан совпасть с числом из каталога. Разойдись он — значит,
|
||||||
|
// свет тратит что-то ещё, кроме пути, и таймер вернулся боком.
|
||||||
|
const float expected = LightDrain(game.depth);
|
||||||
|
if (perTile > 0.0f && std::fabs(perTile - expected) > expected * 0.05f)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] расход %.2f за тайл против %.2f в каталоге — свет тратит"
|
||||||
|
" не только путь\n", double(perTile), double(expected));
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (standCost > 1.0f)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] заряд тает на стоянке (%.1f за две секунды) — это таймер,"
|
||||||
|
" а таймера в игре нет\n", double(standCost));
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (perTile <= 0.0f)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] ход не тратит света — фонарь декоративен\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
// Нижняя граница — по ЗДЕШНЕМУ этажу: на нём отряд стоит сейчас, и не
|
||||||
|
// дойти до спуска в свету он не должен ни на одном сиде.
|
||||||
|
if (reach < float(exitTiles))
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] заряда (%.0f тайлов) не хватает даже на дорогу до спуска"
|
||||||
|
" (%d) — этаж непроходим в свету\n", double(reach), exitTiles);
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
// Верхняя — по среднему. Полная зачистка этажа — это два-три прямых
|
||||||
|
// пути, поэтому шесть прямых путей на заряд это уже «полтора этажа с
|
||||||
|
// осмотром», и дальше свет перестаёт быть решением.
|
||||||
|
if (floorsPerCharge > 6.0f)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] заряда хватает на %.1f пути до спуска — свет ничего"
|
||||||
|
" не связывает\n", double(floorsPerCharge));
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (sightDark >= sightLit * 0.6f)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] темнота не режет обзор: %.2f против %.2f\n",
|
||||||
|
double(sightDark), double(sightLit));
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
if (!relit || !spentBattery)
|
||||||
|
{
|
||||||
|
std::printf(" [FAIL] батарея не сработала сама — свет чинить нечем\n");
|
||||||
|
++bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.squad.bag = savedBag;
|
||||||
|
game.squad.lightCharge = savedLight;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
|
// Переноска. Проверяется НА ВЫДУМАННОМ инвентаре, а не по столбцу «груз»
|
||||||
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
|
// выше: прогон кладёт в сумки бинты, чтобы проверить перенос между этажами,
|
||||||
@ -963,14 +1139,15 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
|
|||||||
const int floorsDone = game.floorsCleared + 1;
|
const int floorsDone = game.floorsCleared + 1;
|
||||||
const float xpEarned = game.xp;
|
const float xpEarned = game.xp;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
|
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
|
||||||
|
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
|
||||||
|
// влияет, выглядит точно так же, как работающее улучшение.
|
||||||
//
|
//
|
||||||
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
|
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
|
||||||
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
|
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
|
||||||
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
|
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
|
||||||
// сломана, а потому что её сломал измеряющий.
|
// сломана, а потому что её сломал измеряющий.
|
||||||
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
|
|
||||||
// влияет, выглядит точно так же, как работающее улучшение.
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
{
|
{
|
||||||
Profile& pr = *game.profile;
|
Profile& pr = *game.profile;
|
||||||
|
|||||||
37
src/game.cpp
37
src/game.cpp
@ -323,6 +323,12 @@ void Game::BuildFloor(const SquadCarry* carry)
|
|||||||
// правила стрельбы, а не то, на сколько минут хватило патронов.
|
// правила стрельбы, а не то, на сколько минут хватило патронов.
|
||||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||||
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
|
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
|
||||||
|
|
||||||
|
// Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает
|
||||||
|
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь
|
||||||
|
// смысл — на четвёртом этаже светить уже почти нечем.
|
||||||
|
// В песочнице приёмки света бесконечно (-1), как и патронов.
|
||||||
|
squad.lightCharge = combatMode ? LIGHT_FULL : -1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
lanes.Clear();
|
lanes.Clear();
|
||||||
@ -417,6 +423,8 @@ SolverContext Game::MakeContext() const
|
|||||||
// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон,
|
// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон,
|
||||||
// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно:
|
// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно:
|
||||||
// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой.
|
// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой.
|
||||||
|
// Батарея ставится ровно в тот шаг, когда фонарь сел: держать её в сумке при
|
||||||
|
// погасшем свете отряду незачем, а темнота ради темноты — не решение игрока.
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
void Game::UseConsumables(float dt)
|
void Game::UseConsumables(float dt)
|
||||||
{
|
{
|
||||||
@ -441,6 +449,11 @@ void Game::UseConsumables(float dt)
|
|||||||
const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac;
|
const bool hurt = a.hp < a.maxHp * g_tune.healAtFrac;
|
||||||
const bool safe = nearest > g_tune.healSafeDist;
|
const bool safe = nearest > g_tune.healSafeDist;
|
||||||
|
|
||||||
|
// Свет — ресурс ОТРЯДА, поэтому условие пересчитывается на каждом бойце:
|
||||||
|
// поставил батарею первый — второй уже видит заряженный фонарь и свою
|
||||||
|
// не тратит.
|
||||||
|
const bool dark = squad.LightOut();
|
||||||
|
|
||||||
// Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто
|
// Патроны нужны тому, у кого ВООБЩЕ есть ствол, — не только тому, кто
|
||||||
// держит его прямо сейчас. Боец с клинком в руках и запасным карабином
|
// держит его прямо сейчас. Боец с клинком в руках и запасным карабином
|
||||||
// в сумке иначе таскал бы найденную пачку до конца забега мёртвым
|
// в сумке иначе таскал бы найденную пачку до конца забега мёртвым
|
||||||
@ -458,10 +471,12 @@ void Game::UseConsumables(float dt)
|
|||||||
|
|
||||||
const bool healsNow = (d.heal > 0.0f) && hurt && safe;
|
const bool healsNow = (d.heal > 0.0f) && hurt && safe;
|
||||||
const bool loadsNow = (d.rounds > 0) && dry;
|
const bool loadsNow = (d.rounds > 0) && dry;
|
||||||
if (!healsNow && !loadsNow) continue;
|
const bool lightsNow = (d.charge > 0.0f) && dark;
|
||||||
|
if (!healsNow && !loadsNow && !lightsNow) continue;
|
||||||
|
|
||||||
if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal);
|
if (healsNow) a.hp = std::min(a.maxHp, a.hp + d.heal);
|
||||||
if (loadsNow) a.ammoReserve += d.rounds;
|
if (loadsNow) a.ammoReserve += d.rounds;
|
||||||
|
if (lightsNow) squad.lightCharge = d.charge;
|
||||||
|
|
||||||
squad.bag.slots[s] = ItemId::NONE;
|
squad.bag.slots[s] = ItemId::NONE;
|
||||||
events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{});
|
events.Push(CombatEventKind::ITEM_USED, i, a.pos, Vec2{});
|
||||||
@ -483,10 +498,12 @@ void Game::Step(float dt)
|
|||||||
// его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл
|
// его долю на остальных: обратно наверх отряд пойдёт медленнее, чем шёл
|
||||||
// сюда, и это не побочный эффект, а то, ради чего сумка общая.
|
// сюда, и это не побочный эффект, а то, ради чего сумка общая.
|
||||||
const float bagShare = squad.bag.Load() / float(squad.Carriers());
|
const float bagShare = squad.bag.Load() / float(squad.Carriers());
|
||||||
|
|
||||||
SquadBuffs buffs;
|
SquadBuffs buffs;
|
||||||
buffs.sight = squad.sightBonus;
|
buffs.sight = squad.sightBonus;
|
||||||
buffs.hp = squad.hpBonus;
|
buffs.hp = squad.hpBonus;
|
||||||
buffs.carry = squad.carryBonus;
|
buffs.carry = squad.carryBonus;
|
||||||
|
buffs.lightOut = squad.LightOut();
|
||||||
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs);
|
for (int i = 0; i < NUM_AGENTS; ++i) SyncLoadout(world, squad.agents[i], bagShare, buffs);
|
||||||
for (int i = 0; i < NUM_AGENTS; ++i)
|
for (int i = 0; i < NUM_AGENTS; ++i)
|
||||||
squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt);
|
squad.agents[i].hurtTimer = std::max(0.0f, squad.agents[i].hurtTimer - dt);
|
||||||
@ -500,7 +517,8 @@ void Game::Step(float dt)
|
|||||||
UpdateTargets(targets, dt);
|
UpdateTargets(targets, dt);
|
||||||
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
|
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
|
||||||
// на то, что видит сейчас, а не на прошлый кадр.
|
// на то, что видит сейчас, а не на прошлый кадр.
|
||||||
UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events);
|
UpdateEnemies(world, map, squad.agents.data(), NUM_AGENTS, targets, dt, &events,
|
||||||
|
buffs.lightOut ? DARK_AGGRO_MUL : 1.0f);
|
||||||
|
|
||||||
// --- 1. якорь и состояние отряда -----------------------------------------
|
// --- 1. якорь и состояние отряда -----------------------------------------
|
||||||
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
|
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
|
||||||
@ -508,8 +526,23 @@ void Game::Step(float dt)
|
|||||||
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
|
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
|
||||||
|
|
||||||
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
|
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
|
||||||
|
const Vec2 lightFrom = squad.Centroid();
|
||||||
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
|
squad.UpdateMovement(map, enableLaneAvoid ? &lanes : nullptr, targets, dt);
|
||||||
|
|
||||||
|
// --- 2.5 свет -------------------------------------------------------------
|
||||||
|
// Фонарь садится за ПРОЙДЕННЫЙ ПУТЬ, и меряется путь по центру отряда, а не
|
||||||
|
// по якорю: якорь — это курсор, он ходит и тогда, когда отряд упёрся в
|
||||||
|
// стену, и светил бы фонарь на месте.
|
||||||
|
//
|
||||||
|
// Топтание в строю сюда попадает тоже, но оно на порядок меньше похода
|
||||||
|
// через этаж и на решение не влияет. Считать «только осмысленное движение»
|
||||||
|
// значило бы завести второе понятие пути ради полутора процентов.
|
||||||
|
if (squad.lightCharge > 0.0f)
|
||||||
|
{
|
||||||
|
const float walked = Distance(squad.Centroid(), lightFrom);
|
||||||
|
squad.lightCharge = std::max(0.0f, squad.lightCharge - walked * LightDrain(depth));
|
||||||
|
}
|
||||||
|
|
||||||
// --- 3. ручное прицеливание (6.8) ----------------------------------------
|
// --- 3. ручное прицеливание (6.8) ----------------------------------------
|
||||||
UpdateAim(dt);
|
UpdateAim(dt);
|
||||||
|
|
||||||
|
|||||||
@ -129,6 +129,25 @@ void DrawRunBar(Framebuffer& fb, const Game& game)
|
|||||||
std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried);
|
std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried);
|
||||||
MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW);
|
MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW);
|
||||||
|
|
||||||
|
// Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как
|
||||||
|
// патроны в карточке бойца. Разница одна: кончившийся заряд показывается
|
||||||
|
// СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и
|
||||||
|
// то, кто кого замечает первым, — а «полоски не видно» читалось бы как
|
||||||
|
// «полоски тут и не было».
|
||||||
|
if (game.squad.lightCharge >= 0.0f)
|
||||||
|
{
|
||||||
|
const int ly = 5 + (MICRO_H + 3) * 2;
|
||||||
|
if (game.squad.LightOut())
|
||||||
|
MicroTextShadow(fb, 5, ly, "DARK", RGBA(225, 95, 70), COL_SHADOW);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const float k = Clampf(game.squad.lightCharge / LIGHT_FULL, 0.0f, 1.0f);
|
||||||
|
fb.FillRect(5, ly + 1, 46, 3, RGBA(44, 48, 56));
|
||||||
|
fb.FillRect(5, ly + 1, std::max(1, int(46.0f * k)), 3,
|
||||||
|
(k < 0.25f) ? RGBA(220, 150, 60) : RGBA(232, 214, 150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
|
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
|
||||||
// «нажми F» висела бы на экране весь забег и перестала бы читаться.
|
// «нажми F» висела бы на экране весь забег и перестала бы читаться.
|
||||||
//
|
//
|
||||||
|
|||||||
@ -218,7 +218,11 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
|
|||||||
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
|
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
|
||||||
a.load = inv->Load() + bagShare;
|
a.load = inv->Load() + bagShare;
|
||||||
a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry);
|
a.speedMul = inv->SpeedMul() * CarryMul(a.load, buffs.carry);
|
||||||
a.sightMul = inv->SightMul() + buffs.sight;
|
// Темнота режет ВСЁ, что боец наработал на обзор, — и надетое, и
|
||||||
|
// купленное. Улучшение «обзор» без света не спасает: сел фонарь —
|
||||||
|
// светить нечем, сколько бы отряд ни вложил в глаза.
|
||||||
|
a.sightMul = (inv->SightMul(!buffs.lightOut) + buffs.sight) *
|
||||||
|
(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.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;
|
||||||
@ -227,6 +231,7 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
|
|||||||
{
|
{
|
||||||
a.maxHp = g_tune.agentHP + buffs.hp;
|
a.maxHp = g_tune.agentHP + buffs.hp;
|
||||||
a.load = bagShare;
|
a.load = bagShare;
|
||||||
|
a.sightMul = buffs.lightOut ? DARK_SIGHT_MUL : 1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
|
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);
|
||||||
|
|||||||
@ -164,6 +164,10 @@ struct SquadBuffs
|
|||||||
float sight = 0.0f; // прибавка к множителю обзора
|
float sight = 0.0f; // прибавка к множителю обзора
|
||||||
float hp = 0.0f; // прибавка к запасу здоровья
|
float hp = 0.0f; // прибавка к запасу здоровья
|
||||||
float carry = 0.0f; // прибавка к норме переноски
|
float carry = 0.0f; // прибавка к норме переноски
|
||||||
|
// Фонарь отряда сел: обзор режется до DARK_SIGHT_MUL, а сам фонарь в кармане
|
||||||
|
// перестаёт что-либо давать. По умолчанию false — песочница приёмки светит
|
||||||
|
// всегда (sim/squad.h).
|
||||||
|
bool lightOut = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f,
|
void SyncLoadout(ecs::World& world, Agent& a, float bagShare = 0.0f,
|
||||||
|
|||||||
@ -64,7 +64,8 @@ void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
||||||
std::vector<Target>& targets, float dt, CombatEventLog* events)
|
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
||||||
|
float aggroMul)
|
||||||
{
|
{
|
||||||
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
|
// Захват гаснет САМ, а держащая тварь его продлевает. Так освобождение
|
||||||
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
|
// бойца не требует, чтобы про захват знали пуля, клинок и респавн: убили
|
||||||
@ -151,7 +152,7 @@ void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int age
|
|||||||
|
|
||||||
// --- кого преследуем --------------------------------------------------
|
// --- кого преследуем --------------------------------------------------
|
||||||
float dist = 0.0f;
|
float dist = 0.0f;
|
||||||
const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius, &dist);
|
const int victim = NearestAgent(t.pos, agents, agentCount, t.aggroRadius * aggroMul, &dist);
|
||||||
if (victim >= 0) t.aggro = true; // заметил — уже не забудет
|
if (victim >= 0) t.aggro = true; // заметил — уже не забудет
|
||||||
t.victim = victim;
|
t.victim = victim;
|
||||||
|
|
||||||
|
|||||||
@ -32,8 +32,13 @@
|
|||||||
|
|
||||||
// Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще:
|
// Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще:
|
||||||
// --accept обязан считать ровно то же, что считал.
|
// --accept обязан считать ровно то же, что считал.
|
||||||
|
// aggroMul — во сколько раз дальше твари замечают отряд прямо сейчас. Больше
|
||||||
|
// единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h).
|
||||||
|
// Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они
|
||||||
|
// так же и ходят так же, просто находят раньше.
|
||||||
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
void UpdateEnemies(ecs::World& world, const Tilemap& map, Agent* agents, int agentCount,
|
||||||
std::vector<Target>& targets, float dt, CombatEventLog* events);
|
std::vector<Target>& targets, float dt, CombatEventLog* events,
|
||||||
|
float aggroMul = 1.0f);
|
||||||
|
|
||||||
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
|
// Сколько тварей сейчас идёт к отряду. Для напряжения и HUD.
|
||||||
int CountAggroEnemies(const std::vector<Target>& targets);
|
int CountAggroEnemies(const std::vector<Target>& targets);
|
||||||
|
|||||||
@ -55,6 +55,7 @@ enum class ItemId : uint8_t
|
|||||||
BANDAGE,
|
BANDAGE,
|
||||||
STIMPACK,
|
STIMPACK,
|
||||||
AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем
|
AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем
|
||||||
|
BATTERY, // батарея: уходит в фонарь, когда тот сел
|
||||||
|
|
||||||
COUNT,
|
COUNT,
|
||||||
};
|
};
|
||||||
@ -76,6 +77,7 @@ struct ItemDef
|
|||||||
|
|
||||||
float heal = 0.0f; // расходник: сколько здоровья возвращает
|
float heal = 0.0f; // расходник: сколько здоровья возвращает
|
||||||
int rounds = 0; // расходник: сколько патронов добавляет в запас
|
int rounds = 0; // расходник: сколько патронов добавляет в запас
|
||||||
|
float charge = 0.0f; // расходник: сколько секунд света возвращает
|
||||||
|
|
||||||
// Сколько предмет весит. Единица условная, но соотношения — нет: плита
|
// Сколько предмет весит. Единица условная, но соотношения — нет: плита
|
||||||
// тяжелее всего, что можно взять в руки, а карманное почти ничего не весит.
|
// тяжелее всего, что можно взять в руки, а карманное почти ничего не весит.
|
||||||
@ -147,6 +149,48 @@ inline float CarryMul(float load, float bonus = 0.0f)
|
|||||||
return 1.0f + (CARRY_SLOW - 1.0f) * (load - limit) / (top - limit);
|
return 1.0f + (CARRY_SLOW - 1.0f) * (load - limit) / (top - limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Свет. ВТОРОЙ ресурс вылазки после патронов.
|
||||||
|
//
|
||||||
|
// ГЛАВНОЕ РЕШЕНИЕ: заряд тратится за ПРОЙДЕННОЕ РАССТОЯНИЕ, а не за время.
|
||||||
|
//
|
||||||
|
// Расход по секундам был бы таймером — тем самым, которого в этой игре нет по
|
||||||
|
// прямому требованию к жанру (docs/17-extraction.md): давит исчерпание, а не
|
||||||
|
// часы. Фонарь, садящийся сам по себе, торопил бы игрока ровно как таймер,
|
||||||
|
// только без циферблата: думать стало бы дорого, а думать здесь и есть игра.
|
||||||
|
//
|
||||||
|
// За расстояние — совсем другое решение и ровно то, которое нужно: стоять и
|
||||||
|
// разглядывать этаж бесплатно, а вот ОБОЙТИ его весь ради лута стоит света.
|
||||||
|
// Тот же выбор, что и вес, только с другой стороны: «унести всё» стоит
|
||||||
|
// скорости, «обшарить всё» стоит темноты. Прямой путь к спуску дёшев, полная
|
||||||
|
// зачистка — дорога.
|
||||||
|
//
|
||||||
|
// Сел фонарь — обзор падает до DARK_SIGHT_MUL, а твари начинают замечать отряд
|
||||||
|
// дальше. Отряд не слепнет насовсем (иначе игра просто кончалась бы) — он
|
||||||
|
// меняется с тварями местами: теперь первым видит не он.
|
||||||
|
//
|
||||||
|
// Заряд считается ПО ОТРЯДУ, а не по фонарю в кармане каждого: отряд ходит
|
||||||
|
// одной кучей, и пять личных фонарей были бы пятью копиями одного числа.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Единица заряда — тайл пути на первом этаже. Число выставлено по РАЗМЕРУ
|
||||||
|
// ЭТАЖА, а не на глаз: путь до спуска на первых этажах — шесть-восемь десятков
|
||||||
|
// тайлов, к восьмому — полторы сотни. Полного заряда должно хватать на этаж с
|
||||||
|
// осмотром и НЕ хватать на несколько этажей подряд, иначе свет ничего не
|
||||||
|
// связывает. Проверяется в `--descend`: там он сравнивается с длиной пути до
|
||||||
|
// спуска на текущей глубине.
|
||||||
|
constexpr float LIGHT_FULL = 420.0f;
|
||||||
|
constexpr float DARK_SIGHT_MUL = 0.35f; // во что превращается обзор без света
|
||||||
|
constexpr float DARK_AGGRO_MUL = 1.35f; // насколько дальше твари замечают в темноте
|
||||||
|
|
||||||
|
// Сколько заряда съедает один тайл пути. Чем глубже, тем дороже шаг: на сотом
|
||||||
|
// этаже полного заряда хватает вдвое меньше, чем на первом, — а сами этажи там
|
||||||
|
// больше. Расход растёт с глубиной по той же причине, что и всё остальное:
|
||||||
|
// иначе к лейтгейму свет перестал бы быть ресурсом.
|
||||||
|
inline float LightDrain(int depth)
|
||||||
|
{
|
||||||
|
return 1.0f + 0.012f * float(depth > 1 ? depth : 1);
|
||||||
|
}
|
||||||
|
|
||||||
inline ItemDef ItemDefOf(ItemId id)
|
inline ItemDef ItemDefOf(ItemId id)
|
||||||
{
|
{
|
||||||
ItemDef d;
|
ItemDef d;
|
||||||
@ -246,6 +290,10 @@ inline ItemDef ItemDefOf(ItemId id)
|
|||||||
d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS;
|
d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS;
|
||||||
d.weight = 1.0f;
|
d.weight = 1.0f;
|
||||||
d.r = 200; d.g = 176; d.b = 96; break;
|
d.r = 200; d.g = 176; d.b = 96; break;
|
||||||
|
case ItemId::BATTERY:
|
||||||
|
d.name = "BATTERY"; d.kind = ItemKind::CONSUMABLE; d.charge = LIGHT_FULL;
|
||||||
|
d.weight = 0.3f;
|
||||||
|
d.r = 150; d.g = 210; d.b = 200; break;
|
||||||
|
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
@ -357,7 +405,17 @@ struct Inventory
|
|||||||
ItemDefOf(weapon).speedMul;
|
ItemDefOf(weapon).speedMul;
|
||||||
}
|
}
|
||||||
float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; }
|
float HpBonus() const { return ItemDefOf(armor).hpBonus + ItemDefOf(trinket).hpBonus; }
|
||||||
float SightMul() const { return ItemDefOf(armor).sightMul * ItemDefOf(trinket).sightMul; }
|
|
||||||
|
// lanternLit = у отряда ещё есть заряд. Погасший фонарь не даёт НИЧЕГО:
|
||||||
|
// без этого он продолжал бы светить на треть от своих 35% и «сел» значило
|
||||||
|
// бы «стал слабее», а не «кончился».
|
||||||
|
float SightMul(bool lanternLit = true) const
|
||||||
|
{
|
||||||
|
const float tr = (!lanternLit && trinket == ItemId::LANTERN)
|
||||||
|
? 1.0f
|
||||||
|
: ItemDefOf(trinket).sightMul;
|
||||||
|
return ItemDefOf(armor).sightMul * tr;
|
||||||
|
}
|
||||||
|
|
||||||
// Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи
|
// Что боец несёт САМ — ствол в руках. Доля общей сумки прибавляется снаружи
|
||||||
// (SyncLoadout): инвентарь про отряд не знает.
|
// (SyncLoadout): инвентарь про отряд не знает.
|
||||||
|
|||||||
@ -46,7 +46,7 @@ const LootRow LOOT_ROWS[] = {
|
|||||||
{"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD},
|
{"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD},
|
||||||
{"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM},
|
{"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM},
|
||||||
{"spurs", ItemId::SPURS},
|
{"spurs", ItemId::SPURS},
|
||||||
{"ammo", ItemId::AMMO},
|
{"ammo", ItemId::AMMO}, {"battery", ItemId::BATTERY},
|
||||||
{"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK},
|
{"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,17 @@ struct Squad
|
|||||||
// как одно целое (sim/items.h). Личным остаётся только надетое.
|
// как одно целое (sim/items.h). Личным остаётся только надетое.
|
||||||
SquadBag bag;
|
SquadBag bag;
|
||||||
|
|
||||||
|
// Сколько заряда фонаря осталось. Ресурс отряда, а не бойца, и тратится он
|
||||||
|
// за ПРОЙДЕННЫЙ ПУТЬ, а не за время: таймера в игре нет (sim/items.h).
|
||||||
|
//
|
||||||
|
// -1 означает «не садится» и стоит в ПЕСОЧНИЦЕ приёмки — по той же причине,
|
||||||
|
// по которой там бесконечны патроны: критерии меряют огневую работу, а не
|
||||||
|
// то, на сколько минут хватило фонаря.
|
||||||
|
float lightCharge = -1.0f;
|
||||||
|
|
||||||
|
// Фонарь сел. Отрицательный заряд — это песочница, там темноты нет.
|
||||||
|
bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; }
|
||||||
|
|
||||||
// Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего.
|
// Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего.
|
||||||
int Carriers() const
|
int Carriers() const
|
||||||
{
|
{
|
||||||
|
|||||||
@ -273,6 +273,7 @@ Tip ItemTip(ItemId id)
|
|||||||
if (d.sightMul != 1.0f) t.Add("SIGHT %+d%%", int((d.sightMul - 1.0f) * 100.0f));
|
if (d.sightMul != 1.0f) t.Add("SIGHT %+d%%", int((d.sightMul - 1.0f) * 100.0f));
|
||||||
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));
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user