diff --git a/src/features/editScene/AGENTS.md b/src/features/editScene/AGENTS.md index 2155a16..20bd238 100644 --- a/src/features/editScene/AGENTS.md +++ b/src/features/editScene/AGENTS.md @@ -44,6 +44,37 @@ cd build-vscode/src/features/editScene # Run only matching test steps (substring match on step name) TERRAIN_TEST_FILTER=terrainCompliance ./editSceneEditor --headless --run-terrain-tests=1 +# Demo: game-mode SceneScriptComponent inline-Lua event handling +# (scene_loaded / prefab_loaded / custom "demo_pulse"; watch stdout for +# [demo] lines and the cubes moving/scaling). Lives in +# demos/demo-lua-scene-script and is self-contained in its own build +# directory (resources.cfg copied; resources/, characters/, lua-scripts/ +# symlinked from the editScene binary dir; prefabs/demo_pulse_cube.json +# copied). Requires the editSceneEditor staging to have run once. +cd demos/demo-lua-scene-script +./demoLuaSceneScript +# ...or headless smoke run (one frame, then exit): +./demoLuaSceneScript --headless --exit-after-first-frame +# Controls (no player controller in the demo scene; cursor is not grabbed): +# RMB drag = look, W/A/S/D = move, Q/E = down/up, Shift = boost, +# Escape = built-in game-mode pause menu toggle. + +# Demo: game-mode PlayerControllerSystem on a flat physical floor +# (demos/demo-character-controller, target demoCharacterController). The +# scene (demo_character_controller.json) has a flat colored floor plane +# (created programmatically as "DemoFloorPlane" in demo_main.cpp) with a +# static box collider, a character spawner ("s1", character registry ID 2, +# same player character setup as town8.json, prefab prefabs/char_2.json) +# and a PlayerControllerComponent targeting it. The initial window is +# 1920x1080 and the mouse stays grabbed while Playing. +cd demos/demo-character-controller +./demoCharacterController +# ...or headless smoke run (one frame, then exit): +./demoCharacterController --headless --exit-after-first-frame +# Controls: mouse = look, W/A/S/D = move, Shift = run, +# Escape = pause menu (frees the cursor). + + # Road wedge/segment self-intersection regression test (no scene needed, # also registered as CTest roadGeometryOverlapTest) ./road_geometry_overlap_test @@ -364,7 +395,9 @@ Attaches a Lua script to a scene or prefab entity. Fields: - `sendSceneLoaded(scenePath)` sends `"scene_loaded"` with param `scenePath`; `sendPrefabLoaded(prefabPath, entityId)` sends `"prefab_loaded"` with params `prefabPath` and `entityId` (both via `EventBus` + - `editScene::EventParams`). + `editScene::EventParams`). `entityId` (and any `ENTITY_ID` event param) + is delivered to Lua handlers mapped into the `ecs.*` entity ID space, so + handlers can pass it straight to `ecs.get_component` etc. Wiring call sites (each calls `loadPendingScripts` then sends the event): @@ -380,6 +413,19 @@ Wiring call sites (each calls `loadPendingScripts` then sends the event): Lua binding: `ecs.get_component`/`set_component` with `"SceneScript"` (`scriptPath`, `inlineScript`). Example: `lua-examples/scene_script_example.lua`. +A runnable game-mode demo with inline scripts lives in +`demos/demo-lua-scene-script` (target `demoLuaSceneScript`): the scene-level +script catches `scene_loaded` and a custom `demo_pulse` event, while the demo +prefab's own inline script also catches `prefab_loaded`. Note that +scene-level scripts execute lazily at the first `loadPendingScripts()` call +inside `PrefabSystem::resolveInstances()`, so they DO see the `prefab_loaded` +events of prefabs instantiated during the same `startNewGame()` load; the +known exception is `startup_menu.json`, whose scripts only run later in +`EditorApp::setup()` (after Lua init), missing the menu's prefab events. + +Note: setting the `Transform` component from Lua (`ecs.set_component` / +`ecs.set_field`) applies the new values to the scene node and bumps the +change-tracking version, so script-side moves/scales are visible immediately. ### Save/Load diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index ca9139a..3ebd064 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -887,3 +887,7 @@ add_custom_command(TARGET editSceneEditor POST_BUILD "${CMAKE_CURRENT_BINARY_DIR}/prefabs" COMMENT "Copying resources to editSceneEditor build directory" ) + +# Demos (separate executables reusing the editScene sources) +add_subdirectory(demos/demo-lua-scene-script) +add_subdirectory(demos/demo-character-controller) diff --git a/src/features/editScene/demos/demo-character-controller/CMakeLists.txt b/src/features/editScene/demos/demo-character-controller/CMakeLists.txt new file mode 100644 index 0000000..2dad49e --- /dev/null +++ b/src/features/editScene/demos/demo-character-controller/CMakeLists.txt @@ -0,0 +1,88 @@ +# --------------------------------------------------------------------------- +# demo-character-controller — game-mode demo for PlayerControllerSystem +# --------------------------------------------------------------------------- +# Separate executable built from the same sources as editSceneEditor (minus +# main.cpp). It runs in game mode and loads demo_character_controller.json, +# which contains a flat colored floor plane (procedurally created in +# demo_main.cpp as "DemoFloorPlane") with a static box collider, a character +# spawner ("s1", character registry ID 2, same player character setup as +# town8.json) and a PlayerControllerComponent targeting it. The mouse is +# grabbed while Playing (built-in game-mode behaviour) and the initial +# window is larger than the default (1920x1080, see DemoApp in +# demo_main.cpp). +# +# The executable is self-contained in this build directory: a POST_BUILD +# step (stage_runtime.cmake) copies resources.cfg, the demo scene, the +# character prefab (prefabs/char_2.json, written by a previous editor/game +# run) and any runtime config JSONs here, and symlinks the big pre-staged +# runtime directories (resources/, characters/, lua-scripts/) from the +# editScene binary directory. Run it from here: +# cd /src/features/editScene/demos/demo-character-controller +# ./demoCharacterController +# Controls: mouse = look, W/A/S/D = move, Shift = run, +# Escape = pause menu (frees the cursor). + +get_filename_component(EDITSCENE_SOURCE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +get_filename_component(EDITSCENE_BINARY_DIR + "${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE) + +# Reuse the editScene sources, swapping main.cpp for demo_main.cpp. +set(DEMO_SOURCES ${EDITSCENE_SOURCES}) +list(REMOVE_ITEM DEMO_SOURCES main.cpp) +list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/") + +add_executable(demoCharacterController + demo_main.cpp + ${DEMO_SOURCES} +) + +add_dependencies(demoCharacterController morph) + +# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor) +target_compile_definitions(demoCharacterController PRIVATE JPH_DEBUG_RENDERER) + +target_link_libraries(demoCharacterController + OgreMain + OgreBites + OgreOverlay + OgreMeshLodGenerator + OgrePaging + OgreTerrain + flecs::flecs_static + nlohmann_json::nlohmann_json + Jolt::Jolt + OgreProcedural::OgreProcedural + RecastNavigation::Recast + RecastNavigation::Detour + RecastNavigation::DetourTileCache + RecastNavigation::DetourCrowd + RecastNavigation::DebugUtils + PackageArchive + RoadGeometryLib + lua + SDL2::SDL2 +) + +target_include_directories(demoCharacterController PRIVATE + ${EDITSCENE_SOURCE_DIR} + ${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include + ${CMAKE_SOURCE_DIR}/src/FastNoiseLite + ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src + ${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0 +) + +# Stage the standalone runtime next to the executable (see header comment). +add_custom_command(TARGET demoCharacterController POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR} + -DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR} + -DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR} + -DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR} + -P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake" + COMMENT "Staging demo-character-controller standalone runtime" +) diff --git a/src/features/editScene/demos/demo-character-controller/demo_character_controller.json b/src/features/editScene/demos/demo-character-controller/demo_character_controller.json new file mode 100644 index 0000000..a704cb0 --- /dev/null +++ b/src/features/editScene/demos/demo-character-controller/demo_character_controller.json @@ -0,0 +1,191 @@ +{ + "version": "1.0", + "entities": [ + { + "id": 1, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "light": { + "lightType": "directional", + "diffuseColor": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + }, + "specularColor": { + "r": 0.5, + "g": 0.5, + "b": 0.5, + "a": 1.0 + }, + "direction": { + "x": 0.3, + "y": -1.0, + "z": 0.2 + }, + "intensity": 1.0, + "castShadows": false + }, + "children": [] + }, + { + "id": 2, + "name": { + "name": "demo_floor" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "DemoFloorPlane", + "visible": true + }, + "rigidBody": { + "bodyType": "static", + "mass": 1.0, + "friction": 0.8, + "restitution": 0.0, + "isSensor": false, + "enabled": true + }, + "collider": { + "shapeType": "box", + "parameters": { + "x": 30.0, + "y": 0.1, + "z": 30.0 + }, + "radius": 0.5, + "halfHeight": 1.0, + "meshName": "", + "offset": { + "x": 0.0, + "y": -0.1, + "z": 0.0 + }, + "rotationOffset": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + } + }, + "children": [] + }, + { + "id": 3, + "name": { + "name": "s1" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": -3.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "characterSpawner": { + "despawnDistance": 200.0, + "registryId": 2, + "spawnDistance": 100.0 + }, + "children": [] + }, + { + "id": 4, + "name": { + "name": "player" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "playerController": { + "actuatorColor": [ + 0.0, + 0.4000000059604645, + 1.0 + ], + "actuatorCooldown": 1.5, + "actuatorDistance": 25.0, + "actuatorLabelFontSize": 12.0, + "cameraMode": 0, + "distantCircleRadius": 8.0, + "fpsBoneName": "Head", + "idleState": "idle", + "locomotionStateMachine": "locomotion", + "mouseSensitivity": 0.20000000298023224, + "nearCircleRadius": 14.0, + "runState": "running", + "swimFastState": "swimming-fast", + "swimIdleState": "swim-idle", + "swimState": "swimming", + "targetCharacterName": "s1", + "tpsDistance": 3.0, + "tpsHeight": 2.0, + "walkState": "walking" + }, + "children": [] + } + ] +} diff --git a/src/features/editScene/demos/demo-character-controller/demo_main.cpp b/src/features/editScene/demos/demo-character-controller/demo_main.cpp new file mode 100644 index 0000000..63e50d5 --- /dev/null +++ b/src/features/editScene/demos/demo-character-controller/demo_main.cpp @@ -0,0 +1,145 @@ +#include +#include "EditorApp.hpp" +#include "systems/CharacterRegistry.hpp" +#include +#include + +struct ExitAfterFirstFrameListener : public Ogre::FrameListener { + Ogre::Root *root; + bool triggered = false; + ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {} + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + if (!triggered) { + triggered = true; + root->queueEndRendering(); + } + return true; + } +}; + +/* Same application as the editor/game, but with a larger initial window. */ +class DemoApp : public EditorApp { +public: + OgreBites::NativeWindowPair + createWindow(const Ogre::String &name, uint32_t w, uint32_t h, + Ogre::NameValuePairList miscParams) override + { + (void)w; + (void)h; + return EditorApp::createWindow(name, 1920, 1080, miscParams); + } +}; + +/* + * The demo floor mesh and its flat colored material are created + * programmatically because RenderableComponent only references a mesh by + * name and carries no material/color of its own. The demo scene JSON + * references the "DemoFloorPlane" mesh on the "demo_floor" entity. + */ +static void createFloorResources() +{ + const Ogre::String group = + Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME; + + if (!Ogre::MeshManager::getSingleton() + .getByName("DemoFloorPlane", group) + .isNull()) + return; + + Ogre::MaterialPtr mat = Ogre::MaterialManager::getSingleton().create( + "DemoFloorMaterial", group); + Ogre::Pass *pass = mat->getTechnique(0)->getPass(0); + pass->setDiffuse(0.35f, 0.5f, 0.35f, 1.0f); + pass->setAmbient(0.15f, 0.2f, 0.15f); + pass->setSpecular(0.0f, 0.0f, 0.0f, 1.0f); + + /* 60 x 60 units, matching the 30 x 0.1 x 30 static box collider + * (top surface at y = 0) on the "demo_floor" entity in the scene. */ + Ogre::Plane groundPlane(Ogre::Vector3::UNIT_Y, 0.0f); + Ogre::MeshPtr mesh = Ogre::MeshManager::getSingleton().createPlane( + "DemoFloorPlane", group, groundPlane, 60.0f, 60.0f, 1, 1, + true, 1, 1.0f, 1.0f, Ogre::Vector3::UNIT_Z); + mesh->getSubMesh(0)->setMaterialName("DemoFloorMaterial"); +} + +/* + * demo-character-controller: runs the editScene game mode with a small + * hand-authored scene (demo_character_controller.json) containing a flat + * colored floor plane with a static physics collider, a character spawner + * ("s1", character registry ID 2, same character setup as town8.json) and + * a PlayerControllerComponent targeting it. The character walks on the + * physical floor exactly as in game mode. + * + * The mouse is grabbed while Playing (built-in game-mode behaviour); + * Escape toggles the pause menu, which frees the cursor. + * + * The binary is self-contained in its build directory: run it from there + * (resources.cfg, resources/, characters/, lua-scripts/, prefabs/ and the + * runtime config JSONs are staged next to it by the build). + */ +int main(int argc, char *argv[]) +{ + try { + DemoApp app; + app.setGameMode(EditorApp::GameMode::Game); + + bool headless = false; + bool exitAfterFirstFrame = false; + Ogre::String sceneFile = "demo_character_controller.json"; + for (int i = 1; i < argc; i++) { + Ogre::String arg = argv[i]; + if (arg == "--headless") { + headless = true; + } else if (arg == "--exit-after-first-frame") { + exitAfterFirstFrame = true; + } else if (arg.length() > 0 && arg[0] != '-') { + sceneFile = arg; + } + } + app.setHeadless(headless); + + app.initApp(); + + if (headless) { + /* Headless mode never creates EditorUISystem, which + * owns the CharacterRegistry singleton; the demo scene + * has a character spawner, so provide a bare registry + * to keep spawner resolution from asserting. The + * character then falls back to an inline spawn without + * a physics capsule (fine for a smoke run). */ + static CharacterRegistry s_characterRegistry; + s_characterRegistry.setWorld(app.getWorld()); + s_characterRegistry.setSceneManager(app.getSceneManager()); + s_characterRegistry.initialize(); + } + + /* Mesh + material referenced by the floor entity. */ + createFloorResources(); + + std::cout << "[demo] starting new game with scene: " + << sceneFile << std::endl; + app.startNewGame(sceneFile); + std::cout << "[demo] controls: mouse = look, W/A/S/D = move, " + "Shift = run, Escape = pause menu" << std::endl; + + ExitAfterFirstFrameListener exitListener(app.getRoot()); + if (exitAfterFirstFrame) + app.getRoot()->addFrameListener(&exitListener); + + app.getRoot()->startRendering(); + /* Destroy scene entities while the systems are still alive: + * CharacterSpawnerSystem registers an OnRemove observer on + * CharacterSpawnerComponent that dereferences the system, and + * the flecs world only dies after destroyEditorSystems() in + * ~EditorApp, so letting the spawner entity live that long + * would call back into a destroyed system. */ + app.clearScene(); + app.closeApp(); + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/src/features/editScene/demos/demo-character-controller/stage_runtime.cmake b/src/features/editScene/demos/demo-character-controller/stage_runtime.cmake new file mode 100644 index 0000000..a84d45b --- /dev/null +++ b/src/features/editScene/demos/demo-character-controller/stage_runtime.cmake @@ -0,0 +1,49 @@ +# Stage everything demoCharacterController needs into its own directory so +# it runs standalone from +# /src/features/editScene/demos/demo-character-controller. +# +# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR. +# +# Small demo-owned files are COPIED; the big pre-staged runtime directories +# are SYMLINKED from the editScene binary directory (Ogre FileSystem +# locations follow symlinks, and copying would duplicate hundreds of MB on +# every build). The symlink targets are populated by the editSceneEditor +# staging, so editSceneEditor must have been built (and run its POST_BUILD +# staging) at least once. + +file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}") +file(COPY "${SRC_DIR}/demo_character_controller.json" DESTINATION "${DEMO_DIR}") + +# Character prefab for the spawner's registry entry (registryId 2). It is +# written by a previous editor/game run (CharacterRegistry::savePrefab...), +# not part of the source tree, so copy it from the editScene binary +# directory when present. +file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs") +foreach(f char_2.json) + if(EXISTS "${EDITSCENE_BIN}/prefabs/${f}") + file(COPY "${EDITSCENE_BIN}/prefabs/${f}" + DESTINATION "${DEMO_DIR}/prefabs") + endif() +endforeach() + +foreach(dir resources characters lua-scripts) + set(link "${DEMO_DIR}/${dir}") + if(EXISTS "${link}" OR IS_SYMLINK "${link}") + file(REMOVE_RECURSE "${link}") + endif() + file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC) +endforeach() + +# Runtime config JSONs loaded at startup relative to the CWD (game mode +# reads startup_menu.json, the registries read the rest — the demo needs +# character_registry.json for the spawner's registryId 2 and +# animation_tree.json for the character's "male1_6" animation tree). They +# only exist after the editor/game has run once; copy them when present so +# the demo behaves the same as when run from the editor binary directory. +foreach(f startup_menu.json character_registry.json character_class.json + items.json item_state.json inventory_config.json + animation_tree.json) + if(EXISTS "${EDITSCENE_BIN}/${f}") + file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}") + endif() +endforeach() diff --git a/src/features/editScene/demos/demo-lua-scene-script/CMakeLists.txt b/src/features/editScene/demos/demo-lua-scene-script/CMakeLists.txt new file mode 100644 index 0000000..03f9051 --- /dev/null +++ b/src/features/editScene/demos/demo-lua-scene-script/CMakeLists.txt @@ -0,0 +1,86 @@ +# --------------------------------------------------------------------------- +# demo-lua-scene-script — game-mode demo for SceneScriptComponent +# --------------------------------------------------------------------------- +# Separate executable built from the same sources as editSceneEditor (minus +# main.cpp). It runs in game mode and loads demo_lua_scene_script.json, +# whose "demo_events" entity carries an inline SceneScriptComponent Lua +# script demonstrating scene_loaded / prefab_loaded / custom "demo_pulse" +# event handling. The demo prefab (demo_pulse_cube.json) carries its own +# inline script covering the prefab-local variant: prefab scripts run inside +# instantiatePrefab() right before the instance's prefab_loaded is sent, and +# work even when no scene script exists yet (e.g. in the startup menu scene). +# +# The executable is self-contained in this build directory: a POST_BUILD +# step (stage_runtime.cmake) copies resources.cfg, the demo scene/prefab and +# any runtime config JSONs here, and symlinks the big pre-staged runtime +# directories (resources/, characters/, lua-scripts/) from the editScene +# binary directory. Run it from here: +# cd /src/features/editScene/demos/demo-lua-scene-script +# ./demoLuaSceneScript +# Controls: RMB drag = look, W/A/S/D = move, Q/E = down/up, Shift = boost, +# Escape = pause menu. + +get_filename_component(EDITSCENE_SOURCE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +get_filename_component(EDITSCENE_BINARY_DIR + "${CMAKE_CURRENT_BINARY_DIR}/../.." ABSOLUTE) + +# Reuse the editScene sources, swapping main.cpp for demo_main.cpp. +set(DEMO_SOURCES ${EDITSCENE_SOURCES}) +list(REMOVE_ITEM DEMO_SOURCES main.cpp) +list(TRANSFORM DEMO_SOURCES PREPEND "${EDITSCENE_SOURCE_DIR}/") + +add_executable(demoLuaSceneScript + demo_main.cpp + ${DEMO_SOURCES} +) + +add_dependencies(demoLuaSceneScript morph) + +# Define JPH_DEBUG_RENDERER for physics debug drawing (same as editor) +target_compile_definitions(demoLuaSceneScript PRIVATE JPH_DEBUG_RENDERER) + +target_link_libraries(demoLuaSceneScript + OgreMain + OgreBites + OgreOverlay + OgreMeshLodGenerator + OgrePaging + OgreTerrain + flecs::flecs_static + nlohmann_json::nlohmann_json + Jolt::Jolt + OgreProcedural::OgreProcedural + RecastNavigation::Recast + RecastNavigation::Detour + RecastNavigation::DetourTileCache + RecastNavigation::DetourCrowd + RecastNavigation::DebugUtils + PackageArchive + RoadGeometryLib + lua + SDL2::SDL2 +) + +target_include_directories(demoLuaSceneScript PRIVATE + ${EDITSCENE_SOURCE_DIR} + ${EDITSCENE_SOURCE_DIR}/recastnavigation/Recast/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/Detour/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourTileCache/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DetourCrowd/Include + ${EDITSCENE_SOURCE_DIR}/recastnavigation/DebugUtils/Include + ${CMAKE_SOURCE_DIR}/src/FastNoiseLite + ${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src + ${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0 +) + +# Stage the standalone runtime next to the executable (see header comment). +add_custom_command(TARGET demoLuaSceneScript POST_BUILD + COMMAND ${CMAKE_COMMAND} + -DDEMO_DIR=${CMAKE_CURRENT_BINARY_DIR} + -DEDITSCENE_BIN=${EDITSCENE_BINARY_DIR} + -DEDITSCENE_SRC=${EDITSCENE_SOURCE_DIR} + -DSRC_DIR=${CMAKE_CURRENT_SOURCE_DIR} + -P "${CMAKE_CURRENT_SOURCE_DIR}/stage_runtime.cmake" + COMMENT "Staging demo-lua-scene-script standalone runtime" +) diff --git a/src/features/editScene/demos/demo-lua-scene-script/demo_lua_scene_script.json b/src/features/editScene/demos/demo-lua-scene-script/demo_lua_scene_script.json new file mode 100644 index 0000000..5fe1e28 --- /dev/null +++ b/src/features/editScene/demos/demo-lua-scene-script/demo_lua_scene_script.json @@ -0,0 +1,196 @@ +{ + "version": "1.0", + "entities": [ + { + "id": 1, + "name": { + "name": "demo_light" + }, + "transform": { + "position": { + "x": 0.0, + "y": 10.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "light": { + "lightType": "directional", + "diffuseColor": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + }, + "specularColor": { + "r": 0.5, + "g": 0.5, + "b": 0.5, + "a": 1.0 + }, + "direction": { + "x": 0.3, + "y": -1.0, + "z": 0.2 + }, + "intensity": 1.0, + "castShadows": false + }, + "children": [] + }, + { + "id": 2, + "name": { + "name": "demo_ground" + }, + "transform": { + "position": { + "x": 0.0, + "y": -0.2, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 30.0, + "y": 0.2, + "z": 30.0 + } + }, + "renderable": { + "meshName": "Cube.mesh", + "visible": true + }, + "children": [] + }, + { + "id": 3, + "name": { + "name": "demo_cube" + }, + "transform": { + "position": { + "x": -2.0, + "y": 0.5, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "Cube.mesh", + "visible": true + }, + "children": [] + }, + { + "id": 4, + "name": { + "name": "demo_pulse_target" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.5, + "z": -2.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "Cube.mesh", + "visible": true + }, + "children": [] + }, + { + "id": 5, + "name": { + "name": "demo_prefab_instance" + }, + "transform": { + "position": { + "x": 2.0, + "y": 0.5, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "prefabInstance": { + "prefabPath": "prefabs/demo_pulse_cube.json" + }, + "children": [] + }, + { + "id": 6, + "name": { + "name": "demo_events" + }, + "transform": { + "position": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "sceneScript": { + "scriptPath": "", + "inlineScript": "-- demo-lua-scene-script: inline scene script.\n-- Executed once when the scene finishes loading; subscribes to the\n-- engine load events and to a custom \"demo_pulse\" event. Every handler\n-- prints a [demo] line to stdout AND visibly mutates an entity.\n\nprint(\"[demo] scene script executed: subscribing to events\")\n\n-- scene_loaded: sent by EditorApp::startNewGame() right after this script\n-- runs, so the subscription below always catches it.\necs.subscribe_event(\"scene_loaded\", function(event, params)\n print(\"[demo] scene_loaded: \" .. tostring(params and params.scenePath))\n -- Visibly mutate: lift the cube named \"demo_cube\" up by 2 units.\n local cube = ecs.get_entity_by_name(\"demo_cube\")\n if cube and ecs.has_component(cube, \"Transform\") then\n local t = ecs.get_component(cube, \"Transform\")\n ecs.set_component(cube, \"Transform\", {\n position = { t.position[1], t.position[2] + 2.0, t.position[3] },\n rotation = t.rotation,\n scale = t.scale,\n })\n print(\"[demo] scene_loaded: demo_cube moved up by +2.0 in Y\")\n end\n -- Fire the custom event so its handler runs as part of the load.\n ecs.send_event(\"demo_pulse\", { tick = 1 })\n print(\"[demo] scene_loaded: demo_pulse sent\")\nend)\n\n-- prefab_loaded: scene scripts run lazily at the first loadPendingScripts\n-- call, which happens inside PrefabSystem::resolveInstances() right before\n-- the instance's prefab_loaded is sent, so this handler fires even for\n-- prefabs instantiated during the same scene load. The inline script inside\n-- prefabs/demo_pulse_cube.json demonstrates the prefab-local variant, which\n-- also works when a prefab is instantiated before any scene script exists\n-- (e.g. in the startup menu scene).\necs.subscribe_event(\"prefab_loaded\", function(event, params)\n print(\"[demo] scene script saw prefab_loaded: \" ..\n tostring(params and params.prefabPath) ..\n \" entity=\" .. tostring(params and params.entityId))\nend)\n\n-- Custom event: scales the cube named \"demo_pulse_target\" by 2x.\necs.subscribe_event(\"demo_pulse\", function(event, params)\n print(\"[demo] demo_pulse received, tick=\" ..\n tostring(params and params.tick))\n local cube = ecs.get_entity_by_name(\"demo_pulse_target\")\n if cube and ecs.has_component(cube, \"Transform\") then\n local t = ecs.get_component(cube, \"Transform\")\n ecs.set_component(cube, \"Transform\", {\n position = t.position,\n rotation = t.rotation,\n scale = { t.scale[1] * 2.0, t.scale[2] * 2.0, t.scale[3] * 2.0 },\n })\n print(\"[demo] demo_pulse: demo_pulse_target scaled x2\")\n end\nend)\n" + }, + "children": [] + } + ] +} diff --git a/src/features/editScene/demos/demo-lua-scene-script/demo_main.cpp b/src/features/editScene/demos/demo-lua-scene-script/demo_main.cpp new file mode 100644 index 0000000..ce09eb7 --- /dev/null +++ b/src/features/editScene/demos/demo-lua-scene-script/demo_main.cpp @@ -0,0 +1,167 @@ +#include +#include "EditorApp.hpp" +#include "GameMode.hpp" +#include "camera/EditorCamera.hpp" +#include +#include +#include + +struct ExitAfterFirstFrameListener : public Ogre::FrameListener { + Ogre::Root *root; + bool triggered = false; + ExitAfterFirstFrameListener(Ogre::Root *r) : root(r) {} + bool frameRenderingQueued(const Ogre::FrameEvent &) override + { + if (!triggered) { + triggered = true; + root->queueEndRendering(); + } + return true; + } +}; + +/* + * Demo fly camera. The demo scene has no player controller, so in game + * mode nothing drives the camera and EditorApp would only grab the mouse + * (setGamePlayState(Playing) -> setWindowGrab(true)). EditorApp consumes + * all SDL input events in game mode before chained listeners see them, so + * the controls are polled per frame via SDL_GetKeyboardState() / + * SDL_GetRelativeMouseState() — the same approach GameInputState uses. + * + * Controls: RMB drag = look, W/A/S/D = move, Q/E = down/up, Shift = boost. + * Escape keeps the built-in game-mode behaviour (pause menu toggle). + */ +class DemoFlyCamera : public Ogre::FrameListener { +public: + DemoFlyCamera(EditorApp &app) + : m_app(app) + { + } + + bool frameRenderingQueued(const Ogre::FrameEvent &evt) override + { + /* Keep the cursor free: game mode re-grabs the mouse whenever + * the Playing state is (re-)entered (also when leaving the + * pause menu), so ungrab on every transition into Playing. */ + bool playing = editScene::isGamePlaying(); + if (playing && !m_wasPlaying) + m_app.setWindowGrab(false); + m_wasPlaying = playing; + + Ogre::Camera *cam = m_app.getEditorCamera()->getCamera(); + if (!cam || !cam->getParentSceneNode()) + return true; + Ogre::SceneNode *node = cam->getParentSceneNode(); + + /* Mouse look while the right button is held; the relative + * state is polled every frame so deltas stay current. */ + int dx, dy; + uint32_t buttons = SDL_GetRelativeMouseState(&dx, &dy); + if (buttons & SDL_BUTTON(SDL_BUTTON_RIGHT)) { + m_yaw -= dx * 0.003f; + m_pitch -= dy * 0.003f; + m_pitch = Ogre::Math::Clamp(m_pitch, -1.5f, 1.5f); + } + node->setOrientation( + Ogre::Quaternion(Ogre::Radian(m_yaw), + Ogre::Vector3::UNIT_Y) * + Ogre::Quaternion(Ogre::Radian(m_pitch), + Ogre::Vector3::UNIT_X)); + + /* WASD + QE fly movement, Shift boosts x10. */ + const uint8_t *keys = SDL_GetKeyboardState(nullptr); + Ogre::Vector3 move = Ogre::Vector3::ZERO; + if (keys[SDL_SCANCODE_W]) + move.z -= 1.0f; + if (keys[SDL_SCANCODE_S]) + move.z += 1.0f; + if (keys[SDL_SCANCODE_A]) + move.x -= 1.0f; + if (keys[SDL_SCANCODE_D]) + move.x += 1.0f; + float vertical = 0.0f; + if (keys[SDL_SCANCODE_E]) + vertical += 1.0f; + if (keys[SDL_SCANCODE_Q]) + vertical -= 1.0f; + float speed = 10.0f; + if (keys[SDL_SCANCODE_LSHIFT] || keys[SDL_SCANCODE_RSHIFT]) + speed *= 10.0f; + if (!move.isZeroLength() || vertical != 0.0f) { + Ogre::Vector3 delta = + node->getOrientation() * move + + Ogre::Vector3(0.0f, vertical, 0.0f); + delta.normalise(); + node->translate(delta * speed * evt.timeSinceLastFrame); + } + return true; + } + +private: + EditorApp &m_app; + bool m_wasPlaying = false; + float m_yaw = 0.0f; + float m_pitch = -0.3f; +}; + +/* + * demo-lua-scene-script: runs the editScene game mode with a hand-authored + * scene whose SceneScriptComponent carries an inline Lua script + * demonstrating event handling (scene_loaded, prefab_loaded and a custom + * "demo_pulse" event). See demo_lua_scene_script.json. + * + * The binary is self-contained in its build directory: run it from there + * (resources.cfg, resources/, characters/, lua-scripts/ and prefabs/ are + * staged next to it by the build). + */ +int main(int argc, char *argv[]) +{ + try { + EditorApp app; + app.setGameMode(EditorApp::GameMode::Game); + + bool headless = false; + bool exitAfterFirstFrame = false; + Ogre::String sceneFile = "demo_lua_scene_script.json"; + for (int i = 1; i < argc; i++) { + Ogre::String arg = argv[i]; + if (arg == "--headless") { + headless = true; + } else if (arg == "--exit-after-first-frame") { + exitAfterFirstFrame = true; + } else if (arg.length() > 0 && arg[0] != '-') { + sceneFile = arg; + } + } + app.setHeadless(headless); + + app.initApp(); + + std::cout << "[demo] starting new game with scene: " + << sceneFile << std::endl; + app.startNewGame(sceneFile); + /* Game mode grabbed the mouse on entering Playing; the demo + * has no player controller, so free the cursor again (the fly + * camera below re-frees it whenever Playing is re-entered). */ + app.setWindowGrab(false); + std::cout << "[demo] controls: RMB drag = look, " + "W/A/S/D = move, Q/E = down/up, Shift = boost, " + "Escape = pause menu" << std::endl; + + DemoFlyCamera flyCamera(app); + if (!headless) + app.getRoot()->addFrameListener(&flyCamera); + + ExitAfterFirstFrameListener exitListener(app.getRoot()); + if (exitAfterFirstFrame) + app.getRoot()->addFrameListener(&exitListener); + + app.getRoot()->startRendering(); + app.closeApp(); + } catch (const std::exception &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/src/features/editScene/demos/demo-lua-scene-script/demo_pulse_cube.json b/src/features/editScene/demos/demo-lua-scene-script/demo_pulse_cube.json new file mode 100644 index 0000000..dde74ee --- /dev/null +++ b/src/features/editScene/demos/demo-lua-scene-script/demo_pulse_cube.json @@ -0,0 +1,29 @@ +{ + "name": "DemoPulseCube", + "transform": { + "position": { + "x": 0.0, + "y": 0.5, + "z": 0.0 + }, + "rotation": { + "w": 1.0, + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "scale": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + } + }, + "renderable": { + "meshName": "Cube.mesh", + "visible": true + }, + "sceneScript": { + "scriptPath": "", + "inlineScript": "-- demo-lua-scene-script: inline prefab script.\n-- Runs inside PrefabSystem/SceneSerializer::instantiatePrefab() right\n-- before the \"prefab_loaded\" event for the instance is sent, so the\n-- subscription below always catches the instance's own load event. (Scene\n-- scripts do see it too \u2014 they run lazily at this same loadPendingScripts\n-- call \u2014 but prefab-local scripts also work when no scene script exists,\n-- e.g. for prefabs instantiated while the startup menu scene loads.)\n\nprint(\"[demo] prefab script executed: subscribing to prefab_loaded\")\n\necs.subscribe_event(\"prefab_loaded\", function(event, params)\n if not params then\n return\n end\n print(\"[demo] prefab_loaded: \" .. tostring(params.prefabPath) ..\n \" entity=\" .. tostring(params.entityId))\n -- Visibly mutate: lift the freshly instantiated cube by 1.5 units.\n if params.entityId and ecs.has_component(params.entityId, \"Transform\") then\n local t = ecs.get_component(params.entityId, \"Transform\")\n ecs.set_component(params.entityId, \"Transform\", {\n position = { t.position[1], t.position[2] + 1.5, t.position[3] },\n rotation = t.rotation,\n scale = t.scale,\n })\n print(\"[demo] prefab_loaded: instance lifted by +1.5 in Y\")\n end\nend)\n" + } +} diff --git a/src/features/editScene/demos/demo-lua-scene-script/stage_runtime.cmake b/src/features/editScene/demos/demo-lua-scene-script/stage_runtime.cmake new file mode 100644 index 0000000..e6faaec --- /dev/null +++ b/src/features/editScene/demos/demo-lua-scene-script/stage_runtime.cmake @@ -0,0 +1,37 @@ +# Stage everything demoLuaSceneScript needs into its own directory so it +# runs standalone from +# /src/features/editScene/demos/demo-lua-scene-script. +# +# Expected -D arguments: DEMO_DIR, EDITSCENE_BIN, EDITSCENE_SRC, SRC_DIR. +# +# Small demo-owned files are COPIED; the big pre-staged runtime directories +# are SYMLINKED from the editScene binary directory (Ogre FileSystem +# locations follow symlinks, and copying would duplicate hundreds of MB on +# every build). The symlink targets are populated by the editSceneEditor +# staging, so editSceneEditor must have been built (and run its POST_BUILD +# staging) at least once. + +file(COPY "${EDITSCENE_SRC}/resources.cfg" DESTINATION "${DEMO_DIR}") +file(COPY "${SRC_DIR}/demo_lua_scene_script.json" DESTINATION "${DEMO_DIR}") +file(MAKE_DIRECTORY "${DEMO_DIR}/prefabs") +file(COPY "${SRC_DIR}/demo_pulse_cube.json" DESTINATION "${DEMO_DIR}/prefabs") + +foreach(dir resources characters lua-scripts) + set(link "${DEMO_DIR}/${dir}") + if(EXISTS "${link}" OR IS_SYMLINK "${link}") + file(REMOVE_RECURSE "${link}") + endif() + file(CREATE_LINK "${EDITSCENE_BIN}/${dir}" "${link}" SYMBOLIC) +endforeach() + +# Runtime config JSONs loaded at startup relative to the CWD (game mode +# reads startup_menu.json, the registries read the rest). They only exist +# after the editor/game has run once; copy them when present so the demo +# behaves the same as when run from the editor binary directory. +foreach(f startup_menu.json character_registry.json character_class.json + items.json item_state.json inventory_config.json + animation_tree.json) + if(EXISTS "${EDITSCENE_BIN}/${f}") + file(COPY "${EDITSCENE_BIN}/${f}" DESTINATION "${DEMO_DIR}") + endif() +endforeach() diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 2e19758..bcae5ca 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -275,20 +275,47 @@ static void registerAllComponents() lua_pop(L, 1);); // --- Transform --- - REGISTER_COMPONENT( - TransformComponent, "Transform", pushVector3(L, c.position); - lua_setfield(L, -2, "position"); pushQuaternion(L, c.rotation); - lua_setfield(L, -2, "rotation"); pushVector3(L, c.scale); - lua_setfield(L, -2, "scale"); - , if (lua_getfield(L, idx, "position"), lua_istable(L, -1)) - c.position = readVector3(L, lua_gettop(L)); - lua_pop(L, 1); - if (lua_getfield(L, idx, "rotation"), lua_istable(L, -1)) - c.rotation = readQuaternion(L, lua_gettop(L)); - lua_pop(L, 1); - if (lua_getfield(L, idx, "scale"), lua_istable(L, -1)) - c.scale = readVector3(L, lua_gettop(L)); - lua_pop(L, 1);); + /* Hand-written (not REGISTER_COMPONENT) so that a script-side + * set_component/set_field also applies the new values to the scene + * node and bumps the change-tracking version; the generic writer only + * updates the component data, leaving the visual node unmoved. */ + s_components["Transform"] = { + "Transform", + [](lua_State *L, flecs::entity e) { + if (!e.has()) { + lua_pushnil(L); + return; + } + const auto &c = e.get(); + lua_newtable(L); + pushVector3(L, c.position); + lua_setfield(L, -2, "position"); + pushQuaternion(L, c.rotation); + lua_setfield(L, -2, "rotation"); + pushVector3(L, c.scale); + lua_setfield(L, -2, "scale"); + }, + [](lua_State *L, flecs::entity e, int idx) { + TransformComponent c; + if (e.has()) + c = e.get(); + if (lua_getfield(L, idx, "position"), + lua_istable(L, -1)) + c.position = readVector3(L, lua_gettop(L)); + lua_pop(L, 1); + if (lua_getfield(L, idx, "rotation"), + lua_istable(L, -1)) + c.rotation = readQuaternion(L, lua_gettop(L)); + lua_pop(L, 1); + if (lua_getfield(L, idx, "scale"), lua_istable(L, -1)) + c.scale = readVector3(L, lua_gettop(L)); + lua_pop(L, 1); + e.set(c); + auto &stored = e.get_mut(); + stored.applyToNode(); + stored.markChanged(); + } + }; // --- Renderable --- REGISTER_COMPONENT( diff --git a/src/features/editScene/lua/LuaEntityApi.cpp b/src/features/editScene/lua/LuaEntityApi.cpp index de17566..366f276 100644 --- a/src/features/editScene/lua/LuaEntityApi.cpp +++ b/src/features/editScene/lua/LuaEntityApi.cpp @@ -1,5 +1,6 @@ #include "LuaEntityApi.hpp" #include "components/EditorMarker.hpp" +#include "components/EntityName.hpp" #include #include @@ -101,6 +102,8 @@ static int luaGetPlayerEntity(lua_State *L) // --------------------------------------------------------------------------- // Lua: ecs.get_entity_by_name(name) -> int (entity ID) or nil +// Matches the Flecs entity name first, then falls back to +// EntityNameComponent (the name scene entities get on load). // --------------------------------------------------------------------------- static int luaGetEntityByName(lua_State *L) @@ -109,6 +112,16 @@ static int luaGetEntityByName(lua_State *L) const char *name = lua_tostring(L, 1); flecs::world world = getWorld(L); flecs::entity e = world.lookup(name); + if (!e.is_valid()) { + /* Fall back to EntityNameComponent (the scene/editor name); + * world.lookup only covers Flecs entity names, which + * scene-loaded entities do not have. */ + world.query().each( + [&](flecs::entity cand, const EntityNameComponent &n) { + if (!e.is_valid() && n.name == name) + e = cand; + }); + } if (e.is_valid()) { int id = g_luaEntityIdMap.addEntity(e); lua_pushinteger(L, id); diff --git a/src/features/editScene/lua/LuaEventApi.cpp b/src/features/editScene/lua/LuaEventApi.cpp index 8cf5cc5..b1d32c0 100644 --- a/src/features/editScene/lua/LuaEventApi.cpp +++ b/src/features/editScene/lua/LuaEventApi.cpp @@ -21,6 +21,26 @@ namespace editScene static std::unordered_map s_luaSubscriptions; static int s_nextLuaSubId = 1; +/* Same registry lookup as LuaEntityApi.cpp (kept file-local). */ +static flecs::world getWorld(lua_State *L) +{ + lua_getfield(L, LUA_REGISTRYINDEX, "EditSceneFlecsWorld"); + OgreAssert(lua_islightuserdata(L, -1), "Flecs world not registered"); + flecs::world *world = + static_cast(lua_touserdata(L, -1)); + lua_pop(L, 1); + return *world; +} + +/* Map a raw Flecs entity ID from an EventParams payload into the + * Lua-facing ID space (g_luaEntityIdMap) so handlers can pass it back to + * the ecs.* entity/component functions. Returns -1 for dead entities. */ +static lua_Integer pushableEntityId(lua_State *L, uint64_t rawId) +{ + flecs::entity e = getWorld(L).get_alive((flecs::entity_t)rawId); + return (lua_Integer)luaEntityToId(e); +} + // --------------------------------------------------------------------------- // Helper: push an EventValue as a Lua value // --------------------------------------------------------------------------- @@ -38,7 +58,7 @@ static void pushEventValue(lua_State *L, const EventValue &val) lua_pushnil(L); break; case EventValue::ENTITY_ID: - lua_pushinteger(L, (lua_Integer)val.getEntityId()); + lua_pushinteger(L, pushableEntityId(L, val.getEntityId())); break; case EventValue::INT: lua_pushinteger(L, (lua_Integer)val.getInt()); @@ -56,7 +76,7 @@ static void pushEventValue(lua_State *L, const EventValue &val) lua_newtable(L); const auto &arr = val.getEntityIdArray(); for (size_t i = 0; i < arr.size(); i++) { - lua_pushinteger(L, (lua_Integer)arr[i]); + lua_pushinteger(L, pushableEntityId(L, arr[i])); lua_rawseti(L, -2, (int)(i + 1)); } break; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index c968b57..8b68f57 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -1103,11 +1103,17 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity, EditorUISystem *uiSystem) { try { + /* Copy the path: callers commonly pass a reference straight + * into component storage (PrefabInstanceComponent::prefabPath) + * which can be relocated when instantiation adds components + * to the instance entity. */ + const std::string path = filepath; + /* Cached parse (M4.4): the same prefab may be instantiated * by hundreds of streamed spawners. */ - const nlohmann::json *cached = loadPrefabJsonCached(filepath); + const nlohmann::json *cached = loadPrefabJsonCached(path); if (!cached) { - m_lastError = "Failed to open prefab: " + filepath; + m_lastError = "Failed to open prefab: " + path; return false; } const nlohmann::json &prefabJson = *cached; @@ -1136,8 +1142,7 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity, * (pending-only, so repeat instantiations are cheap), then * notify listeners that this instance finished loading. */ SceneScriptSystem::loadPendingScripts(m_world); - SceneScriptSystem::sendPrefabLoaded(filepath, - instanceEntity.id()); + SceneScriptSystem::sendPrefabLoaded(path, instanceEntity.id()); return true; } catch (const std::exception &e) {