diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md
index 3fea0f1..fdd71ec 100644
--- a/src/features/editScene/AGENTS.md
+++ b/src/features/editScene/AGENTS.md
@@ -32,6 +32,13 @@ cmake --build build-vscode --target editSceneEditor -j4
cd build-vscode/src/features/editScene
./editSceneEditor
+# Open a project directory (chdir into it before OGRE init; all
+# CWD-relative paths — resources.cfg, config JSONs, scenes, prefabs,
+# heightmaps/ — resolve against it; see "Project Directory" below)
+./editSceneEditor --project /path/to/project
+./editSceneEditor --project /path/to/project --game # force game mode
+./editSceneEditor --project /path/to/project --editor # force editor mode
+
# Run game mode (loads the configured base scene through the startup menu)
./editSceneEditor --game
@@ -101,17 +108,31 @@ cd demos/demo-scene-switching
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
# Escape = pause menu (frees the cursor).
-# Demo: same scene-switching setup, but scene A additionally holds an
-# "interrior" entity with a CellGridComponent (room with floor, ceiling,
-# interior walls and an exit door) carrying its own ProceduralMaterial +
-# ProceduralTexture; the grid's texture rectangle names reference the
-# texture's named rects ("floor" / "ceiling") without any
-# Lot/District/Town parent.
+# Demo: same scene-switching setup, but both transitions go through
+# F1 scene-switch doors instead of portal actuators. Scene A holds an
+# "interrior" entity with an interiorOnly CellGridComponent (room with
+# floor, ceiling, interior walls, boundary windows with opaque glass and
+# doors) carrying its own ProceduralMaterial + ProceduralTexture; its
+# external doorway Z:0:0:15 is configured (doorConfigs) as a scene-switch
+# door to demo_scene_b.json (target arrival_b). Scene B holds an
+# exteriorOnly CellGridComponent (entity "x1"/"w1" - just the shell with
+# opaque window glass) whose grid-wide doorSceneSwitchPath /
+# doorSceneSwitchTarget makes its external doorway Z:0:0:0 the way back
+# to demo_scene_a.json (target arrival_a). E on such a door swings the
+# leaf open; the switch fires only when the leaf is fully open, with a
+# black occluder hiding the ungenerated half of the building. Scene A's
+# internal doorway Z:0:0:8 additionally demos F6 (persistent + lockable,
+# locked by default, unlocked by the grid's inline scene script through
+# the door event contract).
cd demos/demo-scene-switching-extra
./demoSceneSwitchingExtra
# ...or headless smoke run (one frame, then exit):
./demoSceneSwitchingExtra --headless --exit-after-first-frame
-# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
+# ...or headless end-to-end check of the A -> B -> A round trip through
+# both scene-switch doors plus the F6 locked-door contract
+# (exits non-zero on failure):
+./demoSceneSwitchingExtra --headless --test-switch
+# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
@@ -150,6 +171,55 @@ to `EditorApp`.
| `Playing` | Player controller and gameplay systems active |
| `Paused` | Pause menu / character sheet open, gameplay systems frozen |
+## Project Directory (F8)
+
+A "project" is a directory the editor/game treats as its working directory.
+`main.cpp` handles `--project
` (or `--project=`) by `chdir()`ing
+into the project root **before** OGRE initialization, so every CWD-relative
+path (`resources.cfg`, config JSONs, scenes, prefabs, `heightmaps/`,
+`lua-scripts`) resolves against it. `EditorApp::getProjectConfig()` /
+`getProjectRoot()` expose the state; **File -> Open Project...** in the
+editor switches project at runtime (`EditorApp::openProject()`: chdir +
+reload project.json + clear scene). The dialog is an ImGui directory
+browser (ImGui has no native file dialog): subdirectory list with `..`
+navigation, dot-directories hidden, dirs containing `project.json`
+tagged `[project]`, plus an editable path field (Enter navigates,
+relative paths resolve against the browsed dir) and "Open This
+Directory".
+
+A project root may contain `project.json`:
+
+```json
+{
+ "appName": "My Game",
+ "startScene": "scenes/level1.json",
+ "gameMode": true
+}
+```
+
+- `appName` (fallback: directory name) drives the window title and the
+ per-project save directory `//saves/`
+ (`SaveLoadSystem::setAppName()`; default remains `World2`).
+- `gameMode: true` makes the editor binary enter game mode by default for
+ the project (`--editor` forces editor mode, `--game` forces game mode).
+- `gameMode` + `startScene`: game mode skips `startup_menu.json` and calls
+ `startNewGame(startScene)` right after `initApp()`, like a release
+ binary. Caveat: demos that create meshes programmatically in their
+ `demo_main.cpp` (e.g. `DemoFloorPlaneA`) only have them in their own
+ binary — the generic editor binary logs missing-mesh warnings for those.
+
+**Release binaries.** Demo executables (e.g. `demoSceneSwitchingExtra`)
+embed their project at build time: CMake reads the demo's `project.json`
+(`string(JSON ...)`) and generates `project.h` via `configure_file`
+(`EDITSCENE_PROJECT_APP_NAME`, `EDITSCENE_PROJECT_START_SCENE`,
+`EDITSCENE_PROJECT_GAME_MODE`; the target gets
+`-DEDITSCENE_HAS_EMBEDDED_PROJECT` and the generated include dir).
+`CMAKE_CONFIGURE_DEPENDS` on `project.json` re-generates the header when
+the file changes. The demo's `main()` passes the embedded app name to the
+`EditorApp` constructor and sets a `ProjectConfig` rooted at the CWD, so
+the binary runs its project with no flags and no editor UI. Ship the
+binary + its staged project directory as the distributable.
+
## Frame Update Order
`EditorApp::frameRenderingQueued()` updates systems in this order when not paused:
@@ -454,16 +524,238 @@ when `CellGridComponent::doorActionName` is set, also runs that action.
`doorOpenAngle` at `doorOpenSpeed`, disables the door's rigid body while the
door is not fully closed, and re-enables it once closed again.
-Scene switching doors: when `CellGridComponent::doorSceneSwitchPath` is set,
-activation instead queues `EditorApp::switchScene()` directly (no action or
-behavior tree involved) with `doorSceneSwitchTarget` as the teleport target
-entity name in the new scene (same mechanism as the "switchScene" BT node's
-"@name" param). The leaf does not swing and the prompt always reads "E Open".
+Scene switching doors (F1): when `CellGridComponent::doorSceneSwitchPath`
+is set (or the per-door override's `sceneSwitchPath`), activation swings
+the leaf like a normal door (`toggleRequested` +
+`DoorComponent::sceneSwitchPending`); `DoorSystem` queues
+`EditorApp::switchScene(sceneSwitchPath, {targetEntityName:
+sceneSwitchTarget})` and emits the `door_scene_switch` event (params:
+`path`, `target`, `door_id`) only when the leaf reaches `openAngle` — the
+existing loading cover hides the transition. Pressing E on an
+already-open scene-switch door switches immediately; a close cancels a
+pending switch. The prompt stays "E Open" (never "E Close"). The door
+builder also adds an unlit black occluder box (`CellGridDoorOccluderBox`
+mesh + `CellGridDoorOccluderBlack` material) covering the doorway a few
+cm behind the closed leaf plane, childed to the grid node (it does NOT
+swing with the hinge) and tracked via `DoorComponent::occluder`;
+`DoorSystem` hides it while the door is fully closed so the player never
+sees the missing room interior through the opened doorway before the
+switch fires.
New `CellGridComponent` fields (serialized in the scene JSON and exposed to
Lua): `doorsEnabled`, `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`,
-`doorOpenAngle`, `doorOpenSpeed`, `doorActionName`, `doorSceneSwitchPath`,
-`doorSceneSwitchTarget`.
+`doorOpenAngle`, `doorOpenSpeed`, `doorSwingReversed` (F3: negates the
+applied swing angle via `DoorSystem::swingOrientation()`, also used when
+snapping a restored-open door; stored angles stay positive),
+`doorActionName`, `doorSceneSwitchPath`, `doorSceneSwitchTarget`.
+
+#### Generation mode (F4/F5)
+
+`CellGridComponent::generationMode` (string: `"full"` default,
+`"interiorOnly"`, `"exteriorOnly"`; helpers `interiorOnlyMode()` /
+`exteriorOnlyMode()`; serialized + Lua-bound; editor combo in the Cell Grid
+editor) selects which parts `buildCellGrid()` generates:
+
+- `"interiorOnly"` skips the exterior shell: external wall planes
+ (`buildWalls`), external window panels (`buildWindows`), external window
+ frame placement, external corners (`buildCorners`) and roofs
+ (`buildRoofs`). Floors, ceilings, internal walls/frames, furniture, the
+ exit doorway wall panels (`extDoorsTb`), external door frames and all
+ door entities stay — interior-only scenes are paired with exterior-only
+ scenes and transition through scene-switch doors. Boundary windows
+ (cells carrying both the internal and the matching external window
+ flag) get a glass pane at the internal wall plane, hiding the void
+ outside (see below).
+- `"exteriorOnly"` keeps only the shell (external walls, external
+ door/window panels, roofs, corners, external frames, exit door
+ entities) and adds a glass pane per external window opening.
+- Window glass (both modes): `CellGridSystem::buildGlass()` generates a
+ thin box (2 cm) per window opening into `meshData.glassMesh`, which
+ gets a collider in the static shell (no climbing through windows).
+ exteriorOnly panes sit at the external wall plane; interiorOnly panes
+ only cover boundary windows and sit at the internal wall plane; pure
+ interior room-to-room windows get no glass. The pane uses
+ `glassMaterialName` when set, otherwise a built-in per-grid
+ `CellGridGlass_` material created/updated by
+ `getGlassMaterialName()` — **opaque on purpose** (no alpha blend,
+ depth write on, diffuse alpha forced to 1) because it must hide the
+ half of the building the mode does not generate; the `glassColor`
+ alpha is ignored by the built-in material, `glassReflectivity` drives
+ specular/shininess. Fields `glassColor` / `glassMaterialName` /
+ `glassReflectivity` are serialized and Lua-bound; the editor shows them
+ when the mode is exteriorOnly or interiorOnly.
+- Skipped parts have empty buffers, so they get no meshes and no physics
+ colliders (colliders follow the mesh lists).
+
+#### Door identity & per-door configuration (F0)
+
+A doorway's identity is its **canonical edge key** (`"X:x:y:z"` /
+`"Z:x:y:z"`, `CellGridSystem::doorEdgeKey()` — both cells sharing a door
+edge produce the same key). A door's **global ID** is
+`:` where `gridUid` is a serialized
+`CellGridComponent::gridUid` (a random UUID generated lazily by
+`ensureGridUid()`), so door IDs survive grid entity renames, grid rebuilds
+and scene loads. `DoorComponent` carries the runtime copies `edgeKey` and
+`doorId` (empty `doorId` = ephemeral door: no persistence).
+
+Per-doorway configuration lives **inside the grid component** as
+`CellGridComponent::doorConfigs` (`std::map`, keyed by edge key, serialized + Lua-bound as the
+`doorConfigs` table). Entries override the grid-wide `door*` defaults for
+one doorway (`hasOverride` gates the behaviour fields: `openAngle`,
+`openSpeed`, `swingReversed`, `actionName`, `sceneSwitchPath`,
+`sceneSwitchTarget`) and add per-door flags: `label` (editor UX),
+`disabled` (spawn no door entity for this doorway), `persistent`,
+`lockable`, `lockedByDefault`, `keyItemId` (consumed by F6). Doorways
+without an entry behave exactly as before (grid defaults, ephemeral).
+Entries whose doorway no longer exists are **orphaned**: they are kept
+(never auto-deleted, serialized like live entries) until pruned or
+reassigned in the editor.
+
+Editor UX: door entities no longer appear in the scene tree
+(`EditorUISystem::renderEntityNode()` skips children with `DoorComponent`,
+like `GeneratedPhysicsTag`). Configuration happens in the Cell Grid
+editor's **Doors panel** (`ui/CellGridEditor.cpp::renderDoorEditor()`):
+grid-wide defaults, the list of all unique doorways
+(`CellGridSystem::collectDoorways()`) with badges, "Pick Door in Viewport"
+(click raycasts against the grid's door leaves via
+`ui/DoorPickState.hpp` + `EditorUISystem::onMousePressed`), a highlight of
+the selected door (leaf material swapped to `GizmoYellow` while selected),
+per-door override widgets with the full copyable global door ID, and an
+orphaned-config section with **Prune** and **Reassign...** (moves the entry
+to another doorway of the grid; warns when the target already has a config
+and when Lua-visible identity changes).
+
+Runtime lookup: `CellGridSystem::findDoorEntity(gridEntity, edgeKey)`
+(static) finds the live door entity of a doorway by matching
+`DoorComponent::edgeKey` on the grid's children.
+
+Tests: `tests/cellgrid_door_test.cpp` (target `cellgrid_door_test`, CTest
+`cellgridDoorTest`; headless — edge-key canonicalization, doorway dedup,
+uid stability, serialization round trip incl. orphans, old-scene defaults)
+and `tests/component_lua_test.cpp` test 32b (Lua get/set of `gridUid` +
+`doorConfigs`).
+
+#### Persistent door state (F6)
+
+Doors with a global ID (F0: `persistent`, `lockable` or scene-switch
+doors) keep their state in the `GlobalStateStore` (F9) under
+`door..locked` and `door..isOpen`, so it survives CellGrid
+rebuilds, scene switches and save/load (the save file's `globalState`
+section). Ephemeral doors (empty `doorId`) never touch the store.
+
+- **Defaults & restore**: `DoorBuilder::build()` calls
+ `DoorSystem::declareDoorDefaults(doorId, lockedByDefault)` (declares the
+ store defaults, returns the persisted open state) and snaps a door that
+ was left open straight to `openAngle` with its collider disabled — no
+ swing animation on load.
+- **Write path**: `DoorSystem::update()` stores `isOpen` when a swing
+ completes.
+- **Locked state**: `DoorSystem::isDoorLocked()` / `isDoorLockedById()` /
+ `setDoorLocked()`. `setDoorLocked(id, false)` emits the notifications
+ `door_unlocked_` and `door_unlocked` (params: `door_id`).
+- **Unlock events** (EventBus has no wildcards): `DoorSystem` subscribes
+ per lockable door to `door_unlock_` (refreshed each update,
+ removed with the door) plus the generic `door_unlock` (param `door_id`).
+- **Interaction**: E on a locked door shows "E Locked"; if the door has a
+ `keyItemId` and the player inventory contains the item, the door unlocks
+ (keys are NOT consumed) and the press proceeds normally; otherwise the
+ events `door_locked_` / `door_locked` (params: `door_id`,
+ `entity_id`) are emitted and nothing happens. Holding E on an unlocked
+ lockable door opens a small menu with "Close" and "Lock" (Lock requires
+ the key when `keyItemId` is set).
+- **Lua** (`lua/LuaDoorApi.cpp`, example
+ `lua-examples/door_lock_example.lua`): `ecs.door.is_locked(doorId)`,
+ `ecs.door.is_open(doorId)`, `ecs.door.lock(doorId)`,
+ `ecs.door.unlock(doorId)`; the events are reachable via
+ `ecs.subscribe_event` / `ecs.send_event`.
+- **Modes**: editor mode resets the store to defaults on startup/scene
+ load (a door always starts in its scene-defined state); game mode starts
+ a new game from defaults and restores from the save's `globalState` on
+ load (`global_state.json` is the cross-session cache, like
+ `item_state.json`).
+
+Tests: `tests/cellgrid_door_test.cpp` tests 6–9 (defaults/lock helpers,
+unlock events incl. cleanup on door removal, swing-completion persistence,
+Lua `door_locked` → `door_unlock_` round trip with `ecs.door.*`).
+The `demo-scene-switching-extra` demo scene A has a persistent locked
+door (internal doorway `Z:0:0:8`) with an inline scene script unlocking
+it on the first bump; `--headless --test-switch` verifies the whole flow
+including the state restore after the A→B→A round trip.
+
+#### Standalone doors (F2)
+
+Door entity construction is factored into `DoorBuilder`
+(`systems/DoorBuilder.*`: `DoorBuildParams` + `DoorBuilder::build()`), used
+by both `CellGridSystem` and `StandaloneDoorSystem`, so both produce the
+identical subtree (hinge node, leaf, collider child, actuator, F1 occluder,
+F6 defaults/snap). Leaf mesh creation stays with the caller.
+
+`StandaloneDoorComponent` (`components/StandaloneDoor.hpp`, serialized
+section `standaloneDoor`) builds a door without a CellGrid: the entity's
+`TransformComponent` is the doorway placement (center of the opening at
+floor level, local +X along the wall, +Z out of the room). It carries the
+full per-door config (`meshName`/`useMeshMaterial`/`rectName`, explicit
+`leafWidth`/`leafHeight`/`leafThickness`, `openAngle`/`openSpeed`/
+`swingReversed`, `actionName`, `sceneSwitchPath`/`sceneSwitchTarget`,
+`persistent`/`lockable`/`lockedByDefault`/`keyItemId`, `doorId`) plus a
+runtime `dirty` flag (not serialized; deserialization leaves it set so the
+door is built on load). `StandaloneDoorSystem`
+(`systems/StandaloneDoorSystem.*`, created next to `DoorSystem` in
+`EditorApp`) rebuilds the subtree when `dirty` is set and polls for dead
+owners (no OnRemove observer — entity IDs must not shift, see
+CellGridSystem). The procedural leaf is a box with the hinge edge at the
+origin spanning +X/+Y, skinned by the entity's own
+`ProceduralMaterialComponent` material, with `rectName` selecting the UV
+rect of the entity's `ProceduralTextureComponent` atlas. `doorId` is
+auto-generated (UUID) on first build when the door is persistent/lockable/
+a scene-switch door and stays serialized from then on; persistence and
+locking reuse the F6 `door..*` store keys. Editor: "Add Component
+-> Game -> Standalone Door" (`ui/StandaloneDoorEditor.*` sets `dirty` on
+every change and warns on duplicate `doorId`). Lua: `StandaloneDoor`
+component binding (setter sets `dirty`).
+
+Tests: `tests/component_lua_test.cpp` test 35 (Lua round trip) and
+`tests/cellgrid_door_test.cpp` test 12 (serializer round trip). Runtime
+build needs a SceneManager, so it is covered by editor/demo runs rather
+than headless tests.
+
+#### Navigation vs doors (F7)
+
+Doors interact with the navmesh in three ways, all keyed off the
+`DoorComponent`:
+
+- **Doors are not obstacles**: `NavMeshSystem::collectStaticEntities()`
+ skips `DoorComponent` entities (static-rigid-body and forced-source
+ paths, and the flecs-hierarchy walk for CellGrid/District/Lot
+ geometry), so closed doors no longer block doorways in the mesh.
+- **Doorway area cost**: doorway floors are painted with
+ `TileCacheNavMesh::EDITSCENE_AREA_DOOR` (id 1) via `rcMarkBoxArea`
+ after walkable-area erosion in `rasterizeTileLayers()`; the query
+ filter assigns it `NavMeshComponent::doorAreaCost` (default 5.0,
+ serialized, Lua-bound, NavMesh editor widget), so paths prefer
+ doorless detours but may cross doorways. Volumes come from
+ `NavMeshSystem::getDoorVolume()` — the closed-pose hinge transform
+ (parent derived transform * `DoorComponent::closedOrientation`) plus
+ the leaf box collider's extents, padded 0.3 — refreshed every frame
+ (`collectDoorVolumes()`) and applied to tiles at (re)build time.
+- **Locked doors block**: `NavMeshSystem::syncDoorObstacles()` adds a
+ DetourTileCache box obstacle (`addBoxObstacle`/`removeObstacle`, refs
+ in `NavMeshState::doorObstacles`) for every locked door
+ (`DoorSystem::isDoorLocked()`) and removes it on unlock or when the
+ door entity dies; `TileCacheNavMesh::update()` pumps the tile-cache
+ request queue each `NavMeshSystem::update()`.
+
+`PathFollowingSystem::handleDoorAhead()` auto-opens doors for NPCs: when
+the segment to the current waypoint passes within 1.2 m of a closed,
+unlocked, non-scene-switch door's centre and the character is within
+2.5 m, it sets `toggleRequested` and holds position until the leaf
+swings past 40°. NPCs never close doors behind them. Debug aid:
+`NavMeshSystem::getPolyAreaAt()` reports the area id at a position.
+
+Test: `testNavMeshDoors` in the `--run-terrain-tests` suite (builds a
+floor + wall + doorway with a real door entity headlessly; path through
+the doorway, door-area marking, lock blocks, unlock restores).
### SceneScriptComponent & SceneScriptSystem
@@ -529,11 +821,45 @@ Game-mode saves are JSON files in the OS user-data directory (see
- `characterRegistry` – full character registry state
- `runtimeEntities` – runtime-spawned entities (dropped items, etc.)
- `characterRuntimeData` – per-character component overrides
+- `globalState` – typed global variables (`GlobalStateStore`, F9)
- `luaData` – data from Lua save callbacks
On load, if the saved character is owned by a spawner, the controller target is
restored to the spawner name so the character respawns correctly.
+### GlobalStateStore (F9 global persistent storage)
+
+`GlobalStateStore` (`systems/GlobalStateStore.hpp/.cpp`) is the generic
+persistent variable storage for gameplay systems: a scene-independent
+singleton of typed variables (string name + `bool` / `int64` / `double` /
+`std::string`), shared between C++ and Lua (`ecs.global.*`, registered by
+`registerLuaGlobalStateApi()`, see `lua-examples/global_state_example.lua`).
+
+- **Defaults**: systems declare the variables they use with
+ `declareDefault()`; reading an unset variable returns the declared default
+ (or the caller's fallback). Defaults are NOT persisted - only explicitly
+ `set()` values land in the save file's `globalState` section and in the
+ `global_state.json` auto-save cache.
+- **Key naming**: dot-namespaced; each system owns a prefix. Registered
+ prefixes:
+ - `door..locked`, `door..isOpen` - persistent door state
+ (F6; `doorId` is the F0 global door ID `:`).
+- **Mode semantics**: game mode loads `global_state.json` at startup,
+ `startNewGame()` resets to defaults, `loadGame()` restores the save's
+ `globalState` (old saves without it load as defaults). Editor mode calls
+ `clearToDefaults()` at startup and on scene (re)load
+ (`EditorUISystem::loadScene`, `EditorApp::openProject`) with auto-save
+ disabled - the editor never loads or writes the cache file, so a previous
+ game session cannot leak into the edited scene.
+- **Scene switches do not touch the store** (singleton, like the other
+ registries) - state survives `switchScene()`.
+- `renamePrefix(old, new)` moves all explicit values under a key prefix
+ (used by F0 door reassignment to keep a moved door's persisted state).
+
+Tests: `tests/global_state_test.cpp` (target `global_state_test`, CTest
+`globalStateTest`; headless - C++/Lua round trips, defaults, serialization,
+`clearToDefaults`, `renamePrefix`).
+
### Scene Switching
`EditorApp::switchScene(path, opts)` (queued; executed at the top of the next
@@ -570,7 +896,8 @@ ecs.switch_scene(path, { position = ..., rotation = { w=1, x=0, y=0, z=0 } })
re-clamps for ~120 frames until the character reports a floor, covering the
window where streaming terrain colliders are not built yet.
- Persistent non-scene storages (CharacterRegistry, AnimationTreeRegistry,
- ItemRegistry, item/container state registries, Lua state) are untouched.
+ ItemRegistry, item/container state registries, GlobalStateStore, Lua state)
+ are untouched.
Additionally, despawning a character (spawner or registry driven) now syncs
its live position/rotation back to its `CharacterRegistry` record so a later
respawn is position-faithful; inventory/BT state remains entity-local and is
diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt
index 27234e7..fb7abe1 100644
--- a/src/features/editScene/CMakeLists.txt
+++ b/src/features/editScene/CMakeLists.txt
@@ -16,6 +16,7 @@ set(EDITSCENE_SOURCES
main.cpp
EditorApp.cpp
GameMode.cpp
+ ProjectConfig.cpp
systems/EditorUISystem.cpp
systems/SceneSerializer.cpp
systems/PhysicsSystem.cpp
@@ -37,6 +38,10 @@ set(EDITSCENE_SOURCES
systems/ProceduralMeshSystem.cpp
systems/CellGridSystem.cpp
systems/DoorSystem.cpp
+ systems/DoorBuilder.cpp
+ systems/StandaloneDoorSystem.cpp
+ components/StandaloneDoorModule.cpp
+ ui/StandaloneDoorEditor.cpp
systems/NormalDebugSystem.cpp
systems/RoomLayoutSystem.cpp
systems/FurnitureLibrary.cpp
@@ -46,6 +51,7 @@ set(EDITSCENE_SOURCES
systems/AnimationTreeRegistry.cpp
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
+ systems/GlobalStateStore.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
@@ -200,6 +206,8 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterClassApi.cpp
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
+ lua/LuaGlobalStateApi.cpp
+ lua/LuaDoorApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
@@ -252,10 +260,13 @@ set(EDITSCENE_HEADERS
systems/AnimationTreeRegistry.hpp
systems/ContainerStateRegistry.hpp
systems/ItemStateRegistry.hpp
+ systems/GlobalStateStore.hpp
systems/PlayerControllerSystem.hpp
systems/EditorUISystem.hpp
systems/CellGridSystem.hpp
systems/DoorSystem.hpp
+ systems/DoorBuilder.hpp
+ systems/StandaloneDoorSystem.hpp
systems/NormalDebugSystem.hpp
systems/RoomLayoutSystem.hpp
systems/FurnitureLibrary.hpp
@@ -287,7 +298,9 @@ set(EDITSCENE_HEADERS
systems/GoapPlannerSystem.hpp
components/Actuator.hpp
components/Door.hpp
+ components/StandaloneDoor.hpp
ui/ActuatorEditor.hpp
+ ui/StandaloneDoorEditor.hpp
systems/EventBus.hpp
components/EventHandler.hpp
systems/EventHandlerSystem.hpp
@@ -392,6 +405,8 @@ set(EDITSCENE_HEADERS
lua/LuaCharacterClassApi.hpp
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
+ lua/LuaGlobalStateApi.hpp
+ lua/LuaDoorApi.hpp
lua/LuaTerrainApi.hpp
lua/LuaSceneSwitchApi.hpp
)
@@ -712,6 +727,87 @@ target_include_directories(scene_switch_test PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
+# ---------------------------------------------------------------------------
+# Test: CellGrid door identity + per-door config (F0, headless)
+# ---------------------------------------------------------------------------
+# Links the full editScene sources (minus main.cpp) like scene_switch_test,
+# but runs without OGRE initialization: doorway identity is pure grid math
+# and the SceneSerializer round trip needs no SceneManager for the CellGrid
+# component.
+set(CELLGRID_DOOR_TEST_SOURCES ${EDITSCENE_SOURCES})
+list(REMOVE_ITEM CELLGRID_DOOR_TEST_SOURCES main.cpp)
+
+add_executable(cellgrid_door_test
+ tests/cellgrid_door_test.cpp
+ ${CELLGRID_DOOR_TEST_SOURCES}
+)
+
+add_dependencies(cellgrid_door_test morph)
+
+target_compile_definitions(cellgrid_door_test PRIVATE JPH_DEBUG_RENDERER)
+
+target_link_libraries(cellgrid_door_test
+ OgreMain
+ OgreBites
+ OgreOverlay
+ OgreMeshLodGenerator
+ OgrePaging
+ OgreTerrain
+ flecs::flecs_static
+ nlohmann_json::nlohmann_json
+ Jolt::Jolt
+ OgreProcedural::OgreProcedural
+ RecastNavigation::Recast
+ RecastNavigation::Detour
+ RecastNavigation::DetourTileCache
+ RecastNavigation::DetourCrowd
+ RecastNavigation::DebugUtils
+ PackageArchive
+ RoadGeometryLib
+ lua
+ SDL2::SDL2
+)
+
+target_include_directories(cellgrid_door_test PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Recast/Include
+ ${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Detour/Include
+ ${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
+ ${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
+ ${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
+ ${CMAKE_SOURCE_DIR}/src/FastNoiseLite
+ ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
+ ${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
+)
+
+add_test(NAME cellgridDoorTest
+ COMMAND cellgrid_door_test
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+
+# ---------------------------------------------------------------------------
+# Test: Global persistent variable storage (F9, headless)
+# ---------------------------------------------------------------------------
+# Links only the store and its Lua API: no OGRE, no flecs.
+add_executable(global_state_test
+ tests/global_state_test.cpp
+ systems/GlobalStateStore.cpp
+ lua/LuaGlobalStateApi.cpp
+)
+
+target_link_libraries(global_state_test
+ lua
+ nlohmann_json::nlohmann_json
+)
+
+target_include_directories(global_state_test PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
+)
+
+add_test(NAME globalStateTest
+ COMMAND global_state_test
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
+
# ---------------------------------------------------------------------------
# Road Geometry Library — standalone wedge/segment generation (M5)
# ---------------------------------------------------------------------------
diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp
index cd2fedd..c45d469 100644
--- a/src/features/editScene/EditorApp.cpp
+++ b/src/features/editScene/EditorApp.cpp
@@ -46,11 +46,14 @@
#include "systems/AnimationTreeRegistry.hpp"
#include "systems/ContainerStateRegistry.hpp"
#include "systems/ItemStateRegistry.hpp"
+#include "systems/GlobalStateStore.hpp"
#include "systems/CharacterClassSystem.hpp"
#include "systems/PregnancySystem.hpp"
#include "components/CharacterClassDatabase.hpp"
#include "lua/LuaCharacterApi.hpp"
#include "lua/LuaSaveLoadApi.hpp"
+#include "lua/LuaDoorApi.hpp"
+#include "lua/LuaGlobalStateApi.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/SceneSerializer.hpp"
@@ -105,6 +108,7 @@
#include "components/PathFollowing.hpp"
#include "systems/ActuatorSystem.hpp"
#include "systems/DoorSystem.hpp"
+#include "systems/StandaloneDoorSystem.hpp"
#include "systems/EventHandlerSystem.hpp"
#include "systems/EventBus.hpp"
#include "systems/SceneScriptSystem.hpp"
@@ -243,8 +247,8 @@ void ImGuiRenderListener::postViewportUpdate(
// EditorApp Implementation
//=============================================================================
-EditorApp::EditorApp()
- : OgreBites::ApplicationContext("EditSceneEditor")
+EditorApp::EditorApp(const Ogre::String &appName)
+ : OgreBites::ApplicationContext(appName)
, m_sceneMgr(nullptr)
, m_overlaySystem(nullptr)
, m_imguiOverlay(nullptr)
@@ -393,6 +397,7 @@ void EditorApp::destroyEditorSystems()
m_eventHandlerSystem.reset();
m_actuatorSystem.reset();
m_doorSystem.reset();
+ m_standaloneDoorSystem.reset();
m_goapPlannerSystem.reset();
m_pathFollowingSystem.reset();
m_goapRunnerSystem.reset();
@@ -709,6 +714,11 @@ void EditorApp::setup()
// Setup Door system (swing animation for cell grid doors)
m_doorSystem = std::make_unique(m_world);
+ m_doorSystem->setEditorApp(this);
+
+ // Standalone doors (F2, same DoorBuilder as CellGrid doors)
+ m_standaloneDoorSystem =
+ std::make_unique(m_world, m_sceneMgr);
// Wire CellGridSystem into NavMeshSystem so it can collect
// batched frame/furniture geometry from StaticGeometry.
@@ -743,6 +753,18 @@ void EditorApp::setup()
ContainerStateRegistry::getInstance().loadFromFile(
"container_state.json");
+ /* F9: the global state store is the cross-session cache in
+ * game mode; editor mode always starts from system defaults
+ * and must not leak a previous game session's state (nor
+ * overwrite the cache file), so auto-save is off there. */
+ if (m_gameMode == GameMode::Game) {
+ GlobalStateStore::getInstance().loadFromFile(
+ "global_state.json");
+ } else {
+ GlobalStateStore::getInstance().setAutoSaveEnabled(false);
+ GlobalStateStore::getInstance().clearToDefaults();
+ }
+
m_characterClassSystem =
std::make_unique(m_world, this);
m_pregnancySystem = std::make_unique(m_world);
@@ -757,20 +779,29 @@ void EditorApp::setup()
bool startupMenuLoaded = false;
if (m_gameMode == GameMode::Game) {
+ /* A game project (project.json with gameMode +
+ * startScene) skips the startup menu and goes straight
+ * to its start scene, like a release binary. */
+ bool directStart = m_projectConfig.gameMode &&
+ !m_projectConfig.startScene.empty();
+
// Load startup menu scene configured in editor.
// This must happen before show() so the
// StartupMenuComponent entity exists for font preparation.
SceneSerializer serializer(m_world, m_sceneMgr);
- Ogre::LogManager::getSingleton().logMessage(
- "Game mode: Loading startup_menu.json...");
- if (serializer.loadFromFile("startup_menu.json",
+ if (!directStart) {
+ Ogre::LogManager::getSingleton().logMessage(
+ "Game mode: Loading startup_menu.json...");
+ }
+ if (!directStart &&
+ serializer.loadFromFile("startup_menu.json",
m_uiSystem.get())) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
prefabSys.resolveInstances();
startupMenuLoaded = true;
Ogre::LogManager::getSingleton().logMessage(
"Game mode: startup_menu.json loaded");
- } else {
+ } else if (!directStart) {
Ogre::LogManager::getSingleton().logMessage(
"Game mode: Failed to load startup_menu.json: " +
serializer.getLastError());
@@ -839,6 +870,8 @@ void EditorApp::setup()
editScene::registerLuaItemApi(L);
editScene::registerLuaTerrainApi(L);
editScene::registerLuaSceneSwitchApi(L);
+ editScene::registerLuaGlobalStateApi(L);
+ editScene::registerLuaDoorApi(L);
editScene::setSceneSwitchEditorApp(this);
// Scene scripts execute in this shared Lua state.
@@ -940,6 +973,49 @@ void EditorApp::setHeadless(bool headless)
m_headless = headless;
}
+void EditorApp::setProjectConfig(const ProjectConfig &config)
+{
+ m_projectConfig = config;
+ if (!config.appName.empty())
+ SaveLoadSystem::setAppName(config.appName);
+}
+
+bool EditorApp::openProject(const std::string &dir)
+{
+ std::error_code ec;
+ std::filesystem::path abs =
+ std::filesystem::absolute(std::filesystem::path(dir), ec);
+ if (ec || !std::filesystem::is_directory(abs)) {
+ Ogre::LogManager::getSingleton().logMessage(
+ "openProject: not a directory: " + dir);
+ return false;
+ }
+ std::filesystem::current_path(abs, ec);
+ if (ec) {
+ Ogre::LogManager::getSingleton().logMessage(
+ "openProject: cannot enter directory: " + dir);
+ return false;
+ }
+
+ setProjectConfig(loadProjectConfig(abs.string()));
+ Ogre::LogManager::getSingleton().logMessage(
+ "openProject: project root is now " + abs.string());
+
+ /* Best-effort window title update to the project app name. */
+ if (!m_headless && !mWindows.empty() && mWindows[0].native) {
+ SDL_SetWindowTitle((SDL_Window *)mWindows[0].native,
+ m_projectConfig.appName.c_str());
+ }
+
+ /* Drop the current scene: relative scene/prefab paths now resolve
+ * against the new project root. */
+ clearScene();
+ /* F9: opening a project in the editor resets the global state to
+ * system defaults (editor mode never carries game state). */
+ GlobalStateStore::getInstance().clearToDefaults();
+ return true;
+}
+
void EditorApp::setGamePlayState(GamePlayState state)
{
m_gamePlayState = state;
@@ -990,6 +1066,9 @@ void EditorApp::clearScene()
void EditorApp::startNewGame(const Ogre::String &scenePath)
{
clearScene();
+ /* F9: a new game starts from system defaults, not from the
+ * cross-session cache. */
+ GlobalStateStore::getInstance().clearToDefaults();
SceneSerializer serializer(m_world, m_sceneMgr);
if (serializer.loadFromFile(scenePath, m_uiSystem.get())) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
@@ -1487,6 +1566,7 @@ void EditorApp::saveGame(const std::string &slotPath,
saveData["containerState"] =
ContainerStateRegistry::getInstance().serialize();
saveData["itemState"] = ItemStateRegistry::getInstance().serialize();
+ saveData["globalState"] = GlobalStateStore::getInstance().serialize();
/* Runtime entities — skip characters and player controller */
saveData["runtimeEntities"] = nlohmann::json::array();
@@ -1578,6 +1658,13 @@ void EditorApp::loadGame(const std::string &slotPath)
if (saveData.contains("itemState"))
ItemStateRegistry::getInstance().deserialize(
saveData["itemState"]);
+ /* Old saves have no globalState section: fall back to system
+ * defaults (clearToDefaults) instead of keeping stale values. */
+ if (saveData.contains("globalState"))
+ GlobalStateStore::getInstance().deserialize(
+ saveData["globalState"]);
+ else
+ GlobalStateStore::getInstance().clearToDefaults();
/* Destroy ALL spawned characters so the registry spawn is the
* only source of characters. */
@@ -2160,6 +2247,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_doorSystem->update(evt.timeSinceLastFrame);
}
+ /* --- Standalone doors (rebuild dirty door entities) --- */
+ if (m_standaloneDoorSystem) {
+ m_standaloneDoorSystem->update(evt.timeSinceLastFrame);
+ }
+
/* --- Event Handler system (event-driven BTs) --- */
if (m_eventHandlerSystem) {
m_eventHandlerSystem->update(evt.timeSinceLastFrame);
diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp
index 4002397..a5d2db6 100644
--- a/src/features/editScene/EditorApp.hpp
+++ b/src/features/editScene/EditorApp.hpp
@@ -10,6 +10,7 @@
#include
#include
#include "lua/LuaState.hpp"
+#include "ProjectConfig.hpp"
// Forward declarations
class EditorUISystem;
@@ -49,6 +50,7 @@ class PathFollowingSystem;
class GoapPlannerSystem;
class ActuatorSystem;
class DoorSystem;
+class StandaloneDoorSystem;
class EventHandlerSystem;
class ItemSystem;
class CharacterClassSystem;
@@ -153,7 +155,7 @@ public:
enum class GameMode { Editor, Game };
enum class GamePlayState { Menu, Playing, Paused };
- EditorApp();
+ EditorApp(const Ogre::String &appName = "EditSceneEditor");
virtual ~EditorApp();
// OgreBites::ApplicationContext overrides
@@ -250,6 +252,24 @@ public:
void saveGame(const std::string &slotPath, const std::string &slotName);
void loadGame(const std::string &slotPath);
+ // Project directory (F8)
+ void setProjectConfig(const ProjectConfig &config);
+ const ProjectConfig &getProjectConfig() const
+ {
+ return m_projectConfig;
+ }
+ const std::string &getProjectRoot() const
+ {
+ return m_projectConfig.rootDir;
+ }
+ /**
+ * Open a project directory at runtime (editor File -> Open
+ * Project...): chdir()s into it, loads its project.json, switches
+ * the save directory to the project's appName and clears the
+ * current scene. Returns false when the directory does not exist.
+ */
+ bool openProject(const std::string &dir);
+
// Input access
GameInputState &getGameInputState()
{
@@ -330,6 +350,18 @@ public:
{
return m_pregnancySystem.get();
}
+ NavMeshSystem *getNavMeshSystem() const
+ {
+ return m_navMeshSystem.get();
+ }
+ CellGridSystem *getCellGridSystem() const
+ {
+ return m_cellGridSystem.get();
+ }
+ DoorSystem *getDoorSystem() const
+ {
+ return m_doorSystem.get();
+ }
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -380,6 +412,7 @@ private:
std::unique_ptr m_goapPlannerSystem;
std::unique_ptr m_actuatorSystem;
std::unique_ptr m_doorSystem;
+ std::unique_ptr m_standaloneDoorSystem;
std::unique_ptr m_eventHandlerSystem;
std::unique_ptr m_itemSystem;
std::unique_ptr m_characterClassSystem;
@@ -399,6 +432,10 @@ private:
bool m_debugBuoyancy = false;
bool m_headless = false;
+ /* Project directory (F8): empty rootDir = plain session in the
+ * binary directory. */
+ ProjectConfig m_projectConfig;
+
void destroyEditorSystems();
float m_playTime = 0.0f;
std::string m_currentBaseScene;
diff --git a/src/features/editScene/GameFeatures202609.md b/src/features/editScene/GameFeatures202609.md
new file mode 100644
index 0000000..f5842b1
--- /dev/null
+++ b/src/features/editScene/GameFeatures202609.md
@@ -0,0 +1,1043 @@
+# Game Features — September 2026 Specification
+
+Test bed: the demo at `src/features/editScene/demos/demo-scene-switching-extra`
+(target `demoSceneSwitchingExtra`) is the primary verification target for all
+features in this document.
+
+Original draft (verbatim, preserved as the requirement source; numbering kept,
+"reverse priority order" means item 8 is handled first):
+
+> 1. If the door is set to switch scenes, the switch should happen when the door completely open. However the door in this case should show black cube behind it when open, to avoid wrong visual representation.
+> 2. Need standalone door, with the same features like Cell Grid door.
+> 3. It should be possible to switch door swing angle sign with simple checkbox, as it is better for some doors to open in different direction.
+> 4. Need to make Cell Grid to be able to be in interior-only mode (no need to generate external walls).
+> 5. Need to make Cell Grid to be able to be in exterior-only mode (only exterior elements are generated, exit doors are generated, no furniture, windows has blue rectangles as glass inside window frames, with reflective glossy material to simulate glass).
+> 6. Need some doors to have unique ID in global storage. It is needed to implement locked and open/closed state to be persistent. Make sure that already implemented global storage and it is testable and verifiable.
+> - Implement persistent locked state. It should be optionally integrated into Lua system via events (attempt to open locked door should emit unique event for the door, then the handler should emit event to accept or reject). That should be supported by door's unique ID and event name should be predictable by the user. The documentation, examples and tests should be added.
+> - Implement item of type 'key' support. This item's id should be possible for a door to be unlocked first, then the door could be open. Also it should be possible to lock open door.
+> - Implement persistent open/closed state. It should survive scene switching and stored in the save and loaded.
+> - in editor mode, the persistent storage should be reset to default state and game mode should rely on save files for the storage. Please check that operation is sane and there should be tests from it.
+> 7. Navigation should ignore doors as obstacles, however NPCs should be able to pass through open doors by openning them on the path to their GOAP goal without additional behavior tree writing, Door area should be costly to pass with Detour/Recast area cost. Locked doors should implement Detour obstacles.
+> 8. Need for editor to support a concept of project directory. The main thing is to have single editor binary, which can open demo's directory as if it is editor current directory where it loads prefabs, scenes, searches for paths to be relative to that directory.
+
+## Current-state survey (relevant subsystems)
+
+Verified against the source on 2026-09-07; the code is the source of truth.
+
+### Doors (`DoorSystem`, `DoorComponent`, `CellGridSystem`)
+
+- Doors exist **only** as runtime children of a CellGrid entity
+ (`CellGridSystem::buildDoorEntities()`, `systems/CellGridSystem.cpp:4632`).
+ One door entity per unique doorway, deduplicated on a canonical cell-edge
+ key (`"X:x:y:z"` / `"Z:x:y:z"`, `CellGridSystem.cpp:4708-4728`).
+- Each door entity carries `TransformComponent` (node = hinge),
+ `RenderableComponent` (leaf), `DoorComponent`, static
+ `RigidBodyComponent` + child box `PhysicsColliderComponent`, and
+ `ActuatorComponent` (`CellGridSystem.cpp:4844-4889`).
+- `DoorComponent` (`components/Door.hpp`) is **runtime only, not
+ serialized**; all configuration (`openAngle`, `openSpeed`,
+ `sceneSwitchPath`, `sceneSwitchTarget`) is copied from
+ `CellGridComponent` at build time.
+- `DoorSystem::update()` (`systems/DoorSystem.cpp`) swings the hinge around
+ local +Y towards `openAngle` (positive sign only), disables the rigid body
+ immediately when opening starts, re-enables it when fully closed. There is
+ no "fully open" callback/event today.
+- Scene-switch doors: `ActuatorSystem.cpp:517-523` queues
+ `EditorApp::switchScene()` **immediately on E**; the leaf never swings.
+ Nothing is rendered behind the open doorway.
+- **Door configuration is grid-wide only**: `doorSceneSwitchPath` and all
+ other door settings live on `CellGridComponent` and apply to *every* door
+ of the grid (`ui/CellGridEditor.cpp:59-110`). There is no way to make one
+ specific door a scene-switch door — this is why configuring "the door" to
+ switch scenes is impossible today.
+- **Door entities clutter the editor scene tree**: the tree shows all
+ children of a grid entity except `GeneratedPhysicsTag` ones
+ (`EditorUISystem.cpp:792-796`), and door entities are children of the
+ grid with `[T][R]` indicators. They are selectable but expose no editable
+ config (`DoorComponent` is runtime-only), so they are pure noise.
+- CellGrid door config fields (serialized, Lua-bound): `doorsEnabled`,
+ `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`, `doorOpenAngle`,
+ `doorOpenSpeed`, `doorActionName`, `doorSceneSwitchPath`,
+ `doorSceneSwitchTarget` (`components/CellGrid.hpp:148-162`,
+ `ui/CellGridEditor.cpp`).
+
+### CellGrid generation
+
+- Entry point `CellGridSystem::buildCellGrid()`; part builders:
+ `buildFloorsAndCeilings`, `buildWalls`, `buildDoors` (frames),
+ `buildWindows`, `buildCorners`, `buildInternalCorners`, `buildFurniture`,
+ `buildFrames`, `placeDoorFramesInStaticGeometry`,
+ `placeWindowFramesInStaticGeometry` (`systems/CellGridSystem.hpp:98-191`).
+- There is **no** interior/exterior generation-mode concept and **no glass**
+ anywhere in CellGrid today (no `glass` matches in `CellGridSystem.cpp`).
+- Windows are openings + frame meshes; `RoomComponent::createWindows`
+ converts exterior-facing walls to windows.
+
+### Persistent state / registries
+
+- **No generic "global storage" exists.** The claim in draft item 6
+ ("already implemented global storage") does not hold — the closest things
+ are per-domain singleton registries, and the new door storage should
+ follow their pattern:
+ - `ItemStateRegistry` (`systems/ItemStateRegistry.hpp`) — per-instance
+ `disabled` flag keyed by `instanceId`, `serialize()`/`deserialize()`,
+ `autoSave()` to `item_state.json` (CWD-relative). Loaded at startup
+ (`EditorApp.cpp:741`) in **both** modes; restored from the save file in
+ `EditorApp::loadGame()` (`EditorApp.cpp:1578-1580`).
+ - `ContainerStateRegistry` — same pattern (`container_state.json`).
+ - `CharacterRegistry`, `ItemRegistry` (item **definitions**, incl.
+ `ItemDefinition::itemType` string — a `'key'` type fits here without
+ schema changes), `AnimationTreeRegistry`.
+- Singleton registries survive `EditorApp::switchScene()` untouched (scene
+ switch only destroys scene entities).
+- Save format (`EditorApp::saveGame()`, `EditorApp.cpp:1443`): `saveGame`
+ block, `characterRegistry`, `containerState`, `itemState`,
+ `runtimeEntities`, `characterRuntimeData`, `luaData`. A new top-level
+ `globalState` section plugs in next to `itemState` (see F9).
+- `docs/SaveLoadSystem.md` documents the format and must be extended.
+
+### Events / Lua
+
+- `EventBus` (`systems/EventBus.hpp`): synchronous, named events with
+ `editScene::EventParams` payloads (entity ids, ints, floats, doubles,
+ strings, arrays).
+- Lua API (`lua/LuaEventApi.cpp`): `ecs.subscribe_event(name, fn) -> id`,
+ `ecs.unsubscribe_event(id)`, `ecs.send_event(name [, params_table])`.
+ Entity ids in params are mapped into the `ecs.*` id space.
+- `SceneScriptSystem` runs scene/prefab scripts once and sends
+ `scene_loaded` / `prefab_loaded` through the same bus. Examples live in
+ `lua-examples/`.
+
+### Navigation (`NavMeshSystem`, `TileCacheNavMesh`, `PathFollowingSystem`)
+
+- Navmesh = DetourTileCache via `recast/TileCacheNavMesh.*`.
+ `NavMeshSystem::collectStaticEntities()` bakes **all** entities with a
+ static `RigidBodyComponent` (plus StaticGeometry members and
+ `NavMeshGeometrySource`) into the mesh (`NavMeshSystem.cpp:294-330`) — so
+ **closed door leaves are currently baked in as obstacles**, and the door
+ hinge rotation likely re-dirties tiles via transform-version tracking.
+- Area support: exactly one area (`SAMPLE_POLYAREA_GROUND`, cost 1.0,
+ `TileCacheNavMesh.cpp:106-278`). No `rcMarkBoxArea` usage, no convex
+ volumes, no custom `dtQueryFilter` costs.
+- `dtTileCache::addObstacle`/`removeObstacle` exist in the vendored Detour
+ but are **unused** by `TileCacheNavMesh` (no obstacle API exposed, tile
+ cache update pumping for obstacle requests must be verified).
+- `PathFollowingSystem` walks waypoint lists from `NavMeshSystem::findPath`;
+ it has no door awareness.
+
+### Paths / project directory
+
+- Everything is **CWD-relative**: `resources.cfg`, `items.json`,
+ `item_state.json`, `container_state.json`, `animation_tree.json`,
+ `character_registry.json`, scene/prefab paths, `heightmaps//`.
+ No `chdir`, no configurable base path anywhere (`EditorApp` has no
+ resources.cfg handling — OGRE Bites picks it up from CWD).
+- Each demo is a **separate binary** (`demo_main.cpp` + all editScene
+ sources) with a POST_BUILD `stage_runtime.cmake` that copies
+ `resources.cfg` + config JSONs and symlinks `resources/`, `characters/`,
+ `lua-scripts/` into the demo's own build directory (see
+ `demos/demo-scene-switching-extra/CMakeLists.txt` and
+ `stage_runtime.cmake`). Feature 8 should make most of this staging
+ unnecessary.
+- CLI args today (`main.cpp:53-79`): `--game`, `--debug-buoyancy`,
+ `--exit-after-first-frame`, `--run-terrain-tests=N`, `--headless`, plus a
+ positional scene path.
+
+---
+
+## F8 — Project directory (highest priority, do first)
+
+**Status: implemented (2026-09-07).** `--project`/`--project=`/`--editor` CLI
+args, `ProjectConfig` + `project.json` (`appName`/`startScene`/`gameMode`),
+chdir-before-init, per-project save dir (`SaveLoadSystem::setAppName`),
+File -> Open Project..., game-mode direct start scene, and the embedded
+`project.h` release-binary mechanism for `demoSceneSwitchingExtra`
+(CMake `string(JSON)` + `configure_file`, `CMAKE_CONFIGURE_DEPENDS`).
+Verified: headless smoke runs exit 0 for editor-without-project,
+`--project` editor mode, `--project --game` (auto-starts the project start
+scene), and the embedded release binary incl. `--test-switch` PASS.
+
+**Goal:** one `editSceneEditor` binary can open any project directory (e.g. a
+demo directory) and resolve all relative paths against it.
+
+### Design
+
+- New CLI argument: `--project `. Also **File -> Open Project...** in
+ the editor UI (folder picker sets the same state and reloads).
+- Implementation: resolve the project root **early in `main()`**, before OGRE
+ initialization. Preferred mechanism: `chdir(projectDir)` (or platform
+ equivalent) so every existing CWD-relative path (`resources.cfg`, config
+ JSONs, scene/prefab paths, `heightmaps/`, saves) keeps working unchanged.
+ Engine-level files that belong to the binary, not the project
+ (`plugins.cfg`, OGRE SDK resources), must be resolved against the
+ executable directory **before** the chdir.
+- `EditorApp` exposes `getProjectRoot()` for UI display and for any code
+ that must build absolute paths.
+- **Per-project identity:** the project defines its metadata in
+ `project.json` in the project root:
+
+ ```json
+ {
+ "appName": "Demo Scene Switching Extra",
+ "startScene": "demo_scene_a.json",
+ "gameMode": true
+ }
+ ```
+
+ `appName` (falls back to the project directory name) drives:
+ - the OS user-data save directory: `//` instead of a
+ single shared one — saves are individual per project;
+ - the render window title.
+ `startScene` is the scene loaded by game mode; `gameMode` says the project
+ is a playable game rather than a plain scene collection.
+- **Two binary flavours, one codebase:**
+ - **Editor binary** (`editSceneEditor`) stays fully generic: it opens any
+ project via `--project ` / **File -> Open Project...** and reads
+ everything from `project.json` at runtime.
+ - **Demo/release binaries** (the existing per-demo executables such as
+ `demoSceneSwitchingExtra`) are kept and become **project-bound release
+ builds**: at build time CMake reads the demo's `project.json` and
+ generates a `project.h` (via `configure_file` or a small generator
+ script) embedding the parameters (`appName`, `startScene`,
+ `gameMode`, default project root) as compile-time constants. The
+ release binary therefore needs no `--project` flag — it is attached to
+ its project directory by construction, starts directly in game mode,
+ and has **no editor functionality** (editor UI/systems compiled out or
+ disabled via the same generated header).
+ - Release binaries are the distribution artefact: ship the executable
+ plus its project directory. The generic editor binary remains the
+ authoring tool. Future advancements (installers, packaging) build on
+ this split.
+- Demo migration: `demos/demo-scene-switching-extra` (and siblings) gain a
+ `project.json`; their CMake targets switch to the generated-`project.h`
+ mechanism. Each demo stays runnable both ways during migration:
+ `./demoSceneSwitchingExtra` (embedded project) and
+ `editSceneEditor --game --project ` (generic binary). The
+ `stage_runtime.cmake` staging keeps assembling the project directory
+ content (scenes, prefabs, project-local config, resources symlinks) —
+ that directory *is* the project.
+
+### Decisions
+
+- Save files stay in the OS user-data dir, but **individual per project**:
+ `//`, where `appName` comes from the project (see
+ above). The window title also uses the per-project app name.
+- Per-demo executables are **kept** alongside the editor binary and become
+ the per-project **release binaries**: game-mode only (no editor
+ functionality), with project parameters embedded at build time from
+ `project.json` via a generated `project.h`. The generic editor binary
+ opens the same demo project directories via `--project`.
+- Recent-projects list in the editor: optional, not required for the first
+ iteration.
+
+### Acceptance criteria
+
+- `editSceneEditor --project build/.../demo-scene-switching-extra --game`
+ behaves identically to running the `demoSceneSwitchingExtra` binary.
+- The release binary runs its project with no flags: correct window title,
+ start scene from the embedded `project.json`, saves land in
+ `//`, and no editor UI/menus are reachable.
+- Editor mode with `--project` loads/saves scenes and prefabs relative to
+ the project dir; no files leak into the binary dir.
+- Without `--project`, the editor binary behaves exactly as today.
+- Changing `project.json` and rebuilding the demo target updates the
+ embedded values (CMake dependency on `project.json`).
+
+### Tests
+
+- Headless run: `--headless --project --exit-after-first-frame`.
+- Headless release-binary run: `./demoSceneSwitchingExtra --headless
+ --exit-after-first-frame` proves the embedded project resolves without
+ `--project`.
+- CTest entries mirroring the demo smoke runs for both flavours.
+
+---
+
+## F0 — Door identity, per-door configuration & editor UX
+
+**Status: implemented (2026-09-07).** `CellGridComponent::gridUid` (lazy
+`ensureGridUid()`), serialized `doorConfigs` map keyed by canonical edge
+key (`CellGridSystem::doorEdgeKey`, static), `DoorComponent::edgeKey` +
+`doorId` (`:` assigned when `persistent || lockable ||
+sceneSwitchPath` set), per-door overrides and `disabled` skip in
+`buildDoorEntities()`, serialization round trip (old scenes tolerated, uid
+generated on load/save), Lua bindings for `gridUid` + `doorConfigs`, door
+entities hidden from the editor tree, the Doors panel rewrite in
+`ui/CellGridEditor.cpp` (doorway list with badges, pick-in-viewport via
+`ui/DoorPickState.hpp` + `EditorUISystem::onMousePressed`, leaf highlight
+via `GizmoYellow` material swap, per-door override widgets, copyable global
+door ID, orphan Prune/Reassign incl. `GlobalStateStore::renamePrefix`
+state migration). Tests: `cellgrid_door_test` (CTest
+`cellgridDoorTest`, 5/5) + `component_lua_test` test 32b. Editor-only parts
+(pick, highlight, panel) verified by build/code review; interactive
+verification pending a manual editor run.
+
+*Not from the original draft — identified during review: every other door
+feature (F1/F3/F6/F7) needs to target ONE specific door, which is impossible
+today. Implement before all other door features.*
+
+### Problem statement (verified in code)
+
+- All door settings are grid-wide (`CellGridComponent::door*`), so a single
+ door cannot be configured (e.g. made a scene-switch door) without
+ affecting every door of the grid.
+- Door runtime entities clutter the editor scene tree and are selectable
+ but not editable.
+- Doors have no identity: they are rebuilt from scratch on every grid
+ rebuild, so nothing per-door can survive even a scene reload, let alone
+ a save.
+
+### Design
+
+#### Door identity
+
+- A doorway's identity is the **canonical edge key** already computed in
+ `buildDoorEntities()` (`X:x:y:z` / `Z:x:y:z`, `CellGridSystem.cpp:4708-4728`).
+ It is stable across CellGrid rebuilds and scene loads as long as the door
+ cells are not moved — moving/deleting the doorway orphans its config,
+ which is acceptable and documented (the editor shows orphaned entries and
+ offers "Prune" and "Reassign", see below).
+- **Orphaned configs are kept, never auto-deleted**: clearing a door cell
+ flag and re-adding it later (a common editing pattern) must not lose the
+ door's configuration. Orphaned entries are serialized in `doorConfigs`
+ like live ones and survive scene saves until explicitly pruned or
+ reassigned.
+- **Manual reassignment:** an orphaned entry in the Cell Grid editor's
+ Doors panel offers "Reassign...", which moves the whole config entry to
+ another doorway of the same grid:
+ - the target doorway is chosen from a dropdown of the grid's current
+ doorways (edge key + label) or by pick-in-viewport;
+ - targets that already have a config entry are flagged; choosing one
+ asks for confirmation (the target's old entry is overwritten);
+ - the door's global ID changes to `:` — this is a
+ deliberate identity move, not a copy (the orphan entry is removed);
+ - **persisted state follows the config** (F6): all global-storage keys
+ with the prefix `door..` (locked/open state) are renamed to
+ `door..` via `GlobalStateStore::renamePrefix()` at reassignment
+ time, so a moved door keeps its locked/open state. Old save files still
+ reference the old ID and simply find no matching door — acceptable,
+ documented;
+ - the reassignment dialog warns that Lua scripts referencing the door by
+ its old global ID must be updated (shown when the entry is
+ `lockable`/`persistent` or has a `sceneSwitchPath`).
+- `CellGridComponent` gains a serialized `gridUid` (UUID string generated
+ when the component is created) so door IDs survive grid entity renames.
+- **Global door ID** = `:`. This is the key used in the
+ F9 global storage (`door..*` keys) and by Lua events
+ (predictable: the editor shows the full ID for the selected door,
+ copyable to clipboard).
+
+#### Per-door configuration (no disconnected data)
+
+- `CellGridComponent` gains a serialized map:
+ `std::map doorConfigs` keyed by edge
+ key. `CellGridDoorConfig` holds per-door **overrides** of the grid
+ defaults plus the new per-door features:
+
+ ```cpp
+ struct CellGridDoorConfig {
+ bool hasOverride = false; // entry exists but uses grid defaults when false
+ // identity / UX
+ std::string label; // user-visible name ("Kitchen door")
+ // behaviour overrides
+ float openAngle, openSpeed; // defaults copied from grid on first override
+ bool swingReversed = false; // F3
+ std::string actionName;
+ std::string sceneSwitchPath, sceneSwitchTarget; // F1
+ // persistence / locking (F6)
+ bool persistent = false; // opt-in: track state in the F9 global storage
+ bool lockable = false;
+ bool lockedByDefault = false;
+ std::string keyItemId;
+ };
+ ```
+
+- Doors absent from the map behave exactly as today (grid defaults,
+ ephemeral). No door entities appear in the scene JSON; the config lives
+ inside the grid component it belongs to — nothing disconnected.
+- Alternatives considered and rejected: (a) real serialized child entities
+ per door — clutters the scene and fights the runtime-rebuild model;
+ (b) a separate per-scene door-config file keyed by grid name — exactly
+ the "disconnected data" failure mode to avoid (breaks on renames, easy
+ to lose).
+- `buildDoorEntities()` consults the map per doorway: applies overrides,
+ sets `DoorComponent::doorId = gridUid + ":" + edgeKey` when
+ `persistent || lockable || sceneSwitchPath` is set (otherwise leaves it
+ empty = ephemeral), and skips spawning entirely for a doorway whose
+ config sets a `disabled` flag (per-door "no leaf here" escape hatch).
+
+#### Editor UX
+
+- **Declutter the tree:** `EditorUISystem::renderEntityNode()` skips
+ children with `DoorComponent` (same treatment as `GeneratedPhysicsTag`).
+ Doors stop being selectable tree entries; they are configured through
+ their owning grid.
+- **Doors panel in the Cell Grid editor** (`ui/CellGridEditor.cpp`,
+ extending the existing "Doors" header):
+ - A list of all unique doorways of the grid: edge key + label +
+ badges (`[switch]` `[locked]` `[persistent]`).
+ - "Pick in viewport" mode: the next click on a door leaf raycasts to the
+ door entity and selects its doorway in the list (doors are raycastable
+ via their RenderableComponent).
+ - Selecting a row highlights the door in the viewport (debug wire box or
+ leaf tint through `DoorSystem`) so it is always obvious **which** door
+ is being configured.
+ - Per-door editing widgets for the fields above; "Reset to grid
+ defaults" removes the override entry.
+ - The full global door ID is displayed with a copy button.
+ - Orphaned entries (config for an edge key that no longer exists) are
+ listed at the bottom, each with "Reassign..." (move to another doorway,
+ see "Door identity") and "Prune" (delete) buttons.
+
+### Acceptance criteria
+
+- In the editor, two doors of the same grid can be configured differently
+ (e.g. one scene-switch exit door, one normal internal door).
+- Door entities no longer appear in the scene tree.
+- The configured door is visually highlighted while editing; its global ID
+ is shown and survives scene save/load and grid rebuilds unchanged.
+- Scene JSON contains the per-door config inside the grid component; old
+ scenes without `doorConfigs`/`gridUid` load unchanged (uid generated on
+ first save).
+
+### Tests
+
+- `component_lua_test.cpp`: serialization round trip of `doorConfigs` +
+ `gridUid`; Lua get/set of the new fields.
+- Headless: rebuild a grid twice, assert the same door entity gets the same
+ `doorId`; orphan detection after removing a door flag from the cells;
+ reassignment moves the config entry and renames the door's
+ `door..*` global-storage keys to the new global ID.
+
+---
+
+## F9 — Global persistent variable storage ("global storage")
+
+**Status: implemented (2026-09-07).** `GlobalStateStore`
+(`systems/GlobalStateStore.hpp/.cpp`, singleton; typed bool/int64/double/
+string values, `declareDefault`/`set`/typed getters/`has`/`remove`/
+`renamePrefix`/`clearToDefaults`, JSON serialize/deserialize, autoSave to
+`global_state.json`); Lua API `ecs.global.*`
+(`lua/LuaGlobalStateApi.hpp/.cpp`, registered in `EditorApp`); save-file
+`globalState` section in `EditorApp::saveGame`/`loadGame` (old saves load
+as defaults); mode semantics (game mode loads the cache, editor mode
+`clearToDefaults()` at startup/`loadScene`/`openProject` with auto-save
+disabled); `lua-examples/global_state_example.lua`; `docs/SaveLoadSystem.md`
+extended. Tests: `global_state_test` (CTest `globalStateTest`, 5/5).
+
+*Not from the original draft — requested during review. This is the generic
+"global storage" the draft item 6 assumed existed; F6 (persistent door
+state) becomes its first consumer instead of defining a bespoke
+DoorStateRegistry.*
+
+### Current state (verified)
+
+- No generic typed key-value storage exists. The closest mechanisms:
+ - per-domain singleton registries (`ItemStateRegistry`,
+ `ContainerStateRegistry`) — fixed-schema, one per feature;
+ - Lua save callbacks: `ecs.register_save_callback(name, fn)` feeding the
+ save file's `luaData` section (`lua/LuaSaveLoadApi.cpp`,
+ `EditorApp.cpp:1527`) — each script hand-serializes its own state;
+ nothing typed, nothing shared with C++.
+
+### Design
+
+New singleton `GlobalStateStore` (`systems/GlobalStateStore.hpp/.cpp`),
+following the lifetime/persistence pattern of `ItemStateRegistry`:
+
+- **Variables**: string name + typed value — `bool`, `int64`, `double`
+ (float) or `std::string`.
+- **C++ API**:
+
+ ```cpp
+ class GlobalStateStore {
+ public:
+ static GlobalStateStore &getInstance();
+
+ void declareDefault(const std::string &name, bool v);
+ void declareDefault(const std::string &name, int64_t v);
+ void declareDefault(const std::string &name, double v);
+ void declareDefault(const std::string &name, const std::string &v);
+
+ void set(const std::string &name, bool v); // + int64/double/string overloads
+ bool getBool(const std::string &name, bool fallback = false) const;
+ int64_t getInt(const std::string &name, int64_t fallback = 0) const;
+ double getFloat(const std::string &name, double fallback = 0.0) const;
+ std::string getString(const std::string &name,
+ const std::string &fallback = "") const;
+ bool has(const std::string &name) const;
+ void remove(const std::string &name);
+ void renamePrefix(const std::string &oldPrefix,
+ const std::string &newPrefix); // F0 door reassignment
+ void clearToDefaults();
+
+ nlohmann::json serialize() const;
+ void deserialize(const nlohmann::json &j);
+ void autoSave(); // global_state.json
+ };
+ ```
+
+- **Defaults are registered by the systems that use the storage**
+ (`declareDefault` at init / door build time). Reading an unset key returns
+ its registered default (or the caller's fallback). Defaults are *not*
+ persisted — only explicitly `set()` values are serialized, keeping saves
+ small and letting defaults evolve in code.
+- **Scene-independent**: a singleton, untouched by
+ `EditorApp::switchScene()` (same as the other registries).
+- **Save-game integration**: `saveData["globalState"]` in
+ `EditorApp::saveGame()` (next to `luaData`), restored in `loadGame()`;
+ `autoSave()` to `global_state.json` (CWD/project dir after F8) with the
+ same semantics as `item_state.json`. `docs/SaveLoadSystem.md` extended.
+- **Mode semantics** (shared rule for all consumers, see F6.6):
+ - editor mode: `clearToDefaults()` at startup and on scene (re)load;
+ `global_state.json` is NOT loaded — the editor always starts from
+ system defaults;
+ - game mode: new game starts from defaults; `loadGame()` restores the
+ save file's `globalState`; the JSON file is the cross-session cache.
+- **Lua API** (`lua/LuaGlobalStateApi.cpp`), so scripts can create, set,
+ read and remove variables:
+ - `ecs.global.set(name, value)` — type inferred from the Lua value
+ (boolean / integer number / float number / string);
+ - `ecs.global.get_bool(name [, default])`, `get_int`, `get_float`,
+ `get_string` — typed reads (explicit getters avoid Lua's single number
+ type ambiguity);
+ - `ecs.global.has(name)`, `ecs.global.remove(name)`.
+- **Naming convention**: dot-namespaced keys; each gameplay system owns a
+ prefix and registers it in `AGENTS.md`. First consumer (F6 doors):
+ `door..locked`, `door..isOpen`.
+- Optional extension (not in first iteration): an EventBus
+ `global_state_changed` event with the variable name on every `set()`.
+
+### Acceptance criteria
+
+- A variable set from C++ is readable from Lua and vice versa, with correct
+ types.
+- Values survive `switchScene()` and a save/load round trip; a new game and
+ the editor see registered defaults, not leftover values.
+- Old save files without a `globalState` section load cleanly (defaults).
+
+### Tests
+
+- `tests/component_lua_test.cpp` (or `save_load_lua_test.cpp` pattern):
+ Lua set/get/has/remove round trip for all four types; int vs float
+ distinction; default fallback.
+- Headless (no OGRE): serialize/deserialize round trip;
+ `clearToDefaults()` semantics; `renamePrefix`; save-section
+ write/read through the save format.
+- `lua-examples/global_state_example.lua`.
+
+---
+
+## F6 — Persistent door state (unique ID, locked, open/closed)
+
+**Status: implemented (2026-09-07).** `DoorComponent` gained `persistent` /
+`lockable` / `keyItemId` (copied from the F0 `CellGridDoorConfig` at build
+time). `DoorSystem` gained the static helpers `isDoorLocked()` /
+`isDoorLockedById()` / `setDoorLocked()` / `declareDoorDefaults()`, writes
+`door..isOpen` when a swing completes, and manages the unlock-event
+subscriptions (per-door `door_unlock_`, refreshed each update and
+removed with the door, plus the generic `door_unlock`). `CellGridSystem`'s
+door builder declares the store defaults and snaps a door that was left
+open straight to `openAngle` with the collider disabled. `ActuatorSystem`
+shows "E Locked", unlocks with the configured key item (keys are not
+consumed), emits `door_locked_` / `door_locked` otherwise, and
+offers Close/Lock in a hold-E menu on lockable doors. Lua:
+`ecs.door.is_locked/is_open/lock/unlock` (`lua/LuaDoorApi.cpp`). Tests:
+`cellgrid_door_test` tests 6–9 cover defaults, unlock notifications,
+per-door/generic unlock events, swing-completion persistence and the Lua
+`door_locked` → `door_unlock_` round trip (the Lua event test lives
+there rather than in `component_lua_test` because it needs the real
+bindings, not the stub). Demo: `demo-scene-switching-extra` scene A has a
+persistent locked door (internal doorway `Z:0:0:8`, fixed `gridUid`) with
+an inline scene script unlocking it on the first bump; `--headless
+--test-switch` verifies lock-at-start, scripted unlock, open-state
+persistence and the state restore after the A→B→A round trip. Docs:
+AGENTS.md "Persistent door state (F6)", `docs/SaveLoadSystem.md`,
+`lua-examples/door_lock_example.lua`.
+
+Deviation notes (6.4): the wire delivery uses both per-door subscriptions
+(`door_unlock_`, cleaned up each update) and the generic
+`door_unlock` event; unlocking emits the `door_unlocked_` /
+`door_unlocked` notifications from `DoorSystem::setDoorLocked()`.
+
+Deviation notes (6.6): `ItemStateRegistry`/`ContainerStateRegistry` still
+load their JSON unconditionally at startup — intentionally left unchanged
+(harmless leftovers of the pre-F9 design); only the `GlobalStateStore`
+follows the editor/game split.
+
+Depends on F8 (where `global_state.json` lives) and F9 (the storage itself).
+
+### 6.1 Door state in the global storage (F9)
+
+No bespoke `DoorStateRegistry` — door state lives in the F9
+`GlobalStateStore` under the `door.` prefix:
+
+| Key | Type | Meaning |
+|-----|------|---------|
+| `door..locked` | bool | persistent locked state |
+| `door..isOpen` | bool | persistent open/closed state |
+
+- Defaults are declared by the door builder (`buildDoorEntities()` /
+ F2's `DoorBuilder`) when a persistent door is (re)created:
+ `locked` defaults to the F0 config's `lockedByDefault`, `isOpen` to
+ false.
+- Save-game integration comes free with F9's `globalState` section;
+ `docs/SaveLoadSystem.md` documents the `door.*` keys.
+- Door reassignment (F0) migrates state via
+ `GlobalStateStore::renamePrefix("door..", "door..")`.
+
+### 6.2 Door unique IDs
+
+Provided by **F0**: the global door ID is `:` for CellGrid
+doors (stable across rebuilds, renames and scene loads) and a user-editable,
+editor-validated-unique `doorId` field on standalone doors (F2). F6 consumes
+`DoorComponent::doorId`; it does not define its own scheme.
+
+- Persistence opt-in: empty `doorId` = ephemeral door (never persisted), so
+ only "some doors" pay for storage, per the draft. For CellGrid doors the
+ F0 `persistent`/`lockable`/scene-switch flags decide whether a `doorId`
+ is assigned at all.
+
+### 6.3 Persistent open/closed state
+
+- `DoorSystem` writes `GlobalStateStore::set("door." + doorId + ".isOpen",
+ isOpen)` when a swing **completes** (reaches 0 or `openAngle`).
+- Door creation (`buildDoorEntities`, standalone builder) reads the store
+ and snaps `currentAngle`/`isOpen`/collider to the persisted state
+ instantly (no swing animation on load).
+- Survives scene switches: the store is a singleton and scene switch does
+ not touch it; a door re-created in a revisited scene restores its state.
+
+### 6.4 Persistent locked state + Lua events
+
+- `DoorComponent`/config gains `lockable` (bool) and optional
+ `keyItemId` (see 6.5). Locked state itself lives only in the global
+ storage key `door..locked`.
+- `ActuatorSystem` on E with a locked door:
+ 1. Emits `door_locked_` on the `EventBus` with params
+ `{ door_id, entity_id }` — the event name is predictable from the door
+ ID alone.
+ 2. Also emits a generic `door_locked` event with the same params, for
+ handlers that manage many doors.
+ 3. A Lua handler accepts by calling
+ `ecs.send_event("door_unlock_")` (or
+ `ecs.door.unlock(doorId)`, a thin wrapper over
+ `ecs.global.set("door." .. doorId .. ".locked", false)`) which clears
+ the locked key; the door does **not** open by itself — the player
+ presses E again. Because the state is a plain global variable, scripts
+ can also lock/unlock doors directly with `ecs.global.set()`.
+ 4. If nothing unlocks it, the door stays shut; the prompt shows
+ "E Locked".
+- Wire delivery: `DoorSystem` (or a small `DoorEventBridge`) subscribes to
+ `door_unlock_*` via a wildcard-free approach — subscribe to the generic
+ `door_unlock` event with `door_id` param and apply it. (EventBus has no
+ wildcard matching; per-door subscriptions must be cleaned up with the
+ door entity.)
+- Documentation: new section in `AGENTS.md` + `docs/SaveLoadSystem.md`;
+ example: `lua-examples/door_lock_example.lua` (a scene script that
+ subscribes to `door_locked_*` and unlocks after a condition).
+
+### 6.5 Key items
+
+- `ItemDefinition::itemType == "key"` (the string column already exists in
+ `ItemRegistry`; no schema change).
+- Door config field `keyItemId`: when set and the door is locked,
+ `ActuatorSystem` checks the player character's `InventoryComponent` for
+ that item. If present: unlock (registry write) and open.
+ **Keys are not consumed on unlock** (decided) — a key remains in the
+ inventory and can be reused (including to re-lock).
+- "Lock an open door": doors with `lockable` and a key get a second
+ actuator action ("Lock") available when open and the key is in inventory;
+ activating it closes (if needed) and sets `locked=true`.
+- NPC/GOAP path: out of scope here (see F7 open questions).
+
+### 6.6 Editor mode vs game mode
+
+- **Editor mode:** the global store is `clearToDefaults()` at startup and
+ on scene (re)load — doors always start from their scene-defined default
+ state; `global_state.json` is NOT loaded in editor mode.
+- **Game mode:** new game starts from defaults; `loadGame()` restores from
+ the save file's `globalState`; `global_state.json` auto-save is the
+ cross-session cache (same semantics as `item_state.json` today).
+- Note: `ItemStateRegistry`/`ContainerStateRegistry` currently load their
+ JSON unconditionally at startup (`EditorApp.cpp:741-744`) regardless of
+ mode — verify this "sanity" for the new storage and align (editor mode
+ must not leak a previous game session's door states into the edited
+ scene).
+
+### Tests
+
+- `tests/component_lua_test.cpp`: Lua event round trip
+ (`door_locked_` → handler → `door_unlock_` →
+ `door..locked` cleared in the global store).
+- Headless test (pattern of `tests/scene_switch_test.cpp`, no OGRE):
+ door state survives a save/load round trip via the `globalState`
+ section; editor-mode `clearToDefaults()` resets door keys.
+- Demo: a locked door in demo scene A of `demo-scene-switching-extra` with
+ an inline scene script unlocking it; verified via
+ `--headless --test-switch`-style scripted check.
+
+---
+
+## F7 — Navigation vs doors
+
+**Status: implemented (2026-09-08).** Doors are excluded from navmesh
+geometry collection (`NavMeshSystem::collectStaticEntities` paths 1/3 and
+the flecs-hierarchy walk skip `DoorComponent` entities). Doorway floors
+are painted with `TileCacheNavMesh::EDITSCENE_AREA_DOOR` (id 1) via
+`rcMarkBoxArea` after walkable-area erosion; the query filter assigns it
+`NavMeshComponent::doorAreaCost` (default 5.0, serialized as
+`doorAreaCost`, Lua-bound, editable in the NavMesh editor) so paths
+prefer detours but may cross doorways. Door volumes come from
+`NavMeshSystem::getDoorVolume()` — the closed-pose hinge transform
+(parent derived transform * `DoorComponent::closedOrientation`) plus the
+leaf box collider's extents — refreshed every frame and applied to tiles
+at (re)build time. Locked doors additionally become DetourTileCache box
+obstacles: `NavMeshSystem::syncDoorObstacles()` adds/removes them from
+`DoorSystem::isDoorLocked()` state each frame and
+`TileCacheNavMesh::update()` pumps the tile-cache request queue (dead
+door entities drop their obstacle automatically). `PathFollowingSystem`
+auto-opens closed, unlocked, non-scene-switch doors whose doorway the
+current path segment crosses (`handleDoorAhead`): it sets
+`toggleRequested` within interaction range and holds position until the
+leaf swings past 40 degrees. NPCs do not close doors behind them; if a
+door locks while an NPC walks, the obstacle appears and the periodic
+2 s path recalculation routes around it.
+
+Test: `testNavMeshDoors` in the `--run-terrain-tests` suite builds a
+floor + wall + doorway scene with a real door entity headlessly and
+verifies: path through the closed door's doorway succeeds, doorway polys
+carry `EDITSCENE_AREA_DOOR` (`NavMeshSystem::getPolyAreaAt`), locking
+makes the target unreachable, unlocking restores the path. The Lua
+binding is covered by `component_lua_test` (test 26, `doorAreaCost`).
+
+### Design
+
+1. **Doors are not obstacles.** `NavMeshSystem::collectStaticEntities()`
+ (both the full-collect and the per-tile paths, `NavMeshSystem.cpp:294+`
+ and `:138-150`) skip entities with `DoorComponent`. Closed doors no
+ longer block the mesh; NPCs path through doorways.
+2. **Doorway area cost.** New area id (e.g. `EDITSCENE_AREA_DOOR`) marked
+ via `rcMarkBoxArea` on the doorway volume (from the door's closed-pose
+ bounds) during tile rasterization — requires a per-tile area-marking
+ hook in `TileCacheNavMesh::rasterizeTileLayers()` fed by door volumes
+ collected by `NavMeshSystem`. `dtQueryFilter::setAreaCost(
+ EDITSCENE_AREA_DOOR, k)` with k > 1 (default e.g. 5.0, configurable on
+ `NavMeshComponent`) makes paths prefer detours over doorways but still
+ allow them.
+3. **Locked doors are obstacles.** On lock: `dtTileCache::addObstacle`
+ (cylinder or box covering the doorway); on unlock: `removeObstacle`.
+ Requires exposing obstacle add/remove + request pumping in
+ `TileCacheNavMesh` (DetourTileCache queues obstacle requests — verify
+ the per-frame `dtTileCache::update` call exists, add it if missing).
+4. **NPC auto-open (no BT authoring).** `PathFollowingSystem` checks, when
+ advancing waypoints, whether the segment to the next waypoint crosses a
+ doorway volume with a **closed, unlocked** door within interaction range
+ of the character; if so it sets `DoorComponent::toggleRequested = true`
+ (same path as the player's E), waits for the swing, then continues.
+ Locked doors need no special case: the Detour obstacle from (3) already
+ keeps paths away.
+
+### Open questions
+
+- Exact area cost value and whether it should be per-door configurable.
+- Should NPCs close doors behind them? (Recommendation: no, leave open.)
+- Blocked-path replanning if a door becomes locked while an NPC walks.
+
+### Tests
+
+- Headless navmesh test: findPath through a doorway succeeds with doors
+ closed; cost comparison prefers a longer doorless route at high k;
+ locking the door makes findPath fail/avoid; unlocking restores it.
+
+---
+
+## F1 — Scene-switch doors: switch when fully open + black occluder
+
+Depends on F0 (per-door `sceneSwitchPath`/`sceneSwitchTarget` — grid-wide
+fields become defaults) and combines trivially with F3 (sign).
+
+**Status: implemented (2026-09-07).** `DoorComponent` gained
+`sceneSwitchPending` (runtime) and `occluder` (runtime `Ogre::Entity*`
+tracked by `DoorSystem`). `ActuatorSystem` no longer switches immediately:
+E on a scene-switch door sets `toggleRequested` + `sceneSwitchPending`
+(an already-fully-open door switches immediately; E never closes a
+scene-switch door, and the hold-E "Close" menu action cancels a pending
+switch via the toggle path). `DoorSystem` fires the switch — and the new
+`door_scene_switch` event (params `path`/`target`/`door_id`) — only when
+the leaf reaches `openAngle` (`setEditorApp()` wires the `EditorApp` call;
+without it only the event fires, which is what the headless test uses).
+The door builder creates the black occluder (shared unit-box mesh
+`CellGridDoorOccluderBox` + unlit `CellGridDoorOccluderBlack` material),
+childed to the grid node so it does not swing, sized from the closed-leaf
+collider extents and pushed 2 cm behind the leaf plane; `DoorSystem`
+hides it while fully closed. Tests: `cellgrid_door_test` test 10 (event
+timing — nothing mid-swing, exactly once at full opening, close cancels).
+Demo: `demo-scene-switching-extra` scene A exit doorway `Z:0:0:15` is a
+scene-switch door to scene B; `--headless --test-switch` does the A→B
+leg through it and asserts the switch queues only at full opening (the
+frame timeout was raised to 10000 frames because headless frames are
+sub-millisecond while swings are real-time).
+
+### Current behaviour (to change)
+
+`ActuatorSystem.cpp:517-523`: E on a scene-switch door queues
+`switchScene()` immediately; the leaf never swings.
+
+### Design
+
+- Scene-switch doors swing like normal doors. On E:
+ `DoorComponent::toggleRequested = true` plus new flag
+ `DoorComponent::sceneSwitchPending = true` (runtime only).
+- `DoorSystem` detects swing completion (`currentAngle == openAngle` while
+ `isOpen && sceneSwitchPending`) and only then calls
+ `EditorApp::switchScene(sceneSwitchPath, {targetEntityName:
+ sceneSwitchTarget})`. The existing loading cover hides the switch.
+- **Black occluder:** `buildDoorEntities()` (and the F2 standalone builder)
+ additionally creates an unlit black box ("cube") entity childed to the
+ door entity, sized to the doorway opening, placed a few cm behind the
+ closed leaf plane, for doors with a non-empty `sceneSwitchPath`. Its
+ visibility follows the door state: hidden while fully closed, visible
+ while open/swinging (`RenderQueue`/visible flag toggled by `DoorSystem`
+ from `currentAngle`), so the player never sees the missing room interior
+ behind the opened doorway before the switch fires.
+- The prompt stays "E Open" (never "E Close") for scene-switch doors, as
+ today.
+
+### Acceptance criteria
+
+- In `demo-scene-switching-extra`: E on the interior exit door swings it
+ open, black box is visible through the doorway, the scene switch happens
+ only when the leaf reaches `doorOpenAngle`, and the arrival teleport +
+ loading cover behave as today.
+
+---
+
+## F2 — Standalone door entity
+
+**Status: implemented (2026-09-08).** `StandaloneDoorComponent`
+(`components/StandaloneDoor.hpp`) carries the full per-door config
+(`meshName`/`useMeshMaterial`/`rectName`, explicit `leafWidth`/`leafHeight`/
+`leafThickness`, `openAngle`/`openSpeed`/`swingReversed`, `actionName`,
+`sceneSwitchPath`/`sceneSwitchTarget`, `persistent`/`lockable`/
+`lockedByDefault`/`keyItemId`, `doorId`, plus a runtime `dirty` flag).
+The door-building logic was factored out of `CellGridSystem` into
+`systems/DoorBuilder.*` (`DoorBuildParams` + `DoorBuilder::build()`); both
+CellGrid doors and the new `StandaloneDoorSystem`
+(`systems/StandaloneDoorSystem.*`) use it and produce the identical entity
+subtree (hinge node, leaf, collider child, actuator, F1 occluder).
+`StandaloneDoorSystem::update` rebuilds on the `dirty` flag and cleans up
+built doors whose owner entity died (polled, like CellGrid — an OnRemove
+observer would shift entity IDs and break SceneSerializer). The entity's
+`TransformComponent` is the doorway placement (center of the opening at
+floor level, local +X along the wall). The procedural leaf is a box with
+the hinge edge at the origin spanning +X/+Y, skinned by the entity's own
+`ProceduralMaterialComponent` material, with `rectName` selecting the UV
+rect of the entity's `ProceduralTextureComponent` atlas; `doorId` is
+auto-generated (UUID) on first build when the door is persistent/lockable/
+a scene-switch door and stays serialized from then on. Editor: "Add
+Component -> Game -> Standalone Door", `ui/StandaloneDoorEditor.*` (every
+change sets `dirty`; duplicate `doorId` warning), serialization section
+`standaloneDoor` in `SceneSerializer.cpp`, Lua binding `StandaloneDoor` in
+`lua/LuaComponentApi.cpp` (setter sets `dirty`), tests:
+`component_lua_test` test 35, `cellgrid_door_test` test 12.
+
+### Design
+
+- New **serialized** component `StandaloneDoorComponent`
+ (`components/StandaloneDoor.hpp`) carrying the full door config that
+ today lives on `CellGridComponent`: `meshName` (empty = procedural leaf),
+ `useMeshMaterial`, `rectName`, `openAngle`, `openSpeed`, `swingReversed`
+ (F3), `actionName`, `sceneSwitchPath`, `sceneSwitchTarget`, `lockable`,
+ `keyItemId`, `doorId` (F6), `leafWidth/leafHeight/leafThickness` (explicit
+ instead of derived from cellSize).
+- The door-building logic in `CellGridSystem::buildDoorEntities()` is
+ factored into a shared builder (e.g. `DoorBuilder` in
+ `systems/DoorBuilder.*`) used by both CellGrid and a new
+ `StandaloneDoorSystem` that (re)builds the door entity subtree when the
+ component is modified. Entity structure (hinge node, leaf, collider
+ child, actuator, occluder child for scene-switch doors) is identical to
+ CellGrid doors.
+- Editor integration per the "Adding a New Component" checklist in
+ `AGENTS.md`: `REGISTER_COMPONENT_GROUP`, `ui/StandaloneDoorEditor.*`,
+ serialization in `SceneSerializer.cpp`, Lua binding in
+ `lua/LuaComponentApi.cpp`, test case in `tests/component_lua_test.cpp`.
+
+### Acceptance criteria
+
+- A standalone door can be added to an empty scene in the editor, saved,
+ reloaded, opened/closed with E, and supports scene-switch, lock/key and
+ persistence exactly like a CellGrid door.
+
+---
+
+## F3 — Door swing direction checkbox
+
+**Status: implemented (2026-09-07, CellGrid part).** Grid-wide
+`CellGridComponent::doorSwingReversed` (default false, serialized +
+Lua-bound), per-door `CellGridDoorConfig::swingReversed` (F0), copied to
+`DoorComponent::swingReversed` at build time. Per the recommendation,
+`openAngle`/`currentAngle` stay positive and the sign is applied at
+orientation time via `DoorSystem::swingOrientation(door, angle)`, used both
+by `DoorSystem::update()` and by the restore-open snap in the door builder.
+UI: "Reversed swing" checkbox next to the grid-wide angle/speed inputs in
+the Doors panel (the per-door override checkbox existed since F0; the
+override snapshot now seeds `swingReversed` from the grid default). Tests:
+`cellgrid_door_test` test 10 (serialization round trip, old-scene default,
+quaternion sign), `component_lua_test` 32b extended. The
+`StandaloneDoorComponent` part lands with F2.
+
+### Design
+
+- New bool `doorSwingReversed` (default false) as the grid-wide default on
+ `CellGridComponent`, per-door via F0's `CellGridDoorConfig::swingReversed`,
+ and on `StandaloneDoorComponent` (F2); the effective value is copied to
+ `DoorComponent::swingReversed` at build time.
+- `DoorSystem` applies the swing as `currentAngle * (swingReversed ? -1 :
+ 1)` around local Y (equivalently: negate `openAngle` at copy time —
+ pick one and document it; recommendation: keep `openAngle` positive and
+ negate in `DoorSystem`, so persisted angles stay comparable).
+- UI: a "Reversed swing" checkbox in the Cell Grid editor door section
+ (`ui/CellGridEditor.cpp`, next to the angle/speed inputs) and in the
+ standalone door editor.
+- Serialized in the scene JSON; exposed to Lua like the other door fields.
+
+---
+
+## F4 — CellGrid interior-only mode
+
+**Status: implemented (2026-09-08).** `CellGridComponent::generationMode`
+(string, `"full"` default / `"interiorOnly"` / `"exteriorOnly"` for F5) with
+`interiorOnlyMode()` / `exteriorOnlyMode()` helpers. In `"interiorOnly"`,
+`CellGridSystem` skips external wall planes (`buildWalls`), external window
+panels (`buildWindows`), external window frame placement
+(`placeWindowFrames[InStaticGeometry]`), external corners (`buildCorners`)
+and roofs (`buildRoofs`); floors, ceilings, internal walls/frames, furniture,
+the exit doorway wall panels (`extDoorsTb`), external door frames and all
+door entities are still generated. Boundary windows (cells with both the
+internal and the matching external window flag) get an opaque glass pane
+at the internal wall plane, hiding the void outside (see F5). Physics
+colliders follow the mesh lists, so skipped parts get no colliders
+automatically. Editor: "Generation Mode"
+combo in the Cell Grid editor; serialized as `generationMode` in the
+cellGrid JSON section; Lua field `generationMode`. Tests:
+`cellgrid_door_test` test 13 (round trip + old-scene default),
+`component_lua_test` test 33 extended. Geometry verified by patching the
+demo scene A grid to interiorOnly: `extWall=0 extWindows=0 roofTop=0
+roofSide=0` while `intWall`/`extDoors`/`intDoors`/floors stay.
+
+### Design
+
+- New `CellGridComponent::generationMode` enum serialized as a string:
+ `"full"` (default, current behaviour), `"interiorOnly"`,
+ `"exteriorOnly"` (F5). (Two booleans were considered; an enum is
+ future-proof for e.g. `"floorsOnly"`.)
+- `"interiorOnly"`: `buildCellGrid()` skips external walls, external
+ windows and their frames, roofs and corner pieces that only serve the
+ exterior shell; floors, ceilings, internal walls, internal door/window
+ frames, internal door entities and furniture are still generated.
+ **Exit door frames and exit door entities ARE generated** (decided):
+ interior-only and exterior-only scenes are paired for transitions, so the
+ interior scene needs its exit doors (typically scene-switch doors to the
+ exterior scene) even though the exterior shell around them is omitted.
+- The Cell Grid editor gets a mode dropdown; missing-piece bookkeeping in
+ the Texture Rectangles panel is unaffected (rect names are simply unused
+ for skipped parts).
+
+---
+
+## F5 — CellGrid exterior-only mode
+
+**Status: implemented (2026-09-08).** `"exteriorOnly"` keeps only the shell:
+external walls, external door/window panels, roofs, corners, external
+door/window frames and exit door entities (internal doorways get no door
+entity). Floors/ceilings, internal walls, internal frames, internal door
+panels/entities and furniture are skipped (`buildFloorsAndCeilings`,
+internal bits in `buildWalls`, `buildInternalCorners`, internal blocks in
+`buildDoors`/`buildWindows`, `intMesh` blocks in frame placement,
+`buildFurniture`, internal doorways in `tryPlaceDoor`). Window glass:
+`CellGridSystem::buildGlass()` adds one thin box pane (2 cm) per window
+opening (same dimensions as `buildWindows`), stored as
+`meshData.glassMesh` with a collider in the static shell (so the player
+cannot climb through windows), rendered with either `glassMaterialName` or
+a built-in per-grid `CellGridGlass_` material created/updated by
+`getGlassMaterialName()` and removed in `destroyCellGridMeshes()`. New
+serialized + Lua-bound fields: `glassColor` (ColourValue, default
+bluish 0.4/0.6/0.8/0.35), `glassMaterialName` (empty = built-in),
+`glassReflectivity` (0.8). Editor: glass widgets shown in the Cell Grid
+editor when the mode is exteriorOnly or interiorOnly. Tests:
+`cellgrid_door_test` test 13 extended (glass round trip + old-scene
+defaults), `component_lua_test` test 33 extended. Geometry verified on
+the demo scenes (rooms regenerate cell flags on load — hand-patched flags
+are lost, use `createWindows` on the Room): exteriorOnly yields
+`floor=0 ceiling=0 intWall=0 intDoors=0` with `extWall`/`extDoors` kept
+and `glass=384` (16 panes); interiorOnly with `createWindows` also yields
+`glass=384` (boundary windows only).
+
+**Update (2026-09-08): the glass is opaque, in both modes.** The built-in
+material no longer uses alpha blending (no `SBT_TRANSPARENT_ALPHA`, depth
+write enabled, diffuse alpha forced to 1; the `glassColor` alpha is
+ignored by the built-in material) — transparent glass let the player see
+the ungenerated half of the building (exteriorOnly: the empty interior;
+interiorOnly: the void outside). `interiorOnly` grids now also get glass:
+panes are generated only for **boundary windows** (cells carrying both the
+internal `IntWindow*` and the matching external `Window*` flag), placed at
+the internal wall plane (`±(halfCell - 0.1)`); pure interior room-to-room
+windows get no glass. A custom `glassMaterialName` can still override
+with any material.
+
+### Design
+
+- `"exteriorOnly"`: only the exterior shell is generated — external walls,
+ external door/window frames, roofs, corners, and **exit door entities**
+ (typically scene-switch doors). No internal walls, no furniture, no
+ internal frames, and **no floors/ceilings** (decided — the shell is not
+ meant to be walked in; anything inside comes from the paired interior
+ scene).
+- **Window glass:** for each external window, a blue quad/box is generated
+ inside the window frame opening, using a dedicated glossy material
+ ("fake glass"): blue base colour with high specular / shininess.
+ **The glass is opaque** (decided 2026-09-08, both exteriorOnly and
+ interiorOnly): it exists to hide the half of the building the generation
+ mode omits, so the built-in material does no alpha blending and ignores
+ the `glassColor` alpha; a custom `glassMaterialName` remains the escape
+ hatch for real transparency.
+ **The glass is a collider** (decided): it is included in the static shell
+ collider so the player cannot climb through windows.
+- New config: `glassColor` (ColourValue, default bluish), `glassMaterialName`
+ (empty = built-in default), `glassReflectivity` gloss parameter if the
+ built-in material is used.
+
+---
+
+## Cross-cutting requirements
+
+For every feature above, per project rules (`AGENTS.md`):
+
+- Update `src/features/editScene/AGENTS.md` (and root `AGENTS.md` if the
+ module table / build outputs change) in the same change.
+- Update `docs/SaveLoadSystem.md` for F6; add `lua-examples/` entries for
+ F6 Lua events.
+- Add/extend tests: `tests/component_lua_test.cpp` for new components and
+ Lua bindings; headless tests (pattern of `scene_switch_test.cpp` /
+ `--run-terrain-tests`) for registry logic and navmesh behaviour; extend
+ the `demo-scene-switching-extra` demo as the end-to-end test bed
+ (locked door, scene-switch door with occluder, standalone door).
+- Serialization changes must tolerate loading old scenes (missing fields →
+ defaults, as with the `bookmarks` precedent).
+
+## Suggested implementation order
+
+1. **F8** project directory (unblocks demo-as-project workflow)
+2. **F0** door identity, per-door config, editor UX (foundation for all
+ other door features)
+3. **F9** global persistent variable storage (foundation for F6 and all
+ future gameplay persistence)
+4. **F6.1–6.3** door state in the global storage + open/closed persistence
+5. **F6.4–6.5** locked state, Lua events, key items (+6.6 mode sanity)
+6. **F3** swing sign checkbox (per-door via F0 config)
+7. **F1** scene-switch on fully open + black occluder
+8. **F2** standalone door (reuses F0/F1/F3/F6 machinery via DoorBuilder)
+9. **F4** interior-only mode
+10. **F5** exterior-only mode + glass
+11. **F7** navigation integration (largest risk: TileCache obstacle pumping)
diff --git a/src/features/editScene/ProjectConfig.cpp b/src/features/editScene/ProjectConfig.cpp
new file mode 100644
index 0000000..e8cc4ee
--- /dev/null
+++ b/src/features/editScene/ProjectConfig.cpp
@@ -0,0 +1,47 @@
+#include "ProjectConfig.hpp"
+
+#include
+
+#include
+#include
+
+std::string sanitizeAppName(const std::string &name)
+{
+ std::string out = name;
+ for (auto &c : out) {
+ bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '.' || c == '_' ||
+ c == '-' || c == ' ';
+ if (!ok)
+ c = '_';
+ }
+ if (out.empty())
+ out = "project";
+ return out;
+}
+
+ProjectConfig loadProjectConfig(const std::string &dir)
+{
+ ProjectConfig cfg;
+ cfg.rootDir = dir;
+ cfg.appName = std::filesystem::path(dir).filename().string();
+
+ std::filesystem::path jsonPath =
+ std::filesystem::path(dir) / "project.json";
+ std::ifstream file(jsonPath);
+ if (!file.is_open())
+ return cfg;
+
+ try {
+ nlohmann::json j;
+ file >> j;
+ cfg.appName = j.value("appName", cfg.appName);
+ cfg.startScene = j.value("startScene", "");
+ cfg.gameMode = j.value("gameMode", false);
+ cfg.loaded = true;
+ } catch (const std::exception &e) {
+ fprintf(stderr, "WARNING: could not parse %s: %s\n",
+ jsonPath.string().c_str(), e.what());
+ }
+ return cfg;
+}
diff --git a/src/features/editScene/ProjectConfig.hpp b/src/features/editScene/ProjectConfig.hpp
new file mode 100644
index 0000000..13eccf4
--- /dev/null
+++ b/src/features/editScene/ProjectConfig.hpp
@@ -0,0 +1,61 @@
+#ifndef EDITSCENE_PROJECT_CONFIG_HPP
+#define EDITSCENE_PROJECT_CONFIG_HPP
+#pragma once
+
+#include
+
+/**
+ * Project directory configuration (F8).
+ *
+ * A "project" is a directory that acts as the editor/game working
+ * directory: scenes, prefabs, resources.cfg, runtime config JSONs and
+ * heightmaps/ are all resolved relative to it (the process chdir()s into
+ * the project root at startup when --project is given, or the release
+ * binary simply runs from it).
+ *
+ * The project root may contain a project.json:
+ *
+ * {
+ * "appName": "My Game",
+ * "startScene": "scenes/level1.json",
+ * "gameMode": true
+ * }
+ *
+ * appName - per-project identity: drives the window title and the
+ * per-project save directory (//saves/).
+ * Falls back to the directory name when missing.
+ * startScene - scene loaded directly by game mode (skips the startup
+ * menu) when gameMode is true.
+ * gameMode - the project is a playable game; the editor binary enters
+ * game mode by default for such projects (--editor forces
+ * editor mode).
+ */
+struct ProjectConfig {
+ /* Absolute path of the project root; empty when no project is open
+ * (plain editor session in the binary directory). */
+ std::string rootDir;
+
+ /* Per-project application name (window title, save directory). */
+ std::string appName;
+
+ /* Game-mode start scene (relative to the project root). */
+ std::string startScene;
+
+ /* Project defaults to game mode. */
+ bool gameMode = false;
+
+ /* True when a project.json was found and parsed. */
+ bool loaded = false;
+};
+
+/**
+ * Load /project.json. Tolerates a missing or malformed file:
+ * appName falls back to the directory name, loaded is set false and a
+ * warning is logged on parse errors. rootDir is always set to dir.
+ */
+ProjectConfig loadProjectConfig(const std::string &dir);
+
+/** Make an appName safe for use as a filesystem path component. */
+std::string sanitizeAppName(const std::string &name);
+
+#endif // EDITSCENE_PROJECT_CONFIG_HPP
diff --git a/src/features/editScene/components/CellGrid.cpp b/src/features/editScene/components/CellGrid.cpp
index 0d8184e..1e13885 100644
--- a/src/features/editScene/components/CellGrid.cpp
+++ b/src/features/editScene/components/CellGrid.cpp
@@ -1,5 +1,28 @@
#include "CellGrid.hpp"
#include
+#include
+#include
+
+const std::string& CellGridComponent::ensureGridUid()
+{
+ if (!gridUid.empty())
+ return gridUid;
+
+ // Random UUID-like hex string (8-4-4-4-12), no external dependency.
+ std::random_device rd;
+ std::mt19937_64 gen(((uint64_t)rd() << 32) ^ (uint64_t)rd());
+ uint64_t a = gen(), b = gen();
+ char buf[40];
+ snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x",
+ (unsigned)(a & 0xffffffffu),
+ (unsigned)((a >> 32) & 0xffffu),
+ (unsigned)(((a >> 48) & 0x0fffu) | 0x4000u), // version 4
+ (unsigned)((b & 0x3fffu) | 0x8000u), // variant 1
+ (unsigned)((b >> 16) & 0xffffu),
+ (unsigned)((b >> 32) & 0xffffffffu));
+ gridUid = buf;
+ return gridUid;
+}
Cell* CellGridComponent::findCell(int x, int y, int z)
{
diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp
index 3b5020a..861bc3a 100644
--- a/src/features/editScene/components/CellGrid.hpp
+++ b/src/features/editScene/components/CellGrid.hpp
@@ -3,6 +3,7 @@
#include
#include
#include
+#include