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)
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.

View file

@ -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 лучу на каждый вертикальный столбец экрана):

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

Binary file not shown.

View file

@ -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<int32_t>(wx * 64.0f);
sprites[draggingSpriteIdx].y = static_cast<int32_t>(wy * 64.0f);
sprites[draggingSpriteIdx].x = std::clamp(static_cast<int32_t>(wx * 64.0f), 0, MAP_GRID_W*64-1);
sprites[draggingSpriteIdx].y = std::clamp(static_cast<int32_t>(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<int>(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;
}
}

View file

@ -100,6 +100,7 @@ public:
bool showCodeEditorModal = false;
bool showProjectSettingsModal = false;
bool showExportGameModal = false;
bool contentDrawerOpen = false;
std::unique_ptr<EblanProject> 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();

View file

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

View file

@ -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<uint8_t>(s.flags & 0xFF);
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 ||
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<bool, MAP_TOTAL_CELLS> triggers{};
for (const auto& transition : level.transitions) {

View file

@ -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;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) {
@ -140,7 +156,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
io.Fonts->AddFontDefault();
}
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<MINMAXINFO*>(lParam);
limits->ptMinTrackSize.x=1000;
limits->ptMinTrackSize.y=680;
return 0;
}
case WM_DROPFILES: {
HDROP hDrop = reinterpret_cast<HDROP>(wParam);
char filePath[MAX_PATH];

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

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 "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;
}

View file

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

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
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("<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))
with wave.open(str(work / "editor/quiet.wav"), "wb") as sound:
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)
source = (ROOT / "tests/engine_regressions.asm").read_text() + "".join(
(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)
raw = (work / "engine_results.bin").read_bytes()
values = struct.unpack(f"<{len(raw)//4}I", raw)