Compare commits

...

2 Commits

Author SHA1 Message Date
a1b74aa2d5 Now Path Following component works 2026-04-27 05:37:41 +03:00
c80d9c96e6 AI motion refactoring 2026-04-27 05:24:45 +03:00
38 changed files with 2804 additions and 433 deletions

View File

@@ -46,6 +46,15 @@ set(EDITSCENE_SOURCES
systems/SmartObjectSystem.cpp
components/SmartObjectModule.cpp
ui/SmartObjectEditor.cpp
components/GoapPlannerModule.cpp
systems/GoapRunnerSystem.cpp
systems/PathFollowingSystem.cpp
systems/GoapPlannerSystem.cpp
components/GoapRunnerModule.cpp
components/PathFollowingModule.cpp
ui/GoapRunnerEditor.cpp
ui/PathFollowingEditor.cpp
ui/GoapPlannerEditor.cpp
systems/PrefabSystem.cpp
ui/PrefabInstanceEditor.cpp
@@ -173,7 +182,16 @@ set(EDITSCENE_HEADERS
systems/SmartObjectSystem.hpp
components/SmartObject.hpp
ui/SmartObjectEditor.hpp
components/GoapPlanner.hpp
components/PathFollowing.hpp
components/GoapRunner.hpp
ui/GoapPlannerEditor.hpp
ui/GoapRunnerEditor.hpp
ui/PathFollowingEditor.hpp
systems/PrefabSystem.hpp
systems/GoapRunnerSystem.hpp
systems/PathFollowingSystem.hpp
systems/GoapPlannerSystem.hpp
components/PrefabInstance.hpp
ui/PrefabInstanceEditor.hpp

View File

@@ -19,6 +19,9 @@
#include "systems/NavMeshSystem.hpp"
#include "systems/CharacterSystem.hpp"
#include "systems/SmartObjectSystem.hpp"
#include "systems/GoapRunnerSystem.hpp"
#include "systems/PathFollowingSystem.hpp"
#include "systems/GoapPlannerSystem.hpp"
#include "systems/CellGridSystem.hpp"
#include "systems/NormalDebugSystem.hpp"
@@ -66,6 +69,9 @@
#include "systems/PrefabSystem.hpp"
#include "components/NavMesh.hpp"
#include "components/SmartObject.hpp"
#include "components/GoapPlanner.hpp"
#include "components/GoapRunner.hpp"
#include "components/PathFollowing.hpp"
#include <OgreRTShaderSystem.h>
#include <imgui.h>
@@ -237,6 +243,7 @@ void EditorApp::setup()
if (m_uiSystem)
m_uiSystem->setEditorUIEnabled(m_gameMode ==
GameMode::Editor);
m_uiSystem->setEditorCamera(m_camera.get());
// Setup physics system
m_physicsSystem = std::make_unique<EditorPhysicsSystem>(
@@ -335,6 +342,25 @@ void EditorApp::setup()
// Wire up EditorApp for game mode detection
m_smartObjectSystem->setEditorApp(this);
// Setup GOAP Runner system
m_goapRunnerSystem = std::make_unique<GoapRunnerSystem>(
m_world, m_sceneMgr, m_smartObjectSystem.get(),
m_behaviorTreeSystem.get(), m_navMeshSystem.get());
m_goapRunnerSystem->setEditorApp(this);
m_goapRunnerSystem->setAnimationTreeSystem(
m_animationTreeSystem.get());
// Setup GOAP Planner system
m_goapPlannerSystem = std::make_unique<GoapPlannerSystem>(
m_world);
m_goapPlannerSystem->setEditorApp(this);
// Setup Path Following system
m_pathFollowingSystem = std::make_unique<PathFollowingSystem>(
m_world, m_sceneMgr, m_navMeshSystem.get());
m_pathFollowingSystem->setAnimationTreeSystem(
m_animationTreeSystem.get());
// Setup CellGrid system
m_cellGridSystem =
std::make_unique<CellGridSystem>(m_world, m_sceneMgr);
@@ -575,6 +601,15 @@ void EditorApp::setupECS()
// Register Smart Object component
m_world.component<SmartObjectComponent>();
// Register GOAP Planner component
m_world.component<GoapPlannerComponent>();
// Register GOAP Runner component
m_world.component<GoapRunnerComponent>();
// Register Path Following component
m_world.component<PathFollowingComponent>();
// Register Navigation components
m_world.component<NavMeshComponent>();
m_world.component<NavMeshGeometrySource>();
@@ -733,6 +768,9 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
if (m_behaviorTreeSystem)
m_behaviorTreeSystem->update(evt.timeSinceLastFrame);
}
if (m_pathFollowingSystem) {
m_pathFollowingSystem->update(evt.timeSinceLastFrame);
}
if (m_proceduralMeshSystem) {
m_proceduralMeshSystem->update();
}
@@ -760,6 +798,16 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
m_smartObjectSystem->update(evt.timeSinceLastFrame);
}
/* --- GOAP Planner system (plan generation) --- */
if (m_goapPlannerSystem) {
m_goapPlannerSystem->update(evt.timeSinceLastFrame);
}
/* --- GOAP Runner system (plan execution) --- */
if (m_goapRunnerSystem) {
m_goapRunnerSystem->update(evt.timeSinceLastFrame);
}
/* --- Dynamic physics (characters after static world) --- */
if (m_characterSystem) {

View File

@@ -36,6 +36,9 @@ class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
class GoapPlannerSystem;
class EditorApp;
/**
@@ -222,6 +225,9 @@ private:
std::unique_ptr<NormalDebugSystem> m_normalDebugSystem;
std::unique_ptr<RoomLayoutSystem> m_roomLayoutSystem;
std::unique_ptr<SmartObjectSystem> m_smartObjectSystem;
std::unique_ptr<GoapRunnerSystem> m_goapRunnerSystem;
std::unique_ptr<PathFollowingSystem> m_pathFollowingSystem;
std::unique_ptr<GoapPlannerSystem> m_goapPlannerSystem;
// Game systems

View File

@@ -7,37 +7,13 @@
#include <vector>
#include <unordered_map>
/**
* Configuration for one path following animation state.
*
* Each state (e.g. "idle", "walk", "run", "swim-idle", "swim", "swim-fast")
* consists of unlimited name-value pairs where:
* - name = state machine name (e.g. "main", "locomotion")
* - value = state name within that machine (e.g. "Idle", "Walking")
*
* When a path following state is activated, ALL its name-value pairs
* are applied via AnimationTreeSystem::setState().
*/
struct PathFollowingState {
/** Logical name (e.g. "idle", "walk", "run", "swim-idle", "swim", "swim-fast") */
Ogre::String name;
/** State machine name -> state name pairs */
std::vector<std::pair<Ogre::String, Ogre::String> > stateMachineStates;
PathFollowingState() = default;
PathFollowingState(const Ogre::String &name_)
: name(name_)
{
}
};
/**
* Per-character action debug component.
*
* Allows test-running individual actions and inspecting the character's
* local blackboard state. Used for debugging AI behavior in the editor.
*
* Path following animation states have been moved to PathFollowingComponent.
*/
struct ActionDebug {
// Character's local GOAP blackboard
@@ -57,39 +33,7 @@ struct ActionDebug {
// Debug output
Ogre::String lastResult;
// --- Path following animation states ---
// Each state has unlimited state machine name -> state name pairs.
// Default entries: idle, walk, run
std::vector<PathFollowingState> pathFollowingStates = {
{ "idle" },
{ "walk" },
{ "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/state pairs for a given path following state name.
* Returns nullptr if not found.
*/
const PathFollowingState *
findPathState(const Ogre::String &stateName) const
{
for (const auto &state : pathFollowingStates) {
if (state.name == stateName)
return &state;
}
return nullptr;
}
};
#endif // EDITSCENE_ACTION_DEBUG_HPP

View File

@@ -21,6 +21,10 @@ struct GoapAction {
GoapBlackboard preconditions;
GoapBlackboard effects;
// Bitmask for precondition checking. Only bits set here are compared.
// Defaults to all 1s (check all bits).
uint64_t preconditionMask = ~0ULL;
// Behavior tree to execute when this action is selected
BehaviorTreeNode behaviorTree;
@@ -35,10 +39,35 @@ struct GoapAction {
{
}
// Check if the given blackboard satisfies this action's preconditions
// Check if the given blackboard satisfies this action's preconditions.
// Only bits in preconditionMask are compared.
bool canRun(const GoapBlackboard &blackboard) const
{
return blackboard.satisfies(preconditions);
// Fast-path: if mask covers all set bits, use standard check
if (preconditionMask == ~0ULL)
return blackboard.satisfies(preconditions);
// Masked check: only compare bits in preconditionMask
uint64_t relevantBits = preconditionMask;
uint64_t bbBits = blackboard.bits & relevantBits;
uint64_t preBits = preconditions.bits & relevantBits;
uint64_t bbMask = blackboard.mask & relevantBits;
uint64_t preMask = preconditions.mask & relevantBits;
// All precondition bits must be present in blackboard
if ((bbMask & preMask) != preMask)
return false;
if ((bbBits & preMask) != preBits)
return false;
// Check integer values
for (const auto &kv : preconditions.values) {
if (!blackboard.hasValue(kv.first))
return false;
if (blackboard.getValue(kv.first) != kv.second)
return false;
}
return true;
}
};

View File

@@ -42,12 +42,17 @@ int GoapBlackboard::findBitByName(const std::string &name)
bool GoapBlackboard::satisfies(const GoapBlackboard &other) const
{
// Check bits: for every bit set in other's mask, our bit must match
uint64_t commonMask = mask & other.mask;
// Only compare bits that both sides consider relevant
uint64_t relevantMask = bitmask & other.bitmask;
// Check bits: for every bit set in other's mask (within relevant mask),
// our bit must match
uint64_t commonMask = mask & other.mask & relevantMask;
if ((bits & commonMask) != (other.bits & commonMask))
return false;
// Also check bits that other has set but we don't
uint64_t missingMask = other.mask & ~mask;
// Also check bits that other has set but we don't (within relevant mask)
uint64_t missingMask = other.mask & ~mask & relevantMask;
if (missingMask) {
// Other requires bits we don't have set -> fail
// But only if other has those bits set to 1
@@ -97,20 +102,23 @@ bool GoapBlackboard::getScalarValue(const std::string &key,
}
int GoapBlackboard::distanceTo(const GoapBlackboard &target,
bool ignoreValues) const
bool ignoreValues) const
{
int distance = 0;
// Bit differences
uint64_t commonMask = mask & target.mask;
// Only compare bits that both sides consider relevant
uint64_t relevantMask = bitmask & target.bitmask;
// Bit differences (within relevant mask)
uint64_t commonMask = mask & target.mask & relevantMask;
distance += __builtin_popcountll((bits ^ target.bits) & commonMask);
// Bits target cares about but we don't have
uint64_t missingInUs = target.mask & ~mask;
// Bits target cares about but we don't have (within relevant mask)
uint64_t missingInUs = target.mask & ~mask & relevantMask;
distance += __builtin_popcountll(target.bits & missingInUs);
// Bits we care about but target doesn't (we may need to unset them)
uint64_t missingInTarget = mask & ~target.mask;
// Bits we care about but target doesn't (within relevant mask)
uint64_t missingInTarget = mask & ~target.mask & relevantMask;
distance += __builtin_popcountll(bits & missingInTarget);
if (ignoreValues)

View File

@@ -22,6 +22,10 @@ struct GoapBlackboard {
uint64_t bits = 0;
uint64_t mask = 0; // which bits are actually set
// Bitmask for comparison: only bits set here are compared.
// Defaults to all 1s (compare all bits).
uint64_t bitmask = ~0ULL;
// Named integer values (health, hunger, etc.) — used by preconditions/effects
std::unordered_map<std::string, int> values;
@@ -184,6 +188,7 @@ struct GoapBlackboard {
bool operator==(const GoapBlackboard &other) const
{
return bits == other.bits && mask == other.mask &&
bitmask == other.bitmask &&
values == other.values &&
floatValues == other.floatValues &&
vec3Values == other.vec3Values;

View File

@@ -0,0 +1,99 @@
#ifndef EDITSCENE_GOAP_PLANNER_HPP
#define EDITSCENE_GOAP_PLANNER_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <string>
#include <queue>
/**
* GOAP Planner component.
*
* Holds a curated list of action and goal names from an ActionDatabase,
* plus configuration for smart-object action discovery.
* The planner resolves names against an ActionDatabase at runtime.
*
* The actionNames and goalNames lists act as external references:
* prefabs can store them even when the ActionDatabase is not
* part of the prefab itself.
*/
struct GoapPlannerComponent {
// Selected action names from ActionDatabase
std::vector<Ogre::String> actionNames;
// Selected goal names from ActionDatabase
std::vector<Ogre::String> goalNames;
// Maximum distance to search for smart objects with matching actions
float smartObjectDistance = 50.0f;
// Whether to include smart object actions in planning
bool includeSmartObjects = true;
// Optional reference to an external ActionDatabase entity by name.
Ogre::String actionDatabaseRef;
// -----------------------------------------------------------------
// Runtime plan queue (not serialized)
// -----------------------------------------------------------------
struct Plan {
std::vector<Ogre::String> actions;
int totalCost = 0;
Ogre::String goalName;
};
std::vector<Plan> planQueue;
// Planner status
enum class Status {
Idle, // No planning requested
Planning, // Currently planning
PlansAvailable, // One or more plans in queue
NoPlanFound // Planning finished but no valid plan found
};
Status status = Status::Idle;
// Goal name that was used for the current plan batch
Ogre::String currentGoalName;
// Planning control
bool planDirty = true;
int maxPlans = 3; // stop after generating this many plans
// Planning progress (for status display)
int plansGenerated = 0;
int nodesExplored = 0;
GoapPlannerComponent() = default;
void clearPlans()
{
planQueue.clear();
plansGenerated = 0;
status = Status::Idle;
}
// Pop the cheapest plan from the queue
Plan popCheapestPlan()
{
if (planQueue.empty())
return Plan();
size_t bestIdx = 0;
for (size_t i = 1; i < planQueue.size(); i++) {
if (planQueue[i].totalCost < planQueue[bestIdx].totalCost)
bestIdx = i;
}
Plan result = std::move(planQueue[bestIdx]);
planQueue.erase(planQueue.begin() + bestIdx);
if (planQueue.empty() && status == Status::PlansAvailable)
status = Status::Idle;
return result;
}
bool hasPlans() const
{
return !planQueue.empty();
}
};
#endif // EDITSCENE_GOAP_PLANNER_HPP

View File

@@ -0,0 +1,21 @@
#include "GoapPlanner.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/GoapPlannerEditor.hpp"
REGISTER_COMPONENT_GROUP("GOAP Planner", "AI", GoapPlannerComponent,
GoapPlannerEditor)
{
registry.registerComponent<GoapPlannerComponent>(
"GOAP Planner", "AI",
std::make_unique<GoapPlannerEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<GoapPlannerComponent>())
e.set<GoapPlannerComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<GoapPlannerComponent>())
e.remove<GoapPlannerComponent>();
});
}

View File

@@ -0,0 +1,50 @@
#ifndef EDITSCENE_GOAP_RUNNER_HPP
#define EDITSCENE_GOAP_RUNNER_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <vector>
/**
* GOAP Runner component.
*
* Executes plans from GoapPlannerComponent.
* For normal actions: runs the action's behavior tree.
* For smart object actions: pathfinds to the smart object and executes.
* After plan completion, marks the planner dirty for replanning.
*/
struct GoapRunnerComponent {
// Current plan execution state
enum class State {
Idle, // No plan running
RunningAction, // Executing a normal action
MovingToSmartObject, // Pathfinding to a smart object
ExecutingSmartObject, // Executing smart object action
PlanComplete // Plan finished, waiting for replan
};
State state = State::Idle;
// Index of current action in the plan
int currentActionIndex = 0;
// Name of the currently executing action
Ogre::String currentActionName;
// Timer for action execution
float actionTimer = 0.0f;
// Entity ID of target smart object (if applicable)
uint64_t targetSmartObjectId = 0;
// Active plan actions (copied from planner when plan starts)
std::vector<Ogre::String> planActions;
// Whether to auto-replan after completion
bool autoReplan = true;
GoapRunnerComponent() = default;
};
#endif // EDITSCENE_GOAP_RUNNER_HPP

View File

@@ -0,0 +1,21 @@
#include "GoapRunner.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/GoapRunnerEditor.hpp"
REGISTER_COMPONENT_GROUP("GOAP Runner", "AI", GoapRunnerComponent,
GoapRunnerEditor)
{
registry.registerComponent<GoapRunnerComponent>(
"GOAP Runner", "AI",
std::make_unique<GoapRunnerEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<GoapRunnerComponent>())
e.set<GoapRunnerComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<GoapRunnerComponent>())
e.remove<GoapRunnerComponent>();
});
}

View File

@@ -0,0 +1,77 @@
#ifndef EDITSCENE_PATH_FOLLOWING_HPP
#define EDITSCENE_PATH_FOLLOWING_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <string>
#include <utility>
/**
* Animation state configuration for path following.
*
* Each state (e.g. "idle", "walk", "run") consists of unlimited
* state-machine-name -> state-name pairs applied via AnimationTreeSystem.
*/
struct PathFollowingState {
Ogre::String name;
std::vector<std::pair<Ogre::String, Ogre::String> > stateMachineStates;
PathFollowingState() = default;
explicit PathFollowingState(const Ogre::String &name_)
: name(name_)
{
}
};
/**
* Path Following component.
*
* Configures animation states for locomotion (idle/walk/run)
* and stores the current locomotion state. Used by GoapRunner
* to animate characters during plan execution.
*/
struct PathFollowingComponent {
// Animation state configurations
std::vector<PathFollowingState> pathFollowingStates = {
PathFollowingState("idle"),
PathFollowingState("walk"),
PathFollowingState("run"),
};
// Current locomotion state name
Ogre::String currentLocomotionState = "idle";
// Walk speed (m/s) for root motion scaling
float walkSpeed = 2.5f;
// Run speed (m/s) for root motion scaling
float runSpeed = 5.0f;
// Whether to use root motion
bool useRootMotion = true;
// Target position for path following (set by GoapRunner)
Ogre::Vector3 targetPosition = Ogre::Vector3::ZERO;
// Whether we have an active target
bool hasTarget = false;
// Path waypoints (set by GoapRunner, followed by PathFollowingSystem)
std::vector<Ogre::Vector3> path;
int pathIndex = 0;
float pathRecalcTimer = 0.0f;
PathFollowingComponent() = default;
const PathFollowingState *findState(const Ogre::String &name) const
{
for (const auto &state : pathFollowingStates) {
if (state.name == name)
return &state;
}
return nullptr;
}
};
#endif // EDITSCENE_PATH_FOLLOWING_HPP

View File

@@ -0,0 +1,21 @@
#include "PathFollowing.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/PathFollowingEditor.hpp"
REGISTER_COMPONENT_GROUP("Path Following", "AI", PathFollowingComponent,
PathFollowingEditor)
{
registry.registerComponent<PathFollowingComponent>(
"Path Following", "AI",
std::make_unique<PathFollowingEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<PathFollowingComponent>())
e.set<PathFollowingComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<PathFollowingComponent>())
e.remove<PathFollowingComponent>();
});
}

View File

@@ -2,38 +2,68 @@
#include "../components/Transform.hpp"
#include <cmath>
// Colors for cursor - bright cyan/white for visibility
static const float COLOR_CYAN[3] = { 0.0f, 1.0f, 1.0f };
static const float COLOR_WHITE[3] = { 1.0f, 1.0f, 1.0f };
static const float COLOR_RED[3] = { 1.0f, 0.2f, 0.2f };
static const float COLOR_GREEN[3] = { 0.2f, 1.0f, 0.2f };
static const float COLOR_BLUE[3] = { 0.2f, 0.4f, 1.0f };
static const float COLOR_CYAN[3] = { 0.0f, 1.0f, 1.0f };
static const float COLOR_YELLOW[3] = { 1.0f, 1.0f, 0.0f };
static bool projectRayOntoAxis(const Ogre::Ray &ray,
const Ogre::Vector3 &axisOrigin,
const Ogre::Vector3 &axisDir, float &outT)
{
Ogre::Vector3 rayOrigin = ray.getOrigin();
Ogre::Vector3 rayDir = ray.getDirection();
Ogre::Vector3 w0 = rayOrigin - axisOrigin;
float a = rayDir.dotProduct(rayDir);
float b = rayDir.dotProduct(axisDir);
float c = axisDir.dotProduct(axisDir);
float d = rayDir.dotProduct(w0);
float e = axisDir.dotProduct(w0);
float denom = a * c - b * b;
if (std::abs(denom) < 0.0001f)
return false;
outT = (a * e - b * d) / denom;
return true;
}
Cursor3D::Cursor3D(Ogre::SceneManager *sceneMgr)
: m_sceneMgr(sceneMgr)
, m_cursorNode(nullptr)
, m_axesObj(nullptr)
, m_markerObj(nullptr)
, m_axisX(nullptr)
, m_axisY(nullptr)
, m_axisZ(nullptr)
, m_centerMarker(nullptr)
, m_position(Ogre::Vector3::ZERO)
, m_orientation(Ogre::Quaternion::IDENTITY)
, m_size(1.0f)
, m_visible(false)
, m_mode(Mode::Translate)
, m_selectedAxis(Axis::None)
, m_hoveredAxis(Axis::None)
, m_isDragging(false)
, m_dragStartT(0.0f)
, m_dragStartAngle(0.0f)
{
m_cursorNode = m_sceneMgr->getRootSceneNode()->createChildSceneNode(
"Cursor3DNode");
m_axesObj = m_sceneMgr->createManualObject("Cursor3DAxes");
m_markerObj = m_sceneMgr->createManualObject("Cursor3DMarker");
m_axisX = m_sceneMgr->createManualObject("CursorAxisX");
m_axisY = m_sceneMgr->createManualObject("CursorAxisY");
m_axisZ = m_sceneMgr->createManualObject("CursorAxisZ");
m_centerMarker = m_sceneMgr->createManualObject("CursorCenter");
m_cursorNode->attachObject(m_axesObj);
m_cursorNode->attachObject(m_markerObj);
m_cursorNode->attachObject(m_axisX);
m_cursorNode->attachObject(m_axisY);
m_cursorNode->attachObject(m_axisZ);
m_cursorNode->attachObject(m_centerMarker);
// Draw on top of everything
m_axesObj->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_markerObj->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_axisX->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_axisY->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_axisZ->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_centerMarker->setRenderQueueGroup(Ogre::RENDER_QUEUE_OVERLAY);
m_cursorNode->setVisible(false);
createGeometry();
}
@@ -41,35 +71,34 @@ void Cursor3D::shutdown()
{
if (!m_sceneMgr)
return;
if (m_cursorNode && m_axesObj) {
try {
m_cursorNode->detachObject(m_axesObj);
} catch (...) {
auto detach = [&](Ogre::ManualObject *obj) {
if (m_cursorNode && obj) {
try {
m_cursorNode->detachObject(obj);
} catch (...) {
}
}
}
if (m_cursorNode && m_markerObj) {
try {
m_cursorNode->detachObject(m_markerObj);
} catch (...) {
};
auto destroy = [&](Ogre::ManualObject *obj) {
if (obj) {
try {
m_sceneMgr->destroyManualObject(obj);
} catch (...) {
}
}
}
if (m_axesObj) {
try {
m_sceneMgr->destroyManualObject(m_axesObj);
} catch (...) {
}
m_axesObj = nullptr;
}
if (m_markerObj) {
try {
m_sceneMgr->destroyManualObject(m_markerObj);
} catch (...) {
}
m_markerObj = nullptr;
}
};
detach(m_axisX);
detach(m_axisY);
detach(m_axisZ);
detach(m_centerMarker);
destroy(m_axisX);
m_axisX = nullptr;
destroy(m_axisY);
m_axisY = nullptr;
destroy(m_axisZ);
m_axisZ = nullptr;
destroy(m_centerMarker);
m_centerMarker = nullptr;
if (m_cursorNode) {
try {
m_sceneMgr->destroySceneNode(m_cursorNode);
@@ -77,7 +106,6 @@ void Cursor3D::shutdown()
}
m_cursorNode = nullptr;
}
m_sceneMgr = nullptr;
}
@@ -133,8 +161,7 @@ void Cursor3D::applyToTransform(TransformComponent &transform) const
{
if (!transform.node)
return;
Ogre::SceneNode *parent = static_cast<Ogre::SceneNode*>(
Ogre::SceneNode *parent = static_cast<Ogre::SceneNode *>(
transform.node->getParent());
if (parent) {
transform.position = parent->convertWorldToLocalPosition(
@@ -149,22 +176,6 @@ void Cursor3D::applyToTransform(TransformComponent &transform) const
transform.markChanged();
}
bool Cursor3D::hitTest(const Ogre::Ray &mouseRay) const
{
if (!m_cursorNode || !m_visible)
return false;
// Check if ray passes near cursor center
Ogre::Vector3 toCursor = m_position - mouseRay.getOrigin();
float tca = toCursor.dotProduct(mouseRay.getDirection());
if (tca < 0.01f)
return false;
float d2 = toCursor.dotProduct(toCursor) - tca * tca;
float threshold = 0.3f * m_size;
return d2 <= threshold * threshold;
}
void Cursor3D::updateNodeTransform()
{
if (m_cursorNode) {
@@ -178,36 +189,49 @@ void Cursor3D::createGeometry()
float len = 0.5f * m_size;
float half = 0.05f * m_size;
// Axes - short lines in RGB
m_axesObj->clear();
m_axesObj->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
// X Axis
bool xSel = (m_selectedAxis == Axis::X || m_hoveredAxis == Axis::X);
m_axisX->clear();
m_axisX->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_axisX->colour(xSel ? COLOR_YELLOW[0] : COLOR_RED[0],
xSel ? COLOR_YELLOW[1] : COLOR_RED[1],
xSel ? COLOR_YELLOW[2] : COLOR_RED[2]);
m_axisX->position(0, 0, 0);
m_axisX->position(len, 0, 0);
m_axisX->end();
// X axis - red
m_axesObj->colour(COLOR_RED[0], COLOR_RED[1], COLOR_RED[2]);
m_axesObj->position(0, 0, 0);
m_axesObj->position(len, 0, 0);
// Y Axis
bool ySel = (m_selectedAxis == Axis::Y || m_hoveredAxis == Axis::Y);
m_axisY->clear();
m_axisY->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_axisY->colour(ySel ? COLOR_YELLOW[0] : COLOR_GREEN[0],
ySel ? COLOR_YELLOW[1] : COLOR_GREEN[1],
ySel ? COLOR_YELLOW[2] : COLOR_GREEN[2]);
m_axisY->position(0, 0, 0);
m_axisY->position(0, len, 0);
m_axisY->end();
// Y axis - green
m_axesObj->colour(COLOR_GREEN[0], COLOR_GREEN[1], COLOR_GREEN[2]);
m_axesObj->position(0, 0, 0);
m_axesObj->position(0, len, 0);
// Z Axis
bool zSel = (m_selectedAxis == Axis::Z || m_hoveredAxis == Axis::Z);
m_axisZ->clear();
m_axisZ->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_axisZ->colour(zSel ? COLOR_YELLOW[0] : COLOR_BLUE[0],
zSel ? COLOR_YELLOW[1] : COLOR_BLUE[1],
zSel ? COLOR_YELLOW[2] : COLOR_BLUE[2]);
m_axisZ->position(0, 0, 0);
m_axisZ->position(0, 0, len);
m_axisZ->end();
// Z axis - blue
m_axesObj->colour(COLOR_BLUE[0], COLOR_BLUE[1], COLOR_BLUE[2]);
m_axesObj->position(0, 0, 0);
m_axesObj->position(0, 0, len);
m_axesObj->end();
// Center marker - small wireframe cube in cyan
m_markerObj->clear();
m_markerObj->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_markerObj->colour(COLOR_CYAN[0], COLOR_CYAN[1], COLOR_CYAN[2]);
// Cube corners
Ogre::Vector3 corners[8] = {
// Center marker
m_centerMarker->clear();
m_centerMarker->begin("Ogre/AxisGizmo",
Ogre::RenderOperation::OT_LINE_LIST);
m_centerMarker->colour(COLOR_CYAN[0], COLOR_CYAN[1],
COLOR_CYAN[2]);
Ogre::Vector3 c[8] = {
Ogre::Vector3(-half, -half, -half),
Ogre::Vector3(half, -half, -half),
Ogre::Vector3(half, half, -half),
@@ -217,36 +241,169 @@ void Cursor3D::createGeometry()
Ogre::Vector3(half, half, half),
Ogre::Vector3(-half, half, half),
};
// Bottom face
m_markerObj->position(corners[0]);
m_markerObj->position(corners[1]);
m_markerObj->position(corners[1]);
m_markerObj->position(corners[2]);
m_markerObj->position(corners[2]);
m_markerObj->position(corners[3]);
m_markerObj->position(corners[3]);
m_markerObj->position(corners[0]);
// Top face
m_markerObj->position(corners[4]);
m_markerObj->position(corners[5]);
m_markerObj->position(corners[5]);
m_markerObj->position(corners[6]);
m_markerObj->position(corners[6]);
m_markerObj->position(corners[7]);
m_markerObj->position(corners[7]);
m_markerObj->position(corners[4]);
// Vertical edges
m_markerObj->position(corners[0]);
m_markerObj->position(corners[4]);
m_markerObj->position(corners[1]);
m_markerObj->position(corners[5]);
m_markerObj->position(corners[2]);
m_markerObj->position(corners[6]);
m_markerObj->position(corners[3]);
m_markerObj->position(corners[7]);
m_markerObj->end();
auto line = [&](int a, int b) {
m_centerMarker->position(c[a]);
m_centerMarker->position(c[b]);
};
line(0, 1);
line(1, 2);
line(2, 3);
line(3, 0);
line(4, 5);
line(5, 6);
line(6, 7);
line(7, 4);
line(0, 4);
line(1, 5);
line(2, 6);
line(3, 7);
m_centerMarker->end();
}
Cursor3D::Axis Cursor3D::hitTest(const Ogre::Ray &mouseRay)
{
if (!m_cursorNode || !m_axisX->isVisible())
return Axis::None;
Ogre::Vector3 cursorPos = m_cursorNode->getPosition();
float len = 0.5f * m_size;
float threshold = 0.15f * m_size;
float bestDist = 1000000.0f;
Axis bestAxis = Axis::None;
for (int i = 0; i < 3; ++i) {
Ogre::Vector3 axisDir;
if (i == 0)
axisDir = m_cursorNode->getOrientation() *
Ogre::Vector3::UNIT_X;
else if (i == 1)
axisDir = m_cursorNode->getOrientation() *
Ogre::Vector3::UNIT_Y;
else
axisDir = m_cursorNode->getOrientation() *
Ogre::Vector3::UNIT_Z;
for (int j = 0; j <= 8; ++j) {
float t = len * j / 8.0f;
Ogre::Vector3 pointOnAxis = cursorPos + axisDir * t;
Ogre::Vector3 L = pointOnAxis - mouseRay.getOrigin();
float tca = L.dotProduct(mouseRay.getDirection());
if (tca < 0.01f)
continue;
float d2 = L.dotProduct(L) - tca * tca;
if (d2 <= threshold * threshold) {
if (tca < bestDist) {
bestDist = tca;
bestAxis = static_cast<Axis>(i + 1);
}
}
}
}
return bestAxis;
}
bool Cursor3D::onMousePressed(const Ogre::Ray &mouseRay,
const Ogre::Camera *camera)
{
(void)camera;
if (!m_axisX->isVisible())
return false;
m_selectedAxis = hitTest(mouseRay);
if (m_selectedAxis != Axis::None) {
m_isDragging = true;
m_dragStartPosition = m_position;
m_dragStartRotation = m_orientation;
Ogre::Vector3 axisDir;
switch (m_selectedAxis) {
case Axis::X:
axisDir = m_orientation * Ogre::Vector3::UNIT_X;
break;
case Axis::Y:
axisDir = m_orientation * Ogre::Vector3::UNIT_Y;
break;
case Axis::Z:
axisDir = m_orientation * Ogre::Vector3::UNIT_Z;
break;
default:
axisDir = Ogre::Vector3::UNIT_X;
break;
}
m_dragAxisDir = axisDir;
if (m_mode == Mode::Translate) {
projectRayOntoAxis(mouseRay, m_dragStartPosition,
m_dragAxisDir, m_dragStartT);
} else if (m_mode == Mode::Rotate) {
(void)camera;
m_dragStartAngle = 0.0f;
}
createGeometry();
return true;
}
return false;
}
bool Cursor3D::onMouseMoved(const Ogre::Ray &mouseRay,
const Ogre::Vector2 &mouseDelta)
{
if (m_isDragging && m_selectedAxis != Axis::None) {
if (m_mode == Mode::Translate) {
float currentT;
if (!projectRayOntoAxis(mouseRay, m_dragStartPosition,
m_dragAxisDir, currentT)) {
return true;
}
float deltaT = currentT - m_dragStartT;
Ogre::Vector3 newPos =
m_dragStartPosition + m_dragAxisDir * deltaT;
setPosition(newPos);
return true;
} else if (m_mode == Mode::Rotate) {
// Simple axis-based rotation from mouse delta
float deltaAngle = 0.0f;
if (m_selectedAxis == Axis::X) {
// Up/down rotates around X
deltaAngle = mouseDelta.y * 0.5f;
} else if (m_selectedAxis == Axis::Y) {
// Left/right rotates around Y
deltaAngle = -mouseDelta.x * 0.5f;
} else if (m_selectedAxis == Axis::Z) {
// Left/right rotates around Z
deltaAngle = -mouseDelta.x * 0.5f;
}
m_dragStartAngle += deltaAngle;
Ogre::Quaternion rot(
Ogre::Degree(m_dragStartAngle),
m_dragAxisDir);
Ogre::Quaternion newRot = rot * m_dragStartRotation;
setOrientation(newRot);
return true;
}
} else if (m_axisX->isVisible()) {
Axis prevHover = m_hoveredAxis;
m_hoveredAxis = hitTest(mouseRay);
if (prevHover != m_hoveredAxis)
createGeometry();
}
return false;
}
bool Cursor3D::onMouseReleased()
{
if (m_isDragging) {
m_isDragging = false;
m_selectedAxis = Axis::None;
m_hoveredAxis = Axis::None;
createGeometry();
return true;
}
return false;
}

View File

@@ -9,80 +9,88 @@
struct TransformComponent;
/**
* 3D Cursor - a visual marker for prefab placement and transform reference
* Shows a small crosshair with axis indicators in world space
* 3D Cursor - a visual marker for prefab placement and transform reference.
* Supports axis-based translation and rotation interaction like the gizmo.
*/
class Cursor3D {
public:
enum class Mode {
Translate,
Rotate
};
enum class Axis {
None,
X,
Y,
Z
};
Cursor3D(Ogre::SceneManager *sceneMgr);
~Cursor3D();
/**
* Shutdown and cleanup - must be called before SceneManager is destroyed
*/
void shutdown();
/**
* Set world position
*/
void setPosition(const Ogre::Vector3 &pos);
Ogre::Vector3 getPosition() const
{
return m_position;
}
Ogre::Vector3 getPosition() const { return m_position; }
/**
* Set world orientation
*/
void setOrientation(const Ogre::Quaternion &rot);
Ogre::Quaternion getOrientation() const
{
return m_orientation;
}
Ogre::Quaternion getOrientation() const { return m_orientation; }
/**
* Show/hide cursor
*/
void setVisible(bool visible);
bool isVisible() const;
/**
* Set cursor size/scale
*/
void setSize(float size);
float getSize() const
{
return m_size;
}
float getSize() const { return m_size; }
void setMode(Mode mode) { m_mode = mode; }
Mode getMode() const { return m_mode; }
/**
* Copy position and orientation from a TransformComponent (world space)
*/
void snapToTransform(const TransformComponent &transform);
/**
* Apply cursor position and orientation to a TransformComponent
*/
void applyToTransform(TransformComponent &transform) const;
/**
* Simple hit test - returns true if mouse ray passes near cursor center
* Handle mouse input for cursor interaction.
* Returns true if cursor handled the input.
*/
bool hitTest(const Ogre::Ray &mouseRay) const;
bool onMousePressed(const Ogre::Ray &mouseRay,
const Ogre::Camera *camera);
bool onMouseMoved(const Ogre::Ray &mouseRay,
const Ogre::Vector2 &mouseDelta);
bool onMouseReleased();
bool isDragging() const { return m_isDragging; }
Axis getSelectedAxis() const { return m_selectedAxis; }
private:
void createGeometry();
void updateNodeTransform();
Axis hitTest(const Ogre::Ray &mouseRay);
Ogre::SceneManager *m_sceneMgr;
Ogre::SceneNode *m_cursorNode;
Ogre::ManualObject *m_axesObj;
Ogre::ManualObject *m_markerObj;
Ogre::ManualObject *m_axisX;
Ogre::ManualObject *m_axisY;
Ogre::ManualObject *m_axisZ;
Ogre::ManualObject *m_centerMarker;
Ogre::Vector3 m_position;
Ogre::Quaternion m_orientation;
float m_size;
bool m_visible;
Mode m_mode;
Axis m_selectedAxis;
Axis m_hoveredAxis;
bool m_isDragging;
// Drag state
Ogre::Vector3 m_dragStartPosition;
Ogre::Quaternion m_dragStartRotation;
Ogre::Vector3 m_dragAxisDir;
float m_dragStartT;
float m_dragStartAngle;
Ogre::Vector2 m_dragScreenAxis;
};
#endif // EDITSCENE_CURSOR3D_HPP

View File

@@ -1,6 +1,7 @@
#include "../components/GeneratedPhysicsTag.hpp"
#include "EditorUISystem.hpp"
#include "PrefabSystem.hpp"
#include "../camera/EditorCamera.hpp"
#include "../components/EntityName.hpp"
#include "../components/Transform.hpp"
#include "../components/Renderable.hpp"
@@ -35,6 +36,9 @@
#include "../components/GoapBlackboard.hpp"
#include "../components/NavMesh.hpp"
#include "../components/SmartObject.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/GoapRunner.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/PrefabInstance.hpp"
#include "../ui/TransformEditor.hpp"
@@ -87,11 +91,16 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
return true;
}
// If cursor placement mode is active, raycast and place cursor
// (skip if ImGui wants the mouse for UI interaction)
if (m_cursor3D && m_cursorPlaceMode && m_physicsSystem &&
m_physicsSystem->isInitialized() &&
!ImGui::GetIO().WantCaptureMouse) {
// Skip if ImGui wants the mouse
if (ImGui::GetIO().WantCaptureMouse)
return false;
if (!m_cursor3D || !m_cursor3D->isVisible())
return false;
// Cursor place mode: raycast and place cursor on surface
if (m_cursorMode == CursorInteractionMode::Place && m_physicsSystem &&
m_physicsSystem->isInitialized()) {
JoltPhysicsWrapper *physics =
m_physicsSystem->getPhysicsWrapper();
if (physics) {
@@ -103,30 +112,62 @@ bool EditorUISystem::onMousePressed(const Ogre::Ray &mouseRay)
if (physics->raycastQuery(start, end, hitPos,
hitBody)) {
m_cursor3D->setPosition(hitPos);
m_cursorPlaceMode =
false; // disable after placement
m_cursorMode = CursorInteractionMode::None;
return true;
}
}
}
// Cursor translate/rotate: delegate to Cursor3D axis-based interaction
if (m_cursorMode == CursorInteractionMode::Translate) {
m_cursor3D->setMode(Cursor3D::Mode::Translate);
Ogre::Camera *cam =
m_editorCamera ? m_editorCamera->getCamera() : nullptr;
if (m_cursor3D->onMousePressed(mouseRay, cam))
return true;
} else if (m_cursorMode == CursorInteractionMode::Rotate) {
m_cursor3D->setMode(Cursor3D::Mode::Rotate);
Ogre::Camera *cam =
m_editorCamera ? m_editorCamera->getCamera() : nullptr;
if (m_cursor3D->onMousePressed(mouseRay, cam))
return true;
}
return false;
}
bool EditorUISystem::onMouseMoved(const Ogre::Ray &mouseRay,
const Ogre::Vector2 &mouseDelta)
{
if (m_gizmo) {
return m_gizmo->onMouseMoved(mouseRay, mouseDelta);
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseMoved(mouseRay, mouseDelta)) {
return true;
}
// Cursor translate/rotate drag
if (m_cursor3D && (m_cursorMode == CursorInteractionMode::Translate ||
m_cursorMode == CursorInteractionMode::Rotate)) {
if (m_cursor3D->onMouseMoved(mouseRay, mouseDelta))
return true;
}
return false;
}
bool EditorUISystem::onMouseReleased()
{
if (m_gizmo) {
return m_gizmo->onMouseReleased();
// Gizmo gets first shot
if (m_gizmo && m_gizmo->onMouseReleased()) {
return true;
}
// Cursor translate/rotate release
if (m_cursor3D && (m_cursorMode == CursorInteractionMode::Translate ||
m_cursorMode == CursorInteractionMode::Rotate)) {
if (m_cursor3D->onMouseReleased())
return true;
}
return false;
}
@@ -575,6 +616,8 @@ void EditorUISystem::renderEntityNode(flecs::entity entity, int depth)
indicators += " [NavSrc]";
if (entity.has<SmartObjectComponent>())
indicators += " [SO]";
if (entity.has<GoapPlannerComponent>())
indicators += " [Planner]";
snprintf(label, sizeof(label), "%s%s##%llu", name.c_str(),
indicators.c_str(), (unsigned long long)entity.id());
@@ -998,6 +1041,28 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render GoapPlanner if present
if (entity.has<GoapPlannerComponent>()) {
auto &planner = entity.get_mut<GoapPlannerComponent>();
m_componentRegistry.render<GoapPlannerComponent>(entity,
planner);
componentCount++;
}
// Render GoapRunner if present
if (entity.has<GoapRunnerComponent>()) {
auto &runner = entity.get_mut<GoapRunnerComponent>();
m_componentRegistry.render<GoapRunnerComponent>(entity, runner);
componentCount++;
}
// Render PathFollowing if present
if (entity.has<PathFollowingComponent>()) {
auto &pf = entity.get_mut<PathFollowingComponent>();
m_componentRegistry.render<PathFollowingComponent>(entity, pf);
componentCount++;
}
// Show message if no components
if (componentCount == 0) {
@@ -1741,6 +1806,44 @@ void EditorUISystem::renderPrefabBrowser()
setSelectedEntity(instance);
}
}
ImGui::SameLine();
// Delete prefab button
std::string delId = "Del##" + file;
if (ImGui::Button(delId.c_str())) {
m_prefabToDelete = path;
m_showDeletePrefabConfirm = true;
}
}
// Delete prefab confirmation dialog
if (m_showDeletePrefabConfirm) {
ImGui::OpenPopup("Delete Prefab");
}
if (ImGui::BeginPopupModal("Delete Prefab",
&m_showDeletePrefabConfirm,
ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text(
"Are you sure you want to delete this prefab?");
ImGui::Text(" %s", m_prefabToDelete.c_str());
ImGui::Separator();
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f),
"This action cannot be undone!");
if (ImGui::Button("Delete", ImVec2(120, 0))) {
PrefabSystem prefabSys(m_world, m_sceneMgr);
if (prefabSys.deletePrefab(m_prefabToDelete)) {
m_refreshPrefabList = true;
}
m_showDeletePrefabConfirm = false;
m_prefabToDelete.clear();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
m_showDeletePrefabConfirm = false;
m_prefabToDelete.clear();
}
ImGui::EndPopup();
}
}
ImGui::End();
@@ -1766,15 +1869,48 @@ void EditorUISystem::renderCursorPanel()
m_cursor3D->setVisible(visible);
}
// Placement mode
ImGui::Separator();
// Interaction mode radio buttons
ImGui::Text("Interaction Mode:");
int mode = static_cast<int>(m_cursorMode);
if (ImGui::RadioButton("None", &mode, 0)) {
m_cursorMode = CursorInteractionMode::None;
}
ImGui::SameLine();
if (ImGui::Checkbox("Place on Click", &m_cursorPlaceMode)) {
if (m_cursorPlaceMode && !m_cursor3D->isVisible())
if (ImGui::RadioButton("Place", &mode, 1)) {
m_cursorMode = CursorInteractionMode::Place;
if (!m_cursor3D->isVisible())
m_cursor3D->setVisible(true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Click in the 3D viewport to place cursor on surface");
"Click in viewport to raycast-place cursor on surface");
}
ImGui::SameLine();
if (ImGui::RadioButton("Move", &mode, 2)) {
m_cursorMode = CursorInteractionMode::Translate;
if (!m_cursor3D->isVisible())
m_cursor3D->setVisible(true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Click and drag an axis to translate along it");
}
ImGui::SameLine();
if (ImGui::RadioButton("Rotate", &mode, 3)) {
m_cursorMode = CursorInteractionMode::Rotate;
if (!m_cursor3D->isVisible())
m_cursor3D->setVisible(true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Click and drag an axis to rotate around it");
}
if (m_cursor3D->isDragging()) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0, 1, 0, 1), "[Dragging]");
}
ImGui::Separator();

View File

@@ -16,6 +16,7 @@
class EditorPhysicsSystem;
class BuoyancySystem;
class NormalDebugSystem;
class EditorCamera;
namespace Ogre
{
@@ -142,6 +143,14 @@ public:
m_normalDebugSystem = normalDebug;
}
/**
* Set editor camera for cursor drag operations
*/
void setEditorCamera(EditorCamera *camera)
{
m_editorCamera = camera;
}
/**
* Enable/disable editor UI rendering
*/
@@ -267,12 +276,26 @@ private:
std::vector<std::string> m_prefabFiles;
bool m_refreshPrefabList = true;
bool m_prefabInstAtRoot = false;
bool m_prefabUseCursor = true; // Use 3D cursor for prefab placement
float m_prefabRaycastMargin = 0.0f; // Vertical margin for raycast placement
bool m_prefabUseCursor = true; // Use 3D cursor for prefab placement
float m_prefabRaycastMargin =
0.0f; // Vertical margin for raycast placement
// Prefab delete confirmation
bool m_showDeletePrefabConfirm = false;
std::string m_prefabToDelete;
// 3D Cursor state
bool m_showCursorPanel = false;
bool m_cursorPlaceMode = false; // Click in viewport to place cursor
enum class CursorInteractionMode {
None,
Place, // Click to raycast-place on surface
Translate, // Drag axis to translate cursor
Rotate // Drag axis to rotate cursor
};
CursorInteractionMode m_cursorMode = CursorInteractionMode::None;
// Camera reference for cursor placement/rotation
EditorCamera *m_editorCamera = nullptr;
// Queries
flecs::query<EntityNameComponent> m_nameQuery;

View File

@@ -0,0 +1,266 @@
#include "GoapPlannerSystem.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/GoapAction.hpp"
#include "../components/GoapGoal.hpp"
#include "../EditorApp.hpp"
#include <OgreLogManager.h>
#include <queue>
#include <functional>
#include <unordered_map>
namespace {
struct PlannerNode {
GoapBlackboard state;
std::vector<Ogre::String> actions;
int cost = 0;
int heuristic(const GoapBlackboard &goal) const
{
(void)goal;
return 0;
}
int f(const GoapBlackboard &goal) const
{
return cost + heuristic(goal);
}
};
struct NodeCompare {
const GoapBlackboard *goal;
bool operator()(const PlannerNode &a, const PlannerNode &b) const
{
return a.f(*goal) > b.f(*goal);
}
};
} // namespace
GoapPlannerSystem::GoapPlannerSystem(flecs::world &world)
: m_world(world)
{
}
GoapPlannerSystem::~GoapPlannerSystem() = default;
size_t GoapPlannerSystem::hashState(const GoapBlackboard &bb)
{
std::hash<uint64_t> hasher;
size_t h = hasher(bb.bits);
h ^= hasher(bb.mask) + 0x9e3779b9 + (h << 6) + (h >> 2);
h ^= hasher(bb.bitmask) + 0x9e3779b9 + (h << 6) + (h >> 2);
for (const auto &pair : bb.values) {
h ^= std::hash<std::string>{}(pair.first) + 0x9e3779b9 +
(h << 6) + (h >> 2);
h ^= std::hash<int>{}(pair.second) + 0x9e3779b9 + (h << 6) +
(h >> 2);
}
return h;
}
bool GoapPlannerSystem::statesEqual(const GoapBlackboard &a,
const GoapBlackboard &b)
{
return a.bits == b.bits && a.mask == b.mask &&
a.bitmask == b.bitmask && a.values == b.values;
}
std::vector<const GoapAction *> GoapPlannerSystem::resolveActions(
const GoapPlannerComponent &planner, const ActionDatabase *db)
{
std::vector<const GoapAction *> result;
if (!db)
return result;
for (const auto &name : planner.actionNames) {
const GoapAction *action = db->findAction(name);
if (action)
result.push_back(action);
}
return result;
}
const GoapGoal *GoapPlannerSystem::selectGoal(
const GoapPlannerComponent &planner, const ActionDatabase *db,
const GoapBlackboard &blackboard)
{
if (!db)
return nullptr;
const GoapGoal *best = nullptr;
int bestPriority = -1;
for (const auto &name : planner.goalNames) {
const GoapGoal *goal = db->findGoal(name);
if (!goal)
continue;
if (!goal->isValid(blackboard))
continue;
if (goal->priority > bestPriority) {
bestPriority = goal->priority;
best = goal;
}
}
return best;
}
void GoapPlannerSystem::planForEntity(flecs::entity e,
GoapPlannerComponent &planner,
const GoapBlackboard &blackboard)
{
(void)e;
// Find ActionDatabase
const ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Select best valid goal
const GoapGoal *goal = selectGoal(planner, db, blackboard);
if (!goal) {
planner.status = GoapPlannerComponent::Status::NoPlanFound;
planner.planDirty = false;
planner.currentGoalName.clear();
Ogre::LogManager::getSingleton().logMessage(
"[GoapPlanner] No valid goal found.");
return;
}
planner.currentGoalName = goal->name;
// Resolve actions
std::vector<const GoapAction *> actions =
resolveActions(planner, db);
if (actions.empty()) {
planner.status = GoapPlannerComponent::Status::NoPlanFound;
planner.planDirty = false;
Ogre::LogManager::getSingleton().logMessage(
"[GoapPlanner] No actions available for planning.");
return;
}
// A* search
NodeCompare cmp{&goal->target};
std::priority_queue<PlannerNode, std::vector<PlannerNode>,
NodeCompare>
openSet(cmp);
// Closed set: map from state hash -> best cost seen for that state
std::unordered_map<size_t, int> closedSet;
// Track found plans to avoid duplicates
std::unordered_set<std::string> foundPlanSignatures;
PlannerNode start;
start.state = blackboard;
start.cost = 0;
openSet.push(start);
planner.nodesExplored = 0;
planner.plansGenerated = 0;
int maxNodes = 10000; // safety limit
while (!openSet.empty() && planner.plansGenerated < planner.maxPlans &&
planner.nodesExplored < maxNodes) {
PlannerNode current = openSet.top();
openSet.pop();
planner.nodesExplored++;
// Check if goal is satisfied
if (current.state.satisfies(goal->target)) {
// Build plan signature for deduplication
std::string sig;
for (const auto &name : current.actions)
sig += name + ",";
if (foundPlanSignatures.insert(sig).second) {
GoapPlannerComponent::Plan plan;
plan.actions = current.actions;
plan.totalCost = current.cost;
plan.goalName = goal->name;
planner.planQueue.push_back(std::move(plan));
planner.plansGenerated++;
}
continue;
}
// Check closed set
size_t stateHash = hashState(current.state);
auto it = closedSet.find(stateHash);
if (it != closedSet.end() && it->second <= current.cost)
continue;
closedSet[stateHash] = current.cost;
// Expand: try each action
for (const GoapAction *action : actions) {
if (!action)
continue;
if (!action->canRun(current.state))
continue;
PlannerNode next = current;
next.state.apply(action->effects);
next.actions.push_back(action->name);
next.cost += action->cost;
openSet.push(std::move(next));
}
}
if (planner.plansGenerated > 0) {
planner.status =
GoapPlannerComponent::Status::PlansAvailable;
Ogre::LogManager::getSingleton().logMessage(
"[GoapPlanner] Generated " +
Ogre::StringConverter::toString(
planner.plansGenerated) +
" plan(s) for goal: " + goal->name);
} else {
planner.status =
GoapPlannerComponent::Status::NoPlanFound;
Ogre::LogManager::getSingleton().logMessage(
"[GoapPlanner] No plan found for goal: " +
goal->name);
}
planner.planDirty = false;
}
void GoapPlannerSystem::update(float deltaTime)
{
(void)deltaTime;
bool shouldRun = true;
if (m_editorApp) {
if (m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
m_editorApp->getGamePlayState() !=
EditorApp::GamePlayState::Playing) {
shouldRun = false;
}
}
if (!shouldRun)
return;
m_world
.query<GoapPlannerComponent, GoapBlackboard>()
.each([&](flecs::entity e, GoapPlannerComponent &planner,
GoapBlackboard &blackboard) {
(void)e;
if (!planner.planDirty)
return;
// Clear old plans before replanning
planner.clearPlans();
planner.status =
GoapPlannerComponent::Status::Planning;
planForEntity(e, planner, blackboard);
});
}

View File

@@ -0,0 +1,68 @@
#ifndef EDITSCENE_GOAP_PLANNER_SYSTEM_HPP
#define EDITSCENE_GOAP_PLANNER_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <vector>
#include <string>
#include <queue>
#include <unordered_set>
// Forward declarations
class EditorApp;
class ActionDatabase;
struct GoapPlannerComponent;
struct GoapAction;
struct GoapGoal;
struct GoapBlackboard;
/**
* GOAP Planner system.
*
* Runs A* planning for entities with GoapPlannerComponent + GoapBlackboard.
* When planDirty is set:
* 1. Selects the highest-priority valid goal from the planner's goal list
* 2. Resolves action names against ActionDatabase
* 3. Runs forward A* search to find action sequences reaching the goal
* 4. Stores up to maxPlans plans in the planner's planQueue
* 5. Sets status to PlansAvailable or NoPlanFound
*/
class GoapPlannerSystem {
public:
GoapPlannerSystem(flecs::world &world);
~GoapPlannerSystem();
void update(float deltaTime);
/**
* Set the EditorApp for game mode detection.
* Planning only runs when playing (game mode) or always in editor.
*/
void setEditorApp(EditorApp *app)
{
m_editorApp = app;
}
private:
void planForEntity(flecs::entity e, GoapPlannerComponent &planner,
const GoapBlackboard &blackboard);
std::vector<const GoapAction *>
resolveActions(const GoapPlannerComponent &planner,
const ActionDatabase *db);
const GoapGoal *selectGoal(const GoapPlannerComponent &planner,
const ActionDatabase *db,
const GoapBlackboard &blackboard);
// Hash a blackboard state for closed-set deduplication
static size_t hashState(const GoapBlackboard &bb);
static bool statesEqual(const GoapBlackboard &a,
const GoapBlackboard &b);
flecs::world &m_world;
EditorApp *m_editorApp = nullptr;
};
#endif // EDITSCENE_GOAP_PLANNER_SYSTEM_HPP

View File

@@ -0,0 +1,325 @@
#include "GoapRunnerSystem.hpp"
#include "SmartObjectSystem.hpp"
#include "BehaviorTreeSystem.hpp"
#include "AnimationTreeSystem.hpp"
#include "NavMeshSystem.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/GoapRunner.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/SmartObject.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/Transform.hpp"
#include "../components/Character.hpp"
#include "../components/NavMesh.hpp"
#include "../EditorApp.hpp"
#include <OgreLogManager.h>
#include <cmath>
GoapRunnerSystem::GoapRunnerSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
SmartObjectSystem *soSystem,
BehaviorTreeSystem *btSystem,
NavMeshSystem *navSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_soSystem(soSystem)
, m_btSystem(btSystem)
, m_navSystem(navSystem)
{
}
GoapRunnerSystem::~GoapRunnerSystem() = default;
flecs::entity GoapRunnerSystem::findNearestSmartObject(
flecs::entity character, const Ogre::String &actionName,
float maxDistance)
{
if (!character.is_alive() || !character.has<TransformComponent>())
return flecs::entity::null();
Ogre::Vector3 charPos =
character.get<TransformComponent>().node
->_getDerivedPosition();
flecs::entity nearest = flecs::entity::null();
float bestDist = maxDistance;
m_world.query<SmartObjectComponent, TransformComponent>()
.each([&](flecs::entity so, SmartObjectComponent &soComp,
TransformComponent &soTrans) {
bool hasAction = false;
for (const auto &name : soComp.actionNames) {
if (name == actionName) {
hasAction = true;
break;
}
}
if (!hasAction)
return;
Ogre::Vector3 soPos =
soTrans.node->_getDerivedPosition();
float dist = charPos.distance(soPos);
if (dist < bestDist) {
bestDist = dist;
nearest = so;
}
});
return nearest;
}
bool GoapRunnerSystem::startNextAction(flecs::entity e)
{
if (!e.has<GoapPlannerComponent>() || !e.has<GoapRunnerComponent>())
return false;
auto &planner = e.get_mut<GoapPlannerComponent>();
auto &runner = e.get_mut<GoapRunnerComponent>();
if (runner.currentActionIndex >= (int)runner.planActions.size()) {
// Plan complete
Ogre::LogManager::getSingleton().logMessage(
"[GoapRunner] Plan complete.");
runner.planActions.clear();
runner.currentActionIndex = 0;
runner.currentActionName.clear();
runner.state = GoapRunnerComponent::State::Idle;
// Clear PathFollowing target
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get_mut<PathFollowingComponent>();
pf.hasTarget = false;
pf.currentLocomotionState = "idle";
pf.path.clear();
pf.pathIndex = 0;
}
return false;
}
const Ogre::String &actionName =
runner.planActions[runner.currentActionIndex];
runner.currentActionName = actionName;
// Find action database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
const GoapAction *action = db ? db->findAction(actionName) : nullptr;
if (action) {
// Normal action: run behavior tree via ActionDebug
runner.state = GoapRunnerComponent::State::RunningAction;
if (!e.has<ActionDebug>()) {
e.set<ActionDebug>({});
m_managedActionDebugs.insert(e.id());
}
auto &debug = e.get_mut<ActionDebug>();
debug.isRunning = true;
debug.runTimer = 0.0f;
debug.currentActionName = actionName;
debug.selectedActionName = actionName;
Ogre::LogManager::getSingleton().logMessage(
"[GoapRunner] Starting normal action: " + actionName);
} else {
// Smart object action: pathfind and set up PathFollowingComponent
flecs::entity so = findNearestSmartObject(
e, actionName, planner.smartObjectDistance);
if (so.is_alive()) {
runner.state =
GoapRunnerComponent::State::MovingToSmartObject;
runner.targetSmartObjectId = so.id();
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get_mut<PathFollowingComponent>();
auto &soTrans = so.get<TransformComponent>();
pf.targetPosition =
soTrans.node->_getDerivedPosition();
pf.hasTarget = true;
pf.path.clear();
pf.pathIndex = 0;
pf.pathRecalcTimer = 0.0f;
// Find path via navmesh
if (m_navSystem) {
flecs::entity navmeshEntity =
flecs::entity::null();
m_world
.query<NavMeshComponent>()
.each([&](flecs::entity ne,
NavMeshComponent &) {
if (!navmeshEntity.is_alive())
navmeshEntity = ne;
});
if (navmeshEntity.is_alive()) {
auto &charTrans =
e.get<TransformComponent>();
bool found = m_navSystem->findPath(
navmeshEntity,
charTrans.node
->_getDerivedPosition(),
pf.targetPosition,
pf.path);
if (!found || pf.path.empty()) {
// Fallback: direct path
pf.path.clear();
pf.path.push_back(
pf.targetPosition);
}
} else {
pf.path.clear();
pf.path.push_back(pf.targetPosition);
}
} else {
pf.path.clear();
pf.path.push_back(pf.targetPosition);
}
// Choose walk or run based on distance
auto &charTrans = e.get<TransformComponent>();
float dist = charTrans.node
->_getDerivedPosition()
.distance(pf.targetPosition);
if (dist > pf.walkSpeed * 3.0f)
pf.currentLocomotionState = "run";
else
pf.currentLocomotionState = "walk";
}
Ogre::LogManager::getSingleton().logMessage(
"[GoapRunner] Starting smart object action: " +
actionName);
} else {
Ogre::LogManager::getSingleton().logMessage(
"[GoapRunner] No smart object found for action: " +
actionName);
// Skip this action
runner.currentActionIndex++;
return startNextAction(e);
}
}
return true;
}
void GoapRunnerSystem::update(float deltaTime)
{
bool shouldRun = true;
if (m_editorApp) {
if (m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
m_editorApp->getGamePlayState() !=
EditorApp::GamePlayState::Playing) {
shouldRun = false;
}
}
if (!shouldRun)
return;
m_world.query<GoapPlannerComponent, GoapRunnerComponent>()
.each([&](flecs::entity e, GoapPlannerComponent &planner,
GoapRunnerComponent &runner) {
// If idle and plans available, pop one and start
if (runner.state ==
GoapRunnerComponent::State::Idle &&
planner.hasPlans()) {
auto plan = planner.popCheapestPlan();
runner.planActions = std::move(plan.actions);
runner.currentActionIndex = 0;
runner.currentActionName.clear();
startNextAction(e);
return;
}
// If idle and no plans, mark planner dirty for replanning
if (runner.state ==
GoapRunnerComponent::State::Idle &&
!planner.hasPlans()) {
if (!runner.planActions.empty()) {
// Just finished a plan batch, need more
runner.planActions.clear();
runner.currentActionIndex = 0;
}
planner.planDirty = true;
return;
}
// If still idle, nothing to do
if (runner.state ==
GoapRunnerComponent::State::Idle)
return;
// Update action timer
runner.actionTimer += deltaTime;
// Handle smart object navigation completion
if (runner.state == GoapRunnerComponent::State::
MovingToSmartObject) {
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get<PathFollowingComponent>();
if (!pf.hasTarget) {
// PathFollowingSystem has reached the
// destination - execute the action
Ogre::LogManager::getSingleton()
.logMessage(
"[GoapRunner] Reached smart object, executing: " +
runner.currentActionName);
// Apply action effects
if (e.has<GoapBlackboard>()) {
ActionDatabase *db = nullptr;
m_world
.query<ActionDatabase>()
.each([&](flecs::entity,
ActionDatabase
&database) {
if (!db)
db = &database;
});
if (db) {
const GoapAction *action =
db->findAction(
runner.currentActionName);
if (action) {
auto &bb = e.get_mut<GoapBlackboard>();
bb.apply(action->effects);
}
}
}
// Advance to next action
runner.currentActionIndex++;
runner.actionTimer = 0.0f;
runner.targetSmartObjectId = 0;
startNextAction(e);
}
}
return;
}
// Check if current normal action is complete
if (runner.state == GoapRunnerComponent::State::
RunningAction &&
m_btSystem && e.has<ActionDebug>()) {
auto &debug = e.get_mut<ActionDebug>();
debug.runTimer += deltaTime;
auto &btState =
m_btSystem->getActionDebugState(e.id());
if (debug.runTimer > 0.5f &&
btState.treeResult !=
BehaviorTreeSystem::Status::running) {
debug.isRunning = false;
runner.currentActionIndex++;
runner.actionTimer = 0.0f;
startNextAction(e);
}
}
});
}

View File

@@ -0,0 +1,68 @@
#ifndef EDITSCENE_GOAP_RUNNER_SYSTEM_HPP
#define EDITSCENE_GOAP_RUNNER_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_set>
// Forward declarations
class SmartObjectSystem;
class BehaviorTreeSystem;
class EditorApp;
class AnimationTreeSystem;
class NavMeshSystem;
/**
* System that executes GOAP plans from GoapPlannerComponent's plan queue.
*
* For each entity with GoapPlannerComponent + GoapRunnerComponent:
* 1. If no active plan, pop the cheapest plan from planner.planQueue
* 2. Normal actions: run via BehaviorTreeSystem (using ActionDebug)
* 3. Smart object actions: pathfind to smart object using NavMeshSystem,
* store path in PathFollowingComponent, then execute on arrival
* 4. Advance to next action when current completes
* 5. When plan finishes, pop next plan. If queue empty, mark planner dirty.
*/
class GoapRunnerSystem {
public:
GoapRunnerSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
SmartObjectSystem *soSystem,
BehaviorTreeSystem *btSystem,
NavMeshSystem *navSystem);
~GoapRunnerSystem();
void update(float deltaTime);
void setAnimationTreeSystem(AnimationTreeSystem *system)
{
m_animTreeSystem = system;
}
/**
* Set the EditorApp for game mode detection.
*/
void setEditorApp(EditorApp *app)
{
m_editorApp = app;
}
private:
bool startNextAction(flecs::entity e);
flecs::entity findNearestSmartObject(flecs::entity character,
const Ogre::String &actionName,
float maxDistance);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
SmartObjectSystem *m_soSystem;
BehaviorTreeSystem *m_btSystem;
NavMeshSystem *m_navSystem;
AnimationTreeSystem *m_animTreeSystem = nullptr;
EditorApp *m_editorApp = nullptr;
// Track which entities we set up ActionDebug for
std::unordered_set<flecs::entity_t> m_managedActionDebugs;
};
#endif // EDITSCENE_GOAP_RUNNER_SYSTEM_HPP

