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 #include #include #include @@ -105,6 +106,38 @@ struct FurnitureCell { } }; +/** + * @brief Per-doorway configuration override (F0). + * + * Stored in CellGridComponent::doorConfigs keyed by the canonical doorway + * edge key ("X:x:y:z" / "Z:x:y:z" - the same key buildDoorEntities() uses + * to deduplicate doorways, see CellGridSystem::doorEdgeKey()). Entries + * override the grid-wide door* defaults for one specific doorway and + * survive scene loads and grid rebuilds. Entries whose doorway no longer + * exists are "orphaned": they are kept (never auto-deleted) until pruned + * or reassigned in the Cell Grid editor. + */ +struct CellGridDoorConfig { + // Identity / UX + std::string label; // user-visible name ("Kitchen door") + + // Behaviour overrides (grid defaults apply when hasOverride is false) + bool hasOverride = false; + float openAngle = 100.0f; // copied from grid on first override + float openSpeed = 180.0f; + bool swingReversed = false; // F3 + std::string actionName; + std::string sceneSwitchPath; // F1 (empty = normal swinging door) + std::string sceneSwitchTarget; + bool disabled = false; // spawn no door entity for this doorway + + // Persistence / locking (F6) + bool persistent = false; // track state in the global storage + bool lockable = false; + bool lockedByDefault = false; + std::string keyItemId; // item that unlocks this door +}; + /** * @brief Cell grid for procedural building generation * @@ -114,6 +147,39 @@ struct FurnitureCell { * Used by: House/Lot generation, dungeon generation */ struct CellGridComponent { + // Stable identity of this grid (F0): a UUID generated when the + // component is created or first serialized. Door global IDs are + // ":", so they survive grid entity renames. + std::string gridUid; + + // Per-doorway configuration overrides keyed by canonical edge key + // (see CellGridDoorConfig). Serialized inside this component. + std::map doorConfigs; + + // Generation mode (F4/F5, serialized as a string): + // "full" - default, the whole building + // "interiorOnly" - skip the exterior shell (external walls, external + // window panels + frames, roofs, external corners); + // floors, ceilings, internal walls/frames, furniture, + // exit door wall panels + frames and all door entities + // are still generated (paired exterior scene handles + // the shell) + // "exteriorOnly" - F5: only the exterior shell + std::string generationMode = "full"; + + bool interiorOnlyMode() const { return generationMode == "interiorOnly"; } + bool exteriorOnlyMode() const { return generationMode == "exteriorOnly"; } + + // Window glass (F5, exteriorOnly/interiorOnly): window openings get an + // opaque glossy pane (exteriorOnly: every external window; interiorOnly: + // boundary windows) that hides the missing half of the building and is + // part of the static collider set, so the player cannot climb through + // windows. The built-in material is used when glassMaterialName is + // empty; the glassColor alpha is ignored by it (opaque). + Ogre::ColourValue glassColor = Ogre::ColourValue(0.4f, 0.6f, 0.8f, 0.35f); + std::string glassMaterialName; // empty = built-in CellGridGlass + float glassReflectivity = 0.8f; // built-in material gloss [0..1] + // Grid dimensions (in cells) int width = 10; // X dimension int height = 1; // Y dimension (floors) @@ -154,6 +220,7 @@ struct CellGridComponent { bool doorUseMeshMaterial = false; // Custom mesh keeps its own material float doorOpenAngle = 100.0f; // Swing angle when open (degrees) float doorOpenSpeed = 180.0f; // Swing speed (degrees per second) + bool doorSwingReversed = false; // F3: swing to the other side std::string doorActionName; // Optional action run on activation // Scene switching doors (e.g. interior <-> exterior): when // doorSceneSwitchPath is set, activating a door queues an @@ -192,6 +259,10 @@ struct CellGridComponent { // Mark for rebuild void markDirty() { dirty = true; version++; } + + // Return gridUid, generating a random UUID first when empty. + // Call before building door IDs or serializing. + const std::string& ensureGridUid(); // Convert local cell position to world position Ogre::Vector3 cellToWorld(int x, int y, int z) const; diff --git a/src/features/editScene/components/Door.hpp b/src/features/editScene/components/Door.hpp index 27f022d..a8549ae 100644 --- a/src/features/editScene/components/Door.hpp +++ b/src/features/editScene/components/Door.hpp @@ -27,6 +27,9 @@ struct DoorComponent { // Swing configuration (copied from CellGridComponent at build time) float openAngle = 100.0f; // Target angle when open (degrees) float openSpeed = 180.0f; // Swing speed (degrees per second) + bool swingReversed = false; // F3: swing to the other side (negates + // the applied angle; currentAngle and + // openAngle stay positive) // Runtime: current swing angle (0 = closed) float currentAngle = 0.0f; @@ -39,11 +42,39 @@ struct DoorComponent { // node sits on the side edge of the doorway) Ogre::Vector3 centerOffset = Ogre::Vector3::ZERO; - // Scene switching (copied from CellGridComponent at build time): - // when sceneSwitchPath is set, activation switches scenes instead - // of swinging the leaf + // Door identity (F0): canonical doorway edge key within the owning + // grid ("X:x:y:z" / "Z:x:y:z") and the global persistent ID + // ":" (empty = ephemeral door, never persisted). + std::string edgeKey; + std::string doorId; + + // Persistence / locking (F6, copied from the per-door + // CellGridDoorConfig at build time). The actual locked and + // open/closed state lives in the GlobalStateStore under + // "door..locked" / "door..isOpen"; these flags only + // say the door participates. See DoorSystem::isDoorLocked() / + // setDoorLocked(). + bool persistent = false; // open/closed state is persisted + bool lockable = false; // can be locked/unlocked + std::string keyItemId; // inventory item that unlocks/locks it + + // Scene switching (copied from CellGridComponent or the per-door + // CellGridDoorConfig override at build time): + // when sceneSwitchPath is set, activation swings the leaf open and + // the scene switch fires once the door is fully open (F1) std::string sceneSwitchPath; // Target scene path (empty = disabled) std::string sceneSwitchTarget; // Teleport target entity name + + // F1: a scene-switch door first swings open; DoorSystem fires the + // switch (and the "door_scene_switch" event) only when the leaf + // reaches openAngle. Runtime only, set by ActuatorSystem. + bool sceneSwitchPending = false; + + // F1: unlit black box covering the doorway of a scene-switch door, + // created by the door builder a few cm behind the closed leaf plane + // (child of the grid node, so it does NOT swing with the hinge). + // Hidden while the door is fully closed. Runtime only. + Ogre::Entity *occluder = nullptr; }; #endif // EDITSCENE_DOOR_HPP diff --git a/src/features/editScene/components/NavMesh.hpp b/src/features/editScene/components/NavMesh.hpp index 86d2e64..6d6ffaa 100644 --- a/src/features/editScene/components/NavMesh.hpp +++ b/src/features/editScene/components/NavMesh.hpp @@ -27,6 +27,10 @@ struct NavMeshComponent { float regionMergeSize = 20.0f; int tileSize = 48; // cells per tile + // F7: traversal cost multiplier for doorway polys (> 1 makes + // paths prefer doorless routes but still allows doorways) + float doorAreaCost = 5.0f; + // Runtime flags bool enabled = true; bool debugDraw = false; diff --git a/src/features/editScene/components/StandaloneDoor.hpp b/src/features/editScene/components/StandaloneDoor.hpp new file mode 100644 index 0000000..feb1b55 --- /dev/null +++ b/src/features/editScene/components/StandaloneDoor.hpp @@ -0,0 +1,59 @@ +#ifndef EDITSCENE_STANDALONE_DOOR_HPP +#define EDITSCENE_STANDALONE_DOOR_HPP +#pragma once + +#include +#include + +/** + * Standalone door component (F2, serialized). + * + * A door without a CellGrid: the entity's TransformComponent marks the + * doorway (center of the opening at floor level; local +X runs along the + * wall, +Z faces out of the room) and StandaloneDoorSystem builds the + * runtime door subtree under it with DoorBuilder - hinge node, leaf, + * collider child, actuator and (for scene-switch doors) the F1 black + * occluder, identical to CellGrid doors. + * + * All behaviour flags mirror the CellGrid per-door configuration + * (CellGridDoorConfig); persistence and locking work through the same + * GlobalStateStore keys "door..locked" / ".isOpen". + * + * Identity: `doorId` is the global persistent ID, user-editable and + * expected to be unique scene-wide (the editor warns on duplicates). + * When empty while the door needs one (persistent/lockable/scene-switch), + * a random UUID is generated on first build and stays in the serialized + * component from then on. Empty + no persistence flags = ephemeral door. + */ +struct StandaloneDoorComponent { + // Leaf geometry + std::string meshName; // empty = procedural box leaf + bool useMeshMaterial = false; // keep the custom mesh's material + std::string rectName; // UV rect name in the entity's own + // ProceduralTexture (optional) + float leafWidth = 1.0f; // procedural leaf dimensions + float leafHeight = 2.0f; // (explicit, not cell-derived) + float leafThickness = 0.08f; + + // Behaviour + float openAngle = 100.0f; // degrees + float openSpeed = 180.0f; // degrees per second + bool swingReversed = false; // F3 + std::string actionName; // optional actuator action + std::string sceneSwitchPath; // F1 (empty = normal swinging door) + std::string sceneSwitchTarget; + + // Persistence / locking (F6) + bool persistent = false; + bool lockable = false; + bool lockedByDefault = false; + std::string keyItemId; + std::string doorId; // global ID; see the class comment + + // Runtime: rebuild the door subtree on the next + // StandaloneDoorSystem::update (set by the editor on changes; + // not serialized) + bool dirty = true; +}; + +#endif // EDITSCENE_STANDALONE_DOOR_HPP diff --git a/src/features/editScene/components/StandaloneDoorModule.cpp b/src/features/editScene/components/StandaloneDoorModule.cpp new file mode 100644 index 0000000..64a689c --- /dev/null +++ b/src/features/editScene/components/StandaloneDoorModule.cpp @@ -0,0 +1,21 @@ +#include "StandaloneDoor.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/StandaloneDoorEditor.hpp" + +REGISTER_COMPONENT_GROUP("Standalone Door", "Game", StandaloneDoorComponent, + StandaloneDoorEditor) +{ + registry.registerComponent( + "Standalone Door", "Game", + std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt b/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt index eb0cc12..10d782d 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt +++ b/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt @@ -32,11 +32,34 @@ set(DEMO_SOURCES ${EDITSCENE_SOURCES}) list(REMOVE_ITEM DEMO_SOURCES main.cpp) list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/") +# --- Embedded project configuration (F8 release binary) --------------- +# Read project.json at configure time and embed its parameters into the +# binary via a generated project.h, so the release binary is attached to +# its project directory without needing --project. Editing project.json +# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS). +set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${PROJECT_JSON_PATH}") +file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT) +string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName) +string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene) +string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode) +if(PROJECT_GAME_MODE) + set(PROJECT_GAME_MODE_VALUE 1) +else() + set(PROJECT_GAME_MODE_VALUE 0) +endif() +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY) + add_executable(demoSceneSwitchingExtra demo_main.cpp ${DEMO_SOURCES} ) +target_compile_definitions(demoSceneSwitchingExtra + PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT) + add_dependencies(demoSceneSwitchingExtra morph) # Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor) @@ -65,6 +88,7 @@ target_link_libraries(demoSceneSwitchingExtra ) target_include_directories(demoSceneSwitchingExtra PRIVATE + ${CMAKE_CURRENT_BINARY_DIR}/generated ${EDITSCENE_SOURCE_DIR} ${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include ${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp index 38c7498..e571c2f 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp @@ -1,15 +1,21 @@ #include #include "EditorApp.hpp" +#include "ProjectConfig.hpp" #include "camera/EditorCamera.hpp" #include "systems/CharacterRegistry.hpp" -#include "systems/BehaviorTreeSystem.hpp" -#include "components/ActionDatabase.hpp" +#include "systems/DoorSystem.hpp" +#include "systems/EventBus.hpp" +#include "systems/GlobalStateStore.hpp" +#include "components/Door.hpp" #include "components/EntityName.hpp" #include "components/Transform.hpp" #include #include -#include -#include +#include + +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT +#include "project.h" +#endif struct ExitAfterFirstFrameListener : public Ogre::FrameListener { Ogre::Root *root; @@ -28,6 +34,8 @@ struct ExitAfterFirstFrameListener : public Ogre::FrameListener { /* Same application as the editor/game, but with a larger initial window. */ class DemoApp : public EditorApp { public: + using EditorApp::EditorApp; + OgreBites::NativeWindowPair createWindow(const Ogre::String &name, uint32_t w, uint32_t h, Ogre::NameValuePairList miscParams) override @@ -73,75 +81,56 @@ static void createFloorMesh(const Ogre::String &meshName, mesh->getSubMesh(0)->setMaterialName(materialName); } -/* - * Visible pillar meshes marking the actuator positions ("portal_a" in - * scene A, "portal_b" in scene B). The ActuatorSystem already draws a - * screen-space indicator, but a physical marker makes the spot visible - * from across the floor. The box is 0.6 x 1.6 x 0.6 centered on the - * entity origin, so the entities sit at y = 0.8. - */ -static void createMarkerMesh(const Ogre::String &meshName, - const Ogre::String &materialName, - float dr, float dg, float db) -{ - const Ogre::String group = - Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; - - if (!Ogre::MeshManager::getSingleton() - .getByName(meshName, group) - .isNull()) - return; - - Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create( - materialName, group); - Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); - pass->setDiffuse(dr, dg, db, 1.0f); - pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f); - pass->setSpecular(0.1f, 0.1f, 0.1f, 1.0f); - - Procedural::BoxGenerator boxGen; - boxGen.setSizeX(0.6f).setSizeY(1.6f).setSizeZ(0.6f); - Ogre::MeshPtr mesh = boxGen.realizeMesh(meshName, group); - if (mesh && mesh->getNumSubMeshes() > 0) - mesh->getSubMesh(0)->setMaterialName(materialName); -} - static void createDemoResources() { - /* Scene A: green floor, orange portal marker. */ + /* Scene A: green floor. */ createFloorMesh("DemoFloorPlaneA", "DemoFloorMaterialA", 0.35f, 0.5f, 0.35f); - createMarkerMesh("DemoActuatorMarkerA", "DemoActuatorMarkerMaterialA", - 0.9f, 0.5f, 0.1f); - /* Scene B: blue floor, magenta portal marker. */ + /* Scene B: blue floor. */ createFloorMesh("DemoFloorPlaneB", "DemoFloorMaterialB", 0.3f, 0.4f, 0.6f); - createMarkerMesh("DemoActuatorMarkerB", "DemoActuatorMarkerMaterialB", - 0.8f, 0.2f, 0.6f); } /* * End-to-end check for --test-switch: drives the same path an E-press on - * the actuator would take (ActionDatabase action -> behavior tree -> - * "switchScene" node -> EditorApp::switchScene() queue -> - * performSceneSwitch() on the next frame with the "@arrival_*" teleport), - * for a full A -> B -> A round trip. The prompt/targeting glue is - * screen-space and therefore not covered headless. The ActuatorSystem is - * not involved because its interaction path needs an ImGui context; the - * tree is evaluated through a local BehaviorTreeSystem exactly like - * ActuatorSystem::isActionComplete() does. + * a scene-switch door takes (DoorComponent toggleRequested + + * sceneSwitchPending -> DoorSystem swing -> EditorApp::switchScene() + * queue once the leaf is fully open -> performSceneSwitch() on the next + * frame with the "@arrival_*" teleport), for a full A -> B -> A round + * trip: scene A exits through the "interrior" grid's door Z:0:0:15 and + * scene B returns through its own grid's door Z:0:0:0. The + * prompt/targeting glue is screen-space and therefore not covered + * headless. */ struct SceneSwitchTestListener : public Ogre::FrameListener { EditorApp *app; - BehaviorTreeSystem *bt; int frame = 0; - int phase = 0; + int phase = 10; /* 10-12: F6 locked door, then 0-3 scene switching */ bool failed = false; Ogre::String failReason; - SceneSwitchTestListener(EditorApp *a, BehaviorTreeSystem *b) - : app(a), bt(b) + /* F6 demo door: internal doorway Z:0:0:8 of the "interrior" grid, + * lockable + locked by default (doorConfigs in demo_scene_a.json); + * the inline scene script unlocks it on the first door_locked bump. */ + const Ogre::String doorId = + "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8"; + + /* F1 demo doors: scene A exits through the external doorway + * Z:0:0:15 of the "interrior" grid (scene-switch door to + * demo_scene_b.json), scene B returns through the external doorway + * Z:0:0:0 of its own exteriorOnly grid (scene-switch door back to + * demo_scene_a.json). For both legs the switch must fire only + * when the leaf is fully open. */ + const Ogre::String exitDoorId = + "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:15"; + const Ogre::String returnDoorId = + "77370b1d-e8ab-44a6-865b-e55c15b0fc78:Z:0:0:0"; + int exitOpenWait = 0; + int returnOpenWait = 0; + + SceneSwitchTestListener(EditorApp *a) + : app(a) { } @@ -156,44 +145,79 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { return found; } - /* Returns false while the player is not available yet (retry). - * Hard failures set failed/failReason. */ - bool runAction(const char *actionName, const char *expectPath) + /* The F6 demo door entity (recreated on every scene load). */ + flecs::entity findDoor() { - flecs::entity player = app->getPlayerCharacterEntity(); - if (!player.is_alive()) + return findDoorById(doorId); + } + + flecs::entity findDoorById(const Ogre::String &id) + { + flecs::entity result = flecs::entity::null(); + app->getWorld()->query().each( + [&](flecs::entity e, DoorComponent &d) { + if (d.doorId == id) + result = e; + }); + return result; + } + + /* Emulate the E-press on a scene-switch door. */ + bool requestDoorSwitch(const Ogre::String &id) + { + flecs::entity door = findDoorById(id); + if (!door.is_alive()) return false; - - ActionDatabase *db = ActionDatabase::getSingletonPtr(); - const GoapAction *action = - db ? db->findAction(actionName) : nullptr; - if (!action) { + DoorComponent &d = door.get_mut(); + if (!d.occluder) { failed = true; - failReason = Ogre::String("action not found: ") + - actionName; - return true; - } - - BehaviorTreeSystem::Status status = - bt->evaluatePlayerAction(player.id(), - action->behaviorTree, 0.016f, - true); - if (status != BehaviorTreeSystem::Status::success) { - failed = true; - failReason = Ogre::String("action did not succeed: ") + - actionName; - return true; - } - if (!app->hasPendingSceneSwitch() || - app->getPendingSceneSwitchPath() != expectPath) { - failed = true; - failReason = Ogre::String("no pending switch to ") + - expectPath; + failReason = "F1 scene-switch door has no occluder"; return true; } + d.toggleRequested = true; + d.sceneSwitchPending = true; return true; } + /* Waits for the queued scene switch of a scene-switch door; + * returns true once the switch to expectPath is pending (the door + * must be fully open by then). */ + bool waitDoorSwitch(const Ogre::String &id, const Ogre::String &label, + const char *expectPath, int &openWait) + { + flecs::entity door = findDoorById(id); + if (!door.is_alive()) + return false; + const DoorComponent &d = door.get(); + bool fullyOpen = d.isOpen && d.currentAngle == d.openAngle; + if (app->hasPendingSceneSwitch()) { + if (!fullyOpen) { + failed = true; + failReason = + "F1 scene switch fired before the door was fully open"; + return true; + } + if (app->getPendingSceneSwitchPath() != expectPath) { + failed = true; + failReason = "F1 wrong pending switch path"; + return true; + } + std::cout << "[test] F1 " << label + << " fully open, switch queued" << std::endl; + return true; + } + if (fullyOpen) { + /* Frame-listener ordering grace. */ + if (++openWait > 5) { + failed = true; + failReason = "F1 scene switch never fired"; + } + } else { + openWait = 0; + } + return false; + } + bool checkPlayerNear(const Ogre::Vector3 &expected, float tol) { flecs::entity player = app->getPlayerCharacterEntity(); @@ -218,9 +242,8 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { /* The arrival markers sit at (0, 0, 24) facing -Z (toward the * floor center); verify the camera took a position behind the - * character looking at the walking surface with the portal - * pillar (z = 28) behind the camera, and that the character's - * visual facing (local +Z) points at the center too. */ + * character looking at the walking surface, and that the + * character's visual facing (local +Z) points at the center too. */ bool checkCameraFacesCenter(const Ogre::Vector3 &charPos) { flecs::entity player = app->getPlayerCharacterEntity(); @@ -269,7 +292,10 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { app->getRoot()->queueEndRendering(); return true; } - if (frame > 1200) { + /* The swings are real-time (doorOpenSpeed deg/s) while a + * headless frame is sub-millisecond, so the door legs need a + * few thousand frames (~10 s wall clock at 1000+ fps). */ + if (frame > 10000) { failed = true; failReason = "timeout waiting for scene switch"; app->getRoot()->queueEndRendering(); @@ -277,18 +303,92 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { } switch (phase) { - case 0: + case 10: { + /* F6: the demo door starts locked (lockedByDefault). */ if (frame < 5) - break; /* let the character spawn */ - if (!runAction("goto_scene_b", "demo_scene_b.json")) break; - if (!failed) - std::cout << "[test] queued switch A -> B" - << std::endl; - phase = 1; + flecs::entity door = findDoor(); + if (!door.is_alive()) { + if (frame > 60) { + failed = true; + failReason = "F6 demo door not found"; + } + break; + } + if (!DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = "F6 demo door not locked at start"; + break; + } + /* Emulate the ActuatorSystem E-press on a locked door; + * the inline scene script answers door_locked with + * door_unlock_. */ + EventBus::getInstance().send("door_locked", "door_id", + doorId); + phase = 11; break; + } + case 11: { + /* F6: wait for the scene script to unlock the door + * (the frame timeout covers a missing handler). */ + if (DoorSystem::isDoorLockedById(doorId)) + break; + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + door.get_mut().toggleRequested = true; + std::cout << "[test] F6 door unlocked via scene script, " + "opening" + << std::endl; + phase = 12; + break; + } + case 12: { + /* F6: the swing completes and the open state lands in + * the global store. */ + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + const DoorComponent &d = door.get(); + if (!d.isOpen || d.currentAngle != d.openAngle) + break; + if (!GlobalStateStore::getInstance().getBool( + "door." + doorId + ".isOpen")) { + failed = true; + failReason = "F6 door open state not persisted"; + break; + } + std::cout << "[test] F6 door open and persisted" + << std::endl; + phase = 0; + break; + } + case 0: { + /* F1: A -> B through the exit scene-switch door. */ + if (frame < 5) + break; + if (!findDoorById(exitDoorId).is_alive()) { + if (frame > 60) { + failed = true; + failReason = "F1 exit door not found"; + } + break; + } + if (!requestDoorSwitch(exitDoorId)) + break; + phase = 13; + break; + } + case 13: { + /* F1: the scene switch must fire only once the leaf + * is fully open. */ + if (waitDoorSwitch(exitDoorId, "exit door", + "demo_scene_b.json", exitOpenWait)) + phase = 1; + break; + } case 1: - if (!entityExists("portal_b")) + if (!entityExists("x1")) break; if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f), 1.0f)) @@ -301,15 +401,26 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { std::cout << "[test] arrived in scene B at arrival_b, " "camera faces the walking surface" << std::endl; - if (!runAction("goto_scene_a", "demo_scene_a.json")) + /* F1: B -> A through scene B's own scene-switch + * door (external doorway Z:0:0:0 of its + * exteriorOnly grid). */ + if (!findDoorById(returnDoorId).is_alive()) { + failed = true; + failReason = "F1 return door not found"; break; - if (!failed) - std::cout << "[test] queued switch B -> A" - << std::endl; - phase = 2; + } + if (!requestDoorSwitch(returnDoorId)) + break; + phase = 14; + break; + case 14: + /* F1: same fully-open contract on the way back. */ + if (waitDoorSwitch(returnDoorId, "return door", + "demo_scene_a.json", returnOpenWait)) + phase = 2; break; case 2: - if (!entityExists("portal_a")) + if (!entityExists("interrior")) break; if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f), 1.0f)) @@ -317,6 +428,25 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f, 24.0f))) break; + /* F6: the door rebuilt with the scene must be snapped + * back to the persisted open + unlocked state. */ + { + flecs::entity door = findDoor(); + if (!door.is_alive()) + break; + const DoorComponent &d = + door.get(); + if (!d.isOpen || d.currentAngle != d.openAngle || + DoorSystem::isDoorLockedById(doorId)) { + failed = true; + failReason = + "F6 door state not restored after scene switch"; + break; + } + std::cout << "[test] F6 door state restored " + "after scene switch" + << std::endl; + } if (!failed) { std::cout << "[test] arrived back in scene A " "at arrival_a, camera faces the " @@ -336,24 +466,36 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * hand-authored scenes (demo_scene_a.json and demo_scene_b.json), each * containing a flat colored floor plane with a static physics collider, * a character spawner ("s1", character registry ID 2, same character - * setup as town8.json), a PlayerControllerComponent targeting it and an - * actuator ("portal_a" / "portal_b", marked by a colored pillar). + * setup as town8.json) and a PlayerControllerComponent targeting it. * Scene A additionally holds an "interrior" entity with a - * CellGridComponent (a 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. + * CellGridComponent in interiorOnly generation mode (a room with floor, + * ceiling, interior walls, windows with opaque glass 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. Scene B + * holds an exteriorOnly CellGridComponent (entity "x1"/"w1") - just the + * building shell with opaque window glass, seen from the outside. * - * Each actuator names one action ("goto_scene_b" / "goto_scene_a") whose - * behavior tree is defined in the scene's top-level "actionDatabase" - * block: a sequence ending in a "switchScene" node that queues an - * EditorApp::switchScene() to the other scene with a - * "@arrival_a"/"@arrival_b" teleport target, so the player reappears - * right next to the return portal. Walking into the pillar's radius - * shows the "E goto_scene_*" prompt; pressing E runs the tree and the - * scene switch executes at the start of the next frame. Both scenes - * carry their own player controller, so each switch is a clean takeover - * (see EditorApp::performSceneSwitch); travel back and forth is endless. + * F6 demo content: scene A grid's internal doorway Z:0:0:8 is configured + * (doorConfigs in demo_scene_a.json) as persistent + lockable + locked + * by default, with a fixed gridUid so its global door ID is stable, and + * the grid entity carries an inline scene script that answers the + * "door_locked" event with "door_unlock_" (the door event + * contract; the door never opens by itself, the player presses E again). + * The door's locked and open/closed state lives in the global state + * store and survives the A -> B -> A scene switches. + * + * F1 demo content: both scene transitions go through scene-switch doors. + * Scene A's grid has the external exit doorway Z:0:0:15 configured as a + * scene-switch door to demo_scene_b.json (target arrival_b); scene B's + * exteriorOnly grid sets doorSceneSwitchPath/demo_scene_a.json + + * doorSceneSwitchTarget/arrival_a grid-wide, so its external doorway + * Z:0:0:0 is the way back. E on such a door swings the leaf open first; + * the scene switch fires only when the leaf reaches the open angle, and + * a black occluder box behind the doorway hides the missing half of the + * building while it swings. Both scenes carry their own player + * controller, so each switch is a clean takeover (see + * EditorApp::performSceneSwitch); travel back and forth is endless. * * The mouse is grabbed while Playing (built-in game-mode behaviour); * Escape toggles the pause menu, which frees the cursor. @@ -365,20 +507,47 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * without a rebuild). * * Extra flags: - * --test-switch headless-friendly end-to-end check: executes both - * portal actions and verifies the A -> B -> A round - * trip and the arrival teleports; exits 0 on PASS. + * --test-switch headless-friendly end-to-end check: verifies the F6 + * locked door (locked at start, unlocked by the scene + * script through the door event contract, open state + * persisted and restored across the scene switches) and + * both F1 scene-switch doors (A -> B and B -> A each + * fire only when the leaf is fully open, black occluder + * present), verifying the round-trip arrival teleports; + * exits 0 on PASS. */ int main(int argc, char *argv[]) { try { +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT + /* Release binary: project parameters are embedded at build + * time from project.json (generated project.h); the project + * root is the working directory the binary runs from. */ + DemoApp app(EDITSCENE_PROJECT_APP_NAME); + { + ProjectConfig cfg; + cfg.rootDir = + std::filesystem::current_path().string(); + cfg.appName = EDITSCENE_PROJECT_APP_NAME; + cfg.startScene = EDITSCENE_PROJECT_START_SCENE; + cfg.gameMode = EDITSCENE_PROJECT_GAME_MODE; + cfg.loaded = true; + app.setProjectConfig(cfg); + } +#else DemoApp app; +#endif app.setGameMode(EditorApp::GameMode::Game); bool headless = false; bool exitAfterFirstFrame = false; bool testSwitch = false; + bool sceneArgGiven = false; +#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT + Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE; +#else Ogre::String sceneFile = "demo_scene_a.json"; +#endif for (int i = 1; i < argc; i++) { Ogre::String arg = argv[i]; if (arg == "--headless") { @@ -389,6 +558,7 @@ int main(int argc, char *argv[]) testSwitch = true; } else if (arg.length() > 0 && arg[0] != '-') { sceneFile = arg; + sceneArgGiven = true; } } app.setHeadless(headless); @@ -411,25 +581,22 @@ int main(int argc, char *argv[]) /* Meshes + materials referenced by the scene entities. */ createDemoResources(); + /* With an embedded project, EditorApp::setup() skipped the + * startup menu; the start scene defaults to the embedded + * one and can still be overridden positionally. */ + (void)sceneArgGiven; std::cout << "[demo] starting new game with scene: " << sceneFile << std::endl; app.startNewGame(sceneFile); std::cout << "[demo] controls: mouse = look, W/A/S/D = move, " - "Shift = run, E = use portal, Escape = pause menu" + "Shift = run, E = use door, Escape = pause menu" << std::endl; ExitAfterFirstFrameListener exitListener(app.getRoot()); if (exitAfterFirstFrame && !testSwitch) app.getRoot()->addFrameListener(&exitListener); - /* The test evaluates the action trees exactly like - * ActuatorSystem::isActionComplete() does; only the - * switchScene/debugPrint nodes are used, so no animation - * or character system is needed. */ - BehaviorTreeSystem testBt(*app.getWorld(), app.getSceneManager(), - nullptr, nullptr); - testBt.setEditorApp(&app); - SceneSwitchTestListener testListener(&app, &testBt); + SceneSwitchTestListener testListener(&app); if (testSwitch) app.getRoot()->addFrameListener(&testListener); diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json index 66311b5..02d2501 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json @@ -86,32 +86,6 @@ "bits": 0, "mask": 0 } - }, - { - "behaviorTree": { - "children": [ - { - "name": "[demo] portal A: switching to scene B", - "type": "debugPrint" - }, - { - "name": "demo_scene_b.json", - "params": "@arrival_b", - "type": "switchScene" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "goto_scene_b", - "preconditions": { - "bits": 0, - "mask": 0 - } } ], "bitNames": [ @@ -130,7 +104,7 @@ "entities": [ { "children": [], - "id": 488, + "id": 4294967793, "name": { "name": "arrival_a" }, @@ -155,7 +129,7 @@ }, { "children": [], - "id": 489, + "id": 4294967794, "light": { "castShadows": false, "constantAttenuation": 1.0, @@ -231,7 +205,7 @@ }, "shapeType": "box" }, - "id": 490, + "id": 4294967792, "name": { "name": "demo_floor" }, @@ -266,42 +240,6 @@ } } }, - { - "actuator": { - "actionNames": [ - "goto_scene_b" - ], - "height": 1.7999999523162842, - "radius": 1.5 - }, - "children": [], - "id": 491, - "name": { - "name": "portal_a" - }, - "renderable": { - "meshName": "DemoActuatorMarkerA", - "visible": true - }, - "transform": { - "position": { - "x": 0.0, - "y": 0.800000011920929, - "z": 28.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, { "characterSpawner": { "despawnDistance": 200.0, @@ -309,7 +247,7 @@ "spawnDistance": 100.0 }, "children": [], - "id": 492, + "id": 4294967790, "name": { "name": "s1" }, @@ -334,7 +272,7 @@ }, { "children": [], - "id": 493, + "id": 4294967789, "name": { "name": "player" }, @@ -569,7 +507,7 @@ "z": 7 }, { - "flags": 147463, + "flags": 4326403, "x": -1, "y": 0, "z": 8 @@ -581,13 +519,13 @@ "z": 8 }, { - "flags": 163851, + "flags": 8521731, "x": 1, "y": 0, "z": 8 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 9 @@ -599,13 +537,13 @@ "z": 9 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 9 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 10 @@ -617,13 +555,13 @@ "z": 10 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 10 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 11 @@ -635,13 +573,13 @@ "z": 11 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 11 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 12 @@ -653,13 +591,13 @@ "z": 12 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 12 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 13 @@ -671,13 +609,13 @@ "z": 13 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 13 }, { - "flags": 81943, + "flags": 20976643, "x": -1, "y": 0, "z": 14 @@ -689,7 +627,7 @@ "z": 14 }, { - "flags": 98331, + "flags": 25171971, "x": 1, "y": 0, "z": 14 @@ -697,10 +635,45 @@ ], "depth": 10, "doorActionName": "", + "doorConfigs": { + "Z:0:0:15": { + "actionName": "", + "disabled": false, + "hasOverride": true, + "keyItemId": "", + "label": "Exit door", + "lockable": false, + "lockedByDefault": false, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": false, + "sceneSwitchPath": "demo_scene_b.json", + "sceneSwitchTarget": "arrival_b", + "swingReversed": false + }, + "Z:0:0:8": { + "actionName": "", + "disabled": false, + "hasOverride": false, + "keyItemId": "", + "label": "Demo locked door", + "lockable": true, + "lockedByDefault": true, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": true, + "sceneSwitchPath": "", + "sceneSwitchTarget": "", + "swingReversed": false + } + }, "doorMeshName": "", "doorOpenAngle": 100.0, "doorOpenSpeed": 180.0, "doorRectName": "", + "doorSceneSwitchPath": "", + "doorSceneSwitchTarget": "", + "doorSwingReversed": false, "doorUseMeshMaterial": false, "doorsEnabled": true, "extDoorFrameRectName": "", @@ -709,7 +682,17 @@ "floorRectName": "floor", "friction": 0.5, "furnitureCells": [], + "generationMode": "interiorOnly", "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6", "height": 1, "intDoorFrameRectName": "", "intWallRectName": "", @@ -733,7 +716,7 @@ "minY": 0, "minZ": -8 }, - "id": 495, + "id": 4294967786, "name": { "name": "r1" }, @@ -758,7 +741,7 @@ }, { "children": [], - "id": 496, + "id": 8589935095, "name": { "name": "r2" }, @@ -810,7 +793,7 @@ }, { "children": [], - "id": 509, + "id": 4294967816, "name": { "name": "r3" }, @@ -821,7 +804,7 @@ "createCeiling": true, "createFloor": true, "createInteriorWalls": true, - "createWindows": false, + "createWindows": true, "exits": [ false, true, @@ -861,7 +844,7 @@ } } ], - "id": 494, + "id": 4294967785, "name": { "name": "interrior" }, @@ -1511,6 +1494,10 @@ "textureSize": 512, "uvMargin": 0.009999999776482582 }, + "sceneScript": { + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "scriptPath": "" + }, "transform": { "position": { "x": 0.0, diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json- b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json- new file mode 100644 index 0000000..da295c4 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json- @@ -0,0 +1,1610 @@ +{ + "actionDatabase": { + "actions": [ + { + "behaviorTree": { + "children": [ + { + "name": "luaHello", + "params": "message=Welcome to the game!", + "type": "luaTask" + }, + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "9.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "lua_hello_action", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "6.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + }, + { + "name": "luaHello", + "params": "message=\"hello, world!\"", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "testAction", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "[demo] portal A: switching to scene B", + "type": "debugPrint" + }, + { + "name": "demo_scene_b.json", + "params": "@arrival_b", + "type": "switchScene" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "goto_scene_b", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "[demo] portal B: switching to scene A", + "type": "debugPrint" + }, + { + "name": "demo_scene_a.json", + "params": "@arrival_a", + "type": "switchScene" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "goto_scene_a", + "preconditions": { + "bits": 0, + "mask": 0 + } + } + ], + "bitNames": [ + { + "index": 1, + "name": "hungry" + }, + { + "index": 2, + "name": "thirsty" + } + ], + "goals": [] + }, + "bookmarks": [], + "entities": [ + { + "children": [], + "id": 4294967793, + "name": { + "name": "arrival_a" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 24.0 + }, + "rotation": { + "w": 0.0, + "x": 0.0, + "y": 1.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 4294967794, + "light": { + "castShadows": false, + "constantAttenuation": 1.0, + "diffuseColor": { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "direction": { + "x": 0.30000001192092896, + "y": -1.0, + "z": 0.20000000298023224 + }, + "intensity": 1.0, + "lightType": "directional", + "linearAttenuation": 0.0, + "quadraticAttenuation": 0.0, + "range": 100.0, + "specularColor": { + "a": 1.0, + "b": 0.5, + "g": 0.5, + "r": 0.5 + }, + "spotlightFalloff": 1.0, + "spotlightInnerAngle": 30.0, + "spotlightOuterAngle": 45.0 + }, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "collider": { + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": -0.10000000149011612, + "z": 0.0 + }, + "parameters": { + "x": 30.0, + "y": 0.10000000149011612, + "z": 30.0 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "id": 4294967792, + "name": { + "name": "demo_floor" + }, + "renderable": { + "meshName": "DemoFloorPlaneA", + "visible": true + }, + "rigidBody": { + "bodyType": "static", + "enabled": true, + "friction": 0.800000011920929, + "isSensor": false, + "mass": 1.0, + "restitution": 0.0 + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "actuator": { + "actionNames": [ + "goto_scene_b" + ], + "height": 1.7999999523162842, + "radius": 1.5 + }, + "children": [], + "id": 4294967791, + "name": { + "name": "portal_a" + }, + "renderable": { + "meshName": "DemoActuatorMarkerA", + "visible": true + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.800000011920929, + "z": 28.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "characterSpawner": { + "despawnDistance": 200.0, + "registryId": 2, + "spawnDistance": 100.0 + }, + "children": [], + "id": 4294967790, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.10288965702056885, + "z": -3.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 4294967789, + "name": { + "name": "player" + }, + "playerController": { + "actuatorColor": [ + 0.0, + 0.4000000059604645, + 1.0 + ], + "actuatorCooldown": 1.5, + "actuatorDistance": 25.0, + "actuatorLabelFontSize": 12.0, + "cameraMode": 0, + "distantCircleRadius": 8.0, + "fpsBoneName": "Head", + "idleState": "idle", + "locomotionStateMachine": "locomotion", + "mouseSensitivity": 0.20000000298023224, + "nearCircleRadius": 14.0, + "runState": "running", + "swimFastState": "swimming-fast", + "swimIdleState": "swim-idle", + "swimState": "swimming", + "targetCharacterName": "s1", + "tpsDistance": 3.0, + "tpsHeight": 2.0, + "walkState": "walking" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "cellGrid": { + "ceilingRectName": "ceiling", + "cellHeight": 4.0, + "cellSize": 2.0, + "cells": [ + { + "flags": 147495, + "x": -1, + "y": 0, + "z": -2 + }, + { + "flags": 131107, + "x": 0, + "y": 0, + "z": -2 + }, + { + "flags": 163883, + "x": 1, + "y": 0, + "z": -2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": -1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": -1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": -1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 0 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 2 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 2 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 3 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 4 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 4 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 4 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 5 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 5 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 5 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 6 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 6 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 6 + }, + { + "flags": 81927, + "x": -1, + "y": 0, + "z": 7 + }, + { + "flags": 1048579, + "x": 0, + "y": 0, + "z": 7 + }, + { + "flags": 98315, + "x": 1, + "y": 0, + "z": 7 + }, + { + "flags": 4326403, + "x": -1, + "y": 0, + "z": 8 + }, + { + "flags": 2097155, + "x": 0, + "y": 0, + "z": 8 + }, + { + "flags": 8521731, + "x": 1, + "y": 0, + "z": 8 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 9 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 9 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 9 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 10 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 10 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 10 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 11 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 11 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 11 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 12 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 12 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 12 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 13 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 13 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 13 + }, + { + "flags": 20976643, + "x": -1, + "y": 0, + "z": 14 + }, + { + "flags": 1048835, + "x": 0, + "y": 0, + "z": 14 + }, + { + "flags": 25171971, + "x": 1, + "y": 0, + "z": 14 + } + ], + "depth": 10, + "doorActionName": "", + "doorConfigs": { + "Z:0:0:15": { + "actionName": "", + "disabled": false, + "hasOverride": true, + "keyItemId": "", + "label": "Exit door", + "lockable": false, + "lockedByDefault": false, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": false, + "sceneSwitchPath": "demo_scene_b.json", + "sceneSwitchTarget": "arrival_b", + "swingReversed": false + }, + "Z:0:0:8": { + "actionName": "", + "disabled": false, + "hasOverride": false, + "keyItemId": "", + "label": "Demo locked door", + "lockable": true, + "lockedByDefault": true, + "openAngle": 100.0, + "openSpeed": 180.0, + "persistent": true, + "sceneSwitchPath": "", + "sceneSwitchTarget": "", + "swingReversed": false + } + }, + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorSceneSwitchPath": "", + "doorSceneSwitchTarget": "", + "doorSwingReversed": false, + "doorUseMeshMaterial": false, + "doorsEnabled": true, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "floor", + "friction": 0.5, + "furnitureCells": [], + "generationMode": "interiorOnly", + "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6", + "height": 1, + "intDoorFrameRectName": "", + "intWallRectName": "", + "intWindowFrameRectName": "", + "roofSideRectName": "", + "roofTopRectName": "", + "width": 10 + }, + "children": [ + { + "children": [], + "clearArea": { + "clearCells": true, + "clearFurniture": true, + "clearRoofs": false, + "clearRooms": false, + "maxX": 8, + "maxY": 1, + "maxZ": 20, + "minX": -8, + "minY": 0, + "minZ": -8 + }, + "id": 4294967786, + "name": { + "name": "r1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 8589935095, + "name": { + "name": "r2" + }, + "room": { + "connectedRoomIds": [ + "room_1788724060923073709_509" + ], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": false, + "exits": [ + false, + true, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 8, + "minX": -1, + "minY": 0, + "minZ": -2, + "persistentId": "room_1788648326350139622_498", + "roomType": "", + "tags": [] + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 4294967816, + "name": { + "name": "r3" + }, + "room": { + "connectedRoomIds": [ + "room_1788648326350139622_498" + ], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": true, + "exits": [ + false, + true, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 15, + "minX": -1, + "minY": 0, + "minZ": 8, + "persistentId": "room_1788724060923073709_509", + "roomType": "", + "tags": [] + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "id": 4294967785, + "name": { + "name": "interrior" + }, + "proceduralMaterial": { + "ambient": { + "b": 0.20000000298023224, + "g": 0.20000000298023224, + "r": 0.20000000298023224 + }, + "diffuse": { + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "diffuseTextureId": "id_1788648541764281950_3", + "materialId": "id_1788648527415023537_1", + "materialName": "ProceduralMat_id_1788648527415023537_1", + "roughness": 0.5, + "shininess": 32.0, + "specular": { + "b": 0.0, + "g": 0.0, + "r": 0.0 + } + }, + "proceduralTexture": { + "colors": [ + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + } + ], + "namedRects": [ + { + "name": "ceiling", + "u1": 0.10000000149011612, + "u2": 0.20000000298023224, + "v1": 0.0, + "v2": 0.10000000149011612 + }, + { + "name": "floor", + "u1": 0.0, + "u2": 0.10000000149011612, + "v1": 0.0, + "v2": 0.10000000149011612 + } + ], + "textureId": "id_1788648541764281950_3", + "textureName": "ProceduralTex_id_1788648541764281950_3", + "textureSize": 512, + "uvMargin": 0.009999999776482582 + }, + "sceneScript": { + "inlineScript": "-- F6 demo: the internal doorway Z:0:0:8 of this grid is lockable\n-- and locked by default (see the grid's doorConfigs). The first time\n-- the player bumps into the locked door, this handler unlocks it through\n-- the door event contract; the door itself never opens on its own, the\n-- player presses E again after the unlock.\nlocal door_id = \"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8\"\n\necs.subscribe_event(\"door_locked\", function(event, params)\n if params and params.door_id == door_id then\n print(\"[demo] door_locked for \" .. door_id ..\n \" (locked=\" .. tostring(ecs.door.is_locked(door_id)) ..\n \"), unlocking\")\n ecs.send_event(\"door_unlock_\" .. door_id)\n print(\"[demo] door unlocked, locked=\" ..\n tostring(ecs.door.is_locked(door_id)))\n end\nend)\n", + "scriptPath": "" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.013780713081359863, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "version": "1.0" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json index 758a166..bc4378a 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json @@ -86,32 +86,6 @@ "bits": 0, "mask": 0 } - }, - { - "behaviorTree": { - "children": [ - { - "name": "[demo] portal B: switching to scene A", - "type": "debugPrint" - }, - { - "name": "demo_scene_a.json", - "params": "@arrival_a", - "type": "switchScene" - } - ], - "type": "sequence" - }, - "cost": 1, - "effects": { - "bits": 0, - "mask": 0 - }, - "name": "goto_scene_a", - "preconditions": { - "bits": 0, - "mask": 0 - } } ], "bitNames": [ @@ -138,7 +112,7 @@ "position": { "x": 0.0, "y": 0.0, - "z": 24.0 + "z": 18.265350341796875 }, "rotation": { "w": 0.0, @@ -162,7 +136,7 @@ "cellSize": 2.0, "cells": [ { - "flags": 147495, + "flags": 37757955, "x": -1, "y": 0, "z": 0 @@ -174,13 +148,13 @@ "z": 0 }, { - "flags": 163883, + "flags": 41953283, "x": 1, "y": 0, "z": 0 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 1 @@ -192,13 +166,13 @@ "z": 1 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 1 }, { - "flags": 16391, + "flags": 4195331, "x": -1, "y": 0, "z": 2 @@ -210,25 +184,25 @@ "z": 2 }, { - "flags": 32779, + "flags": 8390659, "x": 1, "y": 0, "z": 2 }, { - "flags": 81943, + "flags": 20976643, "x": -1, "y": 0, "z": 3 }, { - "flags": 65555, + "flags": 16781315, "x": 0, "y": 0, "z": 3 }, { - "flags": 98331, + "flags": 25171971, "x": 1, "y": 0, "z": 3 @@ -236,10 +210,14 @@ ], "depth": 10, "doorActionName": "", + "doorConfigs": {}, "doorMeshName": "", "doorOpenAngle": 100.0, "doorOpenSpeed": 180.0, "doorRectName": "", + "doorSceneSwitchPath": "demo_scene_a.json", + "doorSceneSwitchTarget": "arrival_a", + "doorSwingReversed": true, "doorUseMeshMaterial": false, "doorsEnabled": true, "extDoorFrameRectName": "", @@ -248,7 +226,17 @@ "floorRectName": "", "friction": 0.5, "furnitureCells": [], + "generationMode": "exteriorOnly", "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78", "height": 1, "intDoorFrameRectName": "", "intWallRectName": "", @@ -306,7 +294,7 @@ "createCeiling": true, "createFloor": true, "createInteriorWalls": true, - "createWindows": false, + "createWindows": true, "exits": [ true, false, @@ -376,9 +364,9 @@ }, "transform": { "position": { - "x": -0.9691458940505981, + "x": 0.0, "y": 0.0, - "z": 32.09623718261719 + "z": 22.0 }, "rotation": { "w": 1.0, @@ -506,42 +494,6 @@ } } }, - { - "actuator": { - "actionNames": [ - "goto_scene_a" - ], - "height": 1.7999999523162842, - "radius": 1.5 - }, - "children": [], - "id": 495, - "name": { - "name": "portal_b" - }, - "renderable": { - "meshName": "DemoActuatorMarkerB", - "visible": true - }, - "transform": { - "position": { - "x": 0.0, - "y": 0.800000011920929, - "z": 28.0 - }, - "rotation": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - }, - "scale": { - "x": 1.0, - "y": 1.0, - "z": 1.0 - } - } - }, { "characterSpawner": { "despawnDistance": 200.0, @@ -549,7 +501,7 @@ "spawnDistance": 100.0 }, "children": [], - "id": 496, + "id": 495, "name": { "name": "s1" }, @@ -574,7 +526,7 @@ }, { "children": [], - "id": 497, + "id": 496, "name": { "name": "player" }, diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json- b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json- new file mode 100644 index 0000000..2d9250e --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json- @@ -0,0 +1,667 @@ +{ + "actionDatabase": { + "actions": [ + { + "behaviorTree": { + "children": [ + { + "name": "luaHello", + "params": "message=Welcome to the game!", + "type": "luaTask" + }, + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "9.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "lua_hello_action", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "6.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + }, + { + "name": "luaHello", + "params": "message=\"hello, world!\"", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "testAction", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "[demo] portal A: switching to scene B", + "type": "debugPrint" + }, + { + "name": "demo_scene_b.json", + "params": "@arrival_b", + "type": "switchScene" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "goto_scene_b", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "[demo] portal B: switching to scene A", + "type": "debugPrint" + }, + { + "name": "demo_scene_a.json", + "params": "@arrival_a", + "type": "switchScene" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "goto_scene_a", + "preconditions": { + "bits": 0, + "mask": 0 + } + } + ], + "bitNames": [ + { + "index": 1, + "name": "hungry" + }, + { + "index": 2, + "name": "thirsty" + } + ], + "goals": [] + }, + "bookmarks": [], + "entities": [ + { + "children": [], + "id": 488, + "name": { + "name": "arrival_b" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 24.0 + }, + "rotation": { + "w": 0.0, + "x": 0.0, + "y": 1.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [ + { + "cellGrid": { + "ceilingRectName": "", + "cellHeight": 4.0, + "cellSize": 2.0, + "cells": [ + { + "flags": 37757955, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 2097667, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 41953283, + "x": 1, + "y": 0, + "z": 0 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 1 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 1 + }, + { + "flags": 4195331, + "x": -1, + "y": 0, + "z": 2 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 2 + }, + { + "flags": 8390659, + "x": 1, + "y": 0, + "z": 2 + }, + { + "flags": 20976643, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 16781315, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 25171971, + "x": 1, + "y": 0, + "z": 3 + } + ], + "depth": 10, + "doorActionName": "", + "doorConfigs": {}, + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorSceneSwitchPath": "demo_scene_a.json", + "doorSceneSwitchTarget": "arrival_a", + "doorSwingReversed": false, + "doorUseMeshMaterial": false, + "doorsEnabled": true, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "", + "friction": 0.5, + "furnitureCells": [], + "generationMode": "exteriorOnly", + "generationScript": "", + "glassColor": [ + 0.4000000059604645, + 0.6000000238418579, + 0.800000011920929, + 0.3499999940395355 + ], + "glassMaterialName": "", + "glassReflectivity": 0.800000011920929, + "gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78", + "height": 1, + "intDoorFrameRectName": "", + "intWallRectName": "", + "intWindowFrameRectName": "", + "roofSideRectName": "", + "roofTopRectName": "", + "width": 10 + }, + "children": [ + { + "children": [], + "clearArea": { + "clearCells": true, + "clearFurniture": true, + "clearRoofs": false, + "clearRooms": false, + "maxX": 10, + "maxY": 1, + "maxZ": 10, + "minX": -10, + "minY": 0, + "minZ": -10 + }, + "id": 491, + "name": { + "name": "c1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 492, + "name": { + "name": "r1" + }, + "room": { + "connectedRoomIds": [], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": true, + "exits": [ + true, + false, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 4, + "minX": -1, + "minY": 0, + "minZ": 0, + "persistentId": "room_1788689581735260047_499", + "roomType": "", + "tags": [] + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "id": 490, + "name": { + "name": "w1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "id": 489, + "name": { + "name": "x1" + }, + "transform": { + "position": { + "x": -0.9691458940505981, + "y": 0.0, + "z": 32.09623718261719 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 493, + "light": { + "castShadows": false, + "constantAttenuation": 1.0, + "diffuseColor": { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "direction": { + "x": 0.30000001192092896, + "y": -1.0, + "z": 0.20000000298023224 + }, + "intensity": 1.0, + "lightType": "directional", + "linearAttenuation": 0.0, + "quadraticAttenuation": 0.0, + "range": 100.0, + "specularColor": { + "a": 1.0, + "b": 0.5, + "g": 0.5, + "r": 0.5 + }, + "spotlightFalloff": 1.0, + "spotlightInnerAngle": 30.0, + "spotlightOuterAngle": 45.0 + }, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "collider": { + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": -0.10000000149011612, + "z": 0.0 + }, + "parameters": { + "x": 30.0, + "y": 0.10000000149011612, + "z": 30.0 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "id": 494, + "name": { + "name": "demo_floor" + }, + "renderable": { + "meshName": "DemoFloorPlaneB", + "visible": true + }, + "rigidBody": { + "bodyType": "static", + "enabled": true, + "friction": 0.800000011920929, + "isSensor": false, + "mass": 1.0, + "restitution": 0.0 + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "actuator": { + "actionNames": [ + "goto_scene_a" + ], + "height": 1.7999999523162842, + "radius": 1.5 + }, + "children": [], + "id": 495, + "name": { + "name": "portal_b" + }, + "renderable": { + "meshName": "DemoActuatorMarkerB", + "visible": true + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.800000011920929, + "z": 28.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "characterSpawner": { + "despawnDistance": 200.0, + "registryId": 2, + "spawnDistance": 100.0 + }, + "children": [], + "id": 496, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": -3.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 497, + "name": { + "name": "player" + }, + "playerController": { + "actuatorColor": [ + 0.0, + 0.4000000059604645, + 1.0 + ], + "actuatorCooldown": 1.5, + "actuatorDistance": 25.0, + "actuatorLabelFontSize": 12.0, + "cameraMode": 0, + "distantCircleRadius": 8.0, + "fpsBoneName": "Head", + "idleState": "idle", + "locomotionStateMachine": "locomotion", + "mouseSensitivity": 0.20000000298023224, + "nearCircleRadius": 14.0, + "runState": "running", + "swimFastState": "swimming-fast", + "swimIdleState": "swim-idle", + "swimState": "swimming", + "targetCharacterName": "s1", + "tpsDistance": 3.0, + "tpsHeight": 2.0, + "walkState": "walking" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "version": "1.0" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-scene-switching-extra/imgui.ini b/src/features/editScene/demos/demo-scene-switching-extra/imgui.ini new file mode 100644 index 0000000..c0edfb3 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/imgui.ini @@ -0,0 +1,131 @@ +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +LastUsed=20260908 + +[Window][Entity Hierarchy] +Pos=0,0 +Size=300,1043 +LastUsed=20260908 + +[Window][Property Editor] +Pos=1570,0 +Size=350,1043 +LastUsed=20260908 + +[Window][Load Scene] +Pos=710,321 +Size=500,400 +LastUsed=20260908 + +[Window][Save Scene] +Pos=710,321 +Size=500,400 +LastUsed=20260908 + +[Window][StartupMenu] +Pos=0,0 +Size=1920,1043 + +[Window][Prefab Browser] +Pos=300,300 +Size=250,400 + +[Window][Create Prefab] +Pos=655,364 +Size=305,77 + +[Window][3D Cursor] +Pos=300,100 +Size=280,350 + +[Window][Mesh Browser] +Pos=533,240 +Size=416,406 + +[Window][Action Database (Singleton)] +Pos=326,33 +Size=404,694 + +[Window][Dialogue Settings] +Pos=300,100 +Size=400,500 + +[Window][DialogueBox] +Pos=0,681 +Size=1682,227 + +[Window][Character Class Database] +Pos=303,109 +Size=600,600 + +[Window][Character Registry] +Pos=476,258 +Size=903,429 + +[Window][Delete Prefab] +Pos=797,467 +Size=325,109 + +[Window][PauseMenu] +Pos=0,0 +Size=2490,1536 +LastUsed=20260905 + +[Window][Item Registry] +Pos=60,60 +Size=600,500 + +[Window][Inventory Dialog Config] +Pos=300,100 +Size=350,200 + +[Window][Character Sheet] +Pos=0,0 +Size=1920,1043 + +[Window][Load Game] +Pos=710,321 +Size=500,400 + +[Window][Save Game] +Pos=710,321 +Size=500,400 + +[Window][Animation Tree Registry] +Pos=60,60 +Size=900,600 + +[Window][Confirm Blend Map Resolution Change] +Pos=815,477 +Size=290,105 + +[Window][Confirm Heightmap Resolution Change] +Pos=815,477 +Size=290,105 + +[Window][Road Graph Invalid] +Pos=892,217 +Size=136,127 +LastUsed=20260731 + +[Window][Wedge Geometry Debug] +Pos=10,10 +Size=420,520 +LastUsed=20260814 + +[Window][Road Graph Valid] +Pos=882,486 +Size=156,71 +LastUsed=20260821 + +[Window][Switch Scene] +Pos=710,321 +Size=500,400 +LastUsed=20260906 + +[Window][Open Project] +Pos=300,100 +Size=639,377 +LastUsed=20260908 + diff --git a/src/features/editScene/demos/demo-scene-switching-extra/inventory_config.json b/src/features/editScene/demos/demo-scene-switching-extra/inventory_config.json new file mode 100644 index 0000000..dc54a49 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/inventory_config.json @@ -0,0 +1,4 @@ +{ + "fontPath": "Jupiteroid-Bold.ttf", + "fontSize": 16.0 +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-scene-switching-extra/project.h.in b/src/features/editScene/demos/demo-scene-switching-extra/project.h.in new file mode 100644 index 0000000..3bb789a --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/project.h.in @@ -0,0 +1,7 @@ +#pragma once +/* Generated by CMake from project.json - do not edit. + * Embeds the project parameters into the release binary so it runs its + * project with no --project flag (see GameFeatures202609.md, F8). */ +#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@" +#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@" +#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@ diff --git a/src/features/editScene/demos/demo-scene-switching-extra/project.json b/src/features/editScene/demos/demo-scene-switching-extra/project.json new file mode 100644 index 0000000..ccd1311 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/project.json @@ -0,0 +1,5 @@ +{ + "appName": "demo-scene-switching-extra", + "startScene": "demo_scene_a.json", + "gameMode": true +} diff --git a/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake b/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake index 2a6041f..f70978e 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake +++ b/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake @@ -15,6 +15,7 @@ # staging) at least once. file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}") +file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}") foreach(f demo_scene_a.json demo_scene_b.json) set(link "${DEMO_DIR}/${f}") diff --git a/src/features/editScene/docs/SaveLoadSystem.md b/src/features/editScene/docs/SaveLoadSystem.md index ece9511..17b45a5 100644 --- a/src/features/editScene/docs/SaveLoadSystem.md +++ b/src/features/editScene/docs/SaveLoadSystem.md @@ -34,13 +34,16 @@ menu or startup menu. ### Save File Location -Saves are stored in an OS-dependent user data directory: +Saves are stored in an OS-dependent user data directory. The `` +path component defaults to `World2` and is replaced by the per-project +`appName` when a project directory is opened (`--project`, embedded +`project.h` in a release binary, or File -> Open Project...): | Platform | Path | |----------|------| -| Linux | `~/.local/share/World2/saves/` (or `$XDG_DATA_HOME/World2/saves/`) | -| Windows | `%APPDATA%/World2/saves/` | -| macOS | `~/Library/Application Support/World2/saves/` | +| Linux | `~/.local/share//saves/` (or `$XDG_DATA_HOME//saves/`) | +| Windows | `%APPDATA%//saves/` | +| macOS | `~/Library/Application Support//saves/` | Each save is a single `.json` file named `save_NNN.json`. @@ -61,6 +64,7 @@ Each save is a single `.json` file named `save_NNN.json`. "characterRegistry": { ... }, "containerState": { ... }, "itemState": { ... }, + "globalState": { ... }, "runtimeEntities": [ ... ], "characterRuntimeData": { ... }, "luaData": { ... } @@ -76,6 +80,7 @@ Each save is a single `.json` file named `save_NNN.json`. | `characterRegistry` | object | Serialized `CharacterRegistry` (stats, skills, needs, levels, XP) | | `containerState` | object | Serialized `ContainerStateRegistry` (chest/loot contents) | | `itemState` | object | Serialized `ItemStateRegistry` (world item pickup state) | +| `globalState` | object | Serialized `GlobalStateStore` (typed global variables, F9). Missing in old saves: the store falls back to system defaults | | `runtimeEntities` | array | Runtime-spawned entities (dropped items, etc.) | | `characterRuntimeData` | object | Runtime component overrides per character (inventory, GOAP state, animation state) | | `luaData` | object | Data collected from Lua save callbacks | @@ -258,6 +263,7 @@ EditorApp::saveGame(slotPath, slotName) ├── Serialize CharacterRegistry ├── Serialize ContainerStateRegistry ├── Serialize ItemStateRegistry + ├── Serialize GlobalStateStore (globalState section) ├── Serialize runtime entities (dropped items, etc.) ├── Serialize character runtime component overrides ├── Collect Lua save callback data @@ -290,6 +296,7 @@ EditorApp::loadGame(slotPath) ├── Restore CharacterRegistry ├── Restore ContainerStateRegistry ├── Restore ItemStateRegistry + ├── Restore GlobalStateStore (defaults when the save has no globalState) ├── Destroy all existing character entities ├── Spawn persistent characters from registry ├── Restore character runtime component overrides @@ -318,6 +325,50 @@ Persists container slot overrides keyed by `containerId`. Auto-saves to Persists world item state (picked up / disabled) keyed by `instanceId`. Auto-saves to `item_state.json`. +### GlobalStateStore (F9) + +Generic typed key-value storage for gameplay systems: string name + +`bool` / `int64` / `double` / `string` value, accessible from C++ +(`GlobalStateStore::getInstance()`) and Lua (`ecs.global.*`). Persisted in +the save file's `globalState` section; auto-saves to `global_state.json` +in game mode (the cross-session cache). + +Key semantics: + +- Systems declare defaults with `declareDefault()`; reading an unset + variable returns the declared default (or the caller's fallback). + Defaults are **not** serialized - only explicitly `set()` values are. +- Keys are dot-namespaced; each system owns a prefix registered in + `AGENTS.md`. Door state (F6): `door..locked`, + `door..isOpen`. +- **Game mode:** startup loads `global_state.json`; `startNewGame()` resets + to defaults; `loadGame()` restores the save's `globalState` (old saves + without it load as defaults). +- **Editor mode:** the store is `clearToDefaults()` at startup and on scene + (re)load (`EditorUISystem::loadScene`, `EditorApp::openProject`), the + cache file is never loaded and auto-save is disabled, so a previous game + session never leaks into the edited scene. + +Lua API: + +```lua +ecs.global.set(name, value) -- boolean/integer/float/string; nil removes +ecs.global.get_bool(name [, default]) -- typed reads +ecs.global.get_int(name [, default]) +ecs.global.get_float(name [, default]) +ecs.global.get_string(name [, default]) +ecs.global.has(name) +ecs.global.remove(name) +``` + +See `lua-examples/global_state_example.lua`. + +Door persistence (F6) builds on this: `door..locked` / +`door..isOpen`, managed by `DoorSystem` (see AGENTS.md "Persistent +door state (F6)"), with the Lua wrappers `ecs.door.is_locked/is_open/ +lock/unlock` (`lua/LuaDoorApi.cpp`, example +`lua-examples/door_lock_example.lua`). + --- ## Runtime Entities diff --git a/src/features/editScene/lua-examples/door_lock_example.lua b/src/features/editScene/lua-examples/door_lock_example.lua new file mode 100644 index 0000000..1608978 --- /dev/null +++ b/src/features/editScene/lua-examples/door_lock_example.lua @@ -0,0 +1,58 @@ +-- F6: persistent door state - locked doors, events, key items. +-- +-- Persistent door state lives in the global storage under +-- "door..locked" (bool) and "door..isOpen" (bool) +-- where doorId is the F0 global door ID ":" (see the +-- Doors panel of the CellGrid editor - the door ID is shown there and can +-- be copied). Doors without persistent/lockable/scene-switch config are +-- ephemeral (empty doorId) and never touch the store. +-- +-- Event contract (EventBus, usable from C++ and Lua): +-- door_locked_ sent when a player (or NPC) bumps into a +-- door_locked locked door; params: door_id, entity_id. +-- The generic variant is for handlers managing +-- many doors. +-- door_unlock_ send to unlock one door. +-- door_unlock generic unlock; params: door_id. +-- door_unlocked_ notification after a door was unlocked. +-- door_unlocked generic notification; params: door_id. +-- +-- The door never opens by itself on unlock - the player presses E again. +-- A door configured with keyItemId is unlocked by E when the player has +-- the item in the inventory (keys are NOT consumed); holding E on a +-- lockable door offers Close/Lock in the action menu. + +local door_id = "11111111-2222-3333-4444-555555555555:Z:0:0:8" + +-- Reading state (also works for doors in scenes that are not loaded): +if ecs.door.is_locked(door_id) then + print("door is locked") +end +print("door open: " .. tostring(ecs.door.is_open(door_id))) + +-- Direct control (thin wrappers over ecs.global.set on the door keys): +-- ecs.door.lock(door_id) +-- ecs.door.unlock(door_id) +-- Equivalent low-level form: +-- ecs.global.set("door." .. door_id .. ".locked", true) + +-- Example: a quest-gated door. The first bump tells the player what is +-- needed; once the quest flag is set in the global storage, the next bump +-- unlocks the door. +ecs.subscribe_event("door_locked_" .. door_id, function(event, params) + if ecs.global.get_bool("quest.found_cellar_key", false) then + print("the cellar key fits - the door clicks open") + ecs.send_event("door_unlock_" .. door_id) + else + print("the door is locked - find the cellar key") + -- (show a dialogue, play a sound, ...) + end +end) + +-- Somewhere in the quest script: +-- ecs.global.set("quest.found_cellar_key", true) + +-- Unlock notification (e.g. to award XP or advance a quest): +ecs.subscribe_event("door_unlocked", function(event, params) + print("unlocked: " .. tostring(params.door_id)) +end) diff --git a/src/features/editScene/lua-examples/global_state_example.lua b/src/features/editScene/lua-examples/global_state_example.lua new file mode 100644 index 0000000..10aac9c --- /dev/null +++ b/src/features/editScene/lua-examples/global_state_example.lua @@ -0,0 +1,81 @@ +-- ============================================================================= +-- Global Persistent Storage Lua API Examples (F9) +-- ============================================================================= +-- The ecs.global API gives scripts access to the GlobalStateStore: a +-- scene-independent singleton of typed variables (boolean, integer, float, +-- string) shared with C++ and persisted in the save file's "globalState" +-- section. +-- +-- Semantics: +-- - Values set here are readable from C++ (GlobalStateStore::getInstance()) +-- and vice versa. +-- - The store survives scene switches. +-- - Game mode: variables persist across sessions via the save file and the +-- global_state.json cache; a new game starts from system defaults. +-- - Editor mode: the store is reset to defaults on startup and on scene +-- (re)load, so edited scenes always start from a clean state. +-- +-- Naming convention: dot-namespaced keys; each gameplay system owns a +-- prefix (e.g. doors use "door..locked" / "door..isOpen", +-- see AGENTS.md). +-- ============================================================================= + +-- ============================================================================= +-- Setting Variables +-- ============================================================================= +-- The type is inferred from the Lua value: boolean, integer number, float +-- number or string. Setting nil removes the variable. + +ecs.global.set("quest.main.started", true) -- boolean +ecs.global.set("quest.main.stage", 2) -- integer +ecs.global.set("player.reputation", 0.75) -- float +ecs.global.set("world.last_scene", "town.json") -- string + +-- ============================================================================= +-- Typed Reads +-- ============================================================================= +-- Explicit getters avoid Lua's single-number-type ambiguity: get_int only +-- reads integer variables, get_float only float variables. The optional +-- second argument is the fallback returned when the variable is unset (or +-- has a different type). + +local started = ecs.global.get_bool("quest.main.started") -- false default +local stage = ecs.global.get_int("quest.main.stage", 1) -- fallback 1 +local rep = ecs.global.get_float("player.reputation", 0.0) +local scene = ecs.global.get_string("world.last_scene", "none") + +-- An int is NOT readable as float and vice versa (fallback kicks in): +assert(ecs.global.get_float("quest.main.stage", -1.0) == -1.0) + +-- ============================================================================= +-- Existence and Removal +-- ============================================================================= + +if ecs.global.has("quest.main.started") then + print("Main quest state exists") +end + +ecs.global.remove("player.reputation") +ecs.global.set("world.last_scene", nil) -- same as remove() + +-- ============================================================================= +-- Door State Example (F6 consumer) +-- ============================================================================= +-- Persistent doors store their state under the "door." prefix, keyed by +-- the door's global ID (shown in the Cell Grid editor's Doors panel): +-- +-- door..locked (boolean) +-- door..isOpen (boolean) +-- +-- Because the state is a plain global variable, scripts can lock/unlock +-- doors directly: + +local doorId = "3f2a1b4c-....:X:3:0:1" -- copy from the editor Doors panel + +-- Unlock a door: +ecs.global.set("door." .. doorId .. ".locked", false) + +-- Check whether a door is open: +if ecs.global.get_bool("door." .. doorId .. ".isOpen") then + print("The door is open") +end diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index fff9d6d..aa0ae35 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -55,6 +55,7 @@ #include "components/PathFollowing.hpp" #include "components/EventHandler.hpp" #include "components/SceneScript.hpp" +#include "components/StandaloneDoor.hpp" #include "components/Item.hpp" #include "components/Inventory.hpp" #include "components/GeneratedPhysicsTag.hpp" @@ -843,6 +844,8 @@ static void registerAllComponents() lua_setfield(L, -2, "enabled"); lua_pushboolean(L, c.debugDraw ? 1 : 0); lua_setfield(L, -2, "debugDraw"); + lua_pushnumber(L, c.doorAreaCost); + lua_setfield(L, -2, "doorAreaCost"); , if (lua_getfield(L, idx, "cellSize"), lua_isnumber(L, -1)) c.cellSize = (float)lua_tonumber(L, -1); lua_pop(L, 1); @@ -866,6 +869,9 @@ static void registerAllComponents() lua_pop(L, 1); if (lua_getfield(L, idx, "debugDraw"), lua_isboolean(L, -1)) c.debugDraw = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorAreaCost"), lua_isnumber(L, -1)) + c.doorAreaCost = (float)lua_tonumber(L, -1); lua_pop(L, 1);); // --- NavMeshAgent --- @@ -1422,12 +1428,57 @@ static void registerAllComponents() lua_setfield(L, -2, "doorOpenAngle"); lua_pushnumber(L, c.doorOpenSpeed); lua_setfield(L, -2, "doorOpenSpeed"); + lua_pushboolean(L, c.doorSwingReversed ? 1 : 0); + lua_setfield(L, -2, "doorSwingReversed"); lua_pushstring(L, c.doorActionName.c_str()); lua_setfield(L, -2, "doorActionName"); lua_pushstring(L, c.doorSceneSwitchPath.c_str()); lua_setfield(L, -2, "doorSceneSwitchPath"); lua_pushstring(L, c.doorSceneSwitchTarget.c_str()); lua_setfield(L, -2, "doorSceneSwitchTarget"); + lua_pushstring(L, c.gridUid.c_str()); + lua_setfield(L, -2, "gridUid"); + lua_pushstring(L, c.generationMode.c_str()); + lua_setfield(L, -2, "generationMode"); + pushColourValue(L, c.glassColor); + lua_setfield(L, -2, "glassColor"); + lua_pushstring(L, c.glassMaterialName.c_str()); + lua_setfield(L, -2, "glassMaterialName"); + lua_pushnumber(L, c.glassReflectivity); + lua_setfield(L, -2, "glassReflectivity"); + lua_newtable(L); + for (const auto &pair : c.doorConfigs) { + const CellGridDoorConfig &dc = pair.second; + lua_newtable(L); + lua_pushstring(L, dc.label.c_str()); + lua_setfield(L, -2, "label"); + lua_pushboolean(L, dc.hasOverride ? 1 : 0); + lua_setfield(L, -2, "hasOverride"); + lua_pushnumber(L, dc.openAngle); + lua_setfield(L, -2, "openAngle"); + lua_pushnumber(L, dc.openSpeed); + lua_setfield(L, -2, "openSpeed"); + lua_pushboolean(L, dc.swingReversed ? 1 : 0); + lua_setfield(L, -2, "swingReversed"); + lua_pushstring(L, dc.actionName.c_str()); + lua_setfield(L, -2, "actionName"); + lua_pushstring(L, dc.sceneSwitchPath.c_str()); + lua_setfield(L, -2, "sceneSwitchPath"); + lua_pushstring(L, dc.sceneSwitchTarget.c_str()); + lua_setfield(L, -2, "sceneSwitchTarget"); + lua_pushboolean(L, dc.disabled ? 1 : 0); + lua_setfield(L, -2, "disabled"); + lua_pushboolean(L, dc.persistent ? 1 : 0); + lua_setfield(L, -2, "persistent"); + lua_pushboolean(L, dc.lockable ? 1 : 0); + lua_setfield(L, -2, "lockable"); + lua_pushboolean(L, dc.lockedByDefault ? 1 : 0); + lua_setfield(L, -2, "lockedByDefault"); + lua_pushstring(L, dc.keyItemId.c_str()); + lua_setfield(L, -2, "keyItemId"); + lua_setfield(L, -2, pair.first.c_str()); + } + lua_setfield(L, -2, "doorConfigs"); , if (lua_getfield(L, idx, "width"), lua_isnumber(L, -1)) c.width = (int)lua_tointeger(L, -1); lua_pop(L, 1); @@ -1462,6 +1513,10 @@ static void registerAllComponents() if (lua_getfield(L, idx, "doorOpenSpeed"), lua_isnumber(L, -1)) c.doorOpenSpeed = (float)lua_tonumber(L, -1); lua_pop(L, 1); + if (lua_getfield(L, idx, "doorSwingReversed"), + lua_isboolean(L, -1)) + c.doorSwingReversed = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); if (lua_getfield(L, idx, "doorActionName"), lua_isstring(L, -1)) c.doorActionName = lua_tostring(L, -1); lua_pop(L, 1); @@ -1472,8 +1527,233 @@ static void registerAllComponents() if (lua_getfield(L, idx, "doorSceneSwitchTarget"), lua_isstring(L, -1)) c.doorSceneSwitchTarget = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "gridUid"), lua_isstring(L, -1)) + c.gridUid = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "generationMode"), lua_isstring(L, -1)) + c.generationMode = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "glassColor"), lua_istable(L, -1)) + c.glassColor = readColourValue(L, lua_gettop(L)); + lua_pop(L, 1); + if (lua_getfield(L, idx, "glassMaterialName"), + lua_isstring(L, -1)) + c.glassMaterialName = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "glassReflectivity"), + lua_isnumber(L, -1)) + c.glassReflectivity = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorConfigs"), lua_istable(L, -1)) { + c.doorConfigs.clear(); + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + if (lua_isstring(L, -2) && lua_istable(L, -1)) { + CellGridDoorConfig dc; + if (lua_getfield(L, -1, "label"), + lua_isstring(L, -1)) + dc.label = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "hasOverride"), + lua_isboolean(L, -1)) + dc.hasOverride = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, "openAngle"), + lua_isnumber(L, -1)) + dc.openAngle = + (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "openSpeed"), + lua_isnumber(L, -1)) + dc.openSpeed = + (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "swingReversed"), + lua_isboolean(L, -1)) + dc.swingReversed = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, "actionName"), + lua_isstring(L, -1)) + dc.actionName = + lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, + "sceneSwitchPath"), + lua_isstring(L, -1)) + dc.sceneSwitchPath = + lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, + "sceneSwitchTarget"), + lua_isstring(L, -1)) + dc.sceneSwitchTarget = + lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "disabled"), + lua_isboolean(L, -1)) + dc.disabled = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, "persistent"), + lua_isboolean(L, -1)) + dc.persistent = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, "lockable"), + lua_isboolean(L, -1)) + dc.lockable = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, + "lockedByDefault"), + lua_isboolean(L, -1)) + dc.lockedByDefault = + lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, -1, "keyItemId"), + lua_isstring(L, -1)) + dc.keyItemId = + lua_tostring(L, -1); + lua_pop(L, 1); + c.doorConfigs[lua_tostring(L, -2)] = dc; + } + lua_pop(L, 1); + } + } lua_pop(L, 1);); + // --- StandaloneDoor (F2) --- + REGISTER_COMPONENT( + StandaloneDoorComponent, "StandaloneDoor", + lua_pushstring(L, c.meshName.c_str()); + lua_setfield(L, -2, "meshName"); + lua_pushboolean(L, c.useMeshMaterial ? 1 : 0); + lua_setfield(L, -2, "useMeshMaterial"); + lua_pushstring(L, c.rectName.c_str()); + lua_setfield(L, -2, "rectName"); + lua_pushnumber(L, c.leafWidth); + lua_setfield(L, -2, "leafWidth"); + lua_pushnumber(L, c.leafHeight); + lua_setfield(L, -2, "leafHeight"); + lua_pushnumber(L, c.leafThickness); + lua_setfield(L, -2, "leafThickness"); + lua_pushnumber(L, c.openAngle); + lua_setfield(L, -2, "openAngle"); + lua_pushnumber(L, c.openSpeed); + lua_setfield(L, -2, "openSpeed"); + lua_pushboolean(L, c.swingReversed ? 1 : 0); + lua_setfield(L, -2, "swingReversed"); + lua_pushstring(L, c.actionName.c_str()); + lua_setfield(L, -2, "actionName"); + lua_pushstring(L, c.sceneSwitchPath.c_str()); + lua_setfield(L, -2, "sceneSwitchPath"); + lua_pushstring(L, c.sceneSwitchTarget.c_str()); + lua_setfield(L, -2, "sceneSwitchTarget"); + lua_pushboolean(L, c.persistent ? 1 : 0); + lua_setfield(L, -2, "persistent"); + lua_pushboolean(L, c.lockable ? 1 : 0); + lua_setfield(L, -2, "lockable"); + lua_pushboolean(L, c.lockedByDefault ? 1 : 0); + lua_setfield(L, -2, "lockedByDefault"); + lua_pushstring(L, c.keyItemId.c_str()); + lua_setfield(L, -2, "keyItemId"); + lua_pushstring(L, c.doorId.c_str()); + lua_setfield(L, -2, "doorId"); + , bool changed = false; + if (lua_getfield(L, idx, "meshName"), lua_isstring(L, -1)) { + c.meshName = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "useMeshMaterial"), lua_isboolean(L, -1)) { + c.useMeshMaterial = lua_toboolean(L, -1) != 0; + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "rectName"), lua_isstring(L, -1)) { + c.rectName = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "leafWidth"), lua_isnumber(L, -1)) { + c.leafWidth = (float)lua_tonumber(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "leafHeight"), lua_isnumber(L, -1)) { + c.leafHeight = (float)lua_tonumber(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "leafThickness"), lua_isnumber(L, -1)) { + c.leafThickness = (float)lua_tonumber(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "openAngle"), lua_isnumber(L, -1)) { + c.openAngle = (float)lua_tonumber(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "openSpeed"), lua_isnumber(L, -1)) { + c.openSpeed = (float)lua_tonumber(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "swingReversed"), lua_isboolean(L, -1)) { + c.swingReversed = lua_toboolean(L, -1) != 0; + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "actionName"), lua_isstring(L, -1)) { + c.actionName = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "sceneSwitchPath"), + lua_isstring(L, -1)) { + c.sceneSwitchPath = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "sceneSwitchTarget"), + lua_isstring(L, -1)) { + c.sceneSwitchTarget = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "persistent"), lua_isboolean(L, -1)) { + c.persistent = lua_toboolean(L, -1) != 0; + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "lockable"), lua_isboolean(L, -1)) { + c.lockable = lua_toboolean(L, -1) != 0; + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "lockedByDefault"), + lua_isboolean(L, -1)) { + c.lockedByDefault = lua_toboolean(L, -1) != 0; + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "keyItemId"), lua_isstring(L, -1)) { + c.keyItemId = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorId"), lua_isstring(L, -1)) { + c.doorId = lua_tostring(L, -1); + changed = true; + } + lua_pop(L, 1); + if (changed) + c.dirty = true;); + // --- Room --- REGISTER_COMPONENT( RoomComponent, "Room", lua_pushinteger(L, c.minX); diff --git a/src/features/editScene/lua/LuaDoorApi.cpp b/src/features/editScene/lua/LuaDoorApi.cpp new file mode 100644 index 0000000..d385920 --- /dev/null +++ b/src/features/editScene/lua/LuaDoorApi.cpp @@ -0,0 +1,61 @@ +#include "LuaDoorApi.hpp" +#include "../systems/DoorSystem.hpp" +#include "../systems/GlobalStateStore.hpp" + +namespace editScene +{ + +static int luaDoorIsLocked(lua_State *L) +{ + const char *doorId = luaL_checkstring(L, 1); + lua_pushboolean(L, DoorSystem::isDoorLockedById(doorId) ? 1 : 0); + return 1; +} + +static int luaDoorIsOpen(lua_State *L) +{ + const char *doorId = luaL_checkstring(L, 1); + lua_pushboolean(L, GlobalStateStore::getInstance().getBool( + std::string("door.") + doorId + ".isOpen") ? + 1 : + 0); + return 1; +} + +static int luaDoorLock(lua_State *L) +{ + const char *doorId = luaL_checkstring(L, 1); + DoorSystem::setDoorLocked(doorId, true); + return 0; +} + +static int luaDoorUnlock(lua_State *L) +{ + const char *doorId = luaL_checkstring(L, 1); + DoorSystem::setDoorLocked(doorId, false); + return 0; +} + +void registerLuaDoorApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_newtable(L); + lua_pushcfunction(L, luaDoorIsLocked); + lua_setfield(L, -2, "is_locked"); + lua_pushcfunction(L, luaDoorIsOpen); + lua_setfield(L, -2, "is_open"); + lua_pushcfunction(L, luaDoorLock); + lua_setfield(L, -2, "lock"); + lua_pushcfunction(L, luaDoorUnlock); + lua_setfield(L, -2, "unlock"); + lua_setfield(L, -2, "door"); + + lua_pop(L, 1); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaDoorApi.hpp b/src/features/editScene/lua/LuaDoorApi.hpp new file mode 100644 index 0000000..ed6cc16 --- /dev/null +++ b/src/features/editScene/lua/LuaDoorApi.hpp @@ -0,0 +1,30 @@ +#ifndef EDITSCENE_LUA_DOOR_API_HPP +#define EDITSCENE_LUA_DOOR_API_HPP +#pragma once + +#include + +namespace editScene +{ + +/** + * @brief Register the door persistence Lua API (F6). + * + * Adds the "ecs.door" table: + * ecs.door.is_locked(doorId) -- persistent locked state + * ecs.door.is_open(doorId) -- persisted open/closed state + * ecs.door.lock(doorId) -- set the locked state + * ecs.door.unlock(doorId) -- clear it (fires door_unlocked_* events) + * + * All functions operate on the GlobalStateStore door keys + * ("door..locked" / ".isOpen"), so they work for doors in any + * scene, loaded or not. Combine with ecs.subscribe_event("door_locked_*") + * / ecs.send_event("door_unlock_") for scripted lock behaviour. + * + * @param L The Lua state (the "ecs" table must already exist). + */ +void registerLuaDoorApi(lua_State *L); + +} // namespace editScene + +#endif // EDITSCENE_LUA_DOOR_API_HPP diff --git a/src/features/editScene/lua/LuaGlobalStateApi.cpp b/src/features/editScene/lua/LuaGlobalStateApi.cpp new file mode 100644 index 0000000..c727a3c --- /dev/null +++ b/src/features/editScene/lua/LuaGlobalStateApi.cpp @@ -0,0 +1,116 @@ +#include "LuaGlobalStateApi.hpp" +#include "../systems/GlobalStateStore.hpp" + +namespace editScene +{ + +static int luaGlobalSet(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + GlobalStateStore &store = GlobalStateStore::getInstance(); + switch (lua_type(L, 2)) { + case LUA_TBOOLEAN: + store.set(name, lua_toboolean(L, 2) != 0); + break; + case LUA_TNUMBER: + if (lua_isinteger(L, 2)) + store.set(name, (int64_t)lua_tointeger(L, 2)); + else + store.set(name, (double)lua_tonumber(L, 2)); + break; + case LUA_TSTRING: + store.set(name, std::string(lua_tostring(L, 2))); + break; + case LUA_TNIL: + store.remove(name); + break; + default: + return luaL_error( + L, "ecs.global.set: value must be boolean, number, " + "string or nil"); + } + return 0; +} + +static int luaGlobalGetBool(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + bool fallback = lua_toboolean(L, 2) != 0; + lua_pushboolean(L, + GlobalStateStore::getInstance().getBool(name, fallback) ? + 1 : + 0); + return 1; +} + +static int luaGlobalGetInt(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + int64_t fallback = (int64_t)luaL_optinteger(L, 2, 0); + lua_pushinteger(L, (lua_Integer)GlobalStateStore::getInstance().getInt( + name, fallback)); + return 1; +} + +static int luaGlobalGetFloat(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + double fallback = luaL_optnumber(L, 2, 0.0); + lua_pushnumber(L, GlobalStateStore::getInstance().getFloat(name, + fallback)); + return 1; +} + +static int luaGlobalGetString(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + const char *fallback = luaL_optstring(L, 2, ""); + lua_pushstring(L, GlobalStateStore::getInstance() + .getString(name, fallback) + .c_str()); + return 1; +} + +static int luaGlobalHas(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + lua_pushboolean(L, GlobalStateStore::getInstance().has(name) ? 1 : 0); + return 1; +} + +static int luaGlobalRemove(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + GlobalStateStore::getInstance().remove(name); + return 0; +} + +void registerLuaGlobalStateApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return; + } + + lua_newtable(L); + lua_pushcfunction(L, luaGlobalSet); + lua_setfield(L, -2, "set"); + lua_pushcfunction(L, luaGlobalGetBool); + lua_setfield(L, -2, "get_bool"); + lua_pushcfunction(L, luaGlobalGetInt); + lua_setfield(L, -2, "get_int"); + lua_pushcfunction(L, luaGlobalGetFloat); + lua_setfield(L, -2, "get_float"); + lua_pushcfunction(L, luaGlobalGetString); + lua_setfield(L, -2, "get_string"); + lua_pushcfunction(L, luaGlobalHas); + lua_setfield(L, -2, "has"); + lua_pushcfunction(L, luaGlobalRemove); + lua_setfield(L, -2, "remove"); + lua_setfield(L, -2, "global"); + + lua_pop(L, 1); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaGlobalStateApi.hpp b/src/features/editScene/lua/LuaGlobalStateApi.hpp new file mode 100644 index 0000000..3713bbd --- /dev/null +++ b/src/features/editScene/lua/LuaGlobalStateApi.hpp @@ -0,0 +1,32 @@ +#ifndef EDITSCENE_LUA_GLOBALSTATE_API_HPP +#define EDITSCENE_LUA_GLOBALSTATE_API_HPP +#pragma once + +#include + +namespace editScene +{ + +/** + * @brief Register the global persistent storage Lua API (F9). + * + * Adds the "ecs.global" table: + * ecs.global.set(name, value) -- boolean/integer/float/string; + * nil removes the variable + * ecs.global.get_bool(name [, default]) -- typed reads + * ecs.global.get_int(name [, default]) + * ecs.global.get_float(name [, default]) + * ecs.global.get_string(name [, default]) + * ecs.global.has(name) + * ecs.global.remove(name) + * + * The backing store is the GlobalStateStore singleton, so variables are + * shared with C++ and persisted in the save file's "globalState" section. + * + * @param L The Lua state (the "ecs" table must already exist). + */ +void registerLuaGlobalStateApi(lua_State *L); + +} // namespace editScene + +#endif // EDITSCENE_LUA_GLOBALSTATE_API_HPP diff --git a/src/features/editScene/main.cpp b/src/features/editScene/main.cpp index 056b0b7..da1e698 100644 --- a/src/features/editScene/main.cpp +++ b/src/features/editScene/main.cpp @@ -1,6 +1,9 @@ #include +#include #include "EditorApp.hpp" +#include "ProjectConfig.hpp" #include "systems/SceneSerializer.hpp" +#include "systems/CharacterRegistry.hpp" #include "systems/TerrainTests.hpp" #include "OgreRoot.h" @@ -53,19 +56,28 @@ struct TerrainTestFrameListener : public Ogre::FrameListener { int main(int argc, char *argv[]) { try { - EditorApp app; - - // Parse command line arguments + // Parse command line arguments (project dir first: it + // changes the working directory and the app name before + // EditorApp is constructed) bool gameMode = false; + bool editorMode = false; bool debugBuoyancy = false; bool exitAfterFirstFrame = false; bool headless = false; int terrainTestIterations = 0; Ogre::String sceneFile; + Ogre::String projectDir; for (int i = 1; i < argc; i++) { Ogre::String arg = argv[i]; if (arg == "--game") { gameMode = true; + } else if (arg == "--editor") { + editorMode = true; + } else if (arg == "--project") { + if (i + 1 < argc) + projectDir = argv[++i]; + } else if (arg.find("--project=") == 0) { + projectDir = arg.substr(10); } else if (arg == "--debug-buoyancy") { debugBuoyancy = true; } else if (arg == "--exit-after-first-frame") { @@ -83,7 +95,38 @@ int main(int argc, char *argv[]) } } - if (gameMode) { + /* Project directory (F8): enter the project root before any + * OGRE initialization so every CWD-relative path + * (resources.cfg, config JSONs, scenes, prefabs, + * heightmaps/) resolves against it. */ + ProjectConfig projectConfig; + if (!projectDir.empty()) { + std::error_code ec; + std::filesystem::path abs = std::filesystem::absolute( + std::filesystem::path(projectDir), ec); + if (ec || !std::filesystem::is_directory(abs)) { + std::cerr << "--project: not a directory: " + << projectDir << std::endl; + return 1; + } + std::filesystem::current_path(abs, ec); + if (ec) { + std::cerr << "--project: cannot enter directory: " + << projectDir << std::endl; + return 1; + } + projectConfig = loadProjectConfig(abs.string()); + std::cout << "Project: " << projectConfig.appName + << " (" << abs.string() << ")" << std::endl; + } + + EditorApp app(projectConfig.appName.empty() ? + Ogre::String("EditSceneEditor") : + projectConfig.appName); + app.setProjectConfig(projectConfig); + + if (gameMode || + (projectConfig.gameMode && !editorMode)) { app.setGameMode(EditorApp::GameMode::Game); } @@ -98,6 +141,29 @@ int main(int argc, char *argv[]) app.initApp(); + if (headless && !CharacterRegistry::getSingletonPtr()) { + /* Headless mode never creates EditorUISystem, which + * owns the CharacterRegistry singleton; a scene with a + * character spawner asserts without it (same workaround + * as the demo mains). */ + static CharacterRegistry s_characterRegistry; + s_characterRegistry.setWorld(app.getWorld()); + s_characterRegistry.setSceneManager(app.getSceneManager()); + s_characterRegistry.initialize(); + } + + /* A game project with a start scene goes straight into it + * (no startup menu), like a release binary. Done after + * initApp() so main() can create programmatic resources + * first if it needs to. */ + bool gameSceneStarted = false; + if (app.getGameMode() == EditorApp::GameMode::Game && + projectConfig.gameMode && !projectConfig.startScene.empty() && + sceneFile.empty()) { + app.startNewGame(projectConfig.startScene); + gameSceneStarted = true; + } + // Use frame-driven terrain test if requested. // Terrain init requires an active render loop (GPU). TerrainTestFrameListener terrainTestListener( @@ -125,6 +191,12 @@ int main(int argc, char *argv[]) if (exitAfterFirstFrame) app.getRoot()->addFrameListener(&exitListener); app.getRoot()->startRendering(); + + /* Destroy scene entities while the systems are still alive + * (same teardown-ordering reason as the demo mains: spawner + * OnRemove observers dereference their systems). */ + if (gameSceneStarted) + app.clearScene(); app.closeApp(); /* Propagate terrain test result as process exit code so CI can diff --git a/src/features/editScene/recast/TileCacheNavMesh.cpp b/src/features/editScene/recast/TileCacheNavMesh.cpp index 7549c1c..da34209 100644 --- a/src/features/editScene/recast/TileCacheNavMesh.cpp +++ b/src/features/editScene/recast/TileCacheNavMesh.cpp @@ -116,6 +116,12 @@ struct MeshProcess : public dtTileCacheMeshProcess { if (polyAreas[i] == DT_TILECACHE_WALKABLE_AREA) { polyAreas[i] = SAMPLE_POLYAREA_GROUND; polyFlags[i] = SAMPLE_POLYFLAGS_WALK; + } else if (polyAreas[i] == + TileCacheNavMesh::EDITSCENE_AREA_DOOR) { + /* F7: keep the door area id (the query + * filter assigns it a higher traversal + * cost) but leave the poly walkable. */ + polyFlags[i] = SAMPLE_POLYFLAGS_WALK; } } } @@ -276,6 +282,10 @@ TileCacheNavMesh::TileCacheNavMesh(Ogre::SceneManager *sceneMgr, m_filter->setIncludeFlags(0xFFFF); m_filter->setExcludeFlags(0); m_filter->setAreaCost(SAMPLE_POLYAREA_GROUND, 1.0f); + m_filter->setAreaCost(EDITSCENE_AREA_DOOR, + m_params.doorAreaCost > 1.0f ? + m_params.doorAreaCost : + 1.0f); m_extents[0] = 32.0f; m_extents[1] = 32.0f; @@ -641,6 +651,21 @@ int TileCacheNavMesh::rasterizeTileLayers(int tx, int ty, TileCacheData *tiles, if (!rcErodeWalkableArea(m_ctx, tcfg.walkableRadius, *rc.chf)) return 0; + /* F7: paint doorway volumes with the door area id (after + * erosion so the doorway stays passable even though walls pinch + * it; the query filter's area cost, not erosion, deters + * pathing through doors). */ + for (const Ogre::AxisAlignedBox &vol : m_doorVolumes) { + if (vol.isNull() || !vol.isFinite()) + continue; + float vbmin[3] = { vol.getMinimum().x, vol.getMinimum().y, + vol.getMinimum().z }; + float vbmax[3] = { vol.getMaximum().x, vol.getMaximum().y, + vol.getMaximum().z }; + rcMarkBoxArea(m_ctx, vbmin, vbmax, EDITSCENE_AREA_DOOR, + *rc.chf); + } + if (!rcBuildDistanceField(m_ctx, *rc.chf)) return 0; @@ -779,6 +804,48 @@ void TileCacheNavMesh::getTileCoords(const float *pos, int &tx, int &ty) ty = m_th - 1; } +// ------------------------------------------------------------------ +// Door volumes and dynamic obstacles (F7) +// ------------------------------------------------------------------ + +void TileCacheNavMesh::setDoorVolumes( + const std::vector &volumes) +{ + m_doorVolumes = volumes; +} + +uint32_t TileCacheNavMesh::addObstacle(const Ogre::AxisAlignedBox &box) +{ + if (!m_tileCache || box.isNull() || !box.isFinite()) + return 0; + float bmin[3] = { box.getMinimum().x, box.getMinimum().y, + box.getMinimum().z }; + float bmax[3] = { box.getMaximum().x, box.getMaximum().y, + box.getMaximum().z }; + dtObstacleRef ref = 0; + dtStatus status = m_tileCache->addBoxObstacle(bmin, bmax, &ref); + if (dtStatusFailed(status) || !ref) + return 0; + return static_cast(ref); +} + +void TileCacheNavMesh::removeObstacle(uint32_t ref) +{ + if (!m_tileCache || !ref) + return; + m_tileCache->removeObstacle(static_cast(ref)); +} + +void TileCacheNavMesh::update() +{ + if (!m_tileCache || !m_navMesh) + return; + /* Processes queued obstacle add/remove requests (and the tile + * rebuilds they trigger). Must be pumped regularly while + * obstacles are in use. */ + m_tileCache->update(0, m_navMesh); +} + // ------------------------------------------------------------------ // Queries // ------------------------------------------------------------------ @@ -876,6 +943,29 @@ Ogre::Vector3 TileCacheNavMesh::getRandomPoint() return Ogre::Vector3(pt[0], pt[1], pt[2]); } +unsigned char TileCacheNavMesh::getAreaAt(const Ogre::Vector3 &pos) +{ + if (!m_navQuery || !m_navMesh) + return 0xFF; + + float p[3] = { pos.x, pos.y, pos.z }; + dtPolyRef ref = 0; + float nearest[3]; + dtStatus status = m_navQuery->findNearestPoly(p, m_extents, m_filter, + &ref, nearest); + if (dtStatusFailed(status) || !ref) + return 0xFF; + + const dtMeshTile *tile = nullptr; + const dtPoly *poly = nullptr; + if (dtStatusFailed( + m_navMesh->getTileAndPolyByRef(ref, &tile, &poly)) || + !poly) + return 0xFF; + + return poly->getArea(); +} + // ------------------------------------------------------------------ // Debug draw // ------------------------------------------------------------------ diff --git a/src/features/editScene/recast/TileCacheNavMesh.hpp b/src/features/editScene/recast/TileCacheNavMesh.hpp index ce05aed..cfc6fa8 100644 --- a/src/features/editScene/recast/TileCacheNavMesh.hpp +++ b/src/features/editScene/recast/TileCacheNavMesh.hpp @@ -26,6 +26,12 @@ class PartitionedMesh; */ class TileCacheNavMesh { public: + /* Area id painted on doorway volumes during tile rasterization + * (F7). Kept below DT_TILECACHE_WALKABLE_AREA (63); the mesh + * process keeps walk flags on these polys so paths remain possible + * but the query filter's area cost makes detours preferable. */ + static constexpr unsigned char EDITSCENE_AREA_DOOR = 1; + struct BuildParams { float cellSize = 0.3f; float cellHeight = 0.2f; @@ -41,6 +47,10 @@ public: float detailSampleDist = 6.0f; float detailSampleMaxError = 1.0f; int tileSize = 48; // voxels per tile + // F7: traversal cost multiplier for doorway polys + // (EDITSCENE_AREA_DOOR); > 1 makes paths prefer doorless + // routes but still allows doorways. + float doorAreaCost = 5.0f; }; TileCacheNavMesh(Ogre::SceneManager *sceneMgr, @@ -57,6 +67,22 @@ public: // --- Partial rebuild --- void rebuildTilesInArea(const Ogre::AxisAlignedBox &area); + // --- Door volumes (F7) --- + // Doorway volumes (closed-pose bounds of every door) are painted + // with EDITSCENE_AREA_DOOR during tile rasterization. Call before + // build()/rebuildTilesInArea() to (re)apply; tiles are only + // re-marked when (re)built, so call sites that move doors must + // dirty the affected tiles. + void setDoorVolumes(const std::vector &volumes); + + // --- Dynamic obstacles (F7: locked doors) --- + // DetourTileCache queues obstacle requests; update() must be + // called regularly (per frame) to process them. Returns 0 on + // failure. + uint32_t addObstacle(const Ogre::AxisAlignedBox &box); + void removeObstacle(uint32_t ref); + void update(); + // --- Queries --- bool findPath(const Ogre::Vector3 &start, const Ogre::Vector3 &end, @@ -65,6 +91,10 @@ public: Ogre::Vector3 &out); Ogre::Vector3 getRandomPoint(); + /* Area id of the nearest poly to pos (EDITSCENE_AREA_DOOR, + * ground 0, ...); 0xFF when nothing found. Debug/testing aid. */ + unsigned char getAreaAt(const Ogre::Vector3 &pos); + // --- Debug --- void drawNavMesh(); void clearDebugDraw(); @@ -80,6 +110,10 @@ private: float m_bmax[3]; std::unique_ptr m_partitionedMesh; + // F7: doorway volumes painted with EDITSCENE_AREA_DOOR during + // tile rasterization (world/render space, same as input geometry) + std::vector m_doorVolumes; + // Tile cache dtTileCacheAlloc *m_talloc; dtTileCacheCompressor *m_tcomp; diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index 74e50fa..cceee70 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -1,6 +1,8 @@ #include "ActuatorSystem.hpp" #include "../EditorApp.hpp" #include "BehaviorTreeSystem.hpp" +#include "DoorSystem.hpp" +#include "EventBus.hpp" #include "ItemSystem.hpp" #include "ItemRegistry.hpp" #include "ItemStateRegistry.hpp" @@ -195,14 +197,27 @@ void ActuatorSystem::drawActionMenu(flecs::entity actuatorEntity) ImGui::Text("Select an action:"); ImGui::Separator(); - for (const auto &name : actuator.actionNames) { - if (name.empty()) - continue; - if (ImGui::Button( - name.c_str(), - ImVec2(ImGui::GetContentRegionAvail().x, - 0))) { - m_pendingActionName = name; + /* Door menus use the pseudo-actions captured when the menu + * was opened (F6); other actuators list their actions. */ + if (actuatorEntity.has()) { + for (const auto &name : m_doorMenuActions) { + if (ImGui::Button( + name.c_str(), + ImVec2(ImGui::GetContentRegionAvail().x, + 0))) { + m_pendingActionName = name; + } + } + } else { + for (const auto &name : actuator.actionNames) { + if (name.empty()) + continue; + if (ImGui::Button( + name.c_str(), + ImVec2(ImGui::GetContentRegionAvail().x, + 0))) { + m_pendingActionName = name; + } } } @@ -438,12 +453,16 @@ void ActuatorSystem::update(float deltaTime) // Doors toggle open/close on E const auto &door = targetEntity.get(); - // Scene switching doors never toggle - m_labelText = - (door.isOpen && - door.sceneSwitchPath.empty()) ? - "E Close" : - "E Open"; + if (DoorSystem::isDoorLocked(door)) { + m_labelText = "E Locked"; + } else { + // Scene switching doors never toggle + m_labelText = + (door.isOpen && + door.sceneSwitchPath.empty()) ? + "E Close" : + "E Open"; + } } else { auto &actuator = targetEntity.get(); @@ -476,8 +495,23 @@ void ActuatorSystem::update(float deltaTime) // Menu rendering happens in render() if (!m_pendingActionName.empty()) { - executeAction(playerCharacter, menuActuator, - m_pendingActionName); + if (menuActuator.is_alive() && + menuActuator.has()) { + /* Door menu pseudo-actions (F6) */ + auto &door = + menuActuator.get_mut(); + if (m_pendingActionName == "Lock") { + if (door.isOpen) + door.toggleRequested = true; + DoorSystem::setDoorLocked(door.doorId, + true); + } else if (m_pendingActionName == "Close") { + door.toggleRequested = true; + } + } else { + executeAction(playerCharacter, menuActuator, + m_pendingActionName); + } m_pendingActionName.clear(); m_menuOpen = false; m_menuActuatorId = 0; @@ -511,16 +545,59 @@ void ActuatorSystem::update(float deltaTime) // Doors toggle open/close on E and optionally // run the configured action; scene switching // doors queue a scene switch instead + auto &door = + targetEntity.get_mut(); if (input.ePressed) { - auto &door = - targetEntity.get_mut(); - if (!door.sceneSwitchPath.empty()) { - SceneSwitchOptions opts; - opts.targetEntityName = - door.sceneSwitchTarget; - m_editorApp->switchScene( - door.sceneSwitchPath, - opts); + /* Locked doors (F6): the configured key + * item unlocks (keys are not consumed); + * otherwise only the door_locked events + * are emitted. */ + bool locked = DoorSystem::isDoorLocked(door); + if (locked && !door.keyItemId.empty() && + playerCharacter.is_alive() && + playerCharacter + .has() && + playerCharacter + .get() + .hasItem(door.keyItemId)) { + DoorSystem::setDoorLocked( + door.doorId, false); + locked = false; + } + if (locked) { + editScene::EventParams params; + params.setString("door_id", + door.doorId); + params.setEntityId( + "entity_id", + targetEntity.id()); + EventBus &bus = + EventBus::getInstance(); + bus.send("door_locked_" + + door.doorId, + params); + bus.send("door_locked", params); + } else if (!door.sceneSwitchPath.empty()) { + /* F1: the door swings open + * first; DoorSystem fires the + * switch at full opening. An + * already-open door switches + * immediately. */ + if (door.isOpen && + door.currentAngle == + door.openAngle) { + SceneSwitchOptions opts; + opts.targetEntityName = + door.sceneSwitchTarget; + m_editorApp->switchScene( + door.sceneSwitchPath, + opts); + } else { + door.toggleRequested = + true; + door.sceneSwitchPending = + true; + } } else { door.toggleRequested = true; if (!actuator.actionNames @@ -535,6 +612,36 @@ void ActuatorSystem::update(float deltaTime) } } m_eHoldTime = 0.0f; + } else if (door.lockable && !door.doorId.empty() && + !DoorSystem::isDoorLocked(door) && + m_eHoldTime > 0.3f && !m_menuOpen) { + /* Hold E on a lockable door: door + * menu with Close / Lock (F6). */ + m_doorMenuActions.clear(); + if (door.isOpen && + door.sceneSwitchPath.empty()) + m_doorMenuActions.push_back( + "Close"); + bool hasKey = + door.keyItemId.empty() || + (playerCharacter.is_alive() && + playerCharacter.has< + InventoryComponent>() && + playerCharacter + .get() + .hasItem(door.keyItemId)); + if (hasKey) + m_doorMenuActions.push_back( + "Lock"); + if (!m_doorMenuActions.empty()) { + m_menuOpen = true; + m_menuActuatorId = + targetEntity.id(); + m_eWasHeld = true; + if (m_editorApp) + m_editorApp->setWindowGrab( + false); + } } } else if (actuator.actionNames.size() == 1 && !actuator.actionNames[0].empty()) { diff --git a/src/features/editScene/systems/ActuatorSystem.hpp b/src/features/editScene/systems/ActuatorSystem.hpp index f8e0333..69f1a11 100644 --- a/src/features/editScene/systems/ActuatorSystem.hpp +++ b/src/features/editScene/systems/ActuatorSystem.hpp @@ -91,6 +91,10 @@ private: bool m_eWasHeld = false; Ogre::String m_pendingActionName; + // Door menu (F6): pseudo-actions ("Close"/"Lock") shown when E is + // held on a lockable door; filled when the menu opens + std::vector m_doorMenuActions; + // Currently executing action state flecs::entity_t m_executingActuatorId = 0; flecs::entity_t m_executingCharacterId = 0; diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index 1f072a7..2916ce9 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -1,4 +1,6 @@ #include "CellGridSystem.hpp" +#include "DoorBuilder.hpp" +#include "DoorSystem.hpp" #include "FurnitureLibrary.hpp" #include "PhysicsSystem.hpp" #include "../components/CellGrid.hpp" @@ -390,13 +392,39 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, Procedural::TriangleBuffer floorTb, ceilingTb, extWallTb, intWallTb, extWindowsTb, intWindowsTb, extDoorsTb, intDoorsTb, roofTopTb, roofSideTb; - buildFloorsAndCeilings(grid, floorTb, ceilingTb); + /* F4: interiorOnly skips the exterior shell (external walls, external + * window panels + frames, roofs, external corners); exit doorway wall + * panels (extDoorsTb), exit door frames and all door entities stay, so + * a paired exterior scene can take over through scene-switch doors. + * F5: exteriorOnly generates only that shell (plus opaque glass panes + * in the external window openings): no floors/ceilings, internal + * walls/frames, internal door panels/entities or furniture. + * Both limited modes get opaque glass panes: exteriorOnly in every + * external window, interiorOnly in boundary windows (see + * buildGlass), hiding the missing half of the building. */ + const bool shell = !grid.interiorOnlyMode(); + const bool exteriorOnly = grid.exteriorOnlyMode(); + + Procedural::TriangleBuffer glassTb; + + if (!exteriorOnly) { + buildFloorsAndCeilings(grid, floorTb, ceilingTb); + } buildWalls(grid, extWallTb, intWallTb, intWindowsTb); - buildCorners(grid, extWallTb); - buildInternalCorners(grid, intWallTb); + if (shell) { + buildCorners(grid, extWallTb); + } + if (!exteriorOnly) { + buildInternalCorners(grid, intWallTb); + } buildDoors(grid, extDoorsTb, intDoorsTb); buildWindows(grid, extWindowsTb, intWindowsTb); - buildRoofs(entity, grid, roofTopTb, roofSideTb); + if (shell) { + buildRoofs(entity, grid, roofTopTb, roofSideTb); + } + if (exteriorOnly || grid.interiorOnlyMode()) { + buildGlass(grid, glassTb); + } // Apply UV mapping for each part (use rect if specified, otherwise default) Ogre::LogManager::getSingleton().logMessage( @@ -504,6 +532,17 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, meshData.entities.push_back(entity3d); } + // F5: opaque glass panes in the window openings (exteriorOnly: + // external windows; interiorOnly: boundary windows) + if (glassTb.getVertices().size() >= 3) { + meshData.glassMesh = baseName + "_glass"; + auto mesh = convertToMesh(meshData.glassMesh, glassTb, + getGlassMaterialName(entity, grid)); + auto entity3d = m_sceneMgr->createEntity(meshData.glassMesh); + transform.node->attachObject(entity3d); + meshData.entities.push_back(entity3d); + } + // External doors if (extDoorsTb.getVertices().size() >= 3) { meshData.doorMeshes.push_back(baseName + "_extDoors"); @@ -562,7 +601,8 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, " extDoors=" + std::to_string(extDoorsTb.getVertices().size()) + " intDoors=" + std::to_string(intDoorsTb.getVertices().size()) + " roofTop=" + std::to_string(roofTopTb.getVertices().size()) + - " roofSide=" + std::to_string(roofSideTb.getVertices().size())); + " roofSide=" + std::to_string(roofSideTb.getVertices().size()) + + " glass=" + std::to_string(glassTb.getVertices().size())); // Build window and door frames (creates StaticGeometry region) try { @@ -577,15 +617,18 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, } // Build furniture (non-isolated items go into the same StaticGeometry) - try { - buildFurniture(entity, grid); - } catch (const std::exception &e) { - Ogre::LogManager::getSingleton().logMessage( - "CellGrid: Error building furniture: " + - std::string(e.what())); - } catch (...) { - Ogre::LogManager::getSingleton().logMessage( - "CellGrid: Unknown error building furniture"); + // (F5: exteriorOnly has no interior, hence no furniture) + if (!exteriorOnly) { + try { + buildFurniture(entity, grid); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Error building furniture: " + + std::string(e.what())); + } catch (...) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Unknown error building furniture"); + } } // Build door leaf entities (one per unique doorway) @@ -775,11 +818,20 @@ void CellGridSystem::buildWalls(const CellGridComponent &grid, &intWallTb }, }; + const bool shell = !grid.interiorOnlyMode(); + const bool interior = !grid.exteriorOnlyMode(); + for (const auto &cell : grid.cells) { Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); uint64_t flags = cell.flags; for (const auto &bit : bits_solid) { + /* F4: interiorOnly generates no external wall planes. + * F5: exteriorOnly generates no internal wall planes. */ + if (!shell && bit.tb == &extWallTb) + continue; + if (!interior && bit.tb != &extWallTb) + continue; if ((flags & bit.bit) == bit.bit) { genPlane(bit.sizeX, bit.sizeY, bit.normal, bit.offset, *bit.tb, origin); @@ -1254,10 +1306,12 @@ void CellGridSystem::buildDoors(const CellGridComponent &grid, } // Internal doors - append directly to intDoorsTb - if (cell.hasFlag(CellFlags::IntDoorXNeg) || - cell.hasFlag(CellFlags::IntDoorXPos) || - cell.hasFlag(CellFlags::IntDoorZNeg) || - cell.hasFlag(CellFlags::IntDoorZPos)) { + // (F5: skipped entirely in exteriorOnly mode) + if (!grid.exteriorOnlyMode() && + (cell.hasFlag(CellFlags::IntDoorXNeg) || + cell.hasFlag(CellFlags::IntDoorXPos) || + cell.hasFlag(CellFlags::IntDoorZNeg) || + cell.hasFlag(CellFlags::IntDoorZPos))) { float sideWidth = (2.0f * hScale - 0.2f - doorWidth) / 2.0f; float moffset = @@ -1398,10 +1452,12 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); // External windows - append directly to extWindowsTb - if (cell.hasFlag(CellFlags::WindowXNeg) || - cell.hasFlag(CellFlags::WindowXPos) || - cell.hasFlag(CellFlags::WindowZNeg) || - cell.hasFlag(CellFlags::WindowZPos)) { + // (F4: skipped entirely in interiorOnly mode) + if (!grid.interiorOnlyMode() && + (cell.hasFlag(CellFlags::WindowXNeg) || + cell.hasFlag(CellFlags::WindowXPos) || + cell.hasFlag(CellFlags::WindowZNeg) || + cell.hasFlag(CellFlags::WindowZPos))) { // External cell width = 2.0 * hScale (full cell size) float externalCellWidth = 2.0f * hScale; float sideWidth = @@ -1700,10 +1756,12 @@ void CellGridSystem::buildWindows(const CellGridComponent &grid, } // Internal windows - append directly to intWindowsTb - if (cell.hasFlag(CellFlags::IntWindowXNeg) || - cell.hasFlag(CellFlags::IntWindowXPos) || - cell.hasFlag(CellFlags::IntWindowZNeg) || - cell.hasFlag(CellFlags::IntWindowZPos)) { + // (F5: skipped entirely in exteriorOnly mode) + if (!grid.exteriorOnlyMode() && + (cell.hasFlag(CellFlags::IntWindowXNeg) || + cell.hasFlag(CellFlags::IntWindowXPos) || + cell.hasFlag(CellFlags::IntWindowZNeg) || + cell.hasFlag(CellFlags::IntWindowZPos))) { float intWallOffset = 0.1f; // Internal cell width = 2.0 * hScale - 2 * intWallOffset (1.8f for cellSize=4.0) float internalCellWidth = @@ -2042,6 +2100,124 @@ static void deformRoofSideVertices(Procedural::TriangleBuffer &tb, } } +std::string CellGridSystem::getGlassMaterialName( + flecs::entity entity, const CellGridComponent &grid) +{ + if (!grid.glassMaterialName.empty()) + return grid.glassMaterialName; + + /* Built-in fake-glass material, one per grid entity so per-grid + * glassColor/glassReflectivity apply; updated in place on rebuild. */ + std::string name = "CellGridGlass_" + std::to_string(entity.id()); + auto &matMgr = Ogre::MaterialManager::getSingleton(); + Ogre::MaterialPtr mat = matMgr.getByName( + name, Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + if (!mat) { + mat = matMgr.create( + name, + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME); + } + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + float gloss = Ogre::Math::Clamp(grid.glassReflectivity, 0.0f, 1.0f); + /* Opaque on purpose: the glass exists to hide the missing half + * of a generation-limited grid (exteriorOnly: the empty interior; + * interiorOnly: the void outside), so it must not be + * see-through. A custom glassMaterialName can still override. */ + pass->setDepthCheckEnabled(true); + pass->setDepthWriteEnabled(true); + pass->setDiffuse(grid.glassColor.r, grid.glassColor.g, + grid.glassColor.b, 1.0f); + pass->setAmbient(grid.glassColor.r * 0.5f, grid.glassColor.g * 0.5f, + grid.glassColor.b * 0.5f); + pass->setSpecular(gloss, gloss, gloss, 1.0f); + pass->setShininess(16.0f + gloss * 112.0f); + mat->compile(); + return name; +} + +void CellGridSystem::buildGlass(const CellGridComponent &grid, + Procedural::TriangleBuffer &glassTb) +{ + /* One thin box pane per window opening, matching the dimensions + * used by buildWindows(). The pane becomes part of the static + * collider set (meshData.glassMesh), so the player cannot climb + * through windows. + * + * exteriorOnly: pane in every external window opening (the pane + * hides the empty interior). interiorOnly: pane in every + * BOUNDARY window - cells carrying both the internal and the + * matching external window flag - placed at the internal wall + * plane (there is no external wall in this mode; the pane hides + * the void outside). Pure interior windows between rooms get + * no glass. */ + const bool interiorOnly = grid.interiorOnlyMode(); + const float hScale = grid.cellSize / 2.0f; + const float halfCell = 1.0f * hScale; + const float cornerWidth = 0.2f; + const float intPlane = halfCell - 0.1f; + const float windowWidth = 1.6f * hScale; + const float windowHeight = 2.0f * (grid.cellHeight / 4.0f); + const float windowBottomOffset = 0.8f * (grid.cellHeight / 4.0f); + const float windowCenterY = windowBottomOffset + windowHeight / 2.0f; + const float thickness = 0.02f; + + for (const auto &cell : grid.cells) { + Ogre::Vector3 origin = grid.cellToWorld(cell.x, cell.y, cell.z); + + auto addPane = [&](const Ogre::Vector3 ¢er, bool alongX) { + Procedural::BoxGenerator() + .setSizeX(alongX ? thickness : windowWidth) + .setSizeY(windowHeight) + .setSizeZ(alongX ? windowWidth : thickness) + .setNumSegX(1) + .setNumSegY(1) + .setNumSegZ(1) + .setPosition(origin + center) + .setEnableNormals(true) + .addToTriangleBuffer(glassTb); + }; + + if (interiorOnly) { + if (cell.hasFlag(CellFlags::IntWindowXNeg) && + cell.hasFlag(CellFlags::WindowXNeg)) + addPane(Ogre::Vector3(-intPlane, windowCenterY, 0), + true); + if (cell.hasFlag(CellFlags::IntWindowXPos) && + cell.hasFlag(CellFlags::WindowXPos)) + addPane(Ogre::Vector3(intPlane, windowCenterY, 0), + true); + if (cell.hasFlag(CellFlags::IntWindowZNeg) && + cell.hasFlag(CellFlags::WindowZNeg)) + addPane(Ogre::Vector3(0, windowCenterY, + -intPlane), + false); + if (cell.hasFlag(CellFlags::IntWindowZPos) && + cell.hasFlag(CellFlags::WindowZPos)) + addPane(Ogre::Vector3(0, windowCenterY, + intPlane), + false); + continue; + } + + if (cell.hasFlag(CellFlags::WindowXNeg)) + addPane(Ogre::Vector3(-halfCell - cornerWidth, + windowCenterY, 0), + true); + if (cell.hasFlag(CellFlags::WindowXPos)) + addPane(Ogre::Vector3(halfCell + cornerWidth, + windowCenterY, 0), + true); + if (cell.hasFlag(CellFlags::WindowZNeg)) + addPane(Ogre::Vector3(0, windowCenterY, + -halfCell - cornerWidth), + false); + if (cell.hasFlag(CellFlags::WindowZPos)) + addPane(Ogre::Vector3(0, windowCenterY, + halfCell + cornerWidth), + false); + } +} + void CellGridSystem::buildRoofs(flecs::entity cellGridEntity, const CellGridComponent &grid, Procedural::TriangleBuffer &roofTopTb, @@ -2678,6 +2854,18 @@ void CellGridSystem::destroyCellGridMeshes(flecs::entity entity) removeMesh(it->second.intWindowsMesh); removeMesh(it->second.roofMesh); removeMesh(it->second.roofSideMesh); + removeMesh(it->second.glassMesh); + it->second.glassMesh.clear(); + + // Built-in F5 glass material (per grid entity) + auto &matMgr = Ogre::MaterialManager::getSingleton(); + std::string glassMat = + "CellGridGlass_" + std::to_string(entity.id()); + try { + if (matMgr.resourceExists(glassMat)) + matMgr.remove(glassMat); + } catch (...) { + } for (const auto &name : it->second.doorMeshes) { removeMesh(name); } @@ -2843,6 +3031,7 @@ void CellGridSystem::buildPhysicsColliders(flecs::entity entity) addMeshCollider(meshData.intWindowsMesh); addMeshCollider(meshData.roofMesh); addMeshCollider(meshData.roofSideMesh); + addMeshCollider(meshData.glassMesh); for (const auto &doorMesh : meshData.doorMeshes) addMeshCollider(doorMesh); for (const auto &windowMesh : meshData.windowMeshes) @@ -4108,7 +4297,8 @@ void CellGridSystem::placeWindowFrames( frameEntities.push_back(ent); }; - if (extMesh) { + /* F4: no external window frames in interiorOnly mode. */ + if (extMesh && !grid.interiorOnlyMode()) { frameOffset = 0.1f; if (cell.hasFlag(CellFlags::WindowXNeg)) { Ogre::Vector3 pos = @@ -4148,7 +4338,8 @@ void CellGridSystem::placeWindowFrames( } } - if (intMesh) { + /* F5: no internal window frames in exteriorOnly mode. */ + if (intMesh && !grid.exteriorOnlyMode()) { frameOffset = 0.1f; if (cell.hasFlag(CellFlags::IntWindowXNeg)) { Ogre::Vector3 pos = @@ -4283,7 +4474,8 @@ void CellGridSystem::placeDoorFrames(flecs::entity entity, } } - if (intMesh) { + /* F5: no internal door frames in exteriorOnly mode. */ + if (intMesh && !grid.exteriorOnlyMode()) { if (cell.hasFlag(CellFlags::IntDoorXNeg)) { Ogre::Vector3 pos = origin + @@ -4434,7 +4626,8 @@ void CellGridSystem::placeWindowFramesInStaticGeometry( } }; - if (extMesh) { + /* F4: no external window frames in interiorOnly mode. */ + if (extMesh && !grid.interiorOnlyMode()) { frameOffset = 0.1f; if (cell.hasFlag(CellFlags::WindowXNeg)) { Ogre::Vector3 pos = origin + Ogre::Vector3(-halfCell - frameOffset, windowY, 0); @@ -4458,7 +4651,8 @@ void CellGridSystem::placeWindowFramesInStaticGeometry( } } - if (intMesh) { + /* F5: no internal window frames in exteriorOnly mode. */ + if (intMesh && !grid.exteriorOnlyMode()) { frameOffset = 0.1f; if (cell.hasFlag(CellFlags::IntWindowXNeg)) { Ogre::Vector3 pos = origin + Ogre::Vector3(-halfCell + frameOffset, windowY, 0); @@ -4556,7 +4750,8 @@ void CellGridSystem::placeDoorFramesInStaticGeometry( } } - if (intMesh) { + /* F5: no internal door frames in exteriorOnly mode. */ + if (intMesh && !grid.exteriorOnlyMode()) { if (cell.hasFlag(CellFlags::IntDoorXNeg)) { Ogre::Vector3 pos = origin + Ogre::Vector3(-halfCell + frameOffset, intDoorFrameY, 0); Ogre::Quaternion rot(Ogre::Degree(-90), Ogre::Vector3::UNIT_Y); @@ -4629,8 +4824,81 @@ void CellGridSystem::createDoorLeafMesh(const CellGridComponent &grid, generateLodForMesh(leafMesh); } +std::string CellGridSystem::doorEdgeKey(int cellX, int cellY, int cellZ, + int side) +{ + // Canonical key of the shared cell edge: side 2/3 are X-facing + // edges (X+ uses the neighbour's coordinate), side 0/1 are + // Z-facing. Both cells sharing a door edge produce the same key. + if (side == 3) + cellX += 1; + else if (side == 1) + cellZ += 1; + char axis = (side == 2 || side == 3) ? 'X' : 'Z'; + return std::string(1, axis) + ":" + std::to_string(cellX) + ":" + + std::to_string(cellY) + ":" + std::to_string(cellZ); +} + +std::vector +CellGridSystem::collectDoorways(const CellGridComponent &grid) +{ + std::vector result; + std::set seen; + + auto tryAdd = [&](const Cell &cell, int side, bool internal) { + std::string key = doorEdgeKey(cell.x, cell.y, cell.z, side); + if (!seen.insert(key).second) + return; + DoorwayInfo info; + info.edgeKey = key; + info.cellX = cell.x; + info.cellY = cell.y; + info.cellZ = cell.z; + info.side = side; + info.internal = internal; + result.push_back(info); + }; + + for (const auto &cell : grid.cells) { + if (cell.hasFlag(CellFlags::DoorXNeg)) + tryAdd(cell, 2, false); + if (cell.hasFlag(CellFlags::DoorXPos)) + tryAdd(cell, 3, false); + if (cell.hasFlag(CellFlags::DoorZNeg)) + tryAdd(cell, 0, false); + if (cell.hasFlag(CellFlags::DoorZPos)) + tryAdd(cell, 1, false); + if (cell.hasFlag(CellFlags::IntDoorXNeg)) + tryAdd(cell, 2, true); + if (cell.hasFlag(CellFlags::IntDoorXPos)) + tryAdd(cell, 3, true); + if (cell.hasFlag(CellFlags::IntDoorZNeg)) + tryAdd(cell, 0, true); + if (cell.hasFlag(CellFlags::IntDoorZPos)) + tryAdd(cell, 1, true); + } + return result; +} + +flecs::entity CellGridSystem::findDoorEntity(flecs::entity gridEntity, + const std::string &edgeKey) +{ + flecs::entity result = flecs::entity::null(); + if (!gridEntity.is_valid() || !gridEntity.is_alive()) + return result; + gridEntity.children([&](flecs::entity child) { + if (result.is_valid()) + return; + if (!child.has()) + return; + if (child.get().edgeKey == edgeKey) + result = child; + }); + return result; +} + void CellGridSystem::buildDoorEntities(flecs::entity entity, - const CellGridComponent &grid, + CellGridComponent &grid, const std::string &materialName, flecs::entity materialEntity) { @@ -4706,27 +4974,25 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity, // side: 0 = Z-, 1 = Z+, 2 = X-, 3 = X+ auto tryPlaceDoor = [&](const Cell &cell, int side, bool internal) { - // Canonical key of the shared cell edge - std::string key; - if (side == 2) - key = "X:" + std::to_string(cell.x) + ":" + - std::to_string(cell.y) + ":" + - std::to_string(cell.z); - else if (side == 3) - key = "X:" + std::to_string(cell.x + 1) + ":" + - std::to_string(cell.y) + ":" + - std::to_string(cell.z); - else if (side == 0) - key = "Z:" + std::to_string(cell.x) + ":" + - std::to_string(cell.y) + ":" + - std::to_string(cell.z); - else - key = "Z:" + std::to_string(cell.x) + ":" + - std::to_string(cell.y) + ":" + - std::to_string(cell.z + 1); + /* F5: exteriorOnly has no interior, hence no internal door + * entities - only exit doors are built. */ + if (internal && grid.exteriorOnlyMode()) + return; + + // Canonical key of the shared cell edge (F0: also the + // door's identity key within this grid) + std::string key = doorEdgeKey(cell.x, cell.y, cell.z, side); if (!placedDoors.insert(key).second) return; // doorway already has a door + // Per-doorway configuration override (F0) + const CellGridDoorConfig *doorCfg = nullptr; + auto cfgIt = grid.doorConfigs.find(key); + if (cfgIt != grid.doorConfigs.end()) + doorCfg = &cfgIt->second; + if (doorCfg && doorCfg->disabled) + return; // no door entity for this doorway + // Same placement as the door frame for this side // (see placeDoorFramesInStaticGeometry) Ogre::Vector3 origin = @@ -4769,126 +5035,53 @@ void CellGridSystem::buildDoorEntities(flecs::entity entity, break; } - Ogre::Entity *leafEnt = nullptr; - try { - leafEnt = m_sceneMgr->createEntity(leafMeshName); - } catch (const std::exception &e) { - Ogre::LogManager::getSingleton().logMessage( - "CellGrid: Error creating door entity: " + - std::string(e.what())); + // Resolved behaviour + geometry for the shared door builder + // (F2): grid defaults with the per-doorway F0 override applied. + bool hasOverride = doorCfg && doorCfg->hasOverride; + + DoorBuildParams params; + params.openAngle = hasOverride ? doorCfg->openAngle : + grid.doorOpenAngle; + params.openSpeed = hasOverride ? doorCfg->openSpeed : + grid.doorOpenSpeed; + params.swingReversed = hasOverride ? doorCfg->swingReversed : + grid.doorSwingReversed; + params.actionName = hasOverride ? doorCfg->actionName : + grid.doorActionName; + params.sceneSwitchPath = hasOverride ? doorCfg->sceneSwitchPath : + grid.doorSceneSwitchPath; + params.sceneSwitchTarget = hasOverride ? + doorCfg->sceneSwitchTarget : + grid.doorSceneSwitchTarget; + params.persistent = doorCfg && doorCfg->persistent; + params.lockable = doorCfg && doorCfg->lockable; + params.lockedByDefault = doorCfg && doorCfg->lockedByDefault; + if (doorCfg) + params.keyItemId = doorCfg->keyItemId; + /* F0 identity: a global persistent ID is assigned to doors + * that need one (persistence, locking or scene switching); + * ephemeral doors keep an empty ID. */ + if (params.persistent || params.lockable || + !params.sceneSwitchPath.empty()) + params.doorId = grid.ensureGridUid() + ":" + key; + params.edgeKey = key; + params.friction = grid.friction; + params.leafMeshName = leafMeshName; + params.customMesh = usingCustomMesh; + params.useMeshMaterial = useMeshMaterial; + params.materialName = materialName; + params.customBounds = customBounds; + params.leafWidth = leafWidth; + params.leafHeight = leafHeight; + params.leafThickness = leafThickness; + + flecs::entity doorEntity = + DoorBuilder::build(m_world, m_sceneMgr, entity, + transform.node, pos, rot, params); + if (!doorEntity.is_valid()) return; - } - if (!leafEnt) - return; - if (!useMeshMaterial && !materialName.empty()) - leafEnt->setMaterialName(materialName); - // The hinge node is the rotation pivot (DoorSystem rotates it - // around local Y), so it sits on the side edge of the doorway - // with the leaf extending +X from it in door-local space, not - // in the doorway center. Its height is also used for the - // actuator prompt position. - Ogre::Vector3 hingePos; - Ogre::Vector3 colliderCenter; - Ogre::Vector3 colliderHalfExtents; - if (usingCustomMesh) { - // Custom mesh: hinge at the mesh origin (mesh spans - // +X/+Y from it); shift the hinge so the leaf is - // centered in the doorway when closed - Ogre::Vector3 center = Ogre::Vector3::ZERO; - if (!customBounds.isNull() && - customBounds.isFinite()) { - center = customBounds.getCenter(); - colliderHalfExtents = - customBounds.getHalfSize(); - } else { - colliderHalfExtents = - Ogre::Vector3(0.5f, 1.5f, 0.05f); - } - hingePos = pos - rot * Ogre::Vector3(center.x, 0, 0); - hingePos.y = pos.y; - colliderCenter = center; - } else { - // Procedural leaf: mesh spans [0, leafWidth] x - // [0, leafHeight] from the hinge edge; the child node - // centers it vertically on the hinge - hingePos = pos - - rot * Ogre::Vector3(leafWidth / 2.0f, 0, 0); - hingePos.y = pos.y + leafHeight / 2.0f; - colliderHalfExtents = - Ogre::Vector3(leafWidth / 2.0f, - leafHeight / 2.0f, - leafThickness / 2.0f); - colliderCenter = - Ogre::Vector3(leafWidth / 2.0f, 0, 0); - } - - Ogre::SceneNode *hingeNode = - transform.node->createChildSceneNode(); - hingeNode->setPosition(hingePos); - hingeNode->setOrientation(rot); - - if (usingCustomMesh) { - hingeNode->attachObject(leafEnt); - } else { - Ogre::SceneNode *leafNode = - hingeNode->createChildSceneNode(); - leafNode->setPosition( - Ogre::Vector3(0, -leafHeight / 2.0f, 0)); - leafNode->attachObject(leafEnt); - } - - // Door entity (runtime - no EditorMarkerComponent, so it is - // neither serialized nor editable; it dies with the grid - // through the ChildOf cascade) - flecs::entity doorEntity = m_world.entity(); - doorEntity.child_of(entity); - doorEntity.set( - {hingeNode, hingePos, rot, Ogre::Vector3::UNIT_SCALE}); - - RenderableComponent renderable; - renderable.entity = leafEnt; - renderable.meshName = leafMeshName; - doorEntity.set(renderable); - - DoorComponent door; - door.openAngle = grid.doorOpenAngle; - door.openSpeed = grid.doorOpenSpeed; - door.closedOrientation = rot; - door.centerOffset = colliderCenter; - door.sceneSwitchPath = grid.doorSceneSwitchPath; - door.sceneSwitchTarget = grid.doorSceneSwitchTarget; - doorEntity.set(door); - - RigidBodyComponent rb; - rb.bodyType = RigidBodyComponent::BodyType::Static; - rb.friction = grid.friction; - doorEntity.set(rb); - - ActuatorComponent actuator; - actuator.radius = 1.5f; - actuator.height = 1.8f; - if (!grid.doorActionName.empty()) - actuator.actionNames.push_back(grid.doorActionName); - doorEntity.set(actuator); - - // Box collider for the closed door (child of the door - // entity with zero local offset, shape offset to the leaf - // center); DoorSystem disables the body while the door is - // not fully closed - flecs::entity colliderEntity = m_world.entity(); - colliderEntity.child_of(doorEntity); - colliderEntity.set( - {nullptr, Ogre::Vector3::ZERO, - Ogre::Quaternion::IDENTITY, - Ogre::Vector3::UNIT_SCALE}); - PhysicsColliderComponent collider; - collider.shapeType = PhysicsColliderComponent::ShapeType::Box; - collider.parameters = colliderHalfExtents; - collider.offset = colliderCenter; - colliderEntity.set(collider); - - meshData.doorEntities.push_back(doorEntity); + meshData.doorEntities.push_back({key, doorEntity}); }; for (const auto &cell : grid.cells) { @@ -4925,7 +5118,8 @@ void CellGridSystem::destroyDoorEntities(flecs::entity entity) if (it == m_entityMeshes.end()) return; - for (auto doorEntity : it->second.doorEntities) { + for (auto &pair : it->second.doorEntities) { + flecs::entity doorEntity = pair.second; if (!doorEntity.is_valid() || !doorEntity.is_alive()) continue; diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index 1499281..00aa8d5 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -79,6 +79,36 @@ public: return m_lotBaseMeshes; } + // --- Doorway identity (F0) --- + + /* One unique doorway of a grid (a cell edge carrying any of the 8 + * door flags, deduplicated across the two cells sharing the edge). */ + struct DoorwayInfo { + std::string edgeKey; // canonical "X:x:y:z" / "Z:x:y:z" + int cellX = 0, cellY = 0, cellZ = 0; // cell the flag was on + int side = 0; // 0=Z-, 1=Z+, 2=X-, 3=X+ + bool internal = false; + }; + + /* Canonical edge key of the doorway on the given side of cell + * (x, y, z) - the same key buildDoorEntities() uses to deduplicate + * doorways, and the key of CellGridComponent::doorConfigs. */ + static std::string doorEdgeKey(int cellX, int cellY, int cellZ, + int side); + + /* All unique doorways of the grid (empty when cells have no door + * flags). Used by buildDoorEntities() and the Cell Grid editor + * Doors panel. */ + static std::vector + collectDoorways(const struct CellGridComponent &grid); + + /* Find the runtime door entity for a doorway of the given grid + * entity (flecs::entity::null() when none - e.g. doorsEnabled off + * or the doorway config is disabled). Matches DoorComponent::edgeKey + * on the grid's children. */ + static flecs::entity findDoorEntity(flecs::entity gridEntity, + const std::string &edgeKey); + private: flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; @@ -117,6 +147,14 @@ private: void buildInternalCorners(const struct CellGridComponent &grid, Procedural::TriangleBuffer &intWallTb); + // F5: glass panes for external window openings (exteriorOnly mode); + // returns the material name used (built-in per-grid or + // glassMaterialName) + void buildGlass(const struct CellGridComponent &grid, + Procedural::TriangleBuffer &glassTb); + std::string getGlassMaterialName( + flecs::entity entity, const struct CellGridComponent &grid); + // Build roofs void buildRoofs(flecs::entity lotEntity, const struct CellGridComponent &grid, @@ -173,7 +211,7 @@ private: // Door leaf entities (one per unique doorway, swing open/closed) void buildDoorEntities(flecs::entity entity, - const struct CellGridComponent &grid, + struct CellGridComponent &grid, const std::string &materialName, flecs::entity materialEntity); void createDoorLeafMesh(const struct CellGridComponent &grid, @@ -234,6 +272,7 @@ private: std::vector windowMeshes; std::string roofMesh; std::string roofSideMesh; + std::string glassMesh; std::vector entities; // Track texture dependency for automatic rebuild when texture changes @@ -261,8 +300,9 @@ private: std::vector isolatedFurnitureEntities; flecs::entity physicsParentEntity = flecs::entity::null(); - // Door leaf entities (runtime, one per unique doorway) - std::vector doorEntities; + // Door leaf entities (runtime, one per unique doorway), each + // with its canonical edge key (F0) + std::vector > doorEntities; std::string doorLeafMesh; // procedural leaf mesh (custom mesh not owned) }; std::unordered_map m_entityMeshes; diff --git a/src/features/editScene/systems/DoorBuilder.cpp b/src/features/editScene/systems/DoorBuilder.cpp new file mode 100644 index 0000000..c4835e2 --- /dev/null +++ b/src/features/editScene/systems/DoorBuilder.cpp @@ -0,0 +1,238 @@ +#include "DoorBuilder.hpp" +#include "DoorSystem.hpp" +#include "GlobalStateStore.hpp" +#include "../components/Door.hpp" +#include "../components/Transform.hpp" +#include "../components/RigidBody.hpp" +#include "../components/PhysicsCollider.hpp" +#include "../components/Actuator.hpp" +#include "../components/Renderable.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +flecs::entity DoorBuilder::build(flecs::world &world, + Ogre::SceneManager *sceneMgr, + flecs::entity parentEntity, + Ogre::SceneNode *parentNode, + const Ogre::Vector3 &doorwayPos, + const Ogre::Quaternion &doorwayRot, + const DoorBuildParams ¶ms) +{ + if (params.leafMeshName.empty() || !parentNode) + return flecs::entity::null(); + + Ogre::Entity *leafEnt = nullptr; + try { + leafEnt = sceneMgr->createEntity(params.leafMeshName); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "DoorBuilder: Error creating door entity: " + + std::string(e.what())); + return flecs::entity::null(); + } + if (!leafEnt) + return flecs::entity::null(); + if (!params.useMeshMaterial && !params.materialName.empty()) + leafEnt->setMaterialName(params.materialName); + + // The hinge node is the rotation pivot (DoorSystem rotates it + // around local Y), so it sits on the side edge of the doorway + // with the leaf extending +X from it in door-local space, not + // in the doorway center. Its height is also used for the + // actuator prompt position. + Ogre::Vector3 hingePos; + Ogre::Vector3 colliderCenter; + Ogre::Vector3 colliderHalfExtents; + if (params.customMesh) { + // Custom mesh: hinge at the mesh origin (mesh spans + // +X/+Y from it); shift the hinge so the leaf is + // centered in the doorway when closed + Ogre::Vector3 center = Ogre::Vector3::ZERO; + if (!params.customBounds.isNull() && + params.customBounds.isFinite()) { + center = params.customBounds.getCenter(); + colliderHalfExtents = params.customBounds.getHalfSize(); + } else { + colliderHalfExtents = Ogre::Vector3(0.5f, 1.5f, 0.05f); + } + hingePos = doorwayPos - doorwayRot * Ogre::Vector3(center.x, 0, 0); + hingePos.y = doorwayPos.y; + colliderCenter = center; + } else { + // Procedural leaf: mesh spans [0, leafWidth] x + // [0, leafHeight] from the hinge edge; the child node + // centers it vertically on the hinge + hingePos = doorwayPos - + doorwayRot * Ogre::Vector3(params.leafWidth / 2.0f, 0, 0); + hingePos.y = doorwayPos.y + params.leafHeight / 2.0f; + colliderHalfExtents = + Ogre::Vector3(params.leafWidth / 2.0f, + params.leafHeight / 2.0f, + params.leafThickness / 2.0f); + colliderCenter = Ogre::Vector3(params.leafWidth / 2.0f, 0, 0); + } + + Ogre::SceneNode *hingeNode = parentNode->createChildSceneNode(); + hingeNode->setPosition(hingePos); + hingeNode->setOrientation(doorwayRot); + + if (params.customMesh) { + hingeNode->attachObject(leafEnt); + } else { + Ogre::SceneNode *leafNode = hingeNode->createChildSceneNode(); + leafNode->setPosition( + Ogre::Vector3(0, -params.leafHeight / 2.0f, 0)); + leafNode->attachObject(leafEnt); + } + + // Door entity (runtime - no EditorMarkerComponent, so it is + // neither serialized nor editable; it dies with the parent + // through the ChildOf cascade) + flecs::entity doorEntity = world.entity(); + doorEntity.child_of(parentEntity); + doorEntity.set( + {hingeNode, hingePos, doorwayRot, Ogre::Vector3::UNIT_SCALE}); + + RenderableComponent renderable; + renderable.entity = leafEnt; + renderable.meshName = params.leafMeshName; + doorEntity.set(renderable); + + DoorComponent door; + door.edgeKey = params.edgeKey; + door.openAngle = params.openAngle; + door.openSpeed = params.openSpeed; + door.swingReversed = params.swingReversed; + door.closedOrientation = doorwayRot; + door.centerOffset = colliderCenter; + door.sceneSwitchPath = params.sceneSwitchPath; + door.sceneSwitchTarget = params.sceneSwitchTarget; + door.persistent = params.persistent; + door.lockable = params.lockable; + door.keyItemId = params.keyItemId; + door.doorId = params.doorId; + + /* F6: declare the store defaults and snap the door to the + * persisted open state (rebuilds and reloads restore the + * state the door had when it was saved/left). */ + bool wasOpen = false; + if (!door.doorId.empty()) { + wasOpen = DoorSystem::declareDoorDefaults( + door.doorId, params.lockedByDefault); + } + if (wasOpen) { + door.isOpen = true; + door.currentAngle = door.openAngle; + hingeNode->setOrientation( + DoorSystem::swingOrientation(door, door.openAngle)); + } + doorEntity.set(door); + + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + rb.friction = params.friction; + /* A door restored open keeps its collider disabled. */ + rb.enabled = !wasOpen; + doorEntity.set(rb); + + ActuatorComponent actuator; + actuator.radius = 1.5f; + actuator.height = 1.8f; + if (!params.actionName.empty()) + actuator.actionNames.push_back(params.actionName); + doorEntity.set(actuator); + + // Box collider for the closed door (child of the door + // entity with zero local offset, shape offset to the leaf + // center); DoorSystem disables the body while the door is + // not fully closed + flecs::entity colliderEntity = world.entity(); + colliderEntity.child_of(doorEntity); + colliderEntity.set( + {nullptr, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE}); + PhysicsColliderComponent collider; + collider.shapeType = PhysicsColliderComponent::ShapeType::Box; + collider.parameters = colliderHalfExtents; + collider.offset = colliderCenter; + colliderEntity.set(collider); + + /* F1: scene-switch doors get an unlit black occluder box + * covering the doorway, a few cm behind the closed leaf + * plane, so the player never sees the missing room + * interior through the opened doorway before the switch + * fires. The node hangs off the PARENT node (not the + * hinge), so it does not swing with the leaf; DoorSystem + * toggles its visibility from the swing angle. */ + if (!door.sceneSwitchPath.empty()) { + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + if (Ogre::MeshManager::getSingleton() + .getByName("CellGridDoorOccluderBox", group) + .isNull()) { + Procedural::BoxGenerator boxGen; + boxGen.setSizeX(1.0f).setSizeY(1.0f).setSizeZ(1.0f); + boxGen.realizeMesh("CellGridDoorOccluderBox", group); + } + if (Ogre::MaterialManager::getSingleton() + .getByName("CellGridDoorOccluderBlack", group) + .isNull()) { + Ogre::MaterialPtr mat = + Ogre::MaterialManager::getSingleton().create( + "CellGridDoorOccluderBlack", group); + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + pass->setLightingEnabled(false); + pass->setDiffuse(Ogre::ColourValue::Black); + } + + Ogre::Entity *occEnt = nullptr; + try { + occEnt = sceneMgr->createEntity("CellGridDoorOccluderBox"); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "DoorBuilder: Error creating door occluder: " + + std::string(e.what())); + } + if (occEnt) { + occEnt->setMaterialName("CellGridDoorOccluderBlack"); + occEnt->setVisible(wasOpen); + + Ogre::Vector3 occScale(colliderHalfExtents.x * 2.0f, + colliderHalfExtents.y * 2.0f, + 0.05f); + Ogre::Vector3 occPos = + hingePos + + doorwayRot * + (colliderCenter + + Ogre::Vector3(0, 0, + colliderHalfExtents.z + + 0.02f)); + Ogre::SceneNode *occNode = + parentNode->createChildSceneNode(); + occNode->setPosition(occPos); + occNode->setOrientation(doorwayRot); + occNode->setScale(occScale); + occNode->attachObject(occEnt); + + flecs::entity occEntity = world.entity(); + occEntity.child_of(doorEntity); + occEntity.set( + {occNode, occPos, doorwayRot, occScale}); + RenderableComponent occRenderable; + occRenderable.entity = occEnt; + occRenderable.meshName = "CellGridDoorOccluderBox"; + occEntity.set(occRenderable); + + doorEntity.get_mut().occluder = occEnt; + } + } + + return doorEntity; +} diff --git a/src/features/editScene/systems/DoorBuilder.hpp b/src/features/editScene/systems/DoorBuilder.hpp new file mode 100644 index 0000000..7b603e6 --- /dev/null +++ b/src/features/editScene/systems/DoorBuilder.hpp @@ -0,0 +1,89 @@ +#ifndef EDITSCENE_DOOR_BUILDER_HPP +#define EDITSCENE_DOOR_BUILDER_HPP +#pragma once + +#include +#include +#include + +/** + * Shared door entity builder (F2). + * + * Factors the door-entity construction that used to live inside + * CellGridSystem::buildDoorEntities() so both CellGrid doors and the + * StandaloneDoorComponent produce the identical entity structure: + * + * parentEntity + * └── door entity (TransformComponent hinge node, RenderableComponent + * leaf, DoorComponent, RigidBodyComponent static, ActuatorComponent) + * ├── collider child (PhysicsColliderComponent box of the closed + * │ leaf; disabled by DoorSystem while not fully closed) + * └── occluder child (F1, scene-switch doors only: unlit black box + * behind the leaf plane, node on the PARENT node so it does not + * swing; DoorSystem toggles its visibility) + * + * The builder also declares the F6 store defaults for persistent doors + * (DoorSystem::declareDoorDefaults) and snaps a door that was left open + * straight to its persisted pose with the collider disabled. + * + * Leaf mesh creation stays with the caller: pass a ready mesh name + * (CellGrid generates its UV-mapped procedural leaf; standalone doors + * generate a plain box leaf or use a custom mesh). Custom meshes must be + * modeled with the hinge edge at the origin, spanning +X/+Y. + */ +struct DoorBuildParams { + /* Behaviour (already resolved by the caller: grid defaults + + * per-door override, or the StandaloneDoorComponent values). */ + float openAngle = 100.0f; // degrees + float openSpeed = 180.0f; // degrees per second + bool swingReversed = false; // F3 + std::string actionName; // optional actuator action + std::string sceneSwitchPath; // F1 (empty = normal swinging door) + std::string sceneSwitchTarget; + bool persistent = false; // F6 + bool lockable = false; + bool lockedByDefault = false; + std::string keyItemId; + std::string doorId; // "" = ephemeral (never persisted) + std::string edgeKey; // CellGrid only (findDoorEntity) + float friction = 0.5f; + + /* Leaf. */ + std::string leafMeshName; // required + bool customMesh = false; // hinge-at-origin mesh vs + // procedural box leaf + bool useMeshMaterial = false; // keep the mesh's own material + Ogre::String materialName; // applied otherwise (when non-empty) + Ogre::AxisAlignedBox customBounds; // customMesh bounds (for the + // collider + hinge shift) + float leafWidth = 1.0f; // procedural leaf dimensions + float leafHeight = 2.0f; + float leafThickness = 0.08f; +}; + +class DoorBuilder { +public: + /** + * Build a door entity subtree. + * + * @param parentEntity ECS parent (grid entity / standalone door + * entity); the door dies with it through the + * ChildOf cascade. + * @param parentNode scene node the hinge node is attached to. + * @param doorwayPos doorway center at floor level, in + * parentNode-local space. + * @param doorwayRot doorway orientation in parentNode-local + * space (leaf spans +X from the hinge, wall + * runs along local X). + * @return the door entity, or a null entity on failure. + */ + static flecs::entity build(flecs::world &world, + Ogre::SceneManager *sceneMgr, + flecs::entity parentEntity, + Ogre::SceneNode *parentNode, + const Ogre::Vector3 &doorwayPos, + const Ogre::Quaternion &doorwayRot, + const DoorBuildParams ¶ms); +}; + +#endif // EDITSCENE_DOOR_BUILDER_HPP diff --git a/src/features/editScene/systems/DoorSystem.cpp b/src/features/editScene/systems/DoorSystem.cpp index 83ec820..dc10563 100644 --- a/src/features/editScene/systems/DoorSystem.cpp +++ b/src/features/editScene/systems/DoorSystem.cpp @@ -1,20 +1,96 @@ #include "DoorSystem.hpp" +#include "GlobalStateStore.hpp" +#include "../EditorApp.hpp" #include "../components/Door.hpp" #include "../components/Transform.hpp" #include "../components/RigidBody.hpp" #include +#include #include +#include DoorSystem::DoorSystem(flecs::world &world) : m_world(world) , m_doorQuery(world.query()) { + /* Generic unlock event: "door_unlock" with param "door_id". */ + m_genericUnlockSub = EventBus::getInstance().subscribe( + "door_unlock", + [](const Ogre::String &, const editScene::EventParams ¶ms) { + std::string doorId = params.getString("door_id", ""); + if (!doorId.empty()) + setDoorLocked(doorId, false); + }); } -DoorSystem::~DoorSystem() = default; +DoorSystem::~DoorSystem() +{ + EventBus &bus = EventBus::getInstance(); + for (auto &it : m_unlockSubs) + bus.unsubscribe(it.second); + m_unlockSubs.clear(); + if (m_genericUnlockSub) { + bus.unsubscribe(m_genericUnlockSub); + m_genericUnlockSub = 0; + } +} + +bool DoorSystem::isDoorLocked(const DoorComponent &door) +{ + if (door.doorId.empty()) + return false; + return isDoorLockedById(door.doorId); +} + +bool DoorSystem::isDoorLockedById(const std::string &doorId) +{ + return GlobalStateStore::getInstance().getBool( + "door." + doorId + ".locked"); +} + +void DoorSystem::setDoorLocked(const std::string &doorId, bool locked) +{ + if (doorId.empty()) + return; + + GlobalStateStore::getInstance().set("door." + doorId + ".locked", + locked); + + if (!locked) { + /* Unlock notifications: per-door and generic. */ + editScene::EventParams params; + params.setString("door_id", doorId); + EventBus &bus = EventBus::getInstance(); + bus.send("door_unlocked_" + doorId, params); + bus.send("door_unlocked", params); + } +} + +bool DoorSystem::declareDoorDefaults(const std::string &doorId, + bool lockedByDefault) +{ + if (doorId.empty()) + return false; + + GlobalStateStore &store = GlobalStateStore::getInstance(); + store.declareDefault("door." + doorId + ".locked", lockedByDefault); + store.declareDefault("door." + doorId + ".isOpen", false); + return store.getBool("door." + doorId + ".isOpen"); +} + +Ogre::Quaternion DoorSystem::swingOrientation(const DoorComponent &door, + float angle) +{ + return door.closedOrientation * + Ogre::Quaternion(Ogre::Degree(door.swingReversed ? -angle : + angle), + Ogre::Vector3::UNIT_Y); +} void DoorSystem::update(float deltaTime) { + std::unordered_set liveLockableDoors; + m_doorQuery.each([&](flecs::entity entity, DoorComponent &door, TransformComponent &transform) { // Consume toggle requests (from ActuatorSystem or scripts) @@ -26,8 +102,15 @@ void DoorSystem::update(float deltaTime) auto &rb = entity.get_mut(); rb.enabled = false; } + // A close cancels a pending scene switch (F1) + if (!door.isOpen) + door.sceneSwitchPending = false; } + // Track lockable doors for the per-door unlock subscriptions + if (door.lockable && !door.doorId.empty()) + liveLockableDoors.insert(door.doorId); + float targetAngle = door.isOpen ? door.openAngle : 0.0f; if (door.currentAngle == targetAngle) return; @@ -44,10 +127,7 @@ void DoorSystem::update(float deltaTime) if (transform.node) { transform.node->setOrientation( - door.closedOrientation * - Ogre::Quaternion( - Ogre::Degree(door.currentAngle), - Ogre::Vector3::UNIT_Y)); + swingOrientation(door, door.currentAngle)); } // Fully closed again: restore the collider in the closed pose @@ -56,5 +136,62 @@ void DoorSystem::update(float deltaTime) auto &rb = entity.get_mut(); rb.enabled = true; } + + // Swing completed: persist the open state (F6) + if (door.currentAngle == targetAngle && + !door.doorId.empty()) { + GlobalStateStore::getInstance().set( + "door." + door.doorId + ".isOpen", door.isOpen); + } + + // F1: a scene-switch door fires the switch only when fully + // open; the loading cover hides the actual transition + if (door.sceneSwitchPending && door.isOpen && + door.currentAngle == door.openAngle) { + door.sceneSwitchPending = false; + if (!door.sceneSwitchPath.empty()) { + editScene::EventParams params; + params.setString("path", door.sceneSwitchPath); + params.setString("target", + door.sceneSwitchTarget); + params.setString("door_id", door.doorId); + EventBus::getInstance().send("door_scene_switch", + params); + if (m_editorApp) { + SceneSwitchOptions opts; + opts.targetEntityName = + door.sceneSwitchTarget; + m_editorApp->switchScene( + door.sceneSwitchPath, opts); + } + } + } + + // F1: the black occluder is visible unless the door is + // fully closed + if (door.occluder) + door.occluder->setVisible(door.currentAngle != 0.0f); }); + + // Refresh the per-door "door_unlock_" subscriptions + // (EventBus has no wildcard matching). + EventBus &bus = EventBus::getInstance(); + for (auto it = m_unlockSubs.begin(); it != m_unlockSubs.end();) { + if (liveLockableDoors.count(it->first)) { + ++it; + } else { + bus.unsubscribe(it->second); + it = m_unlockSubs.erase(it); + } + } + for (const std::string &doorId : liveLockableDoors) { + if (m_unlockSubs.count(doorId)) + continue; + m_unlockSubs[doorId] = bus.subscribe( + "door_unlock_" + doorId, + [doorId](const Ogre::String &, + const editScene::EventParams &) { + setDoorLocked(doorId, false); + }); + } } diff --git a/src/features/editScene/systems/DoorSystem.hpp b/src/features/editScene/systems/DoorSystem.hpp index 4dffac5..fa69794 100644 --- a/src/features/editScene/systems/DoorSystem.hpp +++ b/src/features/editScene/systems/DoorSystem.hpp @@ -2,7 +2,10 @@ #define EDITSCENE_DOOR_SYSTEM_HPP #pragma once +#include "EventBus.hpp" #include +#include +#include /** * Door system - animates CellGrid door entities. @@ -13,6 +16,16 @@ * local Y at the configured speed, and disables the door's * RigidBodyComponent while the door is not fully closed (the collider * only exists in the closed pose). + * + * Persistent door state (F6) lives in the GlobalStateStore under + * "door..locked" / "door..isOpen" (doorId is the F0 global + * door ID ":"; empty = ephemeral door). The system + * writes isOpen when a swing completes; CellGridSystem snaps a rebuilt + * door to the persisted state. Locked state is managed through the + * static helpers below and through the EventBus: sending + * "door_unlock_" or the generic "door_unlock" event (param + * door_id) unlocks a door; "door_unlocked_" / "door_unlocked" are + * emitted as notifications. */ class DoorSystem { public: @@ -21,10 +34,40 @@ public: void update(float deltaTime); + /* F1: needed to queue the scene switch when a scene-switch door + * finishes swinging open. */ + void setEditorApp(class EditorApp *app) { m_editorApp = app; } + + /* Persistent door state helpers (F6). All are no-ops / false for + * ephemeral doors (empty doorId). */ + static bool isDoorLocked(const struct DoorComponent &door); + static bool isDoorLockedById(const std::string &doorId); + static void setDoorLocked(const std::string &doorId, bool locked); + + /* Declare the store defaults for a persistent door (called by the + * door builders when a door is (re)created) and return the + * persisted open state. */ + static bool declareDoorDefaults(const std::string &doorId, + bool lockedByDefault); + + /* Hinge orientation for a swing angle (F3: swingReversed negates + * the applied angle; the stored angles stay positive). Used by + * update() and by the door builders when snapping a restored-open + * door to its persisted pose. */ + static Ogre::Quaternion + swingOrientation(const struct DoorComponent &door, float angle); + private: flecs::world &m_world; + class EditorApp *m_editorApp = nullptr; flecs::query m_doorQuery; + + /* Per-door "door_unlock_" subscriptions (EventBus has no + * wildcard matching); refreshed each update, cleaned up when the + * door entity disappears. */ + std::unordered_map m_unlockSubs; + EventBus::ListenerId m_genericUnlockSub = 0; }; #endif // EDITSCENE_DOOR_SYSTEM_HPP diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index b3e607f..cfb403b 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -1,4 +1,7 @@ #include "../components/GeneratedPhysicsTag.hpp" +#include "../components/Door.hpp" +#include "../ui/DoorPickState.hpp" +#include "GlobalStateStore.hpp" #include "EditorUISystem.hpp" #include "../EditorApp.hpp" #include "DialogueSystem.hpp" @@ -140,6 +143,44 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay) if (ImGui::GetIO().WantCaptureMouse) return false; + // Door pick mode (F0): the Cell Grid editor's Doors panel armed a + // pick; this click selects a doorway of that grid and is consumed. + { + DoorPickState &pick = DoorPickState::instance(); + if (pick.active) { + pick.active = false; + pick.done = true; + pick.resultValid = false; + pick.resultEdgeKey.clear(); + flecs::entity gridEntity = + m_world.entity(pick.gridEntityId); + if (gridEntity.is_alive()) { + float bestT = FLT_MAX; + gridEntity.children([&](flecs::entity child) { + if (!child.has() || + !child.has()) + return; + Ogre::Entity *oe = + child.get() + .entity; + if (!oe) + return; + auto hit = Ogre::Math::intersects( + mouseRay, + oe->getWorldBoundingBox()); + if (hit.first && hit.second < bestT) { + bestT = hit.second; + pick.resultValid = true; + pick.resultEdgeKey = + child.get() + .edgeKey; + } + }); + } + return true; + } + } + // Prefab spawn mode (M6): record the press; the click-vs-drag // decision is made on release (5 px threshold). The gizmo already // had priority above so spawners stay movable while placing. @@ -407,6 +448,11 @@ void EditorUISystem::update(float deltaTime) renderPrefabBrowser(); renderCursorPanel(); + // Render Open Project dialog + if (m_showOpenProjectDialog) { + renderOpenProjectDialog(); + } + // Render Navigation panel (teleport + world bookmarks) if (m_showNavigation) { m_navigationPanel.render(&m_showNavigation); @@ -503,6 +549,9 @@ void EditorUISystem::renderHierarchyWindow() if (ImGui::MenuItem("Switch Scene...")) { showFileDialog(FileDialogMode::Switch); } + if (ImGui::MenuItem("Open Project...")) { + m_showOpenProjectDialog = true; + } ImGui::Separator(); if (ImGui::MenuItem("Save Action DB", nullptr)) { @@ -787,10 +836,12 @@ void EditorUISystem::renderEntityNode(flecs::entity entity, int depth) } // Collect children using flecs::ChildOf relationship - // (skip auto-generated physics entities) + // (skip auto-generated physics and door entities; doors are + // configured per-doorway in the CellGrid editor instead) std::vector children; entity.children([&](flecs::entity child) { - if (!child.has()) { + if (!child.has() && + !child.has()) { children.push_back(child); } }); @@ -1367,6 +1418,11 @@ void EditorUISystem::loadScene(const std::string &filepath) if (!m_serializer) return; + /* F9: editor mode always starts a (re)loaded scene from system + * defaults - the global state store never carries game-session + * state into the editor. */ + GlobalStateStore::getInstance().clearToDefaults(); + if (m_serializer->loadFromFile(filepath, this)) { m_navigationPanel.setBookmarks(m_serializer->getBookmarks()); SceneScriptSystem::loadPendingScripts(m_world); @@ -1835,6 +1891,152 @@ void EditorUISystem::renderPrefabBrowser() ImGui::End(); } +void EditorUISystem::renderOpenProjectDialog() +{ + namespace fs = std::filesystem; + + ImGui::SetNextWindowPos(ImVec2(LEFT_PANEL_WIDTH, 100), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(520, 430), ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + if (!ImGui::Begin("Open Project", &m_showOpenProjectDialog, + flags)) { + ImGui::End(); + return; + } + + /* Lazy init: start browsing at the current project root's + * parent (or the working directory). */ + if (m_openProjectBrowseDir.empty()) { + std::error_code ec; + fs::path start = fs::current_path(ec); + if (m_editorApp && !m_editorApp->getProjectRoot().empty()) { + fs::path parent = + fs::path(m_editorApp->getProjectRoot()) + .parent_path(); + if (!parent.empty() && fs::is_directory(parent, ec)) + start = parent; + } + m_openProjectBrowseDir = start.string(); + m_openProjectDirsDirty = true; + std::snprintf(m_openProjectPath, sizeof(m_openProjectPath), + "%s", m_openProjectBrowseDir.c_str()); + } + + ImGui::TextWrapped( + "Open a project directory: the working directory switches to " + "it (scenes, prefabs, config JSONs resolve relative to it), " + "the save directory follows the project's appName, and the " + "current scene is closed. Directories containing a " + "project.json are marked [project]."); + if (m_editorApp && + !m_editorApp->getProjectRoot().empty()) { + ImGui::Text("Current project: %s", + m_editorApp->getProjectConfig().appName.c_str()); + ImGui::TextWrapped("%s", + m_editorApp->getProjectRoot().c_str()); + } + + /* Editable path: Enter navigates the browser (relative paths + * resolve against the browsed directory). */ + if (ImGui::InputText("Directory", m_openProjectPath, + sizeof(m_openProjectPath), + ImGuiInputTextFlags_EnterReturnsTrue)) { + std::error_code ec; + fs::path p(m_openProjectPath); + if (p.is_relative()) + p = fs::path(m_openProjectBrowseDir) / p; + p = fs::weakly_canonical(p, ec); + if (!ec && fs::is_directory(p, ec)) { + m_openProjectBrowseDir = p.string(); + m_openProjectDirsDirty = true; + m_openProjectError.clear(); + std::snprintf(m_openProjectPath, + sizeof(m_openProjectPath), "%s", + m_openProjectBrowseDir.c_str()); + } else { + m_openProjectError = "Not a directory: " + + std::string(m_openProjectPath); + } + } + + /* Subdirectory listing, rebuilt on navigation. Dot-directories + * are hidden. */ + if (m_openProjectDirsDirty) { + m_openProjectDirsDirty = false; + m_openProjectDirs.clear(); + std::error_code ec; + for (const auto &entry : + fs::directory_iterator(m_openProjectBrowseDir, ec)) { + if (!entry.is_directory(ec)) + continue; + std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') + continue; + bool isProject = + fs::exists(entry.path() / "project.json", ec); + m_openProjectDirs.emplace_back(name, isProject); + } + std::sort(m_openProjectDirs.begin(), m_openProjectDirs.end(), + [](const auto &a, const auto &b) { + return a.first < b.first; + }); + } + + auto enterDir = [&](const fs::path &p) { + m_openProjectBrowseDir = p.string(); + m_openProjectDirsDirty = true; + m_openProjectError.clear(); + std::snprintf(m_openProjectPath, sizeof(m_openProjectPath), + "%s", m_openProjectBrowseDir.c_str()); + }; + + if (ImGui::BeginChild("##projdirs", ImVec2(0, 180), + ImGuiChildFlags_Borders)) { + if (ImGui::Selectable("..")) { + fs::path cur(m_openProjectBrowseDir); + fs::path parent = cur.parent_path(); + if (!parent.empty() && parent != cur) + enterDir(parent); + } + for (const auto &dir : m_openProjectDirs) { + std::string label = + dir.first + (dir.second ? " [project]" : ""); + if (ImGui::Selectable(label.c_str())) + enterDir(fs::path(m_openProjectBrowseDir) / + dir.first); + } + } + ImGui::EndChild(); + + if (!m_openProjectError.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, + ImVec4(1.0f, 0.4f, 0.4f, 1.0f)); + ImGui::TextWrapped("%s", m_openProjectError.c_str()); + ImGui::PopStyleColor(); + } + + if (ImGui::Button("Open This Directory", ImVec2(180, 0))) { + if (m_editorApp && + m_editorApp->openProject(m_openProjectBrowseDir)) { + m_showOpenProjectDialog = false; + m_openProjectBrowseDir.clear(); + m_openProjectError.clear(); + } else { + m_openProjectError = + "Failed to open: " + m_openProjectBrowseDir; + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + m_showOpenProjectDialog = false; + m_openProjectBrowseDir.clear(); + m_openProjectError.clear(); + } + ImGui::End(); +} + void EditorUISystem::renderCursorPanel() { if (!m_showCursorPanel) diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index 3fa9c51..9753804 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -229,6 +229,7 @@ public: void showCreatePrefabDialog(flecs::entity entity); void renderPrefabBrowser(); void renderCursorPanel(); + void renderOpenProjectDialog(); void renderDialogueSettingsWindow(); void renderInventoryConfigWindow(); @@ -331,6 +332,20 @@ private: // 3D Cursor state bool m_showCursorPanel = false; + + // Open Project dialog state (F8) + bool m_showOpenProjectDialog = false; + char m_openProjectPath[512] = { 0 }; + /* Open Project directory browser (ImGui has no native file + * dialog): m_openProjectBrowseDir is the directory being browsed + * (m_openProjectPath mirrors it for manual entry); + * m_openProjectDirs caches its subdirectories - name plus a + * "contains project.json" flag - rebuilt when the dirty flag is + * set. */ + std::string m_openProjectBrowseDir; + std::vector> m_openProjectDirs; + bool m_openProjectDirsDirty = true; + std::string m_openProjectError; enum class CursorInteractionMode { None, Place, // Click to raycast-place on surface diff --git a/src/features/editScene/systems/GlobalStateStore.cpp b/src/features/editScene/systems/GlobalStateStore.cpp new file mode 100644 index 0000000..287249a --- /dev/null +++ b/src/features/editScene/systems/GlobalStateStore.cpp @@ -0,0 +1,272 @@ +#include "GlobalStateStore.hpp" +#include +#include + +GlobalStateStore &GlobalStateStore::getInstance() +{ + static GlobalStateStore instance; + return instance; +} + +GlobalStateStore::GlobalStateStore() +{ + m_autoSavePath = "global_state.json"; +} + +GlobalStateStore::Value GlobalStateStore::makeBool(bool v) +{ + Value val; + val.type = Value::BOOL; + val.boolVal = v; + return val; +} + +GlobalStateStore::Value GlobalStateStore::makeInt(int64_t v) +{ + Value val; + val.type = Value::INT; + val.intVal = v; + return val; +} + +GlobalStateStore::Value GlobalStateStore::makeFloat(double v) +{ + Value val; + val.type = Value::FLOAT; + val.floatVal = v; + return val; +} + +GlobalStateStore::Value GlobalStateStore::makeString(const std::string &v) +{ + Value val; + val.type = Value::STRING; + val.strVal = v; + return val; +} + +void GlobalStateStore::declareDefault(const std::string &name, bool v) +{ + if (!name.empty()) + m_defaults[name] = makeBool(v); +} + +void GlobalStateStore::declareDefault(const std::string &name, int64_t v) +{ + if (!name.empty()) + m_defaults[name] = makeInt(v); +} + +void GlobalStateStore::declareDefault(const std::string &name, double v) +{ + if (!name.empty()) + m_defaults[name] = makeFloat(v); +} + +void GlobalStateStore::declareDefault(const std::string &name, + const std::string &v) +{ + if (!name.empty()) + m_defaults[name] = makeString(v); +} + +void GlobalStateStore::set(const std::string &name, bool v) +{ + if (name.empty()) + return; + m_values[name] = makeBool(v); + autoSave(); +} + +void GlobalStateStore::set(const std::string &name, int64_t v) +{ + if (name.empty()) + return; + m_values[name] = makeInt(v); + autoSave(); +} + +void GlobalStateStore::set(const std::string &name, double v) +{ + if (name.empty()) + return; + m_values[name] = makeFloat(v); + autoSave(); +} + +void GlobalStateStore::set(const std::string &name, const std::string &v) +{ + if (name.empty()) + return; + m_values[name] = makeString(v); + autoSave(); +} + +bool GlobalStateStore::getBool(const std::string &name, bool fallback) const +{ + auto it = m_values.find(name); + if (it != m_values.end() && it->second.type == Value::BOOL) + return it->second.boolVal; + auto dit = m_defaults.find(name); + if (dit != m_defaults.end() && dit->second.type == Value::BOOL) + return dit->second.boolVal; + return fallback; +} + +int64_t GlobalStateStore::getInt(const std::string &name, + int64_t fallback) const +{ + auto it = m_values.find(name); + if (it != m_values.end() && it->second.type == Value::INT) + return it->second.intVal; + auto dit = m_defaults.find(name); + if (dit != m_defaults.end() && dit->second.type == Value::INT) + return dit->second.intVal; + return fallback; +} + +double GlobalStateStore::getFloat(const std::string &name, + double fallback) const +{ + auto it = m_values.find(name); + if (it != m_values.end() && it->second.type == Value::FLOAT) + return it->second.floatVal; + auto dit = m_defaults.find(name); + if (dit != m_defaults.end() && dit->second.type == Value::FLOAT) + return dit->second.floatVal; + return fallback; +} + +std::string GlobalStateStore::getString(const std::string &name, + const std::string &fallback) const +{ + auto it = m_values.find(name); + if (it != m_values.end() && it->second.type == Value::STRING) + return it->second.strVal; + auto dit = m_defaults.find(name); + if (dit != m_defaults.end() && dit->second.type == Value::STRING) + return dit->second.strVal; + return fallback; +} + +bool GlobalStateStore::has(const std::string &name) const +{ + return m_values.count(name) || m_defaults.count(name); +} + +void GlobalStateStore::remove(const std::string &name) +{ + if (m_values.erase(name)) + autoSave(); +} + +void GlobalStateStore::renamePrefix(const std::string &oldPrefix, + const std::string &newPrefix) +{ + if (oldPrefix.empty() || oldPrefix == newPrefix) + return; + std::vector> moved; + for (auto it = m_values.begin(); it != m_values.end();) { + if (it->first.compare(0, oldPrefix.size(), oldPrefix) == 0) { + moved.push_back( + { newPrefix + it->first.substr(oldPrefix.size()), + it->second }); + it = m_values.erase(it); + } else { + ++it; + } + } + for (auto &pair : moved) + m_values[pair.first] = pair.second; + if (!moved.empty()) + autoSave(); +} + +void GlobalStateStore::clearToDefaults() +{ + m_values.clear(); + autoSave(); +} + +nlohmann::json GlobalStateStore::serialize() const +{ + nlohmann::json j; + j["version"] = "1.0"; + nlohmann::json values = nlohmann::json::object(); + for (const auto &pair : m_values) { + const Value &v = pair.second; + switch (v.type) { + case Value::BOOL: + values[pair.first] = v.boolVal; + break; + case Value::INT: + values[pair.first] = v.intVal; + break; + case Value::FLOAT: + values[pair.first] = v.floatVal; + break; + case Value::STRING: + values[pair.first] = v.strVal; + break; + } + } + j["values"] = values; + return j; +} + +void GlobalStateStore::deserialize(const nlohmann::json &j) +{ + m_values.clear(); + if (!j.contains("values") || !j["values"].is_object()) + return; + for (auto &[name, val] : j["values"].items()) { + if (val.is_boolean()) + m_values[name] = makeBool(val.get()); + else if (val.is_number_integer()) + m_values[name] = makeInt(val.get()); + else if (val.is_number_float()) + m_values[name] = makeFloat(val.get()); + else if (val.is_string()) + m_values[name] = makeString(val.get()); + } +} + +bool GlobalStateStore::saveToFile(const std::string &filepath) +{ + try { + std::ofstream file(filepath); + if (!file.is_open()) { + m_lastError = "Cannot open " + filepath; + return false; + } + file << serialize().dump(4); + return true; + } catch (const std::exception &e) { + m_lastError = std::string("Save error: ") + e.what(); + return false; + } +} + +bool GlobalStateStore::loadFromFile(const std::string &filepath) +{ + try { + std::ifstream file(filepath); + if (!file.is_open()) { + m_lastError = "Cannot open " + filepath; + return false; + } + nlohmann::json j; + file >> j; + deserialize(j); + return true; + } catch (const std::exception &e) { + m_lastError = std::string("Load error: ") + e.what(); + return false; + } +} + +void GlobalStateStore::autoSave() +{ + if (m_autoSaveEnabled && !m_autoSavePath.empty()) + saveToFile(m_autoSavePath); +} diff --git a/src/features/editScene/systems/GlobalStateStore.hpp b/src/features/editScene/systems/GlobalStateStore.hpp new file mode 100644 index 0000000..9aed34d --- /dev/null +++ b/src/features/editScene/systems/GlobalStateStore.hpp @@ -0,0 +1,117 @@ +#ifndef EDITSCENE_GLOBALSTATESTORE_HPP +#define EDITSCENE_GLOBALSTATESTORE_HPP +#pragma once + +#include +#include +#include +#include + +/** + * Global persistent variable storage (F9). + * + * A scene-independent singleton store of typed variables (string name + + * bool / int64 / double / string value) that can be created, set, read and + * removed from both C++ and Lua (see lua/LuaGlobalStateApi.cpp). It is + * the generic persistent storage for gameplay systems; each system owns a + * dot-namespaced key prefix (registered in AGENTS.md). First consumer: + * persistent door state under "door..locked" / ".isOpen" (F6). + * + * Semantics: + * - Systems declare the variables they use with declareDefault(); reading + * an unset variable returns its declared 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. + * - The store survives EditorApp::switchScene() (singleton, scene + * independent). + * - Save-game integration: EditorApp::saveGame()/loadGame() carry the + * store in the save file's "globalState" section. + * - autoSave() mirrors the store to global_state.json (CWD = project dir + * after F8), the cross-session cache in game mode. Editor mode does + * NOT load that file: EditorApp calls clearToDefaults() at startup and + * on scene (re)load so editing always starts from system defaults. + */ +class GlobalStateStore { +public: + static GlobalStateStore &getInstance(); + + /* Register a default for a variable. Reading an unset variable + * returns its declared default. Re-declaring updates the default; + * explicitly set values are unaffected. Not persisted. */ + 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); + + /* Explicitly set a variable (persisted). Triggers autoSave(). */ + void set(const std::string &name, bool v); + void set(const std::string &name, int64_t v); + void set(const std::string &name, double v); + void set(const std::string &name, const std::string &v); + + /* Typed reads: explicit value first, then the declared default, + * then the caller's fallback. A type mismatch falls through to the + * next source (no implicit conversion). */ + 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; + + /* True when the variable has an explicit value or a declared + * default. */ + bool has(const std::string &name) const; + + /* Remove the explicit value (the declared default, if any, still + * applies). Triggers autoSave(). */ + void remove(const std::string &name); + + /* Move all explicitly set values whose name starts with oldPrefix + * to newPrefix (F0 door reassignment state migration). Defaults + * are not renamed (they are re-declared by the systems). */ + void renamePrefix(const std::string &oldPrefix, + const std::string &newPrefix); + + /* Drop all explicit values, keeping the declared defaults. + * Triggers autoSave(). */ + void clearToDefaults(); + + nlohmann::json serialize() const; + void deserialize(const nlohmann::json &j); + + bool saveToFile(const std::string &filepath); + bool loadFromFile(const std::string &filepath); + const std::string &getLastError() const { return m_lastError; } + + void autoSave(); + void setAutoSaveEnabled(bool enabled) { m_autoSaveEnabled = enabled; } + +private: + GlobalStateStore(); + ~GlobalStateStore() = default; + + GlobalStateStore(const GlobalStateStore &) = delete; + GlobalStateStore &operator=(const GlobalStateStore &) = delete; + + struct Value { + enum Type { BOOL, INT, FLOAT, STRING } type = BOOL; + bool boolVal = false; + int64_t intVal = 0; + double floatVal = 0.0; + std::string strVal; + }; + + static Value makeBool(bool v); + static Value makeInt(int64_t v); + static Value makeFloat(double v); + static Value makeString(const std::string &v); + + std::unordered_map m_values; + std::unordered_map m_defaults; + mutable std::string m_lastError; + std::string m_autoSavePath; + bool m_autoSaveEnabled = true; +}; + +#endif // EDITSCENE_GLOBALSTATESTORE_HPP diff --git a/src/features/editScene/systems/NavMeshSystem.cpp b/src/features/editScene/systems/NavMeshSystem.cpp index 24ab479..19077da 100644 --- a/src/features/editScene/systems/NavMeshSystem.cpp +++ b/src/features/editScene/systems/NavMeshSystem.cpp @@ -7,8 +7,10 @@ #include "../components/StaticGeometryMember.hpp" #include "../components/PhysicsCollider.hpp" #include "../components/CellGrid.hpp" +#include "../components/Door.hpp" #include "../recast/TileCacheNavMesh.hpp" #include "CellGridSystem.hpp" +#include "DoorSystem.hpp" #include #include #include @@ -75,9 +77,14 @@ static void collectEntitiesFromNode(Ogre::SceneNode *node, // and all its descendants. This is needed because flecs parent/child // hierarchy does NOT map to Ogre SceneNode hierarchy; each entity has // its own independent scene node. +// F7: door entities (and their subtrees: leaf, collider, occluder) are +// skipped - doors are not navmesh obstacles; the doorway floor is +// painted with the door area id instead (see getDoorVolume). static void collectEntitiesFromFlecsEntity(flecs::entity e, std::vector &out) { + if (e.has()) + return; if (e.has()) { auto &trans = e.get(); if (trans.node) @@ -119,14 +126,18 @@ void NavMeshSystem::update(float deltaTime) auto &state = m_states[e.id()]; + /* F7: refresh doorway volumes before any (re)build so + * fresh tiles get the door area id. */ + if (state.navmesh && state.navmesh->isBuilt()) + state.navmesh->setDoorVolumes(collectDoorVolumes()); + // Handle full rebuild request if (comp.dirty) { buildOrUpdate(e.id(), comp, state); comp.dirty = false; comp.needsPartialRebuild = false; state.lastTransformVersions.clear(); - return; - } + } else { // Check for geometry changes that need partial rebuild bool changed = false; @@ -191,6 +202,15 @@ void NavMeshSystem::update(float deltaTime) if (state.debugDraw) state.navmesh->drawNavMesh(); } + } + + /* F7: pump queued tile-cache requests (locked-door + * obstacles) and keep the obstacle set in sync with the + * live doors' locked state. */ + if (state.navmesh && state.navmesh->isBuilt()) { + state.navmesh->update(); + syncDoorObstacles(state); + } }); } @@ -199,6 +219,7 @@ void NavMeshSystem::buildOrUpdate(flecs::entity_t e, NavMeshComponent &comp, { state.navmesh.reset(); state.lastTransformVersions.clear(); + state.doorObstacles.clear(); TileCacheNavMesh::BuildParams params; params.cellSize = comp.cellSize; @@ -212,8 +233,10 @@ void NavMeshSystem::buildOrUpdate(flecs::entity_t e, NavMeshComponent &comp, params.regionMinSize = comp.regionMinSize; params.regionMergeSize = comp.regionMergeSize; params.tileSize = comp.tileSize; + params.doorAreaCost = comp.doorAreaCost; state.navmesh = std::make_unique(m_sceneMgr, params); + state.navmesh->setDoorVolumes(collectDoorVolumes()); std::vector entities; std::vector tempEntities; @@ -237,6 +260,11 @@ void NavMeshSystem::buildOrUpdate(flecs::entity_t e, NavMeshComponent &comp, Ogre::LogManager::getSingleton().logMessage( "[NavMeshSystem] Navmesh built successfully"); + /* F7: doors that are already locked get their obstacle right + * away (syncDoorObstacles keeps this in sync from now on). */ + syncDoorObstacles(state); + state.navmesh->update(); + if (state.debugDraw) state.navmesh->drawNavMesh(); @@ -298,6 +326,11 @@ void NavMeshSystem::collectStaticEntities(std::vector &out, return; if (!e.has()) return; + // F7: doors are not obstacles; the doorway gets + // an area cost instead (locked doors become + // dynamic obstacles, see syncDoorObstacles) + if (e.has()) + return; auto &rend = e.get(); if (rend.entity) { addExisting(rend.entity); @@ -326,6 +359,9 @@ void NavMeshSystem::collectStaticEntities(std::vector &out, NavMeshGeometrySource &nmg) { if (!nmg.include) return; + // F7: doors are not obstacles (see path 1) + if (e.has()) + return; Ogre::String meshName; if (e.has()) { auto &rend = e.get(); @@ -515,6 +551,113 @@ Ogre::AxisAlignedBox NavMeshSystem::getEntityBounds(flecs::entity e) return worldBB; } +Ogre::AxisAlignedBox NavMeshSystem::getDoorVolume(flecs::entity doorEntity) +{ + if (!doorEntity.has() || + !doorEntity.has()) + return Ogre::AxisAlignedBox::EXTENT_NULL; + auto &door = doorEntity.get(); + auto &trans = doorEntity.get(); + if (!trans.node) + return Ogre::AxisAlignedBox::EXTENT_NULL; + + /* Closed-pose world transform of the hinge: the live node + * transform includes the current swing angle, so rebuild it + * from the parent's derived transform and the stored closed + * orientation. */ + Ogre::Quaternion parentRot = Ogre::Quaternion::IDENTITY; + Ogre::Vector3 parentScale = Ogre::Vector3::UNIT_SCALE; + Ogre::Node *parent = trans.node->getParent(); + if (parent) { + parentRot = parent->_getDerivedOrientation(); + parentScale = parent->_getDerivedScale(); + } + Ogre::Vector3 hingePos = trans.node->_getDerivedPosition(); + Ogre::Quaternion closedRot = parentRot * door.closedOrientation; + Ogre::Vector3 scale = parentScale * trans.node->getScale(); + + /* The door's box-collider child carries the closed-pose leaf + * shape exactly (offset = DoorComponent::centerOffset). */ + Ogre::Vector3 halfExtents(0.5f, 1.0f, 0.1f); + bool found = false; + doorEntity.children([&](flecs::entity child) { + if (found || !child.has()) + return; + halfExtents = child.get().parameters; + found = true; + }); + halfExtents.x *= Ogre::Math::Abs(scale.x); + halfExtents.y *= Ogre::Math::Abs(scale.y); + halfExtents.z *= Ogre::Math::Abs(scale.z); + + Ogre::Vector3 center = + hingePos + closedRot * (scale * door.centerOffset); + + /* Pad XZ so the marked area survives agent-radius erosion at + * the doorway edges, and Y so the floor spans under the leaf + * are caught. */ + Ogre::Vector3 pad(0.3f, 0.3f, 0.3f); + return Ogre::AxisAlignedBox(center - halfExtents - pad, + center + halfExtents + pad); +} + +std::vector NavMeshSystem::collectDoorVolumes() +{ + std::vector out; + m_world.query().each( + [&](flecs::entity de, DoorComponent &, TransformComponent &) { + Ogre::AxisAlignedBox vol = getDoorVolume(de); + if (!vol.isNull()) + out.push_back(vol); + }); + return out; +} + +void NavMeshSystem::syncDoorObstacles(NavMeshState &state) +{ + if (!state.navmesh || !state.navmesh->isBuilt()) + return; + + /* Drop obstacles whose door entity is gone (grid rebuilds + * destroy and respawn door entities). */ + for (auto it = state.doorObstacles.begin(); + it != state.doorObstacles.end();) { + if (!m_world.entity(it->first).is_alive()) { + state.navmesh->removeObstacle(it->second); + it = state.doorObstacles.erase(it); + } else { + ++it; + } + } + + m_world.query().each( + [&](flecs::entity de, DoorComponent &door, + TransformComponent &) { + auto it = state.doorObstacles.find(de.id()); + bool locked = DoorSystem::isDoorLocked(door); + if (locked && it == state.doorObstacles.end()) { + Ogre::AxisAlignedBox vol = getDoorVolume(de); + if (vol.isNull()) + return; + uint32_t ref = state.navmesh->addObstacle(vol); + if (ref) + state.doorObstacles[de.id()] = ref; + } else if (!locked && it != state.doorObstacles.end()) { + state.navmesh->removeObstacle(it->second); + state.doorObstacles.erase(it); + } + }); +} + +unsigned char NavMeshSystem::getPolyAreaAt(flecs::entity navmeshEntity, + const Ogre::Vector3 &pos) +{ + auto it = m_states.find(navmeshEntity.id()); + if (it == m_states.end() || !it->second.navmesh) + return 0xFF; + return it->second.navmesh->getAreaAt(pos); +} + bool NavMeshSystem::findPath(flecs::entity navmeshEntity, Ogre::Vector3 start, Ogre::Vector3 end, std::vector &path) diff --git a/src/features/editScene/systems/NavMeshSystem.hpp b/src/features/editScene/systems/NavMeshSystem.hpp index f697a17..c1f310f 100644 --- a/src/features/editScene/systems/NavMeshSystem.hpp +++ b/src/features/editScene/systems/NavMeshSystem.hpp @@ -49,6 +49,12 @@ public: // --- CellGrid integration --- void setCellGridSystem(CellGridSystem *system); + /* Area id of the nearest navmesh poly to pos (F7: door areas + * report TileCacheNavMesh::EDITSCENE_AREA_DOOR); 0xFF when no + * navmesh/poly. Debug/testing aid. */ + unsigned char getPolyAreaAt(flecs::entity navmeshEntity, + const Ogre::Vector3 &pos); + private: struct NavMeshState { std::unique_ptr navmesh; @@ -56,6 +62,9 @@ private: bool firstBuild = true; // Track transform versions for rebuild std::unordered_map lastTransformVersions; + // F7: tile-cache obstacle refs for locked doors, keyed by + // door entity id + std::unordered_map doorObstacles; }; flecs::world &m_world; @@ -76,6 +85,13 @@ private: const Ogre::Quaternion &rot, const Ogre::Vector3 &scale); Ogre::AxisAlignedBox getEntityBounds(flecs::entity e); + + // F7: door volumes (closed-pose doorway bounds, render space) + // paint the door area id during tile rasterization; locked doors + // additionally get a tile-cache obstacle that blocks pathing. + Ogre::AxisAlignedBox getDoorVolume(flecs::entity door); + std::vector collectDoorVolumes(); + void syncDoorObstacles(NavMeshState &state); }; #endif // EDITSCENE_NAVMESH_SYSTEM_HPP diff --git a/src/features/editScene/systems/PathFollowingSystem.cpp b/src/features/editScene/systems/PathFollowingSystem.cpp index 70b0bae..577bbbc 100644 --- a/src/features/editScene/systems/PathFollowingSystem.cpp +++ b/src/features/editScene/systems/PathFollowingSystem.cpp @@ -1,14 +1,17 @@ #include "PathFollowingSystem.hpp" #include "AnimationTreeSystem.hpp" #include "NavMeshSystem.hpp" +#include "DoorSystem.hpp" #include "CharacterRegistry.hpp" #include "../components/PathFollowing.hpp" #include "../components/Character.hpp" #include "../components/CharacterIdentity.hpp" #include "../components/CharacterSlots.hpp" +#include "../components/Door.hpp" #include "../components/Transform.hpp" #include "../components/NavMesh.hpp" #include +#include #include PathFollowingSystem::PathFollowingSystem(flecs::world &world, @@ -75,6 +78,59 @@ void PathFollowingSystem::rotateTowards(flecs::entity e, trans.rotation = Ogre::Quaternion(yaw, Ogre::Vector3::UNIT_Y); } +bool PathFollowingSystem::handleDoorAhead(const Ogre::Vector3 &charPos, + const Ogre::Vector3 &waypointPos) +{ + bool wait = false; + m_world.query().each( + [&](flecs::entity, DoorComponent &door, + TransformComponent &dtrans) { + /* Scene-switch doors belong to the player. */ + if (!door.sceneSwitchPath.empty()) + return; + /* Locked doors are navmesh obstacles; paths + * already avoid them. */ + if (DoorSystem::isDoorLocked(door)) + return; + if (!dtrans.node) + return; + + Ogre::Vector3 center = + dtrans.node->_getDerivedPosition() + + dtrans.node->_getDerivedOrientation() * + door.centerOffset; + + /* Distance from the XZ segment charPos->waypoint + * to the door centre. */ + Ogre::Vector2 a(charPos.x, charPos.z); + Ogre::Vector2 b(waypointPos.x, waypointPos.z); + Ogre::Vector2 p(center.x, center.z); + Ogre::Vector2 ab = b - a; + float t = 0.0f; + float abLen2 = ab.squaredLength(); + if (abLen2 > 1e-6f) { + t = (p - a).dotProduct(ab) / abLen2; + t = std::max(0.0f, std::min(1.0f, t)); + } + float segDist = (a + ab * t - p).length(); + float charDist = (p - a).length(); + + if (segDist > 1.2f || charDist > 2.5f) + return; + + if (door.currentAngle < 40.0f) { + /* Closed or still swinging: request the + * toggle once and hold position until + * the leaf clears the doorway. */ + if (door.currentAngle < 5.0f && + !door.toggleRequested && !door.isOpen) + door.toggleRequested = true; + wait = true; + } + }); + return wait; +} + void PathFollowingSystem::applyLocomotionState(flecs::entity e) { if (!e.has() || !m_animTreeSystem) @@ -188,6 +244,16 @@ void PathFollowingSystem::update(float deltaTime) toTarget = waypointPos - charPos; } + /* F7: hold while a door ahead on the path is + * closed or still swinging open. */ + if (handleDoorAhead(charPos, waypointPos)) { + if (!cc.useRootMotion) + cc.linearVelocity = + Ogre::Vector3::ZERO; + applyLocomotionState(e); + return; + } + // Move toward waypoint toTarget.y = 0; if (toTarget.squaredLength() > 0.0001f) { diff --git a/src/features/editScene/systems/PathFollowingSystem.hpp b/src/features/editScene/systems/PathFollowingSystem.hpp index f207a37..31d8304 100644 --- a/src/features/editScene/systems/PathFollowingSystem.hpp +++ b/src/features/editScene/systems/PathFollowingSystem.hpp @@ -35,6 +35,13 @@ private: Ogre::Vector3 getEntityPosition(flecs::entity e); void rotateTowards(flecs::entity e, const Ogre::Vector3 &direction, float deltaTime); + /* F7: request a toggle on a closed, unlocked door whose doorway + * the segment charPos->waypointPos is about to cross; returns + * true while the character should hold position (door closed or + * still swinging). Locked doors need no handling - they are + * navmesh obstacles (NavMeshSystem::syncDoorObstacles). */ + bool handleDoorAhead(const Ogre::Vector3 &charPos, + const Ogre::Vector3 &waypointPos); flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; diff --git a/src/features/editScene/systems/SaveLoadSystem.cpp b/src/features/editScene/systems/SaveLoadSystem.cpp index 3be1c96..05b852c 100644 --- a/src/features/editScene/systems/SaveLoadSystem.cpp +++ b/src/features/editScene/systems/SaveLoadSystem.cpp @@ -1,4 +1,5 @@ #include "SaveLoadSystem.hpp" +#include "../ProjectConfig.hpp" #include #include #include @@ -6,6 +7,22 @@ #include #include +/* ===================================================================== */ +/* Application name (per-project save directory, F8) */ +/* ===================================================================== */ + +static std::string s_appName = "World2"; + +void SaveLoadSystem::setAppName(const std::string &appName) +{ + s_appName = sanitizeAppName(appName); +} + +const std::string &SaveLoadSystem::getAppName() +{ + return s_appName; +} + /* ===================================================================== */ /* OS-dependent save directory */ /* ===================================================================== */ @@ -17,24 +34,25 @@ std::string SaveLoadSystem::getSaveDirectory() #ifdef _WIN32 const char *appdata = getenv("APPDATA"); if (appdata) - dir = std::string(appdata) + "/World2/saves/"; + dir = std::string(appdata) + "/" + s_appName + "/saves/"; else dir = "./saves/"; #elif defined(__APPLE__) const char *home = getenv("HOME"); if (home) - dir = std::string(home) + - "/Library/Application Support/World2/saves/"; + dir = std::string(home) + "/Library/Application Support/" + + s_appName + "/saves/"; else dir = "./saves/"; #else /* Linux */ const char *xdgDataHome = getenv("XDG_DATA_HOME"); if (xdgDataHome) - dir = std::string(xdgDataHome) + "/World2/saves/"; + dir = std::string(xdgDataHome) + "/" + s_appName + "/saves/"; else { const char *home = getenv("HOME"); if (home) - dir = std::string(home) + "/.local/share/World2/saves/"; + dir = std::string(home) + "/.local/share/" + s_appName + + "/saves/"; else dir = "./saves/"; } diff --git a/src/features/editScene/systems/SaveLoadSystem.hpp b/src/features/editScene/systems/SaveLoadSystem.hpp index 7848ac4..a65b6d7 100644 --- a/src/features/editScene/systems/SaveLoadSystem.hpp +++ b/src/features/editScene/systems/SaveLoadSystem.hpp @@ -10,9 +10,13 @@ * Save/load system for game-mode saves. * * Manages save slots in an OS-dependent user data directory: - * Linux: ~/.local/share/World2/saves/ - * Windows: %APPDATA%/World2/saves/ - * macOS: ~/Library/Application Support/World2/saves/ + * Linux: ~/.local/share//saves/ + * Windows: %APPDATA%//saves/ + * macOS: ~/Library/Application Support//saves/ + * + * defaults to "World2" (backwards compatible) and is replaced + * by the per-project appName when a project directory is opened (F8, see + * ProjectConfig) via setAppName(). * * Each slot is a single JSON file containing: * - Save metadata (base scene, timestamp, play time, slot name) @@ -33,6 +37,18 @@ public: std::string baseScene; }; + /** + * Set the application name used for the save directory + * (//saves/). Called when a project directory + * with its own appName is opened; defaults to "World2". + */ + static void setAppName(const std::string &appName); + + /** + * Get the application name used for the save directory. + */ + static const std::string &getAppName(); + /** * Get the OS-dependent save directory. * Creates the directory if it doesn't exist. diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 79b7716..b703fb4 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -41,6 +41,7 @@ #include "../components/Skybox.hpp" #include "../components/EventHandler.hpp" #include "../components/SceneScript.hpp" +#include "../components/StandaloneDoor.hpp" #include "../components/ActionDatabase.hpp" #include "../components/ActionDebug.hpp" #include "../components/SmartObject.hpp" @@ -410,6 +411,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["sceneScript"] = serializeSceneScript(entity); } + if (entity.has()) { + json["standaloneDoor"] = serializeStandaloneDoor(entity); + } if (entity.has()) { json["goapPlanner"] = serializeGoapPlanner(entity); } @@ -652,6 +656,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("sceneScript")) { deserializeSceneScript(entity, json["sceneScript"]); } + if (json.contains("standaloneDoor")) { + deserializeStandaloneDoor(entity, json["standaloneDoor"]); + } if (json.contains("goapPlanner")) { deserializeGoapPlanner(entity, json["goapPlanner"]); } @@ -967,6 +974,9 @@ void SceneSerializer::deserializeEntityComponents( if (json.contains("sceneScript")) { deserializeSceneScript(entity, json["sceneScript"]); } + if (json.contains("standaloneDoor")) { + deserializeStandaloneDoor(entity, json["standaloneDoor"]); + } if (json.contains("goapPlanner")) { deserializeGoapPlanner(entity, json["goapPlanner"]); } @@ -2580,6 +2590,7 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity) json["cellSize"] = grid.cellSize; json["cellHeight"] = grid.cellHeight; json["generationScript"] = grid.generationScript; + json["generationMode"] = grid.generationMode; // Serialize texture rectangles json["floorRectName"] = grid.floorRectName; @@ -2601,10 +2612,43 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity) json["doorUseMeshMaterial"] = grid.doorUseMeshMaterial; json["doorOpenAngle"] = grid.doorOpenAngle; json["doorOpenSpeed"] = grid.doorOpenSpeed; + json["doorSwingReversed"] = grid.doorSwingReversed; json["doorActionName"] = grid.doorActionName; json["doorSceneSwitchPath"] = grid.doorSceneSwitchPath; json["doorSceneSwitchTarget"] = grid.doorSceneSwitchTarget; + // F5: window glass (exteriorOnly) + json["glassColor"] = { grid.glassColor.r, grid.glassColor.g, + grid.glassColor.b, grid.glassColor.a }; + json["glassMaterialName"] = grid.glassMaterialName; + json["glassReflectivity"] = grid.glassReflectivity; + + // F0: grid identity + per-doorway configuration overrides. + // The uid is generated lazily on first save when missing (e.g. + // components created from Lua). + json["gridUid"] = + const_cast(grid).ensureGridUid(); + nlohmann::json doorConfigsJson = nlohmann::json::object(); + for (const auto &pair : grid.doorConfigs) { + const CellGridDoorConfig &cfg = pair.second; + nlohmann::json cj; + cj["label"] = cfg.label; + cj["hasOverride"] = cfg.hasOverride; + cj["openAngle"] = cfg.openAngle; + cj["openSpeed"] = cfg.openSpeed; + cj["swingReversed"] = cfg.swingReversed; + cj["actionName"] = cfg.actionName; + cj["sceneSwitchPath"] = cfg.sceneSwitchPath; + cj["sceneSwitchTarget"] = cfg.sceneSwitchTarget; + cj["disabled"] = cfg.disabled; + cj["persistent"] = cfg.persistent; + cj["lockable"] = cfg.lockable; + cj["lockedByDefault"] = cfg.lockedByDefault; + cj["keyItemId"] = cfg.keyItemId; + doorConfigsJson[pair.first] = cj; + } + json["doorConfigs"] = doorConfigsJson; + // Serialize cells nlohmann::json cellsJson = nlohmann::json::array(); for (const auto &cell : grid.cells) { @@ -2793,6 +2837,7 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity, grid.cellSize = json.value("cellSize", 4.0f); grid.cellHeight = json.value("cellHeight", 4.0f); grid.generationScript = json.value("generationScript", ""); + grid.generationMode = json.value("generationMode", "full"); // Deserialize texture rectangles grid.floorRectName = json.value("floorRectName", ""); @@ -2814,10 +2859,46 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity, grid.doorUseMeshMaterial = json.value("doorUseMeshMaterial", false); grid.doorOpenAngle = json.value("doorOpenAngle", 100.0f); grid.doorOpenSpeed = json.value("doorOpenSpeed", 180.0f); + grid.doorSwingReversed = json.value("doorSwingReversed", false); grid.doorActionName = json.value("doorActionName", ""); grid.doorSceneSwitchPath = json.value("doorSceneSwitchPath", ""); grid.doorSceneSwitchTarget = json.value("doorSceneSwitchTarget", ""); + // F5: window glass (exteriorOnly) + if (json.contains("glassColor") && json["glassColor"].is_array() && + json["glassColor"].size() >= 4) { + grid.glassColor = Ogre::ColourValue(json["glassColor"][0], + json["glassColor"][1], + json["glassColor"][2], + json["glassColor"][3]); + } + grid.glassMaterialName = json.value("glassMaterialName", ""); + grid.glassReflectivity = json.value("glassReflectivity", 0.8f); + + // F0: grid identity + per-doorway configuration overrides. Old + // scenes have no gridUid - a fresh one is generated below. + grid.gridUid = json.value("gridUid", ""); + if (json.contains("doorConfigs") && json["doorConfigs"].is_object()) { + for (auto &[key, cj] : json["doorConfigs"].items()) { + CellGridDoorConfig cfg; + cfg.label = cj.value("label", ""); + cfg.hasOverride = cj.value("hasOverride", false); + cfg.openAngle = cj.value("openAngle", 0.0f); + cfg.openSpeed = cj.value("openSpeed", 0.0f); + cfg.swingReversed = cj.value("swingReversed", false); + cfg.actionName = cj.value("actionName", ""); + cfg.sceneSwitchPath = cj.value("sceneSwitchPath", ""); + cfg.sceneSwitchTarget = cj.value("sceneSwitchTarget", ""); + cfg.disabled = cj.value("disabled", false); + cfg.persistent = cj.value("persistent", false); + cfg.lockable = cj.value("lockable", false); + cfg.lockedByDefault = cj.value("lockedByDefault", false); + cfg.keyItemId = cj.value("keyItemId", ""); + grid.doorConfigs[key] = cfg; + } + } + grid.ensureGridUid(); + // Deserialize cells if (json.contains("cells") && json["cells"].is_array()) { for (const auto &cellJson : json["cells"]) { @@ -4086,6 +4167,7 @@ nlohmann::json SceneSerializer::serializeNavMesh(flecs::entity entity) json["regionMinSize"] = nm.regionMinSize; json["regionMergeSize"] = nm.regionMergeSize; json["tileSize"] = nm.tileSize; + json["doorAreaCost"] = nm.doorAreaCost; json["enabled"] = nm.enabled; json["debugDraw"] = nm.debugDraw; return json; @@ -4106,6 +4188,7 @@ void SceneSerializer::deserializeNavMesh(flecs::entity entity, nm.regionMinSize = json.value("regionMinSize", 50.0f); nm.regionMergeSize = json.value("regionMergeSize", 20.0f); nm.tileSize = json.value("tileSize", 48); + nm.doorAreaCost = json.value("doorAreaCost", 5.0f); nm.enabled = json.value("enabled", true); nm.debugDraw = json.value("debugDraw", false); nm.dirty = true; @@ -4194,6 +4277,56 @@ void SceneSerializer::deserializeSceneScript(flecs::entity entity, entity.set(script); } +nlohmann::json SceneSerializer::serializeStandaloneDoor(flecs::entity entity) +{ + const StandaloneDoorComponent &door = + entity.get(); + nlohmann::json json; + json["meshName"] = door.meshName; + json["useMeshMaterial"] = door.useMeshMaterial; + json["rectName"] = door.rectName; + json["leafWidth"] = door.leafWidth; + json["leafHeight"] = door.leafHeight; + json["leafThickness"] = door.leafThickness; + json["openAngle"] = door.openAngle; + json["openSpeed"] = door.openSpeed; + json["swingReversed"] = door.swingReversed; + json["actionName"] = door.actionName; + json["sceneSwitchPath"] = door.sceneSwitchPath; + json["sceneSwitchTarget"] = door.sceneSwitchTarget; + json["persistent"] = door.persistent; + json["lockable"] = door.lockable; + json["lockedByDefault"] = door.lockedByDefault; + json["keyItemId"] = door.keyItemId; + json["doorId"] = door.doorId; + return json; +} + +void SceneSerializer::deserializeStandaloneDoor(flecs::entity entity, + const nlohmann::json &json) +{ + StandaloneDoorComponent door; + door.meshName = json.value("meshName", ""); + door.useMeshMaterial = json.value("useMeshMaterial", false); + door.rectName = json.value("rectName", ""); + door.leafWidth = json.value("leafWidth", 1.0f); + door.leafHeight = json.value("leafHeight", 2.0f); + door.leafThickness = json.value("leafThickness", 0.08f); + door.openAngle = json.value("openAngle", 100.0f); + door.openSpeed = json.value("openSpeed", 180.0f); + door.swingReversed = json.value("swingReversed", false); + door.actionName = json.value("actionName", ""); + door.sceneSwitchPath = json.value("sceneSwitchPath", ""); + door.sceneSwitchTarget = json.value("sceneSwitchTarget", ""); + door.persistent = json.value("persistent", false); + door.lockable = json.value("lockable", false); + door.lockedByDefault = json.value("lockedByDefault", false); + door.keyItemId = json.value("keyItemId", ""); + door.doorId = json.value("doorId", ""); + // dirty stays true so StandaloneDoorSystem builds the door + entity.set(door); +} + nlohmann::json SceneSerializer::serializeItem(flecs::entity entity) { const ItemComponent &item = entity.get(); diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 1b21d02..c059d57 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -278,6 +278,7 @@ private: nlohmann::json serializeActuator(flecs::entity entity); nlohmann::json serializeEventHandler(flecs::entity entity); nlohmann::json serializeSceneScript(flecs::entity entity); + nlohmann::json serializeStandaloneDoor(flecs::entity entity); nlohmann::json serializeGoapPlanner(flecs::entity entity); nlohmann::json serializeGoapRunner(flecs::entity entity); nlohmann::json serializeBehaviorTree(flecs::entity entity); @@ -294,6 +295,8 @@ private: const nlohmann::json &json); void deserializeSceneScript(flecs::entity entity, const nlohmann::json &json); + void deserializeStandaloneDoor(flecs::entity entity, + const nlohmann::json &json); void deserializeGoapPlanner(flecs::entity entity, const nlohmann::json &json); void deserializeGoapRunner(flecs::entity entity, diff --git a/src/features/editScene/systems/StandaloneDoorSystem.cpp b/src/features/editScene/systems/StandaloneDoorSystem.cpp new file mode 100644 index 0000000..c6373f9 --- /dev/null +++ b/src/features/editScene/systems/StandaloneDoorSystem.cpp @@ -0,0 +1,216 @@ +#include "StandaloneDoorSystem.hpp" +#include "DoorBuilder.hpp" +#include "../components/StandaloneDoor.hpp" +#include "../components/Transform.hpp" +#include "../components/ProceduralMaterial.hpp" +#include "../components/ProceduralTexture.hpp" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/* Random UUID-like hex string (8-4-4-4-12), same scheme as + * CellGridComponent::ensureGridUid(). */ +std::string generateDoorUid() +{ + 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), + (unsigned)((b & 0x3fffu) | 0x8000u), + (unsigned)((b >> 16) & 0xffffu), + (unsigned)((b >> 32) & 0xffffffffu)); + return buf; +} + +} // namespace + +StandaloneDoorSystem::StandaloneDoorSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr) + : m_world(world) + , m_sceneMgr(sceneMgr) +{ +} + +void StandaloneDoorSystem::destroyBuilt(BuiltDoor &built) +{ + flecs::entity doorEntity = m_world.entity(built.doorEntity); + if (doorEntity.is_alive()) + doorEntity.destruct(); + if (built.ownsLeafMesh && !built.leafMeshName.empty()) { + auto &meshMgr = Ogre::MeshManager::getSingleton(); + if (meshMgr.resourceExists(built.leafMeshName)) + meshMgr.remove(built.leafMeshName); + } + built = BuiltDoor(); +} + +void StandaloneDoorSystem::cleanupDead() +{ + for (auto it = m_built.begin(); it != m_built.end();) { + if (!m_world.entity(it->first).is_alive()) { + destroyBuilt(it->second); + it = m_built.erase(it); + } else { + ++it; + } + } +} + +void StandaloneDoorSystem::rebuild(flecs::entity entity, + StandaloneDoorComponent &door) +{ + BuiltDoor &built = m_built[entity.id()]; + destroyBuilt(built); + + if (!entity.has()) + return; + auto &transform = entity.get(); + if (!transform.node) + return; + + /* Doors that need a persistent ID but have none get a generated + * UUID, written back so it stays stable from then on. */ + if (door.doorId.empty() && + (door.persistent || door.lockable || + !door.sceneSwitchPath.empty())) { + door.doorId = generateDoorUid(); + } + + // Resolve the leaf mesh: custom mesh or procedural box leaf. + DoorBuildParams params; + params.openAngle = door.openAngle; + params.openSpeed = door.openSpeed; + params.swingReversed = door.swingReversed; + params.actionName = door.actionName; + params.sceneSwitchPath = door.sceneSwitchPath; + params.sceneSwitchTarget = door.sceneSwitchTarget; + params.persistent = door.persistent; + params.lockable = door.lockable; + params.lockedByDefault = door.lockedByDefault; + params.keyItemId = door.keyItemId; + params.doorId = door.doorId; + params.leafWidth = door.leafWidth; + params.leafHeight = door.leafHeight; + params.leafThickness = door.leafThickness; + + // The entity's own procedural material (if any) skins the + // procedural leaf; rectName picks the UV rect of its texture atlas. + if (entity.has()) { + params.materialName = + entity.get().materialName; + } + + if (!door.meshName.empty()) { + auto &meshMgr = Ogre::MeshManager::getSingleton(); + Ogre::MeshPtr customMesh = meshMgr.getByName(door.meshName); + if (!customMesh) { + Ogre::String group = Ogre::ResourceGroupManager:: + getSingleton() + .findGroupContainingResource( + door.meshName); + if (!group.empty()) + customMesh = meshMgr.load(door.meshName, group); + } + if (customMesh) { + params.leafMeshName = door.meshName; + params.customMesh = true; + params.useMeshMaterial = door.useMeshMaterial; + params.customBounds = customMesh->getBounds(); + } else { + Ogre::LogManager::getSingleton().logMessage( + "StandaloneDoor: Could not load door mesh '" + + door.meshName + + "', falling back to procedural door"); + } + } + + if (params.leafMeshName.empty()) { + /* Procedural box leaf, hinge edge at the origin, spanning + * +X/+Y (same convention as CellGrid doors). */ + std::string leafMeshName = + "StandaloneDoor_" + std::to_string(entity.id()) + + "_leaf"; + auto &meshMgr = Ogre::MeshManager::getSingleton(); + if (meshMgr.resourceExists(leafMeshName)) + meshMgr.remove(leafMeshName); + + Procedural::TriangleBuffer leafTb; + Procedural::BoxGenerator() + .setSizeX(door.leafWidth) + .setSizeY(door.leafHeight) + .setSizeZ(door.leafThickness) + .setNumSegX(1) + .setNumSegY(2) + .setNumSegZ(1) + .setPosition(Ogre::Vector3(door.leafWidth / 2.0f, + door.leafHeight / 2.0f, 0)) + .setEnableNormals(true) + .addToTriangleBuffer(leafTb); + + // UV rect mapping into the entity's own procedural + // texture atlas (optional) + if (!door.rectName.empty() && + entity.has()) { + const TextureRectInfo *rect = + entity.get() + .getNamedRect(door.rectName); + if (rect) { + for (auto &v : leafTb.getVertices()) { + v.mUV.x = rect->u1 + + v.mUV.x * (rect->u2 - rect->u1); + v.mUV.y = rect->v1 + + v.mUV.y * (rect->v2 - rect->v1); + } + } else { + Ogre::LogManager::getSingleton().logMessage( + "StandaloneDoor: texture rect '" + + door.rectName + "' not found"); + } + } + + Ogre::MeshPtr leafMesh = leafTb.transformToMesh(leafMeshName); + if (!params.materialName.empty() && + leafMesh->getNumSubMeshes() > 0) { + leafMesh->getSubMesh(0)->setMaterialName( + params.materialName); + } + params.leafMeshName = leafMeshName; + built.leafMeshName = leafMeshName; + built.ownsLeafMesh = true; + } + + /* The entity's transform IS the doorway placement (center of the + * opening at floor level, local +X along the wall). */ + flecs::entity doorEntity = + DoorBuilder::build(m_world, m_sceneMgr, entity, transform.node, + Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, params); + if (!doorEntity.is_valid()) + return; + built.doorEntity = doorEntity.id(); +} + +void StandaloneDoorSystem::update(float deltaTime) +{ + (void)deltaTime; + cleanupDead(); + + m_world.query().each( + [&](flecs::entity entity, StandaloneDoorComponent &door) { + if (!door.dirty) + return; + door.dirty = false; + rebuild(entity, door); + }); +} diff --git a/src/features/editScene/systems/StandaloneDoorSystem.hpp b/src/features/editScene/systems/StandaloneDoorSystem.hpp new file mode 100644 index 0000000..c85a12d --- /dev/null +++ b/src/features/editScene/systems/StandaloneDoorSystem.hpp @@ -0,0 +1,52 @@ +#ifndef EDITSCENE_STANDALONE_DOOR_SYSTEM_HPP +#define EDITSCENE_STANDALONE_DOOR_SYSTEM_HPP +#pragma once + +#include +#include +#include +#include + +/** + * Standalone door system (F2). + * + * Builds the runtime door subtree for entities with a serialized + * StandaloneDoorComponent, using DoorBuilder (the same entity structure + * as CellGrid doors: hinge node + leaf, collider child, actuator, F1 + * occluder for scene-switch doors). Rebuilds when the component's dirty + * flag is set (editor changes); cleans up meshes/entities when the owning + * entity disappears (polled, like CellGridSystem, because OnRemove + * observer registration shifts entity IDs and breaks SceneSerializer's + * hardcoded ID resolution). + * + * The leaf is a custom mesh (meshName, hinge edge at the origin spanning + * +X/+Y) or a procedural box of leafWidth x leafHeight x leafThickness; + * the procedural leaf uses the entity's own ProceduralMaterialComponent + * material when present, with rectName selecting the UV rect of the + * entity's ProceduralTextureComponent atlas. + */ +class StandaloneDoorSystem { +public: + StandaloneDoorSystem(flecs::world &world, Ogre::SceneManager *sceneMgr); + ~StandaloneDoorSystem() = default; + + void update(float deltaTime); + +private: + struct BuiltDoor { + flecs::entity_t doorEntity = 0; + std::string leafMeshName; // "" when a shared/custom mesh + bool ownsLeafMesh = false; + }; + + void rebuild(flecs::entity entity, + struct StandaloneDoorComponent &door); + void destroyBuilt(BuiltDoor &built); + void cleanupDead(); + + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + std::unordered_map m_built; +}; + +#endif // EDITSCENE_STANDALONE_DOOR_SYSTEM_HPP diff --git a/src/features/editScene/systems/TerrainTests.cpp b/src/features/editScene/systems/TerrainTests.cpp index 9c1dc8b..e3ee2fc 100644 --- a/src/features/editScene/systems/TerrainTests.cpp +++ b/src/features/editScene/systems/TerrainTests.cpp @@ -15,16 +15,25 @@ #include "../components/TriangleBuffer.hpp" #include "../components/Renderable.hpp" #include "../components/NavMesh.hpp" +#include "../components/Door.hpp" +#include "../components/RigidBody.hpp" #include "../components/Lod.hpp" #include "../components/PhysicsCollider.hpp" #include "../systems/SceneSerializer.hpp" #include "../systems/SpawnerRegionStore.hpp" #include "../systems/RoadRegionStore.hpp" #include "../systems/WorldMapData.hpp" +#include "../systems/NavMeshSystem.hpp" +#include "../systems/DoorSystem.hpp" +#include "../systems/GlobalStateStore.hpp" +#include "../recast/TileCacheNavMesh.hpp" #include "../ui/NavigationPanel.hpp" #include "../physics/physics.h" #include "../roadlib/RoadGeometryLib.hpp" +#include +#include + #include #include @@ -5701,6 +5710,256 @@ bool TerrainTestRunner::testPrefabJsonCache(EditorApp &app, return true; } +/* ------------------------------------------------------------------ */ +/* testNavMeshDoors (F7: navigation vs doors) */ +/* ------------------------------------------------------------------ */ + +/* Create (once) a centred box mesh plus an entity and node at pos. */ +static Ogre::Entity *navTestBox(EditorApp &app, const Ogre::String &name, + const Ogre::Vector3 &size, + const Ogre::Vector3 &pos, + Ogre::SceneNode **outNode) +{ + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + if (Ogre::MeshManager::getSingleton() + .getByName(name, group) + .isNull()) { + Procedural::BoxGenerator boxGen; + boxGen.setSizeX(size.x).setSizeY(size.y).setSizeZ(size.z); + boxGen.realizeMesh(name, group); + } + Ogre::SceneManager *sm = app.getSceneManager(); + Ogre::Entity *ent = sm->createEntity(name + "Ent", name); + Ogre::SceneNode *node = + sm->getRootSceneNode()->createChildSceneNode(name + "Node"); + node->setPosition(pos); + node->attachObject(ent); + if (outNode) + *outNode = node; + return ent; +} + +static flecs::entity navTestStaticEntity(flecs::world *w, Ogre::Entity *ent, + Ogre::SceneNode *node) +{ + flecs::entity e = w->entity(); + e.set( + { node, node->getPosition(), Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE }); + RigidBodyComponent rb; + rb.bodyType = RigidBodyComponent::BodyType::Static; + e.set(rb); + RenderableComponent rend; + rend.entity = ent; + rend.meshName = ent->getMesh()->getName(); + e.set(rend); + return e; +} + +static void navTestDestroyEntity(EditorApp &app, flecs::entity e) +{ + Ogre::SceneManager *sm = app.getSceneManager(); + if (e.has()) { + Ogre::Entity *ent = e.get().entity; + if (ent) { + Ogre::SceneNode *node = ent->getParentSceneNode(); + if (node) + node->detachAllObjects(); + sm->destroyEntity(ent); + } + } + if (e.has()) { + Ogre::SceneNode *node = e.get().node; + if (node) { + node->detachAllObjects(); + sm->destroySceneNode(node); + } + } + if (e.is_alive()) + e.destruct(); +} + +bool TerrainTestRunner::testNavMeshDoors(EditorApp &app, TerrainSystem *ts) +{ + (void)ts; + NavMeshSystem *navSys = app.getNavMeshSystem(); + if (!navSys) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no NavMeshSystem in app"); + return false; + } + + flecs::world *w = app.getWorld(); + Ogre::SceneManager *sm = app.getSceneManager(); + + /* Geometry: a flat 20x20 floor split by a wall at z=0 with a + * doorway gap at x in [-1, 1] - the only passage between the + * two halves. */ + Ogre::SceneNode *floorNode, *wallLNode, *wallRNode; + Ogre::Entity *floorEnt = + navTestBox(app, "NavTestFloor", Ogre::Vector3(20, 0.2f, 20), + Ogre::Vector3(0, -0.1f, 0), &floorNode); + Ogre::Entity *wallLEnt = + navTestBox(app, "NavTestWallL", Ogre::Vector3(9, 3, 0.4f), + Ogre::Vector3(-5.5f, 1.5f, 0), &wallLNode); + Ogre::Entity *wallREnt = + navTestBox(app, "NavTestWallR", Ogre::Vector3(9, 3, 0.4f), + Ogre::Vector3(5.5f, 1.5f, 0), &wallRNode); + + flecs::entity floorE = navTestStaticEntity(w, floorEnt, floorNode); + flecs::entity wallLE = navTestStaticEntity(w, wallLEnt, wallLNode); + flecs::entity wallRE = navTestStaticEntity(w, wallREnt, wallRNode); + + /* Door: hinge node on the left doorway edge, leaf extends +X; + * the box-collider child describes the closed-pose leaf shape + * (same layout as DoorBuilder produces). The door carries a + * static RigidBodyComponent + RenderableComponent, so it would + * be collected as navmesh geometry without the F7 exclusion. */ + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + if (Ogre::MeshManager::getSingleton() + .getByName("NavTestDoorLeaf", group) + .isNull()) { + Procedural::BoxGenerator boxGen; + boxGen.setSizeX(2.0f).setSizeY(2.0f).setSizeZ(0.1f); + boxGen.realizeMesh("NavTestDoorLeaf", group); + } + Ogre::Entity *leafEnt = + sm->createEntity("NavTestDoorLeafEnt", "NavTestDoorLeaf"); + Ogre::SceneNode *hingeNode = + sm->getRootSceneNode()->createChildSceneNode("NavTestDoorHinge"); + hingeNode->setPosition(Ogre::Vector3(-1.0f, 1.0f, 0.0f)); + Ogre::SceneNode *leafNode = + hingeNode->createChildSceneNode("NavTestDoorLeafSwing"); + leafNode->setPosition(Ogre::Vector3(1.0f, 0.0f, 0.0f)); + leafNode->attachObject(leafEnt); + + flecs::entity doorE = w->entity(); + doorE.set( + { hingeNode, hingeNode->getPosition(), + Ogre::Quaternion::IDENTITY, Ogre::Vector3::UNIT_SCALE }); + RenderableComponent drend; + drend.entity = leafEnt; + drend.meshName = "NavTestDoorLeaf"; + doorE.set(drend); + RigidBodyComponent drb; + drb.bodyType = RigidBodyComponent::BodyType::Static; + doorE.set(drb); + DoorComponent door; + door.doorId = "navtestdoor"; + door.persistent = true; + door.lockable = true; + door.centerOffset = Ogre::Vector3(1.0f, 0.0f, 0.0f); + doorE.set(door); + + flecs::entity colliderE = w->entity(); + colliderE.child_of(doorE); + colliderE.set( + { nullptr, Ogre::Vector3::ZERO, Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE }); + PhysicsColliderComponent col; + col.shapeType = PhysicsColliderComponent::ShapeType::Box; + col.parameters = Ogre::Vector3(1.0f, 1.0f, 0.1f); + col.offset = Ogre::Vector3(1.0f, 0.0f, 0.0f); + colliderE.set(col); + + /* Navmesh manager entity. */ + flecs::entity navE = w->entity(); + { + NavMeshComponent nm; + nm.cellSize = 0.3f; + nm.cellHeight = 0.2f; + nm.agentHeight = 2.0f; + nm.agentRadius = 0.4f; + nm.agentMaxClimb = 0.5f; + nm.agentMaxSlope = 30.0f; + nm.tileSize = 48; + nm.doorAreaCost = 5.0f; + navE.set(nm); + } + navSys->rebuild(navE); + + bool ok = true; + const Ogre::Vector3 start(-5.0f, 0.5f, -5.0f); + const Ogre::Vector3 target(5.0f, 0.5f, 5.0f); + std::vector path; + + /* 1. A path through the closed door's doorway exists (doors + * are not obstacles; the doorway floor is painted with the + * door area instead). */ + bool found = navSys->findPath(navE, start, target, path); + if (!found || path.empty() || + (path.back() - target).length() > 1.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - no path through doorway (closed door)"); + ok = false; + } + + /* 2. The doorway polys carry the door area id. */ + if (ok && navSys->getPolyAreaAt(navE, Ogre::Vector3(0, 0.5f, 0)) != + TileCacheNavMesh::EDITSCENE_AREA_DOOR) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - doorway polys not marked as door area, got " + + Ogre::StringConverter::toString( + (int)navSys->getPolyAreaAt( + navE, Ogre::Vector3(0, 0.5f, 0)))); + ok = false; + } + + /* 3. Locking the door adds a tile-cache obstacle; with the + * only passage blocked the target becomes unreachable. */ + DoorSystem::setDoorLocked("navtestdoor", true); + for (int i = 0; i < 5; ++i) + navSys->update(0.016f); + if (ok) { + found = navSys->findPath(navE, start, target, path); + if (found && !path.empty() && + (path.back() - target).length() <= 1.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - path still reaches through locked door"); + ok = false; + } + } + + /* 4. Unlocking removes the obstacle and restores the path. */ + DoorSystem::setDoorLocked("navtestdoor", false); + for (int i = 0; i < 5; ++i) + navSys->update(0.016f); + if (ok) { + found = navSys->findPath(navE, start, target, path); + if (!found || path.empty() || + (path.back() - target).length() > 1.5f) { + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: FAIL - path not restored after unlock"); + ok = false; + } + } + + /* Cleanup: the store keys, then the entities and the manually + * created Ogre objects. */ + GlobalStateStore::getInstance().remove("door.navtestdoor.locked"); + GlobalStateStore::getInstance().remove("door.navtestdoor.isOpen"); + + /* navTestDestroyEntity(doorE) destroys the hinge node, which + * cascades to the leaf child node. */ + if (colliderE.is_alive()) + colliderE.destruct(); + navTestDestroyEntity(app, doorE); + navTestDestroyEntity(app, floorE); + navTestDestroyEntity(app, wallLE); + navTestDestroyEntity(app, wallRE); + if (navE.is_alive()) + navE.destruct(); + + if (!ok) + return false; + + Ogre::LogManager::getSingleton().logMessage( + "TerrainTests: navmesh door test passed"); + return true; +} + /* ------------------------------------------------------------------ */ /* Logging */ /* ------------------------------------------------------------------ */ @@ -5805,6 +6064,7 @@ int TerrainTestRunner::run(EditorApp &app, int iterations) { "roadRegionStore", testRoadRegionStore }, { "roadRegionStreaming", testRoadRegionStreaming }, { "roadRegionMigration", testRoadRegionMigration }, + { "navMeshDoors", testNavMeshDoors }, }; diff --git a/src/features/editScene/systems/TerrainTests.hpp b/src/features/editScene/systems/TerrainTests.hpp index 8cf82d6..80799e8 100644 --- a/src/features/editScene/systems/TerrainTests.hpp +++ b/src/features/editScene/systems/TerrainTests.hpp @@ -115,6 +115,7 @@ static bool testTerrainPrefabSpawners(EditorApp &app, TerrainSystem *ts); static bool testRoadRegionStore(EditorApp &app, TerrainSystem *ts); static bool testRoadRegionStreaming(EditorApp &app, TerrainSystem *ts); static bool testRoadRegionMigration(EditorApp &app, TerrainSystem *ts); + static bool testNavMeshDoors(EditorApp &app, TerrainSystem *ts); static bool testMemoryStability(EditorApp &app); static void logResult(const TerrainTestResult &r); diff --git a/src/features/editScene/tests/cellgrid_door_test.cpp b/src/features/editScene/tests/cellgrid_door_test.cpp new file mode 100644 index 0000000..9d80185 --- /dev/null +++ b/src/features/editScene/tests/cellgrid_door_test.cpp @@ -0,0 +1,802 @@ +/** + * @file cellgrid_door_test.cpp + * @brief Headless tests for F0 door identity and per-door configuration. + * + * Runs without OGRE initialization (no window, no GL): doorway identity + * (edge keys, doorway collection) is pure grid math, and the + * serialization round trip only needs a flecs world (SceneSerializer + * with a null SceneManager touches no OGRE objects for name + CellGrid + * components). + * + * Covered: + * - CellGridSystem::doorEdgeKey canonicalization (both cells sharing a + * door edge produce the same key) + * - CellGridSystem::collectDoorways deduplication and internal flag + * - CellGridComponent::ensureGridUid generation and stability + * - doorConfigs + gridUid serialization round trip (incl. orphaned + * entries surviving the round trip) + * - old scenes without gridUid/doorConfigs loading with defaults + * - Lua get/set of the new CellGrid fields (gridUid, doorConfigs) is + * covered by component_lua_test (test 32) + */ + +#include +#include + +#include "../systems/CellGridSystem.hpp" +#include "../systems/SceneSerializer.hpp" +#include "../systems/DoorSystem.hpp" +#include "../systems/GlobalStateStore.hpp" +#include "../systems/EventBus.hpp" +#include "../components/CellGrid.hpp" +#include "../components/Door.hpp" +#include "../components/StandaloneDoor.hpp" +#include "../components/Transform.hpp" +#include "../lua/LuaEventApi.hpp" +#include "../lua/LuaGlobalStateApi.hpp" +#include "../lua/LuaDoorApi.hpp" + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +static int testCount = 0; +static int passCount = 0; + +#define TEST(name) \ + do { \ + testCount++; \ + printf(" TEST %d: %s ... ", testCount, name); \ + } while (0) + +#define PASS() \ + do { \ + passCount++; \ + printf("PASS\n"); \ + } while (0) + +#define FAIL(msg) \ + do { \ + printf("FAIL: %s\n", std::string(msg).c_str()); \ + return 1; \ + } while (0) + +static int testEdgeKeyCanonicalization() +{ + TEST("doorEdgeKey canonicalization"); + + // The X+ edge of cell (3,0,5) is the X- edge of cell (4,0,5). + if (CellGridSystem::doorEdgeKey(3, 0, 5, 3) != + CellGridSystem::doorEdgeKey(4, 0, 5, 2)) + FAIL("X+/X- edge keys differ"); + if (CellGridSystem::doorEdgeKey(3, 0, 5, 3) != "X:4:0:5") + FAIL("X edge key format wrong"); + + // The Z+ edge of cell (3,0,5) is the Z- edge of cell (3,0,6). + if (CellGridSystem::doorEdgeKey(3, 0, 5, 1) != + CellGridSystem::doorEdgeKey(3, 0, 6, 0)) + FAIL("Z+/Z- edge keys differ"); + if (CellGridSystem::doorEdgeKey(3, 0, 5, 1) != "Z:3:0:6") + FAIL("Z edge key format wrong"); + + // Z- and X- keep the cell coordinate. + if (CellGridSystem::doorEdgeKey(3, 0, 5, 0) != "Z:3:0:5") + FAIL("Z- edge key wrong"); + if (CellGridSystem::doorEdgeKey(3, 0, 5, 2) != "X:3:0:5") + FAIL("X- edge key wrong"); + + PASS(); + return 0; +} + +static int testCollectDoorways() +{ + TEST("collectDoorways deduplication"); + + CellGridComponent grid; + grid.width = 4; + grid.height = 1; + grid.depth = 4; + + // Two cells sharing a door edge, both flag it. + Cell a; + a.x = 0; + a.y = 0; + a.z = 0; + a.setFlag(CellFlags::DoorXPos); + grid.cells.push_back(a); + Cell b; + b.x = 1; + b.y = 0; + b.z = 0; + b.setFlag(CellFlags::DoorXNeg); + grid.cells.push_back(b); + // An internal door on the Z- edge of (0,0,1). + Cell c; + c.x = 0; + c.y = 0; + c.z = 1; + c.setFlag(CellFlags::IntDoorZNeg); + grid.cells.push_back(c); + + auto doorways = CellGridSystem::collectDoorways(grid); + if (doorways.size() != 2) + FAIL("expected 2 unique doorways, got " + + std::to_string(doorways.size())); + + bool foundShared = false; + bool foundInternal = false; + for (const auto &dw : doorways) { + if (dw.edgeKey == "X:1:0:0" && !dw.internal) + foundShared = true; + if (dw.edgeKey == "Z:0:0:1" && dw.internal) + foundInternal = true; + } + if (!foundShared) + FAIL("shared-edge doorway X:1:0:0 missing or mislabeled"); + if (!foundInternal) + FAIL("internal doorway Z:0:0:1 missing or mislabeled"); + + // No door flags -> no doorways. + CellGridComponent empty; + if (!CellGridSystem::collectDoorways(empty).empty()) + FAIL("empty grid produced doorways"); + + PASS(); + return 0; +} + +static int testGridUid() +{ + TEST("ensureGridUid generation and stability"); + + CellGridComponent grid; + if (!grid.gridUid.empty()) + FAIL("gridUid not empty by default"); + const std::string &uid = grid.ensureGridUid(); + if (uid.size() != 36) + FAIL("gridUid is not a 36-char UUID: " + uid); + if (grid.ensureGridUid() != uid) + FAIL("ensureGridUid not stable"); + + CellGridComponent other; + if (other.ensureGridUid() == uid) + FAIL("two grids got the same uid"); + + PASS(); + return 0; +} + +static int testSerializationRoundTrip() +{ + TEST("doorConfigs + gridUid serialization round trip"); + + flecs::world world; + SceneSerializer serializer(world, nullptr); + + flecs::entity e = world.entity(); + CellGridComponent grid; + grid.gridUid = "11111111-2222-3333-4444-555555555555"; + + Cell cell; + cell.x = 0; + cell.y = 0; + cell.z = 0; + cell.setFlag(CellFlags::DoorXPos); + grid.cells.push_back(cell); + + CellGridDoorConfig cfg; + cfg.label = "Kitchen door"; + cfg.hasOverride = true; + cfg.openAngle = 42.0f; + cfg.openSpeed = 99.0f; + cfg.swingReversed = true; + cfg.actionName = "knock"; + cfg.sceneSwitchPath = "interior.json"; + cfg.sceneSwitchTarget = "arrival"; + cfg.persistent = true; + cfg.lockable = true; + cfg.lockedByDefault = true; + cfg.keyItemId = "key_brass"; + grid.doorConfigs["X:1:0:0"] = cfg; + + // Orphaned entry: no doorway with this edge key exists. It must + // survive the round trip (never auto-deleted). + CellGridDoorConfig orphan; + orphan.label = "Old cellar door"; + orphan.lockable = true; + grid.doorConfigs["Z:9:9:9"] = orphan; + + e.set(grid); + + nlohmann::json json = serializer.serializeEntity(e); + if (!json.contains("cellGrid")) + FAIL("no cellGrid section in serialized entity"); + const nlohmann::json &cj = json["cellGrid"]; + if (cj.value("gridUid", "") != + "11111111-2222-3333-4444-555555555555") + FAIL("gridUid not serialized"); + if (!cj.contains("doorConfigs") || !cj["doorConfigs"].is_object() || + cj["doorConfigs"].size() != 2) + FAIL("doorConfigs not serialized"); + + // Deserialize into a fresh entity. + flecs::entity e2 = world.entity(); + serializer.deserializeEntityComponents(e2, json, flecs::entity::null(), + nullptr, false, false, false); + if (!e2.has()) + FAIL("CellGridComponent missing after deserialize"); + const CellGridComponent &g2 = e2.get(); + if (g2.gridUid != "11111111-2222-3333-4444-555555555555") + FAIL("gridUid mismatch after round trip"); + if (g2.doorConfigs.size() != 2) + FAIL("doorConfigs size mismatch after round trip"); + + auto it = g2.doorConfigs.find("X:1:0:0"); + if (it == g2.doorConfigs.end()) + FAIL("live door config missing after round trip"); + const CellGridDoorConfig &c2 = it->second; + if (c2.label != "Kitchen door" || !c2.hasOverride || + c2.openAngle != 42.0f || c2.openSpeed != 99.0f || + !c2.swingReversed || c2.actionName != "knock" || + c2.sceneSwitchPath != "interior.json" || + c2.sceneSwitchTarget != "arrival" || !c2.persistent || + !c2.lockable || !c2.lockedByDefault || + c2.keyItemId != "key_brass" || c2.disabled) + FAIL("door config fields mismatch after round trip"); + + auto oit = g2.doorConfigs.find("Z:9:9:9"); + if (oit == g2.doorConfigs.end()) + FAIL("orphaned door config lost in round trip"); + if (oit->second.label != "Old cellar door" || !oit->second.lockable) + FAIL("orphaned door config fields mismatch"); + + PASS(); + return 0; +} + +static int testOldSceneDefaults() +{ + TEST("old scene without gridUid/doorConfigs loads with defaults"); + + flecs::world world; + SceneSerializer serializer(world, nullptr); + + // Simulate an old scene: a cellGrid section without the F0 fields. + nlohmann::json json; + json["cellGrid"] = { { "width", 3 }, + { "height", 1 }, + { "depth", 3 }, + { "doorOpenAngle", 90.0f } }; + + flecs::entity e = world.entity(); + serializer.deserializeEntityComponents(e, json, flecs::entity::null(), + nullptr, false, false, false); + if (!e.has()) + FAIL("CellGridComponent missing after deserialize"); + const CellGridComponent &g = e.get(); + if (!g.doorConfigs.empty()) + FAIL("doorConfigs not empty for old scene"); + if (g.gridUid.empty()) + FAIL("gridUid not generated for old scene"); + if (g.gridUid.size() != 36) + FAIL("generated gridUid is not a 36-char UUID"); + if (g.doorOpenAngle != 90.0f) + FAIL("existing field lost"); + + // The generated uid must be kept on re-serialization (stability). + nlohmann::json out = serializer.serializeEntity(e); + if (out["cellGrid"].value("gridUid", "") != g.gridUid) + FAIL("generated gridUid not stable across save"); + + PASS(); + return 0; +} + +// --------------------------------------------------------------------------- +// F6: persistent door state +// --------------------------------------------------------------------------- + +static int testDoorDefaultsAndLocked() +{ + TEST("declareDoorDefaults + locked helpers + unlock notifications"); + + GlobalStateStore &store = GlobalStateStore::getInstance(); + const std::string id = "f6t1:X:1:0:0"; + store.remove("door." + id + ".locked"); + store.remove("door." + id + ".isOpen"); + + // Ephemeral doors are never locked. + DoorComponent ephemeral; + if (DoorSystem::isDoorLocked(ephemeral)) + FAIL("ephemeral door reports locked"); + + // Declare defaults: locked by default, closed by default. + if (DoorSystem::declareDoorDefaults(id, true)) + FAIL("declareDoorDefaults did not return closed default"); + if (!DoorSystem::isDoorLockedById(id)) + FAIL("locked default not applied"); + + DoorComponent door; + door.doorId = id; + if (!DoorSystem::isDoorLocked(door)) + FAIL("component-level isDoorLocked wrong"); + + // Unlock notifications: per-door and generic, with door_id param. + int perDoorFired = 0; + int genericFired = 0; + std::string seenId; + EventBus &bus = EventBus::getInstance(); + EventBus::ListenerId s1 = bus.subscribe( + "door_unlocked_" + id, + [&](const Ogre::String &, const editScene::EventParams &p) { + perDoorFired++; + seenId = p.getString("door_id", ""); + }); + EventBus::ListenerId s2 = bus.subscribe( + "door_unlocked", + [&](const Ogre::String &, const editScene::EventParams &p) { + genericFired++; + seenId = p.getString("door_id", ""); + }); + + DoorSystem::setDoorLocked(id, false); + if (DoorSystem::isDoorLockedById(id)) + FAIL("setDoorLocked(false) did not unlock"); + if (perDoorFired != 1 || genericFired != 1) + FAIL("unlock notifications not fired"); + if (seenId != id) + FAIL("door_id param wrong in notification"); + + // Locking fires no notifications. + DoorSystem::setDoorLocked(id, true); + if (!DoorSystem::isDoorLockedById(id)) + FAIL("setDoorLocked(true) did not lock"); + if (perDoorFired != 1 || genericFired != 1) + FAIL("notifications fired on lock"); + + bus.unsubscribe(s1); + bus.unsubscribe(s2); + PASS(); + return 0; +} + +static int testDoorUnlockEvents() +{ + TEST("door_unlock_ and generic door_unlock events"); + + flecs::world world; + const std::string id = "f6t2:Z:0:0:1"; + + flecs::entity e = world.entity(); + DoorComponent door; + door.doorId = id; + door.lockable = true; + e.set(door); + e.set({nullptr, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE}); + + { + DoorSystem ds(world); + DoorSystem::setDoorLocked(id, true); + ds.update(0.016f); // registers the per-door subscription + + EventBus::getInstance().send("door_unlock_" + id); + if (DoorSystem::isDoorLockedById(id)) + FAIL("per-door unlock event ignored"); + + DoorSystem::setDoorLocked(id, true); + editScene::EventParams params; + params.setString("door_id", id); + EventBus::getInstance().send("door_unlock", params); + if (DoorSystem::isDoorLockedById(id)) + FAIL("generic door_unlock event ignored"); + + // A generic unlock for a different door leaves this one alone. + DoorSystem::setDoorLocked(id, true); + editScene::EventParams other; + other.setString("door_id", "f6t2:other"); + EventBus::getInstance().send("door_unlock", other); + if (!DoorSystem::isDoorLockedById(id)) + FAIL("generic unlock leaked across doors"); + + // The subscription disappears with the door entity. + e.destruct(); + ds.update(0.016f); + DoorSystem::setDoorLocked(id, true); + EventBus::getInstance().send("door_unlock_" + id); + if (!DoorSystem::isDoorLockedById(id)) + FAIL("stale subscription survived door removal"); + } + + PASS(); + return 0; +} + +static int testDoorOpenStatePersisted() +{ + TEST("open/closed state persisted when the swing completes"); + + flecs::world world; + GlobalStateStore &store = GlobalStateStore::getInstance(); + const std::string id = "f6t3:X:2:0:2"; + const std::string openKey = "door." + id + ".isOpen"; + store.remove(openKey); + + flecs::entity e = world.entity(); + DoorComponent door; + door.doorId = id; + door.persistent = true; + door.openAngle = 90.0f; + door.openSpeed = 180.0f; + e.set(door); + e.set({nullptr, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE}); + + DoorSystem ds(world); + if (DoorSystem::declareDoorDefaults(id, false)) + FAIL("fresh door not closed by default"); + + // Open it: request a toggle, then swing to completion. + e.get_mut().toggleRequested = true; + for (int i = 0; i < 120; i++) + ds.update(1.0f / 60.0f); + if (!e.get().isOpen) + FAIL("door did not open"); + if (!store.getBool(openKey)) + FAIL("open state not persisted"); + + // Close it again. + e.get_mut().toggleRequested = true; + for (int i = 0; i < 120; i++) + ds.update(1.0f / 60.0f); + if (e.get().isOpen) + FAIL("door did not close"); + if (store.getBool(openKey)) + FAIL("closed state not persisted"); + + // Rebuild path: declareDoorDefaults returns the persisted state. + store.set(openKey, true); + if (!DoorSystem::declareDoorDefaults(id, false)) + FAIL("persisted open state not returned on rebuild"); + + PASS(); + return 0; +} + +static int testDoorLuaRoundTrip() +{ + TEST("Lua: door_locked handler unlocks via events; ecs.door.* API"); + + const std::string id = "f6t4:X:3:0:3"; + + lua_State *L = luaL_newstate(); + if (!L) + FAIL("luaL_newstate failed"); + luaL_openlibs(L); + editScene::registerLuaEventApi(L); + editScene::registerLuaGlobalStateApi(L); + editScene::registerLuaDoorApi(L); + + flecs::world world; + flecs::entity e = world.entity(); + DoorComponent door; + door.doorId = id; + door.lockable = true; + e.set(door); + e.set({nullptr, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE}); + + DoorSystem ds(world); + DoorSystem::declareDoorDefaults(id, true); + ds.update(0.016f); // registers the per-door subscription + + std::string script = + "seen = nil\n" + "ecs.subscribe_event('door_locked', function(ev, params)\n" + " seen = params.door_id\n" + " assert(ecs.door.is_locked(params.door_id))\n" + " ecs.send_event('door_unlock_' .. params.door_id)\n" + " assert(not ecs.door.is_locked(params.door_id))\n" + "end)\n" + "ecs.send_event('door_locked', { door_id = '" + id + "' })\n" + "assert(seen == '" + id + "')\n" + "ecs.door.lock('" + id + "')\n" + "assert(ecs.door.is_locked('" + id + "'))\n" + "ecs.door.unlock('" + id + "')\n" + "assert(not ecs.door.is_locked('" + id + "'))\n" + "assert(ecs.door.is_open('" + id + "') == false)\n"; + if (luaL_dostring(L, script.c_str()) != LUA_OK) { + std::string err = lua_tostring(L, -1); + lua_close(L); + FAIL("lua script failed: " + err); + } + + if (DoorSystem::isDoorLockedById(id)) + FAIL("door still locked after Lua round trip"); + + PASS(); + return 0; +} + +static int testSceneSwitchDoorTiming() +{ + TEST("F1: scene switch fires only when fully open (+ event)"); + + flecs::world world; + + flecs::entity e = world.entity(); + DoorComponent door; + door.sceneSwitchPath = "scene_b.json"; + door.sceneSwitchTarget = "arrival_b"; + door.openAngle = 90.0f; + door.openSpeed = 180.0f; + e.set(door); + e.set({nullptr, Ogre::Vector3::ZERO, + Ogre::Quaternion::IDENTITY, + Ogre::Vector3::UNIT_SCALE}); + + int fired = 0; + std::string seenPath, seenTarget; + EventBus::ListenerId sub = EventBus::getInstance().subscribe( + "door_scene_switch", + [&](const Ogre::String &, const editScene::EventParams &p) { + fired++; + seenPath = p.getString("path", ""); + seenTarget = p.getString("target", ""); + }); + + DoorSystem ds(world); /* no EditorApp: only the event fires */ + + // Open with a pending switch. + { + DoorComponent &d = e.get_mut(); + d.toggleRequested = true; + d.sceneSwitchPending = true; + } + + // Mid-swing: nothing may fire. + for (int i = 0; i < 15; i++) + ds.update(1.0f / 60.0f); + if (fired != 0) + FAIL("scene switch fired before the door was fully open"); + if (e.get().currentAngle <= 0.0f || + e.get().currentAngle >= 90.0f) + FAIL("door not mid-swing in timing test"); + + // Swing to completion: fires exactly once, with the params. + for (int i = 0; i < 60; i++) + ds.update(1.0f / 60.0f); + if (fired != 1) + FAIL("scene switch did not fire at full opening"); + if (seenPath != "scene_b.json" || seenTarget != "arrival_b") + FAIL("door_scene_switch params wrong"); + if (e.get().sceneSwitchPending) + FAIL("sceneSwitchPending not consumed"); + + // Close cancels a pending switch: open again with pending, then + // close mid-swing; no event may fire when the swing completes. + { + DoorComponent &d = e.get_mut(); + d.toggleRequested = true; /* close */ + } + for (int i = 0; i < 30; i++) + ds.update(1.0f / 60.0f); + { + DoorComponent &d = e.get_mut(); + d.toggleRequested = true; /* reopen */ + d.sceneSwitchPending = true; + } + ds.update(1.0f / 60.0f); + { + /* Mid-swing close cancels the pending switch. */ + DoorComponent &d = e.get_mut(); + d.toggleRequested = true; + } + for (int i = 0; i < 120; i++) + ds.update(1.0f / 60.0f); + if (fired != 1) + FAIL("cancelled scene switch fired anyway"); + if (e.get().sceneSwitchPending) + FAIL("sceneSwitchPending not cleared on close"); + + EventBus::getInstance().unsubscribe(sub); + PASS(); + return 0; +} + +static int testSwingReversed() +{ + TEST("F3: grid doorSwingReversed round trip + swingOrientation sign"); + + // Serializer round trip of the grid-wide field. + flecs::world world; + SceneSerializer serializer(world, nullptr); + + flecs::entity e = world.entity(); + CellGridComponent grid; + grid.doorSwingReversed = true; + e.set(grid); + + nlohmann::json json = serializer.serializeEntity(e); + if (!json["cellGrid"].value("doorSwingReversed", false)) + FAIL("doorSwingReversed not serialized"); + + flecs::entity e2 = world.entity(); + serializer.deserializeEntityComponents(e2, json, flecs::entity::null(), + nullptr, false, false, false); + if (!e2.get().doorSwingReversed) + FAIL("doorSwingReversed lost in round trip"); + + // Old scenes default to false. + nlohmann::json old; + old["cellGrid"] = { { "width", 1 }, { "height", 1 }, { "depth", 1 } }; + flecs::entity e3 = world.entity(); + serializer.deserializeEntityComponents(e3, old, flecs::entity::null(), + nullptr, false, false, false); + if (e3.get().doorSwingReversed) + FAIL("old scene did not default doorSwingReversed to false"); + + // swingOrientation: +90 deg about local Y maps +Z to +X; reversed + // maps it to -X. (Quaternion math needs no OGRE init.) + DoorComponent door; + door.closedOrientation = Ogre::Quaternion::IDENTITY; + Ogre::Vector3 n = DoorSystem::swingOrientation(door, 90.0f) * + Ogre::Vector3::UNIT_Z; + if (n.x < 0.99f) + FAIL("normal swing does not turn +Z towards +X"); + door.swingReversed = true; + Ogre::Vector3 r = DoorSystem::swingOrientation(door, 90.0f) * + Ogre::Vector3::UNIT_Z; + if (r.x > -0.99f) + FAIL("reversed swing does not turn +Z towards -X"); + + PASS(); + return 0; +} + +static int testStandaloneDoorSerialization() +{ + TEST("F2: StandaloneDoor serialization round trip"); + + flecs::world world; + SceneSerializer serializer(world, nullptr); + + flecs::entity e = world.entity(); + StandaloneDoorComponent door; + door.meshName = "door.mesh"; + door.useMeshMaterial = true; + door.rectName = "door"; + door.leafWidth = 1.2f; + door.leafHeight = 2.1f; + door.leafThickness = 0.06f; + door.openAngle = 95.0f; + door.openSpeed = 150.0f; + door.swingReversed = true; + door.actionName = "knock"; + door.sceneSwitchPath = "outside.json"; + door.sceneSwitchTarget = "arrival"; + door.persistent = true; + door.lockable = true; + door.lockedByDefault = true; + door.keyItemId = "key_brass"; + door.doorId = "door-test-1"; + e.set(door); + + nlohmann::json json = serializer.serializeEntity(e); + if (!json.contains("standaloneDoor")) + FAIL("no standaloneDoor section in serialized entity"); + + flecs::entity e2 = world.entity(); + serializer.deserializeEntityComponents(e2, json, flecs::entity::null(), + nullptr, false, false, false); + if (!e2.has()) + FAIL("StandaloneDoorComponent missing after deserialize"); + const StandaloneDoorComponent &d2 = e2.get(); + if (d2.meshName != "door.mesh" || !d2.useMeshMaterial || + d2.rectName != "door" || d2.leafWidth != 1.2f || + d2.leafHeight != 2.1f || d2.leafThickness != 0.06f || + d2.openAngle != 95.0f || d2.openSpeed != 150.0f || + !d2.swingReversed || d2.actionName != "knock" || + d2.sceneSwitchPath != "outside.json" || + d2.sceneSwitchTarget != "arrival" || !d2.persistent || + !d2.lockable || !d2.lockedByDefault || + d2.keyItemId != "key_brass" || d2.doorId != "door-test-1") + FAIL("standaloneDoor fields mismatch after round trip"); + if (!d2.dirty) + FAIL("dirty must stay set so the system builds the door"); + + // Old scenes without the section simply do not gain the component. + flecs::entity e3 = world.entity(); + nlohmann::json old; + old["cellGrid"] = { { "width", 1 }, { "height", 1 }, { "depth", 1 } }; + serializer.deserializeEntityComponents(e3, old, flecs::entity::null(), + nullptr, false, false, false); + if (e3.has()) + FAIL("old scene gained a StandaloneDoorComponent"); + + PASS(); + return 0; +} + +static int testGenerationModeSerialization() +{ + TEST("F4/F5: generationMode + glass serialization round trip"); + + flecs::world world; + SceneSerializer serializer(world, nullptr); + + flecs::entity e = world.entity(); + CellGridComponent grid; + grid.generationMode = "interiorOnly"; + grid.glassColor = Ogre::ColourValue(0.1f, 0.2f, 0.3f, 0.4f); + grid.glassMaterialName = "MyGlass"; + grid.glassReflectivity = 0.25f; + e.set(grid); + + nlohmann::json json = serializer.serializeEntity(e); + if (json["cellGrid"].value("generationMode", "") != "interiorOnly") + FAIL("generationMode not serialized"); + if (json["cellGrid"].value("glassMaterialName", "") != "MyGlass") + FAIL("glassMaterialName not serialized"); + + flecs::entity e2 = world.entity(); + serializer.deserializeEntityComponents(e2, json, flecs::entity::null(), + nullptr, false, false, false); + const CellGridComponent &g2 = e2.get(); + if (!g2.interiorOnlyMode()) + FAIL("generationMode lost in round trip"); + if (g2.glassColor != Ogre::ColourValue(0.1f, 0.2f, 0.3f, 0.4f)) + FAIL("glassColor lost in round trip"); + if (g2.glassMaterialName != "MyGlass" || + g2.glassReflectivity != 0.25f) + FAIL("glass material fields lost in round trip"); + + // Old scenes default to "full" and built-in glass defaults. + nlohmann::json old; + old["cellGrid"] = { { "width", 1 }, { "height", 1 }, { "depth", 1 } }; + flecs::entity e3 = world.entity(); + serializer.deserializeEntityComponents(e3, old, flecs::entity::null(), + nullptr, false, false, false); + const CellGridComponent &g3 = e3.get(); + if (g3.generationMode != "full" || g3.interiorOnlyMode() || + g3.exteriorOnlyMode()) + FAIL("old scene did not default generationMode to full"); + if (!g3.glassMaterialName.empty() || g3.glassReflectivity != 0.8f) + FAIL("old scene did not default glass fields"); + + PASS(); + return 0; +} + +int main() +{ + printf("CellGrid door identity / per-door config tests (F0, F6)\n"); + printf("====================================================\n"); + + /* The store is a singleton shared by the process; keep it off the + * disk during tests. */ + GlobalStateStore::getInstance().setAutoSaveEnabled(false); + + int failures = 0; + failures += testEdgeKeyCanonicalization(); + failures += testCollectDoorways(); + failures += testGridUid(); + failures += testSerializationRoundTrip(); + failures += testOldSceneDefaults(); + failures += testDoorDefaultsAndLocked(); + failures += testDoorUnlockEvents(); + failures += testDoorOpenStatePersisted(); + failures += testDoorLuaRoundTrip(); + failures += testSceneSwitchDoorTiming(); + failures += testSwingReversed(); + failures += testStandaloneDoorSerialization(); + failures += testGenerationModeSerialization(); + + printf("====================================================\n"); + printf("Results: %d/%d passed\n", passCount, testCount); + return failures ? 1 : 0; +} diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index 1049cec..6b1fc69 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -862,7 +862,8 @@ static int testNavMeshComponent(lua_State *L) " agentRadius = 0.5," " agentMaxClimb = 0.5," " agentMaxSlope = 45.0," - " enabled = true" + " enabled = true," + " doorAreaCost = 7.5" "});" "local n = ecs.get_component(id, 'NavMesh');" "assert(n ~= nil, 'NavMesh should exist');" @@ -872,7 +873,8 @@ static int testNavMeshComponent(lua_State *L) "assert(n.agentRadius == 0.5, 'wrong agentRadius');" "assert(n.agentMaxClimb == 0.5, 'wrong agentMaxClimb');" "assert(n.agentMaxSlope == 45.0, 'wrong agentMaxSlope');" - "assert(n.enabled == true, 'wrong enabled')"); + "assert(n.enabled == true, 'wrong enabled');" + "assert(n.doorAreaCost == 7.5, 'wrong doorAreaCost')"); if (!ok) FAIL("NavMesh component assertion failed"); @@ -1059,6 +1061,7 @@ static int testCellGridComponent(lua_State *L) " depth = 10," " cellSize = 1.0," " cellHeight = 0.5," + " generationMode = 'interiorOnly'," " doorsEnabled = false," " doorRectName = 'door'," " doorMeshName = 'door.mesh'," @@ -1067,7 +1070,11 @@ static int testCellGridComponent(lua_State *L) " doorOpenSpeed = 120.0," " doorActionName = 'knock'," " doorSceneSwitchPath = 'outside.json'," - " doorSceneSwitchTarget = 'arrival'" + " doorSceneSwitchTarget = 'arrival'," + " glassColor = { r = 0.1, g = 0.2, b = 0.3," + " a = 0.4 }," + " glassMaterialName = 'MyGlass'," + " glassReflectivity = 0.25" "});" "local c = ecs.get_component(id, 'CellGrid');" "assert(c ~= nil, 'CellGrid should exist');" @@ -1076,6 +1083,8 @@ static int testCellGridComponent(lua_State *L) "assert(c.depth == 10, 'wrong depth');" "assert(c.cellSize == 1.0, 'wrong cellSize');" "assert(c.cellHeight == 0.5, 'wrong cellHeight');" + "assert(c.generationMode == 'interiorOnly', " + "'wrong generationMode');" "assert(c.doorsEnabled == false, 'wrong doorsEnabled');" "assert(c.doorRectName == 'door', 'wrong doorRectName');" "assert(c.doorMeshName == 'door.mesh', " @@ -1091,7 +1100,13 @@ static int testCellGridComponent(lua_State *L) "assert(c.doorSceneSwitchPath == 'outside.json', " "'wrong doorSceneSwitchPath');" "assert(c.doorSceneSwitchTarget == 'arrival', " - "'wrong doorSceneSwitchTarget')"); + "'wrong doorSceneSwitchTarget');" + "assert(c.glassColor.r == 0.1 and " + "c.glassColor.a == 0.4, 'wrong glassColor');" + "assert(c.glassMaterialName == 'MyGlass', " + "'wrong glassMaterialName');" + "assert(c.glassReflectivity == 0.25, " + "'wrong glassReflectivity')"); if (!ok) FAIL("CellGrid component assertion failed"); @@ -1099,6 +1114,155 @@ static int testCellGridComponent(lua_State *L) return 0; } +// --------------------------------------------------------------------------- +// Test 32b: CellGrid door identity fields (F0): gridUid + doorConfigs +// --------------------------------------------------------------------------- + +static int testCellGridDoorConfig(lua_State *L) +{ + TEST("CellGrid gridUid + doorConfigs (F0)"); + + bool ok = runLua( + L, "local id = ecs.create_entity();" + "ecs.set_component(id, 'CellGrid', {" + " width = 4," + " gridUid = 'uid-test-1234'," + " doorSwingReversed = true," + " doorConfigs = {" + " ['X:1:0:0'] = {" + " label = 'Kitchen door'," + " hasOverride = true," + " openAngle = 42.0," + " openSpeed = 99.0," + " swingReversed = true," + " actionName = 'knock'," + " sceneSwitchPath = 'interior.json'," + " sceneSwitchTarget = 'arrival'," + " disabled = false," + " persistent = true," + " lockable = true," + " lockedByDefault = true," + " keyItemId = 'key_brass'" + " }," + " ['Z:9:9:9'] = { label = 'Old cellar door'," + " lockable = true }" + " }" + "});" + "local c = ecs.get_component(id, 'CellGrid');" + "assert(c ~= nil, 'CellGrid should exist');" + "assert(c.gridUid == 'uid-test-1234', 'wrong gridUid');" + "assert(c.doorSwingReversed == true, " + "'wrong doorSwingReversed');" + "assert(type(c.doorConfigs) == 'table', " + "'doorConfigs missing');" + "local d = c.doorConfigs['X:1:0:0'];" + "assert(d ~= nil, 'door config X:1:0:0 missing');" + "assert(d.label == 'Kitchen door', 'wrong label');" + "assert(d.hasOverride == true, 'wrong hasOverride');" + "assert(d.openAngle == 42.0, 'wrong openAngle');" + "assert(d.openSpeed == 99.0, 'wrong openSpeed');" + "assert(d.swingReversed == true, 'wrong swingReversed');" + "assert(d.actionName == 'knock', 'wrong actionName');" + "assert(d.sceneSwitchPath == 'interior.json', " + "'wrong sceneSwitchPath');" + "assert(d.sceneSwitchTarget == 'arrival', " + "'wrong sceneSwitchTarget');" + "assert(d.disabled == false, 'wrong disabled');" + "assert(d.persistent == true, 'wrong persistent');" + "assert(d.lockable == true, 'wrong lockable');" + "assert(d.lockedByDefault == true, " + "'wrong lockedByDefault');" + "assert(d.keyItemId == 'key_brass', 'wrong keyItemId');" + "local o = c.doorConfigs['Z:9:9:9'];" + "assert(o ~= nil, 'orphan config missing');" + "assert(o.label == 'Old cellar door', " + "'wrong orphan label');" + "assert(o.lockable == true, 'wrong orphan lockable');" + "assert(not o.hasOverride, " + "'orphan hasOverride should be falsy');" + "local n = 0;" + "for k, v in pairs(c.doorConfigs) do n = n + 1 end;" + "assert(n == 2, 'expected 2 door configs');" + "ecs.set_component(id, 'CellGrid'," + " { doorConfigs = {} });" + "local c2 = ecs.get_component(id, 'CellGrid');" + "assert(next(c2.doorConfigs) == nil, " + "'doorConfigs not cleared')"); + if (!ok) + FAIL("CellGrid door config assertion failed"); + + PASS(); + return 0; +} + +// --------------------------------------------------------------------------- +// Test 32c: StandaloneDoor component (F2) +// --------------------------------------------------------------------------- + +static int testStandaloneDoorComponent(lua_State *L) +{ + TEST("StandaloneDoor component (F2)"); + + bool ok = runLua( + L, "local id = ecs.create_entity();" + "ecs.set_component(id, 'StandaloneDoor', {" + " meshName = 'door.mesh'," + " useMeshMaterial = true," + " rectName = 'door'," + " leafWidth = 1.2," + " leafHeight = 2.1," + " leafThickness = 0.06," + " openAngle = 95.0," + " openSpeed = 150.0," + " swingReversed = true," + " actionName = 'knock'," + " sceneSwitchPath = 'outside.json'," + " sceneSwitchTarget = 'arrival'," + " persistent = true," + " lockable = true," + " lockedByDefault = true," + " keyItemId = 'key_brass'," + " doorId = 'door-test-1'" + "});" + "local c = ecs.get_component(id, 'StandaloneDoor');" + "assert(c ~= nil, 'StandaloneDoor should exist');" + "assert(c.meshName == 'door.mesh', 'wrong meshName');" + "assert(c.useMeshMaterial == true, " + "'wrong useMeshMaterial');" + "assert(c.rectName == 'door', 'wrong rectName');" + "assert(math.abs(c.leafWidth - 1.2) < 0.001, " + "'wrong leafWidth');" + "assert(math.abs(c.leafHeight - 2.1) < 0.001, " + "'wrong leafHeight');" + "assert(math.abs(c.leafThickness - 0.06) < 0.001, " + "'wrong leafThickness');" + "assert(c.openAngle == 95.0, 'wrong openAngle');" + "assert(c.openSpeed == 150.0, 'wrong openSpeed');" + "assert(c.swingReversed == true, 'wrong swingReversed');" + "assert(c.actionName == 'knock', 'wrong actionName');" + "assert(c.sceneSwitchPath == 'outside.json', " + "'wrong sceneSwitchPath');" + "assert(c.sceneSwitchTarget == 'arrival', " + "'wrong sceneSwitchTarget');" + "assert(c.persistent == true, 'wrong persistent');" + "assert(c.lockable == true, 'wrong lockable');" + "assert(c.lockedByDefault == true, " + "'wrong lockedByDefault');" + "assert(c.keyItemId == 'key_brass', 'wrong keyItemId');" + "assert(c.doorId == 'door-test-1', 'wrong doorId');" + "ecs.set_component(id, 'StandaloneDoor'," + " { leafWidth = 0.9, lockable = false });" + "local c2 = ecs.get_component(id, 'StandaloneDoor');" + "assert(math.abs(c2.leafWidth - 0.9) < 0.001, " + "'leafWidth not updated');" + "assert(c2.lockable == false, 'lockable not updated')"); + if (!ok) + FAIL("StandaloneDoor component assertion failed"); + + PASS(); + return 0; +} + // --------------------------------------------------------------------------- // Test 33: Room component // --------------------------------------------------------------------------- @@ -1972,6 +2136,8 @@ int main() failures += testGoapBlackboardComponent(L); failures += testPrefabInstanceComponent(L); failures += testCellGridComponent(L); + failures += testCellGridDoorConfig(L); + failures += testStandaloneDoorComponent(L); failures += testRoomComponent(L); failures += testLotComponent(L); failures += testDistrictComponent(L); diff --git a/src/features/editScene/tests/global_state_test.cpp b/src/features/editScene/tests/global_state_test.cpp new file mode 100644 index 0000000..4a736bb --- /dev/null +++ b/src/features/editScene/tests/global_state_test.cpp @@ -0,0 +1,302 @@ +/** + * @file global_state_test.cpp + * @brief Headless tests for the F9 global persistent variable storage. + * + * Links only GlobalStateStore + the Lua global-state API (no OGRE, no + * flecs): the store is a plain singleton and the Lua API needs just a + * Lua state with an "ecs" table. + * + * Covered: + * - set/get/has/remove for all four types from C++ + * - int vs float distinction + * - declared defaults and caller fallbacks; defaults not persisted + * - serialize/deserialize round trip + * - clearToDefaults semantics + * - renamePrefix (F0 door reassignment state migration) + * - Lua API round trip (ecs.global.*) and C++ <-> Lua sharing + * + * Build with CMake: + * cmake --build --target global_state_test + * Run: + * /src/features/editScene/global_state_test + */ + +#include +#include + +#include "../systems/GlobalStateStore.hpp" +#include "../lua/LuaGlobalStateApi.hpp" + +extern "C" { +#include +#include +#include +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +static int testCount = 0; +static int passCount = 0; + +#define TEST(name) \ + do { \ + testCount++; \ + printf(" TEST %d: %s ... ", testCount, name); \ + } while (0) + +#define PASS() \ + do { \ + passCount++; \ + printf("PASS\n"); \ + } while (0) + +#define FAIL(msg) \ + do { \ + printf("FAIL: %s\n", std::string(msg).c_str()); \ + return 1; \ + } while (0) + +/* Run a Lua snippet, FAIL on error. */ +#define RUN_LUA(code) \ + do { \ + if (!runLua(L, code)) \ + FAIL("Lua error in: " #code); \ + } while (0) + +static bool runLua(lua_State *L, const char *code) +{ + if (luaL_dostring(L, code) != LUA_OK) { + fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1)); + return false; + } + return true; +} + +static int testCppTypes() +{ + TEST("C++ set/get/has/remove for all types"); + + GlobalStateStore &s = GlobalStateStore::getInstance(); + s.setAutoSaveEnabled(false); + + s.set("test.bool", true); + s.set("test.int", (int64_t)-42); + s.set("test.float", 1.5); + s.set("test.str", std::string("hello")); + + if (!s.getBool("test.bool")) + FAIL("bool readback failed"); + if (s.getInt("test.int") != -42) + FAIL("int readback failed"); + if (s.getFloat("test.float") != 1.5) + FAIL("float readback failed"); + if (s.getString("test.str") != "hello") + FAIL("string readback failed"); + + if (!s.has("test.bool") || s.has("test.missing")) + FAIL("has() wrong"); + + // Type mismatch falls back, no implicit conversion. + if (s.getFloat("test.int", 7.0) != 7.0) + FAIL("int read as float should fall back"); + if (s.getBool("test.str", true) != true) + FAIL("string read as bool should fall back"); + + // Unknown keys return the caller fallback. + if (s.getBool("nope", true) != true || + s.getInt("nope", 5) != 5 || + s.getFloat("nope", 2.5) != 2.5 || + s.getString("nope", "fb") != "fb") + FAIL("fallbacks wrong"); + + s.remove("test.bool"); + if (s.has("test.bool")) + FAIL("remove() failed"); + + PASS(); + return 0; +} + +static int testDefaults() +{ + TEST("declared defaults and persistence"); + + GlobalStateStore &s = GlobalStateStore::getInstance(); + + s.declareDefault("door.abc.locked", true); + s.declareDefault("door.abc.isOpen", false); + s.declareDefault("quest.stage", (int64_t)1); + s.declareDefault("player.name", std::string("Nameless")); + + if (!s.getBool("door.abc.locked")) + FAIL("declared bool default not returned"); + if (s.getBool("door.abc.isOpen")) + FAIL("declared false default not returned"); + if (s.getInt("quest.stage") != 1) + FAIL("declared int default not returned"); + if (s.getString("player.name") != "Nameless") + FAIL("declared string default not returned"); + if (!s.has("door.abc.locked")) + FAIL("has() should be true for declared defaults"); + + // Defaults are not persisted: only set() values serialize. + nlohmann::json j = s.serialize(); + if (j["values"].contains("door.abc.locked")) + FAIL("default leaked into serialization"); + + // An explicit value shadows the default; remove() re-exposes it. + s.set("door.abc.locked", false); + if (s.getBool("door.abc.locked")) + FAIL("explicit value did not shadow default"); + s.remove("door.abc.locked"); + if (!s.getBool("door.abc.locked")) + FAIL("remove() did not re-expose default"); + + PASS(); + return 0; +} + +static int testSerializeRoundTrip() +{ + TEST("serialize/deserialize round trip"); + + GlobalStateStore &s = GlobalStateStore::getInstance(); + s.clearToDefaults(); + s.set("rt.bool", true); + s.set("rt.int", (int64_t)9223372036854775807LL); + s.set("rt.negint", (int64_t)-7); + s.set("rt.float", 2.25); + s.set("rt.str", std::string("door: abc")); + + nlohmann::json j = s.serialize(); + + s.clearToDefaults(); + if (s.has("rt.bool")) + FAIL("clearToDefaults left a value"); + + s.deserialize(j); + if (!s.getBool("rt.bool") || + s.getInt("rt.int") != 9223372036854775807LL || + s.getInt("rt.negint") != -7 || s.getFloat("rt.float") != 2.25 || + s.getString("rt.str") != "door: abc") + FAIL("round trip mismatch"); + + // Old saves without the section load cleanly (values cleared). + s.deserialize(nlohmann::json::object()); + if (s.has("rt.bool")) + FAIL("deserialize of empty json kept values"); + + PASS(); + return 0; +} + +static int testClearAndRename() +{ + TEST("clearToDefaults and renamePrefix"); + + GlobalStateStore &s = GlobalStateStore::getInstance(); + s.declareDefault("keep.default", (int64_t)9); + s.set("door.uid1:X:1:0:0.locked", true); + s.set("door.uid1:X:1:0:0.isOpen", false); + s.set("door.uid2:Z:0:0:1.locked", true); + s.set("unrelated", (int64_t)3); + + // F0 door reassignment: door.uid1:X:1:0:0 -> door.uid1:X:2:0:0 + s.renamePrefix("door.uid1:X:1:0:0.", "door.uid1:X:2:0:0."); + if (s.has("door.uid1:X:1:0:0.locked")) + FAIL("old prefix key survived renamePrefix"); + if (!s.getBool("door.uid1:X:2:0:0.locked")) + FAIL("renamed locked key missing"); + if (s.getBool("door.uid1:X:2:0:0.isOpen")) + FAIL("renamed isOpen key wrong"); + if (!s.getBool("door.uid2:Z:0:0:1.locked") || + s.getInt("unrelated") != 3) + FAIL("unrelated keys clobbered by renamePrefix"); + + s.clearToDefaults(); + if (s.has("door.uid1:X:2:0:0.locked") || s.has("unrelated")) + FAIL("clearToDefaults kept explicit values"); + // Declared defaults survive clearToDefaults. + if (s.getInt("keep.default") != 9) + FAIL("clearToDefaults dropped declared defaults"); + + PASS(); + return 0; +} + +static int testLuaApi() +{ + TEST("Lua ecs.global.* API"); + + lua_State *L = luaL_newstate(); + luaL_openlibs(L); + lua_newtable(L); + lua_setglobal(L, "ecs"); + editScene::registerLuaGlobalStateApi(L); + + RUN_LUA("assert(type(ecs.global) == 'table', 'ecs.global missing');" + "ecs.global.set('lua.bool', true);" + "ecs.global.set('lua.int', 42);" + "ecs.global.set('lua.float', 0.5);" + "ecs.global.set('lua.str', 'from lua');" + "assert(ecs.global.get_bool('lua.bool') == true);" + "assert(ecs.global.get_int('lua.int') == 42);" + "assert(ecs.global.get_float('lua.float') == 0.5);" + "assert(ecs.global.get_string('lua.str') == 'from lua');" + "assert(ecs.global.has('lua.bool'));" + "assert(not ecs.global.has('lua.nope'));" + // int vs float distinction + "assert(ecs.global.get_int('lua.float', -1) == -1," + " 'float readable as int');" + "assert(ecs.global.get_float('lua.int', -1.0) == -1.0," + " 'int readable as float');" + // defaults + "assert(ecs.global.get_int('lua.missing', 77) == 77);" + "assert(ecs.global.get_string('lua.missing', 'd') == 'd');" + // remove + "ecs.global.remove('lua.bool');" + "assert(not ecs.global.has('lua.bool'));" + // set nil == remove + "ecs.global.set('lua.int', nil);" + "assert(not ecs.global.has('lua.int'));"); + + // C++ sees what Lua set. + GlobalStateStore &s = GlobalStateStore::getInstance(); + if (s.getFloat("lua.float") != 0.5) + FAIL("C++ cannot read Lua-set float"); + if (s.getString("lua.str") != "from lua") + FAIL("C++ cannot read Lua-set string"); + + // Lua sees what C++ sets (and declared defaults). + s.set("cpp.flag", true); + s.declareDefault("door.xyz.locked", false); + RUN_LUA("assert(ecs.global.get_bool('cpp.flag') == true," + " 'Lua cannot read C++-set bool');" + "assert(ecs.global.get_bool('door.xyz.locked') == false," + " 'Lua cannot read declared default');" + "assert(ecs.global.has('door.xyz.locked')," + " 'declared default not visible in Lua');"); + + lua_close(L); + PASS(); + return 0; +} + +int main() +{ + printf("GlobalStateStore tests (F9)\n"); + printf("====================================================\n"); + + int failures = 0; + failures += testCppTypes(); + failures += testDefaults(); + failures += testSerializeRoundTrip(); + failures += testClearAndRename(); + failures += testLuaApi(); + + printf("====================================================\n"); + printf("Results: %d/%d passed\n", passCount, testCount); + return failures ? 1 : 0; +} diff --git a/src/features/editScene/ui/CellGridEditor.cpp b/src/features/editScene/ui/CellGridEditor.cpp index 25b2415..c74d9e2 100644 --- a/src/features/editScene/ui/CellGridEditor.cpp +++ b/src/features/editScene/ui/CellGridEditor.cpp @@ -1,14 +1,25 @@ #include "CellGridEditor.hpp" +#include "DoorPickState.hpp" #include "../components/CellGrid.hpp" #include "../components/ProceduralMaterial.hpp" #include "../components/ProceduralTexture.hpp" +#include "../components/Renderable.hpp" +#include "../systems/CellGridSystem.hpp" #include "../systems/FurnitureLibrary.hpp" +#include "../systems/GlobalStateStore.hpp" #include #include bool CellGridEditor::renderComponent(flecs::entity entity, CellGridComponent& grid) { + if (m_currentEntity != entity) { + // Switched to another grid: drop door selection/highlight/pick + m_selectedDoorKey.clear(); + m_reassignSource.clear(); + clearDoorHighlight(); + DoorPickState::instance().cancel(); + } m_currentEntity = entity; bool modified = false; @@ -35,6 +46,60 @@ bool CellGridEditor::renderComponent(flecs::entity entity, CellGridComponent& gr modified = true; } + // Generation mode (F4/F5) + { + const char* modes[] = { "Full", "Interior Only", "Exterior Only" }; + const char* modeValues[] = { "full", "interiorOnly", + "exteriorOnly" }; + int modeIndex = 0; + for (int i = 0; i < 3; i++) { + if (grid.generationMode == modeValues[i]) { + modeIndex = i; + break; + } + } + if (ImGui::Combo("Generation Mode", &modeIndex, modes, 3)) { + grid.generationMode = modeValues[modeIndex]; + modified = true; + } + if (grid.interiorOnlyMode()) { + ImGui::SameLine(); + ImGui::TextDisabled("(no exterior shell)"); + } else if (grid.exteriorOnlyMode()) { + ImGui::SameLine(); + ImGui::TextDisabled("(shell only)"); + } + } + + // Window glass settings (F5, exteriorOnly/interiorOnly; the + // built-in material is opaque, the color alpha is ignored by it) + if (grid.exteriorOnlyMode() || grid.interiorOnlyMode()) { + float glassCol[4] = { grid.glassColor.r, grid.glassColor.g, + grid.glassColor.b, grid.glassColor.a }; + if (ImGui::ColorEdit4("Glass Color", glassCol)) { + grid.glassColor = Ogre::ColourValue(glassCol[0], glassCol[1], + glassCol[2], glassCol[3]); + modified = true; + } + char glassMatBuf[256]; + snprintf(glassMatBuf, sizeof(glassMatBuf), "%s", + grid.glassMaterialName.c_str()); + if (ImGui::InputText("Glass Material", glassMatBuf, + sizeof(glassMatBuf))) { + grid.glassMaterialName = glassMatBuf; + modified = true; + } + if (grid.glassMaterialName.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(built-in)"); + } + if (ImGui::SliderFloat("Glass Reflectivity", + &grid.glassReflectivity, 0.0f, 1.0f, + "%.2f")) { + modified = true; + } + } + ImGui::Separator(); // Statistics @@ -58,58 +123,9 @@ bool CellGridEditor::renderComponent(flecs::entity entity, CellGridComponent& gr // Door editor if (ImGui::CollapsingHeader("Doors")) { - if (ImGui::Checkbox("Enabled", &grid.doorsEnabled)) { - grid.markDirty(); - } - if (ImGui::DragFloat("Open Angle", &grid.doorOpenAngle, - 1.0f, 0.0f, 180.0f, "%.0f deg")) { - grid.markDirty(); - } - if (ImGui::DragFloat("Open Speed", &grid.doorOpenSpeed, - 1.0f, 1.0f, 720.0f, "%.0f deg/s")) { - grid.markDirty(); - } - char meshBuffer[256]; - strncpy(meshBuffer, grid.doorMeshName.c_str(), - sizeof(meshBuffer) - 1); - meshBuffer[sizeof(meshBuffer) - 1] = '\0'; - if (ImGui::InputText("Custom Mesh (optional)", meshBuffer, - sizeof(meshBuffer))) { - grid.doorMeshName = meshBuffer; - grid.markDirty(); - } - if (ImGui::Checkbox("Use Mesh Material", - &grid.doorUseMeshMaterial)) { - grid.markDirty(); - } - char actionBuffer[256]; - strncpy(actionBuffer, grid.doorActionName.c_str(), - sizeof(actionBuffer) - 1); - actionBuffer[sizeof(actionBuffer) - 1] = '\0'; - if (ImGui::InputText("Action on Toggle (optional)", - actionBuffer, sizeof(actionBuffer))) { - grid.doorActionName = actionBuffer; - grid.markDirty(); - } - char sceneBuffer[512]; - strncpy(sceneBuffer, grid.doorSceneSwitchPath.c_str(), - sizeof(sceneBuffer) - 1); - sceneBuffer[sizeof(sceneBuffer) - 1] = '\0'; - if (ImGui::InputText("Scene Switch Path (optional)", - sceneBuffer, sizeof(sceneBuffer))) { - grid.doorSceneSwitchPath = sceneBuffer; - grid.markDirty(); - } - char targetBuffer[256]; - strncpy(targetBuffer, grid.doorSceneSwitchTarget.c_str(), - sizeof(targetBuffer) - 1); - targetBuffer[sizeof(targetBuffer) - 1] = '\0'; - if (ImGui::InputText("Teleport Target (optional)", - targetBuffer, sizeof(targetBuffer))) { - grid.doorSceneSwitchTarget = targetBuffer; - grid.markDirty(); - } + renderDoorEditor(entity, grid); } + updateDoorHighlight(entity, grid); // Script editor if (ImGui::CollapsingHeader("Generation Script")) { @@ -484,3 +500,470 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo ImGui::Unindent(); } + +// ============================================================================ +// F0: per-doorway configuration panel +// ============================================================================ + +void CellGridEditor::renderDoorEditor(flecs::entity entity, CellGridComponent& grid) +{ + // --- Grid-wide door defaults (apply to every doorway without an + // override entry) --- + if (ImGui::Checkbox("Enabled", &grid.doorsEnabled)) { + grid.markDirty(); + } + if (ImGui::DragFloat("Open Angle", &grid.doorOpenAngle, + 1.0f, 0.0f, 180.0f, "%.0f deg")) { + grid.markDirty(); + } + if (ImGui::DragFloat("Open Speed", &grid.doorOpenSpeed, + 1.0f, 1.0f, 720.0f, "%.0f deg/s")) { + grid.markDirty(); + } + if (ImGui::Checkbox("Reversed swing", &grid.doorSwingReversed)) { + grid.markDirty(); + } + char meshBuffer[256]; + strncpy(meshBuffer, grid.doorMeshName.c_str(), sizeof(meshBuffer) - 1); + meshBuffer[sizeof(meshBuffer) - 1] = '\0'; + if (ImGui::InputText("Custom Mesh (optional)", meshBuffer, + sizeof(meshBuffer))) { + grid.doorMeshName = meshBuffer; + grid.markDirty(); + } + if (ImGui::Checkbox("Use Mesh Material", &grid.doorUseMeshMaterial)) { + grid.markDirty(); + } + char actionBuffer[256]; + strncpy(actionBuffer, grid.doorActionName.c_str(), + sizeof(actionBuffer) - 1); + actionBuffer[sizeof(actionBuffer) - 1] = '\0'; + if (ImGui::InputText("Action on Toggle (optional)", + actionBuffer, sizeof(actionBuffer))) { + grid.doorActionName = actionBuffer; + grid.markDirty(); + } + char sceneBuffer[512]; + strncpy(sceneBuffer, grid.doorSceneSwitchPath.c_str(), + sizeof(sceneBuffer) - 1); + sceneBuffer[sizeof(sceneBuffer) - 1] = '\0'; + if (ImGui::InputText("Scene Switch Path (optional)", + sceneBuffer, sizeof(sceneBuffer))) { + grid.doorSceneSwitchPath = sceneBuffer; + grid.markDirty(); + } + char targetBuffer[256]; + strncpy(targetBuffer, grid.doorSceneSwitchTarget.c_str(), + sizeof(targetBuffer) - 1); + targetBuffer[sizeof(targetBuffer) - 1] = '\0'; + if (ImGui::InputText("Teleport Target (optional)", + targetBuffer, sizeof(targetBuffer))) { + grid.doorSceneSwitchTarget = targetBuffer; + grid.markDirty(); + } + + ImGui::Separator(); + + // Grid identity: make sure the uid exists so door IDs shown below + // are the final stable ones. + const std::string& uid = grid.ensureGridUid(); + ImGui::Text("Grid UID: %s", uid.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy##griduid")) { + ImGui::SetClipboardText(uid.c_str()); + } + + // --- Doorway list --- + std::vector doorways = + CellGridSystem::collectDoorways(grid); + + // Pick in viewport: the next click raycasts against this grid's door + // leaves (handled by EditorUISystem::onMousePressed). + DoorPickState& pick = DoorPickState::instance(); + if (pick.done) { + if (pick.resultValid && pick.gridEntityId == entity.id()) { + m_selectedDoorKey = pick.resultEdgeKey; + } + pick.done = false; + } + if (pick.active && pick.gridEntityId == entity.id()) { + if (ImGui::Button("Cancel Pick")) { + pick.cancel(); + } + ImGui::SameLine(); + ImGui::TextDisabled("Click a door leaf in the viewport..."); + } else { + bool canPick = !doorways.empty(); + if (!canPick) { + ImGui::BeginDisabled(); + } + if (ImGui::Button("Pick Door in Viewport")) { + pick.arm(entity); + } + if (!canPick) { + ImGui::EndDisabled(); + } + } + + auto effectiveSwitchPath = [&](const std::string& key) -> std::string { + auto it = grid.doorConfigs.find(key); + if (it != grid.doorConfigs.end() && it->second.hasOverride) { + return it->second.sceneSwitchPath; + } + return grid.doorSceneSwitchPath; + }; + + if (doorways.empty()) { + ImGui::TextDisabled("No doorways - set door flags on cells first"); + } else if (ImGui::BeginListBox("Doorways", ImVec2(-FLT_MIN, 120))) { + for (const auto& dw : doorways) { + std::string row = dw.edgeKey; + auto cfgIt = grid.doorConfigs.find(dw.edgeKey); + if (cfgIt != grid.doorConfigs.end()) { + const CellGridDoorConfig& c = cfgIt->second; + if (!c.label.empty()) { + row += " \"" + c.label + "\""; + } + if (c.disabled) row += " [disabled]"; + if (c.persistent) row += " [persistent]"; + if (c.lockable) row += " [lockable]"; + } + if (!effectiveSwitchPath(dw.edgeKey).empty()) { + row += " [switch]"; + } + if (dw.internal) { + row += " (int)"; + } + ImGui::PushID(dw.edgeKey.c_str()); + if (ImGui::Selectable(row.c_str(), + m_selectedDoorKey == dw.edgeKey)) { + m_selectedDoorKey = dw.edgeKey; + m_reassignSource.clear(); + } + ImGui::PopID(); + } + ImGui::EndListBox(); + } + + // --- Selected doorway editor --- + bool selectedIsDoorway = false; + for (const auto& dw : doorways) { + if (dw.edgeKey == m_selectedDoorKey) { + selectedIsDoorway = true; + break; + } + } + if (!selectedIsDoorway && !m_selectedDoorKey.empty() && + grid.doorConfigs.find(m_selectedDoorKey) == grid.doorConfigs.end()) { + // Selection refers to nothing at all (e.g. doorway removed and no + // config kept): drop it. + m_selectedDoorKey.clear(); + } + + if (selectedIsDoorway) { + ImGui::Separator(); + ImGui::Text("Doorway: %s", m_selectedDoorKey.c_str()); + std::string doorId = uid + ":" + m_selectedDoorKey; + ImGui::Text("Door ID: %s", doorId.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Copy##doorid")) { + ImGui::SetClipboardText(doorId.c_str()); + } + + auto it = grid.doorConfigs.find(m_selectedDoorKey); + bool hasEntry = it != grid.doorConfigs.end(); + bool configure = hasEntry; + if (ImGui::Checkbox("Configure this door", &configure)) { + if (configure) { + // Create the entry as a snapshot of the grid defaults; + // hasOverride stays false until the user opts in. + CellGridDoorConfig cfg; + cfg.openAngle = grid.doorOpenAngle; + cfg.openSpeed = grid.doorOpenSpeed; + cfg.swingReversed = grid.doorSwingReversed; + cfg.actionName = grid.doorActionName; + cfg.sceneSwitchPath = grid.doorSceneSwitchPath; + cfg.sceneSwitchTarget = grid.doorSceneSwitchTarget; + grid.doorConfigs[m_selectedDoorKey] = cfg; + } else { + grid.doorConfigs.erase(m_selectedDoorKey); + } + grid.markDirty(); + hasEntry = configure; + it = grid.doorConfigs.find(m_selectedDoorKey); + } + + if (hasEntry) { + CellGridDoorConfig& cfg = it->second; + + char labelBuffer[128]; + strncpy(labelBuffer, cfg.label.c_str(), + sizeof(labelBuffer) - 1); + labelBuffer[sizeof(labelBuffer) - 1] = '\0'; + if (ImGui::InputText("Label", labelBuffer, + sizeof(labelBuffer))) { + cfg.label = labelBuffer; + } + + if (ImGui::Checkbox("Override grid defaults", + &cfg.hasOverride)) { + if (cfg.hasOverride) { + // Snapshot current grid defaults as the starting + // point for the override values. + cfg.openAngle = grid.doorOpenAngle; + cfg.openSpeed = grid.doorOpenSpeed; + cfg.swingReversed = grid.doorSwingReversed; + cfg.actionName = grid.doorActionName; + cfg.sceneSwitchPath = grid.doorSceneSwitchPath; + cfg.sceneSwitchTarget = grid.doorSceneSwitchTarget; + } + grid.markDirty(); + } + if (cfg.hasOverride) { + if (ImGui::DragFloat("Open Angle##door", &cfg.openAngle, + 1.0f, 0.0f, 180.0f, "%.0f deg")) { + grid.markDirty(); + } + if (ImGui::DragFloat("Open Speed##door", &cfg.openSpeed, + 1.0f, 1.0f, 720.0f, "%.0f deg/s")) { + grid.markDirty(); + } + if (ImGui::Checkbox("Reversed swing", + &cfg.swingReversed)) { + grid.markDirty(); + } + char actBuffer[256]; + strncpy(actBuffer, cfg.actionName.c_str(), + sizeof(actBuffer) - 1); + actBuffer[sizeof(actBuffer) - 1] = '\0'; + if (ImGui::InputText("Action on Toggle##door", actBuffer, + sizeof(actBuffer))) { + cfg.actionName = actBuffer; + grid.markDirty(); + } + char pathBuffer[512]; + strncpy(pathBuffer, cfg.sceneSwitchPath.c_str(), + sizeof(pathBuffer) - 1); + pathBuffer[sizeof(pathBuffer) - 1] = '\0'; + if (ImGui::InputText("Scene Switch Path##door", + pathBuffer, sizeof(pathBuffer))) { + cfg.sceneSwitchPath = pathBuffer; + grid.markDirty(); + } + char tgtBuffer[256]; + strncpy(tgtBuffer, cfg.sceneSwitchTarget.c_str(), + sizeof(tgtBuffer) - 1); + tgtBuffer[sizeof(tgtBuffer) - 1] = '\0'; + if (ImGui::InputText("Teleport Target##door", tgtBuffer, + sizeof(tgtBuffer))) { + cfg.sceneSwitchTarget = tgtBuffer; + grid.markDirty(); + } + } + + if (ImGui::Checkbox("Disabled (no door entity)", + &cfg.disabled)) { + grid.markDirty(); + } + if (ImGui::Checkbox("Persistent state", &cfg.persistent)) { + grid.markDirty(); + } + if (ImGui::Checkbox("Lockable", &cfg.lockable)) { + grid.markDirty(); + } + if (cfg.lockable) { + if (ImGui::Checkbox("Locked by default", + &cfg.lockedByDefault)) { + // only a default - no rebuild needed + } + char keyBuffer[256]; + strncpy(keyBuffer, cfg.keyItemId.c_str(), + sizeof(keyBuffer) - 1); + keyBuffer[sizeof(keyBuffer) - 1] = '\0'; + if (ImGui::InputText("Key Item ID", keyBuffer, + sizeof(keyBuffer))) { + cfg.keyItemId = keyBuffer; + } + } + + if (ImGui::Button("Reset to grid defaults")) { + grid.doorConfigs.erase(m_selectedDoorKey); + grid.markDirty(); + } + } + } + + // --- Orphaned configs (doorway removed/moved, config kept) --- + std::vector orphans; + for (const auto& pair : grid.doorConfigs) { + bool live = false; + for (const auto& dw : doorways) { + if (dw.edgeKey == pair.first) { + live = true; + break; + } + } + if (!live) { + orphans.push_back(pair.first); + } + } + + if (!orphans.empty()) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), + "Orphaned door configs (doorway no longer exists):"); + for (const auto& key : orphans) { + ImGui::PushID(key.c_str()); + const CellGridDoorConfig& cfg = grid.doorConfigs[key]; + std::string row = key; + if (!cfg.label.empty()) { + row += " \"" + cfg.label + "\""; + } + ImGui::TextUnformatted(row.c_str()); + + if (m_reassignSource != key) { + if (ImGui::SmallButton("Prune")) { + grid.doorConfigs.erase(key); + grid.markDirty(); + ImGui::PopID(); + break; + } + ImGui::SameLine(); + bool canReassign = !doorways.empty(); + if (!canReassign) { + ImGui::BeginDisabled(); + } + if (ImGui::SmallButton("Reassign...")) { + m_reassignSource = key; + m_reassignTarget = -1; + } + if (!canReassign) { + ImGui::EndDisabled(); + } + } else { + // Target doorway picker + std::string preview = m_reassignTarget >= 0 + ? doorways[m_reassignTarget].edgeKey + : "Select target doorway..."; + if (ImGui::BeginCombo("Target", preview.c_str())) { + for (int i = 0; i < (int)doorways.size(); ++i) { + std::string tlabel = doorways[i].edgeKey; + bool occupied = grid.doorConfigs.find( + doorways[i].edgeKey) != grid.doorConfigs.end(); + if (occupied) { + tlabel += " (has config - will overwrite)"; + } + ImGui::PushID(i); + if (ImGui::Selectable(tlabel.c_str(), + m_reassignTarget == i)) { + m_reassignTarget = i; + } + ImGui::PopID(); + } + ImGui::EndCombo(); + } + if (m_reassignTarget >= 0) { + const std::string& newKey = + doorways[m_reassignTarget].edgeKey; + bool overwrite = grid.doorConfigs.find(newKey) != + grid.doorConfigs.end(); + if (overwrite) { + ImGui::TextColored( + ImVec4(1.0f, 0.2f, 0.2f, 1.0f), + "Target already has a config - it will be " + "overwritten!"); + } + if (cfg.persistent || cfg.lockable || + !cfg.sceneSwitchPath.empty()) { + ImGui::TextColored( + ImVec4(1.0f, 0.6f, 0.0f, 1.0f), + "The door's global ID changes; Lua scripts " + "referencing the old ID must be updated."); + } + if (ImGui::SmallButton(overwrite + ? "Overwrite and Move" : "Move Config")) { + CellGridDoorConfig moved = + grid.doorConfigs[m_reassignSource]; + grid.doorConfigs.erase(m_reassignSource); + grid.doorConfigs[newKey] = moved; + grid.markDirty(); + // F6: persisted state follows the config - move + // the door's global-storage keys to the new ID. + GlobalStateStore::getInstance().renamePrefix( + "door." + uid + ":" + m_reassignSource + ".", + "door." + uid + ":" + newKey + "."); + m_reassignSource.clear(); + m_selectedDoorKey = newKey; + ImGui::PopID(); + break; + } + ImGui::SameLine(); + } + if (ImGui::SmallButton("Cancel##reassign")) { + m_reassignSource.clear(); + } + } + ImGui::PopID(); + } + } +} + +void CellGridEditor::updateDoorHighlight(flecs::entity gridEntity, + CellGridComponent& grid) +{ + (void)grid; + // Drop the highlight if the highlighted door vanished (grid rebuild + // destroys and recreates the runtime door entities). + if (m_highlightedDoor.is_valid()) { + if (!m_highlightedDoor.is_alive() || + !m_highlightedDoor.has() || + !m_highlightedDoor.get().entity) { + m_highlightedDoor = flecs::entity::null(); + m_highlightSavedMaterials.clear(); + } + } + + flecs::entity target = flecs::entity::null(); + if (!m_selectedDoorKey.empty()) { + target = CellGridSystem::findDoorEntity(gridEntity, + m_selectedDoorKey); + } + + if (target == m_highlightedDoor) { + return; + } + + clearDoorHighlight(); + if (target.is_valid() && target.is_alive() && + target.has()) { + Ogre::Entity* oe = target.get().entity; + if (oe) { + for (unsigned i = 0; i < oe->getNumSubEntities(); ++i) { + Ogre::SubEntity* sub = oe->getSubEntity(i); + m_highlightSavedMaterials.push_back( + sub->getMaterialName()); + sub->setMaterialName("GizmoYellow"); + } + m_highlightedDoor = target; + } + } +} + +void CellGridEditor::clearDoorHighlight() +{ + if (m_highlightedDoor.is_valid() && m_highlightedDoor.is_alive() && + m_highlightedDoor.has()) { + Ogre::Entity* oe = m_highlightedDoor.get().entity; + if (oe) { + unsigned n = std::min( + oe->getNumSubEntities(), + (unsigned)m_highlightSavedMaterials.size()); + for (unsigned i = 0; i < n; ++i) { + oe->getSubEntity(i)->setMaterialName( + m_highlightSavedMaterials[i]); + } + } + } + m_highlightedDoor = flecs::entity::null(); + m_highlightSavedMaterials.clear(); +} diff --git a/src/features/editScene/ui/CellGridEditor.hpp b/src/features/editScene/ui/CellGridEditor.hpp index 19e9add..2f3d699 100644 --- a/src/features/editScene/ui/CellGridEditor.hpp +++ b/src/features/editScene/ui/CellGridEditor.hpp @@ -1,24 +1,42 @@ #pragma once #include "ComponentEditor.hpp" +#include +#include struct CellGridComponent; +struct CellGridDoorConfig; class CellGridEditor : public ComponentEditor { public: bool renderComponent(flecs::entity entity, CellGridComponent& grid) override; const char* getName() const override { return "Cell Grid"; } - + private: void renderCellEditor(CellGridComponent& grid); void renderFurnitureEditor(CellGridComponent& grid); void renderScriptEditor(CellGridComponent& grid); void renderTextureRectEditor(flecs::entity entity, CellGridComponent& grid); - + // F0: per-doorway configuration panel (door list, overrides, + // pick-in-viewport, highlight, orphan handling) + void renderDoorEditor(flecs::entity entity, CellGridComponent& grid); + + // Leaf material highlight for the doorway selected in the door panel + void updateDoorHighlight(flecs::entity gridEntity, + CellGridComponent& grid); + void clearDoorHighlight(); + // State for UI int selectedCellX = 0, selectedCellY = 0, selectedCellZ = 0; int newCellX = 0, newCellY = 0, newCellZ = 0; char scriptBuffer[16384] = {0}; bool showGrid = false; flecs::entity m_currentEntity = flecs::entity::null(); + + // Door panel state + std::string m_selectedDoorKey; // edge key of the selected doorway + flecs::entity m_highlightedDoor = flecs::entity::null(); + std::vector m_highlightSavedMaterials; + int m_reassignTarget = -1; // doorway combo index for reassign + std::string m_reassignSource; // orphan edge key being reassigned }; diff --git a/src/features/editScene/ui/DoorPickState.hpp b/src/features/editScene/ui/DoorPickState.hpp new file mode 100644 index 0000000..842cd80 --- /dev/null +++ b/src/features/editScene/ui/DoorPickState.hpp @@ -0,0 +1,46 @@ +#ifndef EDITSCENE_DOORPICKSTATE_HPP +#define EDITSCENE_DOORPICKSTATE_HPP +#pragma once + +#include +#include + +/** + * Shared state between the Cell Grid editor's Doors panel and + * EditorUISystem for "pick door in viewport" (F0). + * + * The panel arms a pick for the grid entity it edits; the next viewport + * click (EditorUISystem::onMousePressed) raycasts against the grid's + * door leaf entities, records the picked doorway edge key here and + * consumes the click. The panel polls done/resultValid on its next + * frame. + */ +struct DoorPickState { + bool active = false; // a pick is armed + uint64_t gridEntityId = 0; // flecs id of the owning grid entity + bool done = false; // a pick attempt finished + bool resultValid = false; // a door leaf was hit + std::string resultEdgeKey; // picked doorway edge key + + static DoorPickState &instance() + { + static DoorPickState s; + return s; + } + + void arm(flecs::entity gridEntity) + { + active = true; + done = false; + resultValid = false; + resultEdgeKey.clear(); + gridEntityId = gridEntity.id(); + } + + void cancel() + { + active = false; + } +}; + +#endif // EDITSCENE_DOORPICKSTATE_HPP diff --git a/src/features/editScene/ui/NavMeshEditor.cpp b/src/features/editScene/ui/NavMeshEditor.cpp index 7418e07..af7ecfb 100644 --- a/src/features/editScene/ui/NavMeshEditor.cpp +++ b/src/features/editScene/ui/NavMeshEditor.cpp @@ -69,6 +69,12 @@ void NavMeshEditor::renderParams(NavMeshComponent &comp) comp.tileSize = 16; if (comp.tileSize > 128) comp.tileSize = 128; + // F7: doorway polys get this traversal cost; > 1 makes + // paths prefer doorless routes without forbidding doors + ImGui::InputFloat("Door Area Cost", &comp.doorAreaCost, + 0.5f, 0.0f, "%.1f"); + if (comp.doorAreaCost < 1.0f) + comp.doorAreaCost = 1.0f; ImGui::TreePop(); } } diff --git a/src/features/editScene/ui/StandaloneDoorEditor.cpp b/src/features/editScene/ui/StandaloneDoorEditor.cpp new file mode 100644 index 0000000..cafbea6 --- /dev/null +++ b/src/features/editScene/ui/StandaloneDoorEditor.cpp @@ -0,0 +1,111 @@ +#include "StandaloneDoorEditor.hpp" +#include + +namespace +{ + +bool editString(const char *label, std::string &value) +{ + char buf[256]; + snprintf(buf, sizeof(buf), "%s", value.c_str()); + if (ImGui::InputText(label, buf, sizeof(buf))) { + value = buf; + return true; + } + return false; +} + +} // namespace + +bool StandaloneDoorEditor::renderComponent(flecs::entity entity, + StandaloneDoorComponent &door) +{ + bool modified = false; + ImGui::PushID("StandaloneDoor"); + + ImGui::Text("Standalone Door (F2)"); + ImGui::SameLine(); + ImGui::TextDisabled("(entity transform = doorway placement)"); + ImGui::Separator(); + + if (editString("Door ID", door.doorId)) + modified = true; + if (!door.doorId.empty()) { + /* Warn when another standalone door already uses this ID: + * persistence keys would collide in the save file. */ + int duplicates = 0; + entity.world() + .query() + .each([&](flecs::entity other, + const StandaloneDoorComponent &d) { + if (other != entity && d.doorId == door.doorId) + duplicates++; + }); + if (duplicates > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "Duplicate door ID (%d other door%s)", + duplicates, duplicates > 1 ? "s" : ""); + } + } else { + ImGui::SameLine(); + ImGui::TextDisabled("(auto-generated when needed)"); + } + + ImGui::Separator(); + ImGui::Text("Leaf"); + if (editString("Mesh Name", door.meshName)) + modified = true; + if (ImGui::Checkbox("Use Mesh Material", &door.useMeshMaterial)) + modified = true; + if (editString("Texture Rect", door.rectName)) + modified = true; + if (ImGui::DragFloat("Leaf Width", &door.leafWidth, 0.01f, 0.05f, + 10.0f, "%.2f")) + modified = true; + if (ImGui::DragFloat("Leaf Height", &door.leafHeight, 0.01f, 0.05f, + 10.0f, "%.2f")) + modified = true; + if (ImGui::DragFloat("Leaf Thickness", &door.leafThickness, 0.005f, + 0.01f, 1.0f, "%.3f")) + modified = true; + + ImGui::Separator(); + ImGui::Text("Behaviour"); + if (ImGui::DragFloat("Open Angle", &door.openAngle, 1.0f, -180.0f, + 180.0f, "%.0f deg")) + modified = true; + if (ImGui::DragFloat("Open Speed", &door.openSpeed, 1.0f, 1.0f, + 720.0f, "%.0f deg/s")) + modified = true; + if (ImGui::Checkbox("Swing Reversed", &door.swingReversed)) + modified = true; + if (editString("Action Name", door.actionName)) + modified = true; + if (editString("Scene Switch Path", door.sceneSwitchPath)) + modified = true; + if (!door.sceneSwitchPath.empty()) { + if (editString("Scene Switch Target", + door.sceneSwitchTarget)) + modified = true; + } + + ImGui::Separator(); + ImGui::Text("Persistence / Locking"); + if (ImGui::Checkbox("Persistent", &door.persistent)) + modified = true; + if (ImGui::Checkbox("Lockable", &door.lockable)) + modified = true; + if (door.lockable) { + if (ImGui::Checkbox("Locked By Default", + &door.lockedByDefault)) + modified = true; + if (editString("Key Item ID", door.keyItemId)) + modified = true; + } + + if (modified) + door.dirty = true; + + ImGui::PopID(); + return modified; +} diff --git a/src/features/editScene/ui/StandaloneDoorEditor.hpp b/src/features/editScene/ui/StandaloneDoorEditor.hpp new file mode 100644 index 0000000..ef19be5 --- /dev/null +++ b/src/features/editScene/ui/StandaloneDoorEditor.hpp @@ -0,0 +1,20 @@ +#ifndef EDITSCENE_STANDALONE_DOOR_EDITOR_HPP +#define EDITSCENE_STANDALONE_DOOR_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/StandaloneDoor.hpp" + +class StandaloneDoorEditor : public ComponentEditor { +public: + const char *getName() const override + { + return "Standalone Door"; + } + +protected: + bool renderComponent(flecs::entity entity, + StandaloneDoorComponent &door) override; +}; + +#endif // EDITSCENE_STANDALONE_DOOR_EDITOR_HPP