diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 20bd238..c39ad46 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -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 diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 3ebd064..1b5665c 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -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) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index f4e820d..6a88398 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -1,5 +1,8 @@ #include #include +#include +#include +#include #include "EditorApp.hpp" #include "GameMode.hpp" #include @@ -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( 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()) { - // 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{ 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()) { - playerCharacter.set( - 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()) { + // 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{ 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()) { + playerCharacter.set( + 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 seen; + std::vector toDelete; + auto collect = [&](flecs::entity e) { + if (e.is_alive() && seen.insert(e.id()).second) + toDelete.push_back(e); + }; + m_world.query().each( + [&](flecs::entity e, EditorMarkerComponent) { collect(e); }); + m_world.query().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().each( + [&](flecs::entity e, EntityNameComponent &name) { + if (name.name == "player" && + e.has()) + 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()) + controller.remove(); + if (controller.has()) + controller.remove(); + if (carriedCharacter.is_alive()) { + if (carriedCharacter.has()) + carriedCharacter + .remove(); + if (carriedCharacter.has()) + carriedCharacter + .remove(); + + /* 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().each( + [&](flecs::entity, PlayerControllerComponent &) { + controllerActive = true; + }); + if (newHasCamera && !controllerActive && m_cameraSystem) { + m_cameraSystem->processAllCameras(); + flecs::entity camEntity = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, CameraComponent &cc) { + if (!camEntity.is_alive() && cc.camera) + camEntity = e; + }); + if (camEntity.is_alive()) + m_cameraSystem->setViewportCamera( + camEntity.get().camera); + } + + /* Teleport the player character when requested. */ + flecs::entity playerCharacter = getPlayerCharacterEntity(); + if (playerCharacter.is_alive() && + playerCharacter.has()) { + 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() + .each([&](flecs::entity e, + EntityNameComponent &en, + TransformComponent &) { + if (en.name == opts.targetEntityName) + target = e; + }); + if (target.is_alive()) { + auto &tt = target.get(); + 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(); + 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()) { + playerCharacter.get_mut() + .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()) { + m_groundingFramesLeft = 0; + } else { + bool grounded = e.has() && + e.get().hasFloor; + TerrainSystem *ts = TerrainSystem::getInstance(); + if (grounded || !ts || !ts->isActive()) { + m_groundingFramesLeft = 0; + } else { + auto &t = e.get_mut(); + 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()) { + 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) diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index ebe9eab..c8f4de1 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -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; diff --git a/src/features/editScene/camera/EditorCamera.cpp b/src/features/editScene/camera/EditorCamera.cpp index 9d4c0ed..8b8849a 100644 --- a/src/features/editScene/camera/EditorCamera.cpp +++ b/src/features/editScene/camera/EditorCamera.cpp @@ -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(); +} diff --git a/src/features/editScene/camera/EditorCamera.hpp b/src/features/editScene/camera/EditorCamera.hpp index 828916d..6a050a1 100644 --- a/src/features/editScene/camera/EditorCamera.hpp +++ b/src/features/editScene/camera/EditorCamera.hpp @@ -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); diff --git a/src/features/editScene/components/BehaviorTree.hpp b/src/features/editScene/components/BehaviorTree.hpp index 9f047de..8003f02 100644 --- a/src/features/editScene/components/BehaviorTree.hpp +++ b/src/features/editScene/components/BehaviorTree.hpp @@ -51,6 +51,19 @@ * (for quest rewards, etc.). * params="itemId,itemName,itemType,count,weight,value" * + * --- Scene switch node --- + * "switchScene" - Leaf: queues a scene switch via + * EditorApp::switchScene (name=scene file path). + * The switch is deferred to the start of the next + * frame, so it is safe from AI and actuator trees. + * params (optional): + * "@EntityName" - teleport the player to a named + * entity with a Transform component + * in the new scene + * "x,y,z" - teleport to a world-space position + * "x,y,z,yaw" - position + yaw in degrees + * Returns success when the switch was queued. + * * --- Lua node --- * "luaTask" - Leaf: calls a registered Lua function. * name = registered node handler name. @@ -105,6 +118,7 @@ struct BehaviorTreeNode { type == "hasItemByName" || type == "countItem" || type == "pickupItem" || type == "dropItem" || type == "useItem" || type == "addItemToInventory" || + type == "switchScene" || type == "luaTask"; } }; diff --git a/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt b/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt new file mode 100644 index 0000000..a09baf5 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/CMakeLists.txt @@ -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 /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" +) diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp new file mode 100644 index 0000000..5106643 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_main.cpp @@ -0,0 +1,469 @@ +#include +#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 +#include +#include +#include + +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().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()) + return false; + + const TransformComponent &t = player.get(); + 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()) + return false; + + const TransformComponent &t = player.get(); + 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; +} diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json new file mode 100644 index 0000000..75cfd18 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_a.json @@ -0,0 +1,1348 @@ +{ + "actionDatabase": { + "actions": [ + { + "behaviorTree": { + "children": [ + { + "name": "luaHello", + "params": "message=Welcome to the game!", + "type": "luaTask" + }, + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "9.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "lua_hello_action", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "main/action", + "type": "setAnimationState" + }, + { + "name": "action/sitting-ground", + "type": "setAnimationState" + }, + { + "name": "dly", + "params": "6.0", + "type": "delay" + }, + { + "name": "main/locomotion", + "type": "setAnimationState" + }, + { + "name": "locomotion/idle", + "type": "setAnimationState" + }, + { + "name": "luaHello", + "params": "message=\"hello, world!\"", + "type": "luaTask" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "testAction", + "preconditions": { + "bits": 0, + "mask": 0 + } + }, + { + "behaviorTree": { + "children": [ + { + "name": "[demo] portal A: switching to scene B", + "type": "debugPrint" + }, + { + "name": "demo_scene_b.json", + "params": "@arrival_b", + "type": "switchScene" + } + ], + "type": "sequence" + }, + "cost": 1, + "effects": { + "bits": 0, + "mask": 0 + }, + "name": "goto_scene_b", + "preconditions": { + "bits": 0, + "mask": 0 + } + } + ], + "bitNames": [ + { + "index": 1, + "name": "hungry" + }, + { + "index": 2, + "name": "thirsty" + } + ], + "goals": [] + }, + "bookmarks": [], + "entities": [ + { + "children": [], + "id": 4294967791, + "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": 133143986672, + "light": { + "castShadows": false, + "constantAttenuation": 1.0, + "diffuseColor": { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "direction": { + "x": 0.30000001192092896, + "y": -1.0, + "z": 0.20000000298023224 + }, + "intensity": 1.0, + "lightType": "directional", + "linearAttenuation": 0.0, + "quadraticAttenuation": 0.0, + "range": 100.0, + "specularColor": { + "a": 1.0, + "b": 0.5, + "g": 0.5, + "r": 0.5 + }, + "spotlightFalloff": 1.0, + "spotlightInnerAngle": 30.0, + "spotlightOuterAngle": 45.0 + }, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "collider": { + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": -0.10000000149011612, + "z": 0.0 + }, + "parameters": { + "x": 30.0, + "y": 0.10000000149011612, + "z": 30.0 + }, + "radius": 0.5, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "shapeType": "box" + }, + "id": 77309411831, + "name": { + "name": "demo_floor" + }, + "renderable": { + "meshName": "DemoFloorPlaneA", + "visible": true + }, + "rigidBody": { + "bodyType": "static", + "enabled": true, + "friction": 0.800000011920929, + "isSensor": false, + "mass": 1.0, + "restitution": 0.0 + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "actuator": { + "actionNames": [ + "goto_scene_b" + ], + "height": 1.7999999523162842, + "radius": 1.5 + }, + "children": [], + "id": 77309411832, + "name": { + "name": "portal_a" + }, + "renderable": { + "meshName": "DemoActuatorMarkerA", + "visible": true + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.800000011920929, + "z": 28.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "characterSpawner": { + "despawnDistance": 200.0, + "registryId": 2, + "spawnDistance": 100.0 + }, + "children": [], + "id": 77309411833, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.10288965702056885, + "z": -3.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 77309411834, + "name": { + "name": "player" + }, + "playerController": { + "actuatorColor": [ + 0.0, + 0.4000000059604645, + 1.0 + ], + "actuatorCooldown": 1.5, + "actuatorDistance": 25.0, + "actuatorLabelFontSize": 12.0, + "cameraMode": 0, + "distantCircleRadius": 8.0, + "fpsBoneName": "Head", + "idleState": "idle", + "locomotionStateMachine": "locomotion", + "mouseSensitivity": 0.20000000298023224, + "nearCircleRadius": 14.0, + "runState": "running", + "swimFastState": "swimming-fast", + "swimIdleState": "swim-idle", + "swimState": "swimming", + "targetCharacterName": "s1", + "tpsDistance": 3.0, + "tpsHeight": 2.0, + "walkState": "walking" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "cellGrid": { + "ceilingRectName": "ceiling", + "cellHeight": 4.0, + "cellSize": 4.0, + "cells": [ + { + "flags": 147495, + "x": -1, + "y": 0, + "z": -2 + }, + { + "flags": 131107, + "x": 0, + "y": 0, + "z": -2 + }, + { + "flags": 163883, + "x": 1, + "y": 0, + "z": -2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": -1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": -1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": -1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 0 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 0 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 0 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 1 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 1 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 1 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 2 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 2 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 2 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 3 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 3 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 3 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 4 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 4 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 4 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 5 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 5 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 5 + }, + { + "flags": 16391, + "x": -1, + "y": 0, + "z": 6 + }, + { + "flags": 3, + "x": 0, + "y": 0, + "z": 6 + }, + { + "flags": 32779, + "x": 1, + "y": 0, + "z": 6 + }, + { + "flags": 81943, + "x": -1, + "y": 0, + "z": 7 + }, + { + "flags": 1048835, + "x": 0, + "y": 0, + "z": 7 + }, + { + "flags": 98331, + "x": 1, + "y": 0, + "z": 7 + } + ], + "depth": 10, + "extDoorFrameRectName": "", + "extWallRectName": "", + "extWindowFrameRectName": "", + "floorRectName": "floor", + "friction": 0.5, + "furnitureCells": [], + "generationScript": "", + "height": 1, + "intDoorFrameRectName": "", + "intWallRectName": "", + "intWindowFrameRectName": "", + "roofSideRectName": "", + "roofTopRectName": "", + "width": 10 + }, + "children": [ + { + "children": [], + "clearArea": { + "clearCells": true, + "clearFurniture": true, + "clearRoofs": false, + "clearRooms": false, + "maxX": 8, + "maxY": 1, + "maxZ": 8, + "minX": -8, + "minY": 0, + "minZ": -8 + }, + "id": 103079215604, + "name": { + "name": "r1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + }, + { + "children": [], + "id": 103079215605, + "name": { + "name": "r2" + }, + "room": { + "connectedRoomIds": [], + "createCeiling": true, + "createFloor": true, + "createInteriorWalls": true, + "createWindows": false, + "exits": [ + false, + true, + false, + false + ], + "fillRoomWithFurniture": false, + "furnitureSeed": 42, + "furnitureYOffset": 0.05000000074505806, + "maxX": 2, + "maxY": 1, + "maxZ": 8, + "minX": -1, + "minY": 0, + "minZ": -2, + "persistentId": "room_1788648326350139622_498", + "roomType": "", + "tags": [] + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "id": 103079215603, + "name": { + "name": "interrior" + }, + "proceduralMaterial": { + "ambient": { + "b": 0.20000000298023224, + "g": 0.20000000298023224, + "r": 0.20000000298023224 + }, + "diffuse": { + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + "diffuseTextureId": "id_1788648541764281950_3", + "materialId": "id_1788648527415023537_1", + "materialName": "ProceduralMat_id_1788648527415023537_1", + "roughness": 0.5, + "shininess": 32.0, + "specular": { + "b": 0.0, + "g": 0.0, + "r": 0.0 + } + }, + "proceduralTexture": { + "colors": [ + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + }, + { + "a": 1.0, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + { + "a": 1.0, + "b": 1.0, + "g": 1.0, + "r": 1.0 + } + ], + "namedRects": [ + { + "name": "ceiling", + "u1": 0.10000000149011612, + "u2": 0.20000000298023224, + "v1": 0.0, + "v2": 0.10000000149011612 + }, + { + "name": "floor", + "u1": 0.0, + "u2": 0.10000000149011612, + "v1": 0.0, + "v2": 0.10000000149011612 + } + ], + "textureId": "id_1788648541764281950_3", + "textureName": "ProceduralTex_id_1788648541764281950_3", + "textureSize": 512, + "uvMargin": 0.009999999776482582 + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.15325629711151123, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + } + } + ], + "version": "1.0" +} \ No newline at end of file diff --git a/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json new file mode 100644 index 0000000..ec88016 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/demo_scene_b.json @@ -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": [] + } + ] +} diff --git a/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake b/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake new file mode 100644 index 0000000..fe23775 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching-extra/stage_runtime.cmake @@ -0,0 +1,50 @@ +# Stage everything demoSceneSwitchingExtra needs into its own directory so it +# runs standalone from +# /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() diff --git a/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt b/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt new file mode 100644 index 0000000..8e5f995 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching/CMakeLists.txt @@ -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 /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" +) diff --git a/src/features/editScene/demos/demo-scene-switching/demo_main.cpp b/src/features/editScene/demos/demo-scene-switching/demo_main.cpp new file mode 100644 index 0000000..a0a16b9 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching/demo_main.cpp @@ -0,0 +1,464 @@ +#include +#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 +#include +#include +#include + +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().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()) + return false; + + const TransformComponent &t = player.get(); + 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()) + return false; + + const TransformComponent &t = player.get(); + 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; +} diff --git a/src/features/editScene/demos/demo-scene-switching/demo_scene_a.json b/src/features/editScene/demos/demo-scene-switching/demo_scene_a.json new file mode 100644 index 0000000..16cb127 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching/demo_scene_a.json @@ -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": [] + } + ] +} diff --git a/src/features/editScene/demos/demo-scene-switching/demo_scene_b.json b/src/features/editScene/demos/demo-scene-switching/demo_scene_b.json new file mode 100644 index 0000000..ec88016 --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching/demo_scene_b.json @@ -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": [] + } + ] +} diff --git a/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake b/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake new file mode 100644 index 0000000..599098f --- /dev/null +++ b/src/features/editScene/demos/demo-scene-switching/stage_runtime.cmake @@ -0,0 +1,50 @@ +# Stage everything demoSceneSwitching needs into its own directory so it +# runs standalone from +# /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() diff --git a/src/features/editScene/lua-examples/behavior_tree_example.lua b/src/features/editScene/lua-examples/behavior_tree_example.lua index 64e8799..269e591 100644 --- a/src/features/editScene/lua-examples/behavior_tree_example.lua +++ b/src/features/editScene/lua-examples/behavior_tree_example.lua @@ -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" } +-- ============================================================================= diff --git a/src/features/editScene/lua-examples/scene_switch_example.lua b/src/features/editScene/lua-examples/scene_switch_example.lua new file mode 100644 index 0000000..27dc966 --- /dev/null +++ b/src/features/editScene/lua-examples/scene_switch_example.lua @@ -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. diff --git a/src/features/editScene/lua/LuaBehaviorTreeApi.cpp b/src/features/editScene/lua/LuaBehaviorTreeApi.cpp index 3124b3b..ba7b900 100644 --- a/src/features/editScene/lua/LuaBehaviorTreeApi.cpp +++ b/src/features/editScene/lua/LuaBehaviorTreeApi.cpp @@ -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"); diff --git a/src/features/editScene/lua/LuaBehaviorTreeApi.hpp b/src/features/editScene/lua/LuaBehaviorTreeApi.hpp index 120e480..39c88d5 100644 --- a/src/features/editScene/lua/LuaBehaviorTreeApi.hpp +++ b/src/features/editScene/lua/LuaBehaviorTreeApi.hpp @@ -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 diff --git a/src/features/editScene/lua/LuaSceneSwitchApi.cpp b/src/features/editScene/lua/LuaSceneSwitchApi.cpp new file mode 100644 index 0000000..94f8b80 --- /dev/null +++ b/src/features/editScene/lua/LuaSceneSwitchApi.cpp @@ -0,0 +1,110 @@ +#include "LuaSceneSwitchApi.hpp" +#include "../EditorApp.hpp" +#include + +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 diff --git a/src/features/editScene/lua/LuaSceneSwitchApi.hpp b/src/features/editScene/lua/LuaSceneSwitchApi.hpp new file mode 100644 index 0000000..35c608d --- /dev/null +++ b/src/features/editScene/lua/LuaSceneSwitchApi.hpp @@ -0,0 +1,40 @@ +#ifndef EDITSCENE_LUA_SCENESWITCH_API_HPP +#define EDITSCENE_LUA_SCENESWITCH_API_HPP +#pragma once + +#include + +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 diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index 568aa83..2d1c886 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -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; diff --git a/src/features/editScene/systems/BehaviorTreeSystem.cpp b/src/features/editScene/systems/BehaviorTreeSystem.cpp index f5223b6..eecbfa2 100644 --- a/src/features/editScene/systems/BehaviorTreeSystem.cpp +++ b/src/features/editScene/systems/BehaviorTreeSystem.cpp @@ -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); diff --git a/src/features/editScene/systems/BehaviorTreeSystem.hpp b/src/features/editScene/systems/BehaviorTreeSystem.hpp index 58f2b6a..b37dc43 100644 --- a/src/features/editScene/systems/BehaviorTreeSystem.hpp +++ b/src/features/editScene/systems/BehaviorTreeSystem.hpp @@ -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 m_runnerStates; std::unordered_map m_actionDebugStates; diff --git a/src/features/editScene/systems/CellGridSystem.cpp b/src/features/editScene/systems/CellGridSystem.cpp index eca00a0..220b8bd 100644 --- a/src/features/editScene/systems/CellGridSystem.cpp +++ b/src/features/editScene/systems/CellGridSystem.cpp @@ -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()) { + const auto &mat = + entity.get(); + if (mat.diffuseTextureEntity.is_valid() && + mat.diffuseTextureEntity.is_alive() && + mat.diffuseTextureEntity.has()) { + currentTextureEntity = + mat.diffuseTextureEntity; + const auto &tex = + mat.diffuseTextureEntity.get(); + 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()) { + const auto &mat = entity.get(); + 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()) { auto &lot = parent.get(); 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 deadGrids; + for (const auto &pair : m_entityMeshes) { + flecs::entity e = m_world.entity(pair.first); + if (!e.is_alive() || !e.has()) + 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(); + }); + sweepMap(m_lotBaseMeshes, [](flecs::entity e) { + return e.has(); + }); +} + void CellGridSystem::destroyCellGridMeshes(flecs::entity entity) { auto it = m_entityMeshes.find(entity.id()); diff --git a/src/features/editScene/systems/CellGridSystem.hpp b/src/features/editScene/systems/CellGridSystem.hpp index 4177f29..1d54b31 100644 --- a/src/features/editScene/systems/CellGridSystem.hpp +++ b/src/features/editScene/systems/CellGridSystem.hpp @@ -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, diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index 57af8d2..80dce6f 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -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()) { + const auto &t = e.get(); + 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; } diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.cpp b/src/features/editScene/systems/CharacterSpawnerSystem.cpp index 1ea9efa..4ecfb7e 100644 --- a/src/features/editScene/systems/CharacterSpawnerSystem.cpp +++ b/src/features/editScene/systems/CharacterSpawnerSystem.cpp @@ -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() && + character.has()) { + uint64_t rid = + character.get().registryId; + auto *rec = CharacterRegistry::getSingleton().findCharacter(rid); + if (rec) { + const auto &t = character.get(); + 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() || diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.hpp b/src/features/editScene/systems/CharacterSpawnerSystem.hpp index 9dee3a5..cdbe284 100644 --- a/src/features/editScene/systems/CharacterSpawnerSystem.hpp +++ b/src/features/editScene/systems/CharacterSpawnerSystem.hpp @@ -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); diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 86ec537..b3e607f 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -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()) { + auto &light = entity.get_mut(); + 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()) { auto &transform = entity.get_mut(); @@ -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); } diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index f957ca8..3fa9c51 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -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 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; diff --git a/src/features/editScene/systems/PlayerControllerSystem.cpp b/src/features/editScene/systems/PlayerControllerSystem.cpp index ea28482..75c03f2 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.cpp +++ b/src/features/editScene/systems/PlayerControllerSystem.cpp @@ -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()) { + const auto &t = state.targetEntity.get(); + 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; diff --git a/src/features/editScene/systems/PlayerControllerSystem.hpp b/src/features/editScene/systems/PlayerControllerSystem.hpp index 01e196f..f18fe91 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.hpp +++ b/src/features/editScene/systems/PlayerControllerSystem.hpp @@ -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(); diff --git a/src/features/editScene/systems/ProceduralTextureSystem.cpp b/src/features/editScene/systems/ProceduralTextureSystem.cpp index 416bd04..87b880f 100644 --- a/src/features/editScene/systems/ProceduralTextureSystem.cpp +++ b/src/features/editScene/systems/ProceduralTextureSystem.cpp @@ -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 diff --git a/src/features/editScene/tests/scene_switch_test.cpp b/src/features/editScene/tests/scene_switch_test.cpp new file mode 100644 index 0000000..6c1938e --- /dev/null +++ b/src/features/editScene/tests/scene_switch_test.cpp @@ -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 +#include +#include +#include +#include + +#include + +#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(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; +} diff --git a/src/features/editScene/ui/BehaviorTreeEditor.cpp b/src/features/editScene/ui/BehaviorTreeEditor.cpp index 8f83428..2b1b47a 100644 --- a/src/features/editScene/ui/BehaviorTreeEditor.cpp +++ b/src/features/editScene/ui/BehaviorTreeEditor.cpp @@ -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))) diff --git a/src/features/editScene/ui/CellGridEditor.cpp b/src/features/editScene/ui/CellGridEditor.cpp index cedae43..68c82f2 100644 --- a/src/features/editScene/ui/CellGridEditor.cpp +++ b/src/features/editScene/ui/CellGridEditor.cpp @@ -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()) { + const auto& mat = entity.get(); + if (mat.diffuseTextureEntity.is_alive() && + mat.diffuseTextureEntity.has()) { + textureEntity = mat.diffuseTextureEntity; + } + } flecs::entity parent = entity.parent(); - while (parent.is_alive()) { + while (!textureEntity.is_alive() && parent.is_alive()) { if (parent.has()) { auto& lot = parent.get(); if (lot.proceduralMaterialEntity.is_alive() && @@ -315,7 +324,10 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo } if (!textureEntity.is_alive() || !textureEntity.has()) { - 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:"); diff --git a/src/features/editScene/ui/InlineBehaviorTreeEditor.cpp b/src/features/editScene/ui/InlineBehaviorTreeEditor.cpp index 2858e51..6fe7174 100644 --- a/src/features/editScene/ui/InlineBehaviorTreeEditor.cpp +++ b/src/features/editScene/ui/InlineBehaviorTreeEditor.cpp @@ -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)))