Compare commits

...

18 Commits

Author SHA1 Message Date
39a053d4ee Game mode API 2026-05-02 20:25:16 +03:00
c5da977857 Dualogue event API doc/samples 2026-05-02 18:25:30 +03:00
3e7b0169d5 Lua behavior tree 2026-05-01 13:54:44 +03:00
f918c5cefb Better handling of lua tasks 2026-05-01 04:02:47 +03:00
976ced3731 Lua-based behavior tree node 2026-05-01 00:31:06 +03:00
0fd8deaf53 direct save/load action database 2026-04-30 20:21:18 +03:00
4d843c18c7 Lua API 2026-04-30 19:07:35 +03:00
0ed83966da Lua action APIs 2026-04-30 10:03:56 +03:00
998984f75a Root motion fixed now 2026-04-29 18:45:37 +03:00
02fa78764a Lua API implemented 2026-04-29 14:13:50 +03:00
abe6eef6b3 Modules update 2026-04-29 12:53:13 +03:00
cca732b41b Fixes for event system 2026-04-27 20:53:04 +03:00
8507a3a501 Event system 2026-04-27 18:45:01 +03:00
b9cce0248a Labels and actuators work perfectly! 2026-04-27 09:03:47 +03:00
fa49bb5005 Actuators 2026-04-27 06:55:05 +03:00
37441aa8fd Motion fixed 2026-04-27 06:04:14 +03:00
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
126 changed files with 23436 additions and 1021 deletions

View File

@@ -69,3 +69,27 @@ material Debug/Red2
}
}
/**
* Debug material for normal visualization overlay.
* Renders on top of everything (depth_check off, depth_write off)
* Uses vertex colours so each normal line can have its own colour.
* Rendered in overlay queue to appear on top of all geometry.
*/
material Debug/NormalOverlay
{
technique
{
pass
{
lighting off
depth_check off
depth_write off
ambient 1.0 1.0 1.0 1.0
diffuse vertexcolour
specular 0.0 0.0 0.0 1.0
cull_software none
cull_hardware none
scene_blend alpha_blend
}
}
}

View File

@@ -46,9 +46,31 @@ 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/ActuatorSystem.cpp
ui/ActuatorEditor.cpp
components/ActuatorModule.cpp
systems/EventBus.cpp
systems/EventHandlerSystem.cpp
ui/EventHandlerEditor.cpp
components/EventHandlerModule.cpp
systems/PrefabSystem.cpp
ui/PrefabInstanceEditor.cpp
systems/ItemSystem.cpp
components/ItemModule.cpp
components/InventoryModule.cpp
ui/ItemEditor.cpp
ui/InventoryEditor.cpp
ui/TransformEditor.cpp
ui/RenderableEditor.cpp
ui/PhysicsColliderEditor.cpp
@@ -85,6 +107,7 @@ set(EDITSCENE_SOURCES
ui/InlineBehaviorTreeEditor.cpp
ui/NavMeshEditor.cpp
ui/ActionDatabaseEditor.cpp
ui/ActionDatabaseSingletonEditor.cpp
ui/ActionDebugEditor.cpp
ui/ComponentRegistration.cpp
components/GoapBlackboard.cpp
@@ -113,6 +136,9 @@ set(EDITSCENE_SOURCES
components/CellGrid.cpp
components/StartupMenuModule.cpp
components/PlayerControllerModule.cpp
components/DialogueComponentModule.cpp
systems/DialogueSystem.cpp
ui/DialogueEditor.cpp
components/BuoyancyInfoModule.cpp
components/WaterPhysicsModule.cpp
components/WaterPlaneModule.cpp
@@ -122,6 +148,12 @@ set(EDITSCENE_SOURCES
gizmo/Gizmo.cpp
gizmo/Cursor3D.cpp
physics/physics.cpp
lua/LuaState.cpp
lua/LuaEntityApi.cpp
lua/LuaComponentApi.cpp
lua/LuaEventApi.cpp
lua/LuaActionApi.cpp
lua/LuaBehaviorTreeApi.cpp
)
set(EDITSCENE_HEADERS
@@ -153,6 +185,9 @@ set(EDITSCENE_HEADERS
components/CellGrid.hpp
components/StartupMenu.hpp
components/PlayerController.hpp
components/DialogueComponent.hpp
systems/DialogueSystem.hpp
ui/DialogueEditor.hpp
systems/StartupMenuSystem.hpp
systems/PlayerControllerSystem.hpp
systems/EditorUISystem.hpp
@@ -173,10 +208,31 @@ 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/Actuator.hpp
ui/ActuatorEditor.hpp
systems/EventBus.hpp
components/EventHandler.hpp
systems/EventHandlerSystem.hpp
ui/EventHandlerEditor.hpp
components/PrefabInstance.hpp
ui/PrefabInstanceEditor.hpp
systems/ItemSystem.hpp
components/Item.hpp
components/Inventory.hpp
ui/ItemEditor.hpp
ui/InventoryEditor.hpp
systems/ProceduralTextureSystem.hpp
systems/StaticGeometrySystem.hpp
systems/SceneSerializer.hpp
@@ -229,6 +285,7 @@ set(EDITSCENE_HEADERS
ui/NavMeshEditor.hpp
ui/NavMeshGeometrySourceEditor.hpp
ui/ActionDatabaseEditor.hpp
ui/ActionDatabaseSingletonEditor.hpp
ui/ActionDebugEditor.hpp
components/GoapBlackboard.hpp
components/GoapExpression.hpp
@@ -242,6 +299,12 @@ set(EDITSCENE_HEADERS
gizmo/Gizmo.hpp
gizmo/Cursor3D.hpp
physics/physics.h
lua/LuaState.hpp
lua/LuaEntityApi.hpp
lua/LuaComponentApi.hpp
lua/LuaEventApi.hpp
lua/LuaActionApi.hpp
lua/LuaBehaviorTreeApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
@@ -263,6 +326,7 @@ target_link_libraries(editSceneEditor
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
lua
)
target_include_directories(editSceneEditor PRIVATE
@@ -272,8 +336,108 @@ target_include_directories(editSceneEditor PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourTileCache/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DetourCrowd/Include
${CMAKE_CURRENT_SOURCE_DIR}/recastnavigation/DebugUtils/Include
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
${CMAKE_SOURCE_DIR}/src/lua/lpeg-1.1.0
)
# ---------------------------------------------------------------------------
# Tests: Lua API standalone tests
# ---------------------------------------------------------------------------
# These standalone tests verify the Lua API functions work correctly.
# They do not require OGRE or Flecs - only Lua and the core component types.
# Test: Entity Lua API
add_executable(entity_lua_test
tests/entity_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(entity_lua_test
lua
)
target_include_directories(entity_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Component Lua API
add_executable(component_lua_test
tests/component_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(component_lua_test
lua
)
target_include_directories(component_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Event Lua API
add_executable(event_lua_test
tests/event_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(event_lua_test
lua
)
target_include_directories(event_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: ActionDatabase Lua API
add_executable(action_db_lua_test
tests/action_db_lua_test.cpp
components/ActionDatabase.cpp
components/GoapBlackboard.cpp
components/GoapGoal.cpp
components/GoapExpression.cpp
lua/LuaActionApi.cpp
)
target_link_libraries(action_db_lua_test
lua
flecs::flecs_static
nlohmann_json::nlohmann_json
)
target_include_directories(action_db_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Behavior Tree Lua API
add_executable(behavior_tree_lua_test
tests/behavior_tree_lua_test.cpp
lua/LuaBehaviorTreeApi.cpp
lua/LuaEntityApi.cpp
components/GoapBlackboard.cpp
)
target_link_libraries(behavior_tree_lua_test
lua
flecs::flecs_static
OgreMain
)
target_include_directories(behavior_tree_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
target_compile_definitions(behavior_tree_lua_test PRIVATE flecs_STATIC)
# Copy local resources (materials, etc.)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/resources")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/resources"

View File

@@ -19,11 +19,15 @@
#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"
#include "systems/RoomLayoutSystem.hpp"
#include "systems/StartupMenuSystem.hpp"
#include "systems/DialogueSystem.hpp"
#include "systems/PlayerControllerSystem.hpp"
#include "systems/SceneSerializer.hpp"
#include "camera/EditorCamera.hpp"
@@ -55,6 +59,7 @@
#include "components/AnimationTreeTemplate.hpp"
#include "components/Character.hpp"
#include "components/StartupMenu.hpp"
#include "components/DialogueComponent.hpp"
#include "components/PlayerController.hpp"
#include "components/CellGrid.hpp"
#include "components/CellGridModule.hpp"
@@ -66,9 +71,24 @@
#include "systems/PrefabSystem.hpp"
#include "components/NavMesh.hpp"
#include "components/SmartObject.hpp"
#include "components/Actuator.hpp"
#include "components/GoapPlanner.hpp"
#include "components/GoapRunner.hpp"
#include "components/PathFollowing.hpp"
#include "systems/ActuatorSystem.hpp"
#include "systems/EventHandlerSystem.hpp"
#include "systems/ItemSystem.hpp"
#include "components/EventHandler.hpp"
#include "components/Item.hpp"
#include "components/Inventory.hpp"
#include <OgreRTShaderSystem.h>
#include <imgui.h>
#include "lua/LuaEntityApi.hpp"
#include "lua/LuaComponentApi.hpp"
#include "lua/LuaEventApi.hpp"
#include "lua/LuaActionApi.hpp"
#include "lua/LuaBehaviorTreeApi.hpp"
//=============================================================================
// ImGuiRenderListener Implementation
@@ -104,6 +124,14 @@ void ImGuiRenderListener::preViewportUpdate(
m_uiSystem->update(m_deltaTime);
}
// Render actuator markers in game mode (after NewFrame so draw
// commands survive)
if (m_editorApp) {
ActuatorSystem *actuatorSys = m_editorApp->getActuatorSystem();
if (actuatorSys)
actuatorSys->render();
}
// Render startup menu in game mode (inside ImGui frame scope)
if (m_editorApp &&
m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
@@ -112,6 +140,16 @@ void ImGuiRenderListener::preViewportUpdate(
if (sms)
sms->update(m_deltaTime);
}
// Render dialogue box in game mode (inside ImGui frame scope)
if (m_editorApp &&
m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
m_editorApp->getGamePlayState() ==
EditorApp::GamePlayState::Playing) {
DialogueSystem *ds = m_editorApp->getDialogueSystem();
if (ds)
ds->update(m_deltaTime);
}
}
void ImGuiRenderListener::postViewportUpdate(
@@ -157,22 +195,27 @@ EditorApp::~EditorApp()
// This ensures all components with Ogre resources are cleaned up while SceneManager exists
// Collect entities first, then delete after iteration (can't modify during iteration)
std::vector<flecs::entity> entitiesToDelete;
m_world.query<EditorMarkerComponent>().each(
[&](flecs::entity e, EditorMarkerComponent) {
entitiesToDelete.push_back(e);
});
for (auto &e : entitiesToDelete) {
if (e.is_alive()) {
e.destruct();
}
}
// Release all systems
m_playerControllerSystem.reset();
// Destroy dialogue system before other systems
m_dialogueSystem.reset();
m_startupMenuSystem.reset();
m_characterSlotSystem.reset();
m_animationTreeSystem.reset();
m_playerControllerSystem.reset();
m_itemSystem.reset();
m_eventHandlerSystem.reset();
m_actuatorSystem.reset();
m_goapPlannerSystem.reset();
m_pathFollowingSystem.reset();
m_goapRunnerSystem.reset();
m_smartObjectSystem.reset();
m_roomLayoutSystem.reset();
m_normalDebugSystem.reset();
m_cellGridSystem.reset();
m_characterSystem.reset();
m_navMeshSystem.reset();
m_behaviorTreeSystem.reset();
m_animationTreeSystem.reset();
m_characterSlotSystem.reset();
m_proceduralMeshSystem.reset();
m_proceduralMaterialSystem.reset();
m_proceduralTextureSystem.reset();
@@ -237,6 +280,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 +379,40 @@ void EditorApp::setup()
// Wire up EditorApp for game mode detection
m_smartObjectSystem->setEditorApp(this);
// Setup Actuator system
m_actuatorSystem = std::make_unique<ActuatorSystem>(
m_world, m_sceneMgr, this, m_behaviorTreeSystem.get());
// Setup Event Handler system
m_eventHandlerSystem = std::make_unique<EventHandlerSystem>(
m_world, m_behaviorTreeSystem.get());
// Setup Item system
m_itemSystem = std::make_unique<ItemSystem>(
m_world, m_sceneMgr, this, m_behaviorTreeSystem.get());
// Wire ItemSystem into SmartObjectSystem for BT access
m_smartObjectSystem->setItemSystem(m_itemSystem.get());
// 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);
@@ -362,6 +440,8 @@ void EditorApp::setup()
// Setup game systems
m_startupMenuSystem = std::make_unique<StartupMenuSystem>(
m_world, m_sceneMgr, this);
m_dialogueSystem = std::make_unique<DialogueSystem>(
m_world, m_sceneMgr, this);
m_playerControllerSystem =
std::make_unique<PlayerControllerSystem>(
m_world, m_sceneMgr, this);
@@ -384,10 +464,11 @@ void EditorApp::setup()
}
}
// Pre-load menu font before showing overlay
// (OGRE builds the atlas in createFontTexture() during show())
// Pre-load fonts before showing overlay
if (m_startupMenuSystem)
m_startupMenuSystem->prepareFont();
if (m_dialogueSystem)
m_dialogueSystem->prepareFont();
// Now show the overlay — font atlas will be built with our font
if (m_imguiOverlay)
@@ -408,6 +489,41 @@ void EditorApp::setup()
addInputListener(this);
addInputListener(getImGuiInputListener());
// Initialize Lua scripting
{
lua_State *L = m_lua.getState();
// Store the Flecs world pointer in the Lua registry
// so Lua API functions can access it.
flecs::world *worldPtr = &m_world;
lua_pushlightuserdata(L, worldPtr);
lua_setfield(L, LUA_REGISTRYINDEX,
"EditSceneFlecsWorld");
// Register all Lua API modules.
// Order matters: Entity API creates the "ecs" table,
// Component and Event APIs add to it.
editScene::registerLuaEntityApi(L);
editScene::registerLuaComponentApi(L);
editScene::registerLuaEventApi(L);
editScene::registerLuaActionApi(L);
editScene::registerLuaBehaviorTreeApi(L);
// Run late setup: load data.lua and initial scripts.
m_lua.lateSetup();
// Auto-load Action Database from "actions.json" in the General
// resource group. If the file is missing or contains errors
// the error is logged and the database stays empty (scene
// components will still be processed below).
ActionDatabase::loadFromJson("actions.json");
// Re-process scene components so that any
// ActionDatabaseComponent entities in the scene override or
// append actions from the file.
ActionDatabase::reloadFromSceneComponents(m_world);
}
// Game mode can be set externally before setup() is called
m_setupComplete = true;
@@ -559,6 +675,7 @@ void EditorApp::setupECS()
// Register game components
m_world.component<StartupMenuComponent>();
m_world.component<DialogueComponent>();
m_world.component<PlayerControllerComponent>();
m_world.component<InWater>();
@@ -567,7 +684,7 @@ void EditorApp::setupECS()
m_world.component<SkyboxComponent>();
// Register AI/GOAP components
m_world.component<ActionDatabase>();
// ActionDatabase is now a singleton, registered in ActionDatabaseModule
m_world.component<ActionDebug>();
m_world.component<BehaviorTreeComponent>();
m_world.component<GoapBlackboard>();
@@ -575,6 +692,21 @@ void EditorApp::setupECS()
// Register Smart Object component
m_world.component<SmartObjectComponent>();
// Register Actuator component
m_world.component<ActuatorComponent>();
// Register Event Handler component
m_world.component<EventHandlerComponent>();
// 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>();
@@ -584,6 +716,10 @@ void EditorApp::setupECS()
// Register PrefabInstance component
m_world.component<PrefabInstanceComponent>();
// Register Item and Inventory components
m_world.component<ItemComponent>();
m_world.component<InventoryComponent>();
}
void EditorApp::createDefaultEntities()
@@ -733,6 +869,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 +899,26 @@ 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);
}
/* --- Actuator system (player interaction prompts) --- */
if (m_actuatorSystem) {
m_actuatorSystem->update(evt.timeSinceLastFrame);
}
/* --- Event Handler system (event-driven BTs) --- */
if (m_eventHandlerSystem) {
m_eventHandlerSystem->update(evt.timeSinceLastFrame);
}
/* --- Dynamic physics (characters after static world) --- */
if (m_characterSystem) {

View File

@@ -9,6 +9,7 @@
#include <OgreRenderTargetListener.h>
#include <flecs.h>
#include <memory>
#include "lua/LuaState.hpp"
// Forward declarations
class EditorUISystem;
@@ -29,6 +30,7 @@ class CharacterSystem;
class CellGridSystem;
class RoomLayoutSystem;
class StartupMenuSystem;
class DialogueSystem;
class PlayerControllerSystem;
class BuoyancySystem;
class EditorSunSystem;
@@ -36,6 +38,12 @@ class EditorSkyboxSystem;
class EditorWaterPlaneSystem;
class NormalDebugSystem;
class SmartObjectSystem;
class GoapRunnerSystem;
class PathFollowingSystem;
class GoapPlannerSystem;
class ActuatorSystem;
class EventHandlerSystem;
class ItemSystem;
class EditorApp;
/**
@@ -182,6 +190,18 @@ public:
{
return m_startupMenuSystem.get();
}
DialogueSystem *getDialogueSystem() const
{
return m_dialogueSystem.get();
}
ActuatorSystem *getActuatorSystem() const
{
return m_actuatorSystem.get();
}
EventHandlerSystem *getEventHandlerSystem() const
{
return m_eventHandlerSystem.get();
}
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -222,10 +242,16 @@ 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;
std::unique_ptr<ActuatorSystem> m_actuatorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<DialogueSystem> m_dialogueSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
// State
@@ -236,6 +262,9 @@ private:
bool m_setupComplete = false;
bool m_debugBuoyancy = false;
// Lua scripting
editScene::LuaState m_lua;
// Editor visualization nodes
Ogre::SceneNode *m_gridNode = nullptr;
Ogre::SceneNode *m_axisNode = nullptr;

View File

@@ -1,4 +1,32 @@
#include "ActionDatabase.hpp"
#ifndef OGRE_STUB_H
#include <OgreLogManager.h>
#include <OgreResourceGroupManager.h>
#endif
#include <flecs.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include <filesystem>
// ---------------------------------------------------------------------------
// Singleton
// ---------------------------------------------------------------------------
ActionDatabase &ActionDatabase::getSingleton()
{
static ActionDatabase instance;
return instance;
}
ActionDatabase *ActionDatabase::getSingletonPtr()
{
return &getSingleton();
}
// ---------------------------------------------------------------------------
// Find methods
// ---------------------------------------------------------------------------
const GoapAction *ActionDatabase::findAction(const Ogre::String &name) const
{
@@ -36,6 +64,36 @@ GoapGoal *ActionDatabase::findGoal(const Ogre::String &name)
return nullptr;
}
// ---------------------------------------------------------------------------
// Add or replace
// ---------------------------------------------------------------------------
void ActionDatabase::addOrReplaceAction(const GoapAction &action)
{
for (auto &a : actions) {
if (a.name == action.name) {
a = action;
return;
}
}
actions.push_back(action);
}
void ActionDatabase::addOrReplaceGoal(const GoapGoal &goal)
{
for (auto &g : goals) {
if (g.name == goal.name) {
g = goal;
return;
}
}
goals.push_back(goal);
}
// ---------------------------------------------------------------------------
// Remove methods
// ---------------------------------------------------------------------------
bool ActionDatabase::removeAction(const Ogre::String &name)
{
for (auto it = actions.begin(); it != actions.end(); ++it) {
@@ -58,8 +116,12 @@ bool ActionDatabase::removeGoal(const Ogre::String &name)
return false;
}
const GoapGoal *ActionDatabase::selectBestGoal(
const GoapBlackboard &blackboard) const
// ---------------------------------------------------------------------------
// Selection / validation
// ---------------------------------------------------------------------------
const GoapGoal *
ActionDatabase::selectBestGoal(const GoapBlackboard &blackboard) const
{
const GoapGoal *best = nullptr;
int bestPriority = -1;
@@ -75,8 +137,8 @@ const GoapGoal *ActionDatabase::selectBestGoal(
return best;
}
std::vector<const GoapAction *> ActionDatabase::getValidActions(
const GoapBlackboard &blackboard) const
std::vector<const GoapAction *>
ActionDatabase::getValidActions(const GoapBlackboard &blackboard) const
{
std::vector<const GoapAction *> result;
for (const auto &action : actions) {
@@ -85,3 +147,369 @@ std::vector<const GoapAction *> ActionDatabase::getValidActions(
}
return result;
}
// ---------------------------------------------------------------------------
// Clear
// ---------------------------------------------------------------------------
void ActionDatabase::clear()
{
actions.clear();
goals.clear();
}
// ---------------------------------------------------------------------------
// ActionDatabaseComponent
// ---------------------------------------------------------------------------
void ActionDatabaseComponent::syncToSingleton() const
{
auto &db = ActionDatabase::getSingleton();
db.clear();
for (const auto &action : actions)
db.addOrReplaceAction(action);
for (const auto &goal : goals)
db.addOrReplaceGoal(goal);
}
// ---------------------------------------------------------------------------
// JSON serialization helpers (local to this translation unit)
// ---------------------------------------------------------------------------
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)
json["values"][pair.first] = pair.second;
}
if (!bb.floatValues.empty()) {
json["floatValues"] = nlohmann::json::object();
for (const auto &pair : bb.floatValues)
json["floatValues"][pair.first] = pair.second;
}
if (!bb.vec3Values.empty()) {
json["vec3Values"] = nlohmann::json::object();
for (const auto &pair : bb.vec3Values) {
nlohmann::json v;
v.push_back(pair.second.x);
v.push_back(pair.second.y);
v.push_back(pair.second.z);
json["vec3Values"][pair.first] = v;
}
}
return json;
}
static void deserializeGoapBlackboard(GoapBlackboard &bb,
const nlohmann::json &json)
{
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())
bb.values[key] = val.get<int>();
}
bb.floatValues.clear();
if (json.contains("floatValues") && json["floatValues"].is_object()) {
for (auto &[key, val] : json["floatValues"].items())
bb.floatValues[key] = val.get<float>();
}
bb.vec3Values.clear();
if (json.contains("vec3Values") && json["vec3Values"].is_object()) {
for (auto &[key, val] : json["vec3Values"].items()) {
if (val.is_array() && val.size() >= 3)
bb.vec3Values[key] =
Ogre::Vector3(val[0].get<float>(),
val[1].get<float>(),
val[2].get<float>());
}
}
}
static nlohmann::json serializeBehaviorTreeNode(const BehaviorTreeNode &node)
{
nlohmann::json json;
json["type"] = node.type;
if (!node.name.empty())
json["name"] = node.name;
if (!node.params.empty())
json["params"] = node.params;
if (!node.children.empty()) {
json["children"] = nlohmann::json::array();
for (const auto &child : node.children)
json["children"].push_back(
serializeBehaviorTreeNode(child));
}
return json;
}
static void deserializeBehaviorTreeNode(BehaviorTreeNode &node,
const nlohmann::json &json)
{
node.type = json.value("type", "task");
node.name = json.value("name", "");
node.params = json.value("params", "");
node.children.clear();
if (json.contains("children") && json["children"].is_array()) {
for (const auto &childJson : json["children"]) {
BehaviorTreeNode child;
deserializeBehaviorTreeNode(child, childJson);
node.children.push_back(child);
}
}
}
static nlohmann::json serializeGoapAction(const GoapAction &action)
{
nlohmann::json json;
json["name"] = action.name;
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;
return json;
}
static void deserializeGoapAction(GoapAction &action,
const nlohmann::json &json)
{
action.name = json.value("name", "Unnamed");
action.cost = json.value("cost", 1);
if (json.contains("preconditions"))
deserializeGoapBlackboard(action.preconditions,
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"]);
action.behaviorTreeName = json.value("behaviorTreeName", "");
}
static nlohmann::json serializeGoapGoal(const GoapGoal &goal)
{
nlohmann::json json;
json["name"] = goal.name;
json["priority"] = goal.priority;
json["target"] = serializeGoapBlackboard(goal.target);
if (!goal.condition.empty())
json["condition"] = goal.condition;
return json;
}
static void deserializeGoapGoal(GoapGoal &goal, const nlohmann::json &json)
{
goal.name = json.value("name", "Unnamed");
goal.priority = json.value("priority", 1);
if (json.contains("target"))
deserializeGoapBlackboard(goal.target, json["target"]);
goal.condition = json.value("condition", "");
}
// ---------------------------------------------------------------------------
// saveToJson / loadFromJson
// ---------------------------------------------------------------------------
bool ActionDatabase::saveToJson(const std::string &filename)
{
try {
// Resolve the filesystem path from the "General" resource group
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
const Ogre::ResourceGroupManager::LocationList &locations =
rgm.getResourceLocationList("General");
if (locations.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::saveToJson: "
"no resource locations for group 'General'");
return false;
}
// Use the first location's path
std::string dir = locations.begin()->archive->getName();
std::string filepath = dir + "/" + filename;
// Backup existing file
if (std::filesystem::exists(filepath)) {
std::string backup = filepath + ".bak";
try {
std::filesystem::copy_file(
filepath, backup,
std::filesystem::copy_options::
overwrite_existing);
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::saveToJson: "
"backup failed: " +
Ogre::String(e.what()));
}
}
const ActionDatabase &db = getSingleton();
nlohmann::json root;
root["actions"] = nlohmann::json::array();
for (const auto &action : db.actions)
root["actions"].push_back(serializeGoapAction(action));
root["goals"] = nlohmann::json::array();
for (const auto &goal : db.goals)
root["goals"].push_back(serializeGoapGoal(goal));
// Save bit names
nlohmann::json bitNames = nlohmann::json::array();
for (int i = 0; i < 64; i++) {
const char *name = GoapBlackboard::getBitName(i);
if (name) {
nlohmann::json entry;
entry["index"] = i;
entry["name"] = name;
bitNames.push_back(entry);
}
}
if (!bitNames.empty())
root["bitNames"] = bitNames;
std::ofstream file(filepath);
if (!file.is_open()) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::saveToJson: "
"failed to open " +
filepath);
return false;
}
file << root.dump(4);
file.close();
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase saved to " + filepath);
return true;
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::saveToJson error: " +
Ogre::String(e.what()));
return false;
}
}
bool ActionDatabase::loadFromJson(const std::string &filename)
{
try {
// Resolve the filesystem path from the "General" resource group
Ogre::ResourceGroupManager &rgm =
Ogre::ResourceGroupManager::getSingleton();
const Ogre::ResourceGroupManager::LocationList &locations =
rgm.getResourceLocationList("General");
if (locations.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::loadFromJson: "
"no resource locations for group 'General'");
return false;
}
std::string dir = locations.begin()->archive->getName();
std::string filepath = dir + "/" + filename;
if (!std::filesystem::exists(filepath)) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::loadFromJson: "
"file not found: " +
filepath);
return false;
}
std::ifstream file(filepath);
if (!file.is_open()) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::loadFromJson: "
"failed to open " +
filepath);
return false;
}
nlohmann::json root;
try {
file >> root;
} catch (const nlohmann::json::parse_error &e) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::loadFromJson: "
"JSON parse error in " +
filepath + ": " + Ogre::String(e.what()));
return false;
}
file.close();
ActionDatabase &db = getSingleton();
// Load actions (add/replace)
if (root.contains("actions") && root["actions"].is_array()) {
for (const auto &actionJson : root["actions"]) {
GoapAction action;
deserializeGoapAction(action, actionJson);
db.addOrReplaceAction(action);
}
}
// Load goals (add/replace)
if (root.contains("goals") && root["goals"].is_array()) {
for (const auto &goalJson : root["goals"]) {
GoapGoal goal;
deserializeGoapGoal(goal, goalJson);
db.addOrReplaceGoal(goal);
}
}
// Load bit names
if (root.contains("bitNames") && root["bitNames"].is_array()) {
for (const auto &entry : root["bitNames"]) {
if (entry.contains("index") &&
entry.contains("name"))
GoapBlackboard::setBitName(
entry["index"].get<int>(),
entry["name"]
.get<std::string>());
}
}
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase loaded from " + filepath);
return true;
} catch (const std::exception &e) {
Ogre::LogManager::getSingleton().logMessage(
"ActionDatabase::loadFromJson error: " +
Ogre::String(e.what()));
return false;
}
}
// ---------------------------------------------------------------------------
// reloadFromSceneComponents
// ---------------------------------------------------------------------------
void ActionDatabase::reloadFromSceneComponents(flecs::world &world)
{
// First, load from file (if available) — this is done by the caller
// before calling this function. Here we just re-sync from scene
// entities so that scene-defined actions are applied on top.
// Iterate all entities with ActionDatabaseComponent
world.each([](flecs::entity e, ActionDatabaseComponent &dbComp) {
(void)e;
dbComp.syncToSingleton();
});
}

View File

@@ -5,14 +5,29 @@
#include "GoapAction.hpp"
#include "GoapGoal.hpp"
#include <vector>
#include <unordered_map>
#include <string>
// Forward declaration for reloadFromSceneComponents
namespace flecs
{
class world;
}
/**
* Global action database component.
* Global action database singleton.
*
* Holds the master list of GOAP actions and goals that characters can use.
* Typically attached to a single "game manager" entity.
* This is a singleton accessible from anywhere in the codebase.
* The ActionDatabaseComponent on a scene entity stores the actions/goals
* and syncs them to the singleton on scene load.
*/
struct ActionDatabase {
class ActionDatabase {
public:
/** Get the singleton instance */
static ActionDatabase &getSingleton();
static ActionDatabase *getSingletonPtr();
std::vector<GoapAction> actions;
std::vector<GoapGoal> goals;
@@ -24,6 +39,12 @@ struct ActionDatabase {
const GoapGoal *findGoal(const Ogre::String &name) const;
GoapGoal *findGoal(const Ogre::String &name);
// Add or replace an action by name
void addOrReplaceAction(const GoapAction &action);
// Add or replace a goal by name
void addOrReplaceGoal(const GoapGoal &goal);
// Remove an action by name
bool removeAction(const Ogre::String &name);
@@ -35,8 +56,59 @@ struct ActionDatabase {
const GoapGoal *selectBestGoal(const GoapBlackboard &blackboard) const;
// Build a list of actions that can run from a given blackboard state
std::vector<const GoapAction *> getValidActions(
const GoapBlackboard &blackboard) const;
std::vector<const GoapAction *>
getValidActions(const GoapBlackboard &blackboard) const;
// Clear all actions and goals
void clear();
/**
* Save the action database to a JSON file.
* Creates a backup of the existing file (if any) by appending ".bak".
* The file is written to the filesystem path resolved from the
* "General" resource group.
*
* @param filename The filename (e.g. "actions.json").
* @return true on success.
*/
static bool saveToJson(const std::string &filename);
/**
* Load the action database from a JSON file.
* The file is located via the "General" resource group.
* On failure (file not found, parse error) the error is logged
* and the database is left unchanged.
*
* @param filename The filename (e.g. "actions.json").
* @return true on success.
*/
static bool loadFromJson(const std::string &filename);
/**
* Re-process all ActionDatabaseComponent entities in the given
* Flecs world: clear the singleton and re-sync from every entity
* that carries the component. This is used after a reload so
* scene-defined actions are re-applied on top of the file.
*/
static void reloadFromSceneComponents(flecs::world &world);
private:
ActionDatabase() = default;
~ActionDatabase() = default;
ActionDatabase(const ActionDatabase &) = delete;
ActionDatabase &operator=(const ActionDatabase &) = delete;
};
/**
* Flecs component that stores action database data on a scene entity.
* When set on an entity, it syncs its contents to the ActionDatabase singleton.
*/
struct ActionDatabaseComponent {
std::vector<GoapAction> actions;
std::vector<GoapGoal> goals;
/** Sync this component's data to the ActionDatabase singleton */
void syncToSingleton() const;
};
#endif // EDITSCENE_ACTION_DATABASE_HPP

View File

@@ -8,21 +8,21 @@
#include "../ui/ActionDatabaseEditor.hpp"
#include "../ui/BehaviorTreeEditor.hpp"
REGISTER_COMPONENT_GROUP("Action Database", "AI", ActionDatabase,
REGISTER_COMPONENT_GROUP("Action Database", "AI", ActionDatabaseComponent,
ActionDatabaseEditor)
{
registry.registerComponent<ActionDatabase>(
registry.registerComponent<ActionDatabaseComponent>(
"Action Database", "AI",
std::make_unique<ActionDatabaseEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<ActionDatabase>())
e.set<ActionDatabase>({});
if (!e.has<ActionDatabaseComponent>())
e.set<ActionDatabaseComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<ActionDatabase>())
e.remove<ActionDatabase>();
if (e.has<ActionDatabaseComponent>())
e.remove<ActionDatabaseComponent>();
});
}
@@ -30,8 +30,7 @@ REGISTER_COMPONENT_GROUP("Behavior Tree", "AI", BehaviorTreeComponent,
BehaviorTreeEditor)
{
registry.registerComponent<BehaviorTreeComponent>(
"Behavior Tree", "AI",
std::make_unique<BehaviorTreeEditor>(),
"Behavior Tree", "AI", std::make_unique<BehaviorTreeEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<BehaviorTreeComponent>()) {

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

@@ -0,0 +1,44 @@
#ifndef EDITSCENE_ACTUATOR_HPP
#define EDITSCENE_ACTUATOR_HPP
#pragma once
#include <Ogre.h>
#include <vector>
#include <string>
/**
* Actuator component.
*
* An interactive object visible only to the player character.
* When the player is within radius + height range, an on-screen
* prompt appears and the action can be triggered with the action key.
*
* Unlike SmartObject, Actuators do not use pathfinding or path
* following — they are instantaneous interactions.
*/
struct ActuatorComponent {
// Interaction radius in XZ plane
float radius = 1.5f;
// Maximum height difference for interaction
float height = 1.8f;
// Names of GOAP actions (from ActionDatabase) that this actuator provides
std::vector<Ogre::String> actionNames;
// Runtime: cooldown timer (seconds remaining)
float cooldownTimer = 0.0f;
// Runtime: currently executing an action
bool isExecuting = false;
ActuatorComponent() = default;
explicit ActuatorComponent(float radius_, float height_)
: radius(radius_)
, height(height_)
{
}
};
#endif // EDITSCENE_ACTUATOR_HPP

View File

@@ -0,0 +1,19 @@
#include "Actuator.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/ActuatorEditor.hpp"
REGISTER_COMPONENT_GROUP("Actuator", "Game", ActuatorComponent, ActuatorEditor)
{
registry.registerComponent<ActuatorComponent>(
"Actuator", "Game", std::make_unique<ActuatorEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<ActuatorComponent>())
e.set<ActuatorComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<ActuatorComponent>())
e.remove<ActuatorComponent>();
});
}

View File

@@ -32,6 +32,33 @@
* system so physics no longer interferes with animation.
* "enablePhysics" - Leaf: re-adds character's JPH::BodyID to physics
* system to restore physics simulation.
*
* --- Item / Inventory nodes ---
* "hasItem" - Leaf check: true if character's inventory has an item
* matching the given itemId (name=itemId).
* "hasItemByName" - Leaf check: true if character's inventory has an item
* matching the given itemName (name=itemName).
* "countItem" - Leaf check: true if character's inventory has at least
* N of itemId (name=itemId, params=count as int).
* "pickupItem" - Leaf: picks up the nearest ItemComponent entity within
* range into the character's inventory.
* name=itemId filter (optional, empty = any).
* "dropItem" - Leaf: drops an item from inventory into the world.
* name=itemId, params=count (optional, default 1).
* "useItem" - Leaf: uses an item from inventory (executes its
* useAction behavior tree). name=itemId.
* "addItemToInventory"- Leaf: adds an item directly to character's inventory
* (for quest rewards, etc.).
* params="itemId,itemName,itemType,count,weight,value"
*
* --- Lua node ---
* "luaTask" - Leaf: calls a registered Lua function.
* name = registered node handler name.
* params = "key=val,key2=val2" passed to the Lua function.
* The Lua function receives (entity_id, params_table)
* and must return "success", "failure", or "running".
* Register handlers via:
* ecs.behavior_tree.register_node("name", function)
*/
struct BehaviorTreeNode {
Ogre::String type = "task";
@@ -73,7 +100,12 @@ struct BehaviorTreeNode {
type == "checkBit" || type == "setValue" ||
type == "checkValue" || type == "blackboardDump" ||
type == "delay" || type == "teleportToChild" ||
type == "disablePhysics" || type == "enablePhysics";
type == "disablePhysics" || type == "enablePhysics" ||
type == "sendEvent" || type == "hasItem" ||
type == "hasItemByName" || type == "countItem" ||
type == "pickupItem" || type == "dropItem" ||
type == "useItem" || type == "addItemToInventory" ||
type == "luaTask";
}
};

View File

@@ -37,6 +37,12 @@ struct CharacterComponent {
/* Dirty flag — triggers rebuild of the Jolt character */
bool dirty = true;
/* When true, the scene node position is driven by root motion
* (AnimationTreeSystem), not by physics. The physics character
* position is synced to match the scene node each frame, and
* physics does NOT write its position back to the scene node. */
bool useRootMotion = false;
/* Floor detection: raycast downward to find ground before enabling gravity */
bool hasFloor = false;
float floorCheckDistance = 2.0f;

View File

@@ -0,0 +1,131 @@
#ifndef EDITSCENE_DIALOGUE_COMPONENT_HPP
#define EDITSCENE_DIALOGUE_COMPONENT_HPP
#pragma once
#include <Ogre.h>
#include <functional>
#include <string>
#include <vector>
/**
* Visual-novel style dialogue box component.
*
* Displays a narration text box at the bottom of the screen with optional
* player choices. The dialogue can be driven via the EventBus system
* (using "dialogue_show" event) or directly via the component API.
*
* Only active in game mode (GamePlayState::Playing).
*
* Event payload (GoapBlackboard) parameters:
* "text" (string) - Narration text to display
* "choices" (string) - Comma-separated list of choice labels
* "speaker" (string) - Optional speaker name
* "auto_progress" (int) - If 1, clicking anywhere progresses (no choices)
*
* Component state transitions:
* Idle -> Showing (on show() or event)
* Showing -> AwaitingChoice (if choices provided)
* Showing -> Idle (if no choices, on click progress)
* AwaitingChoice -> Idle (on choice selected)
*/
struct DialogueComponent {
/** Current state of the dialogue box */
enum class State {
Idle, ///< No dialogue active
Showing, ///< Text is being displayed
AwaitingChoice ///< Waiting for player to pick a choice
};
State state = State::Idle;
/** The narration text to display */
Ogre::String text;
/** Optional speaker name (displayed above the text) */
Ogre::String speaker;
/** Player choice labels (empty = no choices, click to progress) */
std::vector<Ogre::String> choices;
/** Font configuration */
Ogre::String fontName = "Jupiteroid-Regular.ttf";
float fontSize = 24.0f;
/** Speaker name font size (slightly smaller) */
float speakerFontSize = 20.0f;
/** Background opacity (0.0 - 1.0) */
float backgroundOpacity = 0.85f;
/** Height of the dialogue box as fraction of screen height (0.0 - 1.0) */
float boxHeightFraction = 0.25f;
/** Vertical position as fraction from top (0.0 = top, 0.75 = bottom quarter) */
float boxPositionFraction = 0.75f;
/** Whether the dialogue box is enabled (can be toggled) */
bool enabled = true;
/** Callback invoked when a choice is selected (choice index, 1-based) */
std::function<void(int)> onChoiceSelected;
/** Callback invoked when dialogue is dismissed (no choices mode) */
std::function<void()> onDismissed;
/** Callback invoked when dialogue starts showing */
std::function<void()> onShow;
/* --- API --- */
/** Show dialogue with given text and optional choices */
void show(const Ogre::String &narrationText,
const std::vector<Ogre::String> &choiceLabels = {},
const Ogre::String &speakerName = "")
{
text = narrationText;
choices = choiceLabels;
speaker = speakerName;
state = choices.empty() ? State::Showing :
State::AwaitingChoice;
if (onShow)
onShow();
}
/** Progress the dialogue (click-through when no choices) */
void progress()
{
if (state == State::Showing && choices.empty()) {
state = State::Idle;
if (onDismissed)
onDismissed();
}
}
/** Select a choice by 1-based index */
void selectChoice(int index)
{
if (state == State::AwaitingChoice && index >= 1 &&
index <= (int)choices.size()) {
state = State::Idle;
if (onChoiceSelected)
onChoiceSelected(index);
}
}
/** Check if dialogue is currently active */
bool isActive() const
{
return state != State::Idle;
}
/** Reset dialogue to idle state */
void reset()
{
state = State::Idle;
text.clear();
choices.clear();
speaker.clear();
}
};
#endif // EDITSCENE_DIALOGUE_COMPONENT_HPP

View File

@@ -0,0 +1,23 @@
#include "../ui/ComponentRegistration.hpp"
#include "../ui/DialogueEditor.hpp"
#include "DialogueComponent.hpp"
REGISTER_COMPONENT_GROUP("Dialogue Box", "Game", DialogueComponent,
DialogueEditor)
{
registry.registerComponent<DialogueComponent>(
DialogueComponent_name, DialogueComponent_group,
std::make_unique<DialogueEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<DialogueComponent>()) {
e.set<DialogueComponent>({});
}
},
// Remover
[](flecs::entity e) {
if (e.has<DialogueComponent>()) {
e.remove<DialogueComponent>();
}
});
}

View File

@@ -0,0 +1,21 @@
#ifndef EDITSCENE_EVENT_HANDLER_HPP
#define EDITSCENE_EVENT_HANDLER_HPP
#pragma once
#include <Ogre.h>
/**
* Event-driven behavior tree handler component.
*
* When the specified event is received, the referenced GoapAction's
* behavior tree is executed for this entity. Event parameters are
* merged into the entity's GoapBlackboard before the tree runs and
* cleaned up when the tree completes.
*/
struct EventHandlerComponent {
Ogre::String eventName;
Ogre::String actionName;
bool enabled = true;
};
#endif // EDITSCENE_EVENT_HANDLER_HPP

View File

@@ -0,0 +1,21 @@
#include "EventHandler.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/EventHandlerEditor.hpp"
REGISTER_COMPONENT_GROUP("Event Handler", "Game", EventHandlerComponent,
EventHandlerEditor)
{
registry.registerComponent<EventHandlerComponent>(
"Event Handler", "Game",
std::make_unique<EventHandlerEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<EventHandlerComponent>())
e.set<EventHandlerComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<EventHandlerComponent>())
e.remove<EventHandlerComponent>();
});
}

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)
@@ -180,9 +188,32 @@ Ogre::String GoapBlackboard::dump() const
")\n";
}
if (!stringValues.empty()) {
result += " String values:\n";
for (const auto &pair : stringValues)
result += " " + pair.first + " = " + pair.second + "\n";
}
return result;
}
void GoapBlackboard::merge(const GoapBlackboard &other)
{
// Merge bits
bits = (bits & ~other.mask) | (other.bits & other.mask);
mask |= other.mask;
// Merge values
for (const auto &pair : other.values)
values[pair.first] = pair.second;
for (const auto &pair : other.floatValues)
floatValues[pair.first] = pair.second;
for (const auto &pair : other.vec3Values)
vec3Values[pair.first] = pair.second;
for (const auto &pair : other.stringValues)
stringValues[pair.first] = pair.second;
}
std::vector<int> GoapBlackboard::getSetBits() const
{
std::vector<int> result;

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;
@@ -31,6 +35,9 @@ struct GoapBlackboard {
// Named Vector3 values — runtime character state
std::unordered_map<std::string, Ogre::Vector3> vec3Values;
// Named string values — event params, tags, etc.
std::unordered_map<std::string, Ogre::String> stringValues;
GoapBlackboard() = default;
/* --- Bit accessors --- */
@@ -144,6 +151,34 @@ struct GoapBlackboard {
vec3Values.erase(key);
}
/* --- String value accessors --- */
void setStringValue(const std::string &key, const Ogre::String &value)
{
stringValues[key] = value;
}
Ogre::String getStringValue(const std::string &key,
const Ogre::String &defaultValue = "") const
{
auto it = stringValues.find(key);
if (it != stringValues.end())
return it->second;
return defaultValue;
}
bool hasStringValue(const std::string &key) const
{
return stringValues.find(key) != stringValues.end();
}
void removeStringValue(const std::string &key)
{
stringValues.erase(key);
}
/* --- Merge another blackboard into this one --- */
void merge(const GoapBlackboard &other);
/* --- Generic scalar lookup (tries int then float) --- */
bool getScalarValue(const std::string &key, float &out) const;
@@ -167,6 +202,7 @@ struct GoapBlackboard {
values.clear();
floatValues.clear();
vec3Values.clear();
stringValues.clear();
}
Ogre::String dump() const;
@@ -184,9 +220,11 @@ 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;
vec3Values == other.vec3Values &&
stringValues == other.stringValues;
}
bool operator!=(const GoapBlackboard &other) const

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,165 @@
#ifndef EDITSCENE_INVENTORY_HPP
#define EDITSCENE_INVENTORY_HPP
#pragma once
#include <Ogre.h>
#include <flecs.h>
#include <vector>
#include <string>
#include <cstdint>
/**
* A single slot in an inventory.
* Stores a reference to an item entity (if the item is a world entity)
* or stores item data directly for items that exist only in inventory.
*/
struct InventorySlot {
// Flecs entity ID of the item (0 if slot is empty)
flecs::entity_t itemEntity = 0;
// Item data for items that exist only in inventory (no world entity)
Ogre::String itemId;
Ogre::String itemName;
Ogre::String itemType;
int stackSize = 0;
int maxStackSize = 99;
float weight = 0.1f;
int value = 1;
Ogre::String useActionName;
bool isEmpty() const
{
return itemEntity == 0 && stackSize <= 0;
}
void clear()
{
itemEntity = 0;
itemId.clear();
itemName.clear();
itemType.clear();
stackSize = 0;
maxStackSize = 99;
weight = 0.1f;
value = 1;
useActionName.clear();
}
};
/**
* Inventory component.
*
* Attached to a character entity to hold items.
* Can also be attached to container entities (chests, barrels, etc.)
* to define their contents.
*
* The inventory stores items as InventorySlot entries, each of which
* may reference a world ItemComponent entity or hold item data directly.
*/
struct InventoryComponent {
// Maximum number of slots
int maxSlots = 20;
// Current slots
std::vector<InventorySlot> slots;
// Total weight of all items (computed)
float totalWeight = 0.0f;
// Maximum weight capacity (0 = unlimited)
float maxWeight = 50.0f;
// Whether this inventory is a container (chest, barrel, etc.)
// Containers can be opened by characters to transfer items.
bool isContainer = false;
// Whether this inventory is currently open (for containers being browsed)
bool isOpen = false;
InventoryComponent() = default;
explicit InventoryComponent(int maxSlots_)
: maxSlots(maxSlots_)
{
slots.reserve(maxSlots_);
}
/** Find the first empty slot index, or -1 if full. */
int findEmptySlot() const
{
for (int i = 0; i < maxSlots; i++) {
if (i >= (int)slots.size())
return i;
if (slots[i].isEmpty())
return i;
}
return -1;
}
/** Find a slot containing an item with the given itemId. */
int findItem(const Ogre::String &itemId) const
{
for (int i = 0; i < (int)slots.size(); i++) {
if (!slots[i].isEmpty() && slots[i].itemId == itemId)
return i;
}
return -1;
}
/** Find a slot containing an item with the given itemName. */
int findItemByName(const Ogre::String &itemName) const
{
for (int i = 0; i < (int)slots.size(); i++) {
if (!slots[i].isEmpty() &&
slots[i].itemName == itemName)
return i;
}
return -1;
}
/** Count total number of items (sum of stack sizes). */
int countItems() const
{
int count = 0;
for (const auto &slot : slots) {
if (!slot.isEmpty())
count += slot.stackSize;
}
return count;
}
/** Count how many of a specific itemId are in the inventory. */
int countItem(const Ogre::String &itemId) const
{
int count = 0;
for (const auto &slot : slots) {
if (!slot.isEmpty() && slot.itemId == itemId)
count += slot.stackSize;
}
return count;
}
/** Check if inventory has at least one of a specific itemId. */
bool hasItem(const Ogre::String &itemId) const
{
return findItem(itemId) >= 0;
}
/** Check if inventory has at least one of a specific itemName. */
bool hasItemByName(const Ogre::String &itemName) const
{
return findItemByName(itemName) >= 0;
}
/** Recalculate total weight. */
void recalculateWeight()
{
totalWeight = 0.0f;
for (const auto &slot : slots) {
if (!slot.isEmpty())
totalWeight += slot.weight * slot.stackSize;
}
}
};
#endif // EDITSCENE_INVENTORY_HPP

View File

@@ -0,0 +1,20 @@
#include "Inventory.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/InventoryEditor.hpp"
REGISTER_COMPONENT_GROUP("Inventory", "Game", InventoryComponent,
InventoryEditor)
{
registry.registerComponent<InventoryComponent>(
"Inventory", "Game", std::make_unique<InventoryEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<InventoryComponent>())
e.set<InventoryComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<InventoryComponent>())
e.remove<InventoryComponent>();
});
}

View File

@@ -0,0 +1,59 @@
#ifndef EDITSCENE_ITEM_HPP
#define EDITSCENE_ITEM_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <vector>
/**
* Item definition component.
*
* Attached to a world entity that represents a pickable item.
* The ActuatorSystem detects items (entities with ItemComponent)
* and shows "E - Pick up [ItemName]" prompts to the player.
*
* Items can also be placed in containers (chests, etc.) which
* have an InventoryComponent.
*
* For AI characters, behavior tree nodes (hasItem, pickupItem,
* dropItem, useItem, addItemToInventory) provide inventory access.
*/
struct ItemComponent {
// Display name of the item (e.g. "Apple", "Sword", "Key")
Ogre::String itemName = "Item";
// Item type for categorization (e.g. "food", "weapon", "key", "quest")
Ogre::String itemType = "misc";
// Unique identifier for this item definition
// Multiple entities can share the same itemId (e.g. multiple coins)
Ogre::String itemId;
// Stack size: how many of this item are in this stack
int stackSize = 1;
// Maximum stack size (0 = no stacking)
int maxStackSize = 99;
// Weight per unit (for encumbrance calculations)
float weight = 0.1f;
// Value (for trading)
int value = 1;
// Name of the GOAP action to execute when "using" this item
// (e.g. "eat", "equip", "read"). Empty = no use action.
Ogre::String useActionName;
ItemComponent() = default;
explicit ItemComponent(const Ogre::String &name,
const Ogre::String &type = "misc")
: itemName(name)
, itemType(type)
{
}
};
#endif // EDITSCENE_ITEM_HPP

View File

@@ -0,0 +1,19 @@
#include "Item.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/ItemEditor.hpp"
REGISTER_COMPONENT_GROUP("Item", "Game", ItemComponent, ItemEditor)
{
registry.registerComponent<ItemComponent>(
"Item", "Game", std::make_unique<ItemEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<ItemComponent>())
e.set<ItemComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<ItemComponent>())
e.remove<ItemComponent>();
});
}

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

@@ -26,6 +26,17 @@ struct PlayerControllerComponent {
Ogre::String swimIdleState = "swim-idle";
Ogre::String swimState = "swim";
Ogre::String swimFastState = "swim-fast";
/* Actuator interaction settings */
float actuatorDistance = 25.0f;
float actuatorCooldown = 1.5f;
Ogre::Vector3 actuatorColor = Ogre::Vector3(0.0f, 0.4f, 1.0f);
float distantCircleRadius = 8.0f;
float nearCircleRadius = 14.0f;
float actuatorLabelFontSize = 14.0f;
/* Runtime: set by ActuatorSystem while executing an action */
bool inputLocked = false;
};
#endif // EDITSCENE_PLAYERCONTROLLER_HPP

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

@@ -0,0 +1,418 @@
-- =============================================================================
-- Action Database Lua API Examples
-- =============================================================================
-- This file demonstrates how to create, query, and manage GOAP actions and
-- goals from Lua using the ecs.action_db API.
--
-- The ActionDatabase is a global singleton. Actions and goals defined here
-- are immediately available to all characters in the scene.
-- =============================================================================
-- =============================================================================
-- Defining Bit Names
-- =============================================================================
-- Before using bits in preconditions/effects, you should define meaningful
-- names for the 64 available bit slots. This makes your actions readable.
--
-- Bit names are global across the entire game session. They map
-- human-readable names (like "has_axe", "is_hungry") to bit indices
-- (0-63) used in GoapBlackboard preconditions and effects.
--
-- You can define bits explicitly at startup:
-- =============================================================================
-- Explicitly assign bit names to specific indices:
ecs.action_db.set_bit_name(0, "has_axe")
ecs.action_db.set_bit_name(1, "has_wood")
ecs.action_db.set_bit_name(2, "is_hungry")
ecs.action_db.set_bit_name(3, "near_tree")
ecs.action_db.set_bit_name(4, "near_well")
ecs.action_db.set_bit_name(5, "has_bucket")
ecs.action_db.set_bit_name(6, "has_food")
ecs.action_db.set_bit_name(7, "near_fire")
ecs.action_db.set_bit_name(8, "has_cooked_food")
ecs.action_db.set_bit_name(9, "is_awake")
ecs.action_db.set_bit_name(10, "at_market")
ecs.action_db.set_bit_name(11, "at_home")
ecs.action_db.set_bit_name(12, "near_chair")
ecs.action_db.set_bit_name(13, "is_sitting")
ecs.action_db.set_bit_name(14, "near_forest")
ecs.action_db.set_bit_name(15, "is_strong")
ecs.action_db.set_bit_name(16, "has_strength")
-- Or use auto_assign_bit() to let the system pick the index:
local idx = ecs.action_db.auto_assign_bit("has_water")
print("'has_water' assigned to bit " .. idx)
-- Look up a bit by name:
local bit_idx = ecs.action_db.find_bit_by_name("has_axe")
print("'has_axe' is at bit " .. bit_idx)
-- Get the name for a bit index:
local name = ecs.action_db.get_bit_name(0)
print("Bit 0 is named '" .. name .. "'")
-- List all currently assigned bit names:
local bits = ecs.action_db.list_bit_names()
print("Assigned bit names:")
for _, b in ipairs(bits) do
print(" Bit " .. b.index .. ": " .. b.name)
end
-- NOTE: If you use a bit name in an action's preconditions/effects
-- that hasn't been explicitly assigned, it will be auto-assigned
-- to the first free slot automatically. So you don't HAVE to
-- pre-define them, but it's good practice for clarity.
-- =============================================================================
-- Creating Actions
-- =============================================================================
-- Simple action with just a name and cost:
ecs.action_db.add_action("idle", 1)
-- Action with preconditions (what must be true before the action can run):
ecs.action_db.add_action("chop_wood", 2,
{
bits = { has_axe = true },
values = { stamina = 10 }
},
{ -- effects (what becomes true after the action runs)
bits = { has_wood = true },
values = { stamina = -5 }
}
)
-- Action with only preconditions, no effects:
ecs.action_db.add_action("fetch_water", 3,
{
bits = { near_well = true, has_bucket = true },
values = { thirst = 50 }
}
)
-- Action with only effects, no preconditions:
ecs.action_db.add_action("rest", 1,
{},
{
values = { stamina = 100, energy = 100 }
}
)
-- Action with float and string values in blackboard:
ecs.action_db.add_action("cook_food", 4,
{
bits = { has_food = true, near_fire = true },
values = { cooking_skill = 3 },
floatValues = { hunger = 50.0 }
},
{
bits = { has_cooked_food = true },
values = { hunger = -30 },
stringValues = { last_action = "cooking" }
}
)
-- =============================================================================
-- Creating Actions WITH Behavior Trees
-- =============================================================================
-- Action with a simple leaf behavior tree (plays an animation):
ecs.action_db.add_action("wave", 1,
{}, -- no preconditions
{}, -- no effects
{ -- behavior tree (arg 5)
type = "sequence",
children = {
{ type = "setAnimationState", name = "SM/Wave" },
{ type = "delay", params = "2.0" },
{ type = "setAnimationState", name = "SM/Idle" }
}
}
)
-- Action with a selector behavior tree (try to chop, fall back to idle):
ecs.action_db.add_action("chop_tree", 3,
{
bits = { near_tree = true },
values = { stamina = 15 }
},
{
bits = { has_wood = true },
values = { stamina = -8, wood_count = 1 }
},
{
type = "selector",
children = {
{
type = "sequence",
children = {
{ type = "checkBit", name = "has_axe", params = "1" },
{ type = "setAnimationState", name = "SM/Chop" },
{ type = "delay", params = "3.0" },
{ type = "setAnimationState", name = "SM/Idle" },
{ type = "setBit", name = "has_wood", params = "1" }
}
},
{
type = "sequence",
children = {
{ type = "debugPrint", name = "No axe! Picking up stick..." },
{ type = "setAnimationState", name = "SM/Pickup" },
{ type = "delay", params = "1.0" },
{ type = "setBit", name = "has_wood", params = "1" }
}
}
}
}
)
-- Action with blackboard checks and value manipulation:
ecs.action_db.add_action("travel_to_market", 5,
{
bits = { is_awake = true },
values = { energy = 20 }
},
{
bits = { at_market = true },
values = { energy = -15 }
},
{
type = "sequence",
children = {
{ type = "checkValue", name = "energy", params = ">= 20" },
{ type = "setAnimationState", name = "SM/Walk" },
{ type = "delay", params = "5.0" },
{ type = "setAnimationState", name = "SM/Idle" },
{ type = "setBit", name = "at_market", params = "1" },
{ type = "setBit", name = "at_home", params = "0" }
}
}
)
-- Action with inventory operations:
ecs.action_db.add_action("gather_wood", 2,
{
bits = { near_forest = true }
},
{
values = { wood_count = 3 }
},
{
type = "sequence",
children = {
{ type = "setAnimationState", name = "SM/Gather" },
{ type = "delay", params = "2.0" },
{ type = "addItemToInventory", params = "wood,Firewood,material,3,1.0,0" },
{ type = "setAnimationState", name = "SM/Idle" }
}
}
)
-- Action that teleports character to a smart object child:
ecs.action_db.add_action("sit_on_chair", 1,
{
bits = { near_chair = true }
},
{
bits = { is_sitting = true }
},
{
type = "sequence",
children = {
{ type = "teleportToChild", name = "SitTarget" },
{ type = "disablePhysics" },
{ type = "setAnimationState", name = "SM/Sit" },
{ type = "delay", params = "5.0" },
{ type = "setAnimationState", name = "SM/Stand" },
{ type = "enablePhysics" }
}
}
)
-- =============================================================================
-- Creating Goals
-- =============================================================================
-- Simple goal with just a name and priority:
ecs.action_db.add_goal("survive", 100)
-- Goal with a target blackboard state:
ecs.action_db.add_goal("gather_resources", 50,
{
bits = { has_wood = true, has_water = true },
values = { wood_count = 5, water_count = 3 }
}
)
-- Goal with a condition expression (evaluated against character's blackboard):
ecs.action_db.add_goal("stay_healthy", 80,
{
values = { health = 100, stamina = 80 }
},
"health < 50 || stamina < 30" -- only valid when character needs healing
)
-- Goal with full specification:
ecs.action_db.add_goal("become_strong", 30,
{
bits = { is_strong = true },
values = { strength = 100 }
},
"strength < 100" -- only valid if not already strong
)
-- =============================================================================
-- Querying Actions and Goals
-- =============================================================================
-- Find an action by name:
local action = ecs.action_db.find_action("chop_wood")
if action then
print("Found action: " .. action.name .. " (cost: " .. action.cost .. ")")
-- action.preconditions and action.effects are tables with:
-- .bits - table of boolean flags
-- .values - table of integer values
-- .floatValues - table of float values
-- .stringValues - table of string values
-- action.behaviorTree is a table with:
-- .type - node type string
-- .name - optional name
-- .params - optional params
-- .children - optional array of child nodes
end
-- Find a goal by name:
local goal = ecs.action_db.find_goal("gather_resources")
if goal then
print("Found goal: " .. goal.name .. " (priority: " .. goal.priority .. ")")
-- goal.target is a blackboard table
-- goal.condition is the condition string
end
-- =============================================================================
-- Listing All Actions and Goals
-- =============================================================================
-- List all action names:
local actions = ecs.action_db.list_actions()
print("Available actions:")
for i, name in ipairs(actions) do
print(" " .. i .. ". " .. name)
end
-- List all goal names:
local goals = ecs.action_db.list_goals()
print("Available goals:")
for i, name in ipairs(goals) do
print(" " .. i .. ". " .. name)
end
-- =============================================================================
-- Removing Actions and Goals
-- =============================================================================
-- Remove an action by name:
local removed = ecs.action_db.remove_action("idle")
if removed then
print("Removed action: idle")
end
-- Remove a goal by name:
ecs.action_db.remove_goal("become_strong")
-- =============================================================================
-- Replacing Actions (same name = replace)
-- =============================================================================
-- If you add an action with the same name as an existing one, it replaces it:
ecs.action_db.add_action("chop_wood", 5,
{
bits = { has_axe = true, has_strength = true },
values = { stamina = 20 }
},
{
bits = { has_wood = true },
values = { stamina = -10, wood_count = 2 }
}
)
-- The old "chop_wood" action is replaced with this new definition.
-- =============================================================================
-- Clearing Everything
-- =============================================================================
-- Remove all actions and goals:
-- ecs.action_db.clear()
-- =============================================================================
-- Blackboard Table Format Reference
-- =============================================================================
--
-- The blackboard table passed to add_action/add_goal has this structure:
--
-- {
-- bits = {
-- has_axe = true, -- boolean flags (use named bits)
-- has_wood = false,
-- is_hungry = true
-- },
-- values = { -- integer values
-- health = 100,
-- stamina = 50,
-- wood_count = 0
-- },
-- floatValues = { -- float values
-- hunger = 75.5,
-- speed = 1.2
-- },
-- stringValues = { -- string values
-- last_action = "idle",
-- current_state = "exploring"
-- }
-- }
--
-- All sub-tables are optional. An empty table or nil means no constraints.
-- =============================================================================
-- =============================================================================
-- Behavior Tree Table Format Reference
-- =============================================================================
--
-- The behaviorTree table (arg 5 of add_action) has this structure:
--
-- {
-- type = "sequence", -- node type (required)
-- name = "optional_name", -- depends on type (task name, anim state, etc.)
-- params = "optional_params", -- extra parameters (delay seconds, bit index, etc.)
-- children = { -- array of child nodes (for sequence/selector/invert)
-- { type = "task", name = "myAction" },
-- { type = "setAnimationState", name = "SM/Walk" },
-- { type = "delay", params = "2.0" },
-- { type = "checkBit", name = "has_axe", params = "1" }
-- }
-- }
--
-- Common node types:
-- "sequence" - Execute children in order until one fails
-- "selector" - Execute children in order until one succeeds
-- "invert" - Invert the result of a single child
-- "task" - Leaf: references a named task
-- "check" - Leaf: references a named condition
-- "debugPrint" - Leaf: prints 'name' to console
-- "setAnimationState"- Leaf: sets animation state (name="SM/State")
-- "isAnimationEnded" - Leaf: true if animation ended
-- "setBit" - Leaf: sets blackboard bit (name=bit, params=0/1)
-- "checkBit" - Leaf: true if blackboard bit is set
-- "setValue" - Leaf: sets blackboard value (name=key, params=val)
-- "checkValue" - Leaf: blackboard comparison (name=key, params="op val")
-- "delay" - Leaf: waits N seconds (params=seconds as float)
-- "teleportToChild" - Leaf: teleports to named child of Smart Object
-- "disablePhysics" - Leaf: removes character from physics
-- "enablePhysics" - Leaf: re-adds character to physics
-- "hasItem" - Leaf check: true if inventory has itemId
-- "pickupItem" - Leaf: picks up nearest item
-- "dropItem" - Leaf: drops item from inventory
-- "useItem" - Leaf: uses item from inventory
-- "addItemToInventory"- Leaf: adds item directly to inventory
-- =============================================================================

View File

@@ -0,0 +1,514 @@
-- =============================================================================
-- Behavior Tree Lua API Examples
-- =============================================================================
-- This file demonstrates how to create custom behavior tree nodes using
-- Lua functions via the ecs.behavior_tree API, and how to use the
-- built-in C++ node types via ecs.behavior_tree.create_node().
--
-- The API allows you to:
-- 1. Register Lua functions as behavior tree node handlers
-- 2. Create behavior tree nodes (both Lua and built-in C++ types)
-- 3. Return "success", "failure", or "running" to control tree flow
-- 4. Pass parameters from the behavior tree editor to your Lua function
-- =============================================================================
-- =============================================================================
-- Registering Lua Node Handlers
-- =============================================================================
-- Use ecs.behavior_tree.register_node(name, function) to register a Lua
-- function as a behavior tree node handler.
--
-- The function receives two arguments:
-- entity_id - The entity ID executing this behavior tree node
-- params - A table of parameters parsed from the node's params string
--
-- The function must return one of:
-- "success" - Node completed successfully (tree continues)
-- "failure" - Node failed (tree stops with failure)
-- "running" - Node is still running (will be called again next frame)
-- =============================================================================
-- =============================================================================
-- Example 1: Simple greeting node
-- =============================================================================
-- Prints a message and succeeds immediately.
ecs.behavior_tree.register_node("say_hello", function(entity_id, params)
local message = params.message or "Hello!"
print("Entity " .. entity_id .. " says: " .. message)
return "success"
end)
-- =============================================================================
-- Example 2: Node that checks a blackboard value
-- =============================================================================
-- Checks if a blackboard integer value meets a minimum threshold.
-- Succeeds if value >= min, fails otherwise.
ecs.behavior_tree.register_node("check_blackboard_value", function(entity_id, params)
local key = params.key
local min_val = tonumber(params.min) or 0
if not key then
return "failure"
end
local bb = ecs.get_component(entity_id, "GoapBlackboard")
if not bb then
return "failure"
end
local value = bb.values[key]
if value == nil then
return "failure"
end
if value >= min_val then
return "success"
else
return "failure"
end
end)
-- =============================================================================
-- Example 3: Node that runs over multiple frames (running state)
-- =============================================================================
-- Waits for a specified duration, storing progress in the blackboard's
-- floatValues. Returns "running" each frame until the duration elapses.
ecs.behavior_tree.register_node("wait_for_duration", function(entity_id, params)
local duration = tonumber(params.duration) or 1.0
local timer_key = params.timer_key or "wait_timer"
local bb = ecs.get_component(entity_id, "GoapBlackboard")
if not bb then
return "failure"
end
local elapsed = bb.floatValues[timer_key] or 0.0
local dt = ecs.get_delta_time() or 0.016
elapsed = elapsed + dt
bb.floatValues[timer_key] = elapsed
ecs.set_component(entity_id, "GoapBlackboard", bb)
if elapsed >= duration then
bb.floatValues[timer_key] = nil
ecs.set_component(entity_id, "GoapBlackboard", bb)
return "success"
end
return "running"
end)
-- =============================================================================
-- Example 4: Node that modifies blackboard values
-- =============================================================================
-- Adds a configurable amount to a blackboard integer value.
ecs.behavior_tree.register_node("add_blackboard_value", function(entity_id, params)
local key = params.key
local amount = tonumber(params.amount) or 1
if not key then
return "failure"
end
local bb = ecs.get_component(entity_id, "GoapBlackboard")
if not bb then
return "failure"
end
bb.values[key] = (bb.values[key] or 0) + amount
ecs.set_component(entity_id, "GoapBlackboard", bb)
return "success"
end)
-- =============================================================================
-- Example 5: Node that sets a blackboard bit
-- =============================================================================
-- Sets or clears a named bit in the blackboard.
ecs.behavior_tree.register_node("set_blackboard_bit", function(entity_id, params)
local bit_name = params.bit
local value = params.value == "1" or params.value == "true"
if not bit_name then
return "failure"
end
local bb = ecs.get_component(entity_id, "GoapBlackboard")
if not bb then
return "failure"
end
bb.bits[bit_name] = value
ecs.set_component(entity_id, "GoapBlackboard", bb)
return "success"
end)
-- =============================================================================
-- Example 6: Node that checks a blackboard bit
-- =============================================================================
-- Checks if a named bit is set to a specific value.
ecs.behavior_tree.register_node("check_blackboard_bit", function(entity_id, params)
local bit_name = params.bit
local expected = params.value ~= "0" and params.value ~= "false"
if not bit_name then
return "failure"
end
local bb = ecs.get_component(entity_id, "GoapBlackboard")
if not bb then
return "failure"
end
local actual = bb.bits[bit_name] == true
if actual == expected then
return "success"
else
return "failure"
end
end)
-- =============================================================================
-- Example 7: Random chance node
-- =============================================================================
-- Succeeds with a configurable probability (0.0 to 1.0).
ecs.behavior_tree.register_node("random_chance", function(entity_id, params)
local probability = tonumber(params.probability) or 0.5
local roll = math.random()
if roll < probability then
return "success"
else
return "failure"
end
end)
-- =============================================================================
-- Using Built-in Node Types via create_node()
-- =============================================================================
-- ecs.behavior_tree.create_node(type, name, params) creates a node table
-- for any built-in C++ node type. If the first argument matches a registered
-- Lua handler name, it creates a luaTask node instead.
--
-- Built-in node types:
-- Control: sequence, selector, invert
-- Animation: setAnimationState, isAnimationEnded
-- Blackboard: setBit, checkBit, setValue, checkValue, blackboardDump
-- Timing: delay
-- Movement: teleportToChild
-- Physics: disablePhysics, enablePhysics
-- Events: sendEvent
-- Inventory: hasItem, hasItemByName, countItem, pickupItem, dropItem,
-- useItem, addItemToInventory
-- Debug: debugPrint
-- Lua: luaTask (auto-detected when name matches a registered handler)
-- =============================================================================
-- =============================================================================
-- Example 8: Action using built-in animation nodes
-- =============================================================================
-- Uses setAnimationState and isAnimationEnded to play an animation and
-- wait for it to finish.
ecs.action_db.add_action("play_walk_animation", 1,
{}, -- preconditions
{}, -- effects
{ -- behavior tree
type = "sequence",
children = {
ecs.behavior_tree.create_node("setAnimationState", "locomotion/walk"),
ecs.behavior_tree.create_node("isAnimationEnded", "locomotion"),
ecs.behavior_tree.create_node("setAnimationState", "locomotion/idle")
}
}
)
-- =============================================================================
-- Example 9: Action using built-in delay node
-- =============================================================================
-- Waits for a specified duration using the built-in delay node.
ecs.action_db.add_action("wait_and_greet", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("delay", "", "2.0"),
ecs.behavior_tree.create_node("say_hello", "message=Waited 2 seconds!")
}
}
)
-- =============================================================================
-- Example 10: Action using built-in blackboard nodes
-- =============================================================================
-- Sets a blackboard bit, checks it, and sets a value.
ecs.action_db.add_action("blackboard_demo", 1,
{},
{
bits = { has_sword = true },
values = { gold = 100 }
},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("setBit", "has_sword", "1"),
ecs.behavior_tree.create_node("checkBit", "has_sword"),
ecs.behavior_tree.create_node("setValue", "gold", "100"),
ecs.behavior_tree.create_node("checkValue", "gold", ">= 50"),
ecs.behavior_tree.create_node("blackboardDump", "After setup")
}
}
)
-- =============================================================================
-- Example 11: Action using built-in sendEvent node
-- =============================================================================
-- Sends an event with parameters.
ecs.action_db.add_action("trigger_quest_event", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("sendEvent", "quest_started",
"quest_id=the_ancient_sword,quest_giver=elder"),
ecs.behavior_tree.create_node("debugPrint", "Quest event sent")
}
}
)
-- =============================================================================
-- Example 12: Action using built-in physics nodes
-- =============================================================================
-- Disables physics, waits, then re-enables.
ecs.action_db.add_action("physics_control", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("disablePhysics"),
ecs.behavior_tree.create_node("delay", "", "1.0"),
ecs.behavior_tree.create_node("enablePhysics")
}
}
)
-- =============================================================================
-- Example 13: Action using built-in inventory nodes
-- =============================================================================
-- Adds an item to inventory, checks for it, uses it, then drops it.
ecs.action_db.add_action("inventory_demo", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("addItemToInventory", "potion_01",
"Health Potion,misc,1,0.5,10"),
ecs.behavior_tree.create_node("hasItem", "potion_01"),
ecs.behavior_tree.create_node("countItem", "potion_01", "1"),
ecs.behavior_tree.create_node("useItem", "potion_01"),
ecs.behavior_tree.create_node("dropItem", "potion_01", "1")
}
}
)
-- =============================================================================
-- Example 14: Action using built-in teleport node
-- =============================================================================
-- Teleports the entity to a named child transform.
ecs.action_db.add_action("teleport_to_entrance", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("teleportToChild", "entrance"),
ecs.behavior_tree.create_node("debugPrint", "Teleported to entrance")
}
}
)
-- =============================================================================
-- Example 15: Complex action mixing Lua and built-in nodes
-- =============================================================================
-- A selector that first tries to use a sword, and if not available,
-- picks one up and equips it.
ecs.action_db.add_action("equip_sword", 2,
{},
{
bits = { has_sword = true }
},
{
type = "selector",
children = {
-- Try to use existing sword
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("checkBit", "has_sword"),
ecs.behavior_tree.create_node("debugPrint", "Already have a sword")
}
},
-- Pick up and equip a sword
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("setAnimationState", "locomotion/walk"),
ecs.behavior_tree.create_node("delay", "", "3.0"),
ecs.behavior_tree.create_node("addItemToInventory", "sword_01",
"Iron Sword,weapon,1,2.5,50"),
ecs.behavior_tree.create_node("setBit", "has_sword", "1"),
ecs.behavior_tree.create_node("setAnimationState", "locomotion/idle"),
ecs.behavior_tree.create_node("debugPrint", "Picked up the sword!")
}
}
}
}
)
-- =============================================================================
-- Example 16: Action with conditional logic using Lua nodes
-- =============================================================================
-- Uses Lua-registered nodes for blackboard checks and modifications.
ecs.action_db.add_action("gain_experience", 2,
{
values = { experience = 0 }
},
{
values = { experience = 15 }
},
{
type = "selector",
children = {
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("check_blackboard_value",
"key=experience,min=50"),
ecs.behavior_tree.create_node("say_hello",
"message=You are experienced!"),
ecs.behavior_tree.create_node("add_blackboard_value",
"key=experience,amount=10")
}
},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("say_hello",
"message=You are still learning..."),
ecs.behavior_tree.create_node("add_blackboard_value",
"key=experience,amount=5")
}
}
}
}
)
-- =============================================================================
-- Example 17: Action with random outcomes
-- =============================================================================
-- Uses the Lua random_chance node for probabilistic behavior.
ecs.action_db.add_action("try_gamble", 3,
{},
{
values = { gold = 10 }
},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("random_chance", "probability=0.3"),
ecs.behavior_tree.create_node("say_hello", "message=You won the gamble!"),
ecs.behavior_tree.create_node("add_blackboard_value", "key=gold,amount=10")
}
}
)
-- =============================================================================
-- Example 18: Action with running-state Lua node
-- =============================================================================
-- Uses the wait_for_duration Lua node which returns "running" each frame.
ecs.action_db.add_action("wait_and_continue", 1,
{},
{},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("wait_for_duration",
"duration=2.5,timer_key=my_timer"),
ecs.behavior_tree.create_node("say_hello", "message=Done waiting!")
}
}
)
-- =============================================================================
-- Example 19: Action with blackboard bit operations via Lua nodes
-- =============================================================================
ecs.action_db.add_action("toggle_flag", 1,
{},
{
bits = { quest_complete = true }
},
{
type = "sequence",
children = {
ecs.behavior_tree.create_node("set_blackboard_bit",
"bit=quest_complete,value=1"),
ecs.behavior_tree.create_node("check_blackboard_bit",
"bit=quest_complete,value=1"),
ecs.behavior_tree.create_node("debugPrint", "Quest flag set and verified")
}
}
)
-- =============================================================================
-- Managing Registered Nodes
-- =============================================================================
-- List all registered node handlers:
local nodes = ecs.behavior_tree.list_nodes()
print("Registered behavior tree nodes:")
for i, name in ipairs(nodes) do
print(" " .. i .. ". " .. name)
end
-- Unregister a node handler:
-- local removed = ecs.behavior_tree.unregister_node("say_hello")
-- if removed then
-- print("Unregistered node: say_hello")
-- end
-- =============================================================================
-- Parameter Format Reference
-- =============================================================================
--
-- The params string passed to create_node() supports:
--
-- Integer: "count=42" -> params.count = 42 (number)
-- Float: "speed=3.5" -> params.speed = 3.5 (number)
-- String: "msg=hello" -> params.msg = "hello" (string)
-- Quoted: 'msg="hello world"' -> params.msg = "hello world" (string)
-- Multiple: "key=val,count=5" -> params.key = "val", params.count = 5
-- Empty: "" -> params = {} (empty table)
--
-- The params table is passed as the second argument to your registered
-- Lua function handler.
-- =============================================================================

View File

@@ -0,0 +1,711 @@
-- =============================================================================
-- Component Lua API Examples
-- =============================================================================
-- This file demonstrates how to add, remove, query, and manipulate ECS
-- components from Lua using the ecs.* Lua API.
--
-- Components are data attached to entities. They define what an entity IS
-- and what it CAN DO. For example, a Transform component gives an entity
-- a position in the world, while a Renderable component makes it visible.
-- =============================================================================
-- =============================================================================
-- Checking for Components
-- =============================================================================
-- Create an entity and check what components it has:
local entity = ecs.create_entity()
ecs.set_entity_name(entity, "TestObject")
-- Check if an entity has a specific component:
if ecs.has_component(entity, "Transform") then
print("Entity has Transform component")
end
-- New entities typically have EditorMarker by default:
if ecs.has_component(entity, "EditorMarker") then
print("Entity has EditorMarker (default for new entities)")
end
-- =============================================================================
-- Adding and Removing Components
-- =============================================================================
-- Add a tag component (a component with no data):
ecs.add_component(entity, "InWater")
print("Added InWater tag")
-- Check it was added:
if ecs.has_component(entity, "InWater") then
print("Entity is now in water")
end
-- Remove a tag component:
ecs.remove_component(entity, "InWater")
if not ecs.has_component(entity, "InWater") then
print("Entity is no longer in water")
end
-- =============================================================================
-- Setting and Getting Components with Data
-- =============================================================================
-- Set a component with data (creates or replaces it):
ecs.set_component(entity, "Transform", {
position = { 10, 20, 30 },
rotation = { 1, 0, 0, 0 }, -- quaternion w, x, y, z
scale = { 2, 2, 2 }
})
-- Get the component back:
local transform = ecs.get_component(entity, "Transform")
if transform then
print("Position: " .. transform.position[1] .. ", "
.. transform.position[2] .. ", "
.. transform.position[3])
print("Scale: " .. transform.scale[1] .. ", "
.. transform.scale[2] .. ", "
.. transform.scale[3])
end
-- =============================================================================
-- Individual Field Access
-- =============================================================================
-- Get a single field from a component:
local pos_x = ecs.get_field(entity, "Transform", "position")
print("Position X from get_field: " .. pos_x[1])
-- Set a single field:
ecs.set_field(entity, "Transform", "position", { 0, 0, 0 })
local new_pos = ecs.get_field(entity, "Transform", "position")
print("New position: " .. new_pos[1] .. ", " .. new_pos[2] .. ", " .. new_pos[3])
-- =============================================================================
-- Practical Examples: Common Components
-- =============================================================================
-- Create a player character:
function create_player(name, x, y, z)
local player = ecs.create_entity()
ecs.set_entity_name(player, name)
-- Transform (position in the world):
ecs.set_component(player, "Transform", {
position = { x, y, z },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
-- Renderable (visible mesh):
ecs.set_component(player, "Renderable", {
meshName = "character.mesh",
visible = true
})
-- Character (physics capsule for movement):
ecs.set_component(player, "Character", {
radius = 0.4,
height = 1.8,
offset = { 0, 0.9, 0 },
enabled = true,
useGravity = true
})
-- PlayerController (camera and input):
ecs.set_component(player, "PlayerController", {
cameraMode = 1,
tpsDistance = 5.0,
tpsHeight = 2.0,
mouseSensitivity = 0.5
})
-- Inventory:
ecs.set_component(player, "Inventory", {
maxSlots = 20,
maxWeight = 50.0,
isContainer = true
})
print("Created player: " .. name)
return player
end
local hero = create_player("Hero", 0, 0, 0)
-- Create a light source:
function create_light(name, x, y, z, light_type)
local light = ecs.create_entity()
ecs.set_entity_name(light, name)
ecs.set_component(light, "Transform", {
position = { x, y, z },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
ecs.set_component(light, "Light", {
lightType = light_type or 0, -- 0=point, 1=directional, 2=spot
diffuseColor = { 1, 1, 1, 1 },
intensity = 1.5,
range = 100,
castShadows = true
})
print("Created light: " .. name)
return light
end
local sun = create_light("Sun", 0, 100, 0, 1) -- directional light
-- Create a building with smart object interaction:
function create_building(name, x, z)
local building = ecs.create_entity()
ecs.set_entity_name(building, name)
ecs.set_component(building, "Transform", {
position = { x, 0, z },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
ecs.set_component(building, "Renderable", {
meshName = "building.mesh",
visible = true
})
ecs.set_component(building, "SmartObject", {
radius = 2.0,
height = 3.0,
actionNames = { "enter", "exit" }
})
-- RigidBody for physics:
ecs.set_component(building, "RigidBody", {
bodyType = 0, -- 0=static
mass = 0,
friction = 0.5,
restitution = 0.1,
enabled = true
})
print("Created building: " .. name)
return building
end
local house = create_building("House", 10, 10)
-- Create an item:
function create_item(name, item_id, x, y, z)
local item = ecs.create_entity()
ecs.set_entity_name(item, name)
ecs.set_component(item, "Transform", {
position = { x, y, z },
rotation = { 1, 0, 0, 0 },
scale = { 0.5, 0.5, 0.5 }
})
ecs.set_component(item, "Renderable", {
meshName = "potion.mesh",
visible = true
})
ecs.set_component(item, "Item", {
itemName = name,
itemType = "consumable",
itemId = item_id,
stackSize = 1,
maxStackSize = 10,
weight = 0.5,
value = 50,
useActionName = "drink_potion"
})
print("Created item: " .. name)
return item
end
local potion = create_item("Health Potion", "potion_health", 5, 0.5, 5)
-- Create a NavMesh area:
function create_navmesh_area(name)
local nav = ecs.create_entity()
ecs.set_entity_name(nav, name)
ecs.set_component(nav, "NavMesh", {
cellSize = 0.3,
cellHeight = 0.2,
agentHeight = 2.0,
agentRadius = 0.5,
agentMaxClimb = 0.5,
agentMaxSlope = 45.0,
enabled = true
})
print("Created NavMesh: " .. name)
return nav
end
local navmesh = create_navmesh_area("MainNavMesh")
-- =============================================================================
-- Working with Tag Components
-- =============================================================================
-- Tag components are boolean markers with no data fields.
-- Common tags include:
-- EditorMarker - marks entities visible in the editor
-- InWater - entity is currently in water
-- GeneratedPhysicsTag - physics was auto-generated
-- ParentComponent - entity is a parent in a hierarchy
-- NavMeshGeometrySource - entity contributes to navmesh generation
-- Example: Mark entities for different purposes:
local marker1 = ecs.create_entity()
ecs.add_component(marker1, "GeneratedPhysicsTag")
local marker2 = ecs.create_entity()
ecs.add_component(marker2, "NavMeshGeometrySource")
-- =============================================================================
-- Working with GOAP Components
-- =============================================================================
-- Create an NPC with GOAP AI:
function create_npc(name, x, z)
local npc = ecs.create_entity()
ecs.set_entity_name(npc, name)
ecs.set_component(npc, "Transform", {
position = { x, 0, z },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
ecs.set_component(npc, "Character", {
radius = 0.4,
height = 1.8,
offset = { 0, 0.9, 0 },
enabled = true,
useGravity = true
})
-- GOAP blackboard (character's knowledge about the world):
ecs.set_component(npc, "GoapBlackboard", {
values = {
health = 100,
stamina = 100,
hunger = 0,
wood_count = 0
},
floatValues = {
hunger = 0.0
},
stringValues = {
state = "idle"
}
})
-- GOAP planner (plans actions to achieve goals):
ecs.set_component(npc, "GoapPlanner", {
enabled = true,
maxIterations = 100
})
-- GOAP runner (executes the plan):
ecs.set_component(npc, "GoapRunner", {
enabled = true,
currentAction = "idle"
})
-- Behavior tree for idle behavior:
ecs.set_component(npc, "BehaviorTree", {
treeName = "idle_behavior",
enabled = true
})
-- Path following for movement:
ecs.set_component(npc, "PathFollowing", {
enabled = true,
speed = 1.5
})
-- NavMesh agent for navigation:
ecs.set_component(npc, "NavMeshAgent", {
enabled = true,
radius = 0.5,
height = 2.0
})
print("Created NPC: " .. name)
return npc
end
local npc = create_npc("Villager_01", -10, -10)
-- =============================================================================
-- Working with Event Handler Components
-- =============================================================================
-- Add event handlers to entities:
ecs.set_component(npc, "EventHandler", {
eventName = "collision",
actionName = "handle_collision",
enabled = true
})
-- =============================================================================
-- Working with Dialogue Components
-- =============================================================================
-- Add dialogue to an NPC:
ecs.set_component(npc, "Dialogue", {
text = "Hello there, traveler!",
speaker = "Villager_01",
enabled = true
})
-- =============================================================================
-- Working with Water Components
-- =============================================================================
-- Create a water plane:
function create_water(name, y)
local water = ecs.create_entity()
ecs.set_entity_name(water, name)
ecs.set_component(water, "WaterPlane", {
enabled = true,
waterSurfaceY = y or 0.0,
planeSize = 1000,
reflectivity = 0.5,
waveSpeed = 1.0
})
ecs.set_component(water, "WaterPhysics", {
enabled = true,
waveHeight = 0.5
})
print("Created water: " .. name)
return water
end
local ocean = create_water("Ocean", 0.0)
-- =============================================================================
-- Working with Sky and Sun Components
-- =============================================================================
-- Create a skybox:
function create_sky(name)
local sky = ecs.create_entity()
ecs.set_entity_name(sky, name)
ecs.set_component(sky, "Skybox", {
enabled = true,
size = 500,
starsEnabled = true
})
print("Created sky: " .. name)
return sky
end
local skybox = create_sky("Skybox")
-- Update sun properties:
ecs.set_component(sun, "Sun", {
enabled = true,
timeOfDay = 12.0,
timeSpeed = 1.0,
intensity = 1.0,
castShadows = true
})
-- =============================================================================
-- Working with Camera Components
-- =============================================================================
-- Create a camera:
function create_camera(name)
local cam = ecs.create_entity()
ecs.set_entity_name(cam, name)
ecs.set_component(cam, "Camera", {
fovY = 60,
nearClip = 0.1,
farClip = 1000,
orthographic = false
})
print("Created camera: " .. name)
return cam
end
local camera = create_camera("MainCamera")
-- =============================================================================
-- Working with Prefab Components
-- =============================================================================
-- Mark an entity as a prefab instance:
ecs.set_component(house, "PrefabInstance", {
prefabPath = "prefabs/house.json",
instantiated = true
})
-- =============================================================================
-- Working with CellGrid Components
-- =============================================================================
-- Create a cell grid for spatial partitioning:
function create_cell_grid(name, width, height, depth)
local grid = ecs.create_entity()
ecs.set_entity_name(grid, name)
ecs.set_component(grid, "CellGrid", {
width = width or 10,
height = height or 5,
depth = depth or 10,
cellSize = 1.0,
cellHeight = 0.5
})
print("Created cell grid: " .. name)
return grid
end
local grid = create_cell_grid("WorldGrid", 20, 10, 20)
-- =============================================================================
-- Working with Town/District/Lot/Room Components
-- =============================================================================
-- Create a town with districts, lots, and rooms:
function create_town(name)
local town = ecs.create_entity()
ecs.set_entity_name(town, name)
ecs.set_component(town, "Town", {
townName = name,
population = 500
})
-- Create a district:
local district = ecs.create_entity()
ecs.set_entity_name(district, "Market District")
ecs.set_component(district, "District", {
districtName = "Market District",
districtType = "commercial"
})
-- Create a lot:
local lot = ecs.create_entity()
ecs.set_entity_name(lot, "Residential Lot 1")
ecs.set_component(lot, "Lot", {
lotName = "Residential Lot 1",
lotType = "residential",
width = 20,
depth = 30
})
-- Create a room:
local room = ecs.create_entity()
ecs.set_entity_name(room, "Kitchen")
ecs.set_component(room, "Room", {
roomName = "Kitchen",
roomType = "kitchen",
floor = 0
})
print("Created town: " .. name)
return town
end
local rivendell = create_town("Rivendell")
-- =============================================================================
-- Working with Furniture Components
-- =============================================================================
-- Create a furniture template:
function create_furniture(name, mesh, category)
local furniture = ecs.create_entity()
ecs.set_entity_name(furniture, name)
ecs.set_component(furniture, "FurnitureTemplate", {
templateName = name,
meshName = mesh or "chair.mesh",
category = category or "seating"
})
print("Created furniture: " .. name)
return furniture
end
local chair = create_furniture("Wooden Chair", "chair.mesh", "seating")
-- =============================================================================
-- Working with Animation Components
-- =============================================================================
-- Add animation tree to a character:
ecs.set_component(npc, "AnimationTree", {
treeName = "humanoid",
enabled = true
})
ecs.set_component(npc, "AnimationTreeTemplate", {
templateName = "humanoid_base",
blendTime = 0.2
})
-- =============================================================================
-- Working with Physics Components
-- =============================================================================
-- Add a physics collider to an entity:
ecs.set_component(house, "PhysicsCollider", {
shapeType = "box",
size = { 5, 3, 5 },
enabled = true
})
-- Add buoyancy info for water physics:
ecs.set_component(house, "BuoyancyInfo", {
enabled = true,
buoyancy = 1.0,
linearDrag = 0.5,
angularDrag = 0.3
})
-- =============================================================================
-- Working with LOD Components
-- =============================================================================
-- Add LOD settings:
ecs.set_component(entity, "Lod", {
lodLevel = 0,
distance = 100.0
})
ecs.set_component(entity, "LodSettings", {
enabled = true,
lodBias = 1.0
})
-- =============================================================================
-- Working with Static Geometry Components
-- =============================================================================
-- Batch entities into static geometry:
ecs.set_component(entity, "StaticGeometry", {
batchName = "forest_batch",
enabled = true
})
ecs.set_component(entity, "StaticGeometryMember", {
parentBatch = "forest_batch",
enabled = true
})
-- =============================================================================
-- Working with Procedural Components
-- =============================================================================
-- Create procedural textures and materials:
ecs.set_component(entity, "ProceduralTexture", {
textureName = "grass",
width = 512,
height = 512
})
ecs.set_component(entity, "ProceduralMaterial", {
materialName = "ground_mat",
baseColor = { 0.5, 0.5, 0.5, 1.0 }
})
-- =============================================================================
-- Working with Primitive Components
-- =============================================================================
-- Create a primitive shape:
ecs.set_component(entity, "Primitive", {
primitiveType = "box",
size = { 1, 2, 1 }
})
-- =============================================================================
-- Working with Triangle Buffer Components
-- =============================================================================
ecs.set_component(entity, "TriangleBuffer", {
enabled = true,
vertexCount = 100
})
-- =============================================================================
-- Working with Character Slots
-- =============================================================================
ecs.set_component(entity, "CharacterSlots", {
slotCount = 8
})
-- =============================================================================
-- Working with Roof Components
-- =============================================================================
ecs.set_component(entity, "Roof", {
roofType = "gable",
height = 2.5,
enabled = true
})
-- =============================================================================
-- Working with ClearArea Components
-- =============================================================================
ecs.set_component(entity, "ClearArea", {
radius = 5.0,
enabled = true
})
-- =============================================================================
-- Working with Action Database Components
-- =============================================================================
ecs.set_component(entity, "ActionDatabase", {
enabled = true
})
ecs.set_component(entity, "ActionDebug", {
enabled = true,
showDebugInfo = true
})
-- =============================================================================
-- Working with Startup Menu Components
-- =============================================================================
ecs.set_component(entity, "StartupMenu", {
enabled = true,
showOnStart = true
})
-- =============================================================================
-- Component Lifecycle Summary
-- =============================================================================
-- Components can be:
-- 1. Added: ecs.add_component(entity, "ComponentName")
-- 2. Removed: ecs.remove_component(entity, "ComponentName")
-- 3. Checked: ecs.has_component(entity, "ComponentName")
-- 4. Get: ecs.get_component(entity, "ComponentName")
-- 5. Set: ecs.set_component(entity, "ComponentName", { fields... })
-- 6. Field: ecs.get_field(entity, "ComponentName", "fieldName")
-- 7. Field: ecs.set_field(entity, "ComponentName", "fieldName", value)
print("Component API examples completed successfully!")

View File

@@ -0,0 +1,109 @@
-- =============================================================================
-- Dialogue: Basic Show via Event System
-- =============================================================================
-- This example demonstrates how to show a simple dialogue box using the
-- EventBus "dialogue_show" event.
--
-- The DialogueSystem listens for "dialogue_show" events and displays the
-- text on any entity that has a DialogueComponent.
--
-- Event payload parameters:
-- "text" (string) - Narration text to display
-- "speaker" (string) - Optional speaker name (shown above text)
-- "choices" (string) - Comma-separated choice labels (optional)
-- "auto_progress" (int) - If 1, click anywhere progresses (no choices)
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. Create an entity with a DialogueComponent
-- ---------------------------------------------------------------------------
-- First we need an entity that has the Dialogue component so the system
-- knows where to render the dialogue box.
local dialogue_entity = ecs.create_entity()
ecs.set_entity_name(dialogue_entity, "DialogueBox")
-- Add the Dialogue component with default settings:
ecs.add_component(dialogue_entity, "Dialogue")
-- You can also configure the dialogue box appearance:
ecs.set_component(dialogue_entity, "Dialogue", {
fontName = "Jupiteroid-Regular.ttf",
fontSize = 24.0,
speakerFontSize = 20.0,
backgroundOpacity = 0.85,
boxHeightFraction = 0.25, -- 25% of screen height
boxPositionFraction = 0.75, -- bottom quarter of screen
enabled = true
})
print("Dialogue entity created with ID: " .. dialogue_entity)
-- ---------------------------------------------------------------------------
-- 2. Show a simple narration (no choices)
-- ---------------------------------------------------------------------------
-- Send a "dialogue_show" event with just text. The dialogue box will appear
-- and the player can click anywhere to dismiss it.
ecs.send_event("dialogue_show", {
stringValues = {
text = "Welcome to the world of World2!",
speaker = "Narrator"
}
})
print("Sent basic narration dialogue")
-- ---------------------------------------------------------------------------
-- 3. Show dialogue with player choices
-- ---------------------------------------------------------------------------
-- When "choices" is provided (comma-separated), the dialogue box shows
-- buttons instead of click-to-progress. The player must pick one.
ecs.send_event("dialogue_show", {
stringValues = {
text = "Where would you like to go?",
speaker = "Guide",
choices = "The Forest,The Village,The Mountains"
}
})
print("Sent dialogue with choices")
-- ---------------------------------------------------------------------------
-- 4. Show dialogue without a speaker name
-- ---------------------------------------------------------------------------
ecs.send_event("dialogue_show", {
stringValues = {
text = "A mysterious voice echoes through the chamber..."
}
})
print("Sent anonymous narration")
-- ---------------------------------------------------------------------------
-- 5. Multi-line dialogue (use \n for line breaks)
-- ---------------------------------------------------------------------------
ecs.send_event("dialogue_show", {
stringValues = {
text = "Greetings, traveler.\n\nI have been expecting you.\nThe prophecy spoke of your arrival.",
speaker = "Elder Marcus"
}
})
print("Sent multi-line dialogue")
-- =============================================================================
-- Summary
-- =============================================================================
-- To show dialogue from Lua:
-- 1. Ensure an entity with DialogueComponent exists (create one if needed)
-- 2. Call ecs.send_event("dialogue_show", { stringValues = { ... } })
-- 3. Required: text = "The narration text"
-- 4. Optional: speaker = "Speaker Name"
-- 5. Optional: choices = "Choice1,Choice2,Choice3" (comma-separated)
-- =============================================================================
print("Dialogue basic show examples completed!")

View File

@@ -0,0 +1,224 @@
-- =============================================================================
-- Dialogue: Direct Component API Control
-- =============================================================================
-- This example demonstrates how to control the DialogueComponent directly
-- via the ECS component API, without using the EventBus.
--
-- The DialogueComponent has methods that can be called from C++:
-- show(text, choices, speaker) - Display dialogue
-- progress() - Dismiss (no-choices mode)
-- selectChoice(index) - Select a choice (1-based)
-- isActive() - Check if dialogue is active
-- reset() - Reset to idle state
--
-- From Lua, you manipulate the component's fields directly using the
-- ecs.set_component / ecs.get_component API.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. Create an entity with DialogueComponent
-- ---------------------------------------------------------------------------
local dlg = ecs.create_entity()
ecs.set_entity_name(dlg, "DialogueBox")
ecs.add_component(dlg, "Dialogue")
-- ---------------------------------------------------------------------------
-- 2. Set dialogue text directly via component fields
-- ---------------------------------------------------------------------------
-- Instead of sending an event, you can set the component fields directly.
-- The DialogueSystem will pick up the state change on the next frame.
ecs.set_component(dlg, "Dialogue", {
text = "This dialogue was set directly via the component API!",
speaker = "Lua Script",
enabled = true
})
-- Note: Setting the fields directly does NOT automatically change the state
-- to Showing. You need to also set the state, or use the event system.
-- The DialogueComponent's show() method handles state transitions.
-- ---------------------------------------------------------------------------
-- 3. Read dialogue state from the component
-- ---------------------------------------------------------------------------
local comp = ecs.get_component(dlg, "Dialogue")
if comp then
print("Dialogue text: " .. (comp.text or "(empty)"))
print("Dialogue speaker: " .. (comp.speaker or "(none)"))
print("Dialogue enabled: " .. tostring(comp.enabled))
print("Font: " .. (comp.fontName or "default"))
print("Font size: " .. (comp.fontSize or 24))
end
-- ---------------------------------------------------------------------------
-- 4. Modify individual dialogue fields
-- ---------------------------------------------------------------------------
-- Change just the text:
ecs.set_field(dlg, "Dialogue", "text", "Updated dialogue text!")
-- Change just the speaker:
ecs.set_field(dlg, "Dialogue", "speaker", "Mysterious Stranger")
-- Change appearance settings:
ecs.set_field(dlg, "Dialogue", "backgroundOpacity", 0.9)
ecs.set_field(dlg, "Dialogue", "boxHeightFraction", 0.3)
ecs.set_field(dlg, "Dialogue", "boxPositionFraction", 0.7)
-- Read back the changes:
local updated_text = ecs.get_field(dlg, "Dialogue", "text")
local updated_speaker = ecs.get_field(dlg, "Dialogue", "speaker")
print("Updated text: " .. updated_text)
print("Updated speaker: " .. updated_speaker)
-- ---------------------------------------------------------------------------
-- 5. Toggle dialogue visibility
-- ---------------------------------------------------------------------------
-- Disable the dialogue box:
ecs.set_field(dlg, "Dialogue", "enabled", false)
print("Dialogue disabled")
-- Re-enable it:
ecs.set_field(dlg, "Dialogue", "enabled", true)
print("Dialogue re-enabled")
-- ---------------------------------------------------------------------------
-- 6. Check if dialogue component exists
-- ---------------------------------------------------------------------------
if ecs.has_component(dlg, "Dialogue") then
print("Entity has a Dialogue component")
end
-- ---------------------------------------------------------------------------
-- 7. Remove the dialogue component entirely
-- ---------------------------------------------------------------------------
-- ecs.remove_component(dlg, "Dialogue")
-- print("Dialogue component removed")
-- ---------------------------------------------------------------------------
-- 8. Practical: Configure dialogue appearance per-NPC
-- ---------------------------------------------------------------------------
function create_npc_with_dialogue(name, mesh, greeting_text)
local npc = ecs.create_entity()
ecs.set_entity_name(npc, name)
-- Basic NPC setup
ecs.set_component(npc, "Transform", {
position = { 0, 0, 0 },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
ecs.set_component(npc, "Renderable", {
meshName = mesh or "character.mesh",
visible = true
})
-- Dialogue component with NPC-specific appearance
ecs.set_component(npc, "Dialogue", {
text = greeting_text or "Hello!",
speaker = name,
fontName = "Jupiteroid-Regular.ttf",
fontSize = 24.0,
speakerFontSize = 20.0,
backgroundOpacity = 0.85,
boxHeightFraction = 0.25,
boxPositionFraction = 0.75,
enabled = true
})
print("Created NPC with dialogue: " .. name)
return npc
end
-- Create a few NPCs with different dialogue configurations
local merchant = create_npc_with_dialogue(
"Merchant",
"merchant.mesh",
"Welcome to my shop! Best wares in town."
)
local guard = create_npc_with_dialogue(
"Guard",
"guard.mesh",
"Halt! Who goes there?"
)
-- ---------------------------------------------------------------------------
-- 9. Practical: Update dialogue based on game events
-- ---------------------------------------------------------------------------
function update_npc_dialogue(npc_entity, new_text, new_speaker)
-- Update the dialogue text and speaker
ecs.set_field(npc_entity, "Dialogue", "text", new_text)
if new_speaker then
ecs.set_field(npc_entity, "Dialogue", "speaker", new_speaker)
end
-- Show the updated dialogue via event (this triggers the state change)
ecs.send_event("dialogue_show", {
stringValues = {
text = new_text,
speaker = new_speaker or ecs.get_field(npc_entity, "Dialogue", "speaker")
}
})
end
-- Update the merchant's dialogue after a transaction
update_npc_dialogue(merchant, "Thank you for your business! Come again.")
-- Update the guard's dialogue when player has high reputation
update_npc_dialogue(guard, "At ease, friend. The town is safe with you around.")
-- ---------------------------------------------------------------------------
-- 10. Practical: Dialogue with dynamic choices from component data
-- ---------------------------------------------------------------------------
function show_dialogue_with_dynamic_choices(npc_entity, base_text, choice_list)
-- choice_list is a table of strings
local choices_str = table.concat(choice_list, ",")
-- Update the component
ecs.set_field(npc_entity, "Dialogue", "text", base_text)
-- Show via event (which handles state transitions properly)
ecs.send_event("dialogue_show", {
stringValues = {
text = base_text,
speaker = ecs.get_field(npc_entity, "Dialogue", "speaker"),
choices = choices_str
}
})
end
-- Example: Shop inventory as dialogue choices
local shop_items = { "Buy Sword (50 gold)", "Buy Shield (30 gold)", "Buy Potion (10 gold)", "Leave" }
show_dialogue_with_dynamic_choices(merchant, "What would you like to buy?", shop_items)
-- =============================================================================
-- Summary
-- =============================================================================
-- Direct component API vs EventBus approach:
--
-- Component API (ecs.set_component / ecs.get_component):
-- - Read/write any DialogueComponent field
-- - Configure appearance (font, size, opacity, position)
-- - Toggle enabled/disabled
-- - Does NOT trigger state transitions (Showing/AwaitingChoice/Idle)
--
-- EventBus (ecs.send_event "dialogue_show"):
-- - Triggers proper state transitions
-- - Parses choices from comma-separated string
-- - Best for showing dialogue to the player
--
-- Best practice: Use the EventBus to SHOW dialogue, and the component API
-- to CONFIGURE the dialogue box appearance.
-- =============================================================================
print("Dialogue component API examples completed!")

View File

@@ -0,0 +1,275 @@
-- =============================================================================
-- Dialogue: EventHandler Component Integration
-- =============================================================================
-- This example demonstrates how to use the EventHandlerComponent to trigger
-- dialogue automatically when an event is received.
--
-- The EventHandlerComponent links an event name to a GoapAction. When the
-- event fires, the action's behavior tree is executed. This allows you to
-- wire up dialogue triggers declaratively without writing Lua code.
--
-- Combined with the EventBus, you can create complex event-driven dialogue
-- sequences where one event triggers dialogue, and the player's choice
-- triggers another event.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. Create the dialogue entity
-- ---------------------------------------------------------------------------
local dlg = ecs.create_entity()
ecs.set_entity_name(dlg, "DialogueBox")
ecs.add_component(dlg, "Dialogue")
-- ---------------------------------------------------------------------------
-- 2. Create an NPC with EventHandler for dialogue triggers
-- ---------------------------------------------------------------------------
local npc = ecs.create_entity()
ecs.set_entity_name(npc, "QuestGiver")
ecs.set_component(npc, "Transform", {
position = { 5, 0, 5 },
rotation = { 1, 0, 0, 0 },
scale = { 1, 1, 1 }
})
ecs.set_component(npc, "Renderable", {
meshName = "character.mesh",
visible = true
})
-- Add an EventHandler that triggers dialogue when the player approaches
ecs.set_component(npc, "EventHandler", {
eventName = "player_approached",
actionName = "npc_greeting",
enabled = true
})
print("Created NPC with EventHandler for player_approached event")
-- ---------------------------------------------------------------------------
-- 3. Create an EventHandler that triggers on quest completion
-- ---------------------------------------------------------------------------
local quest_npc = ecs.create_entity()
ecs.set_entity_name(quest_npc, "QuestRewarder")
ecs.set_component(quest_npc, "EventHandler", {
eventName = "quest_completed",
actionName = "quest_reward_dialogue",
enabled = true
})
print("Created NPC with EventHandler for quest_completed event")
-- ---------------------------------------------------------------------------
-- 4. Trigger dialogue via events from other game systems
-- ---------------------------------------------------------------------------
-- Simulate a proximity trigger: when the player gets close to an NPC,
-- send an event that triggers the dialogue.
function on_player_near_npc(npc_name, distance)
print("Player is " .. distance .. "m from " .. npc_name)
if distance < 5.0 then
-- Send the event that the EventHandler is listening for
ecs.send_event("player_approached", {
stringValues = {
npc_name = npc_name,
location = "town_square"
},
floatValues = {
distance = distance
}
})
-- Also show dialogue directly
ecs.send_event("dialogue_show", {
stringValues = {
text = "Hello there! I have a quest for a brave adventurer.",
speaker = npc_name,
choices = "I'll help!,What's the reward?,Not interested"
}
})
end
end
-- Simulate the player approaching
on_player_near_npc("QuestGiver", 3.0)
-- ---------------------------------------------------------------------------
-- 5. Chain events: choice -> event -> next dialogue
-- ---------------------------------------------------------------------------
-- When the player makes a choice, we can send a new event that triggers
-- another EventHandler, creating a chain reaction.
-- Subscribe to dialogue choices
local choice_sub = ecs.subscribe_event("dialogue_choice", function(event, params)
local choice_index = params.values and params.values.choice_index or 0
local choice_text = params.stringValues and params.stringValues.choice_text or ""
if choice_text == "I'll help!" then
-- Player accepted the quest - trigger quest acceptance event
ecs.send_event("quest_accepted", {
stringValues = {
quest_name = "The Lost Artifact",
giver = "QuestGiver"
},
values = {
reward_gold = 100,
reward_xp = 500
}
})
-- Show follow-up dialogue
ecs.send_event("dialogue_show", {
stringValues = {
text = "Excellent! The ancient artifact was stolen from the temple.\nBring it back and you'll be richly rewarded!",
speaker = "QuestGiver",
choices = "Where is the temple?,I'm on it!,Tell me more"
}
})
elseif choice_text == "What's the reward?" then
ecs.send_event("dialogue_show", {
stringValues = {
text = "100 gold pieces and a magical amulet! What do you say?",
speaker = "QuestGiver",
choices = "I'll help!,Sounds good,Maybe later"
}
})
elseif choice_text == "Not interested" then
ecs.send_event("dialogue_show", {
stringValues = {
text = "Very well. The offer stands if you change your mind.",
speaker = "QuestGiver"
}
})
end
end)
print("Subscribed to dialogue_choice for event chaining")
-- ---------------------------------------------------------------------------
-- 6. Subscribe to custom events for game logic
-- ---------------------------------------------------------------------------
-- Listen for quest acceptance
local quest_sub = ecs.subscribe_event("quest_accepted", function(event, params)
local quest_name = params.stringValues and params.stringValues.quest_name or "unknown"
local reward = params.values and params.values.reward_gold or 0
local xp = params.values and params.values.reward_xp or 0
print("Quest accepted: " .. quest_name)
print(" Reward: " .. reward .. " gold, " .. xp .. " XP")
-- This could trigger other EventHandlers on other entities
ecs.send_event("quest_log_updated", {
stringValues = { quest_name = quest_name },
values = { active_quests = 1 }
})
end)
print("Subscribed to quest_accepted events")
-- ---------------------------------------------------------------------------
-- 7. Practical: Zone entry dialogue
-- ---------------------------------------------------------------------------
-- When the player enters a new area, show contextual dialogue.
function on_zone_entered(zone_name)
local zone_dialogues = {
forest = {
text = "You enter the Dark Forest. The trees loom overhead,\nblocking out the sunlight.",
speaker = "Narrator"
},
village = {
text = "Welcome to Greenhaven Village. Smoke rises from\nchimneys and children play in the streets.",
speaker = "Narrator"
},
dungeon = {
text = "The air grows cold and damp as you descend into\nthe ancient dungeon. Somewhere, water drips.",
speaker = "Narrator"
},
beach = {
text = "The sea stretches to the horizon. Waves crash\nagainst the shore. A ship is docked nearby.",
speaker = "Narrator"
}
}
local dialogue = zone_dialogues[zone_name]
if dialogue then
ecs.send_event("dialogue_show", {
stringValues = {
text = dialogue.text,
speaker = dialogue.speaker
}
})
-- Also send a zone-specific event for other systems
ecs.send_event("zone_entered", {
stringValues = { zone = zone_name }
})
end
end
-- Simulate zone transitions
on_zone_entered("forest")
on_zone_entered("village")
on_zone_entered("dungeon")
-- ---------------------------------------------------------------------------
-- 8. Practical: Item pickup dialogue
-- ---------------------------------------------------------------------------
function on_item_picked_up(item_name, item_count)
local pickup_messages = {
health_potion = "You pick up a Health Potion. It glows with a warm light.",
ancient_key = "An ancient key, covered in rust. It must open something important.",
gold_coins = "You find " .. item_count .. " gold coins. They clink satisfyingly.",
mysterious_map = "A faded map with markings you can't decipher. Someone might know what it means.",
sword = "A fine steel sword. It feels balanced in your hand."
}
local message = pickup_messages[item_name]
if message then
ecs.send_event("dialogue_show", {
stringValues = {
text = message,
speaker = "Narrator"
}
})
end
end
-- Simulate item pickups
on_item_picked_up("health_potion", 1)
on_item_picked_up("ancient_key", 1)
on_item_picked_up("gold_coins", 50)
-- =============================================================================
-- Summary
-- =============================================================================
-- EventHandler + Dialogue integration patterns:
--
-- 1. Proximity triggers:
-- Player near NPC -> send event -> EventHandler triggers action -> dialogue
--
-- 2. Choice chaining:
-- Player picks choice -> send event -> EventHandler triggers -> next dialogue
--
-- 3. Zone entry:
-- Player enters area -> send event -> dialogue shows description
--
-- 4. Item pickup:
-- Player picks up item -> send event -> contextual dialogue
--
-- 5. Quest flow:
-- Accept quest -> event -> update quest log -> next dialogue
-- Complete quest -> event -> reward dialogue -> next dialogue
-- =============================================================================
print("Dialogue EventHandler integration examples completed!")

View File

@@ -0,0 +1,141 @@
-- =============================================================================
-- Dialogue: Event Subscription & Choice Handling
-- =============================================================================
-- This example demonstrates how to subscribe to dialogue-related events
-- and handle player choices from Lua.
--
-- The DialogueSystem fires events when the player interacts with the
-- dialogue box. You can subscribe to these events to drive game logic.
--
-- Dialogue-related events you can subscribe to:
-- "dialogue_show" - Fired when dialogue should be displayed
-- "dialogue_choice" - Fired when player selects a choice
-- "dialogue_dismiss" - Fired when dialogue is dismissed (no choices)
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. Create the dialogue entity
-- ---------------------------------------------------------------------------
local dialogue_entity = ecs.create_entity()
ecs.set_entity_name(dialogue_entity, "DialogueBox")
ecs.add_component(dialogue_entity, "Dialogue")
-- ---------------------------------------------------------------------------
-- 2. Subscribe to dialogue choice events
-- ---------------------------------------------------------------------------
-- When the player selects a choice in the dialogue box, we can react to it.
-- The DialogueComponent's onChoiceSelected callback fires with the 1-based
-- choice index. We bridge this via the EventBus.
local choice_sub = ecs.subscribe_event("dialogue_choice", function(event, params)
local choice_index = params.values and params.values.choice_index or 0
local choice_text = params.stringValues and params.stringValues.choice_text or "unknown"
print("Player selected choice #" .. choice_index .. ": " .. choice_text)
-- React based on which choice was selected
if choice_index == 1 then
print(" -> Player chose the first option!")
elseif choice_index == 2 then
print(" -> Player chose the second option!")
elseif choice_index == 3 then
print(" -> Player chose the third option!")
end
end)
print("Subscribed to dialogue_choice events (ID: " .. choice_sub .. ")")
-- ---------------------------------------------------------------------------
-- 3. Subscribe to dialogue dismiss events
-- ---------------------------------------------------------------------------
-- When dialogue is dismissed (clicked through with no choices), we can
-- trigger follow-up actions.
local dismiss_sub = ecs.subscribe_event("dialogue_dismiss", function(event, params)
print("Dialogue was dismissed by the player")
-- You could trigger follow-up dialogue or game logic here
local next_text = params.stringValues and params.stringValues.next_text or ""
if next_text ~= "" then
print(" -> Next dialogue queued: " .. next_text)
end
end)
print("Subscribed to dialogue_dismiss events (ID: " .. dismiss_sub .. ")")
-- ---------------------------------------------------------------------------
-- 4. Subscribe to dialogue show events (for logging/tracking)
-- ---------------------------------------------------------------------------
local show_sub = ecs.subscribe_event("dialogue_show", function(event, params)
local text = params.stringValues and params.stringValues.text or ""
local speaker = params.stringValues and params.stringValues.speaker or "Unknown"
local choices = params.stringValues and params.stringValues.choices or ""
print("[Dialogue Log] " .. speaker .. ": \"" .. text .. "\"")
if choices ~= "" then
print("[Dialogue Log] Choices: " .. choices)
end
end)
print("Subscribed to dialogue_show events for logging (ID: " .. show_sub .. ")")
-- ---------------------------------------------------------------------------
-- 5. Example: Branching dialogue with choice handling
-- ---------------------------------------------------------------------------
-- This shows a complete flow: show dialogue -> handle choice -> react
function show_branching_dialogue()
-- Step 1: Show the dialogue with choices
ecs.send_event("dialogue_show", {
stringValues = {
text = "You see a dark cave entrance. What do you do?",
speaker = "Narrator",
choices = "Enter the cave,Look around first,Leave"
}
})
-- Step 2: The choice will be handled by our subscriber above.
-- In a real scenario, you'd use a state machine or coroutine to
-- manage the flow. See dialogue_sequence.lua for a more advanced example.
end
show_branching_dialogue()
-- ---------------------------------------------------------------------------
-- 6. Example: NPC greeting with follow-up
-- ---------------------------------------------------------------------------
function npc_greeting(npc_name, greeting_text)
-- Show initial greeting
ecs.send_event("dialogue_show", {
stringValues = {
text = greeting_text,
speaker = npc_name,
choices = "Who are you?,Tell me about this place,Goodbye"
}
})
-- The choice subscriber will handle the response.
-- You could extend this with a lookup table for NPC responses.
end
npc_greeting("Elder Marcus", "Ah, a new face in our village! Welcome, traveler.")
-- =============================================================================
-- Summary
-- =============================================================================
-- To handle dialogue choices from Lua:
-- 1. Subscribe to "dialogue_choice" events
-- 2. Check params.values.choice_index (1-based) to see which was picked
-- 3. Check params.stringValues.choice_text for the label text
-- 4. React accordingly in your game logic
--
-- To handle dialogue dismissal:
-- 1. Subscribe to "dialogue_dismiss" events
-- 2. Trigger follow-up actions as needed
-- =============================================================================
print("Dialogue event subscription examples completed!")

View File

@@ -0,0 +1,338 @@
-- =============================================================================
-- Dialogue: Sequential Dialogue with Coroutines
-- =============================================================================
-- This example demonstrates how to create sequential, branching dialogue
-- using Lua coroutines. This is the most practical approach for story-driven
-- dialogue where you need to wait for player input between lines.
--
-- The pattern:
-- 1. Show dialogue with choices
-- 2. Wait for player to select a choice (via event subscription)
-- 3. React and show next dialogue based on the choice
-- 4. Repeat until the conversation ends
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. Create the dialogue entity
-- ---------------------------------------------------------------------------
local dialogue_entity = ecs.create_entity()
ecs.set_entity_name(dialogue_entity, "DialogueBox")
ecs.add_component(dialogue_entity, "Dialogue")
-- ---------------------------------------------------------------------------
-- 2. Dialogue Queue System
-- ---------------------------------------------------------------------------
-- A simple queue that lets you chain dialogue lines and wait for player
-- input between each one.
local DialogueQueue = {}
local dialogue_queue_active = false
local dialogue_queue_pending = false
local dialogue_queue_choice = 0
local dialogue_queue_choice_text = ""
-- Subscribe to choice events to unblock the queue
local queue_choice_sub = ecs.subscribe_event("dialogue_choice", function(event, params)
if dialogue_queue_pending then
dialogue_queue_choice = params.values and params.values.choice_index or 0
dialogue_queue_choice_text = params.stringValues and params.stringValues.choice_text or ""
dialogue_queue_pending = false
end
end)
-- Subscribe to dismiss events to unblock the queue
local queue_dismiss_sub = ecs.subscribe_event("dialogue_dismiss", function(event, params)
if dialogue_queue_pending then
dialogue_queue_choice = -1 -- signal dismissed
dialogue_queue_pending = false
end
end)
-- ---------------------------------------------------------------------------
-- 3. Helper: Show dialogue and wait for player response
-- ---------------------------------------------------------------------------
--- Show a line of dialogue and wait for the player to respond.
--- @param text string The narration text
--- @param speaker string|nil Optional speaker name
--- @param choices string|nil Comma-separated choices (nil = click to dismiss)
--- @return number choice_index (0 if dismissed, 1+ for choices)
function show_and_wait(text, speaker, choices)
-- Send the dialogue event
ecs.send_event("dialogue_show", {
stringValues = {
text = text,
speaker = speaker or "",
choices = choices or ""
}
})
-- Wait for player response
dialogue_queue_pending = true
dialogue_queue_choice = 0
-- Busy-wait loop (in a real coroutine-based system, you'd yield here)
-- This is a simplified version; see the coroutine example below.
local timeout = 1000
while dialogue_queue_pending and timeout > 0 do
-- In a real game loop, this would be a coroutine yield.
-- For this example, we simulate with a counter.
timeout = timeout - 1
if timeout <= 0 then
dialogue_queue_pending = false
print("WARNING: Dialogue wait timed out!")
end
end
return dialogue_queue_choice
end
-- ---------------------------------------------------------------------------
-- 4. Example: Simple linear conversation
-- ---------------------------------------------------------------------------
function simple_conversation()
print("=== Simple Conversation ===")
-- Line 1: Narration with no choices (click to continue)
ecs.send_event("dialogue_show", {
stringValues = {
text = "The old man sits by the fire, staring into the flames.",
speaker = "Narrator"
}
})
-- In a real game, you'd wait for the dismiss event here.
-- For this example, we just show the pattern.
-- Line 2: NPC speaks with choices
ecs.send_event("dialogue_show", {
stringValues = {
text = "I've been expecting you. The darkness grows stronger each day.",
speaker = "Old Man",
choices = "Tell me more,How can I help?,I must go"
}
})
print(" (Player would now see choices and pick one)")
end
-- ---------------------------------------------------------------------------
-- 5. Example: Branching conversation tree
-- ---------------------------------------------------------------------------
-- Define a conversation tree as a table of nodes
local conversations = {
village_elder = {
greeting = {
text = "Welcome to our village, stranger. What brings you here?",
speaker = "Elder Marcus",
choices = {
{ text = "I seek adventure", next = "adventure" },
{ text = "Just passing through", next = "passing" },
{ text = "I need a place to stay", next = "lodging" }
}
},
adventure = {
text = "Adventure, you say? The old ruins to the east have been stirring.",
speaker = "Elder Marcus",
choices = {
{ text = "Tell me about the ruins", next = "ruins" },
{ text = "I'll check them out", next = "goodbye_adventure" },
{ text = "Maybe another time", next = "goodbye" }
}
},
passing = {
text = "Safe travels! The road north is clear, but beware the forest at night.",
speaker = "Elder Marcus",
choices = {
{ text = "Thank you for the warning", next = "goodbye" },
{ text = "What's in the forest?", next = "forest" }
}
},
lodging = {
text = "The inn is just down the road. Tell them Marcus sent you.",
speaker = "Elder Marcus",
choices = {
{ text = "Thank you, elder", next = "goodbye" },
{ text = "Is there work in town?", next = "work" }
}
},
ruins = {
text = "Ancient ruins, full of traps and treasure. Several have entered, few returned.",
speaker = "Elder Marcus",
choices = {
{ text = "I'm not afraid", next = "goodbye_adventure" },
{ text = "Sounds too dangerous", next = "goodbye" }
}
},
forest = {
text = "Strange creatures have been seen. Wolves the size of horses!",
speaker = "Elder Marcus",
choices = {
{ text = "I'll be careful", next = "goodbye" },
{ text = "I can handle it", next = "goodbye_adventure" }
}
},
work = {
text = "The blacksmith needs an apprentice. Ask for Henrik.",
speaker = "Elder Marcus",
choices = {
{ text = "I'll visit the blacksmith", next = "goodbye" },
{ text = "Thanks, but I'll move on", next = "goodbye" }
}
},
goodbye_adventure = {
text = "Good luck, brave one. You'll need it.",
speaker = "Elder Marcus",
choices = {
{ text = "Farewell!", next = nil }
}
},
goodbye = {
text = "May the winds guide you safely.",
speaker = "Elder Marcus",
choices = {
{ text = "Farewell!", next = nil }
}
}
}
}
--- Walk through a conversation tree.
--- @param tree table The conversation tree definition
--- @param start_node string The starting node name
function run_conversation(tree, start_node)
print("=== Starting Conversation ===")
local current_node_name = start_node
local max_steps = 20
local step = 0
while current_node_name and step < max_steps do
step = step + 1
local node = tree[current_node_name]
if not node then
print("ERROR: Unknown conversation node: " .. current_node_name)
break
end
-- Build choices string from the node's choices table
local choices_str = ""
local choice_map = {}
if node.choices then
local parts = {}
for i, choice in ipairs(node.choices) do
table.insert(parts, choice.text)
choice_map[i] = choice
end
choices_str = table.concat(parts, ",")
end
-- Show the dialogue
ecs.send_event("dialogue_show", {
stringValues = {
text = node.text,
speaker = node.speaker or "",
choices = choices_str
}
})
-- In a real game, you'd wait for the player's choice here.
-- For this example, we simulate by picking the first choice.
print(" [Node: " .. current_node_name .. "] " .. node.speaker .. ": \"" .. node.text .. "\"")
-- Simulate picking a choice (in real game, wait for player input)
if node.choices and #node.choices > 0 then
local chosen = node.choices[1] -- Simulate picking first choice
print(" [Player chose: " .. chosen.text .. "]")
current_node_name = chosen.next
else
current_node_name = nil
end
end
print("=== Conversation Ended ===")
end
-- Run the conversation tree
run_conversation(conversations.village_elder, "greeting")
-- ---------------------------------------------------------------------------
-- 6. Example: NPC dialogue with state tracking
-- ---------------------------------------------------------------------------
-- Track NPC dialogue state
local npc_state = {
marcus_met = false,
marcus_friendship = 0,
quest_active = false,
quest_completed = false
}
function talk_to_elder_marcus()
if not npc_state.marcus_met then
-- First meeting
npc_state.marcus_met = true
npc_state.marcus_friendship = 10
ecs.send_event("dialogue_show", {
stringValues = {
text = "Ah, a new face! I am Elder Marcus, keeper of this village.\nIt's been so long since we had visitors.",
speaker = "Elder Marcus",
choices = "Pleasure to meet you,I've heard stories about you,Hello"
}
})
elseif npc_state.quest_active and npc_state.quest_completed then
-- Quest completed
npc_state.marcus_friendship = npc_state.marcus_friendship + 50
ecs.send_event("dialogue_show", {
stringValues = {
text = "You did it! The village is safe thanks to you.\nPlease, take this reward.",
speaker = "Elder Marcus",
choices = "Thank you, elder,I was happy to help"
}
})
elseif npc_state.quest_active then
-- Quest in progress
ecs.send_event("dialogue_show", {
stringValues = {
text = "Have you dealt with those bandits yet?\nThe villagers are growing anxious.",
speaker = "Elder Marcus",
choices = "I'm working on it,I need more information,Not yet"
}
})
else
-- Regular greeting
ecs.send_event("dialogue_show", {
stringValues = {
text = "Welcome back, friend. The village is peaceful today.",
speaker = "Elder Marcus",
choices = "Any news?,I need supplies,Goodbye"
}
})
end
end
-- Simulate talking to Marcus a few times
print("=== NPC State Tracking ===")
talk_to_elder_marcus() -- First meeting
npc_state.quest_active = true
talk_to_elder_marcus() -- Quest active
npc_state.quest_completed = true
talk_to_elder_marcus() -- Quest completed
-- =============================================================================
-- Summary
-- =============================================================================
-- For sequential dialogue:
-- 1. Use a queue/coroutine pattern to chain dialogue lines
-- 2. Subscribe to "dialogue_choice" and "dialogue_dismiss" events
-- 3. Wait for player input between each line
-- 4. Use conversation trees for branching narratives
-- 5. Track NPC state to change dialogue based on game progress
-- =============================================================================
print("Dialogue sequence examples completed!")

View File

@@ -0,0 +1,177 @@
-- =============================================================================
-- Entity Lua API Examples
-- =============================================================================
-- This file demonstrates how to create, manage, and query entities from Lua
-- using the ecs.* Lua API.
--
-- Entities are the fundamental building blocks of the ECS world. Every object
-- in the game world is an entity with a unique numeric ID.
-- =============================================================================
-- =============================================================================
-- Creating Entities
-- =============================================================================
-- Create a new entity. Returns a numeric ID.
local player = ecs.create_entity()
print("Created entity with ID: " .. player)
-- Create multiple entities:
local npc1 = ecs.create_entity()
local npc2 = ecs.create_entity()
local item = ecs.create_entity()
-- =============================================================================
-- Entity Existence and Lifecycle
-- =============================================================================
-- Check if an entity exists:
if ecs.entity_exists(player) then
print("Player entity exists")
end
-- Check a non-existent entity:
if not ecs.entity_exists(999999) then
print("Entity 999999 does not exist")
end
-- Destroy an entity:
ecs.destroy_entity(npc2)
if not ecs.entity_exists(npc2) then
print("npc2 was destroyed")
end
-- =============================================================================
-- Entity Names
-- =============================================================================
-- Set an entity's name:
ecs.set_entity_name(player, "Hero")
ecs.set_entity_name(npc1, "Villager_01")
ecs.set_entity_name(item, "Health_Potion")
-- Get an entity's name:
local name = ecs.get_entity_name(player)
print("Player name: " .. name)
-- Look up an entity by name:
local found = ecs.get_entity_by_name("Villager_01")
if found then
print("Found entity " .. found .. " with name 'Villager_01'")
end
-- Look up a non-existent name:
local not_found = ecs.get_entity_by_name("Nonexistent")
if not_found == nil then
print("'Nonexistent' not found (returns nil)")
end
-- =============================================================================
-- Entity Hierarchy (Parent/Children)
-- =============================================================================
-- Create a parent-child hierarchy:
local house = ecs.create_entity()
ecs.set_entity_name(house, "House")
local door = ecs.create_entity()
ecs.set_entity_name(door, "Door")
local window = ecs.create_entity()
ecs.set_entity_name(window, "Window")
-- Check parent of a child (initially none):
local p = ecs.parent(door)
if p == nil then
print("Door has no parent initially")
end
-- Check children of a parent (initially none):
local kids = ecs.children(house)
print("House has " .. #kids .. " children initially")
-- =============================================================================
-- Practical Example: Creating a Scene
-- =============================================================================
-- Create a complete scene with entities:
function create_scene()
-- Create terrain
local terrain = ecs.create_entity()
ecs.set_entity_name(terrain, "Terrain")
-- Create lighting
local sun = ecs.create_entity()
ecs.set_entity_name(sun, "Sun")
-- Create buildings
local buildings = {}
for i = 1, 3 do
local bldg = ecs.create_entity()
ecs.set_entity_name(bldg, "Building_" .. i)
table.insert(buildings, bldg)
end
-- Create characters
local characters = {}
for i = 1, 5 do
local char = ecs.create_entity()
ecs.set_entity_name(char, "Character_" .. i)
table.insert(characters, char)
end
print("Scene created with:")
print(" 1 terrain entity")
print(" 1 sun entity")
print(" " .. #buildings .. " buildings")
print(" " .. #characters .. " characters")
return {
terrain = terrain,
sun = sun,
buildings = buildings,
characters = characters
}
end
local scene = create_scene()
-- =============================================================================
-- Entity ID Properties
-- =============================================================================
-- Entity IDs are positive integers:
local e = ecs.create_entity()
assert(type(e) == "number", "Entity ID should be a number")
assert(e > 0, "Entity ID should be positive")
-- Each entity gets a unique ID:
local ids = {}
for i = 1, 10 do
ids[i] = ecs.create_entity()
end
for i = 1, 10 do
for j = i + 1, 10 do
assert(ids[i] ~= ids[j], "IDs should be unique")
end
end
print("All 10 entities have unique IDs")
-- Destroyed entity IDs are not reused immediately:
local old_id = ecs.create_entity()
ecs.destroy_entity(old_id)
local new_id = ecs.create_entity()
assert(new_id ~= old_id, "New entity should have different ID")
print("Destroyed entity ID " .. old_id .. " is not reused")
-- =============================================================================
-- Error Handling
-- =============================================================================
-- These operations should not crash:
ecs.destroy_entity(999999) -- destroying non-existent entity is safe
ecs.set_entity_name(999999, "ghost") -- setting name on non-existent entity
local n = ecs.get_entity_name(999999) -- returns empty string
print("Name of non-existent entity: '" .. n .. "'")
print("Entity API examples completed successfully!")

View File

@@ -0,0 +1,285 @@
-- =============================================================================
-- Event Lua API Examples
-- =============================================================================
-- This file demonstrates how to subscribe to, send, and manage events from
-- Lua using the ecs.* Lua API.
--
-- Events are a publish/subscribe mechanism. Any part of the game can send
-- an event, and any subscriber can react to it. This decouples systems
-- from each other.
-- =============================================================================
-- =============================================================================
-- Subscribing to Events
-- =============================================================================
-- Subscribe to an event. Returns a subscription ID (number) that can be
-- used to unsubscribe later.
local sub_id = ecs.subscribe_event("hello", function(event, params)
print("Received event: " .. event)
end)
print("Subscribed to 'hello' with ID: " .. sub_id)
-- =============================================================================
-- Sending Events
-- =============================================================================
-- Send a simple event with no parameters:
ecs.send_event("hello")
-- Send an event with parameters:
ecs.send_event("hello", {
values = { count = 42 },
stringValues = { message = "Hello World!" }
})
-- =============================================================================
-- Event Callback Parameters
-- =============================================================================
-- The callback receives two arguments:
-- 1. event - the event name (string)
-- 2. params - a table with the event data (or nil if no data was sent)
ecs.subscribe_event("player_damaged", function(event, params)
print("Event: " .. event)
if params then
if params.values then
print(" Damage: " .. (params.values.damage or 0))
print(" Health remaining: " .. (params.values.health or 0))
end
if params.stringValues then
print(" Source: " .. (params.stringValues.source or "unknown"))
end
if params.vec3Values then
local pos = params.vec3Values.position
if pos then
print(" Position: " .. pos[1] .. ", " .. pos[2] .. ", " .. pos[3])
end
end
end
end)
-- Send a damage event:
ecs.send_event("player_damaged", {
values = { damage = 25, health = 75 },
stringValues = { source = "goblin_archer" },
vec3Values = { position = { 10, 0, 20 } }
})
-- =============================================================================
-- Unsubscribing from Events
-- =============================================================================
-- When you no longer need to listen to an event, unsubscribe:
local temp_sub = ecs.subscribe_event("temporary", function()
print("This should not be printed after unsubscribe")
end)
ecs.send_event("temporary") -- callback fires
ecs.unsubscribe_event(temp_sub)
ecs.send_event("temporary") -- callback does NOT fire (unsubscribed)
-- =============================================================================
-- Multiple Subscribers
-- =============================================================================
-- Multiple subscribers can listen to the same event. All of them will be
-- called when the event is sent.
local count_a = 0
local count_b = 0
local sub_a = ecs.subscribe_event("multi", function()
count_a = count_a + 1
print("Subscriber A called (total: " .. count_a .. ")")
end)
local sub_b = ecs.subscribe_event("multi", function()
count_b = count_b + 1
print("Subscriber B called (total: " .. count_b .. ")")
end)
ecs.send_event("multi") -- both A and B fire
ecs.send_event("multi") -- both fire again
-- =============================================================================
-- Event Parameter Types
-- =============================================================================
-- Events can carry various types of data in their params table:
ecs.subscribe_event("data_event", function(event, params)
print("Received data_event with:")
if params then
-- Integer values:
if params.values then
for k, v in pairs(params.values) do
print(" int " .. k .. " = " .. v)
end
end
-- Float values:
if params.floatValues then
for k, v in pairs(params.floatValues) do
print(" float " .. k .. " = " .. v)
end
end
-- String values:
if params.stringValues then
for k, v in pairs(params.stringValues) do
print(" string " .. k .. " = '" .. v .. "'")
end
end
-- Vec3 values:
if params.vec3Values then
for k, v in pairs(params.vec3Values) do
print(" vec3 " .. k .. " = (" .. v[1] .. ", " .. v[2] .. ", " .. v[3] .. ")")
end
end
-- Bit flags:
if params.bits ~= nil then
print(" bits = " .. params.bits)
end
if params.mask ~= nil then
print(" mask = " .. params.mask)
end
end
end)
-- Send an event with all parameter types:
ecs.send_event("data_event", {
values = { score = 100, level = 5, kills = 42 },
floatValues = { speed = 1.5, health = 75.5 },
stringValues = { name = "Hero", state = "exploring" },
vec3Values = { position = { 10, 20, 30 }, velocity = { 1, 0, 0 } },
bits = 5,
mask = 7
})
-- =============================================================================
-- Practical Example: Game Event System
-- =============================================================================
-- Create a simple game event system:
local event_handlers = {}
function register_game_handlers()
-- Quest events:
event_handlers.quest_complete = ecs.subscribe_event("quest_complete", function(event, params)
local quest_name = params and params.stringValues and params.stringValues.quest_name or "unknown"
local reward_xp = params and params.values and params.values.reward_xp or 0
print("Quest completed: " .. quest_name .. " (+" .. reward_xp .. " XP)")
end)
-- Combat events:
event_handlers.enemy_killed = ecs.subscribe_event("enemy_killed", function(event, params)
local enemy = params and params.stringValues and params.stringValues.enemy_type or "unknown"
local xp = params and params.values and params.values.xp_reward or 0
print("Killed " .. enemy .. " (+" .. xp .. " XP)")
end)
-- Item events:
event_handlers.item_picked_up = ecs.subscribe_event("item_picked_up", function(event, params)
local item = params and params.stringValues and params.stringValues.item_name or "unknown"
local count = params and params.values and params.values.count or 1
print("Picked up " .. count .. "x " .. item)
end)
-- Dialogue events:
event_handlers.dialogue_started = ecs.subscribe_event("dialogue_started", function(event, params)
local npc = params and params.stringValues and params.stringValues.npc_name or "unknown"
print("Started dialogue with " .. npc)
end)
-- Environment events:
event_handlers.time_changed = ecs.subscribe_event("time_changed", function(event, params)
local hour = params and params.values and params.values.hour or 0
local minute = params and params.values and params.values.minute or 0
print("Time changed to " .. hour .. ":" .. string.format("%02d", minute))
end)
-- Player events:
event_handlers.player_died = ecs.subscribe_event("player_died", function(event, params)
local killer = params and params.stringValues and params.stringValues.killed_by or "unknown"
print("Player was killed by " .. killer .. "!")
end)
print("All game event handlers registered")
end
register_game_handlers()
-- Simulate some game events:
ecs.send_event("quest_complete", {
stringValues = { quest_name = "The Lost Artifact" },
values = { reward_xp = 500 }
})
ecs.send_event("enemy_killed", {
stringValues = { enemy_type = "Goblin Warrior" },
values = { xp_reward = 50 }
})
ecs.send_event("item_picked_up", {
stringValues = { item_name = "Health Potion" },
values = { count = 2 }
})
ecs.send_event("dialogue_started", {
stringValues = { npc_name = "Elder Marcus" }
})
ecs.send_event("time_changed", {
values = { hour = 18, minute = 30 }
})
-- =============================================================================
-- Cleanup: Unsubscribe All
-- =============================================================================
-- When cleaning up, unsubscribe all registered handlers:
function cleanup_event_handlers()
for name, id in pairs(event_handlers) do
ecs.unsubscribe_event(id)
print("Unsubscribed from: " .. name)
end
end
-- Uncomment to clean up:
-- cleanup_event_handlers()
-- =============================================================================
-- Event API Reference
-- =============================================================================
--
-- ecs.subscribe_event(event_name, callback)
-- Subscribe to an event. Returns a subscription ID (number).
-- Parameters:
-- event_name - string, the name of the event to listen for
-- callback - function(event, params), called when the event fires
-- Returns: subscription ID (number)
--
-- ecs.unsubscribe_event(subscription_id)
-- Unsubscribe from an event.
-- Parameters:
-- subscription_id - number, the ID returned by subscribe_event
-- Safe to call with an invalid ID (no crash).
--
-- ecs.send_event(event_name, params)
-- Send an event to all subscribers.
-- Parameters:
-- event_name - string, the name of the event to send
-- params - optional table with event data:
-- .values - table of integer key-value pairs
-- .floatValues - table of float key-value pairs
-- .stringValues - table of string key-value pairs
-- .vec3Values - table of vec3 key-value pairs (each vec3 is {x, y, z})
-- .bits - integer bit flags
-- .mask - integer bit mask
-- =============================================================================
print("Event API examples completed successfully!")

View File

@@ -0,0 +1,576 @@
#include "LuaActionApi.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/BehaviorTree.hpp"
#include <OgreLogManager.h>
#include <cstring>
namespace editScene
{
// ---------------------------------------------------------------------------
// Helper: push a BehaviorTreeNode as a Lua table
// ---------------------------------------------------------------------------
static void pushBehaviorTree(lua_State *L, const BehaviorTreeNode &node)
{
lua_newtable(L);
lua_pushstring(L, node.type.c_str());
lua_setfield(L, -2, "type");
if (!node.name.empty()) {
lua_pushstring(L, node.name.c_str());
lua_setfield(L, -2, "name");
}
if (!node.params.empty()) {
lua_pushstring(L, node.params.c_str());
lua_setfield(L, -2, "params");
}
if (!node.children.empty()) {
lua_newtable(L);
for (size_t i = 0; i < node.children.size(); i++) {
pushBehaviorTree(L, node.children[i]);
lua_rawseti(L, -2, (int)i + 1);
}
lua_setfield(L, -2, "children");
}
}
// ---------------------------------------------------------------------------
// Helper: read a BehaviorTreeNode from a Lua table at given index
// ---------------------------------------------------------------------------
static BehaviorTreeNode readBehaviorTree(lua_State *L, int idx)
{
BehaviorTreeNode node;
if (!lua_istable(L, idx))
return node;
// type (required)
lua_getfield(L, idx, "type");
if (lua_isstring(L, -1))
node.type = lua_tostring(L, -1);
lua_pop(L, 1);
// name (optional)
lua_getfield(L, idx, "name");
if (lua_isstring(L, -1))
node.name = lua_tostring(L, -1);
lua_pop(L, 1);
// params (optional)
lua_getfield(L, idx, "params");
if (lua_isstring(L, -1))
node.params = lua_tostring(L, -1);
lua_pop(L, 1);
// children (optional array)
lua_getfield(L, idx, "children");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
// key is numeric index, value is child table
if (lua_istable(L, -1)) {
node.children.push_back(
readBehaviorTree(L, lua_gettop(L)));
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return node;
}
// ---------------------------------------------------------------------------
// Helper: push a GoapBlackboard as a Lua table
// ---------------------------------------------------------------------------
static void pushBlackboard(lua_State *L, const GoapBlackboard &bb)
{
lua_newtable(L); // blackboard table
// Bits
lua_newtable(L); // bits table
for (int i = 0; i < 64; i++) {
if (bb.hasBit(i)) {
const char *name = GoapBlackboard::getBitName(i);
const char *key = name ? name : "";
lua_pushboolean(L, bb.getBit(i));
lua_setfield(L, -2, key);
}
}
lua_setfield(L, -2, "bits");
// Integer values
lua_newtable(L);
for (const auto &kv : bb.values) {
lua_pushinteger(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "values");
// Float values
lua_newtable(L);
for (const auto &kv : bb.floatValues) {
lua_pushnumber(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "floatValues");
// String values
lua_newtable(L);
for (const auto &kv : bb.stringValues) {
lua_pushstring(L, kv.second.c_str());
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "stringValues");
}
// ---------------------------------------------------------------------------
// Helper: read a GoapBlackboard from a Lua table (optional, at given index)
// ---------------------------------------------------------------------------
static GoapBlackboard readBlackboard(lua_State *L, int idx)
{
GoapBlackboard bb;
if (!lua_istable(L, idx))
return bb;
// Read bits
lua_getfield(L, idx, "bits");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
// key is the bit name (string), value is boolean
if (lua_isstring(L, -2) && lua_isboolean(L, -1)) {
const char *name = lua_tostring(L, -2);
bool val = lua_toboolean(L, -1) != 0;
// Find bit index by name (auto-registers if new)
int idx2 = GoapBlackboard::findBitByName(name);
if (idx2 < 0) {
// Find first free slot
for (int i = 0; i < 64; i++) {
if (GoapBlackboard::getBitName(
i) == nullptr) {
GoapBlackboard::setBitName(
i, name);
idx2 = i;
break;
}
}
}
if (idx2 >= 0)
bb.setBit(idx2, val);
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read integer values
lua_getfield(L, idx, "values");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isinteger(L, -1))
bb.values[lua_tostring(L, -2)] =
(int)lua_tointeger(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read float values
lua_getfield(L, idx, "floatValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isnumber(L, -1))
bb.floatValues[lua_tostring(L, -2)] =
(float)lua_tonumber(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// Read string values
lua_getfield(L, idx, "stringValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1))
bb.stringValues[lua_tostring(L, -2)] =
lua_tostring(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return bb;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.add_action(name, cost, preconds, effects, behaviorTree)
// ---------------------------------------------------------------------------
static int luaAddAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int cost = (int)luaL_optinteger(L, 2, 1);
GoapAction action(name, cost);
// Optional preconditions table (arg 3)
if (lua_gettop(L) >= 3 && lua_istable(L, 3))
action.preconditions = readBlackboard(L, 3);
// Optional effects table (arg 4)
if (lua_gettop(L) >= 4 && lua_istable(L, 4))
action.effects = readBlackboard(L, 4);
// Optional behavior tree table (arg 5)
if (lua_gettop(L) >= 5 && lua_istable(L, 5))
action.behaviorTree = readBehaviorTree(L, 5);
ActionDatabase::getSingleton().addOrReplaceAction(action);
Ogre::LogManager::getSingleton().stream()
<< "[Lua] Added action: " << name;
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.add_goal(name, priority, target, condition)
// ---------------------------------------------------------------------------
static int luaAddGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int priority = (int)luaL_optinteger(L, 2, 1);
GoapGoal goal(name, priority);
// Optional target blackboard (arg 3)
if (lua_gettop(L) >= 3 && lua_istable(L, 3))
goal.target = readBlackboard(L, 3);
// Optional condition string (arg 4)
if (lua_gettop(L) >= 4 && lua_isstring(L, 4))
goal.condition = lua_tostring(L, 4);
ActionDatabase::getSingleton().addOrReplaceGoal(goal);
Ogre::LogManager::getSingleton().stream()
<< "[Lua] Added goal: " << name;
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.remove_action(name) -> bool
// ---------------------------------------------------------------------------
static int luaRemoveAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
bool removed = ActionDatabase::getSingleton().removeAction(name);
lua_pushboolean(L, removed);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.remove_goal(name) -> bool
// ---------------------------------------------------------------------------
static int luaRemoveGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
bool removed = ActionDatabase::getSingleton().removeGoal(name);
lua_pushboolean(L, removed);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_action(name) -> table or nil
// ---------------------------------------------------------------------------
static int luaFindAction(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
const GoapAction *action =
ActionDatabase::getSingleton().findAction(name);
if (!action) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushstring(L, action->name.c_str());
lua_setfield(L, -2, "name");
lua_pushinteger(L, action->cost);
lua_setfield(L, -2, "cost");
pushBlackboard(L, action->preconditions);
lua_setfield(L, -2, "preconditions");
pushBlackboard(L, action->effects);
lua_setfield(L, -2, "effects");
// Behavior tree
pushBehaviorTree(L, action->behaviorTree);
lua_setfield(L, -2, "behaviorTree");
// Behavior tree name (optional reference)
if (!action->behaviorTreeName.empty()) {
lua_pushstring(L, action->behaviorTreeName.c_str());
lua_setfield(L, -2, "behaviorTreeName");
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_goal(name) -> table or nil
// ---------------------------------------------------------------------------
static int luaFindGoal(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
const GoapGoal *goal = ActionDatabase::getSingleton().findGoal(name);
if (!goal) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushstring(L, goal->name.c_str());
lua_setfield(L, -2, "name");
lua_pushinteger(L, goal->priority);
lua_setfield(L, -2, "priority");
pushBlackboard(L, goal->target);
lua_setfield(L, -2, "target");
lua_pushstring(L, goal->condition.c_str());
lua_setfield(L, -2, "condition");
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_actions() -> table of action names
// ---------------------------------------------------------------------------
static int luaListActions(lua_State *L)
{
const auto &db = ActionDatabase::getSingleton();
lua_newtable(L);
int idx = 1;
for (const auto &action : db.actions) {
lua_pushstring(L, action.name.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_goals() -> table of goal names
// ---------------------------------------------------------------------------
static int luaListGoals(lua_State *L)
{
const auto &db = ActionDatabase::getSingleton();
lua_newtable(L);
int idx = 1;
for (const auto &goal : db.goals) {
lua_pushstring(L, goal.name.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.clear() -> nil
// ---------------------------------------------------------------------------
static int luaClear(lua_State *L)
{
(void)L;
ActionDatabase::getSingleton().clear();
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.set_bit_name(index, name) -> nil
// ---------------------------------------------------------------------------
static int luaSetBitName(lua_State *L)
{
int index = (int)luaL_checkinteger(L, 1);
const char *name = luaL_checkstring(L, 2);
if (index < 0 || index >= 64)
luaL_error(L, "bit index must be 0-63, got %d", index);
GoapBlackboard::setBitName(index, name);
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.find_bit_by_name(name) -> index or nil
// ---------------------------------------------------------------------------
static int luaFindBitByName(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int index = GoapBlackboard::findBitByName(name);
if (index < 0) {
lua_pushnil(L);
} else {
lua_pushinteger(L, index);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.get_bit_name(index) -> name or nil
// ---------------------------------------------------------------------------
static int luaGetBitName(lua_State *L)
{
int index = (int)luaL_checkinteger(L, 1);
if (index < 0 || index >= 64) {
lua_pushnil(L);
return 1;
}
const char *name = GoapBlackboard::getBitName(index);
if (name) {
lua_pushstring(L, name);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.list_bit_names() -> table of { index, name }
// ---------------------------------------------------------------------------
static int luaListBitNames(lua_State *L)
{
lua_newtable(L);
int idx = 1;
for (int i = 0; i < 64; i++) {
const char *name = GoapBlackboard::getBitName(i);
if (name) {
lua_newtable(L);
lua_pushinteger(L, i);
lua_setfield(L, -2, "index");
lua_pushstring(L, name);
lua_setfield(L, -2, "name");
lua_rawseti(L, -2, idx++);
}
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.action_db.auto_assign_bit(name) -> index
// ---------------------------------------------------------------------------
// Finds a bit by name, or auto-assigns the first free slot if not found.
// Returns the bit index, or -1 if all 64 slots are full.
static int luaAutoAssignBit(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
int index = GoapBlackboard::findBitByName(name);
if (index >= 0) {
lua_pushinteger(L, index);
return 1;
}
// Find first free slot
for (int i = 0; i < 64; i++) {
if (GoapBlackboard::getBitName(i) == nullptr) {
GoapBlackboard::setBitName(i, name);
lua_pushinteger(L, i);
return 1;
}
}
lua_pushinteger(L, -1);
return 1;
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void registerLuaActionApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// Create the action_db sub-table
lua_newtable(L);
lua_pushcfunction(L, luaAddAction);
lua_setfield(L, -2, "add_action");
lua_pushcfunction(L, luaAddGoal);
lua_setfield(L, -2, "add_goal");
lua_pushcfunction(L, luaRemoveAction);
lua_setfield(L, -2, "remove_action");
lua_pushcfunction(L, luaRemoveGoal);
lua_setfield(L, -2, "remove_goal");
lua_pushcfunction(L, luaFindAction);
lua_setfield(L, -2, "find_action");
lua_pushcfunction(L, luaFindGoal);
lua_setfield(L, -2, "find_goal");
lua_pushcfunction(L, luaListActions);
lua_setfield(L, -2, "list_actions");
lua_pushcfunction(L, luaListGoals);
lua_setfield(L, -2, "list_goals");
lua_pushcfunction(L, luaClear);
lua_setfield(L, -2, "clear");
// Bit name management
lua_pushcfunction(L, luaSetBitName);
lua_setfield(L, -2, "set_bit_name");
lua_pushcfunction(L, luaFindBitByName);
lua_setfield(L, -2, "find_bit_by_name");
lua_pushcfunction(L, luaGetBitName);
lua_setfield(L, -2, "get_bit_name");
lua_pushcfunction(L, luaListBitNames);
lua_setfield(L, -2, "list_bit_names");
lua_pushcfunction(L, luaAutoAssignBit);
lua_setfield(L, -2, "auto_assign_bit");
// Set action_db as a field of ecs
lua_setfield(L, -2, "action_db");
// Ensure ecs is global
lua_setglobal(L, "ecs");
}
} // namespace editScene

View File

@@ -0,0 +1,101 @@
#ifndef EDITSCENE_LUA_ACTION_API_HPP
#define EDITSCENE_LUA_ACTION_API_HPP
#pragma once
#include <lua.hpp>
/**
* @file LuaActionApi.hpp
* @brief Lua API for the ActionDatabase singleton.
*
* Provides Lua functions to create, query, and manage GOAP actions
* and goals in the global ActionDatabase singleton.
*
* Exposed Lua globals (under the "ecs" table):
* ecs.action_db.add_action(name, cost, preconds, effects, behaviorTree) -> nil
* ecs.action_db.add_goal(name, priority, target, condition) -> nil
* ecs.action_db.remove_action(name) -> bool
* ecs.action_db.remove_goal(name) -> bool
* ecs.action_db.find_action(name) -> table or nil
* ecs.action_db.find_goal(name) -> table or nil
* ecs.action_db.list_actions() -> table of action names
* ecs.action_db.list_goals() -> table of goal names
* ecs.action_db.clear() -> nil
*
* Bit Name Management:
* ecs.action_db.set_bit_name(index, name) -> nil
* Assign a human-readable name to a bit slot (0-63).
* Example: ecs.action_db.set_bit_name(0, "has_axe")
*
* ecs.action_db.find_bit_by_name(name) -> index or nil
* Look up which bit index a name is assigned to.
* Returns nil if the name hasn't been assigned yet.
*
* ecs.action_db.get_bit_name(index) -> name or nil
* Get the name assigned to a bit slot, or nil if unassigned.
*
* ecs.action_db.list_bit_names() -> table of { index, name }
* Returns an array of all assigned bit names with their indices.
*
* ecs.action_db.auto_assign_bit(name) -> index
* Find a bit by name, or auto-assign the first free slot.
* Returns the bit index, or -1 if all 64 slots are full.
* This is the same logic used internally by readBlackboard()
* when it encounters an unknown bit name in preconditions/effects.
*
* Bit Name Convention:
* Bit names are global across the entire game session. They map
* human-readable names (like "has_axe", "is_hungry") to bit indices
* (0-63) used in GoapBlackboard preconditions and effects.
*
* You can define bits explicitly at startup:
* ecs.action_db.set_bit_name(0, "has_axe")
* ecs.action_db.set_bit_name(1, "has_wood")
* ecs.action_db.set_bit_name(2, "is_hungry")
*
* Or let them be auto-assigned when you use them in actions:
* ecs.action_db.add_action("chop_wood", 2,
* { bits = { has_axe = true } }, -- "has_axe" auto-assigned if new
* { bits = { has_wood = true } })
*
* Use auto_assign_bit() to explicitly reserve a name:
* local idx = ecs.action_db.auto_assign_bit("my_flag")
* -- idx is now the bit index for "my_flag"
*
* Use list_bit_names() to see all currently assigned names:
* local bits = ecs.action_db.list_bit_names()
* for _, b in ipairs(bits) do
* print(b.index .. ": " .. b.name)
* end
*
* Behavior Tree Table Format (arg 5 of add_action):
* {
* type = "sequence", -- node type: sequence, selector, invert, task, check, etc.
* name = "optional_name", -- action/condition name, animation state, etc.
* params = "optional_params", -- extra parameters (e.g. delay seconds, bit index)
* children = { -- array of child nodes (for sequence/selector/invert)
* { type = "task", name = "myAction" },
* { type = "setAnimationState", name = "SM/Walk" },
* { type = "delay", params = "2.0" }
* }
* }
*
* See BehaviorTree.hpp for full list of node types.
*/
namespace editScene
{
/**
* @brief Register all ActionDatabase-related Lua API functions.
*
* Adds action_db sub-table to the "ecs" global table.
* Must be called after LuaState is constructed.
*
* @param L The Lua state.
*/
void registerLuaActionApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_ACTION_API_HPP

View File

@@ -0,0 +1,471 @@
#include "LuaBehaviorTreeApi.hpp"
#include "LuaEntityApi.hpp"
#include "../components/GoapBlackboard.hpp"
#include <OgreLogManager.h>
#include <unordered_map>
#include <string>
#include <vector>
namespace editScene
{
// ---------------------------------------------------------------------------
// Global registry of Lua node handlers
// ---------------------------------------------------------------------------
// Maps node name -> Lua registry reference for the callback function.
// The callback is stored as a Lua reference in the registry so it persists
// across Lua state resets and can be called from C++.
static std::unordered_map<std::string, int> g_luaNodeHandlers;
// Global Lua state pointer, set during registerLuaBehaviorTreeApi().
// Used by callLuaBehaviorTreeNode() which is invoked from the C++
// behavior tree system without direct access to the Lua state.
static lua_State *g_luaState = nullptr;
// ---------------------------------------------------------------------------
// Helper: push a GoapBlackboard as a Lua table (reused from LuaActionApi)
// ---------------------------------------------------------------------------
static void pushBlackboard(lua_State *L, const GoapBlackboard &bb)
{
lua_newtable(L); // blackboard table
// Bits
lua_newtable(L); // bits table
for (int i = 0; i < 64; i++) {
if (bb.hasBit(i)) {
const char *name = GoapBlackboard::getBitName(i);
const char *key = name ? name : "";
lua_pushboolean(L, bb.getBit(i));
lua_setfield(L, -2, key);
}
}
lua_setfield(L, -2, "bits");
// Integer values
lua_newtable(L);
for (const auto &kv : bb.values) {
lua_pushinteger(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "values");
// Float values
lua_newtable(L);
for (const auto &kv : bb.floatValues) {
lua_pushnumber(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "floatValues");
// String values
lua_newtable(L);
for (const auto &kv : bb.stringValues) {
lua_pushstring(L, kv.second.c_str());
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "stringValues");
}
// ---------------------------------------------------------------------------
// Helper: parse "key=val,key2=val2" params into a Lua table
// ---------------------------------------------------------------------------
static void pushParamsTable(lua_State *L, const std::string &params)
{
lua_newtable(L); // params table
if (params.empty())
return;
const char *s = params.c_str();
while (*s) {
// Skip whitespace
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
break;
// Find key
const char *keyStart = s;
while (*s && *s != '=' && *s != ',')
s++;
std::string key(keyStart, static_cast<size_t>(s - keyStart));
// Trim trailing spaces from key
while (!key.empty() &&
(key.back() == ' ' || key.back() == '\t'))
key.pop_back();
if (*s != '=') {
while (*s && *s != ',')
s++;
if (*s == ',')
s++;
continue;
}
s++; // skip '='
// Skip whitespace before value
while (*s == ' ' || *s == '\t')
s++;
// Find value end (next comma or end)
const char *valStart = s;
bool inQuotes = false;
while (*s && (*s != ',' || inQuotes)) {
if (*s == '"')
inQuotes = !inQuotes;
s++;
}
std::string val(valStart, static_cast<size_t>(s - valStart));
// Trim trailing spaces from value
while (!val.empty() &&
(val.back() == ' ' || val.back() == '\t'))
val.pop_back();
// Strip quotes if present
if (val.size() >= 2 && val.front() == '"' &&
val.back() == '"') {
val = val.substr(1, val.size() - 2);
lua_pushstring(L, val.c_str());
lua_setfield(L, -2, key.c_str());
} else {
// Try numeric
char *end = nullptr;
long iVal = strtol(val.c_str(), &end, 10);
if (end != val.c_str() && *end == '\0') {
lua_pushinteger(L, (int)iVal);
lua_setfield(L, -2, key.c_str());
} else {
// Try float
end = nullptr;
float fVal = strtof(val.c_str(), &end);
if (end != val.c_str() && *end == '\0') {
lua_pushnumber(L, fVal);
lua_setfield(L, -2, key.c_str());
} else {
// Fallback to string
lua_pushstring(L, val.c_str());
lua_setfield(L, -2, key.c_str());
}
}
}
if (*s == ',')
s++;
}
}
// ---------------------------------------------------------------------------
// Public API: Call a registered Lua node handler
// ---------------------------------------------------------------------------
// Returns: 0 = success, 1 = failure, 2 = running, -1 = not found/error
int callLuaBehaviorTreeNode(void *L_, const std::string &nodeName,
flecs::entity entity, const std::string &params)
{
// Use the global Lua state if none was passed
lua_State *L = static_cast<lua_State *>(L_);
if (!L)
L = g_luaState;
if (!L)
return -1;
auto it = g_luaNodeHandlers.find(nodeName);
if (it == g_luaNodeHandlers.end())
return -1;
int ref = it->second;
// Push the callback function
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
// Push entity ID as first argument
lua_pushinteger(L, luaEntityToId(entity));
// Push params table as second argument
pushParamsTable(L, params);
// Call the function
if (lua_pcall(L, 2, 1, 0) != LUA_OK) {
Ogre::LogManager::getSingleton().stream()
<< "[Lua BT] Error calling node '" << nodeName
<< "': " << lua_tostring(L, -1);
lua_pop(L, 1);
return -1;
}
// Read the result
if (!lua_isstring(L, -1)) {
lua_pop(L, 1);
return -1;
}
const char *result = lua_tostring(L, -1);
lua_pop(L, 1);
if (strcmp(result, "success") == 0)
return 0;
if (strcmp(result, "failure") == 0)
return 1;
if (strcmp(result, "running") == 0)
return 2;
Ogre::LogManager::getSingleton().stream()
<< "[Lua BT] Node '" << nodeName
<< "' returned invalid result: " << result;
return -1;
}
// ---------------------------------------------------------------------------
// Convenience overload: call with raw entity ID (for tests)
// ---------------------------------------------------------------------------
int callLuaBehaviorTreeNode(void *L_, const std::string &nodeName,
uint64_t entityId, const std::string &params)
{
// Use the global Lua state if none was passed
lua_State *L = static_cast<lua_State *>(L_);
if (!L)
L = g_luaState;
if (!L)
return -1;
auto it = g_luaNodeHandlers.find(nodeName);
if (it == g_luaNodeHandlers.end())
return -1;
int ref = it->second;
// Push the callback function
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
// Push entity ID as first argument
lua_pushinteger(L, (lua_Integer)entityId);
// Push params table as second argument
pushParamsTable(L, params);
// Call the function
if (lua_pcall(L, 2, 1, 0) != LUA_OK) {
Ogre::LogManager::getSingleton().stream()
<< "[Lua BT] Error calling node '" << nodeName
<< "': " << lua_tostring(L, -1);
lua_pop(L, 1);
return -1;
}
// Read the result
if (!lua_isstring(L, -1)) {
lua_pop(L, 1);
return -1;
}
const char *result = lua_tostring(L, -1);
lua_pop(L, 1);
if (strcmp(result, "success") == 0)
return 0;
if (strcmp(result, "failure") == 0)
return 1;
if (strcmp(result, "running") == 0)
return 2;
Ogre::LogManager::getSingleton().stream()
<< "[Lua BT] Node '" << nodeName
<< "' returned invalid result: " << result;
return -1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.behavior_tree.register_node(name, function)
// ---------------------------------------------------------------------------
static int luaRegisterNode(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
if (!lua_isfunction(L, 2))
luaL_error(L, "Expected function as second argument");
// Remove any existing handler with the same name
auto it = g_luaNodeHandlers.find(name);
if (it != g_luaNodeHandlers.end()) {
luaL_unref(L, LUA_REGISTRYINDEX, it->second);
}
// Store the function reference
lua_pushvalue(L, 2);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
g_luaNodeHandlers[name] = ref;
Ogre::LogManager::getSingleton().stream()
<< "[Lua BT] Registered node handler: " << name;
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.behavior_tree.unregister_node(name) -> bool
// ---------------------------------------------------------------------------
static int luaUnregisterNode(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
auto it = g_luaNodeHandlers.find(name);
if (it == g_luaNodeHandlers.end()) {
lua_pushboolean(L, false);
return 1;
}
luaL_unref(L, LUA_REGISTRYINDEX, it->second);
g_luaNodeHandlers.erase(it);
lua_pushboolean(L, true);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.behavior_tree.list_nodes() -> table of names
// ---------------------------------------------------------------------------
static int luaListNodeHandlers(lua_State *L)
{
lua_newtable(L);
int idx = 1;
for (const auto &kv : g_luaNodeHandlers) {
lua_pushstring(L, kv.first.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.behavior_tree.create_node(type, name, params) -> table
// ecs.behavior_tree.create_node(type, params) -> table
// ecs.behavior_tree.create_node(luaHandlerName, params) -> table (backward compat)
// ---------------------------------------------------------------------------
// Creates a behavior tree node table suitable for use in action behavior trees.
//
// If the first argument matches a registered Lua node handler name, it creates
// a luaTask node (backward compatible behavior).
// Otherwise, the first argument is treated as the node type string.
//
// Examples:
// ecs.behavior_tree.create_node("setAnimationState", "locomotion/walk")
// -> { type = "setAnimationState", name = "locomotion/walk" }
//
// ecs.behavior_tree.create_node("delay", "2.0")
// -> { type = "delay", params = "2.0" }
//
// ecs.behavior_tree.create_node("delay", "", "2.0")
// -> { type = "delay", params = "2.0" }
//
// ecs.behavior_tree.create_node("say_hello", "message=Hi")
// -> { type = "luaTask", name = "say_hello", params = "message=Hi" }
static int luaCreateNode(lua_State *L)
{
const char *arg1 = luaL_checkstring(L, 1);
// Check if arg1 is a registered Lua node handler name (backward compat)
bool isLuaHandler = g_luaNodeHandlers.find(arg1) !=
g_luaNodeHandlers.end();
// Save arg2 and arg3 before pushing the result table (which shifts indices)
const char *arg2 = NULL;
const char *arg3 = NULL;
if (lua_gettop(L) >= 2 && lua_isstring(L, 2))
arg2 = lua_tostring(L, 2);
if (lua_gettop(L) >= 3 && lua_isstring(L, 3))
arg3 = lua_tostring(L, 3);
lua_newtable(L);
if (isLuaHandler) {
// Backward compatible: create a luaTask node
lua_pushstring(L, "luaTask");
lua_setfield(L, -2, "type");
lua_pushstring(L, arg1);
lua_setfield(L, -2, "name");
if (arg2 && arg2[0]) {
lua_pushstring(L, arg2);
lua_setfield(L, -2, "params");
}
} else {
// arg1 is the node type
lua_pushstring(L, arg1);
lua_setfield(L, -2, "type");
// arg2 is either name or params depending on the node type
if (arg2 && arg2[0]) {
lua_pushstring(L, arg2);
lua_setfield(L, -2, "name");
}
// arg3 is params (optional)
if (arg3 && arg3[0]) {
lua_pushstring(L, arg3);
lua_setfield(L, -2, "params");
}
}
return 1;
}
// ---------------------------------------------------------------------------
// Public C++ API: get list of registered Lua node names
// ---------------------------------------------------------------------------
std::vector<std::string> getRegisteredLuaNodeNames()
{
std::vector<std::string> names;
names.reserve(g_luaNodeHandlers.size());
for (const auto &kv : g_luaNodeHandlers)
names.push_back(kv.first);
return names;
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void registerLuaBehaviorTreeApi(lua_State *L)
{
// Store the Lua state globally so callLuaBehaviorTreeNode can use it
g_luaState = L;
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// Create the behavior_tree sub-table
lua_newtable(L);
lua_pushcfunction(L, luaRegisterNode);
lua_setfield(L, -2, "register_node");
lua_pushcfunction(L, luaUnregisterNode);
lua_setfield(L, -2, "unregister_node");
lua_pushcfunction(L, luaListNodeHandlers);
lua_setfield(L, -2, "list_nodes");
lua_pushcfunction(L, luaCreateNode);
lua_setfield(L, -2, "create_node");
// Set behavior_tree as a field of ecs
lua_setfield(L, -2, "behavior_tree");
// Ensure ecs is global
lua_setglobal(L, "ecs");
}
} // namespace editScene

View File

@@ -0,0 +1,149 @@
#ifndef EDITSCENE_LUA_BEHAVIOR_TREE_API_HPP
#define EDITSCENE_LUA_BEHAVIOR_TREE_API_HPP
#pragma once
#include <lua.hpp>
#include <vector>
#include <string>
#include <cstdint>
#include <flecs.h>
/**
* @file LuaBehaviorTreeApi.hpp
* @brief Lua API for creating behavior tree nodes that run Lua functions.
*
* Provides Lua bindings to register named Lua functions as behavior tree
* node handlers, and to create behavior tree nodes that invoke those
* functions during tree evaluation.
*
* Exposed Lua globals (under the "ecs" table):
* ecs.behavior_tree.register_node(name, function) -> nil
* Register a Lua function as a behavior tree node handler.
* The function receives (entity_id, params_table) and must return
* "success", "failure", or "running".
*
* ecs.behavior_tree.unregister_node(name) -> bool
* Remove a previously registered node handler.
*
* ecs.behavior_tree.list_nodes() -> table of registered node names
* Returns an array of all registered node handler names.
*
* ecs.behavior_tree.create_node(type, name, params) -> table
* Creates a behavior tree node table suitable for use in
* ecs.action_db.add_action() behavior trees.
*
* If the first argument matches a registered Lua node handler name,
* it creates a luaTask node (backward compatible).
* Otherwise, the first argument is the node type string.
*
* Examples:
* ecs.behavior_tree.create_node("setAnimationState", "locomotion/walk")
* -> { type = "setAnimationState", name = "locomotion/walk" }
*
* ecs.behavior_tree.create_node("delay", "", "2.0")
* -> { type = "delay", params = "2.0" }
*
* ecs.behavior_tree.create_node("say_hello", "message=Hi")
* -> { type = "luaTask", name = "say_hello", params = "message=Hi" }
*
* Supported node types (see BehaviorTree.hpp for full list):
* sequence, selector, invert, task, check, debugPrint,
* setAnimationState, isAnimationEnded, setBit, checkBit,
* setValue, checkValue, blackboardDump, delay, teleportToChild,
* disablePhysics, enablePhysics, sendEvent, hasItem, hasItemByName,
* countItem, pickupItem, dropItem, useItem, addItemToInventory,
* luaTask
*
* Example:
* -- Register a Lua node handler
* ecs.behavior_tree.register_node("say_hello", function(entity_id, params)
* print("Hello from entity " .. entity_id .. "! " .. (params.message or ""))
* return "success"
* end)
*
* -- Build a behavior tree using both built-in C++ nodes and Lua nodes
* ecs.action_db.add_action("greet", 1, {}, {}, {
* type = "sequence",
* children = {
* ecs.behavior_tree.create_node("setAnimationState", "locomotion/walk"),
* ecs.behavior_tree.create_node("delay", "", "2.0"),
* ecs.behavior_tree.create_node("setAnimationState", "locomotion/idle"),
* ecs.behavior_tree.create_node("say_hello", "message=Hello World")
* }
* })
*
* -- A Lua node that runs for a while:
* ecs.behavior_tree.register_node("wait_random", function(entity_id, params)
* local bb = ecs.get_component(entity_id, "GoapBlackboard")
* if not bb then return "failure" end
*
* local key = "wait_timer_" .. params.timer_key
* local dt = ecs.get_delta_time()
* bb.floatValues[key] = (bb.floatValues[key] or 0) + dt
*
* local duration = tonumber(params.duration) or 1.0
* if bb.floatValues[key] >= duration then
* bb.floatValues[key] = nil
* ecs.set_component(entity_id, "GoapBlackboard", bb)
* return "success"
* end
* ecs.set_component(entity_id, "GoapBlackboard", bb)
* return "running"
* end)
*/
namespace editScene
{
/**
* @brief Register all behavior tree Lua API functions into the "ecs" table.
*
* Adds a behavior_tree sub-table to the "ecs" global table with functions
* for registering Lua node handlers and creating behavior tree nodes.
*
* @param L The Lua state.
*/
void registerLuaBehaviorTreeApi(lua_State *L);
/**
* @brief Get the list of registered Lua node handler names.
*
* Used by the behavior tree editor UI to show registered Lua nodes
* in a dropdown instead of requiring manual entry.
*
* @return A vector of registered node handler names.
*/
std::vector<std::string> getRegisteredLuaNodeNames();
/**
* @brief Call a registered Lua behavior tree node handler.
*
* This is the primary evaluation function used by the behavior tree system.
* It looks up the registered handler by name, pushes entity and params
* as arguments, calls the Lua function, and interprets the result.
*
* @param L_ Pointer to the Lua state (can be nullptr to use global state).
* @param nodeName The name of the registered node handler.
* @param entity The flecs entity executing this node.
* @param params The params string (key=val,key2=val2 format).
* @return 0 = success, 1 = failure, 2 = running, -1 = error/not found.
*/
int callLuaBehaviorTreeNode(void *L_, const std::string &nodeName,
flecs::entity entity, const std::string &params);
/**
* @brief Convenience overload for calling a registered Lua node handler
* with a raw entity ID (for use in tests or contexts without flecs).
*
* @param L_ Pointer to the Lua state (can be nullptr to use global state).
* @param nodeName The name of the registered node handler.
* @param entityId The raw entity ID.
* @param params The params string (key=val,key2=val2 format).
* @return 0 = success, 1 = failure, 2 = running, -1 = error/not found.
*/
int callLuaBehaviorTreeNode(void *L_, const std::string &nodeName,
uint64_t entityId, const std::string &params);
} // namespace editScene
#endif // EDITSCENE_LUA_BEHAVIOR_TREE_API_HPP

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
#ifndef EDITSCENE_LUA_COMPONENT_API_HPP
#define EDITSCENE_LUA_COMPONENT_API_HPP
#pragma once
#include <lua.hpp>
/**
* @file LuaComponentApi.hpp
* @brief Lua API for adding, removing, and modifying ECS components.
*
* Provides a generic component API where Lua scripts can add, get, set,
* and remove components on entities. Each component type is registered
* with a name string and a set of field accessors.
*
* Exposed Lua globals (in the "ecs" table):
* ecs.add_component(id, "ComponentName") -> nil
* ecs.remove_component(id, "ComponentName") -> nil
* ecs.has_component(id, "ComponentName") -> bool
* ecs.get_component(id, "ComponentName") -> table (field -> value)
* ecs.set_component(id, "ComponentName", table) -> nil
* ecs.get_field(id, "ComponentName", "fieldName") -> value
* ecs.set_field(id, "ComponentName", "fieldName", value) -> nil
*
* Supported component names (case-sensitive):
* "EntityName", "Transform", "Renderable", "Light", "Camera",
* "RigidBody", "PhysicsCollider", "Character", "CharacterSlots",
* "AnimationTree", "AnimationTreeTemplate", "BehaviorTree",
* "GoapBlackboard", "GoapAction", "GoapGoal", "ActionDatabase",
* "ActionDebug", "SmartObject", "Actuator", "EventHandler",
* "GoapPlanner", "GoapRunner", "PathFollowing", "NavMesh",
* "NavMeshGeometrySource", "NavMeshAgent", "Item", "Inventory",
* "Lod", "LodSettings", "StaticGeometry", "StaticGeometryMember",
* "ProceduralTexture", "ProceduralMaterial", "Primitive",
* "TriangleBuffer", "Sun", "Skybox", "WaterPlane", "WaterPhysics",
* "BuoyancyInfo", "InWater", "StartupMenu", "Dialogue",
* "PlayerController", "CellGrid", "Room", "ClearArea", "Roof",
* "Lot", "District", "Town", "FurnitureTemplate", "PrefabInstance",
* "EditorMarker", "GeneratedPhysicsTag", "ParentComponent",
* "ModifiedComponent"
*/
namespace editScene
{
/**
* @brief Register all component Lua API functions into the "ecs" global table.
*
* Adds functions for component manipulation (add, remove, has, get, set,
* get_field, set_field) to the existing "ecs" table.
*
* @param L The Lua state.
*/
void registerLuaComponentApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_COMPONENT_API_HPP

View File

@@ -0,0 +1,236 @@
#include "LuaEntityApi.hpp"
#include "components/EditorMarker.hpp"
#include <OgreLogManager.h>
#include <iostream>
namespace editScene
{
// Global entity ID map
LuaEntityIdMap g_luaEntityIdMap;
int luaEntityToId(flecs::entity e)
{
if (!e.is_valid())
return -1;
return g_luaEntityIdMap.addEntity(e);
}
flecs::entity luaIdToEntity(int id)
{
return g_luaEntityIdMap.getEntity(id);
}
// ---------------------------------------------------------------------------
// Helper: get the Flecs world from the Lua registry
// ---------------------------------------------------------------------------
static flecs::world getWorld(lua_State *L)
{
lua_getfield(L, LUA_REGISTRYINDEX, "EditSceneFlecsWorld");
OgreAssert(lua_islightuserdata(L, -1), "Flecs world not registered");
flecs::world *world =
static_cast<flecs::world *>(lua_touserdata(L, -1));
lua_pop(L, 1);
return *world;
}
// ---------------------------------------------------------------------------
// Lua: ecs.create_entity() -> int (entity ID)
// Creates a new entity with EditorMarkerComponent and returns its Lua ID.
// ---------------------------------------------------------------------------
static int luaCreateEntity(lua_State *L)
{
flecs::world world = getWorld(L);
flecs::entity e = world.entity();
// Add EditorMarkerComponent so it appears in the editor hierarchy
// (the component is forward-declared; we use a tag approach)
e.add<EditorMarkerComponent>();
int id = g_luaEntityIdMap.addEntity(e);
lua_pushinteger(L, id);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.destroy_entity(id) -> nil
// Destroys an entity and removes it from the ID map.
// ---------------------------------------------------------------------------
static int luaDestroyEntity(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
int id = lua_tointeger(L, 1);
flecs::entity e = g_luaEntityIdMap.getEntity(id);
if (e.is_alive()) {
g_luaEntityIdMap.removeEntity(e);
e.destruct();
}
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.entity_exists(id) -> bool
// ---------------------------------------------------------------------------
static int luaEntityExists(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
int id = lua_tointeger(L, 1);
lua_pushboolean(L, g_luaEntityIdMap.hasId(id) ? 1 : 0);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.get_player_entity() -> int (entity ID) or nil
// Looks up the entity named "player" in the Flecs world.
// ---------------------------------------------------------------------------
static int luaGetPlayerEntity(lua_State *L)
{
flecs::world world = getWorld(L);
flecs::entity e = world.lookup("player");
if (e.is_valid()) {
int id = g_luaEntityIdMap.addEntity(e);
lua_pushinteger(L, id);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.get_entity_by_name(name) -> int (entity ID) or nil
// ---------------------------------------------------------------------------
static int luaGetEntityByName(lua_State *L)
{
luaL_checktype(L, 1, LUA_TSTRING);
const char *name = lua_tostring(L, 1);
flecs::world world = getWorld(L);
flecs::entity e = world.lookup(name);
if (e.is_valid()) {
int id = g_luaEntityIdMap.addEntity(e);
lua_pushinteger(L, id);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.set_entity_name(id, name) -> nil
// ---------------------------------------------------------------------------
static int luaSetEntityName(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
luaL_checktype(L, 2, LUA_TSTRING);
int id = lua_tointeger(L, 1);
const char *name = lua_tostring(L, 2);
flecs::entity e = g_luaEntityIdMap.getEntity(id);
e.set_name(name);
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.get_entity_name(id) -> string or nil
// ---------------------------------------------------------------------------
static int luaGetEntityName(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
int id = lua_tointeger(L, 1);
flecs::entity e = g_luaEntityIdMap.getEntity(id);
const char *name = e.name();
if (name && name[0]) {
lua_pushstring(L, name);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.parent(id) -> int (parent ID) or nil
// ---------------------------------------------------------------------------
static int luaGetParent(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
int id = lua_tointeger(L, 1);
flecs::entity e = g_luaEntityIdMap.getEntity(id);
flecs::entity parent = e.parent();
if (parent.is_valid()) {
int parentId = g_luaEntityIdMap.addEntity(parent);
lua_pushinteger(L, parentId);
} else {
lua_pushnil(L);
}
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.children(id) -> table of child IDs
// ---------------------------------------------------------------------------
static int luaGetChildren(lua_State *L)
{
luaL_checktype(L, 1, LUA_TNUMBER);
int id = lua_tointeger(L, 1);
flecs::entity e = g_luaEntityIdMap.getEntity(id);
lua_newtable(L); // result table
int index = 1;
e.children([&](flecs::entity child) {
int childId = g_luaEntityIdMap.addEntity(child);
lua_pushinteger(L, childId);
lua_rawseti(L, -2, index);
index++;
});
return 1;
}
// ---------------------------------------------------------------------------
// Register all entity API functions
// ---------------------------------------------------------------------------
void registerLuaEntityApi(lua_State *L)
{
// Create the "ecs" global table
lua_newtable(L);
// Entity management
lua_pushcfunction(L, luaCreateEntity);
lua_setfield(L, -2, "create_entity");
lua_pushcfunction(L, luaDestroyEntity);
lua_setfield(L, -2, "destroy_entity");
lua_pushcfunction(L, luaEntityExists);
lua_setfield(L, -2, "entity_exists");
lua_pushcfunction(L, luaGetPlayerEntity);
lua_setfield(L, -2, "get_player_entity");
lua_pushcfunction(L, luaGetEntityByName);
lua_setfield(L, -2, "get_entity_by_name");
lua_pushcfunction(L, luaSetEntityName);
lua_setfield(L, -2, "set_entity_name");
lua_pushcfunction(L, luaGetEntityName);
lua_setfield(L, -2, "get_entity_name");
// Hierarchy
lua_pushcfunction(L, luaGetParent);
lua_setfield(L, -2, "parent");
lua_pushcfunction(L, luaGetChildren);
lua_setfield(L, -2, "children");
// Set the global
lua_setglobal(L, "ecs");
}
} // namespace editScene

View File

@@ -0,0 +1,126 @@
#ifndef EDITSCENE_LUA_ENTITY_API_HPP
#define EDITSCENE_LUA_ENTITY_API_HPP
#pragma once
#include <flecs.h>
#include <lua.hpp>
#include <Ogre.h>
#include <unordered_map>
/**
* @file LuaEntityApi.hpp
* @brief Lua API for entity creation, destruction, and ID mapping.
*
* Provides a bidirectional mapping between Lua integer IDs and
* Flecs entity handles. Lua scripts reference entities by integer
* IDs rather than raw Flecs handles for safety and simplicity.
*
* Exposed Lua globals:
* ecs.create_entity() -> int (entity ID)
* ecs.destroy_entity(id) -> nil
* ecs.entity_exists(id) -> bool
* ecs.get_player_entity() -> int (entity ID)
* ecs.get_entity_by_name(name) -> int (entity ID) or nil
* ecs.set_entity_name(id, name)-> nil
* ecs.get_entity_name(id) -> string or nil
* ecs.parent(id) -> int (parent ID) or nil
* ecs.children(id) -> table of child IDs
*/
namespace editScene
{
/**
* @brief Global bidirectional mapping between Lua integer IDs and Flecs entities.
*
* This is a singleton-like global so that all Lua API functions can
* access it without needing to pass it through every closure.
*/
struct LuaEntityIdMap {
std::unordered_map<int, flecs::entity> id2entity;
std::unordered_map<flecs::entity_t, int> entity2id;
int nextId = 0;
/** @brief Get the next available integer ID. */
int getNextId()
{
nextId++;
return nextId;
}
/**
* @brief Add an entity to the map, returning its integer ID.
* If the entity is already mapped, returns the existing ID.
*/
int addEntity(flecs::entity e)
{
if (entity2id.find(e.id()) != entity2id.end())
return entity2id[e.id()];
int id = getNextId();
id2entity[id] = e;
entity2id[e.id()] = id;
return id;
}
/**
* @brief Get the Flecs entity for an integer ID.
* Asserts if the ID is not found or the entity is invalid.
*/
flecs::entity getEntity(int id)
{
auto it = id2entity.find(id);
OgreAssert(it != id2entity.end(), "Invalid entity ID");
OgreAssert(it->second.is_valid(), "Entity is no longer valid");
return it->second;
}
/**
* @brief Remove an entity from the map.
*/
void removeEntity(flecs::entity e)
{
auto it = entity2id.find(e.id());
if (it != entity2id.end()) {
id2entity.erase(it->second);
entity2id.erase(it);
}
}
/**
* @brief Check if an integer ID is valid.
*/
bool hasId(int id) const
{
auto it = id2entity.find(id);
return it != id2entity.end() && it->second.is_valid();
}
};
/** @brief Global entity ID map instance. */
extern LuaEntityIdMap g_luaEntityIdMap;
/**
* @brief Convert a Flecs entity to a Lua integer ID.
* @return The integer ID, or -1 if the entity is invalid.
*/
int luaEntityToId(flecs::entity e);
/**
* @brief Convert a Lua integer ID to a Flecs entity.
* Asserts if the ID is invalid.
*/
flecs::entity luaIdToEntity(int id);
/**
* @brief Register all entity-related Lua API functions into the global table.
*
* Creates the "ecs" global table (or adds to it) with entity management
* functions. Must be called after LuaState is constructed.
*
* @param L The Lua state.
*/
void registerLuaEntityApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_ENTITY_API_HPP

View File

@@ -0,0 +1,320 @@
#include "LuaEventApi.hpp"
#include "LuaEntityApi.hpp"
#include "../systems/EventBus.hpp"
#include "../components/GoapBlackboard.hpp"
#include <OgreLogManager.h>
#include <OgreVector3.h>
#include <unordered_map>
namespace editScene
{
// ---------------------------------------------------------------------------
// Internal: subscription ID tracking for Lua-managed subscriptions
// ---------------------------------------------------------------------------
/**
* @brief Map from Lua subscription IDs to EventBus ListenerIds.
*
* When Lua subscribes to an event, we store the mapping so that
* unsubscribe_event() can remove the correct listener.
*/
static std::unordered_map<int, EventBus::ListenerId> s_luaSubscriptions;
static int s_nextLuaSubId = 1;
// ---------------------------------------------------------------------------
// Helper: push a GoapBlackboard as a Lua table
// ---------------------------------------------------------------------------
/**
* @brief Push a GoapBlackboard as a Lua table with named fields.
*
* The resulting table has:
* .bits -> integer
* .mask -> integer
* .values -> {string -> int}
* .floatValues -> {string -> float}
* .vec3Values -> {string -> {x, y, z}}
* .stringValues -> {string -> string}
*
* @param L Lua state.
* @param bb The GoapBlackboard to convert.
*/
static void pushGoapBlackboard(lua_State *L, const GoapBlackboard &bb)
{
lua_newtable(L);
// bits and mask
lua_pushinteger(L, (lua_Integer)bb.bits);
lua_setfield(L, -2, "bits");
lua_pushinteger(L, (lua_Integer)bb.mask);
lua_setfield(L, -2, "mask");
// values (int map)
lua_newtable(L);
for (auto &kv : bb.values) {
lua_pushinteger(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "values");
// floatValues
lua_newtable(L);
for (auto &kv : bb.floatValues) {
lua_pushnumber(L, kv.second);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "floatValues");
// vec3Values
lua_newtable(L);
for (auto &kv : bb.vec3Values) {
lua_newtable(L);
lua_pushnumber(L, kv.second.x);
lua_rawseti(L, -2, 1);
lua_pushnumber(L, kv.second.y);
lua_rawseti(L, -2, 2);
lua_pushnumber(L, kv.second.z);
lua_rawseti(L, -2, 3);
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "vec3Values");
// stringValues
lua_newtable(L);
for (auto &kv : bb.stringValues) {
lua_pushstring(L, kv.second.c_str());
lua_setfield(L, -2, kv.first.c_str());
}
lua_setfield(L, -2, "stringValues");
}
/**
* @brief Read a Lua table at the given index as a GoapBlackboard.
*
* Expects the same format as pushGoapBlackboard produces.
*
* @param L Lua state.
* @param idx Stack index of the table.
* @return GoapBlackboard populated from the table.
*/
static GoapBlackboard readGoapBlackboard(lua_State *L, int idx)
{
GoapBlackboard bb;
// bits
lua_getfield(L, idx, "bits");
if (lua_isnumber(L, -1))
bb.bits = (uint64_t)lua_tointeger(L, -1);
lua_pop(L, 1);
// mask
lua_getfield(L, idx, "mask");
if (lua_isnumber(L, -1))
bb.mask = (uint64_t)lua_tointeger(L, -1);
lua_pop(L, 1);
// values (int map)
lua_getfield(L, idx, "values");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isnumber(L, -1))
bb.values[lua_tostring(L, -2)] =
(int)lua_tointeger(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// floatValues
lua_getfield(L, idx, "floatValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isnumber(L, -1))
bb.floatValues[lua_tostring(L, -2)] =
(float)lua_tonumber(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// vec3Values
lua_getfield(L, idx, "vec3Values");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_istable(L, -1)) {
Ogre::Vector3 v;
lua_rawgeti(L, -1, 1);
v.x = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 2);
v.y = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
lua_rawgeti(L, -1, 3);
v.z = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
bb.vec3Values[lua_tostring(L, -2)] = v;
}
lua_pop(L, 1);
}
}
lua_pop(L, 1);
// stringValues
lua_getfield(L, idx, "stringValues");
if (lua_istable(L, -1)) {
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1))
bb.stringValues[lua_tostring(L, -2)] =
lua_tostring(L, -1);
lua_pop(L, 1);
}
}
lua_pop(L, 1);
return bb;
}
// ---------------------------------------------------------------------------
// Lua: ecs.send_event("eventName", [params_table])
// ---------------------------------------------------------------------------
/**
* @brief Lua: ecs.send_event("eventName", [params_table]) -> nil
*
* Sends an event through the global EventBus. The optional params table
* is converted to a GoapBlackboard payload.
*
* Usage:
* ecs.send_event("collision")
* ecs.send_event("collision", { entity_id = 42, damage = 10 })
*/
static int luaSendEvent(lua_State *L)
{
const char *eventName = luaL_checkstring(L, 1);
GoapBlackboard params;
if (lua_gettop(L) >= 2 && lua_istable(L, 2)) {
params = readGoapBlackboard(L, 2);
}
EventBus::getInstance().send(eventName, params);
return 0;
}
// ---------------------------------------------------------------------------
// Lua: ecs.subscribe_event("eventName", callback_fn) -> int (subscription id)
// ---------------------------------------------------------------------------
/**
* @brief Lua: ecs.subscribe_event("eventName", callback_fn) -> int
*
* Subscribes a Lua function to an event. The callback receives:
* function(event_name, params_table)
*
* Returns a subscription ID that can be used with unsubscribe_event().
*
* Usage:
* local sub_id = ecs.subscribe_event("collision", function(event, params)
* print("Collision event received!")
* print("entity_id: " .. params.values.entity_id)
* end)
*/
static int luaSubscribeEvent(lua_State *L)
{
const char *eventName = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
// Create a reference to the Lua callback function
lua_pushvalue(L, 2);
int callbackRef = luaL_ref(L, LUA_REGISTRYINDEX);
// Subscribe to the EventBus with a C++ lambda that calls the Lua function
EventBus::ListenerId listenerId = EventBus::getInstance().subscribe(
eventName, [L, callbackRef](const Ogre::String &eventName,
const GoapBlackboard &params) {
// Push the Lua callback function
lua_rawgeti(L, LUA_REGISTRYINDEX, callbackRef);
// Push event name
lua_pushstring(L, eventName.c_str());
// Push params as a Lua table
pushGoapBlackboard(L, params);
// Call the Lua function (2 args, 0 results)
if (lua_pcall(L, 2, 0, 0) != LUA_OK) {
Ogre::LogManager::getSingleton().stream()
<< "Lua event callback error: "
<< lua_tostring(L, -1);
lua_pop(L, 1);
}
});
// Store the mapping from Lua subscription ID to EventBus listener ID
int luaSubId = s_nextLuaSubId++;
s_luaSubscriptions[luaSubId] = listenerId;
lua_pushinteger(L, luaSubId);
return 1;
}
// ---------------------------------------------------------------------------
// Lua: ecs.unsubscribe_event(subscription_id) -> nil
// ---------------------------------------------------------------------------
/**
* @brief Lua: ecs.unsubscribe_event(subscription_id) -> nil
*
* Unsubscribes a previously registered event subscription.
*
* Usage:
* ecs.unsubscribe_event(sub_id)
*/
static int luaUnsubscribeEvent(lua_State *L)
{
int luaSubId = (int)luaL_checkinteger(L, 1);
auto it = s_luaSubscriptions.find(luaSubId);
if (it != s_luaSubscriptions.end()) {
EventBus::getInstance().unsubscribe(it->second);
s_luaSubscriptions.erase(it);
}
return 0;
}
// ---------------------------------------------------------------------------
// Public registration function
// ---------------------------------------------------------------------------
void registerLuaEventApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_newtable(L);
lua_setglobal(L, "ecs");
lua_getglobal(L, "ecs");
}
// Register event functions
lua_pushcfunction(L, luaSendEvent);
lua_setfield(L, -2, "send_event");
lua_pushcfunction(L, luaSubscribeEvent);
lua_setfield(L, -2, "subscribe_event");
lua_pushcfunction(L, luaUnsubscribeEvent);
lua_setfield(L, -2, "unsubscribe_event");
// Pop the ecs table
lua_pop(L, 1);
}
} // namespace editScene

View File

@@ -0,0 +1,55 @@
#ifndef EDITSCENE_LUA_EVENT_API_HPP
#define EDITSCENE_LUA_EVENT_API_HPP
#pragma once
#include <lua.hpp>
/**
* @file LuaEventApi.hpp
* @brief Lua API for the EventBus system.
*
* Provides Lua bindings for the global EventBus singleton, allowing
* Lua scripts to subscribe to events, send events, and manage
* event subscriptions.
*
* The EventBus is a synchronous publish/subscribe system. Events are
* identified by name strings. Payloads use GoapBlackboard, which
* supports int, float, Vector3, and string values.
*
* Exposed Lua globals (in the "ecs" table):
* ecs.send_event("eventName") -> nil
* ecs.send_event("eventName", {key=value, ...}) -> nil
* ecs.subscribe_event("eventName", callback_fn) -> int (subscription id)
* ecs.unsubscribe_event(subscription_id) -> nil
*
* The callback function receives (event_name, params_table):
* ecs.subscribe_event("collision", function(event, params)
* print(event .. " occurred")
* print("entity_id: " .. params.entity_id)
* end)
*
* The params table contains the GoapBlackboard fields:
* params.bits -> integer (bitfield)
* params.mask -> integer (bitmask)
* params.values -> table {string -> int}
* params.floatValues -> table {string -> float}
* params.vec3Values -> table {string -> {x, y, z}}
* params.stringValues -> table {string -> string}
*/
namespace editScene
{
/**
* @brief Register all event-related Lua API functions into the "ecs" table.
*
* Adds functions for event subscription, unsubscription, and sending.
* Must be called after LuaState is constructed and the "ecs" table exists.
*
* @param L The Lua state.
*/
void registerLuaEventApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_EVENT_API_HPP

View File

@@ -0,0 +1,187 @@
#include "LuaState.hpp"
#include <OgreResourceGroupManager.h>
#include <OgreLogManager.h>
#include <OgreDataStream.h>
#include <iostream>
extern "C" {
int luaopen_lpeg(lua_State *L);
}
namespace editScene
{
// ---------------------------------------------------------------------------
// Custom library loader: loads .lua files from OGRE resource groups
// ---------------------------------------------------------------------------
int LuaState::luaLibraryLoader(lua_State *L)
{
if (!lua_isstring(L, 1)) {
luaL_error(
L,
"luaLibraryLoader: Expected string for first parameter");
}
std::string libraryFile = lua_tostring(L, 1);
// Translate '.' to '/' for OGRE resource path compatibility
while (libraryFile.find('.') != std::string::npos)
libraryFile.replace(libraryFile.find('.'), 1, "/");
libraryFile += ".lua";
Ogre::DataStreamPtr stream =
Ogre::ResourceGroupManager::getSingleton().openResource(
libraryFile, "LuaScripts");
Ogre::String script = stream->getAsString();
if (luaL_loadbuffer(L, script.c_str(), script.length(),
libraryFile.c_str())) {
luaL_error(
L,
"Error loading library '%s' from resource archive.\n%s",
libraryFile.c_str(), lua_tostring(L, -1));
}
return 1;
}
// ---------------------------------------------------------------------------
// Install the custom loader into package.searchers
// ---------------------------------------------------------------------------
void LuaState::installLibraryLoader()
{
lua_getglobal(L, "table");
lua_getfield(L, -1, "insert");
lua_remove(L, -2); // table
lua_getglobal(L, "package");
lua_getfield(L, -1, "searchers");
lua_remove(L, -2); // package
lua_pushnumber(L, 1); // insert at position 1 (highest priority)
lua_pushcfunction(L, luaLibraryLoader);
if (lua_pcall(L, 3, 0, 0))
Ogre::LogManager::getSingleton().stream() << lua_tostring(L, 1);
}
// ---------------------------------------------------------------------------
// Constructor: create Lua state and open standard libraries
// ---------------------------------------------------------------------------
LuaState::LuaState()
: L(luaL_newstate())
{
luaopen_base(L);
luaopen_table(L);
luaopen_package(L);
luaL_requiref(L, "table", luaopen_table, 1);
lua_pop(L, 1);
luaL_requiref(L, "math", luaopen_math, 1);
lua_pop(L, 1);
luaL_requiref(L, "package", luaopen_package, 1);
lua_pop(L, 1);
luaL_requiref(L, "string", luaopen_string, 1);
lua_pop(L, 1);
luaL_requiref(L, "io", luaopen_io, 1);
lua_pop(L, 1);
luaL_requiref(L, "lpeg", luaopen_lpeg, 1);
lua_pop(L, 1);
installLibraryLoader();
lua_pop(L, 1);
}
// ---------------------------------------------------------------------------
// Destructor
// ---------------------------------------------------------------------------
LuaState::~LuaState()
{
lua_close(L);
}
// ---------------------------------------------------------------------------
// Handler registration
// ---------------------------------------------------------------------------
int LuaState::setupHandler()
{
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_pushvalue(L, 1);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
setupHandlers.push_back(ref);
return 0;
}
// ---------------------------------------------------------------------------
// Call all handlers with an event name (no entities)
// ---------------------------------------------------------------------------
int LuaState::callHandler(const Ogre::String &event)
{
for (int ref : setupHandlers) {
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
lua_pushstring(L, event.c_str());
lua_pushinteger(L, -1);
lua_pushinteger(L, -1);
if (lua_pcall(L, 3, 0, 0) != LUA_OK) {
Ogre::LogManager::getSingleton().stream()
<< lua_tostring(L, -1);
OgreAssert(false, "Lua error");
}
}
return 0;
}
// ---------------------------------------------------------------------------
// Call all handlers with an event name and two entities
// ---------------------------------------------------------------------------
int LuaState::callHandler(const Ogre::String &event, flecs::entity e1,
flecs::entity e2)
{
// Entity IDs are mapped through the global idmap (see LuaEntityApi.cpp)
extern int luaEntityToId(flecs::entity e);
extern flecs::entity luaIdToEntity(int id);
for (int ref : setupHandlers) {
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
lua_pushstring(L, event.c_str());
lua_pushinteger(L, luaEntityToId(e1));
lua_pushinteger(L, luaEntityToId(e2));
if (lua_pcall(L, 3, 0, 0) != LUA_OK) {
Ogre::LogManager::getSingleton().stream()
<< lua_tostring(L, -1);
OgreAssert(false, "Lua error");
}
}
return 0;
}
// ---------------------------------------------------------------------------
// Late setup: load data.lua and run initialisation
// ---------------------------------------------------------------------------
void LuaState::lateSetup()
{
Ogre::DataStreamPtr stream =
Ogre::ResourceGroupManager::getSingleton().openResource(
"data2.lua", "LuaScripts");
std::cout << "stream: " << stream->getAsString() << "\n";
if (luaL_dostring(L, stream->getAsString().c_str()) != LUA_OK) {
std::cout << "error: " << lua_tostring(L, -1) << "\n";
OgreAssert(false, "Script failure");
}
const char *lua_code = "\n\
function stuff()\n\
return 4\n\
end\n\
x = stuff()\n\
";
luaL_dostring(L, lua_code);
lua_getglobal(L, "x");
int x = lua_tonumber(L, 1);
std::cout << "lua: " << x << "\n";
}
} // namespace editScene

View File

@@ -0,0 +1,137 @@
#ifndef EDITSCENE_LUA_STATE_HPP
#define EDITSCENE_LUA_STATE_HPP
#pragma once
#include <Ogre.h>
#include <lua.hpp>
#include <flecs.h>
#include <string>
#include <vector>
/**
* @file LuaState.hpp
* @brief Lua state management for the editScene editor.
*
* Manages the Lua virtual machine instance, library loading,
* script loading from OGRE resource groups, and the custom
* package.searchers entry that loads Lua modules from
* the "LuaScripts" resource group.
*
* Usage:
* LuaState lua;
* lua.installLibraryLoader(); // Register custom searcher
* lua.lateSetup(); // Load data.lua and run startup
* lua.callHandler("event_name", e1, e2);
*/
namespace editScene
{
/**
* @brief Manages a single lua_State instance.
*
* Opens standard Lua libraries (base, table, math, package, string, io)
* plus LPEG. Installs a custom package.searchers entry that loads
* .lua files from OGRE's "LuaScripts" resource group.
*
* Provides a handler/callback system: Lua scripts can register
* callback functions via setup_handler(), and C++ code can invoke
* them with event names and optional entity parameters.
*/
class LuaState {
public:
/**
* @brief Construct a new Lua state.
*
* Opens all standard libraries and installs the custom
* library loader for OGRE resource-based Lua modules.
*/
LuaState();
/**
* @brief Destroy the Lua state and close the VM.
*/
~LuaState();
/**
* @brief Get the underlying lua_State pointer.
*/
lua_State *getState() const
{
return L;
}
/**
* @brief Install the custom library loader into package.searchers.
*
* Inserts luaLibraryLoader at position 1 of the searchers table
* so it takes precedence over the default file-system loader.
* This allows Lua's require() to load modules from OGRE resource
* archives.
*/
void installLibraryLoader();
/**
* @brief Late setup: load data.lua and run initialisation.
*
* Opens "data.lua" from the "LuaScripts" resource group and
* executes it. Also runs a simple inline Lua test snippet.
* Called once after the ECS world is fully initialised.
*/
void lateSetup();
/**
* @brief Register a Lua callback function for event handling.
*
* Expects a Lua function on the stack (at index 1).
* Stores a reference to it in the registry so it can be
* called later via callHandler().
*
* @return int Reference index (stored internally).
*/
int setupHandler();
/**
* @brief Call all registered handlers with an event name.
*
* @param event The event name string.
* @return int 0 on success.
*/
int callHandler(const Ogre::String &event);
/**
* @brief Call all registered handlers with an event name and
* two entity IDs.
*
* Entity IDs are mapped through the global idmap (see LuaEntityApi).
*
* @param event The event name string.
* @param e1 First entity (e.g. subject).
* @param e2 Second entity (e.g. object).
* @return int 0 on success.
*/
int callHandler(const Ogre::String &event, flecs::entity e1,
flecs::entity e2);
private:
lua_State *L;
/** Registry references for registered Lua callback functions. */
std::vector<int> setupHandlers;
/**
* @brief Custom Lua loader function for OGRE resource-based modules.
*
* Registered in package.searchers. Translates dots to path
* separators, appends ".lua", and loads the file from the
* "LuaScripts" OGRE resource group.
*
* @param L Lua state.
* @return int 1 (pushes loaded chunk onto stack) or error.
*/
static int luaLibraryLoader(lua_State *L);
};
} // namespace editScene
#endif // EDITSCENE_LUA_STATE_HPP

View File

@@ -790,10 +790,10 @@ public:
node->_setDerivedOrientation(JoltPhysics::convert(q));
}
for (JPH::Character *ch : characters) {
if (body_interface.IsAdded(ch->GetBodyID())) {
JPH::BodyID bID = ch->GetBodyID();
if (body_interface.IsAdded(bID)) {
ch->PostSimulation(0.1f);
Ogre::SceneNode *node =
id2node[ch->GetBodyID()];
Ogre::SceneNode *node = id2node[bID];
if (node)
node->_setDerivedPosition(
JoltPhysics::convert(
@@ -1633,6 +1633,13 @@ public:
return it->second;
return nullptr;
}
void setRootMotionCharacter(JPH::BodyID id, bool enabled)
{
(void)id;
(void)enabled;
/* No longer needed - root motion drives physics velocity,
* and physics writes position back to scene node normally. */
}
};
void physics()
@@ -1976,5 +1983,10 @@ JoltPhysicsWrapper::getSceneNodeFromBodyID(JPH::BodyID id) const
return phys->getSceneNodeFromBodyID(id);
}
void JoltPhysicsWrapper::setRootMotionCharacter(JPH::BodyID id, bool enabled)
{
phys->setRootMotionCharacter(id, enabled);
}
template <>
JoltPhysicsWrapper *Ogre::Singleton<JoltPhysicsWrapper>::msSingleton = 0;

View File

@@ -230,5 +230,12 @@ public:
bool bodyIsCharacter(JPH::BodyID id) const;
void destroyCharacter(std::shared_ptr<JPH::Character> ch);
Ogre::SceneNode *getSceneNodeFromBodyID(JPH::BodyID id) const;
/* Mark a character body as root-motion-driven.
* When true, Physics::update() will NOT write the character's
* position back to the scene node after the physics step,
* because the scene node position is driven by root motion
* from AnimationTreeSystem. */
void setRootMotionCharacter(JPH::BodyID id, bool enabled);
};
#endif

View File

@@ -0,0 +1,538 @@
#include "ActuatorSystem.hpp"
#include "../EditorApp.hpp"
#include "BehaviorTreeSystem.hpp"
#include "ItemSystem.hpp"
#include "../components/Actuator.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include "../components/PlayerController.hpp"
#include "../components/Transform.hpp"
#include "../components/Character.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/EntityName.hpp"
#include "../camera/EditorCamera.hpp"
#include <OgreCamera.h>
#include <OgreSceneNode.h>
#include <OgreLogManager.h>
#include <imgui.h>
#include <cmath>
ActuatorSystem::ActuatorSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
EditorApp *editorApp,
BehaviorTreeSystem *btSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_editorApp(editorApp)
, m_btSystem(btSystem)
{
}
ActuatorSystem::~ActuatorSystem() = default;
Ogre::Vector2 ActuatorSystem::projectToScreen(const Ogre::Vector3 &worldPoint)
{
if (!m_editorApp)
return Ogre::Vector2(-1, -1);
EditorCamera *editorCam = m_editorApp->getEditorCamera();
if (!editorCam)
return Ogre::Vector2(-1, -1);
Ogre::Camera *camera = editorCam->getCamera();
if (!camera)
return Ogre::Vector2(-1, -1);
ImVec2 vpSize = ImGui::GetMainViewport()->Size;
float width = vpSize.x;
float height = vpSize.y;
// Convert to camera space
Ogre::Vector3 eyeSpacePoint = camera->getViewMatrix() * worldPoint;
// Project to clip space
Ogre::Vector3 clipSpacePoint =
camera->getProjectionMatrix() * eyeSpacePoint;
if (clipSpacePoint.z < 0.0f)
return Ogre::Vector2(-1, -1);
// Convert from clip space (-1 to 1) to screen space (0 to 1)
float screenX = (clipSpacePoint.x / 2.0f) + 0.5f;
float screenY = 1.0f - ((clipSpacePoint.y / 2.0f) + 0.5f);
// Map to actual pixel dimensions
return Ogre::Vector2(screenX * width, screenY * height);
}
bool ActuatorSystem::isInRange(const Ogre::Vector3 &charPos,
const Ogre::Vector3 &objPos, float radius,
float height)
{
Ogre::Vector3 diff = charPos - objPos;
float xzDist = std::sqrt(diff.x * diff.x + diff.z * diff.z);
if (xzDist > radius)
return false;
float yDiff = std::abs(diff.y);
if (yDiff > height)
return false;
return true;
}
void ActuatorSystem::setPlayerInputLocked(bool locked)
{
m_world.query<PlayerControllerComponent>().each(
[&](flecs::entity, PlayerControllerComponent &pc) {
pc.inputLocked = locked;
});
}
void ActuatorSystem::executeAction(flecs::entity character,
flecs::entity actuatorEntity,
const Ogre::String &actionName)
{
if (!character.is_alive() || !actuatorEntity.is_alive())
return;
if (!actuatorEntity.has<ActuatorComponent>())
return;
auto &actuator = actuatorEntity.get_mut<ActuatorComponent>();
actuator.isExecuting = true;
m_executingActuatorId = actuatorEntity.id();
m_executingCharacterId = character.id();
m_executingActionName = actionName;
m_actionFirstFrame = true;
// Lock player input while action executes
setPlayerInputLocked(true);
Ogre::LogManager::getSingleton().logMessage(
"[ActuatorSystem] Executing action: " + actionName);
}
bool ActuatorSystem::isActionComplete(flecs::entity character, float deltaTime)
{
if (!m_btSystem || !character.is_alive())
return true;
if (m_executingActionName.empty())
return true;
// Look up the action in the singleton database to get its behavior tree
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return true;
const GoapAction *action = db->findAction(m_executingActionName);
if (!action)
return true;
// Evaluate the behavior tree directly (no ActionDebug)
auto status = m_btSystem->evaluatePlayerAction(character.id(),
action->behaviorTree,
deltaTime,
m_actionFirstFrame);
m_actionFirstFrame = false;
return status != BehaviorTreeSystem::Status::running;
}
void ActuatorSystem::drawActionMenu(flecs::entity actuatorEntity)
{
if (!actuatorEntity.is_alive() ||
!actuatorEntity.has<ActuatorComponent>())
return;
auto &actuator = actuatorEntity.get<ActuatorComponent>();
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing,
ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(300, 0), ImGuiCond_Appearing);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_AlwaysAutoResize;
if (ImGui::Begin("Select Action", nullptr, flags)) {
ImGui::Text("Select an action:");
ImGui::Separator();
for (const auto &name : actuator.actionNames) {
if (name.empty())
continue;
if (ImGui::Button(
name.c_str(),
ImVec2(ImGui::GetContentRegionAvail().x,
0))) {
m_pendingActionName = name;
}
}
ImGui::Separator();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
m_menuOpen = false;
m_menuActuatorId = 0;
m_eHoldTime = 0.0f;
m_eWasHeld = false;
if (m_editorApp)
m_editorApp->setWindowGrab(true);
}
}
ImGui::End();
}
void ActuatorSystem::update(float deltaTime)
{
// Update cooldown timers for all actuators
m_world.query<ActuatorComponent>().each(
[&](flecs::entity, ActuatorComponent &actuator) {
if (actuator.cooldownTimer > 0.0f) {
actuator.cooldownTimer -= deltaTime;
if (actuator.cooldownTimer < 0.0f)
actuator.cooldownTimer = 0.0f;
}
});
// Only run in game mode while playing
if (!m_editorApp ||
m_editorApp->getGameMode() != EditorApp::GameMode::Game ||
m_editorApp->getGamePlayState() !=
EditorApp::GamePlayState::Playing)
return;
// Check if an action is currently executing
if (m_executingActuatorId != 0) {
flecs::entity character =
m_world.entity(m_executingCharacterId);
if (isActionComplete(character, deltaTime)) {
// Action finished - start cooldown
flecs::entity actuator =
m_world.entity(m_executingActuatorId);
if (actuator.is_alive() &&
actuator.has<ActuatorComponent>()) {
auto &ac =
actuator.get_mut<ActuatorComponent>();
ac.isExecuting = false;
float cooldown = 1.5f;
m_world.query<PlayerControllerComponent>().each(
[&](flecs::entity,
PlayerControllerComponent &pc) {
cooldown = pc.actuatorCooldown;
});
ac.cooldownTimer = cooldown;
}
m_executingActuatorId = 0;
m_executingCharacterId = 0;
m_executingActionName.clear();
// Unlock player input
setPlayerInputLocked(false);
}
// Don't collect or draw anything while executing
m_visibleActuators.clear();
m_targetIndex = -1;
m_labelText.clear();
return;
}
// Find player character
flecs::entity playerCharacter = flecs::entity::null();
float actuatorDistance = 25.0f;
float actuatorCooldown = 1.5f;
m_circleColor = ImVec4(0.0f, 0.4f, 1.0f, 1.0f);
m_distantRadius = 8.0f;
m_nearRadius = 14.0f;
m_labelFontSize = 14.0f;
m_world.query<PlayerControllerComponent>().each([&](flecs::entity e,
PlayerControllerComponent
&pc) {
(void)e;
if (!playerCharacter.is_alive()) {
m_world.query<EntityNameComponent>().each(
[&](flecs::entity ec, EntityNameComponent &en) {
if (!playerCharacter.is_alive() &&
en.name == pc.targetCharacterName)
playerCharacter = ec;
});
actuatorDistance = pc.actuatorDistance;
actuatorCooldown = pc.actuatorCooldown;
m_circleColor = ImVec4(pc.actuatorColor.x,
pc.actuatorColor.y,
pc.actuatorColor.z, 1.0f);
m_distantRadius = pc.distantCircleRadius;
m_nearRadius = pc.nearCircleRadius;
m_labelFontSize = pc.actuatorLabelFontSize;
}
});
if (!playerCharacter.is_alive() ||
!playerCharacter.has<TransformComponent>())
return;
Ogre::Vector3 charPos = playerCharacter.get<TransformComponent>()
.node->_getDerivedPosition();
// Collect actuators within distance
m_visibleActuators.clear();
std::vector<ScreenActuator> inRangeActuators;
// --- Collect ActuatorComponent entities ---
m_world.query<ActuatorComponent, TransformComponent>().each(
[&](flecs::entity e, ActuatorComponent &actuator,
TransformComponent &trans) {
// Skip if on cooldown or executing
if (actuator.cooldownTimer > 0.0f ||
actuator.isExecuting)
return;
if (!trans.node)
return;
Ogre::Vector3 objPos =
trans.node->_getDerivedPosition();
float dist = charPos.distance(objPos);
if (dist > actuatorDistance)
return;
Ogre::Vector2 screenPos = projectToScreen(objPos);
if (screenPos.x < 0)
return;
ScreenActuator sa;
sa.entity = e;
sa.screenPos = screenPos;
sa.distance = dist;
ImVec2 vpSize = ImGui::GetMainViewport()->Size;
sa.distToScreenCenter =
std::abs(screenPos.x - vpSize.x * 0.5f);
m_visibleActuators.push_back(sa);
if (isInRange(charPos, objPos, actuator.radius,
actuator.height)) {
inRangeActuators.push_back(sa);
}
});
// --- Collect ItemComponent entities (no ActuatorComponent) ---
m_world.query<ItemComponent, TransformComponent>().each(
[&](flecs::entity e, ItemComponent &item,
TransformComponent &trans) {
// Skip items that also have an ActuatorComponent
// (those are handled above as actuators)
if (e.has<ActuatorComponent>())
return;
if (!trans.node)
return;
Ogre::Vector3 objPos =
trans.node->_getDerivedPosition();
float dist = charPos.distance(objPos);
if (dist > actuatorDistance)
return;
Ogre::Vector2 screenPos = projectToScreen(objPos);
if (screenPos.x < 0)
return;
ScreenActuator sa;
sa.entity = e;
sa.screenPos = screenPos;
sa.distance = dist;
ImVec2 vpSize = ImGui::GetMainViewport()->Size;
sa.distToScreenCenter =
std::abs(screenPos.x - vpSize.x * 0.5f);
m_visibleActuators.push_back(sa);
// Items use a default pickup range (same as actuator defaults)
if (isInRange(charPos, objPos, 1.5f, 1.8f)) {
inRangeActuators.push_back(sa);
}
});
// Determine target actuator for interaction
m_targetIndex = -1;
if (!inRangeActuators.empty()) {
size_t bestIdx = 0;
for (size_t i = 1; i < inRangeActuators.size(); i++) {
if (inRangeActuators[i].distToScreenCenter <
inRangeActuators[bestIdx].distToScreenCenter) {
bestIdx = i;
} else if (inRangeActuators[i].distToScreenCenter ==
inRangeActuators[bestIdx].distToScreenCenter) {
if (inRangeActuators[i].screenPos.y >
inRangeActuators[bestIdx].screenPos.y)
bestIdx = i;
}
}
// Find the index in m_visibleActuators
for (size_t i = 0; i < m_visibleActuators.size(); i++) {
if (m_visibleActuators[i].entity.id() ==
inRangeActuators[bestIdx].entity.id()) {
m_targetIndex = static_cast<int>(i);
break;
}
}
}
// Build label text
m_labelText.clear();
if (m_targetIndex >= 0) {
flecs::entity targetEntity =
m_visibleActuators[m_targetIndex].entity;
if (targetEntity.is_alive()) {
if (targetEntity.has<ActuatorComponent>()) {
auto &actuator =
targetEntity.get<ActuatorComponent>();
if (actuator.actionNames.size() == 1 &&
!actuator.actionNames[0].empty()) {
m_labelText =
"E " + actuator.actionNames[0];
} else if (actuator.actionNames.size() > 1) {
m_labelText = "E";
}
} else if (targetEntity.has<ItemComponent>()) {
// Show "E - Pick up [ItemName]" for items
auto &item = targetEntity.get<ItemComponent>();
m_labelText = "E Pick up " + item.itemName;
}
}
}
// Handle input
GameInputState &input = m_editorApp->getGameInputState();
// Handle menu state first
if (m_menuOpen) {
flecs::entity menuActuator = m_world.entity(m_menuActuatorId);
// Menu rendering happens in render()
if (!m_pendingActionName.empty()) {
executeAction(playerCharacter, menuActuator,
m_pendingActionName);
m_pendingActionName.clear();
m_menuOpen = false;
m_menuActuatorId = 0;
m_eHoldTime = 0.0f;
m_eWasHeld = false;
if (m_editorApp)
m_editorApp->setWindowGrab(true);
}
if (!input.e && m_eWasHeld) {
m_menuOpen = false;
m_menuActuatorId = 0;
m_eHoldTime = 0.0f;
m_eWasHeld = false;
if (m_editorApp)
m_editorApp->setWindowGrab(true);
}
return;
}
// Handle E key
if (m_targetIndex >= 0 && input.e) {
flecs::entity targetEntity =
m_visibleActuators[m_targetIndex].entity;
if (targetEntity.has<ActuatorComponent>()) {
auto &actuator = targetEntity.get<ActuatorComponent>();
m_eHoldTime += deltaTime;
if (actuator.actionNames.size() == 1 &&
!actuator.actionNames[0].empty()) {
if (input.ePressed) {
executeAction(playerCharacter,
targetEntity,
actuator.actionNames[0]);
m_eHoldTime = 0.0f;
}
} else if (actuator.actionNames.size() > 1) {
if (m_eHoldTime > 0.3f && !m_menuOpen) {
m_menuOpen = true;
m_menuActuatorId = targetEntity.id();
m_eWasHeld = true;
if (m_editorApp)
m_editorApp->setWindowGrab(
false);
}
}
} else if (targetEntity.has<ItemComponent>() &&
input.ePressed) {
// Pick up item
if (m_itemSystem) {
m_itemSystem->pickupItem(playerCharacter,
targetEntity);
}
m_eHoldTime = 0.0f;
}
} else {
m_eHoldTime = 0.0f;
m_eWasHeld = false;
}
}
void ActuatorSystem::render()
{
// Only run in game mode while playing
if (!m_editorApp ||
m_editorApp->getGameMode() != EditorApp::GameMode::Game ||
m_editorApp->getGamePlayState() !=
EditorApp::GamePlayState::Playing)
return;
ImDrawList *drawList = ImGui::GetBackgroundDrawList();
if (!drawList)
return;
ImColor defaultColor(m_circleColor);
ImColor targetColor(ImVec4(std::min(m_circleColor.x + 0.2f, 1.0f),
std::min(m_circleColor.y + 0.3f, 1.0f),
std::min(m_circleColor.z + 0.0f, 1.0f),
1.0f));
for (size_t i = 0; i < m_visibleActuators.size(); i++) {
bool isTarget = (static_cast<int>(i) == m_targetIndex);
float circleRadius = isTarget ? m_nearRadius : m_distantRadius;
ImColor circleCol = isTarget ? targetColor : defaultColor;
ImVec2 center(m_visibleActuators[i].screenPos.x,
m_visibleActuators[i].screenPos.y);
drawList->AddCircleFilled(center, circleRadius, circleCol);
drawList->AddCircle(center, circleRadius,
IM_COL32(255, 255, 255, 180), 0, 2.0f);
}
// Draw label for target
if (m_targetIndex >= 0 && !m_labelText.empty()) {
float circleRadius = m_nearRadius;
ImVec2 center(m_visibleActuators[m_targetIndex].screenPos.x,
m_visibleActuators[m_targetIndex].screenPos.y);
ImFont *font = ImGui::GetFont();
ImVec2 textSize = font->CalcTextSizeA(
m_labelFontSize, FLT_MAX, -1.0f, m_labelText.c_str());
ImVec2 textPos(center.x - (textSize.x * 0.5f),
center.y + circleRadius + 6.0f);
// Shadow
drawList->AddText(font, m_labelFontSize,
ImVec2(textPos.x + 1, textPos.y + 1),
IM_COL32(0, 0, 0, 200), m_labelText.c_str());
// Text
drawList->AddText(font, m_labelFontSize, textPos,
IM_COL32(255, 255, 255, 255),
m_labelText.c_str());
}
// Draw action menu if open
if (m_menuOpen) {
flecs::entity menuActuator = m_world.entity(m_menuActuatorId);
drawActionMenu(menuActuator);
}
}

View File

@@ -0,0 +1,98 @@
#ifndef EDITSCENE_ACTUATOR_SYSTEM_HPP
#define EDITSCENE_ACTUATOR_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <imgui.h>
#include <vector>
#include <string>
class EditorApp;
class BehaviorTreeSystem;
class ItemSystem;
/**
* System that handles player interaction with Actuator entities
* and Item entities.
*
* In game mode:
* - update() finds nearby actuators and items, handles input
* - render() draws on-screen circle markers (called in preViewportUpdate after NewFrame)
* - Shows "E - ActionName" (or just "E" for multi-action) when in reach
* - Shows "E - Pick up [ItemName]" for items
* - Handles E press / hold for action activation
* - Manages per-actuator cooldowns
* - Executes actions via BehaviorTreeSystem directly (no ActionDebug)
* - Disables player controls during action execution
*
* Items (entities with ItemComponent but no ActuatorComponent) are
* detected automatically and shown with a "Pick up" prompt. On E press,
* the item is added to the player's inventory via ItemSystem.
*/
class ActuatorSystem {
public:
ActuatorSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
EditorApp *editorApp, BehaviorTreeSystem *btSystem);
~ActuatorSystem();
/**
* Set the ItemSystem for item pickup handling.
* Must be called after construction.
*/
void setItemSystem(ItemSystem *system)
{
m_itemSystem = system;
}
void update(float deltaTime);
void render();
private:
struct ScreenActuator {
flecs::entity entity;
Ogre::Vector2 screenPos;
float distance;
float distToScreenCenter;
};
Ogre::Vector2 projectToScreen(const Ogre::Vector3 &worldPoint);
bool isInRange(const Ogre::Vector3 &charPos,
const Ogre::Vector3 &objPos, float radius, float height);
void executeAction(flecs::entity character,
flecs::entity actuatorEntity,
const Ogre::String &actionName);
bool isActionComplete(flecs::entity character, float deltaTime);
void drawActionMenu(flecs::entity actuatorEntity);
void setPlayerInputLocked(bool locked);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
EditorApp *m_editorApp;
BehaviorTreeSystem *m_btSystem;
ItemSystem *m_itemSystem = nullptr;
// Cached data between update() and render()
std::vector<ScreenActuator> m_visibleActuators;
int m_targetIndex = -1;
Ogre::String m_labelText;
ImVec4 m_circleColor;
float m_distantRadius = 8.0f;
float m_nearRadius = 14.0f;
float m_labelFontSize = 14.0f;
// Multi-action menu state
bool m_menuOpen = false;
flecs::entity_t m_menuActuatorId = 0;
float m_eHoldTime = 0.0f;
bool m_eWasHeld = false;
Ogre::String m_pendingActionName;
// Currently executing action state
flecs::entity_t m_executingActuatorId = 0;
flecs::entity_t m_executingCharacterId = 0;
Ogre::String m_executingActionName;
bool m_actionFirstFrame = false;
};
#endif // EDITSCENE_ACTUATOR_SYSTEM_HPP

View File

@@ -300,6 +300,12 @@ void AnimationTreeSystem::update(float deltaTime)
flecs::entity e,
AnimationTreeComponent
&at) {
/* Reset root-motion velocity for all entities.
* Root motion velocity will be set below only for
* entities with active root motion. */
if (e.has<CharacterComponent>())
e.get_mut<CharacterComponent>().linearVelocity =
Ogre::Vector3::ZERO;
resolveTemplate(at);
if (at.dirty) {
@@ -369,39 +375,58 @@ void AnimationTreeSystem::update(float deltaTime)
info.ogreAnimState->addTime(data.timeDelta);
float thisTime =
info.ogreAnimState->getTimePosition();
float length = info.ogreAnimState->getLength();
bool loop = info.ogreAnimState->getLoop();
int loops = 0;
if (loop && length > 0.0f) {
loops = (int)std::round(
(lastTime + data.timeDelta -
thisTime) /
length);
}
if (at.useRootMotion && info.rootTrack) {
Ogre::TransformKeyFrame tkf(nullptr,
0.0f);
info.rootTrack->getInterpolatedKeyFrame(
lastTime, &tkf);
Ogre::Vector3 lastPos =
tkf.getTranslate();
info.rootTrack->getInterpolatedKeyFrame(
thisTime, &tkf);
Ogre::Vector3 thisPos =
tkf.getTranslate();
Ogre::Vector3 delta =
thisPos - lastPos +
loops * info.loopTranslation;
Ogre::Vector3 delta;
if (info.hasPrevRootPos) {
/*
* Compute delta from previous
* root bone position. Detect
* animation wrapping by checking
* if time decreased (wrapped
* around). When wrapping, add
* the loop translation to
* compensate for the jump from
* end back to start.
*/
delta = thisPos -
info.prevRootPos;
if (thisTime < lastTime) {
/* Animation wrapped */
delta +=
info.loopTranslation;
}
} else {
delta = Ogre::Vector3::ZERO;
}
info.prevRootPos = thisPos;
info.hasPrevRootPos = true;
totalRootMotion += delta * data.weight;
}
}
}
if (at.useRootMotion && sceneNode) {
/*
* Compute root motion velocity from the animation
* displacement. Do NOT move the scene node directly -
* the physics character's velocity drives movement,
* and physics writes the position back to the scene
* node naturally. This avoids jitter caused by
* teleporting the physics character to match a
* root-motion-driven scene node position.
*/
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
cc.useRootMotion = true;
if (deltaTime > 0.0000001f) {
float safeDelta = Ogre::Math::Clamp(
deltaTime, 0.005f, 0.99f);
@@ -421,9 +446,6 @@ void AnimationTreeSystem::update(float deltaTime)
cc.linearVelocity.y, -10.5f,
10.0f);
}
} else {
sceneNode->translate(totalRootMotion,
Ogre::Node::TS_LOCAL);
}
}
@@ -637,9 +659,18 @@ void AnimationTreeSystem::setStateInternal(flecs::entity e,
auto itAnim = state.animations.find(
animNode->animationName);
if (itAnim != state.animations.end() &&
itAnim->second.ogreAnimState)
itAnim->second.ogreAnimState) {
itAnim->second.ogreAnimState
->setTimePosition(0.0f);
/* Reset root motion tracking
* so the first frame of the
* new animation doesn't
* produce a large delta from
* the previous animation's
* root position. */
itAnim->second.hasPrevRootPos =
false;
}
}
}
}

View File

@@ -51,6 +51,11 @@ private:
Ogre::AnimationState *ogreAnimState = nullptr;
Ogre::NodeAnimationTrack *rootTrack = nullptr;
Ogre::Vector3 loopTranslation = Ogre::Vector3::ZERO;
/* Previous root bone position for root motion delta
* computation. Used to detect animation wrapping and
* compute smooth deltas across loop boundaries. */
Ogre::Vector3 prevRootPos = Ogre::Vector3::ZERO;
bool hasPrevRootPos = false;
};
struct FadeInfo {
@@ -67,6 +72,11 @@ private:
Ogre::Quaternion rootBindingOrientation;
Ogre::Vector3 rootBindingScale;
/* Root motion unapply/reapply state */
Ogre::Vector3 appliedRootTranslation = Ogre::Vector3::ZERO;
Ogre::Quaternion appliedRootRotation =
Ogre::Quaternion::IDENTITY;
std::unordered_map<Ogre::String, AnimationRuntimeInfo>
animations;
std::unordered_map<Ogre::String, FadeInfo> fadeStates;

View File

@@ -2,21 +2,123 @@
#include "AnimationTreeSystem.hpp"
#include "CharacterSystem.hpp"
#include "SmartObjectSystem.hpp"
#include "ItemSystem.hpp"
#include "EventBus.hpp"
#include "../components/BehaviorTree.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/SmartObject.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/Relationship.hpp"
#include "../components/Character.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include <OgreLogManager.h>
#include <iostream>
#include <cstdlib>
#include <cstring>
/* Forward declaration for Lua behavior tree node support.
* The actual implementation is in lua/LuaBehaviorTreeApi.cpp.
* Returns: 0 = success, 1 = failure, 2 = running, -1 = not found/error.
* We use a function pointer approach to avoid a hard dependency on
* the Lua headers in this translation unit.
*
* The lua_State pointer is passed as void* to avoid needing the Lua
* headers in this translation unit. */
namespace editScene
{
int callLuaBehaviorTreeNode(void *L, const std::string &nodeName,
flecs::entity entity, const std::string &params);
}
static float g_epsilon = 0.0001f;
static bool parseValueString(const Ogre::String &str, int &outInt,
float &outFloat, Ogre::Vector3 &outVec3,
int &type);
/** Parse "key=val,key2=val2" params into a GoapBlackboard.
* Values are auto-detected: int, float, vec3 (x,y,z), or quoted string. */
static void parseEventParams(const Ogre::String &str, GoapBlackboard &out)
{
if (str.empty())
return;
const char *s = str.c_str();
while (*s) {
// Skip whitespace
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
break;
// Find key
const char *keyStart = s;
while (*s && *s != '=' && *s != ',')
s++;
Ogre::String key(keyStart, static_cast<size_t>(s - keyStart));
// Trim trailing spaces from key
while (!key.empty() &&
(key.back() == ' ' || key.back() == '\t'))
key.pop_back();
if (*s != '=') {
while (*s && *s != ',')
s++;
if (*s == ',')
s++;
continue;
}
s++; // skip '='
// Skip whitespace before value
while (*s == ' ' || *s == '\t')
s++;
// Find value end (next comma or end)
const char *valStart = s;
bool inQuotes = false;
while (*s && (*s != ',' || inQuotes)) {
if (*s == '"')
inQuotes = !inQuotes;
s++;
}
Ogre::String val(valStart, static_cast<size_t>(s - valStart));
// Trim trailing spaces from value
while (!val.empty() &&
(val.back() == ' ' || val.back() == '\t'))
val.pop_back();
// Strip quotes if present
if (val.size() >= 2 && val.front() == '"' &&
val.back() == '"') {
val = val.substr(1, val.size() - 2);
out.setStringValue(key, val);
} else {
// Try int/float/vec3
int iVal;
float fVal;
Ogre::Vector3 vVal;
int vType;
if (parseValueString(val, iVal, fVal, vVal, vType)) {
if (vType == 0)
out.setValue(key, iVal);
else if (vType == 1)
out.setFloatValue(key, fVal);
else
out.setVec3Value(key, vVal);
} else {
// Fallback to string
out.setStringValue(key, val);
}
}
if (*s == ',')
s++;
}
}
static bool parseBitIndex(const Ogre::String &str, int &out)
{
// Try numeric first
@@ -234,6 +336,15 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
return Status::success;
}
if (node.type == "sendEvent") {
if (isNewlyActive(state, &node)) {
GoapBlackboard params;
parseEventParams(node.params, params);
EventBus::getInstance().send(node.name, params);
}
return Status::success;
}
if (node.type == "delay") {
char key[32];
snprintf(key, sizeof(key), "%p", (void *)&node);
@@ -481,6 +592,267 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
return Status::success;
}
/* --- Item / Inventory nodes --- */
/* These nodes need an ItemSystem instance. We look it up via
* the EditorApp singleton pattern (stored in SmartObjectSystem). */
ItemSystem *itemSystem = nullptr;
{
SmartObjectSystem *soSystem = SmartObjectSystem::getInstance();
if (soSystem)
itemSystem = soSystem->getItemSystem();
}
if (node.type == "hasItem") {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
return itemSystem->hasItem(e, node.name) ? Status::success :
Status::failure;
}
if (node.type == "hasItemByName") {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
return itemSystem->hasItemByName(e, node.name) ?
Status::success :
Status::failure;
}
if (node.type == "countItem") {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
int required = 1;
if (!node.params.empty()) {
char *end = nullptr;
long val = strtol(node.params.c_str(), &end, 10);
if (end != node.params.c_str() && *end == '\0' &&
val > 0)
required = static_cast<int>(val);
}
int count = itemSystem->countItem(e, node.name);
return count >= required ? Status::success : Status::failure;
}
if (node.type == "pickupItem") {
if (isNewlyActive(state, &node)) {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
// Find nearest ItemComponent entity matching the
// optional itemId filter (node.name)
flecs::entity nearestItem = flecs::entity::null();
float nearestDist = std::numeric_limits<float>::max();
Ogre::Vector3 charPos = Ogre::Vector3::ZERO;
if (e.has<TransformComponent>()) {
auto &trans = e.get<TransformComponent>();
if (trans.node)
charPos =
trans.node
->_getDerivedPosition();
else
charPos = trans.position;
}
m_world.query<ItemComponent, TransformComponent>().each(
[&](flecs::entity itemEntity,
ItemComponent &itemComp,
TransformComponent &itemTrans) {
// Skip items that are in containers
// (they have no TransformComponent in world space)
if (!itemTrans.node &&
itemTrans.position ==
Ogre::Vector3::ZERO)
return;
if (!node.name.empty() &&
itemComp.itemId != node.name)
return;
Ogre::Vector3 itemPos =
Ogre::Vector3::ZERO;
if (itemTrans.node)
itemPos =
itemTrans.node
->_getDerivedPosition();
else
itemPos = itemTrans.position;
float dist = charPos.distance(itemPos);
// Default pickup radius of 1.5 units
if (dist < nearestDist && dist < 1.5f) {
nearestDist = dist;
nearestItem = itemEntity;
}
});
if (nearestItem.is_alive()) {
itemSystem->addItemEntityToInventory(
e, nearestItem);
std::cout << "[BT] pickupItem: picked up "
<< nearestItem.id() << std::endl;
return Status::success;
}
return Status::failure;
}
return Status::success;
}
if (node.type == "dropItem") {
if (isNewlyActive(state, &node)) {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
int count = 1;
if (!node.params.empty()) {
char *end = nullptr;
long val =
strtol(node.params.c_str(), &end, 10);
if (end != node.params.c_str() &&
*end == '\0' && val > 0)
count = static_cast<int>(val);
}
// Find the item in inventory
int slotIdx = -1;
auto &inv = e.get<InventoryComponent>();
for (int i = 0; i < (int)inv.slots.size(); i++) {
if (!inv.slots[i].isEmpty() &&
inv.slots[i].itemId == node.name) {
slotIdx = i;
break;
}
}
if (slotIdx < 0)
return Status::failure;
// Get drop position (in front of character)
Ogre::Vector3 dropPos = Ogre::Vector3::ZERO;
if (e.has<TransformComponent>()) {
auto &trans = e.get<TransformComponent>();
if (trans.node) {
dropPos =
trans.node
->_getDerivedPosition();
Ogre::Vector3 forward =
trans.node
->_getDerivedOrientation() *
Ogre::Vector3(0, 0, -1);
forward.y = 0;
forward.normalise();
dropPos += forward * 1.5f;
} else {
dropPos = trans.position;
}
}
itemSystem->dropItem(e, slotIdx, dropPos, count);
std::cout << "[BT] dropItem: dropped " << node.name
<< " x" << count << std::endl;
return Status::success;
}
return Status::success;
}
if (node.type == "useItem") {
if (isNewlyActive(state, &node)) {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
// Find the item in inventory
int slotIdx = -1;
auto &inv = e.get<InventoryComponent>();
for (int i = 0; i < (int)inv.slots.size(); i++) {
if (!inv.slots[i].isEmpty() &&
inv.slots[i].itemId == node.name) {
slotIdx = i;
break;
}
}
if (slotIdx < 0)
return Status::failure;
itemSystem->useItem(e, e, slotIdx);
std::cout << "[BT] useItem: used " << node.name
<< std::endl;
return Status::success;
}
return Status::success;
}
if (node.type == "addItemToInventory") {
if (isNewlyActive(state, &node)) {
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
// Parse params: "itemId,itemName,itemType,count,weight,value"
Ogre::String itemId = node.name;
Ogre::String itemName = node.name;
Ogre::String itemType = "misc";
int count = 1;
float weight = 0.1f;
int value = 1;
if (!node.params.empty()) {
// Parse comma-separated values
std::vector<Ogre::String> parts;
const char *s = node.params.c_str();
const char *start = s;
while (*s) {
if (*s == ',') {
parts.push_back(Ogre::String(
start,
static_cast<size_t>(
s - start)));
start = s + 1;
}
s++;
}
if (s > start)
parts.push_back(Ogre::String(
start, static_cast<size_t>(
s - start)));
if (parts.size() >= 1)
itemName = parts[0];
if (parts.size() >= 2)
itemType = parts[1];
if (parts.size() >= 3) {
char *end = nullptr;
long val = strtol(parts[2].c_str(),
&end, 10);
if (end != parts[2].c_str() &&
*end == '\0' && val > 0)
count = static_cast<int>(val);
}
if (parts.size() >= 4) {
char *end = nullptr;
float val =
strtof(parts[3].c_str(), &end);
if (end != parts[3].c_str() &&
*end == '\0' && val >= 0.0f)
weight = val;
}
if (parts.size() >= 5) {
char *end = nullptr;
long val = strtol(parts[4].c_str(),
&end, 10);
if (end != parts[4].c_str() &&
*end == '\0' && val >= 0)
value = static_cast<int>(val);
}
}
itemSystem->addItemToInventory(e, itemId, itemName,
itemType, count, weight,
value);
std::cout << "[BT] addItemToInventory: added "
<< itemName << " x" << count << std::endl;
return Status::success;
}
return Status::success;
}
/* --- Teleport to Smart Object child node --- */
if (node.type == "teleportToChild") {
if (isNewlyActive(state, &node)) {
@@ -615,6 +987,23 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
return Status::success;
}
/* --- Lua behavior tree node --- */
if (node.type == "luaTask") {
/* Call the Lua function via the forward-declared API.
* Returns: 0 = success, 1 = failure, 2 = running, -1 = error/not found */
int result = editScene::callLuaBehaviorTreeNode(
nullptr, node.name, e, node.params);
if (result == 0) return Status::success;
if (result == 1)
return Status::failure;
if (result == 2)
return Status::running;
/* -1 or other: log error and return failure */
std::cout << "[BT] luaTask: node '" << node.name
<< "' not registered or error" << std::endl;
return Status::failure;
}
return Status::success;
}
@@ -636,12 +1025,7 @@ void BehaviorTreeSystem::update(float deltaTime)
});
/* --- ActionDebug test runs --- */
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return;
@@ -675,3 +1059,24 @@ void BehaviorTreeSystem::update(float deltaTime)
debug.runTimer += deltaTime;
});
}
BehaviorTreeSystem::Status
BehaviorTreeSystem::evaluatePlayerAction(flecs::entity_t id,
const BehaviorTreeNode &root,
float deltaTime, bool reset)
{
auto &state = m_playerActionStates[id];
if (reset) {
state.lastActiveLeaves.clear();
state.firstRun = true;
state.nodeTimers.clear();
state.treeResult = Status::running;
}
state.currentActiveLeaves.clear();
Status result =
evaluateNode(root, m_world.entity(id), state, deltaTime);
state.lastActiveLeaves = state.currentActiveLeaves;
state.firstRun = false;
state.treeResult = result;
return result;
}

View File

@@ -67,6 +67,14 @@ public:
return defaultState;
}
/** Evaluate a behavior tree directly for an entity.
* Used by ActuatorSystem for player action execution.
* Pass reset=true on the first call for a new action.
* Returns running/success/failure. */
Status evaluatePlayerAction(flecs::entity_t id,
const BehaviorTreeNode &root, float deltaTime,
bool reset);
private:
Status evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
RunnerState &state, float deltaTime);
@@ -84,6 +92,7 @@ private:
std::unordered_map<flecs::entity_t, RunnerState> m_runnerStates;
std::unordered_map<flecs::entity_t, RunnerState> m_actionDebugStates;
std::unordered_map<flecs::entity_t, RunnerState> m_playerActionStates;
};
#endif // EDITSCENE_BEHAVIOR_TREE_SYSTEM_HPP

View File

@@ -165,9 +165,10 @@ void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc)
cc.hasFloor = false;
std::cout << "CharacterSystem::setupEntity: entity=" << e.id()
<< " nodePos=" << transform.node->_getDerivedPosition()
<< " parent=" << (transform.node->getParent() ?
transform.node->getParent()->getName() :
"<root>")
<< " parent="
<< (transform.node->getParent() ?
transform.node->getParent()->getName() :
"<root>")
<< " radius=" << cc.radius << " height=" << cc.height
<< " dirty=" << cc.dirty << " hasFloor=" << cc.hasFloor
<< std::endl;
@@ -280,13 +281,24 @@ void CharacterSystem::update(float deltaTime)
Ogre::Vector3 nodePos =
state.sceneNode->_getDerivedPosition();
/* If scene node was moved externally (editor gizmo),
* teleport character there */
/*
* Root motion drives physics velocity (AnimationTreeSystem
* computes it from the animation displacement). The physics
* character moves naturally via velocity, and physics writes
* the position back to the scene node. No teleportation or
* position sync needed - this avoids jitter.
*
* If the scene node was moved externally (editor gizmo),
* teleport the physics character to match.
*/
Ogre::Vector3 diff = nodePos - charPos;
if (diff.squaredLength() > 0.001f) {
state.character->SetPosition(
JoltPhysics::convert(nodePos));
}
/* Root motion velocity is applied via linear velocity
* above. No special physics handling needed - physics
* writes position back to scene node normally. */
/* Apply velocity via Jolt linear velocity.
* Preserve physics-driven Y velocity when no explicit

View File

@@ -0,0 +1,224 @@
#include "DialogueSystem.hpp"
#include "../EditorApp.hpp"
#include "../components/DialogueComponent.hpp"
#include "../systems/EventBus.hpp"
#include "../components/GoapBlackboard.hpp"
#include <imgui.h>
#include <OgreFontManager.h>
#include <OgreImGuiOverlay.h>
#include <OgreLogManager.h>
#include <OgreOverlayManager.h>
DialogueSystem::DialogueSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
EditorApp *editorApp)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_editorApp(editorApp)
{
// Subscribe to dialogue events
EventBus::getInstance().subscribe(
"dialogue_show",
[this](const Ogre::String &, const GoapBlackboard &params) {
// Find the first entity with DialogueComponent
m_world.query<DialogueComponent>().each([&](flecs::entity
e,
DialogueComponent
&dc) {
if (!dc.enabled)
return;
Ogre::String text =
params.getStringValue("text");
if (text.empty())
return;
// Parse choices from comma-separated
// string
std::vector<Ogre::String> choices;
Ogre::String choicesStr =
params.getStringValue("choices");
if (!choicesStr.empty()) {
Ogre::String::size_type start = 0;
Ogre::String::size_type end;
while ((end = choicesStr.find(",",
start)) !=
Ogre::String::npos) {
choices.push_back(
choicesStr.substr(
start,
end - start));
start = end + 1;
}
if (start < choicesStr.length())
choices.push_back(
choicesStr.substr(
start));
}
Ogre::String speaker =
params.getStringValue("speaker");
dc.show(text, choices, speaker);
});
});
}
DialogueSystem::~DialogueSystem()
{
// EventBus subscriptions are managed externally
}
void DialogueSystem::ensureFontLoaded(const Ogre::String &fontName,
float fontSize)
{
if (m_fontLoaded && m_currentFontName == fontName &&
m_currentFontSize == fontSize)
return;
Ogre::ImGuiOverlay *overlay = m_editorApp->getImGuiOverlay();
if (!overlay)
return;
// Load the main dialogue font
Ogre::FontPtr font;
try {
if (Ogre::FontManager::getSingleton().resourceExists(
"DialogueFont", "General")) {
Ogre::FontManager::getSingleton().remove("DialogueFont",
"General");
}
font = Ogre::FontManager::getSingleton().create("DialogueFont",
"General");
font->setType(Ogre::FontType::FT_TRUETYPE);
font->setSource(fontName);
font->setTrueTypeSize(fontSize);
font->setTrueTypeResolution(75);
font->addCodePointRange(Ogre::Font::CodePointRange(32, 255));
font->load();
} catch (...) {
Ogre::LogManager::getSingleton().logMessage(
"DialogueSystem: Failed to load font " + fontName);
m_dialogueFont = nullptr;
m_fontLoaded = false;
return;
}
m_dialogueFont = overlay->addFont("DialogueFont", "General");
m_currentFontName = fontName;
m_currentFontSize = fontSize;
m_fontLoaded = true;
}
void DialogueSystem::prepareFont()
{
if (!m_editorApp)
return;
// Find an entity with DialogueComponent
flecs::entity dialogueEntity = flecs::entity::null();
m_world.query<DialogueComponent>().each(
[&](flecs::entity e, DialogueComponent &) {
if (!dialogueEntity.is_alive())
dialogueEntity = e;
});
if (dialogueEntity.is_alive()) {
auto &dc = dialogueEntity.get_mut<DialogueComponent>();
ensureFontLoaded(dc.fontName, dc.fontSize);
}
}
void DialogueSystem::update(float deltaTime)
{
(void)deltaTime;
if (!m_editorApp ||
m_editorApp->getGameMode() != EditorApp::GameMode::Game ||
m_editorApp->getGamePlayState() !=
EditorApp::GamePlayState::Playing)
return;
// Find an entity with DialogueComponent
flecs::entity dialogueEntity = flecs::entity::null();
m_world.query<DialogueComponent>().each(
[&](flecs::entity e, DialogueComponent &) {
if (!dialogueEntity.is_alive())
dialogueEntity = e;
});
if (!dialogueEntity.is_alive())
return;
auto &dc = dialogueEntity.get_mut<DialogueComponent>();
if (!dc.enabled || !dc.isActive())
return;
renderDialogueBox(dc);
}
void DialogueSystem::renderDialogueBox(DialogueComponent &dc)
{
ImVec2 size = ImGui::GetMainViewport()->Size;
float boxHeight = size.y * dc.boxHeightFraction;
float boxY = size.y * dc.boxPositionFraction;
ImGui::SetNextWindowPos(ImVec2(0, boxY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(size.x, boxHeight), ImGuiCond_Always);
// Semi-transparent background
ImVec4 bgColor = ImVec4(0.0f, 0.0f, 0.0f, dc.backgroundOpacity);
ImGui::PushStyleColor(ImGuiCol_WindowBg, bgColor);
ImGui::Begin(
"DialogueBox", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoFocusOnAppearing);
ImVec2 p = ImGui::GetCursorScreenPos();
// Speaker name (if provided)
if (!dc.speaker.empty()) {
if (m_speakerFont)
ImGui::PushFont(m_speakerFont);
ImGui::TextColored(ImVec4(0.8f, 0.8f, 1.0f, 1.0f), "%s",
dc.speaker.c_str());
if (m_speakerFont)
ImGui::PopFont();
ImGui::Spacing();
}
// Narration text
if (m_dialogueFont)
ImGui::PushFont(m_dialogueFont);
ImGui::TextWrapped("%s", dc.text.c_str());
if (m_dialogueFont)
ImGui::PopFont();
ImGui::Spacing();
// Choices or click-to-progress
if (dc.choices.empty()) {
// No choices: click anywhere to progress
ImGui::SetCursorScreenPos(p);
if (ImGui::InvisibleButton("DialogueProgress",
ImGui::GetWindowSize())) {
dc.progress();
}
} else {
// Choices: render as buttons
for (int i = 0; i < (int)dc.choices.size(); i++) {
if (ImGui::Button(dc.choices[i].c_str())) {
dc.selectChoice(i + 1);
}
}
}
ImGui::End();
ImGui::PopStyleColor();
}

View File

@@ -0,0 +1,58 @@
#ifndef EDITSCENE_DIALOGUESYSTEM_HPP
#define EDITSCENE_DIALOGUESYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <imgui.h>
#include <memory>
#include "../components/DialogueComponent.hpp"
class EditorApp;
/**
* System that renders the visual-novel style dialogue box in game mode.
*
* Only active when EditorApp is in GameMode::Game and
* GamePlayState::Playing. The dialogue box is rendered at the bottom
* of the screen, showing narration text and optional player choices.
*
* The dialogue can be triggered via:
* 1. EventBus event "dialogue_show" with GoapBlackboard payload
* 2. Direct API on DialogueComponent
*/
class DialogueSystem {
public:
DialogueSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
EditorApp *editorApp);
~DialogueSystem();
/**
* Update and render the dialogue box.
* Must be called inside an active ImGui frame.
*/
void update(float deltaTime);
/**
* Pre-load the dialogue font before ImGui NewFrame().
* Must be called outside an active ImGui frame (before NewFrame).
*/
void prepareFont();
private:
void renderDialogueBox(DialogueComponent &dc);
void ensureFontLoaded(const Ogre::String &fontName, float fontSize);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
EditorApp *m_editorApp;
bool m_fontLoaded = false;
Ogre::String m_currentFontName;
float m_currentFontSize = 0.0f;
ImFont *m_dialogueFont = nullptr;
ImFont *m_speakerFont = nullptr;
};
#endif // EDITSCENE_DIALOGUESYSTEM_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,7 +36,14 @@
#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/Actuator.hpp"
#include "../components/EventHandler.hpp"
#include "../components/PrefabInstance.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include "../ui/TransformEditor.hpp"
#include "../ui/RenderableEditor.hpp"
@@ -87,11 +95,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 +116,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;
}
@@ -281,6 +326,12 @@ void EditorUISystem::update(float deltaTime)
renderPrefabBrowser();
renderCursorPanel();
// Render Action Database singleton editor window
if (m_showActionDatabaseSingleton) {
m_actionDatabaseSingletonEditor.render(
&m_showActionDatabaseSingleton);
}
// Render FPS overlay
renderFPSOverlay(deltaTime);
}
@@ -326,6 +377,27 @@ void EditorUISystem::renderHierarchyWindow()
"Ctrl+O")) {
showFileDialog(false);
}
ImGui::Separator();
if (ImGui::MenuItem("Save Action DB",
nullptr)) {
ActionDatabase::saveToJson(
"actions.json");
}
if (ImGui::MenuItem("Load Action DB",
nullptr)) {
ActionDatabase::loadFromJson(
"actions.json");
}
if (ImGui::MenuItem("Reload Action DB",
nullptr)) {
// Reload from file, then re-sync
// scene components on top
ActionDatabase::getSingleton().clear();
ActionDatabase::loadFromJson(
"actions.json");
ActionDatabase::reloadFromSceneComponents(
m_world);
}
ImGui::EndMenu();
}
@@ -343,6 +415,11 @@ void EditorUISystem::renderHierarchyWindow()
if (ImGui::MenuItem("3D Cursor")) {
m_showCursorPanel = true;
}
ImGui::Separator();
if (ImGui::MenuItem(
"Action Database (Singleton)")) {
m_showActionDatabaseSingleton = true;
}
ImGui::EndMenu();
}
@@ -561,10 +638,11 @@ void EditorUISystem::renderEntityNode(flecs::entity entity, int depth)
indicators += " [Mat]";
if (entity.has<AnimationTreeComponent>())
indicators += " [Anim]";
if (entity.has<ActionDatabase>())
indicators += " [AI]";
if (entity.has<ActionDatabaseComponent>())
indicators += " [ActDB]";
if (entity.has<ActionDebug>())
indicators += " [Debug]";
if (entity.has<BehaviorTreeComponent>())
indicators += " [BT]";
if (entity.has<GoapBlackboard>())
@@ -575,6 +653,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());
@@ -952,14 +1032,15 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render ActionDatabase if present
if (entity.has<ActionDatabase>()) {
auto &db = entity.get_mut<ActionDatabase>();
m_componentRegistry.render<ActionDatabase>(entity, db);
// Render ActionDatabaseComponent if present
if (entity.has<ActionDatabaseComponent>()) {
auto &db = entity.get_mut<ActionDatabaseComponent>();
m_componentRegistry.render<ActionDatabaseComponent>(entity, db);
componentCount++;
}
// Render ActionDebug if present
if (entity.has<ActionDebug>()) {
auto &debug = entity.get_mut<ActionDebug>();
m_componentRegistry.render<ActionDebug>(entity, debug);
@@ -998,6 +1079,57 @@ 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++;
}
// Render Actuator if present
if (entity.has<ActuatorComponent>()) {
auto &actuator = entity.get_mut<ActuatorComponent>();
m_componentRegistry.render<ActuatorComponent>(entity, actuator);
componentCount++;
}
// Render EventHandler if present
if (entity.has<EventHandlerComponent>()) {
auto &handler = entity.get_mut<EventHandlerComponent>();
m_componentRegistry.render<EventHandlerComponent>(entity,
handler);
componentCount++;
}
// Render Item if present
if (entity.has<ItemComponent>()) {
auto &item = entity.get_mut<ItemComponent>();
m_componentRegistry.render<ItemComponent>(entity, item);
componentCount++;
}
// Render Inventory if present
if (entity.has<InventoryComponent>()) {
auto &inv = entity.get_mut<InventoryComponent>();
m_componentRegistry.render<InventoryComponent>(entity, inv);
componentCount++;
}
// Show message if no components
if (componentCount == 0) {
@@ -1741,6 +1873,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 +1936,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

@@ -7,6 +7,7 @@
#include <string>
#include <vector>
#include "../ui/ComponentRegistry.hpp"
#include "../ui/ActionDatabaseSingletonEditor.hpp"
#include "../components/EntityName.hpp"
#include "../gizmo/Gizmo.hpp"
#include "../gizmo/Cursor3D.hpp"
@@ -16,6 +17,7 @@
class EditorPhysicsSystem;
class BuoyancySystem;
class NormalDebugSystem;
class EditorCamera;
namespace Ogre
{
@@ -142,6 +144,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 +277,30 @@ 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;
// Action Database singleton editor state
bool m_showActionDatabaseSingleton = false;
ActionDatabaseSingletonEditor m_actionDatabaseSingletonEditor;
// Queries
flecs::query<EntityNameComponent> m_nameQuery;

View File

@@ -0,0 +1,78 @@
#include "EventBus.hpp"
EventBus &EventBus::getInstance()
{
static EventBus instance;
return instance;
}
EventBus::ListenerId EventBus::subscribe(const Ogre::String &eventName,
Callback cb)
{
ListenerId id = m_nextId++;
m_listeners[eventName].push_back({ id, std::move(cb) });
m_idToEvent[id] = eventName;
return id;
}
void EventBus::unsubscribe(ListenerId id)
{
auto it = m_idToEvent.find(id);
if (it == m_idToEvent.end())
return;
const Ogre::String &eventName = it->second;
auto lit = m_listeners.find(eventName);
if (lit != m_listeners.end()) {
auto &vec = lit->second;
for (auto vit = vec.begin(); vit != vec.end(); ++vit) {
if (vit->id == id) {
vec.erase(vit);
break;
}
}
if (vec.empty())
m_listeners.erase(lit);
}
m_idToEvent.erase(it);
}
void EventBus::send(const Ogre::String &eventName,
const GoapBlackboard &params)
{
auto lit = m_listeners.find(eventName);
if (lit == m_listeners.end())
return;
// Copy the listener list in case a callback mutates subscriptions
auto listeners = lit->second;
for (const auto &listener : listeners) {
if (listener.callback)
listener.callback(eventName, params);
}
}
void EventBus::send(const Ogre::String &eventName,
const Ogre::String &paramName, int value)
{
GoapBlackboard params;
params.setValue(paramName, value);
send(eventName, params);
}
void EventBus::send(const Ogre::String &eventName,
const Ogre::String &paramName, float value)
{
GoapBlackboard params;
params.setFloatValue(paramName, value);
send(eventName, params);
}
void EventBus::send(const Ogre::String &eventName,
const Ogre::String &paramName,
const Ogre::Vector3 &value)
{
GoapBlackboard params;
params.setVec3Value(paramName, value);
send(eventName, params);
}

View File

@@ -0,0 +1,66 @@
#ifndef EDITSCENE_EVENT_BUS_HPP
#define EDITSCENE_EVENT_BUS_HPP
#pragma once
#include "../components/GoapBlackboard.hpp"
#include <Ogre.h>
#include <cstdint>
#include <functional>
#include <unordered_map>
#include <vector>
/**
* Global synchronous event bus.
*
* Subscribers register callbacks by event name. When an event is sent,
* all matching callbacks are invoked immediately in the send() call.
*
* Payload is a GoapBlackboard — reuses the existing int/float/vec3/bit/string
* storage with zero RTTI overhead.
*/
class EventBus {
public:
using ListenerId = uint64_t;
using Callback =
std::function<void(const Ogre::String &eventName,
const GoapBlackboard &params)>;
static EventBus &getInstance();
/** Subscribe to an event. Returns a handle for unsubscribe(). */
ListenerId subscribe(const Ogre::String &eventName, Callback cb);
/** Unsubscribe by handle. Safe to call with invalid id. */
void unsubscribe(ListenerId id);
/** Send an event with a GoapBlackboard payload. */
void send(const Ogre::String &eventName,
const GoapBlackboard &params = {});
/** Convenience: send event with a single int param. */
void send(const Ogre::String &eventName,
const Ogre::String &paramName, int value);
/** Convenience: send event with a single float param. */
void send(const Ogre::String &eventName,
const Ogre::String &paramName, float value);
/** Convenience: send event with a single Vec3 param. */
void send(const Ogre::String &eventName,
const Ogre::String &paramName,
const Ogre::Vector3 &value);
private:
EventBus() = default;
struct Listener {
ListenerId id;
Callback callback;
};
ListenerId m_nextId = 1;
std::unordered_map<Ogre::String, std::vector<Listener>> m_listeners;
std::unordered_map<ListenerId, Ogre::String> m_idToEvent;
};
#endif // EDITSCENE_EVENT_BUS_HPP

View File

@@ -0,0 +1,221 @@
#include "EventHandlerSystem.hpp"
#include "BehaviorTreeSystem.hpp"
#include "../components/EventHandler.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include <OgreLogManager.h>
EventHandlerSystem::EventHandlerSystem(flecs::world &world,
BehaviorTreeSystem *btSystem)
: m_world(world)
, m_btSystem(btSystem)
{
}
EventHandlerSystem::~EventHandlerSystem()
{
// Unsubscribe all remaining listeners
for (auto &pair : m_subscriptions) {
EventBus::getInstance().unsubscribe(pair.second);
}
m_subscriptions.clear();
}
void EventHandlerSystem::subscribeEntity(
flecs::entity e, const EventHandlerComponent &handler)
{
if (!handler.enabled || handler.eventName.empty())
return;
flecs::entity_t id = e.id();
if (m_subscriptions.find(id) != m_subscriptions.end())
return;
EventBus::ListenerId lid = EventBus::getInstance().subscribe(
handler.eventName,
[this, id, handler](const Ogre::String &,
const GoapBlackboard &params) {
this->onEvent(id, handler.eventName, params);
});
m_subscriptions[id] = lid;
}
void EventHandlerSystem::unsubscribeEntity(flecs::entity_t id)
{
auto it = m_subscriptions.find(id);
if (it == m_subscriptions.end())
return;
EventBus::getInstance().unsubscribe(it->second);
m_subscriptions.erase(it);
// If there is an active handler, abort it and clean up
auto activeIt = m_activeHandlers.find(id);
if (activeIt != m_activeHandlers.end()) {
auto keysIt = m_injectedKeys.find(id);
if (keysIt != m_injectedKeys.end()) {
flecs::entity e = m_world.entity(id);
if (e.is_alive())
removeParams(e, keysIt->second);
m_injectedKeys.erase(keysIt);
}
m_activeHandlers.erase(activeIt);
}
}
void EventHandlerSystem::onEvent(flecs::entity_t entityId,
const Ogre::String &eventName,
const GoapBlackboard &params)
{
(void)eventName;
flecs::entity e = m_world.entity(entityId);
if (!e.is_alive() || !e.has<EventHandlerComponent>())
return;
auto &handler = e.get<EventHandlerComponent>();
if (!handler.enabled || handler.actionName.empty())
return;
// If already handling an event, abort the previous one first
auto activeIt = m_activeHandlers.find(entityId);
if (activeIt != m_activeHandlers.end()) {
auto keysIt = m_injectedKeys.find(entityId);
if (keysIt != m_injectedKeys.end()) {
removeParams(e, keysIt->second);
m_injectedKeys.erase(keysIt);
}
m_activeHandlers.erase(activeIt);
}
// Start new handler
ActiveHandler ah;
ah.entityId = entityId;
ah.actionName = handler.actionName;
ah.eventParams = params;
ah.firstFrame = true;
m_activeHandlers[entityId] = ah;
// Inject params immediately so the first update() tick sees them
std::unordered_set<std::string> injected;
injectParams(e, params, injected);
m_injectedKeys[entityId] = std::move(injected);
}
void EventHandlerSystem::injectParams(
flecs::entity e, const GoapBlackboard &params,
std::unordered_set<std::string> &outKeys)
{
if (!e.has<GoapBlackboard>())
e.set<GoapBlackboard>({});
auto &bb = e.get_mut<GoapBlackboard>();
for (const auto &pair : params.values) {
bb.setValue(pair.first, pair.second);
outKeys.insert(pair.first);
}
for (const auto &pair : params.floatValues) {
bb.setFloatValue(pair.first, pair.second);
outKeys.insert(pair.first);
}
for (const auto &pair : params.vec3Values) {
bb.setVec3Value(pair.first, pair.second);
outKeys.insert(pair.first);
}
for (const auto &pair : params.stringValues) {
bb.setStringValue(pair.first, pair.second);
outKeys.insert(pair.first);
}
// Bits are not injected individually; they are part of the event
// payload semantics but merging bits globally is risky.
// Instead, event bits are NOT auto-injected. If needed, use
// setBit nodes inside the handler BT.
}
void EventHandlerSystem::removeParams(
flecs::entity e,
const std::unordered_set<std::string> &keys)
{
if (!e.has<GoapBlackboard>())
return;
auto &bb = e.get_mut<GoapBlackboard>();
for (const auto &key : keys) {
bb.removeValue(key);
bb.removeFloatValue(key);
bb.removeVec3Value(key);
bb.removeStringValue(key);
}
}
void EventHandlerSystem::update(float deltaTime)
{
if (!m_btSystem)
return;
// --- Sync subscriptions with current entities ---
std::unordered_set<flecs::entity_t> currentEntities;
m_world.query<EventHandlerComponent>().each(
[&](flecs::entity e, EventHandlerComponent &handler) {
currentEntities.insert(e.id());
if (handler.enabled) {
subscribeEntity(e, handler);
} else {
unsubscribeEntity(e.id());
}
});
// Unsubscribe entities that lost their component
std::vector<flecs::entity_t> toRemove;
for (auto &pair : m_subscriptions) {
if (currentEntities.find(pair.first) == currentEntities.end())
toRemove.push_back(pair.first);
}
for (flecs::entity_t id : toRemove)
unsubscribeEntity(id);
// --- Tick active handlers ---
std::vector<flecs::entity_t> completedHandlers;
for (auto &pair : m_activeHandlers) {
flecs::entity_t id = pair.first;
ActiveHandler &ah = pair.second;
// Look up the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db) {
completedHandlers.push_back(id);
continue;
}
const GoapAction *action = db->findAction(ah.actionName);
if (!action) {
Ogre::LogManager::getSingleton().logMessage(
"[EventHandlerSystem] Action not found: " +
ah.actionName);
completedHandlers.push_back(id);
continue;
}
auto status = m_btSystem->evaluatePlayerAction(
id, action->behaviorTree, deltaTime, ah.firstFrame);
ah.firstFrame = false;
if (status != BehaviorTreeSystem::Status::running) {
completedHandlers.push_back(id);
}
}
// --- Clean up completed handlers ---
for (flecs::entity_t id : completedHandlers) {
flecs::entity e = m_world.entity(id);
auto keysIt = m_injectedKeys.find(id);
if (keysIt != m_injectedKeys.end()) {
if (e.is_alive())
removeParams(e, keysIt->second);
m_injectedKeys.erase(keysIt);
}
m_activeHandlers.erase(id);
}
}

View File

@@ -0,0 +1,64 @@
#ifndef EDITSCENE_EVENT_HANDLER_SYSTEM_HPP
#define EDITSCENE_EVENT_HANDLER_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "EventBus.hpp"
#include "../components/GoapBlackboard.hpp"
class BehaviorTreeSystem;
class EditorApp;
/**
* System that executes behavior trees in response to events.
*
* For each entity with EventHandlerComponent, subscribes to the
* specified event. When the event fires, copies parameters into the
* entity's GoapBlackboard and runs the referenced action's behavior
* tree. Cleans up injected parameters when the tree completes.
*/
class EventHandlerSystem {
public:
EventHandlerSystem(flecs::world &world, BehaviorTreeSystem *btSystem);
~EventHandlerSystem();
void update(float deltaTime);
private:
struct ActiveHandler {
flecs::entity_t entityId;
Ogre::String actionName;
GoapBlackboard eventParams;
bool firstFrame = true;
};
void subscribeEntity(flecs::entity e,
const class EventHandlerComponent &handler);
void unsubscribeEntity(flecs::entity_t id);
void onEvent(flecs::entity_t entityId,
const Ogre::String &eventName,
const GoapBlackboard &params);
void injectParams(flecs::entity e, const GoapBlackboard &params,
std::unordered_set<std::string> &outKeys);
void removeParams(flecs::entity e,
const std::unordered_set<std::string> &keys);
flecs::world &m_world;
BehaviorTreeSystem *m_btSystem;
// Per-entity event subscription
std::unordered_map<flecs::entity_t, EventBus::ListenerId> m_subscriptions;
// Per-entity active handler (one at a time per entity)
std::unordered_map<flecs::entity_t, ActiveHandler> m_activeHandlers;
// Keys injected into blackboard per active handler
std::unordered_map<flecs::entity_t, std::unordered_set<std::string>> m_injectedKeys;
};
#endif // EDITSCENE_EVENT_HANDLER_SYSTEM_HPP

View File

@@ -0,0 +1,261 @@
#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 singleton
const ActionDatabase *db = ActionDatabase::getSingletonPtr();
// 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,312 @@
#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 singleton
ActionDatabase *db = ActionDatabase::getSingletonPtr();
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 = ActionDatabase::getSingletonPtr();
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,359 @@
#include "ItemSystem.hpp"
#include "../EditorApp.hpp"
#include "BehaviorTreeSystem.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/ActionDatabase.hpp"
#include <OgreSceneNode.h>
#include <OgreLogManager.h>
#include <cmath>
ItemSystem::ItemSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
EditorApp *editorApp, BehaviorTreeSystem *btSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_editorApp(editorApp)
, m_btSystem(btSystem)
{
}
ItemSystem::~ItemSystem() = default;
// --- Inventory manipulation API ---
bool ItemSystem::addItemToInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId,
const Ogre::String &itemName,
const Ogre::String &itemType, int stackSize,
float weight, int value,
const Ogre::String &useActionName)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
// Try to stack with existing items of the same itemId
if (!itemId.empty()) {
for (int i = 0; i < (int)inv.slots.size(); i++) {
auto &slot = inv.slots[i];
if (!slot.isEmpty() && slot.itemId == itemId &&
slot.stackSize < slot.maxStackSize) {
int space = slot.maxStackSize - slot.stackSize;
int add = std::min(space, stackSize);
slot.stackSize += add;
stackSize -= add;
if (stackSize <= 0) {
inv.recalculateWeight();
return true;
}
}
}
}
// Find empty slot for remaining items
while (stackSize > 0) {
int slotIdx = inv.findEmptySlot();
if (slotIdx < 0)
return false; // Inventory full
// Ensure slots vector is large enough
while ((int)inv.slots.size() <= slotIdx)
inv.slots.emplace_back();
auto &slot = inv.slots[slotIdx];
slot.itemEntity = 0;
slot.itemId = itemId;
slot.itemName = itemName;
slot.itemType = itemType;
slot.maxStackSize = 99;
slot.weight = weight;
slot.value = value;
slot.useActionName = useActionName;
int add = std::min(stackSize, slot.maxStackSize);
slot.stackSize = add;
stackSize -= add;
}
inv.recalculateWeight();
return true;
}
bool ItemSystem::addItemEntityToInventory(flecs::entity inventoryEntity,
flecs::entity itemEntity)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
if (!itemEntity.is_alive() || !itemEntity.has<ItemComponent>())
return false;
auto &item = itemEntity.get_mut<ItemComponent>();
// Add to inventory
bool result = addItemToInventory(inventoryEntity, item.itemId,
item.itemName, item.itemType,
item.stackSize, item.weight,
item.value, item.useActionName);
if (result) {
// Hide the world entity
if (itemEntity.has<TransformComponent>()) {
auto &trans = itemEntity.get_mut<TransformComponent>();
if (trans.node)
trans.node->setVisible(false);
}
}
return result;
}
int ItemSystem::removeItemFromInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId, int count)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return 0;
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
int removed = 0;
for (int i = (int)inv.slots.size() - 1; i >= 0 && count > 0; i--) {
auto &slot = inv.slots[i];
if (slot.isEmpty() || slot.itemId != itemId)
continue;
int remove = std::min(count, slot.stackSize);
slot.stackSize -= remove;
count -= remove;
removed += remove;
if (slot.stackSize <= 0)
slot.clear();
}
if (removed > 0)
inv.recalculateWeight();
return removed;
}
bool ItemSystem::removeItemFromSlot(flecs::entity inventoryEntity,
int slotIndex, int count)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
if (slotIndex < 0 || slotIndex >= (int)inv.slots.size())
return false;
auto &slot = inv.slots[slotIndex];
if (slot.isEmpty())
return false;
int remove = std::min(count, slot.stackSize);
slot.stackSize -= remove;
if (slot.stackSize <= 0)
slot.clear();
inv.recalculateWeight();
return true;
}
bool ItemSystem::transferItem(flecs::entity fromInventory, int slotIndex,
flecs::entity toInventory, int count)
{
if (!fromInventory.is_alive() ||
!fromInventory.has<InventoryComponent>())
return false;
if (!toInventory.is_alive() || !toInventory.has<InventoryComponent>())
return false;
auto &fromInv = fromInventory.get_mut<InventoryComponent>();
if (slotIndex < 0 || slotIndex >= (int)fromInv.slots.size())
return false;
auto &slot = fromInv.slots[slotIndex];
if (slot.isEmpty())
return false;
int transferCount = std::min(count, slot.stackSize);
// Add to target inventory
bool added = addItemToInventory(toInventory, slot.itemId, slot.itemName,
slot.itemType, transferCount,
slot.weight, slot.value,
slot.useActionName);
if (!added)
return false;
// Remove from source
slot.stackSize -= transferCount;
if (slot.stackSize <= 0)
slot.clear();
fromInv.recalculateWeight();
return true;
}
bool ItemSystem::hasItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
return inventoryEntity.get<InventoryComponent>().hasItem(itemId);
}
bool ItemSystem::hasItemByName(flecs::entity inventoryEntity,
const Ogre::String &itemName) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
return inventoryEntity.get<InventoryComponent>().hasItemByName(
itemName);
}
int ItemSystem::countItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return 0;
return inventoryEntity.get<InventoryComponent>().countItem(itemId);
}
bool ItemSystem::dropItem(flecs::entity inventoryEntity, int slotIndex,
const Ogre::Vector3 &worldPosition, int count)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
if (slotIndex < 0 || slotIndex >= (int)inv.slots.size())
return false;
auto &slot = inv.slots[slotIndex];
if (slot.isEmpty())
return false;
int dropCount = std::min(count, slot.stackSize);
// If the item has a world entity reference, show it
if (slot.itemEntity != 0) {
flecs::entity itemEntity = m_world.entity(slot.itemEntity);
if (itemEntity.is_alive() && itemEntity.has<ItemComponent>()) {
auto &item = itemEntity.get_mut<ItemComponent>();
item.stackSize = dropCount;
if (itemEntity.has<TransformComponent>()) {
auto &trans =
itemEntity.get_mut<TransformComponent>();
trans.position = worldPosition;
if (trans.node) {
trans.node->setPosition(worldPosition);
trans.node->setVisible(true);
}
trans.markChanged();
}
}
} else {
// Create a new world entity for the dropped item
flecs::entity itemEntity = m_world.entity();
itemEntity.set<ItemComponent>(
ItemComponent(slot.itemName, slot.itemType));
auto &item = itemEntity.get_mut<ItemComponent>();
item.itemId = slot.itemId;
item.stackSize = dropCount;
item.weight = slot.weight;
item.value = slot.value;
item.useActionName = slot.useActionName;
// Create a scene node for the dropped item
Ogre::SceneNode *node =
m_sceneMgr->getRootSceneNode()->createChildSceneNode(
worldPosition);
TransformComponent trans;
trans.node = node;
trans.position = worldPosition;
itemEntity.set<TransformComponent>(trans);
EntityNameComponent nameComp;
nameComp.name = slot.itemName + "_dropped";
itemEntity.set<EntityNameComponent>(nameComp);
}
// Remove from inventory
slot.stackSize -= dropCount;
if (slot.stackSize <= 0)
slot.clear();
inv.recalculateWeight();
return true;
}
bool ItemSystem::useItem(flecs::entity characterEntity,
flecs::entity inventoryEntity, int slotIndex)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
auto &inv = inventoryEntity.get<InventoryComponent>();
if (slotIndex < 0 || slotIndex >= (int)inv.slots.size())
return false;
const auto &slot = inv.slots[slotIndex];
if (slot.isEmpty() || slot.useActionName.empty())
return false;
// Execute the use action via behavior tree
if (m_btSystem && characterEntity.is_alive()) {
// Look up the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (db) {
const GoapAction *action =
db->findAction(slot.useActionName);
if (action) {
m_btSystem->evaluatePlayerAction(
characterEntity.id(),
action->behaviorTree, 0.016f, true);
return true;
}
}
}
return false;
}
bool ItemSystem::pickupItem(flecs::entity characterEntity,
flecs::entity itemEntity)
{
if (!characterEntity.is_alive() ||
!characterEntity.has<InventoryComponent>())
return false;
if (!itemEntity.is_alive() || !itemEntity.has<ItemComponent>())
return false;
bool result = addItemEntityToInventory(characterEntity, itemEntity);
if (result) {
Ogre::LogManager::getSingleton().logMessage(
"[ItemSystem] Picked up: " +
itemEntity.get<ItemComponent>().itemName);
}
return result;
}

View File

@@ -0,0 +1,88 @@
#ifndef EDITSCENE_ITEM_SYSTEM_HPP
#define EDITSCENE_ITEM_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <string>
class EditorApp;
class BehaviorTreeSystem;
/**
* System that handles item pickup, drop, use, and inventory management.
*
* Provides a pure API for inventory operations. Proximity detection
* for player pickup is handled by ActuatorSystem (which detects
* entities with ItemComponent and shows "E - Pick up" prompts).
*
* For AI characters, behavior tree nodes provide inventory access.
*/
class ItemSystem {
public:
ItemSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
EditorApp *editorApp, BehaviorTreeSystem *btSystem);
~ItemSystem();
// --- Inventory manipulation API ---
/** Add an item to an inventory by itemId. Creates a new slot. */
bool addItemToInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId,
const Ogre::String &itemName,
const Ogre::String &itemType, int stackSize = 1,
float weight = 0.1f, int value = 1,
const Ogre::String &useActionName = "");
/** Add an item entity (ItemComponent) to an inventory. */
bool addItemEntityToInventory(flecs::entity inventoryEntity,
flecs::entity itemEntity);
/** Remove items from inventory by itemId. Returns number removed. */
int removeItemFromInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId, int count = 1);
/** Remove items from inventory by slot index. */
bool removeItemFromSlot(flecs::entity inventoryEntity, int slotIndex,
int count = 1);
/** Transfer items between two inventories. */
bool transferItem(flecs::entity fromInventory, int slotIndex,
flecs::entity toInventory, int count = 1);
/** Check if an inventory has at least one of a specific itemId. */
bool hasItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const;
/** Check if an inventory has at least one of a specific itemName. */
bool hasItemByName(flecs::entity inventoryEntity,
const Ogre::String &itemName) const;
/** Count how many of a specific itemId are in an inventory. */
int countItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const;
/** Drop an item from inventory into the world at a position. */
bool dropItem(flecs::entity inventoryEntity, int slotIndex,
const Ogre::Vector3 &worldPosition, int count = 1);
/** Use an item from inventory (executes its use action). */
bool useItem(flecs::entity characterEntity,
flecs::entity inventoryEntity, int slotIndex);
/**
* Pick up a world item entity into a character's inventory.
* Called by ActuatorSystem when player presses E near an item.
* Returns true if the item was picked up.
*/
bool pickupItem(flecs::entity characterEntity,
flecs::entity itemEntity);
private:
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
EditorApp *m_editorApp;
BehaviorTreeSystem *m_btSystem;
};
#endif // EDITSCENE_ITEM_SYSTEM_HPP

View File

@@ -0,0 +1,216 @@
#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) {
if (!cc.useRootMotion)
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) {
if (!cc.useRootMotion)
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";
}
/* Only set velocity if root motion is not
* active. When root motion is active,
* AnimationTreeSystem already computed the
* velocity from the animation displacement. */
if (!cc.useRootMotion)
cc.linearVelocity = toTarget * speed;
// Rotate character to face movement direction
rotateTowards(e, toTarget, deltaTime);
} else {
if (!cc.useRootMotion)
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

@@ -164,9 +164,9 @@ void PlayerControllerSystem::updateTPSCamera(PlayerControllerComponent &pc,
state.faceHidden = false;
}
// Read mouse input
// Read mouse input (skip if input locked by action)
GameInputState &input = m_editorApp->getGameInputState();
if (input.mouseMoved) {
if (!pc.inputLocked && input.mouseMoved) {
state.yaw -= input.mouseDeltaX * pc.mouseSensitivity;
state.pitch -= input.mouseDeltaY * pc.mouseSensitivity;
// Clamp pitch
@@ -277,9 +277,9 @@ void PlayerControllerSystem::updateFPSCamera(PlayerControllerComponent &pc,
camNode->setPosition(boneWorldPos + offset);
// Apply mouse look
// Apply mouse look (skip if input locked by action)
GameInputState &input = m_editorApp->getGameInputState();
if (input.mouseMoved) {
if (!pc.inputLocked && input.mouseMoved) {
state.yaw -= input.mouseDeltaX * pc.mouseSensitivity;
state.pitch -= input.mouseDeltaY * pc.mouseSensitivity;
if (state.pitch > 89.0f)
@@ -302,8 +302,11 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc,
if (!state.targetEntity.has<CharacterComponent>())
return;
// Skip locomotion if input is locked by an executing action
if (pc.inputLocked)
return;
GameInputState &input = m_editorApp->getGameInputState();
auto &cc = state.targetEntity.get_mut<CharacterComponent>();
// Get camera yaw for relative movement
Ogre::Quaternion yawRot(Ogre::Degree(state.yaw), Ogre::Vector3::UNIT_Y);
@@ -318,28 +321,26 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc,
if (right.squaredLength() > 0.0001f)
right.normalise();
Ogre::Vector3 desiredVel = Ogre::Vector3::ZERO;
Ogre::Vector3 desiredDir = Ogre::Vector3::ZERO;
if (input.w)
desiredVel += forward;
desiredDir += forward;
if (input.s)
desiredVel -= forward;
desiredDir -= forward;
if (input.a)
desiredVel -= right;
desiredDir -= right;
if (input.d)
desiredVel += right;
desiredDir += right;
bool isMoving = desiredVel.squaredLength() > 0.0001f;
float speed = input.shift ? 5.0f : 2.5f;
bool isMoving = desiredDir.squaredLength() > 0.0001f;
if (isMoving) {
desiredVel.normalise();
cc.linearVelocity = desiredVel * speed;
desiredDir.normalise();
// Rotate character to face movement direction
auto &transform =
state.targetEntity.get_mut<TransformComponent>();
if (transform.node) {
Ogre::Vector3 flatForward = desiredVel;
Ogre::Vector3 flatForward = desiredDir;
flatForward.y = 0;
if (flatForward.squaredLength() > 0.0001f) {
flatForward.normalise();
@@ -354,8 +355,6 @@ void PlayerControllerSystem::updateLocomotion(PlayerControllerComponent &pc,
targetRot, true));
}
}
} else {
cc.linearVelocity = Ogre::Vector3::ZERO;
}
// Update animation state

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

@@ -28,9 +28,15 @@
#include "../components/WaterPlane.hpp"
#include "../components/Sun.hpp"
#include "../components/Skybox.hpp"
#include "../components/EventHandler.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/SmartObject.hpp"
#include "../components/Actuator.hpp"
#include "../components/GoapPlanner.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/BehaviorTree.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/NavMesh.hpp"
@@ -68,6 +74,9 @@ bool SceneSerializer::saveToFile(const std::string &filepath)
}
});
// Save ActionDatabase singleton at scene level
scene["actionDatabase"] = serializeActionDatabase();
// Write to file
std::ofstream file(filepath);
if (!file.is_open()) {
@@ -115,6 +124,11 @@ bool SceneSerializer::loadFromFile(const std::string &filepath,
}
}
// Load ActionDatabase singleton at scene level
if (scene.contains("actionDatabase")) {
deserializeActionDatabase(scene["actionDatabase"]);
}
// Clear entity map for new load
m_entityMap.clear();
@@ -289,19 +303,37 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["waterPlane"] = serializeWaterPlane(entity);
}
if (entity.has<ActionDatabase>()) {
json["actionDatabase"] = serializeActionDatabase(entity);
}
// ActionDatabase is now a singleton, serialized at scene level
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<ActuatorComponent>()) {
json["actuator"] = serializeActuator(entity);
}
if (entity.has<EventHandlerComponent>()) {
json["eventHandler"] = serializeEventHandler(entity);
}
if (entity.has<GoapPlannerComponent>()) {
json["goapPlanner"] = serializeGoapPlanner(entity);
}
if (entity.has<BehaviorTreeComponent>()) {
json["behaviorTree"] = serializeBehaviorTree(entity);
}
if (entity.has<ItemComponent>()) {
json["item"] = serializeItem(entity);
}
if (entity.has<InventoryComponent>()) {
json["inventory"] = serializeInventory(entity);
}
if (entity.has<PrefabInstanceComponent>()) {
json["prefabInstance"] = serializePrefabInstance(entity);
}
@@ -489,19 +521,37 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("actionDatabase")) {
deserializeActionDatabase(entity, json["actionDatabase"]);
}
// ActionDatabase is now a singleton, deserialized at scene level
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("actuator")) {
deserializeActuator(entity, json["actuator"]);
}
if (json.contains("eventHandler")) {
deserializeEventHandler(entity, json["eventHandler"]);
}
if (json.contains("goapPlanner")) {
deserializeGoapPlanner(entity, json["goapPlanner"]);
}
if (json.contains("behaviorTree")) {
deserializeBehaviorTree(entity, json["behaviorTree"]);
}
if (json.contains("item")) {
deserializeItem(entity, json["item"]);
}
if (json.contains("inventory")) {
deserializeInventory(entity, json["inventory"]);
}
if (json.contains("sun")) {
deserializeSun(entity, json["sun"]);
}
@@ -542,14 +592,13 @@ void SceneSerializer::deserializeEntityFirstPass(const nlohmann::json &json,
entity.child_of(parent);
}
deserializeEntityComponents(entity, json, parent, uiSystem,
true, true, true);
deserializeEntityComponents(entity, json, parent, uiSystem, true, true,
true);
}
void SceneSerializer::deserializeEntityComponents(
flecs::entity entity, const nlohmann::json &json,
flecs::entity parent, EditorUISystem *uiSystem,
bool processTransform, bool processName,
flecs::entity entity, const nlohmann::json &json, flecs::entity parent,
EditorUISystem *uiSystem, bool processTransform, bool processName,
bool addEditorMarker)
{
if (processName) {
@@ -603,8 +652,7 @@ void SceneSerializer::deserializeEntityComponents(
}
if (json.contains("proceduralTexture")) {
deserializeProceduralTexture(entity,
json["proceduralTexture"]);
deserializeProceduralTexture(entity, json["proceduralTexture"]);
}
if (json.contains("proceduralMaterial")) {
@@ -638,8 +686,7 @@ void SceneSerializer::deserializeEntityComponents(
}
if (json.contains("playerController")) {
deserializePlayerController(entity,
json["playerController"]);
deserializePlayerController(entity, json["playerController"]);
}
if (json.contains("triangleBuffer")) {
@@ -737,8 +784,7 @@ void SceneSerializer::deserializeEntityComponents(
deserializeRoof(entity, json["roof"]);
}
if (json.contains("furnitureTemplate")) {
deserializeFurnitureTemplate(entity,
json["furnitureTemplate"]);
deserializeFurnitureTemplate(entity, json["furnitureTemplate"]);
}
if (json.contains("clearArea")) {
deserializeClearArea(entity, json["clearArea"]);
@@ -749,8 +795,8 @@ void SceneSerializer::deserializeEntityComponents(
}
if (json.contains("navMeshGeometrySource")) {
deserializeNavMeshGeometrySource(
entity, json["navMeshGeometrySource"]);
deserializeNavMeshGeometrySource(entity,
json["navMeshGeometrySource"]);
}
if (json.contains("buoyancyInfo")) {
@@ -765,19 +811,37 @@ void SceneSerializer::deserializeEntityComponents(
deserializeWaterPlane(entity, json["waterPlane"]);
}
if (json.contains("actionDatabase")) {
deserializeActionDatabase(entity, json["actionDatabase"]);
}
// ActionDatabase is now a singleton, deserialized at scene level
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("actuator")) {
deserializeActuator(entity, json["actuator"]);
if (json.contains("eventHandler")) {
deserializeEventHandler(entity, json["eventHandler"]);
}
}
if (json.contains("goapPlanner")) {
deserializeGoapPlanner(entity, json["goapPlanner"]);
}
if (json.contains("behaviorTree")) {
deserializeBehaviorTree(entity, json["behaviorTree"]);
}
if (json.contains("item")) {
deserializeItem(entity, json["item"]);
}
if (json.contains("inventory")) {
deserializeInventory(entity, json["inventory"]);
}
if (json.contains("sun")) {
deserializeSun(entity, json["sun"]);
}
@@ -820,8 +884,8 @@ nlohmann::json SceneSerializer::serializePrefabInstance(flecs::entity entity)
return json;
}
void SceneSerializer::deserializePrefabInstance(
flecs::entity entity, const nlohmann::json &json)
void SceneSerializer::deserializePrefabInstance(flecs::entity entity,
const nlohmann::json &json)
{
PrefabInstanceComponent prefab;
prefab.prefabPath = json.value("prefabPath", "");
@@ -852,8 +916,8 @@ bool SceneSerializer::savePrefab(flecs::entity rootEntity,
}
}
flecs::entity SceneSerializer::loadPrefabForEdit(
const std::string &filepath, EditorUISystem *uiSystem)
flecs::entity SceneSerializer::loadPrefabForEdit(const std::string &filepath,
EditorUISystem *uiSystem)
{
try {
std::ifstream file(filepath);
@@ -878,9 +942,8 @@ flecs::entity SceneSerializer::loadPrefabForEdit(
}
deserializeEntityComponents(entity, prefabJson,
flecs::entity::null(),
uiSystem, true, true,
true);
flecs::entity::null(), uiSystem,
true, true, true);
return entity;
} catch (const std::exception &e) {
@@ -918,17 +981,16 @@ bool SceneSerializer::instantiatePrefab(flecs::entity instanceEntity,
// is the world-space override.
// Skip name — the instance entity keeps its own name.
flecs::entity parent = instanceEntity.parent();
deserializeEntityComponents(instanceEntity, prefabJson,
parent, uiSystem, false,
false, false);
deserializeEntityComponents(instanceEntity, prefabJson, parent,
uiSystem, false, false, false);
// Restore main scene entity map
m_entityMap = savedMap;
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Prefab instantiate error: ") +
e.what();
m_lastError =
std::string("Prefab instantiate error: ") + e.what();
return false;
}
}
@@ -2837,6 +2899,13 @@ nlohmann::json SceneSerializer::serializePlayerController(flecs::entity entity)
json["swimIdleState"] = pc.swimIdleState;
json["swimState"] = pc.swimState;
json["swimFastState"] = pc.swimFastState;
json["actuatorDistance"] = pc.actuatorDistance;
json["actuatorCooldown"] = pc.actuatorCooldown;
json["actuatorColor"] = { pc.actuatorColor.x, pc.actuatorColor.y,
pc.actuatorColor.z };
json["distantCircleRadius"] = pc.distantCircleRadius;
json["nearCircleRadius"] = pc.nearCircleRadius;
json["actuatorLabelFontSize"] = pc.actuatorLabelFontSize;
return json;
}
@@ -2873,6 +2942,25 @@ void SceneSerializer::deserializePlayerController(flecs::entity entity,
pc.swimIdleState = json.value("swimIdleState", pc.swimIdleState);
pc.swimState = json.value("swimState", pc.swimState);
pc.swimFastState = json.value("swimFastState", pc.swimFastState);
pc.actuatorDistance =
json.value("actuatorDistance", pc.actuatorDistance);
pc.actuatorCooldown =
json.value("actuatorCooldown", pc.actuatorCooldown);
if (json.contains("actuatorColor") &&
json["actuatorColor"].is_array() &&
json["actuatorColor"].size() >= 3) {
pc.actuatorColor = Ogre::Vector3(json["actuatorColor"][0],
json["actuatorColor"][1],
json["actuatorColor"][2]);
}
pc.distantCircleRadius =
json.value("distantCircleRadius", pc.distantCircleRadius);
pc.nearCircleRadius =
json.value("nearCircleRadius", pc.nearCircleRadius);
pc.actuatorLabelFontSize =
json.value("actuatorLabelFontSize", pc.actuatorLabelFontSize);
// inputLocked is runtime-only, always reset to false on load
pc.inputLocked = false;
entity.set<PlayerControllerComponent>(pc);
}
@@ -3172,6 +3260,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 +3290,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 +3353,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 +3371,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"]);
@@ -3304,9 +3398,12 @@ static void deserializeGoapGoal(GoapGoal &goal, const nlohmann::json &json)
goal.condition = json.value("condition", "");
}
nlohmann::json SceneSerializer::serializeActionDatabase(flecs::entity entity)
nlohmann::json SceneSerializer::serializeActionDatabase()
{
const ActionDatabase &db = entity.get<ActionDatabase>();
const ActionDatabase *dbPtr = ActionDatabase::getSingletonPtr();
if (!dbPtr)
return nlohmann::json();
const ActionDatabase &db = *dbPtr;
nlohmann::json json;
json["actions"] = nlohmann::json::array();
@@ -3334,16 +3431,17 @@ nlohmann::json SceneSerializer::serializeActionDatabase(flecs::entity entity)
return json;
}
void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
const nlohmann::json &json)
void SceneSerializer::deserializeActionDatabase(const nlohmann::json &json)
{
ActionDatabase db;
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return;
if (json.contains("actions") && json["actions"].is_array()) {
for (const auto &actionJson : json["actions"]) {
GoapAction action;
deserializeGoapAction(action, actionJson);
db.actions.push_back(action);
db->addOrReplaceAction(action);
}
}
@@ -3351,7 +3449,7 @@ void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
for (const auto &goalJson : json["goals"]) {
GoapGoal goal;
deserializeGoapGoal(goal, goalJson);
db.goals.push_back(goal);
db->addOrReplaceGoal(goal);
}
}
@@ -3364,8 +3462,6 @@ void SceneSerializer::deserializeActionDatabase(flecs::entity entity,
entry["name"].get<std::string>());
}
}
entity.set<ActionDatabase>(db);
}
nlohmann::json SceneSerializer::serializeActionDebug(flecs::entity entity)
@@ -3377,10 +3473,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,10 +3493,9 @@ 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;
}
@@ -3407,11 +3507,16 @@ void SceneSerializer::deserializeActionDebug(flecs::entity entity,
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 +3532,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 +3550,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 +3592,46 @@ 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>();
@@ -3587,3 +3712,140 @@ void SceneSerializer::deserializeNavMeshGeometrySource(
src.include = json.value("include", true);
entity.set<NavMeshGeometrySource>(src);
}
nlohmann::json SceneSerializer::serializeActuator(flecs::entity entity)
{
const ActuatorComponent &actuator = entity.get<ActuatorComponent>();
nlohmann::json json;
json["radius"] = actuator.radius;
json["height"] = actuator.height;
json["actionNames"] = actuator.actionNames;
return json;
}
void SceneSerializer::deserializeActuator(flecs::entity entity,
const nlohmann::json &json)
{
ActuatorComponent actuator;
actuator.radius = json.value("radius", 1.5f);
actuator.height = json.value("height", 1.8f);
if (json.contains("actionNames") && json["actionNames"].is_array()) {
actuator.actionNames.clear();
for (const auto &name : json["actionNames"]) {
if (name.is_string())
actuator.actionNames.push_back(name);
}
}
entity.set<ActuatorComponent>(actuator);
}
nlohmann::json SceneSerializer::serializeEventHandler(flecs::entity entity)
{
const EventHandlerComponent &handler =
entity.get<EventHandlerComponent>();
nlohmann::json json;
json["eventName"] = handler.eventName;
json["actionName"] = handler.actionName;
json["enabled"] = handler.enabled;
return json;
}
void SceneSerializer::deserializeEventHandler(flecs::entity entity,
const nlohmann::json &json)
{
EventHandlerComponent handler;
handler.eventName = json.value("eventName", handler.eventName);
handler.actionName = json.value("actionName", handler.actionName);
handler.enabled = json.value("enabled", handler.enabled);
entity.set<EventHandlerComponent>(handler);
}
nlohmann::json SceneSerializer::serializeItem(flecs::entity entity)
{
const ItemComponent &item = entity.get<ItemComponent>();
nlohmann::json json;
json["itemName"] = item.itemName;
json["itemType"] = item.itemType;
json["itemId"] = item.itemId;
json["stackSize"] = item.stackSize;
json["maxStackSize"] = item.maxStackSize;
json["weight"] = item.weight;
json["value"] = item.value;
json["useActionName"] = item.useActionName;
return json;
}
nlohmann::json SceneSerializer::serializeInventory(flecs::entity entity)
{
const InventoryComponent &inv = entity.get<InventoryComponent>();
nlohmann::json json;
json["maxSlots"] = inv.maxSlots;
json["maxWeight"] = inv.maxWeight;
json["isContainer"] = inv.isContainer;
json["isOpen"] = inv.isOpen;
nlohmann::json slotsJson = nlohmann::json::array();
for (const auto &slot : inv.slots) {
nlohmann::json slotJson;
slotJson["itemEntity"] = (uint64_t)slot.itemEntity;
slotJson["itemName"] = slot.itemName;
slotJson["itemType"] = slot.itemType;
slotJson["itemId"] = slot.itemId;
slotJson["stackSize"] = slot.stackSize;
slotJson["maxStackSize"] = slot.maxStackSize;
slotJson["weight"] = slot.weight;
slotJson["value"] = slot.value;
slotJson["useActionName"] = slot.useActionName;
slotsJson.push_back(slotJson);
}
json["slots"] = slotsJson;
return json;
}
void SceneSerializer::deserializeItem(flecs::entity entity,
const nlohmann::json &json)
{
ItemComponent item;
item.itemName = json.value("itemName", "Item");
item.itemType = json.value("itemType", "misc");
item.itemId = json.value("itemId", "");
item.stackSize = json.value("stackSize", 1);
item.maxStackSize = json.value("maxStackSize", 99);
item.weight = json.value("weight", 0.1f);
item.value = json.value("value", 1);
item.useActionName = json.value("useActionName", "");
entity.set<ItemComponent>(item);
}
void SceneSerializer::deserializeInventory(flecs::entity entity,
const nlohmann::json &json)
{
InventoryComponent inv;
inv.maxSlots = json.value("maxSlots", 20);
inv.maxWeight = json.value("maxWeight", 50.0f);
inv.isContainer = json.value("isContainer", false);
inv.isOpen = json.value("isOpen", false);
inv.slots.clear();
if (json.contains("slots") && json["slots"].is_array()) {
for (const auto &slotJson : json["slots"]) {
InventorySlot slot;
slot.itemEntity = (flecs::entity_t)slotJson.value(
"itemEntity", (uint64_t)0);
slot.itemName = slotJson.value("itemName", "");
slot.itemType = slotJson.value("itemType", "");
slot.itemId = slotJson.value("itemId", "");
slot.stackSize = slotJson.value("stackSize", 0);
slot.maxStackSize = slotJson.value("maxStackSize", 99);
slot.weight = slotJson.value("weight", 0.1f);
slot.value = slotJson.value("value", 1);
slot.useActionName =
slotJson.value("useActionName", "");
inv.slots.push_back(slot);
}
}
inv.recalculateWeight();
entity.set<InventoryComponent>(inv);
}

View File

@@ -40,8 +40,7 @@ public:
/**
* Save an entity subtree as a prefab JSON file.
*/
bool savePrefab(flecs::entity rootEntity,
const std::string &filepath);
bool savePrefab(flecs::entity rootEntity, const std::string &filepath);
/**
* Instantiate a prefab onto an existing entity.
@@ -205,17 +204,35 @@ private:
void deserializeSkybox(flecs::entity entity,
const nlohmann::json &json);
// Item/Inventory serialization
nlohmann::json serializeItem(flecs::entity entity);
nlohmann::json serializeInventory(flecs::entity entity);
void deserializeItem(flecs::entity entity, const nlohmann::json &json);
void deserializeInventory(flecs::entity entity,
const nlohmann::json &json);
// AI/GOAP serialization
nlohmann::json serializeActionDatabase(flecs::entity entity);
nlohmann::json serializeActionDatabase();
nlohmann::json serializeActionDebug(flecs::entity entity);
nlohmann::json serializePathFollowing(flecs::entity entity);
nlohmann::json serializeSmartObject(flecs::entity entity);
nlohmann::json serializeActuator(flecs::entity entity);
nlohmann::json serializeEventHandler(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 deserializeActionDatabase(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 deserializeActuator(flecs::entity entity,
const nlohmann::json &json);
void deserializeEventHandler(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"
@@ -19,6 +20,20 @@
#include <cmath>
#include <unordered_set>
/**
* Check if a character entity has root motion enabled in its
* AnimationTreeComponent. When root motion is active, the
* AnimationTreeSystem computes linearVelocity from animation
* root bone displacement, so SmartObjectSystem should NOT
* overwrite it with direct movement velocity.
*/
static bool hasRootMotion(flecs::entity e)
{
if (!e.has<AnimationTreeComponent>())
return false;
return e.get<AnimationTreeComponent>().useRootMotion;
}
SmartObjectSystem *SmartObjectSystem::s_instance = nullptr;
SmartObjectSystem::SmartObjectSystem(flecs::world &world,
@@ -115,11 +130,10 @@ 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>();
const PathFollowingState *pfState =
debug.findPathState(animName);
// Look up the path following state in PathFollowingComponent
if (e.has<PathFollowingComponent>()) {
auto &pf = e.get<PathFollowingComponent>();
const PathFollowingState *pfState = pf.findState(animName);
if (pfState) {
// Apply ALL state machine name -> state name pairs
for (const auto &pair : pfState->stateMachineStates) {
@@ -142,13 +156,8 @@ bool SmartObjectSystem::testSmartObjectAction(flecs::entity character,
if (!character.is_alive() || !smartObject.is_alive())
return false;
// Find the action in the database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Find the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (!db)
return false;
@@ -174,6 +183,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
@@ -184,13 +203,8 @@ void SmartObjectSystem::update(float deltaTime)
navmeshEntity = e;
});
// Find the action database
ActionDatabase *db = nullptr;
m_world.query<ActionDatabase>().each(
[&](flecs::entity, ActionDatabase &database) {
if (!db)
db = &database;
});
// Get the action database singleton
ActionDatabase *db = ActionDatabase::getSingletonPtr();
// Determine if we're in game mode. In game mode, player-controlled
// characters are managed by PlayerControllerSystem and should NOT
@@ -550,16 +564,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");
}
@@ -567,12 +581,19 @@ void SmartObjectSystem::update(float deltaTime)
setLocomotionState(e, "walk");
}
cc.linearVelocity = toTarget * speed;
// When root motion is enabled, the
// AnimationTreeSystem already computes
// linearVelocity from animation root
// bone displacement. Do NOT overwrite
// it with direct movement velocity.
if (!hasRootMotion(e))
cc.linearVelocity =
toTarget * speed;
// Rotate character to face movement
// direction (Y plane only)
rotateTowards(e, toTarget, deltaTime);
} else {
} else if (!hasRootMotion(e)) {
cc.linearVelocity = Ogre::Vector3::ZERO;
}
return;
@@ -631,167 +652,213 @@ void SmartObjectSystem::update(float deltaTime)
});
// Also process characters with ActionDebug (for editor testing)
m_world.query<CharacterComponent, TransformComponent, ActionDebug>()
.each([&](flecs::entity e, CharacterComponent &cc,
TransformComponent &trans, ActionDebug &debug) {
(void)trans;
(void)debug;
m_world.query<CharacterComponent, TransformComponent, ActionDebug>().each([&](flecs::entity
e,
CharacterComponent
&cc,
TransformComponent
&trans,
ActionDebug
&debug) {
(void)trans;
(void)debug;
// Skip player-controlled characters - they are managed
// by PlayerControllerSystem, not by SmartObjectSystem AI.
if (playerCharacterIds.find(e.id()) !=
playerCharacterIds.end())
return;
// Skip player-controlled characters - they are managed
// by PlayerControllerSystem, not by SmartObjectSystem AI.
if (playerCharacterIds.find(e.id()) != playerCharacterIds.end())
return;
auto &state = m_states[e.id()];
auto &state = m_states[e.id()];
// When the behavior tree is running (via ActionDebug),
// do NOT override animation states - the behavior tree's
// setAnimationState nodes should be the sole controller.
if (debug.isRunning &&
!debug.currentActionName.empty()) {
// Behavior tree controls animations, skip
// locomotion state override
} else if (state.state == State::Idle) {
setLocomotionState(e, "idle");
// When the behavior tree is running (via ActionDebug),
// do NOT override animation states - the behavior tree's
// setAnimationState nodes should be the sole controller.
if (debug.isRunning && !debug.currentActionName.empty()) {
// Behavior tree controls animations, skip
// locomotion state override
} else if (state.state == State::Idle) {
setLocomotionState(e, "idle");
return;
}
GoapBlackboard &bb = debug.blackboard;
// --- State: Pathfinding ---
if (state.state == State::Pathfinding) {
if (!state.target.smartObject.is_alive()) {
std::cout
<< "[SO] Pathfinding: target dead, going idle"
<< std::endl;
state.state = State::Idle;
return;
}
GoapBlackboard &bb = debug.blackboard;
Ogre::Vector3 charPos = getEntityPosition(e);
Ogre::Vector3 objPos =
getEntityPosition(state.target.smartObject);
// --- State: Pathfinding ---
if (state.state == State::Pathfinding) {
if (!state.target.smartObject.is_alive()) {
auto &soComp = state.target.smartObject
.get<SmartObjectComponent>();
if (isInRange(charPos, objPos, soComp.radius,
soComp.height)) {
std::cout
<< "[SO] Pathfinding: already in range, executing"
<< std::endl;
state.state = State::Executing;
state.target.isExecuting = true;
state.target.executionTimer = 0.0f;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
if (navmeshEntity.is_alive() && m_navSystem) {
state.target.path.clear();
state.target.pathIndex = 0;
bool found = m_navSystem->findPath(
navmeshEntity, charPos, objPos,
state.target.path);
if (found && !state.target.path.empty()) {
std::cout
<< "[SO] Pathfinding: target dead, going idle"
<< std::endl;
state.state = State::Idle;
return;
}
Ogre::Vector3 charPos = getEntityPosition(e);
Ogre::Vector3 objPos = getEntityPosition(
state.target.smartObject);
auto &soComp =
state.target.smartObject
.get<SmartObjectComponent>();
if (isInRange(charPos, objPos, soComp.radius,
soComp.height)) {
std::cout
<< "[SO] Pathfinding: already in range, executing"
<< std::endl;
state.state = State::Executing;
state.target.isExecuting = true;
state.target.executionTimer = 0.0f;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
if (navmeshEntity.is_alive() && m_navSystem) {
state.target.path.clear();
state.target.pathIndex = 0;
bool found = m_navSystem->findPath(
navmeshEntity, charPos, objPos,
state.target.path);
if (found &&
!state.target.path.empty()) {
std::cout
<< "[SO] Pathfinding: path found with "
<< state.target.path
.size()
<< " waypoints"
<< std::endl;
state.state = State::Moving;
} else {
std::cout
<< "[SO] Pathfinding: no path found, going idle"
<< std::endl;
state.state = State::Idle;
cc.linearVelocity =
Ogre::Vector3::ZERO;
}
<< "[SO] Pathfinding: path found with "
<< state.target.path.size()
<< " waypoints" << std::endl;
state.state = State::Moving;
} else {
std::cout
<< "[SO] Pathfinding: no navmesh, going direct"
<< std::endl;
state.target.path.clear();
state.target.path.push_back(objPos);
state.target.pathIndex = 0;
state.state = State::Moving;
}
return;
}
// --- State: Moving ---
if (state.state == State::Moving) {
if (!state.target.smartObject.is_alive() ||
state.target.path.empty()) {
std::cout
<< "[SO] Moving: target dead or path empty, idle"
<< "[SO] Pathfinding: no path found, going idle"
<< std::endl;
state.state = State::Idle;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
} else {
std::cout
<< "[SO] Pathfinding: no navmesh, going direct"
<< std::endl;
state.target.path.clear();
state.target.path.push_back(objPos);
state.target.pathIndex = 0;
state.state = State::Moving;
}
return;
}
Ogre::Vector3 charPos = getEntityPosition(e);
Ogre::Vector3 objPos = getEntityPosition(
state.target.smartObject);
// --- State: Moving ---
if (state.state == State::Moving) {
if (!state.target.smartObject.is_alive() ||
state.target.path.empty()) {
std::cout
<< "[SO] Moving: target dead or path empty, idle"
<< std::endl;
state.state = State::Idle;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
auto &soComp =
state.target.smartObject
.get<SmartObjectComponent>();
Ogre::Vector3 charPos = getEntityPosition(e);
Ogre::Vector3 objPos =
getEntityPosition(state.target.smartObject);
// Always check range first
if (isInRange(charPos, objPos, soComp.radius,
soComp.height)) {
auto &soComp = state.target.smartObject
.get<SmartObjectComponent>();
// Always check range first
if (isInRange(charPos, objPos, soComp.radius,
soComp.height)) {
std::cout << "[SO] Moving: in range, executing"
<< std::endl;
state.state = State::Executing;
state.target.isExecuting = true;
state.target.executionTimer = 0.0f;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
state.target.pathRecalcTimer += deltaTime;
if (state.target.pathRecalcTimer > 2.0f) {
state.target.pathRecalcTimer = 0.0f;
if (navmeshEntity.is_alive() && m_navSystem) {
std::vector<Ogre::Vector3> newPath;
bool found = m_navSystem->findPath(
navmeshEntity, charPos, objPos,
newPath);
if (found && !newPath.empty()) {
state.target.path = newPath;
state.target.pathIndex = 0;
}
}
}
// Check if path is exhausted
if (state.target.pathIndex >=
(int)state.target.path.size()) {
float distToObj = charPos.distance(objPos);
std::cout
<< "[SO] Moving: path exhausted, dist="
<< distToObj
<< " radius*2=" << soComp.radius * 2.0f
<< std::endl;
// If close enough, teleport to range
// and execute
if (distToObj < soComp.radius * 2.0f) {
std::cout
<< "[SO] Moving: in range, executing"
<< "[SO] Moving: close enough, teleporting"
<< std::endl;
Ogre::Vector3 dirToObj =
objPos - charPos;
dirToObj.y = 0;
float xzDist = dirToObj.length();
if (xzDist > 0.01f) {
dirToObj /= xzDist;
Ogre::Vector3 newPos =
objPos -
dirToObj *
(soComp.radius *
0.5f);
newPos.y = charPos.y;
if (trans.node) {
trans.node->setPosition(
newPos);
}
trans.position = newPos;
}
state.state = State::Executing;
state.target.isExecuting = true;
state.target.executionTimer = 0.0f;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
// Too far - recalculate path
std::cout << "[SO] Moving: too far, recalc path"
<< std::endl;
state.target.path.clear();
state.target.path.push_back(objPos);
state.target.pathIndex = 0;
}
state.target.pathRecalcTimer += deltaTime;
if (state.target.pathRecalcTimer > 2.0f) {
state.target.pathRecalcTimer = 0.0f;
if (navmeshEntity.is_alive() &&
m_navSystem) {
std::vector<Ogre::Vector3>
newPath;
bool found =
m_navSystem->findPath(
navmeshEntity,
charPos, objPos,
newPath);
if (found && !newPath.empty()) {
state.target.path =
newPath;
state.target.pathIndex =
0;
}
}
}
Ogre::Vector3 targetPos =
state.target.path[state.target.pathIndex];
Ogre::Vector3 toTarget = targetPos - charPos;
// Check if path is exhausted
// Waypoint arrival threshold - generous since
// waypoints are just guide points
const float WAYPOINT_THRESHOLD = 1.0f;
if (toTarget.length() < WAYPOINT_THRESHOLD) {
state.target.pathIndex++;
if (state.target.pathIndex >=
(int)state.target.path.size()) {
// Path exhausted - teleport to
// range and execute if close
// enough
float distToObj =
charPos.distance(objPos);
std::cout
<< "[SO] Moving: path exhausted, dist="
<< "[SO] Moving: last waypoint reached, dist="
<< distToObj << " radius*2="
<< soComp.radius * 2.0f
<< std::endl;
// If close enough, teleport to range
// and execute
if (distToObj < soComp.radius * 2.0f) {
std::cout
<< "[SO] Moving: close enough, teleporting"
<< "[SO] Moving: close enough at waypoint, teleporting"
<< std::endl;
Ogre::Vector3 dirToObj =
objPos - charPos;
@@ -822,207 +889,134 @@ void SmartObjectSystem::update(float deltaTime)
}
// Too far - recalculate path
std::cout
<< "[SO] Moving: too far, recalc path"
<< "[SO] Moving: too far at waypoint, recalc"
<< std::endl;
state.target.path.clear();
state.target.path.push_back(objPos);
state.target.pathIndex = 0;
cc.linearVelocity = Ogre::Vector3::ZERO;
return;
}
Ogre::Vector3 targetPos =
targetPos =
state.target
.path[state.target.pathIndex];
Ogre::Vector3 toTarget = targetPos - charPos;
// Waypoint arrival threshold - generous since
// waypoints are just guide points
const float WAYPOINT_THRESHOLD = 1.0f;
if (toTarget.length() < WAYPOINT_THRESHOLD) {
state.target.pathIndex++;
if (state.target.pathIndex >=
(int)state.target.path.size()) {
// Path exhausted - teleport to
// range and execute if close
// enough
float distToObj =
charPos.distance(
objPos);
std::cout
<< "[SO] Moving: last waypoint reached, dist="
<< distToObj
<< " radius*2="
<< soComp.radius * 2.0f
<< std::endl;
if (distToObj <
soComp.radius * 2.0f) {
std::cout
<< "[SO] Moving: close enough at waypoint, teleporting"
<< std::endl;
Ogre::Vector3 dirToObj =
objPos -
charPos;
dirToObj.y = 0;
float xzDist =
dirToObj.length();
if (xzDist > 0.01f) {
dirToObj /=
xzDist;
Ogre::Vector3 newPos =
objPos -
dirToObj *
(soComp.radius *
0.5f);
newPos.y =
charPos.y;
if (trans.node) {
trans.node
->setPosition(
newPos);
}
trans.position =
newPos;
}
state.state =
State::Executing;
state.target
.isExecuting =
true;
state.target
.executionTimer =
0.0f;
cc.linearVelocity =
Ogre::Vector3::
ZERO;
return;
}
// Too far - recalculate path
std::cout
<< "[SO] Moving: too far at waypoint, recalc"
<< std::endl;
state.target.path.clear();
state.target.path.push_back(
objPos);
state.target.pathIndex = 0;
cc.linearVelocity =
Ogre::Vector3::ZERO;
return;
}
targetPos =
state.target.path
[state.target.pathIndex];
toTarget = targetPos - charPos;
}
toTarget.y = 0;
if (toTarget.squaredLength() > 0.0001f) {
toTarget.normalise();
float distToTarget =
(charPos - objPos).length();
float speed = debug.walkSpeed;
if (distToTarget >
debug.walkSpeed * 3.0f) {
speed = debug.runSpeed;
setLocomotionState(e, "run");
} else {
speed = debug.walkSpeed;
setLocomotionState(e, "walk");
}
cc.linearVelocity = toTarget * speed;
// Rotate character to face movement
// direction (Y plane only)
rotateTowards(e, toTarget, deltaTime);
} else {
cc.linearVelocity = Ogre::Vector3::ZERO;
}
return;
toTarget = targetPos - charPos;
}
// --- State: Executing ---
if (state.state == State::Executing) {
cc.linearVelocity = Ogre::Vector3::ZERO;
toTarget.y = 0;
if (toTarget.squaredLength() > 0.0001f) {
toTarget.normalise();
// When the behavior tree is running (via ActionDebug),
// do NOT override animation states - the behavior tree's
// setAnimationState nodes should be the sole controller.
if (!debug.isRunning ||
debug.currentActionName.empty()) {
setLocomotionState(e, "idle");
float distToTarget =
(charPos - objPos).length();
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 {
setLocomotionState(e, "walk");
}
if (state.target.isExecuting && db) {
const GoapAction *action =
db->findAction(
state.target.actionName);
if (action) {
// Kick off the action's
// behavior tree via
// BehaviorTreeSystem's
// ActionDebug path.
// Only reset runTimer on the
// first frame of execution
// (when isRunning is false) so
// BehaviorTreeSystem re-initializes
// the runner state for a fresh
// execution. Do NOT reset every
// frame or isNewlyActive will
// fire repeatedly.
if (!debug.isRunning) {
debug.runTimer = 0.0f;
}
debug.isRunning = true;
debug.currentActionName =
state.target.actionName;
// When root motion is enabled, the
// AnimationTreeSystem already computes
// linearVelocity from animation root
// bone displacement. Do NOT overwrite
// it with direct movement velocity.
if (!hasRootMotion(e))
cc.linearVelocity = toTarget * speed;
// Check if the behavior tree
// has finished (sequence
// completed or failed).
// Only check completion when
// runTimer > 0 (at least one
// frame has passed since
// starting) to avoid
// immediately detecting
// completion on the same frame
// the tree was started.
auto &btState =
m_btSystem
->getActionDebugState(
e.id());
if (debug.runTimer > 0.0f &&
btState.treeResult !=
BehaviorTreeSystem::
Status::running) {
// Behavior tree
// completed - stop
// evaluation
debug.isRunning = false;
debug.currentActionName
.clear();
// Rotate character to face movement
// direction (Y plane only)
rotateTowards(e, toTarget, deltaTime);
} else if (!hasRootMotion(e)) {
cc.linearVelocity = Ogre::Vector3::ZERO;
}
return;
}
bb.apply(
action->effects);
state.state =
State::Idle;
state.target
.isExecuting =
false;
debug.lastResult =
"Smart object action '" +
state.target
.actionName +
"' completed";
}
} else {
// --- State: Executing ---
if (state.state == State::Executing) {
cc.linearVelocity = Ogre::Vector3::ZERO;
// When the behavior tree is running (via ActionDebug),
// do NOT override animation states - the behavior tree's
// setAnimationState nodes should be the sole controller.
if (!debug.isRunning ||
debug.currentActionName.empty()) {
setLocomotionState(e, "idle");
}
if (state.target.isExecuting && db) {
const GoapAction *action =
db->findAction(state.target.actionName);
if (action) {
// Kick off the action's
// behavior tree via
// BehaviorTreeSystem's
// ActionDebug path.
// Only reset runTimer on the
// first frame of execution
// (when isRunning is false) so
// BehaviorTreeSystem re-initializes
// the runner state for a fresh
// execution. Do NOT reset every
// frame or isNewlyActive will
// fire repeatedly.
if (!debug.isRunning) {
debug.runTimer = 0.0f;
}
debug.isRunning = true;
debug.currentActionName =
state.target.actionName;
// Check if the behavior tree
// has finished (sequence
// completed or failed).
// Only check completion when
// runTimer > 0 (at least one
// frame has passed since
// starting) to avoid
// immediately detecting
// completion on the same frame
// the tree was started.
auto &btState =
m_btSystem->getActionDebugState(
e.id());
if (debug.runTimer > 0.0f &&
btState.treeResult !=
BehaviorTreeSystem::Status::
running) {
// Behavior tree
// completed - stop
// evaluation
debug.isRunning = false;
debug.currentActionName.clear();
bb.apply(action->effects);
state.state = State::Idle;
state.target.isExecuting =
false;
debug.lastResult =
"Smart object action '" +
state.target.actionName +
"' completed";
}
} else {
state.state = State::Idle;
state.target.isExecuting = false;
}
} else {
state.state = State::Idle;
}
});
}
});
}

View File

@@ -10,6 +10,7 @@
class NavMeshSystem;
class BehaviorTreeSystem;
class AnimationTreeSystem;
class ItemSystem;
class EditorApp;
/**
@@ -55,6 +56,22 @@ public:
m_editorApp = app;
}
/**
* Set the ItemSystem for behavior tree item/inventory nodes.
*/
void setItemSystem(ItemSystem *system)
{
m_itemSystem = system;
}
/**
* Get the ItemSystem for behavior tree item/inventory nodes.
*/
ItemSystem *getItemSystem() const
{
return m_itemSystem;
}
void update(float deltaTime);
/**
@@ -65,6 +82,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;
@@ -114,6 +138,7 @@ private:
BehaviorTreeSystem *m_btSystem;
AnimationTreeSystem *m_animTreeSystem;
EditorApp *m_editorApp = nullptr;
ItemSystem *m_itemSystem = nullptr;
std::unordered_map<flecs::entity_t, CharacterState> m_states;

View File

@@ -0,0 +1,136 @@
/**
* @file Ogre.h
* @brief Stub Ogre.h for standalone tests.
*
* Provides minimal Ogre type aliases needed to compile
* ActionDatabase, GoapBlackboard, GoapGoal, GoapAction,
* and BehaviorTree components without the full Ogre SDK.
*
* Only for use in standalone tests (action_db_lua_test).
*/
#ifndef OGRE_STUB_H
#define OGRE_STUB_H
#include <string>
#include <vector>
#include <cstdint>
#include <cstdio>
#include <sstream>
#include <cassert>
namespace Ogre
{
// String is just std::string
using String = std::string;
// Minimal Vector3 for GoapBlackboard
struct Vector3 {
float x, y, z;
Vector3()
: x(0)
, y(0)
, z(0)
{
}
Vector3(float x_, float y_, float z_)
: x(x_)
, y(y_)
, z(z_)
{
}
static const Vector3 ZERO;
// Member operator== defined inline inside the struct body
// This ensures it's found by ADL for std::pair::operator==
bool operator==(const Vector3 &other) const
{
return x == other.x && y == other.y && z == other.z;
}
};
inline const Vector3 Vector3::ZERO(0, 0, 0);
// Minimal StringConverter stub for GoapBlackboard::dump()
struct StringConverter {
static String toString(int val)
{
return std::to_string(val);
}
static String toString(float val)
{
return std::to_string(val);
}
};
// Minimal LogManager stub (used by LuaActionApi and ActionDatabase)
class LogManager {
public:
static LogManager &getSingleton()
{
static LogManager instance;
return instance;
}
class Stream {
public:
template <typename T> Stream &operator<<(const T &)
{
return *this;
}
};
Stream stream()
{
return Stream();
}
void logMessage(const String &, int = 0, bool = false)
{
}
};
// Minimal ResourceGroupManager stub (used by ActionDatabase save/load)
class ResourceGroupManager {
public:
// LocationList is a vector of shared_ptr to Location
struct Location {
struct Archive {
String getName() const
{
return "";
}
};
// Use raw pointer to avoid shared_ptr dependency
Archive *archive;
};
using LocationList = std::vector<Location>;
static ResourceGroupManager &getSingleton()
{
static ResourceGroupManager instance;
return instance;
}
const LocationList &getResourceLocationList(const String &) const
{
static LocationList empty;
return empty;
}
};
// OgreAssert macro (used by LuaEntityApi.hpp)
#ifndef OgreAssert
#define OgreAssert(expr, msg) \
do { \
if (!(expr)) { \
fprintf(stderr, "OgreAssert failed: %s\n", msg); \
assert(expr); \
} \
} while (0)
#endif
} // namespace Ogre
#endif // OGRE_STUB_H

View File

@@ -0,0 +1,14 @@
/**
* @file OgreLogManager.h
* @brief Stub OgreLogManager.h for standalone tests.
*
* Provides Ogre::LogManager for LuaActionApi.cpp compilation
* in standalone test builds.
*/
#ifndef OGRE_LOG_MANAGER_H
#define OGRE_LOG_MANAGER_H
#include "Ogre.h"
#endif // OGRE_LOG_MANAGER_H

View File

@@ -0,0 +1,895 @@
/**
* @file action_db_lua_test.cpp
* @brief Compile-time test for ActionDatabase Lua API.
*
* This test creates a Lua state, registers the ActionDatabase singleton
* and the Lua action API, then runs Lua scripts that create actions
* (including with behavior trees), goals, queries them, and verifies
* the results match expectations.
*
* Build with:
* g++ -std=c++17 -I. -I../.. -I../../lua/lua-5.4.8/src \
* action_db_lua_test.cpp \
* ../components/ActionDatabase.cpp \
* ../components/GoapBlackboard.cpp \
* ../components/GoapGoal.cpp \
* ../components/GoapAction.cpp \
* ../../lua/lua-5.4.8/src/liblua.a \
* -o action_db_lua_test -lm
*
* Or via CMake (see CMakeLists.txt in this directory).
*/
#include <cstdio>
#include <cstring>
#include <cassert>
#include <string>
#include <vector>
// Ogre stub (provides Ogre::String, Ogre::Vector3, Ogre::LogManager)
// Must be included before any component headers that use Ogre types.
#include "ogre_stub.h"
// Include Lua
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
// Include the components we need
#include "../components/ActionDatabase.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/GoapAction.hpp"
#include "../components/GoapGoal.hpp"
#include "../components/BehaviorTree.hpp"
// Forward declare the registration function
namespace editScene
{
void registerLuaActionApi(lua_State *L);
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", msg); \
return 1; \
} while (0)
// ---------------------------------------------------------------------------
// Helper: run a Lua string and check for errors
// ---------------------------------------------------------------------------
static bool runLua(lua_State *L, const char *code)
{
if (luaL_dostring(L, code) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Test 1: Basic action creation and lookup
// ---------------------------------------------------------------------------
static int testBasicAction(lua_State *L)
{
TEST("create and find a simple action");
// Clear any previous state
ActionDatabase::getSingleton().clear();
// Create action via Lua
bool ok = runLua(
L,
"ecs.action_db.add_action('test_action', 5, "
" { bits = { has_axe = true }, values = { stamina = 10 } }, "
" { bits = { has_wood = true }, values = { stamina = -5 } }"
")");
if (!ok)
FAIL("failed to run Lua");
// Verify via C++ API
const GoapAction *action =
ActionDatabase::getSingleton().findAction("test_action");
if (!action)
FAIL("action not found");
if (action->name != "test_action")
FAIL("wrong name");
if (action->cost != 5)
FAIL("wrong cost");
// Verify preconditions
if (!action->preconditions.getBit(0))
FAIL("has_axe bit not set");
if (action->preconditions.values.at("stamina") != 10)
FAIL("wrong stamina precondition");
// Verify effects
if (!action->effects.getBit(1))
FAIL("has_wood bit not set");
if (action->effects.values.at("stamina") != -5)
FAIL("wrong stamina effect");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 2: Action with behavior tree
// ---------------------------------------------------------------------------
static int testActionWithBehaviorTree(lua_State *L)
{
TEST("create action with behavior tree");
ActionDatabase::getSingleton().clear();
bool ok = runLua(L,
"ecs.action_db.add_action('wave', 1, {}, {}, {"
" type = 'sequence',"
" children = {"
" { type = 'setAnimationState', name = 'SM/Wave' },"
" { type = 'delay', params = '2.0' },"
" { type = 'setAnimationState', name = 'SM/Idle' }"
" }"
"})");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("wave");
if (!action)
FAIL("action not found");
// Verify behavior tree structure
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "sequence")
FAIL("expected sequence root");
if (bt.children.size() != 3)
FAIL("expected 3 children");
if (bt.children[0].type != "setAnimationState")
FAIL("child 0 wrong type");
if (bt.children[0].name != "SM/Wave")
FAIL("child 0 wrong name");
if (bt.children[1].type != "delay")
FAIL("child 1 wrong type");
if (bt.children[1].params != "2.0")
FAIL("child 1 wrong params");
if (bt.children[2].type != "setAnimationState")
FAIL("child 2 wrong type");
if (bt.children[2].name != "SM/Idle")
FAIL("child 2 wrong name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 3: Nested behavior tree (selector with sequences)
// ---------------------------------------------------------------------------
static int testNestedBehaviorTree(lua_State *L)
{
TEST("create action with nested behavior tree");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L,
"ecs.action_db.add_action('chop_tree', 3,"
" { bits = { near_tree = true }, values = { stamina = 15 } },"
" { bits = { has_wood = true }, values = { stamina = -8 } },"
" {"
" type = 'selector',"
" children = {"
" {"
" type = 'sequence',"
" children = {"
" { type = 'checkBit', name = 'has_axe', params = '1' },"
" { type = 'setAnimationState', name = 'SM/Chop' },"
" { type = 'delay', params = '3.0' }"
" }"
" },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'debugPrint', name = 'No axe!' },"
" { type = 'setAnimationState', name = 'SM/Pickup' }"
" }"
" }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("chop_tree");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "selector")
FAIL("expected selector root");
if (bt.children.size() != 2)
FAIL("expected 2 children");
// First child: sequence with 3 nodes
const BehaviorTreeNode &seq1 = bt.children[0];
if (seq1.type != "sequence")
FAIL("child 0 should be sequence");
if (seq1.children.size() != 3)
FAIL("seq1 expected 3 children");
if (seq1.children[0].type != "checkBit")
FAIL("seq1 child 0 wrong type");
if (seq1.children[0].name != "has_axe")
FAIL("seq1 child 0 wrong name");
if (seq1.children[0].params != "1")
FAIL("seq1 child 0 wrong params");
// Second child: sequence with 2 nodes
const BehaviorTreeNode &seq2 = bt.children[1];
if (seq2.type != "sequence")
FAIL("child 1 should be sequence");
if (seq2.children.size() != 2)
FAIL("seq2 expected 2 children");
if (seq2.children[0].type != "debugPrint")
FAIL("seq2 child 0 wrong type");
if (seq2.children[0].name != "No axe!")
FAIL("seq2 child 0 wrong name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 4: Goal creation and lookup
// ---------------------------------------------------------------------------
static int testGoal(lua_State *L)
{
TEST("create and find a goal");
ActionDatabase::getSingleton().clear();
bool ok = runLua(L, "ecs.action_db.add_goal('test_goal', 80,"
" { values = { health = 100, stamina = 80 } },"
" 'health < 50 || stamina < 30'"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapGoal *goal =
ActionDatabase::getSingleton().findGoal("test_goal");
if (!goal)
FAIL("goal not found");
if (goal->name != "test_goal")
FAIL("wrong name");
if (goal->priority != 80)
FAIL("wrong priority");
if (goal->condition != "health < 50 || stamina < 30")
FAIL("wrong condition");
if (goal->target.values.at("health") != 100)
FAIL("wrong health target");
if (goal->target.values.at("stamina") != 80)
FAIL("wrong stamina target");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 5: Action replacement (same name)
// ---------------------------------------------------------------------------
static int testActionReplacement(lua_State *L)
{
TEST("replace action with same name");
ActionDatabase::getSingleton().clear();
// Create initial action
runLua(L,
"ecs.action_db.add_action('replace_me', 1, "
"{ bits = { old_flag = true } }, { bits = { old_done = true } })");
// Verify initial
const GoapAction *first =
ActionDatabase::getSingleton().findAction("replace_me");
if (!first)
FAIL("initial action not found");
if (first->cost != 1)
FAIL("initial cost wrong");
// Replace with new definition
runLua(L,
"ecs.action_db.add_action('replace_me', 99, "
"{ bits = { new_flag = true } }, { bits = { new_done = true } })");
// Verify replacement
const GoapAction *second =
ActionDatabase::getSingleton().findAction("replace_me");
if (!second)
FAIL("replaced action not found");
if (second->cost != 99)
FAIL("replaced cost wrong");
if (second->preconditions.getBit(0))
FAIL("old precondition still present");
// Should only be one action with this name
int count = 0;
for (const auto &a : ActionDatabase::getSingleton().actions) {
if (a.name == "replace_me")
count++;
}
if (count != 1)
FAIL("expected exactly one action with this name");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 6: Remove action
// ---------------------------------------------------------------------------
static int testRemoveAction(lua_State *L)
{
TEST("remove action");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('to_remove', 1)");
runLua(L, "ecs.action_db.add_action('to_keep', 2)");
if (!ActionDatabase::getSingleton().findAction("to_remove"))
FAIL("action should exist before removal");
// Remove via Lua
bool ok = runLua(L,
"local r = ecs.action_db.remove_action('to_remove');"
"assert(r == true, 'remove should return true')");
if (!ok)
FAIL("failed to run Lua remove");
if (ActionDatabase::getSingleton().findAction("to_remove"))
FAIL("action should not exist after removal");
if (!ActionDatabase::getSingleton().findAction("to_keep"))
FAIL("other action should still exist");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 7: List actions
// ---------------------------------------------------------------------------
static int testListActions(lua_State *L)
{
TEST("list actions");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('alpha', 1)");
runLua(L, "ecs.action_db.add_action('beta', 2)");
runLua(L, "ecs.action_db.add_action('gamma', 3)");
bool ok = runLua(
L, "local list = ecs.action_db.list_actions();"
"assert(#list == 3, 'expected 3 actions, got ' .. #list);"
"assert(list[1] == 'alpha', 'expected alpha first');"
"assert(list[2] == 'beta', 'expected beta second');"
"assert(list[3] == 'gamma', 'expected gamma third')");
if (!ok)
FAIL("list actions assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 8: Find action via Lua (returns table)
// ---------------------------------------------------------------------------
static int testFindActionLua(lua_State *L)
{
TEST("find action from Lua returns table");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('find_me', 7,"
" { values = { x = 42 } },"
" { values = { y = 99 } },"
" { type = 'task', name = 'do_something' }"
")");
bool ok = runLua(
L,
"local a = ecs.action_db.find_action('find_me');"
"assert(a ~= nil, 'action should exist');"
"assert(a.name == 'find_me', 'wrong name');"
"assert(a.cost == 7, 'wrong cost');"
"assert(a.preconditions.values.x == 42, 'wrong precond');"
"assert(a.effects.values.y == 99, 'wrong effects');"
"assert(a.behaviorTree ~= nil, 'should have behaviorTree');"
"assert(a.behaviorTree.type == 'task', 'wrong bt type');"
"assert(a.behaviorTree.name == 'do_something', 'wrong bt name')");
if (!ok)
FAIL("find action Lua assertions failed");
// Test nil for non-existent
ok = runLua(L, "local a = ecs.action_db.find_action('nonexistent');"
"assert(a == nil, 'nonexistent should be nil')");
if (!ok)
FAIL("nonexistent action should be nil");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 9: Clear all
// ---------------------------------------------------------------------------
static int testClear(lua_State *L)
{
TEST("clear all actions and goals");
ActionDatabase::getSingleton().clear();
runLua(L, "ecs.action_db.add_action('a1', 1)");
runLua(L, "ecs.action_db.add_action('a2', 2)");
runLua(L, "ecs.action_db.add_goal('g1', 10)");
if (ActionDatabase::getSingleton().actions.size() != 2)
FAIL("expected 2 actions before clear");
if (ActionDatabase::getSingleton().goals.size() != 1)
FAIL("expected 1 goal before clear");
runLua(L, "ecs.action_db.clear()");
if (ActionDatabase::getSingleton().actions.size() != 0)
FAIL("expected 0 actions after clear");
if (ActionDatabase::getSingleton().goals.size() != 0)
FAIL("expected 0 goals after clear");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 10: Action with inventory behavior tree nodes
// ---------------------------------------------------------------------------
static int testInventoryBehaviorTree(lua_State *L)
{
TEST("action with inventory behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L, "ecs.action_db.add_action('gather_wood', 2,"
" { bits = { near_forest = true } },"
" { values = { wood_count = 3 } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'setAnimationState', name = 'SM/Gather' },"
" { type = 'delay', params = '2.0' },"
" { type = 'addItemToInventory',"
" params = 'wood,Firewood,material,3,1.0,0' },"
" { type = 'setAnimationState', name = 'SM/Idle' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("gather_wood");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.type != "sequence")
FAIL("expected sequence");
if (bt.children.size() != 4)
FAIL("expected 4 children");
if (bt.children[2].type != "addItemToInventory")
FAIL("wrong node type");
if (bt.children[2].params != "wood,Firewood,material,3,1.0,0")
FAIL("wrong inventory params");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 11: Action with teleport/disablePhysics behavior tree
// ---------------------------------------------------------------------------
static int testPhysicsBehaviorTree(lua_State *L)
{
TEST("action with physics behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L, "ecs.action_db.add_action('sit_on_chair', 1,"
" { bits = { near_chair = true } },"
" { bits = { is_sitting = true } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'teleportToChild', name = 'SitTarget' },"
" { type = 'disablePhysics' },"
" { type = 'setAnimationState', name = 'SM/Sit' },"
" { type = 'delay', params = '5.0' },"
" { type = 'setAnimationState', name = 'SM/Stand' },"
" { type = 'enablePhysics' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("sit_on_chair");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.children.size() != 6)
FAIL("expected 6 children");
if (bt.children[0].type != "teleportToChild")
FAIL("wrong child 0 type");
if (bt.children[0].name != "SitTarget")
FAIL("wrong child 0 name");
if (bt.children[1].type != "disablePhysics")
FAIL("wrong child 1 type");
if (bt.children[4].type != "setAnimationState")
FAIL("wrong child 4 type");
if (bt.children[4].name != "SM/Stand")
FAIL("wrong child 4 name");
if (bt.children[5].type != "enablePhysics")
FAIL("wrong child 5 type");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 12: Action with blackboard check nodes
// ---------------------------------------------------------------------------
static int testBlackboardCheckBehaviorTree(lua_State *L)
{
TEST("action with blackboard check behavior tree nodes");
ActionDatabase::getSingleton().clear();
bool ok = runLua(
L,
"ecs.action_db.add_action('travel_to_market', 5,"
" { bits = { is_awake = true }, values = { energy = 20 } },"
" { bits = { at_market = true }, values = { energy = -15 } },"
" {"
" type = 'sequence',"
" children = {"
" { type = 'checkValue', name = 'energy', params = '>= 20' },"
" { type = 'setAnimationState', name = 'SM/Walk' },"
" { type = 'delay', params = '5.0' },"
" { type = 'setAnimationState', name = 'SM/Idle' },"
" { type = 'setBit', name = 'at_market', params = '1' },"
" { type = 'setBit', name = 'at_home', params = '0' }"
" }"
" }"
")");
if (!ok)
FAIL("failed to run Lua");
const GoapAction *action =
ActionDatabase::getSingleton().findAction("travel_to_market");
if (!action)
FAIL("action not found");
const BehaviorTreeNode &bt = action->behaviorTree;
if (bt.children.size() != 6)
FAIL("expected 6 children");
if (bt.children[0].type != "checkValue")
FAIL("wrong child 0 type");
if (bt.children[0].name != "energy")
FAIL("wrong child 0 name");
if (bt.children[0].params != ">= 20")
FAIL("wrong child 0 params");
if (bt.children[4].type != "setBit")
FAIL("wrong child 4 type");
if (bt.children[4].name != "at_market")
FAIL("wrong child 4 name");
if (bt.children[4].params != "1")
FAIL("wrong child 4 params");
if (bt.children[5].type != "setBit")
FAIL("wrong child 5 type");
if (bt.children[5].name != "at_home")
FAIL("wrong child 5 name");
if (bt.children[5].params != "0")
FAIL("wrong child 5 params");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 13: Set and get bit names from Lua
// ---------------------------------------------------------------------------
static int testSetGetBitName(lua_State *L)
{
TEST("set and get bit names from Lua");
// Clear any previous bit names
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Set bit names via Lua
bool ok = runLua(L, "ecs.action_db.set_bit_name(0, 'has_axe');"
"ecs.action_db.set_bit_name(1, 'has_wood');"
"ecs.action_db.set_bit_name(5, 'is_hungry')");
if (!ok)
FAIL("failed to set bit names");
// Verify via C++ API
const char *name0 = GoapBlackboard::getBitName(0);
if (!name0 || strcmp(name0, "has_axe") != 0)
FAIL("bit 0 should be 'has_axe'");
const char *name1 = GoapBlackboard::getBitName(1);
if (!name1 || strcmp(name1, "has_wood") != 0)
FAIL("bit 1 should be 'has_wood'");
const char *name5 = GoapBlackboard::getBitName(5);
if (!name5 || strcmp(name5, "is_hungry") != 0)
FAIL("bit 5 should be 'is_hungry'");
// Verify unset bits are null
if (GoapBlackboard::getBitName(2) != nullptr)
FAIL("bit 2 should be unset");
// Verify via Lua get_bit_name
ok = runLua(
L,
"local n0 = ecs.action_db.get_bit_name(0);"
"assert(n0 == 'has_axe', 'expected has_axe, got ' .. tostring(n0));"
"local n1 = ecs.action_db.get_bit_name(1);"
"assert(n1 == 'has_wood', 'expected has_wood, got ' .. tostring(n1));"
"local n5 = ecs.action_db.get_bit_name(5);"
"assert(n5 == 'is_hungry', 'expected is_hungry, got ' .. tostring(n5));"
"local n2 = ecs.action_db.get_bit_name(2);"
"assert(n2 == nil, 'bit 2 should be nil, got ' .. tostring(n2))");
if (!ok)
FAIL("get_bit_name assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 14: Find bit by name from Lua
// ---------------------------------------------------------------------------
static int testFindBitByName(lua_State *L)
{
TEST("find bit by name from Lua");
// Clear and set known bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
GoapBlackboard::setBitName(3, "near_tree");
GoapBlackboard::setBitName(7, "near_fire");
bool ok = runLua(
L,
"local idx3 = ecs.action_db.find_bit_by_name('near_tree');"
"assert(idx3 == 3, 'expected 3, got ' .. tostring(idx3));"
"local idx7 = ecs.action_db.find_bit_by_name('near_fire');"
"assert(idx7 == 7, 'expected 7, got ' .. tostring(idx7));"
"local nilIdx = ecs.action_db.find_bit_by_name('nonexistent');"
"assert(nilIdx == nil, 'expected nil, got ' .. tostring(nilIdx))");
if (!ok)
FAIL("find_bit_by_name assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 15: Auto-assign bit from Lua
// ---------------------------------------------------------------------------
static int testAutoAssignBit(lua_State *L)
{
TEST("auto-assign bit from Lua");
// Clear all bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Auto-assign a new name
bool ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('my_flag');"
"assert(idx == 0, 'expected first free slot 0, got ' .. tostring(idx));"
"local name = ecs.action_db.get_bit_name(0);"
"assert(name == 'my_flag', 'expected my_flag, got ' .. tostring(name))");
if (!ok)
FAIL("first auto_assign failed");
// Auto-assign another - should get slot 1
ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('other_flag');"
"assert(idx == 1, 'expected slot 1, got ' .. tostring(idx))");
if (!ok)
FAIL("second auto_assign failed");
// Auto-assign an already-existing name - should return existing index
ok = runLua(
L,
"local idx = ecs.action_db.auto_assign_bit('my_flag');"
"assert(idx == 0, 'expected existing slot 0, got ' .. tostring(idx))");
if (!ok)
FAIL("re-assign existing name failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 16: List bit names from Lua
// ---------------------------------------------------------------------------
static int testListBitNames(lua_State *L)
{
TEST("list bit names from Lua");
// Clear and set known bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
GoapBlackboard::setBitName(0, "alpha");
GoapBlackboard::setBitName(5, "beta");
GoapBlackboard::setBitName(10, "gamma");
bool ok = runLua(
L,
"local bits = ecs.action_db.list_bit_names();"
"assert(#bits == 3, 'expected 3 bits, got ' .. #bits);"
"assert(bits[1].index == 0 and bits[1].name == 'alpha', 'wrong bit 0');"
"assert(bits[2].index == 5 and bits[2].name == 'beta', 'wrong bit 5');"
"assert(bits[3].index == 10 and bits[3].name == 'gamma', 'wrong bit 10')");
if (!ok)
FAIL("list_bit_names assertions failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 17: Bit names auto-assigned when used in action preconditions
// ---------------------------------------------------------------------------
static int testAutoAssignInAction(lua_State *L)
{
TEST("bit names auto-assigned in action preconditions");
// Clear all bits
for (int i = 0; i < 64; i++)
GoapBlackboard::setBitName(i, "");
// Create an action with a bit name that hasn't been defined yet
bool ok = runLua(L, "ecs.action_db.add_action('test_auto_bit', 1,"
" { bits = { brand_new_bit = true } },"
" { bits = { another_new_bit = true } })");
if (!ok)
FAIL("failed to create action with auto-assigned bits");
// The bits should have been auto-assigned
int idx1 = GoapBlackboard::findBitByName("brand_new_bit");
if (idx1 < 0)
FAIL("brand_new_bit should have been auto-assigned");
if (idx1 != 0)
FAIL("brand_new_bit should be at slot 0");
int idx2 = GoapBlackboard::findBitByName("another_new_bit");
if (idx2 < 0)
FAIL("another_new_bit should have been auto-assigned");
if (idx2 != 1)
FAIL("another_new_bit should be at slot 1");
// Verify the action's preconditions use the correct bit
const GoapAction *action =
ActionDatabase::getSingleton().findAction("test_auto_bit");
if (!action)
FAIL("action not found");
if (!action->preconditions.getBit(idx1))
FAIL("precondition bit should be set");
if (!action->effects.getBit(idx2))
FAIL("effect bit should be set");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main()
{
printf("ActionDatabase Lua API Tests\n");
printf("============================\n\n");
// Create Lua state
lua_State *L = luaL_newstate();
if (!L) {
fprintf(stderr, "Failed to create Lua state\n");
return 1;
}
luaL_openlibs(L);
// Register the action API
editScene::registerLuaActionApi(L);
// Run tests
int failures = 0;
failures += testBasicAction(L);
failures += testActionWithBehaviorTree(L);
failures += testNestedBehaviorTree(L);
failures += testGoal(L);
failures += testActionReplacement(L);
failures += testRemoveAction(L);
failures += testListActions(L);
failures += testFindActionLua(L);
failures += testClear(L);
failures += testInventoryBehaviorTree(L);
failures += testPhysicsBehaviorTree(L);
failures += testBlackboardCheckBehaviorTree(L);
failures += testSetGetBitName(L);
failures += testFindBitByName(L);
failures += testAutoAssignBit(L);
failures += testListBitNames(L);
failures += testAutoAssignInAction(L);
// Cleanup
lua_close(L);
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
failures);
return failures > 0 ? 1 : 0;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
/**
* @file EditorMarker.hpp
* @brief Stub for standalone tests.
*
* Provides the EditorMarkerComponent tag used by LuaEntityApi.cpp.
* In the real build, this is defined in the editScene components.
*/
#ifndef EDITSCENE_COMPONENTS_EDITORMARKER_HPP
#define EDITSCENE_COMPONENTS_EDITORMARKER_HPP
#include <flecs.h>
namespace editScene
{
/**
* @brief Tag component marking entities as editor-managed.
*
* In the real build, this is a Flecs tag. For tests, we define
* it as an empty struct so LuaEntityApi.cpp can compile.
*/
struct EditorMarkerComponent {};
} // namespace editScene
#endif // EDITSCENE_COMPONENTS_EDITORMARKER_HPP

View File

@@ -0,0 +1,403 @@
/**
* @file entity_lua_test.cpp
* @brief Standalone test for the Lua Entity API.
*
* Tests entity creation, destruction, naming, hierarchy (parent/children),
* and entity lookup functions exposed via the ecs.* Lua API.
*
* Build with:
* g++ -std=c++17 -I. -I../.. -I../../lua/lua-5.4.8/src \
* entity_lua_test.cpp \
* ../lua/LuaEntityApi.cpp \
* ../../lua/lua-5.4.8/src/liblua.a \
* -o entity_lua_test -lm
*
* Or via CMake (see CMakeLists.txt in this directory).
*/
#include <cstdio>
#include <cstring>
#include <cassert>
#include <string>
#include <vector>
// Ogre stub (provides Ogre::String, Ogre::Vector3, Ogre::LogManager)
#include "ogre_stub.h"
// Include Lua
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
// Flecs stub for standalone testing
// We provide minimal Flecs types needed by LuaEntityApi
namespace flecs
{
// Minimal entity wrapper
struct entity {
uint64_t m_id = 0;
bool m_valid = false;
entity()
: m_id(0)
, m_valid(false)
{
}
explicit entity(uint64_t id)
: m_id(id)
, m_valid(true)
{
}
uint64_t id() const
{
return m_id;
}
bool is_valid() const
{
return m_valid;
}
bool is_alive() const
{
return m_valid;
}
const char *name() const
{
return "";
}
void set_name(const char *)
{
}
void destruct()
{
m_valid = false;
}
entity parent() const
{
return entity();
}
template <typename Func> void children(Func) const
{
}
template <typename T> void add()
{
}
template <typename T> bool has() const
{
return false;
}
template <typename T> const T *get() const
{
return nullptr;
}
template <typename T> void set(const T &)
{
}
bool operator==(const entity &other) const
{
return m_id == other.m_id;
}
};
using entity_t = uint64_t;
// Minimal world stub
struct world {
entity make_entity()
{
return entity(nextId++);
}
entity lookup(const char *)
{
return entity();
}
static world &get()
{
static world w;
return w;
}
private:
uint64_t nextId = 1000;
};
} // namespace flecs
// Forward declare the registration function
namespace editScene
{
void registerLuaEntityApi(lua_State *L);
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", msg); \
return 1; \
} while (0)
// ---------------------------------------------------------------------------
// Helper: run a Lua string and check for errors
// ---------------------------------------------------------------------------
static bool runLua(lua_State *L, const char *code)
{
if (luaL_dostring(L, code) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Test 1: Create entity
// ---------------------------------------------------------------------------
static int testCreateEntity(lua_State *L)
{
TEST("create entity returns integer ID");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"assert(type(id) == 'number', 'expected number, got ' .. type(id));"
"assert(id > 0, 'expected positive ID, got ' .. tostring(id))");
if (!ok)
FAIL("create entity assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 2: Entity exists
// ---------------------------------------------------------------------------
static int testEntityExists(lua_State *L)
{
TEST("entity_exists returns correct values");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"assert(ecs.entity_exists(id) == true, 'entity should exist');"
"assert(ecs.entity_exists(999999) == false, 'fake entity should not exist')");
if (!ok)
FAIL("entity exists assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 3: Destroy entity
// ---------------------------------------------------------------------------
static int testDestroyEntity(lua_State *L)
{
TEST("destroy entity");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"assert(ecs.entity_exists(id) == true, 'entity should exist before destroy');"
"ecs.destroy_entity(id);"
"assert(ecs.entity_exists(id) == false, 'entity should not exist after destroy')");
if (!ok)
FAIL("destroy entity assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 4: Set and get entity name
// ---------------------------------------------------------------------------
static int testEntityName(lua_State *L)
{
TEST("set and get entity name");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"ecs.set_entity_name(id, 'test_hero');"
"local name = ecs.get_entity_name(id);"
"assert(name == 'test_hero', 'expected test_hero, got ' .. tostring(name))");
if (!ok)
FAIL("entity name assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 5: Get entity by name
// ---------------------------------------------------------------------------
static int testGetEntityByName(lua_State *L)
{
TEST("get entity by name");
bool ok = runLua(
L,
"local id = ecs.create_entity();"
"ecs.set_entity_name(id, 'findable_entity');"
"local found = ecs.get_entity_by_name('findable_entity');"
"assert(found == id, 'expected same ID, got ' .. tostring(found));"
"local not_found = ecs.get_entity_by_name('nonexistent');"
"assert(not_found == nil, 'expected nil for nonexistent')");
if (!ok)
FAIL("get entity by name assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 6: Parent and children hierarchy
// ---------------------------------------------------------------------------
static int testHierarchy(lua_State *L)
{
TEST("parent and children hierarchy");
bool ok = runLua(
L,
"local parent = ecs.create_entity();"
"local child = ecs.create_entity();"
"ecs.set_entity_name(parent, 'parent_entity');"
"ecs.set_entity_name(child, 'child_entity');"
"local p = ecs.parent(child);"
"assert(p == nil, 'child should have no parent initially');"
"local kids = ecs.children(parent);"
"assert(type(kids) == 'table', 'children should return a table');"
"assert(#kids == 0, 'parent should have no children initially')");
if (!ok)
FAIL("hierarchy assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 7: Multiple entity creation
// ---------------------------------------------------------------------------
static int testMultipleEntities(lua_State *L)
{
TEST("create multiple entities with unique IDs");
bool ok = runLua(
L,
"local ids = {};"
"for i = 1, 5 do"
" ids[i] = ecs.create_entity();"
"end;"
"for i = 1, 5 do"
" for j = i+1, 5 do"
" assert(ids[i] ~= ids[j], 'IDs should be unique');"
" end;"
"end;"
"for i = 1, 5 do"
" assert(ecs.entity_exists(ids[i]) == true, 'entity should exist');"
"end");
if (!ok)
FAIL("multiple entities assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 8: Destroy and recreate (ID reuse)
// ---------------------------------------------------------------------------
static int testDestroyRecreate(lua_State *L)
{
TEST("destroy and recreate entity");
bool ok = runLua(
L,
"local id1 = ecs.create_entity();"
"ecs.destroy_entity(id1);"
"assert(ecs.entity_exists(id1) == false, 'destroyed entity should not exist');"
"local id2 = ecs.create_entity();"
"assert(ecs.entity_exists(id2) == true, 'new entity should exist');"
"assert(id2 ~= id1, 'new entity should have different ID')");
if (!ok)
FAIL("destroy recreate assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main()
{
printf("Entity Lua API Tests\n");
printf("====================\n\n");
// Create Lua state
lua_State *L = luaL_newstate();
if (!L) {
fprintf(stderr, "Failed to create Lua state\n");
return 1;
}
luaL_openlibs(L);
// Register the entity API
editScene::registerLuaEntityApi(L);
// Run tests
int failures = 0;
failures += testCreateEntity(L);
failures += testEntityExists(L);
failures += testDestroyEntity(L);
failures += testEntityName(L);
failures += testGetEntityByName(L);
failures += testHierarchy(L);
failures += testMultipleEntities(L);
failures += testDestroyRecreate(L);
// Cleanup
lua_close(L);
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
failures);
return failures > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,375 @@
/**
* @file event_lua_test.cpp
* @brief Standalone test for the Lua Event API.
*
* Tests event subscription, unsubscription, and sending functions
* exposed via the ecs.* Lua API.
*
* Build with:
* g++ -std=c++17 -I. -I../.. -I../../lua/lua-5.4.8/src \
* event_lua_test.cpp \
* ../lua/LuaEventApi.cpp \
* ../lua/LuaEntityApi.cpp \
* ../systems/EventBus.cpp \
* ../components/GoapBlackboard.cpp \
* ../../lua/lua-5.4.8/src/liblua.a \
* -o event_lua_test -lm
*
* Or via CMake (see CMakeLists.txt in this directory).
*/
#include <cstdio>
#include <cstring>
#include <cassert>
#include <string>
#include <vector>
// Ogre stub (provides Ogre::String, Ogre::Vector3, Ogre::LogManager)
#include "ogre_stub.h"
// Include Lua
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
// Include the components we need
#include "../systems/EventBus.hpp"
#include "../components/GoapBlackboard.hpp"
// Forward declare the registration function
namespace editScene
{
void registerLuaEventApi(lua_State *L);
void registerLuaEntityApi(lua_State *L);
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
static int testCount = 0;
static int passCount = 0;
#define TEST(name) \
do { \
testCount++; \
printf(" TEST %d: %s ... ", testCount, name); \
} while (0)
#define PASS() \
do { \
passCount++; \
printf("PASS\n"); \
} while (0)
#define FAIL(msg) \
do { \
printf("FAIL: %s\n", msg); \
return 1; \
} while (0)
// ---------------------------------------------------------------------------
// Helper: run a Lua string and check for errors
// ---------------------------------------------------------------------------
static bool runLua(lua_State *L, const char *code)
{
if (luaL_dostring(L, code) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Test 1: Send a simple event
// ---------------------------------------------------------------------------
static int testSendSimpleEvent(lua_State *L)
{
TEST("send a simple event");
bool ok = runLua(L, "ecs.send_event('test_event')");
if (!ok)
FAIL("failed to send simple event");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 2: Subscribe and receive an event
// ---------------------------------------------------------------------------
static int testSubscribeAndReceive(lua_State *L)
{
TEST("subscribe and receive an event");
// Track whether the callback was called
bool ok = runLua(
L,
"local received = false;"
"local sub_id = ecs.subscribe_event('hello', function(event, params)"
" received = true;"
"end);"
"assert(sub_id ~= nil, 'subscription ID should not be nil');"
"assert(type(sub_id) == 'number', 'subscription ID should be a number');"
"ecs.send_event('hello');"
"assert(received == true, 'callback should have been called')");
if (!ok)
FAIL("subscribe and receive assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 3: Subscribe with event name and params
// ---------------------------------------------------------------------------
static int testSubscribeWithParams(lua_State *L)
{
TEST("subscribe with event name and params");
bool ok = runLua(
L,
"local received_event = nil;"
"local sub_id = ecs.subscribe_event('collision', function(event, params)"
" received_event = event;"
"end);"
"ecs.send_event('collision', { values = { damage = 10 } });"
"assert(received_event == 'collision', 'expected collision event')");
if (!ok)
FAIL("subscribe with params assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 4: Unsubscribe from an event
// ---------------------------------------------------------------------------
static int testUnsubscribe(lua_State *L)
{
TEST("unsubscribe from an event");
bool ok = runLua(
L,
"local call_count = 0;"
"local sub_id = ecs.subscribe_event('temp', function(event, params)"
" call_count = call_count + 1;"
"end);"
"ecs.send_event('temp');"
"assert(call_count == 1, 'should have been called once');"
"ecs.unsubscribe_event(sub_id);"
"ecs.send_event('temp');"
"assert(call_count == 1, 'should not have been called after unsubscribe')");
if (!ok)
FAIL("unsubscribe assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 5: Multiple subscribers to same event
// ---------------------------------------------------------------------------
static int testMultipleSubscribers(lua_State *L)
{
TEST("multiple subscribers to same event");
bool ok = runLua(
L,
"local count1 = 0; local count2 = 0;"
"local s1 = ecs.subscribe_event('multi', function() count1 = count1 + 1; end);"
"local s2 = ecs.subscribe_event('multi', function() count2 = count2 + 1; end);"
"ecs.send_event('multi');"
"assert(count1 == 1, 'subscriber 1 should have been called');"
"assert(count2 == 1, 'subscriber 2 should have been called');"
"ecs.send_event('multi');"
"assert(count1 == 2, 'subscriber 1 should have been called twice');"
"assert(count2 == 2, 'subscriber 2 should have been called twice')");
if (!ok)
FAIL("multiple subscribers assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 6: Send event with blackboard params
// ---------------------------------------------------------------------------
static int testEventWithBlackboardParams(lua_State *L)
{
TEST("send event with blackboard params");
bool ok = runLua(
L,
"local result = nil;"
"ecs.subscribe_event('data_event', function(event, params)"
" result = params;"
"end);"
"ecs.send_event('data_event', {"
" values = { score = 42, level = 5 },"
" floatValues = { speed = 1.5 },"
" stringValues = { name = 'hero' }"
"});"
"assert(result ~= nil, 'params should not be nil');"
"assert(result.values.score == 42, 'expected score 42');"
"assert(result.values.level == 5, 'expected level 5');"
"assert(result.floatValues.speed == 1.5, 'expected speed 1.5');"
"assert(result.stringValues.name == 'hero', 'expected name hero')");
if (!ok)
FAIL("event with blackboard params assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 7: Send event with vec3 params
// ---------------------------------------------------------------------------
static int testEventWithVec3Params(lua_State *L)
{
TEST("send event with vec3 params");
bool ok = runLua(
L,
"local result = nil;"
"ecs.subscribe_event('move', function(event, params)"
" result = params;"
"end);"
"ecs.send_event('move', {"
" vec3Values = { position = { 10, 20, 30 }, velocity = { 1, 0, 0 } }"
"});"
"assert(result ~= nil, 'params should not be nil');"
"assert(result.vec3Values.position[1] == 10, 'expected pos.x=10');"
"assert(result.vec3Values.position[2] == 20, 'expected pos.y=20');"
"assert(result.vec3Values.position[3] == 30, 'expected pos.z=30');"
"assert(result.vec3Values.velocity[1] == 1, 'expected vel.x=1')");
if (!ok)
FAIL("event with vec3 params assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 8: Send event with bits and mask
// ---------------------------------------------------------------------------
static int testEventWithBits(lua_State *L)
{
TEST("send event with bits and mask");
bool ok = runLua(
L, "local result = nil;"
"ecs.subscribe_event('flag_event', function(event, params)"
" result = params;"
"end);"
"ecs.send_event('flag_event', {"
" bits = 5,"
" mask = 7"
"});"
"assert(result ~= nil, 'params should not be nil');"
"assert(result.bits == 5, 'expected bits=5');"
"assert(result.mask == 7, 'expected mask=7')");
if (!ok)
FAIL("event with bits assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 9: Multiple events with different names
// ---------------------------------------------------------------------------
static int testMultipleEvents(lua_State *L)
{
TEST("multiple events with different names");
bool ok = runLua(
L,
"local events = {};"
"ecs.subscribe_event('event_a', function() table.insert(events, 'a'); end);"
"ecs.subscribe_event('event_b', function() table.insert(events, 'b'); end);"
"ecs.send_event('event_a');"
"ecs.send_event('event_b');"
"ecs.send_event('event_a');"
"assert(#events == 3, 'expected 3 events, got ' .. #events);"
"assert(events[1] == 'a', 'expected a');"
"assert(events[2] == 'b', 'expected b');"
"assert(events[3] == 'a', 'expected a')");
if (!ok)
FAIL("multiple events assertion failed");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Test 10: Unsubscribe invalid ID (should not crash)
// ---------------------------------------------------------------------------
static int testUnsubscribeInvalid(lua_State *L)
{
TEST("unsubscribe invalid ID (should not crash)");
bool ok = runLua(L, "ecs.unsubscribe_event(99999);");
if (!ok)
FAIL("unsubscribe invalid ID should not error");
PASS();
return 0;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
int main()
{
printf("Event Lua API Tests\n");
printf("===================\n\n");
// Create Lua state
lua_State *L = luaL_newstate();
if (!L) {
fprintf(stderr, "Failed to create Lua state\n");
return 1;
}
luaL_openlibs(L);
// Register the entity and event APIs
editScene::registerLuaEntityApi(L);
editScene::registerLuaEventApi(L);
// Run tests
int failures = 0;
failures += testSendSimpleEvent(L);
failures += testSubscribeAndReceive(L);
failures += testSubscribeWithParams(L);
failures += testUnsubscribe(L);
failures += testMultipleSubscribers(L);
failures += testEventWithBlackboardParams(L);
failures += testEventWithVec3Params(L);
failures += testEventWithBits(L);
failures += testMultipleEvents(L);
failures += testUnsubscribeInvalid(L);
// Cleanup
lua_close(L);
printf("\nResults: %d/%d passed, %d failed\n", passCount, testCount,
failures);
return failures > 0 ? 1 : 0;
}

View File

@@ -0,0 +1,576 @@
/**
* @file lua_test_stubs.cpp
* @brief Stub implementations of Lua API registration functions for standalone tests.
*
* These stubs provide minimal implementations that work with the
* flecs stubs and ogre_stub.h defined in the test files.
* They are used instead of the real Lua API .cpp files which
* require the full OGRE SDK.
*
* The stubs maintain state (entity IDs, names, components) so that
* the tests can verify correct behavior.
*/
#include "ogre_stub.h"
#include <lua.hpp>
#include <cstdio>
#include <cassert>
#include <string>
#include <unordered_map>
#include <vector>
#include <map>
// ---------------------------------------------------------------------------
// Shared component storage (used by both entity and component API stubs)
// ---------------------------------------------------------------------------
namespace editScene
{
// A value can be a number, string, boolean, array of numbers, array of strings,
// or a nested table (map of string->value)
struct ComponentFieldValue {
enum Type { NIL, NUMBER, STRING, BOOLEAN, NUM_ARRAY, STR_ARRAY, TABLE };
Type type = NIL;
double numVal = 0;
std::string strVal;
bool boolVal = false;
std::vector<double> numArr;
std::vector<std::string> strArr;
std::map<std::string, ComponentFieldValue> tableVal;
};
using ComponentData = std::unordered_map<std::string, ComponentFieldValue>;
std::unordered_map<int, std::unordered_map<std::string, ComponentData> >
s_components;
} // namespace editScene
// ---------------------------------------------------------------------------
// Stub: LuaEntityApi
// ---------------------------------------------------------------------------
namespace editScene
{
// Entity state for stubs
struct EntityState {
bool alive;
std::string name;
};
static std::unordered_map<int, EntityState> s_entities;
static int s_nextId = 1000;
static int createEntity()
{
int id = s_nextId++;
s_entities[id] = { true, "" };
// Auto-add EditorMarker component (matching real behavior)
s_components[id]["EditorMarker"];
return id;
}
static void destroyEntity(int id)
{
auto it = s_entities.find(id);
if (it != s_entities.end())
it->second.alive = false;
}
static bool entityExists(int id)
{
auto it = s_entities.find(id);
return it != s_entities.end() && it->second.alive;
}
static void setEntityName(int id, const std::string &name)
{
auto it = s_entities.find(id);
if (it != s_entities.end())
it->second.name = name;
}
static std::string getEntityName(int id)
{
auto it = s_entities.find(id);
if (it != s_entities.end())
return it->second.name;
return "";
}
static int findEntityByName(const std::string &name)
{
for (auto &[id, state] : s_entities) {
if (state.alive && state.name == name)
return id;
}
return -1;
}
void registerLuaEntityApi(lua_State *L)
{
// Create the "ecs" global table
lua_newtable(L);
// create_entity
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = createEntity();
lua_pushinteger(L, id);
return 1;
});
lua_setfield(L, -2, "create_entity");
// destroy_entity
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = lua_tointeger(L, 1);
destroyEntity(id);
return 0;
});
lua_setfield(L, -2, "destroy_entity");
// entity_exists
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = lua_tointeger(L, 1);
lua_pushboolean(L, entityExists(id) ? 1 : 0);
return 1;
});
lua_setfield(L, -2, "entity_exists");
// get_player_entity
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = findEntityByName("player");
if (id >= 0) {
lua_pushinteger(L, id);
} else {
lua_pushnil(L);
}
return 1;
});
lua_setfield(L, -2, "get_player_entity");
// get_entity_by_name
lua_pushcfunction(L, [](lua_State *L) -> int {
const char *name = lua_tostring(L, 1);
int id = findEntityByName(name ? name : "");
if (id >= 0) {
lua_pushinteger(L, id);
} else {
lua_pushnil(L);
}
return 1;
});
lua_setfield(L, -2, "get_entity_by_name");
// set_entity_name
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = lua_tointeger(L, 1);
const char *name = lua_tostring(L, 2);
setEntityName(id, name ? name : "");
return 0;
});
lua_setfield(L, -2, "set_entity_name");
// get_entity_name
lua_pushcfunction(L, [](lua_State *L) -> int {
int id = lua_tointeger(L, 1);
std::string name = getEntityName(id);
if (!name.empty()) {
lua_pushstring(L, name.c_str());
} else {
lua_pushnil(L);
}
return 1;
});
lua_setfield(L, -2, "get_entity_name");
// parent
lua_pushcfunction(L, [](lua_State *L) -> int {
lua_pushnil(L);
return 1;
});
lua_setfield(L, -2, "parent");
// children
lua_pushcfunction(L, [](lua_State *L) -> int {
lua_newtable(L);
return 1;
});
lua_setfield(L, -2, "children");
lua_setglobal(L, "ecs");
}
} // namespace editScene
// ---------------------------------------------------------------------------
// Stub: LuaComponentApi
// ---------------------------------------------------------------------------
namespace editScene
{
static ComponentData &getOrCreateComponent(int entityId,
const std::string &compName)
{
return s_components[entityId][compName];
}
static bool hasComponent(int entityId, const std::string &compName)
{
auto eit = s_components.find(entityId);
if (eit == s_components.end())
return false;
return eit->second.find(compName) != eit->second.end();
}
static void removeComponent(int entityId, const std::string &compName)
{
auto eit = s_components.find(entityId);
if (eit != s_components.end())
eit->second.erase(compName);
}
// Forward declaration
static void pushFieldValueToLua(lua_State *L, const ComponentFieldValue &fv);
static void pushFieldValueToLua(lua_State *L, const ComponentFieldValue &fv)
{
switch (fv.type) {
case ComponentFieldValue::NIL:
lua_pushnil(L);
break;
case ComponentFieldValue::NUMBER:
lua_pushnumber(L, fv.numVal);
break;
case ComponentFieldValue::STRING:
lua_pushstring(L, fv.strVal.c_str());
break;
case ComponentFieldValue::BOOLEAN:
lua_pushboolean(L, fv.boolVal ? 1 : 0);
break;
case ComponentFieldValue::NUM_ARRAY: {
lua_newtable(L);
for (size_t i = 0; i < fv.numArr.size(); i++) {
lua_pushnumber(L, fv.numArr[i]);
lua_rawseti(L, -2, i + 1);
}
break;
}
case ComponentFieldValue::STR_ARRAY: {
lua_newtable(L);
for (size_t i = 0; i < fv.strArr.size(); i++) {
lua_pushstring(L, fv.strArr[i].c_str());
lua_rawseti(L, -2, i + 1);
}
break;
}
case ComponentFieldValue::TABLE: {
lua_newtable(L);
for (auto &[k, v] : fv.tableVal) {
pushFieldValueToLua(L, v);
lua_setfield(L, -2, k.c_str());
}
break;
}
}
}
// Forward declaration
static ComponentFieldValue readLuaValue(lua_State *L, int idx);
static ComponentFieldValue readLuaValue(lua_State *L, int idx)
{
ComponentFieldValue fv;
// Convert to absolute index to be safe with stack changes
int absIdx = lua_absindex(L, idx);
int type = lua_type(L, absIdx);
if (type == LUA_TNIL) {
fv.type = ComponentFieldValue::NIL;
} else if (type == LUA_TNUMBER) {
fv.type = ComponentFieldValue::NUMBER;
fv.numVal = lua_tonumber(L, absIdx);
} else if (type == LUA_TSTRING) {
fv.type = ComponentFieldValue::STRING;
fv.strVal = lua_tostring(L, absIdx);
} else if (type == LUA_TBOOLEAN) {
fv.type = ComponentFieldValue::BOOLEAN;
fv.boolVal = lua_toboolean(L, absIdx) != 0;
} else if (type == LUA_TTABLE) {
// Check if it's an array (all integer keys) or a map (string keys)
bool isArray = true;
bool isStringArray = false;
bool isNumArray = false;
int maxKey = 0;
lua_pushnil(L);
while (lua_next(L, absIdx) != 0) {
if (lua_type(L, -2) == LUA_TNUMBER) {
int k = lua_tointeger(L, -2);
if (k > maxKey)
maxKey = k;
if (lua_type(L, -1) == LUA_TSTRING)
isStringArray = true;
else if (lua_type(L, -1) == LUA_TNUMBER)
isNumArray = true;
} else {
isArray = false;
}
lua_pop(L, 1);
}
if (isArray && maxKey > 0) {
if (isStringArray) {
fv.type = ComponentFieldValue::STR_ARRAY;
for (int i = 1; i <= maxKey; i++) {
lua_rawgeti(L, absIdx, i);
if (lua_type(L, -1) == LUA_TSTRING)
fv.strArr.push_back(
lua_tostring(L, -1));
lua_pop(L, 1);
}
} else if (isNumArray) {
fv.type = ComponentFieldValue::NUM_ARRAY;
for (int i = 1; i <= maxKey; i++) {
lua_rawgeti(L, absIdx, i);
if (lua_type(L, -1) == LUA_TNUMBER)
fv.numArr.push_back(
lua_tonumber(L, -1));
lua_pop(L, 1);
}
}
} else {
fv.type = ComponentFieldValue::TABLE;
lua_pushnil(L);
while (lua_next(L, absIdx) != 0) {
if (lua_type(L, -2) == LUA_TSTRING) {
const char *key = lua_tostring(L, -2);
if (key)
fv.tableVal[key] =
readLuaValue(L, -1);
}
lua_pop(L, 1);
}
}
}
return fv;
}
void registerLuaComponentApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// has_component
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
lua_pushboolean(L, hasComponent(entityId,
compName ? compName : "") ?
1 :
0);
return 1;
});
lua_setfield(L, -2, "has_component");
// add_component
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
if (compName)
getOrCreateComponent(entityId, compName);
return 0;
});
lua_setfield(L, -2, "add_component");
// remove_component
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
if (compName)
removeComponent(entityId, compName);
return 0;
});
lua_setfield(L, -2, "remove_component");
// get_component
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
if (!compName || !hasComponent(entityId, compName)) {
lua_pushnil(L);
return 1;
}
ComponentData &cd = s_components[entityId][compName];
lua_newtable(L);
for (auto &[k, v] : cd) {
pushFieldValueToLua(L, v);
lua_setfield(L, -2, k.c_str());
}
return 1;
});
lua_setfield(L, -2, "get_component");
// set_component
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
if (!compName)
return 0;
ComponentData &cd = getOrCreateComponent(entityId, compName);
if (lua_istable(L, 3)) {
cd.clear();
lua_pushnil(L);
while (lua_next(L, 3) != 0) {
if (lua_type(L, -2) == LUA_TSTRING) {
const char *key = lua_tostring(L, -2);
if (key)
cd[key] = readLuaValue(L, -1);
}
lua_pop(L, 1);
}
}
return 0;
});
lua_setfield(L, -2, "set_component");
// get_field
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
const char *fieldName = lua_tostring(L, 3);
if (!compName || !fieldName ||
!hasComponent(entityId, compName)) {
lua_pushnil(L);
return 1;
}
ComponentData &cd = s_components[entityId][compName];
auto it = cd.find(fieldName);
if (it != cd.end()) {
pushFieldValueToLua(L, it->second);
} else {
lua_pushnil(L);
}
return 1;
});
lua_setfield(L, -2, "get_field");
// set_field
lua_pushcfunction(L, [](lua_State *L) -> int {
int entityId = lua_tointeger(L, 1);
const char *compName = lua_tostring(L, 2);
const char *fieldName = lua_tostring(L, 3);
if (!compName || !fieldName)
return 0;
ComponentData &cd = getOrCreateComponent(entityId, compName);
cd[fieldName] = readLuaValue(L, 4);
return 0;
});
lua_setfield(L, -2, "set_field");
lua_setglobal(L, "ecs");
}
} // namespace editScene
// ---------------------------------------------------------------------------
// Stub: LuaEventApi
// ---------------------------------------------------------------------------
namespace editScene
{
// Event subscription storage
struct EventSubscription {
int id;
std::string eventName;
int callbackRef; // Lua registry reference
};
static std::vector<EventSubscription> s_subscriptions;
static int s_nextSubId = 1;
void registerLuaEventApi(lua_State *L)
{
// Get or create the "ecs" global table
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
// subscribe_event
lua_pushcfunction(L, [](lua_State *L) -> int {
const char *eventName = lua_tostring(L, 1);
if (!eventName || !lua_isfunction(L, 2)) {
lua_pushnil(L);
return 1;
}
// Store callback in Lua registry
lua_pushvalue(L, 2);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
int subId = s_nextSubId++;
s_subscriptions.push_back({ subId, eventName, ref });
lua_pushinteger(L, subId);
return 1;
});
lua_setfield(L, -2, "subscribe_event");
// unsubscribe_event
lua_pushcfunction(L, [](lua_State *L) -> int {
int subId = lua_tointeger(L, 1);
for (auto it = s_subscriptions.begin();
it != s_subscriptions.end(); ++it) {
if (it->id == subId) {
luaL_unref(L, LUA_REGISTRYINDEX,
it->callbackRef);
s_subscriptions.erase(it);
break;
}
}
return 0;
});
lua_setfield(L, -2, "unsubscribe_event");
// send_event
lua_pushcfunction(L, [](lua_State *L) -> int {
const char *eventName = lua_tostring(L, 1);
if (!eventName)
return 0;
// Call all matching subscriptions
for (auto &sub : s_subscriptions) {
if (sub.eventName == eventName) {
// Push callback
lua_rawgeti(L, LUA_REGISTRYINDEX,
sub.callbackRef);
// Push event name
lua_pushstring(L, eventName);
// Push params (table or nil)
if (lua_istable(L, 2)) {
lua_pushvalue(L, 2);
} else {
lua_pushnil(L);
}
// Call callback(event, params)
if (lua_pcall(L, 2, 0, 0) != LUA_OK) {
fprintf(stderr,
"Event callback error: %s\n",
lua_tostring(L, -1));
lua_pop(L, 1);
}
}
}
return 0;
});
lua_setfield(L, -2, "send_event");
lua_setglobal(L, "ecs");
}
} // namespace editScene

View File

@@ -0,0 +1,88 @@
/**
* @file ogre_stub.h
* @brief Minimal Ogre stub for standalone tests.
*
* Provides just enough Ogre type aliases to compile the
* ActionDatabase, GoapBlackboard, GoapGoal, GoapAction,
* and BehaviorTree components without the full Ogre SDK.
*
* Only for use in standalone tests (action_db_lua_test).
*/
#ifndef OGRE_STUB_H
#define OGRE_STUB_H
#include <string>
#include <vector>
#include <cstdint>
namespace Ogre
{
// String is just std::string
using String = std::string;
// Minimal Vector3 for GoapBlackboard
struct Vector3 {
float x, y, z;
Vector3()
: x(0)
, y(0)
, z(0)
{
}
Vector3(float x_, float y_, float z_)
: x(x_)
, y(y_)
, z(z_)
{
}
static const Vector3 ZERO;
// Member operator== needed by std::pair::operator== when comparing
// unordered_map<string, Vector3> in GoapBlackboard::operator==
bool operator==(const Vector3 &other) const
{
return x == other.x && y == other.y && z == other.z;
}
};
inline const Vector3 Vector3::ZERO(0, 0, 0);
// Minimal LogManager stub (used by LuaActionApi)
class LogManager {
public:
static LogManager &getSingleton()
{
static LogManager instance;
return instance;
}
class Stream {
public:
template <typename T> Stream &operator<<(const T &)
{
return *this;
}
};
Stream stream()
{
return Stream();
}
};
// OgreAssert macro (used by LuaEntityApi.hpp)
#ifndef OgreAssert
#define OgreAssert(expr, msg) \
do { \
if (!(expr)) { \
fprintf(stderr, "OgreAssert failed: %s\n", msg); \
assert(expr); \
} \
} while (0)
#endif
} // namespace Ogre
#endif // OGRE_STUB_H

View File

@@ -4,7 +4,7 @@
#include <imgui.h>
bool ActionDatabaseEditor::renderComponent(flecs::entity entity,
ActionDatabase &db)
ActionDatabaseComponent &db)
{
bool modified = false;
(void)entity;
@@ -14,15 +14,18 @@ bool ActionDatabaseEditor::renderComponent(flecs::entity entity,
ImGuiTreeNodeFlags_DefaultOpen))
modified |= renderBitNames();
if (ImGui::CollapsingHeader("Actions",
ImGuiTreeNodeFlags_DefaultOpen))
if (ImGui::CollapsingHeader("Actions", ImGuiTreeNodeFlags_DefaultOpen))
renderActions(db);
if (ImGui::CollapsingHeader("Goals",
ImGuiTreeNodeFlags_DefaultOpen))
if (ImGui::CollapsingHeader("Goals", ImGuiTreeNodeFlags_DefaultOpen))
renderGoals(db);
ImGui::PopID();
// Sync to singleton if modified
if (modified)
db.syncToSingleton();
return modified;
}
@@ -53,7 +56,7 @@ bool ActionDatabaseEditor::renderBitNames()
return changed;
}
void ActionDatabaseEditor::renderActions(ActionDatabase &db)
void ActionDatabaseEditor::renderActions(ActionDatabaseComponent &db)
{
ImGui::Indent();
@@ -71,6 +74,7 @@ void ActionDatabaseEditor::renderActions(ActionDatabase &db)
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.actions.erase(db.actions.begin() + i);
db.syncToSingleton();
if (m_selectedAction == (int)i)
m_selectedAction = -1;
else if (m_selectedAction > (int)i)
@@ -86,18 +90,20 @@ void ActionDatabaseEditor::renderActions(ActionDatabase &db)
snprintf(newName, sizeof(newName), "Action_%zu",
db.actions.size());
db.actions.emplace_back(newName);
db.syncToSingleton();
m_selectedAction = (int)db.actions.size() - 1;
}
if (m_selectedAction >= 0 &&
m_selectedAction < (int)db.actions.size()) {
renderActionEditor(db.actions[m_selectedAction]);
db.syncToSingleton();
}
ImGui::Unindent();
}
void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
void ActionDatabaseEditor::renderGoals(ActionDatabaseComponent &db)
{
ImGui::Indent();
@@ -115,6 +121,7 @@ void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.goals.erase(db.goals.begin() + i);
db.syncToSingleton();
if (m_selectedGoal == (int)i)
m_selectedGoal = -1;
else if (m_selectedGoal > (int)i)
@@ -127,15 +134,15 @@ void ActionDatabaseEditor::renderGoals(ActionDatabase &db)
if (ImGui::Button("Add Goal")) {
char newName[64];
snprintf(newName, sizeof(newName), "Goal_%zu",
db.goals.size());
snprintf(newName, sizeof(newName), "Goal_%zu", db.goals.size());
db.goals.emplace_back(newName);
db.syncToSingleton();
m_selectedGoal = (int)db.goals.size() - 1;
}
if (m_selectedGoal >= 0 &&
m_selectedGoal < (int)db.goals.size()) {
if (m_selectedGoal >= 0 && m_selectedGoal < (int)db.goals.size()) {
renderGoalEditor(db.goals[m_selectedGoal]);
db.syncToSingleton();
}
ImGui::Unindent();
@@ -168,11 +175,42 @@ void ActionDatabaseEditor::renderActionEditor(GoapAction &action)
}
if (ImGui::TreeNode("Preconditions")) {
GoapBlackboardEditor::render(action.preconditions,
"precond");
GoapBlackboardEditor::render(action.preconditions, "precond");
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

@@ -6,21 +6,25 @@
#include "../components/ActionDatabase.hpp"
/**
* Editor for ActionDatabase component.
* Editor for ActionDatabaseComponent.
*
* Allows editing the global list of GOAP actions and goals.
* Edits the component data and syncs to the ActionDatabase singleton.
*/
class ActionDatabaseEditor : public ComponentEditor<ActionDatabase> {
class ActionDatabaseEditor : public ComponentEditor<ActionDatabaseComponent> {
public:
const char *getName() const override { return "Action Database"; }
const char *getName() const override
{
return "Action Database";
}
protected:
bool renderComponent(flecs::entity entity,
ActionDatabase &db) override;
ActionDatabaseComponent &db) override;
private:
void renderActions(ActionDatabase &db);
void renderGoals(ActionDatabase &db);
void renderActions(ActionDatabaseComponent &db);
void renderGoals(ActionDatabaseComponent &db);
void renderActionEditor(GoapAction &action);
void renderGoalEditor(GoapGoal &goal);
bool renderBitNames();

View File

@@ -0,0 +1,236 @@
#include "ActionDatabaseSingletonEditor.hpp"
#include "GoapBlackboardEditor.hpp"
#include "InlineBehaviorTreeEditor.hpp"
#include <imgui.h>
void ActionDatabaseSingletonEditor::render(bool *open)
{
if (!ImGui::Begin("Action Database (Singleton)", open)) {
ImGui::End();
return;
}
if (ImGui::CollapsingHeader("Bit Names",
ImGuiTreeNodeFlags_DefaultOpen))
renderBitNames();
if (ImGui::CollapsingHeader("Actions", ImGuiTreeNodeFlags_DefaultOpen))
renderActions();
if (ImGui::CollapsingHeader("Goals", ImGuiTreeNodeFlags_DefaultOpen))
renderGoals();
ImGui::End();
}
void ActionDatabaseSingletonEditor::renderBitNames()
{
bool changed = false;
ImGui::Indent();
ImGui::Text("Name bits for use in preconditions/effects:");
ImGui::TextDisabled("(empty slots show as numbers in blackboards)");
for (int i = 0; i < 64; i++) {
const char *name = GoapBlackboard::getBitName(i);
char buf[64];
snprintf(buf, sizeof(buf), "%s", name ? name : "");
ImGui::PushID(i);
char label[16];
snprintf(label, sizeof(label), "%2d", i);
ImGui::SetNextItemWidth(-FLT_MIN);
if (ImGui::InputText(label, buf, sizeof(buf))) {
GoapBlackboard::setBitName(i, buf);
changed = true;
}
ImGui::PopID();
}
ImGui::Unindent();
(void)changed;
}
void ActionDatabaseSingletonEditor::renderActions()
{
ActionDatabase &db = ActionDatabase::getSingleton();
ImGui::Indent();
for (size_t i = 0; i < db.actions.size(); i++) {
ImGui::PushID((int)i);
bool isSelected = (m_selectedAction == (int)i);
char label[256];
snprintf(label, sizeof(label), "%s (cost: %d)",
db.actions[i].name.c_str(), db.actions[i].cost);
if (ImGui::Selectable(label, isSelected))
m_selectedAction = isSelected ? -1 : (int)i;
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.actions.erase(db.actions.begin() + i);
if (m_selectedAction == (int)i)
m_selectedAction = -1;
else if (m_selectedAction > (int)i)
m_selectedAction--;
ImGui::PopID();
break;
}
ImGui::PopID();
}
if (ImGui::Button("Add Action")) {
char newName[64];
snprintf(newName, sizeof(newName), "Action_%zu",
db.actions.size());
db.actions.emplace_back(newName);
m_selectedAction = (int)db.actions.size() - 1;
}
if (m_selectedAction >= 0 &&
m_selectedAction < (int)db.actions.size()) {
renderActionEditor(db.actions[m_selectedAction]);
}
ImGui::Unindent();
}
void ActionDatabaseSingletonEditor::renderGoals()
{
ActionDatabase &db = ActionDatabase::getSingleton();
ImGui::Indent();
for (size_t i = 0; i < db.goals.size(); i++) {
ImGui::PushID((int)i + 10000);
bool isSelected = (m_selectedGoal == (int)i);
char label[256];
snprintf(label, sizeof(label), "%s (priority: %d)",
db.goals[i].name.c_str(), db.goals[i].priority);
if (ImGui::Selectable(label, isSelected))
m_selectedGoal = isSelected ? -1 : (int)i;
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
db.goals.erase(db.goals.begin() + i);
if (m_selectedGoal == (int)i)
m_selectedGoal = -1;
else if (m_selectedGoal > (int)i)
m_selectedGoal--;
ImGui::PopID();
break;
}
ImGui::PopID();
}
if (ImGui::Button("Add Goal")) {
char newName[64];
snprintf(newName, sizeof(newName), "Goal_%zu", db.goals.size());
db.goals.emplace_back(newName);
m_selectedGoal = (int)db.goals.size() - 1;
}
if (m_selectedGoal >= 0 && m_selectedGoal < (int)db.goals.size()) {
renderGoalEditor(db.goals[m_selectedGoal]);
}
ImGui::Unindent();
}
void ActionDatabaseSingletonEditor::renderActionEditor(GoapAction &action)
{
ImGui::Separator();
ImGui::Text("Action: %s", action.name.c_str());
char nameBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s", action.name.c_str());
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf)))
action.name = nameBuf;
if (ImGui::InputInt("Cost", &action.cost))
action.cost = action.cost < 0 ? 0 : action.cost;
char btNameBuf[256];
snprintf(btNameBuf, sizeof(btNameBuf), "%s",
action.behaviorTreeName.c_str());
if (ImGui::InputText("Behavior Tree Name", btNameBuf,
sizeof(btNameBuf)))
action.behaviorTreeName = btNameBuf;
if (ImGui::TreeNode("Behavior Tree")) {
if (InlineBehaviorTreeEditor::render(action.behaviorTree))
/* modified */;
ImGui::TreePop();
}
if (ImGui::TreeNode("Preconditions")) {
GoapBlackboardEditor::render(action.preconditions, "precond");
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();
}
}
void ActionDatabaseSingletonEditor::renderGoalEditor(GoapGoal &goal)
{
ImGui::Separator();
ImGui::Text("Goal: %s", goal.name.c_str());
char nameBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s", goal.name.c_str());
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf)))
goal.name = nameBuf;
if (ImGui::InputInt("Priority", &goal.priority))
goal.priority = goal.priority < 0 ? 0 : goal.priority;
char condBuf[512];
snprintf(condBuf, sizeof(condBuf), "%s", goal.condition.c_str());
if (ImGui::InputText("Condition", condBuf, sizeof(condBuf)))
goal.condition = condBuf;
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Optional expression: health > 20 && hunger < 50");
}
if (ImGui::TreeNode("Target Blackboard")) {
GoapBlackboardEditor::render(goal.target, "target");
ImGui::TreePop();
}
}

View File

@@ -0,0 +1,38 @@
#ifndef EDITSCENE_ACTION_DATABASE_SINGLETON_EDITOR_HPP
#define EDITSCENE_ACTION_DATABASE_SINGLETON_EDITOR_HPP
#pragma once
#include "../components/ActionDatabase.hpp"
/**
* Editor for the ActionDatabase singleton.
*
* Unlike ActionDatabaseEditor (which edits the ActionDatabaseComponent on a
* scene entity), this editor works directly on the global ActionDatabase
* singleton. It is opened from the editor menu and renders in its own
* ImGui window.
*/
class ActionDatabaseSingletonEditor {
public:
ActionDatabaseSingletonEditor() = default;
/**
* Render the singleton editor window.
* Call this inside an ImGui frame.
*
* @param open Pointer to bool controlling window visibility.
*/
void render(bool *open);
private:
void renderBitNames();
void renderActions();
void renderGoals();
void renderActionEditor(GoapAction &action);
void renderGoalEditor(GoapGoal &goal);
int m_selectedAction = -1;
int m_selectedGoal = -1;
};
#endif // EDITSCENE_ACTION_DATABASE_SINGLETON_EDITOR_HPP

Some files were not shown because too many files have changed in this diff Show More