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