diff --git a/AGENTS.md b/AGENTS.md index 6fdc049..e2a4a58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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): `4` (readers also accept v1 fixed sprite coordinates and v2/v3 raw-coordinate maps, including early v2 maps with transitions) + - `version` (uint16): `5` (readers also accept v1 fixed sprite coordinates and v2/v3/v4 raw-coordinate maps, including early v2 maps with transitions) - `mapWidth` (uint16): `32` - `mapHeight` (uint16): `32` - `playerStartX` (uint16): cell X coordinate (e.g. 2) @@ -147,16 +147,17 @@ The engine supports both procedural default generation and binary level loading. - `ceilingColor` (uint32): 00RRGGBBh - `floorColor` (uint32): 00RRGGBBh - `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 + - `cutsceneCount` (uint16): 0..128 commands (offset 28, v4+) + - `cutsceneStringCount` (uint16): 0..32 strings (offset 30, v4+) + - `cutsceneFlags` (uint16): bit 0 = autoplay on room entry (offset 32, v4+) + - `lightCount` (uint16): 0..13 authored sources (offset 34, v5 only) + - `lightingReserved` (uint16): zero (offset 36, v5 only) - **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). +- New saves use v5 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. @@ -202,7 +203,7 @@ F6 opens EblanStudio's timeline. Author commands with buttons, drag diamonds to `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 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,362,408-byte shared-memory mailbox (protocol v2): 128-byte header, 1024 cells, 32 sprites, a 96-byte CP1251 subtitle, 16 texture slots, a 320x240 DIB frame, 13 authored light records and 1024 baked RGB cells. 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. @@ -272,3 +273,32 @@ EblanStudio has evolved from a pure level editor into a **Full Game Engine Studi - Editor toolbar 1-click genre switcher: `☮ КВЕСТ / БЕЗ ОРУЖИЯ` vs `⚔ ЭКШЕН / С ОРУЖИЕМ`. - Project settings (`project.eblanproj`) persist `GameMode`, `ShowWeapons`, and `ShowCrosshair`. +7. **Static Lightmap Baking & Realtime Dynamic Lighting (`include/engine_light.inc`)**: + - `BakeMapLighting`: precomputes 1024 RGB cells in `lightGridRGB` from active STATIC sources only, with exact supercover cell-boundary occlusion, approximate distance attenuation and an out-of-place, wall-aware cross filter. `lightGrid` is a derived grayscale view. Realtime sources are never baked or counted twice. + - Dynamic point lights (`LIGHT_TYPE_POINT`) with customizable color, radius, intensity, and organic torch flame flicker (`LIGHT_FLAG_FLICKER`). + - Dynamic burst lights (`LIGHT_TYPE_BURST`): weapon muzzle flare on pistol/shotgun shots and high-intensity fiery explosion blast on explosive barrel detonation. + - Player directional spotlight flashlight `[F]`: conical falloff (`flashlightConeAngle`), distance attenuation, and wall occlusion physics. + - Dynamic lighting mode cycle `[L]`: + - `LIGHTING_MODE_NONE (0)`: classic flat Wolf3D retro fullbright. + - `LIGHTING_MODE_BAKED (1)`: static baked lightmap shadows and ambient shading. + - `LIGHTING_MODE_DYNAMIC (2)`: full dynamic physics (point lights + player flashlight + muzzle flares + barrel blasts + lightmap). + - EblanStudio Studio & Editor Lighting Tools: + - 1-Click Toolbar Button: `💡 ЗАПЕЧЬ СВЕТ` (Bake Lightmap) for instant baking. + - Toolbar Lighting Mode Switcher: `[☀ Свет: ВЫКЛ]`, `[🕯 Свет: ЗАПЕЧЁН]`, `[⚡ Свет: ДИНАМИКА]`. + - Toolbar Flashlight Toggle: `[🔦 Фонарик: ВКЛ/ВЫКЛ]`. + - 2D Canvas Lightmap Heatmap Overlay: `[🗺 Карта: ВКЛ/ВЫКЛ]` visualizing light and shadow gradients across the level grid. + - Light Source Entities on 2D Canvas: interactive light markers with RGB tint, glow halo, radius circle, click-to-select, and real-time mouse dragging. + - Quick Spawner Palette: `+ Источник света` button. + - Scene Hierarchy: dedicated `💡 Источники света (Lights)` tree with selection and "+ Добавить свет". + - Property Inspector: position X/Y sliders, radius slider, intensity slider, RGB color picker, light type dropdown (Static, Point, Torch flicker, Pulse), wall shadow-casting toggle, and torch flicker toggle. + - Real-Time 3D Viewport synchronization via `EnginePreviewPacket` shared-memory protocol. + + +### K. Authored Lighting Format (EBLN v5) +After the v4 cutscene strings, v5 appends `LightingSettings` (32 bytes), `lightCount * 40` bytes of `LightSource`, and 1024 DWORD baked RGB cells (`00RRGGBB`, 4096 bytes). Settings are eight DWORDs: mode (0..2), ambient (0..255), flashlight (0/1), flashlight radius (32..2048), intensity (0..255), half cone angle (1..96 angle units), baked-valid (1), reserved (0). Source layouts match `EditorLightSource` and MASM `LightSource`. Counts/settings/coordinates/radii/intensities/flags/colors and exact file size are validated before committing a room. v1..v4 load no authored lights and mode NONE; the old reserved header bytes are ignored. + +Sources and baked data are map-local and travel with save/load, undo/redo, F5 and standalone exports. Static lighting is baked in the editor with BakeLightingRGB and in ASM with BakeMapLighting; all 1024 RGB cells are regression-compared. Realtime point/pulse/flicker lights use only the live source array on top of the persisted static map. Shadow flags are respected. Slot 0 belongs to the player flashlight, 1..13 to authored sources, 14 to muzzle flare, 15 to explosions; combat never overwrites authored lights. Both wall stripes and sprites receive RGB coefficients. Flat ceiling/floor colors use a camera-cell light sample, not per-pixel floor projection. + +Editor light settings are in the property inspector and toolbar. A stale-bake indicator tracks static sources, geometry and ambient, excluding realtime movement. Saving or F5 automatically rebuilds a stale static bake; runtime and preview consume persisted RGB without baking each frame. The IPC v2 header includes flashlight parameters and a deterministic light tick; sources begin at offset 1,357,792 and baked RGB at 1,358,312. Update the C++, ASM and Python layouts together. + +Lighting verification (24.10.2026): 117 editor checks and 131 ASM checks passed; all 1024 RGB bake cells match, and both lit and classic viewport framebuffers match direct ASM rendering. Project assets were verified unchanged. diff --git a/README.md b/README.md index 4b7650e..7b79c0b 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ build_editor.bat ## Переходы и проверка первых трёх этапов -Карты новых сохранений используют EBLN v4; существующие v1/v2/v3 продолжают читаться. Связанные `.dat` лежат в одной папке. В портале задаётся ASCII-имя файла до 31 символа, целевой спавн, угол и затемнение. На карту доступно до 16 переходов. Заливка учитывает этот лимит целиком; удаление клетки удаляет её триггер. Чёрный пол и потолок поддерживаются. +Карты новых сохранений используют EBLN v5; существующие v1/v2/v3/v4 продолжают читаться. Связанные `.dat` лежат в одной папке. В портале задаётся ASCII-имя файла до 31 символа, целевой спавн, угол и затемнение. На карту доступно до 16 переходов. Заливка учитывает этот лимит целиком; удаление клетки удаляет её триггер. Чёрный пол и потолок поддерживаются. Порталы имеют отдельную текстуру и не занимают слот фонаря. Первый импорт изображения создаёт недостающие папки и пакет со стандартными текстурами в остальных слотах. Повреждённый пакет не перезаписывается импортом; ошибка показывается в строке состояния. Неудачный переход сохраняет текущую комнату и показывает сообщение в игре. @@ -134,4 +134,16 @@ python tools\test_regressions.py Предпросмотр останавливается на команде перехода в другую комнату; цепочка комнат и игровой ввод проверяются через F5. F5/F6 работают при фокусе интерфейса и текстовых полей. Ошибка запуска показывается отдельным сообщением. При старте редактор открывает карту проекта относительно своего файла, поэтому запуск из другой рабочей папки не ломает пути. -Формат v4 и параметры команд описаны в [AGENTS.md](AGENTS.md) и [архитектуре движка](docs/ENGINE_ARCHITECTURE.md). Сценарии регрессии также проверяют сохранение/загрузку трека, повреждённые команды, Undo/Redo, упаковку WAV для F5, горячие клавиши при фокусе в интерфейсе, покадровое совпадение состояния C++ с ASM, передачу кадров в DX11 и точное совпадение пикселей игрового вида с движком. Перед запуском проверок соберите `engine.exe` через `build.bat`. +Формат v5 и параметры команд описаны в [AGENTS.md](AGENTS.md) и [архитектуре движка](docs/ENGINE_ARCHITECTURE.md). Сценарии регрессии также проверяют сохранение/загрузку трека, повреждённые команды, Undo/Redo, упаковку WAV для F5, горячие клавиши при фокусе в интерфейсе, покадровое совпадение состояния C++ с ASM, передачу кадров в DX11 и точное совпадение пикселей игрового вида с движком. Перед запуском проверок соберите `engine.exe` через `build.bat`. + +## Постоянный и динамический свет + +В палитре выберите «+ Источник света» и поставьте его на карте. Источники доступны также в дереве сцены. В инспекторе задайте тип, цвет, радиус, яркость, тени и мерцание; работают перемещение, копирование, удаление и Undo/Redo. + +- **Постоянный**: выберите тип «Постоянный (запекание)», затем нажмите «Запечь постоянный свет». RGB-освещение и тени сохраняются в карте. После изменения геометрии, фонового света или постоянного источника инспектор покажет, что нужно запечь заново. При сохранении и F5 это происходит автоматически. +- **Динамический**: выберите точечный или пульсирующий источник и режим «Запечённый + динамический». Перемещение, цвет, яркость и мерцание сразу влияют на игровой вид. Перезапекать постоянный свет при этом не нужно. +- **Режимы**: «Без освещения», «Только запечённый», «Запечённый + динамический». Фоновый свет и параметры фонарика настраиваются в разделе «Освещение карты» инспектора. В игре F переключает фонарик, L — режим света. + +До 13 источников карты; фонарик, вспышки оружия и взрывов используют отдельные места. Закрытые двери и стены перекрывают динамический свет. Статические тени остаются результатом последнего запекания. Цвета стен и спрайтов освещаются по RGB; освещение плоских потолка/пола берётся в точке камеры. Запечённая карта имеет разрешение 32×32 ячейки. + +Новые сохранения используют EBLN v5. Старые карты сохраняют прежний вид без освещения, пока вы не добавите источники и не включите нужный режим. Свет каждой комнаты сохраняется независимо. Перед экспортом игры сохраните карты. diff --git a/docs/ENGINE_ARCHITECTURE.md b/docs/ENGINE_ARCHITECTURE.md index 6e968b2..0d96942 100644 --- a/docs/ENGINE_ARCHITECTURE.md +++ b/docs/ENGINE_ARCHITECTURE.md @@ -95,7 +95,7 @@ ## 5. Загрузка карт и ресурсов -Новые карты сохраняются как EBLN v4: заголовок 38 байт, сетка 1024 байта, спрайты по 24 байта, переходы по 42 байта, команды кат-сцены по 16 байт и строки по 96 байт. Чтение старых v1/v2/v3 сохранено; ранние v2 с переходами поддерживаются. Загрузчики сначала проверяют весь файл во временных буферах, затем заменяют комнату. При ошибке перехода текущая комната сохраняется; при ошибке начальной загрузки создаётся процедурная карта. Значение цвета 0 — полноценный чёрный цвет. +Новые карты сохраняются как EBLN v5: заголовок 38 байт, сетка 1024 байта, спрайты по 24 байта, переходы по 42 байта, команды кат-сцены по 16 байт и строки по 96 байт. Чтение старых v1/v2/v3/v4 сохранено; ранние v2 с переходами поддерживаются. Загрузчики сначала проверяют весь файл во временных буферах, затем заменяют комнату. При ошибке перехода текущая комната сохраняется; при ошибке начальной загрузки создаётся процедурная карта. Значение цвета 0 — полноценный чёрный цвет. Файлы связанных подуровней лежат рядом друг с другом. В `targetMap[32]` хранится только ASCII-имя `.dat` с завершающим нулём. Переход после появления в его ячейке повторно активируется только после выхода из неё. Двери и взаимодействие реагируют на отдельные нажатия. @@ -142,3 +142,17 @@ WASD, стрелки и правая кнопка мыши позволяют о F5/F6 обрабатываются до признака захвата клавиатуры ImGui — иначе фокус внутри редактора блокировал запуск. Ошибки подготовки и запуска F5 выводятся отдельным диалогом. Начальный путь карты определяется относительно расположения редактора и загружается при старте, независимо от рабочей папки процесса. Проверки выполняют полный путь x86 ASM → раздел памяти → x64 редактор → текстура DX11 на устройстве WARP. Пиксели встроенного вида сравниваются с прямым вызовом обычного ASM-рендерера. Отдельный сценарий `python tools/test_preview_ipc.py` проверяет обновление стен и текстур, субтитры, затемнение и завершение без открытия окон. Для полного регрессионного набора предварительно соберите движок. + +## 9. Запекание и динамический RGB-свет (24.10.2026) + +Запечённый свет отделён от динамических источников. `lighting_format.h` выполняет запекание в редакторе; `engine_lighting_core.inc` содержит эквивалентный ASM-вариант и расчёт света при рендеринге. Постоянные источники (STATIC=0) входят в 1024 RGB-ячейки `lightGridRGB`. Точечные (POINT=1), пульсирующие (PULSE=4), мерцание, фонарик и временные вспышки прибавляются только при динамическом режиме. Это исключает повторное прибавление света. Источник цвета 0 излучает чёрный свет; белый задаётся явно как 00FFFFFFh. + +Оба запекателя используют расстояние `max(abs(dx),abs(dy)) + 3*min(abs(dx),abs(dy))/8`, затухание `(radius-distance)*intensity/radius`, RGB-цвет и насыщение каналов. `CastLightRay` проверяет точные пересечения границ клеток; при пересечении угла проверяются обе соседние клетки. Порталы и открытые двери пропускают свет. Флаг SHADOWS отключает проверку препятствий для выбранного источника. Сглаживание выполняется в отдельном буфере с весом 2 для центра и 1 для доступных соседей, без усреднения сквозь стену. + +В v5 после строк кат-сцены добавлены 32 байта настроек, lightCount записей по 40 байт и 4096 байт RGB-карты. Поля lightCount/reserved занимают смещения 34/36 прежнего 38-байтового заголовка. Настройки: режим, фон, включение фонаря, его радиус, интенсивность, полуугол, baked-valid=1, reserved=0 (все DWORD). Загрузка транзакционная, включая освещение: повреждённая комната не заменяет действующую. Старые v1..v4 сбрасывают источники и сохраняют классическое освещение. После перехода загружается свет новой комнаты; старые источники и вспышки очищаются. + +Игровой вид использует протокол EBPV v2 размером 1 362 408 байт: прежние смещения пикселей и текстур сохранены, после кадра добавлены 13 источников и RGB-карта. Источники и фонарик синхронизируются при каждом запросе; время мерцания/пульсации задаётся 60-Гц счётчиком. Перезапекания на каждом кадре нет. В игре обновление использует время симуляции, а запечённая карта читается непосредственно из файла. У стен освещение берётся у видимой грани, у спрайтов — в их позиции; фонарик проверяет и конус, и заслонение. Плоские потолок/пол используют один образец в точке камеры, без проекции света по каждому пикселю пола. + +Слоты 1..13 принадлежат источникам карты; 0 зарезервирован фонарю, 14 вспышке выстрела, 15 взрыву. Вспышки не могут перезаписать авторские источники. Режимы света, источники, RGB-данные и состояние запекания входят в Undo/Redo. Изменение постоянного света/геометрии/фона помечает результат устаревшим; динамическое движение не требует запекания. Сохранение и F5 автоматически обновляют устаревший результат. + +Регрессии проверяют цвет, заслонение стенами/дверями/углами, флаги теней, отсутствие двойного освещения, пульсацию и затухание вспышек, сохранение v5, отказ от повреждённых данных, старые карты, Undo/Redo и реальное обновление игрового вида. Все 1024 RGB-ячейки сравниваются между C++ и ASM. Кадры сцены с авторским светом из встроенного вида сравниваются с прямым рендерингом той же карты в ASM. diff --git a/editor/editor.exe b/editor/editor.exe index 6b6c178..df68f66 100644 Binary files a/editor/editor.exe and b/editor/editor.exe differ diff --git a/editor/imgui.ini b/editor/imgui.ini index 5f4c428..a762454 100644 --- a/editor/imgui.ini +++ b/editor/imgui.ini @@ -1,22 +1,22 @@ [Window][Workspace] Pos=0,60 Size=2560,1292 -LastUsed=20261023 +LastUsed=20261024 [Window][Debug##Default] Pos=60,60 Size=400,400 -LastUsed=20261023 +LastUsed=20261024 [Window][Toolbar] Pos=0,20 Size=2560,38 -LastUsed=20261023 +LastUsed=20261024 [Window][StatusBar] Pos=0,1352 Size=2560,32 -LastUsed=20261023 +LastUsed=20261024 [Window][Не удалось запустить игру] Pos=649,356 diff --git a/editor/src/editor_app.cpp b/editor/src/editor_app.cpp index 4810e88..c71e983 100644 --- a/editor/src/editor_app.cpp +++ b/editor/src/editor_app.cpp @@ -145,6 +145,7 @@ void EditorApp::InitDefaultLevel() { selectedCutsceneEvent = -1; StopCutscenePreview(); cutsceneCursor = 0; + InitDefaultLights(); previewX = playerStartX*64+32; previewY = playerStartY*64+32; previewAngle = playerStartAngle; previewSceneCamera = true; ScrubCutscene(0); @@ -171,6 +172,10 @@ bool EditorApp::SaveLevelToFile(const std::string& filepath, bool updateCurrentP h.cutsceneCount = static_cast(cutscene.events.size()); h.cutsceneStringCount = static_cast(cutscene.strings.size()); h.cutsceneFlags = cutscene.flags; + if(!ValidateLighting(GetLightingSettings(),lightSources.data(),lightSources.size(),error)) {statusMessage=error;return false;} + if(LightingNeedsBake()) BakeLighting(); + h.lightCount=static_cast(lightSources.size()); + level.lighting.settings=GetLightingSettings();level.lighting.lights=lightSources;level.lighting.rgb=bakedLightRGB; bool success = AtomicWrite(filepath, [&](std::ofstream& out) { out.write(reinterpret_cast(&h), sizeof(h)); out.write(reinterpret_cast(level.cells.data()), level.cells.size()); @@ -183,6 +188,9 @@ bool EditorApp::SaveLevelToFile(const std::string& filepath, bool updateCurrentP std::memcpy(bytes, encoded.data(), encoded.size()); out.write(bytes, sizeof(bytes)); } + out.write(reinterpret_cast(&level.lighting.settings),sizeof(LightingSettings)); + if(!lightSources.empty()) out.write(reinterpret_cast(lightSources.data()),lightSources.size()*sizeof(EditorLightSource)); + out.write(reinterpret_cast(bakedLightRGB.data()),4096); }); if (!success) { statusMessage = "Не удалось сохранить карту: " + filepath; return false; } if (updateCurrentPath) currentFilePath = filepath; @@ -201,6 +209,14 @@ bool EditorApp::LoadLevel(const std::string& filepath) { ceilingColor = h.ceilingColor & 0xFFFFFF; floorColor = h.floorColor & 0xFFFFFF; sprites = std::move(candidate.sprites); transitions = std::move(candidate.transitions); cutscene = std::move(candidate.cutscene); + lightSources=std::move(candidate.lighting.lights); + const auto& ls=candidate.lighting.settings; + lightingMode=ls.mode;ambientLightLevel=ls.ambient;isFlashlightOn=ls.flashlight!=0; + flashlightRadius=ls.flashlightRadius;flashlightIntensity=ls.flashlightIntensity;flashlightCone=ls.flashlightCone; + bakedLightRGB=candidate.lighting.rgb; + for(int i=0;i<1024;++i) {auto c=bakedLightRGB[i];bakedLightmap[i]=(((c>>16)&255)+((c>>8)&255)+(c&255))/3;} + bakedLightingSignature=LightingBakeSignature(&map[0][0],lightSources,ambientLightLevel); + selectedLightIdx=draggingLightIdx=-1; selectedCutsceneEvent = -1; cutsceneCursor = 0; placeCameraPoint = false; ScrubCutscene(0); currentFilePath = filepath; @@ -346,6 +362,42 @@ void EditorApp::CheckPlaytestProcess() { // ----------------------------------------------------------------------------- // Undo / Redo // ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// Lighting Subsystem: Static Lightmap Baking & Raytraced Shadowing +// ----------------------------------------------------------------------------- +bool EditorApp::CastLightRay(int x1,int y1,int x2,int y2) const { + return LightingVisible(&map[0][0],x1,y1,x2,y2); +} + +LightingSettings EditorApp::GetLightingSettings() const { + LightingSettings s; + s.mode=lightingMode;s.ambient=ambientLightLevel;s.flashlight=isFlashlightOn; + s.flashlightRadius=flashlightRadius;s.flashlightIntensity=flashlightIntensity;s.flashlightCone=flashlightCone; + return s; +} + +bool EditorApp::LightingNeedsBake() const { + return bakedLightingSignature!=LightingBakeSignature(&map[0][0],lightSources,ambientLightLevel); +} + +void EditorApp::BakeLighting() { + bakedLightRGB=BakeLightingRGB(&map[0][0],lightSources,ambientLightLevel); + for(int i=0;i<1024;++i) { + uint32_t c=bakedLightRGB[i]; + bakedLightmap[i]=static_cast((((c>>16)&255)+((c>>8)&255)+(c&255))/3); + } + bakedLightingSignature=LightingBakeSignature(&map[0][0],lightSources,ambientLightLevel); + statusMessage="Постоянный свет запечён. Динамические источники рассчитываются отдельно."; +} + +void EditorApp::InitDefaultLights() { + lightSources.clear(); + lightingMode=LIGHTING_MODE_DYNAMIC;ambientLightLevel=40;isFlashlightOn=true; + flashlightRadius=384;flashlightIntensity=240;flashlightCone=45; + selectedLightIdx=draggingLightIdx=-1; + BakeLighting(); +} + LevelSnapshot EditorApp::CaptureSnapshot() const { LevelSnapshot s; std::memcpy(s.map, map, sizeof(map)); @@ -357,6 +409,8 @@ LevelSnapshot EditorApp::CaptureSnapshot() const { s.sprites = sprites; s.transitions = transitions; s.cutscene = cutscene; + s.lightSources = lightSources; + s.lightingSettings=GetLightingSettings();s.bakedRGB=bakedLightRGB;s.bakedSignature=bakedLightingSignature; return s; } @@ -371,6 +425,13 @@ void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { StopCutscenePreview(); transitions = s.transitions; cutscene = s.cutscene; + lightSources = s.lightSources; + lightingMode=s.lightingSettings.mode;ambientLightLevel=s.lightingSettings.ambient;isFlashlightOn=s.lightingSettings.flashlight!=0; + flashlightRadius=s.lightingSettings.flashlightRadius;flashlightIntensity=s.lightingSettings.flashlightIntensity;flashlightCone=s.lightingSettings.flashlightCone; + bakedLightRGB=s.bakedRGB;bakedLightingSignature=s.bakedSignature; + for(int i=0;i<1024;++i) {auto c=bakedLightRGB[i];bakedLightmap[i]=(((c>>16)&255)+((c>>8)&255)+(c&255))/3;} + selectedLightIdx = -1; + draggingLightIdx = -1; selectedCutsceneEvent = -1; placeCameraPoint = false; ScrubCutscene(cutsceneCursor); selectedSpriteIdx = -1; @@ -1276,6 +1337,21 @@ void EditorApp::ApplyTool(int gx, int gy) { if (SetCell(gx, gy, CELL_EMPTY)) { playerStartX = gx; playerStartY = gy; } break; case TOOL_SPRITE_SPAWN: + if (pendingSpawnArchetype == 99) { + if (lightSources.size() < MAX_EDITOR_LIGHTS) { + if(lightingMode==0)lightingMode=2; + EditorLightSource nl; + nl.x = gx * 64 + 32; nl.y = gy * 64 + 32; + lightSources.push_back(nl); + selectedLightIdx = static_cast(lightSources.size()) - 1; + selectedSpriteIdx = -1; selectedTransitionIdx = -1; + BakeLighting(); + statusMessage = "Источник света создан на карте."; + } else { + statusMessage = "Лимит источников света карты: 13."; + } + break; + } if (sprites.size() < MAX_LEVEL_SPRITES) { SpriteEntry sprite{}; sprite.x = gx * 64 + 32; sprite.y = gy * 64 + 32; @@ -1303,7 +1379,7 @@ void EditorApp::ApplyTool(int gx, int gy) { SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_PROP, 100, 0, 0); } sprites.push_back(sprite); - selectedSpriteIdx = static_cast(sprites.size()) - 1; + selectedSpriteIdx = static_cast(sprites.size()) - 1; selectedLightIdx=-1; selectedTransitionIdx = -1; } break; @@ -1339,6 +1415,11 @@ void EditorApp::FloodFill(int startX, int startY, uint8_t targetType, uint8_t fi } void EditorApp::DuplicateSelectedEntity() { + if(selectedLightIdx>=0 && selectedLightIdx(lightSources.size())) { + if(lightSources.size()>=MAX_EDITOR_LIGHTS){statusMessage="Лимит: 13 источников карты.";return;} + auto copy=lightSources[selectedLightIdx];copy.x=(std::min)(2047,copy.x+32);copy.y=(std::min)(2047,copy.y+32); + PushUndo();lightSources.push_back(copy);selectedLightIdx=static_cast(lightSources.size())-1;return; + } if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { if (sprites.size() >= MAX_LEVEL_SPRITES) { statusMessage = "Лимит спрайтов на карте достигнут (макс. 32)."; @@ -1355,6 +1436,9 @@ void EditorApp::DuplicateSelectedEntity() { } void EditorApp::DeleteSelectedEntity() { + if(selectedLightIdx>=0 && selectedLightIdx(lightSources.size())) { + PushUndo();lightSources.erase(lightSources.begin()+selectedLightIdx);selectedLightIdx=draggingLightIdx=-1;return; + } if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { PushUndo(); sprites.erase(sprites.begin() + selectedSpriteIdx); @@ -1370,6 +1454,10 @@ void EditorApp::DeleteSelectedEntity() { } void EditorApp::FocusSelectedEntity() { + if(selectedLightIdx>=0 && selectedLightIdx(lightSources.size())) { + canvasPanX=150.0f-lightSources[selectedLightIdx].x/64.0f*canvasZoom; + canvasPanY=150.0f-lightSources[selectedLightIdx].y/64.0f*canvasZoom;return; + } if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { int gx = sprites[selectedSpriteIdx].x / 64; int gy = sprites[selectedSpriteIdx].y / 64; @@ -1768,6 +1856,51 @@ void EditorApp::RenderToolbar() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); + // Lighting Controls in Toolbar + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.50f, 0.05f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.65f, 0.10f, 1.0f)); + if (ImGui::Button(" 💡 ЗАПЕЧЬ СВЕТ ")) { + PushUndo(); BakeLighting(); + } + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Запечь постоянное освещение (Lightmap) для всей карты с учетом стен и препятствий"); + + ImGui::SameLine(); + const char* lModeBtn = (lightingMode == 0) ? " ☀ Свет: ВЫКЛ " : + (lightingMode == 1) ? " 🕯 Свет: ЗАПЕЧЁН " : + " ⚡ Свет: ДИНАМИКА "; + if (ImGui::Button(lModeBtn)) { + PushUndo(); lightingMode = (lightingMode + 1) % 3; + statusMessage = (lightingMode == 0) ? "Режим света: ВЫКЛ (Wolf3D flat)" : + (lightingMode == 1) ? "Режим света: ЗАПЕЧЁННЫЙ (Lightmap)" : + "Режим света: ДИНАМИЧЕСКИЙ (Point lights + Flashlight)"; + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Переключение режима освещения (Без света / Запечённый / Динамический)"); + + ImGui::SameLine(); + if (isFlashlightOn) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.55f, 0.70f, 1.0f)); + if (ImGui::Button(" 🔦 Фонарик: ВКЛ ")) {PushUndo();isFlashlightOn = false;} + ImGui::PopStyleColor(); + } else { + if (ImGui::Button(" 🔦 Фонарик: ВЫКЛ ")) {PushUndo();isFlashlightOn = true;} + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Переключить фонарик игрока [F]"); + + ImGui::SameLine(); + if (showLightHeatmap) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.70f, 0.40f, 0.10f, 1.0f)); + if (ImGui::Button(" 🗺 Карта: ВКЛ ")) showLightHeatmap = false; + ImGui::PopStyleColor(); + } else { + if (ImGui::Button(" 🗺 Карта: ВЫКЛ ")) showLightHeatmap = true; + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Отображать световую карту (Heatmap) на 2D сетке"); + + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + // Tools auto ToolButton = [&](const char* label, ToolMode mode) { bool active = (currentTool == mode); @@ -1889,6 +2022,16 @@ void EditorApp::RenderGridCanvas() { ImU32 col = GetCellColor(map[y][x]); drawList->AddRectFilled(tileP0, tileP1, col); + + if (showLightHeatmap) { + uint8_t l = bakedLightmap[y * MAP_GRID_W + x]; + ImU32 lightTint = (l < 50) ? IM_COL32(10, 15, 30, 160) : + (l < 100) ? IM_COL32(30, 30, 20, 100) : + (l < 180) ? IM_COL32(200, 160, 30, static_cast((l - 100) * 0.7f)) : + IM_COL32(255, 220, 80, static_cast(60 + (l - 180) * 0.6f)); + drawList->AddRectFilled(tileP0, tileP1, lightTint); + } + drawList->AddRect(tileP0, tileP1, IM_COL32(50, 50, 55, 120), 0.0f, 0, 1.0f); if (map[y][x] == CELL_PORTAL) { @@ -1963,6 +2106,41 @@ void EditorApp::RenderGridCanvas() { ); } + // 2b. Draw Light Sources on Map Canvas + int hoveredLight = -1; + for (size_t i = 0; i < lightSources.size(); ++i) { + float lx = lightSources[i].x / 64.0f; + float ly = lightSources[i].y / 64.0f; + ImVec2 lcPos = ImVec2(gridOrigin.x + lx * tileSize, gridOrigin.y + ly * tileSize); + + float dx = mousePos.x - lcPos.x; + float dy = mousePos.y - lcPos.y; + float r = tileSize * 0.45f; + if (std::sqrt(dx * dx + dy * dy) < r) { + hoveredLight = static_cast(i); + } + + if (static_cast(i) == selectedLightIdx || static_cast(i) == hoveredLight) { + float radPix = (lightSources[i].radius / 64.0f) * tileSize; + drawList->AddCircle(lcPos, radPix, IM_COL32(255, 210, 40, 160), 48, 1.5f); + drawList->AddCircleFilled(lcPos, radPix, IM_COL32(255, 210, 40, 18)); + } + + uint32_t c = lightSources[i].color; + ImU32 lightCol = IM_COL32((c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF, 255); + ImU32 haloCol = IM_COL32((c >> 16) & 0xFF, (c >> 8) & 0xFF, c & 0xFF, 70); + + drawList->AddCircleFilled(lcPos, tileSize * 0.38f, haloCol); + drawList->AddCircleFilled(lcPos, tileSize * 0.22f, lightCol); + drawList->AddCircleFilled(lcPos, tileSize * 0.08f, IM_COL32(255, 255, 255, 255)); + + if (static_cast(i) == selectedLightIdx) { + drawList->AddCircle(lcPos, tileSize * 0.42f, IM_COL32(255, 255, 0, 255), 24, 2.0f); + } else if (static_cast(i) == hoveredLight) { + drawList->AddCircle(lcPos, tileSize * 0.42f, IM_COL32(255, 255, 200, 200), 24, 1.5f); + } + } + // 3. Draw Player Spawn Point & Direction Cone { ImVec2 pPos = ImVec2(gridOrigin.x + (playerStartX + 0.5f) * tileSize, @@ -2010,12 +2188,18 @@ void EditorApp::RenderGridCanvas() { drawList->PopClipRect(); return; } if (placeCameraPoint) { drawList->PopClipRect(); return; } - // Left Click: start action (drag sprite OR paint) + // Left Click: start action (drag light OR drag sprite OR paint) if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - if (currentTool == TOOL_SPRITE_SPAWN && hoveredSprite >= 0) { + if (hoveredLight >= 0) { + draggingLightIdx = hoveredLight; + selectedLightIdx = hoveredLight; + selectedSpriteIdx = -1; + selectedTransitionIdx = -1; + PushUndo(); + } else if (currentTool == TOOL_SPRITE_SPAWN && hoveredSprite >= 0) { // Start dragging existing sprite draggingSpriteIdx = hoveredSprite; - selectedSpriteIdx = hoveredSprite; + selectedSpriteIdx = hoveredSprite; selectedLightIdx=-1; selectedTransitionIdx = -1; PushUndo(); } else if (inGrid) { @@ -2023,7 +2207,7 @@ void EditorApp::RenderGridCanvas() { if (map[hoverGy][hoverGx] == CELL_PORTAL) { for (size_t i = 0; i < transitions.size(); ++i) { if (transitions[i].triggerCellX == hoverGx && transitions[i].triggerCellY == hoverGy) { - selectedTransitionIdx = static_cast(i); + selectedTransitionIdx = static_cast(i); selectedLightIdx=-1; selectedSpriteIdx = -1; break; } @@ -2035,9 +2219,14 @@ void EditorApp::RenderGridCanvas() { } } - // Left Drag: continue painting OR move sprite + // Left Drag: continue painting OR move light OR move sprite if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - if (draggingSpriteIdx >= 0) { + if (draggingLightIdx >= 0) { + float wx = (mousePos.x - gridOrigin.x) / tileSize; + float wy = (mousePos.y - gridOrigin.y) / tileSize; + lightSources[draggingLightIdx].x = std::clamp(static_cast(wx * 64.0f), 32, (MAP_GRID_W - 1) * 64); + lightSources[draggingLightIdx].y = std::clamp(static_cast(wy * 64.0f), 32, (MAP_GRID_H - 1) * 64); + } else if (draggingSpriteIdx >= 0) { float wx = (mousePos.x - gridOrigin.x) / tileSize; float wy = (mousePos.y - gridOrigin.y) / tileSize; sprites[draggingSpriteIdx].x = static_cast(wx * 64.0f); @@ -2049,6 +2238,10 @@ void EditorApp::RenderGridCanvas() { // Left Release: end drag / stroke if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + if (draggingLightIdx >= 0) { + draggingLightIdx = -1; + BakeLighting(); + } draggingSpriteIdx = -1; strokeSnapshotTaken = false; } @@ -2164,6 +2357,7 @@ void EditorApp::RenderTilePalette() { SpawnerBtn("+ Переключатель [E]", ENTITY_ARCHETYPE_SWITCH, ImVec4(0.70f, 0.40f, 0.10f, 1.0f)); SpawnerBtn("+ Бочка (Взрывная)", ENTITY_ARCHETYPE_BARREL, ImVec4(0.70f, 0.25f, 0.10f, 1.0f)); SpawnerBtn("+ Декорация (Проп)", ENTITY_ARCHETYPE_PROP, ImVec4(0.35f, 0.35f, 0.40f, 1.0f)); + SpawnerBtn("+ Источник света", 99, ImVec4(0.85f, 0.60f, 0.10f, 1.0f)); } void EditorApp::RenderHierarchy() { @@ -2195,7 +2389,7 @@ void EditorApp::RenderHierarchy() { spr.textureId = defaultTex; SetSpriteEntityData(spr, archetype, (archetype == ENTITY_ARCHETYPE_GUARD ? 60 : 100), (archetype == ENTITY_ARCHETYPE_GUARD ? 2 : 0), (archetype == ENTITY_ARCHETYPE_GUARD ? 6 : 0)); sprites.push_back(spr); - selectedSpriteIdx = static_cast(sprites.size()) - 1; + selectedSpriteIdx = static_cast(sprites.size()) - 1; selectedLightIdx=-1; selectedTransitionIdx = -1; statusMessage = "Сущность создана на сцене."; } else { @@ -2210,6 +2404,19 @@ void EditorApp::RenderHierarchy() { AddEntityItem("🕹 Рычаг / Переключатель", ENTITY_ARCHETYPE_SWITCH); AddEntityItem("💥 Взрывная бочка", ENTITY_ARCHETYPE_BARREL); AddEntityItem("🏺 Декорация / Проп", ENTITY_ARCHETYPE_PROP); + if (ImGui::MenuItem("💡 Источник света")) { + if (lightSources.size() < MAX_EDITOR_LIGHTS) { + PushUndo(); + if(lightingMode==0)lightingMode=2; + EditorLightSource nl; + nl.x = (playerStartX + 1) * 64 + 32; nl.y = (playerStartY + 1) * 64 + 32; + lightSources.push_back(nl); + selectedLightIdx = static_cast(lightSources.size()) - 1; + selectedSpriteIdx = -1; selectedTransitionIdx = -1; + BakeLighting(); + statusMessage = "Источник света создан на сцене."; + } + } ImGui::EndPopup(); } @@ -2228,7 +2435,7 @@ void EditorApp::RenderHierarchy() { char playerLabel[96]; snprintf(playerLabel, sizeof(playerLabel), "👤 Игрок [Spawn: (%d, %d)]", playerStartX, playerStartY); if (MatchesSearch(playerLabel) || MatchesSearch("player") || MatchesSearch("игрок")) { - bool isPlayerSelected = (selectedSpriteIdx == -1 && selectedTransitionIdx == -1); + bool isPlayerSelected = (selectedSpriteIdx == -1 && selectedTransitionIdx == -1 && selectedLightIdx == -1); if (ImGui::Selectable(playerLabel, isPlayerSelected)) { selectedSpriteIdx = -1; selectedTransitionIdx = -1; @@ -2269,25 +2476,25 @@ void EditorApp::RenderHierarchy() { bool isSel = (selectedSpriteIdx == static_cast(i)); if (ImGui::Selectable(label, isSel)) { - selectedSpriteIdx = static_cast(i); + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; selectedTransitionIdx = -1; } if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { - selectedSpriteIdx = static_cast(i); + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; FocusSelectedEntity(); } if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("📋 Дублировать", "Ctrl+D")) { - selectedSpriteIdx = static_cast(i); + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; DuplicateSelectedEntity(); } if (ImGui::MenuItem("🎯 Сфокусировать", "F")) { - selectedSpriteIdx = static_cast(i); + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; FocusSelectedEntity(); } ImGui::Separator(); if (ImGui::MenuItem("🗑 Удалить", "Del")) { - selectedSpriteIdx = static_cast(i); + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; DeleteSelectedEntity(); } ImGui::EndPopup(); @@ -2326,6 +2533,15 @@ void EditorApp::RenderHierarchy() { ImGui::TreePop(); } + if(ImGui::TreeNodeEx("Источники света",ImGuiTreeNodeFlags_DefaultOpen)) { + for(int i=0;i(lightSources.size());++i) { + char label[100];snprintf(label,sizeof(label),"Свет #%d: %s##light_%d",i+1, + lightSources[i].lightType==LIGHT_TYPE_STATIC?"постоянный":"динамический",i); + if(ImGui::Selectable(label,selectedLightIdx==i)){selectedLightIdx=i;selectedSpriteIdx=selectedTransitionIdx=-1;} + } + if(ImGui::SmallButton("+ Добавить свет")) {pendingSpawnArchetype=99;currentTool=TOOL_SPRITE_SPAWN;statusMessage="Выберите место источника на карте.";} + ImGui::TreePop(); + } // 3. Sublevel Transitions Category char transHdr[64]; snprintf(transHdr, sizeof(transHdr), "🌀 Порталы перехода (%zu)", transitions.size()); @@ -2340,18 +2556,18 @@ void EditorApp::RenderHierarchy() { bool isSel = (selectedTransitionIdx == static_cast(i)); if (ImGui::Selectable(label, isSel)) { - selectedTransitionIdx = static_cast(i); + selectedTransitionIdx = static_cast(i); selectedLightIdx=-1; selectedSpriteIdx = -1; } if (ImGui::BeginPopupContextItem()) { if (ImGui::MenuItem("🎯 Сфокусировать", "F")) { - selectedTransitionIdx = static_cast(i); + selectedTransitionIdx = static_cast(i); selectedLightIdx=-1; canvasPanX = 150.0f - (transitions[i].triggerCellX * canvasZoom); canvasPanY = 150.0f - (transitions[i].triggerCellY * canvasZoom); } ImGui::Separator(); if (ImGui::MenuItem("🗑 Удалить портал", "Del")) { - selectedTransitionIdx = static_cast(i); + selectedTransitionIdx = static_cast(i); selectedLightIdx=-1; DeleteSelectedEntity(); } ImGui::EndPopup(); @@ -2370,6 +2586,10 @@ void EditorApp::RenderInspector() { ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИНСПЕКТОР СВОЙСТВ (INSPECTOR)"); ImGui::Separator(); + if(selectedLightIdx>=0 && selectedLightIdx(lightSources.size())) {RenderLightingInspector();return;} + RenderLightingSettings(); + ImGui::Separator(); + // Case 1: Sprite Entity Selected if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { auto& spr = sprites[selectedSpriteIdx]; @@ -2807,3 +3027,5 @@ void EditorApp::RenderExportGameModal() { } } + +#include "editor_lighting.inl" diff --git a/editor/src/editor_app.h b/editor/src/editor_app.h index a9f1f26..8ca338c 100644 --- a/editor/src/editor_app.h +++ b/editor/src/editor_app.h @@ -9,6 +9,7 @@ #include "level_format.h" #include "cutscene_preview.h" #include "preview_protocol.h" +#include "lighting_format.h" #include #include #include @@ -33,6 +34,10 @@ struct LevelSnapshot { std::vector sprites; std::vector transitions; CutsceneTrack cutscene; + std::vector lightSources; + LightingSettings lightingSettings; + std::array bakedRGB; + uint64_t bakedSignature=0; }; class EditorApp { @@ -107,10 +112,31 @@ public: uint8_t pendingSpawnArchetype = ENTITY_ARCHETYPE_PROP; int selectedSpriteIdx = -1; int selectedTransitionIdx = -1; + int selectedLightIdx = -1; + int draggingLightIdx = -1; bool isEngineRunning = false; std::string statusMessage = "Готов к работе. EblanStudio v1.2"; std::string currentFilePath = "..\\assets\\map.dat"; + // Lighting Subsystem State & Light Sources + int lightingMode = 2; // 0 = None, 1 = Baked, 2 = Dynamic + int ambientLightLevel = 40; + bool isFlashlightOn = true; + int flashlightRadius = 384; + int flashlightIntensity = 240; + int flashlightCone = 45; + bool showLightHeatmap = false; + uint8_t bakedLightmap[MAP_TOTAL_CELLS] = { 0 }; + std::array bakedLightRGB{}; + uint64_t bakedLightingSignature = 0; + std::vector lightSources; + + void BakeLighting(); + void InitDefaultLights(); + bool CastLightRay(int x1, int y1, int x2, int y2) const; + bool LightingNeedsBake() const; + LightingSettings GetLightingSettings() const; + // Viewport pan & zoom float canvasZoom = 20.0f; // pixels per tile float canvasPanX = 20.0f; @@ -121,6 +147,8 @@ public: struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr }; private: + void RenderLightingInspector(); + void RenderLightingSettings(); bool EnsureEnginePreview(); void StopEnginePreview(); void UpdateEnginePreview(); diff --git a/editor/src/editor_code.inl b/editor/src/editor_code.inl index b1c10b6..a3e5c03 100644 --- a/editor/src/editor_code.inl +++ b/editor/src/editor_code.inl @@ -18,6 +18,8 @@ struct CodeFileEntry { }; static const CodeFileEntry g_ProjectCodeFiles[] = { + { "engine_light.inc (Light Sources & Bursts)", "include/engine_light.inc" }, + { "engine_lighting_core.inc (Static RGB Bake & Dynamic Lighting)", "include/engine_lighting_core.inc" }, { "engine_gameplay.inc (Events, Hooks & Dialogs)", "include/engine_gameplay.inc" }, { "engine_combat.inc (Weapons & Combat)", "include/engine_combat.inc" }, { "engine_entity.inc (Entities & AI)", "include/engine_entity.inc" }, diff --git a/editor/src/editor_lighting.inl b/editor/src/editor_lighting.inl new file mode 100644 index 0000000..6e3c935 --- /dev/null +++ b/editor/src/editor_lighting.inl @@ -0,0 +1,72 @@ +void EditorApp::RenderLightingSettings() { + if(!ImGui::CollapsingHeader("Освещение карты",ImGuiTreeNodeFlags_DefaultOpen)) return; + auto edited=GetLightingSettings(); bool changed=false,activated=false; + auto undoItem=[&](){activated|=ImGui::IsItemActivated();}; + const char* modes[]={"Без освещения","Только запечённый","Запечённый + динамический"}; + int mode=edited.mode; + changed|=ImGui::Combo("Режим света",&mode,modes,3);undoItem();edited.mode=mode; + int ambient=edited.ambient; + changed|=ImGui::SliderInt("Фоновый свет",&ambient,0,255);undoItem();edited.ambient=ambient; + bool flash=edited.flashlight!=0; + changed|=ImGui::Checkbox("Фонарик игрока",&flash);undoItem();edited.flashlight=flash; + if(flash) { + int radius=edited.flashlightRadius,intensity=edited.flashlightIntensity,cone=edited.flashlightCone; + changed|=ImGui::SliderInt("Дальность фонаря",&radius,32,2048);undoItem(); + changed|=ImGui::SliderInt("Яркость фонаря",&intensity,0,255);undoItem(); + changed|=ImGui::SliderInt("Полуугол фонаря",&cone,1,96);undoItem(); + edited.flashlightRadius=radius;edited.flashlightIntensity=intensity;edited.flashlightCone=cone; + } + if(activated)PushUndo(); + if(changed) { + lightingMode=edited.mode;ambientLightLevel=edited.ambient;isFlashlightOn=edited.flashlight!=0; + flashlightRadius=edited.flashlightRadius;flashlightIntensity=edited.flashlightIntensity;flashlightCone=edited.flashlightCone; + } + if(ImGui::Button("Запечь постоянный свет",ImVec2(-1,0))) {PushUndo();BakeLighting();} + if(LightingNeedsBake()) ImGui::TextWrapped("Геометрия или постоянный свет изменены: нужно запечь. При сохранении и F5 запекание выполняется автоматически."); + else ImGui::TextDisabled("Постоянный свет запечён"); + ImGui::Checkbox("Карта запечённого света",&showLightHeatmap); + ImGui::TextWrapped("Постоянные источники входят в запекание. Точечные, мерцающие и пульсирующие работают в динамическом режиме."); +} + +void EditorApp::RenderLightingInspector() { + auto& light=lightSources[selectedLightIdx]; + ImGui::Text("Источник света #%d / %d",selectedLightIdx+1,MAX_EDITOR_LIGHTS); + if(ImGui::Button("Удалить источник")) { + PushUndo();lightSources.erase(lightSources.begin()+selectedLightIdx);selectedLightIdx=-1;return; + } + ImGui::SameLine(); + if(ImGui::Button("Копировать")) { + if(lightSources.size()(lightSources.size())-1; + } else statusMessage="Лимит: 13 источников карты; фонарик и вспышки имеют отдельные места."; + return; + } + EditorLightSource edited=light;bool changed=false,activated=false; + auto undoItem=[&](){activated|=ImGui::IsItemActivated();}; + bool active=(edited.flags&LIGHT_FLAG_ACTIVE)!=0; + changed|=ImGui::Checkbox("Включён",&active);undoItem(); + edited.flags=(edited.flags&~LIGHT_FLAG_ACTIVE)|(active?LIGHT_FLAG_ACTIVE:0); + float x=edited.x/64.0f,y=edited.y/64.0f; + if(ImGui::SliderFloat("Позиция X",&x,0,31.98f,"%.2f")){edited.x=static_cast(x*64);changed=true;}undoItem(); + if(ImGui::SliderFloat("Позиция Y",&y,0,31.98f,"%.2f")){edited.y=static_cast(y*64);changed=true;}undoItem(); + changed|=ImGui::SliderInt("Радиус",&edited.radius,32,2048);undoItem(); + changed|=ImGui::SliderInt("Интенсивность",&edited.intensity,0,255);undoItem();edited.curIntensity=edited.intensity; + float color[]={((edited.color>>16)&255)/255.f,((edited.color>>8)&255)/255.f,(edited.color&255)/255.f}; + if(ImGui::ColorEdit3("Цвет света",color)) { + edited.color=(uint32_t(color[0]*255)<<16)|(uint32_t(color[1]*255)<<8)|uint32_t(color[2]*255);changed=true; + }undoItem(); + const char* types[]={"Постоянный (запекание)","Точечный (динамический)","Пульсирующий (динамический)"}; + int type=edited.lightType==LIGHT_TYPE_STATIC?0:edited.lightType==LIGHT_TYPE_PULSE?2:1; + if(ImGui::Combo("Тип света",&type,types,3)){edited.lightType=type==0?LIGHT_TYPE_STATIC:type==1?LIGHT_TYPE_POINT:LIGHT_TYPE_PULSE;changed=true;}undoItem(); + bool shadows=(edited.flags&LIGHT_FLAG_SHADOWS)!=0,flicker=(edited.flags&LIGHT_FLAG_FLICKER)!=0; + changed|=ImGui::Checkbox("Тени от стен и дверей",&shadows);undoItem(); + edited.flags=(edited.flags&~LIGHT_FLAG_SHADOWS)|(shadows?LIGHT_FLAG_SHADOWS:0); + if(edited.lightType!=LIGHT_TYPE_STATIC) { + changed|=ImGui::Checkbox("Мерцание факела",&flicker);undoItem(); + edited.flags=(edited.flags&~LIGHT_FLAG_FLICKER)|(flicker?LIGHT_FLAG_FLICKER:0); + } + if(activated)PushUndo(); + if(changed)light=edited; + RenderLightingSettings(); +} diff --git a/editor/src/editor_viewport.inl b/editor/src/editor_viewport.inl index fa356df..817f092 100644 --- a/editor/src/editor_viewport.inl +++ b/editor/src/editor_viewport.inl @@ -41,7 +41,7 @@ bool EditorApp::EnsureEnginePreview() { 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(); + previewPacket->magic = 0x56504245; previewPacket->version = 2; 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, @@ -105,6 +105,14 @@ void EditorApp::UpdateEnginePreview() { if (!sprites.empty()) memcpy(previewPacket->sprites,sprites.data(),previewPacket->spriteCount*sizeof(SpriteEntry)); previewPacket->cinematic = scene && cutscenePreview.active; previewPacket->fade = scene ? cutscenePreview.fade : 0; + previewPacket->lightingMode = static_cast(lightingMode); + previewPacket->ambientLight = static_cast(ambientLightLevel); + previewPacket->flashlight = isFlashlightOn ? 1 : 0; + previewPacket->lightCount = static_cast((std::min)(lightSources.size(), size_t(MAX_EDITOR_LIGHTS))); + previewPacket->flashlightRadius=flashlightRadius;previewPacket->flashlightIntensity=flashlightIntensity;previewPacket->flashlightCone=flashlightCone; + if(!lightSources.empty()) memcpy(previewPacket->lights,lightSources.data(),previewPacket->lightCount*sizeof(EditorLightSource)); + memcpy(previewPacket->bakedRGB,bakedLightRGB.data(),4096); + previewPacket->lightTick=static_cast(GetTickCount64()*60/1000); memset(previewPacket->subtitle,0,96); if (scene && cutscenePreview.subtitle >= 0 && cutscenePreview.subtitle < static_cast(cutscene.strings.size())) { std::string encoded; diff --git a/editor/src/level_format.h b/editor/src/level_format.h index b28592c..4612250 100644 --- a/editor/src/level_format.h +++ b/editor/src/level_format.h @@ -6,7 +6,7 @@ // ============================================================================== constexpr uint32_t MAP_MAGIC = 0x4E4C4245; // 'EBLN' -constexpr uint16_t MAP_FORMAT_VERSION = 4; // v4 adds a cutscene track and CP1251 string pool +constexpr uint16_t MAP_FORMAT_VERSION = 5; // v5 adds authored lighting and baked RGB cells 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 @@ -66,7 +66,7 @@ struct LevelTransition { struct MapHeader { uint32_t magic; // 'EBLN' - uint16_t version; // Current writer: 4; reader also accepts v1/v2/v3 + uint16_t version; // Current writer: 5; reader also accepts v1/v2/v3/v4 uint16_t mapWidth; // 32 uint16_t mapHeight; // 32 uint16_t playerStartX; // Cell coordinate (0..31) @@ -79,7 +79,8 @@ struct MapHeader { 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 + uint16_t lightCount; // v5 only, authored lights (slots 1..13 in engine) + uint16_t lightingReserved; // must be zero in v5; header remains 38 bytes }; struct CutsceneEvent { @@ -135,3 +136,53 @@ 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"); + +// Lighting Modes & Types (mirrors include/engine_defs.inc) +enum LightingMode : uint32_t { + LIGHTING_MODE_NONE = 0, + LIGHTING_MODE_BAKED = 1, + LIGHTING_MODE_DYNAMIC = 2 +}; + +enum LightType : uint32_t { + LIGHT_TYPE_STATIC = 0, + LIGHT_TYPE_POINT = 1, + LIGHT_TYPE_FLASHLIGHT = 2, + LIGHT_TYPE_BURST = 3, + LIGHT_TYPE_PULSE = 4 +}; + +constexpr uint32_t LIGHT_FLAG_INACTIVE = 0; +constexpr uint32_t LIGHT_FLAG_ACTIVE = 1; +constexpr uint32_t LIGHT_FLAG_SHADOWS = 2; +constexpr uint32_t LIGHT_FLAG_FLICKER = 4; + +constexpr int MAX_EDITOR_LIGHTS = 13; // engine slots 0 / 14 / 15 are flashlight / muzzle / explosion + +#pragma pack(push, 1) +struct EditorLightSource { + int32_t x = 0; // raw world units (cell * 64 + 32) + int32_t y = 0; + int32_t radius = 240; // radius in raw world units + int32_t intensity = 220; // 0..255 base intensity + int32_t curIntensity = 220;// 0..255 current intensity + int32_t lightType = LIGHT_TYPE_POINT; + uint32_t flags = LIGHT_FLAG_ACTIVE | LIGHT_FLAG_SHADOWS | LIGHT_FLAG_FLICKER; + uint32_t color = 0x00FFB040;// 00RRGGBBh tint (warm torch) + int32_t decaySpeed = 0; + int32_t flickerPhase = 0; +}; +#pragma pack(pop) +static_assert(sizeof(EditorLightSource) == 40, "EditorLightSource size mismatch with MASM LightSource"); + +struct LightingSettings { + uint32_t mode = LIGHTING_MODE_NONE; + uint32_t ambient = 40; + uint32_t flashlight = 1; + uint32_t flashlightRadius = 384; + uint32_t flashlightIntensity = 240; + uint32_t flashlightCone = 45; + uint32_t baked = 1; + uint32_t reserved = 0; +}; +static_assert(sizeof(LightingSettings) == 32, "Lighting settings wire layout changed"); diff --git a/editor/src/level_io.h b/editor/src/level_io.h index 3f53229..55b3314 100644 --- a/editor/src/level_io.h +++ b/editor/src/level_io.h @@ -1,6 +1,7 @@ #pragma once #include "level_format.h" #include "cutscene_format.h" +#include "lighting_format.h" #include #include #include @@ -14,6 +15,7 @@ struct BinaryLevel { std::vector sprites; std::vector transitions; CutsceneTrack cutscene; + LevelLighting lighting; }; inline bool IsMapFilename(const char* name) { @@ -63,6 +65,8 @@ inline bool ValidateLevel(const BinaryLevel& level, std::string& error) { } for (size_t i = 0; i < level.cells.size(); ++i) if (level.cells[i] == CELL_PORTAL && !triggers[i]) return fail("Портал не имеет настроенного перехода"); + if(!ValidateLighting(level.lighting.settings,level.lighting.lights.data(),level.lighting.lights.size(),error)) return false; + for(auto c:level.lighting.rgb) if(c&0xFF000000) {error="Неверный цвет запечённого света";return false;} return ValidateCutscene(level.cutscene, level.cells.data(), level.transitions.size(), error); } @@ -85,9 +89,12 @@ inline bool ReadLevelFile(const std::string& path, BinaryLevel& result, std::str if (eventCount > MAX_CUTSCENE_EVENTS || stringCount > MAX_CUTSCENE_STRINGS) { error = "Превышен лимит событий или строк кат-сцены"; return false; } + const size_t lightCount=h.version>=5?h.lightCount:0; + if(lightCount>MAX_EDITOR_LIGHTS || (h.version>=5&&h.lightingReserved)) {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; + eventCount * sizeof(CutsceneEvent) + stringCount * CUTSCENE_STRING_BYTES + + (h.version>=5?sizeof(LightingSettings)+lightCount*sizeof(EditorLightSource)+4096:0); if (fileSize != static_cast(expected)) { error = "Размер файла не соответствует заголовку карты"; return false; } @@ -108,6 +115,12 @@ inline bool ReadLevelFile(const std::string& path, BinaryLevel& result, std::str if (!end) { error = "Строка кат-сцены не имеет завершающего нуля"; return false; } candidate.cutscene.strings.push_back(CutsceneDecode(bytes, static_cast(end - bytes))); } + if(h.version>=5) { + in.read(reinterpret_cast(&candidate.lighting.settings),sizeof(LightingSettings)); + candidate.lighting.lights.resize(lightCount); + if(lightCount)in.read(reinterpret_cast(candidate.lighting.lights.data()),lightCount*sizeof(EditorLightSource)); + in.read(reinterpret_cast(candidate.lighting.rgb.data()),4096); + } 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; diff --git a/editor/src/lighting_format.h b/editor/src/lighting_format.h new file mode 100644 index 0000000..2fce96c --- /dev/null +++ b/editor/src/lighting_format.h @@ -0,0 +1,97 @@ +#pragma once +#include "level_format.h" +#include +#include +#include +#include +#include + +struct LevelLighting { + LightingSettings settings; + std::vector lights; + std::array rgb{}; // 00RRGGBB, baked static contribution + ambient + LevelLighting() { rgb.fill(0x282828); } +}; + +inline bool ValidateLighting(const LightingSettings& s, const EditorLightSource* lights, size_t count, std::string& error) { + if (s.mode>2 || s.ambient>255 || s.flashlight>1 || s.flashlightRadius<32 || s.flashlightRadius>2048 || + s.flashlightIntensity>255 || s.flashlightCone<1 || s.flashlightCone>96 || s.baked!=1 || s.reserved || count>MAX_EDITOR_LIGHTS) { + error="Неверные параметры освещения"; return false; + } + for(size_t i=0;i=2048 || l.y<0 || l.y>=2048 || l.radius<1 || l.radius>2048 || l.intensity<0 || l.intensity>255 || + l.curIntensity<0 || l.curIntensity>255 || (l.lightType!=LIGHT_TYPE_STATIC && l.lightType!=LIGHT_TYPE_POINT && l.lightType!=LIGHT_TYPE_PULSE) || (l.flags&~7u) || (l.color&0xFF000000)) { + error="Неверные координаты, цвет или параметры источника света"; return false; + } + } + return true; +} + +inline bool LightingWalkable(uint8_t cell) { return cell==0 || cell==6 || cell==10; } + +// Exact supercover grid traversal; corner crossings cannot leak diagonally through walls. +inline bool LightingVisible(const uint8_t* cells,int x1,int y1,int x2,int y2) { + if(x1<0||x1>=2048||y1<0||y1>=2048||x2<0||x2>=2048||y2<0||y2>=2048) return false; + int cx=x1/64,cy=y1/64,ex=x2/64,ey=y2/64; + const int dx=std::abs(x2-x1),dy=std::abs(y2-y1),sx=x2>=x1?1:-1,sy=y2>=y1?1:-1; + int nx=sx>0?(cx+1)*64-x1:x1-cx*64,ny=sy>0?(cy+1)*64-y1:y1-cy*64; + auto free=[&](int x,int y){return x>=0&&x<32&&y>=0&&y<32&&LightingWalkable(cells[y*32+x]);}; + // A wall-mounted source may start in a wall; every subsequent cell is tested. + for(int steps=0;steps<65;++steps) { + if(cx==ex&&cy==ey) return free(cx,cy); + const int a=dx?nx*dy:0x3FFFFFFF,b=dy?ny*dx:0x3FFFFFFF; + if(a==b) { + if(!free(cx+sx,cy)||!free(cx,cy+sy)) return false; + cx+=sx;cy+=sy;nx+=64;ny+=64; + } else if(a BakeLightingRGB(const uint8_t* cells,const std::vector& lights,int ambient) { + std::array rgb; + const uint32_t base=ambient*0x010101u;rgb.fill(base); + for(int i=0;i<1024;++i) { + if(!LightingWalkable(cells[i])) continue; + int x=(i%32)*64+32,y=(i/32)*64+32,r=ambient,g=ambient,b=ambient; + for(const auto& l:lights) { + if(!(l.flags&LIGHT_FLAG_ACTIVE)||l.lightType!=LIGHT_TYPE_STATIC||l.radius<=0) continue; + int d=LightDistance(x-l.x,y-l.y);if(d>=l.radius) continue; + if((l.flags&LIGHT_FLAG_SHADOWS)&&!LightingVisible(cells,l.x,l.y,x,y)) continue; + int a=(l.radius-d)*l.intensity/l.radius; + r=(std::min)(255,r+a*int((l.color>>16)&255)/255); + g=(std::min)(255,g+a*int((l.color>>8)&255)/255); + b=(std::min)(255,b+a*int(l.color&255)/255); + } + rgb[i]=(r<<16)|(g<<8)|b; + } + auto smooth=rgb; + for(int y=0;y<32;++y)for(int x=0;x<32;++x) { + int i=y*32+x;if(!LightingWalkable(cells[i])) continue; + int r=((rgb[i]>>16)&255)*2,g=((rgb[i]>>8)&255)*2,b=(rgb[i]&255)*2,n=2; + for(auto delta: {std::pair{-1,0},{1,0},{0,-1},{0,1}}) { + int xx=x+delta.first,yy=y+delta.second; + if(xx<0||xx>=32||yy<0||yy>=32||!LightingWalkable(cells[yy*32+xx])) continue; + uint32_t c=rgb[yy*32+xx];r+=(c>>16)&255;g+=(c>>8)&255;b+=c&255;++n; + } + smooth[i]=((r/n)<<16)|((g/n)<<8)|(b/n); + } + return smooth; +} + +inline uint64_t LightingBakeSignature(const uint8_t* cells,const std::vector& lights,int ambient) { + uint64_t hash=1469598103934665603ull; + auto add=[&](uint32_t v){hash^=v;hash*=1099511628211ull;}; + for(int i=0;i<1024;++i)add(cells[i]);add(ambient); + for(const auto& l:lights)if(l.lightType==LIGHT_TYPE_STATIC) { + add(l.x);add(l.y);add(l.radius);add(l.intensity);add(l.flags&3);add(l.color); + } + return hash; +} diff --git a/editor/src/preview_protocol.h b/editor/src/preview_protocol.h index ce474c3..70fec11 100644 --- a/editor/src/preview_protocol.h +++ b/editor/src/preview_protocol.h @@ -8,21 +8,33 @@ // Never modify payload while state == 1. All fields have fixed wire widths. struct EnginePreviewPacket { uint32_t magic = 0x56504245; // EBPV - uint32_t version = 1; + uint32_t version = 2; 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]{}; + uint32_t lightingMode = 2; // offset 52: 0=None, 1=Baked, 2=Dynamic + uint32_t ambientLight = 40; // offset 56: base shadow level + uint32_t flashlight = 1; // offset 60: 1=flashlight active + uint32_t lightCount = 0; // offset 64: count of active lights + uint32_t flashlightRadius = 384; // 68 + uint32_t flashlightIntensity = 240; // 72 + uint32_t flashlightCone = 45; // 76 + uint32_t lightingRevision = 0; // 80 + uint32_t lightTick = 0; // 84, deterministic 60 Hz time + uint32_t reserved[10]{}; uint8_t cells[1024]{}; SpriteEntry sprites[32]{}; char subtitle[96]{}; uint32_t textures[16*128*128]{}; uint32_t pixels[320*240]{}; + EditorLightSource lights[MAX_EDITOR_LIGHTS]{}; + uint32_t bakedRGB[1024]{}; }; 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"); +static_assert(offsetof(EnginePreviewPacket,lights)==1357792,"Light payload offset changed"); +static_assert(sizeof(EnginePreviewPacket) == 1362408, "Preview mapping size mismatch"); diff --git a/editor/src/project_manager.h b/editor/src/project_manager.h index 70b2ba5..e1334ef 100644 --- a/editor/src/project_manager.h +++ b/editor/src/project_manager.h @@ -19,6 +19,9 @@ struct EblanProject { int gameMode = 1; // 0 = Adventure (No weapons / Peaceful), 1 = Action (Weapons) bool showWeapons = true; bool showCrosshair = true; + int lightingMode = 2; // 0 = None, 1 = Baked, 2 = Dynamic + int ambientLight = 40; + bool flashlight = true; bool Save(const std::string& path) const { HANDLE hFile = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); @@ -38,6 +41,11 @@ struct EblanProject { content += std::string("GameMode=") + numBuf + "\r\n"; content += std::string("ShowWeapons=") + (showWeapons ? "1" : "0") + "\r\n"; content += std::string("ShowCrosshair=") + (showCrosshair ? "1" : "0") + "\r\n"; + snprintf(numBuf, sizeof(numBuf), "%d", lightingMode); + content += std::string("LightingMode=") + numBuf + "\r\n"; + snprintf(numBuf, sizeof(numBuf), "%d", ambientLight); + content += std::string("AmbientLight=") + numBuf + "\r\n"; + content += std::string("Flashlight=") + (flashlight ? "1" : "0") + "\r\n"; DWORD written = 0; WriteFile(hFile, content.data(), static_cast(content.size()), &written, nullptr); CloseHandle(hFile); @@ -75,6 +83,9 @@ struct EblanProject { else if (key == "GameMode") gameMode = std::atoi(val.c_str()); else if (key == "ShowWeapons") showWeapons = (val == "1" || val == "true"); else if (key == "ShowCrosshair") showCrosshair = (val == "1" || val == "true"); + else if (key == "LightingMode") lightingMode = std::atoi(val.c_str()); + else if (key == "AmbientLight") ambientLight = std::atoi(val.c_str()); + else if (key == "Flashlight") flashlight = (val == "1" || val == "true"); } } diff --git a/engine.exe b/engine.exe index 979ec24..61e03d4 100644 Binary files a/engine.exe and b/engine.exe differ diff --git a/include/engine_combat.inc b/include/engine_combat.inc index 92a3f2f..4c3d768 100644 --- a/include/engine_combat.inc +++ b/include/engine_combat.inc @@ -85,6 +85,7 @@ setup_shotgun: mov hitTolerance, 40 mov playerAttackCooldown, 22 mov playerFlashTimer, 7 + invoke SpawnMuzzleFlashLight, playerX, playerY jmp fire_hitscan setup_pistol: @@ -97,6 +98,7 @@ setup_pistol: mov hitTolerance, 22 mov playerAttackCooldown, 12 mov playerFlashTimer, 5 + invoke SpawnMuzzleFlashLight, playerX, playerY fire_hitscan: ; 3. Hitscan check against enemies & destructible barrels in crosshair @@ -288,6 +290,7 @@ explode_barrel: push edi mov esi, [ebx].SpriteStruct.x mov edi, [ebx].SpriteStruct.y + invoke SpawnExplosionLight, esi, edi mov i, 0 aoe_loop: diff --git a/include/engine_data.inc b/include/engine_data.inc index a36f881..c1bd8a4 100644 --- a/include/engine_data.inc +++ b/include/engine_data.inc @@ -71,8 +71,20 @@ keyPause db 0 keyPauseHeld db 0 keyHudStyleHeld db 0 keyMenuHeld db 0 +keyFlashlight db 0 +keyFlashlightHeld db 0 +keyLightMode db 0 +keyLightModeHeld db 0 isGamePaused dd 0 +; Lighting Configuration & Dynamic State +lightingMode dd LIGHTING_MODE_NONE ; 0 = None, 1 = Baked, 2 = Dynamic+Baked (InitLightingSystem enables DYNAMIC) +ambientLightLevel dd 40 ; Base shadow level (40/256 = ~15% darkness) +isFlashlightOn dd 1 ; 1 = Player flashlight active +flashlightRadius dd 384 ; 6 cells distance +flashlightIntensity dd 240 ; Bright focused beam +flashlightConeAngle dd 45 ; Half-angle of beam (~32 degrees in 512-angle circle) + ; Game Mode & HUD Configuration gameMode dd GAME_MODE_ACTION ; 0 = Adventure (No weapons), 1 = Action / Combat hudStyle dd HUD_STYLE_CLEAN ; 0 = Clean / Immersive, 1 = Debug / Classic @@ -250,6 +262,11 @@ entityTimer dd MAX_SPRITES dup(?) entitySpeed dd MAX_SPRITES dup(?) entityAggro dd MAX_SPRITES dup(?) +ALIGN 4 +lightGrid db MAP_SIZE dup(?) ; 1024 bytes: baked light level (0..255) per cell +ALIGN 4 +lightSources db MAX_LIGHTS * (sizeof LightSource) dup(?) + ; Gameplay Variables & Built-in Toast Strings (CP1251) .data ALIGN 4 @@ -275,6 +292,12 @@ szWeaponsEnabledToast db "РЕЖИМ: ОРУЖИЕ ВКЛЮЧЕНО", 0 szWeaponsDisabledToast db "РЕЖИМ: БЕЗ ОРУЖИЯ (КВЕСТ)", 0 szHudCleanToast db "ИНТЕРФЕЙС: ЧИСТЫЙ ВИД", 0 szHudDebugToast db "ИНТЕРФЕЙС: РЕЖИМ ОТЛАДКИ", 0 +szFlashlightOn db 0D4h,0CEh,0CDh,0C0h,0D0h,0C8h,0CAh,03Ah,020h,0C2h,0CAh,0CBh,020h,05Bh,046h,05Dh,0 ; ФОНАРИК: ВКЛ [F] (CP1251) +szFlashlightOff db 0D4h,0CEh,0CDh,0C0h,0D0h,0C8h,0CAh,03Ah,020h,0C2h,0DBh,0CAh,0CBh,020h,05Bh,046h,05Dh,0 ; ФОНАРИК: ВЫКЛ [F] (CP1251) +szLightMode0 db 0D1h,0C2h,0C5h,0D2h,03Ah,020h,0C2h,0DBh,0CAh,0CBh,0DEh,0D7h,0C5h,0CDh,020h,028h,057h,06Fh,06Ch,066h,033h,044h,029h,0 ; СВЕТ: ВЫКЛЮЧЕН (Wolf3D) (CP1251) +szLightMode1 db 0D1h,0C2h,0C5h,0D2h,03Ah,020h,0C7h,0C0h,0CFh,0C5h,0D7h,0A8h,0CDh,0CDh,0DBh,0C9h,020h,028h,04Ch,069h,067h,068h,074h,06Dh,061h,070h,029h,0 ; СВЕТ: ЗАПЕЧЁННЫЙ (Lightmap) (CP1251) +szLightMode2 db 0D1h,0C2h,0C5h,0D2h,03Ah,020h,0C4h,0C8h,0CDh,0C0h,0CCh,0C8h,0D7h,0C5h,0D1h,0CAh,0C8h,0C9h,020h,0D1h,020h,0D4h,0C8h,0C7h,0C8h,0CAh,0CEh,0C9h,0 ; СВЕТ: ДИНАМИЧЕСКИЙ С ФИЗИКОЙ (CP1251) +szBakeDoneToast db 0D1h,0C2h,0C5h,0D2h,020h,0C7h,0C0h,0CFh,0C5h,0D7h,0A8h,0CDh,03Ah,020h,031h,030h,032h,034h,020h,0DFh,0D7h,0C5h,0C9h,0CAh,0C8h,0 ; СВЕТ ЗАПЕЧЁН: 1024 ЯЧЕЙКИ (CP1251) szProjectFile db "project.eblanproj", 0 szProjGameMode0 db "GameMode=0", 0 szProjGameMode1 db "GameMode=1", 0 @@ -285,3 +308,23 @@ szFlagAction db "action", 0 szFlagDebugHud db "debug-hud", 0 szFlagCleanHud db "clean-hud", 0 previewPrefix db "--preview:", 0 + +; Persistent and staged lighting buffers (declared before the map loader). +.data +lightingTick dd 0 +authoredLightCount dd 0 +legacyLightingSettings dd 0,40,1,384,240,45,1,0 +lightRed dd 256 +lightGreen dd 256 +lightBlue dd 256 +lightAccumR dd 0 +lightAccumG dd 0 +lightAccumB dd 0 +lightWeight dd 0 +.data? +ALIGN 4 +lightGridRGB dd MAP_SIZE dup(?) +lightSmoothRGB dd MAP_SIZE dup(?) +stagedLightSettings dd 8 dup(?) +stagedLightSources db 13*40 dup(?) +stagedLightRGB dd MAP_SIZE dup(?) diff --git a/include/engine_defs.inc b/include/engine_defs.inc index 9081565..ac2b60b 100644 --- a/include/engine_defs.inc +++ b/include/engine_defs.inc @@ -74,6 +74,27 @@ VK_KEY_4 equ 34h VK_KEY_5 equ 35h VK_KEY_F1 equ 70h VK_KEY_H equ 48h +VK_KEY_F equ 46h ; Flashlight toggle +VK_KEY_L equ 4Ch ; Lighting mode cycle + +; Lighting Modes +LIGHTING_MODE_NONE equ 0 ; Fullbright (classic retro Wolf3D flat lighting) +LIGHTING_MODE_BAKED equ 1 ; Static Baked Lightmap (raytraced shadow-casting) +LIGHTING_MODE_DYNAMIC equ 2 ; Dynamic Point Lights + Realtime Physics + Flashlight + +; Light Source Archetypes & Flags +LIGHT_TYPE_STATIC equ 0 ; Static baked light +LIGHT_TYPE_POINT equ 1 ; Realtime dynamic point light (torch, lamp) +LIGHT_TYPE_FLASHLIGHT equ 2 ; Player spotlight cone +LIGHT_TYPE_BURST equ 3 ; Decaying light flare (muzzle flash, explosion) +LIGHT_TYPE_PULSE equ 4 ; Flickering / pulsing light + +LIGHT_FLAG_INACTIVE equ 0 +LIGHT_FLAG_ACTIVE equ 1 +LIGHT_FLAG_SHADOWS equ 2 ; Raycast wall occlusion (shadow casting) +LIGHT_FLAG_FLICKER equ 4 ; Organic torch flicker + +MAX_LIGHTS equ 16 ; Game Modes GAME_MODE_ADVENTURE equ 0 ; Exploration / Quest / Horror / Puzzle (No weapons, clean view) @@ -136,7 +157,7 @@ CELL_COUNT equ 11 ; Sector Transition Portals MAX_TRANSITIONS equ 16 MAX_PATH_BYTES equ 260 -MAP_FORMAT_VERSION equ 4 +MAP_FORMAT_VERSION equ 5 LevelTransition STRUCT ; sizeof = 42 bytes (packed) triggerCellX db 0 ; 1 byte @@ -161,6 +182,20 @@ SpriteStruct STRUCT flags dd 0 SpriteStruct ENDS +; Light Source Struct (sizeof = 40 bytes) +LightSource STRUCT + x dd 0 ; raw world units (0..2047) + y dd 0 ; raw world units (0..2047) + radius dd 0 ; radius in raw world units + intensity dd 0 ; 0..255 base intensity + curIntensity dd 0 ; 0..255 current intensity (after flicker/decay) + lightType dd 0 ; LIGHT_TYPE_* + flags dd 0 ; LIGHT_FLAG_* + color dd 0 ; 00RRGGBBh tint (0 = neutral white/ambient) + decaySpeed dd 0 ; decay per tick for BURST lights + flickerPhase dd 0 ; flicker oscillation phase +LightSource ENDS + ; Texture Pack (assets/textures/texturepack.bin): ; dd magic 'EBTX', dw version (1), dw slotCount, then slotCount * 65536 bytes TEXPACK_MAGIC equ 58544245h ; 'EBTX' @@ -177,7 +212,7 @@ MAP_MAGIC equ 4E4C4245h ; 'EBLN' MapHeader STRUCT ; sizeof = 38 bytes (packed) magic dd 0 ; 'EBLN' - version dw 0 ; 1 = fixed coords, 2/3 = transitions, 4 = cutscenes + version dw 0 ; 1 = fixed coords, 2/3 = transitions, 4 = cutscenes, 5 = lighting mapWidth dw 0 ; 32 mapHeight dw 0 ; 32 playerStartX dw 0 ; cell X @@ -190,7 +225,8 @@ MapHeader STRUCT ; sizeof = 38 bytes (packed) cutsceneCount dw 0 cutsceneStringCount dw 0 cutsceneFlags dw 0 - reserved db 4 dup(0) + lightCount dw 0 + lightingReserved dw 0 MapHeader ENDS MAX_CUTSCENE_EVENTS equ 128 diff --git a/include/engine_input.inc b/include/engine_input.inc index ca93aef..4998059 100644 --- a/include/engine_input.inc +++ b/include/engine_input.inc @@ -203,6 +203,53 @@ hud_key_released: mov keyHudStyleHeld, 0 hud_key_sampled: + ; F: Toggle Flashlight + invoke GetAsyncKeyState, VK_KEY_F + test ax, 8000h + jz flash_key_released + cmp keyFlashlightHeld, 0 + jne flash_key_sampled + xor isFlashlightOn, 1 + mov keyFlashlightHeld, 1 + cmp isFlashlightOn, 1 + je toast_flash_on + invoke ShowGameToast, OFFSET szFlashlightOff, 60, 00808080h + jmp flash_key_sampled +toast_flash_on: + invoke ShowGameToast, OFFSET szFlashlightOn, 60, COLOR_CYAN + jmp flash_key_sampled +flash_key_released: + mov keyFlashlightHeld, 0 +flash_key_sampled: + + ; L: Cycle Lighting Mode (0: Off -> 1: Baked -> 2: Dynamic) + invoke GetAsyncKeyState, VK_KEY_L + test ax, 8000h + jz light_key_released + cmp keyLightModeHeld, 0 + jne light_key_sampled + inc lightingMode + cmp lightingMode, 3 + jl @F + mov lightingMode, 0 +@@: + mov keyLightModeHeld, 1 + cmp lightingMode, LIGHTING_MODE_NONE + je toast_light_none + cmp lightingMode, LIGHTING_MODE_BAKED + je toast_light_baked + invoke ShowGameToast, OFFSET szLightMode2, 60, COLOR_YELLOW + jmp light_key_sampled +toast_light_none: + invoke ShowGameToast, OFFSET szLightMode0, 60, 00808080h + jmp light_key_sampled +toast_light_baked: + invoke ShowGameToast, OFFSET szLightMode1, 60, COLOR_CYAN + jmp light_key_sampled +light_key_released: + mov keyLightModeHeld, 0 +light_key_sampled: + ; Weapon Selection Keys: 1, 2, 3 (only in Action mode with weapons enabled) cmp gameMode, GAME_MODE_ACTION jne skip_weapon_select diff --git a/include/engine_light.inc b/include/engine_light.inc new file mode 100644 index 0000000..e84a82e --- /dev/null +++ b/include/engine_light.inc @@ -0,0 +1,103 @@ +include include/engine_lighting_core.inc +AddLightSource PROC uses ebx ecx edx x:DWORD, y:DWORD, radius:DWORD, intensity:DWORD, lightType:DWORD, flags:DWORD, color:DWORD + cmp x,2048 + jae no_slot_avail + cmp y,2048 + jae no_slot_avail + cmp radius,1 + jb no_slot_avail + cmp radius,2048 + ja no_slot_avail + cmp intensity,255 + ja no_slot_avail + cmp lightType,4 + ja no_slot_avail + mov eax,flags + and eax,0FFFFFFF8h + jnz no_slot_avail + test color,0FF000000h + jnz no_slot_avail + mov ecx, 1 ; Slot 0 is reserved for player flashlight +find_slot: + cmp ecx, 14 + jge no_slot_avail + + mov ebx, ecx + imul ebx, sizeof LightSource + add ebx, OFFSET lightSources + + test [ebx].LightSource.flags, LIGHT_FLAG_ACTIVE + jz slot_found + + inc ecx + jmp find_slot + +slot_found: + mov eax, x + mov [ebx].LightSource.x, eax + mov eax, y + mov [ebx].LightSource.y, eax + mov eax, radius + mov [ebx].LightSource.radius, eax + mov eax, intensity + mov [ebx].LightSource.intensity, eax + mov [ebx].LightSource.curIntensity, eax + mov eax, lightType + mov [ebx].LightSource.lightType, eax + mov eax, flags + or eax, LIGHT_FLAG_ACTIVE + mov [ebx].LightSource.flags, eax + mov eax, color + mov [ebx].LightSource.color, eax + mov [ebx].LightSource.decaySpeed, 0 + mov [ebx].LightSource.flickerPhase, 0 + + mov eax, ecx + ret + +no_slot_avail: + mov eax, -1 + ret +AddLightSource ENDP + +; ------------------------------------------------------------------------------ +; SpawnExplosionLight: Triggers high-intensity fiery explosion light burst +; ------------------------------------------------------------------------------ +SpawnExplosionLight PROC uses ebx x:DWORD, y:DWORD + mov ebx, 15 * (sizeof LightSource) + add ebx, OFFSET lightSources + + mov eax, x + mov [ebx].LightSource.x, eax + mov eax, y + mov [ebx].LightSource.y, eax + mov [ebx].LightSource.radius, 450 + mov [ebx].LightSource.intensity, 255 + mov [ebx].LightSource.curIntensity, 255 + mov [ebx].LightSource.lightType, LIGHT_TYPE_BURST + mov [ebx].LightSource.flags, LIGHT_FLAG_ACTIVE or LIGHT_FLAG_SHADOWS + mov [ebx].LightSource.decaySpeed, 12 + mov [ebx].LightSource.color, 00FFAA33h ; Warm fire tint + ret +SpawnExplosionLight ENDP + +; ------------------------------------------------------------------------------ +; SpawnMuzzleFlashLight: Triggers momentary bright gunshot flash +; ------------------------------------------------------------------------------ +SpawnMuzzleFlashLight PROC uses ebx x:DWORD, y:DWORD + mov ebx, 14 * (sizeof LightSource) + add ebx, OFFSET lightSources + + mov eax, x + mov [ebx].LightSource.x, eax + mov eax, y + mov [ebx].LightSource.y, eax + mov [ebx].LightSource.radius, 350 + mov [ebx].LightSource.intensity, 240 + mov [ebx].LightSource.curIntensity, 240 + mov [ebx].LightSource.lightType, LIGHT_TYPE_BURST + mov [ebx].LightSource.flags, LIGHT_FLAG_ACTIVE or LIGHT_FLAG_SHADOWS + mov [ebx].LightSource.decaySpeed, 60 + mov [ebx].LightSource.color, 00FFFFEEh ; White/yellow flare + ret +SpawnMuzzleFlashLight ENDP diff --git a/include/engine_lighting_core.inc b/include/engine_lighting_core.inc new file mode 100644 index 0000000..be498c9 --- /dev/null +++ b/include/engine_lighting_core.inc @@ -0,0 +1,817 @@ +; Versioned authored lighting, RGB shading and exact occlusion traversal. +.code +InitLightingSystem PROTO +BakeMapLighting PROTO +UpdateDynamicLights PROTO +AddLightSource PROTO :DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD +SpawnExplosionLight PROTO :DWORD,:DWORD +SpawnMuzzleFlashLight PROTO :DWORD,:DWORD +CastLightRay PROTO :DWORD,:DWORD,:DWORD,:DWORD +ComputeWallLightLevel PROTO :DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD,:DWORD +ComputeSpriteLight PROTO :DWORD,:DWORD,:DWORD +SamplePointLighting PROTO :DWORD,:DWORD,:DWORD,:DWORD +LightCellFree PROTO :DWORD,:DWORD +RefreshLightGrid PROTO + +ValidateLightingData PROC uses ebx ecx edx esi, settings:DWORD,sources:DWORD,count:DWORD + mov esi,settings + cmp DWORD PTR [esi],2 + ja vl_invalid + cmp DWORD PTR [esi+4],255 + ja vl_invalid + cmp DWORD PTR [esi+8],1 + ja vl_invalid + cmp DWORD PTR [esi+12],32 + jb vl_invalid + cmp DWORD PTR [esi+12],2048 + ja vl_invalid + cmp DWORD PTR [esi+16],255 + ja vl_invalid + cmp DWORD PTR [esi+20],1 + jb vl_invalid + cmp DWORD PTR [esi+20],96 + ja vl_invalid + cmp DWORD PTR [esi+24],1 + jne vl_invalid + cmp DWORD PTR [esi+28],0 + jne vl_invalid + cmp count,13 + ja vl_invalid + mov ecx,count + mov esi,sources +vl_source: + test ecx,ecx + jz vl_valid + cmp [esi].LightSource.x,2048 + jae vl_invalid + cmp [esi].LightSource.y,2048 + jae vl_invalid + cmp [esi].LightSource.radius,1 + jb vl_invalid + cmp [esi].LightSource.radius,2048 + ja vl_invalid + cmp [esi].LightSource.intensity,255 + ja vl_invalid + cmp [esi].LightSource.curIntensity,255 + ja vl_invalid + cmp [esi].LightSource.lightType,1 + jbe vl_type_ok + cmp [esi].LightSource.lightType,4 + jne vl_invalid +vl_type_ok: + mov eax,[esi].LightSource.flags + and eax,0FFFFFFF8h + jnz vl_invalid + test [esi].LightSource.color,0FF000000h + jnz vl_invalid + add esi,40 + dec ecx + jmp vl_source +vl_valid: + mov eax,1 + ret +vl_invalid: + xor eax,eax + ret +ValidateLightingData ENDP + +InitLightingSystem PROC uses eax ecx edi + mov lightingMode,0 + mov ambientLightLevel,40 + mov isFlashlightOn,1 + mov flashlightRadius,384 + mov flashlightIntensity,240 + mov flashlightConeAngle,45 + mov authoredLightCount,0 + mov lightingTick,0 + mov edi,OFFSET lightSources + mov ecx,MAX_LIGHTS*10 + xor eax,eax + rep stosd + mov edi,OFFSET lightGridRGB + mov ecx,MAP_SIZE + mov eax,00282828h + rep stosd + call RefreshLightGrid + ret +InitLightingSystem ENDP + +CommitLightingData PROC uses eax ecx esi edi,settings:DWORD,sources:DWORD,count:DWORD,rgb:DWORD + call InitLightingSystem + mov esi,settings + lodsd + mov lightingMode,eax + lodsd + mov ambientLightLevel,eax + lodsd + mov isFlashlightOn,eax + lodsd + mov flashlightRadius,eax + lodsd + mov flashlightIntensity,eax + lodsd + mov flashlightConeAngle,eax + mov esi,sources + mov edi,OFFSET lightSources+40 + mov ecx,count + mov authoredLightCount,ecx + imul ecx,10 + rep movsd + mov esi,rgb + mov edi,OFFSET lightGridRGB + mov ecx,MAP_SIZE + rep movsd + call RefreshLightGrid + ret +CommitLightingData ENDP + +LightCellFree PROC uses edx,x:DWORD,y:DWORD + cmp x,32 + jae lcf_no + cmp y,32 + jae lcf_no + mov eax,y + shl eax,5 + add eax,x + movzx eax,BYTE PTR [gameMap+eax] + cmp eax,0 + je lcf_yes + cmp eax,6 + je lcf_yes + cmp eax,10 + je lcf_yes +lcf_no: + xor eax,eax + ret +lcf_yes: + mov eax,1 + ret +LightCellFree ENDP + +; Supercover traversal: test both sides when crossing an exact grid corner. +CastLightRay PROC uses ebx ecx edx esi edi,x1:DWORD,y1:DWORD,x2:DWORD,y2:DWORD + LOCAL cellX:DWORD,cellY:DWORD,ex:DWORD,ey:DWORD,deltaX:DWORD,deltaY:DWORD,sx:DWORD,sy:DWORD,nx:DWORD,ny:DWORD,axv:DWORD + mov eax,x1 + or eax,y1 + or eax,x2 + or eax,y2 + test eax,0FFFFF800h + jnz lr_blocked + mov eax,x1 + shr eax,6 + mov cellX,eax + mov eax,y1 + shr eax,6 + mov cellY,eax + mov eax,x2 + shr eax,6 + mov ex,eax + mov eax,y2 + shr eax,6 + mov ey,eax + mov sx,1 + mov eax,x2 + sub eax,x1 + jns lr_dx + neg eax + mov sx,-1 +lr_dx: + mov deltaX,eax + mov sy,1 + mov eax,y2 + sub eax,y1 + jns lr_dy + neg eax + mov sy,-1 +lr_dy: + mov deltaY,eax + mov eax,cellX + shl eax,6 + cmp sx,1 + jne lr_nx_neg + add eax,64 + sub eax,x1 + jmp lr_nx +lr_nx_neg: + neg eax + add eax,x1 +lr_nx: + mov nx,eax + mov eax,cellY + shl eax,6 + cmp sy,1 + jne lr_ny_neg + add eax,64 + sub eax,y1 + jmp lr_ny +lr_ny_neg: + neg eax + add eax,y1 +lr_ny: + mov ny,eax + mov edi,65 +lr_step: + mov eax,cellX + cmp eax,ex + jne lr_cross + mov eax,cellY + cmp eax,ey + je lr_end +lr_cross: + mov eax,3FFFFFFFh + cmp deltaX,0 + je lr_a + mov eax,nx + imul eax,deltaY +lr_a: + mov axv,eax + mov eax,3FFFFFFFh + cmp deltaY,0 + je lr_b + mov eax,ny + imul eax,deltaX +lr_b: + cmp axv,eax + jb lr_x + ja lr_y + mov eax,cellX + add eax,sx + invoke LightCellFree,eax,cellY + test eax,eax + jz lr_blocked + mov eax,cellY + add eax,sy + invoke LightCellFree,cellX,eax + test eax,eax + jz lr_blocked + mov eax,sx + add cellX,eax + add nx,64 +lr_y: + mov eax,sy + add cellY,eax + add ny,64 + jmp lr_test +lr_x: + mov eax,sx + add cellX,eax + add nx,64 +lr_test: + invoke LightCellFree,cellX,cellY + test eax,eax + jz lr_blocked + dec edi + jnz lr_step +lr_blocked: + xor eax,eax + ret +lr_end: + invoke LightCellFree,cellX,cellY + ret +CastLightRay ENDP + +LightDistance PROC uses ebx edx,deltaX:DWORD,deltaY:DWORD + mov eax,deltaX + cdq + xor eax,edx + sub eax,edx + mov ebx,eax + mov eax,deltaY + cdq + xor eax,edx + sub eax,edx + cmp eax,ebx + jae ld_max + xchg eax,ebx +ld_max: + lea ebx,[ebx+ebx*2] + shr ebx,3 + add eax,ebx + ret +LightDistance ENDP + +AddLightColor PROC uses eax ebx ecx edx,color:DWORD,amount:DWORD + mov ebx,255 + mov eax,color + and eax,255 + imul eax,amount + xor edx,edx + div ebx + add lightAccumB,eax + mov eax,color + shr eax,8 + and eax,255 + imul eax,amount + xor edx,edx + div ebx + add lightAccumG,eax + mov eax,color + shr eax,16 + and eax,255 + imul eax,amount + xor edx,edx + div ebx + add lightAccumR,eax + ret +AddLightColor ENDP + +PointLightAmount PROC uses ebx ecx edx esi,source:DWORD,x:DWORD,y:DWORD + LOCAL distance:DWORD + mov esi,source + mov eax,x + sub eax,[esi].LightSource.x + mov ecx,y + sub ecx,[esi].LightSource.y + invoke LightDistance,eax,ecx + mov distance,eax + cmp eax,[esi].LightSource.radius + jae pla_zero + test [esi].LightSource.flags,LIGHT_FLAG_SHADOWS + jz pla_visible + invoke CastLightRay,[esi].LightSource.x,[esi].LightSource.y,x,y + test eax,eax + jz pla_zero +pla_visible: + mov eax,[esi].LightSource.radius + sub eax,distance + imul eax,[esi].LightSource.curIntensity + xor edx,edx + div [esi].LightSource.radius + ret +pla_zero: + xor eax,eax + ret +PointLightAmount ENDP + +RefreshLightGrid PROC uses eax ebx ecx edx esi edi + mov esi,OFFSET lightGridRGB + mov edi,OFFSET lightGrid + mov ecx,MAP_SIZE +rlg_loop: + lodsd + mov edx,eax + and eax,255 + mov ebx,edx + shr ebx,8 + and ebx,255 + add eax,ebx + shr edx,16 + and edx,255 + add eax,edx + xor edx,edx + mov ebx,3 + div ebx + stosb + loop rlg_loop + ret +RefreshLightGrid ENDP + +PackBakedRGB PROC uses ebx edx + mov eax,lightAccumR + cmp eax,255 + jbe @F + mov eax,255 +@@: + shl eax,16 + mov ebx,eax + mov eax,lightAccumG + cmp eax,255 + jbe @F + mov eax,255 +@@: + shl eax,8 + or ebx,eax + mov eax,lightAccumB + cmp eax,255 + jbe @F + mov eax,255 +@@: + or eax,ebx + ret +PackBakedRGB ENDP + +AccumulateBakedCell PROC uses eax ebx ecx edx,x:DWORD,y:DWORD,weight:DWORD + invoke LightCellFree,x,y + test eax,eax + jz abc_done + mov eax,y + shl eax,5 + add eax,x + mov edx,[lightGridRGB+eax*4] + mov eax,edx + and eax,255 + imul eax,weight + add lightAccumB,eax + mov eax,edx + shr eax,8 + and eax,255 + imul eax,weight + add lightAccumG,eax + shr edx,16 + imul edx,weight + add lightAccumR,edx + mov eax,weight + add lightWeight,eax +abc_done: + ret +AccumulateBakedCell ENDP + +BakeMapLighting PROC uses ebx ecx edx esi edi + LOCAL index:DWORD,x:DWORD,y:DWORD + mov index,0 +bl_cell: + mov eax,index + and eax,31 + mov x,eax + mov eax,index + shr eax,5 + mov y,eax + mov eax,ambientLightLevel + mov lightAccumR,eax + mov lightAccumG,eax + mov lightAccumB,eax + invoke LightCellFree,x,y + test eax,eax + jz bl_store + mov eax,x + shl eax,6 + add eax,32 + mov x,eax + mov eax,y + shl eax,6 + add eax,32 + mov y,eax + mov esi,OFFSET lightSources+40 + mov edi,13 +bl_source: + test [esi].LightSource.flags,LIGHT_FLAG_ACTIVE + jz bl_next_source + cmp [esi].LightSource.lightType,LIGHT_TYPE_STATIC + jne bl_next_source + mov eax,[esi].LightSource.intensity + push [esi].LightSource.curIntensity + mov [esi].LightSource.curIntensity,eax + invoke PointLightAmount,esi,x,y + pop [esi].LightSource.curIntensity + invoke AddLightColor,[esi].LightSource.color,eax +bl_next_source: + add esi,40 + dec edi + jnz bl_source +bl_store: + call PackBakedRGB + mov ebx,index + mov [lightGridRGB+ebx*4],eax + inc index + cmp index,MAP_SIZE + jb bl_cell + ; Smooth out of place, without averaging through wall cells. + mov index,0 +bl_smooth: + mov ebx,index + mov eax,[lightGridRGB+ebx*4] + mov [lightSmoothRGB+ebx*4],eax + mov eax,ebx + and eax,31 + mov x,eax + shr ebx,5 + mov y,ebx + invoke LightCellFree,x,y + test eax,eax + jz bl_next_smooth + mov lightAccumR,0 + mov lightAccumG,0 + mov lightAccumB,0 + mov lightWeight,0 + invoke AccumulateBakedCell,x,y,2 + mov eax,x + dec eax + invoke AccumulateBakedCell,eax,y,1 + mov eax,x + inc eax + invoke AccumulateBakedCell,eax,y,1 + mov eax,y + dec eax + invoke AccumulateBakedCell,x,eax,1 + mov eax,y + inc eax + invoke AccumulateBakedCell,x,eax,1 + mov eax,lightAccumR + xor edx,edx + div lightWeight + mov lightAccumR,eax + mov eax,lightAccumG + xor edx,edx + div lightWeight + mov lightAccumG,eax + mov eax,lightAccumB + xor edx,edx + div lightWeight + mov lightAccumB,eax + call PackBakedRGB + mov ebx,index + mov [lightSmoothRGB+ebx*4],eax +bl_next_smooth: + inc index + cmp index,MAP_SIZE + jb bl_smooth + mov esi,OFFSET lightSmoothRGB + mov edi,OFFSET lightGridRGB + mov ecx,MAP_SIZE + rep movsd + call RefreshLightGrid + ret +BakeMapLighting ENDP + +UpdateDynamicLights PROC uses eax ebx ecx edx esi + inc lightingTick + mov esi,OFFSET lightSources+40 + mov ecx,1 +udl_next: + test [esi].LightSource.flags,LIGHT_FLAG_ACTIVE + jz udl_skip + cmp [esi].LightSource.lightType,LIGHT_TYPE_BURST + je udl_burst + mov eax,[esi].LightSource.intensity + cmp [esi].LightSource.lightType,LIGHT_TYPE_STATIC + je udl_store + cmp [esi].LightSource.lightType,LIGHT_TYPE_PULSE + jne udl_flicker + mov edx,lightingTick + shl edx,2 + add edx,ecx + and edx,511 + mov ebx,[tableSin+edx*4] + sar ebx,1 + add ebx,98304 + imul eax,ebx + sar eax,17 +udl_flicker: + test [esi].LightSource.flags,LIGHT_FLAG_FLICKER + jz udl_store + mov edx,lightingTick + shr edx,2 + imul edx,1103515245 + imul ebx,ecx,12345 + add edx,ebx + shr edx,16 + and edx,15 + sub edx,7 + add eax,edx + cmp eax,0 + jge @F + xor eax,eax +@@: + cmp eax,255 + jle udl_store + mov eax,255 + jmp udl_store +udl_burst: + mov eax,[esi].LightSource.curIntensity + sub eax,[esi].LightSource.decaySpeed + jg udl_store + xor eax,eax + mov [esi].LightSource.flags,0 +udl_store: + mov [esi].LightSource.curIntensity,eax +udl_skip: + add esi,40 + inc ecx + cmp ecx,16 + jb udl_next + ret +UpdateDynamicLights ENDP + +SamplePointLighting PROC uses ebx ecx edx esi edi,x:DWORD,y:DWORD,distance:DWORD,side:DWORD + LOCAL forward:DWORD,lateral:DWORD,beam:DWORD,atten:DWORD,fog:DWORD + mov eax,256 + mov lightRed,eax + mov lightGreen,eax + mov lightBlue,eax + cmp lightingMode,0 + je spl_done + mov eax,x + sar eax,6 + and eax,31 + mov edx,y + sar edx,6 + and edx,31 + shl edx,5 + add eax,edx + mov eax,[lightGridRGB+eax*4] + mov edx,eax + and eax,255 + mov lightAccumB,eax + mov eax,edx + shr eax,8 + and eax,255 + mov lightAccumG,eax + shr edx,16 + mov lightAccumR,edx + cmp lightingMode,2 + jne spl_fog + mov esi,OFFSET lightSources+40 + mov edi,15 +spl_source: + test [esi].LightSource.flags,LIGHT_FLAG_ACTIVE + jz spl_next + cmp [esi].LightSource.lightType,LIGHT_TYPE_STATIC + je spl_next + cmp [esi].LightSource.radius,0 + jle spl_next + invoke PointLightAmount,esi,x,y + invoke AddLightColor,[esi].LightSource.color,eax +spl_next: + add esi,40 + dec edi + jnz spl_source + cmp isFlashlightOn,0 + je spl_fog + mov eax,x + sub eax,playerX + mov ebx,y + sub ebx,playerY + invoke LightDistance,eax,ebx + cmp eax,flashlightRadius + jae spl_fog + mov edx,flashlightRadius + sub edx,eax + mov eax,edx + imul eax,flashlightIntensity + xor edx,edx + div flashlightRadius + mov atten,eax + mov ecx,playerAngle + and ecx,511 + mov eax,x + sub eax,playerX + imul [tableCos+ecx*4] + shrd eax,edx,16 + mov forward,eax + mov eax,y + sub eax,playerY + imul [tableSin+ecx*4] + shrd eax,edx,16 + add forward,eax + cmp forward,0 + jle spl_fog + mov eax,y + sub eax,playerY + imul [tableCos+ecx*4] + shrd eax,edx,16 + mov lateral,eax + mov eax,x + sub eax,playerX + imul [tableSin+ecx*4] + shrd eax,edx,16 + sub lateral,eax + mov ecx,flashlightConeAngle + mov eax,forward + imul [tableSin+ecx*4] + idiv DWORD PTR [tableCos+ecx*4] + mov beam,eax + test eax,eax + jle spl_fog + mov eax,lateral + cdq + xor eax,edx + sub eax,edx + cmp eax,beam + jae spl_fog + mov ebx,beam + sub ebx,eax + mov eax,ebx + imul eax,atten + xor edx,edx + div beam + mov atten,eax + invoke CastLightRay,playerX,playerY,x,y + test eax,eax + jz spl_fog + invoke AddLightColor,00FFFFFFh,atten +spl_fog: + mov fog,256 + mov eax,distance + cmp eax,280 + jle spl_side + cmp eax,720 + jb spl_partial + mov fog,32 + jmp spl_side +spl_partial: + sub eax,280 + imul eax,224 + xor edx,edx + mov ebx,440 + div ebx + sub fog,eax +spl_side: + cmp side,1 + jne spl_coeff + mov eax,fog + imul eax,192 + shr eax,8 + mov fog,eax +spl_coeff: + mov eax,lightAccumR + cmp eax,256 + jbe @F + mov eax,256 +@@: + imul eax,fog + shr eax,8 + mov lightRed,eax + mov eax,lightAccumG + cmp eax,256 + jbe @F + mov eax,256 +@@: + imul eax,fog + shr eax,8 + mov lightGreen,eax + mov eax,lightAccumB + cmp eax,256 + jbe @F + mov eax,256 +@@: + imul eax,fog + shr eax,8 + mov lightBlue,eax +spl_done: + mov eax,lightRed + cmp eax,lightGreen + jae @F + mov eax,lightGreen +@@: + cmp eax,lightBlue + jae @F + mov eax,lightBlue +@@: + ret +SamplePointLighting ENDP + +ComputeWallLightLevel PROC uses ebx ecx edx,screenX:DWORD,mapX:DWORD,mapY:DWORD,sideHit:DWORD,stepX:DWORD,stepY:DWORD,rawDist:DWORD,perpWallDist:DWORD,rayAngle:DWORD,rayDirX:DWORD,rayDirY:DWORD + LOCAL x:DWORD,y:DWORD + mov eax,rawDist + imul rayDirX + shrd eax,edx,24 + add eax,playerX + mov x,eax + mov eax,rawDist + imul rayDirY + shrd eax,edx,24 + add eax,playerY + mov y,eax + cmp sideHit,0 + jne cwl_y + mov eax,mapX + shl eax,6 + cmp stepX,0 + jg @F + add eax,64 +@@: + sub eax,stepX + mov x,eax + jmp cwl_ready +cwl_y: + mov eax,mapY + shl eax,6 + cmp stepY,0 + jg @F + add eax,64 +@@: + sub eax,stepY + mov y,eax +cwl_ready: + invoke SamplePointLighting,x,y,perpWallDist,sideHit + ret +ComputeWallLightLevel ENDP + +ComputeSpriteLight PROC sprX:DWORD,sprY:DWORD,sprDist:DWORD + invoke SamplePointLighting,sprX,sprY,sprDist,0 + ret +ComputeSpriteLight ENDP + +ShadeLightPixel PROC uses eax ebx + mov eax,edx + and eax,255 + imul eax,lightBlue + shr eax,8 + mov ebx,eax + mov eax,edx + shr eax,8 + and eax,255 + imul eax,lightGreen + shr eax,8 + shl eax,8 + or ebx,eax + mov eax,edx + shr eax,16 + and eax,255 + imul eax,lightRed + shr eax,8 + shl eax,16 + or eax,ebx + mov edx,eax + ret +ShadeLightPixel ENDP diff --git a/include/engine_map.inc b/include/engine_map.inc index c1e3f2d..61cad6a 100644 --- a/include/engine_map.inc +++ b/include/engine_map.inc @@ -14,6 +14,10 @@ ValidateStagedCutscene PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD CheckPortalTriggers PROTO InitEntities PROTO ShowGameToast PROTO :DWORD, :DWORD, :DWORD +BakeMapLighting PROTO +InitLightingSystem PROTO +ValidateLightingData PROTO :DWORD,:DWORD,:DWORD +CommitLightingData PROTO :DWORD,:DWORD,:DWORD,:DWORD ; ------------------------------------------------------------------------------ ; InitMap: Creates a rich test level with rooms, hallways, pillars & doors @@ -101,6 +105,7 @@ loop_wall2: mov portalLatchCell, -1 mov mapCeilingColor, COLOR_CEILING mov mapFloorColor, COLOR_FLOOR + call InitLightingSystem ret InitMap ENDP @@ -526,7 +531,7 @@ ApplyScreenFade ENDP 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 numEvents:DWORD, numStrings:DWORD, sceneFlags:DWORD, numLights:DWORD LOCAL resolvedPath[MAX_PATH_BYTES]:BYTE LOCAL stagedMap[MAP_SIZE]:BYTE LOCAL stagedSprites[MAX_SPRITES * (sizeof SpriteStruct)]:BYTE @@ -647,6 +652,16 @@ old_map_no_transitions: movzx eax, header.cutsceneFlags mov sceneFlags, eax old_map_no_cutscene: + mov numLights,0 + cmp header.version,5 + jb old_map_no_lighting + cmp header.lightingReserved,0 + jne close_load_failed + movzx eax,header.lightCount + cmp eax,13 + ja close_load_failed + mov numLights,eax +old_map_no_lighting: mov eax, numTransitions imul eax, sizeof LevelTransition mov ebx, eax @@ -660,6 +675,13 @@ old_map_no_cutscene: mov eax, numStrings imul eax, CUTSCENE_STRING_BYTES add ebx, eax + cmp header.version,5 + jb map_lighting_size_ready + mov eax,numLights + imul eax,40 + add ebx,eax + add ebx,32+4096 +map_lighting_size_ready: invoke GetFileSize, hFile, 0 cmp eax, ebx jne close_load_failed @@ -700,6 +722,44 @@ old_map_no_cutscene: jz close_load_failed cmp bytesRead, ebx jne close_load_failed + mov esi,OFFSET legacyLightingSettings + mov edi,OFFSET stagedLightSettings + mov ecx,8 + rep movsd + mov edi,OFFSET stagedLightRGB + mov ecx,MAP_SIZE + mov eax,00282828h + rep stosd + cmp header.version,5 + jb staged_lighting_ready + invoke ReadFile,hFile,OFFSET stagedLightSettings,32,ADDR bytesRead,0 + test eax,eax + jz close_load_failed + cmp bytesRead,32 + jne close_load_failed + mov ebx,numLights + imul ebx,40 + invoke ReadFile,hFile,OFFSET stagedLightSources,ebx,ADDR bytesRead,0 + test eax,eax + jz close_load_failed + cmp bytesRead,ebx + jne close_load_failed + invoke ReadFile,hFile,OFFSET stagedLightRGB,4096,ADDR bytesRead,0 + test eax,eax + jz close_load_failed + cmp bytesRead,4096 + jne close_load_failed + invoke ValidateLightingData,OFFSET stagedLightSettings,OFFSET stagedLightSources,numLights + test eax,eax + jz close_load_failed + mov esi,OFFSET stagedLightRGB + mov ecx,MAP_SIZE +map_check_light_rgb: + lodsd + test eax,0FF000000h + jnz close_load_failed + loop map_check_light_rgb +staged_lighting_ready: invoke ValidateStagedCutscene, numEvents, numStrings, sceneFlags, ADDR stagedMap, numTransitions test eax, eax jz close_load_failed @@ -903,6 +963,7 @@ commit_level: mov eax, newAngle mov playerAngle, eax mov mapErrorFrames, 0 + invoke CommitLightingData,OFFSET stagedLightSettings,OFFSET stagedLightSources,numLights,OFFSET stagedLightRGB mov eax, 1 ret close_load_failed: @@ -971,6 +1032,8 @@ load_startup_map: jnz startup_map_ready call InitMap startup_map_ready: + push eax call InitEntities + pop eax ret LoadMapBinary ENDP diff --git a/include/engine_preview.inc b/include/engine_preview.inc index b5d6579..cfd9ec3 100644 --- a/include/engine_preview.inc +++ b/include/engine_preview.inc @@ -16,14 +16,14 @@ TryEnginePreview PROC uses ebx ecx edx esi edi test eax, eax jz preview_exit_error mov previewHandle, eax - invoke MapViewOfFile, eax, 6, 0, 0, 1357792 + invoke MapViewOfFile, eax, 6, 0, 0, 1362408 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 + cmp DWORD PTR [ebx+4], 2 jne preview_exit_error invoke OpenProcess, 00100000h, 0, DWORD PTR [ebx+12] ; SYNCHRONIZE test eax, eax @@ -106,6 +106,39 @@ preview_textures: mov ecx, MAX_TEXTURES*TEX_SIZE_BYTES/4 rep movsd preview_scene: + mov eax,[ebx+52] + mov stagedLightSettings,eax + mov eax,[ebx+56] + mov stagedLightSettings[4],eax + mov eax,[ebx+60] + mov stagedLightSettings[8],eax + mov eax,[ebx+68] + mov stagedLightSettings[12],eax + mov eax,[ebx+72] + mov stagedLightSettings[16],eax + mov eax,[ebx+76] + mov stagedLightSettings[20],eax + mov stagedLightSettings[24],1 + mov stagedLightSettings[28],0 + lea esi,[ebx+1357792] + invoke ValidateLightingData,OFFSET stagedLightSettings,esi,DWORD PTR [ebx+64] + test eax,eax + jz preview_exit_error + lea esi,[ebx+1358312] + mov edi,MAP_SIZE +preview_check_light_rgb: + lodsd + test eax,0FF000000h + jnz preview_exit_error + dec edi + jnz preview_check_light_rgb + lea esi,[ebx+1357792] + lea edi,[ebx+1358312] + invoke CommitLightingData,OFFSET stagedLightSettings,esi,DWORD PTR [ebx+64],edi + mov eax,[ebx+84] + dec eax + mov lightingTick,eax + call UpdateDynamicLights call Render3DScene mov ebx, previewMemory cmp DWORD PTR [ebx+40], 0 diff --git a/include/engine_raycaster.inc b/include/engine_raycaster.inc index 5432736..e12bd6a 100644 --- a/include/engine_raycaster.inc +++ b/include/engine_raycaster.inc @@ -36,12 +36,18 @@ Render3DScene PROC uses ebx ecx edx esi edi LOCAL glassLineHeight:DWORD LOCAL glassDrawStart:DWORD, glassDrawEnd:DWORD LOCAL glassTexX:DWORD, glassSideHit:DWORD + LOCAL colLightLevel:DWORD ; -------------------------------------------------------------------------- ; 1. Fast Background Clear: Ceiling (Top Half) and Floor (Bottom Half) ; -------------------------------------------------------------------------- + invoke SamplePointLighting,playerX,playerY,0,0 mov edi, OFFSET screenBuffer - mov eax, mapCeilingColor + mov edx,mapCeilingColor + call ShadeLightPixel + mov eax,edx + cmp lightingMode,0 + jne @F cmp playerFlashTimer, 0 je @F add eax, 00202020h ; Dynamic Muzzle Flash ambient lighting boost @@ -49,7 +55,11 @@ Render3DScene PROC uses ebx ecx edx esi edi mov ecx, (SCREEN_W * SCREEN_HALF_H) rep stosd - mov eax, mapFloorColor + mov edx,mapFloorColor + call ShadeLightPixel + mov eax,edx + cmp lightingMode,0 + jne @F cmp playerFlashTimer, 0 je @F add eax, 00202020h ; Dynamic Muzzle Flash ambient lighting boost @@ -473,6 +483,11 @@ wall_texture_ready: imul eax, texStep mov texPos, eax + ; Compute wall lighting (baked lightmap + dynamic point lights + flashlight) + invoke ComputeWallLightLevel, screenX, mapX, mapY, sideHit, stepX, stepY,\ + rawDist, perpWallDist, rayAngle, rayDirX, rayDirY + mov colLightLevel, eax + ; -------------------------------------------------------------------------- ; Draw Vertical Wall Stripe ; -------------------------------------------------------------------------- @@ -501,6 +516,9 @@ wall_pixel_loop: add eax, texPtr mov edx, [eax] ; Sampled Color (BGRA) + cmp lightingMode, LIGHTING_MODE_NONE + jne apply_col_light + ; Wall shading: Y-facing walls darkened 50% for 3D depth perception cmp sideHit, 1 jne apply_distance_fog @@ -521,6 +539,10 @@ apply_distance_fog: make_pitch_black: mov edx, 00111111h + jmp write_wall_pixel + +apply_col_light: + call ShadeLightPixel write_wall_pixel: mov eax, pScreenPixel @@ -599,6 +621,7 @@ RenderSprites PROC uses ebx ecx edx esi edi passFilter:DWORD LOCAL texPtr:DWORD, pPixel:DWORD LOCAL camCos:DWORD, camSin:DWORD LOCAL keyIdx:DWORD, keyDist:DWORD + LOCAL sprLightLevel:DWORD mov eax, spriteCount test eax, eax @@ -844,6 +867,9 @@ sprite_loop: mov texStepY, eax ; Render vertical stripes + invoke ComputeSpriteLight, spriteX, spriteY, transY + mov sprLightLevel, eax + mov eax, drawStartX mov stripe, eax @@ -924,6 +950,9 @@ sprite_pixel_y: cmp edx, COLOR_CHROMA_KEY je skip_sprite_pixel + cmp lightingMode, LIGHTING_MODE_NONE + jne apply_spr_lighting + ; Distance fog (same thresholds as walls) mov eax, transY cmp eax, 350 @@ -935,6 +964,10 @@ sprite_pixel_y: jmp write_sprite_pixel sprite_pixel_dark: mov edx, 00111111h + jmp write_sprite_pixel + +apply_spr_lighting: + call ShadeLightPixel write_sprite_pixel: mov eax, pPixel diff --git a/main.asm b/main.asm index cd16f2d..046be79 100644 --- a/main.asm +++ b/main.asm @@ -112,6 +112,7 @@ include include/engine_textures.inc include include/engine_input.inc include include/engine_physics.inc include include/engine_entity.inc +include include/engine_light.inc include include/engine_raycaster.inc include include/engine_render2d.inc include include/engine_combat.inc @@ -158,7 +159,8 @@ Engine_Init PROC uses ebx esi edi call InitMathTables call GenerateDefaultTextures ; Procedural baseline (always valid) call LoadTexturePack ; Optional: override from assets\textures\texturepack.bin - call LoadMapBinary ; Loads assets/map.dat (falls back to InitMap if missing) + call InitLightingSystem + call LoadMapBinary ; Each room commits its own validated lighting ; 2. Register Window Class invoke GetModuleHandleA, 0 @@ -289,6 +291,7 @@ no_error_tick: call UpdatePlayer call UpdateEntities call UpdateCombat + call UpdateDynamicLights skip_movement_in_dialog: call Game_OnTick @@: diff --git a/main.obj b/main.obj index f31e5e4..99f30a1 100644 Binary files a/main.obj and b/main.obj differ diff --git a/tests/cutscene_editor_checks.inl b/tests/cutscene_editor_checks.inl index 49d4c5e..eadd4e7 100644 --- a/tests/cutscene_editor_checks.inl +++ b/tests/cutscene_editor_checks.inl @@ -11,7 +11,7 @@ static void RunCutsceneChecks(EditorApp& app) { }; 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 && + Check(ReadLevelFile("../assets/scene.dat",level,error) && level.header.version == MAP_FORMAT_VERSION && 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; diff --git a/tests/editor_regressions.cpp b/tests/editor_regressions.cpp index b024c18..a83471a 100644 --- a/tests/editor_regressions.cpp +++ b/tests/editor_regressions.cpp @@ -22,6 +22,7 @@ static std::vector Bytes(const fs::path& path) { } #include "cutscene_editor_checks.inl" +#include "lighting_editor_checks.inl" #include "viewport_editor_checks.inl" int main(int argc, char** argv) { @@ -138,6 +139,7 @@ int main(int argc, char** argv) { app.LaunchPlaytest(); Check(!app.isEngineRunning && app.playtestDirectory.empty() && Bytes("../assets/map.dat") == originalMain, "missing linked map aborts F5 without data loss"); + RunLightingChecks(app); RunCutsceneChecks(app); if (argc > 1) { Check(ReadLevelFile(std::string(argv[1]) + "/map.dat", preview, error), "existing project main map remains compatible"); diff --git a/tests/engine_regressions.asm b/tests/engine_regressions.asm index f64d95a..575db0a 100644 --- a/tests/engine_regressions.asm +++ b/tests/engine_regressions.asm @@ -17,6 +17,7 @@ include include/engine_textures.inc include include/engine_input.inc include include/engine_physics.inc include include/engine_entity.inc +include include/engine_light.inc include include/engine_raycaster.inc include include/engine_render2d.inc include include/engine_combat.inc @@ -127,6 +128,7 @@ state_changed: StateUnchanged ENDP include tests/cutscene_engine_checks.inc +include tests/lighting_engine_checks.inc AuditMain PROC call InitMathTables @@ -363,6 +365,7 @@ flat_depth_ok: call Render3DScene invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE + call RunLightingChecks call RunCutsceneChecks mov eax, resultCount inc eax diff --git a/tests/lighting_editor_checks.inl b/tests/lighting_editor_checks.inl new file mode 100644 index 0000000..e1893f6 --- /dev/null +++ b/tests/lighting_editor_checks.inl @@ -0,0 +1,65 @@ +static void RunLightingChecks(EditorApp& app) { + app.InitDefaultLevel();app.sprites.clear();app.transitions.clear();app.lightSources.clear(); + memset(app.map,0,sizeof(app.map)); + for(int i=0;i<32;++i){app.map[0][i]=app.map[31][i]=app.map[i][0]=app.map[i][31]=1;app.map[i][10]=1;} + app.playerStartX=2;app.playerStartY=5;app.playerStartAngle=0; + app.lightingMode=2;app.ambientLightLevel=10;app.isFlashlightOn=false; + EditorLightSource fixed;fixed.x=352;fixed.y=352;fixed.radius=900;fixed.intensity=220;fixed.curIntensity=220; + fixed.lightType=LIGHT_TYPE_STATIC;fixed.flags=LIGHT_FLAG_ACTIVE|LIGHT_FLAG_SHADOWS;fixed.color=0xFF0000; + app.lightSources.push_back(fixed);app.BakeLighting(); + auto red=app.bakedLightRGB[5*32+5]; + Check(((red>>16)&255)>100 && (red&0xFFFF)==0x0A0A,"static bake preserves RGB light color"); + Check(app.bakedLightRGB[5*32+14]==0x0A0A0A,"baked light cannot cross a wall or smooth through it"); + auto before=app.bakedLightRGB; + EditorLightSource moving=fixed;moving.x=480;moving.lightType=LIGHT_TYPE_POINT;moving.color=0x0000FF; + app.lightSources.push_back(moving);app.BakeLighting(); + Check(app.bakedLightRGB==before,"dynamic lights never enter the static bake"); + app.map[5][7]=CELL_DOOR; + Check(!app.CastLightRay(352,352,608,352),"closed door blocks editor light rays"); + app.map[5][7]=CELL_DOOR_OPEN; + Check(app.CastLightRay(352,352,608,352),"open door transmits editor light rays"); + app.map[5][7]=0;app.map[5][6]=1; + Check(!app.CastLightRay(352,352,416,416),"diagonal light cannot leak through a blocked corner"); + app.map[5][6]=0; + app.lightSources[0].flags=LIGHT_FLAG_ACTIVE;app.BakeLighting(); + Check(((app.bakedLightRGB[5*32+14]>>16)&255)>10,"disabling shadows permits light across walls"); + app.lightSources[0].flags|=LIGHT_FLAG_SHADOWS;app.BakeLighting(); + app.PushUndo();app.lightSources[0].color=0x00FF00;app.ambientLightLevel=25;app.lightingMode=1;app.BakeLighting(); + app.Undo(); + Check(app.lightSources[0].color==0xFF0000 && app.ambientLightLevel==10 && app.lightingMode==2 && app.bakedLightRGB==before, + "undo restores sources, settings and the exact baked RGB data"); + app.Redo();Check(app.lightSources[0].color==0x00FF00 && app.ambientLightLevel==25,"redo restores authored lighting"); + app.Undo(); + app.lightSources[0].x+=1; + Check(app.LightingNeedsBake(),"editing static lights marks the bake out of date"); + app.lightSources[0].x-=1;app.lightSources[1].x+=64; + Check(!app.LightingNeedsBake(),"moving a realtime source does not invalidate static lighting"); + app.lightSources[1].x-=64; + Check(app.SaveLevel("../assets/lit.dat"),"save v5 authored lights and persistent RGB bake"); + std::ofstream reference("../assets/lighting_reference.bin",std::ios::binary); + reference.write(reinterpret_cast(app.bakedLightRGB.data()),4096);reference.close(); + auto valid=Bytes("../assets/lit.dat"); + BinaryLevel loaded;std::string error; + Check(ReadLevelFile("../assets/lit.dat",loaded,error) && loaded.header.version==5 && loaded.lighting.lights.size()==2 && + loaded.lighting.settings.flashlight==0 && loaded.lighting.rgb==app.bakedLightRGB,"v5 lighting round trip is lossless"); + app.lightSources[0].radius=0; + Check(!app.SaveLevel("../assets/lit.dat") && Bytes("../assets/lit.dat")==valid,"invalid light cannot overwrite an existing level"); + app.lightSources[0].radius=900; + app.lightSources[0].lightType=LIGHT_TYPE_BURST; + Check(!app.SaveLevel("../assets/lit.dat") && Bytes("../assets/lit.dat")==valid,"internal burst slots cannot be serialized as authored lights"); + app.lightSources[0].lightType=LIGHT_TYPE_STATIC; + auto bad=valid;bad[34]=14; + {std::ofstream out("../assets/lit_badcount.dat",std::ios::binary);out.write(reinterpret_cast(bad.data()),bad.size());} + bad=valid;uint32_t invalidRadius=0; // settings start after header and grid; no sprites, transitions or script + memcpy(bad.data()+38+1024+32+8,&invalidRadius,4); + {std::ofstream out("../assets/lit_badradius.dat",std::ios::binary);out.write(reinterpret_cast(bad.data()),bad.size());} + bad=valid;bad.back()=255; + {std::ofstream out("../assets/lit_badrgb.dat",std::ios::binary);out.write(reinterpret_cast(bad.data()),bad.size());} + Check(!app.LoadLevel("../assets/lit_badcount.dat") && app.lightSources.size()==2,"malformed lighting count preserves the editor scene"); + Check(!app.LoadLevel("../assets/lit_badradius.dat"),"malformed light radius rejected before scene replacement"); + Check(!app.LoadLevel("../assets/lit_badrgb.dat"),"invalid baked RGB padding rejected before scene replacement"); + std::string start,engine; + Check(app.PreparePlaytest(start,engine),"F5 packages v5 lighting with the current unsaved map");app.CleanupPlaytest(); + app.LoadLevel("../assets/map.dat"); + Check(app.lightSources.empty() && app.lightingMode==0,"legacy maps clear old lights and retain classic rendering"); +} diff --git a/tests/lighting_engine_checks.inc b/tests/lighting_engine_checks.inc new file mode 100644 index 0000000..5bd6a1e --- /dev/null +++ b/tests/lighting_engine_checks.inc @@ -0,0 +1,103 @@ +.data +lightFile db 'lit.dat',0 +lightBadCount db 'lit_badcount.dat',0 +lightBadRadius db 'lit_badradius.dat',0 +lightBadRGB db 'lit_badrgb.dat',0 +lightBakeDump db 'lighting_bake.bin',0 +lightFrameDump db 'lighting_frame.bin',0 +lightSavedRed dd 0 +lightSavedBlue dd 0 +lightSavedPulse dd 0 +.code +RunLightingChecks PROC uses ebx ecx edx esi edi + invoke LoadSublevelBinary,OFFSET lightFile,-1,-1,0 + CHECK eax,1 ; v5 authored lighting loads in ASM + CHECK authoredLightCount,2 ; all authored lights reach runtime slots + CHECK lightingMode,2 ; map lighting mode loads without a startup override + CHECK isFlashlightOn,0 ; saved flashlight toggle reaches the game + CHECK DWORD PTR [lightSources+40+LightSource.color],00FF0000h ; static light color retained on load + call Render3DScene + invoke Dump,OFFSET lightFrameDump,OFFSET screenBuffer,FRAME_BUFFER_SIZE + call BakeMapLighting + invoke Dump,OFFSET lightBakeDump,OFFSET lightGridRGB,4096 + CHECK DWORD PTR [lightGridRGB+(5*32+14)*4],000A0A0Ah ; static light cannot leak through a wall + mov lightingMode,1 + invoke SamplePointLighting,480,352,0,0 + mov eax,lightRed + mov lightSavedRed,eax + mov eax,lightBlue + mov lightSavedBlue,eax + mov lightingMode,2 + call UpdateDynamicLights + invoke SamplePointLighting,480,352,0,0 + CHECK lightRed,lightSavedRed ; dynamic mode does not count static light twice + xor eax,eax + mov ebx,lightBlue + cmp ebx,lightSavedBlue + seta al + CHECK eax,1 ; realtime blue light contributes beyond the baked map + mov DWORD PTR [lightSources+80+LightSource.x],928 + invoke SamplePointLighting,480,352,0,0 + CHECK lightBlue,10 ; dynamic source is occluded by a separating wall + mov DWORD PTR [lightSources+80+LightSource.flags],LIGHT_FLAG_ACTIVE + invoke SamplePointLighting,480,352,0,0 + xor eax,eax + cmp lightBlue,10 + seta al + CHECK eax,1 ; disabling dynamic shadows allows unoccluded contribution + mov DWORD PTR [lightSources+80+LightSource.lightType],LIGHT_TYPE_PULSE + mov lightingTick,0 + call UpdateDynamicLights + mov eax,DWORD PTR [lightSources+80+LightSource.curIntensity] + mov lightSavedPulse,eax + mov lightingTick,31 + call UpdateDynamicLights + mov eax,DWORD PTR [lightSources+80+LightSource.curIntensity] + cmp eax,lightSavedPulse + setne al + movzx eax,al + CHECK eax,1 ; pulse light changes intensity with the simulation clock + mov DWORD PTR [lightSources+13*40+LightSource.intensity],77 + invoke SpawnMuzzleFlashLight,160,160 + invoke SpawnExplosionLight,160,160 + CHECK DWORD PTR [lightSources+13*40+LightSource.intensity],77 ; combat bursts cannot overwrite authored light slots + call UpdateDynamicLights + CHECK DWORD PTR [lightSources+14*40+LightSource.curIntensity],180 ; muzzle light decays in its reserved slot + call InitLightingSystem + mov lightingMode,2 + mov edi,OFFSET lightGridRGB + mov ecx,1024 + mov eax,000A0A0Ah + rep stosd + mov playerX,160 + mov playerY,160 + mov playerAngle,0 + invoke SamplePointLighting,288,160,0,0 + xor eax,eax + cmp lightRed,10 + seta al + CHECK eax,1 ; flashlight illuminates points inside its forward cone + invoke SamplePointLighting,160,288,0,0 + CHECK lightRed,10 ; flashlight cannot illuminate sprites outside its cone + mov BYTE PTR [gameMap+2*32+3],CELL_DOOR + invoke SamplePointLighting,288,160,0,0 + CHECK lightRed,10 ; a closed door blocks the player flashlight + mov BYTE PTR [gameMap+2*32+3],CELL_DOOR_OPEN + invoke CastLightRay,160,160,288,160 + CHECK eax,1 ; an open door transmits dynamic light + mov BYTE PTR [gameMap+5*32+6],CELL_BRICK + invoke CastLightRay,352,352,416,416 + CHECK eax,0 ; ASM light rays reject diagonal corner leaks + invoke LoadSublevelBinary,OFFSET lightFile,-1,-1,0 + invoke LoadSublevelBinary,OFFSET lightBadCount,-1,-1,0 + CHECK eax,0 ; excessive lighting count rejected before committing a room + CHECK authoredLightCount,2 ; malformed lighting preserves current authored lights + invoke LoadSublevelBinary,OFFSET lightBadRadius,-1,-1,0 + CHECK eax,0 ; zero radius rejected without a divide by zero + invoke LoadSublevelBinary,OFFSET lightBadRGB,-1,-1,0 + CHECK eax,0 ; malformed baked RGB cannot replace current lighting + invoke LoadSublevelBinary,OFFSET mainPath,-1,-1,0 + CHECK authoredLightCount,0 ; loading a legacy room clears stale dynamic sources + CHECK lightingMode,0 ; legacy room restores classic lighting mode + ret +RunLightingChecks ENDP diff --git a/tests/viewport_editor_checks.inl b/tests/viewport_editor_checks.inl index 9df81ff..80ac259 100644 --- a/tests/viewport_editor_checks.inl +++ b/tests/viewport_editor_checks.inl @@ -49,6 +49,13 @@ static void RunViewportChecks(EditorApp& app, const char* actualEngine) { Check(equal,"DX11 texture pixels match the ASM framebuffer with opaque alpha"); context->Release(); } + app.LoadLevel("../assets/lit.dat");app.showCutsceneTimeline=false;app.lightingMode=1; + Check(pump(),"saved static lighting arrives in the live viewport"); + auto bakedFrame=app.previewPixels;app.lightingMode=2; + Check(pump() && app.previewPixels!=bakedFrame && app.previewPacket->lightCount==2, + "authored dynamic lights visibly affect the embedded game view"); + auto dynamicFrame=app.previewPixels;app.lightSources[1].x+=256; + Check(pump() && app.previewPixels!=dynamicFrame,"moving a dynamic light updates the image without rebaking"); app.StopEnginePreview(); Check(!app.previewProcess.hProcess && !app.previewPacket && !app.previewMapping,"closing viewport releases renderer process and mapping"); app.ShutdownD3D();device->Release();app.showCutsceneTimeline=false; diff --git a/tools/test_preview_ipc.py b/tools/test_preview_ipc.py index ae14de1..06521cc 100644 --- a/tools/test_preview_ipc.py +++ b/tools/test_preview_ipc.py @@ -9,16 +9,18 @@ import subprocess import time ROOT = Path(__file__).resolve().parents[1] -SIZE, TEXTURES, PIXELS = 1357792, 2016, 1050592 +SIZE, TEXTURES, PIXELS = 1362408, 2016, 1050592 -def check_preview(engine, expected=None, output=None): +def check_preview(engine, expected=None, output=None, level_path=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, + struct.pack_into("<4I3i6I", packet, 0, 0x56504245, 2, 0, os.getpid(), 160, 1024, 0, 0x383838, 0x222222, 0, 0, 0, 1) + struct.pack_into("<8I",packet,52,0,40,0,0,384,240,45,0) + packet[1358312:1358312+4096]=struct.pack(">16,y>>16) packet[1152:1152+len(sprites)]=sprites + if version>=5: + transitions,events,strings=struct.unpack_from("<3H",data,26) + lights=struct.unpack_from("