squad-proto/docs/10-atmosphere.md

201 lines
15 KiB
Markdown
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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 17:00:49 +07:00
# 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 пикселям, для каждого получить мировую точку
Документация: границы движка, работа через модель, дизайн игры до лейтгейма 18-engine.md границы Tile2D, проекции 2D, формат проекта, второй проект 19-agent.md инструменты --tool, скиллы, терминал в редакторе 20-gdd.md акты, петли, рост отряда, закон масштаба, бюджет 200 часов 21-damage.md семь стихий, статусы, десять реакций, формулы, триггеры 22-loot.md редкости, аффиксы, артефакты, зачарования, материалы 23-endgame.md Бездна, мутации, лидерборды, 262 ачивки Плюс правки 01/02/07/15/16/17 под переехавшие пути и новые правила забега. Дизайнерская часть держится на одном законе: угроза растёт квадратично, сила отряда — линейно с затуханием, разрыв закрывается ЗНАНИЕМ, а не числами. Из него выведены и экономика опыта (41 млн против 44 млн дохода), и то, почему реакции считаются от глубины, а не от урона оружия, и почему в Бездне множитель HP разрешён, а во всём авторском контенте запрещён. .claude/commands: скиллы /tile2d, /room, /floor — правила этого движка, которые нельзя вывести из кода. Главное записано первым: не отчитываться об успехе без прохода --tool validate. Числа в этих документах — вход для инструментов, а не украшение. Расхождение между таблицей и выводом инструмента означает, что неправ документ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:45 +07:00
через `v2d::ScreenToWorld` и умножить на освещённость. Так делать нельзя.
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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 17:00:49 +07:00
Спрайт стены имеет привязку `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` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.