From 5ed75521644a369a974eca31fb882ed723ee4b53 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sat, 25 Apr 2026 00:11:21 +0300 Subject: [PATCH] Path following --- src/features/editScene/CMakeLists.txt | 8 + src/features/editScene/EditorApp.cpp | 22 + src/features/editScene/EditorApp.hpp | 3 + .../editScene/components/ActionDebug.hpp | 64 ++ .../editScene/components/CharacterSlots.hpp | 7 + .../editScene/components/SmartObject.hpp | 38 + .../components/SmartObjectModule.cpp | 20 + .../editScene/systems/EditorUISystem.cpp | 12 + .../editScene/systems/SceneSerializer.cpp | 78 ++ .../editScene/systems/SceneSerializer.hpp | 3 + .../editScene/systems/SmartObjectSystem.cpp | 725 ++++++++++++++++++ .../editScene/systems/SmartObjectSystem.hpp | 109 +++ .../editScene/ui/ActionDebugEditor.cpp | 184 ++++- .../editScene/ui/ActionDebugEditor.hpp | 13 +- .../editScene/ui/SmartObjectEditor.cpp | 115 +++ .../editScene/ui/SmartObjectEditor.hpp | 30 + 16 files changed, 1411 insertions(+), 20 deletions(-) create mode 100644 src/features/editScene/components/SmartObject.hpp create mode 100644 src/features/editScene/components/SmartObjectModule.cpp create mode 100644 src/features/editScene/systems/SmartObjectSystem.cpp create mode 100644 src/features/editScene/systems/SmartObjectSystem.hpp create mode 100644 src/features/editScene/ui/SmartObjectEditor.cpp create mode 100644 src/features/editScene/ui/SmartObjectEditor.hpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index c838e45..b246285 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -43,6 +43,10 @@ set(EDITSCENE_SOURCES recast/PartitionedMesh.cpp recast/fastlz.c systems/CharacterSystem.cpp + systems/SmartObjectSystem.cpp + components/SmartObjectModule.cpp + ui/SmartObjectEditor.cpp + ui/TransformEditor.cpp ui/RenderableEditor.cpp ui/PhysicsColliderEditor.cpp @@ -163,6 +167,10 @@ set(EDITSCENE_HEADERS recast/PartitionedMesh.hpp recast/fastlz.h systems/CharacterSystem.hpp + systems/SmartObjectSystem.hpp + components/SmartObject.hpp + ui/SmartObjectEditor.hpp + systems/ProceduralTextureSystem.hpp systems/StaticGeometrySystem.hpp systems/SceneSerializer.hpp diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index babc2ee..06d76b7 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -18,7 +18,9 @@ #include "systems/BehaviorTreeSystem.hpp" #include "systems/NavMeshSystem.hpp" #include "systems/CharacterSystem.hpp" +#include "systems/SmartObjectSystem.hpp" #include "systems/CellGridSystem.hpp" + #include "systems/NormalDebugSystem.hpp" #include "systems/RoomLayoutSystem.hpp" #include "systems/StartupMenuSystem.hpp" @@ -61,6 +63,8 @@ #include "components/BehaviorTree.hpp" #include "components/GoapBlackboard.hpp" #include "components/NavMesh.hpp" +#include "components/SmartObject.hpp" + #include #include @@ -312,7 +316,16 @@ void EditorApp::setup() m_navMeshSystem = std::make_unique(m_world, m_sceneMgr); + // Setup SmartObject system (requires NavMesh, BehaviorTree, and AnimationTree) + m_smartObjectSystem = std::make_unique( + m_world, m_sceneMgr, m_navMeshSystem.get(), + m_behaviorTreeSystem.get()); + // Wire up AnimationTreeSystem for animation state machine control + m_smartObjectSystem->setAnimationTreeSystem( + m_animationTreeSystem.get()); + // Setup Character physics system + m_characterSystem = std::make_unique(m_world, m_sceneMgr); m_characterSystem->initialize(); @@ -550,6 +563,9 @@ void EditorApp::setupECS() m_world.component(); m_world.component(); + // Register Smart Object component + m_world.component(); + // Register Navigation components m_world.component(); m_world.component(); @@ -727,7 +743,13 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt) m_navMeshSystem->update(evt.timeSinceLastFrame); } + /* --- Smart Object system (AI navigation to smart objects) --- */ + if (m_smartObjectSystem) { + m_smartObjectSystem->update(evt.timeSinceLastFrame); + } + /* --- Dynamic physics (characters after static world) --- */ + if (m_characterSystem) { m_characterSystem->update(evt.timeSinceLastFrame); } diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index a67e70b..d2c5c24 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -35,6 +35,7 @@ class EditorSunSystem; class EditorSkyboxSystem; class EditorWaterPlaneSystem; class NormalDebugSystem; +class SmartObjectSystem; class EditorApp; /** @@ -220,8 +221,10 @@ private: std::unique_ptr m_cellGridSystem; std::unique_ptr m_normalDebugSystem; std::unique_ptr m_roomLayoutSystem; + std::unique_ptr m_smartObjectSystem; // Game systems + std::unique_ptr m_startupMenuSystem; std::unique_ptr m_playerControllerSystem; diff --git a/src/features/editScene/components/ActionDebug.hpp b/src/features/editScene/components/ActionDebug.hpp index 110b4fd..f1eab05 100644 --- a/src/features/editScene/components/ActionDebug.hpp +++ b/src/features/editScene/components/ActionDebug.hpp @@ -4,6 +4,34 @@ #include "GoapBlackboard.hpp" #include +#include + +/** + * Configuration for one animation state machine / state pair. + * Used to map logical animation names (e.g. "idle", "walk", "run") + * to the actual state machine and state in the animation tree. + */ +struct AnimationStateConfig { + /** Logical name used by code (e.g. "idle", "walk", "run") */ + Ogre::String name; + + /** Name of the state machine in the animation tree */ + Ogre::String stateMachine = "Locomotion"; + + /** Name of the state within the state machine */ + Ogre::String stateName = "Idle"; + + AnimationStateConfig() = default; + + AnimationStateConfig(const Ogre::String &name_, + const Ogre::String &stateMachine_, + const Ogre::String &stateName_) + : name(name_) + , stateMachine(stateMachine_) + , stateName(stateName_) + { + } +}; /** * Per-character action debug component. @@ -29,7 +57,43 @@ struct ActionDebug { // Debug output Ogre::String lastResult; + // --- Animation state machine configuration --- + // List of animation state configs (name/stateMachine/stateName triples) + // Default entries: idle, walk, run + std::vector animStates = { + { "idle", "Locomotion", "Idle" }, + { "walk", "Locomotion", "Walk" }, + { "run", "Locomotion", "Run" }, + }; + + // Walk speed (m/s) used for root motion scaling + float walkSpeed = 2.5f; + + // Run speed (m/s) used for root motion scaling + float runSpeed = 5.0f; + + // Whether to use root motion (true) or manual velocity (false) + bool useRootMotion = true; + ActionDebug() = default; + + /** + * Get the state machine and state name for a given logical animation name. + * Returns true if found, false if not (caller should use defaults). + */ + bool getAnimState(const Ogre::String &animName, + Ogre::String &outStateMachine, + Ogre::String &outStateName) const + { + for (const auto &cfg : animStates) { + if (cfg.name == animName) { + outStateMachine = cfg.stateMachine; + outStateName = cfg.stateName; + return true; + } + } + return false; + } }; #endif // EDITSCENE_ACTION_DEBUG_HPP diff --git a/src/features/editScene/components/CharacterSlots.hpp b/src/features/editScene/components/CharacterSlots.hpp index 65ac45f..ce323ec 100644 --- a/src/features/editScene/components/CharacterSlots.hpp +++ b/src/features/editScene/components/CharacterSlots.hpp @@ -17,6 +17,13 @@ struct CharacterSlotsComponent { /* Runtime: master entity with shared skeleton (set by CharacterSlotSystem) */ Ogre::Entity *masterEntity = nullptr; + /** + * Front-facing axis for this character model. + * Most models face -Z (NEGATIVE_UNIT_Z), but some face +Z. + * This is used by path following to rotate the character correctly. + */ + Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z; + CharacterSlotsComponent() = default; }; diff --git a/src/features/editScene/components/SmartObject.hpp b/src/features/editScene/components/SmartObject.hpp new file mode 100644 index 0000000..0e37080 --- /dev/null +++ b/src/features/editScene/components/SmartObject.hpp @@ -0,0 +1,38 @@ +#ifndef EDITSCENE_SMART_OBJECT_HPP +#define EDITSCENE_SMART_OBJECT_HPP +#pragma once + +#include +#include +#include + +/** + * Smart Object component. + * + * Defines an interactive object in the world that characters can + * navigate to and perform GOAP actions on. + * + * The entity's TransformComponent defines the position/orientation. + * Characters will pathfind to within `radius` distance in XZ plane + * and `height` difference in Y, then execute the selected action. + */ +struct SmartObjectComponent { + // Interaction radius in XZ plane + float radius = 1.0f; + + // Maximum height difference for interaction + float height = 1.8f; + + // Names of GOAP actions (from ActionDatabase) that this object provides + std::vector actionNames; + + SmartObjectComponent() = default; + + explicit SmartObjectComponent(float radius_, float height_) + : radius(radius_) + , height(height_) + { + } +}; + +#endif // EDITSCENE_SMART_OBJECT_HPP diff --git a/src/features/editScene/components/SmartObjectModule.cpp b/src/features/editScene/components/SmartObjectModule.cpp new file mode 100644 index 0000000..2f755bc --- /dev/null +++ b/src/features/editScene/components/SmartObjectModule.cpp @@ -0,0 +1,20 @@ +#include "SmartObject.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/SmartObjectEditor.hpp" + +REGISTER_COMPONENT_GROUP("Smart Object", "AI", SmartObjectComponent, + SmartObjectEditor) +{ + registry.registerComponent( + "Smart Object", "AI", std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 12688e1..303fb69 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -33,6 +33,8 @@ #include "../components/BehaviorTree.hpp" #include "../components/GoapBlackboard.hpp" #include "../components/NavMesh.hpp" +#include "../components/SmartObject.hpp" + #include "../ui/TransformEditor.hpp" #include "../ui/RenderableEditor.hpp" #include "../ui/PhysicsColliderEditor.hpp" @@ -480,6 +482,8 @@ void EditorUISystem::renderEntityNode(flecs::entity entity, int depth) indicators += " [Nav]"; if (entity.has()) indicators += " [NavSrc]"; + if (entity.has()) + indicators += " [SO]"; snprintf(label, sizeof(label), "%s%s##%llu", name.c_str(), indicators.c_str(), (unsigned long long)entity.id()); @@ -891,7 +895,15 @@ void EditorUISystem::renderComponentList(flecs::entity entity) componentCount++; } + // Render SmartObject if present + if (entity.has()) { + auto &so = entity.get_mut(); + m_componentRegistry.render(entity, so); + componentCount++; + } + // Show message if no components + if (componentCount == 0) { ImGui::TextDisabled("No components"); ImGui::Text("Click 'Add Component' to add components"); diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index b808f5d..a1146e3 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -30,6 +30,7 @@ #include "../components/Skybox.hpp" #include "../components/ActionDatabase.hpp" #include "../components/ActionDebug.hpp" +#include "../components/SmartObject.hpp" #include "../components/BehaviorTree.hpp" #include "../components/GoapBlackboard.hpp" #include "../components/NavMesh.hpp" @@ -292,6 +293,9 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity) if (entity.has()) { json["actionDebug"] = serializeActionDebug(entity); } + if (entity.has()) { + json["smartObject"] = serializeSmartObject(entity); + } if (entity.has()) { json["behaviorTree"] = serializeBehaviorTree(entity); } @@ -485,6 +489,9 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json, if (json.contains("actionDebug")) { deserializeActionDebug(entity, json["actionDebug"]); } + if (json.contains("smartObject")) { + deserializeSmartObject(entity, json["smartObject"]); + } if (json.contains("behaviorTree")) { deserializeBehaviorTree(entity, json["behaviorTree"]); } @@ -748,6 +755,9 @@ void SceneSerializer::deserializeEntityFirstPass(const nlohmann::json &json, if (json.contains("actionDebug")) { deserializeActionDebug(entity, json["actionDebug"]); } + if (json.contains("smartObject")) { + deserializeSmartObject(entity, json["smartObject"]); + } if (json.contains("behaviorTree")) { deserializeBehaviorTree(entity, json["behaviorTree"]); } @@ -3204,6 +3214,20 @@ nlohmann::json SceneSerializer::serializeActionDebug(flecs::entity entity) json["selectedActionName"] = debug.selectedActionName; if (!debug.selectedGoalName.empty()) json["selectedGoalName"] = debug.selectedGoalName; + + // Serialize animation state configs + json["animStates"] = nlohmann::json::array(); + for (const auto &cfg : debug.animStates) { + nlohmann::json entry; + entry["name"] = cfg.name; + entry["stateMachine"] = cfg.stateMachine; + entry["stateName"] = cfg.stateName; + json["animStates"].push_back(entry); + } + + json["walkSpeed"] = debug.walkSpeed; + json["runSpeed"] = debug.runSpeed; + json["useRootMotion"] = debug.useRootMotion; return json; } @@ -3215,9 +3239,63 @@ void SceneSerializer::deserializeActionDebug(flecs::entity entity, deserializeGoapBlackboard(debug.blackboard, json["blackboard"]); debug.selectedActionName = json.value("selectedActionName", ""); debug.selectedGoalName = json.value("selectedGoalName", ""); + + // Deserialize animation state configs (new format) + if (json.contains("animStates") && json["animStates"].is_array()) { + debug.animStates.clear(); + for (const auto &entry : json["animStates"]) { + AnimationStateConfig cfg; + cfg.name = entry.value("name", ""); + cfg.stateMachine = + entry.value("stateMachine", "Locomotion"); + cfg.stateName = entry.value("stateName", "Idle"); + debug.animStates.push_back(cfg); + } + } else { + // Backward compatibility: old format with individual fields + debug.animStates.clear(); + Ogre::String sm = + json.value("locomotionStateMachine", "Locomotion"); + debug.animStates.push_back( + { "idle", sm, json.value("idleStateName", "Idle") }); + debug.animStates.push_back( + { "walk", sm, json.value("walkStateName", "Walk") }); + debug.animStates.push_back( + { "run", sm, json.value("runStateName", "Run") }); + } + + debug.walkSpeed = json.value("walkSpeed", 2.5f); + debug.runSpeed = json.value("runSpeed", 5.0f); + debug.useRootMotion = json.value("useRootMotion", true); entity.set(debug); } +nlohmann::json SceneSerializer::serializeSmartObject(flecs::entity entity) +{ + const SmartObjectComponent &so = entity.get(); + nlohmann::json json; + json["radius"] = so.radius; + json["height"] = so.height; + json["actionNames"] = so.actionNames; + return json; +} + +void SceneSerializer::deserializeSmartObject(flecs::entity entity, + const nlohmann::json &json) +{ + SmartObjectComponent so; + so.radius = json.value("radius", 1.0f); + so.height = json.value("height", 1.8f); + if (json.contains("actionNames") && json["actionNames"].is_array()) { + so.actionNames.clear(); + for (const auto &name : json["actionNames"]) { + if (name.is_string()) + so.actionNames.push_back(name); + } + } + entity.set(so); +} + nlohmann::json SceneSerializer::serializeBehaviorTree(flecs::entity entity) { const BehaviorTreeComponent &bt = entity.get(); diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index d75dcc7..00460d2 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -171,11 +171,14 @@ private: // AI/GOAP serialization nlohmann::json serializeActionDatabase(flecs::entity entity); nlohmann::json serializeActionDebug(flecs::entity entity); + nlohmann::json serializeSmartObject(flecs::entity entity); nlohmann::json serializeBehaviorTree(flecs::entity entity); void deserializeActionDatabase(flecs::entity entity, const nlohmann::json &json); void deserializeActionDebug(flecs::entity entity, const nlohmann::json &json); + void deserializeSmartObject(flecs::entity entity, + const nlohmann::json &json); void deserializeBehaviorTree(flecs::entity entity, const nlohmann::json &json); diff --git a/src/features/editScene/systems/SmartObjectSystem.cpp b/src/features/editScene/systems/SmartObjectSystem.cpp new file mode 100644 index 0000000..3647f3f --- /dev/null +++ b/src/features/editScene/systems/SmartObjectSystem.cpp @@ -0,0 +1,725 @@ +#include "SmartObjectSystem.hpp" +#include "../components/SmartObject.hpp" +#include "../components/Transform.hpp" +#include "../components/Character.hpp" +#include "../components/CharacterSlots.hpp" +#include "../components/GoapBlackboard.hpp" +#include "../components/ActionDatabase.hpp" +#include "../components/ActionDebug.hpp" +#include "../components/NavMesh.hpp" +#include "../components/AnimationTree.hpp" +#include "NavMeshSystem.hpp" +#include "BehaviorTreeSystem.hpp" +#include "AnimationTreeSystem.hpp" +#include +#include +#include + +SmartObjectSystem *SmartObjectSystem::s_instance = nullptr; + +SmartObjectSystem::SmartObjectSystem(flecs::world &world, + Ogre::SceneManager *sceneMgr, + NavMeshSystem *navSystem, + BehaviorTreeSystem *btSystem) + : m_world(world) + , m_sceneMgr(sceneMgr) + , m_navSystem(navSystem) + , m_btSystem(btSystem) + , m_animTreeSystem(nullptr) +{ + s_instance = this; +} + +SmartObjectSystem::~SmartObjectSystem() = default; + +Ogre::Vector3 SmartObjectSystem::getEntityPosition(flecs::entity e) +{ + if (e.has()) { + auto &trans = e.get(); + if (trans.node) + return trans.node->_getDerivedPosition(); + return trans.position; + } + return Ogre::Vector3::ZERO; +} + +Ogre::Vector3 SmartObjectSystem::getFrontAxis(flecs::entity e) const +{ + if (e.has()) { + auto &slots = e.get(); + return slots.frontAxis; + } + return Ogre::Vector3::NEGATIVE_UNIT_Z; +} + +bool SmartObjectSystem::isInRange(const Ogre::Vector3 &charPos, + const Ogre::Vector3 &objPos, float radius, + float height) +{ + // Check XZ distance + Ogre::Vector3 diff = charPos - objPos; + float xzDist = std::sqrt(diff.x * diff.x + diff.z * diff.z); + if (xzDist > radius) + return false; + + // Check Y difference + float yDiff = std::abs(diff.y); + if (yDiff > height) + return false; + + return true; +} + +void SmartObjectSystem::rotateTowards(flecs::entity e, + const Ogre::Vector3 &direction, + float deltaTime) +{ + if (!e.has()) + return; + auto &trans = e.get_mut(); + if (!trans.node) + return; + + Ogre::Vector3 flatDir = direction; + flatDir.y = 0; + if (flatDir.squaredLength() < 0.0001f) + return; + flatDir.normalise(); + + // Get the character's front-facing axis (from CharacterSlots or default -Z) + Ogre::Vector3 frontAxis = getFrontAxis(e); + + // Use yaw rotation only (Y plane) + Ogre::Quaternion currentRot = trans.node->getOrientation(); + Ogre::Quaternion targetRot = frontAxis.getRotationTo(flatDir); + + // Slerp for smooth rotation + Ogre::Quaternion newRot = Ogre::Quaternion::Slerp( + deltaTime * 10.0f, currentRot, targetRot, true); + + // Extract only the Y-axis rotation (yaw) to keep character upright + Ogre::Radian yaw = newRot.getYaw(); + trans.node->setOrientation( + Ogre::Quaternion(yaw, Ogre::Vector3::UNIT_Y)); +} + +void SmartObjectSystem::setLocomotionState(flecs::entity e, + const Ogre::String &animName) +{ + if (!m_animTreeSystem) + return; + if (!e.has()) + return; + + Ogre::String smName = "Locomotion"; + Ogre::String stateName = animName; + + // Try to get the state machine/state from ActionDebug's animStates list + if (e.has()) { + auto &debug = e.get(); + Ogre::String foundSM, foundState; + if (debug.getAnimState(animName, foundSM, foundState)) { + smName = foundSM; + stateName = foundState; + } + } + + m_animTreeSystem->setState(e, smName, stateName, false); +} + +bool SmartObjectSystem::testSmartObjectAction(flecs::entity character, + flecs::entity smartObject, + const Ogre::String &actionName) +{ + if (!character.is_alive() || !smartObject.is_alive()) + return false; + + // Find the action in the database + ActionDatabase *db = nullptr; + m_world.query().each( + [&](flecs::entity, ActionDatabase &database) { + if (!db) + db = &database; + }); + if (!db) + return false; + + const GoapAction *action = db->findAction(actionName); + if (!action) + return false; + + // Set up the character state to navigate to the smart object + auto &state = m_states[character.id()]; + state.state = State::Pathfinding; + state.target.smartObject = smartObject; + state.target.actionName = actionName; + state.target.path.clear(); + state.target.pathIndex = 0; + state.target.pathRecalcTimer = 0.0f; + state.target.isExecuting = false; + state.target.executionTimer = 0.0f; + + std::cout << "[SmartObjectSystem] Character " << character.id() + << " navigating to smart object " << smartObject.id() + << " for action: " << actionName << std::endl; + + return true; +} + +void SmartObjectSystem::update(float deltaTime) +{ + // Find the navmesh entity + flecs::entity navmeshEntity = flecs::entity::null(); + m_world.query().each( + [&](flecs::entity e, NavMeshComponent &) { + if (!navmeshEntity.is_alive()) + navmeshEntity = e; + }); + + // Find the action database + ActionDatabase *db = nullptr; + m_world.query().each( + [&](flecs::entity, ActionDatabase &database) { + if (!db) + db = &database; + }); + + // Process each character with a blackboard (AI-driven characters) + m_world.query() + .each([&](flecs::entity e, CharacterComponent &cc, + TransformComponent &trans, GoapBlackboard &bb) { + (void)cc; + (void)trans; + (void)bb; + + auto &state = m_states[e.id()]; + state.scanTimer += deltaTime; + + // --- State: Idle - scan for smart objects --- + if (state.state == State::Idle) { + // Set idle animation + setLocomotionState(e, "idle"); + + // Only scan periodically + if (state.scanTimer < 1.0f) + return; + state.scanTimer = 0.0f; + + Ogre::Vector3 charPos = getEntityPosition(e); + + // Find the nearest smart object with a + // relevant action + flecs::entity bestObject = + flecs::entity::null(); + Ogre::String bestActionName; + float bestDist = + std::numeric_limits::max(); + + m_world.query() + .each([&](flecs::entity so, + SmartObjectComponent &soComp, + TransformComponent &soTrans) { + (void)soTrans; + + for (const auto &actionName : + soComp.actionNames) { + if (!db) + continue; + + const GoapAction *action = + db->findAction( + actionName); + if (!action) + continue; + + if (!action->canRun(bb)) + continue; + + Ogre::Vector3 objPos = + getEntityPosition( + so); + float dist = + charPos.distance( + objPos); + + if (dist < bestDist) { + bestDist = dist; + bestObject = so; + bestActionName = + actionName; + } + } + }); + + if (bestObject.is_alive()) { + state.state = State::Pathfinding; + state.target.smartObject = bestObject; + state.target.actionName = + bestActionName; + state.target.path.clear(); + state.target.pathIndex = 0; + state.target.pathRecalcTimer = 0.0f; + state.target.isExecuting = false; + state.target.executionTimer = 0.0f; + + std::cout + << "[SmartObjectSystem] Character " + << e.id() + << " found smart object " + << bestObject.id() + << " for action: " + << bestActionName << std::endl; + } + return; + } + + // --- State: Pathfinding - find path to smart object --- + if (state.state == State::Pathfinding) { + if (!state.target.smartObject.is_alive()) { + state.state = State::Idle; + return; + } + + Ogre::Vector3 charPos = getEntityPosition(e); + Ogre::Vector3 objPos = getEntityPosition( + state.target.smartObject); + + auto &soComp = + state.target.smartObject + .get(); + if (isInRange(charPos, objPos, soComp.radius, + soComp.height)) { + state.state = State::Executing; + state.target.isExecuting = true; + state.target.executionTimer = 0.0f; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + if (navmeshEntity.is_alive() && m_navSystem) { + state.target.path.clear(); + state.target.pathIndex = 0; + bool found = m_navSystem->findPath( + navmeshEntity, charPos, objPos, + state.target.path); + if (found && + !state.target.path.empty()) { + state.state = State::Moving; + std::cout + << "[SmartObjectSystem] Path found with " + << state.target.path + .size() + << " waypoints" + << std::endl; + } else { + state.state = State::Idle; + cc.linearVelocity = + Ogre::Vector3::ZERO; + } + } else { + state.target.path.clear(); + state.target.path.push_back(objPos); + state.target.pathIndex = 0; + state.state = State::Moving; + } + return; + } + + // --- State: Moving - follow path --- + if (state.state == State::Moving) { + if (!state.target.smartObject.is_alive() || + state.target.path.empty()) { + state.state = State::Idle; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + Ogre::Vector3 charPos = getEntityPosition(e); + Ogre::Vector3 objPos = getEntityPosition( + state.target.smartObject); + + auto &soComp = + state.target.smartObject + .get(); + if (isInRange(charPos, objPos, soComp.radius, + soComp.height)) { + state.state = State::Executing; + state.target.isExecuting = true; + state.target.executionTimer = 0.0f; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + state.target.pathRecalcTimer += deltaTime; + if (state.target.pathRecalcTimer > 2.0f) { + state.target.pathRecalcTimer = 0.0f; + if (navmeshEntity.is_alive() && + m_navSystem) { + std::vector + newPath; + bool found = + m_navSystem->findPath( + navmeshEntity, + charPos, objPos, + newPath); + if (found && !newPath.empty()) { + state.target.path = + newPath; + state.target.pathIndex = + 0; + } + } + } + + if (state.target.pathIndex >= + (int)state.target.path.size()) { + state.target.path.clear(); + state.target.path.push_back(objPos); + state.target.pathIndex = 0; + } + + Ogre::Vector3 targetPos = + state.target + .path[state.target.pathIndex]; + Ogre::Vector3 toTarget = targetPos - charPos; + + if (toTarget.length() < 0.5f) { + state.target.pathIndex++; + if (state.target.pathIndex >= + (int)state.target.path.size()) { + toTarget = objPos - charPos; + if (toTarget.length() < 0.5f) { + state.state = + State::Executing; + state.target + .isExecuting = + true; + state.target + .executionTimer = + 0.0f; + cc.linearVelocity = + Ogre::Vector3:: + ZERO; + return; + } + } else { + targetPos = + state.target.path + [state.target + .pathIndex]; + toTarget = targetPos - charPos; + } + } + + // Only set velocity direction - AnimationTreeSystem + // applies root motion if useRootMotion is enabled. + // The character is rotated to face the movement + // direction, no other transforms are applied. + toTarget.y = 0; + if (toTarget.squaredLength() > 0.0001f) { + toTarget.normalise(); + + // Determine speed based on distance + // to target + float distToTarget = + (charPos - objPos).length(); + float speed = 2.5f; // walk speed + + // Use run speed if far away + if (e.has()) { + auto &debug = + e.get(); + if (distToTarget > + debug.walkSpeed * 3.0f) { + speed = debug.runSpeed; + setLocomotionState( + e, "run"); + } else { + speed = debug.walkSpeed; + setLocomotionState( + e, "walk"); + } + } else { + setLocomotionState(e, "walk"); + } + + cc.linearVelocity = toTarget * speed; + + // Rotate character to face movement + // direction (Y plane only) + rotateTowards(e, toTarget, deltaTime); + } else { + cc.linearVelocity = Ogre::Vector3::ZERO; + } + return; + } + + // --- State: Executing - run the action --- + if (state.state == State::Executing) { + if (!state.target.smartObject.is_alive()) { + state.state = State::Idle; + return; + } + + // Keep character stopped + cc.linearVelocity = Ogre::Vector3::ZERO; + + // Set idle animation while executing + setLocomotionState(e, "idle"); + + if (state.target.isExecuting && db) { + const GoapAction *action = + db->findAction( + state.target.actionName); + if (action && m_btSystem) { + state.target.executionTimer += + deltaTime; + + if (state.target.executionTimer > + 3.0f) { + bb.apply( + action->effects); + state.state = + State::Idle; + state.target + .isExecuting = + false; + std::cout + << "[SmartObjectSystem] Action '" + << state.target + .actionName + << "' completed" + << std::endl; + } + } else { + if (action) { + bb.apply( + action->effects); + } + state.state = State::Idle; + state.target.isExecuting = + false; + } + } else { + state.state = State::Idle; + } + } + }); + + // Also process characters with ActionDebug (for editor testing) + m_world.query() + .each([&](flecs::entity e, CharacterComponent &cc, + TransformComponent &trans, ActionDebug &debug) { + (void)trans; + (void)debug; + + auto &state = m_states[e.id()]; + + if (state.state == State::Idle) { + setLocomotionState(e, "idle"); + return; + } + + GoapBlackboard &bb = debug.blackboard; + + // --- State: Pathfinding --- + if (state.state == State::Pathfinding) { + if (!state.target.smartObject.is_alive()) { + state.state = State::Idle; + return; + } + + Ogre::Vector3 charPos = getEntityPosition(e); + Ogre::Vector3 objPos = getEntityPosition( + state.target.smartObject); + + auto &soComp = + state.target.smartObject + .get(); + if (isInRange(charPos, objPos, soComp.radius, + soComp.height)) { + state.state = State::Executing; + state.target.isExecuting = true; + state.target.executionTimer = 0.0f; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + if (navmeshEntity.is_alive() && m_navSystem) { + state.target.path.clear(); + state.target.pathIndex = 0; + bool found = m_navSystem->findPath( + navmeshEntity, charPos, objPos, + state.target.path); + if (found && + !state.target.path.empty()) { + state.state = State::Moving; + } else { + state.state = State::Idle; + cc.linearVelocity = + Ogre::Vector3::ZERO; + } + } else { + state.target.path.clear(); + state.target.path.push_back(objPos); + state.target.pathIndex = 0; + state.state = State::Moving; + } + return; + } + + // --- State: Moving --- + if (state.state == State::Moving) { + if (!state.target.smartObject.is_alive() || + state.target.path.empty()) { + state.state = State::Idle; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + Ogre::Vector3 charPos = getEntityPosition(e); + Ogre::Vector3 objPos = getEntityPosition( + state.target.smartObject); + + auto &soComp = + state.target.smartObject + .get(); + if (isInRange(charPos, objPos, soComp.radius, + soComp.height)) { + state.state = State::Executing; + state.target.isExecuting = true; + state.target.executionTimer = 0.0f; + cc.linearVelocity = Ogre::Vector3::ZERO; + return; + } + + state.target.pathRecalcTimer += deltaTime; + if (state.target.pathRecalcTimer > 2.0f) { + state.target.pathRecalcTimer = 0.0f; + if (navmeshEntity.is_alive() && + m_navSystem) { + std::vector + newPath; + bool found = + m_navSystem->findPath( + navmeshEntity, + charPos, objPos, + newPath); + if (found && !newPath.empty()) { + state.target.path = + newPath; + state.target.pathIndex = + 0; + } + } + } + + if (state.target.pathIndex >= + (int)state.target.path.size()) { + state.target.path.clear(); + state.target.path.push_back(objPos); + state.target.pathIndex = 0; + } + + Ogre::Vector3 targetPos = + state.target + .path[state.target.pathIndex]; + Ogre::Vector3 toTarget = targetPos - charPos; + + if (toTarget.length() < 0.5f) { + state.target.pathIndex++; + if (state.target.pathIndex >= + (int)state.target.path.size()) { + toTarget = objPos - charPos; + if (toTarget.length() < 0.5f) { + state.state = + State::Executing; + state.target + .isExecuting = + true; + state.target + .executionTimer = + 0.0f; + cc.linearVelocity = + Ogre::Vector3:: + ZERO; + return; + } + } else { + targetPos = + state.target.path + [state.target + .pathIndex]; + toTarget = targetPos - charPos; + } + } + + toTarget.y = 0; + if (toTarget.squaredLength() > 0.0001f) { + toTarget.normalise(); + + float distToTarget = + (charPos - objPos).length(); + float speed = debug.walkSpeed; + + if (distToTarget > + debug.walkSpeed * 3.0f) { + speed = debug.runSpeed; + setLocomotionState(e, "run"); + } else { + speed = debug.walkSpeed; + setLocomotionState(e, "walk"); + } + + cc.linearVelocity = toTarget * speed; + + // Rotate character to face movement + // direction (Y plane only) + rotateTowards(e, toTarget, deltaTime); + } else { + cc.linearVelocity = Ogre::Vector3::ZERO; + } + return; + } + + // --- State: Executing --- + if (state.state == State::Executing) { + cc.linearVelocity = Ogre::Vector3::ZERO; + + // Set idle animation while executing + setLocomotionState(e, "idle"); + + if (state.target.isExecuting && db) { + const GoapAction *action = + db->findAction( + state.target.actionName); + if (action) { + state.target.executionTimer += + deltaTime; + if (state.target.executionTimer > + 3.0f) { + bb.apply( + action->effects); + state.state = + State::Idle; + state.target + .isExecuting = + false; + debug.lastResult = + "Smart object action '" + + state.target + .actionName + + "' completed"; + } + } else { + state.state = State::Idle; + state.target.isExecuting = + false; + } + } else { + state.state = State::Idle; + } + } + }); +} diff --git a/src/features/editScene/systems/SmartObjectSystem.hpp b/src/features/editScene/systems/SmartObjectSystem.hpp new file mode 100644 index 0000000..c04f514 --- /dev/null +++ b/src/features/editScene/systems/SmartObjectSystem.hpp @@ -0,0 +1,109 @@ +#ifndef EDITSCENE_SMART_OBJECT_SYSTEM_HPP +#define EDITSCENE_SMART_OBJECT_SYSTEM_HPP +#pragma once + +#include +#include +#include +#include + +class NavMeshSystem; +class BehaviorTreeSystem; +class AnimationTreeSystem; + +/** + * System that manages Smart Object interactions. + * + * For each entity with CharacterComponent and a blackboard: + * 1. Scans for nearby SmartObjectComponent entities + * 2. If a smart object action is relevant to the character's GOAP state, + * pathfinds to the smart object + * 3. Follows the path until within radius (XZ) and height (Y) threshold + * 4. Executes the smart object's action behavior tree + */ +class SmartObjectSystem { +public: + SmartObjectSystem(flecs::world &world, Ogre::SceneManager *sceneMgr, + NavMeshSystem *navSystem, + BehaviorTreeSystem *btSystem); + ~SmartObjectSystem(); + + static SmartObjectSystem *getInstance() + { + return s_instance; + } + + /** + * Set the AnimationTreeSystem for animation state machine control. + * Must be called after construction. + */ + void setAnimationTreeSystem(AnimationTreeSystem *system) + { + m_animTreeSystem = system; + } + + void update(float deltaTime); + + /** + * Test-run a smart object action on a character. + * Used by ActionDebugEditor. + */ + bool testSmartObjectAction(flecs::entity character, + flecs::entity smartObject, + const Ogre::String &actionName); + +private: + struct SmartObjectTarget { + flecs::entity smartObject; + Ogre::String actionName; + std::vector path; + int pathIndex = 0; + float pathRecalcTimer = 0.0f; + bool isExecuting = false; + float executionTimer = 0.0f; + }; + + enum class State { Idle, Pathfinding, Moving, Executing }; + + struct CharacterState { + State state = State::Idle; + SmartObjectTarget target; + float scanTimer = 0.0f; + }; + + bool isInRange(const Ogre::Vector3 &charPos, + const Ogre::Vector3 &objPos, float radius, float height); + + Ogre::Vector3 getEntityPosition(flecs::entity e); + + /** + * Rotate character to face a target direction (Y plane only). + * Uses the scene node's yaw rotation. + */ + void rotateTowards(flecs::entity e, const Ogre::Vector3 &direction, + float deltaTime); + + /** + * Set the locomotion animation state (idle/walk/run) via the + * AnimationTreeSystem. + */ + void setLocomotionState(flecs::entity e, const Ogre::String &stateName); + + /** + * Get the front-facing axis for a character entity. + * Checks CharacterSlotsComponent first, defaults to -Z. + */ + Ogre::Vector3 getFrontAxis(flecs::entity e) const; + + flecs::world &m_world; + Ogre::SceneManager *m_sceneMgr; + NavMeshSystem *m_navSystem; + BehaviorTreeSystem *m_btSystem; + AnimationTreeSystem *m_animTreeSystem; + + std::unordered_map m_states; + + static SmartObjectSystem *s_instance; +}; + +#endif // EDITSCENE_SMART_OBJECT_SYSTEM_HPP diff --git a/src/features/editScene/ui/ActionDebugEditor.cpp b/src/features/editScene/ui/ActionDebugEditor.cpp index c64848e..af89e60 100644 --- a/src/features/editScene/ui/ActionDebugEditor.cpp +++ b/src/features/editScene/ui/ActionDebugEditor.cpp @@ -1,5 +1,8 @@ #include "ActionDebugEditor.hpp" #include "GoapBlackboardEditor.hpp" +#include "../components/Transform.hpp" +#include "../components/EntityName.hpp" +#include "../systems/SmartObjectSystem.hpp" #include ActionDatabase *ActionDebugEditor::findDatabase(flecs::entity entity) @@ -31,6 +34,14 @@ bool ActionDebugEditor::renderComponent(flecs::entity entity, if (ImGui::CollapsingHeader("Goal Tester")) renderGoalTester(entity, debug); + if (ImGui::CollapsingHeader("Animation Config", + ImGuiTreeNodeFlags_DefaultOpen)) + renderAnimationConfig(debug); + + if (ImGui::CollapsingHeader("Smart Object Tester", + ImGuiTreeNodeFlags_DefaultOpen)) + renderSmartObjectTester(entity, debug); + if (!debug.lastResult.empty()) { ImGui::Separator(); ImGui::Text("Last result: %s", debug.lastResult.c_str()); @@ -50,8 +61,7 @@ void ActionDebugEditor::renderActionTester(flecs::entity entity, { ActionDatabase *db = findDatabase(entity); if (!db) { - ImGui::TextDisabled( - "No ActionDatabase found in scene."); + ImGui::TextDisabled("No ActionDatabase found in scene."); return; } @@ -72,11 +82,10 @@ void ActionDebugEditor::renderActionTester(flecs::entity entity, debug.currentActionName = action.name; // Apply effects to local blackboard for testing debug.blackboard.apply(action.effects); - debug.lastResult = "Ran " + action.name + - " (cost: " + - Ogre::StringConverter::toString( - action.cost) + - ")"; + debug.lastResult = + "Ran " + action.name + " (cost: " + + Ogre::StringConverter::toString(action.cost) + + ")"; } if (!canRun) @@ -84,8 +93,7 @@ void ActionDebugEditor::renderActionTester(flecs::entity entity, ImGui::SameLine(); ImGui::Text("%s (cost: %d) %s", action.name.c_str(), - action.cost, - canRun ? "" : "[blocked]"); + action.cost, canRun ? "" : "[blocked]"); ImGui::PopID(); } @@ -94,8 +102,7 @@ void ActionDebugEditor::renderActionTester(flecs::entity entity, if (debug.isRunning) { ImGui::Text("Running: %s (%.1fs)", - debug.currentActionName.c_str(), - debug.runTimer); + debug.currentActionName.c_str(), debug.runTimer); ImGui::SameLine(); if (ImGui::SmallButton("Stop")) { debug.isRunning = false; @@ -109,8 +116,7 @@ void ActionDebugEditor::renderGoalTester(flecs::entity entity, { ActionDatabase *db = findDatabase(entity); if (!db) { - ImGui::TextDisabled( - "No ActionDatabase found in scene."); + ImGui::TextDisabled("No ActionDatabase found in scene."); return; } @@ -132,13 +138,11 @@ void ActionDebugEditor::renderGoalTester(flecs::entity entity, ImGui::Text("%s (prio: %d) %s", goal.name.c_str(), goal.priority, satisfied ? "[satisfied]" : - (valid ? "[valid]" : - "[invalid]")); + (valid ? "[valid]" : "[invalid]")); if (!goal.condition.empty()) { ImGui::SameLine(); - ImGui::TextDisabled("cond: %s", - goal.condition.c_str()); + ImGui::TextDisabled("cond: %s", goal.condition.c_str()); } ImGui::PopID(); @@ -146,3 +150,149 @@ void ActionDebugEditor::renderGoalTester(flecs::entity entity, ImGui::Unindent(); } + +void ActionDebugEditor::renderAnimationConfig(ActionDebug &debug) +{ + ImGui::Text("Animation State Configs:"); + ImGui::TextDisabled( + "Map logical names (idle/walk/run) to state machine/state pairs"); + ImGui::Separator(); + + // Render each animation state config + int removeIdx = -1; + for (int i = 0; i < (int)debug.animStates.size(); i++) { + auto &cfg = debug.animStates[i]; + ImGui::PushID(i); + + char buf[256]; + + ImGui::Text("Entry %d:", i); + ImGui::Indent(); + + // Logical name (e.g. "idle", "walk", "run") + strncpy(buf, cfg.name.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("Name", buf, sizeof(buf))) { + cfg.name = buf; + } + + // State machine name + strncpy(buf, cfg.stateMachine.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("State Machine", buf, sizeof(buf))) { + cfg.stateMachine = buf; + } + + // State name within the state machine + strncpy(buf, cfg.stateName.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("State Name", buf, sizeof(buf))) { + cfg.stateName = buf; + } + + // Remove button (keep at least 1 entry) + if (debug.animStates.size() > 1) { + if (ImGui::SmallButton("Remove")) { + removeIdx = i; + } + } + + ImGui::Unindent(); + ImGui::Separator(); + ImGui::PopID(); + } + + if (removeIdx >= 0) { + debug.animStates.erase(debug.animStates.begin() + removeIdx); + } + + // Add new entry button + if (ImGui::Button("Add Animation State")) { + debug.animStates.push_back(AnimationStateConfig()); + } + + ImGui::Separator(); + ImGui::Text("Movement Speeds:"); + ImGui::Indent(); + + ImGui::DragFloat("Walk Speed (m/s)", &debug.walkSpeed, 0.1f, 0.5f, + 20.0f); + ImGui::DragFloat("Run Speed (m/s)", &debug.runSpeed, 0.1f, 0.5f, 20.0f); + + ImGui::Unindent(); + + ImGui::Separator(); + ImGui::Checkbox("Use Root Motion", &debug.useRootMotion); +} + +void ActionDebugEditor::renderSmartObjectTester(flecs::entity entity, + ActionDebug &debug) +{ + auto world = entity.world(); + + // Find SmartObjectSystem instance + SmartObjectSystem *soSystem = SmartObjectSystem::getInstance(); + if (!soSystem) { + ImGui::TextDisabled("SmartObjectSystem not available."); + return; + } + + ImGui::Text("Smart Objects in Scene:"); + ImGui::Indent(); + + // Query all entities with SmartObjectComponent + bool foundAny = false; + world.query().each( + [&](flecs::entity so, SmartObjectComponent &soComp, + TransformComponent &soTrans) { + (void)soTrans; + foundAny = true; + + // Get entity name + Ogre::String name = + "Entity " + + Ogre::StringConverter::toString(so.id()); + if (so.has()) { + name = so.get().name; + } + + ImGui::PushID(so.id()); + ImGui::Text("%s", name.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled("(r=%.1f, h=%.1f, %zu actions)", + soComp.radius, soComp.height, + soComp.actionNames.size()); + + // Show available actions for this smart object + ImGui::Indent(); + for (const auto &actionName : soComp.actionNames) { + ImGui::PushID(actionName.c_str()); + ImGui::Text("%s", actionName.c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("Test on Character")) { + bool ok = + soSystem->testSmartObjectAction( + entity, so, actionName); + if (ok) { + debug.lastResult = + "Testing smart object action '" + + actionName + "' on " + + name; + } else { + debug.lastResult = + "Failed to start smart object action '" + + actionName + "'"; + } + } + ImGui::PopID(); + } + ImGui::Unindent(); + ImGui::PopID(); + }); + + if (!foundAny) { + ImGui::TextDisabled("No smart objects in scene."); + } + + ImGui::Unindent(); +} diff --git a/src/features/editScene/ui/ActionDebugEditor.hpp b/src/features/editScene/ui/ActionDebugEditor.hpp index b45df48..807bfc7 100644 --- a/src/features/editScene/ui/ActionDebugEditor.hpp +++ b/src/features/editScene/ui/ActionDebugEditor.hpp @@ -5,24 +5,31 @@ #include "ComponentEditor.hpp" #include "../components/ActionDebug.hpp" #include "../components/ActionDatabase.hpp" +#include "../components/SmartObject.hpp" /** * Editor for ActionDebug component. * * Allows inspecting and test-running GOAP actions on a character. + * Also displays a list of smart object entities and allows testing + * their actions on the character. */ class ActionDebugEditor : public ComponentEditor { public: - const char *getName() const override { return "Action Debug"; } + const char *getName() const override + { + return "Action Debug"; + } protected: - bool renderComponent(flecs::entity entity, - ActionDebug &debug) override; + bool renderComponent(flecs::entity entity, ActionDebug &debug) override; private: bool renderBlackboard(ActionDebug &debug); void renderActionTester(flecs::entity entity, ActionDebug &debug); void renderGoalTester(flecs::entity entity, ActionDebug &debug); + void renderAnimationConfig(ActionDebug &debug); + void renderSmartObjectTester(flecs::entity entity, ActionDebug &debug); ActionDatabase *findDatabase(flecs::entity entity); }; diff --git a/src/features/editScene/ui/SmartObjectEditor.cpp b/src/features/editScene/ui/SmartObjectEditor.cpp new file mode 100644 index 0000000..e71b067 --- /dev/null +++ b/src/features/editScene/ui/SmartObjectEditor.cpp @@ -0,0 +1,115 @@ +#include "SmartObjectEditor.hpp" +#include + +ActionDatabase *SmartObjectEditor::findDatabase(flecs::entity entity) +{ + auto world = entity.world(); + ActionDatabase *db = nullptr; + world.query().each( + [&](flecs::entity, ActionDatabase &database) { + if (!db) + db = &database; + }); + return db; +} + +bool SmartObjectEditor::renderComponent(flecs::entity entity, + SmartObjectComponent &so) +{ + bool modified = false; + ImGui::PushID("SmartObject"); + + ImGui::Text("Smart Object Settings"); + ImGui::Separator(); + + if (ImGui::DragFloat("Radius", &so.radius, 0.1f, 0.1f, 100.0f, + "%.1f")) { + if (so.radius < 0.1f) + so.radius = 0.1f; + modified = true; + } + ImGui::SameLine(); + ImGui::TextDisabled("(XZ interaction distance)"); + + if (ImGui::DragFloat("Height", &so.height, 0.1f, 0.1f, 100.0f, + "%.1f")) { + if (so.height < 0.1f) + so.height = 0.1f; + modified = true; + } + ImGui::SameLine(); + ImGui::TextDisabled("(Y interaction threshold)"); + + ImGui::Separator(); + ImGui::Text("Actions:"); + ImGui::Indent(); + + // List currently selected actions + for (size_t i = 0; i < so.actionNames.size(); i++) { + ImGui::PushID(static_cast(i)); + ImGui::Text("%s", so.actionNames[i].c_str()); + ImGui::SameLine(); + if (ImGui::SmallButton("X")) { + so.actionNames.erase(so.actionNames.begin() + i); + modified = true; + ImGui::PopID(); + break; + } + ImGui::PopID(); + } + + // Add action from database + ActionDatabase *db = findDatabase(entity); + if (db && !db->actions.empty()) { + ImGui::Separator(); + ImGui::Text("Add action:"); + static int selectedAction = -1; + + // Build list of action names not already selected + std::vector availableNames; + std::vector availableNamesStorage; + for (const auto &action : db->actions) { + bool alreadySelected = false; + for (const auto &selected : so.actionNames) { + if (selected == action.name) { + alreadySelected = true; + break; + } + } + if (!alreadySelected) { + availableNamesStorage.push_back(action.name); + availableNames.push_back( + availableNamesStorage.back().c_str()); + } + } + + if (!availableNames.empty()) { + if (selectedAction >= (int)availableNames.size()) + selectedAction = 0; + if (ImGui::Combo("##actionSelect", &selectedAction, + availableNames.data(), + (int)availableNames.size())) { + // Selection changed + } + ImGui::SameLine(); + if (ImGui::Button("Add")) { + if (selectedAction >= 0 && + selectedAction < + (int)availableNames.size()) { + so.actionNames.push_back( + availableNamesStorage + [selectedAction]); + modified = true; + } + } + } else { + ImGui::TextDisabled("All actions already selected"); + } + } else { + ImGui::TextDisabled("No actions in database"); + } + + ImGui::Unindent(); + ImGui::PopID(); + return modified; +} diff --git a/src/features/editScene/ui/SmartObjectEditor.hpp b/src/features/editScene/ui/SmartObjectEditor.hpp new file mode 100644 index 0000000..90c1a09 --- /dev/null +++ b/src/features/editScene/ui/SmartObjectEditor.hpp @@ -0,0 +1,30 @@ +#ifndef EDITSCENE_SMART_OBJECT_EDITOR_HPP +#define EDITSCENE_SMART_OBJECT_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/SmartObject.hpp" +#include "../components/ActionDatabase.hpp" + +/** + * Editor for SmartObjectComponent. + * + * Allows editing radius, height, and selecting GOAP actions + * from the ActionDatabase. + */ +class SmartObjectEditor : public ComponentEditor { +public: + const char *getName() const override + { + return "Smart Object"; + } + +protected: + bool renderComponent(flecs::entity entity, + SmartObjectComponent &so) override; + +private: + ActionDatabase *findDatabase(flecs::entity entity); +}; + +#endif // EDITSCENE_SMART_OBJECT_EDITOR_HPP