Doors implementation

This commit is contained in:
2026-09-07 11:10:36 +03:00
parent b18c213ee8
commit dd7d8f6b39
23 changed files with 1651 additions and 235 deletions
+51 -6
View File
@@ -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:
+3
View File
@@ -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
+11
View File
@@ -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<CellGridSystem>(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<DoorSystem>(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);
+2
View File
@@ -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<PathFollowingSystem> m_pathFollowingSystem;
std::unique_ptr<GoapPlannerSystem> m_goapPlannerSystem;
std::unique_ptr<ActuatorSystem> m_actuatorSystem;
std::unique_ptr<DoorSystem> m_doorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
std::unique_ptr<CharacterClassSystem> m_characterClassSystem;
@@ -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;
@@ -0,0 +1,49 @@
#ifndef EDITSCENE_DOOR_HPP
#define EDITSCENE_DOOR_HPP
#pragma once
#include <Ogre.h>
/**
* 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
@@ -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 <build>/src/features/editScene/demos/demo-scene-switching-extra
@@ -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
@@ -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": {
@@ -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"
}
@@ -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...),
@@ -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 <build>/src/features/editScene/demos/demo-scene-switching
@@ -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
@@ -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...),
@@ -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 ---
@@ -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<DoorComponent>()) {
const auto &door = e.get<DoorComponent>();
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<ActuatorComponent>()) {
auto &actuator =
targetEntity.get<ActuatorComponent>();
if (actuator.actionNames.size() == 1 &&
!actuator.actionNames[0].empty()) {
if (targetEntity.has<DoorComponent>()) {
// Doors toggle open/close on E
const auto &door =
targetEntity.get<DoorComponent>();
// 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<ActuatorComponent>();
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<ItemComponent>()) {
// Show "E - Pick up [ItemName]" for items
@@ -487,7 +507,36 @@ void ActuatorSystem::update(float deltaTime)
auto &actuator = targetEntity.get<ActuatorComponent>();
m_eHoldTime += deltaTime;
if (actuator.actionNames.size() == 1 &&
if (targetEntity.has<DoorComponent>()) {
// 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<DoorComponent>();
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,
@@ -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 <OgreStaticGeometry.h>
#include "../components/ProceduralMaterial.hpp"
#include "../components/ProceduralTexture.hpp"
@@ -29,6 +33,7 @@
#include <ProceduralConeGenerator.h>
#include <ProceduralLathe.h>
#include <ProceduralShape.h>
#include <set>
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<TransformComponent>())
return;
auto &transform = entity.get<TransformComponent>();
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<std::string> 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<TransformComponent>(
{hingeNode, hingePos, rot, Ogre::Vector3::UNIT_SCALE});
RenderableComponent renderable;
renderable.entity = leafEnt;
renderable.meshName = leafMeshName;
doorEntity.set<RenderableComponent>(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<DoorComponent>(door);
RigidBodyComponent rb;
rb.bodyType = RigidBodyComponent::BodyType::Static;
rb.friction = grid.friction;
doorEntity.set<RigidBodyComponent>(rb);
ActuatorComponent actuator;
actuator.radius = 1.5f;
actuator.height = 1.8f;
if (!grid.doorActionName.empty())
actuator.actionNames.push_back(grid.doorActionName);
doorEntity.set<ActuatorComponent>(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<TransformComponent>(
{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<PhysicsColliderComponent>(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<RigidBodyComponent>() &&
m_physicsSystem) {
auto &rb = doorEntity.get_mut<RigidBodyComponent>();
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<RenderableComponent>()) {
auto &renderable =
doorEntity.get_mut<RenderableComponent>();
if (renderable.entity) {
try {
m_sceneMgr->destroyEntity(
renderable.entity);
} catch (...) {
}
renderable.entity = nullptr;
}
}
if (doorEntity.has<TransformComponent>()) {
auto &t = doorEntity.get_mut<TransformComponent>();
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();
}
}
@@ -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<struct CellGridComponent> m_cellGridQuery;
flecs::query<struct TownComponent> 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<ColliderPlacement> furniturePlacements;
std::vector<flecs::entity> isolatedFurnitureEntities;
flecs::entity physicsParentEntity = flecs::entity::null();
// Door leaf entities (runtime, one per unique doorway)
std::vector<flecs::entity> doorEntities;
std::string doorLeafMesh; // procedural leaf mesh (custom mesh not owned)
};
std::unordered_map<uint64_t, MeshData> m_entityMeshes;
@@ -0,0 +1,60 @@
#include "DoorSystem.hpp"
#include "../components/Door.hpp"
#include "../components/Transform.hpp"
#include "../components/RigidBody.hpp"
#include <OgreSceneNode.h>
#include <algorithm>
DoorSystem::DoorSystem(flecs::world &world)
: m_world(world)
, m_doorQuery(world.query<DoorComponent, TransformComponent>())
{
}
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<RigidBodyComponent>()) {
auto &rb = entity.get_mut<RigidBodyComponent>();
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<RigidBodyComponent>()) {
auto &rb = entity.get_mut<RigidBodyComponent>();
rb.enabled = true;
}
});
}
@@ -0,0 +1,30 @@
#ifndef EDITSCENE_DOOR_SYSTEM_HPP
#define EDITSCENE_DOOR_SYSTEM_HPP
#pragma once
#include <flecs.h>
/**
* 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<struct DoorComponent, struct TransformComponent>
m_doorQuery;
};
#endif // EDITSCENE_DOOR_SYSTEM_HPP
@@ -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"]) {
@@ -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");
@@ -4,6 +4,7 @@
#include "../components/ProceduralTexture.hpp"
#include "../systems/FurnitureLibrary.hpp"
#include <OgreStringConverter.h>
#include <cstring>
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();
}