squad-proto/docs/10-atmosphere.md
z.kirill 4382af2322 Документация: границы движка, работа через модель, дизайн игры до лейтгейма
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-13 21:07:45 +03:00

15 KiB
Raw Blame History

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», и сквозь её силуэт проступит освещённый пол тайла (tx1, ty1), который рисуется раньше по глубине.

Конус зрения и ИИ

Конус — чисто игроцкая условность. ИИ по-прежнему работает по LOS и дальности оружия, LaneClearFrom не изменён. Если завязать выбор целей на видимость, поменяются все критерии приёмки, а отряд начнёт «слепнуть» из-за поворота ствола. Чтобы стрельба в темноту не выглядела абсурдом, мишень показывается, если она освещена или если по ней уже работает кто-то из отряда.

Трассеры

Физика пули не тронута: снаряд летит ровно по проверенному отрезку, правило «не стрелять сквозь своих» и FRIENDLY_HITS = 0 не задеты. Менялся только вид.

  • штрих от pos назад по вектору движения на tracerLength, но не дальше точки вылета — AddBeam рисует ядро с градиентом цвета и боковым ореолом;
  • вспышка у дулаAddGlowDisc + короткий язык пламени, muzzleTime;
  • искра попадания — своя для стены, цели, убийства;
  • пурпурная искра = попадание по своему. Это не украшение, а индикатор: в норме её не должно появиться ни разу.

Всё аддитивно (AddSat), поэтому светится и поверх темноты — трассер сам себе источник света.

Дуга клинка

DrawMeleeArcs в src/render/fx.cpp. В отличие от трассеров, дуга читается прямо из состояния агентов, а не из журнала событий: фаза удара живёт десятые доли секунды и попадает в каждый кадр, а привязка к живой позиции важнее — боец в момент удара едет вперёд рывком.

  • замах — холодная стальная дуга уводится назад за край сектора и разгорается. Это телеграф, и он обязан быть виден;
  • удар — серп из четырёх концентрических дуг: внешний край раскалён, внутренние тускнеют к рукояти. Одна линия на 480×270 читается палкой, четыре — взмахом. Хвост — доля пройденной дуги, а не вся: серп, а не кольцо;
  • восстановление — серп стоит на месте и быстро гаснет.

В изометрии круг — эллипс, поэтому дуга набирается сэмплами мировых точек (AddWorldArc), а не экранной окружностью.

Через журнал идут только дискретные события: MELEE_SWING (вспышка света), MELEE_HIT (оранжевая искра, сильная отдача камеры — удар всегда в упор), MELEE_ABORT (тусклая серая вспышка: свой влез под лезвие). Клинок дульной вспышки не даёт, но искры от удара освещают не хуже — иначе весь ближний бой шёл бы в кромешной темноте.

Подробности механики — 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 — показать диски обзора. Полезно, когда непонятно, баг это или темнота.