Compare commits

...

3 Commits

Author SHA1 Message Date
slapin d4974f5d90 Added src/features/editScene/AGENTS.md 2026-06-22 00:50:21 +03:00
slapin 68ca1f750d Added AGENTS.md 2026-06-22 00:39:14 +03:00
slapin 0ed5d9a970 Improvements for character registry 2026-06-19 14:53:26 +03:00
27 changed files with 1401 additions and 816 deletions
+370
View File
@@ -0,0 +1,370 @@
# World2 Game Project - Agent Guide
## Project Overview
World2 is a 3D life simulation game built using the OGRE3D rendering engine. The project features an Entity-Component-System (ECS) architecture powered by Flecs, with Jolt Physics for simulation, Lua scripting for story content, and ImGui for user interfaces.
## Technology Stack
### Core Dependencies
- **Rendering**: OGRE3D 14.x+ (Object-Oriented Graphics Rendering Engine)
- **ECS Framework**: Flecs (Fast Lightweight Entity Component System)
- **Physics**: Jolt Physics with double precision support (JPH_DOUBLE_PRECISION)
- **Scripting**: Lua 5.4 with LPEG library
- **Audio**: miniaudio (single-header audio library)
- **UI**: ImGui (integrated via Ogre::ImGuiOverlay)
- **Model Loading**: Assimp with pugixml
- **Navigation**: Recast/Detour for crowd simulation
- **Procedural Generation**: OgreProcedural
- **Profiling**: Tracy frame profiler
### Build Requirements
- CMake 3.13+
- C++17 compatible compiler
- Blender (for asset processing, path configurable via `BLENDER` CMake variable)
- OGRE SDK with components: Bites, Paging, Terrain, MeshLodGenerator
## Project Structure
```
/
├── CMakeLists.txt # Main build configuration
├── resources.cfg # OGRE resource locations
├── .clang-format # Linux kernel code style config
├── Game.cpp # Main game executable entry point (legacy)
├── Editor.cpp # World editor executable (legacy)
├── Procedural.cpp # Procedural generation test / character controller demo (legacy)
├── Bootstrap.cpp # Legacy character controller demo (legacy)
├── terrain.cpp # Terrain system test executable (legacy)
├── src/ # Source code
│ ├── gamedata/ # Core ECS modules and game logic (legacy)
│ │ ├── GameData.cpp/h # ECS world setup and management (legacy)
│ │ ├── Components.h # ECS component definitions (legacy)
│ │ ├── *Module.cpp/h # Individual ECS modules (legacy)
│ │ └── items/ # Game item implementations (harbour, temple, town, etc.) (legacy)
│ ├── lua/ # Lua integration
│ │ ├── lua-5.4.8/ # Lua source code
│ │ └── lpeg-1.1.0/ # LPEG parsing library
│ ├── physics/ # Jolt Physics wrapper (physics.h/cpp) (legacy - for buildiung legacy code only)
│ ├── sound/ # Audio system (miniaudio)
│ ├── crowd/ # Recast/Detour navigation
│ │ ├── include/ # Crowd headers (OgreRecast, OgreDetourCrowd, etc.)
│ │ └── src/ # Crowd implementations
│ ├── editor/ # Editor-specific code (legacy)
│ │ ├── EditorGizmoModule.h/cpp
│ │ ├── EditorInputModule.h/cpp
│ │ └── main.cpp
│ ├── features/ # Feature modules
│ │ ├── characters/ # Character rendering test
│ │ ├── editScene/ # Scene editor with ECS, it has game mode and all features, current work is here
│ │ └── sceneEditor/ # some other editor code
│ ├── world/ # World generation and GOAP AI (legacy)
│ ├── terrain/ # Terrain system (legacy)
│ ├── tests/ # Test utilities
│ ├── text_editor/ # ImGui text editor component
│ ├── miniaudio/ # Audio library
│ ├── sceneloader/ # Scene loading utilitie (legacy)
│ ├── aitoolkit/ # AI utilities (GOAP, FSM, Behavior Trees) (legacy)
│ └── vehicles/ # Vehicle definitions (legacy)
├── assets/ # Source assets (Blender files)
│ ├── blender/ # Blender source files
│ │ ├── buildings/ # Building models (.blend)
│ │ ├── characters/ # Character models and clothes (characters asset pipeline)
│ │ ├── vehicles/ # Vehicle models
│ │ ├── scripts/ # Blender Python scripts for export
│ │ └── world/ # World terrain models
│ ├── textures/ # Source textures
│ └── fonts/ # Game fonts
├── lua-scripts/ # Lua game scripts
│ ├── narrator/ # Ink narrative parser
│ ├── stories/ # Story scripts (.ink format, compiled to .lua)
│ └── json/ # JSON library for Lua
├── water/ # Water rendering system (legacy)
│ ├── water.h/cpp # Water class
│ ├── *.frag, *.vert # GLSL shaders
│ └── water.compositor # OGRE compositor script
├── skybox/ # Sky rendering resources
├── resources/ # Runtime resources (build output)
├── audio/ # Audio resources
├── morph/ # Character morphing data
└── build/ # build directory
└── build-vscode/ # VSCode build directory
```
## Critical Constraints & Rules
- The source code is the only source of truth. Please check code as documentation might be out of sync.
- Do not modify legacy code. Current code is at src/features/editScene and its dependencies.
- Please ask questions if anything is unclear.
- Please update tests, documentation and examples every time API or modules and systems change.
## Build Instructions
### Prerequisites
1. Install OGRE3D SDK with Bites, Paging, Terrain, MeshLodGenerator components
2. Install dependencies: Jolt Physics, Flecs, Assimp, pugixml, OgreProcedural, Tracy, SDL2, ZLIB
3. Set `CMAKE_PREFIX_PATH` to OGRE installation
4. Configure Blender path in CMake (default: `${CMAKE_SOURCE_DIR}/../../blender-bin/bin/blender`)
### Build Commands
```bash
# Configure (adjust paths as needed)
cmake -B build -S . -DCMAKE_PREFIX_PATH=/path/to/ogre-sdk
# Build all targets
cmake --build build
# Build specific targets
cmake --build build --target Game # Main game executable (legacy)
cmake --build build --target Editor # World editor (legacy)
cmake --build build --target Procedural # Procedural test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target TerrainTest # Terrain test (legacy)
cmake --build build --target editSceneEditor # Current executable for scene editor and game
```
### Build Outputs
- `build/Game` - Main game executable (legacy)
- `build/Editor` - World editor executable (legacy)
- `build/Procedural` - Procedural generation test (legacy)
- `build/TerrainTest` - Terrain system test (legacy)
- `build//src/features/editScene/editSceneEditor` - current executable
- `build/resources/` - Staged game resources
- `build/characters/` - Processed character models
- `build/water/` - Water assets (legacy)
## Code Style Guidelines
This project follows the **Linux Kernel Coding Style** for C++:
### Formatting Rules
- **Indentation**: 8-character tabs (UseTab: Always)
- **Line Length**: 80 characters maximum
- **Braces**:
- Functions: Opening brace on new line
- Control structures: Opening brace on same line
- **Pointer Alignment**: Right (`int *ptr` not `int* ptr`)
- **Namespaces**: No indentation inside namespaces
### Example
```cpp
void myFunction(int arg)
{
if (arg > 0) {
doSomething();
return;
}
for (int i = 0; i < arg; i++) {
process(i);
}
}
```
### Running clang-format
```bash
clang-format -i src/gamedata/*.cpp src/gamedata/*.h
```
## Architecture
### ECS (Entity Component System)
The game uses Flecs for ECS architecture. Core systems:
#### Components (src/gamedata/Components.h) (legacy)
- `GameData` - Global game state
- `EngineData` - Engine configuration (SceneManager, delta time, debug draw)
- `Input` - Input state (keyboard, mouse, controls)
- `Camera` - Camera node and configuration
- `RenderWindow` - Window and DPI information
- `CollisionShape` - Physics shape references
- `EditorSceneSwitch` - Editor scene switching
- `GameState` - Game running state
#### Modules (src/gamedata/*Module.h) (legacy)
Modules are ECS systems organized by functionality:
| Module | Purpose |
|--------|---------|
| AppModule | Application context management |
| GUIModule | ImGui interface management |
| EditorGUIModule | Editor UI |
| PhysicsModule | Jolt physics integration |
| CharacterModule | Character spawning and management |
| CharacterManagerModule | Player/NPC character management |
| CharacterAnimationModule | Animation state machines |
| CharacterAIModule | AI behavior (GOAP-based) |
| TerrainModule | Terrain generation and rendering |
| WaterModule | Water surface and physics |
| SunModule | Day/night cycle and lighting |
| WorldMapModule | World map overlay |
| QuestModule | Quest system |
| EventModule | Game event handling |
| EventTriggerModule | Event triggers |
| SlotsModule | Inventory slot management |
| BoatModule | Boat physics and control |
| VehicleManagerModule | Vehicle management |
| PlayerActionModule | Player action handling |
| LuaModule | Lua scripting integration |
| StaticGeometryModule | Static geometry management |
### EditScene Modules (`src/features/editScene`)
The editor/game mode executable adds its own ECS components.
The editor/game mode executable adds its own ECS modules:
| Module | Purpose |
|--------|---------|
| CharacterSlotSystem | Catalog-driven multi-slot character mesh builder |
| CharacterSpawnerSystem | Distance-based spawn/despawn of registry characters |
| PlayerControllerSystem | Player input, camera, locomotion and animation state |
| ActuatorSystem | Player interaction prompts and smart-object/item actions |
| EditorUISystem | ImGui property panels and scene management |
| ProceduralTextureSystem | Runtime procedural texture generation |
| ProceduralMaterialSystem | Runtime procedural material creation |
#### PlayerControllerSystem
`PlayerControllerSystem` reads the shared `GameInputState`, drives the TPS/FPS
camera and sets the locomotion animation state (`idle`/`walking`/`running` and
the swim equivalents) on the controlled entity. In game mode it adds the
`PlayerControlledComponent` tag to the target so AI systems know to leave it
alone.
When a controller's `targetCharacterName` points at an entity with
`CharacterSpawnerComponent`, the system locks the spawner and forces an
immediate spawn; the actual controlled entity is the spawned character
instance, not the spawner.
#### Player Character Resolution
Systems that need the live player character (inventory UI, character sheet,
save/load, actuators) must not resolve the controller's
`targetCharacterName` against `EntityNameComponent` directly that returns the
spawner entity when the controller targets a spawner. Instead use:
```cpp
flecs::entity player = editorApp->getPlayerCharacterEntity();
```
`EditorApp::getPlayerCharacterEntity()` returns the controller's current
target, spawning the character through `CharacterSpawnerSystem` if necessary.
`CharacterSpawnerSystem::getSpawnerForCharacter()` can be used in save/load to
keep the controller pointed at the spawner rather than the transient instance.
#### Game Mode Input
Game mode input is kept in sync with the physical keyboard each frame by
calling `SDL_PumpEvents()` followed by `SDL_GetKeyboardState()`. This prevents
missed `keyReleased` events from leaving movement keys stuck (the original
cause of the "infinite slow walk" after releasing Shift+W). Event-driven
`keyPressed`/`keyReleased` handlers are still used for one-shot actions such
as `ePressed`, `fPressed` and `iPressed`.
#### CharacterSpawnerComponent
- `registryId` - Character registry ID to spawn.
- `spawnDistanceSq` - Squared distance to camera at which the character spawns (default 100²).
- `despawnDistanceSq` - Squared distance to camera at which the character despawns (default 200²).
The spawner uses the entity's `TransformComponent` for spawn position/rotation. When the spawner itself moves/rotates/scales in the editor, the spawned character is updated to match; when the spawner is stationary, the character is left alone so it can move on its own. Spawned characters are created without `EditorMarkerComponent` and removed from the editor UI cache so they remain visible but are not selectable/editable in the editor. Registry changes that bump the character version trigger an immediate respawn. Scene serialization stores plain `spawnDistance`/`despawnDistance` values for readability while the component keeps squared values for comparisons.
### Physics System
Uses Jolt Physics with custom wrapper (legacy - `src/physics/physics.h`, current - `src/features/editScene/physics/physics.h`):
```cpp
// Example: Creating a physics body
JoltPhysicsWrapper *physics = new JoltPhysicsWrapper(scnMgr, cameraNode);
JPH::ShapeRefC shape = physics->createBoxShape(Ogre::Vector3(1, 1, 1));
JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
JPH::EMotionType::Dynamic, Layers::MOVING);
```
### Rendering Pipeline
1. **Main Render**: Standard OGRE render with RTSS (Real Time Shader System)
2. **Water**: Reflection/refraction with custom shaders
3. **Sky**: Dynamic skybox with day/night cycle
4. **Shadows**: Configurable shadow techniques
### Asset Pipeline
1. **Source**: Blender `.blend` files in `assets/`
2. **Export**: Python scripts in `assets/blender/scripts/`
- `export_buildings.py` - Building export to glTF
- `export_characters_ogre.py` - Character export to OGRE format
- `export_vehicles.py` - Vehicle export
- `import_vrm.py` - VRM character import
3. **Build**: CMake processes assets during build
4. **Runtime**: Assets loaded from `resources.cfg` paths
## Key Conventions
### Module Registration
Modules register themselves with Flecs using the `FLECS_CPP_NO_AUTO_REGISTRATION` macro:
```cpp
// In module header
struct MyModule {
MyModule(flecs::world &ecs);
};
// In module implementation
MyModule::MyModule(flecs::world &ecs) {
// Register systems, components, etc.
}
```
### Scene Setup
Scenes are configured through ECS setup functions:
```cpp
// Setup exterior game scene
ECS::setupExteriorScene(scnMgr, cameraNode, camera, renderWindow);
// Setup interior scene
ECS::setupInteriorScene(scnMgr, cameraNode, camera, renderWindow);
// Setup editor
ECS::setupEditor(scnMgr, cameraNode, camera, renderWindow);
```
### Lua Scripting
Story content is written in Ink format and parsed to Lua:
- Source: `lua-scripts/stories/*.ink`
- Compiled: `lua-scripts/stories/*.lua`
- Runtime: Narrator parser loads and executes stories
## Development Workflow
### Adding Assets
1. Create/modify `.blend` file in `assets/blender/`
2. Add export logic to relevant Python script if needed
3. Add CMake custom command in `CMakeLists.txt` if new asset type
4. Rebuild to process assets
### Debugging
- Use `ECS::EngineData.enableDbgDraw` for physics debug visualization
- Tracy profiler integration for performance analysis
- FPS and draw call stats printed to console
## Security Considerations
- Lua scripts run in sandboxed environment
- File system access restricted to resource directories
- No network functionality currently implemented
## Additional Resources
- **OGRE3D Docs**: https://ogrecave.github.io/ogre/api/latest/
- **Flecs Manual**: https://www.flecs.dev/flecs/
- **Jolt Physics**: https://jrouwe.github.io/JoltPhysics/
- **Ink Narrative**: https://www.inklestudios.com/ink/
+222
View File
@@ -0,0 +1,222 @@
# EditScene Feature - Agent Guide
This guide covers the `src/features/editScene` sub-project: the OGRE3D/Flecs scene
editor and the `--game` playable mode built on top of it.
## Overview
`editScene` is a self-contained executable (`editSceneEditor`) that can run in two
modes:
- **Editor mode** (default): load, edit and save JSON scenes through ImGui panels.
- **Game mode** (`--game`): present a startup menu, load a base scene, and let the
player control a character with save/load support.
The feature is built from `src/features/editScene/CMakeLists.txt` and links against
OGRE (Bites, Overlay, MeshLodGenerator), Flecs, nlohmann/json, SDL2, Jolt and
OgreProcedural. Recast/Detour is built from the copy in
`src/features/editScene/recastnavigation`.
## Build & Run
The CMake target is `editSceneEditor`. The working directory for the runtime must
be the executable's own directory because resources are copied next to it at build
time.
```bash
# From the project root
cmake -B build-vscode -S . -DCMAKE_PREFIX_PATH=/path/to/ogre-sdk
cmake --build build-vscode --target editSceneEditor -j4
# Run editor
cd build-vscode/src/features/editScene
./editSceneEditor
# Run game mode (loads the configured base scene through the startup menu)
./editSceneEditor --game
# Auto-load a scene in editor mode
./editSceneEditor /path/to/scene.json
```
The main test target is `component_lua_test`:
```bash
cmake --build build-vscode --target component_lua_test -j4
./build-vscode/src/features/editScene/component_lua_test
```
## Entry Point & Modes
- `main.cpp` parses `--game` and `--debug-buoyancy`, sets the mode on
`EditorApp`, calls `initApp()` and enters the OGRE render loop.
- `EditorApp` owns all systems and implements `frameRenderingQueued()`, which is
the per-frame update function.
- Modes/states are also exposed globally through `GameMode.hpp` so systems can
query `editScene::isGameMode()` / `editScene::isGamePlaying()` without a pointer
to `EditorApp`.
| Mode | Purpose |
|------|---------|
| `EditorApp::GameMode::Editor` | Free-camera scene editing, full UI panels |
| `EditorApp::GameMode::Game` | Playable mode with menu / playing / paused states |
| Game play state | Purpose |
|-----------------|---------|
| `Menu` | Startup menu, game not running yet |
| `Playing` | Player controller and gameplay systems active |
| `Paused` | Pause menu / character sheet open, gameplay systems frozen |
## Frame Update Order
`EditorApp::frameRenderingQueued()` updates systems in this order when not paused:
1. `PlayerControllerSystem` (only in `Playing`)
2. `CharacterSpawnerSystem`
3. `CharacterSlotSystem`
4. `AnimationTreeSystem` / `BehaviorTreeSystem`
5. `PathFollowingSystem`
6. `ProceduralMeshSystem`
7. `RoomLayoutSystem`
8. `CellGridSystem`
9. `NormalDebugSystem`
10. `NavMeshSystem`
11. `SmartObjectSystem`
12. `GoapPlannerSystem`
13. `GoapRunnerSystem`
14. `ActuatorSystem`
15. `EventHandlerSystem`
16. `CharacterSystem`
17. `BuoyancySystem`
18. `PhysicsSystem`
19. `HairPhysicsSystem` pose read-back
20. Rendering support systems (sun, skybox, water, light, LOD, etc.)
Systems that write animation state or velocity should respect systems that run
before them. In particular, `PlayerControllerSystem` runs first and is the only
system that should drive player locomotion animation.
## Important Subsystems
### PlayerControllerSystem
- Reads `GameInputState` and drives TPS/FPS camera and locomotion animation.
- Sets animation states on the `locomotion` state machine:
`idle` / `walking` / `running` and the swim equivalents.
- In game mode it adds `PlayerControlledComponent` to the target entity so AI
systems skip it.
- When `targetCharacterName` resolves to a `CharacterSpawnerComponent`, it locks
the spawner and forces a spawn; the controlled entity is the spawned instance,
not the spawner.
### CharacterSpawnerSystem
Distance-based spawn/despawn of registry characters. Special API for player
controller integration:
```cpp
void spawn(flecs::entity spawner);
void lockSpawner(flecs::entity spawner); // keep alive even out of range
void unlockSpawner(flecs::entity spawner);
flecs::entity getSpawnedCharacter(flecs::entity spawner) const;
flecs::entity getSpawnerForCharacter(flecs::entity character) const;
bool isSpawnerLocked(flecs::entity spawner) const;
```
Spawned characters have `EditorMarkerComponent` removed and are removed from
`EditorUISystem` caches so they don't appear in editor lists.
### Player Character Resolution
Never resolve the player by matching `PlayerControllerComponent::targetCharacterName`
against `EntityNameComponent` when the controller targets a spawner that returns
the spawner entity, not the character. Use the central helper:
```cpp
flecs::entity player = editorApp->getPlayerCharacterEntity();
```
This returns the live target (spawning through the spawner if necessary). It is
used by inventory/character-sheet UI, save/load and `ActuatorSystem`.
### Game Mode Input
Movement keys are polled each frame with `SDL_PumpEvents()` + `SDL_GetKeyboardState()`
to avoid stuck keys caused by missed `keyReleased` events. One-shot action flags
(`ePressed`, `fPressed`, `iPressed`) are still set from event handlers.
### AnimationTreeSystem & Root Motion
- Selects a root bone (`Root`, `mixamorig:Hips`, then `Spineroot`), freezes it,
and excludes it from animation via a blend mask.
- Computes `CharacterComponent::linearVelocity` from root-bone displacement when
`AnimationTreeComponent::useRootMotion` is true.
- `CharacterSystem` then applies that velocity to the Jolt physics character.
### ActuatorSystem
Handles player interaction prompts and executes smart-object/item actions. It
uses `EditorApp::getPlayerCharacterEntity()` to know which character performs the
action. Actions run through `BehaviorTreeSystem` and lock player input while
running (`PlayerControllerComponent::inputLocked`).
### Save/Load
Game-mode saves are JSON files in the OS user-data directory (see
`docs/SaveLoadSystem.md`). Key fields:
- `baseScene` the scene file loaded as the foundation
- `playerCharacterId` registry ID of the player character
- `characterRegistry` full character registry state
- `runtimeEntities` runtime-spawned entities (dropped items, etc.)
- `characterRuntimeData` per-character component overrides
- `luaData` data from Lua save callbacks
On load, if the saved character is owned by a spawner, the controller target is
restored to the spawner name so the character respawns correctly.
## Adding a New Component
1. Define the component struct in `components/MyComponent.hpp`.
2. Register it for UI editing in `components/MyComponentModule.cpp` using
`REGISTER_COMPONENT_GROUP(...)` and `registry.registerComponent<T>(...)`.
3. Implement an editor in `ui/MyComponentEditor.hpp` / `.cpp` deriving from
`ComponentEditor`.
4. If the component should be saved/loaded, add serialization support in
`systems/SceneSerializer.cpp`.
5. Add a Lua binding in `lua/LuaComponentApi.cpp` if needed.
6. Add a component-module test case in `tests/component_lua_test.cpp`.
## Adding a New System
1. Create `systems/MySystem.hpp` and `systems/MySystem.cpp`.
2. Add the source files to `CMakeLists.txt`.
3. Instantiate the system in `EditorApp::setup()` and wire any dependencies.
4. Call `update()` from `EditorApp::frameRenderingQueued()` in the appropriate
order.
5. Skip `PlayerControlledComponent` entities if the system would override player
animation or movement.
## Critical Constraints & Rules
- The source code is the only source of truth. Please check code as documentation might be out of sync.
- Please ask questions if anything is unclear.
- Please update tests, documentation and examples every time API or modules and systems change.
Make sure documentation, tests and examples are always in sync.
## Conventions & Caveats
- Use `editScene::isGameMode()` / `editScene::isGamePlaying()` for mode checks
when you don't have an `EditorApp` pointer.
- Systems that set animation states must not affect player-controlled entities;
check `e.has<PlayerControlledComponent>()` early.
- Spawner-targeted characters are transient instances; persistent player data
(inventory, identity) must live on the spawned instance, not the spawner.
- The editor uses `EditorMarkerComponent` to know which entities belong to the
scene file. Spawned/runtime characters usually don't have this tag.
- Keep changes in `EditorApp::frameRenderingQueued()` order-aware; systems later
in the list should not stomp state produced by earlier systems.
## Additional Docs
- `docs/SaveLoadSystem.md` save file format and API details
- Root `AGENTS.md` project-wide build, style and architecture notes
+7 -5
View File
@@ -393,6 +393,9 @@ void EditorApp::setup()
m_characterSlotSystem = std::make_unique<CharacterSlotSystem>(
m_world, m_sceneMgr);
m_characterSlotSystem->initialize();
if (m_uiSystem)
m_uiSystem->getCharacterRegistry().setCharacterSlotSystem(
m_characterSlotSystem.get());
// Setup AnimationTree system
m_animationTreeSystem = std::make_unique<AnimationTreeSystem>(
@@ -989,12 +992,11 @@ void EditorApp::loadGame(const std::string &slotPath)
ItemStateRegistry::getInstance().deserialize(
saveData["itemState"]);
/* Destroy ALL characters (including editor-placed prefab
* instances that may lack CharacterIdentityComponent) so the
* registry spawn is the only source of characters. */
/* Destroy ALL spawned characters so the registry spawn is the
* only source of characters. */
std::vector<flecs::entity> charsToDestroy;
m_world.query<CharacterSlotsComponent>().each(
[&](flecs::entity e, CharacterSlotsComponent &) {
m_world.query<CharacterIdentityComponent>().each(
[&](flecs::entity e, CharacterIdentityComponent &) {
charsToDestroy.push_back(e);
});
for (auto e : charsToDestroy) {
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <atomic>
#include <chrono>
#include <string>
/**
* Generate a unique persistent identifier.
* The value is generated once and should be stored in a component field;
* it will survive scene save/load because it is serialized with the component.
*/
inline std::string generatePersistentId()
{
static std::atomic<uint64_t> s_counter{ 0 };
uint64_t counter = ++s_counter;
uint64_t timestamp = static_cast<uint64_t>(
std::chrono::high_resolution_clock::now()
.time_since_epoch()
.count());
return "id_" + std::to_string(timestamp) + "_" +
std::to_string(counter);
}
@@ -20,42 +20,24 @@ struct SlotSelection {
};
/**
* Multi-slot mesh component for character parts sharing a skeleton.
* The "face" slot (or first available slot) serves as the master skeleton.
* Deprecated marker component for characters.
*
* Age is now stored in CharacterRegistry::CharacterRecord::age
* and should be retrieved from there when needed.
* All persistent character appearance data (sex, slot selections, shape keys,
* front axis) now lives in CharacterRegistry::CharacterRecord. This component
* is kept only as a temporary reminder for scenes that still contain it; it
* is not serialized, cannot be added from the editor, and does not affect
* runtime behavior.
*/
struct CharacterSlotsComponent {
Ogre::String sex = "male";
/* 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) */
Ogre::Entity *masterEntity = nullptr;
/**
* Front-facing axis for this character model.
* Most models face -Z (NEGATIVE_UNIT_Z), but some face +Z.
*/
Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
CharacterSlotsComponent() = default;
};
/**
* Global shape key weights for a character.
* Same name applies to all slots uniformly.
* Deprecated marker component for character shape keys.
*
* Shape key weights are now stored in
* CharacterRegistry::CharacterRecord::inlineShapeKeyWeights.
*/
struct CharacterShapeKeysComponent {
std::unordered_map<Ogre::String, float> weights;
bool dirty = true;
};
#endif // EDITSCENE_CHARACTERSLOTS_HPP
@@ -13,19 +13,9 @@ REGISTER_COMPONENT_GROUP("Character Slots", "Rendering",
"Character Slots",
"Rendering",
std::make_unique<CharacterSlotsEditor>(sceneMgr),
/* Adder */
[sceneMgr](flecs::entity e) {
if (!e.has<TransformComponent>()) {
TransformComponent transform;
transform.node =
sceneMgr->getRootSceneNode()
->createChildSceneNode();
e.set<TransformComponent>(transform);
}
CharacterSlotsComponent cs;
cs.dirty = true;
e.set<CharacterSlotsComponent>(cs);
},
/* Adder: disabled; this component is deprecated and cannot be
* created from the editor. */
nullptr,
/* Remover */
[sceneMgr](flecs::entity e) {
(void)sceneMgr;
@@ -1,5 +1,6 @@
#pragma once
#include "../Utils.hpp"
#include <string>
#include <Ogre.h>
#include <flecs.h>
@@ -18,6 +19,11 @@ struct ProceduralMaterialComponent {
// Material name for Ogre resource
std::string materialName;
ProceduralMaterialComponent()
{
materialId = generatePersistentId();
}
// Persistent reference to texture by ID
std::string diffuseTextureId;
@@ -1,5 +1,6 @@
#pragma once
#include "../Utils.hpp"
#include <string>
#include <array>
#include <vector>
@@ -78,6 +79,9 @@ struct ProceduralTextureComponent {
ProceduralTextureComponent()
{
// Unique persistent ID for referencing this texture across saves.
textureId = generatePersistentId();
// Initialize with default colors (checkerboard pattern)
for (int i = 0; i < RECT_COUNT; ++i) {
int row = i / GRID_SIZE;
+43 -146
View File
@@ -520,156 +520,53 @@ static void registerAllComponents()
c.useGravity = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
// --- CharacterSlots ---
REGISTER_COMPONENT(
CharacterSlotsComponent, "CharacterSlots",
{ // Push: age from registry
Ogre::String age = "adult";
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(id.registryId);
if (rec && !rec->age.empty())
age = rec->age;
}
lua_pushstring(L, age.c_str());
} lua_setfield(L, -2, "age");
lua_pushstring(L, c.sex.c_str()); lua_setfield(L, -2, "sex");
// slots map: push as table
lua_newtable(L); for (auto &kv : c.slots) {
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");
{ // Push: outfitLevel from registry
int outfitLevel = 2;
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(id.registryId);
if (rec)
outfitLevel = rec->inlineOutfitLevel;
}
lua_pushinteger(L, outfitLevel);
} lua_setfield(L, -2, "outfitLevel");
pushVector3(L, c.frontAxis); lua_setfield(L, -2, "frontAxis");
,
{ // Read: age into registry
if (lua_getfield(L, idx, "age"), lua_isstring(L, -1)) {
Ogre::String age = lua_tostring(L, -1);
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<
CharacterIdentityComponent>();
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(
id.registryId);
if (rec) {
rec->age = age;
CharacterRegistry::getSingleton()
.autoSave();
CharacterRegistry::getSingleton()
.markCharacterDirty(
id.registryId);
}
}
}
} lua_pop(L, 1);
if (lua_getfield(L, idx, "sex"), lua_isstring(L, -1))
c.sex = lua_tostring(L, -1);
lua_pop(L, 1); { // Read: outfitLevel into registry
if (lua_getfield(L, idx, "outfitLevel"),
lua_isnumber(L, -1)) {
int outfitLevel = (int)lua_tointeger(L, -1);
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<
CharacterIdentityComponent>();
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(
id.registryId);
if (rec) {
rec->inlineOutfitLevel =
outfitLevel;
CharacterRegistry::getSingleton()
.autoSave();
CharacterRegistry::getSingleton()
.markCharacterDirty(
id.registryId);
}
}
}
} lua_pop(L, 1);
if (lua_getfield(L, idx, "slots"), lua_istable(L, -1)) {
c.slots.clear();
lua_pushnil(L);
while (lua_next(L, -2) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1))
c.slots[lua_tostring(L, -2)] =
lua_tostring(L, -1);
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());
{
const CharacterRegistry::CharacterRecord *rec = nullptr;
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
rec = CharacterRegistry::getSingleton()
.findCharacter(id.registryId);
}
lua_newtable(L);
if (rec) {
for (auto &kv : rec->inlineShapeKeyWeights) {
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);
{
if (!e.has<CharacterIdentityComponent>())
return;
auto &id = e.get<CharacterIdentityComponent>();
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton()
.findCharacter(id.registryId);
if (!rec)
return;
if (lua_istable(L, idx)) {
bool changed = false;
lua_pushnil(L);
while (lua_next(L, idx) != 0) {
if (lua_isstring(L, -2) &&
lua_isnumber(L, -1)) {
rec->inlineShapeKeyWeights[
lua_tostring(L, -2)] =
lua_tonumber(L, -1);
changed = true;
}
lua_pop(L, 1);
}
if (changed) {
CharacterRegistry::getSingleton()
.autoSave();
CharacterRegistry::getSingleton()
.markCharacterDirty(
id.registryId);
}
}
});
@@ -1,4 +1,5 @@
#include "AnimationTreeSystem.hpp"
#include "CharacterSlotSystem.hpp"
#include "../components/Transform.hpp"
#include "../components/Renderable.hpp"
#include "../components/CharacterSlots.hpp"
@@ -53,10 +54,11 @@ Ogre::Entity *AnimationTreeSystem::findAnimatedEntity(flecs::entity e)
if (!e.is_alive())
return nullptr;
if (e.has<CharacterSlotsComponent>()) {
auto &cs = e.get<CharacterSlotsComponent>();
if (cs.masterEntity && cs.masterEntity->hasSkeleton())
return cs.masterEntity;
CharacterSlotSystem *slotSystem = CharacterSlotSystem::getSingletonPtr();
if (slotSystem) {
Ogre::Entity *masterEnt = slotSystem->getMasterEntity(e);
if (masterEnt && masterEnt->hasSkeleton())
return masterEnt;
}
if (e.has<RenderableComponent>()) {
@@ -284,9 +284,11 @@ std::vector<uint64_t> CharacterRegistry::getChildren(uint64_t parentId) const
/* ------------------------------------------------------------------ */
static bool
readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs,
readPrefabAppearance(const std::string &path, std::string &outSex,
std::unordered_map<std::string, SlotSelection> &outSelections,
std::unordered_map<std::string, float> &shapeKeys,
std::string &outAge)
std::string &outAge,
Ogre::Vector3 &outFrontAxis)
{
try {
std::ifstream file(path);
@@ -296,7 +298,17 @@ readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs,
file >> j;
if (j.contains("characterSlots")) {
auto &s = j["characterSlots"];
cs.sex = s.value("sex", "male");
outSex = s.value("sex", "male");
outAge = s.value("age", "adult");
if (s.contains("frontAxis") && s["frontAxis"].is_array() &&
s["frontAxis"].size() >= 3) {
outFrontAxis = Ogre::Vector3(
s["frontAxis"][0].get<float>(),
s["frontAxis"][1].get<float>(),
s["frontAxis"][2].get<float>());
if (outFrontAxis.squaredLength() > 0.0001f)
outFrontAxis.normalise();
}
if (s.contains("slotSelections")) {
for (auto &[slot, selJson] :
s["slotSelections"].items()) {
@@ -309,7 +321,15 @@ readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs,
selJson.value("layer2Mesh", "");
sel.explicitMesh = selJson.value(
"explicitMesh", "");
cs.slotSelections[slot] = sel;
outSelections[slot] = sel;
}
}
/* Backward compat: old flat slots map */
if (s.contains("slots") && s["slots"].is_object()) {
for (auto &[slot, mesh] : s["slots"].items()) {
SlotSelection sel;
sel.explicitMesh = mesh.get<std::string>();
outSelections[slot] = sel;
}
}
}
@@ -424,14 +444,19 @@ uint64_t CharacterRegistry::createChild(uint64_t parentA, uint64_t parentB)
/* Copy appearance from same-sex parent */
if (sameSexParent->inlineSlotSelections.empty() &&
std::filesystem::exists(sameSexParent->prefabPath)) {
CharacterSlotsComponent tmpCs;
std::string prefabSex = "male";
std::string prefabAge;
readPrefabAppearance(sameSexParent->prefabPath, tmpCs,
child->inlineShapeKeyWeights, prefabAge);
std::unordered_map<std::string, SlotSelection> prefabSelections;
Ogre::Vector3 prefabFrontAxis = sameSexParent->frontAxis;
readPrefabAppearance(sameSexParent->prefabPath, prefabSex,
prefabSelections,
child->inlineShapeKeyWeights, prefabAge,
prefabFrontAxis);
child->age = prefabAge;
child->inlineSex = tmpCs.sex;
child->inlineSex = prefabSex;
child->inlineOutfitLevel = sameSexParent->inlineOutfitLevel;
child->inlineSlotSelections = tmpCs.slotSelections;
child->inlineSlotSelections = prefabSelections;
child->frontAxis = sameSexParent->frontAxis;
} else {
child->age = sameSexParent->age;
child->inlineSex = sameSexParent->inlineSex;
@@ -440,6 +465,7 @@ uint64_t CharacterRegistry::createChild(uint64_t parentA, uint64_t parentB)
sameSexParent->inlineSlotSelections;
child->inlineShapeKeyWeights =
sameSexParent->inlineShapeKeyWeights;
child->frontAxis = sameSexParent->frontAxis;
}
/* Randomize configured birth shape keys */
@@ -538,20 +564,10 @@ flecs::entity CharacterRegistry::spawnInlineCharacter(const CharacterRecord &c,
transform.applyToNode();
inst.set<TransformComponent>(transform);
/* CharacterSlots */
CharacterSlotsComponent cs;
cs.sex = c.inlineSex;
cs.slotSelections = c.inlineSlotSelections;
cs.dirty = true;
inst.set<CharacterSlotsComponent>(cs);
/* Shape keys */
if (!c.inlineShapeKeyWeights.empty()) {
CharacterShapeKeysComponent skc;
skc.weights = c.inlineShapeKeyWeights;
skc.dirty = true;
inst.set<CharacterShapeKeysComponent>(skc);
}
/* Character visual data is now read from the registry record at
* build time; no component storage is needed. */
if (m_slotSystem)
m_slotSystem->markEntityDirty(inst.id());
return inst;
}
@@ -700,8 +716,8 @@ void CharacterRegistry::markCharacterDirty(uint64_t id)
flecs::entity e = findSpawnedEntity(id);
if (!e.is_alive())
return;
if (e.has<CharacterSlotsComponent>())
e.get_mut<CharacterSlotsComponent>().dirty = true;
if (m_slotSystem)
m_slotSystem->markEntityDirty(e.id());
}
bool CharacterRegistry::despawnCharacter(uint64_t id)
@@ -720,7 +736,7 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
if (!m_world || !m_sceneMgr || !m_uiSystem)
return flecs::entity::null();
const CharacterRecord *c = findCharacter(id);
CharacterRecord *c = findCharacter(id);
if (!c)
return flecs::entity::null();
@@ -729,6 +745,20 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
if (existing.is_alive())
existing.destruct();
/* Migrate prefab appearance data into the registry record if the
* record has no inline appearance data yet. */
if (c->inlineSlotSelections.empty() &&
std::filesystem::exists(c->prefabPath)) {
std::string prefabAge;
readPrefabAppearance(c->prefabPath, c->inlineSex,
c->inlineSlotSelections,
c->inlineShapeKeyWeights, prefabAge,
c->frontAxis);
if (!prefabAge.empty())
c->age = prefabAge;
autoSave();
}
Ogre::Vector3 pos = c->position;
flecs::entity inst;
if (std::filesystem::exists(c->prefabPath)) {
@@ -762,32 +792,52 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
bool CharacterRegistry::savePrefabForCharacter(uint64_t id)
{
if (!m_world || !m_sceneMgr || !m_uiSystem)
return false;
const CharacterRecord *c = findCharacter(id);
if (!c)
return false;
flecs::entity sel = flecs::entity::null();
if (m_uiSystem)
sel = m_uiSystem->getSelectedEntity();
try {
nlohmann::json j;
{
std::ifstream file(c->prefabPath);
if (file.is_open())
file >> j;
}
if (!sel.is_alive()) {
m_lastError = "No entity selected";
return false;
}
if (!sel.has<CharacterSlotsComponent>()) {
m_lastError = "Selected entity has no CharacterSlots";
return false;
}
j["characterSlots"]["age"] = c->age;
j["characterSlots"]["sex"] = c->inlineSex;
j["characterSlots"]["outfitLevel"] = c->inlineOutfitLevel;
nlohmann::json selections = nlohmann::json::object();
for (const auto &kv : c->inlineSlotSelections) {
nlohmann::json s;
s["layer0Mesh"] = kv.second.layer0Mesh;
s["layer1Mesh"] = kv.second.layer1Mesh;
s["layer2Mesh"] = kv.second.layer2Mesh;
s["explicitMesh"] = kv.second.explicitMesh;
selections[kv.first] = s;
}
j["characterSlots"]["slotSelections"] = selections;
PrefabSystem prefabSys(*m_world, m_sceneMgr);
if (!prefabSys.savePrefab(sel, c->prefabPath)) {
m_lastError = prefabSys.getLastError();
if (!c->inlineShapeKeyWeights.empty()) {
nlohmann::json weights = nlohmann::json::object();
for (const auto &kv : c->inlineShapeKeyWeights)
weights[kv.first] = kv.second;
j["characterShapeKeys"]["weights"] = weights;
}
std::filesystem::path p(c->prefabPath);
std::filesystem::create_directories(p.parent_path());
std::ofstream out(c->prefabPath);
if (!out.is_open()) {
m_lastError = "Cannot open " + c->prefabPath;
return false;
}
out << j.dump(4);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save prefab error: ") + e.what();
return false;
}
return true;
}
/* ===================================================================== */
@@ -1205,6 +1255,13 @@ nlohmann::json CharacterRegistry::serialize() const
rec["age"] = c.age;
rec["inlineSex"] = c.inlineSex;
rec["inlineOutfitLevel"] = c.inlineOutfitLevel;
{
nlohmann::json fa;
fa["x"] = c.frontAxis.x;
fa["y"] = c.frontAxis.y;
fa["z"] = c.frontAxis.z;
rec["frontAxis"] = fa;
}
if (!c.inlineSlotSelections.empty()) {
nlohmann::json selJson;
for (const auto &kv : c.inlineSlotSelections) {
@@ -1378,6 +1435,15 @@ void CharacterRegistry::deserialize(const nlohmann::json &j)
c.age = rec.value("age", "adult");
c.inlineSex = rec.value("inlineSex", "male");
c.inlineOutfitLevel = rec.value("inlineOutfitLevel", 2);
if (rec.contains("frontAxis") &&
rec["frontAxis"].is_object()) {
auto &fa = rec["frontAxis"];
c.frontAxis = Ogre::Vector3(
fa.value("x", 0.0f), fa.value("y", 0.0f),
fa.value("z", 0.0f));
if (c.frontAxis.squaredLength() > 0.0001f)
c.frontAxis.normalise();
}
if (rec.contains("position") &&
rec["position"].is_object()) {
auto &p = rec["position"];
@@ -1612,6 +1678,330 @@ static void drawColumnsEditor(const char *title,
}
}
/* ===================================================================== */
/* Character appearance editor (moved from CharacterSlotsEditor) */
/* ===================================================================== */
static void drawCharacterAppearanceEditor(CharacterRegistry &reg,
CharacterRegistry::CharacterRecord *c)
{
if (!c)
return;
CharacterSlotSystem::loadCatalog();
const Ogre::String currentAge = c->age;
const int outfitLevel = c->inlineOutfitLevel;
/* Sex selector */
std::vector<Ogre::String> sexes =
CharacterSlotSystem::getSexes(currentAge);
Ogre::String currentSex = c->inlineSex;
if (ImGui::BeginCombo("Sex", currentSex.c_str())) {
for (const auto &sex : sexes) {
bool isSelected = (currentSex == sex);
if (ImGui::Selectable(sex.c_str(), isSelected)) {
c->inlineSex = sex;
reg.autoSave();
reg.markCharacterDirty(c->id);
}
if (isSelected)
ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::Separator();
/* Front-facing axis */
{
Ogre::Vector3 axis = c->frontAxis;
float axisVals[3] = { axis.x, axis.y, axis.z };
ImGui::Text("Front Axis:");
ImGui::SameLine();
ImGui::TextDisabled(
"(direction character faces, used by path following)");
if (ImGui::DragFloat3("##frontAxis", axisVals, 0.1f, -1.0f,
1.0f)) {
c->frontAxis = Ogre::Vector3(axisVals[0], axisVals[1],
axisVals[2]);
if (c->frontAxis.squaredLength() > 0.0001f)
c->frontAxis.normalise();
reg.autoSave();
}
ImGui::SameLine();
if (ImGui::SmallButton("-Z")) {
c->frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
reg.autoSave();
}
ImGui::SameLine();
if (ImGui::SmallButton("+Z")) {
c->frontAxis = Ogre::Vector3::UNIT_Z;
reg.autoSave();
}
}
ImGui::Separator();
/* Shape Keys */
if (ImGui::CollapsingHeader("Shape Keys")) {
auto shapeKeys = CharacterSlotSystem::getShapeKeyNames(
currentAge, c->inlineSex);
if (shapeKeys.empty()) {
ImGui::TextDisabled("No shape keys available.");
} else {
for (const auto &name : shapeKeys) {
float val = c->inlineShapeKeyWeights[name];
if (ImGui::SliderFloat(name.c_str(), &val, 0.0f,
1.0f)) {
c->inlineShapeKeyWeights[name] = val;
reg.autoSave();
reg.markCharacterDirty(c->id);
}
}
}
}
ImGui::Separator();
/* Slot selections */
std::vector<Ogre::String> availableSlots =
CharacterSlotSystem::getSlots(currentAge, c->inlineSex);
for (const auto &pair : c->inlineSlotSelections) {
if (std::find(availableSlots.begin(), availableSlots.end(),
pair.first) == availableSlots.end())
availableSlots.push_back(pair.first);
}
std::sort(availableSlots.begin(), availableSlots.end());
for (const auto &slot : availableSlots) {
if (c->inlineSlotSelections.find(slot) ==
c->inlineSlotSelections.end()) {
c->inlineSlotSelections[slot] = SlotSelection();
}
SlotSelection &sel = c->inlineSlotSelections[slot];
if (ImGui::TreeNode(slot.c_str())) {
/* Resolved mesh preview */
Ogre::String resolved = CharacterSlotSystem::resolveMesh(
currentAge, c->inlineSex, slot, sel,
outfitLevel);
ImGui::TextDisabled("Resolved: %s",
resolved.empty() ?
"(none)" :
resolved.c_str());
/* Explicit mesh override */
bool useExplicit = !sel.explicitMesh.empty();
if (ImGui::Checkbox("Lock explicit mesh", &useExplicit)) {
if (!useExplicit) {
sel.explicitMesh.clear();
} else {
sel.explicitMesh = CharacterSlotSystem::resolveMesh(
currentAge, c->inlineSex, slot, sel,
outfitLevel);
}
reg.autoSave();
reg.markCharacterDirty(c->id);
}
if (useExplicit) {
std::vector<Ogre::String> meshes =
CharacterSlotSystem::getMeshes(
currentAge, c->inlineSex, 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();
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
for (const auto &m : meshes) {
bool isSelected =
(sel.explicitMesh == m);
if (ImGui::Selectable(m.c_str(),
isSelected)) {
sel.explicitMesh = m;
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
}
ImGui::EndCombo();
}
} else {
/* Layer 0 combo (base meshes like hair) */
if (slot != "hair") {
std::vector<Ogre::String> layer0Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
c->inlineSex, slot,
0);
if (!layer0Meshes.empty()) {
Ogre::String l0Preview = "auto";
if (!sel.layer0Mesh.empty() &&
sel.layer0Mesh != "none")
l0Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex,
slot,
sel.layer0Mesh);
if (ImGui::BeginCombo(
"Base (Layer 0)",
l0Preview.c_str())) {
if (ImGui::Selectable(
"auto",
sel.layer0Mesh.empty() ||
sel.layer0Mesh ==
"none")) {
sel.layer0Mesh =
"none";
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
for (const auto &m :
layer0Meshes) {
Ogre::String label =
CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex,
slot,
m);
bool isSelected =
(sel.layer0Mesh ==
m);
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
sel.layer0Mesh =
m;
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
}
ImGui::EndCombo();
}
}
} else {
ImGui::TextDisabled(
"Base: auto (stub hair)");
}
/* Layer 1 combo */
std::vector<Ogre::String> layer1Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
c->inlineSex, slot,
1);
Ogre::String l1Preview = "none";
if (!sel.layer1Mesh.empty())
l1Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex, 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";
reg.autoSave();
reg.markCharacterDirty(c->id);
}
for (const auto &m : layer1Meshes) {
Ogre::String label =
CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex,
slot,
m);
bool isSelected =
(sel.layer1Mesh == m);
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
sel.layer1Mesh = m;
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
}
ImGui::EndCombo();
}
/* Layer 2 combo */
std::vector<Ogre::String> layer2Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
c->inlineSex, slot,
2);
Ogre::String l2Preview = "none";
if (!sel.layer2Mesh.empty())
l2Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex, 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";
reg.autoSave();
reg.markCharacterDirty(c->id);
}
for (const auto &m : layer2Meshes) {
Ogre::String label =
CharacterSlotSystem::
getMeshLabel(
currentAge,
c->inlineSex,
slot,
m);
bool isSelected =
(sel.layer2Mesh == m);
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
sel.layer2Mesh = m;
reg.autoSave();
reg.markCharacterDirty(
c->id);
}
}
ImGui::EndCombo();
}
}
ImGui::TreePop();
}
}
/* Rebuild button */
if (ImGui::Button("Rebuild")) {
reg.markCharacterDirty(c->id);
}
}
/* ===================================================================== */
/* ImGui editor */
/* ===================================================================== */
@@ -1932,6 +2322,15 @@ void CharacterRegistry::drawEditor(bool *p_open)
}
}
/* Appearance (formerly CharacterSlots editor) */
ImGui::Separator();
if (ImGui::CollapsingHeader("Appearance",
ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Indent();
drawCharacterAppearanceEditor(*this, c);
ImGui::Unindent();
}
/* Family */
ImGui::Separator();
ImGui::Text("Family");
@@ -15,6 +15,7 @@
#include "../components/CharacterSlots.hpp"
class EditorUISystem;
class CharacterSlotSystem;
/**
* Global character and social-group registry.
@@ -70,12 +71,13 @@ public:
/* Age in years (runtime progression) */
int ageYears = 0;
/* Inline appearance (used when no prefab file exists) */
/* Inline appearance (authoritative source for character visuals) */
std::string inlineSex = "male";
int inlineOutfitLevel = 2;
std::unordered_map<std::string, SlotSelection>
inlineSlotSelections;
std::unordered_map<std::string, float> inlineShapeKeyWeights;
Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
/* Runtime-only characters are NOT saved to character_registry.json */
bool persistent = true;
@@ -145,6 +147,10 @@ public:
{
m_uiSystem = ui;
}
void setCharacterSlotSystem(CharacterSlotSystem *slotSystem)
{
m_slotSystem = slotSystem;
}
/**
* Load registry from auto-save file. Call after setWorld/setSceneManager.
@@ -399,6 +405,7 @@ private:
flecs::world *m_world = nullptr;
Ogre::SceneManager *m_sceneMgr = nullptr;
EditorUISystem *m_uiSystem = nullptr;
CharacterSlotSystem *m_slotSystem = nullptr;
void rebuildIndexes();
void addToIndex(size_t relIndex);
@@ -17,18 +17,30 @@
#include <OgreSceneNode.h>
#include <algorithm>
CharacterSlotSystem *CharacterSlotSystem::ms_singleton = nullptr;
bool CharacterSlotSystem::s_catalogLoaded = false;
nlohmann::json CharacterSlotSystem::s_bodyParts = nlohmann::json::object();
std::set<Ogre::String> CharacterSlotSystem::s_meshNames;
CharacterSlotSystem *CharacterSlotSystem::getSingletonPtr()
{
return ms_singleton;
}
CharacterSlotSystem::CharacterSlotSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
: m_world(world)
, m_sceneMgr(sceneMgr)
{
m_world.observer<CharacterSlotsComponent>("CharacterSlotsCleanup")
ms_singleton = this;
m_world.observer<CharacterIdentityComponent>("CharacterSlotsBuild")
.event(flecs::OnAdd)
.each([this](flecs::entity e, CharacterIdentityComponent &) {
markEntityDirty(e.id());
});
m_world.observer<CharacterIdentityComponent>("CharacterSlotsCleanup")
.event(flecs::OnRemove)
.each([this](flecs::entity e, CharacterSlotsComponent &) {
.each([this](flecs::entity e, CharacterIdentityComponent &) {
destroyCharacterParts(e);
});
}
@@ -40,6 +52,8 @@ CharacterSlotSystem::~CharacterSlotSystem()
toRemove.push_back(pair.first);
for (auto id : toRemove)
destroyCharacterParts(m_world.entity(id));
if (ms_singleton == this)
ms_singleton = nullptr;
}
void CharacterSlotSystem::initialize()
@@ -402,13 +416,14 @@ void CharacterSlotSystem::update()
if (!m_initialized)
return;
m_world.query<CharacterSlotsComponent>().each(
[&](flecs::entity e, CharacterSlotsComponent &cs) {
if (cs.dirty) {
buildCharacter(e, cs);
cs.dirty = false;
}
});
std::vector<flecs::entity_t> dirtyList(m_dirtyEntities.begin(),
m_dirtyEntities.end());
m_dirtyEntities.clear();
for (flecs::entity_t id : dirtyList) {
flecs::entity e = m_world.entity(id);
if (e.is_alive() && e.has<CharacterIdentityComponent>())
buildCharacter(e);
}
}
static const nlohmann::json *findCatalogEntry(const Ogre::String &age,
@@ -455,62 +470,38 @@ static void ensureMeshPoseAnimation(const Ogre::String &meshName)
}
/**
* Helper: retrieve the character's age string from the registry.
* Falls back to "adult" if no registry entry is found.
* Helper: retrieve the character's registry record.
* Returns nullptr if the entity is not a registered character.
*/
static Ogre::String getCharacterAge(flecs::entity e)
static const CharacterRegistry::CharacterRecord *
getCharacterRecord(flecs::entity e)
{
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec && !rec->age.empty())
return rec->age;
}
return "adult";
if (!e.has<CharacterIdentityComponent>())
return nullptr;
auto &id = e.get<CharacterIdentityComponent>();
return CharacterRegistry::getSingleton().findCharacter(id.registryId);
}
/**
* Helper: retrieve the character's outfit level from the registry.
* Falls back to 2 (clothed) if no registry entry is found.
*/
static int getCharacterOutfitLevel(flecs::entity e)
{
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
return rec->inlineOutfitLevel;
}
return 2;
}
void CharacterSlotSystem::buildCharacter(flecs::entity e,
CharacterSlotsComponent &cs)
void CharacterSlotSystem::buildCharacter(flecs::entity e)
{
destroyCharacterParts(e);
Ogre::String age = getCharacterAge(e);
const CharacterRegistry::CharacterRecord *rec = getCharacterRecord(e);
if (!rec)
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;
}
}
const Ogre::String &age = rec->age;
const Ogre::String &sex = rec->inlineSex;
const auto &slotSelections = rec->inlineSlotSelections;
const int outfitLevel = rec->inlineOutfitLevel;
/* Make sure every catalog slot exists in the selection map. This
* guarantees a face/body master is available so that hair with its
* own skeleton can attach to a real head bone. */
auto slots = getSlots(age, cs.sex);
/* Make sure every catalog slot exists in the selection map. */
auto slots = getSlots(age, sex);
std::unordered_map<Ogre::String, SlotSelection> selections =
slotSelections;
for (const auto &slot : slots) {
if (cs.slotSelections.find(slot) == cs.slotSelections.end())
cs.slotSelections[slot] = SlotSelection();
if (selections.find(slot) == selections.end())
selections[slot] = SlotSelection();
}
if (!e.has<TransformComponent>())
@@ -520,20 +511,18 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
if (!transform.node)
return;
int outfitLevel = getCharacterOutfitLevel(e);
/* Determine master slot (face preferred, else first non-empty) */
Ogre::String masterSlot;
if (cs.slotSelections.find("face") != cs.slotSelections.end()) {
Ogre::String mesh = resolveMesh(age, cs.sex, "face",
cs.slotSelections["face"],
if (selections.find("face") != selections.end()) {
Ogre::String mesh = resolveMesh(age, sex, "face",
selections["face"],
outfitLevel);
if (!mesh.empty())
masterSlot = "face";
}
if (masterSlot.empty()) {
for (const auto &pair : cs.slotSelections) {
Ogre::String mesh = resolveMesh(age, cs.sex, pair.first,
for (const auto &pair : selections) {
Ogre::String mesh = resolveMesh(age, sex, pair.first,
pair.second,
outfitLevel);
if (!mesh.empty()) {
@@ -546,8 +535,8 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
if (masterSlot.empty())
return;
Ogre::String masterMesh = resolveMesh(age, cs.sex, masterSlot,
cs.slotSelections[masterSlot],
Ogre::String masterMesh = resolveMesh(age, sex, masterSlot,
selections[masterSlot],
outfitLevel);
if (masterMesh.empty())
@@ -563,23 +552,16 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
masterEnt = m_sceneMgr->createEntity(meshPtr);
transform.node->attachObject(masterEnt);
m_entities[e.id()].parts[masterSlot] = masterEnt;
cs.masterEntity = masterEnt;
m_entities[e.id()].masterEntity = masterEnt;
/* Setup pose animation for shape keys */
const nlohmann::json *entry =
findCatalogEntry(age, cs.sex, masterSlot, masterMesh);
findCatalogEntry(age, sex, masterSlot, masterMesh);
applyShapeKeys(e, masterEnt, entry);
/* Re-prepare temp buffers after enabling vertex animation.
* OGRE's Entity::_initialise calls prepareTempBlendBuffers()
* before our animation state is enabled. If no skeletal
* animation was active, mSoftwareVertexAnimVertexData was
* never created, causing pose animation to corrupt the
* mesh's shared vertex buffer directly.
*/
/* Re-prepare temp buffers after enabling vertex animation. */
prepareEntityTempBlendBuffers(masterEnt);
/* Notify AnimationTreeSystem that entity changed */
if (e.has<AnimationTreeComponent>())
e.get_mut<AnimationTreeComponent>().dirty = true;
@@ -590,7 +572,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
return;
}
for (const auto &pair : cs.slotSelections) {
for (const auto &pair : selections) {
const Ogre::String &slot = pair.first;
const SlotSelection &sel = pair.second;
@@ -598,7 +580,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
continue;
Ogre::String mesh =
resolveMesh(age, cs.sex, slot, sel, outfitLevel);
resolveMesh(age, sex, slot, sel, outfitLevel);
if (mesh.empty())
continue;
@@ -615,7 +597,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e,
* armature). If so, attach to the master's
* Head bone instead of sharing skeletons. */
const nlohmann::json *entry =
findCatalogEntry(age, cs.sex, slot, mesh);
findCatalogEntry(age, sex, slot, mesh);
bool ownSkeleton = false;
Ogre::String attachBone = "mixamorig:Head";
if (entry) {
@@ -744,10 +726,10 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
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) {
/* Apply weights from the character registry record */
const CharacterRegistry::CharacterRecord *rec = getCharacterRecord(e);
if (rec) {
for (const auto &pair : rec->inlineShapeKeyWeights) {
auto it = nameToIndex.find(pair.first);
if (it == nameToIndex.end())
continue;
@@ -777,6 +759,11 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
as->setTimePosition(0.0f);
}
void CharacterSlotSystem::markEntityDirty(flecs::entity_t id)
{
m_dirtyEntities.insert(id);
}
void CharacterSlotSystem::destroyCharacterParts(flecs::entity e)
{
auto it = m_entities.find(e.id());
@@ -795,9 +782,18 @@ void CharacterSlotSystem::destroyCharacterParts(flecs::entity e)
}
it->second.parts.clear();
it->second.masterEntity = nullptr;
m_entities.erase(it);
}
Ogre::Entity *CharacterSlotSystem::getMasterEntity(flecs::entity e)
{
auto it = m_entities.find(e.id());
if (it == m_entities.end())
return nullptr;
return it->second.masterEntity;
}
Ogre::Entity *CharacterSlotSystem::getSlotEntity(flecs::entity e,
const Ogre::String &slot)
{
@@ -7,6 +7,7 @@
#include <nlohmann/json.hpp>
#include <set>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "../components/CharacterSlots.hpp"
@@ -26,6 +27,10 @@ struct BodyPartEntry {
* System that manages multi-slot character meshes with shared skeleton.
* Loads body part catalog from body_part_*.json files and creates/updates
* Ogre entities for each slot, sharing the skeleton from the master slot.
*
* Character appearance is read from CharacterRegistry (via
* CharacterIdentityComponent); the deprecated CharacterSlotsComponent is no
* longer used as a data source.
*/
class CharacterSlotSystem {
public:
@@ -35,6 +40,9 @@ public:
void initialize();
void update();
/* Singleton access */
static CharacterSlotSystem *getSingletonPtr();
/* Static catalog access */
static void loadCatalog();
static bool isCatalogLoaded();
@@ -73,18 +81,23 @@ public:
const SlotSelection &sel,
int outfitLevel);
/* Slot visibility helpers */
/* Mark an entity for rebuild on next update */
void markEntityDirty(flecs::entity_t id);
/* Runtime entity accessors */
Ogre::Entity *getMasterEntity(flecs::entity e);
Ogre::Entity *getSlotEntity(flecs::entity e,
const Ogre::String &slot);
void setSlotVisible(flecs::entity e, const Ogre::String &slot,
bool visible);
private:
static CharacterSlotSystem *ms_singleton;
static bool s_catalogLoaded;
static nlohmann::json s_bodyParts;
static std::set<Ogre::String> s_meshNames;
void buildCharacter(flecs::entity e, CharacterSlotsComponent &cs);
void buildCharacter(flecs::entity e);
void destroyCharacterParts(flecs::entity e);
void applyShapeKeys(flecs::entity e, Ogre::Entity *ent,
const nlohmann::json *entry);
@@ -94,9 +107,11 @@ private:
bool m_initialized = false;
struct PartEntities {
Ogre::Entity *masterEntity = nullptr;
std::unordered_map<Ogre::String, Ogre::Entity *> parts;
};
std::unordered_map<flecs::entity_t, PartEntities> m_entities;
std::unordered_set<flecs::entity_t> m_dirtyEntities;
};
#endif // EDITSCENE_CHARACTERSLOTSYSTEM_HPP
@@ -1,4 +1,5 @@
#include "CharacterSystem.hpp"
#include "CharacterSlotSystem.hpp"
#include "../components/CharacterSlots.hpp"
#include <Jolt/Physics/Character/Character.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
@@ -11,10 +12,12 @@
static Ogre::Entity *getMasterEntity(flecs::entity e)
{
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
if (!e.is_alive())
return nullptr;
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
return cs.masterEntity;
CharacterSlotSystem *slotSystem = CharacterSlotSystem::getSingletonPtr();
if (!slotSystem)
return nullptr;
return slotSystem->getMasterEntity(e);
}
static bool getBoneWorldMatrix(Ogre::Entity *masterEnt,
@@ -1067,7 +1067,9 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
// Render CharacterSlots if present
if (entity.has<CharacterSlotsComponent>()) {
auto &cs = entity.get_mut<CharacterSlotsComponent>();
/* CharacterSlotsComponent is now an empty tag; get_mut is illegal
* for zero-sized types in flecs. */
CharacterSlotsComponent cs;
m_componentRegistry.render<CharacterSlotsComponent>(entity, cs);
componentCount++;
}
@@ -1298,6 +1300,8 @@ void EditorUISystem::renderAddComponentMenu(flecs::entity entity)
[&](const std::type_index &type,
const ComponentRegistry::ComponentInfo
&info) {
if (!info.adder)
return;
if (!m_componentRegistry
.entityHasComponent(
entity, type)) {
@@ -1316,6 +1320,8 @@ void EditorUISystem::renderAddComponentMenu(flecs::entity entity)
[&](const std::type_index &type,
const ComponentRegistry::ComponentInfo
&info) {
if (!info.adder)
return;
if (!m_componentRegistry
.entityHasComponent(
entity,
@@ -201,6 +201,11 @@ public:
void renderDialogueSettingsWindow();
void renderInventoryConfigWindow();
CharacterRegistry &getCharacterRegistry()
{
return m_characterRegistry;
}
private:
// File menu
void renderFileMenu();
@@ -1,5 +1,7 @@
#include "HairPhysicsSystem.hpp"
#include "CharacterSlotSystem.hpp"
#include "CharacterRegistry.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/Character.hpp"
#include <OgreEntity.h>
@@ -96,12 +98,22 @@ void HairPhysicsSystem::prePhysicsUpdate()
if (!m_initialized || !m_physics)
return;
m_world.query<CharacterSlotsComponent>().each(
[this](flecs::entity e, CharacterSlotsComponent &cs) {
Ogre::Entity *masterEnt = cs.masterEntity;
m_world.query<CharacterIdentityComponent>().each(
[this](flecs::entity e, CharacterIdentityComponent &ci) {
Ogre::Entity *masterEnt =
m_slotSystem->getMasterEntity(e);
if (!masterEnt)
return;
for (const auto &pair : cs.slotSelections) {
const Ogre::String &slot = pair.first;
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
ci.registryId);
if (!rec)
return;
auto slots = CharacterSlotSystem::getSlots(
rec->age, rec->inlineSex);
for (const auto &slot : slots) {
Ogre::Entity *partEnt =
m_slotSystem->getSlotEntity(e, slot);
if (!partEnt || !partEnt->hasSkeleton())
@@ -450,7 +462,7 @@ bool HairPhysicsSystem::isStateValid(const HairRagdollState &state) const
return false;
flecs::entity e = m_world.entity(state.entityId);
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
if (!e.is_alive() || !e.has<CharacterIdentityComponent>())
return false;
Ogre::Entity *current = m_slotSystem->getSlotEntity(e, state.slotName);
@@ -463,8 +475,7 @@ void HairPhysicsSystem::syncRootToHead(HairRagdollState &state)
return;
flecs::entity e = m_world.entity(state.entityId);
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
Ogre::Entity *masterEnt = cs.masterEntity;
Ogre::Entity *masterEnt = m_slotSystem->getMasterEntity(e);
if (!masterEnt)
return;
@@ -511,8 +522,7 @@ void HairPhysicsSystem::syncPhysicsToSkeleton(HairRagdollState &state)
/* Get the attachment transform so we can compute hair-entity-local
* transforms for the root bone. */
flecs::entity e = m_world.entity(state.entityId);
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
Ogre::Entity *masterEnt = cs.masterEntity;
Ogre::Entity *masterEnt = m_slotSystem->getMasterEntity(e);
Ogre::Bone *attachBone = findAttachBone(state.hairEntity, masterEnt);
Ogre::Matrix4 attachBoneInv = Ogre::Matrix4::IDENTITY;
@@ -1,8 +1,10 @@
#include "PathFollowingSystem.hpp"
#include "AnimationTreeSystem.hpp"
#include "NavMeshSystem.hpp"
#include "CharacterRegistry.hpp"
#include "../components/PathFollowing.hpp"
#include "../components/Character.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/Transform.hpp"
#include "../components/NavMesh.hpp"
@@ -47,11 +49,15 @@ void PathFollowingSystem::rotateTowards(flecs::entity e,
return;
flatDir.normalise();
// Get the character's front-facing axis
// Get the character's front-facing axis from the registry
Ogre::Vector3 frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
if (e.has<CharacterSlotsComponent>()) {
auto &slots = e.get<CharacterSlotsComponent>();
frontAxis = slots.frontAxis;
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
frontAxis = rec->frontAxis;
}
// Use yaw rotation only (Y plane)
@@ -60,11 +60,15 @@ void ProceduralMaterialSystem::createMaterial(
flecs::entity entity, ProceduralMaterialComponent &component)
{
try {
// Ensure persistent ID is assigned.
if (component.materialId.empty()) {
component.materialId = generatePersistentId();
}
// Generate unique material name if not set
if (component.materialName.empty()) {
component.materialName =
"ProceduralMat_" + std::to_string(entity.id()) +
"_" + std::to_string(m_createdCount++);
"ProceduralMat_" + component.materialId;
}
Ogre::MaterialManager &matMgr =
@@ -34,8 +34,7 @@ private:
flecs::query<struct ProceduralMaterialComponent> m_query;
bool m_initialized = false;
int m_createdCount = 0;
// Create the Ogre material
void createMaterial(flecs::entity entity, struct ProceduralMaterialComponent& component);
@@ -37,9 +37,14 @@ void ProceduralTextureSystem::update()
void ProceduralTextureSystem::generateTexture(flecs::entity entity, ProceduralTextureComponent& component)
{
try {
// Ensure persistent ID is assigned.
if (component.textureId.empty()) {
component.textureId = generatePersistentId();
}
// Generate unique texture name if not set
if (component.textureName.empty()) {
component.textureName = "ProceduralTex_" + std::to_string(entity.id()) + "_" + std::to_string(m_generatedCount++);
component.textureName = "ProceduralTex_" + component.textureId;
}
const int gridSize = ProceduralTextureComponent::GRID_SIZE;
@@ -31,8 +31,7 @@ private:
flecs::query<struct ProceduralTextureComponent> m_query;
bool m_initialized = false;
int m_generatedCount = 0;
// Generate the texture for a component
void generateTexture(flecs::entity entity, struct ProceduralTextureComponent& component);
};
@@ -21,6 +21,7 @@
#include "../components/Character.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/CharacterIdentity.hpp"
#include "CharacterRegistry.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
#include "../components/StartupMenu.hpp"
@@ -244,10 +245,6 @@ nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity)
json["character"] = serializeCharacter(entity);
}
if (entity.has<CharacterSlotsComponent>()) {
json["characterSlots"] = serializeCharacterSlots(entity);
}
if (entity.has<CharacterIdentityComponent>()) {
json["characterIdentity"] = serializeCharacterIdentity(entity);
}
@@ -470,14 +467,16 @@ void SceneSerializer::deserializeEntity(const nlohmann::json &json,
deserializeCharacter(entity, json["character"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
if (json.contains("animationTree")) {
deserializeAnimationTree(entity, json["animationTree"]);
}
@@ -703,14 +702,16 @@ void SceneSerializer::deserializeEntityComponents(
deserializeCharacter(entity, json["character"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
/* Deserialize identity before slots so legacy slot data migrates into
* the correct registry record. */
if (json.contains("characterIdentity")) {
deserializeCharacterIdentity(entity, json["characterIdentity"]);
}
if (json.contains("characterSlots")) {
deserializeCharacterSlots(entity, json["characterSlots"]);
}
if (json.contains("animationTree")) {
deserializeAnimationTree(entity, json["animationTree"]);
}
@@ -1891,7 +1892,7 @@ void SceneSerializer::deserializeProceduralTexture(flecs::entity entity,
// Auto-generate texture ID if empty
if (texture.textureId.empty()) {
texture.textureId = "texture_" + std::to_string(entity.id());
texture.textureId = generatePersistentId();
}
// Deserialize colors array
@@ -1979,7 +1980,7 @@ void SceneSerializer::deserializeProceduralMaterial(flecs::entity entity,
// Auto-generate material ID if empty
if (material.materialId.empty()) {
material.materialId = "material_" + std::to_string(entity.id());
material.materialId = generatePersistentId();
}
// Resolve texture reference by ID (like LOD system does)
@@ -2227,82 +2228,82 @@ static void deserializeAnimationTreeNode(AnimationTreeNode &node,
nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity)
{
auto &cs = entity.get<CharacterSlotsComponent>();
nlohmann::json json;
json["sex"] = cs.sex;
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["layer0Mesh"] = pair.second.layer0Mesh;
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 };
return json;
/* CharacterSlotsComponent is deprecated and no longer saved. */
(void)entity;
return nlohmann::json::object();
}
void SceneSerializer::deserializeCharacterSlots(flecs::entity entity,
const nlohmann::json &json)
{
CharacterSlotsComponent cs;
cs.sex = json.value("sex", "male");
if (json.contains("slots") && json["slots"].is_object()) {
for (auto &[slot, mesh] : json["slots"].items())
cs.slots[slot] = mesh.get<std::string>();
/* Migrate legacy CharacterSlots data into the character registry.
* The empty component is added as a placeholder reminder. */
uint64_t registryId = 0;
if (entity.has<CharacterIdentityComponent>())
registryId = entity.get<CharacterIdentityComponent>().registryId;
if (registryId == 0) {
registryId = CharacterRegistry::getSingleton().createCharacter(
"Migrated", "Character", "", true);
entity.set<CharacterIdentityComponent>(
CharacterIdentityComponent{ registryId });
}
// 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("layer0Mesh"))
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
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";
CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(registryId);
if (rec) {
rec->inlineSex = json.value("sex", rec->inlineSex);
if (json.contains("slots") && json["slots"].is_object()) {
for (auto &[slot, mesh] : json["slots"].items()) {
SlotSelection sel;
sel.explicitMesh = mesh.get<std::string>();
rec->inlineSlotSelections[slot] = sel;
}
sel.explicitMesh = selJson.value("explicitMesh", "");
cs.slotSelections[slot] = sel;
}
}
// Deserialize front axis
if (json.contains("frontAxis") && json["frontAxis"].is_array() &&
json["frontAxis"].size() >= 3) {
cs.frontAxis = Ogre::Vector3(json["frontAxis"][0].get<float>(),
json["frontAxis"][1].get<float>(),
json["frontAxis"][2].get<float>());
if (cs.frontAxis.squaredLength() > 0.0001f)
cs.frontAxis.normalise();
if (json.contains("slotSelections") &&
json["slotSelections"].is_object()) {
for (auto &[slot, selJson] :
json["slotSelections"].items()) {
SlotSelection sel;
if (selJson.contains("layer0Mesh"))
sel.layer0Mesh =
selJson.value("layer0Mesh", "");
if (selJson.contains("layer1Mesh"))
sel.layer1Mesh =
selJson.value("layer1Mesh", "");
if (selJson.contains("layer2Mesh"))
sel.layer2Mesh =
selJson.value("layer2Mesh", "");
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", "");
rec->inlineSlotSelections[slot] = sel;
}
}
if (json.contains("frontAxis") &&
json["frontAxis"].is_array() &&
json["frontAxis"].size() >= 3) {
Ogre::Vector3 fa(
json["frontAxis"][0].get<float>(),
json["frontAxis"][1].get<float>(),
json["frontAxis"][2].get<float>());
if (fa.squaredLength() > 0.0001f)
fa.normalise();
rec->frontAxis = fa;
}
CharacterRegistry::getSingleton().autoSave();
}
cs.dirty = true;
entity.set<CharacterSlotsComponent>(cs);
entity.add<CharacterSlotsComponent>();
CharacterRegistry::getSingleton().markCharacterDirty(registryId);
}
static nlohmann::json serializeAnimationTreeNode(const AnimationTreeNode &node)
@@ -3,7 +3,9 @@
#include "../components/Transform.hpp"
#include "../components/Character.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/GoapBlackboard.hpp"
#include "CharacterRegistry.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/ActionDebug.hpp"
#include "../components/PathFollowing.hpp"
@@ -64,9 +66,13 @@ Ogre::Vector3 SmartObjectSystem::getEntityPosition(flecs::entity e)
Ogre::Vector3 SmartObjectSystem::getFrontAxis(flecs::entity e) const
{
if (e.has<CharacterSlotsComponent>()) {
auto &slots = e.get<CharacterSlotsComponent>();
return slots.frontAxis;
if (e.has<CharacterIdentityComponent>()) {
auto &id = e.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
return rec->frontAxis;
}
return Ogre::Vector3::NEGATIVE_UNIT_Z;
}
@@ -1,9 +1,5 @@
#include "CharacterSlotsEditor.hpp"
#include "../systems/CharacterSlotSystem.hpp"
#include "../systems/CharacterRegistry.hpp"
#include "../components/CharacterIdentity.hpp"
#include <imgui.h>
#include <algorithm>
CharacterSlotsEditor::CharacterSlotsEditor(Ogre::SceneManager *sceneMgr)
: m_sceneMgr(sceneMgr)
@@ -13,384 +9,12 @@ CharacterSlotsEditor::CharacterSlotsEditor(Ogre::SceneManager *sceneMgr)
bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
CharacterSlotsComponent &cs)
{
bool modified = false;
(void)entity;
(void)cs;
(void)m_sceneMgr;
if (ImGui::CollapsingHeader("Character Slots",
ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::Indent();
CharacterSlotSystem::loadCatalog();
/* Get current age from registry */
Ogre::String currentAge = "adult";
if (entity.has<CharacterIdentityComponent>()) {
auto &id = entity.get<CharacterIdentityComponent>();
const CharacterRegistry::CharacterRecord *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec && !rec->age.empty())
currentAge = rec->age;
}
/* Sex selector */
std::vector<Ogre::String> sexes =
CharacterSlotSystem::getSexes(currentAge);
Ogre::String currentSex = cs.sex;
if (ImGui::BeginCombo("Sex", currentSex.c_str())) {
for (const auto &sex : sexes) {
bool isSelected = (currentSex == sex);
if (ImGui::Selectable(sex.c_str(),
isSelected)) {
cs.sex = sex;
modified = true;
cs.dirty = true;
}
if (isSelected)
ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::Separator();
ImGui::Separator();
/* Front-facing axis */
{
Ogre::Vector3 axis = cs.frontAxis;
float axisVals[3] = { axis.x, axis.y, axis.z };
ImGui::Text("Front Axis:");
ImGui::SameLine();
ImGui::TextDisabled(
"(direction character faces, used by path following)");
if (ImGui::DragFloat3("##frontAxis", axisVals, 0.1f,
-1.0f, 1.0f)) {
cs.frontAxis = Ogre::Vector3(
axisVals[0], axisVals[1], axisVals[2]);
if (cs.frontAxis.squaredLength() > 0.0001f)
cs.frontAxis.normalise();
modified = true;
}
ImGui::SameLine();
if (ImGui::SmallButton("-Z")) {
cs.frontAxis = Ogre::Vector3::NEGATIVE_UNIT_Z;
modified = true;
}
ImGui::SameLine();
if (ImGui::SmallButton("+Z")) {
cs.frontAxis = Ogre::Vector3::UNIT_Z;
modified = true;
}
}
ImGui::Separator();
/* Shape Keys */
if (ImGui::CollapsingHeader("Shape Keys")) {
auto shapeKeys = CharacterSlotSystem::getShapeKeyNames(
currentAge, 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();
/* Get outfit level from registry for resolveMesh calls */
int outfitLevel = 2;
if (entity.has<CharacterIdentityComponent>()) {
auto &id = entity.get<CharacterIdentityComponent>();
auto *rec =
CharacterRegistry::getSingleton().findCharacter(
id.registryId);
if (rec)
outfitLevel = rec->inlineOutfitLevel;
}
/* Slot selections */
std::vector<Ogre::String> availableSlots =
CharacterSlotSystem::getSlots(currentAge, cs.sex);
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());
for (const auto &slot : availableSlots) {
/* 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;
}
SlotSelection &sel = cs.slotSelections[slot];
if (ImGui::TreeNode(slot.c_str())) {
/* Resolved mesh preview */
Ogre::String resolved =
CharacterSlotSystem::resolveMesh(
currentAge, cs.sex, slot, sel,
outfitLevel);
ImGui::TextDisabled("Resolved: %s",
resolved.empty() ?
"(none)" :
resolved.c_str());
/* 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(
currentAge,
cs.sex, slot,
sel,
outfitLevel);
}
modified = true;
cs.dirty = true;
}
if (useExplicit) {
std::vector<Ogre::String> meshes =
CharacterSlotSystem::getMeshes(
currentAge, 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 0 combo (base meshes like hair) */
/* Hair slot always uses auto stub; no base editing */
if (slot != "hair") {
std::vector<Ogre::String> layer0Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
cs.sex, slot,
0);
if (!layer0Meshes.empty()) {
Ogre::String l0Preview = "auto";
if (!sel.layer0Mesh.empty() &&
sel.layer0Mesh != "none")
l0Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
cs.sex,
slot,
sel.layer0Mesh);
if (ImGui::BeginCombo(
"Base (Layer 0)",
l0Preview.c_str())) {
if (ImGui::Selectable(
"auto",
sel.layer0Mesh.empty() ||
sel.layer0Mesh ==
"none")) {
sel.layer0Mesh =
"none";
modified = true;
cs.dirty = true;
}
for (const auto &m :
layer0Meshes) {
Ogre::String label = CharacterSlotSystem::
getMeshLabel(
currentAge,
cs.sex,
slot,
m);
bool isSelected =
(sel.layer0Mesh ==
m);
if (ImGui::Selectable(
label.c_str(),
isSelected)) {
sel.layer0Mesh =
m;
modified =
true;
cs.dirty =
true;
}
}
ImGui::EndCombo();
}
}
} else {
ImGui::TextDisabled("Base: auto (stub hair)");
}
/* Layer 1 combo */
std::vector<Ogre::String> layer1Meshes =
CharacterSlotSystem::
getMeshesForLayer(
currentAge,
cs.sex, slot,
1);
Ogre::String l1Preview = "none";
if (!sel.layer1Mesh.empty())
l1Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
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(
currentAge,
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(
currentAge,
cs.sex, slot,
2);
Ogre::String l2Preview = "none";
if (!sel.layer2Mesh.empty())
l2Preview = CharacterSlotSystem::
getMeshLabel(
currentAge,
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(
currentAge,
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();
}
}
ImGui::TreePop();
}
}
/* Rebuild button */
if (ImGui::Button("Rebuild")) {
cs.dirty = true;
modified = true;
}
ImGui::Unindent();
}
return modified;
ImGui::Text("Character Slots");
ImGui::TextDisabled(
"Appearance data has moved to Tools -> Character Registry.");
return false;
}
@@ -6,7 +6,8 @@
#include <OgreSceneManager.h>
/**
* Editor for CharacterSlotsComponent
* Placeholder editor for the deprecated CharacterSlotsComponent.
* All appearance editing now happens in Tools -> Character Registry.
*/
class CharacterSlotsEditor : public ComponentEditor<CharacterSlotsComponent> {
public: