Path following

This commit is contained in:
2026-04-25 00:11:21 +03:00
parent 2b3482da88
commit 5ed7552164
16 changed files with 1411 additions and 20 deletions

View File

@@ -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

View File

@@ -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 <OgreRTShaderSystem.h>
#include <imgui.h>
@@ -312,7 +316,16 @@ void EditorApp::setup()
m_navMeshSystem =
std::make_unique<NavMeshSystem>(m_world, m_sceneMgr);
// Setup SmartObject system (requires NavMesh, BehaviorTree, and AnimationTree)
m_smartObjectSystem = std::make_unique<SmartObjectSystem>(
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<CharacterSystem>(m_world, m_sceneMgr);
m_characterSystem->initialize();
@@ -550,6 +563,9 @@ void EditorApp::setupECS()
m_world.component<BehaviorTreeComponent>();
m_world.component<GoapBlackboard>();
// Register Smart Object component
m_world.component<SmartObjectComponent>();
// Register Navigation components
m_world.component<NavMeshComponent>();
m_world.component<NavMeshGeometrySource>();
@@ -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);
}

View File

@@ -35,6 +35,7 @@ class EditorSunSystem;
class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class SmartObjectSystem;
class EditorApp;
/**
@@ -220,8 +221,10 @@ private:
std::unique_ptr<CellGridSystem> m_cellGridSystem;
std::unique_ptr<NormalDebugSystem> m_normalDebugSystem;
std::unique_ptr<RoomLayoutSystem> m_roomLayoutSystem;
std::unique_ptr<SmartObjectSystem> m_smartObjectSystem;
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;

View File

@@ -4,6 +4,34 @@
#include "GoapBlackboard.hpp"
#include <Ogre.h>
#include <vector>
/**
* 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<AnimationStateConfig> 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

View File

@@ -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;
};

View File

@@ -0,0 +1,38 @@
#ifndef EDITSCENE_SMART_OBJECT_HPP
#define EDITSCENE_SMART_OBJECT_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <string>
/**
* 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<Ogre::String> actionNames;
SmartObjectComponent() = default;
explicit SmartObjectComponent(float radius_, float height_)
: radius(radius_)
, height(height_)
{
}
};
#endif // EDITSCENE_SMART_OBJECT_HPP

View File

@@ -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<SmartObjectComponent>(
"Smart Object", "AI", std::make_unique<SmartObjectEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<SmartObjectComponent>())
e.set<SmartObjectComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<SmartObjectComponent>())
e.remove<SmartObjectComponent>();
});
}

View File

@@ -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<NavMeshGeometrySource>())
indicators += " [NavSrc]";
if (entity.has<SmartObjectComponent>())
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<SmartObjectComponent>()) {
auto &so = entity.get_mut<SmartObjectComponent>();
m_componentRegistry.render<SmartObjectComponent>(entity, so);
componentCount++;
}
// Show message if no components
if (componentCount == 0) {
ImGui::TextDisabled("No components");
ImGui::Text("Click 'Add Component' to add components");

View File

@@ -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<ActionDebug>()) {
json["actionDebug"] = serializeActionDebug(entity);
}
if (entity.has<SmartObjectComponent>()) {
json["smartObject"] = serializeSmartObject(entity);
}
if (entity.has<BehaviorTreeComponent>()) {
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<ActionDebug>(debug);
}
nlohmann::json SceneSerializer::serializeSmartObject(flecs::entity entity)
{
const SmartObjectComponent &so = entity.get<SmartObjectComponent>();
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<SmartObjectComponent>(so);
}
nlohmann::json SceneSerializer::serializeBehaviorTree(flecs::entity entity)
{
const BehaviorTreeComponent &bt = entity.get<BehaviorTreeComponent>();

View File

@@ -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);

View File

@@ -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 <OgreLogManager.h>
#include <iostream>
#include <cmath>
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<TransformComponent>()) {
auto &trans = e.get<TransformComponent>();
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<CharacterSlotsComponent>()) {
auto &slots = e.get<CharacterSlotsComponent>();
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<TransformComponent>())
return;
auto &trans = e.get_mut<TransformComponent>();
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<AnimationTreeComponent>())
return;
Ogre::String smName = "Locomotion";
Ogre::String stateName = animName;
// Try to get the state machine/state from ActionDebug's animStates list
if (e.has<ActionDebug>()) {
auto &debug = e.get<ActionDebug>();
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<ActionDatabase>().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<NavMeshComponent>().each(
[&](flecs::entity e, NavMeshComponent &) {
if (!navmeshEntity.is_alive())
navmeshEntity = e;
});
// Find the action database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Process each character with a blackboard (AI-driven characters)
m_world.query<CharacterComponent, TransformComponent, GoapBlackboard>()
.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<float>::max();
m_world.query<SmartObjectComponent,
TransformComponent>()
.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<SmartObjectComponent>();
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<SmartObjectComponent>();
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<Ogre::Vector3>
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<ActionDebug>()) {
auto &debug =
e.get<ActionDebug>();
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<CharacterComponent, TransformComponent, ActionDebug>()
.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<SmartObjectComponent>();
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<SmartObjectComponent>();
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<Ogre::Vector3>
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;
}
}
});
}

View File

@@ -0,0 +1,109 @@
#ifndef EDITSCENE_SMART_OBJECT_SYSTEM_HPP
#define EDITSCENE_SMART_OBJECT_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_map>
#include <vector>
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<Ogre::Vector3> 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<flecs::entity_t, CharacterState> m_states;
static SmartObjectSystem *s_instance;
};
#endif // EDITSCENE_SMART_OBJECT_SYSTEM_HPP

View File

@@ -1,5 +1,8 @@
#include "ActionDebugEditor.hpp"
#include "GoapBlackboardEditor.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../systems/SmartObjectSystem.hpp"
#include <imgui.h>
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<SmartObjectComponent, TransformComponent>().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<EntityNameComponent>()) {
name = so.get<EntityNameComponent>().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();
}

View File

@@ -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<ActionDebug> {
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);
};

View File

@@ -0,0 +1,115 @@
#include "SmartObjectEditor.hpp"
#include <imgui.h>
ActionDatabase *SmartObjectEditor::findDatabase(flecs::entity entity)
{
auto world = entity.world();
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().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<int>(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<const char *> availableNames;
std::vector<Ogre::String> 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;
}

View File

@@ -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<SmartObjectComponent> {
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