From 333a0b99387a7b8208726642cc4089c2501a9cd0 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sun, 10 May 2026 14:12:09 +0300 Subject: [PATCH] Better tag support --- src/features/editScene/CMakeLists.txt | 24 +- src/features/editScene/EditorApp.cpp | 89 ++- src/features/editScene/EditorApp.hpp | 12 +- .../components/CharacterClassComponent.cpp | 97 +++ .../components/CharacterClassComponent.hpp | 107 ++++ .../components/CharacterClassDatabase.cpp | 442 ++++++++++++++ .../components/CharacterClassDatabase.hpp | 153 +++++ .../components/CharacterClassModule.cpp | 22 + .../CharacterClassOverrideModule.cpp | 22 + .../editScene/components/CharacterSlots.hpp | 31 +- .../components/DialogueComponent.hpp | 131 ----- .../components/DialogueComponentModule.cpp | 23 - src/features/editScene/components/Formula.cpp | 192 ++++++ src/features/editScene/components/Formula.hpp | 65 ++ .../lua-examples/dialogue_basic_show.lua | 49 +- .../lua-examples/dialogue_component_api.lua | 337 +++++------ .../lua-examples/dialogue_event_handler.lua | 96 ++- .../lua-examples/dialogue_event_subscribe.lua | 155 +++-- .../lua-examples/dialogue_sequence.lua | 129 ++-- .../editScene/lua/LuaCharacterClassApi.cpp | 352 +++++++++++ .../editScene/lua/LuaCharacterClassApi.hpp | 13 + .../editScene/lua/LuaComponentApi.cpp | 103 ++-- src/features/editScene/lua/LuaDialogueApi.cpp | 199 +++++++ src/features/editScene/lua/LuaDialogueApi.hpp | 14 + .../systems/CharacterClassSystem.cpp | 554 ++++++++++++++++++ .../systems/CharacterClassSystem.hpp | 60 ++ .../editScene/systems/CharacterSlotSystem.cpp | 360 ++++++++++-- .../editScene/systems/CharacterSlotSystem.hpp | 40 ++ .../editScene/systems/DialogueSystem.cpp | 370 ++++++++---- .../editScene/systems/DialogueSystem.hpp | 126 +++- .../editScene/systems/EditorUISystem.cpp | 159 +++++ .../editScene/systems/EditorUISystem.hpp | 9 + .../editScene/systems/SceneSerializer.cpp | 36 +- .../editScene/systems/SceneSerializer.hpp | 11 + .../editScene/tests/component_lua_test.cpp | 35 +- .../editScene/tests/lua_test_stubs.cpp | 86 +++ .../ui/CharacterClassDatabaseEditor.cpp | 408 +++++++++++++ .../ui/CharacterClassDatabaseEditor.hpp | 39 ++ .../editScene/ui/CharacterClassEditor.cpp | 151 +++++ .../editScene/ui/CharacterClassEditor.hpp | 17 + .../ui/CharacterClassOverrideEditor.cpp | 166 ++++++ .../ui/CharacterClassOverrideEditor.hpp | 21 + .../editScene/ui/CharacterSlotsEditor.cpp | 210 +++++-- src/features/editScene/ui/DialogueEditor.cpp | 93 --- src/features/editScene/ui/DialogueEditor.hpp | 21 - 45 files changed, 4809 insertions(+), 1020 deletions(-) create mode 100644 src/features/editScene/components/CharacterClassComponent.cpp create mode 100644 src/features/editScene/components/CharacterClassComponent.hpp create mode 100644 src/features/editScene/components/CharacterClassDatabase.cpp create mode 100644 src/features/editScene/components/CharacterClassDatabase.hpp create mode 100644 src/features/editScene/components/CharacterClassModule.cpp create mode 100644 src/features/editScene/components/CharacterClassOverrideModule.cpp delete mode 100644 src/features/editScene/components/DialogueComponent.hpp delete mode 100644 src/features/editScene/components/DialogueComponentModule.cpp create mode 100644 src/features/editScene/components/Formula.cpp create mode 100644 src/features/editScene/components/Formula.hpp create mode 100644 src/features/editScene/lua/LuaCharacterClassApi.cpp create mode 100644 src/features/editScene/lua/LuaCharacterClassApi.hpp create mode 100644 src/features/editScene/lua/LuaDialogueApi.cpp create mode 100644 src/features/editScene/lua/LuaDialogueApi.hpp create mode 100644 src/features/editScene/systems/CharacterClassSystem.cpp create mode 100644 src/features/editScene/systems/CharacterClassSystem.hpp create mode 100644 src/features/editScene/ui/CharacterClassDatabaseEditor.cpp create mode 100644 src/features/editScene/ui/CharacterClassDatabaseEditor.hpp create mode 100644 src/features/editScene/ui/CharacterClassEditor.cpp create mode 100644 src/features/editScene/ui/CharacterClassEditor.hpp create mode 100644 src/features/editScene/ui/CharacterClassOverrideEditor.cpp create mode 100644 src/features/editScene/ui/CharacterClassOverrideEditor.hpp delete mode 100644 src/features/editScene/ui/DialogueEditor.cpp delete mode 100644 src/features/editScene/ui/DialogueEditor.hpp diff --git a/src/features/editScene/CMakeLists.txt b/src/features/editScene/CMakeLists.txt index fa882e6..39c388c 100644 --- a/src/features/editScene/CMakeLists.txt +++ b/src/features/editScene/CMakeLists.txt @@ -137,9 +137,17 @@ set(EDITSCENE_SOURCES components/CellGrid.cpp components/StartupMenuModule.cpp components/PlayerControllerModule.cpp - components/DialogueComponentModule.cpp systems/DialogueSystem.cpp - ui/DialogueEditor.cpp + lua/LuaDialogueApi.cpp + components/Formula.cpp + components/CharacterClassDatabase.cpp + components/CharacterClassComponent.cpp + components/CharacterClassModule.cpp + components/CharacterClassOverrideModule.cpp + systems/CharacterClassSystem.cpp + ui/CharacterClassEditor.cpp + ui/CharacterClassOverrideEditor.cpp + ui/CharacterClassDatabaseEditor.cpp components/BuoyancyInfoModule.cpp components/WaterPhysicsModule.cpp components/WaterPlaneModule.cpp @@ -156,6 +164,7 @@ set(EDITSCENE_SOURCES lua/LuaActionApi.cpp lua/LuaBehaviorTreeApi.cpp lua/LuaGameModeApi.cpp + lua/LuaCharacterClassApi.cpp ) set(EDITSCENE_HEADERS @@ -187,9 +196,15 @@ set(EDITSCENE_HEADERS components/CellGrid.hpp components/StartupMenu.hpp components/PlayerController.hpp - components/DialogueComponent.hpp systems/DialogueSystem.hpp - ui/DialogueEditor.hpp + lua/LuaDialogueApi.hpp + components/Formula.hpp + components/CharacterClassDatabase.hpp + components/CharacterClassComponent.hpp + ui/CharacterClassEditor.hpp + ui/CharacterClassOverrideEditor.hpp + ui/CharacterClassDatabaseEditor.hpp + systems/CharacterClassSystem.hpp systems/StartupMenuSystem.hpp systems/PlayerControllerSystem.hpp systems/EditorUISystem.hpp @@ -308,6 +323,7 @@ set(EDITSCENE_HEADERS lua/LuaActionApi.hpp lua/LuaBehaviorTreeApi.hpp lua/LuaGameModeApi.hpp + lua/LuaCharacterClassApi.hpp ) add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS}) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index a902a4a..7d0a5a9 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -1,4 +1,5 @@ #include +#include #include "EditorApp.hpp" #include "GameMode.hpp" #include "systems/EditorUISystem.hpp" @@ -29,6 +30,9 @@ #include "systems/RoomLayoutSystem.hpp" #include "systems/StartupMenuSystem.hpp" #include "systems/DialogueSystem.hpp" +#include "systems/CharacterClassSystem.hpp" +#include "components/CharacterClassDatabase.hpp" +#include "components/CharacterClassComponent.hpp" #include "systems/PlayerControllerSystem.hpp" #include "systems/SceneSerializer.hpp" #include "camera/EditorCamera.hpp" @@ -60,7 +64,6 @@ #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" @@ -92,6 +95,8 @@ #include "lua/LuaActionApi.hpp" #include "lua/LuaBehaviorTreeApi.hpp" #include "lua/LuaGameModeApi.hpp" +#include "lua/LuaCharacterClassApi.hpp" +#include "lua/LuaDialogueApi.hpp" //============================================================================= // ImGuiRenderListener Implementation @@ -144,14 +149,13 @@ void ImGuiRenderListener::preViewportUpdate( 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); + // Render dialogue box (game mode or editor preview) + DialogueSystem::getInstance().update(m_deltaTime); + + // Character class system (needs, level-ups, dialogs) + if (m_editorApp && m_editorApp->getCharacterClassSystem()) { + m_editorApp->getCharacterClassSystem()->update(m_deltaTime); + m_editorApp->getCharacterClassSystem()->renderDialogs(); } } @@ -199,8 +203,7 @@ EditorApp::~EditorApp() // Collect entities first, then delete after iteration (can't modify during iteration) std::vector entitiesToDelete; - // Destroy dialogue system before other systems - m_dialogueSystem.reset(); + // DialogueSystem is a singleton, no manual teardown needed m_startupMenuSystem.reset(); m_playerControllerSystem.reset(); @@ -443,8 +446,15 @@ void EditorApp::setup() // Setup game systems m_startupMenuSystem = std::make_unique( m_world, m_sceneMgr, this); - m_dialogueSystem = std::make_unique( - m_world, m_sceneMgr, this); + DialogueSystem::getInstance().init(this); + DialogueSystem::getInstance().loadSettings("dialogue.json"); + + m_characterClassSystem = + std::make_unique( + m_world, this); + CharacterClassDatabase::loadFromJson( + "character_class.json"); + m_playerControllerSystem = std::make_unique( m_world, m_sceneMgr, this); @@ -474,8 +484,7 @@ void EditorApp::setup() // startup_menu.json scene loaded above. if (m_startupMenuSystem) m_startupMenuSystem->prepareFont(); - if (m_dialogueSystem) - m_dialogueSystem->prepareFont(); + DialogueSystem::getInstance().prepareFont(); } // Now show the overlay — font atlas will be built with our font @@ -517,6 +526,8 @@ void EditorApp::setup() editScene::registerLuaActionApi(L); editScene::registerLuaBehaviorTreeApi(L); editScene::registerLuaGameModeApi(L); + editScene::registerLuaCharacterClassApi(L); + editScene::registerLuaDialogueApi(L); // Run late setup: load data.lua and initial scripts. m_lua.lateSetup(); @@ -707,9 +718,10 @@ void EditorApp::setupECS() // Register game components m_world.component(); - m_world.component(); m_world.component(); m_world.component(); + m_world.component(); + m_world.component(); // Register environment components m_world.component(); @@ -1218,19 +1230,52 @@ flecs::entity EditorApp::getSelectedEntity() const return flecs::entity::null(); } +DialogueSystem *EditorApp::getDialogueSystem() const +{ + return &DialogueSystem::getInstance(); +} + void EditorApp::locateResources() { Ogre::ResourceGroupManager::getSingleton().createResourceGroup( "Characters", true); - // Ogre::ResourceGroupManager::getSingleton().createResourceGroup( - // "Water", true); Ogre::ResourceGroupManager::getSingleton().createResourceGroup( "LuaScripts", false); Ogre::ResourceGroupManager::getSingleton().addResourceLocation( "./lua-scripts", "FileSystem", "LuaScripts", true, true); - Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - "./characters/male", "FileSystem", "Characters", false, true); - Ogre::ResourceGroupManager::getSingleton().addResourceLocation( - "./characters/female", "FileSystem", "Characters", false, true); + + /* Try multiple relative paths for characters to handle different + * working directories (build root vs binary subdirectory) */ + struct CharPathPair { + const char *male; + const char *female; + }; + CharPathPair charPaths[] = { + {"./characters/male", "./characters/female"}, + {"../../../characters/male", "../../../characters/female"}, + {"../../characters/male", "../../characters/female"}, + {"../characters/male", "../characters/female"}, + }; + + bool added = false; + for (const auto &pair : charPaths) { + if (std::filesystem::exists(pair.male)) { + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + pair.male, "FileSystem", "Characters", false, true); + Ogre::ResourceGroupManager::getSingleton().addResourceLocation( + pair.female, "FileSystem", "Characters", false, true); + Ogre::LogManager::getSingleton().logMessage( + "Characters resource location added: " + + Ogre::String(pair.male)); + added = true; + break; + } + } + if (!added) { + Ogre::LogManager::getSingleton().logMessage( + "WARNING: Could not find characters directory from " + + std::filesystem::current_path().string()); + } + OgreBites::ApplicationContext::locateResources(); } diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index dc36b85..1c7cab5 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -44,6 +44,7 @@ class GoapPlannerSystem; class ActuatorSystem; class EventHandlerSystem; class ItemSystem; +class CharacterClassSystem; class EditorApp; /** @@ -190,10 +191,7 @@ public: { return m_startupMenuSystem.get(); } - DialogueSystem *getDialogueSystem() const - { - return m_dialogueSystem.get(); - } + DialogueSystem *getDialogueSystem() const; ActuatorSystem *getActuatorSystem() const { return m_actuatorSystem.get(); @@ -202,6 +200,10 @@ public: { return m_eventHandlerSystem.get(); } + CharacterClassSystem *getCharacterClassSystem() const + { + return m_characterClassSystem.get(); + } Ogre::ImGuiOverlay *getImGuiOverlay() const { return m_imguiOverlay; @@ -248,10 +250,10 @@ private: std::unique_ptr m_actuatorSystem; std::unique_ptr m_eventHandlerSystem; std::unique_ptr m_itemSystem; + std::unique_ptr m_characterClassSystem; // Game systems std::unique_ptr m_startupMenuSystem; - std::unique_ptr m_dialogueSystem; std::unique_ptr m_playerControllerSystem; // State diff --git a/src/features/editScene/components/CharacterClassComponent.cpp b/src/features/editScene/components/CharacterClassComponent.cpp new file mode 100644 index 0000000..d669c76 --- /dev/null +++ b/src/features/editScene/components/CharacterClassComponent.cpp @@ -0,0 +1,97 @@ +#include "CharacterClassComponent.hpp" +#include "CharacterClassDatabase.hpp" + +int CharacterClassComponent::getStat(const Ogre::String &name) const +{ + const auto *def = CharacterClassDatabase::getSingleton().findStat(name); + if (!def) + return 0; + + if (def->kind == CharacterClassDatabase::StatKind::ResourcePool) { + // For pools, getStat returns the CURRENT value + return getPoolCurrent(name); + } + + // For attributes, return the stored value clamped to min/max + auto it = stats.find(name); + if (it == stats.end()) + return 0; + if (it->second < def->minValue) + return def->minValue; + if (it->second > def->maxValue) + return def->maxValue; + return it->second; +} + +int CharacterClassComponent::getPoolMax(const Ogre::String &name) const +{ + const auto *def = CharacterClassDatabase::getSingleton().findStat(name); + if (!def || def->kind != CharacterClassDatabase::StatKind::ResourcePool) + return 0; + auto it = stats.find(name); + if (it == stats.end()) + return 0; + if (it->second < def->minValue) + return def->minValue; + if (it->second > def->maxValue) + return def->maxValue; + return it->second; +} + +int CharacterClassComponent::getPoolCurrent(const Ogre::String &name) const +{ + int maxVal = getPoolMax(name); + if (maxVal <= 0) + return 0; + auto it = currentPools.find(name); + if (it == currentPools.end()) + return maxVal; // not initialized yet, assume full + if (it->second < 0) + return 0; + if (it->second > maxVal) + return maxVal; + return it->second; +} + +void CharacterClassComponent::setPoolCurrent(const Ogre::String &name, + int value) +{ + int maxVal = getPoolMax(name); + if (maxVal <= 0) + return; + if (value < 0) + value = 0; + if (value > maxVal) + value = maxVal; + currentPools[name] = value; +} + +int CharacterClassComponent::getSkill(const Ogre::String &name) const +{ + auto it = skills.find(name); + if (it == skills.end()) + return 0; + const auto *def = CharacterClassDatabase::getSingleton().findSkill(name); + if (!def) + return it->second; + if (it->second < 0) + return 0; + if (it->second > def->maxValue) + return def->maxValue; + return it->second; +} + +int CharacterClassComponent::getNeed(const Ogre::String &name) const +{ + auto it = needs.find(name); + if (it == needs.end()) + return 0; + const auto *def = CharacterClassDatabase::getSingleton().findNeed(name); + if (!def) + return it->second; + if (it->second < 0) + return 0; + if (it->second > def->maxValue) + return def->maxValue; + return it->second; +} diff --git a/src/features/editScene/components/CharacterClassComponent.hpp b/src/features/editScene/components/CharacterClassComponent.hpp new file mode 100644 index 0000000..ac38f6e --- /dev/null +++ b/src/features/editScene/components/CharacterClassComponent.hpp @@ -0,0 +1,107 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP +#define EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP +#pragma once + +#include +#include + +/** + * Runtime character progression state. + * + * Stores the character's current level, XP, available points, + * and current stat/skill/need values. + * + * The class template (base stats, formulas, growth curves) lives in + * the CharacterClassDatabase singleton. This component only holds + * the mutable runtime state for a specific entity. + */ +struct CharacterClassComponent { + /** Class name referencing CharacterClassDatabase */ + Ogre::String className; + + /** Current level (starts at 1, no cap) */ + int level = 1; + + /** Accumulated experience points */ + int64_t currentXP = 0; + + /** Unspent stat points from level ups */ + int availablePoints = 0; + + /** Current stat values (e.g., strength, dexterity, hp_max) */ + std::unordered_map stats; + + /** Current pool values (e.g., hp_current, stamina_current). + * For ResourcePool stats, stats["hp"] is the maximum + * and currentPools["hp"] is the current value. */ + std::unordered_map currentPools; + + /** Current skill values (0-100 proficiency) */ + std::unordered_map skills; + + /** Current need values (0-1000) */ + std::unordered_map needs; + + /** True if the entity has a pending level up (waiting for player) */ + bool levelUpPending = false; + + /** True if the character sheet is open (player only) */ + bool sheetOpen = false; + + /** + * Get a stat value. + * For Attribute: returns the stat value clamped to min/max. + * For ResourcePool: returns the CURRENT pool value. + * Returns 0 if the stat does not exist. + */ + int getStat(const Ogre::String &name) const; + + /** + * Get the maximum value of a resource pool. + * Returns 0 if the stat is not a ResourcePool or does not exist. + */ + int getPoolMax(const Ogre::String &name) const; + + /** + * Get the current value of a resource pool. + * Returns 0 if the stat is not a ResourcePool or does not exist. + */ + int getPoolCurrent(const Ogre::String &name) const; + + /** + * Set the current value of a resource pool. + * Value is clamped to [0, max]. + */ + void setPoolCurrent(const Ogre::String &name, int value); + + /** + * Get a skill value, clamped to 0-100. + * Returns 0 if the skill does not exist. + */ + int getSkill(const Ogre::String &name) const; + + /** + * Get a need value, clamped to 0-1000. + * Returns 0 if the need does not exist. + */ + int getNeed(const Ogre::String &name) const; +}; + +/** + * Per-entity overrides to class defaults. + * + * Applied on top of the class base values during character creation + * or whenever stats are recomputed. + */ +struct CharacterClassOverrideComponent { + /** Flat offsets added to base stats */ + std::unordered_map statOffsets; + + /** Flat offsets added to base skills */ + std::unordered_map skillOffsets; + + /** Flat offsets added to base needs */ + std::unordered_map needOffsets; +}; + +#endif // EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP diff --git a/src/features/editScene/components/CharacterClassDatabase.cpp b/src/features/editScene/components/CharacterClassDatabase.cpp new file mode 100644 index 0000000..0956886 --- /dev/null +++ b/src/features/editScene/components/CharacterClassDatabase.cpp @@ -0,0 +1,442 @@ +#include "CharacterClassDatabase.hpp" +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +CharacterClassDatabase &CharacterClassDatabase::getSingleton() +{ + static CharacterClassDatabase instance; + return instance; +} + +CharacterClassDatabase *CharacterClassDatabase::getSingletonPtr() +{ + return &getSingleton(); +} + +// --------------------------------------------------------------------------- +// Lookup +// --------------------------------------------------------------------------- + +const CharacterClassDatabase::StatDef * +CharacterClassDatabase::findStat(const Ogre::String &name) const +{ + auto it = m_stats.find(name); + if (it != m_stats.end()) + return &it->second; + return nullptr; +} + +const CharacterClassDatabase::SkillDef * +CharacterClassDatabase::findSkill(const Ogre::String &name) const +{ + auto it = m_skills.find(name); + if (it != m_skills.end()) + return &it->second; + return nullptr; +} + +const CharacterClassDatabase::NeedDef * +CharacterClassDatabase::findNeed(const Ogre::String &name) const +{ + auto it = m_needs.find(name); + if (it != m_needs.end()) + return &it->second; + return nullptr; +} + +const CharacterClassDatabase::ClassDef * +CharacterClassDatabase::findClass(const Ogre::String &name) const +{ + auto it = m_classes.find(name); + if (it != m_classes.end()) + return &it->second; + return nullptr; +} + +CharacterClassDatabase::StatDef * +CharacterClassDatabase::findStat(const Ogre::String &name) +{ + auto it = m_stats.find(name); + if (it != m_stats.end()) + return &it->second; + return nullptr; +} + +CharacterClassDatabase::SkillDef * +CharacterClassDatabase::findSkill(const Ogre::String &name) +{ + auto it = m_skills.find(name); + if (it != m_skills.end()) + return &it->second; + return nullptr; +} + +CharacterClassDatabase::NeedDef * +CharacterClassDatabase::findNeed(const Ogre::String &name) +{ + auto it = m_needs.find(name); + if (it != m_needs.end()) + return &it->second; + return nullptr; +} + +CharacterClassDatabase::ClassDef * +CharacterClassDatabase::findClass(const Ogre::String &name) +{ + auto it = m_classes.find(name); + if (it != m_classes.end()) + return &it->second; + return nullptr; +} + +// --------------------------------------------------------------------------- +// Mutators +// --------------------------------------------------------------------------- + +void CharacterClassDatabase::addOrReplaceStat(const StatDef &def) +{ + bool wasNew = m_stats.find(def.name) == m_stats.end(); + m_stats[def.name] = def; + if (wasNew) + m_statNames.push_back(def.name); +} + +void CharacterClassDatabase::addOrReplaceSkill(const SkillDef &def) +{ + bool wasNew = m_skills.find(def.name) == m_skills.end(); + m_skills[def.name] = def; + if (wasNew) + m_skillNames.push_back(def.name); +} + +void CharacterClassDatabase::addOrReplaceNeed(const NeedDef &def) +{ + bool wasNew = m_needs.find(def.name) == m_needs.end(); + m_needs[def.name] = def; + if (wasNew) + m_needNames.push_back(def.name); +} + +void CharacterClassDatabase::addOrReplaceClass(const ClassDef &def) +{ + bool wasNew = m_classes.find(def.name) == m_classes.end(); + m_classes[def.name] = def; + if (wasNew) + m_classNames.push_back(def.name); +} + +bool CharacterClassDatabase::removeStat(const Ogre::String &name) +{ + if (m_stats.erase(name) == 0) + return false; + auto it = std::remove(m_statNames.begin(), m_statNames.end(), + name); + m_statNames.erase(it, m_statNames.end()); + return true; +} + +bool CharacterClassDatabase::removeSkill(const Ogre::String &name) +{ + if (m_skills.erase(name) == 0) + return false; + auto it = std::remove(m_skillNames.begin(), m_skillNames.end(), + name); + m_skillNames.erase(it, m_skillNames.end()); + return true; +} + +bool CharacterClassDatabase::removeNeed(const Ogre::String &name) +{ + if (m_needs.erase(name) == 0) + return false; + auto it = std::remove(m_needNames.begin(), m_needNames.end(), + name); + m_needNames.erase(it, m_needNames.end()); + return true; +} + +bool CharacterClassDatabase::removeClass(const Ogre::String &name) +{ + if (m_classes.erase(name) == 0) + return false; + auto it = std::remove(m_classNames.begin(), m_classNames.end(), + name); + m_classNames.erase(it, m_classNames.end()); + return true; +} + +void CharacterClassDatabase::clear() +{ + m_stats.clear(); + m_skills.clear(); + m_needs.clear(); + m_classes.clear(); + m_statNames.clear(); + m_skillNames.clear(); + m_needNames.clear(); + m_classNames.clear(); +} + +// --------------------------------------------------------------------------- +// Runtime helpers +// --------------------------------------------------------------------------- + +int64_t CharacterClassDatabase::computeXPForLevel(int level, + const ClassDef &cls) const +{ + return static_cast(cls.xpForLevel.evaluate(level)); +} + +int CharacterClassDatabase::computePointsForLevel(int level, + const ClassDef &cls) const +{ + return static_cast(cls.pointsPerLevel.evaluate(level)); +} + +int CharacterClassDatabase::computeStatGrowth(const Ogre::String &stat, + int level, + const ClassDef &cls) const +{ + auto it = cls.statGrowth.find(stat); + if (it == cls.statGrowth.end()) + return 0; + return static_cast(it->second.evaluate(level)); +} + +int CharacterClassDatabase::computeSkillGrowth(const Ogre::String &skill, + int level, + const ClassDef &cls) const +{ + auto it = cls.skillGrowth.find(skill); + if (it == cls.skillGrowth.end()) + return 0; + return static_cast(it->second.evaluate(level)); +} + +int CharacterClassDatabase::computeStatCost(int currentValue, + const ClassDef &cls) const +{ + return static_cast(cls.statCost.evaluate(0, static_cast(currentValue))); +} + +// --------------------------------------------------------------------------- +// JSON serialization +// --------------------------------------------------------------------------- + +bool CharacterClassDatabase::saveToJson(const std::string &filename) +{ + auto &db = getSingleton(); + nlohmann::json json; + json["version"] = 1; + + // Stats + json["stat_definitions"] = nlohmann::json::object(); + for (const auto &name : db.m_statNames) { + const auto &def = db.m_stats.at(name); + nlohmann::json j; + j["display_name"] = def.displayName; + j["kind"] = (def.kind == StatKind::Attribute) ? "attribute" : + "resource_pool"; + j["min"] = def.minValue; + j["max"] = def.maxValue; + json["stat_definitions"][name] = j; + } + + // Skills + json["skill_definitions"] = nlohmann::json::object(); + for (const auto &name : db.m_skillNames) { + const auto &def = db.m_skills.at(name); + nlohmann::json j; + j["display_name"] = def.displayName; + j["max"] = def.maxValue; + json["skill_definitions"][name] = j; + } + + // Needs + json["need_definitions"] = nlohmann::json::object(); + for (const auto &name : db.m_needNames) { + const auto &def = db.m_needs.at(name); + nlohmann::json j; + j["display_name"] = def.displayName; + j["max"] = def.maxValue; + j["accumulation_rate"] = def.accumulationRate; + j["low_threshold"] = def.lowThreshold; + j["high_threshold"] = def.highThreshold; + j["bit_name"] = def.bitName; + json["need_definitions"][name] = j; + } + + // Classes + json["classes"] = nlohmann::json::object(); + for (const auto &name : db.m_classNames) { + const auto &cls = db.m_classes.at(name); + nlohmann::json j; + j["description"] = cls.description; + j["primary_stats"] = cls.primaryStats; + j["base_stats"] = cls.baseStats; + j["base_skills"] = cls.baseSkills; + j["base_needs"] = cls.baseNeeds; + j["xp_for_level"] = cls.xpForLevel.getExpression(); + j["points_per_level"] = cls.pointsPerLevel.getExpression(); + j["stat_cost"] = cls.statCost.getExpression(); + j["stat_growth"] = nlohmann::json::object(); + for (const auto &pair : cls.statGrowth) + j["stat_growth"][pair.first] = + pair.second.getExpression(); + j["skill_growth"] = nlohmann::json::object(); + for (const auto &pair : cls.skillGrowth) + j["skill_growth"][pair.first] = + pair.second.getExpression(); + json["classes"][name] = j; + } + + try { + std::filesystem::path outPath = + std::filesystem::current_path() / filename; + std::ofstream f(outPath); + if (!f.is_open()) { + Ogre::LogManager::getSingleton().logMessage( + "CharacterClassDatabase: Failed to open " + + filename + " for writing"); + return false; + } + f << json.dump(4); + return true; + } catch (...) { + Ogre::LogManager::getSingleton().logMessage( + "CharacterClassDatabase: Exception saving " + filename); + return false; + } +} + +bool CharacterClassDatabase::loadFromJson(const std::string &filename) +{ + auto &db = getSingleton(); + db.clear(); + + std::filesystem::path inPath = + std::filesystem::current_path() / filename; + std::ifstream f(inPath); + if (!f.is_open()) { + Ogre::LogManager::getSingleton().logMessage( + "CharacterClassDatabase: Could not load " + filename + + ", using defaults."); + return false; + } + + nlohmann::json json; + try { + f >> json; + } catch (...) { + Ogre::LogManager::getSingleton().logMessage( + "CharacterClassDatabase: JSON parse error in " + + filename); + return false; + } + + // Stats + if (json.contains("stat_definitions") && + json["stat_definitions"].is_object()) { + for (auto &[key, val] : json["stat_definitions"].items()) { + StatDef def; + def.name = key; + def.displayName = val.value("display_name", key); + Ogre::String kindStr = val.value("kind", "attribute"); + def.kind = (kindStr == "resource_pool") ? StatKind::ResourcePool : + StatKind::Attribute; + def.minValue = val.value("min", 1); + def.maxValue = val.value("max", 999); + db.addOrReplaceStat(def); + } + } + + // Skills + if (json.contains("skill_definitions") && + json["skill_definitions"].is_object()) { + for (auto &[key, val] : json["skill_definitions"].items()) { + SkillDef def; + def.name = key; + def.displayName = val.value("display_name", key); + def.maxValue = val.value("max", 100); + db.addOrReplaceSkill(def); + } + } + + // Needs + if (json.contains("need_definitions") && + json["need_definitions"].is_object()) { + for (auto &[key, val] : json["need_definitions"].items()) { + NeedDef def; + def.name = key; + def.displayName = val.value("display_name", key); + def.maxValue = val.value("max", 1000); + def.accumulationRate = val.value("accumulation_rate", 0.0f); + def.lowThreshold = val.value("low_threshold", 0); + def.highThreshold = val.value("high_threshold", 1000); + def.bitName = val.value("bit_name", ""); + // Backward compat: old files used low_bit_name / high_bit_name + if (def.bitName.empty()) { + def.bitName = val.value("high_bit_name", ""); + if (def.bitName.empty()) + def.bitName = val.value("low_bit_name", ""); + } + db.addOrReplaceNeed(def); + } + } + + // Classes + if (json.contains("classes") && json["classes"].is_object()) { + for (auto &[key, val] : json["classes"].items()) { + ClassDef cls; + cls.name = key; + cls.description = val.value("description", ""); + if (val.contains("primary_stats") && + val["primary_stats"].is_array()) { + for (const auto &item : val["primary_stats"]) + cls.primaryStats.push_back( + item.get()); + } + if (val.contains("base_stats") && + val["base_stats"].is_object()) { + for (auto &[k, v] : val["base_stats"].items()) + cls.baseStats[k] = v.get(); + } + if (val.contains("base_skills") && + val["base_skills"].is_object()) { + for (auto &[k, v] : val["base_skills"].items()) + cls.baseSkills[k] = v.get(); + } + if (val.contains("base_needs") && + val["base_needs"].is_object()) { + for (auto &[k, v] : val["base_needs"].items()) + cls.baseNeeds[k] = v.get(); + } + cls.xpForLevel = Formula(val.value("xp_for_level", "0")); + cls.pointsPerLevel = + Formula(val.value("points_per_level", "0")); + cls.statCost = Formula(val.value("stat_cost", "1")); + if (val.contains("stat_growth") && + val["stat_growth"].is_object()) { + for (auto &[k, v] : val["stat_growth"].items()) + cls.statGrowth[k] = + Formula(v.get()); + } + if (val.contains("skill_growth") && + val["skill_growth"].is_object()) { + for (auto &[k, v] : val["skill_growth"].items()) + cls.skillGrowth[k] = + Formula(v.get()); + } + db.addOrReplaceClass(cls); + } + } + + return true; +} diff --git a/src/features/editScene/components/CharacterClassDatabase.hpp b/src/features/editScene/components/CharacterClassDatabase.hpp new file mode 100644 index 0000000..f6220d8 --- /dev/null +++ b/src/features/editScene/components/CharacterClassDatabase.hpp @@ -0,0 +1,153 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_DATABASE_HPP +#define EDITSCENE_CHARACTER_CLASS_DATABASE_HPP +#pragma once + +#include "Formula.hpp" +#include +#include +#include + +/** + * Global character class database singleton. + * + * Holds the master list of stat/skill/need definitions and class templates. + * This is a singleton accessible from anywhere in the codebase. + * Persisted to character_class.json, read-only in game mode. + */ +class CharacterClassDatabase { +public: + static CharacterClassDatabase &getSingleton(); + static CharacterClassDatabase *getSingletonPtr(); + + // ----------------------------------------------------------------------- + // Definitions + // ----------------------------------------------------------------------- + + enum class StatKind { + Attribute, // permanent value (strength, agility) + ResourcePool // depletable pool (health, stamina, mana) + }; + + struct StatDef { + Ogre::String name; + Ogre::String displayName; + StatKind kind = StatKind::Attribute; + int minValue = 1; + int maxValue = 999; + }; + + struct SkillDef { + Ogre::String name; + Ogre::String displayName; + int maxValue = 100; + }; + + struct NeedDef { + Ogre::String name; + Ogre::String displayName; + int maxValue = 1000; + float accumulationRate = 0.0f; + int lowThreshold = 0; // clear bit when need <= this + int highThreshold = 1000; // set bit when need >= this + Ogre::String bitName; // GOAP blackboard bit (hysteresis) + }; + + struct ClassDef { + Ogre::String name; + Ogre::String description; + std::vector primaryStats; + std::unordered_map baseStats; + std::unordered_map baseSkills; + std::unordered_map baseNeeds; + Formula xpForLevel; + Formula pointsPerLevel; + Formula statCost; + std::unordered_map statGrowth; + std::unordered_map skillGrowth; + }; + + // ----------------------------------------------------------------------- + // Lookup + // ----------------------------------------------------------------------- + + const StatDef *findStat(const Ogre::String &name) const; + const SkillDef *findSkill(const Ogre::String &name) const; + const NeedDef *findNeed(const Ogre::String &name) const; + const ClassDef *findClass(const Ogre::String &name) const; + + StatDef *findStat(const Ogre::String &name); + SkillDef *findSkill(const Ogre::String &name); + NeedDef *findNeed(const Ogre::String &name); + ClassDef *findClass(const Ogre::String &name); + + // ----------------------------------------------------------------------- + // Mutators + // ----------------------------------------------------------------------- + + void addOrReplaceStat(const StatDef &def); + void addOrReplaceSkill(const SkillDef &def); + void addOrReplaceNeed(const NeedDef &def); + void addOrReplaceClass(const ClassDef &def); + + bool removeStat(const Ogre::String &name); + bool removeSkill(const Ogre::String &name); + bool removeNeed(const Ogre::String &name); + bool removeClass(const Ogre::String &name); + + void clear(); + + // ----------------------------------------------------------------------- + // Lists + // ----------------------------------------------------------------------- + + const std::vector &getStatNames() const + { + return m_statNames; + } + const std::vector &getSkillNames() const + { + return m_skillNames; + } + const std::vector &getNeedNames() const + { + return m_needNames; + } + const std::vector &getClassNames() const + { + return m_classNames; + } + + // ----------------------------------------------------------------------- + // Persistence + // ----------------------------------------------------------------------- + + static bool saveToJson(const std::string &filename); + static bool loadFromJson(const std::string &filename); + + // ----------------------------------------------------------------------- + // Runtime helpers + // ----------------------------------------------------------------------- + + int64_t computeXPForLevel(int level, const ClassDef &cls) const; + int computePointsForLevel(int level, const ClassDef &cls) const; + int computeStatGrowth(const Ogre::String &stat, int level, + const ClassDef &cls) const; + int computeSkillGrowth(const Ogre::String &skill, int level, + const ClassDef &cls) const; + int computeStatCost(int currentValue, const ClassDef &cls) const; + +private: + CharacterClassDatabase() = default; + + std::unordered_map m_stats; + std::unordered_map m_skills; + std::unordered_map m_needs; + std::unordered_map m_classes; + + std::vector m_statNames; + std::vector m_skillNames; + std::vector m_needNames; + std::vector m_classNames; +}; + +#endif // EDITSCENE_CHARACTER_CLASS_DATABASE_HPP diff --git a/src/features/editScene/components/CharacterClassModule.cpp b/src/features/editScene/components/CharacterClassModule.cpp new file mode 100644 index 0000000..10a2129 --- /dev/null +++ b/src/features/editScene/components/CharacterClassModule.cpp @@ -0,0 +1,22 @@ +#include "CharacterClassComponent.hpp" +#include "CharacterClassDatabase.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/CharacterClassEditor.hpp" + +REGISTER_COMPONENT_GROUP("Character Class", "Game", + CharacterClassComponent, CharacterClassEditor) +{ + registry.registerComponent( + "Character Class", "Game", + std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/components/CharacterClassOverrideModule.cpp b/src/features/editScene/components/CharacterClassOverrideModule.cpp new file mode 100644 index 0000000..aceaa4f --- /dev/null +++ b/src/features/editScene/components/CharacterClassOverrideModule.cpp @@ -0,0 +1,22 @@ +#include "CharacterClassComponent.hpp" +#include "../ui/ComponentRegistration.hpp" +#include "../ui/CharacterClassOverrideEditor.hpp" + +REGISTER_COMPONENT_GROUP("Character Class Override", "Game", + CharacterClassOverrideComponent, + CharacterClassOverrideEditor) +{ + registry.registerComponent( + "Character Class Override", "Game", + std::make_unique(), + // Adder + [](flecs::entity e) { + if (!e.has()) + e.set({}); + }, + // Remover + [](flecs::entity e) { + if (e.has()) + e.remove(); + }); +} diff --git a/src/features/editScene/components/CharacterSlots.hpp b/src/features/editScene/components/CharacterSlots.hpp index ce323ec..8f4a66c 100644 --- a/src/features/editScene/components/CharacterSlots.hpp +++ b/src/features/editScene/components/CharacterSlots.hpp @@ -3,6 +3,18 @@ #pragma once #include #include +#include + +/** + * Selection criteria for a single character slot. + * Layer 0 (nude base) is always implicit. + * Layer 1 and 2 are selected via combo boxes. + */ +struct SlotSelection { + Ogre::String layer1Mesh; // "none" or exact mesh name for layer 1 + Ogre::String layer2Mesh; // "none" or exact mesh name for layer 2 + Ogre::String explicitMesh; // backward-compat override +}; /** * Multi-slot mesh component for character parts sharing a skeleton. @@ -11,7 +23,16 @@ struct CharacterSlotsComponent { Ogre::String age = "adult"; Ogre::String sex = "male"; + + /* Global outfit level: 0=nude, 1=lingerie, 2=clothed */ + int outfitLevel = 2; + + /* Backward-compat: old mesh-name map. Deserialized into slotSelections on load. */ std::unordered_map slots; + + /* Per-slot layer selections (runtime) */ + std::unordered_map slotSelections; + bool dirty = true; /* Runtime: master entity with shared skeleton (set by CharacterSlotSystem) */ @@ -20,11 +41,19 @@ struct CharacterSlotsComponent { /** * Front-facing axis for this character model. * Most models face -Z (NEGATIVE_UNIT_Z), but some face +Z. - * This is used by path following to rotate the character correctly. */ Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z; CharacterSlotsComponent() = default; }; +/** + * Global shape key weights for a character. + * Same name applies to all slots uniformly. + */ +struct CharacterShapeKeysComponent { + std::unordered_map weights; + bool dirty = true; +}; + #endif // EDITSCENE_CHARACTERSLOTS_HPP diff --git a/src/features/editScene/components/DialogueComponent.hpp b/src/features/editScene/components/DialogueComponent.hpp deleted file mode 100644 index 1dd76bb..0000000 --- a/src/features/editScene/components/DialogueComponent.hpp +++ /dev/null @@ -1,131 +0,0 @@ -#ifndef EDITSCENE_DIALOGUE_COMPONENT_HPP -#define EDITSCENE_DIALOGUE_COMPONENT_HPP -#pragma once - -#include -#include -#include -#include - -/** - * 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 (EventParams) parameters: - * "text" (string) - Narration text to display - * "choices" (string_array) - Array of choice label strings (Lua table) - * "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 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 onChoiceSelected; - - /** Callback invoked when dialogue is dismissed (no choices mode) */ - std::function onDismissed; - - /** Callback invoked when dialogue starts showing */ - std::function onShow; - - /* --- API --- */ - - /** Show dialogue with given text and optional choices */ - void show(const Ogre::String &narrationText, - const std::vector &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 diff --git a/src/features/editScene/components/DialogueComponentModule.cpp b/src/features/editScene/components/DialogueComponentModule.cpp deleted file mode 100644 index f0bc4f6..0000000 --- a/src/features/editScene/components/DialogueComponentModule.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "../ui/ComponentRegistration.hpp" -#include "../ui/DialogueEditor.hpp" -#include "DialogueComponent.hpp" - -REGISTER_COMPONENT_GROUP("Dialogue Box", "Game", DialogueComponent, - DialogueEditor) -{ - registry.registerComponent( - DialogueComponent_name, DialogueComponent_group, - std::make_unique(), - // Adder - [](flecs::entity e) { - if (!e.has()) { - e.set({}); - } - }, - // Remover - [](flecs::entity e) { - if (e.has()) { - e.remove(); - } - }); -} diff --git a/src/features/editScene/components/Formula.cpp b/src/features/editScene/components/Formula.cpp new file mode 100644 index 0000000..97ebec3 --- /dev/null +++ b/src/features/editScene/components/Formula.cpp @@ -0,0 +1,192 @@ +#include "Formula.hpp" +#include +#include +#include + +Formula::Formula(const Ogre::String &expr) + : m_expression(expr) +{ + m_valid = !expr.empty(); +} + +double Formula::evaluate(const std::unordered_map &vars) const +{ + if (!m_valid || m_expression.empty()) + return 0.0; + + Parser p; + p.s = m_expression.c_str(); + p.vars = &vars; + try { + return p.parseExpression(); + } catch (...) { + return 0.0; + } +} + +double Formula::evaluate(int level) const +{ + std::unordered_map vars; + vars["level"] = static_cast(level); + return evaluate(vars); +} + +double Formula::evaluate(int level, double current) const +{ + std::unordered_map vars; + vars["level"] = static_cast(level); + vars["current"] = current; + return evaluate(vars); +} + +// --------------------------------------------------------------------------- +// Parser implementation +// --------------------------------------------------------------------------- + +void Formula::Parser::skipWhitespace() +{ + while (*s == ' ' || *s == '\t') + s++; +} + +bool Formula::Parser::consume(char c) +{ + skipWhitespace(); + if (*s == c) { + s++; + return true; + } + return false; +} + +double Formula::Parser::parseExpression() +{ + double value = parseTerm(); + while (true) { + skipWhitespace(); + if (consume('+')) { + value += parseTerm(); + } else if (consume('-')) { + value -= parseTerm(); + } else { + break; + } + } + return value; +} + +double Formula::Parser::parseTerm() +{ + double value = parsePower(); + while (true) { + skipWhitespace(); + if (consume('*')) { + value *= parsePower(); + } else if (consume('/')) { + double rhs = parsePower(); + if (rhs != 0.0) + value /= rhs; + else + value = 0.0; + } else { + break; + } + } + return value; +} + +double Formula::Parser::parsePower() +{ + double value = parseUnary(); + while (true) { + skipWhitespace(); + if (consume('^')) { + value = std::pow(value, parseUnary()); + } else { + break; + } + } + return value; +} + +double Formula::Parser::parseUnary() +{ + skipWhitespace(); + if (consume('+')) + return parseUnary(); + if (consume('-')) + return -parseUnary(); + return parsePrimary(); +} + +double Formula::Parser::parsePrimary() +{ + skipWhitespace(); + + // Number + if (std::isdigit(*s) || (*s == '.' && std::isdigit(*(s + 1)))) { + char *end = nullptr; + double val = std::strtod(s, &end); + s = end; + return val; + } + + // Parenthesized expression + if (consume('(')) { + double val = parseExpression(); + skipWhitespace(); + if (!consume(')')) + return 0.0; + return val; + } + + // Identifier (variable or function) + if (std::isalpha(*s) || *s == '_') { + const char *start = s; + while (std::isalnum(*s) || *s == '_') + s++; + std::string name(start, static_cast(s - start)); + + skipWhitespace(); + if (consume('(')) { + // Function call + std::vector args; + if (!consume(')')) { + args.push_back(parseExpression()); + while (consume(',')) { + args.push_back(parseExpression()); + } + skipWhitespace(); + if (!consume(')')) + return 0.0; + } + + if (name == "floor" && args.size() >= 1) + return std::floor(args[0]); + if (name == "ceil" && args.size() >= 1) + return std::ceil(args[0]); + if (name == "min" && args.size() >= 2) + return args[0] < args[1] ? args[0] : args[1]; + if (name == "max" && args.size() >= 2) + return args[0] > args[1] ? args[0] : args[1]; + if (name == "clamp" && args.size() >= 3) { + if (args[0] < args[1]) + return args[1]; + if (args[0] > args[2]) + return args[2]; + return args[0]; + } + return 0.0; + } + + // Variable lookup + if (vars) { + auto it = vars->find(name); + if (it != vars->end()) + return it->second; + } + return 0.0; + } + + return 0.0; +} diff --git a/src/features/editScene/components/Formula.hpp b/src/features/editScene/components/Formula.hpp new file mode 100644 index 0000000..f0ab09e --- /dev/null +++ b/src/features/editScene/components/Formula.hpp @@ -0,0 +1,65 @@ +#ifndef EDITSCENE_FORMULA_HPP +#define EDITSCENE_FORMULA_HPP +#pragma once + +#include +#include +#include + +/** + * Lightweight expression evaluator for RPG formulas. + * + * Supports: + * Variables: level, current, base, value + * Operators: +, -, *, /, ^, () + * Functions: floor(x), ceil(x), min(a,b), max(a,b), clamp(x,lo,hi) + * + * Example: "level * level * 100 + floor(current / 10)" + */ +class Formula { +public: + Formula() = default; + explicit Formula(const Ogre::String &expr); + + bool isValid() const { return m_valid; } + const Ogre::String &getExpression() const { return m_expression; } + + /** + * Evaluate the formula with given variable bindings. + * + * @param vars Map of variable names to values. + * @return The computed result. Returns 0.0 if formula is invalid. + */ + double evaluate(const std::unordered_map &vars) const; + + /** + * Convenience: evaluate with just a level. + */ + double evaluate(int level) const; + + /** + * Convenience: evaluate with level and current value. + */ + double evaluate(int level, double current) const; + +private: + Ogre::String m_expression; + bool m_valid = false; + + // Recursive descent parser + struct Parser { + const char *s; + const std::unordered_map *vars; + + double parseExpression(); + double parseTerm(); + double parsePower(); + double parseUnary(); + double parsePrimary(); + + void skipWhitespace(); + bool consume(char c); + }; +}; + +#endif // EDITSCENE_FORMULA_HPP diff --git a/src/features/editScene/lua-examples/dialogue_basic_show.lua b/src/features/editScene/lua-examples/dialogue_basic_show.lua index 50c5158..b160c6d 100644 --- a/src/features/editScene/lua-examples/dialogue_basic_show.lua +++ b/src/features/editScene/lua-examples/dialogue_basic_show.lua @@ -4,8 +4,8 @@ -- 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. +-- The DialogueSystem is a singleton (no ECS component needed). It listens +-- for "dialogue_show" events and displays the text directly. -- -- Event payload parameters: -- "text" (string) - Narration text to display @@ -14,32 +14,7 @@ -- ============================================================================= -- --------------------------------------------------------------------------- --- 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) +-- 1. 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. @@ -52,7 +27,7 @@ ecs.send_event("dialogue_show", { print("Sent basic narration dialogue") -- --------------------------------------------------------------------------- --- 3. Show dialogue with player choices +-- 2. Show dialogue with player choices -- --------------------------------------------------------------------------- -- When "choices" is provided as a table, the dialogue box shows -- buttons instead of click-to-progress. The player must pick one. @@ -66,7 +41,7 @@ ecs.send_event("dialogue_show", { print("Sent dialogue with choices") -- --------------------------------------------------------------------------- --- 4. Show dialogue without a speaker name +-- 3. Show dialogue without a speaker name -- --------------------------------------------------------------------------- ecs.send_event("dialogue_show", { @@ -76,7 +51,7 @@ ecs.send_event("dialogue_show", { print("Sent anonymous narration") -- --------------------------------------------------------------------------- --- 5. Multi-line dialogue (use \n for line breaks) +-- 4. Multi-line dialogue (use \n for line breaks) -- --------------------------------------------------------------------------- ecs.send_event("dialogue_show", { @@ -90,13 +65,11 @@ 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", { text = "...", speaker = "...", choices = { ... } }) --- 3. Required: text = "The narration text" --- 4. Optional: speaker = "Speaker Name" --- 5. Optional: choices = { "Choice1", "Choice2", "Choice3" } (table of strings) --- 6. EventParams uses flat key-value pairs (no nested stringValues/floatValues/etc.) --- 7. Type metadata is available via params._types table +-- 1. Call ecs.send_event("dialogue_show", { text = "...", speaker = "...", choices = { ... } }) +-- 2. Required: text = "The narration text" +-- 3. Optional: speaker = "Speaker Name" +-- 4. Optional: choices = { "Choice1", "Choice2", "Choice3" } (table of strings) +-- 5. No ECS entity or component needed — DialogueSystem is a singleton -- ============================================================================= print("Dialogue basic show examples completed!") diff --git a/src/features/editScene/lua-examples/dialogue_component_api.lua b/src/features/editScene/lua-examples/dialogue_component_api.lua index e191858..9696ed6 100644 --- a/src/features/editScene/lua-examples/dialogue_component_api.lua +++ b/src/features/editScene/lua-examples/dialogue_component_api.lua @@ -1,219 +1,198 @@ -- ============================================================================= --- Dialogue: Direct Component API Control +-- Dialogue: Direct Singleton API Control -- ============================================================================= --- This example demonstrates how to control the DialogueComponent directly --- via the ECS component API, without using the EventBus. +-- This example demonstrates how to control the DialogueSystem directly +-- via its singleton Lua API, without using the EventBus. -- --- The DialogueComponent has methods that can be called from C++: +-- The DialogueSystem singleton exposes these functions: -- 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 +-- hide() - Dismiss dialogue +-- progress() - Click-to-progress (no-choices mode) +-- select_choice(index) - Select a choice (1-based) +-- is_active() - Check if dialogue is active +-- get_settings() - Get visual settings table +-- set_settings(table) - Set visual settings +-- save_settings(path) - Save settings to JSON +-- load_settings(path) - Load settings from JSON -- --- From Lua, you manipulate the component's fields directly using the --- ecs.set_component / ecs.get_component API. +-- Settings table fields: +-- font_name, font_size, speaker_font_size, +-- background_opacity, box_height_fraction, box_position_fraction -- ============================================================================= -- --------------------------------------------------------------------------- --- 1. Create an entity with DialogueComponent +-- 1. Show dialogue directly via the singleton API -- --------------------------------------------------------------------------- -local dlg = ecs.create_entity() -ecs.set_entity_name(dlg, "DialogueBox") -ecs.add_component(dlg, "Dialogue") +ecs.dialogue.show("This dialogue was shown directly via the singleton API!", + {}, + "Lua Script") + +print("Dialogue shown via direct API. Active: " .. tostring(ecs.dialogue.is_active())) -- --------------------------------------------------------------------------- --- 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 +-- 2. Read and modify visual settings -- --------------------------------------------------------------------------- -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 +local settings = ecs.dialogue.get_settings() +print("Current font: " .. settings.font_name) +print("Current font size: " .. settings.font_size) + +-- Change appearance settings +settings.font_name = "Jupiteroid-Regular.ttf" +settings.font_size = 24.0 +settings.speaker_font_size = 20.0 +settings.background_opacity = 0.85 +settings.box_height_fraction = 0.25 +settings.box_position_fraction = 0.75 + +ecs.dialogue.set_settings(settings) +print("Dialogue settings updated") -- --------------------------------------------------------------------------- --- 4. Modify individual dialogue fields +-- 3. Show dialogue with choices -- --------------------------------------------------------------------------- --- 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." +ecs.dialogue.show( + "Where would you like to travel?", + { "The Forest", "The Village", "The Mountains" }, + "Guide" ) -local guard = create_npc_with_dialogue( +print("Dialogue with choices shown. Active: " .. tostring(ecs.dialogue.is_active())) + +-- Simulate player selecting choice 1 +ecs.dialogue.select_choice(1) +print("After choice: Active = " .. tostring(ecs.dialogue.is_active())) + +-- --------------------------------------------------------------------------- +-- 4. Show and dismiss (no choices) +-- --------------------------------------------------------------------------- + +ecs.dialogue.show("A mysterious voice echoes through the chamber...") +print("Narration shown. Active: " .. tostring(ecs.dialogue.is_active())) + +-- Simulate click-to-progress +ecs.dialogue.progress() +print("After progress: Active = " .. tostring(ecs.dialogue.is_active())) + +-- --------------------------------------------------------------------------- +-- 5. Hide dialogue immediately +-- --------------------------------------------------------------------------- + +ecs.dialogue.show("This will be cut short.", {}, "Interrupter") +ecs.dialogue.hide() +print("After hide: Active = " .. tostring(ecs.dialogue.is_active())) + +-- --------------------------------------------------------------------------- +-- 6. Save and load settings +-- --------------------------------------------------------------------------- + +-- Save current settings to dialogue.json +local saved = ecs.dialogue.save_settings("dialogue.json") +print("Settings saved: " .. tostring(saved)) + +-- Load settings back (or from a different file) +local loaded = ecs.dialogue.load_settings("dialogue.json") +print("Settings loaded: " .. tostring(loaded)) + +-- --------------------------------------------------------------------------- +-- 7. Practical: Configure dialogue appearance for a scene +-- --------------------------------------------------------------------------- + +function setup_dialogue_for_scene(scene_type) + local scene_settings = ecs.dialogue.get_settings() + + if scene_type == "dark_cave" then + scene_settings.background_opacity = 0.95 + scene_settings.box_height_fraction = 0.20 + scene_settings.font_size = 22.0 + elseif scene_type == "bright_outdoor" then + scene_settings.background_opacity = 0.70 + scene_settings.box_height_fraction = 0.30 + scene_settings.font_size = 26.0 + elseif scene_type == "intimate_conversation" then + scene_settings.background_opacity = 0.90 + scene_settings.box_height_fraction = 0.22 + scene_settings.font_size = 24.0 + scene_settings.speaker_font_size = 22.0 + end + + ecs.dialogue.set_settings(scene_settings) + print("Dialogue configured for scene: " .. scene_type) +end + +setup_dialogue_for_scene("dark_cave") +setup_dialogue_for_scene("bright_outdoor") + +-- --------------------------------------------------------------------------- +-- 8. Practical: Show NPC dialogue with dynamic choices +-- --------------------------------------------------------------------------- + +function show_npc_dialogue(npc_name, text, choices) + ecs.dialogue.show(text, choices or {}, npc_name) +end + +-- Merchant interaction +show_npc_dialogue( + "Merchant", + "Welcome to my shop! Best wares in town.", + { "Buy Sword (50 gold)", "Buy Shield (30 gold)", "Leave" } +) + +-- Guard interaction +show_npc_dialogue( "Guard", - "guard.mesh", - "Halt! Who goes there?" + "Halt! Who goes there?", + { "I'm a traveler", "I'm looking for the inn", "None of your business" } ) -- --------------------------------------------------------------------------- -- 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) +function update_dialogue_after_event(event_type) + if event_type == "quest_accepted" then + ecs.dialogue.show( + "Excellent! Bring me the artifact and you'll be rewarded.", + { "Where do I find it?", "I'm on my way!", "Tell me more" }, + "Quest Giver" + ) + elseif event_type == "combat_start" then + ecs.dialogue.show( + "Enemies approach! Prepare for battle!", + {}, + "Companion" + ) + elseif event_type == "level_up" then + ecs.dialogue.show( + "You feel a surge of power! You have reached a new level.", + { "View skills", "Continue" }, + "System" + ) end - - -- Show the updated dialogue via event (this triggers the state change) - ecs.send_event("dialogue_show", { - 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 - - -- 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", { - text = base_text, - speaker = ecs.get_field(npc_entity, "Dialogue", "speaker"), - choices = choice_list - }) -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) +update_dialogue_after_event("quest_accepted") -- ============================================================================= -- Summary -- ============================================================================= --- Direct component API vs EventBus approach: +-- Direct singleton 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) +-- Singleton API (ecs.dialogue.*): +-- - Direct control over showing/hiding/progressing/selecting +-- - Read/write visual settings (font, size, opacity, position) +-- - Save/load settings to dialogue.json +-- - Best for scripted sequences and direct game logic -- -- EventBus (ecs.send_event "dialogue_show"): --- - Triggers proper state transitions --- - Parses choices from table of strings --- - Best for showing dialogue to the player +-- - Triggers dialogue via the event system +-- - Good for decoupled systems (e.g., NPCs, triggers, quests) +-- - Same underlying singleton, just a different entry point -- --- Best practice: Use the EventBus to SHOW dialogue, and the component API --- to CONFIGURE the dialogue box appearance. +-- Best practice: Use the singleton API for direct control, and EventBus +-- for triggering dialogue from other systems. -- ============================================================================= -print("Dialogue component API examples completed!") +print("Dialogue singleton API examples completed!") diff --git a/src/features/editScene/lua-examples/dialogue_event_handler.lua b/src/features/editScene/lua-examples/dialogue_event_handler.lua index d96c004..0240366 100644 --- a/src/features/editScene/lua-examples/dialogue_event_handler.lua +++ b/src/features/editScene/lua-examples/dialogue_event_handler.lua @@ -12,19 +12,12 @@ -- sequences where one event triggers dialogue, and the player's choice -- triggers another event. -- --- Event parameters use the EventParams type with flat key-value pairs. +-- Note: DialogueSystem is a singleton. No ECS DialogueComponent is needed. +-- Dialogue is shown via ecs.dialogue.show() or ecs.send_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 +-- 1. Create an NPC with EventHandler for dialogue triggers -- --------------------------------------------------------------------------- local npc = ecs.create_entity() @@ -51,7 +44,7 @@ ecs.set_component(npc, "EventHandler", { print("Created NPC with EventHandler for player_approached event") -- --------------------------------------------------------------------------- --- 3. Create an EventHandler that triggers on quest completion +-- 2. Create an EventHandler that triggers on quest completion -- --------------------------------------------------------------------------- local quest_npc = ecs.create_entity() @@ -66,7 +59,7 @@ ecs.set_component(quest_npc, "EventHandler", { print("Created NPC with EventHandler for quest_completed event") -- --------------------------------------------------------------------------- --- 4. Trigger dialogue via events from other game systems +-- 3. Trigger dialogue via events from other game systems -- --------------------------------------------------------------------------- -- Simulate a proximity trigger: when the player gets close to an NPC, @@ -83,12 +76,12 @@ function on_player_near_npc(npc_name, distance) distance = distance }) - -- Also show dialogue directly - ecs.send_event("dialogue_show", { - text = "Hello there! I have a quest for a brave adventurer.", - speaker = npc_name, - choices = { "I'll help!", "What's the reward?", "Not interested" } - }) + -- Also show dialogue directly via singleton API + ecs.dialogue.show( + "Hello there! I have a quest for a brave adventurer.", + { "I'll help!", "What's the reward?", "Not interested" }, + npc_name + ) end end @@ -96,15 +89,17 @@ end on_player_near_npc("QuestGiver", 3.0) -- --------------------------------------------------------------------------- --- 5. Chain events: choice -> event -> next dialogue +-- 4. 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.choice_index or 0 - local choice_text = params.choice_text or "" +-- Since choice selection happens via C++ callback, in a real game you'd +-- wire the callback to send events. From Lua, we can demonstrate by +-- sending follow-up events manually. + +function on_dialogue_choice_made(choice_text) + print("Player chose: " .. choice_text) if choice_text == "I'll help!" then -- Player accepted the quest - trigger quest acceptance event @@ -116,31 +111,34 @@ local choice_sub = ecs.subscribe_event("dialogue_choice", function(event, params }) -- Show follow-up dialogue - ecs.send_event("dialogue_show", { - 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" } - }) + ecs.dialogue.show( + "Excellent! The ancient artifact was stolen from the temple.\nBring it back and you'll be richly rewarded!", + { "Where is the temple?", "I'm on it!", "Tell me more" }, + "QuestGiver" + ) elseif choice_text == "What's the reward?" then - ecs.send_event("dialogue_show", { - text = "100 gold pieces and a magical amulet! What do you say?", - speaker = "QuestGiver", - choices = { "I'll help!", "Sounds good", "Maybe later" } - }) + ecs.dialogue.show( + "100 gold pieces and a magical amulet! What do you say?", + { "I'll help!", "Sounds good", "Maybe later" }, + "QuestGiver" + ) elseif choice_text == "Not interested" then - ecs.send_event("dialogue_show", { - text = "Very well. The offer stands if you change your mind.", - speaker = "QuestGiver" - }) + ecs.dialogue.show( + "Very well. The offer stands if you change your mind.", + {}, + "QuestGiver" + ) end -end) +end -print("Subscribed to dialogue_choice for event chaining") +-- Simulate choices +on_dialogue_choice_made("I'll help!") +on_dialogue_choice_made("What's the reward?") -- --------------------------------------------------------------------------- --- 6. Subscribe to custom events for game logic +-- 5. Subscribe to custom events for game logic -- --------------------------------------------------------------------------- -- Listen for quest acceptance @@ -162,7 +160,7 @@ end) print("Subscribed to quest_accepted events") -- --------------------------------------------------------------------------- --- 7. Practical: Zone entry dialogue +-- 6. Practical: Zone entry dialogue -- --------------------------------------------------------------------------- -- When the player enters a new area, show contextual dialogue. @@ -188,10 +186,7 @@ function on_zone_entered(zone_name) local dialogue = zone_dialogues[zone_name] if dialogue then - ecs.send_event("dialogue_show", { - text = dialogue.text, - speaker = dialogue.speaker - }) + ecs.dialogue.show(dialogue.text, {}, dialogue.speaker) -- Also send a zone-specific event for other systems ecs.send_event("zone_entered", { @@ -206,7 +201,7 @@ on_zone_entered("village") on_zone_entered("dungeon") -- --------------------------------------------------------------------------- --- 8. Practical: Item pickup dialogue +-- 7. Practical: Item pickup dialogue -- --------------------------------------------------------------------------- function on_item_picked_up(item_name, item_count) @@ -220,10 +215,7 @@ function on_item_picked_up(item_name, item_count) local message = pickup_messages[item_name] if message then - ecs.send_event("dialogue_show", { - text = message, - speaker = "Narrator" - }) + ecs.dialogue.show(message, {}, "Narrator") end end @@ -253,8 +245,8 @@ on_item_picked_up("gold_coins", 50) -- Accept quest -> event -> update quest log -> next dialogue -- Complete quest -> event -> reward dialogue -> next dialogue -- --- EventParams uses flat key-value pairs. Type metadata is available --- via params._types table (e.g., params._types.reward_gold = "int"). +-- No ECS DialogueComponent needed — use ecs.dialogue.show() or +-- ecs.send_event("dialogue_show", { ... }) to display dialogue. -- ============================================================================= print("Dialogue EventHandler integration examples completed!") diff --git a/src/features/editScene/lua-examples/dialogue_event_subscribe.lua b/src/features/editScene/lua-examples/dialogue_event_subscribe.lua index 8fee93d..11e3931 100644 --- a/src/features/editScene/lua-examples/dialogue_event_subscribe.lua +++ b/src/features/editScene/lua-examples/dialogue_event_subscribe.lua @@ -9,135 +9,112 @@ -- -- 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) +-- "dialogue_hide" - Fired when dialogue is hidden -- --- Event parameters use the EventParams type, which supports flat --- key-value pairs with typed values. Use params._types to check types. +-- Note: choice and dismiss callbacks are handled via the singleton's +-- onChoiceSelected / onDismissed callbacks (C++ side). From Lua, you +-- can poll is_active() or subscribe to your own custom events. -- ============================================================================= -- --------------------------------------------------------------------------- --- 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.choice_index or 0 - local choice_text = params.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.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) +-- 1. Subscribe to dialogue show events (for logging/tracking) -- --------------------------------------------------------------------------- local show_sub = ecs.subscribe_event("dialogue_show", function(event, params) local text = params.text or "" local speaker = params.speaker or "Unknown" - local choices = params.choices or {} print("[Dialogue Log] " .. speaker .. ": \"" .. text .. "\"") - - if #choices > 0 then - print("[Dialogue Log] Choices: " .. table.concat(choices, ", ")) - end end) print("Subscribed to dialogue_show events for logging (ID: " .. show_sub .. ")") -- --------------------------------------------------------------------------- --- 5. Example: Branching dialogue with choice handling +-- 2. Subscribe to dialogue hide events -- --------------------------------------------------------------------------- --- This shows a complete flow: show dialogue -> handle choice -> react + +local hide_sub = ecs.subscribe_event("dialogue_hide", function(event, params) + print("[Dialogue Log] Dialogue was hidden") +end) + +print("Subscribed to dialogue_hide events (ID: " .. hide_sub .. ")") + +-- --------------------------------------------------------------------------- +-- 3. Example: Branching dialogue with choice handling +-- --------------------------------------------------------------------------- +-- This shows a complete flow: show dialogue -> handle choice -> react. +-- Since choice selection happens via C++ callbacks, from Lua we can +-- use a custom event pattern or poll is_active(). function show_branching_dialogue() -- Step 1: Show the dialogue with choices - ecs.send_event("dialogue_show", { - text = "You see a dark cave entrance. What do you do?", - speaker = "Narrator", - choices = { "Enter the cave", "Look around first", "Leave" } - }) + ecs.dialogue.show( + "You see a dark cave entrance. What do you do?", + { "Enter the cave", "Look around first", "Leave" }, + "Narrator" + ) - -- 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. + print("Branching dialogue shown. Waiting for player choice...") + + -- Step 2: In a real game, you'd check the result asynchronously. + -- For this example, we demonstrate the pattern. + -- (Use ecs.dialogue.is_active() to poll, or wire C++ callbacks + -- to send custom events when choices are made.) end show_branching_dialogue() -- --------------------------------------------------------------------------- --- 6. Example: NPC greeting with follow-up +-- 4. Example: NPC greeting with follow-up -- --------------------------------------------------------------------------- function npc_greeting(npc_name, greeting_text) -- Show initial greeting - ecs.send_event("dialogue_show", { - text = greeting_text, - speaker = npc_name, - choices = { "Who are you?", "Tell me about this place", "Goodbye" } - }) + ecs.dialogue.show( + greeting_text, + { "Who are you?", "Tell me about this place", "Goodbye" }, + npc_name + ) - -- The choice subscriber will handle the response. - -- You could extend this with a lookup table for NPC responses. + -- The choice handling would be done via C++ callback wiring or + -- by polling is_active() in your game loop. end npc_greeting("Elder Marcus", "Ah, a new face in our village! Welcome, traveler.") +-- --------------------------------------------------------------------------- +-- 5. Example: Chain multiple dialogue lines +-- --------------------------------------------------------------------------- + +function show_dialogue_sequence(lines) + for i, line in ipairs(lines) do + ecs.dialogue.show(line.text, line.choices or {}, line.speaker or "") + print(" [Line " .. i .. "] " .. (line.speaker or "") .. ": \"" .. line.text .. "\"") + end +end + +local intro_sequence = { + { text = "The storm rages outside.", speaker = "Narrator" }, + { text = "You find shelter in an abandoned tower.", speaker = "Narrator" }, + { text = "A voice calls from the shadows...", speaker = "Narrator" }, + { text = "Who dares enter my sanctuary?", speaker = "Mysterious Voice", + choices = { "I seek shelter from the storm", "I mean no harm", "I was sent here" } } +} + +show_dialogue_sequence(intro_sequence) + -- ============================================================================= -- Summary -- ============================================================================= --- To handle dialogue choices from Lua: --- 1. Subscribe to "dialogue_choice" events --- 2. Check params.choice_index (1-based) to see which was picked --- 3. Check params.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 +-- To handle dialogue events from Lua: +-- 1. Subscribe to "dialogue_show" events for logging or side effects +-- 2. Subscribe to "dialogue_hide" events for cleanup +-- 3. Use ecs.dialogue.show() / ecs.dialogue.hide() for direct control +-- 4. For choice reactions, wire C++ callbacks to Lua events, or +-- poll ecs.dialogue.is_active() in your update loop -- -- EventParams uses flat key-value pairs. Type metadata is available --- via params._types table (e.g., params._types.choice_index = "int"). +-- via params._types table (e.g., params._types.text = "string"). -- ============================================================================= print("Dialogue event subscription examples completed!") diff --git a/src/features/editScene/lua-examples/dialogue_sequence.lua b/src/features/editScene/lua-examples/dialogue_sequence.lua index 8720322..17c0887 100644 --- a/src/features/editScene/lua-examples/dialogue_sequence.lua +++ b/src/features/editScene/lua-examples/dialogue_sequence.lua @@ -7,21 +7,15 @@ -- -- The pattern: -- 1. Show dialogue with choices --- 2. Wait for player to select a choice (via event subscription) +-- 2. Wait for player to select a choice (via callback or polling) -- 3. React and show next dialogue based on the choice -- 4. Repeat until the conversation ends +-- +-- Note: DialogueSystem is now a singleton. No ECS entity/component needed. -- ============================================================================= -- --------------------------------------------------------------------------- --- 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 +-- 1. Dialogue Queue System -- --------------------------------------------------------------------------- -- A simple queue that lets you chain dialogue lines and wait for player -- input between each one. @@ -32,25 +26,12 @@ 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.choice_index or 0 - dialogue_queue_choice_text = params.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) +-- Since choice selection is handled via C++ callbacks, in a real game +-- you'd wire those callbacks to set these variables. For this example, +-- we demonstrate the pattern using polling. -- --------------------------------------------------------------------------- --- 3. Helper: Show dialogue and wait for player response +-- 2. Helper: Show dialogue and wait for player response -- --------------------------------------------------------------------------- --- Show a line of dialogue and wait for the player to respond. @@ -59,12 +40,8 @@ end) --- @param choices table|nil Array of choice label strings (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", { - text = text, - speaker = speaker or "", - choices = choices or {} - }) + -- Show dialogue via the singleton API + ecs.dialogue.show(text, choices or {}, speaker or "") -- Wait for player response dialogue_queue_pending = true @@ -76,6 +53,9 @@ function show_and_wait(text, speaker, choices) 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. + if not ecs.dialogue.is_active() then + dialogue_queue_pending = false + end timeout = timeout - 1 if timeout <= 0 then dialogue_queue_pending = false @@ -87,33 +67,34 @@ function show_and_wait(text, speaker, choices) end -- --------------------------------------------------------------------------- --- 4. Example: Simple linear conversation +-- 3. Example: Simple linear conversation -- --------------------------------------------------------------------------- function simple_conversation() print("=== Simple Conversation ===") -- Line 1: Narration with no choices (click to continue) - ecs.send_event("dialogue_show", { - text = "The old man sits by the fire, staring into the flames.", - speaker = "Narrator" - }) + ecs.dialogue.show( + "The old man sits by the fire, staring into the flames.", + {}, + "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", { - 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" } - }) + ecs.dialogue.show( + "I've been expecting you. The darkness grows stronger each day.", + { "Tell me more", "How can I help?", "I must go" }, + "Old Man" + ) print(" (Player would now see choices and pick one)") end -- --------------------------------------------------------------------------- --- 5. Example: Branching conversation tree +-- 4. Example: Branching conversation tree -- --------------------------------------------------------------------------- -- Define a conversation tree as a table of nodes @@ -222,12 +203,8 @@ function run_conversation(tree, start_node) end end - -- Show the dialogue - ecs.send_event("dialogue_show", { - text = node.text, - speaker = node.speaker or "", - choices = choices - }) + -- Show the dialogue via singleton API + ecs.dialogue.show(node.text, choices, node.speaker or "") -- In a real game, you'd wait for the player's choice here. -- For this example, we simulate by picking the first choice. @@ -250,7 +227,7 @@ end run_conversation(conversations.village_elder, "greeting") -- --------------------------------------------------------------------------- --- 6. Example: NPC dialogue with state tracking +-- 5. Example: NPC dialogue with state tracking -- --------------------------------------------------------------------------- -- Track NPC dialogue state @@ -267,34 +244,34 @@ function talk_to_elder_marcus() npc_state.marcus_met = true npc_state.marcus_friendship = 10 - ecs.send_event("dialogue_show", { - 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" } - }) + ecs.dialogue.show( + "Ah, a new face! I am Elder Marcus, keeper of this village.\nIt's been so long since we had visitors.", + { "Pleasure to meet you", "I've heard stories about you", "Hello" }, + "Elder Marcus" + ) 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", { - 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" } - }) + ecs.dialogue.show( + "You did it! The village is safe thanks to you.\nPlease, take this reward.", + { "Thank you, elder", "I was happy to help" }, + "Elder Marcus" + ) elseif npc_state.quest_active then -- Quest in progress - ecs.send_event("dialogue_show", { - 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" } - }) + ecs.dialogue.show( + "Have you dealt with those bandits yet?\nThe villagers are growing anxious.", + { "I'm working on it", "I need more information", "Not yet" }, + "Elder Marcus" + ) else -- Regular greeting - ecs.send_event("dialogue_show", { - text = "Welcome back, friend. The village is peaceful today.", - speaker = "Elder Marcus", - choices = { "Any news?", "I need supplies", "Goodbye" } - }) + ecs.dialogue.show( + "Welcome back, friend. The village is peaceful today.", + { "Any news?", "I need supplies", "Goodbye" }, + "Elder Marcus" + ) end end @@ -310,11 +287,11 @@ 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 +-- 1. Use ecs.dialogue.show() to display each line +-- 2. Wait for player input between lines (callback or polling) +-- 3. Use conversation trees for branching narratives +-- 4. Track NPC state to change dialogue based on game progress +-- 5. No ECS entity needed — DialogueSystem is a singleton -- ============================================================================= print("Dialogue sequence examples completed!") diff --git a/src/features/editScene/lua/LuaCharacterClassApi.cpp b/src/features/editScene/lua/LuaCharacterClassApi.cpp new file mode 100644 index 0000000..b33c3aa --- /dev/null +++ b/src/features/editScene/lua/LuaCharacterClassApi.cpp @@ -0,0 +1,352 @@ +#include "LuaCharacterClassApi.hpp" +#include "../systems/CharacterClassSystem.hpp" +#include "../components/CharacterClassDatabase.hpp" +#include "../components/CharacterClassComponent.hpp" +#include "../components/GoapBlackboard.hpp" +#include + +namespace editScene +{ + +// --------------------------------------------------------------------------- +// 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(lua_touserdata(L, -1)); + lua_pop(L, 1); + return *world; +} + +// --------------------------------------------------------------------------- +// Helper: push string-vector as Lua table +// --------------------------------------------------------------------------- + +static void pushStringVector(lua_State *L, + const std::vector &vec) +{ + lua_newtable(L); + for (size_t i = 0; i < vec.size(); i++) { + lua_pushstring(L, vec[i].c_str()); + lua_rawseti(L, -2, static_cast(i + 1)); + } +} + +// --------------------------------------------------------------------------- +// Database queries +// --------------------------------------------------------------------------- + +static int luaGetClassNames(lua_State *L) +{ + pushStringVector(L, + CharacterClassDatabase::getSingleton() + .getClassNames()); + return 1; +} + +static int luaGetStatNames(lua_State *L) +{ + pushStringVector(L, + CharacterClassDatabase::getSingleton() + .getStatNames()); + return 1; +} + +static int luaGetSkillNames(lua_State *L) +{ + pushStringVector(L, + CharacterClassDatabase::getSingleton() + .getSkillNames()); + return 1; +} + +static int luaGetNeedNames(lua_State *L) +{ + pushStringVector(L, + CharacterClassDatabase::getSingleton() + .getNeedNames()); + return 1; +} + +static int luaGetClass(lua_State *L) +{ + const char *name = lua_tostring(L, 1); + if (!name) + return 0; + + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + name); + if (!cls) + return 0; + + lua_newtable(L); + lua_pushstring(L, cls->name.c_str()); + lua_setfield(L, -2, "name"); + lua_pushstring(L, cls->description.c_str()); + lua_setfield(L, -2, "description"); + pushStringVector(L, cls->primaryStats); + lua_setfield(L, -2, "primary_stats"); + return 1; +} + +static int luaGetStatKind(lua_State *L) +{ + const char *name = lua_tostring(L, 1); + if (!name) { + lua_pushstring(L, "unknown"); + return 1; + } + const auto *def = CharacterClassDatabase::getSingleton().findStat(name); + if (!def) { + lua_pushstring(L, "unknown"); + return 1; + } + lua_pushstring(L, def->kind == CharacterClassDatabase::StatKind::ResourcePool ? + "resource_pool" : "attribute"); + return 1; +} + +// --------------------------------------------------------------------------- +// Per-entity runtime API +// --------------------------------------------------------------------------- + +static int luaGetLevel(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has()) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, e.get().level); + return 1; +} + +static int luaGetXP(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has()) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, + (lua_Integer)e.get().currentXP); + return 1; +} + +static int luaAddXP(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + int64_t amount = static_cast(lua_tointeger(L, 2)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has()) { + lua_pushboolean(L, 0); + return 1; + } + // We can't easily access CharacterClassSystem singleton here, + // so we do the XP add directly and let the system pick it up + auto &cc = e.get_mut(); + cc.currentXP += amount; + lua_pushboolean(L, 1); + return 1; +} + +static int luaGetStat(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *statName = lua_tostring(L, 2); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !statName) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, + (lua_Integer)e.get().getStat( + statName)); + return 1; +} + +static int luaGetSkill(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *skillName = lua_tostring(L, 2); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !skillName) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, + (lua_Integer)e.get().getSkill( + skillName)); + return 1; +} + +static int luaGetNeed(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *needName = lua_tostring(L, 2); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !needName) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, + (lua_Integer)e.get().getNeed( + needName)); + return 1; +} + +static int luaGetAvailablePoints(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has()) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, + (lua_Integer)e.get() + .availablePoints); + return 1; +} + +static int luaSetNeed(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *needName = lua_tostring(L, 2); + int value = static_cast(lua_tointeger(L, 3)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !needName) { + return 0; + } + auto &cc = e.get_mut(); + cc.needs[needName] = value; + return 0; +} + +static int luaGetPoolCurrent(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *poolName = lua_tostring(L, 2); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !poolName) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, (lua_Integer)e.get(). + getPoolCurrent(poolName)); + return 1; +} + +static int luaGetPoolMax(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *poolName = lua_tostring(L, 2); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !poolName) { + lua_pushinteger(L, 0); + return 1; + } + lua_pushinteger(L, (lua_Integer)e.get(). + getPoolMax(poolName)); + return 1; +} + +static int luaSetPoolCurrent(lua_State *L) +{ + int entityId = static_cast(lua_tointeger(L, 1)); + const char *poolName = lua_tostring(L, 2); + int value = static_cast(lua_tointeger(L, 3)); + flecs::entity e = getWorld(L).entity(entityId); + if (!e.is_alive() || !e.has() || + !poolName) { + lua_pushboolean(L, 0); + return 1; + } + e.get_mut().setPoolCurrent(poolName, value); + lua_pushboolean(L, 1); + return 1; +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +void registerLuaCharacterClassApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + } + + lua_newtable(L); + + lua_pushcfunction(L, luaGetClassNames); + lua_setfield(L, -2, "get_class_names"); + + lua_pushcfunction(L, luaGetStatNames); + lua_setfield(L, -2, "get_stat_names"); + + lua_pushcfunction(L, luaGetSkillNames); + lua_setfield(L, -2, "get_skill_names"); + + lua_pushcfunction(L, luaGetNeedNames); + lua_setfield(L, -2, "get_need_names"); + + lua_pushcfunction(L, luaGetClass); + lua_setfield(L, -2, "get_class"); + + lua_pushcfunction(L, luaGetStatKind); + lua_setfield(L, -2, "get_stat_kind"); + + lua_pushcfunction(L, luaGetLevel); + lua_setfield(L, -2, "get_level"); + + lua_pushcfunction(L, luaGetXP); + lua_setfield(L, -2, "get_xp"); + + lua_pushcfunction(L, luaAddXP); + lua_setfield(L, -2, "add_xp"); + + lua_pushcfunction(L, luaGetStat); + lua_setfield(L, -2, "get_stat"); + + lua_pushcfunction(L, luaGetSkill); + lua_setfield(L, -2, "get_skill"); + + lua_pushcfunction(L, luaGetNeed); + lua_setfield(L, -2, "get_need"); + + lua_pushcfunction(L, luaGetAvailablePoints); + lua_setfield(L, -2, "get_available_points"); + + lua_pushcfunction(L, luaSetNeed); + lua_setfield(L, -2, "set_need"); + + lua_pushcfunction(L, luaGetPoolCurrent); + lua_setfield(L, -2, "get_pool_current"); + + lua_pushcfunction(L, luaGetPoolMax); + lua_setfield(L, -2, "get_pool_max"); + + lua_pushcfunction(L, luaSetPoolCurrent); + lua_setfield(L, -2, "set_pool_current"); + + lua_setfield(L, -2, "character_class"); + + lua_setglobal(L, "ecs"); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaCharacterClassApi.hpp b/src/features/editScene/lua/LuaCharacterClassApi.hpp new file mode 100644 index 0000000..389641a --- /dev/null +++ b/src/features/editScene/lua/LuaCharacterClassApi.hpp @@ -0,0 +1,13 @@ +#ifndef EDITSCENE_LUA_CHARACTER_CLASS_API_HPP +#define EDITSCENE_LUA_CHARACTER_CLASS_API_HPP + +#include + +namespace editScene +{ + +void registerLuaCharacterClassApi(lua_State *L); + +} // namespace editScene + +#endif // EDITSCENE_LUA_CHARACTER_CLASS_API_HPP diff --git a/src/features/editScene/lua/LuaComponentApi.cpp b/src/features/editScene/lua/LuaComponentApi.cpp index 3973867..944f791 100644 --- a/src/features/editScene/lua/LuaComponentApi.cpp +++ b/src/features/editScene/lua/LuaComponentApi.cpp @@ -37,7 +37,6 @@ #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/ActionDatabase.hpp" @@ -500,6 +499,21 @@ static void registerAllComponents() lua_pushstring(L, kv.second.c_str()); lua_setfield(L, -2, kv.first.c_str()); } lua_setfield(L, -2, "slots"); + // slotSelections map: push as nested table + lua_newtable(L); + for (auto &kv : c.slotSelections) { + lua_newtable(L); + lua_pushstring(L, kv.second.layer1Mesh.c_str()); + lua_setfield(L, -2, "layer1Mesh"); + lua_pushstring(L, kv.second.layer2Mesh.c_str()); + lua_setfield(L, -2, "layer2Mesh"); + lua_pushstring(L, kv.second.explicitMesh.c_str()); + lua_setfield(L, -2, "explicitMesh"); + lua_setfield(L, -2, kv.first.c_str()); + } + lua_setfield(L, -2, "slotSelections"); + lua_pushinteger(L, c.outfitLevel); + lua_setfield(L, -2, "outfitLevel"); pushVector3(L, c.frontAxis); lua_setfield(L, -2, "frontAxis"); , if (lua_getfield(L, idx, "age"), lua_isstring(L, -1)) c.age = lua_tostring(L, -1); @@ -507,6 +521,9 @@ static void registerAllComponents() if (lua_getfield(L, idx, "sex"), lua_isstring(L, -1)) c.sex = lua_tostring(L, -1); lua_pop(L, 1); + if (lua_getfield(L, idx, "outfitLevel"), lua_isnumber(L, -1)) + c.outfitLevel = lua_tointeger(L, -1); + lua_pop(L, 1); if (lua_getfield(L, idx, "slots"), lua_istable(L, -1)) { c.slots.clear(); lua_pushnil(L); @@ -517,10 +534,48 @@ static void registerAllComponents() lua_pop(L, 1); } } lua_pop(L, 1); + if (lua_getfield(L, idx, "slotSelections"), lua_istable(L, -1)) { + c.slotSelections.clear(); + lua_pushnil(L); + while (lua_next(L, -2) != 0) { + if (lua_isstring(L, -2) && lua_istable(L, -1)) { + SlotSelection sel; + Ogre::String slotName = lua_tostring(L, -2); + if (lua_getfield(L, -1, "layer1Mesh"), lua_isstring(L, -1)) + sel.layer1Mesh = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "layer2Mesh"), lua_isstring(L, -1)) + sel.layer2Mesh = lua_tostring(L, -1); + lua_pop(L, 1); + if (lua_getfield(L, -1, "explicitMesh"), lua_isstring(L, -1)) + sel.explicitMesh = lua_tostring(L, -1); + lua_pop(L, 1); + c.slotSelections[slotName] = sel; + } + lua_pop(L, 1); + } + } lua_pop(L, 1); if (lua_getfield(L, idx, "frontAxis"), lua_istable(L, -1)) c.frontAxis = readVector3(L, lua_gettop(L)); lua_pop(L, 1);); + // --- CharacterShapeKeys --- + REGISTER_COMPONENT( + CharacterShapeKeysComponent, "CharacterShapeKeys", + lua_newtable(L); + for (auto &kv : c.weights) { + lua_pushnumber(L, kv.second); + lua_setfield(L, -2, kv.first.c_str()); + } + , if (lua_istable(L, idx)) { + lua_pushnil(L); + while (lua_next(L, idx) != 0) { + if (lua_isstring(L, -2) && lua_isnumber(L, -1)) + c.weights[lua_tostring(L, -2)] = lua_tonumber(L, -1); + lua_pop(L, 1); + } + }); + // --- AnimationTree --- REGISTER_COMPONENT( AnimationTreeComponent, "AnimationTree", @@ -1245,52 +1300,6 @@ static void registerAllComponents() c.showQuit = lua_toboolean(L, -1) != 0; lua_pop(L, 1);); - // --- Dialogue --- - REGISTER_COMPONENT( - DialogueComponent, "Dialogue", - lua_pushstring(L, c.text.c_str()); - lua_setfield(L, -2, "text"); - lua_pushstring(L, c.speaker.c_str()); - lua_setfield(L, -2, "speaker"); pushStringVector(L, c.choices); - lua_setfield(L, -2, "choices"); - lua_pushstring(L, c.fontName.c_str()); - lua_setfield(L, -2, "fontName"); lua_pushnumber(L, c.fontSize); - lua_setfield(L, -2, "fontSize"); - lua_pushnumber(L, c.backgroundOpacity); - lua_setfield(L, -2, "backgroundOpacity"); - lua_pushnumber(L, c.boxHeightFraction); - lua_setfield(L, -2, "boxHeightFraction"); - lua_pushnumber(L, c.boxPositionFraction); - lua_setfield(L, -2, "boxPositionFraction"); - lua_pushboolean(L, c.enabled ? 1 : 0); - lua_setfield(L, -2, "enabled"); - , if (lua_getfield(L, idx, "text"), lua_isstring(L, -1)) - c.text = lua_tostring(L, -1); - lua_pop(L, 1); - if (lua_getfield(L, idx, "speaker"), lua_isstring(L, -1)) - c.speaker = lua_tostring(L, -1); - lua_pop(L, 1); - if (lua_getfield(L, idx, "choices"), lua_istable(L, -1)) - c.choices = readStringVector(L, lua_gettop(L)); - lua_pop(L, 1); - if (lua_getfield(L, idx, "fontName"), lua_isstring(L, -1)) - c.fontName = lua_tostring(L, -1); - lua_pop(L, 1); - if (lua_getfield(L, idx, "fontSize"), lua_isnumber(L, -1)) - c.fontSize = (float)lua_tonumber(L, -1); - lua_pop(L, 1); if (lua_getfield(L, idx, "backgroundOpacity"), - lua_isnumber(L, -1)) c.backgroundOpacity = - (float)lua_tonumber(L, -1); - lua_pop(L, 1); if (lua_getfield(L, idx, "boxHeightFraction"), - lua_isnumber(L, -1)) c.boxHeightFraction = - (float)lua_tonumber(L, -1); - lua_pop(L, 1); if (lua_getfield(L, idx, "boxPositionFraction"), - lua_isnumber(L, -1)) c.boxPositionFraction = - (float)lua_tonumber(L, -1); - lua_pop(L, 1); - if (lua_getfield(L, idx, "enabled"), lua_isboolean(L, -1)) - c.enabled = lua_toboolean(L, -1) != 0; - lua_pop(L, 1);); // --- PlayerController --- REGISTER_COMPONENT( diff --git a/src/features/editScene/lua/LuaDialogueApi.cpp b/src/features/editScene/lua/LuaDialogueApi.cpp new file mode 100644 index 0000000..12a48b5 --- /dev/null +++ b/src/features/editScene/lua/LuaDialogueApi.cpp @@ -0,0 +1,199 @@ +#include "LuaDialogueApi.hpp" +#include "../systems/DialogueSystem.hpp" +#include + +namespace editScene +{ + +// --------------------------------------------------------------------------- +// Helper: read string vector from Lua table +// --------------------------------------------------------------------------- + +static std::vector readStringVector(lua_State *L, int idx) +{ + std::vector result; + if (!lua_istable(L, idx)) + return result; + + lua_pushnil(L); + while (lua_next(L, idx) != 0) { + if (lua_isstring(L, -1)) + result.push_back(lua_tostring(L, -1)); + lua_pop(L, 1); + } + return result; +} + +// --------------------------------------------------------------------------- +// Lua CFunctions +// --------------------------------------------------------------------------- + +static int luaDialogueShow(lua_State *L) +{ + Ogre::String text; + std::vector choices; + Ogre::String speaker; + + if (lua_gettop(L) >= 1 && lua_isstring(L, 1)) + text = lua_tostring(L, 1); + + if (lua_gettop(L) >= 2 && lua_istable(L, 2)) + choices = readStringVector(L, 2); + + if (lua_gettop(L) >= 3 && lua_isstring(L, 3)) + speaker = lua_tostring(L, 3); + + DialogueSystem::getInstance().show(text, choices, speaker); + return 0; +} + +static int luaDialogueHide(lua_State *L) +{ + (void)L; + DialogueSystem::getInstance().hide(); + return 0; +} + +static int luaDialogueIsActive(lua_State *L) +{ + lua_pushboolean(L, DialogueSystem::getInstance().isActive() ? 1 : 0); + return 1; +} + +static int luaDialogueSelectChoice(lua_State *L) +{ + int index = 0; + if (lua_gettop(L) >= 1 && lua_isnumber(L, 1)) + index = (int)lua_tonumber(L, 1); + DialogueSystem::getInstance().selectChoice(index); + return 0; +} + +static int luaDialogueProgress(lua_State *L) +{ + (void)L; + DialogueSystem::getInstance().progress(); + return 0; +} + +static int luaDialogueGetSettings(lua_State *L) +{ + const auto &s = DialogueSystem::getInstance().getSettings(); + lua_newtable(L); + lua_pushstring(L, s.fontName.c_str()); + lua_setfield(L, -2, "font_name"); + lua_pushnumber(L, s.fontSize); + lua_setfield(L, -2, "font_size"); + lua_pushnumber(L, s.speakerFontSize); + lua_setfield(L, -2, "speaker_font_size"); + lua_pushnumber(L, s.backgroundOpacity); + lua_setfield(L, -2, "background_opacity"); + lua_pushnumber(L, s.boxHeightFraction); + lua_setfield(L, -2, "box_height_fraction"); + lua_pushnumber(L, s.boxPositionFraction); + lua_setfield(L, -2, "box_position_fraction"); + return 1; +} + +static int luaDialogueSetSettings(lua_State *L) +{ + if (!lua_istable(L, 1)) + return 0; + + auto s = DialogueSystem::getInstance().getSettings(); + + if (lua_getfield(L, 1, "font_name"), lua_isstring(L, -1)) + s.fontName = lua_tostring(L, -1); + lua_pop(L, 1); + + if (lua_getfield(L, 1, "font_size"), lua_isnumber(L, -1)) + s.fontSize = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + if (lua_getfield(L, 1, "speaker_font_size"), lua_isnumber(L, -1)) + s.speakerFontSize = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + if (lua_getfield(L, 1, "background_opacity"), lua_isnumber(L, -1)) + s.backgroundOpacity = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + if (lua_getfield(L, 1, "box_height_fraction"), lua_isnumber(L, -1)) + s.boxHeightFraction = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + if (lua_getfield(L, 1, "box_position_fraction"), lua_isnumber(L, -1)) + s.boxPositionFraction = (float)lua_tonumber(L, -1); + lua_pop(L, 1); + + DialogueSystem::getInstance().setSettings(s); + return 0; +} + +static int luaDialogueSaveSettings(lua_State *L) +{ + const char *path = "dialogue.json"; + if (lua_gettop(L) >= 1 && lua_isstring(L, 1)) + path = lua_tostring(L, 1); + bool ok = DialogueSystem::getInstance().saveSettings(path); + lua_pushboolean(L, ok ? 1 : 0); + return 1; +} + +static int luaDialogueLoadSettings(lua_State *L) +{ + const char *path = "dialogue.json"; + if (lua_gettop(L) >= 1 && lua_isstring(L, 1)) + path = lua_tostring(L, 1); + bool ok = DialogueSystem::getInstance().loadSettings(path); + lua_pushboolean(L, ok ? 1 : 0); + return 1; +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +void registerLuaDialogueApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + } + + lua_newtable(L); + + lua_pushcfunction(L, luaDialogueShow); + lua_setfield(L, -2, "show"); + + lua_pushcfunction(L, luaDialogueHide); + lua_setfield(L, -2, "hide"); + + lua_pushcfunction(L, luaDialogueIsActive); + lua_setfield(L, -2, "is_active"); + + lua_pushcfunction(L, luaDialogueSelectChoice); + lua_setfield(L, -2, "select_choice"); + + lua_pushcfunction(L, luaDialogueProgress); + lua_setfield(L, -2, "progress"); + + lua_pushcfunction(L, luaDialogueGetSettings); + lua_setfield(L, -2, "get_settings"); + + lua_pushcfunction(L, luaDialogueSetSettings); + lua_setfield(L, -2, "set_settings"); + + lua_pushcfunction(L, luaDialogueSaveSettings); + lua_setfield(L, -2, "save_settings"); + + lua_pushcfunction(L, luaDialogueLoadSettings); + lua_setfield(L, -2, "load_settings"); + + lua_setfield(L, -2, "dialogue"); + + lua_setglobal(L, "ecs"); +} + +} // namespace editScene diff --git a/src/features/editScene/lua/LuaDialogueApi.hpp b/src/features/editScene/lua/LuaDialogueApi.hpp new file mode 100644 index 0000000..df1dc68 --- /dev/null +++ b/src/features/editScene/lua/LuaDialogueApi.hpp @@ -0,0 +1,14 @@ +#ifndef EDITSCENE_LUA_DIALOGUE_API_HPP +#define EDITSCENE_LUA_DIALOGUE_API_HPP +#pragma once + +#include + +namespace editScene +{ + +void registerLuaDialogueApi(lua_State *L); + +} // namespace editScene + +#endif // EDITSCENE_LUA_DIALOGUE_API_HPP diff --git a/src/features/editScene/systems/CharacterClassSystem.cpp b/src/features/editScene/systems/CharacterClassSystem.cpp new file mode 100644 index 0000000..17dffd8 --- /dev/null +++ b/src/features/editScene/systems/CharacterClassSystem.cpp @@ -0,0 +1,554 @@ +#include "CharacterClassSystem.hpp" +#include "../EditorApp.hpp" +#include "../components/CharacterClassComponent.hpp" +#include "../components/CharacterClassDatabase.hpp" +#include "../components/GoapBlackboard.hpp" +#include "../components/PlayerController.hpp" +#include "../components/Inventory.hpp" +#include +#include +#include + +CharacterClassSystem::CharacterClassSystem(flecs::world &world, + EditorApp *editorApp) + : m_world(world) + , m_editorApp(editorApp) +{ +} + +CharacterClassSystem::~CharacterClassSystem() +{ +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +bool CharacterClassSystem::addXP(flecs::entity entity, int64_t amount) +{ + if (!entity.is_alive() || !entity.has()) + return false; + + auto &cc = entity.get_mut(); + cc.currentXP += amount; + + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return false; + + int64_t needed = CharacterClassDatabase::getSingleton() + .computeXPForLevel(cc.level, *cls); + if (cc.currentXP >= needed) { + applyLevelUp(entity); + return true; + } + return false; +} + +bool CharacterClassSystem::distributePoint(flecs::entity entity, + const Ogre::String &statName) +{ + if (!entity.is_alive() || !entity.has()) + return false; + + auto &cc = entity.get_mut(); + if (cc.availablePoints <= 0) + return false; + + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return false; + + const auto *statDef = CharacterClassDatabase::getSingleton().findStat( + statName); + if (!statDef) + return false; + + int current = cc.getStat(statName); + int cost = CharacterClassDatabase::getSingleton().computeStatCost( + current, *cls); + if (cc.availablePoints < cost) + return false; + + cc.availablePoints -= cost; + cc.stats[statName] = current + 1; + return true; +} + +void CharacterClassSystem::applyLevelUpGrowthAndPoints(flecs::entity entity) +{ + if (!entity.is_alive() || !entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return; + + auto &db = CharacterClassDatabase::getSingleton(); + + // Auto-grow stats + for (const auto &pair : cls->statGrowth) { + int growth = db.computeStatGrowth(pair.first, cc.level, *cls); + cc.stats[pair.first] += growth; + } + + // Auto-grow skills + for (const auto &pair : cls->skillGrowth) { + int growth = db.computeSkillGrowth(pair.first, cc.level, *cls); + cc.skills[pair.first] += growth; + const auto *skillDef = db.findSkill(pair.first); + if (skillDef && cc.skills[pair.first] > skillDef->maxValue) + cc.skills[pair.first] = skillDef->maxValue; + } + + // Grant points + cc.availablePoints += db.computePointsForLevel(cc.level, *cls); +} + +void CharacterClassSystem::initializeFromClass(flecs::entity entity) +{ + if (!entity.is_alive() || !entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return; + + int targetLevel = cc.level; + if (targetLevel < 1) + targetLevel = 1; + + // Reset to base values at level 1 + cc.level = 1; + cc.availablePoints = 0; + + // Base stats + overrides + cc.stats = cls->baseStats; + if (entity.has()) { + const auto &ov = entity.get(); + for (const auto &pair : ov.statOffsets) { + cc.stats[pair.first] += pair.second; + } + } + + // Base skills + overrides + cc.skills = cls->baseSkills; + if (entity.has()) { + const auto &ov = entity.get(); + for (const auto &pair : ov.skillOffsets) { + cc.skills[pair.first] += pair.second; + } + } + + // Base needs + offsets + cc.needs = cls->baseNeeds; + if (entity.has()) { + const auto &ov = entity.get(); + for (const auto &pair : ov.needOffsets) { + cc.needs[pair.first] += pair.second; + } + } + + // Ensure every database-defined stat/skill/need exists with a default + auto &db = CharacterClassDatabase::getSingleton(); + for (const auto &name : db.getStatNames()) { + if (cc.stats.find(name) == cc.stats.end()) { + const auto *def = db.findStat(name); + cc.stats[name] = def ? def->minValue : 1; + } + } + for (const auto &name : db.getSkillNames()) { + if (cc.skills.find(name) == cc.skills.end()) + cc.skills[name] = 0; + } + for (const auto &name : db.getNeedNames()) { + if (cc.needs.find(name) == cc.needs.end()) + cc.needs[name] = 0; + } + + // Simulate level-ups from 2 to targetLevel + for (int lvl = 2; lvl <= targetLevel; ++lvl) { + cc.level = lvl; + applyLevelUpGrowthAndPoints(entity); + distributePointsAI(entity); + } + + // All points should have been spent during simulation + cc.availablePoints = 0; + + // Initialize resource pools to full + for (const auto &name : db.getStatNames()) { + const auto *def = db.findStat(name); + if (def && def->kind == + CharacterClassDatabase::StatKind::ResourcePool) + cc.currentPools[name] = cc.getPoolMax(name); + } +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +void CharacterClassSystem::update(float deltaTime) +{ + accumulateNeeds(deltaTime); + checkLevelUps(); +} + +void CharacterClassSystem::accumulateNeeds(float deltaTime) +{ + auto &db = CharacterClassDatabase::getSingleton(); + + m_world.query().each( + [&](flecs::entity e, CharacterClassComponent &cc) { + const auto *cls = db.findClass(cc.className); + if (!cls) + return; + + for (auto &pair : cc.needs) { + const auto *needDef = db.findNeed(pair.first); + if (!needDef) + continue; + + pair.second += static_cast(needDef->accumulationRate * + deltaTime); + if (pair.second > needDef->maxValue) + pair.second = needDef->maxValue; + if (pair.second < 0) + pair.second = 0; + } + + updateNeedBits(e); + }); +} + +void CharacterClassSystem::updateNeedBits(flecs::entity entity) +{ + if (!entity.has()) + return; + + auto &cc = entity.get_mut(); + auto &db = CharacterClassDatabase::getSingleton(); + + if (!entity.has()) + entity.set({}); + + auto &bb = entity.get_mut(); + + for (const auto &pair : cc.needs) { + const auto *needDef = db.findNeed(pair.first); + if (!needDef || needDef->bitName.empty()) + continue; + + int bitIdx = GoapBlackboard::findBitByName(needDef->bitName); + if (bitIdx < 0) + continue; + + int value = pair.second; + bool currentlySet = bb.getBit(bitIdx); + + // Hysteresis: set at high threshold, clear at low threshold + if (value >= needDef->highThreshold) + bb.setBit(bitIdx, true); + else if (value <= needDef->lowThreshold) + bb.setBit(bitIdx, false); + // else: keep current state (dead zone between thresholds) + } +} + +void CharacterClassSystem::checkLevelUps() +{ + m_world.query().each( + [&](flecs::entity e, CharacterClassComponent &cc) { + if (cc.levelUpPending) + return; + + const auto *cls = CharacterClassDatabase::getSingleton() + .findClass(cc.className); + if (!cls) + return; + + int64_t needed = CharacterClassDatabase::getSingleton() + .computeXPForLevel(cc.level, *cls); + if (cc.currentXP >= needed) { + applyLevelUp(e); + } + }); +} + +void CharacterClassSystem::applyLevelUp(flecs::entity entity) +{ + if (!entity.is_alive() || !entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return; + + int64_t needed = CharacterClassDatabase::getSingleton().computeXPForLevel( + cc.level, *cls); + cc.currentXP -= needed; + cc.level++; + + applyLevelUpGrowthAndPoints(entity); + + // Check if player + bool isPlayer = entity.has(); + if (isPlayer && m_editorApp && + m_editorApp->getGameMode() == EditorApp::GameMode::Game && + m_editorApp->getGamePlayState() == + EditorApp::GamePlayState::Playing) { + cc.levelUpPending = true; + m_levelUpDialogs.insert(entity.id()); + } else { + // AI: auto-distribute immediately + distributePointsAI(entity); + } + + Ogre::LogManager::getSingleton().logMessage( + Ogre::String("CharacterClassSystem: ") + + Ogre::String(entity.name()) + + " reached level " + std::to_string(cc.level) + "!"); +} + +void CharacterClassSystem::distributePointsAI(flecs::entity entity) +{ + if (!entity.is_alive() || !entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return; + + int points = cc.availablePoints; + + // Round-robin primary stats + int idx = 0; + int safety = 0; + while (points > 0 && !cls->primaryStats.empty() && + safety < 1000) { + safety++; + const auto &statName = + cls->primaryStats[idx % cls->primaryStats.size()]; + int current = cc.getStat(statName); + int cost = CharacterClassDatabase::getSingleton() + .computeStatCost(current, *cls); + if (points >= cost) { + cc.stats[statName] = current + 1; + points -= cost; + idx++; + } else { + idx++; + if (idx >= (int)cls->primaryStats.size() * 2) + break; + } + } + + // Random distribution for remainder + if (points > 0 && !cc.stats.empty()) { + std::vector statNames; + for (const auto &pair : cc.stats) + statNames.push_back(pair.first); + + int randomSafety = 0; + while (points > 0 && !statNames.empty() && + randomSafety < 1000) { + randomSafety++; + size_t r = rand() % statNames.size(); + const auto &statName = statNames[r]; + int current = cc.getStat(statName); + int cost = CharacterClassDatabase::getSingleton() + .computeStatCost(current, *cls); + if (points >= cost) { + cc.stats[statName] = current + 1; + points -= cost; + } else { + // Can't afford this stat, remove from pool + statNames.erase(statNames.begin() + r); + } + } + } + + cc.availablePoints = points; +} + +// --------------------------------------------------------------------------- +// Dialog rendering +// --------------------------------------------------------------------------- + +void CharacterClassSystem::renderDialogs() +{ + // Level-up dialogs + std::vector closedDialogs; + for (flecs::entity_t id : m_levelUpDialogs) { + flecs::entity e = m_world.entity(id); + if (!e.is_alive() || !e.has()) { + closedDialogs.push_back(id); + continue; + } + renderLevelUpDialog(e); + } + for (flecs::entity_t id : closedDialogs) + m_levelUpDialogs.erase(id); + + // Character sheets + std::vector closedSheets; + for (flecs::entity_t id : m_sheets) { + flecs::entity e = m_world.entity(id); + if (!e.is_alive() || !e.has()) { + closedSheets.push_back(id); + continue; + } + renderCharacterSheet(e); + } + for (flecs::entity_t id : closedSheets) + m_sheets.erase(id); +} + +void CharacterClassSystem::renderLevelUpDialog(flecs::entity entity) +{ + if (!entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + if (!cls) + return; + + ImGui::SetNextWindowPos(ImVec2(200, 200), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(450, 500), + ImGuiCond_FirstUseEver); + + Ogre::String title = "Level Up! (Level " + + std::to_string(cc.level) + ")"; + bool open = true; + if (!ImGui::Begin(title.c_str(), &open)) { + ImGui::End(); + return; + } + + ImGui::Text("Available Points: %d", cc.availablePoints); + ImGui::Separator(); + + auto &db = CharacterClassDatabase::getSingleton(); + + // Stats + if (!cc.stats.empty()) { + ImGui::Text("Stats"); + for (auto &pair : cc.stats) { + const auto *statDef = db.findStat(pair.first); + int current = pair.second; + int cost = db.computeStatCost(current, *cls); + bool canAfford = cc.availablePoints >= cost; + + ImGui::PushID(pair.first.c_str()); + ImGui::Text("%s: %d", pair.first.c_str(), current); + ImGui::SameLine(150); + ImGui::Text("Cost: %d", cost); + ImGui::SameLine(220); + if (ImGui::Button("+", ImVec2(30, 0)) && canAfford) { + cc.availablePoints -= cost; + pair.second = current + 1; + } + ImGui::PopID(); + } + } + + ImGui::Separator(); + + if (ImGui::Button("Confirm", ImVec2(100, 0))) { + cc.levelUpPending = false; + open = false; + } + ImGui::SameLine(); + if (ImGui::Button("Postpone", ImVec2(100, 0))) { + open = false; + } + + ImGui::End(); + + if (!open) { + m_levelUpDialogs.erase(entity.id()); + cc.levelUpPending = false; + } +} + +void CharacterClassSystem::renderCharacterSheet(flecs::entity entity) +{ + if (!entity.has()) + return; + + auto &cc = entity.get_mut(); + const auto *cls = CharacterClassDatabase::getSingleton().findClass( + cc.className); + + Ogre::String title = "Character Sheet"; + bool open = true; + ImGui::SetNextWindowPos(ImVec2(250, 150), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 500), + ImGuiCond_FirstUseEver); + + if (!ImGui::Begin(title.c_str(), &open)) { + ImGui::End(); + return; + } + + ImGui::Text("Class: %s", + cls ? cls->name.c_str() : cc.className.c_str()); + ImGui::Text("Level: %d", cc.level); + ImGui::Text("XP: %ld", (long)cc.currentXP); + ImGui::Text("Available Points: %d", cc.availablePoints); + ImGui::Separator(); + + // Stats + if (!cc.stats.empty()) { + ImGui::Text("Stats"); + for (const auto &pair : cc.stats) { + ImGui::Text(" %s: %d", pair.first.c_str(), + pair.second); + } + } + + // Skills + if (!cc.skills.empty()) { + ImGui::Text("Skills"); + for (const auto &pair : cc.skills) { + ImGui::Text(" %s: %d", pair.first.c_str(), + pair.second); + } + } + + // Needs + if (!cc.needs.empty()) { + ImGui::Text("Needs"); + for (const auto &pair : cc.needs) { + ImGui::Text(" %s: %d", pair.first.c_str(), + pair.second); + } + } + + ImGui::Separator(); + + // Inventory placeholder + if (entity.has()) { + ImGui::Text("Inventory available (not yet displayed)"); + } else { + ImGui::Text("No inventory"); + } + + ImGui::End(); + + if (!open) + m_sheets.erase(entity.id()); +} diff --git a/src/features/editScene/systems/CharacterClassSystem.hpp b/src/features/editScene/systems/CharacterClassSystem.hpp new file mode 100644 index 0000000..7545889 --- /dev/null +++ b/src/features/editScene/systems/CharacterClassSystem.hpp @@ -0,0 +1,60 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP +#define EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP +#pragma once + +#include +#include +#include + +class EditorApp; + +/** + * System that manages character progression, need accumulation, + * and level-up logic. + * + * - Accumulates needs each frame based on class database rates. + * - Sets/clears GOAP blackboard bits when needs cross thresholds. + * - Checks XP and triggers level-ups. + * - AI entities auto-distribute stat points. + * - Player entities get a level-up dialog. + */ +class CharacterClassSystem { +public: + CharacterClassSystem(flecs::world &world, EditorApp *editorApp); + ~CharacterClassSystem(); + + /** Call every frame. Handles need ticks, level-up checks, dialog. */ + void update(float deltaTime); + + /** Render level-up and character-sheet dialogs (inside ImGui frame). */ + void renderDialogs(); + + /** Manually add XP to an entity. Returns true if a level up occurred. */ + bool addXP(flecs::entity entity, int64_t amount); + + /** Distribute one point to a stat (player manual or scripted). */ + bool distributePoint(flecs::entity entity, const Ogre::String &statName); + + /** Compute initial stats/skills/needs from class + overrides. */ + static void initializeFromClass(flecs::entity entity); + +private: + void accumulateNeeds(float deltaTime); + void updateNeedBits(flecs::entity entity); + void checkLevelUps(); + void applyLevelUp(flecs::entity entity); + static void applyLevelUpGrowthAndPoints(flecs::entity entity); + static void distributePointsAI(flecs::entity entity); + void renderLevelUpDialog(flecs::entity entity); + void renderCharacterSheet(flecs::entity entity); + + flecs::world &m_world; + EditorApp *m_editorApp; + + // Track which entities have an open level-up dialog + std::unordered_set m_levelUpDialogs; + // Track which entities have an open character sheet + std::unordered_set m_sheets; +}; + +#endif // EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index 10b2924..0a1b954 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -1,12 +1,17 @@ #include "CharacterSlotSystem.hpp" #include "../components/Transform.hpp" #include "../components/AnimationTree.hpp" +#include +#include +#include #include #include +#include #include +#include #include #include -#include +#include bool CharacterSlotSystem::s_catalogLoaded = false; nlohmann::json CharacterSlotSystem::s_bodyParts = nlohmann::json::object(); @@ -62,6 +67,7 @@ void CharacterSlotSystem::loadCatalog() names->end()); } + for (const auto &name : partNames) { Ogre::String group = rgm.findGroupContainingResource(name); Ogre::DataStreamPtr stream = rgm.openResource(name, group); @@ -84,7 +90,8 @@ void CharacterSlotSystem::loadCatalog() if (!s_bodyParts[age][sex].contains(slot)) s_bodyParts[age][sex][slot] = nlohmann::json::array(); - s_bodyParts[age][sex][slot].push_back(mesh); + + s_bodyParts[age][sex][slot].push_back(jdata); s_meshNames.insert(mesh); /* Preload mesh into Characters group */ @@ -98,6 +105,7 @@ void CharacterSlotSystem::loadCatalog() } } + s_catalogLoaded = true; } @@ -139,6 +147,51 @@ std::vector CharacterSlotSystem::getSlots( return slots; } +std::vector CharacterSlotSystem::getMeshesForLayer( + const Ogre::String &age, const Ogre::String &sex, + const Ogre::String &slot, int layer) +{ + std::vector meshes; + if (!s_catalogLoaded || !s_bodyParts.contains(age) || + !s_bodyParts[age].contains(sex) || + !s_bodyParts[age][sex].contains(slot)) + return meshes; + + for (const auto &entry : s_bodyParts[age][sex][slot]) { + int entryLayer = entry.value("layer", 0); + if (entryLayer == layer) + meshes.push_back(entry.value("mesh", "")); + } + return meshes; +} + +Ogre::String CharacterSlotSystem::getMeshLabel( + const Ogre::String &age, const Ogre::String &sex, + const Ogre::String &slot, const Ogre::String &mesh) +{ + if (!s_catalogLoaded || !s_bodyParts.contains(age) || + !s_bodyParts[age].contains(sex) || + !s_bodyParts[age][sex].contains(slot)) + return mesh; + + for (const auto &entry : s_bodyParts[age][sex][slot]) { + if (entry.value("mesh", "") == mesh) { + const auto &garments = + entry.value("garments", nlohmann::json::array()); + if (garments.empty()) + return "nude"; + Ogre::String label; + for (size_t i = 0; i < garments.size(); ++i) { + if (i > 0) + label += " + "; + label += garments[i].get(); + } + return label; + } + } + return mesh; +} + std::vector CharacterSlotSystem::getMeshes( const Ogre::String &age, const Ogre::String &sex, const Ogre::String &slot) @@ -148,55 +201,188 @@ std::vector CharacterSlotSystem::getMeshes( !s_bodyParts[age].contains(sex) || !s_bodyParts[age][sex].contains(slot)) return meshes; - for (auto &m : s_bodyParts[age][sex][slot]) - meshes.push_back(m.get()); + for (const auto &entry : s_bodyParts[age][sex][slot]) + meshes.push_back(entry["mesh"].get()); return meshes; } +std::vector CharacterSlotSystem::getShapeKeyNames( + const Ogre::String &age, const Ogre::String &sex) +{ + std::set keySet; + if (!s_catalogLoaded || !s_bodyParts.contains(age) || + !s_bodyParts[age].contains(sex)) + return {}; + + for (auto &slotEl : s_bodyParts[age][sex].items()) { + for (const auto &entry : slotEl.value()) { + const auto &keys = + entry.value("shape_keys", nlohmann::json::array()); + for (const auto &k : keys) + keySet.insert(k.get()); + } + } + + return std::vector(keySet.begin(), keySet.end()); +} + +Ogre::String CharacterSlotSystem::resolveMesh( + const Ogre::String &age, const Ogre::String &sex, + const Ogre::String &slot, const SlotSelection &sel, + int outfitLevel) +{ + if (!sel.explicitMesh.empty()) + return sel.explicitMesh; + + if (!s_catalogLoaded || !s_bodyParts.contains(age) || + !s_bodyParts[age].contains(sex) || + !s_bodyParts[age][sex].contains(slot)) + return ""; + + const auto &slotEntries = s_bodyParts[age][sex][slot]; + + /* outfitLevel: 0=nude, 1=lingerie, 2=clothed */ + if (outfitLevel >= 2 && sel.layer2Mesh != "none" && + !sel.layer2Mesh.empty()) { + for (const auto &entry : slotEntries) { + if (entry.value("mesh", "") == sel.layer2Mesh) + return sel.layer2Mesh; + } + } + if (outfitLevel >= 1 && sel.layer1Mesh != "none" && + !sel.layer1Mesh.empty()) { + for (const auto &entry : slotEntries) { + if (entry.value("mesh", "") == sel.layer1Mesh) + return sel.layer1Mesh; + } + } + + /* Fallback to layer 0 (nude base) — prefer canonical base mesh */ + Ogre::String canonicalBase = sex + "_" + slot + ".mesh"; + Ogre::String firstLayer0; + for (const auto &entry : slotEntries) { + if (entry.value("layer", 0) == 0) { + Ogre::String mesh = entry["mesh"].get(); + if (mesh == canonicalBase) + return mesh; + if (firstLayer0.empty()) + firstLayer0 = mesh; + } + } + if (!firstLayer0.empty()) + return firstLayer0; + + /* Last resort: first available entry */ + if (!slotEntries.empty()) + return slotEntries[0]["mesh"].get(); + + return ""; +} + void CharacterSlotSystem::update() { if (!m_initialized) return; + int total = 0; + int dirtyCount = 0; m_world.query().each( - [this](flecs::entity e, CharacterSlotsComponent &cs) { + [&](flecs::entity e, CharacterSlotsComponent &cs) { + total++; if (cs.dirty) { - std::cout << "CharacterSlotSystem: building entity " - << e.id() << std::endl; + dirtyCount++; buildCharacter(e, cs); cs.dirty = false; } }); } +static const nlohmann::json *findCatalogEntry( + const Ogre::String &age, const Ogre::String &sex, + const Ogre::String &slot, const Ogre::String &mesh) +{ + if (!CharacterSlotSystem::isCatalogLoaded()) + return nullptr; + const nlohmann::json &cat = CharacterSlotSystem::getCatalog(); + if (!cat.contains(age) || !cat[age].contains(sex) || + !cat[age][sex].contains(slot)) + return nullptr; + for (const auto &entry : cat[age][sex][slot]) { + if (entry.value("mesh", "") == mesh) + return &entry; + } + return nullptr; +} + +static void ensureMeshPoseAnimation(const Ogre::String &meshName) +{ + Ogre::MeshPtr mesh; + try { + mesh = Ogre::MeshManager::getSingleton().load(meshName, + "Characters"); + } catch (...) { + return; + } + if (!mesh || mesh->getPoseCount() == 0) + return; + try { + mesh->getAnimation("ShapeKeys"); + } catch (...) { + Ogre::Animation *anim = mesh->createAnimation("ShapeKeys", 1.0f); + Ogre::VertexAnimationTrack *track = anim->createVertexTrack( + 0, Ogre::VAT_POSE); + Ogre::VertexPoseKeyFrame *kf = + track->createVertexPoseKeyFrame(0.0f); + for (size_t i = 0; i < mesh->getPoseCount(); ++i) + kf->addPoseReference(static_cast(i), 0.0f); + } +} + void CharacterSlotSystem::buildCharacter(flecs::entity e, CharacterSlotsComponent &cs) { - std::cout << "CharacterSlotSystem::buildCharacter: entity=" << e.id() - << " age=" << cs.age << " sex=" << cs.sex - << " slots=" << cs.slots.size() << std::endl; - destroyCharacterParts(e); - if (!e.has()) { - std::cout << " no TransformComponent" << std::endl; - return; + /* Migrate old slots map to slotSelections if needed */ + if (cs.slotSelections.empty() && !cs.slots.empty()) { + for (const auto &pair : cs.slots) { + SlotSelection sel; + sel.explicitMesh = pair.second; + cs.slotSelections[pair.first] = sel; + } } - auto &transform = e.get_mut(); - if (!transform.node) { - std::cout << " transform.node is null" << std::endl; - return; + /* Populate default slots from catalog if still empty */ + if (cs.slotSelections.empty()) { + auto slots = getSlots(cs.age, cs.sex); + for (const auto &slot : slots) { + SlotSelection sel; + cs.slotSelections[slot] = sel; + } } + if (!e.has()) + return; + + auto &transform = e.get_mut(); + if (!transform.node) + return; + /* Determine master slot (face preferred, else first non-empty) */ Ogre::String masterSlot; - if (cs.slots.find("face") != cs.slots.end() && - !cs.slots.at("face").empty()) { - masterSlot = "face"; - } else { - for (const auto &pair : cs.slots) { - if (!pair.second.empty()) { + if (cs.slotSelections.find("face") != cs.slotSelections.end()) { + Ogre::String mesh = resolveMesh(cs.age, cs.sex, "face", + cs.slotSelections["face"], + cs.outfitLevel); + if (!mesh.empty()) + masterSlot = "face"; + } + if (masterSlot.empty()) { + for (const auto &pair : cs.slotSelections) { + Ogre::String mesh = resolveMesh(cs.age, cs.sex, pair.first, + pair.second, + cs.outfitLevel); + if (!mesh.empty()) { masterSlot = pair.first; break; } @@ -204,55 +390,68 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, } if (masterSlot.empty()) { - std::cout << " masterSlot empty" << std::endl; return; } - std::cout << " masterSlot=" << masterSlot - << " mesh=" << cs.slots.at(masterSlot) << std::endl; + Ogre::String masterMesh = resolveMesh(cs.age, cs.sex, masterSlot, + cs.slotSelections[masterSlot], + cs.outfitLevel); + + if (masterMesh.empty()) + return; + + /* Pre-create pose animation on mesh so entity knows about it */ + ensureMeshPoseAnimation(masterMesh); Ogre::Entity *masterEnt = nullptr; try { - masterEnt = m_sceneMgr->createEntity(cs.slots.at(masterSlot)); + Ogre::MeshPtr meshPtr = Ogre::MeshManager::getSingleton().load( + masterMesh, "Characters"); + masterEnt = m_sceneMgr->createEntity(meshPtr); transform.node->attachObject(masterEnt); m_entities[e.id()].parts[masterSlot] = masterEnt; cs.masterEntity = masterEnt; - std::cout << " master loaded: " << masterEnt->getName() - << std::endl; - std::cout << " node=" << transform.node->getName() - << " pos=" << transform.node->_getDerivedPosition() - << " attached=" << transform.node->numAttachedObjects() - << std::endl; + + /* Setup pose animation for shape keys */ + const nlohmann::json *entry = findCatalogEntry( + cs.age, cs.sex, masterSlot, masterMesh); + applyShapeKeys(e, masterEnt, entry); /* Notify AnimationTreeSystem that entity changed */ if (e.has()) e.get_mut().dirty = true; } catch (const Ogre::Exception &ex) { - std::cout << " FAILED to load master mesh: " - << ex.getDescription() << std::endl; Ogre::LogManager::getSingleton().logMessage( "CharacterSlotSystem: Failed to load master mesh '" + - cs.slots.at(masterSlot) + "': " + ex.getDescription()); + masterMesh + "': " + ex.getDescription()); return; } - for (const auto &pair : cs.slots) { + for (const auto &pair : cs.slotSelections) { const Ogre::String &slot = pair.first; - const Ogre::String &mesh = pair.second; + const SlotSelection &sel = pair.second; - if (slot == masterSlot || mesh.empty()) + if (slot == masterSlot) + continue; + + Ogre::String mesh = resolveMesh(cs.age, cs.sex, slot, sel, + cs.outfitLevel); + if (mesh.empty()) continue; try { - Ogre::Entity *partEnt = m_sceneMgr->createEntity(mesh); + ensureMeshPoseAnimation(mesh); + Ogre::MeshPtr partMesh = + Ogre::MeshManager::getSingleton().load( + mesh, "Characters"); + Ogre::Entity *partEnt = m_sceneMgr->createEntity(partMesh); partEnt->shareSkeletonInstanceWith(masterEnt); transform.node->attachObject(partEnt); m_entities[e.id()].parts[slot] = partEnt; - std::cout << " part loaded: " << slot << "=" - << partEnt->getName() << std::endl; + const nlohmann::json *entry = findCatalogEntry( + cs.age, cs.sex, slot, mesh); + applyShapeKeys(e, partEnt, entry); } catch (const Ogre::Exception &ex) { - std::cout << " FAILED to load part " << slot - << ": " << ex.getDescription() << std::endl; Ogre::LogManager::getSingleton().logMessage( "CharacterSlotSystem: Failed to load part '" + slot + "' mesh '" + mesh + @@ -261,6 +460,71 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, } } +void CharacterSlotSystem::applyShapeKeys(flecs::entity e, + Ogre::Entity *ent, + const nlohmann::json *entry) +{ + if (!ent || !entry) + return; + + Ogre::MeshPtr mesh = ent->getMesh(); + if (!mesh || mesh->getPoseCount() == 0) + return; + + /* Create a pose animation track if one doesn't exist */ + Ogre::Animation *anim = nullptr; + try { + anim = mesh->getAnimation("ShapeKeys"); + } catch (...) { + anim = mesh->createAnimation("ShapeKeys", 1.0f); + Ogre::VertexAnimationTrack *track = anim->createVertexTrack( + 0, Ogre::VAT_POSE); + Ogre::VertexPoseKeyFrame *kf = + track->createVertexPoseKeyFrame(0.0f); + for (size_t i = 0; i < mesh->getPoseCount(); ++i) + kf->addPoseReference(static_cast(i), 0.0f); + } + + if (!ent->hasAnimationState("ShapeKeys")) + return; + Ogre::AnimationState *as = ent->getAnimationState("ShapeKeys"); + as->setEnabled(true); + as->setLoop(false); + + /* Build name -> pose index map from catalog */ + const auto &shapeKeys = + entry->value("shape_keys", nlohmann::json::array()); + std::unordered_map nameToIndex; + for (size_t i = 0; i < shapeKeys.size(); ++i) + nameToIndex[shapeKeys[i].get()] = i; + + /* Apply weights from CharacterShapeKeysComponent */ + if (e.has()) { + auto &skc = e.get_mut(); + for (const auto &pair : skc.weights) { + auto it = nameToIndex.find(pair.first); + if (it == nameToIndex.end()) + continue; + if (it->second >= mesh->getPoseCount()) + continue; + /* Update the keyframe's pose reference influence */ + Ogre::VertexAnimationTrack *track = + anim->getVertexTrack(0); + if (track) { + Ogre::VertexPoseKeyFrame *kf = + track->getVertexPoseKeyFrame(0); + if (kf) + kf->updatePoseReference( + static_cast(it->second), + pair.second); + } + } + } + + /* Force OGRE to update the entity with new pose weights */ + as->setTimePosition(0.0f); +} + void CharacterSlotSystem::destroyCharacterParts(flecs::entity e) { auto it = m_entities.find(e.id()); @@ -283,7 +547,7 @@ void CharacterSlotSystem::destroyCharacterParts(flecs::entity e) } Ogre::Entity *CharacterSlotSystem::getSlotEntity(flecs::entity e, - const Ogre::String &slot) + const Ogre::String &slot) { auto it = m_entities.find(e.id()); if (it == m_entities.end()) @@ -295,8 +559,8 @@ Ogre::Entity *CharacterSlotSystem::getSlotEntity(flecs::entity e, } void CharacterSlotSystem::setSlotVisible(flecs::entity e, - const Ogre::String &slot, - bool visible) + const Ogre::String &slot, + bool visible) { Ogre::Entity *ent = getSlotEntity(e, slot); if (ent) { diff --git a/src/features/editScene/systems/CharacterSlotSystem.hpp b/src/features/editScene/systems/CharacterSlotSystem.hpp index 1b7f216..4e1efc2 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.hpp +++ b/src/features/editScene/systems/CharacterSlotSystem.hpp @@ -11,6 +11,17 @@ #include "../components/CharacterSlots.hpp" +/** + * Rich catalog entry for a body part mesh. + */ +struct BodyPartEntry { + Ogre::String mesh; + int layer = 0; + std::vector garments; + std::vector tags; + std::vector shapeKeys; +}; + /** * System that manages multi-slot character meshes with shared skeleton. * Loads body part catalog from body_part_*.json files and creates/updates @@ -31,10 +42,37 @@ public: static std::vector getSexes(const Ogre::String &age); static std::vector getSlots(const Ogre::String &age, const Ogre::String &sex); + + /* Query meshes for a specific layer */ + static std::vector getMeshesForLayer( + const Ogre::String &age, const Ogre::String &sex, + const Ogre::String &slot, int layer); + + /* Get display label for a catalog entry (garment names joined) */ + static Ogre::String getMeshLabel(const Ogre::String &age, + const Ogre::String &sex, + const Ogre::String &slot, + const Ogre::String &mesh); + + /* Legacy flat list (for editor explicit mesh fallback) */ static std::vector getMeshes(const Ogre::String &age, const Ogre::String &sex, const Ogre::String &slot); + /* Raw catalog access for systems that need full metadata */ + static const nlohmann::json &getCatalog() { return s_bodyParts; } + + /* Shape key vocabulary for UI */ + static std::vector getShapeKeyNames( + const Ogre::String &age, const Ogre::String &sex); + + /* Resolve a single slot to a mesh name given outfit level */ + static Ogre::String resolveMesh(const Ogre::String &age, + const Ogre::String &sex, + const Ogre::String &slot, + const SlotSelection &sel, + int outfitLevel); + /* Slot visibility helpers */ Ogre::Entity *getSlotEntity(flecs::entity e, const Ogre::String &slot); @@ -48,6 +86,8 @@ private: void buildCharacter(flecs::entity e, CharacterSlotsComponent &cs); void destroyCharacterParts(flecs::entity e); + void applyShapeKeys(flecs::entity e, Ogre::Entity *ent, + const nlohmann::json *entry); flecs::world &m_world; Ogre::SceneManager *m_sceneMgr; diff --git a/src/features/editScene/systems/DialogueSystem.cpp b/src/features/editScene/systems/DialogueSystem.cpp index eac8e9b..25bdab1 100644 --- a/src/features/editScene/systems/DialogueSystem.cpp +++ b/src/features/editScene/systems/DialogueSystem.cpp @@ -1,182 +1,336 @@ #include "DialogueSystem.hpp" #include "../EditorApp.hpp" -#include "../components/DialogueComponent.hpp" -#include "../systems/EventBus.hpp" -#include "../components/EventParams.hpp" -#include #include #include #include #include +#include +#include -DialogueSystem::DialogueSystem(flecs::world &world, - Ogre::SceneManager *sceneMgr, - EditorApp *editorApp) - : m_world(world) - , m_sceneMgr(sceneMgr) - , m_editorApp(editorApp) +// --------------------------------------------------------------------------- +// Settings JSON +// --------------------------------------------------------------------------- + +bool DialogueSystem::Settings::loadFromJson(const std::string &path) +{ + std::ifstream f(path); + if (!f.is_open()) + return false; + + nlohmann::json j; + try { + f >> j; + } catch (...) { + return false; + } + + fontName = j.value("fontName", fontName); + fontSize = j.value("fontSize", fontSize); + speakerFontSize = j.value("speakerFontSize", speakerFontSize); + backgroundOpacity = j.value("backgroundOpacity", backgroundOpacity); + boxHeightFraction = j.value("boxHeightFraction", boxHeightFraction); + boxPositionFraction = j.value("boxPositionFraction", boxPositionFraction); + return true; +} + +bool DialogueSystem::Settings::saveToJson(const std::string &path) const +{ + nlohmann::json j; + j["fontName"] = fontName; + j["fontSize"] = fontSize; + j["speakerFontSize"] = speakerFontSize; + j["backgroundOpacity"] = backgroundOpacity; + j["boxHeightFraction"] = boxHeightFraction; + j["boxPositionFraction"] = boxPositionFraction; + + std::ofstream f(path); + if (!f.is_open()) + return false; + + try { + f << j.dump(4); + } catch (...) { + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Singleton +// --------------------------------------------------------------------------- + +DialogueSystem::DialogueSystem() { // Subscribe to dialogue events - EventBus::getInstance().subscribe( - "dialogue_show", [this](const Ogre::String &, - const editScene::EventParams ¶ms) { - // Find the first entity with DialogueComponent - m_world.query().each( - [&](flecs::entity e, DialogueComponent &dc) { - if (!dc.enabled) - return; + m_showListenerId = EventBus::getInstance().subscribe( + "dialogue_show", + [this](const Ogre::String &, + const editScene::EventParams ¶ms) { + Ogre::String text = params.getString("text"); + if (text.empty()) + return; - Ogre::String text = - params.getString("text"); - if (text.empty()) - return; + std::vector choices; + const editScene::EventValue *choicesVal = + params.get("choices"); + if (choicesVal && + choicesVal->getType() == + editScene::EventValue::STRING_ARRAY) { + const auto &arr = choicesVal->getStringArray(); + choices.reserve(arr.size()); + for (const auto &s : arr) + choices.push_back(s); + } - // Parse choices from string array - std::vector choices; - const editScene::EventValue *choicesVal = - params.get("choices"); - if (choicesVal && - choicesVal->getType() == - editScene::EventValue:: - STRING_ARRAY) { - const auto &arr = - choicesVal - ->getStringArray(); - choices.reserve(arr.size()); - for (const auto &s : arr) - choices.push_back(s); - } + Ogre::String speaker = params.getString("speaker"); + this->show(text, choices, speaker); + }); - Ogre::String speaker = - params.getString("speaker"); - - dc.show(text, choices, speaker); - }); + m_hideListenerId = EventBus::getInstance().subscribe( + "dialogue_hide", + [this](const Ogre::String &, const editScene::EventParams &) { + this->hide(); }); } DialogueSystem::~DialogueSystem() { - // EventBus subscriptions are managed externally + EventBus::getInstance().unsubscribe(m_showListenerId); + EventBus::getInstance().unsubscribe(m_hideListenerId); } +DialogueSystem &DialogueSystem::getInstance() +{ + static DialogueSystem instance; + return instance; +} + +void DialogueSystem::init(EditorApp *editorApp) +{ + m_editorApp = editorApp; +} + +// --------------------------------------------------------------------------- +// Settings helpers +// --------------------------------------------------------------------------- + +bool DialogueSystem::loadSettings(const std::string &path) +{ + if (!m_settings.loadFromJson(path)) { + Ogre::LogManager::getSingleton().logMessage( + "DialogueSystem: Could not load " + path + + ", using defaults."); + return false; + } + m_fontLoaded = false; + return true; +} + +bool DialogueSystem::saveSettings(const std::string &path) const +{ + return m_settings.saveToJson(path); +} + +// --------------------------------------------------------------------------- +// Runtime API +// --------------------------------------------------------------------------- + +void DialogueSystem::show(const Ogre::String &text, + const std::vector &choices, + const Ogre::String &speaker) +{ + m_text = text; + m_choices = choices; + m_speaker = speaker; + m_state = choices.empty() ? State::Showing : State::AwaitingChoice; + if (onShow) + onShow(); +} + +void DialogueSystem::hide() +{ + m_state = State::Idle; + m_text.clear(); + m_choices.clear(); + m_speaker.clear(); +} + +void DialogueSystem::progress() +{ + if (m_state == State::Showing && m_choices.empty()) { + m_state = State::Idle; + if (onDismissed) + onDismissed(); + } +} + +void DialogueSystem::selectChoice(int index) +{ + if (m_state == State::AwaitingChoice && index >= 1 && + index <= (int)m_choices.size()) { + m_state = State::Idle; + if (onChoiceSelected) + onChoiceSelected(index); + } +} + +bool DialogueSystem::isActive() const +{ + return m_state != State::Idle; +} + +// --------------------------------------------------------------------------- +// Editor preview +// --------------------------------------------------------------------------- + +void DialogueSystem::setEditorPreviewEnabled(bool enabled) +{ + m_editorPreviewEnabled = enabled; +} + +bool DialogueSystem::isEditorPreviewEnabled() const +{ + return m_editorPreviewEnabled; +} + +// --------------------------------------------------------------------------- +// Font +// --------------------------------------------------------------------------- + void DialogueSystem::ensureFontLoaded(const Ogre::String &fontName, - float fontSize) + float fontSize, float speakerFontSize) { if (m_fontLoaded && m_currentFontName == fontName && - m_currentFontSize == fontSize) + m_currentFontSize == fontSize && + m_currentSpeakerFontSize == speakerFontSize) + return; + + if (!m_editorApp) return; Ogre::ImGuiOverlay *overlay = m_editorApp->getImGuiOverlay(); if (!overlay) return; - // Load the main dialogue font - Ogre::FontPtr font; + // Main dialogue font try { if (Ogre::FontManager::getSingleton().resourceExists( "DialogueFont", "General")) { Ogre::FontManager::getSingleton().remove("DialogueFont", "General"); } - font = Ogre::FontManager::getSingleton().create("DialogueFont", - "General"); + Ogre::FontPtr 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->addCodePointRange( + Ogre::Font::CodePointRange(32, 255)); font->load(); + m_dialogueFont = overlay->addFont("DialogueFont", "General"); } catch (...) { Ogre::LogManager::getSingleton().logMessage( "DialogueSystem: Failed to load font " + fontName); m_dialogueFont = nullptr; + m_speakerFont = nullptr; m_fontLoaded = false; return; } - m_dialogueFont = overlay->addFont("DialogueFont", "General"); + // Speaker font + try { + if (Ogre::FontManager::getSingleton().resourceExists( + "DialogueSpeakerFont", "General")) { + Ogre::FontManager::getSingleton().remove( + "DialogueSpeakerFont", "General"); + } + Ogre::FontPtr font = Ogre::FontManager::getSingleton().create( + "DialogueSpeakerFont", "General"); + font->setType(Ogre::FontType::FT_TRUETYPE); + font->setSource(fontName); + font->setTrueTypeSize(speakerFontSize); + font->setTrueTypeResolution(75); + font->addCodePointRange( + Ogre::Font::CodePointRange(32, 255)); + font->load(); + m_speakerFont = overlay->addFont("DialogueSpeakerFont", + "General"); + } catch (...) { + Ogre::LogManager::getSingleton().logMessage( + "DialogueSystem: Failed to load speaker font " + + fontName); + m_speakerFont = nullptr; + } + m_currentFontName = fontName; m_currentFontSize = fontSize; + m_currentSpeakerFontSize = speakerFontSize; m_fontLoaded = true; } void DialogueSystem::prepareFont() { - if (!m_editorApp) - return; - - // Find an entity with DialogueComponent - flecs::entity dialogueEntity = flecs::entity::null(); - m_world.query().each( - [&](flecs::entity e, DialogueComponent &) { - if (!dialogueEntity.is_alive()) - dialogueEntity = e; - }); - - if (dialogueEntity.is_alive()) { - auto &dc = dialogueEntity.get_mut(); - ensureFontLoaded(dc.fontName, dc.fontSize); - } + ensureFontLoaded(m_settings.fontName, m_settings.fontSize, + m_settings.speakerFontSize); } +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + void DialogueSystem::update(float deltaTime) { (void)deltaTime; - if (!m_editorApp || - m_editorApp->getGameMode() != EditorApp::GameMode::Game || - m_editorApp->getGamePlayState() != - EditorApp::GamePlayState::Playing) + bool shouldRender = false; + if (m_editorApp) { + bool inGame = + m_editorApp->getGameMode() == EditorApp::GameMode::Game && + m_editorApp->getGamePlayState() == + EditorApp::GamePlayState::Playing; + shouldRender = inGame || m_editorPreviewEnabled; + } else { + shouldRender = m_editorPreviewEnabled; + } + + if (!shouldRender || !isActive()) return; - // Find an entity with DialogueComponent - flecs::entity dialogueEntity = flecs::entity::null(); - m_world.query().each( - [&](flecs::entity e, DialogueComponent &) { - if (!dialogueEntity.is_alive()) - dialogueEntity = e; - }); - - if (!dialogueEntity.is_alive()) - return; - - auto &dc = dialogueEntity.get_mut(); - if (!dc.enabled || !dc.isActive()) - return; - - renderDialogueBox(dc); + renderDialogueBox(); } -void DialogueSystem::renderDialogueBox(DialogueComponent &dc) +void DialogueSystem::renderDialogueBox() { ImVec2 size = ImGui::GetMainViewport()->Size; - float boxHeight = size.y * dc.boxHeightFraction; - float boxY = size.y * dc.boxPositionFraction; + float boxHeight = size.y * m_settings.boxHeightFraction; + float boxY = size.y * m_settings.boxPositionFraction; ImGui::SetNextWindowPos(ImVec2(0, boxY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(size.x, boxHeight), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(size.x, boxHeight), + ImGuiCond_Always); - // Semi-transparent background - ImVec4 bgColor = ImVec4(0.0f, 0.0f, 0.0f, dc.backgroundOpacity); + ImVec4 bgColor = ImVec4(0.0f, 0.0f, 0.0f, + m_settings.backgroundOpacity); ImGui::PushStyleColor(ImGuiCol_WindowBg, bgColor); - ImGui::Begin( - "DialogueBox", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoFocusOnAppearing); + 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()) { + // Speaker name + if (!m_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()); + m_speaker.c_str()); if (m_speakerFont) ImGui::PopFont(); ImGui::Spacing(); @@ -186,7 +340,7 @@ void DialogueSystem::renderDialogueBox(DialogueComponent &dc) if (m_dialogueFont) ImGui::PushFont(m_dialogueFont); - ImGui::TextWrapped("%s", dc.text.c_str()); + ImGui::TextWrapped("%s", m_text.c_str()); if (m_dialogueFont) ImGui::PopFont(); @@ -194,18 +348,16 @@ void DialogueSystem::renderDialogueBox(DialogueComponent &dc) ImGui::Spacing(); // Choices or click-to-progress - if (dc.choices.empty()) { - // No choices: click anywhere to progress + if (m_choices.empty()) { ImGui::SetCursorScreenPos(p); if (ImGui::InvisibleButton("DialogueProgress", ImGui::GetWindowSize())) { - dc.progress(); + 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); + for (int i = 0; i < (int)m_choices.size(); i++) { + if (ImGui::Button(m_choices[i].c_str())) { + selectChoice(i + 1); } } } diff --git a/src/features/editScene/systems/DialogueSystem.hpp b/src/features/editScene/systems/DialogueSystem.hpp index 95d755d..3aae41d 100644 --- a/src/features/editScene/systems/DialogueSystem.hpp +++ b/src/features/editScene/systems/DialogueSystem.hpp @@ -2,57 +2,129 @@ #define EDITSCENE_DIALOGUESYSTEM_HPP #pragma once -#include #include #include -#include +#include +#include +#include -#include "../components/DialogueComponent.hpp" +#include "EventBus.hpp" class EditorApp; /** - * System that renders the visual-novel style dialogue box in game mode. + * Singleton dialogue system. * - * 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. + * Manages a visual-novel style dialogue box that can be triggered + * via direct API, Lua scripts, or EventBus events ("dialogue_show", + * "dialogue_hide"). * - * The dialogue can be triggered via: - * 1. EventBus event "dialogue_show" with EventParams payload - * 2. Direct API on DialogueComponent + * Visual settings are loaded from dialogue.json at startup and can be + * tweaked from the editor or via the API. */ class DialogueSystem { public: - DialogueSystem(flecs::world &world, Ogre::SceneManager *sceneMgr, - EditorApp *editorApp); - ~DialogueSystem(); + struct Settings { + Ogre::String fontName = "Jupiteroid-Regular.ttf"; + float fontSize = 24.0f; + float speakerFontSize = 20.0f; + float backgroundOpacity = 0.85f; + float boxHeightFraction = 0.25f; + float boxPositionFraction = 0.75f; - /** - * Update and render the dialogue box. - * Must be called inside an active ImGui frame. - */ + bool loadFromJson(const std::string &path); + bool saveToJson(const std::string &path) const; + }; + + static DialogueSystem &getInstance(); + + /** Must be called before any font operations. */ + void init(EditorApp *editorApp); + + /** Load visual settings from JSON (defaults used if file missing). */ + bool loadSettings(const std::string &path = "dialogue.json"); + /** Save visual settings to JSON. */ + bool saveSettings(const std::string &path = "dialogue.json") const; + + const Settings &getSettings() const + { + return m_settings; + } + Settings &getSettingsRef() + { + return m_settings; + } + void setSettings(const Settings &s) + { + m_settings = s; + m_fontLoaded = false; + } + + /* --- Runtime API --- */ + + /** Show dialogue with narration text and optional choices. */ + void show(const Ogre::String &text, + const std::vector &choices = {}, + const Ogre::String &speaker = ""); + /** Hide the dialogue immediately. */ + void hide(); + /** Click-to-progress (no choices mode). */ + void progress(); + /** Select a choice by 1-based index. */ + void selectChoice(int index); + /** True if dialogue is currently on screen. */ + bool isActive() const; + + /* --- Editor preview --- */ + + void setEditorPreviewEnabled(bool enabled); + bool isEditorPreviewEnabled() const; + + /* --- Rendering --- */ + + /** Update and render the dialogue box. Must be inside ImGui frame. */ void update(float deltaTime); - /** - * Pre-load the dialogue font before ImGui NewFrame(). - * Must be called outside an active ImGui frame (before NewFrame). - */ + /** Pre-load fonts before ImGui NewFrame. */ void prepareFont(); -private: - void renderDialogueBox(DialogueComponent &dc); - void ensureFontLoaded(const Ogre::String &fontName, float fontSize); + /* --- Callbacks --- */ - flecs::world &m_world; - Ogre::SceneManager *m_sceneMgr; - EditorApp *m_editorApp; + std::function onChoiceSelected; + std::function onDismissed; + std::function onShow; + +private: + DialogueSystem(); + ~DialogueSystem(); + + DialogueSystem(const DialogueSystem &) = delete; + DialogueSystem &operator=(const DialogueSystem &) = delete; + + void renderDialogueBox(); + void ensureFontLoaded(const Ogre::String &fontName, float fontSize, + float speakerFontSize); + + EditorApp *m_editorApp = nullptr; + + enum class State { Idle, Showing, AwaitingChoice }; + State m_state = State::Idle; + Ogre::String m_text; + Ogre::String m_speaker; + std::vector m_choices; + + Settings m_settings; + bool m_editorPreviewEnabled = false; bool m_fontLoaded = false; Ogre::String m_currentFontName; float m_currentFontSize = 0.0f; + float m_currentSpeakerFontSize = 0.0f; ImFont *m_dialogueFont = nullptr; ImFont *m_speakerFont = nullptr; + + EventBus::ListenerId m_showListenerId = 0; + EventBus::ListenerId m_hideListenerId = 0; }; #endif // EDITSCENE_DIALOGUESYSTEM_HPP diff --git a/src/features/editScene/systems/EditorUISystem.cpp b/src/features/editScene/systems/EditorUISystem.cpp index 35b59a1..80f2dea 100644 --- a/src/features/editScene/systems/EditorUISystem.cpp +++ b/src/features/editScene/systems/EditorUISystem.cpp @@ -1,5 +1,6 @@ #include "../components/GeneratedPhysicsTag.hpp" #include "EditorUISystem.hpp" +#include "DialogueSystem.hpp" #include "PrefabSystem.hpp" #include "../camera/EditorCamera.hpp" #include "../components/EntityName.hpp" @@ -44,6 +45,7 @@ #include "../components/PrefabInstance.hpp" #include "../components/Item.hpp" #include "../components/Inventory.hpp" +#include "../components/CharacterClassComponent.hpp" #include "../ui/TransformEditor.hpp" #include "../ui/RenderableEditor.hpp" @@ -59,6 +61,8 @@ #include #include #include +#include +#include EditorUISystem::EditorUISystem(flecs::world &world, Ogre::SceneManager *sceneMgr, @@ -332,6 +336,17 @@ void EditorUISystem::update(float deltaTime) &m_showActionDatabaseSingleton); } + // Render Dialogue settings window + if (m_showDialogueSettings) { + renderDialogueSettingsWindow(); + } + + // Render Character Class Database editor + if (m_showCharacterClassDatabase) { + m_characterClassDatabaseEditor.render( + &m_showCharacterClassDatabase); + } + // Render FPS overlay renderFPSOverlay(deltaTime); } @@ -420,6 +435,15 @@ void EditorUISystem::renderHierarchyWindow() "Action Database (Singleton)")) { m_showActionDatabaseSingleton = true; } + ImGui::Separator(); + if (ImGui::MenuItem("Dialogue Settings")) { + m_showDialogueSettings = true; + } + ImGui::Separator(); + if (ImGui::MenuItem( + "Character Class Database")) { + m_showCharacterClassDatabase = true; + } ImGui::EndMenu(); } @@ -1130,6 +1154,21 @@ void EditorUISystem::renderComponentList(flecs::entity entity) componentCount++; } + // Render CharacterClass if present + if (entity.has()) { + auto &cc = entity.get_mut(); + m_componentRegistry.render(entity, cc); + componentCount++; + } + + // Render CharacterClassOverride if present + if (entity.has()) { + auto &cco = entity.get_mut(); + m_componentRegistry.render( + entity, cco); + componentCount++; + } + // Show message if no components if (componentCount == 0) { @@ -2071,3 +2110,123 @@ void EditorUISystem::renderCursorPanel() } ImGui::End(); } + + +void EditorUISystem::renderDialogueSettingsWindow() +{ + ImGui::SetNextWindowPos(ImVec2(LEFT_PANEL_WIDTH, 100), + ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(400, 500), ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + if (!ImGui::Begin("Dialogue Settings", &m_showDialogueSettings, + flags)) { + ImGui::End(); + return; + } + + DialogueSystem &ds = DialogueSystem::getInstance(); + DialogueSystem::Settings s = ds.getSettings(); + + // --- Visual settings --- + ImGui::Text("Visual Settings"); + ImGui::Separator(); + + char fontNameBuf[256]; + std::strncpy(fontNameBuf, s.fontName.c_str(), sizeof(fontNameBuf) - 1); + fontNameBuf[sizeof(fontNameBuf) - 1] = '\0'; + if (ImGui::InputText("Font Name", fontNameBuf, + sizeof(fontNameBuf))) { + s.fontName = fontNameBuf; + } + + if (ImGui::DragFloat("Font Size", &s.fontSize, 0.5f, 8.0f, + 72.0f)) { + } + if (ImGui::DragFloat("Speaker Font Size", &s.speakerFontSize, + 0.5f, 8.0f, 72.0f)) { + } + if (ImGui::SliderFloat("Background Opacity", &s.backgroundOpacity, + 0.0f, 1.0f)) { + } + if (ImGui::SliderFloat("Box Height Fraction", &s.boxHeightFraction, + 0.05f, 0.5f)) { + } + if (ImGui::SliderFloat("Box Position Fraction", + &s.boxPositionFraction, 0.0f, 1.0f)) { + } + + if (ImGui::Button("Apply Settings")) { + ds.setSettings(s); + } + ImGui::SameLine(); + if (ImGui::Button("Save to dialogue.json")) { + if (ds.saveSettings("dialogue.json")) { + Ogre::LogManager::getSingleton().logMessage( + "Dialogue settings saved."); + } + } + ImGui::SameLine(); + if (ImGui::Button("Load from dialogue.json")) { + if (ds.loadSettings("dialogue.json")) { + Ogre::LogManager::getSingleton().logMessage( + "Dialogue settings loaded."); + } + } + + ImGui::Spacing(); + ImGui::Text("Preview"); + ImGui::Separator(); + + static char sampleText[512] = + "This is sample dialogue text for preview."; + static char sampleSpeaker[128] = "Speaker"; + static char sampleChoices[512] = "Choice 1\nChoice 2\nChoice 3"; + static bool showPreview = false; + + ImGui::InputTextMultiline("Sample Text", sampleText, + sizeof(sampleText), ImVec2(0, 60)); + ImGui::InputText("Sample Speaker", sampleSpeaker, + sizeof(sampleSpeaker)); + ImGui::InputTextMultiline("Sample Choices (one per line)", + sampleChoices, sizeof(sampleChoices), + ImVec2(0, 60)); + + if (ImGui::Checkbox("Show Preview", &showPreview)) { + if (showPreview) { + std::vector choices; + std::istringstream iss(sampleChoices); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty()) + choices.push_back(line); + } + ds.show(sampleText, choices, sampleSpeaker); + ds.setEditorPreviewEnabled(true); + } else { + ds.hide(); + ds.setEditorPreviewEnabled(false); + } + } + + if (showPreview && ds.isActive()) { + if (ImGui::Button("Update Preview")) { + std::vector choices; + std::istringstream iss(sampleChoices); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty()) + choices.push_back(line); + } + ds.show(sampleText, choices, sampleSpeaker); + } + ImGui::SameLine(); + if (ImGui::Button("Hide Preview")) { + showPreview = false; + ds.hide(); + ds.setEditorPreviewEnabled(false); + } + } + + ImGui::End(); +} diff --git a/src/features/editScene/systems/EditorUISystem.hpp b/src/features/editScene/systems/EditorUISystem.hpp index 386e5bc..a301fe4 100644 --- a/src/features/editScene/systems/EditorUISystem.hpp +++ b/src/features/editScene/systems/EditorUISystem.hpp @@ -8,6 +8,7 @@ #include #include "../ui/ComponentRegistry.hpp" #include "../ui/ActionDatabaseSingletonEditor.hpp" +#include "../ui/CharacterClassDatabaseEditor.hpp" #include "../components/EntityName.hpp" #include "../gizmo/Gizmo.hpp" #include "../gizmo/Cursor3D.hpp" @@ -191,6 +192,7 @@ public: void showCreatePrefabDialog(flecs::entity entity); void renderPrefabBrowser(); void renderCursorPanel(); + void renderDialogueSettingsWindow(); private: // File menu @@ -302,6 +304,13 @@ private: bool m_showActionDatabaseSingleton = false; ActionDatabaseSingletonEditor m_actionDatabaseSingletonEditor; + // Dialogue settings editor state + bool m_showDialogueSettings = false; + + // Character class database editor state + bool m_showCharacterClassDatabase = false; + CharacterClassDatabaseEditor m_characterClassDatabaseEditor; + // Queries flecs::query m_nameQuery; diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index 4b97b2a..5d6cb31 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -2144,10 +2144,22 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity) json["age"] = cs.age; json["sex"] = cs.sex; + json["outfitLevel"] = cs.outfitLevel; json["slots"] = nlohmann::json::object(); for (const auto &pair : cs.slots) json["slots"][pair.first] = pair.second; + // Serialize per-layer slot selections + nlohmann::json selections = nlohmann::json::object(); + for (const auto &pair : cs.slotSelections) { + nlohmann::json sel; + sel["layer1Mesh"] = pair.second.layer1Mesh; + sel["layer2Mesh"] = pair.second.layer2Mesh; + sel["explicitMesh"] = pair.second.explicitMesh; + selections[pair.first] = sel; + } + json["slotSelections"] = selections; + // Serialize front axis json["frontAxis"] = { cs.frontAxis.x, cs.frontAxis.y, cs.frontAxis.z }; @@ -2155,15 +2167,37 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity) } void SceneSerializer::deserializeCharacterSlots(flecs::entity entity, - const nlohmann::json &json) + const nlohmann::json &json) { CharacterSlotsComponent cs; cs.age = json.value("age", "adult"); cs.sex = json.value("sex", "male"); + cs.outfitLevel = json.value("outfitLevel", 2); if (json.contains("slots") && json["slots"].is_object()) { for (auto &[slot, mesh] : json["slots"].items()) cs.slots[slot] = mesh.get(); } + // Deserialize per-layer slot selections + if (json.contains("slotSelections") && json["slotSelections"].is_object()) { + for (auto &[slot, selJson] : json["slotSelections"].items()) { + SlotSelection sel; + if (selJson.contains("layer1Mesh")) + sel.layer1Mesh = selJson.value("layer1Mesh", ""); + if (selJson.contains("layer2Mesh")) + sel.layer2Mesh = selJson.value("layer2Mesh", ""); + // Backward compat: old format had layer/requiredTags/excludedTags + if (selJson.contains("layer") && sel.layer1Mesh.empty() && + sel.layer2Mesh.empty()) { + int oldLayer = selJson.value("layer", 2); + if (oldLayer == 1) + sel.layer1Mesh = "auto"; + else if (oldLayer >= 2) + sel.layer2Mesh = "auto"; + } + sel.explicitMesh = selJson.value("explicitMesh", ""); + cs.slotSelections[slot] = sel; + } + } // Deserialize front axis if (json.contains("frontAxis") && json["frontAxis"].is_array() && json["frontAxis"].size() >= 3) { diff --git a/src/features/editScene/systems/SceneSerializer.hpp b/src/features/editScene/systems/SceneSerializer.hpp index 4598ffb..42c1895 100644 --- a/src/features/editScene/systems/SceneSerializer.hpp +++ b/src/features/editScene/systems/SceneSerializer.hpp @@ -106,6 +106,7 @@ private: nlohmann::json serializeTriangleBuffer(flecs::entity entity); nlohmann::json serializeCharacter(flecs::entity entity); nlohmann::json serializeCharacterSlots(flecs::entity entity); + nlohmann::json serializeCharacterShapeKeys(flecs::entity entity); nlohmann::json serializeAnimationTree(flecs::entity entity); nlohmann::json serializeAnimationTreeTemplate(flecs::entity entity); nlohmann::json serializeStartupMenu(flecs::entity entity); @@ -157,6 +158,8 @@ private: const nlohmann::json &json); void deserializeCharacterSlots(flecs::entity entity, const nlohmann::json &json); + void deserializeCharacterShapeKeys(flecs::entity entity, + const nlohmann::json &json); void deserializeAnimationTree(flecs::entity entity, const nlohmann::json &json); void deserializeAnimationTreeTemplate(flecs::entity entity, @@ -211,6 +214,14 @@ private: void deserializeInventory(flecs::entity entity, const nlohmann::json &json); + // Character class serialization + nlohmann::json serializeCharacterClass(flecs::entity entity); + nlohmann::json serializeCharacterClassOverride(flecs::entity entity); + void deserializeCharacterClass(flecs::entity entity, + const nlohmann::json &json); + void deserializeCharacterClassOverride(flecs::entity entity, + const nlohmann::json &json); + // AI/GOAP serialization nlohmann::json serializeActionDatabase(); nlohmann::json serializeActionDebug(flecs::entity entity); diff --git a/src/features/editScene/tests/component_lua_test.cpp b/src/features/editScene/tests/component_lua_test.cpp index fab137e..6a4388b 100644 --- a/src/features/editScene/tests/component_lua_test.cpp +++ b/src/features/editScene/tests/component_lua_test.cpp @@ -137,6 +137,7 @@ namespace editScene { void registerLuaComponentApi(lua_State *L); void registerLuaEntityApi(lua_State *L); +void registerLuaDialogueApi(lua_State *L); } // --------------------------------------------------------------------------- @@ -826,26 +827,27 @@ static int testPlayerControllerComponent(lua_State *L) } // --------------------------------------------------------------------------- -// Test 25: Dialogue component +// Test 25: Dialogue singleton API (via LuaDialogueApi stub) // --------------------------------------------------------------------------- +// DialogueSystem is no longer an ECS component. It is a singleton accessed +// via ecs.dialogue.show(), ecs.dialogue.get_settings(), etc. +// The full API is tested in integration tests; component_lua_test only +// verifies that the stub table exists and is callable. -static int testDialogueComponent(lua_State *L) +static int testDialogueSingletonApi(lua_State *L) { - TEST("Dialogue component"); + TEST("Dialogue singleton API stub"); - bool ok = runLua(L, "local id = ecs.create_entity();" - "ecs.set_component(id, 'Dialogue', {" - " text = 'Hello world'," - " speaker = 'NPC'," - " enabled = true" - "});" - "local d = ecs.get_component(id, 'Dialogue');" - "assert(d ~= nil, 'Dialogue should exist');" - "assert(d.text == 'Hello world', 'wrong text');" - "assert(d.speaker == 'NPC', 'wrong speaker');" - "assert(d.enabled == true, 'wrong enabled')"); + bool ok = runLua(L, + "local s = ecs.dialogue.get_settings();" + "assert(s ~= nil, 'settings should exist');" + "assert(s.font_name ~= nil, 'font_name should exist');" + "ecs.dialogue.show('Hello', {}, 'NPC');" + "ecs.dialogue.hide();" + "local active = ecs.dialogue.is_active();" + "assert(active == false, 'should not be active after hide')"); if (!ok) - FAIL("Dialogue component assertion failed"); + FAIL("Dialogue singleton API assertion failed"); PASS(); return 0; @@ -1833,6 +1835,7 @@ int main() // Register the entity and component APIs editScene::registerLuaEntityApi(L); editScene::registerLuaComponentApi(L); + editScene::registerLuaDialogueApi(L); // Run tests int failures = 0; @@ -1860,7 +1863,7 @@ int main() failures += testWaterPlaneComponent(L); failures += testBuoyancyInfoComponent(L); failures += testPlayerControllerComponent(L); - failures += testDialogueComponent(L); + failures += testDialogueSingletonApi(L); failures += testNavMeshComponent(L); failures += testSmartObjectComponent(L); failures += testActuatorComponent(L); diff --git a/src/features/editScene/tests/lua_test_stubs.cpp b/src/features/editScene/tests/lua_test_stubs.cpp index b3b52ca..7785b18 100644 --- a/src/features/editScene/tests/lua_test_stubs.cpp +++ b/src/features/editScene/tests/lua_test_stubs.cpp @@ -136,6 +136,92 @@ void registerLuaGameModeApi(lua_State *L) lua_setglobal(L, "ecs"); } +// Stub: LuaDialogueApi (DialogueSystem singleton) +void registerLuaDialogueApi(lua_State *L) +{ + lua_getglobal(L, "ecs"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + } + + lua_newtable(L); + + // show(text, choices, speaker) + lua_pushcfunction(L, [](lua_State *L) -> int { + return 0; + }); + lua_setfield(L, -2, "show"); + + // hide() + lua_pushcfunction(L, [](lua_State *L) -> int { + return 0; + }); + lua_setfield(L, -2, "hide"); + + // is_active() + lua_pushcfunction(L, [](lua_State *L) -> int { + lua_pushboolean(L, 0); + return 1; + }); + lua_setfield(L, -2, "is_active"); + + // select_choice(index) + lua_pushcfunction(L, [](lua_State *L) -> int { + return 0; + }); + lua_setfield(L, -2, "select_choice"); + + // progress() + lua_pushcfunction(L, [](lua_State *L) -> int { + return 0; + }); + lua_setfield(L, -2, "progress"); + + // get_settings() + lua_pushcfunction(L, [](lua_State *L) -> int { + lua_newtable(L); + lua_pushstring(L, "Jupiteroid-Regular.ttf"); + lua_setfield(L, -2, "font_name"); + lua_pushnumber(L, 24.0); + lua_setfield(L, -2, "font_size"); + lua_pushnumber(L, 20.0); + lua_setfield(L, -2, "speaker_font_size"); + lua_pushnumber(L, 0.85); + lua_setfield(L, -2, "background_opacity"); + lua_pushnumber(L, 0.25); + lua_setfield(L, -2, "box_height_fraction"); + lua_pushnumber(L, 0.75); + lua_setfield(L, -2, "box_position_fraction"); + return 1; + }); + lua_setfield(L, -2, "get_settings"); + + // set_settings(table) + lua_pushcfunction(L, [](lua_State *L) -> int { + return 0; + }); + lua_setfield(L, -2, "set_settings"); + + // save_settings(path) + lua_pushcfunction(L, [](lua_State *L) -> int { + lua_pushboolean(L, 1); + return 1; + }); + lua_setfield(L, -2, "save_settings"); + + // load_settings(path) + lua_pushcfunction(L, [](lua_State *L) -> int { + lua_pushboolean(L, 1); + return 1; + }); + lua_setfield(L, -2, "load_settings"); + + lua_setfield(L, -2, "dialogue"); + + lua_setglobal(L, "ecs"); +} + } // namespace editScene // --------------------------------------------------------------------------- diff --git a/src/features/editScene/ui/CharacterClassDatabaseEditor.cpp b/src/features/editScene/ui/CharacterClassDatabaseEditor.cpp new file mode 100644 index 0000000..39e384d --- /dev/null +++ b/src/features/editScene/ui/CharacterClassDatabaseEditor.cpp @@ -0,0 +1,408 @@ +#include "CharacterClassDatabaseEditor.hpp" +#include "../components/CharacterClassDatabase.hpp" +#include +#include + +void CharacterClassDatabaseEditor::render(bool *open) +{ + ImGui::SetNextWindowPos(ImVec2(300, 100), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(600, 600), + ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Character Class Database", open)) { + ImGui::End(); + return; + } + + if (ImGui::Button("Save to character_class.json")) { + if (CharacterClassDatabase::saveToJson( + "character_class.json")) { + Ogre::LogManager::getSingleton().logMessage( + "Character class database saved."); + } + } + ImGui::SameLine(); + if (ImGui::Button("Load from character_class.json")) { + if (CharacterClassDatabase::loadFromJson( + "character_class.json")) { + Ogre::LogManager::getSingleton().logMessage( + "Character class database loaded."); + } + } + + ImGui::Separator(); + + ImGui::TextDisabled("(?) Hover over fields for help."); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Formulas support: level, current, base, value"); + ImGui::Text("Operators: + - * / ^"); + ImGui::Text("Functions: floor, ceil, min, max, clamp, sqrt, abs, round"); + ImGui::EndTooltip(); + } + + if (ImGui::BeginTabBar("CCTabs")) { + if (ImGui::BeginTabItem("Stats")) { + renderStatsTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Skills")) { + renderSkillsTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Needs")) { + renderNeedsTab(); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem("Classes")) { + renderClassesTab(); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + +void CharacterClassDatabaseEditor::renderStatsTab() +{ + auto &db = CharacterClassDatabase::getSingleton(); + + ImGui::Text("Add Stat"); + ImGui::InputText("Name", m_nameBuf, sizeof(m_nameBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Internal identifier, e.g. 'strength'"); + ImGui::EndTooltip(); + } + ImGui::InputText("Display Name", m_displayBuf, + sizeof(m_displayBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Human-readable name shown in UI, e.g. 'Strength'"); + ImGui::EndTooltip(); + } + const char *kindLabels[] = { "Attribute", "ResourcePool" }; + ImGui::Combo("Kind", &m_statKindIdx, kindLabels, + IM_ARRAYSIZE(kindLabels)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Attribute: permanent value (str, agi, dex)"); + ImGui::Text("ResourcePool: depletable current/max (hp, stamina, mana)"); + ImGui::EndTooltip(); + } + ImGui::InputInt("Min", &m_intBuf); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Minimum value (attribute) or pool max (resource pool)"); + ImGui::EndTooltip(); + } + ImGui::InputInt("Max", &m_maxVal); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Maximum value (attribute) or pool max cap (resource pool)"); + ImGui::EndTooltip(); + } + if (ImGui::Button("Add Stat")) { + CharacterClassDatabase::StatDef def; + def.name = m_nameBuf; + def.displayName = m_displayBuf; + def.kind = (m_statKindIdx == 1) ? + CharacterClassDatabase::StatKind::ResourcePool : + CharacterClassDatabase::StatKind::Attribute; + def.minValue = m_intBuf; + def.maxValue = m_maxVal; + if (!def.name.empty()) + db.addOrReplaceStat(def); + } + + ImGui::Separator(); + ImGui::Text("Defined Stats"); + for (const auto &name : db.getStatNames()) { + auto *def = db.findStat(name); + if (!def) + continue; + ImGui::PushID(name.c_str()); + ImGui::Text("%s [%s] min:%d max:%d", name.c_str(), + def->kind == CharacterClassDatabase::StatKind::ResourcePool ? + "Pool" : "Attr", + def->minValue, def->maxValue); + ImGui::SameLine(); + if (ImGui::Button("Remove")) + db.removeStat(name); + + if (ImGui::TreeNode("Edit")) { + char buf[128]; + strncpy(buf, def->displayName.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("Display Name", buf, + sizeof(buf))) + def->displayName = buf; + int kindEdit = (def->kind == + CharacterClassDatabase::StatKind::ResourcePool) ? + 1 : + 0; + if (ImGui::Combo("Kind", &kindEdit, kindLabels, + IM_ARRAYSIZE(kindLabels))) + def->kind = (kindEdit == 1) ? + CharacterClassDatabase::StatKind::ResourcePool : + CharacterClassDatabase::StatKind::Attribute; + if (ImGui::InputInt("Min", &def->minValue)) + ; + if (ImGui::InputInt("Max", &def->maxValue)) + ; + ImGui::TreePop(); + } + ImGui::PopID(); + } +} + +void CharacterClassDatabaseEditor::renderSkillsTab() +{ + auto &db = CharacterClassDatabase::getSingleton(); + + ImGui::Text("Add Skill"); + ImGui::InputText("Name", m_nameBuf, sizeof(m_nameBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Internal identifier, e.g. 'smithing'"); + ImGui::EndTooltip(); + } + ImGui::InputText("Display Name", m_displayBuf, + sizeof(m_displayBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Human-readable name shown in UI, e.g. 'Smithing'"); + ImGui::EndTooltip(); + } + if (ImGui::Button("Add Skill")) { + CharacterClassDatabase::SkillDef def; + def.name = m_nameBuf; + def.displayName = m_displayBuf; + def.maxValue = 100; + if (!def.name.empty()) + db.addOrReplaceSkill(def); + } + + ImGui::Separator(); + ImGui::Text("Defined Skills"); + for (const auto &name : db.getSkillNames()) { + auto *def = db.findSkill(name); + if (!def) + continue; + ImGui::PushID(name.c_str()); + ImGui::Text("%s max:%d", name.c_str(), def->maxValue); + ImGui::SameLine(); + if (ImGui::Button("Remove")) + db.removeSkill(name); + + if (ImGui::TreeNode("Edit")) { + char buf[128]; + strncpy(buf, def->displayName.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("Display Name", buf, + sizeof(buf))) + def->displayName = buf; + ImGui::TreePop(); + } + ImGui::PopID(); + } +} + +void CharacterClassDatabaseEditor::renderNeedsTab() +{ + auto &db = CharacterClassDatabase::getSingleton(); + + ImGui::Text("Add Need"); + ImGui::InputText("Name", m_nameBuf, sizeof(m_nameBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Internal identifier, e.g. 'hunger'"); + ImGui::EndTooltip(); + } + ImGui::InputText("Display Name", m_displayBuf, + sizeof(m_displayBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Human-readable name shown in UI, e.g. 'Hunger'"); + ImGui::EndTooltip(); + } + ImGui::InputFloat("Accumulation Rate", &m_floatBuf); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Points added per second (range 0-1000)"); + ImGui::EndTooltip(); + } + ImGui::InputInt("Low Threshold", &m_lowThreshold); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Need value at or below which the bit is CLEARED"); + ImGui::Text("(need is satisfied / low priority)"); + ImGui::EndTooltip(); + } + ImGui::InputInt("High Threshold", &m_highThreshold); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Need value at or above which the bit is SET"); + ImGui::Text("(need is critical / action required)"); + ImGui::EndTooltip(); + } + char bitName[128] = {}; + ImGui::InputText("Bit Name", bitName, sizeof(bitName)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("GOAP blackboard bit managed by this need"); + ImGui::Text("Set when need >= high threshold"); + ImGui::Text("Cleared when need <= low threshold"); + ImGui::Text("Between thresholds the bit keeps its current state (hysteresis)"); + ImGui::EndTooltip(); + } + if (ImGui::Button("Add Need")) { + CharacterClassDatabase::NeedDef def; + def.name = m_nameBuf; + def.displayName = m_displayBuf; + def.accumulationRate = m_floatBuf; + def.lowThreshold = m_lowThreshold; + def.highThreshold = m_highThreshold; + def.bitName = bitName; + if (!def.name.empty()) + db.addOrReplaceNeed(def); + } + + ImGui::Separator(); + ImGui::Text("Defined Needs"); + for (const auto &name : db.getNeedNames()) { + auto *def = db.findNeed(name); + if (!def) + continue; + ImGui::PushID(name.c_str()); + ImGui::Text("%s rate:%.1f low:%d high:%d bit:%s", + name.c_str(), def->accumulationRate, + def->lowThreshold, def->highThreshold, + def->bitName.empty() ? "-" : + def->bitName.c_str()); + ImGui::SameLine(); + if (ImGui::Button("Remove")) + db.removeNeed(name); + + if (ImGui::TreeNode("Edit")) { + char buf[128]; + strncpy(buf, def->displayName.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("Display Name", buf, + sizeof(buf))) + def->displayName = buf; + if (ImGui::InputFloat("Accumulation Rate", + &def->accumulationRate)) + ; + if (ImGui::InputInt("Low Threshold", + &def->lowThreshold)) + ; + if (ImGui::InputInt("High Threshold", + &def->highThreshold)) + ; + strncpy(buf, def->bitName.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputText("Bit Name", buf, sizeof(buf))) + def->bitName = buf; + ImGui::TreePop(); + } + ImGui::PopID(); + } +} + +void CharacterClassDatabaseEditor::renderClassesTab() +{ + auto &db = CharacterClassDatabase::getSingleton(); + + ImGui::Text("Add Class"); + ImGui::InputText("Name", m_nameBuf, sizeof(m_nameBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Internal identifier, e.g. 'warrior'"); + ImGui::EndTooltip(); + } + ImGui::InputTextMultiline("Description", m_descBuf, + sizeof(m_descBuf), ImVec2(0, 40)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Short description shown in UI tooltips"); + ImGui::EndTooltip(); + } + ImGui::InputText("XP Formula", m_formulaBuf, + sizeof(m_formulaBuf)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Variables: level, current, base, value"); + ImGui::Text("Ops: + - * / ^ | Functions: floor, ceil, min, max, clamp, sqrt, abs, round"); + ImGui::Text("Example: '100 * level ^ 1.5' or 'floor(50 * sqrt(level))'"); + ImGui::EndTooltip(); + } + if (ImGui::Button("Add Class")) { + CharacterClassDatabase::ClassDef def; + def.name = m_nameBuf; + def.description = m_descBuf; + def.xpForLevel = Formula(m_formulaBuf); + if (!def.name.empty()) + db.addOrReplaceClass(def); + } + + ImGui::Separator(); + ImGui::Text("Defined Classes"); + for (const auto &name : db.getClassNames()) { + auto *cls = db.findClass(name); + if (!cls) + continue; + ImGui::PushID(name.c_str()); + ImGui::Text("%s - %s", name.c_str(), + cls->description.c_str()); + ImGui::SameLine(); + if (ImGui::Button("Remove")) + db.removeClass(name); + + if (ImGui::TreeNode("Edit")) { + char buf[256]; + strncpy(buf, cls->description.c_str(), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + if (ImGui::InputTextMultiline("Description", buf, + sizeof(buf), + ImVec2(0, 40))) + cls->description = buf; + + char formulaBuf[256]; + strncpy(formulaBuf, + cls->xpForLevel.getExpression().c_str(), + sizeof(formulaBuf) - 1); + formulaBuf[sizeof(formulaBuf) - 1] = '\0'; + if (ImGui::InputText("XP Formula", formulaBuf, + sizeof(formulaBuf))) + cls->xpForLevel = Formula(formulaBuf); + + ImGui::TreePop(); + } + + if (ImGui::TreeNode("Edit Base Stats")) { + for (const auto &sname : db.getStatNames()) { + int val = cls->baseStats.count(sname) ? + cls->baseStats.at(sname) : + 0; + if (ImGui::InputInt(sname.c_str(), &val)) + cls->baseStats[sname] = val; + } + ImGui::TreePop(); + } + + if (ImGui::TreeNode("Edit Base Skills")) { + for (const auto &sname : db.getSkillNames()) { + int val = cls->baseSkills.count(sname) ? + cls->baseSkills.at(sname) : + 0; + if (ImGui::InputInt(sname.c_str(), &val)) + cls->baseSkills[sname] = val; + } + ImGui::TreePop(); + } + + ImGui::PopID(); + } +} diff --git a/src/features/editScene/ui/CharacterClassDatabaseEditor.hpp b/src/features/editScene/ui/CharacterClassDatabaseEditor.hpp new file mode 100644 index 0000000..621867d --- /dev/null +++ b/src/features/editScene/ui/CharacterClassDatabaseEditor.hpp @@ -0,0 +1,39 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_DATABASE_EDITOR_HPP +#define EDITSCENE_CHARACTER_CLASS_DATABASE_EDITOR_HPP +#pragma once + +#include +#include +#include + +/** + * Editor window for the CharacterClassDatabase singleton. + * + * Allows adding/removing stats, skills, needs, and classes, + * as well as editing formulas and base values. + */ +class CharacterClassDatabaseEditor { +public: + void render(bool *open); + +private: + void renderStatsTab(); + void renderSkillsTab(); + void renderNeedsTab(); + void renderClassesTab(); + + // Edit buffers + char m_nameBuf[128] = {}; + char m_displayBuf[128] = {}; + char m_formulaBuf[256] = {}; + char m_descBuf[256] = {}; + + int m_intBuf = 0; + int m_maxVal = 999; + float m_floatBuf = 0.0f; + int m_lowThreshold = 0; + int m_highThreshold = 1000; + int m_statKindIdx = 0; // 0 = Attribute, 1 = ResourcePool +}; + +#endif // EDITSCENE_CHARACTER_CLASS_DATABASE_EDITOR_HPP diff --git a/src/features/editScene/ui/CharacterClassEditor.cpp b/src/features/editScene/ui/CharacterClassEditor.cpp new file mode 100644 index 0000000..84c566f --- /dev/null +++ b/src/features/editScene/ui/CharacterClassEditor.cpp @@ -0,0 +1,151 @@ +#include "CharacterClassEditor.hpp" +#include "../components/CharacterClassDatabase.hpp" +#include "../systems/CharacterClassSystem.hpp" +#include + +bool CharacterClassEditor::renderComponent(flecs::entity entity, + CharacterClassComponent &comp) +{ + (void)entity; + + auto &db = CharacterClassDatabase::getSingleton(); + + // Class selector + const auto &classNames = db.getClassNames(); + if (!classNames.empty()) { + int selected = -1; + for (int i = 0; i < (int)classNames.size(); i++) { + if (classNames[i] == comp.className) { + selected = i; + break; + } + } + if (ImGui::Combo("Class", &selected, + [](void *data, int idx) -> const char * { + const auto *names = + static_cast *>( + data); + if (idx < 0 || + idx >= (int)names->size()) + return nullptr; + return (*names)[idx].c_str(); + }, + (void *)&classNames, + (int)classNames.size())) { + if (selected >= 0 && + selected < (int)classNames.size()) + comp.className = classNames[selected]; + } + } else { + ImGui::Text("No classes defined in database."); + } + + ImGui::Separator(); + + if (ImGui::Button("Reinitialize from Class")) { + CharacterClassSystem::initializeFromClass(entity); + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("Reset stats/skills/needs to class base values,"); + ImGui::Text("then simulate all level-ups with AI point distribution."); + ImGui::EndTooltip(); + } + + // Level & XP + ImGui::InputInt("Level", &comp.level, 1, 5); + if (comp.level < 1) + comp.level = 1; + + int64_t xp = comp.currentXP; + ImGui::InputScalar("Current XP", ImGuiDataType_S64, &xp); + comp.currentXP = xp; + + ImGui::InputInt("Available Points", &comp.availablePoints, 1, 5); + + if (comp.levelUpPending) + ImGui::TextColored(ImVec4(0, 1, 0, 1), "LEVEL UP PENDING!"); + + ImGui::Separator(); + + // Stats: show ALL database stat definitions, not just stored ones + if (!db.getStatNames().empty()) { + ImGui::Text("Stats"); + for (const auto &name : db.getStatNames()) { + const auto *def = db.findStat(name); + if (!def) + continue; + + ImGui::PushID(name.c_str()); + if (def->kind == + CharacterClassDatabase::StatKind::ResourcePool) { + // Resource pool: current / max + int current = comp.getPoolCurrent(name); + int maxVal = comp.stats.count(name) ? + comp.stats.at(name) : + 0; + ImGui::Text("%s", name.c_str()); + ImGui::SameLine(100); + ImGui::Text("Cur:"); + ImGui::SameLine(); + if (ImGui::InputInt("##cur", ¤t, 1, 5)) + comp.setPoolCurrent(name, current); + ImGui::SameLine(); + ImGui::Text("Max:"); + ImGui::SameLine(); + if (ImGui::InputInt("##max", &maxVal, 1, 5)) { + comp.stats[name] = maxVal; + comp.setPoolCurrent(name, + comp.getPoolCurrent( + name)); + } + } else { + // Attribute + int val = comp.stats.count(name) ? + comp.stats.at(name) : + 0; + if (ImGui::InputInt(name.c_str(), &val, 1, 5)) + comp.stats[name] = val; + } + ImGui::PopID(); + } + } + + // Skills: show ALL database skill definitions + if (!db.getSkillNames().empty()) { + ImGui::Separator(); + ImGui::Text("Skills"); + for (const auto &name : db.getSkillNames()) { + int val = comp.skills.count(name) ? + comp.skills.at(name) : + 0; + if (ImGui::InputInt(name.c_str(), &val, 1, 5)) { + if (val < 0) + val = 0; + if (val > 100) + val = 100; + comp.skills[name] = val; + } + } + } + + // Needs: show ALL database need definitions + if (!db.getNeedNames().empty()) { + ImGui::Separator(); + ImGui::Text("Needs"); + for (const auto &name : db.getNeedNames()) { + int val = comp.needs.count(name) ? + comp.needs.at(name) : + 0; + if (ImGui::InputInt(name.c_str(), &val, 1, 5)) { + if (val < 0) + val = 0; + if (val > 1000) + val = 1000; + comp.needs[name] = val; + } + } + } + + return false; +} diff --git a/src/features/editScene/ui/CharacterClassEditor.hpp b/src/features/editScene/ui/CharacterClassEditor.hpp new file mode 100644 index 0000000..d91310c --- /dev/null +++ b/src/features/editScene/ui/CharacterClassEditor.hpp @@ -0,0 +1,17 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_EDITOR_HPP +#define EDITSCENE_CHARACTER_CLASS_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/CharacterClassComponent.hpp" + +class CharacterClassEditor : public ComponentEditor { +public: + const char *getName() const override { return "Character Class"; } + +protected: + bool renderComponent(flecs::entity entity, + CharacterClassComponent &comp) override; +}; + +#endif // EDITSCENE_CHARACTER_CLASS_EDITOR_HPP diff --git a/src/features/editScene/ui/CharacterClassOverrideEditor.cpp b/src/features/editScene/ui/CharacterClassOverrideEditor.cpp new file mode 100644 index 0000000..ef97650 --- /dev/null +++ b/src/features/editScene/ui/CharacterClassOverrideEditor.cpp @@ -0,0 +1,166 @@ +#include "CharacterClassOverrideEditor.hpp" +#include "../components/CharacterClassDatabase.hpp" +#include + +bool CharacterClassOverrideEditor::renderComponent( + flecs::entity entity, CharacterClassOverrideComponent &comp) +{ + (void)entity; + auto &db = CharacterClassDatabase::getSingleton(); + + // --- Stat Offsets --- + ImGui::Text("Stat Offsets"); + if (!comp.statOffsets.empty()) { + for (auto it = comp.statOffsets.begin(); + it != comp.statOffsets.end();) { + ImGui::PushID(it->first.c_str()); + int val = it->second; + ImGui::InputInt(it->first.c_str(), &val, 1, 5); + it->second = val; + ImGui::SameLine(); + if (ImGui::Button("Remove")) + it = comp.statOffsets.erase(it); + else + ++it; + ImGui::PopID(); + } + } else { + ImGui::TextDisabled("No stat offsets."); + } + + // Add stat offset + { + const auto &names = db.getStatNames(); + if (!names.empty()) { + static int selected = 0; + static int valBuf = 0; + ImGui::PushID("add_stat"); + if (selected >= (int)names.size()) + selected = 0; + ImGui::Combo("Stat", &selected, + [](void *data, int idx) -> const char * { + const auto *n = static_cast *>(data); + if (idx < 0 || + idx >= (int)n->size()) + return nullptr; + return (*n)[idx].c_str(); + }, + (void *)&names, (int)names.size()); + ImGui::SameLine(); + ImGui::InputInt("Offset", &valBuf, 1, 5); + ImGui::SameLine(); + if (ImGui::Button("Add")) { + comp.statOffsets[names[selected]] = valBuf; + valBuf = 0; + } + ImGui::PopID(); + } + } + + ImGui::Separator(); + + // --- Skill Offsets --- + ImGui::Text("Skill Offsets"); + if (!comp.skillOffsets.empty()) { + for (auto it = comp.skillOffsets.begin(); + it != comp.skillOffsets.end();) { + ImGui::PushID(it->first.c_str()); + int val = it->second; + ImGui::InputInt(it->first.c_str(), &val, 1, 5); + it->second = val; + ImGui::SameLine(); + if (ImGui::Button("Remove")) + it = comp.skillOffsets.erase(it); + else + ++it; + ImGui::PopID(); + } + } else { + ImGui::TextDisabled("No skill offsets."); + } + + // Add skill offset + { + const auto &names = db.getSkillNames(); + if (!names.empty()) { + static int selected = 0; + static int valBuf = 0; + ImGui::PushID("add_skill"); + if (selected >= (int)names.size()) + selected = 0; + ImGui::Combo("Skill", &selected, + [](void *data, int idx) -> const char * { + const auto *n = static_cast *>(data); + if (idx < 0 || + idx >= (int)n->size()) + return nullptr; + return (*n)[idx].c_str(); + }, + (void *)&names, (int)names.size()); + ImGui::SameLine(); + ImGui::InputInt("Offset", &valBuf, 1, 5); + ImGui::SameLine(); + if (ImGui::Button("Add")) { + comp.skillOffsets[names[selected]] = valBuf; + valBuf = 0; + } + ImGui::PopID(); + } + } + + ImGui::Separator(); + + // --- Need Offsets --- + ImGui::Text("Need Offsets"); + if (!comp.needOffsets.empty()) { + for (auto it = comp.needOffsets.begin(); + it != comp.needOffsets.end();) { + ImGui::PushID(it->first.c_str()); + int val = it->second; + ImGui::InputInt(it->first.c_str(), &val, 1, 5); + it->second = val; + ImGui::SameLine(); + if (ImGui::Button("Remove")) + it = comp.needOffsets.erase(it); + else + ++it; + ImGui::PopID(); + } + } else { + ImGui::TextDisabled("No need offsets."); + } + + // Add need offset + { + const auto &names = db.getNeedNames(); + if (!names.empty()) { + static int selected = 0; + static int valBuf = 0; + ImGui::PushID("add_need"); + if (selected >= (int)names.size()) + selected = 0; + ImGui::Combo("Need", &selected, + [](void *data, int idx) -> const char * { + const auto *n = static_cast *>(data); + if (idx < 0 || + idx >= (int)n->size()) + return nullptr; + return (*n)[idx].c_str(); + }, + (void *)&names, (int)names.size()); + ImGui::SameLine(); + ImGui::InputInt("Offset", &valBuf, 1, 5); + ImGui::SameLine(); + if (ImGui::Button("Add")) { + comp.needOffsets[names[selected]] = valBuf; + valBuf = 0; + } + ImGui::PopID(); + } + } + + return false; +} diff --git a/src/features/editScene/ui/CharacterClassOverrideEditor.hpp b/src/features/editScene/ui/CharacterClassOverrideEditor.hpp new file mode 100644 index 0000000..e272896 --- /dev/null +++ b/src/features/editScene/ui/CharacterClassOverrideEditor.hpp @@ -0,0 +1,21 @@ +#ifndef EDITSCENE_CHARACTER_CLASS_OVERRIDE_EDITOR_HPP +#define EDITSCENE_CHARACTER_CLASS_OVERRIDE_EDITOR_HPP +#pragma once + +#include "ComponentEditor.hpp" +#include "../components/CharacterClassComponent.hpp" + +class CharacterClassOverrideEditor + : public ComponentEditor { +public: + const char *getName() const override + { + return "Character Class Override"; + } + +protected: + bool renderComponent(flecs::entity entity, + CharacterClassOverrideComponent &comp) override; +}; + +#endif // EDITSCENE_CHARACTER_CLASS_OVERRIDE_EDITOR_HPP diff --git a/src/features/editScene/ui/CharacterSlotsEditor.cpp b/src/features/editScene/ui/CharacterSlotsEditor.cpp index 7487d93..ef90d4a 100644 --- a/src/features/editScene/ui/CharacterSlotsEditor.cpp +++ b/src/features/editScene/ui/CharacterSlotsEditor.cpp @@ -9,10 +9,9 @@ CharacterSlotsEditor::CharacterSlotsEditor(Ogre::SceneManager *sceneMgr) } bool CharacterSlotsEditor::renderComponent(flecs::entity entity, - CharacterSlotsComponent &cs) + CharacterSlotsComponent &cs) { bool modified = false; - (void)entity; if (ImGui::CollapsingHeader("Character Slots", ImGuiTreeNodeFlags_DefaultOpen)) { @@ -26,8 +25,7 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, if (ImGui::BeginCombo("Age", currentAge.c_str())) { for (const auto &age : ages) { bool isSelected = (currentAge == age); - if (ImGui::Selectable(age.c_str(), - isSelected)) { + if (ImGui::Selectable(age.c_str(), isSelected)) { cs.age = age; modified = true; cs.dirty = true; @@ -45,8 +43,7 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, if (ImGui::BeginCombo("Sex", currentSex.c_str())) { for (const auto &sex : sexes) { bool isSelected = (currentSex == sex); - if (ImGui::Selectable(sex.c_str(), - isSelected)) { + if (ImGui::Selectable(sex.c_str(), isSelected)) { cs.sex = sex; modified = true; cs.dirty = true; @@ -59,6 +56,23 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, ImGui::Separator(); + /* Global outfit level */ + const char *outfitLabels[] = { "Nude", "Lingerie", "Clothed" }; + int outfit = cs.outfitLevel; + if (outfit < 0) + outfit = 0; + if (outfit > 2) + outfit = 2; + if (ImGui::Combo("Outfit", &outfit, outfitLabels, 3)) { + cs.outfitLevel = outfit; + modified = true; + cs.dirty = true; + } + ImGui::SameLine(); + ImGui::TextDisabled("(global layer switch)"); + + ImGui::Separator(); + /* Front-facing axis */ { Ogre::Vector3 axis = cs.frontAxis; @@ -75,7 +89,6 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, cs.frontAxis.normalise(); modified = true; } - /* Quick presets */ ImGui::SameLine(); if (ImGui::SmallButton("-Z")) { cs.frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z; @@ -90,54 +103,175 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, ImGui::Separator(); - /* Collect available and configured slots */ + /* Shape Keys */ + if (ImGui::CollapsingHeader("Shape Keys")) { + auto shapeKeys = CharacterSlotSystem::getShapeKeyNames( + cs.age, cs.sex); + if (shapeKeys.empty()) { + ImGui::TextDisabled("No shape keys available."); + } else { + CharacterShapeKeysComponent *skc = nullptr; + if (entity.has()) + skc = &entity.get_mut(); + else { + entity.set({}); + skc = &entity.get_mut(); + } + + for (const auto &name : shapeKeys) { + float val = skc->weights[name]; + if (ImGui::SliderFloat(name.c_str(), &val, 0.0f, + 1.0f)) { + skc->weights[name] = val; + skc->dirty = true; + cs.dirty = true; + modified = true; + } + } + } + } + + ImGui::Separator(); + + /* Slot selections */ std::vector availableSlots = CharacterSlotSystem::getSlots(cs.age, cs.sex); - for (const auto &pair : cs.slots) { - if (std::find(availableSlots.begin(), - availableSlots.end(), + for (const auto &pair : cs.slotSelections) { + if (std::find(availableSlots.begin(), availableSlots.end(), pair.first) == availableSlots.end()) availableSlots.push_back(pair.first); } std::sort(availableSlots.begin(), availableSlots.end()); - /* Render mesh selector for each slot */ for (const auto &slot : availableSlots) { - Ogre::String currentMesh = ""; - auto it = cs.slots.find(slot); - if (it != cs.slots.end()) - currentMesh = it->second; + /* Ensure selection exists */ + if (cs.slotSelections.find(slot) == cs.slotSelections.end()) { + SlotSelection sel; + /* Migrate old explicit mesh if present */ + auto oldIt = cs.slots.find(slot); + if (oldIt != cs.slots.end()) + sel.explicitMesh = oldIt->second; + cs.slotSelections[slot] = sel; + } - std::vector meshes = - CharacterSlotSystem::getMeshes(cs.age, cs.sex, - slot); + SlotSelection &sel = cs.slotSelections[slot]; - Ogre::String label = slot; - Ogre::String preview = - currentMesh.empty() ? "(none)" : currentMesh; + if (ImGui::TreeNode(slot.c_str())) { + /* Resolved mesh preview */ + Ogre::String resolved = + CharacterSlotSystem::resolveMesh(cs.age, cs.sex, + slot, sel, + cs.outfitLevel); + ImGui::TextDisabled("Resolved: %s", + resolved.empty() ? "(none)" : + resolved.c_str()); - if (ImGui::BeginCombo(label.c_str(), preview.c_str())) { - bool noneSelected = currentMesh.empty(); - if (ImGui::Selectable("(none)", noneSelected)) { - cs.slots[slot] = ""; + /* Explicit mesh override */ + bool useExplicit = !sel.explicitMesh.empty(); + if (ImGui::Checkbox("Lock explicit mesh", &useExplicit)) { + if (!useExplicit) { + sel.explicitMesh.clear(); + } else { + /* Lock to currently resolved mesh */ + sel.explicitMesh = CharacterSlotSystem::resolveMesh( + cs.age, cs.sex, slot, sel, cs.outfitLevel); + } modified = true; cs.dirty = true; } - if (noneSelected) - ImGui::SetItemDefaultFocus(); - for (const auto &mesh : meshes) { - bool isSelected = (currentMesh == mesh); - if (ImGui::Selectable(mesh.c_str(), - isSelected)) { - cs.slots[slot] = mesh; - modified = true; - cs.dirty = true; + if (useExplicit) { + std::vector meshes = + CharacterSlotSystem::getMeshes( + cs.age, cs.sex, slot); + Ogre::String preview = + sel.explicitMesh.empty() ? + "(none)" : + sel.explicitMesh; + if (ImGui::BeginCombo("Mesh", preview.c_str())) { + if (ImGui::Selectable("(none)", + sel.explicitMesh.empty())) { + sel.explicitMesh.clear(); + modified = true; + cs.dirty = true; + } + for (const auto &m : meshes) { + bool isSelected = (sel.explicitMesh == m); + if (ImGui::Selectable(m.c_str(), isSelected)) { + sel.explicitMesh = m; + modified = true; + cs.dirty = true; + } + } + ImGui::EndCombo(); + } + } else { + /* Layer 1 combo */ + std::vector layer1Meshes = + CharacterSlotSystem::getMeshesForLayer( + cs.age, cs.sex, slot, 1); + Ogre::String l1Preview = "none"; + if (!sel.layer1Mesh.empty()) + l1Preview = CharacterSlotSystem::getMeshLabel( + cs.age, cs.sex, slot, sel.layer1Mesh); + if (ImGui::BeginCombo("Lingerie (Layer 1)", + l1Preview.c_str())) { + if (ImGui::Selectable("none", + sel.layer1Mesh.empty() || + sel.layer1Mesh == "none")) { + sel.layer1Mesh = "none"; + modified = true; + cs.dirty = true; + } + for (const auto &m : layer1Meshes) { + Ogre::String label = + CharacterSlotSystem::getMeshLabel( + cs.age, cs.sex, slot, m); + bool isSelected = (sel.layer1Mesh == m); + if (ImGui::Selectable(label.c_str(), + isSelected)) { + sel.layer1Mesh = m; + modified = true; + cs.dirty = true; + } + } + ImGui::EndCombo(); + } + + /* Layer 2 combo */ + std::vector layer2Meshes = + CharacterSlotSystem::getMeshesForLayer( + cs.age, cs.sex, slot, 2); + Ogre::String l2Preview = "none"; + if (!sel.layer2Mesh.empty()) + l2Preview = CharacterSlotSystem::getMeshLabel( + cs.age, cs.sex, slot, sel.layer2Mesh); + if (ImGui::BeginCombo("Clothing (Layer 2)", + l2Preview.c_str())) { + if (ImGui::Selectable("none", + sel.layer2Mesh.empty() || + sel.layer2Mesh == "none")) { + sel.layer2Mesh = "none"; + modified = true; + cs.dirty = true; + } + for (const auto &m : layer2Meshes) { + Ogre::String label = + CharacterSlotSystem::getMeshLabel( + cs.age, cs.sex, slot, m); + bool isSelected = (sel.layer2Mesh == m); + if (ImGui::Selectable(label.c_str(), + isSelected)) { + sel.layer2Mesh = m; + modified = true; + cs.dirty = true; + } + } + ImGui::EndCombo(); } - if (isSelected) - ImGui::SetItemDefaultFocus(); } - ImGui::EndCombo(); + + ImGui::TreePop(); } } diff --git a/src/features/editScene/ui/DialogueEditor.cpp b/src/features/editScene/ui/DialogueEditor.cpp deleted file mode 100644 index c85b60b..0000000 --- a/src/features/editScene/ui/DialogueEditor.cpp +++ /dev/null @@ -1,93 +0,0 @@ -#include "DialogueEditor.hpp" -#include - -bool DialogueEditor::renderComponent(flecs::entity entity, - DialogueComponent &dc) -{ - (void)entity; - bool modified = false; - - if (ImGui::CollapsingHeader("Dialogue Box", - ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::Indent(); - - // State display (read-only) - const char *stateNames[] = { "Idle", "Showing", - "AwaitingChoice" }; - ImGui::Text("State: %s", stateNames[(int)dc.state]); - ImGui::Separator(); - - // Font configuration - char fontNameBuf[256]; - snprintf(fontNameBuf, sizeof(fontNameBuf), "%s", - dc.fontName.c_str()); - if (ImGui::InputText("Font Name", fontNameBuf, - sizeof(fontNameBuf))) { - dc.fontName = fontNameBuf; - modified = true; - } - - if (ImGui::DragFloat("Font Size", &dc.fontSize, 0.5f, 8.0f, - 128.0f)) { - modified = true; - } - - if (ImGui::DragFloat("Speaker Font Size", &dc.speakerFontSize, - 0.5f, 8.0f, 128.0f)) { - modified = true; - } - - ImGui::Separator(); - - // Visual configuration - if (ImGui::SliderFloat("Background Opacity", - &dc.backgroundOpacity, 0.0f, 1.0f)) { - modified = true; - } - - if (ImGui::SliderFloat("Box Height Fraction", - &dc.boxHeightFraction, 0.1f, 0.5f)) { - modified = true; - } - - if (ImGui::SliderFloat("Box Position Fraction", - &dc.boxPositionFraction, 0.0f, 1.0f)) { - modified = true; - } - - ImGui::Separator(); - - // Enabled toggle - if (ImGui::Checkbox("Enabled", &dc.enabled)) - modified = true; - - ImGui::Separator(); - - // Test buttons (only in editor mode) - if (ImGui::Button("Test: Show Sample Text")) { - dc.show("This is a sample narration text for testing the dialogue box layout. " - "It should wrap properly within the box.", - {}, "Test Speaker"); - modified = true; - } - - if (ImGui::Button("Test: Show With Choices")) { - std::vector testChoices = { - "Option 1: Go left", "Option 2: Go right", - "Option 3: Stay" - }; - dc.show("What would you like to do?", testChoices, - "Narrator"); - modified = true; - } - - if (ImGui::Button("Reset Dialogue")) { - dc.reset(); - modified = true; - } - - ImGui::Unindent(); - } - - return modified; -} diff --git a/src/features/editScene/ui/DialogueEditor.hpp b/src/features/editScene/ui/DialogueEditor.hpp deleted file mode 100644 index 3da9215..0000000 --- a/src/features/editScene/ui/DialogueEditor.hpp +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef EDITSCENE_DIALOGUEEDITOR_HPP -#define EDITSCENE_DIALOGUEEDITOR_HPP -#pragma once - -#include "ComponentEditor.hpp" -#include "../components/DialogueComponent.hpp" - -/** - * Editor for DialogueComponent - */ -class DialogueEditor : public ComponentEditor { -public: - bool renderComponent(flecs::entity entity, - DialogueComponent &dc) override; - const char *getName() const override - { - return "Dialogue Box"; - } -}; - -#endif // EDITSCENE_DIALOGUEEDITOR_HPP