From d832127d5aacf700f0fff6a4321ead1cacdb7e72 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Wed, 24 Jun 2026 13:24:56 +0300 Subject: [PATCH] Fixed stuck animations. --- src/features/editScene/CMakeLists.txt | 13 + src/features/editScene/EditorApp.cpp | 227 +++++++----- src/features/editScene/EditorApp.hpp | 27 +- .../editScene/components/CharacterSpawner.hpp | 29 ++ .../components/CharacterSpawnerModule.cpp | 26 ++ .../editScene/components/PlayerController.hpp | 11 +- .../character_spawner_example.lua | 116 ++++++ .../editScene/lua/LuaComponentApi.cpp | 22 ++ .../editScene/systems/ActuatorSystem.cpp | 37 +- .../editScene/systems/AnimationTreeSystem.cpp | 278 +++++++++----- .../editScene/systems/CharacterRegistry.cpp | 10 + .../editScene/systems/CharacterRegistry.hpp | 12 + .../systems/CharacterSpawnerSystem.cpp | 344 ++++++++++++++++++ .../systems/CharacterSpawnerSystem.hpp | 101 +++++ .../editScene/systems/EditorUISystem.cpp | 9 + .../editScene/systems/EditorUISystem.hpp | 15 + .../systems/PlayerControllerSystem.cpp | 234 ++++++++++-- .../systems/PlayerControllerSystem.hpp | 23 ++ .../editScene/systems/SceneSerializer.cpp | 105 +++++- .../editScene/systems/SceneSerializer.hpp | 11 + .../editScene/systems/SmartObjectSystem.cpp | 11 +- .../editScene/tests/component_lua_test | Bin 0 -> 1470533 bytes .../editScene/tests/component_lua_test.cpp | 35 +- .../editScene/ui/CharacterSpawnerEditor.cpp | 70 ++++ .../editScene/ui/CharacterSpawnerEditor.hpp | 30 ++ 25 files changed, 1517 insertions(+), 279 deletions(-) create mode 100644 src/features/editScene/components/CharacterSpawner.hpp create mode 100644 src/features/editScene/components/CharacterSpawnerModule.cpp create mode 100644 src/features/editScene/lua-examples/character_spawner_example.lua create mode 100644 src/features/editScene/systems/CharacterSpawnerSystem.cpp create mode 100644 src/features/editScene/systems/CharacterSpawnerSystem.hpp create mode 100644 src/features/editScene/tests/component_lua_test create mode 100644 src/features/editScene/ui/CharacterSpawnerEditor.cpp create mode 100644 src/features/editScene/ui/CharacterSpawnerEditor.hpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index 02e7bf7..d8f9f83 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -43,6 +43,7 @@ set(EDITSCENE_SOURCES systems/SaveLoadDialog.cpp systems/PlayerControllerSystem.cpp systems/CharacterSlotSystem.cpp + systems/CharacterSpawnerSystem.cpp systems/OgreEntityHack.cpp systems/CharacterRegistry.cpp systems/MarkovNameGenerator.cpp @@ -98,6 +99,7 @@ set(EDITSCENE_SOURCES ui/PrimitiveEditor.cpp ui/TriangleBufferEditor.cpp ui/CharacterSlotsEditor.cpp + ui/CharacterSpawnerEditor.cpp ui/CharacterIdentityEditor.cpp ui/AnimationTreeEditor.cpp ui/AnimationTreeTemplateEditor.cpp @@ -140,6 +142,7 @@ set(EDITSCENE_SOURCES components/PrimitiveModule.cpp components/TriangleBufferModule.cpp components/CharacterSlotsModule.cpp + components/CharacterSpawnerModule.cpp components/AnimationTreeModule.cpp components/AnimationTreeTemplateModule.cpp components/AnimationTree.cpp @@ -299,6 +302,7 @@ set(EDITSCENE_HEADERS ui/PrimitiveEditor.hpp ui/TriangleBufferEditor.hpp ui/CharacterSlotsEditor.hpp + ui/CharacterSpawnerEditor.hpp ui/CharacterIdentityEditor.hpp ui/AnimationTreeEditor.hpp ui/AnimationTreeTemplateEditor.hpp @@ -352,6 +356,15 @@ set(EDITSCENE_HEADERS add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS}) add_dependencies(editSceneEditor morph) +# Verbose diagnostics for player input, locomotion state changes and animation +# blending. Disabled by default; enable with -DEDITSCENE_VERBOSE_DEBUG=ON. +option(EDITSCENE_VERBOSE_DEBUG + "Enable verbose player controller / animation diagnostics" OFF) +if(EDITSCENE_VERBOSE_DEBUG) + target_compile_definitions(editSceneEditor + PRIVATE EDITSCENE_VERBOSE_DEBUG) +endif() + # Define JPH_DEBUG_RENDERER for physics debug drawing target_compile_definitions(editSceneEditor PRIVATE JPH_DEBUG_RENDERER) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index b11e421..92740af 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -16,6 +16,8 @@ #include "systems/ProceduralMaterialSystem.hpp" #include "systems/ProceduralMeshSystem.hpp" #include "systems/CharacterSlotSystem.hpp" +#include "systems/CharacterSpawnerSystem.hpp" +#include "components/CharacterSpawner.hpp" #include "systems/AnimationTreeSystem.hpp" #include "systems/HairPhysicsSystem.hpp" #include "systems/BehaviorTreeSystem.hpp" @@ -99,10 +101,18 @@ #include "components/Item.hpp" #include "components/Inventory.hpp" +#ifdef EDITSCENE_VERBOSE_DEBUG +#define EDITSCENE_LOG_INPUT(msg) \ + Ogre::LogManager::getSingleton().logMessage(msg) +#else +#define EDITSCENE_LOG_INPUT(msg) +#endif + #include #include #include "package/OgrePackageArchive.h" #include +#include #include "lua/LuaEntityApi.hpp" #include "lua/LuaComponentApi.hpp" #include "lua/LuaEventApi.hpp" @@ -252,6 +262,7 @@ EditorApp::~EditorApp() m_behaviorTreeSystem.reset(); m_animationTreeSystem.reset(); m_characterSlotSystem.reset(); + m_characterSpawnerSystem.reset(); m_proceduralMeshSystem.reset(); m_proceduralMaterialSystem.reset(); m_proceduralTextureSystem.reset(); @@ -397,6 +408,13 @@ void EditorApp::setup() m_uiSystem->getCharacterRegistry().setCharacterSlotSystem( m_characterSlotSystem.get()); + // Setup CharacterSpawner system + m_characterSpawnerSystem = + std::make_unique( + m_world, m_sceneMgr, m_cameraSystem.get(), + m_uiSystem.get()); + m_characterSpawnerSystem->initialize(); + // Setup AnimationTree system m_animationTreeSystem = std::make_unique( m_world, m_sceneMgr); @@ -681,6 +699,11 @@ void EditorApp::setGamePlayState(GamePlayState state) editScene::GamePlayState::Paused : editScene::GamePlayState::Menu); + /* Avoid stuck movement keys when leaving the Playing state (e.g. opening + * the inventory/pause menu while Shift+W is held). */ + if (state != GamePlayState::Playing) + m_gameInput.clearMovementInput(); + // Grab/ungrab mouse based on gameplay state if (m_gameMode == GameMode::Game) { if (state == GamePlayState::Playing) { @@ -726,33 +749,12 @@ void EditorApp::startNewGame(const Ogre::String &scenePath) Ogre::LogManager::getSingleton().logMessage( "Game started: loaded scene " + scenePath); - // Set up player character: find the player controller entity - // and resolve the actual target character so the character sheet - // and inventory are on the same entity that ActuatorSystem uses. - flecs::entity playerController; - m_world.query().each( - [&](flecs::entity e, EntityNameComponent &name) { - if (name.name == "player" && e.is_alive()) { - playerController = e; - } - }); - flecs::entity playerCharacter = playerController; - if (playerController.is_alive() && - playerController.has()) { - auto &pc = playerController - .get(); - if (!pc.targetCharacterName.empty()) { - m_world.query().each( - [&](flecs::entity e, - EntityNameComponent &en) { - if (en.name == - pc.targetCharacterName) - playerCharacter = e; - }); - } - } + // 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()) { - // Check if player already has a character identity if (!playerCharacter.has()) { // Create a character record for the player uint64_t charId = @@ -869,32 +871,11 @@ void EditorApp::saveGame(const std::string &slotPath, /* Identify the player character registry ID for load-time setup */ uint64_t playerCharId = 0; - flecs::entity playerController; - m_world.query().each( - [&](flecs::entity e, EntityNameComponent &name) { - if (name.name == "player") - playerController = e; - }); - if (playerController.is_alive()) { - flecs::entity playerChar = playerController; - if (playerController.has()) { - auto &pc = playerController - .get(); - if (!pc.targetCharacterName.empty()) { - m_world.query().each( - [&](flecs::entity e, - EntityNameComponent &en) { - if (en.name == - pc.targetCharacterName) - playerChar = e; - }); - } - } - if (playerChar.has()) { - playerCharId = - playerChar.get() - .registryId; - } + flecs::entity playerChar = getPlayerCharacterEntity(); + if (playerChar.is_alive() && + playerChar.has()) { + playerCharId = + playerChar.get().registryId; } saveData["saveGame"]["playerCharacterId"] = playerCharId; @@ -1157,9 +1138,21 @@ void EditorApp::loadGame(const std::string &slotPath) auto &pc = playerController .get_mut(); - if (playerChar.has()) { + /* If the saved character is currently owned by a spawner, + * keep the controller targeted at the spawner so the + * character respawns correctly. Otherwise target the + * character directly. */ + flecs::entity targetEntity = playerChar; + if (m_characterSpawnerSystem) { + flecs::entity spawner = + m_characterSpawnerSystem + ->getSpawnerForCharacter(playerChar); + if (spawner.is_alive()) + targetEntity = spawner; + } + if (targetEntity.has()) { pc.targetCharacterName = - playerChar.get() + targetEntity.get() .name; } if (!playerChar.has()) { @@ -1427,6 +1420,9 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) /* --- Gameplay systems (paused when in Paused state) --- */ if (!paused) { /* --- Visual mesh setup (must run before animation) --- */ + if (m_characterSpawnerSystem) { + m_characterSpawnerSystem->update(); + } if (m_characterSlotSystem) { m_characterSlotSystem->update(); } @@ -1581,33 +1577,10 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) flecs::entity::null()); setGamePlayState(GamePlayState::Playing); } else if (m_gamePlayState == GamePlayState::Playing) { - // Find player controller and resolve target character - // so the sheet shows the same entity ActuatorSystem uses. - flecs::entity playerController; - m_world.query().each( - [&](flecs::entity e, - EntityNameComponent &name) { - if (name.name == "player" && - e.is_alive()) { - playerController = e; - } - }); - flecs::entity playerCharacter = playerController; - if (playerController.is_alive() && - playerController.has()) { - auto &pc = playerController.get< - PlayerControllerComponent>(); - if (!pc.targetCharacterName.empty()) { - m_world.query().each( - [&](flecs::entity e, - EntityNameComponent &en) { - if (en.name == - pc.targetCharacterName) - playerCharacter = - e; - }); - } - } + // Resolve the live player character (spawned instance if + // the controller targets a spawner) so the sheet shows + // the same entity ActuatorSystem uses. + flecs::entity playerCharacter = getPlayerCharacterEntity(); if (playerCharacter.is_valid() && playerCharacter.is_alive()) { m_characterClassSystem->toggleCharacterSheet( @@ -1723,40 +1696,64 @@ bool EditorApp::keyPressed(const OgreBites::KeyboardEvent &evt) setGamePlayState(GamePlayState::Playing); return true; } - bool pressed = true; + + /* Ignore repeated keydowns for movement keys. Releasing Shift + * while W/A/S/D is held can generate a repeated keydown with a + * different symbol after the physical release, which would + * re-stick the key. Also avoid re-setting a key that is already + * marked pressed so duplicate initial keydowns cannot flip it + * back on after a release. */ + bool isRepeat = evt.repeat != 0; + switch (evt.keysym.sym) { case 'w': case 'W': - m_gameInput.w = pressed; + if (!isRepeat && !m_gameInput.w) { + m_gameInput.w = true; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed W"); + } break; case 's': case 'S': - m_gameInput.s = pressed; + if (!isRepeat && !m_gameInput.s) { + m_gameInput.s = true; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed S"); + } break; case 'a': case 'A': - m_gameInput.a = pressed; + if (!isRepeat && !m_gameInput.a) { + m_gameInput.a = true; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed A"); + } break; case 'd': case 'D': - m_gameInput.d = pressed; + if (!isRepeat && !m_gameInput.d) { + m_gameInput.d = true; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed D"); + } break; case OgreBites::SDLK_LSHIFT: - m_gameInput.shift = pressed; + case SDLK_RSHIFT: + if (!isRepeat && !m_gameInput.shift) { + m_gameInput.shift = true; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyPressed SHIFT"); + } break; case 'e': case 'E': - m_gameInput.e = pressed; + m_gameInput.e = true; m_gameInput.ePressed = true; break; case 'f': case 'F': - m_gameInput.f = pressed; + m_gameInput.f = true; m_gameInput.fPressed = true; break; case 'i': case 'I': - m_gameInput.i = pressed; + m_gameInput.i = true; m_gameInput.iPressed = true; break; } @@ -1794,40 +1791,46 @@ bool EditorApp::keyReleased(const OgreBites::KeyboardEvent &evt) m_currentModifiers = evt.keysym.mod; if (m_gameMode == GameMode::Game) { - bool pressed = false; switch (evt.keysym.sym) { case 'w': case 'W': - m_gameInput.w = pressed; + m_gameInput.w = false; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyReleased W"); break; case 's': case 'S': - m_gameInput.s = pressed; + m_gameInput.s = false; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyReleased S"); break; case 'a': case 'A': - m_gameInput.a = pressed; + m_gameInput.a = false; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyReleased A"); break; case 'd': case 'D': - m_gameInput.d = pressed; + m_gameInput.d = false; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyReleased D"); break; case OgreBites::SDLK_LSHIFT: - m_gameInput.shift = pressed; + case SDLK_RSHIFT: + m_gameInput.shift = false; + EDITSCENE_LOG_INPUT("[INPUT_DBG] keyReleased SHIFT"); break; case 'e': case 'E': - m_gameInput.e = pressed; + m_gameInput.e = false; break; case 'f': case 'F': - m_gameInput.f = pressed; + m_gameInput.f = false; break; case 'i': case 'I': - m_gameInput.i = pressed; + m_gameInput.i = false; break; } + return true; } @@ -1847,6 +1850,32 @@ flecs::entity EditorApp::getSelectedEntity() const return flecs::entity::null(); } +flecs::entity EditorApp::getPlayerCharacterEntity() +{ + flecs::entity controller = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, EntityNameComponent &name) { + if (name.name == "player") + controller = e; + }); + + if (!controller.is_alive() || + !controller.has()) + return flecs::entity::null(); + + if (m_playerControllerSystem) + return m_playerControllerSystem->getCurrentTarget(controller); + + auto &pc = controller.get(); + flecs::entity target = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, EntityNameComponent &en) { + if (en.name == pc.targetCharacterName) + target = e; + }); + return target; +} + PauseMenuSystem *EditorApp::getPauseMenuSystem() const { return &PauseMenuSystem::getInstance(); diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 7341c4a..37b6082 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -23,6 +23,7 @@ class ProceduralTextureSystem; class ProceduralMaterialSystem; class ProceduralMeshSystem; class CharacterSlotSystem; +class CharacterSpawnerSystem; class AnimationTreeSystem; class HairPhysicsSystem; class BehaviorTreeSystem; @@ -78,6 +79,15 @@ struct GameInputState { fPressed = false; iPressed = false; } + + void clearMovementInput() + { + w = false; + a = false; + s = false; + d = false; + shift = false; + } }; /** @@ -166,8 +176,7 @@ public: void clearScene(); // Save / Load - void saveGame(const std::string &slotPath, - const std::string &slotName); + void saveGame(const std::string &slotPath, const std::string &slotName); void loadGame(const std::string &slotPath); // Input access @@ -178,6 +187,15 @@ public: // Getters flecs::entity getSelectedEntity() const; + + /** + * Return the live player character entity that is currently controlled by + * the "player" controller. If the controller targets a character spawner, + * this returns the spawned character instance, forcing a spawn if necessary. + * Returns null if no player controller or target exists. + */ + flecs::entity getPlayerCharacterEntity(); + Ogre::SceneManager *getSceneManager() const { return m_sceneMgr; @@ -198,6 +216,10 @@ public: { return m_characterSlotSystem.get(); } + CharacterSpawnerSystem *getCharacterSpawnerSystem() const + { + return m_characterSpawnerSystem.get(); + } StartupMenuSystem *getStartupMenuSystem() const { return m_startupMenuSystem.get(); @@ -252,6 +274,7 @@ private: std::unique_ptr m_proceduralMaterialSystem; std::unique_ptr m_proceduralMeshSystem; std::unique_ptr m_characterSlotSystem; + std::unique_ptr m_characterSpawnerSystem; std::unique_ptr m_animationTreeSystem; std::unique_ptr m_hairPhysicsSystem; std::unique_ptr m_behaviorTreeSystem; diff --git a/src/features/editScene/components/CharacterSpawner.hpp b/src/features/editScene/components/CharacterSpawner.hpp new file mode 100644 index 0000000..b03ad09 --- /dev/null +++ b/src/features/editScene/components/CharacterSpawner.hpp @@ -0,0 +1,29 @@ +#ifndef EDITSCENE_CHARACTERSPAWNER_HPP +#define EDITSCENE_CHARACTERSPAWNER_HPP +#pragma once + +#include + +/** + * @brief Distance-based character spawner. + * + * Attaches to an entity with a TransformComponent. When the active camera is + * within spawnDistanceSq of the spawner, the registry character identified by + * registryId is spawned as an independent entity at the spawner's transform. + * When the camera moves beyond despawnDistanceSq, the spawned entity is + * destroyed. + * + * Distances are stored squared for fast comparison. + */ +struct CharacterSpawnerComponent { + /** Registry character ID to spawn. */ + uint64_t registryId = 0; + + /** Squared distance at which the character should be spawned. */ + float spawnDistanceSq = 100.0f * 100.0f; + + /** Squared distance at which the character should be despawned. */ + float despawnDistanceSq = 200.0f * 200.0f; +}; + +#endif // EDITSCENE_CHARACTERSPAWNER_HPP diff --git a/src/features/editScene/components/CharacterSpawnerModule.cpp b/src/features/editScene/components/CharacterSpawnerModule.cpp new file mode 100644 index 0000000..b96fe45 --- /dev/null +++ b/src/features/editScene/components/CharacterSpawnerModule.cpp @@ -0,0 +1,26 @@ +#include "CharacterSpawner.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/CharacterSpawnerEditor.hpp" + +REGISTER_COMPONENT_GROUP("Character Spawner", "Character", + CharacterSpawnerComponent, CharacterSpawnerEditor) +{ + registry.registerComponent( + "Character Spawner", + std::make_unique(sceneMgr), + /* Adder */ + [sceneMgr](flecs::entity e) { + (void)sceneMgr; + if (!e.has()) { + e.set( + CharacterSpawnerComponent{}); + } + }, + /* Remover */ + [sceneMgr](flecs::entity e) { + (void)sceneMgr; + if (e.has()) { + e.remove(); + } + }); +} diff --git a/src/features/editScene/components/PlayerController.hpp b/src/features/editScene/components/PlayerController.hpp index 44b59c3..aeee792 100644 --- a/src/features/editScene/components/PlayerController.hpp +++ b/src/features/editScene/components/PlayerController.hpp @@ -24,8 +24,8 @@ struct PlayerControllerComponent { /* Swim animation states (used when character is in water) */ Ogre::String swimIdleState = "swim-idle"; - Ogre::String swimState = "swim"; - Ogre::String swimFastState = "swim-fast"; + Ogre::String swimState = "swimming"; + Ogre::String swimFastState = "swimming-fast"; /* Actuator interaction settings */ float actuatorDistance = 25.0f; @@ -39,4 +39,11 @@ struct PlayerControllerComponent { bool inputLocked = false; }; +/** + * Tag component marking a character entity as currently under player control. + * AI systems (e.g. SmartObjectSystem) should skip entities with this tag. + */ +struct PlayerControlledComponent { +}; + #endif // EDITSCENE_PLAYERCONTROLLER_HPP diff --git a/src/features/editScene/lua-examples/character_spawner_example.lua b/src/features/editScene/lua-examples/character_spawner_example.lua new file mode 100644 index 0000000..2198280 --- /dev/null +++ b/src/features/editScene/lua-examples/character_spawner_example.lua @@ -0,0 +1,116 @@ +-- ============================================================================= +-- CharacterSpawner Component Lua API Example +-- ============================================================================= +-- This file demonstrates how to create a character spawner from Lua. +-- +-- A CharacterSpawner entity references a character in the CharacterRegistry and +-- automatically spawns/despawns that character based on camera distance. The +-- spawner uses the entity's TransformComponent for the spawn position and +-- rotation. +-- +-- Distances are stored squared on the component for efficient comparison, so +-- set spawnDistanceSq and despawnDistanceSq to the square of the desired +-- distance. +-- ============================================================================= + +-- ============================================================================= +-- Creating a Character Spawner +-- ============================================================================= + +local spawner = ecs.create_entity() +ecs.set_entity_name(spawner, "Village Guard Spawner") + +-- The spawner needs a world transform so the system knows where to spawn the +-- character. +ecs.set_component(spawner, "Transform", { + position = { 120.0, 0.0, 240.0 }, + rotation = { 1, 0, 0, 0 }, -- quaternion w, x, y, z + scale = { 1, 1, 1 } +}) + +-- Add the spawner component. registryId is the ID of a character in the +-- CharacterRegistry. spawnDistanceSq / despawnDistanceSq are squared distances +-- in world units. +ecs.set_component(spawner, "CharacterSpawner", { + registryId = 42, + spawnDistanceSq = 100.0 * 100.0, -- spawn when camera is within 100 units + despawnDistanceSq = 200.0 * 200.0 -- despawn when camera is beyond 200 units +}) + +print("Created character spawner at (120, 0, 240) for registry character 42") + +-- ============================================================================= +-- Querying and Updating a Spawner +-- ============================================================================= + +if ecs.has_component(spawner, "CharacterSpawner") then + local s = ecs.get_component(spawner, "CharacterSpawner") + print("Spawner registryId: " .. s.registryId) + print("Spawner spawnDistanceSq: " .. s.spawnDistanceSq) + print("Spawner despawnDistanceSq: " .. s.despawnDistanceSq) +end + +-- Change which character is spawned by updating the registry ID: +ecs.set_field(spawner, "CharacterSpawner", "registryId", 7) + +-- Adjust distances using squared values: +ecs.set_field(spawner, "CharacterSpawner", "spawnDistanceSq", 150.0 * 150.0) +ecs.set_field(spawner, "CharacterSpawner", "despawnDistanceSq", 300.0 * 300.0) + +local updated = ecs.get_component(spawner, "CharacterSpawner") +print("Updated spawner registryId: " .. updated.registryId) +print("Updated spawnDistanceSq: " .. updated.spawnDistanceSq) +print("Updated despawnDistanceSq: " .. updated.despawnDistanceSq) + +-- ============================================================================= +-- Creating a Dynamic Character and Spawning it Automatically +-- ============================================================================= + +-- Create a character record at runtime. The returned value is the registry ID +-- that the CharacterSpawner component references. +local dynamicNpc = ecs.character.create("Bree", "Miller", "", true) +print("Created dynamic character (ID: " .. dynamicNpc .. ")") + +-- Create another spawner entity that will automatically spawn Bree when the +-- camera gets close and despawn her when the camera moves away. +local dynamicSpawner = ecs.create_entity() +ecs.set_entity_name(dynamicSpawner, "Bree Miller Spawner") + +ecs.set_component(dynamicSpawner, "Transform", { + position = { 50.0, 0.0, 80.0 }, + rotation = { 1, 0, 0, 0 }, + scale = { 1, 1, 1 } +}) + +ecs.set_component(dynamicSpawner, "CharacterSpawner", { + registryId = dynamicNpc, + spawnDistanceSq = 80.0 * 80.0, + despawnDistanceSq = 160.0 * 160.0 +}) + +print("Dynamic spawner set up for character " .. dynamicNpc) + +-- You can still query the spawner settings: +local dynamicSettings = ecs.get_component(dynamicSpawner, "CharacterSpawner") +print("Dynamic spawner registryId: " .. dynamicSettings.registryId) +print("Dynamic spawner spawnDistanceSq: " .. dynamicSettings.spawnDistanceSq) + +-- The CharacterSpawnerSystem will now manage the spawned entity automatically. +-- The registry record stays even if the spawned instance is despawned, so the +-- character can be respawned again later. + +-- ============================================================================= +-- Removing a Spawner +-- ============================================================================= + +-- Removing the component will also despawn any currently spawned character +-- instance managed by this spawner. +ecs.remove_component(spawner, "CharacterSpawner") +ecs.remove_component(dynamicSpawner, "CharacterSpawner") + +if not ecs.has_component(spawner, "CharacterSpawner") and + not ecs.has_component(dynamicSpawner, "CharacterSpawner") then + print("CharacterSpawner components removed; any spawned instances were despawned") +end + +print("CharacterSpawner example completed successfully!") diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 4bd33e2..ad1203b 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -33,6 +33,7 @@ #include "components/Primitive.hpp" #include "components/TriangleBuffer.hpp" #include "components/CharacterSlots.hpp" +#include "components/CharacterSpawner.hpp" #include "components/CharacterIdentity.hpp" #include "systems/CharacterRegistry.hpp" #include "components/AnimationTree.hpp" @@ -570,6 +571,27 @@ static void registerAllComponents() } }); + // --- CharacterSpawner --- + REGISTER_COMPONENT( + CharacterSpawnerComponent, "CharacterSpawner", + lua_pushinteger(L, (lua_Integer)c.registryId); + lua_setfield(L, -2, "registryId"); + lua_pushnumber(L, c.spawnDistanceSq); + lua_setfield(L, -2, "spawnDistanceSq"); + lua_pushnumber(L, c.despawnDistanceSq); + lua_setfield(L, -2, "despawnDistanceSq"); + , if (lua_getfield(L, idx, "registryId"), lua_isnumber(L, -1)) + c.registryId = (uint64_t)lua_tointeger(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "spawnDistanceSq"), + lua_isnumber(L, -1)) + c.spawnDistanceSq = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, idx, "despawnDistanceSq"), + lua_isnumber(L, -1)) + c.despawnDistanceSq = (float)lua_tonumber(L, -1); + lua_pop(L, 1);); + // --- AnimationTree --- REGISTER_COMPONENT( AnimationTreeComponent, "AnimationTree", diff --git a/src/features/editScene/systems/ActuatorSystem.cpp b/src/features/editScene/systems/ActuatorSystem.cpp index 2489617..568aa83 100644 --- a/src/features/editScene/systems/ActuatorSystem.cpp +++ b/src/features/editScene/systems/ActuatorSystem.cpp @@ -272,8 +272,12 @@ void ActuatorSystem::update(float deltaTime) return; } - // Find player character + // Find player character (resolve spawned instance if controller + // targets a spawner). flecs::entity playerCharacter = flecs::entity::null(); + if (m_editorApp) + playerCharacter = m_editorApp->getPlayerCharacterEntity(); + float actuatorDistance = 25.0f; float actuatorCooldown = 1.5f; m_circleColor = ImVec4(0.0f, 0.4f, 1.0f, 1.0f); @@ -281,26 +285,17 @@ void ActuatorSystem::update(float deltaTime) m_nearRadius = 14.0f; m_labelFontSize = 14.0f; - m_world.query().each([&](flecs::entity e, - PlayerControllerComponent - &pc) { - (void)e; - if (!playerCharacter.is_alive()) { - m_world.query().each( - [&](flecs::entity ec, EntityNameComponent &en) { - if (!playerCharacter.is_alive() && - en.name == pc.targetCharacterName) - playerCharacter = ec; - }); - actuatorDistance = pc.actuatorDistance; - actuatorCooldown = pc.actuatorCooldown; - m_circleColor = ImVec4(pc.actuatorColor.x, - pc.actuatorColor.y, - pc.actuatorColor.z, 1.0f); - m_distantRadius = pc.distantCircleRadius; - m_nearRadius = pc.nearCircleRadius; - m_labelFontSize = pc.actuatorLabelFontSize; - } + m_world.query().each([&](flecs::entity, + PlayerControllerComponent + &pc) { + actuatorDistance = pc.actuatorDistance; + actuatorCooldown = pc.actuatorCooldown; + m_circleColor = ImVec4(pc.actuatorColor.x, + pc.actuatorColor.y, + pc.actuatorColor.z, 1.0f); + m_distantRadius = pc.distantCircleRadius; + m_nearRadius = pc.nearCircleRadius; + m_labelFontSize = pc.actuatorLabelFontSize; }); if (!playerCharacter.is_alive() || diff --git a/src/features/editScene/systems/AnimationTreeSystem.cpp b/src/features/editScene/systems/AnimationTreeSystem.cpp index b0c0713..807dd17 100644 --- a/src/features/editScene/systems/AnimationTreeSystem.cpp +++ b/src/features/editScene/systems/AnimationTreeSystem.cpp @@ -4,13 +4,22 @@ #include "../components/Renderable.hpp" #include "../components/CharacterSlots.hpp" #include "../components/Character.hpp" +#include "../components/PlayerController.hpp" #include #include #include #include #include -static unsigned short findBoneBlendIndex(Ogre::SkeletonInstance *skel, Ogre::Bone *bone) +#ifdef EDITSCENE_VERBOSE_DEBUG +#define EDITSCENE_LOG_ANIM(msg) \ + Ogre::LogManager::getSingleton().logMessage(msg) +#else +#define EDITSCENE_LOG_ANIM(msg) +#endif + +static unsigned short findBoneBlendIndex(Ogre::SkeletonInstance *skel, + Ogre::Bone *bone) { unsigned short handle = bone->getHandle(); if (handle < skel->getNumBones() && skel->getBone(handle) == bone) @@ -54,7 +63,8 @@ Ogre::Entity *AnimationTreeSystem::findAnimatedEntity(flecs::entity e) if (!e.is_alive()) return nullptr; - CharacterSlotSystem *slotSystem = CharacterSlotSystem::getSingletonPtr(); + CharacterSlotSystem *slotSystem = + CharacterSlotSystem::getSingletonPtr(); if (slotSystem) { Ogre::Entity *masterEnt = slotSystem->getMasterEntity(e); if (masterEnt && masterEnt->hasSkeleton()) @@ -106,12 +116,11 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e, * setupEntity() does not produce a one-frame zero-delta * stutter when the mesh is recreated by CharacterSlotSystem. */ - std::unordered_map> + std::unordered_map > prevRootMotion; for (const auto &pair : state.animations) { - prevRootMotion[pair.first] = - { pair.second.prevRootPos, - pair.second.hasPrevRootPos }; + prevRootMotion[pair.first] = { pair.second.prevRootPos, + pair.second.hasPrevRootPos }; } state.ogreEntity = ent; state.ogreEntityName = ent->getName(); @@ -154,13 +163,16 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e, if (state.rootBone) { as->destroyBlendMask(); as->createBlendMask(skel->getNumBones(), 1.0f); - unsigned short blendIdx = findBoneBlendIndex(skel, state.rootBone); + unsigned short blendIdx = findBoneBlendIndex( + skel, state.rootBone); if (blendIdx != (unsigned short)-1) { as->setBlendMaskEntry(blendIdx, 0.0f); } else { - std::cout << "AnimationTreeSystem::setupEntity: WARNING could not find blend mask index for root bone " + std::cout + << "AnimationTreeSystem::setupEntity: WARNING could not find blend mask index for root bone " << state.rootBone->getName() - << " handle=" << state.rootBone->getHandle() + << " handle=" + << state.rootBone->getHandle() << std::endl; } } @@ -189,17 +201,19 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e, * would make loopTranslation slightly * wrong and cause a backward lurch on * wrap frames. */ - Ogre::TransformKeyFrame tkfBeg( - info.rootTrack, 0.0f); - Ogre::TransformKeyFrame tkfEnd( - info.rootTrack, 0.0f); + Ogre::TransformKeyFrame + tkfBeg(info.rootTrack, + 0.0f); + Ogre::TransformKeyFrame + tkfEnd(info.rootTrack, + 0.0f); info.rootTrack ->getInterpolatedKeyFrame( - 0.0f, &tkfBeg); - info.rootTrack - ->getInterpolatedKeyFrame( - as->getLength(), - &tkfEnd); + 0.0f, + &tkfBeg); + info.rootTrack->getInterpolatedKeyFrame( + as->getLength(), + &tkfEnd); info.loopTranslation = tkfEnd.getTranslate() - tkfBeg.getTranslate(); @@ -388,18 +402,23 @@ void AnimationTreeSystem::update(float deltaTime) if (!state.ogreEntity) return; +#ifdef EDITSCENE_VERBOSE_DEBUG /* Diagnostic: check if OGRE moved the root bone during rendering */ if (state.rootBone) { - Ogre::Vector3 currentPos = state.rootBone->getPosition(); - Ogre::Vector3 diff = currentPos - state.rootBindingPosition; + Ogre::Vector3 currentPos = + state.rootBone->getPosition(); + Ogre::Vector3 diff = + currentPos - state.rootBindingPosition; if (diff.squaredLength() > 0.0001f) { - std::cout << "[ANIM_DBG] ROOT BONE MOVED! entity=" << e.id() - << " current=" << currentPos - << " binding=" << state.rootBindingPosition - << " diff=" << diff - << std::endl; + std::cout + << "[ANIM_DBG] ROOT BONE MOVED! entity=" + << e.id() << " current=" << currentPos + << " binding=" + << state.rootBindingPosition + << " diff=" << diff << std::endl; } } +#endif if (!at.enabled) { disableAllAnimations(state); @@ -445,13 +464,13 @@ void AnimationTreeSystem::update(float deltaTime) if (at.useRootMotion && info.rootTrack) { Ogre::TransformKeyFrame tkf(nullptr, - 0.0f); + 0.0f); info.rootTrack->getInterpolatedKeyFrame( thisTime, &tkf); Ogre::Vector3 thisPos = tkf.getTranslate(); - /* + /* * For looping animations, use the average * cycle velocity (loopTranslation / duration) * instead of exact frame-to-frame displacement. @@ -464,45 +483,62 @@ void AnimationTreeSystem::update(float deltaTime) * displacement so acceleration curves are * preserved. */ - float animLen = info.ogreAnimState->getLength(); - if ( - animLen > 0.0001f && - info.loopTranslation.squaredLength() > 0.0001f) { - Ogre::Vector3 avgVelLocal = - info.loopTranslation / animLen; - totalRootMotion += - avgVelLocal * - data.timeDelta * - data.weight; - } else if (info.hasPrevRootPos) { - Ogre::Vector3 displacement = - thisPos - - info.prevRootPos; - if (thisTime < lastTime) - displacement += - info.loopTranslation; - totalRootMotion += - displacement * - data.weight; - } else { - /* Fallback for first frame: + float animLen = + info.ogreAnimState->getLength(); + if (animLen > 0.0001f && + info.loopTranslation.squaredLength() > + 0.0001f) { + Ogre::Vector3 avgVelLocal = + info.loopTranslation / + animLen; + totalRootMotion += + avgVelLocal * + data.timeDelta * + data.weight; + } else if (info.hasPrevRootPos) { + Ogre::Vector3 displacement = + thisPos - + info.prevRootPos; + if (thisTime < lastTime) + displacement += + info.loopTranslation; + totalRootMotion += + displacement * + data.weight; + } else { + /* Fallback for first frame: * instantaneous velocity */ - float sampleDt = 0.001f; - float nextTime = thisTime + sampleDt; - bool wrapped = false; - if (nextTime > info.ogreAnimState->getLength()) { - nextTime -= info.ogreAnimState->getLength(); - wrapped = true; - } - Ogre::TransformKeyFrame tkfNext(nullptr, 0.0f); - info.rootTrack->getInterpolatedKeyFrame( - nextTime, &tkfNext); - Ogre::Vector3 nextPos = tkfNext.getTranslate(); - if (wrapped) - nextPos += info.loopTranslation; - Ogre::Vector3 velocityLocal = (nextPos - thisPos) / sampleDt; - totalRootMotion += velocityLocal * data.timeDelta * data.weight; + float sampleDt = 0.001f; + float nextTime = + thisTime + sampleDt; + bool wrapped = false; + if (nextTime > + info.ogreAnimState + ->getLength()) { + nextTime -= + info.ogreAnimState + ->getLength(); + wrapped = true; } + Ogre::TransformKeyFrame tkfNext( + nullptr, 0.0f); + info.rootTrack + ->getInterpolatedKeyFrame( + nextTime, + &tkfNext); + Ogre::Vector3 nextPos = + tkfNext.getTranslate(); + if (wrapped) + nextPos += + info.loopTranslation; + Ogre::Vector3 velocityLocal = + (nextPos - thisPos) / + sampleDt; + totalRootMotion += + velocityLocal * + data.timeDelta * + data.weight; + } info.prevRootPos = thisPos; info.hasPrevRootPos = true; @@ -510,36 +546,89 @@ void AnimationTreeSystem::update(float deltaTime) } } - if (at.useRootMotion && sceneNode) { - /* Smooth root-motion displacement with EMA to + if (at.useRootMotion && sceneNode) { + /* Smooth root-motion displacement with EMA to * filter high-frequency hip oscillation within * the walk cycle. Alpha = 0.15 gives strong * attenuation of ~2 Hz walk-cycle jitter while * still responding to stops within ~6 frames. */ - float alpha = 0.15f; - if (state.hasSmoothedRootMotion) { - totalRootMotion = - state.smoothedRootMotion * - (1.0f - alpha) + - totalRootMotion * alpha; - } - state.smoothedRootMotion = totalRootMotion; - state.hasSmoothedRootMotion = true; + float alpha = 0.15f; + if (state.hasSmoothedRootMotion) { + totalRootMotion = state.smoothedRootMotion * + (1.0f - alpha) + + totalRootMotion * alpha; + } + state.smoothedRootMotion = totalRootMotion; + state.hasSmoothedRootMotion = true; - Ogre::Quaternion worldRot = - sceneNode->_getDerivedOrientation(); - Ogre::Vector3 displacementWorld = - worldRot * totalRootMotion; + Ogre::Quaternion worldRot = + sceneNode->_getDerivedOrientation(); + Ogre::Vector3 displacementWorld = + worldRot * totalRootMotion; - if (e.has()) { - auto &cc = e.get_mut(); - cc.useRootMotion = true; - if (deltaTime > 0.0000001f) { - cc.linearVelocity = - displacementWorld / deltaTime; - } + if (e.has()) { + auto &cc = e.get_mut(); + cc.useRootMotion = true; + if (deltaTime > 0.0000001f) { + cc.linearVelocity = + displacementWorld / deltaTime; } } + } +#ifdef EDITSCENE_VERBOSE_DEBUG + /* Diagnostic: for player-controlled characters in the idle + * locomotion state, log which Ogre animations are still + * active and the state-machine fade weights. This helps + * catch cases where walk/run keeps playing after idle is + * requested. */ + if (e.has()) { + auto itLoco = at.currentStates.find("locomotion"); + if (itLoco != at.currentStates.end() && + itLoco->second == "idle") { + Ogre::String animList; + bool hasNonIdle = false; + int activeCount = 0; + for (const auto &pair : ctx.animData) { + if (pair.second.weight > 0.001f) { + activeCount++; + animList += pair.first + "=" + + Ogre::StringConverter::toString( + pair.second.weight) + + " "; + if (pair.first != "idle") + hasNonIdle = true; + } + } + auto itFade = state.fadeStates.find("locomotion"); + Ogre::String fadeList; + bool fading = false; + if (itFade != state.fadeStates.end()) { + for (const auto &w : itFade->second.weights) { + fadeList += w.first + "=" + + Ogre::StringConverter::toString( + w.second) + + " "; + } + fadeList += "fadeIn={"; + for (const auto &n : itFade->second.fadeIn) { + fadeList += n + " "; + fading = true; + } + fadeList += "} fadeOut={"; + for (const auto &n : itFade->second.fadeOut) { + fadeList += n + " "; + fading = true; + } + fadeList += "}"; + } + if (activeCount > 1 || hasNonIdle || fading) { + EDITSCENE_LOG_ANIM("[ANIM_DBG] player idle anims: " + + animList + "| fade: " + fadeList); + } + } + } +#endif + /* Reset root bone to binding pose */ if (state.rootBone) { state.rootBone->setPosition(state.rootBindingPosition); @@ -551,7 +640,6 @@ void AnimationTreeSystem::update(float deltaTime) /* Handle end-of-animation transitions */ checkEndTransitions(e, at, state, ctx); }); - } void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node, @@ -730,9 +818,19 @@ void AnimationTreeSystem::setStateInternal(flecs::entity e, return; auto &fade = state.fadeStates[stateMachineName]; - fade.fadeOut.insert(itCurrent->second); + const Ogre::String &oldState = itCurrent->second; + + /* If the new state is currently fading out, stop fading it out; if the + * old state is still fading in, stop fading it in. Otherwise a state can + * end up in both fadeIn and fadeOut simultaneously and its weight gets + * stuck. */ + fade.fadeOut.erase(stateName); + fade.fadeIn.erase(oldState); + + fade.fadeOut.insert(oldState); fade.fadeIn.insert(stateName); - fade.weights[stateName] = 0.0f; + if (fade.weights.find(stateName) == fade.weights.end()) + fade.weights[stateName] = 0.0f; itCurrent->second = stateName; state.prevStates[stateMachineName] = stateName; @@ -820,5 +918,3 @@ AnimationTreeSystem::findAnimationNode(const AnimationTreeNode &stateNode) const } return nullptr; } - - diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index 19b5e9e..57af8d2 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -713,6 +713,10 @@ flecs::entity CharacterRegistry::findSpawnedEntity(uint64_t id) const void CharacterRegistry::markCharacterDirty(uint64_t id) { + CharacterRecord *rec = findCharacter(id); + if (rec) + rec->version++; + flecs::entity e = findSpawnedEntity(id); if (!e.is_alive()) return; @@ -720,6 +724,12 @@ void CharacterRegistry::markCharacterDirty(uint64_t id) m_slotSystem->markEntityDirty(e.id()); } +uint64_t CharacterRegistry::getCharacterVersion(uint64_t id) const +{ + const CharacterRecord *rec = findCharacter(id); + return rec ? rec->version : 0; +} + bool CharacterRegistry::despawnCharacter(uint64_t id) { if (!m_world) diff --git a/src/features/editScene/systems/CharacterRegistry.hpp b/src/features/editScene/systems/CharacterRegistry.hpp index a96c6ef..4a7ad02 100644 --- a/src/features/editScene/systems/CharacterRegistry.hpp +++ b/src/features/editScene/systems/CharacterRegistry.hpp @@ -99,6 +99,10 @@ public: std::unordered_map intColumns; std::unordered_map floatColumns; std::unordered_map stringColumns; + + /* Version counter incremented when the record changes in ways that + * require a respawn of distance-spawned characters. Runtime-only. */ + uint64_t version = 0; }; struct GroupRecord { @@ -332,6 +336,14 @@ public: * registry field that affects the character's look. */ void markCharacterDirty(uint64_t id); + + /** + * Return the current version of a character record. The version is + * bumped by markCharacterDirty() and can be used by spawners to detect + * registry changes that require a respawn. + */ + uint64_t getCharacterVersion(uint64_t id) const; + bool despawnCharacter(uint64_t id); flecs::entity spawnCharacter(uint64_t id); flecs::entity spawnInlineCharacter(const CharacterRecord &c, diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.cpp b/src/features/editScene/systems/CharacterSpawnerSystem.cpp new file mode 100644 index 0000000..1ea9efa --- /dev/null +++ b/src/features/editScene/systems/CharacterSpawnerSystem.cpp @@ -0,0 +1,344 @@ +#include "CharacterSpawnerSystem.hpp" +#include "CharacterRegistry.hpp" +#include "CameraSystem.hpp" +#include "EditorUISystem.hpp" +#include "../components/CharacterSpawner.hpp" +#include "../components/Transform.hpp" +#include "../components/EditorMarker.hpp" +#include "../components/Renderable.hpp" +#include "../components/CharacterIdentity.hpp" +#include "../GameMode.hpp" +#include +#include + +CharacterSpawnerSystem::CharacterSpawnerSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr, + EditorCameraSystem *cameraSystem, + EditorUISystem *uiSystem) + : m_world(world) + , m_sceneMgr(sceneMgr) + , m_cameraSystem(cameraSystem) + , m_uiSystem(uiSystem) + , m_query(world.query()) +{ + m_world.observer("CharacterSpawnerCleanup") + .event(flecs::OnRemove) + .each([this](flecs::entity e, CharacterSpawnerComponent &) { + despawn(e); + }); +} + +CharacterSpawnerSystem::~CharacterSpawnerSystem() +{ + std::vector ids; + ids.reserve(m_spawnedEntities.size()); + for (const auto &pair : m_spawnedEntities) + ids.push_back(pair.first); + for (flecs::entity_t id : ids) + despawn(m_world.entity(id)); +} + +void CharacterSpawnerSystem::initialize() +{ + if (m_initialized) + return; + m_initialized = true; + + Ogre::LogManager::getSingleton().logMessage( + "CharacterSpawnerSystem initialized"); +} + +Ogre::Vector3 CharacterSpawnerSystem::getCameraPosition() const +{ + if (m_cameraSystem) { + Ogre::Camera *cam = m_cameraSystem->getActiveCamera(); + if (cam) + return cam->getDerivedPosition(); + } + return Ogre::Vector3::ZERO; +} + +void CharacterSpawnerSystem::spawnCharacter( + flecs::entity spawnerEntity, const CharacterSpawnerComponent &spawner) +{ + if (spawner.registryId == 0 || !spawnerEntity.is_alive() || + !spawnerEntity.has()) + return; + + /* Make sure any previous instance is gone first. */ + despawn(spawnerEntity); + + flecs::entity character = + CharacterRegistry::getSingleton().spawnCharacter( + spawner.registryId); + if (!character.is_alive()) { + const CharacterRegistry::CharacterRecord *rec = + CharacterRegistry::getSingleton().findCharacter( + spawner.registryId); + if (rec) { + character = CharacterRegistry::getSingleton() + .spawnInlineCharacter( + *rec, Ogre::Vector3::ZERO); + } + } + + if (!character.is_alive()) { + Ogre::LogManager::getSingleton().logMessage( + "CharacterSpawnerSystem: failed to spawn character " + + std::to_string(spawner.registryId)); + return; + } + + /* Apply spawner transform to the spawned character. */ + if (character.has()) { + const auto &spawnerTransform = + spawnerEntity.get(); + auto &charTransform = character.get_mut(); + charTransform.position = spawnerTransform.position; + charTransform.rotation = spawnerTransform.rotation; + charTransform.scale = spawnerTransform.scale; + if (charTransform.node && spawnerTransform.node) { + charTransform.node->setPosition( + spawnerTransform.node->getPosition()); + charTransform.node->setOrientation( + spawnerTransform.node->getOrientation()); + charTransform.node->setScale( + spawnerTransform.node->getScale()); + } + charTransform.applyToNode(); + } + + /* Hide from editor entity lists and property panels. */ + if (character.has()) + character.remove(); + + /* Remove from editor UI cache so the spawned instance does not appear in + * entity lists or property panels. */ + if (m_uiSystem) + m_uiSystem->removeEntity(character); + + m_spawnedEntities[spawnerEntity.id()] = character; + m_spawnedVersions[spawnerEntity.id()] = + CharacterRegistry::getSingleton().getCharacterVersion( + spawner.registryId); + + /* Remember the spawner transform so we only re-apply it when it actually + * changes, leaving the spawned character free to move on its own. */ + const auto &spawnerTransform = spawnerEntity.get(); + m_lastSpawnerTransforms[spawnerEntity.id()] = { + spawnerTransform.position, + spawnerTransform.rotation, + spawnerTransform.scale, + }; + + Ogre::LogManager::getSingleton().logMessage( + "CharacterSpawnerSystem: spawned character " + + std::to_string(spawner.registryId) + " for spawner " + + std::to_string(spawnerEntity.id())); +} + +void CharacterSpawnerSystem::destroySpawnedEntity(flecs::entity character) +{ + if (!character.is_alive()) + return; + + /* Explicitly clean up Ogre resources that flecs component destructors do + * not handle automatically. */ + if (character.has()) { + auto &transform = character.get_mut(); + if (transform.node) { + try { + m_sceneMgr->destroySceneNode(transform.node); + } catch (...) { + } + transform.node = nullptr; + } + } + + if (character.has()) { + auto &renderable = character.get_mut(); + if (renderable.entity) { + try { + m_sceneMgr->destroyEntity(renderable.entity); + } catch (...) { + } + renderable.entity = nullptr; + } + } + + /* Remove from editor UI cache before destruction. */ + if (m_uiSystem) + m_uiSystem->removeEntity(character); + + /* CharacterSlotSystem and CharacterSystem clean up the rest via their + * OnRemove observers. */ + character.destruct(); +} + +void CharacterSpawnerSystem::applySpawnerTransform( + flecs::entity spawnerEntity, flecs::entity characterEntity) +{ + if (!spawnerEntity.is_alive() || !characterEntity.is_alive()) + return; + if (!spawnerEntity.has() || + !characterEntity.has()) + return; + + const auto &spawnerTransform = spawnerEntity.get(); + auto &charTransform = characterEntity.get_mut(); + + charTransform.position = spawnerTransform.position; + charTransform.rotation = spawnerTransform.rotation; + charTransform.scale = spawnerTransform.scale; + if (charTransform.node && spawnerTransform.node) { + charTransform.node->setPosition( + spawnerTransform.node->getPosition()); + charTransform.node->setOrientation( + spawnerTransform.node->getOrientation()); + charTransform.node->setScale(spawnerTransform.node->getScale()); + } + charTransform.applyToNode(); +} + +void CharacterSpawnerSystem::despawn(flecs::entity spawnerEntity) +{ + if (!spawnerEntity.is_alive()) + return; + + auto it = m_spawnedEntities.find(spawnerEntity.id()); + if (it == m_spawnedEntities.end()) + return; + + flecs::entity character = it->second; + m_spawnedEntities.erase(it); + m_spawnedVersions.erase(spawnerEntity.id()); + m_lastSpawnerTransforms.erase(spawnerEntity.id()); + + destroySpawnedEntity(character); + + Ogre::LogManager::getSingleton().logMessage( + "CharacterSpawnerSystem: despawned character for spawner " + + std::to_string(spawnerEntity.id())); +} + +void CharacterSpawnerSystem::spawn(flecs::entity spawnerEntity) +{ + if (!spawnerEntity.is_alive() || + !spawnerEntity.has()) + return; + spawnCharacter(spawnerEntity, + spawnerEntity.get()); +} + +void CharacterSpawnerSystem::lockSpawner(flecs::entity spawnerEntity) +{ + if (!spawnerEntity.is_alive() || + !spawnerEntity.has()) + return; + m_lockedSpawners.insert(spawnerEntity.id()); +} + +void CharacterSpawnerSystem::unlockSpawner(flecs::entity spawnerEntity) +{ + m_lockedSpawners.erase(spawnerEntity.id()); +} + +flecs::entity +CharacterSpawnerSystem::getSpawnedCharacter(flecs::entity spawnerEntity) const +{ + auto it = m_spawnedEntities.find(spawnerEntity.id()); + if (it != m_spawnedEntities.end() && it->second.is_alive()) + return it->second; + return flecs::entity::null(); +} + +flecs::entity +CharacterSpawnerSystem::getSpawnerForCharacter(flecs::entity characterEntity) const +{ + for (const auto &pair : m_spawnedEntities) { + if (pair.second == characterEntity) + return m_world.entity(pair.first); + } + return flecs::entity::null(); +} + +bool CharacterSpawnerSystem::isSpawnerLocked(flecs::entity spawnerEntity) const +{ + return m_lockedSpawners.find(spawnerEntity.id()) != + m_lockedSpawners.end(); +} + +void CharacterSpawnerSystem::update() +{ + if (!m_initialized) + return; + + const Ogre::Vector3 cameraPos = getCameraPosition(); + + m_query.each([&](flecs::entity e, CharacterSpawnerComponent &spawner, + TransformComponent &transform) { + if (!transform.node) + return; + + const Ogre::Vector3 diff = + transform.node->_getDerivedPosition() - cameraPos; + const float distSq = diff.squaredLength(); + + auto spawnedIt = m_spawnedEntities.find(e.id()); + const bool isSpawned = spawnedIt != m_spawnedEntities.end() && + spawnedIt->second.is_alive(); + + const bool locked = isSpawnerLocked(e); + + if (locked && !isSpawned) { + spawnCharacter(e, spawner); + } else if (!isSpawned && distSq <= spawner.spawnDistanceSq) { + spawnCharacter(e, spawner); + } else if (isSpawned && !locked && + distSq > spawner.despawnDistanceSq) { + despawn(e); + } else if (isSpawned) { + const uint64_t currentVersion = + CharacterRegistry::getSingleton() + .getCharacterVersion( + spawner.registryId); + auto verIt = m_spawnedVersions.find(e.id()); + if (verIt == m_spawnedVersions.end() || + verIt->second != currentVersion) { + despawn(e); + spawnCharacter(e, spawner); + } else { + /* Editor placement: if the spawner itself moved/rotated/scaled, + * update the spawned character to match. Otherwise leave the + * character alone so it can move on its own. */ + auto lastIt = + m_lastSpawnerTransforms.find(e.id()); + const bool hasSnapshot = + lastIt != m_lastSpawnerTransforms.end(); + const bool moved = + !hasSnapshot || + (transform.position - + lastIt->second.position) + .squaredLength() > + 1e-6f || + (transform.scale - lastIt->second.scale) + .squaredLength() > + 1e-6f || + 1.0f - Ogre::Math::Abs( + transform.rotation.Dot( + lastIt->second + .rotation)) > + 1e-4f; + if (moved) { + applySpawnerTransform( + e, spawnedIt->second); + m_lastSpawnerTransforms[e.id()] = { + transform.position, + transform.rotation, + transform.scale, + }; + } + } + } + }); +} diff --git a/src/features/editScene/systems/CharacterSpawnerSystem.hpp b/src/features/editScene/systems/CharacterSpawnerSystem.hpp new file mode 100644 index 0000000..9dee3a5 --- /dev/null +++ b/src/features/editScene/systems/CharacterSpawnerSystem.hpp @@ -0,0 +1,101 @@ +#ifndef EDITSCENE_CHARACTERSPAWNERSYSTEM_HPP +#define EDITSCENE_CHARACTERSPAWNERSYSTEM_HPP +#pragma once + +#include +#include +#include +#include + +class EditorCameraSystem; +class EditorUISystem; + +/** + * @brief Distance-based character spawning system. + * + * Queries entities with CharacterSpawnerComponent and TransformComponent. + * Spawns the referenced registry character when the active camera is within + * spawnDistanceSq, and despawns it when the camera moves beyond + * despawnDistanceSq. Registry changes that bump the character version also + * trigger a respawn. + */ +class CharacterSpawnerSystem { +public: + CharacterSpawnerSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr, + EditorCameraSystem *cameraSystem, + EditorUISystem *uiSystem = nullptr); + ~CharacterSpawnerSystem(); + + void initialize(); + void update(); + + /** + * Manually despawn the character associated with a spawner, if any. + */ + void despawn(flecs::entity spawnerEntity); + + /** + * Force an immediate spawn for the given spawner, regardless of camera + * distance. Does nothing if the spawner is missing or invalid. + */ + void spawn(flecs::entity spawnerEntity); + + /** + * Lock a spawner so that its spawned character is kept alive even when + * the camera moves out of range. Multiple locks are idempotent. + */ + void lockSpawner(flecs::entity spawnerEntity); + + /** + * Remove a lock previously added with lockSpawner(). + */ + void unlockSpawner(flecs::entity spawnerEntity); + + /** + * Return the character entity currently spawned by a spawner, or null if + * none is alive. + */ + flecs::entity getSpawnedCharacter(flecs::entity spawnerEntity) const; + + /** + * Return the spawner entity that currently owns the given character, or null + * if the character is not spawned by any known spawner. + */ + flecs::entity getSpawnerForCharacter(flecs::entity characterEntity) const; + + /** + * Return true if the spawner is currently locked. + */ + bool isSpawnerLocked(flecs::entity spawnerEntity) const; + +private: + void spawnCharacter(flecs::entity spawnerEntity, + const struct CharacterSpawnerComponent &spawner); + void destroySpawnedEntity(flecs::entity character); + void applySpawnerTransform(flecs::entity spawnerEntity, + flecs::entity characterEntity); + Ogre::Vector3 getCameraPosition() const; + + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + EditorCameraSystem *m_cameraSystem; + EditorUISystem *m_uiSystem; + flecs::query + m_query; + bool m_initialized = false; + + struct SpawnerTransformSnapshot { + Ogre::Vector3 position; + Ogre::Quaternion rotation; + Ogre::Vector3 scale; + }; + + std::unordered_map m_spawnedEntities; + std::unordered_map m_spawnedVersions; + std::unordered_map + m_lastSpawnerTransforms; + std::unordered_set m_lockedSpawners; +}; + +#endif // EDITSCENE_CHARACTERSPAWNERSYSTEM_HPP diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 338919c..61c2938 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -27,6 +27,7 @@ #include "../components/LodSettings.hpp" #include "../components/CharacterSlots.hpp" #include "../components/CharacterIdentity.hpp" +#include "../components/CharacterSpawner.hpp" #include "../components/Character.hpp" #include "../components/AnimationTree.hpp" #include "../components/AnimationTreeTemplate.hpp" @@ -1082,6 +1083,14 @@ void EditorUISystem::renderComponentList(flecs::entity entity) componentCount++; } + // Render CharacterSpawner if present + if (entity.has()) { + auto &spawner = entity.get_mut(); + m_componentRegistry.render(entity, + spawner); + componentCount++; + } + // Render AnimationTree if present if (entity.has()) { auto &at = entity.get_mut(); diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index d9b678a..cee381a 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -3,6 +3,7 @@ #pragma once #include #include +#include #include #include #include @@ -63,6 +64,20 @@ public: m_allEntities.push_back(entity); } + /** + * Remove an entity from the cached list. + * + * Used for runtime entities that should not be tracked by the editor UI + * (e.g. characters spawned by CharacterSpawnerSystem). + */ + void removeEntity(flecs::entity entity) + { + auto it = std::find(m_allEntities.begin(), m_allEntities.end(), + entity); + if (it != m_allEntities.end()) + m_allEntities.erase(it); + } + /** * Clear entity cache and deselect */ diff --git a/src/features/editScene/systems/PlayerControllerSystem.cpp b/src/features/editScene/systems/PlayerControllerSystem.cpp index dd29f91..ea28482 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.cpp +++ b/src/features/editScene/systems/PlayerControllerSystem.cpp @@ -5,8 +5,12 @@ #include "../components/Character.hpp" #include "../components/Transform.hpp" #include "../components/EntityName.hpp" +#include "../components/CharacterSpawner.hpp" +#include "../components/PathFollowing.hpp" +#include "../components/BehaviorTree.hpp" #include "AnimationTreeSystem.hpp" #include "CharacterSlotSystem.hpp" +#include "CharacterSpawnerSystem.hpp" #include "../camera/EditorCamera.hpp" #include #include @@ -15,12 +19,21 @@ #include #include +#ifdef EDITSCENE_VERBOSE_DEBUG +#define EDITSCENE_LOG_PC(msg) \ + Ogre::LogManager::getSingleton().logMessage(msg) +#else +#define EDITSCENE_LOG_PC(msg) +#endif + PlayerControllerSystem::PlayerControllerSystem(flecs::world &world, Ogre::SceneManager *sceneMgr, EditorApp *editorApp) : m_world(world) , m_sceneMgr(sceneMgr) , m_editorApp(editorApp) + , m_spawnerSystem(editorApp ? editorApp->getCharacterSpawnerSystem() : + nullptr) { } @@ -34,6 +47,10 @@ PlayerControllerSystem::~PlayerControllerSystem() void PlayerControllerSystem::shutdownController(ControllerState &state) { + if (state.targetEntity.is_alive()) + state.targetEntity.remove(); + state.targetEntity = flecs::entity::null(); + if (state.pivotNode) { m_sceneMgr->destroySceneNode(state.pivotNode); state.pivotNode = nullptr; @@ -42,6 +59,10 @@ void PlayerControllerSystem::shutdownController(ControllerState &state) m_sceneMgr->destroySceneNode(state.goalNode); state.goalNode = nullptr; } + if (state.spawnerEntity.is_alive() && m_spawnerSystem) { + m_spawnerSystem->unlockSpawner(state.spawnerEntity); + state.spawnerEntity = flecs::entity::null(); + } state.initialized = false; } @@ -66,10 +87,42 @@ void PlayerControllerSystem::initController(flecs::entity controllerEntity, ControllerState &state) { (void)controllerEntity; - state.targetEntity = findTargetEntity(pc.targetCharacterName); + + /* Release any previous spawner lock before re-resolving the target. */ + if (state.spawnerEntity.is_alive() && m_spawnerSystem) { + m_spawnerSystem->unlockSpawner(state.spawnerEntity); + } + state.spawnerEntity = flecs::entity::null(); + + flecs::entity namedEntity = findTargetEntity(pc.targetCharacterName); + flecs::entity target = namedEntity; + + if (namedEntity.is_alive() && + namedEntity.has()) { + state.spawnerEntity = namedEntity; + if (m_spawnerSystem) { + m_spawnerSystem->lockSpawner(namedEntity); + target = m_spawnerSystem->getSpawnedCharacter( + namedEntity); + if (!target.is_alive()) { + m_spawnerSystem->spawn(namedEntity); + target = m_spawnerSystem->getSpawnedCharacter( + namedEntity); + } + } else { + target = flecs::entity::null(); + } + } + + if (state.targetEntity.is_alive() && state.targetEntity != target) + state.targetEntity.remove(); + + state.targetEntity = target; if (!state.targetEntity.is_alive()) return; + state.targetEntity.add(); + if (!state.pivotNode) { state.pivotNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(); @@ -92,48 +145,54 @@ void PlayerControllerSystem::update(float deltaTime) if (!m_editorApp || m_editorApp->getGameMode() != EditorApp::GameMode::Game || m_editorApp->getGamePlayState() != - EditorApp::GamePlayState::Playing) + EditorApp::GamePlayState::Playing) { + /* Clean up any active controller state (and the PlayerControlled + * tag) when leaving game mode / play state so editor-mode + * ActionDebug tests can run on the same character. */ + for (auto &pair : m_states) + shutdownController(pair.second); + m_states.clear(); return; + } - m_world.query().each( - [&](flecs::entity e, PlayerControllerComponent &pc) { - auto it = m_states.find(e.id()); - if (it == m_states.end()) { - ControllerState newState; - initController(e, pc, newState); - it = m_states.insert({ e.id(), newState }).first; - } + m_world.query().each([&](flecs::entity e, + PlayerControllerComponent + &pc) { + auto it = m_states.find(e.id()); + if (it == m_states.end()) { + ControllerState newState; + initController(e, pc, newState); + it = m_states.insert({ e.id(), newState }).first; + } - ControllerState &state = it->second; + ControllerState &state = it->second; - // Re-resolve target if name changed or entity invalid - if (!state.targetEntity.is_alive()) { - initController(e, pc, state); - } - if (!state.targetEntity.is_alive()) - return; + // Re-resolve target if name changed or entity invalid + if (!state.targetEntity.is_alive()) { + initController(e, pc, state); + } + if (!state.targetEntity.is_alive()) + return; - if (!state.targetEntity.has()) - return; + if (!state.targetEntity.has()) + return; - auto &transform = - state.targetEntity.get(); - if (!transform.node) - return; + auto &transform = state.targetEntity.get(); + if (!transform.node) + return; - Ogre::Vector3 charPos = - transform.node->_getDerivedPosition(); + Ogre::Vector3 charPos = transform.node->_getDerivedPosition(); - // Update camera - if (pc.cameraMode == PlayerControllerComponent::TPS) { - updateTPSCamera(pc, state, charPos, deltaTime); - } else { - updateFPSCamera(pc, state, charPos); - } + // Update camera + if (pc.cameraMode == PlayerControllerComponent::TPS) { + updateTPSCamera(pc, state, charPos, deltaTime); + } else { + updateFPSCamera(pc, state, charPos); + } - // Update locomotion - updateLocomotion(pc, state, deltaTime); - }); + // Update locomotion + updateLocomotion(pc, state, deltaTime); + }); } void PlayerControllerSystem::updateTPSCamera(PlayerControllerComponent &pc, @@ -380,8 +439,113 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc, } } + Ogre::String prevState = ats->getCurrentState( + state.targetEntity, pc.locomotionStateMachine); + if (!animState.empty()) { ats->setState(state.targetEntity, pc.locomotionStateMachine, animState); } -} \ No newline at end of file + + /* Log every locomotion transition so we can verify state names and + * see whether another system is overriding the state. */ + if (prevState != animState) { + EDITSCENE_LOG_PC("[PC_DBG] locomotion transition: requested=" + + animState + " prev=" + prevState + " input w=" + + Ogre::StringConverter::toString(input.w) + + " shift=" + + Ogre::StringConverter::toString(input.shift)); + } + + /* Detect the stuck slow-walk condition: we are setting the walk + * animation but no movement keys are pressed, or another system + * has already forced the walk state before we could set idle. */ + bool anyMovement = input.w || input.a || input.s || input.d; + bool hasPathFollowing = + state.targetEntity.has(); + bool hasBehaviorTree = state.targetEntity.has(); + + if ((animState == pc.walkState && !anyMovement) || + (animState == pc.idleState && prevState == pc.walkState && + !anyMovement)) { + m_stuckWalkFrames++; + if (m_stuckWalkFrames >= 60 && !m_stuckWalkLogged) { + EDITSCENE_LOG_PC("[PC_DBG] STUCK WALK DETECTED: frames=" + + Ogre::StringConverter::toString( + m_stuckWalkFrames) + + " w=" + + Ogre::StringConverter::toString(input.w) + + " a=" + + Ogre::StringConverter::toString(input.a) + + " s=" + + Ogre::StringConverter::toString(input.s) + + " d=" + + Ogre::StringConverter::toString(input.d) + + " shift=" + + Ogre::StringConverter::toString(input.shift) + + " requested=" + animState + + " prev=" + prevState + " isMoving=" + + Ogre::StringConverter::toString(isMoving) + + " inputLocked=" + + Ogre::StringConverter::toString( + pc.inputLocked) + + " targetAlive=" + + Ogre::StringConverter::toString( + state.targetEntity.is_alive()) + + " pathFollowing=" + + Ogre::StringConverter::toString( + hasPathFollowing) + + " behaviorTree=" + + Ogre::StringConverter::toString( + hasBehaviorTree)); + m_stuckWalkLogged = true; + } + } else { + m_stuckWalkFrames = 0; + m_stuckWalkLogged = false; + } +} + +flecs::entity +PlayerControllerSystem::getCurrentTarget(flecs::entity controllerEntity) +{ + auto it = m_states.find(controllerEntity.id()); + if (it != m_states.end() && it->second.targetEntity.is_alive()) + return it->second.targetEntity; + + if (!controllerEntity.is_alive() || + !controllerEntity.has()) + return flecs::entity::null(); + + auto &pc = controllerEntity.get(); + return resolveTargetEntity(pc.targetCharacterName, true); +} + +flecs::entity +PlayerControllerSystem::resolveTargetEntity(const Ogre::String &targetName, + bool forceSpawn) +{ + flecs::entity namedEntity = findTargetEntity(targetName); + if (!namedEntity.is_alive()) + return flecs::entity::null(); + + if (!namedEntity.has()) + return namedEntity; + + if (!m_spawnerSystem) + return flecs::entity::null(); + + if (forceSpawn) { + m_spawnerSystem->lockSpawner(namedEntity); + flecs::entity spawned = + m_spawnerSystem->getSpawnedCharacter(namedEntity); + if (!spawned.is_alive()) { + m_spawnerSystem->spawn(namedEntity); + spawned = m_spawnerSystem->getSpawnedCharacter( + namedEntity); + } + return spawned; + } + + return m_spawnerSystem->getSpawnedCharacter(namedEntity); +} diff --git a/src/features/editScene/systems/PlayerControllerSystem.hpp b/src/features/editScene/systems/PlayerControllerSystem.hpp index 86ddc9a..01e196f 100644 --- a/src/features/editScene/systems/PlayerControllerSystem.hpp +++ b/src/features/editScene/systems/PlayerControllerSystem.hpp @@ -11,6 +11,7 @@ class EditorApp; class AnimationTreeSystem; class CharacterSlotSystem; +class CharacterSpawnerSystem; /** * System that handles player input, camera control (FPS/TPS), @@ -26,9 +27,26 @@ public: void update(float deltaTime); + /** + * Return the live target entity for a controller. If the controller has + * already been initialized this frame, returns its cached target. Otherwise + * falls back to resolving the controller's targetCharacterName, spawning the + * character through a spawner if necessary. + */ + flecs::entity getCurrentTarget(flecs::entity controllerEntity); + + /** + * Resolve a target name to a live entity. If the named entity is a character + * spawner and forceSpawn is true, the spawner is locked and spawned and the + * spawned character entity is returned. Direct characters are returned as-is. + */ + flecs::entity resolveTargetEntity(const Ogre::String &targetName, + bool forceSpawn = false); + private: struct ControllerState { flecs::entity targetEntity = flecs::entity::null(); + flecs::entity spawnerEntity = flecs::entity::null(); float yaw = 0.0f; float pitch = 0.0f; Ogre::SceneNode *pivotNode = nullptr; @@ -54,8 +72,13 @@ private: flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; EditorApp *m_editorApp; + CharacterSpawnerSystem *m_spawnerSystem; std::unordered_map m_states; + + // Diagnostics for the stuck slow-walk bug + int m_stuckWalkFrames = 0; + bool m_stuckWalkLogged = false; }; #endif // EDITSCENE_PLAYERCONTROLLERSYSTEM_HPP diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 2a2942b..3c6a60a 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -19,6 +19,7 @@ #include "../components/Primitive.hpp" #include "../components/TriangleBuffer.hpp" #include "../components/Character.hpp" +#include "../components/CharacterSpawner.hpp" #include "../components/CharacterSlots.hpp" #include "../components/CharacterIdentity.hpp" #include "CharacterRegistry.hpp" @@ -158,7 +159,12 @@ bool SceneSerializer::loadFromFile(const std::string &filepath, } } - // Second pass: resolve material entity references + // Second pass: resolve procedural texture references and material + // entity references. + Ogre::LogManager::getSingleton().logMessage( + "SceneSerializer: Resolving procedural texture references (second pass)"); + resolveProceduralTextureReferences(); + Ogre::LogManager::getSingleton().logMessage( "SceneSerializer: Resolving material references (second pass)"); resolveMaterialReferences(); @@ -249,6 +255,10 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) json["characterIdentity"] = serializeCharacterIdentity(entity); } + if (entity.has()) { + json["characterSpawner"] = serializeCharacterSpawner(entity); + } + if (entity.has()) { json["animationTree"] = serializeAnimationTree(entity); } @@ -477,6 +487,10 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, deserializeCharacterSlots(entity, json["characterSlots"]); } + if (json.contains("characterSpawner")) { + deserializeCharacterSpawner(entity, json["characterSpawner"]); + } + if (json.contains("animationTree")) { deserializeAnimationTree(entity, json["animationTree"]); } @@ -712,6 +726,10 @@ void SceneSerializer::deserializeEntityComponents( deserializeCharacterSlots(entity, json["characterSlots"]); } + if (json.contains("characterSpawner")) { + deserializeCharacterSpawner(entity, json["characterSpawner"]); + } + if (json.contains("animationTree")) { deserializeAnimationTree(entity, json["animationTree"]); } @@ -1038,6 +1056,37 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity, } } +void SceneSerializer::resolveProceduralTextureReferences() +{ + for (auto &pending : m_pendingProceduralTextureResolutions) { + flecs::entity entity = pending.first; + const std::string &textureId = pending.second; + if (!entity.is_alive() || textureId.empty() || + !entity.has()) + continue; + + auto &material = entity.get_mut(); + flecs::entity resolved = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity textureEntity, + ProceduralTextureComponent &texture) { + if (texture.textureId == textureId) { + resolved = textureEntity; + } + }); + + if (resolved.is_alive()) { + material.diffuseTextureEntity = resolved; + Ogre::LogManager::getSingleton().logMessage( + "SceneSerializer: Resolved ProceduralMaterial " + + std::to_string(entity.id()) + + " diffuse texture " + textureId); + } + } + + m_pendingProceduralTextureResolutions.clear(); +} + void SceneSerializer::resolveMaterialReferences() { // Resolve Town material references @@ -1059,6 +1108,8 @@ void SceneSerializer::resolveMaterialReferences() ProceduralMaterialComponent>()) { town.proceduralMaterialEntity = matEntity; + town.proceduralMaterialEntityId = + std::to_string(matEntity.id()); Ogre::LogManager::getSingleton().logMessage( "SceneSerializer: Resolved Town material entity " + Ogre::StringConverter:: @@ -1090,6 +1141,8 @@ void SceneSerializer::resolveMaterialReferences() ProceduralMaterialComponent>()) { district.proceduralMaterialEntity = matEntity; + district.proceduralMaterialEntityId = + std::to_string(matEntity.id()); Ogre::LogManager::getSingleton().logMessage( "SceneSerializer: Resolved District material entity " + Ogre::StringConverter:: @@ -1121,6 +1174,8 @@ void SceneSerializer::resolveMaterialReferences() ProceduralMaterialComponent>()) { lot.proceduralMaterialEntity = matEntity; + lot.proceduralMaterialEntityId = + std::to_string(matEntity.id()); Ogre::LogManager::getSingleton().logMessage( "SceneSerializer: Resolved Lot material entity " + Ogre::StringConverter:: @@ -1983,16 +2038,12 @@ void SceneSerializer::deserializeProceduralMaterial(flecs::entity entity, material.materialId = generatePersistentId(); } - // Resolve texture reference by ID (like LOD system does) + // Texture reference is resolved in a second pass after all entities + // are loaded, so the referenced ProceduralTexture entity exists even if + // it appears later in the JSON file. if (!material.diffuseTextureId.empty()) { - m_world.query().each( - [&](flecs::entity e, - ProceduralTextureComponent &texture) { - if (texture.textureId == - material.diffuseTextureId) { - material.diffuseTextureEntity = e; - } - }); + m_pendingProceduralTextureResolutions.push_back( + { entity, material.diffuseTextureId }); } if (json.contains("ambient")) { @@ -2221,6 +2272,28 @@ void SceneSerializer::deserializeCharacterIdentity(flecs::entity entity, entity.set(ci); } +nlohmann::json SceneSerializer::serializeCharacterSpawner(flecs::entity entity) +{ + auto &spawner = entity.get(); + nlohmann::json json; + json["registryId"] = spawner.registryId; + json["spawnDistance"] = std::sqrt(spawner.spawnDistanceSq); + json["despawnDistance"] = std::sqrt(spawner.despawnDistanceSq); + return json; +} + +void SceneSerializer::deserializeCharacterSpawner(flecs::entity entity, + const nlohmann::json &json) +{ + CharacterSpawnerComponent spawner; + spawner.registryId = json.value("registryId", 0); + float spawnDist = json.value("spawnDistance", 100.0f); + float despawnDist = json.value("despawnDistance", 200.0f); + spawner.spawnDistanceSq = spawnDist * spawnDist; + spawner.despawnDistanceSq = despawnDist * despawnDist; + entity.set(spawner); +} + /* Forward declarations for AnimationTreeNode serialization */ static nlohmann::json serializeAnimationTreeNode(const AnimationTreeNode &node); static void deserializeAnimationTreeNode(AnimationTreeNode &node, @@ -2695,6 +2768,8 @@ void SceneSerializer::deserializeTown(flecs::entity entity, ProceduralMaterialComponent>()) { town.proceduralMaterialEntity = matEntity; + town.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } else { // Entity not found in map (might not exist or not loaded yet) @@ -2705,6 +2780,8 @@ void SceneSerializer::deserializeTown(flecs::entity entity, ProceduralMaterialComponent>()) { town.proceduralMaterialEntity = matEntity; + town.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } } catch (...) { @@ -2764,6 +2841,8 @@ void SceneSerializer::deserializeDistrict(flecs::entity entity, ProceduralMaterialComponent>()) { district.proceduralMaterialEntity = matEntity; + district.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } else { // Entity not found in map (might not exist or not loaded yet) @@ -2774,6 +2853,8 @@ void SceneSerializer::deserializeDistrict(flecs::entity entity, ProceduralMaterialComponent>()) { district.proceduralMaterialEntity = matEntity; + district.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } } catch (...) { @@ -2825,6 +2906,8 @@ void SceneSerializer::deserializeLot(flecs::entity entity, ProceduralMaterialComponent>()) { lot.proceduralMaterialEntity = matEntity; + lot.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } else { // Entity not found in map (might not exist or not loaded yet) @@ -2835,6 +2918,8 @@ void SceneSerializer::deserializeLot(flecs::entity entity, ProceduralMaterialComponent>()) { lot.proceduralMaterialEntity = matEntity; + lot.proceduralMaterialEntityId = + std::to_string(matEntity.id()); } } } catch (...) { diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index bfa307c..d553f34 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -84,6 +84,10 @@ private: flecs::entity parent, EditorUISystem *uiSystem); + // Resolve procedural texture references for ProceduralMaterial components + // (must run after all entities are loaded). + void resolveProceduralTextureReferences(); + // Second-pass: resolve material entity references for Town, District, Lot void resolveMaterialReferences(); @@ -105,6 +109,7 @@ private: nlohmann::json serializeTriangleBuffer(flecs::entity entity); nlohmann::json serializeCharacter(flecs::entity entity); nlohmann::json serializeCharacterIdentity(flecs::entity entity); + nlohmann::json serializeCharacterSpawner(flecs::entity entity); nlohmann::json serializeCharacterSlots(flecs::entity entity); nlohmann::json serializeCharacterShapeKeys(flecs::entity entity); nlohmann::json serializeAnimationTree(flecs::entity entity); @@ -158,6 +163,8 @@ private: const nlohmann::json &json); void deserializeCharacterIdentity(flecs::entity entity, const nlohmann::json &json); + void deserializeCharacterSpawner(flecs::entity entity, + const nlohmann::json &json); void deserializeCharacterSlots(flecs::entity entity, const nlohmann::json &json); void deserializeCharacterShapeKeys(flecs::entity entity, @@ -305,6 +312,10 @@ private: std::vector m_pendingTownResolutions; std::vector m_pendingDistrictResolutions; std::vector m_pendingLotResolutions; + + // Pending ProceduralMaterial -> ProceduralTexture resolutions + std::vector> + m_pendingProceduralTextureResolutions; }; #endif // EDITSCENE_SCENESERIALIZER_HPP diff --git a/src/features/editScene/systems/SmartObjectSystem.cpp b/src/features/editScene/systems/SmartObjectSystem.cpp index f01bae8..09f32a9 100644 --- a/src/features/editScene/systems/SmartObjectSystem.cpp +++ b/src/features/editScene/systems/SmartObjectSystem.cpp @@ -36,6 +36,11 @@ static bool hasRootMotion(flecs::entity e) return e.get().useRootMotion; } +static bool isPlayerControlled(flecs::entity e) +{ + return e.has(); +} + SmartObjectSystem *SmartObjectSystem::s_instance = nullptr; SmartObjectSystem::SmartObjectSystem(flecs::world &world, @@ -253,7 +258,8 @@ void SmartObjectSystem::update(float deltaTime) // Skip player-controlled characters - they are managed // by PlayerControllerSystem, not by SmartObjectSystem AI. if (playerCharacterIds.find(e.id()) != - playerCharacterIds.end()) + playerCharacterIds.end() || + isPlayerControlled(e)) return; auto &state = m_states[e.id()]; @@ -671,7 +677,8 @@ void SmartObjectSystem::update(float deltaTime) // Skip player-controlled characters - they are managed // by PlayerControllerSystem, not by SmartObjectSystem AI. - if (playerCharacterIds.find(e.id()) != playerCharacterIds.end()) + if (playerCharacterIds.find(e.id()) != playerCharacterIds.end() || + isPlayerControlled(e)) return; auto &state = m_states[e.id()]; diff --git a/src/features/editScene/tests/component_lua_test b/src/features/editScene/tests/component_lua_test new file mode 100644 index 0000000000000000000000000000000000000000..24fe5a96d0f9b1d3854acc9ddb9ce0532289b2d2 GIT binary patch literal 1470533 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 z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ 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 BCRhLf literal 0 HcmV?d00001 diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index fcc636d..0122930 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -6,14 +6,11 @@ * add_component, remove_component, has_component, get_component, * set_component, get_field, set_field. * - * Build with: - * g++ -std=c++17 -I. -I../.. -I../../lua/lua-5.4.8/src \ - * component_lua_test.cpp \ - * ../lua/LuaComponentApi.cpp \ - * ../../lua/lua-5.4.8/src/liblua.a \ - * -o component_lua_test -lm + * Build with CMake (recommended): + * cmake --build --target component_lua_test * - * Or via CMake (see CMakeLists.txt in this directory). + * Run: + * /src/features/editScene/component_lua_test */ #include @@ -1386,22 +1383,26 @@ static int testTriangleBufferComponent(lua_State *L) } // --------------------------------------------------------------------------- -// Test 46: CharacterSlots component +// Test 46: CharacterSpawner component // --------------------------------------------------------------------------- -static int testCharacterSlotsComponent(lua_State *L) +static int testCharacterSpawnerComponent(lua_State *L) { - TEST("CharacterSlots component"); + TEST("CharacterSpawner component"); bool ok = runLua(L, "local id = ecs.create_entity();" - "ecs.set_component(id, 'CharacterSlots', {" - " slotCount = 8" + "ecs.set_component(id, 'CharacterSpawner', {" + " registryId = 42," + " spawnDistanceSq = 10000," + " despawnDistanceSq = 40000" "});" - "local c = ecs.get_component(id, 'CharacterSlots');" - "assert(c ~= nil, 'CharacterSlots should exist');" - "assert(c.slotCount == 8, 'wrong slotCount')"); + "local c = ecs.get_component(id, 'CharacterSpawner');" + "assert(c ~= nil, 'CharacterSpawner should exist');" + "assert(c.registryId == 42, 'wrong registryId');" + "assert(c.spawnDistanceSq == 10000, 'wrong spawnDistanceSq');" + "assert(c.despawnDistanceSq == 40000, 'wrong despawnDistanceSq')"); if (!ok) - FAIL("CharacterSlots component assertion failed"); + FAIL("CharacterSpawner component assertion failed"); PASS(); return 0; @@ -1878,7 +1879,7 @@ int main() failures += testProceduralMaterialComponent(L); failures += testPrimitiveComponent(L); failures += testTriangleBufferComponent(L); - failures += testCharacterSlotsComponent(L); + failures += testCharacterSpawnerComponent(L); failures += testAnimationTreeComponent(L); failures += testAnimationTreeTemplateComponent(L); failures += testBehaviorTreeComponent(L); diff --git a/src/features/editScene/ui/CharacterSpawnerEditor.cpp b/src/features/editScene/ui/CharacterSpawnerEditor.cpp new file mode 100644 index 0000000..57b9d5c --- /dev/null +++ b/src/features/editScene/ui/CharacterSpawnerEditor.cpp @@ -0,0 +1,70 @@ +#include "CharacterSpawnerEditor.hpp" +#include "../systems/CharacterRegistry.hpp" +#include + +CharacterSpawnerEditor::CharacterSpawnerEditor(Ogre::SceneManager *sceneMgr) + : m_sceneMgr(sceneMgr) +{ + (void)m_sceneMgr; +} + +bool CharacterSpawnerEditor::renderComponent( + flecs::entity entity, CharacterSpawnerComponent &spawner) +{ + (void)entity; + bool modified = false; + + if (ImGui::CollapsingHeader("Character Spawner", + ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Indent(); + + int registryId = static_cast(spawner.registryId); + if (ImGui::InputInt("Registry ID", ®istryId)) { + if (registryId >= 0) { + spawner.registryId = + static_cast(registryId); + modified = true; + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip( + "Character registry ID of the character to spawn"); + } + + const CharacterRegistry::CharacterRecord *rec = + CharacterRegistry::getSingleton().findCharacter( + spawner.registryId); + if (rec) { + ImGui::TextDisabled("Character: %s %s", + rec->firstName.c_str(), + rec->lastName.c_str()); + } else if (spawner.registryId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "No character with this registry ID"); + } + + float spawnDist = std::sqrt(spawner.spawnDistanceSq); + if (ImGui::DragFloat("Spawn Distance", &spawnDist, 1.0f, + 0.0f, 10000.0f)) { + spawner.spawnDistanceSq = spawnDist * spawnDist; + modified = true; + } + + float despawnDist = std::sqrt(spawner.despawnDistanceSq); + if (ImGui::DragFloat("Despawn Distance", &despawnDist, 1.0f, + 0.0f, 10000.0f)) { + spawner.despawnDistanceSq = despawnDist * despawnDist; + modified = true; + } + + if (spawner.spawnDistanceSq > spawner.despawnDistanceSq) { + ImGui::TextColored( + ImVec4(1.0f, 0.4f, 0.4f, 1.0f), + "Spawn distance is greater than despawn distance"); + } + + ImGui::Unindent(); + } + + return modified; +} diff --git a/src/features/editScene/ui/CharacterSpawnerEditor.hpp b/src/features/editScene/ui/CharacterSpawnerEditor.hpp new file mode 100644 index 0000000..be5db09 --- /dev/null +++ b/src/features/editScene/ui/CharacterSpawnerEditor.hpp @@ -0,0 +1,30 @@ +#ifndef EDITSCENE_CHARACTERSPAWNEREDITOR_HPP +#define EDITSCENE_CHARACTERSPAWNEREDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/CharacterSpawner.hpp" +#include + +/** + * @brief Editor panel for CharacterSpawnerComponent. + */ +class CharacterSpawnerEditor + : public ComponentEditor { +public: + explicit CharacterSpawnerEditor(Ogre::SceneManager *sceneMgr); + + const char *getName() const override + { + return "Character Spawner"; + } + +protected: + bool renderComponent(flecs::entity entity, + CharacterSpawnerComponent &spawner) override; + +private: + Ogre::SceneManager *m_sceneMgr; +}; + +#endif // EDITSCENE_CHARACTERSPAWNEREDITOR_HPP