feat: complete engine and editor subsystems (Stages 1-5 from TODO_ARTEM)
- Stage 1: Eliminate fish-eye distortion with precomputed tangential ray offset table and fractional DDA depth - Stage 2: Full asset management in EblanStudio (stb_image/stb_image_write, 128x128 BGRA texture packaging, WAV sound manager) - Stage 3: Multi-level room streaming, LevelTransition/CELL_PORTAL trigger system, 16-frame screen fade to black - Stage 4: 60Hz cutscene engine in pure x86 ASM (v4 binary track, waypoint camera motion, shortest-arc pan, CP1251 Russian subtitles, letterbox overlay, audio) - Stage 5: EblanStudio cutscene timeline (F6) and windowless Live ASM Viewport over shared-memory IPC directly rendered into DX11 - Tooling & Tests: comprehensive regression test suite (94 editor + 107 engine tests), IPC tests, and cutscene demo
This commit is contained in:
parent
01b9aea697
commit
a1d1baa869
54 changed files with 16131 additions and 476 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -6,7 +6,7 @@
|
|||
- **Language**: Pure x86 Assembly (MASM32 / MSVC `ml.exe`).
|
||||
- **Dependencies**: Windows API only (`kernel32.lib`, `user32.lib`, `gdi32.lib`, `winmm.lib`). No external DLLs, DirectX, OpenGL, or C runtimes.
|
||||
- **Rendering**: Software DIBits raycaster (Wolfenstein 3D style DDA grid stepping, texture mapping, distance shading, billboard sprites, 2D UI canvas with 8x8 bitmap font).
|
||||
- **Coordinate Space**: 16.16 signed fixed-point math (`FixedMul`, `FixedDiv`). Trigonometry via 512-entry precomputed lookup tables (`tableSin`, `tableCos`).
|
||||
- **Coordinate Space**: Player/entity positions are raw world units (64 per cell). Trigonometry and sprite scale use 16.16; DDA distances retain 8 fractional bits (24.8) until projection. Trigonometry uses 512-entry lookup tables.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ invoke DrawNumber8x8, 120, 42, playerHealth, COLOR_WHITE
|
|||
The engine supports both procedural default generation and binary level loading. When `assets/map.dat` exists, `LoadMapBinary` in `include/engine_map.inc` automatically loads it:
|
||||
- **Header (`MapHeader`, 38 bytes, packed)**:
|
||||
- `magic` (uint32): `0x4E4C4245` ("EBLN")
|
||||
- `version` (uint16): `2` (v1 legacy: sprite coords are 16.16 fixed, converted on load)
|
||||
- `version` (uint16): `4` (readers also accept v1 fixed sprite coordinates and v2/v3 raw-coordinate maps, including early v2 maps with transitions)
|
||||
- `mapWidth` (uint16): `32`
|
||||
- `mapHeight` (uint16): `32`
|
||||
- `playerStartX` (uint16): cell X coordinate (e.g. 2)
|
||||
|
|
@ -146,13 +146,23 @@ The engine supports both procedural default generation and binary level loading.
|
|||
- `spriteCount` (uint16): count of sprite entries
|
||||
- `ceilingColor` (uint32): 00RRGGBBh
|
||||
- `floorColor` (uint32): 00RRGGBBh
|
||||
- `reserved[12]` (bytes): reserved padding
|
||||
- `transitionCount` (uint16): 0..16 (v1 ignores this formerly reserved field)
|
||||
- `cutsceneCount` (uint16): 0..128 commands (offset 28, v4 only)
|
||||
- `cutsceneStringCount` (uint16): 0..32 strings (offset 30, v4 only)
|
||||
- `cutsceneFlags` (uint16): bit 0 = autoplay on room entry (offset 32, v4 only)
|
||||
- `reserved[4]` (bytes): reserved padding
|
||||
- **Grid Data**: `32 * 32 = 1024` bytes (row-major order). Cell value = texture slot (1 brick, 2 wood, 3 stone, 4 metal, 5 door, 7 pillar, 8 glass, 9 marble).
|
||||
- **Sprite Array**: `spriteCount * 24` bytes (`SpriteStruct`: `x`, `y` in raw world units, `textureId`, `scale` 16.16, `dist`, `flags`).
|
||||
- **Transition Array**: `transitionCount * 42` bytes (`LevelTransition` in both `engine_defs.inc` and `editor/src/level_format.h`). Target names are ASCII basenames, 5..31 bytes including `.dat`, with a terminating NUL inside `targetMap[32]`. Put linked maps in the same assets directory. Triggers must be unique and occupy empty, open-door or portal cells; every portal cell needs a transition.
|
||||
- Files are validated completely before replacing the active room. Counts above capacity, truncated/extra payload, invalid coordinates/scales, blocked spawns and invalid transition names are rejected. Black ceiling/floor colors are valid. A failed runtime transition keeps the current room; a failed startup load creates the procedural map.
|
||||
- **Cutscene Array (v4)**: after transitions, `cutsceneCount * 16` bytes; followed by `cutsceneStringCount * 96` bytes of NUL-terminated CP1251 strings. No serialized pointers. Earlier versions ignore the new header fields and load an empty track.
|
||||
- New saves use v4 without rewriting existing map files until explicitly saved. Sprite positions must remain inside the map; scales accepted by loaders are 0..16.0 (0 keeps the legacy 1.0 behavior).
|
||||
|
||||
Human users can design levels visually using `run_editor.bat` (`editor/editor.exe`, written in C++17 + ImGui + DirectX 11), with 1-click Playtest (F5).
|
||||
AI agents can directly read or write `assets/map.dat` following this specification.
|
||||
|
||||
F5 copies neighboring maps and resources into a dedicated temporary directory, writes the current unsaved map there under its original filename, validates all reachable destinations, and launches `engine.exe "assets\selected-map.dat"`. It never replaces the source `assets/map.dat` or changes the editor's current file path. Starting the engine without an argument still loads `assets/map.dat`.
|
||||
|
||||
### F. Texture Pack (`assets/textures/texturepack.bin`)
|
||||
At startup the engine always generates procedural textures first, then
|
||||
`LoadTexturePack` tries to override them from `assets/textures/texturepack.bin`:
|
||||
|
|
@ -163,3 +173,37 @@ Rebuild the pack from the KAZIK 48-slot atlas (`casino3d-hd.bin`) or fully proce
|
|||
python tools\make_texturepack.py
|
||||
```
|
||||
If the pack is missing or invalid, the engine silently keeps procedural textures.
|
||||
The loader stages and validates the complete pack before replacing pixels. Slots 10..15 are sprite slots; `CELL_PORTAL` uses a separate `portalTextureBuffer`, so importing slot 10 never changes portals. First import creates missing directories and seeds untouched slots with procedural defaults. Import/reset writes are atomic; an invalid existing pack is preserved and reported to the user.
|
||||
|
||||
### G. Regression Checks
|
||||
Run `python tools\test_regressions.py` on Windows with the build toolchains installed. The scenarios compile the real editor logic and ASM procedures, exercise file I/O, playtest isolation, transitions, keyboard edges, renderer geometry, cutscene validation and editor undo in TEMP, and verify that project assets remain unchanged. C++ preview and ASM camera/angle/fade/subtitle states are compared frame by frame. Use `--keep` to retain generated framebuffers and fixtures. These checks do not replace a manual UI playtest.
|
||||
|
||||
### H. Cutscenes (v4)
|
||||
`include/engine_cutscene.inc` owns validation, playback and overlays. `CutsceneEvent` is packed: `uint16 tickTimestamp`, `uint8 opCode`, `uint8 param8`, `int32 paramA`, `int32 paramB`, `int32 dataId`. Time uses 60 Hz ticks, timestamps 0..65535, individual durations 0..3600 ticks. Commands are sorted by timestamp; a nonempty track ends with a single final END.
|
||||
|
||||
| Opcode | paramA | paramB | dataId / param8 |
|
||||
|---|---|---|---|
|
||||
| 0 END | ignored | ignored | Returns control, retains camera pose |
|
||||
| 1 WAIT | duration | ignored | Delays dispatch; existing tweens continue |
|
||||
| 2 MOVE_CAM_TO | raw world X | raw world Y | dataId = duration (0 snaps) |
|
||||
| 3 PAN_CAM_TO | target angle 0..511 | duration | Shortest arc |
|
||||
| 4 SHOW_SUBTITLE | string index | duration (0 persistent) | CP1251, up to 95 characters |
|
||||
| 5 HIDE_SUBTITLE | ignored | ignored | Clears subtitle |
|
||||
| 6 PLAY_SOUND | string index, or -1 to stop | ignored | param8 bit 0 = loop |
|
||||
| 7 FADE_SCREEN | darkness 0..16 | duration | 0 clear, 16 black |
|
||||
| 8 CHANGE_SUBLEVEL | transition index | ignored | Uses existing transition destination/spawn/fade |
|
||||
|
||||
MOVE and PAN interpolate linearly using signed integer math. Camera endpoints must be walkable; movement segments ignore collisions. Each tick advances existing tweens, expires text and waits, then dispatches due commands in stored order. Same-timestamp commands can start independent movement, pan and fade together. WAIT delays overdue commands without shifting their timestamps. A replacement tween starts from the current pose.
|
||||
|
||||
Autoplay begins after a room transition finishes. C starts/replays the current map's track; Tab skips it. Movement/actions are blocked during playback. End/skip/level changes stop cinematic sound, clear subtitles and fade, and return control at the current camera pose. Letterbox is 2.35:1, subtitles use up to 3 centered rows of 38 glyphs over a half-brightness panel. WAV names are ASCII basenames of up to 63 characters in `assets/sounds`; playback uses one asynchronous PlaySound channel.
|
||||
|
||||
F6 opens EblanStudio's timeline. Author commands with buttons, drag diamonds to change timestamps, select overlapping events through the command list, and place MOVE points by clicking the map. The inspector edits parameters and strings. Autoplay, scene data and strings participate in save/load and undo/redo. Preview shows the actual game perspective in the embedded viewport with the camera path on the map; F5 runs the full game, validates referenced WAV files and stages them with linked maps. Editor preview stops at a level-change command. F5/F6 are handled before ImGui keyboard-capture checks.
|
||||
|
||||
`examples/cutscene_demo/run_demo.bat` runs an isolated two-room example with camera waypoints, Russian subtitles, WAV, fades and a scripted transition. Regenerate it with `python tools/make_cutscene_demo.py`.
|
||||
|
||||
### I. Embedded Game View
|
||||
The x64 editor uses a windowless x86 `engine.exe --preview:Local\EblanPreview-...` process to execute the actual `Render3DScene`, cinematic overlay and fade procedures. No duplicate raycaster. `editor/src/preview_protocol.h` and `include/engine_preview.inc` define a versioned 1,357,792-byte shared-memory mailbox: 128-byte header, 1024 cells, 32 sprites, a 96-byte CP1251 subtitle, 16 texture slots and a 320x240 DIB frame. Header state is 0 idle, 1 engine owns payload, 2 editor owns result, 3 shutdown. Publish with InterlockedExchange/xchg; never modify payload while state is 1. All fields are fixed-width; do not put pointers or size_t into the wire layout.
|
||||
|
||||
The renderer validates camera and sprite bounds, uploads textures only when their revision changes, and exits if its parent process ends. The editor has process-exit/timeout checks and a retry button, converts DIB padding to opaque alpha, uploads BGRA to DX11 and displays with point sampling at 4:3. Map/inspector changes appear without saving. Timeline playback/scrubbing drives the viewport camera, subtitles and fade. Free-camera inspection affects no authored data. The viewport shows the scene and cinematic overlays; normal game HUD, door interactions and room switching are tested in F5.
|
||||
|
||||
`tools/test_preview_ipc.py` exercises the actual renderer without a visible window. The full regression suite also checks the x86-to-x64-to-DX11 path on a WARP device and compares the live viewport's pixels with a direct ASM rendering. Build engine.exe before running these tests.
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -53,6 +53,8 @@ engine.exe
|
|||
| **Shift** | Спринт (ускорение) |
|
||||
| **E / Пробел** | Взаимодействие (открыть/закрыть дверь перед собой) |
|
||||
| **M** | Включить/выключить миникарту (радар) |
|
||||
| **C** | Запустить или повторить кат-сцену текущей карты |
|
||||
| **Tab** | Пропустить кат-сцену |
|
||||
| **Escape** | Выход из игры |
|
||||
|
||||
---
|
||||
|
|
@ -64,8 +66,10 @@ engine.exe
|
|||
Движок оснащен нативным высокопроизводительным визуальным редактором уровней в стиле Unity / Godot 4:
|
||||
- Написан на **C++17 + Dear ImGui + DirectX 11**.
|
||||
- Запуск в 1 клик через `run_editor.bat` или `editor/editor.exe`.
|
||||
- **Мгновенный плейтест (F5)**: редактор автоматически сериализует уровень в бинарный формат `assets/map.dat` и запускает ассемблерный движок `engine.exe`.
|
||||
- **Мгновенный плейтест (F5)**: редактор создаёт временную копию связанных карт и ресурсов и запускает `engine.exe` с выбранной стартовой картой. Несохранённые изменения попадают в копию; исходные карты и путь текущего файла не меняются.
|
||||
- **Undo/Redo (Ctrl+Z / Ctrl+Y)** — история до 64 действий (сетка, спавн, цвета, спрайты).
|
||||
- **Кат-сцены (F6)** — таймлайн камеры, поворотов, субтитров, WAV, затемнений и переходов. Правки сцены также поддерживают Undo/Redo.
|
||||
- **Игровой вид в редакторе** — режимы «Рядом», «Игровой вид» и «Карта». Изображение рисует тот же ASM-рендерер, что и игра; изменения стен, спрайтов, цветов и импортированных текстур видны без F5. Кликните по игровому виду для осмотра: WASD, стрелки, Shift; правая кнопка мыши поворачивает камеру. Обзор не меняет спавн или ключи кат-сцены.
|
||||
- **Файловые диалоги Windows**: Ctrl+O (открыть), Ctrl+S (сохранить), Ctrl+Shift+S (сохранить как).
|
||||
- Инструменты:
|
||||
- **Кисть [B]**: рисование стен выбранного типа (Кирпич, Дерево, Камень, Металл, Дверь, Колонна, Стекло, Мрамор).
|
||||
|
|
@ -104,3 +108,30 @@ build_editor.bat
|
|||
- `include/engine_render2d.inc` — примитивы (DrawPixel, Rect, Outline), шрифт 8x8, миникарта.
|
||||
- `include/font8x8.inc` — битовые маски ASCII-символов.
|
||||
- `AGENTS.md` — подробное руководство для AI-агентов по написанию новых игр на этом движке.
|
||||
|
||||
|
||||
## Переходы и проверка первых трёх этапов
|
||||
|
||||
Карты новых сохранений используют EBLN v4; существующие v1/v2/v3 продолжают читаться. Связанные `.dat` лежат в одной папке. В портале задаётся ASCII-имя файла до 31 символа, целевой спавн, угол и затемнение. На карту доступно до 16 переходов. Заливка учитывает этот лимит целиком; удаление клетки удаляет её триггер. Чёрный пол и потолок поддерживаются.
|
||||
|
||||
Порталы имеют отдельную текстуру и не занимают слот фонаря. Первый импорт изображения создаёт недостающие папки и пакет со стандартными текстурами в остальных слотах. Повреждённый пакет не перезаписывается импортом; ошибка показывается в строке состояния. Неудачный переход сохраняет текущую комнату и показывает сообщение в игре.
|
||||
|
||||
Проверки без изменения карт проекта:
|
||||
```cmd
|
||||
python tools\test_regressions.py
|
||||
```
|
||||
Для сохранения тестовых кадров добавьте `--keep`. Нужны MASM32, MSVC BuildTools и Windows SDK.
|
||||
|
||||
## Кат-сцены: этапы 4 и 5
|
||||
|
||||
В EblanStudio нажмите **F6** и добавьте команды кнопками таймлайна. Для движения камеры укажите точку на карте и длительность в кадрах (60 кадров = секунда). Поворот, движение, субтитры и затемнение можно запускать одновременно. Ромбы перетаскиваются по времени; список «Команда» позволяет выбрать события с одинаковым временем. Параметры выбранной команды редактируются справа. Конец сцены добавляется автоматически.
|
||||
|
||||
«При входе» включает автозапуск после загрузки комнаты. Кнопка «Предпросмотр» и ползунок кадра показывают пролёт в игровом виде редактора, включая субтитры, чёрные полосы и затемнение. На карте одновременно видна траектория. Выбор «Камера сцены» возвращает вид к таймлайну после свободного осмотра. **F5** запускает полную игру с временной копией карт и звуков. Для команды перехода сначала создайте портал, затем выберите его в инспекторе. WAV можно импортировать через меню ассетов; имя файла должно быть ASCII.
|
||||
|
||||
Камера движется линейно между точками, поворачивается по кратчайшей дуге и во время пролёта проходит сквозь стены. Конечные точки должны лежать в свободных клетках. Доступна одна сцена на карту, до 128 команд и 32 строк по 95 символов CP1251. На экране помещаются 3 строки субтитров по 38 символов. Звуки используют один канал: новый WAV заменяет предыдущий, завершение или пропуск сцены останавливает его. Tab возвращает управление в текущей точке камеры.
|
||||
|
||||
Готовый пример: [examples/cutscene_demo/README.md](examples/cutscene_demo/README.md). После сборки запустите `examples\cutscene_demo\run_demo.bat`: вступление с русскими субтитрами, WAV и затемнением переводит камеру во вторую комнату. Пример хранится отдельно от основных карт проекта.
|
||||
|
||||
Предпросмотр останавливается на команде перехода в другую комнату; цепочка комнат и игровой ввод проверяются через F5. F5/F6 работают при фокусе интерфейса и текстовых полей. Ошибка запуска показывается отдельным сообщением. При старте редактор открывает карту проекта относительно своего файла, поэтому запуск из другой рабочей папки не ломает пути.
|
||||
|
||||
Формат v4 и параметры команд описаны в [AGENTS.md](AGENTS.md) и [архитектуре движка](docs/ENGINE_ARCHITECTURE.md). Сценарии регрессии также проверяют сохранение/загрузку трека, повреждённые команды, Undo/Redo, упаковку WAV для F5, горячие клавиши при фокусе в интерфейсе, покадровое совпадение состояния C++ с ASM, передачу кадров в DX11 и точное совпадение пикселей игрового вида с движком. Перед запуском проверок соберите `engine.exe` через `build.bat`.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,34 @@
|
|||
|
||||
---
|
||||
|
||||
## Проверка реализации этапов 1–3 — 21.10.2026
|
||||
|
||||
- Этап 1: тангенциальные углы лучей, единственная косинусная коррекция, DDA с 8 дробными битами до проекции. В регрессионной сцене прямая стена имеет одинаковую глубину во всех 320 столбцах.
|
||||
- Этап 2: PNG/JPG/BMP, BGRA и хромакей, безопасное создание и обновление пакета, стандартные слоты при первом импорте, WAV и манифесты. Портал не использует слот фонаря.
|
||||
- Этап 3: EBLN v3 с чтением v1/v2, полная проверка перед заменой комнаты, обработка ошибочного перехода, спавны, затемнение и защита от повторного срабатывания при появлении в портале. F5 работает с временной копией карт и ресурсов.
|
||||
- Регрессионные проверки: `python tools/test_regressions.py`. Ручная проверка интерфейса и длительный игровой прогон остаются отдельной проверкой качества.
|
||||
- Этапы 4 и 5 реализованы следующим изменением, см. статус ниже.
|
||||
|
||||
## Реализация этапов 4–5 — 22.10.2026
|
||||
|
||||
- Этап 4: бинарный трек EBLN v4, чистый ASM, линейные пролёты по точкам, повороты по кратчайшей дуге, паузы, русские субтитры CP1251, letterbox 2.35:1, WAV, затемнения и переходы. Автозапуск при входе, C для повтора и Tab для пропуска; управление игроком блокируется на время сцены. Старые карты v1/v2/v3 читаются.
|
||||
- Этап 5: F6 открывает таймлайн с дорожками, перетаскиванием ключей, списком команд, инспектором, установкой точек на карте, автозапуском, Undo/Redo и сохранением. Предпросмотр и перемотка показывают сцену глазами камеры во встроенном игровом виде, а на карте — траекторию. F5 запускает полную игру и связанные комнаты с проверкой WAV.
|
||||
- Пример `examples/cutscene_demo/run_demo.bat` показывает пролёт, текст, звук и переход во вторую комнату; основные карты проекта он не меняет.
|
||||
- Регрессионный набор расширен проверками формата v4, проигрывателей ASM/C++, оверлея, переходов, редактора и покадрового совпадения состояния.
|
||||
- Уточнения исходного ТЗ: сериализуемый `dataPtr` заменён на `dataId`, строки хранятся в отдельном пуле; движение задаётся длительностью. Использованы линейные waypoints, камера во время пролёта не сталкивается со стенами. WAV воспроизводится одним каналом PlaySoundA без внешних библиотек.
|
||||
|
||||
## Завершение редактора и исправление F5 — 22.10.2026
|
||||
|
||||
- Встроен игровой вид, который получает кадры от настоящего ASM-рендерера. Доступны режимы «Рядом», «Игровой вид», «Карта», свободный осмотр и камера таймлайна. Изменения стен, цветов, спрайтов и текстур видны без сохранения.
|
||||
- Исправлена блокировка F5/F6 при захвате клавиатуры интерфейсом ImGui. Ошибки запуска показываются отдельным диалогом. Начальная карта открывается относительно расположения редактора, независимо от рабочей папки.
|
||||
- Автопроверки проверяют горячие клавиши с фокусом в интерфейсе, реальную передачу кадров из x86 ASM в x64 редактор и DX11, обновление геометрии, перемотку кат-сцены, точное совпадение пикселей с отдельным движком и освобождение процесса рендерера.
|
||||
- Все пять этапов реализованы. Предпросмотр редактора останавливается на команде перехода между комнатами; полный сценарий переходов и игровой ввод доступны через F5. Это ограничение предпросмотра, а не отсутствие переходов в движке.
|
||||
- Итоговая проверка: 94/94 сценария редактора и 107/107 сценариев ASM, совпадение всех 320 углов лучей, покадровое совпадение проигрывателей и точное совпадение пикселей встроенного вида с движком. Обе программы пересобраны. Исходные ресурсы проекта при проверках остались без изменений. Ручное управление окнами было остановлено пользователем через Esc; полный визуальный прогон интерфейса не завершён.
|
||||
|
||||
Разделы ниже сохраняют исходное ТЗ и описание прежних проблем как историю требований. Актуальные формат и поведение описаны в `AGENTS.md` и `docs/ENGINE_ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Оглавление
|
||||
1. [Задача 1: Устранение Fish-Eye эффекта и фикс рендера](#задача-1-устранение-fish-eye-эффекта-и-фикс-рендера)
|
||||
2. [Задача 2: Полноценный импорт ассетов в EblanStudio](#задача-2-полноценный-импорт-ассетов-в-eblanstudio)
|
||||
|
|
@ -14,7 +42,7 @@
|
|||
|
||||
---
|
||||
|
||||
## Задача 1: Устранение Fish-Eye эффекта и фикс рендера
|
||||
## Задача 1: Устранение Fish-Eye эффекта и фикс рендера [ВЫПОЛНЕНО / DONE]
|
||||
|
||||
### 1.1. Суть проблемы в текущем коде
|
||||
В файле [`include/engine_raycaster.inc`](include/engine_raycaster.inc) лучи выпускаются с равномерным шагом угла:
|
||||
|
|
@ -57,7 +85,7 @@ $$\theta_x = \text{playerAngle} + \arctan\left(\left(\frac{2x}{\text{SCREEN\_W}}
|
|||
|
||||
---
|
||||
|
||||
## Задача 2: Полноценный импорт ассетов в EblanStudio
|
||||
## Задача 2: Полноценный импорт ассетов в EblanStudio [ВЫПОЛНЕНО / DONE]
|
||||
|
||||
### 2.1. Требования
|
||||
У Артема и дизайнеров уровней должна быть возможность в пару кликов мыши в редакторе загружать свои изображения (PNG) и звуки (WAV) без ручного запуска сторонних питоновских скриптов.
|
||||
|
|
@ -84,17 +112,19 @@ $$\theta_x = \text{playerAngle} + \arctan\left(\left(\frac{2x}{\text{SCREEN\_W}}
|
|||
|
||||
---
|
||||
|
||||
## Задача 3: Многоуровневые локации и стриминг подуровней
|
||||
---
|
||||
|
||||
## Задача 3: Многоуровневые локации и стриминг подуровней [ВЫПОЛНЕНО / DONE]
|
||||
|
||||
### 3.1. Секторно-комнатная модель уровней
|
||||
Вместо одной изолированной сетки $32 \times 32$ мир организуется как **граф связанных секторов (комнат)**.
|
||||
- Каждый сектор — это отдельный бинарный файл подуровня (например, `sublevel_01.dat`, `sublevel_02.dat`) со своей сеткой $32 \times 32$, цветами пола/потолка и локальными спрайтами.
|
||||
- Каждый сектор — это отдельный бинарный файл подуровня (например, `map.dat`, `sublevel_02.dat`) со своей сеткой $32 \times 32$, цветами пола/потолка и локальными спрайтами.
|
||||
- Сектора соединяются **Порталами / Дверями перехода (Sector Portals)**.
|
||||
|
||||
### 3.2. Структура данных перехода (Portal Transition)
|
||||
В заголовок или массив сущностей карты добавляется тип `CellType::CELL_PORTAL`:
|
||||
В заголовок и сущности карты добавлен тип `CELL_PORTAL = 10` и структура:
|
||||
```cpp
|
||||
// Структура перехода между подуровнями
|
||||
// Структура перехода между подуровнями (42 байта packed)
|
||||
struct LevelTransition {
|
||||
uint8_t triggerCellX; // Координата X двери/триггера в текущем секторе
|
||||
uint8_t triggerCellY; // Координата Y двери/триггера в текущем секторе
|
||||
|
|
@ -103,23 +133,29 @@ struct LevelTransition {
|
|||
uint16_t targetSpawnY; // Точка появления в новом секторе (Y)
|
||||
uint16_t targetAngle; // Направление взгляда после перехода
|
||||
uint8_t fadeEffect; // 0 = мгновенно, 1 = затемнение экрана (Fade to Black)
|
||||
uint8_t reserved; // Выравнивание
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3. Логика динамической подгрузки и выгрузки в Ассемблере
|
||||
В [`include/engine_map.inc`](include/engine_map.inc):
|
||||
1. **Процедура перехода `SwitchSublevel(szNextMap, spawnX, spawnY, spawnAngle)`**:
|
||||
- **Шаг 1 (Fade Out)**: Плавное затемнение буфера экрана `screenBuffer` за 10-15 кадров (уменьшение яркости RGB).
|
||||
- **Шаг 2 (Unload)**: Очистка текущих спрайтов (`mov spriteCount, 0`), сброс флагов дверей.
|
||||
- **Шаг 3 (Load)**: Чтение нового файла карты в `gameMap`, обновление цветов неба/пола (`mapCeilingColor`, `mapFloorColor`), загрузка массива спрайтов нового сектора.
|
||||
1. **Процедура перехода `SwitchSublevel(szNextMap, spawnX, spawnY, spawnAngle, fadeEffect)`**:
|
||||
- **Шаг 1 (Fade Out)**: Плавное затемнение буфера экрана `screenBuffer` за 16 кадров с помощью целочисленного масштабирования цветовых каналов RGB.
|
||||
- **Шаг 2 (Unload)**: Очистка текущих спрайтов (`mov spriteCount, 0`), сброс флагов и переходных триггеров.
|
||||
- **Шаг 3 (Load)**: Чтение нового файла карты через `LoadSublevelBinary` в `gameMap`, обновление цветов неба/пола (`mapCeilingColor`, `mapFloorColor`), загрузка массива спрайтов нового сектора.
|
||||
- **Шаг 4 (Spawn)**: Установка новых координат игрока `playerX = spawnX * 64 + 32`, `playerY = spawnY * 64 + 32`, `playerAngle = spawnAngle`.
|
||||
- **Шаг 5 (Fade In)**: Плавное осветление экрана.
|
||||
2. **Память**:
|
||||
- Оперативная память остается постоянной (~1-2 МБ), старые комнаты полностью освобождают буферы, что позволяет создавать гигантские замки и комплексы из сотен связанных комнат.
|
||||
- **Шаг 5 (Fade In)**: Плавное 16-кадровое осветление экрана.
|
||||
2. **Память и производительность**:
|
||||
- Постоянный объем оперативной памяти (~1-2 МБ), буферы очищаются и заполняются новым сектором на лету, позволяя создавать бесконечные цепочки и комплексы комнат.
|
||||
- Во время обычного геймплея оверхед пост-процесса затемнения равен нулю (`cmp fadeState, 0; je fade_done`).
|
||||
3. **Интеграция в EblanStudio**:
|
||||
- Добавлен тайл `10. Портал перехода` с неоново-фиолетовой палитрой и меткой портала на 2D-сетке.
|
||||
- Инспектор свойств перехода: выбор файла целевого подуровня (`.dat`), кнопка обзора файловой системы, слайдеры целевого спавна и угла, переключатель Fade to Black, быстрое удаление и кнопка мгновенного перехода к подуровню в редакторе.
|
||||
- Поддержка сериализации и десериализации переходов в бинарный формат с сохранением 100% обратной совместимости.
|
||||
|
||||
---
|
||||
|
||||
## Задача 4: Система кат-сцен и скриптовых последовательностей
|
||||
## Задача 4: Система кат-сцен и скриптовых последовательностей [ВЫПОЛНЕНО / DONE]
|
||||
|
||||
### 4.1. Архитектура: Бинарный трек событий (Keyframe Track)
|
||||
Кат-сцена представляет собой упорядоченный во времени список событий (Action Track). Во время кат-сцены управление игрока блокируется, а виртуальный режиссер интерполирует камеру, спавнит звуки и выводит диалоговые субтитры.
|
||||
|
|
@ -178,11 +214,11 @@ struct CutsceneEvent {
|
|||
|
||||
| Этап | Задача | Затронутые файлы | Ожидаемый результат |
|
||||
|---|---|---|---|
|
||||
| **Этап 1** | Исправление Fish-Eye и угла лучей | `include/engine_math.inc`, `include/engine_raycaster.inc` | Стены остаются ровными по краям экрана, нет искажения при вращении камеры. |
|
||||
| **Этап 2** | Импорт текстур и спрайтов в редакторе | `editor/src/stb_image.h`, `editor/src/editor_app.cpp` | Кнопка импорта в GUI загружает PNG и обновляет `assets/textures/texturepack.bin`. |
|
||||
| **Этап 3** | Система связанных секторов (комнат) | `editor/src/level_format.h`, `include/engine_map.inc` | Переход между комнатами по триггеру с выгрузкой старой карты и загрузкой новой. |
|
||||
| **Этап 4** | Движок кат-сцен и диалогов | `include/engine_data.inc`, `include/engine_render2d.inc`, `main.asm` | Пролет камеры по ключевым точкам, показ диалоговых субтитров, блокировка инпута. |
|
||||
| **Этап 5** | Визуальный таймлайн в EblanStudio | `editor/src/editor_app.cpp`, `editor/src/editor_app.h` | Визуальное редактирование траектории камеры и сценария кат-сцены в UI. |
|
||||
| **Этап 1 — готово** | Исправление Fish-Eye и угла лучей | `include/engine_math.inc`, `include/engine_raycaster.inc` | Стены остаются ровными по краям экрана, нет искажения при вращении камеры. |
|
||||
| **Этап 2 — готово** | Импорт текстур и спрайтов в редакторе | `editor/src/stb_image.h`, `editor/src/editor_app.cpp` | Кнопка импорта в GUI загружает PNG и обновляет `assets/textures/texturepack.bin`. |
|
||||
| **Этап 3 — готово** | Система связанных секторов (комнат) | `editor/src/level_format.h`, `include/engine_map.inc` | Переход между комнатами по триггеру с выгрузкой старой карты и загрузкой новой. |
|
||||
| **Этап 4 — готово** | Движок кат-сцен и диалогов | `include/engine_cutscene.inc`, `include/engine_data.inc`, `main.asm` | Пролёт камеры по ключевым точкам, субтитры, звук, затемнения, переходы, блокировка инпута. |
|
||||
| **Этап 5 — готово** | Визуальный таймлайн в EblanStudio | `editor/src/editor_cutscene.inl`, `editor/src/editor_viewport.inl`, `editor/src/cutscene_preview.h` | Редактирование траектории и сценария, предпросмотр игрового вида внутри редактора, Undo/Redo, F5 для полной игры. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
BIN
assets/map.dat
BIN
assets/map.dat
Binary file not shown.
3
assets/sounds/sounds.inc
Normal file
3
assets/sounds/sounds.inc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
; ==============================================================================
|
||||
; EBLAN ENGINE - Auto-Generated Sound Manifest (winmm.lib PlaySound / MCI)
|
||||
; ==============================================================================
|
||||
1
assets/sounds/sounds.txt
Normal file
1
assets/sounds/sounds.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
# EblanStudio Sound Registry & MCI Aliases
|
||||
BIN
assets/sublevel_02.dat
Normal file
BIN
assets/sublevel_02.dat
Normal file
Binary file not shown.
|
|
@ -5,6 +5,13 @@ rem ============================================================================
|
|||
rem EBLAN ENGINE - Build Script (MASM32 / MSVC BuildTools)
|
||||
rem ==============================================================================
|
||||
|
||||
if exist "E:\MASM32\masm32\bin\ml.exe" (
|
||||
set "ML_EXE=E:\MASM32\masm32\bin\ml.exe"
|
||||
set "LINK_EXE=E:\MASM32\masm32\bin\link.exe"
|
||||
set "LIB_PATHS=/LIBPATH:E:\MASM32\masm32\lib"
|
||||
set "EXTRA_FLAGS=/SUBSYSTEM:WINDOWS /RELEASE /VERSION:4.0"
|
||||
goto do_build
|
||||
)
|
||||
if exist "D:\MASM32\masm32\bin\ml.exe" (
|
||||
set "ML_EXE=D:\MASM32\masm32\bin\ml.exe"
|
||||
set "LINK_EXE=D:\MASM32\masm32\bin\link.exe"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
|
||||
### Мировое пространство (World Space)
|
||||
- Мир разделен на клетки сетки размером **64x64 единицы** (`CELL_SIZE equ 64`).
|
||||
- Координаты игрока `playerX` и `playerY` хранятся в формате **16.16 signed fixed point**:
|
||||
- Целая часть = `координата / 64` (индекс клетки в сетке `gameMap`).
|
||||
- Дробная часть = смещение внутри клетки.
|
||||
- Координаты игрока и спрайтов — **целые мировые единицы**, 64 единицы на клетку. Индекс клетки: `coordinate >> 6`.
|
||||
- Синусы, косинусы и масштаб спрайтов используют 16.16. Дистанции DDA используют **24.8** до проекции, чтобы не терять дробную часть на каждом шаге.
|
||||
- Карта `gameMap` имеет размер **32x32 клетки** (всего 1024 байта). Индекс клетки вычисляется через сдвиг: `index = (mapY << 5) + mapX`.
|
||||
|
||||
### Углы и тригонометрия (Angles & Trig)
|
||||
|
|
@ -41,12 +40,15 @@
|
|||
|
||||
### Алгоритм на столбец:
|
||||
1. **Вычисление угла луча**:
|
||||
$$\text{rayAngle} = \left(\text{playerAngle} - \frac{\text{FOV}}{2} + \frac{\text{screenX} \cdot \text{FOV}}{320}\right) \ \& \ 511$$
|
||||
$$\text{rayAngle} = \left(\text{playerAngle} + \operatorname{round}\left[\arctan\left((2x/320-1)\tan(\text{FOV}/2)\right)\frac{256}{\pi}\right]\right) \ \& \ 511$$
|
||||
Смещения и их косинусы вычисляются один раз в `InitMathTables`; углы остаются дискретными (512 направлений).
|
||||
2. **Трассировка по сетке (DDA - Digital Differential Analysis)**:
|
||||
- `deltaDist = (64 << 24) / abs(rayDirection16_16)`: расстояния сохраняют 8 дробных бит. При нулевой компоненте направления используется недостижимая дистанция, деления на ноль нет.
|
||||
- Вычисляются шаг сетки (`stepX = \pm 1`, `stepY = \pm 1`) и расстояния до следующих линий сетки (`sideDistX`, `sideDistY`).
|
||||
- В цикле луч шагает от клетки к клетке, пока не встретит твердую ячейку (`IsSolidCell`).
|
||||
- Флаг `sideHit` фиксирует, была ли пересечена вертикальная грань ($X$) или горизонтальная ($Y$).
|
||||
3. **Устранение дисторсии «рыбий глаз»**:
|
||||
- Некорректированная дистанция используется для точки попадания текстуры.
|
||||
- Расстояние по лучу умножается на косинус разницы между углом луча и углом камеры:
|
||||
$$\text{dist}_{\text{perp}} = \text{dist} \cdot \cos(\text{rayAngle} - \text{playerAngle})$$
|
||||
4. **Проекция высоты стены**:
|
||||
|
|
@ -66,12 +68,12 @@
|
|||
|
||||
0. Спрайты хранят позицию в **сырых мировых единицах** (1 клетка = 64 единицы, как `playerX`/`playerY`). Перед отрисовкой все спрайты сортируются по убыванию квадрата расстояния (алгоритм художника, insertion sort по `spriteOrder`).
|
||||
1. Позиция каждого спрайта переводится в систему координат камеры:
|
||||
$$X_{\text{cam}} = \Delta X \cdot \cos(-\text{ang}) - \Delta Y \cdot \sin(-\text{ang})$$
|
||||
$$Y_{\text{cam}} = \Delta X \cdot \sin(-\text{ang}) + \Delta Y \cdot \cos(-\text{ang})$$
|
||||
$$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})$$
|
||||
2. Если $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).
|
||||
3. Спрайт «стоит на полу»: нижняя граница = горизонт + size/2 (как низ стены на той же глубине), верхняя = низ − size.
|
||||
3. Спрайт стоит на полу: нижняя граница = горизонт + половина размера при масштабе 1.0; верхняя = низ − размер с учётом масштаба. Изменение масштаба не сдвигает точку опоры.
|
||||
4. Отрисовка вертикальных текстурированных полос с проверкой буфера глубины `depthBuffer[stripeX]`: если спрайт ближе стены, пиксели выводятся в `screenBuffer`. Тексели `00FF00FFh` (маджента) прозрачны. Применяется тот же дистанционный туман, что и у стен.
|
||||
|
||||
---
|
||||
|
|
@ -89,3 +91,54 @@
|
|||
DIB_RGB_COLORS, SRCCOPY
|
||||
```
|
||||
- Обеспечивает честное ретро-разрешение и пиксельный вид с нулевой нагрузкой на видеокарту.
|
||||
|
||||
|
||||
## 5. Загрузка карт и ресурсов
|
||||
|
||||
Новые карты сохраняются как EBLN v4: заголовок 38 байт, сетка 1024 байта, спрайты по 24 байта, переходы по 42 байта, команды кат-сцены по 16 байт и строки по 96 байт. Чтение старых v1/v2/v3 сохранено; ранние 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 на клетку). 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 и переход прекращают звук и снимают наложения. Положение камеры сохраняется при возврате управления; защита портала предотвращает немедленный обратный переход из текущей клетки.
|
||||
|
||||
CHANGE_SUBLEVEL ссылается на существующий переход по индексу и использует его имя карты, спавн, угол и настройку затемнения. Старый трек останавливается до загрузки новой комнаты. Уже достигнутая темнота сохраняется при начале затемнения перехода, чтобы избежать светлой вспышки. Новая комната может запустить собственную сцену после появления.
|
||||
|
||||
## 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` проверяет обновление стен и текстур, субтитры, затемнение и завершение без открытия окон. Для полного регрессионного набора предварительно соберите движок.
|
||||
|
|
|
|||
Binary file not shown.
BIN
editor/assets/sublevel_02.dat
Normal file
BIN
editor/assets/sublevel_02.dat
Normal file
Binary file not shown.
|
|
@ -15,13 +15,13 @@ call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\
|
|||
cd /d "%~dp0"
|
||||
|
||||
echo [BUILD] Compiling EblanStudio (C++17 + Dear ImGui + DirectX 11)...
|
||||
cl /nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNDEBUG ^
|
||||
cl /nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNOMINMAX /D_CRT_SECURE_NO_WARNINGS /DNDEBUG ^
|
||||
/Isrc /Iimgui /Iimgui\backends ^
|
||||
src\main.cpp src\editor_app.cpp ^
|
||||
imgui\imgui.cpp imgui\imgui_draw.cpp imgui\imgui_tables.cpp imgui\imgui_widgets.cpp ^
|
||||
imgui\backends\imgui_impl_win32.cpp imgui\backends\imgui_impl_dx11.cpp ^
|
||||
/Fe:editor.exe ^
|
||||
/link /SUBSYSTEM:WINDOWS d3d11.lib dxgi.lib d3dcompiler.lib user32.lib gdi32.lib shell32.lib comdlg32.lib
|
||||
/link /SUBSYSTEM:WINDOWS d3d11.lib dxgi.lib d3dcompiler.lib user32.lib gdi32.lib shell32.lib comdlg32.lib winmm.lib
|
||||
|
||||
if errorlevel 1 goto err
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,20 +1,25 @@
|
|||
[Window][Workspace]
|
||||
Pos=0,60
|
||||
Size=1344,716
|
||||
LastUsed=20261015
|
||||
Size=1536,708
|
||||
LastUsed=20261022
|
||||
|
||||
[Window][Debug##Default]
|
||||
Pos=60,60
|
||||
Size=400,400
|
||||
LastUsed=20261015
|
||||
LastUsed=20261022
|
||||
|
||||
[Window][Toolbar]
|
||||
Pos=0,20
|
||||
Size=1344,38
|
||||
LastUsed=20261015
|
||||
Size=1536,38
|
||||
LastUsed=20261022
|
||||
|
||||
[Window][StatusBar]
|
||||
Pos=0,776
|
||||
Size=1344,32
|
||||
LastUsed=20261015
|
||||
Pos=0,768
|
||||
Size=1536,32
|
||||
LastUsed=20261022
|
||||
|
||||
[Window][Не удалось запустить игру]
|
||||
Pos=649,356
|
||||
Size=237,80
|
||||
LastUsed=20261022
|
||||
|
||||
|
|
|
|||
96
editor/src/cutscene_format.h
Normal file
96
editor/src/cutscene_format.h
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
#pragma once
|
||||
#include "level_format.h"
|
||||
#include <windows.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstring>
|
||||
|
||||
struct CutsceneTrack {
|
||||
std::vector<CutsceneEvent> events;
|
||||
std::vector<std::string> strings; // UTF-8 in the editor, CP1251 on disk/in ASM
|
||||
uint16_t flags = 0;
|
||||
};
|
||||
|
||||
inline bool CutsceneEncode(const std::string& text, std::string& encoded) {
|
||||
if (text.empty()) { encoded.clear(); return true; }
|
||||
int wideSize = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast<int>(text.size()), nullptr, 0);
|
||||
if (!wideSize) return false;
|
||||
std::wstring wide(wideSize, 0);
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast<int>(text.size()), wide.data(), wideSize);
|
||||
BOOL replaced = FALSE;
|
||||
int size = WideCharToMultiByte(1251, WC_NO_BEST_FIT_CHARS, wide.data(), wideSize, nullptr, 0, nullptr, &replaced);
|
||||
if (!size || replaced || size >= CUTSCENE_STRING_BYTES) return false;
|
||||
encoded.resize(size);
|
||||
WideCharToMultiByte(1251, WC_NO_BEST_FIT_CHARS, wide.data(), wideSize, encoded.data(), size, nullptr, &replaced);
|
||||
if (replaced) return false;
|
||||
for (unsigned char ch : encoded) if (ch == 0 || (ch < 32 && ch != '\n')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline std::string CutsceneDecode(const char* data, int size) {
|
||||
if (!size) return {};
|
||||
int n = MultiByteToWideChar(1251, 0, data, size, nullptr, 0);
|
||||
std::wstring wide(n, 0); MultiByteToWideChar(1251, 0, data, size, wide.data(), n);
|
||||
n = WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), nullptr, 0, nullptr, nullptr);
|
||||
std::string text(n, 0);
|
||||
WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), text.data(), n, nullptr, nullptr);
|
||||
return text;
|
||||
}
|
||||
|
||||
inline bool IsSoundFilename(const std::string& name) {
|
||||
if (name.size() < 5 || name.size() > 63) return false;
|
||||
for (unsigned char ch : name)
|
||||
if (ch < 32 || ch >= 127 || std::strchr("\\/:*?\"<>|", ch)) return false;
|
||||
auto ext = name.substr(name.size() - 4);
|
||||
return ext[0] == '.' && (ext[1] | 32) == 'w' && (ext[2] | 32) == 'a' && (ext[3] | 32) == 'v';
|
||||
}
|
||||
|
||||
inline bool ValidateCutscene(const CutsceneTrack& track, const uint8_t* cells, size_t transitionCount, std::string& error) {
|
||||
auto fail = [&](const char* reason) { error = reason; return false; };
|
||||
if (track.events.size() > MAX_CUTSCENE_EVENTS || track.strings.size() > MAX_CUTSCENE_STRINGS || (track.flags & ~CUTSCENE_AUTOPLAY))
|
||||
return fail("Превышен лимит или неверные флаги кат-сцены");
|
||||
for (const auto& text : track.strings) {
|
||||
std::string encoded;
|
||||
if (!CutsceneEncode(text, encoded)) return fail("Текст: до 95 символов CP1251, без emoji и управляющих символов");
|
||||
}
|
||||
auto stringValid = [&](int index) { return index >= 0 && index < static_cast<int>(track.strings.size()); };
|
||||
uint16_t previous = 0;
|
||||
for (size_t i = 0; i < track.events.size(); ++i) {
|
||||
const auto& e = track.events[i];
|
||||
if (e.tickTimestamp < previous || e.opCode > OP_CHANGE_SUBLEVEL || (e.param8 & ~(e.opCode == OP_PLAY_SOUND ? 1 : 0)))
|
||||
return fail("Неверный порядок или команда кат-сцены");
|
||||
previous = e.tickTimestamp;
|
||||
if (e.opCode == OP_END && i + 1 != track.events.size()) return fail("Команда конца должна быть последней");
|
||||
switch (e.opCode) {
|
||||
case OP_WAIT:
|
||||
if (e.paramA < 0 || e.paramA > CUTSCENE_MAX_DURATION) return fail("Неверная длительность паузы");
|
||||
break;
|
||||
case OP_MOVE_CAM_TO:
|
||||
if (e.paramA < 0 || e.paramA >= 2048 || e.paramB < 0 || e.paramB >= 2048 || e.dataId < 0 || e.dataId > CUTSCENE_MAX_DURATION)
|
||||
return fail("Неверная точка или длительность движения камеры");
|
||||
if (cells && cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_EMPTY &&
|
||||
cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_DOOR_OPEN && cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_PORTAL)
|
||||
return fail("Точка камеры находится внутри стены");
|
||||
break;
|
||||
case OP_PAN_CAM_TO: case OP_FADE_SCREEN:
|
||||
if (e.paramA < 0 || e.paramA > (e.opCode == OP_PAN_CAM_TO ? 511 : 16) || e.paramB < 0 || e.paramB > CUTSCENE_MAX_DURATION)
|
||||
return fail("Неверный поворот или затемнение камеры");
|
||||
break;
|
||||
case OP_SHOW_SUBTITLE:
|
||||
if (!stringValid(e.paramA) || e.paramB < 0 || e.paramB > CUTSCENE_MAX_DURATION) return fail("Неверный текст или длительность субтитра");
|
||||
break;
|
||||
case OP_PLAY_SOUND:
|
||||
if (e.paramA != -1 && (!stringValid(e.paramA) || !IsSoundFilename(track.strings[e.paramA]))) return fail("Звук: ASCII-имя WAV из assets/sounds");
|
||||
break;
|
||||
case OP_CHANGE_SUBLEVEL:
|
||||
if (e.paramA < 0 || e.paramA >= static_cast<int>(transitionCount)) return fail("Выберите существующий переход для кат-сцены");
|
||||
break;
|
||||
}
|
||||
int span = e.opCode == OP_MOVE_CAM_TO ? e.dataId :
|
||||
(e.opCode == OP_PAN_CAM_TO || e.opCode == OP_FADE_SCREEN || e.opCode == OP_SHOW_SUBTITLE ? e.paramB :
|
||||
e.opCode == OP_WAIT ? e.paramA : 0);
|
||||
if (static_cast<int>(e.tickTimestamp) + span > 65535) return fail("Команда выходит за пределы 65535 кадров");
|
||||
}
|
||||
if (!track.events.empty() && track.events.back().opCode != OP_END) return fail("Кат-сцена должна заканчиваться командой Конец");
|
||||
return true;
|
||||
}
|
||||
60
editor/src/cutscene_preview.h
Normal file
60
editor/src/cutscene_preview.h
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#pragma once
|
||||
#include "cutscene_format.h"
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
|
||||
// Deterministic 60 Hz reference player for the editor preview and scrubber.
|
||||
// The ASM player follows the same ordering: finish existing tweens, then dispatch.
|
||||
struct CutscenePreview {
|
||||
struct Tween {
|
||||
int from = 0, to = 0, duration = 0, elapsed = 0;
|
||||
int Advance(int value) {
|
||||
if (!duration) return value;
|
||||
++elapsed;
|
||||
value = from + (to-from)*elapsed/duration;
|
||||
if (elapsed >= duration) { value = to; duration = 0; }
|
||||
return value;
|
||||
}
|
||||
void Start(int current, int target, int ticks) { from = current; to = target; duration = ticks; elapsed = 0; }
|
||||
} xTween, yTween, angleTween, fadeTween;
|
||||
int x = 0, y = 0, angle = 0, fade = 0;
|
||||
int tick = 0, nextEvent = 0, wait = 0, subtitle = -1, subtitleUntil = 0, transition = -1;
|
||||
bool active = false;
|
||||
void Start(int px, int py, int direction) {
|
||||
*this = {};
|
||||
x = px; y = py; angle = direction; active = true;
|
||||
}
|
||||
void Stop() {
|
||||
active = false; subtitle = -1; subtitleUntil = 0; fade = 0;
|
||||
xTween = {}; yTween = {}; angleTween = {}; fadeTween = {};
|
||||
}
|
||||
void Step(const CutsceneTrack& track, const std::function<void(int, bool)>& sound = {}) {
|
||||
if (!active) return;
|
||||
x = xTween.Advance(x); y = yTween.Advance(y);
|
||||
angle = angleTween.Advance(angle) & 511;
|
||||
fade = fadeTween.Advance(fade);
|
||||
if (subtitleUntil && tick >= subtitleUntil) { subtitle = -1; subtitleUntil = 0; }
|
||||
if (wait > 0 && --wait > 0) { ++tick; return; }
|
||||
while (nextEvent < static_cast<int>(track.events.size()) && track.events[nextEvent].tickTimestamp <= tick) {
|
||||
const auto e = track.events[nextEvent++];
|
||||
switch (e.opCode) {
|
||||
case OP_END: Stop(); return;
|
||||
case OP_WAIT: wait = e.paramA; if (wait) { ++tick; return; } break;
|
||||
case OP_MOVE_CAM_TO:
|
||||
xTween.Start(x, e.paramA, e.dataId); yTween.Start(y, e.paramB, e.dataId);
|
||||
if (!e.dataId) { x = e.paramA; y = e.paramB; } break;
|
||||
case OP_PAN_CAM_TO: {
|
||||
int delta = ((e.paramA-angle+256)&511)-256;
|
||||
angleTween.Start(angle, angle+delta, e.paramB);
|
||||
if (!e.paramB) angle = e.paramA; break;
|
||||
}
|
||||
case OP_SHOW_SUBTITLE: subtitle = e.paramA; subtitleUntil = e.paramB ? tick+e.paramB : 0; break;
|
||||
case OP_HIDE_SUBTITLE: subtitle = -1; subtitleUntil = 0; break;
|
||||
case OP_PLAY_SOUND: if (sound) sound(e.paramA, (e.param8&1)!=0); break;
|
||||
case OP_FADE_SCREEN: fadeTween.Start(fade, e.paramA, e.paramB); if (!e.paramB) fade = e.paramA; break;
|
||||
case OP_CHANGE_SUBLEVEL: transition = e.paramA; Stop(); return;
|
||||
}
|
||||
}
|
||||
++tick;
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,14 @@
|
|||
#pragma once
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef _CRT_SECURE_NO_WARNINGS
|
||||
#define _CRT_SECURE_NO_WARNINGS
|
||||
#endif
|
||||
|
||||
#include "level_format.h"
|
||||
#include "cutscene_preview.h"
|
||||
#include "preview_protocol.h"
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
|
|
@ -13,12 +22,14 @@ enum ToolMode {
|
|||
TOOL_SPRITE_SPAWN
|
||||
};
|
||||
|
||||
// Full level snapshot for undo/redo (grid + spawn + palette + sprites)
|
||||
// Full level snapshot for undo/redo (grid + spawn + palette + sprites + transitions)
|
||||
struct LevelSnapshot {
|
||||
uint8_t map[MAP_GRID_H][MAP_GRID_W];
|
||||
int playerStartX, playerStartY, playerStartAngle;
|
||||
uint32_t ceilingColor, floorColor;
|
||||
std::vector<SpriteEntry> sprites;
|
||||
std::vector<LevelTransition> transitions;
|
||||
CutsceneTrack cutscene;
|
||||
};
|
||||
|
||||
class EditorApp {
|
||||
|
|
@ -34,6 +45,28 @@ public:
|
|||
void StopPlaytest();
|
||||
void CheckPlaytestProcess();
|
||||
|
||||
void InitD3D(struct ID3D11Device* pDevice);
|
||||
void ShutdownD3D();
|
||||
|
||||
// Asset Management
|
||||
std::string GetTexturePackPath() const;
|
||||
std::string GetSoundsDirectory() const;
|
||||
bool ImportTextureImage(int slotIndex, bool isSprite, const std::string& explicitPath = "");
|
||||
bool ExportTextureImage(int slotIndex);
|
||||
bool ResetTextureSlotToDefault(int slotIndex);
|
||||
bool ImportSoundAudio(const std::string& explicitPath = "");
|
||||
void HandleDropFile(const std::string& droppedPath);
|
||||
void ReloadTextureSlotSRV(int slotIndex);
|
||||
void LoadAllTexturePreviews();
|
||||
void UpdateSoundManifest();
|
||||
std::vector<std::string> GetSoundList() const;
|
||||
void PlayAudioSound(const std::string& filename);
|
||||
void StopAudioSound();
|
||||
|
||||
void RenderAssetManagerModal();
|
||||
bool showAssetManagerModal = false;
|
||||
bool showCutsceneTimeline = false;
|
||||
|
||||
void RenderUI();
|
||||
|
||||
// Level state
|
||||
|
|
@ -44,13 +77,16 @@ public:
|
|||
uint32_t ceilingColor = 0x00383838;
|
||||
uint32_t floorColor = 0x00222222;
|
||||
std::vector<SpriteEntry> sprites;
|
||||
std::vector<LevelTransition> transitions;
|
||||
CutsceneTrack cutscene;
|
||||
|
||||
// Editor state
|
||||
ToolMode currentTool = TOOL_BRUSH;
|
||||
CellType selectedPaletteCell = CELL_BRICK;
|
||||
int selectedSpriteIdx = -1;
|
||||
int selectedTransitionIdx = -1;
|
||||
bool isEngineRunning = false;
|
||||
std::string statusMessage = "Готов к работе. EblanStudio v1.1";
|
||||
std::string statusMessage = "Готов к работе. EblanStudio v1.2";
|
||||
std::string currentFilePath = "..\\assets\\map.dat";
|
||||
|
||||
// Viewport pan & zoom
|
||||
|
|
@ -58,7 +94,56 @@ public:
|
|||
float canvasPanX = 20.0f;
|
||||
float canvasPanY = 20.0f;
|
||||
|
||||
// Texture preview SRVs (DirectX 11)
|
||||
struct ID3D11Device* pd3dDevice = nullptr;
|
||||
struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr };
|
||||
|
||||
private:
|
||||
bool EnsureEnginePreview();
|
||||
void StopEnginePreview();
|
||||
void UpdateEnginePreview();
|
||||
void RenderSceneViewport();
|
||||
std::string FindEnginePath() const;
|
||||
HANDLE previewMapping = nullptr;
|
||||
PROCESS_INFORMATION previewProcess{};
|
||||
EnginePreviewPacket* previewPacket = nullptr;
|
||||
struct ID3D11Texture2D* previewTexture = nullptr;
|
||||
struct ID3D11ShaderResourceView* previewSRV = nullptr;
|
||||
struct ID3D11SamplerState* previewSampler = nullptr;
|
||||
std::vector<uint32_t> previewPixels;
|
||||
std::vector<uint8_t> previewTextures;
|
||||
std::string previewError;
|
||||
bool previewTexturesDirty = true;
|
||||
bool previewAttempted = false;
|
||||
bool previewHasFrame = false;
|
||||
bool previewSceneCamera = true;
|
||||
int viewportMode = 0; // 0 split, 1 scene, 2 map
|
||||
int previewX = 160, previewY = 160, previewAngle = 0;
|
||||
double previewTurn = 0, previewMoveX = 0, previewMoveY = 0;
|
||||
ULONGLONG previewRequestTime = 0;
|
||||
bool showPlaytestError = false;
|
||||
|
||||
void RenderCutsceneTimeline();
|
||||
void RenderCutsceneInspector();
|
||||
void DrawCutscenePath(void* drawList, float originX, float originY, float tileSize);
|
||||
void AddCutsceneEvent(uint8_t op);
|
||||
void NormalizeCutscene();
|
||||
void DeleteCutsceneEvent();
|
||||
void RemoveCutsceneTransition(int index);
|
||||
void ScrubCutscene(int frame);
|
||||
void StopCutscenePreview();
|
||||
void AdvanceCutscenePreview(float seconds);
|
||||
int CutsceneEndTick() const;
|
||||
int selectedCutsceneEvent = -1;
|
||||
int cutsceneCursor = 0;
|
||||
int dragEventOriginalTick = 0;
|
||||
float cutsceneTimelineZoom = 60.0f;
|
||||
bool placeCameraPoint = false;
|
||||
bool cutscenePreviewPlaying = false;
|
||||
bool cutscenePreviewSound = true;
|
||||
double cutscenePreviewAccumulator = 0;
|
||||
CutscenePreview cutscenePreview;
|
||||
|
||||
void RenderMenuBar();
|
||||
void RenderToolbar();
|
||||
void RenderGridCanvas();
|
||||
|
|
@ -67,6 +152,11 @@ private:
|
|||
void RenderHierarchy();
|
||||
void HandleHotkeys();
|
||||
|
||||
bool SaveLevelToFile(const std::string& filepath, bool updateCurrentPath);
|
||||
bool PreparePlaytest(std::string& startMap, std::string& enginePath);
|
||||
void CleanupPlaytest();
|
||||
bool WriteTextureSlot(int slotIndex, const std::vector<uint8_t>& pixels);
|
||||
bool SetCell(int gridX, int gridY, uint8_t value);
|
||||
void ApplyTool(int gridX, int gridY);
|
||||
void FloodFill(int startX, int startY, uint8_t targetType, uint8_t fillType);
|
||||
|
||||
|
|
@ -87,7 +177,11 @@ private:
|
|||
// File dialogs
|
||||
std::string BrowseOpenFile();
|
||||
std::string BrowseSaveFile();
|
||||
std::string BrowseOpenImageFile();
|
||||
std::string BrowseOpenAudioFile();
|
||||
std::string BrowseSavePngFile(int slotIndex);
|
||||
|
||||
std::string playtestDirectory;
|
||||
HANDLE hEngineProcess = NULL;
|
||||
PROCESS_INFORMATION engineProcInfo{};
|
||||
};
|
||||
|
|
|
|||
343
editor/src/editor_cutscene.inl
Normal file
343
editor/src/editor_cutscene.inl
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
// Editor-only authoring and deterministic top-down preview of the ASM track.
|
||||
static const char* const kSceneNames[] = {
|
||||
"Конец", "Пауза", "Камера", "Поворот", "Субтитр", "Скрыть текст", "Звук", "Затемнение", "Переход"
|
||||
};
|
||||
|
||||
int EditorApp::CutsceneEndTick() const {
|
||||
return cutscene.events.empty() ? 0 : cutscene.events.back().tickTimestamp;
|
||||
}
|
||||
|
||||
void EditorApp::StopCutscenePreview() {
|
||||
if (cutscenePreviewPlaying) PlaySoundA(nullptr, nullptr, 0);
|
||||
cutscenePreviewPlaying = false;
|
||||
cutscenePreviewAccumulator = 0;
|
||||
}
|
||||
|
||||
void EditorApp::ScrubCutscene(int frame) {
|
||||
previewSceneCamera = true;
|
||||
StopCutscenePreview();
|
||||
cutsceneCursor = (std::max)(0, (std::min)(frame, CutsceneEndTick()));
|
||||
cutscenePreview.Start(playerStartX*64+32, playerStartY*64+32, playerStartAngle);
|
||||
for (int i = 0; i <= cutsceneCursor && cutscenePreview.active; ++i) cutscenePreview.Step(cutscene);
|
||||
}
|
||||
|
||||
void EditorApp::AdvanceCutscenePreview(float seconds) {
|
||||
if (!cutscenePreviewPlaying) return;
|
||||
cutscenePreviewAccumulator += (std::min)(static_cast<double>(seconds), 0.25) * 60.0;
|
||||
auto audio = [&](int index, bool loop) {
|
||||
if (!cutscenePreviewSound) return;
|
||||
if (index < 0) { PlaySoundA(nullptr, nullptr, 0); return; }
|
||||
const std::string path = GetSoundsDirectory() + cutscene.strings[index];
|
||||
if (!PlaySoundA(path.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT | (loop ? SND_LOOP : 0)))
|
||||
statusMessage = "Не удалось воспроизвести WAV: " + path;
|
||||
};
|
||||
while (cutscenePreviewAccumulator >= 1.0 && cutscenePreviewPlaying) {
|
||||
cutscenePreviewAccumulator -= 1.0;
|
||||
cutsceneCursor = cutscenePreview.tick;
|
||||
cutscenePreview.Step(cutscene, audio);
|
||||
if (!cutscenePreview.active) {
|
||||
StopCutscenePreview();
|
||||
if (cutscenePreview.transition >= 0)
|
||||
statusMessage = "Предпросмотр дошёл до перехода. F5 проверяет последовательность в игре.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EditorApp::NormalizeCutscene() {
|
||||
std::vector<std::pair<CutsceneEvent, int>> sorted;
|
||||
int endTick = 0;
|
||||
bool selectedEnd = false;
|
||||
for (int i = 0; i < static_cast<int>(cutscene.events.size()); ++i) {
|
||||
auto event = cutscene.events[i];
|
||||
if (event.opCode == OP_END) {
|
||||
endTick = (std::max)(endTick, static_cast<int>(event.tickTimestamp));
|
||||
selectedEnd |= i == selectedCutsceneEvent;
|
||||
continue;
|
||||
}
|
||||
int duration = event.opCode == OP_MOVE_CAM_TO ? event.dataId :
|
||||
(event.opCode == OP_PAN_CAM_TO || event.opCode == OP_FADE_SCREEN || event.opCode == OP_SHOW_SUBTITLE ? event.paramB :
|
||||
event.opCode == OP_WAIT ? event.paramA : 1);
|
||||
event.tickTimestamp = static_cast<uint16_t>((std::min)(static_cast<int>(event.tickTimestamp), 65535-duration));
|
||||
endTick = (std::max)(endTick, event.tickTimestamp + (std::max)(1,duration));
|
||||
sorted.push_back({event, i});
|
||||
}
|
||||
std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.first.tickTimestamp < b.first.tickTimestamp; });
|
||||
const int previousSelection = selectedCutsceneEvent;
|
||||
selectedCutsceneEvent = -1;
|
||||
cutscene.events.clear();
|
||||
for (const auto& item : sorted) {
|
||||
if (item.second == previousSelection) selectedCutsceneEvent = static_cast<int>(cutscene.events.size());
|
||||
cutscene.events.push_back(item.first);
|
||||
}
|
||||
if (!sorted.empty() || selectedEnd) {
|
||||
CutsceneEvent end{}; end.tickTimestamp = static_cast<uint16_t>((std::min)(65535, endTick)); end.opCode = OP_END;
|
||||
cutscene.events.push_back(end);
|
||||
if (selectedEnd) selectedCutsceneEvent = static_cast<int>(cutscene.events.size()) - 1;
|
||||
}
|
||||
StopCutscenePreview();
|
||||
}
|
||||
|
||||
void EditorApp::AddCutsceneEvent(uint8_t op) {
|
||||
if (cutscene.events.size() >= MAX_CUTSCENE_EVENTS ||
|
||||
(cutscene.events.empty() && MAX_CUTSCENE_EVENTS < 2)) { statusMessage = "Лимит: 128 команд кат-сцены."; return; }
|
||||
if (op == OP_CHANGE_SUBLEVEL && transitions.empty()) { statusMessage = "Сначала создайте портал перехода на карте."; return; }
|
||||
if ((op == OP_SHOW_SUBTITLE || op == OP_PLAY_SOUND) && cutscene.strings.size() >= MAX_CUTSCENE_STRINGS) {
|
||||
statusMessage = "Лимит: 32 строки/звука в кат-сцене."; return;
|
||||
}
|
||||
PushUndo();
|
||||
CutsceneEvent event{}; event.opCode = op;
|
||||
event.tickTimestamp = static_cast<uint16_t>((std::min)(cutsceneCursor, 61935));
|
||||
switch (op) {
|
||||
case OP_MOVE_CAM_TO: event.paramA = playerStartX*64+32; event.paramB = playerStartY*64+32; event.dataId = 120; break;
|
||||
case OP_PAN_CAM_TO: event.paramA = playerStartAngle; event.paramB = 120; break;
|
||||
case OP_SHOW_SUBTITLE: event.paramA = static_cast<int>(cutscene.strings.size()); event.paramB = 180;
|
||||
cutscene.strings.push_back("Текст кат-сцены"); break;
|
||||
case OP_PLAY_SOUND: {
|
||||
auto sounds = GetSoundList();
|
||||
event.paramA = static_cast<int>(cutscene.strings.size());
|
||||
cutscene.strings.push_back(sounds.empty() ? "sound.wav" : sounds.front()); break;
|
||||
}
|
||||
case OP_CHANGE_SUBLEVEL: event.paramA = (std::max)(0, selectedTransitionIdx); break;
|
||||
case OP_FADE_SCREEN: event.paramA = 16; event.paramB = 60; break;
|
||||
case OP_WAIT: event.paramA = 60; break;
|
||||
}
|
||||
cutscene.events.push_back(event);
|
||||
selectedCutsceneEvent = static_cast<int>(cutscene.events.size()) - 1;
|
||||
NormalizeCutscene();
|
||||
placeCameraPoint = op == OP_MOVE_CAM_TO;
|
||||
ScrubCutscene(cutsceneCursor);
|
||||
}
|
||||
|
||||
void EditorApp::DeleteCutsceneEvent() {
|
||||
if (selectedCutsceneEvent < 0 || selectedCutsceneEvent >= static_cast<int>(cutscene.events.size())) return;
|
||||
PushUndo();
|
||||
cutscene.events.erase(cutscene.events.begin() + selectedCutsceneEvent);
|
||||
selectedCutsceneEvent = -1; placeCameraPoint = false;
|
||||
std::vector<std::string> strings;
|
||||
std::vector<int> remap(cutscene.strings.size(), -1);
|
||||
for (auto& event : cutscene.events) {
|
||||
if ((event.opCode == OP_SHOW_SUBTITLE || event.opCode == OP_PLAY_SOUND) && event.paramA >= 0) {
|
||||
if (remap[event.paramA] < 0) { remap[event.paramA] = static_cast<int>(strings.size()); strings.push_back(cutscene.strings[event.paramA]); }
|
||||
event.paramA = remap[event.paramA];
|
||||
}
|
||||
}
|
||||
cutscene.strings.swap(strings);
|
||||
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
|
||||
}
|
||||
|
||||
void EditorApp::RemoveCutsceneTransition(int index) {
|
||||
cutscene.events.erase(std::remove_if(cutscene.events.begin(), cutscene.events.end(), [&](CutsceneEvent& event) {
|
||||
if (event.opCode != OP_CHANGE_SUBLEVEL) return false;
|
||||
if (event.paramA == index) return true;
|
||||
if (event.paramA > index) --event.paramA;
|
||||
return false;
|
||||
}), cutscene.events.end());
|
||||
selectedCutsceneEvent = -1;
|
||||
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
|
||||
}
|
||||
|
||||
void EditorApp::RenderCutsceneTimeline() {
|
||||
ImGui::TextUnformatted("КАТ-СЦЕНА / 60 кадров в секунду");
|
||||
ImGui::SameLine();
|
||||
bool autoplay = (cutscene.flags & CUTSCENE_AUTOPLAY) != 0;
|
||||
if (ImGui::Checkbox("При входе", &autoplay)) { PushUndo(); cutscene.flags = autoplay ? CUTSCENE_AUTOPLAY : 0; }
|
||||
ImGui::SameLine();
|
||||
if (!cutscenePreviewPlaying) {
|
||||
if (ImGui::Button("▶ Предпросмотр")) {
|
||||
std::string error;
|
||||
if (cutscene.events.empty()) statusMessage = "Добавьте команды кат-сцены.";
|
||||
else if (!ValidateCutscene(cutscene, &map[0][0], transitions.size(), error)) statusMessage = error;
|
||||
else {
|
||||
cutscenePreview.Start(playerStartX*64+32, playerStartY*64+32, playerStartAngle);
|
||||
previewSceneCamera = true;
|
||||
cutsceneCursor = -1; cutscenePreviewPlaying = true; cutscenePreviewAccumulator = 1;
|
||||
AdvanceCutscenePreview(0);
|
||||
}
|
||||
}
|
||||
} else if (ImGui::Button("■ Стоп")) { StopCutscenePreview(); ScrubCutscene(0); }
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Checkbox("WAV", &cutscenePreviewSound) && !cutscenePreviewSound) PlaySoundA(nullptr, nullptr, 0);
|
||||
if (ImGui::Button("+ Камера")) AddCutsceneEvent(OP_MOVE_CAM_TO);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Поворот")) AddCutsceneEvent(OP_PAN_CAM_TO);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Текст")) AddCutsceneEvent(OP_SHOW_SUBTITLE);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Звук")) AddCutsceneEvent(OP_PLAY_SOUND);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Переход")) AddCutsceneEvent(OP_CHANGE_SUBLEVEL);
|
||||
if (ImGui::Button("+ Затемнение")) AddCutsceneEvent(OP_FADE_SCREEN);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Пауза")) AddCutsceneEvent(OP_WAIT);
|
||||
ImGui::SameLine(); if (ImGui::Button("+ Скрыть текст")) AddCutsceneEvent(OP_HIDE_SUBTITLE);
|
||||
ImGui::SameLine(); if (ImGui::Button("Очистить")) {
|
||||
PushUndo(); StopCutscenePreview(); cutscene = {}; selectedCutsceneEvent = -1; placeCameraPoint = false; ScrubCutscene(0);
|
||||
}
|
||||
const char* selectedName = selectedCutsceneEvent >= 0 && selectedCutsceneEvent < static_cast<int>(cutscene.events.size()) ? kSceneNames[cutscene.events[selectedCutsceneEvent].opCode] : "Выберите команду";
|
||||
ImGui::SetNextItemWidth(210);
|
||||
if (ImGui::BeginCombo("Команда",selectedName)) {
|
||||
for (int i=0;i<static_cast<int>(cutscene.events.size());++i) {
|
||||
char label[160]; snprintf(label,sizeof(label),"#%d %s — кадр %u",i+1,kSceneNames[cutscene.events[i].opCode],cutscene.events[i].tickTimestamp);
|
||||
if (ImGui::Selectable(label,i==selectedCutsceneEvent)) { selectedCutsceneEvent=i; placeCameraPoint=false; ScrubCutscene(cutscene.events[i].tickTimestamp); }
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::SetNextItemWidth(160);
|
||||
int frame = cutsceneCursor;
|
||||
if (ImGui::SliderInt("Кадр", &frame, 0, (std::max)(1, CutsceneEndTick()))) ScrubCutscene(frame);
|
||||
ImGui::SameLine(); ImGui::Text("%.2f с / %.2f с", cutsceneCursor/60.0f, CutsceneEndTick()/60.0f);
|
||||
ImGui::SameLine(); ImGui::SetNextItemWidth(90); ImGui::SliderFloat("Масштаб", &cutsceneTimelineZoom, 20, 180, "%.0f");
|
||||
const float left = 82, pixels = cutsceneTimelineZoom/60.0f;
|
||||
ImGui::BeginChild("TrackScroll", ImVec2(0, 130), true, ImGuiWindowFlags_HorizontalScrollbar);
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const float width = (std::max)(ImGui::GetContentRegionAvail().x, left+(CutsceneEndTick()+120)*pixels);
|
||||
auto* draw = ImGui::GetWindowDrawList();
|
||||
static const char* rows[] = {"Камера", "Текст", "Звук", "Сцена"};
|
||||
for (int row = 0; row < 4; ++row) {
|
||||
draw->AddRectFilled(ImVec2(origin.x,origin.y+20+row*22), ImVec2(origin.x+width,origin.y+42+row*22), row%2 ? IM_COL32(31,34,41,255) : IM_COL32(39,42,49,255));
|
||||
draw->AddText(ImVec2(origin.x+2,origin.y+23+row*22), IM_COL32(210,215,225,255), rows[row]);
|
||||
}
|
||||
for (int seconds = 0; seconds <= (CutsceneEndTick()+120)/60; ++seconds) {
|
||||
float x = origin.x+left+seconds*cutsceneTimelineZoom;
|
||||
char label[24]; snprintf(label,sizeof(label),"%ds",seconds);
|
||||
draw->AddText(ImVec2(x,origin.y),IM_COL32(150,160,180,255),label);
|
||||
draw->AddLine(ImVec2(x,origin.y+18),ImVec2(x,origin.y+108),IM_COL32(65,70,82,255));
|
||||
}
|
||||
bool sortAfterDrag = false;
|
||||
for (int i = 0; i < static_cast<int>(cutscene.events.size()); ++i) {
|
||||
auto& event = cutscene.events[i];
|
||||
int row = event.opCode == OP_MOVE_CAM_TO || event.opCode == OP_PAN_CAM_TO ? 0 :
|
||||
event.opCode == OP_SHOW_SUBTITLE || event.opCode == OP_HIDE_SUBTITLE ? 1 : event.opCode == OP_PLAY_SOUND ? 2 : 3;
|
||||
ImVec2 center(origin.x+left+event.tickTimestamp*pixels,origin.y+31+row*22);
|
||||
ImGui::SetCursorScreenPos(ImVec2(center.x-7,center.y-7));
|
||||
ImGui::PushID(i);
|
||||
ImGui::InvisibleButton("Key", ImVec2(14,14));
|
||||
if (ImGui::IsItemActivated()) { PushUndo(); selectedCutsceneEvent = i; dragEventOriginalTick = event.tickTimestamp; StopCutscenePreview(); }
|
||||
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
|
||||
int value = dragEventOriginalTick + static_cast<int>(ImGui::GetMouseDragDelta().x/pixels);
|
||||
event.tickTimestamp = static_cast<uint16_t>((std::max)(0,(std::min)(61935,value)));
|
||||
cutsceneCursor = event.tickTimestamp;
|
||||
}
|
||||
if (ImGui::IsItemDeactivated()) sortAfterDrag = true;
|
||||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s: кадр %u (%.2f с)",kSceneNames[event.opCode],event.tickTimestamp,event.tickTimestamp/60.0f);
|
||||
ImU32 color = selectedCutsceneEvent == i ? IM_COL32(255,220,90,255) : IM_COL32(80+row*35,190-row*20,235,255);
|
||||
draw->AddQuadFilled(ImVec2(center.x,center.y-7),ImVec2(center.x+7,center.y),ImVec2(center.x,center.y+7),ImVec2(center.x-7,center.y),color);
|
||||
ImGui::PopID();
|
||||
}
|
||||
float cursor = origin.x+left+cutsceneCursor*pixels;
|
||||
draw->AddLine(ImVec2(cursor,origin.y),ImVec2(cursor,origin.y+108),IM_COL32(255,90,90,255),2);
|
||||
ImGui::SetCursorScreenPos(origin);
|
||||
ImGui::Dummy(ImVec2(width,112));
|
||||
ImGui::EndChild();
|
||||
if (sortAfterDrag) { NormalizeCutscene(); ScrubCutscene(cutsceneCursor); }
|
||||
if (placeCameraPoint) ImGui::TextWrapped("Выберите точку камеры на карте. Клетки уровня при этом не меняются.");
|
||||
else ImGui::TextWrapped("Выберите ромб для редактирования. C — запуск в игре, Tab — пропуск. Предпросмотр камеры показан на карте.");
|
||||
}
|
||||
|
||||
void EditorApp::RenderCutsceneInspector() {
|
||||
if (selectedCutsceneEvent >= static_cast<int>(cutscene.events.size())) { selectedCutsceneEvent = -1; return; }
|
||||
CutsceneEvent edited = cutscene.events[selectedCutsceneEvent];
|
||||
bool changed = false, activated = false;
|
||||
auto activatedItem = [&]() { activated |= ImGui::IsItemActivated(); };
|
||||
ImGui::TextColored(ImVec4(1,0.8f,0.3f,1),"КАТ-СЦЕНА: %s",kSceneNames[edited.opCode]);
|
||||
int frame = edited.tickTimestamp;
|
||||
changed |= ImGui::InputInt("Кадр",&frame); activatedItem();
|
||||
edited.tickTimestamp = static_cast<uint16_t>((std::max)(0,(std::min)(65535,frame)));
|
||||
ImGui::Text("Время: %.2f с",edited.tickTimestamp/60.0f);
|
||||
bool textChanged = false;
|
||||
std::string newText;
|
||||
switch (edited.opCode) {
|
||||
case OP_MOVE_CAM_TO: {
|
||||
float x = edited.paramA/64.0f, y = edited.paramB/64.0f;
|
||||
if (ImGui::SliderFloat("Камера X",&x,0,31.99f,"%.2f")) { edited.paramA = static_cast<int>(x*64); changed = true; } activatedItem();
|
||||
if (ImGui::SliderFloat("Камера Y",&y,0,31.99f,"%.2f")) { edited.paramB = static_cast<int>(y*64); changed = true; } activatedItem();
|
||||
changed |= ImGui::InputInt("Длительность",&edited.dataId); activatedItem();
|
||||
edited.dataId = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.dataId));
|
||||
if (ImGui::Button("Указать на карте")) placeCameraPoint = true;
|
||||
ImGui::TextWrapped("Линейный пролёт между точками; 0 кадров — мгновенно. Камера не сталкивается со стенами."); break;
|
||||
}
|
||||
case OP_PAN_CAM_TO: {
|
||||
float angle = edited.paramA*360.0f/512;
|
||||
if (ImGui::SliderFloat("Угол",&angle,0,359.3f,"%.1f°")) { edited.paramA = static_cast<int>(angle*512/360)&511; changed = true; } activatedItem();
|
||||
changed |= ImGui::InputInt("Длительность",&edited.paramB); activatedItem();
|
||||
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB));
|
||||
ImGui::TextWrapped("Поворот идёт по кратчайшей дуге."); break;
|
||||
}
|
||||
case OP_SHOW_SUBTITLE: case OP_PLAY_SOUND: {
|
||||
char buffer[384]{};
|
||||
if (edited.paramA >= 0 && edited.paramA < static_cast<int>(cutscene.strings.size())) strcpy_s(buffer,cutscene.strings[edited.paramA].c_str());
|
||||
if (edited.opCode == OP_SHOW_SUBTITLE) {
|
||||
textChanged = ImGui::InputTextMultiline("Текст",buffer,sizeof(buffer),ImVec2(-1,85)); activatedItem();
|
||||
changed |= ImGui::InputInt("Показывать кадров",&edited.paramB); activatedItem();
|
||||
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB));
|
||||
ImGui::TextWrapped("До 95 символов CP1251, на экране 3 строки по 38. 0 кадров — до команды скрытия или конца.");
|
||||
} else {
|
||||
textChanged = ImGui::InputText("WAV",buffer,sizeof(buffer)); activatedItem();
|
||||
auto sounds = GetSoundList();
|
||||
if (ImGui::BeginCombo("Из ассетов",buffer)) {
|
||||
for (const auto& sound : sounds) if (ImGui::Selectable(sound.c_str())) { strcpy_s(buffer,sound.c_str()); textChanged = true; activated = true; }
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
bool loop = (edited.param8&1)!=0;
|
||||
if (ImGui::Checkbox("Повторять",&loop)) { edited.param8 = loop ? 1 : 0; changed = true; } activatedItem();
|
||||
if (ImGui::Button("Стоп звука в этой точке")) { edited.paramA = -1; changed = true; activated = true; }
|
||||
ImGui::TextWrapped("Имя WAV из assets/sounds, ASCII, до 63 символов. Новый звук заменяет предыдущий.");
|
||||
}
|
||||
newText = buffer;
|
||||
changed |= textChanged;
|
||||
break;
|
||||
}
|
||||
case OP_FADE_SCREEN:
|
||||
changed |= ImGui::SliderInt("Затемнение",&edited.paramA,0,16); activatedItem();
|
||||
changed |= ImGui::InputInt("Длительность",&edited.paramB); activatedItem();
|
||||
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB)); break;
|
||||
case OP_WAIT:
|
||||
changed |= ImGui::InputInt("Пауза кадров",&edited.paramA); activatedItem();
|
||||
edited.paramA = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramA));
|
||||
ImGui::TextWrapped("Пауза задерживает следующие команды; начатые движения продолжаются."); break;
|
||||
case OP_CHANGE_SUBLEVEL:
|
||||
if (ImGui::BeginCombo("Портал",edited.paramA >= 0 && edited.paramA < static_cast<int>(transitions.size()) ? transitions[edited.paramA].targetMap : "Нет перехода")) {
|
||||
for (int i=0;i<static_cast<int>(transitions.size());++i) {
|
||||
ImGui::PushID(i);
|
||||
if (ImGui::Selectable(transitions[i].targetMap,edited.paramA==i)) { edited.paramA=i; changed=true; activated=true; }
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
} break;
|
||||
}
|
||||
if (activated) PushUndo();
|
||||
if (changed) {
|
||||
StopCutscenePreview();
|
||||
if (textChanged && edited.paramA < 0 && cutscene.strings.size() < MAX_CUTSCENE_STRINGS) {
|
||||
edited.paramA = static_cast<int>(cutscene.strings.size()); cutscene.strings.push_back(newText);
|
||||
}
|
||||
cutscene.events[selectedCutsceneEvent] = edited;
|
||||
if (textChanged && edited.paramA >= 0) cutscene.strings[edited.paramA] = newText;
|
||||
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
|
||||
}
|
||||
if (ImGui::Button("Удалить команду")) DeleteCutsceneEvent();
|
||||
}
|
||||
|
||||
void EditorApp::DrawCutscenePath(void* list, float ox, float oy, float size) {
|
||||
if (!showCutsceneTimeline) return;
|
||||
auto* draw = static_cast<ImDrawList*>(list);
|
||||
ImVec2 previous(ox+(playerStartX+0.5f)*size,oy+(playerStartY+0.5f)*size);
|
||||
for (int i=0;i<static_cast<int>(cutscene.events.size());++i) {
|
||||
const auto& event=cutscene.events[i];
|
||||
if (event.opCode!=OP_MOVE_CAM_TO) continue;
|
||||
ImVec2 point(ox+event.paramA/64.0f*size,oy+event.paramB/64.0f*size);
|
||||
draw->AddLine(previous,point,IM_COL32(80,190,255,220),2);
|
||||
draw->AddCircleFilled(point,i==selectedCutsceneEvent?6.0f:4.0f,IM_COL32(255,220,60,255));
|
||||
previous=point;
|
||||
}
|
||||
if (cutscene.events.empty()) return;
|
||||
ImVec2 camera(ox+cutscenePreview.x/64.0f*size,oy+cutscenePreview.y/64.0f*size);
|
||||
float angle=cutscenePreview.angle*3.14159265f/256;
|
||||
draw->AddCircle(camera,size*0.3f,IM_COL32(255,255,255,255),0,2);
|
||||
draw->AddLine(camera,ImVec2(camera.x+std::cos(angle)*size,camera.y+std::sin(angle)*size),IM_COL32(255,255,255,255),2);
|
||||
if (cutscenePreview.subtitle>=0 && cutscenePreview.subtitle<static_cast<int>(cutscene.strings.size())) {
|
||||
ImVec2 top=ImGui::GetWindowPos(), available=ImGui::GetWindowSize();
|
||||
ImVec2 p(top.x+8,top.y+available.y-66);
|
||||
draw->AddRectFilled(p,ImVec2(top.x+available.x-8,top.y+available.y-8),IM_COL32(0,0,0,200));
|
||||
draw->AddText(nullptr,0,ImVec2(p.x+8,p.y+4),IM_COL32(255,255,255,255),cutscene.strings[cutscenePreview.subtitle].c_str(),nullptr,available.x-40);
|
||||
}
|
||||
if (cutscenePreview.fade) {
|
||||
ImVec2 top=ImGui::GetWindowPos(), available=ImGui::GetWindowSize();
|
||||
draw->AddRectFilled(top,ImVec2(top.x+available.x,top.y+available.y),IM_COL32(0,0,0,cutscenePreview.fade*255/16));
|
||||
}
|
||||
}
|
||||
186
editor/src/editor_viewport.inl
Normal file
186
editor/src/editor_viewport.inl
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// The viewport displays frames produced by engine.exe itself, not a second raycaster.
|
||||
std::string EditorApp::FindEnginePath() const {
|
||||
char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH);
|
||||
const auto directory = fs::path(module).parent_path();
|
||||
std::error_code ec;
|
||||
for (const auto& candidate : {directory / "engine.exe", directory.parent_path() / "engine.exe", fs::absolute("../engine.exe", ec)}) {
|
||||
ec.clear();
|
||||
if (fs::is_regular_file(candidate, ec)) return fs::absolute(candidate, ec).string();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void EditorApp::StopEnginePreview() {
|
||||
if (previewProcess.hProcess) {
|
||||
if (previewPacket) InterlockedExchange(&previewPacket->state, 3);
|
||||
if (WaitForSingleObject(previewProcess.hProcess, 300) == WAIT_TIMEOUT) {
|
||||
TerminateProcess(previewProcess.hProcess, 0);
|
||||
WaitForSingleObject(previewProcess.hProcess, 1000);
|
||||
}
|
||||
CloseHandle(previewProcess.hProcess); CloseHandle(previewProcess.hThread);
|
||||
previewProcess = {};
|
||||
}
|
||||
if (previewPacket) { UnmapViewOfFile(previewPacket); previewPacket = nullptr; }
|
||||
if (previewMapping) { CloseHandle(previewMapping); previewMapping = nullptr; }
|
||||
previewHasFrame = false;
|
||||
}
|
||||
|
||||
bool EditorApp::EnsureEnginePreview() {
|
||||
if (previewPacket) return true;
|
||||
if (previewAttempted) return false;
|
||||
previewAttempted = true;
|
||||
auto fail = [&](const std::string& reason) {
|
||||
previewError = reason; StopEnginePreview(); return false;
|
||||
};
|
||||
const auto engine = FindEnginePath();
|
||||
if (engine.empty()) return fail("Не найден engine.exe. Соберите движок через build.bat.");
|
||||
const std::string name = "Local\\EblanPreview-" + std::to_string(GetCurrentProcessId()) + "-" + std::to_string(GetTickCount64());
|
||||
previewMapping = CreateFileMappingA(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, sizeof(EnginePreviewPacket), name.c_str());
|
||||
if (!previewMapping) return fail("Не удалось создать буфер игрового вида: " + std::to_string(GetLastError()));
|
||||
previewPacket = static_cast<EnginePreviewPacket*>(MapViewOfFile(previewMapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(EnginePreviewPacket)));
|
||||
if (!previewPacket) return fail("Не удалось открыть буфер игрового вида.");
|
||||
// Initialize in place: no large packet on the thread's stack.
|
||||
memset(previewPacket, 0, sizeof(*previewPacket));
|
||||
previewPacket->magic = 0x56504245; previewPacket->version = 1; previewPacket->parentPid = GetCurrentProcessId();
|
||||
std::string command = "\"" + engine + "\" \"--preview:" + name + "\"";
|
||||
STARTUPINFOA si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE;
|
||||
if (!CreateProcessA(engine.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr,
|
||||
fs::path(engine).parent_path().string().c_str(), &si, &previewProcess))
|
||||
return fail("Не удалось запустить игровой вид: " + std::to_string(GetLastError()));
|
||||
previewTexturesDirty = true; previewError.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditorApp::UpdateEnginePreview() {
|
||||
if (!EnsureEnginePreview()) return;
|
||||
if (WaitForSingleObject(previewProcess.hProcess, 0) == WAIT_OBJECT_0) {
|
||||
DWORD code = 0; GetExitCodeProcess(previewProcess.hProcess, &code);
|
||||
previewError = "Игровой вид остановлен (код " + std::to_string(code) + "). Пересоберите engine.exe.";
|
||||
StopEnginePreview(); return;
|
||||
}
|
||||
const LONG state = InterlockedCompareExchange(&previewPacket->state, 0, 0);
|
||||
if (state == 1) {
|
||||
if (GetTickCount64() - previewRequestTime > 5000) {
|
||||
previewError = "Движок не ответил на запрос игрового вида."; StopEnginePreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state == 2) {
|
||||
previewPixels.assign(std::begin(previewPacket->pixels), std::end(previewPacket->pixels));
|
||||
for (auto& pixel : previewPixels) pixel |= 0xFF000000; // DIB X -> opaque DX11 alpha
|
||||
if (pd3dDevice) {
|
||||
if (!previewTexture) {
|
||||
D3D11_TEXTURE2D_DESC desc{};
|
||||
desc.Width = 320; desc.Height = 240; desc.MipLevels = desc.ArraySize = 1;
|
||||
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; desc.SampleDesc.Count = 1;
|
||||
desc.Usage = D3D11_USAGE_DEFAULT; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
|
||||
if (FAILED(pd3dDevice->CreateTexture2D(&desc,nullptr,&previewTexture)) ||
|
||||
FAILED(pd3dDevice->CreateShaderResourceView(previewTexture,nullptr,&previewSRV))) {
|
||||
if (previewSRV) { previewSRV->Release(); previewSRV = nullptr; }
|
||||
if (previewTexture) { previewTexture->Release(); previewTexture = nullptr; }
|
||||
previewError = "Не удалось создать изображение игрового вида."; StopEnginePreview(); return;
|
||||
}
|
||||
}
|
||||
ID3D11DeviceContext* context = nullptr; pd3dDevice->GetImmediateContext(&context);
|
||||
context->UpdateSubresource(previewTexture,0,nullptr,previewPixels.data(),320*4,0);
|
||||
context->Release();
|
||||
}
|
||||
previewHasFrame = true;
|
||||
}
|
||||
if (previewTexturesDirty) {
|
||||
if (!ReadTextureSlots(GetTexturePackPath(), previewTextures)) {
|
||||
previewError = "Повреждён пакет текстур. Исправьте его и нажмите «Повторить».";
|
||||
StopEnginePreview(); return;
|
||||
}
|
||||
memcpy(previewPacket->textures,previewTextures.data(),previewTextures.size());
|
||||
++previewPacket->textureRevision; previewTexturesDirty = false;
|
||||
}
|
||||
const bool scene = showCutsceneTimeline && !cutscene.events.empty() && previewSceneCamera;
|
||||
previewPacket->x = scene ? cutscenePreview.x : previewSceneCamera ? playerStartX*64+32 : previewX;
|
||||
previewPacket->y = scene ? cutscenePreview.y : previewSceneCamera ? playerStartY*64+32 : previewY;
|
||||
previewPacket->angle = scene ? cutscenePreview.angle : previewSceneCamera ? playerStartAngle : previewAngle;
|
||||
previewPacket->ceiling = ceilingColor; previewPacket->floor = floorColor;
|
||||
previewPacket->spriteCount = static_cast<uint32_t>((std::min)(sprites.size(),size_t(32)));
|
||||
memcpy(previewPacket->cells,map,sizeof(map));
|
||||
if (!sprites.empty()) memcpy(previewPacket->sprites,sprites.data(),previewPacket->spriteCount*sizeof(SpriteEntry));
|
||||
previewPacket->cinematic = scene && cutscenePreview.active;
|
||||
previewPacket->fade = scene ? cutscenePreview.fade : 0;
|
||||
memset(previewPacket->subtitle,0,96);
|
||||
if (scene && cutscenePreview.subtitle >= 0 && cutscenePreview.subtitle < static_cast<int>(cutscene.strings.size())) {
|
||||
std::string encoded;
|
||||
if (CutsceneEncode(cutscene.strings[cutscenePreview.subtitle],encoded)) memcpy(previewPacket->subtitle,encoded.data(),encoded.size());
|
||||
}
|
||||
previewRequestTime = GetTickCount64();
|
||||
InterlockedExchange(&previewPacket->state,1);
|
||||
}
|
||||
|
||||
static void ViewportPointSampler(const ImDrawList*, const ImDrawCmd* command) {
|
||||
auto* sampler = static_cast<ID3D11SamplerState*>(command->UserCallbackData);
|
||||
ID3D11Device* device = nullptr; sampler->GetDevice(&device);
|
||||
ID3D11DeviceContext* context = nullptr; device->GetImmediateContext(&context);
|
||||
context->PSSetSamplers(0,1,&sampler); context->Release(); device->Release();
|
||||
}
|
||||
|
||||
void EditorApp::RenderSceneViewport() {
|
||||
ImGui::TextUnformatted("ИГРОВОЙ ВИД / ASM");
|
||||
if (ImGui::Button("К спавну")) {
|
||||
previewSceneCamera = true; ScrubCutscene(0);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Checkbox("Камера сцены",&previewSceneCamera) && !previewSceneCamera) {
|
||||
previewX = cutscenePreview.x; previewY = cutscenePreview.y; previewAngle = cutscenePreview.angle;
|
||||
}
|
||||
if (!pd3dDevice) { ImGui::TextUnformatted("Игровой вид доступен в окне редактора."); return; }
|
||||
UpdateEnginePreview();
|
||||
if (!previewError.empty()) {
|
||||
ImGui::TextWrapped("%s",previewError.c_str());
|
||||
if (ImGui::Button("Повторить")) { StopEnginePreview(); previewAttempted = false; previewError.clear(); }
|
||||
}
|
||||
if (!previewHasFrame || !previewSRV) { ImGui::TextUnformatted("Подготовка кадра движка…"); return; }
|
||||
const auto available = ImGui::GetContentRegionAvail();
|
||||
float width = (std::max)(1.0f,(std::min)(available.x,(available.y-48)*4/3));
|
||||
ImVec2 size(width,width*3/4);
|
||||
if (!previewSampler) {
|
||||
D3D11_SAMPLER_DESC desc{}; desc.Filter=D3D11_FILTER_MIN_MAG_MIP_POINT;
|
||||
desc.AddressU=desc.AddressV=desc.AddressW=D3D11_TEXTURE_ADDRESS_CLAMP; desc.MaxLOD=D3D11_FLOAT32_MAX;
|
||||
pd3dDevice->CreateSamplerState(&desc,&previewSampler);
|
||||
}
|
||||
if (previewSampler) ImGui::GetWindowDrawList()->AddCallback(ViewportPointSampler,previewSampler);
|
||||
ImGui::Image(reinterpret_cast<ImTextureID>(previewSRV),size);
|
||||
if (previewSampler) ImGui::GetWindowDrawList()->AddCallback(ImDrawCallback_ResetRenderState,nullptr);
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) ImGui::SetWindowFocus();
|
||||
// Free camera never writes to the player's spawn or to authored keyframes.
|
||||
if (!cutscenePreviewPlaying && !ImGui::GetIO().WantTextInput &&
|
||||
ImGui::IsWindowFocused() && !ImGui::IsAnyItemActive() && !ImGui::IsPopupOpen(nullptr,ImGuiPopupFlags_AnyPopupId)) {
|
||||
const auto held = [](ImGuiKey key) { return ImGui::IsKeyDown(key) ? 1 : 0; };
|
||||
const int forward = held(ImGuiKey_W)-held(ImGuiKey_S), strafe = held(ImGuiKey_D)-held(ImGuiKey_A);
|
||||
const int turn = held(ImGuiKey_RightArrow)-held(ImGuiKey_LeftArrow);
|
||||
const float dt = (std::min)(ImGui::GetIO().DeltaTime,0.1f);
|
||||
if (forward || strafe || turn || (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right))) {
|
||||
if (previewSceneCamera) {
|
||||
previewX = previewPacket ? previewPacket->x : playerStartX*64+32;
|
||||
previewY = previewPacket ? previewPacket->y : playerStartY*64+32;
|
||||
previewAngle = previewPacket ? previewPacket->angle : playerStartAngle;
|
||||
previewSceneCamera = false;
|
||||
}
|
||||
previewTurn += turn*128*dt + (hovered && ImGui::IsMouseDown(ImGuiMouseButton_Right) ? ImGui::GetIO().MouseDelta.x*0.5 : 0);
|
||||
int step=static_cast<int>(previewTurn); previewTurn-=step; previewAngle=(previewAngle+step)&511;
|
||||
const double a = previewAngle*3.141592653589793/256;
|
||||
const double speed = (ImGui::GetIO().KeyShift ? 360 : 180)*dt;
|
||||
previewMoveX += (forward*std::cos(a)-strafe*std::sin(a))*speed;
|
||||
previewMoveY += (forward*std::sin(a)+strafe*std::cos(a))*speed;
|
||||
int dx=static_cast<int>(previewMoveX),dy=static_cast<int>(previewMoveY);
|
||||
previewMoveX-=dx; previewMoveY-=dy;
|
||||
auto free = [&](int x,int y) {
|
||||
for (int oy : {-12,12}) for (int ox : {-12,12}) {
|
||||
if(x+ox<0 || x+ox>=2048 || y+oy<0 || y+oy>=2048 || !IsWalkableCell(map[(y+oy)/64][(x+ox)/64])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (free(previewX+dx,previewY)) previewX+=dx;
|
||||
if (free(previewX,previewY+dy)) previewY+=dy;
|
||||
}
|
||||
}
|
||||
ImGui::TextWrapped("Клик по виду: WASD, стрелки — осмотр. Shift — быстрее. Камера сцены — предпросмотр таймлайна.");
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
// ==============================================================================
|
||||
|
||||
constexpr uint32_t MAP_MAGIC = 0x4E4C4245; // 'EBLN'
|
||||
constexpr uint16_t MAP_FORMAT_VERSION = 2; // v2: sprite x/y in raw world units (v1 was 16.16 fixed)
|
||||
constexpr uint16_t MAP_FORMAT_VERSION = 4; // v4 adds a cutscene track and CP1251 string pool
|
||||
constexpr int MAP_GRID_W = 32;
|
||||
constexpr int MAP_GRID_H = 32;
|
||||
constexpr int MAP_TOTAL_CELLS = MAP_GRID_W * MAP_GRID_H; // 1024 bytes
|
||||
|
|
@ -24,13 +24,37 @@ enum CellType : uint8_t {
|
|||
CELL_PILLAR = 7,
|
||||
CELL_GLASS = 8,
|
||||
CELL_MARBLE = 9,
|
||||
CELL_COUNT = 10
|
||||
CELL_PORTAL = 10,
|
||||
CELL_COUNT = 11
|
||||
};
|
||||
|
||||
constexpr int MAX_LEVEL_TRANSITIONS = 16;
|
||||
constexpr int MAX_CUTSCENE_EVENTS = 128;
|
||||
constexpr int MAX_CUTSCENE_STRINGS = 32;
|
||||
constexpr int CUTSCENE_STRING_BYTES = 96;
|
||||
constexpr int CUTSCENE_MAX_DURATION = 3600;
|
||||
constexpr uint16_t CUTSCENE_AUTOPLAY = 1;
|
||||
|
||||
enum CutsceneOpCode : uint8_t {
|
||||
OP_END, OP_WAIT, OP_MOVE_CAM_TO, OP_PAN_CAM_TO, OP_SHOW_SUBTITLE,
|
||||
OP_HIDE_SUBTITLE, OP_PLAY_SOUND, OP_FADE_SCREEN, OP_CHANGE_SUBLEVEL
|
||||
};
|
||||
|
||||
#pragma pack(push, 1)
|
||||
struct LevelTransition {
|
||||
uint8_t triggerCellX; // Trigger cell coordinate X (0..31)
|
||||
uint8_t triggerCellY; // Trigger cell coordinate Y (0..31)
|
||||
char targetMap[32]; // Target sublevel filename (e.g. "sublevel_02.dat")
|
||||
uint16_t targetSpawnX; // Spawn cell X in destination map (0..31)
|
||||
uint16_t targetSpawnY; // Spawn cell Y in destination map (0..31)
|
||||
uint16_t targetAngle; // Player orientation (0..511)
|
||||
uint8_t fadeEffect; // 1 = smooth fade to black, 0 = instant cut
|
||||
uint8_t reserved; // Padding (total 42 bytes)
|
||||
};
|
||||
|
||||
struct MapHeader {
|
||||
uint32_t magic; // 'EBLN'
|
||||
uint16_t version; // 1
|
||||
uint16_t version; // Current writer: 4; reader also accepts v1/v2/v3
|
||||
uint16_t mapWidth; // 32
|
||||
uint16_t mapHeight; // 32
|
||||
uint16_t playerStartX; // Cell coordinate (0..31)
|
||||
|
|
@ -39,7 +63,20 @@ struct MapHeader {
|
|||
uint16_t spriteCount; // Number of active sprites
|
||||
uint32_t ceilingColor; // 00RRGGBBh
|
||||
uint32_t floorColor; // 00RRGGBBh
|
||||
uint8_t reserved[12]; // Reserved for future use
|
||||
uint16_t transitionCount; // Number of sublevel transitions
|
||||
uint16_t cutsceneCount; // v4 only; old versions ignore the remaining header fields
|
||||
uint16_t cutsceneStringCount;
|
||||
uint16_t cutsceneFlags; // bit 0 = start automatically after entering this map
|
||||
uint8_t reserved[4]; // Total header remains 38 bytes
|
||||
};
|
||||
|
||||
struct CutsceneEvent {
|
||||
uint16_t tickTimestamp; // Absolute 60 Hz timeline timestamp, 0..65535
|
||||
uint8_t opCode;
|
||||
uint8_t param8; // PLAY_SOUND bit 0 = loop; other operations require 0
|
||||
int32_t paramA; // X, angle, text/sound index, fade level or transition index
|
||||
int32_t paramB; // Y, duration (PAN/FADE/SUBTITLE) or unused
|
||||
int32_t dataId; // MOVE duration; never a serialized memory pointer
|
||||
};
|
||||
|
||||
// Engine world units: 1 cell = 64 units (raw integers, NOT fixed point).
|
||||
|
|
@ -52,3 +89,8 @@ struct SpriteEntry {
|
|||
uint32_t flags; // Flags / type
|
||||
};
|
||||
#pragma pack(pop)
|
||||
|
||||
static_assert(sizeof(MapHeader) == 38, "EBLN header layout changed");
|
||||
static_assert(sizeof(SpriteEntry) == 24, "EBLN sprite layout changed");
|
||||
static_assert(sizeof(LevelTransition) == 42, "EBLN transition layout changed");
|
||||
static_assert(sizeof(CutsceneEvent) == 16, "EBLN cutscene event layout changed");
|
||||
|
|
|
|||
116
editor/src/level_io.h
Normal file
116
editor/src/level_io.h
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#pragma once
|
||||
#include "level_format.h"
|
||||
#include "cutscene_format.h"
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// Parse into a separate value: a failed load must never partially replace a room.
|
||||
struct BinaryLevel {
|
||||
MapHeader header{};
|
||||
std::array<uint8_t, MAP_TOTAL_CELLS> cells{};
|
||||
std::vector<SpriteEntry> sprites;
|
||||
std::vector<LevelTransition> transitions;
|
||||
CutsceneTrack cutscene;
|
||||
};
|
||||
|
||||
inline bool IsMapFilename(const char* name) {
|
||||
size_t length = 0;
|
||||
for (; length < 32 && name[length]; ++length) {
|
||||
unsigned char ch = static_cast<unsigned char>(name[length]);
|
||||
if (ch < 32 || ch >= 127 || std::strchr("\\/:*?\"<>|", ch)) return false;
|
||||
}
|
||||
if (length < 5 || length >= 32) return false;
|
||||
const char* ext = name + length - 4;
|
||||
return ext[0] == '.' && (ext[1] | 32) == 'd' &&
|
||||
(ext[2] | 32) == 'a' && (ext[3] | 32) == 't';
|
||||
}
|
||||
|
||||
inline bool IsWalkableCell(uint8_t cell) {
|
||||
return cell == CELL_EMPTY || cell == CELL_DOOR_OPEN || cell == CELL_PORTAL;
|
||||
}
|
||||
|
||||
inline bool ValidateLevel(const BinaryLevel& level, std::string& error) {
|
||||
const auto& h = level.header;
|
||||
auto fail = [&](const char* reason) { error = reason; return false; };
|
||||
if (h.magic != MAP_MAGIC || h.version < 1 || h.version > MAP_FORMAT_VERSION ||
|
||||
h.mapWidth != MAP_GRID_W || h.mapHeight != MAP_GRID_H)
|
||||
return fail("Неверный формат или версия карты");
|
||||
if (level.sprites.size() > MAX_LEVEL_SPRITES || level.transitions.size() > MAX_LEVEL_TRANSITIONS)
|
||||
return fail("Превышен лимит спрайтов или переходов");
|
||||
if (h.playerStartX >= MAP_GRID_W || h.playerStartY >= MAP_GRID_H || h.playerStartAngle > 511)
|
||||
return fail("Неверные координаты или угол спавна");
|
||||
for (uint8_t cell : level.cells) if (cell > 15) return fail("Неверный тип клетки");
|
||||
if (!IsWalkableCell(level.cells[h.playerStartY * MAP_GRID_W + h.playerStartX]))
|
||||
return fail("Спавн игрока находится внутри стены");
|
||||
for (const auto& sprite : level.sprites) {
|
||||
if (sprite.x < 0 || sprite.x >= MAP_GRID_W * 64 || sprite.y < 0 || sprite.y >= MAP_GRID_H * 64 ||
|
||||
sprite.textureId >= 16 || sprite.scale < 0 || sprite.scale > 16 * 65536)
|
||||
return fail("Неверные координаты, текстура или масштаб спрайта");
|
||||
}
|
||||
std::array<bool, MAP_TOTAL_CELLS> triggers{};
|
||||
for (const auto& transition : level.transitions) {
|
||||
if (transition.triggerCellX >= MAP_GRID_W || transition.triggerCellY >= MAP_GRID_H ||
|
||||
transition.targetSpawnX >= MAP_GRID_W || transition.targetSpawnY >= MAP_GRID_H ||
|
||||
transition.targetAngle > 511 || transition.fadeEffect > 1 || !IsMapFilename(transition.targetMap))
|
||||
return fail("Неверные свойства перехода (имя файла: ASCII, до 31 символа, .dat)");
|
||||
size_t index = transition.triggerCellY * MAP_GRID_W + transition.triggerCellX;
|
||||
if (triggers[index] || !IsWalkableCell(level.cells[index]))
|
||||
return fail("Повторный переход или триггер внутри стены");
|
||||
triggers[index] = true;
|
||||
}
|
||||
for (size_t i = 0; i < level.cells.size(); ++i)
|
||||
if (level.cells[i] == CELL_PORTAL && !triggers[i]) return fail("Портал не имеет настроенного перехода");
|
||||
return ValidateCutscene(level.cutscene, level.cells.data(), level.transitions.size(), error);
|
||||
}
|
||||
|
||||
inline bool ReadLevelFile(const std::string& path, BinaryLevel& result, std::string& error) {
|
||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||||
if (!in) { error = "Не удалось открыть карту: " + path; return false; }
|
||||
const auto fileSize = in.tellg();
|
||||
in.seekg(0);
|
||||
BinaryLevel candidate;
|
||||
if (!in.read(reinterpret_cast<char*>(&candidate.header), sizeof(MapHeader))) {
|
||||
error = "Обрезанный заголовок карты"; return false;
|
||||
}
|
||||
const auto& h = candidate.header;
|
||||
const size_t transitionCount = h.version == 1 ? 0 : h.transitionCount;
|
||||
if (h.spriteCount > MAX_LEVEL_SPRITES || transitionCount > MAX_LEVEL_TRANSITIONS) {
|
||||
error = "Превышен лимит объектов карты"; return false;
|
||||
}
|
||||
const size_t eventCount = h.version >= 4 ? h.cutsceneCount : 0;
|
||||
const size_t stringCount = h.version >= 4 ? h.cutsceneStringCount : 0;
|
||||
if (eventCount > MAX_CUTSCENE_EVENTS || stringCount > MAX_CUTSCENE_STRINGS) {
|
||||
error = "Превышен лимит событий или строк кат-сцены"; return false;
|
||||
}
|
||||
const size_t expected = sizeof(MapHeader) + MAP_TOTAL_CELLS +
|
||||
h.spriteCount * sizeof(SpriteEntry) + transitionCount * sizeof(LevelTransition) +
|
||||
eventCount * sizeof(CutsceneEvent) + stringCount * CUTSCENE_STRING_BYTES;
|
||||
if (fileSize != static_cast<std::streamoff>(expected)) {
|
||||
error = "Размер файла не соответствует заголовку карты"; return false;
|
||||
}
|
||||
candidate.sprites.resize(h.spriteCount);
|
||||
candidate.transitions.resize(transitionCount);
|
||||
in.read(reinterpret_cast<char*>(candidate.cells.data()), MAP_TOTAL_CELLS);
|
||||
if (!candidate.sprites.empty())
|
||||
in.read(reinterpret_cast<char*>(candidate.sprites.data()), candidate.sprites.size() * sizeof(SpriteEntry));
|
||||
if (!candidate.transitions.empty())
|
||||
in.read(reinterpret_cast<char*>(candidate.transitions.data()), candidate.transitions.size() * sizeof(LevelTransition));
|
||||
candidate.cutscene.events.resize(eventCount);
|
||||
if (eventCount) in.read(reinterpret_cast<char*>(candidate.cutscene.events.data()), eventCount * sizeof(CutsceneEvent));
|
||||
candidate.cutscene.flags = h.version >= 4 ? h.cutsceneFlags : 0;
|
||||
for (size_t i = 0; i < stringCount; ++i) {
|
||||
char bytes[CUTSCENE_STRING_BYTES]{};
|
||||
if (!in.read(bytes, sizeof(bytes))) { error = "Обрезанная строка кат-сцены"; return false; }
|
||||
const char* end = static_cast<const char*>(std::memchr(bytes, 0, sizeof(bytes)));
|
||||
if (!end) { error = "Строка кат-сцены не имеет завершающего нуля"; return false; }
|
||||
candidate.cutscene.strings.push_back(CutsceneDecode(bytes, static_cast<int>(end - bytes)));
|
||||
}
|
||||
if (!in) { error = "Не удалось прочитать карту целиком"; return false; }
|
||||
if (h.version == 1) for (auto& sprite : candidate.sprites) { sprite.x >>= 16; sprite.y >>= 16; }
|
||||
if (!ValidateLevel(candidate, error)) return false;
|
||||
result = std::move(candidate);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -6,9 +6,14 @@
|
|||
#include <tchar.h>
|
||||
#include <string>
|
||||
|
||||
#include <shellapi.h>
|
||||
|
||||
// Forward declare message handler from imgui_impl_win32.cpp
|
||||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
|
||||
// Global Application Instance (for Drag & Drop messages)
|
||||
static EditorApp* g_pApp = nullptr;
|
||||
|
||||
// Global DirectX 11 Data
|
||||
static ID3D11Device* g_pd3dDevice = nullptr;
|
||||
static ID3D11DeviceContext* g_pd3dDeviceContext = nullptr;
|
||||
|
|
@ -87,6 +92,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
|||
|
||||
ShowWindow(hWnd, SW_SHOWDEFAULT);
|
||||
UpdateWindow(hWnd);
|
||||
DragAcceptFiles(hWnd, TRUE);
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
|
|
@ -135,6 +141,9 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
|||
ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext);
|
||||
|
||||
EditorApp app;
|
||||
app.LoadLevel(app.currentFilePath);
|
||||
app.InitD3D(g_pd3dDevice);
|
||||
g_pApp = &app;
|
||||
|
||||
bool done = false;
|
||||
while (!done) {
|
||||
|
|
@ -161,6 +170,9 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
|
|||
g_pSwapChain->Present(1, 0); // V-Sync enabled for 60 FPS
|
||||
}
|
||||
|
||||
g_pApp = nullptr;
|
||||
app.ShutdownD3D();
|
||||
|
||||
ImGui_ImplDX11_Shutdown();
|
||||
ImGui_ImplWin32_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
|
|
@ -227,6 +239,15 @@ LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
|||
return true;
|
||||
|
||||
switch (msg) {
|
||||
case WM_DROPFILES: {
|
||||
HDROP hDrop = reinterpret_cast<HDROP>(wParam);
|
||||
char filePath[MAX_PATH];
|
||||
if (DragQueryFileA(hDrop, 0, filePath, MAX_PATH)) {
|
||||
if (g_pApp) g_pApp->HandleDropFile(filePath);
|
||||
}
|
||||
DragFinish(hDrop);
|
||||
return 0;
|
||||
}
|
||||
case WM_SIZE:
|
||||
if (g_pd3dDevice != NULL && wParam != SIZE_MINIMIZED) {
|
||||
CleanupRenderTarget();
|
||||
|
|
|
|||
28
editor/src/preview_protocol.h
Normal file
28
editor/src/preview_protocol.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include "level_format.h"
|
||||
|
||||
// Single producer/consumer mailbox, shared by the x64 editor and x86 engine.
|
||||
// state: 0 ready, 1 request owned by engine, 2 result owned by editor, 3 quit.
|
||||
// Never modify payload while state == 1. All fields have fixed wire widths.
|
||||
struct EnginePreviewPacket {
|
||||
uint32_t magic = 0x56504245; // EBPV
|
||||
uint32_t version = 1;
|
||||
volatile long state = 0;
|
||||
uint32_t parentPid = 0;
|
||||
int32_t x = 160, y = 160, angle = 0;
|
||||
uint32_t ceiling = 0, floor = 0, spriteCount = 0;
|
||||
uint32_t cinematic = 0, fade = 0, textureRevision = 0;
|
||||
uint32_t reserved[19]{};
|
||||
uint8_t cells[1024]{};
|
||||
SpriteEntry sprites[32]{};
|
||||
char subtitle[96]{};
|
||||
uint32_t textures[16*128*128]{};
|
||||
uint32_t pixels[320*240]{};
|
||||
};
|
||||
static_assert(sizeof(long) == 4, "Preview protocol requires Windows LONG");
|
||||
static_assert(offsetof(EnginePreviewPacket, cells) == 128, "Preview header mismatch");
|
||||
static_assert(offsetof(EnginePreviewPacket, textures) == 2016, "Preview texture offset mismatch");
|
||||
static_assert(offsetof(EnginePreviewPacket, pixels) == 1050592, "Preview framebuffer offset mismatch");
|
||||
static_assert(sizeof(EnginePreviewPacket) == 1357792, "Preview mapping size mismatch");
|
||||
7988
editor/src/stb_image.h
Normal file
7988
editor/src/stb_image.h
Normal file
File diff suppressed because it is too large
Load diff
1724
editor/src/stb_image_write.h
Normal file
1724
editor/src/stb_image_write.h
Normal file
File diff suppressed because it is too large
Load diff
BIN
engine.exe
BIN
engine.exe
Binary file not shown.
15
examples/cutscene_demo/README.md
Normal file
15
examples/cutscene_demo/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Пример кат-сцены
|
||||
|
||||
Запустите `run_demo.bat` после сборки движка. Вступление длится около 10 секунд:
|
||||
камера проходит три отрезка, поворачивается, показывает русские субтитры,
|
||||
проигрывает WAV и с затемнением переходит во вторую комнату. Tab пропускает сцену,
|
||||
C запускает её повторно на карте, где она есть. Во второй комнате работает обычное управление.
|
||||
|
||||
Для редактирования откройте `assets/map.dat` этой папки в EblanStudio (Ctrl+O),
|
||||
затем откройте таймлайн клавишей F6. Выберите команду в списке или ромб на дорожке.
|
||||
F5 использует временную копию карт и звуков. Исходные карты основного проекта этот пример не меняет.
|
||||
|
||||
Файлы примера можно восстановить командой `python tools/make_cutscene_demo.py`
|
||||
из корня репозитория. Предпросмотр в редакторе показывает сцену глазами камеры
|
||||
в игровом виде, а на карте — её траекторию. Ползунок кадра перематывает изображение.
|
||||
F5 запускает полную последовательность со сменой комнаты.
|
||||
BIN
examples/cutscene_demo/assets/map.dat
Normal file
BIN
examples/cutscene_demo/assets/map.dat
Normal file
Binary file not shown.
BIN
examples/cutscene_demo/assets/second_room.dat
Normal file
BIN
examples/cutscene_demo/assets/second_room.dat
Normal file
Binary file not shown.
BIN
examples/cutscene_demo/assets/sounds/chime.wav
Normal file
BIN
examples/cutscene_demo/assets/sounds/chime.wav
Normal file
Binary file not shown.
3
examples/cutscene_demo/run_demo.bat
Normal file
3
examples/cutscene_demo/run_demo.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
cd /d "%~dp0"
|
||||
start "" "..\..\engine.exe" "assets\map.dat"
|
||||
583
include/engine_cutscene.inc
Normal file
583
include/engine_cutscene.inc
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
; Timed 60 Hz cutscene track. Strings are bounded CP1251 data, never pointers.
|
||||
.code
|
||||
|
||||
ValidateSoundFilename PROC uses ebx ecx esi, namePtr:DWORD
|
||||
mov esi, namePtr
|
||||
xor ecx, ecx
|
||||
cs_sound_character:
|
||||
mov al, [esi+ecx]
|
||||
test al, al
|
||||
jz cs_sound_end
|
||||
cmp al, 32
|
||||
jb cs_sound_invalid
|
||||
cmp al, 127
|
||||
jae cs_sound_invalid
|
||||
cmp al, 92
|
||||
je cs_sound_invalid
|
||||
cmp al, '/'
|
||||
je cs_sound_invalid
|
||||
cmp al, ':'
|
||||
je cs_sound_invalid
|
||||
cmp al, '*'
|
||||
je cs_sound_invalid
|
||||
cmp al, '?'
|
||||
je cs_sound_invalid
|
||||
cmp al, 34
|
||||
je cs_sound_invalid
|
||||
cmp al, '<'
|
||||
je cs_sound_invalid
|
||||
cmp al, '>'
|
||||
je cs_sound_invalid
|
||||
cmp al, '|'
|
||||
je cs_sound_invalid
|
||||
inc ecx
|
||||
cmp ecx, 64
|
||||
jb cs_sound_character
|
||||
cs_sound_invalid:
|
||||
xor eax, eax
|
||||
ret
|
||||
cs_sound_end:
|
||||
cmp ecx, 5
|
||||
jb cs_sound_invalid
|
||||
mov eax, [esi+ecx-4]
|
||||
or eax, 20202000h
|
||||
cmp eax, 7661772Eh
|
||||
jne cs_sound_invalid
|
||||
mov eax, 1
|
||||
ret
|
||||
ValidateSoundFilename ENDP
|
||||
|
||||
ValidateStagedCutscene PROC uses ebx ecx edx esi edi, eventCount:DWORD, stringCount:DWORD, flags:DWORD, cells:DWORD, transitions:DWORD
|
||||
LOCAL previous:DWORD, eventIndex:DWORD
|
||||
cmp eventCount, MAX_CUTSCENE_EVENTS
|
||||
ja csv_invalid
|
||||
cmp stringCount, MAX_CUTSCENE_STRINGS
|
||||
ja csv_invalid
|
||||
mov eax, flags
|
||||
and eax, 0FFFFFFFEh
|
||||
jnz csv_invalid
|
||||
mov esi, OFFSET stagedCutsceneStrings
|
||||
mov edx, stringCount
|
||||
csv_next_string:
|
||||
test edx, edx
|
||||
jz csv_events
|
||||
xor ecx, ecx
|
||||
csv_string_char:
|
||||
mov al, [esi+ecx]
|
||||
test al, al
|
||||
jz csv_string_end
|
||||
cmp al, 10
|
||||
je csv_string_advance
|
||||
cmp al, 32
|
||||
jb csv_invalid
|
||||
csv_string_advance:
|
||||
inc ecx
|
||||
cmp ecx, CUTSCENE_STRING_BYTES
|
||||
jb csv_string_char
|
||||
jmp csv_invalid
|
||||
csv_string_end:
|
||||
add esi, CUTSCENE_STRING_BYTES
|
||||
dec edx
|
||||
jmp csv_next_string
|
||||
csv_events:
|
||||
mov esi, OFFSET stagedCutsceneEvents
|
||||
mov previous, 0
|
||||
mov eventIndex, 0
|
||||
csv_event:
|
||||
mov eax, eventIndex
|
||||
cmp eax, eventCount
|
||||
jae csv_finished
|
||||
movzx eax, [esi].CutsceneEvent.tickTimestamp
|
||||
cmp eax, previous
|
||||
jb csv_invalid
|
||||
mov previous, eax
|
||||
movzx eax, [esi].CutsceneEvent.opCode
|
||||
cmp eax, OP_CHANGE_SUBLEVEL
|
||||
ja csv_invalid
|
||||
movzx edx, [esi].CutsceneEvent.param8
|
||||
cmp eax, OP_PLAY_SOUND
|
||||
je csv_sound_flags
|
||||
test edx, edx
|
||||
jnz csv_invalid
|
||||
jmp csv_dispatch
|
||||
csv_sound_flags:
|
||||
cmp edx, 1
|
||||
ja csv_invalid
|
||||
csv_dispatch:
|
||||
cmp eax, OP_END
|
||||
je csv_end
|
||||
cmp eax, OP_WAIT
|
||||
je csv_wait
|
||||
cmp eax, OP_MOVE_CAM_TO
|
||||
je csv_move
|
||||
cmp eax, OP_PAN_CAM_TO
|
||||
je csv_pan
|
||||
cmp eax, OP_FADE_SCREEN
|
||||
je csv_fade
|
||||
cmp eax, OP_SHOW_SUBTITLE
|
||||
je csv_text
|
||||
cmp eax, OP_PLAY_SOUND
|
||||
je csv_sound
|
||||
cmp eax, OP_CHANGE_SUBLEVEL
|
||||
je csv_transition
|
||||
jmp csv_next
|
||||
csv_end:
|
||||
mov eax, eventIndex
|
||||
inc eax
|
||||
cmp eax, eventCount
|
||||
jne csv_invalid
|
||||
jmp csv_next
|
||||
csv_wait:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, CUTSCENE_MAX_DURATION
|
||||
ja csv_invalid
|
||||
jmp csv_next
|
||||
csv_move:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, MAP_W*CELL_SIZE
|
||||
jae csv_invalid
|
||||
mov ebx, [esi].CutsceneEvent.paramB
|
||||
cmp ebx, MAP_H*CELL_SIZE
|
||||
jae csv_invalid
|
||||
shr eax, CELL_SHIFT
|
||||
shr ebx, CELL_SHIFT
|
||||
shl ebx, 5
|
||||
add ebx, eax
|
||||
add ebx, cells
|
||||
mov al, [ebx]
|
||||
cmp al, CELL_EMPTY
|
||||
je csv_move_duration
|
||||
cmp al, CELL_DOOR_OPEN
|
||||
je csv_move_duration
|
||||
cmp al, CELL_PORTAL
|
||||
jne csv_invalid
|
||||
csv_move_duration:
|
||||
mov eax, [esi].CutsceneEvent.dataId
|
||||
cmp eax, CUTSCENE_MAX_DURATION
|
||||
ja csv_invalid
|
||||
jmp csv_next
|
||||
csv_pan:
|
||||
cmp [esi].CutsceneEvent.paramA, ANGLE_MASK
|
||||
ja csv_invalid
|
||||
jmp csv_duration
|
||||
csv_fade:
|
||||
cmp [esi].CutsceneEvent.paramA, 16
|
||||
ja csv_invalid
|
||||
jmp csv_duration
|
||||
csv_text:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, stringCount
|
||||
jae csv_invalid
|
||||
csv_duration:
|
||||
cmp [esi].CutsceneEvent.paramB, CUTSCENE_MAX_DURATION
|
||||
ja csv_invalid
|
||||
jmp csv_next
|
||||
csv_sound:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, -1
|
||||
je csv_next
|
||||
cmp eax, stringCount
|
||||
jae csv_invalid
|
||||
imul eax, CUTSCENE_STRING_BYTES
|
||||
add eax, OFFSET stagedCutsceneStrings
|
||||
invoke ValidateSoundFilename, eax
|
||||
test eax, eax
|
||||
jz csv_invalid
|
||||
jmp csv_next
|
||||
csv_transition:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, transitions
|
||||
jae csv_invalid
|
||||
csv_next:
|
||||
movzx eax, [esi].CutsceneEvent.opCode
|
||||
xor ebx, ebx
|
||||
cmp eax, OP_MOVE_CAM_TO
|
||||
jne csv_span_not_move
|
||||
mov ebx, [esi].CutsceneEvent.dataId
|
||||
jmp csv_check_span
|
||||
csv_span_not_move:
|
||||
cmp eax, OP_WAIT
|
||||
jne csv_span_not_wait
|
||||
mov ebx, [esi].CutsceneEvent.paramA
|
||||
jmp csv_check_span
|
||||
csv_span_not_wait:
|
||||
cmp eax, OP_PAN_CAM_TO
|
||||
je csv_span_param_b
|
||||
cmp eax, OP_FADE_SCREEN
|
||||
je csv_span_param_b
|
||||
cmp eax, OP_SHOW_SUBTITLE
|
||||
jne csv_check_span
|
||||
csv_span_param_b:
|
||||
mov ebx, [esi].CutsceneEvent.paramB
|
||||
csv_check_span:
|
||||
movzx eax, [esi].CutsceneEvent.tickTimestamp
|
||||
add eax, ebx
|
||||
cmp eax, 65535
|
||||
ja csv_invalid
|
||||
add esi, sizeof CutsceneEvent
|
||||
inc eventIndex
|
||||
jmp csv_event
|
||||
csv_finished:
|
||||
cmp eventCount, 0
|
||||
je csv_valid
|
||||
cmp BYTE PTR [esi-sizeof CutsceneEvent+CutsceneEvent.opCode], OP_END
|
||||
jne csv_invalid
|
||||
csv_valid:
|
||||
mov eax, 1
|
||||
ret
|
||||
csv_invalid:
|
||||
xor eax, eax
|
||||
ret
|
||||
ValidateStagedCutscene ENDP
|
||||
|
||||
StopCutscene PROC uses eax ebx
|
||||
mov isCutsceneActive, 0
|
||||
mov cutscenePending, 0
|
||||
mov cutsceneSubtitle, -1
|
||||
mov cutsceneSubtitleUntil, 0
|
||||
mov cutsceneFade, 0
|
||||
mov csMoveDuration, 0
|
||||
mov csAngleDuration, 0
|
||||
mov csFadeDuration, 0
|
||||
mov cutsceneWait, 0
|
||||
mov eax, playerY
|
||||
sar eax, CELL_SHIFT
|
||||
shl eax, 5
|
||||
mov ebx, playerX
|
||||
sar ebx, CELL_SHIFT
|
||||
add eax, ebx
|
||||
mov portalLatchCell, eax
|
||||
invoke PlaySoundA, 0, 0, 0
|
||||
ret
|
||||
StopCutscene ENDP
|
||||
|
||||
StartCutscene PROC
|
||||
mov cutscenePending, 0
|
||||
cmp cutsceneCount, 0
|
||||
je cs_start_empty
|
||||
call StopCutscene
|
||||
mov cutsceneTick, 0
|
||||
mov cutsceneIndex, 0
|
||||
mov isCutsceneActive, 1
|
||||
mov eax, 1
|
||||
ret
|
||||
cs_start_empty:
|
||||
xor eax, eax
|
||||
ret
|
||||
StartCutscene ENDP
|
||||
|
||||
UpdateCutscene PROC uses ebx ecx edx esi edi
|
||||
LOCAL transitionFade:DWORD
|
||||
cmp isCutsceneActive, 0
|
||||
je cs_update_done
|
||||
cmp csMoveDuration, 0
|
||||
je cs_step_angle
|
||||
inc csMoveElapsed
|
||||
mov eax, csMoveToX
|
||||
sub eax, csMoveFromX
|
||||
imul csMoveElapsed
|
||||
idiv csMoveDuration
|
||||
add eax, csMoveFromX
|
||||
mov playerX, eax
|
||||
mov eax, csMoveToY
|
||||
sub eax, csMoveFromY
|
||||
imul csMoveElapsed
|
||||
idiv csMoveDuration
|
||||
add eax, csMoveFromY
|
||||
mov playerY, eax
|
||||
mov eax, csMoveElapsed
|
||||
cmp eax, csMoveDuration
|
||||
jb cs_step_angle
|
||||
mov csMoveDuration, 0
|
||||
cs_step_angle:
|
||||
cmp csAngleDuration, 0
|
||||
je cs_step_fade
|
||||
inc csAngleElapsed
|
||||
mov eax, csAngleDelta
|
||||
imul csAngleElapsed
|
||||
idiv csAngleDuration
|
||||
add eax, csAngleFrom
|
||||
and eax, ANGLE_MASK
|
||||
mov playerAngle, eax
|
||||
mov eax, csAngleElapsed
|
||||
cmp eax, csAngleDuration
|
||||
jb cs_step_fade
|
||||
mov csAngleDuration, 0
|
||||
cs_step_fade:
|
||||
cmp csFadeDuration, 0
|
||||
je cs_subtitle_expiry
|
||||
inc csFadeElapsed
|
||||
mov eax, csFadeTo
|
||||
sub eax, csFadeFrom
|
||||
imul csFadeElapsed
|
||||
idiv csFadeDuration
|
||||
add eax, csFadeFrom
|
||||
mov cutsceneFade, eax
|
||||
mov eax, csFadeElapsed
|
||||
cmp eax, csFadeDuration
|
||||
jb cs_subtitle_expiry
|
||||
mov csFadeDuration, 0
|
||||
cs_subtitle_expiry:
|
||||
cmp cutsceneSubtitleUntil, 0
|
||||
je cs_step_wait
|
||||
mov eax, cutsceneTick
|
||||
cmp eax, cutsceneSubtitleUntil
|
||||
jb cs_step_wait
|
||||
mov cutsceneSubtitle, -1
|
||||
mov cutsceneSubtitleUntil, 0
|
||||
cs_step_wait:
|
||||
cmp cutsceneWait, 0
|
||||
je cs_dispatch
|
||||
dec cutsceneWait
|
||||
jnz cs_advance_tick
|
||||
cs_dispatch:
|
||||
mov eax, cutsceneIndex
|
||||
cmp eax, cutsceneCount
|
||||
jae cs_advance_tick
|
||||
shl eax, 4
|
||||
add eax, OFFSET cutsceneEvents
|
||||
mov esi, eax
|
||||
movzx eax, [esi].CutsceneEvent.tickTimestamp
|
||||
cmp eax, cutsceneTick
|
||||
ja cs_advance_tick
|
||||
inc cutsceneIndex
|
||||
movzx eax, [esi].CutsceneEvent.opCode
|
||||
cmp eax, OP_END
|
||||
je cs_end
|
||||
cmp eax, OP_WAIT
|
||||
je cs_wait
|
||||
cmp eax, OP_MOVE_CAM_TO
|
||||
je cs_move
|
||||
cmp eax, OP_PAN_CAM_TO
|
||||
je cs_pan
|
||||
cmp eax, OP_SHOW_SUBTITLE
|
||||
je cs_text
|
||||
cmp eax, OP_HIDE_SUBTITLE
|
||||
je cs_hide_text
|
||||
cmp eax, OP_PLAY_SOUND
|
||||
je cs_play_sound
|
||||
cmp eax, OP_FADE_SCREEN
|
||||
je cs_fade
|
||||
cmp eax, OP_CHANGE_SUBLEVEL
|
||||
je cs_change_level
|
||||
jmp cs_end
|
||||
cs_wait:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
mov cutsceneWait, eax
|
||||
test eax, eax
|
||||
jnz cs_advance_tick
|
||||
jmp cs_dispatch
|
||||
cs_move:
|
||||
mov eax, playerX
|
||||
mov csMoveFromX, eax
|
||||
mov eax, playerY
|
||||
mov csMoveFromY, eax
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
mov csMoveToX, eax
|
||||
mov eax, [esi].CutsceneEvent.paramB
|
||||
mov csMoveToY, eax
|
||||
mov eax, [esi].CutsceneEvent.dataId
|
||||
mov csMoveDuration, eax
|
||||
mov csMoveElapsed, 0
|
||||
test eax, eax
|
||||
jnz cs_dispatch
|
||||
mov eax, csMoveToX
|
||||
mov playerX, eax
|
||||
mov eax, csMoveToY
|
||||
mov playerY, eax
|
||||
jmp cs_dispatch
|
||||
cs_pan:
|
||||
mov eax, playerAngle
|
||||
mov csAngleFrom, eax
|
||||
mov ebx, [esi].CutsceneEvent.paramA
|
||||
sub ebx, eax
|
||||
add ebx, 256
|
||||
and ebx, ANGLE_MASK
|
||||
sub ebx, 256
|
||||
mov csAngleDelta, ebx
|
||||
mov csAngleElapsed, 0
|
||||
mov eax, [esi].CutsceneEvent.paramB
|
||||
mov csAngleDuration, eax
|
||||
test eax, eax
|
||||
jnz cs_dispatch
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
mov playerAngle, eax
|
||||
jmp cs_dispatch
|
||||
cs_text:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
mov cutsceneSubtitle, eax
|
||||
mov eax, [esi].CutsceneEvent.paramB
|
||||
test eax, eax
|
||||
jz cs_text_expiry_ready
|
||||
add eax, cutsceneTick
|
||||
cs_text_expiry_ready:
|
||||
mov cutsceneSubtitleUntil, eax
|
||||
jmp cs_dispatch
|
||||
cs_hide_text:
|
||||
mov cutsceneSubtitle, -1
|
||||
mov cutsceneSubtitleUntil, 0
|
||||
jmp cs_dispatch
|
||||
cs_play_sound:
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
cmp eax, -1
|
||||
jne cs_sound_start
|
||||
invoke PlaySoundA, 0, 0, 0
|
||||
jmp cs_dispatch
|
||||
cs_sound_start:
|
||||
imul eax, CUTSCENE_STRING_BYTES
|
||||
add eax, OFFSET cutsceneStrings
|
||||
push esi
|
||||
mov esi, OFFSET csSoundPrefix
|
||||
mov edi, OFFSET csSoundPath
|
||||
cs_copy_sound_prefix:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jnz cs_copy_sound_prefix
|
||||
dec edi
|
||||
pop esi
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
imul eax, CUTSCENE_STRING_BYTES
|
||||
add eax, OFFSET cutsceneStrings
|
||||
mov ebx, esi
|
||||
mov esi, eax
|
||||
cs_copy_sound_name:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jnz cs_copy_sound_name
|
||||
movzx eax, [ebx].CutsceneEvent.param8
|
||||
shl eax, 3
|
||||
or eax, 00020003h ; SND_FILENAME | SND_ASYNC | SND_NODEFAULT (+ SND_LOOP)
|
||||
invoke PlaySoundA, OFFSET csSoundPath, 0, eax
|
||||
jmp cs_dispatch
|
||||
cs_fade:
|
||||
mov eax, cutsceneFade
|
||||
mov csFadeFrom, eax
|
||||
mov eax, [esi].CutsceneEvent.paramA
|
||||
mov csFadeTo, eax
|
||||
mov eax, [esi].CutsceneEvent.paramB
|
||||
mov csFadeDuration, eax
|
||||
mov csFadeElapsed, 0
|
||||
test eax, eax
|
||||
jnz cs_dispatch
|
||||
mov eax, csFadeTo
|
||||
mov cutsceneFade, eax
|
||||
jmp cs_dispatch
|
||||
cs_change_level:
|
||||
mov eax, cutsceneFade
|
||||
mov transitionFade, eax
|
||||
mov ebx, [esi].CutsceneEvent.paramA
|
||||
imul ebx, sizeof LevelTransition
|
||||
add ebx, OFFSET transitionList
|
||||
call StopCutscene
|
||||
movzx eax, [ebx].LevelTransition.targetSpawnX
|
||||
movzx ecx, [ebx].LevelTransition.targetSpawnY
|
||||
movzx edx, [ebx].LevelTransition.targetAngle
|
||||
movzx esi, [ebx].LevelTransition.fadeEffect
|
||||
lea edi, [ebx].LevelTransition.targetMap
|
||||
invoke SwitchSublevel, edi, eax, ecx, edx, esi
|
||||
cmp fadeState, 1
|
||||
jne cs_transition_ready
|
||||
mov eax, transitionFade
|
||||
cmp eax, fadeLevel
|
||||
jbe cs_transition_ready
|
||||
mov fadeLevel, eax ; continue from the cinematic fade without flashing bright
|
||||
cs_transition_ready:
|
||||
ret
|
||||
cs_end:
|
||||
call StopCutscene
|
||||
ret
|
||||
cs_advance_tick:
|
||||
inc cutsceneTick
|
||||
cs_update_done:
|
||||
ret
|
||||
UpdateCutscene ENDP
|
||||
|
||||
; Letterbox (320 / 2.35 -> 136-pixel picture) and up to three wrapped lines.
|
||||
RenderCutsceneOverlay PROC uses ebx ecx edx esi edi
|
||||
LOCAL row:DWORD, count:DWORD, pixelY:DWORD
|
||||
invoke DrawRect, 0, 0, SCREEN_W, 52, COLOR_BLACK
|
||||
invoke DrawRect, 0, 188, SCREEN_W, 52, COLOR_BLACK
|
||||
invoke DrawString8x8, 232, 10, OFFSET strSkipScene, COLOR_GRAY
|
||||
mov eax, cutsceneSubtitle
|
||||
cmp eax, -1
|
||||
je cs_overlay_done
|
||||
cmp eax, cutsceneStringCount
|
||||
jae cs_overlay_done
|
||||
imul eax, CUTSCENE_STRING_BYTES
|
||||
add eax, OFFSET cutsceneStrings
|
||||
mov esi, eax
|
||||
; Semitransparent subtitle panel over the bottom of the picture.
|
||||
mov edi, OFFSET screenBuffer + 158*SCREEN_PITCH
|
||||
mov ecx, 30*SCREEN_W
|
||||
cs_subtitle_dim:
|
||||
mov eax, [edi]
|
||||
and eax, 00FEFEFEh
|
||||
shr eax, 1
|
||||
stosd
|
||||
loop cs_subtitle_dim
|
||||
mov row, 0
|
||||
cs_subtitle_line:
|
||||
mov edi, OFFSET csLineBuffer
|
||||
mov count, 0
|
||||
cs_subtitle_char:
|
||||
lodsb
|
||||
test al, al
|
||||
jz cs_subtitle_string_end
|
||||
cmp al, 10
|
||||
je cs_subtitle_emit
|
||||
stosb
|
||||
inc count
|
||||
cmp count, 38
|
||||
jb cs_subtitle_char
|
||||
jmp cs_subtitle_emit
|
||||
cs_subtitle_string_end:
|
||||
dec esi
|
||||
cs_subtitle_emit:
|
||||
mov byte ptr [edi], 0
|
||||
mov eax, count
|
||||
shl eax, 2
|
||||
mov ebx, SCREEN_HALF_W
|
||||
sub ebx, eax
|
||||
mov eax, row
|
||||
imul eax, 10
|
||||
add eax, 160
|
||||
invoke DrawString8x8, ebx, eax, OFFSET csLineBuffer, COLOR_WHITE
|
||||
cmp byte ptr [esi], 0
|
||||
je cs_overlay_done
|
||||
inc row
|
||||
cmp row, 3
|
||||
jb cs_subtitle_line
|
||||
cs_overlay_done:
|
||||
ret
|
||||
RenderCutsceneOverlay ENDP
|
||||
|
||||
ApplyCutsceneFade PROC uses ebx ecx edx esi edi
|
||||
mov ebx, 16
|
||||
sub ebx, cutsceneFade
|
||||
cmp ebx, 16
|
||||
jae cs_fade_done
|
||||
mov edi, OFFSET screenBuffer
|
||||
mov ecx, SCREEN_W*SCREEN_H
|
||||
cs_fade_pixel:
|
||||
mov eax, [edi]
|
||||
movzx edx, al
|
||||
imul edx, ebx
|
||||
shr edx, 4
|
||||
movzx eax, ah
|
||||
imul eax, ebx
|
||||
shr eax, 4
|
||||
shl eax, 8
|
||||
or edx, eax
|
||||
mov eax, [edi]
|
||||
shr eax, 16
|
||||
imul eax, ebx
|
||||
shr eax, 4
|
||||
shl eax, 16
|
||||
or eax, edx
|
||||
stosd
|
||||
loop cs_fade_pixel
|
||||
cs_fade_done:
|
||||
ret
|
||||
ApplyCutsceneFade ENDP
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
|
||||
COMM screenBuffer:BYTE:FRAME_BUFFER_SIZE
|
||||
COMM textureBuffer:BYTE:MAX_TEXTURES * TEX_SIZE_BYTES
|
||||
COMM portalTextureBuffer:BYTE:TEX_SIZE_BYTES
|
||||
COMM texturePackStaging:BYTE:MAX_TEXTURES * TEX_SIZE_BYTES
|
||||
; ==============================================================================
|
||||
; EBLAN ENGINE - Global State and Data Structures
|
||||
; ==============================================================================
|
||||
|
|
@ -47,11 +52,34 @@ keyAction db 0
|
|||
keyRun db 0
|
||||
keyShowMap db 1 ; minimap toggle
|
||||
keyScreenshot db 0 ; F12 screenshot request
|
||||
keyActionHeld db 0
|
||||
keyMapHeld db 0
|
||||
keyShotHeld db 0
|
||||
keyCutscene db 0
|
||||
keySkipScene db 0
|
||||
keySceneHeld db 0
|
||||
keySkipHeld db 0
|
||||
|
||||
; Map palette (overridable by map.dat header)
|
||||
mapCeilingColor dd COLOR_CEILING
|
||||
mapFloorColor dd COLOR_FLOOR
|
||||
szMapFilePath db "assets\map.dat",0
|
||||
szAssetsPrefix db "assets\",0
|
||||
startupMapPath db MAX_PATH_BYTES dup(0)
|
||||
portalLatchCell dd -1 ; suppress repeated entry until the player leaves the cell
|
||||
mapErrorFrames dd 0
|
||||
|
||||
; Sector Streaming & Portals
|
||||
transitionCount dd 0
|
||||
pendingMapPath db 64 dup(0)
|
||||
szSublevelFullPath db 80 dup(0)
|
||||
pendingSpawnX dd 0
|
||||
pendingSpawnY dd 0
|
||||
pendingSpawnAngle dd 0
|
||||
|
||||
; Screen Fade Transition State Machine
|
||||
fadeState dd 0 ; 0 = None, 1 = Fading Out, 2 = Black / Switching, 3 = Fading In
|
||||
fadeLevel dd 0 ; 0..16 (0 = visible, 16 = black)
|
||||
|
||||
; Texture pack (optional; falls back to procedural textures)
|
||||
szTexPackPath db "assets\textures\texturepack.bin",0
|
||||
|
|
@ -63,9 +91,40 @@ spriteCount dd 0
|
|||
; Engine HUD & Strings
|
||||
strControls1 db "WASD: Move / Strafe | Shift: Run",0
|
||||
strControls2 db "Arrows: Turn Camera",0
|
||||
strControls3 db "E: Door | M: Minimap | F12: Shot | Esc: Exit",0
|
||||
strControls3 db "E:Use C:Scene M:Map F12:Shot Esc:Exit",0
|
||||
strEngineName db "EBLAN ENGINE 3D (x86 MASM)",0
|
||||
strDoorHint db "[E] - Door",0
|
||||
strPortalHint db "[E] - Enter Portal",0
|
||||
strMapLoadError db "Cannot load destination level",0
|
||||
strSkipScene db "[Tab] Skip",0
|
||||
cutsceneCount dd 0
|
||||
cutsceneStringCount dd 0
|
||||
cutsceneFlags dd 0
|
||||
isCutsceneActive dd 0
|
||||
cutscenePending dd 0
|
||||
cutsceneTick dd 0
|
||||
cutsceneIndex dd 0
|
||||
cutsceneWait dd 0
|
||||
cutsceneSubtitle dd -1
|
||||
cutsceneSubtitleUntil dd 0
|
||||
cutsceneFade dd 0
|
||||
csMoveFromX dd 0
|
||||
csMoveFromY dd 0
|
||||
csMoveToX dd 0
|
||||
csMoveToY dd 0
|
||||
csMoveDuration dd 0
|
||||
csMoveElapsed dd 0
|
||||
csAngleFrom dd 0
|
||||
csAngleDelta dd 0
|
||||
csAngleDuration dd 0
|
||||
csAngleElapsed dd 0
|
||||
csFadeFrom dd 0
|
||||
csFadeTo dd 0
|
||||
csFadeDuration dd 0
|
||||
csFadeElapsed dd 0
|
||||
csSoundPrefix db "assets\sounds\",0
|
||||
csSoundPath db 128 dup(0)
|
||||
csLineBuffer db 40 dup(0)
|
||||
|
||||
; Screenshot (F12): writes 32-bit top-down BMP of screenBuffer
|
||||
szScreenshotPath db "screenshot.bmp",0
|
||||
|
|
@ -88,22 +147,50 @@ screenshotHeader db 'B','M'
|
|||
; ------------------------------------------------------------------------------
|
||||
.data?
|
||||
ALIGN 4
|
||||
screenBuffer db FRAME_BUFFER_SIZE dup(?)
|
||||
; (moved to COMM)
|
||||
|
||||
ALIGN 4
|
||||
depthBuffer dd SCREEN_W dup(?)
|
||||
|
||||
; Glass (Transparent Wall) per-column buffers
|
||||
ALIGN 4
|
||||
glassDistBuffer dd SCREEN_W dup(?)
|
||||
glassDrawStartBuf dd SCREEN_W dup(?)
|
||||
glassDrawEndBuf dd SCREEN_W dup(?)
|
||||
glassLineHeightBuf dd SCREEN_W dup(?)
|
||||
glassTexXBuf dd SCREEN_W dup(?)
|
||||
glassSideHitBuf dd SCREEN_W dup(?)
|
||||
|
||||
; Fixed-point Math Lookup Tables (512 angles, 16.16 signed fixed point)
|
||||
ALIGN 4
|
||||
tableSin dd ANGLE_512 dup(?)
|
||||
tableCos dd ANGLE_512 dup(?)
|
||||
ALIGN 4
|
||||
tableRayAngleOffset dd SCREEN_W dup(?)
|
||||
ALIGN 4
|
||||
tableRayCos dd SCREEN_W dup(?)
|
||||
|
||||
; Game Map Grid (32x32 = 1024 bytes)
|
||||
gameMap db MAP_SIZE dup(?)
|
||||
|
||||
; Texture Memory (16 slots of 128x128 32-bit BGRA = 1 MB)
|
||||
ALIGN 4
|
||||
textureBuffer db MAX_TEXTURES * TEX_SIZE_BYTES dup(?)
|
||||
; (moved to COMM)
|
||||
|
||||
; (moved to COMM)
|
||||
|
||||
; (moved to COMM)
|
||||
|
||||
|
||||
ALIGN 4
|
||||
spriteList db MAX_SPRITES * (sizeof SpriteStruct) dup(?)
|
||||
spriteOrder dd MAX_SPRITES dup(?) ; sprite indices sorted far-to-near
|
||||
|
||||
ALIGN 4
|
||||
transitionList db MAX_TRANSITIONS * (sizeof LevelTransition) dup(?)
|
||||
|
||||
ALIGN 4
|
||||
cutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?)
|
||||
cutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?)
|
||||
stagedCutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?)
|
||||
stagedCutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ VK_KEY_UP equ 26h
|
|||
VK_KEY_RIGHT equ 27h
|
||||
VK_KEY_DOWN equ 28h
|
||||
VK_KEY_F12 equ 7Bh
|
||||
VK_KEY_C equ 43h
|
||||
VK_KEY_TAB equ 9
|
||||
|
||||
; Colors (32-bit 00RRGGBBh; in memory a DIB stores bytes B,G,R,0 little-endian)
|
||||
COLOR_BLACK equ 00000000h
|
||||
|
|
@ -89,6 +91,24 @@ CELL_DOOR_OPEN equ 6
|
|||
CELL_PILLAR equ 7
|
||||
CELL_GLASS equ 8
|
||||
CELL_MARBLE equ 9
|
||||
CELL_PORTAL equ 10 ; Sector transition portal
|
||||
CELL_COUNT equ 11
|
||||
|
||||
; Sector Transition Portals
|
||||
MAX_TRANSITIONS equ 16
|
||||
MAX_PATH_BYTES equ 260
|
||||
MAP_FORMAT_VERSION equ 4
|
||||
|
||||
LevelTransition STRUCT ; sizeof = 42 bytes (packed)
|
||||
triggerCellX db 0 ; 1 byte
|
||||
triggerCellY db 0 ; 1 byte
|
||||
targetMap db 32 dup(0); 32 bytes ASCII
|
||||
targetSpawnX dw 0 ; 2 bytes
|
||||
targetSpawnY dw 0 ; 2 bytes
|
||||
targetAngle dw 0 ; 2 bytes
|
||||
fadeEffect db 0 ; 1 byte (0=instant, 1=fade to black)
|
||||
reserved db 0 ; 1 byte padding
|
||||
LevelTransition ENDS
|
||||
|
||||
; Sprite Types & Struct
|
||||
MAX_SPRITES equ 32
|
||||
|
|
@ -118,7 +138,7 @@ MAP_MAGIC equ 4E4C4245h ; 'EBLN'
|
|||
|
||||
MapHeader STRUCT ; sizeof = 38 bytes (packed)
|
||||
magic dd 0 ; 'EBLN'
|
||||
version dw 0 ; 1 = sprite coords 16.16 fixed, 2 = raw world units
|
||||
version dw 0 ; 1 = fixed coords, 2/3 = transitions, 4 = cutscenes
|
||||
mapWidth dw 0 ; 32
|
||||
mapHeight dw 0 ; 32
|
||||
playerStartX dw 0 ; cell X
|
||||
|
|
@ -127,5 +147,32 @@ MapHeader STRUCT ; sizeof = 38 bytes (packed)
|
|||
spriteCount dw 0 ; number of sprites
|
||||
ceilingColor dd 0 ; 00RRGGBBh
|
||||
floorColor dd 0 ; 00RRGGBBh
|
||||
reserved db 12 dup(0)
|
||||
transitionCount dw 0 ; number of transitions in file
|
||||
cutsceneCount dw 0
|
||||
cutsceneStringCount dw 0
|
||||
cutsceneFlags dw 0
|
||||
reserved db 4 dup(0)
|
||||
MapHeader ENDS
|
||||
|
||||
MAX_CUTSCENE_EVENTS equ 128
|
||||
MAX_CUTSCENE_STRINGS equ 32
|
||||
CUTSCENE_STRING_BYTES equ 96
|
||||
CUTSCENE_MAX_DURATION equ 3600
|
||||
CUTSCENE_AUTOPLAY equ 1
|
||||
OP_END equ 0
|
||||
OP_WAIT equ 1
|
||||
OP_MOVE_CAM_TO equ 2
|
||||
OP_PAN_CAM_TO equ 3
|
||||
OP_SHOW_SUBTITLE equ 4
|
||||
OP_HIDE_SUBTITLE equ 5
|
||||
OP_PLAY_SOUND equ 6
|
||||
OP_FADE_SCREEN equ 7
|
||||
OP_CHANGE_SUBLEVEL equ 8
|
||||
CutsceneEvent STRUCT
|
||||
tickTimestamp dw 0
|
||||
opCode db 0
|
||||
param8 db 0
|
||||
paramA dd 0
|
||||
paramB dd 0
|
||||
dataId dd 0
|
||||
CutsceneEvent ENDS
|
||||
|
|
|
|||
|
|
@ -8,6 +8,27 @@
|
|||
; PollInput: Reads keyboard state and updates movement & action flags
|
||||
; ------------------------------------------------------------------------------
|
||||
PollInput PROC uses eax ebx
|
||||
mov keyForward, 0
|
||||
mov keyBackward, 0
|
||||
mov keyStrafeLeft, 0
|
||||
mov keyStrafeRight, 0
|
||||
mov keyTurnLeft, 0
|
||||
mov keyTurnRight, 0
|
||||
mov keyRun, 0
|
||||
mov keyAction, 0
|
||||
mov keyScreenshot, 0
|
||||
mov keyCutscene, 0
|
||||
mov keySkipScene, 0
|
||||
invoke GetForegroundWindow
|
||||
cmp eax, hEngineWnd
|
||||
je input_has_focus
|
||||
mov keyActionHeld, 0
|
||||
mov keyMapHeld, 0
|
||||
mov keyShotHeld, 0
|
||||
mov keySceneHeld, 0
|
||||
mov keySkipHeld, 0
|
||||
ret
|
||||
input_has_focus:
|
||||
|
||||
; 1. Forward (W / Up Arrow)
|
||||
invoke GetAsyncKeyState, VK_KEY_W
|
||||
|
|
@ -15,7 +36,9 @@ PollInput PROC uses eax ebx
|
|||
invoke GetAsyncKeyState, VK_KEY_UP
|
||||
or bx, ax
|
||||
test bx, 8000h
|
||||
setnz keyForward
|
||||
jz @F
|
||||
mov keyForward, 1
|
||||
@@:
|
||||
|
||||
; 2. Backward (S / Down Arrow)
|
||||
invoke GetAsyncKeyState, VK_KEY_S
|
||||
|
|
@ -23,55 +46,117 @@ PollInput PROC uses eax ebx
|
|||
invoke GetAsyncKeyState, VK_KEY_DOWN
|
||||
or bx, ax
|
||||
test bx, 8000h
|
||||
setnz keyBackward
|
||||
jz @F
|
||||
mov keyBackward, 1
|
||||
@@:
|
||||
|
||||
; 3. Strafe Left (A)
|
||||
invoke GetAsyncKeyState, VK_KEY_A
|
||||
test ax, 8000h
|
||||
setnz keyStrafeLeft
|
||||
jz @F
|
||||
mov keyStrafeLeft, 1
|
||||
@@:
|
||||
|
||||
; 4. Strafe Right (D)
|
||||
invoke GetAsyncKeyState, VK_KEY_D
|
||||
test ax, 8000h
|
||||
setnz keyStrafeRight
|
||||
jz @F
|
||||
mov keyStrafeRight, 1
|
||||
@@:
|
||||
|
||||
; 5. Turn Left (Left Arrow)
|
||||
invoke GetAsyncKeyState, VK_KEY_LEFT
|
||||
test ax, 8000h
|
||||
setnz keyTurnLeft
|
||||
jz @F
|
||||
mov keyTurnLeft, 1
|
||||
@@:
|
||||
|
||||
; 6. Turn Right (Right Arrow)
|
||||
invoke GetAsyncKeyState, VK_KEY_RIGHT
|
||||
test ax, 8000h
|
||||
setnz keyTurnRight
|
||||
jz @F
|
||||
mov keyTurnRight, 1
|
||||
@@:
|
||||
|
||||
; 7. Action / Open Door (E / Space)
|
||||
; Actions are emitted once per physical press (also for E + Space).
|
||||
invoke GetAsyncKeyState, VK_KEY_E
|
||||
mov bx, ax
|
||||
invoke GetAsyncKeyState, VK_KEY_SPACE
|
||||
or bx, ax
|
||||
test bx, 8000h
|
||||
setnz keyAction
|
||||
jz action_released
|
||||
cmp keyActionHeld, 0
|
||||
jne action_sampled
|
||||
mov keyAction, 1
|
||||
mov keyActionHeld, 1
|
||||
jmp action_sampled
|
||||
action_released:
|
||||
mov keyActionHeld, 0
|
||||
action_sampled:
|
||||
|
||||
; 8. Run / Sprint (Shift)
|
||||
invoke GetAsyncKeyState, VK_KEY_SHIFT
|
||||
test ax, 8000h
|
||||
setnz keyRun
|
||||
jz @F
|
||||
mov keyRun, 1
|
||||
@@:
|
||||
|
||||
; 9. Toggle Minimap (M - single press trigger)
|
||||
invoke GetAsyncKeyState, VK_KEY_M
|
||||
test ax, 0001h ; Check if key was pressed since last call
|
||||
jz @F
|
||||
test ax, 8000h
|
||||
jz map_key_released
|
||||
cmp keyMapHeld, 0
|
||||
jne map_key_sampled
|
||||
xor keyShowMap, 1
|
||||
@@:
|
||||
|
||||
; 10. Screenshot (F12 - single press trigger)
|
||||
mov keyMapHeld, 1
|
||||
jmp map_key_sampled
|
||||
map_key_released:
|
||||
mov keyMapHeld, 0
|
||||
map_key_sampled:
|
||||
invoke GetAsyncKeyState, VK_KEY_F12
|
||||
test ax, 0001h
|
||||
jz @F
|
||||
test ax, 8000h
|
||||
jz shot_key_released
|
||||
cmp keyShotHeld, 0
|
||||
jne shot_key_sampled
|
||||
mov keyScreenshot, 1
|
||||
@@:
|
||||
mov keyShotHeld, 1
|
||||
jmp shot_key_sampled
|
||||
shot_key_released:
|
||||
mov keyShotHeld, 0
|
||||
shot_key_sampled:
|
||||
|
||||
invoke GetAsyncKeyState, VK_KEY_C
|
||||
test ax, 8000h
|
||||
jz scene_key_released
|
||||
cmp keySceneHeld, 0
|
||||
jne scene_key_sampled
|
||||
mov keyCutscene, 1
|
||||
mov keySceneHeld, 1
|
||||
jmp scene_key_sampled
|
||||
scene_key_released:
|
||||
mov keySceneHeld, 0
|
||||
scene_key_sampled:
|
||||
invoke GetAsyncKeyState, VK_KEY_TAB
|
||||
test ax, 8000h
|
||||
jz skip_key_released
|
||||
cmp keySkipHeld, 0
|
||||
jne skip_key_sampled
|
||||
mov keySkipScene, 1
|
||||
mov keySkipHeld, 1
|
||||
jmp skip_key_sampled
|
||||
skip_key_released:
|
||||
mov keySkipHeld, 0
|
||||
skip_key_sampled:
|
||||
cmp isCutsceneActive, 0
|
||||
je normal_keys_enabled
|
||||
mov keyForward, 0
|
||||
mov keyBackward, 0
|
||||
mov keyStrafeLeft, 0
|
||||
mov keyStrafeRight, 0
|
||||
mov keyTurnLeft, 0
|
||||
mov keyTurnRight, 0
|
||||
mov keyRun, 0
|
||||
mov keyAction, 0
|
||||
normal_keys_enabled:
|
||||
; 11. Escape - Exit application
|
||||
invoke GetAsyncKeyState, VK_KEY_ESCAPE
|
||||
test ax, 8000h
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
|
||||
.code
|
||||
|
||||
; Forward prototypes for map & sublevel transition procedures
|
||||
SwitchSublevel PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
LoadSublevelBinary PROTO :DWORD, :DWORD, :DWORD, :DWORD
|
||||
DoSublevelTransition PROTO
|
||||
ApplyScreenFade PROTO
|
||||
ValidateMapFilename PROTO :DWORD
|
||||
ValidateStagedCutscene PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
CheckPortalTriggers PROTO
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; InitMap: Creates a rich test level with rooms, hallways, pillars & doors
|
||||
; ------------------------------------------------------------------------------
|
||||
|
|
@ -80,8 +89,16 @@ loop_wall2:
|
|||
mov playerX, 160
|
||||
mov playerY, 160
|
||||
mov playerAngle, 0
|
||||
call StopCutscene
|
||||
mov cutsceneCount, 0
|
||||
mov cutsceneStringCount, 0
|
||||
mov cutsceneFlags, 0
|
||||
mov cutscenePending, 0
|
||||
mov spriteCount, 0
|
||||
|
||||
mov transitionCount, 0
|
||||
mov portalLatchCell, -1
|
||||
mov mapCeilingColor, COLOR_CEILING
|
||||
mov mapFloorColor, COLOR_FLOOR
|
||||
ret
|
||||
InitMap ENDP
|
||||
|
||||
|
|
@ -145,7 +162,7 @@ IsSolidCell PROC cellType:DWORD
|
|||
cmp eax, CELL_DOOR_OPEN
|
||||
je not_solid
|
||||
|
||||
mov eax, 1 ; Solid wall or closed door
|
||||
mov eax, 1 ; Solid wall, closed door, or portal wall
|
||||
ret
|
||||
|
||||
not_solid:
|
||||
|
|
@ -154,9 +171,9 @@ not_solid:
|
|||
IsSolidCell ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; InteractDoor: Checks if player is facing a door and toggles open/close
|
||||
; InteractDoor: Checks if player is facing a door or portal and handles interaction
|
||||
; ------------------------------------------------------------------------------
|
||||
InteractDoor PROC uses ebx ecx edx
|
||||
InteractDoor PROC uses ebx ecx edx esi edi
|
||||
LOCAL targetX:DWORD, targetY:DWORD
|
||||
|
||||
; Compute point 60 units in front of player
|
||||
|
|
@ -189,17 +206,49 @@ InteractDoor PROC uses ebx ecx edx
|
|||
|
||||
check_door_close:
|
||||
cmp eax, CELL_DOOR_OPEN
|
||||
jne no_door
|
||||
jne check_portal_interact
|
||||
|
||||
; Close the door!
|
||||
invoke SetMapCell, targetX, targetY, CELL_DOOR
|
||||
ret
|
||||
|
||||
check_portal_interact:
|
||||
; Check if facing a transition trigger or CELL_PORTAL
|
||||
cmp transitionCount, 0
|
||||
je no_door
|
||||
|
||||
mov esi, OFFSET transitionList
|
||||
mov edx, 0
|
||||
portal_find_loop:
|
||||
cmp edx, transitionCount
|
||||
jge no_door
|
||||
movzx eax, [esi].LevelTransition.triggerCellX
|
||||
cmp eax, targetX
|
||||
jne portal_find_next
|
||||
movzx eax, [esi].LevelTransition.triggerCellY
|
||||
cmp eax, targetY
|
||||
jne portal_find_next
|
||||
|
||||
; Found transition!
|
||||
movzx eax, [esi].LevelTransition.targetSpawnX
|
||||
movzx ebx, [esi].LevelTransition.targetSpawnY
|
||||
movzx ecx, [esi].LevelTransition.targetAngle
|
||||
movzx edx, [esi].LevelTransition.fadeEffect
|
||||
lea edi, [esi].LevelTransition.targetMap
|
||||
invoke SwitchSublevel, edi, eax, ebx, ecx, edx
|
||||
ret
|
||||
|
||||
portal_find_next:
|
||||
add esi, sizeof LevelTransition
|
||||
inc edx
|
||||
jmp portal_find_loop
|
||||
|
||||
no_door:
|
||||
ret
|
||||
InteractDoor ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; CheckDoorHint: Returns eax=1 if player is facing a door (for HUD hint)
|
||||
; CheckDoorHint: Returns 1 for Door, 2 for Portal, 0 for none (for HUD hint)
|
||||
; ------------------------------------------------------------------------------
|
||||
CheckDoorHint PROC uses ebx ecx edx
|
||||
LOCAL targetX:DWORD, targetY:DWORD
|
||||
|
|
@ -224,144 +273,697 @@ CheckDoorHint PROC uses ebx ecx edx
|
|||
|
||||
invoke GetMapCell, targetX, targetY
|
||||
cmp eax, CELL_DOOR
|
||||
je hint_yes
|
||||
je hint_door
|
||||
cmp eax, CELL_DOOR_OPEN
|
||||
je hint_yes
|
||||
je hint_door
|
||||
cmp eax, CELL_PORTAL
|
||||
je hint_portal
|
||||
|
||||
; Check if target is a trigger in transitionList
|
||||
cmp transitionCount, 0
|
||||
je hint_none
|
||||
mov edx, 0
|
||||
mov eax, OFFSET transitionList
|
||||
hint_check_loop:
|
||||
cmp edx, transitionCount
|
||||
jge hint_none
|
||||
movzx ebx, [eax].LevelTransition.triggerCellX
|
||||
cmp ebx, targetX
|
||||
jne hint_check_next
|
||||
movzx ebx, [eax].LevelTransition.triggerCellY
|
||||
cmp ebx, targetY
|
||||
je hint_portal
|
||||
hint_check_next:
|
||||
add eax, sizeof LevelTransition
|
||||
inc edx
|
||||
jmp hint_check_loop
|
||||
|
||||
hint_none:
|
||||
xor eax, eax
|
||||
ret
|
||||
hint_yes:
|
||||
|
||||
hint_door:
|
||||
mov eax, 1
|
||||
ret
|
||||
|
||||
hint_portal:
|
||||
mov eax, 2
|
||||
ret
|
||||
CheckDoorHint ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; LoadMapBinary: Checks if assets/map.dat exists and loads custom map,
|
||||
; player spawn position, angle, ceiling/floor colors, and sprites.
|
||||
; Returns: eax = 1 if custom map loaded, 0 if fallback to procedural map
|
||||
; CheckPortalTriggers: Checks if player stepped onto/into a transition trigger cell
|
||||
; ------------------------------------------------------------------------------
|
||||
LoadMapBinary PROC uses ebx ecx edx esi edi
|
||||
LOCAL hFile:DWORD
|
||||
LOCAL bytesRead:DWORD
|
||||
LOCAL header:MapHeader
|
||||
LOCAL count:DWORD
|
||||
CheckPortalTriggers PROC uses ebx ecx edx esi edi
|
||||
cmp fadeState, 0
|
||||
jne cpt_done
|
||||
cmp transitionCount, 0
|
||||
je cpt_done
|
||||
|
||||
; Try to open assets/map.dat
|
||||
invoke CreateFileA, OFFSET szMapFilePath, GENERIC_READ, FILE_SHARE_READ,\
|
||||
0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
|
||||
; Current player cell X and Y
|
||||
mov eax, playerX
|
||||
sar eax, 6
|
||||
mov ebx, eax
|
||||
|
||||
mov eax, playerY
|
||||
sar eax, 6
|
||||
mov ecx, eax
|
||||
shl eax, 5
|
||||
add eax, ebx
|
||||
cmp eax, portalLatchCell
|
||||
je cpt_done
|
||||
mov portalLatchCell, eax
|
||||
|
||||
mov esi, OFFSET transitionList
|
||||
mov edx, 0
|
||||
|
||||
cpt_loop:
|
||||
cmp edx, transitionCount
|
||||
jge cpt_done
|
||||
|
||||
movzx eax, [esi].LevelTransition.triggerCellX
|
||||
cmp eax, ebx
|
||||
jne cpt_next
|
||||
|
||||
movzx eax, [esi].LevelTransition.triggerCellY
|
||||
cmp eax, ecx
|
||||
jne cpt_next
|
||||
|
||||
; Walked into trigger cell! Initiate transition
|
||||
movzx eax, [esi].LevelTransition.targetSpawnX
|
||||
movzx ebx, [esi].LevelTransition.targetSpawnY
|
||||
movzx ecx, [esi].LevelTransition.targetAngle
|
||||
movzx edx, [esi].LevelTransition.fadeEffect
|
||||
|
||||
lea edi, [esi].LevelTransition.targetMap
|
||||
invoke SwitchSublevel, edi, eax, ebx, ecx, edx
|
||||
ret
|
||||
|
||||
cpt_next:
|
||||
add esi, sizeof LevelTransition
|
||||
inc edx
|
||||
jmp cpt_loop
|
||||
|
||||
cpt_done:
|
||||
ret
|
||||
CheckPortalTriggers ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; SwitchSublevel: Triggers transition sequence to target sublevel file
|
||||
; ------------------------------------------------------------------------------
|
||||
SwitchSublevel PROC uses esi edi, pszNextMap:DWORD, spawnX:DWORD, spawnY:DWORD, spawnAngle:DWORD, fadeEffect:DWORD
|
||||
cmp fadeState, 0
|
||||
jne switch_busy
|
||||
|
||||
mov esi, pszNextMap
|
||||
lea edi, pendingMapPath
|
||||
mov ecx, 32
|
||||
copy_next_map:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jz next_map_copied
|
||||
dec ecx
|
||||
jnz copy_next_map
|
||||
mov byte ptr [edi-1], 0
|
||||
next_map_copied:
|
||||
|
||||
mov eax, spawnX
|
||||
mov pendingSpawnX, eax
|
||||
mov eax, spawnY
|
||||
mov pendingSpawnY, eax
|
||||
mov eax, spawnAngle
|
||||
mov pendingSpawnAngle, eax
|
||||
|
||||
cmp fadeEffect, 0
|
||||
je switch_instant
|
||||
|
||||
; Initiate smooth Fade Out (takes 16 frames ~ 0.26s)
|
||||
mov fadeState, 1
|
||||
mov fadeLevel, 1
|
||||
ret
|
||||
|
||||
switch_instant:
|
||||
call DoSublevelTransition
|
||||
switch_busy:
|
||||
ret
|
||||
SwitchSublevel ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; DoSublevelTransition: Unloads old sector and loads new sector
|
||||
; ------------------------------------------------------------------------------
|
||||
DoSublevelTransition PROC uses ebx ecx edx
|
||||
; The loader replaces the room only after validating every staged value.
|
||||
invoke LoadSublevelBinary, OFFSET pendingMapPath, pendingSpawnX, pendingSpawnY, pendingSpawnAngle
|
||||
test eax, eax
|
||||
jnz transition_loaded
|
||||
mov mapErrorFrames, 180
|
||||
transition_loaded:
|
||||
ret
|
||||
DoSublevelTransition ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; ApplyScreenFade: Screen darkening post-processing and transition sequencer
|
||||
; ------------------------------------------------------------------------------
|
||||
ApplyScreenFade PROC uses ebx ecx edx esi edi
|
||||
cmp fadeState, 0
|
||||
je fade_done
|
||||
|
||||
cmp fadeState, 1
|
||||
je do_fade_out
|
||||
cmp fadeState, 2
|
||||
je do_switch_state
|
||||
cmp fadeState, 3
|
||||
je do_fade_in
|
||||
jmp fade_done
|
||||
|
||||
do_fade_out:
|
||||
inc fadeLevel
|
||||
cmp fadeLevel, 16
|
||||
jl apply_darkening
|
||||
mov fadeLevel, 16
|
||||
mov fadeState, 2
|
||||
call DoSublevelTransition
|
||||
mov fadeState, 3
|
||||
jmp apply_darkening
|
||||
|
||||
do_switch_state:
|
||||
call DoSublevelTransition
|
||||
mov fadeState, 3
|
||||
jmp apply_darkening
|
||||
|
||||
do_fade_in:
|
||||
dec fadeLevel
|
||||
cmp fadeLevel, 0
|
||||
jg apply_darkening
|
||||
mov fadeLevel, 0
|
||||
mov fadeState, 0
|
||||
jmp fade_done
|
||||
|
||||
apply_darkening:
|
||||
cmp fadeLevel, 0
|
||||
jle fade_done
|
||||
|
||||
cmp fadeLevel, 16
|
||||
jl fade_math
|
||||
|
||||
; Fully black screen
|
||||
mov edi, OFFSET screenBuffer
|
||||
mov ecx, SCREEN_W * SCREEN_H
|
||||
xor eax, eax
|
||||
rep stosd
|
||||
jmp fade_done
|
||||
|
||||
fade_math:
|
||||
mov ebx, 16
|
||||
sub ebx, fadeLevel ; scale: 1..15
|
||||
mov esi, OFFSET screenBuffer
|
||||
mov edi, esi
|
||||
mov ecx, SCREEN_W * SCREEN_H
|
||||
|
||||
fade_pixel_loop:
|
||||
mov eax, [esi]
|
||||
; Blue
|
||||
movzx edx, al
|
||||
imul edx, ebx
|
||||
shr edx, 4
|
||||
; Green
|
||||
movzx eax, ah
|
||||
imul eax, ebx
|
||||
shr eax, 4
|
||||
shl eax, 8
|
||||
or edx, eax
|
||||
; Red
|
||||
mov eax, [esi]
|
||||
shr eax, 16
|
||||
movzx eax, al
|
||||
imul eax, ebx
|
||||
shr eax, 4
|
||||
shl eax, 16
|
||||
or eax, edx
|
||||
|
||||
mov [edi], eax
|
||||
add esi, 4
|
||||
add edi, 4
|
||||
dec ecx
|
||||
jnz fade_pixel_loop
|
||||
|
||||
fade_done:
|
||||
ret
|
||||
ApplyScreenFade ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; LoadSublevelBinary: Parameterized level loader for any .dat file
|
||||
; Overrides player spawn if spawnX != -1, populates gameMap, sprites, transitions.
|
||||
; Returns: eax = 1 on success, 0 on fallback
|
||||
; ------------------------------------------------------------------------------
|
||||
LoadSublevelBinary PROC uses ebx ecx edx esi edi, pszPath:DWORD, spawnX:DWORD, spawnY:DWORD, spawnAngle:DWORD
|
||||
LOCAL hFile:DWORD, bytesRead:DWORD, header:MapHeader
|
||||
LOCAL numSprites:DWORD, numTransitions:DWORD, index:DWORD
|
||||
LOCAL numEvents:DWORD, numStrings:DWORD, sceneFlags:DWORD
|
||||
LOCAL resolvedPath[MAX_PATH_BYTES]:BYTE
|
||||
LOCAL stagedMap[MAP_SIZE]:BYTE
|
||||
LOCAL stagedSprites[MAX_SPRITES * (sizeof SpriteStruct)]:BYTE
|
||||
LOCAL stagedTransitions[MAX_TRANSITIONS * (sizeof LevelTransition)]:BYTE
|
||||
LOCAL newX:DWORD, newY:DWORD, newAngle:DWORD
|
||||
|
||||
; Relative map names live in assets. Accept an actual assets prefix,
|
||||
; drive-qualified paths and absolute paths, never just the first letter.
|
||||
mov esi, pszPath
|
||||
test esi, esi
|
||||
jz load_default_path
|
||||
cmp byte ptr [esi], 0
|
||||
jne resolve_map_path
|
||||
load_default_path:
|
||||
mov esi, OFFSET szMapFilePath
|
||||
resolve_map_path:
|
||||
lea edi, resolvedPath
|
||||
mov ecx, MAX_PATH_BYTES - 1
|
||||
cmp byte ptr [esi], 0
|
||||
je load_failed
|
||||
cmp byte ptr [esi+1], ':'
|
||||
je copy_map_path
|
||||
cmp byte ptr [esi], 92
|
||||
je copy_map_path
|
||||
cmp byte ptr [esi], '/'
|
||||
je copy_map_path
|
||||
push esi
|
||||
push ecx
|
||||
mov ebx, OFFSET szAssetsPrefix
|
||||
mov ecx, 6
|
||||
check_assets_prefix:
|
||||
mov al, [esi]
|
||||
or al, 20h
|
||||
cmp al, [ebx]
|
||||
jne prefix_not_assets
|
||||
inc esi
|
||||
inc ebx
|
||||
loop check_assets_prefix
|
||||
cmp byte ptr [esi], 92
|
||||
je prefix_is_assets
|
||||
cmp byte ptr [esi], '/'
|
||||
je prefix_is_assets
|
||||
prefix_not_assets:
|
||||
pop ecx
|
||||
pop esi
|
||||
mov ebx, OFFSET szAssetsPrefix
|
||||
copy_assets_prefix:
|
||||
mov al, [ebx]
|
||||
test al, al
|
||||
jz copy_map_path
|
||||
stosb
|
||||
inc ebx
|
||||
dec ecx
|
||||
jmp copy_assets_prefix
|
||||
prefix_is_assets:
|
||||
pop ecx
|
||||
pop esi
|
||||
copy_map_path:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jz open_level
|
||||
dec ecx
|
||||
jnz copy_map_path
|
||||
jmp load_failed
|
||||
open_level:
|
||||
invoke CreateFileA, ADDR resolvedPath, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
|
||||
mov hFile, eax
|
||||
cmp hFile, INVALID_HANDLE_VALUE
|
||||
je load_fallback
|
||||
|
||||
; Read MapHeader (38 bytes, packed)
|
||||
cmp eax, INVALID_HANDLE_VALUE
|
||||
je load_failed
|
||||
invoke ReadFile, hFile, ADDR header, sizeof MapHeader, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_and_fallback
|
||||
jz close_load_failed
|
||||
cmp bytesRead, sizeof MapHeader
|
||||
jl close_and_fallback
|
||||
|
||||
; Verify Magic 'EBLN'
|
||||
jne close_load_failed
|
||||
cmp header.magic, MAP_MAGIC
|
||||
jne close_and_fallback
|
||||
|
||||
; Validate version (1 = legacy 16.16 sprite coords, 2 = raw units)
|
||||
movzx eax, header.version
|
||||
cmp eax, 1
|
||||
jl close_and_fallback
|
||||
cmp eax, 2
|
||||
jg close_and_fallback
|
||||
|
||||
; Validate grid dimensions (engine is fixed at 32x32)
|
||||
jne close_load_failed
|
||||
cmp header.version, 1
|
||||
jb close_load_failed
|
||||
cmp header.version, MAP_FORMAT_VERSION
|
||||
ja close_load_failed
|
||||
cmp header.mapWidth, MAP_W
|
||||
jne close_and_fallback
|
||||
jne close_load_failed
|
||||
cmp header.mapHeight, MAP_H
|
||||
jne close_and_fallback
|
||||
|
||||
; Apply Player Spawn Position (in cells -> fixed-point world units)
|
||||
movzx eax, header.playerStartX
|
||||
shl eax, 6 ; * 64
|
||||
add eax, 32 ; center in cell
|
||||
mov playerX, eax
|
||||
|
||||
movzx eax, header.playerStartY
|
||||
shl eax, 6 ; * 64
|
||||
add eax, 32
|
||||
mov playerY, eax
|
||||
|
||||
; Apply Player Start Angle (0..511)
|
||||
movzx eax, header.playerStartAngle
|
||||
and eax, ANGLE_MASK
|
||||
mov playerAngle, eax
|
||||
|
||||
; Apply Custom Ceiling and Floor Colors if specified
|
||||
cmp header.ceilingColor, 0
|
||||
je @F
|
||||
mov eax, header.ceilingColor
|
||||
mov mapCeilingColor, eax
|
||||
@@:
|
||||
cmp header.floorColor, 0
|
||||
je @F
|
||||
mov eax, header.floorColor
|
||||
mov mapFloorColor, eax
|
||||
@@:
|
||||
|
||||
; Read 1024 bytes of gameMap
|
||||
invoke ReadFile, hFile, OFFSET gameMap, MAP_SIZE, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_and_fallback
|
||||
cmp bytesRead, MAP_SIZE
|
||||
jne close_and_fallback
|
||||
|
||||
; Read Sprites
|
||||
jne close_load_failed
|
||||
cmp header.playerStartX, MAP_W
|
||||
jae close_load_failed
|
||||
cmp header.playerStartY, MAP_H
|
||||
jae close_load_failed
|
||||
cmp header.playerStartAngle, ANGLE_MASK
|
||||
ja close_load_failed
|
||||
movzx eax, header.spriteCount
|
||||
cmp eax, MAX_SPRITES
|
||||
jle @F
|
||||
mov eax, MAX_SPRITES
|
||||
@@:
|
||||
mov count, eax
|
||||
mov spriteCount, eax
|
||||
|
||||
test eax, eax
|
||||
jz finish_load
|
||||
|
||||
imul eax, sizeof SpriteStruct
|
||||
mov ecx, eax
|
||||
invoke ReadFile, hFile, OFFSET spriteList, ecx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_and_fallback
|
||||
mov eax, count
|
||||
imul eax, sizeof SpriteStruct
|
||||
cmp bytesRead, eax
|
||||
jne close_and_fallback
|
||||
|
||||
; Format v1 stored sprite x/y as 16.16 fixed point -> convert to raw units
|
||||
ja close_load_failed
|
||||
mov numSprites, eax
|
||||
xor eax, eax
|
||||
cmp header.version, 1
|
||||
jne finish_load
|
||||
mov ecx, 0
|
||||
convert_v1_sprites:
|
||||
cmp ecx, count
|
||||
jge finish_load
|
||||
mov eax, ecx
|
||||
je old_map_no_transitions
|
||||
; Accept the extended v2 files written by early EblanStudio builds.
|
||||
movzx eax, header.transitionCount
|
||||
old_map_no_transitions:
|
||||
cmp eax, MAX_TRANSITIONS
|
||||
ja close_load_failed
|
||||
mov numTransitions, eax
|
||||
mov numEvents, 0
|
||||
mov numStrings, 0
|
||||
mov sceneFlags, 0
|
||||
cmp header.version, 4
|
||||
jb old_map_no_cutscene
|
||||
movzx eax, header.cutsceneCount
|
||||
cmp eax, MAX_CUTSCENE_EVENTS
|
||||
ja close_load_failed
|
||||
mov numEvents, eax
|
||||
movzx eax, header.cutsceneStringCount
|
||||
cmp eax, MAX_CUTSCENE_STRINGS
|
||||
ja close_load_failed
|
||||
mov numStrings, eax
|
||||
movzx eax, header.cutsceneFlags
|
||||
mov sceneFlags, eax
|
||||
old_map_no_cutscene:
|
||||
mov eax, numTransitions
|
||||
imul eax, sizeof LevelTransition
|
||||
mov ebx, eax
|
||||
mov eax, numSprites
|
||||
imul eax, sizeof SpriteStruct
|
||||
add eax, OFFSET spriteList
|
||||
mov edx, [eax].SpriteStruct.x
|
||||
sar edx, 16
|
||||
mov [eax].SpriteStruct.x, edx
|
||||
mov edx, [eax].SpriteStruct.y
|
||||
sar edx, 16
|
||||
mov [eax].SpriteStruct.y, edx
|
||||
inc ecx
|
||||
jmp convert_v1_sprites
|
||||
add ebx, eax
|
||||
add ebx, sizeof MapHeader + MAP_SIZE
|
||||
mov eax, numEvents
|
||||
imul eax, sizeof CutsceneEvent
|
||||
add ebx, eax
|
||||
mov eax, numStrings
|
||||
imul eax, CUTSCENE_STRING_BYTES
|
||||
add ebx, eax
|
||||
invoke GetFileSize, hFile, 0
|
||||
cmp eax, ebx
|
||||
jne close_load_failed
|
||||
|
||||
finish_load:
|
||||
invoke ReadFile, hFile, ADDR stagedMap, MAP_SIZE, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
cmp bytesRead, MAP_SIZE
|
||||
jne close_load_failed
|
||||
mov eax, numSprites
|
||||
imul eax, sizeof SpriteStruct
|
||||
mov ebx, eax
|
||||
invoke ReadFile, hFile, ADDR stagedSprites, ebx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
cmp bytesRead, ebx
|
||||
jne close_load_failed
|
||||
mov eax, numTransitions
|
||||
imul eax, sizeof LevelTransition
|
||||
mov ebx, eax
|
||||
invoke ReadFile, hFile, ADDR stagedTransitions, ebx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
cmp bytesRead, ebx
|
||||
jne close_load_failed
|
||||
|
||||
mov ebx, numEvents
|
||||
imul ebx, sizeof CutsceneEvent
|
||||
invoke ReadFile, hFile, OFFSET stagedCutsceneEvents, ebx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
cmp bytesRead, ebx
|
||||
jne close_load_failed
|
||||
mov ebx, numStrings
|
||||
imul ebx, CUTSCENE_STRING_BYTES
|
||||
invoke ReadFile, hFile, OFFSET stagedCutsceneStrings, ebx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
cmp bytesRead, ebx
|
||||
jne close_load_failed
|
||||
invoke ValidateStagedCutscene, numEvents, numStrings, sceneFlags, ADDR stagedMap, numTransitions
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
|
||||
; Validate all payload data before touching the active room.
|
||||
lea esi, stagedMap
|
||||
mov ecx, MAP_SIZE
|
||||
validate_grid:
|
||||
lodsb
|
||||
cmp al, 15
|
||||
ja close_load_failed
|
||||
loop validate_grid
|
||||
lea esi, stagedSprites
|
||||
mov ecx, numSprites
|
||||
validate_sprite:
|
||||
test ecx, ecx
|
||||
jz validate_transitions
|
||||
cmp header.version, 1
|
||||
jne sprite_coords_raw
|
||||
sar [esi].SpriteStruct.x, 16
|
||||
sar [esi].SpriteStruct.y, 16
|
||||
sprite_coords_raw:
|
||||
cmp [esi].SpriteStruct.x, 0
|
||||
jl close_load_failed
|
||||
cmp [esi].SpriteStruct.x, MAP_W * CELL_SIZE
|
||||
jge close_load_failed
|
||||
cmp [esi].SpriteStruct.y, 0
|
||||
jl close_load_failed
|
||||
cmp [esi].SpriteStruct.y, MAP_H * CELL_SIZE
|
||||
jge close_load_failed
|
||||
cmp [esi].SpriteStruct.textureId, MAX_TEXTURES
|
||||
jae close_load_failed
|
||||
cmp [esi].SpriteStruct.scale, 0
|
||||
jl close_load_failed
|
||||
cmp [esi].SpriteStruct.scale, 16 * FIXED_ONE
|
||||
ja close_load_failed
|
||||
add esi, sizeof SpriteStruct
|
||||
dec ecx
|
||||
jmp validate_sprite
|
||||
validate_transitions:
|
||||
lea esi, stagedTransitions
|
||||
mov index, 0
|
||||
validate_transition:
|
||||
mov eax, index
|
||||
cmp eax, numTransitions
|
||||
jae validate_portal_cells
|
||||
cmp [esi].LevelTransition.triggerCellX, MAP_W
|
||||
jae close_load_failed
|
||||
cmp [esi].LevelTransition.triggerCellY, MAP_H
|
||||
jae close_load_failed
|
||||
cmp [esi].LevelTransition.targetSpawnX, MAP_W
|
||||
jae close_load_failed
|
||||
cmp [esi].LevelTransition.targetSpawnY, MAP_H
|
||||
jae close_load_failed
|
||||
cmp [esi].LevelTransition.targetAngle, ANGLE_MASK
|
||||
ja close_load_failed
|
||||
cmp [esi].LevelTransition.fadeEffect, 1
|
||||
ja close_load_failed
|
||||
push ecx
|
||||
lea edi, [esi].LevelTransition.targetMap
|
||||
invoke ValidateMapFilename, edi
|
||||
pop ecx
|
||||
test eax, eax
|
||||
jz close_load_failed
|
||||
movzx eax, [esi].LevelTransition.triggerCellY
|
||||
shl eax, 5
|
||||
movzx edx, [esi].LevelTransition.triggerCellX
|
||||
add eax, edx
|
||||
mov al, stagedMap[eax]
|
||||
cmp al, CELL_EMPTY
|
||||
je transition_grid_ok
|
||||
cmp al, CELL_DOOR_OPEN
|
||||
je transition_grid_ok
|
||||
cmp al, CELL_PORTAL
|
||||
jne close_load_failed
|
||||
transition_grid_ok:
|
||||
lea edi, stagedTransitions
|
||||
mov ecx, index
|
||||
unique_transition:
|
||||
test ecx, ecx
|
||||
jz transition_unique
|
||||
mov ax, WORD PTR [esi]
|
||||
cmp ax, WORD PTR [edi]
|
||||
je close_load_failed
|
||||
add edi, sizeof LevelTransition
|
||||
dec ecx
|
||||
jmp unique_transition
|
||||
transition_unique:
|
||||
add esi, sizeof LevelTransition
|
||||
inc index
|
||||
jmp validate_transition
|
||||
validate_portal_cells:
|
||||
xor ebx, ebx
|
||||
portal_grid_loop:
|
||||
cmp ebx, MAP_SIZE
|
||||
jae validate_spawn
|
||||
cmp stagedMap[ebx], CELL_PORTAL
|
||||
jne next_portal_grid
|
||||
lea esi, stagedTransitions
|
||||
mov ecx, numTransitions
|
||||
find_grid_transition:
|
||||
test ecx, ecx
|
||||
jz close_load_failed
|
||||
movzx eax, [esi].LevelTransition.triggerCellY
|
||||
shl eax, 5
|
||||
movzx edx, [esi].LevelTransition.triggerCellX
|
||||
add eax, edx
|
||||
cmp eax, ebx
|
||||
je next_portal_grid
|
||||
add esi, sizeof LevelTransition
|
||||
dec ecx
|
||||
jmp find_grid_transition
|
||||
next_portal_grid:
|
||||
inc ebx
|
||||
jmp portal_grid_loop
|
||||
validate_spawn:
|
||||
movzx eax, header.playerStartX
|
||||
movzx ebx, header.playerStartY
|
||||
movzx ecx, header.playerStartAngle
|
||||
cmp spawnX, -1
|
||||
je spawn_values_ready
|
||||
mov eax, spawnX
|
||||
mov ebx, spawnY
|
||||
mov ecx, spawnAngle
|
||||
spawn_values_ready:
|
||||
cmp eax, MAP_W
|
||||
jae close_load_failed
|
||||
cmp ebx, MAP_H
|
||||
jae close_load_failed
|
||||
cmp ecx, ANGLE_MASK
|
||||
ja close_load_failed
|
||||
mov newX, eax
|
||||
mov newY, ebx
|
||||
mov newAngle, ecx
|
||||
shl ebx, 5
|
||||
add ebx, eax
|
||||
mov al, stagedMap[ebx]
|
||||
cmp al, CELL_EMPTY
|
||||
je commit_level
|
||||
cmp al, CELL_DOOR_OPEN
|
||||
je commit_level
|
||||
cmp al, CELL_PORTAL
|
||||
jne close_load_failed
|
||||
commit_level:
|
||||
invoke CloseHandle, hFile
|
||||
mov eax, 1 ; Custom map loaded!
|
||||
call StopCutscene
|
||||
mov esi, OFFSET stagedCutsceneEvents
|
||||
mov edi, OFFSET cutsceneEvents
|
||||
mov ecx, numEvents
|
||||
shl ecx, 2
|
||||
rep movsd
|
||||
mov esi, OFFSET stagedCutsceneStrings
|
||||
mov edi, OFFSET cutsceneStrings
|
||||
mov ecx, numStrings
|
||||
imul ecx, CUTSCENE_STRING_BYTES / 4
|
||||
rep movsd
|
||||
mov eax, numEvents
|
||||
mov cutsceneCount, eax
|
||||
mov eax, numStrings
|
||||
mov cutsceneStringCount, eax
|
||||
mov eax, sceneFlags
|
||||
mov cutsceneFlags, eax
|
||||
and eax, CUTSCENE_AUTOPLAY
|
||||
mov cutscenePending, eax
|
||||
lea esi, stagedMap
|
||||
mov edi, OFFSET gameMap
|
||||
mov ecx, MAP_SIZE / 4
|
||||
rep movsd
|
||||
lea esi, stagedSprites
|
||||
mov edi, OFFSET spriteList
|
||||
mov ecx, numSprites
|
||||
imul ecx, sizeof SpriteStruct / 4
|
||||
rep movsd
|
||||
lea esi, stagedTransitions
|
||||
mov edi, OFFSET transitionList
|
||||
mov ecx, numTransitions
|
||||
imul ecx, sizeof LevelTransition
|
||||
rep movsb
|
||||
mov eax, numSprites
|
||||
mov spriteCount, eax
|
||||
mov eax, numTransitions
|
||||
mov transitionCount, eax
|
||||
mov eax, header.ceilingColor
|
||||
and eax, 00FFFFFFh
|
||||
mov mapCeilingColor, eax
|
||||
mov eax, header.floorColor
|
||||
and eax, 00FFFFFFh
|
||||
mov mapFloorColor, eax
|
||||
mov eax, newY
|
||||
shl eax, 5
|
||||
add eax, newX
|
||||
mov portalLatchCell, eax
|
||||
mov eax, newX
|
||||
shl eax, CELL_SHIFT
|
||||
add eax, CELL_SIZE / 2
|
||||
mov playerX, eax
|
||||
mov eax, newY
|
||||
shl eax, CELL_SHIFT
|
||||
add eax, CELL_SIZE / 2
|
||||
mov playerY, eax
|
||||
mov eax, newAngle
|
||||
mov playerAngle, eax
|
||||
mov mapErrorFrames, 0
|
||||
mov eax, 1
|
||||
ret
|
||||
|
||||
close_and_fallback:
|
||||
close_load_failed:
|
||||
invoke CloseHandle, hFile
|
||||
load_fallback:
|
||||
call InitMap ; Procedural fallback level
|
||||
load_failed:
|
||||
xor eax, eax
|
||||
ret
|
||||
LoadMapBinary ENDP
|
||||
LoadSublevelBinary ENDP
|
||||
|
||||
; Map references in EBLN are bounded ASCII basenames, never arbitrary pointers.
|
||||
ValidateMapFilename PROC uses ebx ecx edx esi, pszName:DWORD
|
||||
mov esi, pszName
|
||||
xor ecx, ecx
|
||||
filename_character:
|
||||
mov al, [esi+ecx]
|
||||
test al, al
|
||||
jz filename_end
|
||||
cmp al, 32
|
||||
jb filename_invalid
|
||||
cmp al, 127
|
||||
jae filename_invalid
|
||||
cmp al, 92
|
||||
je filename_invalid
|
||||
cmp al, '/'
|
||||
je filename_invalid
|
||||
cmp al, ':'
|
||||
je filename_invalid
|
||||
cmp al, '*'
|
||||
je filename_invalid
|
||||
cmp al, '?'
|
||||
je filename_invalid
|
||||
cmp al, 34
|
||||
je filename_invalid
|
||||
cmp al, '<'
|
||||
je filename_invalid
|
||||
cmp al, '>'
|
||||
je filename_invalid
|
||||
cmp al, '|'
|
||||
je filename_invalid
|
||||
inc ecx
|
||||
cmp ecx, 32
|
||||
jb filename_character
|
||||
jmp filename_invalid
|
||||
filename_end:
|
||||
cmp ecx, 5
|
||||
jb filename_invalid
|
||||
mov eax, DWORD PTR [esi+ecx-4]
|
||||
or eax, 20202000h
|
||||
cmp eax, 7461642Eh ; .dat
|
||||
jne filename_invalid
|
||||
mov eax, 1
|
||||
ret
|
||||
filename_invalid:
|
||||
xor eax, eax
|
||||
ret
|
||||
ValidateMapFilename ENDP
|
||||
|
||||
LoadMapBinary PROC
|
||||
mov eax, OFFSET szMapFilePath
|
||||
cmp startupMapPath, 0
|
||||
je load_startup_map
|
||||
mov eax, OFFSET startupMapPath
|
||||
load_startup_map:
|
||||
invoke LoadSublevelBinary, eax, -1, -1, 0
|
||||
test eax, eax
|
||||
jnz startup_map_ready
|
||||
call InitMap
|
||||
startup_map_ready:
|
||||
ret
|
||||
LoadMapBinary ENDP
|
||||
|
|
|
|||
|
|
@ -3,23 +3,28 @@
|
|||
; ==============================================================================
|
||||
|
||||
.data
|
||||
fAngleDiv REAL4 256.0
|
||||
fFixedScale REAL4 65536.0
|
||||
fAngleDiv REAL4 256.0
|
||||
fFixedScale REAL4 65536.0
|
||||
fScreenHalfW REAL4 160.0
|
||||
fTanHalfFov REAL4 0.66817864 ; tan(48 * PI / 256) = tan(33.75 deg)
|
||||
fRadToAngle REAL4 81.48733 ; 256.0 / PI
|
||||
|
||||
.code
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; InitMathTables: Builds 512-entry Sin & Cos tables in 16.16 fixed point
|
||||
; InitMathTables: Builds 512-entry Sin & Cos tables in 16.16 fixed point,
|
||||
; and 320-entry tangential ray angle offset table for flat-plane projection.
|
||||
; ------------------------------------------------------------------------------
|
||||
InitMathTables PROC uses eax ecx edi
|
||||
LOCAL curAngle:DWORD
|
||||
LOCAL curVal:DWORD
|
||||
|
||||
; 1. Generate 512-entry Sin/Cos tables
|
||||
mov ecx, 0
|
||||
init_angle_loop:
|
||||
mov curAngle, ecx
|
||||
mov curVal, ecx
|
||||
|
||||
fldpi
|
||||
fimul curAngle
|
||||
fimul curVal
|
||||
fdiv fAngleDiv ; st(0) = (angle * PI) / 256
|
||||
|
||||
fsincos ; st(0) = cos, st(1) = sin
|
||||
|
|
@ -36,6 +41,32 @@ init_angle_loop:
|
|||
cmp ecx, ANGLE_512
|
||||
jl init_angle_loop
|
||||
|
||||
; 2. Generate 320-entry Ray Angle Offset table (tangential perspective correction)
|
||||
; and precomputed cosine table for instant fish-eye correction.
|
||||
; angleOffset = round( atan(((x - 160.0) / 160.0) * tan(FOV/2)) * (256.0 / PI) )
|
||||
mov ecx, 0
|
||||
init_ray_loop:
|
||||
mov curVal, ecx
|
||||
|
||||
fild curVal ; st(0) = x
|
||||
fsub fScreenHalfW ; st(0) = x - 160.0
|
||||
fdiv fScreenHalfW ; st(0) = (x - 160.0) / 160.0 = cameraX
|
||||
fmul fTanHalfFov ; st(0) = cameraX * tan(FOV/2)
|
||||
fld1 ; st(0) = 1.0, st(1) = cameraX * tan(FOV/2)
|
||||
fpatan ; st(0) = atan(st(1)/st(0)) = radOffset
|
||||
fmul fRadToAngle ; st(0) = radOffset * (256.0 / PI)
|
||||
fistp DWORD PTR [tableRayAngleOffset + ecx*4]
|
||||
|
||||
; Precompute tableRayCos[ecx] = cos(angleOffset)
|
||||
mov eax, DWORD PTR [tableRayAngleOffset + ecx*4]
|
||||
and eax, ANGLE_MASK
|
||||
mov edx, [tableCos + eax*4]
|
||||
mov DWORD PTR [tableRayCos + ecx*4], edx
|
||||
|
||||
inc ecx
|
||||
cmp ecx, SCREEN_W
|
||||
jl init_ray_loop
|
||||
|
||||
ret
|
||||
InitMathTables ENDP
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,30 @@ UpdatePlayer PROC uses ebx ecx edx esi edi
|
|||
LOCAL nextX:DWORD, nextY:DWORD
|
||||
LOCAL checkGridX:DWORD, checkGridY:DWORD
|
||||
|
||||
; Freeze player movement and inputs during level transitions
|
||||
cmp fadeState, 0
|
||||
jne update_player_done
|
||||
|
||||
cmp cutscenePending, 0
|
||||
je scene_manual_start
|
||||
call StartCutscene
|
||||
scene_manual_start:
|
||||
cmp keyCutscene, 0
|
||||
je scene_check_active
|
||||
cmp isCutsceneActive, 0
|
||||
jne scene_check_active
|
||||
call StartCutscene
|
||||
scene_check_active:
|
||||
cmp isCutsceneActive, 0
|
||||
je normal_player_update
|
||||
cmp keySkipScene, 0
|
||||
je scene_update_tick
|
||||
call StopCutscene
|
||||
ret
|
||||
scene_update_tick:
|
||||
call UpdateCutscene
|
||||
ret
|
||||
normal_player_update:
|
||||
mov moveX, 0
|
||||
mov moveY, 0
|
||||
|
||||
|
|
@ -36,6 +60,8 @@ check_action:
|
|||
cmp keyAction, 0
|
||||
je check_movement
|
||||
call InteractDoor
|
||||
cmp fadeState, 0
|
||||
jne update_player_done
|
||||
|
||||
; 2. Determine Movement Speed
|
||||
check_movement:
|
||||
|
|
@ -149,9 +175,12 @@ check_x_col:
|
|||
mov checkGridY, ecx
|
||||
|
||||
invoke GetMapCell, checkGridX, checkGridY
|
||||
cmp eax, CELL_PORTAL
|
||||
je allow_portal_x
|
||||
invoke IsSolidCell, eax
|
||||
test eax, eax
|
||||
jnz skip_x_move ; Collision on X: slide along wall!
|
||||
allow_portal_x:
|
||||
|
||||
mov eax, nextX
|
||||
mov playerX, eax
|
||||
|
|
@ -180,13 +209,20 @@ check_y_col:
|
|||
mov checkGridX, ebx
|
||||
|
||||
invoke GetMapCell, checkGridX, checkGridY
|
||||
cmp eax, CELL_PORTAL
|
||||
je allow_portal_y
|
||||
invoke IsSolidCell, eax
|
||||
test eax, eax
|
||||
jnz update_player_done ; Collision on Y
|
||||
allow_portal_y:
|
||||
|
||||
mov eax, nextY
|
||||
mov playerY, eax
|
||||
|
||||
update_player_done:
|
||||
cmp fadeState, 0
|
||||
jne @F
|
||||
call CheckPortalTriggers
|
||||
@@:
|
||||
ret
|
||||
UpdatePlayer ENDP
|
||||
|
|
|
|||
150
include/engine_preview.inc
Normal file
150
include/engine_preview.inc
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
; Windowless renderer used by EblanStudio. Same Render3DScene and overlays as game.
|
||||
; Fixed wire layout is mirrored by editor/src/preview_protocol.h.
|
||||
.data
|
||||
previewPrefix db "--preview:",0
|
||||
previewHandle dd 0
|
||||
previewMemory dd 0
|
||||
previewParent dd 0
|
||||
previewTextureRevision dd -1
|
||||
.code
|
||||
TryEnginePreview PROC uses ebx ecx edx esi edi
|
||||
mov esi, OFFSET startupMapPath
|
||||
mov edi, OFFSET previewPrefix
|
||||
mov ecx, 10
|
||||
repe cmpsb
|
||||
jne preview_not_requested
|
||||
invoke OpenFileMappingA, 6, 0, esi ; FILE_MAP_READ | FILE_MAP_WRITE
|
||||
test eax, eax
|
||||
jz preview_exit_error
|
||||
mov previewHandle, eax
|
||||
invoke MapViewOfFile, eax, 6, 0, 0, 1357792
|
||||
test eax, eax
|
||||
jz preview_exit_error
|
||||
mov previewMemory, eax
|
||||
mov ebx, eax
|
||||
cmp DWORD PTR [ebx], 56504245h
|
||||
jne preview_exit_error
|
||||
cmp DWORD PTR [ebx+4], 1
|
||||
jne preview_exit_error
|
||||
invoke OpenProcess, 00100000h, 0, DWORD PTR [ebx+12] ; SYNCHRONIZE
|
||||
test eax, eax
|
||||
jz preview_exit_error
|
||||
mov previewParent, eax
|
||||
call InitMathTables
|
||||
call GenerateDefaultTextures
|
||||
preview_wait:
|
||||
invoke WaitForSingleObject, previewParent, 0
|
||||
test eax, eax
|
||||
jz preview_exit_ok
|
||||
mov ebx, previewMemory
|
||||
cmp DWORD PTR [ebx+8], 3
|
||||
je preview_exit_ok
|
||||
cmp DWORD PTR [ebx+8], 1
|
||||
je preview_render
|
||||
invoke Sleep, 1
|
||||
jmp preview_wait
|
||||
preview_render:
|
||||
; Treat IPC as untrusted: bounds before touching engine buffers.
|
||||
mov eax, [ebx+16]
|
||||
cmp eax, 2048
|
||||
jae preview_exit_error
|
||||
mov playerX, eax
|
||||
mov eax, [ebx+20]
|
||||
cmp eax, 2048
|
||||
jae preview_exit_error
|
||||
mov playerY, eax
|
||||
mov eax, [ebx+24]
|
||||
and eax, 511
|
||||
mov playerAngle, eax
|
||||
mov eax, [ebx+28]
|
||||
mov mapCeilingColor, eax
|
||||
mov eax, [ebx+32]
|
||||
mov mapFloorColor, eax
|
||||
mov eax, [ebx+36]
|
||||
cmp eax, MAX_SPRITES
|
||||
ja preview_exit_error
|
||||
mov spriteCount, eax
|
||||
lea esi, [ebx+128]
|
||||
mov edi, OFFSET gameMap
|
||||
mov ecx, 1024/4
|
||||
rep movsd
|
||||
push esi
|
||||
mov esi, OFFSET gameMap
|
||||
mov ecx, MAP_SIZE
|
||||
preview_cell_check:
|
||||
lodsb
|
||||
cmp al, 15
|
||||
ja preview_exit_error
|
||||
loop preview_cell_check
|
||||
pop esi
|
||||
mov edi, OFFSET spriteList
|
||||
mov ecx, 32*sizeof SpriteStruct/4
|
||||
rep movsd
|
||||
; Validate every sprite, including unsaved inspector edits.
|
||||
mov esi, OFFSET spriteList
|
||||
mov ecx, spriteCount
|
||||
preview_sprite_check:
|
||||
test ecx, ecx
|
||||
jz preview_textures
|
||||
cmp DWORD PTR [esi].SpriteStruct.textureId, MAX_TEXTURES
|
||||
jae preview_exit_error
|
||||
cmp DWORD PTR [esi].SpriteStruct.x, 2048
|
||||
jae preview_exit_error
|
||||
cmp DWORD PTR [esi].SpriteStruct.y, 2048
|
||||
jae preview_exit_error
|
||||
cmp DWORD PTR [esi].SpriteStruct.scale, 1048576
|
||||
ja preview_exit_error
|
||||
add esi, sizeof SpriteStruct
|
||||
dec ecx
|
||||
jmp preview_sprite_check
|
||||
preview_textures:
|
||||
mov eax, [ebx+48]
|
||||
cmp eax, previewTextureRevision
|
||||
je preview_scene
|
||||
mov previewTextureRevision, eax
|
||||
lea esi, [ebx+2016]
|
||||
mov edi, OFFSET textureBuffer
|
||||
mov ecx, MAX_TEXTURES*TEX_SIZE_BYTES/4
|
||||
rep movsd
|
||||
preview_scene:
|
||||
call Render3DScene
|
||||
mov ebx, previewMemory
|
||||
cmp DWORD PTR [ebx+40], 0
|
||||
je preview_copy_frame
|
||||
mov cutsceneSubtitle, -1
|
||||
cmp BYTE PTR [ebx+1920], 0
|
||||
je preview_overlay
|
||||
lea esi, [ebx+1920]
|
||||
mov edi, OFFSET cutsceneStrings
|
||||
mov ecx, 96/4
|
||||
rep movsd
|
||||
mov BYTE PTR [cutsceneStrings+95], 0
|
||||
mov cutsceneStringCount, 1
|
||||
mov cutsceneSubtitle, 0
|
||||
preview_overlay:
|
||||
call RenderCutsceneOverlay
|
||||
mov ebx, previewMemory
|
||||
mov eax, [ebx+44]
|
||||
cmp eax, 16
|
||||
ja preview_exit_error
|
||||
mov cutsceneFade, eax
|
||||
call ApplyCutsceneFade
|
||||
preview_copy_frame:
|
||||
mov esi, OFFSET screenBuffer
|
||||
mov ebx, previewMemory
|
||||
lea edi, [ebx+1050592]
|
||||
mov ecx, FRAME_BUFFER_SIZE/4
|
||||
rep movsd
|
||||
mov eax, 2
|
||||
xchg eax, [ebx+8] ; Publish complete frame (full memory barrier).
|
||||
jmp preview_wait
|
||||
preview_exit_ok:
|
||||
invoke UnmapViewOfFile, previewMemory
|
||||
invoke CloseHandle, previewParent
|
||||
invoke CloseHandle, previewHandle
|
||||
invoke ExitProcess, 0
|
||||
preview_exit_error:
|
||||
invoke ExitProcess, 2
|
||||
preview_not_requested:
|
||||
ret
|
||||
TryEnginePreview ENDP
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
.code
|
||||
|
||||
RenderSprites PROTO :DWORD
|
||||
RenderGlass PROTO
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; Render3DScene: Full frame 2.5D raycasting pass (Floor, Ceiling, Walls, Sprites)
|
||||
; ------------------------------------------------------------------------------
|
||||
|
|
@ -15,6 +18,8 @@ Render3DScene PROC uses ebx ecx edx esi edi
|
|||
LOCAL stepX:DWORD, stepY:DWORD
|
||||
LOCAL sideDistX:DWORD, sideDistY:DWORD
|
||||
LOCAL deltaDistX:DWORD, deltaDistY:DWORD
|
||||
LOCAL rawDist:DWORD
|
||||
LOCAL perpWallDistFixed:DWORD ; 24.8 until projection
|
||||
LOCAL perpWallDist:DWORD
|
||||
LOCAL sideHit:DWORD ; 0 = X-wall (Vertical), 1 = Y-wall (Horizontal)
|
||||
LOCAL wallType:DWORD
|
||||
|
|
@ -24,6 +29,13 @@ Render3DScene PROC uses ebx ecx edx esi edi
|
|||
LOCAL texStep:DWORD, texPos:DWORD
|
||||
LOCAL texPtr:DWORD
|
||||
LOCAL pScreenPixel:DWORD
|
||||
LOCAL hasGlassHit:DWORD
|
||||
LOCAL glassRawDist:DWORD
|
||||
LOCAL glassPerpDistFixed:DWORD
|
||||
LOCAL glassPerpDist:DWORD
|
||||
LOCAL glassLineHeight:DWORD
|
||||
LOCAL glassDrawStart:DWORD, glassDrawEnd:DWORD
|
||||
LOCAL glassTexX:DWORD, glassSideHit:DWORD
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; 1. Fast Background Clear: Ceiling (Top Half) and Floor (Bottom Half)
|
||||
|
|
@ -43,16 +55,15 @@ Render3DScene PROC uses ebx ecx edx esi edi
|
|||
mov screenX, 0
|
||||
|
||||
ray_column_loop:
|
||||
; Calculate Ray Angle: playerAngle - FOV_HALF + (screenX * FOV_ANGLES / 320)
|
||||
mov hasGlassHit, 0
|
||||
|
||||
; Lookup Tangential Ray Angle Offset (flat plane perspective correction)
|
||||
mov eax, screenX
|
||||
imul eax, FOV_ANGLES
|
||||
xor edx, edx
|
||||
mov ecx, SCREEN_W
|
||||
div ecx ; eax = offset from left of FOV
|
||||
add eax, playerAngle
|
||||
sub eax, FOV_HALF
|
||||
and eax, ANGLE_MASK
|
||||
mov rayAngle, eax
|
||||
mov edx, [tableRayAngleOffset + eax*4]
|
||||
add edx, playerAngle
|
||||
and edx, ANGLE_MASK
|
||||
mov rayAngle, edx
|
||||
mov eax, edx
|
||||
|
||||
; Ray Direction (16.16 Fixed Point)
|
||||
mov edx, [tableCos + eax*4]
|
||||
|
|
@ -69,37 +80,47 @@ ray_column_loop:
|
|||
sar eax, 6
|
||||
mov mapY, eax
|
||||
|
||||
; Delta Dist X = abs(64 << 16 / rayDirX)
|
||||
; Delta Dist X in 24.8: (64 << 24) / abs(rayDirX)
|
||||
mov eax, rayDirX
|
||||
test eax, eax
|
||||
jnz calc_delta_x
|
||||
mov eax, 1 ; avoid div by 0
|
||||
mov deltaDistX, 3FFFFFFFh
|
||||
jmp delta_x_ready
|
||||
calc_delta_x:
|
||||
cdq
|
||||
xor eax, edx
|
||||
sub eax, edx ; eax = abs(rayDirX)
|
||||
mov ecx, eax
|
||||
mov eax, (64 shl 16)
|
||||
mov eax, (64 shl 24)
|
||||
xor edx, edx
|
||||
div ecx
|
||||
mov deltaDistX, eax
|
||||
delta_x_ready:
|
||||
|
||||
; Delta Dist Y = abs(64 << 16 / rayDirY)
|
||||
; Delta Dist Y in 24.8: (64 << 24) / abs(rayDirY)
|
||||
mov eax, rayDirY
|
||||
test eax, eax
|
||||
jnz calc_delta_y
|
||||
mov eax, 1
|
||||
mov deltaDistY, 3FFFFFFFh
|
||||
jmp delta_y_ready
|
||||
calc_delta_y:
|
||||
cdq
|
||||
xor eax, edx
|
||||
sub eax, edx ; eax = abs(rayDirY)
|
||||
mov ecx, eax
|
||||
mov eax, (64 shl 16)
|
||||
mov eax, (64 shl 24)
|
||||
xor edx, edx
|
||||
div ecx
|
||||
mov deltaDistY, eax
|
||||
delta_y_ready:
|
||||
|
||||
; Calculate Step and Initial Side Dist
|
||||
cmp rayDirX, 0
|
||||
jne nonzero_step_x
|
||||
mov stepX, 1
|
||||
mov sideDistX, 3FFFFFFFh
|
||||
jmp check_step_y
|
||||
nonzero_step_x:
|
||||
cmp rayDirX, 0
|
||||
jge ray_dir_x_pos
|
||||
mov stepX, -1
|
||||
|
|
@ -108,9 +129,10 @@ calc_delta_y:
|
|||
shl eax, 6
|
||||
mov edx, playerX
|
||||
sub edx, eax
|
||||
imul edx, deltaDistX
|
||||
sar edx, 6
|
||||
mov sideDistX, edx
|
||||
mov eax, edx
|
||||
imul deltaDistX
|
||||
shrd eax, edx, 6
|
||||
mov sideDistX, eax
|
||||
jmp check_step_y
|
||||
|
||||
ray_dir_x_pos:
|
||||
|
|
@ -120,11 +142,17 @@ ray_dir_x_pos:
|
|||
inc eax
|
||||
shl eax, 6
|
||||
sub eax, playerX
|
||||
imul eax, deltaDistX
|
||||
sar eax, 6
|
||||
imul deltaDistX
|
||||
shrd eax, edx, 6
|
||||
mov sideDistX, eax
|
||||
|
||||
check_step_y:
|
||||
cmp rayDirY, 0
|
||||
jne nonzero_step_y
|
||||
mov stepY, 1
|
||||
mov sideDistY, 3FFFFFFFh
|
||||
jmp dda_loop
|
||||
nonzero_step_y:
|
||||
cmp rayDirY, 0
|
||||
jge ray_dir_y_pos
|
||||
mov stepY, -1
|
||||
|
|
@ -132,9 +160,10 @@ check_step_y:
|
|||
shl eax, 6
|
||||
mov edx, playerY
|
||||
sub edx, eax
|
||||
imul edx, deltaDistY
|
||||
sar edx, 6
|
||||
mov sideDistY, edx
|
||||
mov eax, edx
|
||||
imul deltaDistY
|
||||
shrd eax, edx, 6
|
||||
mov sideDistY, eax
|
||||
jmp dda_loop
|
||||
|
||||
ray_dir_y_pos:
|
||||
|
|
@ -143,8 +172,8 @@ ray_dir_y_pos:
|
|||
inc eax
|
||||
shl eax, 6
|
||||
sub eax, playerY
|
||||
imul eax, deltaDistY
|
||||
sar eax, 6
|
||||
imul deltaDistY
|
||||
shrd eax, edx, 6
|
||||
mov sideDistY, eax
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
|
|
@ -177,6 +206,117 @@ step_in_y:
|
|||
test_hit:
|
||||
invoke GetMapCell, mapX, mapY
|
||||
mov wallType, eax
|
||||
cmp eax, CELL_GLASS
|
||||
jne not_glass_hit
|
||||
|
||||
; If this ray already recorded a glass wall, continue stepping through subsequent glass cells
|
||||
cmp hasGlassHit, 0
|
||||
jne dda_loop
|
||||
|
||||
; Record first glass hit:
|
||||
mov hasGlassHit, 1
|
||||
cmp sideHit, 0
|
||||
jne glass_dist_from_y
|
||||
mov eax, sideDistX
|
||||
sub eax, deltaDistX
|
||||
jmp glass_apply_fisheye
|
||||
glass_dist_from_y:
|
||||
mov eax, sideDistY
|
||||
sub eax, deltaDistY
|
||||
glass_apply_fisheye:
|
||||
mov glassRawDist, eax
|
||||
mov ecx, screenX
|
||||
mov ecx, [tableRayCos + ecx*4]
|
||||
imul ecx
|
||||
shrd eax, edx, 16
|
||||
cmp eax, 1
|
||||
jg @F
|
||||
mov eax, 1
|
||||
@@:
|
||||
mov glassPerpDistFixed, eax
|
||||
add eax, 128
|
||||
shr eax, 8
|
||||
cmp eax, 1
|
||||
jae @F
|
||||
mov eax, 1
|
||||
@@:
|
||||
mov glassPerpDist, eax
|
||||
|
||||
; Glass line height & drawStart / drawEnd
|
||||
mov eax, (64 * PROJ_DIST shl 8)
|
||||
xor edx, edx
|
||||
div glassPerpDistFixed
|
||||
cmp eax, 1
|
||||
jge @F
|
||||
mov eax, 1
|
||||
@@:
|
||||
mov glassLineHeight, eax
|
||||
|
||||
mov eax, SCREEN_HALF_H
|
||||
mov edx, glassLineHeight
|
||||
shr edx, 1
|
||||
sub eax, edx
|
||||
cmp eax, 0
|
||||
jge @F
|
||||
xor eax, eax
|
||||
@@:
|
||||
mov glassDrawStart, eax
|
||||
|
||||
mov eax, SCREEN_HALF_H
|
||||
mov edx, glassLineHeight
|
||||
shr edx, 1
|
||||
add eax, edx
|
||||
cmp eax, SCREEN_H
|
||||
jl @F
|
||||
mov eax, SCREEN_H - 1
|
||||
@@:
|
||||
mov glassDrawEnd, eax
|
||||
|
||||
; Glass texX
|
||||
cmp sideHit, 0
|
||||
jne glass_tex_hit_y
|
||||
mov eax, glassRawDist
|
||||
mov ebx, rayDirY
|
||||
imul ebx
|
||||
shrd eax, edx, 8
|
||||
mov ebx, playerY
|
||||
shl ebx, 16
|
||||
add eax, ebx
|
||||
jmp glass_compute_tex_x
|
||||
glass_tex_hit_y:
|
||||
mov eax, glassRawDist
|
||||
mov ebx, rayDirX
|
||||
imul ebx
|
||||
shrd eax, edx, 8
|
||||
mov ebx, playerX
|
||||
shl ebx, 16
|
||||
add eax, ebx
|
||||
glass_compute_tex_x:
|
||||
shr eax, 15
|
||||
and eax, TEX_MASK
|
||||
cmp sideHit, 0
|
||||
jne glass_check_flip_y
|
||||
cmp rayDirX, 0
|
||||
jle @F
|
||||
mov ecx, TEX_MASK
|
||||
sub ecx, eax
|
||||
mov eax, ecx
|
||||
jmp @F
|
||||
glass_check_flip_y:
|
||||
cmp rayDirY, 0
|
||||
jge @F
|
||||
mov ecx, TEX_MASK
|
||||
sub ecx, eax
|
||||
mov eax, ecx
|
||||
@@:
|
||||
mov glassTexX, eax
|
||||
mov eax, sideHit
|
||||
mov glassSideHit, eax
|
||||
|
||||
; Continue stepping through the glass to the back wall!
|
||||
jmp dda_loop
|
||||
|
||||
not_glass_hit:
|
||||
invoke IsSolidCell, wallType
|
||||
test eax, eax
|
||||
jz dda_loop
|
||||
|
|
@ -194,17 +334,23 @@ dist_from_y:
|
|||
sub eax, deltaDistY
|
||||
|
||||
apply_fisheye_fix:
|
||||
; perpWallDist = dist * cos(rayAngle - playerAngle)
|
||||
mov ebx, rayAngle
|
||||
sub ebx, playerAngle
|
||||
and ebx, ANGLE_MASK
|
||||
mov ecx, [tableCos + ebx*4]
|
||||
imul ecx ; edx:eax
|
||||
mov rawDist, eax ; Euclidean distance in 24.8, also used for texture X
|
||||
; Fast tableRayCos lookup (precomputed perpendicular cosine per screen column)
|
||||
mov ecx, screenX
|
||||
mov ecx, [tableRayCos + ecx*4]
|
||||
imul ecx ; edx:eax = rawDist * cos(rayAngleOffset)
|
||||
shrd eax, edx, 16
|
||||
cmp eax, 1 ; Prevent division by zero
|
||||
jg @F
|
||||
mov eax, 1
|
||||
@@:
|
||||
mov perpWallDistFixed, eax
|
||||
add eax, 128
|
||||
shr eax, 8
|
||||
cmp eax, 1
|
||||
jae rounded_distance_ready
|
||||
mov eax, 1
|
||||
rounded_distance_ready:
|
||||
mov perpWallDist, eax
|
||||
|
||||
; Store in Depth Buffer for Sprite Occlusion
|
||||
|
|
@ -214,9 +360,13 @@ apply_fisheye_fix:
|
|||
; --------------------------------------------------------------------------
|
||||
; Projected Line Height on Screen: (64 * PROJ_DIST) / perpWallDist
|
||||
; --------------------------------------------------------------------------
|
||||
mov eax, (64 * PROJ_DIST)
|
||||
mov eax, (64 * PROJ_DIST shl 8)
|
||||
xor edx, edx
|
||||
div perpWallDist
|
||||
div perpWallDistFixed
|
||||
cmp eax, 1
|
||||
jge @F
|
||||
mov eax, 1 ; Clamp to >= 1 to prevent div by zero in texStep
|
||||
@@:
|
||||
mov lineHeight, eax
|
||||
|
||||
; Calculate drawStart and drawEnd
|
||||
|
|
@ -241,28 +391,33 @@ apply_fisheye_fix:
|
|||
@@:
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; Texture Coordinate X (texX)
|
||||
; Texture Coordinate X (texX) with full 128-texel sub-unit precision
|
||||
; --------------------------------------------------------------------------
|
||||
cmp sideHit, 0
|
||||
jne tex_hit_y
|
||||
; wallHit = playerY + perpWallDist * rayDirY
|
||||
mov eax, perpWallDist
|
||||
; wallHitFixed = (playerY << 16) + rawDist * rayDirY
|
||||
mov eax, rawDist
|
||||
mov ebx, rayDirY
|
||||
imul ebx
|
||||
shrd eax, edx, 16
|
||||
add eax, playerY
|
||||
shrd eax, edx, 8 ; (24.8 * 16.16) -> 16.16
|
||||
mov ebx, playerY
|
||||
shl ebx, 16
|
||||
add eax, ebx
|
||||
jmp compute_tex_x
|
||||
|
||||
tex_hit_y:
|
||||
mov eax, perpWallDist
|
||||
; wallHitFixed = (playerX << 16) + rawDist * rayDirX
|
||||
mov eax, rawDist
|
||||
mov ebx, rayDirX
|
||||
imul ebx
|
||||
shrd eax, edx, 16
|
||||
add eax, playerX
|
||||
shrd eax, edx, 8
|
||||
mov ebx, playerX
|
||||
shl ebx, 16
|
||||
add eax, ebx
|
||||
|
||||
compute_tex_x:
|
||||
and eax, 63 ; coordinate within 64-unit cell
|
||||
shl eax, 1 ; scale 0..63 to 0..127 (TEX_W)
|
||||
shr eax, 15
|
||||
and eax, TEX_MASK ; full 0..127 range (all 128 texture columns)
|
||||
mov texX, eax
|
||||
|
||||
; Flip texture coordinate depending on ray direction
|
||||
|
|
@ -288,6 +443,10 @@ set_tex_ptr:
|
|||
and eax, 15
|
||||
shl eax, 16 ; eax = wallType * 65536
|
||||
add eax, OFFSET textureBuffer
|
||||
cmp wallType, CELL_PORTAL
|
||||
jne wall_texture_ready
|
||||
mov eax, OFFSET portalTextureBuffer
|
||||
wall_texture_ready:
|
||||
mov texPtr, eax
|
||||
|
||||
; Texture vertical sampling step (16.16 fixed point)
|
||||
|
|
@ -369,14 +528,43 @@ write_wall_pixel:
|
|||
jmp wall_pixel_loop
|
||||
|
||||
end_wall_stripe:
|
||||
mov ecx, screenX
|
||||
cmp hasGlassHit, 0
|
||||
je no_glass_in_col
|
||||
mov eax, glassPerpDist
|
||||
mov [glassDistBuffer + ecx*4], eax
|
||||
mov eax, glassDrawStart
|
||||
mov [glassDrawStartBuf + ecx*4], eax
|
||||
mov eax, glassDrawEnd
|
||||
mov [glassDrawEndBuf + ecx*4], eax
|
||||
mov eax, glassLineHeight
|
||||
mov [glassLineHeightBuf + ecx*4], eax
|
||||
mov eax, glassTexX
|
||||
mov [glassTexXBuf + ecx*4], eax
|
||||
mov eax, glassSideHit
|
||||
mov [glassSideHitBuf + ecx*4], eax
|
||||
jmp col_glass_saved
|
||||
no_glass_in_col:
|
||||
mov DWORD PTR [glassDistBuffer + ecx*4], 0
|
||||
col_glass_saved:
|
||||
inc screenX
|
||||
cmp screenX, SCREEN_W
|
||||
jl ray_column_loop
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; 3. Render Sprites
|
||||
; 3. Render Sprites (Behind Glass & Normal Columns)
|
||||
; --------------------------------------------------------------------------
|
||||
call RenderSprites
|
||||
invoke RenderSprites, 1
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; 4. Render Glass (Semi-Transparent Wall Pass)
|
||||
; --------------------------------------------------------------------------
|
||||
call RenderGlass
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; 5. Render Sprites (In Front of Glass)
|
||||
; --------------------------------------------------------------------------
|
||||
invoke RenderSprites, 2
|
||||
|
||||
ret
|
||||
Render3DScene ENDP
|
||||
|
|
@ -390,11 +578,11 @@ Render3DScene ENDP
|
|||
; per-column depth test vs depthBuffer, chroma key
|
||||
; Sprite x/y are RAW world units (1 cell = 64 units), same scale as playerX/Y.
|
||||
; ------------------------------------------------------------------------------
|
||||
RenderSprites PROC uses ebx ecx edx esi edi
|
||||
RenderSprites PROC uses ebx ecx edx esi edi passFilter:DWORD
|
||||
LOCAL i:DWORD, j:DWORD, sprIdx:DWORD
|
||||
LOCAL spriteX:DWORD, spriteY:DWORD
|
||||
LOCAL transX:DWORD, transY:DWORD
|
||||
LOCAL sprScreenX:DWORD, sprSize:DWORD
|
||||
LOCAL sprScreenX:DWORD, sprSize:DWORD, baseSize:DWORD
|
||||
LOCAL origStartX:DWORD, origStartY:DWORD
|
||||
LOCAL drawStartX:DWORD, drawEndX:DWORD
|
||||
LOCAL drawStartY:DWORD, drawEndY:DWORD
|
||||
|
|
@ -483,7 +671,7 @@ sort_inner:
|
|||
add ebx, OFFSET spriteList
|
||||
mov eax, [ebx].SpriteStruct.dist
|
||||
cmp eax, keyDist
|
||||
jbe sort_insert ; dist[j] <= key -> insert after j
|
||||
jae sort_insert ; dist[j] >= key -> keep far-to-near order
|
||||
|
||||
; Shift spriteOrder[j] -> spriteOrder[j+1]
|
||||
mov ecx, j
|
||||
|
|
@ -566,6 +754,7 @@ sprite_loop:
|
|||
mov eax, (64 * PROJ_DIST)
|
||||
xor edx, edx
|
||||
div transY
|
||||
mov baseSize, eax
|
||||
mov ecx, [ebx].SpriteStruct.scale
|
||||
test ecx, ecx
|
||||
jnz @F
|
||||
|
|
@ -589,7 +778,7 @@ sprite_loop:
|
|||
; Floor-anchored: sprite bottom sits at the floor line of its depth
|
||||
; (wall bottom = horizon + size/2), top = bottom - size
|
||||
mov eax, SCREEN_HALF_H
|
||||
mov edx, sprSize
|
||||
mov edx, baseSize
|
||||
shr edx, 1
|
||||
add eax, edx
|
||||
mov drawEndY, eax
|
||||
|
|
@ -655,6 +844,30 @@ stripe_loop:
|
|||
cmp eax, drawEndX
|
||||
jg next_sprite
|
||||
|
||||
; Filter by glass pass
|
||||
mov ecx, stripe
|
||||
mov edx, [glassDistBuffer + ecx*4]
|
||||
|
||||
cmp passFilter, 1
|
||||
jne check_pass_front
|
||||
|
||||
; Pass 1 (behind glass or columns without glass):
|
||||
test edx, edx
|
||||
jz normal_sprite_depth_test
|
||||
cmp transY, edx
|
||||
jl advance_stripe ; Sprite is in front of glass -> defer to Pass 2
|
||||
jmp normal_sprite_depth_test
|
||||
|
||||
check_pass_front:
|
||||
cmp passFilter, 2
|
||||
jne normal_sprite_depth_test
|
||||
; Pass 2 (in front of glass only):
|
||||
test edx, edx
|
||||
jz advance_stripe ; No glass in this column -> already drawn
|
||||
cmp transY, edx
|
||||
jge advance_stripe ; Sprite is behind glass -> already drawn
|
||||
|
||||
normal_sprite_depth_test:
|
||||
; Depth test: sprite visible only if closer than wall in this column
|
||||
mov eax, transY
|
||||
mov ecx, stripe
|
||||
|
|
@ -739,3 +952,172 @@ next_sprite:
|
|||
sprites_done:
|
||||
ret
|
||||
RenderSprites ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; RenderGlass: Renders semi-transparent glass wall pass over background & sprites
|
||||
; ------------------------------------------------------------------------------
|
||||
RenderGlass PROC uses ebx ecx edx esi edi
|
||||
LOCAL screenX:DWORD
|
||||
LOCAL drawStart:DWORD, drawEnd:DWORD, lineHeight:DWORD
|
||||
LOCAL texX:DWORD, texPos:DWORD, texStep:DWORD
|
||||
LOCAL texPtr:DWORD, pScreenPixel:DWORD
|
||||
LOCAL sideHit:DWORD, perpDist:DWORD
|
||||
|
||||
mov eax, CELL_GLASS
|
||||
shl eax, 16 ; CELL_GLASS * TEX_SIZE_BYTES (8 * 65536)
|
||||
add eax, OFFSET textureBuffer
|
||||
mov texPtr, eax
|
||||
|
||||
mov screenX, 0
|
||||
glass_col_loop:
|
||||
mov ecx, screenX
|
||||
cmp ecx, SCREEN_W
|
||||
jge glass_done
|
||||
|
||||
mov eax, [glassDistBuffer + ecx*4]
|
||||
test eax, eax
|
||||
jz next_glass_col
|
||||
|
||||
mov perpDist, eax
|
||||
mov eax, [glassDrawStartBuf + ecx*4]
|
||||
mov drawStart, eax
|
||||
mov eax, [glassDrawEndBuf + ecx*4]
|
||||
mov drawEnd, eax
|
||||
mov eax, [glassLineHeightBuf + ecx*4]
|
||||
mov lineHeight, eax
|
||||
mov eax, [glassTexXBuf + ecx*4]
|
||||
mov texX, eax
|
||||
mov eax, [glassSideHitBuf + ecx*4]
|
||||
mov sideHit, eax
|
||||
|
||||
; Texture vertical step: texStep = (128 << 16) / lineHeight
|
||||
mov eax, (128 shl 16)
|
||||
xor edx, edx
|
||||
div lineHeight
|
||||
mov texStep, eax
|
||||
|
||||
; texPos = (drawStart - SCREEN_HALF_H + lineHeight / 2) * texStep
|
||||
mov eax, drawStart
|
||||
sub eax, SCREEN_HALF_H
|
||||
mov ebx, lineHeight
|
||||
shr ebx, 1
|
||||
add eax, ebx
|
||||
imul eax, texStep
|
||||
mov texPos, eax
|
||||
|
||||
; Screen pixel address: screenBuffer + (drawStart * SCREEN_PITCH) + (screenX * 4)
|
||||
mov eax, drawStart
|
||||
imul eax, SCREEN_PITCH
|
||||
mov ecx, screenX
|
||||
shl ecx, 2
|
||||
add eax, ecx
|
||||
add eax, OFFSET screenBuffer
|
||||
mov pScreenPixel, eax
|
||||
|
||||
mov ecx, drawStart
|
||||
glass_pixel_loop:
|
||||
cmp ecx, drawEnd
|
||||
jg next_glass_col
|
||||
|
||||
; Sample texture: texY = (texPos >> 16) & TEX_MASK
|
||||
mov eax, texPos
|
||||
shr eax, 16
|
||||
and eax, TEX_MASK
|
||||
mov ebx, eax ; ebx = texY
|
||||
|
||||
shl eax, 7 ; texY * 128
|
||||
add eax, texX
|
||||
shl eax, 2
|
||||
add eax, texPtr
|
||||
mov edx, [eax] ; edx = sampled texel (00RRGGBBh)
|
||||
|
||||
; Wall shading: Y-facing walls darkened 50% for 3D depth perception
|
||||
cmp sideHit, 1
|
||||
jne @F
|
||||
and edx, 00FEFEFEh
|
||||
shr edx, 1
|
||||
@@:
|
||||
; Distance fog for glass
|
||||
mov eax, perpDist
|
||||
cmp eax, 350
|
||||
jl glass_blend
|
||||
cmp eax, 700
|
||||
jge glass_fog_black
|
||||
and edx, 00FEFEFEh
|
||||
shr edx, 1
|
||||
jmp glass_blend
|
||||
glass_fog_black:
|
||||
mov edx, 00111111h
|
||||
|
||||
glass_blend:
|
||||
; Check if this texel is the solid metal frame:
|
||||
; Outer 6 pixels: texX < 6 || texX >= 122 || texY < 6 || texY >= 122
|
||||
cmp texX, 6
|
||||
jl write_solid_frame
|
||||
cmp texX, 122
|
||||
jge write_solid_frame
|
||||
cmp ebx, 6
|
||||
jl write_solid_frame
|
||||
cmp ebx, 122
|
||||
jge write_solid_frame
|
||||
|
||||
; Inside the glass pane: SEMI-TRANSPARENT BLEND!
|
||||
; Background pixel from screenBuffer
|
||||
mov eax, pScreenPixel
|
||||
mov eax, [eax]
|
||||
|
||||
; Check diagonal reflection streak:
|
||||
mov esi, texX
|
||||
add esi, ebx
|
||||
and esi, 63
|
||||
cmp esi, 4
|
||||
jl glass_shine_streak
|
||||
cmp esi, 24
|
||||
jl glass_pane_blend
|
||||
cmp esi, 28
|
||||
jl glass_shine_streak
|
||||
|
||||
glass_pane_blend:
|
||||
; 50% background + 50% glass tint:
|
||||
and eax, 00FEFEFEh
|
||||
shr eax, 1
|
||||
and edx, 00FEFEFEh
|
||||
shr edx, 1
|
||||
add eax, edx
|
||||
jmp write_glass_out
|
||||
|
||||
glass_shine_streak:
|
||||
; Specular reflection: 50% background + 50% glass + specular glare
|
||||
and eax, 00FEFEFEh
|
||||
shr eax, 1
|
||||
and edx, 00FEFEFEh
|
||||
shr edx, 1
|
||||
add eax, edx
|
||||
; Add specular highlight:
|
||||
add eax, 00203040h
|
||||
|
||||
write_glass_out:
|
||||
mov edx, pScreenPixel
|
||||
mov [edx], eax
|
||||
jmp next_glass_pixel
|
||||
|
||||
write_solid_frame:
|
||||
mov eax, pScreenPixel
|
||||
mov [eax], edx
|
||||
|
||||
next_glass_pixel:
|
||||
mov eax, texPos
|
||||
add eax, texStep
|
||||
mov texPos, eax
|
||||
|
||||
add pScreenPixel, SCREEN_PITCH
|
||||
inc ecx
|
||||
jmp glass_pixel_loop
|
||||
|
||||
next_glass_col:
|
||||
inc screenX
|
||||
jmp glass_col_loop
|
||||
|
||||
glass_done:
|
||||
ret
|
||||
RenderGlass ENDP
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
.code
|
||||
|
||||
GenerateDefaultSpriteTextures PROTO
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; GenerateDefaultTextures: Procedurally creates 128x128 32-bit BGRA textures
|
||||
; Zero external asset dependency - works out of the box on any machine!
|
||||
|
|
@ -11,6 +13,12 @@
|
|||
GenerateDefaultTextures PROC uses ebx ecx edx esi edi
|
||||
LOCAL texBase:DWORD, px:DWORD, py:DWORD, color:DWORD
|
||||
|
||||
mov edi, OFFSET textureBuffer
|
||||
mov ecx, MAX_TEXTURES * TEX_SIZE_BYTES / 4
|
||||
xor eax, eax
|
||||
rep stosd
|
||||
call GenerateDefaultSpriteTextures
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; Texture 1: Red Brick Wall
|
||||
; --------------------------------------------------------------------------
|
||||
|
|
@ -460,6 +468,72 @@ write_marble_pixel:
|
|||
cmp py, TEX_H
|
||||
jl marble_y_loop
|
||||
|
||||
; --------------------------------------------------------------------------
|
||||
; Texture 10: Portal / Dimensional Vortex (Swirling purple / cyan stargate)
|
||||
; --------------------------------------------------------------------------
|
||||
mov texBase, OFFSET portalTextureBuffer
|
||||
|
||||
mov py, 0
|
||||
portal_y_loop:
|
||||
mov px, 0
|
||||
portal_x_loop:
|
||||
; Base: deep cosmic dark purple
|
||||
mov color, 002A0835h
|
||||
|
||||
; Stone arch frame on outer 8 pixels
|
||||
mov eax, px
|
||||
cmp eax, 8
|
||||
jl set_portal_arch
|
||||
cmp eax, 120
|
||||
jge set_portal_arch
|
||||
mov eax, py
|
||||
cmp eax, 8
|
||||
jl set_portal_arch
|
||||
|
||||
; Calculate distance from center (64, 64)
|
||||
mov eax, px
|
||||
sub eax, 64
|
||||
imul eax, eax
|
||||
mov edx, py
|
||||
sub edx, 64
|
||||
imul edx, edx
|
||||
add eax, edx ; r^2 = dx^2 + dy^2
|
||||
|
||||
; Concentric glowing energy rings
|
||||
cmp eax, 2500 ; r ~ 50
|
||||
jg write_portal_pixel
|
||||
and eax, 15
|
||||
cmp eax, 3
|
||||
jg @F
|
||||
mov color, 00D030F0h ; Bright violet energy ring
|
||||
jmp write_portal_pixel
|
||||
@@:
|
||||
cmp eax, 8
|
||||
jg write_portal_pixel
|
||||
mov color, 00E0E040h ; Cyan shimmer
|
||||
jmp write_portal_pixel
|
||||
|
||||
set_portal_arch:
|
||||
mov color, 00484852h ; Carved rune stone arch
|
||||
|
||||
write_portal_pixel:
|
||||
mov eax, py
|
||||
shl eax, 7
|
||||
add eax, px
|
||||
shl eax, 2
|
||||
mov edi, texBase
|
||||
add edi, eax
|
||||
mov edx, color
|
||||
mov [edi], edx
|
||||
|
||||
inc px
|
||||
cmp px, TEX_W
|
||||
jl portal_x_loop
|
||||
|
||||
inc py
|
||||
cmp py, TEX_H
|
||||
jl portal_y_loop
|
||||
|
||||
ret
|
||||
GenerateDefaultTextures ENDP
|
||||
|
||||
|
|
@ -471,65 +545,206 @@ GenerateDefaultTextures ENDP
|
|||
; Returns: eax = 1 if pack loaded, 0 if procedural textures stay active.
|
||||
; ------------------------------------------------------------------------------
|
||||
LoadTexturePack PROC uses ebx ecx edx esi edi
|
||||
LOCAL hFile:DWORD
|
||||
LOCAL bytesRead:DWORD
|
||||
LOCAL magic:DWORD, pkVersion:DWORD, slotCount:DWORD
|
||||
LOCAL hFile:DWORD, bytesRead:DWORD
|
||||
LOCAL magic:DWORD, pkVersion:WORD, slotCount:WORD
|
||||
LOCAL payloadBytes:DWORD
|
||||
|
||||
invoke CreateFileA, OFFSET szTexPackPath, GENERIC_READ, FILE_SHARE_READ,\
|
||||
0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
|
||||
mov texPackLoaded, 0
|
||||
invoke CreateFileA, OFFSET szTexPackPath, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
|
||||
mov hFile, eax
|
||||
cmp hFile, INVALID_HANDLE_VALUE
|
||||
cmp eax, INVALID_HANDLE_VALUE
|
||||
je pack_fail
|
||||
|
||||
; Read 8-byte header (dd magic, dw version, dw slotCount)
|
||||
invoke ReadFile, hFile, ADDR magic, 4, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
cmp bytesRead, 4
|
||||
jl pack_close_fail
|
||||
jne pack_close_fail
|
||||
cmp magic, TEXPACK_MAGIC
|
||||
jne pack_close_fail
|
||||
|
||||
invoke ReadFile, hFile, ADDR pkVersion, 2, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
cmp bytesRead, 2
|
||||
jl pack_close_fail
|
||||
jne pack_close_fail
|
||||
cmp pkVersion, 1
|
||||
jne pack_close_fail
|
||||
|
||||
invoke ReadFile, hFile, ADDR slotCount, 2, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
cmp bytesRead, 2
|
||||
jl pack_close_fail
|
||||
|
||||
; Clamp slot count to engine capacity
|
||||
mov eax, slotCount
|
||||
and eax, 0FFFFh
|
||||
jne pack_close_fail
|
||||
movzx eax, slotCount
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
cmp eax, MAX_TEXTURES
|
||||
jle @F
|
||||
mov eax, MAX_TEXTURES
|
||||
@@:
|
||||
mov slotCount, eax
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
|
||||
; Read slotCount * TEX_SIZE_BYTES pixels straight into textureBuffer
|
||||
mov ecx, slotCount
|
||||
shl ecx, 16 ; * 65536
|
||||
invoke ReadFile, hFile, OFFSET textureBuffer, ecx, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
mov eax, slotCount
|
||||
ja pack_close_fail
|
||||
shl eax, 16
|
||||
cmp bytesRead, eax
|
||||
mov payloadBytes, eax
|
||||
add eax, 8
|
||||
mov ebx, eax
|
||||
invoke GetFileSize, hFile, 0
|
||||
cmp eax, ebx
|
||||
jne pack_close_fail
|
||||
|
||||
; Commit only after the complete payload has been read successfully.
|
||||
invoke ReadFile, hFile, OFFSET texturePackStaging, payloadBytes, ADDR bytesRead, 0
|
||||
test eax, eax
|
||||
jz pack_close_fail
|
||||
mov eax, payloadBytes
|
||||
cmp bytesRead, eax
|
||||
jne pack_close_fail
|
||||
mov esi, OFFSET texturePackStaging
|
||||
mov edi, OFFSET textureBuffer
|
||||
mov ecx, payloadBytes
|
||||
shr ecx, 2
|
||||
rep movsd
|
||||
invoke CloseHandle, hFile
|
||||
mov texPackLoaded, 1
|
||||
mov eax, 1
|
||||
ret
|
||||
|
||||
pack_close_fail:
|
||||
invoke CloseHandle, hFile
|
||||
pack_fail:
|
||||
xor eax, eax
|
||||
ret
|
||||
LoadTexturePack ENDP
|
||||
|
||||
; Slots 10..15 remain sprite slots even when the map contains portals.
|
||||
GenerateDefaultSpriteTextures PROC uses eax ebx ecx edx esi edi
|
||||
LOCAL px:DWORD, py:DWORD, slot:DWORD, color:DWORD
|
||||
mov edi, OFFSET textureBuffer + 10 * TEX_SIZE_BYTES
|
||||
mov ecx, 6 * TEX_SIZE_BYTES / 4
|
||||
mov eax, COLOR_CHROMA_KEY
|
||||
rep stosd
|
||||
mov slot, 10
|
||||
sprite_default_slot:
|
||||
mov py, 0
|
||||
sprite_default_y:
|
||||
mov px, 0
|
||||
sprite_default_x:
|
||||
mov color, COLOR_CHROMA_KEY
|
||||
cmp slot, 10
|
||||
jne default_barrel
|
||||
cmp px, 60
|
||||
jl lamp_shade
|
||||
cmp px, 68
|
||||
jge lamp_shade
|
||||
cmp py, 40
|
||||
jl lamp_shade
|
||||
cmp py, 120
|
||||
jge lamp_shade
|
||||
mov color, 00404040h
|
||||
lamp_shade:
|
||||
cmp px, 48
|
||||
jl lamp_foot
|
||||
cmp px, 80
|
||||
jge lamp_foot
|
||||
cmp py, 16
|
||||
jl lamp_foot
|
||||
cmp py, 44
|
||||
jge lamp_foot
|
||||
mov color, 00FFC030h
|
||||
lamp_foot:
|
||||
cmp px, 52
|
||||
jl default_sprite_write
|
||||
cmp px, 76
|
||||
jge default_sprite_write
|
||||
cmp py, 120
|
||||
jl default_sprite_write
|
||||
cmp py, 126
|
||||
jge default_sprite_write
|
||||
mov color, 00303030h
|
||||
jmp default_sprite_write
|
||||
|
||||
default_barrel:
|
||||
cmp slot, 11
|
||||
jne default_statue
|
||||
mov eax, px
|
||||
sub eax, 64
|
||||
imul eax, eax
|
||||
imul eax, 2704
|
||||
mov ebx, py
|
||||
sub ebx, 70
|
||||
imul ebx, ebx
|
||||
imul ebx, 1156
|
||||
add eax, ebx
|
||||
cmp eax, 3125824
|
||||
jg default_sprite_write
|
||||
mov color, 006A401Eh
|
||||
mov eax, py
|
||||
sub eax, 70
|
||||
cdq
|
||||
xor eax, edx
|
||||
sub eax, edx
|
||||
cmp eax, 29
|
||||
jl barrel_highlight
|
||||
cmp eax, 35
|
||||
jg barrel_highlight
|
||||
mov color, 00555555h
|
||||
jmp default_sprite_write
|
||||
barrel_highlight:
|
||||
cmp px, 53
|
||||
jge default_sprite_write
|
||||
cmp eax, 26
|
||||
jge default_sprite_write
|
||||
mov color, 008A5A2Eh
|
||||
jmp default_sprite_write
|
||||
|
||||
default_statue:
|
||||
cmp px, 44
|
||||
jl statue_body
|
||||
cmp px, 84
|
||||
jge statue_body
|
||||
cmp py, 112
|
||||
jl statue_body
|
||||
cmp py, 126
|
||||
jge statue_body
|
||||
mov color, 00606060h
|
||||
statue_body:
|
||||
cmp px, 54
|
||||
jl statue_head
|
||||
cmp px, 74
|
||||
jge statue_head
|
||||
cmp py, 40
|
||||
jl statue_head
|
||||
cmp py, 112
|
||||
jge statue_head
|
||||
mov eax, px
|
||||
xor eax, py
|
||||
and eax, 7
|
||||
add eax, 08Ah
|
||||
imul eax, 010101h
|
||||
mov color, eax
|
||||
statue_head:
|
||||
mov eax, px
|
||||
sub eax, 64
|
||||
imul eax, eax
|
||||
mov ebx, py
|
||||
sub ebx, 28
|
||||
imul ebx, ebx
|
||||
add eax, ebx
|
||||
cmp eax, 100
|
||||
jg default_sprite_write
|
||||
mov color, 009A9A9Ah
|
||||
|
||||
default_sprite_write:
|
||||
mov edi, slot
|
||||
shl edi, 16
|
||||
mov eax, py
|
||||
shl eax, 7
|
||||
add eax, px
|
||||
shl eax, 2
|
||||
add edi, eax
|
||||
mov eax, color
|
||||
mov DWORD PTR [textureBuffer + edi], eax
|
||||
inc px
|
||||
cmp px, TEX_W
|
||||
jl sprite_default_x
|
||||
inc py
|
||||
cmp py, TEX_H
|
||||
jl sprite_default_y
|
||||
inc slot
|
||||
cmp slot, 13
|
||||
jl sprite_default_slot
|
||||
ret
|
||||
GenerateDefaultSpriteTextures ENDP
|
||||
|
|
|
|||
126
main.asm
126
main.asm
|
|
@ -16,6 +16,9 @@ includelib winmm.lib
|
|||
; Windows API Prototypes
|
||||
ExitProcess PROTO :DWORD
|
||||
GetModuleHandleA PROTO :DWORD
|
||||
GetCommandLineA PROTO
|
||||
GetFileSize PROTO :DWORD, :DWORD
|
||||
GetForegroundWindow PROTO
|
||||
RegisterClassExA PROTO :DWORD
|
||||
CreateWindowExA PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
ShowWindow PROTO :DWORD, :DWORD
|
||||
|
|
@ -39,6 +42,12 @@ CreateFileA PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :D
|
|||
ReadFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
WriteFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
CloseHandle PROTO :DWORD
|
||||
PlaySoundA PROTO :DWORD, :DWORD, :DWORD
|
||||
OpenFileMappingA PROTO :DWORD, :DWORD, :DWORD
|
||||
MapViewOfFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
|
||||
UnmapViewOfFile PROTO :DWORD
|
||||
OpenProcess PROTO :DWORD, :DWORD, :DWORD
|
||||
WaitForSingleObject PROTO :DWORD, :DWORD
|
||||
|
||||
; Structures
|
||||
POINT STRUCT
|
||||
|
|
@ -104,6 +113,8 @@ include include/engine_input.inc
|
|||
include include/engine_physics.inc
|
||||
include include/engine_raycaster.inc
|
||||
include include/engine_render2d.inc
|
||||
include include/engine_cutscene.inc
|
||||
include include/engine_preview.inc
|
||||
|
||||
.code
|
||||
|
||||
|
|
@ -121,12 +132,13 @@ WndProc PROC hWnd:DWORD, uMsg:DWORD, wParam:DWORD, lParam:DWORD
|
|||
|
||||
handle_keydown:
|
||||
cmp wParam, VK_KEY_ESCAPE
|
||||
jne @F
|
||||
jne check_f12
|
||||
invoke PostQuitMessage, 0
|
||||
mov isRunning, 0
|
||||
xor eax, eax
|
||||
ret
|
||||
@@:
|
||||
check_f12:
|
||||
; Movement and action edges are handled only by PollInput.
|
||||
invoke DefWindowProcA, hWnd, uMsg, wParam, lParam
|
||||
ret
|
||||
|
||||
|
|
@ -207,6 +219,11 @@ Engine_Init ENDP
|
|||
; Engine_RenderHUD: Draws 2D overlay text, controls, minimap, and FPS
|
||||
; ------------------------------------------------------------------------------
|
||||
Engine_RenderHUD PROC uses ebx ecx edx
|
||||
cmp isCutsceneActive, 0
|
||||
je normal_engine_hud
|
||||
call RenderCutsceneOverlay
|
||||
ret
|
||||
normal_engine_hud:
|
||||
; 1. Top HUD Header
|
||||
invoke DrawString8x8, 10, 10, OFFSET strEngineName, COLOR_YELLOW
|
||||
|
||||
|
|
@ -219,13 +236,26 @@ Engine_RenderHUD PROC uses ebx ecx edx
|
|||
invoke DrawString8x8, 10, 218, OFFSET strControls1, COLOR_WHITE
|
||||
invoke DrawString8x8, 10, 228, OFFSET strControls3, COLOR_GREEN
|
||||
|
||||
; 4. Door Interaction Hint (center screen)
|
||||
; 4. Door / Portal Interaction Hint (center screen)
|
||||
call CheckDoorHint
|
||||
test eax, eax
|
||||
jz @F
|
||||
invoke DrawString8x8, 128, 150, OFFSET strDoorHint, COLOR_YELLOW
|
||||
@@:
|
||||
cmp eax, 1
|
||||
je show_door_hint
|
||||
cmp eax, 2
|
||||
je show_portal_hint
|
||||
jmp check_minimap
|
||||
|
||||
show_door_hint:
|
||||
invoke DrawString8x8, 128, 150, OFFSET strDoorHint, COLOR_YELLOW
|
||||
jmp check_minimap
|
||||
|
||||
show_portal_hint:
|
||||
invoke DrawString8x8, 110, 150, OFFSET strPortalHint, COLOR_CYAN
|
||||
|
||||
check_minimap:
|
||||
cmp mapErrorFrames, 0
|
||||
je hud_no_map_error
|
||||
invoke DrawString8x8, 44, 164, OFFSET strMapLoadError, COLOR_RED
|
||||
hud_no_map_error:
|
||||
; 5. Minimap Radar (Toggle with 'M')
|
||||
call DrawMinimap
|
||||
|
||||
|
|
@ -237,12 +267,17 @@ Engine_RenderHUD ENDP
|
|||
; ------------------------------------------------------------------------------
|
||||
Engine_Frame PROC uses ebx esi edi
|
||||
; 1. Input & Physics Update
|
||||
cmp mapErrorFrames, 0
|
||||
je no_error_tick
|
||||
dec mapErrorFrames
|
||||
no_error_tick:
|
||||
call PollInput
|
||||
call UpdatePlayer
|
||||
|
||||
; 2. 3D Raycasting & 2D HUD Render
|
||||
call Render3DScene
|
||||
call Engine_RenderHUD
|
||||
call ApplyCutsceneFade
|
||||
|
||||
; 2b. Screenshot request (F12): dump fresh framebuffer to BMP
|
||||
cmp keyScreenshot, 0
|
||||
|
|
@ -251,6 +286,9 @@ Engine_Frame PROC uses ebx esi edi
|
|||
call SaveScreenshot
|
||||
@@:
|
||||
|
||||
; 2c. Screen Fade effect for sublevel transitions
|
||||
call ApplyScreenFade
|
||||
|
||||
; 3. Present Framebuffer to Screen via StretchDIBits (320x240 -> 1280x960)
|
||||
invoke StretchDIBits, hEngineDC,\
|
||||
0, 0, WINDOW_W, WINDOW_H,\
|
||||
|
|
@ -274,6 +312,14 @@ Engine_Frame PROC uses ebx esi edi
|
|||
mov fpsTimer, eax
|
||||
|
||||
frame_done:
|
||||
; Reset one-frame input triggers
|
||||
mov keyForward, 0
|
||||
mov keyBackward, 0
|
||||
mov keyStrafeLeft, 0
|
||||
mov keyStrafeRight, 0
|
||||
mov keyTurnLeft, 0
|
||||
mov keyTurnRight, 0
|
||||
mov keyAction, 0
|
||||
ret
|
||||
Engine_Frame ENDP
|
||||
|
||||
|
|
@ -296,6 +342,69 @@ ss_done:
|
|||
ret
|
||||
SaveScreenshot ENDP
|
||||
|
||||
; Optional first argument: a quoted or unquoted starting map path.
|
||||
; Assets and linked map basenames remain relative to the working directory.
|
||||
ParseStartupMap PROC uses ebx ecx edx esi edi
|
||||
invoke GetCommandLineA
|
||||
mov esi, eax
|
||||
mov startupMapPath, 0
|
||||
cmp byte ptr [esi], 34
|
||||
jne skip_executable_word
|
||||
inc esi
|
||||
skip_executable_quote:
|
||||
lodsb
|
||||
test al, al
|
||||
jz startup_arg_done
|
||||
cmp al, 34
|
||||
jne skip_executable_quote
|
||||
jmp skip_argument_spaces
|
||||
skip_executable_word:
|
||||
lodsb
|
||||
test al, al
|
||||
jz startup_arg_done
|
||||
cmp al, 32
|
||||
jne skip_executable_word
|
||||
skip_argument_spaces:
|
||||
cmp byte ptr [esi], 32
|
||||
je consume_argument_space
|
||||
cmp byte ptr [esi], 9
|
||||
jne copy_startup_argument
|
||||
consume_argument_space:
|
||||
inc esi
|
||||
jmp skip_argument_spaces
|
||||
copy_startup_argument:
|
||||
xor ebx, ebx
|
||||
cmp byte ptr [esi], 34
|
||||
jne startup_argument_loop_init
|
||||
mov ebx, 1
|
||||
inc esi
|
||||
startup_argument_loop_init:
|
||||
mov edi, OFFSET startupMapPath
|
||||
mov ecx, MAX_PATH_BYTES - 1
|
||||
startup_argument_loop:
|
||||
lodsb
|
||||
test al, al
|
||||
jz terminate_startup_arg
|
||||
cmp al, 34
|
||||
je terminate_startup_arg
|
||||
test ebx, ebx
|
||||
jnz write_startup_char
|
||||
cmp al, 32
|
||||
je terminate_startup_arg
|
||||
cmp al, 9
|
||||
je terminate_startup_arg
|
||||
write_startup_char:
|
||||
stosb
|
||||
dec ecx
|
||||
jnz startup_argument_loop
|
||||
mov startupMapPath, 0
|
||||
ret
|
||||
terminate_startup_arg:
|
||||
mov byte ptr [edi], 0
|
||||
startup_arg_done:
|
||||
ret
|
||||
ParseStartupMap ENDP
|
||||
|
||||
; ------------------------------------------------------------------------------
|
||||
; main: Application Entry Point & Game Loop
|
||||
; ------------------------------------------------------------------------------
|
||||
|
|
@ -303,6 +412,8 @@ main PROC
|
|||
LOCAL msg:MSG
|
||||
LOCAL curTick:DWORD, elapsed:DWORD
|
||||
|
||||
call ParseStartupMap
|
||||
call TryEnginePreview
|
||||
call Engine_Init
|
||||
|
||||
main_loop:
|
||||
|
|
@ -344,6 +455,7 @@ do_tick:
|
|||
jmp main_loop
|
||||
|
||||
exit_loop:
|
||||
call StopCutscene
|
||||
invoke ReleaseDC, hEngineWnd, hEngineDC
|
||||
invoke timeEndPeriod, 1
|
||||
invoke ExitProcess, 0
|
||||
|
|
|
|||
BIN
main.obj
BIN
main.obj
Binary file not shown.
88
tests/cutscene_editor_checks.inl
Normal file
88
tests/cutscene_editor_checks.inl
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
static void RunCutsceneChecks(EditorApp& app) {
|
||||
Check(app.LoadLevel("../assets/map.dat"), "cutscene test starts from legacy map");
|
||||
app.playerStartAngle = 508; app.playerStartY = 3;
|
||||
app.cutscene.flags = CUTSCENE_AUTOPLAY;
|
||||
app.cutscene.strings = {"Привет, мир!", "quiet.wav"};
|
||||
app.cutscene.events = {
|
||||
{0,OP_MOVE_CAM_TO,0,288,160,4}, {0,OP_PAN_CAM_TO,0,4,4,0},
|
||||
{0,OP_FADE_SCREEN,0,16,4,0}, {0,OP_SHOW_SUBTITLE,0,0,0,0},
|
||||
{0,OP_PLAY_SOUND,0,1,0,0}, {1,OP_WAIT,0,2,0,0},
|
||||
{2,OP_HIDE_SUBTITLE,0,0,0,0}, {4,OP_FADE_SCREEN,0,0,4,0}, {8,OP_END,0,0,0,0}
|
||||
};
|
||||
Check(app.SaveLevel("../assets/scene.dat"), "save v4 cutscene, Russian text and sound reference");
|
||||
BinaryLevel level; std::string error;
|
||||
Check(ReadLevelFile("../assets/scene.dat",level,error) && level.header.version == 4 && level.cutscene.events.size() == 9 &&
|
||||
level.cutscene.strings[0] == "Привет, мир!" && level.cutscene.flags == CUTSCENE_AUTOPLAY,
|
||||
"v4 track round trip retains UTF8 and autoplay");
|
||||
std::string encoded;
|
||||
Check(CutsceneEncode("Привет, мир!",encoded) && encoded.size() == 12 && static_cast<unsigned char>(encoded[0]) == 0xCF,
|
||||
"Russian subtitles use the runtime CP1251 font");
|
||||
Check(!CutsceneEncode("😀",encoded), "unrepresentable subtitle is rejected");
|
||||
CutscenePreview preview;
|
||||
preview.Start(160,224,508);
|
||||
std::ofstream trace("../assets/cutscene_reference.bin",std::ios::binary);
|
||||
int audioCount = 0;
|
||||
for (int tick=0;tick<=8;++tick) {
|
||||
preview.Step(app.cutscene,[&](int id,bool loop) { if(id==1 && !loop) ++audioCount; });
|
||||
int state[6] = {preview.x,preview.y,preview.angle,preview.fade,preview.subtitle,preview.active?1:0};
|
||||
trace.write(reinterpret_cast<const char*>(state),sizeof(state));
|
||||
if (tick==2) Check(preview.x==224 && preview.y==192 && preview.angle==0 && preview.fade==8 && preview.subtitle==0,
|
||||
"preview interpolates signed movement, shortest angle, fade and wait");
|
||||
if (tick==3) Check(preview.subtitle==-1, "wait delays a following hide command until its deadline");
|
||||
}
|
||||
trace.close();
|
||||
Check(preview.x==288 && preview.y==160 && preview.angle==4 && !preview.active && preview.fade==0 && audioCount==1,
|
||||
"preview reaches exact targets and stops cleanly");
|
||||
auto invalid = app.cutscene;
|
||||
invalid.events[0].dataId=3601;
|
||||
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "oversized camera duration rejected");
|
||||
invalid=app.cutscene;invalid.events[3].paramA=99;
|
||||
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "invalid string reference rejected");
|
||||
invalid=app.cutscene;invalid.events.pop_back();
|
||||
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "missing end marker rejected");
|
||||
invalid=app.cutscene;invalid.events[0].paramA=0;invalid.events[0].paramB=0;
|
||||
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "camera point in a wall rejected");
|
||||
for (const char* file : {"scene_badop.dat","scene_badref.dat","scene_badtext.dat","scene_badorder.dat","scene_badflags.dat","scene_noend.dat"}) {
|
||||
Check(!app.LoadLevel(std::string("../assets/")+file) && app.cutscene.events.size()==9,
|
||||
"malformed cutscene load preserves editor track");
|
||||
}
|
||||
std::string start, engine;
|
||||
Check(app.PreparePlaytest(start,engine) && start=="assets\\scene.dat", "F5 packages v4 track and referenced WAV");
|
||||
app.CleanupPlaytest();
|
||||
app.cutscene.strings[1]="absent.wav";start.clear();engine.clear();
|
||||
Check(!app.PreparePlaytest(start,engine), "F5 rejects missing cutscene audio before launch");
|
||||
app.CleanupPlaytest();
|
||||
app.LoadLevel("../assets/map.dat");
|
||||
Check(app.cutscene.events.empty() && app.cutscene.strings.empty(), "legacy map clears previous track");
|
||||
app.AddCutsceneEvent(OP_MOVE_CAM_TO);
|
||||
Check(app.cutscene.events.size()==2 && app.placeCameraPoint, "timeline camera button inserts an end marker and enables map placement");
|
||||
app.Undo();Check(app.cutscene.events.empty(), "undo includes cutscene commands");
|
||||
app.Redo();Check(app.cutscene.events.size()==2, "redo restores cutscene commands");
|
||||
app.cutscene = {};app.selectedCutsceneEvent=-1;
|
||||
for(int i=0;i<40;++i) { app.AddCutsceneEvent(OP_SHOW_SUBTITLE);app.DeleteCutsceneEvent(); }
|
||||
Check(app.cutscene.strings.empty(), "deleting commands reclaims string pool slots");
|
||||
app.AddCutsceneEvent(OP_CHANGE_SUBLEVEL);
|
||||
app.SetCell(4,2,CELL_EMPTY);
|
||||
Check(std::none_of(app.cutscene.events.begin(),app.cutscene.events.end(),[](const auto& e){return e.opCode==OP_CHANGE_SUBLEVEL;}),
|
||||
"removing a portal repairs cutscene transition references");
|
||||
app.LoadLevel("../assets/scene.dat");app.showCutsceneTimeline=true;
|
||||
ImGui::CreateContext();
|
||||
auto& io=ImGui::GetIO();io.DisplaySize=ImVec2(1360,840);io.DeltaTime=1.0f/60;io.IniFilename=nullptr;
|
||||
unsigned char* pixels;int w,h;io.Fonts->GetTexDataAsRGBA32(&pixels,&w,&h);
|
||||
ImGui::NewFrame();app.RenderUI();ImGui::Render();
|
||||
Check(ImGui::GetDrawData()->TotalVtxCount>0,"editor timeline renders without a native window");
|
||||
io.AddKeyEvent(ImGuiKey_F5,true);
|
||||
ImGui::NewFrame(); io.WantCaptureKeyboard=true; io.WantTextInput=true;
|
||||
app.HandleHotkeys();
|
||||
Check(app.isEngineRunning,"F5 launches even when ImGui captures keyboard input");
|
||||
ImGui::Render();
|
||||
if(app.hEngineProcess) WaitForSingleObject(app.hEngineProcess,5000);
|
||||
app.CheckPlaytestProcess();
|
||||
io.AddKeyEvent(ImGuiKey_F5,false); io.AddKeyEvent(ImGuiKey_F6,true);
|
||||
ImGui::NewFrame(); io.WantCaptureKeyboard=true; io.WantTextInput=true;
|
||||
app.HandleHotkeys();
|
||||
Check(!app.showCutsceneTimeline,"F6 remains available while a text widget owns focus");
|
||||
ImGui::Render();
|
||||
ImGui::DestroyContext();
|
||||
app.showCutsceneTimeline=false;
|
||||
}
|
||||
211
tests/cutscene_engine_checks.inc
Normal file
211
tests/cutscene_engine_checks.inc
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
.data
|
||||
sceneFile db 'scene.dat',0
|
||||
sceneInvalidOp db 'scene_badop.dat',0
|
||||
sceneInvalidRef db 'scene_badref.dat',0
|
||||
sceneInvalidText db 'scene_badtext.dat',0
|
||||
sceneInvalidOrder db 'scene_badorder.dat',0
|
||||
sceneInvalidFlags db 'scene_badflags.dat',0
|
||||
sceneMissingEnd db 'scene_noend.dat',0
|
||||
sceneTraceFile db 'cutscene_trace.bin',0
|
||||
sceneFrameFile db 'cutscene_frame.bgra',0
|
||||
sceneTrace dd 54 dup(0)
|
||||
lastSoundFlags dd 0
|
||||
soundCapture db 128 dup(0)
|
||||
.code
|
||||
AuditPlaySoundA PROC uses ebx ecx esi edi, path:DWORD, module:DWORD, flags:DWORD
|
||||
mov eax, flags
|
||||
mov lastSoundFlags, eax
|
||||
mov esi, path
|
||||
mov soundCapture, 0
|
||||
test esi, esi
|
||||
jz sound_mock_done
|
||||
mov edi, OFFSET soundCapture
|
||||
mov ecx, 127
|
||||
sound_mock_copy:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jz sound_mock_done
|
||||
loop sound_mock_copy
|
||||
mov byte ptr [edi], 0
|
||||
sound_mock_done:
|
||||
mov eax, 1
|
||||
ret
|
||||
AuditPlaySoundA ENDP
|
||||
|
||||
RunCutsceneChecks PROC uses ebx ecx edx esi edi
|
||||
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
|
||||
CHECK eax, 1 ; load v4 track with Russian subtitles
|
||||
CHECK cutsceneCount, 9 ; all serialized cutscene events loaded
|
||||
CHECK cutsceneStringCount, 2 ; all serialized strings loaded
|
||||
CHECK cutscenePending, 1 ; autoplay waits until normal player update
|
||||
movzx eax, BYTE PTR [cutsceneStrings]
|
||||
CHECK eax, 0CFh ; Russian text arrives as CP1251 bytes
|
||||
call StartCutscene
|
||||
mov esi, 0
|
||||
scene_trace_tick:
|
||||
call UpdateCutscene
|
||||
mov edi, esi
|
||||
imul edi, 24
|
||||
add edi, OFFSET sceneTrace
|
||||
mov eax, playerX
|
||||
stosd
|
||||
mov eax, playerY
|
||||
stosd
|
||||
mov eax, playerAngle
|
||||
stosd
|
||||
mov eax, cutsceneFade
|
||||
stosd
|
||||
mov eax, cutsceneSubtitle
|
||||
stosd
|
||||
mov eax, isCutsceneActive
|
||||
stosd
|
||||
cmp esi, 0
|
||||
jne scene_trace_not_zero
|
||||
CHECK lastSoundFlags, 00020003h ; WAV event uses asynchronous filename playback
|
||||
CHECK DWORD PTR [soundCapture+14], 65697571h ; sound path is assets/sounds/quiet.wav
|
||||
scene_trace_not_zero:
|
||||
cmp esi, 2
|
||||
jne scene_trace_not_two
|
||||
CHECK playerX, 224 ; camera reaches exact interpolated X midpoint
|
||||
CHECK playerY, 192 ; camera interpolates a negative Y delta
|
||||
CHECK playerAngle, 0 ; pan wraps through zero along shortest arc
|
||||
CHECK cutsceneFade, 8 ; fade reaches midpoint
|
||||
CHECK cutsceneSubtitle, 0 ; wait blocks a later hide command
|
||||
scene_trace_not_two:
|
||||
inc esi
|
||||
cmp esi, 9
|
||||
jl scene_trace_tick
|
||||
invoke Dump, OFFSET sceneTraceFile, OFFSET sceneTrace, 216
|
||||
CHECK playerX, 288 ; movement reaches its exact target before END
|
||||
CHECK playerY, 160 ; Y target retained after END
|
||||
CHECK playerAngle, 4 ; angle target retained after END
|
||||
CHECK isCutsceneActive, 0 ; END returns control
|
||||
CHECK cutsceneFade, 0 ; END clears cinematic darkening
|
||||
CHECK cutsceneSubtitle, -1 ; END clears subtitles
|
||||
CHECK lastSoundFlags, 0 ; END stops audio
|
||||
|
||||
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
|
||||
mov keyForward, 1
|
||||
mov keyCutscene, 0
|
||||
mov keySkipScene, 0
|
||||
call UpdatePlayer
|
||||
CHECK isCutsceneActive, 1 ; player update starts autoplay
|
||||
CHECK playerX, 160 ; movement input does not move the player during the first scene frame
|
||||
call UpdatePlayer
|
||||
CHECK playerX, 192 ; next frame is driven only by cinematic interpolation
|
||||
mov keySkipScene, 1
|
||||
call UpdatePlayer
|
||||
CHECK isCutsceneActive, 0 ; Tab skips and releases camera control
|
||||
mov keySkipScene, 0
|
||||
mov keyCutscene, 1
|
||||
call UpdatePlayer
|
||||
CHECK isCutsceneActive, 1 ; C can restart a finished scene
|
||||
mov keyCutscene, 0
|
||||
mov keyForward, 0
|
||||
mov testFocus, 1
|
||||
mov hEngineWnd, 1
|
||||
mov testKeys[VK_KEY_W*2], 8000h
|
||||
mov testKeys[VK_KEY_TAB*2], 8000h
|
||||
call PollInput
|
||||
movzx eax, keyForward
|
||||
CHECK eax, 0 ; scene masks normal movement input
|
||||
movzx eax, keySkipScene
|
||||
CHECK eax, 1 ; skip input remains available in a scene
|
||||
call StopCutscene
|
||||
call PollInput
|
||||
movzx eax, keyForward
|
||||
CHECK eax, 1 ; normal input returns after scene stop
|
||||
mov testKeys[VK_KEY_W*2], 0
|
||||
mov testKeys[VK_KEY_TAB*2], 0
|
||||
mov keyForward, 0
|
||||
mov keySkipScene, 0
|
||||
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
|
||||
call StartCutscene
|
||||
invoke LoadSublevelBinary, OFFSET sceneInvalidOp, -1, -1, 0
|
||||
CHECK eax, 0 ; unknown cutscene opcode rejected
|
||||
CHECK isCutsceneActive, 1 ; invalid track load keeps currently running scene
|
||||
CHECK cutsceneCount, 9 ; invalid track load keeps original commands
|
||||
invoke LoadSublevelBinary, OFFSET sceneInvalidRef, -1, -1, 0
|
||||
CHECK eax, 0 ; invalid subtitle reference rejected
|
||||
invoke LoadSublevelBinary, OFFSET sceneInvalidText, -1, -1, 0
|
||||
CHECK eax, 0 ; unterminated string rejected
|
||||
invoke LoadSublevelBinary, OFFSET sceneInvalidOrder, -1, -1, 0
|
||||
CHECK eax, 0 ; unsorted commands rejected
|
||||
invoke LoadSublevelBinary, OFFSET sceneInvalidFlags, -1, -1, 0
|
||||
CHECK eax, 0 ; unsupported scene flags rejected
|
||||
invoke LoadSublevelBinary, OFFSET sceneMissingEnd, -1, -1, 0
|
||||
CHECK eax, 0 ; scene without END rejected
|
||||
invoke LoadSublevelBinary, OFFSET mainPath, -1, -1, 0
|
||||
CHECK isCutsceneActive, 0 ; a successful room load stops old playback
|
||||
CHECK cutsceneCount, 0 ; a legacy room clears old commands
|
||||
CHECK cutsceneStringCount, 0 ; a legacy room clears old string references
|
||||
|
||||
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
|
||||
mov DWORD PTR [cutsceneEvents+3*sizeof CutsceneEvent+CutsceneEvent.paramB], 2
|
||||
call StartCutscene
|
||||
call UpdateCutscene
|
||||
call UpdateCutscene
|
||||
call UpdateCutscene
|
||||
CHECK cutsceneSubtitle, -1 ; timed subtitle expires even while a WAIT blocks events
|
||||
mov cutsceneSubtitle, 0
|
||||
mov edi, OFFSET screenBuffer
|
||||
mov ecx, SCREEN_W*SCREEN_H
|
||||
mov eax, COLOR_BLUE
|
||||
rep stosd
|
||||
call RenderCutsceneOverlay
|
||||
CHECK DWORD PTR [screenBuffer], COLOR_BLACK ; cinematic top letterbox
|
||||
CHECK DWORD PTR [screenBuffer+200*SCREEN_PITCH], COLOR_BLACK ; cinematic bottom letterbox
|
||||
CHECK DWORD PTR [screenBuffer+100*SCREEN_PITCH], COLOR_BLUE ; picture area remains visible
|
||||
CHECK DWORD PTR [screenBuffer+159*SCREEN_PITCH], 0000007Fh ; subtitle panel is semitransparent
|
||||
mov edi, OFFSET screenBuffer+160*SCREEN_PITCH
|
||||
mov ecx, 28*SCREEN_W
|
||||
xor ebx, ebx
|
||||
scene_white_pixel:
|
||||
cmp DWORD PTR [edi], COLOR_WHITE
|
||||
jne scene_next_pixel
|
||||
inc ebx
|
||||
scene_next_pixel:
|
||||
add edi, 4
|
||||
loop scene_white_pixel
|
||||
xor eax, eax
|
||||
test ebx, ebx
|
||||
setnz al
|
||||
CHECK eax, 1 ; Russian subtitle glyphs are drawn
|
||||
invoke Dump, OFFSET sceneFrameFile, OFFSET screenBuffer, FRAME_BUFFER_SIZE
|
||||
mov edi, OFFSET screenBuffer
|
||||
mov ecx, SCREEN_W*SCREEN_H
|
||||
mov eax, 00123456h
|
||||
rep stosd
|
||||
mov cutsceneFade, 8
|
||||
call ApplyCutsceneFade
|
||||
CHECK DWORD PTR [screenBuffer], 00091A2Bh ; fade blends each RGB channel correctly
|
||||
mov cutsceneFade, 16
|
||||
call ApplyCutsceneFade
|
||||
CHECK DWORD PTR [screenBuffer], 0 ; full cinematic fade produces black
|
||||
call StopCutscene
|
||||
|
||||
; Scene transitions use the same validated room switch and fade sequencer.
|
||||
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
|
||||
mov cutsceneCount, 2
|
||||
mov edi, OFFSET cutsceneEvents
|
||||
mov ecx, 8
|
||||
xor eax, eax
|
||||
rep stosd
|
||||
mov BYTE PTR [cutsceneEvents+CutsceneEvent.opCode], OP_CHANGE_SUBLEVEL
|
||||
mov WORD PTR [cutsceneEvents+sizeof CutsceneEvent], 1
|
||||
call StartCutscene
|
||||
mov cutsceneFade, 16
|
||||
call UpdateCutscene
|
||||
CHECK isCutsceneActive, 0 ; changing sublevel stops the old scene
|
||||
CHECK fadeState, 1 ; scripted transition starts its configured fade
|
||||
CHECK fadeLevel, 16 ; transition preserves an existing cinematic fade without a bright flash
|
||||
mov esi, 32
|
||||
scene_transition_fade:
|
||||
call ApplyScreenFade
|
||||
dec esi
|
||||
jnz scene_transition_fade
|
||||
CHECK spriteCount, 1 ; scripted transition loads the destination room
|
||||
CHECK cutsceneCount, 0 ; scripted transition cannot reuse old scene buffers
|
||||
ret
|
||||
RunCutsceneChecks ENDP
|
||||
157
tests/editor_regressions.cpp
Normal file
157
tests/editor_regressions.cpp
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Compile the real editor implementation in this translation unit so the
|
||||
// scenarios can exercise tools and playtest preparation without opening a UI.
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <windows.h>
|
||||
#define private public
|
||||
#include "../editor/src/editor_app.h"
|
||||
#undef private
|
||||
#include "../editor/src/editor_app.cpp"
|
||||
#include <iostream>
|
||||
|
||||
static int failures = 0, checks = 0;
|
||||
static void Check(bool ok, const char* name) {
|
||||
++checks;
|
||||
if (!ok) ++failures;
|
||||
std::cout << (ok ? "PASS " : "FAIL ") << name << '\n';
|
||||
}
|
||||
static std::vector<uint8_t> Bytes(const fs::path& path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return {std::istreambuf_iterator<char>(in), {}};
|
||||
}
|
||||
|
||||
#include "cutscene_editor_checks.inl"
|
||||
#include "viewport_editor_checks.inl"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
EditorApp app;
|
||||
std::vector<uint8_t> defaults;
|
||||
ReadTextureSlots("absent-defaults.bin", defaults);
|
||||
{
|
||||
std::ofstream out("../assets/defaults.bin", std::ios::binary);
|
||||
out.write(reinterpret_cast<const char*>(defaults.data()), defaults.size());
|
||||
}
|
||||
Check(app.LoadLevel("../assets/map.dat"), "load v3 main map");
|
||||
Check(app.sprites.size() == 2 && app.transitions.size() == 1, "load all entities");
|
||||
Check(app.LoadLevel("../assets/legacy1.dat") && app.sprites[0].x == 224, "convert v1 coordinates");
|
||||
Check(app.LoadLevel("../assets/legacy2.dat") && app.transitions.size() == 1, "read extended v2 maps");
|
||||
Check(app.LoadLevel("../assets/black.dat") && app.ceilingColor == 0 && app.floorColor == 0, "apply black palette");
|
||||
Check(app.LoadLevel("../assets/map.dat"), "restore main map");
|
||||
auto before = app.CaptureSnapshot();
|
||||
auto pathBefore = app.currentFilePath;
|
||||
for (const char* name : {"truncated.dat", "overflow.dat", "badname.dat", "badspawn.dat", "orphan.dat", "duplicate.dat"}) {
|
||||
Check(!app.LoadLevel(std::string("../assets/") + name), name);
|
||||
Check(app.currentFilePath == pathBefore && app.sprites.size() == before.sprites.size() &&
|
||||
app.transitions.size() == before.transitions.size() && !std::memcmp(app.map, before.map, sizeof(app.map)),
|
||||
"failed load preserves current room");
|
||||
}
|
||||
Check(app.SaveLevel("../assets/saved.dat"), "save current format atomically");
|
||||
BinaryLevel saved; std::string error;
|
||||
Check(ReadLevelFile("../assets/saved.dat", saved, error) && saved.header.version == MAP_FORMAT_VERSION, "saved map reads back");
|
||||
auto goodSave = Bytes("../assets/saved.dat");
|
||||
app.playerStartX = -1;
|
||||
Check(!app.SaveLevel("../assets/saved.dat") && Bytes("../assets/saved.dat") == goodSave,
|
||||
"invalid save does not overwrite a valid file");
|
||||
app.playerStartX = 2;
|
||||
|
||||
std::vector<uint8_t> rgba(128 * 128 * 4);
|
||||
for (int i = 0; i < 128 * 128; ++i) {
|
||||
rgba[4*i] = 0x11; rgba[4*i+1] = 0x22; rgba[4*i+2] = 0x33; rgba[4*i+3] = 255;
|
||||
}
|
||||
stbi_write_png("wall.png", 128, 128, 4, rgba.data(), 128 * 4);
|
||||
Check(app.ImportTextureImage(1, false, "wall.png"), "first PNG import creates directories and pack");
|
||||
auto pack = Bytes("../assets/textures/texturepack.bin");
|
||||
Check(pack.size() == 8 + 16 * 65536, "complete pack layout");
|
||||
Check(pack[8+65536] == 0x33 && pack[9+65536] == 0x22 && pack[10+65536] == 0x11 && pack[11+65536] == 0,
|
||||
"RGBA converts to engine BGRA");
|
||||
auto fallback = MakeDefaultTextureSlot(2);
|
||||
Check(std::equal(fallback.begin(), fallback.end(), pack.begin() + 8 + 2 * 65536), "first import retains fallback walls");
|
||||
auto lamp = MakeDefaultTextureSlot(10);
|
||||
Check(std::equal(lamp.begin(), lamp.end(), pack.begin() + 8 + 10 * 65536), "first import retains sprite defaults");
|
||||
for (int y = 0; y < 128; ++y) for (int x = 0; x < 64; ++x) rgba[4*(y*128+x)+3] = 0;
|
||||
stbi_write_png("sprite.png", 128, 128, 4, rgba.data(), 128 * 4);
|
||||
Check(app.ImportTextureImage(11, true, "sprite.png"), "import transparent sprite");
|
||||
auto updated = Bytes("../assets/textures/texturepack.bin");
|
||||
const auto* sprite = updated.data() + 8 + 11 * 65536;
|
||||
Check(sprite[0] == 255 && sprite[1] == 0 && sprite[2] == 255 && sprite[3] == 0 && sprite[400] == 0x33,
|
||||
"alpha converts to chroma key");
|
||||
Check(std::equal(pack.begin(), pack.begin() + 8 + 11 * 65536, updated.begin()), "sprite import preserves other slots");
|
||||
fs::copy_file("../assets/textures/texturepack.bin", "../assets/imported.bin", fs::copy_options::overwrite_existing);
|
||||
auto corrupt = updated; corrupt.resize(100);
|
||||
{ std::ofstream out("../assets/textures/texturepack.bin", std::ios::binary); out.write(reinterpret_cast<char*>(corrupt.data()), corrupt.size()); }
|
||||
Check(!app.ImportTextureImage(1, false, "wall.png") && Bytes("../assets/textures/texturepack.bin") == corrupt,
|
||||
"invalid existing pack is preserved");
|
||||
fs::copy_file("../assets/imported.bin", "../assets/textures/texturepack.bin", fs::copy_options::overwrite_existing);
|
||||
Check(app.ResetTextureSlotToDefault(2), "reset uses the same safe pack writer");
|
||||
auto reset = Bytes("../assets/textures/texturepack.bin");
|
||||
Check(std::equal(updated.begin() + 8 + 11 * 65536, updated.end(), reset.begin() + 8 + 11 * 65536),
|
||||
"reset preserves imported sprites");
|
||||
Check(app.ImportSoundAudio("quiet.wav"), "WAV import without scripts");
|
||||
app.StopAudioSound();
|
||||
auto manifest = Bytes("../assets/sounds/sounds.inc");
|
||||
Check(Bytes("quiet.wav") == Bytes("../assets/sounds/quiet.wav") &&
|
||||
std::string(manifest.begin(), manifest.end()).find("SND_QUIET") != std::string::npos, "sound file and manifest");
|
||||
|
||||
app.InitDefaultLevel();
|
||||
Check(app.SetCell(2, 2, CELL_PORTAL) && app.transitions.size() == 1, "brush creates portal and transition together");
|
||||
app.FloodFill(2, 2, CELL_PORTAL, CELL_EMPTY);
|
||||
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "fill removes transition with portal");
|
||||
app.map[2][2] = CELL_METAL; app.map[2][3] = CELL_METAL;
|
||||
app.FloodFill(2, 2, CELL_METAL, CELL_PORTAL);
|
||||
Check(app.transitions.size() == 2 && app.map[2][2] == CELL_PORTAL && app.map[2][3] == CELL_PORTAL,
|
||||
"small fill creates every transition");
|
||||
app.SetCell(2, 2, CELL_EMPTY); app.SetCell(3, 2, CELL_EMPTY);
|
||||
auto fillBefore = app.CaptureSnapshot();
|
||||
app.FloodFill(2, 2, CELL_EMPTY, CELL_PORTAL);
|
||||
Check(app.transitions.empty() && !std::memcmp(app.map, fillBefore.map, sizeof(app.map)), "oversized portal fill is atomic");
|
||||
app.PushUndo(); app.SetCell(2, 2, CELL_PORTAL); app.Undo();
|
||||
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "undo restores both grid and transitions");
|
||||
app.Redo(); Check(app.map[2][2] == CELL_PORTAL && app.transitions.size() == 1, "redo restores portal");
|
||||
app.currentTool = TOOL_PLAYER_SPAWN; app.ApplyTool(2, 2);
|
||||
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "moving spawn onto a portal removes its trigger");
|
||||
app.InitDefaultLevel();
|
||||
for (int i = 0; i < 16; ++i) app.SetCell(i + 1, 20, CELL_PORTAL);
|
||||
Check(!app.SetCell(20, 20, CELL_PORTAL) && app.map[20][20] != CELL_PORTAL && app.transitions.size() == 16,
|
||||
"portal limit cannot create orphan cells");
|
||||
|
||||
Check(app.LoadLevel("../assets/sublevel_02.dat"), "open sublevel for F5");
|
||||
auto originalMain = Bytes("../assets/map.dat"), originalSub = Bytes("../assets/sublevel_02.dat");
|
||||
auto originalPath = app.currentFilePath;
|
||||
app.map[12][12] = CELL_MARBLE; // unsaved edit included only in preview
|
||||
app.LaunchPlaytest();
|
||||
Check(app.isEngineRunning && app.currentFilePath == originalPath, "F5 preserves current file path");
|
||||
Check(Bytes("../assets/map.dat") == originalMain && Bytes("../assets/sublevel_02.dat") == originalSub,
|
||||
"F5 never overwrites source maps");
|
||||
fs::path sandbox = app.playtestDirectory;
|
||||
BinaryLevel preview;
|
||||
Check(ReadLevelFile((sandbox / "assets/sublevel_02.dat").string(), preview, error) && preview.cells[12*32+12] == CELL_MARBLE,
|
||||
"F5 includes unsaved edits");
|
||||
Check(Bytes(sandbox / "assets/map.dat") == originalMain, "reverse portal keeps the real main map");
|
||||
Check(Bytes(sandbox / "assets/textures/texturepack.bin") == Bytes("../assets/textures/texturepack.bin"), "F5 copies imported resources");
|
||||
if (app.hEngineProcess) WaitForSingleObject(app.hEngineProcess, 5000);
|
||||
auto launched = Bytes(sandbox / "launched.txt");
|
||||
Check(std::string(launched.begin(), launched.end()) == "assets\\sublevel_02.dat", "engine receives the selected start map");
|
||||
app.CheckPlaytestProcess();
|
||||
Check(!app.isEngineRunning && !fs::exists(sandbox), "finished playtest cleans its own temporary directory");
|
||||
strcpy_s(app.transitions[0].targetMap, "missing.dat");
|
||||
app.LaunchPlaytest();
|
||||
Check(!app.isEngineRunning && app.playtestDirectory.empty() && Bytes("../assets/map.dat") == originalMain,
|
||||
"missing linked map aborts F5 without data loss");
|
||||
RunCutsceneChecks(app);
|
||||
if (argc > 1) {
|
||||
Check(ReadLevelFile(std::string(argv[1]) + "/map.dat", preview, error), "existing project main map remains compatible");
|
||||
Check(ReadLevelFile(std::string(argv[1]) + "/sublevel_02.dat", preview, error), "existing project sublevel remains compatible");
|
||||
app.LoadLevel(std::string(argv[1]) + "/map.dat");
|
||||
std::string start, executable;
|
||||
Check(app.PreparePlaytest(start, executable), "existing project graph prepares for playtest");
|
||||
app.CleanupPlaytest();
|
||||
app.LoadLevel(std::string(argv[1]) + "/sublevel_02.dat");
|
||||
start.clear(); executable.clear();
|
||||
Check(app.PreparePlaytest(start, executable), "existing project sublevel and reverse link prepare for playtest");
|
||||
app.CleanupPlaytest();
|
||||
}
|
||||
if(argc>2) RunViewportChecks(app,argv[2]);
|
||||
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
370
tests/engine_regressions.asm
Normal file
370
tests/engine_regressions.asm
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
include engine_api.inc
|
||||
|
||||
AuditGetAsyncKeyState PROTO :DWORD
|
||||
AuditGetForegroundWindow PROTO
|
||||
AuditPlaySoundA PROTO :DWORD, :DWORD, :DWORD
|
||||
GetAsyncKeyState TEXTEQU <AuditGetAsyncKeyState>
|
||||
GetForegroundWindow TEXTEQU <AuditGetForegroundWindow>
|
||||
PlaySoundA TEXTEQU <AuditPlaySoundA>
|
||||
CopyFileA PROTO :DWORD, :DWORD, :DWORD
|
||||
|
||||
include include/engine_defs.inc
|
||||
include include/font8x8.inc
|
||||
include include/engine_data.inc
|
||||
include include/engine_math.inc
|
||||
include include/engine_map.inc
|
||||
include include/engine_textures.inc
|
||||
include include/engine_input.inc
|
||||
include include/engine_physics.inc
|
||||
include include/engine_raycaster.inc
|
||||
include include/engine_render2d.inc
|
||||
include include/engine_cutscene.inc
|
||||
include startup_parser.inc
|
||||
include project_paths.inc
|
||||
|
||||
.data
|
||||
resultCount dd 0
|
||||
results dd 256 dup(0)
|
||||
resultPath db 'engine_results.bin',0
|
||||
anglePath db 'angles.bin',0
|
||||
framePath db 'frame.bgra',0
|
||||
projectFramePath db 'project_frame.bgra',0
|
||||
subPath db 'sublevel_02.dat',0
|
||||
mainPath db 'map.dat',0
|
||||
arenaPath db 'arena.dat',0
|
||||
blackPath db 'black.dat',0
|
||||
badPath db 'truncated.dat',0
|
||||
missingPath db 'missing.dat',0
|
||||
legacy1Path db 'legacy1.dat',0
|
||||
legacy2Path db 'legacy2.dat',0
|
||||
badNamePath db 'badname.dat',0
|
||||
blockedPath db 'badspawn.dat',0
|
||||
overflowPath db 'overflow.dat',0
|
||||
orphanPath db 'orphan.dat',0
|
||||
duplicatePath db 'duplicate.dat',0
|
||||
validPack db 'assets/imported.bin',0
|
||||
badPack db 'assets/badpack.bin',0
|
||||
defaultPixels db 'assets/defaults.bin',0
|
||||
defaultFile dd 0
|
||||
defaultBytes dd 0
|
||||
testKeys dw 256 dup(0)
|
||||
testFocus dd 1
|
||||
snapshot dd 6 dup(0)
|
||||
|
||||
CHECK MACRO value, expected
|
||||
mov eax, value
|
||||
cmp eax, expected
|
||||
sete al
|
||||
movzx eax, al
|
||||
mov ecx, resultCount
|
||||
mov [results+ecx*4], eax
|
||||
inc resultCount
|
||||
ENDM
|
||||
|
||||
.code
|
||||
AuditGetAsyncKeyState PROC key:DWORD
|
||||
mov ecx, key
|
||||
movzx eax, WORD PTR [testKeys+ecx*2]
|
||||
ret
|
||||
AuditGetAsyncKeyState ENDP
|
||||
AuditGetForegroundWindow PROC
|
||||
mov eax, testFocus
|
||||
ret
|
||||
AuditGetForegroundWindow ENDP
|
||||
|
||||
Dump PROC uses ebx, path:DWORD, dataPtr:DWORD, dataSize:DWORD
|
||||
LOCAL file:DWORD, written:DWORD
|
||||
invoke CreateFileA, path, GENERIC_WRITE, 0, 0, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0
|
||||
mov file, eax
|
||||
invoke WriteFile, file, dataPtr, dataSize, ADDR written, 0
|
||||
invoke CloseHandle, file
|
||||
ret
|
||||
Dump ENDP
|
||||
|
||||
CaptureState PROC uses eax esi edi ecx
|
||||
mov eax, playerX
|
||||
mov snapshot, eax
|
||||
mov eax, playerY
|
||||
mov snapshot[4], eax
|
||||
mov eax, spriteCount
|
||||
mov snapshot[8], eax
|
||||
mov eax, transitionCount
|
||||
mov snapshot[12], eax
|
||||
mov eax, DWORD PTR [gameMap]
|
||||
mov snapshot[16], eax
|
||||
mov eax, mapCeilingColor
|
||||
mov snapshot[20], eax
|
||||
ret
|
||||
CaptureState ENDP
|
||||
|
||||
StateUnchanged PROC uses ebx
|
||||
mov eax, playerX
|
||||
cmp eax, snapshot
|
||||
jne state_changed
|
||||
mov eax, playerY
|
||||
cmp eax, snapshot[4]
|
||||
jne state_changed
|
||||
mov eax, spriteCount
|
||||
cmp eax, snapshot[8]
|
||||
jne state_changed
|
||||
mov eax, transitionCount
|
||||
cmp eax, snapshot[12]
|
||||
jne state_changed
|
||||
mov eax, DWORD PTR [gameMap]
|
||||
cmp eax, snapshot[16]
|
||||
jne state_changed
|
||||
mov eax, mapCeilingColor
|
||||
cmp eax, snapshot[20]
|
||||
jne state_changed
|
||||
mov eax, 1
|
||||
ret
|
||||
state_changed:
|
||||
xor eax, eax
|
||||
ret
|
||||
StateUnchanged ENDP
|
||||
|
||||
include tests/cutscene_engine_checks.inc
|
||||
|
||||
AuditMain PROC
|
||||
call InitMathTables
|
||||
invoke Dump, OFFSET anglePath, OFFSET tableRayAngleOffset, SCREEN_W*4
|
||||
call ParseStartupMap
|
||||
CHECK DWORD PTR [startupMapPath], 65636170h ; quoted command-line start map (pace...)
|
||||
call GenerateDefaultTextures
|
||||
invoke CreateFileA, OFFSET defaultPixels, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
|
||||
mov defaultFile, eax
|
||||
invoke ReadFile, defaultFile, OFFSET texturePackStaging, MAX_TEXTURES*TEX_SIZE_BYTES, OFFSET defaultBytes, 0
|
||||
invoke CloseHandle, defaultFile
|
||||
mov esi, OFFSET texturePackStaging
|
||||
mov edi, OFFSET textureBuffer
|
||||
mov ecx, MAX_TEXTURES*TEX_SIZE_BYTES/4
|
||||
repe cmpsd
|
||||
sete al
|
||||
movzx eax, al
|
||||
CHECK eax, 1 ; editor fallback previews match all engine default pixels
|
||||
CHECK DWORD PTR [textureBuffer+10*TEX_SIZE_BYTES], COLOR_CHROMA_KEY ; default lamp exists independently of portal
|
||||
invoke CopyFileA, OFFSET validPack, OFFSET szTexPackPath, 0
|
||||
mov ecx, 128
|
||||
dirty_stack:
|
||||
push 0A5A5A5A5h
|
||||
loop dirty_stack
|
||||
add esp, 512
|
||||
call LoadTexturePack
|
||||
CHECK eax, 1 ; valid texture pack loads with dirty stack
|
||||
CHECK DWORD PTR [textureBuffer+TEX_SIZE_BYTES], 00112233h ; imported PNG reaches ASM unchanged
|
||||
CHECK DWORD PTR [textureBuffer+11*TEX_SIZE_BYTES], COLOR_CHROMA_KEY ; imported sprite transparency reaches ASM
|
||||
invoke CopyFileA, OFFSET badPack, OFFSET szTexPackPath, 0
|
||||
call LoadTexturePack
|
||||
CHECK eax, 0 ; truncated texture pack rejected
|
||||
CHECK DWORD PTR [textureBuffer+TEX_SIZE_BYTES], 00112233h ; failed pack load preserves current pixels
|
||||
|
||||
mov startupMapPath, 0
|
||||
call LoadMapBinary
|
||||
CHECK eax, 1 ; startup main map loads
|
||||
CHECK spriteCount, 2 ; startup sprite count
|
||||
CHECK transitionCount, 1 ; startup transition count
|
||||
invoke SwitchSublevel, OFFSET subPath, 4, 6, 0, 0
|
||||
CHECK spriteCount, 1 ; instant transition replaces sprites
|
||||
CHECK playerX, 288 ; destination X spawn
|
||||
CHECK playerY, 416 ; destination Y spawn
|
||||
invoke SwitchSublevel, OFFSET mainPath, 2, 2, 0, 1
|
||||
mov esi, 32
|
||||
fade_frames:
|
||||
call ApplyScreenFade
|
||||
dec esi
|
||||
jnz fade_frames
|
||||
CHECK fadeState, 0 ; fade finishes and releases player
|
||||
CHECK fadeLevel, 0 ; fade returns full brightness
|
||||
CHECK spriteCount, 2 ; fade transition loads destination
|
||||
invoke LoadSublevelBinary, OFFSET arenaPath, -1, -1, 0
|
||||
CHECK eax, 1 ; map basename beginning with a resolves in assets
|
||||
invoke LoadSublevelBinary, OFFSET legacy1Path, -1, -1, 0
|
||||
CHECK eax, 1 ; legacy v1 accepted
|
||||
CHECK DWORD PTR [spriteList], 224 ; legacy sprite coordinates converted
|
||||
invoke LoadSublevelBinary, OFFSET legacy2Path, -1, -1, 0
|
||||
CHECK eax, 1 ; legacy extended v2 accepted
|
||||
CHECK transitionCount, 1 ; legacy v2 transitions retained
|
||||
invoke LoadSublevelBinary, OFFSET blackPath, -1, -1, 0
|
||||
CHECK eax, 1 ; black palette map loads
|
||||
CHECK mapCeilingColor, 0 ; black ceiling applied
|
||||
CHECK mapFloorColor, 0 ; black floor applied
|
||||
|
||||
call CaptureState
|
||||
invoke SwitchSublevel, OFFSET missingPath, 2, 2, 0, 0
|
||||
call StateUnchanged
|
||||
CHECK eax, 1 ; missing destination preserves current room
|
||||
CHECK mapErrorFrames, 180 ; failed transition reports an error
|
||||
invoke LoadSublevelBinary, OFFSET badPath, -1, -1, 0
|
||||
CHECK eax, 0 ; truncated map rejected
|
||||
call StateUnchanged
|
||||
CHECK eax, 1 ; truncated load preserves current room
|
||||
invoke LoadSublevelBinary, OFFSET badNamePath, -1, -1, 0
|
||||
CHECK eax, 0 ; unterminated target filename rejected
|
||||
invoke LoadSublevelBinary, OFFSET blockedPath, -1, -1, 0
|
||||
CHECK eax, 0 ; blocked spawn rejected
|
||||
invoke LoadSublevelBinary, OFFSET overflowPath, -1, -1, 0
|
||||
CHECK eax, 0 ; object count overflow rejected without clamping
|
||||
invoke LoadSublevelBinary, OFFSET orphanPath, -1, -1, 0
|
||||
CHECK eax, 0 ; portal without transition rejected
|
||||
invoke LoadSublevelBinary, OFFSET duplicatePath, -1, -1, 0
|
||||
CHECK eax, 0 ; duplicate triggers rejected
|
||||
invoke LoadSublevelBinary, OFFSET mainPath, 0, 0, 0
|
||||
CHECK eax, 0 ; transition spawn inside a wall rejected
|
||||
call StateUnchanged
|
||||
CHECK eax, 1 ; invalid spawn leaves current room intact
|
||||
|
||||
mov esi, OFFSET missingPath
|
||||
mov edi, OFFSET startupMapPath
|
||||
copy_missing_startup:
|
||||
lodsb
|
||||
stosb
|
||||
test al, al
|
||||
jnz copy_missing_startup
|
||||
call LoadMapBinary
|
||||
CHECK DWORD PTR [gameMap], 01010101h ; missing startup map creates the procedural fallback
|
||||
CHECK spriteCount, 0 ; fallback clears stale sprites
|
||||
CHECK transitionCount, 0 ; fallback clears stale transitions
|
||||
mov startupMapPath, 0
|
||||
|
||||
; Re-entering an occupied destination trigger must not bounce back.
|
||||
invoke LoadSublevelBinary, OFFSET subPath, 2, 2, 0
|
||||
mov mapErrorFrames, 0
|
||||
call CheckPortalTriggers
|
||||
CHECK spriteCount, 1 ; destination trigger is latched on spawn
|
||||
mov playerX, 224
|
||||
call CheckPortalTriggers
|
||||
mov playerX, 160
|
||||
call CheckPortalTriggers
|
||||
CHECK fadeState, 1 ; leaving and re-entering a trigger activates it
|
||||
mov fadeState, 0
|
||||
mov fadeLevel, 0
|
||||
|
||||
; Input APIs are mocked; PollInput itself is the production procedure.
|
||||
mov hEngineWnd, 1
|
||||
mov testKeys[VK_KEY_SHIFT*2], 8000h
|
||||
call PollInput
|
||||
movzx eax, keyRun
|
||||
CHECK eax, 1 ; Shift starts sprint
|
||||
mov testKeys[VK_KEY_SHIFT*2], 0
|
||||
call PollInput
|
||||
movzx eax, keyRun
|
||||
CHECK eax, 0 ; releasing Shift stops sprint
|
||||
mov testKeys[VK_KEY_E*2], 8000h
|
||||
call PollInput
|
||||
movzx eax, keyAction
|
||||
CHECK eax, 1 ; action emitted on first key press
|
||||
call PollInput
|
||||
movzx eax, keyAction
|
||||
CHECK eax, 0 ; held action does not repeat
|
||||
mov testKeys[VK_KEY_E*2], 0
|
||||
call PollInput
|
||||
mov testKeys[VK_KEY_E*2], 8000h
|
||||
call PollInput
|
||||
movzx eax, keyAction
|
||||
CHECK eax, 1 ; releasing action re-arms it
|
||||
mov testFocus, 0
|
||||
call PollInput
|
||||
movzx eax, keyAction
|
||||
CHECK eax, 0 ; unfocused engine ignores keyboard
|
||||
|
||||
; Flat wall at x=640, player x=160: every perpendicular depth is 480.
|
||||
mov edi, OFFSET gameMap
|
||||
mov ecx, MAP_SIZE
|
||||
xor eax, eax
|
||||
rep stosb
|
||||
xor ecx, ecx
|
||||
straight_wall:
|
||||
mov eax, ecx
|
||||
shl eax, 5
|
||||
mov byte ptr [gameMap+eax+10], CELL_BRICK
|
||||
inc ecx
|
||||
cmp ecx, MAP_H
|
||||
jl straight_wall
|
||||
mov playerX, 160
|
||||
mov playerY, 1024
|
||||
mov playerAngle, 0
|
||||
mov spriteCount, 0
|
||||
call Render3DScene
|
||||
mov esi, OFFSET depthBuffer
|
||||
mov ecx, SCREEN_W
|
||||
mov ebx, 1
|
||||
check_flat_depth:
|
||||
cmp DWORD PTR [esi], 480
|
||||
je flat_depth_ok
|
||||
xor ebx, ebx
|
||||
flat_depth_ok:
|
||||
add esi, 4
|
||||
loop check_flat_depth
|
||||
CHECK ebx, 1 ; all 320 columns of a flat wall have exactly the same depth
|
||||
invoke Dump, OFFSET framePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
|
||||
|
||||
; Overlapping sprites are sorted far-to-near, independent of insertion order.
|
||||
mov spriteCount, 2
|
||||
mov esi, OFFSET spriteList
|
||||
mov [esi].SpriteStruct.x, 288
|
||||
mov [esi].SpriteStruct.y, 1024
|
||||
mov [esi].SpriteStruct.scale, FIXED_ONE
|
||||
mov [esi].SpriteStruct.textureId, 11
|
||||
add esi, sizeof SpriteStruct
|
||||
mov [esi].SpriteStruct.x, 416
|
||||
mov [esi].SpriteStruct.y, 1024
|
||||
mov [esi].SpriteStruct.scale, FIXED_ONE
|
||||
mov [esi].SpriteStruct.textureId, 12
|
||||
call Render3DScene
|
||||
CHECK spriteOrder, 1 ; farther sprite draws first
|
||||
CHECK spriteOrder[4], 0 ; nearer sprite draws last
|
||||
|
||||
; Sprite slot 10 can be replaced without repainting wall portals.
|
||||
mov edi, OFFSET textureBuffer+10*TEX_SIZE_BYTES
|
||||
mov ecx, TEX_SIZE_BYTES/4
|
||||
mov eax, COLOR_RED
|
||||
rep stosd
|
||||
mov edi, OFFSET portalTextureBuffer
|
||||
mov ecx, TEX_SIZE_BYTES/4
|
||||
mov eax, COLOR_GREEN
|
||||
rep stosd
|
||||
mov byte ptr [gameMap+16*MAP_W+10], CELL_PORTAL
|
||||
mov spriteCount, 0
|
||||
call Render3DScene
|
||||
CHECK DWORD PTR [screenBuffer+120*SCREEN_PITCH+160*4], 00007F00h ; portal uses separate artwork despite imported sprite slot 10
|
||||
mov byte ptr [gameMap+16*MAP_W+10], CELL_BRICK
|
||||
|
||||
; Scaling a sprite changes its height, not the floor point at its depth.
|
||||
mov edi, OFFSET textureBuffer+11*TEX_SIZE_BYTES
|
||||
mov ecx, TEX_SIZE_BYTES/4
|
||||
mov eax, COLOR_RED
|
||||
rep stosd
|
||||
mov spriteCount, 1
|
||||
mov esi, OFFSET spriteList
|
||||
mov [esi].SpriteStruct.scale, 2*FIXED_ONE
|
||||
call Render3DScene
|
||||
CHECK DWORD PTR [screenBuffer+179*SCREEN_PITCH+160*4], COLOR_RED ; scaled sprite still reaches its floor point
|
||||
mov eax, DWORD PTR [screenBuffer+180*SCREEN_PITCH+160*4]
|
||||
cmp eax, COLOR_RED
|
||||
setne al
|
||||
movzx eax, al
|
||||
CHECK eax, 1 ; scaled sprite does not sink into the floor
|
||||
|
||||
; Cardinal rays must not divide by zero or wrap DDA distances.
|
||||
mov spriteCount, 0
|
||||
mov playerAngle, ANGLE_90
|
||||
call Render3DScene
|
||||
CHECK DWORD PTR [depthBuffer+160*4], 1024 ; exact cardinal ray handles zero X direction
|
||||
|
||||
call GenerateDefaultTextures
|
||||
invoke CopyFileA, OFFSET projectPackPath, OFFSET szTexPackPath, 0
|
||||
call LoadTexturePack
|
||||
CHECK eax, 1 ; actual project texture pack loads
|
||||
invoke LoadSublevelBinary, OFFSET projectMapPath, -1, -1, 0
|
||||
CHECK eax, 1 ; actual project map loads in ASM
|
||||
call Render3DScene
|
||||
invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
|
||||
|
||||
call RunCutsceneChecks
|
||||
mov eax, resultCount
|
||||
inc eax
|
||||
shl eax, 2
|
||||
invoke Dump, OFFSET resultPath, OFFSET resultCount, eax
|
||||
invoke ExitProcess, 0
|
||||
AuditMain ENDP
|
||||
END AuditMain
|
||||
6
tests/playtest_stub.cpp
Normal file
6
tests/playtest_stub.cpp
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include <fstream>
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 2) return 1;
|
||||
std::ofstream("launched.txt") << argv[1];
|
||||
return 0;
|
||||
}
|
||||
65
tests/viewport_editor_checks.inl
Normal file
65
tests/viewport_editor_checks.inl
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Integration test: real x86 ASM producer -> x64 editor -> DX11 WARP texture.
|
||||
static void RunViewportChecks(EditorApp& app, const char* actualEngine) {
|
||||
std::error_code ec;
|
||||
const auto testEngine = app.FindEnginePath();
|
||||
fs::copy_file(actualEngine,testEngine,fs::copy_options::overwrite_existing,ec);
|
||||
Check(!ec,"stage real ASM renderer for editor integration");
|
||||
ID3D11Device* device=nullptr;
|
||||
HRESULT hr=D3D11CreateDevice(nullptr,D3D_DRIVER_TYPE_WARP,nullptr,0,nullptr,0,D3D11_SDK_VERSION,&device,nullptr,nullptr);
|
||||
Check(SUCCEEDED(hr),"DX11 software device supports the embedded viewport");
|
||||
if(!device || ec) return;
|
||||
app.LoadLevel("../assets/scene.dat"); app.showCutsceneTimeline=true; app.previewAttempted=false;
|
||||
app.InitD3D(device);
|
||||
auto pump = [&]() {
|
||||
int frames=0; ULONGLONG until=GetTickCount64()+5000;
|
||||
while(frames<3 && GetTickCount64()<until) {
|
||||
if(app.previewPacket && InterlockedCompareExchange(&app.previewPacket->state,0,0)==2) ++frames;
|
||||
app.UpdateEnginePreview();
|
||||
if(!app.previewError.empty()) break;
|
||||
Sleep(2);
|
||||
}
|
||||
return frames>=3 && app.previewError.empty();
|
||||
};
|
||||
Check(pump() && app.previewSRV && app.previewHasFrame,"real ASM frames arrive in the editor DX11 viewport");
|
||||
auto before=app.previewPixels;
|
||||
app.map[3][4]=CELL_METAL;
|
||||
Check(pump() && app.previewPixels!=before,"unsaved geometry updates the live game view");
|
||||
app.map[3][4]=CELL_EMPTY;
|
||||
app.ScrubCutscene(2);
|
||||
Check(pump() && app.previewPacket->x==224 && app.previewPacket->angle==0 && app.previewPacket->fade==8,
|
||||
"timeline scrub drives the actual ASM viewport camera and fade");
|
||||
if(app.previewTexture) {
|
||||
D3D11_TEXTURE2D_DESC desc{};app.previewTexture->GetDesc(&desc);
|
||||
desc.Usage=D3D11_USAGE_STAGING;desc.BindFlags=0;desc.CPUAccessFlags=D3D11_CPU_ACCESS_READ;
|
||||
ID3D11Texture2D* staging=nullptr;
|
||||
device->CreateTexture2D(&desc,nullptr,&staging);
|
||||
ID3D11DeviceContext* context=nullptr;device->GetImmediateContext(&context);
|
||||
bool equal=false;
|
||||
if(staging) {
|
||||
context->CopyResource(staging,app.previewTexture);
|
||||
D3D11_MAPPED_SUBRESOURCE read{};
|
||||
if(SUCCEEDED(context->Map(staging,0,D3D11_MAP_READ,0,&read))) {
|
||||
equal=true;
|
||||
for(int y=0;y<240;++y) if(memcmp(static_cast<const char*>(read.pData)+y*read.RowPitch,
|
||||
app.previewPixels.data()+y*320,1280)) equal=false;
|
||||
context->Unmap(staging,0);
|
||||
}
|
||||
staging->Release();
|
||||
}
|
||||
Check(equal,"DX11 texture pixels match the ASM framebuffer with opaque alpha");
|
||||
context->Release();
|
||||
}
|
||||
app.StopEnginePreview();
|
||||
Check(!app.previewProcess.hProcess && !app.previewPacket && !app.previewMapping,"closing viewport releases renderer process and mapping");
|
||||
app.ShutdownD3D();device->Release();app.showCutsceneTimeline=false;
|
||||
const auto demo=fs::path(actualEngine).parent_path()/"examples/cutscene_demo/assets/map.dat";
|
||||
Check(app.LoadLevel(demo.string()),"isolated cutscene demo loads in the editor");
|
||||
std::string start,executable;
|
||||
Check(app.PreparePlaytest(start,executable),"demo F5 validates both rooms and packages its WAV");
|
||||
app.CleanupPlaytest();
|
||||
CutscenePreview demoPreview;
|
||||
demoPreview.Start(app.playerStartX*64+32,app.playerStartY*64+32,app.playerStartAngle);
|
||||
while(demoPreview.active && demoPreview.tick<1000) demoPreview.Step(app.cutscene);
|
||||
Check(demoPreview.transition==0 && demoPreview.x==800 && demoPreview.y==544 && demoPreview.angle==0,
|
||||
"demo camera reaches final waypoint and dispatches its room transition");
|
||||
}
|
||||
46
tools/make_cutscene_demo.py
Normal file
46
tools/make_cutscene_demo.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build the separate cutscene example; never modifies the user's assets/ maps."""
|
||||
from pathlib import Path
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / "examples/cutscene_demo"
|
||||
|
||||
|
||||
def write_map(name, *, spawn, angle, events=(), strings=(), transitions=(), autoplay=False):
|
||||
grid = bytearray(1024)
|
||||
for x in range(32): grid[x] = grid[31*32+x] = 1
|
||||
for y in range(32): grid[y*32] = grid[y*32+31] = 1
|
||||
for y in range(1, 18): grid[y*32+16] = 3
|
||||
for x, y in ((6,6),(10,5),(12,12),(4,10)): grid[y*32+x] = 7
|
||||
for t in transitions: grid[t[1]*32+t[0]] = 10
|
||||
sprites = [(7*64+32,5*64+32,11,65536,0,0),(11*64+32,7*64+32,10,65536,0,0)]
|
||||
header = struct.pack("<I7HII4H4s",0x4E4C4245,4,32,32,*spawn,angle,len(sprites),0x303848,0x282018,
|
||||
len(transitions),len(events),len(strings),int(autoplay),bytes(4))
|
||||
payload = header+grid+b"".join(struct.pack("<6i",*s) for s in sprites)
|
||||
payload += b"".join(struct.pack("<BB32sHHHBB",*t) for t in transitions)
|
||||
payload += b"".join(struct.pack("<HBBiii",*e) for e in events)
|
||||
payload += b"".join(s.encode("cp1251").ljust(96,b"\0") for s in strings)
|
||||
(ROOT / "assets" / name).write_bytes(payload)
|
||||
|
||||
|
||||
def main():
|
||||
(ROOT / "assets/sounds").mkdir(parents=True,exist_ok=True)
|
||||
# END, WAIT, MOVE, PAN, TEXT, HIDE, SOUND, FADE, LEVEL
|
||||
events = [(0,7,0,16,0,0),(0,7,0,0,60,0),(0,6,0,1,0,0),(0,2,0,544,224,180),
|
||||
(20,4,0,0,140,0),(180,3,0,128,90,0),(180,2,0,544,544,180),
|
||||
(240,4,0,2,170,0),(360,3,0,0,90,0),(360,2,0,800,544,180),
|
||||
(450,5,0,0,0,0),(540,7,0,16,60,0),(600,8,0,0,0,0),(601,0,0,0,0,0)]
|
||||
write_map("map.dat",spawn=(3,3),angle=0,events=events,
|
||||
strings=("Кат-сцена: камера, текст и звук", "chime.wav", "Tab — пропустить. После пролёта — другая комната."),
|
||||
transitions=((15,8,b"second_room.dat",3,3,0,1,0),),autoplay=True)
|
||||
write_map("second_room.dat",spawn=(3,3),angle=0,
|
||||
transitions=((2,3,b"map.dat",3,3,0,1,0),))
|
||||
with wave.open(str(ROOT / "assets/sounds/chime.wav"),"wb") as sound:
|
||||
sound.setnchannels(1);sound.setsampwidth(2);sound.setframerate(16000)
|
||||
sound.writeframes(b"".join(struct.pack("<h",int(4000*math.sin(2*math.pi*660*i/16000)*(1-i/8000))) for i in range(8000)))
|
||||
print(ROOT)
|
||||
|
||||
|
||||
if __name__ == "__main__": main()
|
||||
|
|
@ -44,7 +44,7 @@ def gen_brick():
|
|||
r = g = b = 0x55
|
||||
else:
|
||||
n = (((x ^ y) & 7) << 2)
|
||||
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
|
||||
b = min(255, b + n) # match ASM packed-color noise (low byte)
|
||||
out += px(r, g, b)
|
||||
return bytes(out)
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ def gen_wood():
|
|||
r, g, b = 0x20, 0x10, 0x05
|
||||
else:
|
||||
n = ((y & 3) << 3)
|
||||
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
|
||||
b = min(255, b + n) # match ASM packed-color noise (low byte)
|
||||
out += px(r, g, b)
|
||||
return bytes(out)
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ def gen_stone():
|
|||
r = g = b = 0x22
|
||||
else:
|
||||
n = ((((x * 3) + y) & 15) << 2)
|
||||
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
|
||||
b = min(255, b + n) # match ASM packed-color noise (low byte)
|
||||
out += px(r, g, b)
|
||||
return bytes(out)
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ def gen_marble():
|
|||
r = g = b = 0x55
|
||||
else:
|
||||
n = (x ^ y) & 3
|
||||
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
|
||||
b = min(255, b + n) # match ASM packed-color noise (low byte)
|
||||
out += px(r, g, b)
|
||||
return bytes(out)
|
||||
|
||||
|
|
@ -213,9 +213,9 @@ GENERATORS = {
|
|||
10: gen_lamp,
|
||||
11: gen_barrel,
|
||||
12: gen_statue,
|
||||
13: lambda: bytes(TEX_BYTES), # reserved
|
||||
14: lambda: bytes(TEX_BYTES), # reserved
|
||||
15: lambda: bytes(TEX_BYTES), # reserved
|
||||
13: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
|
||||
14: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
|
||||
15: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
|
||||
}
|
||||
|
||||
def main():
|
||||
|
|
|
|||
87
tools/test_preview_ipc.py
Normal file
87
tools/test_preview_ipc.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise the actual windowless ASM renderer through its public mailbox."""
|
||||
import ctypes as C
|
||||
import mmap
|
||||
import os
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SIZE, TEXTURES, PIXELS = 1357792, 2016, 1050592
|
||||
|
||||
|
||||
def check_preview(engine, expected=None, output=None):
|
||||
name = f"Local\\EblanPreview-test-{os.getpid()}-{time.monotonic_ns()}"
|
||||
with mmap.mmap(-1, SIZE, tagname=name) as packet:
|
||||
base = C.addressof(C.c_char.from_buffer(packet))
|
||||
state = C.cast(base+8, C.POINTER(C.c_long))
|
||||
struct.pack_into("<4I3i6I", packet, 0, 0x56504245, 1, 0, os.getpid(), 160, 1024, 0,
|
||||
0x383838, 0x222222, 0, 0, 0, 1)
|
||||
for y in range(32): packet[128+y*32+10] = 1
|
||||
# Flat red wall, other slots black; proves texture upload rather than fallback.
|
||||
packet[TEXTURES+65536:TEXTURES+2*65536] = struct.pack("<I",0xE02010)*16384
|
||||
proc = subprocess.Popen([str(engine), "--preview:"+name], cwd=ROOT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
|
||||
def request():
|
||||
# Aligned DWORD stores are atomic and release-ordered on x86/x64.
|
||||
# Payload writes above finish before publishing the request.
|
||||
state[0]=1
|
||||
deadline = time.monotonic()+5
|
||||
while state[0] != 2:
|
||||
if proc.poll() is not None: raise AssertionError(f"Preview process exited: {proc.returncode}")
|
||||
if time.monotonic()>deadline: raise AssertionError("Preview renderer did not answer")
|
||||
time.sleep(.005)
|
||||
return packet[PIXELS:PIXELS+307200]
|
||||
|
||||
try:
|
||||
frame=request()
|
||||
middle=struct.unpack_from("<I",frame,(120*320+160)*4)[0]
|
||||
assert middle not in (0,0x383838,0x222222), "Wall was not rendered"
|
||||
packet[128+16*32+5]=4
|
||||
changed=request()
|
||||
assert changed!=frame, "Unsaved wall change did not reach the renderer"
|
||||
packet[128+16*32+5]=0
|
||||
struct.pack_into("<2I",packet,40,1,16)
|
||||
assert not any(request()), "Full cinematic fade is not black"
|
||||
struct.pack_into("<2I",packet,40,1,0)
|
||||
packet[1920:1920+7]="Привет".encode("cp1251")+b"\0"
|
||||
subtitle=request()
|
||||
assert not any(subtitle[:8*320*4]) and not any(subtitle[188*320*4:]), "Letterbox missing"
|
||||
assert subtitle!=frame, "Subtitle overlay missing"
|
||||
# Compare the live IPC output with a direct Render3DScene regression dump.
|
||||
if expected:
|
||||
data=(ROOT/"assets/map.dat").read_bytes()
|
||||
h=struct.unpack_from("<I7HII",data)
|
||||
version=h[1]; count=h[7]
|
||||
struct.pack_into("<3i",packet,16,h[4]*64+32,h[5]*64+32,h[6])
|
||||
struct.pack_into("<5I",packet,28,h[8],h[9],count,0,0)
|
||||
packet[128:1152]=data[38:1062]
|
||||
sprites=bytearray(data[1062:1062+count*24])
|
||||
if version==1:
|
||||
for i in range(count):
|
||||
x,y=struct.unpack_from("<ii",sprites,i*24)
|
||||
struct.pack_into("<ii",sprites,i*24,x>>16,y>>16)
|
||||
packet[1152:1152+len(sprites)]=sprites
|
||||
pack=(ROOT/"assets/textures/texturepack.bin").read_bytes()
|
||||
packet[TEXTURES:TEXTURES+len(pack)-8]=pack[8:]
|
||||
struct.pack_into("<I",packet,48,2)
|
||||
actual=request()
|
||||
assert actual==Path(expected).read_bytes(), "Viewport differs from the standalone ASM renderer"
|
||||
if output:
|
||||
header=struct.pack("<2sIHHI",b"BM",307254,0,0,54)
|
||||
header+=struct.pack("<IiiHHIIiiII",40,320,-240,1,32,0,307200,0,0,0,0)
|
||||
Path(output).write_bytes(header+actual)
|
||||
state[0]=3
|
||||
assert proc.wait(timeout=5)==0, "Preview did not exit cleanly"
|
||||
print("PASS live ASM viewport: map edits, texture upload, letterbox, subtitles, fade and clean shutdown")
|
||||
if expected: print("PASS live viewport pixels exactly match the standalone ASM renderer")
|
||||
finally:
|
||||
if proc.poll() is None: proc.terminate(); proc.wait(timeout=5)
|
||||
del state
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_preview(ROOT/"engine.exe")
|
||||
194
tools/test_regressions.py
Normal file
194
tools/test_regressions.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compile and run real C++/ASM regression scenarios in an isolated TEMP tree.
|
||||
|
||||
Requires the same MASM32 and MSVC tools as the normal builds. No test opens the
|
||||
editor/game window or writes the project's levels, textures or executables.
|
||||
Use --keep to retain the generated framebuffer and fixtures for inspection.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import wave
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(args, cwd):
|
||||
result = subprocess.run(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=120, errors="replace")
|
||||
if result.returncode:
|
||||
print(result.stdout)
|
||||
raise RuntimeError(f"Failed ({result.returncode}): {args[0]}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def fixtures(work):
|
||||
assets = work / "assets"
|
||||
assets.mkdir()
|
||||
(work / "editor").mkdir()
|
||||
grid = bytearray(1024)
|
||||
for x in range(32):
|
||||
grid[x] = grid[31 * 32 + x] = 1
|
||||
for y in range(32):
|
||||
grid[y * 32] = grid[y * 32 + 31] = 1
|
||||
|
||||
def level(name, *, version=3, count=2, transitions=None, black=False, spawn=(2, 2), cells=None):
|
||||
cells = bytearray(grid if cells is None else cells)
|
||||
transitions = [(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] if transitions is None else transitions
|
||||
for t in transitions:
|
||||
cells[t[1]*32+t[0]] = 10
|
||||
h = struct.pack("<I7HIIH10s", 0x4E4C4245, version, 32, 32, *spawn, 0, count,
|
||||
0 if black else 0x383838, 0 if black else 0x222222,
|
||||
len(transitions), bytes(10))
|
||||
sprites = b"".join(struct.pack("<6i", 224 << (16 if version == 1 else 0),
|
||||
(224 + i * 64) << (16 if version == 1 else 0), 11, 65536, 0, 0)
|
||||
for i in range(count))
|
||||
data = h + cells + sprites + b"".join(struct.pack("<BB32sHHHBB", *t) for t in transitions)
|
||||
(assets / name).write_bytes(data)
|
||||
return data
|
||||
|
||||
good = level("map.dat")
|
||||
level("sublevel_02.dat", count=1, spawn=(3, 2), transitions=[(2, 2, b"map.dat", 5, 2, 0, 1, 0)])
|
||||
level("arena.dat", count=1, transitions=[])
|
||||
level("legacy1.dat", version=1, count=1, transitions=[])
|
||||
level("legacy2.dat", version=2)
|
||||
level("black.dat", black=True)
|
||||
# Deliberately malformed v4 payloads; ordinary fixtures stay v1..v3.
|
||||
def bad_scene(name, events, strings=(b"text",), flags=0):
|
||||
header = bytearray(good[:38]); struct.pack_into("<H",header,4,4)
|
||||
struct.pack_into("<HHH",header,28,len(events),len(strings),flags)
|
||||
payload = bytes(header)+good[38:]+b"".join(struct.pack("<HBBiii",*e) for e in events)
|
||||
payload += b"".join(text.ljust(96,b"\0") for text in strings)
|
||||
(assets/name).write_bytes(payload)
|
||||
bad_scene("scene_badop.dat",[(0,99,0,0,0,0),(1,0,0,0,0,0)])
|
||||
bad_scene("scene_badref.dat",[(0,4,0,12,0,0),(1,0,0,0,0,0)])
|
||||
bad_scene("scene_badtext.dat",[(0,4,0,0,0,0),(1,0,0,0,0,0)],(b"x"*96,))
|
||||
bad_scene("scene_badorder.dat",[(3,4,0,0,0,0),(1,0,0,0,0,0)])
|
||||
bad_scene("scene_badflags.dat",[(1,0,0,0,0,0)],flags=2)
|
||||
bad_scene("scene_noend.dat",[(0,4,0,0,0,0)])
|
||||
(assets / "truncated.dat").write_bytes(good[:48])
|
||||
level("overflow.dat", count=33)
|
||||
level("badname.dat", transitions=[(4, 2, b"x" * 32, 3, 2, 0, 1, 0)])
|
||||
level("badspawn.dat", spawn=(0, 0))
|
||||
orphan = bytearray(grid); orphan[2 * 32 + 4] = 10
|
||||
level("orphan.dat", transitions=[], cells=orphan)
|
||||
level("duplicate.dat", transitions=[(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] * 2)
|
||||
(assets / "badpack.bin").write_bytes(struct.pack("<IHH", 0x58544245, 1, 16) + bytes(12))
|
||||
with wave.open(str(work / "editor/quiet.wav"), "wb") as sound:
|
||||
sound.setnchannels(1); sound.setsampwidth(2); sound.setframerate(8000)
|
||||
sound.writeframes(bytes(800))
|
||||
|
||||
|
||||
def main():
|
||||
sys.dont_write_bytecode = True
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keep", action="store_true")
|
||||
args = parser.parse_args()
|
||||
work = Path(tempfile.mkdtemp(prefix="eblan-regressions-"))
|
||||
watched = list((ROOT / "assets").rglob("*"))
|
||||
original = {p: hashlib.sha256(p.read_bytes()).digest() for p in watched if p.is_file()}
|
||||
succeeded = False
|
||||
try:
|
||||
fixtures(work)
|
||||
vcvars_candidates = [Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)")) /
|
||||
"Microsoft Visual Studio/18/BuildTools/VC/Auxiliary/Build/vcvars64.bat"]
|
||||
vcvars_candidates += list(Path("C:/Program Files/Microsoft Visual Studio").glob("*/BuildTools/VC/Auxiliary/Build/vcvars64.bat"))
|
||||
vcvars = next((p for p in vcvars_candidates if p.is_file()), None)
|
||||
if not vcvars:
|
||||
raise RuntimeError("MSVC vcvars64.bat was not found")
|
||||
sources = [ROOT / "tests/editor_regressions.cpp"] + [ROOT / "editor/imgui" / name for name in
|
||||
("imgui.cpp", "imgui_draw.cpp", "imgui_tables.cpp", "imgui_widgets.cpp")]
|
||||
flags = "/nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNOMINMAX /D_CRT_SECURE_NO_WARNINGS /DNDEBUG"
|
||||
batch = f'@echo off\ncall "{vcvars}" >nul 2>&1\n'
|
||||
batch += f'cl {flags} "{ROOT / "tests/playtest_stub.cpp"}" /Fe:engine.exe\nif errorlevel 1 exit /b 1\n'
|
||||
batch += f'cl {flags} ' + " ".join(f'"{p}"' for p in sources)
|
||||
batch += ' /Fe:editor_tests.exe /link /SUBSYSTEM:CONSOLE d3d11.lib dxgi.lib user32.lib gdi32.lib shell32.lib comdlg32.lib winmm.lib\n'
|
||||
(work / "build_tests.bat").write_text(batch)
|
||||
run(["cmd", "/c", str(work / "build_tests.bat")], work)
|
||||
print(run([str(work / "editor_tests.exe"), str(ROOT / "assets"), str(ROOT / "engine.exe")], work / "editor"), end="")
|
||||
spec = importlib.util.spec_from_file_location("texturepack", ROOT / "tools/make_texturepack.py")
|
||||
texturepack = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(texturepack)
|
||||
generated = b"".join(texturepack.GENERATORS[i]() for i in range(16))
|
||||
if generated != (work / "assets/defaults.bin").read_bytes():
|
||||
raise RuntimeError("Python texture generator differs from editor/runtime defaults")
|
||||
print("PASS Python, editor and engine use identical procedural texture defaults")
|
||||
|
||||
main_source = (ROOT / "main.asm").read_text(encoding="utf-8")
|
||||
api = main_source[:main_source.index("; ------------------------------------------------------------------------------\n; Engine Includes")]
|
||||
api = re.sub(r"^(GetAsyncKeyState|GetForegroundWindow|PlaySoundA)\s+PROTO.*\n", "", api, flags=re.M)
|
||||
(work / "engine_api.inc").write_text(api)
|
||||
start = main_source.index("ParseStartupMap PROC")
|
||||
end = main_source.index("ParseStartupMap ENDP", start) + len("ParseStartupMap ENDP")
|
||||
(work / "startup_parser.inc").write_text(main_source[start:end])
|
||||
(work / "project_paths.inc").write_text(
|
||||
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
|
||||
f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\n')
|
||||
masm_candidates = [Path(p) for p in ("D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")]
|
||||
masm_candidates += list(Path("C:/Program Files (x86)/Microsoft Visual Studio").glob("*/BuildTools/VC/Tools/MSVC/*/bin/Hostx64/x86/ml.exe"))
|
||||
ml = next((p for p in masm_candidates if p.is_file()), None)
|
||||
if not ml:
|
||||
raise RuntimeError("x86 MASM assembler was not found")
|
||||
sdk = sorted(Path("C:/Program Files (x86)/Windows Kits/10/Lib").glob("*/um/x86"))
|
||||
if not sdk:
|
||||
raise RuntimeError("Windows x86 SDK libraries were not found")
|
||||
run([str(ml), "/c", "/coff", "/Cp", "/nologo", f"/I{work}", f"/I{ROOT}",
|
||||
f"/Fo{work / 'engine_tests.obj'}", str(ROOT / "tests/engine_regressions.asm")], work)
|
||||
link = ml.with_name("link.exe")
|
||||
run([str(link), "/NOLOGO", "/SUBSYSTEM:CONSOLE", f"/LIBPATH:{sdk[-1]}",
|
||||
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
|
||||
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
|
||||
run([str(work / "engine_tests.exe"), "pace map.dat"], work)
|
||||
source = (ROOT / "tests/engine_regressions.asm").read_text() + (ROOT / "tests/cutscene_engine_checks.inc").read_text()
|
||||
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
|
||||
raw = (work / "engine_results.bin").read_bytes()
|
||||
values = struct.unpack(f"<{len(raw)//4}I", raw)
|
||||
if values[0] != len(names):
|
||||
raise RuntimeError(f"ASM result count mismatch: {values[0]} vs {len(names)}")
|
||||
for name, passed in zip(names, values[1:]):
|
||||
print(("PASS " if passed else "FAIL ") + name)
|
||||
angles = struct.unpack("<320i", (work / "angles.bin").read_bytes())
|
||||
expected = tuple(round(math.atan((x-160)/160*math.tan(48*math.pi/256))*256/math.pi) for x in range(320))
|
||||
if angles != expected or not all(values[1:]):
|
||||
raise RuntimeError("ASM regressions failed")
|
||||
print(f"ASM: {len(names)}/{len(names)} passed; 320 ray angles match the specification")
|
||||
frame = (work / "frame.bgra").read_bytes()
|
||||
bitmap_header = struct.pack("<2sIHHI", b"BM", len(frame)+54, 0, 0, 54)
|
||||
bitmap_header += struct.pack("<IiiHHIIiiII", 40, 320, -240, 1, 32, 0, len(frame), 0, 0, 0, 0)
|
||||
(work / "flat_wall.bmp").write_bytes(bitmap_header + frame)
|
||||
project_frame = (work / "project_frame.bgra").read_bytes()
|
||||
(work / "project_preview.bmp").write_bytes(bitmap_header + project_frame)
|
||||
for path, digest in original.items():
|
||||
if hashlib.sha256(path.read_bytes()).digest() != digest:
|
||||
raise RuntimeError(f"Test changed a project asset: {path}")
|
||||
reference = (work / "assets/cutscene_reference.bin").read_bytes()
|
||||
if (work / "cutscene_trace.bin").read_bytes() != reference:
|
||||
raise RuntimeError("ASM and editor cinematic players disagree on frame-by-frame state")
|
||||
print("PASS editor and ASM cutscene state agrees on every tested frame")
|
||||
viewport_spec = importlib.util.spec_from_file_location("preview_ipc", ROOT / "tools/test_preview_ipc.py")
|
||||
viewport_tests = importlib.util.module_from_spec(viewport_spec)
|
||||
viewport_spec.loader.exec_module(viewport_tests)
|
||||
viewport_tests.check_preview(ROOT / "engine.exe",work / "project_frame.bgra",work / "live_viewport.bmp")
|
||||
(work / "cutscene_preview.bmp").write_bytes(bitmap_header + (work / "cutscene_frame.bgra").read_bytes())
|
||||
print("PASS original project assets remain byte-for-byte unchanged")
|
||||
succeeded = True
|
||||
finally:
|
||||
if args.keep or not succeeded:
|
||||
print(f"Test artifacts: {work}")
|
||||
else:
|
||||
# Only delete the verified dedicated directory created above.
|
||||
if work.resolve().parent == Path(tempfile.gettempdir()).resolve() and work.name.startswith("eblan-regressions-"):
|
||||
shutil.rmtree(work)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue