diff --git a/AGENTS.md b/AGENTS.md index e99c5ef..4982917 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -307,6 +307,15 @@ EblanStudio has evolved from a pure level editor into a **Full Game Engine Studi ### K. Authored Lighting Format (EBLN v5) +Stage-3 authored history and interaction rules are documented in +`docs/STABILIZATION.md`. Level Undo uses nested `LevelEdit` scopes around UI; +do not add a second snapshot inside a scope. Sprite switch flags opt into a door +binding only when `(flags & 0xFEFF) == 0xA805`: byte 1 is A8 (switch-only) or A9 +(key-unlockable), bytes 2/3 are door X/Y. One switch per door; validate both C++ and +ASM loaders. No EBLN/IPC size change. Runtime unlocks reset in `InitEntities`. +F focuses selection, G fills; Ctrl+Space opens Content Drawer. External asset and +project-file writes are outside level Undo. Dialog text remains code-authored. + 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. diff --git a/docs/ENGINE_ARCHITECTURE.md b/docs/ENGINE_ARCHITECTURE.md index 940f41f..197846f 100644 --- a/docs/ENGINE_ARCHITECTURE.md +++ b/docs/ENGINE_ARCHITECTURE.md @@ -54,6 +54,25 @@ Playing. Playing обновляет игрока, сущности, бой, `Gam представляет кадр; `UpdateScreenFade` обновляет переход, `ApplyScreenFade` меняет только пиксели. Скриншот и счётчик FPS относятся к представлению. +### Авторские транзакции и взаимодействия (этап 3) + +`EditorApp::LevelEdit` снимает полный snapshot вокруг UI-редактирования. Сравнение +авторских данных исключает пустые действия; ActiveId виджета/жест холста объединяет +непрерывный ввод, включая запекание на отпускании мыши. Undo/Redo ограничены 64 +снимками; успешная загрузка очищает историю. Snapshot включает свет, baked RGB, +кат-сцены, переходы, спавн, сущности и палитру, но не внешние файлы ассетов/проекта. +Настоящие ImGui-поля проверяются в `tests/studio_editor_checks.inl`. + +Опциональная связь рычага с дверью помещается в существующий `SpriteStruct.flags`: +тип 5 в младшем байте, `0xA8`/`0xA9` во втором байте, X/Y двери в старших байтах. +`0xA9` разрешает также открыть дверь ключом: первый ключ расходуется и сохраняет +runtime-флаг отпирания до переинициализации комнаты. Без связи обычные двери +бесплатны; старые рычаги вызывают прежний hook/toggle. Оба загрузчика валидируют +целевую дверь, границы и уникальность связи. `ToggleDoorCell` не закрывает дверь +поверх тела игрока ни при прямом E, ни через рычаг. Прямое взаимодействие с дверью +потребляет E до обработки ближайших рычагов. Бинарные размеры EBLN v5 и IPC v2 +не изменились. Подробный контракт и ограничения: `STABILIZATION.md`, этап 3. + ## 2. Конвейер лучевого рендеринга (2.5D Raycasting) Каждый кадр для экрана шириной 320 пикселей выпускается ровно **320 лучей** (по 1 лучу на каждый вертикальный столбец экрана): diff --git a/docs/STABILIZATION.md b/docs/STABILIZATION.md index e9b5716..377fe4b 100644 --- a/docs/STABILIZATION.md +++ b/docs/STABILIZATION.md @@ -168,15 +168,69 @@ cinematic traces and 1024 baked RGB cells agree. Classic/lit IPC v2 frames match direct ASM rendering through the x86/x64/DX11 path. Project assets remain byte-for-byte unchanged. No manual interactive playtest was performed. +## Stage 3 — authored history, interactions and studio UI + +Implemented: + +- Nested `LevelEdit` scopes compare complete level snapshots around production UI + edits. Inspector transforms, sprite texture/type/AI parameters, spawn/angle, + palette/presets, portal filenames/destinations/fade, light settings/sources and + cinematic events/strings participate in Undo/Redo. Existing explicit `PushUndo` + calls are suppressed inside these scopes, avoiding duplicate entries. A stable + active widget or canvas stroke groups continuous edits; release-time baking and + timeline normalization belong to the same gesture. Selection/view changes and + clicks without authored changes create no history. New edits clear Redo; history + is limited to 64 snapshots. Successful loads clear history, failed loads preserve + it; creating a default level is undoable. Undo/Redo clear active widgets, drag + state and selection, restore RGB bake/signature and rescrub cinematic preview. +- Sprite drags clamp to map bounds. Inspector duplication returns immediately, + avoiding references invalidated by vector growth. Releasing a light drag outside + the canvas clears its radius/position drag state. +- A switch can opt into one authored door target without changing EBLN v5 or IPC + v2 layouts. Flags low byte is 5; byte 1 is `0xA8` (switch-only door) or `0xA9` + (also unlockable with a key), bytes 2/3 are door cell X/Y. Other switch flags + retain legacy hook/toggle behavior. Each door has at most one bound switch; + C++ and ASM loaders reject non-door targets, out-of-bounds targets and duplicate + bindings before replacing the room. Painting away a bound door leaves a visible + inspector error and blocks save/F5 until the target is repaired or unbound. +- Ordinary doors remain free. Bound doors require their switch, or one collected + key if explicitly enabled. First direct unlock consumes one key and persists + until room reinitialization; subsequent opens/closes are free. Switch use itself + never consumes a key. `ToggleDoorCell` shares full-player-body closure protection + between direct and remote interactions. An occupied/invalid target does not + change switch state. `E` prioritizes the facing door/portal, then the first nearby + switch in authored order, consuming the action once. Custom switch hooks run + before default door behavior and may handle the action themselves. This stage + does not add generic proximity dialogs or switch line-of-sight tests. +- Removed fake per-entity dialogue fields and the nonfunctional insertion promise. + UI explicitly identifies dialogue as an `engine_gameplay.inc` code hook, not map + data. Texture/audio import and project settings remain external file operations, + not part of level Undo; IDE text history is separate. +- UE5-inspired studio uses charcoal surfaces, compact square panels, Outliner and + Details headers, a two-row horizontally scrollable toolbar, resizable views and + a bottom Content Drawer (`Ctrl+Space`). Minimum desktop size is 1000x680; narrow + mode shortens viewport tabs. No docking framework or alternative renderer was + introduced. Workspace geometry ignores stale ImGui layout entries; no existing + `imgui.ini` is rewritten by the implementation. F5/F6/F7 remain; F focuses, G + fills, resolving the previous F conflict. Runtime pause menu uses the same + restrained charcoal/blue visual language with five clearly separated rows. + +Verification (2026-10-31): production builds succeed; editor **162/162**, ASM +**307/307**. New checks exercise actual ImGui spawn/sprite/portal inputs and palette +preset buttons, history grouping/branching/capacity, workspace/content drawer at +1000/1360/1920 widths, real C++/ASM binding validation, key consumption, legacy +compatibility, single-action priority and remotely occupied doors. All 320 ray +angles, cinematic traces, 1024 RGB bake cells and classic/lit IPC frames still +agree. Project assets remain byte-for-byte unchanged. No manual visual/keyboard +playtest was performed; automated UI checks do not replace it. + ## Remaining stages -3. Honest editor/gameplay: complete undo, keys/switches and supported interactions; - UE5-inspired editor/runtime UI redesign. 4. Project lifecycle and validated, safe standalone export. 5. Measurements and targeted renderer/editor optimizations. 6. Finished multi-room demo game and release verification. 7. Data-driven extensions and subsequent rendering features. -Stages 1 and 2 do not claim that all audit findings are fixed: AI visibility/melee -through walls, full undo and exporter safety remain assigned to subsequent -stages. Existing map and texture assets are not migrated or rewritten. +Stages 1–3 do not claim that all audit findings are fixed: AI visibility/melee +through walls and exporter safety remain open. Existing map and texture assets +are not migrated or rewritten. diff --git a/editor/editor.exe b/editor/editor.exe index c2b1375..9ddcdd2 100644 Binary files a/editor/editor.exe and b/editor/editor.exe differ diff --git a/editor/src/editor_app.cpp b/editor/src/editor_app.cpp index 20a2652..4fb2f9d 100644 --- a/editor/src/editor_app.cpp +++ b/editor/src/editor_app.cpp @@ -92,6 +92,8 @@ EditorApp::~EditorApp() { } void EditorApp::InitDefaultLevel() { + draggingLightRadiusIdx = -1; + strokeSnapshotTaken = false; // Clear map for (int y = 0; y < MAP_GRID_H; ++y) { for (int x = 0; x < MAP_GRID_W; ++x) { @@ -226,6 +228,8 @@ bool EditorApp::LoadLevel(const std::string& filepath) { selectedSpriteIdx = selectedTransitionIdx = draggingSpriteIdx = -1; strokeSnapshotTaken = false; undoStack.clear(); redoStack.clear(); + ++editEpoch; undoGesture = 0; + draggingLightRadiusIdx = -1; statusMessage = "Уровень загружен: " + filepath; return true; } @@ -415,6 +419,7 @@ LevelSnapshot EditorApp::CaptureSnapshot() const { } void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { + if (ImGui::GetCurrentContext()) ImGui::ClearActiveID(); std::memcpy(map, s.map, sizeof(map)); playerStartX = s.playerStartX; playerStartY = s.playerStartY; @@ -432,6 +437,8 @@ void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { 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; + draggingLightRadiusIdx = -1; + strokeSnapshotTaken = false; selectedCutsceneEvent = -1; placeCameraPoint = false; ScrubCutscene(cutsceneCursor); selectedSpriteIdx = -1; @@ -440,12 +447,15 @@ void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { } void EditorApp::PushUndo() { + if (editDepth) return; // UI scope records only real changes, once per gesture. + undoGesture = 0; undoStack.push_back(CaptureSnapshot()); if (undoStack.size() > MAX_UNDO) undoStack.pop_front(); redoStack.clear(); } void EditorApp::Undo() { + ++editEpoch; undoGesture = 0; if (undoStack.empty()) return; redoStack.push_back(CaptureSnapshot()); RestoreSnapshot(undoStack.back()); @@ -454,6 +464,7 @@ void EditorApp::Undo() { } void EditorApp::Redo() { + ++editEpoch; undoGesture = 0; if (redoStack.empty()) return; undoStack.push_back(CaptureSnapshot()); RestoreSnapshot(redoStack.back()); @@ -461,6 +472,50 @@ void EditorApp::Redo() { statusMessage = "Повтор действия."; } +bool EditorApp::SameLevel(const LevelSnapshot& a, const LevelSnapshot& b) { + auto bytesEqual = [](const auto& x, const auto& y) { + return x.size() == y.size() && (x.empty() || + std::memcmp(x.data(), y.data(), x.size() * sizeof(x[0])) == 0); + }; + return !std::memcmp(a.map,b.map,sizeof(a.map)) && + a.playerStartX==b.playerStartX && a.playerStartY==b.playerStartY && a.playerStartAngle==b.playerStartAngle && + a.ceilingColor==b.ceilingColor && a.floorColor==b.floorColor && + bytesEqual(a.sprites,b.sprites) && bytesEqual(a.transitions,b.transitions) && + a.cutscene.flags==b.cutscene.flags && bytesEqual(a.cutscene.events,b.cutscene.events) && + a.cutscene.strings==b.cutscene.strings && bytesEqual(a.lightSources,b.lightSources) && + !std::memcmp(&a.lightingSettings,&b.lightingSettings,sizeof(LightingSettings)) && + a.bakedRGB==b.bakedRGB && a.bakedSignature==b.bakedSignature; +} + +void EditorApp::RecordLevelEdit(const LevelSnapshot& before, unsigned gesture) { + if (!SameLevel(before,CaptureSnapshot())) { + if (!gesture || gesture != undoGesture) { + undoStack.push_back(before); + if (undoStack.size()>MAX_UNDO) undoStack.pop_front(); + } + redoStack.clear(); + undoGesture = gesture; + } else if (gesture != undoGesture) { + undoGesture = 0; + } +} + +void EditorApp::BeginLevelEdit() { + if (editDepth++ == 0) { editBefore=CaptureSnapshot(); scopeEpoch=editEpoch; } +} + +void EditorApp::EndLevelEdit() { + if (--editDepth) return; + if (scopeEpoch!=editEpoch) return; // Load/Undo/Redo replaced the document. + unsigned gesture=ImGui::GetCurrentContext()->ActiveId; + if (!gesture && ImGui::IsMouseDown(ImGuiMouseButton_Left)) gesture=0xFFFFFFFEu; + if (!gesture && ImGui::IsMouseDown(ImGuiMouseButton_Right)) gesture=0xFFFFFFFFu; + // Release-frame bake/normalization belongs to the gesture that just ended. + const bool released=ImGui::IsMouseReleased(ImGuiMouseButton_Left) || ImGui::IsMouseReleased(ImGuiMouseButton_Right); + RecordLevelEdit(editBefore, !gesture && released ? undoGesture : gesture); + if (!gesture) undoGesture=0; +} + // ----------------------------------------------------------------------------- // File dialogs (comdlg32) // ----------------------------------------------------------------------------- @@ -1502,6 +1557,7 @@ void EditorApp::HandleHotkeys() { if (io.WantTextInput || ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return; if (io.KeyCtrl) { + if (ImGui::IsKeyPressed(ImGuiKey_Space, false)) contentDrawerOpen=!contentDrawerOpen; if (ImGui::IsKeyPressed(ImGuiKey_N, false)) { PushUndo(); InitDefaultLevel(); } if (ImGui::IsKeyPressed(ImGuiKey_O, false)) { std::string p = BrowseOpenFile(); @@ -1529,7 +1585,7 @@ void EditorApp::HandleHotkeys() { 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_G, 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; @@ -1633,6 +1689,7 @@ static bool DrawRatioSplitter(const char* id, float* ratio, float totalWidth, fl void EditorApp::RenderUI() { AdvanceCutscenePreview(ImGui::GetIO().DeltaTime); CheckPlaytestProcess(); + LevelEdit edit(*this); HandleHotkeys(); RenderMenuBar(); @@ -1652,17 +1709,20 @@ void EditorApp::RenderUI() { // Main layout ImGuiViewport* vp = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 64)); - ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 89)); + const float drawerHeight=contentDrawerOpen ? (std::min)(240.0f,vp->Size.y*0.32f) : 0; + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 118)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 143 - drawerHeight)); ImGui::Begin("Workspace", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus); + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoSavedSettings); // 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; + const bool compact=totalAvailX<940.0f; + if (compact) { leftPanelWidth=180.0f; rightPanelWidth=240.0f; } float maxLeftW = (std::max)(180.0f, totalAvailX - rightPanelWidth - 320.0f); if (leftPanelWidth > maxLeftW) leftPanelWidth = maxLeftW; if (leftPanelWidth < 180.0f) leftPanelWidth = 180.0f; @@ -1677,7 +1737,7 @@ void EditorApp::RenderUI() { // Left Panel (Unity / Unreal Docked Tabs) ImGui::BeginChild("LeftPanel", ImVec2(leftPanelWidth, 0), true); if (ImGui::BeginTabBar("LeftPanelTabs", ImGuiTabBarFlags_None)) { - if (ImGui::BeginTabItem("Сцена")) { + if (ImGui::BeginTabItem("Объекты")) { RenderHierarchy(); ImGui::EndTabItem(); } @@ -1685,7 +1745,7 @@ void EditorApp::RenderUI() { RenderTilePalette(); ImGui::EndTabItem(); } - if (ImGui::BeginTabItem("Файлы")) { + if (ImGui::BeginTabItem("Контент")) { ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "Браузер ассетов"); ImGui::Separator(); RenderAssetManagerContents(); @@ -1721,12 +1781,12 @@ void EditorApp::RenderUI() { ImGui::PopStyleColor(2); }; - ModeTabButton("2D Карта", 2); + ModeTabButton(compact ? "2D" : "2D Карта", 2); ImGui::SameLine(); - ModeTabButton("3D Сцена", 1); - ImGui::SameLine(); - ModeTabButton("2D + 3D", 0); + ModeTabButton(compact ? "3D" : "3D Сцена", 1); ImGui::SameLine(); + ModeTabButton(compact ? "Оба" : "2D + 3D", 0); + if (!compact) ImGui::SameLine(); ModeTabButton("Код / IDE", 3); ImGui::SameLine(); ModeTabButton("Ассеты", 4); @@ -1801,15 +1861,32 @@ void EditorApp::RenderUI() { ImGui::End(); + if (contentDrawerOpen) { + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x,vp->Pos.y+vp->Size.y-25-drawerHeight)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x,drawerHeight)); + ImGui::Begin("ContentDrawer",nullptr,ImGuiWindowFlags_NoTitleBar|ImGuiWindowFlags_NoResize| + ImGuiWindowFlags_NoMove|ImGuiWindowFlags_NoSavedSettings); + ImGui::TextUnformatted("КОНТЕНТ / Текстуры и звуки"); + ImGui::SameLine(); + if (ImGui::SmallButton("Свернуть (Ctrl+Space)")) contentDrawerOpen=false; + ImGui::Separator(); + ImGui::PushID("ContentDrawerAssets"); + RenderAssetManagerContents(); + ImGui::PopID(); + ImGui::End(); + } + // Status Bar ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + vp->Size.y - 25)); ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 25)); ImGui::Begin("StatusBar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "● %s", statusMessage.c_str()); - ImGui::SameLine(vp->Size.x - 280); - ImGui::Text("FPS: %.1f | Zoom: %.0f%% | Undo: %zu", ImGui::GetIO().Framerate, (canvasZoom / 20.0f) * 100.0f, undoStack.size()); + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings); + if (ImGui::SmallButton("Контент Ctrl+Space")) contentDrawerOpen=!contentDrawerOpen; + ImGui::SameLine(); + ImGui::Text("Undo: %zu | Redo: %zu",undoStack.size(),redoStack.size()); + ImGui::SameLine(); + ImGui::TextDisabled("%s",statusMessage.c_str()); ImGui::End(); } @@ -1861,7 +1938,7 @@ void EditorApp::RenderMenuBar() { if (ImGui::BeginMenu("Инструменты")) { if (ImGui::MenuItem("Кисть (Стена)", "B", currentTool == TOOL_BRUSH)) currentTool = TOOL_BRUSH; if (ImGui::MenuItem("Ластик (Очистить)", "E", currentTool == TOOL_ERASER)) currentTool = TOOL_ERASER; - if (ImGui::MenuItem("Заливка области", "F", currentTool == TOOL_FILL)) currentTool = TOOL_FILL; + if (ImGui::MenuItem("Заливка области", "G", 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; @@ -1935,12 +2012,12 @@ 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, 42)); + ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 96)); ImGui::Begin("Toolbar", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoSavedSettings); - ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 2.0f); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(5.0f, 4.0f)); // 1. PLAY / STOP @@ -2011,18 +2088,21 @@ void EditorApp::RenderToolbar() { 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, "Кисть для рисования стен [B]"); + ImGui::SameLine(); ToolButton(" [E] Ластик ", TOOL_ERASER, "Ластик для очистки ячеек [E]"); - ToolButton(" [F] Заливка ", TOOL_FILL, "Заливка замкнутой области стен [F]"); + ImGui::SameLine(); + ToolButton(" [G] Заливка ", TOOL_FILL, "Заливка замкнутой области стен [G]. F — фокус выделенного объекта."); + ImGui::SameLine(); ToolButton(" [P] Игрок ", TOOL_PLAYER_SPAWN, "Точка старта игрока [P]"); + ImGui::SameLine(); ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN, "Размещение сущностей и спрайтов [S]"); + ImGui::SameLine(); ToolButton(" [L] Свет ", TOOL_LIGHT_SPAWN, "Размещение источников освещения [L]"); - ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); - ImGui::SameLine(); + ImGui::Separator(); // 3. LIGHTING SUBSYSTEM ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.48f, 0.05f, 1.0f)); @@ -2053,7 +2133,7 @@ void EditorApp::RenderToolbar() { } else { if (ImGui::Button(" Фонарик: Выкл ", ImVec2(0, 26))) { PushUndo(); isFlashlightOn = true; } } - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Включить/выключить фонарик игрока [F]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Фонарик игрока. В игре переключается клавишей F."); ImGui::SameLine(); if (showLightHeatmap) { @@ -2429,8 +2509,8 @@ void EditorApp::RenderGridCanvas() { } 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); - sprites[draggingSpriteIdx].y = static_cast(wy * 64.0f); + sprites[draggingSpriteIdx].x = std::clamp(static_cast(wx * 64.0f), 0, MAP_GRID_W*64-1); + sprites[draggingSpriteIdx].y = std::clamp(static_cast(wy * 64.0f), 0, MAP_GRID_H*64-1); } else if (strokeSnapshotTaken && inGrid) { ApplyTool(hoverGx, hoverGy); // no extra snapshot during stroke } @@ -2461,8 +2541,11 @@ void EditorApp::RenderGridCanvas() { } else { // Mouse left canvas: end drag / stroke if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + const bool changedDrag=strokeSnapshotTaken || draggingLightIdx>=0 || draggingLightRadiusIdx>=0; draggingSpriteIdx = -1; strokeSnapshotTaken = false; + draggingLightIdx = draggingLightRadiusIdx = -1; + if (changedDrag && autoBakeLighting && LightingNeedsBake()) BakeLighting(); } } @@ -2580,7 +2663,7 @@ void EditorApp::RenderTilePalette() { } void EditorApp::RenderHierarchy() { - ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИЕРАРХИЯ СЦЕНЫ (SCENE TREE)"); + ImGui::TextUnformatted("ОБЪЕКТЫ / OUTLINER"); ImGui::Separator(); // Search filter + Quick Add button (Unity / Unreal style) @@ -2826,12 +2909,13 @@ void EditorApp::RenderHierarchy() { } void EditorApp::RenderInspector() { + LevelEdit edit(*this); if (showCutsceneTimeline && selectedCutsceneEvent >= 0) { RenderCutsceneInspector(); ImGui::Separator(); } - ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИНСПЕКТОР СВОЙСТВ (INSPECTOR)"); + ImGui::TextUnformatted("СВОЙСТВА / DETAILS"); ImGui::Separator(); if (selectedLightIdx >= 0 && selectedLightIdx < static_cast(lightSources.size())) { @@ -2853,7 +2937,7 @@ void EditorApp::RenderInspector() { // 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(); } + if (ImGui::SmallButton("Дублировать (Ctrl+D)")) { DuplicateSelectedEntity(); return; } ImGui::SameLine(); if (ImGui::SmallButton("Фокус (F)")) { FocusSelectedEntity(); } ImGui::SameLine(); @@ -2935,6 +3019,22 @@ void EditorApp::RenderInspector() { 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] рядом с объектом"); + bool bound=HasSwitchDoor(spr); + if (ImGui::Checkbox("Связать с дверью", &bound)) { + if (bound) SetSwitchDoor(spr, spr.x/64, spr.y/64, false); + else SetSpriteEntityData(spr, ENTITY_ARCHETYPE_SWITCH, 100, 0, 0); + } + if (bound) { + int x=(spr.flags>>16)&255, y=(spr.flags>>24)&255; + bool key=(spr.flags&SWITCH_DOOR_KEY)!=0; + bool changed=ImGui::SliderInt("Дверь X", &x, 0, 31); + changed|=ImGui::SliderInt("Дверь Y", &y, 0, 31); + changed|=ImGui::Checkbox("Открывать также ключом", &key); + if (changed) SetSwitchDoor(spr,x,y,key); + if (map[y][x]!=CELL_DOOR && map[y][x]!=CELL_DOOR_OPEN) + ImGui::TextWrapped("Ошибка: в целевой клетке должна стоять дверь. Сохранение будет отклонено."); + ImGui::TextWrapped("Рычаг переключает дверь. Без ключевого режима дверь открывается только рычагом. Один ключ навсегда отпирает дверь до перезапуска комнаты."); + } } else if (archType == ENTITY_ARCHETYPE_BARREL) { ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "● Взрывается от пуль, нанося 60 AOE урона вокруг!"); } @@ -2946,12 +3046,8 @@ void EditorApp::RenderInspector() { // 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)")) { + ImGui::TextWrapped("Диалоги задаются в engine_gameplay.inc через Game_OnPlayerInteract и ShowGameDialog. Текст диалога не хранится в карте EBLN v5."); + if (ImGui::Button("Открыть IDE для редактирования хука (F7)")) { viewportMode = 3; } } diff --git a/editor/src/editor_app.h b/editor/src/editor_app.h index db34cd0..9095dad 100644 --- a/editor/src/editor_app.h +++ b/editor/src/editor_app.h @@ -100,6 +100,7 @@ public: bool showCodeEditorModal = false; bool showProjectSettingsModal = false; bool showExportGameModal = false; + bool contentDrawerOpen = false; std::unique_ptr project; std::string exportStatusMessage; @@ -167,6 +168,18 @@ public: struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr }; private: + // Nested UI scopes capture authored data, never selection/view preferences. + struct LevelEdit { + EditorApp& app; + explicit LevelEdit(EditorApp& owner) : app(owner) { app.BeginLevelEdit(); } + ~LevelEdit() { app.EndLevelEdit(); } + }; + void BeginLevelEdit(); + void EndLevelEdit(); + void RecordLevelEdit(const LevelSnapshot& before, unsigned gesture); + static bool SameLevel(const LevelSnapshot& a, const LevelSnapshot& b); + unsigned editDepth = 0, editEpoch = 0, scopeEpoch = 0, undoGesture = 0; + LevelSnapshot editBefore; void RenderLightingInspector(); void RenderLightingSettings(); bool EnsureEnginePreview(); diff --git a/editor/src/editor_lighting.inl b/editor/src/editor_lighting.inl index 0e57a04..a962141 100644 --- a/editor/src/editor_lighting.inl +++ b/editor/src/editor_lighting.inl @@ -139,7 +139,7 @@ void EditorApp::RenderLightingInspector() { 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("Дублировать (Ctrl+D)", ImVec2(colW2, 24))) { DuplicateSelectedEntity(); return; } if (ImGui::Button("К сетке (Snap)", ImVec2(colW2, 24))) { SnapLightToGrid(selectedLightIdx); } ImGui::SameLine(); diff --git a/editor/src/level_format.h b/editor/src/level_format.h index 7711e93..89710b3 100644 --- a/editor/src/level_format.h +++ b/editor/src/level_format.h @@ -103,6 +103,17 @@ struct SpriteEntry { }; #pragma pack(pop) +// Opt-in switch binding. Existing switch flags keep legacy hook-only behavior. +constexpr uint32_t SWITCH_DOOR_BOUND = 0x0000A800; +constexpr uint32_t SWITCH_DOOR_KEY = 0x00000100; +inline bool HasSwitchDoor(const SpriteEntry& s) { + return (s.flags & 0x0000FEFF) == (SWITCH_DOOR_BOUND | ENTITY_ARCHETYPE_SWITCH); +} +inline void SetSwitchDoor(SpriteEntry& s, int x, int y, bool key) { + s.flags = ENTITY_ARCHETYPE_SWITCH | SWITCH_DOOR_BOUND | (key ? SWITCH_DOOR_KEY : 0) | + (uint32_t(x & 31) << 16) | (uint32_t(y & 31) << 24); +} + inline uint8_t GetSpriteEntityType(const SpriteEntry& s) { uint8_t t = static_cast(s.flags & 0xFF); if (s.flags == 0) { diff --git a/editor/src/level_io.h b/editor/src/level_io.h index 55b3314..1cdaf5c 100644 --- a/editor/src/level_io.h +++ b/editor/src/level_io.h @@ -51,6 +51,14 @@ inline bool ValidateLevel(const BinaryLevel& level, std::string& error) { if (sprite.x < 0 || sprite.x >= MAP_GRID_W * 64 || sprite.y < 0 || sprite.y >= MAP_GRID_H * 64 || sprite.textureId >= 16 || sprite.scale < 0 || sprite.scale > 16 * 65536) return fail("Неверные координаты, текстура или масштаб спрайта"); + if (HasSwitchDoor(sprite)) { + unsigned x=(sprite.flags>>16)&255, y=(sprite.flags>>24)&255; + if (x>=32 || y>=32 || (level.cells[y*32+x]!=CELL_DOOR && level.cells[y*32+x]!=CELL_DOOR_OPEN)) + return fail("Рычаг должен ссылаться на клетку двери"); + for (const auto& other : level.sprites) + if (&other!=&sprite && HasSwitchDoor(other) && (other.flags>>16)==(sprite.flags>>16)) + return fail("У двери может быть только один связанный рычаг"); + } } std::array triggers{}; for (const auto& transition : level.transitions) { diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 82bf38f..36e3137 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -26,22 +26,22 @@ void CreateRenderTarget(); void CleanupRenderTarget(); LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); -void SetupGodotStyle() { +void SetupUnrealStyle() { ImGuiStyle& style = ImGui::GetStyle(); ImVec4* colors = style.Colors; - 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.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.FrameRounding = 2.0f; + style.PopupRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.GrabRounding = 2.0f; + style.TabRounding = 2.0f; + style.FrameBorderSize = 0.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.WindowPadding = ImVec2(8.0f, 8.0f); + style.FramePadding = ImVec2(7.0f, 4.0f); + style.ItemSpacing = ImVec2(6.0f, 5.0f); style.ItemInnerSpacing = ImVec2(6.0f, 4.0f); // Modern Game Studio Dark Palette (Unreal / Unity / Godot Pro) @@ -75,6 +75,22 @@ void SetupGodotStyle() { 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); + // UE5-inspired neutral surfaces. Blue denotes selection, not every panel. + for (int i=0;iAddFontDefault(); } - SetupGodotStyle(); + SetupUnrealStyle(); ImGui_ImplWin32_Init(hWnd); ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext); @@ -244,6 +260,12 @@ LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { return true; switch (msg) { + case WM_GETMINMAXINFO: { + auto* limits=reinterpret_cast(lParam); + limits->ptMinTrackSize.x=1000; + limits->ptMinTrackSize.y=680; + return 0; + } case WM_DROPFILES: { HDROP hDrop = reinterpret_cast(wParam); char filePath[MAX_PATH]; diff --git a/engine.exe b/engine.exe index 1e148fe..2efebc5 100644 Binary files a/engine.exe and b/engine.exe differ diff --git a/include/engine_combat.inc b/include/engine_combat.inc index 8f4f0d2..2185b9a 100644 --- a/include/engine_combat.inc +++ b/include/engine_combat.inc @@ -714,20 +714,25 @@ 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 + ; Compact charcoal surfaces, restrained blue accent, consistent option rows. + invoke DrawRect, 46, 52, 228, 144, 000B0B0Bh + invoke DrawRect, 44, 50, 228, 144, 00202020h + invoke DrawRectOutline, 44, 50, 228, 144, 003C3C3Ch + invoke DrawRect, 44, 50, 228, 2, 002DA5E0h + invoke DrawRect, 45, 53, 226, 24, 00292929h + invoke DrawRect, 52, 84, 212, 18, 00353535h ; Header - invoke DrawString8x8, 112, 72, OFFSET strPauseTitle, COLOR_YELLOW + invoke DrawString8x8, 112, 61, OFFSET strPauseTitle, 00DEDEDEh ; 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 + invoke DrawRect, 52, 84, 2, 18, 002DA5E0h + invoke DrawString8x8, 60, 89, OFFSET strPauseOpt1, 00FFFFFFh + invoke DrawString8x8, 60, 109, OFFSET strPauseOpt2, 00CDCDCDh + invoke DrawString8x8, 60, 129, OFFSET strPauseOpt3, 00CDCDCDh + invoke DrawString8x8, 60, 149, OFFSET strPauseOpt4, 00CDCDCDh + invoke DrawRect, 52, 166, 212, 1, 003C3C3Ch + invoke DrawString8x8, 60, 177, OFFSET strPauseOpt5, 00D99898h pause_menu_done: ret diff --git a/include/engine_data.inc b/include/engine_data.inc index aa4841e..09781a4 100644 --- a/include/engine_data.inc +++ b/include/engine_data.inc @@ -297,6 +297,7 @@ ALIGN 4 entityType dd MAX_SPRITES dup(?) entityHealth dd MAX_SPRITES dup(?) entityState dd MAX_SPRITES dup(?) +doorUnlocked db MAP_SIZE dup(?) entityTimer dd MAX_SPRITES dup(?) entitySpeed dd MAX_SPRITES dup(?) entityAggro dd MAX_SPRITES dup(?) @@ -326,6 +327,7 @@ szSwitchOnToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВКЛ", 0 szSwitchOffToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВЫКЛ", 0 szDoorOpenedToast db "ДВЕРЬ ОТКРЫТА", 0 szDoorClosedToast db "ДВЕРЬ ЗАКРЫТА", 0 +szDoorLockedToast db "ДВЕРЬ ЗАПЕРТА", 0 szBarrelExplodedToast db "ВЗРЫВ БОЧКИ!", 0 szWeaponsEnabledToast db "РЕЖИМ: ОРУЖИЕ ВКЛЮЧЕНО", 0 szWeaponsDisabledToast db "РЕЖИМ: БЕЗ ОРУЖИЯ (КВЕСТ)", 0 diff --git a/include/engine_entity.inc b/include/engine_entity.inc index 70d61f5..b9be35f 100644 --- a/include/engine_entity.inc +++ b/include/engine_entity.inc @@ -12,6 +12,10 @@ RestartCurrentRoom PROTO ; InitEntities: Initialize entity runtime state from spriteList flags/defaults ; ------------------------------------------------------------------------------ InitEntities PROC uses eax ebx ecx edx esi edi + xor eax, eax + mov ecx, MAP_SIZE + mov edi, OFFSET doorUnlocked + rep stosb ; 1. Clear runtime arrays xor eax, eax mov ecx, MAX_SPRITES @@ -287,11 +291,33 @@ check_switch_entity: test eax, eax jnz next_entity - ; Default switch behavior: toggle state + ; Opt-in authored door binding: byte 1 = A8/A9, X/Y in bytes 2/3. + mov eax, [ebx].SpriteStruct.flags + mov edx, eax + and edx, 0FEFFh + cmp edx, 0A805h + jne legacy_switch_toggle + mov edx, eax + shr edx, 24 + shr eax, 16 + and eax, 0FFh + invoke ToggleDoorCell, eax, edx + test eax, eax + jz next_entity + ; ToggleDoorCell returns the resulting cell, or zero if blocked/invalid. + cmp eax, CELL_DOOR_OPEN + sete al + movzx eax, al + mov ecx, i + mov [entityState + ecx*4], eax + jmp switch_show_state +legacy_switch_toggle: + ; Legacy switches still toggle state and run the public gameplay hook. mov ecx, i mov eax, [entityState + ecx*4] xor eax, 1 mov [entityState + ecx*4], eax +switch_show_state: test eax, eax jz switch_off invoke ShowGameToast, OFFSET szSwitchOnToast, 60, COLOR_GREEN diff --git a/include/engine_map.inc b/include/engine_map.inc index 755cc04..7ba6c13 100644 --- a/include/engine_map.inc +++ b/include/engine_map.inc @@ -187,6 +187,46 @@ IsSolidCell ENDP ; ------------------------------------------------------------------------------ ; InteractDoor: Checks if player is facing a door or portal and handles interaction ; ------------------------------------------------------------------------------ +; Returns resulting cell or zero. Shared by direct interaction and switches. +ToggleDoorCell PROC uses ebx ecx edx, cellX:DWORD, cellY:DWORD + invoke GetMapCell, cellX, cellY + cmp eax, CELL_DOOR + jne toggle_check_close + invoke SetMapCell, cellX, cellY, CELL_DOOR_OPEN + mov eax, CELL_DOOR_OPEN + ret +toggle_check_close: + cmp eax, CELL_DOOR_OPEN + jne toggle_blocked + mov eax, playerX + sub eax, playerRadius + sar eax, CELL_SHIFT + cmp eax, cellX + jg toggle_close + mov eax, playerX + add eax, playerRadius + sar eax, CELL_SHIFT + cmp eax, cellX + jl toggle_close + mov eax, playerY + sub eax, playerRadius + sar eax, CELL_SHIFT + cmp eax, cellY + jg toggle_close + mov eax, playerY + add eax, playerRadius + sar eax, CELL_SHIFT + cmp eax, cellY + jge toggle_blocked +toggle_close: + invoke SetMapCell, cellX, cellY, CELL_DOOR + mov eax, CELL_DOOR + ret +toggle_blocked: + xor eax, eax + ret +ToggleDoorCell ENDP + InteractDoor PROC uses ebx ecx edx esi edi LOCAL targetX:DWORD, targetY:DWORD @@ -212,42 +252,63 @@ InteractDoor PROC uses ebx ecx edx esi edi invoke GetMapCell, targetX, targetY cmp eax, CELL_DOOR - jne check_door_close - - ; Open the door! - invoke SetMapCell, targetX, targetY, CELL_DOOR_OPEN - invoke ShowGameToast, OFFSET szDoorOpenedToast, 50, COLOR_GREEN - ret - -check_door_close: + je interact_door_found cmp eax, CELL_DOOR_OPEN jne check_portal_interact - - ; Never close a door over any part of the player's collision body. - mov eax, playerX - sub eax, playerRadius - sar eax, CELL_SHIFT - cmp eax, targetX - jg close_clear_door - mov eax, playerX - add eax, playerRadius - sar eax, CELL_SHIFT - cmp eax, targetX - jl close_clear_door - mov eax, playerY - sub eax, playerRadius - sar eax, CELL_SHIFT - cmp eax, targetY - jg close_clear_door - mov eax, playerY - add eax, playerRadius - sar eax, CELL_SHIFT - cmp eax, targetY - jge no_door -close_clear_door: - invoke SetMapCell, targetX, targetY, CELL_DOOR +interact_door_found: + mov keyAction, 0 + ; A bound door is switch-only, or key-unlockable. Ordinary doors stay free. + mov edi, targetY + shl edi, CELL_SHIFT-1 ; MAP_W is 32 + add edi, targetX + cmp byte ptr [doorUnlocked+edi], 0 + jne interact_toggle + mov esi, OFFSET spriteList + mov ecx, spriteCount +interact_binding_loop: + test ecx, ecx + jz interact_toggle + mov eax, [esi].SpriteStruct.flags + cmp al, ENTITY_TYPE_SWITCH + jne interact_binding_next + mov edx, eax + and edx, 0FEFFh + cmp edx, 0A805h + jne interact_binding_next + mov edx, eax + shr edx, 16 + and edx, 0FFh + cmp edx, targetX + jne interact_binding_next + mov edx, eax + shr edx, 24 + cmp edx, targetY + jne interact_binding_next + test eax, 100h + jz interact_locked + cmp playerKeys, 0 + je interact_locked + dec playerKeys + mov byte ptr [doorUnlocked+edi], 1 + jmp interact_toggle +interact_binding_next: + add esi, sizeof SpriteStruct + dec ecx + jmp interact_binding_loop +interact_locked: + invoke ShowGameToast, OFFSET szDoorLockedToast, 50, COLOR_YELLOW + ret +interact_toggle: + invoke ToggleDoorCell, targetX, targetY + cmp eax, CELL_DOOR_OPEN + je interact_opened + cmp eax, CELL_DOOR + jne no_door invoke ShowGameToast, OFFSET szDoorClosedToast, 50, COLOR_YELLOW ret +interact_opened: + invoke ShowGameToast, OFFSET szDoorOpenedToast, 50, COLOR_GREEN + ret check_portal_interact: ; Check if facing a transition trigger or CELL_PORTAL @@ -267,6 +328,7 @@ portal_find_loop: jne portal_find_next ; Found transition! + mov keyAction, 0 movzx eax, [esi].LevelTransition.targetSpawnX movzx ebx, [esi].LevelTransition.targetSpawnY movzx ecx, [esi].LevelTransition.targetAngle @@ -831,6 +893,49 @@ sprite_coords_raw: jl close_load_failed cmp [esi].SpriteStruct.scale, 16 * FIXED_ONE ja close_load_failed + mov eax, [esi].SpriteStruct.flags + cmp al, ENTITY_TYPE_SWITCH + jne validated_sprite_binding + mov edx, eax + and edx, 0FEFFh + cmp edx, 0A805h + jne validated_sprite_binding + mov edx, eax + shr edx, 24 + cmp edx, MAP_H + jae close_load_failed + shr eax, 16 + and eax, 0FFh + cmp eax, MAP_W + jae close_load_failed + shl edx, 5 + add edx, eax + mov al, stagedMap[edx] + cmp al, CELL_DOOR + je binding_cell_ok + cmp al, CELL_DOOR_OPEN + jne close_load_failed +binding_cell_ok: + lea edi, stagedSprites +binding_unique_loop: + cmp edi, esi + jae validated_sprite_binding + mov eax, [edi].SpriteStruct.flags + cmp al, ENTITY_TYPE_SWITCH + jne binding_unique_next + mov edx, eax + and edx, 0FEFFh + cmp edx, 0A805h + jne binding_unique_next + shr eax, 16 + mov edx, [esi].SpriteStruct.flags + shr edx, 16 + cmp eax, edx + je close_load_failed +binding_unique_next: + add edi, sizeof SpriteStruct + jmp binding_unique_loop +validated_sprite_binding: add esi, sizeof SpriteStruct dec ecx jmp validate_sprite diff --git a/main.obj b/main.obj index 0952839..be62444 100644 Binary files a/main.obj and b/main.obj differ diff --git a/tests/editor_regressions.cpp b/tests/editor_regressions.cpp index 417ce48..f1907df 100644 --- a/tests/editor_regressions.cpp +++ b/tests/editor_regressions.cpp @@ -25,6 +25,7 @@ static std::vector Bytes(const fs::path& path) { #include "lighting_editor_checks.inl" #include "viewport_editor_checks.inl" #include "stability_editor_checks.inl" +#include "studio_editor_checks.inl" int main(int argc, char** argv) { EditorApp app; @@ -156,6 +157,7 @@ int main(int argc, char** argv) { } if(argc>2) RunViewportChecks(app,argv[2]); RunStabilityEditorChecks(); + RunStudioEditorChecks(); std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n"; return failures ? 1 : 0; } diff --git a/tests/engine_regressions.asm b/tests/engine_regressions.asm index 1625d8c..ac975f2 100644 --- a/tests/engine_regressions.asm +++ b/tests/engine_regressions.asm @@ -133,6 +133,7 @@ include tests/lighting_engine_checks.inc include tests/stability_engine_checks.inc include tests/integration_engine_checks.inc include tests/simulation_engine_checks.inc +include tests/studio_engine_checks.inc AuditMain PROC call InitMathTables @@ -374,6 +375,7 @@ flat_depth_ok: call RunCutsceneChecks call RunStabilityChecks call RunSimulationChecks + call RunStudioChecks mov eax, resultCount inc eax shl eax, 2 diff --git a/tests/studio_editor_checks.inl b/tests/studio_editor_checks.inl new file mode 100644 index 0000000..fcc6929 --- /dev/null +++ b/tests/studio_editor_checks.inl @@ -0,0 +1,106 @@ +static void RunStudioEditorChecks() { + EditorApp app; + app.LoadLevel("../assets/map.dat"); + auto original=app.CaptureSnapshot(); + auto before=original; + app.RecordLevelEdit(before,71); + Check(app.undoStack.empty(),"selection-only gesture creates no undo entry"); + app.playerStartX=7; + app.RecordLevelEdit(before,71); + before=app.CaptureSnapshot(); app.playerStartX=8; app.RecordLevelEdit(before,71); + Check(app.undoStack.size()==1,"continuous inspector gesture creates exactly one snapshot"); + app.RecordLevelEdit(app.CaptureSnapshot(),0); + before=app.CaptureSnapshot(); app.floorColor=0; app.RecordLevelEdit(before,72); + Check(app.undoStack.size()==2,"next property edit gets an independent snapshot"); + app.Undo(); Check(app.playerStartX==8 && app.floorColor==original.floorColor,"undo palette preserves preceding transform"); + app.Undo(); Check(EditorApp::SameLevel(app.CaptureSnapshot(),original),"undo continuous transform restores initial state"); + app.Redo(); app.Redo(); Check(app.playerStartX==8 && app.floorColor==0,"redo restores both authored edits"); + app.Undo(); before=app.CaptureSnapshot(); app.ceilingColor=123; app.RecordLevelEdit(before,0); + Check(app.redoStack.empty(),"new edit after undo invalidates redo branch"); + + before=app.CaptureSnapshot(); + app.sprites[0].x+=10; app.sprites[0].scale=131072; app.sprites[0].textureId=13; + SetSpriteEntityData(app.sprites[0],ENTITY_ARCHETYPE_GUARD,55,3,8); + app.transitions[0].targetSpawnX=9; app.transitions[0].targetAngle=128; + strcpy_s(app.transitions[0].targetMap,"other.dat"); + app.cutscene.flags=CUTSCENE_AUTOPLAY; + app.RecordLevelEdit(before,0); + app.draggingLightRadiusIdx=2; app.strokeSnapshotTaken=true; app.Undo(); + Check(EditorApp::SameLevel(app.CaptureSnapshot(),before),"snapshot restores sprite parameters portal names and cutscene flags together"); + Check(app.draggingLightRadiusIdx==-1 && !app.strokeSnapshotTaken,"undo cancels light-radius drag and paint stroke"); + for(int i=0;i<80;++i) {before=app.CaptureSnapshot(); app.floorColor=i+1; app.RecordLevelEdit(before,0);} + Check(app.undoStack.size()==64,"history remains bounded to sixty-four snapshots"); + + BinaryLevel level; std::string error; + ReadLevelFile("../assets/map.dat",level,error); + level.cells[10*32+10]=CELL_DOOR; + SetSwitchDoor(level.sprites[0],10,10,true); + Check(ValidateLevel(level,error) && HasSwitchDoor(level.sprites[0]),"switch binding uses existing v5 sprite bytes"); + level.cells[10*32+10]=CELL_EMPTY; + Check(!ValidateLevel(level,error),"switch binding cannot target a non-door cell"); + level.cells[10*32+10]=CELL_DOOR; + level.sprites.push_back(level.sprites[0]); + Check(!ValidateLevel(level,error),"duplicate door bindings are rejected"); + level.sprites.pop_back(); level.sprites[0].flags|=0x20000000; + Check(!ValidateLevel(level,error),"out-of-bounds switch target is rejected"); + + // Drive actual ImGui inspector controls, not a duplicate model of UI edits. + ImGui::CreateContext(); + auto& io=ImGui::GetIO(); io.IniFilename=nullptr; io.DisplaySize=ImVec2(1400,1000); io.DeltaTime=1.0f/60; + unsigned char* pixels; int w,h; io.Fonts->GetTexDataAsRGBA32(&pixels,&w,&h); + app.LoadLevel("../assets/map.dat"); app.selectedSpriteIdx=-1; app.selectedTransitionIdx=-1; + auto frame=[&](const char* target, const char* text) { + if(text) io.AddInputCharactersUTF8(text); + ImGui::NewFrame(); + ImGui::SetNextWindowPos(ImVec2(0,0)); ImGui::SetNextWindowSize(ImVec2(600,950)); + ImGui::Begin("InspectorTest",nullptr,ImGuiWindowFlags_NoSavedSettings); + if(target) { + auto& g=*ImGui::GetCurrentContext(); + g.NavActivateId=g.NavActivateDownId=ImGui::GetID(target); g.NavActivateFlags=ImGuiActivateFlags_PreferInput; + } + app.RenderInspector(); + ImGui::End(); ImGui::Render(); + }; + frame(nullptr,nullptr); + frame("Spawn X",nullptr); + frame(nullptr,"7"); + io.AddKeyEvent(ImGuiKey_Enter,true); frame(nullptr,nullptr); + io.AddKeyEvent(ImGuiKey_Enter,false); frame(nullptr,nullptr); + Check(app.playerStartX==7 && app.undoStack.size()==1,"real inspector spawn input records original authored state"); + app.Undo(); Check(app.playerStartX==2,"real inspector spawn input can be undone"); + app.Redo(); Check(app.playerStartX==7,"real inspector spawn input can be redone"); + app.selectedSpriteIdx=0; + frame(nullptr,nullptr); + auto oldX=app.sprites[0].x; auto count=app.undoStack.size(); + frame("Позиция X",nullptr); frame(nullptr,"6"); + io.AddKeyEvent(ImGuiKey_Enter,true); frame(nullptr,nullptr); + io.AddKeyEvent(ImGuiKey_Enter,false); frame(nullptr,nullptr); + Check(app.sprites[0].x==384 && app.undoStack.size()==count+1,"real sprite inspector transform participates in history"); + app.Undo(); Check(app.sprites[0].x==oldX,"real sprite inspector undo restores raw world coordinates"); + app.selectedSpriteIdx=-1; app.selectedTransitionIdx=0; + auto oldSpawn=app.transitions[0].targetSpawnX; count=app.undoStack.size(); + frame(nullptr,nullptr); frame("Целевой Spawn X",nullptr); frame(nullptr,"11"); + io.AddKeyEvent(ImGuiKey_Enter,true); frame(nullptr,nullptr); + io.AddKeyEvent(ImGuiKey_Enter,false); frame(nullptr,nullptr); + Check(app.transitions[0].targetSpawnX==11 && app.undoStack.size()==count+1,"real portal destination input records a transaction"); + app.Undo(); Check(app.transitions[0].targetSpawnX==oldSpawn,"real portal destination undo restores original spawn"); + app.selectedTransitionIdx=-1; + auto oldPalette=app.CaptureSnapshot(); count=app.undoStack.size(); + frame(nullptr,nullptr); frame("Хоррор",nullptr); frame(nullptr,nullptr); + Check(app.ceilingColor==0x003B0710 && app.undoStack.size()==count+1,"real environment preset records both colors once"); + app.Undo(); Check(app.ceilingColor==oldPalette.ceilingColor && app.floorColor==oldPalette.floorColor, + "real environment preset undo restores ceiling and floor"); + app.viewportMode=2; app.showCutsceneTimeline=false; + auto uiBefore=app.CaptureSnapshot(); count=app.undoStack.size(); + for (float width : {1000.0f,1360.0f,1920.0f}) { + io.DisplaySize=ImVec2(width,840); + app.contentDrawerOpen=true; + ImGui::NewFrame(); app.RenderUI(); ImGui::Render(); + ImGui::NewFrame(); app.RenderUI(); ImGui::Render(); + } + Check(ImGui::GetDrawData()->TotalVtxCount>0 && app.editDepth==0, + "full workspace and content drawer render at supported window widths"); + Check(EditorApp::SameLevel(uiBefore,app.CaptureSnapshot()) && app.undoStack.size()==count, + "workspace layout and content browsing do not dirty authored history"); + ImGui::DestroyContext(); +} diff --git a/tests/studio_engine_checks.inc b/tests/studio_engine_checks.inc new file mode 100644 index 0000000..53ee4f6 --- /dev/null +++ b/tests/studio_engine_checks.inc @@ -0,0 +1,88 @@ +.data +bindingPath db 'binding.dat',0 +badBindingPath db 'badbinding.dat',0 +badBindingBoundsPath db 'badbindingbounds.dat',0 +badBindingDuplicatePath db 'badbindingduplicate.dat',0 +.code +RunStudioChecks PROC uses ebx esi edi + invoke LoadSublevelBinary, OFFSET bindingPath, -1, -1, 0 + CHECK eax, 1 ; ASM loader accepts valid authored door binding + invoke LoadSublevelBinary, OFFSET badBindingPath, -1, -1, 0 + CHECK eax, 0 ; ASM loader rejects switch binding to a non-door + invoke LoadSublevelBinary, OFFSET badBindingBoundsPath, -1, -1, 0 + CHECK eax, 0 ; ASM loader rejects out-of-bounds switch target + invoke LoadSublevelBinary, OFFSET badBindingDuplicatePath, -1, -1, 0 + CHECK eax, 0 ; ASM loader rejects duplicate switch target bindings + call ResetSimulationFixture + mov keyForward, 0 + mov playerX, 160 + mov playerY, 160 + mov playerAngle, 0 + mov spriteCount, 1 + mov [spriteList].SpriteStruct.x, 160 + mov [spriteList].SpriteStruct.y, 160 + mov [spriteList].SpriteStruct.textureId, 12 + mov [spriteList].SpriteStruct.scale, FIXED_ONE + ; target (3,2), key-unlockable switch binding + mov [spriteList].SpriteStruct.flags, 0203A905h + mov BYTE PTR [gameMap+2*MAP_W+3], CELL_DOOR + call InitEntities + mov playerKeys, 0 + mov keyAction, 1 + call InteractDoor + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR ; bound door stays locked without a key + movzx eax, keyAction + CHECK eax, 0 ; locked door consumes action before nearby switches + mov playerKeys, 2 + call InteractDoor + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR_OPEN ; key opens the explicitly authored locked door + CHECK playerKeys, 1 ; first unlock consumes exactly one key + call InteractDoor + call InteractDoor + CHECK playerKeys, 1 ; unlocked door never consumes a second key + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR_OPEN ; unlocked door remains reusable + call InitEntities + movzx eax, BYTE PTR [doorUnlocked+2*MAP_W+3] + CHECK eax, 0 ; room entity initialization clears temporary unlocks + mov [spriteList].SpriteStruct.flags, 0203A805h + mov BYTE PTR [gameMap+2*MAP_W+3], CELL_DOOR + mov keyAction, 1 + call InteractDoor + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR ; switch-only door cannot be bypassed with keys + mov playerAngle, 256 ; face away from door so E reaches nearby switch + mov keyAction, 1 + call UpdateEntities + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR_OPEN ; authored switch opens its target door + CHECK entityState, 1 ; switch state reflects the resulting door state + mov keyAction, 1 + call UpdateEntities + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR ; second switch action closes target door + mov BYTE PTR [gameMap+2*MAP_W+3], CELL_DOOR_OPEN + mov playerX, 3*64-8 ; body overlaps target door + mov [spriteList].SpriteStruct.x, 3*64-8 + mov keyAction, 1 + call UpdateEntities + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR_OPEN ; remote switch cannot close a door across player body + mov playerX, 160 + mov playerAngle, 0 + mov [spriteList].SpriteStruct.flags, 6405h ; legacy switch has no binding marker + mov BYTE PTR [gameMap+2*MAP_W+3], CELL_DOOR + call InitEntities + mov playerKeys, 0 + mov keyAction, 1 + call InteractDoor + call UpdateEntities + movzx eax, BYTE PTR [gameMap+2*MAP_W+3] + CHECK eax, CELL_DOOR_OPEN ; ordinary doors remain free on legacy maps + CHECK entityState, 0 ; one action cannot open a door and toggle a nearby legacy switch + invoke ToggleDoorCell, 32, 32 + CHECK eax, 0 ; invalid switch targets are bounded at runtime + ret +RunStudioChecks ENDP diff --git a/tools/test_regressions.py b/tools/test_regressions.py index 1e3fa4b..1ea80dd 100644 --- a/tools/test_regressions.py +++ b/tools/test_regressions.py @@ -82,6 +82,16 @@ def fixtures(work): orphan = bytearray(grid); orphan[2 * 32 + 4] = 10 level("orphan.dat", transitions=[], cells=orphan) level("duplicate.dat", transitions=[(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] * 2) + binding = bytearray(level("binding.dat",count=1,transitions=[])) + binding[38+2*32+3]=5 + struct.pack_into("