Better tag support

This commit is contained in:
2026-05-10 14:12:09 +03:00
parent 11530dd7fc
commit 333a0b9938
45 changed files with 4809 additions and 1020 deletions
+20 -4
View File
@@ -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})
+67 -22
View File
@@ -1,4 +1,5 @@
#include <iostream>
#include <filesystem>
#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<flecs::entity> 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<StartupMenuSystem>(
m_world, m_sceneMgr, this);
m_dialogueSystem = std::make_unique<DialogueSystem>(
m_world, m_sceneMgr, this);
DialogueSystem::getInstance().init(this);
DialogueSystem::getInstance().loadSettings("dialogue.json");
m_characterClassSystem =
std::make_unique<CharacterClassSystem>(
m_world, this);
CharacterClassDatabase::loadFromJson(
"character_class.json");
m_playerControllerSystem =
std::make_unique<PlayerControllerSystem>(
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<StartupMenuComponent>();
m_world.component<DialogueComponent>();
m_world.component<PlayerControllerComponent>();
m_world.component<InWater>();
m_world.component<CharacterClassComponent>();
m_world.component<CharacterClassOverrideComponent>();
// Register environment components
m_world.component<SunComponent>();
@@ -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();
}
+7 -5
View File
@@ -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<ActuatorSystem> m_actuatorSystem;
std::unique_ptr<EventHandlerSystem> m_eventHandlerSystem;
std::unique_ptr<ItemSystem> m_itemSystem;
std::unique_ptr<CharacterClassSystem> m_characterClassSystem;
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<DialogueSystem> m_dialogueSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
// State
@@ -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;
}
@@ -0,0 +1,107 @@
#ifndef EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP
#define EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP
#pragma once
#include <Ogre.h>
#include <unordered_map>
/**
* 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<Ogre::String, int> 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<Ogre::String, int> currentPools;
/** Current skill values (0-100 proficiency) */
std::unordered_map<Ogre::String, int> skills;
/** Current need values (0-1000) */
std::unordered_map<Ogre::String, int> 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<Ogre::String, int> statOffsets;
/** Flat offsets added to base skills */
std::unordered_map<Ogre::String, int> skillOffsets;
/** Flat offsets added to base needs */
std::unordered_map<Ogre::String, int> needOffsets;
};
#endif // EDITSCENE_CHARACTER_CLASS_COMPONENT_HPP
@@ -0,0 +1,442 @@
#include "CharacterClassDatabase.hpp"
#include <OgreLogManager.h>
#include <OgreResourceGroupManager.h>
#include <nlohmann/json.hpp>
#include <fstream>
#include <filesystem>
// ---------------------------------------------------------------------------
// 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<int64_t>(cls.xpForLevel.evaluate(level));
}
int CharacterClassDatabase::computePointsForLevel(int level,
const ClassDef &cls) const
{
return static_cast<int>(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<int>(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<int>(it->second.evaluate(level));
}
int CharacterClassDatabase::computeStatCost(int currentValue,
const ClassDef &cls) const
{
return static_cast<int>(cls.statCost.evaluate(0, static_cast<double>(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<std::string>());
}
if (val.contains("base_stats") &&
val["base_stats"].is_object()) {
for (auto &[k, v] : val["base_stats"].items())
cls.baseStats[k] = v.get<int>();
}
if (val.contains("base_skills") &&
val["base_skills"].is_object()) {
for (auto &[k, v] : val["base_skills"].items())
cls.baseSkills[k] = v.get<int>();
}
if (val.contains("base_needs") &&
val["base_needs"].is_object()) {
for (auto &[k, v] : val["base_needs"].items())
cls.baseNeeds[k] = v.get<int>();
}
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<std::string>());
}
if (val.contains("skill_growth") &&
val["skill_growth"].is_object()) {
for (auto &[k, v] : val["skill_growth"].items())
cls.skillGrowth[k] =
Formula(v.get<std::string>());
}
db.addOrReplaceClass(cls);
}
}
return true;
}
@@ -0,0 +1,153 @@
#ifndef EDITSCENE_CHARACTER_CLASS_DATABASE_HPP
#define EDITSCENE_CHARACTER_CLASS_DATABASE_HPP
#pragma once
#include "Formula.hpp"
#include <Ogre.h>
#include <unordered_map>
#include <vector>
/**
* 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<Ogre::String> primaryStats;
std::unordered_map<Ogre::String, int> baseStats;
std::unordered_map<Ogre::String, int> baseSkills;
std::unordered_map<Ogre::String, int> baseNeeds;
Formula xpForLevel;
Formula pointsPerLevel;
Formula statCost;
std::unordered_map<Ogre::String, Formula> statGrowth;
std::unordered_map<Ogre::String, Formula> 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<Ogre::String> &getStatNames() const
{
return m_statNames;
}
const std::vector<Ogre::String> &getSkillNames() const
{
return m_skillNames;
}
const std::vector<Ogre::String> &getNeedNames() const
{
return m_needNames;
}
const std::vector<Ogre::String> &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<Ogre::String, StatDef> m_stats;
std::unordered_map<Ogre::String, SkillDef> m_skills;
std::unordered_map<Ogre::String, NeedDef> m_needs;
std::unordered_map<Ogre::String, ClassDef> m_classes;
std::vector<Ogre::String> m_statNames;
std::vector<Ogre::String> m_skillNames;
std::vector<Ogre::String> m_needNames;
std::vector<Ogre::String> m_classNames;
};
#endif // EDITSCENE_CHARACTER_CLASS_DATABASE_HPP
@@ -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<CharacterClassComponent>(
"Character Class", "Game",
std::make_unique<CharacterClassEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<CharacterClassComponent>())
e.set<CharacterClassComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<CharacterClassComponent>())
e.remove<CharacterClassComponent>();
});
}
@@ -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<CharacterClassOverrideComponent>(
"Character Class Override", "Game",
std::make_unique<CharacterClassOverrideEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<CharacterClassOverrideComponent>())
e.set<CharacterClassOverrideComponent>({});
},
// Remover
[](flecs::entity e) {
if (e.has<CharacterClassOverrideComponent>())
e.remove<CharacterClassOverrideComponent>();
});
}
@@ -3,6 +3,18 @@
#pragma once
#include <Ogre.h>
#include <unordered_map>
#include <vector>
/**
* 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<Ogre::String, Ogre::String> slots;
/* Per-slot layer selections (runtime) */
std::unordered_map<Ogre::String, SlotSelection> 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<Ogre::String, float> weights;
bool dirty = true;
};
#endif // EDITSCENE_CHARACTERSLOTS_HPP
@@ -1,131 +0,0 @@
#ifndef EDITSCENE_DIALOGUE_COMPONENT_HPP
#define EDITSCENE_DIALOGUE_COMPONENT_HPP
#pragma once
#include <Ogre.h>
#include <functional>
#include <string>
#include <vector>
/**
* Visual-novel style dialogue box component.
*
* Displays a narration text box at the bottom of the screen with optional
* player choices. The dialogue can be driven via the EventBus system
* (using "dialogue_show" event) or directly via the component API.
*
* Only active in game mode (GamePlayState::Playing).
*
* Event payload (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<Ogre::String> choices;
/** Font configuration */
Ogre::String fontName = "Jupiteroid-Regular.ttf";
float fontSize = 24.0f;
/** Speaker name font size (slightly smaller) */
float speakerFontSize = 20.0f;
/** Background opacity (0.0 - 1.0) */
float backgroundOpacity = 0.85f;
/** Height of the dialogue box as fraction of screen height (0.0 - 1.0) */
float boxHeightFraction = 0.25f;
/** Vertical position as fraction from top (0.0 = top, 0.75 = bottom quarter) */
float boxPositionFraction = 0.75f;
/** Whether the dialogue box is enabled (can be toggled) */
bool enabled = true;
/** Callback invoked when a choice is selected (choice index, 1-based) */
std::function<void(int)> onChoiceSelected;
/** Callback invoked when dialogue is dismissed (no choices mode) */
std::function<void()> onDismissed;
/** Callback invoked when dialogue starts showing */
std::function<void()> onShow;
/* --- API --- */
/** Show dialogue with given text and optional choices */
void show(const Ogre::String &narrationText,
const std::vector<Ogre::String> &choiceLabels = {},
const Ogre::String &speakerName = "")
{
text = narrationText;
choices = choiceLabels;
speaker = speakerName;
state = choices.empty() ? State::Showing :
State::AwaitingChoice;
if (onShow)
onShow();
}
/** Progress the dialogue (click-through when no choices) */
void progress()
{
if (state == State::Showing && choices.empty()) {
state = State::Idle;
if (onDismissed)
onDismissed();
}
}
/** Select a choice by 1-based index */
void selectChoice(int index)
{
if (state == State::AwaitingChoice && index >= 1 &&
index <= (int)choices.size()) {
state = State::Idle;
if (onChoiceSelected)
onChoiceSelected(index);
}
}
/** Check if dialogue is currently active */
bool isActive() const
{
return state != State::Idle;
}
/** Reset dialogue to idle state */
void reset()
{
state = State::Idle;
text.clear();
choices.clear();
speaker.clear();
}
};
#endif // EDITSCENE_DIALOGUE_COMPONENT_HPP
@@ -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>(
DialogueComponent_name, DialogueComponent_group,
std::make_unique<DialogueEditor>(),
// Adder
[](flecs::entity e) {
if (!e.has<DialogueComponent>()) {
e.set<DialogueComponent>({});
}
},
// Remover
[](flecs::entity e) {
if (e.has<DialogueComponent>()) {
e.remove<DialogueComponent>();
}
});
}
@@ -0,0 +1,192 @@
#include "Formula.hpp"
#include <cctype>
#include <cmath>
#include <OgreLogManager.h>
Formula::Formula(const Ogre::String &expr)
: m_expression(expr)
{
m_valid = !expr.empty();
}
double Formula::evaluate(const std::unordered_map<std::string, double> &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<std::string, double> vars;
vars["level"] = static_cast<double>(level);
return evaluate(vars);
}
double Formula::evaluate(int level, double current) const
{
std::unordered_map<std::string, double> vars;
vars["level"] = static_cast<double>(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<size_t>(s - start));
skipWhitespace();
if (consume('(')) {
// Function call
std::vector<double> 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;
}
@@ -0,0 +1,65 @@
#ifndef EDITSCENE_FORMULA_HPP
#define EDITSCENE_FORMULA_HPP
#pragma once
#include <Ogre.h>
#include <string>
#include <unordered_map>
/**
* 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<std::string, double> &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<std::string, double> *vars;
double parseExpression();
double parseTerm();
double parsePower();
double parseUnary();
double parsePrimary();
void skipWhitespace();
bool consume(char c);
};
};
#endif // EDITSCENE_FORMULA_HPP
@@ -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!")
@@ -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!")
@@ -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!")
@@ -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!")
@@ -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!")
@@ -0,0 +1,352 @@
#include "LuaCharacterClassApi.hpp"
#include "../systems/CharacterClassSystem.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include "../components/CharacterClassComponent.hpp"
#include "../components/GoapBlackboard.hpp"
#include <OgreLogManager.h>
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<flecs::world *>(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<Ogre::String> &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<int>(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<int>(lua_tointeger(L, 1));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L, e.get<CharacterClassComponent>().level);
return 1;
}
static int luaGetXP(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L,
(lua_Integer)e.get<CharacterClassComponent>().currentXP);
return 1;
}
static int luaAddXP(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
int64_t amount = static_cast<int64_t>(lua_tointeger(L, 2));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
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<CharacterClassComponent>();
cc.currentXP += amount;
lua_pushboolean(L, 1);
return 1;
}
static int luaGetStat(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *statName = lua_tostring(L, 2);
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!statName) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L,
(lua_Integer)e.get<CharacterClassComponent>().getStat(
statName));
return 1;
}
static int luaGetSkill(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *skillName = lua_tostring(L, 2);
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!skillName) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L,
(lua_Integer)e.get<CharacterClassComponent>().getSkill(
skillName));
return 1;
}
static int luaGetNeed(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *needName = lua_tostring(L, 2);
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!needName) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L,
(lua_Integer)e.get<CharacterClassComponent>().getNeed(
needName));
return 1;
}
static int luaGetAvailablePoints(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L,
(lua_Integer)e.get<CharacterClassComponent>()
.availablePoints);
return 1;
}
static int luaSetNeed(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *needName = lua_tostring(L, 2);
int value = static_cast<int>(lua_tointeger(L, 3));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!needName) {
return 0;
}
auto &cc = e.get_mut<CharacterClassComponent>();
cc.needs[needName] = value;
return 0;
}
static int luaGetPoolCurrent(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *poolName = lua_tostring(L, 2);
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!poolName) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L, (lua_Integer)e.get<CharacterClassComponent>().
getPoolCurrent(poolName));
return 1;
}
static int luaGetPoolMax(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *poolName = lua_tostring(L, 2);
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!poolName) {
lua_pushinteger(L, 0);
return 1;
}
lua_pushinteger(L, (lua_Integer)e.get<CharacterClassComponent>().
getPoolMax(poolName));
return 1;
}
static int luaSetPoolCurrent(lua_State *L)
{
int entityId = static_cast<int>(lua_tointeger(L, 1));
const char *poolName = lua_tostring(L, 2);
int value = static_cast<int>(lua_tointeger(L, 3));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterClassComponent>() ||
!poolName) {
lua_pushboolean(L, 0);
return 1;
}
e.get_mut<CharacterClassComponent>().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
@@ -0,0 +1,13 @@
#ifndef EDITSCENE_LUA_CHARACTER_CLASS_API_HPP
#define EDITSCENE_LUA_CHARACTER_CLASS_API_HPP
#include <lua.hpp>
namespace editScene
{
void registerLuaCharacterClassApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_CHARACTER_CLASS_API_HPP
+56 -47
View File
@@ -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(
@@ -0,0 +1,199 @@
#include "LuaDialogueApi.hpp"
#include "../systems/DialogueSystem.hpp"
#include <OgreLogManager.h>
namespace editScene
{
// ---------------------------------------------------------------------------
// Helper: read string vector from Lua table
// ---------------------------------------------------------------------------
static std::vector<Ogre::String> readStringVector(lua_State *L, int idx)
{
std::vector<Ogre::String> 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<Ogre::String> 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
@@ -0,0 +1,14 @@
#ifndef EDITSCENE_LUA_DIALOGUE_API_HPP
#define EDITSCENE_LUA_DIALOGUE_API_HPP
#pragma once
#include <lua.hpp>
namespace editScene
{
void registerLuaDialogueApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_DIALOGUE_API_HPP
@@ -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 <OgreLogManager.h>
#include <imgui.h>
#include <algorithm>
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<CharacterClassComponent>())
return false;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<CharacterClassComponent>())
return false;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<CharacterClassOverrideComponent>()) {
const auto &ov = entity.get<CharacterClassOverrideComponent>();
for (const auto &pair : ov.statOffsets) {
cc.stats[pair.first] += pair.second;
}
}
// Base skills + overrides
cc.skills = cls->baseSkills;
if (entity.has<CharacterClassOverrideComponent>()) {
const auto &ov = entity.get<CharacterClassOverrideComponent>();
for (const auto &pair : ov.skillOffsets) {
cc.skills[pair.first] += pair.second;
}
}
// Base needs + offsets
cc.needs = cls->baseNeeds;
if (entity.has<CharacterClassOverrideComponent>()) {
const auto &ov = entity.get<CharacterClassOverrideComponent>();
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<CharacterClassComponent>().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<int>(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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
auto &db = CharacterClassDatabase::getSingleton();
if (!entity.has<GoapBlackboard>())
entity.set<GoapBlackboard>({});
auto &bb = entity.get_mut<GoapBlackboard>();
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<CharacterClassComponent>().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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<PlayerControllerComponent>();
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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<Ogre::String> 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<flecs::entity_t> closedDialogs;
for (flecs::entity_t id : m_levelUpDialogs) {
flecs::entity e = m_world.entity(id);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
closedDialogs.push_back(id);
continue;
}
renderLevelUpDialog(e);
}
for (flecs::entity_t id : closedDialogs)
m_levelUpDialogs.erase(id);
// Character sheets
std::vector<flecs::entity_t> closedSheets;
for (flecs::entity_t id : m_sheets) {
flecs::entity e = m_world.entity(id);
if (!e.is_alive() || !e.has<CharacterClassComponent>()) {
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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<CharacterClassComponent>())
return;
auto &cc = entity.get_mut<CharacterClassComponent>();
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<InventoryComponent>()) {
ImGui::Text("Inventory available (not yet displayed)");
} else {
ImGui::Text("No inventory");
}
ImGui::End();
if (!open)
m_sheets.erase(entity.id());
}
@@ -0,0 +1,60 @@
#ifndef EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
#define EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_set>
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<flecs::entity_t> m_levelUpDialogs;
// Track which entities have an open character sheet
std::unordered_set<flecs::entity_t> m_sheets;
};
#endif // EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
@@ -1,12 +1,17 @@
#include "CharacterSlotSystem.hpp"
#include "../components/Transform.hpp"
#include "../components/AnimationTree.hpp"
#include <OgreAnimation.h>
#include <OgreAnimationState.h>
#include <OgreAnimationTrack.h>
#include <OgreDataStream.h>
#include <OgreEntity.h>
#include <OgreKeyFrame.h>
#include <OgreLogManager.h>
#include <OgreMesh.h>
#include <OgreResourceGroupManager.h>
#include <OgreSceneNode.h>
#include <iostream>
#include <algorithm>
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<Ogre::String> CharacterSlotSystem::getSlots(
return slots;
}
std::vector<Ogre::String> CharacterSlotSystem::getMeshesForLayer(
const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot, int layer)
{
std::vector<Ogre::String> 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<std::string>();
}
return label;
}
}
return mesh;
}
std::vector<Ogre::String> CharacterSlotSystem::getMeshes(
const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot)
@@ -148,55 +201,188 @@ std::vector<Ogre::String> 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<Ogre::String>());
for (const auto &entry : s_bodyParts[age][sex][slot])
meshes.push_back(entry["mesh"].get<Ogre::String>());
return meshes;
}
std::vector<Ogre::String> CharacterSlotSystem::getShapeKeyNames(
const Ogre::String &age, const Ogre::String &sex)
{
std::set<Ogre::String> 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<Ogre::String>());
}
}
return std::vector<Ogre::String>(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<Ogre::String>();
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<Ogre::String>();
return "";
}
void CharacterSlotSystem::update()
{
if (!m_initialized)
return;
int total = 0;
int dirtyCount = 0;
m_world.query<CharacterSlotsComponent>().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<ushort>(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<TransformComponent>()) {
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<TransformComponent>();
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<TransformComponent>())
return;
auto &transform = e.get_mut<TransformComponent>();
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<AnimationTreeComponent>())
e.get_mut<AnimationTreeComponent>().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<ushort>(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<Ogre::String, size_t> nameToIndex;
for (size_t i = 0; i < shapeKeys.size(); ++i)
nameToIndex[shapeKeys[i].get<Ogre::String>()] = i;
/* Apply weights from CharacterShapeKeysComponent */
if (e.has<CharacterShapeKeysComponent>()) {
auto &skc = e.get_mut<CharacterShapeKeysComponent>();
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<ushort>(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) {
@@ -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<Ogre::String> garments;
std::vector<Ogre::String> tags;
std::vector<Ogre::String> 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<Ogre::String> getSexes(const Ogre::String &age);
static std::vector<Ogre::String> getSlots(const Ogre::String &age,
const Ogre::String &sex);
/* Query meshes for a specific layer */
static std::vector<Ogre::String> 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<Ogre::String> 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<Ogre::String> 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;
+261 -109
View File
@@ -1,182 +1,336 @@
#include "DialogueSystem.hpp"
#include "../EditorApp.hpp"
#include "../components/DialogueComponent.hpp"
#include "../systems/EventBus.hpp"
#include "../components/EventParams.hpp"
#include <imgui.h>
#include <OgreFontManager.h>
#include <OgreImGuiOverlay.h>
#include <OgreLogManager.h>
#include <OgreOverlayManager.h>
#include <nlohmann/json.hpp>
#include <fstream>
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 &params) {
// Find the first entity with DialogueComponent
m_world.query<DialogueComponent>().each(
[&](flecs::entity e, DialogueComponent &dc) {
if (!dc.enabled)
return;
m_showListenerId = EventBus::getInstance().subscribe(
"dialogue_show",
[this](const Ogre::String &,
const editScene::EventParams &params) {
Ogre::String text = params.getString("text");
if (text.empty())
return;
Ogre::String text =
params.getString("text");
if (text.empty())
return;
std::vector<Ogre::String> 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<Ogre::String> 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<Ogre::String> &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<DialogueComponent>().each(
[&](flecs::entity e, DialogueComponent &) {
if (!dialogueEntity.is_alive())
dialogueEntity = e;
});
if (dialogueEntity.is_alive()) {
auto &dc = dialogueEntity.get_mut<DialogueComponent>();
ensureFontLoaded(dc.fontName, dc.fontSize);
}
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<DialogueComponent>().each(
[&](flecs::entity e, DialogueComponent &) {
if (!dialogueEntity.is_alive())
dialogueEntity = e;
});
if (!dialogueEntity.is_alive())
return;
auto &dc = dialogueEntity.get_mut<DialogueComponent>();
if (!dc.enabled || !dc.isActive())
return;
renderDialogueBox(dc);
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);
}
}
}
@@ -2,57 +2,129 @@
#define EDITSCENE_DIALOGUESYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <imgui.h>
#include <memory>
#include <functional>
#include <string>
#include <vector>
#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<Ogre::String> &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<void(int)> onChoiceSelected;
std::function<void()> onDismissed;
std::function<void()> 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<Ogre::String> 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
@@ -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 <algorithm>
#include <filesystem>
#include <cstring>
#include <sstream>
#include <string>
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<CharacterClassComponent>()) {
auto &cc = entity.get_mut<CharacterClassComponent>();
m_componentRegistry.render<CharacterClassComponent>(entity, cc);
componentCount++;
}
// Render CharacterClassOverride if present
if (entity.has<CharacterClassOverrideComponent>()) {
auto &cco = entity.get_mut<CharacterClassOverrideComponent>();
m_componentRegistry.render<CharacterClassOverrideComponent>(
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<Ogre::String> 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<Ogre::String> 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();
}
@@ -8,6 +8,7 @@
#include <vector>
#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<EntityNameComponent> m_nameQuery;
@@ -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<std::string>();
}
// 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) {
@@ -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);
@@ -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);
@@ -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
// ---------------------------------------------------------------------------
@@ -0,0 +1,408 @@
#include "CharacterClassDatabaseEditor.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include <OgreLogManager.h>
#include <cstring>
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();
}
}
@@ -0,0 +1,39 @@
#ifndef EDITSCENE_CHARACTER_CLASS_DATABASE_EDITOR_HPP
#define EDITSCENE_CHARACTER_CLASS_DATABASE_EDITOR_HPP
#pragma once
#include <Ogre.h>
#include <imgui.h>
#include <vector>
/**
* 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
@@ -0,0 +1,151 @@
#include "CharacterClassEditor.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include "../systems/CharacterClassSystem.hpp"
#include <imgui.h>
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<const std::vector<Ogre::String> *>(
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", &current, 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;
}
@@ -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<CharacterClassComponent> {
public:
const char *getName() const override { return "Character Class"; }
protected:
bool renderComponent(flecs::entity entity,
CharacterClassComponent &comp) override;
};
#endif // EDITSCENE_CHARACTER_CLASS_EDITOR_HPP
@@ -0,0 +1,166 @@
#include "CharacterClassOverrideEditor.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include <imgui.h>
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<const std::vector<
Ogre::String> *>(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<const std::vector<
Ogre::String> *>(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<const std::vector<
Ogre::String> *>(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;
}
@@ -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<CharacterClassOverrideComponent> {
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
@@ -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<CharacterShapeKeysComponent>())
skc = &entity.get_mut<CharacterShapeKeysComponent>();
else {
entity.set<CharacterShapeKeysComponent>({});
skc = &entity.get_mut<CharacterShapeKeysComponent>();
}
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<Ogre::String> 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<Ogre::String> 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<Ogre::String> 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<Ogre::String> 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<Ogre::String> 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();
}
}
@@ -1,93 +0,0 @@
#include "DialogueEditor.hpp"
#include <imgui.h>
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<Ogre::String> 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;
}
@@ -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<DialogueComponent> {
public:
bool renderComponent(flecs::entity entity,
DialogueComponent &dc) override;
const char *getName() const override
{
return "Dialogue Box";
}
};
#endif // EDITSCENE_DIALOGUEEDITOR_HPP