Group level edits across inspector and canvas gestures, preserve legacy maps, and expose opt-in switch/key door bindings without changing wire layouts. Refresh studio and pause UI with UE5-inspired surfaces and content drawer. Verify 162 editor and 307 ASM checks plus renderer parity.
35 KiB
Архитектура и математический пайплайн EBLAN ENGINE 3D
1. Системы координат и единицы измерения
Мировое пространство (World Space)
- Мир разделен на клетки сетки размером 64x64 единицы (
CELL_SIZE equ 64). - Координаты игрока и спрайтов — целые мировые единицы, 64 единицы на клетку. Индекс клетки:
coordinate >> 6. - Синусы, косинусы и масштаб спрайтов используют 16.16. Дистанции DDA используют 24.8 до проекции, чтобы не терять дробную часть на каждом шаге.
- Карта
gameMapимеет размер 32x32 клетки (всего 1024 байта). Индекс клетки вычисляется через сдвиг:index = (mapY << 5) + mapX.
Углы и тригонометрия (Angles & Trig)
- Полная окружность (360°) разделена на 512 дискретных угловых шагов (
ANGLE_512):0= Восток (вправо, +X)128= Юг (вниз, +Y)256= Запад (влево, -X)384= Север (вверх, -Y)
- Угол всегда маскируется:
and eax, 511(исключает переполнение и ветвления). - Таблицы
tableSinиtableCosпредвычисляются на старте через математический сопроцессор FPU:rad = (angle * PI) / 256 tableCos[angle] = round(cos(rad) * 65536) tableSin[angle] = round(sin(rad) * 65536)
Симуляция и движение (этап 2)
include/engine_simulation.inc отделяет опрос ввода, фиксированные тики 60 Гц и
рисование. QPC-аккумулятор хранит elapsedCounts * 60; на тик вычитается частота
QPC без округления периода. За проход допускается до восьми тиков. Задержка более
250 мс, обратный отсчёт QPC, смена фокуса, пауза/возобновление, открытие/закрытие
диалога и успешный рестарт сбрасывают накопленный долг. Рендер не интерполируется
и не меняет скорость игрового времени.
PollInput сохраняет удерживаемые оси и накапливает фронты команд до следующего
тика; ConsumeInputEdges потребляет их один раз, включая заблокированные тики.
Возврат фокуса синхронизирует физические защёлки без новых команд: удерживаемое
движение возобновляется, действие требует нового нажатия.
playerX/Y остаются целыми мировыми координатами. playerFractionX/Y хранят
знаковые остатки 16.16 только в runtime; деление округляется к нулю симметрично
для всех направлений. Противоположные оси сокращаются; диагональ нормализуется
множителем 46341/65536. Скорость ходьбы/бега — 3/6 единиц за тик. Столкновения
проверяют все клетки квадратного тела position ± playerRadius, по единичным
подшагам каждой оси с проскальзыванием (до 64 подшагов за тик). Заблокированная
ось теряет дробный остаток. Порталы проходимы; дверь не закрывается поверх тела.
Приоритет состояний: Unfocused → Paused → Dialog → Transition → Dead → Cutscene →
Playing. Playing обновляет игрока, сущности, бой, Game_OnTick, свет и toast;
между подсистемами повторно проверяется общий запрет gameplay. Cutscene обновляет
трек/камеру и свет; Transition — только затемнение/загрузку. Пауза, диалог, смерть
и потеря фокуса замораживают мировые таймеры. Engine_Frame только рисует и
представляет кадр; UpdateScreenFade обновляет переход, ApplyScreenFade меняет
только пиксели. Скриншот и счётчик FPS относятся к представлению.
Авторские транзакции и взаимодействия (этап 3)
EditorApp::LevelEdit снимает полный snapshot вокруг UI-редактирования. Сравнение
авторских данных исключает пустые действия; ActiveId виджета/жест холста объединяет
непрерывный ввод, включая запекание на отпускании мыши. Undo/Redo ограничены 64
снимками; успешная загрузка очищает историю. Snapshot включает свет, baked RGB,
кат-сцены, переходы, спавн, сущности и палитру, но не внешние файлы ассетов/проекта.
Настоящие ImGui-поля проверяются в tests/studio_editor_checks.inl.
Опциональная связь рычага с дверью помещается в существующий SpriteStruct.flags:
тип 5 в младшем байте, 0xA8/0xA9 во втором байте, X/Y двери в старших байтах.
0xA9 разрешает также открыть дверь ключом: первый ключ расходуется и сохраняет
runtime-флаг отпирания до переинициализации комнаты. Без связи обычные двери
бесплатны; старые рычаги вызывают прежний hook/toggle. Оба загрузчика валидируют
целевую дверь, границы и уникальность связи. ToggleDoorCell не закрывает дверь
поверх тела игрока ни при прямом E, ни через рычаг. Прямое взаимодействие с дверью
потребляет E до обработки ближайших рычагов. Бинарные размеры EBLN v5 и IPC v2
не изменились. Подробный контракт и ограничения: STABILIZATION.md, этап 3.
2. Конвейер лучевого рендеринга (2.5D Raycasting)
Каждый кадр для экрана шириной 320 пикселей выпускается ровно 320 лучей (по 1 лучу на каждый вертикальный столбец экрана):
Левый край экрана Правый край экрана
(screenX = 0) Игрок (screenX = 319)
\ | /
\ | /
\ Угол обзора (FOV = 96) /
\ | /
\ v /
Алгоритм на столбец:
- Вычисление угла луча:
Смещения и их косинусы вычисляются один раз в\text{rayAngle} = \left(\text{playerAngle} + \operatorname{round}\left[\arctan\left((2x/320-1)\tan(\text{FOV}/2)\right)\frac{256}{\pi}\right]\right) \ \& \ 511InitMathTables; углы остаются дискретными (512 направлений). - Трассировка по сетке (DDA - Digital Differential Analysis):
deltaDist = (64 << 24) / abs(rayDirection16_16): расстояния сохраняют 8 дробных бит. При нулевой компоненте направления используется недостижимая дистанция, деления на ноль нет.- Вычисляются шаг сетки (
stepX = \pm 1,stepY = \pm 1) и расстояния до следующих линий сетки (sideDistX,sideDistY). - В цикле луч шагает от клетки к клетке, пока не встретит твердую ячейку (
IsSolidCell). - Флаг
sideHitфиксирует, была ли пересечена вертикальная грань (X) или горизонтальная (Y).
- Устранение дисторсии «рыбий глаз»:
- Некорректированная дистанция используется для точки попадания текстуры.
- Расстояние по лучу умножается на косинус разницы между углом луча и углом камеры:
\text{dist}_{\text{perp}} = \text{dist} \cdot \cos(\text{rayAngle} - \text{playerAngle})
- Проекция высоты стены:
\text{lineHeight} = \frac{\text{CELL\_SIZE} \cdot \text{PROJ\_DIST}}{\text{dist}_{\text{perp}}}- Центр проекции — середина экрана (
Y = 120). - Верхняя граница:
\text{drawStart} = 120 - \frac{\text{lineHeight}}{2}. - Нижняя граница:
\text{drawEnd} = 120 + \frac{\text{lineHeight}}{2}.
- Центр проекции — середина экрана (
- Текстурирование и затенение:
- Вычисляется горизонтальная координата текстуры
U \in [0, 127]. - Текстура интерполируется по вертикали с шагом
\text{texStep} = \frac{128 \ll 16}{\text{lineHeight}}. - $Y$-грани затемняются на 50% (
>> 1) для создания ощущения трехмерного объема. - Далекие стены плавно уходят в туман (половинная яркость от 350 единиц, почти черный от 700).
- Вычисляется горизонтальная координата текстуры
3. Билборд-спрайты (Sprites)
- Спрайты хранят позицию в сырых мировых единицах (1 клетка = 64 единицы, как
playerX/playerY). Перед отрисовкой все спрайты сортируются по убыванию квадрата расстояния (алгоритм художника, insertion sort поspriteOrder). - Позиция каждого спрайта переводится в систему координат камеры:
X_{\text{cam}} = \Delta X \cdot \sin(-\text{ang}) + \Delta Y \cdot \cos(-\text{ang})Y_{\text{cam}} = \Delta X \cdot \cos(-\text{ang}) - \Delta Y \cdot \sin(-\text{ang}) - Если
Y_{\text{cam}} > 8(спрайт находится перед камерой):- Проекция на экран:
\text{screenX} = \frac{X_{\text{cam}} \cdot \text{PROJ\_DIST}}{Y_{\text{cam}}} + 160. - Размер:
\text{size} = \frac{64 \cdot \text{PROJ\_DIST}}{Y_{\text{cam}}} \cdot \text{scale}(scale — 16.16 fixed).
- Проекция на экран:
- Спрайт стоит на полу: нижняя граница = горизонт + половина размера при масштабе 1.0; верхняя = низ − размер с учётом масштаба. Изменение масштаба не сдвигает точку опоры.
- Отрисовка вертикальных текстурированных полос с проверкой буфера глубины
depthBuffer[stripeX]: если спрайт ближе стены, пиксели выводятся вscreenBuffer. Тексели00FF00FFh(маджента) прозрачны. Применяется тот же дистанционный туман, что и у стен.
4. 2D Буфер кадра и вывод на экран
screenBuffer— плоский массив в оперативной памяти размером320 \times 240 \times 4 = 307\,200байт.- Формат пикселя — 32-битный BGRA (
00RRGGBBh). - Передача кадра в окно Windows производится аппаратным вызовом GDI:
invoke StretchDIBits, hEngineDC, 0, 0, 1280, 960, ; Область окна назначения 0, 0, 320, 240, ; Область исходного буфера OFFSET screenBuffer, OFFSET bmiEngine, DIB_RGB_COLORS, SRCCOPY - Обеспечивает честное ретро-разрешение и пиксельный вид с нулевой нагрузкой на видеокарту.
5. Загрузка карт и ресурсов
Новые карты сохраняются как EBLN v5: заголовок 38 байт, сетка 1024 байта, спрайты по 24 байта, переходы по 42 байта, команды кат-сцены по 16 байт и строки по 96 байт. Чтение старых v1/v2/v3/v4 сохранено; ранние v2 с переходами поддерживаются. Загрузчики сначала проверяют весь файл во временных буферах, затем заменяют комнату. При ошибке перехода текущая комната сохраняется; при ошибке начальной загрузки создаётся процедурная карта. Значение цвета 0 — полноценный чёрный цвет.
Файлы связанных подуровней лежат рядом друг с другом. В targetMap[32] хранится только ASCII-имя .dat с завершающим нулём. Переход после появления в его ячейке повторно активируется только после выхода из неё. Двери и взаимодействие реагируют на отдельные нажатия.
Портал использует отдельный буфер текстуры, не занимая слоты спрайтов 10..15. Пакет текстур загружается через отдельный буфер и применяется целиком после проверки. Постоянные буферы занимают примерно 2.5 МиБ (основная часть — пакет текстур и его буфер проверки); число загруженных комнат всегда одно.
F5 запускает копию графа карт и ресурсов во временной папке. Открытая карта со всеми несохранёнными изменениями сохраняется только в эту копию, под исходным именем. Движок получает стартовую карту первым аргументом командной строки. Исходные файлы и текущий путь редактора не меняются.
Регрессионные сценарии: python tools/test_regressions.py. С --keep сохраняются тестовые кадры и карты. Автотесты проверяют математику, файловый ввод-вывод, состояние процедур и отрисовку таймлайна без окон; ручная проверка интерфейса остаётся отдельной задачей.
6. Кат-сцены в движке
Подсистема include/engine_cutscene.inc воспроизводит один трек текущей карты. В заголовке v4 поля по смещениям 28/30/32 задают число команд (до 128), число строк (до 32) и флаг автозапуска (бит 0). В v1–v3 эти поля игнорируются. Сразу после переходов идут команды CutsceneEvent, затем строки CP1251 с завершающим нулём в пределах каждого 96-байтового слота. Текст редактора хранится в UTF-8 и преобразуется при сохранении; непредставимые символы отклоняются. В файле нет указателей.
Команда занимает 16 байт: tickTimestamp:WORD, opCode:BYTE, param8:BYTE, paramA:SDWORD, paramB:SDWORD, dataId:SDWORD. Метки времени упорядочены, отсчитываются от старта с частотой 60 Гц и ограничены 65535. Продолжительность отдельного действия — до 3600 кадров. Непустой трек заканчивается END; загрузчик проверяет весь трек, строки, координаты, флаги и ссылки до замены активного состояния. Точная таблица параметров приведена в разделе H файла AGENTS.md.
UpdatePlayer обрабатывает автозапуск после завершения межкомнатного затемнения и ручной запуск клавишей C. При активной сцене управление движением, дверями и обычными порталами заблокировано. UpdateCutscene сначала обновляет уже начатые интерполяции и время показа текста, затем обрабатывает паузу и наступившие команды. Несколько команд с одним временем выполняются в порядке хранения: камера, поворот, текст, звук и затемнение могут работать одновременно. WAIT задерживает обработку следующих команд, но время и уже начатые интерполяции продолжаются.
Координаты камеры — сырые мировые единицы (64 на клетку), отдельные csCameraX/Y/Angle, не позиция игрока. Рендер выбирает viewX/Y/Angle; фонарик и выборки света следуют камере, как в предпросмотре. MOVE_CAM_TO использует линейную интерполяцию по длительности из dataId; PAN_CAM_TO — кратчайшую дугу угла 0..511 и длительность из paramB. Целочисленное умножение с последующим знаковым делением гарантирует точную конечную точку. Нулевая длительность означает мгновенную установку. Новая команда того же типа заменяет незавершённую интерполяцию, начиная с текущего состояния. Конечная клетка должна быть проходимой; сами отрезки не проверяют столкновения со стенами.
FADE_SCREEN интерполирует темноту от 0 до 16 и масштабирует каждый RGB-канал. Во время сцены HUD заменён чёрными полосами по 52 пикселя (видимая область 320×136, примерно 2.35:1). Субтитры центрируются на полупрозрачной плашке: до 3 строк по 38 глифов 8×8, перенос по ширине или LF. Полный текст ограничен 95 символами CP1251; лишние экранные строки не выводятся. SHOW_SUBTITLE с нулевой длительностью остаётся до HIDE_SUBTITLE или завершения сцены.
PLAY_SOUND использует PlaySoundA из уже подключённой WinMM, асинхронно и без системного звука при ошибке. Строка содержит только ASCII-имя WAV длиной до 63 символов, файл лежит в assets/sounds. Бит 0 param8 включает повтор, индекс -1 останавливает звук. Канал один; новый WAV заменяет предыдущий. END, Tab и переход прекращают звук и снимают наложения. При END/Tab камера передаёт позицию и угол игроку только если всё collision body свободно; иначе сохраняется прежняя безопасная поза игрока. Дробные остатки сбрасываются; защита портала предотвращает немедленный обратный переход из текущей клетки.
CHANGE_SUBLEVEL ссылается на существующий переход по индексу и использует его имя карты, спавн, угол и настройку затемнения. Старый трек останавливается до загрузки новой комнаты. Уже достигнутая темнота сохраняется при начале затемнения перехода, чтобы избежать светлой вспышки. Новая комната может запустить собственную сцену после появления.
Рестарт текущей комнаты
RestartCurrentRoom используется смертью + R и пунктом 4 паузы. Запоминаются
фактический путь комнаты и entry spawn, включая override перехода. Сначала
выполняется транзакционная загрузка; лишь после успеха сбрасываются сущности,
HP/патроны, ключи/очки, боевые таймеры/вспышки, диалог/toast, затемнение, ожидающий
переход и дробные остатки. Данные комнаты и авторский свет восстанавливаются из
файла; жанр, настройка оружия/прицела, HUD и выбранное оружие сохраняются. Ошибка
не заменяет комнату процедурной и не оживляет игрока; пауза остаётся. Процедурный
стартовый fallback перезапускается процедурно.
7. Таймлайн EblanStudio
F6 или кнопка панели открывает таймлайн (editor/src/editor_cutscene.inl). Дорожки камеры, текста, звука и сцены показывают метки времени и перетаскиваемые ромбы. Команды с одинаковым временем можно выбрать через список «Команда». Инспектор редактирует время, длительность, координаты, углы, строки, WAV, повтор и целевой портал. Для камеры кнопка «Указать на карте» позволяет выбрать свободную клетку, не меняя сетку. END добавляется автоматически и переносится за последнее действие. Удаление портала удаляет или перенумеровывает ссылки сцены.
Трек, строки и автозапуск входят в снимки Undo/Redo и формат карты. Состояние предпросмотра (cutscene_preview.h) задаёт камеру, текст и затемнение для встроенного игрового вида; ползунок кадра восстанавливает состояние к выбранному времени. На 2D-карте видны траектория и направление. Звук предпросмотра можно отключить. При команде перехода предпросмотр останавливается. Полная последовательность комнат и игровой ввод проверяются через F5. При подготовке F5 редактор дополнительно проверяет наличие всех WAV, на которые ссылаются достижимые карты, и копирует их во временную папку.
Пример examples/cutscene_demo содержит две независимые карты, русские субтитры и короткий WAV. run_demo.bat запускает его после сборки движка; tools/make_cutscene_demo.py восстанавливает файлы примера. Регрессии сравнивают состояния камеры, угла, текста и затемнения C++ и ASM покадрово, проверяют повреждённые треки, переходы, упаковку звуков и пиксели кинематографического оверлея.
8. Игровой вид внутри редактора
В центре доступны режимы «Рядом», «Игровой вид» и «Карта». Игровое изображение создаётся настоящими ASM-процедурами движка: Render3DScene, RenderCutsceneOverlay и ApplyCutsceneFade. Копии raycaster на C++ нет. Сохраняется исходное разрешение 320×240, соотношение 4:3 и точечное масштабирование. Обычный игровой HUD во время редактирования скрыт; кат-сцены показывают свои полосы, субтитры и затемнение.
Редактор собран для x64, движок — для x86, поэтому рендерер работает в отдельном процессе без окна. Аргумент --preview:<имя> выбирает этот режим до обычной инициализации окна и карты. Именованный раздел памяти передаёт несохранённую сетку, спрайты, цвета, камеру, текст и пакет текстур. Общий формат описан в editor/src/preview_protocol.h и зеркально в include/engine_preview.inc; размер и смещения проверяются при сборке C++. Один запрос в работе исключает чтение неполного кадра: state=1 принадлежит движку, state=2 — редактору. Публикация выполняется атомарно с барьером памяти. Пакет текстур копируется в движок при изменении ревизии.
Движок проверяет диапазоны входных координат и спрайтов, наблюдает за процессом редактора и завершает работу, если тот закрыт. Редактор проверяет завершение рендерера и тайм-аут, показывает ошибку с повторным запуском и освобождает процесс и память при закрытии. Готовый BGRA-кадр загружается в текстуру DX11; неиспользуемый байт DIB заменяется непрозрачной альфой.
WASD, стрелки и правая кнопка мыши позволяют осмотреть сцену после клика по игровому виду; Shift ускоряет движение. Свободная камера не меняет спавн и ключи. Начало воспроизведения или перемотка таймлайна возвращают вид к камере сцены. Правки геометрии, цветов, спрайтов и импорт текстур видны без сохранения и F5. Межкомнатные переходы и взаимодействия проверяются в полном запуске F5.
F5/F6 обрабатываются до признака захвата клавиатуры ImGui — иначе фокус внутри редактора блокировал запуск. Ошибки подготовки и запуска F5 выводятся отдельным диалогом. Начальный путь карты определяется относительно расположения редактора и загружается при старте, независимо от рабочей папки процесса.
Проверки выполняют полный путь x86 ASM → раздел памяти → x64 редактор → текстура DX11 на устройстве WARP. Пиксели встроенного вида сравниваются с прямым вызовом обычного ASM-рендерера. Отдельный сценарий python tools/test_preview_ipc.py проверяет обновление стен и текстур, субтитры, затемнение и завершение без открытия окон. Для полного регрессионного набора предварительно соберите движок.
9. Запекание и динамический RGB-свет (24.10.2026)
Запечённый свет отделён от динамических источников. lighting_format.h выполняет запекание в редакторе; engine_lighting_core.inc содержит эквивалентный ASM-вариант и расчёт света при рендеринге. Постоянные источники (STATIC=0) входят в 1024 RGB-ячейки lightGridRGB. Точечные (POINT=1), пульсирующие (PULSE=4), мерцание, фонарик и временные вспышки прибавляются только при динамическом режиме. Это исключает повторное прибавление света. Источник цвета 0 излучает чёрный свет; белый задаётся явно как 00FFFFFFh.
Оба запекателя используют расстояние max(abs(dx),abs(dy)) + 3*min(abs(dx),abs(dy))/8, затухание (radius-distance)*intensity/radius, RGB-цвет и насыщение каналов. CastLightRay проверяет точные пересечения границ клеток; при пересечении угла проверяются обе соседние клетки. Порталы и открытые двери пропускают свет. Флаг SHADOWS отключает проверку препятствий для выбранного источника. Сглаживание выполняется в отдельном буфере с весом 2 для центра и 1 для доступных соседей, без усреднения сквозь стену.
В v5 после строк кат-сцены добавлены 32 байта настроек, lightCount записей по 40 байт и 4096 байт RGB-карты. Поля lightCount/reserved занимают смещения 34/36 прежнего 38-байтового заголовка. Настройки: режим, фон, включение фонаря, его радиус, интенсивность, полуугол, baked-valid=1, reserved=0 (все DWORD). Загрузка транзакционная, включая освещение: повреждённая комната не заменяет действующую. Старые v1..v4 сбрасывают источники и сохраняют классическое освещение. После перехода загружается свет новой комнаты; старые источники и вспышки очищаются.
Игровой вид использует протокол EBPV v2 размером 1 362 408 байт: прежние смещения пикселей и текстур сохранены, после кадра добавлены 13 источников и RGB-карта. Источники и фонарик синхронизируются при каждом запросе; время мерцания/пульсации задаётся 60-Гц счётчиком. Перезапекания на каждом кадре нет. В игре обновление использует время симуляции, а запечённая карта читается непосредственно из файла. У стен освещение берётся у видимой грани, у спрайтов — в их позиции; фонарик проверяет и конус, и заслонение. Плоские потолок/пол используют один образец в точке камеры, без проекции света по каждому пикселю пола.
Слоты 1..13 принадлежат источникам карты; 0 зарезервирован фонарю, 14 вспышке выстрела, 15 взрыву. Вспышки не могут перезаписать авторские источники. Режимы света, источники, RGB-данные и состояние запекания входят в Undo/Redo. Изменение постоянного света/геометрии/фона помечает результат устаревшим; динамическое движение не требует запекания. Сохранение и F5 автоматически обновляют устаревший результат.
Регрессии проверяют цвет, заслонение стенами/дверями/углами, флаги теней, отсутствие двойного освещения, пульсацию и затухание вспышек, сохранение v5, отказ от повреждённых данных, старые карты, Undo/Redo и реальное обновление игрового вида. Все 1024 RGB-ячейки сравниваются между C++ и ASM. Кадры сцены с авторским светом из встроенного вида сравниваются с прямым рендерингом той же карты в ASM.