Свет как расходник: заряд тратится за путь, а не за время (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:
z.kirill 2026-08-15 22:12:05 +03:00
parent c1831ac370
commit 0fb6e31c2b
20 changed files with 428 additions and 20 deletions

View File

@ -54,6 +54,10 @@
`squad_proto.exe --descend 8` (спуск, эвакуация, круговой прогон профиля).
- **Унести можно только то, что на ВЫЖИВШИХ.** Павший теряет своё, вайп теряет всё,
достигнутая глубина не теряется никогда ([17-extraction.md](docs/17-extraction.md)).
- **Таймера в игре нет и не заводить.** Фонарь тратит заряд за ПРОЙДЕННЫЙ ПУТЬ,
а не за время: стоять и думать бесплатно, дорого — обшарить этаж целиком.
Расход со `dt` вместо пути — это таймер, и `--descend` его ловит
([17-extraction.md](docs/17-extraction.md)).
- **Закон масштаба:** угроза растёт квадратично, сила отряда — линейно с
затуханием, разрыв закрывается ЗНАНИЕМ (стихии, реакции, порядок целей).
Числа не должны спасать — иначе хоррор кончается на сороковом этаже

View File

@ -130,7 +130,7 @@ cmake -S . -B build -G "Visual Studio 17 2022" -A x64
| `--view <iso\|topdown>` | перебить вид отображения из манифеста проекта (отладочное) |
| `--seed N` | сид сборки уровня (по умолчанию 1) |
| `--depth N` | начать не с первого этажа (для проверок) |
| `--descend N` | прогон забега на N этажей без окна |
| `--descend N` | прогон забега на N этажей без окна: спуск, сумки, вес, свет, эвакуация, профиль |
| `--tool <команда>` | балансовые инструменты без окна: `weapons`, `enemies`, `ttk`, `economy` |
| `--profile <файл>` | где лежит профиль: схрон, снаряжение, чекпоинты |
| `--no-profile` | не читать и не писать профиль |

View File

@ -34,6 +34,8 @@
| **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)) |
## Что находилось по ходу и чинилось
Прогоны без окна оказались полезнее, чем разглядывание кадра: три дефекта,

View File

@ -182,7 +182,11 @@
| SHROUD | 4% | 112% | обзор 92% |
В карман: LANTERN (обзор 135%), CHARM (+30 HP), SPURS (скорость 114%).
Расходники: BANDAGE, STIM.
Расходники: BANDAGE, STIM, AMMO, BATTERY.
Фонарь — единственная вещь с ЗАРЯДОМ: он садится за пройденный путь, и погасший
не даёт ничего. Заряд общий на отряд, батарея встаёт сама
([17-extraction.md](17-extraction.md)).
Комплекты раздаются по СЛОТУ СТРОЯ, а не «персонажу» (`Game::EquipKits`):
остриё клина берёт плиту и тесак, хвост — лёгкую броню и дальний ствол. Состав
@ -217,6 +221,11 @@ HP, броню, скорость, обзор. `ENTER` меняет местам
- уголок над карточкой — боец при цели;
- карточка перечёркнута — боец упал.
Исключение одно и на весь HUD — строка забега сверху: этаж, опыт, добыча и
заряд фонаря. Номер этажа формой не покажешь. Погасший фонарь там показан
СЛОВОМ `DARK`, а не полосой нулевой длины: темнота меняет правила, а
«полоски не видно» читалось бы как «полоски тут и не было».
## Смерть бойца
Мёртвый агент остаётся в массиве: `NUM_AGENTS` — величина времени сборки, по

View File