View File

@@ -0,0 +1,205 @@
#include "PathFollowingSystem.hpp"
#include "AnimationTreeSystem.hpp"
#include "NavMeshSystem.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/Character.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/Transform.hpp"
#include "../components/NavMesh.hpp"
#include <OgreLogManager.h>
#include <cmath>
PathFollowingSystem::PathFollowingSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
NavMeshSystem *navSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_navSystem(navSystem)
{
}
PathFollowingSystem::~PathFollowingSystem() = default;
Ogre::Vector3 PathFollowingSystem::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;
}
void PathFollowingSystem::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
Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
if (e.has<CharacterSlotsComponent>()) {
auto &slots = e.get<CharacterSlotsComponent>();
frontAxis = slots.frontAxis;
}
// 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));
trans.rotation = Ogre::Quaternion(yaw, Ogre::Vector3::UNIT_Y);
}
void PathFollowingSystem::applyLocomotionState(flecs::entity e)
{
if (!e.has<PathFollowingComponent>() || !m_animTreeSystem)
return;
auto &path = e.get<PathFollowingComponent>();
const PathFollowingState *state =
path.findState(path.currentLocomotionState);
if (!state)
return;
for (const auto &pair : state->stateMachineStates) {
m_animTreeSystem->setState(e, pair.first, pair.second, false);
}
}
void PathFollowingSystem::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;
});
m_world
.query<PathFollowingComponent, CharacterComponent, TransformComponent>()
.each([&](flecs::entity e, PathFollowingComponent &pf,
CharacterComponent &cc, TransformComponent &trans) {
(void)trans;
if (!pf.hasTarget)
return;
Ogre::Vector3 charPos = getEntityPosition(e);
Ogre::Vector3 targetPos = pf.targetPosition;
// Check if we've reached the destination
Ogre::Vector3 toFinal = targetPos - charPos;
toFinal.y = 0;
float distToFinal = toFinal.length();
if (distToFinal < 0.5f) {
cc.linearVelocity = Ogre::Vector3::ZERO;
pf.hasTarget = false;
pf.currentLocomotionState = "idle";
pf.path.clear();
pf.pathIndex = 0;
applyLocomotionState(e);
return;
}
// Recalculate path periodically
pf.pathRecalcTimer += deltaTime;
if (pf.pathRecalcTimer > 2.0f) {
pf.pathRecalcTimer = 0.0f;
if (navmeshEntity.is_alive() && m_navSystem) {
std::vector<Ogre::Vector3> newPath;
bool found = m_navSystem->findPath(
navmeshEntity, charPos, targetPos,
newPath);
if (found && !newPath.empty()) {
pf.path = std::move(newPath);
pf.pathIndex = 0;
}
}
}
// Fallback: if no path, go direct
if (pf.path.empty()) {
pf.path.clear();
pf.path.push_back(targetPos);
pf.pathIndex = 0;
}
// Advance waypoints
if (pf.pathIndex >= (int)pf.path.size()) {
pf.path.clear();
pf.path.push_back(targetPos);
pf.pathIndex = 0;
}
Ogre::Vector3 waypointPos = pf.path[pf.pathIndex];
Ogre::Vector3 toTarget = waypointPos - charPos;
const float WAYPOINT_THRESHOLD = 0.5f;
if (toTarget.length() < WAYPOINT_THRESHOLD) {
pf.pathIndex++;
if (pf.pathIndex >= (int)pf.path.size()) {
// Last waypoint - check if close to final target
if (distToFinal < 0.5f) {
cc.linearVelocity = Ogre::Vector3::ZERO;
pf.hasTarget = false;
pf.currentLocomotionState = "idle";
pf.path.clear();
pf.pathIndex = 0;
applyLocomotionState(e);
return;
}
// Extend with direct target
pf.path.clear();
pf.path.push_back(targetPos);
pf.pathIndex = 0;
}
waypointPos = pf.path[pf.pathIndex];
toTarget = waypointPos - charPos;
}
// Move toward waypoint
toTarget.y = 0;
if (toTarget.squaredLength() > 0.0001f) {
toTarget.normalise();
// Determine speed based on distance to final target
float speed = pf.walkSpeed;
if (distToFinal > pf.walkSpeed * 3.0f) {
speed = pf.runSpeed;
pf.currentLocomotionState = "run";
} else {
speed = pf.walkSpeed;
pf.currentLocomotionState = "walk";
}
cc.linearVelocity = toTarget * speed;
// Rotate character to face movement direction
rotateTowards(e, toTarget, deltaTime);
} else {
cc.linearVelocity = Ogre::Vector3::ZERO;
}
applyLocomotionState(e);
});
}

