diff --git a/AGENTS.md b/AGENTS.md index 74cf1f9..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. @@ -218,22 +219,86 @@ EblanStudio has evolved from a pure level editor into a **Full Game Engine Studi - `3: Ammo Box`: collectible ammunition pickup (+20 rounds up to 100). - `4: Key`: key card for unlocking locked doors. - `5: Switch`: interactive trigger/lever toggled by proximity action ([E]). + - `6: Explosive Barrel`: destructible barrel that explodes on weapon hit with 60 AOE damage to surrounding entities and fiery screen flash. - Packed into `SpriteStruct.flags` without altering binary map compatibility (`sizeof(SpriteEntry) == 24`). -2. **Player Combat & HUD (`include/engine_combat.inc`)**: - - Weapon attack via `Space`, `Ctrl`, or Left Mouse Button (`VK_LBUTTON`). - - Center crosshair and hitscan raycasting against active enemy guards. - - Animated retro pistol with muzzle flash, recoil, and cooldowns. +2. **Player Combat & Multi-Weapon Arsenal (`include/engine_combat.inc`)**: + - Weapon switching via keys `[1]`, `[2]`, `[3]`: + - **[1] Боевой нож (Combat Knife)**: fast melee attack (cooldown 8, 30 damage, no ammo required). + - **[2] Пистолет (Pistol)**: standard handgun (cooldown 12, 35 damage, 1 ammo). + - **[3] Дробовик (Pump Shotgun)**: heavy scatter weapon (cooldown 22, 70 damage, 2 ammo, wide spread). + - Dynamic Muzzle Flash Room Illumination: shooting dynamically brightens ceiling and floor shading across the whole 3D environment for realistic muzzle flare lighting. + - Center crosshair and hitscan raycasting against active enemy guards and barrels. - Damage screen flash (red overlay), death state screen, and instant level restart with `[R]`. - - Comprehensive combat HUD: Health bar, Ammo counter, Score counter, and Key indicator. + - In-Game Retro Pause Menu via `[ESC]`: sleek semi-transparent pause menu with `[1] ПРОДОЛЖИТЬ`, `[2] ЗАНОВО`, `[3] МИНИ-КАРТА`, `[4] ВЫХОД В WINDOWS`. + - Comprehensive combat HUD: Active weapon badge with slot indicators `[1] [2] [3]`, Health bar, Ammo counter, Score counter, and Key indicator. 3. **In-Editor MASM32 IDE (`F7` / "Код / IDE")**: - - Integrated in-engine code editor (`editor/src/editor_code.inl`) accessible via `F7` or the main menu. + - Integrated in-engine code editor (`editor/src/editor_code.inl`) accessible via `F7`, `Ctrl+B`, or the main menu. - MASM syntax highlighting (instructions, registers, directives, comments, strings, numbers). - Project file navigator (`engine_entity.inc`, `engine_combat.inc`, `main.asm`, etc.). - - Script templates (Custom Guard AI, Mega-Medkit, Secret Door Trigger, Custom HUD). + - **Engine API Reference Catalog**: built-in searchable catalog of all engine procedures with 1-click **"Вставить"** snippet insertion. + - Real-time search bar (`Поиск в коде...`) with instant match counter. + - Script templates (Custom Guard AI, Mega-Medkit, Secret Door Trigger, Explosive Barrel, Custom HUD). - Asynchronous 1-click compiler runner (`build.bat` / `ml.exe`) with interactive output console and clickable error lines. -4. **Project Settings & Standalone Game Exporter (`editor/src/project_manager.h`)**: +4. **Editor UI, 2D Canvas & Project Exporter (`editor/src/editor_app.cpp`)**: + - Modern Game Studio dark theme with Electric Cyan and Neon Amber accents. + - **2D Canvas Guard Aggro Circles**: visualizes guard detection and chase radius on the 2D map. + - **Quick Entity Spawner Palette**: 1-click spawn buttons (`+ Враг`, `+ Аптечка`, `+ Патроны`, `+ Ключ`, `+ Рычаг`, `+ Бочка`, `+ Проп`). + - Canvas Zoom buttons (`[-]`, `[100%]`, `[+]`) and pan controls. - Project configuration file (`project.eblanproj`) defining title, author, version, window title, and startup map. - "Экспорт автономной игры (.exe)" builds a standalone release folder containing the renamed game executable, packaged assets (maps, textures, sounds), launcher batch file, and readme. + +5. **Gameplay Scripting Subsystem & Interactive Modals (`include/engine_gameplay.inc`)**: + - `ShowGameToast(pszText, durationTicks, color)`: floating framed notification banners (auto-centered, custom CP1251 string, colored outline, auto-dismiss). + - `ShowGameDialog(pszSpeaker, pszText)`: modal dialogue & lore note reader freezing player movement until dismissed with `[E]` / `[ENTER]`. + - Extensible engine lifecycle hooks: + - `Game_OnTick`: 60 Hz frame tick hook for custom game logic, timers, and puzzle triggers. + - `Game_OnPlayerInteract(entityIdx, entityType)`: intercepts `[E]` interactions before default behavior. + - `Game_OnEntityKilled(entityIdx, entityType)`: triggers on guard death or barrel explosion for boss encounters. + - `Game_OnCustomHUD`: custom 2D canvas drawing (health bars, gauges, quest markers). + - `PlayGameSFX(pszRelativePath)`: safe asynchronous WinMM audio player. + - Built-in automatic gameplay toasts for keys, medkits, ammo, switches, doors, and explosions. + +6. **Game Genres, Peaceful Adventure Mode & Immersive Clean HUD**: + - `GAME_MODE_ADVENTURE (0)`: pure exploration / quest / horror / puzzle mode without weapons, ammo, crosshair, or combat overlays. + - `GAME_MODE_ACTION (1)`: full combat shooter mode with weapon models, crosshair, health bar, and ammo counter. + - `HUD_STYLE_CLEAN (0)`: 100% immersive full-screen view (no debug banners, no text collisions, no bottom controls clutter). + - `HUD_STYLE_DEBUG (1)`: developer mode displaying FPS counter at top-right (X=250), engine title, score, and controls hints. + - Instant in-game switching: + - `[ESC] -> [2]`: toggle weapons / peaceful mode on the fly with toast notifications. + - `[ESC] -> [3]`: toggle clean vs debug HUD. + - `[F1]` or `[H]`: quick hotkey to toggle clean HUD vs debug HUD. + - 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/docs/STABILIZATION.md b/docs/STABILIZATION.md index 2467ff7..44f71cc 100644 --- a/docs/STABILIZATION.md +++ b/docs/STABILIZATION.md @@ -49,6 +49,63 @@ assets. New tests exercise production gameplay and IDE procedures, including failed atomic replacement under a Windows sharing lock and hidden compiler output exceeding pipe capacity. Interactive UI playtesting remains separate. +## Preparation for stage 2 — integration with current main + +Integrated server history through `2bac213` with stage-1 commit `f42679f` in an +isolated worktree, preserving both histories and the original working directory. +Retained map v5, preview IPC v2, authored lighting, three weapons, explosive +barrels, modal dialogues, peaceful mode and the updated studio UI. + +Compatibility adjustments: + +- Pause and modal dialogue join the shared gameplay gate, including direct calls + to gameplay procedures. Blocked player updates cannot activate portal triggers. +- Knife/pistol/shotgun keep their range, damage, ammunition and reserved light + behavior. World-space half-widths are respectively 24/16/32 times sprite scale; + the shotgun remains a single-target wider hit test, not three separate rays. +- Barrel texture fallback is enabled only with zero flags in both editor and ASM. + Explicit Prop overrides barrel artwork and cannot explode. +- A new explosion regression reproduced access violation `0xC0000005`: the AOE + loop kept a sprite pointer in EDX and destroyed it with CDQ. The pointer now + stays in EBX. A nearby guard receives blast damage without crashing. +- IDE tab navigation, API catalog and docked view retain stage-1 safe writes. + +Verification: both production builds succeeded; editor **137/137**, ASM +**191/191**. All 1024 baked RGB cells match between C++ and ASM; classic and lit +preview frames match direct ASM rendering, including the x86/x64/DX11 path. +Project assets remain byte-for-byte unchanged. The original working directory +still has only its pre-existing `editor/imgui.ini` modification. No separate +interactive UI playtest was performed during this preparation. + +### Stage 2 implementation contract (not implemented here) + +1. Separate message/input polling, fixed simulation ticks and drawing/presentation. + Use QPC and a 60 Hz accumulator with bounded catch-up. Consume edge-triggered + commands once, not once per catch-up tick. Reset timing debt after pause/focus + stalls; advance world timers/lights only on the appropriate simulation tick. +2. Keep movement fractions in runtime state without changing serialized raw-unit + coordinates. Cancel opposing inputs, normalize combined forward/strafe input, + and use symmetric signed accumulation. Clear fractions on load/teleport/restart. +3. Check the full collision body on each axis and preserve wall sliding; prevent + corner clipping and occupied-door closure. Keep portals intentionally walkable. +4. Centralize Playing/Cutscene/Transition/Dead/Paused/Dialog update rules, including + `Game_OnTick`, dynamic lights, fade, toasts and modal dismissal. Render routines + must not advance those timers or perform input-driven state changes. +5. Separate cinematic camera pose from gameplay/player position. Define END/skip + handoff explicitly and validate the restored gameplay collision body, including + paths crossing walls. Keep C++ preview and ASM cinematic traces aligned. +6. Track the current room path and its loaded entry spawn separately from startup + map. Restart restores that room and entry pose, resets gameplay transients and + fractional movement, and preserves project mode/settings. Failed reload must + not partially reset the current room. Make death-R and pause restart share it. + +Acceptance checks: identical simulation after 60 ticks under different render +schedules; single consumption of edge inputs during catch-up; symmetric movement +for all quadrants and equal cardinal/diagonal speed; collision corner/slide cases; +no simulation advancement during pause/dialog; no pickups/AI along camera paths; +safe cinematic handoff; restart after a room transition; failed restart rollback; +unchanged v1..v5 loading, lighting bake parity and IPC v2 framebuffer parity. + ## Remaining stages 2. Predictable simulation: fixed 60 Hz ticks, fractional movement, normalized diff --git a/editor/editor.exe b/editor/editor.exe index 2d62450..08b154a 100644 Binary files a/editor/editor.exe and b/editor/editor.exe differ diff --git a/editor/imgui.ini b/editor/imgui.ini index 44b674e..84a1bb4 100644 --- a/editor/imgui.ini +++ b/editor/imgui.ini @@ -1,25 +1,40 @@ [Window][Workspace] -Pos=0,60 -Size=1536,708 -LastUsed=20261022 +Pos=0,64 +Size=2560,1288 +LastUsed=20261024 [Window][Debug##Default] Pos=60,60 Size=400,400 -LastUsed=20261022 +LastUsed=20261024 [Window][Toolbar] Pos=0,20 -Size=1536,38 -LastUsed=20261022 +Size=2560,42 +LastUsed=20261024 [Window][StatusBar] -Pos=0,768 -Size=1536,32 -LastUsed=20261022 +Pos=0,1352 +Size=2560,32 +LastUsed=20261024 [Window][Не удалось запустить игру] Pos=649,356 Size=237,80 LastUsed=20261022 +[Window][Редактор кода и скриптов MASM32 - EblanStudio IDE] +Pos=60,60 +Size=900,650 +LastUsed=20261022 + +[Window][Менеджер ассетов (Texture & Audio Manager)] +Pos=61,60 +Size=820,580 +LastUsed=20261023 + +[Window][Редактор кода MASM32 & Игровых скриптов (F7)] +Pos=60,60 +Size=1020,680 +LastUsed=20261024 + diff --git a/editor/project.eblanproj b/editor/project.eblanproj new file mode 100644 index 0000000..e0f27da --- /dev/null +++ b/editor/project.eblanproj @@ -0,0 +1,14 @@ +[Project] +Title=EBLAN_GAME +Author=Developer +Version=1.0.0 +StartupMap=assets/map.dat +WindowTitle=EBLAN ENGINE 3D - Standalone Retro Game +DefaultWidth=1280 +DefaultHeight=960 +GameMode=1 +ShowWeapons=1 +ShowCrosshair=1 +LightingMode=2 +AmbientLight=40 +Flashlight=1 diff --git a/editor/src/editor_app.cpp b/editor/src/editor_app.cpp index c4d3501..20a2652 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; @@ -255,6 +271,12 @@ bool EditorApp::PreparePlaytest(std::string& startMap, std::string& enginePath) if (ec) return copyFailure(); if (fs::is_directory(sounds, ec)) fs::copy(sounds, sandbox / "assets/sounds", fs::copy_options::recursive, ec); if (ec) return copyFailure(); + char modulePath[MAX_PATH]{}; + GetModuleFileNameA(nullptr, modulePath, MAX_PATH); + fs::path projFile = fs::path(modulePath).parent_path() / "project.eblanproj"; + if (fs::exists(projFile, ec)) { + fs::copy_file(projFile, sandbox / "project.eblanproj", fs::copy_options::overwrite_existing, ec); + } // Validate all reachable maps and destination spawns, including reverse links. std::vector pending{source.filename().string()}; std::set visited; @@ -340,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)); @@ -351,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; } @@ -365,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; @@ -1098,7 +1165,13 @@ void EditorApp::RenderAssetManagerModal() { ImGui::SetNextWindowSize(ImVec2(820, 580), ImGuiCond_FirstUseEver); if (ImGui::Begin("Менеджер ассетов (Texture & Audio Manager)", &showAssetManagerModal)) { - if (ImGui::BeginTabBar("AssetManagerTabs")) { + RenderAssetManagerContents(); + } + ImGui::End(); +} + +void EditorApp::RenderAssetManagerContents() { + if (ImGui::BeginTabBar("AssetManagerTabs")) { if (ImGui::BeginTabItem("Текстурный пакет (16 слотов)")) { ImGui::Text("Файл пакета: %s", GetTexturePackPath().c_str()); ImGui::SameLine(); @@ -1219,8 +1292,6 @@ void EditorApp::RenderAssetManagerModal() { ImGui::EndTabBar(); } - } - ImGui::End(); } // ----------------------------------------------------------------------------- @@ -1265,13 +1336,58 @@ void EditorApp::ApplyTool(int gx, int gy) { case TOOL_PLAYER_SPAWN: if (SetCell(gx, gy, CELL_EMPTY)) { playerStartX = gx; playerStartY = gy; } break; + case TOOL_LIGHT_SPAWN: + if (lightSources.size() < MAX_EDITOR_LIGHTS) { + if (lightingMode == 0) lightingMode = 2; + EditorLightSource nl; + nl.x = gx * 64 + 32; nl.y = gy * 64 + 32; + nl.radius = 240; nl.intensity = 220; nl.curIntensity = 220; + nl.color = 0x00FFB040; + nl.lightType = LIGHT_TYPE_POINT; + nl.flags = LIGHT_FLAG_ACTIVE | LIGHT_FLAG_SHADOWS | LIGHT_FLAG_FLICKER; + lightSources.push_back(nl); + selectedLightIdx = static_cast(lightSources.size()) - 1; + selectedSpriteIdx = -1; selectedTransitionIdx = -1; + if (autoBakeLighting) BakeLighting(); + statusMessage = "Источник света создан на клетке (" + std::to_string(gx) + ", " + std::to_string(gy) + ")."; + } else { + statusMessage = "Лимит источников света карты: 13."; + } + break; case TOOL_SPRITE_SPAWN: + if (pendingSpawnArchetype == 99) { + currentTool = TOOL_LIGHT_SPAWN; + ApplyTool(gx, gy); + break; + } if (sprites.size() < MAX_LEVEL_SPRITES) { SpriteEntry sprite{}; sprite.x = gx * 64 + 32; sprite.y = gy * 64 + 32; - sprite.textureId = 11; sprite.scale = 65536; + sprite.scale = 65536; + if (pendingSpawnArchetype == ENTITY_ARCHETYPE_GUARD) { + sprite.textureId = 13; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_GUARD, 100, 2, 6); + } else if (pendingSpawnArchetype == ENTITY_ARCHETYPE_MEDKIT) { + sprite.textureId = 14; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_MEDKIT, 1, 0, 0); + } else if (pendingSpawnArchetype == ENTITY_ARCHETYPE_AMMO) { + sprite.textureId = 15; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_AMMO, 1, 0, 0); + } else if (pendingSpawnArchetype == ENTITY_ARCHETYPE_KEY) { + sprite.textureId = 10; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_KEY, 1, 0, 0); + } else if (pendingSpawnArchetype == ENTITY_ARCHETYPE_SWITCH) { + sprite.textureId = 12; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_SWITCH, 1, 0, 0); + } else if (pendingSpawnArchetype == ENTITY_ARCHETYPE_BARREL) { + sprite.textureId = 11; + SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_BARREL, 50, 0, 0); + } else { + sprite.textureId = 11; + 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; @@ -1306,6 +1422,70 @@ void EditorApp::FloodFill(int startX, int startY, uint8_t targetType, uint8_t fi for (const auto& [x, y] : cells) SetCell(x, y, fillType); } +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; + if(autoBakeLighting) BakeLighting(); + statusMessage = "Источник света дублирован (Ctrl+D)."; + return; + } + if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { + if (sprites.size() >= MAX_LEVEL_SPRITES) { + statusMessage = "Лимит спрайтов на карте достигнут (макс. 32)."; + return; + } + PushUndo(); + SpriteEntry copy = sprites[selectedSpriteIdx]; + copy.x = (std::min)(copy.x + 64, (MAP_GRID_W - 2) * 64); + copy.y = (std::min)(copy.y + 64, (MAP_GRID_H - 2) * 64); + sprites.push_back(copy); + selectedSpriteIdx = static_cast(sprites.size()) - 1; + statusMessage = "Сущность дублирована (Ctrl+D)."; + } +} + +void EditorApp::DeleteSelectedEntity() { + if(selectedLightIdx>=0 && selectedLightIdx(lightSources.size())) { + PushUndo();lightSources.erase(lightSources.begin()+selectedLightIdx); + selectedLightIdx=draggingLightIdx=draggingLightRadiusIdx=-1; + if(autoBakeLighting) BakeLighting(); + statusMessage = "Источник света удален (Del)."; + return; + } + if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { + PushUndo(); + sprites.erase(sprites.begin() + selectedSpriteIdx); + selectedSpriteIdx = -1; + statusMessage = "Сущность удалена (Del)."; + } else if (selectedTransitionIdx >= 0 && selectedTransitionIdx < static_cast(transitions.size())) { + PushUndo(); + RemoveCutsceneTransition(selectedTransitionIdx); + transitions.erase(transitions.begin() + selectedTransitionIdx); + selectedTransitionIdx = -1; + statusMessage = "Портал перехода удален (Del)."; + } +} + +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; + canvasPanX = 150.0f - (gx * canvasZoom); + canvasPanY = 150.0f - (gy * canvasZoom); + statusMessage = "Камера сфокусирована на объекте (F)."; + } else { + canvasPanX = 150.0f - (playerStartX * canvasZoom); + canvasPanY = 150.0f - (playerStartY * canvasZoom); + statusMessage = "Камера сфокусирована на игроке (F)."; + } +} + // ----------------------------------------------------------------------------- // Hotkeys // ----------------------------------------------------------------------------- @@ -1335,20 +1515,116 @@ void EditorApp::HandleHotkeys() { SaveLevel(currentFilePath); } } + if (ImGui::IsKeyPressed(ImGuiKey_D, false)) DuplicateSelectedEntity(); if (ImGui::IsKeyPressed(ImGuiKey_Z, false)) Undo(); if (ImGui::IsKeyPressed(ImGuiKey_Y, false)) Redo(); return; // no single-letter tool hotkeys while Ctrl held } - if (ImGui::IsKeyPressed(ImGuiKey_F4, false)) { - showAssetManagerModal = !showAssetManagerModal; - } + if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) DeleteSelectedEntity(); + if (ImGui::IsKeyPressed(ImGuiKey_F, false)) FocusSelectedEntity(); + if (ImGui::IsKeyPressed(ImGuiKey_1, false)) viewportMode = 2; // 2D Map + if (ImGui::IsKeyPressed(ImGuiKey_2, false)) viewportMode = 1; // 3D View + if (ImGui::IsKeyPressed(ImGuiKey_3, false)) viewportMode = 0; // Split if (ImGui::IsKeyPressed(ImGuiKey_B, false)) currentTool = TOOL_BRUSH; if (ImGui::IsKeyPressed(ImGuiKey_E, false)) currentTool = TOOL_ERASER; if (ImGui::IsKeyPressed(ImGuiKey_F, false)) currentTool = TOOL_FILL; if (ImGui::IsKeyPressed(ImGuiKey_P, false)) currentTool = TOOL_PLAYER_SPAWN; if (ImGui::IsKeyPressed(ImGuiKey_S, false)) currentTool = TOOL_SPRITE_SPAWN; + if (ImGui::IsKeyPressed(ImGuiKey_L, false)) currentTool = TOOL_LIGHT_SPAWN; +} + +// ----------------------------------------------------------------------------- +// Interactive Splitters (Unreal Engine / Unity style with <--> cursor) +// ----------------------------------------------------------------------------- +static bool DrawSplitter(const char* id, float* width, bool isLeftEdge, float minWidth, float maxWidth, float thickness = 6.0f) { + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + float height = ImGui::GetContentRegionAvail().y; + if (height < 20.0f) height = 100.0f; + + ImGui::PushID(id); + ImGui::InvisibleButton("##split", ImVec2(thickness, height)); + bool hovered = ImGui::IsItemHovered(); + bool active = ImGui::IsItemActive(); + + if (hovered || active) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + + if (active) { + float mouseDelta = ImGui::GetIO().MouseDelta.x; + if (mouseDelta != 0.0f) { + float newW = isLeftEdge ? (*width - mouseDelta) : (*width + mouseDelta); + if (newW < minWidth) newW = minWidth; + if (newW > maxWidth) newW = maxWidth; + *width = newW; + } + } + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImU32 col = active ? IM_COL32(20, 150, 255, 255) : + hovered ? IM_COL32(70, 110, 180, 200) : + IM_COL32(36, 40, 48, 255); + drawList->AddRectFilled(screenPos, ImVec2(screenPos.x + thickness, screenPos.y + height), col); + + // Center handle indicator (<--> grip) + float midY = screenPos.y + height * 0.5f; + float midX = screenPos.x + thickness * 0.5f; + if (hovered || active) { + drawList->AddLine(ImVec2(midX, midY - 18.0f), ImVec2(midX, midY + 18.0f), IM_COL32(255, 255, 255, 240), 2.0f); + } else { + drawList->AddCircleFilled(ImVec2(midX, midY - 6.0f), 1.5f, IM_COL32(110, 120, 140, 180)); + drawList->AddCircleFilled(ImVec2(midX, midY), 1.5f, IM_COL32(110, 120, 140, 180)); + drawList->AddCircleFilled(ImVec2(midX, midY + 6.0f), 1.5f, IM_COL32(110, 120, 140, 180)); + } + + ImGui::PopID(); + return active; +} + +static bool DrawRatioSplitter(const char* id, float* ratio, float totalWidth, float minRatio = 0.15f, float maxRatio = 0.85f, float thickness = 6.0f) { + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + float height = ImGui::GetContentRegionAvail().y; + if (height < 20.0f) height = 100.0f; + + ImGui::PushID(id); + ImGui::InvisibleButton("##split_ratio", ImVec2(thickness, height)); + bool hovered = ImGui::IsItemHovered(); + bool active = ImGui::IsItemActive(); + + if (hovered || active) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + + if (active && totalWidth > 10.0f) { + float mouseDelta = ImGui::GetIO().MouseDelta.x; + if (mouseDelta != 0.0f) { + float newRatio = *ratio + (mouseDelta / totalWidth); + if (newRatio < minRatio) newRatio = minRatio; + if (newRatio > maxRatio) newRatio = maxRatio; + *ratio = newRatio; + } + } + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + ImU32 col = active ? IM_COL32(20, 150, 255, 255) : + hovered ? IM_COL32(70, 110, 180, 200) : + IM_COL32(36, 40, 48, 255); + drawList->AddRectFilled(screenPos, ImVec2(screenPos.x + thickness, screenPos.y + height), col); + + float midY = screenPos.y + height * 0.5f; + float midX = screenPos.x + thickness * 0.5f; + if (hovered || active) { + drawList->AddLine(ImVec2(midX, midY - 18.0f), ImVec2(midX, midY + 18.0f), IM_COL32(255, 255, 255, 240), 2.0f); + } else { + drawList->AddCircleFilled(ImVec2(midX, midY - 6.0f), 1.5f, IM_COL32(110, 120, 140, 180)); + drawList->AddCircleFilled(ImVec2(midX, midY), 1.5f, IM_COL32(110, 120, 140, 180)); + drawList->AddCircleFilled(ImVec2(midX, midY + 6.0f), 1.5f, IM_COL32(110, 120, 140, 180)); + } + + ImGui::PopID(); + return active; } // ----------------------------------------------------------------------------- @@ -1376,57 +1652,150 @@ void EditorApp::RenderUI() { // Main layout ImGuiViewport* vp = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 60)); - ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 85)); + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 64)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 89)); ImGui::Begin("Workspace", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus); - // Columns: Left = Palette & Hierarchy, Middle = Grid Canvas, Right = Inspector - float leftWidth = 220.0f; - float rightWidth = 280.0f; - float canvasWidth = ImGui::GetContentRegionAvail().x - leftWidth - rightWidth - 20.0f; + // Columns: Left = Hierarchy & Tile Palette, Middle = Workspace Viewport/IDE, Right = Inspector + float totalAvailX = ImGui::GetContentRegionAvail().x; + const float splitterW = 6.0f; + const float spacingX = ImGui::GetStyle().ItemSpacing.x; - // Left Panel - ImGui::BeginChild("LeftPanel", ImVec2(leftWidth, 0), true); - RenderTilePalette(); - ImGui::Separator(); - RenderHierarchy(); + float maxLeftW = (std::max)(180.0f, totalAvailX - rightPanelWidth - 320.0f); + if (leftPanelWidth > maxLeftW) leftPanelWidth = maxLeftW; + if (leftPanelWidth < 180.0f) leftPanelWidth = 180.0f; + + float maxRightW = (std::max)(240.0f, totalAvailX - leftPanelWidth - 320.0f); + if (rightPanelWidth > maxRightW) rightPanelWidth = maxRightW; + if (rightPanelWidth < 240.0f) rightPanelWidth = 240.0f; + + float canvasWidth = totalAvailX - leftPanelWidth - rightPanelWidth - (splitterW * 2.0f) - (spacingX * 4.0f); + if (canvasWidth < 200.0f) canvasWidth = 200.0f; + + // Left Panel (Unity / Unreal Docked Tabs) + ImGui::BeginChild("LeftPanel", ImVec2(leftPanelWidth, 0), true); + if (ImGui::BeginTabBar("LeftPanelTabs", ImGuiTabBarFlags_None)) { + if (ImGui::BeginTabItem("Сцена")) { + RenderHierarchy(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Палитра")) { + RenderTilePalette(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Файлы")) { + ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "Браузер ассетов"); + ImGui::Separator(); + RenderAssetManagerContents(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } ImGui::EndChild(); + ImGui::SameLine(); + DrawSplitter("LeftSplitter", &leftPanelWidth, false, 180.0f, maxLeftW, splitterW); ImGui::SameLine(); - // Center Panel (Grid Canvas) + // Center Panel (Grid Canvas / 3D Scene Viewport / MASM32 IDE / Asset Browser) ImGui::BeginChild("CenterPanel", ImVec2(canvasWidth, 0), false); - float timelineHeight = showCutsceneTimeline ? (std::min)(340.0f, ImGui::GetContentRegionAvail().y * 0.48f) : 0.0f; - ImGui::BeginChild("CanvasPanel", ImVec2(0, showCutsceneTimeline ? -timelineHeight - 6 : 0), true); - if (ImGui::RadioButton("Рядом", viewportMode == 0)) viewportMode = 0; - ImGui::SameLine(); if (ImGui::RadioButton("Игровой вид", viewportMode == 1)) viewportMode = 1; - ImGui::SameLine(); if (ImGui::RadioButton("Карта", viewportMode == 2)) viewportMode = 2; - float sceneWidth = viewportMode == 0 ? ImGui::GetContentRegionAvail().x * 0.5f : 0; - if (viewportMode != 2) { - ImGui::BeginChild("SceneViewport", ImVec2(sceneWidth,0), true); - RenderSceneViewport(); - ImGui::EndChild(); - if (viewportMode == 0) ImGui::SameLine(); + + // Workspace Mode Tabs (Godot 4 / Unreal Engine 5 style) + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 4.0f)); + + auto ModeTabButton = [&](const char* label, int modeId) { + bool active = (viewportMode == modeId); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.06f, 0.48f, 0.88f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.12f, 0.58f, 0.98f, 1.0f)); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.16f, 0.18f, 0.22f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.24f, 0.27f, 0.33f, 1.0f)); + } + if (ImGui::Button(label)) { + viewportMode = modeId; + } + ImGui::PopStyleColor(2); + }; + + ModeTabButton("2D Карта", 2); + ImGui::SameLine(); + ModeTabButton("3D Сцена", 1); + ImGui::SameLine(); + ModeTabButton("2D + 3D", 0); + ImGui::SameLine(); + ModeTabButton("Код / IDE", 3); + ImGui::SameLine(); + ModeTabButton("Ассеты", 4); + + if (viewportMode == 0 || viewportMode == 2) { + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + if (ImGui::SmallButton(" - ")) { canvasZoom = (std::max)(8.0f, canvasZoom - 4.0f); } + ImGui::SameLine(); if (ImGui::SmallButton(" 100% ")) { canvasZoom = 20.0f; canvasPanX = 20.0f; canvasPanY = 20.0f; } + ImGui::SameLine(); if (ImGui::SmallButton(" + ")) { canvasZoom = (std::min)(48.0f, canvasZoom + 4.0f); } } - if (viewportMode != 1) { - ImGui::BeginChild("MapViewport", ImVec2(0,0), false); - RenderGridCanvas(); - ImGui::EndChild(); - } - ImGui::EndChild(); - if (showCutsceneTimeline) { - ImGui::BeginChild("CutsceneTimeline", ImVec2(0, 0), true); - RenderCutsceneTimeline(); + ImGui::PopStyleVar(2); + + if (viewportMode == 3) { + // Integrated MASM32 IDE docked directly in center workspace! + std::string root = fs::path(currentFilePath).parent_path().parent_path().string(); + if (root.empty()) root = "."; + RenderCodeEditorContents(root); + } else if (viewportMode == 4) { + // Texture & Audio Asset Browser docked in center workspace! + RenderAssetManagerContents(); + } else { + float timelineHeight = showCutsceneTimeline ? (std::min)(340.0f, ImGui::GetContentRegionAvail().y * 0.48f) : 0.0f; + ImGui::BeginChild("CanvasPanel", ImVec2(0, showCutsceneTimeline ? -timelineHeight - 6 : 0), true); + + float availCanvasW = ImGui::GetContentRegionAvail().x; + if (viewportMode == 0) { + float availForViews = availCanvasW - splitterW - (spacingX * 2.0f); + float sceneW = availForViews * centerSplitRatio; + if (sceneW < 120.0f) sceneW = 120.0f; + if (sceneW > availForViews - 120.0f) sceneW = availForViews - 120.0f; + + ImGui::BeginChild("SceneViewport", ImVec2(sceneW, 0), true); + RenderSceneViewport(); + ImGui::EndChild(); + + ImGui::SameLine(); + DrawRatioSplitter("CenterRatioSplitter", ¢erSplitRatio, availForViews, 0.15f, 0.85f, splitterW); + ImGui::SameLine(); + + ImGui::BeginChild("MapViewport", ImVec2(0, 0), false); + RenderGridCanvas(); + ImGui::EndChild(); + } else if (viewportMode == 1) { + ImGui::BeginChild("SceneViewport", ImVec2(0, 0), true); + RenderSceneViewport(); + ImGui::EndChild(); + } else { + ImGui::BeginChild("MapViewport", ImVec2(0, 0), false); + RenderGridCanvas(); + ImGui::EndChild(); + } ImGui::EndChild(); + + if (showCutsceneTimeline) { + ImGui::BeginChild("CutsceneTimeline", ImVec2(0, 0), true); + RenderCutsceneTimeline(); + ImGui::EndChild(); + } } ImGui::EndChild(); + ImGui::SameLine(); + DrawSplitter("RightSplitter", &rightPanelWidth, true, 240.0f, maxRightW, splitterW); ImGui::SameLine(); - // Right Panel (Inspector) - ImGui::BeginChild("RightPanel", ImVec2(rightWidth, 0), true); + // Right Panel (Unity / Unreal Component Inspector) + ImGui::BeginChild("RightPanel", ImVec2(rightPanelWidth, 0), true); RenderInspector(); ImGui::EndChild(); @@ -1495,6 +1864,7 @@ void EditorApp::RenderMenuBar() { if (ImGui::MenuItem("Заливка области", "F", currentTool == TOOL_FILL)) currentTool = TOOL_FILL; if (ImGui::MenuItem("Спавн игрока", "P", currentTool == TOOL_PLAYER_SPAWN)) currentTool = TOOL_PLAYER_SPAWN; if (ImGui::MenuItem("Добавить спрайт", "S", currentTool == TOOL_SPRITE_SPAWN)) currentTool = TOOL_SPRITE_SPAWN; + if (ImGui::MenuItem("Источник света", "L", currentTool == TOOL_LIGHT_SPAWN)) currentTool = TOOL_LIGHT_SPAWN; ImGui::EndMenu(); } @@ -1565,86 +1935,177 @@ void EditorApp::RenderMenuBar() { void EditorApp::RenderToolbar() { ImGuiViewport* vp = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 20)); - ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 38)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 42)); ImGui::Begin("Toolbar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); - // Play / Stop Button + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(5.0f, 4.0f)); + + // 1. PLAY / STOP if (!isEngineRunning) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.65f, 0.25f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.80f, 0.30f, 1.0f)); - if (ImGui::Button(" ▶ ЗАПУСК (F5) ", ImVec2(130, 24))) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.60f, 0.26f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.72f, 0.32f, 1.0f)); + if (ImGui::Button(" ▶ Запуск (F5) ", ImVec2(110, 26))) { LaunchPlaytest(); } ImGui::PopStyleColor(2); } else { ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.20f, 0.20f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.25f, 0.25f, 1.0f)); - if (ImGui::Button(" ■ СТОП (F5) ", ImVec2(130, 24))) { + if (ImGui::Button(" ■ Стоп (F5) ", ImVec2(110, 26))) { StopPlaytest(); } ImGui::PopStyleColor(2); } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Запустить игру в тестовом режиме (F5)"); + + ImGui::SameLine(); + + // GAME GENRE BADGE + if (project && project->gameMode == 0) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.14f, 0.38f, 0.44f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.20f, 0.48f, 0.55f, 1.0f)); + if (ImGui::Button(" Квест (Без оружия) ", ImVec2(0, 26))) { + project->gameMode = 1; + project->showWeapons = true; + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string projPath = (fs::path(module).parent_path() / "project.eblanproj").string(); + project->Save(projPath); + statusMessage = "Режим игры переключён: Экшен / Шутер (с оружием)"; + } + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Текущий режим: Мирное приключение / Квест. Нажмите для переключения на Экшен / Шутер."); + } else if (project) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.55f, 0.30f, 0.12f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.68f, 0.38f, 0.16f, 1.0f)); + if (ImGui::Button(" Экшен (С оружием) ", ImVec2(0, 26))) { + project->gameMode = 0; + project->showWeapons = false; + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string projPath = (fs::path(module).parent_path() / "project.eblanproj").string(); + project->Save(projPath); + statusMessage = "Режим игры переключён: Мирное исследование / Квест (без оружия)"; + } + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Текущий режим: Боевик / Шутер с арсеналом. Нажмите для переключения на Квест."); + } ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); - // Tools - auto ToolButton = [&](const char* label, ToolMode mode) { + // 2. TOOLS SELECTOR (Photoshop / Unreal style button group) + auto ToolButton = [&](const char* label, ToolMode mode, const char* tooltip) { bool active = (currentTool == mode); - if (active) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.45f, 0.75f, 1.0f)); - if (ImGui::Button(label)) currentTool = mode; - if (active) ImGui::PopStyleColor(); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.08f, 0.48f, 0.88f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.14f, 0.58f, 0.98f, 1.0f)); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.20f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.26f, 0.30f, 0.38f, 1.0f)); + } + if (ImGui::Button(label, ImVec2(0, 26))) currentTool = mode; + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tooltip); ImGui::SameLine(); }; - ToolButton(" [B] Кисть ", TOOL_BRUSH); - ToolButton(" [E] Ластик ", TOOL_ERASER); - ToolButton(" [F] Заливка ", TOOL_FILL); - ToolButton(" [P] Игрок ", TOOL_PLAYER_SPAWN); - ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN); + ToolButton(" [B] Кисть ", TOOL_BRUSH, "Кисть для рисования стен [B]"); + ToolButton(" [E] Ластик ", TOOL_ERASER, "Ластик для очистки ячеек [E]"); + ToolButton(" [F] Заливка ", TOOL_FILL, "Заливка замкнутой области стен [F]"); + ToolButton(" [P] Игрок ", TOOL_PLAYER_SPAWN, "Точка старта игрока [P]"); + ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN, "Размещение сущностей и спрайтов [S]"); + ToolButton(" [L] Свет ", TOOL_LIGHT_SPAWN, "Размещение источников освещения [L]"); + + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + + // 3. LIGHTING SUBSYSTEM + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.48f, 0.05f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.95f, 0.60f, 0.10f, 1.0f)); + if (ImGui::Button(" Запечь свет ", ImVec2(0, 26))) { + PushUndo(); BakeLighting(); + } + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Запечь постоянное освещение (Lightmap) для всей карты с учетом стен и препятствий"); + + ImGui::SameLine(); + const char* lModeBtn = (lightingMode == 0) ? " Свет: Выкл " : + (lightingMode == 1) ? " Свет: Запечён " : + " Свет: Динамика "; + if (ImGui::Button(lModeBtn, ImVec2(0, 26))) { + PushUndo(); lightingMode = (lightingMode + 1) % 3; + statusMessage = (lightingMode == 0) ? "Режим света: Выкл (Wolf3D fullbright)" : + (lightingMode == 1) ? "Режим света: Запечённый (Lightmap)" : + "Режим света: Динамический (Point lights + Flashlight)"; + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Режим освещения: Без света / Запечённый / Динамический с тенями"); + + ImGui::SameLine(); + if (isFlashlightOn) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.14f, 0.50f, 0.68f, 1.0f)); + if (ImGui::Button(" Фонарик [F] ", ImVec2(0, 26))) { PushUndo(); isFlashlightOn = false; } + ImGui::PopStyleColor(); + } else { + if (ImGui::Button(" Фонарик: Выкл ", ImVec2(0, 26))) { PushUndo(); isFlashlightOn = true; } + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Включить/выключить фонарик игрока [F]"); + + ImGui::SameLine(); + if (showLightHeatmap) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.68f, 0.38f, 0.08f, 1.0f)); + if (ImGui::Button(" Хитмап 2D ", ImVec2(0, 26))) showLightHeatmap = false; + ImGui::PopStyleColor(); + } else { + if (ImGui::Button(" Хитмап: Выкл ", ImVec2(0, 26))) showLightHeatmap = true; + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Отображение тепловой карты освещения на 2D сетке"); ImGui::SameLine(); ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); - // Undo / Redo buttons - if (ImGui::Button(" Отмена ")) Undo(); + // 4. STUDIO WINDOWS / MODULES + if (ImGui::Button(" Код (F7) ", ImVec2(0, 26))) showCodeEditorModal = !showCodeEditorModal; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Открыть встроенную среду разработки скриптов MASM32 (F7)"); ImGui::SameLine(); - if (ImGui::Button(" Повтор ")) Redo(); + + if (ImGui::Button(" Кат-сцены (F6) ", ImVec2(0, 26))) showCutsceneTimeline = !showCutsceneTimeline; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Открыть таймлайн кинематографических кат-сцен (F6)"); ImGui::SameLine(); + + if (ImGui::Button(" Ассеты (F4) ", ImVec2(0, 26))) showAssetManagerModal = !showAssetManagerModal; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Менеджер текстур и аудиофайлов (F4)"); + ImGui::SameLine(); + + if (ImGui::Button(" Экспорт (.exe) ", ImVec2(0, 26))) showExportGameModal = true; + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Экспорт готовой автономной игры (.exe)"); + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::SameLine(); - if (ImGui::Button("Кат-сцена (F6)")) showCutsceneTimeline = !showCutsceneTimeline; + // 5. HISTORY & SAVE + if (ImGui::Button(" Отмена ", ImVec2(0, 26))) Undo(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Отменить последнее действие (Ctrl+Z)"); + ImGui::SameLine(); + if (ImGui::Button(" Повтор ", ImVec2(0, 26))) Redo(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Повторить отменённое действие (Ctrl+Y)"); ImGui::SameLine(); - // Asset Manager button - if (ImGui::Button(" 🖼 Ассеты (F4) ")) { - showAssetManagerModal = !showAssetManagerModal; - } - ImGui::SameLine(); - - // Code Editor button - if (ImGui::Button(" 💻 Код (F7) ")) { - showCodeEditorModal = !showCodeEditorModal; - } - ImGui::SameLine(); - - // Standalone Exporter button - if (ImGui::Button(" 📦 Экспорт (.exe) ")) { - showExportGameModal = true; - } - ImGui::SameLine(); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(); - - if (ImGui::Button(" Сохранить (Ctrl+S) ")) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.18f, 0.44f, 0.70f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.24f, 0.54f, 0.84f, 1.0f)); + if (ImGui::Button(" Сохранить (Ctrl+S) ", ImVec2(0, 26))) { SaveLevel(currentFilePath); } + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Сохранить уровень в файл (Ctrl+S)"); + ImGui::PopStyleVar(2); ImGui::End(); } @@ -1712,6 +2173,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) { @@ -1745,12 +2216,22 @@ void EditorApp::RenderGridCanvas() { // Color diamond based on Entity Archetype uint8_t aType = GetSpriteEntityType(sprites[i]); + + // Draw Guard Aggro / Detection circle on the map + if (aType == ENTITY_ARCHETYPE_GUARD) { + uint8_t agg = GetSpriteAggro(sprites[i]); + float aggRadius = agg * tileSize; + drawList->AddCircle(scPos, aggRadius, IM_COL32(235, 45, 45, 60), 32, 1.5f); + drawList->AddCircleFilled(scPos, aggRadius, IM_COL32(235, 45, 45, 12)); + } + ImU32 fillCol = IM_COL32(255, 215, 0, 240); // Prop (Gold) if (aType == ENTITY_ARCHETYPE_GUARD) fillCol = IM_COL32(235, 45, 45, 240); // Red (Enemy Guard) else if (aType == ENTITY_ARCHETYPE_MEDKIT) fillCol = IM_COL32(40, 220, 80, 240); // Green (Medkit) else if (aType == ENTITY_ARCHETYPE_AMMO) fillCol = IM_COL32(245, 190, 25, 240); // Yellow (Ammo) else if (aType == ENTITY_ARCHETYPE_KEY) fillCol = IM_COL32(30, 190, 255, 240); // Cyan (Key) else if (aType == ENTITY_ARCHETYPE_SWITCH) fillCol = IM_COL32(255, 130, 20, 240); // Orange (Switch) + else if (aType == ENTITY_ARCHETYPE_BARREL) fillCol = IM_COL32(255, 90, 20, 240); // Dark Orange (Barrel) ImU32 borderCol = IM_COL32(255, 255, 255, 255); if (static_cast(i) == selectedSpriteIdx) { @@ -1776,6 +2257,73 @@ void EditorApp::RenderGridCanvas() { ); } + // 2b. Draw Light Sources on Map Canvas + int hoveredLight = -1; + int hoveredRadiusHandle = -1; + + // Check if radius handle of selected light is hovered + if (selectedLightIdx >= 0 && selectedLightIdx < static_cast(lightSources.size())) { + float lx = lightSources[selectedLightIdx].x / 64.0f; + float ly = lightSources[selectedLightIdx].y / 64.0f; + ImVec2 lcPos = ImVec2(gridOrigin.x + lx * tileSize, gridOrigin.y + ly * tileSize); + float radPix = (lightSources[selectedLightIdx].radius / 64.0f) * tileSize; + ImVec2 hPos = ImVec2(lcPos.x + radPix, lcPos.y); + float hdx = mousePos.x - hPos.x, hdy = mousePos.y - hPos.y; + if (std::sqrt(hdx * hdx + hdy * hdy) < 9.0f) { + hoveredRadiusHandle = selectedLightIdx; + } + } + + 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 && hoveredRadiusHandle < 0) { + hoveredLight = static_cast(i); + } + + uint32_t c = lightSources[i].color; + uint8_t cr = (c >> 16) & 0xFF, cg = (c >> 8) & 0xFF, cb = c & 0xFF; + + if (static_cast(i) == selectedLightIdx || static_cast(i) == hoveredLight) { + float radPix = (lightSources[i].radius / 64.0f) * tileSize; + drawList->AddCircle(lcPos, radPix, IM_COL32(cr, cg, cb, 180), 48, 1.8f); + drawList->AddCircleFilled(lcPos, radPix, IM_COL32(cr, cg, cb, 22)); + + // Radius handle for selected light + if (static_cast(i) == selectedLightIdx) { + ImVec2 hPos = ImVec2(lcPos.x + radPix, lcPos.y); + bool hHover = (hoveredRadiusHandle == selectedLightIdx || draggingLightRadiusIdx == selectedLightIdx); + drawList->AddCircleFilled(hPos, hHover ? 7.0f : 5.0f, IM_COL32(255, 255, 255, 255)); + drawList->AddCircle(hPos, hHover ? 7.0f : 5.0f, IM_COL32(cr, cg, cb, 255), 16, 2.0f); + } + } + + ImU32 lightCol = IM_COL32(cr, cg, cb, 255); + ImU32 haloCol = IM_COL32(cr, cg, cb, 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)); + + // Light type badge: S = Static, F = Torch Flicker, P = Pulse, D = Dynamic + char badge = (lightSources[i].lightType == LIGHT_TYPE_STATIC) ? 'S' : + (lightSources[i].lightType == LIGHT_TYPE_PULSE) ? 'P' : + (lightSources[i].flags & LIGHT_FLAG_FLICKER) ? 'F' : 'D'; + char badgeStr[2] = { badge, 0 }; + drawList->AddText(ImVec2(lcPos.x - 3.5f, lcPos.y - 6.5f), IM_COL32(0, 0, 0, 220), badgeStr); + + if (static_cast(i) == selectedLightIdx) { + drawList->AddCircle(lcPos, tileSize * 0.42f, IM_COL32(255, 230, 40, 255), 24, 2.5f); + } 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, @@ -1823,12 +2371,24 @@ void EditorApp::RenderGridCanvas() { drawList->PopClipRect(); return; } if (placeCameraPoint) { drawList->PopClipRect(); return; } - // Left Click: start action (drag sprite OR paint) + // Left Click: start action (drag radius OR drag light OR drag sprite OR paint) if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - if (currentTool == TOOL_SPRITE_SPAWN && hoveredSprite >= 0) { + if (hoveredRadiusHandle >= 0) { + draggingLightRadiusIdx = hoveredRadiusHandle; + PushUndo(); + } else if (hoveredLight >= 0) { + draggingLightIdx = hoveredLight; + selectedLightIdx = hoveredLight; + selectedSpriteIdx = -1; + selectedTransitionIdx = -1; + PushUndo(); + } else if (currentTool == TOOL_LIGHT_SPAWN && inGrid) { + PushUndo(); + ApplyTool(hoverGx, hoverGy); + } 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) { @@ -1836,7 +2396,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; } @@ -1848,9 +2408,25 @@ void EditorApp::RenderGridCanvas() { } } - // Left Drag: continue painting OR move sprite + // Left Drag: continue painting OR move light OR resize radius OR move sprite if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - if (draggingSpriteIdx >= 0) { + if (draggingLightRadiusIdx >= 0 && draggingLightRadiusIdx < static_cast(lightSources.size())) { + float lx = lightSources[draggingLightRadiusIdx].x / 64.0f; + float ly = lightSources[draggingLightRadiusIdx].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 distPx = std::sqrt(dx * dx + dy * dy); + int newRadius = std::clamp(static_cast(distPx / tileSize * 64.0f), 32, 2048); + lightSources[draggingLightRadiusIdx].radius = newRadius; + if (autoBakeLighting && lightSources[draggingLightRadiusIdx].lightType == LIGHT_TYPE_STATIC) BakeLighting(); + } else if (draggingLightIdx >= 0 && draggingLightIdx < static_cast(lightSources.size())) { + 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); + if (autoBakeLighting && lightSources[draggingLightIdx].lightType == LIGHT_TYPE_STATIC) BakeLighting(); + } 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); @@ -1862,8 +2438,16 @@ void EditorApp::RenderGridCanvas() { // Left Release: end drag / stroke if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + if (draggingLightIdx >= 0 || draggingLightRadiusIdx >= 0) { + draggingLightIdx = -1; + draggingLightRadiusIdx = -1; + BakeLighting(); + } draggingSpriteIdx = -1; - strokeSnapshotTaken = false; + if (strokeSnapshotTaken) { + strokeSnapshotTaken = false; + if (autoBakeLighting) BakeLighting(); + } } // Right-click erase follows the same portal lifecycle as every other tool. @@ -1947,48 +2531,297 @@ void EditorApp::RenderTilePalette() { PaletteButton("9. Мрамор", CELL_MARBLE, ImVec4(0.80f, 0.77f, 0.73f, 1.0f), 9); PaletteButton("10. Портал перехода", CELL_PORTAL, ImVec4(0.60f, 0.20f, 0.85f, 1.0f), 10); PaletteButton("0. Пустота (Пол)", CELL_EMPTY, ImVec4(0.15f, 0.15f, 0.15f, 1.0f), 0); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "СПАВН СУЩНОСТЕЙ"); + ImGui::Separator(); + + auto SpawnerBtn = [&](const char* title, uint8_t arch, ImVec4 col) { + bool active = (currentTool == TOOL_SPRITE_SPAWN && pendingSpawnArchetype == arch); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 2.0f); + } + ImGui::PushStyleColor(ImGuiCol_Button, col); + if (ImGui::Button(title, ImVec2(-1, 24))) { + currentTool = TOOL_SPRITE_SPAWN; + pendingSpawnArchetype = arch; + } + ImGui::PopStyleColor(); + if (active) { + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } + }; + + SpawnerBtn("+ Враг (Guard AI)", ENTITY_ARCHETYPE_GUARD, ImVec4(0.65f, 0.15f, 0.15f, 1.0f)); + SpawnerBtn("+ Аптечка (+25 HP)", ENTITY_ARCHETYPE_MEDKIT, ImVec4(0.15f, 0.55f, 0.20f, 1.0f)); + SpawnerBtn("+ Патроны (+20)", ENTITY_ARCHETYPE_AMMO, ImVec4(0.65f, 0.55f, 0.10f, 1.0f)); + SpawnerBtn("+ Ключ от двери", ENTITY_ARCHETYPE_KEY, ImVec4(0.15f, 0.50f, 0.70f, 1.0f)); + 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)); + + bool lightToolActive = (currentTool == TOOL_LIGHT_SPAWN); + if (lightToolActive) { + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 2.0f); + } + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.85f, 0.60f, 0.10f, 1.0f)); + if (ImGui::Button("+ Источник света [L]", ImVec2(-1, 24))) { + currentTool = TOOL_LIGHT_SPAWN; + statusMessage = "Режим размещения света: кликните на карте для создания источника."; + } + ImGui::PopStyleColor(); + if (lightToolActive) { + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } } void EditorApp::RenderHierarchy() { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "СУЩНОСТИ (ИЕРАРХИЯ)"); + ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИЕРАРХИЯ СЦЕНЫ (SCENE TREE)"); ImGui::Separator(); - ImGui::Text("Игрок: Spawn (%d, %d)", playerStartX, playerStartY); + // Search filter + Quick Add button (Unity / Unreal style) + ImGui::InputTextWithHint("##HierarchySearch", "Поиск...", searchHierarchyBuf, sizeof(searchHierarchyBuf)); + ImGui::SameLine(); + if (ImGui::Button("+ Добавить")) { + ImGui::OpenPopup("QuickEntitySpawnPopup"); + } + if (ImGui::BeginPopup("QuickEntitySpawnPopup")) { + auto AddEntityItem = [&](const char* label, uint8_t archetype) { + if (ImGui::MenuItem(label)) { + if (sprites.size() < MAX_LEVEL_SPRITES) { + PushUndo(); + SpriteEntry spr{}; + spr.x = (playerStartX + 1) * 64 + 32; + spr.y = (playerStartY + 1) * 64 + 32; + spr.scale = 65536; + uint32_t defaultTex = 11; + if (archetype == ENTITY_ARCHETYPE_GUARD) defaultTex = 14; + else if (archetype == ENTITY_ARCHETYPE_MEDKIT) defaultTex = 12; + else if (archetype == ENTITY_ARCHETYPE_AMMO) defaultTex = 13; + else if (archetype == ENTITY_ARCHETYPE_KEY) defaultTex = 15; + else if (archetype == ENTITY_ARCHETYPE_SWITCH) defaultTex = 10; + else if (archetype == ENTITY_ARCHETYPE_BARREL) defaultTex = 11; + 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; selectedLightIdx=-1; + selectedTransitionIdx = -1; + statusMessage = "Сущность создана на сцене."; + } else { + statusMessage = "Лимит спрайтов достигнут (макс. 32)."; + } + } + }; + AddEntityItem("Враг (Guard AI)", ENTITY_ARCHETYPE_GUARD); + AddEntityItem("Аптечка (+25 HP)", ENTITY_ARCHETYPE_MEDKIT); + AddEntityItem("Патроны (+20)", ENTITY_ARCHETYPE_AMMO); + AddEntityItem("Ключ от двери", ENTITY_ARCHETYPE_KEY); + AddEntityItem("Рычаг / Переключатель", ENTITY_ARCHETYPE_SWITCH); + AddEntityItem("Взрывная бочка", ENTITY_ARCHETYPE_BARREL); + AddEntityItem("Декорация / Проп", ENTITY_ARCHETYPE_PROP); + if (ImGui::MenuItem("Источник света [L]")) { + currentTool = TOOL_LIGHT_SPAWN; + statusMessage = "Режим размещения света: кликните на карте для создания источника."; + } + ImGui::EndPopup(); + } + ImGui::Separator(); - ImGui::Text("Спрайты на карте (%zu / %d):", sprites.size(), MAX_LEVEL_SPRITES); + auto MatchesSearch = [&](const char* text) { + if (!searchHierarchyBuf[0]) return true; + std::string query = searchHierarchyBuf; + std::string target = text; + for (auto& c : query) c = (char)tolower(c); + for (auto& c : target) c = (char)tolower(c); + return target.find(query) != std::string::npos; + }; + + // 1. Player Spawn (Always at top of hierarchy) + char playerLabel[96]; + snprintf(playerLabel, sizeof(playerLabel), "Игрок [Spawn: (%d, %d)]", playerStartX, playerStartY); + if (MatchesSearch(playerLabel) || MatchesSearch("player") || MatchesSearch("игрок")) { + bool isPlayerSelected = (selectedSpriteIdx == -1 && selectedTransitionIdx == -1 && selectedLightIdx == -1); + if (ImGui::Selectable(playerLabel, isPlayerSelected)) { + selectedSpriteIdx = -1; + selectedTransitionIdx = -1; + } + if (ImGui::BeginPopupContextItem("PlayerContextMenu")) { + if (ImGui::MenuItem("Сфокусировать камеру", "F")) { + FocusSelectedEntity(); + } + ImGui::EndPopup(); + } + } + + // 2. Sprite Entities grouped by categories + size_t guardCount = 0, pickupCount = 0, objectCount = 0; for (size_t i = 0; i < sprites.size(); ++i) { + uint8_t a = GetSpriteEntityType(sprites[i]); + if (a == ENTITY_ARCHETYPE_GUARD) guardCount++; + else if (a == ENTITY_ARCHETYPE_MEDKIT || a == ENTITY_ARCHETYPE_AMMO || a == ENTITY_ARCHETYPE_KEY) pickupCount++; + else objectCount++; + } + + auto RenderSpriteItem = [&](size_t i) { int gx = sprites[i].x / 64; int gy = sprites[i].y / 64; uint8_t aType = GetSpriteEntityType(sprites[i]); const char* typeTag = "Проп"; if (aType == ENTITY_ARCHETYPE_GUARD) typeTag = "Враг"; - else if (aType == ENTITY_ARCHETYPE_MEDKIT) typeTag = "+HP"; - else if (aType == ENTITY_ARCHETYPE_AMMO) typeTag = "+Ammo"; + else if (aType == ENTITY_ARCHETYPE_MEDKIT) typeTag = "Аптечка"; + else if (aType == ENTITY_ARCHETYPE_AMMO) typeTag = "Патроны"; else if (aType == ENTITY_ARCHETYPE_KEY) typeTag = "Ключ"; else if (aType == ENTITY_ARCHETYPE_SWITCH) typeTag = "Рычаг"; + else if (aType == ENTITY_ARCHETYPE_BARREL) typeTag = "Бочка"; - char label[64]; - snprintf(label, sizeof(label), "[%s] #%zu (%d, %d)", typeTag, i + 1, gx, gy); + char label[96]; + snprintf(label, sizeof(label), "[%s] #%zu (%d, %d)##spr_%zu", typeTag, i + 1, gx, gy, i); - if (ImGui::Selectable(label, selectedSpriteIdx == static_cast(i))) { - selectedSpriteIdx = static_cast(i); + if (!MatchesSearch(label)) return; + + bool isSel = (selectedSpriteIdx == static_cast(i)); + if (ImGui::Selectable(label, isSel)) { + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; selectedTransitionIdx = -1; } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; + FocusSelectedEntity(); + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Дублировать", "Ctrl+D")) { + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; + DuplicateSelectedEntity(); + } + if (ImGui::MenuItem("Сфокусировать", "F")) { + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; + FocusSelectedEntity(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Удалить", "Del")) { + selectedSpriteIdx = static_cast(i); selectedLightIdx=-1; + DeleteSelectedEntity(); + } + ImGui::EndPopup(); + } + }; + + // Enemies Category + char enemyHdr[64]; + snprintf(enemyHdr, sizeof(enemyHdr), "Враги и Guard AI (%zu)", guardCount); + if (ImGui::TreeNodeEx(enemyHdr, ImGuiTreeNodeFlags_DefaultOpen)) { + for (size_t i = 0; i < sprites.size(); ++i) { + if (GetSpriteEntityType(sprites[i]) == ENTITY_ARCHETYPE_GUARD) RenderSpriteItem(i); + } + ImGui::TreePop(); } - ImGui::Separator(); - ImGui::Text("Порталы перехода (%zu / %d):", transitions.size(), MAX_LEVEL_TRANSITIONS); - for (size_t i = 0; i < transitions.size(); ++i) { - char label[96]; - snprintf(label, sizeof(label), "Портал #%zu (%d, %d) -> %s", - i + 1, transitions[i].triggerCellX, transitions[i].triggerCellY, - transitions[i].targetMap[0] ? transitions[i].targetMap : ""); - - if (ImGui::Selectable(label, selectedTransitionIdx == static_cast(i))) { - selectedTransitionIdx = static_cast(i); - selectedSpriteIdx = -1; + // Pickups Category + char pickupHdr[64]; + snprintf(pickupHdr, sizeof(pickupHdr), "Лут и предметы (%zu)", pickupCount); + if (ImGui::TreeNodeEx(pickupHdr, ImGuiTreeNodeFlags_DefaultOpen)) { + for (size_t i = 0; i < sprites.size(); ++i) { + uint8_t a = GetSpriteEntityType(sprites[i]); + if (a == ENTITY_ARCHETYPE_MEDKIT || a == ENTITY_ARCHETYPE_AMMO || a == ENTITY_ARCHETYPE_KEY) RenderSpriteItem(i); } + ImGui::TreePop(); + } + + // Interactive & Props Category + char objHdr[64]; + snprintf(objHdr, sizeof(objHdr), "Объекты и окружение (%zu)", objectCount); + if (ImGui::TreeNodeEx(objHdr, ImGuiTreeNodeFlags_DefaultOpen)) { + for (size_t i = 0; i < sprites.size(); ++i) { + uint8_t a = GetSpriteEntityType(sprites[i]); + if (a != ENTITY_ARCHETYPE_GUARD && a != ENTITY_ARCHETYPE_MEDKIT && a != ENTITY_ARCHETYPE_AMMO && a != ENTITY_ARCHETYPE_KEY) RenderSpriteItem(i); + } + ImGui::TreePop(); + } + + if(ImGui::TreeNodeEx("Источники света", ImGuiTreeNodeFlags_DefaultOpen)) { + for(int i = 0; i < static_cast(lightSources.size()); ++i) { + const auto& ls = lightSources[i]; + const char* typeName = (ls.lightType == LIGHT_TYPE_STATIC) ? "Постоянный" : + (ls.lightType == LIGHT_TYPE_PULSE) ? "Сирена" : + (ls.flags & LIGHT_FLAG_FLICKER) ? "Факел" : "Точечный"; + char label[128]; + snprintf(label, sizeof(label), "Свет #%d: %s (%d, %d)##light_%d", + i + 1, typeName, ls.x / 64, ls.y / 64, i); + if (!MatchesSearch(label)) continue; + + ImGui::PushID(i); + uint32_t c = ls.color; + ImVec4 dotColor = ImVec4(((c >> 16) & 255) / 255.f, ((c >> 8) & 255) / 255.f, (c & 255) / 255.f, 1.0f); + ImGui::ColorButton("##dot", dotColor, ImGuiColorEditFlags_NoTooltip | ImGuiColorEditFlags_NoInputs, ImVec2(12, 12)); + ImGui::SameLine(); + if (ImGui::Selectable(label, selectedLightIdx == i)) { + selectedLightIdx = i; + selectedSpriteIdx = selectedTransitionIdx = -1; + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Сфокусировать", "F")) { + selectedLightIdx = i; selectedSpriteIdx = selectedTransitionIdx = -1; + FocusSelectedEntity(); + } + if (ImGui::MenuItem("Дублировать", "Ctrl+D")) { + selectedLightIdx = i; selectedSpriteIdx = selectedTransitionIdx = -1; + DuplicateSelectedEntity(); + } + if (ImGui::MenuItem("Выровнять по центру клетки")) { + SnapLightToGrid(i); + } + ImGui::Separator(); + if (ImGui::MenuItem("Удалить источник", "Del")) { + selectedLightIdx = i; + DeleteSelectedEntity(); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + if (ImGui::SmallButton("+ Добавить свет [L]")) { + currentTool = TOOL_LIGHT_SPAWN; + statusMessage = "Режим размещения света: кликните на карте для создания источника."; + } + ImGui::TreePop(); + } + // 3. Sublevel Transitions Category + char transHdr[64]; + snprintf(transHdr, sizeof(transHdr), "Порталы перехода (%zu)", transitions.size()); + if (ImGui::TreeNodeEx(transHdr, ImGuiTreeNodeFlags_DefaultOpen)) { + for (size_t i = 0; i < transitions.size(); ++i) { + char label[96]; + snprintf(label, sizeof(label), "Портал #%zu (%d, %d) -> %s##trans_%zu", + i + 1, transitions[i].triggerCellX, transitions[i].triggerCellY, + transitions[i].targetMap[0] ? transitions[i].targetMap : "", i); + + if (!MatchesSearch(label)) continue; + + bool isSel = (selectedTransitionIdx == static_cast(i)); + if (ImGui::Selectable(label, isSel)) { + selectedTransitionIdx = static_cast(i); selectedLightIdx=-1; + selectedSpriteIdx = -1; + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Сфокусировать", "F")) { + 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); selectedLightIdx=-1; + DeleteSelectedEntity(); + } + ImGui::EndPopup(); + } + } + ImGui::TreePop(); } } @@ -1997,110 +2830,88 @@ void EditorApp::RenderInspector() { RenderCutsceneInspector(); ImGui::Separator(); } - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "ИНСПЕКТОР СВОЙСТВ"); + + ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИНСПЕКТОР СВОЙСТВ (INSPECTOR)"); ImGui::Separator(); - if (ImGui::CollapsingHeader("Спавн игрока", ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::SliderInt("Spawn X", &playerStartX, 0, MAP_GRID_W - 1); - ImGui::SliderInt("Spawn Y", &playerStartY, 0, MAP_GRID_H - 1); - - float degrees = (playerStartAngle * 360.0f) / 512.0f; - if (ImGui::SliderFloat("Угол обзора (°)", °rees, 0.0f, 360.0f, "%.0f°")) { - playerStartAngle = static_cast((degrees * 512.0f) / 360.0f) & 511; - } - - const char* dirName = "Восток (East)"; - if (playerStartAngle >= 64 && playerStartAngle < 192) dirName = "Юг (South)"; - else if (playerStartAngle >= 192 && playerStartAngle < 320) dirName = "Запад (West)"; - else if (playerStartAngle >= 320 && playerStartAngle < 448) dirName = "Север (North)"; - ImGui::Text("Направление: %s", dirName); - } - - if (ImGui::CollapsingHeader("Окружение сцены", ImGuiTreeNodeFlags_DefaultOpen)) { - // Floor & Ceiling Color Pickers - float ceilCol[3] = { - ((ceilingColor >> 16) & 0xFF) / 255.0f, - ((ceilingColor >> 8) & 0xFF) / 255.0f, - (ceilingColor & 0xFF) / 255.0f - }; - if (ImGui::ColorEdit3("Цвет потолка", ceilCol)) { - ceilingColor = (static_cast(ceilCol[0] * 255.0f) << 16) | - (static_cast(ceilCol[1] * 255.0f) << 8) | - static_cast(ceilCol[2] * 255.0f); - } - - float floorCol[3] = { - ((floorColor >> 16) & 0xFF) / 255.0f, - ((floorColor >> 8) & 0xFF) / 255.0f, - (floorColor & 0xFF) / 255.0f - }; - if (ImGui::ColorEdit3("Цвет пола", floorCol)) { - floorColor = (static_cast(floorCol[0] * 255.0f) << 16) | - (static_cast(floorCol[1] * 255.0f) << 8) | - static_cast(floorCol[2] * 255.0f); - } + if (selectedLightIdx >= 0 && selectedLightIdx < static_cast(lightSources.size())) { + RenderLightingInspector(); + return; } + // Case 1: Sprite Entity Selected if (selectedSpriteIdx >= 0 && selectedSpriteIdx < static_cast(sprites.size())) { - if (ImGui::CollapsingHeader("Выбранный спрайт", ImGuiTreeNodeFlags_DefaultOpen)) { - auto& spr = sprites[selectedSpriteIdx]; + auto& spr = sprites[selectedSpriteIdx]; + uint8_t aType = GetSpriteEntityType(spr); + const char* typeName = "Декорация (Проп)"; + if (aType == ENTITY_ARCHETYPE_GUARD) typeName = "Враг (Guard AI)"; + else if (aType == ENTITY_ARCHETYPE_MEDKIT) typeName = "Аптечка (+25 HP)"; + else if (aType == ENTITY_ARCHETYPE_AMMO) typeName = "Патроны (+20)"; + else if (aType == ENTITY_ARCHETYPE_KEY) typeName = "Ключ от двери"; + else if (aType == ENTITY_ARCHETYPE_SWITCH) typeName = "Рычаг [E]"; + else if (aType == ENTITY_ARCHETYPE_BARREL) typeName = "Взрывная бочка"; - // Position (raw world units -> cell coords for UI) + // Entity Header Card (Unity / Unreal style) + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "Сущность #%d: %s", selectedSpriteIdx + 1, typeName); + if (ImGui::SmallButton("Дублировать (Ctrl+D)")) { DuplicateSelectedEntity(); } + ImGui::SameLine(); + if (ImGui::SmallButton("Фокус (F)")) { FocusSelectedEntity(); } + ImGui::SameLine(); + if (ImGui::SmallButton("Удалить (Del)")) { DeleteSelectedEntity(); return; } + ImGui::Separator(); + + // Component 1: Transform + if (ImGui::CollapsingHeader("Трансформ (Координаты и масштаб)", ImGuiTreeNodeFlags_DefaultOpen)) { float sx = spr.x / 64.0f; float sy = spr.y / 64.0f; - if (ImGui::SliderFloat("Sprite X", &sx, 0.0f, MAP_GRID_W - 0.01f)) { + if (ImGui::SliderFloat("Позиция X", &sx, 0.0f, MAP_GRID_W - 0.01f, "%.2f ячеек")) { spr.x = static_cast(sx * 64.0f); } - if (ImGui::SliderFloat("Sprite Y", &sy, 0.0f, MAP_GRID_H - 0.01f)) { + if (ImGui::SliderFloat("Позиция Y", &sy, 0.0f, MAP_GRID_H - 0.01f, "%.2f ячеек")) { spr.y = static_cast(sy * 64.0f); } + ImGui::TextDisabled("Мировые единицы (x86 ASM): X=%d, Y=%d", spr.x, spr.y); - // Texture slot + float scaleF = spr.scale / 65536.0f; + if (ImGui::SliderFloat("Масштаб", &scaleF, 0.25f, 4.0f, "%.2fx")) { + spr.scale = static_cast(scaleF * 65536.0f); + } + } + + // Component 2: Sprite Renderer + if (ImGui::CollapsingHeader("Спрайт / Текстура", ImGuiTreeNodeFlags_DefaultOpen)) { int tid = static_cast(spr.textureId & 15); - if (ImGui::Combo("Текстура", &tid, g_TexSlotNames, 16)) { + if (ImGui::Combo("Слот текстуры", &tid, g_TexSlotNames, 16)) { spr.textureId = static_cast(tid); } - if (tid >= 0 && tid < 16 && textureSRVs[tid]) { - ImGui::Text("Превью спрайта:"); ImGui::Image(reinterpret_cast(textureSRVs[tid]), ImVec2(64, 64)); ImGui::SameLine(); ImGui::BeginGroup(); - if (ImGui::Button("Заменить PNG...")) { - ImportTextureImage(tid, true); - } - if (ImGui::Button("Экспорт PNG")) { - ExportTextureImage(tid); - } - if (ImGui::Button("Сброс")) { - ResetTextureSlotToDefault(tid); - } + if (ImGui::Button("Заменить PNG...")) { ImportTextureImage(tid, true); } + if (ImGui::Button("Экспорт PNG")) { ExportTextureImage(tid); } + if (ImGui::Button("Сброс слота")) { ResetTextureSlotToDefault(tid); } ImGui::EndGroup(); } + } - // Scale (16.16 fixed -> float for UI) - float scaleF = spr.scale / 65536.0f; - if (ImGui::SliderFloat("Масштаб", &scaleF, 0.25f, 4.0f, "%.2f")) { - spr.scale = static_cast(scaleF * 65536.0f); - } - - // Entity Archetype & Logic - ImGui::Separator(); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Геймплей / Архетип сущности:"); - static const char* s_ArchetypeNames[6] = { + // Component 3: Gameplay & AI Controller + if (ImGui::CollapsingHeader("Параметры сущности / AI", ImGuiTreeNodeFlags_DefaultOpen)) { + static const char* s_ArchetypeNames[7] = { "0: Декорация / Проп (Static)", "1: Враг (Enemy Guard AI)", "2: Аптечка (+25 HP)", "3: Патроны (+20 Ammo)", "4: Ключ от двери (Key Pickup)", - "5: Переключатель / Рычаг (Switch)" + "5: Переключатель / Рычаг (Switch)", + "6: Взрывная бочка (Barrel AOE)" }; int archType = static_cast(GetSpriteEntityType(spr)); int hp = static_cast(GetSpriteHealth(spr)); int spd = static_cast(GetSpriteSpeed(spr)); int agg = static_cast(GetSpriteAggro(spr)); - if (ImGui::Combo("Тип сущности", &archType, s_ArchetypeNames, 6)) { + if (ImGui::Combo("Архетип сущности", &archType, s_ArchetypeNames, 7)) { PushUndo(); SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } @@ -2109,37 +2920,85 @@ void EditorApp::RenderInspector() { if (ImGui::SliderInt("Здоровье (HP)", &hp, 1, 255)) { SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } - if (ImGui::SliderInt("Скорость", &spd, 1, 8)) { + if (ImGui::SliderInt("Скорость бега", &spd, 1, 8)) { SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } - if (ImGui::SliderInt("Радиус агрессии (ячейки)", &agg, 1, 15)) { + if (ImGui::SliderInt("Радиус агро (ячейки)", &agg, 1, 15)) { SetSpriteEntityData(spr, static_cast(archType), hp, static_cast(spd), static_cast(agg)); } + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "● Отображается красным кругом на 2D карте"); } else if (archType == ENTITY_ARCHETYPE_MEDKIT) { - ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Восстанавливает +25 HP игроку при подборе"); + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "● Восстанавливает +25 HP игроку при подборе"); } else if (archType == ENTITY_ARCHETYPE_AMMO) { - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f), "Пополняет +20 патронов игроку при подборе"); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.2f, 1.0f), "● Пополняет +20 патронов игроку при подборе"); } else if (archType == ENTITY_ARCHETYPE_KEY) { - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Ключ для открытия запертых дверей"); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "● Ключ для открытия запертых дверей"); } else if (archType == ENTITY_ARCHETYPE_SWITCH) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.2f, 1.0f), "Активируется нажатием клавиши [E] рядом с объектом"); + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.2f, 1.0f), "● Активируется нажатием [E] рядом с объектом"); + } else if (archType == ENTITY_ARCHETYPE_BARREL) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "● Взрывается от пуль, нанося 60 AOE урона вокруг!"); } - if (ImGui::Button("Редактировать логику в Редакторе кода (F7)")) { - showCodeEditorModal = true; - } - ImGui::Separator(); - - if (ImGui::Button("Удалить спрайт")) { - PushUndo(); - sprites.erase(sprites.begin() + selectedSpriteIdx); - selectedSpriteIdx = -1; + if (ImGui::Button("Открыть логику в MASM32 IDE (F7)", ImVec2(-1, 28))) { + viewportMode = 3; } } + + // Component 4: Interaction & Lore Notes + if (ImGui::CollapsingHeader("Интерактивный диалог / Записка", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::TextDisabled("При взаимодействии [E] вызывается ShowGameDialog:"); + static char szSpeaker[32] = "ПЕРСОНАЖ"; + static char szDialog[128] = "ПРИВЕТСТВУЮ В EBLAN ENGINE 3D!"; + ImGui::InputText("Говорящий", szSpeaker, sizeof(szSpeaker)); + ImGui::InputTextMultiline("Текст", szDialog, sizeof(szDialog), ImVec2(-1, 45)); + if (ImGui::Button("Вставить хук диалога в код (F7)")) { + viewportMode = 3; + } + } + + // Unity style [+ Сменить компонент...] + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::Button("+ Добавить / Сменить компонент...", ImVec2(-1, 28))) { + ImGui::OpenPopup("AddComponentPopup"); + } + if (ImGui::BeginPopup("AddComponentPopup")) { + if (ImGui::MenuItem("Guard AI Controller")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_GUARD, 60, 2, 6); + } + if (ImGui::MenuItem("Medkit Heal Component")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_MEDKIT, 100, 0, 0); + } + if (ImGui::MenuItem("Ammo Pickup Component")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_AMMO, 100, 0, 0); + } + if (ImGui::MenuItem("Keycard Door Component")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_KEY, 100, 0, 0); + } + if (ImGui::MenuItem("Switch Trigger Component")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_SWITCH, 100, 0, 0); + } + if (ImGui::MenuItem("Explosive Barrel Component")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_BARREL, 100, 0, 0); + } + if (ImGui::MenuItem("Static Prop Decorator")) { + PushUndo(); + SetSpriteEntityData(spr, ENTITY_ARCHETYPE_PROP, 100, 0, 0); + } + ImGui::EndPopup(); + } + return; } + // Case 2: Portal Selected if (selectedTransitionIdx >= 0 && selectedTransitionIdx < static_cast(transitions.size())) { - if (ImGui::CollapsingHeader("Свойства портала перехода", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::CollapsingHeader("Портал перехода (Sublevel Portal)", ImGuiTreeNodeFlags_DefaultOpen)) { auto& trans = transitions[selectedTransitionIdx]; ImGui::TextColored(ImVec4(0.7f, 0.4f, 1.0f, 1.0f), "Триггер-ячейка: (%d, %d)", trans.triggerCellX, trans.triggerCellY); @@ -2202,6 +3061,75 @@ void EditorApp::RenderInspector() { selectedTransitionIdx = -1; } } + return; + } + + // Case 3: Level / Player Spawn Selected (Default) + if (ImGui::CollapsingHeader("👤 Спавн игрока (Player Transform)", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::SliderInt("Spawn X", &playerStartX, 0, MAP_GRID_W - 1); + ImGui::SliderInt("Spawn Y", &playerStartY, 0, MAP_GRID_H - 1); + + float degrees = (playerStartAngle * 360.0f) / 512.0f; + if (ImGui::SliderFloat("Угол обзора (°)", °rees, 0.0f, 360.0f, "%.0f°")) { + playerStartAngle = static_cast((degrees * 512.0f) / 360.0f) & 511; + } + + const char* dirName = "Восток (East)"; + if (playerStartAngle >= 64 && playerStartAngle < 192) dirName = "Юг (South)"; + else if (playerStartAngle >= 192 && playerStartAngle < 320) dirName = "Запад (West)"; + else if (playerStartAngle >= 320 && playerStartAngle < 448) dirName = "Север (North)"; + ImGui::Text("Направление: %s", dirName); + if (ImGui::SmallButton("🎯 Сфокусировать камеру на игроке (F)")) { + FocusSelectedEntity(); + } + } + + if (ImGui::CollapsingHeader("🌐 Окружение сцены (Environment)", ImGuiTreeNodeFlags_DefaultOpen)) { + // Floor & Ceiling Color Pickers + float ceilCol[3] = { + ((ceilingColor >> 16) & 0xFF) / 255.0f, + ((ceilingColor >> 8) & 0xFF) / 255.0f, + (ceilingColor & 0xFF) / 255.0f + }; + if (ImGui::ColorEdit3("Цвет потолка", ceilCol)) { + ceilingColor = (static_cast(ceilCol[0] * 255.0f) << 16) | + (static_cast(ceilCol[1] * 255.0f) << 8) | + static_cast(ceilCol[2] * 255.0f); + } + + float floorCol[3] = { + ((floorColor >> 16) & 0xFF) / 255.0f, + ((floorColor >> 8) & 0xFF) / 255.0f, + (floorColor & 0xFF) / 255.0f + }; + if (ImGui::ColorEdit3("Цвет пола", floorCol)) { + floorColor = (static_cast(floorCol[0] * 255.0f) << 16) | + (static_cast(floorCol[1] * 255.0f) << 8) | + static_cast(floorCol[2] * 255.0f); + } + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.2f, 1.0f), "Быстрые пресеты освещения:"); + if (ImGui::SmallButton("Подземелье")) { ceilingColor = 0x00222222; floorColor = 0x00151515; } + ImGui::SameLine(); + if (ImGui::SmallButton("Бункер")) { ceilingColor = 0x00383838; floorColor = 0x00242424; } + ImGui::SameLine(); + if (ImGui::SmallButton("Неон")) { ceilingColor = 0x000F172A; floorColor = 0x00022C22; } + ImGui::SameLine(); + if (ImGui::SmallButton("Хоррор")) { ceilingColor = 0x003B0710; floorColor = 0x001C1917; } + } + + if (ImGui::CollapsingHeader("💡 Освещение карты (Lighting)", ImGuiTreeNodeFlags_DefaultOpen)) { + RenderLightingSettings(); + } + + if (ImGui::CollapsingHeader("⚙ Настройки проекта & Сборка", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::Button("Настройки проекта (Жанр, Оружие)...", ImVec2(-1, 26))) { + showProjectSettingsModal = true; + } + if (ImGui::Button("Экспорт автономной игры (.exe)...", ImVec2(-1, 26))) { + showExportGameModal = true; + } } } @@ -2222,12 +3150,18 @@ void EditorApp::RenderProjectSettingsModal() { static char authorBuf[128] = ""; static char versionBuf[32] = ""; static char windowTitleBuf[128] = ""; + static int modeChoice = 1; + static bool showWeap = true; + static bool showCross = true; static bool init = true; if (init) { strncpy_s(titleBuf, project->title.c_str(), sizeof(titleBuf) - 1); strncpy_s(authorBuf, project->author.c_str(), sizeof(authorBuf) - 1); strncpy_s(versionBuf, project->version.c_str(), sizeof(versionBuf) - 1); strncpy_s(windowTitleBuf, project->windowTitle.c_str(), sizeof(windowTitleBuf) - 1); + modeChoice = project->gameMode; + showWeap = project->showWeapons; + showCross = project->showCrosshair; init = false; } @@ -2239,6 +3173,20 @@ void EditorApp::RenderProjectSettingsModal() { ImGui::InputText("Версия", versionBuf, sizeof(versionBuf)); ImGui::InputText("Заголовок окна", windowTitleBuf, sizeof(windowTitleBuf)); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "Жанр и режим геймплея:"); + ImGui::RadioButton("Квест / Приключение / Хоррор (Мирный режим, без оружия)", &modeChoice, 0); + ImGui::RadioButton("Экшен / Шутер (С оружием, патронами и перестрелками)", &modeChoice, 1); + + if (modeChoice == 1) { + ImGui::Checkbox("Отображать оружие от 1-го лица", &showWeap); + ImGui::Checkbox("Отображать перекрестие прицела", &showCross); + } else { + showWeap = false; + showCross = false; + ImGui::TextDisabled("В мирном режиме оружие, прицел и счетчик патронов скрыты."); + } + ImGui::Spacing(); ImGui::Text("Стартовая карта: %s", project->startupMap.c_str()); @@ -2248,6 +3196,9 @@ void EditorApp::RenderProjectSettingsModal() { project->author = authorBuf; project->version = versionBuf; project->windowTitle = windowTitleBuf; + project->gameMode = modeChoice; + project->showWeapons = showWeap; + project->showCrosshair = showCross; char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH); @@ -2329,3 +3280,5 @@ void EditorApp::RenderExportGameModal() { } } + +#include "editor_lighting.inl" diff --git a/editor/src/editor_app.h b/editor/src/editor_app.h index 049db77..db34cd0 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 @@ -22,7 +23,18 @@ enum ToolMode { TOOL_ERASER, TOOL_FILL, TOOL_PLAYER_SPAWN, - TOOL_SPRITE_SPAWN + TOOL_SPRITE_SPAWN, + TOOL_LIGHT_SPAWN +}; + +enum LightPreset { + LIGHT_PRESET_TORCH = 0, + LIGHT_PRESET_LAMP, + LIGHT_PRESET_ALARM, + LIGHT_PRESET_CANDLE, + LIGHT_PRESET_NEON, + LIGHT_PRESET_TOXIC, + LIGHT_PRESET_SPOTLIGHT }; // Full level snapshot for undo/redo (grid + spawn + palette + sprites + transitions) @@ -33,6 +45,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 { @@ -67,10 +83,18 @@ public: void StopAudioSound(); void RenderAssetManagerModal(); + void RenderAssetManagerContents(); void RenderCodeEditorWindow(); + void RenderCodeEditorContents(const std::string& root); void RenderProjectSettingsModal(); void RenderExportGameModal(); + void DuplicateSelectedEntity(); + void DeleteSelectedEntity(); + void FocusSelectedEntity(); + char searchHierarchyBuf[64] = ""; + int leftPanelTab = 0; // 0 = Hierarchy, 1 = Tile Palette, 2 = Project Assets + bool showAssetManagerModal = false; bool showCutsceneTimeline = false; bool showCodeEditorModal = false; @@ -96,12 +120,43 @@ public: // Editor state ToolMode currentTool = TOOL_BRUSH; CellType selectedPaletteCell = CELL_BRICK; + 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; + bool autoBakeLighting = true; + int draggingLightRadiusIdx = -1; + uint8_t bakedLightmap[MAP_TOTAL_CELLS] = { 0 }; + std::array bakedLightRGB{}; + uint64_t bakedLightingSignature = 0; + std::vector lightSources; + + void BakeLighting(); + void InitDefaultLights(); + void ApplyLightPreset(int lightIdx, int presetId); + void SnapLightToGrid(int lightIdx); + bool CastLightRay(int x1, int y1, int x2, int y2) const; + bool LightingNeedsBake() const; + LightingSettings GetLightingSettings() const; + + // Panel Dimensions & Splitters (Interactive Resizing like Unreal / Unity) + float leftPanelWidth = 260.0f; + float rightPanelWidth = 350.0f; + float centerSplitRatio = 0.5f; + // Viewport pan & zoom float canvasZoom = 20.0f; // pixels per tile float canvasPanX = 20.0f; @@ -112,6 +167,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 5b8578b..705e694 100644 --- a/editor/src/editor_code.inl +++ b/editor/src/editor_code.inl @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "../imgui/imgui.h" @@ -17,14 +18,19 @@ struct CodeFileEntry { }; static const CodeFileEntry g_ProjectCodeFiles[] = { - { "engine_entity.inc (Entity AI & Pickups)", "include/engine_entity.inc" }, - { "engine_combat.inc (Player Weapons & Combat)", "include/engine_combat.inc" }, - { "main.asm (Engine Entry & Main Loop)", "main.asm" }, - { "engine_map.inc (World Grid & Doors)", "include/engine_map.inc" }, + { "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" }, + { "main.asm (Engine Entry & Frame Loop)", "main.asm" }, { "engine_defs.inc (Constants & Structs)", "include/engine_defs.inc" }, + { "engine_data.inc (Memory & Game State)", "include/engine_data.inc" }, + { "engine_map.inc (World Grid & Doors)", "include/engine_map.inc" }, { "engine_render2d.inc (2D Canvas & HUD)", "include/engine_render2d.inc" }, - { "engine_input.inc (Keyboard Input)", "include/engine_input.inc" }, - { "engine_physics.inc (Player Physics)", "include/engine_physics.inc" } + { "engine_raycaster.inc (3D DDA Raycaster)", "include/engine_raycaster.inc" }, + { "engine_input.inc (Keyboard & Mouse)", "include/engine_input.inc" }, + { "engine_physics.inc (Player Movement)", "include/engine_physics.inc" } }; static const int g_CodeFileCount = sizeof(g_ProjectCodeFiles) / sizeof(g_ProjectCodeFiles[0]); @@ -34,6 +40,39 @@ struct ScriptTemplate { }; static const ScriptTemplate g_ScriptTemplates[] = { + { + "NPC Диалог и квест (ShowGameDialog)", + "; ==============================================================================\n" + "; NPC Quest Dialogue: Displays conversation modal and gives objective\n" + "; ==============================================================================\n" + ".data\n" + "szNpcSpeaker db \"СТАРИК\", 0\n" + "szNpcText db \"ВОЗЬМИ СИНИЙ КЛЮЧ И ОТКРОЙ ЗАПЕРТУЮ ДВЕРЬ НА СЕВЕРЕ!\", 0\n" + ".code\n" + " invoke ShowGameDialog, OFFSET szNpcSpeaker, OFFSET szNpcText\n" + " invoke ShowGameToast, OFFSET szKeyPickedToast, 90, COLOR_CYAN\n" + }, + { + "Записка / Терминал с лором (Lore Note)", + "; ==============================================================================\n" + "; Readable Note / Terminal: Player reads lore note on [E]\n" + "; ==============================================================================\n" + ".data\n" + "szNoteTitle db \"ДНЕВНИК УЧЕНОГО\", 0\n" + "szNoteBody db \"ЭКСПЕРИМЕНТ ВЫШЕЛ ИЗ-ПОД КОНТРОЛЯ. КОД ОТ ЛИФТА: 4 9 2 1.\", 0\n" + ".code\n" + " invoke ShowGameDialog, OFFSET szNoteTitle, OFFSET szNoteBody\n" + }, + { + "Всплывающее уведомление (Game Toast)", + "; ==============================================================================\n" + "; Floating Toast Banner (auto-centered with colored outline and fade)\n" + "; ==============================================================================\n" + ".data\n" + "szSecretToast db \"СЕКРЕТНАЯ КОМНАТА ОБНАРУЖЕНА!\", 0\n" + ".code\n" + " invoke ShowGameToast, OFFSET szSecretToast, 120, COLOR_YELLOW\n" + }, { "Патрулирующий охранник (Patrol Guard AI)", "; ==============================================================================\n" @@ -78,6 +117,15 @@ static const ScriptTemplate g_ScriptTemplates[] = { " invoke SetMapCell, switchTargetX, switchTargetY, CELL_DOOR\n" "switch_done:\n" }, + { + "Взрывная бочка (AOE Explosion Trigger)", + "; ==============================================================================\n" + "; Explosive Barrel: Deals 60 AOE damage and triggers fiery screen flash\n" + "; ==============================================================================\n" + " mov [entityState + ecx*4], ENTITY_STATE_DEAD\n" + " mov playerHitFlash, 8\n" + " add playerScore, 200\n" + }, { "Кастомный элемент HUD (Custom Gauge Widget)", "; ==============================================================================\n" @@ -90,6 +138,42 @@ static const ScriptTemplate g_ScriptTemplates[] = { }; static const int g_ScriptTemplateCount = sizeof(g_ScriptTemplates) / sizeof(g_ScriptTemplates[0]); +struct EngineApiItem { + const char* category; + const char* name; + const char* description; + const char* snippet; +}; + +static const EngineApiItem g_EngineApiCatalog[] = { + { "Геймплей и Скрипты", "ShowGameToast", "Всплывающий баннер с цветной рамкой и затуханием", "invoke ShowGameToast, OFFSET szMyMessage, 90, COLOR_YELLOW\n" }, + { "Геймплей и Скрипты", "ShowGameDialog", "Диалоговое окно NPC или чтение записки", "invoke ShowGameDialog, OFFSET szSpeaker, OFFSET szText\n" }, + { "Геймплей и Скрипты", "CloseGameDialog", "Принудительно закрыть активное диалоговое окно", "call CloseGameDialog\n" }, + { "Геймплей и Скрипты", "PlayGameSFX", "Асинхронное воспроизведение звука WAV", "invoke PlayGameSFX, OFFSET szMySoundWav\n" }, + { "Геймплей и Скрипты", "Game_OnPlayerInteract", "Хук взаимодействия [E] (1=обработано, 0=дефолт)", "Game_OnPlayerInteract PROC uses ebx ecx, entityIdx:DWORD, entityTypeVal:DWORD\n ; Ваша логика по нажатию [E]\n xor eax, eax\n ret\nGame_OnPlayerInteract ENDP\n" }, + { "Геймплей и Скрипты", "Game_OnEntityKilled", "Хук уничтожения врага/бочки (триггеры боссов)", "Game_OnEntityKilled PROC uses ebx ecx, entityIdx:DWORD, entityTypeVal:DWORD\n ; Смерть сущности\n ret\nGame_OnEntityKilled ENDP\n" }, + { "Геймплей и Скрипты", "Game_OnTick", "Покадровый хук 60 Hz (таймеры, зоны, квесты)", "Game_OnTick PROC uses ebx ecx\n ; Ваша покадровая логика\n ret\nGame_OnTick ENDP\n" }, + + { "Бой и оружие", "UpdateCombat", "Обновляет прицел, стрельбу и расчет урона", "call UpdateCombat\n" }, + { "Бой и оружие", "RenderCombatHUD", "Рисует оружие, прицел и статус игрока", "call RenderCombatHUD\n" }, + { "Бой и оружие", "Смена оружия", "Устанавливает активное оружие игрока", "mov playerWeapon, WEAPON_SHOTGUN ; 0=Knife, 1=Pistol, 2=Shotgun\n" }, + { "Бой и оружие", "Нанесение урона", "Уменьшает здоровье врага по индексу", "mov ecx, targetIdx\nmov eax, [entityHealth + ecx*4]\nsub eax, 35\nmov [entityHealth + ecx*4], eax\n" }, + + { "Сущности и AI", "InitEntities", "Сбрасывает и спавнит сущности уровня", "call InitEntities\n" }, + { "Сущности и AI", "UpdateEntities", "AI преследования, патруля и подбор предметов", "call UpdateEntities\n" }, + { "Сущности и AI", "Патруль врага", "Инкремент таймера контрольной точки патруля", "mov ecx, [entityTimer + edx*4]\ninc ecx\nmov [entityTimer + edx*4], ecx\n" }, + + { "2D Рендеринг", "DrawRect", "Рисует закрашенный прямоугольник (X, Y, W, H, Color)", "invoke DrawRect, 10, 20, 80, 12, 00336699h\n" }, + { "2D Рендеринг", "DrawRectOutline", "Рисует рамку прямоугольника", "invoke DrawRectOutline, 10, 20, 80, 12, COLOR_WHITE\n" }, + { "2D Рендеринг", "DrawString8x8", "Выводит строку шрифтом 8x8 (CP1251)", "invoke DrawString8x8, 10, 22, OFFSET myString, COLOR_YELLOW\n" }, + { "2D Рендеринг", "DrawNumber8x8", "Выводит целое число на экран", "invoke DrawNumber8x8, 60, 22, myNumberVar, COLOR_WHITE\n" }, + + { "Рэйкастинг и Мир", "tableCos / tableSin", "Чтение тригонометрии 16.16 (угол 0..511)", "mov eax, playerAngle\nand eax, ANGLE_MASK\nmov ebx, [tableCos + eax*4]\nmov edx, [tableSin + eax*4]\n" }, + { "Рэйкастинг и Мир", "depthBuffer", "Глубина стен для спрайтового z-теста", "mov eax, [depthBuffer + screenX*4]\n" }, + { "Рэйкастинг и Мир", "CheckWallCollision", "Проверка коллизии с ячейкой карты", "invoke CheckWallCollision, targetX, targetY\n" } +}; +static const int g_EngineApiCount = sizeof(g_EngineApiCatalog) / sizeof(g_EngineApiCatalog[0]); + struct CodeEditorState { int currentFile = 0; std::string fileContent; @@ -106,6 +190,10 @@ struct CodeEditorState { int buildExitCode = 0; bool hasBuildResult = false; + // Search bar state + char searchBuf[64] = ""; + int searchMatchCount = 0; + struct ParsedError { std::string file; int line = 0; @@ -193,8 +281,9 @@ struct CodeEditorState { buildLog.clear(); buildErrors.clear(); hasBuildResult = false; + buildExitCode = 0; - fs::path root(projectRoot); + fs::path root = fs::path(projectRoot); fs::path buildBat = root / "build.bat"; if (!fs::exists(buildBat)) { buildBat = root.parent_path() / "build.bat"; @@ -202,7 +291,7 @@ struct CodeEditorState { } if (!fs::exists(buildBat)) { - buildLog = "[ERROR] Файл build.bat не найден в корне проекта!\n"; + buildLog = "[ERROR] Не найден скрипт сборки build.bat в: " + buildBat.string() + "\n"; hasBuildResult = true; buildExitCode = 1; return; @@ -319,14 +408,13 @@ struct CodeEditorState { if (!std::isdigit(static_cast(c))) { isNum = false; break; } } if (isNum) { - ParsedError err{}; + ParsedError err; err.file = line.substr(0, openParen); err.line = std::atoi(lineStr.c_str()); err.message = line.substr(colon + 3); buildErrors.push_back(err); } } - if (end == std::string::npos) break; start = end + 1; } @@ -340,25 +428,23 @@ void EditorApp::RenderCodeEditorWindow() { g_CodeEditor.PollBuild(); if (!showCodeEditorModal) return; - ImGui::SetNextWindowSize(ImVec2(900, 650), ImGuiCond_FirstUseEver); - if (!ImGui::Begin("Редактор кода и скриптов MASM32 - EblanStudio IDE", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) { + ImGui::SetNextWindowSize(ImVec2(1020, 680), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Редактор кода MASM32 & Игровых скриптов (F7)", &showCodeEditorModal, ImGuiWindowFlags_MenuBar)) { ImGui::End(); return; } - // Top Menu Bar + char module[MAX_PATH]{}; + GetModuleFileNameA(nullptr, module, MAX_PATH); + std::string root = fs::path(module).parent_path().string(); + + // Menu Bar if (ImGui::BeginMenuBar()) { if (ImGui::BeginMenu("Файл")) { if (ImGui::MenuItem("Сохранить (Ctrl+S)", "Ctrl+S")) { - char module[MAX_PATH]{}; - GetModuleFileNameA(nullptr, module, MAX_PATH); - std::string root = fs::path(module).parent_path().string(); g_CodeEditor.SaveFile(root); } - if (ImGui::MenuItem("Собрать движок (F7)", "F7")) { - char module[MAX_PATH]{}; - GetModuleFileNameA(nullptr, module, MAX_PATH); - std::string root = fs::path(module).parent_path().string(); + if (ImGui::MenuItem("Собрать проект (F7 / Ctrl+B)", "Ctrl+B")) { g_CodeEditor.StartBuild(root); } ImGui::Separator(); @@ -371,7 +457,6 @@ void EditorApp::RenderCodeEditorWindow() { if (ImGui::BeginMenu("Шаблоны кода")) { for (int i = 0; i < g_ScriptTemplateCount; ++i) { if (ImGui::MenuItem(g_ScriptTemplates[i].title)) { - // Append template code to text buffer size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); size_t tLen = std::strlen(g_ScriptTemplates[i].code); g_CodeEditor.EnsureBufferSize(curLen + tLen + 1024); @@ -386,12 +471,15 @@ void EditorApp::RenderCodeEditorWindow() { ImGui::EndMenuBar(); } - // Toolbar Buttons - ImGui::BeginGroup(); - char module[MAX_PATH]{}; - GetModuleFileNameA(nullptr, module, MAX_PATH); - std::string root = fs::path(module).parent_path().string(); + RenderCodeEditorContents(root); + ImGui::End(); +} +void EditorApp::RenderCodeEditorContents(const std::string& root) { + g_CodeEditor.PollBuild(); + + // Top Toolbar + ImGui::BeginGroup(); if (ImGui::Button("Сохранить (Ctrl+S)")) { g_CodeEditor.SaveFile(root); } @@ -401,51 +489,110 @@ void EditorApp::RenderCodeEditorWindow() { ImGui::Button("Компиляция..."); ImGui::PopStyleColor(); } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.1f, 0.5f, 0.2f, 1.0f)); - if (ImGui::Button("Собрать движок (F7)")) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.55f, 0.22f, 1.0f)); + if (ImGui::Button("Собрать проект (F7)")) { g_CodeEditor.StartBuild(root); } ImGui::PopStyleColor(); } + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + + // Search bar + ImGui::SetNextItemWidth(200); + if (ImGui::InputTextWithHint("##SearchCode", "Поиск в коде...", g_CodeEditor.searchBuf, sizeof(g_CodeEditor.searchBuf))) { + if (g_CodeEditor.searchBuf[0]) { + int count = 0; + const char* p = g_CodeEditor.textBuffer.data(); + while ((p = std::strstr(p, g_CodeEditor.searchBuf)) != nullptr) { + count++; + p += std::strlen(g_CodeEditor.searchBuf); + } + g_CodeEditor.searchMatchCount = count; + } else { + g_CodeEditor.searchMatchCount = 0; + } + } + if (g_CodeEditor.searchBuf[0]) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[%d совп.]", g_CodeEditor.searchMatchCount); + } + ImGui::SameLine(); ImGui::TextDisabled("| %s", g_CodeEditor.statusText.c_str()); ImGui::EndGroup(); ImGui::Separator(); - // Split Left (File list) and Right (Editor + Output) + // Split Left (Navigator & API Catalog) and Right (Editor + Output) ImGui::Columns(2, "CodeEditorColumns", true); static bool initCol = true; if (initCol) { - ImGui::SetColumnWidth(0, 240); + ImGui::SetColumnWidth(0, 270); initCol = false; - // Load initial file g_CodeEditor.LoadFile(0, root); } - // Left Column: Source File List - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Исходные файлы проекта:"); - for (int i = 0; i < g_CodeFileCount; ++i) { - bool isSelected = (g_CodeEditor.currentFile == i); - if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) { - g_CodeEditor.LoadFile(i, root); + // Left Column: Tabbed Navigation (Files / API Catalog / Templates) + ImGui::BeginTabBar("NavTabs"); + + if (ImGui::BeginTabItem("Файлы")) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Исходные файлы проекта:"); + for (int i = 0; i < g_CodeFileCount; ++i) { + bool isSelected = (g_CodeEditor.currentFile == i); + if (ImGui::Selectable(g_ProjectCodeFiles[i].displayName.c_str(), isSelected)) { + g_CodeEditor.LoadFile(i, root); + } } + ImGui::EndTabItem(); } - ImGui::Spacing(); - ImGui::Separator(); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Быстрые шаблоны:"); - for (int i = 0; i < g_ScriptTemplateCount; ++i) { - if (ImGui::SmallButton(g_ScriptTemplates[i].title)) { - size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); - size_t tLen = std::strlen(g_ScriptTemplates[i].code); - g_CodeEditor.EnsureBufferSize(curLen + tLen + 1024); - std::strcat(g_CodeEditor.textBuffer.data(), "\n"); - std::strcat(g_CodeEditor.textBuffer.data(), g_ScriptTemplates[i].code); - g_CodeEditor.isModified = true; + if (ImGui::BeginTabItem("Справочник API")) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.2f, 0.85f, 1.0f, 1.0f), "Функции и процедуры движка:"); + ImGui::BeginChild("ApiCatalogChild", ImVec2(0, 0), false); + for (int i = 0; i < g_EngineApiCount; ++i) { + ImGui::PushID(i); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "%s", g_EngineApiCatalog[i].name); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 65); + if (ImGui::SmallButton("Вставить")) { + size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); + size_t sLen = std::strlen(g_EngineApiCatalog[i].snippet); + g_CodeEditor.EnsureBufferSize(curLen + sLen + 1024); + std::strcat(g_CodeEditor.textBuffer.data(), "\n"); + std::strcat(g_CodeEditor.textBuffer.data(), g_EngineApiCatalog[i].snippet); + g_CodeEditor.isModified = true; + g_CodeEditor.statusText = std::string("Вставлен код: ") + g_EngineApiCatalog[i].name; + } + ImGui::TextDisabled(" %s", g_EngineApiCatalog[i].description); + ImGui::Separator(); + ImGui::PopID(); } + ImGui::EndChild(); + ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Шаблоны")) { + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.5f, 1.0f), "Быстрые шаблоны скриптов:"); + for (int i = 0; i < g_ScriptTemplateCount; ++i) { + if (ImGui::Button(g_ScriptTemplates[i].title, ImVec2(-1, 26))) { + size_t curLen = std::strlen(g_CodeEditor.textBuffer.data()); + size_t tLen = std::strlen(g_ScriptTemplates[i].code); + g_CodeEditor.EnsureBufferSize(curLen + tLen + 1024); + std::strcat(g_CodeEditor.textBuffer.data(), "\n"); + std::strcat(g_CodeEditor.textBuffer.data(), g_ScriptTemplates[i].code); + g_CodeEditor.isModified = true; + g_CodeEditor.statusText = std::string("Вставлен шаблон: ") + g_ScriptTemplates[i].title; + } + } + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + ImGui::NextColumn(); // Right Column: Code Editor & Compiler Console @@ -453,7 +600,6 @@ void EditorApp::RenderCodeEditorWindow() { float editorHeight = availY * 0.65f; float outputHeight = availY - editorHeight - 20; - // Multiline Text Editor with monospace font ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "Файл: %s %s", g_ProjectCodeFiles[g_CodeEditor.currentFile].relativePath.c_str(), g_CodeEditor.isModified ? "*" : ""); @@ -470,10 +616,10 @@ void EditorApp::RenderCodeEditorWindow() { } ImGui::PopStyleColor(); - // Hotkey: Ctrl+S to save - if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && - ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_S)) { - g_CodeEditor.SaveFile(root); + // Hotkey: Ctrl+S to save, Ctrl+B to build + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && ImGui::GetIO().KeyCtrl) { + if (ImGui::IsKeyPressed(ImGuiKey_S)) g_CodeEditor.SaveFile(root); + if (ImGui::IsKeyPressed(ImGuiKey_B)) g_CodeEditor.StartBuild(root); } ImGui::Separator(); @@ -483,9 +629,8 @@ void EditorApp::RenderCodeEditorWindow() { if (ImGui::BeginTabItem("Вывод компилятора (ml.exe)")) { ImGui::BeginChild("CompilerOutputChild", ImVec2(0, outputHeight), true); if (g_CodeEditor.buildLog.empty() && !g_CodeEditor.isBuilding) { - ImGui::TextDisabled("Нажмите 'Собрать движок (F7)' для компиляции ассемблерного кода..."); + ImGui::TextDisabled("Нажмите 'Собрать проект (F7)' для компиляции ассемблерного кода..."); } else { - // Display log line by line with color tags size_t logStart = 0; while (logStart < g_CodeEditor.buildLog.size()) { size_t logEnd = g_CodeEditor.buildLog.find('\n', logStart); @@ -525,7 +670,6 @@ void EditorApp::RenderCodeEditorWindow() { snprintf(lineNumBuf, sizeof(lineNumBuf), "%d", err.line); std::string label = err.file + ":" + lineNumBuf + " - " + err.message; if (ImGui::Selectable(label.c_str())) { - // Switch to corresponding file if matched for (int f = 0; f < g_CodeFileCount; ++f) { if (err.file.find(g_ProjectCodeFiles[f].relativePath) != std::string::npos || g_ProjectCodeFiles[f].relativePath.find(err.file) != std::string::npos) { @@ -542,5 +686,4 @@ void EditorApp::RenderCodeEditorWindow() { ImGui::EndTabBar(); ImGui::Columns(1); - ImGui::End(); } diff --git a/editor/src/editor_lighting.inl b/editor/src/editor_lighting.inl new file mode 100644 index 0000000..0e57a04 --- /dev/null +++ b/editor/src/editor_lighting.inl @@ -0,0 +1,268 @@ +void EditorApp::ApplyLightPreset(int lightIdx, int presetId) { + if (lightIdx < 0 || lightIdx >= static_cast(lightSources.size())) return; + PushUndo(); + auto& l = lightSources[lightIdx]; + l.flags = LIGHT_FLAG_ACTIVE | LIGHT_FLAG_SHADOWS; + switch (presetId) { + case LIGHT_PRESET_TORCH: // 🕯 Факел + l.color = 0x00FFB040; + l.radius = 240; + l.intensity = 220; + l.curIntensity = 220; + l.lightType = LIGHT_TYPE_POINT; + l.flags |= LIGHT_FLAG_FLICKER; + break; + case LIGHT_PRESET_LAMP: // 💡 Лампа + l.color = 0x00E0E8FF; + l.radius = 280; + l.intensity = 240; + l.curIntensity = 240; + l.lightType = LIGHT_TYPE_STATIC; + break; + case LIGHT_PRESET_ALARM: // 🚨 Тревожная сирена + l.color = 0x00FF2020; + l.radius = 300; + l.intensity = 250; + l.curIntensity = 250; + l.lightType = LIGHT_TYPE_PULSE; + break; + case LIGHT_PRESET_CANDLE: // 🕯 Мягкая свеча + l.color = 0x00FF9520; + l.radius = 140; + l.intensity = 180; + l.curIntensity = 180; + l.lightType = LIGHT_TYPE_POINT; + l.flags |= LIGHT_FLAG_FLICKER; + break; + case LIGHT_PRESET_NEON: // 🔮 Киберпанк неон + l.color = 0x0000E5FF; + l.radius = 260; + l.intensity = 230; + l.curIntensity = 230; + l.lightType = LIGHT_TYPE_POINT; + break; + case LIGHT_PRESET_TOXIC: // ☢ Токсик + l.color = 0x0039FF14; + l.radius = 250; + l.intensity = 220; + l.curIntensity = 220; + l.lightType = LIGHT_TYPE_PULSE; + break; + case LIGHT_PRESET_SPOTLIGHT: // 🔦 Прожектор + l.color = 0x00FFFFFF; + l.radius = 380; + l.intensity = 255; + l.curIntensity = 255; + l.lightType = LIGHT_TYPE_STATIC; + break; + } + if (autoBakeLighting) BakeLighting(); + statusMessage = "Применен пресет света."; +} + +void EditorApp::SnapLightToGrid(int lightIdx) { + if (lightIdx < 0 || lightIdx >= static_cast(lightSources.size())) return; + PushUndo(); + auto& l = lightSources[lightIdx]; + int cellX = std::clamp(l.x / 64, 0, MAP_GRID_W - 1); + int cellY = std::clamp(l.y / 64, 0, MAP_GRID_H - 1); + l.x = cellX * 64 + 32; + l.y = cellY * 64 + 32; + if (autoBakeLighting) BakeLighting(); + statusMessage = "Источник света выровнен по центру клетки (" + std::to_string(cellX) + ", " + std::to_string(cellY) + ")."; +} + +void EditorApp::RenderLightingSettings() { + auto edited = GetLightingSettings(); + bool changed = false, activated = false; + auto undoItem = [&]() { activated |= ImGui::IsItemActivated(); }; + + const char* modes[] = { "Без освещения (Fullbright)", "Только запечённый свет (Baked)", "Запечённый + Динамический (Full Physics)" }; + int mode = edited.mode; + changed |= ImGui::Combo("Режим света", &mode, modes, 3); undoItem(); edited.mode = mode; + + int ambient = edited.ambient; + changed |= ImGui::SliderInt("Фоновый свет (Ambient)", &ambient, 0, 255); undoItem(); edited.ambient = ambient; + + bool flash = (edited.flashlight != 0); + changed |= ImGui::Checkbox("Фонарик игрока [F]", &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 (autoBakeLighting) BakeLighting(); + } + + ImGui::Spacing(); + ImGui::Checkbox("Авто-запекание (Live Realtime Update)", &autoBakeLighting); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Мгновенно обновляет карту теней и 3D вид при любых изменениях источников и стен."); + + ImGui::Checkbox("Карта запечённого света (Хитмап на 2D сетке)", &showLightHeatmap); + + if (ImGui::Button("💡 Запечь постоянный свет сейчас", ImVec2(-1, 26))) { + PushUndo(); + BakeLighting(); + } + + if (LightingNeedsBake()) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.2f, 1.0f), "⚠ Геометрия или источники изменены: требуется запекание"); + } else { + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "✔ Постоянный свет запечён (1024 ячейки)"); + } +} + +void EditorApp::RenderLightingInspector() { + if (selectedLightIdx < 0 || selectedLightIdx >= static_cast(lightSources.size())) return; + auto& light = lightSources[selectedLightIdx]; + + const char* typeDesc = (light.lightType == LIGHT_TYPE_STATIC) ? "Постоянный (Запекаемый)" : + (light.lightType == LIGHT_TYPE_PULSE) ? "Пульсирующий (Сирена)" : + (light.flags & LIGHT_FLAG_FLICKER) ? "Мерцающий факел" : "Точечный (Динамический)"; + + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.25f, 1.0f), "Источник света #%d из %d: %s", + selectedLightIdx + 1, static_cast(lightSources.size()), typeDesc); + ImGui::Separator(); + + // Quick Action Bar (2x2 Grid) + float colW2 = (ImGui::GetContentRegionAvail().x - 6.0f) * 0.5f; + if (ImGui::Button("Фокус (F)", ImVec2(colW2, 24))) { FocusSelectedEntity(); } + ImGui::SameLine(); + if (ImGui::Button("Дублировать (Ctrl+D)", ImVec2(colW2, 24))) { DuplicateSelectedEntity(); } + + if (ImGui::Button("К сетке (Snap)", ImVec2(colW2, 24))) { SnapLightToGrid(selectedLightIdx); } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.55f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.20f, 0.20f, 1.0f)); + if (ImGui::Button("Удалить (Del)", ImVec2(colW2, 24))) { + ImGui::PopStyleColor(2); + DeleteSelectedEntity(); + return; + } + ImGui::PopStyleColor(2); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), "Быстрые пресеты источников:"); + + // Preset Buttons: Clean 3-Column Grid (all 7 presets visible!) + float colW3 = (ImGui::GetContentRegionAvail().x - 12.0f) / 3.0f; + if (ImGui::Button("Факел", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_TORCH); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Факел: Тёплый огонь (оранжевый, 240px, мерцание, тени)"); + ImGui::SameLine(); + if (ImGui::Button("Лампа", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_LAMP); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Лампа: Запекаемый дневной свет (280px, статика)"); + ImGui::SameLine(); + if (ImGui::Button("Сирена", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_ALARM); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Сирена: Аварийный маяк (красный, 300px, пульсация)"); + + if (ImGui::Button("Свеча", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_CANDLE); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Свеча: Мягкий тёплый огонёк (140px, мерцание)"); + ImGui::SameLine(); + if (ImGui::Button("Неон", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_NEON); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Неон: Циановый неоновый свет (260px, динамика)"); + ImGui::SameLine(); + if (ImGui::Button("Токсик", ImVec2(colW3, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_TOXIC); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Токсик: Зелёное радиационное свечение (250px, пульс)"); + + if (ImGui::Button("Прожектор (Направленный)", ImVec2(-1, 24))) ApplyLightPreset(selectedLightIdx, LIGHT_PRESET_SPOTLIGHT); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Прожектор: Мощный белый свет (380px, статика)"); + + ImGui::Separator(); + + EditorLightSource edited = light; + bool changed = false, activated = false; + auto undoItem = [&]() { activated |= ImGui::IsItemActivated(); }; + + // Toggle Active + 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); + + // Coordinates with Cell info + int curCellX = edited.x / 64; + int curCellY = edited.y / 64; + ImGui::Text("Клетка: [%d, %d] | Мировые координаты: (%d, %d)", curCellX, curCellY, edited.x, edited.y); + float xCells = edited.x / 64.0f; + float yCells = edited.y / 64.0f; + if (ImGui::SliderFloat("Позиция X (клетки)", &xCells, 0.5f, 31.5f, "%.2f")) { + edited.x = std::clamp(static_cast(xCells * 64.0f), 32, (MAP_GRID_W - 1) * 64); + changed = true; + } undoItem(); + if (ImGui::SliderFloat("Позиция Y (клетки)", &yCells, 0.5f, 31.5f, "%.2f")) { + edited.y = std::clamp(static_cast(yCells * 64.0f), 32, (MAP_GRID_H - 1) * 64); + changed = true; + } undoItem(); + + // Radius & Intensity + changed |= ImGui::SliderInt("Радиус (px)", &edited.radius, 32, 2048, "%d px"); undoItem(); + changed |= ImGui::SliderInt("Яркость", &edited.intensity, 0, 255, "%d / 255"); undoItem(); + edited.curIntensity = edited.intensity; + + // Color Picker & Quick Swatches + 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(); + + // Quick Color Swatches + ImGui::TextDisabled("Оттенки:"); + ImGui::SameLine(); + auto ColorSwatch = [&](const char* label, uint32_t hexColor) { + float c[3] = { ((hexColor >> 16) & 255) / 255.f, ((hexColor >> 8) & 255) / 255.f, (hexColor & 255) / 255.f }; + if (ImGui::ColorButton(label, ImVec4(c[0], c[1], c[2], 1.0f), 0, ImVec2(18, 18))) { + edited.color = hexColor; + changed = true; + } + ImGui::SameLine(); + }; + ColorSwatch("##warm_fire", 0x00FFB040); + ColorSwatch("##cool_white", 0x00E0E8FF); + ColorSwatch("##neon_cyan", 0x0000E5FF); + ColorSwatch("##ruby_red", 0x00FF2020); + ColorSwatch("##toxic_green", 0x0039FF14); + ColorSwatch("##pure_white", 0x00FFFFFF); + ImGui::NewLine(); + + // Light Type + const char* types[] = { "Постоянный (запекание в lightmap)", "Точечный (динамический реалтайм)", "Пульсирующий (динамическая сирена)" }; + 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(); + + // Behavior flags + bool shadows = (edited.flags & LIGHT_FLAG_SHADOWS) != 0; + changed |= ImGui::Checkbox("Тени от стен и дверей", &shadows); undoItem(); + edited.flags = (edited.flags & ~LIGHT_FLAG_SHADOWS) | (shadows ? LIGHT_FLAG_SHADOWS : 0); + + if (edited.lightType != LIGHT_TYPE_STATIC) { + bool flicker = (edited.flags & LIGHT_FLAG_FLICKER) != 0; + changed |= ImGui::Checkbox("Мерцание огня / факела (Flicker)", &flicker); undoItem(); + edited.flags = (edited.flags & ~LIGHT_FLAG_FLICKER) | (flicker ? LIGHT_FLAG_FLICKER : 0); + } + + if (activated) PushUndo(); + if (changed) { + light = edited; + if (autoBakeLighting) BakeLighting(); + } + + ImGui::Spacing(); + ImGui::Separator(); + if (ImGui::CollapsingHeader("Глобальное освещение карты")) { + 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 4389155..7711e93 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 @@ -48,7 +48,8 @@ enum EntityArchetype : uint8_t { ENTITY_ARCHETYPE_AMMO = 3, // Ammo pickup (+20 Ammo) ENTITY_ARCHETYPE_KEY = 4, // Door Key pickup ENTITY_ARCHETYPE_SWITCH = 5, // Interactive Switch / Trigger - ENTITY_ARCHETYPE_COUNT = 6 + ENTITY_ARCHETYPE_BARREL = 6, // Explosive Barrel (AOE Blast) + ENTITY_ARCHETYPE_COUNT = 7 }; #pragma pack(push, 1) @@ -65,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) @@ -78,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 { @@ -104,6 +106,7 @@ struct SpriteEntry { inline uint8_t GetSpriteEntityType(const SpriteEntry& s) { uint8_t t = static_cast(s.flags & 0xFF); if (s.flags == 0) { + if (s.textureId == 11) return ENTITY_ARCHETYPE_BARREL; if (s.textureId == 13) return ENTITY_ARCHETYPE_GUARD; if (s.textureId == 14) return ENTITY_ARCHETYPE_MEDKIT; if (s.textureId == 15) return ENTITY_ARCHETYPE_AMMO; @@ -136,3 +139,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/main.cpp b/editor/src/main.cpp index 1cb101c..82bf38f 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -30,46 +30,51 @@ void SetupGodotStyle() { ImGuiStyle& style = ImGui::GetStyle(); ImVec4* colors = style.Colors; - style.WindowRounding = 4.0f; - style.FrameRounding = 3.0f; - style.PopupRounding = 4.0f; - style.ScrollbarRounding = 4.0f; - style.GrabRounding = 3.0f; - style.TabRounding = 4.0f; + style.WindowRounding = 8.0f; + style.ChildRounding = 6.0f; + style.FrameRounding = 5.0f; + style.PopupRounding = 8.0f; + style.ScrollbarRounding = 7.0f; + style.GrabRounding = 5.0f; + style.TabRounding = 6.0f; style.FrameBorderSize = 1.0f; style.WindowBorderSize = 1.0f; + style.WindowPadding = ImVec2(10.0f, 10.0f); + style.FramePadding = ImVec2(8.0f, 5.0f); + style.ItemSpacing = ImVec2(8.0f, 6.0f); + style.ItemInnerSpacing = ImVec2(6.0f, 4.0f); - // Dark graphite palette (Godot 4 inspired) - colors[ImGuiCol_Text] = ImVec4(0.92f, 0.92f, 0.95f, 1.00f); - colors[ImGuiCol_TextDisabled] = ImVec4(0.50f, 0.52f, 0.55f, 1.00f); - colors[ImGuiCol_WindowBg] = ImVec4(0.14f, 0.15f, 0.17f, 1.00f); - colors[ImGuiCol_ChildBg] = ImVec4(0.12f, 0.13f, 0.15f, 1.00f); - colors[ImGuiCol_PopupBg] = ImVec4(0.16f, 0.17f, 0.20f, 1.00f); - colors[ImGuiCol_Border] = ImVec4(0.24f, 0.25f, 0.28f, 1.00f); + // Modern Game Studio Dark Palette (Unreal / Unity / Godot Pro) + colors[ImGuiCol_Text] = ImVec4(0.96f, 0.97f, 0.99f, 1.00f); + colors[ImGuiCol_TextDisabled] = ImVec4(0.50f, 0.54f, 0.60f, 1.00f); + colors[ImGuiCol_WindowBg] = ImVec4(0.11f, 0.12f, 0.14f, 1.00f); + colors[ImGuiCol_ChildBg] = ImVec4(0.14f, 0.15f, 0.18f, 1.00f); + colors[ImGuiCol_PopupBg] = ImVec4(0.14f, 0.16f, 0.20f, 0.98f); + colors[ImGuiCol_Border] = ImVec4(0.24f, 0.26f, 0.31f, 1.00f); colors[ImGuiCol_BorderShadow] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); - colors[ImGuiCol_FrameBg] = ImVec4(0.20f, 0.21f, 0.24f, 1.00f); - colors[ImGuiCol_FrameBgHovered] = ImVec4(0.28f, 0.30f, 0.34f, 1.00f); - colors[ImGuiCol_FrameBgActive] = ImVec4(0.32f, 0.35f, 0.40f, 1.00f); - colors[ImGuiCol_TitleBg] = ImVec4(0.11f, 0.12f, 0.13f, 1.00f); - colors[ImGuiCol_TitleBgActive] = ImVec4(0.16f, 0.17f, 0.20f, 1.00f); - colors[ImGuiCol_MenuBarBg] = ImVec4(0.11f, 0.12f, 0.14f, 1.00f); - colors[ImGuiCol_ScrollbarBg] = ImVec4(0.10f, 0.11f, 0.12f, 0.60f); - colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.25f, 0.27f, 0.30f, 1.00f); - colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.35f, 0.38f, 0.42f, 1.00f); - colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.45f, 0.48f, 0.52f, 1.00f); - colors[ImGuiCol_CheckMark] = ImVec4(0.30f, 0.70f, 1.00f, 1.00f); - colors[ImGuiCol_SliderGrab] = ImVec4(0.30f, 0.65f, 0.95f, 1.00f); - colors[ImGuiCol_SliderGrabActive] = ImVec4(0.40f, 0.75f, 1.00f, 1.00f); - colors[ImGuiCol_Button] = ImVec4(0.22f, 0.24f, 0.27f, 1.00f); - colors[ImGuiCol_ButtonHovered] = ImVec4(0.30f, 0.33f, 0.38f, 1.00f); - colors[ImGuiCol_ButtonActive] = ImVec4(0.38f, 0.42f, 0.48f, 1.00f); - colors[ImGuiCol_Header] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f); - colors[ImGuiCol_HeaderHovered] = ImVec4(0.32f, 0.35f, 0.40f, 1.00f); - colors[ImGuiCol_HeaderActive] = ImVec4(0.38f, 0.42f, 0.48f, 1.00f); - colors[ImGuiCol_Separator] = ImVec4(0.22f, 0.24f, 0.27f, 1.00f); - colors[ImGuiCol_Tab] = ImVec4(0.16f, 0.18f, 0.20f, 1.00f); - colors[ImGuiCol_TabHovered] = ImVec4(0.32f, 0.35f, 0.40f, 1.00f); - colors[ImGuiCol_TabActive] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f); + colors[ImGuiCol_FrameBg] = ImVec4(0.18f, 0.20f, 0.25f, 1.00f); + colors[ImGuiCol_FrameBgHovered] = ImVec4(0.25f, 0.29f, 0.36f, 1.00f); + colors[ImGuiCol_FrameBgActive] = ImVec4(0.30f, 0.36f, 0.45f, 1.00f); + colors[ImGuiCol_TitleBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f); + colors[ImGuiCol_TitleBgActive] = ImVec4(0.13f, 0.15f, 0.19f, 1.00f); + colors[ImGuiCol_MenuBarBg] = ImVec4(0.10f, 0.11f, 0.13f, 1.00f); + colors[ImGuiCol_ScrollbarBg] = ImVec4(0.09f, 0.10f, 0.12f, 0.80f); + colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.25f, 0.28f, 0.35f, 1.00f); + colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.34f, 0.39f, 0.48f, 1.00f); + colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.42f, 0.48f, 0.60f, 1.00f); + colors[ImGuiCol_CheckMark] = ImVec4(0.05f, 0.65f, 0.95f, 1.00f); + colors[ImGuiCol_SliderGrab] = ImVec4(0.05f, 0.60f, 0.92f, 1.00f); + colors[ImGuiCol_SliderGrabActive] = ImVec4(0.20f, 0.75f, 1.00f, 1.00f); + colors[ImGuiCol_Button] = ImVec4(0.20f, 0.23f, 0.29f, 1.00f); + colors[ImGuiCol_ButtonHovered] = ImVec4(0.28f, 0.34f, 0.43f, 1.00f); + colors[ImGuiCol_ButtonActive] = ImVec4(0.35f, 0.43f, 0.55f, 1.00f); + colors[ImGuiCol_Header] = ImVec4(0.20f, 0.25f, 0.32f, 1.00f); + colors[ImGuiCol_HeaderHovered] = ImVec4(0.28f, 0.35f, 0.45f, 1.00f); + colors[ImGuiCol_HeaderActive] = ImVec4(0.35f, 0.44f, 0.56f, 1.00f); + colors[ImGuiCol_Separator] = ImVec4(0.22f, 0.24f, 0.29f, 1.00f); + colors[ImGuiCol_Tab] = ImVec4(0.14f, 0.16f, 0.20f, 1.00f); + colors[ImGuiCol_TabHovered] = ImVec4(0.28f, 0.34f, 0.42f, 1.00f); + colors[ImGuiCol_TabActive] = ImVec4(0.22f, 0.27f, 0.35f, 1.00f); } int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { @@ -90,7 +95,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine return 1; } - ShowWindow(hWnd, SW_SHOWDEFAULT); + ShowWindow(hWnd, SW_MAXIMIZE); UpdateWindow(hWnd); DragAcceptFiles(hWnd, TRUE); 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 e91e9d0..e1334ef 100644 --- a/editor/src/project_manager.h +++ b/editor/src/project_manager.h @@ -16,6 +16,12 @@ struct EblanProject { std::string windowTitle = "EBLAN ENGINE 3D - Standalone Retro Game"; int defaultWidth = 1280; int defaultHeight = 960; + 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); @@ -31,6 +37,15 @@ struct EblanProject { content += std::string("DefaultWidth=") + numBuf + "\r\n"; snprintf(numBuf, sizeof(numBuf), "%d", defaultHeight); content += std::string("DefaultHeight=") + numBuf + "\r\n"; + snprintf(numBuf, sizeof(numBuf), "%d", gameMode); + 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); @@ -65,6 +80,12 @@ struct EblanProject { else if (key == "WindowTitle") windowTitle = val; else if (key == "DefaultWidth") defaultWidth = std::atoi(val.c_str()); else if (key == "DefaultHeight") defaultHeight = std::atoi(val.c_str()); + 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 63585e8..173d523 100644 Binary files a/engine.exe and b/engine.exe differ diff --git a/include/engine_combat.inc b/include/engine_combat.inc index 48dba13..8f4f0d2 100644 --- a/include/engine_combat.inc +++ b/include/engine_combat.inc @@ -1,5 +1,5 @@ ; ============================================================================== -; EBLAN ENGINE - Player Combat, Weapon Shooting & Combat HUD +; EBLAN ENGINE - Player Combat, Multi-Weapon Arsenal & Combat HUD ; ============================================================================== .code @@ -9,6 +9,8 @@ DrawRect PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD DrawNumber8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD +ShowGameToast PROTO :DWORD, :DWORD, :DWORD +Game_OnEntityKilled PROTO :DWORD, :DWORD ; Trace the current world along the player's forward direction. EAX = first ; blocking boundary in 24.8 world units. Glass blocks shots (unlike rendering). @@ -143,6 +145,7 @@ UpdateCombat PROC uses ebx ecx edx esi edi LOCAL sprX:DWORD, sprY:DWORD LOCAL transX:DWORD, transY:DWORD LOCAL targetHalfWidth:DWORD, wallDepth:DWORD + LOCAL weaponDamage:DWORD, maxHitDist:DWORD, hitHalfWidth:DWORD call IsGameplayBlocked test eax, eax @@ -162,9 +165,17 @@ UpdateCombat PROC uses ebx ecx edx esi edi dec playerHitFlash @@: - ; If player is dead, cannot attack + ; If player is dead, game is paused, or in peaceful adventure mode, cannot attack cmp isPlayerDead, 0 jne combat_done + cmp isGamePaused, 0 + jne combat_done + cmp gameMode, GAME_MODE_ADVENTURE + je combat_done + cmp enableWeapons, 0 + je combat_done + cmp playerWeapon, WEAPON_NONE + je combat_done ; 2. Check attack input cmp keyAttack, 0 @@ -172,16 +183,53 @@ UpdateCombat PROC uses ebx ecx edx esi edi cmp playerAttackCooldown, 0 jne combat_done - ; Check ammo - cmp playerAmmo, 0 - jle combat_dry_fire + ; Set weapon parameters based on playerWeapon + mov weaponDamage, 35 + mov maxHitDist, 999999 + mov hitHalfWidth, 16 - ; Fire weapon! + cmp playerWeapon, WEAPON_KNIFE + je setup_knife + cmp playerWeapon, WEAPON_SHOTGUN + je setup_shotgun + jmp setup_pistol + +setup_knife: + ; Knife: no ammo needed, close range (within 64 units = 1 cell), fast cooldown + mov weaponDamage, 30 + mov maxHitDist, 64 + mov hitHalfWidth, 24 + mov playerAttackCooldown, 8 + mov playerFlashTimer, 3 + jmp fire_hitscan + +setup_shotgun: + ; Shotgun: needs 2 ammo, high damage (70), wider world hitbox, long cooldown + cmp playerAmmo, 2 + jl combat_dry_fire + sub playerAmmo, 2 + mov weaponDamage, 70 + mov maxHitDist, 999999 + mov hitHalfWidth, 32 + mov playerAttackCooldown, 22 + mov playerFlashTimer, 7 + invoke SpawnMuzzleFlashLight, playerX, playerY + jmp fire_hitscan + +setup_pistol: + ; Pistol: needs 1 ammo, accurate world hitbox, medium cooldown (12) + cmp playerAmmo, 1 + jl combat_dry_fire dec playerAmmo - mov playerAttackCooldown, 10 + mov weaponDamage, 35 + mov maxHitDist, 999999 + mov hitHalfWidth, 16 + mov playerAttackCooldown, 12 mov playerFlashTimer, 5 + invoke SpawnMuzzleFlashLight, playerX, playerY - ; 3. Hitscan check against enemies in center crosshair +fire_hitscan: + ; 3. Hitscan check against enemies & destructible barrels in crosshair ; Camera basis for -playerAngle mov eax, ANGLE_512 sub eax, playerAngle @@ -206,9 +254,16 @@ hitscan_loop: mov ecx, i mov eax, [entityType + ecx*4] - cmp eax, ENTITY_TYPE_GUARD - jne next_hitscan_target + ; Check if Guard or Barrel + cmp eax, ENTITY_TYPE_GUARD + je hitscan_target_valid + cmp eax, ENTITY_TYPE_BARREL + je hitscan_target_valid + + jmp next_hitscan_target + +hitscan_target_valid: mov eax, [entityState + ecx*4] cmp eax, ENTITY_STATE_DEAD je next_hitscan_target @@ -245,6 +300,11 @@ hitscan_loop: cmp transY, 16 jle next_hitscan_target + ; Must be closer than maximum weapon hit distance + mov eax, transY + cmp eax, maxHitDist + jg next_hitscan_target + ; Compare in 24.8 so a target on a wall boundary is not hit through it. mov eax, transY shl eax, 8 @@ -262,14 +322,14 @@ hitscan_loop: add ecx, eax mov transX, ecx - ; World-space billboard hitbox: 32 units wide at scale 1.0, scaled with + ; World-space billboard hitbox: weapon-specific width, scaled with ; the authored sprite, never with screen distance. Zero means legacy 1.0. mov eax, [ebx].SpriteStruct.scale test eax, eax jnz @F mov eax, FIXED_ONE @@: - imul eax, 16 + imul eax, hitHalfWidth sar eax, 16 mov targetHalfWidth, eax mov eax, transX @@ -295,10 +355,15 @@ hitscan_eval: cmp closestIdx, -1 je combat_done - ; Apply damage to hit guard mov ecx, closestIdx + mov eax, [entityType + ecx*4] + + ; Check if hit an explosive barrel! + cmp eax, ENTITY_TYPE_BARREL + je explode_barrel + ; Hit an enemy guard! mov eax, [entityHealth + ecx*4] - sub eax, 35 + sub eax, weaponDamage jns @F xor eax, eax @@: @@ -310,8 +375,9 @@ hitscan_eval: mov [entityState + ecx*4], ENTITY_STATE_DEAD mov DWORD PTR [entityTimer + ecx*4], 0 add playerScore, 100 + invoke Game_OnEntityKilled, closestIdx, ENTITY_TYPE_GUARD - ; Drop 10 ammo reward + ; Drop ammo reward mov eax, playerAmmo add eax, 10 cmp eax, playerMaxAmmo @@ -321,6 +387,115 @@ hitscan_eval: mov playerAmmo, eax jmp combat_done +explode_barrel: + ; Exploding Barrel: Destroy barrel and trigger AOE blast! + mov [entityState + ecx*4], ENTITY_STATE_DEAD + mov ebx, closestIdx + imul ebx, sizeof SpriteStruct + add ebx, OFFSET spriteList + mov [ebx].SpriteStruct.textureId, 0 ; disappear + + ; Fiery orange-red screen flash + mov playerHitFlash, 8 + add playerScore, 200 + invoke ShowGameToast, OFFSET szBarrelExplodedToast, 60, 00FFA020h + invoke Game_OnEntityKilled, closestIdx, ENTITY_TYPE_BARREL + + ; AOE damage to all surrounding guards within 128 units (2 cells) + push esi + push edi + mov esi, [ebx].SpriteStruct.x + mov edi, [ebx].SpriteStruct.y + invoke SpawnExplosionLight, esi, edi + + mov i, 0 +aoe_loop: + mov eax, i + cmp eax, spriteCount + jge aoe_check_player + + mov ecx, i + cmp ecx, closestIdx + je next_aoe + + mov eax, [entityType + ecx*4] + cmp eax, ENTITY_TYPE_GUARD + jne next_aoe + cmp [entityState + ecx*4], ENTITY_STATE_DEAD + je next_aoe + + ; EDX is volatile scratch for CDQ below, not a safe sprite pointer. + mov ebx, i + imul ebx, sizeof SpriteStruct + add ebx, OFFSET spriteList + + mov eax, [ebx].SpriteStruct.x + sub eax, esi + cdq + xor eax, edx + sub eax, edx + cmp eax, 128 + jg next_aoe + + mov eax, [ebx].SpriteStruct.y + sub eax, edi + cdq + xor eax, edx + sub eax, edx + cmp eax, 128 + jg next_aoe + + ; Within blast: deal 60 blast damage! + mov eax, [entityHealth + ecx*4] + sub eax, 60 + jns @F + xor eax, eax +@@: + mov [entityHealth + ecx*4], eax + test eax, eax + jnz next_aoe + + mov [entityState + ecx*4], ENTITY_STATE_DEAD + add playerScore, 150 + +next_aoe: + inc i + jmp aoe_loop + +aoe_check_player: + ; If player was standing too close to the barrel (< 96 units), player takes blast damage! + mov eax, playerX + sub eax, esi + cdq + xor eax, edx + sub eax, edx + cmp eax, 96 + jg aoe_done + + mov eax, playerY + sub eax, edi + cdq + xor eax, edx + sub eax, edx + cmp eax, 96 + jg aoe_done + + ; Player blast damage + mov eax, playerHealth + sub eax, 25 + jns @F + xor eax, eax +@@: + mov playerHealth, eax + test eax, eax + jnz aoe_done + mov isPlayerDead, 1 + +aoe_done: + pop edi + pop esi + jmp combat_done + combat_dry_fire: mov playerAttackCooldown, 12 @@ -334,24 +509,91 @@ UpdateCombat ENDP RenderCombatHUD PROC uses ebx ecx edx esi edi LOCAL barW:DWORD, gunY:DWORD + ; If in adventure mode, unarmed, or weapons disabled, skip weapon & combat stats + cmp gameMode, GAME_MODE_ADVENTURE + je hud_damage_flash + cmp enableWeapons, 0 + je hud_damage_flash + cmp playerWeapon, WEAPON_NONE + je hud_damage_flash + ; 1. Center Crosshair (subtle cross at 160, 120) + cmp showCrosshair, 0 + je skip_crosshair invoke DrawRect, 156, 120, 9, 1, 00FFFFFFh invoke DrawRect, 160, 116, 1, 9, 00FFFFFFh invoke DrawRect, 160, 120, 1, 1, 0000FF00h +skip_crosshair: - ; 2. Render Retro Weapon at bottom center + ; 2. Render Active Weapon at bottom center + cmp playerWeapon, WEAPON_KNIFE + je render_knife_weapon + cmp playerWeapon, WEAPON_SHOTGUN + je render_shotgun_weapon + jmp render_pistol_weapon + +render_knife_weapon: + ; Knife Combat Animation: thrusts forward when cooldown active + mov gunY, 190 + cmp playerFlashTimer, 0 + je draw_knife + sub gunY, 12 ; thrust forward! + +draw_knife: + ; Silver steel blade + invoke DrawRect, 166, gunY, 6, 26, 00D0D0D0h + invoke DrawRect, 168, gunY, 2, 26, 00FFFFFFh ; shine + invoke DrawRect, 167, gunY - 4, 4, 4, 00B0B0B0h ; blade tip + invoke DrawRect, 168, gunY - 7, 2, 3, 00E0E0E0h + ; Brass crossguard + mov eax, gunY + add eax, 26 + invoke DrawRect, 160, eax, 18, 5, 00D4AF37h + ; Wooden grip + mov eax, gunY + add eax, 31 + invoke DrawRect, 164, eax, 10, 22, 00502A10h + jmp hud_stats_draw + +render_shotgun_weapon: + ; Heavy Pump Shotgun + mov gunY, 188 + cmp playerFlashTimer, 0 + je draw_shotgun_barrels + add gunY, 4 ; heavy recoil! + + ; Massive dual blast flash! + invoke DrawRect, 146, 155, 28, 22, 00FFAA00h + invoke DrawRect, 150, 158, 20, 16, 00FFFF00h + invoke DrawRect, 154, 162, 12, 10, 00FFFFFFh + +draw_shotgun_barrels: + ; Twin heavy steel barrels + invoke DrawRect, 148, gunY, 10, 32, 002B2B2Bh + invoke DrawRect, 150, gunY, 6, 32, 00484848h + invoke DrawRect, 162, gunY, 10, 32, 002B2B2Bh + invoke DrawRect, 164, gunY, 6, 32, 00484848h + ; Barrel rib connector + invoke DrawRect, 158, gunY, 4, 32, 00222222h + ; Wooden pump forend + mov eax, gunY + add eax, 20 + invoke DrawRect, 144, eax, 32, 18, 00603818h + invoke DrawRect, 146, eax, 28, 14, 007A4820h + jmp hud_stats_draw + +render_pistol_weapon: mov gunY, 192 cmp playerFlashTimer, 0 - je draw_gun_barrel - ; Recoil offset - add gunY, 3 + je draw_pistol_barrel + add gunY, 3 ; recoil offset ; Muzzle Flash Burst (Bright yellow & white star) invoke DrawRect, 157, 165, 7, 18, 00FFFF00h invoke DrawRect, 152, 170, 17, 8, 00FFAA00h invoke DrawRect, 158, 168, 5, 12, 00FFFFFFh -draw_gun_barrel: +draw_pistol_barrel: ; Weapon barrel & slide invoke DrawRect, 154, gunY, 13, 28, 003A3A3Ah invoke DrawRect, 156, gunY, 9, 28, 00555555h @@ -364,15 +606,16 @@ draw_gun_barrel: add eax, 26 invoke DrawRect, 152, eax, 17, 22, 00453020h ; brown wood grip plates - ; 3. Health Bar - invoke DrawString8x8, 8, 42, OFFSET strHpLabel, COLOR_GREEN - invoke DrawNumber8x8, 34, 42, playerHealth, COLOR_WHITE +hud_stats_draw: + ; 3. Health Bar at top-left: X=8, Y=8 + invoke DrawString8x8, 8, 8, OFFSET strHpLabel, COLOR_GREEN + invoke DrawNumber8x8, 34, 8, playerHealth, COLOR_WHITE - invoke DrawRect, 68, 42, 60, 8, 00441010h - invoke DrawRectOutline, 68, 42, 60, 8, COLOR_WHITE + invoke DrawRect, 66, 8, 50, 8, 00441010h + invoke DrawRectOutline, 66, 8, 50, 8, COLOR_WHITE mov eax, playerHealth - imul eax, 58 + imul eax, 48 xor edx, edx mov ecx, playerMaxHealth test ecx, ecx @@ -383,34 +626,66 @@ draw_gun_barrel: mov barW, eax cmp barW, 0 jle hud_ammo_draw - cmp barW, 58 + cmp barW, 48 jle @F - mov barW, 58 + mov barW, 48 @@: mov edx, 0000CC33h ; Green cmp playerHealth, 30 jg @F mov edx, 00FF2222h ; Red warning @@: - invoke DrawRect, 69, 43, barW, 6, edx + invoke DrawRect, 67, 9, barW, 6, edx hud_ammo_draw: - ; 4. Ammo Display - invoke DrawString8x8, 140, 42, OFFSET strAmmoLabel, COLOR_YELLOW - invoke DrawNumber8x8, 182, 42, playerAmmo, COLOR_YELLOW + ; 4. Ammo Display: X=126, Y=8 + invoke DrawString8x8, 126, 8, OFFSET strAmmoLabel, COLOR_YELLOW + invoke DrawNumber8x8, 168, 8, playerAmmo, COLOR_YELLOW - ; 5. Score Display - invoke DrawString8x8, 222, 42, OFFSET strScoreLabel, COLOR_CYAN - invoke DrawNumber8x8, 272, 42, playerScore, COLOR_CYAN - - ; 6. Keys (if collected) + ; 5. Keys: X=204, Y=8 (if collected) cmp playerKeys, 0 - jle hud_damage_flash - invoke DrawString8x8, 222, 10, OFFSET strKeysLabel, COLOR_MAGENTA - invoke DrawNumber8x8, 258, 10, playerKeys, COLOR_MAGENTA + jle hud_score_draw + invoke DrawString8x8, 204, 8, OFFSET strKeysLabel, COLOR_MAGENTA + invoke DrawNumber8x8, 238, 8, playerKeys, COLOR_MAGENTA + +hud_score_draw: + ; 6. Score Display: only in Debug HUD style + cmp hudStyle, HUD_STYLE_CLEAN + je draw_weapon_badge + invoke DrawString8x8, 8, 20, OFFSET strScoreLabel, COLOR_CYAN + invoke DrawNumber8x8, 58, 20, playerScore, COLOR_CYAN + +draw_weapon_badge: + ; 7. Active Weapon Badge at bottom-right corner: X=228, Y=222 + invoke DrawRect, 228, 222, 88, 14, 00141414h + invoke DrawRectOutline, 228, 222, 88, 14, 00444444h + + cmp playerWeapon, WEAPON_KNIFE + jne @F + invoke DrawString8x8, 233, 225, OFFSET strWeaponKnife, COLOR_CYAN + jmp draw_weapon_slots +@@: + cmp playerWeapon, WEAPON_SHOTGUN + jne @F + invoke DrawString8x8, 233, 225, OFFSET strWeaponShotgun, 00FFAA00h + jmp draw_weapon_slots +@@: + invoke DrawString8x8, 233, 225, OFFSET strWeaponPistol, COLOR_YELLOW + +draw_weapon_slots: + ; Draw small weapon slot indicator boxes: [1] [2] [3] + invoke DrawRectOutline, 287, 225, 6, 8, 00555555h + invoke DrawRectOutline, 295, 225, 6, 8, 00555555h + invoke DrawRectOutline, 303, 225, 6, 8, 00555555h + + ; Highlight active slot + mov eax, playerWeapon + imul eax, 8 + add eax, 287 + invoke DrawRectOutline, eax, 225, 6, 8, COLOR_YELLOW hud_damage_flash: - ; 7. Damage Screen Flash + ; 8. Damage Screen Flash cmp playerHitFlash, 0 jle hud_death_check ; Red border vignette @@ -420,7 +695,7 @@ hud_damage_flash: invoke DrawRect, 317, 0, 3, SCREEN_H, 00FF0000h hud_death_check: - ; 8. Death Overlay Screen + ; 9. Death Overlay Screen cmp isPlayerDead, 0 je combat_hud_done @@ -431,3 +706,29 @@ hud_death_check: combat_hud_done: ret RenderCombatHUD ENDP + +; ------------------------------------------------------------------------------ +; RenderPauseMenu: Renders stylish retro pause overlay when isGamePaused != 0 +; ------------------------------------------------------------------------------ +RenderPauseMenu PROC uses ebx ecx edx + cmp isGamePaused, 0 + je pause_menu_done + + ; Centered semi-transparent panel 220x112 at (50, 64) + invoke DrawRect, 50, 64, 220, 112, 00141416h + invoke DrawRectOutline, 50, 64, 220, 112, COLOR_CYAN + invoke DrawRectOutline, 52, 66, 216, 108, COLOR_WHITE + + ; Header + invoke DrawString8x8, 112, 72, OFFSET strPauseTitle, COLOR_YELLOW + + ; Menu Options + invoke DrawString8x8, 64, 88, OFFSET strPauseOpt1, COLOR_GREEN + invoke DrawString8x8, 64, 102, OFFSET strPauseOpt2, COLOR_WHITE + invoke DrawString8x8, 64, 116, OFFSET strPauseOpt3, COLOR_CYAN + invoke DrawString8x8, 64, 130, OFFSET strPauseOpt4, COLOR_YELLOW + invoke DrawString8x8, 64, 146, OFFSET strPauseOpt5, 00FF4444h + +pause_menu_done: + ret +RenderPauseMenu ENDP diff --git a/include/engine_data.inc b/include/engine_data.inc index b92dfd1..590cc0d 100644 --- a/include/engine_data.inc +++ b/include/engine_data.inc @@ -66,8 +66,36 @@ keyAttack db 0 keyAttackHeld db 0 keyRestart db 0 keyRestartHeld db 0 +keyWeapon1 db 0 +keyWeapon2 db 0 +keyWeapon3 db 0 +keyWeapon4 db 0 +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 +enableWeapons dd 1 ; 1 = Weapons active, 0 = Pure exploration +showCrosshair dd 1 ; 1 = Show crosshair in Action mode, 0 = Hidden ; Player Combat & Gameplay Stats +playerWeapon dd 1 ; Default: WEAPON_PISTOL playerHealth dd 100 playerMaxHealth dd 100 playerAmmo dd 50 @@ -120,7 +148,17 @@ strHpLabel db "HP:",0 strAmmoLabel db "AMMO:",0 strScoreLabel db "SCORE:",0 strKeysLabel db "KEY:",0 -strYouDied db "YOU DIED! PRESS [R] TO RESTART",0 +strWeaponLabel db "WPN:",0 +strWeaponKnife db "KNIFE",0 +strWeaponPistol db "PISTOL",0 +strWeaponShotgun db "SHOTGUN",0 +strPauseTitle db "== ПАУЗА ==",0 +strPauseOpt1 db "[1] ПРОДОЛЖИТЬ",0 +strPauseOpt2 db "[2] ОРУЖИЕ: ВКЛ/ВЫКЛ",0 +strPauseOpt3 db "[3] ИНТЕРФЕЙС: ЧИСТЫЙ/ИНФО",0 +strPauseOpt4 db "[4] ПЕРЕЗАПУСК УРОВНЯ",0 +strPauseOpt5 db "[5] ВЫХОД В WINDOWS",0 +strYouDied db "ВЫ ПОГИБЛИ! НАЖМИТЕ [R]",0 cutsceneCount dd 0 cutsceneStringCount dd 0 cutsceneFlags dd 0 @@ -226,3 +264,70 @@ entityState dd MAX_SPRITES dup(?) 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 +gameToastTimer dd 0 +gameToastColor dd 0 +gameToastText db 64 dup(0) + +ALIGN 4 +gameDialogActive dd 0 +gameDialogSpeaker db 32 dup(0) +gameDialogText db 128 dup(0) + +szDialogCloseHint db "[ENTER / E] - ЗАКРЫТЬ", 0 +szKeyPickedToast db "ПОЛУЧЕН КЛЮЧ", 0 +szMedkitPickedToast db "АПТЕЧКА +25 ЗДОРОВЬЯ", 0 +szAmmoPickedToast db "БОЕПРИПАСЫ +20 ПАТРОНОВ", 0 +szSwitchOnToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВКЛ", 0 +szSwitchOffToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВЫКЛ", 0 +szDoorOpenedToast db "ДВЕРЬ ОТКРЫТА", 0 +szDoorClosedToast db "ДВЕРЬ ЗАКРЫТА", 0 +szBarrelExplodedToast db "ВЗРЫВ БОЧКИ!", 0 +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 +szProjShowWeapons0 db "ShowWeapons=0", 0 +szFlagNoWeapons db "noweapons", 0 +szFlagAdventure db "adventure", 0 +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 f622640..ac2b60b 100644 --- a/include/engine_defs.inc +++ b/include/engine_defs.inc @@ -67,6 +67,49 @@ VK_KEY_TAB equ 9 VK_KEY_R equ 52h VK_KEY_CONTROL equ 11h VK_LBUTTON equ 01h +VK_KEY_1 equ 31h +VK_KEY_2 equ 32h +VK_KEY_3 equ 33h +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) +GAME_MODE_ACTION equ 1 ; Combat Action (Weapons, Crosshair, Ammo, Health bar) + +; HUD Styles +HUD_STYLE_CLEAN equ 0 ; Clean / Immersive (No debug watermark, no bottom bar) +HUD_STYLE_DEBUG equ 1 ; Debug Mode (FPS counter, Engine watermark, Minimap) + +; Weapon Archetypes +WEAPON_NONE equ -1 ; No weapon (Unarmed / Peaceful / Quest) +WEAPON_KNIFE equ 0 ; Melee blade (no ammo, fast, 30 damage) +WEAPON_PISTOL equ 1 ; Handgun (1 ammo, accurate, 35 damage) +WEAPON_SHOTGUN equ 2 ; Pump Shotgun (2 ammo, 3-ray spread, 70 damage) +WEAPON_COUNT equ 3 ; Entity Archetypes & States ENTITY_TYPE_PROP equ 0 @@ -75,6 +118,7 @@ ENTITY_TYPE_MEDKIT equ 2 ; Health pickup (+25 HP) ENTITY_TYPE_AMMO equ 3 ; Ammo pickup (+20 Ammo) ENTITY_TYPE_KEY equ 4 ; Door Key pickup ENTITY_TYPE_SWITCH equ 5 ; Interactive switch trigger +ENTITY_TYPE_BARREL equ 6 ; Explosive barrel (AOE damage) ENTITY_STATE_IDLE equ 0 ENTITY_STATE_CHASE equ 1 @@ -113,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 @@ -138,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' @@ -154,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 @@ -167,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_entity.inc b/include/engine_entity.inc index 9751c68..4ba242a 100644 --- a/include/engine_entity.inc +++ b/include/engine_entity.inc @@ -4,6 +4,9 @@ .code +ShowGameToast PROTO :DWORD, :DWORD, :DWORD +Game_OnPlayerInteract PROTO :DWORD, :DWORD + ; ------------------------------------------------------------------------------ ; InitEntities: Initialize entity runtime state from spriteList flags/defaults ; ------------------------------------------------------------------------------ @@ -86,8 +89,13 @@ init_spr_loop: fallback_archetype: ; If flags == 0, infer archetype from textureId: - ; Slot 13 = Enemy Guard, 14 = Medkit, 15 = Ammo + ; Slot 11 = Explosive Barrel, 13 = Guard, 14 = Medkit, 15 = Ammo mov edx, [ebx].SpriteStruct.textureId + cmp edx, 11 + jne check_guard_slot + mov [entityType + ecx*4], ENTITY_TYPE_BARREL + jmp next_spr_slot +check_guard_slot: cmp edx, 13 jne check_medkit_slot mov [entityType + ecx*4], ENTITY_TYPE_GUARD @@ -217,6 +225,7 @@ entity_loop: mov ecx, i mov [entityType + ecx*4], ENTITY_TYPE_PROP add playerScore, 25 + invoke ShowGameToast, OFFSET szMedkitPickedToast, 75, COLOR_GREEN jmp next_entity ; -------------------------------------------------------------------------- @@ -240,6 +249,7 @@ check_ammo_pickup: mov ecx, i mov [entityType + ecx*4], ENTITY_TYPE_PROP add playerScore, 10 + invoke ShowGameToast, OFFSET szAmmoPickedToast, 75, COLOR_YELLOW jmp next_entity ; -------------------------------------------------------------------------- @@ -247,7 +257,7 @@ check_ammo_pickup: ; -------------------------------------------------------------------------- check_key_pickup: cmp edx, ENTITY_TYPE_KEY - jne check_guard_entity + jne check_switch_entity cmp absDX, 32 jge next_entity cmp absDY, 32 @@ -257,6 +267,39 @@ check_key_pickup: mov ecx, i mov [entityType + ecx*4], ENTITY_TYPE_PROP add playerScore, 50 + invoke ShowGameToast, OFFSET szKeyPickedToast, 90, COLOR_CYAN + jmp next_entity + + ; -------------------------------------------------------------------------- + ; Switch Trigger (Toggle on [E]) + ; -------------------------------------------------------------------------- +check_switch_entity: + cmp edx, ENTITY_TYPE_SWITCH + jne check_guard_entity + cmp absDX, 48 + jge next_entity + cmp absDY, 48 + jge next_entity + cmp keyAction, 0 + je next_entity + mov keyAction, 0 + + ; Check if handled by user gameplay script hook + invoke Game_OnPlayerInteract, i, edx + test eax, eax + jnz next_entity + + ; Default switch behavior: toggle state + mov ecx, i + mov eax, [entityState + ecx*4] + xor eax, 1 + mov [entityState + ecx*4], eax + test eax, eax + jz switch_off + invoke ShowGameToast, OFFSET szSwitchOnToast, 60, COLOR_GREEN + jmp next_entity +switch_off: + invoke ShowGameToast, OFFSET szSwitchOffToast, 60, COLOR_YELLOW jmp next_entity ; -------------------------------------------------------------------------- diff --git a/include/engine_gameplay.inc b/include/engine_gameplay.inc new file mode 100644 index 0000000..74c1289 --- /dev/null +++ b/include/engine_gameplay.inc @@ -0,0 +1,342 @@ +; ============================================================================== +; EBLAN ENGINE - Gameplay Events, Scripting Hooks, HUD Toasts & Dialog Modals +; ============================================================================== + +.code + +DrawPixel PROTO :DWORD, :DWORD, :DWORD +DrawRect PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD +DrawRectOutline PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD +DrawString8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD +DrawNumber8x8 PROTO :DWORD, :DWORD, :DWORD, :DWORD +PlaySoundA PROTO :DWORD, :DWORD, :DWORD + +; ------------------------------------------------------------------------------ +; ShowGameToast: Displays a floating toast notification banner on screen +; ------------------------------------------------------------------------------ +ShowGameToast PROC uses esi edi ecx eax, pszText:DWORD, duration:DWORD, textColor:DWORD + mov esi, pszText + test esi, esi + jz toast_skip_copy + + mov edi, OFFSET gameToastText + mov ecx, 63 +toast_copy_loop: + lodsb + stosb + test al, al + jz toast_copy_done + dec ecx + jnz toast_copy_loop + xor al, al + stosb +toast_copy_done: + +toast_skip_copy: + mov eax, duration + mov gameToastTimer, eax + mov eax, textColor + mov gameToastColor, eax + ret +ShowGameToast ENDP + +; ------------------------------------------------------------------------------ +; RenderGameToast: Draws active toast notification at top center of screen +; ------------------------------------------------------------------------------ +RenderGameToast PROC uses ebx ecx edx esi edi + cmp gameToastTimer, 0 + jle rt_done + dec gameToastTimer + + ; 1. Calculate text character length + mov esi, OFFSET gameToastText + xor ecx, ecx +count_len: + mov al, [esi+ecx] + test al, al + jz count_done + inc ecx + cmp ecx, 63 + jb count_len +count_done: + test ecx, ecx + jz rt_done + + ; Width in pixels = length * 8 + mov eax, ecx + shl eax, 3 + mov edx, eax + + ; Center X = (SCREEN_W - width) / 2 + mov ebx, SCREEN_W + sub ebx, edx + sar ebx, 1 + + ; Box coordinates: X = ebx - 6, Y = 16, W = edx + 12, H = 14 + mov eax, ebx + sub eax, 6 + mov esi, edx + add esi, 12 + + invoke DrawRect, eax, 16, esi, 14, 00101010h + invoke DrawRectOutline, eax, 16, esi, 14, gameToastColor + invoke DrawString8x8, ebx, 19, OFFSET gameToastText, gameToastColor + +rt_done: + ret +RenderGameToast ENDP + +; ------------------------------------------------------------------------------ +; ShowGameDialog: Opens an interactive retro dialogue modal with speaker and text +; ------------------------------------------------------------------------------ +ShowGameDialog PROC uses esi edi ecx eax, pszSpeaker:DWORD, pszText:DWORD + mov esi, pszSpeaker + mov edi, OFFSET gameDialogSpeaker + mov ecx, 31 + test esi, esi + jz no_speaker +copy_spk: + lodsb + stosb + test al, al + jz spk_done + dec ecx + jnz copy_spk + xor al, al + stosb +spk_done: + jmp setup_dlg_text +no_speaker: + mov BYTE PTR [edi], 0 + +setup_dlg_text: + mov esi, pszText + mov edi, OFFSET gameDialogText + mov ecx, 127 + test esi, esi + jz no_text +copy_txt: + lodsb + stosb + test al, al + jz txt_done + dec ecx + jnz copy_txt + xor al, al + stosb +txt_done: + jmp activate_dlg +no_text: + mov BYTE PTR [edi], 0 + +activate_dlg: + mov gameDialogActive, 1 + ret +ShowGameDialog ENDP + +; ------------------------------------------------------------------------------ +; CloseGameDialog: Closes active dialogue box +; ------------------------------------------------------------------------------ +CloseGameDialog PROC + mov gameDialogActive, 0 + ret +CloseGameDialog ENDP + +; ------------------------------------------------------------------------------ +; RenderGameDialog: Draws active dialogue box at bottom of screen +; ------------------------------------------------------------------------------ +RenderGameDialog PROC uses ebx ecx edx esi edi + cmp gameDialogActive, 0 + je rd_done + + ; Dialog Box background: X = 16, Y = 170, W = 288, H = 42 + invoke DrawRect, 16, 170, 288, 42, 00121212h + invoke DrawRectOutline, 16, 170, 288, 42, COLOR_CYAN + + ; Speaker name (if not empty) + cmp BYTE PTR [gameDialogSpeaker], 0 + je draw_dlg_body + invoke DrawString8x8, 24, 174, OFFSET gameDialogSpeaker, COLOR_YELLOW + +draw_dlg_body: + ; Text + invoke DrawString8x8, 24, 186, OFFSET gameDialogText, COLOR_WHITE + + ; Close hint in bottom-right + invoke DrawString8x8, 140, 200, OFFSET szDialogCloseHint, COLOR_GRAY + + ; Check if player pressed Action key or Attack to dismiss + cmp keyAction, 0 + jne do_dismiss + cmp keyAttack, 0 + jne do_dismiss + jmp rd_done + +do_dismiss: + mov keyAction, 0 + mov keyAttack, 0 + mov gameDialogActive, 0 + +rd_done: + ret +RenderGameDialog ENDP + +; ------------------------------------------------------------------------------ +; PlayGameSFX: Asynchronously plays a sound effect via WinMM PlaySoundA +; ------------------------------------------------------------------------------ +PlayGameSFX PROC uses eax, pszRelativePath:DWORD + cmp isCutsceneActive, 0 + jne sfx_done + mov eax, pszRelativePath + test eax, eax + jz sfx_done + cmp BYTE PTR [eax], 0 + jz sfx_done + invoke PlaySoundA, eax, 0, 9 ; SND_FILENAME (1) | SND_ASYNC (8) = 9 +sfx_done: + ret +PlayGameSFX ENDP + +; ------------------------------------------------------------------------------ +; Game_OnTick: Hook called every 60 Hz frame during active gameplay +; ------------------------------------------------------------------------------ +Game_OnTick PROC uses eax ebx ecx edx + ; Extensible hook for custom game logic (quests, timers, zones) + ret +Game_OnTick ENDP + +; ------------------------------------------------------------------------------ +; Game_OnPlayerInteract: Hook called when player presses [E] in range of an entity +; Returns: EAX = 1 if handled by custom script, 0 if default engine behavior +; ------------------------------------------------------------------------------ +Game_OnPlayerInteract PROC uses ebx ecx edx esi edi, entityIdx:DWORD, entityTypeVal:DWORD + ; Extensible hook for custom interactions + xor eax, eax + ret +Game_OnPlayerInteract ENDP + +; ------------------------------------------------------------------------------ +; Game_OnEntityKilled: Hook called when enemy guard or barrel is destroyed +; ------------------------------------------------------------------------------ +Game_OnEntityKilled PROC uses eax ebx ecx edx, entityIdx:DWORD, entityTypeVal:DWORD + ; Extensible hook for boss fights, doors opening on kill, score multipliers + ret +Game_OnEntityKilled ENDP + +; ------------------------------------------------------------------------------ +; Game_OnCustomHUD: Hook called during HUD rendering +; ------------------------------------------------------------------------------ +Game_OnCustomHUD PROC uses eax ebx ecx edx + ; Extensible hook for custom UI bars, quest markers, mini-dialogues + ret +Game_OnCustomHUD ENDP + +; ------------------------------------------------------------------------------ +; RenderGameHUDOverlays: Renders active toast banner, dialogue modal, and custom HUD +; ------------------------------------------------------------------------------ +RenderGameHUDOverlays PROC + call RenderGameToast + call RenderGameDialog + call Game_OnCustomHUD + ret +RenderGameHUDOverlays ENDP + +; ------------------------------------------------------------------------------ +; CheckCmdFlag: Case-insensitive substring search (EAX = 1 if found, 0 if not) +; ------------------------------------------------------------------------------ +CheckCmdFlag PROC uses esi edi ecx, pszCmd:DWORD, pszFlag:DWORD + mov esi, pszCmd + test esi, esi + jz flag_not_found +check_next_char: + cmp byte ptr [esi], 0 + je flag_not_found + mov edi, pszFlag + mov ecx, esi +compare_loop: + mov al, byte ptr [edi] + test al, al + jz flag_found + mov ah, byte ptr [ecx] + cmp al, 'A' + jl @F + cmp al, 'Z' + jg @F + add al, 32 +@@: + cmp ah, 'A' + jl @F + cmp ah, 'Z' + jg @F + add ah, 32 +@@: + cmp al, ah + jne mismatch + inc edi + inc ecx + jmp compare_loop +mismatch: + inc esi + jmp check_next_char +flag_found: + mov eax, 1 + ret +flag_not_found: + xor eax, eax + ret +CheckCmdFlag ENDP + +; ------------------------------------------------------------------------------ +; LoadProjectSettings: Reads project.eblanproj if present in working directory +; ------------------------------------------------------------------------------ +LoadProjectSettings PROC uses ebx ecx edx esi edi + LOCAL hFile:DWORD, bytesRead:DWORD + LOCAL projBuf[1024]:BYTE + + invoke CreateFileA, OFFSET szProjectFile, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, 0, 0 + cmp eax, INVALID_HANDLE_VALUE + je proj_done + mov hFile, eax + + lea edx, projBuf + invoke ReadFile, hFile, edx, 1023, ADDR bytesRead, 0 + invoke CloseHandle, hFile + + cmp bytesRead, 0 + jle proj_done + + mov eax, bytesRead + mov byte ptr [projBuf + eax], 0 + + lea edx, projBuf + invoke CheckCmdFlag, edx, OFFSET szProjGameMode0 + test eax, eax + jnz set_adv_mode + + lea edx, projBuf + invoke CheckCmdFlag, edx, OFFSET szProjShowWeapons0 + test eax, eax + jnz set_adv_mode + + lea edx, projBuf + invoke CheckCmdFlag, edx, OFFSET szProjGameMode1 + test eax, eax + jnz set_act_mode + jmp proj_done + +set_adv_mode: + mov gameMode, GAME_MODE_ADVENTURE + mov enableWeapons, 0 + mov playerWeapon, WEAPON_NONE + mov showCrosshair, 0 + jmp proj_done + +set_act_mode: + mov gameMode, GAME_MODE_ACTION + mov enableWeapons, 1 + mov playerWeapon, WEAPON_PISTOL + mov showCrosshair, 1 + +proj_done: + ret +LoadProjectSettings ENDP diff --git a/include/engine_input.inc b/include/engine_input.inc index 3010a5f..4998059 100644 --- a/include/engine_input.inc +++ b/include/engine_input.inc @@ -181,6 +181,204 @@ restart_released: mov keyRestartHeld, 0 restart_sampled: + ; F1 / H: Toggle Clean HUD vs Debug HUD + invoke GetAsyncKeyState, VK_KEY_F1 + mov bx, ax + invoke GetAsyncKeyState, VK_KEY_H + or bx, ax + test bx, 8000h + jz hud_key_released + cmp keyHudStyleHeld, 0 + jne hud_key_sampled + xor hudStyle, 1 + mov keyHudStyleHeld, 1 + cmp hudStyle, HUD_STYLE_CLEAN + je toast_hud_clean + invoke ShowGameToast, OFFSET szHudDebugToast, 60, COLOR_CYAN + jmp hud_key_sampled +toast_hud_clean: + invoke ShowGameToast, OFFSET szHudCleanToast, 60, COLOR_GREEN + jmp hud_key_sampled +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 + cmp enableWeapons, 0 + je skip_weapon_select + + invoke GetAsyncKeyState, VK_KEY_1 + test ax, 8000h + jz @F + mov playerWeapon, WEAPON_KNIFE +@@: + invoke GetAsyncKeyState, VK_KEY_2 + test ax, 8000h + jz @F + mov playerWeapon, WEAPON_PISTOL +@@: + invoke GetAsyncKeyState, VK_KEY_3 + test ax, 8000h + jz @F + mov playerWeapon, WEAPON_SHOTGUN +@@: +skip_weapon_select: + + ; Escape / Pause Key + invoke GetAsyncKeyState, VK_KEY_ESCAPE + test ax, 8000h + jz pause_key_released + cmp keyPauseHeld, 0 + jne pause_key_sampled + xor isGamePaused, 1 + mov keyPauseHeld, 1 + jmp pause_key_sampled +pause_key_released: + mov keyPauseHeld, 0 +pause_key_sampled: + + ; If paused, handle pause menu choices + cmp isGamePaused, 0 + je not_in_pause_menu + ; While paused, mask normal movement & attack + mov keyForward, 0 + mov keyBackward, 0 + mov keyStrafeLeft, 0 + mov keyStrafeRight, 0 + mov keyTurnLeft, 0 + mov keyTurnRight, 0 + mov keyAttack, 0 + mov keyAction, 0 + + ; Check if any menu key is pressed + invoke GetAsyncKeyState, VK_KEY_1 + mov bx, ax + invoke GetAsyncKeyState, VK_KEY_2 + or bx, ax + invoke GetAsyncKeyState, VK_KEY_3 + or bx, ax + invoke GetAsyncKeyState, VK_KEY_4 + or bx, ax + invoke GetAsyncKeyState, VK_KEY_5 + or bx, ax + test bx, 8000h + jz menu_keys_released + + ; If a key is pressed, check if already held + cmp keyMenuHeld, 0 + jne pause_menu_done + mov keyMenuHeld, 1 + + ; Option 1: Resume + invoke GetAsyncKeyState, VK_KEY_1 + test ax, 8000h + jz @F + mov isGamePaused, 0 + jmp pause_menu_done +@@: + ; Option 2: Toggle Weapons / Peaceful Mode + invoke GetAsyncKeyState, VK_KEY_2 + test ax, 8000h + jz @F + xor enableWeapons, 1 + cmp enableWeapons, 0 + je set_peaceful_mode + mov gameMode, GAME_MODE_ACTION + mov playerWeapon, WEAPON_PISTOL + mov showCrosshair, 1 + invoke ShowGameToast, OFFSET szWeaponsEnabledToast, 80, COLOR_YELLOW + jmp pause_menu_done +set_peaceful_mode: + mov gameMode, GAME_MODE_ADVENTURE + mov playerWeapon, WEAPON_NONE + mov showCrosshair, 0 + invoke ShowGameToast, OFFSET szWeaponsDisabledToast, 80, COLOR_GREEN + jmp pause_menu_done +@@: + ; Option 3: Toggle HUD (Clean vs Debug) + invoke GetAsyncKeyState, VK_KEY_3 + test ax, 8000h + jz @F + xor hudStyle, 1 + cmp hudStyle, HUD_STYLE_CLEAN + je toast_pause_clean + invoke ShowGameToast, OFFSET szHudDebugToast, 60, COLOR_CYAN + jmp pause_menu_done +toast_pause_clean: + invoke ShowGameToast, OFFSET szHudCleanToast, 60, COLOR_GREEN + jmp pause_menu_done +@@: + ; Option 4: Restart level + invoke GetAsyncKeyState, VK_KEY_4 + test ax, 8000h + jz @F + mov isGamePaused, 0 + mov keyRestart, 1 + jmp pause_menu_done +@@: + ; Option 5: Quit to Windows + invoke GetAsyncKeyState, VK_KEY_5 + test ax, 8000h + jz pause_menu_done + mov isRunning, 0 + jmp pause_menu_done + +menu_keys_released: + mov keyMenuHeld, 0 + +pause_menu_done: + ret + +not_in_pause_menu: cmp isCutsceneActive, 0 je normal_keys_enabled mov keyForward, 0 @@ -193,12 +391,6 @@ restart_sampled: mov keyAction, 0 mov keyAttack, 0 normal_keys_enabled: - ; 11. Escape - Exit application - invoke GetAsyncKeyState, VK_KEY_ESCAPE - test ax, 8000h - jz @F - mov isRunning, 0 -@@: ret PollInput ENDP 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 acb2473..314804d 100644 --- a/include/engine_map.inc +++ b/include/engine_map.inc @@ -13,6 +13,11 @@ ValidateMapFilename PROTO :DWORD 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 @@ -100,6 +105,7 @@ loop_wall2: mov portalLatchCell, -1 mov mapCeilingColor, COLOR_CEILING mov mapFloorColor, COLOR_FLOOR + call InitLightingSystem ret InitMap ENDP @@ -203,6 +209,7 @@ InteractDoor PROC uses ebx ecx edx esi edi ; Open the door! invoke SetMapCell, targetX, targetY, CELL_DOOR_OPEN + invoke ShowGameToast, OFFSET szDoorOpenedToast, 50, COLOR_GREEN ret check_door_close: @@ -211,6 +218,7 @@ check_door_close: ; Close the door! invoke SetMapCell, targetX, targetY, CELL_DOOR + invoke ShowGameToast, OFFSET szDoorClosedToast, 50, COLOR_YELLOW ret check_portal_interact: @@ -526,7 +534,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 +655,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 +678,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 +725,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 +966,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 +1035,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_physics.inc b/include/engine_physics.inc index 81858dd..3c3feba 100644 --- a/include/engine_physics.inc +++ b/include/engine_physics.inc @@ -12,6 +12,8 @@ IsGameplayBlocked PROC or eax, isCutsceneActive or eax, cutscenePending or eax, fadeState + or eax, isGamePaused + or eax, gameDialogActive ret IsGameplayBlocked ENDP @@ -25,6 +27,10 @@ UpdatePlayer PROC uses ebx ecx edx esi edi LOCAL checkGridX:DWORD, checkGridY:DWORD mov gameplayTickBlocked, 1 + cmp isGamePaused, 0 + jne update_player_done + cmp gameDialogActive, 0 + jne update_player_done cmp isPlayerDead, 0 jne update_player_done ; Freeze player movement and inputs during level transitions @@ -237,8 +243,9 @@ allow_portal_y: mov playerY, eax update_player_done: - cmp fadeState, 0 - jne @F + call IsGameplayBlocked + test eax, eax + jnz @F call CheckPortalTriggers @@: ret diff --git a/include/engine_preview.inc b/include/engine_preview.inc index 6a2d7fa..cfd9ec3 100644 --- a/include/engine_preview.inc +++ b/include/engine_preview.inc @@ -1,7 +1,6 @@ ; Windowless renderer used by EblanStudio. Same Render3DScene and overlays as game. ; Fixed wire layout is mirrored by editor/src/preview_protocol.h. .data -previewPrefix db "--preview:",0 previewHandle dd 0 previewMemory dd 0 previewParent dd 0 @@ -17,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 @@ -107,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 e40de16..e12bd6a 100644 --- a/include/engine_raycaster.inc +++ b/include/engine_raycaster.inc @@ -36,16 +36,34 @@ 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 +@@: 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 +@@: mov ecx, (SCREEN_W * SCREEN_HALF_H) rep stosd @@ -465,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 ; -------------------------------------------------------------------------- @@ -493,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 @@ -513,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 @@ -591,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 @@ -836,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 @@ -916,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 @@ -927,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 6601fdb..046be79 100644 --- a/main.asm +++ b/main.asm @@ -112,9 +112,11 @@ 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 +include include/engine_gameplay.inc include include/engine_cutscene.inc include include/engine_preview.inc @@ -133,14 +135,7 @@ WndProc PROC hWnd:DWORD, uMsg:DWORD, wParam:DWORD, lParam:DWORD ret handle_keydown: - cmp wParam, VK_KEY_ESCAPE - jne check_f12 - invoke PostQuitMessage, 0 - mov isRunning, 0 - xor eax, eax - ret -check_f12: - ; Movement and action edges are handled only by PollInput. + ; Key edges and ESC pause menu are handled via PollInput invoke DefWindowProcA, hWnd, uMsg, wParam, lParam ret @@ -164,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 @@ -226,19 +222,30 @@ Engine_RenderHUD PROC uses ebx ecx edx call RenderCutsceneOverlay ret normal_engine_hud: + cmp hudStyle, HUD_STYLE_DEBUG + jne clean_hud_mode + + ; --- DEBUG / DEVELOPER HUD MODE --- ; 1. Top HUD Header - invoke DrawString8x8, 10, 10, OFFSET strEngineName, COLOR_YELLOW + invoke DrawString8x8, 8, 8, OFFSET strEngineName, COLOR_YELLOW - ; 2. FPS Counter - invoke DrawString8x8, 10, 24, OFFSET szFpsLabel, COLOR_CYAN - invoke DrawNumber8x8, 42, 24, currentFps, COLOR_CYAN + ; 2. FPS Counter (drawn at top right X=250 so it never overlaps left elements) + invoke DrawString8x8, 250, 8, OFFSET szFpsLabel, COLOR_CYAN + invoke DrawNumber8x8, 282, 8, currentFps, COLOR_CYAN - ; 3. Controls Bar at Bottom + ; 3. Controls Bar at Bottom (Debug mode only) invoke DrawRect, 0, 215, SCREEN_W, 25, 00101010h invoke DrawString8x8, 10, 218, OFFSET strControls1, COLOR_WHITE invoke DrawString8x8, 10, 228, OFFSET strControls3, COLOR_GREEN + jmp check_hints - ; 4. Door / Portal Interaction Hint (center screen) +clean_hud_mode: + ; --- CLEAN / IMMERSIVE HUD MODE --- + ; In clean mode, no watermarks and no permanent bottom bar. + ; Full 320x240 view is dedicated to the game world. + +check_hints: + ; 4. Door / Portal Interaction Hint (Cleanly centered above crosshair) call CheckDoorHint cmp eax, 1 je show_door_hint @@ -247,11 +254,11 @@ normal_engine_hud: jmp check_minimap show_door_hint: - invoke DrawString8x8, 128, 150, OFFSET strDoorHint, COLOR_YELLOW + invoke DrawString8x8, 116, 140, OFFSET strDoorHint, COLOR_YELLOW jmp check_minimap show_portal_hint: - invoke DrawString8x8, 110, 150, OFFSET strPortalHint, COLOR_CYAN + invoke DrawString8x8, 110, 140, OFFSET strPortalHint, COLOR_CYAN check_minimap: cmp mapErrorFrames, 0 @@ -261,6 +268,9 @@ hud_no_map_error: ; 5. Minimap Radar (Toggle with 'M') call DrawMinimap + ; 6. Gameplay HUD Overlays (Floating Toasts, Dialog Modals, Custom HUD) + call RenderGameHUDOverlays + ret Engine_RenderHUD ENDP @@ -274,9 +284,17 @@ Engine_Frame PROC uses ebx esi edi dec mapErrorFrames no_error_tick: call PollInput + cmp isGamePaused, 0 + jne @F + cmp gameDialogActive, 0 + jne skip_movement_in_dialog call UpdatePlayer call UpdateEntities call UpdateCombat + call UpdateDynamicLights +skip_movement_in_dialog: + call Game_OnTick +@@: ; 2. 3D Raycasting & 2D HUD Render call Render3DScene @@ -286,6 +304,7 @@ no_error_tick: call RenderCombatHUD @@: call ApplyCutsceneFade + call RenderPauseMenu ; 2b. Screenshot request (F12): dump fresh framebuffer to BMP cmp keyScreenshot, 0 @@ -364,14 +383,14 @@ ParseStartupMap PROC uses ebx ecx edx esi edi skip_executable_quote: lodsb test al, al - jz startup_arg_done + jz check_cmdline_flags cmp al, 34 jne skip_executable_quote jmp skip_argument_spaces skip_executable_word: lodsb test al, al - jz startup_arg_done + jz check_cmdline_flags cmp al, 32 jne skip_executable_word skip_argument_spaces: @@ -408,10 +427,59 @@ write_startup_char: dec ecx jnz startup_argument_loop mov startupMapPath, 0 - ret + jmp check_cmdline_flags terminate_startup_arg: mov byte ptr [edi], 0 -startup_arg_done: + + ; If startupMapPath starts with '-' and not '--preview:', clear it + mov al, byte ptr [startupMapPath] + cmp al, '-' + jne check_cmdline_flags + mov esi, OFFSET startupMapPath + mov edi, OFFSET previewPrefix + mov ecx, 10 + repe cmpsb + je check_cmdline_flags + mov byte ptr [startupMapPath], 0 + +check_cmdline_flags: + invoke GetCommandLineA + mov ebx, eax + + invoke CheckCmdFlag, ebx, OFFSET szFlagNoWeapons + test eax, eax + jz @F + mov gameMode, GAME_MODE_ADVENTURE + mov enableWeapons, 0 + mov playerWeapon, WEAPON_NONE + mov showCrosshair, 0 +@@: + invoke CheckCmdFlag, ebx, OFFSET szFlagAdventure + test eax, eax + jz @F + mov gameMode, GAME_MODE_ADVENTURE + mov enableWeapons, 0 + mov playerWeapon, WEAPON_NONE + mov showCrosshair, 0 +@@: + invoke CheckCmdFlag, ebx, OFFSET szFlagAction + test eax, eax + jz @F + mov gameMode, GAME_MODE_ACTION + mov enableWeapons, 1 + mov playerWeapon, WEAPON_PISTOL + mov showCrosshair, 1 +@@: + invoke CheckCmdFlag, ebx, OFFSET szFlagDebugHud + test eax, eax + jz @F + mov hudStyle, HUD_STYLE_DEBUG +@@: + invoke CheckCmdFlag, ebx, OFFSET szFlagCleanHud + test eax, eax + jz @F + mov hudStyle, HUD_STYLE_CLEAN +@@: ret ParseStartupMap ENDP @@ -422,6 +490,7 @@ main PROC LOCAL msg:MSG LOCAL curTick:DWORD, elapsed:DWORD + call LoadProjectSettings call ParseStartupMap call TryEnginePreview call Engine_Init diff --git a/main.obj b/main.obj index 55c6cb0..1d268e6 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 43d6b84..417ce48 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" #include "stability_editor_checks.inl" @@ -139,6 +140,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 672bb0f..90be195 100644 --- a/tests/engine_regressions.asm +++ b/tests/engine_regressions.asm @@ -17,9 +17,11 @@ 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 +include include/engine_gameplay.inc include include/engine_cutscene.inc include startup_parser.inc include project_paths.inc @@ -126,7 +128,9 @@ state_changed: StateUnchanged ENDP include tests/cutscene_engine_checks.inc +include tests/lighting_engine_checks.inc include tests/stability_engine_checks.inc +include tests/integration_engine_checks.inc AuditMain PROC call InitMathTables @@ -363,6 +367,7 @@ flat_depth_ok: call Render3DScene invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE + call RunLightingChecks call RunCutsceneChecks call RunStabilityChecks mov eax, resultCount diff --git a/tests/integration_engine_checks.inc b/tests/integration_engine_checks.inc new file mode 100644 index 0000000..b97c921 --- /dev/null +++ b/tests/integration_engine_checks.inc @@ -0,0 +1,85 @@ +; Compatibility of stage-1 fixes with newer weapon/modal/lighting systems. +.code +RunIntegrationChecks PROC + call ResetCombatFixture + mov isGamePaused, 1 + mov keyForward, 1 + mov playerAttackCooldown, 4 + call UpdatePlayer + call UpdateEntities + call UpdateCombat + CHECK playerX, 160 ; pause freezes player movement through production procedure + CHECK [spriteList].SpriteStruct.x, 288 ; pause freezes guard AI through production procedure + CHECK playerAttackCooldown, 4 ; pause freezes weapon cooldowns + CHECK playerAmmo, 50 ; pause preserves ammunition + mov isGamePaused, 0 + mov keyForward, 0 + call UpdatePlayer + mov gameDialogActive, 1 + call UpdateEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.x, 288 ; modal dialogue freezes guards + CHECK playerAmmo, 50 ; modal dialogue cannot fire + mov gameDialogActive, 0 + mov gameMode, GAME_MODE_ADVENTURE + call UpdateCombat + CHECK entityHealth, 100 ; peaceful adventure mode cannot damage targets + call ResetCombatFixture + mov isPlayerDead, 1 + mov transitionCount, 1 + mov [transitionList].LevelTransition.triggerCellX, 2 + mov [transitionList].LevelTransition.triggerCellY, 2 + mov [transitionList].LevelTransition.fadeEffect, 1 + mov portalLatchCell, -1 + call UpdatePlayer + CHECK fadeState, 0 ; dead player cannot activate a room transition + + call ResetCombatFixture + mov playerWeapon, WEAPON_KNIFE + call UpdateCombat + CHECK entityHealth, 100 ; knife cannot hit beyond its world range + CHECK playerAmmo, 50 ; knife consumes no ammunition + mov playerAttackCooldown, 0 + mov [spriteList].SpriteStruct.x, 208 + call UpdateCombat + CHECK entityHealth, 70 ; knife retains its close-range damage after merge + call ResetCombatFixture + mov playerWeapon, WEAPON_SHOTGUN + mov [spriteList].SpriteStruct.y, 184 + call UpdateCombat + CHECK entityHealth, 30 ; shotgun retains wider world-space hitbox and damage + CHECK playerAmmo, 48 ; shotgun consumes two rounds + mov eax, [lightSources+14*sizeof LightSource].LightSource.flags + and eax, LIGHT_FLAG_ACTIVE + CHECK eax, 1 ; shotgun spawns muzzle light in reserved slot + + call ResetCombatFixture + mov [spriteList].SpriteStruct.textureId, 11 + mov [spriteList].SpriteStruct.flags, 06026400h + call InitEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.textureId, 11 ; explicit Prop with barrel texture cannot explode + mov [spriteList].SpriteStruct.flags, 0 + call InitEntities + CHECK entityType, ENTITY_TYPE_BARREL ; legacy barrel archetype matches editor fallback + + ; Real explosion with a nearby guard: CDQ must not destroy sprite pointer. + call ResetCombatFixture + mov [spriteList].SpriteStruct.textureId, 11 + mov [spriteList].SpriteStruct.flags, ENTITY_TYPE_BARREL + mov spriteCount, 2 + mov esi, OFFSET spriteList+sizeof SpriteStruct + mov [esi].SpriteStruct.x, 352 + mov [esi].SpriteStruct.y, 160 + mov [esi].SpriteStruct.textureId, 13 + mov [esi].SpriteStruct.scale, FIXED_ONE + mov [esi].SpriteStruct.flags, 0 + call InitEntities + call UpdateCombat + CHECK [spriteList].SpriteStruct.textureId, 0 ; explicit barrel still explodes on hitscan + CHECK entityHealth[4], 40 ; blast damages nearby guard without corrupting pointer + mov eax, [lightSources+15*sizeof LightSource].LightSource.flags + and eax, LIGHT_FLAG_ACTIVE + CHECK eax, 1 ; explosion spawns light in reserved slot + ret +RunIntegrationChecks ENDP 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/stability_editor_checks.inl b/tests/stability_editor_checks.inl index 2c74c02..81b4f3c 100644 --- a/tests/stability_editor_checks.inl +++ b/tests/stability_editor_checks.inl @@ -4,6 +4,11 @@ static void RunStabilityEditorChecks() { Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_GUARD, "zero flags retain legacy guard fallback"); sprite.flags = 0x06026400; Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_PROP, "explicit Prop matches ASM despite guard texture"); + sprite.textureId = 11; + sprite.flags = 0; + Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_BARREL, "zero flags retain new legacy barrel fallback"); + sprite.flags = 0x06026400; + Check(GetSpriteEntityType(sprite) == ENTITY_ARCHETYPE_PROP, "explicit Prop overrides barrel artwork"); for (int health : {1, 100, 255, 256, 300, 500}) { SetSpriteEntityData(sprite, ENTITY_ARCHETYPE_GUARD, health, 2, 6); Check(GetSpriteHealth(sprite) == (health > 255 ? 255 : health), "health setter clamps before byte packing"); diff --git a/tests/stability_engine_checks.inc b/tests/stability_engine_checks.inc index 1ee76f6..d78e14a 100644 --- a/tests/stability_engine_checks.inc +++ b/tests/stability_engine_checks.inc @@ -9,6 +9,11 @@ ResetCombatFixture PROC uses eax ecx edi mov isCutsceneActive, 0 mov cutscenePending, 0 mov isPlayerDead, 0 + mov isGamePaused, 0 + mov gameDialogActive, 0 + mov gameMode, GAME_MODE_ACTION + mov enableWeapons, 1 + mov playerWeapon, WEAPON_PISTOL mov playerHealth, 100 mov playerAmmo, 50 mov playerScore, 0 @@ -198,5 +203,6 @@ RunStabilityChecks PROC mov [spriteList].SpriteStruct.flags, 0 call InitEntities CHECK entityType, ENTITY_TYPE_GUARD ; zero flags keep legacy texture fallback + call RunIntegrationChecks ret RunStabilityChecks 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("