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>
201 lines
15 KiB
Markdown
201 lines
15 KiB
Markdown
# 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 пикселям, для каждого получить мировую точку
|
||
через `v2d::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` — показать диски обзора. Полезно, когда непонятно, баг это или темнота.
|