Compare commits

...

17 Commits

Author SHA1 Message Date
slapin d300a16333 Sokoban demo is complete 2026-09-15 12:52:53 +03:00
slapin dd1835b4a3 Sokoban demo: phase 1 complete 2026-09-12 05:31:07 +03:00
slapin 7a388d2a6f Fixed light bleeding throw scene switching doors 2026-09-11 21:53:36 +03:00
slapin 7aa7682e97 Fixed black tunnels 2026-09-11 18:39:02 +03:00
slapin 1159bb5a47 Visual barriers for doors are now black 2026-09-11 16:38:12 +03:00
slapin 867a15d6aa Camera collision fixes 2026-09-11 14:38:57 +03:00
slapin bb65c589f4 Sky and water are fixed for demo 2026-09-10 22:16:33 +03:00
slapin 1572066df3 Interior/exterior implemented in the demo 2026-09-08 13:50:11 +03:00
slapin dd7d8f6b39 Doors implementation 2026-09-07 11:10:36 +03:00
slapin b18c213ee8 Added more demos 2026-09-06 03:19:34 +03:00
slapin 58deefa0a0 Demos for scee script and player controller 2026-09-05 13:32:15 +03:00
slapin 4670aa7af9 Scene Script added. Hide verbose error message. 2026-09-04 23:45:08 +03:00
slapin b062d8331d Terrain improvement 2026-09-04 21:52:47 +03:00
slapin 4562db481a Terrain improvements 2026-08-21 00:46:49 +03:00
slapin 5ea7a6bde8 Fixed normals for road geometry 2026-08-15 08:29:22 +03:00
slapin c38ac8eff3 Component workflow 2026-08-15 06:52:14 +03:00
slapin 2d60b20cca Added road geometry overlap test 2026-08-10 07:28:08 +03:00
231 changed files with 52418 additions and 1835 deletions
File diff suppressed because it is too large Load Diff
+225
View File
@@ -16,14 +16,20 @@ set(EDITSCENE_SOURCES
main.cpp
EditorApp.cpp
GameMode.cpp
ProjectConfig.cpp
systems/EditorUISystem.cpp
systems/SceneSerializer.cpp
systems/PhysicsSystem.cpp
systems/VehicleSystem.cpp
systems/VehicleControllerSystem.cpp
systems/BuoyancySystem.cpp
systems/EditorSunSystem.cpp
systems/EditorSkyboxSystem.cpp
systems/EditorWaterPlaneSystem.cpp
systems/TerrainSystem.cpp
systems/RenderOriginSystem.cpp
systems/SpawnerRegionStore.cpp
systems/RoadRegionStore.cpp
systems/RoadSystem.cpp
systems/LightSystem.cpp
systems/CameraSystem.cpp
@@ -33,6 +39,11 @@ set(EDITSCENE_SOURCES
systems/ProceduralMaterialSystem.cpp
systems/ProceduralMeshSystem.cpp
systems/CellGridSystem.cpp
systems/DoorSystem.cpp
systems/DoorBuilder.cpp
systems/StandaloneDoorSystem.cpp
components/StandaloneDoorModule.cpp
ui/StandaloneDoorEditor.cpp
systems/NormalDebugSystem.cpp
systems/RoomLayoutSystem.cpp
systems/FurnitureLibrary.cpp
@@ -42,11 +53,13 @@ set(EDITSCENE_SOURCES
systems/AnimationTreeRegistry.cpp
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
systems/GlobalStateStore.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
systems/CharacterSlotSystem.cpp
systems/CharacterSpawnerSystem.cpp
systems/TerrainPrefabSpawnerSystem.cpp
systems/OgreEntityHack.cpp
systems/CharacterRegistry.cpp
systems/MarkovNameGenerator.cpp
@@ -74,10 +87,19 @@ set(EDITSCENE_SOURCES
systems/ActuatorSystem.cpp
ui/ActuatorEditor.cpp
components/ActuatorModule.cpp
ui/PushableEditor.cpp
components/PushableModule.cpp
ui/TargetZoneEditor.cpp
components/TargetZoneModule.cpp
systems/ZoneSystem.cpp
systems/GameHudSystem.cpp
systems/EventBus.cpp
systems/EventHandlerSystem.cpp
ui/EventHandlerEditor.cpp
components/EventHandlerModule.cpp
systems/SceneScriptSystem.cpp
ui/SceneScriptEditor.cpp
components/SceneScriptModule.cpp
systems/PrefabSystem.cpp
ui/PrefabInstanceEditor.cpp
@@ -91,6 +113,7 @@ set(EDITSCENE_SOURCES
ui/RenderableEditor.cpp
ui/PhysicsColliderEditor.cpp
ui/RigidBodyEditor.cpp
ui/VehicleEditor.cpp
ui/LightEditor.cpp
ui/CameraEditor.cpp
ui/LodEditor.cpp
@@ -103,6 +126,7 @@ set(EDITSCENE_SOURCES
ui/TriangleBufferEditor.cpp
ui/CharacterSlotsEditor.cpp
ui/CharacterSpawnerEditor.cpp
ui/TerrainPrefabSpawnerEditor.cpp
ui/CharacterIdentityEditor.cpp
ui/AnimationTreeEditor.cpp
ui/AnimationTreeNodeEditor.cpp
@@ -147,6 +171,7 @@ set(EDITSCENE_SOURCES
components/TriangleBufferModule.cpp
components/CharacterSlotsModule.cpp
components/CharacterSpawnerModule.cpp
components/TerrainPrefabSpawnerModule.cpp
components/AnimationTreeModule.cpp
components/AnimationTree.cpp
components/CharacterModule.cpp
@@ -168,6 +193,13 @@ set(EDITSCENE_SOURCES
components/TerrainModule.cpp
components/SunModule.cpp
components/SkyboxModule.cpp
components/TransformModule.cpp
components/RenderableModule.cpp
components/RigidBodyModule.cpp
components/VehicleModule.cpp
components/PhysicsColliderModule.cpp
components/PrefabInstanceModule.cpp
components/CharacterIdentityModule.cpp
camera/EditorCamera.cpp
gizmo/Gizmo.cpp
gizmo/Cursor3D.cpp
@@ -184,7 +216,11 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterClassApi.cpp
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
lua/LuaGlobalStateApi.cpp
lua/LuaDoorApi.cpp
lua/LuaHudApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
)
@@ -235,9 +271,13 @@ set(EDITSCENE_HEADERS
systems/AnimationTreeRegistry.hpp
systems/ContainerStateRegistry.hpp
systems/ItemStateRegistry.hpp
systems/GlobalStateStore.hpp
systems/PlayerControllerSystem.hpp
systems/EditorUISystem.hpp
systems/CellGridSystem.hpp
systems/DoorSystem.hpp
systems/DoorBuilder.hpp
systems/StandaloneDoorSystem.hpp
systems/NormalDebugSystem.hpp
systems/RoomLayoutSystem.hpp
systems/FurnitureLibrary.hpp
@@ -268,13 +308,21 @@ set(EDITSCENE_HEADERS
systems/PathFollowingSystem.hpp
systems/GoapPlannerSystem.hpp
components/Actuator.hpp
components/Door.hpp
components/StandaloneDoor.hpp
ui/ActuatorEditor.hpp
ui/StandaloneDoorEditor.hpp
systems/EventBus.hpp
components/EventHandler.hpp
systems/EventHandlerSystem.hpp
ui/EventHandlerEditor.hpp
components/SceneScript.hpp
systems/SceneScriptSystem.hpp
ui/SceneScriptEditor.hpp
components/PrefabInstance.hpp
components/TerrainPrefabSpawner.hpp
ui/PrefabInstanceEditor.hpp
ui/TerrainPrefabSpawnerEditor.hpp
systems/ItemSystem.hpp
components/Item.hpp
@@ -294,6 +342,7 @@ set(EDITSCENE_HEADERS
systems/EditorWaterPlaneSystem.hpp
systems/TerrainSystem.hpp
systems/RoadSystem.hpp
systems/TerrainPrefabSpawnerSystem.hpp
systems/LightSystem.hpp
systems/CameraSystem.hpp
systems/LodSystem.hpp
@@ -367,7 +416,11 @@ set(EDITSCENE_HEADERS
lua/LuaCharacterClassApi.hpp
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
lua/LuaGlobalStateApi.hpp
lua/LuaDoorApi.hpp
lua/LuaHudApi.hpp
lua/LuaTerrainApi.hpp
lua/LuaSceneSwitchApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
@@ -630,6 +683,143 @@ target_include_directories(save_load_lua_test PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# ---------------------------------------------------------------------------
# Test: Scene switch API + "switchScene" behavior tree node (headless)
# ---------------------------------------------------------------------------
# Links the full editScene sources (minus main.cpp) like the demos, but runs
# without OGRE initialization: EditorApp::switchScene() only validates the
# path and queues a deferred request, and the behavior tree node evaluation
# needs no SceneManager. Covers the Lua API (ecs.switch_scene,
# ecs.behavior_tree.create_scene_switch_node) and the BT node through both
# the actuator path (evaluatePlayerAction) and the AI path
# (BehaviorTreeComponent + update()).
set(SCENE_SWITCH_TEST_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM SCENE_SWITCH_TEST_SOURCES main.cpp)
add_executable(scene_switch_test
tests/scene_switch_test.cpp
${SCENE_SWITCH_TEST_SOURCES}
)
add_dependencies(scene_switch_test morph)
target_compile_definitions(scene_switch_test PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(scene_switch_test
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(scene_switch_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Recast/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Detour/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# ---------------------------------------------------------------------------
# Test: CellGrid door identity + per-door config (F0, headless)
# ---------------------------------------------------------------------------
# Links the full editScene sources (minus main.cpp) like scene_switch_test,
# but runs without OGRE initialization: doorway identity is pure grid math
# and the SceneSerializer round trip needs no SceneManager for the CellGrid
# component.
set(CELLGRID_DOOR_TEST_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM CELLGRID_DOOR_TEST_SOURCES main.cpp)
add_executable(cellgrid_door_test
tests/cellgrid_door_test.cpp
${CELLGRID_DOOR_TEST_SOURCES}
)
add_dependencies(cellgrid_door_test morph)
target_compile_definitions(cellgrid_door_test PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(cellgrid_door_test
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(cellgrid_door_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Recast/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/Detour/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
add_test(NAME cellgridDoorTest
COMMAND cellgrid_door_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Test: Global persistent variable storage (F9, headless)
# ---------------------------------------------------------------------------
# Links only the store and its Lua API: no OGRE, no flecs.
add_executable(global_state_test
tests/global_state_test.cpp
systems/GlobalStateStore.cpp
lua/LuaGlobalStateApi.cpp
)
target_link_libraries(global_state_test
lua
nlohmann_json::nlohmann_json
)
target_include_directories(global_state_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
add_test(NAME globalStateTest
COMMAND global_state_test
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR})
# ---------------------------------------------------------------------------
# Road Geometry Library — standalone wedge/segment generation (M5)
# ---------------------------------------------------------------------------
@@ -670,6 +860,24 @@ target_include_directories(RoadGeometryDemo PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
# ---------------------------------------------------------------------------
# Road geometry overlap test — standalone self-intersection regression test
# ---------------------------------------------------------------------------
# Headless (no scene/ECS/render window): builds the demo's A-B-C wedge graph
# in several height configurations and fails if any generated slab has
# coplanar-overlapping or piercing triangle pairs. Optional args
# "Ax Ay Az Bx By Bz Cx Cy Cz" analyse a single custom configuration.
add_executable(road_geometry_overlap_test
tests/road_geometry_overlap_test.cpp
)
target_link_libraries(road_geometry_overlap_test
RoadGeometryLib
)
add_test(NAME roadGeometryOverlapTest
COMMAND road_geometry_overlap_test)
# ---------------------------------------------------------------------------
# Package Archive Library
# ---------------------------------------------------------------------------
@@ -837,5 +1045,22 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/resources"
"${CMAKE_CURRENT_BINARY_DIR}/resources"
# Test fixtures (road side-prefab test, etc.)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/tests/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/tests/prefabs"
# Project runtime prefabs (PrefabSystem resolves "prefabs/..."
# relative to the executable directory)
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/prefabs"
"${CMAKE_CURRENT_BINARY_DIR}/prefabs"
COMMENT "Copying resources to editSceneEditor build directory"
)
# Demos (separate executables reusing the editScene sources)
add_subdirectory(demos/demo-lua-scene-script)
add_subdirectory(demos/demo-character-controller)
add_subdirectory(demos/demo-scene-switching)
add_subdirectory(demos/demo-scene-switching-extra)
add_subdirectory(demos/demo-interior-exterior-dynamics)
add_subdirectory(demos/demo-sokoban)
@@ -0,0 +1,256 @@
# Component Architecture Improvement — Implementation Specification
This document is the working specification for removing the remaining
per-component hardcoding in `src/features/editScene`. It records what has
already been implemented and, step by step, what remains.
## Goal
Adding a new component to the editor should require **one** place to describe
the component (name, group, editor, add/remove, render, serialize, Lua) instead
of editing six or seven files with scattered special-case code.
## Status
| Step | Description | Status |
|------|-------------|--------|
| A | Type-erased editor renderer (`ComponentRenderer`) | Done |
| B | Unified registration (extract hardcoded components into `*Module.cpp`) | Done |
| C | Serialization hooks (drive `SceneSerializer` from the registry) | To do |
| D | Lifecycle hooks (hierarchy badges, delete/duplicate cleanup) | To do |
| E | Unified Lua component table (single source of truth) | To do |
| F | Flecs meta reflection for auto-generated editors/serde (optional) | To do |
---
## What is already done (A + B)
### A — Type-erased renderer
`ui/ComponentRegistry.hpp` now stores a `ComponentRenderer` closure per
component in `ComponentInfo`:
```cpp
struct ComponentInfo {
const char *name;
const char *group;
int order = 0; // lower renders first; Transform = -1
std::unique_ptr<IComponentEditor> editor;
ComponentRenderer renderer; // type-erased render
ComponentAdder adder;
ComponentRemover remover;
ComponentChecker checker;
ComponentModified onModified; // optional post-render side effect
};
```
`registerComponent<T>()` builds `renderer` from the (templated) type, handling
empty/tag components via `std::is_empty_v<T>` so `get_mut<T>()` is never called
on a zero-sized type.
`EditorUISystem::renderComponentList()` is now a generic loop: it collects the
registered components present on the entity, sorts them by `(order, name)`, and
invokes each `renderer`; if the renderer returns `true` it also runs the
optional `onModified` hook.
### B — Unified registration
The six previously hardcoded components were extracted into module files that
use the same `REGISTER_COMPONENT_GROUP` mechanism as every other component:
- `components/TransformModule.cpp`
- `components/RenderableModule.cpp`
- `components/RigidBodyModule.cpp`
- `components/PhysicsColliderModule.cpp`
- `components/PrefabInstanceModule.cpp`
- `components/CharacterIdentityModule.cpp`
`EditorUISystem::registerComponentEditors()` was removed; the constructor now
calls `registerModularComponents()` directly, which invokes
`ComponentRegistration::registerAll()`.
Cross-component post-render side effects were moved into `onModified` hooks
co-located with each component's registration:
- Transform → marks `StaticGeometryMemberComponent` dirty.
- Sun / Skybox / WaterPlane / Room / ClearArea → `markDirty()`.
- NavMesh → syncs `debugDraw` with `NavMeshSystem`.
---
## Suggestion C — Serialization hooks
**Problem.** `systems/SceneSerializer.cpp` (~4366 lines) hardcodes a
`serializeXxx`/`deserializeXxx` method pair per component plus two giant
`if (entity.has<T>())` / `if (json.contains("key"))` dispatch chains in
`serializeEntity()` and `deserializeEntityComponents()`. Adding a component
today still requires edits to the serializer.
**Design.** Extend `ComponentInfo` (or a parallel serde table) with two closures
and drive the serializer from the registry:
```cpp
using ComponentSerialize = std::function<nlohmann::json(flecs::entity)>;
using ComponentDeserialize = std::function<void(flecs::entity, const nlohmann::json&)>;
struct ComponentInfo {
...
ComponentSerialize serialize;
ComponentDeserialize deserialize;
ComponentModified postLoad; // second-pass reference resolution
};
```
The serializer then:
1. In `serializeEntity`, iterate registered components, call `serialize(e)` for
those that have it, and write the result under a per-component JSON key.
2. In `deserializeEntityComponents`, for each key in the entity JSON that
matches a registered component, call `deserialize(e, value)`.
3. Keep the two-pass reference resolution (material references,
procedural-texture references, character spawner) as `postLoad` hooks that
run after all entities are loaded.
**Steps.**
1. Add the serde type aliases + fields to `ComponentRegistry.hpp` (keep the
`nlohmann::json` dependency out of the header if possible; prefer a
`void*`/`std::function` boundary or move the serde table into
`SceneSerializer` so the registry stays UI-only).
2. Migrate simple components first (Transform, Renderable, RigidBody,
Collider, Light, Camera, Lod, etc.) — move their `serialize/deserialize`
bodies from `SceneSerializer.cpp` into their `*Module.cpp` registration.
3. Leave complex components (Town/District/Lot, ProceduralMaterial,
CharacterSpawner, NavMesh) as explicit overrides initially; the registry is
a fallback. Then migrate them one at a time, adding `postLoad` where the
current two-pass logic lives.
4. Remove the now-dead `serializeXxx`/`deserializeXxx` declarations from
`SceneSerializer.hpp` and their dispatch blocks from `.cpp`.
**Acceptance criteria.**
- `serializeEntity` / `deserializeEntityComponents` contain no
`if (entity.has<T>())` component dispatch (or only a tiny, documented set of
explicit overrides for two-pass components).
- Round-trip save/load tests still pass (see `tests/` and
`docs/SaveLoadSystem.md`).
- Adding a new serializable component touches only its `*Module.cpp`.
---
## Suggestion D — Lifecycle hooks
**Problem.** Three UI hot-spots still enumerate components by hand:
1. `EditorUISystem::renderEntityNode()` — ~26 hardcoded hierarchy badge lines
(`indicators += " [T]"`).
2. `EditorUISystem::deleteEntity()` — hardcoded cleanup for RigidBody
(physics body), Transform (scene node), Renderable (Ogre entity).
3. `EditorUISystem::duplicateEntity()` — hardcoded copy logic for name,
transform, renderable.
**Design.** Add nullable hooks to `ComponentInfo` (following the `onModified`
pattern already established):
```cpp
struct ComponentInfo {
...
std::function<std::string(flecs::entity)> badge; // hierarchy indicator
std::function<void(flecs::entity)> onDelete; // cleanup on delete
std::function<void(flecs::entity, flecs::entity)> onDuplicate; // copy
};
```
**Steps.**
1. Add the fields + optional parameters to `registerComponent<T>()` (or a
post-registration setter to avoid growing the signature further).
2. Move the badge strings into each `*Module.cpp` (Transform `[T]`, Renderable
`[R]`, Light `[L]`, …). `renderEntityNode` then appends
`info.badge(entity)` for each component the entity has.
3. Move `deleteEntity` cleanup into `onDelete` hooks (RigidBody removes its
physics body, Transform destroys its scene node, Renderable destroys its
Ogre entity). `deleteEntity` becomes a generic "invoke onDelete for every
registered component present, then destruct".
4. Move `duplicateEntity` copy logic into `onDuplicate` hooks, or — for most
components — a generic `entity.set<T>(other.get<T>())` copy that falls back
to a hook for components with non-trivial ownership.
**Acceptance criteria.**
- `renderEntityNode`, `deleteEntity`, `duplicateEntity` contain no
`if (entity.has<T>())` component dispatch.
- Deleting/duplicating an entity with a renderable + rigid body + transform
behaves identically to before.
---
## Suggestion E — Unified Lua component table
**Problem.** `lua/LuaComponentApi.cpp` maintains its own `s_components` map and
its own `REGISTER_COMPONENT` macro with hand-written push/read field lambdas —
a third independent list of components.
**Design.** Make one shared descriptor the single source of truth. The editor
registry, the serializer (C) and the Lua binder (E) all read name/group/fields
from it.
**Steps.**
1. Introduce a `ComponentTraits<T>` (or `ComponentDescriptor`) struct in a
neutral header (not UI-, not Lua-specific) holding at least `name`, `group`,
and optional `luaPush` / `luaRead` closures (or field-table reflection).
2. Have the `REGISTER_COMPONENT_GROUP` macro (or a new unified macro) populate
both the `ComponentRegistry` entry and the Lua `s_components` entry from the
same traits.
3. Migrate the existing hand-written `REGISTER_COMPONENT(...)` entries in
`LuaComponentApi.cpp` to the shared traits one component at a time, keeping
`lua-examples/` and `tests/*_lua_test.cpp` green.
**Acceptance criteria.**
- There is exactly one place that lists a component's name/group/serde/Lua
behaviour.
- `component_lua_test` and the other `*_lua_test` executables still pass.
---
## Suggestion F — Flecs meta reflection (optional, long term)
**Problem.** Even after C/D/E, "dumb data" components still need a hand-written
editor + serializer + Lua binding.
**Design.** Register plain-data components with Flecs meta (`flecs::meta`) and
generate the ImGui editor, JSON serde and Lua accessor from the reflected
fields. Keep custom editors only for behaviour-heavy components (Camera
preview, NavMesh, CharacterSlots, etc.).
**Steps.**
1. Add `flecs::meta` to the project and register a pilot component with it.
2. Write a generic `MetaComponentEditor<T>` that walks `flecs::meta` cursor
fields and renders an ImGui widget per field.
3. Write a generic meta-based serializer (or use the built-in `flecs::json`
exporter) and a meta-based Lua binding.
4. Migrate the simplest components; validate against existing round-trip tests.
**Acceptance criteria.**
- A new plain-data component can be added without writing an editor, a
serializer, or a Lua binding.
- OGRE math types (`Ogre::Vector3`, `Quaternion`, `ColourValue`) have registered
meta type handlers so they render and serialize correctly.
---
## Conventions and caveats
- Keep `order` on `ComponentInfo` for deterministic render order; only Transform
needs a non-zero value today. Do not rely on `unordered_map` iteration order.
- Empty/tag components must render via a stack-local dummy (handled by
`std::is_empty_v` in the renderer). Do not call `get_mut<T>()` on them.
- Two-pass scene load (material/character/procedural-texture references) must
stay explicit; fold it into a `postLoad` hook, never into a single pass.
- The source code is the source of truth. Update `AGENTS.md` ("Adding a New
Component"), `docs/`, and `lua-examples/` whenever the registration API
changes.
File diff suppressed because it is too large Load Diff
+194 -1
View File
@@ -10,9 +10,13 @@
#include <flecs.h>
#include <memory>
#include "lua/LuaState.hpp"
#include "ProjectConfig.hpp"
// Forward declarations
class EditorUISystem;
class VehicleSystem;
class ZoneSystem;
class GameHudSystem;
class EditorCamera;
class EditorPhysicsSystem;
class EditorLightSystem;
@@ -24,6 +28,7 @@ class ProceduralMaterialSystem;
class ProceduralMeshSystem;
class CharacterSlotSystem;
class CharacterSpawnerSystem;
class TerrainPrefabSpawnerSystem;
class AnimationTreeSystem;
class HairPhysicsSystem;
class BehaviorTreeSystem;
@@ -35,17 +40,21 @@ class StartupMenuSystem;
class PauseMenuSystem;
class DialogueSystem;
class PlayerControllerSystem;
class VehicleControllerSystem;
class BuoyancySystem;
class EditorSunSystem;
class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class TerrainSystem;
class RenderOriginSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
class GoapPlannerSystem;
class ActuatorSystem;
class DoorSystem;
class StandaloneDoorSystem;
class EventHandlerSystem;
class ItemSystem;
class CharacterClassSystem;
@@ -64,9 +73,13 @@ struct GameInputState {
bool e = false;
bool f = false;
bool i = false;
bool r = false;
bool space = false;
bool ePressed = false;
bool fPressed = false;
bool iPressed = false;
bool rPressed = false;
bool spacePressed = false;
float mouseDeltaX = 0.0f;
float mouseDeltaY = 0.0f;
bool mouseMoved = false;
@@ -79,6 +92,8 @@ struct GameInputState {
ePressed = false;
fPressed = false;
iPressed = false;
rPressed = false;
spacePressed = false;
}
void clearMovementInput()
@@ -122,6 +137,25 @@ private:
int m_lastBatchCount = 0;
};
/**
* Options for EditorApp::switchScene(). Positions are absolute
* world-space doubles (see systems/RenderOriginSystem).
*/
struct SceneSwitchOptions {
/* Explicit destination for the player character (world space). */
bool hasPosition = false;
double posX = 0.0;
double posY = 0.0;
double posZ = 0.0;
bool hasRotation = false;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
/* Name of an entity with a TransformComponent in the NEW scene.
* When set, the player character is teleported to that entity's
* transform (takes precedence over position/rotation). */
Ogre::String targetEntityName;
};
/**
* Main application class for the scene editor / game
*/
@@ -131,7 +165,7 @@ public:
enum class GameMode { Editor, Game };
enum class GamePlayState { Menu, Playing, Paused };
EditorApp();
EditorApp(const Ogre::String &appName = "EditSceneEditor");
virtual ~EditorApp();
// OgreBites::ApplicationContext overrides
@@ -146,10 +180,15 @@ public:
void shutdownEditor();
void closeApp();
/* Pin the per-frame delta (seconds) for deterministic headless
* tests; 0 (default) uses the wall-clock delta. */
void setFixedDeltaTime(float dt) { m_fixedDeltaTime = dt; }
// OgreBites::InputListener overrides
bool mouseMoved(const OgreBites::MouseMotionEvent &evt) override;
bool mousePressed(const OgreBites::MouseButtonEvent &evt) override;
bool mouseReleased(const OgreBites::MouseButtonEvent &evt) override;
bool mouseWheelRolled(const OgreBites::MouseWheelEvent &evt) override;
bool keyPressed(const OgreBites::KeyboardEvent &evt) override;
bool keyReleased(const OgreBites::KeyboardEvent &evt) override;
@@ -190,10 +229,61 @@ public:
void startNewGame(const Ogre::String &scenePath);
void clearScene();
/**
* Queue a switch to another scene, executed at the start of the
* next frame. In editor mode the current scene is completely
* destroyed and the new one loaded as if the editor was restarted.
* In game mode the player controller and camera are carried over
* when the new scene has none of its own; otherwise the new
* scene's controller/camera take over. opts can teleport the
* player character to a world-space position/rotation or to a
* named entity with a TransformComponent in the new scene.
* Returns false only for obvious errors (empty/unreadable path);
* load errors are reported via the log.
*/
bool switchScene(const Ogre::String &scenePath,
const SceneSwitchOptions &opts = SceneSwitchOptions{});
/* Introspection/cancel for a queued scene switch (tests, debug). */
bool hasPendingSceneSwitch() const
{
return m_sceneSwitchPending;
}
const Ogre::String &getPendingSceneSwitchPath() const
{
return m_pendingSceneSwitchPath;
}
const SceneSwitchOptions &getPendingSceneSwitchOptions() const
{
return m_pendingSceneSwitchOptions;
}
void clearPendingSceneSwitch()
{
m_sceneSwitchPending = false;
}
// Save / Load
void saveGame(const std::string &slotPath, const std::string &slotName);
void loadGame(const std::string &slotPath);
// Project directory (F8)
void setProjectConfig(const ProjectConfig &config);
const ProjectConfig &getProjectConfig() const
{
return m_projectConfig;
}
const std::string &getProjectRoot() const
{
return m_projectConfig.rootDir;
}
/**
* Open a project directory at runtime (editor File -> Open
* Project...): chdir()s into it, loads its project.json, switches
* the save directory to the project's appName and clears the
* current scene. Returns false when the directory does not exist.
*/
bool openProject(const std::string &dir);
// Input access
GameInputState &getGameInputState()
{
@@ -223,6 +313,10 @@ public:
{
return m_camera.get();
}
RenderOriginSystem *getRenderOriginSystem() const
{
return m_renderOriginSystem.get();
}
AnimationTreeSystem *getAnimationTreeSystem() const
{
return m_animationTreeSystem.get();
@@ -235,6 +329,43 @@ public:
{
return m_characterSpawnerSystem.get();
}
PlayerControllerSystem *getPlayerControllerSystem() const
{
return m_playerControllerSystem.get();
}
CharacterSystem *getCharacterSystem() const
{
return m_characterSystem.get();
}
VehicleControllerSystem *getVehicleControllerSystem() const
{
return m_vehicleControllerSystem.get();
}
VehicleSystem *getVehicleSystem() const
{
return m_vehicleSystem.get();
}
ZoneSystem *getZoneSystem() const
{
return m_zoneSystem.get();
}
GameHudSystem *getGameHudSystem() const
{
return m_gameHudSystem.get();
}
/* Shared Lua state (tests/tools; scripts access it via the ecs API). */
editScene::LuaState *getLuaState()
{
return &m_lua;
}
EditorPhysicsSystem *getEditorPhysicsSystem() const
{
return m_physicsSystem.get();
}
TerrainPrefabSpawnerSystem *getTerrainPrefabSpawnerSystem() const
{
return m_terrainPrefabSpawnerSystem.get();
}
ProceduralMeshSystem *getProceduralMeshSystem() const
{
return m_proceduralMeshSystem.get();
@@ -249,6 +380,11 @@ public:
{
return m_actuatorSystem.get();
}
/* Draws the fullscreen "Loading" cover shown for a few frames
* after a game-mode scene switch (hides T-posing characters and
* half-built content). Called from ImGuiRenderListener. */
void renderSceneSwitchCover();
EventHandlerSystem *getEventHandlerSystem() const
{
return m_eventHandlerSystem.get();
@@ -261,6 +397,18 @@ public:
{
return m_pregnancySystem.get();
}
NavMeshSystem *getNavMeshSystem() const
{
return m_navMeshSystem.get();
}
CellGridSystem *getCellGridSystem() const
{
return m_cellGridSystem.get();
}
DoorSystem *getDoorSystem() const
{
return m_doorSystem.get();
}
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -286,6 +434,7 @@ private:
std::unique_ptr<EditorSkyboxSystem> m_skyboxSystem;
std::unique_ptr<EditorWaterPlaneSystem> m_waterPlaneSystem;
std::unique_ptr<TerrainSystem> m_terrainSystem;
std::unique_ptr<RenderOriginSystem> m_renderOriginSystem;
std::unique_ptr<EditorLightSystem> m_lightSystem;
std::unique_ptr<EditorCameraSystem> m_cameraSystem;
std::unique_ptr<EditorLodSystem> m_lodSystem;
@@ -295,6 +444,7 @@ private:
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<CharacterSpawnerSystem> m_characterSpawnerSystem;
std::unique_ptr<TerrainPrefabSpawnerSystem> m_terrainPrefabSpawnerSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
@@ -308,6 +458,8 @@ 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<StandaloneDoorSystem> m_standaloneDoorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
std::unique_ptr<CharacterClassSystem> m_characterClassSystem;
@@ -316,6 +468,12 @@ private:
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
/* F10 vehicles: Jolt VehicleConstraint per VehicleComponent. */
std::unique_ptr<VehicleSystem> m_vehicleSystem;
std::unique_ptr<ZoneSystem> m_zoneSystem;
std::unique_ptr<GameHudSystem> m_gameHudSystem;
/* F10 enter/exit + drive input for vehicles (game mode). */
std::unique_ptr<VehicleControllerSystem> m_vehicleControllerSystem;
// State
uint16_t m_currentModifiers;
@@ -327,10 +485,45 @@ private:
bool m_debugBuoyancy = false;
bool m_headless = false;
/* Project directory (F8): empty rootDir = plain session in the
* binary directory. */
ProjectConfig m_projectConfig;
void destroyEditorSystems();
float m_playTime = 0.0f;
std::string m_currentBaseScene;
/* Scene switch state. switchScene() only queues a request; the
* actual teardown + load runs at the top of the next
* frameRenderingQueued() so callers (Lua scripts, ImGui menus)
* never mutate the world mid-frame. */
bool m_sceneSwitchPending = false;
Ogre::String m_pendingSceneSwitchPath;
SceneSwitchOptions m_pendingSceneSwitchOptions;
/* Grounding watchdog: after a game-mode scene switch the terrain
* physics colliders may not exist yet; keep the player character
* at or above the terrain surface until it reports a floor. */
flecs::entity_t m_groundingEntity = 0;
int m_groundingFramesLeft = 0;
/* Fullscreen loading cover shown after a game-mode scene switch
* (frames remaining). Hides freshly spawned characters before
* their animation state is applied (T-pose) and any content that
* takes a few frames to build. */
int m_sceneSwitchCoverFrames = 0;
static const int SCENE_SWITCH_COVER_FRAMES = 45;
/* When > 0, frameRenderingQueued uses this fixed delta instead of
* the wall-clock delta (headless test determinism). */
float m_fixedDeltaTime = 0.0f;
void processPendingSceneSwitch();
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
void destroySceneEntities();
void setupPlayerCharacter();
// Lua scripting
editScene::LuaState m_lua;
File diff suppressed because it is too large Load Diff
+172 -16
View File
@@ -19,7 +19,10 @@ The template from `getRoadTemplate(cfg)`:
- **Y**: ∈ [-roadThickness/2, +roadThickness/2]. Maps directly to world vertical offset from the road surface at that position.
- **Z**: ∈ [-1, 0] (fallback box; loaded files may differ but must span exactly 1 unit of distance along the road).
- **UVs**: span (0,0)(1,1) over X/Z extents on each face.
- **Normals**: preserved through rigid rotation during transformation.
- **Normals**: the template axes are mapped explicitly during the bend
(+X → outer-curb direction, +Y → up, -Z → travel), because the wedge
bend is a *reflection* of the template (see §5.7) and therefore cannot
be represented by a single rotation around Y.
If the template file is missing, the fallback is a 6-face unit box (24 verts, 36 indices, X∈[0,1], Y∈[-thick/2,+thick/2], Z∈[-1,0]).
@@ -62,8 +65,8 @@ The single implementation lives in `roadlib/RoadGeometryLib.cpp`
(namespace `RoadGeometryLib`); the public `RoadSystem` statics forward
to it. The transformed wedge strip is already a closed tube (the
template supplies top, bottom and curb faces), so it is appended to
the output verbatim — slab extrusion (§8) applies to straight
segments only. `RoadGeometryLib` also provides
the output with winding reversed (§5.9) — slab extrusion (§8) applies
to straight segments only. `RoadGeometryLib` also provides
`loadTemplateFromMesh()` (template loading per §2) and
`makeFallbackTemplate()`.
@@ -283,12 +286,16 @@ v.uv.x = (d <= L1) ? halfEdgeU(H1, graph, L1 - d)
: halfEdgeU(H2, graph, d - L1)
v.uv.y = v.uv.y * width(d) + in1
// Normal — rotate template-forward (-Z) to segment direction by the
// SIGNED angle around Y:
segDir = (d <= L1) ? dir1 : dir2
theta = atan2(-segDir.x, -segDir.z)
Ogre::Quaternion q(Ogre::Radian(theta), Ogre::Vector3::UNIT_Y);
v.normal = q * v.normal;
// Normal — map the template axes onto the bent world frame explicitly.
// The bend is a REFLECTION of the template: travel is -dir1 on the first
// half-edge and the outer curb runs along +offset(d), which is
// -roadRight(dir2) on the second half-edge. A single rotation around Y
// would invert those axes, so the template axes are mapped one by one:
lateral = normalize(offset(d)) // +X -> outer-curb direction
travel = (d <= L1) ? -dir1 : dir2 // -Z -> travel direction
v.normal = lateral * v.normal.x
+ Vector3(0, v.normal.y, 0)
+ travel * (-v.normal.z);
```
Since Phase 1 guarantees d ∈ [0, L] (we use exactly ceil(L) copies and
@@ -303,14 +310,42 @@ at adjacent distances d and d+ε map to adjacent world positions. **No gap
opens at the outer corner.**
The travel direction `dir(d)` is piecewise (dir1 → dir2 at the node), but
`dir(d)` only affects the normal rotation and UV computation — it does
not affect vertex positions. The cross-section orientation is driven
`dir(d)` only affects the normal mapping (§5.7) and UV computation — it
does not affect vertex positions. The cross-section orientation is driven
entirely by the continuous `offset(d)`.
The centerline has a sharp corner at O, but the centerline edge is the
**inside** of the bend, shared with adjacent wedges. No fill is needed
there.
### 5.9 The Bend Is a Reflection (Normals and Winding)
The template's local frame — X = outer-curb lateral, Y = up, Z =
longitudinal with travel along -Z — is **left-handed** (X × Y = +Z =
-forward), while the world road frame (outer-curb lateral, up, travel)
is **right-handed** (lateral × up = +travel). The position mapping
```
world = center(d) + offset(d) * x + (0, y, 0)
```
is therefore a *reflection* of the template, not a rotation. That has
two consequences, both handled explicitly:
* **Normals** cannot be recovered by a single rotation around Y. The
template-forward -Z does not map to `dir1`/`dir2`: travel is -dir1 on
the first half-edge (midpoint → node) and the outer curb runs along
`offset(d)`, which equals -roadRight(dir2) on the second half-edge.
`transformWedgeVertices` therefore maps the template axes one by one
(§5.7): +X → normalize(offset(d)), +Y → +Y, -Z → travel.
* **Winding** comes out inverted — every triangle's front face flips to
the back. `buildWedgeGeometry` reverses each triangle's index order
before appending the strip (§8.3) so the closed solid is front-facing
outward; otherwise the top surface would be culled by back-face
culling and the underside (with downward normals) would show through —
the "inverted normals" symptom reported when nodes are repositioned.
## 6. Phase 3 — Center Seam Shifting
**Function**: `static void shiftSeamVertices(Procedural::TriangleBuffer &strip, const RoadWedge &wedge, const RoadGraph &graph)`
@@ -437,10 +472,12 @@ Where `refPoint` is the centroid of `centerSurf`.
and curb faces, and `appendTemplateCopy` drops only the template
caps and the centerline wall (the open ends butt exactly against the
neighbouring pieces at the edge midpoints, the open centerline side
against the adjacent wedge). The transformed strip is appended to
the output verbatim. (Re-extruding it additionally stacked coplanar
sheets at the strip's center surface and doubled the slab
thickness.)
against the adjacent wedge). Because the bend is a reflection of the
template, each triangle's winding is reversed before the strip is
appended, so the closed solid is front-facing outward (its top
surface survives back-face culling). (Re-extruding it additionally
stacked coplanar sheets at the strip's center surface and doubled the
slab thickness.)
- **Segment**: the center-surface band (§7) is flat, so it is passed
to `extrudeToSlab`, keeping the far-end edge open (it meets the
neighbour node's piece exactly).
@@ -458,6 +495,8 @@ interior and get no skirts — they meet adjacent road pieces.
| ROAD_SEAM_OVERLAP on segments (§7) | Center gap for dead-end nodes | Segment band |
| Center seam shifting (§6) | Center hole where >2 wedges meet | Phase 3 |
| Slab extrusion (§8) | Road must be a closed solid | Segments |
| Explicit normal mapping (§5.7) | Outer-curb wall normal inverted on the second half-edge | Phase 2 |
| Winding reversal (§5.9, §8.3) | Inside-out wedge (top surface culled; "inverted normals") | buildWedgeGeometry |
## 10. Internal Functions (Testable)
@@ -529,6 +568,19 @@ zone it is `K - center(d)`.
| Single triangle, thick=0.3 | 6 tris (top+bottom+3 skirts), Y∈[-0.15,0.15] |
| Two adjacent triangles | 10 tris (shared edge has no skirt) |
### 11.4 Standalone Overlap Test
`tests/road_geometry_overlap_test.cpp` (target
`road_geometry_overlap_test`, CTest `roadGeometryOverlapTest`) builds
the demo's ABC graph headlessly — flat, corner node raised/lowered,
endpoints raised — and fails when any wedge or segment slab contains
coplanar-overlapping or piercing triangle pairs, when the flat wedge's
slab thickness exceeds roadThickness/2, or when any triangle's stored
vertex normal disagrees with its winding (`dot(geometric, stored) < 0`),
which indicates an inside-out (reflected) face. Optional arguments
`Ax Ay Az Bx By Bz Cx Cy Cz` analyse a single custom configuration
(useful when debugging geometry reported by the demo).
## 12. Migration from Current Implementation
### Kept unchanged
@@ -583,9 +635,21 @@ benefit for the narrow blend zone (W ≈ 0.2 units).
`dir(d)` is piecewise (dir1 for d≤L1, dir2 for d>L1) because the
centerline is a polyline with a sharp corner. This is correct for
road intersections. `dir(d)` only affects normal rotation and UV
road intersections. `dir(d)` only affects the normal mapping and UV
lookup — vertex positions are driven by the continuous `offset(d)`.
### Why map the template axes instead of rotating normals?
The template frame (X = outer-curb lateral, Y = up, -Z = forward) is
left-handed while the bent world frame (lateral, up, travel) is
right-handed, so the bend is a reflection and no rotation around Y can
map the template normals onto the surface. Rotating the normal toward
`dir(d)` inverted the outer-curb wall normal on the second half-edge
(where the curb runs along -roadRight(dir2)) and the travel axis on the
first half-edge (travel = -dir1), producing inside-out faces. Mapping
the axes one by one (§5.7) and reversing the winding (§5.9) restores a
correctly oriented closed solid.
### Template mesh is finally used
The current implementation ignores the template from M5.3. This
@@ -625,3 +689,95 @@ Build and run:
cmake --build <build-dir> --target RoadGeometryDemo
./RoadGeometryDemo
```
## 15. Sidewalks (M5.15)
Sidewalks are an optional elevated pedestrian strip appended to the road
slab along its outer curb. They are pure geometry: lane counts and the
drivable width are unchanged, so nothing derived from the road width
needs adjustment. Enabled per graph via `RoadConfig::sidewalkEnabled`
with `sidewalkWidth` (lateral extent), `sidewalkHeight` (top elevation
above the road surface), `sidewalkThickness` (fallback box thickness)
and `sidewalkMeshTemplate` (empty = procedural box).
### 15.1 Template conventions
The sidewalk template follows the same template-space conventions as the
road template (§2), with one change: the profile Y range is
`[-sidewalkThickness, 0]` — the strip *top* sits at template Y = 0, so
the phase-2 mapping `worldY = roadSurfaceY + sidewalkHeight + vy` lands
the top exactly `sidewalkHeight` above the road surface and the body
hangs below it. `makeSidewalkFallbackTemplate(sidewalkThickness)`
generates the default box profile; `RoadSystem::getSidewalkTemplate()`
mirrors `getRoadTemplate()` and caches the buffer per
`sidewalkMeshTemplate`/`sidewalkThickness` pair.
### 15.2 Wedge strips — widened miter chains
One strip per wedge along its outer curb, built with the same
three-phase pipeline as the road (concatenate → transform → append).
Phase 2 differs: instead of mapping template X across `[0, sideWidth]`
against the single curb offset, each vertex's X is mapped linearly
between two *widened* curb offsets:
```
offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP)
offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth)
worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * x
worldY = roadSurfaceY(d) + sidewalkHeight + vy
```
UVs: `u = halfEdgeU(...)` (phase-continuous, same as the road), `v`
across `[0, sidewalkWidth]`.
`curbOffsetWidened()` is a static worker extracted from
`computeCurbOffset()`: it scales the per-half-edge offset vectors offA /
offB by `(len + widen) / len` *before* the miter math, then runs the
unchanged corner-blend pipeline. The public `computeCurbOffset()` is a
wrapper with `widen = 0`, so road geometry is bit-identical.
**Why not `center + offset + normalize(offset) * x`?** Extending along
the normalized curb offset (the original plan) folds at inner corners:
the road's cross-sections pivot around the pinned miter corner K while
the curb chain transitions between half-edges, so consecutive sidewalk
rows computed from the same rays cross each other near K (coplanar /
piercing triangles and inverted normals — caught by
`road_geometry_overlap_test`). Widening the offset chains gives the
inner and outer sidewalk curbs their own pinned miter corners (K moved
outward by the gap and by gap+width respectively), so each chain keeps
the §5.4 no-fold property independently and the strip between them
cannot self-intersect.
### 15.3 Z-fighting gap
`ROAD_SIDEWALK_WALL_GAP = 0.002f` (RoadGraph.hpp) shifts the whole strip
slightly outward so the sidewalk's inner wall is never coplanar with the
road curb wall. The sidewalk top still overlaps the curb horizontally,
so the gap is invisible from above.
### 15.4 Straight segments
Dead-end segments (§7) get *two* sidewalk bands — one per curb (inbound
and outbound) — each extruded to a slab of `sidewalkThickness` whose top
sits at `roadSurfaceY + sidewalkHeight`.
`computeSegmentSidewalkBand()` computes the four top corners (offset
`width + ROAD_SIDEWALK_WALL_GAP``width + gap + sidewalkWidth` from
the centerline); `buildSegmentSidewalkGeometry()` extrudes both bands
via the shared `extrudeToSlab()`, skipping the far-end skirt so the
bands stay open where a future wedge would connect.
### 15.5 Integration and terrain compliance
Sidewalk triangles are appended to the same page `TriangleBuffer` as the
road, so LOD/visibility distance, the M5.9 collider soup and
`markNavMeshDirty()` need no changes. Sidewalks share the road
material.
`RoadSystem::complyTerrain()` accounts for sidewalks when enabled:
- the perpendicular falloff origin moves outward by `sidewalkWidth`, so
the shoulder fade starts at the sidewalk's outer edge;
- fixups under sidewalk vertices target the sidewalk *underside*
(`top - sidewalkThickness`), so the terrain hugs the strip body
instead of its elevated top.
@@ -0,0 +1,454 @@
# Procedural Road Geometry — Improvement Plan
Procedural road geometry is specified in `ProceduralRoadGeometry.md`; the road
milestones and their status live in `TerrainRequirements.md` (Milestone 5,
M5.1M5.12). This plan covers three improvements:
1. Edge splitting: create a node in the middle of an edge, remove the edge and
create two edges through the new node (AB → ACB).
2. Sidewalks: an elevated extra lane where vehicles do not go, intended for
pedestrians, enabled by checkbox, with selectable template geometry (same
mechanism as the normal lane template) and a procedural-box fallback.
3. Road prefabs: per-edge left/right side prefabs plus one road-midpoint
prefab, with distance-based unload, edit-time preview, easy prefab
selection and leak-free resource handling.
4. It should be somehow possible to select two nodes on the graph and create
new edge.
5. It should be possible to select 3 neighboring nodes on the graph and smooth
their position (position relaxation, no graph mutation — see §5).
Each section first states what already exists (with code references — the
source is the only source of truth) and then defines the remaining work.
---
## 1. Edge splitting (AB → ACB)
**Status (2026-08-18): ✅ DONE** — per-row "Split" `SmallButton` in the
edge list (toolbar button removed), slot remap in `splitEdge`, editor-layer
Y snap of the new node, `testRoadDataModel` extended. Verified by the
terrain test suite (`--headless --run-terrain-tests=1`).
### Current state
The core operation already exists and is exposed in the editor:
- `RoadGraph::splitEdge(edgeIndex, t)` (`components/RoadGraph.hpp:523`) removes
the edge, inserts node C at `lerp(A, B, t)` and pushes two new edges AC and
CB. C is snapped to integer half-edge lengths per M5.7
(`snapToIntegerLength`), and `verticalOffset`/`roadLevel` are interpolated so
the road surface stays continuous.
- UI: "Split Selected Edge" button in the Terrain panel's road-section
toolbar (`ui/TerrainEditor.hpp:733`) — hardcoded `t = 0.5`, i.e. exactly
the requested middle split. The interaction is counter-intuitive: the
toolbar is overloaded, and splitting takes two steps — select the edge in
the edge list, then find the toolbar button.
- Tests: `testRoadDataModel` (`systems/TerrainTests.cpp:1334`) and
`testRoadEdgeLengthConstraint` (`:29863038`).
### Remaining work
- **1.1 Move splitting into the edge list.** The edge list
(`ui/TerrainEditor.hpp:864-891`) renders one `Selectable` row per edge
(with a "Remove" `SmallButton` on the selected row), and the node list
already shows the per-row action pattern with its "Connect"
`SmallButton`s (`:835-860`). Give *every* edge row its own "Split"
`SmallButton` (placed `SameLine` after the row label, like "Connect") that
splits that edge at `t = 0.5` — one click, no prior selection, and the
overloaded toolbar shrinks accordingly: delete the toolbar's "Split
Selected Edge" button (`:733-738`). After the split, select the new node
so the gizmo and node inspector follow it.
- **1.2 Prefab carry-over on split (bug).** `splitEdge` copies the old edge
struct into both new edges, so `sidePrefabs` (and later the §3 slots) are
*duplicated*: both halves spawn the same prefabs. Remap instead: prefab
`edgeT < actualT` stays on edge AC with `edgeT' = edgeT / actualT`;
otherwise it moves to CB with `edgeT' = (edgeT - actualT) / (1 - actualT)`
(`actualT` is the post-snap position already computed in `splitEdge`).
Left/right side semantics are preserved because both halves keep the A→B
orientation. With the §3 slot model: the left/right slots remap their
`edgeT` this way on both halves; the mid slot transfers to the half that
contains the original midpoint, the other half gets an empty slot.
- **1.3 New-node Y snap.** `RoadGraph` is terrain-agnostic, so `splitEdge`
only interpolates Y (see the comment at `RoadGraph.hpp:561`). After a UI
split, snap C's Y to `terrainHeight(x, z) + verticalOffset` in the editor
layer, the same way `addRoadNodeAt` does
(`systems/EditorUISystem.cpp:2303`).
- **1.4 Tests.** Extend `testRoadDataModel`: split an edge carrying prefab
slots and assert remapped `edgeT`/side on both halves and that no prefab is
duplicated; assert road-surface continuity at C (levels from both halves
match).
---
## 2. Sidewalks
**Status (2026-08-18): ✅ DONE** — data model, widened-chain geometry
(see the corrected §2.2 mapping below), template + fallback, terrain
compliance, editor UI and tests; specified in
`ProceduralRoadGeometry.md` §15. Verified by `testRoadWedgeGeometry`
(new sidewalk case), `testRoadSerialization` and
`road_geometry_overlap_test` (sidewalks enabled in all regression
configs).
New feature — nothing existed before (no mentions in code or docs).
### Requirement mapping
"Extra lane where vehicles will not go": sidewalks are pure geometry appended
to the road slab; lane counts (`resolveLaneCounts`) are unchanged, so the
drivable width — and everything derived from it — is unaffected. Pedestrian
routing over sidewalks is crowd/navmesh integration and is out of scope here.
### 2.1 Data model (`components/RoadGraph.hpp`, `RoadConfig`)
Global per-graph settings, next to `roadMeshTemplate`:
```cpp
bool sidewalkEnabled = false; // the checkbox
float sidewalkWidth = 1.5f; // lateral width in world units
float sidewalkHeight = 0.15f; // top elevation above road surface
float sidewalkThickness = 0.3f; // fallback box thickness
std::string sidewalkMeshTemplate; // empty = procedural box
```
Serialization goes into the existing `roadConfig` block
(`systems/SceneSerializer.cpp:4233` write, `:4341` read) with the defaults
above, so old scenes load with sidewalks disabled. Per-edge opt-out flags are
a possible later extension and are deliberately not part of this plan.
### 2.2 Geometry (`roadlib/RoadGeometryLib.cpp`)
One sidewalk strip per wedge along its outer curb (each wedge is one lateral
half of the road, so per-wedge strips automatically produce both sides and
continuous corner sidewalks), built with the same three-phase pipeline:
- **Template** — new `RoadSystem::getSidewalkTemplate(cfg)` mirroring
`getRoadTemplate` (`systems/RoadSystem.cpp`), same template-space
conventions (`ProceduralRoadGeometry.md` §2), cached like
`m_templateBuffer`. Empty/missing `sidewalkMeshTemplate` yields a
procedural box of `sidewalkWidth × sidewalkThickness` profile.
- **Phase 1** — identical concatenation (`N = ceil(L1 + L2)` copies).
- **Phase 2 mapping** — the strip starts at the curb and extends outward.
The originally planned per-vertex extension
`worldXZ = center(d) + offset(d) + normalize(offset(d)) * (vx * sidewalkWidth + ROAD_SIDEWALK_WALL_GAP)`
**folds at inner corners** (proven numerically during implementation):
cross-sections pivot around the pinned miter corner K while the curb
chain transitions between half-edges, so consecutive strip rows cross
near K — the overlap test reported coplanar/piercing triangles and
inverted normals at K. The shipped mapping instead interpolates
between two *widened* curb-offset chains:
```
offIn(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP)
offOut(d) = curbOffsetWidened(d, ROAD_SIDEWALK_WALL_GAP + sidewalkWidth)
worldXZ = center(d) + offIn(d) + (offOut(d) - offIn(d)) * vx
worldY = roadSurfaceY(d) + sidewalkHeight + vy
```
`curbOffsetWidened` scales offA/offB by `(len + widen) / len` before
the miter math, so the inner and outer sidewalk curbs get their own
pinned miter corners and each keeps the §5.4 no-fold property
independently (gap/fold-freedom is inherited by construction, not by
ray-sharing). Public `computeCurbOffset` is a wrapper with
`widen = 0` — road geometry unchanged. UVs:
`u = halfEdgeU(...)` (phase-continuous, same as road), `v` across
`[0, sidewalkWidth]`.
- **Z-fighting** — the sidewalk inner wall is coplanar with the road curb
wall over a small Y band. `ROAD_SIDEWALK_WALL_GAP` (~2 mm) shifts the whole
sidewalk strip slightly outward so the walls never share a plane; the top
surfaces overlap horizontally, so no hole is visible.
- **Straight segments** (dead ends, §7 of the spec) get *two* sidewalk bands
(inbound and outbound curb), each elevated and extruded via the existing
`extrudeToSlab`.
- The strips are appended to the same page `TriangleBuffer` as the road, so
LOD/visibility distance, the M5.9 collider soup and `markNavMeshDirty()`
need no changes. Sidewalks initially share the road material; a separate
material (separate buffer/entity per page) is out of scope.
### 2.3 Terrain compliance
`RoadSystem::complyTerrain` writes fixups under every buffer vertex, so
sidewalk vertices are flattened automatically. Only the falloff origin moves:
pass `sideWidth + sidewalkWidth` (when enabled) into `writeComplianceFalloff`
(`systems/RoadSystem.hpp:291`) so the shoulder fade starts at the sidewalk's
outer edge.
### 2.4 Editor UI
In the Terrain panel "Road Config" tree (`ui/TerrainEditor.hpp:683`): an
"Enabled" checkbox, width/height/thickness sliders, and a template combo
reusing the existing mesh-scan helpers (`scanMeshFiles`/`getMeshList`,
`:14601518`) with a "(Procedural Box)" default — the same UX as the road
mesh combo at `:648`. All edits call `rg.bumpVersion()`.
### 2.5 Tests
- `testRoadWedgeGeometry` (`TerrainTests.cpp:2135`): with sidewalks enabled —
lateral extent reaches `halfWidth + sidewalkWidth`, sidewalk top Y ≈
`roadSurfaceY + sidewalkHeight`, vertex count grows by the sidewalk
template count × `ceil(L)`; dead-end segment case has both bands.
- `tests/road_geometry_overlap_test.cpp`: enable sidewalks in the existing
ABC configurations (flat, corner raised/lowered) — the
coplanar-overlap/piercing checks must stay green (miter no-fold
regression).
- `testRoadSerialization` (`TerrainTests.cpp:1346`): `roadConfig` round-trip
with the new fields; loading a pre-sidewalk scene yields disabled defaults.
---
## 3. Road prefabs
**Status (2026-08-18): ✅ DONE** — three fixed slots per edge with legacy
JSON migration, per-edge spawn records keyed by `edgePrefabKey`,
plain-meter spawn/despawn hysteresis, edit-mode force preview, shared
`PrefabSystem::destroyInstance`, yaw/Y/anchor transforms, picker UI.
Verified by the reworked `testRoadSidePrefabs`.
### Current state (M5.11) and its problems
`RoadEdge::sidePrefabs` is an unbounded vector of
`{prefabPath, edgeT, sideOffset, leftSide}` (`RoadGraph.hpp:185-211`),
serialized per edge (`SceneSerializer.cpp:4254-4277` / `:4371-4396`), spawned
by `RoadSystem::spawnSidePrefabs` (`systems/RoadSystem.cpp:985-1050`) once per
page finalize. Known defects:
- **Duplicates across pages**: it iterates *all* graph edges for every
finalizing page, so each loaded road page spawns its own copy of every
edge's prefabs.
- **Name collisions**: instances are named `road_prefab_<A>_<B>_<edgeT>`; two
prefabs on one edge with equal `edgeT` collide.
- **No yaw alignment**: road direction is never applied to the instance
rotation.
- **Wrong Y**: snapped to raw terrain height; road levels/elevation ignored.
- **Leaks**: teardown is a bare `entity.destruct()` (`RoadSystem.cpp:417`,
`:456`) — the Ogre SceneNode/Entity and Jolt bodies survive (contrast
`TerrainPrefabSpawnerSystem::destroyInstanceRecursive`,
`TerrainPrefabSpawnerSystem.cpp:182-225`).
- **Silent failure**: `PrefabSystem::createInstance` returns a live empty
entity when the file is missing (`PrefabSystem.cpp:129-141`), so the
"failed to spawn" branch is dead and the empty proxy is tracked.
- **No distance unload** and **no prefab picker** (raw `InputText`,
`ui/TerrainEditor.hpp:1045-1087`).
### 3.1 Data model — fixed slots instead of a vector
Replace `sidePrefabs` with three fixed slots per edge
(`components/RoadGraph.hpp`):
```cpp
struct RoadEdgePrefabSlot {
std::string prefabPath; // empty = slot disabled
float edgeT = 0.5f; // normalized position along the edge
float lateralOffset = 0.0f; // meters from the anchor, away from road
float yOffset = 0.0f;
};
// RoadEdge members:
RoadEdgePrefabSlot prefabLeft; // anchor: left curb (looking A -> B)
RoadEdgePrefabSlot prefabRight; // anchor: right curb
RoadEdgePrefabSlot prefabMid; // anchor: centerline, edge midpoint
```
Anchors implement "zero position at the road edge, tunable along and
perpendicular": the side-slot anchor sits on the curb at `edgeT` — lateral
distance from the centerline equals the resolved half-width *of that side*
(asymmetric roads have different left/right widths) — and `lateralOffset = 0`
places the prefab exactly at the road edge. `prefabMid` anchors at the
centerline (`edgeT`, default 0.5) and is the "one per edge, aligned to the
road midpoint" prefab.
**Serialization/migration** (`SceneSerializer.cpp:4254`, `:4371`): write the
three slots; when loading an old scene, migrate the first `leftSide == true`
entry to `prefabLeft` and the first `false` entry to `prefabRight` (remap
`sideOffset` to `lateralOffset = sideOffset - sideHalfWidth`), log and drop
any extra entries.
### 3.2 Spawning, distance unload, lifetime (`systems/RoadSystem.cpp`)
- **Per-edge records, not per-page.** Track spawn state in
`RoadSystem`, keyed by the ordered node-id pair `(min(A,B), max(A,B))` —
stable across edge-vector reordering. Remove
`RoadPageGeometry::spawnedPrefabs`. This fixes the cross-page duplication.
- **Distance gating.** New `RoadConfig` fields `prefabSpawnDistance = 150`
and `prefabDespawnDistance = 250` (serialized as plain meters, same
convention as `TerrainPrefabSpawnerComponent`). `RoadSystem::update()`
re-evaluates when the camera has moved beyond a threshold (the
`SpawnerRecord`/`CAM_REEVAL_DIST_SQ` pattern in
`TerrainPrefabSpawnerSystem.hpp:157-178`): spawn when the edge is within
spawn distance *and* its endpoint pages are loaded; despawn beyond despawn
distance (hysteresis), on page unload, on edge removal and on teardown.
- **Transform.** Position: `center(edgeT)` plus the lateral anchor described
in §3.1. Y: interpolated road surface height
`lerp(A.y + roadLevelA, B.y + roadLevelB, edgeT) + yOffset` (terrain
compliance already flattens the curb area to road level). Rotation: yaw
from the edge direction, composed with the prefab's own stored root
rotation. Unique names: `road_prefab_<A>_<B>_<slot>`.
- **Failure handling.** Verify the prefab instantiated (file readable /
instance flagged) before tracking it; otherwise despawn and log once.
- **Graph changes.** On version bump, diff slot data per edge key and respawn
only edges whose slots changed; a split (§1.2) remaps slots onto the new
halves so prefabs survive the edit.
- **No leaks.** Hoist `TerrainPrefabSpawnerSystem::destroyInstanceRecursive`
into a shared `PrefabSystem::destroyInstance(entity)` and call it from both
systems; it recursively destroys child entities, Jolt bodies, Ogre entities
and scene nodes before `destruct()`.
### 3.3 Editor
- Rework the edge inspector prefab section (`ui/TerrainEditor.hpp:1045-1087`)
into three slot groups (Left / Right / Mid), each with: a prefab picker
combo fed by the existing `scanPrefabFiles` helper (`:508-523`, same pattern
as the terrain prefab-spawn picker), and `edgeT` / `lateralOffset` /
`yOffset` sliders.
- **Edit-time preview.** While road edit mode is active, force-spawn the
selected edge's prefabs regardless of distance so placement is WYSIWYG;
leaving edit mode re-applies distance rules. (Road edit mode currently has
no ESC handling — adding ESC-to-exit alongside this, mirroring prefab spawn
mode at `EditorApp.cpp:1917-1923`, is a cheap consistency win.)
- Config UI: spawn/despawn distance sliders in the "Road Config" tree.
### 3.4 Tests
Extend `testRoadSidePrefabs` (`TerrainTests.cpp:3462-3622`, fixtures in
`tests/prefabs/`): side anchors sit at the curb (±half-width), mid slot at
the centerline midpoint; yaw follows the edge direction; Y follows road
level; spawn/despawn hysteresis under camera movement; force-spawn in edit
mode; **no duplicates with two road pages loaded**; scene-graph node and
Ogre entity counts return to baseline after despawn/teardown cycles (leak
check); split remaps slots without duplication.
---
## 4. Connecting two nodes with a new edge
**Status (2026-08-18): ✅ DONE** — "Connect" radio tool with viewport
pinning and chaining (the second node becomes the new source),
validation with feedback (self/duplicate, cross-page via
`edgeStaysWithinOnePage`, wedge-angle rollback), `testRoadDataModel`
extended. The node-list "Connect" buttons remain for off-screen nodes.
### Current state
- The graph op exists: `RoadGraph::joinNodes(nodeA, nodeB)`
(`components/RoadGraph.hpp:593`) creates the edge if absent; `addEdge`
(`:480`) rejects self-edges, duplicates and missing nodes, and `joinNodes`
logs a warning for sub-`ROAD_MIN_EDGE_LENGTH` edges.
- A UI path exists but is list-only: select node A's row, then click the
"Connect" `SmallButton` on node B's row (`ui/TerrainEditor.hpp:835-860`).
There is no 3D-view gesture, and the selection model is single-node
(`RoadSystem::m_selectedNodeId`).
- `RoadGraph::edgeStaysWithinOnePage` (`RoadGraph.hpp:631`) encodes the hard
page constraint — an edge is valid only when both endpoints lie in the
same terrain page — but it is never called today, so cross-page edges can
be created and silently break the page-bucketed geometry (M5.4).
- The M5.5 wedge-angle limits (30°–270°) are only checked by
`RoadGraph::validate()`, never enforced when an edge is created.
### Remaining work
- **4.1 Connect tool for the 3D view.** Add a third radio "Connect" next to
"Move Node" / "Add Node" (`ui/TerrainEditor.hpp:716-725`; `RoadEditTool`
enum at `systems/TerrainSystem.hpp:331-335`). The first click in the
viewport pins the source node (reuse `pickRoadNode`,
`systems/EditorUISystem.cpp:2217`) and gives it the selection highlight;
the second click on another node calls `joinNodes` and clears the pin;
clicking empty terrain moves or clears the pin. This is the "select two
nodes, create edge" gesture; the node-list "Connect" buttons stay for
off-screen nodes.
- **4.2 Validation with feedback.** On a connect attempt: reject
self/duplicate via `joinNodes` (exists); reject cross-page pairs by wiring
up `edgeStaysWithinOnePage` (page world size comes from the
`TerrainComponent`); after the edge is added, re-enumerate the wedges at
both endpoint nodes and roll back with `removeEdge` if a new wedge
violates the 30°–270° limits. Report the rejection reason in the road
section, following the existing "Validate Road Graph" modal pattern
(`ui/TerrainEditor.hpp:741-767`).
- **4.3 Tests.** Extend `testRoadDataModel`
(`systems/TerrainTests.cpp:1246`): duplicate/self connects rejected,
cross-page pair rejected, angle-violating connect rolled back, successful
connect bumps the graph version.
---
## 5. Smoothing three neighboring nodes
**Status (2026-08-18): ✅ DONE** — `RoadGraph::smoothNode` (position
relaxation, no graph mutation) with min-length / page-crossing /
wedge-angle rollback, inspector UI ("Smooth ABC" + strength slider)
and neighbor highlight, editor-layer Y re-snap, `testRoadDataModel`
extended.
### Current state
No smoothing exists. Corners are intentionally sharp — the centerline is a
polyline (`ProceduralRoadGeometry.md` §5.3) — and selection is single-node
only.
Key observation: a node with exactly two neighbors unambiguously identifies
a 3-node chain ABC (itself plus its two neighbors), so the triple needs no
new multi-selection infrastructure — clicking the middle node B selects it.
### Semantics (decided)
Position relaxation, no graph mutation: B moves toward the straight line
AC; A and C are anchors and never move (they connect to the rest of the
network). A strength factor (0..1, default 0.5) controls how far B moves
per application, so repeated applications converge to straight:
```
B'.xz = lerp(B.xz, midpoint(A.xz, C.xz), strength)
B'.verticalOffset = lerp(B.verticalOffset,
(A.verticalOffset + C.verticalOffset) / 2, strength)
```
Y is then re-snapped to `terrainHeight + verticalOffset` by the editor layer
(the same helper used by the gizmo drag and by §1.3), keeping the road
surface continuous.
### Remaining work
- **5.1 Graph op.** New `RoadGraph::smoothNode(int nodeId, float strength)`
next to `splitEdge` (`components/RoadGraph.hpp:523`): pure graph function,
returns false unless the node has exactly two neighbors; applies the lerp
above (terrain-agnostic — no Y snap) and bumps the version.
- **5.2 Validation.** A move is rejected and reverted when it would shorten
an incident edge below `ROAD_MIN_EDGE_LENGTH`, push B across a terrain
page boundary (`edgeStaysWithinOnePage` for both incident edges), or
create a wedge outside 30°–270° (re-enumerate at A, B and C).
- **5.3 UI.** In the selected-node inspector (`ui/TerrainEditor.hpp:894-958`),
when the selected node has exactly two neighbors: show the derived triple
("Smooth ABC"), a strength slider and a "Smooth" button; extend
`buildSelectionHighlight` (`systems/RoadSystem.cpp`) to also mark the two
neighbor nodes in a second color so the affected triple is visible in the
viewport. Disabled with a hint when the degree is not 2 (junctions and
endpoints have no unique triple).
- **5.4 Tests.** `testRoadDataModel` additions: degree ≠ 2 rejected; B moved
by the exact lerp; `verticalOffset` averaged; min-length and page-crossing
moves reverted. Geometry needs no new cases — existing wedge tests
already cover straight and angled chains.
---
## 6. Implementation order
1. **§3 Road prefabs** — the slot data model and lifecycle fixes are a
prerequisite for §1.2 (split remapping operates on the slot model).
2. **§1 Edge splitting** — small, bounded; finishes the split/prefab
interaction.
3. **§4 Node connecting** and **§5 Corner smoothing** — small, independent
graph-editing additions; either order.
4. **§2 Sidewalks** — independent of the others; the largest piece
(geometry pipeline changes).
## 7. Documentation and definition of done
Per the project rules, every landed item updates docs and tests in the same
change:
- `TerrainRequirements.md`: add sub-milestones **M5.13 Road prefab slots and
distance unload**, **M5.14 Edge split polish**, **M5.15 Sidewalks**,
**M5.16 Node connect tool**, **M5.17 Corner smoothing**, each with a
status block and a definition-of-done checklist naming the verifying tests
(`--headless --run-terrain-tests=1` suite and
`road_geometry_overlap_test`).
- `ProceduralRoadGeometry.md`: new section specifying the sidewalk strip
(template conventions, per-vertex mapping, miter behavior, UV, segment
bands) once §2 lands. **Landed as §15.**
- This document: mark items done with dates as they land.
+47
View File
@@ -0,0 +1,47 @@
#include "ProjectConfig.hpp"
#include <nlohmann/json.hpp>
#include <filesystem>
#include <fstream>
std::string sanitizeAppName(const std::string &name)
{
std::string out = name;
for (auto &c : out) {
bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' ||
c == '-' || c == ' ';
if (!ok)
c = '_';
}
if (out.empty())
out = "project";
return out;
}
ProjectConfig loadProjectConfig(const std::string &dir)
{
ProjectConfig cfg;
cfg.rootDir = dir;
cfg.appName = std::filesystem::path(dir).filename().string();
std::filesystem::path jsonPath =
std::filesystem::path(dir) / "project.json";
std::ifstream file(jsonPath);
if (!file.is_open())
return cfg;
try {
nlohmann::json j;
file >> j;
cfg.appName = j.value("appName", cfg.appName);
cfg.startScene = j.value("startScene", "");
cfg.gameMode = j.value("gameMode", false);
cfg.loaded = true;
} catch (const std::exception &e) {
fprintf(stderr, "WARNING: could not parse %s: %s\n",
jsonPath.string().c_str(), e.what());
}
return cfg;
}
+61
View File
@@ -0,0 +1,61 @@
#ifndef EDITSCENE_PROJECT_CONFIG_HPP
#define EDITSCENE_PROJECT_CONFIG_HPP
#pragma once
#include <string>
/**
* Project directory configuration (F8).
*
* A "project" is a directory that acts as the editor/game working
* directory: scenes, prefabs, resources.cfg, runtime config JSONs and
* heightmaps/ are all resolved relative to it (the process chdir()s into
* the project root at startup when --project is given, or the release
* binary simply runs from it).
*
* The project root may contain a project.json:
*
* {
* "appName": "My Game",
* "startScene": "scenes/level1.json",
* "gameMode": true
* }
*
* appName - per-project identity: drives the window title and the
* per-project save directory (<user-data>/<appName>/saves/).
* Falls back to the directory name when missing.
* startScene - scene loaded directly by game mode (skips the startup
* menu) when gameMode is true.
* gameMode - the project is a playable game; the editor binary enters
* game mode by default for such projects (--editor forces
* editor mode).
*/
struct ProjectConfig {
/* Absolute path of the project root; empty when no project is open
* (plain editor session in the binary directory). */
std::string rootDir;
/* Per-project application name (window title, save directory). */
std::string appName;
/* Game-mode start scene (relative to the project root). */
std::string startScene;
/* Project defaults to game mode. */
bool gameMode = false;
/* True when a project.json was found and parsed. */
bool loaded = false;
};
/**
* Load <dir>/project.json. Tolerates a missing or malformed file:
* appName falls back to the directory name, loaded is set false and a
* warning is logged on parse errors. rootDir is always set to dir.
*/
ProjectConfig loadProjectConfig(const std::string &dir);
/** Make an appName safe for use as a filesystem path component. */
std::string sanitizeAppName(const std::string &name);
#endif // EDITSCENE_PROJECT_CONFIG_HPP
+187
View File
@@ -0,0 +1,187 @@
# Terrain System — Functional Description
This document describes how the terrain in `src/features/editScene` works: what it can
do, how the data is organised, and how the editor and runtime systems interact with it.
For the implementation history see `TerrainImprovment2.md`; for contributor rules see
`AGENTS.md`.
## Overview
The terrain is a single `TerrainComponent`-driven Ogre `TerrainGroup` owned by
`TerrainSystem`. Two modes exist:
- **Legacy mode** (`streamingEnabled = false`): a small fixed grid, all pages defined
and loaded up front, base heights from a single in-memory heightmap buffer. Used by
old scenes and most headless tests.
- **Streaming mode** (`streamingEnabled = true`): a bounded world of up to
40,000,000 × 40,000,000 units (20,000 × 20,000 pages at the default 2000-unit page
size). Pages stream in around the camera; base heights are procedural; edits, paint
layers, spawners and roads persist in per-page sidecar files.
## Coordinate spaces
Four coordinate spaces coexist; converting at system boundaries is every caller's job:
- **WORLD** — absolute double-precision coordinates, authoritative. Region files,
bookmarks, the Navigation panel, `TransformComponent::worldX/Y/Z` and physics use it.
- **RENDER** — float, relative to the floating render origin:
`render = world - renderOrigin`. Scene nodes, mouse rays and cameras live here.
`RenderOriginSystem` (singleton) owns the origin and rebases when the camera moves
more than 8192 render units away from it, shifting every render-space position by the
exact negated delta. Convert with `RenderOriginSystem::worldToRender()` /
`renderToWorld()`.
- **PHYSICS** — Jolt `DVec3`/`RVec3` in absolute world coordinates
(`physics/physics.h`). Terrain colliders live here.
- **PHYSICAL HEIGHTMAP** — per-page vertex indices: physical X is shifted by half a
page, physical Z is mirrored within each page. Convert with
`TerrainSystem::visualToPhysicalX/Z` / `physicalToVisualX/Z`.
Page indices are PHYSICAL page coordinates `[0, N-1]`. The Ogre TerrainGroup slot for
physical page `(x, y)` is `(x, -y)` because `ALIGN_X_Z` negates Z — use
`TerrainSystem::isPageLoaded(x, y)` rather than raw slot math.
Never store a world position in a float; floats cannot represent 40,000,000 with
sub-metre precision.
## Configuration (`TerrainComponent`)
| Field | Default | Meaning |
|---|---|---|
| `streamingEnabled` | false | Opt in to the streamed world |
| `worldSizeUnits` | 40,000,000 | World extent per axis (units) |
| `pageLoadRadius` | 2 | Pages loaded around the camera page |
| `pageHoldRadius` | 3 | Pages kept before unloading (hysteresis) |
| `farClipDistance` | 6000 | Camera far clip while terrain is active |
| `fogEnabled` / `fogStart` / `fogEnd` | true / 2500 / 5500 | Linear fog hiding pop-in |
| `baseNoise` | seeded FastNoiseLite | Procedural base height source |
All fields serialize with the scene (`SceneSerializer::serializeTerrain`).
## Streaming window
Each frame `TerrainSystem::updateStreamingWindow()` computes the camera's page, then:
- defines and loads pages entering the load window (at most 2 per frame), and
- unloads pages beyond the hold radius (at most 4 per frame).
Loading is synchronous and amortized — Ogre's background WorkQueue is deliberately not
used (it caused shutdown hangs). Base heights for a page are evaluated on demand from
`baseNoise` (double precision, deterministic for a given seed); a legacy
`heightmaps/<terrainId>/heightmap.bin`, when present, is sampled as a "base patch"
inside its bounds for backward compatibility.
Sculpting never touches the base layer: `setHeightAt`/brushes write into the sparse
per-page **fixup chunk** store (`heightmaps/<terrainId>/terrain_fixup/x{cX}_z{cZ}.bin`),
which is lazily disk-loaded, LRU-capped, and saved on eviction when dirty. Blend maps
and aux maps are likewise per-page files under `heightmaps/<terrainId>/`.
Height queries (`getHeightAt`, `raycastTerrain`) hit loaded pages when possible and fall
back to the analytic base+fixup evaluation for far/unloaded terrain, so tools (teleport
snapping, world map) work anywhere in the world.
Each loaded page gets a static Jolt collider created at its page-index-computed world
position (double precision, no float round-trip); colliders are queued/removed with the
page lifecycle.
## Region storage layout
All per-terrain streamed data lives under `heightmaps/<terrainId>/`:
```
heightmaps/<terrainId>/
├── terrain_fixup/x{cX}_z{cZ}.bin # sparse sculpt edits (chunk = page)
├── blend/... aux/... # per-page paint layers
├── spawners/<px>_<py>.json # prefab spawner definitions (M4)
└── roads/<px>_<py>.json # road graph partition (M5)
```
`<px>_<py>` are physical page indices. Positions inside the JSON files are absolute
world-space doubles, so the files are immune to render-origin rebases.
## Prefab spawner streaming
Two kinds of prefab spawners exist:
- **Always-loaded**: scene-embedded `TerrainPrefabSpawnerComponent` entities — the
classic behaviour, unchanged, right for small scenes.
- **Streamed**: when the terrain streams, spawner definitions live in the region store.
`TerrainPrefabSpawnerSystem::syncStreamedSpawners()` watches the loaded page set,
creates a region spawner entity (tagged `StreamedSpawnerTag`, excluded from scene
JSON) when a page loads, and destroys it — writing position/distance edits back,
including moves across page boundaries — when it unloads. The distance-based
spawn/despawn of the actual prefab instances is unchanged.
The editor's click-to-place prefab spawn mode writes straight to the region store when
streaming.
## Road streaming
With streaming enabled, the road graph (`RoadGraph`: nodes + edges + `roadConfig`) is
partitioned per page:
- A node belongs to the page containing its position; a cross-page edge is written to
**both** endpoint pages' files (foreign endpoint repeated inline), so each file is
self-contained.
- On page load, `RoadSystem::syncRegions()` merges the file into the active graph,
matching nodes by world XZ (0.01 eps) and creating an edge only when both endpoints
exist — an edge into an unloaded neighbour appears when that neighbour loads.
- On page unload, the page's nodes and their edges are extracted back to the file.
- Legacy scenes with an inline `roadGraph` migrate to region files automatically on
first sync; the scene JSON then keeps only `roadConfig`.
- Saving the scene flushes the region store first
(`EditorUISystem::saveScene``RoadSystem::flushRegionStore()`).
Per-page road meshes, colliders and roadside prefabs were already page-scoped; they are
rebuilt only when a rebase-invariant content signature changes, and navmesh dirtying is
debounced (~15 quiet frames) so mass page transitions do not trigger rebuild storms.
## Editor navigation tools
- **Fly camera**: mouse-wheel zoom, Shift boost (×10), configurable speed
(`EditorCamera::setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation**: teleport to world X/Y/Z or to a page index (clamped to world
bounds, camera snapped above the terrain); named **bookmarks** stored in the scene
JSON `bookmarks` array.
- **Tools → World Map**: data-driven ImGui canvas (no RTT) showing coarse cached height
shading, loaded/unloaded pages, road polylines, prefab spawners, bookmarks and the
camera marker; drag to pan, wheel to zoom, double-click to teleport.
- `TerrainEditor` hosts a compact "Navigation" section with the same teleport backend.
## Serialization behaviour
- Scene JSON: terrain component settings, `roadConfig`, bookmarks, and all
non-streamed entities. When streaming, `roadNodes`/`roadEdges` and
`StreamedSpawnerTag` entities are **not** written — they live in the region stores.
- Region stores are written immediately on edit (spawners), on page unload, and on
scene save (roads).
- Loading an old scene with inline roads/spawners migrates them into region files on
first sync/save.
## Lua API coordinates
`lua/LuaTerrainApi.hpp` documents the coordinate space per function:
`terrain.sculpt` / `terrain.paintAux` take visual world coordinates,
`terrain.paint` converts world → render internally (rebase-safe), and
`terrain.sampleAux` takes physical heightmap coordinates.
## Testing
Headless integration suite (no display needed):
```bash
cd build-vscode/src/features/editScene
./editSceneEditor --headless --run-terrain-tests=1 # full suite
TERRAIN_TEST_FILTER=roadRegion ./editSceneEditor --headless --run-terrain-tests=1
```
Covers: streaming window follow/unload, procedural determinism, fixup persistence,
rebase stability and round-trip, bookmarks, world-map transforms, spawner region
round-trip and window lifecycle, road region store/streaming/migration. Success line:
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`.
## Known limitations
- The world is **bounded**, not looped: page indices and teleports clamp to
`[0, N-1]`. Wrap-around was explicitly scoped out (see `TerrainImprovment2.md`).
- Synchronous amortized loading can briefly lag behind very fast camera movement.
- Roads into an unloaded neighbour page render only once that page loads (by design).
@@ -0,0 +1,204 @@
# Terrain Improvement Plan 2 — Streamed 40M×40M World (with status)
Status legend: **[DONE]** implemented and verified, **[PARTIAL]** implemented with
deviations, **[OPEN]** not implemented.
Overall status: **all milestones complete** (verified 2026-09: build clean,
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED`, `component_lua_test` 63/63).
## Goal
Convert `src/features/editScene` terrain from a fixed 3×3-page, always-loaded grid into a
camera-streamed, bounded 40,000,000 × 40,000,000-unit world with:
- Terrain page streaming — pages load/unload around the camera.
- Procedural base terrain + sparse persisted edits.
- Rendering origin rebasing — world coordinates stay `double`; rendering floats never see
huge values. Jolt runs with `JPH_DOUBLE_PRECISION`, so physics stays in world space.
- Editor navigation tools — teleport, world map with info layers, bookmarks, speed-scaled
fly camera.
- Terrain-attached prefab streaming — spawner definitions stored per region.
- Road streaming — road graph stored per region, loaded around the camera.
## Confirmed decisions
- Bounded world, **no wrap-around**. (The original request mentioned a looped world; this
was consciously scoped out. See "Open items" below.)
- Base terrain = procedural + sparse edits (no giant heightmap file).
- Precision = origin rebasing for rendering only; physics uses doubles already.
- All four editor tools wanted; world map must show info layers (prefabs, roads, pages).
## Architecture
### Coordinate model
- **World space**: `double`, authoritative. World bounds `[0, 40,000,000]` both axes →
pages `[0, 19999]` at the default 2000-unit page size (clamped to 32768 for
TerrainGroup's signed 16-bit slot packing).
- **Render space**: float, camera-centric. `RenderOriginSystem` owns a `JPH::DVec3`
origin; `render = world - origin`. Rebase when the camera moves further than 8192
render units from the origin: the origin snaps to integer world coordinates near the
camera and every render-space position shifts by the exact negated delta.
- **Physics space**: Jolt `DVec3`/`RVec3` in absolute world coordinates.
- **Physical heightmap space**: physical X shifted by half a page, physical Z mirrored
within each page; convert with `TerrainSystem::visualToPhysicalX/Z` /
`physicalToVisualX/Z`. Note: the Ogre TerrainGroup slot for physical page `(x, y)` is
`(x, -y)` because `ALIGN_X_Z` negates Z.
### Region/window model for content streaming
- Terrain pages stream in a window around the camera's page (`pageLoadRadius` = 2,
`pageHoldRadius` = 3 by default, configurable in `TerrainComponent`), synchronously,
amortized (2 loads + 4 unloads per frame) to avoid hitches and the WorkQueue shutdown
hangs seen with Ogre background loading.
- Sidecar content (prefab spawners, road graph) is stored **per page** under
`heightmaps/<terrainId>/spawners/<px>_<py>.json` and
`heightmaps/<terrainId>/roads/<px>_<py>.json` (the plan originally considered coarser
8×8-page regions; per-page files proved simpler and match the terrain page lifecycle).
---
## Milestones
### M1 — Terrain page streaming core — [DONE]
- `TerrainComponent` gained `streamingEnabled` (default false; opt-in per terrain),
`worldSizeUnits` (default 40,000,000), `pageLoadRadius`/`pageHoldRadius` (2/3),
`farClipDistance` (6000), linear fog (`fogEnabled`, 2500→5500) and `baseNoise`
(FastNoiseLite config). All serialized in `SceneSerializer`.
- Base heights are procedural-on-demand from `baseNoise` (double precision);
`setHeightAt`/sculpting write through the sparse per-page fixup chunk layer
(LRU-capped, save-on-evict). A legacy `heightmap.bin` keeps working as an optional
base patch for old scenes.
- `TerrainSystem::updateStreamingWindow()` loads/unloads pages around the camera page
each frame; old `m_pageMin/Max` full-grid loops were converted to iterate loaded
slots; blend/aux maps are per-page files under `heightmaps/<terrainId>/`.
- `getHeightAt`/`raycastTerrain` fall back to the analytic height path for unloaded
(far) pages.
- Camera far clip + fog come from `TerrainComponent` so pop-in is hidden at the hold
radius.
- Tests: page window follows camera, unload frees slots, procedural determinism across
reload, fixup persistence across unload/reload; legacy assumptions adapted.
### M2 — Rendering origin rebasing + double world positions — [DONE]
- New `systems/RenderOriginSystem.{hpp,cpp}` (singleton): `worldToRender(x,y,z)`,
`renderToWorld(Ogre::Vector3) -> JPH::DVec3`, 8192-unit rebase threshold; runs before
all other systems each frame.
- `TransformComponent` carries authoritative double `worldX/Y/Z` alongside the float
node-local position; serialization writes the doubles (float kept for legacy load).
- Physics wrapper gained `JPH::RVec3` overloads so world-space doubles flow without a
float round-trip; terrain page colliders are created at page-index-computed world
positions.
- `TerrainSystem` exposes `worldToRender`, `renderToWorldX/Y/Z`, `isPageLoaded(x,y)`
(physical page coords), `streamingCameraPage`, `getTerrainEntityId`, `getStreamingActive`,
`getStreamingPageCount`, `snapCameraAboveTerrain`.
- Tests: rebase stability (geometry shifts by exactly the origin delta),
world→render→world round-trip, far-corner teleport renders correct terrain.
### M3 — Editor navigation tools — [DONE]
- `EditorCamera`: mouse-wheel zoom (`handleMouseWheel`), Shift boost (×10), configurable
fly speed (`setFlySpeed`, 1..100,000 units/s).
- **Tools → Navigation** (`ui/NavigationPanel.hpp`, header-only): teleport by world
X/Y/Z or by page index (clamped to world bounds); named bookmarks
(`ui/WorldBookmark.hpp`) persisted in the scene JSON top-level `bookmarks` array
(tolerated when missing on load).
- **Tools → World Map** (`ui/WorldMapPanel.hpp` + `systems/WorldMapData.hpp`): ImGui
2D canvas (data-driven, not RTT) with coarse cached height shading, loaded/unloaded
page overlay, road polylines, prefab-spawner points, bookmarks, camera marker;
drag pan, wheel zoom, double-click teleport.
- `TerrainEditor` has a compact "Navigation" section sharing the same teleport backend.
- Tests: bookmark round-trip, world↔map transforms at extremes, navigation teleport.
### M4 — Terrain-attached prefab streaming — [DONE]
- New `systems/SpawnerRegionStore.{hpp,cpp}`: `SpawnerRegionDef` (prefabPath, world-space
double position, spawn/despawn distances, per-page id) in
`heightmaps/<terrainId>/spawners/<px>_<py>.json`, saved immediately on edit.
- `TerrainPrefabSpawnerSystem::syncStreamedSpawners()` polls
`TerrainGroup::getTerrainSlots()`: creates region spawner entities on page load
(tagged `StreamedSpawnerTag{pageX,pageY,defId}`), destroys them and writes edits back
(including page-boundary crossings) on unload. Existing distance-based spawn/despawn
logic is unchanged.
- Editor click-to-place (`createSpawnPoint`) writes to the region store when streaming;
scene-embedded spawner entities remain as "always-loaded" spawners for
small/non-streaming scenes.
- `SceneSerializer` excludes `StreamedSpawnerTag` entities from scene JSON and caches
parsed prefab JSON (`loadPrefabJsonCached`/`invalidatePrefabJsonCache`, invalidated on
prefab save/delete).
- Tests: region store round-trip, streamed spawner window lifecycle, prefab JSON cache.
### M5 — Road streaming — [DONE]
- New `systems/RoadRegionStore.{hpp,cpp}`: per-page files
`heightmaps/<terrainId>/roads/<px>_<py>.json` with nodes as absolute world-space
doubles and edges referencing per-file node ids (full lane/level/prefab-slot data).
- Membership rule: a node belongs to the page containing its position; an edge is stored
in the region file of **each** endpoint page (foreign endpoint repeated inline), so a
page file is self-contained. Merge matches nodes by world XZ (0.01 eps); an edge is
created only when both endpoints exist in the active graph; `hasEdge` prevents
duplicates.
- `RoadSystem::syncRegions()` runs each frame: merges region files of newly loaded pages,
extracts departing pages back to disk (two-phase: all departing pages are written
before any node is removed, because `removeNode` cascades incident edges).
- One-time migration: a non-empty inline `roadGraph` (legacy scene JSON) is partitioned
into region files on first sync; `SceneSerializer::serializeTerrain` then skips
`roadNodes`/`roadEdges` when `streamingEnabled` (keeps `roadConfig`).
- `EditorUISystem::saveScene` calls `RoadSystem::flushRegionStore()` before writing.
- Page mesh rebuilds are gated by a rebase-invariant FNV-1a content signature (world
positions quantized to cm) instead of re-dirtying all pages on every graph change;
navmesh dirtying is debounced (~15 quiet frames) while streaming.
- `TerrainSystem::onRenderOriginChanged` forwards to `RoadSystem::onRenderOriginChanged`
(shifts render-space node positions, bumps version; region files untouched).
- Notable fix found by tests: physical page assignment on the mirrored Z axis needs a
small epsilon (`floor((visualToPhysicalZ(wz) - 1e-6) / ws)`) so boundary nodes land in
the same page `connectNodes`' round-half rule picks.
- Tests: `roadRegionStore`, `roadRegionStreaming` (incl. cross-page `connectNodes` split,
rebase invariance, unload/reload remerge), `roadRegionMigration`.
### M6 — Docs, examples, cleanup — [DONE]
- Root `AGENTS.md`: "Road Region Streaming (M5)" + "Terrain Streaming Architecture"
sections (coordinate spaces, streaming window, region storage layout, navigation
tools, streamed vs always-loaded spawners).
- `src/features/editScene/AGENTS.md`: streaming paragraphs for
`TerrainPrefabSpawnerSystem` and `RoadSystem`, a "Terrain Streaming & Navigation"
section, and new caveats (physical page vs Ogre slot `(x,-y)`; never store world
positions in floats; streamed content lives in region stores; Lua coordinate
expectations).
- `TerrainRequirements.md`: stale "all pages loaded" / fixed-grid statements corrected.
- `lua/LuaTerrainApi`: per-function coordinate spaces documented in the header;
`terrain.paint` fixed to convert world→render (it painted at the wrong spot after a
rebase); `terrain.sculpt`/`paintAux` take visual world coords, `sampleAux` takes
physical coords.
- CMake: `prefabs/` is now copied next to the runtime resources (mirrors the existing
`tests/prefabs` copy), fixing the missing runtime prefab directory.
- Stale comments in `RoadSystem.hpp` / `TerrainSystem.hpp` / `RenderOriginSystem.hpp`
brought in line with the final behaviour.
---
## Verification
After every milestone and at completion:
- `cmake --build build-vscode --target editSceneEditor -j4` — clean.
- `cd build-vscode/src/features/editScene && ./editSceneEditor --headless --run-terrain-tests=1`
`TERRAIN TESTS: ALL 1 ITERATIONS PASSED` (single steps via `TERRAIN_TEST_FILTER=<substr>`).
- `./component_lua_test` — 63/63 passed.
## Open items / known limitations
- **Looped (wrap-around) world**: requested but explicitly scoped out; the world is
bounded and page indices/teleports clamp to `[0, N-1]`. Implementing a toroidal world
(wrapping streaming window, render origin, region stores, minimap) is a separate
feature.
- Page loads/unloads are synchronous and amortized (2+4 per frame); very fast camera
movement can outrun the window briefly. Ogre's background WorkQueue was deliberately
avoided (shutdown hangs).
- `connectNodes` boundary splitting uses render-space round-half math and can misplace
splits after a non-page-aligned rebase (nodes re-merge by position, so this is
cosmetic at worst).
- Roads crossing into an unloaded neighbour page appear only when the second page loads
(by design; both-files rule).
@@ -36,9 +36,14 @@ others are not, and `UNCOVERED` when no automated test exists at all.
| M5.9 | Road physics colliders| `testRoadPageMeshes` (partial) | PARTIAL | bodyId validity and PhysicsColliderComponent presence asserted at page finalize time. **Missing**: physical interaction (raycast against road collider, collider removal on rebuild). |
| M5.9.6| Road collider debug visibility | — | UNCOVERED | New item: toggle for road colliders in physics debug draw. Not tested in headless suite. |
| M5.9.5| Fixup chunk support | `testFixupChunks` | COVERED | writeFixup, sampleHeightAt reads override, save/load round-trip, clearAll |
| M5.10 | Terrain compliance | | UNCOVERED | `RoadSystem::complyTerrain()` has no automated test. Perpendicular falloff (laneWidth*2 fade) needs implementation. |
| M5.11 | Roadside prefab spawning | | UNCOVERED | `RoadSystem::spawnSidePrefabs()` has no automated test |
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/sidePrefabs JSON round-trip; wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
| M5.10 | Terrain compliance | `testTerrainCompliance` | COVERED | Under-road / mid-fade / fade-end / beyond-fade heights vs base heightmap, save/load + clear round-trips. With sidewalks enabled (M5.15) the falloff origin moves to the sidewalk outer edge and fixups under sidewalk vertices target the strip underside. |
| M5.11 | Roadside prefab spawning | `testRoadSidePrefabs` | COVERED (reworked by M5.13) | Per-edge slot spawn/respawn/despawn, distance gating, edit-mode preview, teardown |
| M5.12 | Serialization + wiring| `testRoadSerialization` | COVERED | Config/nodes/edges/prefab-slot JSON round-trip (legacy `sidePrefabs` migrates); wiring: roadSystem lifecycle covered by testRoadPageAssignment + testRoadPageMeshes |
| M5.13 | Road prefab slots + distance unload | `testRoadSidePrefabs`, `testRoadSerialization` | COVERED | Slot anchors/yaw/Y, per-edge records (`RoadSystem::getEdgePrefabs`), hysteresis, edit-mode preview, no cross-page duplicates, leak-free despawn |
| M5.14 | Edge split polish | `testRoadDataModel` | COVERED | Per-row Split button, slot `edgeT` remap without duplication, surface continuity at the new node |
| M5.15 | Sidewalks | `testRoadWedgeGeometry`, `testRoadSerialization`, `road_geometry_overlap_test` | COVERED | Strip extent/top Y/vertex growth, both segment bands, config round-trip; overlap test runs all ABC configs with sidewalks enabled |
| M5.16 | Node connect tool | `testRoadDataModel` | COVERED | Duplicate/self rejected, cross-page auto-split at the page boundary (incl. corner dedupe), axis-crossing same-page accept, wedge-angle rollback, version bump |
| M5.17 | Corner smoothing | `testRoadDataModel` | COVERED | Degree ≠ 2 rejected, exact lerp, `verticalOffset` averaged, min-length/page-crossing moves reverted |
### 1.1 M5.9 Collider Coverage Detail
@@ -172,8 +177,11 @@ sampling path observes, including the perpendicular falloff.
### 2.3 `testRoadSidePrefabs`
**Purpose**: verify `RoadSystem::spawnSidePrefabs()` creates and destroys
prefab instances correctly.
**Purpose**: verify the M5.13 slot-based roadside prefab lifecycle in
`RoadSystem` — spawn at the correct anchor, respawn on slot edits,
distance gating, edit-mode preview and leak-free teardown. (Originally
written against the M5.11 `spawnSidePrefabs()`/`sidePrefabs` vector;
fully reworked with M5.13.)
**Test prefab fixture**: a minimal `.prefab` JSON file placed in
`src/features/editScene/tests/prefabs/tiny_cube.prefab`. This is a self-contained
@@ -182,17 +190,25 @@ test resource; it must not be mixed with user-created prefabs.
**Steps**:
1. Ensure the test prefab is loadable (the file is registered in a resource
group accessible during tests).
2. Create terrain, add a road edge with one `RoadSidePrefab` pointing at
`tiny_cube.prefab` with `edgeT=0.5, sideOffset=3, leftSide=true`.
2. Create terrain, add a road edge, fill its `prefabLeft` slot with
`tiny_cube.prefab` (`edgeT=0.5, lateralOffset=0`) and set huge
spawn/despawn distances so the test camera is always in range.
3. Pump frames until road mesh + prefabs exist.
4. Verify `RoadPageGeometry::spawnedPrefabs` is non-empty (1 entity).
5. Verify the spawned entity is alive, has a `TransformComponent`, and its
position is approximately the expected world position (edge midpoint + 3
units left).
6. Bump graph version (add a dummy node+edge) → pump frames.
- Verify old prefab entity is no longer alive.
- Verify `spawnedPrefabs` now contains the new prefab instance.
7. Deactivate terrain → verify spawned prefab entity is not alive.
4. Verify the per-edge record (`RoadSystem::getEdgePrefabs()` keyed by
`RoadSystem::edgePrefabKey(n1, n2)`) has a live `left` instance with a
`TransformComponent` at the left-curb anchor (edge midpoint offset by
the side half-width), road-level Y and yaw aligned to the edge
direction.
5. Add an unrelated node+edge → pump frames: the prefab must stay alive
(spawn state is per edge, not per page — no cross-page duplicates).
6. Edit the slot (`edgeT 0.5 → 0.25`) → pump frames: old instance
destroyed, new instance at the remapped anchor.
7. Shrink spawn/despawn distances to 1 m → the instance despawns and the
root scene-node child count returns to baseline (no leak).
8. Enable road edit mode and select the edge → the prefab force-spawns
(edit-time preview); leaving edit mode despawns it again.
9. Restore large distances (respawn), then destroy the terrain entity →
the prefab entity is not alive (teardown).
## 3. Manual Verification Procedures
@@ -282,11 +298,11 @@ ordered by dependency.
| # | Item | Files to modify | Status |
|---|------|-----------------|--------|
| W0 | Sweep-based wedge geometry (M5.6 gaps + overlaps) — **superseded 2026-08-02**: the radial curb sweep left node-center holes, diagonal > 180° bands, bowed through-roads and double-height segments; replaced by the mitered polyline sweep (`computeWedgeOutline`/`triangulateOutline` + `emitSlab`) per user direction | `RoadSystem.cpp`, `RoadSystem.hpp`, `TerrainTests.cpp` | ✅ DONE (2026-08-02, reworked) |
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE |
| W1 | M5.10 perpendicular falloff in `complyTerrain()` | `RoadSystem.cpp` | ✅ DONE (2026-08-16 rework: per-sample fade writes, see below) |
| W2 | Helper `computeComplianceHeight()` + unit test | `RoadSystem.cpp`, `TerrainTests.cpp` | ✅ DONE |
| W3 | `testTerrainCompliance` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W4 | `testRoadColliderInteraction` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | |
| W5 | Road collider debug draw toggle (M5.9.6) | `RoadSystem.hpp/.cpp`, `TerrainSystem.hpp/.cpp`, `TerrainEditor.hpp` | ✅ DONE (2026-08-16) |
| W6 | Test prefab fixture `tiny_cube.prefab` | `src/features/editScene/tests/prefabs/tiny_cube.prefab` (new) | ✅ DONE |
| W7 | `testRoadSidePrefabs` (automated) | `TerrainTests.cpp`, `TerrainTests.hpp` | ✅ DONE |
| W8 | Register new tests in `TerrainTestRunner::run()` | `TerrainTests.cpp` | ✅ DONE |
@@ -298,7 +314,7 @@ ordered by dependency.
| M5.1M5.8 automated coverage | ✅ Adequate (8/8 sub-items have tests) |
| M5.6 wedge geometry | ✅ Mitered polyline sweep (2026-08-02) — replaces the broken radial curb sweep (W0 rework): wedge = one mesh bent along the 2-segment centerline polyline, exact width at corners, no node holes/overlaps |
| M5.9 automated coverage | ✅ W4 adds raycast+rebuild verification |
| M5.9.6 road collider debug toggle | ❌ Not implemented → W5 |
| M5.9.6 road collider debug toggle | ✅ Implemented (W5, 2026-08-16) |
| M5.10 perpendicular falloff | ✅ Implemented → W1+W2 |
| M5.10 automated coverage | ✅ W3 covers falloff + save/load |
| M5.11 automated coverage | ✅ W6+W7 cover prefab spawn + teardown |
@@ -311,7 +327,66 @@ ordered by dependency.
(2026-08-02 rework; radial curb sweep attempt reverted).
- [x] W1W4, W6W8 implemented.
- [x] `./editSceneEditor --headless --run-terrain-tests=1` passes with all
22 tests green per iteration (verified 2026-07-31).
22 tests green per iteration (re-verified 2026-08-16 after the
re-audit fixes below).
- [ ] Manual verification walkthroughs 3.13.7 are executed and pass.
- [ ] `ctest -R editSceneTerrainTest` passes in CI.
- [ ] W5 (road collider debug draw toggle) implemented.
- [x] `ctest -R editSceneTerrainTest` passes in CI (verified 2026-08-16,
102 s).
- [x] W5 (road collider debug draw toggle) implemented (2026-08-16).
## 6. Re-audit fixes (2026-08-16)
A code-level re-audit (prompted by "do not trust completion status") found
several items marked ✅ that were not actually working; all fixed and now
covered by the 22-test headless suite:
- **W1 was not implemented**: `complyTerrain()` wrote only full-compliance
fixups under the road; the `laneWidth * 2` perpendicular fade did not
exist. Implemented as `RoadSystem::writeComplianceFalloff()` (wedge
outer curb + both segment sides), with `TerrainSystem::sampleBaseHeightAt()`
added so fade targets blend toward the natural height rather than reading
back already-written fixups.
- **W5 was not implemented**: the `TerrainBodyDrawFilter` road-ID set
(`RoadSystem::m_roadBodyIds`) was never populated, so the "Show Road
Colliders" checkbox drew nothing. `createPageCollider()` /
`destroyPageCollider()` now maintain the set.
- **Fixup chunk grid unusable at spec density**: M5.9.5/4.2's literal
addressing (chunk span `worldSize/256`, 256x256 samples → one sample per
`worldSize/65536` units) cannot be filled by the compliance writer, and
page vertices (sampling at ~`worldSize/64` spacing) never see the fixups.
Chunks now span the full per-page `worldSize` with 256x256 samples (one
sample per `worldSize/256` units — "same as base heightmap", section 4.2);
chunk indices coincide with page indices. Fixup files written before this
change used the old grid and must be deleted
(`heightmaps/<terrainId>/terrain_fixup/` or "Clear All Fixups").
- **Sentinel blending toward 0.0**: partially written chunk cells blended
toward zero, digging trenches at fixup borders; unwritten corners now fall
back to the natural (base + noise) height at the corner.
- **Falloff writes were sparse point splats** (invisible between chunk
cells). The fade pass now evaluates the exact fade target at every fixup
sample inside the band (`writeFixupSample()`), so bilinear reads reproduce
the linear ramp. The fade pass runs BEFORE the full-compliance pass so
slab-underside values win on shared samples.
- **Segment underside depth mismatch**: the segment pass wrote
`centerY - roadThickness` while the wedge pass wrote `topY -
roadThickness` (= `centerY - roadThickness/2`); unified on the half
thickness.
- **Roadside prefabs leaked on graph rebuild** (M5.11): `buildPageMeshes()`
rebuilt mesh + collider but never destroyed `spawnedPrefabs`; old
instances survived and new ones spawned on top. Old prefabs are now
destroyed at the start of every page mesh rebuild. *(Superseded by
M5.13: spawn state moved out of the pages into per-edge records and
teardown goes through the shared `PrefabSystem::destroyInstance()`.)*
- **Roadside prefabs were serialized with the scene** (M5.12): spawned
instances kept their `EditorMarkerComponent` and, as flecs children of the
terrain entity, were written into the scene file and duplicated on reload.
`spawnSidePrefabs()` now strips the marker (runtime-only entities).
*(M5.13: instances are created marker-free by the new spawn path.)*
- **Tests strengthened**: `terrainCompliance` checks under-road / mid-fade /
fade-end / beyond-fade heights against the natural (procedural) base
height plus save/load and clear round-trips; `roadColliderInteraction`
raycasts the slab top (40.15) and underside (39.85) on a Y=40 road clear
of the terrain and verifies collider replacement across a graph rebuild;
`roadSidePrefabs` verifies slot anchor position/yaw/Y, respawn on slot
edit, distance gating, edit-mode preview and teardown destruction; the
`tiny_cube.prefab` fixture is staged next to the test binary by CMake.
+374 -71
View File
@@ -188,8 +188,15 @@ std::unordered_map<uint64_t, TerrainCollider> mColliders;
`TerrainPaging`, `PagedWorld`, `TerrainPagedWorldSection`.
- Configure default `Ogre::Terrain::ImportData` from component values.
- Set the custom `CustomTerrainDefiner`.
- Call `loadAllTerrains(true)` for editor-mode synchronous setup, or rely on
paging for game mode.
- In legacy (non-streaming) mode: define every page in the configured
range and call `loadAllTerrains(true)` for editor-mode synchronous
setup (the camera is not attached to `PageManager`; the paging
objects are present so runtime game mode can attach one). With
`TerrainComponent::streamingEnabled` only the initial load window
around the camera is defined/loaded; `updateStreamingWindow()`
streams further pages in/out as the camera moves (see
`TerrainSystem::updateStreamingWindow` and the streaming section of
`AGENTS.md`).
3. **Deactivation / scene clear**
- Disable paging operations (`PageManager::setPagingOperationsEnabled(false)`)
@@ -238,8 +245,8 @@ Ogre's `WorkQueue`. The following rules prevent crashes, leaks, and use-after-fr
- **Do not drive paging from a worker thread in the editor.** In editor mode
the camera is intentionally **not** added to `PageManager`;
pages are loaded synchronously through `TerrainGroup::loadAllTerrains(
true)`.This prevents
pages are loaded synchronously — the initial set through `TerrainGroup::loadAllTerrains(
true)` (non-streaming) or the load window around the camera plus `loadTerrain(x, y, true)` per streamed page (streaming mode). This prevents
`CustomTerrainDefiner::define()` from being called on a
background thread and touching GL
/ ECS state unsafely.-
@@ -628,6 +635,12 @@ Use `EShapeSubType::User1` for the shape subtype.
at component creation (e.g. `std::random_device{}() | (uint64_t)time(0) << 32`),
serialized with the component, and survives save/reload cycles unchanged.
- **Default size**: 256x256.
- **Streaming mode**: with `TerrainComponent::streamingEnabled` the legacy
heightmap buffer is not the base-height source; base heights come from
on-demand procedural evaluation of `TerrainComponent::baseNoise`
(FastNoiseLite OpenSimplex2, double precision) and pages stream in/out in a
`pageLoadRadius`/`pageHoldRadius` window around the camera. Edits write
through the fixup chunks (4.2).
- Editable in the terrain editor with elevation and curve brushes.
- A fresh empty heightmap initializes all samples to `0.0f`.
@@ -640,11 +653,18 @@ Use `EShapeSubType::User1` for the shape subtype.
- **Naming**: `x<chunkX>_z<chunkZ>.bin` where chunk coordinates are derived from
world coordinates:
```
int chunkX = floor(worldX / (worldSize / 256));
int chunkZ = floor(worldZ / (worldSize / 256));
int chunkX = floor(worldX / worldSize);
int chunkZ = floor(worldZ / worldSize);
```
So chunk `(0,0)` covers world `(0,0)` to `(worldSize/256, worldSize/256)` in
X and Z.
So chunk `(0,0)` covers world `(0,0)` to `(worldSize, worldSize)` in X and Z
— one chunk per terrain page, one sample per `worldSize/256` units (the same
density as the base heightmap, matching "same as base heightmap" above).
**Corrected 2026-08-16**: the original formula (`worldSize / 256` per chunk,
i.e. one sample per `worldSize/65536` units) made the fixup grid 256x denser
than the base heightmap; road-compliance writes could not fill it and page
meshes never saw the fixups. Fixup files written before the correction use
the old grid and must be deleted (`heightmaps/<terrainId>/terrain_fixup/` or
the "Clear all fixups" button).
- **Storage directory**: `heightmaps/<terrainId>/terrain_fixup/` — registered as
an Ogre resource location when the scene is loaded so the definer can load them
on demand. The `<terrainId>` prefix matches the base heightmap directory scheme
@@ -966,6 +986,13 @@ struct TerrainPrefabSpawnerComponent {
};
```
> **Implementation note**: as implemented, `position`/`rotation` are NOT
> stored in the component — they live on the entity's `TransformComponent`
> (single transform source, so editor gizmo moves cannot desync; same
> precedent as `CharacterSpawnerComponent`). The implemented component holds
> only `prefabPath`, `spawnDistanceSq`, `despawnDistanceSq`, and the runtime
> `spawnedEntity`.
### 6.3 TerrainPrefabSpawnerSystem
A lightweight system that:
@@ -1182,10 +1209,13 @@ target_link_libraries(editSceneEditor PUBLIC
- CMake (`OgrePaging`/`OgreTerrain` links) and `EditorApp` wiring.
- Component registration in `setupECS()` + editor module.
Definition of done: a paged terrain renders in the editor. In the current
editor mode the camera is not attached to `PageManager`, so all pages in the
configured range are loaded synchronously; the paging objects are present and
ready for runtime game mode where a camera can be attached.
Definition of done: a paged terrain renders in the editor. In legacy
(non-streaming) mode the camera is not attached to `PageManager`, so all pages
in the configured range are loaded synchronously; the paging objects are
present and ready for runtime game mode where a camera can be attached. With
`streamingEnabled` (added later) only the load window around the camera is
defined up front and `updateStreamingWindow()` streams pages in/out as the
camera moves.
### Milestone 2 — Physics integration ✅ DONE
@@ -2119,14 +2149,18 @@ view, rendered by sweeping a 1-unit mesh template along the graph, and makes the
terrain conform to the road surface without intersecting it. Roads may be
asymmetric: an edge can have a different number of lanes in each direction.
**State snapshot (2026-07-30)** — re-verified against the working tree:
`editSceneEditor` builds cleanly. Nineteen headless tests pass. Milestone 5
has all 13 sub-items implemented, but the verification audit
(`TerrainML5Verification.md`) identifies gaps in automated test coverage for
M5.9 (physics interaction), M5.10 (terrain compliance), and M5.11 (roadside
prefab spawning), plus a spec-vs-implementation discrepancy in M5.10's
perpendicular falloff. See `TerrainML5Verification.md` for the complete
verification plan, manual test procedures, and open questions.
**State snapshot (2026-08-16)** — re-verified against the working tree:
`editSceneEditor` builds cleanly. All 22 headless tests pass and
`ctest -R editSceneTerrainTest` is green. A code-level re-audit on
2026-08-16 (completion statuses were not trusted) found several items marked
done that were not actually working: M5.10's perpendicular falloff and the
M5.9.6 road-collider debug toggle were missing, the fixup chunk grid was too
dense to function (corrected in section 4.2), segment underside depth was
inconsistent, and roadside prefabs leaked on rebuild and were serialized
with the scene. All fixed and covered by tests; see
`TerrainML5Verification.md` section 6 for the full list. The manual
walkthroughs (`TerrainML5Verification.md` 3.13.7) still require a display
and remain pending.
| Item | State | Notes |
|------|-------|-------|
@@ -2139,9 +2173,9 @@ verification plan, manual test procedures, and open questions.
| M5.7 Edge length constraint | ✅ complete | `snapToIntegerLength()` + `ROAD_MIN_EDGE_LENGTH`; `splitEdge` snaps, `joinNodes` warns, `validate` rejects short edges; `roadEdgeLength` test green |
| M5.8 Mesh assembly per page | ✅ complete | page entities with `TriangleBufferComponent(proceduralContent)` + `RenderableComponent` + `NavMeshGeometrySource` + `LodComponent`, `roadPageMeshes` test |
| M5.9 Road physics colliders | ✅ complete | `createPageCollider`/`destroyPageCollider` in `RoadSystem`, asserted in `roadPageMeshes` |
| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29) | `writeFixup`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; `fixupChunks` test green |
| M5.10 Terrain compliance | ✅ DONE (2026-07-29) | `RoadSystem::complyTerrain()` walks road wedges/segments, writes fixup under each top-surface vertex; "Comply Terrain to Roads" button wired; works with M5.9.5 |
| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload/rebuild |
| M5.9.5 Fixup chunk support | ✅ DONE (2026-07-29; grid corrected 2026-08-16) | `writeFixup`/`writeFixupSample`/`saveFixups`/`clearAllFixups`/`sampleFixupLocked` implemented in `TerrainSystem.cpp`; wired into `sampleHeightAtLocked`; "Clear All Fixups" UI button; chunk span corrected to the full per-page `worldSize` (see 4.2); `fixupChunks` test green |
| M5.10 Terrain compliance | ✅ DONE (2026-07-29; falloff added 2026-08-16; clearance-hardened 2026-08-22; constraint-solver rework 2026-08-30) | `RoadSystem::complyTerrain()` collects road top-surface triangles and solves "rendered terrain stays 0.05 below the road top" as linear constraints on the rendered page-lattice vertices (actual per-row triangulation diagonal), with damped Kaczmarz lower-only sweeps plus a raise-only relaxation so the roadbed follows curved roads without phantom-diagonal over-excavation; per-vertex lowering is written via `TerrainSystem::lowerFixupCorners()` (sentinel corners materialize at the current surface, preserving the untouched surface exactly); "Comply Terrain to Roads" button wired; inverse `RoadSystem::complyRoadsToTerrain()` ("Comply Roads to Terrain") lifts/sinks nodes so edges stay `roadThickness + 0.05` above the terrain (constraints sampled against the rendered lattice surface, filtered to the actual road XZ footprint, solved with Kaczmarz deficit distribution + alternating lower relaxation and pairwise rebalancing); `terrainCompliance`, `terrainComplianceClearance` and `complyRoadsToTerrain` tests green |
| M5.11 Roadside prefab spawning | ✅ DONE (2026-07-30; respawn fix 2026-08-16) | `RoadSystem::spawnSidePrefabs()` creates instances via `PrefabSystem` at edge positions with terrain-snapped Y; tracked and destroyed on page unload AND on mesh rebuild; spawned instances are stripped of `EditorMarkerComponent` so they stay runtime-only; `roadSidePrefabs` test green |
| M5.12 Serialization + wiring | ✅ complete | serialization round-trip for roadConfig/nodes/edges/sidePrefabs; lifecycle + page detection + mesh/collider/prefab creation through M5.8/M5.9/M5.11; fixup save wired into SceneSerializer |
#### M5.1 Road data model
@@ -2489,8 +2523,10 @@ default `LESS` depth test) but double the mesh memory, draw calls, collider
bodies, and navmesh input. The `roadVisibilityDistance` expansion is kept only
for the page world AABB used in navmesh dirty marking (M5.8). Trade-off
accepted: a wedge disappears if its seed page unloads while a neighboring page
stays loaded — in the editor all pages are loaded synchronously, so this only
matters for future runtime paging.
stays loaded — with `streamingEnabled` pages genuinely stream in/out around
the camera, so this trade-off is live (the hold radius keeps a margin of
loaded pages beyond the load radius to limit it); in legacy mode all pages
are loaded synchronously and it never triggers.
**Navigation mesh input**: Road surfaces are walkable, so every generated road
page entity must be discoverable by `NavMeshSystem`. The `TriangleBufferComponent`
@@ -2923,8 +2959,10 @@ writers yet.
sentinel `-FLT_MAX` = "no fixup here" (fall through to base heightmap +
detail noise).
- Chunk naming/addressing: `x<chunkX>_z<chunkZ>.bin`,
`chunkX = floor(worldX / (worldSize / 256))` (same for Z); chunk (0,0)
covers world (0,0)..(worldSize/256, worldSize/256).
`chunkX = floor(worldX / worldSize)` (same for Z); chunk (0,0) covers one
full terrain page — one sample per `worldSize/256` units. **Updated
2026-08-16**: the original `worldSize / 256` chunk span made the grid
unfillably dense; see the correction note in section 4.2.
- Lazy creation: a chunk object/file appears only when a writer first writes
into it; absent chunks mean "no fixup".
- Runtime storage lives in `TerrainSystem` (in-memory chunk map keyed by chunk
@@ -2950,18 +2988,108 @@ writers yet.
#### M5.10 Terrain compliance (conform, not flatten)
**Status (2026-07-29): ✅ DONE.** `RoadSystem::complyTerrain()` walks every
wedge and straight segment, samples the top-surface vertices, writes
`roadSurfaceY - roadThickness` into the fixup chunk at each (X,Z), applies a
smooth perpendicular falloff over `laneWidth * 2`, then marks affected pages
dirty and saves the fixups. The "Comply Terrain to Roads" button in
`TerrainEditor` is wired and functional.
**Status (2026-08-30): ✅ DONE, constraint-solver rework.**
`RoadSystem::complyTerrain()` collects the road top-surface triangles
and solves "rendered terrain stays `ROAD_COMPLIANCE_SAG` (0.05 m) below
the road top" as linear constraints on the rendered page-lattice vertex
heights (see the 2026-08-30 note below), then writes the per-vertex
lowering as fixups via `TerrainSystem::lowerFixupCorners()`, marks
affected pages dirty and saves the fixups. The "Comply Terrain to
Roads" button in `TerrainEditor` is wired and functional.
(Supersedes the 2026-08-16 per-texel slab-underside writes with
perpendicular falloff and the 2026-08-22 clamp/cap passes; the original
2026-07-29 ✅ was wrong — the falloff did not exist and the fixup grid
was too dense for page meshes to see the writes.)
**2026-08-22 clearance hardening** (covered by the
`terrainComplianceClearance` headless test, which replicates
`terrain2_test.json`): guarantees the rendered terrain never
rises above the road top.
**2026-08-30 constraint-solver rework** (supersedes the earlier
clamp/cap passes): compliance is now handled as an iterative
constraint-solving problem on the *rendered page-lattice* vertices —
one vertex per `worldSize/(terrainSize-1)` (e.g. 31.25 m), the only
heights the renderer interpolates between:
- *Constraint collection* (`collectComplianceConstraints`): every road
top-surface triangle (wedges, sidewalk wedges, segment and sidewalk
band quads) is clipped against each lattice cell it overlaps; each
candidate maximum point (cell-corner polygon vertices plus the
polygon-edge intersections with the cell's *actual* triangulation
diagonal — the renderer/collider zigzag by row parity, not both
diagonals) contributes one linear constraint on the 4 cell-corner
heights with barycentric weights and target `roadTop -
ROAD_COMPLIANCE_SAG` (0.05 m). Constraining the phantom second
diagonal fabricated violations up to metres high and caused massive
over-excavation far from the road.
- *Phase 1 — damped Kaczmarz lower-only sweeps*: each violated
constraint lowers its corners by the minimal-norm share of the excess
(shares proportional to the barycentric weights). Lowering can never
violate a constraint, so the sweeps converge to feasibility.
- *Phase 2 — raise-only relaxation*: every vertex is repeatedly set to
the highest value its constraints allow given the current neighbours
(capped at the natural height), recovering the hysteresis overshoot of
phase 1 (a vertex lowered while its neighbours were still high would
otherwise stay low) so the roadbed follows the road curvature instead
of collapsing flat to the lowest road nearby.
- *Fixup application* (`TerrainSystem::lowerFixupCorners`): the
per-vertex lowering is written as fixups on the 4 samples blending
into that lattice vertex; sentinel (unwritten) corners materialize at
the *current* surface height, so the post-write blend at the vertex
equals `natural - delta` exactly instead of snapping to texel-corner
naturals (which could even raise the terrain).
The footprint stays within the lattice cells touching the road (only
lowering past the natural height never happens elsewhere) instead of
flattening the whole corridor.
**Comply Roads to Terrain** (`RoadSystem::complyRoadsToTerrain()`, button
next to "Comply Terrain to Roads", covered by the `complyRoadsToTerrain`
headless test): the inverse operation — shifts road nodes vertically (down
or up) so every edge stays at least `roadThickness + 0.05` above the current
terrain. The road surface along a half-edge is linear between the node
surface and the edge midpoint, so every sampled terrain point yields one
linear constraint `coef*Yn + w*Ynb >= required` on the two endpoint node
heights. Constraints are sampled against `renderedHeightAt()` — the
rendered page-lattice surface evaluated with the actual per-row cell
triangulation diagonal the renderer/collider use — at dense 0.5 m
intervals along the centreline and both curb lines (extended backwards
over node wedges/end caps), at every lattice-line crossing of those
lines, and at every page vertex inside the road footprint (lanes plus
sidewalk only when enabled). Because the sampling rectangle around a
half-edge sticks out past the wedge fan / end cap behind a node (by up
to `halfWidth * sqrt(2)` at the corners), every sample is then filtered
against the actual XZ footprint of the road top surface (the same
wedge/band triangles `complyTerrain` collects, in a coarse lookup grid)
— terrain bumps outside the road mesh would otherwise lift nodes over
ground the road never covers. Connected nodes are initialized to
terrain + elevation and the constraint system is solved iteratively in
three phases: (1) Kaczmarz projection sweeps raise nodes to feasibility,
distributing each violated constraint's deficit between the node and its
neighbour along the constraint normal (minimum-norm correction) — a
per-node "raise to the max lower bound" update instead corners the
solution onto whichever node the update order hits first (a constraint
with a small neighbour coefficient is most cheaply satisfied by raising
the *other* node), leaving metres of hover over the far end; (2)
alternating damped lower-only relaxation (tightens nodes against the
terrain, clearing the slack the projection phase leaves on constraints
processed before their neighbours rose) and pairwise rebalancing (a
binding constraint with unequal coefficients is rebalanced by lowering
its small-coefficient node while raising the large-coefficient node to
compensate, which keeps the constraint satisfied and strictly reduces
the total node height — plain lowering alone sticks at such LP corners);
(3) a few raise-only passes clear the residual violations the
simultaneous damping of phase 2 can introduce. Node
`verticalOffset` values are re-derived from the terrain and the graph
version is bumped on change.
The terrain must hug the underside of the road: it should not poke through the
road surface and should not leave gaps beneath it. The road surface itself is
not required to be flat — it follows the node heights and edge slopes.
**Algorithm**:
**Algorithm** (original per-texel design, superseded 2026-08-30 by the
lattice constraint solver described in the rework note above):
1. For every point on the generated road surface (edge samples + wedge samples),
compute the world position `roadSurfacePos` of the road top.
@@ -2985,35 +3113,41 @@ simplified representation, so the terrain matches the exact road surface.
#### M5.11 Roadside prefab spawning
**Status (2026-07-30): ✅ DONE.** `RoadSystem::spawnSidePrefabs()` iterates
all edges' side prefabs during page finalize, computes world positions (edge
interpolation + lateral offset), snaps Y to terrain via `TerrainSystem`, and
calls `PrefabSystem::createInstance()`. Spawned entities are tracked in
`RoadPageGeometry::spawnedPrefabs` and destroyed on page unload/rebuild.
**Status (2026-08-16): ✅ DONE; reworked 2026-08-18 (see M5.13).**
Roadside prefabs spawn per edge from the edge's three prefab slots
(`prefabLeft`/`prefabRight`/`prefabMid`, replacing the original
`RoadSidePrefab` vector), are distance-gated with a camera hysteresis,
and are tracked in per-edge spawn records (`RoadSystem::m_edgePrefabs`)
instead of per-page lists — the original page-attached tracking
duplicated instances on edges crossing page boundaries. Spawned
instances are stripped of `EditorMarkerComponent` so they stay
runtime-only and are not serialized with the scene (M5.12).
For each `RoadEdge::RoadSidePrefab`:
For each configured `RoadEdgePrefabSlot`:
1. Compute base position along the edge:
1. Compute the anchor on the edge:
```cpp
Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, prefab.edgeT);
Ogre::Vector3 pos = lerp(nodeA.position, nodeB.position, slot.edgeT);
```
2. Compute lateral offset:
2. Shift to the curb and apply the lateral offset (left of the A→B
travel direction for `prefabLeft`, right for `prefabRight`, on the
centerline for `prefabMid`):
```cpp
Ogre::Vector3 dir = (nodeB.position - nodeA.position);
dir.y = 0;
dir.normalise();
Ogre::Vector3 side = Ogre::Vector3::UNIT_Y.crossProduct(dir);
if (!prefab.leftSide) side = -side;
pos += side * prefab.sideOffset;
Ogre::Vector3 left = Ogre::Vector3::UNIT_Y.crossProduct(dir);
pos += left * (sign * (halfWidth + slot.lateralOffset));
```
3. Snap Y to terrain: `pos.y = TerrainSystem::getInstance()->getHeightAt(pos)`.
4. Instantiate via `PrefabSystem::createInstance(prefabPath, terrainEntity,
pos, name, uiSystem)`.
5. Track spawned entities in `RoadPageGeometry::spawnedPrefabs` and destroy them
when the page geometry is rebuilt **or when the terrain page is unloaded**.
3. Y follows the interpolated road surface height (node Y + road level),
plus `slot.yOffset`.
4. Instantiate via `PrefabSystem::createInstance(prefabPath,
flecs::entity::null(), pos, name)` (root-level, so terrain teardown
leaves no dangling scene nodes) and align the prefab's -Z forward
with the edge direction.
5. Destroy instances when the camera leaves the despawn radius, when
the slot data changes, when the edge disappears, or on terrain
teardown — via the shared `PrefabSystem::destroyInstance()`.
Roadside prefabs are runtime-only and page-attached; they are regenerated from
edge data when the page is loaded.
Roadside prefabs are runtime-only; they are regenerated from edge data
whenever the spawn conditions hold.
---
@@ -3043,10 +3177,137 @@ edge data when the page is loaded.
---
#### M5.13 Road prefab slots and distance unload
**Status (2026-08-18): ✅ DONE** (improvement plan §3;
`ProceduralRoadGeometryImprovement.md`).
The unbounded `RoadEdge::sidePrefabs` vector was replaced by three fixed
`RoadEdgePrefabSlot` slots — `prefabLeft`, `prefabRight`, `prefabMid` — each
with `prefabPath`, `edgeT`, `lateralOffset` (relative to the curb) and
`yOffset` (relative to the road surface). Legacy `sidePrefabs` scene data
migrates on load (first `leftSide` entry becomes `prefabLeft`, etc.).
1. Spawn state lives in `RoadSystem::m_edgePrefabs`, one
`EdgePrefabRecord` per edge keyed by the ordered node-id pair — never per
page (the old per-page tracking duplicated instances on cross-page edges).
2. Spawn/despawn re-evaluates every frame with a camera-distance hysteresis
(`RoadConfig::prefabSpawnDistance`/`prefabDespawnDistance`, serialized);
distance is measured to the edge segment, and both endpoint pages must be
loaded.
3. Slot data changes (any field) respawn the edge's instances; a failed spawn
(missing file) is not retried until data or conditions change.
4. Road edit mode force-spawns the selected edge's prefabs regardless of
distance, so slot edits preview immediately.
5. Instances spawn at the root level with their -Z forward aligned to the
edge direction, and are destroyed through the shared
`PrefabSystem::destroyInstance()` (children, rigid bodies, scene nodes).
Verified by `testRoadSidePrefabs`, `testRoadDataModel` and
`testRoadSerialization` (`--headless --run-terrain-tests=1`).
---
#### M5.14 Edge split polish
**Status (2026-08-18): ✅ DONE** (improvement plan §1).
1. Every row of the Edges list has its own "Split" button — splitting no
longer requires selecting the edge first (the toolbar "Split Selected
Edge" button was removed).
2. The new midpoint node's Y is re-snapped to the terrain surface (split
interpolates Y between the endpoints, which cuts across terrain).
3. `splitEdge` remaps the prefab slots: left/right slots keep their curb side
on both halves (`edgeT` recomputed into the half's range); the mid slot
goes to the half containing the original anchor (t = 0.5).
Verified by `testRoadDataModel` (slot remap cases).
---
#### M5.15 Sidewalks
**Status (2026-08-18): ✅ DONE** (improvement plan §2; specified in
`ProceduralRoadGeometry.md` §15).
Elevated pedestrian strips along both curbs, appended to the road's page
geometry (inheriting its material, LOD, collider soup and navmesh dirtying).
Lane counts — and therefore the drivable width — are unaffected.
1. `RoadConfig`: `sidewalkEnabled` (default off), `sidewalkWidth`,
`sidewalkHeight`, `sidewalkThickness`, `sidewalkMeshTemplate`; serialized
in the `roadConfig` block; UI in the terrain panel "Road Config" tree.
2. Per wedge, one strip along the outer curb via the three-phase pipeline
(`RoadGeometryLib::buildSidewalkGeometry`): the strip spans two widened
mitered curb chains (inner: curb + `ROAD_SIDEWALK_WALL_GAP`, outer:
+ `sidewalkWidth`), so inner corners miter correctly without folding.
Top surface at `roadSurfaceY + sidewalkHeight`.
3. Template from `RoadSystem::getSidewalkTemplate()` (cached like
`getRoadTemplate`); empty/missing mesh yields a procedural box of
`sidewalkWidth × sidewalkThickness` profile with its top at template Y=0.
4. Dead-end straight segments get two bands (inbound and outbound curb) via
`RoadGeometryLib::computeSegmentSidewalkBand()` + `extrudeToSlab()`.
5. Terrain compliance: the perpendicular falloff starts at the sidewalk's
outer edge, and the full-compliance pass flattens the terrain under the
sidewalk bodies (fixup target = strip top - `sidewalkThickness`).
Verified by `testRoadWedgeGeometry` (case 6), `road_geometry_overlap_test`
(sidewalks enabled in all regression configurations) and
`testRoadSerialization` (config round-trip).
---
#### M5.16 Node connect tool
**Status (2026-08-18): ✅ DONE** (improvement plan §4).
1. `RoadGraph::connectNodes(nodeA, nodeB, worldSize, &error, &createdNodes)`
connects two existing nodes with validation: missing nodes, self-edges,
duplicates and wedge-angle violations are rejected and leave the graph
unchanged (angle check rolls back the inserted edges/nodes). Page
membership uses the origin-centred slot math of
`TerrainGroup::convertWorldPositionToTerrainSlot` (slot (0,0) spans
-worldSize/2 .. +worldSize/2), so edges crossing the world X/Z axes are
not falsely reported as cross-page. A genuine cross-page connection is
not rejected either: a node is inserted at every page-boundary crossing
along the segment (corner crossings deduplicated) and the edge is built
as a chain through them, keeping every edge inside one page; the
inserted node IDs are reported through `createdNodes` and the editor
re-snaps their Y to the terrain (`RoadSystem::snapNodesToTerrain`).
2. New "Connect" `RoadEditTool` radio: the first click pins a source node,
a click on a second node connects them; the second node becomes the new
source so paths chain with repeated clicks. Rejections surface as a modal
("Road Connect Failed").
3. The node-list "Connect" buttons use `connectNodes` as well (previously
the unchecked `joinNodes`).
4. ESC exits road edit mode (same convention as sculpt/paint/prefab modes).
Verified by `testRoadDataModel` (connectNodes acceptance/rejection cases).
---
#### M5.17 Corner smoothing
**Status (2026-08-18): ✅ DONE** (improvement plan §5).
1. `RoadGraph::smoothNode(nodeId, strength, worldSize)` relaxes a degree-2
node toward the midpoint of its two neighbors (position relaxation, not
chamfering): XZ lerps by `strength`, `verticalOffset` lerps toward the
anchors' average. Moves that would break the minimum edge length, cross
a terrain page boundary, or create a wedge outside the 30270° limits are
rejected and leave the graph unchanged.
2. Node inspector UI: "Smooth Strength" slider + "Smooth Node" button for
degree-2 nodes (hint text otherwise); Y is re-snapped to the terrain after
the move, same as after a gizmo drag.
Verified by `testRoadDataModel` (smoothNode acceptance/rejection cases).
---
### Milestone 5 definition of done
- [x] M5.1 — Road data model: `RoadConfig`, `RoadNode`, `RoadEdge`, and
`RoadSidePrefab` live in `components/RoadGraph.hpp` with detailed
`RoadEdgePrefabSlot` live in `components/RoadGraph.hpp` with detailed
purpose annotations; `TerrainComponent` owns a `RoadGraph`; helpers for
node lookup, edge enumeration, lane-count resolution, ID generation,
and graph validation are available and covered by tests.
@@ -3074,9 +3335,10 @@ edge data when the page is loaded.
- [x] "Comply Terrain to Roads" makes the terrain follow the road underside
(sloped/curved where the road is sloped/curved) without gaps or
intersections (M5.10, `RoadSystem::complyTerrain()` wired).
- [x] Roadside prefabs spawn at configured edge positions (Y snapped to terrain
surface) and are destroyed on page unload/rebuild; scene load regenerates
them (M5.11, `RoadSystem::spawnSidePrefabs()` via `PrefabSystem`).
- [x] Roadside prefabs spawn at configured edge positions (Y follows the road
surface) and are destroyed on distance unload/data change/teardown;
scene load regenerates them (M5.11 + M5.13, per-edge records via
`PrefabSystem::createInstance`/`destroyInstance`).
- [x] Save/reload round-trip preserves road nodes, edges, config, and side
prefabs (verified by `TerrainTests.cpp` headless test).
- [x] Road meshes and colliders are created when a terrain page loads and
@@ -3086,19 +3348,60 @@ edge data when the page is loaded.
- [x] M5.7 — Edge length constraint: `splitEdge` snaps to integer half-lengths,
`joinNodes` warns on short edges, `validate` rejects edges < 1 unit
(verified by `testRoadEdgeLengthConstraint`).
- [x] M5.13 — Road prefab slots: three fixed slots per edge, per-edge spawn
records, camera-distance hysteresis, edit-mode preview (verified by
`testRoadSidePrefabs`).
- [x] M5.14 — Edge split polish: per-row Split buttons, terrain Y re-snap,
prefab slot remapping (verified by `testRoadDataModel`).
- [x] M5.15 — Sidewalks: elevated curb strips via widened mitered curb chains,
segment bands, template + config UI, terrain compliance (verified by
`testRoadWedgeGeometry` case 6 and `road_geometry_overlap_test`).
- [x] M5.16 — Node connect tool: validated `connectNodes`, 3D Connect tool
with chaining, error modal, ESC exits road edit mode (verified by
`testRoadDataModel`).
- [x] M5.17 — Corner smoothing: `smoothNode` position relaxation with
structural guards, node inspector UI (verified by `testRoadDataModel`).
**Overall M5 status**: ✅ ALL 13 sub-items complete (M5.1M5.12).
**Overall M5 status**: ✅ ALL 18 sub-items complete (M5.1M5.17).
### Milestone 6 — Prefab spawns and terrain compliance
- `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`.
- `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via
`PrefabSystem`.
- Terrain-snap at placement (raycast against terrain).
- Terrain compliance tool: flatten terrain under prefab footprint using fixup
chunks.
- [x] `TerrainPrefabSpawnerComponent` + `TerrainPrefabSpawnerModule`
(`components/TerrainPrefabSpawner.hpp`,
`components/TerrainPrefabSpawnerModule.cpp`).
**Deviation from 6.2**: world position/rotation live on the entity's
`TransformComponent` (single transform source, same precedent as
`CharacterSpawnerComponent`); the component only stores `prefabPath`,
`spawnDistanceSq`, `despawnDistanceSq`, and the runtime
`spawnedEntity` handle.
- [x] `TerrainPrefabSpawnerSystem` with distance-based spawn/despawn via
`PrefabSystem` (`systems/TerrainPrefabSpawnerSystem.hpp/.cpp`).
Per-spawner camera hysteresis: distance re-evaluation is skipped while
the camera moved < 10 units and neither the spawner transform nor its
parameters changed (6.3 item 4). Prefab-path changes force a respawn;
an `OnRemove` observer despawns the instance when the spawner
component/entity is removed. Spawned instances are runtime-only:
`EditorMarkerComponent` is stripped and the instance is removed from
the editor UI caches, so they are never serialized.
- [x] Terrain-snap at placement (6.4): `snapToTerrain()` sets the spawner's
Y from `TerrainSystem::getHeightAt()` at placement time, on spawn, and
when the spawner is moved in the editor.
- [x] Terrain compliance tool (6.5): `complyTerrainToPrefab()` flattens the
terrain under the spawned prefab's world-AABB footprint using fixup
chunks (linear falloff band written first, full flatten under the
footprint second — same ordering as road compliance), marks affected
pages dirty, and saves the fixups.
- [x] Editor integration (7.2): prefab spawn mode with click-to-place on the
terrain (Terrain editor "Prefab Spawners" section, ESC to exit,
mutually exclusive with sculpt/paint/aux/road modes),
`TerrainPrefabSpawnerEditor` property panel with prefab picker,
Snap-to-terrain and Flatten buttons.
- [x] Scene serialization under the `terrainPrefabSpawner` key with plain
(non-squared) distances, Lua binding `TerrainPrefabSpawner`.
- [x] Headless test `testTerrainPrefabSpawners` in `TerrainTests.cpp`
(spawn + snap + runtime-only checks, serialization round-trip,
compliance flatten, distance despawn, respawn, observer cleanup).
Definition of done: towns/rocks spawn at configured locations on terrain and sit
flush; save/load round-trips correctly.
**Overall M6 status**: ✅ complete (verified by `--run-terrain-tests`).
## 11. Risks and Mitigations
@@ -3135,8 +3438,8 @@ flush; save/load round-trips correctly.
- [ ] Road page unload → road collider and navmesh contribution are removed.
- [ ] Per-edge lane override → edge with `lanesAtoB=2` renders two lanes A→B.
- [ ] Save/reload scene with roads → nodes, edges, and config restored.
- [ ] Place prefab spawner → prefab appears at correct distance and sits on terrain.
- [ ] Move camera far away and back → prefab despawns and respawns correctly.
- [x] Place prefab spawner → prefab appears at correct distance and sits on terrain.
- [x] Move camera far away and back → prefab despawns and respawns correctly.
- [ ] Terrain reflected in water → terrain visible in reflection RTT pass.
- [ ] Height function round-trip: write known heights → sample them back → values match.
- [ ] Fixup chunk persistence: write fixup → save → reload → fixup still applied.
+51 -1
View File
@@ -1,5 +1,7 @@
#include "EditorCamera.hpp"
#include <OgreViewport.h>
#include <cmath>
#include <algorithm>
EditorCamera::EditorCamera(Ogre::SceneManager *sceneMgr,
Ogre::RenderWindow *window)
@@ -102,7 +104,8 @@ void EditorCamera::updateFPSMovement(float deltaTime)
// Apply movement
if (movement.squaredLength() > 0.0001f) {
movement.normalise();
m_target += movement * FPS_SPEED * deltaTime;
float speed = m_flySpeed * (m_keyShift ? SHIFT_BOOST : 1.0f);
m_target += movement * speed * deltaTime;
m_targetNode->setPosition(m_target);
m_cameraMan->setTarget(m_targetNode);
}
@@ -163,6 +166,7 @@ void EditorCamera::handleMouseRelease(const OgreBites::MouseButtonEvent &evt)
false; // Disable FPS mode when right mouse is released
// Reset key states
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
m_keyShift = false;
} else if (evt.button == OgreBites::BUTTON_MIDDLE) {
m_panning = false;
}
@@ -198,9 +202,28 @@ void EditorCamera::handleKeyboard(const OgreBites::KeyboardEvent &evt)
case 'E':
m_keyE = pressed;
break;
case OgreBites::SDLK_LSHIFT:
m_keyShift = pressed;
break;
}
}
void EditorCamera::handleMouseWheel(float y)
{
if (y == 0.0f)
return;
m_distance *= std::pow(0.85f, y);
if (m_distance < 0.05f)
m_distance = 0.05f;
if (m_distance > 500000.0f)
m_distance = 500000.0f;
}
void EditorCamera::setFlySpeed(float s)
{
m_flySpeed = std::max(1.0f, std::min(100000.0f, s));
}
void EditorCamera::focusOn(const Ogre::Vector3 &point)
{
m_target = point;
@@ -215,6 +238,15 @@ void EditorCamera::setPosition(const Ogre::Vector3 &pos)
m_cameraMan->setTarget(m_targetNode);
}
void EditorCamera::shiftPosition(const Ogre::Vector3 &offset)
{
m_position += offset;
m_target += offset;
m_targetNode->setPosition(m_target);
m_cameraNode->setPosition(m_cameraNode->getPosition() + offset);
m_cameraMan->setTarget(m_targetNode);
}
Ogre::Ray EditorCamera::getMouseRay(float screenX, float screenY) const
{
// Convert pixel coordinates to normalized viewport coordinates (0-1)
@@ -235,3 +267,21 @@ void EditorCamera::updateCameraPosition()
m_cameraMan->setYawPitchDist(Ogre::Degree(m_yaw), Ogre::Degree(m_pitch),
m_distance);
}
void EditorCamera::resetPose()
{
m_position = Ogre::Vector3(0, 5, 15);
m_target = Ogre::Vector3(0, 0, 0);
m_distance = 15.0f;
m_yaw = 0.0f;
m_pitch = -20.0f;
m_fpsMode = false;
m_rotating = false;
m_panning = false;
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
m_keyShift = false;
m_cameraMan->setStyle(OgreBites::CS_ORBIT);
m_cameraMan->setTarget(m_targetNode);
updateCameraPosition();
}
+48 -1
View File
@@ -49,6 +49,43 @@ public:
*/
void setPosition(const Ogre::Vector3 &pos);
/**
* Shift camera and target by a render-space offset (used by
* RenderOriginSystem on rebase)
*/
void shiftPosition(const Ogre::Vector3 &offset);
/**
* Orbit target (render space)
*/
Ogre::Vector3 getTarget() const
{
return m_target;
}
/**
* Orbit distance
*/
float getDistance() const
{
return m_distance;
}
/**
* Mouse wheel: zoom the orbit distance (multiplicative).
*/
void handleMouseWheel(float y);
/**
* Fly (FPS-mode) speed in units/second; Shift boosts x10.
* Range 1 .. 100000 (100 km/s) for streamed-world navigation.
*/
float getFlySpeed() const
{
return m_flySpeed;
}
void setFlySpeed(float s);
/**
* Get camera position
*/
@@ -70,6 +107,14 @@ public:
return m_fpsMode;
}
/**
* Reset the camera to the default startup pose (position (0,5,15),
* target origin, yaw 0, pitch -20, distance 15) and clear any input
* state. Used by scene switching so an editor-mode scene switch
* looks like a fresh editor start.
*/
void resetPose();
private:
void updateCameraPosition();
void updateFPSMovement(float deltaTime);
@@ -103,11 +148,13 @@ private:
bool m_keyD;
bool m_keyQ;
bool m_keyE;
bool m_keyShift = false;
// Movement speeds
static constexpr float ROTATION_SPEED = 0.3f;
static constexpr float PAN_SPEED = 0.01f;
static constexpr float FPS_SPEED = 10.0f;
float m_flySpeed = 10.0f; // units/second, see setFlySpeed
static constexpr float SHIFT_BOOST = 10.0f;
};
#endif // EDITSCENE_EDITORCAMERA_HPP
@@ -51,6 +51,19 @@
* (for quest rewards, etc.).
* params="itemId,itemName,itemType,count,weight,value"
*
* --- Scene switch node ---
* "switchScene" - Leaf: queues a scene switch via
* EditorApp::switchScene (name=scene file path).
* The switch is deferred to the start of the next
* frame, so it is safe from AI and actuator trees.
* params (optional):
* "@EntityName" - teleport the player to a named
* entity with a Transform component
* in the new scene
* "x,y,z" - teleport to a world-space position
* "x,y,z,yaw" - position + yaw in degrees
* Returns success when the switch was queued.
*
* --- Lua node ---
* "luaTask" - Leaf: calls a registered Lua function.
* name = registered node handler name.
@@ -105,6 +118,7 @@ struct BehaviorTreeNode {
type == "hasItemByName" || type == "countItem" ||
type == "pickupItem" || type == "dropItem" ||
type == "useItem" || type == "addItemToInventory" ||
type == "switchScene" ||
type == "luaTask";
}
};
@@ -1,5 +1,28 @@
#include "CellGrid.hpp"
#include <algorithm>
#include <random>
#include <cstdio>
const std::string& CellGridComponent::ensureGridUid()
{
if (!gridUid.empty())
return gridUid;
// Random UUID-like hex string (8-4-4-4-12), no external dependency.
std::random_device rd;
std::mt19937_64 gen(((uint64_t)rd() << 32) ^ (uint64_t)rd());
uint64_t a = gen(), b = gen();
char buf[40];
snprintf(buf, sizeof(buf), "%08x-%04x-%04x-%04x-%04x%08x",
(unsigned)(a & 0xffffffffu),
(unsigned)((a >> 32) & 0xffffu),
(unsigned)(((a >> 48) & 0x0fffu) | 0x4000u), // version 4
(unsigned)((b & 0x3fffu) | 0x8000u), // variant 1
(unsigned)((b >> 16) & 0xffffu),
(unsigned)((b >> 32) & 0xffffffffu));
gridUid = buf;
return gridUid;
}
Cell* CellGridComponent::findCell(int x, int y, int z)
{
@@ -3,6 +3,7 @@
#include <cstdint>
#include <vector>
#include <string>
#include <map>
#include <unordered_map>
#include <chrono>
#include <flecs.h>
@@ -105,6 +106,38 @@ struct FurnitureCell {
}
};
/**
* @brief Per-doorway configuration override (F0).
*
* Stored in CellGridComponent::doorConfigs keyed by the canonical doorway
* edge key ("X:x:y:z" / "Z:x:y:z" - the same key buildDoorEntities() uses
* to deduplicate doorways, see CellGridSystem::doorEdgeKey()). Entries
* override the grid-wide door* defaults for one specific doorway and
* survive scene loads and grid rebuilds. Entries whose doorway no longer
* exists are "orphaned": they are kept (never auto-deleted) until pruned
* or reassigned in the Cell Grid editor.
*/
struct CellGridDoorConfig {
// Identity / UX
std::string label; // user-visible name ("Kitchen door")
// Behaviour overrides (grid defaults apply when hasOverride is false)
bool hasOverride = false;
float openAngle = 100.0f; // copied from grid on first override
float openSpeed = 180.0f;
bool swingReversed = false; // F3
std::string actionName;
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
bool disabled = false; // spawn no door entity for this doorway
// Persistence / locking (F6)
bool persistent = false; // track state in the global storage
bool lockable = false;
bool lockedByDefault = false;
std::string keyItemId; // item that unlocks this door
};
/**
* @brief Cell grid for procedural building generation
*
@@ -114,6 +147,39 @@ struct FurnitureCell {
* Used by: House/Lot generation, dungeon generation
*/
struct CellGridComponent {
// Stable identity of this grid (F0): a UUID generated when the
// component is created or first serialized. Door global IDs are
// "<gridUid>:<edgeKey>", so they survive grid entity renames.
std::string gridUid;
// Per-doorway configuration overrides keyed by canonical edge key
// (see CellGridDoorConfig). Serialized inside this component.
std::map<std::string, CellGridDoorConfig> doorConfigs;
// Generation mode (F4/F5, serialized as a string):
// "full" - default, the whole building
// "interiorOnly" - skip the exterior shell (external walls, external
// window panels + frames, roofs, external corners);
// floors, ceilings, internal walls/frames, furniture,
// exit door wall panels + frames and all door entities
// are still generated (paired exterior scene handles
// the shell)
// "exteriorOnly" - F5: only the exterior shell
std::string generationMode = "full";
bool interiorOnlyMode() const { return generationMode == "interiorOnly"; }
bool exteriorOnlyMode() const { return generationMode == "exteriorOnly"; }
// Window glass (F5, exteriorOnly/interiorOnly): window openings get an
// opaque glossy pane (exteriorOnly: every external window; interiorOnly:
// boundary windows) that hides the missing half of the building and is
// part of the static collider set, so the player cannot climb through
// windows. The built-in material is used when glassMaterialName is
// empty; the glassColor alpha is ignored by it (opaque).
Ogre::ColourValue glassColor = Ogre::ColourValue(0.4f, 0.6f, 0.8f, 0.35f);
std::string glassMaterialName; // empty = built-in CellGridGlass
float glassReflectivity = 0.8f; // built-in material gloss [0..1]
// Grid dimensions (in cells)
int width = 10; // X dimension
int height = 1; // Y dimension (floors)
@@ -145,6 +211,23 @@ 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)
bool doorSwingReversed = false; // F3: swing to the other side
std::string doorActionName; // Optional action run on activation
// Scene switching doors (e.g. interior <-> exterior): when
// doorSceneSwitchPath is set, activating a door queues an
// 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;
@@ -176,6 +259,10 @@ struct CellGridComponent {
// Mark for rebuild
void markDirty() { dirty = true; version++; }
// Return gridUid, generating a random UUID first when empty.
// Call before building door IDs or serializing.
const std::string& ensureGridUid();
// Convert local cell position to world position
Ogre::Vector3 cellToWorld(int x, int y, int z) const;
@@ -120,6 +120,10 @@ REGISTER_COMPONENT_GROUP("Room", "Room Layout", RoomComponent, RoomEditor)
if (e.has<RoomComponent>()) {
e.remove<RoomComponent>();
}
},
// On-modified: mark dirty so RoomLayoutSystem rebuilds the room
[](flecs::entity e) {
e.get_mut<RoomComponent>().markDirty();
}
);
}
@@ -158,6 +162,10 @@ REGISTER_COMPONENT_GROUP("Clear Area", "Room Layout", ClearAreaComponent, ClearA
if (e.has<ClearAreaComponent>()) {
e.remove<ClearAreaComponent>();
}
},
// On-modified: mark dirty so RoomLayoutSystem rebuilds the clear area
[](flecs::entity e) {
e.get_mut<ClearAreaComponent>().markDirty();
}
);
}
@@ -9,9 +9,10 @@
/**
* Character physics component
*
* Attaches a Jolt JPH::Character (kinematic capsule) to the entity.
* The entity may also have CharacterSlotsComponent; the character
* physics lives on the same entity as the visual character.
* Attaches a Jolt JPH::Character (dynamic capsule, translation-only)
* to the entity. The entity may also have CharacterSlotsComponent;
* the character physics lives on the same entity as the visual
* character.
*
* Child entities can add extra collision shapes via PhysicsColliderComponent.
*/
@@ -50,6 +51,12 @@ struct CharacterComponent {
float floorCheckDistance = 2.0f;
bool useGravity = true;
/* Ground contact state from JPH::Character::IsSupported(), refreshed
* by CharacterSystem every frame. Runtime only used to tell
* "floating/swimming" apart from "standing on the bottom in shallow
* water" (InWater alone cannot). */
bool isSupported = false;
/* Per-character collision group. Body = subgroup 0, head = subgroup 1,
* hair joints = 2+. Runtime only regenerated when character is rebuilt. */
uint32_t collisionGroupId = 0;
@@ -0,0 +1,23 @@
#include "CharacterIdentity.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/CharacterIdentityEditor.hpp"
// Register CharacterIdentity component
REGISTER_COMPONENT_GROUP("Character Identity", "Character",
CharacterIdentityComponent, CharacterIdentityEditor)
{
registry.registerComponent<CharacterIdentityComponent>(
"Character Identity", "Character",
std::make_unique<CharacterIdentityEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<CharacterIdentityComponent>())
e.set<CharacterIdentityComponent>(
CharacterIdentityComponent{});
},
// Remover
[](flecs::entity e) {
if (e.has<CharacterIdentityComponent>())
e.remove<CharacterIdentityComponent>();
});
}
@@ -0,0 +1,90 @@
#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)
bool swingReversed = false; // F3: swing to the other side (negates
// the applied angle; currentAngle and
// openAngle stay positive)
// Runtime: current swing angle (0 = closed)
float currentAngle = 0.0f;
// 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;
// Door identity (F0): canonical doorway edge key within the owning
// grid ("X:x:y:z" / "Z:x:y:z") and the global persistent ID
// "<gridUid>:<edgeKey>" (empty = ephemeral door, never persisted).
std::string edgeKey;
std::string doorId;
// Persistence / locking (F6, copied from the per-door
// CellGridDoorConfig at build time). The actual locked and
// open/closed state lives in the GlobalStateStore under
// "door.<doorId>.locked" / "door.<doorId>.isOpen"; these flags only
// say the door participates. See DoorSystem::isDoorLocked() /
// setDoorLocked().
bool persistent = false; // open/closed state is persisted
bool lockable = false; // can be locked/unlocked
std::string keyItemId; // inventory item that unlocks/locks it
// Scene switching (copied from CellGridComponent or the per-door
// CellGridDoorConfig override at build time):
// when sceneSwitchPath is set, activation swings the leaf open and
// the scene switch fires once the door is fully open (F1)
std::string sceneSwitchPath; // Target scene path (empty = disabled)
std::string sceneSwitchTarget; // Teleport target entity name
// F1: a scene-switch door first swings open; DoorSystem fires the
// switch (and the "door_scene_switch" event) only when the leaf
// reaches openAngle. Runtime only, set by ActuatorSystem.
bool sceneSwitchPending = false;
// F1: black open-front tunnel covering the doorway of a scene-switch
// door, created by the door builder on the VOID side of the doorway
// (door-local +Z outward from the owning cell; -Z for exteriorOnly
// grids); its depth covers the fully-open leaf sweep, so the leaf
// stays visible for both swing directions. Child of the grid node,
// so it does NOT swing with the hinge. Hidden while the door is
// fully closed. Runtime only.
Ogre::Entity *occluder = nullptr;
// F1: flat black gap-shield quad just behind the closed leaf of a
// scene-switch door (same void side as the tunnel), visible only
// while the door is fully closed - it blacks out the leaf/frame
// clearance slits (0.02 m sides, 0.05 m top) the tunnel, hidden
// while closed, does not cover. Runtime only.
Ogre::Entity *gapShield = nullptr;
};
#endif // EDITSCENE_DOOR_HPP
@@ -27,6 +27,10 @@ struct NavMeshComponent {
float regionMergeSize = 20.0f;
int tileSize = 48; // cells per tile
// F7: traversal cost multiplier for doorway polys (> 1 makes
// paths prefer doorless routes but still allows doorways)
float doorAreaCost = 5.0f;
// Runtime flags
bool enabled = true;
bool debugDraw = false;
@@ -2,6 +2,7 @@
#include "../ui/ComponentRegistration.hpp"
#include "../ui/NavMeshEditor.hpp"
#include "../ui/NavMeshGeometrySourceEditor.hpp"
#include "../systems/NavMeshSystem.hpp"
REGISTER_COMPONENT_GROUP("NavMesh", "Navigation", NavMeshComponent,
NavMeshEditor)
@@ -18,6 +19,13 @@ REGISTER_COMPONENT_GROUP("NavMesh", "Navigation", NavMeshComponent,
[](flecs::entity e) {
if (e.has<NavMeshComponent>())
e.remove<NavMeshComponent>();
},
// On-modified: sync the debug-draw toggle with the NavMesh system
[](flecs::entity e) {
auto &nav = e.get_mut<NavMeshComponent>();
if (nav.debugDraw && NavMeshSystem::getInstance())
NavMeshSystem::getInstance()->setDebugDraw(e,
nav.debugDraw);
});
}
@@ -0,0 +1,22 @@
#include "PhysicsCollider.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PhysicsColliderEditor.hpp"
// Register PhysicsCollider component
REGISTER_COMPONENT_GROUP("Physics Collider", "Physics",
PhysicsColliderComponent, PhysicsColliderEditor)
{
registry.registerComponent<PhysicsColliderComponent>(
"Physics Collider", "Physics",
std::make_unique<PhysicsColliderEditor>(sceneMgr),
// Adder
[](flecs::entity e) {
if (!e.has<PhysicsColliderComponent>())
e.set<PhysicsColliderComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PhysicsColliderComponent>())
e.remove<PhysicsColliderComponent>();
});
}
@@ -37,6 +37,14 @@ struct PlayerControllerComponent {
/* Runtime: set by ActuatorSystem while executing an action */
bool inputLocked = false;
/* Runtime: set by VehicleControllerSystem while the player is
* driving a vehicle; locomotion and actuator prompts are skipped. */
bool driving = false;
/* Locomotion state-machine state selected by VehicleControllerSystem
* while driving (animation_tree.json "locomotion" machine, currently
* mapped to the "sitting" animation); idleState is restored on
* exit. */
Ogre::String drivingState = "driving";
};
/**
@@ -0,0 +1,21 @@
#include "PrefabInstance.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PrefabInstanceEditor.hpp"
// Register PrefabInstance component
REGISTER_COMPONENT_GROUP("Prefab Instance", "Scene", PrefabInstanceComponent,
PrefabInstanceEditor)
{
registry.registerComponent<PrefabInstanceComponent>(
"Prefab Instance", "Scene", std::make_unique<PrefabInstanceEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PrefabInstanceComponent>())
e.set<PrefabInstanceComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PrefabInstanceComponent>())
e.remove<PrefabInstanceComponent>();
});
}
@@ -0,0 +1,20 @@
#ifndef EDITSCENE_PUSHABLE_HPP
#define EDITSCENE_PUSHABLE_HPP
#pragma once
/**
* Pushable component (F11, see demos/demo-sokoban/PLAN.md).
*
* Generic marker for "a dynamic prop that activities care about": zone
* detection (TargetZoneComponent) keys off it, so any crate can be used
* by any sokoban instance (or a future activity). The entity is expected
* to also carry a dynamic RigidBodyComponent and a box collider.
*
* Note: flecs treats empty structs as tags (set/get_mut assert on them),
* so this marker carries an enabled flag to stay a real component.
*/
struct PushableComponent {
bool enabled = true;
};
#endif // EDITSCENE_PUSHABLE_HPP
@@ -0,0 +1,20 @@
#include "Pushable.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PushableEditor.hpp"
REGISTER_COMPONENT_GROUP("Pushable", "Game", PushableComponent,
PushableEditor)
{
registry.registerComponent<PushableComponent>(
"Pushable", "Game", std::make_unique<PushableEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PushableComponent>())
e.set<PushableComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PushableComponent>())
e.remove<PushableComponent>();
});
}
@@ -0,0 +1,28 @@
#include "Renderable.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/RenderableEditor.hpp"
// Register Renderable component
REGISTER_COMPONENT_GROUP("Renderable", "Rendering", RenderableComponent,
RenderableEditor)
{
registry.registerComponent<RenderableComponent>(
"Renderable", "Rendering",
std::make_unique<RenderableEditor>(sceneMgr),
// Adder
[](flecs::entity e) {
if (!e.has<RenderableComponent>())
e.set<RenderableComponent>({});
},
// Remover
[sceneMgr](flecs::entity e) {
if (e.has<RenderableComponent>()) {
auto &renderable = e.get_mut<RenderableComponent>();
if (renderable.entity) {
sceneMgr->destroyEntity(renderable.entity);
renderable.entity = nullptr;
}
e.remove<RenderableComponent>();
}
});
}
@@ -0,0 +1,21 @@
#include "RigidBody.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/RigidBodyEditor.hpp"
// Register RigidBody component
REGISTER_COMPONENT_GROUP("Rigid Body", "Physics", RigidBodyComponent,
RigidBodyEditor)
{
registry.registerComponent<RigidBodyComponent>(
"Rigid Body", "Physics", std::make_unique<RigidBodyEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<RigidBodyComponent>())
e.set<RigidBodyComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<RigidBodyComponent>())
e.remove<RigidBodyComponent>();
});
}
+407 -44
View File
@@ -5,6 +5,7 @@
#include <Ogre.h>
#include <algorithm>
#include <cmath>
#include <functional>
#include <string>
#include <vector>
@@ -17,6 +18,14 @@
*/
static const float ROAD_MIN_EDGE_LENGTH = 1.0f;
/**
* Lateral gap between the road curb wall and the sidewalk's inner wall
* (improvement plan §2.2). Without it the two walls would be coplanar
* and z-fight; the sidewalk top still overlaps the curb horizontally,
* so no hole is visible.
*/
static const float ROAD_SIDEWALK_WALL_GAP = 0.002f;
/**
* Road configuration parameters global settings that apply to the whole
* road network owned by a single TerrainComponent. These values are
@@ -77,6 +86,28 @@ struct RoadConfig {
* Ogre material applied to generated road surfaces.
*/
std::string roadMaterialName = "RoadMaterial";
/**
* Sidewalks (improvement plan §2). When enabled, each side of the
* road gets an elevated pedestrian strip of sidewalkWidth lateral
* extent whose top sits sidewalkHeight above the road surface.
* sidewalkMeshTemplate follows the same template-space conventions
* as roadMeshTemplate; empty selects the generated procedural box
* (sidewalkWidth x sidewalkThickness profile).
*/
bool sidewalkEnabled = false;
float sidewalkWidth = 1.5f;
float sidewalkHeight = 0.15f;
float sidewalkThickness = 0.3f;
std::string sidewalkMeshTemplate;
/**
* Camera distances in plain meters at which road-edge prefabs spawn
* and despawn (improvement plan §3.2). The despawn distance must
* exceed the spawn distance so the hysteresis band is stable.
*/
float prefabSpawnDistance = 150.0f;
float prefabDespawnDistance = 250.0f;
};
/**
@@ -119,6 +150,38 @@ struct RoadNode {
int id = 0;
};
/**
* A prefab spawn slot attached to a road edge (improvement plan §3.1).
*
* The slot's anchor depends on which RoadEdge member it occupies: the
* left/right slots anchor at the corresponding curb (at the resolved
* half-width of that side of the road), prefabMid anchors at the
* centerline. The anchor sits at normalized position edgeT along the
* edge and is pushed lateralOffset meters further away from the road
* (0 = exactly at the curb / centerline) and yOffset meters above the
* interpolated road surface height.
*
* Side convention: looking from nodeA toward nodeB, the left side is
* UNIT_Y x dir (bounded by the inbound lanesBtoA half-width) and the
* right side is dir x UNIT_Y (bounded by the outbound lanesAtoB
* half-width).
*
* An empty prefabPath disables the slot.
*/
struct RoadEdgePrefabSlot {
/** Prefab JSON file path, relative to the prefabs directory. */
std::string prefabPath;
/** Normalized position along the edge from nodeA to nodeB. */
float edgeT = 0.5f;
/** Meters beyond the anchor, away from the road centerline. */
float lateralOffset = 0.0f;
/** Vertical offset above the interpolated road surface height. */
float yOffset = 0.0f;
};
/**
* A connection between two RoadNode objects.
*
@@ -175,42 +238,35 @@ struct RoadEdge {
*/
int lanesBtoA = 0;
/**
* A prefab instance placed beside the road on a specific edge.
*
* Roadside prefabs are regenerated from edge data when the terrain
* page that contains them is loaded; they are not serialized as
* independent scene entities.
*/
struct RoadSidePrefab {
/** Prefab JSON file path, relative to the prefabs directory. */
std::string prefabPath;
/** Prefab anchored at the left curb (looking from nodeA to nodeB). */
RoadEdgePrefabSlot prefabLeft;
/**
* Normalized position along the edge from nodeA to nodeB.
*
* 0.0 places the prefab at nodeA; 1.0 places it at nodeB.
*/
float edgeT = 0.5f;
/** Prefab anchored at the right curb (looking from nodeA to nodeB). */
RoadEdgePrefabSlot prefabRight;
/**
* Lateral distance from the road center line.
*
* The actual side (left or right) is determined by @c leftSide.
*/
float sideOffset = 5.0f;
/**
* true = left side of the road when looking from nodeA toward nodeB.
* false = right side of the road when looking from nodeA toward nodeB.
*/
bool leftSide = true;
};
/** Prefab spawn points attached to this edge. */
std::vector<RoadSidePrefab> sidePrefabs;
/** Prefab anchored at the centerline, at edgeT (edge midpoint). */
RoadEdgePrefabSlot prefabMid;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
struct RoadGraph;
struct RoadWedge;
struct RoadStraightSegment;
/**
* Enumerate all wedges and straight segments of a road graph (M5.5).
* Defined at the bottom of this header; declared here so RoadGraph
* editing helpers can validate the wedges their operations create.
*/
void enumerateWedges(const RoadGraph &graph,
std::vector<RoadWedge> &outWedges,
std::vector<RoadStraightSegment> &outSegments);
/**
* RoadGraph container and lightweight utility layer for a terrain's road
* network.
@@ -572,6 +628,51 @@ struct RoadGraph {
edgeB.nodeA = newId;
edgeB.roadLevelA = 0.0f;
/* Carry the prefab slots over to the half that contains
* them instead of copying them onto both halves (which
* spawned every prefab twice). edgeT is remapped into the
* receiving half's local 0..1 range; the left/right sides
* are preserved because both halves keep the A->B
* orientation. The mid slot transfers to the half that
* contains the original edge midpoint (t = 0.5). */
edgeA.prefabLeft = RoadEdgePrefabSlot();
edgeA.prefabRight = RoadEdgePrefabSlot();
edgeA.prefabMid = RoadEdgePrefabSlot();
edgeB.prefabLeft = RoadEdgePrefabSlot();
edgeB.prefabRight = RoadEdgePrefabSlot();
edgeB.prefabMid = RoadEdgePrefabSlot();
if (actualT > 1e-6f && actualT < 1.0f - 1e-6f) {
auto remapSlot = [&](const RoadEdgePrefabSlot &src,
RoadEdgePrefabSlot &dstA,
RoadEdgePrefabSlot &dstB) {
if (src.prefabPath.empty())
return;
if (src.edgeT <= actualT) {
dstA = src;
dstA.edgeT = src.edgeT / actualT;
} else {
dstB = src;
dstB.edgeT = (src.edgeT - actualT) /
(1.0f - actualT);
}
};
remapSlot(oldEdge.prefabLeft, edgeA.prefabLeft,
edgeB.prefabLeft);
remapSlot(oldEdge.prefabRight, edgeA.prefabRight,
edgeB.prefabRight);
if (!oldEdge.prefabMid.prefabPath.empty()) {
RoadEdgePrefabSlot mid = oldEdge.prefabMid;
if (0.5f <= actualT) {
mid.edgeT = 0.5f / actualT;
edgeA.prefabMid = mid;
} else {
mid.edgeT = (0.5f - actualT) /
(1.0f - actualT);
edgeB.prefabMid = mid;
}
}
}
edges.erase(edges.begin() + edgeIndex);
edges.push_back(edgeA);
edges.push_back(edgeB);
@@ -616,12 +717,258 @@ struct RoadGraph {
return addEdge(nodeA, nodeB);
}
/**
* Check that every wedge seeded at one of the given nodes respects
* the M5.5 angle limits (30..270 degrees).
*
* Used by editing operations to validate the wedges their result
* would create without running a full-graph validate(). Defined
* after the wedge/segment structs at the bottom of this header.
*/
bool wedgeAnglesValidForNodes(const std::vector<int> &nodeIds) const;
/**
* Connect two existing nodes with a new edge, with validation
* (improvement plan §4.2).
*
* Unlike joinNodes this rejects structurally invalid connections:
* missing nodes, self-edges, duplicates and connections that would
* create a wedge outside the M5.5 angle limits. A rejected
* connection leaves the graph unchanged.
*
* When the endpoints sit in different terrain pages (only possible
* when @p worldSize > 0) the connection is not rejected: instead a
* new node is inserted at every page-boundary crossing along the
* straight line between the endpoints and the edge is built as a
* chain through those nodes, so every resulting edge stays inside
* one page. The inserted nodes get interpolated Y and
* verticalOffset; editor callers should re-snap their Y to the
* terrain (the IDs are reported via @p createdNodes).
*
* @param nodeA Stable ID of the first node.
* @param nodeB Stable ID of the second node.
* @param worldSize Length of one terrain page in world units, or 0
* to skip the page check (headless tests).
* @param error If non-null and the connection is rejected,
* receives a human readable reason.
* @param createdNodes If non-null, receives the IDs of the nodes
* inserted at page-boundary crossings (empty when
* no split was needed).
* @return index of the first new edge, or -1 on rejection.
*/
int connectNodes(int nodeA, int nodeB, float worldSize = 0.0f,
std::string *error = nullptr,
std::vector<int> *createdNodes = nullptr)
{
const RoadNode *na = findNodeById(nodeA);
const RoadNode *nb = findNodeById(nodeB);
if (!na || !nb) {
if (error)
*error = "One of the nodes does not exist.";
return -1;
}
if (nodeA == nodeB) {
if (error)
*error = "Cannot connect a node to itself.";
return -1;
}
if (hasEdge(nodeA, nodeB)) {
if (error)
*error = "An edge already connects these nodes.";
return -1;
}
std::vector<int> chain;
std::vector<int> inserted;
chain.push_back(nodeA);
if (worldSize > 0.0f &&
!edgeStaysWithinOnePage(na->position, nb->position,
worldSize)) {
/* Copy the endpoint data up front: addNode may
* reallocate the nodes vector and invalidate the
* na/nb pointers. */
const Ogre::Vector3 posA = na->position;
const Ogre::Vector3 posB = nb->position;
const float offA = na->verticalOffset;
const float offB = nb->verticalOffset;
std::vector<float> ts =
pageBoundaryCrossings(posA, posB, worldSize);
if (ts.empty()) {
if (error)
*error = "The nodes are in different "
"terrain pages.";
return -1;
}
for (float t : ts) {
Ogre::Vector3 pos = posA + (posB - posA) * t;
float off = offA + (offB - offA) * t;
inserted.push_back(addNode(pos, off));
}
}
for (int id : inserted)
chain.push_back(id);
chain.push_back(nodeB);
int firstEdge = -1;
std::vector<int> createdEdges;
for (size_t i = 0; i + 1 < chain.size(); ++i) {
int idx = addEdge(chain[i], chain[i + 1]);
if (idx < 0) {
if (error)
*error = "joinNodes failed.";
break;
}
if (firstEdge < 0)
firstEdge = idx;
createdEdges.push_back(idx);
}
if (createdEdges.size() + 1 != chain.size() ||
!wedgeAnglesValidForNodes(chain)) {
/* Roll back: drop the new edges (descending index
* order so earlier indices stay valid), then the
* inserted nodes. */
std::sort(createdEdges.begin(), createdEdges.end(),
std::greater<int>());
for (int idx : createdEdges)
removeEdge((size_t)idx);
for (int id : inserted)
removeNode(id);
if (error && createdEdges.size() + 1 == chain.size())
*error = "The connection would create a wedge "
"outside the 30-270 degree limits.";
return -1;
}
if (createdNodes)
*createdNodes = inserted;
return firstEdge;
}
/**
* Compute the normalized positions (0..1) along the segment a->b at
* which it crosses a terrain page boundary.
*
* Page boundaries lie at (k + 0.5) * worldSize for integer k on
* both the X and Z axes (see edgeStaysWithinOnePage). A crossing
* through a grid corner is reported once. Crossings within 1e-4 of
* an endpoint are dropped: the endpoint effectively sits on the
* boundary and needs no split node.
*
* @return sorted, deduplicated list of crossing parameters.
*/
static std::vector<float>
pageBoundaryCrossings(const Ogre::Vector3 &a, const Ogre::Vector3 &b,
float worldSize)
{
std::vector<float> ts;
if (worldSize <= 0.0f)
return ts;
auto collect = [&](float pa, float pb) {
float d = pb - pa;
if (std::fabs(d) < 1e-6f)
return;
float lo = std::min(pa, pb);
float hi = std::max(pa, pb);
long kFirst = (long)std::floor(lo / worldSize + 0.5f);
long kLast = (long)std::floor(hi / worldSize + 0.5f);
for (long k = kFirst; k < kLast; ++k) {
float boundary =
((float)k + 0.5f) * worldSize;
float t = (boundary - pa) / d;
if (t > 1e-4f && t < 1.0f - 1e-4f)
ts.push_back(t);
}
};
collect(a.x, b.x);
collect(a.z, b.z);
std::sort(ts.begin(), ts.end());
std::vector<float> unique;
for (float t : ts) {
if (unique.empty() || t - unique.back() > 1e-4f)
unique.push_back(t);
}
return unique;
}
/**
* Relax a 3-node chain A-B-C toward a straighter path (improvement
* plan §5).
*
* The node must have exactly two neighbors; they act as anchors and
* never move. The node's XZ position is lerped toward the anchors'
* midpoint by @p strength (0..1), and its verticalOffset is lerped
* toward the anchors' average offset by the same amount. The Y
* coordinate itself is left untouched the editor re-snaps it to
* terrain_height + verticalOffset, same as after a gizmo drag.
*
* A move is rejected (the graph is left unchanged) when it would
* shorten an incident edge below ROAD_MIN_EDGE_LENGTH, push the
* node across a terrain page boundary (when @p worldSize > 0), or
* create a wedge outside the M5.5 angle limits.
*
* @return true if the node moved.
*/
bool smoothNode(int nodeId, float strength, float worldSize = 0.0f)
{
RoadNode *node = findNodeById(nodeId);
if (!node)
return false;
std::vector<int> nbr = getNeighborIds(nodeId);
if (nbr.size() != 2)
return false;
const RoadNode *na = findNodeById(nbr[0]);
const RoadNode *nc = findNodeById(nbr[1]);
if (!na || !nc)
return false;
float s = std::max(0.0f, std::min(1.0f, strength));
Ogre::Vector3 mid =
(na->position + nc->position) * 0.5f;
Ogre::Vector3 newPos = node->position;
newPos.x += (mid.x - newPos.x) * s;
newPos.z += (mid.z - newPos.z) * s;
auto horizDist = [](const Ogre::Vector3 &p,
const Ogre::Vector3 &q) {
float dx = p.x - q.x, dz = p.z - q.z;
return std::sqrt(dx * dx + dz * dz);
};
if (horizDist(na->position, newPos) < ROAD_MIN_EDGE_LENGTH ||
horizDist(newPos, nc->position) < ROAD_MIN_EDGE_LENGTH)
return false;
if (!edgeStaysWithinOnePage(na->position, newPos,
worldSize) ||
!edgeStaysWithinOnePage(newPos, nc->position, worldSize))
return false;
Ogre::Vector3 oldPos = node->position;
node->position = newPos;
if (!wedgeAnglesValidForNodes(
{ nbr[0], nodeId, nbr[1] })) {
node->position = oldPos;
return false;
}
float midOffset =
(na->verticalOffset + nc->verticalOffset) * 0.5f;
node->verticalOffset +=
(midOffset - node->verticalOffset) * s;
bumpVersion();
return true;
}
/**
* Check whether the straight-line edge between two world positions crosses
* a terrain page boundary without a node at the crossing.
*
* Terrain pages are axis-aligned squares of side @c worldSize. An edge
* is valid only if both endpoints lie inside the same page.
* Terrain pages are axis-aligned squares of side @c worldSize centred on
* the terrain-group origin: slot (0,0) spans
* -worldSize/2 .. +worldSize/2, matching
* TerrainGroup::convertWorldPositionToTerrainSlot with a zero group
* origin (plain floor(pos / worldSize) would put a spurious page
* boundary on the world X/Z axes). An edge is valid only if both
* endpoints lie inside the same page.
*
* @param a Start position in world space.
* @param b End position in world space.
@@ -635,10 +982,10 @@ struct RoadGraph {
if (worldSize <= 0.0f)
return true;
long pageAx = (long)std::floor(a.x / worldSize);
long pageAz = (long)std::floor(a.z / worldSize);
long pageBx = (long)std::floor(b.x / worldSize);
long pageBz = (long)std::floor(b.z / worldSize);
long pageAx = (long)std::floor(a.x / worldSize + 0.5f);
long pageAz = (long)std::floor(a.z / worldSize + 0.5f);
long pageBx = (long)std::floor(b.x / worldSize + 0.5f);
long pageBz = (long)std::floor(b.z / worldSize + 0.5f);
return pageAx == pageBx && pageAz == pageBz;
}
@@ -767,12 +1114,6 @@ struct RoadStraightSegment {
RoadHalfEdge halfEdge;
};
/** Minimum wedge swept angle; sharper wedges are rejected (M5.5). */
static const float ROAD_WEDGE_MIN_ANGLE_DEG = 30.0f;
/** Maximum wedge swept angle; only near-360 deg wedges are degenerate. */
static const float ROAD_WEDGE_MAX_ANGLE_DEG = 359.9f;
/**
* Enumerate all wedges and straight segments of a road graph (M5.5).
*
@@ -871,6 +1212,28 @@ inline void enumerateWedges(const RoadGraph &graph,
}
}
inline bool
RoadGraph::wedgeAnglesValidForNodes(const std::vector<int> &nodeIds) const
{
std::vector<RoadWedge> wedges;
std::vector<RoadStraightSegment> segments;
enumerateWedges(*this, wedges, segments);
for (const auto &w : wedges) {
bool relevant = false;
for (int id : nodeIds) {
if (w.nodeId == id) {
relevant = true;
break;
}
}
if (!relevant)
continue;
if (w.sweptAngleDeg < ROAD_WEDGE_MIN_ANGLE_DEG || w.degenerate)
return false;
}
return true;
}
inline Ogre::Vector3
RoadGraph::snapToIntegerLength(const Ogre::Vector3 &anchor,
const Ogre::Vector3 &pos)
@@ -0,0 +1,28 @@
#ifndef EDITSCENE_SCENE_SCRIPT_HPP
#define EDITSCENE_SCENE_SCRIPT_HPP
#pragma once
#include <string>
/**
* Scene/prefab Lua script component.
*
* References a Lua script that is executed exactly once in the shared
* lua_State owned by EditorApp when the scene or prefab containing this
* entity is loaded. Scripts are never unloaded; their usual purpose is
* to subscribe event handlers (ecs.subscribe_event) specific to the
* scene or prefab.
*
* If scriptPath is non-empty it wins and the file is resolved in the
* "LuaScripts" OGRE resource group (same resolution as
* LuaState::luaLibraryLoader). Otherwise inlineScript is executed.
* Execution is deduplicated: by path for file scripts, by content hash
* for inline scripts. On a Lua error the script is NOT marked executed,
* so a later load can retry.
*/
struct SceneScriptComponent {
std::string scriptPath;
std::string inlineScript;
};
#endif // EDITSCENE_SCENE_SCRIPT_HPP
@@ -0,0 +1,52 @@
#include "SceneScript.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/SceneScriptEditor.hpp"
/**
* Template prefilled into inlineScript when the component is added in
* the editor. Shows the most common event handlers; everything is
* commented out so a fresh component executes as a no-op.
*/
static const char kSceneScriptTemplate[] =
"-- Scene script: executed once when this scene or prefab is loaded.\n"
"-- Lives in the shared Lua environment and is never unloaded.\n"
"-- Its purpose is to subscribe event handlers specific to this\n"
"-- scene/prefab. Uncomment the handlers you need:\n"
"\n"
"-- ecs.subscribe_event(\"scene_loaded\", function(event, params)\n"
"-- -- params.scenePath identifies the loaded scene\n"
"-- end)\n"
"\n"
"-- ecs.subscribe_event(\"prefab_loaded\", function(event, params)\n"
"-- -- params.prefabPath and params.entityId identify the instance\n"
"-- end)\n"
"\n"
"-- ecs.subscribe_event(\"game_start\", function(event, params) end)\n"
"-- ecs.subscribe_event(\"game_saved\", function(event, params) end)\n"
"-- ecs.subscribe_event(\"game_loaded\", function(event, params) end)\n"
"\n"
"-- Custom events:\n"
"-- local id = ecs.subscribe_event(\"my_event\", function(event, params) end)\n"
"-- ecs.unsubscribe_event(id)\n"
"-- ecs.send_event(\"my_event\", { key = \"value\" })\n";
REGISTER_COMPONENT_GROUP("Scene Script", "Game", SceneScriptComponent,
SceneScriptEditor)
{
registry.registerComponent<SceneScriptComponent>(
"Scene Script", "Game",
std::make_unique<SceneScriptEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<SceneScriptComponent>()) {
SceneScriptComponent script;
script.inlineScript = kSceneScriptTemplate;
e.set<SceneScriptComponent>(script);
}
},
// Remover
[](flecs::entity e) {
if (e.has<SceneScriptComponent>())
e.remove<SceneScriptComponent>();
});
}
+5 -4
View File
@@ -5,9 +5,9 @@
#include <Ogre.h>
/**
* Skybox component - procedural sky rendered as a large cube
* with a fragment shader that creates dynamic day/night/sunset
* sky gradients.
* Skybox component - procedural sky rendered as a fullscreen
* triangle with a fragment shader that creates dynamic
* day/night/sunset sky gradients from the per-pixel view ray.
*
* Designed to work alongside SunComponent on the same entity.
* If no SunComponent is present, uses default noon lighting.
@@ -16,7 +16,8 @@ struct SkyboxComponent {
// Enable/disable skybox
bool enabled = true;
// Size of the skybox cube (default 500)
// Legacy size of the old skybox cube; kept for scene
// compatibility but unused - the sky is a fullscreen triangle now.
float size = 500.0f;
// Day sky colors
@@ -20,5 +20,9 @@ REGISTER_COMPONENT_GROUP("Skybox", "Environment", SkyboxComponent, SkyboxEditor)
if (e.has<SkyboxComponent>()) {
e.remove<SkyboxComponent>();
}
},
// On-modified: mark dirty so EditorSkyboxSystem rebuilds the skybox
[](flecs::entity e) {
e.get_mut<SkyboxComponent>().markDirty();
});
}
@@ -0,0 +1,59 @@
#ifndef EDITSCENE_STANDALONE_DOOR_HPP
#define EDITSCENE_STANDALONE_DOOR_HPP
#pragma once
#include <Ogre.h>
#include <string>
/**
* Standalone door component (F2, serialized).
*
* A door without a CellGrid: the entity's TransformComponent marks the
* doorway (center of the opening at floor level; local +X runs along the
* wall, +Z faces out of the room) and StandaloneDoorSystem builds the
* runtime door subtree under it with DoorBuilder - hinge node, leaf,
* collider child, actuator and (for scene-switch doors) the F1 black
* occluder, identical to CellGrid doors.
*
* All behaviour flags mirror the CellGrid per-door configuration
* (CellGridDoorConfig); persistence and locking work through the same
* GlobalStateStore keys "door.<doorId>.locked" / ".isOpen".
*
* Identity: `doorId` is the global persistent ID, user-editable and
* expected to be unique scene-wide (the editor warns on duplicates).
* When empty while the door needs one (persistent/lockable/scene-switch),
* a random UUID is generated on first build and stays in the serialized
* component from then on. Empty + no persistence flags = ephemeral door.
*/
struct StandaloneDoorComponent {
// Leaf geometry
std::string meshName; // empty = procedural box leaf
bool useMeshMaterial = false; // keep the custom mesh's material
std::string rectName; // UV rect name in the entity's own
// ProceduralTexture (optional)
float leafWidth = 1.0f; // procedural leaf dimensions
float leafHeight = 2.0f; // (explicit, not cell-derived)
float leafThickness = 0.08f;
// Behaviour
float openAngle = 100.0f; // degrees
float openSpeed = 180.0f; // degrees per second
bool swingReversed = false; // F3
std::string actionName; // optional actuator action
std::string sceneSwitchPath; // F1 (empty = normal swinging door)
std::string sceneSwitchTarget;
// Persistence / locking (F6)
bool persistent = false;
bool lockable = false;
bool lockedByDefault = false;
std::string keyItemId;
std::string doorId; // global ID; see the class comment
// Runtime: rebuild the door subtree on the next
// StandaloneDoorSystem::update (set by the editor on changes;
// not serialized)
bool dirty = true;
};
#endif // EDITSCENE_STANDALONE_DOOR_HPP
@@ -0,0 +1,21 @@
#include "StandaloneDoor.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/StandaloneDoorEditor.hpp"
REGISTER_COMPONENT_GROUP("Standalone Door", "Game", StandaloneDoorComponent,
StandaloneDoorEditor)
{
registry.registerComponent<StandaloneDoorComponent>(
"Standalone Door", "Game",
std::make_unique<StandaloneDoorEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<StandaloneDoorComponent>())
e.set<StandaloneDoorComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<StandaloneDoorComponent>())
e.remove<StandaloneDoorComponent>();
});
}
@@ -20,5 +20,9 @@ REGISTER_COMPONENT_GROUP("Sun", "Environment", SunComponent, SunEditor)
if (e.has<SunComponent>()) {
e.remove<SunComponent>();
}
},
// On-modified: mark dirty so EditorSunSystem rebuilds light/material
[](flecs::entity e) {
e.get_mut<SunComponent>().markDirty();
});
}
@@ -0,0 +1,34 @@
#ifndef EDITSCENE_TARGET_ZONE_HPP
#define EDITSCENE_TARGET_ZONE_HPP
#pragma once
#include <Ogre.h>
/**
* Target zone component (F11, see demos/demo-sokoban/PLAN.md).
*
* Generic "pad/zone" marker: an axis-aligned box (centred on the
* entity transform) that activities use as a goal area. ZoneSystem
* (Phase 6) tests PushableComponent entities for XZ-overlap + nearly-
* at-rest against every zone and emits zone_entered/zone_left events;
* it also swaps the zone entity's material between
* uncovered/coveredMaterialName as feedback.
*
* Generic on purpose: any activity can use zones, not just sokoban.
* Zone ids must be unique per yard (e.g. "yard1.pad1") so multiple
* instances never interfere.
*/
struct TargetZoneComponent {
/* Unique zone id, e.g. "yard1.pad1". */
Ogre::String zoneId;
/* Zone box half extents in entity space (y is only informational
* for the XZ-overlap check). */
Ogre::Vector3 halfExtents = Ogre::Vector3(0.6f, 0.1f, 0.6f);
/* Material swapped onto the zone entity's renderable by ZoneSystem
* when the zone is uncovered/covered; empty = no visual swap. */
Ogre::String uncoveredMaterialName;
Ogre::String coveredMaterialName;
bool enabled = true;
};
#endif // EDITSCENE_TARGET_ZONE_HPP
@@ -0,0 +1,20 @@
#include "TargetZone.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TargetZoneEditor.hpp"
REGISTER_COMPONENT_GROUP("TargetZone", "Game", TargetZoneComponent,
TargetZoneEditor)
{
registry.registerComponent<TargetZoneComponent>(
"TargetZone", "Game", std::make_unique<TargetZoneEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<TargetZoneComponent>())
e.set<TargetZoneComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<TargetZoneComponent>())
e.remove<TargetZoneComponent>();
});
}
@@ -73,6 +73,45 @@ struct TerrainComponent {
};
DetailNoise detailNoise;
/* --- Streaming / far-distance rendering (world streaming
* groundwork) ---
*
* streamingEnabled switches the base-height source from the single
* legacy heightmap buffer to on-demand procedural evaluation of
* baseNoise over the whole bounded world. Page streaming itself is
* NOT implemented yet: the fixed 3x3 page grid is still loaded; only
* the height source and the far-fallback paths change. */
bool streamingEnabled = false;
// Total bounded world extent per axis, in world units.
double worldSizeUnits = 40000000.0;
// Page streaming window radii (pages). Reserved for the streaming
// window task; currently unused.
int pageLoadRadius = 2;
int pageHoldRadius = 3;
// Camera far clip applied while the terrain is active.
float farClipDistance = 6000.0f;
// Linear fog applied while the terrain is active.
bool fogEnabled = true;
float fogStart = 2500.0f;
float fogEnd = 5500.0f;
// Procedural base terrain shape (streaming mode only). Evaluated
// on demand at any world coordinate via FastNoiseLite; replaces the
// legacy heightmap buffer when streamingEnabled is true.
struct BaseNoise {
int seed = 1337;
int octaves = 4;
float frequency = 0.0005f;
float amplitude = 40.0f;
float lacunarity = 2.0f;
float persistence = 0.5f;
};
BaseNoise baseNoise;
// Auxiliary maps (foliage density, material masks, etc.).
struct AuxMap {
std::string name;
@@ -0,0 +1,58 @@
#ifndef EDITSCENE_TERRAINPREFABSPAWNER_HPP
#define EDITSCENE_TERRAINPREFABSPAWNER_HPP
#pragma once
#include <flecs.h>
#include <string>
/**
* @brief Distance-based prefab spawner for terrain scenes (Milestone 6).
*
* Attaches to an entity with a TransformComponent. When the active camera
* is within spawnDistanceSq of the spawner, the prefab referenced by
* prefabPath is instantiated at the spawner's transform (Y snapped to the
* terrain surface). When the camera moves beyond despawnDistanceSq, the
* spawned instance is destroyed.
*
* The world-space position and rotation live on the entity's
* TransformComponent (deviation from the original TerrainRequirements.md
* 6.2 struct, which embedded position/rotation here keeping a single
* transform source avoids stale duplicates when the spawner is moved with
* the editor gizmo; same precedent as CharacterSpawnerComponent).
*
* Spawned instances are runtime-only: they carry no EditorMarkerComponent
* and are never serialized with the scene. Distances are stored squared
* for fast comparison; the serializer writes them as plain distances.
*/
struct TerrainPrefabSpawnerComponent {
/** Prefab JSON file path (e.g. "prefabs/test_cube.json"). */
std::string prefabPath;
/** Squared distance at which the prefab should be spawned. */
float spawnDistanceSq = 100.0f * 100.0f;
/** Squared distance at which the prefab should be despawned. */
float despawnDistanceSq = 200.0f * 200.0f;
/** Runtime: the spawned instance entity (0 when not spawned).
* Managed by TerrainPrefabSpawnerSystem; not serialized. */
flecs::entity_t spawnedEntity = 0;
};
/**
* Marks a spawner entity as owned by the streamed-region store (M4).
*
* Such entities are created/destroyed by TerrainPrefabSpawnerSystem as
* terrain pages stream in/out; their authoritative data lives in the
* SpawnerRegionStore page files (heightmaps/<terrainId>/spawners/), so
* SceneSerializer must NOT write them into the scene JSON. The tag
* also identifies the def for edit write-back (move/delete/property
* edits in the editor update the region store).
*/
struct StreamedSpawnerTag {
long pageX = 0;
long pageY = 0;
uint64_t defId = 0;
};
#endif // EDITSCENE_TERRAINPREFABSPAWNER_HPP
@@ -0,0 +1,26 @@
#include "TerrainPrefabSpawner.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TerrainPrefabSpawnerEditor.hpp"
// Register TerrainPrefabSpawner component (Milestone 6)
REGISTER_COMPONENT_GROUP("Terrain Prefab Spawner", "Environment",
TerrainPrefabSpawnerComponent,
TerrainPrefabSpawnerEditor)
{
registry.registerComponent<TerrainPrefabSpawnerComponent>(
"Terrain Prefab Spawner", "Environment",
std::make_unique<TerrainPrefabSpawnerEditor>(sceneMgr),
/* Adder */
[](flecs::entity e) {
if (!e.has<TerrainPrefabSpawnerComponent>()) {
e.set<TerrainPrefabSpawnerComponent>(
TerrainPrefabSpawnerComponent{});
}
},
/* Remover */
[](flecs::entity e) {
if (e.has<TerrainPrefabSpawnerComponent>()) {
e.remove<TerrainPrefabSpawnerComponent>();
}
});
}
@@ -12,7 +12,21 @@ struct TransformComponent {
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
Ogre::Vector3 scale = Ogre::Vector3::UNIT_SCALE;
/* Authoritative double-precision world position (absolute world
* space, independent of the render origin see
* systems/RenderOriginSystem). Only meaningful when
* hasWorldPosition is true; legacy scenes leave it false and rely
* on the float `position` (which then doubles as the world
* position because the render origin starts at 0). For root-level
* entities `position` stays the render-space node-local value
* (world - renderOrigin); on rebase RenderOriginSystem recomputes
* it exactly from these doubles. */
double worldX = 0.0;
double worldY = 0.0;
double worldZ = 0.0;
bool hasWorldPosition = false;
// Version tracking for change detection
unsigned int version = 0;
@@ -0,0 +1,42 @@
#include "Transform.hpp"
#include "StaticGeometryMember.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/TransformEditor.hpp"
// Register Transform component
REGISTER_COMPONENT_GROUP("Transform", "Transform", TransformComponent,
TransformEditor)
{
registry.registerComponent<TransformComponent>(
"Transform", "Transform", std::make_unique<TransformEditor>(),
// Adder
[sceneMgr](flecs::entity e) {
if (!e.has<TransformComponent>()) {
TransformComponent transform;
transform.node = sceneMgr->getRootSceneNode()
->createChildSceneNode();
transform.position = Ogre::Vector3::ZERO;
transform.rotation = Ogre::Quaternion::IDENTITY;
transform.scale = Ogre::Vector3::UNIT_SCALE;
e.set<TransformComponent>(transform);
}
},
// Remover
[sceneMgr](flecs::entity e) {
if (e.has<TransformComponent>()) {
auto &transform = e.get_mut<TransformComponent>();
if (transform.node) {
sceneMgr->destroySceneNode(transform.node);
transform.node = nullptr;
}
e.remove<TransformComponent>();
}
},
// On-modified: keep a StaticGeometryMember in sync with its transform
[](flecs::entity e) {
if (e.has<StaticGeometryMemberComponent>())
e.get_mut<StaticGeometryMemberComponent>().markDirty();
},
// Render order: Transform always renders first
-1);
}
@@ -0,0 +1,126 @@
#ifndef EDITSCENE_VEHICLE_HPP
#define EDITSCENE_VEHICLE_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <Jolt/Jolt.h>
namespace JPH
{
class VehicleConstraint;
}
/**
* Vehicle wheel definition (F10, see demos/demo-sokoban/PLAN.md).
*
* Wheels come in axle pairs (0/1 = first axle, 2/3 = second, ...);
* engine torque is routed through a differential per pair whose wheels
* are both marked driven.
*/
struct VehicleWheel {
/* Suspension attachment point in chassis space. */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
float radius = 0.3f;
float width = 0.15f;
float suspensionMinLength = 0.1f;
float suspensionMaxLength = 0.4f;
float suspensionFrequency = 1.5f;
float suspensionDamping = 0.7f;
/* 0 = not steered (degrees). */
float maxSteerAngleDeg = 0.0f;
bool driven = false;
/* 0 = no handbrake on this wheel. */
float maxHandBrakeTorque = 0.0f;
};
/**
* Vehicle component.
*
* Turns an entity with a dynamic RigidBodyComponent (the chassis) into
* a drivable vehicle: VehicleSystem attaches a Jolt VehicleConstraint
* with a WheeledVehicleController to the chassis body and feeds the
* driver input fields below into it.
*
* Visuals: the chassis mesh comes from the entity's RenderableComponent
* (or Primitive); when wheelMeshName is set, VehicleSystem creates one
* child node per wheel and syncs it from the constraint state.
*/
struct VehicleComponent {
std::vector<VehicleWheel> wheels;
float maxTorque = 400.0f;
/* Degrees; 0 = unlimited. */
float maxPitchRollAngleDeg = 60.0f;
/* Driver seat offset in chassis space (used by the
* VehicleControllerSystem enter/exit flow to seat the player
* character). */
Ogre::Vector3 seatOffset = Ogre::Vector3(0.0f, 1.0f, 0.0f);
/* Rear-wheel steering (like a real forklift): flips the steer sign
* so D still turns the nose right with the steered axle behind
* the driver the raw wheel angle would turn the nose left. */
bool rearSteer = false;
/* TPS camera boom while driving: VehicleControllerSystem swaps the
* player controller's tpsDistance/tpsHeight for these on enter and
* restores them on exit. */
float cameraDistance = 5.0f;
float cameraHeight = 2.2f;
/* Engine braking (0 = off): when the driver gives no throttle/
* brake input and the vehicle is still rolling above ~0.5 m/s,
* this brake value (0..1) is applied so a heavy chassis does not
* coast forever. */
float engineBrake = 0.5f;
/* Wheel visual mesh (Y-axis aligned cylinder); empty = no wheel
* visuals. */
Ogre::String wheelMeshName;
/* Steering wheel visual: mesh modelled around the origin with its
* spin axis on local Y (same convention as the wheel mesh).
* VehicleSystem places it at steeringWheelOffset (chassis space),
* tilts it steeringWheelTiltDeg back from vertical toward the
* driver and spins it around its local Y with the smoothed
* steering angle times steeringRatio. Empty mesh name = no
* steering wheel visual. */
Ogre::String steeringWheelMeshName;
Ogre::Vector3 steeringWheelOffset = Ogre::Vector3::ZERO;
float steeringWheelTiltDeg = 25.0f;
float steeringRatio = 4.0f;
/* Fork lift (forklift): a child node with forkMeshName is created
* like the wheel visuals and sits at forkOffset (chassis space)
* when the fork is down; inputFork raises/lowers it between 0 and
* forkMaxHeight at forkSpeed.
*
* Carry mechanic: when the fork starts rising from the bottom and
* a PushableComponent body's center is inside the attach box at
* the tines (see VehicleSystem), the crate is pinned to the
* carriage and follows the fork (gravity off, teleported with the
* chassis each frame); when the fork is lowered back to 0 the
* crate is released. Empty mesh name = no fork, no carry. */
Ogre::String forkMeshName;
Ogre::Vector3 forkOffset = Ogre::Vector3::ZERO;
float forkMaxHeight = 1.2f;
float forkSpeed = 0.8f;
/* Driver input, written by the controlling system
* (VehicleControllerSystem or tests), consumed by VehicleSystem:
* forward/right/fork -1..1, brake/handbrake 0..1. */
float inputForward = 0.0f;
float inputRight = 0.0f;
float inputBrake = 0.0f;
float inputHandBrake = 0.0f;
float inputFork = 0.0f;
/* Runtime: current fork height above forkOffset (0..forkMaxHeight). */
float forkHeight = 0.0f;
/* Runtime: the Jolt vehicle constraint on the chassis body. */
JPH::VehicleConstraint *constraint = nullptr;
bool constraintCreated = false;
};
#endif // EDITSCENE_VEHICLE_HPP
@@ -0,0 +1,48 @@
#include "Vehicle.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/VehicleEditor.hpp"
/* Default 4-wheel layout when the component is added in the editor:
* a small forklift-ish chassis (half extents ~0.7 x 0.4 x 1.2) with
* front axle driven and rear axle steered (real forklifts steer with
* the rear wheels; see demos/demo-sokoban/PLAN.md open questions). */
static VehicleComponent makeDefaultVehicle()
{
VehicleComponent v;
v.wheels.resize(4);
for (int i = 0; i < 4; i++) {
VehicleWheel &w = v.wheels[i];
bool left = (i % 2) == 0;
bool front = i < 2;
w.position = Ogre::Vector3(left ? 0.65f : -0.65f, -0.2f,
front ? 0.85f : -0.85f);
w.radius = 0.3f;
w.width = 0.2f;
w.suspensionMinLength = 0.05f;
w.suspensionMaxLength = 0.3f;
w.suspensionFrequency = 2.0f;
w.suspensionDamping = 0.8f;
w.driven = front;
w.maxSteerAngleDeg = front ? 0.0f : 35.0f;
w.maxHandBrakeTorque = front ? 0.0f : 200.0f;
}
return v;
}
// Register Vehicle component
REGISTER_COMPONENT_GROUP("Vehicle", "Physics", VehicleComponent,
VehicleEditor)
{
registry.registerComponent<VehicleComponent>(
"Vehicle", "Physics", std::make_unique<VehicleEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<VehicleComponent>())
e.set<VehicleComponent>(makeDefaultVehicle());
},
// Remover
[](flecs::entity e) {
if (e.has<VehicleComponent>())
e.remove<VehicleComponent>();
});
}
@@ -21,5 +21,9 @@ REGISTER_COMPONENT_GROUP("Water Plane", "Water", WaterPlane, WaterPlaneEditor)
if (e.has<WaterPlane>()) {
e.remove<WaterPlane>();
}
},
// On-modified: mark dirty so EditorWaterPlaneSystem rebuilds the plane
[](flecs::entity e) {
e.get_mut<WaterPlane>().markDirty();
});
}
@@ -0,0 +1,88 @@
# ---------------------------------------------------------------------------
# demo-character-controller game-mode demo for PlayerControllerSystem
# ---------------------------------------------------------------------------
# Separate executable built from the same sources as editSceneEditor (minus
# main.cpp). It runs in game mode and loads demo_character_controller.json,
# which contains a flat colored floor plane (procedurally created in
# demo_main.cpp as "DemoFloorPlane") with a static box collider, a character
# spawner ("s1", character registry ID 2, same player character setup as
# town8.json) and a PlayerControllerComponent targeting it. The mouse is
# grabbed while Playing (built-in game-mode behaviour) and the initial
# window is larger than the default (1920x1080, see DemoApp in
# demo_main.cpp).
#
# The executable is self-contained in this build directory: a POST_BUILD
# step (stage_runtime.cmake) copies resources.cfg, the demo scene, 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
# runtime directories (resources/, characters/, lua-scripts/) from the
# editScene binary directory. Run it from here:
# cd <build>/src/features/editScene/demos/demo-character-controller
# ./demoCharacterController
# Controls: mouse = look, W/A/S/D = move, Shift = run,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
add_executable(demoCharacterController
demo_main.cpp
${DEMO_SOURCES}
)
add_dependencies(demoCharacterController morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoCharacterController PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoCharacterController
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoCharacterController PRIVATE
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoCharacterController POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-character-controller standalone runtime"
)
@@ -0,0 +1,191 @@
{
"version": "1.0",
"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": "DemoFloorPlane",
"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": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": []
},
{
"id": 4,
"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,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"children": []
}
]
}
@@ -0,0 +1,145 @@
#include <iostream>
#include "EditorApp.hpp"
#include "systems/CharacterRegistry.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/* Same application as the editor/game, but with a larger initial window. */
class DemoApp : public EditorApp {
public:
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override
{
(void)w;
(void)h;
return EditorApp::createWindow(name, 1920, 1080, miscParams);
}
};
/*
* The demo floor mesh and its flat colored material are created
* programmatically because RenderableComponent only references a mesh by
* name and carries no material/color of its own. The demo scene JSON
* references the "DemoFloorPlane" mesh on the "demo_floor" entity.
*/
static void createFloorResources()
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName("DemoFloorPlane", group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
"DemoFloorMaterial", group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(0.35f, 0.5f, 0.35f, 1.0f);
pass->setAmbient(0.15f, 0.2f, 0.15f);
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
* (top surface at y = 0) on the "demo_floor" entity in the scene. */
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
"DemoFloorPlane", group, groundPlane, 60.0f, 60.0f, 1, 1,
true, 1, 1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
mesh->getSubMesh(0)->setMaterialName("DemoFloorMaterial");
}
/*
* demo-character-controller: runs the editScene game mode with a small
* hand-authored scene (demo_character_controller.json) containing a flat
* colored floor plane with a static physics collider, a character spawner
* ("s1", character registry ID 2, same character setup as town8.json) and
* a PlayerControllerComponent targeting it. The character walks on the
* physical floor exactly as in game mode.
*
* The mouse is grabbed while Playing (built-in game-mode behaviour);
* Escape toggles the pause menu, which frees the cursor.
*
* The binary is self-contained in its build directory: run it from there
* (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the
* runtime config JSONs are staged next to it by the build).
*/
int main(int argc, char *argv[])
{
try {
DemoApp app;
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
Ogre::String sceneFile = "demo_character_controller.json";
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
}
}
app.setHeadless(headless);
app.initApp();
if (headless) {
/* Headless mode never creates EditorUISystem, which
* owns the CharacterRegistry singleton; the demo scene
* has a character spawner, so provide a bare registry
* to keep spawner resolution from asserting. The
* character then falls back to an inline spawn without
* a physics capsule (fine for a smoke run). */
static CharacterRegistry s_characterRegistry;
s_characterRegistry.setWorld(app.getWorld());
s_characterRegistry.setSceneManager(app.getSceneManager());
s_characterRegistry.initialize();
}
/* Mesh + material referenced by the floor entity. */
createFloorResources();
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, Escape = pause menu" << std::endl;
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame)
app.getRoot()->addFrameListener(&exitListener);
app.getRoot()->startRendering();
/* Destroy scene entities while the systems are still alive:
* CharacterSpawnerSystem registers an OnRemove observer on
* CharacterSpawnerComponent that dereferences the system, and
* the flecs world only dies after destroyEditorSystems() in
* ~EditorApp, so letting the spawner entity live that long
* would call back into a destroyed system. */
app.clearScene();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
@@ -0,0 +1,49 @@
# Stage everything demoCharacterController needs into its own directory so
# it runs standalone from
# <build>/src/features/editScene/demos/demo-character-controller.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# Small demo-owned files are COPIED; 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/demo_character_controller.json" DESTINATION "${DEMO_DIR}")
# Character prefab for the spawner's registry entry (registryId 2). It is
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest the demo needs
# character_registry.json for the spawner's registryId 2 and
# animation_tree.json for the character's "male1_6" animation tree). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
@@ -0,0 +1,145 @@
# ---------------------------------------------------------------------------
# demo-interior-exterior-dynamics scene switching demo with a CellGrid
# interior and a terrain/sky/water exterior
# ---------------------------------------------------------------------------
# Same scene-switch-door setup as demos/demo-scene-switching-extra, but
# the exterior scene replaces the flat colored floor with a real outdoor
# environment: a STREAMING terrain entity (worldSizeUnits 10000, 5x5 pages
# of 2000 units, base heights from TerrainComponent::baseNoise
# OpenSimplex2 seed 55, 3 octaves, frequency 0.0003, amplitude 15: an
# archipelago of large islands, ~34% land with the shipped heightmap.bin
# kept staged for non-streaming fallback), a sky entity (skybox + sun) and
# a water entity (waterPlane + waterPhysics), modeled on town8.json. The
# bounded streaming world spans [-1000, 9000] on both axes around the
# terrain entity at the origin, so all exterior content (exteriorOnly
# CellGrid shell, arrival_b, spawner s1, sky, water) is placed near the
# world center at ~(4000, 4000) on an island with 500+ units of dry land
# in every direction, with the grid floor and arrival marker resting on
# the terrain surface (heights probed from the engine) and the water level
# at Y 6 so the site (terrain ~10) stays dry while everything below is
# ocean floor. The sky entity sits at the content site because
# EditorSunSystem keeps the sun's light node on its transform node (the
# sun/moon spheres themselves orbit the camera). The interior scene is
# unchanged: an "interrior" entity
# with a CellGridComponent (a room with a floor, ceiling, interior walls
# and an exit door) that carries its own ProceduralMaterial +
# ProceduralTexture. The grid's texture rectangle names reference the
# texture's named rects ("floor" / "ceiling"), demonstrating material/UV
# pickup from a grid entity 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, the
# character prefab (prefabs/char_2.json, written by a previous editor/game
# run) and any runtime config JSONs here, and symlinks both demo scenes
# (demo_scene_interior.json / demo_scene_exterior.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; the terrain heightmap is staged separately
# by configure_file (see below) so source changes re-copy it on the next
# build. Run it from here:
# cd <build>/src/features/editScene/demos/demo-interior-exterior-dynamics
# ./demoInteriorExteriorDynamics
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
# --- Embedded project configuration (F8 release binary) ---------------
# Read project.json at configure time and embed its parameters into the
# binary via a generated project.h, so the release binary is attached to
# its project directory without needing --project. Editing project.json
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${PROJECT_JSON_PATH}")
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
if(PROJECT_GAME_MODE)
set(PROJECT_GAME_MODE_VALUE 1)
else()
set(PROJECT_GAME_MODE_VALUE 0)
endif()
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
# Terrain heightmap for the exterior scene's terrain entity (terrainId
# 4242424300000001, heightmapFile heightmap.bin TerrainSystem resolves it
# as heightmaps/<terrainId>/<heightmapFile> relative to the CWD). Generated
# in the source tree by gen_heightmap.py. The terrain runs in streaming
# mode (base heights come from baseNoise, not the heightmap), but the file
# stays staged so flipping streamingEnabled off keeps working. A
# configure_file COPYONLY copy is used on purpose: it registers a configure
# dependency on the source file, so regenerating heightmap.bin re-copies it
# into the staging directory on the next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
add_executable(demoInteriorExteriorDynamics
demo_main.cpp
${DEMO_SOURCES}
)
target_compile_definitions(demoInteriorExteriorDynamics
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
add_dependencies(demoInteriorExteriorDynamics morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoInteriorExteriorDynamics PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoInteriorExteriorDynamics
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoInteriorExteriorDynamics PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoInteriorExteriorDynamics POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-interior-exterior-dynamics standalone runtime"
)
@@ -0,0 +1,975 @@
#include <iostream>
#include <cstdlib>
#include "EditorApp.hpp"
#include "ProjectConfig.hpp"
#include "camera/EditorCamera.hpp"
#include "systems/CharacterRegistry.hpp"
#include "systems/DoorSystem.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/TerrainSystem.hpp"
#include "systems/EventBus.hpp"
#include "systems/GlobalStateStore.hpp"
#include "components/Door.hpp"
#include "components/EntityName.hpp"
#include "components/Transform.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
#include <filesystem>
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
#include "project.h"
#endif
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/* Debug capture aid (for offscreen/xvfb screenshots):
* --force-pos x y z yawDeg [pitchDeg]
* teleport the player character once, as
* soon as it is alive (physics picks the
* new position up through the usual
* node->physics sync, gravity/buoyancy
* then act naturally); yaw/pitch (degrees)
* are re-applied to the TPS camera every
* frame via PlayerControllerSystem;
* --screenshot N path write frame N of the render window to
* path (RenderWindow::writeContentsToFile)
* and quit.
* --debug-buoyancy enable BuoyancySystem debug logging (same
* as the editor's --debug-buoyancy). */
struct DebugCaptureListener : public Ogre::FrameListener {
EditorApp *app;
bool teleportEnabled = false;
bool teleported = false;
Ogre::Vector3 teleportPos = Ogre::Vector3::ZERO;
float teleportYawDeg = 0.0f;
float teleportPitchDeg = 0.0f;
int shotFrame = -1;
Ogre::String shotFile;
int frame = 0;
DebugCaptureListener(EditorApp *a) : app(a) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (teleportEnabled) {
flecs::entity player = app->getPlayerCharacterEntity();
if (player.is_alive() &&
player.has<TransformComponent>()) {
TransformComponent &t =
player.get_mut<TransformComponent>();
if (t.node) {
/* Re-teleport while the character is below
* the terrain surface at the target: the
* streaming terrain colliders may not be
* built yet on the first frames, so the
* character can fall through. Once the
* collider exists it stands (or floats in
* deep water) at/above the surface and the
* teleport stops. */
bool fellThrough = false;
TerrainSystem *ts =
TerrainSystem::getInstance();
if (teleported && ts && ts->isActive()) {
float ground = ts->getHeightAt(
teleportPos);
fellThrough =
t.node->getPosition().y <
ground - 0.5f;
}
if (!teleported || fellThrough) {
t.node->setPosition(teleportPos);
t.node->setOrientation(Ogre::Quaternion(
Ogre::Degree(teleportYawDeg),
Ogre::Vector3::UNIT_Y));
teleported = true;
}
}
}
}
/* The TPS camera yaw lives in PlayerControllerSystem state,
* not in the character node orientation, so re-apply the
* requested yaw/pitch every frame once teleported. */
if (teleported && app->getPlayerControllerSystem())
app->getPlayerControllerSystem()->setControllerYawPitch(
teleportYawDeg, teleportPitchDeg);
if (shotFrame > 0 && frame >= shotFrame) {
app->getRenderWindow()->writeContentsToFile(shotFile);
app->getRoot()->queueEndRendering();
}
return true;
}
};
/* Same application as the editor/game, but with a larger initial window. */
class DemoApp : public EditorApp {
public:
using EditorApp::EditorApp;
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override
{
(void)w;
(void)h;
return EditorApp::createWindow(name, 1920, 1080, miscParams);
}
};
/*
* The demo floor mesh and its flat colored material are created
* programmatically because RenderableComponent only references a mesh by
* name and carries no material/color of its own. Only the interior scene
* needs one (the green "DemoFloorPlaneInterior"); the exterior scene
* walks on the terrain instead.
*/
static void createFloorMesh(const Ogre::String &meshName,
const Ogre::String &materialName,
float dr, float dg, float db)
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName(meshName, group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
materialName, group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(dr, dg, db, 1.0f);
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
mesh->getSubMesh(0)->setMaterialName(materialName);
}
static void createDemoResources()
{
/* Interior scene: green floor. The exterior scene has no floor
* mesh the terrain provides the ground. */
createFloorMesh("DemoFloorPlaneInterior", "DemoFloorMaterialInterior",
0.35f, 0.5f, 0.35f);
}
/*
* End-to-end check for --test-switch: drives the same path an E-press on
* a scene-switch door takes (DoorComponent toggleRequested +
* sceneSwitchPending -> DoorSystem swing -> EditorApp::switchScene()
* queue once the leaf is fully open -> performSceneSwitch() on the next
* frame with the "@arrival_*" teleport), for a full
* interior -> exterior -> interior round trip: the interior scene exits
* through the "interrior" grid's door Z:0:0:15 and the exterior scene
* returns through its own grid's door Z:0:0:0. The prompt/targeting
* glue is screen-space and therefore not covered headless.
*/
struct SceneSwitchTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 10; /* 10-12: F6 locked door, then 0-3 scene switching */
bool failed = false;
Ogre::String failReason;
/* F6 demo door: internal doorway Z:0:0:8 of the "interrior" grid,
* lockable + locked by default (doorConfigs in
* demo_scene_interior.json); the inline scene script unlocks it on
* the first door_locked bump. */
const Ogre::String doorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8";
/* F1 demo doors: the interior scene exits through the external
* doorway Z:0:0:15 of the "interrior" grid (scene-switch door to
* demo_scene_exterior.json), the exterior scene returns through the
* external doorway Z:0:0:0 of its own exteriorOnly grid
* (scene-switch door back to demo_scene_interior.json). For both
* legs the switch must fire only when the leaf is fully open. */
const Ogre::String exitDoorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:15";
const Ogre::String returnDoorId =
"77370b1d-e8ab-44a6-865b-e55c15b0fc78:Z:0:0:0";
int exitOpenWait = 0;
int returnOpenWait = 0;
int arrivalDeadline = 0;
/* arrival_b in the exterior scene: 3 units outside (-Z of) the
* exteriorOnly grid's return door Z:0:0:0 near the streaming-world
* center. The expected Y is the terrain surface height there, read
* back from the engine (streaming terrain: base heights come from
* baseNoise, so no flat Y can be hardcoded). */
Ogre::Vector3 expectedArrivalB() const
{
Ogre::Vector3 expected(4000.6f, 0.0f, 3995.9f);
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->isActive())
expected.y = ts->getHeightAt(ts->worldToRender(
expected.x, 0.0, expected.z));
return expected;
}
SceneSwitchTestListener(EditorApp *a)
: app(a)
{
}
bool entityExists(const char *name)
{
bool found = false;
app->getWorld()->query<EntityNameComponent>().each(
[&](flecs::entity, EntityNameComponent &n) {
if (n.name == name)
found = true;
});
return found;
}
/* The F6 demo door entity (recreated on every scene load). */
flecs::entity findDoor()
{
return findDoorById(doorId);
}
flecs::entity findDoorById(const Ogre::String &id)
{
flecs::entity result = flecs::entity::null();
app->getWorld()->query<DoorComponent>().each(
[&](flecs::entity e, DoorComponent &d) {
if (d.doorId == id)
result = e;
});
return result;
}
/* Emulate the E-press on a scene-switch door. */
bool requestDoorSwitch(const Ogre::String &id)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
DoorComponent &d = door.get_mut<DoorComponent>();
if (!d.occluder) {
failed = true;
failReason = "F1 scene-switch door has no occluder";
return true;
}
d.toggleRequested = true;
d.sceneSwitchPending = true;
return true;
}
/* Waits for the queued scene switch of a scene-switch door;
* returns true once the switch to expectPath is pending (the door
* must be fully open by then). */
bool waitDoorSwitch(const Ogre::String &id, const Ogre::String &label,
const char *expectPath, int &openWait)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
const DoorComponent &d = door.get<DoorComponent>();
bool fullyOpen = d.isOpen && d.currentAngle == d.openAngle;
if (app->hasPendingSceneSwitch()) {
if (!fullyOpen) {
failed = true;
failReason =
"F1 scene switch fired before the door was fully open";
return true;
}
if (app->getPendingSceneSwitchPath() != expectPath) {
failed = true;
failReason = "F1 wrong pending switch path";
return true;
}
std::cout << "[test] F1 " << label
<< " fully open, switch queued" << std::endl;
return true;
}
if (fullyOpen) {
/* Frame-listener ordering grace. */
if (++openWait > 5) {
failed = true;
failReason = "F1 scene switch never fired";
}
} else {
openWait = 0;
}
return false;
}
bool playerPos(Ogre::Vector3 &out) const
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
out = t.node ? t.node->_getDerivedPosition() : t.position;
return true;
}
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
{
Ogre::Vector3 pos;
if (!playerPos(pos))
return false;
if (pos.distance(expected) > tol) {
failed = true;
failReason = "player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
")";
}
return true;
}
/* The arrival markers face -Z (both scenes keep the same facing
* convention; the exterior arrival_b at (4000.6, ~, 3995.9) faces
* away from the return door); verify the camera took a position
* behind the character (relative to charPos) looking at the walking
* surface, and that the character's visual facing (local +Z) points
* the same way. */
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Quaternion q =
t.node ? t.node->getOrientation() : t.rotation;
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
EditorCamera *ec = app->getEditorCamera();
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
Ogre::SceneNode *camNode =
cam ? cam->getParentSceneNode() : nullptr;
if (!camNode)
return false;
Ogre::Vector3 cp = camNode->_getDerivedPosition();
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
Ogre::Vector3::NEGATIVE_UNIT_Z;
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
charFacing.z > -0.9f) {
failed = true;
failReason =
"not facing the walking surface: camera pos (" +
Ogre::StringConverter::toString(cp.x) + ", " +
Ogre::StringConverter::toString(cp.y) + ", " +
Ogre::StringConverter::toString(cp.z) +
") view (" +
Ogre::StringConverter::toString(vd.x) + ", " +
Ogre::StringConverter::toString(vd.y) + ", " +
Ogre::StringConverter::toString(vd.z) +
") character facing (" +
Ogre::StringConverter::toString(charFacing.x) +
", " +
Ogre::StringConverter::toString(charFacing.z) +
")";
}
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 3) {
app->getRoot()->queueEndRendering();
return true;
}
/* The swings are real-time (doorOpenSpeed deg/s) while a
* headless frame is sub-millisecond, so the door legs need a
* few thousand frames (~10 s wall clock at 1000+ fps); the
* streaming terrain window and the grounding watchdog add a
* few hundred frames on top. */
if (frame > 20000) {
failed = true;
failReason = "timeout waiting for scene switch";
app->getRoot()->queueEndRendering();
return true;
}
switch (phase) {
case 10: {
/* F6: the demo door starts locked (lockedByDefault). */
if (frame < 5)
break;
flecs::entity door = findDoor();
if (!door.is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F6 demo door not found";
}
break;
}
if (!DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason = "F6 demo door not locked at start";
break;
}
/* Emulate the ActuatorSystem E-press on a locked door;
* the inline scene script answers door_locked with
* door_unlock_<doorId>. */
EventBus::getInstance().send("door_locked", "door_id",
doorId);
phase = 11;
break;
}
case 11: {
/* F6: wait for the scene script to unlock the door
* (the frame timeout covers a missing handler). */
if (DoorSystem::isDoorLockedById(doorId))
break;
flecs::entity door = findDoor();
if (!door.is_alive())
break;
door.get_mut<DoorComponent>().toggleRequested = true;
std::cout << "[test] F6 door unlocked via scene script, "
"opening"
<< std::endl;
phase = 12;
break;
}
case 12: {
/* F6: the swing completes and the open state lands in
* the global store. */
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d = door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle)
break;
if (!GlobalStateStore::getInstance().getBool(
"door." + doorId + ".isOpen")) {
failed = true;
failReason = "F6 door open state not persisted";
break;
}
std::cout << "[test] F6 door open and persisted"
<< std::endl;
phase = 0;
break;
}
case 0: {
/* F1: interior -> exterior through the exit scene-switch
* door. */
if (frame < 5)
break;
if (!findDoorById(exitDoorId).is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F1 exit door not found";
}
break;
}
/* F1: every scene-switch door gets the void-side
* tunnel occluder (depth covers the leaf sweep,
* so both swing directions stay visible) plus the
* gap shield backing the leaf/frame clearance
* slits while the door is closed. */
{
const DoorComponent &d =
findDoorById(exitDoorId)
.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 exit door has no tunnel occluder";
break;
}
if (!d.gapShield ||
d.gapShield->getMesh()
->getName()
.find("CellGridDoorOccluderShield") !=
0) {
failed = true;
failReason =
"F1 exit door has no gap shield";
break;
}
/* The door is still closed here: the shield
* backs the slits, the tunnel stays hidden. */
if (!d.gapShield->isVisible() ||
d.occluder->isVisible()) {
failed = true;
failReason =
"F1 closed-door occluder visibility wrong";
break;
}
/* The tunnel walls must reach past the
* leaf/frame clearances (0.02 m sides,
* 0.05 m top) or the void shows between the
* frame and the black corridor while the
* door is open; both meshes are hinge-local,
* so the tunnel footprint must cover at
* least the gap shield's (6 cm margin). */
{
Ogre::Vector3 th =
d.occluder->getMesh()
->getBounds()
.getHalfSize();
Ogre::Vector3 sh =
d.gapShield->getMesh()
->getBounds()
.getHalfSize();
if (th.x < sh.x - 0.001f ||
th.y < sh.y - 0.001f) {
failed = true;
failReason =
"F1 tunnel walls inside the leaf/frame clearances";
break;
}
}
}
if (!requestDoorSwitch(exitDoorId))
break;
phase = 13;
break;
}
case 13: {
/* F1: the scene switch must fire only once the leaf
* is fully open. */
if (waitDoorSwitch(exitDoorId, "exit door",
"demo_scene_exterior.json",
exitOpenWait))
phase = 1;
break;
}
case 1: {
if (!entityExists("x1"))
break;
if (!arrivalDeadline)
arrivalDeadline = frame + 600;
/* The teleport clamps Y against whatever height source is
* available at switch time (analytic baseNoise fallback
* until the pages around the camera stream in), then the
* grounding watchdog re-clamps against the real terrain
* for ~120 frames; poll across frames until the player
* settles at the arrival point instead of checking once. */
Ogre::Vector3 expected = expectedArrivalB();
Ogre::Vector3 pos;
if (!playerPos(pos))
break;
if (pos.distance(expected) > 1.0f) {
if (frame < arrivalDeadline)
break;
failed = true;
failReason =
"player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
"), expected (" +
Ogre::StringConverter::toString(
expected.x) +
", " +
Ogre::StringConverter::toString(
expected.y) +
", " +
Ogre::StringConverter::toString(
expected.z) +
")";
break;
}
if (!checkCameraFacesCenter(expected))
break;
if (failed)
break;
/* The island site must be dry: terrain height at the
* grid XZ above the water surface (Y 6). Doubles as an
* in-engine cross-check of the offline baseNoise mapping
* used to place the exterior content. */
{
TerrainSystem *ts = TerrainSystem::getInstance();
float h = ts && ts->isActive()
? ts->getHeightAt(
ts->worldToRender(4000.0,
0.0,
4000.0))
: 0.0f;
if (h <= 6.0f) {
failed = true;
failReason =
"island site is not dry";
break;
}
}
std::cout << "[test] arrived in the exterior scene at "
"arrival_b, camera faces the walking surface"
<< std::endl;
/* F1: exterior -> interior through the exterior
* scene's own scene-switch door (external doorway
* Z:0:0:0 of its exteriorOnly grid). */
if (!findDoorById(returnDoorId).is_alive()) {
failed = true;
failReason = "F1 return door not found";
break;
}
if (!requestDoorSwitch(returnDoorId))
break;
phase = 14;
break;
}
case 14:
/* F1: same fully-open contract on the way back. */
if (waitDoorSwitch(returnDoorId, "return door",
"demo_scene_interior.json",
returnOpenWait))
phase = 2;
break;
case 2:
if (!entityExists("interrior"))
break;
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
/* F6: the door rebuilt with the scene must be snapped
* back to the persisted open + unlocked state. */
{
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d =
door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle ||
DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason =
"F6 door state not restored after scene switch";
break;
}
std::cout << "[test] F6 door state restored "
"after scene switch"
<< std::endl;
}
/* F1: the scene-switch exit door rebuilt with the
* scene must be closed again - scene-switch doors
* never restore the persisted open state. */
{
flecs::entity exitDoor =
findDoorById(exitDoorId);
if (!exitDoor.is_alive())
break;
const DoorComponent &d =
exitDoor.get<DoorComponent>();
if (d.isOpen || d.currentAngle != 0.0f ||
GlobalStateStore::getInstance().getBool(
"door." + exitDoorId + ".isOpen")) {
failed = true;
failReason =
"F1 exit door not closed after scene switch";
break;
}
std::cout << "[test] F1 exit door closed after "
"scene switch"
<< std::endl;
}
if (!failed) {
std::cout << "[test] arrived back in the interior "
"scene at arrival_a, camera faces the "
"walking surface"
<< std::endl;
std::cout << "[test] PASS" << std::endl;
}
phase = 3;
break;
}
return true;
}
};
/*
* demo-interior-exterior-dynamics: same scene-switching demo as
* demos/demo-scene-switching-extra, but the exterior scene is a real
* outdoor environment instead of a flat colored floor. Two
* hand-authored scenes (demo_scene_interior.json and
* demo_scene_exterior.json), each with a character spawner ("s1",
* character registry ID 2, same character setup as town8.json) and a
* PlayerControllerComponent targeting it.
*
* The interior scene holds a flat colored floor plane with a static
* physics collider plus an "interrior" entity with a CellGridComponent
* in interiorOnly generation mode (a room with floor, ceiling, interior
* walls, windows with opaque glass and an exit door) carrying its own
* ProceduralMaterial + ProceduralTexture; the grid's texture rectangle
* names reference the texture's named rects ("floor" / "ceiling")
* without any Lot/District/Town parent.
*
* The exterior scene holds an exteriorOnly CellGridComponent (entity
* "x1"/"w1" - just the building shell with opaque window glass, seen
* from the outside) standing on a STREAMING terrain entity
* (streamingEnabled, worldSizeUnits 10000 -> 5x5 pages of 2000 units,
* base heights from TerrainComponent::baseNoise: OpenSimplex2 seed 55,
* 3 octaves, frequency 0.0003, amplitude 15 - an archipelago of large
* islands, ~34% land, the rest is ocean). The bounded streaming world
* spans [-1000, 9000] on both axes around the terrain entity at the
* origin (world (0,0) is half a page in from the corner, NOT the world
* center), so all exterior content is placed near the world center on
* an island with more than 500 units of dry land in every direction:
* the grid at (4000, 10.0148, 4000) with its floor exactly on the
* terrain surface, arrival_b at (4000.6, 9.9905, 3995.9) 3 units
* outside (-Z of) the return door Z:0:0:0, spawner s1 at (4000,
* 9.9039, 3975) - all Y values probed from the engine's
* TerrainSystem::getHeightAt. The "sky" entity (skybox + sun
* components) sits at (4000, 10, 4000): EditorSunSystem parents the
* sun/moon sphere nodes to the root node and orbits them around the
* camera (orbit radius 200), so they always stay inside the
* camera-following skybox cube and line up with the shader sun/moon
* discs, but the light node stays a child of the sky entity's
* transform node, so the entity should still sit near the content for
* sensible shadow placement (town8.json keeps its sky entity at the
* origin next to its content). A "water"
* entity (waterPlane + waterPhysics components, the buoyancy setup of
* town8.json, planeSize 12000 centered on the content) completes the
* scene; the water surface at Y 6 turns everything below into ocean
* floor while the content island (terrain ~10 at the site) stays dry.
* The shipped heightmaps/4242424300000001/heightmap.bin (generated by
* gen_heightmap.py) is still staged - unused in streaming mode, it keeps
* flipping streamingEnabled off a working fallback.
*
* F6 demo content: the interior grid's internal doorway Z:0:0:8 is
* configured (doorConfigs in demo_scene_interior.json) as persistent +
* lockable + locked by default, with a fixed gridUid so its global door
* ID is stable, and the grid entity carries an inline scene script that
* answers the "door_locked" event with "door_unlock_<doorId>" (the door
* event contract; the door never opens by itself, the player presses E
* again). The door's locked and open/closed state lives in the global
* state store and survives the interior -> exterior -> interior scene
* switches.
*
* F1 demo content: both scene transitions go through scene-switch doors.
* The interior grid has the external exit doorway Z:0:0:15 configured as
* a scene-switch door to demo_scene_exterior.json (target arrival_b);
* the exterior grid sets
* doorSceneSwitchPath/demo_scene_interior.json +
* doorSceneSwitchTarget/arrival_a grid-wide, so its external doorway
* Z:0:0:0 is the way back. E on such a door swings the leaf open first;
* the scene switch fires only when the leaf reaches the open angle, and
* a black occluder box behind the doorway hides the missing half of the
* building while it swings; while the door is closed a flat black gap
* shield behind the leaf blacks out the leaf/frame clearance slits, so
* no sky/terrain bleeds through the closed door. Both scenes carry
* their own player
* controller, so each switch is a clean takeover (see
* EditorApp::performSceneSwitch); travel back and forth is endless.
*
* The mouse is grabbed while Playing (built-in game-mode behaviour);
* Escape toggles the pause menu, which frees the cursor.
*
* The binary is self-contained in its build directory: run it from there
* (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; the terrain heightmap is staged to
* heightmaps/4242424300000001/ by configure_file, re-copied automatically
* when the source file changes).
*
* Extra flags:
* --test-switch headless-friendly end-to-end check: verifies the F6
* locked door (locked at start, unlocked by the scene
* script through the door event contract, open state
* persisted and restored across the scene switches) and
* both F1 scene-switch doors (interior -> exterior and
* back each fire only when the leaf is fully open,
* black occluder tunnel + closed-door gap shield
* present with the right closed-state visibility),
* verifying the round-trip
* arrival teleports (the exterior arrival check reads
* the expected Y from TerrainSystem::getHeightAt and
* polls while the streaming window loads and the
* grounding watchdog re-clamps); exits 0 on PASS.
*/
int main(int argc, char *argv[])
{
try {
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
/* Release binary: project parameters are embedded at build
* time from project.json (generated project.h); the project
* root is the working directory the binary runs from. */
DemoApp app(EDITSCENE_PROJECT_APP_NAME);
{
ProjectConfig cfg;
cfg.rootDir =
std::filesystem::current_path().string();
cfg.appName = EDITSCENE_PROJECT_APP_NAME;
cfg.startScene = EDITSCENE_PROJECT_START_SCENE;
cfg.gameMode = EDITSCENE_PROJECT_GAME_MODE;
cfg.loaded = true;
app.setProjectConfig(cfg);
}
#else
DemoApp app;
#endif
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
bool testSwitch = false;
bool sceneArgGiven = false;
Ogre::String shotFile;
int shotFrame = -1;
bool forcePos = false;
Ogre::Vector3 forcePosVec = Ogre::Vector3::ZERO;
float forcePosYaw = 0.0f;
float forcePosPitch = 0.0f;
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE;
#else
Ogre::String sceneFile = "demo_scene_interior.json";
#endif
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg == "--test-switch") {
testSwitch = true;
} else if (arg == "--debug-buoyancy") {
app.setDebugBuoyancy(true);
} else if (arg == "--screenshot" && i + 2 < argc) {
shotFrame = atoi(argv[i + 1]);
shotFile = argv[i + 2];
i += 2;
} else if (arg == "--force-pos" && i + 4 < argc) {
forcePos = true;
forcePosVec = Ogre::Vector3(
(float)atof(argv[i + 1]),
(float)atof(argv[i + 2]),
(float)atof(argv[i + 3]));
forcePosYaw = (float)atof(argv[i + 4]);
i += 4;
/* Optional pitch (degrees); only consumed when
* the next token is plainly numeric, so the
* positional scene file is never eaten. */
if (i + 1 < argc) {
char *end = nullptr;
float p = (float)strtod(argv[i + 1],
&end);
if (end && *end == '\0' &&
end != argv[i + 1]) {
forcePosPitch = p;
i += 1;
}
}
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
sceneArgGiven = true;
}
}
app.setHeadless(headless);
app.initApp();
if (headless) {
/* Headless mode never creates EditorUISystem, which
* owns the CharacterRegistry singleton; the demo scene
* has a character spawner, so provide a bare registry
* to keep spawner resolution from asserting. The
* character then falls back to an inline spawn without
* a physics capsule (fine for a smoke run). */
static CharacterRegistry s_characterRegistry;
s_characterRegistry.setWorld(app.getWorld());
s_characterRegistry.setSceneManager(app.getSceneManager());
s_characterRegistry.initialize();
}
/* Meshes + materials referenced by the scene entities. */
createDemoResources();
/* With an embedded project, EditorApp::setup() skipped the
* startup menu; the start scene defaults to the embedded
* one and can still be overridden positionally. */
(void)sceneArgGiven;
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, E = use door, Escape = pause menu"
<< std::endl;
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame && !testSwitch)
app.getRoot()->addFrameListener(&exitListener);
SceneSwitchTestListener testListener(&app);
if (testSwitch)
app.getRoot()->addFrameListener(&testListener);
DebugCaptureListener captureListener(&app);
if (forcePos) {
captureListener.teleportEnabled = true;
captureListener.teleportPos = forcePosVec;
captureListener.teleportYawDeg = forcePosYaw;
captureListener.teleportPitchDeg = forcePosPitch;
}
if (shotFrame > 0 && !shotFile.empty())
captureListener.shotFrame = shotFrame;
captureListener.shotFile = shotFile;
if (forcePos || captureListener.shotFrame > 0)
app.getRoot()->addFrameListener(&captureListener);
app.getRoot()->startRendering();
if (testSwitch) {
if (testListener.failed) {
std::cerr << "[test] FAIL: "
<< testListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (testListener.phase != 3) {
std::cerr << "[test] FAIL: incomplete"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
/* Destroy scene entities while the systems are still alive:
* CharacterSpawnerSystem registers an OnRemove observer on
* CharacterSpawnerComponent that dereferences the system, and
* the flecs world only dies after destroyEditorSystems() in
* ~EditorApp, so letting the spawner entity live that long
* would call back into a destroyed system. */
app.clearScene();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
@@ -0,0 +1,771 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 488,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 4000.60009765625,
"y": 9.990500450134277,
"z": 3995.89990234375
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [
{
"cellGrid": {
"ceilingRectName": "",
"cellHeight": 4.0,
"cellSize": 2.0,
"cells": [
{
"flags": 37757955,
"x": -1,
"y": 0,
"z": 0
},
{
"flags": 2097667,
"x": 0,
"y": 0,
"z": 0
},
{
"flags": 41953283,
"x": 1,
"y": 0,
"z": 0
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 1
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 1
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 1
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 2
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 2
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 2
},
{
"flags": 20976643,
"x": -1,
"y": 0,
"z": 3
},
{
"flags": 16781315,
"x": 0,
"y": 0,
"z": 3
},
{
"flags": 25171971,
"x": 1,
"y": 0,
"z": 3
}
],
"depth": 10,
"doorActionName": "",
"doorConfigs": {},
"doorMeshName": "",
"doorOpenAngle": 100.0,
"doorOpenSpeed": 180.0,
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_interior.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": false,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
"extWallRectName": "",
"extWindowFrameRectName": "",
"floorRectName": "",
"friction": 0.5,
"furnitureCells": [],
"generationMode": "exteriorOnly",
"generationScript": "",
"glassColor": [
0.4000000059604645,
0.6000000238418579,
0.800000011920929,
0.3499999940395355
],
"glassMaterialName": "",
"glassReflectivity": 0.800000011920929,
"gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78",
"height": 1,
"intDoorFrameRectName": "",
"intWallRectName": "",
"intWindowFrameRectName": "",
"roofSideRectName": "",
"roofTopRectName": "",
"width": 10
},
"children": [
{
"children": [],
"clearArea": {
"clearCells": true,
"clearFurniture": true,
"clearRoofs": false,
"clearRooms": false,
"maxX": 10,
"maxY": 1,
"maxZ": 10,
"minX": -10,
"minY": 0,
"minZ": -10
},
"id": 491,
"name": {
"name": "c1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 492,
"name": {
"name": "r1"
},
"room": {
"connectedRoomIds": [],
"createCeiling": true,
"createFloor": true,
"createInteriorWalls": true,
"createWindows": true,
"exits": [
true,
false,
false,
false
],
"fillRoomWithFurniture": false,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
"maxY": 1,
"maxZ": 4,
"minX": -1,
"minY": 0,
"minZ": 0,
"persistentId": "room_1788689581735260047_499",
"roomType": "",
"tags": []
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 490,
"name": {
"name": "w1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 489,
"name": {
"name": "x1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.014800071716309,
"z": 4000.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": 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": 4000.0,
"y": 20.0,
"z": 4000.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": 494,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 4000.0,
"y": 9.903900146484375,
"z": 3975.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": 495,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 496,
"name": {
"name": "terrain"
},
"terrain": {
"auxMaps": [],
"baseNoise": {
"amplitude": 15.0,
"frequency": 0.0003000000142492354,
"lacunarity": 2.0,
"octaves": 3,
"persistence": 0.5,
"seed": 55
},
"blendMapSize": 1024,
"compositeMapDistance": 300.0,
"detailNoise": {
"amplitude": 10.0,
"enabled": false,
"frequency": 0.006000000052154064,
"lacunarity": 2.0,
"octaves": 4,
"persistence": 0.5,
"seed": 26368
},
"enabled": true,
"farClipDistance": 6000.0,
"fogEnabled": true,
"fogEnd": 5500.0,
"fogStart": 2500.0,
"heightmapFile": "heightmap.bin",
"heightmapSize": 256,
"layers": [
{
"diffuseTexture": "Ground23_col.jpg",
"name": "Base",
"normalTexture": "Ground23_normheight.dds",
"worldSize": 100.0
},
{
"diffuseTexture": "Ground37_diffspec.dds",
"name": "Layer 1",
"normalTexture": "Ground37_normheight.dds",
"worldSize": 100.0
}
],
"maxBatchSize": 65,
"maxPixelError": 1.0,
"minBatchSize": 17,
"pageHoldRadius": 3,
"pageLoadRadius": 2,
"roadConfig": {
"laneWidth": 3.0,
"lanesPerDirection": 1,
"prefabDespawnDistance": 250.0,
"prefabSpawnDistance": 150.0,
"roadLodDistance": 200.0,
"roadMaterialName": "RoadMaterial",
"roadMeshTemplate": "road_segment.mesh",
"roadThickness": 0.30000001192092896,
"roadVisibilityDistance": 1000.0,
"sidewalkEnabled": false,
"sidewalkHeight": 0.15000000596046448,
"sidewalkMeshTemplate": "",
"sidewalkThickness": 0.30000001192092896,
"sidewalkWidth": 1.5
},
"streamingEnabled": true,
"terrainId": 4242424300000001,
"terrainSize": 65,
"worldSize": 2000.0,
"worldSizeUnits": 10000.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
}
}
},
{
"children": [],
"id": 497,
"name": {
"name": "sky"
},
"skybox": {
"cloudiness": 0.0,
"dayBottomColor": [
0.6000000238418579,
0.800000011920929,
1.0
],
"dayTopColor": [
0.20000000298023224,
0.5,
1.0
],
"enabled": true,
"moonSize": 0.029999999329447746,
"nightBottomColor": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"nightTopColor": [
0.0,
0.0,
0.05000000074505806
],
"size": 443.0,
"starsEnabled": false,
"sunSize": 0.05000000074505806,
"sunriseColor": [
1.0,
0.5,
0.20000000298023224
],
"sunsetColor": [
1.0,
0.30000001192092896,
0.10000000149011612
]
},
"sun": {
"ambientDay": [
0.30000001192092896,
0.30000001192092896,
0.30000001192092896
],
"ambientNight": [
0.05000000074505806,
0.05000000074505806,
0.15000000596046448
],
"ambientSunrise": [
0.30000001192092896,
0.20000000298023224,
0.15000000596046448
],
"ambientSunset": [
0.25,
0.15000000596046448,
0.10000000149011612
],
"castShadows": true,
"enabled": true,
"intensity": 1.7899999618530273,
"moonColor": [
0.30000001192092896,
0.30000001192092896,
0.5
],
"moonSphereSize": 3.4000000953674316,
"orbitTilt": 15.0,
"showMoonSphere": true,
"showSunSphere": true,
"sunColor": [
1.0,
0.949999988079071,
0.800000011920929
],
"sunSphereSize": 5.0,
"timeOfDay": 8.35942554473877,
"timeSpeed": 0.12999999523162842
},
"transform": {
"position": {
"x": 4000.0,
"y": 10.0,
"z": 4000.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": 498,
"name": {
"name": "water"
},
"transform": {
"position": {
"x": 4000.0,
"y": 0.0,
"z": 4000.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"waterPhysics": {
"defaultAngularDrag": 0.05000000074505806,
"defaultBuoyancy": 1.0,
"defaultLinearDrag": 0.25,
"defaultSubmergedThreshold": 0.10000000149011612,
"enabled": true,
"gravity": 9.8100004196167,
"waterDensity": 1000.0,
"waterSurfaceY": 6.0
},
"waterPlane": {
"autoUpdateFromWaterPhysics": true,
"enabled": true,
"planeSize": 12000.0,
"reflectivity": 0.3799999952316284,
"renderTextureSize": 512,
"tiling": 0.012000000104308128,
"waterColor": [
0.0,
0.30000001192092896,
0.5,
0.800000011920929
],
"waterSurfaceY": 6.0,
"waveScale": 0.03099999949336052,
"waveSpeed": 0.9800000190734863
}
}
],
"version": "1.0"
}
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
streaming mode, where base heights come from the terrain's baseNoise
(FastNoiseLite) and this file is NOT sampled it is only staged next to
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
finds it. This script documents the historical non-streaming layout and
is kept as the generator of record for that staged file.
Legacy description (non-streaming mode):
The exterior scene's terrain entity (terrainId 4242424300000001,
non-streaming, worldSize 500 per page) loads its base heights from
heightmaps/4242424300000001/heightmap.bin via
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
then res*res little-endian float32 heights.
Height profile: a flat disc at y=0 around the origin (the playable area
with the building shell and the scene-switch arrival point), sloping
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
as a lake around the centre.
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
loads a fixed 3x3 page grid, so the heightmap covers the physical world
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
the height at physical coordinate (-ws + i * 3*ws / res). Physical
space maps to visual (scene) space by a half-page shift on X and a
per-page Z mirror; this script inverts that mapping so every texel gets
the height of the *visual* position it will be rendered at, then
verifies the round trip by re-sampling the generated grid the same
bilinear way the engine does.
"""
import math
import struct
import sys
RES = 256 # must match "heightmapSize" in the scene JSON
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
EDGE_RADIUS = 160.0 # slope reaches full depth here
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
def profile(r):
"""Visual height at distance r from the origin."""
if r <= FLAT_RADIUS:
return 0.0
if r >= EDGE_RADIUS:
return DEPTH
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
t = t * t * (3.0 - 2.0 * t) # smoothstep
return DEPTH * t
def physical_to_visual(px, pz):
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
vx = px - WORLD_SIZE * 0.5
page = math.floor(pz / WORLD_SIZE)
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
return vx, vz
def visual_to_physical(vx, vz):
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
px = vx + WORLD_SIZE * 0.5
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
return px, pz
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
span = 3.0 * WORLD_SIZE
origin = -WORLD_SIZE
grid = []
for iz in range(RES):
row = []
pz = origin + iz * span / RES
for ix in range(RES):
px = origin + ix * span / RES
vx, vz = physical_to_visual(px, pz)
row.append(profile(math.hypot(vx, vz)))
grid.append(row)
with open(out_path, "wb") as f:
f.write(struct.pack("<I", RES))
for row in grid:
f.write(struct.pack("<%df" % RES, *row))
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
# (bilinear, physical coords) at a few visual positions.
def sample(vx, vz):
px, pz = visual_to_physical(vx, vz)
fx = (px - origin) / span * RES
fz = (pz - origin) / span * RES
x0 = max(0, min(int(math.floor(fx)), RES - 1))
z0 = max(0, min(int(math.floor(fz)), RES - 1))
x1 = max(0, min(x0 + 1, RES - 1))
z1 = max(0, min(z0 + 1, RES - 1))
tx = fx - int(math.floor(fx))
tz = fz - int(math.floor(fz))
h00 = grid[z0][x0]
h10 = grid[z0][x1]
h01 = grid[z1][x0]
h11 = grid[z1][x1]
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
(1 - tx) * tz * h01 + tx * tz * h11)
checks = [
("arrival (0,24)", 0.0, 24.0),
("origin (0,0)", 0.0, 0.0),
("building corner (4,30)", 4.0, 30.0),
("flat edge (55,0)", 55.0, 0.0),
("mid slope (110,0)", 110.0, 0.0),
("lake (200,0)", 200.0, 0.0),
("lake (-200,200)", -200.0, 200.0),
]
ok = True
for label, vx, vz in checks:
h = sample(vx, vz)
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
% (label, vx, vz, h))
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
if abs(sample(vx, vz)) > 1e-4:
print("ERROR: playable area not flat at", vx, vz)
ok = False
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
if sample(vx, vz) > -4.9:
print("ERROR: lake area not deep enough at", vx, vz)
ok = False
if not ok:
sys.exit(1)
print("heightmap written to %s (%dx%d), all checks passed"
% (out_path, RES, RES))
if __name__ == "__main__":
main()
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,4 @@
{
"fontPath": "Jupiteroid-Bold.ttf",
"fontSize": 16.0
}
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-interior-exterior-dynamics",
"startScene": "demo_scene_interior.json",
"gameMode": true
}
@@ -0,0 +1,65 @@
# Stage everything demoInteriorExteriorDynamics needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-interior-exterior-dynamics.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
foreach(f demo_scene_interior.json demo_scene_exterior.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()
# Terrain heightmap: staged at configure time by a configure_file COPYONLY
# in CMakeLists.txt (to heightmaps/4242424300000001/), which re-copies it
# automatically when the source file changes. Not symlinked: the fixup
# layer and save path write next to it.
# Character prefab for the spawner's registry entry (registryId 2). It is
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest the demo needs
# character_registry.json for the spawner's registryId 2 and
# animation_tree.json for the character's "male1_6" animation tree). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
@@ -0,0 +1,86 @@
# ---------------------------------------------------------------------------
# demo-lua-scene-script game-mode demo for SceneScriptComponent
# ---------------------------------------------------------------------------
# Separate executable built from the same sources as editSceneEditor (minus
# main.cpp). It runs in game mode and loads demo_lua_scene_script.json,
# whose "demo_events" entity carries an inline SceneScriptComponent Lua
# script demonstrating scene_loaded / prefab_loaded / custom "demo_pulse"
# event handling. The demo prefab (demo_pulse_cube.json) carries its own
# inline script covering the prefab-local variant: prefab scripts run inside
# instantiatePrefab() right before the instance's prefab_loaded is sent, and
# work even when no scene script exists yet (e.g. in the startup menu scene).
#
# The executable is self-contained in this build directory: a POST_BUILD
# step (stage_runtime.cmake) copies resources.cfg, the demo scene/prefab and
# any runtime config JSONs here, and symlinks 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-lua-scene-script
# ./demoLuaSceneScript
# Controls: RMB drag = look, W/A/S/D = move, Q/E = down/up, Shift = boost,
# Escape = pause menu.
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
add_executable(demoLuaSceneScript
demo_main.cpp
${DEMO_SOURCES}
)
add_dependencies(demoLuaSceneScript morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoLuaSceneScript PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoLuaSceneScript
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoLuaSceneScript PRIVATE
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoLuaSceneScript POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-lua-scene-script standalone runtime"
)
@@ -0,0 +1,196 @@
{
"version": "1.0",
"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_ground"
},
"transform": {
"position": {
"x": 0.0,
"y": -0.2,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 30.0,
"y": 0.2,
"z": 30.0
}
},
"renderable": {
"meshName": "Cube.mesh",
"visible": true
},
"children": []
},
{
"id": 3,
"name": {
"name": "demo_cube"
},
"transform": {
"position": {
"x": -2.0,
"y": 0.5,
"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": "Cube.mesh",
"visible": true
},
"children": []
},
{
"id": 4,
"name": {
"name": "demo_pulse_target"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.5,
"z": -2.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": "Cube.mesh",
"visible": true
},
"children": []
},
{
"id": 5,
"name": {
"name": "demo_prefab_instance"
},
"transform": {
"position": {
"x": 2.0,
"y": 0.5,
"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
}
},
"prefabInstance": {
"prefabPath": "prefabs/demo_pulse_cube.json"
},
"children": []
},
{
"id": 6,
"name": {
"name": "demo_events"
},
"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
}
},
"sceneScript": {
"scriptPath": "",
"inlineScript": "-- demo-lua-scene-script: inline scene script.\n-- Executed once when the scene finishes loading; subscribes to the\n-- engine load events and to a custom \"demo_pulse\" event. Every handler\n-- prints a [demo] line to stdout AND visibly mutates an entity.\n\nprint(\"[demo] scene script executed: subscribing to events\")\n\n-- scene_loaded: sent by EditorApp::startNewGame() right after this script\n-- runs, so the subscription below always catches it.\necs.subscribe_event(\"scene_loaded\", function(event, params)\n print(\"[demo] scene_loaded: \" .. tostring(params and params.scenePath))\n -- Visibly mutate: lift the cube named \"demo_cube\" up by 2 units.\n local cube = ecs.get_entity_by_name(\"demo_cube\")\n if cube and ecs.has_component(cube, \"Transform\") then\n local t = ecs.get_component(cube, \"Transform\")\n ecs.set_component(cube, \"Transform\", {\n position = { t.position[1], t.position[2] + 2.0, t.position[3] },\n rotation = t.rotation,\n scale = t.scale,\n })\n print(\"[demo] scene_loaded: demo_cube moved up by +2.0 in Y\")\n end\n -- Fire the custom event so its handler runs as part of the load.\n ecs.send_event(\"demo_pulse\", { tick = 1 })\n print(\"[demo] scene_loaded: demo_pulse sent\")\nend)\n\n-- prefab_loaded: scene scripts run lazily at the first loadPendingScripts\n-- call, which happens inside PrefabSystem::resolveInstances() right before\n-- the instance's prefab_loaded is sent, so this handler fires even for\n-- prefabs instantiated during the same scene load. The inline script inside\n-- prefabs/demo_pulse_cube.json demonstrates the prefab-local variant, which\n-- also works when a prefab is instantiated before any scene script exists\n-- (e.g. in the startup menu scene).\necs.subscribe_event(\"prefab_loaded\", function(event, params)\n print(\"[demo] scene script saw prefab_loaded: \" ..\n tostring(params and params.prefabPath) ..\n \" entity=\" .. tostring(params and params.entityId))\nend)\n\n-- Custom event: scales the cube named \"demo_pulse_target\" by 2x.\necs.subscribe_event(\"demo_pulse\", function(event, params)\n print(\"[demo] demo_pulse received, tick=\" ..\n tostring(params and params.tick))\n local cube = ecs.get_entity_by_name(\"demo_pulse_target\")\n if cube and ecs.has_component(cube, \"Transform\") then\n local t = ecs.get_component(cube, \"Transform\")\n ecs.set_component(cube, \"Transform\", {\n position = t.position,\n rotation = t.rotation,\n scale = { t.scale[1] * 2.0, t.scale[2] * 2.0, t.scale[3] * 2.0 },\n })\n print(\"[demo] demo_pulse: demo_pulse_target scaled x2\")\n end\nend)\n"
},
"children": []
}
]
}
@@ -0,0 +1,167 @@
#include <iostream>
#include "EditorApp.hpp"
#include "GameMode.hpp"
#include "camera/EditorCamera.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
#include <SDL2/SDL.h>
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/*
* Demo fly camera. The demo scene has no player controller, so in game
* mode nothing drives the camera and EditorApp would only grab the mouse
* (setGamePlayState(Playing) -> setWindowGrab(true)). EditorApp consumes
* all SDL input events in game mode before chained listeners see them, so
* the controls are polled per frame via SDL_GetKeyboardState() /
* SDL_GetRelativeMouseState() the same approach GameInputState uses.
*
* Controls: RMB drag = look, W/A/S/D = move, Q/E = down/up, Shift = boost.
* Escape keeps the built-in game-mode behaviour (pause menu toggle).
*/
class DemoFlyCamera : public Ogre::FrameListener {
public:
DemoFlyCamera(EditorApp &app)
: m_app(app)
{
}
bool frameRenderingQueued(const Ogre::FrameEvent &evt) override
{
/* Keep the cursor free: game mode re-grabs the mouse whenever
* the Playing state is (re-)entered (also when leaving the
* pause menu), so ungrab on every transition into Playing. */
bool playing = editScene::isGamePlaying();
if (playing && !m_wasPlaying)
m_app.setWindowGrab(false);
m_wasPlaying = playing;
Ogre::Camera *cam = m_app.getEditorCamera()->getCamera();
if (!cam || !cam->getParentSceneNode())
return true;
Ogre::SceneNode *node = cam->getParentSceneNode();
/* Mouse look while the right button is held; the relative
* state is polled every frame so deltas stay current. */
int dx, dy;
uint32_t buttons = SDL_GetRelativeMouseState(&dx, &dy);
if (buttons & SDL_BUTTON(SDL_BUTTON_RIGHT)) {
m_yaw -= dx * 0.003f;
m_pitch -= dy * 0.003f;
m_pitch = Ogre::Math::Clamp(m_pitch, -1.5f, 1.5f);
}
node->setOrientation(
Ogre::Quaternion(Ogre::Radian(m_yaw),
Ogre::Vector3::UNIT_Y) *
Ogre::Quaternion(Ogre::Radian(m_pitch),
Ogre::Vector3::UNIT_X));
/* WASD + QE fly movement, Shift boosts x10. */
const uint8_t *keys = SDL_GetKeyboardState(nullptr);
Ogre::Vector3 move = Ogre::Vector3::ZERO;
if (keys[SDL_SCANCODE_W])
move.z -= 1.0f;
if (keys[SDL_SCANCODE_S])
move.z += 1.0f;
if (keys[SDL_SCANCODE_A])
move.x -= 1.0f;
if (keys[SDL_SCANCODE_D])
move.x += 1.0f;
float vertical = 0.0f;
if (keys[SDL_SCANCODE_E])
vertical += 1.0f;
if (keys[SDL_SCANCODE_Q])
vertical -= 1.0f;
float speed = 10.0f;
if (keys[SDL_SCANCODE_LSHIFT] || keys[SDL_SCANCODE_RSHIFT])
speed *= 10.0f;
if (!move.isZeroLength() || vertical != 0.0f) {
Ogre::Vector3 delta =
node->getOrientation() * move +
Ogre::Vector3(0.0f, vertical, 0.0f);
delta.normalise();
node->translate(delta * speed * evt.timeSinceLastFrame);
}
return true;
}
private:
EditorApp &m_app;
bool m_wasPlaying = false;
float m_yaw = 0.0f;
float m_pitch = -0.3f;
};
/*
* demo-lua-scene-script: runs the editScene game mode with a hand-authored
* scene whose SceneScriptComponent carries an inline Lua script
* demonstrating event handling (scene_loaded, prefab_loaded and a custom
* "demo_pulse" event). See demo_lua_scene_script.json.
*
* The binary is self-contained in its build directory: run it from there
* (resources.cfg, resources/, characters/, lua-scripts/ and prefabs/ are
* staged next to it by the build).
*/
int main(int argc, char *argv[])
{
try {
EditorApp app;
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
Ogre::String sceneFile = "demo_lua_scene_script.json";
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
}
}
app.setHeadless(headless);
app.initApp();
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
/* Game mode grabbed the mouse on entering Playing; the demo
* has no player controller, so free the cursor again (the fly
* camera below re-frees it whenever Playing is re-entered). */
app.setWindowGrab(false);
std::cout << "[demo] controls: RMB drag = look, "
"W/A/S/D = move, Q/E = down/up, Shift = boost, "
"Escape = pause menu" << std::endl;
DemoFlyCamera flyCamera(app);
if (!headless)
app.getRoot()->addFrameListener(&flyCamera);
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame)
app.getRoot()->addFrameListener(&exitListener);
app.getRoot()->startRendering();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
@@ -0,0 +1,29 @@
{
"name": "DemoPulseCube",
"transform": {
"position": {
"x": 0.0,
"y": 0.5,
"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": "Cube.mesh",
"visible": true
},
"sceneScript": {
"scriptPath": "",
"inlineScript": "-- demo-lua-scene-script: inline prefab script.\n-- Runs inside PrefabSystem/SceneSerializer::instantiatePrefab() right\n-- before the \"prefab_loaded\" event for the instance is sent, so the\n-- subscription below always catches the instance's own load event. (Scene\n-- scripts do see it too \u2014 they run lazily at this same loadPendingScripts\n-- call \u2014 but prefab-local scripts also work when no scene script exists,\n-- e.g. for prefabs instantiated while the startup menu scene loads.)\n\nprint(\"[demo] prefab script executed: subscribing to prefab_loaded\")\n\necs.subscribe_event(\"prefab_loaded\", function(event, params)\n if not params then\n return\n end\n print(\"[demo] prefab_loaded: \" .. tostring(params.prefabPath) ..\n \" entity=\" .. tostring(params.entityId))\n -- Visibly mutate: lift the freshly instantiated cube by 1.5 units.\n if params.entityId and ecs.has_component(params.entityId, \"Transform\") then\n local t = ecs.get_component(params.entityId, \"Transform\")\n ecs.set_component(params.entityId, \"Transform\", {\n position = { t.position[1], t.position[2] + 1.5, t.position[3] },\n rotation = t.rotation,\n scale = t.scale,\n })\n print(\"[demo] prefab_loaded: instance lifted by +1.5 in Y\")\n end\nend)\n"
}
}
@@ -0,0 +1,37 @@
# Stage everything demoLuaSceneScript needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-lua-scene-script.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# Small demo-owned files are COPIED; 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/demo_lua_scene_script.json" DESTINATION "${DEMO_DIR}")
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
file(COPY "${SRC_DIR}/demo_pulse_cube.json" DESTINATION "${DEMO_DIR}/prefabs")
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest). They only exist
# after the editor/game has run once; copy them when present so the demo
# behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
@@ -0,0 +1,112 @@
# ---------------------------------------------------------------------------
# demo-scene-switching-extra scene switching demo with a CellGrid interior
# ---------------------------------------------------------------------------
# Same actuator-driven scene switching setup as demos/demo-scene-switching,
# but scene A additionally holds an "interrior" entity with a
# CellGridComponent (a room with a floor, ceiling, interior walls and an
# exit door) that carries its own ProceduralMaterial + ProceduralTexture.
# The grid's texture rectangle names reference the texture's named rects
# ("floor" / "ceiling"), demonstrating material/UV pickup from a grid entity
# 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, the
# character prefab (prefabs/char_2.json, written by a previous editor/game
# 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
# ./demoSceneSwitchingExtra
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
# --- Embedded project configuration (F8 release binary) ---------------
# Read project.json at configure time and embed its parameters into the
# binary via a generated project.h, so the release binary is attached to
# its project directory without needing --project. Editing project.json
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${PROJECT_JSON_PATH}")
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
if(PROJECT_GAME_MODE)
set(PROJECT_GAME_MODE_VALUE 1)
else()
set(PROJECT_GAME_MODE_VALUE 0)
endif()
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
add_executable(demoSceneSwitchingExtra
demo_main.cpp
${DEMO_SOURCES}
)
target_compile_definitions(demoSceneSwitchingExtra
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
add_dependencies(demoSceneSwitchingExtra morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoSceneSwitchingExtra PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoSceneSwitchingExtra
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoSceneSwitchingExtra PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoSceneSwitchingExtra POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-scene-switching-extra standalone runtime"
)
@@ -0,0 +1,702 @@
#include <iostream>
#include "EditorApp.hpp"
#include "ProjectConfig.hpp"
#include "camera/EditorCamera.hpp"
#include "systems/CharacterRegistry.hpp"
#include "systems/DoorSystem.hpp"
#include "systems/EventBus.hpp"
#include "systems/GlobalStateStore.hpp"
#include "components/Door.hpp"
#include "components/EntityName.hpp"
#include "components/Transform.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
#include <filesystem>
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
#include "project.h"
#endif
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/* Same application as the editor/game, but with a larger initial window. */
class DemoApp : public EditorApp {
public:
using EditorApp::EditorApp;
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override
{
(void)w;
(void)h;
return EditorApp::createWindow(name, 1920, 1080, miscParams);
}
};
/*
* The demo floor meshes and their flat colored materials are created
* programmatically because RenderableComponent only references a mesh by
* name and carries no material/color of its own. Scene A references the
* green "DemoFloorPlaneA", scene B the blue "DemoFloorPlaneB" so it is
* obvious which scene is currently loaded.
*/
static void createFloorMesh(const Ogre::String &meshName,
const Ogre::String &materialName,
float dr, float dg, float db)
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName(meshName, group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
materialName, group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(dr, dg, db, 1.0f);
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
mesh->getSubMesh(0)->setMaterialName(materialName);
}
static void createDemoResources()
{
/* Scene A: green floor. */
createFloorMesh("DemoFloorPlaneA", "DemoFloorMaterialA",
0.35f, 0.5f, 0.35f);
/* Scene B: blue floor. */
createFloorMesh("DemoFloorPlaneB", "DemoFloorMaterialB",
0.3f, 0.4f, 0.6f);
}
/*
* End-to-end check for --test-switch: drives the same path an E-press on
* a scene-switch door takes (DoorComponent toggleRequested +
* sceneSwitchPending -> DoorSystem swing -> EditorApp::switchScene()
* queue once the leaf is fully open -> performSceneSwitch() on the next
* frame with the "@arrival_*" teleport), for a full A -> B -> A round
* trip: scene A exits through the "interrior" grid's door Z:0:0:15 and
* scene B returns through its own grid's door Z:0:0:0. The
* prompt/targeting glue is screen-space and therefore not covered
* headless.
*/
struct SceneSwitchTestListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 10; /* 10-12: F6 locked door, then 0-3 scene switching */
bool failed = false;
Ogre::String failReason;
/* F6 demo door: internal doorway Z:0:0:8 of the "interrior" grid,
* lockable + locked by default (doorConfigs in demo_scene_a.json);
* the inline scene script unlocks it on the first door_locked bump. */
const Ogre::String doorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:8";
/* F1 demo doors: scene A exits through the external doorway
* Z:0:0:15 of the "interrior" grid (scene-switch door to
* demo_scene_b.json), scene B returns through the external doorway
* Z:0:0:0 of its own exteriorOnly grid (scene-switch door back to
* demo_scene_a.json). For both legs the switch must fire only
* when the leaf is fully open. */
const Ogre::String exitDoorId =
"d6f6a1e2-4b5c-4d6e-8f70-a1b2c3d4e5f6:Z:0:0:15";
const Ogre::String returnDoorId =
"77370b1d-e8ab-44a6-865b-e55c15b0fc78:Z:0:0:0";
int exitOpenWait = 0;
int returnOpenWait = 0;
SceneSwitchTestListener(EditorApp *a)
: app(a)
{
}
bool entityExists(const char *name)
{
bool found = false;
app->getWorld()->query<EntityNameComponent>().each(
[&](flecs::entity, EntityNameComponent &n) {
if (n.name == name)
found = true;
});
return found;
}
/* The F6 demo door entity (recreated on every scene load). */
flecs::entity findDoor()
{
return findDoorById(doorId);
}
flecs::entity findDoorById(const Ogre::String &id)
{
flecs::entity result = flecs::entity::null();
app->getWorld()->query<DoorComponent>().each(
[&](flecs::entity e, DoorComponent &d) {
if (d.doorId == id)
result = e;
});
return result;
}
/* Emulate the E-press on a scene-switch door. */
bool requestDoorSwitch(const Ogre::String &id)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
DoorComponent &d = door.get_mut<DoorComponent>();
if (!d.occluder) {
failed = true;
failReason = "F1 scene-switch door has no occluder";
return true;
}
d.toggleRequested = true;
d.sceneSwitchPending = true;
return true;
}
/* Waits for the queued scene switch of a scene-switch door;
* returns true once the switch to expectPath is pending (the door
* must be fully open by then). */
bool waitDoorSwitch(const Ogre::String &id, const Ogre::String &label,
const char *expectPath, int &openWait)
{
flecs::entity door = findDoorById(id);
if (!door.is_alive())
return false;
const DoorComponent &d = door.get<DoorComponent>();
bool fullyOpen = d.isOpen && d.currentAngle == d.openAngle;
if (app->hasPendingSceneSwitch()) {
if (!fullyOpen) {
failed = true;
failReason =
"F1 scene switch fired before the door was fully open";
return true;
}
if (app->getPendingSceneSwitchPath() != expectPath) {
failed = true;
failReason = "F1 wrong pending switch path";
return true;
}
std::cout << "[test] F1 " << label
<< " fully open, switch queued" << std::endl;
return true;
}
if (fullyOpen) {
/* Frame-listener ordering grace. */
if (++openWait > 5) {
failed = true;
failReason = "F1 scene switch never fired";
}
} else {
openWait = 0;
}
return false;
}
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Vector3 pos =
t.node ? t.node->_getDerivedPosition() : t.position;
if (pos.distance(expected) > tol) {
failed = true;
failReason = "player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
")";
}
return true;
}
/* The arrival markers sit at (0, 0, 24) facing -Z (toward the
* floor center); verify the camera took a position behind the
* character looking at the walking surface, and that the
* character's visual facing (local +Z) points at the center too. */
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Quaternion q =
t.node ? t.node->getOrientation() : t.rotation;
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
EditorCamera *ec = app->getEditorCamera();
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
Ogre::SceneNode *camNode =
cam ? cam->getParentSceneNode() : nullptr;
if (!camNode)
return false;
Ogre::Vector3 cp = camNode->_getDerivedPosition();
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
Ogre::Vector3::NEGATIVE_UNIT_Z;
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
charFacing.z > -0.9f) {
failed = true;
failReason =
"not facing the walking surface: camera pos (" +
Ogre::StringConverter::toString(cp.x) + ", " +
Ogre::StringConverter::toString(cp.y) + ", " +
Ogre::StringConverter::toString(cp.z) +
") view (" +
Ogre::StringConverter::toString(vd.x) + ", " +
Ogre::StringConverter::toString(vd.y) + ", " +
Ogre::StringConverter::toString(vd.z) +
") character facing (" +
Ogre::StringConverter::toString(charFacing.x) +
", " +
Ogre::StringConverter::toString(charFacing.z) +
")";
}
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 3) {
app->getRoot()->queueEndRendering();
return true;
}
/* The swings are real-time (doorOpenSpeed deg/s) while a
* headless frame is sub-millisecond, so the door legs need a
* few thousand frames (~10 s wall clock at 1000+ fps). */
if (frame > 10000) {
failed = true;
failReason = "timeout waiting for scene switch";
app->getRoot()->queueEndRendering();
return true;
}
switch (phase) {
case 10: {
/* F6: the demo door starts locked (lockedByDefault). */
if (frame < 5)
break;
flecs::entity door = findDoor();
if (!door.is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F6 demo door not found";
}
break;
}
if (!DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason = "F6 demo door not locked at start";
break;
}
/* Emulate the ActuatorSystem E-press on a locked door;
* the inline scene script answers door_locked with
* door_unlock_<doorId>. */
EventBus::getInstance().send("door_locked", "door_id",
doorId);
phase = 11;
break;
}
case 11: {
/* F6: wait for the scene script to unlock the door
* (the frame timeout covers a missing handler). */
if (DoorSystem::isDoorLockedById(doorId))
break;
flecs::entity door = findDoor();
if (!door.is_alive())
break;
door.get_mut<DoorComponent>().toggleRequested = true;
std::cout << "[test] F6 door unlocked via scene script, "
"opening"
<< std::endl;
phase = 12;
break;
}
case 12: {
/* F6: the swing completes and the open state lands in
* the global store. */
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d = door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle)
break;
if (!GlobalStateStore::getInstance().getBool(
"door." + doorId + ".isOpen")) {
failed = true;
failReason = "F6 door open state not persisted";
break;
}
std::cout << "[test] F6 door open and persisted"
<< std::endl;
phase = 0;
break;
}
case 0: {
/* F1: A -> B through the exit scene-switch door. */
if (frame < 5)
break;
if (!findDoorById(exitDoorId).is_alive()) {
if (frame > 60) {
failed = true;
failReason = "F1 exit door not found";
}
break;
}
/* F1: every scene-switch door gets the void-side
* tunnel occluder (depth covers the leaf sweep,
* so both swing directions stay visible). */
{
const DoorComponent &d =
findDoorById(exitDoorId)
.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 exit door has no tunnel occluder";
break;
}
}
if (!requestDoorSwitch(exitDoorId))
break;
phase = 13;
break;
}
case 13: {
/* F1: the scene switch must fire only once the leaf
* is fully open. */
if (waitDoorSwitch(exitDoorId, "exit door",
"demo_scene_b.json", exitOpenWait))
phase = 1;
break;
}
case 1:
if (!entityExists("x1"))
break;
/* F1: the return door swings AWAY from the player
* (doorSwingReversed in demo_scene_b.json); the
* void-side tunnel occluder covers the full leaf
* sweep, so the swinging leaf stays visible. */
{
flecs::entity returnDoor =
findDoorById(returnDoorId);
if (returnDoor.is_alive()) {
const DoorComponent &d =
returnDoor.get<DoorComponent>();
if (!d.occluder ||
d.occluder->getMesh()
->getName()
.find("CellGridDoorOccluderTunnel") !=
0) {
failed = true;
failReason =
"F1 swing-away door has no tunnel occluder";
break;
}
std::cout << "[test] F1 swing-away door "
"has tunnel occluder"
<< std::endl;
}
}
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
if (failed)
break;
std::cout << "[test] arrived in scene B at arrival_b, "
"camera faces the walking surface"
<< std::endl;
/* F1: B -> A through scene B's own scene-switch
* door (external doorway Z:0:0:0 of its
* exteriorOnly grid). */
if (!findDoorById(returnDoorId).is_alive()) {
failed = true;
failReason = "F1 return door not found";
break;
}
if (!requestDoorSwitch(returnDoorId))
break;
phase = 14;
break;
case 14:
/* F1: same fully-open contract on the way back. */
if (waitDoorSwitch(returnDoorId, "return door",
"demo_scene_a.json", returnOpenWait))
phase = 2;
break;
case 2:
if (!entityExists("interrior"))
break;
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
/* F6: the door rebuilt with the scene must be snapped
* back to the persisted open + unlocked state. */
{
flecs::entity door = findDoor();
if (!door.is_alive())
break;
const DoorComponent &d =
door.get<DoorComponent>();
if (!d.isOpen || d.currentAngle != d.openAngle ||
DoorSystem::isDoorLockedById(doorId)) {
failed = true;
failReason =
"F6 door state not restored after scene switch";
break;
}
std::cout << "[test] F6 door state restored "
"after scene switch"
<< std::endl;
}
/* F1: the scene-switch exit door rebuilt with the
* scene must be closed again - scene-switch doors
* never restore the persisted open state. */
{
flecs::entity exitDoor =
findDoorById(exitDoorId);
if (!exitDoor.is_alive())
break;
const DoorComponent &d =
exitDoor.get<DoorComponent>();
if (d.isOpen || d.currentAngle != 0.0f ||
GlobalStateStore::getInstance().getBool(
"door." + exitDoorId + ".isOpen")) {
failed = true;
failReason =
"F1 exit door not closed after scene switch";
break;
}
std::cout << "[test] F1 exit door closed after "
"scene switch"
<< std::endl;
}
if (!failed) {
std::cout << "[test] arrived back in scene A "
"at arrival_a, camera faces the "
"walking surface"
<< std::endl;
std::cout << "[test] PASS" << std::endl;
}
phase = 3;
break;
}
return true;
}
};
/*
* demo-scene-switching-extra: runs the editScene game mode with two small
* hand-authored scenes (demo_scene_a.json and demo_scene_b.json), each
* containing a flat colored floor plane with a static physics collider,
* a character spawner ("s1", character registry ID 2, same character
* setup as town8.json) and a PlayerControllerComponent targeting it.
* Scene A additionally holds an "interrior" entity with a
* CellGridComponent in interiorOnly generation mode (a room with floor,
* ceiling, interior walls, windows with opaque glass and an exit door)
* carrying its own ProceduralMaterial + ProceduralTexture; the grid's
* texture rectangle names reference the texture's named rects
* ("floor" / "ceiling") without any Lot/District/Town parent. Scene B
* holds an exteriorOnly CellGridComponent (entity "x1"/"w1") - just the
* building shell with opaque window glass, seen from the outside.
*
* F6 demo content: scene A grid's internal doorway Z:0:0:8 is configured
* (doorConfigs in demo_scene_a.json) as persistent + lockable + locked
* by default, with a fixed gridUid so its global door ID is stable, and
* the grid entity carries an inline scene script that answers the
* "door_locked" event with "door_unlock_<doorId>" (the door event
* contract; the door never opens by itself, the player presses E again).
* The door's locked and open/closed state lives in the global state
* store and survives the A -> B -> A scene switches.
*
* F1 demo content: both scene transitions go through scene-switch doors.
* Scene A's grid has the external exit doorway Z:0:0:15 configured as a
* scene-switch door to demo_scene_b.json (target arrival_b); scene B's
* exteriorOnly grid sets doorSceneSwitchPath/demo_scene_a.json +
* doorSceneSwitchTarget/arrival_a grid-wide, so its external doorway
* Z:0:0:0 is the way back. E on such a door swings the leaf open first;
* the scene switch fires only when the leaf reaches the open angle, and
* a black occluder box behind the doorway hides the missing half of the
* building while it swings. Both scenes carry their own player
* controller, so each switch is a clean takeover (see
* EditorApp::performSceneSwitch); travel back and forth is endless.
*
* The mouse is grabbed while Playing (built-in game-mode behaviour);
* Escape toggles the pause menu, which frees the cursor.
*
* The binary is self-contained in its build directory: run it from there
* (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: verifies the F6
* locked door (locked at start, unlocked by the scene
* script through the door event contract, open state
* persisted and restored across the scene switches) and
* both F1 scene-switch doors (A -> B and B -> A each
* fire only when the leaf is fully open, black occluder
* present), verifying the round-trip arrival teleports;
* exits 0 on PASS.
*/
int main(int argc, char *argv[])
{
try {
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
/* Release binary: project parameters are embedded at build
* time from project.json (generated project.h); the project
* root is the working directory the binary runs from. */
DemoApp app(EDITSCENE_PROJECT_APP_NAME);
{
ProjectConfig cfg;
cfg.rootDir =
std::filesystem::current_path().string();
cfg.appName = EDITSCENE_PROJECT_APP_NAME;
cfg.startScene = EDITSCENE_PROJECT_START_SCENE;
cfg.gameMode = EDITSCENE_PROJECT_GAME_MODE;
cfg.loaded = true;
app.setProjectConfig(cfg);
}
#else
DemoApp app;
#endif
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
bool testSwitch = false;
bool sceneArgGiven = false;
#ifdef EDITSCENE_HAS_EMBEDDED_PROJECT
Ogre::String sceneFile = EDITSCENE_PROJECT_START_SCENE;
#else
Ogre::String sceneFile = "demo_scene_a.json";
#endif
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg == "--test-switch") {
testSwitch = true;
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
sceneArgGiven = true;
}
}
app.setHeadless(headless);
app.initApp();
if (headless) {
/* Headless mode never creates EditorUISystem, which
* owns the CharacterRegistry singleton; the demo scene
* has a character spawner, so provide a bare registry
* to keep spawner resolution from asserting. The
* character then falls back to an inline spawn without
* a physics capsule (fine for a smoke run). */
static CharacterRegistry s_characterRegistry;
s_characterRegistry.setWorld(app.getWorld());
s_characterRegistry.setSceneManager(app.getSceneManager());
s_characterRegistry.initialize();
}
/* Meshes + materials referenced by the scene entities. */
createDemoResources();
/* With an embedded project, EditorApp::setup() skipped the
* startup menu; the start scene defaults to the embedded
* one and can still be overridden positionally. */
(void)sceneArgGiven;
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, E = use door, Escape = pause menu"
<< std::endl;
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame && !testSwitch)
app.getRoot()->addFrameListener(&exitListener);
SceneSwitchTestListener testListener(&app);
if (testSwitch)
app.getRoot()->addFrameListener(&testListener);
app.getRoot()->startRendering();
if (testSwitch) {
if (testListener.failed) {
std::cerr << "[test] FAIL: "
<< testListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (testListener.phase != 3) {
std::cerr << "[test] FAIL: incomplete"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
/* Destroy scene entities while the systems are still alive:
* CharacterSpawnerSystem registers an OnRemove observer on
* CharacterSpawnerComponent that dereferences the system, and
* the flecs world only dies after destroyEditorSystems() in
* ~EditorApp, so letting the spawner entity live that long
* would call back into a destroyed system. */
app.clearScene();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,579 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 488,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 18.265350341796875
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [
{
"cellGrid": {
"ceilingRectName": "",
"cellHeight": 4.0,
"cellSize": 2.0,
"cells": [
{
"flags": 37757955,
"x": -1,
"y": 0,
"z": 0
},
{
"flags": 2097667,
"x": 0,
"y": 0,
"z": 0
},
{
"flags": 41953283,
"x": 1,
"y": 0,
"z": 0
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 1
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 1
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 1
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 2
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 2
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 2
},
{
"flags": 20976643,
"x": -1,
"y": 0,
"z": 3
},
{
"flags": 16781315,
"x": 0,
"y": 0,
"z": 3
},
{
"flags": 25171971,
"x": 1,
"y": 0,
"z": 3
}
],
"depth": 10,
"doorActionName": "",
"doorConfigs": {},
"doorMeshName": "",
"doorOpenAngle": 100.0,
"doorOpenSpeed": 180.0,
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_a.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": true,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
"extWallRectName": "",
"extWindowFrameRectName": "",
"floorRectName": "",
"friction": 0.5,
"furnitureCells": [],
"generationMode": "exteriorOnly",
"generationScript": "",
"glassColor": [
0.4000000059604645,
0.6000000238418579,
0.800000011920929,
0.3499999940395355
],
"glassMaterialName": "",
"glassReflectivity": 0.800000011920929,
"gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78",
"height": 1,
"intDoorFrameRectName": "",
"intWallRectName": "",
"intWindowFrameRectName": "",
"roofSideRectName": "",
"roofTopRectName": "",
"width": 10
},
"children": [
{
"children": [],
"clearArea": {
"clearCells": true,
"clearFurniture": true,
"clearRoofs": false,
"clearRooms": false,
"maxX": 10,
"maxY": 1,
"maxZ": 10,
"minX": -10,
"minY": 0,
"minZ": -10
},
"id": 491,
"name": {
"name": "c1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 492,
"name": {
"name": "r1"
},
"room": {
"connectedRoomIds": [],
"createCeiling": true,
"createFloor": true,
"createInteriorWalls": true,
"createWindows": true,
"exits": [
true,
false,
false,
false
],
"fillRoomWithFurniture": false,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
"maxY": 1,
"maxZ": 4,
"minX": -1,
"minY": 0,
"minZ": 0,
"persistentId": "room_1788689581735260047_499",
"roomType": "",
"tags": []
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 490,
"name": {
"name": "w1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 489,
"name": {
"name": "x1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 22.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": 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
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 495,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 496,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"version": "1.0"
}
@@ -0,0 +1,667 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "[demo] portal A: switching to scene B",
"type": "debugPrint"
},
{
"name": "demo_scene_b.json",
"params": "@arrival_b",
"type": "switchScene"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "goto_scene_b",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "[demo] portal B: switching to scene A",
"type": "debugPrint"
},
{
"name": "demo_scene_a.json",
"params": "@arrival_a",
"type": "switchScene"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "goto_scene_a",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 488,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 24.0
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [
{
"cellGrid": {
"ceilingRectName": "",
"cellHeight": 4.0,
"cellSize": 2.0,
"cells": [
{
"flags": 37757955,
"x": -1,
"y": 0,
"z": 0
},
{
"flags": 2097667,
"x": 0,
"y": 0,
"z": 0
},
{
"flags": 41953283,
"x": 1,
"y": 0,
"z": 0
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 1
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 1
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 1
},
{
"flags": 4195331,
"x": -1,
"y": 0,
"z": 2
},
{
"flags": 3,
"x": 0,
"y": 0,
"z": 2
},
{
"flags": 8390659,
"x": 1,
"y": 0,
"z": 2
},
{
"flags": 20976643,
"x": -1,
"y": 0,
"z": 3
},
{
"flags": 16781315,
"x": 0,
"y": 0,
"z": 3
},
{
"flags": 25171971,
"x": 1,
"y": 0,
"z": 3
}
],
"depth": 10,
"doorActionName": "",
"doorConfigs": {},
"doorMeshName": "",
"doorOpenAngle": 100.0,
"doorOpenSpeed": 180.0,
"doorRectName": "",
"doorSceneSwitchPath": "demo_scene_a.json",
"doorSceneSwitchTarget": "arrival_a",
"doorSwingReversed": false,
"doorUseMeshMaterial": false,
"doorsEnabled": true,
"extDoorFrameRectName": "",
"extWallRectName": "",
"extWindowFrameRectName": "",
"floorRectName": "",
"friction": 0.5,
"furnitureCells": [],
"generationMode": "exteriorOnly",
"generationScript": "",
"glassColor": [
0.4000000059604645,
0.6000000238418579,
0.800000011920929,
0.3499999940395355
],
"glassMaterialName": "",
"glassReflectivity": 0.800000011920929,
"gridUid": "77370b1d-e8ab-44a6-865b-e55c15b0fc78",
"height": 1,
"intDoorFrameRectName": "",
"intWallRectName": "",
"intWindowFrameRectName": "",
"roofSideRectName": "",
"roofTopRectName": "",
"width": 10
},
"children": [
{
"children": [],
"clearArea": {
"clearCells": true,
"clearFurniture": true,
"clearRoofs": false,
"clearRooms": false,
"maxX": 10,
"maxY": 1,
"maxZ": 10,
"minX": -10,
"minY": 0,
"minZ": -10
},
"id": 491,
"name": {
"name": "c1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 492,
"name": {
"name": "r1"
},
"room": {
"connectedRoomIds": [],
"createCeiling": true,
"createFloor": true,
"createInteriorWalls": true,
"createWindows": true,
"exits": [
true,
false,
false,
false
],
"fillRoomWithFurniture": false,
"furnitureSeed": 42,
"furnitureYOffset": 0.05000000074505806,
"maxX": 2,
"maxY": 1,
"maxZ": 4,
"minX": -1,
"minY": 0,
"minZ": 0,
"persistentId": "room_1788689581735260047_499",
"roomType": "",
"tags": []
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 490,
"name": {
"name": "w1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"id": 489,
"name": {
"name": "x1"
},
"transform": {
"position": {
"x": -0.9691458940505981,
"y": 0.0,
"z": 32.09623718261719
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 493,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
"diffuseColor": {
"a": 1.0,
"b": 1.0,
"g": 1.0,
"r": 1.0
},
"direction": {
"x": 0.30000001192092896,
"y": -1.0,
"z": 0.20000000298023224
},
"intensity": 1.0,
"lightType": "directional",
"linearAttenuation": 0.0,
"quadraticAttenuation": 0.0,
"range": 100.0,
"specularColor": {
"a": 1.0,
"b": 0.5,
"g": 0.5,
"r": 0.5
},
"spotlightFalloff": 1.0,
"spotlightInnerAngle": 30.0,
"spotlightOuterAngle": 45.0
},
"name": {
"name": "demo_light"
},
"transform": {
"position": {
"x": 0.0,
"y": 10.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"collider": {
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": -0.10000000149011612,
"z": 0.0
},
"parameters": {
"x": 30.0,
"y": 0.10000000149011612,
"z": 30.0
},
"radius": 0.5,
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"shapeType": "box"
},
"id": 494,
"name": {
"name": "demo_floor"
},
"renderable": {
"meshName": "DemoFloorPlaneB",
"visible": true
},
"rigidBody": {
"bodyType": "static",
"enabled": true,
"friction": 0.800000011920929,
"isSensor": false,
"mass": 1.0,
"restitution": 0.0
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"actuator": {
"actionNames": [
"goto_scene_a"
],
"height": 1.7999999523162842,
"radius": 1.5
},
"children": [],
"id": 495,
"name": {
"name": "portal_b"
},
"renderable": {
"meshName": "DemoActuatorMarkerB",
"visible": true
},
"transform": {
"position": {
"x": 0.0,
"y": 0.800000011920929,
"z": 28.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 496,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 497,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
}
],
"version": "1.0"
}
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,4 @@
{
"fontPath": "Jupiteroid-Bold.ttf",
"fontSize": 16.0
}
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-scene-switching-extra",
"startScene": "demo_scene_a.json",
"gameMode": true
}
@@ -0,0 +1,60 @@
# Stage everything demoSceneSwitchingExtra needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-scene-switching-extra.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
foreach(f demo_scene_a.json demo_scene_b.json)
set(link "${DEMO_DIR}/${f}")
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...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest the demo needs
# character_registry.json for the spawner's registryId 2 and
# animation_tree.json for the character's "male1_6" animation tree). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
@@ -0,0 +1,98 @@
# ---------------------------------------------------------------------------
# demo-scene-switching game-mode demo for actuator-driven scene switching
# ---------------------------------------------------------------------------
# Separate executable built from the same sources as editSceneEditor (minus
# main.cpp). It runs in game mode and loads demo_scene_a.json, which
# contains a flat colored floor plane (procedurally created in demo_main.cpp
# as "DemoFloorPlaneA"; scene B uses the blue "DemoFloorPlaneB") with a
# static box collider, a character spawner ("s1", character registry ID 2,
# same player character setup as town8.json), a PlayerControllerComponent
# targeting it and an actuator ("portal_a", marked by a colored pillar mesh
# also created in demo_main.cpp). The actuator's "goto_scene_b" action
# (defined in the scene's top-level actionDatabase block) runs a behavior
# tree whose "switchScene" node queues EditorApp::switchScene() to
# demo_scene_b.json with a "@arrival_b" teleport target; demo_scene_b.json
# mirrors this with its own "portal_b" actuator and "goto_scene_a" action,
# so the player can travel back and forth endlessly. Both scenes carry
# their own player controller, so each switch is a clean takeover. The
# mouse is grabbed while Playing (built-in game-mode behaviour) and the
# initial window is larger than the default (1920x1080, see DemoApp in
# demo_main.cpp).
#
# The executable is self-contained in this build directory: a POST_BUILD
# 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 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
# ./demoSceneSwitching
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
add_executable(demoSceneSwitching
demo_main.cpp
${DEMO_SOURCES}
)
add_dependencies(demoSceneSwitching morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoSceneSwitching PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoSceneSwitching
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoSceneSwitching PRIVATE
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoSceneSwitching POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-scene-switching standalone runtime"
)
@@ -0,0 +1,465 @@
#include <iostream>
#include "EditorApp.hpp"
#include "camera/EditorCamera.hpp"
#include "systems/CharacterRegistry.hpp"
#include "systems/BehaviorTreeSystem.hpp"
#include "components/ActionDatabase.hpp"
#include "components/EntityName.hpp"
#include "components/Transform.hpp"
#include <Ogre.h>
#include <OgreRoot.h>
#include <ProceduralBoxGenerator.h>
#include <ProceduralMeshGenerator.h>
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
Ogre::Root *root;
bool triggered = false;
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
if (!triggered) {
triggered = true;
root->queueEndRendering();
}
return true;
}
};
/* Same application as the editor/game, but with a larger initial window. */
class DemoApp : public EditorApp {
public:
OgreBites::NativeWindowPair
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
Ogre::NameValuePairList miscParams) override
{
(void)w;
(void)h;
return EditorApp::createWindow(name, 1920, 1080, miscParams);
}
};
/*
* The demo floor meshes and their flat colored materials are created
* programmatically because RenderableComponent only references a mesh by
* name and carries no material/color of its own. Scene A references the
* green "DemoFloorPlaneA", scene B the blue "DemoFloorPlaneB" so it is
* obvious which scene is currently loaded.
*/
static void createFloorMesh(const Ogre::String &meshName,
const Ogre::String &materialName,
float dr, float dg, float db)
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName(meshName, group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
materialName, group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(dr, dg, db, 1.0f);
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
mesh->getSubMesh(0)->setMaterialName(materialName);
}
/*
* Visible pillar meshes marking the actuator positions ("portal_a" in
* scene A, "portal_b" in scene B). The ActuatorSystem already draws a
* screen-space indicator, but a physical marker makes the spot visible
* from across the floor. The box is 0.6 x 1.6 x 0.6 centered on the
* entity origin, so the entities sit at y = 0.8.
*/
static void createMarkerMesh(const Ogre::String &meshName,
const Ogre::String &materialName,
float dr, float dg, float db)
{
const Ogre::String group =
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
if (!Ogre::MeshManager::getSingleton()
.getByName(meshName, group)
.isNull())
return;
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
materialName, group);
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
pass->setDiffuse(dr, dg, db, 1.0f);
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
pass->setSpecular(0.1f, 0.1f, 0.1f, 1.0f);
Procedural::BoxGenerator boxGen;
boxGen.setSizeX(0.6f).setSizeY(1.6f).setSizeZ(0.6f);
Ogre::MeshPtr mesh = boxGen.realizeMesh(meshName, group);
if (mesh && mesh->getNumSubMeshes() > 0)
mesh->getSubMesh(0)->setMaterialName(materialName);
}
static void createDemoResources()
{
/* Scene A: green floor, orange portal marker. */
createFloorMesh("DemoFloorPlaneA", "DemoFloorMaterialA",
0.35f, 0.5f, 0.35f);
createMarkerMesh("DemoActuatorMarkerA", "DemoActuatorMarkerMaterialA",
0.9f, 0.5f, 0.1f);
/* Scene B: blue floor, magenta portal marker. */
createFloorMesh("DemoFloorPlaneB", "DemoFloorMaterialB",
0.3f, 0.4f, 0.6f);
createMarkerMesh("DemoActuatorMarkerB", "DemoActuatorMarkerMaterialB",
0.8f, 0.2f, 0.6f);
}
/*
* End-to-end check for --test-switch: drives the same path an E-press on
* the actuator would take (ActionDatabase action -> behavior tree ->
* "switchScene" node -> EditorApp::switchScene() queue ->
* performSceneSwitch() on the next frame with the "@arrival_*" teleport),
* for a full A -> B -> A round trip. The prompt/targeting glue is
* screen-space and therefore not covered headless. The ActuatorSystem is
* not involved because its interaction path needs an ImGui context; the
* tree is evaluated through a local BehaviorTreeSystem exactly like
* ActuatorSystem::isActionComplete() does.
*/
struct SceneSwitchTestListener : public Ogre::FrameListener {
EditorApp *app;
BehaviorTreeSystem *bt;
int frame = 0;
int phase = 0;
bool failed = false;
Ogre::String failReason;
SceneSwitchTestListener(EditorApp *a, BehaviorTreeSystem *b)
: app(a), bt(b)
{
}
bool entityExists(const char *name)
{
bool found = false;
app->getWorld()->query<EntityNameComponent>().each(
[&](flecs::entity, EntityNameComponent &n) {
if (n.name == name)
found = true;
});
return found;
}
/* Returns false while the player is not available yet (retry).
* Hard failures set failed/failReason. */
bool runAction(const char *actionName, const char *expectPath)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive())
return false;
ActionDatabase *db = ActionDatabase::getSingletonPtr();
const GoapAction *action =
db ? db->findAction(actionName) : nullptr;
if (!action) {
failed = true;
failReason = Ogre::String("action not found: ") +
actionName;
return true;
}
BehaviorTreeSystem::Status status =
bt->evaluatePlayerAction(player.id(),
action->behaviorTree, 0.016f,
true);
if (status != BehaviorTreeSystem::Status::success) {
failed = true;
failReason = Ogre::String("action did not succeed: ") +
actionName;
return true;
}
if (!app->hasPendingSceneSwitch() ||
app->getPendingSceneSwitchPath() != expectPath) {
failed = true;
failReason = Ogre::String("no pending switch to ") +
expectPath;
return true;
}
return true;
}
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Vector3 pos =
t.node ? t.node->_getDerivedPosition() : t.position;
if (pos.distance(expected) > tol) {
failed = true;
failReason = "player not at arrival point: (" +
Ogre::StringConverter::toString(pos.x) +
", " +
Ogre::StringConverter::toString(pos.y) +
", " +
Ogre::StringConverter::toString(pos.z) +
")";
}
return true;
}
/* The arrival markers sit at (0, 0, 24) facing -Z (toward the
* floor center); verify the camera took a position behind the
* character looking at the walking surface with the portal
* pillar (z = 28) behind the camera, and that the character's
* visual facing (local +Z) points at the center too. */
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
{
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() || !player.has<TransformComponent>())
return false;
const TransformComponent &t = player.get<TransformComponent>();
Ogre::Quaternion q =
t.node ? t.node->getOrientation() : t.rotation;
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
EditorCamera *ec = app->getEditorCamera();
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
Ogre::SceneNode *camNode =
cam ? cam->getParentSceneNode() : nullptr;
if (!camNode)
return false;
Ogre::Vector3 cp = camNode->_getDerivedPosition();
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
Ogre::Vector3::NEGATIVE_UNIT_Z;
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
charFacing.z > -0.9f) {
failed = true;
failReason =
"not facing the walking surface: camera pos (" +
Ogre::StringConverter::toString(cp.x) + ", " +
Ogre::StringConverter::toString(cp.y) + ", " +
Ogre::StringConverter::toString(cp.z) +
") view (" +
Ogre::StringConverter::toString(vd.x) + ", " +
Ogre::StringConverter::toString(vd.y) + ", " +
Ogre::StringConverter::toString(vd.z) +
") character facing (" +
Ogre::StringConverter::toString(charFacing.x) +
", " +
Ogre::StringConverter::toString(charFacing.z) +
")";
}
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
frame++;
if (failed || phase == 3) {
app->getRoot()->queueEndRendering();
return true;
}
if (frame > 1200) {
failed = true;
failReason = "timeout waiting for scene switch";
app->getRoot()->queueEndRendering();
return true;
}
switch (phase) {
case 0:
if (frame < 5)
break; /* let the character spawn */
if (!runAction("goto_scene_b", "demo_scene_b.json"))
break;
if (!failed)
std::cout << "[test] queued switch A -> B"
<< std::endl;
phase = 1;
break;
case 1:
if (!entityExists("portal_b"))
break;
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
if (failed)
break;
std::cout << "[test] arrived in scene B at arrival_b, "
"camera faces the walking surface"
<< std::endl;
if (!runAction("goto_scene_a", "demo_scene_a.json"))
break;
if (!failed)
std::cout << "[test] queued switch B -> A"
<< std::endl;
phase = 2;
break;
case 2:
if (!entityExists("portal_a"))
break;
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
1.0f))
break;
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
24.0f)))
break;
if (!failed) {
std::cout << "[test] arrived back in scene A "
"at arrival_a, camera faces the "
"walking surface"
<< std::endl;
std::cout << "[test] PASS" << std::endl;
}
phase = 3;
break;
}
return true;
}
};
/*
* demo-scene-switching: runs the editScene game mode with two small
* hand-authored scenes (demo_scene_a.json and demo_scene_b.json), each
* containing a flat colored floor plane with a static physics collider,
* a character spawner ("s1", character registry ID 2, same character
* setup as town8.json), a PlayerControllerComponent targeting it and an
* actuator ("portal_a" / "portal_b", marked by a colored pillar).
*
* Each actuator names one action ("goto_scene_b" / "goto_scene_a") whose
* behavior tree is defined in the scene's top-level "actionDatabase"
* block: a sequence ending in a "switchScene" node that queues an
* EditorApp::switchScene() to the other scene with a
* "@arrival_a"/"@arrival_b" teleport target, so the player reappears
* right next to the return portal. Walking into the pillar's radius
* shows the "E goto_scene_*" prompt; pressing E runs the tree and the
* scene switch executes at the start of the next frame. Both scenes
* carry their own player controller, so each switch is a clean takeover
* (see EditorApp::performSceneSwitch); travel back and forth is endless.
*
* The mouse is grabbed while Playing (built-in game-mode behaviour);
* Escape toggles the pause menu, which frees the cursor.
*
* The binary is self-contained in its build directory: run it from there
* (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
* portal actions and verifies the A -> B -> A round
* trip and the arrival teleports; exits 0 on PASS.
*/
int main(int argc, char *argv[])
{
try {
DemoApp app;
app.setGameMode(EditorApp::GameMode::Game);
bool headless = false;
bool exitAfterFirstFrame = false;
bool testSwitch = false;
Ogre::String sceneFile = "demo_scene_a.json";
for (int i = 1; i < argc; i++) {
Ogre::String arg = argv[i];
if (arg == "--headless") {
headless = true;
} else if (arg == "--exit-after-first-frame") {
exitAfterFirstFrame = true;
} else if (arg == "--test-switch") {
testSwitch = true;
} else if (arg.length() > 0 && arg[0] != '-') {
sceneFile = arg;
}
}
app.setHeadless(headless);
app.initApp();
if (headless) {
/* Headless mode never creates EditorUISystem, which
* owns the CharacterRegistry singleton; the demo scene
* has a character spawner, so provide a bare registry
* to keep spawner resolution from asserting. The
* character then falls back to an inline spawn without
* a physics capsule (fine for a smoke run). */
static CharacterRegistry s_characterRegistry;
s_characterRegistry.setWorld(app.getWorld());
s_characterRegistry.setSceneManager(app.getSceneManager());
s_characterRegistry.initialize();
}
/* Meshes + materials referenced by the scene entities. */
createDemoResources();
std::cout << "[demo] starting new game with scene: "
<< sceneFile << std::endl;
app.startNewGame(sceneFile);
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
"Shift = run, E = use portal, Escape = pause menu"
<< std::endl;
ExitAfterFirstFrameListener exitListener(app.getRoot());
if (exitAfterFirstFrame && !testSwitch)
app.getRoot()->addFrameListener(&exitListener);
/* The test evaluates the action trees exactly like
* ActuatorSystem::isActionComplete() does; only the
* switchScene/debugPrint nodes are used, so no animation
* or character system is needed. */
BehaviorTreeSystem testBt(*app.getWorld(), app.getSceneManager(),
nullptr, nullptr);
testBt.setEditorApp(&app);
SceneSwitchTestListener testListener(&app, &testBt);
if (testSwitch)
app.getRoot()->addFrameListener(&testListener);
app.getRoot()->startRendering();
if (testSwitch) {
if (testListener.failed) {
std::cerr << "[test] FAIL: "
<< testListener.failReason
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
if (testListener.phase != 3) {
std::cerr << "[test] FAIL: incomplete"
<< std::endl;
app.clearScene();
app.closeApp();
return 1;
}
}
/* Destroy scene entities while the systems are still alive:
* CharacterSpawnerSystem registers an OnRemove observer on
* CharacterSpawnerComponent that dereferences the system, and
* the flecs world only dies after destroyEditorSystems() in
* ~EditorApp, so letting the spawner entity live that long
* would call back into a destroyed system. */
app.clearScene();
app.closeApp();
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
return 0;
}
@@ -0,0 +1,280 @@
{
"version": "1.0",
"actionDatabase": {
"actions": [
{
"name": "goto_scene_b",
"cost": 1,
"preconditions": {
"bits": 0,
"mask": 0
},
"effects": {
"bits": 0,
"mask": 0
},
"behaviorTree": {
"type": "sequence",
"children": [
{
"type": "debugPrint",
"name": "[demo] portal A: switching to scene B"
},
{
"type": "switchScene",
"name": "demo_scene_b.json",
"params": "@arrival_b"
}
]
}
}
]
},
"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": "DemoFloorPlaneA",
"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_a"
},
"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": "DemoActuatorMarkerA",
"visible": true
},
"actuator": {
"radius": 1.5,
"height": 1.8,
"actionNames": ["goto_scene_b"]
},
"children": []
},
{
"id": 4,
"name": {
"name": "arrival_a"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 24.0
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"children": []
},
{
"id": 5,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": []
},
{
"id": 6,
"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,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"children": []
}
]
}
@@ -0,0 +1,280 @@
{
"version": "1.0",
"actionDatabase": {
"actions": [
{
"name": "goto_scene_a",
"cost": 1,
"preconditions": {
"bits": 0,
"mask": 0
},
"effects": {
"bits": 0,
"mask": 0
},
"behaviorTree": {
"type": "sequence",
"children": [
{
"type": "debugPrint",
"name": "[demo] portal B: switching to scene A"
},
{
"type": "switchScene",
"name": "demo_scene_a.json",
"params": "@arrival_a"
}
]
}
}
]
},
"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,
"name": {
"name": "arrival_b"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 24.0
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"children": []
},
{
"id": 5,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
},
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": []
},
{
"id": 6,
"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,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"children": []
}
]
}
@@ -0,0 +1,59 @@
# Stage everything demoSceneSwitching needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-scene-switching.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" 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...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest the demo needs
# character_registry.json for the spawner's registryId 2 and
# animation_tree.json for the character's "male1_6" animation tree). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json
animation_tree.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
@@ -0,0 +1,125 @@
# ---------------------------------------------------------------------------
# demo-sokoban forklift sokoban activity demo (vehicle + quest HUD)
# ---------------------------------------------------------------------------
# Starts as an exact copy of demos/demo-interior-exterior-dynamics (same
# scene-switch-door setup, same streaming-terrain/sky/water exterior at
# world center ~(4000, 4000)); the sokoban activity (drivable forklift,
# pushable crates, target pads, completion HUD) is added to
# demo_scene_exterior.json step by step - see PLAN.md in this directory.
#
# The executable is self-contained in this build directory: a POST_BUILD
# 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 both demo scenes
# (demo_scene_interior.json / demo_scene_exterior.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; the terrain heightmap is staged separately
# by configure_file (see below) so source changes re-copy it on the next
# build. Run it from here:
# cd <build>/src/features/editScene/demos/demo-sokoban
# ./demoSokoban
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use door,
# Escape = pause menu (frees the cursor).
get_filename_component(EDITSCENE_SOURCE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
get_filename_component(EDITSCENE_BINARY_DIR
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
# --- Embedded project configuration (F8 release binary) ---------------
# Read project.json at configure time and embed its parameters into the
# binary via a generated project.h, so the release binary is attached to
# its project directory without needing --project. Editing project.json
# re-triggers the CMake configure step (CMAKE_CONFIGURE_DEPENDS).
set(PROJECT_JSON_PATH "${CMAKE_CURRENT_SOURCE_DIR}/project.json")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${PROJECT_JSON_PATH}")
file(READ "${PROJECT_JSON_PATH}" PROJECT_JSON_TEXT)
string(JSON PROJECT_APP_NAME GET "${PROJECT_JSON_TEXT}" appName)
string(JSON PROJECT_START_SCENE GET "${PROJECT_JSON_TEXT}" startScene)
string(JSON PROJECT_GAME_MODE GET "${PROJECT_JSON_TEXT}" gameMode)
if(PROJECT_GAME_MODE)
set(PROJECT_GAME_MODE_VALUE 1)
else()
set(PROJECT_GAME_MODE_VALUE 0)
endif()
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/project.h.in"
"${CMAKE_CURRENT_BINARY_DIR}/generated/project.h" @ONLY)
# Terrain heightmap for the exterior scene's terrain entity (terrainId
# 4242424300000001, heightmapFile heightmap.bin TerrainSystem resolves it
# as heightmaps/<terrainId>/<heightmapFile> relative to the CWD). Generated
# in the source tree by gen_heightmap.py. The terrain runs in streaming
# mode (base heights come from baseNoise, not the heightmap), but the file
# stays staged so flipping streamingEnabled off keeps working. A
# configure_file COPYONLY copy is used on purpose: it registers a configure
# dependency on the source file, so regenerating heightmap.bin re-copies it
# into the staging directory on the next build without manual intervention.
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/heightmap.bin"
"${CMAKE_CURRENT_BINARY_DIR}/heightmaps/4242424300000001/heightmap.bin"
COPYONLY)
add_executable(demoSokoban
demo_main.cpp
${DEMO_SOURCES}
)
target_compile_definitions(demoSokoban
PRIVATE EDITSCENE_HAS_EMBEDDED_PROJECT)
add_dependencies(demoSokoban morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
target_compile_definitions(demoSokoban PRIVATE JPH_DEBUG_RENDERER)
target_link_libraries(demoSokoban
OgreMain
OgreBites
OgreOverlay
OgreMeshLodGenerator
OgrePaging
OgreTerrain
flecs::flecs_static
nlohmann_json::nlohmann_json
Jolt::Jolt
OgreProcedural::OgreProcedural
RecastNavigation::Recast
RecastNavigation::Detour
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
RoadGeometryLib
lua
SDL2::SDL2
)
target_include_directories(demoSokoban PRIVATE
${CMAKE_CURRENT_BINARY_DIR}/generated
${EDITSCENE_SOURCE_DIR}
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# Stage the standalone runtime next to the executable (see header comment).
add_custom_command(TARGET demoSokoban POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
COMMENT "Staging demo-sokoban standalone runtime"
)
@@ -0,0 +1,810 @@
# demo-sokoban — Implementation Plan
**Status: COMPLETE (2026-09-15).** All phases landed; full headless test
battery green (`--test-crate`, `--test-fork`, `--test-vehicle`,
`--test-vehicle-drive`, `--test-switch`, `--test-sokoban`,
`--test-sokoban-reset`).
Forklift sokoban activity in the open world of `demo_scene_exterior.json`:
the player finds a drivable forklift near the building, drives it (Jolt
`VehicleConstraint`), pushes crates onto marked target pads in a walled
yard, and completing the layout registers a completed "quest" shown in a
reusable HUD status display.
This demo starts as an exact copy of `demo-interior-exterior-dynamics`
(scene-switch doors interior <-> exterior, streaming terrain archipelago,
sky, water). All sokoban content is added to the exterior scene near the
existing content site at world ~(4000, 10, 4000).
## Design decisions (agreed with the user)
- **Quest**: no full quest system yet. Completion is recorded in
`GlobalStateStore` (per-instance keys `quest.<id>.completed = true`),
a `quest_completed` event is sent on the `EventBus`, and a new reusable
HUD status display (top-right corner) shows progress and the
completion banner. The HUD display is designed to be reused by a
future real quest system.
- **Vehicle**: real `JPH::VehicleConstraint` with
`WheeledVehicleController` — not a kinematic arcade hack.
- **Rules**: classic multi-crate sokoban field — several crates, the
same number of target pads, walls around the yard, completion when
every pad is covered. Crates are free dynamic bodies pushed by the
forklift (no grid snapping of motion; the *layout* is grid-aligned).
- **Visuals**: new Blender assets (forklift, crate, target pad)
exported through the existing asset pipeline — no procedural
primitives for the final look.
- Should be implemented in a way it is easy to set up in other demo
- It should be possible to play multiple instances of Sokoban in the same demo using the same or different forklifts.
- Should use Lua APIs where appropriate (for Sokoban logic at least).
- Jolt examples can be a reference for vehicle implementation.
### Consequences of these decisions (architecture constraints)
- **Engine-level, not demo-level**: all new components/systems/Lua APIs
live in `src/features/editScene` (`components/`, `systems/`, `lua/`,
`ui/`) so any demo or scene can use them. `demo-sokoban` itself only
contributes scene JSON data, assets and headless test listeners in
`demo_main.cpp` — no game logic in the demo binary.
- **Instance-based sokoban**: one yard = one game instance with its own
id, crate list, pad list and `quest.<id>.*` global-state keys. Any
number of yards can coexist in a scene; forklifts and crates are not
bound to a yard — anything that pushes a crate onto a pad counts.
- **Lua runs the rules**: C++ provides per-frame physics-adjacent
detection and emits events; the sokoban rules (what counts as
completion, HUD text, rewards, reset) live in a shared Lua module.
Note the existing Lua integration is execute-once + event-driven
(`SceneScriptSystem`, `EventBus`) with *no per-frame Lua hook* — so
per-frame detection stays in C++ and Lua reacts to events; no
per-frame Lua polling is added.
## Existing pieces we build on
- `physics/physics.h` (`JoltPhysicsWrapper`): body creation (incl.
world-space `RVec3` overloads), sensors, per-body contact listeners,
raycasts, `getPhysicsSystem()` access to the raw `JPH::PhysicsSystem`.
- `components/RigidBody.hpp` + `components/PhysicsCollider.hpp` +
`systems/PhysicsSystem.cpp`: static/dynamic/kinematic bodies from
scene JSON (`rigidBody`, `collider` keys), physics->node sync for
dynamic bodies. The crate needs nothing new.
- `components/Actuator.hpp` + `systems/ActuatorSystem.cpp`: E-key
interaction prompts; actions run behavior trees from the scene's
top-level `actionDatabase`, including `luaTask` nodes — enough to
emit a `vehicle_enter` event without new BT node types.
- `systems/PlayerControllerSystem.cpp` + `GameInputState`
(EditorApp.hpp): game-mode input handling and TPS camera boom to
model the vehicle controller/camera on.
- `systems/GlobalStateStore.*` (F9): persistent key/value store,
survives `switchScene()`, C++/Lua API.
- `systems/EventBus.*`: `send`/`subscribe` events, used by the door
contract and Lua (`ecs.send_event` / `ecs.subscribe_event`).
- `components/SceneScript.hpp` + inline scene scripts: per-scene Lua
glue.
- Component registration pattern: `components/FooModule.cpp` with
`REGISTER_COMPONENT_GROUP` + `ui/FooEditor.hpp` (see
`components/RigidBodyModule.cpp`), plus serialization in
`systems/SceneSerializer.cpp`.
- Demo scaffolding: this directory (CMake target, `stage_runtime.cmake`,
embedded `project.json`, symlinked scenes, `--headless
--exit-after-first-frame` smoke run, `--test-switch` end-to-end
listener pattern in `demo_main.cpp`).
- Feature doc convention: `GameFeatures202609.md` (F0-F9 used; this
work adds F10/F11/F12).
- Jolt vehicle reference implementations, checked out locally:
`/media/slapin/library/ogre3/jolt/Samples/Tests/Vehicle/`
(`VehicleConstraintTest`, `VehicleTest`, `VehicleSixDOFTest`) — the
same Jolt version the SDK is built from (`Jolt/Jolt.h` headers in the
ogre-sdk include tree).
## Pieces that do not exist yet (to build)
1. Vehicle physics: no `VehicleConstraint` use anywhere; the wrapper
has no constraint support at all.
2. ~~Vehicle concept: no `VehicleComponent`/`VehicleSystem`, no
enter/exit flow, no vehicle input/camera.~~ (Phases 1-3:
`VehicleComponent`/`VehicleSystem`, `VehicleControllerSystem`
enter/exit + drive input + TPS camera swap all exist now.)
3. Quest system: none in editScene (only legacy `src/gamedata`, off
limits) — deliberately out of scope; only the HUD status display
+ global-state record + event are built.
4. Sokoban logic: no crate/target/game-state tracking. Split into a
generic C++ zone-detection piece (reusable by future activities) and
the sokoban rules in Lua.
5. Assets: no forklift/crate/pad meshes.
6. Lua surface: no `ecs.hud.*` bindings; the HUD status display and its
Lua API are new.
## Phases
Each phase ends in a buildable, runnable demo; verification commands
assume the build tree (e.g. `build-vscode`) is configured.
### Phase 0 — Demo skeleton (this step)
Copy of `demo-interior-exterior-dynamics` with target `demoSokoban`,
`project.json` appName `demo-sokoban`, registered via
`add_subdirectory(demos/demo-sokoban)` in
`src/features/editScene/CMakeLists.txt`.
Verify:
```bash
cmake --build build-vscode --target demoSokoban -j4
cd build-vscode/src/features/editScene/demos/demo-sokoban
./demoSokoban --headless --exit-after-first-frame
./demoSokoban --headless --test-switch # inherited scene contract
```
### Phase 1 — Vehicle physics foundation (F10) — DONE
Status: complete; `--test-vehicle` passes on both the flat-floor scene
(`demo_scene_vehicletest.json`) and the streaming-terrain exterior scene.
What landed:
- `physics/physics.h/.cpp`: `JoltPhysicsWrapper::createVehicle` /
`destroyVehicle` / `setVehicleInput` / `getVehicleForwardSpeed` /
`getWheelWorldTransform` on top of `JPH::VehicleConstraint` +
`WheeledVehicleController` + a raycast `VehicleCollisionTester`;
the differential `mEngineTorqueRatio` is split evenly (1/N per driven
wheel) so 4WD configs satisfy Jolt's sum-of-torque-ratios assert.
- `components/Vehicle.hpp` + `components/VehicleModule.cpp` +
`ui/VehicleEditor.{hpp,cpp}`: `VehicleComponent` (wheel list with
offset/radius/width/suspension/steer/driven/handbrake per wheel,
`maxTorque`, `maxPitchRollAngleDeg`, `seatOffset`, `wheelMeshName`),
registered, editable in the editor UI and serialized as the `vehicle`
scene key (see `SceneSerializer`).
- `systems/VehicleSystem.{hpp,cpp}`: creates/destroys the constraint
from `VehicleComponent` + the entity's existing `RigidBodyComponent`
body, applies driver input (with the Jolt sample's
brake-before-reverse rule) in `prePhysicsUpdate`, and moves wheel
visual child nodes from constraint state in `postPhysicsUpdate`
(both wired into `EditorApp` around the physics step).
- Forklift placeholder entity `forklift1` (id 500) at (4012, 10.8, 3960)
in `demo_scene_exterior.json` and (0, 1.5, 0) in the new flat-floor
regression scene `demo_scene_vehicletest.json` (symlinked into the
build dir by `stage_runtime.cmake` like the two main scenes).
Tuning: `maxTorque` 120 (500 Nm through 1st gear produced ~15 kN and
wheelies/backflips), front wheels driven, rear wheels steer 35 deg +
handbrake (rear-steer like a real forklift — resolves open question
4). Drives straight and stable: 0 -> ~8 m/s in 7 s on flat ground.
- `--test-vehicle` in `demo_main.cpp` (`VehicleTestListener`):
waits for constraint creation, waits for the chassis to settle
upright on the terrain (a grounding watchdog re-teleports the chassis
to its authored pose while the streaming page colliders are still
being built — kept deliberately, it guards the collider race), drives
forward 180 frames asserting progress and uprightness, then brakes to
a full stop; prints `[test] PASS`. Note: the process then dies with
a known pre-existing teardown segfault (WaterPlane RTT viewport
destruction, also present in the base demo) that masks the exit code
with 139 — judge by the `[test] PASS` line in stdout.
- Headless determinism: headless frames are ~1.3 ms wall time, which
starved the fixed-step accumulator and made the soft suspension
position-solve misbehave; `EditorApp::setFixedDeltaTime(float)` (used
by all headless test modes in this demo) pins
`evt.timeSinceLastFrame` to 1/60 so physics sees real steps.
`EditorPhysicsSystem::update()` also clamps deltaTime > 0.1 to 0.1 so
the post-load hitch cannot trigger huge catch-up steps.
- Bug fix in `systems/PhysicsSystem.cpp` (`buildCompoundShape`): the
rigid-body entity's *own* collider shape was wrapped in a
`RotatedTranslatedShape` offset by the entity's world position while
the body was also created at that position — the collider ended up at
2x the world position, so any dynamic body far from the origin fell
through the terrain (invisible near the origin, which is why it went
unnoticed; wheel raycasts still hit the terrain, which made it look
like a convex-vs-mesh narrowphase failure — a standalone Jolt repro
proved the page `MeshShape` itself is fine). The own-collider is now
placed at the compound origin; child colliders keep their local
offsets.
- Debug scene variants used during the hunt
(`demo_scene_exterior_nowater/_box/_min/_nostream.json`) were
deleted; coverage is the two `--test-vehicle` scenes.
Verify:
```bash
cmake --build build-vscode --target demoSokoban -j4
cd build-vscode/src/features/editScene/demos/demo-sokoban
./demoSokoban --headless --test-vehicle # terrain scene
./demoSokoban --headless --test-vehicle demo_scene_vehicletest.json # flat floor
./demoSokoban --headless --test-switch # inherited contract
./demoSokoban --headless --exit-after-first-frame # smoke
```
### Phase 2 — Blender assets — DONE
Status: complete; forklift/crate/pad meshes are generated, exported and
live in both demo scenes (`forklift1` uses `forklift.mesh` +
`forklift-wheel.mesh`); all headless tests still pass and the visuals
were screenshot-verified.
What landed:
- `assets/blender/scripts/gen_sokoban_assets.py` — one-shot generator
(run with `blender -b -Y -P ...`) that builds the geometry and writes
`assets/blender/vehicles/{forklift,crate,pad}.blend`:
- `forklift.blend`: `forklift` (joined body, counterweight, seat,
overhead guard, mast, fork tines; front = Blender -Y = Ogre +Z) and
`forklift-wheel` (single wheel, axle along X = the Jolt wheel spin
axis, reused for all 4 wheel visuals via
`VehicleComponent::wheelMeshName`);
- `crate.blend`: `crate` — 1 m box (0.96 core + proud edge beams)
centred at the origin, materials `crate` / `crate-frame`;
- `pad.blend`: `pad` — flat 1.2 x 1.2 x 0.04 marker slab, single
material `pad` (the zone system will swap it for covered/uncovered
feedback in Phase 6).
- `assets/blender/vehicles/CMakeLists.txt`: the three .blend files are
in `VEHICLES_SRC`, so `import_vehicles` exports
`{forklift,crate,pad}.{glb,scene}` plus per-object `.mesh` files
(`forklift.mesh`, `forklift-wheel.mesh`, `crate.mesh`, `pad.mesh`)
and materials into `resources/vehicles/` via the existing
`export_vehicles.py` pipeline (blender2ogre). Gotcha: the exporter
names `.mesh` files after the mesh *data* name, not the object name —
the generator sets both.
- Scene swap: `forklift1` renderable `Cube.mesh` (scaled) ->
`forklift.mesh` (scale 1) and `wheelMeshName` ->
`forklift-wheel.mesh` in `demo_scene_exterior.json` and
`demo_scene_vehicletest.json`.
- Resource staging note: `editSceneEditor`'s POST_BUILD copies
`${CMAKE_BINARY_DIR}/resources` into the editScene binary dir (the
demo symlinks it from there), so after building `import_vehicles`
either relink `editSceneEditor` or copy
`build*/resources/vehicles` into `build*/src/features/editScene/resources/`
manually.
Verified:
```bash
cmake --build build-vscode --target import_vehicles -j4
# (forklift/crate/pad meshes land in build-vscode/resources/vehicles)
./demoSokoban --headless --test-vehicle # PASS
./demoSokoban --headless --test-vehicle demo_scene_vehicletest.json # PASS
./demoSokoban --headless --test-switch # PASS
# visuals: xvfb-run -a ./demoSokoban demo_scene_exterior.json \
# --force-pos 4014 10.5 3968 0 10 --screenshot 250 /tmp/shot.png
```
### Phase 3 — Enter/exit and vehicle controller (F10) — DONE
Implemented as a separate `systems/VehicleControllerSystem.{hpp,cpp}`
(game mode only):
- Enter: the forklift entity carries an `ActuatorComponent`
(`actionNames: ["vehicle_drive"]`); the scene `actionDatabase` action
`vehicle_drive` runs a single `luaTask` node `vehicleEnter`
(registered in `src/features/editScene/lua-scripts/data2.lua`) which
sends the `vehicle_enter` event with `vehicle=<entity name>`.
`VehicleControllerSystem` subscribes on the `EventBus` (and also
exposes `enterVehicle(entity)` for tests): on enter, the player
character's physics capsule is disabled
(`CharacterSystem::disablePhysics`), its node is pinned to the
vehicle's `seatOffset` every frame (position + orientation), the
locomotion state machine is parked in the idle state, the vehicle
gets the `PlayerControlledComponent` tag, and the controller's
`tpsDistance`/`tpsHeight` are swapped for the new serialized
`VehicleComponent::cameraDistance` (5.0) / `cameraHeight` (2.2),
restored on exit. The existing collision-clamped TPS boom follows
the vehicle automatically because it tracks the character node.
- Driving: `VehicleControllerSystem::update` (runs right before
`PlayerControllerSystem::update` in Game+Playing) feeds
`GameInputState` into the vehicle inputs — W/S `inputForward`,
A/D `inputRight`, Space `inputHandBrake` (new `space`/`spacePressed`
fields on `GameInputState`, wired in the game-mode
keyPressed/keyReleased). New runtime flag
`PlayerControllerComponent::driving` gates off
`PlayerControllerSystem::updateLocomotion` and the
`ActuatorSystem` prompt collection while seated.
- Exit: E while driving picks the first raycast-clear spot beside
(±X) or behind/ahead (±Z) of the chassis (horizontal clearance ray
plus a downward ground ray; `raycastQuery` sees NON_MOVING only, so
the vehicle never blocks its own exit), moves the character node
there, re-enables its physics (the CharacterSystem diff check
teleports the capsule to the node) and restores on-foot controls.
No clear spot → stay seated, warning logged.
- Guardrails: `resetDriving()` (no raycasts) is called from
`EditorApp::destroySceneEntities` before controller reset, and the
system is torn down before `PlayerControllerSystem`/`CharacterSystem`.
Driving state is runtime-only: it does NOT survive save/load — after
a load the character stands where the seat was, on foot.
Verified: `--test-vehicle-drive` headless — waits for forklift +
player, sends `vehicle_enter`, asserts the seated state (driving flag,
vehicle tag, capsule disabled when present), drives 240 frames with W
held, asserts ≥2 units moved, exits and asserts the character ends up
next to the forklift with physics re-enabled. `--test-vehicle`,
`--test-switch` and `--exit-after-first-frame` still PASS.
Amendments after first hands-on drive (user feedback):
- Exit is now hold-to-exit: a cooldown (`EXIT_MIN_SEAT_TIME` 0.75 s)
after seating must pass before E counts at all (the entering tap never
leaks through), then E must be held for `EXIT_HOLD_TIME` (0.5 s).
- New `driving` state in the `locomotion` state machine of every
male/female animation tree (mapped to the `sitting` animation for
now), selected on enter; `idleState` is restored on exit. The state
name lives in the new serialized
`PlayerControllerComponent::drivingState` ("driving").
- `animation_tree.json` is now demo-owned: copied into
`demos/demo-sokoban/` (source tree, versioned with the demo) and
staged by copy (not symlink — the registry auto-saves on runtime
migrations and must not write into the source tree). All 7 male1*
trees carry the new state; the nav-test* trees were left alone.
- Forklift `cameraDistance`/`cameraHeight` tuned to 5.0/1.7 in the
scene JSONs (the standing idle posed the camera too high).
Amendments after second hands-on drive (user feedback):
- Steering felt reversed: the forklift steers with its rear axle, so
the raw wheel angle turns the nose the opposite way a front-steer car
would. New serialized `VehicleComponent::rearSteer` flag flips the
steer sign in `VehicleSystem::prePhysicsUpdate` (D still turns the
nose right = player muscle memory); set on both forklifts. Editor
checkbox in `VehicleEditor`.
- Seat position: the character sat on the roof — `seatOffset` lowered
to (0, 0.05, -0.35) (seat cushion level, cabin centre) and
`cameraHeight` to 1.2; screenshot-verified seated pose via the new
`--debug-enter-vehicle <name>` demo flag (sends `vehicle_enter` once
player + vehicle are up; combine with `--screenshot`).
Amendments after third hands-on drive (user feedback):
- Coasting damping: the forklift kept rolling far too long. New
serialized `VehicleComponent::engineBrake` (default 0.5, editor
DragFloat in `VehicleEditor`): with no throttle input and speed above
0.5 m/s, `VehicleSystem::prePhysicsUpdate` applies this brake factor
automatically, so releasing W/S now slows the forklift like engine
braking would.
- Seat still too high (head clipped the roof): `seatOffset` y lowered
another 0.1 to -0.05 in both scene JSONs; screenshot-verified.
### Phase 4 — Crate (F11) — DONE
- `components/Pushable.hpp` + `PushableModule.cpp` + `ui/PushableEditor`
+ serialization (`pushable` scene key): a small generic marker
component (`PushableComponent`) for "a dynamic prop that activities
care about" — zone detection keys off it, so any crate can be used by
any sokoban instance (or a future activity). It carries an `enabled`
flag because flecs treats empty structs as tags (`set`/`get_mut`
assert on them); registered with the world in
`EditorApp::registerComponents` next to `ActuatorComponent`.
- Scene JSON per crate: `renderable` (crate mesh), `rigidBody`
(dynamic, 40 kg, friction 0.6, no restitution), 0.5 half-extents box
`collider`, `pushable`. Three crates (crate1-3) parked behind the
forklift in the exterior scene, one (crate1) off the drive path in
`demo_scene_vehicletest.json`.
- Bug found while landing this: `Physics::createBody` (editScene
physics wrapper) set `mMassPropertiesOverride` but never
`mOverrideMassProperties`, so Jolt silently derived mass from shape
density (1000 kg/m3) and `rigidBody.mass` did nothing — the "40 kg"
crate was actually 1000 kg. Fixed with
`EOverrideMassProperties::CalculateInertia`; all dynamic bodies with
an explicit mass now really have it (the forklift is now truly
1500 kg; all vehicle tests still PASS).
Verify: `--test-crate` headless (flat-floor scene): the settled crate
is woken and kicked with a 200 N*s impulse (5 m/s at 40 kg), slides
~1.8 units, stays upright and settles on the floor. PASS.
### Phase 5 — Sokoban yard layout + target zones (F11) — DONE
Status: complete; the yard is laid out in `demo_scene_exterior.json`,
screenshot-verified, and all headless tests still pass.
What landed:
- Yard ground decision (open question #1): resolved as "near-flat
terrain, no flattening, no pad platform". The new demo flag
`--probe-heights cx cz half step` prints a grid of streaming-terrain
heights; the yard rect (x 4005..4019, z 3936..3950) varies only
9.91..10.05 with a smooth ~0.7% grade and no bumps, so per-entity Ys
were taken from the probe instead of running terrain compliance.
- `components/TargetZone.hpp` + `TargetZoneModule.cpp` +
`ui/TargetZoneEditor` + serialization (`targetZone` scene key at both
deserialize dispatch sites): generic `TargetZoneComponent` (zoneId,
halfExtents, uncovered/coveredMaterialName for the Phase 6 visual
swap — left empty until the materials exist, `enabled`).
- Yard layout (entity ids 504-512): three pads (`pad.mesh`, zoneIds
`yard1.pad1..3`) in a row at z=3941 (x 4008/4012/4016), crate1-3
moved to the start row z=3945 (x 4009/4012/4015), and five boundary
walls with a 3 m gate on the south side (x 4010.5..4013.5) for the
forklift. Walls are static rigid bodies with box colliders.
- New `wall.mesh` (unit 1x1x1 cube, `sokoban-wall` material) added to
`gen_sokoban_assets.py` (`gen_wall()`) and `VEHICLES_SRC` — the
buildings `Cube.mesh` is NOT 1-unit (first attempt produced
screen-filling walls). Remember to copy
`build*/resources/vehicles/{wall.mesh,sokoban-wall.material}` into
the editScene resources dir (or relink `editSceneEditor`) after
building `import_vehicles`.
- `yard1` controller entity (id 512) with a stub inline sceneScript —
the real `sokoban.new{...}` registration lands in Phase 6.
Verify: screenshot of the yard from above the site:
`xvfb-run -a ./demoSokoban demo_scene_exterior.json --force-pos 4017 11.2 3957 20 -30 --screenshot 250 /tmp/yard.png`
(negative pitch looks down).
### Phase 6 — Zone detection (C++) + sokoban rules (Lua) (F11/F12) — DONE
Status: complete; `--test-sokoban` passes and the full test battery
(`--test-crate`, `--test-vehicle`, `--test-vehicle-drive`,
`--test-switch`, first-frame smoke) is green. The as-built details
below supersede the original sketch where they differ.
C++ side (per-frame detection, engine-level, multi-instance):
- `systems/ZoneSystem.{hpp,cpp}`: per-frame XZ-overlap + nearly-at-rest
check of `PushableComponent` entities against `TargetZoneComponent`
zones; emits `EventBus` events `zone_entered` / `zone_left`
(params: zone id, entity name/id); drives the pad visual feedback
(covered/uncovered material swap). Zone ids are unique per yard
(`<yardId>.pad<n>`), so multiple yards never interfere.
- As built: runs in Game+Playing mode only, called from `EditorApp`
right after `PlayerControllerSystem`. Occupancy = XZ overlap within
`halfExtents`, |dy| < 1, speed < 0.2 m/s. Occupant diff handles
entity/zone destruction (vanished occupants and vanished zones flush
`zone_left` so Lua never goes stale). Test API: `isZoneCovered`,
`getZoneOccupantCount`.
- **Physics positions are authoritative**: prop positions come from
`JoltPhysicsWrapper::getPosition(bodyID)` (converted to render space
via `RenderOriginSystem::worldToRender`), NOT the scene node — the
node lags one frame behind a physics teleport and produced phantom
zone entries in the test (a crate teleported 4 m off a pad briefly
"re-entered" it, latching a bogus completion).
- Known cosmetic quirk: crates creep on the slight terrain grade, so a
crate resting on a pad can momentarily exceed the 0.2 m/s speed
threshold and fire `zone_left`/`zone_entered` pairs while sliding
(visible in the test trace). Completion is latched so this does not
affect the quest; if it ever matters, add friction to the pads or
hysteresis to the speed threshold.
Lua side (the actual sokoban rules, shared by all demos):
- `lua-scripts/sokoban.lua` (in `src/features/editScene/lua-scripts/`,
staged into the `LuaScripts` resource group by `lua_scripts_package`,
loaded with plain `require("sokoban")``LuaState::luaLibraryLoader`
maps dots to slashes and appends `.lua`; open question #7 resolved):
`sokoban.new{ id = "yard1", pads = { ... }, questName = "Crate Yard",
onCompleted = optional fn }` returns an instance handle. The instance
subscribes to `zone_entered`/`zone_left` via `ecs.subscribe_event`,
keeps a per-pad occupant-name set, and on full coverage (latched):
- `ecs.global.set("quest.<id>.completed", true)` and
`quest.<id>.progress` (int, updated on every change; survives scene
switches, F9) — note the API is `ecs.global`, not
`ecs.global_state` as sketched;
- `ecs.send_event("quest_completed", { quest_id = id, quest_name =
name })` and `sokoban_progress` on every progress change;
- completion is restored from global state at registration, so a
reloaded scene does not re-fire the quest.
- Instances ignore zone ids outside their `pads` list (multi-yard
isolation, verified by the test's second instance).
- The HUD binding (`ecs.hud.*`) and the reset event/actuator are
deferred to Phase 7+; they were part of the original sketch.
- The yard controller entity (id 512) inline sceneScript does
`require("sokoban")` + `sokoban.new{ id = "yard1", pads = {
"yard1.pad1", "yard1.pad2", "yard1.pad3" }, questName = "Crate Yard" }`.
The three pads now name `pad`/`pad-covered` materials; `pad-covered`
(green) is created programmatically in `demo_main.cpp`
(`createDemoResources`, demo-only).
- Decision (kept): crate positions are *not* persisted across scene
switches/saves — the layout resets to the scene JSON.
`--test-sokoban` (headless, defaults to the exterior scene): phase 0
waits for crates/bodies/pads/ZoneSystem; phase 1 teleports crate1-3 onto
the pad centers (+0.55 y, velocity zeroed); phase 2 waits for all three
`zone_entered` events, `quest.yard1.completed` in GlobalStateStore and
the `quest_completed` event; phase 3 registers a second Lua instance
(`yardtest`, shares pad1) via `luaL_dostring` on
`EditorApp::getLuaState()`, pulls crate1 4 m west (pinned there every
frame so the slope cannot roll it back onto a pad), asserts yard1 stays
latched and yardtest does not complete without a crate, then returns
crate1 to pad1; phase 33 waits for yardtest's independent completion.
A one-shot `registeredTestyard` flag guards the registration — the
original `frame == phaseStart` trick never fired because phase
transitions set `phaseStart = frame` on the transition frame itself.
Exit code mirrors the crate test (`phase != 4` = incomplete FAIL);
judge by `[test] PASS` in stdout (known teardown segfault can mask the
exit code with 139).
Also landed alongside this phase (user feedback on driving): see the
"Amendments after third hands-on drive" block under Phase 3
(`VehicleComponent::engineBrake` coasting brake, seatOffset y -0.05).
### Phase 7 — HUD status display + Lua bindings (F12) — DONE
Status: complete; `--test-sokoban` asserts the HUD state headless and
the overlay is screenshot-verified (status lines + completion banner).
What landed (as planned, no deviations):
- `systems/GameHudSystem.{hpp,cpp}`: game-mode-only ImGui overlay,
top-right corner, no window chrome (`##game_hud`, NoTitleBar/NoMove/
NoSavedSettings...); reusable API: `setStatus(key, text)` for
persistent lines ("Crate Yard: crates 2/3", ordered by key) and
`pushMessage(text, ttl)` for transient banners ("Quest completed:
Crate Yard", golden text fading over the last second, default ttl
6 s). render() gates on Game+Playing and on a live ImGui context
(headless safe); update(deltaTime) expires messages. Owned by
`EditorApp` (`getGameHudSystem()`), wired into
`ImGuiRenderListener::preViewportUpdate` next to
`ActuatorSystem::render()`. A future quest system reuses the same
display.
- `lua/LuaHudApi.{hpp,cpp}`: `ecs.hud.set_status(key, text)`,
`ecs.hud.clear_status(key)`, `ecs.hud.push_message(text [, ttl])`
forwarding to the GameHudSystem set via `setLuaHudSystem()` (safe
no-ops without one, e.g. unit tests). Registered in `EditorApp`
right after `registerLuaDoorApi`.
- `sokoban.lua` is the first client: status key `sokoban.<id>` shows
"<questName>: crates n/m" from registration on (0/m until zones
report), updates on every zone event, and pushes the
"Quest completed: <questName>" banner (8 s) on completion. The
`ecs.hud` calls are guarded (`if ecs.hud`) so the module still loads
in bare Lua states.
- `--test-sokoban` phase 2 now also asserts the HUD status line text
and that a completion banner message exists.
Verify: `xvfb-run -a ./demoSokoban demo_scene_exterior.json
--screenshot 200 /tmp/hud.png` (initial "crates 0/3" line);
`xvfb-run -a ./demoSokoban --test-sokoban --screenshot 115
/tmp/hud2.png` (3/3 + "Test Yard" line + "Quest completed: Crate Yard"
banner — under xvfb the wall-clock ttl expires fast, screenshot early).
Amendments after fourth hands-on drive (user feedback, steering wheel):
- New steering wheel visual: `forklift-steering-wheel.mesh` (torus rim
+ hub + 3 spokes in `gen_sokoban_assets.py`, spin axis Blender Z =
Ogre Y, modelled at the origin) plus a steering column added to the
forklift body mesh. New serialized `VehicleComponent` fields:
`steeringWheelMeshName`, `steeringWheelOffset` (chassis space),
`steeringWheelTiltDeg` (25, wheel plane leaning toward the driver),
`steeringRatio` (4 = steering-wheel degrees per road-wheel degree) —
all editable in `VehicleEditor`. `VehicleSystem` creates the visual
node in `createWheelVisuals`, smooths the road-wheel steer angle
(`m_steerSmooth`, ~8/s blend in prePhysicsUpdate) and spins the node
around its tilted local Y in `postPhysicsUpdate`; D spins it the way
a front-steer car would (sign follows the driver, not the raw
rear-steer wheel angle). `--test-vehicle-drive` gained a D-hold
phase asserting the node orientation actually changes
(`getSteeringWheelNode()` test accessor).
- Footwell (leg room): the one-piece body box in
`gen_sokoban_assets.py` is now split into a front hood block ending
at blender y 0.0, an under-seat block and a footwell floor plate, so
the seated driver's legs dangle into a recess instead of clipping
the body. Seat position unchanged (it was correct).
- Debug pitfall worth remembering: the steering wheel looked
"misplaced above the roof" in outside screenshots while its node
position was provably correct — screen-space misjudgement from
seeing the cab from below roof level. The decisive check was
swapping `steeringWheelMeshName` to `crate.mesh` (a 1 m box makes
the render position obvious) and projecting two known reference
points. Scene JSON is symlinked into the build dir, so such probes
need no rebuild.
### Phase 8 — Working fork + yard reset (F10/F11) — DONE
Status: complete; `--test-fork` (flat scene: align, lift, drive
carried, release, drive away) and `--test-sokoban-reset` (exterior
scene: displace crate, move the player 150 m away, crates teleport
back) both pass headless; seat/wheel placement screenshot-verified.
Seat/steering-wheel placement (fifth hands-on drive):
- `seatOffset` z -0.35 -> -0.85 in both scene JSONs: the `sitting`
animation renders the character mesh ~0.5 m forward of its node, so
the pin point must sit that far behind the visual seat (empirical,
screenshot-iterated from the side profile).
- `steeringWheelOffset` (0, 0.75, -0.3) (was (0, 1.06, 0.08) — up at
roof level).
Amendments after sixth hands-on drive (user feedback: seat jitter,
detached steering wheel):
- Seat jitter: the character was pinned to the seat from
`VehicleControllerSystem::update`, which runs BEFORE the physics step,
so the pin read last frame's vehicle node while the vehicle node was
synced to the current physics state right before rendering. Because
the physics step advances in fixed 60 Hz bursts (accumulator in
physics.cpp), the one-sync lag turned into visible back-forth jitter
of the seat under the (smooth, camera-glued) driver. The pin moved
to `VehicleControllerSystem::postPhysicsUpdate()`, called right after
`VehicleSystem::postPhysicsUpdate()`, and the driving-mode camera
moved to `PlayerControllerSystem::postPhysicsUpdate()` right after
that (`update()` skips the camera while `driving`), so the vehicle
node, the driver and the camera all derive from the same physics
state every rendered frame.
- Steering column: the exported `forklift.mesh` carries a baked-in
origin offset — `join_into()` in `gen_sokoban_assets.py` makes the
first part (body-front at blender (0, -0.45, 0.55)) the join target
and the exporter does not apply its object transform, so a blender
point (X, Y, Z) renders at Ogre (X, Z - 0.55, -(Y + 0.45)), NOT the
plain axis swap the old comment claimed. The column was aimed at a
phantom hub (blender (0, 0.3, 1.35)) and ended 0.6 above / 0.45
behind the real one; it now runs from the hood top to just under the
actual hub (blender (0, -0.15, 1.30) for steeringWheelOffset
(0, 0.75, -0.3)), leaning 25 deg to match the wheel tilt. The
mapping is documented in the script's docstring. Verified by dumping
the exported mesh through OgreXMLConverter and checking the column
cluster against the hub coordinates.
- Test robustness: `--test-sokoban` phase 3 waited for a fresh
`zone_left yard1.pad1|crate1` after pulling the crate off the pad,
but a settling bounce (the zone system drops occupants above 0.2 m/s)
could consume that inside->outside edge first, stalling the test
until the phase timeout. The wait now polls
`ZoneSystem::isZoneCovered("yard1.pad1")` instead of the event edge.
Amendments after seventh hands-on drive (user feedback: seat gap,
wheel/column angle, head vs roof):
- Seat group (`seat-base`, `seat-back` and the under-seat `body-seat`
block in `gen_sokoban_assets.py`) moved 0.1 towards the rear
(blender +Y) while the driver pin (`seatOffset`) stays put — the
legs drop into the footwell opening and the hole behind the seat
closes.
- Wheel/column angle: the wheel's spin axis was 65 deg from vertical
while the column leans 25 deg — the wheel plane looked parallel to
the column instead of perpendicular. `steeringWheelTiltDeg`
25 -> 65 in both scene JSONs puts the spin axis colinear with the
column (the parameter semantics — "wheel plane this many degrees
from vertical" — are unchanged).
- Overhead guard raised 0.25 (posts 0.85 -> 1.10 tall, roof at blender
z 1.92) so the seated driver's head clears the roof; the driver pin
and the chassis collider are unchanged, and the camera boom
(cameraHeight 1.2) still runs below the roof.
Amendments after eighth hands-on drive (user feedback: driver height,
head vs roof again, detached counterweight):
- Driver pin lowered 0.02: `seatOffset` y -0.05 -> -0.07 in both scene
JSONs (no mesh change).
- Overhead guard raised another 0.1: posts 1.10 -> 1.20 tall (centres
at blender z 1.395), roof at blender z 2.02 (spans 1.99..2.05).
- Counterweight moved forward from blender y 1.15 to 0.915 so it embeds
1 cm into the under-seat block's back face (y 0.70) — no more gap,
and no coplanar z-fight. The chassis collider grew to cover it:
box half z 1.0 -> 1.3 with offset z -0.3 in both scene JSONs, giving
Ogre z coverage [-1.6, +1.0]; the front face stays at +1.0 so the
fork/crate interaction from Phase 8 is untouched. Mesh verified
numerically (OgreXMLConverter AABB dump): yellow z min -1.59 inside
the collider, steel y max 1.50 (raised roof), seat range unchanged.
Amendments after ninth hands-on drive (user feedback: completion
sometimes not registering with all pads green, fork controls
undiscoverable, fork visually behind the front wheels):
- Completion reliability: new `ecs.is_zone_covered(zone_id)` Lua
binding wired to `ZoneSystem::isZoneCovered` (the same state that
paints a pad green) via the injected-callback pattern in
LuaEntityApi. `sokoban.lua` now reads coverage from that
authoritative state (falling back to its event-tracked occupant sets
when the binding is absent, e.g. pure-Lua tests), publishes progress
only on change, and reconciles on a throttled (0.25 s) frame tick —
a missed `zone_entered`/`zone_left` edge can no longer leave every
pad visibly green without `quest_completed` firing. Registration
semantics are preserved with a baseline latch: pads already covered
when an instance registers do not count until they have read
uncovered at least once (a fresh instance on an already-covered pad
still needs a fresh cover, as `--test-sokoban` phase 3 asserts).
- Controls hint: `VehicleControllerSystem::enterVehicle` pushes a
GameHudSystem message on entry ("W/S move, A/D steer, hold E to
exit", plus "R/F fork up/down" when the vehicle has a fork).
- Fork in front of the front wheels: a `body-nose` part extends the
hood forward (Ogre z 0.45 -> 0.90, front wheels tuck under it), the
mast rails moved ahead of the front wheels (front face Ogre z 1.14
vs wheel edge 1.15) and `mast-cross` became a low beam at axle
height linking nose to mast between the wheels. `forkOffset` z
0 -> 1.17 in both scene JSONs shifts the whole fork assembly forward
(carriage rides the mast front, tines span Ogre z [1.125, 2.075] —
right under a crate flush against the chassis). The attach box in
`VehicleSystem::tryForkAttach` is now absolute chassis space
(constants unchanged, [1.4, 2.1]); `forkOffset` is purely visual and
cancels out of the carry pin math.
- Fork height fix: the fork mesh has its own baked origin (the
carriage location (0, -1.12, 0.15) -> Ogre (X, Z - 0.15,
-(Y + 1.12))), not the main mesh's offset, so the tines had been
rendering 0.4 too high (floating at crate mid-height between the
wheels). Tines/heels remodelled to floor level (tine top just above
the floor at chassis y -0.70).
Working fork (fork-carry, open question 2):
- `gen_sokoban_assets.py`: the tines + heels left `forklift.mesh` and
became `forklift-forks.mesh` (carriage plate + tines modelled at the
fork-down position, origin = chassis origin). The tines are dropped
0.2 below the collider bottom so they hug the floor (the chassis
rides ~0.73 high on its suspension).
- Chassis collider z half extent 1.2 -> 1.0 in both scene JSONs: the
front face now stops at the crate face (faces touch at chassis z =
crate z - 1.5) instead of shoving the crate before the tines are
under it. The tines themselves have no collider and slide under the
crate. Push-sokoban still works (the front face pushes).
- New serialized `VehicleComponent` fields (editable in
`VehicleEditor`): `forkMeshName`, `forkOffset` (carriage position at
height 0, chassis space), `forkMaxHeight` (1.2), `forkSpeed` (0.8),
plus runtime `forkHeight` and driver input `inputFork` (-1..1).
Keys while driving: hold R = raise, F = lower (GameInputState gained
`r`/`rPressed`; `VehicleControllerSystem` feeds `inputFork`).
- `VehicleSystem::updateFork` (prePhysicsUpdate): integrates
`forkHeight`, moves the fork visual child node. Carry mechanic —
no Jolt constraint, a per-frame pin instead: when the fork starts
rising from the bottom and a `PushableComponent` body's center is in
the attach box (absolute chassis space: |x| <= 0.7,
y in [-0.7, 0.3], z in [1.4, 2.1] — past the chassis front face so
the carried crate never overlaps the collider), the crate's gravity
factor is zeroed and it is teleported with the chassis each frame
(world-space math against the chassis BODY, not the lagging node);
lowering the fork back to 0 releases it. An external teleport
(>5 m from the pin target, e.g. the yard reset) breaks the carry.
physics.h gained `setVelocities(id, linear, angular)`.
- Test accessors: `getForkNode(id)`, `getCarriedEntity(id)`.
Yard reset (user request: leaving an incomplete, disturbed yard resets
the crates):
- New generic engine pieces: a per-frame `frame` event with a `dt`
float param (EditorApp::frameRenderingQueued, game mode Playing —
Lua subscribers throttle themselves), and LuaEntityApi additions
`ecs.get_player_character()` (the controller's live target, not the
stationary controller entity), `ecs.get_entity_position(id)`
(render space) and `ecs.teleport_entity(id, x, y, z [, yawDeg])`
(teleports the rigid body world-space with zeroed velocities, node
fallback; the EditorApp-facing pieces are injected as plain
`std::function` callbacks — `setLuaPlayerCharacterResolver`,
`setLuaEntityPositionGetter`, `setLuaEntityTeleporter`, same pattern
as LuaHudApi — so LuaEntityApi.cpp keeps compiling in the standalone
`*_lua_test` targets that build against the stub `tests/Ogre.h`
without the real OGRE/Jolt headers).
- `sokoban.lua` config gains `crates` (entity names), `anchor` (static
reference entity) and `resetDistance` (default 100). At
registration each crate's offset from the anchor is recorded —
offsets are invariant under render-origin rebases. Every 0.5 s
(throttled on `frame`), while the quest is incomplete and the player
is beyond resetDistance, any crate displaced > 0.5 m from its slot
teleports ALL crates back (yaw 0) and fires `sokoban_reset` +
a HUD message. A crate on the fork is yanked free (the carry
breaks, see above).
- The yard1 controller scene script passes `crates =
{ "crate1", "crate2", "crate3" }, anchor = "yard1", resetDistance =
100`.
### Phase 9 — Docs and cleanup — DONE
- Update `src/features/editScene/AGENTS.md`: demo entry (build/run/
controls/test flags), new systems (Vehicle, VehicleController, Zone,
GameHud), components (Vehicle, Pushable, TargetZone) and the
`ecs.hud.*` Lua API.
- Update root `AGENTS.md`: build target/output for `demoSokoban`.
- `GameFeatures202609.md`: F10 vehicle concept + forklift, F11 sokoban
activity (zones + Lua rules), F12 HUD status display.
- `lua-examples/sokoban_example.lua` showing a minimal yard setup
(zones + `sokoban.new`) and a `quest_completed` subscription.
## Open questions (to resolve as phases start)
1. Yard ground: terrain compliance flattening vs static concrete pad
platform (Phase 5 experiment decides).
RESOLVED (Phase 5): neither — the `--probe-heights` grid showed the
yard rect is near-flat (9.91..10.05, smooth ~0.7% grade), so the
yard sits on the plain terrain with per-entity Ys from the probe.
2. Forklift forks: decorative (push-only, classic sokoban) or
functional lifting (adds grab/constraint mechanics — big extra;
assume decorative for now).
RESOLVED (Phase 8): functional. The fork carriage/tines are a
separate mesh lifted by VehicleSystem (R/F while driving); a crate
in the attach box pins to the carriage when the fork rises and
releases at the bottom. No Jolt constraint — a per-frame world-space
pin with gravity off; external teleports break the carry.
3. Crate/pad count and layout difficulty (start with 3 and one known
solvable layout).
4. Steering: rear-wheel steering like a real forklift, or front-wheel?
(Resolved in Phase 1: rear axle steers 35 deg, front wheels driven.)
5. Exit placement rule when the forklift is boxed in.
6. Whether crate positions persist across scene switches/saves
(default: no — the layout resets).
RESOLVED (Phase 6): no — crate positions are not persisted; the
layout resets to the scene JSON on reload.
7. Lua module loading for `lua-scripts/sokoban.lua`: how scene scripts
pull in a shared module today (resource-group path + `require`
shim vs an explicit loader) — decide when Phase 6 starts.
RESOLVED (Phase 6): plain `require("sokoban")`
`LuaState::luaLibraryLoader` maps dots to slashes, appends `.lua`
and loads from the `LuaScripts` resource group; staging is the
`lua_scripts_package` ALL target.
8. `ecs.hud.*` naming and whether `GameHudSystem` messages should also
be reachable from C++ only (minimal) or fully scriptable (chosen:
fully scriptable, per the Lua-first rule).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,541 @@
{
"actionDatabase": {
"actions": [
{
"behaviorTree": {
"children": [
{
"name": "luaHello",
"params": "message=Welcome to the game!",
"type": "luaTask"
},
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "9.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "lua_hello_action",
"preconditions": {
"bits": 0,
"mask": 0
}
},
{
"behaviorTree": {
"children": [
{
"name": "main/action",
"type": "setAnimationState"
},
{
"name": "action/sitting-ground",
"type": "setAnimationState"
},
{
"name": "dly",
"params": "6.0",
"type": "delay"
},
{
"name": "main/locomotion",
"type": "setAnimationState"
},
{
"name": "locomotion/idle",
"type": "setAnimationState"
},
{
"name": "luaHello",
"params": "message=\"hello, world!\"",
"type": "luaTask"
}
],
"type": "sequence"
},
"cost": 1,
"effects": {
"bits": 0,
"mask": 0
},
"name": "testAction",
"preconditions": {
"bits": 0,
"mask": 0
}
}
],
"bitNames": [
{
"index": 1,
"name": "hungry"
},
{
"index": 2,
"name": "thirsty"
}
],
"goals": []
},
"bookmarks": [],
"entities": [
{
"children": [],
"id": 4294967793,
"name": {
"name": "arrival_a"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 24.0
},
"rotation": {
"w": 0.0,
"x": 0.0,
"y": 1.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 4294967794,
"light": {
"castShadows": false,
"constantAttenuation": 1.0,
"diffuseColor": {
"a": 1.0,
"b": 1.0,
"g": 1.0,
"r": 1.0
},
"direction": {
"x": 0.30000001192092896,
"y": -1.0,
"z": 0.20000000298023224
},
"intensity": 1.0,
"lightType": "directional",
"linearAttenuation": 0.0,
"quadraticAttenuation": 0.0,
"range": 100.0,
"specularColor": {
"a": 1.0,
"b": 0.5,
"g": 0.5,
"r": 0.5
},
"spotlightFalloff": 1.0,
"spotlightInnerAngle": 30.0,
"spotlightOuterAngle": 45.0
},
"name": {
"name": "demo_light"
},
"transform": {
"position": {
"x": 0.0,
"y": 10.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"collider": {
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": -0.10000000149011612,
"z": 0.0
},
"parameters": {
"x": 30.0,
"y": 0.10000000149011612,
"z": 30.0
},
"radius": 0.5,
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"shapeType": "box"
},
"id": 4294967792,
"name": {
"name": "demo_floor"
},
"renderable": {
"meshName": "DemoFloorPlaneInterior",
"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
}
}
},
{
"characterSpawner": {
"despawnDistance": 200.0,
"registryId": 2,
"spawnDistance": 100.0
},
"children": [],
"id": 4294967790,
"name": {
"name": "s1"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.10288965702056885,
"z": -3.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 4294967789,
"name": {
"name": "player"
},
"playerController": {
"actuatorColor": [
0.0,
0.4000000059604645,
1.0
],
"actuatorCooldown": 1.5,
"actuatorDistance": 25.0,
"actuatorLabelFontSize": 12.0,
"cameraMode": 0,
"distantCircleRadius": 8.0,
"fpsBoneName": "Head",
"idleState": "idle",
"locomotionStateMachine": "locomotion",
"mouseSensitivity": 0.20000000298023224,
"nearCircleRadius": 14.0,
"runState": "running",
"swimFastState": "swimming-fast",
"swimIdleState": "swim-idle",
"swimState": "swimming",
"targetCharacterName": "s1",
"tpsDistance": 3.0,
"tpsHeight": 2.0,
"walkState": "walking"
},
"transform": {
"position": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotation": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"scale": {
"x": 1.0,
"y": 1.0,
"z": 1.0
}
}
},
{
"children": [],
"id": 500,
"name": {
"name": "forklift1"
},
"transform": {
"position": {
"x": 0.0,
"y": 1.5,
"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": "forklift.mesh",
"visible": true
},
"collider": {
"shapeType": "box",
"parameters": {
"x": 0.7,
"y": 0.3,
"z": 1.3
},
"radius": 0.5,
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": 0.0,
"z": -0.3
},
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
}
},
"rigidBody": {
"bodyType": "dynamic",
"mass": 1500.0,
"friction": 0.8,
"restitution": 0.0,
"isSensor": false,
"enabled": true
},
"vehicle": {
"maxTorque": 120.0,
"maxPitchRollAngleDeg": 60.0,
"seatOffset": {
"x": 0.0,
"y": -0.07,
"z": -0.85
},
"wheelMeshName": "forklift-wheel.mesh",
"steeringWheelMeshName": "forklift-steering-wheel.mesh",
"steeringWheelOffset": {
"x": 0.0,
"y": 0.75,
"z": -0.3
},
"steeringWheelTiltDeg": 65.0,
"forkMeshName": "forklift-forks.mesh",
"forkOffset": {
"x": 0.0,
"y": 0.0,
"z": 1.17
},
"forkMaxHeight": 1.2,
"forkSpeed": 0.8,
"steeringRatio": 4.0,
"cameraDistance": 5.0,
"cameraHeight": 1.2,
"rearSteer": true,
"wheels": [
{
"position": {
"x": 0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": 0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 0.0,
"driven": true,
"maxHandBrakeTorque": 0.0
},
{
"position": {
"x": 0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
},
{
"position": {
"x": -0.65,
"y": -0.2,
"z": -0.85
},
"radius": 0.3,
"width": 0.2,
"suspensionMinLength": 0.05,
"suspensionMaxLength": 0.3,
"suspensionFrequency": 2.0,
"suspensionDamping": 0.8,
"maxSteerAngleDeg": 35.0,
"driven": false,
"maxHandBrakeTorque": 200.0
}
]
}
},
{
"children": [],
"id": 501,
"name": {
"name": "crate1"
},
"transform": {
"position": {
"x": 3.0,
"y": 0.6,
"z": 6.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": "crate.mesh",
"visible": true
},
"collider": {
"shapeType": "box",
"parameters": {
"x": 0.5,
"y": 0.5,
"z": 0.5
},
"radius": 0.5,
"halfHeight": 1.0,
"meshName": "",
"offset": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"rotationOffset": {
"w": 1.0,
"x": 0.0,
"y": 0.0,
"z": 0.0
}
},
"rigidBody": {
"bodyType": "dynamic",
"mass": 40.0,
"friction": 0.6,
"restitution": 0.0,
"isSensor": false,
"enabled": true
},
"pushable": {
"enabled": true
}
}
],
"version": "1.0"
}
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Generate heightmap.bin for the demo-interior-exterior-dynamics terrain.
NOTE: the exterior scene's terrain (terrainId 4242424300000001) runs in
streaming mode, where base heights come from the terrain's baseNoise
(FastNoiseLite) and this file is NOT sampled it is only staged next to
the binary (heightmaps/4242424300000001/heightmap.bin) so the loader
finds it. This script documents the historical non-streaming layout and
is kept as the generator of record for that staged file.
Legacy description (non-streaming mode):
The exterior scene's terrain entity (terrainId 4242424300000001,
non-streaming, worldSize 500 per page) loads its base heights from
heightmaps/4242424300000001/heightmap.bin via
TerrainSystem::loadSceneHeightmap. File format: uint32 resolution,
then res*res little-endian float32 heights.
Height profile: a flat disc at y=0 around the origin (the playable area
with the building shell and the scene-switch arrival point), sloping
down to -5 in a ring so the water plane (waterSurfaceY -0.5) is visible
as a lake around the centre.
Sampling math (see TerrainSystem::sampleBaseLocked / fillPageHeightData
and the visualToPhysicalX/Z helpers): in non-streaming mode the system
loads a fixed 3x3 page grid, so the heightmap covers the physical world
rect [-worldSize, 2*worldSize] on both axes and texel (ix, iz) holds
the height at physical coordinate (-ws + i * 3*ws / res). Physical
space maps to visual (scene) space by a half-page shift on X and a
per-page Z mirror; this script inverts that mapping so every texel gets
the height of the *visual* position it will be rendered at, then
verifies the round trip by re-sampling the generated grid the same
bilinear way the engine does.
"""
import math
import struct
import sys
RES = 256 # must match "heightmapSize" in the scene JSON
WORLD_SIZE = 500.0 # per-page world size ("worldSize" in the scene JSON)
FLAT_RADIUS = 60.0 # flat-0 disc radius around the visual origin
EDGE_RADIUS = 160.0 # slope reaches full depth here
DEPTH = -5.0 # lake floor height (below waterSurfaceY -0.5)
def profile(r):
"""Visual height at distance r from the origin."""
if r <= FLAT_RADIUS:
return 0.0
if r >= EDGE_RADIUS:
return DEPTH
t = (r - FLAT_RADIUS) / (EDGE_RADIUS - FLAT_RADIUS)
t = t * t * (3.0 - 2.0 * t) # smoothstep
return DEPTH * t
def physical_to_visual(px, pz):
"""Invert TerrainSystem::visualToPhysicalX/Z (terrain origin 0,0,0)."""
vx = px - WORLD_SIZE * 0.5
page = math.floor(pz / WORLD_SIZE)
vz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - pz
return vx, vz
def visual_to_physical(vx, vz):
"""TerrainSystem::visualToPhysicalX/Z with a zero terrain origin."""
px = vx + WORLD_SIZE * 0.5
page = math.floor((vz + WORLD_SIZE * 0.5) / WORLD_SIZE)
pz = 2.0 * page * WORLD_SIZE + WORLD_SIZE * 0.5 - vz
return px, pz
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "heightmap.bin"
span = 3.0 * WORLD_SIZE
origin = -WORLD_SIZE
grid = []
for iz in range(RES):
row = []
pz = origin + iz * span / RES
for ix in range(RES):
px = origin + ix * span / RES
vx, vz = physical_to_visual(px, pz)
row.append(profile(math.hypot(vx, vz)))
grid.append(row)
with open(out_path, "wb") as f:
f.write(struct.pack("<I", RES))
for row in grid:
f.write(struct.pack("<%df" % RES, *row))
# Verify: re-sample the grid exactly like TerrainSystem::sampleBaseLocked
# (bilinear, physical coords) at a few visual positions.
def sample(vx, vz):
px, pz = visual_to_physical(vx, vz)
fx = (px - origin) / span * RES
fz = (pz - origin) / span * RES
x0 = max(0, min(int(math.floor(fx)), RES - 1))
z0 = max(0, min(int(math.floor(fz)), RES - 1))
x1 = max(0, min(x0 + 1, RES - 1))
z1 = max(0, min(z0 + 1, RES - 1))
tx = fx - int(math.floor(fx))
tz = fz - int(math.floor(fz))
h00 = grid[z0][x0]
h10 = grid[z0][x1]
h01 = grid[z1][x0]
h11 = grid[z1][x1]
return ((1 - tx) * (1 - tz) * h00 + tx * (1 - tz) * h10 +
(1 - tx) * tz * h01 + tx * tz * h11)
checks = [
("arrival (0,24)", 0.0, 24.0),
("origin (0,0)", 0.0, 0.0),
("building corner (4,30)", 4.0, 30.0),
("flat edge (55,0)", 55.0, 0.0),
("mid slope (110,0)", 110.0, 0.0),
("lake (200,0)", 200.0, 0.0),
("lake (-200,200)", -200.0, 200.0),
]
ok = True
for label, vx, vz in checks:
h = sample(vx, vz)
print("%-24s visual (%7.1f,%7.1f) -> height %8.3f"
% (label, vx, vz, h))
for vx, vz in [(0.0, 24.0), (0.0, 0.0), (4.0, 30.0), (55.0, 0.0)]:
if abs(sample(vx, vz)) > 1e-4:
print("ERROR: playable area not flat at", vx, vz)
ok = False
for vx, vz in [(200.0, 0.0), (-200.0, 200.0)]:
if sample(vx, vz) > -4.9:
print("ERROR: lake area not deep enough at", vx, vz)
ok = False
if not ok:
sys.exit(1)
print("heightmap written to %s (%dx%d), all checks passed"
% (out_path, RES, RES))
if __name__ == "__main__":
main()
@@ -0,0 +1,131 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20260908
[Window][Entity Hierarchy]
Pos=0,0
Size=300,1043
LastUsed=20260908
[Window][Property Editor]
Pos=1570,0
Size=350,1043
LastUsed=20260908
[Window][Load Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][Save Scene]
Pos=710,321
Size=500,400
LastUsed=20260908
[Window][StartupMenu]
Pos=0,0
Size=1920,1043
[Window][Prefab Browser]
Pos=300,300
Size=250,400
[Window][Create Prefab]
Pos=655,364
Size=305,77
[Window][3D Cursor]
Pos=300,100
Size=280,350
[Window][Mesh Browser]
Pos=533,240
Size=416,406
[Window][Action Database (Singleton)]
Pos=326,33
Size=404,694
[Window][Dialogue Settings]
Pos=300,100
Size=400,500
[Window][DialogueBox]
Pos=0,681
Size=1682,227
[Window][Character Class Database]
Pos=303,109
Size=600,600
[Window][Character Registry]
Pos=476,258
Size=903,429
[Window][Delete Prefab]
Pos=797,467
Size=325,109
[Window][PauseMenu]
Pos=0,0
Size=2490,1536
LastUsed=20260905
[Window][Item Registry]
Pos=60,60
Size=600,500
[Window][Inventory Dialog Config]
Pos=300,100
Size=350,200
[Window][Character Sheet]
Pos=0,0
Size=1920,1043
[Window][Load Game]
Pos=710,321
Size=500,400
[Window][Save Game]
Pos=710,321
Size=500,400
[Window][Animation Tree Registry]
Pos=60,60
Size=900,600
[Window][Confirm Blend Map Resolution Change]
Pos=815,477
Size=290,105
[Window][Confirm Heightmap Resolution Change]
Pos=815,477
Size=290,105
[Window][Road Graph Invalid]
Pos=892,217
Size=136,127
LastUsed=20260731
[Window][Wedge Geometry Debug]
Pos=10,10
Size=420,520
LastUsed=20260814
[Window][Road Graph Valid]
Pos=882,486
Size=156,71
LastUsed=20260821
[Window][Switch Scene]
Pos=710,321
Size=500,400
LastUsed=20260906
[Window][Open Project]
Pos=300,100
Size=639,377
LastUsed=20260908
@@ -0,0 +1,7 @@
#pragma once
/* Generated by CMake from project.json - do not edit.
* Embeds the project parameters into the release binary so it runs its
* project with no --project flag (see GameFeatures202609.md, F8). */
#define EDITSCENE_PROJECT_APP_NAME "@PROJECT_APP_NAME@"
#define EDITSCENE_PROJECT_START_SCENE "@PROJECT_START_SCENE@"
#define EDITSCENE_PROJECT_GAME_MODE @PROJECT_GAME_MODE_VALUE@
@@ -0,0 +1,5 @@
{
"appName": "demo-sokoban",
"startScene": "demo_scene_interior.json",
"gameMode": true
}
@@ -0,0 +1,70 @@
# Stage everything demoSokoban needs into its own directory so it
# runs standalone from
# <build>/src/features/editScene/demos/demo-sokoban.
#
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
#
# 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
# staging, so editSceneEditor must have been built (and run its POST_BUILD
# staging) at least once.
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/project.json" DESTINATION "${DEMO_DIR}")
foreach(f demo_scene_interior.json demo_scene_exterior.json
demo_scene_vehicletest.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()
# Terrain heightmap: staged at configure time by a configure_file COPYONLY
# in CMakeLists.txt (to heightmaps/4242424300000001/), which re-copies it
# automatically when the source file changes. Not symlinked: the fixup
# layer and save path write next to it.
# Character prefab for the spawner's registry entry (registryId 2). It is
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
# not part of the source tree, so copy it from the editScene binary
# directory when present.
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
foreach(f char_2.json)
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
DESTINATION "${DEMO_DIR}/prefabs")
endif()
endforeach()
foreach(dir resources characters lua-scripts)
set(link "${DEMO_DIR}/${dir}")
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
file(REMOVE_RECURSE "${link}")
endif()
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
endforeach()
# Runtime config JSONs loaded at startup relative to the CWD (game mode
# reads startup_menu.json, the registries read the rest the demo needs
# character_registry.json for the spawner's registryId 2). They
# only exist after the editor/game has run once; copy them when present so
# the demo behaves the same as when run from the editor binary directory.
foreach(f startup_menu.json character_registry.json character_class.json
items.json item_state.json inventory_config.json)
if(EXISTS "${EDITSCENE_BIN}/${f}")
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
endif()
endforeach()
# animation_tree.json is demo-owned (source tree, versioned with the demo;
# it carries the "driving" locomotion state used by the forklift seat).
# COPIED, not symlinked: AnimationTreeRegistry auto-saves on runtime tree
# migrations and must not write through into the source tree.
file(COPY "${SRC_DIR}/animation_tree.json" DESTINATION "${DEMO_DIR}")
+55 -4
View File
@@ -34,13 +34,16 @@ menu or startup menu.
### Save File Location
Saves are stored in an OS-dependent user data directory:
Saves are stored in an OS-dependent user data directory. The `<appName>`
path component defaults to `World2` and is replaced by the per-project
`appName` when a project directory is opened (`--project`, embedded
`project.h` in a release binary, or File -> Open Project...):
| Platform | Path |
|----------|------|
| Linux | `~/.local/share/World2/saves/` (or `$XDG_DATA_HOME/World2/saves/`) |
| Windows | `%APPDATA%/World2/saves/` |
| macOS | `~/Library/Application Support/World2/saves/` |
| Linux | `~/.local/share/<appName>/saves/` (or `$XDG_DATA_HOME/<appName>/saves/`) |
| Windows | `%APPDATA%/<appName>/saves/` |
| macOS | `~/Library/Application Support/<appName>/saves/` |
Each save is a single `.json` file named `save_NNN.json`.
@@ -61,6 +64,7 @@ Each save is a single `.json` file named `save_NNN.json`.
"characterRegistry": { ... },
"containerState": { ... },
"itemState": { ... },
"globalState": { ... },
"runtimeEntities": [ ... ],
"characterRuntimeData": { ... },
"luaData": { ... }
@@ -76,6 +80,7 @@ Each save is a single `.json` file named `save_NNN.json`.
| `characterRegistry` | object | Serialized `CharacterRegistry` (stats, skills, needs, levels, XP) |
| `containerState` | object | Serialized `ContainerStateRegistry` (chest/loot contents) |
| `itemState` | object | Serialized `ItemStateRegistry` (world item pickup state) |
| `globalState` | object | Serialized `GlobalStateStore` (typed global variables, F9). Missing in old saves: the store falls back to system defaults |
| `runtimeEntities` | array | Runtime-spawned entities (dropped items, etc.) |
| `characterRuntimeData` | object | Runtime component overrides per character (inventory, GOAP state, animation state) |
| `luaData` | object | Data collected from Lua save callbacks |
@@ -258,6 +263,7 @@ EditorApp::saveGame(slotPath, slotName)
├── Serialize CharacterRegistry
├── Serialize ContainerStateRegistry
├── Serialize ItemStateRegistry
├── Serialize GlobalStateStore (globalState section)
├── Serialize runtime entities (dropped items, etc.)
├── Serialize character runtime component overrides
├── Collect Lua save callback data
@@ -290,6 +296,7 @@ EditorApp::loadGame(slotPath)
├── Restore CharacterRegistry
├── Restore ContainerStateRegistry
├── Restore ItemStateRegistry
├── Restore GlobalStateStore (defaults when the save has no globalState)
├── Destroy all existing character entities
├── Spawn persistent characters from registry
├── Restore character runtime component overrides
@@ -318,6 +325,50 @@ Persists container slot overrides keyed by `containerId`. Auto-saves to
Persists world item state (picked up / disabled) keyed by `instanceId`.
Auto-saves to `item_state.json`.
### GlobalStateStore (F9)
Generic typed key-value storage for gameplay systems: string name +
`bool` / `int64` / `double` / `string` value, accessible from C++
(`GlobalStateStore::getInstance()`) and Lua (`ecs.global.*`). Persisted in
the save file's `globalState` section; auto-saves to `global_state.json`
in game mode (the cross-session cache).
Key semantics:
- Systems declare defaults with `declareDefault()`; reading an unset
variable returns the declared default (or the caller's fallback).
Defaults are **not** serialized - only explicitly `set()` values are.
- Keys are dot-namespaced; each system owns a prefix registered in
`AGENTS.md`. Door state (F6): `door.<doorId>.locked`,
`door.<doorId>.isOpen`.
- **Game mode:** startup loads `global_state.json`; `startNewGame()` resets
to defaults; `loadGame()` restores the save's `globalState` (old saves
without it load as defaults).
- **Editor mode:** the store is `clearToDefaults()` at startup and on scene
(re)load (`EditorUISystem::loadScene`, `EditorApp::openProject`), the
cache file is never loaded and auto-save is disabled, so a previous game
session never leaks into the edited scene.
Lua API:
```lua
ecs.global.set(name, value) -- boolean/integer/float/string; nil removes
ecs.global.get_bool(name [, default]) -- typed reads
ecs.global.get_int(name [, default])
ecs.global.get_float(name [, default])
ecs.global.get_string(name [, default])
ecs.global.has(name)
ecs.global.remove(name)
```
See `lua-examples/global_state_example.lua`.
Door persistence (F6) builds on this: `door.<doorId>.locked` /
`door.<doorId>.isOpen`, managed by `DoorSystem` (see AGENTS.md "Persistent
door state (F6)"), with the Lua wrappers `ecs.door.is_locked/is_open/
lock/unlock` (`lua/LuaDoorApi.cpp`, example
`lua-examples/door_lock_example.lua`).
---
## Runtime Entities

Some files were not shown because too many files have changed in this diff Show More