@ -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`). Для спуска этого

View File

@ -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% обзора снимаются вместе с зарядом.
Иначе «сел» означало бы «стал слабее», а не «кончился».
## Во что вкладывается опыт
![экран улучшений](ui-upgrades.png)
@ -234,6 +270,9 @@ squad_proto.exe --descend 7 --no-profile
```
расходники: патроны набиты, лечение сработало (33 -> 58), из сумки ушли обе
перезарядка: запас 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
```
@ -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
эвакуация: вынесено 40, потеряно 0, выживших 5, чекпоинт D6

View File

@ -295,16 +295,34 @@ DECAY 6.2 3.1 0
## 7. Свет как ресурс
Хоррор-часть, которая сейчас не сделана: фонарь даёт множитель обзора и не
садится. Должен садиться.
Сделано в M23, но **не по формуле, которая стояла здесь раньше**. В ней расход
считался от времени:
```
заряд(t) = заряд₀ drain·t, drain = 1/сек
радиус = base · (0.35 + 0.65 · заряд/заряд₀)
заряд(t) = заряд₀ drain·t, drain = 1/сек <- ТАК НЕ СДЕЛАНО
```
Батареи — расходник (вес 0.3), заменяются автоматически при разряде, как бинты.
Кончились — отряд идёт в темноте: обзор 35%, твари замечают первыми.
Это таймер. Игра заявлена без таймера намеренно
([17-extraction.md](17-extraction.md)): давить обязано исчерпание, а не часы.
Фонарь, садящийся сам по себе, торопил бы игрока ровно как секундомер, только
без циферблата, — и думать над этажом стало бы дорого.
Как сделано:
```
заряд ← заряд путь · drain(глубина), drain = 1 + 0.012·глубина
```
Путь — смещение центра отряда за шаг. Стоять бесплатно, идти дорого. Полного
заряда (420) хватает примерно на четыре прямых пути до спуска, то есть на этаж
с осмотром и не больше.
Радиус не тает плавно вместе с зарядом, как предполагала старая формула:
фонарь либо светит полностью, либо не светит вовсе (обзор 35%, твари замечают в
1.35 раза дальше). Плавное угасание — это «стало чуть хуже» пять минут подряд,
и заметить момент, когда пора наверх, по нему нельзя. Обрыв заметен сразу.
Батареи — расходник (вес 0.3), встают в фонарь сами при разряде, как бинты.
Это второй после патронов ресурс, который гонит наверх, и он же — причина, по
которой ветвь Разведки в дереве узлов не «приятная», а обязательная.

View File

@ -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 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 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

View File

@ -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 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 rifle class=loot tier=1 depth=2 radius=0.26 color=208,196,150 sprite=item_icon_rifle name=CARBINE

View File

@ -633,9 +633,16 @@ int RunDescend(const RoomLibrary& rooms, const EntityCatalog& catalog, uint32_t
"EXIT", "SQUAD");
int bad = 0;
// Средний путь до спуска по всем этажам забега. Нужен свету: сравнивать
// заряд с длиной ОДНОГО этажа — значит мерить не баланс, а везение сида,
// а этажи одной глубины различаются вдвое.
int exitSum = 0;
int exitCount = 0;
for (int i = 0; i < floors; ++i)
{
const GeneratedLevel& l = game.level;
if (l.exitDistance > 0) { exitSum += l.exitDistance; ++exitCount; }
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);
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,
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.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 float xpEarned = game.xp;
// -------------------------------------------------------------------------
// Опыт тратится. Проверяется цепочка целиком: купили — подействовало на
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
// влияет, выглядит точно так же, как работающее улучшение.
//
// Стоит В КОНЦЕ намеренно: проверка начинает новую вылазку, а это сбрасывает
// глубину и добычу. Стоя выше, она превращала восьмиэтажный забег в
// одноэтажный, и отчёт об эвакуации начинал врать — не потому что игра
// сломана, а потому что её сломал измеряющий.
// отряд — пережило запись профиля. Число в профиле, которое ни на что не
// влияет, выглядит точно так же, как работающее улучшение.
// -------------------------------------------------------------------------
{
Profile& pr = *game.profile;

View File

@ -323,6 +323,12 @@ void Game::BuildFloor(const SquadCarry* carry)
// правила стрельбы, а не то, на сколько минут хватило патронов.
for (int i = 0; i < NUM_AGENTS; ++i)
squad.agents[i].ammoReserve = combatMode ? int(g_tune.startAmmo + ammoBonus) : -1;
// Свежий фонарь выдаётся на ВЫЛАЗКУ, а не на этаж: заряд переезжает
// вниз вместе с отрядом (Squad живёт дольше уровня), и в этом весь
// смысл — на четвёртом этаже светить уже почти нечем.
// В песочнице приёмки света бесконечно (-1), как и патронов.
squad.lightCharge = combatMode ? LIGHT_FULL : -1.0f;
}
lanes.Clear();
@ -417,6 +423,8 @@ SolverContext Game::MakeContext() const
// Лечиться можно только ВНЕ схватки: перевязка посреди боя обесценила бы урон,
// который тварь только что нанесла. Патроны — наоборот, набиваются когда угодно:
// боец с пустым стволом и пачкой в сумке, стоящий столбом, выглядел бы поломкой.
// Батарея ставится ровно в тот шаг, когда фонарь сел: держать её в сумке при
// погасшем свете отряду незачем, а темнота ради темноты — не решение игрока.
// -----------------------------------------------------------------------------
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 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 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 (loadsNow) a.ammoReserve += d.rounds;
if (lightsNow) squad.lightCharge = d.charge;
squad.bag.slots[s] = ItemId::NONE;
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());
SquadBuffs buffs;
buffs.sight = squad.sightBonus;
buffs.hp = squad.hpBonus;
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)
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);
// Твари ходят и бьют ДО того, как отряд принимает решения: боец реагирует
// на то, что видит сейчас, а не на прошлый кадр.
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. якорь и состояние отряда -----------------------------------------
// Экранные оси ввода -> мировое направление. Дальше изометрии нет нигде.
@ -508,8 +526,23 @@ void Game::Step(float dt)
squad.UpdateAnchorAndState(moveWorld, in.sprint, map, dt);
// --- 2. движение (реестр линий — с предыдущего шага, задержка 1/60 с) -----
const Vec2 lightFrom = squad.Centroid();
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) ----------------------------------------
UpdateAim(dt);

View File

@ -129,6 +129,25 @@ void DrawRunBar(Framebuffer& fb, const Game& game)
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);
// Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как
// патроны в карточке бойца. Разница одна: кончившийся заряд показывается
// СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и
// то, кто кого замечает первым, — а «полоски не видно» читалось бы как
// «полоски тут и не было».
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» висела бы на экране весь забег и перестала бы читаться.
//

View File

@ -218,7 +218,11 @@ void SyncLoadout(ecs::World& world, Agent& a, float bagShare, const SquadBuffs&
// пришлось бы везде, где скорость читается, а смысла в этом ноль.
a.load = inv->Load() + bagShare;
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.held = inv->weapon;
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.load = bagShare;
a.sightMul = buffs.lightOut ? DARK_SIGHT_MUL : 1.0f;
}
const WeaponItem* item = world.Get<WeaponItem>(a.weaponEntity);

View File

@ -164,6 +164,10 @@ struct SquadBuffs
float sight = 0.0f; // прибавка к множителю обзора
float hp = 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,

View File

@ -64,7 +64,8 @@ void HurtAgent(Agent& a, int index, float damage, Vec2 dir, CombatEventLog* even
} // namespace
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;
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; // заметил — уже не забудет
t.victim = victim;

View File

@ -32,8 +32,13 @@
// Шевелит всех, у кого kind != DUMMY. Мишени приёмки не трогает вообще:
// --accept обязан считать ровно то же, что считал.
// aggroMul — во сколько раз дальше твари замечают отряд прямо сейчас. Больше
// единицы бывает ровно в одном случае: у отряда сел фонарь (sim/items.h).
// Радиус ЗАМЕТИЛ — единственное, что темнота меняет на стороне тварей: бьют они
// так же и ходят так же, просто находят раньше.
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.
int CountAggroEnemies(const std::vector<Target>& targets);

View File

@ -55,6 +55,7 @@ enum class ItemId : uint8_t
BANDAGE,
STIMPACK,
AMMO, // пачка патронов: уходит в подсумок, когда стрелять нечем
BATTERY, // батарея: уходит в фонарь, когда тот сел
COUNT,
};
@ -76,6 +77,7 @@ struct ItemDef
float heal = 0.0f; // расходник: сколько здоровья возвращает
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);
}
// -----------------------------------------------------------------------------
// Свет. ВТОРОЙ ресурс вылазки после патронов.
//
// ГЛАВНОЕ РЕШЕНИЕ: заряд тратится за ПРОЙДЕННОЕ РАССТОЯНИЕ, а не за время.
//
// Расход по секундам был бы таймером — тем самым, которого в этой игре нет по
// прямому требованию к жанру (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)
{
ItemDef d;
@ -246,6 +290,10 @@ inline ItemDef ItemDefOf(ItemId id)
d.name = "AMMO"; d.kind = ItemKind::CONSUMABLE; d.rounds = AMMO_BOX_ROUNDS;
d.weight = 1.0f;
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;
}
@ -357,7 +405,17 @@ struct Inventory
ItemDefOf(weapon).speedMul;
}
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): инвентарь про отряд не знает.

View File

@ -46,7 +46,7 @@ const LootRow LOOT_ROWS[] = {
{"plate", ItemId::PLATE}, {"shroud", ItemId::SHROUD},
{"lantern", ItemId::LANTERN}, {"charm", ItemId::CHARM},
{"spurs", ItemId::SPURS},
{"ammo", ItemId::AMMO},
{"ammo", ItemId::AMMO}, {"battery", ItemId::BATTERY},
{"bandage", ItemId::BANDAGE}, {"stimpack", ItemId::STIMPACK},
};

View File

@ -53,6 +53,17 @@ struct Squad
// как одно целое (sim/items.h). Личным остаётся только надетое.
SquadBag bag;
// Сколько заряда фонаря осталось. Ресурс отряда, а не бойца, и тратится он
// за ПРОЙДЕННЫЙ ПУТЬ, а не за время: таймера в игре нет (sim/items.h).
//
// -1 означает «не садится» и стоит в ПЕСОЧНИЦЕ приёмки — по той же причине,
// по которой там бесконечны патроны: критерии меряют огневую работу, а не
// то, на сколько минут хватило фонаря.
float lightCharge = -1.0f;
// Фонарь сел. Отрицательный заряд — это песочница, там темноты нет.
bool LightOut() const { return lightCharge >= 0.0f && lightCharge <= 0.0f; }
// Сколько живых делят ношу. Ноль живых — отряда нет, и делить нечего.
int Carriers() const
{

View File

@ -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.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.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge));
return t;
}