diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index c39ad46..3fea0f1 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -170,12 +170,13 @@ to `EditorApp`. 13. `GoapPlannerSystem` 14. `GoapRunnerSystem` 15. `ActuatorSystem` -16. `EventHandlerSystem` -17. `CharacterSystem` -18. `BuoyancySystem` -19. `PhysicsSystem` -20. `HairPhysicsSystem` pose read-back -21. Rendering support systems (sun, skybox, water, light, LOD, etc.) +16. `DoorSystem` +17. `EventHandlerSystem` +18. `CharacterSystem` +19. `BuoyancySystem` +20. `PhysicsSystem` +21. `HairPhysicsSystem` pose read-back +22. Rendering support systems (sun, skybox, water, light, LOD, etc.) Systems that write animation state or velocity should respect systems that run before them. In particular, `PlayerControllerSystem` runs first and is the only @@ -420,6 +421,50 @@ screen-space via ImGui, so `update()` and `render()` bail out early when there is no ImGui context (headless mode); cooldown timers and the executing-action block still run. +### DoorSystem & CellGrid doors + +`CellGridSystem::buildDoorEntities()` spawns one runtime door entity per +unique doorway when `CellGridComponent::doorsEnabled` is set (default true). +Doors are placed on the same condition as door frames (any of the 8 door cell +flags, internal and external); because the two cells sharing a door edge both +carry the flag, placement is deduplicated on a canonical edge key so each room +connection or exit gets exactly one door. + +Each door entity (child of the grid, no `EditorMarkerComponent`, so it is not +serialized or editable) carries: + +- `TransformComponent` whose node is the hinge node at the doorway +- `RenderableComponent` with the leaf mesh +- `DoorComponent` (runtime only, not serialized) with the swing state +- `RigidBodyComponent` (static) plus a child entity with a box + `PhysicsColliderComponent` covering the closed leaf +- `ActuatorComponent` so the player gets an "E Open"/"E Close" prompt + +The leaf mesh is procedural by default (a box sized to fit the door frame +opening, hinge edge at the origin, UV-mapped through +`CellGridComponent::doorRectName` like the other grid parts) and uses the +grid's procedural material. Setting `CellGridComponent::doorMeshName` loads a +custom mesh instead (it must be modeled with the hinge edge at the origin, +spanning +X/+Y); it still uses the procedural material unless +`doorUseMeshMaterial` is set. + +Interaction: `ActuatorSystem` sets `DoorComponent::toggleRequested` on E and, +when `CellGridComponent::doorActionName` is set, also runs that action. +`DoorSystem` consumes the request, swings the hinge node around local Y to +`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". + +New `CellGridComponent` fields (serialized in the scene JSON and exposed to +Lua): `doorsEnabled`, `doorRectName`, `doorMeshName`, `doorUseMeshMaterial`, +`doorOpenAngle`, `doorOpenSpeed`, `doorActionName`, `doorSceneSwitchPath`, +`doorSceneSwitchTarget`. + ### SceneScriptComponent & SceneScriptSystem Attaches a Lua script to a scene or prefab entity. Fields: diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 1b5665c..27234e7 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -36,6 +36,7 @@ set(EDITSCENE_SOURCES systems/ProceduralMaterialSystem.cpp systems/ProceduralMeshSystem.cpp systems/CellGridSystem.cpp + systems/DoorSystem.cpp systems/NormalDebugSystem.cpp systems/RoomLayoutSystem.cpp systems/FurnitureLibrary.cpp @@ -254,6 +255,7 @@ set(EDITSCENE_HEADERS systems/PlayerControllerSystem.hpp systems/EditorUISystem.hpp systems/CellGridSystem.hpp + systems/DoorSystem.hpp systems/NormalDebugSystem.hpp systems/RoomLayoutSystem.hpp systems/FurnitureLibrary.hpp @@ -284,6 +286,7 @@ set(EDITSCENE_HEADERS systems/PathFollowingSystem.hpp systems/GoapPlannerSystem.hpp components/Actuator.hpp + components/Door.hpp ui/ActuatorEditor.hpp systems/EventBus.hpp components/EventHandler.hpp diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 6a88398..cd2fedd 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -104,6 +104,7 @@ #include "components/GoapRunner.hpp" #include "components/PathFollowing.hpp" #include "systems/ActuatorSystem.hpp" +#include "systems/DoorSystem.hpp" #include "systems/EventHandlerSystem.hpp" #include "systems/EventBus.hpp" #include "systems/SceneScriptSystem.hpp" @@ -391,6 +392,7 @@ void EditorApp::destroyEditorSystems() m_itemSystem.reset(); m_eventHandlerSystem.reset(); m_actuatorSystem.reset(); + m_doorSystem.reset(); m_goapPlannerSystem.reset(); m_pathFollowingSystem.reset(); m_goapRunnerSystem.reset(); @@ -702,8 +704,12 @@ void EditorApp::setup() // Setup CellGrid system m_cellGridSystem = std::make_unique(m_world, m_sceneMgr); + m_cellGridSystem->setPhysicsSystem(m_physicsSystem.get()); m_cellGridSystem->initialize(); + // Setup Door system (swing animation for cell grid doors) + m_doorSystem = std::make_unique(m_world); + // Wire CellGridSystem into NavMeshSystem so it can collect // batched frame/furniture geometry from StaticGeometry. m_navMeshSystem->setCellGridSystem(m_cellGridSystem.get()); @@ -2149,6 +2155,11 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) m_actuatorSystem->update(evt.timeSinceLastFrame); } + /* --- Door system (door swing animation) --- */ + if (m_doorSystem) { + m_doorSystem->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 c8f4de1..4002397 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -48,6 +48,7 @@ class GoapRunnerSystem; class PathFollowingSystem; class GoapPlannerSystem; class ActuatorSystem; +class DoorSystem; class EventHandlerSystem; class ItemSystem; class CharacterClassSystem; @@ -378,6 +379,7 @@ private: std::unique_ptr m_pathFollowingSystem; std::unique_ptr m_goapPlannerSystem; std::unique_ptr m_actuatorSystem; + std::unique_ptr m_doorSystem; std::unique_ptr m_eventHandlerSystem; std::unique_ptr m_itemSystem; std::unique_ptr m_characterClassSystem; diff --git a/src/features/editScene/components/CellGrid.hpp b/src/features/editScene/components/CellGrid.hpp index d4fa887..3b5020a 100644 --- a/src/features/editScene/components/CellGrid.hpp +++ b/src/features/editScene/components/CellGrid.hpp @@ -145,6 +145,22 @@ struct CellGridComponent { std::string roofTopRectName; std::string roofSideRectName; + // Door settings - one door entity is spawned per unique doorway + // (deduplicated across the two cells sharing a door edge) on the same + // condition as door frames (any of the 8 door cell flags). + bool doorsEnabled = true; // Spawn openable door entities + std::string doorRectName; // Named rect for procedural door leaf UV + std::string doorMeshName; // Custom door leaf mesh (empty = procedural) + 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) + std::string doorActionName; // Optional action run on activation + // Scene switching doors (e.g. interior <-> exterior): when + // doorSceneSwitchPath is set, activating a door queues an + // EditorApp::switchScene() instead of swinging the leaf + std::string doorSceneSwitchPath; // Target scene path (empty = disabled) + std::string doorSceneSwitchTarget; // Teleport target entity name (optional) + // Physics properties for generated colliders float friction = 0.5f; diff --git a/src/features/editScene/components/Door.hpp b/src/features/editScene/components/Door.hpp new file mode 100644 index 0000000..27f022d --- /dev/null +++ b/src/features/editScene/components/Door.hpp @@ -0,0 +1,49 @@ +#ifndef EDITSCENE_DOOR_HPP +#define EDITSCENE_DOOR_HPP +#pragma once + +#include + +/** + * Door component (runtime only - not serialized). + * + * Attached to door entities spawned by CellGridSystem for each unique + * doorway (door cell flags, deduplicated across the two cells sharing a + * door edge). The entity's TransformComponent node is the hinge node: + * DoorSystem rotates it around local Y to swing the door. + * + * Interaction works through the ActuatorComponent on the same entity: + * ActuatorSystem sets toggleRequested on activation; DoorSystem consumes + * it, swings the door and disables/enables the door's RigidBodyComponent + * (collider disabled while the door is not fully closed). + */ +struct DoorComponent { + // Current state + bool isOpen = false; + + // Set by ActuatorSystem (or scripts) to request an open/close toggle + bool toggleRequested = false; + + // 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) + + // Runtime: current swing angle (0 = closed) + float currentAngle = 0.0f; + + // Local orientation of the hinge node in the closed pose + Ogre::Quaternion closedOrientation = Ogre::Quaternion::IDENTITY; + + // Door-local offset from the hinge to the leaf center; used by + // ActuatorSystem to place the interaction prompt/circle (the hinge + // 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 + std::string sceneSwitchPath; // Target scene path (empty = disabled) + std::string sceneSwitchTarget; // Teleport target entity name +}; + +#endif // EDITSCENE_DOOR_HPP 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 a09baf5..eb0cc12 100644 --- a/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt +++ b/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt @@ -10,9 +10,11 @@ # without a Lot/District/Town parent. # # The executable is self-contained in this build directory: a POST_BUILD -# step (stage_runtime.cmake) copies resources.cfg, both demo scenes, the +# step (stage_runtime.cmake) copies resources.cfg, the # character prefab (prefabs/char_2.json, written by a previous editor/game -# run) and any runtime config JSONs here, and symlinks the big pre-staged +# run) and any runtime config JSONs here, and symlinks both demo scenes +# (demo_scene_a.json / demo_scene_b.json, so scene edits in the source tree +# are visible without a rebuild) plus the big pre-staged # runtime directories (resources/, characters/, lua-scripts/) from the # editScene binary directory. Run it from here: # cd /src/features/editScene/demos/demo-scene-switching-extra 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 5106643..38c7498 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 @@ -359,9 +359,10 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * Escape toggles the pause menu, which frees the cursor. * * The binary is self-contained in its build directory: run it from there - * (resources.cfg, both scene JSONs, resources/, characters/, lua-scripts/, - * prefabs/ and the runtime config JSONs are staged next to it by the - * build). + * (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the + * runtime config JSONs are staged next to it by the build; both scene + * JSONs are symlinks into the source tree, so scene edits take effect + * without a rebuild). * * Extra flags: * --test-switch headless-friendly end-to-end check: executes both 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 75cfd18..66311b5 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 @@ -130,7 +130,7 @@ "entities": [ { "children": [], - "id": 4294967791, + "id": 488, "name": { "name": "arrival_a" }, @@ -155,7 +155,7 @@ }, { "children": [], - "id": 133143986672, + "id": 489, "light": { "castShadows": false, "constantAttenuation": 1.0, @@ -231,7 +231,7 @@ }, "shapeType": "box" }, - "id": 77309411831, + "id": 490, "name": { "name": "demo_floor" }, @@ -275,7 +275,7 @@ "radius": 1.5 }, "children": [], - "id": 77309411832, + "id": 491, "name": { "name": "portal_a" }, @@ -309,7 +309,7 @@ "spawnDistance": 100.0 }, "children": [], - "id": 77309411833, + "id": 492, "name": { "name": "s1" }, @@ -334,7 +334,7 @@ }, { "children": [], - "id": 77309411834, + "id": 493, "name": { "name": "player" }, @@ -386,7 +386,7 @@ "cellGrid": { "ceilingRectName": "ceiling", "cellHeight": 4.0, - "cellSize": 4.0, + "cellSize": 2.0, "cells": [ { "flags": 147495, @@ -551,25 +551,158 @@ "z": 6 }, { - "flags": 81943, + "flags": 81927, "x": -1, "y": 0, "z": 7 }, { - "flags": 1048835, + "flags": 1048579, "x": 0, "y": 0, "z": 7 }, { - "flags": 98331, + "flags": 98315, "x": 1, "y": 0, "z": 7 + }, + { + "flags": 147463, + "x": -1, + "y": 0, + "z": 8 + }, + { + "flags": 2097155, + "x": 0, + "y": 0, + "z": 8 + }, + { + "flags": 163851, + "x": 1, + "y": 0, + "z": 8 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 9 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 9 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 9 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 10 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 10 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 10 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 11 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 11 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 11 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 12 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 12 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 12 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 13 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 13 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 13 + }, + { + "flags": 81943, + "x": -1, + "y": 0, + "z": 14 + }, + { + "flags": 1048835, + "x": 0, + "y": 0, + "z": 14 + }, + { + "flags": 98331, + "x": 1, + "y": 0, + "z": 14 } ], "depth": 10, + "doorActionName": "", + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorUseMeshMaterial": false, + "doorsEnabled": true, "extDoorFrameRectName": "", "extWallRectName": "", "extWindowFrameRectName": "", @@ -595,12 +728,12 @@ "clearRooms": false, "maxX": 8, "maxY": 1, - "maxZ": 8, + "maxZ": 20, "minX": -8, "minY": 0, "minZ": -8 }, - "id": 103079215604, + "id": 495, "name": { "name": "r1" }, @@ -625,12 +758,14 @@ }, { "children": [], - "id": 103079215605, + "id": 496, "name": { "name": "r2" }, "room": { - "connectedRoomIds": [], + "connectedRoomIds": [ + "room_1788724060923073709_509" + ], "createCeiling": true, "createFloor": true, "createInteriorWalls": true, @@ -672,9 +807,61 @@ "z": 1.0 } } + }, + { + "children": [], + "id": 509, + "name": { + "name": "r3" + }, + "room": { + "connectedRoomIds": [ + "room_1788648326350139622_498" + ], + "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": 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": 103079215603, + "id": 494, "name": { "name": "interrior" }, @@ -1327,7 +1514,7 @@ "transform": { "position": { "x": 0.0, - "y": 0.15325629711151123, + "y": 0.013780713081359863, "z": 0.0 }, "rotation": { 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 ec88016..758a166 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 @@ -1,178 +1,136 @@ { - "version": "1.0", "actionDatabase": { "actions": [ { - "name": "goto_scene_a", - "cost": 1, - "preconditions": { - "bits": 0, - "mask": 0 + "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": { - "type": "sequence", "children": [ { - "type": "debugPrint", - "name": "[demo] portal B: switching to scene A" + "name": "main/action", + "type": "setAnimationState" }, { - "type": "switchScene", - "name": "demo_scene_a.json", - "params": "@arrival_a" + "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 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": [ { - "id": 1, - "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 - } - }, - "light": { - "lightType": "directional", - "diffuseColor": { - "r": 1.0, - "g": 1.0, - "b": 1.0, - "a": 1.0 - }, - "specularColor": { - "r": 0.5, - "g": 0.5, - "b": 0.5, - "a": 1.0 - }, - "direction": { - "x": 0.3, - "y": -1.0, - "z": 0.2 - }, - "intensity": 1.0, - "castShadows": false - }, - "children": [] - }, - { - "id": 2, - "name": { - "name": "demo_floor" - }, - "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 - } - }, - "renderable": { - "meshName": "DemoFloorPlaneB", - "visible": true - }, - "rigidBody": { - "bodyType": "static", - "mass": 1.0, - "friction": 0.8, - "restitution": 0.0, - "isSensor": false, - "enabled": true - }, - "collider": { - "shapeType": "box", - "parameters": { - "x": 30.0, - "y": 0.1, - "z": 30.0 - }, - "radius": 0.5, - "halfHeight": 1.0, - "meshName": "", - "offset": { - "x": 0.0, - "y": -0.1, - "z": 0.0 - }, - "rotationOffset": { - "w": 1.0, - "x": 0.0, - "y": 0.0, - "z": 0.0 - } - }, - "children": [] - }, - { - "id": 3, - "name": { - "name": "portal_b" - }, - "transform": { - "position": { - "x": 0.0, - "y": 0.8, - "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 - } - }, - "renderable": { - "meshName": "DemoActuatorMarkerB", - "visible": true - }, - "actuator": { - "radius": 1.5, - "height": 1.8, - "actionNames": ["goto_scene_a"] - }, - "children": [] - }, - { - "id": 4, + "children": [], + "id": 488, "name": { "name": "arrival_b" }, @@ -193,11 +151,405 @@ "y": 1.0, "z": 1.0 } - }, - "children": [] + } }, { - "id": 5, + "children": [ + { + "cellGrid": { + "ceilingRectName": "", + "cellHeight": 4.0, + "cellSize": 2.0, + "cells": [ + { + "flags": 147495, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 2097667, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 163883, + "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": 81943, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 65555, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 98331, + "x": 1, + "y": 0, + "z": 3 + } + ], + "depth": 10, + "doorActionName": "", + "doorMeshName": "", + "doorOpenAngle": 100.0, + "doorOpenSpeed": 180.0, + "doorRectName": "", + "doorUseMeshMaterial": false, + "doorsEnabled": true, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "", + "friction": 0.5, + "furnitureCells": [], + "generationScript": "", + "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": false, + "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" }, @@ -218,37 +570,14 @@ "y": 1.0, "z": 1.0 } - }, - "characterSpawner": { - "despawnDistance": 200.0, - "registryId": 2, - "spawnDistance": 100.0 - }, - "children": [] + } }, { - "id": 6, + "children": [], + "id": 497, "name": { "name": "player" }, - "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 - } - }, "playerController": { "actuatorColor": [ 0.0, @@ -274,7 +603,25 @@ "tpsHeight": 2.0, "walkState": "walking" }, - "children": [] + "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/stage_runtime.cmake b/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake index fe23775..2a6041f 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 @@ -4,7 +4,10 @@ # # Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR. # -# Small demo-owned files are COPIED; the big pre-staged runtime directories +# Small demo-owned files are COPIED, except the demo scenes, which are +# SYMLINKED to the source tree so scene edits are visible to the demo +# without a rebuild (the demo never saves scenes, so nothing writes through +# the links); the big pre-staged runtime directories # are SYMLINKED from the editScene binary directory (Ogre FileSystem # locations follow symlinks, and copying would duplicate hundreds of MB on # every build). The symlink targets are populated by the editSceneEditor @@ -12,8 +15,14 @@ # staging) at least once. file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}") -file(COPY "${SRC_DIR}/demo_scene_a.json" DESTINATION "${DEMO_DIR}") -file(COPY "${SRC_DIR}/demo_scene_b.json" DESTINATION "${DEMO_DIR}") + +foreach(f demo_scene_a.json demo_scene_b.json) + set(link "${DEMO_DIR}/${f}") + if(EXISTS "${link}" OR IS_SYMLINK "${link}") + file(REMOVE "${link}") + endif() + file(CREATE_LINK "${SRC_DIR}/${f}" "${link}" SYMBOLIC) +endforeach() # Character prefab for the spawner's registry entry (registryId 2). It is # written by a previous editor/game run (CharacterRegistry::savePrefab...), diff --git a/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt b/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt index 8e5f995..0cb69e1 100644 --- a/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt +++ b/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt @@ -20,9 +20,11 @@ # demo_main.cpp). # # The executable is self-contained in this build directory: a POST_BUILD -# step (stage_runtime.cmake) copies resources.cfg, both demo scenes, the +# step (stage_runtime.cmake) copies resources.cfg, the # character prefab (prefabs/char_2.json, written by a previous editor/game -# run) and any runtime config JSONs here, and symlinks the big pre-staged +# run) and any runtime config JSONs here, and symlinks both demo scenes +# (demo_scene_a.json / demo_scene_b.json, so scene edits in the source tree +# are visible without a rebuild) plus the big pre-staged # runtime directories (resources/, characters/, lua-scripts/) from the # editScene binary directory. Run it from here: # cd /src/features/editScene/demos/demo-scene-switching diff --git a/src/features/editScene/demos/demo-scene-switching/demo_main.cpp b/src/features/editScene/demos/demo-scene-switching/demo_main.cpp index a0a16b9..87d6c6d 100644 --- a/src/features/editScene/demos/demo-scene-switching/demo_main.cpp +++ b/src/features/editScene/demos/demo-scene-switching/demo_main.cpp @@ -354,9 +354,10 @@ struct SceneSwitchTestListener : public Ogre::FrameListener { * Escape toggles the pause menu, which frees the cursor. * * The binary is self-contained in its build directory: run it from there - * (resources.cfg, both scene JSONs, resources/, characters/, lua-scripts/, - * prefabs/ and the runtime config JSONs are staged next to it by the - * build). + * (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the + * runtime config JSONs are staged next to it by the build; both scene + * JSONs are symlinks into the source tree, so scene edits take effect + * without a rebuild). * * Extra flags: * --test-switch headless-friendly end-to-end check: executes both diff --git a/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake b/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake index 599098f..476574c 100644 --- a/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake +++ b/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake @@ -4,7 +4,10 @@ # # Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR. # -# Small demo-owned files are COPIED; the big pre-staged runtime directories +# Small demo-owned files are COPIED, except the demo scenes, which are +# SYMLINKED to the source tree so scene edits are visible to the demo +# without a rebuild (the demo never saves scenes, so nothing writes through +# the links); the big pre-staged runtime directories # are SYMLINKED from the editScene binary directory (Ogre FileSystem # locations follow symlinks, and copying would duplicate hundreds of MB on # every build). The symlink targets are populated by the editSceneEditor @@ -12,8 +15,14 @@ # staging) at least once. file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}") -file(COPY "${SRC_DIR}/demo_scene_a.json" DESTINATION "${DEMO_DIR}") -file(COPY "${SRC_DIR}/demo_scene_b.json" DESTINATION "${DEMO_DIR}") + +foreach(f demo_scene_a.json demo_scene_b.json) + set(link "${DEMO_DIR}/${f}") + if(EXISTS "${link}" OR IS_SYMLINK "${link}") + file(REMOVE "${link}") + endif() + file(CREATE_LINK "${SRC_DIR}/${f}" "${link}" SYMBOLIC) +endforeach() # Character prefab for the spawner's registry entry (registryId 2). It is # written by a previous editor/game run (CharacterRegistry::savePrefab...), diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index bcae5ca..fff9d6d 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -1410,6 +1410,24 @@ static void registerAllComponents() lua_setfield(L, -2, "cellSize"); lua_pushnumber(L, c.cellHeight); lua_setfield(L, -2, "cellHeight"); + lua_pushboolean(L, c.doorsEnabled ? 1 : 0); + lua_setfield(L, -2, "doorsEnabled"); + lua_pushstring(L, c.doorRectName.c_str()); + lua_setfield(L, -2, "doorRectName"); + lua_pushstring(L, c.doorMeshName.c_str()); + lua_setfield(L, -2, "doorMeshName"); + lua_pushboolean(L, c.doorUseMeshMaterial ? 1 : 0); + lua_setfield(L, -2, "doorUseMeshMaterial"); + lua_pushnumber(L, c.doorOpenAngle); + lua_setfield(L, -2, "doorOpenAngle"); + lua_pushnumber(L, c.doorOpenSpeed); + lua_setfield(L, -2, "doorOpenSpeed"); + 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"); , if (lua_getfield(L, idx, "width"), lua_isnumber(L, -1)) c.width = (int)lua_tointeger(L, -1); lua_pop(L, 1); @@ -1424,6 +1442,36 @@ static void registerAllComponents() lua_pop(L, 1); if (lua_getfield(L, idx, "cellHeight"), lua_isnumber(L, -1)) c.cellHeight = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorsEnabled"), lua_isboolean(L, -1)) + c.doorsEnabled = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorRectName"), lua_isstring(L, -1)) + c.doorRectName = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorMeshName"), lua_isstring(L, -1)) + c.doorMeshName = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorUseMeshMaterial"), + lua_isboolean(L, -1)) + c.doorUseMeshMaterial = lua_toboolean(L, -1) != 0; + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorOpenAngle"), lua_isnumber(L, -1)) + c.doorOpenAngle = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + 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, "doorActionName"), lua_isstring(L, -1)) + c.doorActionName = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorSceneSwitchPath"), + lua_isstring(L, -1)) + c.doorSceneSwitchPath = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "doorSceneSwitchTarget"), + lua_isstring(L, -1)) + c.doorSceneSwitchTarget = lua_tostring(L, -1); lua_pop(L, 1);); // --- Room --- diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index 2d1c886..74e50fa 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -5,6 +5,7 @@ #include "ItemRegistry.hpp" #include "ItemStateRegistry.hpp" #include "../components/Actuator.hpp" +#include "../components/Door.hpp" #include "../components/Item.hpp" #include "../components/Inventory.hpp" #include "../components/PlayerController.hpp" @@ -330,6 +331,13 @@ void ActuatorSystem::update(float deltaTime) Ogre::Vector3 objPos = trans.node->_getDerivedPosition(); + // Doors: the node is the hinge on the side edge of the + // doorway; prompt at the leaf center instead + if (e.has()) { + const auto &door = e.get(); + objPos += trans.node->_getDerivedOrientation() * + door.centerOffset; + } float dist = charPos.distance(objPos); if (dist > actuatorDistance) return; @@ -426,14 +434,26 @@ void ActuatorSystem::update(float deltaTime) m_visibleActuators[m_targetIndex].entity; if (targetEntity.is_alive()) { if (targetEntity.has()) { - auto &actuator = - targetEntity.get(); - if (actuator.actionNames.size() == 1 && - !actuator.actionNames[0].empty()) { + if (targetEntity.has()) { + // Doors toggle open/close on E + const auto &door = + targetEntity.get(); + // Scene switching doors never toggle m_labelText = - "E " + actuator.actionNames[0]; - } else if (actuator.actionNames.size() > 1) { - m_labelText = "E"; + (door.isOpen && + door.sceneSwitchPath.empty()) ? + "E Close" : + "E Open"; + } else { + auto &actuator = + targetEntity.get(); + if (actuator.actionNames.size() == 1 && + !actuator.actionNames[0].empty()) { + m_labelText = + "E " + actuator.actionNames[0]; + } else if (actuator.actionNames.size() > 1) { + m_labelText = "E"; + } } } else if (targetEntity.has()) { // Show "E - Pick up [ItemName]" for items @@ -487,7 +507,36 @@ void ActuatorSystem::update(float deltaTime) auto &actuator = targetEntity.get(); m_eHoldTime += deltaTime; - if (actuator.actionNames.size() == 1 && + if (targetEntity.has()) { + // Doors toggle open/close on E and optionally + // run the configured action; scene switching + // doors queue a scene switch instead + if (input.ePressed) { + auto &door = + targetEntity.get_mut(); + if (!door.sceneSwitchPath.empty()) { + SceneSwitchOptions opts; + opts.targetEntityName = + door.sceneSwitchTarget; + m_editorApp->switchScene( + door.sceneSwitchPath, + opts); + } else { + door.toggleRequested = true; + if (!actuator.actionNames + .empty() && + !actuator.actionNames[0] + .empty()) { + executeAction( + playerCharacter, + targetEntity, + actuator.actionNames + [0]); + } + } + m_eHoldTime = 0.0f; + } + } else if (actuator.actionNames.size() == 1 && !actuator.actionNames[0].empty()) { if (input.ePressed) { executeAction(playerCharacter, diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index 220b8bd..1f072a7 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -1,5 +1,6 @@ #include "CellGridSystem.hpp" #include "FurnitureLibrary.hpp" +#include "PhysicsSystem.hpp" #include "../components/CellGrid.hpp" #include "../components/TriangleBuffer.hpp" #include "../components/Transform.hpp" @@ -7,6 +8,9 @@ #include "../components/StaticGeometryMember.hpp" #include "../components/RigidBody.hpp" #include "../components/PhysicsCollider.hpp" +#include "../components/Door.hpp" +#include "../components/Actuator.hpp" +#include "../components/Renderable.hpp" #include #include "../components/ProceduralMaterial.hpp" #include "../components/ProceduralTexture.hpp" @@ -29,6 +33,7 @@ #include #include #include +#include CellGridSystem::CellGridSystem(flecs::world &world, Ogre::SceneManager *sceneMgr) @@ -583,6 +588,18 @@ void CellGridSystem::buildCellGrid(flecs::entity entity, "CellGrid: Unknown error building furniture"); } + // Build door leaf entities (one per unique doorway) + try { + buildDoorEntities(entity, grid, materialName, materialEntity); + } catch (const std::exception &e) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Error building doors: " + + std::string(e.what())); + } catch (...) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Unknown error building doors"); + } + // Build combined static geometry (frames + furniture + roofs) auto sgIt = m_entityMeshes.find(entity.id()); if (sgIt != m_entityMeshes.end() && sgIt->second.frameStaticGeometry) { @@ -2681,6 +2698,9 @@ void CellGridSystem::destroyCellGridMeshes(flecs::entity entity) // Destroy frames destroyFrames(entity); + // Destroy door entities + destroyDoorEntities(entity); + // Destroy physics colliders destroyPhysicsColliders(entity); @@ -4560,3 +4580,399 @@ void CellGridSystem::placeDoorFramesInStaticGeometry( } } } + +// ============================================================================ +// Door leaf entities (one per unique doorway, swing open/closed) +// ============================================================================ + +void CellGridSystem::createDoorLeafMesh(const CellGridComponent &grid, + const std::string &materialName, + const std::string &meshPrefix, + flecs::entity materialEntity) +{ + std::string leafMeshName = meshPrefix + "door_leaf"; + + auto &meshMgr = Ogre::MeshManager::getSingleton(); + if (meshMgr.resourceExists(leafMeshName)) { + meshMgr.remove(leafMeshName); + } + + // Leaf dimensions: fit inside the door frame opening + // (door frame: width 2.8 * cellSize/4, height 3.0 * cellHeight/4, + // frame post thickness 0.1) + const float doorWidth = 2.8f * (grid.cellSize / 4.0f); + const float doorHeight = 3.0f * (grid.cellHeight / 4.0f); + float leafWidth = doorWidth - 2.0f * 0.1f - 0.04f; + float leafHeight = doorHeight - 0.05f; + float leafThickness = 0.08f; + + // Origin at the hinge edge: the leaf spans x in [0, leafWidth], + // y in [0, leafHeight] + Procedural::TriangleBuffer leafTb; + Procedural::BoxGenerator() + .setSizeX(leafWidth) + .setSizeY(leafHeight) + .setSizeZ(leafThickness) + .setNumSegX(1) + .setNumSegY(2) + .setNumSegZ(1) + .setPosition( + Ogre::Vector3(leafWidth / 2.0f, leafHeight / 2.0f, 0)) + .setEnableNormals(true) + .addToTriangleBuffer(leafTb); + + applyUVMappingToBuffer(leafTb, grid.doorRectName, materialEntity); + Ogre::MeshPtr leafMesh = leafTb.transformToMesh(leafMeshName); + if (!materialName.empty() && leafMesh->getNumSubMeshes() > 0) { + leafMesh->getSubMesh(0)->setMaterialName(materialName); + } + generateLodForMesh(leafMesh); +} + +void CellGridSystem::buildDoorEntities(flecs::entity entity, + const CellGridComponent &grid, + const std::string &materialName, + flecs::entity materialEntity) +{ + destroyDoorEntities(entity); + + if (!grid.doorsEnabled) + return; + + if (!entity.has()) + return; + auto &transform = entity.get(); + if (!transform.node) + return; + + auto &meshData = m_entityMeshes[entity.id()]; + + // Resolve the door leaf mesh: custom mesh or procedural leaf. + // Custom door meshes must be modeled with the hinge edge at the + // origin, the leaf spanning +X and +Y. + std::string meshPrefix = + "CellGrid_" + std::to_string(entity.id()) + "_"; + std::string leafMeshName; + bool usingCustomMesh = false; + bool useMeshMaterial = false; + Ogre::AxisAlignedBox customBounds; + if (!grid.doorMeshName.empty()) { + auto &meshMgr = Ogre::MeshManager::getSingleton(); + Ogre::MeshPtr customMesh = + meshMgr.getByName(grid.doorMeshName); + if (!customMesh) { + Ogre::String group = + Ogre::ResourceGroupManager::getSingleton() + .findGroupContainingResource( + grid.doorMeshName); + if (!group.empty()) { + customMesh = meshMgr.load(grid.doorMeshName, + group); + } + } + if (customMesh) { + leafMeshName = grid.doorMeshName; + usingCustomMesh = true; + useMeshMaterial = grid.doorUseMeshMaterial; + customBounds = customMesh->getBounds(); + generateLodForMesh(customMesh); + } else { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Could not load door mesh '" + + grid.doorMeshName + + "', falling back to procedural door"); + } + } + if (leafMeshName.empty()) { + createDoorLeafMesh(grid, materialName, meshPrefix, + materialEntity); + leafMeshName = meshPrefix + "door_leaf"; + meshData.doorLeafMesh = leafMeshName; + } + + // Leaf dimensions (procedural leaf - must match createDoorLeafMesh) + const float doorWidth = 2.8f * (grid.cellSize / 4.0f); + const float doorHeight = 3.0f * (grid.cellHeight / 4.0f); + float leafWidth = doorWidth - 2.0f * 0.1f - 0.04f; + float leafHeight = doorHeight - 0.05f; + float leafThickness = 0.08f; + + const float halfCell = grid.cellSize / 2.0f; + const float frameOffset = 0.1f; + + // One door per unique doorway: the two cells sharing a door edge + // both carry a door flag, so dedupe on a canonical edge key. + std::set placedDoors; + + // 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); + if (!placedDoors.insert(key).second) + return; // doorway already has a door + + // Same placement as the door frame for this side + // (see placeDoorFramesInStaticGeometry) + Ogre::Vector3 origin = + grid.cellToWorld(cell.x, cell.y, cell.z); + float yBase = internal ? 0.1f : 0.0f; + Ogre::Vector3 pos; + Ogre::Quaternion rot; + switch (side) { + case 2: // X- + pos = origin + + Ogre::Vector3(internal ? -halfCell + frameOffset : + -halfCell - frameOffset, + yBase, 0); + rot = Ogre::Quaternion(Ogre::Degree(-90), + Ogre::Vector3::UNIT_Y); + break; + case 3: // X+ + pos = origin + + Ogre::Vector3(internal ? halfCell - frameOffset : + halfCell + frameOffset, + yBase, 0); + rot = Ogre::Quaternion(Ogre::Degree(90), + Ogre::Vector3::UNIT_Y); + break; + case 0: // Z- + pos = origin + + Ogre::Vector3(0, yBase, + internal ? -halfCell + frameOffset : + -halfCell - frameOffset); + rot = Ogre::Quaternion(Ogre::Degree(180), + Ogre::Vector3::UNIT_Y); + break; + default: // Z+ + pos = origin + + Ogre::Vector3(0, yBase, + internal ? halfCell - frameOffset : + halfCell + frameOffset); + rot = Ogre::Quaternion(Ogre::Degree(0), + Ogre::Vector3::UNIT_Y); + 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())); + 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); + }; + + for (const auto &cell : grid.cells) { + if (cell.hasFlag(CellFlags::DoorXNeg)) + tryPlaceDoor(cell, 2, false); + if (cell.hasFlag(CellFlags::DoorXPos)) + tryPlaceDoor(cell, 3, false); + if (cell.hasFlag(CellFlags::DoorZNeg)) + tryPlaceDoor(cell, 0, false); + if (cell.hasFlag(CellFlags::DoorZPos)) + tryPlaceDoor(cell, 1, false); + if (cell.hasFlag(CellFlags::IntDoorXNeg)) + tryPlaceDoor(cell, 2, true); + if (cell.hasFlag(CellFlags::IntDoorXPos)) + tryPlaceDoor(cell, 3, true); + if (cell.hasFlag(CellFlags::IntDoorZNeg)) + tryPlaceDoor(cell, 0, true); + if (cell.hasFlag(CellFlags::IntDoorZPos)) + tryPlaceDoor(cell, 1, true); + } + + if (!meshData.doorEntities.empty()) { + Ogre::LogManager::getSingleton().logMessage( + "CellGrid: Created " + + std::to_string(meshData.doorEntities.size()) + + " door entities for entity " + + std::to_string(entity.id())); + } +} + +void CellGridSystem::destroyDoorEntities(flecs::entity entity) +{ + auto it = m_entityMeshes.find(entity.id()); + if (it == m_entityMeshes.end()) + return; + + for (auto doorEntity : it->second.doorEntities) { + if (!doorEntity.is_valid() || !doorEntity.is_alive()) + continue; + + // Remove the physics body before the entity dies + if (doorEntity.has() && + m_physicsSystem) { + auto &rb = doorEntity.get_mut(); + if (rb.bodyCreated) + m_physicsSystem->removeRigidBody(rb); + } + + // Destroy the leaf renderable and the hinge node (the leaf + // child node dies with it) + if (doorEntity.has()) { + auto &renderable = + doorEntity.get_mut(); + if (renderable.entity) { + try { + m_sceneMgr->destroyEntity( + renderable.entity); + } catch (...) { + } + renderable.entity = nullptr; + } + } + if (doorEntity.has()) { + auto &t = doorEntity.get_mut(); + if (t.node) { + try { + m_sceneMgr->destroySceneNode(t.node); + } catch (...) { + } + t.node = nullptr; + } + } + doorEntity.destruct(); + } + it->second.doorEntities.clear(); + + // Remove the procedural leaf mesh (custom meshes are shared + // resources and are not removed) + if (!it->second.doorLeafMesh.empty()) { + try { + Ogre::MeshManager::getSingleton().remove( + it->second.doorLeafMesh); + } catch (...) { + } + it->second.doorLeafMesh.clear(); + } +} diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index 1d54b31..1499281 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -27,6 +27,13 @@ public: // Force rebuild of a specific cell grid void rebuildCellGrid(flecs::entity entity); + // Wire the physics system so door rigid bodies can be removed + // properly when doors are rebuilt (EditorApp calls this once) + void setPhysicsSystem(class EditorPhysicsSystem *physics) + { + m_physicsSystem = physics; + } + // Create/Update town material void updateTownMaterial(flecs::entity townEntity); @@ -75,6 +82,7 @@ public: private: flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; + class EditorPhysicsSystem *m_physicsSystem = nullptr; flecs::query m_cellGridQuery; flecs::query m_townQuery; @@ -163,6 +171,17 @@ private: const Ogre::Vector3 &parentScale, Ogre::StaticGeometry *staticGeom, int &frameCount); + // Door leaf entities (one per unique doorway, swing open/closed) + void buildDoorEntities(flecs::entity entity, + const struct CellGridComponent &grid, + const std::string &materialName, + flecs::entity materialEntity); + void createDoorLeafMesh(const struct CellGridComponent &grid, + const std::string &materialName, + const std::string &meshPrefix, + flecs::entity materialEntity); + void destroyDoorEntities(flecs::entity entity); + // Convert triangle buffer to mesh Ogre::MeshPtr convertToMesh(const std::string &name, Procedural::TriangleBuffer &tb, @@ -241,6 +260,10 @@ private: std::vector furniturePlacements; std::vector isolatedFurnitureEntities; flecs::entity physicsParentEntity = flecs::entity::null(); + + // Door leaf entities (runtime, one per unique doorway) + 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/DoorSystem.cpp b/src/features/editScene/systems/DoorSystem.cpp new file mode 100644 index 0000000..83ec820 --- /dev/null +++ b/src/features/editScene/systems/DoorSystem.cpp @@ -0,0 +1,60 @@ +#include "DoorSystem.hpp" +#include "../components/Door.hpp" +#include "../components/Transform.hpp" +#include "../components/RigidBody.hpp" +#include +#include + +DoorSystem::DoorSystem(flecs::world &world) + : m_world(world) + , m_doorQuery(world.query()) +{ +} + +DoorSystem::~DoorSystem() = default; + +void DoorSystem::update(float deltaTime) +{ + m_doorQuery.each([&](flecs::entity entity, DoorComponent &door, + TransformComponent &transform) { + // Consume toggle requests (from ActuatorSystem or scripts) + if (door.toggleRequested) { + door.toggleRequested = false; + door.isOpen = !door.isOpen; + // Opening: remove the collider immediately + if (door.isOpen && entity.has()) { + auto &rb = entity.get_mut(); + rb.enabled = false; + } + } + + float targetAngle = door.isOpen ? door.openAngle : 0.0f; + if (door.currentAngle == targetAngle) + return; + + // Swing towards the target angle + float step = door.openSpeed * deltaTime; + if (door.currentAngle < targetAngle) { + door.currentAngle = + std::min(door.currentAngle + step, targetAngle); + } else { + door.currentAngle = + std::max(door.currentAngle - step, targetAngle); + } + + if (transform.node) { + transform.node->setOrientation( + door.closedOrientation * + Ogre::Quaternion( + Ogre::Degree(door.currentAngle), + Ogre::Vector3::UNIT_Y)); + } + + // Fully closed again: restore the collider in the closed pose + if (!door.isOpen && door.currentAngle == 0.0f && + entity.has()) { + auto &rb = entity.get_mut(); + rb.enabled = true; + } + }); +} diff --git a/src/features/editScene/systems/DoorSystem.hpp b/src/features/editScene/systems/DoorSystem.hpp new file mode 100644 index 0000000..4dffac5 --- /dev/null +++ b/src/features/editScene/systems/DoorSystem.hpp @@ -0,0 +1,30 @@ +#ifndef EDITSCENE_DOOR_SYSTEM_HPP +#define EDITSCENE_DOOR_SYSTEM_HPP +#pragma once + +#include + +/** + * Door system - animates CellGrid door entities. + * + * Doors are runtime entities spawned by CellGridSystem (one per unique + * doorway). This system consumes DoorComponent::toggleRequested (set by + * ActuatorSystem on player interaction), swings the hinge node around + * 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). + */ +class DoorSystem { +public: + explicit DoorSystem(flecs::world &world); + ~DoorSystem(); + + void update(float deltaTime); + +private: + flecs::world &m_world; + flecs::query + m_doorQuery; +}; + +#endif // EDITSCENE_DOOR_SYSTEM_HPP diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 8b68f57..79b7716 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -2594,6 +2594,17 @@ nlohmann::json SceneSerializer::serializeCellGrid(flecs::entity entity) json["roofSideRectName"] = grid.roofSideRectName; json["friction"] = grid.friction; + // Serialize door settings + json["doorsEnabled"] = grid.doorsEnabled; + json["doorRectName"] = grid.doorRectName; + json["doorMeshName"] = grid.doorMeshName; + json["doorUseMeshMaterial"] = grid.doorUseMeshMaterial; + json["doorOpenAngle"] = grid.doorOpenAngle; + json["doorOpenSpeed"] = grid.doorOpenSpeed; + json["doorActionName"] = grid.doorActionName; + json["doorSceneSwitchPath"] = grid.doorSceneSwitchPath; + json["doorSceneSwitchTarget"] = grid.doorSceneSwitchTarget; + // Serialize cells nlohmann::json cellsJson = nlohmann::json::array(); for (const auto &cell : grid.cells) { @@ -2796,6 +2807,17 @@ void SceneSerializer::deserializeCellGrid(flecs::entity entity, grid.roofSideRectName = json.value("roofSideRectName", ""); grid.friction = json.value("friction", 0.5f); + // Deserialize door settings + grid.doorsEnabled = json.value("doorsEnabled", true); + grid.doorRectName = json.value("doorRectName", ""); + grid.doorMeshName = json.value("doorMeshName", ""); + grid.doorUseMeshMaterial = json.value("doorUseMeshMaterial", false); + grid.doorOpenAngle = json.value("doorOpenAngle", 100.0f); + grid.doorOpenSpeed = json.value("doorOpenSpeed", 180.0f); + grid.doorActionName = json.value("doorActionName", ""); + grid.doorSceneSwitchPath = json.value("doorSceneSwitchPath", ""); + grid.doorSceneSwitchTarget = json.value("doorSceneSwitchTarget", ""); + // Deserialize cells if (json.contains("cells") && json["cells"].is_array()) { for (const auto &cellJson : json["cells"]) { diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index 8455ad4..1049cec 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -1058,7 +1058,16 @@ static int testCellGridComponent(lua_State *L) " height = 5," " depth = 10," " cellSize = 1.0," - " cellHeight = 0.5" + " cellHeight = 0.5," + " doorsEnabled = false," + " doorRectName = 'door'," + " doorMeshName = 'door.mesh'," + " doorUseMeshMaterial = true," + " doorOpenAngle = 90.0," + " doorOpenSpeed = 120.0," + " doorActionName = 'knock'," + " doorSceneSwitchPath = 'outside.json'," + " doorSceneSwitchTarget = 'arrival'" "});" "local c = ecs.get_component(id, 'CellGrid');" "assert(c ~= nil, 'CellGrid should exist');" @@ -1066,7 +1075,23 @@ static int testCellGridComponent(lua_State *L) "assert(c.height == 5, 'wrong height');" "assert(c.depth == 10, 'wrong depth');" "assert(c.cellSize == 1.0, 'wrong cellSize');" - "assert(c.cellHeight == 0.5, 'wrong cellHeight')"); + "assert(c.cellHeight == 0.5, 'wrong cellHeight');" + "assert(c.doorsEnabled == false, 'wrong doorsEnabled');" + "assert(c.doorRectName == 'door', 'wrong doorRectName');" + "assert(c.doorMeshName == 'door.mesh', " + "'wrong doorMeshName');" + "assert(c.doorUseMeshMaterial == true, " + "'wrong doorUseMeshMaterial');" + "assert(c.doorOpenAngle == 90.0, " + "'wrong doorOpenAngle');" + "assert(c.doorOpenSpeed == 120.0, " + "'wrong doorOpenSpeed');" + "assert(c.doorActionName == 'knock', " + "'wrong doorActionName');" + "assert(c.doorSceneSwitchPath == 'outside.json', " + "'wrong doorSceneSwitchPath');" + "assert(c.doorSceneSwitchTarget == 'arrival', " + "'wrong doorSceneSwitchTarget')"); if (!ok) FAIL("CellGrid component assertion failed"); diff --git a/src/features/editScene/ui/CellGridEditor.cpp b/src/features/editScene/ui/CellGridEditor.cpp index 68c82f2..25b2415 100644 --- a/src/features/editScene/ui/CellGridEditor.cpp +++ b/src/features/editScene/ui/CellGridEditor.cpp @@ -4,6 +4,7 @@ #include "../components/ProceduralTexture.hpp" #include "../systems/FurnitureLibrary.hpp" #include +#include bool CellGridEditor::renderComponent(flecs::entity entity, CellGridComponent& grid) @@ -55,6 +56,61 @@ bool CellGridEditor::renderComponent(flecs::entity entity, CellGridComponent& gr renderTextureRectEditor(entity, grid); } + // 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(); + } + } + // Script editor if (ImGui::CollapsingHeader("Generation Script")) { renderScriptEditor(grid); @@ -422,6 +478,9 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo if (renderRectCombo("Roof Side", grid.roofSideRectName)) { grid.markDirty(); } + if (renderRectCombo("Door Leaf", grid.doorRectName)) { + grid.markDirty(); + } ImGui::Unindent(); }