View File

@@ -0,0 +1,45 @@
#ifndef EDITSCENE_PATH_FOLLOWING_SYSTEM_HPP
#define EDITSCENE_PATH_FOLLOWING_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
// Forward declarations
class AnimationTreeSystem;
class NavMeshSystem;
/**
* System that follows navmesh paths and drives character movement.
*
* Reads path waypoints from PathFollowingComponent, advances waypoints,
* rotates the character toward the movement direction (Y-axis only),
* sets linear velocity for root motion, and applies animation states
* via AnimationTreeSystem.
*/
class PathFollowingSystem {
public:
PathFollowingSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
NavMeshSystem *navSystem);
~PathFollowingSystem();
void setAnimationTreeSystem(AnimationTreeSystem *system)
{
m_animTreeSystem = system;
}
void update(float deltaTime);
private:
void applyLocomotionState(flecs::entity e);
Ogre::Vector3 getEntityPosition(flecs::entity e);
void rotateTowards(flecs::entity e, const Ogre::Vector3 &direction,
float deltaTime);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
NavMeshSystem *m_navSystem;
AnimationTreeSystem *m_animTreeSystem = nullptr;
};
#endif // EDITSCENE_PATH_FOLLOWING_SYSTEM_HPP

View File

@@ -13,8 +13,7 @@ std::string PrefabSystem::getPrefabsDirectory()
return "prefabs";
}
PrefabSystem::PrefabSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
PrefabSystem::PrefabSystem(flecs::world &world, Ogre::SceneManager *sceneMgr)
: m_world(world)
, m_sceneMgr(sceneMgr)
{
@@ -35,7 +34,8 @@ void PrefabSystem::resolveInstances()
m_lastError = serializer.getLastError();
Ogre::LogManager::getSingleton().logMessage(
"PrefabSystem: Failed to instantiate '" +
prefab.prefabPath + "': " + m_lastError);
prefab.prefabPath +
"': " + m_lastError);
}
});
}
@@ -116,12 +116,12 @@ flecs::entity PrefabSystem::createInstance(const std::string &prefabPath,
transform.node->_getDerivedPosition()) +
" parent=" +
(parent.is_valid() && parent != 0 ?
std::to_string(parent.id()) :
"<root>") +
std::to_string(parent.id()) :
"<root>") +
" nodeParent=" +
(parentNode != m_sceneMgr->getRootSceneNode() ?
parentNode->getName() :
"<root>"));
parentNode->getName() :
"<root>"));
if (uiSystem)
uiSystem->addEntity(instance);
@@ -151,3 +151,24 @@ bool PrefabSystem::savePrefab(flecs::entity rootEntity,
}
return true;
}
bool PrefabSystem::deletePrefab(const std::string &prefabPath)
{
try {
if (!std::filesystem::exists(prefabPath)) {
m_lastError = "Prefab file not found: " + prefabPath;
return false;
}
if (!std::filesystem::remove(prefabPath)) {
m_lastError = "Failed to delete prefab: " + prefabPath;
return false;
}
Ogre::LogManager::getSingleton().logMessage(
"PrefabSystem: Deleted prefab '" + prefabPath + "'");
return true;
} catch (const std::exception &e) {
m_lastError =
std::string("Failed to delete prefab: ") + e.what();
return false;
}
}

