# 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», и сквозь её силуэт проступит освещённый пол тайла (tx−1, ty−1), который рисуется раньше по глубине. ### Конус зрения и ИИ Конус — **чисто игроцкая условность**. ИИ по-прежнему работает по 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.4–3.5 мс** на кадр при бюджете 16.6 мс, то есть 8–21 %. Верхняя граница — когда память карты уже разведана и освещённых тайлов много; fps при этом держится 60. Симуляция — 1.9 мкс. Ни одна из оптимизаций (предзатенённые спрайты, пересчёт видимости через кадр, уменьшение числа ступеней рампы) не нужна. Самое дорогое — не пост-проход, а `BlitShaded` сцены. Пропуск полностью тёмных тайлов это частично компенсирует: в тумане рисуется заметно меньше половины карты. ## Аварийные тумблеры `F5` — выключить весь хоррор-слой (свет, туман, пост) и вернуть прежний кадр. `F6` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.