Added more demos

This commit is contained in:
2026-09-06 03:19:34 +03:00
parent 58deefa0a0
commit b18c213ee8
40 changed files with 5401 additions and 108 deletions
+125 -1
View File
@@ -74,6 +74,46 @@ cd demos/demo-character-controller
# Controls: mouse = look, W/A/S/D = move, Shift = run,
# Escape = pause menu (frees the cursor).
# Demo: game-mode actuator-driven scene switching
# (demos/demo-scene-switching, target demoSceneSwitching). Two scenes
# (demo_scene_a.json green floor / demo_scene_b.json blue floor, meshes
# created programmatically in demo_main.cpp) each hold the same flat
# floor + collider + spawner + player controller setup as the
# character-controller demo plus an actuator pillar ("portal_a" /
# "portal_b") standing at the edge of the walking surface. The
# actuator's action (defined in the scene's top-level actionDatabase
# block) runs a behavior tree whose "switchScene" node queues
# EditorApp::switchScene() to the other scene with a
# "@arrival_a"/"@arrival_b" teleport target; each arrival marker sits a
# few units inward of the destination portal and faces the floor centre
# (yaw 180), so the player arrives with the portal pillar at their back,
# looking across the walking surface. A black "Loading..." cover hides
# the characters' initial T-pose for ~45 frames after each switch (see
# "Scene Switching" below), so travel back and forth is endless.
cd demos/demo-scene-switching
./demoSceneSwitching
# ...or headless smoke run (one frame, then exit):
./demoSceneSwitching --headless --exit-after-first-frame
# ...or headless end-to-end check of the A -> B -> A round trip
# (executes both portal actions' behavior trees and verifies the
# "@arrival_*" teleports; exits non-zero on failure):
./demoSceneSwitching --headless --test-switch
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
# Escape = pause menu (frees the cursor).
# Demo: same scene-switching setup, but scene A additionally holds an
# "interrior" entity with a CellGridComponent (room with floor, ceiling,
# interior walls and an exit door) carrying its own ProceduralMaterial +
# ProceduralTexture; the grid's texture rectangle names reference the
# texture's named rects ("floor" / "ceiling") without any
# Lot/District/Town parent.
cd demos/demo-scene-switching-extra
./demoSceneSwitchingExtra
# ...or headless smoke run (one frame, then exit):
./demoSceneSwitchingExtra --headless --exit-after-first-frame
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
# Escape = pause menu (frees the cursor).
# Road wedge/segment self-intersection regression test (no scene needed,
# also registered as CTest roadGeometryOverlapTest)
@@ -153,6 +193,10 @@ system that should drive player locomotion animation.
- When `targetCharacterName` resolves to a `CharacterSpawnerComponent`, it locks
the spawner and forces a spawn; the controlled entity is the spawned instance,
not the spawner.
- On (re-)initialization the camera yaw is derived from the character's facing
(`orientation * UNIT_Z`) so the camera starts behind the character whatever
its rotation; an identity-rotation character keeps the old fixed yaw-180
behaviour.
### CharacterSpawnerSystem
@@ -371,7 +415,10 @@ to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags
Handles player interaction prompts and executes smart-object/item actions. It
uses `EditorApp::getPlayerCharacterEntity()` to know which character performs the
action. Actions run through `BehaviorTreeSystem` and lock player input while
running (`PlayerControllerComponent::inputLocked`).
running (`PlayerControllerComponent::inputLocked`). Targeting/prompting is
screen-space via ImGui, so `update()` and `render()` bail out early when there
is no ImGui context (headless mode); cooldown timers and the executing-action
block still run.
### SceneScriptComponent & SceneScriptSystem
@@ -442,6 +489,65 @@ Game-mode saves are JSON files in the OS user-data directory (see
On load, if the saved character is owned by a spawner, the controller target is
restored to the spawner name so the character respawns correctly.
### Scene Switching
`EditorApp::switchScene(path, opts)` (queued; executed at the top of the next
`frameRenderingQueued`) switches the running scene. It is exposed in the editor
menu (**File -> Switch Scene...**) and in Lua:
```lua
ecs.switch_scene("scenes/level2.json")
ecs.switch_scene("scenes/level2.json", { position = { x = 10, y = 5, z = 3 },
yaw = 90 }) -- degrees around +Y
ecs.switch_scene("scenes/level2.json", { target = "SpawnPoint" })
ecs.switch_scene(path, { position = ..., rotation = { w=1, x=0, y=0, z=0 } })
```
- **Editor mode**: the current scene is completely destroyed
(`destroySceneEntities()` removes `EditorMarkerComponent` AND
`RuntimeMarkerComponent` entities, resets controller/spawner bookkeeping) and
the new scene is loaded from scratch; the editor camera resets to its default
pose and the render origin rebases to (0,0,0). Unsaved changes are discarded.
- **Game mode**: the target scene JSON is pre-scanned for `camera` and
`playerController` components.
- New scene has its own controller and/or camera -> the old scene (incl. the
old player controller/character) is fully cleaned and the new ones take
over, like a fresh `startNewGame` (except `m_playTime` keeps accumulating).
A scene camera only takes over the viewport when NO player controller is
active after the switch.
- New scene has neither -> the current `player` controller entity and the
live player character are carried over (markers stripped, spawner released
via `CharacterSpawnerSystem::releaseSpawned`, controller retargeted to the
character itself), so inventory/stats/BT state survive.
- Teleport: `SceneSwitchOptions` takes a world-space position+rotation or a
`targetEntityName` resolved against the NEW scene. The destination Y is
clamped to `TerrainSystem::getHeightAt` (+0.05) and a grounding watchdog
re-clamps for ~120 frames until the character reports a floor, covering the
window where streaming terrain colliders are not built yet.
- Persistent non-scene storages (CharacterRegistry, AnimationTreeRegistry,
ItemRegistry, item/container state registries, Lua state) are untouched.
Additionally, despawning a character (spawner or registry driven) now syncs
its live position/rotation back to its `CharacterRegistry` record so a later
respawn is position-faithful; inventory/BT state remains entity-local and is
only captured by the save-game path.
- **Loading cover**: in game mode a fullscreen black "Loading..." overlay
(`EditorApp::renderSceneSwitchCover`, drawn from the ImGui render listener,
headless-safe) hides the scene for `SCENE_SWITCH_COVER_FRAMES` (45) frames
after each switch, covering the characters' initial T-pose while animations
warm up. It is armed at the end of `performSceneSwitch` and counted down in
`frameRenderingQueued` after `processPendingSceneSwitch()`.
The `switchScene` behavior tree node (name=scene path) queues the same switch
from AI trees (SmartObject/GOAP) and actuator/player-action trees; it fires
once per activation and is safe because the switch is deferred to the next
frame. `params` selects the optional player teleport: `@EntityName` (entity
with a Transform in the new scene), `x,y,z` or `x,y,z,yaw` (world-space,
yaw in degrees). From Lua use
`ecs.behavior_tree.create_scene_switch_node(path [, "EntityName" | x, y, z [, yawDeg]])`
or the generic `create_node("switchScene", path, params)`. Headless coverage
lives in `tests/scene_switch_test.cpp` (target `scene_switch_test`, needs no
OGRE init).
## Adding a New Component
1. Define the component struct in `components/MyComponent.hpp`.
@@ -534,6 +640,24 @@ Make sure documentation, tests and examples are always in sync.
page unload and via `RoadSystem::flushRegionStore()` on scene save;
dirty fixup chunks flush on LRU eviction. Expect edits to survive only
through those paths.
- `CellGridSystem` resolves the build material from the grid entity's own
`ProceduralMaterialComponent` first, then the parent hierarchy
(Lot -> District -> Town). The Cell Grid editor's "Texture Rectangles"
panel offers the texture's named rects per part and flags parts whose
rect name is missing (default UV mapping) or not found in the texture.
- `CellGridSystem` cleans up meshes of destroyed grid/plaza/lot entities
via a per-frame sweep (`cleanupDestroyedEntities()`), not an OnRemove
observer — observer registration shifts entity IDs, which breaks
SceneSerializer's hardcoded ID resolution (see
`CellGridSystem::initialize()`). Without it, frame StaticGeometry and
physics colliders survive a scene switch.
- Procedural textures/materials survive scene switches as Ogre resources:
destroying a scene entity does not remove its generated texture/material
from the Ogre managers. `ProceduralTextureSystem::generateTexture()`
therefore removes any stale texture resource of the same name before
`loadImage()` (which fails on duplicates), and
`ProceduralMaterialSystem::createMaterial()` reuses an existing material
object of the same name.
- The `ecs.terrain` Lua brush functions (`sculpt`, `paint`, `paintAux`)
take absolute visual WORLD coordinates (render-origin independent) and
convert internally; only `terrain.sampleAux` takes PHYSICAL heightmap
+60
View File
@@ -200,6 +200,7 @@ set(EDITSCENE_SOURCES
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
lua/LuaTerrainApi.cpp
lua/LuaSceneSwitchApi.cpp
systems/TerrainTests.cpp
)
@@ -389,6 +390,7 @@ set(EDITSCENE_HEADERS
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
lua/LuaTerrainApi.hpp
lua/LuaSceneSwitchApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
@@ -651,6 +653,62 @@ 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
)
# ---------------------------------------------------------------------------
# Road Geometry Library — standalone wedge/segment generation (M5)
# ---------------------------------------------------------------------------
@@ -891,3 +949,5 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
# 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)
+489 -77
View File
@@ -1,5 +1,8 @@
#include <iostream>
#include <filesystem>
#include <fstream>
#include <functional>
#include <unordered_set>
#include "EditorApp.hpp"
#include "GameMode.hpp"
#include <OgreMaterialManager.h>
@@ -48,6 +51,7 @@
#include "components/CharacterClassDatabase.hpp"
#include "lua/LuaCharacterApi.hpp"
#include "lua/LuaSaveLoadApi.hpp"
#include "lua/LuaSceneSwitchApi.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/SceneSerializer.hpp"
#include "systems/SaveLoadSystem.hpp"
@@ -176,6 +180,13 @@ void ImGuiRenderListener::preViewportUpdate(
actuatorSys->render();
}
/* Scene-switch loading cover: fullscreen black overlay for the
* first frames of the new scene (hides T-posing characters and
* half-built content). Foreground draw list so it covers all
* other game-mode UI. */
if (m_editorApp)
m_editorApp->renderSceneSwitchCover();
// Render startup menu in game mode (inside ImGui frame scope)
if (m_editorApp &&
m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
@@ -502,6 +513,7 @@ void EditorApp::setup()
m_uiSystem->setEditorUIEnabled(
m_gameMode == GameMode::Editor);
m_uiSystem->setEditorCamera(m_camera.get());
m_uiSystem->setEditorApp(this);
}
// Setup physics system
@@ -633,6 +645,8 @@ void EditorApp::setup()
m_behaviorTreeSystem = std::make_unique<BehaviorTreeSystem>(
m_world, m_sceneMgr, m_animationTreeSystem.get(),
m_characterSystem.get());
/* Wire up EditorApp for the "switchScene" BT node. */
m_behaviorTreeSystem->setEditorApp(this);
// Setup NavMesh system
m_navMeshSystem =
@@ -818,6 +832,8 @@ void EditorApp::setup()
editScene::registerLuaDialogueApi(L);
editScene::registerLuaItemApi(L);
editScene::registerLuaTerrainApi(L);
editScene::registerLuaSceneSwitchApi(L);
editScene::setSceneSwitchEditorApp(this);
// Scene scripts execute in this shared Lua state.
SceneScriptSystem::init(L);
@@ -980,83 +996,7 @@ void EditorApp::startNewGame(const Ogre::String &scenePath)
Ogre::LogManager::getSingleton().logMessage(
"Game started: loaded scene " + scenePath);
// Set up player character: resolve the actual target character
// (spawned instance if the controller targets a spawner) so the
// character sheet and inventory are on the same entity that
// ActuatorSystem uses.
flecs::entity playerCharacter = getPlayerCharacterEntity();
if (playerCharacter.is_valid() && playerCharacter.is_alive()) {
if (!playerCharacter.has<CharacterIdentityComponent>()) {
// Create a character record for the player
uint64_t charId =
CharacterRegistry::getSingleton()
.createCharacter("Player",
"One", "",
false);
// Link the entity to the registry record
playerCharacter.set<CharacterIdentityComponent>(
CharacterIdentityComponent{ charId });
// Set default class and initial stats from
// the database
auto *rec = CharacterRegistry::getSingleton()
.findCharacter(charId);
if (rec) {
auto &db = CharacterClassDatabase::
getSingleton();
// Use "pclass" for the player if it
// exists, otherwise fall back to the
// first available class
const auto &classNames =
db.getClassNames();
rec->className = "pclass";
bool found = false;
for (const auto &cn : classNames) {
if (cn == "pclass") {
found = true;
break;
}
}
if (!found && !classNames.empty()) {
rec->className = classNames[0];
}
const auto *cls =
db.findClass(rec->className);
if (cls) {
// Initialize stats from
// baseStats
for (const auto &pair :
cls->baseStats) {
rec->stats[pair.first] =
pair.second;
}
// Initialize needs
auto needNames =
db.getNeedNames();
for (const auto &n :
needNames) {
const auto *def =
db.findNeed(n);
if (def)
rec->needs[n] =
0;
}
}
}
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: created character record for player entity " +
Ogre::StringConverter::toString(
(int)playerCharacter.id()));
}
// Ensure player has an inventory for the character sheet
if (!playerCharacter.has<InventoryComponent>()) {
playerCharacter.set<InventoryComponent>(
InventoryComponent());
}
} else {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: no player entity found in scene");
}
setupPlayerCharacter();
// Send "scene_ready" event after scene is loaded and
// entities/components are populated and ready to run.
@@ -1067,6 +1007,433 @@ void EditorApp::startNewGame(const Ogre::String &scenePath)
}
}
void EditorApp::setupPlayerCharacter()
{
// Set up player character: resolve the actual target character
// (spawned instance if the controller targets a spawner) so the
// character sheet and inventory are on the same entity that
// ActuatorSystem uses.
flecs::entity playerCharacter = getPlayerCharacterEntity();
if (playerCharacter.is_valid() && playerCharacter.is_alive()) {
if (!playerCharacter.has<CharacterIdentityComponent>()) {
// Create a character record for the player
uint64_t charId =
CharacterRegistry::getSingleton()
.createCharacter("Player", "One", "",
false);
// Link the entity to the registry record
playerCharacter.set<CharacterIdentityComponent>(
CharacterIdentityComponent{ charId });
// Set default class and initial stats from
// the database
auto *rec = CharacterRegistry::getSingleton()
.findCharacter(charId);
if (rec) {
auto &db =
CharacterClassDatabase::getSingleton();
// Use "pclass" for the player if it
// exists, otherwise fall back to the
// first available class
const auto &classNames = db.getClassNames();
rec->className = "pclass";
bool found = false;
for (const auto &cn : classNames) {
if (cn == "pclass") {
found = true;
break;
}
}
if (!found && !classNames.empty()) {
rec->className = classNames[0];
}
const auto *cls = db.findClass(rec->className);
if (cls) {
// Initialize stats from
// baseStats
for (const auto &pair : cls->baseStats) {
rec->stats[pair.first] =
pair.second;
}
// Initialize needs
auto needNames = db.getNeedNames();
for (const auto &n : needNames) {
const auto *def =
db.findNeed(n);
if (def)
rec->needs[n] = 0;
}
}
}
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: created character record for player entity " +
Ogre::StringConverter::toString(
(int)playerCharacter.id()));
}
// Ensure player has an inventory for the character sheet
if (!playerCharacter.has<InventoryComponent>()) {
playerCharacter.set<InventoryComponent>(
InventoryComponent());
}
} else {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: no player entity found in scene");
}
}
bool EditorApp::switchScene(const Ogre::String &scenePath,
const SceneSwitchOptions &opts)
{
if (scenePath.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp::switchScene: empty scene path");
return false;
}
if (!std::filesystem::exists(scenePath)) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp::switchScene: file not found: " + scenePath);
return false;
}
/* Queue the request; the actual teardown + load runs at the top
* of the next frame so callers (Lua scripts, ImGui menus) never
* mutate the world mid-frame. A new request replaces a queued
* one. */
m_pendingSceneSwitchPath = scenePath;
m_pendingSceneSwitchOptions = opts;
m_sceneSwitchPending = true;
return true;
}
void EditorApp::processPendingSceneSwitch()
{
if (!m_sceneSwitchPending)
return;
m_sceneSwitchPending = false;
performSceneSwitch(m_pendingSceneSwitchPath,
m_pendingSceneSwitchOptions);
}
void EditorApp::renderSceneSwitchCover()
{
if (m_gameMode != GameMode::Game || m_sceneSwitchCoverFrames <= 0)
return;
/* Headless runs have no ImGui context. */
if (!ImGui::GetCurrentContext())
return;
ImGuiViewport *vp = ImGui::GetMainViewport();
ImDrawList *drawList = ImGui::GetForegroundDrawList();
if (!vp || !drawList)
return;
drawList->AddRectFilled(vp->Pos,
ImVec2(vp->Pos.x + vp->Size.x,
vp->Pos.y + vp->Size.y),
IM_COL32(0, 0, 0, 255));
const char *text = "Loading...";
ImVec2 textSize = ImGui::CalcTextSize(text);
ImVec2 textPos(vp->Pos.x + (vp->Size.x - textSize.x) * 0.5f,
vp->Pos.y + (vp->Size.y - textSize.y) * 0.5f);
drawList->AddText(textPos, IM_COL32(255, 255, 255, 255), text);
}
void EditorApp::destroySceneEntities()
{
/* Drop all controller state first so spawner locks, pivot nodes
* and PlayerControlledComponent tags don't leak. */
if (m_playerControllerSystem)
m_playerControllerSystem->resetControllers();
/* Destroy both persistent scene entities (EditorMarkerComponent)
* and runtime-spawned ones (RuntimeMarkerComponent: spawned
* characters, dropped items). */
std::unordered_set<flecs::entity_t> seen;
std::vector<flecs::entity> toDelete;
auto collect = [&](flecs::entity e) {
if (e.is_alive() && seen.insert(e.id()).second)
toDelete.push_back(e);
};
m_world.query<EditorMarkerComponent>().each(
[&](flecs::entity e, EditorMarkerComponent) { collect(e); });
m_world.query<RuntimeMarkerComponent>().each(
[&](flecs::entity e, RuntimeMarkerComponent) { collect(e); });
for (auto &e : toDelete) {
if (!e.is_alive())
continue;
if (m_uiSystem)
m_uiSystem->deleteEntity(e);
else
e.destruct();
}
/* Spawner bookkeeping is keyed by flecs entity id and ids are
* recycled; clear it so stale entries can't alias entities of the
* new scene (its entities were already destroyed above). */
if (m_characterSpawnerSystem)
m_characterSpawnerSystem->clearSpawnerState();
if (m_uiSystem)
m_uiSystem->clearEntityCache();
/* Hand the viewport back to the editor camera in case a scene
* camera owned it. */
if (m_cameraSystem)
m_cameraSystem->restoreOriginalCamera();
}
/* Recursively scan a scene JSON entity (and its children) for camera
* and playerController components. */
static void scanSceneEntityJson(const nlohmann::json &entityJson,
bool &hasCamera, bool &hasController)
{
if (entityJson.contains("camera"))
hasCamera = true;
if (entityJson.contains("playerController"))
hasController = true;
if (entityJson.contains("children") &&
entityJson["children"].is_array()) {
for (const auto &child : entityJson["children"])
scanSceneEntityJson(child, hasCamera, hasController);
}
}
bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts)
{
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: switching scene to " + scenePath);
if (m_gameMode == GameMode::Editor) {
/* Editor mode: behave as if the editor was started anew
* and the scene loaded (unsaved changes are discarded). */
destroySceneEntities();
if (m_renderOriginSystem)
m_renderOriginSystem->rebase(JPH::DVec3(0, 0, 0));
if (m_camera)
m_camera->resetPose();
if (m_uiSystem) {
/* loadScene wires the serializer, navigation
* bookmarks and scene script events. */
m_uiSystem->loadScene(scenePath);
} else {
SceneSerializer serializer(m_world, m_sceneMgr);
serializer.loadFromFile(scenePath, nullptr);
}
m_currentBaseScene = scenePath;
return true;
}
/* --- Game mode --- */
/* Pre-scan the target scene JSON for camera/playerController
* components to decide the takeover/carry-over behavior. */
bool newHasCamera = false;
bool newHasController = false;
try {
std::ifstream in(scenePath);
nlohmann::json scene = nlohmann::json::parse(in);
if (scene.contains("entities") && scene["entities"].is_array())
for (const auto &ent : scene["entities"])
scanSceneEntityJson(ent, newHasCamera,
newHasController);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp::switchScene: failed to parse " + scenePath +
": " + e.what());
return false;
}
/* Carry the current player controller + character over only when
* the new scene has no controller of its own. */
flecs::entity controller = flecs::entity::null();
m_world.query<EntityNameComponent>().each(
[&](flecs::entity e, EntityNameComponent &name) {
if (name.name == "player" &&
e.has<PlayerControllerComponent>())
controller = e;
});
flecs::entity carriedCharacter = flecs::entity::null();
const bool preservePlayer = !newHasController && controller.is_alive();
if (preservePlayer) {
carriedCharacter = getPlayerCharacterEntity();
/* Keep the controller and its character out of the scene
* cleanup by stripping their markers. */
if (controller.has<EditorMarkerComponent>())
controller.remove<EditorMarkerComponent>();
if (controller.has<RuntimeMarkerComponent>())
controller.remove<RuntimeMarkerComponent>();
if (carriedCharacter.is_alive()) {
if (carriedCharacter.has<EditorMarkerComponent>())
carriedCharacter
.remove<EditorMarkerComponent>();
if (carriedCharacter.has<RuntimeMarkerComponent>())
carriedCharacter
.remove<RuntimeMarkerComponent>();
/* If the character is spawner-owned, detach it from
* the spawner (which dies with the old scene) and
* point the controller at the character itself. */
if (m_characterSpawnerSystem) {
flecs::entity spawner =
m_characterSpawnerSystem
->getSpawnerForCharacter(
carriedCharacter);
if (spawner.is_alive()) {
m_characterSpawnerSystem
->releaseSpawned(spawner);
if (carriedCharacter.has<
EntityNameComponent>()) {
controller.get_mut<
PlayerControllerComponent>()
.targetCharacterName =
carriedCharacter.get<
EntityNameComponent>()
.name;
}
}
}
}
}
destroySceneEntities();
SceneSerializer serializer(m_world, m_sceneMgr);
if (!serializer.loadFromFile(scenePath, m_uiSystem.get())) {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp: failed to load scene: " +
serializer.getLastError());
return false;
}
PrefabSystem prefabSys(m_world, m_sceneMgr);
prefabSys.resolveInstances();
SceneScriptSystem::loadPendingScripts(m_world);
SceneScriptSystem::sendSceneLoaded(scenePath);
m_currentBaseScene = scenePath;
/* m_playTime keeps accumulating across the switch (same session). */
setGamePlayState(GamePlayState::Playing);
setupPlayerCharacter();
/* Camera handoff: a scene camera takes over the viewport only
* when no player controller is active after the switch (the
* PlayerControllerSystem drives the editor camera otherwise).
* Cameras are built lazily by EditorCameraSystem::update, so
* process them now. */
bool controllerActive = false;
m_world.query<PlayerControllerComponent>().each(
[&](flecs::entity, PlayerControllerComponent &) {
controllerActive = true;
});
if (newHasCamera && !controllerActive && m_cameraSystem) {
m_cameraSystem->processAllCameras();
flecs::entity camEntity = flecs::entity::null();
m_world.query<CameraComponent>().each(
[&](flecs::entity e, CameraComponent &cc) {
if (!camEntity.is_alive() && cc.camera)
camEntity = e;
});
if (camEntity.is_alive())
m_cameraSystem->setViewportCamera(
camEntity.get<CameraComponent>().camera);
}
/* Teleport the player character when requested. */
flecs::entity playerCharacter = getPlayerCharacterEntity();
if (playerCharacter.is_alive() &&
playerCharacter.has<TransformComponent>()) {
bool haveDestination = false;
Ogre::Vector3 destRender = Ogre::Vector3::ZERO;
Ogre::Quaternion destRot = Ogre::Quaternion::IDENTITY;
if (!opts.targetEntityName.empty()) {
flecs::entity target = flecs::entity::null();
m_world.query<EntityNameComponent, TransformComponent>()
.each([&](flecs::entity e,
EntityNameComponent &en,
TransformComponent &) {
if (en.name == opts.targetEntityName)
target = e;
});
if (target.is_alive()) {
auto &tt = target.get<TransformComponent>();
destRender = tt.node ?
tt.node->_getDerivedPosition() :
tt.position;
destRot = tt.node ?
tt.node->_getDerivedOrientation() :
tt.rotation;
haveDestination = true;
} else {
Ogre::LogManager::getSingleton().logMessage(
"EditorApp::switchScene: target entity '" +
opts.targetEntityName + "' not found");
}
} else if (opts.hasPosition) {
/* API positions are absolute world space. */
destRender = m_renderOriginSystem ?
m_renderOriginSystem->worldToRender(
opts.posX, opts.posY,
opts.posZ) :
Ogre::Vector3((float)opts.posX,
(float)opts.posY,
(float)opts.posZ);
destRot = opts.hasRotation ? opts.rotation :
Ogre::Quaternion::IDENTITY;
haveDestination = true;
}
if (haveDestination) {
/* Never spawn below the terrain surface. */
TerrainSystem *ts = TerrainSystem::getInstance();
if (ts && ts->isActive()) {
float ground = ts->getHeightAt(destRender);
if (destRender.y < ground + 0.05f)
destRender.y = ground + 0.05f;
}
auto &t =
playerCharacter.get_mut<TransformComponent>();
t.position = destRender;
t.rotation = destRot;
if (m_renderOriginSystem) {
JPH::DVec3 world =
m_renderOriginSystem->renderToWorld(
destRender);
t.worldX = world.GetX();
t.worldY = world.GetY();
t.worldZ = world.GetZ();
t.hasWorldPosition = true;
}
t.applyToNode();
if (playerCharacter.has<CharacterComponent>()) {
playerCharacter.get_mut<CharacterComponent>()
.linearVelocity = Ogre::Vector3::ZERO;
}
}
/* Arm the grounding watchdog whenever a player character
* exists after the switch: the terrain physics colliders
* (streaming pages) may not exist for a few frames. */
m_groundingEntity = playerCharacter.id();
m_groundingFramesLeft = 120;
}
/* Cover the first frames of the new scene: freshly spawned
* characters are still in T-pose until the animation system
* applies a state, and streaming content may need a few frames
* to build. */
m_sceneSwitchCoverFrames = SCENE_SWITCH_COVER_FRAMES;
EventBus::getInstance().send("scene_ready");
return true;
}
void EditorApp::saveGame(const std::string &slotPath,
const std::string &slotName)
{
@@ -1645,6 +2012,51 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
{
bool paused = (m_gamePlayState == GamePlayState::Paused);
/* A queued scene switch runs before anything else so systems
* below already see the new scene. */
processPendingSceneSwitch();
/* Count down the scene-switch loading cover. */
if (m_sceneSwitchCoverFrames > 0)
m_sceneSwitchCoverFrames--;
/* Grounding watchdog: after a game-mode scene switch the terrain
* physics colliders (streaming pages) may not be built yet; keep
* the player character at or above the terrain surface until it
* reports a floor or the watchdog times out. */
if (m_groundingFramesLeft > 0) {
m_groundingFramesLeft--;
flecs::entity e = m_world.entity(m_groundingEntity);
if (!e.is_alive() || !e.has<TransformComponent>()) {
m_groundingFramesLeft = 0;
} else {
bool grounded = e.has<CharacterComponent>() &&
e.get<CharacterComponent>().hasFloor;
TerrainSystem *ts = TerrainSystem::getInstance();
if (grounded || !ts || !ts->isActive()) {
m_groundingFramesLeft = 0;
} else {
auto &t = e.get_mut<TransformComponent>();
Ogre::Vector3 renderPos =
t.node ? t.node->_getDerivedPosition() :
t.position;
float ground = ts->getHeightAt(renderPos);
if (renderPos.y < ground + 0.02f) {
renderPos.y = ground + 0.05f;
t.position = renderPos;
t.applyToNode();
if (e.has<CharacterComponent>()) {
auto &ch = e.get_mut<
CharacterComponent>();
if (ch.linearVelocity.y < 0.0f)
ch.linearVelocity.y =
0.0f;
}
}
}
}
}
/* Render origin rebase runs first so every system below sees the
* post-rebase render space. */
if (m_renderOriginSystem)
+84
View File
@@ -124,6 +124,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
*/
@@ -193,6 +212,39 @@ 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);
@@ -260,6 +312,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();
@@ -344,6 +401,33 @@ private:
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;
void processPendingSceneSwitch();
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
void destroySceneEntities();
void setupPlayerCharacter();
// Lua scripting
editScene::LuaState m_lua;
@@ -267,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();
}
@@ -107,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);
@@ -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";
}
};
@@ -0,0 +1,86 @@
# ---------------------------------------------------------------------------
# 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, both demo scenes, 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-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}/")
add_executable(demoSceneSwitchingExtra
demo_main.cpp
${DEMO_SOURCES}
)
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
${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,469 @@
#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-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), a PlayerControllerComponent targeting it and an
* actuator ("portal_a" / "portal_b", marked by a colored pillar).
* Scene A additionally holds an "interrior" entity with a
* CellGridComponent (a room with floor, ceiling, interior walls and an
* exit door) carrying its own ProceduralMaterial + ProceduralTexture;
* the grid's texture rectangle names reference the texture's named rects
* ("floor" / "ceiling") without any Lot/District/Town parent.
*
* 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, both scene JSONs, resources/, characters/, lua-scripts/,
* prefabs/ and the runtime config JSONs are staged next to it by the
* build).
*
* 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;
}
File diff suppressed because it is too large Load Diff
@@ -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,50 @@
# 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; 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_scene_a.json" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/demo_scene_b.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,96 @@
# ---------------------------------------------------------------------------
# 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, both demo scenes, 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-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,464 @@
#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, both scene JSONs, resources/, characters/, lua-scripts/,
* prefabs/ and the runtime config JSONs are staged next to it by the
* build).
*
* 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,50 @@
# 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; 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_scene_a.json" DESTINATION "${DEMO_DIR}")
file(COPY "${SRC_DIR}/demo_scene_b.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()
@@ -203,6 +203,7 @@ end)
-- Movement: teleportToChild
-- Physics: disablePhysics, enablePhysics
-- Events: sendEvent
-- Scenes: switchScene
-- Inventory: hasItem, hasItemByName, countItem, pickupItem, dropItem,
-- useItem, addItemToInventory
-- Debug: debugPrint
@@ -512,3 +513,49 @@ end
-- The params table is passed as the second argument to your registered
-- Lua function handler.
-- =============================================================================
-- =============================================================================
-- Example: Scene switch node (AI / actuator trees)
-- =============================================================================
-- The built-in "switchScene" node queues a scene switch via
-- EditorApp::switchScene when it becomes active; the actual switch runs at
-- the start of the next frame. It works from AI trees (SmartObject/GOAP)
-- and actuator (player action) trees alike.
--
-- Node fields:
-- name = scene file path
-- params = optional teleport for the player character:
-- "@EntityName" - entity with a Transform in the NEW scene
-- "x,y,z" - world-space position
-- "x,y,z,yaw" - position + yaw in degrees
--
-- ecs.behavior_tree.create_scene_switch_node() builds the same node with
-- friendlier arguments:
-- create_scene_switch_node("scenes/level2.json")
-- create_scene_switch_node("scenes/level2.json", "SpawnPoint")
-- create_scene_switch_node("scenes/level2.json", 10, 5, 3, 90) -- x,y,z,yaw
ecs.action_db.add_action("enter_portal", 1,
{}, -- preconditions
{}, -- effects
{ -- behavior tree
type = "sequence",
children = {
ecs.behavior_tree.create_node("setAnimationState",
"locomotion/walk"),
ecs.behavior_tree.create_node("delay", "", "1.0"),
-- Teleport the player to the "SpawnPoint" entity of the
-- new scene after the switch.
ecs.behavior_tree.create_scene_switch_node(
"scenes/level2.json", "SpawnPoint")
}
}
)
-- The same node written out by hand:
-- { type = "switchScene", name = "scenes/level2.json",
-- params = "@SpawnPoint" }
-- or with a world-space destination and facing:
-- { type = "switchScene", name = "scenes/level2.json",
-- params = "10,5,3,90" }
-- =============================================================================
@@ -0,0 +1,83 @@
-- =============================================================================
-- Scene Switch Lua API Example
-- =============================================================================
-- This file demonstrates how to switch scenes from Lua (game mode or editor
-- mode) via ecs.switch_scene().
--
-- ecs.switch_scene(path [, opts]) -> bool
-- Queues a scene switch; the actual teardown + load happens at the start of
-- the next frame, so it is safe to call from scene scripts (e.g. in a
-- scene_loaded or event handler). Returns true when the switch was queued.
--
-- Behavior:
-- Editor mode: the current scene is completely destroyed and the new one is
-- loaded from scratch (editor camera and render origin are reset, unsaved
-- changes are discarded).
-- Game mode: if the new scene has its own player controller and/or camera,
-- the old scene (including the old player) is fully cleaned and the new
-- ones take over, like a fresh game start. If the new scene has neither,
-- the current player controller and the live player character are carried
-- over (inventory/stats are preserved).
--
-- opts (all fields optional):
-- position = { x = 10, y = 5, z = 3 } -- world-space destination
-- yaw = 90 -- degrees around +Y
-- rotation = { w = 1, x = 0, y = 0, z = 0 } -- quaternion (overrides yaw)
-- target = "SpawnPoint" -- name of an entity with a Transform component in
-- -- the NEW scene; takes precedence over position
--
-- The teleported player is clamped to the terrain surface and a short
-- grounding watchdog keeps it from falling through the floor while physics
-- colliders (streaming terrain pages) are still being built.
-- =============================================================================
-- Simple switch: clean scene swap.
ecs.switch_scene("scenes/level2.json")
-- Switch and teleport the player to a world-space position, facing 90 degrees.
ecs.switch_scene("scenes/level2.json", {
position = { x = 10, y = 5, z = 3 },
yaw = 90,
})
-- Switch and teleport the player onto a named spawn-point entity in the new
-- scene (the entity must have a Transform component).
ecs.switch_scene("scenes/level2.json", { target = "SpawnPoint" })
-- Switch with an explicit quaternion rotation.
ecs.switch_scene("scenes/level2.json", {
position = { x = 0, y = 0, z = 0 },
rotation = { w = 1, x = 0, y = 0, z = 0 },
})
-- Typical use: react to a game event by moving to another scene. Guard
-- against re-triggering after the switch (scene scripts re-run on load).
local switched = false
ecs.subscribe_event("portal_entered", function(data)
if switched then
return
end
switched = true
ecs.switch_scene("scenes/cave.json", { target = "CaveEntrance" })
end)
-- =============================================================================
-- Behavior tree usage (AI and actuator/player-action trees)
-- =============================================================================
-- The built-in "switchScene" behavior tree node queues the same scene switch
-- when it becomes active, so SmartObject/GOAP trees and player actions can
-- trigger scene switches without writing Lua handlers:
--
-- ecs.behavior_tree.create_scene_switch_node("scenes/cave.json", "CaveEntrance")
-- -> { type = "switchScene", name = "scenes/cave.json",
-- params = "@CaveEntrance" }
--
-- ecs.behavior_tree.create_scene_switch_node("scenes/cave.json", 10, 5, 3, 90)
-- -> { type = "switchScene", name = "scenes/cave.json",
-- params = "10.0,5.0,3.0,90.0" }
--
-- The same node can be written by hand or built with the generic helper:
-- ecs.behavior_tree.create_node("switchScene", "scenes/cave.json",
-- "@CaveEntrance")
--
-- See behavior_tree_example.lua for a full action example.
@@ -417,6 +417,65 @@ static int luaCreateNode(lua_State *L)
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.behavior_tree.create_scene_switch_node(path) -> table
// ecs.behavior_tree.create_scene_switch_node(path, "EntityName") -> table
// ecs.behavior_tree.create_scene_switch_node(path, x, y, z [, yawDeg])
// -> table
// ---------------------------------------------------------------------------
// Creates a "switchScene" behavior tree node that queues a scene switch via
// EditorApp::switchScene when the node becomes active. Works from AI trees
// (SmartObject/GOAP) and actuator (player action) trees alike; the actual
// switch is deferred to the start of the next frame.
//
// Examples:
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json")
// -> { type = "switchScene", name = "scenes/level2.json" }
//
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json", "SpawnPoint")
// -> { type = "switchScene", name = ..., params = "@SpawnPoint" }
//
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json", 10, 5, 3, 90)
// -> { type = "switchScene", name = ..., params = "10,5,3,90" }
static int luaCreateSceneSwitchNode(lua_State *L)
{
const char *path = luaL_checkstring(L, 1);
lua_newtable(L);
lua_pushstring(L, "switchScene");
lua_setfield(L, -2, "type");
lua_pushstring(L, path);
lua_setfield(L, -2, "name");
/* Note: lua_isstring() is true for numbers as well, so the
* argument kind must be tested with lua_type(). */
if (lua_type(L, 2) == LUA_TSTRING) {
/* Teleport to a named entity in the new scene. */
lua_pushfstring(L, "@%s", lua_tostring(L, 2));
lua_setfield(L, -2, "params");
} else if (lua_isnumber(L, 2) && lua_isnumber(L, 3) &&
lua_isnumber(L, 4)) {
/* Teleport to a world-space position, optional yaw. */
if (lua_isnumber(L, 5)) {
lua_pushfstring(L, "%f,%f,%f,%f",
(double)lua_tonumber(L, 2),
(double)lua_tonumber(L, 3),
(double)lua_tonumber(L, 4),
(double)lua_tonumber(L, 5));
} else {
lua_pushfstring(L, "%f,%f,%f", (double)lua_tonumber(L, 2),
(double)lua_tonumber(L, 3),
(double)lua_tonumber(L, 4));
}
lua_setfield(L, -2, "params");
}
return 1;
}
// ---------------------------------------------------------------------------
// Public C++ API: get list of registered Lua node names
// ---------------------------------------------------------------------------
@@ -461,6 +520,9 @@ void registerLuaBehaviorTreeApi(lua_State *L)
lua_pushcfunction(L, luaCreateNode);
lua_setfield(L, -2, "create_node");
lua_pushcfunction(L, luaCreateSceneSwitchNode);
lua_setfield(L, -2, "create_scene_switch_node");
// Set behavior_tree as a field of ecs
lua_setfield(L, -2, "behavior_tree");
@@ -52,7 +52,24 @@
* setValue, checkValue, blackboardDump, delay, teleportToChild,
* disablePhysics, enablePhysics, sendEvent, hasItem, hasItemByName,
* countItem, pickupItem, dropItem, useItem, addItemToInventory,
* luaTask
* switchScene, luaTask
*
* ecs.behavior_tree.create_scene_switch_node(path [, target]) -> table
* Convenience wrapper that creates a "switchScene" node table.
* `path` is the scene JSON path to switch to (node name).
* Optional `target` selects the player teleport destination:
* - "@EntityName" string: teleport to the named entity's
* TransformComponent in the new scene;
* - x, y, z [, yawDeg] numbers: explicit world position and
* optional yaw in degrees.
* The switch is queued once per node activation and executed at
* the start of the next frame (see EditorApp::switchScene).
*
* Examples:
* ecs.behavior_tree.create_scene_switch_node("scenes/town.json",
* "@PlayerStart")
* ecs.behavior_tree.create_scene_switch_node("scenes/town.json",
* 100, 5, 30, 90)
*
* Example:
* -- Register a Lua node handler
@@ -0,0 +1,110 @@
#include "LuaSceneSwitchApi.hpp"
#include "../EditorApp.hpp"
#include <OgreLogManager.h>
namespace editScene
{
static EditorApp *s_editorApp = nullptr;
void setSceneSwitchEditorApp(EditorApp *app)
{
s_editorApp = app;
}
// ---------------------------------------------------------------------------
// Lua: ecs.switch_scene(path [, opts]) -> bool
// ---------------------------------------------------------------------------
static int luaSwitchScene(lua_State *L)
{
if (!s_editorApp) {
Ogre::LogManager::getSingleton().logMessage(
"ecs.switch_scene: no EditorApp set");
lua_pushboolean(L, 0);
return 1;
}
const char *path = luaL_checkstring(L, 1);
SceneSwitchOptions opts;
if (lua_istable(L, 2)) {
/* position = { x = .., y = .., z = .. } (world space) */
lua_getfield(L, 2, "position");
if (lua_istable(L, -1)) {
lua_getfield(L, -1, "x");
lua_getfield(L, -2, "y");
lua_getfield(L, -3, "z");
if (lua_isnumber(L, -3) && lua_isnumber(L, -2) &&
lua_isnumber(L, -1)) {
opts.hasPosition = true;
opts.posX = lua_tonumber(L, -3);
opts.posY = lua_tonumber(L, -2);
opts.posZ = lua_tonumber(L, -1);
}
lua_pop(L, 3);
}
lua_pop(L, 1);
/* rotation = { w = .., x = .., y = .., z = .. } */
lua_getfield(L, 2, "rotation");
if (lua_istable(L, -1)) {
lua_getfield(L, -1, "w");
lua_getfield(L, -2, "x");
lua_getfield(L, -3, "y");
lua_getfield(L, -4, "z");
if (lua_isnumber(L, -4) && lua_isnumber(L, -3) &&
lua_isnumber(L, -2) && lua_isnumber(L, -1)) {
opts.hasRotation = true;
opts.rotation = Ogre::Quaternion(
(float)lua_tonumber(L, -4),
(float)lua_tonumber(L, -3),
(float)lua_tonumber(L, -2),
(float)lua_tonumber(L, -1));
}
lua_pop(L, 4);
}
lua_pop(L, 1);
/* yaw = degrees around +Y (only used when rotation is unset) */
lua_getfield(L, 2, "yaw");
if (lua_isnumber(L, -1) && !opts.hasRotation) {
opts.hasRotation = true;
opts.rotation = Ogre::Quaternion(
Ogre::Degree((float)lua_tonumber(L, -1)),
Ogre::Vector3::UNIT_Y);
}
lua_pop(L, 1);
/* target = "entity name with a Transform component" */
lua_getfield(L, 2, "target");
if (lua_isstring(L, -1))
opts.targetEntityName = lua_tostring(L, -1);
lua_pop(L, 1);
}
lua_pushboolean(L, s_editorApp->switchScene(path, opts) ? 1 : 0);
return 1;
}
// ---------------------------------------------------------------------------
// Register the scene switch API functions
// ---------------------------------------------------------------------------
void registerLuaSceneSwitchApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
lua_pushcfunction(L, luaSwitchScene);
lua_setfield(L, -2, "switch_scene");
// Set the global
lua_setglobal(L, "ecs");
}
} // namespace editScene
@@ -0,0 +1,40 @@
#ifndef EDITSCENE_LUA_SCENESWITCH_API_HPP
#define EDITSCENE_LUA_SCENESWITCH_API_HPP
#pragma once
#include <lua.hpp>
class EditorApp;
/**
* @file LuaSceneSwitchApi.hpp
* @brief Lua API for switching scenes.
*
* Exposed Lua globals (in the "ecs" table):
* ecs.switch_scene(path [, opts]) -> bool (true when the switch was queued)
*
* opts is an optional table:
* position = { x = 10, y = 5, z = 3 } -- world-space destination
* yaw = 90 -- degrees around +Y
* rotation = { w = 1, x = 0, y = 0, z = 0 }
* target = "SpawnPoint" -- entity with a Transform component in the
* -- new scene; takes precedence over position
*/
namespace editScene
{
/**
* @brief Set the EditorApp instance used by the scene switch API.
* Called once from EditorApp::setup().
*/
void setSceneSwitchEditorApp(EditorApp *app);
/**
* @brief Register the scene switch Lua API into the "ecs" global table.
*/
void registerLuaSceneSwitchApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_SCENESWITCH_API_HPP
@@ -272,6 +272,13 @@ void ActuatorSystem::update(float deltaTime)
return;
}
/* Targeting/prompts project through ImGui::GetMainViewport(); in
* headless mode there is no ImGui context at all, so skip the
* collection/label/input part (cooldowns above and the executing
* action block still run). */
if (!ImGui::GetCurrentContext())
return;
// Find player character (resolve spawned instance if controller
// targets a spawner).
flecs::entity playerCharacter = flecs::entity::null();
@@ -550,6 +557,10 @@ void ActuatorSystem::render()
EditorApp::GamePlayState::Playing)
return;
/* Headless runs have no ImGui context. */
if (!ImGui::GetCurrentContext())
return;
ImDrawList *drawList = ImGui::GetBackgroundDrawList();
if (!drawList)
return;
@@ -6,6 +6,7 @@
#include "ItemRegistry.hpp"
#include "ItemStateRegistry.hpp"
#include "EventBus.hpp"
#include "../EditorApp.hpp"
#include "../components/BehaviorTree.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
@@ -348,6 +349,59 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
return Status::success;
}
if (node.type == "switchScene") {
if (!isNewlyActive(state, &node))
return Status::success;
if (!m_editorApp) {
Ogre::LogManager::getSingleton().logMessage(
"[BT] switchScene: no EditorApp set");
return Status::failure;
}
/* params: "@EntityName" teleports the player to a named
* entity in the new scene; "x,y,z[,yawDeg]" teleports to a
* world-space position; empty keeps the current spot. */
SceneSwitchOptions opts;
const Ogre::String &p = node.params;
if (!p.empty()) {
if (p[0] == '@') {
opts.targetEntityName = p.substr(1);
} else {
float vals[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
int count = 0;
const char *s = p.c_str();
while (count < 4) {
char *end = nullptr;
float v = strtof(s, &end);
if (end == s)
break;
vals[count++] = v;
while (*end == ' ' || *end == '\t')
end++;
if (*end != ',')
break;
s = end + 1;
}
if (count >= 3) {
opts.hasPosition = true;
opts.posX = vals[0];
opts.posY = vals[1];
opts.posZ = vals[2];
}
if (count >= 4) {
opts.hasRotation = true;
opts.rotation = Ogre::Quaternion(
Ogre::Degree(vals[3]),
Ogre::Vector3::UNIT_Y);
}
}
}
return m_editorApp->switchScene(node.name, opts) ?
Status::success :
Status::failure;
}
if (node.type == "delay") {
char key[32];
snprintf(key, sizeof(key), "%p", (void *)&node);
@@ -13,6 +13,7 @@
class AnimationTreeSystem;
class CharacterSystem;
class EditorApp;
/**
* Evaluates data-driven BehaviorTreeComponent each frame.
@@ -30,6 +31,15 @@ public:
void update(float deltaTime);
/**
* Set the EditorApp used by the "switchScene" behavior tree node
* to queue scene switches.
*/
void setEditorApp(EditorApp *app)
{
m_editorApp = app;
}
/** Result of a single behavior tree evaluation tick. */
enum class Status { success, failure, running };
@@ -89,6 +99,7 @@ private:
Ogre::SceneManager *m_sceneMgr;
AnimationTreeSystem *m_animSystem;
CharacterSystem *m_charSystem;
EditorApp *m_editorApp = nullptr;
std::unordered_map<flecs::entity_t, RunnerState> m_runnerStates;
std::unordered_map<flecs::entity_t, RunnerState> m_actionDebugStates;
@@ -63,6 +63,8 @@ void CellGridSystem::update()
if (!m_initialized)
return;
cleanupDestroyedEntities();
// Process dirty cell grids
m_cellGridQuery.each(
[&](flecs::entity entity, CellGridComponent &grid) {
@@ -100,7 +102,26 @@ void CellGridSystem::update()
currentTextureVersion = it->second.textureVersion;
}
// If no stored texture, try to find from parent material
// If no stored texture, check the grid entity's own material
// first (a CellGrid entity may carry its own
// ProceduralMaterial, e.g. a standalone interior without a
// Lot/District/Town parent)
if (!currentTextureEntity.is_valid() &&
entity.has<ProceduralMaterialComponent>()) {
const auto &mat =
entity.get<ProceduralMaterialComponent>();
if (mat.diffuseTextureEntity.is_valid() &&
mat.diffuseTextureEntity.is_alive() &&
mat.diffuseTextureEntity.has<ProceduralTextureComponent>()) {
currentTextureEntity =
mat.diffuseTextureEntity;
const auto &tex =
mat.diffuseTextureEntity.get<ProceduralTextureComponent>();
currentTextureVersion = tex.version;
}
}
// If still no texture, try to find from parent material
if (!currentTextureEntity.is_valid()) {
flecs::entity parent = entity.parent();
while (parent.is_valid() && parent.is_alive()) {
@@ -282,9 +303,20 @@ void CellGridSystem::buildCellGrid(flecs::entity entity,
flecs::entity townEntity = flecs::entity::null();
flecs::entity materialEntity = flecs::entity::null();
// A CellGrid entity may carry its own ProceduralMaterial (e.g. a
// standalone interior without a Lot/District/Town parent)
if (entity.has<ProceduralMaterialComponent>()) {
const auto &mat = entity.get<ProceduralMaterialComponent>();
if (!mat.materialName.empty()) {
materialName = mat.materialName;
materialEntity = entity;
}
}
// Look for ProceduralMaterial in parent hierarchy (Lot -> District -> Town)
flecs::entity parent = entity.parent();
while (parent.is_valid() && parent.is_alive()) {
while (!materialEntity.is_valid() && parent.is_valid() &&
parent.is_alive()) {
if (parent.has<LotComponent>()) {
auto &lot = parent.get<LotComponent>();
if (lot.proceduralMaterialEntity.is_valid() && lot.proceduralMaterialEntity.is_alive() &&
@@ -2546,6 +2578,55 @@ void CellGridSystem::destroyCellGridMeshes(CellGridComponent &grid)
// The actual cleanup needs to happen there
}
void CellGridSystem::cleanupDestroyedEntities()
{
// Cell grids: full teardown via destroyCellGridMeshes
std::vector<flecs::entity> deadGrids;
for (const auto &pair : m_entityMeshes) {
flecs::entity e = m_world.entity(pair.first);
if (!e.is_alive() || !e.has<CellGridComponent>())
deadGrids.push_back(e);
}
for (flecs::entity e : deadGrids) {
Ogre::LogManager::getSingleton().logMessage(
"CellGrid: cleaning up meshes of destroyed entity " +
std::to_string(e.id()));
destroyCellGridMeshes(e);
}
// District plazas and lot bases (same pattern as the build paths)
auto sweepMap = [&](auto &map, auto hasOwnerComponent) {
for (auto it = map.begin(); it != map.end();) {
flecs::entity e = m_world.entity(it->first);
if (e.is_alive() && hasOwnerComponent(e)) {
++it;
continue;
}
if (it->second.entity) {
try {
m_sceneMgr->destroyEntity(it->second.entity);
} catch (...) {
}
}
if (!it->second.meshName.empty()) {
try {
Ogre::MeshManager::getSingleton().remove(
it->second.meshName);
} catch (...) {
}
}
destroyPhysicsColliders(e);
it = map.erase(it);
}
};
sweepMap(m_plazaMeshes, [](flecs::entity e) {
return e.has<DistrictComponent>();
});
sweepMap(m_lotBaseMeshes, [](flecs::entity e) {
return e.has<LotComponent>();
});
}
void CellGridSystem::destroyCellGridMeshes(flecs::entity entity)
{
auto it = m_entityMeshes.find(entity.id());
@@ -184,6 +184,12 @@ private:
void destroyCellGridMeshes(struct CellGridComponent &grid);
void destroyCellGridMeshes(flecs::entity entity);
// Remove meshes/colliders whose owning entity was destroyed
// (scene switch, entity deletion). Polled instead of an OnRemove
// observer because observer registration shifts entity IDs, which
// breaks SceneSerializer's hardcoded ID resolution (see initialize()).
void cleanupDestroyedEntities();
// Physics helpers
void addPhysicsCollider(flecs::entity physicsParent,
const std::string &meshName,
@@ -737,6 +737,22 @@ bool CharacterRegistry::despawnCharacter(uint64_t id)
flecs::entity e = findSpawnedEntity(id);
if (!e.is_alive())
return false;
/* Persist the live position/rotation into the registry record so a
* later respawn does not lose where the character was. This mirrors
* the sync loop in EditorApp::saveGame(). */
CharacterRecord *rec = findCharacter(id);
if (rec && e.has<TransformComponent>()) {
const auto &t = e.get<TransformComponent>();
if (t.node) {
rec->position = t.node->_getDerivedPosition();
rec->rotation = t.node->_getDerivedOrientation();
} else {
rec->position = t.position;
rec->rotation = t.rotation;
}
}
e.destruct();
return true;
}
@@ -214,6 +214,28 @@ void CharacterSpawnerSystem::despawn(flecs::entity spawnerEntity)
m_spawnedVersions.erase(spawnerEntity.id());
m_lastSpawnerTransforms.erase(spawnerEntity.id());
/* Persist the live position/rotation into the registry record so a
* later respawn does not lose where the character was. This mirrors
* the sync loop in EditorApp::saveGame(). */
if (character.is_alive() &&
character.has<CharacterIdentityComponent>() &&
character.has<TransformComponent>()) {
uint64_t rid =
character.get<CharacterIdentityComponent>().registryId;
auto *rec = CharacterRegistry::getSingleton().findCharacter(rid);
if (rec) {
const auto &t = character.get<TransformComponent>();
if (t.node) {
rec->position = t.node->_getDerivedPosition();
rec->rotation =
t.node->_getDerivedOrientation();
} else {
rec->position = t.position;
rec->rotation = t.rotation;
}
}
}
destroySpawnedEntity(character);
Ogre::LogManager::getSingleton().logMessage(
@@ -221,6 +243,22 @@ void CharacterSpawnerSystem::despawn(flecs::entity spawnerEntity)
std::to_string(spawnerEntity.id()));
}
void CharacterSpawnerSystem::releaseSpawned(flecs::entity spawnerEntity)
{
m_spawnedEntities.erase(spawnerEntity.id());
m_spawnedVersions.erase(spawnerEntity.id());
m_lastSpawnerTransforms.erase(spawnerEntity.id());
m_lockedSpawners.erase(spawnerEntity.id());
}
void CharacterSpawnerSystem::clearSpawnerState()
{
m_spawnedEntities.clear();
m_spawnedVersions.clear();
m_lastSpawnerTransforms.clear();
m_lockedSpawners.clear();
}
void CharacterSpawnerSystem::spawn(flecs::entity spawnerEntity)
{
if (!spawnerEntity.is_alive() ||
@@ -69,6 +69,22 @@ public:
*/
bool isSpawnerLocked(flecs::entity spawnerEntity) const;
/**
* Forget all bookkeeping for a spawner (including its lock) WITHOUT
* despawning the spawned character. Used by scene switching when the
* player character is carried over into the next scene while its
* spawner entity is destroyed with the old scene.
*/
void releaseSpawned(flecs::entity spawnerEntity);
/**
* Clear all spawner bookkeeping (spawned map, versions, transform
* snapshots, locks) without touching any entities. Call after the
* entities themselves have been destroyed (scene switch cleanup) so
* recycled flecs entity ids cannot alias stale entries.
*/
void clearSpawnerState();
private:
void spawnCharacter(flecs::entity spawnerEntity,
const struct CharacterSpawnerComponent &spawner);
@@ -1,5 +1,6 @@
#include "../components/GeneratedPhysicsTag.hpp"
#include "EditorUISystem.hpp"
#include "../EditorApp.hpp"
#include "DialogueSystem.hpp"
#include "PrefabSystem.hpp"
#include "ItemRegistry.hpp"
@@ -493,11 +494,14 @@ void EditorUISystem::renderHierarchyWindow()
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Save Scene...",
"Ctrl+S")) {
showFileDialog(true);
showFileDialog(FileDialogMode::Save);
}
if (ImGui::MenuItem("Load Scene...",
"Ctrl+O")) {
showFileDialog(false);
showFileDialog(FileDialogMode::Load);
}
if (ImGui::MenuItem("Switch Scene...")) {
showFileDialog(FileDialogMode::Switch);
}
ImGui::Separator();
if (ImGui::MenuItem("Save Action DB",
@@ -1178,6 +1182,22 @@ void EditorUISystem::deleteEntity(flecs::entity entity)
}
}
/* Clean up the Ogre light before the transform node: destroying the
* node would only detach the light, leaving it alive in the
* SceneManager with no parent, and OGRE asserts
* (Light::getDerivedDirection) when rendering such a light. */
if (entity.has<LightComponent>()) {
auto &light = entity.get_mut<LightComponent>();
if (light.light) {
Ogre::SceneNode *parent =
light.light->getParentSceneNode();
if (parent)
parent->detachObject(light.light);
m_sceneMgr->destroyLight(light.light);
light.light = nullptr;
}
}
// Clean up transform node
if (entity.has<TransformComponent>()) {
auto &transform = entity.get_mut<TransformComponent>();
@@ -1362,10 +1382,10 @@ void EditorUISystem::loadScene(const std::string &filepath)
// File Dialog Implementation
void EditorUISystem::showFileDialog(bool isSave)
void EditorUISystem::showFileDialog(FileDialogMode mode)
{
m_showFileDialog = true;
m_fileDialogIsSave = isSave;
m_fileDialogMode = mode;
m_refreshDirectory = true;
// Initialize current path if empty
@@ -1374,7 +1394,8 @@ void EditorUISystem::showFileDialog(bool isSave)
}
// Set default filename for save dialog
if (isSave && strlen(m_filenameBuffer) == 0) {
if (mode == FileDialogMode::Save &&
strlen(m_filenameBuffer) == 0) {
std::strncpy(m_filenameBuffer, "scene.json",
sizeof(m_filenameBuffer) - 1);
}
@@ -1387,7 +1408,10 @@ void EditorUISystem::closeFileDialog()
void EditorUISystem::renderFileDialog()
{
const char *title = m_fileDialogIsSave ? "Save Scene" : "Load Scene";
const char *title = m_fileDialogMode == FileDialogMode::Switch ?
"Switch Scene" :
(m_fileDialogMode == FileDialogMode::Save ? "Save Scene" :
"Load Scene");
// Center the dialog
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
@@ -1441,8 +1465,8 @@ void EditorUISystem::renderFileDialog()
std::filesystem::path(m_currentPath) / name;
bool isDirectory = std::filesystem::is_directory(fullPath);
// Skip non-json files in load mode (but show directories)
if (!m_fileDialogIsSave && !isDirectory) {
// Skip non-json files in load/switch mode (but show directories)
if (m_fileDialogMode != FileDialogMode::Save && !isDirectory) {
if (fullPath.extension() != ".json") {
continue;
}
@@ -1482,12 +1506,14 @@ void EditorUISystem::renderFileDialog()
sizeof(m_filenameBuffer));
// Buttons
if (ImGui::Button(m_fileDialogIsSave ? "Save" : "Load",
ImVec2(120, 0))) {
const char *confirmLabel =
m_fileDialogMode == FileDialogMode::Switch ? "Switch" :
(m_fileDialogMode == FileDialogMode::Save ? "Save" : "Load");
if (ImGui::Button(confirmLabel, ImVec2(120, 0))) {
std::string filename(m_filenameBuffer);
if (!filename.empty()) {
// Add .json extension if missing in save mode
if (m_fileDialogIsSave &&
if (m_fileDialogMode == FileDialogMode::Save &&
filename.find('.') == std::string::npos) {
filename += ".json";
}
@@ -1497,8 +1523,11 @@ void EditorUISystem::renderFileDialog()
filename)
.string();
if (m_fileDialogIsSave) {
if (m_fileDialogMode == FileDialogMode::Save) {
saveScene(fullPath);
} else if (m_fileDialogMode == FileDialogMode::Switch) {
if (m_editorApp)
m_editorApp->switchScene(fullPath);
} else {
loadScene(fullPath);
}
@@ -25,6 +25,7 @@ class EditorPhysicsSystem;
class BuoyancySystem;
class NormalDebugSystem;
class EditorCamera;
class EditorApp;
namespace Ogre
{
@@ -180,6 +181,14 @@ public:
m_worldMapPanel.setEditorCamera(camera);
}
/**
* Set the EditorApp instance (used to queue scene switches)
*/
void setEditorApp(EditorApp *app)
{
m_editorApp = app;
}
/**
* Enable/disable editor UI rendering
*/
@@ -207,9 +216,10 @@ public:
void loadScene(const std::string &filepath);
/**
* Show file dialog for save/load
* Show file dialog for save/load/scene-switch
*/
void showFileDialog(bool isSave);
enum class FileDialogMode { Save, Load, Switch };
void showFileDialog(FileDialogMode mode);
void renderFileDialog();
void closeFileDialog();
@@ -296,7 +306,7 @@ private:
// File dialog state
bool m_showFileDialog = false;
bool m_fileDialogIsSave = false; // true = save, false = load
FileDialogMode m_fileDialogMode = FileDialogMode::Load;
std::string m_currentPath;
std::string m_selectedFile;
std::vector<std::string> m_directoryContents;
@@ -332,6 +342,9 @@ private:
// Camera reference for cursor placement/rotation
EditorCamera *m_editorCamera = nullptr;
// EditorApp reference (scene switching)
EditorApp *m_editorApp = nullptr;
// Navigation panel (teleport + world bookmarks)
bool m_showNavigation = false;
NavigationPanel m_navigationPanel;
@@ -45,6 +45,14 @@ PlayerControllerSystem::~PlayerControllerSystem()
m_states.clear();
}
void PlayerControllerSystem::resetControllers()
{
for (auto &pair : m_states) {
shutdownController(pair.second);
}
m_states.clear();
}
void PlayerControllerSystem::shutdownController(ControllerState &state)
{
if (state.targetEntity.is_alive())
@@ -132,8 +140,29 @@ void PlayerControllerSystem::initController(flecs::entity controllerEntity,
m_sceneMgr->getRootSceneNode()->createChildSceneNode();
}
// Initialize camera to start behind character (180 degrees yaw)
state.yaw = 180.0f;
/* Initialize the camera behind the character's current facing
* instead of a fixed yaw: the character model's visual forward is
* +Z (verified against the locomotion movement direction), and
* the camera view direction at yaw t is R_y(t) * -Z, so the yaw
* that looks along facing f is atan2(-f.x, -f.z). This makes
* scene spawns and scene-switch teleports (which set the
* character's rotation) control where the player initially
* looks; identity rotation keeps the old behind-the-character
* view (yaw 180). */
Ogre::Vector3 facing = Ogre::Vector3::UNIT_Z;
if (state.targetEntity.has<TransformComponent>()) {
const auto &t = state.targetEntity.get<TransformComponent>();
if (t.node)
facing = t.node->getOrientation() *
Ogre::Vector3::UNIT_Z;
else
facing = t.rotation * Ogre::Vector3::UNIT_Z;
}
facing.y = 0.0f;
if (facing.squaredLength() < 0.0001f)
facing = Ogre::Vector3::UNIT_Z;
facing.normalise();
state.yaw = Ogre::Math::ATan2(-facing.x, -facing.z).valueDegrees();
state.pitch = 0.0f;
state.initialized = true;
@@ -43,6 +43,14 @@ public:
flecs::entity resolveTargetEntity(const Ogre::String &targetName,
bool forceSpawn = false);
/**
* Shut down every active controller state (removes PlayerControlled
* tags, destroys pivot/goal nodes, unlocks spawners) and clears the
* state map. Used by scene switching so no stale entity ids or scene
* nodes survive the cleanup.
*/
void resetControllers();
private:
struct ControllerState {
flecs::entity targetEntity = flecs::entity::null();
@@ -96,6 +96,18 @@ void ProceduralTextureSystem::generateTexture(flecs::entity entity, ProceduralTe
if (component.ogreTexture) {
Ogre::TextureManager::getSingleton().remove(component.ogreTexture);
component.ogreTexture.reset();
} else if (Ogre::TextureManager::getSingleton().resourceExists(
component.textureName,
Ogre::ResourceGroupManager::
DEFAULT_RESOURCE_GROUP_NAME)) {
// A texture with this name may be left over from a previous
// scene load: scene switching destroys the entity (and its
// component) but the Ogre resource stays in the
// TextureManager, and loadImage() below fails on the
// duplicate name.
Ogre::TextureManager::getSingleton().remove(
component.textureName,
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
}
// Create Ogre texture from image
@@ -0,0 +1,432 @@
/**
* @file scene_switch_test.cpp
* @brief Headless tests for the scene switch API and the "switchScene"
* behavior tree node.
*
* Runs without OGRE initialization (no window, no GL): the scene switch
* API only validates the path and queues a deferred request, and the
* behavior tree node evaluation needs neither a SceneManager nor a
* physics world.
*
* Covered:
* - ecs.switch_scene() validation and option parsing (Lua API)
* - ecs.behavior_tree.create_scene_switch_node() (Lua helper)
* - EditorApp::switchScene() queue semantics (C++ API)
* - "switchScene" behavior tree node via the actuator path
* (BehaviorTreeSystem::evaluatePlayerAction) and the AI path
* (BehaviorTreeSystem::update with a BehaviorTreeComponent entity)
*/
#include <cstdio>
#include <cstring>
#include <cmath>
#include <fstream>
#include <string>
#include <OgreLogManager.h>
#include "../EditorApp.hpp"
#include "../lua/LuaSceneSwitchApi.hpp"
#include "../lua/LuaBehaviorTreeApi.hpp"
#include "../systems/BehaviorTreeSystem.hpp"
#include "../components/BehaviorTree.hpp"
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", std::string(msg).c_str()); \
return 1; \
} while (0)
/* Scene file referenced by the tests; content only needs to exist for
* switchScene validation (parsing is exercised by the runtime demos). */
#define kSceneFile "scene_switch_test_target.json"
#define kMissingFile "scene_switch_test_missing.json"
static void writeTestScene()
{
std::ofstream out(kSceneFile);
out << "{ \"version\": \"1.0\", \"entities\": [] }\n";
}
/* Run a Lua snippet, FAIL on error. */
#define RUN_LUA(code) \
do { \
if (!runLua(L, code)) \
FAIL("Lua error in: " #code); \
} while (0)
static bool runLua(lua_State *L, const char *code)
{
if (luaL_dostring(L, code) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
return true;
}
static bool getGlobalBool(lua_State *L, const char *name)
{
lua_getglobal(L, name);
bool val = lua_toboolean(L, -1);
lua_pop(L, 1);
return val;
}
static std::string getGlobalString(lua_State *L, const char *name)
{
lua_getglobal(L, name);
const char *s = lua_tostring(L, -1);
std::string val = s ? s : "";
lua_pop(L, 1);
return val;
}
// ---------------------------------------------------------------------------
int main()
{
/* Ogre::LogManager is needed by the APIs under test (they log
* errors); no other OGRE subsystem is initialized. */
Ogre::LogManager logManager;
writeTestScene();
EditorApp app;
app.setHeadless(true);
editScene::setSceneSwitchEditorApp(&app);
lua_State *L = luaL_newstate();
luaL_openlibs(L);
/* The scene switch API lives in the "ecs" table; create it the
* same way LuaEntityApi would. */
lua_newtable(L);
lua_setglobal(L, "ecs");
editScene::registerLuaSceneSwitchApi(L);
editScene::registerLuaBehaviorTreeApi(L);
printf("Scene switch API and behavior tree node tests\n");
printf("=============================================\n");
/* --- Lua: validation --- */
TEST("switch_scene with empty path returns false");
RUN_LUA("r = ecs.switch_scene(\"\")");
if (!getGlobalBool(L, "r"))
PASS();
else
FAIL("expected false for empty path");
TEST("switch_scene with missing file returns false");
RUN_LUA("r = ecs.switch_scene(\"" kMissingFile "\")");
if (!getGlobalBool(L, "r"))
PASS();
else
FAIL("expected false for missing file");
TEST("switch_scene with existing file queues the switch");
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile "\")");
if (getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
app.getPendingSceneSwitchPath() == kSceneFile)
PASS();
else
FAIL("expected queued switch for existing file");
/* --- Lua: option parsing --- */
TEST("switch_scene parses position and yaw");
app.clearPendingSceneSwitch();
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
"\", { position = { x = 1.5, y = 2.5, z = -3.5 },"
" yaw = 90 })");
{
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
bool ok = getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
o.hasPosition && o.posX == 1.5 && o.posY == 2.5 &&
o.posZ == -3.5 && o.hasRotation &&
o.targetEntityName.empty();
/* yaw=90 degrees around +Y: w = cos(45deg), y = sin(45deg) */
const float s = std::sqrt(0.5f);
ok = ok && std::fabs(o.rotation.w - s) < 0.001f &&
std::fabs(o.rotation.y - s) < 0.001f;
if (ok)
PASS();
else
FAIL("position/yaw not parsed into options");
}
TEST("switch_scene parses explicit quaternion rotation");
app.clearPendingSceneSwitch();
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
"\", { rotation = { w = 1, x = 0, y = 0, z = 0 } })");
{
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
if (getGlobalBool(L, "r") && !o.hasPosition && o.hasRotation &&
o.rotation == Ogre::Quaternion::IDENTITY)
PASS();
else
FAIL("rotation not parsed into options");
}
TEST("switch_scene parses target entity name");
app.clearPendingSceneSwitch();
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
"\", { target = \"SpawnPoint\" })");
{
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
if (getGlobalBool(L, "r") && !o.hasPosition &&
o.targetEntityName == "SpawnPoint")
PASS();
else
FAIL("target entity name not parsed into options");
}
TEST("switch_scene without options queues a plain switch");
app.clearPendingSceneSwitch();
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile "\")");
{
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
if (getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
!o.hasPosition && !o.hasRotation &&
o.targetEntityName.empty())
PASS();
else
FAIL("expected default options");
}
TEST("a failed switch_scene call keeps the queued request");
RUN_LUA("r = ecs.switch_scene(\"" kMissingFile "\")");
if (!getGlobalBool(L, "r") && app.hasPendingSceneSwitch())
PASS();
else
FAIL("pending request should survive a failed call");
app.clearPendingSceneSwitch();
/* --- Lua: create_scene_switch_node helper --- */
TEST("create_scene_switch_node without teleport");
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node(\"a.json\")\n"
"nt = n.type\n"
"nn = n.name\n"
"np = n.params");
if (getGlobalString(L, "nt") == "switchScene" &&
getGlobalString(L, "nn") == "a.json" &&
getGlobalString(L, "np").empty())
PASS();
else
FAIL("unexpected node fields");
TEST("create_scene_switch_node with target entity");
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node("
"\"a.json\", \"SpawnPoint\")\n"
"np = n.params");
if (getGlobalString(L, "np") == "@SpawnPoint")
PASS();
else
FAIL("expected params '@SpawnPoint'");
TEST("create_scene_switch_node with position and yaw");
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node("
"\"a.json\", 10, 5, 3, 90)\n"
"np = n.params");
if (getGlobalString(L, "np") == "10.0,5.0,3.0,90.0")
PASS();
else {
printf("(actual: '%s') ", getGlobalString(L, "np").c_str());
FAIL("expected params '10.0,5.0,3.0,90.0'");
}
lua_close(L);
/* --- C++: switchScene validation --- */
TEST("C++ switchScene rejects an empty path");
app.clearPendingSceneSwitch();
if (!app.switchScene("") && !app.hasPendingSceneSwitch())
PASS();
else
FAIL("expected false for empty path");
TEST("C++ switchScene rejects a missing file");
if (!app.switchScene(kMissingFile) && !app.hasPendingSceneSwitch())
PASS();
else
FAIL("expected false for missing file");
TEST("C++ switchScene queues an existing scene");
if (app.switchScene(kSceneFile) && app.hasPendingSceneSwitch() &&
app.getPendingSceneSwitchPath() == kSceneFile)
PASS();
else
FAIL("expected queued switch");
/* --- C++: switchScene behavior tree node (actuator path) --- */
flecs::world world;
flecs::entity actor = world.entity("actor");
BehaviorTreeSystem bts(world, nullptr, nullptr, nullptr);
BehaviorTreeNode switchTree;
switchTree.type = "sequence";
{
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kSceneFile;
switchTree.children.push_back(leaf);
}
TEST("BT switchScene fails without an EditorApp");
app.clearPendingSceneSwitch();
if (bts.evaluatePlayerAction(actor.id(), switchTree, 0.016f,
true) ==
BehaviorTreeSystem::Status::failure &&
!app.hasPendingSceneSwitch())
PASS();
else
FAIL("expected failure without EditorApp");
bts.setEditorApp(&app);
TEST("BT switchScene fails for a missing scene file");
{
BehaviorTreeNode badTree;
badTree.type = "sequence";
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kMissingFile;
badTree.children.push_back(leaf);
if (bts.evaluatePlayerAction(actor.id(), badTree, 0.016f,
true) ==
BehaviorTreeSystem::Status::failure &&
!app.hasPendingSceneSwitch())
PASS();
else
FAIL("expected failure for missing scene file");
}
TEST("BT switchScene queues the switch (actuator path)");
if (bts.evaluatePlayerAction(actor.id(), switchTree, 0.016f,
true) ==
BehaviorTreeSystem::Status::success &&
app.hasPendingSceneSwitch() &&
app.getPendingSceneSwitchPath() == kSceneFile)
PASS();
else
FAIL("expected success and queued switch");
TEST("BT switchScene parses '@EntityName' teleport params");
{
app.clearPendingSceneSwitch();
BehaviorTreeNode tree;
tree.type = "sequence";
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kSceneFile;
leaf.params = "@SpawnPoint";
tree.children.push_back(leaf);
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
true) ==
BehaviorTreeSystem::Status::success;
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
ok = ok && app.hasPendingSceneSwitch() &&
o.targetEntityName == "SpawnPoint" && !o.hasPosition;
if (ok)
PASS();
else
FAIL("'@' params not parsed into options");
}
TEST("BT switchScene parses 'x,y,z,yaw' teleport params");
{
app.clearPendingSceneSwitch();
BehaviorTreeNode tree;
tree.type = "sequence";
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kSceneFile;
leaf.params = "1.5,2.5,-3.5,90";
tree.children.push_back(leaf);
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
true) ==
BehaviorTreeSystem::Status::success;
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
ok = ok && o.hasPosition && o.posX == 1.5 && o.posY == 2.5 &&
o.posZ == -3.5 && o.hasRotation;
const float s = std::sqrt(0.5f);
ok = ok && std::fabs(o.rotation.w - s) < 0.001f &&
std::fabs(o.rotation.y - s) < 0.001f;
if (ok)
PASS();
else
FAIL("positional params not parsed into options");
}
TEST("BT switchScene fires only once per activation");
{
app.clearPendingSceneSwitch();
BehaviorTreeNode tree;
tree.type = "sequence";
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kMissingFile;
tree.children.push_back(leaf);
/* First activation: missing file -> failure, not queued. */
bts.evaluatePlayerAction(actor.id(), tree, 0.016f, true);
/* Second activation (no reset): the node must not fire
* again; it reports success so the tree can move on. */
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
false) ==
BehaviorTreeSystem::Status::success;
if (ok && !app.hasPendingSceneSwitch())
PASS();
else
FAIL("node re-fired or returned wrong status");
}
/* --- C++: switchScene behavior tree node (AI path) --- */
TEST("BT switchScene queues the switch (BehaviorTreeComponent path)");
{
app.clearPendingSceneSwitch();
flecs::entity npc = world.entity("npc");
BehaviorTreeComponent bt;
bt.enabled = true;
bt.root.type = "sequence";
BehaviorTreeNode leaf;
leaf.type = "switchScene";
leaf.name = kSceneFile;
bt.root.children.push_back(leaf);
npc.set<BehaviorTreeComponent>(bt);
bts.update(0.016f);
if (app.hasPendingSceneSwitch() &&
app.getPendingSceneSwitchPath() == kSceneFile)
PASS();
else
FAIL("expected queued switch from update()");
}
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
testCount - passCount);
return (passCount == testCount) ? 0 : 1;
}
@@ -30,6 +30,8 @@ static ImU32 nodeTypeColorU32(const Ogre::String &type)
return IM_COL32(0x4C, 0xCC, 0x4C, 0xFF);
if (type == "sendEvent")
return IM_COL32(0xFF, 0x99, 0x00, 0xFF);
if (type == "switchScene")
return IM_COL32(0x66, 0xFF, 0xCC, 0xFF);
if (type == "luaTask")
return IM_COL32(0xAA, 0x66, 0xFF, 0xFF);
return IM_COL32(0xFF, 0xFF, 0xFF, 0xFF);
@@ -247,6 +249,8 @@ void BehaviorTreeEditor::renderTree(BehaviorTreeNode &node,
queueAddChild(&node, "enablePhysics");
if (ImGui::MenuItem("Add Send Event"))
queueAddChild(&node, "sendEvent");
if (ImGui::MenuItem("Add Switch Scene"))
queueAddChild(&node, "switchScene");
if (ImGui::BeginMenu("Add Lua Task")) {
renderLuaNodeSubmenu(&node, m_editOps);
ImGui::EndMenu();
@@ -306,6 +310,8 @@ void BehaviorTreeEditor::renderTree(BehaviorTreeNode &node,
queueAddChild(&node, "enablePhysics");
if (ImGui::MenuItem("Send Event"))
queueAddChild(&node, "sendEvent");
if (ImGui::MenuItem("Switch Scene"))
queueAddChild(&node, "switchScene");
if (ImGui::BeginMenu("Lua Task")) {
renderLuaNodeSubmenu(&node, m_editOps);
ImGui::EndMenu();
@@ -365,6 +371,7 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
"disablePhysics",
"enablePhysics",
"sendEvent",
"switchScene",
"luaTask" };
int typeIdx = 0;
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
@@ -444,6 +451,9 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
} else if (node->type == "sendEvent") {
label = "Event Name";
hint = "Name of the event to send via EventBus";
} else if (node->type == "switchScene") {
label = "Scene Path";
hint = "Scene file to switch to (queued for next frame)";
}
if (ImGui::InputText(label, buf, sizeof(buf)))
@@ -455,7 +465,8 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
// Params input for nodes that need it
if (node->type == "setBit" || node->type == "setValue" ||
node->type == "checkValue" || node->type == "delay" ||
node->type == "sendEvent" || node->type == "luaTask") {
node->type == "sendEvent" || node->type == "switchScene" ||
node->type == "luaTask") {
char pbuf[256];
snprintf(pbuf, sizeof(pbuf), "%s",
node->params.c_str());
@@ -470,6 +481,8 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
phint = "Duration in seconds (float, default 1.0)";
else if (node->type == "sendEvent")
phint = "Event params: key=val key2=val2 ...";
else if (node->type == "switchScene")
phint = "Teleport: @EntityName or x,y,z[,yawDeg] (optional)";
else if (node->type == "luaTask")
phint = "Parameters: key=val,key2=val2 passed to Lua function";
if (ImGui::InputText("Params", pbuf, sizeof(pbuf)))
+35 -8
View File
@@ -273,10 +273,19 @@ void CellGridEditor::renderScriptEditor(CellGridComponent& grid)
void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridComponent& grid)
{
// Find available texture rectangles from parent material
// Find available texture rectangles: the grid entity may carry its own
// ProceduralMaterial (matching CellGridSystem::buildCellGrid), otherwise
// fall back to the parent hierarchy (Lot -> District -> Town).
flecs::entity textureEntity = flecs::entity::null();
if (entity.has<ProceduralMaterialComponent>()) {
const auto& mat = entity.get<ProceduralMaterialComponent>();
if (mat.diffuseTextureEntity.is_alive() &&
mat.diffuseTextureEntity.has<ProceduralTextureComponent>()) {
textureEntity = mat.diffuseTextureEntity;
}
}
flecs::entity parent = entity.parent();
while (parent.is_alive()) {
while (!textureEntity.is_alive() && parent.is_alive()) {
if (parent.has<LotComponent>()) {
auto& lot = parent.get<LotComponent>();
if (lot.proceduralMaterialEntity.is_alive() &&
@@ -315,7 +324,10 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
}
if (!textureEntity.is_alive() || !textureEntity.has<ProceduralTextureComponent>()) {
ImGui::TextDisabled("No ProceduralMaterial with texture found in parent hierarchy");
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
"ERROR: no ProceduralMaterial with a texture found on this entity "
"or in the parent hierarchy - the grid will use the default "
"material and default UV mapping");
return;
}
@@ -323,7 +335,9 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
const auto& namedRects = texture.getAllNamedRects();
if (namedRects.empty()) {
ImGui::TextDisabled("No named rectangles defined in texture");
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
"ERROR: no named rectangles defined in the texture - all parts "
"will use default UV mapping");
return;
}
@@ -342,24 +356,37 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
break;
}
}
std::string comboItems;
for (size_t i = 0; i < rectNames.size(); ++i) {
if (i > 0) comboItems += '\0';
comboItems += rectNames[i];
}
comboItems += '\0';
int newIndex = currentIndex;
bool changed = false;
if (ImGui::Combo(label, &newIndex, comboItems.c_str())) {
if (newIndex == 0) {
currentValue.clear();
} else {
currentValue = rectNames[newIndex];
}
return true;
changed = true;
}
return false;
// Flag parts that will not sample the intended atlas region:
// an unset name falls back to default UV mapping, an unknown
// name is a stale/typoed reference.
ImGui::SameLine();
if (currentValue.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f),
"missing (default UV mapping)");
} else if (namedRects.find(currentValue) == namedRects.end()) {
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
"ERROR: rect not found in texture");
}
return changed;
};
ImGui::Text("Select texture rectangles for each part:");
@@ -117,6 +117,8 @@ static ImVec4 typeColorVec(const char *type)
return ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
if (!strcmp(type, "sendEvent"))
return ImVec4(1.0f, 0.6f, 0.0f, 1.0f);
if (!strcmp(type, "switchScene"))
return ImVec4(0.4f, 1.0f, 0.8f, 1.0f);
if (!strcmp(type, "luaTask"))
return ImVec4(0.67f, 0.4f, 1.0f, 1.0f);
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
@@ -199,6 +201,12 @@ void InlineBehaviorTreeEditor::renderNode(BehaviorTreeNode &node,
.childIndex = node.children.size(),
.addType = "sendEvent" });
}
if (ImGui::MenuItem("Add Switch Scene")) {
queueOp(EditOp{ .type = EditOp::Add,
.parent = &node,
.childIndex = node.children.size(),
.addType = "switchScene" });
}
if (ImGui::BeginMenu("Add Lua Task")) {
renderLuaNodeSubmenu(&node, node.children.size());
ImGui::EndMenu();
@@ -320,6 +328,7 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
"disablePhysics",
"enablePhysics",
"sendEvent",
"switchScene",
"luaTask" };
int current = 0;
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
@@ -398,6 +407,9 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
} else if (node->type == "sendEvent") {
label = "Event Name";
hint = "Name of the event to send via EventBus";
} else if (node->type == "switchScene") {
label = "Scene Path";
hint = "Scene file to switch to (queued for next frame)";
}
if (ImGui::InputText(label, buf, sizeof(buf)))
@@ -409,7 +421,8 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
// Params input for nodes that need it
if (node->type == "setBit" || node->type == "setValue" ||
node->type == "checkValue" || node->type == "delay" ||
node->type == "sendEvent" || node->type == "luaTask") {
node->type == "sendEvent" || node->type == "switchScene" ||
node->type == "luaTask") {
char pbuf[256];
snprintf(pbuf, sizeof(pbuf), "%s",
node->params.c_str());
@@ -424,6 +437,8 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
phint = "Duration in seconds (float, default 1.0)";
else if (node->type == "sendEvent")
phint = "Event params: key=val key2=val2 ...";
else if (node->type == "switchScene")
phint = "Teleport: @EntityName or x,y,z[,yawDeg] (optional)";
else if (node->type == "luaTask")
phint = "Parameters: key=val,key2=val2 passed to Lua function";
if (ImGui::InputText("Params", pbuf, sizeof(pbuf)))