squad-proto/docs/10-atmosphere.md
z.kirill 7880f275bd Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (raylib только окно, ввод и финальный блит).

Ядро (спек, раздел 6):
- LaneClear: проверка линии огня по своим, стенам и дальности;
- решатель огневых позиций с гистерезисом и коммитом;
- резервирование линий огня и расступание из чужих секторов;
- пули-снаряды, дружественный урон физически возможен.

Хоррор-слой (render-only): свет и туман войны с памятью карты,
светящиеся трассеры, кровь, виньетка, зерно, напряжение.

Ближний бой: сектор удара, три фазы, связка из трёх ударов,
своя дисциплина «не бить сквозь своего».

Проверка: squad_proto.exe --accept прогоняет критерии приёмки
раздела 11 плюс блок M7 по ближнему бою — все PASS,
FRIENDLY_HITS = 0 за 3 минуты боя.

Сборка: cmake -S . -B build && cmake --build build --config Release
raylib 6.0 подтягивается через FetchContent.

Документация — CLAUDE.md как оглавление, содержание в docs/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:00:49 +03:00

201 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 10 — Хоррор-слой: свет, туман войны, трассеры, напряжение
Весь этот слой — **render-only**. Симуляция о нём не знает: `RenderGame` принимает
`const Game&`, поэтому ни свет, ни трассеры, ни тряска камеры физически не могут
изменить состояние симуляции. Headless-прогоны (`--accept`, `--headless`) `View`
не создают вообще, и цифры приёмки остаются цифрами симуляции.
> Раздел 12 исходного спека запрещал освещение и туман войны. Заказчик изменил
> требования — теперь это ядро подачи. Правила стрельбы (раздел 6) не тронуты.
## Порядок кадра
```
Clear(чёрный) ступень 0 рампы == цвет очистки
-> свет и туман (Lighting::Update)
-> пол: BlitShaded, тёмные тайлы пропускаются
-> список сортировки: стены, мишени, агенты — тоже BlitShaded
-> глаза мишеней (аддитивно)
-> трассеры, вспышки дула, искры (аддитивно)
-> ОДИН слитый пост-проход: виньетка, зерно, сердцебиение
-> Present
-> debug-оверлей (raylib, dev-only, F1)
```
## Почему свет вплавлен в блит, а не сделан пост-проходом
Соблазн очевидный: пройти по 129600 пикселям, для каждого получить мировую точку
через `iso::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
Спрайт стены имеет привязку `ay = TILE_HALF_H + TILE_H = 24`, то есть верхняя
грань поднята на 16 px над ромбом своего тайла. Обратная проекция этого пикселя
даёт `ScreenToWorld(0, -16) = (-1, -1)`**ровно соседний тайл на северо-запад**.
Пост-проход осветил бы крыши стен чужой видимостью: чёрные крыши на освещённых
стенах и светящийся ореол на тайл северо-западнее каждой стены. Чинится это
только буфером высот на пиксель, то есть честным depth-буфером, которого в
проекте нет и заводить его ради этого не стоит.
Поэтому свет модулирует цвет **на этапе блита спрайта**: спрайт целиком светится
от своего тайла, и артефакт исчезает конструктивно, без единой проверки.
## Модель света
Одна скалярная величина на точку мира плюс **рампа** — таблица
`g_shadeLut[32][3][256]`, переводящая уровень в цвет (подход COLORMAP из Doom).
Цветокоррекция зашита в таблицу: в полумраке картинка уходит в холодный синий,
на свету нейтральна, во вспышке выбивается в тёплый пересвет. На пиксель
остаются три выборки из таблицы и ноль умножений.
```
tile = min(1, max(память, видимость * sightBrightness) + вспышки)
```
| Состояние | Значение | Как выглядит |
|---|---|---|
| никогда не видели | 0 | чистый чёрный, тайл вообще не рисуется |
| видели раньше | `fogMemoryLevel` | тускло и холодно, сущностей нет |
| видно сейчас | `sightBrightness` | нормальная яркость |
| вспышка выстрела | выше | тёплый пересвет |
Диапазон выше `sightBrightness` намеренно зарезервирован под динамический свет:
выстрел всегда «выбивает» кадр вверх и в тепло.
### Билинейка без швов
Узловая сетка 49×49 (`corner`), узел = среднее смежных тайлов. Патч света на
тайл раскладывается в координатах спрайта без перекрёстного члена:
```
Bu = c10-c00, Bv = c01-c00, K = c00-c10-c01+c11
a = c00 - (Bu-Bv)/2 - K/4 b = (Bu-Bv+K)/TILE_W c = (Bu+Bv)/TILE_H
e = -K/(TILE_W*TILE_W) f = K/(TILE_H*TILE_H)
L(px,py) = a + b*px + c*py + e*px^2 + f*py^2
```
Это **точная** билинейка по четырём вершинам ромба: подстановка (16,0), (32,8),
(0,8), (16,16) даёт ровно c00, c10, c01, c11. Значит на общем ребре двух соседних
тайлов значения совпадают и швов нет. По строке считается прямыми разностями —
во внутреннем цикле два сложения на пиксель.
Блочность добивают четыре вещи: узловая сетка, точная билинейка, временное
сглаживание (`fogFadeRate`) и упорядоченный дизеринг Байера 4×4 — 32 ступени
рампы выглядят как ~200, полос не видно.
## Видимость
Для каждого агента перебираются тайлы в квадрате радиуса обзора; для каждого —
затухание по дальности × max(конус взгляда, круговой «личный» ореол), затем
проверка LOS уже существующим `Tilemap::RaycastBlocked`. Рейкаст делается
последним: дальность и конус отсекают большинство тайлов даром.
Стену нельзя проверить лучом до её центра — DDA считает вход в саму стену
перекрытием. Поэтому стены освещаются отдельным проходом: берут максимум от
соседей-нестен, `WALL_LIGHT_PASSES = 3` раза с затуханием. Без этого разделитель
`y=13..15` толщиной три тайла остаётся чёрным изнутри, и его южная грань висит
в пустоте.
**Инвариант:** стена никогда не темнее самого яркого соседа-пола. Иначе тёмная
стена будет пропущена как «ступень 0», и сквозь её силуэт проступит освещённый
пол тайла (tx1, ty1), который рисуется раньше по глубине.
### Конус зрения и ИИ
Конус — **чисто игроцкая условность**. ИИ по-прежнему работает по LOS и дальности
оружия, `LaneClearFrom` не изменён. Если завязать выбор целей на видимость,
поменяются все критерии приёмки, а отряд начнёт «слепнуть» из-за поворота ствола.
Чтобы стрельба в темноту не выглядела абсурдом, мишень показывается, если она
освещена **или** если по ней уже работает кто-то из отряда.
## Трассеры
Физика пули не тронута: снаряд летит ровно по проверенному отрезку, правило
«не стрелять сквозь своих» и `FRIENDLY_HITS = 0` не задеты. Менялся только вид.
- **штрих** от `pos` назад по вектору движения на `tracerLength`, но не дальше
точки вылета — `AddBeam` рисует ядро с градиентом цвета и боковым ореолом;
- **вспышка у дула** — `AddGlowDisc` + короткий язык пламени, `muzzleTime`;
- **искра попадания** — своя для стены, цели, убийства;
- **пурпурная искра** = попадание по своему. Это не украшение, а индикатор:
в норме её не должно появиться ни разу.
Всё аддитивно (`AddSat`), поэтому светится и поверх темноты — трассер сам себе
источник света.
## Дуга клинка
`DrawMeleeArcs` в [src/render/fx.cpp](../src/render/fx.cpp). В отличие от
трассеров, дуга читается **прямо из состояния агентов**, а не из журнала событий:
фаза удара живёт десятые доли секунды и попадает в каждый кадр, а привязка к
живой позиции важнее — боец в момент удара едет вперёд рывком.
- **замах** — холодная стальная дуга уводится назад за край сектора и
разгорается. Это телеграф, и он обязан быть виден;
- **удар** — серп из четырёх концентрических дуг: внешний край раскалён,
внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре —
взмахом. Хвост — доля пройденной дуги, а не вся: серп, а не кольцо;
- **восстановление** — серп стоит на месте и быстро гаснет.
В изометрии круг — эллипс, поэтому дуга набирается сэмплами **мировых** точек
(`AddWorldArc`), а не экранной окружностью.
Через журнал идут только дискретные события: `MELEE_SWING` (вспышка света),
`MELEE_HIT` (оранжевая искра, сильная отдача камеры — удар всегда в упор),
`MELEE_ABORT` (тусклая серая вспышка: свой влез под лезвие). Клинок дульной
вспышки не даёт, но искры от удара освещают не хуже — иначе весь ближний бой
шёл бы в кромешной темноте.
Подробности механики — [11-melee.md](11-melee.md).
## Канал «симуляция → эффекты»
`sim/events.h`: кольцевой журнал `CombatEventLog`. Симуляция только пишет,
правила не читают, в headless передаётся `nullptr`.
Почему журнал, а не флаг `Agent::fired`: за один кадр рендера проходит **до
`MAX_STEPS_PER_FRAME` шагов симуляции**. Флаг, выставленный на шаге 1 и затёртый
на шаге 2, эффект не увидит. По той же причине `Dread` считает дельты
накопительных счётчиков метрик, а не смотрит на флаги.
## Напряжение (Dread)
Складывается из близости мишени, близости **невидимой** мишени (страшнее всего),
толпы и того, сколько агентов вообще заняты боем. «Занят» — это линия огня у
стрелка или цель в зоне удара у клинка: боец, достающий врага, беспомощным не
выглядит и напряжение снимает так же. Сглаживание асимметричное: страх набегает
быстро (`dreadAttack`), отпускает медленно (`dreadRelease`).
Кормит: глубину виньетки, амплитуду зерна, частоту сердцебиения, отдачу камеры.
Отдача добавляется к `scene.ox/oy` сразу после `scene.Begin` — только там она
согласована и с полом, и со списком сортировки, и с оверлеем.
## Пост-проход
Один слитый цикл по 129600 пикселям: виньетка (статическое поле + таблица кадра),
зерно с амплитудой обратно яркости (в тенях шумит, на свету чисто), давление
сердцебиения. Света на входе **нет** — он уже вплавлен на блите.
Границы: индекс таблицы насыщения равен `128 + канал + шум`, поэтому амплитуда
зерна зажата 127 — иначе в Release пошли бы случайные яркие точки, которые
легко принять за художественный замысел.
## Псевдослучайность
`render/noise.h` — единственное место. Чистые хеш-функции без состояния:
значение зависит только от координаты и номера кадра. Общего генератора нет,
синхронизировать нечего, детерминизм симуляции не задет.
## Цена
Замер на Intel Iris Xe, Release: **1.43.5 мс** на кадр при бюджете 16.6 мс,
то есть 821 %. Верхняя граница — когда память карты уже разведана и освещённых
тайлов много; fps при этом держится 60. Симуляция — 1.9 мкс. Ни одна из оптимизаций (предзатенённые
спрайты, пересчёт видимости через кадр, уменьшение числа ступеней рампы) не нужна.
Самое дорогое — не пост-проход, а `BlitShaded` сцены. Пропуск полностью тёмных
тайлов это частично компенсирует: в тумане рисуется заметно меньше половины карты.
## Аварийные тумблеры
`F5` — выключить весь хоррор-слой (свет, туман, пост) и вернуть прежний кадр.
`F6` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.