View File

@@ -52,6 +52,13 @@ public:
bool savePrefab(flecs::entity rootEntity,
const std::string &prefabPath);
/**
* @brief Delete a prefab JSON file.
* @param prefabPath Path to the prefab file to delete.
* @return true on success.
*/
bool deletePrefab(const std::string &prefabPath);
/**
* @brief Get the directory where prefabs are stored.
*/

View File

@@ -31,6 +31,8 @@
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/SmartObject.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/BehaviorTree.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/NavMesh.hpp"
@@ -295,9 +297,15 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
if (entity.has<ActionDebug>()) {
json["actionDebug"] = serializeActionDebug(entity);
}
if (entity.has<PathFollowingComponent>()) {
json["pathFollowing"] = serializePathFollowing(entity);
}
if (entity.has<SmartObjectComponent>()) {
json["smartObject"] = serializeSmartObject(entity);
}
if (entity.has<GoapPlannerComponent>()) {
json["goapPlanner"] = serializeGoapPlanner(entity);
}
if (entity.has<BehaviorTreeComponent>()) {
json["behaviorTree"] = serializeBehaviorTree(entity);
}
@@ -495,9 +503,15 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
if (json.contains("actionDebug")) {
deserializeActionDebug(entity, json["actionDebug"]);
}
if (json.contains("pathFollowing")) {
deserializePathFollowing(entity, json["pathFollowing"]);
}
if (json.contains("smartObject")) {
deserializeSmartObject(entity, json["smartObject"]);
}
if (json.contains("goapPlanner")) {
deserializeGoapPlanner(entity, json["goapPlanner"]);
}
if (json.contains("behaviorTree")) {
deserializeBehaviorTree(entity, json["behaviorTree"]);
}
@@ -771,9 +785,15 @@ void SceneSerializer::deserializeEntityComponents(
if (json.contains("actionDebug")) {
deserializeActionDebug(entity, json["actionDebug"]);
}
if (json.contains("pathFollowing")) {
deserializePathFollowing(entity, json["pathFollowing"]);
}
if (json.contains("smartObject")) {
deserializeSmartObject(entity, json["smartObject"]);
}
if (json.contains("goapPlanner")) {
deserializeGoapPlanner(entity, json["goapPlanner"]);
}
if (json.contains("behaviorTree")) {
deserializeBehaviorTree(entity, json["behaviorTree"]);
}
@@ -3172,6 +3192,8 @@ static nlohmann::json serializeGoapBlackboard(const GoapBlackboard &bb)
nlohmann::json json;
json["bits"] = (uint64_t)bb.bits;
json["mask"] = (uint64_t)bb.mask;
if (bb.bitmask != ~0ULL)
json["bitmask"] = (uint64_t)bb.bitmask;
if (!bb.values.empty()) {
json["values"] = nlohmann::json::object();
for (const auto &pair : bb.values)
@@ -3200,6 +3222,7 @@ static void deserializeGoapBlackboard(GoapBlackboard &bb,
{
bb.bits = json.value("bits", (uint64_t)0);
bb.mask = json.value("mask", (uint64_t)0);
bb.bitmask = json.value("bitmask", ~0ULL);
bb.values.clear();
if (json.contains("values") && json["values"].is_object()) {
for (auto &[key, val] : json["values"].items())
@@ -3262,6 +3285,8 @@ static nlohmann::json serializeGoapAction(const GoapAction &action)
json["cost"] = action.cost;
json["preconditions"] = serializeGoapBlackboard(action.preconditions);
json["effects"] = serializeGoapBlackboard(action.effects);
if (action.preconditionMask != ~0ULL)
json["preconditionMask"] = action.preconditionMask;
json["behaviorTree"] = serializeBehaviorTreeNode(action.behaviorTree);
if (!action.behaviorTreeName.empty())
json["behaviorTreeName"] = action.behaviorTreeName;
@@ -3278,6 +3303,7 @@ static void deserializeGoapAction(GoapAction &action,
json["preconditions"]);
if (json.contains("effects"))
deserializeGoapBlackboard(action.effects, json["effects"]);
action.preconditionMask = json.value("preconditionMask", ~0ULL);
if (json.contains("behaviorTree"))
deserializeBehaviorTreeNode(action.behaviorTree,
json["behaviorTree"]);
@@ -3377,10 +3403,15 @@ nlohmann::json SceneSerializer::serializeActionDebug(flecs::entity entity)
json["selectedActionName"] = debug.selectedActionName;
if (!debug.selectedGoalName.empty())
json["selectedGoalName"] = debug.selectedGoalName;
return json;
}
// Serialize path following animation states
nlohmann::json SceneSerializer::serializePathFollowing(flecs::entity entity)
{
const PathFollowingComponent &pf = entity.get<PathFollowingComponent>();
nlohmann::json json;
json["pathFollowingStates"] = nlohmann::json::array();
for (const auto &pfState : debug.pathFollowingStates) {
for (const auto &pfState : pf.pathFollowingStates) {
nlohmann::json entry;
entry["name"] = pfState.name;
entry["stateMachineStates"] = nlohmann::json::array();
@@ -3392,26 +3423,30 @@ nlohmann::json SceneSerializer::serializeActionDebug(flecs::entity entity)
}
json["pathFollowingStates"].push_back(entry);
}
json["walkSpeed"] = debug.walkSpeed;
json["runSpeed"] = debug.runSpeed;
json["useRootMotion"] = debug.useRootMotion;
json["walkSpeed"] = pf.walkSpeed;
json["runSpeed"] = pf.runSpeed;
json["useRootMotion"] = pf.useRootMotion;
return json;
}
void SceneSerializer::deserializeActionDebug(flecs::entity entity,
const nlohmann::json &json)
const nlohmann::json &json)
{
ActionDebug debug;
if (json.contains("blackboard"))
deserializeGoapBlackboard(debug.blackboard, json["blackboard"]);
debug.selectedActionName = json.value("selectedActionName", "");
debug.selectedGoalName = json.value("selectedGoalName", "");
entity.set<ActionDebug>(debug);
}
// Deserialize path following animation states
void SceneSerializer::deserializePathFollowing(flecs::entity entity,
const nlohmann::json &json)
{
PathFollowingComponent pf;
if (json.contains("pathFollowingStates") &&
json["pathFollowingStates"].is_array()) {
debug.pathFollowingStates.clear();
pf.pathFollowingStates.clear();
for (const auto &entry : json["pathFollowingStates"]) {
PathFollowingState pfState;
pfState.name = entry.value("name", "");
@@ -3427,12 +3462,12 @@ void SceneSerializer::deserializeActionDebug(flecs::entity entity,
{ sm, state });
}
}
debug.pathFollowingStates.push_back(pfState);
pf.pathFollowingStates.push_back(pfState);
}
} else if (json.contains("animStates") &&
json["animStates"].is_array()) {
// Backward compatibility: old animStates format
debug.pathFollowingStates.clear();
pf.pathFollowingStates.clear();
for (const auto &entry : json["animStates"]) {
PathFollowingState pfState;
pfState.name = entry.value("name", "");
@@ -3445,40 +3480,20 @@ void SceneSerializer::deserializeActionDebug(flecs::entity entity,
pfState.stateMachineStates.push_back({ mainSM, subSM });
pfState.stateMachineStates.push_back(
{ subSM, stateName });
debug.pathFollowingStates.push_back(pfState);
pf.pathFollowingStates.push_back(pfState);
}
} else {
// Backward compatibility: old individual fields format
debug.pathFollowingStates.clear();
Ogre::String sm =
json.value("locomotionStateMachine", "Locomotion");
{
PathFollowingState idle("idle");
idle.stateMachineStates.push_back({ "main", sm });
idle.stateMachineStates.push_back(
{ sm, json.value("idleStateName", "Idle") });
debug.pathFollowingStates.push_back(idle);
}
{
PathFollowingState walk("walk");
walk.stateMachineStates.push_back({ "main", sm });
walk.stateMachineStates.push_back(
{ sm, json.value("walkStateName", "Walk") });
debug.pathFollowingStates.push_back(walk);
}
{
PathFollowingState run("run");
run.stateMachineStates.push_back({ "main", sm });
run.stateMachineStates.push_back(
{ sm, json.value("runStateName", "Run") });
debug.pathFollowingStates.push_back(run);
}
// Default states
pf.pathFollowingStates.clear();
pf.pathFollowingStates.push_back(PathFollowingState("idle"));
pf.pathFollowingStates.push_back(PathFollowingState("walk"));
pf.pathFollowingStates.push_back(PathFollowingState("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);
pf.walkSpeed = json.value("walkSpeed", 2.5f);
pf.runSpeed = json.value("runSpeed", 5.0f);
pf.useRootMotion = json.value("useRootMotion", true);
entity.set<PathFollowingComponent>(pf);
}
nlohmann::json SceneSerializer::serializeSmartObject(flecs::entity entity)
@@ -3507,6 +3522,45 @@ void SceneSerializer::deserializeSmartObject(flecs::entity entity,
entity.set<SmartObjectComponent>(so);
}
nlohmann::json SceneSerializer::serializeGoapPlanner(flecs::entity entity)
{
const GoapPlannerComponent &planner = entity.get<GoapPlannerComponent>();
nlohmann::json json;
json["actionNames"] = planner.actionNames;
if (!planner.goalNames.empty())
json["goalNames"] = planner.goalNames;
json["smartObjectDistance"] = planner.smartObjectDistance;
json["includeSmartObjects"] = planner.includeSmartObjects;
if (!planner.actionDatabaseRef.empty())
json["actionDatabaseRef"] = planner.actionDatabaseRef;
return json;
}
void SceneSerializer::deserializeGoapPlanner(flecs::entity entity,
const nlohmann::json &json)
{
GoapPlannerComponent planner;
if (json.contains("actionNames") && json["actionNames"].is_array()) {
planner.actionNames.clear();
for (const auto &name : json["actionNames"]) {
if (name.is_string())
planner.actionNames.push_back(name);
}
}
if (json.contains("goalNames") && json["goalNames"].is_array()) {
planner.goalNames.clear();
for (const auto &name : json["goalNames"]) {
if (name.is_string())
planner.goalNames.push_back(name);
}
}
planner.smartObjectDistance = json.value("smartObjectDistance", 50.0f);
planner.includeSmartObjects = json.value("includeSmartObjects", true);
planner.actionDatabaseRef = json.value("actionDatabaseRef", "");
planner.planDirty = true;
entity.set<GoapPlannerComponent>(planner);
}
nlohmann::json SceneSerializer::serializeBehaviorTree(flecs::entity entity)
{
const BehaviorTreeComponent &bt = entity.get<BehaviorTreeComponent>();

View File

@@ -208,14 +208,20 @@ private:
// AI/GOAP serialization
nlohmann::json serializeActionDatabase(flecs::entity entity);
nlohmann::json serializeActionDebug(flecs::entity entity);
nlohmann::json serializePathFollowing(flecs::entity entity);
nlohmann::json serializeSmartObject(flecs::entity entity);
nlohmann::json serializeGoapPlanner(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 deserializePathFollowing(flecs::entity entity,
const nlohmann::json &json);
void deserializeSmartObject(flecs::entity entity,
const nlohmann::json &json);
void deserializeGoapPlanner(flecs::entity entity,
const nlohmann::json &json);
void deserializeBehaviorTree(flecs::entity entity,
const nlohmann::json &json);

View File

@@ -6,6 +6,7 @@
#include "../components/GoapBlackboard.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/NavMesh.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/PlayerController.hpp"
@@ -115,11 +116,11 @@ void SmartObjectSystem::setLocomotionState(flecs::entity e,
if (!e.has<AnimationTreeComponent>())
return;
// Look up the path following state in ActionDebug
if (e.has<ActionDebug>()) {
auto &debug = e.get<ActionDebug>();
// Look up the path following state in PathFollowingComponent
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get<PathFollowingComponent>();
const PathFollowingState *pfState =
debug.findPathState(animName);
pf.findState(animName);
if (pfState) {
// Apply ALL state machine name -> state name pairs
for (const auto &pair : pfState->stateMachineStates) {
@@ -174,6 +175,16 @@ bool SmartObjectSystem::testSmartObjectAction(flecs::entity character,
return true;
}
bool SmartObjectSystem::isIdle(flecs::entity character) const
{
if (!character.is_alive())
return true;
auto it = m_states.find(character.id());
if (it == m_states.end())
return true;
return it->second.state == State::Idle;
}
void SmartObjectSystem::update(float deltaTime)
{
// Find the navmesh entity
@@ -550,16 +561,16 @@ void SmartObjectSystem::update(float deltaTime)
float speed = 2.5f; // walk speed
// Use run speed if far away
if (e.has<ActionDebug>()) {
auto &debug =
e.get<ActionDebug>();
if (e.has<PathFollowingComponent>()) {
auto &pf =
e.get<PathFollowingComponent>();
if (distToTarget >
debug.walkSpeed * 3.0f) {
speed = debug.runSpeed;
pf.walkSpeed * 3.0f) {
speed = pf.runSpeed;
setLocomotionState(
e, "run");
} else {
speed = debug.walkSpeed;
speed = pf.walkSpeed;
setLocomotionState(
e, "walk");
}
@@ -919,14 +930,19 @@ void SmartObjectSystem::update(float deltaTime)
float distToTarget =
(charPos - objPos).length();
float speed = debug.walkSpeed;
if (distToTarget >
debug.walkSpeed * 3.0f) {
speed = debug.runSpeed;
setLocomotionState(e, "run");
float speed = 2.5f;
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get<PathFollowingComponent>();
speed = pf.walkSpeed;
if (distToTarget >
pf.walkSpeed * 3.0f) {
speed = pf.runSpeed;
setLocomotionState(e, "run");
} else {
speed = pf.walkSpeed;
setLocomotionState(e, "walk");
}
} else {
speed = debug.walkSpeed;
setLocomotionState(e, "walk");
}

View File

@@ -65,6 +65,13 @@ public:
flecs::entity smartObject,
const Ogre::String &actionName);
/**
* Check if a character's smart object interaction is idle.
* Returns true if the character is not currently pathfinding,
* moving, or executing a smart object action.
*/
bool isIdle(flecs::entity character) const;
private:
struct SmartObjectTarget {
flecs::entity smartObject;

View File

@@ -173,6 +173,39 @@ void ActionDatabaseEditor::renderActionEditor(GoapAction &action)
ImGui::TreePop();
}
// Precondition mask editor
ImGui::Separator();
ImGui::Text("Precondition Mask (bits to check)");
for (int i = 0; i < 64; i += 8) {
for (int j = 0; j < 8 && (i + j) < 64; j++) {
int bit = i + j;
bool enabled = (action.preconditionMask >>
bit) & 1ULL;
char label[8];
snprintf(label, sizeof(label), "%d", bit);
if (j > 0)
ImGui::SameLine();
if (ImGui::Checkbox(label, &enabled)) {
if (enabled)
action.preconditionMask |=
(1ULL << bit);
else
action.preconditionMask &=
~(1ULL << bit);
}
}
}
ImGui::Text("Mask: 0x%016llX",
(unsigned long long)action.preconditionMask);
ImGui::SameLine();
if (ImGui::SmallButton("Set All##mask")) {
action.preconditionMask = ~0ULL;
}
ImGui::SameLine();
if (ImGui::SmallButton("Clear All##mask")) {
action.preconditionMask = 0ULL;
}
if (ImGui::TreeNode("Effects")) {
GoapBlackboardEditor::render(action.effects, "effects");
ImGui::TreePop();

View File

@@ -34,14 +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 (ImGui::CollapsingHeader("Planner",
ImGuiTreeNodeFlags_DefaultOpen))
renderPlanner(entity, debug);
if (!debug.lastResult.empty()) {
ImGui::Separator();
ImGui::Text("Last result: %s", debug.lastResult.c_str());
@@ -151,122 +151,6 @@ void ActionDebugEditor::renderGoalTester(flecs::entity entity,
ImGui::Unindent();
}
void ActionDebugEditor::renderAnimationConfig(ActionDebug &debug)
{
ImGui::Text("Path Following Animation States:");
ImGui::TextDisabled(
"Each state (idle/walk/run/swim-idle/swim/swim-fast) has unlimited\n"
"state machine name -> state name pairs applied when activated.");
ImGui::Separator();
// Render each path following state
int removeStateIdx = -1;
for (int i = 0; i < (int)debug.pathFollowingStates.size(); i++) {
auto &pfState = debug.pathFollowingStates[i];
ImGui::PushID(i);
char buf[256];
ImGui::Text("State %d:", i);
ImGui::Indent();
// Logical name (e.g. "idle", "walk", "run")
strncpy(buf, pfState.name.c_str(), sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
if (ImGui::InputText("Name", buf, sizeof(buf))) {
pfState.name = buf;
}
ImGui::Text("State Machine -> State pairs:");
ImGui::Indent();
// Render each name-value pair
int removePairIdx = -1;
for (int j = 0; j < (int)pfState.stateMachineStates.size();
j++) {
auto &pair = pfState.stateMachineStates[j];
ImGui::PushID(j);
char smBuf[128];
char stateBuf[128];
strncpy(smBuf, pair.first.c_str(), sizeof(smBuf) - 1);
smBuf[sizeof(smBuf) - 1] = '\0';
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##sm", smBuf, sizeof(smBuf))) {
pair.first = smBuf;
}
ImGui::SameLine();
ImGui::Text("->");
ImGui::SameLine();
strncpy(stateBuf, pair.second.c_str(),
sizeof(stateBuf) - 1);
stateBuf[sizeof(stateBuf) - 1] = '\0';
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##state", stateBuf,
sizeof(stateBuf))) {
pair.second = stateBuf;
}
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
removePairIdx = j;
}
ImGui::PopID();
}
if (removePairIdx >= 0) {
pfState.stateMachineStates.erase(
pfState.stateMachineStates.begin() +
removePairIdx);
}
if (ImGui::SmallButton("+ Add Pair")) {
pfState.stateMachineStates.push_back(
{ "main", "Idle" });
}
ImGui::Unindent();
// Remove state button
if (debug.pathFollowingStates.size() > 1) {
if (ImGui::SmallButton("Remove State")) {
removeStateIdx = i;
}
}
ImGui::Unindent();
ImGui::Separator();
ImGui::PopID();
}
if (removeStateIdx >= 0) {
debug.pathFollowingStates.erase(
debug.pathFollowingStates.begin() + removeStateIdx);
}
// Add new state button
if (ImGui::Button("Add Path Following State")) {
debug.pathFollowingStates.push_back(
PathFollowingState("new_state"));
}
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)
@@ -339,3 +223,113 @@ void ActionDebugEditor::renderSmartObjectTester(flecs::entity entity,
ImGui::Unindent();
}
void ActionDebugEditor::renderPlanner(flecs::entity entity,
ActionDebug &debug)
{
(void)debug;
if (!entity.has<GoapPlannerComponent>()) {
ImGui::TextDisabled(
"No GoapPlannerComponent found on this entity.");
return;
}
auto &planner = entity.get_mut<GoapPlannerComponent>();
// Status
const char *statusStr = "Idle";
ImVec4 statusColor(0.7f, 0.7f, 0.7f, 1.0f);
switch (planner.status) {
case GoapPlannerComponent::Status::Idle:
statusStr = "Idle";
break;
case GoapPlannerComponent::Status::Planning:
statusStr = "Planning...";
statusColor = ImVec4(1.0f, 0.8f, 0.2f, 1.0f);
break;
case GoapPlannerComponent::Status::PlansAvailable:
statusStr = "Plans Available";
statusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
break;
case GoapPlannerComponent::Status::NoPlanFound:
statusStr = "No Plan Found";
statusColor = ImVec4(1.0f, 0.3f, 0.3f, 1.0f);
break;
}
ImGui::Text("Status: ");
ImGui::SameLine();
ImGui::TextColored(statusColor, "%s", statusStr);
if (planner.status == GoapPlannerComponent::Status::Planning) {
ImGui::Text("Nodes explored: %d",
planner.nodesExplored);
}
if (!planner.currentGoalName.empty()) {
ImGui::Text("Current Goal: %s",
planner.currentGoalName.c_str());
}
ImGui::Separator();
// Display plan queue
if (planner.planQueue.empty()) {
ImGui::TextDisabled("No plans in queue.");
} else {
ImGui::Text("Plan Queue (%zu plans):",
planner.planQueue.size());
ImGui::Indent();
ActionDatabase *db = findDatabase(entity);
for (int p = 0; p < (int)planner.planQueue.size(); p++) {
const auto &plan = planner.planQueue[p];
ImGui::Text("Plan %d (cost: %d, goal: %s):",
p + 1, plan.totalCost,
plan.goalName.c_str());
ImGui::Indent();
for (int i = 0;
i < (int)plan.actions.size(); i++) {
const auto &actionName = plan.actions[i];
bool isSmartObjectAction = false;
if (db) {
const GoapAction *action =
db->findAction(actionName);
if (!action)
isSmartObjectAction = true;
}
if (isSmartObjectAction) {
ImGui::TextColored(
ImVec4(0.3f, 0.8f, 1.0f,
1.0f),
"%d. %s [SO]", i + 1,
actionName.c_str());
} else {
ImGui::Text("%d. %s", i + 1,
actionName.c_str());
}
}
ImGui::Unindent();
}
ImGui::Unindent();
}
ImGui::Separator();
// Trigger replan button
if (ImGui::Button("Force Replan")) {
planner.planDirty = true;
}
// Show selected planner actions
ImGui::Separator();
ImGui::Text("Planner Action Pool (%zu):",
planner.actionNames.size());
ImGui::Indent();
for (const auto &name : planner.actionNames) {
ImGui::Text("%s", name.c_str());
}
ImGui::Unindent();
}

View File

@@ -6,6 +6,7 @@
#include "../components/ActionDebug.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/SmartObject.hpp"
#include "../components/GoapPlanner.hpp"
/**
* Editor for ActionDebug component.
@@ -28,8 +29,8 @@ 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);
void renderPlanner(flecs::entity entity, ActionDebug &debug);
ActionDatabase *findDatabase(flecs::entity entity);
};

View File

@@ -0,0 +1,296 @@
#include "GoapPlannerEditor.hpp"
#include "../components/EntityName.hpp"
#include <imgui.h>
ActionDatabase *GoapPlannerEditor::findDatabase(
flecs::entity entity, GoapPlannerComponent &planner)
{
auto world = entity.world();
// 1. Try to find by referenced entity name
if (!planner.actionDatabaseRef.empty()) {
ActionDatabase *found = nullptr;
world.query<ActionDatabase>()
.each([&](flecs::entity dbEntity, ActionDatabase &db) {
if (found)
return;
if (dbEntity.has<EntityNameComponent>()) {
auto &name =
dbEntity.get<EntityNameComponent>();
if (name.name ==
planner.actionDatabaseRef)
found = &db;
}
});
if (found)
return found;
}
// 2. Fall back to any ActionDatabase in the world
ActionDatabase *db = nullptr;
world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
return db;
}
bool GoapPlannerEditor::renderComponent(flecs::entity entity,
GoapPlannerComponent &planner)
{
bool modified = false;
ImGui::PushID("GoapPlanner");
// External ActionDatabase reference
char refBuf[256];
snprintf(refBuf, sizeof(refBuf), "%s",
planner.actionDatabaseRef.c_str());
if (ImGui::InputText("Action Database Ref", refBuf,
sizeof(refBuf))) {
planner.actionDatabaseRef = refBuf;
modified = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Name of the entity that owns the ActionDatabase. "
"Used to resolve action and goal names when editing prefabs.");
}
ImGui::Separator();
// Smart object settings
if (ImGui::DragFloat("Smart Object Distance",
&planner.smartObjectDistance, 0.5f,
1.0f, 500.0f)) {
modified = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Maximum distance to search for smart objects "
"with matching actions");
}
if (ImGui::Checkbox("Include Smart Objects",
&planner.includeSmartObjects)) {
modified = true;
}
ImGui::Separator();
// Action and Goal selectors
ActionDatabase *db = findDatabase(entity, planner);
renderActionSelector(planner, db);
ImGui::Separator();
renderGoalSelector(planner, db);
ImGui::PopID();
return modified;
}
void GoapPlannerEditor::renderActionSelector(
GoapPlannerComponent &planner, ActionDatabase *db)
{
ImGui::Text("Selected Actions (%zu):",
planner.actionNames.size());
// Show currently selected actions with remove buttons
int removeIdx = -1;
for (int i = 0; i < (int)planner.actionNames.size(); i++) {
ImGui::PushID(i);
ImGui::Text("%s", planner.actionNames[i].c_str());
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
removeIdx = i;
}
ImGui::PopID();
}
if (removeIdx >= 0) {
planner.actionNames.erase(
planner.actionNames.begin() + removeIdx);
planner.planDirty = true;
}
if (planner.actionNames.empty()) {
ImGui::TextDisabled("No actions selected");
}
ImGui::Separator();
// Add actions from database
if (!db) {
ImGui::TextDisabled(
"No ActionDatabase found. Add actions manually:");
static char manualAction[256] = { 0 };
if (ImGui::InputText("Action Name", manualAction,
sizeof(manualAction),
ImGuiInputTextFlags_EnterReturnsTrue)) {
if (manualAction[0] != '\0') {
planner.actionNames.push_back(manualAction);
planner.planDirty = true;
manualAction[0] = '\0';
}
}
return;
}
ImGui::Text("Available Actions from Database:");
ImGui::Indent();
for (const auto &action : db->actions) {
bool alreadySelected = false;
for (const auto &name : planner.actionNames) {
if (name == action.name) {
alreadySelected = true;
break;
}
}
ImGui::PushID(action.name.c_str());
if (alreadySelected) {
ImGui::BeginDisabled();
}
if (ImGui::Button("+")) {
planner.actionNames.push_back(action.name);
planner.planDirty = true;
}
if (alreadySelected) {
ImGui::EndDisabled();
}
ImGui::SameLine();
ImGui::Text("%s (cost: %d)", action.name.c_str(),
action.cost);
ImGui::PopID();
}
ImGui::Unindent();
}
void GoapPlannerEditor::renderGoalSelector(
GoapPlannerComponent &planner, ActionDatabase *db)
{
ImGui::Text("Selected Goals (%zu):",
planner.goalNames.size());
// Show currently selected goals with remove buttons
int removeIdx = -1;
for (int i = 0; i < (int)planner.goalNames.size(); i++) {
ImGui::PushID(i + 10000);
ImGui::Text("%s", planner.goalNames[i].c_str());
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
removeIdx = i;
}
ImGui::PopID();
}
if (removeIdx >= 0) {
planner.goalNames.erase(
planner.goalNames.begin() + removeIdx);
planner.planDirty = true;
}
if (planner.goalNames.empty()) {
ImGui::TextDisabled("No goals selected");
}
ImGui::Separator();
// Add goals from database
if (!db) {
ImGui::TextDisabled(
"No ActionDatabase found. Add goals manually:");
static char manualGoal[256] = { 0 };
if (ImGui::InputText("Goal Name", manualGoal,
sizeof(manualGoal),
ImGuiInputTextFlags_EnterReturnsTrue)) {
if (manualGoal[0] != '\0') {
planner.goalNames.push_back(manualGoal);
planner.planDirty = true;
manualGoal[0] = '\0';
}
}
return;
}
ImGui::Text("Available Goals from Database:");
ImGui::Indent();
for (const auto &goal : db->goals) {
bool alreadySelected = false;
for (const auto &name : planner.goalNames) {
if (name == goal.name) {
alreadySelected = true;
break;
}
}
ImGui::PushID(goal.name.c_str());
if (alreadySelected) {
ImGui::BeginDisabled();
}
if (ImGui::Button("+")) {
planner.goalNames.push_back(goal.name);
planner.planDirty = true;
}
if (alreadySelected) {
ImGui::EndDisabled();
}
ImGui::SameLine();
ImGui::Text("%s (prio: %d)", goal.name.c_str(),
goal.priority);
ImGui::PopID();
}
ImGui::Unindent();
}
void GoapPlannerEditor::renderPreconditionMaskEditor(
GoapAction &action)
{
ImGui::Separator();
ImGui::Text("Precondition Mask (bit filter)");
ImGui::TextDisabled(
"Only bits set here are checked during planning.");
for (int i = 0; i < 64; i += 8) {
for (int j = 0; j < 8 && (i + j) < 64; j++) {
int bit = i + j;
bool enabled = (action.preconditionMask >>
bit) & 1ULL;
char label[8];
snprintf(label, sizeof(label), "%d", bit);
if (j > 0)
ImGui::SameLine();
if (ImGui::Checkbox(label, &enabled)) {
if (enabled)
action.preconditionMask |= (1ULL << bit);
else
action.preconditionMask &=
~(1ULL << bit);
}
}
}
ImGui::Text("Mask: 0x%016llX",
(unsigned long long)action.preconditionMask);
ImGui::SameLine();
if (ImGui::SmallButton("Set All")) {
action.preconditionMask = ~0ULL;
}
ImGui::SameLine();
if (ImGui::SmallButton("Clear All")) {
action.preconditionMask = 0ULL;
}
}

View File

@@ -0,0 +1,37 @@
#ifndef EDITSCENE_GOAP_PLANNER_EDITOR_HPP
#define EDITSCENE_GOAP_PLANNER_EDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/ActionDatabase.hpp"
/**
* Editor for GoapPlannerComponent.
*
* Allows selecting actions from an ActionDatabase,
* configuring smart-object distance, and referencing
* an external ActionDatabase entity.
*/
class GoapPlannerEditor : public ComponentEditor<GoapPlannerComponent> {
public:
const char *getName() const override
{
return "GOAP Planner";
}
protected:
bool renderComponent(flecs::entity entity,
GoapPlannerComponent &planner) override;
private:
ActionDatabase *findDatabase(flecs::entity entity,
GoapPlannerComponent &planner);
void renderActionSelector(GoapPlannerComponent &planner,
ActionDatabase *db);
void renderGoalSelector(GoapPlannerComponent &planner,
ActionDatabase *db);
void renderPreconditionMaskEditor(GoapAction &action);
};
#endif // EDITSCENE_GOAP_PLANNER_EDITOR_HPP

View File

@@ -0,0 +1,99 @@
#include "GoapRunnerEditor.hpp"
#include "../components/GoapPlanner.hpp"
#include <imgui.h>
const char *GoapRunnerEditor::stateName(GoapRunnerComponent::State state)
{
switch (state) {
case GoapRunnerComponent::State::Idle:
return "Idle";
case GoapRunnerComponent::State::RunningAction:
return "Running Action";
case GoapRunnerComponent::State::MovingToSmartObject:
return "Moving to Smart Object";
case GoapRunnerComponent::State::ExecutingSmartObject:
return "Executing Smart Object";
case GoapRunnerComponent::State::PlanComplete:
return "Plan Complete";
}
return "Unknown";
}
bool GoapRunnerEditor::renderComponent(flecs::entity entity,
GoapRunnerComponent &runner)
{
bool modified = false;
ImGui::PushID("GoapRunner");
ImGui::Text("State: %s", stateName(runner.state));
ImGui::Text("Action Index: %d", runner.currentActionIndex);
if (!runner.currentActionName.empty()) {
ImGui::Text("Current Action: %s",
runner.currentActionName.c_str());
}
if (runner.targetSmartObjectId != 0) {
ImGui::Text("Target Smart Object: %llu",
(unsigned long long)runner.targetSmartObjectId);
}
if (ImGui::Checkbox("Auto Replan", &runner.autoReplan))
modified = true;
ImGui::Separator();
// Display active plan
if (!runner.planActions.empty()) {
ImGui::Text("Active Plan (%zu actions):",
runner.planActions.size());
ImGui::Indent();
for (int i = 0; i < (int)runner.planActions.size(); i++) {
bool isCurrent = (i == runner.currentActionIndex);
if (isCurrent) {
ImGui::TextColored(ImVec4(0, 1, 0, 1),
"-> %d. %s", i + 1,
runner.planActions[i].c_str());
} else {
ImGui::Text(" %d. %s", i + 1,
runner.planActions[i].c_str());
}
}
ImGui::Unindent();
} else {
ImGui::TextDisabled("No active plan.");
}
// Display plan queue
if (entity.has<GoapPlannerComponent>()) {
auto &planner = entity.get<GoapPlannerComponent>();
if (!planner.planQueue.empty()) {
ImGui::Separator();
ImGui::Text("Plan Queue (%zu plans):",
planner.planQueue.size());
ImGui::Indent();
for (int p = 0;
p < (int)planner.planQueue.size(); p++) {
const auto &plan = planner.planQueue[p];
ImGui::Text("Plan %d: cost=%d, %zu actions",
p + 1, plan.totalCost,
plan.actions.size());
}
ImGui::Unindent();
}
}
// Manual controls
if (runner.state != GoapRunnerComponent::State::Idle) {
ImGui::Separator();
if (ImGui::Button("Stop")) {
runner.state = GoapRunnerComponent::State::Idle;
runner.currentActionIndex = 0;
runner.currentActionName.clear();
runner.targetSmartObjectId = 0;
runner.planActions.clear();
modified = true;
}
}
ImGui::PopID();
return modified;
}

View File

@@ -0,0 +1,26 @@
#ifndef EDITSCENE_GOAP_RUNNER_EDITOR_HPP
#define EDITSCENE_GOAP_RUNNER_EDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/GoapRunner.hpp"
/**
* Editor for GoapRunnerComponent.
*/
class GoapRunnerEditor : public ComponentEditor<GoapRunnerComponent> {
public:
const char *getName() const override
{
return "GOAP Runner";
}
protected:
bool renderComponent(flecs::entity entity,
GoapRunnerComponent &runner) override;
private:
const char *stateName(GoapRunnerComponent::State state);
};
#endif // EDITSCENE_GOAP_RUNNER_EDITOR_HPP

View File

@@ -0,0 +1,103 @@
#include "PathFollowingEditor.hpp"
#include <imgui.h>
bool PathFollowingEditor::renderComponent(flecs::entity entity,
PathFollowingComponent &pf)
{
(void)entity;
bool modified = false;
ImGui::PushID("PathFollowing");
// Current state display
ImGui::Text("Current State: %s", pf.currentLocomotionState.c_str());
ImGui::Separator();
// Path following states
ImGui::Text("Animation States:");
int removeStateIdx = -1;
for (int i = 0; i < (int)pf.pathFollowingStates.size(); i++) {
auto &pfState = pf.pathFollowingStates[i];
ImGui::PushID(i);
char buf[256];
snprintf(buf, sizeof(buf), "%s", pfState.name.c_str());
if (ImGui::InputText("Name", buf, sizeof(buf))) {
pfState.name = buf;
modified = true;
}
ImGui::Indent();
int removePairIdx = -1;
for (int j = 0; j < (int)pfState.stateMachineStates.size(); j++) {
auto &pair = pfState.stateMachineStates[j];
ImGui::PushID(j);
char smBuf[128];
char stateBuf[128];
snprintf(smBuf, sizeof(smBuf), "%s", pair.first.c_str());
snprintf(stateBuf, sizeof(stateBuf), "%s",
pair.second.c_str());
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##sm", smBuf, sizeof(smBuf))) {
pair.first = smBuf;
modified = true;
}
ImGui::SameLine();
ImGui::Text("->");
ImGui::SameLine();
ImGui::SetNextItemWidth(120.0f);
if (ImGui::InputText("##state", stateBuf,
sizeof(stateBuf))) {
pair.second = stateBuf;
modified = true;
}
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
removePairIdx = j;
}
ImGui::PopID();
}
if (removePairIdx >= 0) {
pfState.stateMachineStates.erase(
pfState.stateMachineStates.begin() +
removePairIdx);
modified = true;
}
if (ImGui::SmallButton("+ Add Pair")) {
pfState.stateMachineStates.push_back({ "main", "Idle" });
modified = true;
}
if (pf.pathFollowingStates.size() > 1) {
if (ImGui::SmallButton("Remove State")) {
removeStateIdx = i;
modified = true;
}
}
ImGui::Unindent();
ImGui::Separator();
ImGui::PopID();
}
if (removeStateIdx >= 0) {
pf.pathFollowingStates.erase(
pf.pathFollowingStates.begin() + removeStateIdx);
}
if (ImGui::Button("Add State")) {
pf.pathFollowingStates.push_back(PathFollowingState("new_state"));
modified = true;
}
ImGui::Separator();
ImGui::DragFloat("Walk Speed", &pf.walkSpeed, 0.1f, 0.5f, 20.0f);
ImGui::DragFloat("Run Speed", &pf.runSpeed, 0.1f, 0.5f, 20.0f);
if (ImGui::Checkbox("Use Root Motion", &pf.useRootMotion))
modified = true;
ImGui::PopID();
return modified;
}

View File

@@ -0,0 +1,23 @@
#ifndef EDITSCENE_PATH_FOLLOWING_EDITOR_HPP
#define EDITSCENE_PATH_FOLLOWING_EDITOR_HPP
#pragma once
#include "ComponentEditor.hpp"
#include "../components/PathFollowing.hpp"
/**
* Editor for PathFollowingComponent.
*/
class PathFollowingEditor : public ComponentEditor<PathFollowingComponent> {
public:
const char *getName() const override
{
return "Path Following";
}
protected:
bool renderComponent(flecs::entity entity,
PathFollowingComponent &pf) override;
};
#endif // EDITSCENE_PATH_FOLLOWING_EDITOR_HPP