Compare commits
17 Commits
e82190ec6d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d300a16333 | |||
| dd1835b4a3 | |||
| 7a388d2a6f | |||
| 7aa7682e97 | |||
| 1159bb5a47 | |||
| 867a15d6aa | |||
| bb65c589f4 | |||
| 1572066df3 | |||
| dd7d8f6b39 | |||
| b18c213ee8 | |||
| 58deefa0a0 | |||
| 4670aa7af9 | |||
| b062d8331d | |||
| 4562db481a | |||
| 5ea7a6bde8 | |||
| c38ac8eff3 | |||
| 2d60b20cca |
+1267
-27
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 A–B–C 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.1–M5.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 (A–B → A–C–B).
|
||||
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 (A–B → A–C–B)
|
||||
|
||||
**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 A–C and
|
||||
C–B. 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` (`:2986–3038`).
|
||||
|
||||
### 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 A–C with `edgeT' = edgeT / actualT`;
|
||||
otherwise it moves to C–B 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`,
|
||||
`:1460–1518`) 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
|
||||
A–B–C 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 A–B–C" + 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 A–B–C (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
|
||||
A–C; 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 A–B–C"), 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 A–B–C 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.1–M5.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] W1–W4, W6–W8 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.1–3.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.
|
||||
|
||||
@@ -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.1–3.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 30–270° 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.1–M5.12).
|
||||
**Overall M5 status**: ✅ ALL 18 sub-items complete (M5.1–M5.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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
});
|
||||
}
|
||||
@@ -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,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"
|
||||
)
|
||||
+191
@@ -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;
|
||||
}
|
||||
+771
@@ -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"
|
||||
}
|
||||
+1522
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Binary file not shown.
@@ -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()
|
||||
Binary file not shown.
@@ -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}")
|
||||
@@ -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
Reference in New Issue
Block a user