feat: add authored history and door interactions

Group level edits across inspector and canvas gestures, preserve legacy maps, and expose opt-in switch/key door bindings without changing wire layouts.

Refresh studio and pause UI with UE5-inspired surfaces and content drawer. Verify 162 editor and 307 ASM checks plus renderer parity.
This commit is contained in:
Игорь Федосеев 2026-10-31 22:51:08 +03:00
parent aa64510def
commit 7a56403d65
21 changed files with 675 additions and 97 deletions

View file

@ -307,6 +307,15 @@ EblanStudio has evolved from a pure level editor into a **Full Game Engine Studi
### K. Authored Lighting Format (EBLN v5) ### 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. 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. 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.

View file

@ -54,6 +54,25 @@ Playing. Playing обновляет игрока, сущности, бой, `Gam
представляет кадр; `UpdateScreenFade` обновляет переход, `ApplyScreenFade` меняет представляет кадр; `UpdateScreenFade` обновляет переход, `ApplyScreenFade` меняет
только пиксели. Скриншот и счётчик FPS относятся к представлению. только пиксели. Скриншот и счётчик 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) ## 2. Конвейер лучевого рендеринга (2.5D Raycasting)
Каждый кадр для экрана шириной 320 пикселей выпускается ровно **320 лучей** (по 1 лучу на каждый вертикальный столбец экрана): Каждый кадр для экрана шириной 320 пикселей выпускается ровно **320 лучей** (по 1 лучу на каждый вертикальный столбец экрана):

View file

@ -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 direct ASM rendering through the x86/x64/DX11 path. Project assets remain
byte-for-byte unchanged. No manual interactive playtest was performed. 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 ## 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. 4. Project lifecycle and validated, safe standalone export.
5. Measurements and targeted renderer/editor optimizations. 5. Measurements and targeted renderer/editor optimizations.
6. Finished multi-room demo game and release verification. 6. Finished multi-room demo game and release verification.
7. Data-driven extensions and subsequent rendering features. 7. Data-driven extensions and subsequent rendering features.
Stages 1 and 2 do not claim that all audit findings are fixed: AI visibility/melee Stages 1–3 do not claim that all audit findings are fixed: AI visibility/melee
through walls, full undo and exporter safety remain assigned to subsequent through walls and exporter safety remain open. Existing map and texture assets
stages. Existing map and texture assets are not migrated or rewritten. are not migrated or rewritten.

Binary file not shown.

View file

@ -92,6 +92,8 @@ EditorApp::~EditorApp() {
} }
void EditorApp::InitDefaultLevel() { void EditorApp::InitDefaultLevel() {
draggingLightRadiusIdx = -1;
strokeSnapshotTaken = false;
// Clear map // Clear map
for (int y = 0; y < MAP_GRID_H; ++y) { for (int y = 0; y < MAP_GRID_H; ++y) {
for (int x = 0; x < MAP_GRID_W; ++x) { for (int x = 0; x < MAP_GRID_W; ++x) {
@ -226,6 +228,8 @@ bool EditorApp::LoadLevel(const std::string& filepath) {
selectedSpriteIdx = selectedTransitionIdx = draggingSpriteIdx = -1; selectedSpriteIdx = selectedTransitionIdx = draggingSpriteIdx = -1;
strokeSnapshotTaken = false; strokeSnapshotTaken = false;
undoStack.clear(); redoStack.clear(); undoStack.clear(); redoStack.clear();
++editEpoch; undoGesture = 0;
draggingLightRadiusIdx = -1;
statusMessage = "Уровень загружен: " + filepath; statusMessage = "Уровень загружен: " + filepath;
return true; return true;
} }
@ -415,6 +419,7 @@ LevelSnapshot EditorApp::CaptureSnapshot() const {
} }
void EditorApp::RestoreSnapshot(const LevelSnapshot& s) { void EditorApp::RestoreSnapshot(const LevelSnapshot& s) {
if (ImGui::GetCurrentContext()) ImGui::ClearActiveID();
std::memcpy(map, s.map, sizeof(map)); std::memcpy(map, s.map, sizeof(map));
playerStartX = s.playerStartX; playerStartX = s.playerStartX;
playerStartY = s.playerStartY; 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;} for(int i=0;i<1024;++i) {auto c=bakedLightRGB[i];bakedLightmap[i]=(((c>>16)&255)+((c>>8)&255)+(c&255))/3;}
selectedLightIdx = -1; selectedLightIdx = -1;
draggingLightIdx = -1; draggingLightIdx = -1;
draggingLightRadiusIdx = -1;
strokeSnapshotTaken = false;
selectedCutsceneEvent = -1; placeCameraPoint = false; selectedCutsceneEvent = -1; placeCameraPoint = false;
ScrubCutscene(cutsceneCursor); ScrubCutscene(cutsceneCursor);
selectedSpriteIdx = -1; selectedSpriteIdx = -1;
@ -440,12 +447,15 @@ void EditorApp::RestoreSnapshot(const LevelSnapshot& s) {
} }
void EditorApp::PushUndo() { void EditorApp::PushUndo() {
if (editDepth) return; // UI scope records only real changes, once per gesture.
undoGesture = 0;
undoStack.push_back(CaptureSnapshot()); undoStack.push_back(CaptureSnapshot());
if (undoStack.size() > MAX_UNDO) undoStack.pop_front(); if (undoStack.size() > MAX_UNDO) undoStack.pop_front();
redoStack.clear(); redoStack.clear();
} }
void EditorApp::Undo() { void EditorApp::Undo() {
++editEpoch; undoGesture = 0;
if (undoStack.empty()) return; if (undoStack.empty()) return;
redoStack.push_back(CaptureSnapshot()); redoStack.push_back(CaptureSnapshot());
RestoreSnapshot(undoStack.back()); RestoreSnapshot(undoStack.back());
@ -454,6 +464,7 @@ void EditorApp::Undo() {
} }
void EditorApp::Redo() { void EditorApp::Redo() {
++editEpoch; undoGesture = 0;
if (redoStack.empty()) return; if (redoStack.empty()) return;
undoStack.push_back(CaptureSnapshot()); undoStack.push_back(CaptureSnapshot());
RestoreSnapshot(redoStack.back()); RestoreSnapshot(redoStack.back());
@ -461,6 +472,50 @@ void EditorApp::Redo() {
statusMessage = "Повтор действия."; 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) // File dialogs (comdlg32)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -1502,6 +1557,7 @@ void EditorApp::HandleHotkeys() {
if (io.WantTextInput || ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return; if (io.WantTextInput || ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId)) return;
if (io.KeyCtrl) { if (io.KeyCtrl) {
if (ImGui::IsKeyPressed(ImGuiKey_Space, false)) contentDrawerOpen=!contentDrawerOpen;
if (ImGui::IsKeyPressed(ImGuiKey_N, false)) { PushUndo(); InitDefaultLevel(); } if (ImGui::IsKeyPressed(ImGuiKey_N, false)) { PushUndo(); InitDefaultLevel(); }
if (ImGui::IsKeyPressed(ImGuiKey_O, false)) { if (ImGui::IsKeyPressed(ImGuiKey_O, false)) {
std::string p = BrowseOpenFile(); std::string p = BrowseOpenFile();
@ -1529,7 +1585,7 @@ void EditorApp::HandleHotkeys() {
if (ImGui::IsKeyPressed(ImGuiKey_B, false)) currentTool = TOOL_BRUSH; if (ImGui::IsKeyPressed(ImGuiKey_B, false)) currentTool = TOOL_BRUSH;
if (ImGui::IsKeyPressed(ImGuiKey_E, false)) currentTool = TOOL_ERASER; 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_P, false)) currentTool = TOOL_PLAYER_SPAWN;
if (ImGui::IsKeyPressed(ImGuiKey_S, false)) currentTool = TOOL_SPRITE_SPAWN; if (ImGui::IsKeyPressed(ImGuiKey_S, false)) currentTool = TOOL_SPRITE_SPAWN;
if (ImGui::IsKeyPressed(ImGuiKey_L, false)) currentTool = TOOL_LIGHT_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() { void EditorApp::RenderUI() {
AdvanceCutscenePreview(ImGui::GetIO().DeltaTime); AdvanceCutscenePreview(ImGui::GetIO().DeltaTime);
CheckPlaytestProcess(); CheckPlaytestProcess();
LevelEdit edit(*this);
HandleHotkeys(); HandleHotkeys();
RenderMenuBar(); RenderMenuBar();
@ -1652,17 +1709,20 @@ void EditorApp::RenderUI() {
// Main layout // Main layout
ImGuiViewport* vp = ImGui::GetMainViewport(); ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 64)); const float drawerHeight=contentDrawerOpen ? (std::min)(240.0f,vp->Size.y*0.32f) : 0;
ImGui::SetNextWindowSize(ImVec2(vp->Size.x, vp->Size.y - 89)); 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, ImGui::Begin("Workspace", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | 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 // Columns: Left = Hierarchy & Tile Palette, Middle = Workspace Viewport/IDE, Right = Inspector
float totalAvailX = ImGui::GetContentRegionAvail().x; float totalAvailX = ImGui::GetContentRegionAvail().x;
const float splitterW = 6.0f; const float splitterW = 6.0f;
const float spacingX = ImGui::GetStyle().ItemSpacing.x; 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); float maxLeftW = (std::max)(180.0f, totalAvailX - rightPanelWidth - 320.0f);
if (leftPanelWidth > maxLeftW) leftPanelWidth = maxLeftW; if (leftPanelWidth > maxLeftW) leftPanelWidth = maxLeftW;
if (leftPanelWidth < 180.0f) leftPanelWidth = 180.0f; if (leftPanelWidth < 180.0f) leftPanelWidth = 180.0f;
@ -1677,7 +1737,7 @@ void EditorApp::RenderUI() {
// Left Panel (Unity / Unreal Docked Tabs) // Left Panel (Unity / Unreal Docked Tabs)
ImGui::BeginChild("LeftPanel", ImVec2(leftPanelWidth, 0), true); ImGui::BeginChild("LeftPanel", ImVec2(leftPanelWidth, 0), true);
if (ImGui::BeginTabBar("LeftPanelTabs", ImGuiTabBarFlags_None)) { if (ImGui::BeginTabBar("LeftPanelTabs", ImGuiTabBarFlags_None)) {
if (ImGui::BeginTabItem("Сцена")) { if (ImGui::BeginTabItem("Объекты")) {
RenderHierarchy(); RenderHierarchy();
ImGui::EndTabItem(); ImGui::EndTabItem();
} }
@ -1685,7 +1745,7 @@ void EditorApp::RenderUI() {
RenderTilePalette(); RenderTilePalette();
ImGui::EndTabItem(); ImGui::EndTabItem();
} }
if (ImGui::BeginTabItem("Файлы")) { if (ImGui::BeginTabItem("Контент")) {
ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "Браузер ассетов"); ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "Браузер ассетов");
ImGui::Separator(); ImGui::Separator();
RenderAssetManagerContents(); RenderAssetManagerContents();
@ -1721,12 +1781,12 @@ void EditorApp::RenderUI() {
ImGui::PopStyleColor(2); ImGui::PopStyleColor(2);
}; };
ModeTabButton("2D Карта", 2); ModeTabButton(compact ? "2D" : "2D Карта", 2);
ImGui::SameLine(); ImGui::SameLine();
ModeTabButton("3D Сцена", 1); ModeTabButton(compact ? "3D" : "3D Сцена", 1);
ImGui::SameLine();
ModeTabButton("2D + 3D", 0);
ImGui::SameLine(); ImGui::SameLine();
ModeTabButton(compact ? "Оба" : "2D + 3D", 0);
if (!compact) ImGui::SameLine();
ModeTabButton("Код / IDE", 3); ModeTabButton("Код / IDE", 3);
ImGui::SameLine(); ImGui::SameLine();
ModeTabButton("Ассеты", 4); ModeTabButton("Ассеты", 4);
@ -1801,15 +1861,32 @@ void EditorApp::RenderUI() {
ImGui::End(); 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 // Status Bar
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + vp->Size.y - 25)); ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + vp->Size.y - 25));
ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 25)); ImGui::SetNextWindowSize(ImVec2(vp->Size.x, 25));
ImGui::Begin("StatusBar", nullptr, ImGui::Begin("StatusBar", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar); ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings);
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "● %s", statusMessage.c_str()); if (ImGui::SmallButton("Контент Ctrl+Space")) contentDrawerOpen=!contentDrawerOpen;
ImGui::SameLine(vp->Size.x - 280); ImGui::SameLine();
ImGui::Text("FPS: %.1f | Zoom: %.0f%% | Undo: %zu", ImGui::GetIO().Framerate, (canvasZoom / 20.0f) * 100.0f, undoStack.size()); ImGui::Text("Undo: %zu | Redo: %zu",undoStack.size(),redoStack.size());
ImGui::SameLine();
ImGui::TextDisabled("%s",statusMessage.c_str());
ImGui::End(); ImGui::End();
} }
@ -1861,7 +1938,7 @@ void EditorApp::RenderMenuBar() {
if (ImGui::BeginMenu("Инструменты")) { if (ImGui::BeginMenu("Инструменты")) {
if (ImGui::MenuItem("Кисть (Стена)", "B", currentTool == TOOL_BRUSH)) currentTool = TOOL_BRUSH; if (ImGui::MenuItem("Кисть (Стена)", "B", currentTool == TOOL_BRUSH)) currentTool = TOOL_BRUSH;
if (ImGui::MenuItem("Ластик (Очистить)", "E", currentTool == TOOL_ERASER)) currentTool = TOOL_ERASER; 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("Спавн игрока", "P", currentTool == TOOL_PLAYER_SPAWN)) currentTool = TOOL_PLAYER_SPAWN;
if (ImGui::MenuItem("Добавить спрайт", "S", currentTool == TOOL_SPRITE_SPAWN)) currentTool = TOOL_SPRITE_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; if (ImGui::MenuItem("Источник света", "L", currentTool == TOOL_LIGHT_SPAWN)) currentTool = TOOL_LIGHT_SPAWN;
@ -1935,12 +2012,12 @@ void EditorApp::RenderMenuBar() {
void EditorApp::RenderToolbar() { void EditorApp::RenderToolbar() {
ImGuiViewport* vp = ImGui::GetMainViewport(); ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x, vp->Pos.y + 20)); 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, ImGui::Begin("Toolbar", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | 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)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(5.0f, 4.0f));
// 1. PLAY / STOP // 1. PLAY / STOP
@ -2011,18 +2088,21 @@ void EditorApp::RenderToolbar() {
if (ImGui::Button(label, ImVec2(0, 26))) currentTool = mode; if (ImGui::Button(label, ImVec2(0, 26))) currentTool = mode;
ImGui::PopStyleColor(2); ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tooltip); if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tooltip);
ImGui::SameLine();
}; };
ToolButton(" [B] Кисть ", TOOL_BRUSH, "Кисть для рисования стен [B]"); ToolButton(" [B] Кисть ", TOOL_BRUSH, "Кисть для рисования стен [B]");
ImGui::SameLine();
ToolButton(" [E] Ластик ", TOOL_ERASER, "Ластик для очистки ячеек [E]"); 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]"); ToolButton(" [P] Игрок ", TOOL_PLAYER_SPAWN, "Точка старта игрока [P]");
ImGui::SameLine();
ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN, "Размещение сущностей и спрайтов [S]"); ToolButton(" [S] Спрайт ", TOOL_SPRITE_SPAWN, "Размещение сущностей и спрайтов [S]");
ImGui::SameLine();
ToolButton(" [L] Свет ", TOOL_LIGHT_SPAWN, "Размещение источников освещения [L]"); ToolButton(" [L] Свет ", TOOL_LIGHT_SPAWN, "Размещение источников освещения [L]");
ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); ImGui::Separator();
ImGui::SameLine();
// 3. LIGHTING SUBSYSTEM // 3. LIGHTING SUBSYSTEM
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.48f, 0.05f, 1.0f)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.80f, 0.48f, 0.05f, 1.0f));
@ -2053,7 +2133,7 @@ void EditorApp::RenderToolbar() {
} else { } else {
if (ImGui::Button(" Фонарик: Выкл ", ImVec2(0, 26))) { PushUndo(); isFlashlightOn = true; } if (ImGui::Button(" Фонарик: Выкл ", ImVec2(0, 26))) { PushUndo(); isFlashlightOn = true; }
} }
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Включить/выключить фонарик игрока [F]"); if (ImGui::IsItemHovered()) ImGui::SetTooltip("Фонарик игрока. В игре переключается клавишей F.");
ImGui::SameLine(); ImGui::SameLine();
if (showLightHeatmap) { if (showLightHeatmap) {
@ -2429,8 +2509,8 @@ void EditorApp::RenderGridCanvas() {
} else if (draggingSpriteIdx >= 0) { } else if (draggingSpriteIdx >= 0) {
float wx = (mousePos.x - gridOrigin.x) / tileSize; float wx = (mousePos.x - gridOrigin.x) / tileSize;
float wy = (mousePos.y - gridOrigin.y) / tileSize; float wy = (mousePos.y - gridOrigin.y) / tileSize;
sprites[draggingSpriteIdx].x = static_cast<int32_t>(wx * 64.0f); sprites[draggingSpriteIdx].x = std::clamp(static_cast<int32_t>(wx * 64.0f), 0, MAP_GRID_W*64-1);
sprites[draggingSpriteIdx].y = static_cast<int32_t>(wy * 64.0f); sprites[draggingSpriteIdx].y = std::clamp(static_cast<int32_t>(wy * 64.0f), 0, MAP_GRID_H*64-1);
} else if (strokeSnapshotTaken && inGrid) { } else if (strokeSnapshotTaken && inGrid) {
ApplyTool(hoverGx, hoverGy); // no extra snapshot during stroke ApplyTool(hoverGx, hoverGy); // no extra snapshot during stroke
} }
@ -2461,8 +2541,11 @@ void EditorApp::RenderGridCanvas() {
} else { } else {
// Mouse left canvas: end drag / stroke // Mouse left canvas: end drag / stroke
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
const bool changedDrag=strokeSnapshotTaken || draggingLightIdx>=0 || draggingLightRadiusIdx>=0;
draggingSpriteIdx = -1; draggingSpriteIdx = -1;
strokeSnapshotTaken = false; strokeSnapshotTaken = false;
draggingLightIdx = draggingLightRadiusIdx = -1;
if (changedDrag && autoBakeLighting && LightingNeedsBake()) BakeLighting();
} }
} }
@ -2580,7 +2663,7 @@ void EditorApp::RenderTilePalette() {
} }
void EditorApp::RenderHierarchy() { void EditorApp::RenderHierarchy() {
ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИЕРАРХИЯ СЦЕНЫ (SCENE TREE)"); ImGui::TextUnformatted("ОБЪЕКТЫ / OUTLINER");
ImGui::Separator(); ImGui::Separator();
// Search filter + Quick Add button (Unity / Unreal style) // Search filter + Quick Add button (Unity / Unreal style)
@ -2826,12 +2909,13 @@ void EditorApp::RenderHierarchy() {
} }
void EditorApp::RenderInspector() { void EditorApp::RenderInspector() {
LevelEdit edit(*this);
if (showCutsceneTimeline && selectedCutsceneEvent >= 0) { if (showCutsceneTimeline && selectedCutsceneEvent >= 0) {
RenderCutsceneInspector(); RenderCutsceneInspector();
ImGui::Separator(); ImGui::Separator();
} }
ImGui::TextColored(ImVec4(0.2f, 0.8f, 1.0f, 1.0f), "ИНСПЕКТОР СВОЙСТВ (INSPECTOR)"); ImGui::TextUnformatted("СВОЙСТВА / DETAILS");
ImGui::Separator(); ImGui::Separator();
if (selectedLightIdx >= 0 && selectedLightIdx < static_cast<int>(lightSources.size())) { if (selectedLightIdx >= 0 && selectedLightIdx < static_cast<int>(lightSources.size())) {
@ -2853,7 +2937,7 @@ void EditorApp::RenderInspector() {
// Entity Header Card (Unity / Unreal style) // Entity Header Card (Unity / Unreal style)
ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "Сущность #%d: %s", selectedSpriteIdx + 1, typeName); 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(); ImGui::SameLine();
if (ImGui::SmallButton("Фокус (F)")) { FocusSelectedEntity(); } if (ImGui::SmallButton("Фокус (F)")) { FocusSelectedEntity(); }
ImGui::SameLine(); ImGui::SameLine();
@ -2935,6 +3019,22 @@ void EditorApp::RenderInspector() {
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) { } 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] рядом с объектом");
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) { } else if (archType == ENTITY_ARCHETYPE_BARREL) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "● Взрывается от пуль, нанося 60 AOE урона вокруг!"); 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 // Component 4: Interaction & Lore Notes
if (ImGui::CollapsingHeader("Интерактивный диалог / Записка", ImGuiTreeNodeFlags_DefaultOpen)) { if (ImGui::CollapsingHeader("Интерактивный диалог / Записка", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::TextDisabled("При взаимодействии [E] вызывается ShowGameDialog:"); ImGui::TextWrapped("Диалоги задаются в engine_gameplay.inc через Game_OnPlayerInteract и ShowGameDialog. Текст диалога не хранится в карте EBLN v5.");
static char szSpeaker[32] = "ПЕРСОНАЖ"; if (ImGui::Button("Открыть IDE для редактирования хука (F7)")) {
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; viewportMode = 3;
} }
} }

View file

@ -100,6 +100,7 @@ public:
bool showCodeEditorModal = false; bool showCodeEditorModal = false;
bool showProjectSettingsModal = false; bool showProjectSettingsModal = false;
bool showExportGameModal = false; bool showExportGameModal = false;
bool contentDrawerOpen = false;
std::unique_ptr<EblanProject> project; std::unique_ptr<EblanProject> project;
std::string exportStatusMessage; std::string exportStatusMessage;
@ -167,6 +168,18 @@ public:
struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr }; struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr };
private: 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 RenderLightingInspector();
void RenderLightingSettings(); void RenderLightingSettings();
bool EnsureEnginePreview(); bool EnsureEnginePreview();

View file

@ -139,7 +139,7 @@ void EditorApp::RenderLightingInspector() {
float colW2 = (ImGui::GetContentRegionAvail().x - 6.0f) * 0.5f; float colW2 = (ImGui::GetContentRegionAvail().x - 6.0f) * 0.5f;
if (ImGui::Button("Фокус (F)", ImVec2(colW2, 24))) { FocusSelectedEntity(); } if (ImGui::Button("Фокус (F)", ImVec2(colW2, 24))) { FocusSelectedEntity(); }
ImGui::SameLine(); 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); } if (ImGui::Button("К сетке (Snap)", ImVec2(colW2, 24))) { SnapLightToGrid(selectedLightIdx); }
ImGui::SameLine(); ImGui::SameLine();

View file

@ -103,6 +103,17 @@ struct SpriteEntry {
}; };
#pragma pack(pop) #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) { inline uint8_t GetSpriteEntityType(const SpriteEntry& s) {
uint8_t t = static_cast<uint8_t>(s.flags & 0xFF); uint8_t t = static_cast<uint8_t>(s.flags & 0xFF);
if (s.flags == 0) { if (s.flags == 0) {

View file

@ -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 || 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) sprite.textureId >= 16 || sprite.scale < 0 || sprite.scale > 16 * 65536)
return fail("Неверные координаты, текстура или масштаб спрайта"); 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<bool, MAP_TOTAL_CELLS> triggers{}; std::array<bool, MAP_TOTAL_CELLS> triggers{};
for (const auto& transition : level.transitions) { for (const auto& transition : level.transitions) {

View file

@ -26,22 +26,22 @@ void CreateRenderTarget();
void CleanupRenderTarget(); void CleanupRenderTarget();
LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
void SetupGodotStyle() { void SetupUnrealStyle() {
ImGuiStyle& style = ImGui::GetStyle(); ImGuiStyle& style = ImGui::GetStyle();
ImVec4* colors = style.Colors; ImVec4* colors = style.Colors;
style.WindowRounding = 8.0f; style.WindowRounding = 0.0f;
style.ChildRounding = 6.0f; style.ChildRounding = 0.0f;
style.FrameRounding = 5.0f; style.FrameRounding = 2.0f;
style.PopupRounding = 8.0f; style.PopupRounding = 2.0f;
style.ScrollbarRounding = 7.0f; style.ScrollbarRounding = 2.0f;
style.GrabRounding = 5.0f; style.GrabRounding = 2.0f;
style.TabRounding = 6.0f; style.TabRounding = 2.0f;
style.FrameBorderSize = 1.0f; style.FrameBorderSize = 0.0f;
style.WindowBorderSize = 1.0f; style.WindowBorderSize = 1.0f;
style.WindowPadding = ImVec2(10.0f, 10.0f); style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(8.0f, 5.0f); style.FramePadding = ImVec2(7.0f, 4.0f);
style.ItemSpacing = ImVec2(8.0f, 6.0f); style.ItemSpacing = ImVec2(6.0f, 5.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f); style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
// Modern Game Studio Dark Palette (Unreal / Unity / Godot Pro) // 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_Tab] = ImVec4(0.14f, 0.16f, 0.20f, 1.00f);
colors[ImGuiCol_TabHovered] = ImVec4(0.28f, 0.34f, 0.42f, 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); 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;i<ImGuiCol_COUNT;++i) {
auto& c=colors[i];
float gray=(c.x+c.y+c.z)/3.0f;
c.x=c.y=c.z=gray;
}
colors[ImGuiCol_Text]=ImVec4(0.82f,0.82f,0.82f,1);
colors[ImGuiCol_WindowBg]=ImVec4(0.075f,0.075f,0.075f,1);
colors[ImGuiCol_ChildBg]=ImVec4(0.12f,0.12f,0.12f,1);
colors[ImGuiCol_FrameBg]=ImVec4(0.075f,0.075f,0.075f,1);
colors[ImGuiCol_PopupBg]=ImVec4(0.13f,0.13f,0.13f,1);
colors[ImGuiCol_Header]=ImVec4(0.18f,0.18f,0.18f,1);
colors[ImGuiCol_HeaderActive]=ImVec4(0.12f,0.32f,0.49f,1);
colors[ImGuiCol_CheckMark]=ImVec4(0.16f,0.56f,0.86f,1);
colors[ImGuiCol_SliderGrab]=colors[ImGuiCol_CheckMark];
colors[ImGuiCol_TabActive]=ImVec4(0.20f,0.20f,0.20f,1);
} }
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {
@ -140,7 +156,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
io.Fonts->AddFontDefault(); io.Fonts->AddFontDefault();
} }
SetupGodotStyle(); SetupUnrealStyle();
ImGui_ImplWin32_Init(hWnd); ImGui_ImplWin32_Init(hWnd);
ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext); ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext);
@ -244,6 +260,12 @@ LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
return true; return true;
switch (msg) { switch (msg) {
case WM_GETMINMAXINFO: {
auto* limits=reinterpret_cast<MINMAXINFO*>(lParam);
limits->ptMinTrackSize.x=1000;
limits->ptMinTrackSize.y=680;
return 0;
}
case WM_DROPFILES: { case WM_DROPFILES: {
HDROP hDrop = reinterpret_cast<HDROP>(wParam); HDROP hDrop = reinterpret_cast<HDROP>(wParam);
char filePath[MAX_PATH]; char filePath[MAX_PATH];

Binary file not shown.

View file

@ -714,20 +714,25 @@ RenderPauseMenu PROC uses ebx ecx edx
cmp isGamePaused, 0 cmp isGamePaused, 0
je pause_menu_done je pause_menu_done
; Centered semi-transparent panel 220x112 at (50, 64) ; Compact charcoal surfaces, restrained blue accent, consistent option rows.
invoke DrawRect, 50, 64, 220, 112, 00141416h invoke DrawRect, 46, 52, 228, 144, 000B0B0Bh
invoke DrawRectOutline, 50, 64, 220, 112, COLOR_CYAN invoke DrawRect, 44, 50, 228, 144, 00202020h
invoke DrawRectOutline, 52, 66, 216, 108, COLOR_WHITE 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 ; Header
invoke DrawString8x8, 112, 72, OFFSET strPauseTitle, COLOR_YELLOW invoke DrawString8x8, 112, 61, OFFSET strPauseTitle, 00DEDEDEh
; Menu Options ; Menu Options
invoke DrawString8x8, 64, 88, OFFSET strPauseOpt1, COLOR_GREEN invoke DrawRect, 52, 84, 2, 18, 002DA5E0h
invoke DrawString8x8, 64, 102, OFFSET strPauseOpt2, COLOR_WHITE invoke DrawString8x8, 60, 89, OFFSET strPauseOpt1, 00FFFFFFh
invoke DrawString8x8, 64, 116, OFFSET strPauseOpt3, COLOR_CYAN invoke DrawString8x8, 60, 109, OFFSET strPauseOpt2, 00CDCDCDh
invoke DrawString8x8, 64, 130, OFFSET strPauseOpt4, COLOR_YELLOW invoke DrawString8x8, 60, 129, OFFSET strPauseOpt3, 00CDCDCDh
invoke DrawString8x8, 64, 146, OFFSET strPauseOpt5, 00FF4444h invoke DrawString8x8, 60, 149, OFFSET strPauseOpt4, 00CDCDCDh
invoke DrawRect, 52, 166, 212, 1, 003C3C3Ch
invoke DrawString8x8, 60, 177, OFFSET strPauseOpt5, 00D99898h
pause_menu_done: pause_menu_done:
ret ret

View file

@ -297,6 +297,7 @@ ALIGN 4
entityType dd MAX_SPRITES dup(?) entityType dd MAX_SPRITES dup(?)
entityHealth dd MAX_SPRITES dup(?) entityHealth dd MAX_SPRITES dup(?)
entityState dd MAX_SPRITES dup(?) entityState dd MAX_SPRITES dup(?)
doorUnlocked db MAP_SIZE dup(?)
entityTimer dd MAX_SPRITES dup(?) entityTimer dd MAX_SPRITES dup(?)
entitySpeed dd MAX_SPRITES dup(?) entitySpeed dd MAX_SPRITES dup(?)
entityAggro dd MAX_SPRITES dup(?) entityAggro dd MAX_SPRITES dup(?)
@ -326,6 +327,7 @@ szSwitchOnToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВКЛ", 0
szSwitchOffToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВЫКЛ", 0 szSwitchOffToast db "ПЕРЕКЛЮЧАТЕЛЬ: ВЫКЛ", 0
szDoorOpenedToast db "ДВЕРЬ ОТКРЫТА", 0 szDoorOpenedToast db "ДВЕРЬ ОТКРЫТА", 0
szDoorClosedToast db "ДВЕРЬ ЗАКРЫТА", 0 szDoorClosedToast db "ДВЕРЬ ЗАКРЫТА", 0
szDoorLockedToast db "ДВЕРЬ ЗАПЕРТА", 0
szBarrelExplodedToast db "ВЗРЫВ БОЧКИ!", 0 szBarrelExplodedToast db "ВЗРЫВ БОЧКИ!", 0
szWeaponsEnabledToast db "РЕЖИМ: ОРУЖИЕ ВКЛЮЧЕНО", 0 szWeaponsEnabledToast db "РЕЖИМ: ОРУЖИЕ ВКЛЮЧЕНО", 0
szWeaponsDisabledToast db "РЕЖИМ: БЕЗ ОРУЖИЯ (КВЕСТ)", 0 szWeaponsDisabledToast db "РЕЖИМ: БЕЗ ОРУЖИЯ (КВЕСТ)", 0

View file

@ -12,6 +12,10 @@ RestartCurrentRoom PROTO
; InitEntities: Initialize entity runtime state from spriteList flags/defaults ; InitEntities: Initialize entity runtime state from spriteList flags/defaults
; ------------------------------------------------------------------------------ ; ------------------------------------------------------------------------------
InitEntities PROC uses eax ebx ecx edx esi edi 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 ; 1. Clear runtime arrays
xor eax, eax xor eax, eax
mov ecx, MAX_SPRITES mov ecx, MAX_SPRITES
@ -287,11 +291,33 @@ check_switch_entity:
test eax, eax test eax, eax
jnz next_entity 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 ecx, i
mov eax, [entityState + ecx*4] mov eax, [entityState + ecx*4]
xor eax, 1 xor eax, 1
mov [entityState + ecx*4], eax mov [entityState + ecx*4], eax
switch_show_state:
test eax, eax test eax, eax
jz switch_off jz switch_off
invoke ShowGameToast, OFFSET szSwitchOnToast, 60, COLOR_GREEN invoke ShowGameToast, OFFSET szSwitchOnToast, 60, COLOR_GREEN

View file

@ -187,6 +187,46 @@ IsSolidCell ENDP
; ------------------------------------------------------------------------------ ; ------------------------------------------------------------------------------
; InteractDoor: Checks if player is facing a door or portal and handles interaction ; 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 InteractDoor PROC uses ebx ecx edx esi edi
LOCAL targetX:DWORD, targetY:DWORD LOCAL targetX:DWORD, targetY:DWORD
@ -212,42 +252,63 @@ InteractDoor PROC uses ebx ecx edx esi edi
invoke GetMapCell, targetX, targetY invoke GetMapCell, targetX, targetY
cmp eax, CELL_DOOR cmp eax, CELL_DOOR
jne check_door_close je interact_door_found
; Open the door!
invoke SetMapCell, targetX, targetY, CELL_DOOR_OPEN
invoke ShowGameToast, OFFSET szDoorOpenedToast, 50, COLOR_GREEN
ret
check_door_close:
cmp eax, CELL_DOOR_OPEN cmp eax, CELL_DOOR_OPEN
jne check_portal_interact jne check_portal_interact
interact_door_found:
; Never close a door over any part of the player's collision body. mov keyAction, 0
mov eax, playerX ; A bound door is switch-only, or key-unlockable. Ordinary doors stay free.
sub eax, playerRadius mov edi, targetY
sar eax, CELL_SHIFT shl edi, CELL_SHIFT-1 ; MAP_W is 32
cmp eax, targetX add edi, targetX
jg close_clear_door cmp byte ptr [doorUnlocked+edi], 0
mov eax, playerX jne interact_toggle
add eax, playerRadius mov esi, OFFSET spriteList
sar eax, CELL_SHIFT mov ecx, spriteCount
cmp eax, targetX interact_binding_loop:
jl close_clear_door test ecx, ecx
mov eax, playerY jz interact_toggle
sub eax, playerRadius mov eax, [esi].SpriteStruct.flags
sar eax, CELL_SHIFT cmp al, ENTITY_TYPE_SWITCH
cmp eax, targetY jne interact_binding_next
jg close_clear_door mov edx, eax
mov eax, playerY and edx, 0FEFFh
add eax, playerRadius cmp edx, 0A805h
sar eax, CELL_SHIFT jne interact_binding_next
cmp eax, targetY mov edx, eax
jge no_door shr edx, 16
close_clear_door: and edx, 0FFh
invoke SetMapCell, targetX, targetY, CELL_DOOR 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 invoke ShowGameToast, OFFSET szDoorClosedToast, 50, COLOR_YELLOW
ret ret
interact_opened:
invoke ShowGameToast, OFFSET szDoorOpenedToast, 50, COLOR_GREEN
ret
check_portal_interact: check_portal_interact:
; Check if facing a transition trigger or CELL_PORTAL ; Check if facing a transition trigger or CELL_PORTAL
@ -267,6 +328,7 @@ portal_find_loop:
jne portal_find_next jne portal_find_next
; Found transition! ; Found transition!
mov keyAction, 0
movzx eax, [esi].LevelTransition.targetSpawnX movzx eax, [esi].LevelTransition.targetSpawnX
movzx ebx, [esi].LevelTransition.targetSpawnY movzx ebx, [esi].LevelTransition.targetSpawnY
movzx ecx, [esi].LevelTransition.targetAngle movzx ecx, [esi].LevelTransition.targetAngle
@ -831,6 +893,49 @@ sprite_coords_raw:
jl close_load_failed jl close_load_failed
cmp [esi].SpriteStruct.scale, 16 * FIXED_ONE cmp [esi].SpriteStruct.scale, 16 * FIXED_ONE
ja close_load_failed 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 add esi, sizeof SpriteStruct
dec ecx dec ecx
jmp validate_sprite jmp validate_sprite

BIN
main.obj

Binary file not shown.

View file

@ -25,6 +25,7 @@ static std::vector<uint8_t> Bytes(const fs::path& path) {
#include "lighting_editor_checks.inl" #include "lighting_editor_checks.inl"
#include "viewport_editor_checks.inl" #include "viewport_editor_checks.inl"
#include "stability_editor_checks.inl" #include "stability_editor_checks.inl"
#include "studio_editor_checks.inl"
int main(int argc, char** argv) { int main(int argc, char** argv) {
EditorApp app; EditorApp app;
@ -156,6 +157,7 @@ int main(int argc, char** argv) {
} }
if(argc>2) RunViewportChecks(app,argv[2]); if(argc>2) RunViewportChecks(app,argv[2]);
RunStabilityEditorChecks(); RunStabilityEditorChecks();
RunStudioEditorChecks();
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n"; std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
return failures ? 1 : 0; return failures ? 1 : 0;
} }

View file

@ -133,6 +133,7 @@ include tests/lighting_engine_checks.inc
include tests/stability_engine_checks.inc include tests/stability_engine_checks.inc
include tests/integration_engine_checks.inc include tests/integration_engine_checks.inc
include tests/simulation_engine_checks.inc include tests/simulation_engine_checks.inc
include tests/studio_engine_checks.inc
AuditMain PROC AuditMain PROC
call InitMathTables call InitMathTables
@ -374,6 +375,7 @@ flat_depth_ok:
call RunCutsceneChecks call RunCutsceneChecks
call RunStabilityChecks call RunStabilityChecks
call RunSimulationChecks call RunSimulationChecks
call RunStudioChecks
mov eax, resultCount mov eax, resultCount
inc eax inc eax
shl eax, 2 shl eax, 2

View file

@ -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();
}

View file

@ -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

View file

@ -82,6 +82,16 @@ def fixtures(work):
orphan = bytearray(grid); orphan[2 * 32 + 4] = 10 orphan = bytearray(grid); orphan[2 * 32 + 4] = 10
level("orphan.dat", transitions=[], cells=orphan) level("orphan.dat", transitions=[], cells=orphan)
level("duplicate.dat", transitions=[(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] * 2) 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("<I",binding,38+1024+20,0x0203A905)
(assets/"binding.dat").write_bytes(binding)
broken=bytearray(binding); broken[38+2*32+3]=0
(assets/"badbinding.dat").write_bytes(broken)
broken=bytearray(binding); struct.pack_into("<I",broken,38+1024+20,0x2003A905)
(assets/"badbindingbounds.dat").write_bytes(broken)
broken=bytearray(binding); struct.pack_into("<H",broken,16,2); broken+=broken[38+1024:]
(assets/"badbindingduplicate.dat").write_bytes(broken)
(assets / "badpack.bin").write_bytes(struct.pack("<IHH", 0x58544245, 1, 16) + bytes(12)) (assets / "badpack.bin").write_bytes(struct.pack("<IHH", 0x58544245, 1, 16) + bytes(12))
with wave.open(str(work / "editor/quiet.wav"), "wb") as sound: with wave.open(str(work / "editor/quiet.wav"), "wb") as sound:
sound.setnchannels(1); sound.setsampwidth(2); sound.setframerate(8000) sound.setnchannels(1); sound.setsampwidth(2); sound.setframerate(8000)
@ -150,7 +160,7 @@ def main():
run([str(work / "engine_tests.exe"), "pace map.dat"], work) run([str(work / "engine_tests.exe"), "pace map.dat"], work)
source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join( source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join(
(ROOT / "tests" / name).read_text() for name in (ROOT / "tests" / name).read_text() for name in
("lighting_engine_checks.inc", "cutscene_engine_checks.inc", "stability_engine_checks.inc", "integration_engine_checks.inc", "simulation_engine_checks.inc")) ("lighting_engine_checks.inc", "cutscene_engine_checks.inc", "stability_engine_checks.inc", "integration_engine_checks.inc", "simulation_engine_checks.inc", "studio_engine_checks.inc"))
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M) names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
raw = (work / "engine_results.bin").read_bytes() raw = (work / "engine_results.bin").read_bytes()
values = struct.unpack(f"<{len(raw)//4}I", raw) values = struct.unpack(f"<{len(raw)//4}I", raw)