Added more demos
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include <iostream>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <unordered_set>
|
||||
#include "EditorApp.hpp"
|
||||
#include "GameMode.hpp"
|
||||
#include <OgreMaterialManager.h>
|
||||
@@ -48,6 +51,7 @@
|
||||
#include "components/CharacterClassDatabase.hpp"
|
||||
#include "lua/LuaCharacterApi.hpp"
|
||||
#include "lua/LuaSaveLoadApi.hpp"
|
||||
#include "lua/LuaSceneSwitchApi.hpp"
|
||||
#include "systems/PlayerControllerSystem.hpp"
|
||||
#include "systems/SceneSerializer.hpp"
|
||||
#include "systems/SaveLoadSystem.hpp"
|
||||
@@ -176,6 +180,13 @@ void ImGuiRenderListener::preViewportUpdate(
|
||||
actuatorSys->render();
|
||||
}
|
||||
|
||||
/* Scene-switch loading cover: fullscreen black overlay for the
|
||||
* first frames of the new scene (hides T-posing characters and
|
||||
* half-built content). Foreground draw list so it covers all
|
||||
* other game-mode UI. */
|
||||
if (m_editorApp)
|
||||
m_editorApp->renderSceneSwitchCover();
|
||||
|
||||
// Render startup menu in game mode (inside ImGui frame scope)
|
||||
if (m_editorApp &&
|
||||
m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
|
||||
@@ -502,6 +513,7 @@ void EditorApp::setup()
|
||||
m_uiSystem->setEditorUIEnabled(
|
||||
m_gameMode == GameMode::Editor);
|
||||
m_uiSystem->setEditorCamera(m_camera.get());
|
||||
m_uiSystem->setEditorApp(this);
|
||||
}
|
||||
|
||||
// Setup physics system
|
||||
@@ -633,6 +645,8 @@ void EditorApp::setup()
|
||||
m_behaviorTreeSystem = std::make_unique<BehaviorTreeSystem>(
|
||||
m_world, m_sceneMgr, m_animationTreeSystem.get(),
|
||||
m_characterSystem.get());
|
||||
/* Wire up EditorApp for the "switchScene" BT node. */
|
||||
m_behaviorTreeSystem->setEditorApp(this);
|
||||
|
||||
// Setup NavMesh system
|
||||
m_navMeshSystem =
|
||||
@@ -818,6 +832,8 @@ void EditorApp::setup()
|
||||
editScene::registerLuaDialogueApi(L);
|
||||
editScene::registerLuaItemApi(L);
|
||||
editScene::registerLuaTerrainApi(L);
|
||||
editScene::registerLuaSceneSwitchApi(L);
|
||||
editScene::setSceneSwitchEditorApp(this);
|
||||
|
||||
// Scene scripts execute in this shared Lua state.
|
||||
SceneScriptSystem::init(L);
|
||||
@@ -980,83 +996,7 @@ void EditorApp::startNewGame(const Ogre::String &scenePath)
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"Game started: loaded scene " + scenePath);
|
||||
|
||||
// Set up player character: resolve the actual target character
|
||||
// (spawned instance if the controller targets a spawner) so the
|
||||
// character sheet and inventory are on the same entity that
|
||||
// ActuatorSystem uses.
|
||||
flecs::entity playerCharacter = getPlayerCharacterEntity();
|
||||
if (playerCharacter.is_valid() && playerCharacter.is_alive()) {
|
||||
if (!playerCharacter.has<CharacterIdentityComponent>()) {
|
||||
// Create a character record for the player
|
||||
uint64_t charId =
|
||||
CharacterRegistry::getSingleton()
|
||||
.createCharacter("Player",
|
||||
"One", "",
|
||||
false);
|
||||
// Link the entity to the registry record
|
||||
playerCharacter.set<CharacterIdentityComponent>(
|
||||
CharacterIdentityComponent{ charId });
|
||||
|
||||
// Set default class and initial stats from
|
||||
// the database
|
||||
auto *rec = CharacterRegistry::getSingleton()
|
||||
.findCharacter(charId);
|
||||
if (rec) {
|
||||
auto &db = CharacterClassDatabase::
|
||||
getSingleton();
|
||||
// Use "pclass" for the player if it
|
||||
// exists, otherwise fall back to the
|
||||
// first available class
|
||||
const auto &classNames =
|
||||
db.getClassNames();
|
||||
rec->className = "pclass";
|
||||
bool found = false;
|
||||
for (const auto &cn : classNames) {
|
||||
if (cn == "pclass") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && !classNames.empty()) {
|
||||
rec->className = classNames[0];
|
||||
}
|
||||
const auto *cls =
|
||||
db.findClass(rec->className);
|
||||
if (cls) {
|
||||
// Initialize stats from
|
||||
// baseStats
|
||||
for (const auto &pair :
|
||||
cls->baseStats) {
|
||||
rec->stats[pair.first] =
|
||||
pair.second;
|
||||
}
|
||||
// Initialize needs
|
||||
auto needNames =
|
||||
db.getNeedNames();
|
||||
for (const auto &n :
|
||||
needNames) {
|
||||
const auto *def =
|
||||
db.findNeed(n);
|
||||
if (def)
|
||||
rec->needs[n] =
|
||||
0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: created character record for player entity " +
|
||||
Ogre::StringConverter::toString(
|
||||
(int)playerCharacter.id()));
|
||||
}
|
||||
// Ensure player has an inventory for the character sheet
|
||||
if (!playerCharacter.has<InventoryComponent>()) {
|
||||
playerCharacter.set<InventoryComponent>(
|
||||
InventoryComponent());
|
||||
}
|
||||
} else {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: no player entity found in scene");
|
||||
}
|
||||
setupPlayerCharacter();
|
||||
|
||||
// Send "scene_ready" event after scene is loaded and
|
||||
// entities/components are populated and ready to run.
|
||||
@@ -1067,6 +1007,433 @@ void EditorApp::startNewGame(const Ogre::String &scenePath)
|
||||
}
|
||||
}
|
||||
|
||||
void EditorApp::setupPlayerCharacter()
|
||||
{
|
||||
// Set up player character: resolve the actual target character
|
||||
// (spawned instance if the controller targets a spawner) so the
|
||||
// character sheet and inventory are on the same entity that
|
||||
// ActuatorSystem uses.
|
||||
flecs::entity playerCharacter = getPlayerCharacterEntity();
|
||||
if (playerCharacter.is_valid() && playerCharacter.is_alive()) {
|
||||
if (!playerCharacter.has<CharacterIdentityComponent>()) {
|
||||
// Create a character record for the player
|
||||
uint64_t charId =
|
||||
CharacterRegistry::getSingleton()
|
||||
.createCharacter("Player", "One", "",
|
||||
false);
|
||||
// Link the entity to the registry record
|
||||
playerCharacter.set<CharacterIdentityComponent>(
|
||||
CharacterIdentityComponent{ charId });
|
||||
|
||||
// Set default class and initial stats from
|
||||
// the database
|
||||
auto *rec = CharacterRegistry::getSingleton()
|
||||
.findCharacter(charId);
|
||||
if (rec) {
|
||||
auto &db =
|
||||
CharacterClassDatabase::getSingleton();
|
||||
// Use "pclass" for the player if it
|
||||
// exists, otherwise fall back to the
|
||||
// first available class
|
||||
const auto &classNames = db.getClassNames();
|
||||
rec->className = "pclass";
|
||||
bool found = false;
|
||||
for (const auto &cn : classNames) {
|
||||
if (cn == "pclass") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found && !classNames.empty()) {
|
||||
rec->className = classNames[0];
|
||||
}
|
||||
const auto *cls = db.findClass(rec->className);
|
||||
if (cls) {
|
||||
// Initialize stats from
|
||||
// baseStats
|
||||
for (const auto &pair : cls->baseStats) {
|
||||
rec->stats[pair.first] =
|
||||
pair.second;
|
||||
}
|
||||
// Initialize needs
|
||||
auto needNames = db.getNeedNames();
|
||||
for (const auto &n : needNames) {
|
||||
const auto *def =
|
||||
db.findNeed(n);
|
||||
if (def)
|
||||
rec->needs[n] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: created character record for player entity " +
|
||||
Ogre::StringConverter::toString(
|
||||
(int)playerCharacter.id()));
|
||||
}
|
||||
// Ensure player has an inventory for the character sheet
|
||||
if (!playerCharacter.has<InventoryComponent>()) {
|
||||
playerCharacter.set<InventoryComponent>(
|
||||
InventoryComponent());
|
||||
}
|
||||
} else {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: no player entity found in scene");
|
||||
}
|
||||
}
|
||||
|
||||
bool EditorApp::switchScene(const Ogre::String &scenePath,
|
||||
const SceneSwitchOptions &opts)
|
||||
{
|
||||
if (scenePath.empty()) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp::switchScene: empty scene path");
|
||||
return false;
|
||||
}
|
||||
if (!std::filesystem::exists(scenePath)) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp::switchScene: file not found: " + scenePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Queue the request; the actual teardown + load runs at the top
|
||||
* of the next frame so callers (Lua scripts, ImGui menus) never
|
||||
* mutate the world mid-frame. A new request replaces a queued
|
||||
* one. */
|
||||
m_pendingSceneSwitchPath = scenePath;
|
||||
m_pendingSceneSwitchOptions = opts;
|
||||
m_sceneSwitchPending = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditorApp::processPendingSceneSwitch()
|
||||
{
|
||||
if (!m_sceneSwitchPending)
|
||||
return;
|
||||
m_sceneSwitchPending = false;
|
||||
performSceneSwitch(m_pendingSceneSwitchPath,
|
||||
m_pendingSceneSwitchOptions);
|
||||
}
|
||||
|
||||
void EditorApp::renderSceneSwitchCover()
|
||||
{
|
||||
if (m_gameMode != GameMode::Game || m_sceneSwitchCoverFrames <= 0)
|
||||
return;
|
||||
/* Headless runs have no ImGui context. */
|
||||
if (!ImGui::GetCurrentContext())
|
||||
return;
|
||||
|
||||
ImGuiViewport *vp = ImGui::GetMainViewport();
|
||||
ImDrawList *drawList = ImGui::GetForegroundDrawList();
|
||||
if (!vp || !drawList)
|
||||
return;
|
||||
|
||||
drawList->AddRectFilled(vp->Pos,
|
||||
ImVec2(vp->Pos.x + vp->Size.x,
|
||||
vp->Pos.y + vp->Size.y),
|
||||
IM_COL32(0, 0, 0, 255));
|
||||
|
||||
const char *text = "Loading...";
|
||||
ImVec2 textSize = ImGui::CalcTextSize(text);
|
||||
ImVec2 textPos(vp->Pos.x + (vp->Size.x - textSize.x) * 0.5f,
|
||||
vp->Pos.y + (vp->Size.y - textSize.y) * 0.5f);
|
||||
drawList->AddText(textPos, IM_COL32(255, 255, 255, 255), text);
|
||||
}
|
||||
|
||||
void EditorApp::destroySceneEntities()
|
||||
{
|
||||
/* Drop all controller state first so spawner locks, pivot nodes
|
||||
* and PlayerControlledComponent tags don't leak. */
|
||||
if (m_playerControllerSystem)
|
||||
m_playerControllerSystem->resetControllers();
|
||||
|
||||
/* Destroy both persistent scene entities (EditorMarkerComponent)
|
||||
* and runtime-spawned ones (RuntimeMarkerComponent: spawned
|
||||
* characters, dropped items). */
|
||||
std::unordered_set<flecs::entity_t> seen;
|
||||
std::vector<flecs::entity> toDelete;
|
||||
auto collect = [&](flecs::entity e) {
|
||||
if (e.is_alive() && seen.insert(e.id()).second)
|
||||
toDelete.push_back(e);
|
||||
};
|
||||
m_world.query<EditorMarkerComponent>().each(
|
||||
[&](flecs::entity e, EditorMarkerComponent) { collect(e); });
|
||||
m_world.query<RuntimeMarkerComponent>().each(
|
||||
[&](flecs::entity e, RuntimeMarkerComponent) { collect(e); });
|
||||
|
||||
for (auto &e : toDelete) {
|
||||
if (!e.is_alive())
|
||||
continue;
|
||||
if (m_uiSystem)
|
||||
m_uiSystem->deleteEntity(e);
|
||||
else
|
||||
e.destruct();
|
||||
}
|
||||
|
||||
/* Spawner bookkeeping is keyed by flecs entity id and ids are
|
||||
* recycled; clear it so stale entries can't alias entities of the
|
||||
* new scene (its entities were already destroyed above). */
|
||||
if (m_characterSpawnerSystem)
|
||||
m_characterSpawnerSystem->clearSpawnerState();
|
||||
|
||||
if (m_uiSystem)
|
||||
m_uiSystem->clearEntityCache();
|
||||
|
||||
/* Hand the viewport back to the editor camera in case a scene
|
||||
* camera owned it. */
|
||||
if (m_cameraSystem)
|
||||
m_cameraSystem->restoreOriginalCamera();
|
||||
}
|
||||
|
||||
/* Recursively scan a scene JSON entity (and its children) for camera
|
||||
* and playerController components. */
|
||||
static void scanSceneEntityJson(const nlohmann::json &entityJson,
|
||||
bool &hasCamera, bool &hasController)
|
||||
{
|
||||
if (entityJson.contains("camera"))
|
||||
hasCamera = true;
|
||||
if (entityJson.contains("playerController"))
|
||||
hasController = true;
|
||||
if (entityJson.contains("children") &&
|
||||
entityJson["children"].is_array()) {
|
||||
for (const auto &child : entityJson["children"])
|
||||
scanSceneEntityJson(child, hasCamera, hasController);
|
||||
}
|
||||
}
|
||||
|
||||
bool EditorApp::performSceneSwitch(const Ogre::String &scenePath,
|
||||
const SceneSwitchOptions &opts)
|
||||
{
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: switching scene to " + scenePath);
|
||||
|
||||
if (m_gameMode == GameMode::Editor) {
|
||||
/* Editor mode: behave as if the editor was started anew
|
||||
* and the scene loaded (unsaved changes are discarded). */
|
||||
destroySceneEntities();
|
||||
if (m_renderOriginSystem)
|
||||
m_renderOriginSystem->rebase(JPH::DVec3(0, 0, 0));
|
||||
if (m_camera)
|
||||
m_camera->resetPose();
|
||||
if (m_uiSystem) {
|
||||
/* loadScene wires the serializer, navigation
|
||||
* bookmarks and scene script events. */
|
||||
m_uiSystem->loadScene(scenePath);
|
||||
} else {
|
||||
SceneSerializer serializer(m_world, m_sceneMgr);
|
||||
serializer.loadFromFile(scenePath, nullptr);
|
||||
}
|
||||
m_currentBaseScene = scenePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --- Game mode --- */
|
||||
|
||||
/* Pre-scan the target scene JSON for camera/playerController
|
||||
* components to decide the takeover/carry-over behavior. */
|
||||
bool newHasCamera = false;
|
||||
bool newHasController = false;
|
||||
try {
|
||||
std::ifstream in(scenePath);
|
||||
nlohmann::json scene = nlohmann::json::parse(in);
|
||||
if (scene.contains("entities") && scene["entities"].is_array())
|
||||
for (const auto &ent : scene["entities"])
|
||||
scanSceneEntityJson(ent, newHasCamera,
|
||||
newHasController);
|
||||
} catch (const std::exception &e) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp::switchScene: failed to parse " + scenePath +
|
||||
": " + e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Carry the current player controller + character over only when
|
||||
* the new scene has no controller of its own. */
|
||||
flecs::entity controller = flecs::entity::null();
|
||||
m_world.query<EntityNameComponent>().each(
|
||||
[&](flecs::entity e, EntityNameComponent &name) {
|
||||
if (name.name == "player" &&
|
||||
e.has<PlayerControllerComponent>())
|
||||
controller = e;
|
||||
});
|
||||
|
||||
flecs::entity carriedCharacter = flecs::entity::null();
|
||||
const bool preservePlayer = !newHasController && controller.is_alive();
|
||||
if (preservePlayer) {
|
||||
carriedCharacter = getPlayerCharacterEntity();
|
||||
|
||||
/* Keep the controller and its character out of the scene
|
||||
* cleanup by stripping their markers. */
|
||||
if (controller.has<EditorMarkerComponent>())
|
||||
controller.remove<EditorMarkerComponent>();
|
||||
if (controller.has<RuntimeMarkerComponent>())
|
||||
controller.remove<RuntimeMarkerComponent>();
|
||||
if (carriedCharacter.is_alive()) {
|
||||
if (carriedCharacter.has<EditorMarkerComponent>())
|
||||
carriedCharacter
|
||||
.remove<EditorMarkerComponent>();
|
||||
if (carriedCharacter.has<RuntimeMarkerComponent>())
|
||||
carriedCharacter
|
||||
.remove<RuntimeMarkerComponent>();
|
||||
|
||||
/* If the character is spawner-owned, detach it from
|
||||
* the spawner (which dies with the old scene) and
|
||||
* point the controller at the character itself. */
|
||||
if (m_characterSpawnerSystem) {
|
||||
flecs::entity spawner =
|
||||
m_characterSpawnerSystem
|
||||
->getSpawnerForCharacter(
|
||||
carriedCharacter);
|
||||
if (spawner.is_alive()) {
|
||||
m_characterSpawnerSystem
|
||||
->releaseSpawned(spawner);
|
||||
if (carriedCharacter.has<
|
||||
EntityNameComponent>()) {
|
||||
controller.get_mut<
|
||||
PlayerControllerComponent>()
|
||||
.targetCharacterName =
|
||||
carriedCharacter.get<
|
||||
EntityNameComponent>()
|
||||
.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroySceneEntities();
|
||||
|
||||
SceneSerializer serializer(m_world, m_sceneMgr);
|
||||
if (!serializer.loadFromFile(scenePath, m_uiSystem.get())) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp: failed to load scene: " +
|
||||
serializer.getLastError());
|
||||
return false;
|
||||
}
|
||||
PrefabSystem prefabSys(m_world, m_sceneMgr);
|
||||
prefabSys.resolveInstances();
|
||||
SceneScriptSystem::loadPendingScripts(m_world);
|
||||
SceneScriptSystem::sendSceneLoaded(scenePath);
|
||||
m_currentBaseScene = scenePath;
|
||||
/* m_playTime keeps accumulating across the switch (same session). */
|
||||
setGamePlayState(GamePlayState::Playing);
|
||||
|
||||
setupPlayerCharacter();
|
||||
|
||||
/* Camera handoff: a scene camera takes over the viewport only
|
||||
* when no player controller is active after the switch (the
|
||||
* PlayerControllerSystem drives the editor camera otherwise).
|
||||
* Cameras are built lazily by EditorCameraSystem::update, so
|
||||
* process them now. */
|
||||
bool controllerActive = false;
|
||||
m_world.query<PlayerControllerComponent>().each(
|
||||
[&](flecs::entity, PlayerControllerComponent &) {
|
||||
controllerActive = true;
|
||||
});
|
||||
if (newHasCamera && !controllerActive && m_cameraSystem) {
|
||||
m_cameraSystem->processAllCameras();
|
||||
flecs::entity camEntity = flecs::entity::null();
|
||||
m_world.query<CameraComponent>().each(
|
||||
[&](flecs::entity e, CameraComponent &cc) {
|
||||
if (!camEntity.is_alive() && cc.camera)
|
||||
camEntity = e;
|
||||
});
|
||||
if (camEntity.is_alive())
|
||||
m_cameraSystem->setViewportCamera(
|
||||
camEntity.get<CameraComponent>().camera);
|
||||
}
|
||||
|
||||
/* Teleport the player character when requested. */
|
||||
flecs::entity playerCharacter = getPlayerCharacterEntity();
|
||||
if (playerCharacter.is_alive() &&
|
||||
playerCharacter.has<TransformComponent>()) {
|
||||
bool haveDestination = false;
|
||||
Ogre::Vector3 destRender = Ogre::Vector3::ZERO;
|
||||
Ogre::Quaternion destRot = Ogre::Quaternion::IDENTITY;
|
||||
|
||||
if (!opts.targetEntityName.empty()) {
|
||||
flecs::entity target = flecs::entity::null();
|
||||
m_world.query<EntityNameComponent, TransformComponent>()
|
||||
.each([&](flecs::entity e,
|
||||
EntityNameComponent &en,
|
||||
TransformComponent &) {
|
||||
if (en.name == opts.targetEntityName)
|
||||
target = e;
|
||||
});
|
||||
if (target.is_alive()) {
|
||||
auto &tt = target.get<TransformComponent>();
|
||||
destRender = tt.node ?
|
||||
tt.node->_getDerivedPosition() :
|
||||
tt.position;
|
||||
destRot = tt.node ?
|
||||
tt.node->_getDerivedOrientation() :
|
||||
tt.rotation;
|
||||
haveDestination = true;
|
||||
} else {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"EditorApp::switchScene: target entity '" +
|
||||
opts.targetEntityName + "' not found");
|
||||
}
|
||||
} else if (opts.hasPosition) {
|
||||
/* API positions are absolute world space. */
|
||||
destRender = m_renderOriginSystem ?
|
||||
m_renderOriginSystem->worldToRender(
|
||||
opts.posX, opts.posY,
|
||||
opts.posZ) :
|
||||
Ogre::Vector3((float)opts.posX,
|
||||
(float)opts.posY,
|
||||
(float)opts.posZ);
|
||||
destRot = opts.hasRotation ? opts.rotation :
|
||||
Ogre::Quaternion::IDENTITY;
|
||||
haveDestination = true;
|
||||
}
|
||||
|
||||
if (haveDestination) {
|
||||
/* Never spawn below the terrain surface. */
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (ts && ts->isActive()) {
|
||||
float ground = ts->getHeightAt(destRender);
|
||||
if (destRender.y < ground + 0.05f)
|
||||
destRender.y = ground + 0.05f;
|
||||
}
|
||||
|
||||
auto &t =
|
||||
playerCharacter.get_mut<TransformComponent>();
|
||||
t.position = destRender;
|
||||
t.rotation = destRot;
|
||||
if (m_renderOriginSystem) {
|
||||
JPH::DVec3 world =
|
||||
m_renderOriginSystem->renderToWorld(
|
||||
destRender);
|
||||
t.worldX = world.GetX();
|
||||
t.worldY = world.GetY();
|
||||
t.worldZ = world.GetZ();
|
||||
t.hasWorldPosition = true;
|
||||
}
|
||||
t.applyToNode();
|
||||
|
||||
if (playerCharacter.has<CharacterComponent>()) {
|
||||
playerCharacter.get_mut<CharacterComponent>()
|
||||
.linearVelocity = Ogre::Vector3::ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
/* Arm the grounding watchdog whenever a player character
|
||||
* exists after the switch: the terrain physics colliders
|
||||
* (streaming pages) may not exist for a few frames. */
|
||||
m_groundingEntity = playerCharacter.id();
|
||||
m_groundingFramesLeft = 120;
|
||||
}
|
||||
|
||||
/* Cover the first frames of the new scene: freshly spawned
|
||||
* characters are still in T-pose until the animation system
|
||||
* applies a state, and streaming content may need a few frames
|
||||
* to build. */
|
||||
m_sceneSwitchCoverFrames = SCENE_SWITCH_COVER_FRAMES;
|
||||
|
||||
EventBus::getInstance().send("scene_ready");
|
||||
return true;
|
||||
}
|
||||
|
||||
void EditorApp::saveGame(const std::string &slotPath,
|
||||
const std::string &slotName)
|
||||
{
|
||||
@@ -1645,6 +2012,51 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
|
||||
{
|
||||
bool paused = (m_gamePlayState == GamePlayState::Paused);
|
||||
|
||||
/* A queued scene switch runs before anything else so systems
|
||||
* below already see the new scene. */
|
||||
processPendingSceneSwitch();
|
||||
|
||||
/* Count down the scene-switch loading cover. */
|
||||
if (m_sceneSwitchCoverFrames > 0)
|
||||
m_sceneSwitchCoverFrames--;
|
||||
|
||||
/* Grounding watchdog: after a game-mode scene switch the terrain
|
||||
* physics colliders (streaming pages) may not be built yet; keep
|
||||
* the player character at or above the terrain surface until it
|
||||
* reports a floor or the watchdog times out. */
|
||||
if (m_groundingFramesLeft > 0) {
|
||||
m_groundingFramesLeft--;
|
||||
flecs::entity e = m_world.entity(m_groundingEntity);
|
||||
if (!e.is_alive() || !e.has<TransformComponent>()) {
|
||||
m_groundingFramesLeft = 0;
|
||||
} else {
|
||||
bool grounded = e.has<CharacterComponent>() &&
|
||||
e.get<CharacterComponent>().hasFloor;
|
||||
TerrainSystem *ts = TerrainSystem::getInstance();
|
||||
if (grounded || !ts || !ts->isActive()) {
|
||||
m_groundingFramesLeft = 0;
|
||||
} else {
|
||||
auto &t = e.get_mut<TransformComponent>();
|
||||
Ogre::Vector3 renderPos =
|
||||
t.node ? t.node->_getDerivedPosition() :
|
||||
t.position;
|
||||
float ground = ts->getHeightAt(renderPos);
|
||||
if (renderPos.y < ground + 0.02f) {
|
||||
renderPos.y = ground + 0.05f;
|
||||
t.position = renderPos;
|
||||
t.applyToNode();
|
||||
if (e.has<CharacterComponent>()) {
|
||||
auto &ch = e.get_mut<
|
||||
CharacterComponent>();
|
||||
if (ch.linearVelocity.y < 0.0f)
|
||||
ch.linearVelocity.y =
|
||||
0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Render origin rebase runs first so every system below sees the
|
||||
* post-rebase render space. */
|
||||
if (m_renderOriginSystem)
|
||||
|
||||
@@ -124,6 +124,25 @@ private:
|
||||
int m_lastBatchCount = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for EditorApp::switchScene(). Positions are absolute
|
||||
* world-space doubles (see systems/RenderOriginSystem).
|
||||
*/
|
||||
struct SceneSwitchOptions {
|
||||
/* Explicit destination for the player character (world space). */
|
||||
bool hasPosition = false;
|
||||
double posX = 0.0;
|
||||
double posY = 0.0;
|
||||
double posZ = 0.0;
|
||||
bool hasRotation = false;
|
||||
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
|
||||
|
||||
/* Name of an entity with a TransformComponent in the NEW scene.
|
||||
* When set, the player character is teleported to that entity's
|
||||
* transform (takes precedence over position/rotation). */
|
||||
Ogre::String targetEntityName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main application class for the scene editor / game
|
||||
*/
|
||||
@@ -193,6 +212,39 @@ public:
|
||||
void startNewGame(const Ogre::String &scenePath);
|
||||
void clearScene();
|
||||
|
||||
/**
|
||||
* Queue a switch to another scene, executed at the start of the
|
||||
* next frame. In editor mode the current scene is completely
|
||||
* destroyed and the new one loaded as if the editor was restarted.
|
||||
* In game mode the player controller and camera are carried over
|
||||
* when the new scene has none of its own; otherwise the new
|
||||
* scene's controller/camera take over. opts can teleport the
|
||||
* player character to a world-space position/rotation or to a
|
||||
* named entity with a TransformComponent in the new scene.
|
||||
* Returns false only for obvious errors (empty/unreadable path);
|
||||
* load errors are reported via the log.
|
||||
*/
|
||||
bool switchScene(const Ogre::String &scenePath,
|
||||
const SceneSwitchOptions &opts = SceneSwitchOptions{});
|
||||
|
||||
/* Introspection/cancel for a queued scene switch (tests, debug). */
|
||||
bool hasPendingSceneSwitch() const
|
||||
{
|
||||
return m_sceneSwitchPending;
|
||||
}
|
||||
const Ogre::String &getPendingSceneSwitchPath() const
|
||||
{
|
||||
return m_pendingSceneSwitchPath;
|
||||
}
|
||||
const SceneSwitchOptions &getPendingSceneSwitchOptions() const
|
||||
{
|
||||
return m_pendingSceneSwitchOptions;
|
||||
}
|
||||
void clearPendingSceneSwitch()
|
||||
{
|
||||
m_sceneSwitchPending = false;
|
||||
}
|
||||
|
||||
// Save / Load
|
||||
void saveGame(const std::string &slotPath, const std::string &slotName);
|
||||
void loadGame(const std::string &slotPath);
|
||||
@@ -260,6 +312,11 @@ public:
|
||||
{
|
||||
return m_actuatorSystem.get();
|
||||
}
|
||||
|
||||
/* Draws the fullscreen "Loading" cover shown for a few frames
|
||||
* after a game-mode scene switch (hides T-posing characters and
|
||||
* half-built content). Called from ImGuiRenderListener. */
|
||||
void renderSceneSwitchCover();
|
||||
EventHandlerSystem *getEventHandlerSystem() const
|
||||
{
|
||||
return m_eventHandlerSystem.get();
|
||||
@@ -344,6 +401,33 @@ private:
|
||||
float m_playTime = 0.0f;
|
||||
std::string m_currentBaseScene;
|
||||
|
||||
/* Scene switch state. switchScene() only queues a request; the
|
||||
* actual teardown + load runs at the top of the next
|
||||
* frameRenderingQueued() so callers (Lua scripts, ImGui menus)
|
||||
* never mutate the world mid-frame. */
|
||||
bool m_sceneSwitchPending = false;
|
||||
Ogre::String m_pendingSceneSwitchPath;
|
||||
SceneSwitchOptions m_pendingSceneSwitchOptions;
|
||||
|
||||
/* Grounding watchdog: after a game-mode scene switch the terrain
|
||||
* physics colliders may not exist yet; keep the player character
|
||||
* at or above the terrain surface until it reports a floor. */
|
||||
flecs::entity_t m_groundingEntity = 0;
|
||||
int m_groundingFramesLeft = 0;
|
||||
|
||||
/* Fullscreen loading cover shown after a game-mode scene switch
|
||||
* (frames remaining). Hides freshly spawned characters before
|
||||
* their animation state is applied (T-pose) and any content that
|
||||
* takes a few frames to build. */
|
||||
int m_sceneSwitchCoverFrames = 0;
|
||||
static const int SCENE_SWITCH_COVER_FRAMES = 45;
|
||||
|
||||
void processPendingSceneSwitch();
|
||||
bool performSceneSwitch(const Ogre::String &scenePath,
|
||||
const SceneSwitchOptions &opts);
|
||||
void destroySceneEntities();
|
||||
void setupPlayerCharacter();
|
||||
|
||||
// Lua scripting
|
||||
editScene::LuaState m_lua;
|
||||
|
||||
|
||||
@@ -267,3 +267,21 @@ void EditorCamera::updateCameraPosition()
|
||||
m_cameraMan->setYawPitchDist(Ogre::Degree(m_yaw), Ogre::Degree(m_pitch),
|
||||
m_distance);
|
||||
}
|
||||
|
||||
void EditorCamera::resetPose()
|
||||
{
|
||||
m_position = Ogre::Vector3(0, 5, 15);
|
||||
m_target = Ogre::Vector3(0, 0, 0);
|
||||
m_distance = 15.0f;
|
||||
m_yaw = 0.0f;
|
||||
m_pitch = -20.0f;
|
||||
m_fpsMode = false;
|
||||
m_rotating = false;
|
||||
m_panning = false;
|
||||
m_keyW = m_keyS = m_keyA = m_keyD = m_keyQ = m_keyE = false;
|
||||
m_keyShift = false;
|
||||
|
||||
m_cameraMan->setStyle(OgreBites::CS_ORBIT);
|
||||
m_cameraMan->setTarget(m_targetNode);
|
||||
updateCameraPosition();
|
||||
}
|
||||
|
||||
@@ -107,6 +107,14 @@ public:
|
||||
return m_fpsMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the camera to the default startup pose (position (0,5,15),
|
||||
* target origin, yaw 0, pitch -20, distance 15) and clear any input
|
||||
* state. Used by scene switching so an editor-mode scene switch
|
||||
* looks like a fresh editor start.
|
||||
*/
|
||||
void resetPose();
|
||||
|
||||
private:
|
||||
void updateCameraPosition();
|
||||
void updateFPSMovement(float deltaTime);
|
||||
|
||||
@@ -51,6 +51,19 @@
|
||||
* (for quest rewards, etc.).
|
||||
* params="itemId,itemName,itemType,count,weight,value"
|
||||
*
|
||||
* --- Scene switch node ---
|
||||
* "switchScene" - Leaf: queues a scene switch via
|
||||
* EditorApp::switchScene (name=scene file path).
|
||||
* The switch is deferred to the start of the next
|
||||
* frame, so it is safe from AI and actuator trees.
|
||||
* params (optional):
|
||||
* "@EntityName" - teleport the player to a named
|
||||
* entity with a Transform component
|
||||
* in the new scene
|
||||
* "x,y,z" - teleport to a world-space position
|
||||
* "x,y,z,yaw" - position + yaw in degrees
|
||||
* Returns success when the switch was queued.
|
||||
*
|
||||
* --- Lua node ---
|
||||
* "luaTask" - Leaf: calls a registered Lua function.
|
||||
* name = registered node handler name.
|
||||
@@ -105,6 +118,7 @@ struct BehaviorTreeNode {
|
||||
type == "hasItemByName" || type == "countItem" ||
|
||||
type == "pickupItem" || type == "dropItem" ||
|
||||
type == "useItem" || type == "addItemToInventory" ||
|
||||
type == "switchScene" ||
|
||||
type == "luaTask";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# demo-scene-switching-extra — scene switching demo with a CellGrid interior
|
||||
# ---------------------------------------------------------------------------
|
||||
# Same actuator-driven scene switching setup as demos/demo-scene-switching,
|
||||
# but scene A additionally holds an "interrior" entity with a
|
||||
# CellGridComponent (a room with a floor, ceiling, interior walls and an
|
||||
# exit door) that carries its own ProceduralMaterial + ProceduralTexture.
|
||||
# The grid's texture rectangle names reference the texture's named rects
|
||||
# ("floor" / "ceiling"), demonstrating material/UV pickup from a grid entity
|
||||
# without a Lot/District/Town parent.
|
||||
#
|
||||
# The executable is self-contained in this build directory: a POST_BUILD
|
||||
# step (stage_runtime.cmake) copies resources.cfg, both demo scenes, the
|
||||
# character prefab (prefabs/char_2.json, written by a previous editor/game
|
||||
# run) and any runtime config JSONs here, and symlinks the big pre-staged
|
||||
# runtime directories (resources/, characters/, lua-scripts/) from the
|
||||
# editScene binary directory. Run it from here:
|
||||
# cd <build>/src/features/editScene/demos/demo-scene-switching-extra
|
||||
# ./demoSceneSwitchingExtra
|
||||
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
|
||||
# Escape = pause menu (frees the cursor).
|
||||
|
||||
get_filename_component(EDITSCENE_SOURCE_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
get_filename_component(EDITSCENE_BINARY_DIR
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
|
||||
|
||||
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
|
||||
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
|
||||
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
|
||||
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
|
||||
|
||||
add_executable(demoSceneSwitchingExtra
|
||||
demo_main.cpp
|
||||
${DEMO_SOURCES}
|
||||
)
|
||||
|
||||
add_dependencies(demoSceneSwitchingExtra morph)
|
||||
|
||||
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
|
||||
target_compile_definitions(demoSceneSwitchingExtra PRIVATE JPH_DEBUG_RENDERER)
|
||||
|
||||
target_link_libraries(demoSceneSwitchingExtra
|
||||
OgreMain
|
||||
OgreBites
|
||||
OgreOverlay
|
||||
OgreMeshLodGenerator
|
||||
OgrePaging
|
||||
OgreTerrain
|
||||
flecs::flecs_static
|
||||
nlohmann_json::nlohmann_json
|
||||
Jolt::Jolt
|
||||
OgreProcedural::OgreProcedural
|
||||
RecastNavigation::Recast
|
||||
RecastNavigation::Detour
|
||||
RecastNavigation::DetourTileCache
|
||||
RecastNavigation::DetourCrowd
|
||||
RecastNavigation::DebugUtils
|
||||
PackageArchive
|
||||
RoadGeometryLib
|
||||
lua
|
||||
SDL2::SDL2
|
||||
)
|
||||
|
||||
target_include_directories(demoSceneSwitchingExtra PRIVATE
|
||||
${EDITSCENE_SOURCE_DIR}
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
|
||||
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
|
||||
)
|
||||
|
||||
# Stage the standalone runtime next to the executable (see header comment).
|
||||
add_custom_command(TARGET demoSceneSwitchingExtra POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
|
||||
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
|
||||
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
|
||||
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
|
||||
COMMENT "Staging demo-scene-switching-extra standalone runtime"
|
||||
)
|
||||
@@ -0,0 +1,469 @@
|
||||
#include <iostream>
|
||||
#include "EditorApp.hpp"
|
||||
#include "camera/EditorCamera.hpp"
|
||||
#include "systems/CharacterRegistry.hpp"
|
||||
#include "systems/BehaviorTreeSystem.hpp"
|
||||
#include "components/ActionDatabase.hpp"
|
||||
#include "components/EntityName.hpp"
|
||||
#include "components/Transform.hpp"
|
||||
#include <Ogre.h>
|
||||
#include <OgreRoot.h>
|
||||
#include <ProceduralBoxGenerator.h>
|
||||
#include <ProceduralMeshGenerator.h>
|
||||
|
||||
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
|
||||
Ogre::Root *root;
|
||||
bool triggered = false;
|
||||
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
if (!triggered) {
|
||||
triggered = true;
|
||||
root->queueEndRendering();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/* Same application as the editor/game, but with a larger initial window. */
|
||||
class DemoApp : public EditorApp {
|
||||
public:
|
||||
OgreBites::NativeWindowPair
|
||||
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
|
||||
Ogre::NameValuePairList miscParams) override
|
||||
{
|
||||
(void)w;
|
||||
(void)h;
|
||||
return EditorApp::createWindow(name, 1920, 1080, miscParams);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* The demo floor meshes and their flat colored materials are created
|
||||
* programmatically because RenderableComponent only references a mesh by
|
||||
* name and carries no material/color of its own. Scene A references the
|
||||
* green "DemoFloorPlaneA", scene B the blue "DemoFloorPlaneB" so it is
|
||||
* obvious which scene is currently loaded.
|
||||
*/
|
||||
static void createFloorMesh(const Ogre::String &meshName,
|
||||
const Ogre::String &materialName,
|
||||
float dr, float dg, float db)
|
||||
{
|
||||
const Ogre::String group =
|
||||
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
|
||||
|
||||
if (!Ogre::MeshManager::getSingleton()
|
||||
.getByName(meshName, group)
|
||||
.isNull())
|
||||
return;
|
||||
|
||||
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
|
||||
materialName, group);
|
||||
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
|
||||
pass->setDiffuse(dr, dg, db, 1.0f);
|
||||
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
|
||||
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
|
||||
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
|
||||
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
|
||||
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
|
||||
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
|
||||
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
|
||||
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
|
||||
mesh->getSubMesh(0)->setMaterialName(materialName);
|
||||
}
|
||||
|
||||
/*
|
||||
* Visible pillar meshes marking the actuator positions ("portal_a" in
|
||||
* scene A, "portal_b" in scene B). The ActuatorSystem already draws a
|
||||
* screen-space indicator, but a physical marker makes the spot visible
|
||||
* from across the floor. The box is 0.6 x 1.6 x 0.6 centered on the
|
||||
* entity origin, so the entities sit at y = 0.8.
|
||||
*/
|
||||
static void createMarkerMesh(const Ogre::String &meshName,
|
||||
const Ogre::String &materialName,
|
||||
float dr, float dg, float db)
|
||||
{
|
||||
const Ogre::String group =
|
||||
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
|
||||
|
||||
if (!Ogre::MeshManager::getSingleton()
|
||||
.getByName(meshName, group)
|
||||
.isNull())
|
||||
return;
|
||||
|
||||
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
|
||||
materialName, group);
|
||||
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
|
||||
pass->setDiffuse(dr, dg, db, 1.0f);
|
||||
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
|
||||
pass->setSpecular(0.1f, 0.1f, 0.1f, 1.0f);
|
||||
|
||||
Procedural::BoxGenerator boxGen;
|
||||
boxGen.setSizeX(0.6f).setSizeY(1.6f).setSizeZ(0.6f);
|
||||
Ogre::MeshPtr mesh = boxGen.realizeMesh(meshName, group);
|
||||
if (mesh && mesh->getNumSubMeshes() > 0)
|
||||
mesh->getSubMesh(0)->setMaterialName(materialName);
|
||||
}
|
||||
|
||||
static void createDemoResources()
|
||||
{
|
||||
/* Scene A: green floor, orange portal marker. */
|
||||
createFloorMesh("DemoFloorPlaneA", "DemoFloorMaterialA",
|
||||
0.35f, 0.5f, 0.35f);
|
||||
createMarkerMesh("DemoActuatorMarkerA", "DemoActuatorMarkerMaterialA",
|
||||
0.9f, 0.5f, 0.1f);
|
||||
|
||||
/* Scene B: blue floor, magenta portal marker. */
|
||||
createFloorMesh("DemoFloorPlaneB", "DemoFloorMaterialB",
|
||||
0.3f, 0.4f, 0.6f);
|
||||
createMarkerMesh("DemoActuatorMarkerB", "DemoActuatorMarkerMaterialB",
|
||||
0.8f, 0.2f, 0.6f);
|
||||
}
|
||||
|
||||
/*
|
||||
* End-to-end check for --test-switch: drives the same path an E-press on
|
||||
* the actuator would take (ActionDatabase action -> behavior tree ->
|
||||
* "switchScene" node -> EditorApp::switchScene() queue ->
|
||||
* performSceneSwitch() on the next frame with the "@arrival_*" teleport),
|
||||
* for a full A -> B -> A round trip. The prompt/targeting glue is
|
||||
* screen-space and therefore not covered headless. The ActuatorSystem is
|
||||
* not involved because its interaction path needs an ImGui context; the
|
||||
* tree is evaluated through a local BehaviorTreeSystem exactly like
|
||||
* ActuatorSystem::isActionComplete() does.
|
||||
*/
|
||||
struct SceneSwitchTestListener : public Ogre::FrameListener {
|
||||
EditorApp *app;
|
||||
BehaviorTreeSystem *bt;
|
||||
int frame = 0;
|
||||
int phase = 0;
|
||||
bool failed = false;
|
||||
Ogre::String failReason;
|
||||
|
||||
SceneSwitchTestListener(EditorApp *a, BehaviorTreeSystem *b)
|
||||
: app(a), bt(b)
|
||||
{
|
||||
}
|
||||
|
||||
bool entityExists(const char *name)
|
||||
{
|
||||
bool found = false;
|
||||
app->getWorld()->query<EntityNameComponent>().each(
|
||||
[&](flecs::entity, EntityNameComponent &n) {
|
||||
if (n.name == name)
|
||||
found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Returns false while the player is not available yet (retry).
|
||||
* Hard failures set failed/failReason. */
|
||||
bool runAction(const char *actionName, const char *expectPath)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive())
|
||||
return false;
|
||||
|
||||
ActionDatabase *db = ActionDatabase::getSingletonPtr();
|
||||
const GoapAction *action =
|
||||
db ? db->findAction(actionName) : nullptr;
|
||||
if (!action) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("action not found: ") +
|
||||
actionName;
|
||||
return true;
|
||||
}
|
||||
|
||||
BehaviorTreeSystem::Status status =
|
||||
bt->evaluatePlayerAction(player.id(),
|
||||
action->behaviorTree, 0.016f,
|
||||
true);
|
||||
if (status != BehaviorTreeSystem::Status::success) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("action did not succeed: ") +
|
||||
actionName;
|
||||
return true;
|
||||
}
|
||||
if (!app->hasPendingSceneSwitch() ||
|
||||
app->getPendingSceneSwitchPath() != expectPath) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("no pending switch to ") +
|
||||
expectPath;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() || !player.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
const TransformComponent &t = player.get<TransformComponent>();
|
||||
Ogre::Vector3 pos =
|
||||
t.node ? t.node->_getDerivedPosition() : t.position;
|
||||
if (pos.distance(expected) > tol) {
|
||||
failed = true;
|
||||
failReason = "player not at arrival point: (" +
|
||||
Ogre::StringConverter::toString(pos.x) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(pos.y) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(pos.z) +
|
||||
")";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The arrival markers sit at (0, 0, 24) facing -Z (toward the
|
||||
* floor center); verify the camera took a position behind the
|
||||
* character looking at the walking surface with the portal
|
||||
* pillar (z = 28) behind the camera, and that the character's
|
||||
* visual facing (local +Z) points at the center too. */
|
||||
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() || !player.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
const TransformComponent &t = player.get<TransformComponent>();
|
||||
Ogre::Quaternion q =
|
||||
t.node ? t.node->getOrientation() : t.rotation;
|
||||
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
|
||||
|
||||
EditorCamera *ec = app->getEditorCamera();
|
||||
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
|
||||
Ogre::SceneNode *camNode =
|
||||
cam ? cam->getParentSceneNode() : nullptr;
|
||||
if (!camNode)
|
||||
return false;
|
||||
Ogre::Vector3 cp = camNode->_getDerivedPosition();
|
||||
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
|
||||
Ogre::Vector3::NEGATIVE_UNIT_Z;
|
||||
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
|
||||
charFacing.z > -0.9f) {
|
||||
failed = true;
|
||||
failReason =
|
||||
"not facing the walking surface: camera pos (" +
|
||||
Ogre::StringConverter::toString(cp.x) + ", " +
|
||||
Ogre::StringConverter::toString(cp.y) + ", " +
|
||||
Ogre::StringConverter::toString(cp.z) +
|
||||
") view (" +
|
||||
Ogre::StringConverter::toString(vd.x) + ", " +
|
||||
Ogre::StringConverter::toString(vd.y) + ", " +
|
||||
Ogre::StringConverter::toString(vd.z) +
|
||||
") character facing (" +
|
||||
Ogre::StringConverter::toString(charFacing.x) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(charFacing.z) +
|
||||
")";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
frame++;
|
||||
if (failed || phase == 3) {
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
if (frame > 1200) {
|
||||
failed = true;
|
||||
failReason = "timeout waiting for scene switch";
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 0:
|
||||
if (frame < 5)
|
||||
break; /* let the character spawn */
|
||||
if (!runAction("goto_scene_b", "demo_scene_b.json"))
|
||||
break;
|
||||
if (!failed)
|
||||
std::cout << "[test] queued switch A -> B"
|
||||
<< std::endl;
|
||||
phase = 1;
|
||||
break;
|
||||
case 1:
|
||||
if (!entityExists("portal_b"))
|
||||
break;
|
||||
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
|
||||
1.0f))
|
||||
break;
|
||||
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
|
||||
24.0f)))
|
||||
break;
|
||||
if (failed)
|
||||
break;
|
||||
std::cout << "[test] arrived in scene B at arrival_b, "
|
||||
"camera faces the walking surface"
|
||||
<< std::endl;
|
||||
if (!runAction("goto_scene_a", "demo_scene_a.json"))
|
||||
break;
|
||||
if (!failed)
|
||||
std::cout << "[test] queued switch B -> A"
|
||||
<< std::endl;
|
||||
phase = 2;
|
||||
break;
|
||||
case 2:
|
||||
if (!entityExists("portal_a"))
|
||||
break;
|
||||
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
|
||||
1.0f))
|
||||
break;
|
||||
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
|
||||
24.0f)))
|
||||
break;
|
||||
if (!failed) {
|
||||
std::cout << "[test] arrived back in scene A "
|
||||
"at arrival_a, camera faces the "
|
||||
"walking surface"
|
||||
<< std::endl;
|
||||
std::cout << "[test] PASS" << std::endl;
|
||||
}
|
||||
phase = 3;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* demo-scene-switching-extra: runs the editScene game mode with two small
|
||||
* hand-authored scenes (demo_scene_a.json and demo_scene_b.json), each
|
||||
* containing a flat colored floor plane with a static physics collider,
|
||||
* a character spawner ("s1", character registry ID 2, same character
|
||||
* setup as town8.json), a PlayerControllerComponent targeting it and an
|
||||
* actuator ("portal_a" / "portal_b", marked by a colored pillar).
|
||||
* Scene A additionally holds an "interrior" entity with a
|
||||
* CellGridComponent (a room with floor, ceiling, interior walls and an
|
||||
* exit door) carrying its own ProceduralMaterial + ProceduralTexture;
|
||||
* the grid's texture rectangle names reference the texture's named rects
|
||||
* ("floor" / "ceiling") without any Lot/District/Town parent.
|
||||
*
|
||||
* Each actuator names one action ("goto_scene_b" / "goto_scene_a") whose
|
||||
* behavior tree is defined in the scene's top-level "actionDatabase"
|
||||
* block: a sequence ending in a "switchScene" node that queues an
|
||||
* EditorApp::switchScene() to the other scene with a
|
||||
* "@arrival_a"/"@arrival_b" teleport target, so the player reappears
|
||||
* right next to the return portal. Walking into the pillar's radius
|
||||
* shows the "E goto_scene_*" prompt; pressing E runs the tree and the
|
||||
* scene switch executes at the start of the next frame. Both scenes
|
||||
* carry their own player controller, so each switch is a clean takeover
|
||||
* (see EditorApp::performSceneSwitch); travel back and forth is endless.
|
||||
*
|
||||
* The mouse is grabbed while Playing (built-in game-mode behaviour);
|
||||
* Escape toggles the pause menu, which frees the cursor.
|
||||
*
|
||||
* The binary is self-contained in its build directory: run it from there
|
||||
* (resources.cfg, both scene JSONs, resources/, characters/, lua-scripts/,
|
||||
* prefabs/ and the runtime config JSONs are staged next to it by the
|
||||
* build).
|
||||
*
|
||||
* Extra flags:
|
||||
* --test-switch headless-friendly end-to-end check: executes both
|
||||
* portal actions and verifies the A -> B -> A round
|
||||
* trip and the arrival teleports; exits 0 on PASS.
|
||||
*/
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
try {
|
||||
DemoApp app;
|
||||
app.setGameMode(EditorApp::GameMode::Game);
|
||||
|
||||
bool headless = false;
|
||||
bool exitAfterFirstFrame = false;
|
||||
bool testSwitch = false;
|
||||
Ogre::String sceneFile = "demo_scene_a.json";
|
||||
for (int i = 1; i < argc; i++) {
|
||||
Ogre::String arg = argv[i];
|
||||
if (arg == "--headless") {
|
||||
headless = true;
|
||||
} else if (arg == "--exit-after-first-frame") {
|
||||
exitAfterFirstFrame = true;
|
||||
} else if (arg == "--test-switch") {
|
||||
testSwitch = true;
|
||||
} else if (arg.length() > 0 && arg[0] != '-') {
|
||||
sceneFile = arg;
|
||||
}
|
||||
}
|
||||
app.setHeadless(headless);
|
||||
|
||||
app.initApp();
|
||||
|
||||
if (headless) {
|
||||
/* Headless mode never creates EditorUISystem, which
|
||||
* owns the CharacterRegistry singleton; the demo scene
|
||||
* has a character spawner, so provide a bare registry
|
||||
* to keep spawner resolution from asserting. The
|
||||
* character then falls back to an inline spawn without
|
||||
* a physics capsule (fine for a smoke run). */
|
||||
static CharacterRegistry s_characterRegistry;
|
||||
s_characterRegistry.setWorld(app.getWorld());
|
||||
s_characterRegistry.setSceneManager(app.getSceneManager());
|
||||
s_characterRegistry.initialize();
|
||||
}
|
||||
|
||||
/* Meshes + materials referenced by the scene entities. */
|
||||
createDemoResources();
|
||||
|
||||
std::cout << "[demo] starting new game with scene: "
|
||||
<< sceneFile << std::endl;
|
||||
app.startNewGame(sceneFile);
|
||||
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
|
||||
"Shift = run, E = use portal, Escape = pause menu"
|
||||
<< std::endl;
|
||||
|
||||
ExitAfterFirstFrameListener exitListener(app.getRoot());
|
||||
if (exitAfterFirstFrame && !testSwitch)
|
||||
app.getRoot()->addFrameListener(&exitListener);
|
||||
|
||||
/* The test evaluates the action trees exactly like
|
||||
* ActuatorSystem::isActionComplete() does; only the
|
||||
* switchScene/debugPrint nodes are used, so no animation
|
||||
* or character system is needed. */
|
||||
BehaviorTreeSystem testBt(*app.getWorld(), app.getSceneManager(),
|
||||
nullptr, nullptr);
|
||||
testBt.setEditorApp(&app);
|
||||
SceneSwitchTestListener testListener(&app, &testBt);
|
||||
if (testSwitch)
|
||||
app.getRoot()->addFrameListener(&testListener);
|
||||
|
||||
app.getRoot()->startRendering();
|
||||
|
||||
if (testSwitch) {
|
||||
if (testListener.failed) {
|
||||
std::cerr << "[test] FAIL: "
|
||||
<< testListener.failReason
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
if (testListener.phase != 3) {
|
||||
std::cerr << "[test] FAIL: incomplete"
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Destroy scene entities while the systems are still alive:
|
||||
* CharacterSpawnerSystem registers an OnRemove observer on
|
||||
* CharacterSpawnerComponent that dereferences the system, and
|
||||
* the flecs world only dies after destroyEditorSystems() in
|
||||
* ~EditorApp, so letting the spawner entity live that long
|
||||
* would call back into a destroyed system. */
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"actionDatabase": {
|
||||
"actions": [
|
||||
{
|
||||
"name": "goto_scene_a",
|
||||
"cost": 1,
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"behaviorTree": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "debugPrint",
|
||||
"name": "[demo] portal B: switching to scene A"
|
||||
},
|
||||
{
|
||||
"type": "switchScene",
|
||||
"name": "demo_scene_a.json",
|
||||
"params": "@arrival_a"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {
|
||||
"name": "demo_light"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 10.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"lightType": "directional",
|
||||
"diffuseColor": {
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
},
|
||||
"specularColor": {
|
||||
"r": 0.5,
|
||||
"g": 0.5,
|
||||
"b": 0.5,
|
||||
"a": 1.0
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.3,
|
||||
"y": -1.0,
|
||||
"z": 0.2
|
||||
},
|
||||
"intensity": 1.0,
|
||||
"castShadows": false
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": {
|
||||
"name": "demo_floor"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoFloorPlaneB",
|
||||
"visible": true
|
||||
},
|
||||
"rigidBody": {
|
||||
"bodyType": "static",
|
||||
"mass": 1.0,
|
||||
"friction": 0.8,
|
||||
"restitution": 0.0,
|
||||
"isSensor": false,
|
||||
"enabled": true
|
||||
},
|
||||
"collider": {
|
||||
"shapeType": "box",
|
||||
"parameters": {
|
||||
"x": 30.0,
|
||||
"y": 0.1,
|
||||
"z": 30.0
|
||||
},
|
||||
"radius": 0.5,
|
||||
"halfHeight": 1.0,
|
||||
"meshName": "",
|
||||
"offset": {
|
||||
"x": 0.0,
|
||||
"y": -0.1,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotationOffset": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": {
|
||||
"name": "portal_b"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.8,
|
||||
"z": 28.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoActuatorMarkerB",
|
||||
"visible": true
|
||||
},
|
||||
"actuator": {
|
||||
"radius": 1.5,
|
||||
"height": 1.8,
|
||||
"actionNames": ["goto_scene_a"]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": {
|
||||
"name": "arrival_b"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 24.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 1.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": {
|
||||
"name": "s1"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": -3.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"characterSpawner": {
|
||||
"despawnDistance": 200.0,
|
||||
"registryId": 2,
|
||||
"spawnDistance": 100.0
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": {
|
||||
"name": "player"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"playerController": {
|
||||
"actuatorColor": [
|
||||
0.0,
|
||||
0.4000000059604645,
|
||||
1.0
|
||||
],
|
||||
"actuatorCooldown": 1.5,
|
||||
"actuatorDistance": 25.0,
|
||||
"actuatorLabelFontSize": 12.0,
|
||||
"cameraMode": 0,
|
||||
"distantCircleRadius": 8.0,
|
||||
"fpsBoneName": "Head",
|
||||
"idleState": "idle",
|
||||
"locomotionStateMachine": "locomotion",
|
||||
"mouseSensitivity": 0.20000000298023224,
|
||||
"nearCircleRadius": 14.0,
|
||||
"runState": "running",
|
||||
"swimFastState": "swimming-fast",
|
||||
"swimIdleState": "swim-idle",
|
||||
"swimState": "swimming",
|
||||
"targetCharacterName": "s1",
|
||||
"tpsDistance": 3.0,
|
||||
"tpsHeight": 2.0,
|
||||
"walkState": "walking"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Stage everything demoSceneSwitchingExtra needs into its own directory so it
|
||||
# runs standalone from
|
||||
# <build>/src/features/editScene/demos/demo-scene-switching-extra.
|
||||
#
|
||||
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
|
||||
#
|
||||
# Small demo-owned files are COPIED; the big pre-staged runtime directories
|
||||
# are SYMLINKED from the editScene binary directory (Ogre FileSystem
|
||||
# locations follow symlinks, and copying would duplicate hundreds of MB on
|
||||
# every build). The symlink targets are populated by the editSceneEditor
|
||||
# staging, so editSceneEditor must have been built (and run its POST_BUILD
|
||||
# staging) at least once.
|
||||
|
||||
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
|
||||
file(COPY "${SRC_DIR}/demo_scene_a.json" DESTINATION "${DEMO_DIR}")
|
||||
file(COPY "${SRC_DIR}/demo_scene_b.json" DESTINATION "${DEMO_DIR}")
|
||||
|
||||
# Character prefab for the spawner's registry entry (registryId 2). It is
|
||||
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
|
||||
# not part of the source tree, so copy it from the editScene binary
|
||||
# directory when present.
|
||||
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
|
||||
foreach(f char_2.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
|
||||
DESTINATION "${DEMO_DIR}/prefabs")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
foreach(dir resources characters lua-scripts)
|
||||
set(link "${DEMO_DIR}/${dir}")
|
||||
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
|
||||
file(REMOVE_RECURSE "${link}")
|
||||
endif()
|
||||
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
|
||||
endforeach()
|
||||
|
||||
# Runtime config JSONs loaded at startup relative to the CWD (game mode
|
||||
# reads startup_menu.json, the registries read the rest — the demo needs
|
||||
# character_registry.json for the spawner's registryId 2 and
|
||||
# animation_tree.json for the character's "male1_6" animation tree). They
|
||||
# only exist after the editor/game has run once; copy them when present so
|
||||
# the demo behaves the same as when run from the editor binary directory.
|
||||
foreach(f startup_menu.json character_registry.json character_class.json
|
||||
items.json item_state.json inventory_config.json
|
||||
animation_tree.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -0,0 +1,96 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# demo-scene-switching — game-mode demo for actuator-driven scene switching
|
||||
# ---------------------------------------------------------------------------
|
||||
# Separate executable built from the same sources as editSceneEditor (minus
|
||||
# main.cpp). It runs in game mode and loads demo_scene_a.json, which
|
||||
# contains a flat colored floor plane (procedurally created in demo_main.cpp
|
||||
# as "DemoFloorPlaneA"; scene B uses the blue "DemoFloorPlaneB") with a
|
||||
# static box collider, a character spawner ("s1", character registry ID 2,
|
||||
# same player character setup as town8.json), a PlayerControllerComponent
|
||||
# targeting it and an actuator ("portal_a", marked by a colored pillar mesh
|
||||
# also created in demo_main.cpp). The actuator's "goto_scene_b" action
|
||||
# (defined in the scene's top-level actionDatabase block) runs a behavior
|
||||
# tree whose "switchScene" node queues EditorApp::switchScene() to
|
||||
# demo_scene_b.json with a "@arrival_b" teleport target; demo_scene_b.json
|
||||
# mirrors this with its own "portal_b" actuator and "goto_scene_a" action,
|
||||
# so the player can travel back and forth endlessly. Both scenes carry
|
||||
# their own player controller, so each switch is a clean takeover. The
|
||||
# mouse is grabbed while Playing (built-in game-mode behaviour) and the
|
||||
# initial window is larger than the default (1920x1080, see DemoApp in
|
||||
# demo_main.cpp).
|
||||
#
|
||||
# The executable is self-contained in this build directory: a POST_BUILD
|
||||
# step (stage_runtime.cmake) copies resources.cfg, both demo scenes, the
|
||||
# character prefab (prefabs/char_2.json, written by a previous editor/game
|
||||
# run) and any runtime config JSONs here, and symlinks the big pre-staged
|
||||
# runtime directories (resources/, characters/, lua-scripts/) from the
|
||||
# editScene binary directory. Run it from here:
|
||||
# cd <build>/src/features/editScene/demos/demo-scene-switching
|
||||
# ./demoSceneSwitching
|
||||
# Controls: mouse = look, W/A/S/D = move, Shift = run, E = use portal,
|
||||
# Escape = pause menu (frees the cursor).
|
||||
|
||||
get_filename_component(EDITSCENE_SOURCE_DIR
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
|
||||
get_filename_component(EDITSCENE_BINARY_DIR
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE)
|
||||
|
||||
# Reuse the editScene sources, swapping main.cpp for demo_main.cpp.
|
||||
set(DEMO_SOURCES ${EDITSCENE_SOURCES})
|
||||
list(REMOVE_ITEM DEMO_SOURCES main.cpp)
|
||||
list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/")
|
||||
|
||||
add_executable(demoSceneSwitching
|
||||
demo_main.cpp
|
||||
${DEMO_SOURCES}
|
||||
)
|
||||
|
||||
add_dependencies(demoSceneSwitching morph)
|
||||
|
||||
# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor)
|
||||
target_compile_definitions(demoSceneSwitching PRIVATE JPH_DEBUG_RENDERER)
|
||||
|
||||
target_link_libraries(demoSceneSwitching
|
||||
OgreMain
|
||||
OgreBites
|
||||
OgreOverlay
|
||||
OgreMeshLodGenerator
|
||||
OgrePaging
|
||||
OgreTerrain
|
||||
flecs::flecs_static
|
||||
nlohmann_json::nlohmann_json
|
||||
Jolt::Jolt
|
||||
OgreProcedural::OgreProcedural
|
||||
RecastNavigation::Recast
|
||||
RecastNavigation::Detour
|
||||
RecastNavigation::DetourTileCache
|
||||
RecastNavigation::DetourCrowd
|
||||
RecastNavigation::DebugUtils
|
||||
PackageArchive
|
||||
RoadGeometryLib
|
||||
lua
|
||||
SDL2::SDL2
|
||||
)
|
||||
|
||||
target_include_directories(demoSceneSwitching PRIVATE
|
||||
${EDITSCENE_SOURCE_DIR}
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
|
||||
${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include
|
||||
${CMAKE_SOURCE_DIR}/src/FastNoiseLite
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
|
||||
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
|
||||
)
|
||||
|
||||
# Stage the standalone runtime next to the executable (see header comment).
|
||||
add_custom_command(TARGET demoSceneSwitching POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR}
|
||||
-DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR}
|
||||
-DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR}
|
||||
-DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR}
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake"
|
||||
COMMENT "Staging demo-scene-switching standalone runtime"
|
||||
)
|
||||
@@ -0,0 +1,464 @@
|
||||
#include <iostream>
|
||||
#include "EditorApp.hpp"
|
||||
#include "camera/EditorCamera.hpp"
|
||||
#include "systems/CharacterRegistry.hpp"
|
||||
#include "systems/BehaviorTreeSystem.hpp"
|
||||
#include "components/ActionDatabase.hpp"
|
||||
#include "components/EntityName.hpp"
|
||||
#include "components/Transform.hpp"
|
||||
#include <Ogre.h>
|
||||
#include <OgreRoot.h>
|
||||
#include <ProceduralBoxGenerator.h>
|
||||
#include <ProceduralMeshGenerator.h>
|
||||
|
||||
struct ExitAfterFirstFrameListener : public Ogre::FrameListener {
|
||||
Ogre::Root *root;
|
||||
bool triggered = false;
|
||||
ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {}
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
if (!triggered) {
|
||||
triggered = true;
|
||||
root->queueEndRendering();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/* Same application as the editor/game, but with a larger initial window. */
|
||||
class DemoApp : public EditorApp {
|
||||
public:
|
||||
OgreBites::NativeWindowPair
|
||||
createWindow(const Ogre::String &name, uint32_t w, uint32_t h,
|
||||
Ogre::NameValuePairList miscParams) override
|
||||
{
|
||||
(void)w;
|
||||
(void)h;
|
||||
return EditorApp::createWindow(name, 1920, 1080, miscParams);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* The demo floor meshes and their flat colored materials are created
|
||||
* programmatically because RenderableComponent only references a mesh by
|
||||
* name and carries no material/color of its own. Scene A references the
|
||||
* green "DemoFloorPlaneA", scene B the blue "DemoFloorPlaneB" so it is
|
||||
* obvious which scene is currently loaded.
|
||||
*/
|
||||
static void createFloorMesh(const Ogre::String &meshName,
|
||||
const Ogre::String &materialName,
|
||||
float dr, float dg, float db)
|
||||
{
|
||||
const Ogre::String group =
|
||||
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
|
||||
|
||||
if (!Ogre::MeshManager::getSingleton()
|
||||
.getByName(meshName, group)
|
||||
.isNull())
|
||||
return;
|
||||
|
||||
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
|
||||
materialName, group);
|
||||
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
|
||||
pass->setDiffuse(dr, dg, db, 1.0f);
|
||||
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
|
||||
pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f);
|
||||
|
||||
/* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider
|
||||
* (top surface at y = 0) on the "demo_floor" entity in the scenes. */
|
||||
Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f);
|
||||
Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane(
|
||||
meshName, group, groundPlane, 60.0f, 60.0f, 1, 1, true, 1,
|
||||
1.0f, 1.0f, Ogre::Vector3::UNIT_Z);
|
||||
mesh->getSubMesh(0)->setMaterialName(materialName);
|
||||
}
|
||||
|
||||
/*
|
||||
* Visible pillar meshes marking the actuator positions ("portal_a" in
|
||||
* scene A, "portal_b" in scene B). The ActuatorSystem already draws a
|
||||
* screen-space indicator, but a physical marker makes the spot visible
|
||||
* from across the floor. The box is 0.6 x 1.6 x 0.6 centered on the
|
||||
* entity origin, so the entities sit at y = 0.8.
|
||||
*/
|
||||
static void createMarkerMesh(const Ogre::String &meshName,
|
||||
const Ogre::String &materialName,
|
||||
float dr, float dg, float db)
|
||||
{
|
||||
const Ogre::String group =
|
||||
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME;
|
||||
|
||||
if (!Ogre::MeshManager::getSingleton()
|
||||
.getByName(meshName, group)
|
||||
.isNull())
|
||||
return;
|
||||
|
||||
Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create(
|
||||
materialName, group);
|
||||
Ogre::Pass *pass = mat->getTechnique(0)->getPass(0);
|
||||
pass->setDiffuse(dr, dg, db, 1.0f);
|
||||
pass->setAmbient(dr * 0.4f, dg * 0.4f, db * 0.4f);
|
||||
pass->setSpecular(0.1f, 0.1f, 0.1f, 1.0f);
|
||||
|
||||
Procedural::BoxGenerator boxGen;
|
||||
boxGen.setSizeX(0.6f).setSizeY(1.6f).setSizeZ(0.6f);
|
||||
Ogre::MeshPtr mesh = boxGen.realizeMesh(meshName, group);
|
||||
if (mesh && mesh->getNumSubMeshes() > 0)
|
||||
mesh->getSubMesh(0)->setMaterialName(materialName);
|
||||
}
|
||||
|
||||
static void createDemoResources()
|
||||
{
|
||||
/* Scene A: green floor, orange portal marker. */
|
||||
createFloorMesh("DemoFloorPlaneA", "DemoFloorMaterialA",
|
||||
0.35f, 0.5f, 0.35f);
|
||||
createMarkerMesh("DemoActuatorMarkerA", "DemoActuatorMarkerMaterialA",
|
||||
0.9f, 0.5f, 0.1f);
|
||||
|
||||
/* Scene B: blue floor, magenta portal marker. */
|
||||
createFloorMesh("DemoFloorPlaneB", "DemoFloorMaterialB",
|
||||
0.3f, 0.4f, 0.6f);
|
||||
createMarkerMesh("DemoActuatorMarkerB", "DemoActuatorMarkerMaterialB",
|
||||
0.8f, 0.2f, 0.6f);
|
||||
}
|
||||
|
||||
/*
|
||||
* End-to-end check for --test-switch: drives the same path an E-press on
|
||||
* the actuator would take (ActionDatabase action -> behavior tree ->
|
||||
* "switchScene" node -> EditorApp::switchScene() queue ->
|
||||
* performSceneSwitch() on the next frame with the "@arrival_*" teleport),
|
||||
* for a full A -> B -> A round trip. The prompt/targeting glue is
|
||||
* screen-space and therefore not covered headless. The ActuatorSystem is
|
||||
* not involved because its interaction path needs an ImGui context; the
|
||||
* tree is evaluated through a local BehaviorTreeSystem exactly like
|
||||
* ActuatorSystem::isActionComplete() does.
|
||||
*/
|
||||
struct SceneSwitchTestListener : public Ogre::FrameListener {
|
||||
EditorApp *app;
|
||||
BehaviorTreeSystem *bt;
|
||||
int frame = 0;
|
||||
int phase = 0;
|
||||
bool failed = false;
|
||||
Ogre::String failReason;
|
||||
|
||||
SceneSwitchTestListener(EditorApp *a, BehaviorTreeSystem *b)
|
||||
: app(a), bt(b)
|
||||
{
|
||||
}
|
||||
|
||||
bool entityExists(const char *name)
|
||||
{
|
||||
bool found = false;
|
||||
app->getWorld()->query<EntityNameComponent>().each(
|
||||
[&](flecs::entity, EntityNameComponent &n) {
|
||||
if (n.name == name)
|
||||
found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Returns false while the player is not available yet (retry).
|
||||
* Hard failures set failed/failReason. */
|
||||
bool runAction(const char *actionName, const char *expectPath)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive())
|
||||
return false;
|
||||
|
||||
ActionDatabase *db = ActionDatabase::getSingletonPtr();
|
||||
const GoapAction *action =
|
||||
db ? db->findAction(actionName) : nullptr;
|
||||
if (!action) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("action not found: ") +
|
||||
actionName;
|
||||
return true;
|
||||
}
|
||||
|
||||
BehaviorTreeSystem::Status status =
|
||||
bt->evaluatePlayerAction(player.id(),
|
||||
action->behaviorTree, 0.016f,
|
||||
true);
|
||||
if (status != BehaviorTreeSystem::Status::success) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("action did not succeed: ") +
|
||||
actionName;
|
||||
return true;
|
||||
}
|
||||
if (!app->hasPendingSceneSwitch() ||
|
||||
app->getPendingSceneSwitchPath() != expectPath) {
|
||||
failed = true;
|
||||
failReason = Ogre::String("no pending switch to ") +
|
||||
expectPath;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkPlayerNear(const Ogre::Vector3 &expected, float tol)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() || !player.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
const TransformComponent &t = player.get<TransformComponent>();
|
||||
Ogre::Vector3 pos =
|
||||
t.node ? t.node->_getDerivedPosition() : t.position;
|
||||
if (pos.distance(expected) > tol) {
|
||||
failed = true;
|
||||
failReason = "player not at arrival point: (" +
|
||||
Ogre::StringConverter::toString(pos.x) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(pos.y) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(pos.z) +
|
||||
")";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* The arrival markers sit at (0, 0, 24) facing -Z (toward the
|
||||
* floor center); verify the camera took a position behind the
|
||||
* character looking at the walking surface with the portal
|
||||
* pillar (z = 28) behind the camera, and that the character's
|
||||
* visual facing (local +Z) points at the center too. */
|
||||
bool checkCameraFacesCenter(const Ogre::Vector3 &charPos)
|
||||
{
|
||||
flecs::entity player = app->getPlayerCharacterEntity();
|
||||
if (!player.is_alive() || !player.has<TransformComponent>())
|
||||
return false;
|
||||
|
||||
const TransformComponent &t = player.get<TransformComponent>();
|
||||
Ogre::Quaternion q =
|
||||
t.node ? t.node->getOrientation() : t.rotation;
|
||||
Ogre::Vector3 charFacing = q * Ogre::Vector3::UNIT_Z;
|
||||
|
||||
EditorCamera *ec = app->getEditorCamera();
|
||||
Ogre::Camera *cam = ec ? ec->getCamera() : nullptr;
|
||||
Ogre::SceneNode *camNode =
|
||||
cam ? cam->getParentSceneNode() : nullptr;
|
||||
if (!camNode)
|
||||
return false;
|
||||
Ogre::Vector3 cp = camNode->_getDerivedPosition();
|
||||
Ogre::Vector3 vd = camNode->_getDerivedOrientation() *
|
||||
Ogre::Vector3::NEGATIVE_UNIT_Z;
|
||||
if (vd.z > -0.9f || cp.z < charPos.z + 1.0f ||
|
||||
charFacing.z > -0.9f) {
|
||||
failed = true;
|
||||
failReason =
|
||||
"not facing the walking surface: camera pos (" +
|
||||
Ogre::StringConverter::toString(cp.x) + ", " +
|
||||
Ogre::StringConverter::toString(cp.y) + ", " +
|
||||
Ogre::StringConverter::toString(cp.z) +
|
||||
") view (" +
|
||||
Ogre::StringConverter::toString(vd.x) + ", " +
|
||||
Ogre::StringConverter::toString(vd.y) + ", " +
|
||||
Ogre::StringConverter::toString(vd.z) +
|
||||
") character facing (" +
|
||||
Ogre::StringConverter::toString(charFacing.x) +
|
||||
", " +
|
||||
Ogre::StringConverter::toString(charFacing.z) +
|
||||
")";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool frameRenderingQueued(const Ogre::FrameEvent &) override
|
||||
{
|
||||
frame++;
|
||||
if (failed || phase == 3) {
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
if (frame > 1200) {
|
||||
failed = true;
|
||||
failReason = "timeout waiting for scene switch";
|
||||
app->getRoot()->queueEndRendering();
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (phase) {
|
||||
case 0:
|
||||
if (frame < 5)
|
||||
break; /* let the character spawn */
|
||||
if (!runAction("goto_scene_b", "demo_scene_b.json"))
|
||||
break;
|
||||
if (!failed)
|
||||
std::cout << "[test] queued switch A -> B"
|
||||
<< std::endl;
|
||||
phase = 1;
|
||||
break;
|
||||
case 1:
|
||||
if (!entityExists("portal_b"))
|
||||
break;
|
||||
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
|
||||
1.0f))
|
||||
break;
|
||||
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
|
||||
24.0f)))
|
||||
break;
|
||||
if (failed)
|
||||
break;
|
||||
std::cout << "[test] arrived in scene B at arrival_b, "
|
||||
"camera faces the walking surface"
|
||||
<< std::endl;
|
||||
if (!runAction("goto_scene_a", "demo_scene_a.json"))
|
||||
break;
|
||||
if (!failed)
|
||||
std::cout << "[test] queued switch B -> A"
|
||||
<< std::endl;
|
||||
phase = 2;
|
||||
break;
|
||||
case 2:
|
||||
if (!entityExists("portal_a"))
|
||||
break;
|
||||
if (!checkPlayerNear(Ogre::Vector3(0.0f, 0.0f, 24.0f),
|
||||
1.0f))
|
||||
break;
|
||||
if (!checkCameraFacesCenter(Ogre::Vector3(0.0f, 0.0f,
|
||||
24.0f)))
|
||||
break;
|
||||
if (!failed) {
|
||||
std::cout << "[test] arrived back in scene A "
|
||||
"at arrival_a, camera faces the "
|
||||
"walking surface"
|
||||
<< std::endl;
|
||||
std::cout << "[test] PASS" << std::endl;
|
||||
}
|
||||
phase = 3;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* demo-scene-switching: runs the editScene game mode with two small
|
||||
* hand-authored scenes (demo_scene_a.json and demo_scene_b.json), each
|
||||
* containing a flat colored floor plane with a static physics collider,
|
||||
* a character spawner ("s1", character registry ID 2, same character
|
||||
* setup as town8.json), a PlayerControllerComponent targeting it and an
|
||||
* actuator ("portal_a" / "portal_b", marked by a colored pillar).
|
||||
*
|
||||
* Each actuator names one action ("goto_scene_b" / "goto_scene_a") whose
|
||||
* behavior tree is defined in the scene's top-level "actionDatabase"
|
||||
* block: a sequence ending in a "switchScene" node that queues an
|
||||
* EditorApp::switchScene() to the other scene with a
|
||||
* "@arrival_a"/"@arrival_b" teleport target, so the player reappears
|
||||
* right next to the return portal. Walking into the pillar's radius
|
||||
* shows the "E goto_scene_*" prompt; pressing E runs the tree and the
|
||||
* scene switch executes at the start of the next frame. Both scenes
|
||||
* carry their own player controller, so each switch is a clean takeover
|
||||
* (see EditorApp::performSceneSwitch); travel back and forth is endless.
|
||||
*
|
||||
* The mouse is grabbed while Playing (built-in game-mode behaviour);
|
||||
* Escape toggles the pause menu, which frees the cursor.
|
||||
*
|
||||
* The binary is self-contained in its build directory: run it from there
|
||||
* (resources.cfg, both scene JSONs, resources/, characters/, lua-scripts/,
|
||||
* prefabs/ and the runtime config JSONs are staged next to it by the
|
||||
* build).
|
||||
*
|
||||
* Extra flags:
|
||||
* --test-switch headless-friendly end-to-end check: executes both
|
||||
* portal actions and verifies the A -> B -> A round
|
||||
* trip and the arrival teleports; exits 0 on PASS.
|
||||
*/
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
try {
|
||||
DemoApp app;
|
||||
app.setGameMode(EditorApp::GameMode::Game);
|
||||
|
||||
bool headless = false;
|
||||
bool exitAfterFirstFrame = false;
|
||||
bool testSwitch = false;
|
||||
Ogre::String sceneFile = "demo_scene_a.json";
|
||||
for (int i = 1; i < argc; i++) {
|
||||
Ogre::String arg = argv[i];
|
||||
if (arg == "--headless") {
|
||||
headless = true;
|
||||
} else if (arg == "--exit-after-first-frame") {
|
||||
exitAfterFirstFrame = true;
|
||||
} else if (arg == "--test-switch") {
|
||||
testSwitch = true;
|
||||
} else if (arg.length() > 0 && arg[0] != '-') {
|
||||
sceneFile = arg;
|
||||
}
|
||||
}
|
||||
app.setHeadless(headless);
|
||||
|
||||
app.initApp();
|
||||
|
||||
if (headless) {
|
||||
/* Headless mode never creates EditorUISystem, which
|
||||
* owns the CharacterRegistry singleton; the demo scene
|
||||
* has a character spawner, so provide a bare registry
|
||||
* to keep spawner resolution from asserting. The
|
||||
* character then falls back to an inline spawn without
|
||||
* a physics capsule (fine for a smoke run). */
|
||||
static CharacterRegistry s_characterRegistry;
|
||||
s_characterRegistry.setWorld(app.getWorld());
|
||||
s_characterRegistry.setSceneManager(app.getSceneManager());
|
||||
s_characterRegistry.initialize();
|
||||
}
|
||||
|
||||
/* Meshes + materials referenced by the scene entities. */
|
||||
createDemoResources();
|
||||
|
||||
std::cout << "[demo] starting new game with scene: "
|
||||
<< sceneFile << std::endl;
|
||||
app.startNewGame(sceneFile);
|
||||
std::cout << "[demo] controls: mouse = look, W/A/S/D = move, "
|
||||
"Shift = run, E = use portal, Escape = pause menu"
|
||||
<< std::endl;
|
||||
|
||||
ExitAfterFirstFrameListener exitListener(app.getRoot());
|
||||
if (exitAfterFirstFrame && !testSwitch)
|
||||
app.getRoot()->addFrameListener(&exitListener);
|
||||
|
||||
/* The test evaluates the action trees exactly like
|
||||
* ActuatorSystem::isActionComplete() does; only the
|
||||
* switchScene/debugPrint nodes are used, so no animation
|
||||
* or character system is needed. */
|
||||
BehaviorTreeSystem testBt(*app.getWorld(), app.getSceneManager(),
|
||||
nullptr, nullptr);
|
||||
testBt.setEditorApp(&app);
|
||||
SceneSwitchTestListener testListener(&app, &testBt);
|
||||
if (testSwitch)
|
||||
app.getRoot()->addFrameListener(&testListener);
|
||||
|
||||
app.getRoot()->startRendering();
|
||||
|
||||
if (testSwitch) {
|
||||
if (testListener.failed) {
|
||||
std::cerr << "[test] FAIL: "
|
||||
<< testListener.failReason
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
if (testListener.phase != 3) {
|
||||
std::cerr << "[test] FAIL: incomplete"
|
||||
<< std::endl;
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Destroy scene entities while the systems are still alive:
|
||||
* CharacterSpawnerSystem registers an OnRemove observer on
|
||||
* CharacterSpawnerComponent that dereferences the system, and
|
||||
* the flecs world only dies after destroyEditorSystems() in
|
||||
* ~EditorApp, so letting the spawner entity live that long
|
||||
* would call back into a destroyed system. */
|
||||
app.clearScene();
|
||||
app.closeApp();
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"actionDatabase": {
|
||||
"actions": [
|
||||
{
|
||||
"name": "goto_scene_b",
|
||||
"cost": 1,
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"behaviorTree": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "debugPrint",
|
||||
"name": "[demo] portal A: switching to scene B"
|
||||
},
|
||||
{
|
||||
"type": "switchScene",
|
||||
"name": "demo_scene_b.json",
|
||||
"params": "@arrival_b"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {
|
||||
"name": "demo_light"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 10.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"lightType": "directional",
|
||||
"diffuseColor": {
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
},
|
||||
"specularColor": {
|
||||
"r": 0.5,
|
||||
"g": 0.5,
|
||||
"b": 0.5,
|
||||
"a": 1.0
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.3,
|
||||
"y": -1.0,
|
||||
"z": 0.2
|
||||
},
|
||||
"intensity": 1.0,
|
||||
"castShadows": false
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": {
|
||||
"name": "demo_floor"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoFloorPlaneA",
|
||||
"visible": true
|
||||
},
|
||||
"rigidBody": {
|
||||
"bodyType": "static",
|
||||
"mass": 1.0,
|
||||
"friction": 0.8,
|
||||
"restitution": 0.0,
|
||||
"isSensor": false,
|
||||
"enabled": true
|
||||
},
|
||||
"collider": {
|
||||
"shapeType": "box",
|
||||
"parameters": {
|
||||
"x": 30.0,
|
||||
"y": 0.1,
|
||||
"z": 30.0
|
||||
},
|
||||
"radius": 0.5,
|
||||
"halfHeight": 1.0,
|
||||
"meshName": "",
|
||||
"offset": {
|
||||
"x": 0.0,
|
||||
"y": -0.1,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotationOffset": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": {
|
||||
"name": "portal_a"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.8,
|
||||
"z": 28.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoActuatorMarkerA",
|
||||
"visible": true
|
||||
},
|
||||
"actuator": {
|
||||
"radius": 1.5,
|
||||
"height": 1.8,
|
||||
"actionNames": ["goto_scene_b"]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": {
|
||||
"name": "arrival_a"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 24.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 1.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": {
|
||||
"name": "s1"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": -3.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"characterSpawner": {
|
||||
"despawnDistance": 200.0,
|
||||
"registryId": 2,
|
||||
"spawnDistance": 100.0
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": {
|
||||
"name": "player"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"playerController": {
|
||||
"actuatorColor": [
|
||||
0.0,
|
||||
0.4000000059604645,
|
||||
1.0
|
||||
],
|
||||
"actuatorCooldown": 1.5,
|
||||
"actuatorDistance": 25.0,
|
||||
"actuatorLabelFontSize": 12.0,
|
||||
"cameraMode": 0,
|
||||
"distantCircleRadius": 8.0,
|
||||
"fpsBoneName": "Head",
|
||||
"idleState": "idle",
|
||||
"locomotionStateMachine": "locomotion",
|
||||
"mouseSensitivity": 0.20000000298023224,
|
||||
"nearCircleRadius": 14.0,
|
||||
"runState": "running",
|
||||
"swimFastState": "swimming-fast",
|
||||
"swimIdleState": "swim-idle",
|
||||
"swimState": "swimming",
|
||||
"targetCharacterName": "s1",
|
||||
"tpsDistance": 3.0,
|
||||
"tpsHeight": 2.0,
|
||||
"walkState": "walking"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"actionDatabase": {
|
||||
"actions": [
|
||||
{
|
||||
"name": "goto_scene_a",
|
||||
"cost": 1,
|
||||
"preconditions": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"effects": {
|
||||
"bits": 0,
|
||||
"mask": 0
|
||||
},
|
||||
"behaviorTree": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "debugPrint",
|
||||
"name": "[demo] portal B: switching to scene A"
|
||||
},
|
||||
{
|
||||
"type": "switchScene",
|
||||
"name": "demo_scene_a.json",
|
||||
"params": "@arrival_a"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": {
|
||||
"name": "demo_light"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 10.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"lightType": "directional",
|
||||
"diffuseColor": {
|
||||
"r": 1.0,
|
||||
"g": 1.0,
|
||||
"b": 1.0,
|
||||
"a": 1.0
|
||||
},
|
||||
"specularColor": {
|
||||
"r": 0.5,
|
||||
"g": 0.5,
|
||||
"b": 0.5,
|
||||
"a": 1.0
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.3,
|
||||
"y": -1.0,
|
||||
"z": 0.2
|
||||
},
|
||||
"intensity": 1.0,
|
||||
"castShadows": false
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": {
|
||||
"name": "demo_floor"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoFloorPlaneB",
|
||||
"visible": true
|
||||
},
|
||||
"rigidBody": {
|
||||
"bodyType": "static",
|
||||
"mass": 1.0,
|
||||
"friction": 0.8,
|
||||
"restitution": 0.0,
|
||||
"isSensor": false,
|
||||
"enabled": true
|
||||
},
|
||||
"collider": {
|
||||
"shapeType": "box",
|
||||
"parameters": {
|
||||
"x": 30.0,
|
||||
"y": 0.1,
|
||||
"z": 30.0
|
||||
},
|
||||
"radius": 0.5,
|
||||
"halfHeight": 1.0,
|
||||
"meshName": "",
|
||||
"offset": {
|
||||
"x": 0.0,
|
||||
"y": -0.1,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotationOffset": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": {
|
||||
"name": "portal_b"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.8,
|
||||
"z": 28.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"renderable": {
|
||||
"meshName": "DemoActuatorMarkerB",
|
||||
"visible": true
|
||||
},
|
||||
"actuator": {
|
||||
"radius": 1.5,
|
||||
"height": 1.8,
|
||||
"actionNames": ["goto_scene_a"]
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": {
|
||||
"name": "arrival_b"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 24.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 0.0,
|
||||
"x": 0.0,
|
||||
"y": 1.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": {
|
||||
"name": "s1"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": -3.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"characterSpawner": {
|
||||
"despawnDistance": 200.0,
|
||||
"registryId": 2,
|
||||
"spawnDistance": 100.0
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": {
|
||||
"name": "player"
|
||||
},
|
||||
"transform": {
|
||||
"position": {
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"rotation": {
|
||||
"w": 1.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"z": 0.0
|
||||
},
|
||||
"scale": {
|
||||
"x": 1.0,
|
||||
"y": 1.0,
|
||||
"z": 1.0
|
||||
}
|
||||
},
|
||||
"playerController": {
|
||||
"actuatorColor": [
|
||||
0.0,
|
||||
0.4000000059604645,
|
||||
1.0
|
||||
],
|
||||
"actuatorCooldown": 1.5,
|
||||
"actuatorDistance": 25.0,
|
||||
"actuatorLabelFontSize": 12.0,
|
||||
"cameraMode": 0,
|
||||
"distantCircleRadius": 8.0,
|
||||
"fpsBoneName": "Head",
|
||||
"idleState": "idle",
|
||||
"locomotionStateMachine": "locomotion",
|
||||
"mouseSensitivity": 0.20000000298023224,
|
||||
"nearCircleRadius": 14.0,
|
||||
"runState": "running",
|
||||
"swimFastState": "swimming-fast",
|
||||
"swimIdleState": "swim-idle",
|
||||
"swimState": "swimming",
|
||||
"targetCharacterName": "s1",
|
||||
"tpsDistance": 3.0,
|
||||
"tpsHeight": 2.0,
|
||||
"walkState": "walking"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Stage everything demoSceneSwitching needs into its own directory so it
|
||||
# runs standalone from
|
||||
# <build>/src/features/editScene/demos/demo-scene-switching.
|
||||
#
|
||||
# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR.
|
||||
#
|
||||
# Small demo-owned files are COPIED; the big pre-staged runtime directories
|
||||
# are SYMLINKED from the editScene binary directory (Ogre FileSystem
|
||||
# locations follow symlinks, and copying would duplicate hundreds of MB on
|
||||
# every build). The symlink targets are populated by the editSceneEditor
|
||||
# staging, so editSceneEditor must have been built (and run its POST_BUILD
|
||||
# staging) at least once.
|
||||
|
||||
file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}")
|
||||
file(COPY "${SRC_DIR}/demo_scene_a.json" DESTINATION "${DEMO_DIR}")
|
||||
file(COPY "${SRC_DIR}/demo_scene_b.json" DESTINATION "${DEMO_DIR}")
|
||||
|
||||
# Character prefab for the spawner's registry entry (registryId 2). It is
|
||||
# written by a previous editor/game run (CharacterRegistry::savePrefab...),
|
||||
# not part of the source tree, so copy it from the editScene binary
|
||||
# directory when present.
|
||||
file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs")
|
||||
foreach(f char_2.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/prefabs/${f}"
|
||||
DESTINATION "${DEMO_DIR}/prefabs")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
foreach(dir resources characters lua-scripts)
|
||||
set(link "${DEMO_DIR}/${dir}")
|
||||
if(EXISTS "${link}" OR IS_SYMLINK "${link}")
|
||||
file(REMOVE_RECURSE "${link}")
|
||||
endif()
|
||||
file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC)
|
||||
endforeach()
|
||||
|
||||
# Runtime config JSONs loaded at startup relative to the CWD (game mode
|
||||
# reads startup_menu.json, the registries read the rest — the demo needs
|
||||
# character_registry.json for the spawner's registryId 2 and
|
||||
# animation_tree.json for the character's "male1_6" animation tree). They
|
||||
# only exist after the editor/game has run once; copy them when present so
|
||||
# the demo behaves the same as when run from the editor binary directory.
|
||||
foreach(f startup_menu.json character_registry.json character_class.json
|
||||
items.json item_state.json inventory_config.json
|
||||
animation_tree.json)
|
||||
if(EXISTS "${EDITSCENE_BIN}/${f}")
|
||||
file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}")
|
||||
endif()
|
||||
endforeach()
|
||||
@@ -203,6 +203,7 @@ end)
|
||||
-- Movement: teleportToChild
|
||||
-- Physics: disablePhysics, enablePhysics
|
||||
-- Events: sendEvent
|
||||
-- Scenes: switchScene
|
||||
-- Inventory: hasItem, hasItemByName, countItem, pickupItem, dropItem,
|
||||
-- useItem, addItemToInventory
|
||||
-- Debug: debugPrint
|
||||
@@ -512,3 +513,49 @@ end
|
||||
-- The params table is passed as the second argument to your registered
|
||||
-- Lua function handler.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- Example: Scene switch node (AI / actuator trees)
|
||||
-- =============================================================================
|
||||
-- The built-in "switchScene" node queues a scene switch via
|
||||
-- EditorApp::switchScene when it becomes active; the actual switch runs at
|
||||
-- the start of the next frame. It works from AI trees (SmartObject/GOAP)
|
||||
-- and actuator (player action) trees alike.
|
||||
--
|
||||
-- Node fields:
|
||||
-- name = scene file path
|
||||
-- params = optional teleport for the player character:
|
||||
-- "@EntityName" - entity with a Transform in the NEW scene
|
||||
-- "x,y,z" - world-space position
|
||||
-- "x,y,z,yaw" - position + yaw in degrees
|
||||
--
|
||||
-- ecs.behavior_tree.create_scene_switch_node() builds the same node with
|
||||
-- friendlier arguments:
|
||||
-- create_scene_switch_node("scenes/level2.json")
|
||||
-- create_scene_switch_node("scenes/level2.json", "SpawnPoint")
|
||||
-- create_scene_switch_node("scenes/level2.json", 10, 5, 3, 90) -- x,y,z,yaw
|
||||
|
||||
ecs.action_db.add_action("enter_portal", 1,
|
||||
{}, -- preconditions
|
||||
{}, -- effects
|
||||
{ -- behavior tree
|
||||
type = "sequence",
|
||||
children = {
|
||||
ecs.behavior_tree.create_node("setAnimationState",
|
||||
"locomotion/walk"),
|
||||
ecs.behavior_tree.create_node("delay", "", "1.0"),
|
||||
-- Teleport the player to the "SpawnPoint" entity of the
|
||||
-- new scene after the switch.
|
||||
ecs.behavior_tree.create_scene_switch_node(
|
||||
"scenes/level2.json", "SpawnPoint")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
-- The same node written out by hand:
|
||||
-- { type = "switchScene", name = "scenes/level2.json",
|
||||
-- params = "@SpawnPoint" }
|
||||
-- or with a world-space destination and facing:
|
||||
-- { type = "switchScene", name = "scenes/level2.json",
|
||||
-- params = "10,5,3,90" }
|
||||
-- =============================================================================
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
-- =============================================================================
|
||||
-- Scene Switch Lua API Example
|
||||
-- =============================================================================
|
||||
-- This file demonstrates how to switch scenes from Lua (game mode or editor
|
||||
-- mode) via ecs.switch_scene().
|
||||
--
|
||||
-- ecs.switch_scene(path [, opts]) -> bool
|
||||
-- Queues a scene switch; the actual teardown + load happens at the start of
|
||||
-- the next frame, so it is safe to call from scene scripts (e.g. in a
|
||||
-- scene_loaded or event handler). Returns true when the switch was queued.
|
||||
--
|
||||
-- Behavior:
|
||||
-- Editor mode: the current scene is completely destroyed and the new one is
|
||||
-- loaded from scratch (editor camera and render origin are reset, unsaved
|
||||
-- changes are discarded).
|
||||
-- Game mode: if the new scene has its own player controller and/or camera,
|
||||
-- the old scene (including the old player) is fully cleaned and the new
|
||||
-- ones take over, like a fresh game start. If the new scene has neither,
|
||||
-- the current player controller and the live player character are carried
|
||||
-- over (inventory/stats are preserved).
|
||||
--
|
||||
-- opts (all fields optional):
|
||||
-- position = { x = 10, y = 5, z = 3 } -- world-space destination
|
||||
-- yaw = 90 -- degrees around +Y
|
||||
-- rotation = { w = 1, x = 0, y = 0, z = 0 } -- quaternion (overrides yaw)
|
||||
-- target = "SpawnPoint" -- name of an entity with a Transform component in
|
||||
-- -- the NEW scene; takes precedence over position
|
||||
--
|
||||
-- The teleported player is clamped to the terrain surface and a short
|
||||
-- grounding watchdog keeps it from falling through the floor while physics
|
||||
-- colliders (streaming terrain pages) are still being built.
|
||||
-- =============================================================================
|
||||
|
||||
-- Simple switch: clean scene swap.
|
||||
ecs.switch_scene("scenes/level2.json")
|
||||
|
||||
-- Switch and teleport the player to a world-space position, facing 90 degrees.
|
||||
ecs.switch_scene("scenes/level2.json", {
|
||||
position = { x = 10, y = 5, z = 3 },
|
||||
yaw = 90,
|
||||
})
|
||||
|
||||
-- Switch and teleport the player onto a named spawn-point entity in the new
|
||||
-- scene (the entity must have a Transform component).
|
||||
ecs.switch_scene("scenes/level2.json", { target = "SpawnPoint" })
|
||||
|
||||
-- Switch with an explicit quaternion rotation.
|
||||
ecs.switch_scene("scenes/level2.json", {
|
||||
position = { x = 0, y = 0, z = 0 },
|
||||
rotation = { w = 1, x = 0, y = 0, z = 0 },
|
||||
})
|
||||
|
||||
-- Typical use: react to a game event by moving to another scene. Guard
|
||||
-- against re-triggering after the switch (scene scripts re-run on load).
|
||||
local switched = false
|
||||
ecs.subscribe_event("portal_entered", function(data)
|
||||
if switched then
|
||||
return
|
||||
end
|
||||
switched = true
|
||||
ecs.switch_scene("scenes/cave.json", { target = "CaveEntrance" })
|
||||
end)
|
||||
|
||||
-- =============================================================================
|
||||
-- Behavior tree usage (AI and actuator/player-action trees)
|
||||
-- =============================================================================
|
||||
-- The built-in "switchScene" behavior tree node queues the same scene switch
|
||||
-- when it becomes active, so SmartObject/GOAP trees and player actions can
|
||||
-- trigger scene switches without writing Lua handlers:
|
||||
--
|
||||
-- ecs.behavior_tree.create_scene_switch_node("scenes/cave.json", "CaveEntrance")
|
||||
-- -> { type = "switchScene", name = "scenes/cave.json",
|
||||
-- params = "@CaveEntrance" }
|
||||
--
|
||||
-- ecs.behavior_tree.create_scene_switch_node("scenes/cave.json", 10, 5, 3, 90)
|
||||
-- -> { type = "switchScene", name = "scenes/cave.json",
|
||||
-- params = "10.0,5.0,3.0,90.0" }
|
||||
--
|
||||
-- The same node can be written by hand or built with the generic helper:
|
||||
-- ecs.behavior_tree.create_node("switchScene", "scenes/cave.json",
|
||||
-- "@CaveEntrance")
|
||||
--
|
||||
-- See behavior_tree_example.lua for a full action example.
|
||||
@@ -417,6 +417,65 @@ static int luaCreateNode(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua: ecs.behavior_tree.create_scene_switch_node(path) -> table
|
||||
// ecs.behavior_tree.create_scene_switch_node(path, "EntityName") -> table
|
||||
// ecs.behavior_tree.create_scene_switch_node(path, x, y, z [, yawDeg])
|
||||
// -> table
|
||||
// ---------------------------------------------------------------------------
|
||||
// Creates a "switchScene" behavior tree node that queues a scene switch via
|
||||
// EditorApp::switchScene when the node becomes active. Works from AI trees
|
||||
// (SmartObject/GOAP) and actuator (player action) trees alike; the actual
|
||||
// switch is deferred to the start of the next frame.
|
||||
//
|
||||
// Examples:
|
||||
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json")
|
||||
// -> { type = "switchScene", name = "scenes/level2.json" }
|
||||
//
|
||||
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json", "SpawnPoint")
|
||||
// -> { type = "switchScene", name = ..., params = "@SpawnPoint" }
|
||||
//
|
||||
// ecs.behavior_tree.create_scene_switch_node("scenes/level2.json", 10, 5, 3, 90)
|
||||
// -> { type = "switchScene", name = ..., params = "10,5,3,90" }
|
||||
|
||||
static int luaCreateSceneSwitchNode(lua_State *L)
|
||||
{
|
||||
const char *path = luaL_checkstring(L, 1);
|
||||
|
||||
lua_newtable(L);
|
||||
|
||||
lua_pushstring(L, "switchScene");
|
||||
lua_setfield(L, -2, "type");
|
||||
|
||||
lua_pushstring(L, path);
|
||||
lua_setfield(L, -2, "name");
|
||||
|
||||
/* Note: lua_isstring() is true for numbers as well, so the
|
||||
* argument kind must be tested with lua_type(). */
|
||||
if (lua_type(L, 2) == LUA_TSTRING) {
|
||||
/* Teleport to a named entity in the new scene. */
|
||||
lua_pushfstring(L, "@%s", lua_tostring(L, 2));
|
||||
lua_setfield(L, -2, "params");
|
||||
} else if (lua_isnumber(L, 2) && lua_isnumber(L, 3) &&
|
||||
lua_isnumber(L, 4)) {
|
||||
/* Teleport to a world-space position, optional yaw. */
|
||||
if (lua_isnumber(L, 5)) {
|
||||
lua_pushfstring(L, "%f,%f,%f,%f",
|
||||
(double)lua_tonumber(L, 2),
|
||||
(double)lua_tonumber(L, 3),
|
||||
(double)lua_tonumber(L, 4),
|
||||
(double)lua_tonumber(L, 5));
|
||||
} else {
|
||||
lua_pushfstring(L, "%f,%f,%f", (double)lua_tonumber(L, 2),
|
||||
(double)lua_tonumber(L, 3),
|
||||
(double)lua_tonumber(L, 4));
|
||||
}
|
||||
lua_setfield(L, -2, "params");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public C++ API: get list of registered Lua node names
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -461,6 +520,9 @@ void registerLuaBehaviorTreeApi(lua_State *L)
|
||||
lua_pushcfunction(L, luaCreateNode);
|
||||
lua_setfield(L, -2, "create_node");
|
||||
|
||||
lua_pushcfunction(L, luaCreateSceneSwitchNode);
|
||||
lua_setfield(L, -2, "create_scene_switch_node");
|
||||
|
||||
// Set behavior_tree as a field of ecs
|
||||
lua_setfield(L, -2, "behavior_tree");
|
||||
|
||||
|
||||
@@ -52,7 +52,24 @@
|
||||
* setValue, checkValue, blackboardDump, delay, teleportToChild,
|
||||
* disablePhysics, enablePhysics, sendEvent, hasItem, hasItemByName,
|
||||
* countItem, pickupItem, dropItem, useItem, addItemToInventory,
|
||||
* luaTask
|
||||
* switchScene, luaTask
|
||||
*
|
||||
* ecs.behavior_tree.create_scene_switch_node(path [, target]) -> table
|
||||
* Convenience wrapper that creates a "switchScene" node table.
|
||||
* `path` is the scene JSON path to switch to (node name).
|
||||
* Optional `target` selects the player teleport destination:
|
||||
* - "@EntityName" string: teleport to the named entity's
|
||||
* TransformComponent in the new scene;
|
||||
* - x, y, z [, yawDeg] numbers: explicit world position and
|
||||
* optional yaw in degrees.
|
||||
* The switch is queued once per node activation and executed at
|
||||
* the start of the next frame (see EditorApp::switchScene).
|
||||
*
|
||||
* Examples:
|
||||
* ecs.behavior_tree.create_scene_switch_node("scenes/town.json",
|
||||
* "@PlayerStart")
|
||||
* ecs.behavior_tree.create_scene_switch_node("scenes/town.json",
|
||||
* 100, 5, 30, 90)
|
||||
*
|
||||
* Example:
|
||||
* -- Register a Lua node handler
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "LuaSceneSwitchApi.hpp"
|
||||
#include "../EditorApp.hpp"
|
||||
#include <OgreLogManager.h>
|
||||
|
||||
namespace editScene
|
||||
{
|
||||
|
||||
static EditorApp *s_editorApp = nullptr;
|
||||
|
||||
void setSceneSwitchEditorApp(EditorApp *app)
|
||||
{
|
||||
s_editorApp = app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lua: ecs.switch_scene(path [, opts]) -> bool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static int luaSwitchScene(lua_State *L)
|
||||
{
|
||||
if (!s_editorApp) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"ecs.switch_scene: no EditorApp set");
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *path = luaL_checkstring(L, 1);
|
||||
|
||||
SceneSwitchOptions opts;
|
||||
if (lua_istable(L, 2)) {
|
||||
/* position = { x = .., y = .., z = .. } (world space) */
|
||||
lua_getfield(L, 2, "position");
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_getfield(L, -1, "x");
|
||||
lua_getfield(L, -2, "y");
|
||||
lua_getfield(L, -3, "z");
|
||||
if (lua_isnumber(L, -3) && lua_isnumber(L, -2) &&
|
||||
lua_isnumber(L, -1)) {
|
||||
opts.hasPosition = true;
|
||||
opts.posX = lua_tonumber(L, -3);
|
||||
opts.posY = lua_tonumber(L, -2);
|
||||
opts.posZ = lua_tonumber(L, -1);
|
||||
}
|
||||
lua_pop(L, 3);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
/* rotation = { w = .., x = .., y = .., z = .. } */
|
||||
lua_getfield(L, 2, "rotation");
|
||||
if (lua_istable(L, -1)) {
|
||||
lua_getfield(L, -1, "w");
|
||||
lua_getfield(L, -2, "x");
|
||||
lua_getfield(L, -3, "y");
|
||||
lua_getfield(L, -4, "z");
|
||||
if (lua_isnumber(L, -4) && lua_isnumber(L, -3) &&
|
||||
lua_isnumber(L, -2) && lua_isnumber(L, -1)) {
|
||||
opts.hasRotation = true;
|
||||
opts.rotation = Ogre::Quaternion(
|
||||
(float)lua_tonumber(L, -4),
|
||||
(float)lua_tonumber(L, -3),
|
||||
(float)lua_tonumber(L, -2),
|
||||
(float)lua_tonumber(L, -1));
|
||||
}
|
||||
lua_pop(L, 4);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
/* yaw = degrees around +Y (only used when rotation is unset) */
|
||||
lua_getfield(L, 2, "yaw");
|
||||
if (lua_isnumber(L, -1) && !opts.hasRotation) {
|
||||
opts.hasRotation = true;
|
||||
opts.rotation = Ogre::Quaternion(
|
||||
Ogre::Degree((float)lua_tonumber(L, -1)),
|
||||
Ogre::Vector3::UNIT_Y);
|
||||
}
|
||||
lua_pop(L, 1);
|
||||
|
||||
/* target = "entity name with a Transform component" */
|
||||
lua_getfield(L, 2, "target");
|
||||
if (lua_isstring(L, -1))
|
||||
opts.targetEntityName = lua_tostring(L, -1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
|
||||
lua_pushboolean(L, s_editorApp->switchScene(path, opts) ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register the scene switch API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void registerLuaSceneSwitchApi(lua_State *L)
|
||||
{
|
||||
// Get or create the "ecs" global table
|
||||
lua_getglobal(L, "ecs");
|
||||
if (lua_isnil(L, -1)) {
|
||||
lua_pop(L, 1);
|
||||
lua_newtable(L);
|
||||
}
|
||||
|
||||
lua_pushcfunction(L, luaSwitchScene);
|
||||
lua_setfield(L, -2, "switch_scene");
|
||||
|
||||
// Set the global
|
||||
lua_setglobal(L, "ecs");
|
||||
}
|
||||
|
||||
} // namespace editScene
|
||||
@@ -0,0 +1,40 @@
|
||||
#ifndef EDITSCENE_LUA_SCENESWITCH_API_HPP
|
||||
#define EDITSCENE_LUA_SCENESWITCH_API_HPP
|
||||
#pragma once
|
||||
|
||||
#include <lua.hpp>
|
||||
|
||||
class EditorApp;
|
||||
|
||||
/**
|
||||
* @file LuaSceneSwitchApi.hpp
|
||||
* @brief Lua API for switching scenes.
|
||||
*
|
||||
* Exposed Lua globals (in the "ecs" table):
|
||||
* ecs.switch_scene(path [, opts]) -> bool (true when the switch was queued)
|
||||
*
|
||||
* opts is an optional table:
|
||||
* position = { x = 10, y = 5, z = 3 } -- world-space destination
|
||||
* yaw = 90 -- degrees around +Y
|
||||
* rotation = { w = 1, x = 0, y = 0, z = 0 }
|
||||
* target = "SpawnPoint" -- entity with a Transform component in the
|
||||
* -- new scene; takes precedence over position
|
||||
*/
|
||||
|
||||
namespace editScene
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Set the EditorApp instance used by the scene switch API.
|
||||
* Called once from EditorApp::setup().
|
||||
*/
|
||||
void setSceneSwitchEditorApp(EditorApp *app);
|
||||
|
||||
/**
|
||||
* @brief Register the scene switch Lua API into the "ecs" global table.
|
||||
*/
|
||||
void registerLuaSceneSwitchApi(lua_State *L);
|
||||
|
||||
} // namespace editScene
|
||||
|
||||
#endif // EDITSCENE_LUA_SCENESWITCH_API_HPP
|
||||
@@ -272,6 +272,13 @@ void ActuatorSystem::update(float deltaTime)
|
||||
return;
|
||||
}
|
||||
|
||||
/* Targeting/prompts project through ImGui::GetMainViewport(); in
|
||||
* headless mode there is no ImGui context at all, so skip the
|
||||
* collection/label/input part (cooldowns above and the executing
|
||||
* action block still run). */
|
||||
if (!ImGui::GetCurrentContext())
|
||||
return;
|
||||
|
||||
// Find player character (resolve spawned instance if controller
|
||||
// targets a spawner).
|
||||
flecs::entity playerCharacter = flecs::entity::null();
|
||||
@@ -550,6 +557,10 @@ void ActuatorSystem::render()
|
||||
EditorApp::GamePlayState::Playing)
|
||||
return;
|
||||
|
||||
/* Headless runs have no ImGui context. */
|
||||
if (!ImGui::GetCurrentContext())
|
||||
return;
|
||||
|
||||
ImDrawList *drawList = ImGui::GetBackgroundDrawList();
|
||||
if (!drawList)
|
||||
return;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "ItemRegistry.hpp"
|
||||
#include "ItemStateRegistry.hpp"
|
||||
#include "EventBus.hpp"
|
||||
#include "../EditorApp.hpp"
|
||||
#include "../components/BehaviorTree.hpp"
|
||||
#include "../components/ActionDatabase.hpp"
|
||||
#include "../components/GoapBlackboard.hpp"
|
||||
@@ -348,6 +349,59 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
|
||||
return Status::success;
|
||||
}
|
||||
|
||||
if (node.type == "switchScene") {
|
||||
if (!isNewlyActive(state, &node))
|
||||
return Status::success;
|
||||
if (!m_editorApp) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"[BT] switchScene: no EditorApp set");
|
||||
return Status::failure;
|
||||
}
|
||||
|
||||
/* params: "@EntityName" teleports the player to a named
|
||||
* entity in the new scene; "x,y,z[,yawDeg]" teleports to a
|
||||
* world-space position; empty keeps the current spot. */
|
||||
SceneSwitchOptions opts;
|
||||
const Ogre::String &p = node.params;
|
||||
if (!p.empty()) {
|
||||
if (p[0] == '@') {
|
||||
opts.targetEntityName = p.substr(1);
|
||||
} else {
|
||||
float vals[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
int count = 0;
|
||||
const char *s = p.c_str();
|
||||
while (count < 4) {
|
||||
char *end = nullptr;
|
||||
float v = strtof(s, &end);
|
||||
if (end == s)
|
||||
break;
|
||||
vals[count++] = v;
|
||||
while (*end == ' ' || *end == '\t')
|
||||
end++;
|
||||
if (*end != ',')
|
||||
break;
|
||||
s = end + 1;
|
||||
}
|
||||
if (count >= 3) {
|
||||
opts.hasPosition = true;
|
||||
opts.posX = vals[0];
|
||||
opts.posY = vals[1];
|
||||
opts.posZ = vals[2];
|
||||
}
|
||||
if (count >= 4) {
|
||||
opts.hasRotation = true;
|
||||
opts.rotation = Ogre::Quaternion(
|
||||
Ogre::Degree(vals[3]),
|
||||
Ogre::Vector3::UNIT_Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m_editorApp->switchScene(node.name, opts) ?
|
||||
Status::success :
|
||||
Status::failure;
|
||||
}
|
||||
|
||||
if (node.type == "delay") {
|
||||
char key[32];
|
||||
snprintf(key, sizeof(key), "%p", (void *)&node);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
class AnimationTreeSystem;
|
||||
class CharacterSystem;
|
||||
class EditorApp;
|
||||
|
||||
/**
|
||||
* Evaluates data-driven BehaviorTreeComponent each frame.
|
||||
@@ -30,6 +31,15 @@ public:
|
||||
|
||||
void update(float deltaTime);
|
||||
|
||||
/**
|
||||
* Set the EditorApp used by the "switchScene" behavior tree node
|
||||
* to queue scene switches.
|
||||
*/
|
||||
void setEditorApp(EditorApp *app)
|
||||
{
|
||||
m_editorApp = app;
|
||||
}
|
||||
|
||||
/** Result of a single behavior tree evaluation tick. */
|
||||
enum class Status { success, failure, running };
|
||||
|
||||
@@ -89,6 +99,7 @@ private:
|
||||
Ogre::SceneManager *m_sceneMgr;
|
||||
AnimationTreeSystem *m_animSystem;
|
||||
CharacterSystem *m_charSystem;
|
||||
EditorApp *m_editorApp = nullptr;
|
||||
|
||||
std::unordered_map<flecs::entity_t, RunnerState> m_runnerStates;
|
||||
std::unordered_map<flecs::entity_t, RunnerState> m_actionDebugStates;
|
||||
|
||||
@@ -63,6 +63,8 @@ void CellGridSystem::update()
|
||||
if (!m_initialized)
|
||||
return;
|
||||
|
||||
cleanupDestroyedEntities();
|
||||
|
||||
// Process dirty cell grids
|
||||
m_cellGridQuery.each(
|
||||
[&](flecs::entity entity, CellGridComponent &grid) {
|
||||
@@ -100,7 +102,26 @@ void CellGridSystem::update()
|
||||
currentTextureVersion = it->second.textureVersion;
|
||||
}
|
||||
|
||||
// If no stored texture, try to find from parent material
|
||||
// If no stored texture, check the grid entity's own material
|
||||
// first (a CellGrid entity may carry its own
|
||||
// ProceduralMaterial, e.g. a standalone interior without a
|
||||
// Lot/District/Town parent)
|
||||
if (!currentTextureEntity.is_valid() &&
|
||||
entity.has<ProceduralMaterialComponent>()) {
|
||||
const auto &mat =
|
||||
entity.get<ProceduralMaterialComponent>();
|
||||
if (mat.diffuseTextureEntity.is_valid() &&
|
||||
mat.diffuseTextureEntity.is_alive() &&
|
||||
mat.diffuseTextureEntity.has<ProceduralTextureComponent>()) {
|
||||
currentTextureEntity =
|
||||
mat.diffuseTextureEntity;
|
||||
const auto &tex =
|
||||
mat.diffuseTextureEntity.get<ProceduralTextureComponent>();
|
||||
currentTextureVersion = tex.version;
|
||||
}
|
||||
}
|
||||
|
||||
// If still no texture, try to find from parent material
|
||||
if (!currentTextureEntity.is_valid()) {
|
||||
flecs::entity parent = entity.parent();
|
||||
while (parent.is_valid() && parent.is_alive()) {
|
||||
@@ -282,9 +303,20 @@ void CellGridSystem::buildCellGrid(flecs::entity entity,
|
||||
flecs::entity townEntity = flecs::entity::null();
|
||||
flecs::entity materialEntity = flecs::entity::null();
|
||||
|
||||
// A CellGrid entity may carry its own ProceduralMaterial (e.g. a
|
||||
// standalone interior without a Lot/District/Town parent)
|
||||
if (entity.has<ProceduralMaterialComponent>()) {
|
||||
const auto &mat = entity.get<ProceduralMaterialComponent>();
|
||||
if (!mat.materialName.empty()) {
|
||||
materialName = mat.materialName;
|
||||
materialEntity = entity;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for ProceduralMaterial in parent hierarchy (Lot -> District -> Town)
|
||||
flecs::entity parent = entity.parent();
|
||||
while (parent.is_valid() && parent.is_alive()) {
|
||||
while (!materialEntity.is_valid() && parent.is_valid() &&
|
||||
parent.is_alive()) {
|
||||
if (parent.has<LotComponent>()) {
|
||||
auto &lot = parent.get<LotComponent>();
|
||||
if (lot.proceduralMaterialEntity.is_valid() && lot.proceduralMaterialEntity.is_alive() &&
|
||||
@@ -2546,6 +2578,55 @@ void CellGridSystem::destroyCellGridMeshes(CellGridComponent &grid)
|
||||
// The actual cleanup needs to happen there
|
||||
}
|
||||
|
||||
void CellGridSystem::cleanupDestroyedEntities()
|
||||
{
|
||||
// Cell grids: full teardown via destroyCellGridMeshes
|
||||
std::vector<flecs::entity> deadGrids;
|
||||
for (const auto &pair : m_entityMeshes) {
|
||||
flecs::entity e = m_world.entity(pair.first);
|
||||
if (!e.is_alive() || !e.has<CellGridComponent>())
|
||||
deadGrids.push_back(e);
|
||||
}
|
||||
for (flecs::entity e : deadGrids) {
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
"CellGrid: cleaning up meshes of destroyed entity " +
|
||||
std::to_string(e.id()));
|
||||
destroyCellGridMeshes(e);
|
||||
}
|
||||
|
||||
// District plazas and lot bases (same pattern as the build paths)
|
||||
auto sweepMap = [&](auto &map, auto hasOwnerComponent) {
|
||||
for (auto it = map.begin(); it != map.end();) {
|
||||
flecs::entity e = m_world.entity(it->first);
|
||||
if (e.is_alive() && hasOwnerComponent(e)) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
if (it->second.entity) {
|
||||
try {
|
||||
m_sceneMgr->destroyEntity(it->second.entity);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
if (!it->second.meshName.empty()) {
|
||||
try {
|
||||
Ogre::MeshManager::getSingleton().remove(
|
||||
it->second.meshName);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
destroyPhysicsColliders(e);
|
||||
it = map.erase(it);
|
||||
}
|
||||
};
|
||||
sweepMap(m_plazaMeshes, [](flecs::entity e) {
|
||||
return e.has<DistrictComponent>();
|
||||
});
|
||||
sweepMap(m_lotBaseMeshes, [](flecs::entity e) {
|
||||
return e.has<LotComponent>();
|
||||
});
|
||||
}
|
||||
|
||||
void CellGridSystem::destroyCellGridMeshes(flecs::entity entity)
|
||||
{
|
||||
auto it = m_entityMeshes.find(entity.id());
|
||||
|
||||
@@ -184,6 +184,12 @@ private:
|
||||
void destroyCellGridMeshes(struct CellGridComponent &grid);
|
||||
void destroyCellGridMeshes(flecs::entity entity);
|
||||
|
||||
// Remove meshes/colliders whose owning entity was destroyed
|
||||
// (scene switch, entity deletion). Polled instead of an OnRemove
|
||||
// observer because observer registration shifts entity IDs, which
|
||||
// breaks SceneSerializer's hardcoded ID resolution (see initialize()).
|
||||
void cleanupDestroyedEntities();
|
||||
|
||||
// Physics helpers
|
||||
void addPhysicsCollider(flecs::entity physicsParent,
|
||||
const std::string &meshName,
|
||||
|
||||
@@ -737,6 +737,22 @@ bool CharacterRegistry::despawnCharacter(uint64_t id)
|
||||
flecs::entity e = findSpawnedEntity(id);
|
||||
if (!e.is_alive())
|
||||
return false;
|
||||
|
||||
/* Persist the live position/rotation into the registry record so a
|
||||
* later respawn does not lose where the character was. This mirrors
|
||||
* the sync loop in EditorApp::saveGame(). */
|
||||
CharacterRecord *rec = findCharacter(id);
|
||||
if (rec && e.has<TransformComponent>()) {
|
||||
const auto &t = e.get<TransformComponent>();
|
||||
if (t.node) {
|
||||
rec->position = t.node->_getDerivedPosition();
|
||||
rec->rotation = t.node->_getDerivedOrientation();
|
||||
} else {
|
||||
rec->position = t.position;
|
||||
rec->rotation = t.rotation;
|
||||
}
|
||||
}
|
||||
|
||||
e.destruct();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,28 @@ void CharacterSpawnerSystem::despawn(flecs::entity spawnerEntity)
|
||||
m_spawnedVersions.erase(spawnerEntity.id());
|
||||
m_lastSpawnerTransforms.erase(spawnerEntity.id());
|
||||
|
||||
/* Persist the live position/rotation into the registry record so a
|
||||
* later respawn does not lose where the character was. This mirrors
|
||||
* the sync loop in EditorApp::saveGame(). */
|
||||
if (character.is_alive() &&
|
||||
character.has<CharacterIdentityComponent>() &&
|
||||
character.has<TransformComponent>()) {
|
||||
uint64_t rid =
|
||||
character.get<CharacterIdentityComponent>().registryId;
|
||||
auto *rec = CharacterRegistry::getSingleton().findCharacter(rid);
|
||||
if (rec) {
|
||||
const auto &t = character.get<TransformComponent>();
|
||||
if (t.node) {
|
||||
rec->position = t.node->_getDerivedPosition();
|
||||
rec->rotation =
|
||||
t.node->_getDerivedOrientation();
|
||||
} else {
|
||||
rec->position = t.position;
|
||||
rec->rotation = t.rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroySpawnedEntity(character);
|
||||
|
||||
Ogre::LogManager::getSingleton().logMessage(
|
||||
@@ -221,6 +243,22 @@ void CharacterSpawnerSystem::despawn(flecs::entity spawnerEntity)
|
||||
std::to_string(spawnerEntity.id()));
|
||||
}
|
||||
|
||||
void CharacterSpawnerSystem::releaseSpawned(flecs::entity spawnerEntity)
|
||||
{
|
||||
m_spawnedEntities.erase(spawnerEntity.id());
|
||||
m_spawnedVersions.erase(spawnerEntity.id());
|
||||
m_lastSpawnerTransforms.erase(spawnerEntity.id());
|
||||
m_lockedSpawners.erase(spawnerEntity.id());
|
||||
}
|
||||
|
||||
void CharacterSpawnerSystem::clearSpawnerState()
|
||||
{
|
||||
m_spawnedEntities.clear();
|
||||
m_spawnedVersions.clear();
|
||||
m_lastSpawnerTransforms.clear();
|
||||
m_lockedSpawners.clear();
|
||||
}
|
||||
|
||||
void CharacterSpawnerSystem::spawn(flecs::entity spawnerEntity)
|
||||
{
|
||||
if (!spawnerEntity.is_alive() ||
|
||||
|
||||
@@ -69,6 +69,22 @@ public:
|
||||
*/
|
||||
bool isSpawnerLocked(flecs::entity spawnerEntity) const;
|
||||
|
||||
/**
|
||||
* Forget all bookkeeping for a spawner (including its lock) WITHOUT
|
||||
* despawning the spawned character. Used by scene switching when the
|
||||
* player character is carried over into the next scene while its
|
||||
* spawner entity is destroyed with the old scene.
|
||||
*/
|
||||
void releaseSpawned(flecs::entity spawnerEntity);
|
||||
|
||||
/**
|
||||
* Clear all spawner bookkeeping (spawned map, versions, transform
|
||||
* snapshots, locks) without touching any entities. Call after the
|
||||
* entities themselves have been destroyed (scene switch cleanup) so
|
||||
* recycled flecs entity ids cannot alias stale entries.
|
||||
*/
|
||||
void clearSpawnerState();
|
||||
|
||||
private:
|
||||
void spawnCharacter(flecs::entity spawnerEntity,
|
||||
const struct CharacterSpawnerComponent &spawner);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "../components/GeneratedPhysicsTag.hpp"
|
||||
#include "EditorUISystem.hpp"
|
||||
#include "../EditorApp.hpp"
|
||||
#include "DialogueSystem.hpp"
|
||||
#include "PrefabSystem.hpp"
|
||||
#include "ItemRegistry.hpp"
|
||||
@@ -493,11 +494,14 @@ void EditorUISystem::renderHierarchyWindow()
|
||||
if (ImGui::BeginMenu("File")) {
|
||||
if (ImGui::MenuItem("Save Scene...",
|
||||
"Ctrl+S")) {
|
||||
showFileDialog(true);
|
||||
showFileDialog(FileDialogMode::Save);
|
||||
}
|
||||
if (ImGui::MenuItem("Load Scene...",
|
||||
"Ctrl+O")) {
|
||||
showFileDialog(false);
|
||||
showFileDialog(FileDialogMode::Load);
|
||||
}
|
||||
if (ImGui::MenuItem("Switch Scene...")) {
|
||||
showFileDialog(FileDialogMode::Switch);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Save Action DB",
|
||||
@@ -1178,6 +1182,22 @@ void EditorUISystem::deleteEntity(flecs::entity entity)
|
||||
}
|
||||
}
|
||||
|
||||
/* Clean up the Ogre light before the transform node: destroying the
|
||||
* node would only detach the light, leaving it alive in the
|
||||
* SceneManager with no parent, and OGRE asserts
|
||||
* (Light::getDerivedDirection) when rendering such a light. */
|
||||
if (entity.has<LightComponent>()) {
|
||||
auto &light = entity.get_mut<LightComponent>();
|
||||
if (light.light) {
|
||||
Ogre::SceneNode *parent =
|
||||
light.light->getParentSceneNode();
|
||||
if (parent)
|
||||
parent->detachObject(light.light);
|
||||
m_sceneMgr->destroyLight(light.light);
|
||||
light.light = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up transform node
|
||||
if (entity.has<TransformComponent>()) {
|
||||
auto &transform = entity.get_mut<TransformComponent>();
|
||||
@@ -1362,10 +1382,10 @@ void EditorUISystem::loadScene(const std::string &filepath)
|
||||
|
||||
// File Dialog Implementation
|
||||
|
||||
void EditorUISystem::showFileDialog(bool isSave)
|
||||
void EditorUISystem::showFileDialog(FileDialogMode mode)
|
||||
{
|
||||
m_showFileDialog = true;
|
||||
m_fileDialogIsSave = isSave;
|
||||
m_fileDialogMode = mode;
|
||||
m_refreshDirectory = true;
|
||||
|
||||
// Initialize current path if empty
|
||||
@@ -1374,7 +1394,8 @@ void EditorUISystem::showFileDialog(bool isSave)
|
||||
}
|
||||
|
||||
// Set default filename for save dialog
|
||||
if (isSave && strlen(m_filenameBuffer) == 0) {
|
||||
if (mode == FileDialogMode::Save &&
|
||||
strlen(m_filenameBuffer) == 0) {
|
||||
std::strncpy(m_filenameBuffer, "scene.json",
|
||||
sizeof(m_filenameBuffer) - 1);
|
||||
}
|
||||
@@ -1387,7 +1408,10 @@ void EditorUISystem::closeFileDialog()
|
||||
|
||||
void EditorUISystem::renderFileDialog()
|
||||
{
|
||||
const char *title = m_fileDialogIsSave ? "Save Scene" : "Load Scene";
|
||||
const char *title = m_fileDialogMode == FileDialogMode::Switch ?
|
||||
"Switch Scene" :
|
||||
(m_fileDialogMode == FileDialogMode::Save ? "Save Scene" :
|
||||
"Load Scene");
|
||||
|
||||
// Center the dialog
|
||||
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
|
||||
@@ -1441,8 +1465,8 @@ void EditorUISystem::renderFileDialog()
|
||||
std::filesystem::path(m_currentPath) / name;
|
||||
bool isDirectory = std::filesystem::is_directory(fullPath);
|
||||
|
||||
// Skip non-json files in load mode (but show directories)
|
||||
if (!m_fileDialogIsSave && !isDirectory) {
|
||||
// Skip non-json files in load/switch mode (but show directories)
|
||||
if (m_fileDialogMode != FileDialogMode::Save && !isDirectory) {
|
||||
if (fullPath.extension() != ".json") {
|
||||
continue;
|
||||
}
|
||||
@@ -1482,12 +1506,14 @@ void EditorUISystem::renderFileDialog()
|
||||
sizeof(m_filenameBuffer));
|
||||
|
||||
// Buttons
|
||||
if (ImGui::Button(m_fileDialogIsSave ? "Save" : "Load",
|
||||
ImVec2(120, 0))) {
|
||||
const char *confirmLabel =
|
||||
m_fileDialogMode == FileDialogMode::Switch ? "Switch" :
|
||||
(m_fileDialogMode == FileDialogMode::Save ? "Save" : "Load");
|
||||
if (ImGui::Button(confirmLabel, ImVec2(120, 0))) {
|
||||
std::string filename(m_filenameBuffer);
|
||||
if (!filename.empty()) {
|
||||
// Add .json extension if missing in save mode
|
||||
if (m_fileDialogIsSave &&
|
||||
if (m_fileDialogMode == FileDialogMode::Save &&
|
||||
filename.find('.') == std::string::npos) {
|
||||
filename += ".json";
|
||||
}
|
||||
@@ -1497,8 +1523,11 @@ void EditorUISystem::renderFileDialog()
|
||||
filename)
|
||||
.string();
|
||||
|
||||
if (m_fileDialogIsSave) {
|
||||
if (m_fileDialogMode == FileDialogMode::Save) {
|
||||
saveScene(fullPath);
|
||||
} else if (m_fileDialogMode == FileDialogMode::Switch) {
|
||||
if (m_editorApp)
|
||||
m_editorApp->switchScene(fullPath);
|
||||
} else {
|
||||
loadScene(fullPath);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class EditorPhysicsSystem;
|
||||
class BuoyancySystem;
|
||||
class NormalDebugSystem;
|
||||
class EditorCamera;
|
||||
class EditorApp;
|
||||
|
||||
namespace Ogre
|
||||
{
|
||||
@@ -180,6 +181,14 @@ public:
|
||||
m_worldMapPanel.setEditorCamera(camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the EditorApp instance (used to queue scene switches)
|
||||
*/
|
||||
void setEditorApp(EditorApp *app)
|
||||
{
|
||||
m_editorApp = app;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable editor UI rendering
|
||||
*/
|
||||
@@ -207,9 +216,10 @@ public:
|
||||
void loadScene(const std::string &filepath);
|
||||
|
||||
/**
|
||||
* Show file dialog for save/load
|
||||
* Show file dialog for save/load/scene-switch
|
||||
*/
|
||||
void showFileDialog(bool isSave);
|
||||
enum class FileDialogMode { Save, Load, Switch };
|
||||
void showFileDialog(FileDialogMode mode);
|
||||
void renderFileDialog();
|
||||
void closeFileDialog();
|
||||
|
||||
@@ -296,7 +306,7 @@ private:
|
||||
|
||||
// File dialog state
|
||||
bool m_showFileDialog = false;
|
||||
bool m_fileDialogIsSave = false; // true = save, false = load
|
||||
FileDialogMode m_fileDialogMode = FileDialogMode::Load;
|
||||
std::string m_currentPath;
|
||||
std::string m_selectedFile;
|
||||
std::vector<std::string> m_directoryContents;
|
||||
@@ -332,6 +342,9 @@ private:
|
||||
// Camera reference for cursor placement/rotation
|
||||
EditorCamera *m_editorCamera = nullptr;
|
||||
|
||||
// EditorApp reference (scene switching)
|
||||
EditorApp *m_editorApp = nullptr;
|
||||
|
||||
// Navigation panel (teleport + world bookmarks)
|
||||
bool m_showNavigation = false;
|
||||
NavigationPanel m_navigationPanel;
|
||||
|
||||
@@ -45,6 +45,14 @@ PlayerControllerSystem::~PlayerControllerSystem()
|
||||
m_states.clear();
|
||||
}
|
||||
|
||||
void PlayerControllerSystem::resetControllers()
|
||||
{
|
||||
for (auto &pair : m_states) {
|
||||
shutdownController(pair.second);
|
||||
}
|
||||
m_states.clear();
|
||||
}
|
||||
|
||||
void PlayerControllerSystem::shutdownController(ControllerState &state)
|
||||
{
|
||||
if (state.targetEntity.is_alive())
|
||||
@@ -132,8 +140,29 @@ void PlayerControllerSystem::initController(flecs::entity controllerEntity,
|
||||
m_sceneMgr->getRootSceneNode()->createChildSceneNode();
|
||||
}
|
||||
|
||||
// Initialize camera to start behind character (180 degrees yaw)
|
||||
state.yaw = 180.0f;
|
||||
/* Initialize the camera behind the character's current facing
|
||||
* instead of a fixed yaw: the character model's visual forward is
|
||||
* +Z (verified against the locomotion movement direction), and
|
||||
* the camera view direction at yaw t is R_y(t) * -Z, so the yaw
|
||||
* that looks along facing f is atan2(-f.x, -f.z). This makes
|
||||
* scene spawns and scene-switch teleports (which set the
|
||||
* character's rotation) control where the player initially
|
||||
* looks; identity rotation keeps the old behind-the-character
|
||||
* view (yaw 180). */
|
||||
Ogre::Vector3 facing = Ogre::Vector3::UNIT_Z;
|
||||
if (state.targetEntity.has<TransformComponent>()) {
|
||||
const auto &t = state.targetEntity.get<TransformComponent>();
|
||||
if (t.node)
|
||||
facing = t.node->getOrientation() *
|
||||
Ogre::Vector3::UNIT_Z;
|
||||
else
|
||||
facing = t.rotation * Ogre::Vector3::UNIT_Z;
|
||||
}
|
||||
facing.y = 0.0f;
|
||||
if (facing.squaredLength() < 0.0001f)
|
||||
facing = Ogre::Vector3::UNIT_Z;
|
||||
facing.normalise();
|
||||
state.yaw = Ogre::Math::ATan2(-facing.x, -facing.z).valueDegrees();
|
||||
state.pitch = 0.0f;
|
||||
|
||||
state.initialized = true;
|
||||
|
||||
@@ -43,6 +43,14 @@ public:
|
||||
flecs::entity resolveTargetEntity(const Ogre::String &targetName,
|
||||
bool forceSpawn = false);
|
||||
|
||||
/**
|
||||
* Shut down every active controller state (removes PlayerControlled
|
||||
* tags, destroys pivot/goal nodes, unlocks spawners) and clears the
|
||||
* state map. Used by scene switching so no stale entity ids or scene
|
||||
* nodes survive the cleanup.
|
||||
*/
|
||||
void resetControllers();
|
||||
|
||||
private:
|
||||
struct ControllerState {
|
||||
flecs::entity targetEntity = flecs::entity::null();
|
||||
|
||||
@@ -96,6 +96,18 @@ void ProceduralTextureSystem::generateTexture(flecs::entity entity, ProceduralTe
|
||||
if (component.ogreTexture) {
|
||||
Ogre::TextureManager::getSingleton().remove(component.ogreTexture);
|
||||
component.ogreTexture.reset();
|
||||
} else if (Ogre::TextureManager::getSingleton().resourceExists(
|
||||
component.textureName,
|
||||
Ogre::ResourceGroupManager::
|
||||
DEFAULT_RESOURCE_GROUP_NAME)) {
|
||||
// A texture with this name may be left over from a previous
|
||||
// scene load: scene switching destroys the entity (and its
|
||||
// component) but the Ogre resource stays in the
|
||||
// TextureManager, and loadImage() below fails on the
|
||||
// duplicate name.
|
||||
Ogre::TextureManager::getSingleton().remove(
|
||||
component.textureName,
|
||||
Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME);
|
||||
}
|
||||
|
||||
// Create Ogre texture from image
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* @file scene_switch_test.cpp
|
||||
* @brief Headless tests for the scene switch API and the "switchScene"
|
||||
* behavior tree node.
|
||||
*
|
||||
* Runs without OGRE initialization (no window, no GL): the scene switch
|
||||
* API only validates the path and queues a deferred request, and the
|
||||
* behavior tree node evaluation needs neither a SceneManager nor a
|
||||
* physics world.
|
||||
*
|
||||
* Covered:
|
||||
* - ecs.switch_scene() validation and option parsing (Lua API)
|
||||
* - ecs.behavior_tree.create_scene_switch_node() (Lua helper)
|
||||
* - EditorApp::switchScene() queue semantics (C++ API)
|
||||
* - "switchScene" behavior tree node via the actuator path
|
||||
* (BehaviorTreeSystem::evaluatePlayerAction) and the AI path
|
||||
* (BehaviorTreeSystem::update with a BehaviorTreeComponent entity)
|
||||
*/
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include <OgreLogManager.h>
|
||||
|
||||
#include "../EditorApp.hpp"
|
||||
#include "../lua/LuaSceneSwitchApi.hpp"
|
||||
#include "../lua/LuaBehaviorTreeApi.hpp"
|
||||
#include "../systems/BehaviorTreeSystem.hpp"
|
||||
#include "../components/BehaviorTree.hpp"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static int testCount = 0;
|
||||
static int passCount = 0;
|
||||
|
||||
#define TEST(name) \
|
||||
do { \
|
||||
testCount++; \
|
||||
printf(" TEST %d: %s ... ", testCount, name); \
|
||||
} while (0)
|
||||
|
||||
#define PASS() \
|
||||
do { \
|
||||
passCount++; \
|
||||
printf("PASS\n"); \
|
||||
} while (0)
|
||||
|
||||
#define FAIL(msg) \
|
||||
do { \
|
||||
printf("FAIL: %s\n", std::string(msg).c_str()); \
|
||||
return 1; \
|
||||
} while (0)
|
||||
|
||||
/* Scene file referenced by the tests; content only needs to exist for
|
||||
* switchScene validation (parsing is exercised by the runtime demos). */
|
||||
#define kSceneFile "scene_switch_test_target.json"
|
||||
#define kMissingFile "scene_switch_test_missing.json"
|
||||
|
||||
static void writeTestScene()
|
||||
{
|
||||
std::ofstream out(kSceneFile);
|
||||
out << "{ \"version\": \"1.0\", \"entities\": [] }\n";
|
||||
}
|
||||
|
||||
/* Run a Lua snippet, FAIL on error. */
|
||||
#define RUN_LUA(code) \
|
||||
do { \
|
||||
if (!runLua(L, code)) \
|
||||
FAIL("Lua error in: " #code); \
|
||||
} while (0)
|
||||
|
||||
static bool runLua(lua_State *L, const char *code)
|
||||
{
|
||||
if (luaL_dostring(L, code) != LUA_OK) {
|
||||
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
|
||||
lua_pop(L, 1);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool getGlobalBool(lua_State *L, const char *name)
|
||||
{
|
||||
lua_getglobal(L, name);
|
||||
bool val = lua_toboolean(L, -1);
|
||||
lua_pop(L, 1);
|
||||
return val;
|
||||
}
|
||||
|
||||
static std::string getGlobalString(lua_State *L, const char *name)
|
||||
{
|
||||
lua_getglobal(L, name);
|
||||
const char *s = lua_tostring(L, -1);
|
||||
std::string val = s ? s : "";
|
||||
lua_pop(L, 1);
|
||||
return val;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
int main()
|
||||
{
|
||||
/* Ogre::LogManager is needed by the APIs under test (they log
|
||||
* errors); no other OGRE subsystem is initialized. */
|
||||
Ogre::LogManager logManager;
|
||||
|
||||
writeTestScene();
|
||||
|
||||
EditorApp app;
|
||||
app.setHeadless(true);
|
||||
editScene::setSceneSwitchEditorApp(&app);
|
||||
|
||||
lua_State *L = luaL_newstate();
|
||||
luaL_openlibs(L);
|
||||
|
||||
/* The scene switch API lives in the "ecs" table; create it the
|
||||
* same way LuaEntityApi would. */
|
||||
lua_newtable(L);
|
||||
lua_setglobal(L, "ecs");
|
||||
editScene::registerLuaSceneSwitchApi(L);
|
||||
editScene::registerLuaBehaviorTreeApi(L);
|
||||
|
||||
printf("Scene switch API and behavior tree node tests\n");
|
||||
printf("=============================================\n");
|
||||
|
||||
/* --- Lua: validation --- */
|
||||
|
||||
TEST("switch_scene with empty path returns false");
|
||||
RUN_LUA("r = ecs.switch_scene(\"\")");
|
||||
if (!getGlobalBool(L, "r"))
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected false for empty path");
|
||||
|
||||
TEST("switch_scene with missing file returns false");
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kMissingFile "\")");
|
||||
if (!getGlobalBool(L, "r"))
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected false for missing file");
|
||||
|
||||
TEST("switch_scene with existing file queues the switch");
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile "\")");
|
||||
if (getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
|
||||
app.getPendingSceneSwitchPath() == kSceneFile)
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected queued switch for existing file");
|
||||
|
||||
/* --- Lua: option parsing --- */
|
||||
|
||||
TEST("switch_scene parses position and yaw");
|
||||
app.clearPendingSceneSwitch();
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
|
||||
"\", { position = { x = 1.5, y = 2.5, z = -3.5 },"
|
||||
" yaw = 90 })");
|
||||
{
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
bool ok = getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
|
||||
o.hasPosition && o.posX == 1.5 && o.posY == 2.5 &&
|
||||
o.posZ == -3.5 && o.hasRotation &&
|
||||
o.targetEntityName.empty();
|
||||
/* yaw=90 degrees around +Y: w = cos(45deg), y = sin(45deg) */
|
||||
const float s = std::sqrt(0.5f);
|
||||
ok = ok && std::fabs(o.rotation.w - s) < 0.001f &&
|
||||
std::fabs(o.rotation.y - s) < 0.001f;
|
||||
if (ok)
|
||||
PASS();
|
||||
else
|
||||
FAIL("position/yaw not parsed into options");
|
||||
}
|
||||
|
||||
TEST("switch_scene parses explicit quaternion rotation");
|
||||
app.clearPendingSceneSwitch();
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
|
||||
"\", { rotation = { w = 1, x = 0, y = 0, z = 0 } })");
|
||||
{
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
if (getGlobalBool(L, "r") && !o.hasPosition && o.hasRotation &&
|
||||
o.rotation == Ogre::Quaternion::IDENTITY)
|
||||
PASS();
|
||||
else
|
||||
FAIL("rotation not parsed into options");
|
||||
}
|
||||
|
||||
TEST("switch_scene parses target entity name");
|
||||
app.clearPendingSceneSwitch();
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile
|
||||
"\", { target = \"SpawnPoint\" })");
|
||||
{
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
if (getGlobalBool(L, "r") && !o.hasPosition &&
|
||||
o.targetEntityName == "SpawnPoint")
|
||||
PASS();
|
||||
else
|
||||
FAIL("target entity name not parsed into options");
|
||||
}
|
||||
|
||||
TEST("switch_scene without options queues a plain switch");
|
||||
app.clearPendingSceneSwitch();
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kSceneFile "\")");
|
||||
{
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
if (getGlobalBool(L, "r") && app.hasPendingSceneSwitch() &&
|
||||
!o.hasPosition && !o.hasRotation &&
|
||||
o.targetEntityName.empty())
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected default options");
|
||||
}
|
||||
|
||||
TEST("a failed switch_scene call keeps the queued request");
|
||||
RUN_LUA("r = ecs.switch_scene(\"" kMissingFile "\")");
|
||||
if (!getGlobalBool(L, "r") && app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("pending request should survive a failed call");
|
||||
app.clearPendingSceneSwitch();
|
||||
|
||||
/* --- Lua: create_scene_switch_node helper --- */
|
||||
|
||||
TEST("create_scene_switch_node without teleport");
|
||||
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node(\"a.json\")\n"
|
||||
"nt = n.type\n"
|
||||
"nn = n.name\n"
|
||||
"np = n.params");
|
||||
if (getGlobalString(L, "nt") == "switchScene" &&
|
||||
getGlobalString(L, "nn") == "a.json" &&
|
||||
getGlobalString(L, "np").empty())
|
||||
PASS();
|
||||
else
|
||||
FAIL("unexpected node fields");
|
||||
|
||||
TEST("create_scene_switch_node with target entity");
|
||||
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node("
|
||||
"\"a.json\", \"SpawnPoint\")\n"
|
||||
"np = n.params");
|
||||
if (getGlobalString(L, "np") == "@SpawnPoint")
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected params '@SpawnPoint'");
|
||||
|
||||
TEST("create_scene_switch_node with position and yaw");
|
||||
RUN_LUA("n = ecs.behavior_tree.create_scene_switch_node("
|
||||
"\"a.json\", 10, 5, 3, 90)\n"
|
||||
"np = n.params");
|
||||
if (getGlobalString(L, "np") == "10.0,5.0,3.0,90.0")
|
||||
PASS();
|
||||
else {
|
||||
printf("(actual: '%s') ", getGlobalString(L, "np").c_str());
|
||||
FAIL("expected params '10.0,5.0,3.0,90.0'");
|
||||
}
|
||||
|
||||
lua_close(L);
|
||||
|
||||
/* --- C++: switchScene validation --- */
|
||||
|
||||
TEST("C++ switchScene rejects an empty path");
|
||||
app.clearPendingSceneSwitch();
|
||||
if (!app.switchScene("") && !app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected false for empty path");
|
||||
|
||||
TEST("C++ switchScene rejects a missing file");
|
||||
if (!app.switchScene(kMissingFile) && !app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected false for missing file");
|
||||
|
||||
TEST("C++ switchScene queues an existing scene");
|
||||
if (app.switchScene(kSceneFile) && app.hasPendingSceneSwitch() &&
|
||||
app.getPendingSceneSwitchPath() == kSceneFile)
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected queued switch");
|
||||
|
||||
/* --- C++: switchScene behavior tree node (actuator path) --- */
|
||||
|
||||
flecs::world world;
|
||||
flecs::entity actor = world.entity("actor");
|
||||
|
||||
BehaviorTreeSystem bts(world, nullptr, nullptr, nullptr);
|
||||
|
||||
BehaviorTreeNode switchTree;
|
||||
switchTree.type = "sequence";
|
||||
{
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kSceneFile;
|
||||
switchTree.children.push_back(leaf);
|
||||
}
|
||||
|
||||
TEST("BT switchScene fails without an EditorApp");
|
||||
app.clearPendingSceneSwitch();
|
||||
if (bts.evaluatePlayerAction(actor.id(), switchTree, 0.016f,
|
||||
true) ==
|
||||
BehaviorTreeSystem::Status::failure &&
|
||||
!app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected failure without EditorApp");
|
||||
|
||||
bts.setEditorApp(&app);
|
||||
|
||||
TEST("BT switchScene fails for a missing scene file");
|
||||
{
|
||||
BehaviorTreeNode badTree;
|
||||
badTree.type = "sequence";
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kMissingFile;
|
||||
badTree.children.push_back(leaf);
|
||||
if (bts.evaluatePlayerAction(actor.id(), badTree, 0.016f,
|
||||
true) ==
|
||||
BehaviorTreeSystem::Status::failure &&
|
||||
!app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected failure for missing scene file");
|
||||
}
|
||||
|
||||
TEST("BT switchScene queues the switch (actuator path)");
|
||||
if (bts.evaluatePlayerAction(actor.id(), switchTree, 0.016f,
|
||||
true) ==
|
||||
BehaviorTreeSystem::Status::success &&
|
||||
app.hasPendingSceneSwitch() &&
|
||||
app.getPendingSceneSwitchPath() == kSceneFile)
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected success and queued switch");
|
||||
|
||||
TEST("BT switchScene parses '@EntityName' teleport params");
|
||||
{
|
||||
app.clearPendingSceneSwitch();
|
||||
BehaviorTreeNode tree;
|
||||
tree.type = "sequence";
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kSceneFile;
|
||||
leaf.params = "@SpawnPoint";
|
||||
tree.children.push_back(leaf);
|
||||
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
|
||||
true) ==
|
||||
BehaviorTreeSystem::Status::success;
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
ok = ok && app.hasPendingSceneSwitch() &&
|
||||
o.targetEntityName == "SpawnPoint" && !o.hasPosition;
|
||||
if (ok)
|
||||
PASS();
|
||||
else
|
||||
FAIL("'@' params not parsed into options");
|
||||
}
|
||||
|
||||
TEST("BT switchScene parses 'x,y,z,yaw' teleport params");
|
||||
{
|
||||
app.clearPendingSceneSwitch();
|
||||
BehaviorTreeNode tree;
|
||||
tree.type = "sequence";
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kSceneFile;
|
||||
leaf.params = "1.5,2.5,-3.5,90";
|
||||
tree.children.push_back(leaf);
|
||||
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
|
||||
true) ==
|
||||
BehaviorTreeSystem::Status::success;
|
||||
const SceneSwitchOptions &o = app.getPendingSceneSwitchOptions();
|
||||
ok = ok && o.hasPosition && o.posX == 1.5 && o.posY == 2.5 &&
|
||||
o.posZ == -3.5 && o.hasRotation;
|
||||
const float s = std::sqrt(0.5f);
|
||||
ok = ok && std::fabs(o.rotation.w - s) < 0.001f &&
|
||||
std::fabs(o.rotation.y - s) < 0.001f;
|
||||
if (ok)
|
||||
PASS();
|
||||
else
|
||||
FAIL("positional params not parsed into options");
|
||||
}
|
||||
|
||||
TEST("BT switchScene fires only once per activation");
|
||||
{
|
||||
app.clearPendingSceneSwitch();
|
||||
BehaviorTreeNode tree;
|
||||
tree.type = "sequence";
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kMissingFile;
|
||||
tree.children.push_back(leaf);
|
||||
/* First activation: missing file -> failure, not queued. */
|
||||
bts.evaluatePlayerAction(actor.id(), tree, 0.016f, true);
|
||||
/* Second activation (no reset): the node must not fire
|
||||
* again; it reports success so the tree can move on. */
|
||||
bool ok = bts.evaluatePlayerAction(actor.id(), tree, 0.016f,
|
||||
false) ==
|
||||
BehaviorTreeSystem::Status::success;
|
||||
if (ok && !app.hasPendingSceneSwitch())
|
||||
PASS();
|
||||
else
|
||||
FAIL("node re-fired or returned wrong status");
|
||||
}
|
||||
|
||||
/* --- C++: switchScene behavior tree node (AI path) --- */
|
||||
|
||||
TEST("BT switchScene queues the switch (BehaviorTreeComponent path)");
|
||||
{
|
||||
app.clearPendingSceneSwitch();
|
||||
flecs::entity npc = world.entity("npc");
|
||||
BehaviorTreeComponent bt;
|
||||
bt.enabled = true;
|
||||
bt.root.type = "sequence";
|
||||
BehaviorTreeNode leaf;
|
||||
leaf.type = "switchScene";
|
||||
leaf.name = kSceneFile;
|
||||
bt.root.children.push_back(leaf);
|
||||
npc.set<BehaviorTreeComponent>(bt);
|
||||
bts.update(0.016f);
|
||||
if (app.hasPendingSceneSwitch() &&
|
||||
app.getPendingSceneSwitchPath() == kSceneFile)
|
||||
PASS();
|
||||
else
|
||||
FAIL("expected queued switch from update()");
|
||||
}
|
||||
|
||||
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
|
||||
testCount - passCount);
|
||||
return (passCount == testCount) ? 0 : 1;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ static ImU32 nodeTypeColorU32(const Ogre::String &type)
|
||||
return IM_COL32(0x4C, 0xCC, 0x4C, 0xFF);
|
||||
if (type == "sendEvent")
|
||||
return IM_COL32(0xFF, 0x99, 0x00, 0xFF);
|
||||
if (type == "switchScene")
|
||||
return IM_COL32(0x66, 0xFF, 0xCC, 0xFF);
|
||||
if (type == "luaTask")
|
||||
return IM_COL32(0xAA, 0x66, 0xFF, 0xFF);
|
||||
return IM_COL32(0xFF, 0xFF, 0xFF, 0xFF);
|
||||
@@ -247,6 +249,8 @@ void BehaviorTreeEditor::renderTree(BehaviorTreeNode &node,
|
||||
queueAddChild(&node, "enablePhysics");
|
||||
if (ImGui::MenuItem("Add Send Event"))
|
||||
queueAddChild(&node, "sendEvent");
|
||||
if (ImGui::MenuItem("Add Switch Scene"))
|
||||
queueAddChild(&node, "switchScene");
|
||||
if (ImGui::BeginMenu("Add Lua Task")) {
|
||||
renderLuaNodeSubmenu(&node, m_editOps);
|
||||
ImGui::EndMenu();
|
||||
@@ -306,6 +310,8 @@ void BehaviorTreeEditor::renderTree(BehaviorTreeNode &node,
|
||||
queueAddChild(&node, "enablePhysics");
|
||||
if (ImGui::MenuItem("Send Event"))
|
||||
queueAddChild(&node, "sendEvent");
|
||||
if (ImGui::MenuItem("Switch Scene"))
|
||||
queueAddChild(&node, "switchScene");
|
||||
if (ImGui::BeginMenu("Lua Task")) {
|
||||
renderLuaNodeSubmenu(&node, m_editOps);
|
||||
ImGui::EndMenu();
|
||||
@@ -365,6 +371,7 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
|
||||
"disablePhysics",
|
||||
"enablePhysics",
|
||||
"sendEvent",
|
||||
"switchScene",
|
||||
"luaTask" };
|
||||
int typeIdx = 0;
|
||||
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
|
||||
@@ -444,6 +451,9 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
|
||||
} else if (node->type == "sendEvent") {
|
||||
label = "Event Name";
|
||||
hint = "Name of the event to send via EventBus";
|
||||
} else if (node->type == "switchScene") {
|
||||
label = "Scene Path";
|
||||
hint = "Scene file to switch to (queued for next frame)";
|
||||
}
|
||||
|
||||
if (ImGui::InputText(label, buf, sizeof(buf)))
|
||||
@@ -455,7 +465,8 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
|
||||
// Params input for nodes that need it
|
||||
if (node->type == "setBit" || node->type == "setValue" ||
|
||||
node->type == "checkValue" || node->type == "delay" ||
|
||||
node->type == "sendEvent" || node->type == "luaTask") {
|
||||
node->type == "sendEvent" || node->type == "switchScene" ||
|
||||
node->type == "luaTask") {
|
||||
char pbuf[256];
|
||||
snprintf(pbuf, sizeof(pbuf), "%s",
|
||||
node->params.c_str());
|
||||
@@ -470,6 +481,8 @@ void BehaviorTreeEditor::renderProperties(BehaviorTreeNode *node)
|
||||
phint = "Duration in seconds (float, default 1.0)";
|
||||
else if (node->type == "sendEvent")
|
||||
phint = "Event params: key=val key2=val2 ...";
|
||||
else if (node->type == "switchScene")
|
||||
phint = "Teleport: @EntityName or x,y,z[,yawDeg] (optional)";
|
||||
else if (node->type == "luaTask")
|
||||
phint = "Parameters: key=val,key2=val2 passed to Lua function";
|
||||
if (ImGui::InputText("Params", pbuf, sizeof(pbuf)))
|
||||
|
||||
@@ -273,10 +273,19 @@ void CellGridEditor::renderScriptEditor(CellGridComponent& grid)
|
||||
|
||||
void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridComponent& grid)
|
||||
{
|
||||
// Find available texture rectangles from parent material
|
||||
// Find available texture rectangles: the grid entity may carry its own
|
||||
// ProceduralMaterial (matching CellGridSystem::buildCellGrid), otherwise
|
||||
// fall back to the parent hierarchy (Lot -> District -> Town).
|
||||
flecs::entity textureEntity = flecs::entity::null();
|
||||
if (entity.has<ProceduralMaterialComponent>()) {
|
||||
const auto& mat = entity.get<ProceduralMaterialComponent>();
|
||||
if (mat.diffuseTextureEntity.is_alive() &&
|
||||
mat.diffuseTextureEntity.has<ProceduralTextureComponent>()) {
|
||||
textureEntity = mat.diffuseTextureEntity;
|
||||
}
|
||||
}
|
||||
flecs::entity parent = entity.parent();
|
||||
while (parent.is_alive()) {
|
||||
while (!textureEntity.is_alive() && parent.is_alive()) {
|
||||
if (parent.has<LotComponent>()) {
|
||||
auto& lot = parent.get<LotComponent>();
|
||||
if (lot.proceduralMaterialEntity.is_alive() &&
|
||||
@@ -315,7 +324,10 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
|
||||
}
|
||||
|
||||
if (!textureEntity.is_alive() || !textureEntity.has<ProceduralTextureComponent>()) {
|
||||
ImGui::TextDisabled("No ProceduralMaterial with texture found in parent hierarchy");
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
|
||||
"ERROR: no ProceduralMaterial with a texture found on this entity "
|
||||
"or in the parent hierarchy - the grid will use the default "
|
||||
"material and default UV mapping");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,7 +335,9 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
|
||||
const auto& namedRects = texture.getAllNamedRects();
|
||||
|
||||
if (namedRects.empty()) {
|
||||
ImGui::TextDisabled("No named rectangles defined in texture");
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
|
||||
"ERROR: no named rectangles defined in the texture - all parts "
|
||||
"will use default UV mapping");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -342,24 +356,37 @@ void CellGridEditor::renderTextureRectEditor(flecs::entity entity, CellGridCompo
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string comboItems;
|
||||
for (size_t i = 0; i < rectNames.size(); ++i) {
|
||||
if (i > 0) comboItems += '\0';
|
||||
comboItems += rectNames[i];
|
||||
}
|
||||
comboItems += '\0';
|
||||
|
||||
|
||||
int newIndex = currentIndex;
|
||||
bool changed = false;
|
||||
if (ImGui::Combo(label, &newIndex, comboItems.c_str())) {
|
||||
if (newIndex == 0) {
|
||||
currentValue.clear();
|
||||
} else {
|
||||
currentValue = rectNames[newIndex];
|
||||
}
|
||||
return true;
|
||||
changed = true;
|
||||
}
|
||||
return false;
|
||||
|
||||
// Flag parts that will not sample the intended atlas region:
|
||||
// an unset name falls back to default UV mapping, an unknown
|
||||
// name is a stale/typoed reference.
|
||||
ImGui::SameLine();
|
||||
if (currentValue.empty()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f),
|
||||
"missing (default UV mapping)");
|
||||
} else if (namedRects.find(currentValue) == namedRects.end()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f),
|
||||
"ERROR: rect not found in texture");
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
ImGui::Text("Select texture rectangles for each part:");
|
||||
|
||||
@@ -117,6 +117,8 @@ static ImVec4 typeColorVec(const char *type)
|
||||
return ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
|
||||
if (!strcmp(type, "sendEvent"))
|
||||
return ImVec4(1.0f, 0.6f, 0.0f, 1.0f);
|
||||
if (!strcmp(type, "switchScene"))
|
||||
return ImVec4(0.4f, 1.0f, 0.8f, 1.0f);
|
||||
if (!strcmp(type, "luaTask"))
|
||||
return ImVec4(0.67f, 0.4f, 1.0f, 1.0f);
|
||||
return ImVec4(1.0f, 1.0f, 1.0f, 1.0f);
|
||||
@@ -199,6 +201,12 @@ void InlineBehaviorTreeEditor::renderNode(BehaviorTreeNode &node,
|
||||
.childIndex = node.children.size(),
|
||||
.addType = "sendEvent" });
|
||||
}
|
||||
if (ImGui::MenuItem("Add Switch Scene")) {
|
||||
queueOp(EditOp{ .type = EditOp::Add,
|
||||
.parent = &node,
|
||||
.childIndex = node.children.size(),
|
||||
.addType = "switchScene" });
|
||||
}
|
||||
if (ImGui::BeginMenu("Add Lua Task")) {
|
||||
renderLuaNodeSubmenu(&node, node.children.size());
|
||||
ImGui::EndMenu();
|
||||
@@ -320,6 +328,7 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
|
||||
"disablePhysics",
|
||||
"enablePhysics",
|
||||
"sendEvent",
|
||||
"switchScene",
|
||||
"luaTask" };
|
||||
int current = 0;
|
||||
for (int i = 0; i < IM_ARRAYSIZE(types); i++) {
|
||||
@@ -398,6 +407,9 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
|
||||
} else if (node->type == "sendEvent") {
|
||||
label = "Event Name";
|
||||
hint = "Name of the event to send via EventBus";
|
||||
} else if (node->type == "switchScene") {
|
||||
label = "Scene Path";
|
||||
hint = "Scene file to switch to (queued for next frame)";
|
||||
}
|
||||
|
||||
if (ImGui::InputText(label, buf, sizeof(buf)))
|
||||
@@ -409,7 +421,8 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
|
||||
// Params input for nodes that need it
|
||||
if (node->type == "setBit" || node->type == "setValue" ||
|
||||
node->type == "checkValue" || node->type == "delay" ||
|
||||
node->type == "sendEvent" || node->type == "luaTask") {
|
||||
node->type == "sendEvent" || node->type == "switchScene" ||
|
||||
node->type == "luaTask") {
|
||||
char pbuf[256];
|
||||
snprintf(pbuf, sizeof(pbuf), "%s",
|
||||
node->params.c_str());
|
||||
@@ -424,6 +437,8 @@ void InlineBehaviorTreeEditor::renderProps(BehaviorTreeNode *node)
|
||||
phint = "Duration in seconds (float, default 1.0)";
|
||||
else if (node->type == "sendEvent")
|
||||
phint = "Event params: key=val key2=val2 ...";
|
||||
else if (node->type == "switchScene")
|
||||
phint = "Teleport: @EntityName or x,y,z[,yawDeg] (optional)";
|
||||
else if (node->type == "luaTask")
|
||||
phint = "Parameters: key=val,key2=val2 passed to Lua function";
|
||||
if (ImGui::InputText("Params", pbuf, sizeof(pbuf)))
|
||||
|
||||
Reference in New Issue
Block a user