Compare commits

...

28 Commits

Author SHA1 Message Date
slapin 0a5edacf8a Character hair physics implementation 2026-06-16 01:22:20 +03:00
slapin 765dffbed0 Clothes and hairs 2026-05-30 20:04:07 +03:00
slapin a553621c7f Fixed normal seams 2026-05-25 16:00:24 +03:00
slapin 86310e96f2 Seams are fixed 2026-05-25 05:07:23 +03:00
slapin 1a0fb87b93 some docs 2026-05-25 04:19:03 +03:00
slapin b71b599d9c Fixed inflation 2026-05-25 04:10:44 +03:00
slapin eea50adfcb Gap filling, improvements for character pipeline 2026-05-25 01:42:28 +03:00
slapin c7ef9283cd Fixed shape keys 2026-05-22 22:03:31 +03:00
slapin 3a3edf785c Character pipeline fixes 2026-05-22 18:09:26 +03:00
slapin fc486eea82 Pipeline update clothes 2026-05-21 20:04:06 +03:00
slapin b19033b557 outfitLevel moved, character ID safeguard 2026-05-21 15:36:27 +03:00
slapin 9968cb8c75 Lua scripts package 2026-05-21 12:36:08 +03:00
slapin aae7620512 Documentation update 2026-05-19 10:15:00 +03:00
slapin 970d0f9034 Save/Load system works well 2026-05-19 09:55:25 +03:00
slapin bea438bd50 No more animation jitter 2026-05-18 16:01:53 +03:00
slapin 3f40d84847 PackageTool and package library for assets 2026-05-16 20:50:26 +03:00
slapin 8630bfcf18 Updated APIs and tests 2026-05-14 13:32:32 +03:00
slapin 5bb20d416d Item registry 2026-05-14 02:28:33 +03:00
slapin eb0d05a577 Pause menu 2026-05-14 00:23:53 +03:00
slapin ef49506515 Pregnancy and birth 2026-05-13 23:31:59 +03:00
slapin 089d13520e Name generator 2026-05-11 16:37:32 +03:00
slapin 472af01e94 Now RPG data are in character registry 2026-05-11 15:03:31 +03:00
slapin f9e61dcb05 Update to use character registry; Character Slots fixes 2026-05-11 13:12:18 +03:00
slapin 42f6a218fb Now nude meshes work as intended 2026-05-11 00:30:47 +03:00
slapin ce888bc5bb Pipeline update 2026-05-10 14:43:57 +03:00
slapin 333a0b9938 Better tag support 2026-05-10 14:12:09 +03:00
slapin 11530dd7fc Dialogue uses arrays 2026-05-03 01:25:25 +03:00
slapin 3fd167ebff More events added 2026-05-03 01:11:14 +03:00
134 changed files with 25187 additions and 1846 deletions
+63 -20
View File
@@ -47,15 +47,12 @@ set(VRM_IMPORTED_BLENDS
# COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${CHARACTER_GLBS}
# DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models.py ${VRM_IMPORTED_BLENDS} ${EDITED_BLEND_TARGETS}
# WORKING_DIRECTORY ${CMAKE_BINARY_DIR})
set(FEMALE_OBJECTS "BodyTopRobe;BodyTop;BodyBottom;BodyFeet;Hair;Face;BackHair;Accessoty")
set(MALE_OBJECTS "BodyTopRobe;BodyTop;BodyBottomPants;BodyBottom_Panties001;BodyBottom;BodyFeetPants;BodyFeetPantsShoes;BodyFeet;Hair;Face;BackHair;Accessory")
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES}
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
"${MALE_OBJECTS}"
"male"
tmp-edited-male.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb
@@ -65,6 +62,7 @@ add_custom_command(
DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py
${VRM_IMPORTED_BLENDS}
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend
# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
VERBATIM
@@ -76,7 +74,6 @@ add_custom_command(
COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py --
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend
${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
"${FEMALE_OBJECTS}"
"female"
tmp-edited-female.blend
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb
@@ -328,8 +325,8 @@ function(add_blend_consolidation INPUT_BLEND COMBINED_BLEND OUTPUT_BLEND)
# Get the base name from the combined blend file for stamp derivation
get_filename_component(COMBINED_NAME "${COMBINED_BLEND}" NAME_WE)
# Remove "_combined" suffix if present
string(REGEX REPLACE "_combined$" "" TARGET_BASE "${COMBINED_NAME}")
# Remove "_combined" or "_shaped" suffix if present
string(REGEX REPLACE "_(combined|shaped)$" "" TARGET_BASE "${COMBINED_NAME}")
# Derive stamp dependency
get_filename_component(COMBINED_DIR "${COMBINED_BLEND}" DIRECTORY)
@@ -408,10 +405,10 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND)
${COMBINED_BLEND}
${SHAPED_BLEND}
)
# Step 2: Consolidate
# Step 2: Consolidate (use shaped blend which has shape keys transferred)
add_blend_consolidation(
"${INPUT_BLEND}"
"${COMBINED_BLEND}"
"${SHAPED_BLEND}"
"${FINAL_OUTPUT_BLEND}"
)
@@ -422,18 +419,64 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND)
)
endfunction()
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-bottom.blend)
set(SEX_LIST "male" "female")
foreach(SEX ${SEX_LIST})
set(HAIR_WEIGHTED_STAMP "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.stamp")
add_custom_command(
OUTPUT ${HAIR_WEIGHTED_STAMP}
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py --
${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend
./ ${CMAKE_CURRENT_BINARY_DIR}/clothes
COMMAND ${CMAKE_COMMAND} -E touch ${HAIR_WEIGHTED_STAMP}
COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.blend
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMENT "Processing hair meshes (weight transfer)"
)
add_clothes_pipeline(
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend" # FINAL_OUTPUT_BLEND
)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-top.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-bottom.blend)
weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-feet.blend)
endforeach()
add_clothes_pipeline(
"${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend" # INPUT_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-bottom_weighted.blend" # WEIGHTED_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend" # FINAL_OUTPUT_BLEND
)
# Propagate missing shape keys (like "fat") from Body_shapes to base body parts.
# This ensures all body part meshes have consistent shape keys, preventing seams.
function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
add_custom_command(
OUTPUT ${OUTPUT_BLEND}
DEPENDS ${INPUT_BLEND}
${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMAND ${CMAKE_COMMAND} -E copy ${INPUT_BLEND} ${OUTPUT_BLEND}
COMMAND ${BLENDER} -b -Y ${OUTPUT_BLEND}
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
${OUTPUT_BLEND} ${OUTPUT_BLEND}
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)
endfunction()
set(SLOT_LIST "bottom" "top" "feet" "hair")
list(GET SLOT_LIST -1 LAST_SLOT)
# male and female clothes pipeline
foreach (SEX ${SEX_LIST})
add_shape_key_propagation(
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend
)
set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend")
foreach (SLOT ${SLOT_LIST})
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend")
if (SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend"
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
)
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
endforeach()
@@ -0,0 +1,141 @@
"""
Add missing shape keys from Body_shapes to body part objects.
Preserves existing shape keys.
Usage: blender -b --python add_missing_shape_keys.py -- <blend_file> <output_file>
"""
import bpy
import sys
import os
import mathutils
from mathutils.bvhtree import BVHTree
# Import functions from transfer_shape_keys.py
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from transfer_shape_keys import (
compute_robust_surface_mapping,
compute_side_preserving_position,
smooth_boundary_areas,
smooth_penetration_areas,
create_bvh_for_source,
load_source_data,
fix_seams_across_objects,
fix_normals_across_objects
)
def get_source_object_name(target_obj_info):
if target_obj_info.get('ref_shapes'):
return target_obj_info['ref_shapes']
return None
def add_missing_shape_keys(source_data, target_obj):
"""Add only missing shape keys to target object, preserving existing ones."""
if not target_obj.data.shape_keys:
target_obj.shape_key_add(name='Basis', from_mix=False)
existing_names = {kb.name for kb in target_obj.data.shape_keys.key_blocks}
missing_names = [n for n in source_data['names'] if n != 'Basis' and n not in existing_names]
if not missing_names:
print(f" {target_obj.name}: all shape keys present, skipping")
return
print(f" {target_obj.name}: adding missing keys: {missing_names}")
# Create missing shape keys
for sk_name in missing_names:
target_obj.shape_key_add(name=sk_name, from_mix=False)
target_obj.data.shape_keys.use_relative = True
# Compute mapping
mapping = compute_robust_surface_mapping(target_obj, source_data)
# Set values for missing shape keys
for sk_name in missing_names:
print(f" Setting {sk_name}...")
sk = target_obj.data.shape_keys.key_blocks[sk_name]
target_obj.data.shape_keys.use_relative = False
for i, v in enumerate(sk.data):
if i < len(mapping['target_verts']):
pos = compute_side_preserving_position(i, sk_name, mapping, source_data, 1.0)
v.co = pos
sk.data.update()
target_obj.data.update_tag()
bpy.context.view_layer.update()
target_obj.data.shape_keys.use_relative = True
target_obj.data.update_tag()
bpy.context.view_layer.update()
smooth_boundary_areas(target_obj, sk_name, mapping, source_data)
smooth_penetration_areas(target_obj, sk_name, mapping, source_data)
print(f" ✓ Added {sk_name}")
def process_blend(blend_path, output_path):
print(f"Processing: {blend_path}")
bpy.ops.wm.open_mainfile(filepath=blend_path)
# Find source object
source_obj = None
for name in ['Body_shapes', 'Body']:
if name in bpy.data.objects and bpy.data.objects[name].type == 'MESH':
obj = bpy.data.objects[name]
if obj.data.shape_keys and len(obj.data.shape_keys.key_blocks) > 1:
source_obj = obj
print(f"Found source: {name}")
break
if not source_obj:
print("ERROR: No source object with shape keys found")
return False
# Build source data
source_data = {
'names': [kb.name for kb in source_obj.data.shape_keys.key_blocks],
'vertex_positions': {},
'polygons': [],
'is_relative': source_obj.data.shape_keys.use_relative,
'source_obj_name': source_obj.name
}
for poly in source_obj.data.polygons:
source_data['polygons'].append([v for v in poly.vertices])
for sk in source_obj.data.shape_keys.key_blocks:
source_data['vertex_positions'][sk.name] = [(v.co.x, v.co.y, v.co.z) for v in sk.data]
print(f"Source shape keys: {source_data['names']}")
# Process body part objects
required_props = {'age', 'sex', 'slot'}
modified = False
for obj in bpy.data.objects:
if obj.type == 'MESH' and all(p in obj for p in required_props):
if obj == source_obj:
continue
add_missing_shape_keys(source_data, obj)
modified = True
# Fix seams: synchronize shape key offsets for vertices sharing
# the same basis position across body parts.
fix_seams_across_objects()
fix_normals_across_objects()
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"Saved: {output_path}")
return True
if __name__ == "__main__":
try:
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) >= 2:
process_blend(args[0], args[1])
else:
print("Usage: blender -b -P script.py -- <input.blend> <output.blend>")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+175 -52
View File
@@ -2,14 +2,21 @@ import bpy
import bmesh
import sys
import os
import json
import mathutils
from mathutils.bvhtree import BVHTree
def load_blend_files(clothes_blend_path, body_blend_path):
"""Load objects from blend files and return all loaded objects"""
"""Load objects from blend files and return all loaded objects
IMPORTANT: Body file must be loaded FIRST so its objects keep their original names.
The clothes file may contain reference meshes with the same names as body parts
(e.g., 'BodyTop' used for weight painting). If clothes are loaded first, the body's
real objects get renamed to 'BodyTop.001' etc., breaking the ref_part lookup.
"""
loaded_objects = []
for path in [clothes_blend_path, body_blend_path]:
for path in [body_blend_path, clothes_blend_path]:
with bpy.data.libraries.load(path) as (data_from, data_to):
data_to.objects = data_from.objects
for obj in data_to.objects:
@@ -36,34 +43,27 @@ def get_transformation_matrices(obj):
return m, m_inv, m_normal
def raycast_and_adjust_vertices(target_body, bvh_cloth, out_ray_length=0.15):
"""Raycast from body to cloth and adjust vertices that intersect"""
m_body, m_body_inv, m_body_normal = get_transformation_matrices(target_body)
"""Raycast from body to cloth and mark vertices that are covered.
Returns a list where 1 = vertex is covered by clothing, 0 = visible.
Does NOT modify vertex positions -- that used to cause visible steps
at clothing boundaries because border vertices kept the inward offset
while exposed neighbours did not."""
m_body, _, m_body_normal = get_transformation_matrices(target_body)
num_verts = len(target_body.data.vertices)
hit_values = [0] * num_verts
has_shape_keys = target_body.data.shape_keys is not None
# Forward raycast (into cloth)
for i, v in enumerate(target_body.data.vertices):
v_world = m_body @ v.co
n_world = (m_body_normal @ v.normal).normalized()
# Raycast forward (into cloth) and backward (from inside cloth)
# Raycast forward (outward) and backward (inward)
hit_f, _, _, _ = bvh_cloth.ray_cast(v_world, n_world, out_ray_length)
hit_b, _, _, _ = bvh_cloth.ray_cast(v_world, -n_world, 0.005)
if hit_f or hit_b:
hit_values[i] = 1
# Adjust vertex position to be slightly outside cloth
offset = -n_world * (0.005 if hit_f else 0.01)
new_co = m_body_inv @ (v_world + offset)
v.co = new_co
# Update shape keys if they exist
if has_shape_keys:
for kb in target_body.data.shape_keys.key_blocks:
kb.data[i].co = new_co
return hit_values
def protect_and_remove_hidden_geometry(target_body, hit_values, threshold=4.0):
@@ -151,13 +151,23 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
print(f"Processing: {clothing_name_for_final} -> {target_name} (using copy)")
# NEW CODE: Store custom properties from clothing before they might be lost
# Store custom properties from clothing before they might be lost
clothing_props = {}
for prop_name in ["ref_shapes", "ref_ray_length"]:
for prop_name in ["ref_shapes", "ref_ray_length",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_props[prop_name] = clothing_obj[prop_name]
print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing")
# Also store metadata tags for export pipeline
clothing_meta = {}
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags",
"age", "sex", "slot",
"has_own_armature", "own_armature_name"]:
if prop_name in clothing_obj:
clothing_meta[prop_name] = clothing_obj[prop_name]
print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing")
# Step A: Raycast & adjust vertices
out_ray_length = 0.015
if "ref_ray_length" in clothing_obj:
@@ -174,18 +184,31 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
whitelist.add(master_arm)
# Handle clothing armature (if it's not a copy for layer 2)
has_own_arm = clothing_obj.get("has_own_armature", False)
if not is_clothing_copy and clothing_obj.parent and clothing_obj.parent.type == 'ARMATURE':
old_arm = clothing_obj.parent
clothing_obj.matrix_world = clothing_obj.matrix_world.copy()
clothing_obj.parent = None
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
# Keep hair armatures (objects with own skeleton)
if not has_own_arm:
if old_arm not in whitelist:
bpy.data.objects.remove(old_arm, do_unlink=True)
else:
whitelist.add(old_arm) # preserve hair skeleton
elif not is_clothing_copy and has_own_arm:
# Hair has own skeleton but parent is None (or not an armature);
# find the armature by name and preserve it.
own_arm_name = clothing_obj.get("own_armature_name", "")
old_arm = bpy.data.objects.get(own_arm_name)
if old_arm and old_arm.type == 'ARMATURE' and old_arm not in whitelist:
whitelist.add(old_arm)
print(f" Preserved hair armature '{old_arm.name}' by name for {clothing_name_for_final}")
# For layer 2 clothing copies, we don't need to handle armature separately
# as they'll inherit from the target
# Reparent to master armature if exists
if master_arm:
if master_arm and not clothing_obj.get("has_own_armature", False):
clothing_obj.parent = master_arm
for mod in clothing_obj.modifiers:
if mod.type == 'ARMATURE':
@@ -196,14 +219,59 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
clothing_obj.select_set(True)
new_target.select_set(True)
bpy.context.view_layer.objects.active = new_target
# Remember clothing's material BEFORE join so we can verify
# after Blender potentially remaps faces to a stale copy.
clothing_mat = clothing_obj.active_material
bpy.ops.object.join()
# NEW CODE: Copy stored custom properties to combined object
# After joining, new_target is the active object and contains the combined mesh
# ---- Fix: ensure clothing faces keep the clothing's material ----
if clothing_mat:
clothing_slot = None
stale_slot = None
for i, slot in enumerate(new_target.material_slots):
if slot.material == clothing_mat:
clothing_slot = i
elif (slot.material and
slot.material != clothing_mat and
slot.material.name == clothing_mat.name):
# Same name, different data block -> stale
stale_slot = i
if clothing_slot is not None and stale_slot is not None:
mesh = new_target.data
fixed = 0
for poly in mesh.polygons:
if poly.material_index == stale_slot:
poly.material_index = clothing_slot
fixed += 1
if fixed:
print(f" Fixed {fixed} faces: stale mat slot "
f"{stale_slot} -> clothing slot {clothing_slot}")
# ----------------------------------------------------------------
# Copy stored custom properties to combined object
for prop_name, prop_value in clothing_props.items():
new_target[prop_name] = prop_value
print(f" Copied property '{prop_name}' = {prop_value} to combined object")
# Aggregate clothing metadata onto combined object
if "_combined_meta" in new_target:
meta = json.loads(new_target["_combined_meta"])
else:
meta = {"garments": [], "tags": [], "layers": []}
if "garment_id" in clothing_meta:
meta["garments"].append(clothing_meta["garment_id"])
else:
meta["garments"].append(clothing_name_for_final)
if "tags" in clothing_meta and clothing_meta["tags"]:
for t in str(clothing_meta["tags"]).split(";"):
t = t.strip()
if t and t not in meta["tags"]:
meta["tags"].append(t)
if "ref_layer" in clothing_meta:
meta["layers"].append(int(clothing_meta["ref_layer"]))
new_target["_combined_meta"] = json.dumps(meta)
# Rename the combined object using the appropriate clothing name
new_target.name = f"{target_name}_{clothing_name_for_final}"
whitelist.add(new_target)
@@ -237,26 +305,70 @@ def run_batch_combine():
all_objs = bpy.data.objects
# Separate body objects (no ref_layer property)
body_objects = [o for o in all_objs if o.type == 'MESH' and "ref_layer" not in o]
body_objects = []
skipped_objects = []
for o in all_objs:
if o.type != 'MESH' or "ref_layer" in o:
continue
if o.name.startswith("cs_") or o.name.startswith("WGT-") or o.name.startswith("MCH-") or o.name.startswith("ORG-"):
skipped_objects.append(o.name)
continue
if len(o.data.vertices) == 0:
skipped_objects.append(o.name)
continue
body_objects.append(o)
# Separate clothing by layer
clothing_layer1 = [o for o in all_objs if o.type == 'MESH' and "ref_layer" in o and o["ref_layer"] == 1]
clothing_layer2 = [o for o in all_objs if o.type == 'MESH' and "ref_layer" in o and o["ref_layer"] == 2]
if skipped_objects:
print(f"Skipped {len(skipped_objects)} non-body mesh objects: {', '.join(skipped_objects[:10])}{'...' if len(skipped_objects) > 10 else ''}")
required_body_props = {"age", "sex", "slot"}
valid_body_objects = []
skipped_body_objects = []
for body_obj in body_objects:
missing = [p for p in required_body_props if p not in body_obj]
if missing:
print(f"WARNING: Mesh object '{body_obj.name}' is missing required body properties {missing}.")
print(f" Treating as helper/reference object (not a body part).")
print(f" Available properties: {[k for k in body_obj.keys() if not k.startswith('_')]}")
skipped_body_objects.append(body_obj.name)
else:
valid_body_objects.append(body_obj)
if skipped_body_objects:
print(f"Skipped {len(skipped_body_objects)} mesh objects treated as helpers: {', '.join(skipped_body_objects)}")
body_objects = valid_body_objects
required_clothing_props = {"ref_layer", "ref_part", "garment_id"}
clothing_layer1 = []
clothing_layer2 = []
skipped_clothing_objects = []
for o in all_objs:
if o.type != 'MESH' or "ref_layer" not in o:
continue
missing = [p for p in required_clothing_props if p not in o]
if missing:
print(f"WARNING: Mesh object '{o.name}' has ref_layer but is missing required clothing properties {missing}.")
print(f" Treating as helper/reference object (not clothing).")
skipped_clothing_objects.append(o.name)
continue
if o["ref_layer"] == 1:
clothing_layer1.append(o)
elif o["ref_layer"] == 2:
clothing_layer2.append(o)
if skipped_clothing_objects:
print(f"Skipped {len(skipped_clothing_objects)} mesh objects treated as helpers: {', '.join(skipped_clothing_objects)}")
print(f"Found {len(body_objects)} body objects")
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
# Create dictionary of body objects for quick lookup
body_objects_dict = {obj.name: obj for obj in body_objects}
# Track objects to keep
whitelist = set()
# List to store combined objects from layer 1
combined_objects = []
# PROCESS LAYER 1: Combine with original body parts
# PROCESS LAYER 1
print("\n=== PROCESSING LAYER 1 CLOTHING ===")
for clothing_obj in clothing_layer1:
if "ref_part" not in clothing_obj:
@@ -270,7 +382,6 @@ def run_batch_combine():
print(f"Warning: Target body '{target_name}' not found for clothing '{clothing_obj.name}', skipping")
continue
# Process the pair
result = process_clothing_pair(clothing_obj, original_body, whitelist)
if result:
combined_objects.append(result)
@@ -284,27 +395,20 @@ def run_batch_combine():
for clothing_obj in clothing_layer2:
print(f"\nProcessing layer 2 clothing: {clothing_obj.name}")
# Store the original clothing name for later use
original_clothing_name = clothing_obj.name
# For each combined object from layer 1
for combined_obj in combined_objects:
# Create a copy of the layer 2 clothing
clothing_copy = clothing_obj.copy()
clothing_copy.data = clothing_obj.data.copy()
bpy.context.collection.objects.link(clothing_copy)
# Copy custom properties
for key in clothing_obj.keys():
clothing_copy[key] = clothing_obj[key]
# Set the ref_part to point to the combined object
clothing_copy["ref_part"] = combined_obj.name
print(f" Combining with layer 1 result: {combined_obj.name}")
# Process the pair
result = process_clothing_pair(clothing_copy, combined_obj, whitelist,
is_clothing_copy=True,
original_clothing_name=original_clothing_name)
@@ -314,7 +418,7 @@ def run_batch_combine():
print(f"Layer 2 first pass complete. Created {len(layer2_results_over_l1)} combined objects")
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts (like layer 1)
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts
print("\n=== PROCESSING LAYER 2 CLOTHING (Second Pass - Direct to Body) ===")
layer2_results_direct = []
@@ -332,7 +436,6 @@ def run_batch_combine():
print(f"\nProcessing layer 2 clothing directly with body: {clothing_obj.name} -> {target_name}")
# Process directly with body part (no copy needed for the clothing itself)
result = process_clothing_pair(clothing_obj, original_body, whitelist, is_clothing_copy=False)
if result:
layer2_results_direct.append(result)
@@ -340,7 +443,6 @@ def run_batch_combine():
print(f"Layer 2 second pass complete. Created {len(layer2_results_direct)} combined objects")
# Add all results to combined objects list
all_results = combined_objects + layer2_results_over_l1 + layer2_results_direct
print(f"\n=== SUMMARY ===")
@@ -349,17 +451,38 @@ def run_batch_combine():
print(f"Layer 2 direct to body results: {len(layer2_results_direct)}")
print(f"Total combined objects: {len(all_results)}")
# Final cleanup - keep all combined objects and their armatures
for obj in all_results:
whitelist.add(obj)
if "_combined_meta" in obj:
meta = json.loads(obj["_combined_meta"])
layers = meta.get("layers", [])
garments = meta.get("garments", [])
tags = meta.get("tags", [])
if layers:
obj["layer"] = max(layers)
else:
obj["layer"] = 0
if garments:
obj["garments"] = ";".join(garments)
else:
obj["garments"] = ""
if tags:
obj["clothing_tags"] = ";".join(sorted(set(tags)))
else:
obj["clothing_tags"] = ""
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
del obj["_combined_meta"]
cleanup_unused_objects(whitelist)
# Save the result
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"\nSaved to: {output_path}")
if __name__ == "__main__":
run_batch_combine()
+88 -16
View File
@@ -2,61 +2,134 @@ import bpy
import sys
import os
# Import seam/normal fix functions from transfer_shape_keys.py
_script_dir = os.path.dirname(os.path.abspath(__file__))
if _script_dir not in sys.path:
sys.path.insert(0, _script_dir)
from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_objects
def process_append(source_files, output_path):
required_props = {"age", "sex", "slot"}
for file_path in source_files:
if not os.path.exists(file_path):
print(f"Warning: Source file not found: {file_path}")
continue
with bpy.data.libraries.load(file_path) as (data_from, data_to):
# ---- Pre-import materials and armatures from source blend ----
with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to):
data_to.materials = data_from.materials
data_to.objects = data_from.objects
imported_materials = {}
for mat in data_to.materials:
if mat is None:
continue
existing = bpy.data.materials.get(mat.name)
if existing and existing != mat:
print(f" Material '{mat.name}' already exists with "
f"different data; incoming material will "
f"replace it on appended objects.")
imported_materials[mat.name] = mat
# Also track imported armatures
imported_armatures = {}
for obj in data_to.objects:
if obj is None:
continue
if obj.type == 'ARMATURE':
imported_armatures[obj.name] = obj
# ----------------------------------------------------
for obj in data_to.objects:
if obj is None: continue
# Check criteria
# Keep armatures from the source blend
if obj.type == 'ARMATURE':
bpy.context.collection.objects.link(obj)
print(f"Imported armature '{obj.name}' from source blend")
continue
# Check criteria for body part meshes
has_props = all(p in obj.keys() for p in required_props)
if obj.type == 'MESH' and has_props:
# 1. Link to the scene root temporarily
bpy.context.collection.objects.link(obj)
# ---- Remap material slots to imported versions ----
for slot in obj.material_slots:
if slot.material and slot.material.name in imported_materials:
imported = imported_materials[slot.material.name]
if imported != slot.material:
print(f" Remapping slot "
f"'{slot.material.name}' -> "
f"'{imported.name}' on '{obj.name}'")
slot.material = imported
# ---------------------------------------------------
# 2. Synchronize Names
obj.data.name = obj.name
# 3. Find Target Armature
# 3. Armature handling
has_own_arm = obj.get("has_own_armature", False)
if has_own_arm:
own_arm_name = obj.get("own_armature_name", "")
own_arm = bpy.data.objects.get(own_arm_name)
if not own_arm and own_arm_name in imported_armatures:
own_arm = imported_armatures[own_arm_name]
if own_arm:
# Keep hair's own armature; don't reparent
# to body armature.
# Fix the Armature modifier to point to the
# hair armature (combine_clothes may have
# changed it to the body rig).
arm_mod = next((m for m in obj.modifiers
if m.type == 'ARMATURE'), None)
if arm_mod:
arm_mod.object = own_arm
obj.parent = own_arm
print(f"Processed {obj.name}: keeping own "
f"armature '{own_arm_name}'")
continue
# Normal body part: parent to body armature
arm_name = obj.get("sex")
arm_obj = bpy.data.objects.get(arm_name)
if arm_obj and arm_obj.type == 'ARMATURE':
# A. Handle Collections: Move mesh to armature's collections
# Remove from all current collections first
# A. Handle Collections
for col in obj.users_collection:
col.objects.unlink(obj)
# Link to every collection the armature belongs to
for col in arm_obj.users_collection:
col.objects.link(obj)
# B. Parent to Armature
obj.parent = arm_obj
# C. Handle Armature Modifier
arm_mod = next((m for m in obj.modifiers if m.type == 'ARMATURE'), None)
if not arm_mod:
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = arm_obj
print(f"Processed {obj.name}: Parented and Modset to {arm_name}")
else:
print(f"Warning: Armature '{arm_name}' not found for {obj.name}")
elif obj.type == 'MESH':
missing = [p for p in required_props if p not in obj.keys()]
print(f"WARNING: Mesh object '{obj.name}' is missing required properties {missing}.")
print(f" Treating as helper/reference object (not a body part).")
print(f" Available properties: {[k for k in obj.keys() if not k.startswith('_')]}")
else:
# Clean up data not meeting criteria
bpy.data.objects.remove(obj, do_unlink=True)
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
# 5. Fix seams across ALL body parts in the consolidated scene.
print("\nFixing seams across all consolidated body parts...")
fix_seams_across_objects()
fix_normals_across_objects()
print("Consolidated seam/normal fix complete.\n")
# Save
bpy.ops.wm.save_as_mainfile(filepath=output_path)
@@ -68,4 +141,3 @@ if __name__ == "__main__":
process_append(sources, output)
except ValueError:
print("Error: Use '--' to separate Blender args from script args.")
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,501 @@
#!/usr/bin/env python3
"""
Garment System UI Panel for Blender
Run this script in Blender's Scripting editor (or via blender -b -P garment_system_ui.py)
to add a side-panel for editing garment-system custom properties on mesh objects.
Properties managed:
Body Part -> age, sex, slot, layer, garments, clothing_tags
Clothing -> ref_layer, ref_part, garment_id, ref_sex, ref_age, ref_clothing, tags
"""
import bpy
# ---------------------------------------------------------------------------
# Property helpers
# ---------------------------------------------------------------------------
SLOT_ITEMS = [
("face", "Face", ""),
("bottom", "Bottom", ""),
("top", "Top", ""),
("feet", "Feet", ""),
("hair", "Hair", ""),
]
SEX_ITEMS = [
("male", "Male", ""),
("female", "Female", ""),
]
AGE_ITEMS = [
("adult", "Adult", ""),
("child", "Child", ""),
("teen", "Teen", ""),
("baby", "Baby", ""),
]
def _ensure_prop(obj, key, default):
if key not in obj:
obj[key] = default
return obj[key]
def _get_prop_str(obj, key, default=""):
if key not in obj:
return default
return str(obj[key])
def _get_prop_int(obj, key, default=0):
if key not in obj:
return default
try:
return int(obj[key])
except (ValueError, TypeError):
return default
def _set_prop_str(obj, key, value):
if value:
obj[key] = value
elif key in obj:
del obj[key]
def _set_prop_int(obj, key, value):
obj[key] = int(value)
def _del_prop(obj, key):
if key in obj:
del obj[key]
# ---------------------------------------------------------------------------
# Operators
# ---------------------------------------------------------------------------
class GARMENT_OT_apply_body_part(bpy.types.Operator):
bl_idname = "garment.apply_body_part"
bl_label = "Apply Body Part"
bl_description = "Write body-part custom properties to the active object"
bl_options = {"REGISTER", "UNDO"}
age: bpy.props.EnumProperty(name="Age", items=AGE_ITEMS)
sex: bpy.props.EnumProperty(name="Sex", items=SEX_ITEMS)
slot: bpy.props.EnumProperty(name="Slot", items=SLOT_ITEMS)
layer: bpy.props.IntProperty(name="Layer", min=0, max=2, default=0)
garments: bpy.props.StringProperty(name="Garments", description="Semicolon-separated garment names")
clothing_tags: bpy.props.StringProperty(name="Clothing Tags", description="Semicolon-separated tags")
def execute(self, context):
obj = context.active_object
if not obj or obj.type != "MESH":
self.report({"WARNING"}, "Active object is not a mesh")
return {"CANCELLED"}
_set_prop_str(obj, "age", self.age)
_set_prop_str(obj, "sex", self.sex)
_set_prop_str(obj, "slot", self.slot)
_set_prop_int(obj, "layer", self.layer)
_set_prop_str(obj, "garments", self.garments)
_set_prop_str(obj, "clothing_tags", self.clothing_tags)
# Remove clothing-only props so they don't confuse the pipeline
for k in ("ref_layer", "ref_part", "garment_id", "ref_sex",
"ref_age", "ref_clothing", "tags"):
_del_prop(obj, k)
self.report({"INFO"}, f"Body-part props applied to {obj.name}")
return {"FINISHED"}
def invoke(self, context, event):
obj = context.active_object
if obj:
self.age = _get_prop_str(obj, "age", "adult")
self.sex = _get_prop_str(obj, "sex", "male")
self.slot = _get_prop_str(obj, "slot", "bottom")
self.layer = _get_prop_int(obj, "layer", 0)
self.garments = _get_prop_str(obj, "garments", "")
self.clothing_tags = _get_prop_str(obj, "clothing_tags", "")
return context.window_manager.invoke_props_dialog(self)
class GARMENT_OT_apply_clothing(bpy.types.Operator):
bl_idname = "garment.apply_clothing"
bl_label = "Apply Clothing"
bl_description = "Write clothing custom properties to the active object"
bl_options = {"REGISTER", "UNDO"}
ref_layer: bpy.props.IntProperty(name="Ref Layer", min=1, max=2, default=1,
description="1 = lingerie, 2 = clothing")
ref_part: bpy.props.StringProperty(name="Ref Part",
description="Target body-part object name, e.g. BodyBottom")
garment_id: bpy.props.StringProperty(name="Garment ID",
description="Garment name used for labels, e.g. panties7")
ref_sex: bpy.props.EnumProperty(name="Ref Sex", items=SEX_ITEMS)
ref_age: bpy.props.EnumProperty(name="Ref Age", items=AGE_ITEMS)
ref_clothing: bpy.props.StringProperty(name="Ref Clothing",
description="Mesh name used for weight transfer, e.g. BodyBottom")
tags: bpy.props.StringProperty(name="Tags", description="Semicolon-separated tags")
def execute(self, context):
obj = context.active_object
if not obj or obj.type != "MESH":
self.report({"WARNING"}, "Active object is not a mesh")
return {"CANCELLED"}
_set_prop_int(obj, "ref_layer", self.ref_layer)
_set_prop_str(obj, "ref_part", self.ref_part)
_set_prop_str(obj, "garment_id", self.garment_id)
_set_prop_str(obj, "ref_sex", self.ref_sex)
_set_prop_str(obj, "ref_age", self.ref_age)
_set_prop_str(obj, "ref_clothing", self.ref_clothing)
_set_prop_str(obj, "tags", self.tags)
# Remove body-part-only props so they don't confuse the pipeline
for k in ("age", "sex", "slot", "layer", "garments", "clothing_tags"):
_del_prop(obj, k)
self.report({"INFO"}, f"Clothing props applied to {obj.name}")
return {"FINISHED"}
def invoke(self, context, event):
obj = context.active_object
if obj:
self.ref_layer = _get_prop_int(obj, "ref_layer", 1)
self.ref_part = _get_prop_str(obj, "ref_part", "")
self.garment_id = _get_prop_str(obj, "garment_id", "")
self.ref_sex = _get_prop_str(obj, "ref_sex", "male")
self.ref_age = _get_prop_str(obj, "ref_age", "adult")
self.ref_clothing = _get_prop_str(obj, "ref_clothing", "")
self.tags = _get_prop_str(obj, "tags", "")
return context.window_manager.invoke_props_dialog(self)
class GARMENT_OT_auto_detect(bpy.types.Operator):
bl_idname = "garment.auto_detect"
bl_label = "Auto-detect from Name"
bl_description = "Guess sex/slot from object name"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj or obj.type != "MESH":
self.report({"WARNING"}, "Active object is not a mesh")
return {"CANCELLED"}
name = obj.name.lower()
changed = []
# Guess sex
if "female" in name:
obj["sex"] = "female"
changed.append("sex=female")
elif "male" in name:
obj["sex"] = "male"
changed.append("sex=male")
# Guess slot
slot_map = {
"face": "face",
"bottom": "bottom",
"top": "top",
"feet": "feet",
"hair": "hair",
}
for key, slot in slot_map.items():
if key in name:
obj["slot"] = slot
changed.append(f"slot={slot}")
break
# Guess layer from name hints
if any(x in name for x in ("panties", "lingerie", "underwear")):
obj["layer"] = 1
changed.append("layer=1")
elif any(x in name for x in ("pants", "skirt", "robe", "shirt", "dress", "cloth")):
obj["layer"] = 2
changed.append("layer=2")
else:
obj["layer"] = 0
changed.append("layer=0")
# Set garment_id from object name for clothing
if "ref_layer" in obj or "ref_part" in obj:
if "garment_id" not in obj or not obj["garment_id"]:
obj["garment_id"] = obj.name
changed.append(f"garment_id={obj.name}")
self.report({"INFO"}, "Auto-detect: " + ", ".join(changed) if changed else "nothing changed")
return {"FINISHED"}
class GARMENT_OT_clear_props(bpy.types.Operator):
bl_idname = "garment.clear_props"
bl_label = "Clear All Garment Props"
bl_description = "Remove all garment-system custom properties from the active object"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
keys = list(obj.keys())
removed = []
for k in keys:
if k in ("age", "sex", "slot", "layer", "garments", "clothing_tags",
"ref_layer", "ref_part", "garment_id", "ref_sex", "ref_age",
"ref_clothing", "tags"):
del obj[k]
removed.append(k)
self.report({"INFO"}, f"Removed {len(removed)} props" if removed else "No garment props found")
return {"FINISHED"}
class GARMENT_OT_sync_tags(bpy.types.Operator):
bl_idname = "garment.sync_tags"
bl_label = "Sync Tags from Garments"
bl_description = "Copy clothing_tags from garments field"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
obj = context.active_object
if not obj:
return {"CANCELLED"}
garments = _get_prop_str(obj, "garments", "")
obj["clothing_tags"] = garments
self.report({"INFO"}, f"Tags set to: {garments}")
return {"FINISHED"}
class GARMENT_OT_check_scene(bpy.types.Operator):
bl_idname = "garment.check_scene"
bl_label = "Check Scene"
bl_description = "Validate all garment-system objects for pipeline readiness"
bl_options = {"REGISTER"}
def execute(self, context):
errors = []
warnings = []
body_count = 0
cloth_count = 0
ok_count = 0
required_body_props = {"age", "sex", "slot"}
required_clothing_props = {"ref_layer", "ref_part", "garment_id"}
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
has_body_props = all(p in obj for p in required_body_props)
has_clothing_props = all(p in obj for p in required_clothing_props)
if has_body_props and has_clothing_props:
errors.append(f"'{obj.name}': has BOTH body-part and clothing properties (mixed)")
continue
if has_body_props:
body_count += 1
# Check body part values are valid
age = str(obj.get("age", ""))
sex = str(obj.get("sex", ""))
slot = str(obj.get("slot", ""))
valid_ages = {"adult", "child", "teen", "baby"}
valid_sexes = {"male", "female"}
valid_slots = {"face", "bottom", "top", "feet", "hair"}
if age not in valid_ages:
errors.append(f"'{obj.name}': invalid age='{age}' (valid: {', '.join(sorted(valid_ages))})")
if sex not in valid_sexes:
errors.append(f"'{obj.name}': invalid sex='{sex}' (valid: {', '.join(sorted(valid_sexes))})")
if slot not in valid_slots:
errors.append(f"'{obj.name}': invalid slot='{slot}' (valid: {', '.join(sorted(valid_slots))})")
ok_count += 1
elif has_clothing_props:
cloth_count += 1
# Check clothing values are valid
ref_sex = str(obj.get("ref_sex", ""))
ref_age = str(obj.get("ref_age", ""))
valid_ages = {"adult", "child", "teen", "baby"}
valid_sexes = {"male", "female"}
if ref_sex and ref_sex not in valid_sexes:
errors.append(f"'{obj.name}': invalid ref_sex='{ref_sex}' (valid: {', '.join(sorted(valid_sexes))})")
if ref_age and ref_age not in valid_ages:
errors.append(f"'{obj.name}': invalid ref_age='{ref_age}' (valid: {', '.join(sorted(valid_ages))})")
# Check ref_part points to an existing body object
ref_part = str(obj.get("ref_part", ""))
if ref_part and ref_part not in bpy.data.objects:
warnings.append(f"'{obj.name}': ref_part='{ref_part}' not found in scene")
ok_count += 1
else:
# Mesh object with no garment props - check if it should have them
if obj.name.startswith("Body") or any(x in obj.name.lower() for x in ("shoes", "pants", "shirt", "skirt", "dress", "hat", "hair", "top", "bottom")):
warnings.append(f"'{obj.name}': mesh object with no garment properties (may need age/sex/slot or ref_layer/ref_part/garment_id)")
# Report results
self.report({"INFO"}, f"Check complete: {body_count} body parts, {cloth_count} clothing, {len(errors)} errors, {len(warnings)} warnings")
# Print detailed results to console
print("\n" + "=" * 60)
print("GARMENT SYSTEM SCENE CHECK")
print("=" * 60)
print(f"Body parts: {body_count}")
print(f"Clothing: {cloth_count}")
print(f"OK objects: {ok_count}")
print(f"Errors: {len(errors)}")
print(f"Warnings: {len(warnings)}")
print("-" * 60)
if errors:
print("\nERRORS (must fix before pipeline run):")
for e in errors:
print(f" [ERROR] {e}")
if warnings:
print("\nWARNINGS (review recommended):")
for w in warnings:
print(f" [WARN] {w}")
if not errors and not warnings:
print("\n ✓ Scene looks good! Pipeline should run successfully.")
print("=" * 60)
return {"FINISHED"}
# ---------------------------------------------------------------------------
# Panel
# ---------------------------------------------------------------------------
class GARMENT_PT_panel(bpy.types.Panel):
bl_label = "Garment System"
bl_idname = "GARMENT_PT_panel"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Garment"
def draw(self, context):
layout = self.layout
obj = context.active_object
if not obj:
layout.label(text="No active object")
return
layout.label(text=f"Object: {obj.name}", icon="OBJECT_DATA")
# Detect current type
is_body = any(k in obj for k in ("age", "sex", "slot"))
is_cloth = any(k in obj for k in ("ref_layer", "ref_part", "garment_id"))
if is_body and is_cloth:
layout.alert = True
layout.label(text="WARNING: mixed props", icon="ERROR")
layout.alert = False
elif is_body:
layout.label(text="Type: Body Part", icon="MESH_DATA")
elif is_cloth:
layout.label(text="Type: Clothing", icon="MOD_CLOTH")
else:
layout.label(text="Type: (unset)", icon="QUESTION")
layout.separator()
# Quick action buttons
row = layout.row(align=True)
row.operator("garment.apply_body_part", icon="MESH_DATA")
row = layout.row(align=True)
row.operator("garment.apply_clothing", icon="MOD_CLOTH")
layout.separator()
row = layout.row(align=True)
row.operator("garment.auto_detect", icon="VIEWZOOM")
row.operator("garment.clear_props", icon="X")
layout.separator()
# Show current body-part props
if is_body or not is_cloth:
box = layout.box()
box.label(text="Body Part Props", icon="MESH_DATA")
col = box.column(align=True)
col.label(text=f"age: {_get_prop_str(obj, 'age', '')}")
col.label(text=f"sex: {_get_prop_str(obj, 'sex', '')}")
col.label(text=f"slot: {_get_prop_str(obj, 'slot', '')}")
col.label(text=f"layer: {_get_prop_int(obj, 'layer', 0)}")
col.label(text=f"garments: {_get_prop_str(obj, 'garments', '')}")
col.label(text=f"clothing_tags: {_get_prop_str(obj, 'clothing_tags', '')}")
if _get_prop_str(obj, "garments", ""):
col.operator("garment.sync_tags", icon="FILE_REFRESH")
# Show current clothing props
if is_cloth:
box = layout.box()
box.label(text="Clothing Props", icon="MOD_CLOTH")
col = box.column(align=True)
col.label(text=f"ref_layer: {_get_prop_int(obj, 'ref_layer', 1)}")
col.label(text=f"ref_part: {_get_prop_str(obj, 'ref_part', '')}")
col.label(text=f"garment_id: {_get_prop_str(obj, 'garment_id', '')}")
col.label(text=f"ref_sex: {_get_prop_str(obj, 'ref_sex', '')}")
col.label(text=f"ref_age: {_get_prop_str(obj, 'ref_age', '')}")
col.label(text=f"ref_clothing: {_get_prop_str(obj, 'ref_clothing', '')}")
col.label(text=f"tags: {_get_prop_str(obj, 'tags', '')}")
layout.separator()
# Check scene button
layout.separator()
row = layout.row(align=True)
row.scale_y = 1.5
row.operator("garment.check_scene", icon="CHECKBOX_HLT")
# Scene overview
box = layout.box()
box.label(text="Scene Overview", icon="SCENE_DATA")
body_count = 0
cloth_count = 0
for o in bpy.data.objects:
if o.type != "MESH":
continue
if any(k in o for k in ("age", "sex", "slot")):
body_count += 1
elif any(k in o for k in ("ref_layer", "ref_part", "garment_id")):
cloth_count += 1
col = box.column(align=True)
col.label(text=f"Body parts: {body_count}")
col.label(text=f"Clothing: {cloth_count}")
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
classes = [
GARMENT_OT_apply_body_part,
GARMENT_OT_apply_clothing,
GARMENT_OT_auto_detect,
GARMENT_OT_clear_props,
GARMENT_OT_sync_tags,
GARMENT_OT_check_scene,
GARMENT_PT_panel,
]
def register():
for cls in classes:
bpy.utils.register_class(cls)
def unregister():
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()
+84 -20
View File
@@ -72,7 +72,7 @@ def process_batch():
# 3. Locate Reference Library
target_lib_name = f"normal_{age}_{sex}.blend"
target_lib_path = os.path.join(lib_directory, target_lib_name)
rig_name = str(sex)
rig_name = str(sex)
if not os.path.exists(target_lib_path):
if target_lib_name == "normal_adult_male.blend":
@@ -94,11 +94,48 @@ def process_batch():
source_mesh = bpy.data.objects.get(ref_mesh_name)
rig = bpy.data.objects.get(rig_name)
# ---- Detect hair with its own skeleton ----
has_own_arm = False
own_arm_name = None
own_arm = None
print(f" Checking {name} modifiers for own armature:")
for mod in clothing.modifiers:
print(f" type={mod.type} object={mod.object}")
if mod.type == 'ARMATURE':
arm = mod.object
if arm is None:
print(f" mod.object is None, trying by name...")
continue
print(f" arm.name='{arm.name}' rig_name='{rig_name}'")
if arm.name != rig_name:
has_own_arm = True
own_arm_name = arm.name
own_arm = arm
break
if has_own_arm and own_arm:
clothing["has_own_armature"] = True
clothing["own_armature_name"] = own_arm_name
# Ensure hair armature is linked to scene so parent/modifier
# references survive the save.
try:
bpy.context.collection.objects.link(own_arm)
except RuntimeError:
pass # Already linked
print(f" DETECTED own armature '{own_arm_name}' on {name}")
else:
print(f" No own armature on {name} (rig='{rig_name}')")
# -------------------------------------------
# 5. Prep Objects (Apply Scale & Clear Animation)
bpy.context.view_layer.objects.active = clothing
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
for item in [clothing, rig]:
# Don't clear animation on the hair armature if it has
# its own skeleton (we want to preserve hair animations)
items_to_clear = [clothing]
if not has_own_arm:
items_to_clear.append(rig)
for item in items_to_clear:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
@@ -106,26 +143,54 @@ def process_batch():
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
# 6. Weight Transfer
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
# Also clear the body rig's animation (always)
if has_own_arm:
for item in [rig]:
if item.animation_data: item.animation_data_clear()
if item.type == 'ARMATURE':
bpy.context.view_layer.objects.active = item
bpy.ops.object.mode_set(mode='POSE')
bpy.ops.pose.transforms_clear()
bpy.ops.object.mode_set(mode='OBJECT')
remove_empty_vertex_groups(clothing, threshold=0.001)
# Set garment_id from object name if not already set
if "garment_id" not in clothing:
clothing["garment_id"] = clothing.name
# 6. Weight Transfer
if has_own_arm:
# Hair with its own skeleton already has correct vertex
# groups for its hair bones; body weight transfer would
# destroy them.
print(f"Skipped weight transfer for {name}: has own skeleton")
else:
clothing.vertex_groups.clear()
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
dt.object = source_mesh
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.layers_vgroup_select_src = 'ALL'
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.context.view_layer.objects.active = clothing
# "Generate Data Layers" step
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
remove_empty_vertex_groups(clothing, threshold=0.001)
# 7. Final Parenting
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
if has_own_arm:
# Hair with its own skeleton: keep the existing Armature
# modifier (which points to the hair skeleton). Just
# parent to the hair armature; do NOT add a body
# Armature modifier.
clothing.parent = own_arm
print(f"Parented {name} to hair armature '{own_arm_name}'")
else:
clothing.parent = rig
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = rig
# 8. Cleanup Reference Mesh (keep the Rig!)
bpy.data.objects.remove(source_mesh, do_unlink=True)
@@ -143,4 +208,3 @@ def process_batch():
if __name__ == "__main__":
process_batch()
@@ -0,0 +1,91 @@
import bpy
import sys
import os
def propagate_shape_keys(blend_path, output_path):
"""Copy missing shape keys from Body_shapes to all body part objects."""
print(f"Propagating shape keys from {blend_path} to {output_path}")
bpy.ops.wm.open_mainfile(filepath=blend_path)
# Find source object with all shape keys
source_obj = None
for name in ['Body_shapes', 'Body']:
if name in bpy.data.objects and bpy.data.objects[name].type == 'MESH':
obj = bpy.data.objects[name]
if obj.data.shape_keys and len(obj.data.shape_keys.key_blocks) > 1:
source_obj = obj
print(f"Found source object: {name}")
break
if not source_obj:
print("ERROR: No source object with shape keys found")
return False
source_keys = source_obj.data.shape_keys.key_blocks
source_basis = source_keys['Basis'].data
modified = False
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
# Only process body part objects
if not all(p in obj for p in ['age', 'sex', 'slot']):
continue
# Skip the source object itself
if obj == source_obj:
continue
if not obj.data.shape_keys:
# Create basis if missing
obj.shape_key_add(name='Basis', from_mix=False)
existing_names = {kb.name for kb in obj.data.shape_keys.key_blocks}
for sk in source_keys:
if sk.name == 'Basis':
continue
if sk.name in existing_names:
print(f" {obj.name}: already has '{sk.name}', skipping")
continue
# Copy shape key
print(f" {obj.name}: adding '{sk.name}' from {source_obj.name}")
new_sk = obj.shape_key_add(name=sk.name, from_mix=False)
# Transfer vertex positions relative to basis
# We need to map source vertices to target vertices
# For now, assume same topology (body parts were separated from same mesh)
num_verts = min(len(new_sk.data), len(sk.data), len(source_basis))
for i in range(num_verts):
# The shape key position in source
src_pos = sk.data[i].co
src_basis_pos = source_basis[i].co
# Offset from basis
offset = src_pos - src_basis_pos
# Apply to target using target's basis position
new_sk.data[i].co = obj.data.shape_keys.key_blocks['Basis'].data[i].co + offset
new_sk.data.update()
modified = True
if modified:
obj.data.update_tag()
bpy.context.view_layer.update()
bpy.ops.wm.save_as_mainfile(filepath=output_path)
print(f"Saved: {output_path}")
return True
if __name__ == "__main__":
try:
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) >= 2:
propagate_shape_keys(args[0], args[1])
else:
print("Usage: blender -b -P script.py -- <input.blend> <output.blend>")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
+283 -23
View File
@@ -137,9 +137,10 @@ def load_target_file(target_path):
# Store information about target objects without keeping references
target_objects_info = []
required_props = {'age', 'sex', 'slot'}
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data:
if all(prop in obj for prop in ['age', 'sex', 'slot']):
if all(prop in obj for prop in required_props):
obj_info = {
'name': obj.name,
'age': obj['age'],
@@ -157,6 +158,24 @@ def load_target_file(target_path):
if obj_info['ref_shapes']:
print(f" - ref_shapes: '{obj_info['ref_shapes']}'")
if not target_objects_info:
print("\n" + "=" * 70)
print("WARNING: No target objects found with required properties (age, sex, slot)")
print("=" * 70)
print("\nThis likely means the combine_clothes.py step did not properly")
print("propagate age/sex/slot properties from body objects to combined objects,")
print("or the source blend file only contains helper/reference objects.")
print("\nAvailable mesh objects and their properties:")
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data:
props = [k for k in obj.keys() if not k.startswith('_')]
has_req = all(p in obj for p in required_props)
marker = " [OK]" if has_req else " [MISSING age/sex/slot]"
print(f" - {obj.name}: {props}{marker}")
print("\nReturning empty target list. The pipeline will skip shape key transfer.")
print("=" * 70)
return target_objects_info, temp_target
return target_objects_info, temp_target
def get_target_object_by_name(obj_name):
@@ -166,22 +185,45 @@ def get_target_object_by_name(obj_name):
return None
def delete_existing_shape_keys(target_obj):
"""Delete all existing shape keys from target object"""
"""Delete all existing shape keys from target object.
CRITICAL: Blender's shape_key_remove() leaves mesh vertices in their
*deformed* state rather than restoring basis positions. We must save
the Basis positions before deletion and restore them afterwards."""
if not target_obj.data.shape_keys:
return False
# Save Basis positions before deletion
basis_positions = None
for kb in target_obj.data.shape_keys.key_blocks:
if kb.name == 'Basis':
basis_positions = [v.co.copy() for v in kb.data]
break
# Fallback: if no Basis exists, use current vertex positions
if basis_positions is None:
basis_positions = [v.co.copy() for v in target_obj.data.vertices]
num_keys = len(target_obj.data.shape_keys.key_blocks)
print(f" Deleting {num_keys} existing shape keys...")
bpy.context.view_layer.objects.active = target_obj
target_obj.select_set(True)
bpy.ops.object.mode_set(mode='OBJECT')
while target_obj.data.shape_keys:
target_obj.active_shape_key_index = 0
bpy.ops.object.shape_key_remove()
target_obj.select_set(False)
# RESTORE BASIS POSITIONS - Blender leaves mesh deformed after removal
for i, v in enumerate(target_obj.data.vertices):
if i < len(basis_positions):
v.co = basis_positions[i]
target_obj.data.update_tag()
bpy.context.view_layer.update()
print(f" Restored basis positions for {len(basis_positions)} vertices")
return True
def ensure_shape_keys_structure(shape_key_names, target_obj):
@@ -224,12 +266,16 @@ def compute_signed_distance_and_direction(bvh, point, reference_normal=None):
if location is None:
return None, None, None, None
# Determine sign using reference normal
# Determine sign using reference normal.
# For points outside (in the direction of the normal), signed_distance
# must be positive. For points inside, negative.
if reference_normal is not None:
to_surface = location - point
if to_surface.length > 0:
dot = to_surface.normalized().dot(reference_normal)
signed_distance = distance if dot > 0 else -distance
# Inverted relative to the no-reference-normal branch because
# to_surface here points *toward* the surface, not away.
signed_distance = -distance if dot > 0 else distance
else:
signed_distance = 0
else:
@@ -503,9 +549,9 @@ def enforce_side_constraint(pos, surface_point, reference_normal, target_side, c
# Very small offset to stay near surface
offset = abs((surface_point - pos).length) * 0.2
if target_side > 0:
return proj_point - reference_normal * offset
else:
return proj_point + reference_normal * offset
else:
return proj_point - reference_normal * offset
return pos
# Regular vertices - stronger enforcement
@@ -515,9 +561,9 @@ def enforce_side_constraint(pos, surface_point, reference_normal, target_side, c
offset = abs((surface_point - pos).length) * 0.95
if target_side > 0:
corrected_pos = proj_point - reference_normal * offset
else:
corrected_pos = proj_point + reference_normal * offset
else:
corrected_pos = proj_point - reference_normal * offset
return corrected_pos
@@ -724,15 +770,21 @@ def smooth_penetration_areas(target_obj, sk_name, mapping, source_data, threshol
problem_vertices = set()
bvh_deformed, _ = create_bvh_for_source(source_data, sk_name)
# Shape keys are in relative mode here, so current_positions are offsets.
# The BVH is built from absolute source positions, so we must convert
# relative offsets to absolute positions before querying it.
basis_positions = mapping['target_verts']
for i, pos in enumerate(current_positions):
if i < len(mapping['surface_points']) and i < len(mapping['side_flags']):
surface_point = mapping['surface_points'][i]
target_side = mapping['side_flags'][i]
if target_side != 0:
location, _, _, _ = bvh_deformed.find_nearest(pos)
if target_side != 0 and i < len(basis_positions):
abs_pos = basis_positions[i] + pos
location, _, _, _ = bvh_deformed.find_nearest(abs_pos)
if location and i < len(mapping['direction_vectors']):
to_surface = pos - location
to_surface = abs_pos - location
current_side = 1 if to_surface.dot(mapping['direction_vectors'][i]) > 0 else -1
if current_side != target_side:
@@ -860,7 +912,7 @@ def set_shape_key_with_side_preservation(target_obj, sk_name, mapping, source_da
smooth_penetration_areas(target_obj, sk_name, mapping, source_data)
def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
"""Test shape key quality with side tracking"""
"""Test shape key quality with side tracking (READ-ONLY - does not modify shape key data)"""
sk = target_obj.data.shape_keys.key_blocks[sk_name]
@@ -870,6 +922,9 @@ def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
prev_positions = None
boundary_vertices = mapping.get('boundary_vertices', set())
# Save original shape key data so we can restore it after testing
original_positions = [v.co.copy() for v in sk.data]
for val in test_values:
if val == 0.0:
sk.value = 0.0
@@ -913,6 +968,13 @@ def test_shape_key_quality(target_obj, sk_name, mapping, source_data):
prev_positions = [v.co.copy() for v in target_obj.data.vertices]
# Restore original shape key data (the test should not modify the shape key)
target_obj.data.shape_keys.use_relative = False
for i, v in enumerate(sk.data):
if i < len(original_positions):
v.co = original_positions[i]
target_obj.data.shape_keys.use_relative = True
sk.value = 0.0
target_obj.data.update_tag()
bpy.context.view_layer.update()
@@ -980,16 +1042,197 @@ def process_target_object(target_obj_info, source_data, current_file_path):
mapping = transfer_shape_keys(source_data, target_obj)
verify_transfer(source_data['names'], target_obj)
# Test quality of 'fat' shape key if it exists
for sk_name in source_data['names']:
if sk_name == 'fat':
test_shape_key_quality(target_obj, sk_name, mapping, source_data)
break
print(f" {'=' * 40}")
print(f" ✓ Completed")
return True
def get_body_part_objects():
"""Find all mesh objects that are body parts (have age/sex/slot)"""
result = []
required_props = {'age', 'sex', 'slot'}
for obj in bpy.data.objects:
if obj.type == 'MESH' and obj.data and all(p in obj for p in required_props):
result.append(obj)
return result
def round_pos(co, tolerance=0.0001):
"""Round a coordinate to tolerance-based buckets for spatial hashing"""
return (round(co.x / tolerance),
round(co.y / tolerance),
round(co.z / tolerance))
def fix_seams_across_objects(tolerance=0.0001):
"""
Post-process shape keys to ensure vertices sharing the same basis
position (within tolerance) get identical offsets. This fixes seams
between body parts and UV seams within the same part.
"""
body_parts = get_body_part_objects()
if not body_parts:
print("No body part objects found for seam fixing")
return
print(f"\n{'=' * 60}")
print("SEAM FIX: Synchronizing shape key offsets across body parts")
print(f"{'=' * 60}")
print(f"Found {len(body_parts)} body part objects")
# Collect shape key names from first object that has them
shape_key_names = []
for obj in body_parts:
if obj.data.shape_keys and obj.data.shape_keys.key_blocks:
for kb in obj.data.shape_keys.key_blocks:
if kb.name != 'Basis':
shape_key_names.append(kb.name)
break
if not shape_key_names:
print("No shape keys found, skipping seam fix")
return
print(f"Synchronizing {len(shape_key_names)} shape key(s): {', '.join(shape_key_names)}")
fixed_vertices = 0
fixed_groups = 0
for sk_name in shape_key_names:
# Build spatial hash: position_key -> list of (obj, vert_idx, offset)
pos_map = {}
for obj in body_parts:
if not obj.data.shape_keys or sk_name not in obj.data.shape_keys.key_blocks:
continue
sk = obj.data.shape_keys.key_blocks[sk_name]
for i, v in enumerate(obj.data.vertices):
if i >= len(sk.data):
continue
pos_key = round_pos(v.co, tolerance)
offset = sk.data[i].co.copy()
if pos_key not in pos_map:
pos_map[pos_key] = []
pos_map[pos_key].append((obj, i, offset))
# Average offsets for each position group with multiple vertices
for pos_key, entries in pos_map.items():
if len(entries) < 2:
continue
# Compute average offset
avg_offset = mathutils.Vector((0.0, 0.0, 0.0))
for obj, idx, offset in entries:
avg_offset += offset
avg_offset /= len(entries)
# Apply averaged offset to all vertices in this group
for obj, idx, offset in entries:
sk = obj.data.shape_keys.key_blocks[sk_name]
sk.data[idx].co = avg_offset.copy()
fixed_vertices += 1
fixed_groups += 1
# Update all meshes
for obj in body_parts:
if obj.data.shape_keys:
for kb in obj.data.shape_keys.key_blocks:
kb.data.update()
obj.data.update_tag()
bpy.context.view_layer.update()
print(f"Fixed {fixed_vertices} vertices across {fixed_groups} position groups")
print(f"Seam fix complete")
def fix_normals_across_objects(tolerance=0.0001):
"""
Post-process normals to ensure vertices sharing the same basis
position (within tolerance) get identical normals. This fixes
lighting seams between body parts.
"""
body_parts = get_body_part_objects()
if not body_parts:
print("No body part objects found for normal fixing")
return
print(f"\n{'=' * 60}")
print("NORMAL FIX: Synchronizing normals across body parts")
print(f"{'=' * 60}")
print(f"Found {len(body_parts)} body part objects")
# Build spatial hash: position_key -> list of normals
pos_map = {}
for obj in body_parts:
obj.data.calc_normals()
for v in obj.data.vertices:
pos_key = round_pos(v.co, tolerance)
if pos_key not in pos_map:
pos_map[pos_key] = []
pos_map[pos_key].append(v.normal.copy())
# Compute averaged normals for positions with multiple vertices
avg_normals = {}
for pos_key, normals in pos_map.items():
if len(normals) < 2:
continue
avg = mathutils.Vector((0.0, 0.0, 0.0))
for n in normals:
avg += n
if avg.length > 0.001:
avg.normalize()
avg_normals[pos_key] = avg
if not avg_normals:
print("No matching vertices found, skipping normal fix")
return
print(f"Found {len(avg_normals)} position groups with matching vertices")
# Apply averaged normals to each object
fixed_loops = 0
fixed_verts = 0
for obj in body_parts:
obj.data.use_auto_smooth = True
if not obj.data.has_custom_normals:
obj.data.create_normals_split()
obj.data.calc_normals_split()
vert_normal_map = {}
for i, v in enumerate(obj.data.vertices):
pos_key = round_pos(v.co, tolerance)
if pos_key in avg_normals:
vert_normal_map[i] = avg_normals[pos_key]
if not vert_normal_map:
continue
custom_normals = []
for loop in obj.data.loops:
if loop.vertex_index in vert_normal_map:
n = vert_normal_map[loop.vertex_index]
custom_normals.append((n.x, n.y, n.z))
fixed_loops += 1
else:
if hasattr(obj.data, 'corner_normals'):
cn = obj.data.corner_normals[loop.index]
custom_normals.append((cn.vector.x, cn.vector.y, cn.vector.z))
else:
custom_normals.append((loop.normal.x, loop.normal.y, loop.normal.z))
obj.data.normals_split_custom_set(custom_normals)
fixed_verts += len(vert_normal_map)
obj.data.update_tag()
bpy.context.view_layer.update()
print(f"Fixed {fixed_verts} vertices ({fixed_loops} loops) across {len(body_parts)} objects")
print(f"Normal fix complete")
def main():
print("=" * 60)
print("Blender Shape Key Transfer Script - Boundary Velocity Limiting")
@@ -1006,7 +1249,14 @@ def main():
if not target_objects_info:
print("\nNo target objects found with required properties")
sys.exit(1)
print("Skipping shape key transfer. Saving target file as-is.")
# Save the target file as-is (no shape keys transferred)
bpy.ops.wm.save_as_mainfile(filepath=output_file)
print(f"\nSaved output (unchanged): {output_file}")
print("=" * 60)
print("Script completed (no shape keys transferred)")
print("=" * 60)
return
print(f"\nFound {len(target_objects_info)} target objects to process")
@@ -1041,6 +1291,16 @@ def main():
print(f"\nProgress saved to: {temp_progress_file}")
# Post-process: fix seams by synchronizing offsets across body parts
print(f"\nLoading final working file for seam fix...")
bpy.ops.wm.open_mainfile(filepath=working_file)
fix_seams_across_objects()
fix_normals_across_objects()
# Save after seam fix
print(f"\nSaving after seam fix...")
bpy.ops.wm.save_as_mainfile(filepath=working_file)
# Copy the final working file to the output location
print(f"\nCopying final result to: {output_file}")
shutil.copy2(working_file, output_file)
@@ -354,7 +354,11 @@ class OgreMaterialGenerator(object):
else:
image_filepath = bpy.path.abspath(image.filepath, library=image.library)
image_filepath = os.path.normpath(image_filepath)
if not os.path.isfile(image_filepath):
logger.warning("Skipping texture copy: source path is not a file (%s)", image_filepath)
return
# Should we update the file
update = False
if os.path.isfile(target_filepath):
+406 -19
View File
@@ -186,26 +186,32 @@ def extra_linear(angle, offset):
mapping_blend_path = argv[0]
mapping_gltf_path = argv[1]
mapping_objects = argv[2]
mapping_armature_name = argv[3]
mapping_outfile = argv[4]
# Backward compat: support both 4-arg (blend, gltf, armature, outfile) and 5-arg (blend, gltf, objects, armature, outfile)
if len(argv) >= 5:
mapping_objects = argv[2]
mapping_armature_name = argv[3]
mapping_outfile = argv[4]
else:
mapping_objects = "AUTO"
mapping_armature_name = argv[2]
mapping_outfile = argv[3]
#for mapping in [ExportMappingFemale(), ExportMappingMale(), ExportMappingMaleBabyShape(), ExportMappingMaleEdited(), ExportMappingFemaleEdited(), ExportMappingMaleTestShapeEdited(), ExportMappingMaleBaseShapeEdited()]:
class CommandLineMapping:
blend_path = mapping_blend_path
gltf_path = mapping_gltf_path
# ogre_scene = "characters/female/vroid-normal-female.scene"
inner_path = "Object"
# objs = ["male", "Body", "Hair", "Face", "BackHair", "Tops", "Bottoms", "Shoes", "Accessory"]
# objs = ["female", "Body", "Hair", "Face", "BackHair", "Tops", "Bottoms", "Shoes", "Accessory"]
objs = []
armature_name = mapping_armature_name
outfile = mapping_outfile
default_action = 'default'
auto_discover = False
def __init__(self):
self.objs = [mapping_armature_name]
if len(mapping_objects) > 0:
if len(mapping_objects) > 0 and mapping_objects != "AUTO":
self.objs += [o.strip() for o in mapping_objects.split(";")]
else:
self.auto_discover = True
self.files = []
for fobj in self.objs:
self.files.append({"name": fobj})
@@ -224,11 +230,51 @@ for mapping in[CommandLineMapping()]:
bpy.app.driver_namespace["angle_to_linear_x"] = angle_to_linear_x
print("Driver setup done...")
bpy.ops.wm.append(
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
directory=os.path.join(mapping.blend_path, mapping.inner_path),
files=mapping.files)
print("Append done...")
if mapping.auto_discover:
# Load all objects from the blend file so we can discover meshes by custom props
with bpy.data.libraries.load(mapping.blend_path) as (data_from, data_to):
data_to.objects = data_from.objects
for obj in data_to.objects:
if obj is not None:
try:
bpy.context.collection.objects.link(obj)
except RuntimeError:
pass # Already linked
print("Library load done...")
discovered = []
for ob in bpy.data.objects:
if ob.type == 'MESH' and all(p in ob.keys() for p in ["age", "sex", "slot"]):
discovered.append(ob.name)
mapping.objs += discovered
print(f"Auto-discovered {len(discovered)} body part objects: {discovered}")
# ---- Separate hair with its own skeleton ----
hair_own_skel = []
hair_own_skel_objs = [] # save object references
for name in list(mapping.objs):
obj = bpy.data.objects.get(name)
if obj:
has_it = obj.get("has_own_armature", False)
own_arm = obj.get("own_armature_name", "")
print(f" Object '{name}': has_own_armature="
f"{has_it}, own_armature_name='{own_arm}'")
if has_it:
hair_own_skel.append(name)
hair_own_skel_objs.append(obj)
mapping.objs.remove(name)
if hair_own_skel:
print(f"Hair with own skeleton (exported separately): "
f"{hair_own_skel}")
else:
print(f"No hair with own skeleton detected")
# ---------------------------------------------
else:
bpy.ops.wm.append(
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
directory=os.path.join(mapping.blend_path, mapping.inner_path),
files=mapping.files)
print("Append done...")
prepare_armature(mapping)
print("Armature done...")
@@ -239,6 +285,23 @@ for mapping in[CommandLineMapping()]:
bpy.data.objects.remove(ob)
elif ob.name.startswith("Face") and ob.name != "Face":
bpy.data.objects.remove(ob)
# Remove auto-discovered objects that weren't meant for export
elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs:
if ob.get("has_own_armature", False):
continue # exported separately, don't rename
if not ob.name.endswith("-noimp"):
ob.name = ob.name + "-noimp"
# Remove .001 suffixed duplicates that may have leaked from consolidate step
# These are objects that have the same base name as an object in mapping.objs
# but with a .001 suffix (e.g., BodyBottom.001 when BodyBottom is in mapping.objs)
if mapping.auto_discover:
for ob in list(bpy.data.objects):
if ob.type == 'MESH' and ob.name.endswith('.001'):
base_name = ob.name[:-4]
if base_name in mapping.objs:
print(f"Removing duplicate '{ob.name}' (base '{base_name}' already in export list)")
bpy.data.objects.remove(ob, do_unlink=True)
print("Removing original armature and actions...")
orig_arm = bpy.data.objects[mapping.armature_name + '_orig']
@@ -254,11 +317,28 @@ for mapping in[CommandLineMapping()]:
bpy.data.actions.remove(act)
for obj in bpy.data.objects:
if obj.type == 'MESH':
# Skip hair-with-own-skeleton objects — they're
# exported separately and must not be renamed
if obj.get("has_own_armature", False):
continue
if not obj.name in mapping.objs and obj.parent is None:
if not obj.name.endswith("-noimp"):
obj.name = obj.name + "-noimp"
bpy.ops.wm.save_as_mainfile(filepath=(basepath + "/assets/blender/scripts/" + mapping.outfile))
# Hide hair-with-own-skeleton objects from the body glTF export.
# The glTF exporter crashes when sampling animations for hair armatures
# that are not part of the main rig, so we exclude them here and export
# hair separately via OGRE later.
for obj in hair_own_skel_objs:
obj.hide_viewport = True
obj.hide_render = True
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = True
hair_arm.hide_render = True
os.makedirs(os.path.dirname(mapping.gltf_path), exist_ok=True)
bpy.ops.export_scene.gltf(filepath=mapping.gltf_path,
use_selection=False,
@@ -277,7 +357,7 @@ for mapping in[CommandLineMapping()]:
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_visible=True,
use_renderable=False,
export_yup=True,
export_animations=True,
@@ -291,6 +371,16 @@ for mapping in[CommandLineMapping()]:
export_lights=False,
export_skins=True)
print("exported to: " + mapping.gltf_path)
# Unhide hair objects for OGRE export
for obj in hair_own_skel_objs:
obj.hide_viewport = False
obj.hide_render = False
arm_name = obj.get("own_armature_name", "")
hair_arm = bpy.data.objects.get(arm_name)
if hair_arm:
hair_arm.hide_viewport = False
hair_arm.hide_render = False
obj_names = mapping.objs
prefix = mapping.armature_name + "_"
@@ -298,18 +388,44 @@ for mapping in[CommandLineMapping()]:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
# 1. Rename Mesh Data
if not obj.data.name.startswith(prefix):
obj.data.name = prefix + obj.data.name
# 1. Rename Mesh Data to match object name (not mesh data name)
# This prevents .001 suffixed mesh data names (e.g., BodyBottom.001)
# from becoming male_BodyBottom.001 and clashing with male_BodyBottom.
new_mesh_name = prefix + name
if obj.data.name != new_mesh_name:
# Check if another mesh data already has this name
if new_mesh_name in bpy.data.meshes and bpy.data.meshes[new_mesh_name] != obj.data:
old = bpy.data.meshes[new_mesh_name]
old.name = new_mesh_name + "_old"
print(f"Renamed conflicting mesh data '{old.name}' for object '{name}'")
obj.data.name = new_mesh_name
print(f"Renamed mesh data from '{obj.data.name}' to '{new_mesh_name}' for object '{name}'")
# 2. Iterate through all Material Slots on the object
for slot in obj.material_slots:
if slot.material:
mat = slot.material
# 3. Check if material already has the prefix
# 3. Normalize material name: strip Blender auto-suffixes
# (.001,.002 from name collisions, .### from dupes)
# before applying the armature prefix. This prevents
# duplicate .material files with unpredictable names
# (e.g. male_male-clothes-ed.001 vs male_male-clothes-ed)
# that cause submeshes to reference missing materials
# at runtime.
import re
clean = re.sub(r'\.\d{3}$', '', mat.name)
if clean != mat.name:
old = mat.name
mat.name = clean
# If Blender added .001 again because the clean
# name is already taken, keep the suffixed name.
print(f"Normalized material '{old}' -> '{mat.name}'"
f" on object '{name}'")
# 4. Check if material already has the prefix
if not mat.name.startswith(prefix):
mat.name = prefix + mat.name
print(f"Renamed material '{mat.name}' on object '{name}'")
print(f"Renamed material '{mat.name}'"
f" on object '{name}'")
# 3. Export custom properties to json
save_data = {}
for key in obj.keys():
@@ -317,17 +433,288 @@ for mapping in[CommandLineMapping()]:
save_data[key] = obj[key]
if key.startswith("body_"):
save_data[key.replace("body_", "", 1)] = obj[key]
# Export aggregated clothing metadata
if "layer" in obj:
save_data["layer"] = int(obj["layer"])
else:
save_data["layer"] = 0
tags = []
if "garments" in obj and obj["garments"]:
garments = [g.strip() for g in str(obj["garments"]).split(";") if g.strip()]
save_data["garments"] = garments
tags.extend(garments)
else:
save_data["garments"] = []
if "clothing_tags" in obj and obj["clothing_tags"]:
clothing_tags = [t.strip() for t in str(obj["clothing_tags"]).split(";") if t.strip()]
tags.extend(clothing_tags)
# Deduplicate and sort
seen = set()
unique_tags = []
for t in tags:
t_lower = t.lower()
if t_lower not in seen:
seen.add(t_lower)
unique_tags.append(t)
save_data["tags"] = unique_tags
# Export shape keys if present
# IMPORTANT: Skip "Basis" to match OGRE's pose indexing.
# OGRE's blender2ogre exporter skips the Basis shape key (index 0),
# so pose index 0 in OGRE = first non-Basis shape key.
shape_keys = []
if obj.data.shape_keys and obj.data.shape_keys.key_blocks:
for sk in obj.data.shape_keys.key_blocks:
if sk.name != 'Basis':
shape_keys.append(sk.name)
save_data["shape_keys"] = shape_keys
save_data["mesh"] = obj.data.name + ".mesh"
json_dir = os.path.dirname(mapping.gltf_path)
save_file = json_dir + "/body_part_" + obj.data.name + ".json"
json_filepath = os.path.join(json_dir, save_file)
with open(json_filepath, 'w') as f:
json.dump(save_data, f)
json.dump(save_data, f, indent=2)
# Triangulate body part meshes before OGRE export to prevent the exporter's
# triangulation from producing mismatched split normals at seams.
# BodyTop and BodyBottom have different face topologies, so the exporter's
# bmesh triangulation creates slightly different corner normals for matching
# vertices. Pre-triangulating ensures the exporter sees already-triangulated
# meshes and preserves our custom normals.
import bmesh
body_part_objs = []
for ob in bpy.data.objects:
if ob.type == 'MESH' and all(p in ob for p in ["age", "sex", "slot"]):
body_part_objs.append(ob)
print(f"\nTriangulating {len(body_part_objs)} body part meshes for OGRE export...")
for obj in body_part_objs:
mesh = obj.data
bm = bmesh.new()
bm.from_mesh(mesh)
bmesh.ops.triangulate(bm, faces=bm.faces)
bm.to_mesh(mesh)
bm.free()
mesh.calc_normals()
print(f" Triangulated '{obj.name}': {len(mesh.polygons)} polygons")
# Re-run normal fix on triangulated meshes to ensure matching vertices
# have identical custom split normals after triangulation.
chars_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "characters")
if chars_dir not in sys.path:
sys.path.insert(0, chars_dir)
from transfer_shape_keys import fix_normals_across_objects
fix_normals_across_objects()
armobj = bpy.data.objects.get(mapping.armature_name)
armobj.data.name = armobj.name
# ---- Fix: sync Image filepath to Image name ----
# Blender silently reuses existing Images when appending
# from libraries, so the Image's filepath may still point
# to the old texture even after the user renamed it.
# blender2ogre writes the filepath to .material files,
# so we must update filepath to match the Image name.
import re as _re
seen_imgs = set()
for name in obj_names:
obj = bpy.data.objects.get(name)
if obj and obj.type == 'MESH':
for slot in obj.material_slots:
if slot.material and slot.material.node_tree:
for node in slot.material.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
img = node.image
if img.name not in seen_imgs:
seen_imgs.add(img.name)
# Strip Blender auto-suffix (.NNN)
clean = _re.sub(
r'\.\d{3}$', '', img.name)
# Extract just the filename from
# the current filepath
old_name = os.path.basename(
img.filepath)
if old_name != clean and clean:
# Rebuild filepath with new
# filename
dirpart = os.path.dirname(
img.filepath)
new_path = os.path.join(
dirpart, clean)
print(f" Updating Image "
f"'{img.name}': "
f"filepath "
f"'{old_name}' -> "
f"'{clean}'")
img.filepath = new_path
# ---------------------------------------------------
bpy.ops.ogre.export(filepath=mapping.gltf_path.replace(".glb", ".scene"), EX_SELECTED_ONLY=False, EX_SHARED_ARMATURE=True, EX_LOD_GENERATION='0', EX_LOD_DISTANCE=20, EX_LOD_LEVELS=4, EX_GENERATE_TANGENTS='4')
ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene"))
# Fix alpha_rejection values in ALL generated .material files.
# blender2ogre 0.9.0 writes float values (e.g. "127.5") for
# alpha_rejection thresholds, but OGRE 14 expects an integer
# in the range 0-255. Float values are truncated to integer
# during material compilation, causing 127.5 -> 127 when 128
# was intended. This makes submeshes with alpha < 128 in the
# texture atlas completely invisible.
import glob as _glob
_mat_files = _glob.glob(os.path.join(ogre_export_dir, "*.material"))
for _mf in _mat_files:
with open(_mf, 'r') as _f:
_content = _f.read()
if 'alpha_rejection' in _content:
import re as _re
_new = _re.sub(
r'alpha_rejection (\S+) (\d+\.?\d*)',
lambda m: 'alpha_rejection ' + m.group(1) +
' ' + str(int(float(m.group(2)))),
_content)
if _new != _content:
with open(_mf, 'w') as _f:
_f.write(_new)
print(f" Fixed alpha_rejection in {_mf}")
# Post-process exported OGRE meshes to fix normal/tangent seams between
# body parts. Even with identical custom normals in Blender, the OGRE
# exporter's calc_tangents() produces slightly different tangents for
# matching vertices because BodyTop and BodyBottom have different face
# topologies. This causes a visible lighting seam with normal mapping,
# especially when shape keys (like "fat") are applied.
fix_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fix_ogre_mesh_seams.py")
if os.path.exists(fix_script):
print(f"\nPost-processing OGRE meshes in {ogre_export_dir}...")
import subprocess
import shutil
# Use system python3 if available, since sys.executable in Blender
# may point to the Blender binary itself, not a Python interpreter.
python_exe = shutil.which('python3') or shutil.which('python') or sys.executable
result = subprocess.run(
[python_exe, fix_script, ogre_export_dir],
capture_output=True, text=True
)
print(result.stdout)
if result.returncode != 0:
print(f"WARNING: Mesh seam fix failed: {result.stderr}")
else:
print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}")
# ---- Export hair with own skeleton ----
print(f"\n=== HAIR DEBUG: auto_discover={mapping.auto_discover} "
f"hair_own_skel={hair_own_skel}")
if mapping.auto_discover and hair_own_skel:
# Collect hair meshes grouped by their armature name
hair_by_arm = {}
for obj in hair_own_skel_objs:
arm_name = obj.get("own_armature_name", "")
print(f" HAIR DEBUG: obj '{obj.name}' "
f"own_armature_name='{arm_name}' "
f"has_own_armature={obj.get('has_own_armature', False)}")
if arm_name not in hair_by_arm:
hair_by_arm[arm_name] = []
hair_by_arm[arm_name].append(obj)
print(f" HAIR DEBUG: hair_by_arm has {len(hair_by_arm)} groups")
body_arm_name = mapping.armature_name # save "male"/"female"
body_prefix = body_arm_name + "_"
# Debug: list all armatures in the scene
all_arms = [o.name for o in bpy.data.objects
if o.type == 'ARMATURE']
print(f" HAIR DEBUG: armatures in scene: {all_arms}")
for arm_name, hair_objs in hair_by_arm.items():
hair_arm = bpy.data.objects.get(arm_name)
print(f" HAIR DEBUG: arm_name='{arm_name}' "
f"hair_arm_found={hair_arm is not None}")
if not hair_arm or hair_arm.type != 'ARMATURE':
print(f" WARNING: Armature '{arm_name}' not found "
f"for hair, skipping")
continue
hair_obj_names = [o.name for o in hair_objs]
print(f" Exporting hair with skeleton '{arm_name}': "
f"{hair_obj_names}")
# Sync armature data name
hair_arm.data.name = hair_arm.name
print(f" Armature data name: '{hair_arm.data.name}'")
# Write body_part JSON for each hair mesh
json_dir = os.path.dirname(mapping.gltf_path)
for obj in hair_objs:
new_mesh_name = body_prefix + obj.name
obj.data.name = new_mesh_name
save_data = {
"age": obj.get("age", ""),
"sex": obj.get("sex", ""),
"slot": obj.get("slot", ""),
"mesh": obj.data.name + ".mesh",
"layer": int(obj.get("layer", 0)),
"garments": [],
"tags": [],
"shape_keys": [],
"own_skeleton": True,
"attach_to_bone": "mixamorig:Head"
}
garments = obj.get("garments", "")
if garments:
save_data["garments"] = [
g.strip()
for g in str(garments).split(";")
if g.strip()]
clothing_tags = obj.get("clothing_tags", "")
if clothing_tags:
save_data["tags"] = [
t.strip()
for t in str(clothing_tags).split(";")
if t.strip()]
save_file = (json_dir + "/body_part_" +
obj.data.name + ".json")
with open(save_file, 'w') as f:
json.dump(save_data, f, indent=2)
print(f" Wrote {save_file}")
# OGRE export for hair with its own skeleton
# OGRE export for hair with its own skeleton
hair_scene = mapping.gltf_path.replace(
".glb", "_hair_" + arm_name + ".scene")
bpy.ops.ogre.export(
filepath=hair_scene,
EX_SELECTED_ONLY=False,
EX_SHARED_ARMATURE=True,
EX_LOD_GENERATION='0',
EX_LOD_DISTANCE=20,
EX_LOD_LEVELS=4,
EX_GENERATE_TANGENTS='4')
print(f" Exported hair scene: {hair_scene}")
# Fix alpha_rejection in hair .material files
_mat_files = _glob.glob(
os.path.join(ogre_export_dir, "*.material"))
for _mf in _mat_files:
with open(_mf, 'r') as _f:
_content = _f.read()
if 'alpha_rejection' in _content:
_new = _re.sub(
r'alpha_rejection (\S+) (\d+\.?\d*)',
lambda m: 'alpha_rejection ' +
m.group(1) + ' ' +
str(int(float(m.group(2)))),
_content)
if _new != _content:
with open(_mf, 'w') as _f:
_f.write(_new)
# -----------------------------------------
bpy.ops.wm.read_homefile(use_empty=True)
time.sleep(2)
bpy.ops.wm.quit_blender()
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
Post-process exported OGRE mesh files to fix normal/tangent seams
between body part meshes at matching vertex positions.
Also fixes pose normal offsets (shape key normals) so that animated
shape keys don't reintroduce seams at runtime.
Usage: python3 fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]
"""
import os
import sys
import subprocess
import xml.etree.ElementTree as ET
import math
# Default path to OgreXMLConverter (can be overridden via argument or env var)
DEFAULT_OGRE_XML_CONVERTER = os.environ.get(
'OGRETOOLS_XML_CONVERTER',
'/media/slapin/library/ogre3/ogre-sdk/bin/OgreXMLConverter'
)
# Body part prefixes to process
BODY_PREFIXES = ['BodyTop', 'BodyBottom', 'BodyFeet']
def find_converter():
"""Find OgreXMLConverter executable."""
if len(sys.argv) >= 3:
return sys.argv[2]
if os.path.exists(DEFAULT_OGRE_XML_CONVERTER):
return DEFAULT_OGRE_XML_CONVERTER
# Try PATH
for path in os.environ.get('PATH', '').split(os.pathsep):
exe = os.path.join(path, 'OgreXMLConverter')
if os.path.exists(exe):
return exe
return None
def mesh_to_xml(mesh_path, converter):
xml_path = mesh_path + '.xml'
result = subprocess.run(
[converter, mesh_path, xml_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"WARNING: Failed to convert {mesh_path} to XML:")
print(result.stdout)
print(result.stderr)
return None
return xml_path
def xml_to_mesh(xml_path, converter):
mesh_path = xml_path[:-4] # remove .xml
result = subprocess.run(
[converter, xml_path, mesh_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"WARNING: Failed to convert {xml_path} to binary:")
print(result.stdout)
print(result.stderr)
return None
return mesh_path
def parse_mesh_xml(xml_path):
"""Parse mesh XML and return (tree, vertices list, poses dict)."""
try:
tree = ET.parse(xml_path)
except ET.ParseError as e:
print(f"ERROR: Failed to parse {xml_path}: {e}")
return None, None, None
root = tree.getroot()
sharedgeom = root.find('.//sharedgeometry')
if sharedgeom is None:
return None, None, None
vbs = sharedgeom.findall('vertexbuffer')
if len(vbs) < 1:
return None, None, None
# The first vertexbuffer has positions and normals
pos_norm_vb = vbs[0]
# The second vertexbuffer (if any) has texcoords and tangents
tex_tan_vb = vbs[1] if len(vbs) > 1 else None
pn_verts = pos_norm_vb.findall('vertex')
tt_verts = tex_tan_vb.findall('vertex') if tex_tan_vb is not None else []
vertices = []
for i, pnv in enumerate(pn_verts):
pos = pnv.find('position')
normal = pnv.find('normal')
if pos is None or normal is None:
continue
p = (float(pos.get('x')), float(pos.get('y')), float(pos.get('z')))
n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
t = None
if i < len(tt_verts):
tangent = tt_verts[i].find('tangent')
if tangent is not None:
t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
vertices.append({
'index': i,
'pos': p,
'normal': n,
'tangent': t,
})
# Parse poses: pose_name -> {vertex_index -> (nx, ny, nz)}
poses = {}
for pose in root.findall('.//poses/pose'):
name = pose.get('name')
pose_offsets = {}
for po in pose.findall('poseoffset'):
idx = int(po.get('index'))
nx = float(po.get('nx', 0.0))
ny = float(po.get('ny', 0.0))
nz = float(po.get('nz', 0.0))
pose_offsets[idx] = (nx, ny, nz)
poses[name] = pose_offsets
return tree, vertices, poses
def round_pos(p, tolerance=0.0001):
return (
round(p[0] / tolerance) * tolerance,
round(p[1] / tolerance) * tolerance,
round(p[2] / tolerance) * tolerance,
)
def vector_normalize(v):
length = math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
if length > 0.0001:
return (v[0] / length, v[1] / length, v[2] / length)
return v
def fix_seams(mesh_dir, converter):
"""Fix normal/tangent/pose-normal seams across all body part meshes in directory."""
# Find all body part mesh files
mesh_files = []
for f in os.listdir(mesh_dir):
if not f.endswith('.mesh') or f.endswith('.mesh.xml'):
continue
for prefix in BODY_PREFIXES:
if prefix in f:
mesh_files.append(os.path.join(mesh_dir, f))
break
if not mesh_files:
print(f"No body part meshes found in {mesh_dir}")
return 0
print(f"\n[OGRE Mesh Seam Fix] Found {len(mesh_files)} body part meshes")
for mf in mesh_files:
print(f" - {os.path.basename(mf)}")
# Convert all to XML
xml_paths = []
for mesh_path in mesh_files:
print(f"\nConverting {os.path.basename(mesh_path)} to XML...")
xml_path = mesh_to_xml(mesh_path, converter)
if xml_path:
xml_paths.append(xml_path)
else:
print(f" SKIPPED (conversion failed)")
if not xml_paths:
print("No meshes could be converted to XML, aborting.")
return 0
# Parse all XMLs
mesh_data = {}
all_pose_names = set()
for xml_path in xml_paths:
name = os.path.basename(xml_path)[:-9] # remove .mesh.xml
tree, vertices, poses = parse_mesh_xml(xml_path)
if vertices:
mesh_data[name] = {
'tree': tree,
'vertices': vertices,
'poses': poses,
'xml_path': xml_path,
}
all_pose_names.update(poses.keys())
else:
print(f"WARNING: No vertices found in {name}")
if not mesh_data:
print("No mesh data could be parsed, aborting.")
return 0
# Build global position -> list of (normal, tangent) map for BASIS
pos_map = {}
for name, data in mesh_data.items():
for v in data['vertices']:
key = round_pos(v['pos'], 0.0001)
if key not in pos_map:
pos_map[key] = []
pos_map[key].append((v['normal'], v['tangent']))
# Compute averaged normals and tangents for positions appearing in 2+ meshes
avg_data = {}
multi_mesh_positions = 0
for pos_key, entries in pos_map.items():
if len(entries) < 2:
continue
multi_mesh_positions += 1
# Average normals
avg_n = [0.0, 0.0, 0.0]
for n, t in entries:
avg_n[0] += n[0]
avg_n[1] += n[1]
avg_n[2] += n[2]
avg_n = vector_normalize(avg_n)
# Average tangents (only if at least one entry has a tangent)
avg_t = [0.0, 0.0, 0.0]
tangent_count = 0
for n, t in entries:
if t is not None:
avg_t[0] += t[0]
avg_t[1] += t[1]
avg_t[2] += t[2]
tangent_count += 1
if tangent_count > 0:
avg_t = vector_normalize(avg_t)
else:
avg_t = None
avg_data[pos_key] = {
'normal': avg_n,
'tangent': avg_t,
}
print(f"\nFound {multi_mesh_positions} positions shared across 2+ meshes")
print(f"Averaging normals/tangents for {len(avg_data)} positions...")
# Build global position -> list of pose normal offsets for EACH pose
# pose_name -> {pos_key -> list of (nx, ny, nz)}
pose_pos_maps = {}
for pose_name in all_pose_names:
pose_pos_map = {}
for name, data in mesh_data.items():
if pose_name not in data['poses']:
continue
pose_offsets = data['poses'][pose_name]
for v in data['vertices']:
if v['index'] not in pose_offsets:
continue
key = round_pos(v['pos'], 0.0001)
if key not in pose_pos_map:
pose_pos_map[key] = []
pose_pos_map[key].append(pose_offsets[v['index']])
pose_pos_maps[pose_name] = pose_pos_map
# Compute averaged pose normal offsets for positions appearing in 2+ meshes
avg_pose_data = {}
for pose_name, pose_pos_map in pose_pos_maps.items():
avg_offsets = {}
for pos_key, entries in pose_pos_map.items():
if len(entries) < 2:
continue
avg_n = [0.0, 0.0, 0.0]
for nx, ny, nz in entries:
avg_n[0] += nx
avg_n[1] += ny
avg_n[2] += nz
avg_n = vector_normalize(avg_n)
avg_offsets[pos_key] = avg_n
if avg_offsets:
avg_pose_data[pose_name] = avg_offsets
if avg_pose_data:
print(f"Averaging pose normal offsets for {len(avg_pose_data)} pose(s):")
for pose_name in sorted(avg_pose_data.keys()):
print(f" - {pose_name}: {len(avg_pose_data[pose_name])} positions")
else:
print("No pose normal offsets to fix.")
# Update all meshes with averaged values
modified_count = 0
for name, data in mesh_data.items():
tree = data['tree']
vertices = data['vertices']
modified = False
normal_fixes = 0
tangent_fixes = 0
pose_normal_fixes = 0
root = tree.getroot()
sharedgeom = root.find('.//sharedgeometry')
if sharedgeom is None:
continue
vbs = sharedgeom.findall('vertexbuffer')
if len(vbs) < 1:
continue
pos_norm_vb = vbs[0]
tt_verts = vbs[1].findall('vertex') if len(vbs) > 1 else []
pn_verts = pos_norm_vb.findall('vertex')
# Fix basis normals and tangents
for v in vertices:
key = round_pos(v['pos'], 0.0001)
if key not in avg_data:
continue
avg = avg_data[key]
# Update normal
pnv = pn_verts[v['index']]
normal = pnv.find('normal')
if normal is not None:
old_n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
new_n = avg['normal']
diff = math.sqrt(
(old_n[0]-new_n[0])**2 +
(old_n[1]-new_n[1])**2 +
(old_n[2]-new_n[2])**2
)
if diff > 0.000001:
normal.set('x', f"{new_n[0]:.6f}")
normal.set('y', f"{new_n[1]:.6f}")
normal.set('z', f"{new_n[2]:.6f}")
normal_fixes += 1
# Update tangent
if avg['tangent'] is not None and v['tangent'] is not None:
if v['index'] < len(tt_verts):
ttv = tt_verts[v['index']]
tangent = ttv.find('tangent')
if tangent is not None:
old_t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
new_t = avg['tangent']
diff = math.sqrt(
(old_t[0]-new_t[0])**2 +
(old_t[1]-new_t[1])**2 +
(old_t[2]-new_t[2])**2
)
if diff > 0.000001:
tangent.set('x', f"{new_t[0]:.6f}")
tangent.set('y', f"{new_t[1]:.6f}")
tangent.set('z', f"{new_t[2]:.6f}")
tangent_fixes += 1
modified = True
# Fix pose normal offsets
for pose_name, avg_offsets in avg_pose_data.items():
if pose_name not in data['poses']:
continue
# Find the <pose> element
pose_elem = None
for p in root.findall('.//poses/pose'):
if p.get('name') == pose_name:
pose_elem = p
break
if pose_elem is None:
continue
for v in vertices:
key = round_pos(v['pos'], 0.0001)
if key not in avg_offsets:
continue
if v['index'] not in data['poses'][pose_name]:
continue
# Find the poseoffset element for this vertex
for po in pose_elem.findall('poseoffset'):
if int(po.get('index')) == v['index']:
old_nx = float(po.get('nx', 0.0))
old_ny = float(po.get('ny', 0.0))
old_nz = float(po.get('nz', 0.0))
new_n = avg_offsets[key]
diff = math.sqrt(
(old_nx-new_n[0])**2 +
(old_ny-new_n[1])**2 +
(old_nz-new_n[2])**2
)
if diff > 0.000001:
po.set('nx', f"{new_n[0]:.6f}")
po.set('ny', f"{new_n[1]:.6f}")
po.set('nz', f"{new_n[2]:.6f}")
pose_normal_fixes += 1
break
if pose_normal_fixes > 0:
modified = True
if modified:
xml_path = data['xml_path']
tree.write(xml_path, encoding='utf-8', xml_declaration=True)
print(f" {name}: fixed {normal_fixes} normals, {tangent_fixes} tangents, {pose_normal_fixes} pose normals")
modified_count += 1
else:
print(f" {name}: no changes needed")
# Convert back to binary
print(f"\nConverting {modified_count} modified meshes back to binary...")
converted = 0
for xml_path in xml_paths:
mesh_path = xml_to_mesh(xml_path, converter)
if mesh_path:
converted += 1
# Clean up XML file
try:
os.remove(xml_path)
except OSError:
pass
print(f"[OGRE Mesh Seam Fix] Done. {converted}/{len(xml_paths)} meshes converted.")
return converted
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]")
sys.exit(1)
mesh_dir = sys.argv[1]
converter = find_converter()
if not converter:
print("ERROR: OgreXMLConverter not found. Set OGRETOOLS_XML_CONVERTER env var or pass path as 2nd argument.")
sys.exit(1)
if not os.path.isdir(mesh_dir):
print(f"ERROR: {mesh_dir} is not a directory")
sys.exit(1)
fix_seams(mesh_dir, converter)
+217
View File
@@ -0,0 +1,217 @@
# Buoyancy System Analysis
## Problem: Characters are not affected by buoyancy
After analyzing the code in `src/features/editScene`, I've identified several potential issues:
## 1. Character Gravity Factor Issue
**Root Cause**: Characters have gravity factor set to 0.0f by default.
In `CharacterSystem.cpp` line 163:
```cpp
m_physics->setGravityFactor(ch->GetBodyID(), 0.0f);
```
This means characters won't sink into water naturally. The `BuoyancySystem` tries to handle this by setting gravity factor to 1.0f when characters are in water (line 127 in `BuoyancySystem.cpp`), but there may be timing or detection issues.
## 2. Broadphase Query Area Settings
The `broadphaseQuery` function in `physics.cpp` uses these settings:
```cpp
JPH::AABox water_box(-JPH::Vec3(1000, 1000, 1000),
JPH::Vec3(1000, 0.1f, 1000));
water_box.Translate(JPH::Vec3(surface_point));
```
Where `surface_point = position + Ogre::Vector3(0, -0.1f, 0)` (position is the water surface Y level).
**Dimensions**:
- X: -1000 to 1000 (2000 units wide, centered at surface_point.x)
- Y: -1000 to 0.1f (1000.1 units tall, but centered 0.1 units BELOW water surface)
- Z: -1000 to 1000 (2000 units deep, centered at surface_point.z)
**Issue**: The water box extends 1000 units BELOW the surface point, but only 0.1 units ABOVE it. Since `surface_point` is 0.1 units below the actual water surface, the box effectively covers:
- From 1000.1 units below water surface
- To 0.0 units at water surface (not above it)
This means bodies need to be at or below the water surface to be detected.
## 3. Character Detection in Broadphase
Characters are `JPH::Character` objects, not regular dynamic bodies. The broadphase query filters for:
- `BroadPhaseLayers::MOVING` layer
- `Layers::MOVING` object layer
Characters should be in these layers, but there may be issues with how character bodies are registered in the broadphase.
## 4. Debugging Approach
### 4.1 Enable Debug Logging
Modify `BuoyancySystem.cpp` to add debug logging:
```cpp
// In update() method, after broadphaseQuery call:
Ogre::LogManager::getSingleton().logMessage(
"BuoyancySystem: Found " + Ogre::StringConverter::toString(m_bodiesInWater.size()) +
" bodies in water");
// In the loop applying buoyancy:
for (JPH::BodyID bodyID : m_bodiesInWater) {
Ogre::SceneNode *node = m_physics->getSceneNodeFromBodyID(bodyID);
if (node) {
Ogre::LogManager::getSingleton().logMessage(
"Body " + Ogre::StringConverter::toString(bodyID.GetIndex()) +
" at position: " + Ogre::StringConverter::toString(m_physics->getPosition(bodyID)));
}
// Check if it's a character
if (m_physics->bodyIsCharacter(bodyID)) {
Ogre::LogManager::getSingleton().logMessage(
"Body " + Ogre::StringConverter::toString(bodyID.GetIndex()) + " is a character");
}
}
```
### 4.2 Visual Debugging - Draw Water Box
Add debug rendering to visualize the water detection area:
```cpp
// In BuoyancySystem::update(), after broadphaseQuery:
void drawDebugWaterBox(const Ogre::Vector3& waterSurfacePos) {
// Create a manual object to visualize the water box
static Ogre::ManualObject* waterBoxDebug = nullptr;
if (!waterBoxDebug) {
waterBoxDebug = m_sceneManager->createManualObject("WaterBoxDebug");
Ogre::SceneNode* debugNode = m_sceneManager->getRootSceneNode()->createChildSceneNode();
debugNode->attachObject(waterBoxDebug);
}
waterBoxDebug->clear();
waterBoxDebug->begin("BaseWhiteNoLighting", Ogre::RenderOperation::OT_LINE_LIST);
// Water box dimensions (matching broadphaseQuery)
float halfSize = 1000.0f;
float top = waterSurfacePos.y - 0.1f + 0.1f; // surface_point.y + 0.1f
float bottom = waterSurfacePos.y - 0.1f - 1000.0f; // surface_point.y - 1000.0f
// Draw box edges
Ogre::Vector3 corners[8] = {
{waterSurfacePos.x - halfSize, bottom, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, bottom, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, bottom, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, bottom, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, top, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, top, waterSurfacePos.z - halfSize},
{waterSurfacePos.x + halfSize, top, waterSurfacePos.z + halfSize},
{waterSurfacePos.x - halfSize, top, waterSurfacePos.z + halfSize}
};
// Bottom square
for (int i = 0; i < 4; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[(i+1)%4]);
}
// Top square
for (int i = 4; i < 8; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[4 + (i-3)%4]);
}
// Vertical edges
for (int i = 0; i < 4; i++) {
waterBoxDebug->position(corners[i]);
waterBoxDebug->position(corners[i+4]);
}
waterBoxDebug->end();
}
```
### 4.3 Check Character Position Relative to Water
Add a debug function to check character positions:
```cpp
void debugCharacterPositions() {
m_world.query<CharacterComponent, TransformComponent>().each(
[&](flecs::entity entity, CharacterComponent &cc, TransformComponent &transform) {
if (transform.node) {
Ogre::Vector3 worldPos = transform.node->_getDerivedPosition();
Ogre::LogManager::getSingleton().logMessage(
"Character entity " + Ogre::StringConverter::toString(entity.id()) +
" at Y: " + Ogre::StringConverter::toString(worldPos.y));
// Check if character has physics body
auto it = m_states.find(entity.id());
if (it != m_states.end() && it->second.character) {
JPH::BodyID bodyID = it->second.character->GetBodyID();
Ogre::Vector3 bodyPos = m_physics->getPosition(bodyID);
Ogre::LogManager::getSingleton().logMessage(
"Character body at Y: " + Ogre::StringConverter::toString(bodyPos.y) +
", gravity factor: " + Ogre::StringConverter::toString(m_physics->getGravityFactor(bodyID)));
}
}
});
}
```
## 5. Recommended Fixes
### 5.1 Adjust Water Box Parameters
The current water box may be too shallow (only 0.1 units at the top). Consider adjusting:
```cpp
// In broadphaseQuery function:
JPH::AABox water_box(-JPH::Vec3(1000, 1.0f, 1000), // Increased from 0.1f to 1.0f
JPH::Vec3(1000, 1000, 1000)); // Symmetrical above/below
```
This creates a 2-unit tall detection area centered on the surface point.
### 5.2 Fix Character Gravity Handling
Modify `BuoyancySystem::update()` to better handle character gravity:
```cpp
// Current issue: characters with gravity factor 0 won't sink into water
// Even if buoyancy is applied, they need gravity to sink first
// Potential fix: Always give characters some minimal gravity when near water
// or modify CharacterSystem to not set gravity factor to 0
```
### 5.3 Verify Character Body Registration
Ensure character bodies are properly registered in the physics system and included in broadphase queries. Check that:
1. Characters are added to the physics system (`ch->AddToPhysicsSystem()`)
2. They are in the `MOVING` broadphase layer
3. Their body IDs are valid for queries
## 6. Testing Procedure
1. **Enable debug logging** as shown above
2. **Place a character in water** (Y position below water surface)
3. **Check console output** for:
- Number of bodies detected in water
- Character body positions
- Gravity factor changes
4. **Use visual debug** to see water box
5. **Adjust water surface Y** in WaterPhysics component to ensure it's above character position
## 7. Water Physics Settings
Default `WaterPhysics` component has:
- `waterSurfaceY = -0.1f` (slightly below origin)
- `defaultBuoyancy = 1.0f` (neutral buoyancy)
- `enabled = true`
Make sure:
1. WaterPhysics entity exists (BuoyancySystem creates one if missing)
2. `waterSurfaceY` is above character positions for testing
3. Water physics is enabled (`enabled = true`)
+172
View File
@@ -0,0 +1,172 @@
# Buoyancy System Analysis and Debugging Guide
## Problem Analysis
After analyzing the buoyancy system in `src/features/editScene`, I identified several key issues why characters are not affected by buoyancy:
### 1. **Broadphase Query Area Not Following Camera**
The original buoyancy system used a fixed position `(0, waterSurfaceY, 0)` for the broadphase query AABox. This meant the water detection area was static at world origin, not following the camera or characters.
**Fix Applied**: Modified `BuoyancySystem::update()` to use camera XZ position with water Y position:
```cpp
Ogre::Vector3 waterSurfacePos(m_cameraPosition.x, waterPhysics->waterSurfaceY, m_cameraPosition.z);
```
### 2. **Character Physics Layer Issue**
Characters are created with `Layers::MOVING` but the broadphase query in `physics.cpp` was checking for bodies in specific layers. The query needs to include the MOVING layer.
**Fix Applied**: Updated `broadphaseQuery` in `physics.cpp` to include `Layers::MOVING`:
```cpp
if (body->GetMotionType() == JPH::EMotionType::Dynamic &&
(body->GetObjectLayer() == Layers::MOVING ||
body->GetObjectLayer() == Layers::NON_MOVING)) {
```
### 3. **Character Gravity Factor Management**
Characters have gravity factor 0 by default (to prevent sinking into terrain). When they enter water, we need to:
1. Save original gravity factor
2. Set gravity factor to 1.0 to allow sinking
3. Restore original gravity when leaving water
**Fix Applied**: Added gravity factor caching in `BuoyancySystem`:
```cpp
// Save original gravity factor if not already saved
if (m_characterOriginalGravity.find(bodyID) == m_characterOriginalGravity.end()) {
m_characterOriginalGravity[bodyID] = m_physics->getGravityFactor(bodyID);
}
// Enable gravity for characters in water so they sink
m_physics->setGravityFactor(bodyID, 1.0f);
```
### 4. **Water AABox Size Configuration**
The broadphase query uses an AABox centered at water surface position with size `(100, 10, 100)`. This may need adjustment based on your scene scale.
## Debugging Approach
### 1. **Enable Debug Logging**
Run the editor with the `--debug-buoyancy` command line option:
```bash
./build/Editor --debug-buoyancy
```
This enables verbose logging every 60 frames (about 1 second at 60 FPS) showing:
- Water physics state (surface Y, enabled, buoyancy)
- Camera position
- Water detection center position
- All characters and their positions
- Bodies detected in water by broadphase query
- Character gravity factor cache
### 2. **Broadphase Query Settings**
The water detection area is configured in `src/features/editScene/physics/physics.cpp`:
```cpp
// AABox for water detection (centered at water surface position)
// Box extends from (-1000, 1.0, -1000) to (1000, 1000, 1000) relative to surface
// Total size: 2000x999x2000 units
JPH::AABox water_box(-JPH::Vec3(1000, 1.0f, 1000),
JPH::Vec3(1000, 1000, 1000));
water_box.Translate(JPH::Vec3(surface_point));
```
**Current Filter Settings**:
- Only checks `Layers::MOVING` bodies (line 1587)
- Uses `BroadPhaseLayers::MOVING` filter (line 1586)
**Adjustment Recommendations**:
1. **Check character layer**: Ensure characters are in `Layers::MOVING`
2. **Adjust box size**: The current 2000x999x2000 box is very large
- Reduce 1000 values for smaller detection area
- Adjust Y values (1.0f and 1000) for vertical detection range
3. **Add NON_MOVING layer**: If characters are in NON_MOVING layer, update filter:
```cpp
JPH::SpecifiedObjectLayerFilter(Layers::MOVING | Layers::NON_MOVING)
```
4. **Box follows camera**: The box is translated to `surface_point` which now uses camera XZ position
### 3. **Water Physics Configuration**
Default water settings (in `EditorApp::createDefaultEntities()`):
- `waterSurfaceY = -0.1f` (just below ground level)
- `defaultBuoyancy = 1.0f` (neutral buoyancy)
- `defaultLinearDrag = 0.1f`
- `defaultAngularDrag = 0.05f`
- `gravity = 9.81f`
**To adjust**: Use the Water Physics editor UI or modify the WaterPhysics component.
### 4. **Character Configuration**
Ensure characters:
1. Have `CharacterComponent` attached
2. Are in the `MOVING` physics layer
3. Have proper collision shapes
4. Are spawned at Y position below water surface for testing
## Testing Procedure
1. **Build the project**:
```bash
cmake --build build --target Editor
```
2. **Run with debug mode**:
```bash
./build/Editor --debug-buoyancy
```
3. **Create test scene**:
- Add water (WaterPhysics entity exists by default)
- Spawn characters (use Character spawner in UI)
- Position characters below water surface (Y < -0.1)
4. **Monitor console output** for debug messages showing:
- "Bodies in water (broadphase): X"
- Character positions and "inWater" status
- Gravity factor changes
5. **Adjust settings as needed**:
- Increase water surface Y if characters are above water
- Adjust AABox size in physics.cpp
- Modify buoyancy/drag coefficients
## Key Code Changes Made
1. **BuoyancySystem.cpp/hpp**:
- Added camera position tracking
- Added gravity factor caching for characters
- Added debug logging system
- Fixed broadphase query position
2. **physics.cpp**:
- Fixed broadphase query to include MOVING layer
- Ensured character bodies are detected
3. **EditorApp.cpp/hpp**:
- Added `--debug-buoyancy` command line option
- Added `setDebugBuoyancy()` method
- Updated camera position to buoyancy system each frame
4. **EditorCamera.hpp**:
- Added `getPosition()` method
5. **main.cpp**:
- Added command line argument parsing for `--debug-buoyancy`
## Expected Behavior After Fixes
1. Characters should sink into water (gravity enabled)
2. Buoyancy forces should push characters upward
3. Debug logs should show bodies detected in water
4. Character gravity should be restored when leaving water
5. Water detection area should follow camera movement
## Troubleshooting
If characters still aren't affected:
1. **Check debug logs**: Ensure bodies are being detected
2. **Verify water surface Y**: Characters must be below this value
3. **Check physics layers**: Characters should be in MOVING layer
4. **Test with simple objects**: Create a simple box to verify buoyancy works
5. **Adjust AABox size**: Increase detection area if characters are far from camera
The system is now properly configured to detect characters in water and apply buoyancy forces with comprehensive debugging capabilities.
+258 -4
View File
@@ -35,9 +35,20 @@ set(EDITSCENE_SOURCES
systems/RoomLayoutSystem.cpp
systems/FurnitureLibrary.cpp
systems/StartupMenuSystem.cpp
systems/PauseMenuSystem.cpp
systems/ItemRegistry.cpp
systems/ContainerStateRegistry.cpp
systems/ItemStateRegistry.cpp
systems/SaveLoadSystem.cpp
systems/SaveLoadDialog.cpp
systems/PlayerControllerSystem.cpp
systems/CharacterSlotSystem.cpp
systems/OgreEntityHack.cpp
systems/CharacterRegistry.cpp
systems/MarkovNameGenerator.cpp
systems/PregnancySystem.cpp
systems/AnimationTreeSystem.cpp
systems/HairPhysicsSystem.cpp
systems/BehaviorTreeSystem.cpp
systems/NavMeshSystem.cpp
recast/TileCacheNavMesh.cpp
@@ -87,6 +98,7 @@ set(EDITSCENE_SOURCES
ui/PrimitiveEditor.cpp
ui/TriangleBufferEditor.cpp
ui/CharacterSlotsEditor.cpp
ui/CharacterIdentityEditor.cpp
ui/AnimationTreeEditor.cpp
ui/AnimationTreeTemplateEditor.cpp
ui/CharacterEditor.cpp
@@ -137,9 +149,13 @@ set(EDITSCENE_SOURCES
components/CellGrid.cpp
components/StartupMenuModule.cpp
components/PlayerControllerModule.cpp
components/DialogueComponentModule.cpp
systems/DialogueSystem.cpp
ui/DialogueEditor.cpp
lua/LuaDialogueApi.cpp
lua/LuaItemApi.cpp
components/Formula.cpp
components/CharacterClassDatabase.cpp
systems/CharacterClassSystem.cpp
ui/CharacterClassDatabaseEditor.cpp
components/BuoyancyInfoModule.cpp
components/WaterPhysicsModule.cpp
components/WaterPlaneModule.cpp
@@ -156,6 +172,9 @@ set(EDITSCENE_SOURCES
lua/LuaActionApi.cpp
lua/LuaBehaviorTreeApi.cpp
lua/LuaGameModeApi.cpp
lua/LuaCharacterClassApi.cpp
lua/LuaCharacterApi.cpp
lua/LuaSaveLoadApi.cpp
)
set(EDITSCENE_HEADERS
@@ -182,15 +201,26 @@ set(EDITSCENE_HEADERS
components/Primitive.hpp
components/TriangleBuffer.hpp
components/CharacterSlots.hpp
components/CharacterIdentity.hpp
components/RuntimeMarker.hpp
components/AnimationTree.hpp
components/Character.hpp
components/CellGrid.hpp
components/StartupMenu.hpp
components/PlayerController.hpp
components/DialogueComponent.hpp
systems/DialogueSystem.hpp
ui/DialogueEditor.hpp
lua/LuaDialogueApi.hpp
lua/LuaItemApi.hpp
components/Formula.hpp
components/CharacterClassDatabase.hpp
ui/CharacterClassDatabaseEditor.hpp
systems/CharacterClassSystem.hpp
systems/StartupMenuSystem.hpp
systems/PauseMenuSystem.hpp
systems/ItemRegistry.hpp
systems/ContainerStateRegistry.hpp
systems/ItemStateRegistry.hpp
systems/PlayerControllerSystem.hpp
systems/EditorUISystem.hpp
systems/CellGridSystem.hpp
@@ -200,7 +230,10 @@ set(EDITSCENE_HEADERS
systems/ProceduralMaterialSystem.hpp
systems/ProceduralMeshSystem.hpp
systems/CharacterSlotSystem.hpp
systems/CharacterRegistry.hpp
systems/PregnancySystem.hpp
systems/AnimationTreeSystem.hpp
systems/HairPhysicsSystem.hpp
systems/BehaviorTreeSystem.hpp
systems/NavMeshSystem.hpp
recast/TileCacheNavMesh.hpp
@@ -238,6 +271,8 @@ set(EDITSCENE_HEADERS
systems/ProceduralTextureSystem.hpp
systems/StaticGeometrySystem.hpp
systems/SceneSerializer.hpp
systems/SaveLoadSystem.hpp
systems/SaveLoadDialog.hpp
systems/PhysicsSystem.hpp
systems/BuoyancySystem.hpp
systems/EditorSunSystem.hpp
@@ -264,6 +299,7 @@ set(EDITSCENE_HEADERS
ui/PrimitiveEditor.hpp
ui/TriangleBufferEditor.hpp
ui/CharacterSlotsEditor.hpp
ui/CharacterIdentityEditor.hpp
ui/AnimationTreeEditor.hpp
ui/AnimationTreeTemplateEditor.hpp
ui/CharacterEditor.hpp
@@ -308,9 +344,13 @@ set(EDITSCENE_HEADERS
lua/LuaActionApi.hpp
lua/LuaBehaviorTreeApi.hpp
lua/LuaGameModeApi.hpp
lua/LuaCharacterClassApi.hpp
lua/LuaCharacterApi.hpp
lua/LuaSaveLoadApi.hpp
)
add_executable(editSceneEditor ${EDITSCENE_SOURCES} ${EDITSCENE_HEADERS})
add_dependencies(editSceneEditor morph)
# Define JPH_DEBUG_RENDERER for physics debug drawing
target_compile_definitions(editSceneEditor PRIVATE JPH_DEBUG_RENDERER)
@@ -329,6 +369,7 @@ target_link_libraries(editSceneEditor
RecastNavigation::DetourTileCache
RecastNavigation::DetourCrowd
RecastNavigation::DebugUtils
PackageArchive
lua
)
@@ -474,6 +515,215 @@ target_include_directories(game_mode_lua_test PRIVATE
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Item / Inventory / Container Lua API
add_executable(item_lua_test
tests/item_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(item_lua_test
lua
)
target_include_directories(item_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Character Lua API
add_executable(character_lua_test
tests/character_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(character_lua_test
lua
)
target_include_directories(character_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Dialogue Lua API
add_executable(dialogue_lua_test
tests/dialogue_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(dialogue_lua_test
lua
)
target_include_directories(dialogue_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Character Class Lua API
add_executable(character_class_lua_test
tests/character_class_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(character_class_lua_test
lua
)
target_include_directories(character_class_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# Test: Save/Load Lua API
add_executable(save_load_lua_test
tests/save_load_lua_test.cpp
tests/lua_test_stubs.cpp
)
target_link_libraries(save_load_lua_test
lua
)
target_include_directories(save_load_lua_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/tests
${CMAKE_SOURCE_DIR}/src/lua/lua-5.4.8/src
)
# ---------------------------------------------------------------------------
# Package Archive Library
# ---------------------------------------------------------------------------
# This implements an Ogre::Archive for .package files (uncompressed indexed
# file containers, similar to Unity packages). It can be used from resources.cfg
# with "Package=path/to/file.package".
add_library(PackageArchive STATIC
package/OgrePackageArchive.cpp
package/OgrePackageArchive.h
)
target_include_directories(PackageArchive PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(PackageArchive PUBLIC
OgreMain
)
# ---------------------------------------------------------------------------
# PackageTool - command-line tool for creating/managing .package archives
# ---------------------------------------------------------------------------
add_executable(PackageTool
package/PackageTool.cpp
package/OgrePackageArchive.cpp
package/OgrePackageArchive.h
)
target_link_libraries(PackageTool
PackageArchive
OgreMain
)
target_include_directories(PackageTool PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
# ---------------------------------------------------------------------------
# lua-scripts.package - package all Lua scripts into a single archive
# ---------------------------------------------------------------------------
# Creates lua-scripts.package from the staged lua-scripts build directory.
# Files are stored with paths relative to lua-scripts/ (the "lua-scripts"
# directory prefix is stripped from archive paths).
#
# The staged directory is assembled from two sources:
# 1. Main lua-scripts (from ${CMAKE_BINARY_DIR}/lua-scripts, staged by
# the stage_lua_scripts target in the main build)
# 2. EditScene-specific lua-scripts (from
# ${CMAKE_CURRENT_SOURCE_DIR}/lua-scripts, overlaid on top)
# The overlay ensures editScene files take precedence over main files
# with the same name.
set(LUA_SCRIPTS_PACKAGE "${CMAKE_CURRENT_BINARY_DIR}/lua-scripts.package")
set(LUA_SCRIPTS_STAGED_DIR "${CMAKE_CURRENT_BINARY_DIR}/lua-scripts")
set(LUA_SCRIPTS_EDITSCENE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/lua-scripts")
# Collect all source files from the main lua-scripts source directory so
# that the package is rebuilt when any of them change.
# CONFIGURE_DEPENDS makes CMake re-evaluate the glob at build time,
# so newly added files are picked up without re-running cmake.
file(GLOB_RECURSE LUA_SCRIPTS_MAIN_FILES
CONFIGURE_DEPENDS
"${CMAKE_SOURCE_DIR}/lua-scripts/*.lua"
"${CMAKE_SOURCE_DIR}/lua-scripts/*.ink"
)
# Collect all editScene-specific lua-scripts source files.
file(GLOB_RECURSE LUA_SCRIPTS_EDITSCENE_FILES
CONFIGURE_DEPENDS
"${LUA_SCRIPTS_EDITSCENE_SRC}/*.lua"
"${LUA_SCRIPTS_EDITSCENE_SRC}/*.ink"
)
# Custom command to overlay editScene-specific lua-scripts on top of the
# staged main lua-scripts directory. This copies files from the editScene
# lua-scripts source directory into the staged directory, overlaying on
# top of any files already there from the main build.
set(LUA_SCRIPTS_OVERLAY_DONE "${CMAKE_CURRENT_BINARY_DIR}/.lua_scripts_overlay_done")
add_custom_command(
OUTPUT "${LUA_SCRIPTS_OVERLAY_DONE}"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${LUA_SCRIPTS_EDITSCENE_SRC}"
"${LUA_SCRIPTS_STAGED_DIR}"
COMMAND ${CMAKE_COMMAND} -E touch "${LUA_SCRIPTS_OVERLAY_DONE}"
DEPENDS stage_lua_scripts
${LUA_SCRIPTS_EDITSCENE_FILES}
COMMENT "Overlaying editScene lua-scripts on ${LUA_SCRIPTS_STAGED_DIR}"
)
add_custom_command(
OUTPUT "${LUA_SCRIPTS_PACKAGE}"
COMMAND $<TARGET_FILE:PackageTool>
create "${LUA_SCRIPTS_PACKAGE}"
--exclude "CMakeFiles"
--exclude "cmake_install.cmake"
--exclude "Makefile"
--exclude ".lua_scripts_overlay_done"
--exclude "*.lua-"
"${LUA_SCRIPTS_STAGED_DIR}"
DEPENDS PackageTool
"${LUA_SCRIPTS_OVERLAY_DONE}"
${LUA_SCRIPTS_MAIN_FILES}
COMMENT "Creating lua-scripts.package from ${LUA_SCRIPTS_STAGED_DIR}"
)
add_custom_target(lua_scripts_package ALL
DEPENDS "${LUA_SCRIPTS_PACKAGE}"
)
# ---------------------------------------------------------------------------
# Package Archive Test
# ---------------------------------------------------------------------------
add_executable(PackageArchiveTest
tests/package_archive_test.cpp
package/OgrePackageArchive.cpp
package/OgrePackageArchive.h
)
target_link_libraries(PackageArchiveTest
PackageArchive
OgreMain
)
target_include_directories(PackageArchiveTest PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${CMAKE_CURRENT_SOURCE_DIR}/package
)
# Copy local resources (materials, etc.)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/resources")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/resources"
@@ -491,6 +741,10 @@ add_custom_command(TARGET editSceneEditor POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_BINARY_DIR}/lua-scripts"
"${CMAKE_CURRENT_BINARY_DIR}/lua-scripts"
# Overlay editScene-specific lua-scripts on top of main lua-scripts
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${LUA_SCRIPTS_EDITSCENE_SRC}"
"${CMAKE_CURRENT_BINARY_DIR}/lua-scripts"
COMMAND ${CMAKE_COMMAND} -E copy
"${CMAKE_CURRENT_SOURCE_DIR}/resources.cfg"
"${CMAKE_CURRENT_BINARY_DIR}/resources.cfg"
File diff suppressed because it is too large Load Diff
+27 -5
View File
@@ -24,12 +24,14 @@ class ProceduralMaterialSystem;
class ProceduralMeshSystem;
class CharacterSlotSystem;
class AnimationTreeSystem;
class HairPhysicsSystem;
class BehaviorTreeSystem;
class NavMeshSystem;
class CharacterSystem;
class CellGridSystem;
class RoomLayoutSystem;
class StartupMenuSystem;
class PauseMenuSystem;
class DialogueSystem;
class PlayerControllerSystem;
class BuoyancySystem;
@@ -44,6 +46,8 @@ class GoapPlannerSystem;
class ActuatorSystem;
class EventHandlerSystem;
class ItemSystem;
class CharacterClassSystem;
class PregnancySystem;
class EditorApp;
/**
@@ -57,8 +61,10 @@ struct GameInputState {
bool shift = false;
bool e = false;
bool f = false;
bool i = false;
bool ePressed = false;
bool fPressed = false;
bool iPressed = false;
float mouseDeltaX = 0.0f;
float mouseDeltaY = 0.0f;
bool mouseMoved = false;
@@ -70,6 +76,7 @@ struct GameInputState {
mouseDeltaY = 0.0f;
ePressed = false;
fPressed = false;
iPressed = false;
}
};
@@ -158,6 +165,11 @@ public:
void startNewGame(const Ogre::String &scenePath);
void clearScene();
// Save / Load
void saveGame(const std::string &slotPath,
const std::string &slotName);
void loadGame(const std::string &slotPath);
// Input access
GameInputState &getGameInputState()
{
@@ -190,10 +202,8 @@ public:
{
return m_startupMenuSystem.get();
}
DialogueSystem *getDialogueSystem() const
{
return m_dialogueSystem.get();
}
PauseMenuSystem *getPauseMenuSystem() const;
DialogueSystem *getDialogueSystem() const;
ActuatorSystem *getActuatorSystem() const
{
return m_actuatorSystem.get();
@@ -202,6 +212,14 @@ public:
{
return m_eventHandlerSystem.get();
}
CharacterClassSystem *getCharacterClassSystem() const
{
return m_characterClassSystem.get();
}
PregnancySystem *getPregnancySystem() const
{
return m_pregnancySystem.get();
}
Ogre::ImGuiOverlay *getImGuiOverlay() const
{
return m_imguiOverlay;
@@ -235,6 +253,7 @@ private:
std::unique_ptr<ProceduralMeshSystem> m_proceduralMeshSystem;
std::unique_ptr<CharacterSlotSystem> m_characterSlotSystem;
std::unique_ptr<AnimationTreeSystem> m_animationTreeSystem;
std::unique_ptr<HairPhysicsSystem> m_hairPhysicsSystem;
std::unique_ptr<BehaviorTreeSystem> m_behaviorTreeSystem;
std::unique_ptr<NavMeshSystem> m_navMeshSystem;
std::unique_ptr<CharacterSystem> m_characterSystem;
@@ -248,10 +267,11 @@ 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;
std::unique_ptr<PregnancySystem> m_pregnancySystem;
// Game systems
std::unique_ptr<StartupMenuSystem> m_startupMenuSystem;
std::unique_ptr<DialogueSystem> m_dialogueSystem;
std::unique_ptr<PlayerControllerSystem> m_playerControllerSystem;
// State
@@ -261,6 +281,8 @@ private:
GameInputState m_gameInput;
bool m_setupComplete = false;
bool m_debugBuoyancy = false;
float m_playTime = 0.0f;
std::string m_currentBaseScene;
// Lua scripting
editScene::LuaState m_lua;
@@ -3,6 +3,8 @@
#pragma once
#include <Ogre.h>
#include <Jolt/Jolt.h>
#include <Jolt/Physics/Body/BodyID.h>
/**
* Character physics component
@@ -48,6 +50,39 @@ struct CharacterComponent {
float floorCheckDistance = 2.0f;
bool useGravity = true;
/* Per-character collision group. Body = subgroup 0, head = subgroup 1,
* hair joints = 2+. Runtime only regenerated when character is rebuilt. */
uint32_t collisionGroupId = 0;
/* Separate head collider body for hair physics. Runtime only. */
JPH::BodyID headColliderBody;
/* Head sphere radius for hair physics collision. */
float headRadius = 0.13f;
/* Vertical offset from the character base to the head sphere centre.
* If zero, defaults to getTotalHeight() - headRadius. */
float headOffsetY = 0.0f;
/* Bone that drives the head collider. If empty, falls back to the
* static offset above. */
Ogre::String headBoneName = "mixamorig:Head";
/* Optional chest/upper-body collider to stop long hair from clipping
* through the torso. Runtime only. */
JPH::BodyID chestColliderBody;
/* Chest box half-extents. If any axis is zero, no chest collider is
* created. */
Ogre::Vector3 chestHalfExtents = Ogre::Vector3(0.2f, 0.12f, 0.12f);
/* Vertical offset along the world Y axis applied to the chest bone
* position when placing the chest collider. */
float chestOffsetY = 0.0f;
/* Bone that drives the chest collider. */
Ogre::String chestBoneName = "mixamorig:Spine2";
float getHalfHeight() const
{
return height * 0.5f;
@@ -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,18 @@
#ifndef EDITSCENE_CHARACTERIDENTITY_HPP
#define EDITSCENE_CHARACTERIDENTITY_HPP
#pragma once
#include <cstdint>
/**
* Links a spawned entity to a global character registry entry.
*
* When a character entity is created from a prefab or manually,
* this component stores the persistent registry ID so the entity
* knows who it is in the social graph.
*/
struct CharacterIdentityComponent {
uint64_t registryId = 0;
};
#endif // EDITSCENE_CHARACTERIDENTITY_HPP
@@ -3,15 +3,38 @@
#pragma once
#include <Ogre.h>
#include <unordered_map>
#include <vector>
#include "AnimationTree.hpp"
/**
* Selection criteria for a single character slot.
* Layer 0 (nude base) can be selected via combo box (e.g. hair styles).
* Layer 1 and 2 are selected via combo boxes.
*/
struct SlotSelection {
Ogre::String layer0Mesh; // "none" or exact mesh name for layer 0
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.
* The "face" slot (or first available slot) serves as the master skeleton.
*
* Age is now stored in CharacterRegistry::CharacterRecord::age
* and should be retrieved from there when needed.
*/
struct CharacterSlotsComponent {
Ogre::String age = "adult";
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) */
@@ -20,11 +43,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) - Comma-separated list of choice labels
* "speaker" (string) - Optional speaker name
* "auto_progress" (int) - If 1, clicking anywhere progresses (no choices)
*
* Component state transitions:
* Idle -> Showing (on show() or event)
* Showing -> AwaitingChoice (if choices provided)
* Showing -> Idle (if no choices, on click progress)
* AwaitingChoice -> Idle (on choice selected)
*/
struct DialogueComponent {
/** Current state of the dialogue box */
enum class State {
Idle, ///< No dialogue active
Showing, ///< Text is being displayed
AwaitingChoice ///< Waiting for player to pick a choice
};
State state = State::Idle;
/** The narration text to display */
Ogre::String text;
/** Optional speaker name (displayed above the text) */
Ogre::String speaker;
/** Player choice labels (empty = no choices, click to progress) */
std::vector<Ogre::String> choices;
/** Font configuration */
Ogre::String fontName = "Jupiteroid-Regular.ttf";
float fontSize = 24.0f;
/** Speaker name font size (slightly smaller) */
float speakerFontSize = 20.0f;
/** Background opacity (0.0 - 1.0) */
float backgroundOpacity = 0.85f;
/** Height of the dialogue box as fraction of screen height (0.0 - 1.0) */
float boxHeightFraction = 0.25f;
/** Vertical position as fraction from top (0.0 = top, 0.75 = bottom quarter) */
float boxPositionFraction = 0.75f;
/** Whether the dialogue box is enabled (can be toggled) */
bool enabled = true;
/** Callback invoked when a choice is selected (choice index, 1-based) */
std::function<void(int)> onChoiceSelected;
/** Callback invoked when dialogue is dismissed (no choices mode) */
std::function<void()> onDismissed;
/** Callback invoked when dialogue starts showing */
std::function<void()> onShow;
/* --- API --- */
/** Show dialogue with given text and optional choices */
void show(const Ogre::String &narrationText,
const std::vector<Ogre::String> &choiceLabels = {},
const Ogre::String &speakerName = "")
{
text = narrationText;
choices = choiceLabels;
speaker = speakerName;
state = choices.empty() ? State::Showing :
State::AwaitingChoice;
if (onShow)
onShow();
}
/** Progress the dialogue (click-through when no choices) */
void progress()
{
if (state == State::Showing && choices.empty()) {
state = State::Idle;
if (onDismissed)
onDismissed();
}
}
/** Select a choice by 1-based index */
void selectChoice(int index)
{
if (state == State::AwaitingChoice && index >= 1 &&
index <= (int)choices.size()) {
state = State::Idle;
if (onChoiceSelected)
onChoiceSelected(index);
}
}
/** Check if dialogue is currently active */
bool isActive() const
{
return state != State::Idle;
}
/** Reset dialogue to idle state */
void reset()
{
state = State::Idle;
text.clear();
choices.clear();
speaker.clear();
}
};
#endif // EDITSCENE_DIALOGUE_COMPONENT_HPP
@@ -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
+18 -42
View File
@@ -8,41 +8,33 @@
#include <string>
#include <cstdint>
#include "../systems/ItemRegistry.hpp"
/**
* A single slot in an inventory.
* Stores a reference to an item entity (if the item is a world entity)
* or stores item data directly for items that exist only in inventory.
*/
struct InventorySlot {
// Flecs entity ID of the item (0 if slot is empty)
// Flecs entity ID of the item (0 if slot is empty or no world entity)
flecs::entity_t itemEntity = 0;
// Item data for items that exist only in inventory (no world entity)
Ogre::String itemId;
Ogre::String itemName;
Ogre::String itemType;
// Item registry key
std::string itemId;
// Stack size
int stackSize = 0;
int maxStackSize = 99;
float weight = 0.1f;
int value = 1;
Ogre::String useActionName;
bool isEmpty() const
{
return itemEntity == 0 && stackSize <= 0;
return itemId.empty() && itemEntity == 0;
}
void clear()
{
itemEntity = 0;
itemId.clear();
itemName.clear();
itemType.clear();
stackSize = 0;
maxStackSize = 99;
weight = 0.1f;
value = 1;
useActionName.clear();
}
};
@@ -52,9 +44,6 @@ struct InventorySlot {
* Attached to a character entity to hold items.
* Can also be attached to container entities (chests, barrels, etc.)
* to define their contents.
*
* The inventory stores items as InventorySlot entries, each of which
* may reference a world ItemComponent entity or hold item data directly.
*/
struct InventoryComponent {
// Maximum number of slots
@@ -70,12 +59,14 @@ struct InventoryComponent {
float maxWeight = 50.0f;
// Whether this inventory is a container (chest, barrel, etc.)
// Containers can be opened by characters to transfer items.
bool isContainer = false;
// Whether this inventory is currently open (for containers being browsed)
bool isOpen = false;
// Persistent ID for scene containers (empty for character inventories)
std::string containerId;
InventoryComponent() = default;
explicit InventoryComponent(int maxSlots_)
@@ -97,7 +88,7 @@ struct InventoryComponent {
}
/** Find a slot containing an item with the given itemId. */
int findItem(const Ogre::String &itemId) const
int findItem(const std::string &itemId) const
{
for (int i = 0; i < (int)slots.size(); i++) {
if (!slots[i].isEmpty() && slots[i].itemId == itemId)
@@ -106,17 +97,6 @@ struct InventoryComponent {
return -1;
}
/** Find a slot containing an item with the given itemName. */
int findItemByName(const Ogre::String &itemName) const
{
for (int i = 0; i < (int)slots.size(); i++) {
if (!slots[i].isEmpty() &&
slots[i].itemName == itemName)
return i;
}
return -1;
}
/** Count total number of items (sum of stack sizes). */
int countItems() const
{
@@ -129,7 +109,7 @@ struct InventoryComponent {
}
/** Count how many of a specific itemId are in the inventory. */
int countItem(const Ogre::String &itemId) const
int countItem(const std::string &itemId) const
{
int count = 0;
for (const auto &slot : slots) {
@@ -140,24 +120,20 @@ struct InventoryComponent {
}
/** Check if inventory has at least one of a specific itemId. */
bool hasItem(const Ogre::String &itemId) const
bool hasItem(const std::string &itemId) const
{
return findItem(itemId) >= 0;
}
/** Check if inventory has at least one of a specific itemName. */
bool hasItemByName(const Ogre::String &itemName) const
{
return findItemByName(itemName) >= 0;
}
/** Recalculate total weight. */
/** Recalculate total weight from registry. */
void recalculateWeight()
{
totalWeight = 0.0f;
for (const auto &slot : slots) {
if (!slot.isEmpty())
totalWeight += slot.weight * slot.stackSize;
totalWeight +=
ItemRegistry::getSingleton().getWeight(slot.itemId) *
slot.stackSize;
}
}
};
+19 -33
View File
@@ -4,54 +4,40 @@
#include <Ogre.h>
#include <string>
#include <vector>
/**
* Item definition component.
* Item reference component.
*
* Attached to a world entity that represents a pickable item.
* The ActuatorSystem detects items (entities with ItemComponent)
* and shows "E - Pick up [ItemName]" prompts to the player.
* Attached to a world entity that represents a pickable / interactable item.
* All item properties (name, type, weight, etc.) are stored in
* the ItemRegistry singleton and looked up by itemId.
*
* Items can also be placed in containers (chests, etc.) which
* have an InventoryComponent.
*
* For AI characters, behavior tree nodes (hasItem, pickupItem,
* dropItem, useItem, addItemToInventory) provide inventory access.
* action: Optional GOAP action name to execute on interact (E key).
* If empty, the item is picked up into inventory.
* instanceId: Optional unique ID for global state tracking (save/load).
* disabled: Set to true when the item has been picked up / consumed.
*/
struct ItemComponent {
// Display name of the item (e.g. "Apple", "Sword", "Key")
Ogre::String itemName = "Item";
// Item type for categorization (e.g. "food", "weapon", "key", "quest")
Ogre::String itemType = "misc";
// Unique identifier for this item definition
// Multiple entities can share the same itemId (e.g. multiple coins)
// Registry key for this item definition
Ogre::String itemId;
// Stack size: how many of this item are in this stack
// Stack size for this world entity
int stackSize = 1;
// Maximum stack size (0 = no stacking)
int maxStackSize = 99;
// Optional action to execute on interact (instead of pickup)
Ogre::String action;
// Weight per unit (for encumbrance calculations)
float weight = 0.1f;
// Unique instance ID for global state tracking
Ogre::String instanceId;
// Value (for trading)
int value = 1;
// Name of the GOAP action to execute when "using" this item
// (e.g. "eat", "equip", "read"). Empty = no use action.
Ogre::String useActionName;
// True when item has been picked up or consumed
bool disabled = false;
ItemComponent() = default;
explicit ItemComponent(const Ogre::String &name,
const Ogre::String &type = "misc")
: itemName(name)
, itemType(type)
explicit ItemComponent(const Ogre::String &id, int stack = 1)
: itemId(id)
, stackSize(stack)
{
}
};
@@ -0,0 +1,14 @@
#ifndef EDITSCENE_RUNTIMEMARKER_HPP
#define EDITSCENE_RUNTIMEMARKER_HPP
#pragma once
/**
* Marker component for entities created at runtime (not in the editor).
* Used by the save/load system to distinguish runtime-spawned entities
* from editor-placed scene entities.
*/
struct RuntimeMarkerComponent {
// Empty marker component
};
#endif // EDITSCENE_RUNTIMEMARKER_HPP
+282
View File
@@ -0,0 +1,282 @@
# Item, Inventory & Container Lua API
This document describes the Lua APIs for managing item definitions, inventories,
and persistent container state in the editScene editor.
---
## Overview
The item system is split into three namespaces:
| Namespace | Purpose |
|-----------|---------|
| `ecs.items` | Register and query item **definitions** (global registry) |
| `ecs.inventory` | Add, remove, and query items in an entity's **inventory** |
| `ecs.container` | Save/load persistent **container state** by `containerId` |
Item data lives in a single authoritative registry (`ItemRegistry`). Entities
only store `itemId` + `stackSize`, so changing a definition (name, weight, etc.)
propagates automatically to all instances.
---
## `ecs.items` — Item Definition Registry
### `ecs.items.register(itemId, definition)`
Register a new item type or overwrite an existing one.
**Parameters:**
- `itemId` *(string)* — Unique identifier, e.g. `"potion_health"`
- `definition` *(table)* — Item properties
**Definition fields:**
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `itemName` | string | `""` | Human-readable name |
| `itemType` | string | `"misc"` | Category: `consumable`, `weapon`, `armor`, `misc`, … |
| `maxStackSize` | int | `99` | How many fit in one inventory slot |
| `weight` | float | `0.1` | Weight per unit (for encumbrance) |
| `value` | int | `1` | Base trade value |
| `useActionName` | string | `""` | Action triggered on "use" |
| `unique` | bool | `false` | If `true`, only one may exist in the world |
**Example:**
```lua
ecs.items.register('potion_health', {
itemName = 'Health Potion',
itemType = 'consumable',
maxStackSize = 10,
weight = 0.5,
value = 25,
useActionName = 'drink_potion',
unique = false
})
```
---
### `ecs.items.find(itemId)` → table | nil
Look up a registered item definition.
**Returns:**
- Table with all definition fields (see above), or `nil` if not found.
**Example:**
```lua
local def = ecs.items.find('potion_health')
if def then
print(def.itemName, 'weight:', def.weight)
end
```
---
### `ecs.items.list()` → array of strings
Get a list of all registered `itemId`s.
**Example:**
```lua
local allItems = ecs.items.list()
for _, id in ipairs(allItems) do
print(id)
end
```
---
### `ecs.items.is_unique(itemId)` → bool
Check whether an item is marked as unique.
**Example:**
```lua
if ecs.items.is_unique('amulet_legendary') then
print('There can be only one!')
end
```
---
## `ecs.inventory` — Entity Inventory Operations
All functions operate on a specific entity that has an `InventoryComponent`.
The entity is referenced by its numeric ECS id (the same id used with
`ecs.create_entity()`).
### `ecs.inventory.add(entityId, itemId, count)` → bool
Add `count` copies of an item to the entity's inventory.
**Parameters:**
- `entityId` *(number)* — ECS entity id
- `itemId` *(string)* — Registered item id
- `count` *(number)* — Quantity to add (default 1 if omitted in C++, but Lua
wrapper requires all 3 arguments)
**Returns:** `true` if the operation succeeded, `false` if the inventory is
full, over weight limit, or the item is unique and already exists elsewhere.
**Example:**
```lua
local player = ecs.get_player_entity() -- or any entity id
ecs.inventory.add(player, 'potion_health', 5)
```
---
### `ecs.inventory.remove(entityId, itemId, count)` → int
Remove up to `count` copies of an item.
**Returns:** The number of items actually removed.
**Example:**
```lua
local removed = ecs.inventory.remove(player, 'potion_health', 2)
print('Removed', removed, 'potions')
```
---
### `ecs.inventory.has(entityId, itemId)` → bool
Check if the inventory contains at least one of the given item.
**Example:**
```lua
if ecs.inventory.has(player, 'key_temple') then
ecs.event('unlock_temple_door')
end
```
---
### `ecs.inventory.count(entityId, itemId)` → int
Get the total stack size of an item across all slots.
**Example:**
```lua
local coins = ecs.inventory.count(player, 'gold_coin')
print('You have', coins, 'gold coins')
```
---
### `ecs.inventory.get_slots(entityId)` → array of slots
Get a snapshot of the inventory's non-empty slots.
**Returns:** Array of tables, each with:
- `itemId` *(string)*
- `stackSize` *(number)*
**Example:**
```lua
local slots = ecs.inventory.get_slots(player)
for _, slot in ipairs(slots) do
print(slot.itemId, 'x' .. slot.stackSize)
end
```
---
### `ecs.inventory.set_slots(entityId, slots)`
Overwrite the entire inventory with a new array of slots.
**Parameters:**
- `slots` *(array)* — Each element is `{ itemId = "...", stackSize = N }`
**Example:**
```lua
ecs.inventory.set_slots(chestEntity, {
{ itemId = 'gold_coin', stackSize = 50 },
{ itemId = 'iron_dagger', stackSize = 1 }
})
```
---
## `ecs.container` — Persistent Container State
Containers with a `containerId` in their `InventoryComponent` automatically
sync to the global `ContainerStateRegistry`. This lets chests, shops, and
loot crates retain their contents across scene reloads.
The `ecs.container` API exposes the same persistence layer directly so Lua
scripts can read or override container state.
### `ecs.container.get_state(containerId)` → array of slots
Load the persisted contents of a container.
**Returns:** Array of `{ itemId, stackSize }` tables. Empty array if no state
has been saved.
**Example:**
```lua
local loot = ecs.container.get_state('chest_village')
for _, slot in ipairs(loot) do
print('Chest contains', slot.itemId, 'x' .. slot.stackSize)
end
```
---
### `ecs.container.set_state(containerId, slots)`
Overwrite the persisted state of a container.
**Parameters:**
- `slots` *(array)*`{ itemId = "...", stackSize = N }`
**Example:**
```lua
ecs.container.set_state('chest_village', {
{ itemId = 'healing_herb', stackSize = 3 },
{ itemId = 'rusty_key', stackSize = 1 }
})
```
---
### `ecs.container.clear_state(containerId)`
Delete the persisted state for a container, so it will revert to its scene
defaults on next load.
**Example:**
```lua
ecs.container.clear_state('chest_village')
```
---
## Persistence
| File | Content | Auto-save? |
|------|---------|------------|
| `items.json` | All item definitions | Yes, on every mutation |
| `container_state.json` | Container slot overrides | Yes, on every mutation |
Both files live in the working directory of the editor executable and are
loaded automatically on startup.
---
## Unique Items
When `unique = true` is set on an item definition, the engine rejects any
attempt to create a duplicate:
- `ecs.inventory.add` returns `false` if the item already exists in any
inventory or as a world entity.
- Scene deserialization skips unique items that are already present.
This is useful for quest keys, legendary artifacts, and one-off rewards.
@@ -0,0 +1,351 @@
# Save/Load System
This document describes the game-mode save/load system in the editScene editor.
---
## Overview
The save/load system persists the entire game state to JSON files. It is
designed for **game mode** (`--game` flag) and is triggered from the pause
menu or startup menu.
### Architecture
```
┌─────────────────────────────────────────────────────────┐
│ EditorApp │
│ saveGame(slotPath, slotName) │
│ loadGame(slotPath) │
├─────────────────────────────────────────────────────────┤
│ SaveLoadSystem │
│ (static utility: directory, file I/O, listing) │
├─────────────────────────────────────────────────────────┤
│ SaveLoadDialog │
│ (ImGui modal: slot selection UI) │
├─────────────────────────────────────────────────────────┤
│ SceneSerializer │
│ (serializes/deserializes ECS entities & components) │
├─────────────────────────────────────────────────────────┤
│ CharacterRegistry │ ContainerStateRegistry │
│ ItemStateRegistry │ LuaSaveLoadApi │
└─────────────────────────────────────────────────────────┘
```
### Save File Location
Saves are stored in an OS-dependent user data directory:
| Platform | Path |
|----------|------|
| Linux | `~/.local/share/World2/saves/` (or `$XDG_DATA_HOME/World2/saves/`) |
| Windows | `%APPDATA%/World2/saves/` |
| macOS | `~/Library/Application Support/World2/saves/` |
Each save is a single `.json` file named `save_NNN.json`.
---
## Save File Format (version 2.0)
```json
{
"version": "2.0",
"saveGame": {
"baseScene": "scene.json",
"timestamp": "2026-05-19 12:34:56",
"slotName": "My Save",
"playTime": 3600.0,
"playerCharacterId": 42
},
"characterRegistry": { ... },
"containerState": { ... },
"itemState": { ... },
"runtimeEntities": [ ... ],
"characterRuntimeData": { ... },
"luaData": { ... }
}
```
### Top-level fields
| Field | Type | Description |
|-------|------|-------------|
| `version` | string | Format version (`"2.0"`) |
| `saveGame` | object | Metadata (see below) |
| `characterRegistry` | object | Serialized `CharacterRegistry` (stats, skills, needs, levels, XP) |
| `containerState` | object | Serialized `ContainerStateRegistry` (chest/loot contents) |
| `itemState` | object | Serialized `ItemStateRegistry` (world item pickup state) |
| `runtimeEntities` | array | Runtime-spawned entities (dropped items, etc.) |
| `characterRuntimeData` | object | Runtime component overrides per character (inventory, GOAP state, animation state) |
| `luaData` | object | Data collected from Lua save callbacks |
### `saveGame` metadata
| Field | Type | Description |
|-------|------|-------------|
| `baseScene` | string | Path to the base scene file (e.g. `"scene.json"`) |
| `timestamp` | string | ISO-8601 timestamp of save |
| `slotName` | string | User-visible slot name |
| `playTime` | float | Accumulated play time in seconds |
| `playerCharacterId` | number | Registry ID of the player character |
---
## C++ API
### `EditorApp`
```cpp
// Save the current game state to a file.
// slotPath: Full path to the save file.
// slotName: Human-readable name for the save slot.
void EditorApp::saveGame(const std::string &slotPath,
const std::string &slotName);
// Load a game state from a file.
// slotPath: Full path to the save file.
void EditorApp::loadGame(const std::string &slotPath);
```
### `SaveLoadSystem` (static utility)
```cpp
// Get the OS-dependent save directory (creates it if missing).
static std::string SaveLoadSystem::getSaveDirectory();
// List all existing save files.
static std::vector<SaveInfo> SaveLoadSystem::listSaves();
// Generate a unique filename for a new save.
static std::string SaveLoadSystem::generateSaveFilename();
// Delete a save file.
static bool SaveLoadSystem::deleteSave(const std::string &path);
// Write a JSON object to a save file.
static bool SaveLoadSystem::writeSaveFile(
const std::string &path, const nlohmann::json &data);
// Read a JSON object from a save file.
static bool SaveLoadSystem::readSaveFile(
const std::string &path, nlohmann::json &outData);
// Get current ISO-8601 timestamp.
static std::string SaveLoadSystem::getCurrentTimestamp();
```
### `SaveLoadDialog` (ImGui modal)
```cpp
// Open the save/load dialog.
static void SaveLoadDialog::show(EditorApp *editorApp, Mode mode);
// Render the dialog every frame.
static void SaveLoadDialog::render(EditorApp *editorApp);
// Check if the dialog is open.
static bool SaveLoadDialog::isOpen();
// Close the dialog.
static void SaveLoadDialog::close();
```
### `SceneSerializer` (entity serialization)
```cpp
// Serialize a single entity (with all components) to JSON.
nlohmann::json SceneSerializer::serializeEntity(flecs::entity entity);
// Deserialize all components from JSON onto an existing entity.
void SceneSerializer::deserializeEntityComponents(
flecs::entity entity,
const nlohmann::json &json,
flecs::entity parent,
EditorUISystem *uiSystem,
bool createNew);
```
---
## Lua API
### `ecs.register_save_callback(name, function)`
Register a Lua function that will be called during save to collect custom data.
**Parameters:**
- `name` *(string)* — Unique identifier for this callback
- `function` *(function)* — Must return a Lua table (or nil)
**Example:**
```lua
ecs.register_save_callback('my_quest_data', function()
return {
current_quest = 'find_the_artifact',
quest_stage = 3,
npc_flags = { merchant_helped = true, guard_spoken = false }
}
end)
```
### `ecs.register_load_callback(name, function)`
Register a Lua function that will be called during load with previously saved data.
**Parameters:**
- `name` *(string)* — Must match the name used in `register_save_callback`
- `function` *(function)* — Receives one argument: the table that was returned by the save callback
**Example:**
```lua
ecs.register_load_callback('my_quest_data', function(data)
if data then
current_quest = data.current_quest
quest_stage = data.quest_stage
npc_flags = data.npc_flags
end
end)
```
---
## Events
The save/load system fires events on the `EventBus` at key points:
| Event | When |
|-------|------|
| `save_game_requested` | Before save data is collected |
| `game_saved` | After save file is written successfully |
| `load_game_requested` | Before load begins |
| `game_loaded` | After load completes and game state is restored |
Lua scripts can subscribe to these events:
```lua
ecs.subscribe_event('game_saved', function()
print('Game was saved!')
end)
ecs.subscribe_event('game_loaded', function()
print('Game was loaded!')
end)
```
---
## Save Flow
```
User clicks "Save" in pause menu
SaveLoadDialog::show() opens modal
User enters slot name, clicks Save
SaveLoadDialog::doSave()
EditorApp::saveGame(slotPath, slotName)
├── EventBus: "save_game_requested"
├── Sync character positions from SceneNodes
├── Identify player character registry ID
├── Serialize CharacterRegistry
├── Serialize ContainerStateRegistry
├── Serialize ItemStateRegistry
├── Serialize runtime entities (dropped items, etc.)
├── Serialize character runtime component overrides
├── Collect Lua save callback data
├── Write JSON file via SaveLoadSystem::writeSaveFile()
└── EventBus: "game_saved"
```
## Load Flow
```
User clicks "Load" in startup/pause menu
SaveLoadDialog::show() opens modal
User selects a save slot, clicks Load
SaveLoadDialog::doLoad()
EditorApp::loadGame(slotPath)
├── EventBus: "load_game_requested"
├── Read JSON file via SaveLoadSystem::readSaveFile()
├── Clear current scene
├── Load base scene (scene.json)
├── Resolve prefab instances
├── Restore CharacterRegistry
├── Restore ContainerStateRegistry
├── Restore ItemStateRegistry
├── Destroy all existing character entities
├── Spawn persistent characters from registry
├── Restore character runtime component overrides
├── Restore runtime entities (match by instanceId or name)
├── Apply Lua load callback data
├── Set game state to Playing
└── EventBus: "game_loaded"
```
---
## Registries
### CharacterRegistry
Persists character stats, skills, needs, level, XP, class, position, and
rotation. Auto-saves to `character_registry.json` after every mutation.
### ContainerStateRegistry
Persists container slot overrides keyed by `containerId`. Auto-saves to
`container_state.json`. Used by chests, shops, and loot crates.
### ItemStateRegistry
Persists world item state (picked up / disabled) keyed by `instanceId`.
Auto-saves to `item_state.json`.
---
## Runtime Entities
Entities marked with `RuntimeMarkerComponent` are spawned at runtime (not
placed in the editor). These include:
- Dropped items
- Spawned NPCs
- Temporary objects
During save, runtime entities are serialized individually. During load, the
system tries to match them to existing scene entities by `instanceId` (for
items) or by `EntityNameComponent`. If no match is found, a new entity is
created.
---
## Character Runtime Data
Characters have two layers of data:
1. **Registry data** (persisted in `CharacterRegistry`): stats, skills,
needs, level, class, position.
2. **Runtime component overrides** (persisted in `characterRuntimeData`):
inventory contents, GOAP blackboard state, GOAP planner/runner state,
path following state, behavior tree state, animation state.
During save, the runtime overrides are extracted per character. During load,
characters are first spawned from the registry, then the overrides are
applied on top.
@@ -0,0 +1,262 @@
-- =============================================================================
-- Character Class Lua API Examples
-- =============================================================================
-- This file demonstrates how to use the ecs.character_class API for
-- querying character class definitions and managing per-entity character
-- stats, skills, needs, and resource pools.
--
-- The character class system provides:
-- - Database queries for class definitions, stats, skills, and needs
-- - Per-entity runtime stats (level, XP, stats, skills, needs)
-- - Resource pool management (health, mana, stamina, etc.)
-- =============================================================================
-- =============================================================================
-- Database Queries
-- =============================================================================
-- List all registered class names
local class_names = ecs.character_class.get_class_names()
print("Registered classes (" .. #class_names .. "):")
for _, name in ipairs(class_names) do
print(" - " .. name)
end
-- List all stat names
local stat_names = ecs.character_class.get_stat_names()
print("Stats (" .. #stat_names .. "):")
for _, name in ipairs(stat_names) do
print(" - " .. name)
end
-- List all skill names
local skill_names = ecs.character_class.get_skill_names()
print("Skills (" .. #skill_names .. "):")
for _, name in ipairs(skill_names) do
print(" - " .. name)
end
-- List all need names
local need_names = ecs.character_class.get_need_names()
print("Needs (" .. #need_names .. "):")
for _, name in ipairs(need_names) do
print(" - " .. name)
end
-- =============================================================================
-- Querying Class Definitions
-- =============================================================================
-- Get a specific class definition
local warrior_class = ecs.character_class.get_class("warrior")
if warrior_class then
print("Warrior class:")
print(" Name: " .. warrior_class.name)
print(" Description: " .. warrior_class.description)
print(" Primary stats (" .. #warrior_class.primary_stats .. "):")
for _, stat in ipairs(warrior_class.primary_stats) do
print(" - " .. stat)
end
else
print("Warrior class not found (stub database may be empty)")
end
-- =============================================================================
-- Stat Kind Queries
-- =============================================================================
-- Check the kind of a stat (attribute vs resource_pool)
local strength_kind = ecs.character_class.get_stat_kind("strength")
print("Strength stat kind: " .. strength_kind)
local health_kind = ecs.character_class.get_stat_kind("health")
print("Health stat kind: " .. health_kind)
-- Unknown stat returns "unknown"
local unknown_kind = ecs.character_class.get_stat_kind("nonexistent")
print("Unknown stat kind: " .. unknown_kind)
-- =============================================================================
-- Per-Entity Character Stats
-- =============================================================================
-- Create a player entity with character identity
local player = ecs.create_entity()
ecs.set_entity_name(player, "hero")
ecs.set_component(player, "CharacterIdentity", {
registryId = 1
})
print("Created player entity (ID: " .. player .. ")")
-- Get character level
local level = ecs.character_class.get_level(player)
print("Player level: " .. level)
-- Get current XP
local xp = ecs.character_class.get_xp(player)
print("Player XP: " .. xp)
-- Add XP to the player
local xp_added = ecs.character_class.add_xp(player, 150)
print("XP added: " .. tostring(xp_added))
-- Verify XP was added
local new_xp = ecs.character_class.get_xp(player)
print("Player XP after adding: " .. new_xp)
-- =============================================================================
-- Stat, Skill, and Need Queries
-- =============================================================================
-- Get a specific stat value
local strength = ecs.character_class.get_stat(player, "strength")
print("Player strength: " .. strength)
-- Get a specific skill value
local swordsmanship = ecs.character_class.get_skill(player, "swordsmanship")
print("Player swordsmanship: " .. swordsmanship)
-- Get a specific need value
local hunger = ecs.character_class.get_need(player, "hunger")
print("Player hunger: " .. hunger)
-- Get available attribute/skill points
local available_points = ecs.character_class.get_available_points(player)
print("Available points: " .. available_points)
-- =============================================================================
-- Setting Needs
-- =============================================================================
-- Set a need value (e.g., after eating)
ecs.character_class.set_need(player, "hunger", 0)
print("Set hunger to 0")
-- Verify the change
local new_hunger = ecs.character_class.get_need(player, "hunger")
print("Hunger after setting: " .. new_hunger)
-- =============================================================================
-- Resource Pool Management
-- =============================================================================
-- Get current pool value (e.g., health)
local current_health = ecs.character_class.get_pool_current(player, "health")
print("Current health: " .. current_health)
-- Get maximum pool value
local max_health = ecs.character_class.get_pool_max(player, "health")
print("Max health: " .. max_health)
-- Set current pool value (e.g., after taking damage)
local pool_set = ecs.character_class.set_pool_current(player, "health", 75)
print("Health set to 75: " .. tostring(pool_set))
-- Verify the change
local new_health = ecs.character_class.get_pool_current(player, "health")
print("Health after setting: " .. new_health)
-- =============================================================================
-- Practical: Character Level Up
-- =============================================================================
function level_up(entity, xp_gained)
print("=== Level Up ===")
-- Add XP
ecs.character_class.add_xp(entity, xp_gained)
-- Get updated level
local new_level = ecs.character_class.get_level(entity)
print("New level: " .. new_level)
-- Get available points
local points = ecs.character_class.get_available_points(entity)
print("Points to spend: " .. points)
-- Increase a stat
local current_str = ecs.character_class.get_stat(entity, "strength")
print("Strength was: " .. current_str)
-- Note: Stats are typically increased via the character system,
-- not directly through this API. This example shows querying.
-- Restore health on level up
local max_hp = ecs.character_class.get_pool_max(entity, "health")
ecs.character_class.set_pool_current(entity, "health", max_hp)
print("Health restored to max: " .. max_hp)
print("=== Level Up Complete ===")
end
level_up(player, 500)
-- =============================================================================
-- Practical: Character Status Report
-- =============================================================================
function print_character_status(entity)
print("=== Character Status ===")
print("Level: " .. ecs.character_class.get_level(entity))
print("XP: " .. ecs.character_class.get_xp(entity))
print("Available Points: " .. ecs.character_class.get_available_points(entity))
print("Stats:")
for _, name in ipairs(stat_names) do
local val = ecs.character_class.get_stat(entity, name)
local kind = ecs.character_class.get_stat_kind(name)
if kind == "resource_pool" then
local current = ecs.character_class.get_pool_current(entity, name)
local max = ecs.character_class.get_pool_max(entity, name)
print(" " .. name .. ": " .. current .. "/" .. max)
else
print(" " .. name .. ": " .. val)
end
end
print("Skills:")
for _, name in ipairs(skill_names) do
local val = ecs.character_class.get_skill(entity, name)
print(" " .. name .. ": " .. val)
end
print("Needs:")
for _, name in ipairs(need_names) do
local val = ecs.character_class.get_need(entity, name)
print(" " .. name .. ": " .. val)
end
print("=== End Status ===")
end
print_character_status(player)
-- =============================================================================
-- API Reference
-- =============================================================================
--
-- Database Queries:
-- ecs.character_class.get_class_names() -> table of strings
-- ecs.character_class.get_stat_names() -> table of strings
-- ecs.character_class.get_skill_names() -> table of strings
-- ecs.character_class.get_need_names() -> table of strings
-- ecs.character_class.get_class(name) -> table or nil
-- Returns { name, description, primary_stats }
-- ecs.character_class.get_stat_kind(name) -> string
-- Returns "attribute", "resource_pool", or "unknown"
--
-- Per-Entity Runtime API:
-- ecs.character_class.get_level(entity_id) -> int
-- ecs.character_class.get_xp(entity_id) -> int
-- ecs.character_class.add_xp(entity_id, amount) -> bool
-- ecs.character_class.get_stat(entity_id, stat_name) -> int
-- ecs.character_class.get_skill(entity_id, skill_name) -> int
-- ecs.character_class.get_need(entity_id, need_name) -> int
-- ecs.character_class.get_available_points(entity_id) -> int
-- ecs.character_class.set_need(entity_id, need_name, value) -> nil
-- ecs.character_class.get_pool_current(entity_id, pool_name) -> int
-- ecs.character_class.get_pool_max(entity_id, pool_name) -> int
-- ecs.character_class.set_pool_current(entity_id, pool_name, value) -> bool
-- =============================================================================
print("Character class examples completed successfully!")
@@ -0,0 +1,284 @@
-- =============================================================================
-- Character Lua API Examples
-- =============================================================================
-- This file demonstrates how to use the ecs.character API for managing
-- character records in the CharacterRegistry.
--
-- The character system provides:
-- - Character creation, deletion, and lookup
-- - Spawning/despawning characters as ECS entities
-- - Pregnancy management (conceive, abort, check progress)
-- - Lineage tracking (parents, children, create_child)
-- - Name management
-- =============================================================================
-- =============================================================================
-- Character Creation
-- =============================================================================
-- Create a character with first name, last name, template path, and persistence
local hero = ecs.character.create("Arthur", "Pendragon", "characters/knight", true)
print("Created hero character (ID: " .. hero .. ")")
-- Create a non-persistent character (e.g., a temporary NPC)
local npc = ecs.character.create("Merlin", "the Wise", "characters/wizard", false)
print("Created NPC character (ID: " .. npc .. ")")
-- Create a character without a template path
local villager = ecs.character.create("John", "Smith", "", true)
print("Created villager character (ID: " .. villager .. ")")
-- =============================================================================
-- Character Lookup
-- =============================================================================
-- Find a character by ID
local found = ecs.character.find(hero)
if found then
print("Found character:")
print(" ID: " .. found.id)
print(" Name: " .. found.firstName .. " " .. found.lastName)
print(" Class: " .. found.className)
print(" Level: " .. found.level)
print(" Age: " .. found.ageYears)
print(" Sex: " .. found.sex)
print(" Persistent: " .. tostring(found.persistent))
print(" Pregnant by: " .. found.pregnantByFatherId)
print(" Pregnancy progress: " .. found.pregnancyProgress)
print(" Pregnancy max progress: " .. found.pregnancyMaxProgress)
end
-- Find a non-existent character returns nil
local missing = ecs.character.find(99999)
print("Non-existent character: " .. tostring(missing))
-- =============================================================================
-- Listing All Characters
-- =============================================================================
-- Get all character IDs
local all_chars = ecs.character.get_all()
print("All characters (" .. #all_chars .. "):")
for _, id in ipairs(all_chars) do
local c = ecs.character.find(id)
if c then
print(" [" .. id .. "] " .. c.firstName .. " " .. c.lastName)
end
end
-- =============================================================================
-- Character Name Management
-- =============================================================================
-- Change a character's name
ecs.character.set_name(hero, "Arthur", "the Great")
print("Renamed hero")
-- Verify the name change
local renamed = ecs.character.find(hero)
print("New name: " .. renamed.firstName .. " " .. renamed.lastName)
-- =============================================================================
-- Spawning and Despawning
-- =============================================================================
-- Spawn a character as an ECS entity (returns entity ID)
local entity_id = ecs.character.spawn(hero)
if entity_id then
print("Spawned hero as entity (ID: " .. entity_id .. ")")
else
print("Failed to spawn hero")
end
-- Check if a character is spawned
local is_spawned = ecs.character.is_spawned(hero)
print("Hero is spawned: " .. tostring(is_spawned))
-- Despawn a character
local despawned = ecs.character.despawn(hero)
print("Hero despawned: " .. tostring(despawned))
-- Verify despawn
local still_spawned = ecs.character.is_spawned(hero)
print("Hero still spawned: " .. tostring(still_spawned))
-- =============================================================================
-- Pregnancy Management
-- =============================================================================
-- Create two characters for pregnancy demo
local mother = ecs.character.create("Guinevere", "Pendragon", "", true)
local father = ecs.character.create("Lancelot", "du Lac", "", true)
print("Created mother (ID: " .. mother .. ") and father (ID: " .. father .. ")")
-- Conceive a child
local conceived = ecs.character.conceive(mother, father)
print("Conceived: " .. tostring(conceived))
-- Check if pregnant
local pregnant = ecs.character.is_pregnant(mother)
print("Is pregnant: " .. tostring(pregnant))
-- Get pregnancy progress
local progress = ecs.character.get_pregnancy_progress(mother)
if progress then
print("Pregnancy progress:")
print(" Progress: " .. progress.progress)
print(" Max progress: " .. progress.maxProgress)
print(" Ratio: " .. progress.ratio)
end
-- Abort pregnancy
ecs.character.abort_pregnancy(mother)
print("Pregnancy aborted")
-- Verify abortion
local still_pregnant = ecs.character.is_pregnant(mother)
print("Still pregnant: " .. tostring(still_pregnant))
-- =============================================================================
-- Lineage: Creating Children
-- =============================================================================
-- Create a child from two parents
local child = ecs.character.create_child(mother, father)
print("Created child (ID: " .. child .. ")")
-- Get the child's info
local child_info = ecs.character.find(child)
print("Child name: " .. child_info.firstName .. " " .. child_info.lastName)
-- Get parents of the child
local parents = ecs.character.get_parents(child)
print("Parents of child (" .. #parents .. "):")
for _, parent_id in ipairs(parents) do
local p = ecs.character.find(parent_id)
if p then
print(" [" .. parent_id .. "] " .. p.firstName .. " " .. p.lastName)
end
end
-- Get children of a parent
local children = ecs.character.get_children(mother)
print("Children of mother (" .. #children .. "):")
for _, child_id in ipairs(children) do
local c = ecs.character.find(child_id)
if c then
print(" [" .. child_id .. "] " .. c.firstName .. " " .. c.lastName)
end
end
-- =============================================================================
-- Practical: Family Tree
-- =============================================================================
function print_family_tree(character_id, indent)
indent = indent or ""
local c = ecs.character.find(character_id)
if not c then
print(indent .. "[Unknown character]")
return
end
print(indent .. c.firstName .. " " .. c.lastName .. " (ID: " .. c.id .. ")")
-- Print children
local kids = ecs.character.get_children(character_id)
for _, kid_id in ipairs(kids) do
print_family_tree(kid_id, indent .. " ")
end
end
print("=== Family Tree ===")
print_family_tree(mother)
-- =============================================================================
-- Practical: Character Lifecycle
-- =============================================================================
function character_lifecycle_demo()
print("=== Character Lifecycle Demo ===")
-- 1. Create
local sim = ecs.character.create("Sim", "One", "", true)
print("1. Created character: " .. sim)
-- 2. Spawn
local eid = ecs.character.spawn(sim)
print("2. Spawned as entity: " .. tostring(eid))
-- 3. Rename
ecs.character.set_name(sim, "Simantha", "One")
print("3. Renamed to: Simantha One")
-- 4. Despawn
ecs.character.despawn(sim)
print("4. Despawned")
-- 5. Delete
ecs.character.delete(sim)
print("5. Deleted")
-- Verify deletion
local gone = ecs.character.find(sim)
print("6. After deletion: " .. tostring(gone))
print("=== Lifecycle Demo Complete ===")
end
character_lifecycle_demo()
-- =============================================================================
-- API Reference
-- =============================================================================
--
-- ecs.character.create(firstName, lastName, templatePath, persistent) -> int
-- Creates a new character record. Returns the character ID.
--
-- ecs.character.delete(id) -> nil
-- Deletes a character record.
--
-- ecs.character.find(id) -> table or nil
-- Returns character info: id, firstName, lastName, className, level,
-- ageYears, sex, persistent, pregnantByFatherId, pregnancyProgress,
-- pregnancyMaxProgress.
--
-- ecs.character.get_all() -> table of ints
-- Returns all character IDs.
--
-- ecs.character.set_name(id, firstName, lastName) -> nil
-- Updates a character's name.
--
-- ecs.character.spawn(id) -> int or nil
-- Spawns the character as an ECS entity. Returns the entity ID.
--
-- ecs.character.despawn(id) -> bool
-- Despawns the character's entity.
--
-- ecs.character.is_spawned(id) -> bool
-- Returns true if the character is currently spawned.
--
-- ecs.character.conceive(motherId, fatherId) -> bool
-- Initiates pregnancy for the mother.
--
-- ecs.character.abort_pregnancy(motherId) -> nil
-- Aborts the mother's pregnancy.
--
-- ecs.character.is_pregnant(motherId) -> bool
-- Returns true if the mother is pregnant.
--
-- ecs.character.get_pregnancy_progress(motherId) -> table or nil
-- Returns { progress, maxProgress, ratio } or nil if not pregnant.
--
-- ecs.character.create_child(parentA, parentB) -> int
-- Creates a child character from two parents.
--
-- ecs.character.get_parents(childId) -> table of ints
-- Returns the parent IDs of a child.
--
-- ecs.character.get_children(parentId) -> table of ints
-- Returns the child IDs of a parent.
-- =============================================================================
print("Character examples completed successfully!")
@@ -0,0 +1,199 @@
-- =============================================================================
-- Container State Lua API Examples
-- =============================================================================
-- This file demonstrates how to manage persistent container states using
-- the ecs.container API. Container states are stored in the
-- ContainerStateRegistry singleton, separate from entity inventories.
--
-- Use cases:
-- - Persistent chests that remember their contents across sessions
-- - Shop inventories that reset on a timer
-- - Quest containers that change state based on story progress
-- - Lootable containers that can be emptied permanently
-- =============================================================================
-- =============================================================================
-- Setting Container State
-- =============================================================================
-- Define the contents of a treasure chest
ecs.container.set_state("treasure_chest_001", {
{ itemId = "sword_iron", stackSize = 1 },
{ itemId = "potion_health", stackSize = 3 },
{ itemId = "gold_coin", stackSize = 100 }
})
print("Set state for treasure_chest_001")
-- Define a shop inventory
ecs.container.set_state("blacksmith_shop", {
{ itemId = "sword_iron", stackSize = 3 },
{ itemId = "bow_wood", stackSize = 2 },
{ itemId = "arrow", stackSize = 50 },
{ itemId = "potion_health", stackSize = 5 }
})
print("Set state for blacksmith_shop")
-- =============================================================================
-- Getting Container State
-- =============================================================================
-- Retrieve the contents of a container
local chest_contents = ecs.container.get_state("treasure_chest_001")
print("Treasure chest contents (" .. #chest_contents .. " items):")
for i, slot in ipairs(chest_contents) do
print(" [" .. i .. "] " .. slot.itemId .. " x" .. slot.stackSize)
end
-- =============================================================================
-- Updating Container State
-- =============================================================================
-- Simulate looting the chest: remove items and update state
local function loot_container(container_id, item_id, count)
local contents = ecs.container.get_state(container_id)
local remaining = count
-- Build new contents minus the looted items
local new_contents = {}
for _, slot in ipairs(contents) do
if slot.itemId == item_id and remaining > 0 then
local to_remove = math.min(remaining, slot.stackSize)
slot.stackSize = slot.stackSize - to_remove
remaining = remaining - to_remove
end
if slot.stackSize > 0 then
table.insert(new_contents, slot)
end
end
ecs.container.set_state(container_id, new_contents)
print("Looted " .. (count - remaining) .. " " .. item_id .. " from " .. container_id)
return count - remaining
end
-- Player loots 2 health potions from the chest
loot_container("treasure_chest_001", "potion_health", 2)
-- Check what's left
local remaining = ecs.container.get_state("treasure_chest_001")
print("Remaining in treasure_chest_001:")
for i, slot in ipairs(remaining) do
print(" [" .. i .. "] " .. slot.itemId .. " x" .. slot.stackSize)
end
-- =============================================================================
-- Clearing Container State
-- =============================================================================
-- Clear a container's state (e.g., after it's been fully looted)
ecs.container.clear_state("treasure_chest_001")
print("Cleared state for treasure_chest_001")
-- Verify it's empty
local empty = ecs.container.get_state("treasure_chest_001")
print("After clear, contents count: " .. #empty)
-- =============================================================================
-- Practical: Shop Restocking
-- =============================================================================
-- Restock a shop's inventory (reset to initial state)
function restock_shop(shop_id)
local stock = {
blacksmith_shop = {
{ itemId = "sword_iron", stackSize = 3 },
{ itemId = "bow_wood", stackSize = 2 },
{ itemId = "arrow", stackSize = 50 },
{ itemId = "potion_health", stackSize = 5 }
},
alchemist_shop = {
{ itemId = "potion_health", stackSize = 10 },
{ itemId = "potion_stamina", stackSize = 10 }
}
}
if stock[shop_id] then
ecs.container.set_state(shop_id, stock[shop_id])
print("Restocked " .. shop_id)
else
print("Unknown shop: " .. shop_id)
end
end
-- Simulate a purchase: remove items from shop
local function buy_from_shop(shop_id, item_id, count)
local contents = ecs.container.get_state(shop_id)
local available = 0
for _, slot in ipairs(contents) do
if slot.itemId == item_id then
available = slot.stackSize
break
end
end
if available >= count then
loot_container(shop_id, item_id, count)
print("Purchased " .. count .. " " .. item_id .. " from " .. shop_id)
return true
else
print("Not enough stock in " .. shop_id .. " (have " .. available .. ", need " .. count .. ")")
return false
end
end
-- Player buys from blacksmith
buy_from_shop("blacksmith_shop", "sword_iron", 1)
buy_from_shop("blacksmith_shop", "arrow", 10)
-- Restock the shop
restock_shop("blacksmith_shop")
-- =============================================================================
-- Practical: Quest Container with Conditional State
-- =============================================================================
-- A quest container that changes based on story progress
function setup_quest_container(quest_stage)
if quest_stage == "not_started" then
ecs.container.set_state("ancient_tomb", {
{ itemId = "potion_health", stackSize = 2 },
{ itemId = "gold_coin", stackSize = 50 }
})
elseif quest_stage == "in_progress" then
ecs.container.set_state("ancient_tomb", {
{ itemId = "potion_health", stackSize = 2 },
{ itemId = "gold_coin", stackSize = 50 },
{ itemId = "amulet_legendary", stackSize = 1 }
})
elseif quest_stage == "completed" then
ecs.container.clear_state("ancient_tomb")
end
print("Quest container 'ancient_tomb' set to stage: " .. quest_stage)
end
setup_quest_container("not_started")
setup_quest_container("in_progress")
setup_quest_container("completed")
-- =============================================================================
-- API Reference
-- =============================================================================
--
-- ecs.container.set_state(container_id, slots_table) -> nil
-- Sets the persistent state of a container.
-- Each slot: { itemId = "...", stackSize = N }
--
-- ecs.container.get_state(container_id) -> table of { itemId, stackSize }
-- Returns the current state of a container.
--
-- ecs.container.clear_state(container_id) -> nil
-- Clears all items from a container's state.
--
-- Container state is separate from entity inventories (InventoryComponent).
-- Use containers for persistent world objects like chests, shops, and
-- quest lootables that need to remember their state across sessions.
-- =============================================================================
print("Container examples completed successfully!")
@@ -4,43 +4,17 @@
-- 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
-- "speaker" (string) - Optional speaker name (shown above text)
-- "choices" (string) - Comma-separated choice labels (optional)
-- "auto_progress" (int) - If 1, click anywhere progresses (no choices)
-- "choices" (table) - Array of choice label strings (optional)
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 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.
@@ -53,21 +27,21 @@ 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 (comma-separated), the dialogue box shows
-- When "choices" is provided as a table, the dialogue box shows
-- buttons instead of click-to-progress. The player must pick one.
ecs.send_event("dialogue_show", {
text = "Where would you like to go?",
speaker = "Guide",
choices = "The Forest,The Village,The Mountains"
choices = { "The Forest", "The Village", "The Mountains" }
})
print("Sent dialogue with choices")
-- ---------------------------------------------------------------------------
-- 4. Show dialogue without a speaker name
-- 3. Show dialogue without a speaker name
-- ---------------------------------------------------------------------------
ecs.send_event("dialogue_show", {
@@ -77,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", {
@@ -91,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" (comma-separated)
-- 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,220 +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
local choices_str = table.concat(choice_list, ",")
-- Update the component
ecs.set_field(npc_entity, "Dialogue", "text", base_text)
-- Show via event (which handles state transitions properly)
ecs.send_event("dialogue_show", {
text = base_text,
speaker = ecs.get_field(npc_entity, "Dialogue", "speaker"),
choices = choices_str
})
end
-- Example: Shop inventory as dialogue choices
local shop_items = { "Buy Sword (50 gold)", "Buy Shield (30 gold)", "Buy Potion (10 gold)", "Leave" }
show_dialogue_with_dynamic_choices(merchant, "What would you like to buy?", shop_items)
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 comma-separated string
-- - 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 ~= "" then
print("[Dialogue Log] Choices: " .. choices)
end
end)
print("Subscribed to dialogue_show events for logging (ID: " .. show_sub .. ")")
-- ---------------------------------------------------------------------------
-- 5. Example: Branching dialogue with choice handling
-- 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!")
@@ -0,0 +1,211 @@
-- =============================================================================
-- Dialogue Lua API Examples
-- =============================================================================
-- This file demonstrates how to use the ecs.dialogue API for managing
-- in-game dialogue boxes with text, choices, speaker names, and settings.
--
-- The dialogue system provides:
-- - Show/hide dialogue boxes with text and optional choices
-- - Speaker name display
-- - Choice selection and progression
-- - Settings management (font, opacity, positioning)
-- - Settings persistence (save/load to JSON)
-- =============================================================================
-- =============================================================================
-- Basic Dialogue Display
-- =============================================================================
-- Show a simple dialogue with text only
ecs.dialogue.show("Hello, traveler! Welcome to our village.")
print("Showed basic dialogue")
-- Show dialogue with speaker name
ecs.dialogue.show("I have a quest for you.", {}, "Elder Marcus")
print("Showed dialogue with speaker")
-- =============================================================================
-- Dialogue with Choices
-- =============================================================================
-- Show dialogue with multiple choices
ecs.dialogue.show("What would you like to do?", {
"Ask about the quest",
"Browse his wares",
"Say goodbye"
}, "Shopkeeper")
print("Showed dialogue with choices")
-- Select a choice (simulates player clicking option 1)
ecs.dialogue.select_choice(1)
print("Selected choice 1")
-- =============================================================================
-- Hiding Dialogue
-- =============================================================================
-- Hide the current dialogue
ecs.dialogue.hide()
print("Dialogue hidden")
-- =============================================================================
-- Checking Dialogue State
-- =============================================================================
-- Check if dialogue is currently active
local active = ecs.dialogue.is_active()
print("Dialogue active: " .. tostring(active))
-- =============================================================================
-- Progressing Through Dialogue
-- =============================================================================
-- Show a multi-line narration and progress through it
ecs.dialogue.show("The sun sets over the horizon...")
ecs.dialogue.progress()
ecs.dialogue.show("A cool breeze sweeps through the valley...")
ecs.dialogue.progress()
ecs.dialogue.show("You hear footsteps in the distance.")
ecs.dialogue.progress()
print("Progressed through narration")
-- =============================================================================
-- Dialogue Settings Management
-- =============================================================================
-- Get current settings
local settings = ecs.dialogue.get_settings()
print("Current settings:")
print(" Font: " .. settings.font_name)
print(" Font size: " .. settings.font_size)
print(" Speaker font size: " .. settings.speaker_font_size)
print(" Background opacity: " .. settings.background_opacity)
print(" Box height fraction: " .. settings.box_height_fraction)
print(" Box position fraction: " .. settings.box_position_fraction)
-- Modify settings
ecs.dialogue.set_settings({
font_name = "Jupiteroid-Regular.ttf",
font_size = 20.0,
speaker_font_size = 18.0,
background_opacity = 0.85,
box_height_fraction = 0.25,
box_position_fraction = 0.75
})
print("Updated dialogue settings")
-- Verify the changes
local updated = ecs.dialogue.get_settings()
print("Updated font size: " .. updated.font_size)
-- =============================================================================
-- Saving and Loading Settings
-- =============================================================================
-- Save settings to default path (dialogue.json)
local saved = ecs.dialogue.save_settings()
print("Settings saved: " .. tostring(saved))
-- Save settings to a custom path
local saved_custom = ecs.dialogue.save_settings("my_dialogue_config.json")
print("Settings saved to custom path: " .. tostring(saved_custom))
-- Load settings from default path
local loaded = ecs.dialogue.load_settings()
print("Settings loaded: " .. tostring(loaded))
-- Load settings from custom path
local loaded_custom = ecs.dialogue.load_settings("my_dialogue_config.json")
print("Settings loaded from custom path: " .. tostring(loaded_custom))
-- =============================================================================
-- Practical: Dialogue Sequence with Choices
-- =============================================================================
-- A simple dialogue tree simulation
function run_dialogue_tree()
-- Node 1: Greeting
ecs.dialogue.show("Greetings, adventurer! How can I help you?", {
"Tell me about the local area",
"I need supplies",
"I'm just passing through"
}, "Innkeeper")
-- Simulate player choosing option 1
ecs.dialogue.select_choice(1)
-- Node 2: Response to choice 1
ecs.dialogue.show("Ah, you must be new here! This town has a rich history. "
.. "To the north lies the ancient forest, and to the east, "
.. "the old ruins.", {}, "Innkeeper")
ecs.dialogue.progress()
-- Node 3: Offer quest
ecs.dialogue.show("If you're looking for adventure, I heard the ruins "
.. "hold a legendary treasure. But beware of the traps!", {
"I'll check it out!",
"Sounds too dangerous"
}, "Innkeeper")
-- Simulate player choosing option 1
ecs.dialogue.select_choice(1)
-- Node 4: Final response
ecs.dialogue.show("Excellent! Good luck on your journey, adventurer!", {}, "Innkeeper")
ecs.dialogue.progress()
-- End dialogue
ecs.dialogue.hide()
print("Dialogue tree completed")
end
run_dialogue_tree()
-- =============================================================================
-- API Reference
-- =============================================================================
--
-- ecs.dialogue.show(text, choices?, speaker?)
-- text - string, the dialogue text to display
-- choices - optional table of strings, player response options
-- speaker - optional string, name of the speaking character
--
-- ecs.dialogue.hide()
-- Hides the current dialogue box.
--
-- ecs.dialogue.is_active() -> bool
-- Returns true if a dialogue is currently being displayed.
--
-- ecs.dialogue.select_choice(index)
-- Simulates selecting a choice by its 1-based index.
--
-- ecs.dialogue.progress()
-- Advances to the next line of dialogue (click-to-dismiss).
--
-- ecs.dialogue.get_settings() -> table
-- Returns a table with fields:
-- font_name, font_size, speaker_font_size,
-- background_opacity, box_height_fraction, box_position_fraction
--
-- ecs.dialogue.set_settings(settings_table)
-- Updates dialogue display settings. Partial tables are accepted.
--
-- ecs.dialogue.save_settings(path?) -> bool
-- Saves current settings to JSON. Default path: "dialogue.json"
--
-- ecs.dialogue.load_settings(path?) -> bool
-- Loads settings from JSON. Default path: "dialogue.json"
-- =============================================================================
print("Dialogue examples completed successfully!")
@@ -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,39 +26,22 @@ 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.
--- @param text string The narration text
--- @param speaker string|nil Optional speaker name
--- @param choices string|nil Comma-separated choices (nil = click to dismiss)
--- @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
@@ -212,24 +193,18 @@ function run_conversation(tree, start_node)
break
end
-- Build choices string from the node's choices table
local choices_str = ""
-- Build choices table from the node's choices
local choices = {}
local choice_map = {}
if node.choices then
local parts = {}
for i, choice in ipairs(node.choices) do
table.insert(parts, choice.text)
table.insert(choices, choice.text)
choice_map[i] = choice
end
choices_str = table.concat(parts, ",")
end
-- Show the dialogue
ecs.send_event("dialogue_show", {
text = node.text,
speaker = node.speaker or "",
choices = choices_str
})
-- 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.
@@ -252,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
@@ -269,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
@@ -312,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,198 @@
-- =============================================================================
-- Inventory Lua API Examples
-- =============================================================================
-- This file demonstrates how to manage entity inventories using the
-- ecs.inventory API. Inventories are stored as InventoryComponent on
-- ECS entities.
--
-- Prerequisites:
-- - Items must be registered via ecs.items.register() first
-- - Entities must have an InventoryComponent (added via ecs.add_component)
-- =============================================================================
-- =============================================================================
-- Setup: Create an entity with an inventory
-- =============================================================================
-- Create a player entity
local player = ecs.create_entity()
ecs.set_entity_name(player, "player")
-- Add an InventoryComponent with 20 slots and 50.0 max weight
ecs.set_component(player, "Inventory", {
maxSlots = 20,
maxWeight = 50.0,
isContainer = false,
containerId = ""
})
print("Created player entity with inventory (ID: " .. player .. ")")
-- =============================================================================
-- Adding Items to Inventory
-- =============================================================================
-- Add 5 health potions
local added = ecs.inventory.add(player, "potion_health", 5)
print("Added 5 health potions: " .. tostring(added))
-- Add 3 arrows
ecs.inventory.add(player, "arrow", 3)
print("Added 3 arrows")
-- Add 1 iron sword
ecs.inventory.add(player, "sword_iron", 1)
print("Added 1 iron sword")
-- Add 50 gold coins
ecs.inventory.add(player, "gold_coin", 50)
print("Added 50 gold coins")
-- =============================================================================
-- Checking Inventory Contents
-- =============================================================================
-- Check if the player has a specific item
if ecs.inventory.has(player, "potion_health") then
print("Player has health potions")
end
-- Count how many of a specific item
local potion_count = ecs.inventory.count(player, "potion_health")
print("Health potion count: " .. potion_count)
local gold_count = ecs.inventory.count(player, "gold_coin")
print("Gold coin count: " .. gold_count)
-- =============================================================================
-- Listing Inventory Slots
-- =============================================================================
-- Get all non-empty slots
local slots = ecs.inventory.get_slots(player)
print("Inventory slots (" .. #slots .. " non-empty):")
for i, slot in ipairs(slots) do
print(" [" .. i .. "] " .. slot.itemId .. " x" .. slot.stackSize)
end
-- =============================================================================
-- Removing Items from Inventory
-- =============================================================================
-- Remove 2 health potions
local removed = ecs.inventory.remove(player, "potion_health", 2)
print("Removed " .. removed .. " health potions")
-- Check remaining count
local remaining = ecs.inventory.count(player, "potion_health")
print("Remaining health potions: " .. remaining)
-- =============================================================================
-- Setting Inventory Slots Directly
-- =============================================================================
-- Replace the entire inventory contents
ecs.inventory.set_slots(player, {
{ itemId = "potion_health", stackSize = 3 },
{ itemId = "sword_iron", stackSize = 1 },
{ itemId = "gold_coin", stackSize = 100 }
})
print("Inventory slots replaced via set_slots")
-- Verify the new contents
local new_slots = ecs.inventory.get_slots(player)
print("New inventory slots (" .. #new_slots .. "):")
for i, slot in ipairs(new_slots) do
print(" [" .. i .. "] " .. slot.itemId .. " x" .. slot.stackSize)
end
-- =============================================================================
-- Practical: Gold Management
-- =============================================================================
function add_gold(entity, amount)
local current = ecs.inventory.count(entity, "gold_coin")
ecs.inventory.add(entity, "gold_coin", amount)
print("Gold: " .. current .. " -> " .. (current + amount))
end
function remove_gold(entity, amount)
local current = ecs.inventory.count(entity, "gold_coin")
if current >= amount then
ecs.inventory.remove(entity, "gold_coin", amount)
print("Gold: " .. current .. " -> " .. (current - amount))
return true
else
print("Not enough gold! Have " .. current .. ", need " .. amount)
return false
end
end
add_gold(player, 50)
remove_gold(player, 30)
remove_gold(player, 200) -- Should fail
-- =============================================================================
-- Practical: Item Transfer Between Entities
-- =============================================================================
-- Create a chest entity
local chest = ecs.create_entity()
ecs.set_entity_name(chest, "chest_wooden")
ecs.set_component(chest, "Inventory", {
maxSlots = 10,
maxWeight = 100.0,
isContainer = true,
containerId = "chest_wooden_001"
})
-- Add items to chest
ecs.inventory.add(chest, "bow_wood", 1)
ecs.inventory.add(chest, "arrow", 20)
ecs.inventory.add(chest, "potion_health", 2)
-- Transfer function: move items from one entity to another
function transfer_item(from_entity, to_entity, item_id, count)
local available = ecs.inventory.count(from_entity, item_id)
if available < count then
print("Not enough " .. item_id .. " to transfer (have " .. available .. ")")
return false
end
ecs.inventory.remove(from_entity, item_id, count)
ecs.inventory.add(to_entity, item_id, count)
print("Transferred " .. count .. " " .. item_id .. " from " ..
ecs.get_entity_name(from_entity) .. " to " ..
ecs.get_entity_name(to_entity))
return true
end
-- Transfer arrows from chest to player
transfer_item(chest, player, "arrow", 10)
-- =============================================================================
-- API Reference
-- =============================================================================
--
-- ecs.inventory.add(entity_id, item_id, count) -> bool
-- Adds items to the entity's inventory. Returns true if successful.
--
-- ecs.inventory.remove(entity_id, item_id, count) -> int
-- Removes items and returns the number actually removed.
--
-- ecs.inventory.has(entity_id, item_id) -> bool
-- Returns true if the entity has at least one of the item.
--
-- ecs.inventory.count(entity_id, item_id) -> int
-- Returns the total count of the item across all slots.
--
-- ecs.inventory.get_slots(entity_id) -> table of { itemId, stackSize }
-- Returns all non-empty inventory slots.
--
-- ecs.inventory.set_slots(entity_id, slots_table) -> nil
-- Replaces the entire inventory with the given slots.
-- Each slot: { itemId = "...", stackSize = N }
-- =============================================================================
print("Inventory examples completed successfully!")
@@ -0,0 +1,144 @@
-- =============================================================================
-- Item Registry Lua API Examples
-- =============================================================================
-- This file demonstrates how to register item definitions using the
-- ecs.items API. Run this from data.lua or any other Lua entry point
-- to populate the global item registry.
--
-- The ItemRegistry is a global singleton. Items defined here are
-- immediately available for use in inventories, containers, and quests.
-- =============================================================================
-- =============================================================================
-- Registering Items
-- =============================================================================
-- Consumables
ecs.items.register("potion_health", {
itemName = "Health Potion",
itemType = "consumable",
maxStackSize = 10,
weight = 0.5,
value = 25,
useActionName = "drink_potion",
unique = false
})
ecs.items.register("potion_stamina", {
itemName = "Stamina Potion",
itemType = "consumable",
maxStackSize = 10,
weight = 0.5,
value = 20,
useActionName = "drink_potion",
unique = false
})
-- Weapons
ecs.items.register("sword_iron", {
itemName = "Iron Sword",
itemType = "weapon",
maxStackSize = 1,
weight = 3.5,
value = 100,
useActionName = "equip_weapon",
unique = false
})
ecs.items.register("bow_wood", {
itemName = "Wooden Bow",
itemType = "weapon",
maxStackSize = 1,
weight = 2.0,
value = 75,
useActionName = "equip_weapon",
unique = false
})
-- Ammo
ecs.items.register("arrow", {
itemName = "Arrow",
itemType = "ammo",
maxStackSize = 99,
weight = 0.1,
value = 2,
useActionName = "",
unique = false
})
-- Unique quest item
ecs.items.register("amulet_legendary", {
itemName = "Amulet of the Ancients",
itemType = "quest",
maxStackSize = 1,
weight = 0.2,
value = 5000,
useActionName = "inspect_amulet",
unique = true
})
-- Currency
ecs.items.register("gold_coin", {
itemName = "Gold Coin",
itemType = "currency",
maxStackSize = 999,
weight = 0.01,
value = 1,
useActionName = "",
unique = false
})
-- =============================================================================
-- Querying the Item Registry
-- =============================================================================
-- Find an item by ID:
local potion = ecs.items.find("potion_health")
if potion then
print("Found item: " .. potion.itemName .. " (" .. potion.itemType .. ")")
print(" Max stack: " .. potion.maxStackSize)
print(" Weight: " .. potion.weight)
print(" Value: " .. potion.value)
print(" Unique: " .. tostring(potion.unique))
end
-- Check if an item is unique:
if ecs.items.is_unique("amulet_legendary") then
print("Amulet of the Ancients is a unique item")
end
-- List all registered items:
print("Registered items:")
local all_items = ecs.items.list()
for _, id in ipairs(all_items) do
local def = ecs.items.find(id)
print(" " .. id .. " - " .. def.itemName .. " (" .. def.itemType .. ")")
end
-- =============================================================================
-- Item Definition Fields Reference
-- =============================================================================
--
-- ecs.items.register(itemId, definition)
-- itemId - string, unique identifier for the item
-- definition - table with fields:
-- itemName - string, display name
-- itemType - string, category ("consumable", "weapon", "ammo",
-- "quest", "currency", "material", "armor", etc.)
-- maxStackSize - integer, maximum items per inventory slot
-- weight - number, weight per item
-- value - integer, base value in gold
-- useActionName - string, GOAP action name when used (empty = no action)
-- unique - boolean, true if only one instance allowed per character
--
-- ecs.items.find(itemId) -> table or nil
-- Returns the item definition table with all fields above.
--
-- ecs.items.list() -> table of item ID strings
-- Returns all registered item IDs.
--
-- ecs.items.is_unique(itemId) -> boolean
-- Returns true if the item is marked as unique.
-- =============================================================================
print("Item registry examples completed successfully!")
@@ -0,0 +1,248 @@
-- =============================================================================
-- Quest Reward Lua API Examples
-- =============================================================================
-- This file demonstrates how to use the inventory and container APIs
-- together to implement quest reward systems.
--
-- This combines:
-- - ecs.inventory.* for player inventory management
-- - ecs.container.* for quest container state
-- - ecs.items.* for item registry queries
-- - ecs.send_event / ecs.subscribe_event for event-driven quest flow
-- =============================================================================
-- =============================================================================
-- Setup: Create player and quest-related entities
-- =============================================================================
-- Create the player entity with an inventory
local player = ecs.create_entity()
ecs.set_entity_name(player, "player")
ecs.set_component(player, "Inventory", {
maxSlots = 30,
maxWeight = 100.0,
isContainer = false,
containerId = ""
})
print("Created player entity (ID: " .. player .. ")")
-- Create a quest giver NPC
local quest_giver = ecs.create_entity()
ecs.set_entity_name(quest_giver, "quest_giver_elder")
ecs.set_component(quest_giver, "Inventory", {
maxSlots = 10,
maxWeight = 50.0,
isContainer = false,
containerId = ""
})
print("Created quest giver entity (ID: " .. quest_giver .. ")")
-- =============================================================================
-- Quest Reward Distribution
-- =============================================================================
-- Give the player starting equipment
ecs.inventory.add(player, "sword_iron", 1)
ecs.inventory.add(player, "potion_health", 3)
ecs.inventory.add(player, "gold_coin", 25)
print("Player starting equipment added")
-- Give the quest giver some reward items
ecs.inventory.add(quest_giver, "gold_coin", 200)
ecs.inventory.add(quest_giver, "potion_health", 2)
ecs.inventory.add(quest_giver, "amulet_legendary", 1)
print("Quest giver reward items added")
-- =============================================================================
-- Quest Completion: Reward Player
-- =============================================================================
-- Complete a quest and give the player rewards
function complete_quest(quest_name, reward_items, xp_reward)
print("=== Quest Completed: " .. quest_name .. " ===")
print("XP Reward: " .. xp_reward)
-- Give each reward item to the player
for _, reward in ipairs(reward_items) do
local item_def = ecs.items.find(reward.itemId)
if item_def then
ecs.inventory.add(player, reward.itemId, reward.count)
print(" Received: " .. reward.count .. "x " .. item_def.itemName)
else
print(" WARNING: Unknown item '" .. reward.itemId .. "'")
end
end
-- Send a quest completion event
ecs.send_event("quest_completed", {
quest_name = quest_name,
player_id = player,
xp_rewarded = xp_reward
})
print("Quest completion event sent")
end
-- Complete "The Lost Artifact" quest
complete_quest("The Lost Artifact", {
{ itemId = "gold_coin", count = 100 },
{ itemId = "potion_health", count = 2 }
}, 500)
-- =============================================================================
-- Quest Item Requirement Check
-- =============================================================================
-- Check if the player has the required items for a quest
function has_quest_items(quest_requirements)
for _, req in ipairs(quest_requirements) do
local count = ecs.inventory.count(player, req.itemId)
if count < req.count then
local def = ecs.items.find(req.itemId)
local name = def and def.itemName or req.itemId
print(" Missing: " .. (req.count - count) .. " more " .. name)
return false
end
end
return true
end
-- Remove quest items from the player's inventory (turn in)
function remove_quest_items(quest_requirements)
for _, req in ipairs(quest_requirements) do
ecs.inventory.remove(player, req.itemId, req.count)
local def = ecs.items.find(req.itemId)
local name = def and def.itemName or req.itemId
print(" Removed: " .. req.count .. "x " .. name)
end
end
-- Define a quest that requires items
local bandit_quest = {
name = "Bandit Menace",
requirements = {
{ itemId = "sword_iron", count = 1 },
{ itemId = "potion_health", count = 2 }
},
rewards = {
{ itemId = "gold_coin", count = 150 },
{ itemId = "bow_wood", count = 1 }
},
xp = 750
}
-- Check if player can start the quest
print("Checking quest requirements for '" .. bandit_quest.name .. "':")
if has_quest_items(bandit_quest.requirements) then
print("Player has all required items!")
remove_quest_items(bandit_quest.requirements)
complete_quest(bandit_quest.name, bandit_quest.rewards, bandit_quest.xp)
else
print("Player does not meet quest requirements")
end
-- =============================================================================
-- Quest Container Loot
-- =============================================================================
-- Set up a quest-related container (e.g., a treasure chest at the quest location)
ecs.container.set_state("bandit_hideout_chest", {
{ itemId = "gold_coin", stackSize = 200 },
{ itemId = "potion_health", stackSize = 2 },
{ itemId = "potion_stamina", stackSize = 1 }
})
print("Bandit hideout chest populated with loot")
-- Player loots the chest
local chest_loot = ecs.container.get_state("bandit_hideout_chest")
print("Looting bandit hideout chest:")
for _, slot in ipairs(chest_loot) do
ecs.inventory.add(player, slot.itemId, slot.stackSize)
local def = ecs.items.find(slot.itemId)
local name = def and def.itemName or slot.itemId
print(" Acquired: " .. slot.stackSize .. "x " .. name)
end
-- Clear the chest after looting
ecs.container.clear_state("bandit_hideout_chest")
print("Chest cleared after looting")
-- =============================================================================
-- Quest Progression with Event Subscriptions
-- =============================================================================
-- Track quest state
local quest_state = {
active_quests = {},
completed_quests = {}
}
-- Subscribe to quest completion events
local quest_sub = ecs.subscribe_event("quest_completed", function(event, params)
local quest_name = params.quest_name or "unknown"
local xp = params.xp_rewarded or 0
-- Track completed quests
table.insert(quest_state.completed_quests, quest_name)
print("[Quest Log] Completed: " .. quest_name .. " (+" .. xp .. " XP)")
print("[Quest Log] Total completed: " .. #quest_state.completed_quests)
end)
print("Subscribed to quest_completed events (ID: " .. quest_sub .. ")")
-- =============================================================================
-- Practical: Full Quest Flow
-- =============================================================================
function run_quest_flow(quest_name, requirements, rewards, xp)
print("\n=== Starting Quest: " .. quest_name .. " ===")
-- 1. Check requirements
if not has_quest_items(requirements) then
print("Cannot start quest - missing items")
return false
end
-- 2. Remove quest items
print("Turning in quest items:")
remove_quest_items(requirements)
-- 3. Complete quest and give rewards
complete_quest(quest_name, rewards, xp)
return true
end
-- Run a second quest
run_quest_flow(
"The Blacksmith's Request",
{
{ itemId = "gold_coin", count = 50 }
},
{
{ itemId = "sword_iron", count = 1 },
{ itemId = "gold_coin", count = 75 }
},
300
)
-- =============================================================================
-- Summary
-- =============================================================================
-- Quest reward patterns demonstrated:
--
-- 1. Direct reward: ecs.inventory.add() to give items to the player
-- 2. Requirement check: ecs.inventory.count() to verify quest items
-- 3. Item removal: ecs.inventory.remove() to consume quest items
-- 4. Container loot: ecs.container.get_state() + ecs.inventory.add()
-- 5. Event-driven: ecs.send_event() + ecs.subscribe_event() for quest flow
-- 6. Item lookup: ecs.items.find() for display names and metadata
-- =============================================================================
print("Quest reward examples completed successfully!")
@@ -0,0 +1,187 @@
--[[
Save/Load Lua API Example
==========================
Demonstrates how to use ecs.register_save_callback and
ecs.register_load_callback to persist custom Lua state.
The save/load system automatically calls all registered callbacks
when the player saves or loads a game from the pause menu.
Usage:
require('save_load_example') -- in your game script
--]]
-- =====================================================================
-- Example 1: Quest state persistence
-- =====================================================================
-- Track quest state in Lua
local quest_state = {
active_quests = {},
completed_quests = {},
quest_stages = {}
}
-- Register save callback for quest data
ecs.register_save_callback('quest_data', function()
return {
active_quests = quest_state.active_quests,
completed_quests = quest_state.completed_quests,
quest_stages = quest_state.quest_stages
}
end)
-- Register load callback for quest data
ecs.register_load_callback('quest_data', function(data)
if data then
quest_state.active_quests = data.active_quests or {}
quest_state.completed_quests = data.completed_quests or {}
quest_state.quest_stages = data.quest_stages or {}
print('Quest state restored from save')
end
end)
-- Helper functions for quest management
function start_quest(quest_id, quest_name)
quest_state.active_quests[quest_id] = quest_name
quest_state.quest_stages[quest_id] = 1
print('Started quest:', quest_name)
end
function advance_quest(quest_id)
local stage = quest_state.quest_stages[quest_id]
if stage then
quest_state.quest_stages[quest_id] = stage + 1
print('Advanced quest', quest_id, 'to stage', stage + 1)
end
end
function complete_quest(quest_id)
local name = quest_state.active_quests[quest_id]
if name then
quest_state.active_quests[quest_id] = nil
quest_state.quest_stages[quest_id] = nil
table.insert(quest_state.completed_quests, quest_id)
print('Completed quest:', name)
end
end
-- =====================================================================
-- Example 2: NPC relationship tracking
-- =====================================================================
local npc_relationships = {}
ecs.register_save_callback('npc_relationships', function()
return npc_relationships
end)
ecs.register_load_callback('npc_relationships', function(data)
if data then
npc_relationships = data
print('NPC relationships restored from save')
end
end)
function set_npc_relationship(npc_id, value)
npc_relationships[npc_id] = value
end
function get_npc_relationship(npc_id)
return npc_relationships[npc_id] or 0
end
-- =====================================================================
-- Example 3: World state flags
-- =====================================================================
local world_flags = {}
ecs.register_save_callback('world_flags', function()
return world_flags
end)
ecs.register_load_callback('world_flags', function(data)
if data then
world_flags = data
print('World flags restored from save')
end
end)
function set_world_flag(flag_name, value)
world_flags[flag_name] = value
end
function get_world_flag(flag_name)
return world_flags[flag_name] or false
end
-- =====================================================================
-- Example 4: Reacting to save/load events
-- =====================================================================
ecs.subscribe_event('save_game_requested', function()
print('Save is about to begin - syncing character positions...')
-- Sync any runtime state that needs to be up-to-date before save
end)
ecs.subscribe_event('game_saved', function()
print('Game was saved successfully!')
end)
ecs.subscribe_event('load_game_requested', function()
print('Load is about to begin - cleaning up runtime state...')
-- Clean up any runtime-only state before load
end)
ecs.subscribe_event('game_loaded', function()
print('Game was loaded successfully!')
-- Re-initialize any runtime systems that depend on loaded state
end)
-- =====================================================================
-- Example 5: Player stats extension
-- =====================================================================
-- Extend player stats with custom Lua-tracked values
local player_extras = {
reputation = 0,
discovery_percentage = 0.0,
visited_locations = {}
}
ecs.register_save_callback('player_extras', function()
return player_extras
end)
ecs.register_load_callback('player_extras', function(data)
if data then
player_extras = data
print('Player extras restored from save')
end
end)
function add_reputation(amount)
player_extras.reputation = player_extras.reputation + amount
print('Reputation is now:', player_extras.reputation)
end
function visit_location(location_name)
if not player_extras.visited_locations[location_name] then
player_extras.visited_locations[location_name] = true
local count = 0
for _, _ in pairs(player_extras.visited_locations) do
count = count + 1
end
-- Estimate discovery percentage (example: 100 total locations)
player_extras.discovery_percentage = (count / 100) * 100
print('Discovered new location:', location_name)
end
end
-- =====================================================================
-- Initialization
-- =====================================================================
print('Save/Load example loaded')
print('Registered callbacks: quest_data, npc_relationships, world_flags, player_extras')
@@ -0,0 +1,36 @@
print("hello!")
ecs.behavior_tree.register_node("luaHello", function(entity_id, params)
local message = params.message or "Hello!"
print("Entity " .. entity_id .. " says: " .. message)
return "success"
end)
ecs.action_db.add_action("lua_hello_action", 1,
{}, -- no preconditions
{}, -- no effects
{ -- behavior tree
type = "sequence",
children = {
ecs.behavior_tree.create_node("luaHello", "message=Welcome to the game!"),
ecs.behavior_tree.create_node("setAnimationState", "main/action"),
ecs.behavior_tree.create_node("setAnimationState", "action/sitting-ground"),
ecs.behavior_tree.create_node("delay", "dly", "9.0"),
ecs.behavior_tree.create_node("setAnimationState", "main/locomotion"),
ecs.behavior_tree.create_node("setAnimationState", "locomotion/idle"),
}
}
)
ecs.subscribe_event("game_start", function(event, params)
print("Event: " .. event)
-- ecs.debug_crash("game_start triggered")
ecs.subscribe_event("new_game", function(event, params)
-- ecs.debug_crash("new_game triggered")
local tsub = ecs.subscribe_event("scene_ready", function(event, params)
-- ecs.unsubscribe_event(tsub)
-- ecs.debug_crash("scene_ready triggered")
end)
end)
end)
@@ -0,0 +1,322 @@
#include "LuaCharacterApi.hpp"
#include "LuaEntityApi.hpp"
#include "../systems/CharacterRegistry.hpp"
#include "../components/CharacterIdentity.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 uint64_t vector as Lua table
// ---------------------------------------------------------------------------
static void pushUint64Vector(lua_State *L, const std::vector<uint64_t> &vec)
{
lua_newtable(L);
for (size_t i = 0; i < vec.size(); i++) {
lua_pushinteger(L, static_cast<lua_Integer>(vec[i]));
lua_rawseti(L, -2, static_cast<int>(i + 1));
}
}
// ---------------------------------------------------------------------------
// Character creation & management
// ---------------------------------------------------------------------------
static int luaCharacterCreate(lua_State *L)
{
const char *firstName = luaL_checkstring(L, 1);
const char *lastName = luaL_checkstring(L, 2);
const char *templatePath = lua_tostring(L, 3);
bool persistent = true;
if (lua_gettop(L) >= 4)
persistent = lua_toboolean(L, 4) != 0;
uint64_t id = CharacterRegistry::getSingleton().createCharacter(
firstName, lastName, templatePath ? templatePath : "",
persistent);
lua_pushinteger(L, static_cast<lua_Integer>(id));
return 1;
}
static int luaCharacterDelete(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
CharacterRegistry::getSingleton().deleteCharacter(id);
return 0;
}
static int luaCharacterFind(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
const CharacterRegistry::CharacterRecord *c =
CharacterRegistry::getSingleton().findCharacter(id);
if (!c) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushinteger(L, static_cast<lua_Integer>(c->id));
lua_setfield(L, -2, "id");
lua_pushstring(L, c->firstName.c_str());
lua_setfield(L, -2, "firstName");
lua_pushstring(L, c->lastName.c_str());
lua_setfield(L, -2, "lastName");
lua_pushstring(L, c->className.c_str());
lua_setfield(L, -2, "className");
lua_pushinteger(L, c->level);
lua_setfield(L, -2, "level");
lua_pushinteger(L, c->ageYears);
lua_setfield(L, -2, "ageYears");
lua_pushstring(L, c->inlineSex.c_str());
lua_setfield(L, -2, "sex");
lua_pushboolean(L, c->persistent ? 1 : 0);
lua_setfield(L, -2, "persistent");
lua_pushinteger(L,
static_cast<lua_Integer>(c->pregnantByFatherId));
lua_setfield(L, -2, "pregnantByFatherId");
lua_pushnumber(L, c->pregnancyProgress);
lua_setfield(L, -2, "pregnancyProgress");
lua_pushnumber(L, c->pregnancyMaxProgress);
lua_setfield(L, -2, "pregnancyMaxProgress");
return 1;
}
static int luaCharacterGetAll(lua_State *L)
{
const auto &chars =
CharacterRegistry::getSingleton().getCharacters();
lua_newtable(L);
int idx = 1;
for (const auto &pair : chars) {
lua_pushinteger(L, static_cast<lua_Integer>(pair.first));
lua_rawseti(L, -2, idx++);
}
return 1;
}
static int luaCharacterSetName(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
const char *firstName = luaL_checkstring(L, 2);
const char *lastName = luaL_checkstring(L, 3);
CharacterRegistry::CharacterRecord *c =
CharacterRegistry::getSingleton().findCharacter(id);
if (c) {
c->firstName = firstName;
c->lastName = lastName;
}
return 0;
}
// ---------------------------------------------------------------------------
// Spawn / despawn
// ---------------------------------------------------------------------------
static int luaCharacterSpawn(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
flecs::entity e =
CharacterRegistry::getSingleton().spawnCharacter(id);
if (!e.is_alive()) {
lua_pushnil(L);
return 1;
}
int luaId = g_luaEntityIdMap.addEntity(e);
lua_pushinteger(L, luaId);
return 1;
}
static int luaCharacterDespawn(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
bool ok = CharacterRegistry::getSingleton().despawnCharacter(id);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaCharacterIsSpawned(lua_State *L)
{
uint64_t id = static_cast<uint64_t>(luaL_checkinteger(L, 1));
flecs::entity e =
CharacterRegistry::getSingleton().findSpawnedEntity(id);
lua_pushboolean(L, e.is_alive() ? 1 : 0);
return 1;
}
// ---------------------------------------------------------------------------
// Pregnancy
// ---------------------------------------------------------------------------
static int luaCharacterConceive(lua_State *L)
{
uint64_t motherId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
uint64_t fatherId =
static_cast<uint64_t>(luaL_checkinteger(L, 2));
bool ok = CharacterRegistry::getSingleton().conceive(motherId,
fatherId);
lua_pushboolean(L, ok ? 1 : 0);
return 1;
}
static int luaCharacterAbortPregnancy(lua_State *L)
{
uint64_t motherId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
CharacterRegistry::getSingleton().abortPregnancy(motherId);
return 0;
}
static int luaCharacterIsPregnant(lua_State *L)
{
uint64_t motherId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
bool pregnant = CharacterRegistry::getSingleton().isPregnant(
motherId);
lua_pushboolean(L, pregnant ? 1 : 0);
return 1;
}
static int luaCharacterGetPregnancyProgress(lua_State *L)
{
uint64_t motherId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
const CharacterRegistry::CharacterRecord *c =
CharacterRegistry::getSingleton().findCharacter(motherId);
if (!c || c->pregnantByFatherId == 0) {
lua_pushnil(L);
return 1;
}
lua_newtable(L);
lua_pushnumber(L, c->pregnancyProgress);
lua_setfield(L, -2, "progress");
lua_pushnumber(L, c->pregnancyMaxProgress);
lua_setfield(L, -2, "maxProgress");
lua_pushnumber(L,
c->pregnancyMaxProgress > 0.0f ?
c->pregnancyProgress /
c->pregnancyMaxProgress :
0.0f);
lua_setfield(L, -2, "ratio");
return 1;
}
// ---------------------------------------------------------------------------
// Birth & lineage
// ---------------------------------------------------------------------------
static int luaCharacterCreateChild(lua_State *L)
{
uint64_t parentA =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
uint64_t parentB =
static_cast<uint64_t>(luaL_checkinteger(L, 2));
uint64_t childId =
CharacterRegistry::getSingleton().createChild(parentA,
parentB);
lua_pushinteger(L, static_cast<lua_Integer>(childId));
return 1;
}
static int luaCharacterGetParents(lua_State *L)
{
uint64_t childId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
auto parents = CharacterRegistry::getSingleton().getParents(
childId);
pushUint64Vector(L, parents);
return 1;
}
static int luaCharacterGetChildren(lua_State *L)
{
uint64_t parentId =
static_cast<uint64_t>(luaL_checkinteger(L, 1));
auto children = CharacterRegistry::getSingleton().getChildren(
parentId);
pushUint64Vector(L, children);
return 1;
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void registerLuaCharacterApi(lua_State *L)
{
lua_getglobal(L, "ecs");
if (lua_isnil(L, -1)) {
lua_pop(L, 1);
lua_newtable(L);
}
lua_newtable(L); // ecs.character
lua_pushcfunction(L, luaCharacterCreate);
lua_setfield(L, -2, "create");
lua_pushcfunction(L, luaCharacterDelete);
lua_setfield(L, -2, "delete");
lua_pushcfunction(L, luaCharacterFind);
lua_setfield(L, -2, "find");
lua_pushcfunction(L, luaCharacterGetAll);
lua_setfield(L, -2, "get_all");
lua_pushcfunction(L, luaCharacterSetName);
lua_setfield(L, -2, "set_name");
lua_pushcfunction(L, luaCharacterSpawn);
lua_setfield(L, -2, "spawn");
lua_pushcfunction(L, luaCharacterDespawn);
lua_setfield(L, -2, "despawn");
lua_pushcfunction(L, luaCharacterIsSpawned);
lua_setfield(L, -2, "is_spawned");
lua_pushcfunction(L, luaCharacterConceive);
lua_setfield(L, -2, "conceive");
lua_pushcfunction(L, luaCharacterAbortPregnancy);
lua_setfield(L, -2, "abort_pregnancy");
lua_pushcfunction(L, luaCharacterIsPregnant);
lua_setfield(L, -2, "is_pregnant");
lua_pushcfunction(L, luaCharacterGetPregnancyProgress);
lua_setfield(L, -2, "get_pregnancy_progress");
lua_pushcfunction(L, luaCharacterCreateChild);
lua_setfield(L, -2, "create_child");
lua_pushcfunction(L, luaCharacterGetParents);
lua_setfield(L, -2, "get_parents");
lua_pushcfunction(L, luaCharacterGetChildren);
lua_setfield(L, -2, "get_children");
lua_setfield(L, -2, "character"); // ecs.character = { ... }
lua_setglobal(L, "ecs");
}
} // namespace editScene
@@ -0,0 +1,11 @@
#ifndef EDITSCENE_LUA_CHARACTER_API_HPP
#define EDITSCENE_LUA_CHARACTER_API_HPP
#pragma once
#include <lua.hpp>
namespace editScene {
void registerLuaCharacterApi(lua_State *L);
}
#endif // EDITSCENE_LUA_CHARACTER_API_HPP
@@ -0,0 +1,400 @@
#include "LuaCharacterClassApi.hpp"
#include "../systems/CharacterRegistry.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include "../components/CharacterIdentity.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: get character record from entity ID
// ---------------------------------------------------------------------------
static CharacterRegistry::CharacterRecord *getCharacterRecord(
lua_State *L, int entityArgIdx)
{
int entityId = static_cast<int>(lua_tointeger(L, entityArgIdx));
flecs::entity e = getWorld(L).entity(entityId);
if (!e.is_alive() || !e.has<CharacterIdentityComponent>())
return nullptr;
auto &ci = e.get<CharacterIdentityComponent>();
return CharacterRegistry::getSingleton().findCharacter(ci.registryId);
}
// ---------------------------------------------------------------------------
// 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 (via CharacterRegistry)
// ---------------------------------------------------------------------------
static int luaGetLevel(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
lua_pushinteger(L, rec ? rec->level : 0);
return 1;
}
static int luaGetXP(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
lua_pushinteger(L, rec ? (lua_Integer)rec->currentXP : 0);
return 1;
}
static int luaAddXP(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
int64_t amount = static_cast<int64_t>(lua_tointeger(L, 2));
if (!rec) {
lua_pushboolean(L, 0);
return 1;
}
rec->currentXP += amount;
lua_pushboolean(L, 1);
return 1;
}
static int luaGetStat(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *statName = lua_tostring(L, 2);
if (!rec || !statName) {
lua_pushinteger(L, 0);
return 1;
}
const auto *def = CharacterClassDatabase::getSingleton().findStat(statName);
if (def && def->kind ==
CharacterClassDatabase::StatKind::ResourcePool) {
int maxVal = rec->stats.count(statName) ? rec->stats[statName] : 0;
if (maxVal <= 0) {
lua_pushinteger(L, 0);
return 1;
}
auto it = rec->currentPools.find(statName);
if (it == rec->currentPools.end()) {
lua_pushinteger(L, maxVal);
return 1;
}
int val = it->second;
if (val < 0)
val = 0;
if (val > maxVal)
val = maxVal;
lua_pushinteger(L, val);
return 1;
}
lua_pushinteger(L, rec->stats.count(statName) ? rec->stats[statName] : 0);
return 1;
}
static int luaGetSkill(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *skillName = lua_tostring(L, 2);
if (!rec || !skillName) {
lua_pushinteger(L, 0);
return 1;
}
int val = rec->skills.count(skillName) ? rec->skills[skillName] : 0;
const auto *def = CharacterClassDatabase::getSingleton().findSkill(skillName);
if (def) {
if (val < 0)
val = 0;
if (val > def->maxValue)
val = def->maxValue;
}
lua_pushinteger(L, val);
return 1;
}
static int luaGetNeed(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *needName = lua_tostring(L, 2);
if (!rec || !needName) {
lua_pushinteger(L, 0);
return 1;
}
int val = rec->needs.count(needName) ? rec->needs[needName] : 0;
const auto *def = CharacterClassDatabase::getSingleton().findNeed(needName);
if (def) {
if (val < 0)
val = 0;
if (val > def->maxValue)
val = def->maxValue;
}
lua_pushinteger(L, val);
return 1;
}
static int luaGetAvailablePoints(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
lua_pushinteger(L, rec ? (lua_Integer)rec->availablePoints : 0);
return 1;
}
static int luaSetNeed(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *needName = lua_tostring(L, 2);
int value = static_cast<int>(lua_tointeger(L, 3));
if (!rec || !needName)
return 0;
rec->needs[needName] = value;
return 0;
}
static int luaGetPoolCurrent(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *poolName = lua_tostring(L, 2);
if (!rec || !poolName) {
lua_pushinteger(L, 0);
return 1;
}
int maxVal = rec->stats.count(poolName) ? rec->stats[poolName] : 0;
if (maxVal <= 0) {
lua_pushinteger(L, 0);
return 1;
}
auto it = rec->currentPools.find(poolName);
if (it == rec->currentPools.end()) {
lua_pushinteger(L, maxVal);
return 1;
}
int val = it->second;
if (val < 0)
val = 0;
if (val > maxVal)
val = maxVal;
lua_pushinteger(L, val);
return 1;
}
static int luaGetPoolMax(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *poolName = lua_tostring(L, 2);
if (!rec || !poolName) {
lua_pushinteger(L, 0);
return 1;
}
const auto *def = CharacterClassDatabase::getSingleton().findStat(poolName);
if (!def || def->kind != CharacterClassDatabase::StatKind::ResourcePool) {
lua_pushinteger(L, 0);
return 1;
}
int val = rec->stats.count(poolName) ? rec->stats[poolName] : 0;
if (val < def->minValue)
val = def->minValue;
if (val > def->maxValue)
val = def->maxValue;
lua_pushinteger(L, val);
return 1;
}
static int luaSetPoolCurrent(lua_State *L)
{
auto *rec = getCharacterRecord(L, 1);
const char *poolName = lua_tostring(L, 2);
int value = static_cast<int>(lua_tointeger(L, 3));
if (!rec || !poolName) {
lua_pushboolean(L, 0);
return 1;
}
int maxVal = rec->stats.count(poolName) ? rec->stats[poolName] : 0;
const auto *def = CharacterClassDatabase::getSingleton().findStat(poolName);
if (def) {
if (maxVal < def->minValue)
maxVal = def->minValue;
if (maxVal > def->maxValue)
maxVal = def->maxValue;
}
if (maxVal <= 0) {
lua_pushboolean(L, 0);
return 1;
}
if (value < 0)
value = 0;
if (value > maxVal)
value = maxVal;
rec->currentPools[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
+180 -79
View File
@@ -33,11 +33,12 @@
#include "components/Primitive.hpp"
#include "components/TriangleBuffer.hpp"
#include "components/CharacterSlots.hpp"
#include "components/CharacterIdentity.hpp"
#include "systems/CharacterRegistry.hpp"
#include "components/AnimationTree.hpp"
#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"
@@ -468,6 +469,18 @@ static void registerAllComponents()
lua_setfield(L, -2, "linearVelocity");
lua_pushboolean(L, c.enabled ? 1 : 0);
lua_setfield(L, -2, "enabled");
lua_pushnumber(L, c.headRadius);
lua_setfield(L, -2, "headRadius");
lua_pushnumber(L, c.headOffsetY);
lua_setfield(L, -2, "headOffsetY");
lua_pushstring(L, c.headBoneName.c_str());
lua_setfield(L, -2, "headBoneName");
pushVector3(L, c.chestHalfExtents);
lua_setfield(L, -2, "chestHalfExtents");
lua_pushnumber(L, c.chestOffsetY);
lua_setfield(L, -2, "chestOffsetY");
lua_pushstring(L, c.chestBoneName.c_str());
lua_setfield(L, -2, "chestBoneName");
lua_pushboolean(L, c.useGravity ? 1 : 0);
lua_setfield(L, -2, "useGravity");
, if (lua_getfield(L, idx, "radius"), lua_isnumber(L, -1))
@@ -485,6 +498,24 @@ static void registerAllComponents()
if (lua_getfield(L, idx, "enabled"), lua_isboolean(L, -1))
c.enabled = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, idx, "headRadius"), lua_isnumber(L, -1))
c.headRadius = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "headOffsetY"), lua_isnumber(L, -1))
c.headOffsetY = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "headBoneName"), lua_isstring(L, -1))
c.headBoneName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestHalfExtents"), lua_istable(L, -1))
c.chestHalfExtents = readVector3(L, lua_gettop(L));
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestOffsetY"), lua_isnumber(L, -1))
c.chestOffsetY = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "chestBoneName"), lua_isstring(L, -1))
c.chestBoneName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "useGravity"), lua_isboolean(L, -1))
c.useGravity = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
@@ -492,21 +523,95 @@ static void registerAllComponents()
// --- CharacterSlots ---
REGISTER_COMPONENT(
CharacterSlotsComponent, "CharacterSlots",
lua_pushstring(L, c.age.c_str());
lua_setfield(L, -2, "age"); lua_pushstring(L, c.sex.c_str());
lua_setfield(L, -2, "sex");
{ // 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");
, if (lua_getfield(L, idx, "age"), lua_isstring(L, -1))
c.age = lua_tostring(L, -1);
lua_pop(L, 1);
,
{ // 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);
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);
@@ -517,10 +622,57 @@ 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",
@@ -774,42 +926,29 @@ static void registerAllComponents()
// --- Item ---
REGISTER_COMPONENT(
ItemComponent, "Item", lua_pushstring(L, c.itemName.c_str());
lua_setfield(L, -2, "itemName");
lua_pushstring(L, c.itemType.c_str());
lua_setfield(L, -2, "itemType");
lua_pushstring(L, c.itemId.c_str());
ItemComponent, "Item", lua_pushstring(L, c.itemId.c_str());
lua_setfield(L, -2, "itemId"); lua_pushinteger(L, c.stackSize);
lua_setfield(L, -2, "stackSize");
lua_pushinteger(L, c.maxStackSize);
lua_setfield(L, -2, "maxStackSize");
lua_pushnumber(L, c.weight); lua_setfield(L, -2, "weight");
lua_pushinteger(L, c.value); lua_setfield(L, -2, "value");
lua_pushstring(L, c.useActionName.c_str());
lua_setfield(L, -2, "useActionName");
, if (lua_getfield(L, idx, "itemName"), lua_isstring(L, -1))
c.itemName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "itemType"), lua_isstring(L, -1))
c.itemType = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "itemId"), lua_isstring(L, -1))
c.itemId = lua_tostring(L, -1);
lua_pushstring(L, c.action.c_str());
lua_setfield(L, -2, "action");
lua_pushstring(L, c.instanceId.c_str());
lua_setfield(L, -2, "instanceId");
lua_pushboolean(L, c.disabled ? 1 : 0);
lua_setfield(L, -2, "disabled");
, if (lua_getfield(L, idx, "itemId"), lua_isstring(L, -1))
c.itemId = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "stackSize"), lua_isnumber(L, -1))
c.stackSize = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "maxStackSize"), lua_isnumber(L, -1))
c.maxStackSize = (int)lua_tointeger(L, -1);
if (lua_getfield(L, idx, "action"), lua_isstring(L, -1))
c.action = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "weight"), lua_isnumber(L, -1))
c.weight = (float)lua_tonumber(L, -1);
if (lua_getfield(L, idx, "instanceId"), lua_isstring(L, -1))
c.instanceId = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "value"), lua_isnumber(L, -1))
c.value = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, idx, "useActionName"), lua_isstring(L, -1))
c.useActionName = lua_tostring(L, -1);
if (lua_getfield(L, idx, "disabled"), lua_isboolean(L, -1))
c.disabled = lua_toboolean(L, -1) != 0;
lua_pop(L, 1););
// --- Inventory ---
@@ -819,6 +958,8 @@ static void registerAllComponents()
lua_setfield(L, -2, "maxWeight");
lua_pushboolean(L, c.isContainer ? 1 : 0);
lua_setfield(L, -2, "isContainer");
lua_pushstring(L, c.containerId.c_str());
lua_setfield(L, -2, "containerId");
, if (lua_getfield(L, idx, "maxSlots"), lua_isnumber(L, -1))
c.maxSlots = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
@@ -827,6 +968,9 @@ static void registerAllComponents()
lua_pop(L, 1);
if (lua_getfield(L, idx, "isContainer"), lua_isboolean(L, -1))
c.isContainer = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
if (lua_getfield(L, idx, "containerId"), lua_isstring(L, -1))
c.containerId = lua_tostring(L, -1);
lua_pop(L, 1););
// --- Lod ---
@@ -1245,49 +1389,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");
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, "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(
PlayerControllerComponent, "PlayerController",
@@ -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
+392
View File
@@ -0,0 +1,392 @@
#include "LuaItemApi.hpp"
#include "../systems/ItemRegistry.hpp"
#include "../systems/ContainerStateRegistry.hpp"
#include "../systems/ItemSystem.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include <OgreLogManager.h>
#include <flecs.h>
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;
}
namespace editScene
{
// ---------------------------------------------------------------------------
// ecs.items API
// ---------------------------------------------------------------------------
static int luaItemRegister(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isstring(L, 1) || !lua_istable(L, 2))
return 0;
std::string itemId = lua_tostring(L, 1);
ItemRegistry::ItemDefinition def;
def.itemId = itemId;
if (lua_getfield(L, 2, "itemName"), lua_isstring(L, -1))
def.itemName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "itemType"), lua_isstring(L, -1))
def.itemType = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "maxStackSize"), lua_isnumber(L, -1))
def.maxStackSize = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "weight"), lua_isnumber(L, -1))
def.weight = (float)lua_tonumber(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "value"), lua_isnumber(L, -1))
def.value = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "useActionName"), lua_isstring(L, -1))
def.useActionName = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, 2, "unique"), lua_isboolean(L, -1))
def.unique = lua_toboolean(L, -1) != 0;
lua_pop(L, 1);
ItemRegistry::getSingleton().registerItem(def);
return 0;
}
static int luaItemFind(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
std::string itemId = lua_tostring(L, 1);
auto *def = ItemRegistry::getSingleton().findDefinition(itemId);
if (!def)
return 0;
lua_newtable(L);
lua_pushstring(L, def->itemId.c_str());
lua_setfield(L, -2, "itemId");
lua_pushstring(L, def->itemName.c_str());
lua_setfield(L, -2, "itemName");
lua_pushstring(L, def->itemType.c_str());
lua_setfield(L, -2, "itemType");
lua_pushinteger(L, def->maxStackSize);
lua_setfield(L, -2, "maxStackSize");
lua_pushnumber(L, def->weight);
lua_setfield(L, -2, "weight");
lua_pushinteger(L, def->value);
lua_setfield(L, -2, "value");
lua_pushstring(L, def->useActionName.c_str());
lua_setfield(L, -2, "useActionName");
lua_pushboolean(L, def->unique ? 1 : 0);
lua_setfield(L, -2, "unique");
return 1;
}
static int luaItemList(lua_State *L)
{
lua_newtable(L);
int idx = 1;
for (const auto &pair : ItemRegistry::getSingleton().getDefinitions()) {
lua_pushstring(L, pair.second.itemId.c_str());
lua_rawseti(L, -2, idx++);
}
return 1;
}
static int luaItemIsUnique(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
lua_pushboolean(L,
ItemRegistry::getSingleton().isUnique(lua_tostring(L, 1)) ?
1 :
0);
return 1;
}
// ---------------------------------------------------------------------------
// ecs.inventory API
// ---------------------------------------------------------------------------
static flecs::entity luaCheckEntity(lua_State *L, int idx)
{
if (!lua_isnumber(L, idx))
return flecs::entity::null();
flecs::world world = getWorld(L);
return world.entity((flecs::entity_t)lua_tointeger(L, idx));
}
static int luaInventoryAdd(lua_State *L)
{
if (lua_gettop(L) < 3 || !lua_isnumber(L, 1) || !lua_isstring(L, 2) ||
!lua_isnumber(L, 3))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
std::string itemId = lua_tostring(L, 2);
int count = (int)lua_tointeger(L, 3);
// Find ItemSystem via ECS world query
bool added = false;
if (e.is_alive() && e.has<InventoryComponent>()) {
auto &inv = e.get_mut<InventoryComponent>();
int maxStack = ItemRegistry::getSingleton().getMaxStackSize(itemId);
// Simple add logic (no ItemSystem pointer available in Lua)
while (count > 0) {
bool stacked = false;
for (auto &slot : inv.slots) {
if (!slot.isEmpty() && slot.itemId == itemId &&
slot.stackSize < maxStack) {
int space = maxStack - slot.stackSize;
int add = std::min(space, count);
slot.stackSize += add;
count -= add;
stacked = true;
if (count <= 0)
break;
}
}
if (!stacked || count > 0) {
int slotIdx = inv.findEmptySlot();
if (slotIdx < 0)
break;
while ((int)inv.slots.size() <= slotIdx)
inv.slots.emplace_back();
auto &slot = inv.slots[slotIdx];
slot.itemId = itemId;
int add = std::min(count, maxStack);
slot.stackSize = add;
count -= add;
}
}
inv.recalculateWeight();
added = true;
}
lua_pushboolean(L, added ? 1 : 0);
return 1;
}
static int luaInventoryRemove(lua_State *L)
{
if (lua_gettop(L) < 3 || !lua_isnumber(L, 1) || !lua_isstring(L, 2) ||
!lua_isnumber(L, 3))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
std::string itemId = lua_tostring(L, 2);
int count = (int)lua_tointeger(L, 3);
int removed = 0;
if (e.is_alive() && e.has<InventoryComponent>()) {
auto &inv = e.get_mut<InventoryComponent>();
for (int i = (int)inv.slots.size() - 1;
i >= 0 && count > 0; i--) {
auto &slot = inv.slots[i];
if (slot.isEmpty() || slot.itemId != itemId)
continue;
int rem = std::min(count, slot.stackSize);
slot.stackSize -= rem;
count -= rem;
removed += rem;
if (slot.stackSize <= 0)
slot.clear();
}
inv.recalculateWeight();
}
lua_pushinteger(L, removed);
return 1;
}
static int luaInventoryHas(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isnumber(L, 1) || !lua_isstring(L, 2))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
std::string itemId = lua_tostring(L, 2);
bool has = false;
if (e.is_alive() && e.has<InventoryComponent>())
has = e.get<InventoryComponent>().hasItem(itemId);
lua_pushboolean(L, has ? 1 : 0);
return 1;
}
static int luaInventoryCount(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isnumber(L, 1) || !lua_isstring(L, 2))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
std::string itemId = lua_tostring(L, 2);
int count = 0;
if (e.is_alive() && e.has<InventoryComponent>())
count = e.get<InventoryComponent>().countItem(itemId);
lua_pushinteger(L, count);
return 1;
}
static int luaInventoryGetSlots(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isnumber(L, 1))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
lua_newtable(L);
if (e.is_alive() && e.has<InventoryComponent>()) {
auto &inv = e.get<InventoryComponent>();
int idx = 1;
for (const auto &slot : inv.slots) {
if (slot.isEmpty())
continue;
lua_newtable(L);
lua_pushstring(L, slot.itemId.c_str());
lua_setfield(L, -2, "itemId");
lua_pushinteger(L, slot.stackSize);
lua_setfield(L, -2, "stackSize");
lua_rawseti(L, -2, idx++);
}
}
return 1;
}
static int luaInventorySetSlots(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isnumber(L, 1) || !lua_istable(L, 2))
return 0;
flecs::entity e = luaCheckEntity(L, 1);
if (!e.is_alive() || !e.has<InventoryComponent>())
return 0;
auto &inv = e.get_mut<InventoryComponent>();
inv.slots.clear();
int len = (int)lua_rawlen(L, 2);
for (int i = 1; i <= len; i++) {
lua_rawgeti(L, 2, i);
if (lua_istable(L, -1)) {
InventorySlot slot;
if (lua_getfield(L, -1, "itemId"), lua_isstring(L, -1))
slot.itemId = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, -1, "stackSize"), lua_isnumber(L, -1))
slot.stackSize = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (!slot.isEmpty())
inv.slots.push_back(slot);
}
lua_pop(L, 1);
}
inv.recalculateWeight();
return 0;
}
// ---------------------------------------------------------------------------
// ecs.container API
// ---------------------------------------------------------------------------
static int luaContainerGetState(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
std::string containerId = lua_tostring(L, 1);
auto slots = ContainerStateRegistry::getInstance().getState(
containerId);
lua_newtable(L);
int idx = 1;
for (const auto &slot : slots) {
lua_newtable(L);
lua_pushstring(L, slot.itemId.c_str());
lua_setfield(L, -2, "itemId");
lua_pushinteger(L, slot.stackSize);
lua_setfield(L, -2, "stackSize");
lua_rawseti(L, -2, idx++);
}
return 1;
}
static int luaContainerSetState(lua_State *L)
{
if (lua_gettop(L) < 2 || !lua_isstring(L, 1) || !lua_istable(L, 2))
return 0;
std::string containerId = lua_tostring(L, 1);
std::vector<ContainerStateRegistry::ContainerSlot> slots;
int len = (int)lua_rawlen(L, 2);
for (int i = 1; i <= len; i++) {
lua_rawgeti(L, 2, i);
if (lua_istable(L, -1)) {
ContainerStateRegistry::ContainerSlot slot;
if (lua_getfield(L, -1, "itemId"), lua_isstring(L, -1))
slot.itemId = lua_tostring(L, -1);
lua_pop(L, 1);
if (lua_getfield(L, -1, "stackSize"), lua_isnumber(L, -1))
slot.stackSize = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
slots.push_back(slot);
}
lua_pop(L, 1);
}
ContainerStateRegistry::getInstance().loadState(containerId, slots);
return 0;
}
static int luaContainerClearState(lua_State *L)
{
if (lua_gettop(L) < 1 || !lua_isstring(L, 1))
return 0;
ContainerStateRegistry::getInstance().clearState(lua_tostring(L, 1));
return 0;
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
void registerLuaItemApi(lua_State *L)
{
// ecs.items
lua_newtable(L);
lua_pushcfunction(L, luaItemRegister);
lua_setfield(L, -2, "register");
lua_pushcfunction(L, luaItemFind);
lua_setfield(L, -2, "find");
lua_pushcfunction(L, luaItemList);
lua_setfield(L, -2, "list");
lua_pushcfunction(L, luaItemIsUnique);
lua_setfield(L, -2, "is_unique");
lua_setfield(L, -2, "items");
// ecs.inventory
lua_newtable(L);
lua_pushcfunction(L, luaInventoryAdd);
lua_setfield(L, -2, "add");
lua_pushcfunction(L, luaInventoryRemove);
lua_setfield(L, -2, "remove");
lua_pushcfunction(L, luaInventoryHas);
lua_setfield(L, -2, "has");
lua_pushcfunction(L, luaInventoryCount);
lua_setfield(L, -2, "count");
lua_pushcfunction(L, luaInventoryGetSlots);
lua_setfield(L, -2, "get_slots");
lua_pushcfunction(L, luaInventorySetSlots);
lua_setfield(L, -2, "set_slots");
lua_setfield(L, -2, "inventory");
// ecs.container
lua_newtable(L);
lua_pushcfunction(L, luaContainerGetState);
lua_setfield(L, -2, "get_state");
lua_pushcfunction(L, luaContainerSetState);
lua_setfield(L, -2, "set_state");
lua_pushcfunction(L, luaContainerClearState);
lua_setfield(L, -2, "clear_state");
lua_setfield(L, -2, "container");
}
} // namespace editScene
+14
View File
@@ -0,0 +1,14 @@
#ifndef EDITSCENE_LUA_ITEM_API_HPP
#define EDITSCENE_LUA_ITEM_API_HPP
#pragma once
#include <lua.hpp>
namespace editScene
{
void registerLuaItemApi(lua_State *L);
} // namespace editScene
#endif // EDITSCENE_LUA_ITEM_API_HPP
@@ -0,0 +1,220 @@
#include "LuaSaveLoadApi.hpp"
#include <OgreLogManager.h>
#include <string>
#include <unordered_map>
namespace editScene
{
/* ===================================================================== */
/* Internal state */
/* ===================================================================== */
static std::unordered_map<std::string, int> s_saveCallbacks;
static std::unordered_map<std::string, int> s_loadCallbacks;
/* ===================================================================== */
/* Lua table <-> JSON conversion */
/* ===================================================================== */
nlohmann::json luaTableToJson(lua_State *L, int index)
{
nlohmann::json result;
int absIdx = lua_absindex(L, index);
if (lua_istable(L, absIdx)) {
/* First, try to detect if it's an array */
bool isArray = true;
lua_len(L, absIdx);
int len = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if (len > 0) {
for (int i = 1; i <= len; i++) {
lua_rawgeti(L, absIdx, i);
if (lua_isnil(L, -1)) {
isArray = false;
lua_pop(L, 1);
break;
}
lua_pop(L, 1);
}
}
if (isArray && len > 0) {
result = nlohmann::json::array();
for (int i = 1; i <= len; i++) {
lua_rawgeti(L, absIdx, i);
result.push_back(luaTableToJson(L, -1));
lua_pop(L, 1);
}
} else {
result = nlohmann::json::object();
lua_pushnil(L);
while (lua_next(L, absIdx) != 0) {
std::string key;
if (lua_type(L, -2) == LUA_TNUMBER) {
key = std::to_string(
(lua_Integer)lua_tonumber(L, -2));
} else if (lua_type(L, -2) == LUA_TSTRING) {
key = lua_tostring(L, -2);
} else {
lua_pop(L, 1);
continue;
}
result[key] = luaTableToJson(L, -1);
lua_pop(L, 1);
}
}
} else if (lua_isboolean(L, absIdx)) {
result = lua_toboolean(L, absIdx) != 0;
} else if (lua_isinteger(L, absIdx)) {
result = (int64_t)lua_tointeger(L, absIdx);
} else if (lua_isnumber(L, absIdx)) {
result = lua_tonumber(L, absIdx);
} else if (lua_isstring(L, absIdx)) {
result = lua_tostring(L, absIdx);
} else {
result = nullptr;
}
return result;
}
void jsonToLuaValue(lua_State *L, const nlohmann::json &j)
{
if (j.is_null()) {
lua_pushnil(L);
} else if (j.is_boolean()) {
lua_pushboolean(L, j.get<bool>());
} else if (j.is_number_integer()) {
lua_pushinteger(L, (lua_Integer)j.get<int64_t>());
} else if (j.is_number_float()) {
lua_pushnumber(L, j.get<double>());
} else if (j.is_string()) {
lua_pushstring(L, j.get<std::string>().c_str());
} else if (j.is_array()) {
lua_newtable(L);
int i = 1;
for (const auto &elem : j) {
jsonToLuaValue(L, elem);
lua_rawseti(L, -2, i++);
}
} else if (j.is_object()) {
lua_newtable(L);
for (auto &[key, val] : j.items()) {
lua_pushstring(L, key.c_str());
jsonToLuaValue(L, val);
lua_rawset(L, -3);
}
} else {
lua_pushnil(L);
}
}
/* ===================================================================== */
/* Save / Load data collection */
/* ===================================================================== */
nlohmann::json collectLuaSaveData(lua_State *L)
{
nlohmann::json data;
for (const auto &pair : s_saveCallbacks) {
const std::string &name = pair.first;
int ref = pair.second;
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
if (lua_pcall(L, 0, 1, 0) == 0) {
if (lua_istable(L, -1)) {
data[name] = luaTableToJson(L, -1);
}
lua_pop(L, 1);
} else {
Ogre::LogManager::getSingleton().logMessage(
"Lua save callback '" + name +
"' error: " + lua_tostring(L, -1));
lua_pop(L, 1);
}
}
return data;
}
void applyLuaLoadData(lua_State *L, const nlohmann::json &data)
{
for (const auto &pair : s_loadCallbacks) {
const std::string &name = pair.first;
int ref = pair.second;
if (!data.contains(name))
continue;
lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
jsonToLuaValue(L, data[name]);
if (lua_pcall(L, 1, 0, 0) != 0) {
Ogre::LogManager::getSingleton().logMessage(
"Lua load callback '" + name +
"' error: " + lua_tostring(L, -1));
lua_pop(L, 1);
}
}
}
/* ===================================================================== */
/* Lua C API functions */
/* ===================================================================== */
static int luaRegisterSaveCallback(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
/* Unregister existing callback with same name */
auto it = s_saveCallbacks.find(name);
if (it != s_saveCallbacks.end()) {
luaL_unref(L, LUA_REGISTRYINDEX, it->second);
}
/* Store new callback in registry */
lua_pushvalue(L, 2);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
s_saveCallbacks[name] = ref;
return 0;
}
static int luaRegisterLoadCallback(lua_State *L)
{
const char *name = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
auto it = s_loadCallbacks.find(name);
if (it != s_loadCallbacks.end()) {
luaL_unref(L, LUA_REGISTRYINDEX, it->second);
}
lua_pushvalue(L, 2);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
s_loadCallbacks[name] = ref;
return 0;
}
/* ===================================================================== */
/* Registration */
/* ===================================================================== */
void registerLuaSaveLoadApi(lua_State *L)
{
lua_getglobal(L, "ecs");
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return;
}
lua_pushcfunction(L, luaRegisterSaveCallback);
lua_setfield(L, -2, "register_save_callback");
lua_pushcfunction(L, luaRegisterLoadCallback);
lua_setfield(L, -2, "register_load_callback");
lua_pop(L, 1);
}
} // namespace editScene
@@ -0,0 +1,48 @@
#ifndef EDITSCENE_LUA_SAVELOAD_API_HPP
#define EDITSCENE_LUA_SAVELOAD_API_HPP
#pragma once
#include <lua.hpp>
#include <nlohmann/json.hpp>
namespace editScene
{
/**
* @brief Register save/load callback Lua API functions into the "ecs" table.
*
* Adds:
* ecs.register_save_callback(name, function() return table end)
* ecs.register_load_callback(name, function(table) end)
*
* @param L The Lua state.
*/
void registerLuaSaveLoadApi(lua_State *L);
/**
* @brief Call all registered Lua save callbacks and collect their data.
* @param L Lua state.
* @return JSON object keyed by callback name.
*/
nlohmann::json collectLuaSaveData(lua_State *L);
/**
* @brief Call all registered Lua load callbacks with saved data.
* @param L Lua state.
* @param data JSON object keyed by callback name.
*/
void applyLuaLoadData(lua_State *L, const nlohmann::json &data);
/**
* @brief Convert a Lua table at the given stack index to JSON.
*/
nlohmann::json luaTableToJson(lua_State *L, int index);
/**
* @brief Push a JSON value onto the Lua stack as a table/value.
*/
void jsonToLuaValue(lua_State *L, const nlohmann::json &j);
} // namespace editScene
#endif // EDITSCENE_LUA_SAVELOAD_API_HPP
@@ -0,0 +1,814 @@
/*
* -----------------------------------------------------------------------------
* This source file is part of OGRE
* (Object-oriented Graphics Rendering Engine)
* For the latest info, see http://www.ogre3d.org/
*
* Copyright (c) 2000-2014 Torus Knot Software Ltd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
* -----------------------------------------------------------------------------
*/
#include "OgrePackageArchive.h"
#include "OgreException.h"
#include "OgreLogManager.h"
#include "OgreString.h"
#include "OgreStringVector.h"
#include <algorithm>
#include <cstring>
#include <sys/stat.h>
namespace Ogre
{
const uint32_t PackageArchive::MAGIC = 0x4B434150; // "PACK" in little-endian
const uint32_t PackageArchive::FORMAT_VERSION = 1;
//-----------------------------------------------------------------------
PackageArchive::PackageArchive(const String &name, const String &archType)
: Archive(name, archType)
, mLoaded(false)
{
mFilePath = name;
}
//-----------------------------------------------------------------------
PackageArchive::~PackageArchive()
{
unload();
}
//-----------------------------------------------------------------------
void PackageArchive::load()
{
OGRE_LOCK_AUTO_MUTEX;
if (mLoaded)
return;
// Open the file for reading
mFileStream.open(mFilePath.c_str(),
std::ios::in | std::ios::out | std::ios::binary);
if (!mFileStream.is_open()) {
// Try opening as read-only if read-write failed
mFileStream.clear();
mFileStream.open(mFilePath.c_str(),
std::ios::in | std::ios::binary);
}
if (mFileStream.is_open()) {
// Read the index from the existing file
readIndex();
mLoaded = true;
// Check if we have write access
mFileStream.clear();
mFileStream.seekp(0, std::ios::end);
mReadOnly = !mFileStream.good();
} else {
// File doesn't exist yet - create it for writing
mFileStream.clear();
mFileStream.open(mFilePath.c_str(),
std::ios::in | std::ios::out |
std::ios::binary | std::ios::trunc);
if (mFileStream.is_open()) {
mLoaded = true;
mReadOnly = false;
} else {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"Cannot open package file: " + mFilePath,
"PackageArchive::load");
}
}
}
//-----------------------------------------------------------------------
void PackageArchive::unload()
{
OGRE_LOCK_AUTO_MUTEX;
if (mFileStream.is_open()) {
mFileStream.close();
}
mFileIndex.clear();
mLoaded = false;
}
//-----------------------------------------------------------------------
void PackageArchive::readIndex()
{
// Seek to end to read footer
mFileStream.seekg(0, std::ios::end);
std::streampos fileSize = mFileStream.tellg();
if (fileSize < static_cast<std::streamoff>(12)) {
// File too small to contain a valid footer
return;
}
// Read magic number and index offset from the end
// Footer layout (last 12 bytes):
// [index offset (uint64_t)] [magic (uint32_t)]
// The index offset is at offset -12 from end, magic is at offset -4
uint64_t indexOffset;
uint32_t magic;
mFileStream.seekg(-static_cast<std::streamoff>(12), std::ios::end);
mFileStream.read(reinterpret_cast<char *>(&indexOffset),
sizeof(indexOffset));
mFileStream.read(reinterpret_cast<char *>(&magic), sizeof(magic));
if (magic != MAGIC) {
// Not a valid package file
if (LogManager::getSingletonPtr())
LogManager::getSingleton().logMessage(
"PackageArchive: Invalid magic number in " +
mFilePath);
return;
}
// Seek to the index
mFileStream.seekg(static_cast<std::streamoff>(indexOffset),
std::ios::beg);
// Read number of files
uint32_t numFiles;
mFileStream.read(reinterpret_cast<char *>(&numFiles), sizeof(numFiles));
mFileIndex.clear();
for (uint32_t i = 0; i < numFiles; i++) {
// Read filename length and data
uint32_t nameLen;
mFileStream.read(reinterpret_cast<char *>(&nameLen),
sizeof(nameLen));
std::string filename(nameLen, '\0');
mFileStream.read(&filename[0], nameLen);
// Read entry data
FileEntry entry;
mFileStream.read(reinterpret_cast<char *>(&entry.offset),
sizeof(entry.offset));
mFileStream.read(reinterpret_cast<char *>(&entry.size),
sizeof(entry.size));
mFileStream.read(reinterpret_cast<char *>(&entry.timestamp),
sizeof(entry.timestamp));
mFileIndex[filename] = entry;
}
}
//-----------------------------------------------------------------------
void PackageArchive::writeIndex()
{
// Write the index and footer at the current stream position.
// The caller is responsible for positioning the write cursor
// at the correct offset (after all file data).
uint32_t numFiles = static_cast<uint32_t>(mFileIndex.size());
mFileStream.write(reinterpret_cast<const char *>(&numFiles),
sizeof(numFiles));
for (auto &[filename, entry] : mFileIndex) {
uint32_t nameLen = static_cast<uint32_t>(filename.length());
mFileStream.write(reinterpret_cast<const char *>(&nameLen),
sizeof(nameLen));
mFileStream.write(filename.c_str(), nameLen);
mFileStream.write(reinterpret_cast<const char *>(&entry.offset),
sizeof(entry.offset));
mFileStream.write(reinterpret_cast<const char *>(&entry.size),
sizeof(entry.size));
mFileStream.write(
reinterpret_cast<const char *>(&entry.timestamp),
sizeof(entry.timestamp));
}
// Write footer: index offset + magic
uint64_t indexOffset = static_cast<uint64_t>(mFileStream.tellp());
mFileStream.write(reinterpret_cast<const char *>(&indexOffset),
sizeof(indexOffset));
mFileStream.write(reinterpret_cast<const char *>(&MAGIC),
sizeof(MAGIC));
mFileStream.flush();
}
//-----------------------------------------------------------------------
void PackageArchive::rebuildPackage()
{
// Rebuild the entire package file from the in-memory index.
// Strategy: write to a temporary file, then swap.
String tmpPath = mFilePath + ".tmp";
std::ofstream tmpFile(tmpPath.c_str(),
std::ios::binary | std::ios::trunc);
if (!tmpFile.is_open()) {
OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE,
"Cannot create temporary file: " + tmpPath,
"PackageArchive::rebuildPackage");
}
// Write file data sequentially and build new offsets
std::map<String, FileEntry> newIndex;
uint64_t currentOffset = 0;
for (auto &[filename, entry] : mFileIndex) {
// Read the old data from the original file
std::vector<char> buffer(static_cast<size_t>(entry.size));
mFileStream.seekg(static_cast<std::streamoff>(entry.offset),
std::ios::beg);
mFileStream.read(buffer.data(), buffer.size());
// Write to temp file at new position
tmpFile.write(buffer.data(), buffer.size());
// Update entry
FileEntry newEntry;
newEntry.offset = currentOffset;
newEntry.size = entry.size;
newEntry.timestamp = entry.timestamp;
newIndex[filename] = newEntry;
currentOffset += entry.size;
}
// Write index and footer to temp file
uint32_t numFiles = static_cast<uint32_t>(newIndex.size());
tmpFile.write(reinterpret_cast<const char *>(&numFiles),
sizeof(numFiles));
for (auto &[filename, entry] : newIndex) {
uint32_t nameLen = static_cast<uint32_t>(filename.length());
tmpFile.write(reinterpret_cast<const char *>(&nameLen),
sizeof(nameLen));
tmpFile.write(filename.c_str(), nameLen);
tmpFile.write(reinterpret_cast<const char *>(&entry.offset),
sizeof(entry.offset));
tmpFile.write(reinterpret_cast<const char *>(&entry.size),
sizeof(entry.size));
tmpFile.write(reinterpret_cast<const char *>(&entry.timestamp),
sizeof(entry.timestamp));
}
uint64_t indexOffset = currentOffset;
tmpFile.write(reinterpret_cast<const char *>(&indexOffset),
sizeof(indexOffset));
tmpFile.write(reinterpret_cast<const char *>(&MAGIC), sizeof(MAGIC));
tmpFile.flush();
tmpFile.close();
// Close the original file
mFileStream.close();
// Replace the original with the temp file
if (std::rename(tmpPath.c_str(), mFilePath.c_str()) != 0) {
OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE,
"Cannot replace package file: " + mFilePath,
"PackageArchive::rebuildPackage");
}
// Re-open the file
mFileStream.open(mFilePath.c_str(),
std::ios::in | std::ios::out | std::ios::binary);
if (!mFileStream.is_open()) {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"Cannot re-open package file after rebuild: " +
mFilePath,
"PackageArchive::rebuildPackage");
}
// Update the in-memory index with new offsets
mFileIndex = newIndex;
}
//-----------------------------------------------------------------------
DataStreamPtr PackageArchive::open(const String &filename, bool readOnly) const
{
OGRE_LOCK_AUTO_MUTEX;
if (!mLoaded) {
OGRE_EXCEPT(Exception::ERR_INVALID_STATE, "Archive not loaded",
"PackageArchive::open");
}
String lookUp = filename;
// Try direct match first
auto it = mFileIndex.find(lookUp);
if (it == mFileIndex.end()) {
// Try with just the basename (non-strict mode)
String basename, path;
StringUtil::splitFilename(lookUp, basename, path);
// Search for matching basename
for (auto &[fname, entry] : mFileIndex) {
String fbasename, fpath;
StringUtil::splitFilename(fname, fbasename, fpath);
if (fbasename == basename) {
lookUp = fname;
it = mFileIndex.find(lookUp);
break;
}
}
}
if (it == mFileIndex.end()) {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"File not found in package: " + filename,
"PackageArchive::open");
}
const FileEntry &entry = it->second;
// Read the data from the file
auto buf = OGRE_ALLOC_T(char, entry.size, MEMCATEGORY_GENERAL);
mFileStream.seekg(static_cast<std::streamoff>(entry.offset),
std::ios::beg);
mFileStream.read(buf, entry.size);
auto ret = std::make_shared<MemoryDataStream>(
lookUp, buf, static_cast<size_t>(entry.size), true, true);
return ret;
}
//-----------------------------------------------------------------------
DataStreamPtr PackageArchive::create(const String &filename)
{
OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED,
"Use addFile() to add files to a package archive",
"PackageArchive::create");
}
//-----------------------------------------------------------------------
void PackageArchive::remove(const String &filename)
{
OGRE_LOCK_AUTO_MUTEX;
if (mReadOnly) {
OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED,
"Cannot remove file from read-only package",
"PackageArchive::remove");
}
auto it = mFileIndex.find(filename);
if (it == mFileIndex.end()) {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"File not found in package: " + filename,
"PackageArchive::remove");
}
mFileIndex.erase(it);
// Rebuild the entire package without the removed file
rebuildPackage();
}
//-----------------------------------------------------------------------
StringVectorPtr PackageArchive::list(bool recursive, bool dirs) const
{
OGRE_LOCK_AUTO_MUTEX;
auto ret = std::make_shared<StringVector>();
for (auto &[filename, entry] : mFileIndex) {
// In our flat archive, there are no directories
if (dirs)
continue;
if (recursive) {
ret->push_back(filename);
} else {
// Only top-level files (no '/' in path)
if (filename.find('/') == String::npos)
ret->push_back(filename);
}
}
return ret;
}
//-----------------------------------------------------------------------
FileInfoListPtr PackageArchive::listFileInfo(bool recursive, bool dirs) const
{
OGRE_LOCK_AUTO_MUTEX;
auto ret = std::make_shared<FileInfoList>();
for (auto &[filename, entry] : mFileIndex) {
if (dirs)
continue;
if (!recursive && filename.find('/') != String::npos)
continue;
FileInfo info;
info.archive = this;
info.filename = filename;
info.compressedSize = static_cast<size_t>(entry.size);
info.uncompressedSize = static_cast<size_t>(entry.size);
StringUtil::splitFilename(filename, info.basename, info.path);
ret->push_back(info);
}
return ret;
}
//-----------------------------------------------------------------------
StringVectorPtr PackageArchive::find(const String &pattern, bool recursive,
bool dirs) const
{
OGRE_LOCK_AUTO_MUTEX;
auto ret = std::make_shared<StringVector>();
bool full_match = (pattern.find('/') != String::npos) ||
(pattern.find('\\') != String::npos);
bool wildCard = pattern.find('*') != String::npos;
for (auto &[filename, entry] : mFileIndex) {
if (dirs)
continue;
if (!recursive && !full_match && !wildCard) {
// For non-recursive, non-wildcard searches,
// check exact match
String basename, path;
StringUtil::splitFilename(filename, basename, path);
if (filename == pattern || basename == pattern)
ret->push_back(filename);
continue;
}
String basename, path;
StringUtil::splitFilename(filename, basename, path);
// For non-recursive searches with wildcards, skip files in subdirectories
if (!recursive && !full_match && wildCard && !path.empty())
continue;
if (StringUtil::match(full_match ? filename : basename, pattern,
false)) {
ret->push_back(filename);
}
}
return ret;
}
//-----------------------------------------------------------------------
FileInfoListPtr PackageArchive::findFileInfo(const String &pattern,
bool recursive, bool dirs) const
{
OGRE_LOCK_AUTO_MUTEX;
auto ret = std::make_shared<FileInfoList>();
bool full_match = (pattern.find('/') != String::npos) ||
(pattern.find('\\') != String::npos);
bool wildCard = pattern.find('*') != String::npos;
for (auto &[filename, entry] : mFileIndex) {
if (dirs)
continue;
if (!recursive && !full_match && !wildCard) {
// For non-recursive, non-wildcard searches,
// check exact match
String basename, path;
StringUtil::splitFilename(filename, basename, path);
if (filename == pattern || basename == pattern) {
FileInfo info;
info.archive = this;
info.filename = filename;
info.compressedSize =
static_cast<size_t>(entry.size);
info.uncompressedSize =
static_cast<size_t>(entry.size);
info.basename = basename;
info.path = path;
ret->push_back(info);
}
continue;
}
String basename, path;
StringUtil::splitFilename(filename, basename, path);
// For non-recursive searches with wildcards, skip files in subdirectories
if (!recursive && !full_match && wildCard && !path.empty())
continue;
if (StringUtil::match(full_match ? filename : basename, pattern,
false)) {
FileInfo info;
info.archive = this;
info.filename = filename;
info.compressedSize = static_cast<size_t>(entry.size);
info.uncompressedSize = static_cast<size_t>(entry.size);
info.basename = basename;
info.path = path;
ret->push_back(info);
}
}
return ret;
}
//-----------------------------------------------------------------------
bool PackageArchive::exists(const String &filename) const
{
OGRE_LOCK_AUTO_MUTEX;
if (!mLoaded)
return false;
String lookUp = filename;
// Try direct match
if (mFileIndex.find(lookUp) != mFileIndex.end())
return true;
// Try basename match
String basename, path;
StringUtil::splitFilename(lookUp, basename, path);
for (auto &[fname, entry] : mFileIndex) {
String fbasename, fpath;
StringUtil::splitFilename(fname, fbasename, fpath);
if (fbasename == basename)
return true;
}
return false;
}
//-----------------------------------------------------------------------
time_t PackageArchive::getModifiedTime(const String &filename) const
{
OGRE_LOCK_AUTO_MUTEX;
auto it = mFileIndex.find(filename);
if (it == mFileIndex.end()) {
// Fall back to the package file's modification time
struct stat tagStat;
if (stat(mFilePath.c_str(), &tagStat) == 0)
return tagStat.st_mtime;
return 0;
}
return static_cast<time_t>(it->second.timestamp);
}
//-----------------------------------------------------------------------
void PackageArchive::addFile(const String &filename, const void *data,
size_t size, int64_t timestamp)
{
OGRE_LOCK_AUTO_MUTEX;
if (mReadOnly) {
OGRE_EXCEPT(Exception::ERR_NOT_IMPLEMENTED,
"Cannot add file to read-only package",
"PackageArchive::addFile");
}
if (!mLoaded) {
load();
// After loading a non-existent file, it's writable
mReadOnly = false;
}
// Create new entry
FileEntry entry;
entry.offset = 0; // Will be set during rebuild
entry.size = size;
entry.timestamp = (timestamp != 0) ? timestamp : time(nullptr);
// If the file already exists, we need to replace it
auto it = mFileIndex.find(filename);
if (it != mFileIndex.end()) {
// Replace existing entry
it->second = entry;
} else {
mFileIndex[filename] = entry;
}
// Rebuild the entire package with the new file data
// We need to write the new data into the rebuilt file.
// rebuildPackage reads from the old file, so we need to handle
// the new data separately. We'll do a custom rebuild.
String tmpPath = mFilePath + ".tmp";
std::ofstream tmpFile(tmpPath.c_str(),
std::ios::binary | std::ios::trunc);
if (!tmpFile.is_open()) {
OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE,
"Cannot create temporary file: " + tmpPath,
"PackageArchive::addFile");
}
// Write file data sequentially and build new offsets
std::map<String, FileEntry> newIndex;
uint64_t currentOffset = 0;
for (auto &[fname, fentry] : mFileIndex) {
if (fname == filename) {
// This is the new/updated file - write the provided
// data
tmpFile.write(static_cast<const char *>(data),
static_cast<std::streamsize>(size));
FileEntry newEntry;
newEntry.offset = currentOffset;
newEntry.size = size;
newEntry.timestamp = entry.timestamp;
newIndex[fname] = newEntry;
currentOffset += size;
} else {
// Read existing data from the original file
std::vector<char> buffer(
static_cast<size_t>(fentry.size));
mFileStream.seekg(
static_cast<std::streamoff>(fentry.offset),
std::ios::beg);
mFileStream.read(buffer.data(), buffer.size());
// Write to temp file
tmpFile.write(buffer.data(), buffer.size());
FileEntry newEntry;
newEntry.offset = currentOffset;
newEntry.size = fentry.size;
newEntry.timestamp = fentry.timestamp;
newIndex[fname] = newEntry;
currentOffset += fentry.size;
}
}
// Write index and footer to temp file
uint32_t numFiles = static_cast<uint32_t>(newIndex.size());
tmpFile.write(reinterpret_cast<const char *>(&numFiles),
sizeof(numFiles));
for (auto &[fname, fentry] : newIndex) {
uint32_t nameLen = static_cast<uint32_t>(fname.length());
tmpFile.write(reinterpret_cast<const char *>(&nameLen),
sizeof(nameLen));
tmpFile.write(fname.c_str(), nameLen);
tmpFile.write(reinterpret_cast<const char *>(&fentry.offset),
sizeof(fentry.offset));
tmpFile.write(reinterpret_cast<const char *>(&fentry.size),
sizeof(fentry.size));
tmpFile.write(reinterpret_cast<const char *>(&fentry.timestamp),
sizeof(fentry.timestamp));
}
uint64_t indexOffset = currentOffset;
tmpFile.write(reinterpret_cast<const char *>(&indexOffset),
sizeof(indexOffset));
tmpFile.write(reinterpret_cast<const char *>(&MAGIC), sizeof(MAGIC));
tmpFile.flush();
tmpFile.close();
// Close the original file
mFileStream.close();
// Replace the original with the temp file
if (std::rename(tmpPath.c_str(), mFilePath.c_str()) != 0) {
OGRE_EXCEPT(Exception::ERR_CANNOT_WRITE_TO_FILE,
"Cannot replace package file: " + mFilePath,
"PackageArchive::addFile");
}
// Re-open the file
mFileStream.open(mFilePath.c_str(),
std::ios::in | std::ios::out | std::ios::binary);
if (!mFileStream.is_open()) {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"Cannot re-open package file after add: " +
mFilePath,
"PackageArchive::addFile");
}
// Update the in-memory index with new offsets
mFileIndex = newIndex;
}
//-----------------------------------------------------------------------
void PackageArchive::addFileFromDisk(const String &destFilename,
const String &sourcePath)
{
std::ifstream source(sourcePath.c_str(),
std::ios::binary | std::ios::ate);
if (!source.is_open()) {
OGRE_EXCEPT(Exception::ERR_FILE_NOT_FOUND,
"Cannot open source file: " + sourcePath,
"PackageArchive::addFileFromDisk");
}
std::streamsize size = source.tellg();
source.seekg(0, std::ios::beg);
std::vector<char> buffer(static_cast<size_t>(size));
source.read(buffer.data(), size);
source.close();
// Get file modification time
struct stat tagStat;
int64_t timestamp = 0;
if (stat(sourcePath.c_str(), &tagStat) == 0) {
timestamp = static_cast<int64_t>(tagStat.st_mtime);
}
addFile(destFilename, buffer.data(), static_cast<size_t>(size),
timestamp);
}
//-----------------------------------------------------------------------
bool PackageArchive::extractFile(const String &filename,
const String &destPath) const
{
OGRE_LOCK_AUTO_MUTEX;
auto it = mFileIndex.find(filename);
if (it == mFileIndex.end())
return false;
const FileEntry &entry = it->second;
std::vector<char> buffer(static_cast<size_t>(entry.size));
mFileStream.seekg(static_cast<std::streamoff>(entry.offset),
std::ios::beg);
mFileStream.read(buffer.data(), buffer.size());
std::ofstream dest(destPath.c_str(), std::ios::binary);
if (!dest.is_open())
return false;
dest.write(buffer.data(), buffer.size());
dest.close();
return true;
}
//-----------------------------------------------------------------------
StringVector PackageArchive::listFiles() const
{
OGRE_LOCK_AUTO_MUTEX;
StringVector result;
for (auto &[filename, entry] : mFileIndex) {
result.push_back(filename);
}
return result;
}
//-----------------------------------------------------------------------
// PackageArchiveFactory
//-----------------------------------------------------------------------
Archive *PackageArchiveFactory::createInstance(const String &name,
bool readOnly)
{
// Package archives support both read-only and read-write access
return OGRE_NEW PackageArchive(name, getType());
}
//-----------------------------------------------------------------------
const String &PackageArchiveFactory::getType(void) const
{
static String name = "Package";
return name;
}
} // namespace Ogre
@@ -0,0 +1,223 @@
/*
* -----------------------------------------------------------------------------
* This source file is part of OGRE
* (Object-oriented Graphics Rendering Engine)
* For the latest info, see http://www.ogre3d.org/
*
* Copyright (c) 2000-2014 Torus Knot Software Ltd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
* -----------------------------------------------------------------------------
*/
#ifndef __OgrePackageArchive_H__
#define __OgrePackageArchive_H__
#include "OgrePrerequisites.h"
#include "OgreArchive.h"
#include "OgreArchiveFactory.h"
#include "OgreHeaderPrefix.h"
#include "Threading/OgreThreadHeaders.h"
#include <fstream>
#include <map>
#include <vector>
#include <cstdint>
namespace Ogre
{
/** \addtogroup Core
* @{
*/
/** \addtogroup Resources
* @{
*/
/**
* Package archive format - an uncompressed indexed file container.
*
* The .package format is a simple archive format similar to Unity's asset
* bundles. It stores files sequentially with a file index at the end,
* allowing fast random access without compression overhead.
*
* Format layout:
* [File 1 Data]
* [File 2 Data]
* ...
* [File N Data]
* [Index Block]
* - Number of files (uint32_t)
* - For each file:
* - Filename length (uint32_t)
* - Filename data
* - Offset (uint64_t)
* - Size (uint64_t)
* - Timestamp (int64_t)
* [Magic Footer]
* - Index offset (uint64_t)
* - Magic number "PACK" (uint32_t)
*/
class _OgreExport PackageArchive : public Archive {
public:
struct FileEntry {
uint64_t offset;
uint64_t size;
int64_t timestamp;
};
protected:
/// File path to the .package file on disk
String mFilePath;
/// File stream for reading/writing
mutable std::fstream mFileStream;
/// File index: filename -> FileEntry
typedef std::map<String, FileEntry> FileIndex;
FileIndex mFileIndex;
/// Whether the archive is loaded
bool mLoaded;
/// Magic number and format constants
static const uint32_t MAGIC;
static const uint32_t FORMAT_VERSION;
OGRE_AUTO_MUTEX;
/** Read the index from the package file. */
void readIndex();
/** Write the index to the package file.
* This rewrites the entire file: data blocks followed by index and footer.
*/
void writeIndex();
/** Rebuild the package file from the current in-memory index.
* This is used after add/remove operations to produce a clean file.
*/
void rebuildPackage();
public:
PackageArchive(const String &name, const String &archType);
~PackageArchive();
/// @copydoc Archive::isCaseSensitive
bool isCaseSensitive(void) const override
{
return false;
}
/// @copydoc Archive::load
void load() override;
/// @copydoc Archive::unload
void unload() override;
/// @copydoc Archive::isReadOnly
bool isReadOnly() const override
{
return mReadOnly;
}
/// @copydoc Archive::open
DataStreamPtr open(const String &filename,
bool readOnly = true) const override;
/// @copydoc Archive::create
DataStreamPtr create(const String &filename) override;
/// @copydoc Archive::remove
void remove(const String &filename) override;
/// @copydoc Archive::list
StringVectorPtr list(bool recursive = true,
bool dirs = false) const override;
/// @copydoc Archive::listFileInfo
FileInfoListPtr listFileInfo(bool recursive = true,
bool dirs = false) const override;
/// @copydoc Archive::find
StringVectorPtr find(const String &pattern, bool recursive = true,
bool dirs = false) const override;
/// @copydoc Archive::findFileInfo
FileInfoListPtr findFileInfo(const String &pattern,
bool recursive = true,
bool dirs = false) const override;
/// @copydoc Archive::exists
bool exists(const String &filename) const override;
/// @copydoc Archive::getModifiedTime
time_t getModifiedTime(const String &filename) const override;
/** Add a file from an external source into the package.
* @param filename The name to store the file as in the archive
* @param data Pointer to the file data
* @param size Size of the data in bytes
* @param timestamp Modification time (0 = use current time)
*/
void addFile(const String &filename, const void *data, size_t size,
int64_t timestamp = 0);
/** Add a file from the filesystem into the package.
* @param destFilename The name to store the file as in the archive
* @param sourcePath Path to the source file on disk
*/
void addFileFromDisk(const String &destFilename,
const String &sourcePath);
/** Extract a file from the package to the filesystem.
* @param filename The name of the file in the archive
* @param destPath Path to write the extracted file to
* @return true if successful
*/
bool extractFile(const String &filename, const String &destPath) const;
/** List all files in the package index. */
StringVector listFiles() const;
};
/** Factory for creating PackageArchive instances. */
class _OgreExport PackageArchiveFactory : public ArchiveFactory {
public:
virtual ~PackageArchiveFactory()
{
}
/// @copydoc FactoryObj::getType
const String &getType(void) const override;
//! @cond Doxygen_Suppress
using ArchiveFactory::createInstance;
//! @endcond
Archive *createInstance(const String &name, bool readOnly) override;
};
/** @} */
/** @} */
} // namespace Ogre
#include "OgreHeaderSuffix.h"
#endif // __OgrePackageArchive_H__
@@ -0,0 +1,470 @@
/*
* PackageTool - Command-line tool for creating and managing .package archives
*
* Usage:
* PackageTool create <package.package> <file1> [file2 ...]
* PackageTool add <package.package> <file1> [file2 ...]
* PackageTool remove <package.package> <file1> [file2 ...]
* PackageTool extract <package.package> <file1> [dest]
* PackageTool extract-all <package.package> [dest-dir]
* PackageTool list <package.package>
* PackageTool info <package.package>
*/
#include "OgrePackageArchive.h"
#include "OgreException.h"
#include <cstring>
#include <filesystem>
#include <iostream>
#include <vector>
namespace fs = std::filesystem;
static void printUsage(const char *prog)
{
std::cerr << "Usage:" << std::endl;
std::cerr << " " << prog
<< " create <package.package> <file1> [file2 ...]"
<< std::endl;
std::cerr << " " << prog
<< " add <package.package> <file1> [file2 ...]" << std::endl;
std::cerr << " " << prog
<< " remove <package.package> <file1> [file2 ...]"
<< std::endl;
std::cerr << " " << prog << " extract <package.package> <file1> [dest]"
<< std::endl;
std::cerr << " " << prog << " extract-all <package.package> [dest-dir]"
<< std::endl;
std::cerr << " " << prog << " list <package.package>" << std::endl;
std::cerr << " " << prog << " info <package.package>" << std::endl;
std::cerr << std::endl;
std::cerr << "Notes:" << std::endl;
std::cerr << " - For 'create' and 'add', if a source path is a "
<< "directory, its contents are added recursively."
<< std::endl;
std::cerr << " - Files are stored with their relative path when "
<< "added from subdirectories." << std::endl;
}
/**
* Check if a filename matches a simple glob pattern.
* Supports '*' (matches any sequence of characters except '/').
*/
static bool matchesGlob(const std::string &filename, const std::string &pattern)
{
size_t pi = 0, fi = 0;
while (pi < pattern.size()) {
if (pattern[pi] == '*') {
// '*' matches any sequence of non-'/' characters
pi++;
if (pi == pattern.size())
return true; // trailing '*' matches everything
// Find the next character in filename that matches
// pattern[pi]
while (fi < filename.size() && filename[fi] != '/') {
if (filename[fi] == pattern[pi])
break;
fi++;
}
if (fi >= filename.size() || filename[fi] == '/')
return false;
} else {
if (fi >= filename.size() ||
filename[fi] != pattern[pi])
return false;
fi++;
pi++;
}
}
return fi == filename.size();
}
/**
* Check if a relative path matches any of the exclude patterns.
* Patterns can be:
* - Prefix match: "CMakeFiles" matches "CMakeFiles/foo" or "CMakeFiles"
* - Glob match: "*.lua-" matches "data.lua-" or "foo/bar.lua-"
* - Exact match: "Makefile" matches "Makefile"
*/
static bool isExcluded(const std::string &path,
const std::vector<std::string> &excludes)
{
// Extract just the filename part for glob matching
std::string filename = path;
auto slashPos = path.rfind('/');
if (slashPos != std::string::npos)
filename = path.substr(slashPos + 1);
for (const auto &pattern : excludes) {
// If pattern contains a glob character, use glob matching
if (pattern.find('*') != std::string::npos) {
if (matchesGlob(filename, pattern))
return true;
} else {
// Otherwise use prefix matching (existing behavior)
if (path == pattern || path.find(pattern + "/") == 0) {
return true;
}
}
}
return false;
}
/**
* Add files from a source path to the archive.
* If the source is a directory, it is added recursively.
* The destName is the name to store the file as in the archive.
* For directories, destName is used as a prefix for contained files.
*/
static void addPathToArchive(Ogre::PackageArchive &archive,
const fs::path &sourcePath,
const std::string &destPrefix = "",
const std::vector<std::string> &excludes = {})
{
std::error_code ec;
if (fs::is_directory(sourcePath, ec)) {
// Recursively add directory contents
for (const auto &entry :
fs::recursive_directory_iterator(sourcePath, ec)) {
if (fs::is_regular_file(entry.path(), ec)) {
// Compute relative path from the source
// directory
fs::path relativePath = fs::relative(
entry.path(), sourcePath, ec);
std::string destName =
destPrefix.empty() ?
relativePath.string() :
destPrefix + "/" +
relativePath.string();
// Normalize path separators to forward slash
for (auto &c : destName) {
if (c == '\\')
c = '/';
}
// Skip excluded paths
if (isExcluded(destName, excludes)) {
std::cout << "Skipping (excluded): "
<< entry.path().string()
<< std::endl;
continue;
}
std::cout << "Adding: " << entry.path().string()
<< " -> " << destName << std::endl;
archive.addFileFromDisk(destName,
entry.path().string());
}
}
} else if (fs::is_regular_file(sourcePath, ec)) {
// Single file
std::string destName = destPrefix.empty() ?
sourcePath.filename().string() :
destPrefix;
std::cout << "Adding: " << sourcePath.string() << " -> "
<< destName << std::endl;
archive.addFileFromDisk(destName, sourcePath.string());
} else {
std::cerr << "Warning: skipping '" << sourcePath.string()
<< "' - not a regular file or directory" << std::endl;
}
}
/**
* Parse --exclude options from the argument list.
* Returns the remaining non-option arguments.
*/
static std::vector<std::string>
parseExcludes(const std::vector<std::string> &args,
std::vector<std::string> &excludes)
{
std::vector<std::string> remaining;
for (size_t i = 0; i < args.size(); i++) {
if (args[i] == "--exclude" && i + 1 < args.size()) {
excludes.push_back(args[i + 1]);
i++;
} else {
remaining.push_back(args[i]);
}
}
return remaining;
}
static int cmdCreate(const std::vector<std::string> &args)
{
std::vector<std::string> excludes;
std::vector<std::string> remaining = parseExcludes(args, excludes);
if (remaining.size() < 2) {
std::cerr << "Error: create requires a package path and at "
"least one source file"
<< std::endl;
return 1;
}
const std::string &packagePath = remaining[0];
Ogre::PackageArchive archive(packagePath, "Package");
// Load will create the file if it doesn't exist
archive.load();
for (size_t i = 1; i < remaining.size(); i++) {
addPathToArchive(archive, remaining[i], "", excludes);
}
std::cout << "Created package: " << packagePath << " with "
<< archive.listFiles().size() << " file(s)" << std::endl;
return 0;
}
static int cmdAdd(const std::vector<std::string> &args)
{
if (args.size() < 2) {
std::cerr << "Error: add requires a package path and at least "
"one source file"
<< std::endl;
return 1;
}
const std::string &packagePath = args[0];
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
for (size_t i = 1; i < args.size(); i++) {
addPathToArchive(archive, args[i]);
}
std::cout << "Added files to " << packagePath
<< " (total: " << archive.listFiles().size() << " file(s))"
<< std::endl;
return 0;
}
static int cmdRemove(const std::vector<std::string> &args)
{
if (args.size() < 2) {
std::cerr << "Error: remove requires a package path and at "
"least one filename"
<< std::endl;
return 1;
}
const std::string &packagePath = args[0];
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
for (size_t i = 1; i < args.size(); i++) {
const std::string &filename = args[i];
std::cout << "Removing: " << filename << std::endl;
archive.remove(filename);
}
std::cout << "Removed " << args.size() - 1 << " file(s) from "
<< packagePath << std::endl;
return 0;
}
static int cmdExtract(const std::vector<std::string> &args)
{
if (args.size() < 2) {
std::cerr << "Error: extract requires a package path and a "
"filename"
<< std::endl;
return 1;
}
const std::string &packagePath = args[0];
const std::string &filename = args[1];
std::string destPath;
if (args.size() >= 3) {
destPath = args[2];
} else {
destPath = filename;
}
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
if (archive.extractFile(filename, destPath)) {
std::cout << "Extracted: " << filename << " -> " << destPath
<< std::endl;
return 0;
} else {
std::cerr << "Error: could not extract " << filename
<< std::endl;
return 1;
}
}
static int cmdExtractAll(const std::vector<std::string> &args)
{
if (args.size() < 1) {
std::cerr << "Error: extract-all requires a package path"
<< std::endl;
return 1;
}
const std::string &packagePath = args[0];
std::string destDir;
if (args.size() >= 2) {
destDir = args[1];
} else {
destDir = ".";
}
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
Ogre::StringVector files = archive.listFiles();
if (files.empty()) {
std::cout << "Package is empty." << std::endl;
return 0;
}
// Create destination directory if needed
fs::create_directories(destDir);
for (const auto &filename : files) {
// Preserve directory structure in the extracted path
fs::path filePath(filename);
fs::path fullDestPath = fs::path(destDir) / filePath;
// Create parent directories as needed
fs::create_directories(fullDestPath.parent_path());
if (archive.extractFile(filename, fullDestPath.string())) {
std::cout << "Extracted: " << filename << " -> "
<< fullDestPath.string() << std::endl;
} else {
std::cerr << "Error: could not extract " << filename
<< std::endl;
}
}
return 0;
}
static int cmdList(const std::vector<std::string> &args)
{
if (args.size() < 1) {
std::cerr << "Error: list requires a package path" << std::endl;
return 1;
}
const std::string &packagePath = args[0];
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
Ogre::StringVector files = archive.listFiles();
std::cout << "Files in " << packagePath << ":" << std::endl;
if (files.empty()) {
std::cout << " (empty)" << std::endl;
} else {
for (const auto &filename : files) {
std::cout << " " << filename << std::endl;
}
}
std::cout << "Total: " << files.size() << " file(s)" << std::endl;
return 0;
}
static int cmdInfo(const std::vector<std::string> &args)
{
if (args.size() < 1) {
std::cerr << "Error: info requires a package path" << std::endl;
return 1;
}
const std::string &packagePath = args[0];
Ogre::PackageArchive archive(packagePath, "Package");
archive.load();
Ogre::StringVector files = archive.listFiles();
std::cout << "Package: " << packagePath << std::endl;
std::cout << "Type: Ogre Package Archive" << std::endl;
std::cout << "Files: " << files.size() << std::endl;
uint64_t totalSize = 0;
for (const auto &filename : files) {
// Get file info via listFileInfo
Ogre::FileInfoListPtr infoList =
archive.findFileInfo(filename, false, false);
if (infoList && !infoList->empty()) {
totalSize += infoList->at(0).uncompressedSize;
}
}
std::cout << "Total uncompressed size: " << totalSize << " bytes"
<< std::endl;
std::cout << std::endl;
std::cout << "Files:" << std::endl;
for (const auto &filename : files) {
Ogre::FileInfoListPtr infoList =
archive.findFileInfo(filename, false, false);
if (infoList && !infoList->empty()) {
const auto &info = infoList->at(0);
std::cout << " " << filename << " ("
<< info.uncompressedSize << " bytes)"
<< std::endl;
} else {
std::cout << " " << filename << std::endl;
}
}
return 0;
}
int main(int argc, char **argv)
{
if (argc < 2) {
printUsage(argv[0]);
return 1;
}
std::string command = argv[1];
std::vector<std::string> args;
for (int i = 2; i < argc; i++) {
args.push_back(argv[i]);
}
try {
if (command == "create") {
return cmdCreate(args);
} else if (command == "add") {
return cmdAdd(args);
} else if (command == "remove") {
return cmdRemove(args);
} else if (command == "extract") {
return cmdExtract(args);
} else if (command == "extract-all") {
return cmdExtractAll(args);
} else if (command == "list") {
return cmdList(args);
} else if (command == "info") {
return cmdInfo(args);
} else {
std::cerr << "Error: unknown command '" << command
<< "'" << std::endl;
printUsage(argv[0]);
return 1;
}
} catch (const Ogre::Exception &e) {
std::cerr << "Ogre Error: " << e.getFullDescription()
<< std::endl;
return 1;
} catch (const std::exception &e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}
+135
View File
@@ -0,0 +1,135 @@
# Ogre Package Archive (.package)
A simple uncompressed indexed file container format for OGRE3D, similar to Unity asset bundles.
## Format
The `.package` format stores files sequentially with a file index at the end:
```
[File 1 Data]
[File 2 Data]
...
[File N Data]
[Index Block]
- Number of files (uint32_t)
- For each file:
- Filename length (uint32_t)
- Filename data (UTF-8)
- Offset in file (uint64_t)
- Size in bytes (uint64_t)
- Timestamp (int64_t)
[Footer]
- Index offset (uint64_t) - points to start of Index Block
- Magic number "PACK" (uint32_t) = 0x4B434150
```
## Features
- **No compression** - Files are stored as-is for fast access
- **Indexed** - O(1) lookup by filename via in-memory index
- **Random access** - Files can be read individually without scanning
- **Persistent** - Index is stored in the file footer for fast loading
- **Mutable** - Supports add, remove, and replace operations
- **OGRE Archive interface** - Usable from `resources.cfg` like Zip archives
## Usage from resources.cfg
```
[General]
Package=path/to/resources.package
```
The archive type is registered as `"Package"`.
## PackageTool CLI
The `PackageTool` executable provides command-line management of `.package` files:
```
PackageTool create <package.package> <file1> [file2 ...]
Create a new package containing the specified files.
PackageTool add <package.package> <file1> [file2 ...]
Add files to an existing package.
PackageTool remove <package.package> <file1> [file2 ...]
Remove files from a package.
PackageTool extract <package.package> <file1> [dest]
Extract a single file from a package. If dest is omitted,
the file is extracted to the current directory with its
original filename.
PackageTool extract-all <package.package> [dest-dir]
Extract all files from a package. If dest-dir is omitted,
files are extracted to the current directory.
PackageTool list <package.package>
List all files in a package.
PackageTool info <package.package>
Show detailed information about a package.
```
### Notes
- For `create` and `add`, if a source path is a directory, its contents are added recursively.
- Files are stored with their relative path when added from subdirectories.
- If a file with the same name already exists in the package, it is replaced.
## C++ API
```cpp
#include "OgrePackageArchive.h"
// Create or open a package
Ogre::PackageArchive archive("my.package", "Package");
archive.load();
// Add a file from memory
const char *data = "Hello, World!";
archive.addFile("hello.txt", data, strlen(data));
// Add a file from disk
archive.addFileFromDisk("stored.txt", "/path/to/source.txt");
// Open and read a file
auto stream = archive.open("hello.txt");
std::vector<char> buf(stream->size());
stream->read(buf.data(), stream->size());
// List files
auto files = archive.list(true, false); // recursive, no dirs
for (const auto &f : *files) {
std::cout << f << std::endl;
}
// Find files by pattern
auto found = archive.find("*.txt", true, false);
// Check if file exists
bool exists = archive.exists("hello.txt");
// Remove a file
archive.remove("hello.txt");
// Extract to disk
archive.extractFile("hello.txt", "/tmp/hello.txt");
// Get modification time
time_t mtime = archive.getModifiedTime("hello.txt");
// Unload
archive.unload();
```
## Building
The library is built as a static library target `PackageArchive`:
```cmake
target_link_libraries(my_target PackageArchive OgreMain)
```
The `PackageTool` executable is built as a separate target.
+86 -25
View File
@@ -86,13 +86,19 @@ public:
{
switch (inObject1) {
case Layers::NON_MOVING:
return inObject2 ==
Layers::MOVING; // Non moving only collides with moving
return inObject2 == Layers::MOVING ||
inObject2 == Layers::HAIR;
case Layers::MOVING:
return true; // Moving collides with everything
return inObject2 != Layers::HEAD;
case Layers::SENSORS:
return inObject2 ==
Layers::MOVING; // Non moving only collides with moving
return inObject2 == Layers::MOVING;
case Layers::HAIR:
return inObject2 == Layers::NON_MOVING ||
inObject2 == Layers::MOVING ||
inObject2 == Layers::HAIR ||
inObject2 == Layers::HEAD;
case Layers::HEAD:
return inObject2 == Layers::HAIR;
default:
JPH_ASSERT(false);
return false;
@@ -111,6 +117,8 @@ public:
BroadPhaseLayers::NON_MOVING;
mObjectToBroadPhase[Layers::MOVING] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::SENSORS] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::HAIR] = BroadPhaseLayers::MOVING;
mObjectToBroadPhase[Layers::HEAD] = BroadPhaseLayers::MOVING;
}
virtual uint GetNumBroadPhaseLayers() const override
@@ -157,6 +165,13 @@ public:
return inLayer2 == BroadPhaseLayers::MOVING;
case Layers::MOVING:
return true;
case Layers::SENSORS:
return inLayer2 == BroadPhaseLayers::MOVING;
case Layers::HAIR:
return inLayer2 == BroadPhaseLayers::NON_MOVING ||
inLayer2 == BroadPhaseLayers::MOVING;
case Layers::HEAD:
return inLayer2 == BroadPhaseLayers::MOVING;
default:
JPH_ASSERT(false);
return false;
@@ -281,31 +296,15 @@ public:
JPH::RVec3Arg inV3, JPH::ColorArg inColor,
ECastShadow inCastShadow = ECastShadow::Off) override
{
Ogre::Vector3 d = mCameraNode->_getDerivedOrientation() *
Ogre::Vector3(0, 0, -1);
JPH::Vec4 color = inColor.ToVec4();
Ogre::Vector3 p1 = JoltPhysics::convert(inV1);
Ogre::Vector3 p2 = JoltPhysics::convert(inV2);
Ogre::Vector3 p3 = JoltPhysics::convert(inV3);
Ogre::ColourValue cv(color[0], color[1], color[2], color[3]);
#if 0
float dproj1 = p1.dotProduct(d);
float dproj2 = p2.dotProduct(d);
float dproj3 = p3.dotProduct(d);
if (dproj1 < 0 && dproj2 < 0 && dproj3 < 0)
return;
if (dproj1 > 50 && dproj2 > 50 && dproj3 > 50)
return;
#endif
mLines.push_back({ p1, p2, cv });
#if 0
mTriangles.push_back({ { { inV1[0], inV1[1], inV1[2] },
{ inV2[0], inV2[1], inV2[2] },
{ inV3[0], inV3[1], inV3[2] } },
Ogre::ColourValue(color[0], color[1],
color[2], color[3]) });
#endif
mLines.push_back({ p2, p3, cv });
mLines.push_back({ p3, p1, cv });
}
#if 0
Batch CreateTriangleBatch(const Triangle *inTriangles,
@@ -339,6 +338,11 @@ public:
std::cout << "geometry\n";
}
#endif
void updateCameraPos()
{
SetCameraPos(JoltPhysics::convert(
mCameraNode->_getDerivedPosition()));
}
void finish()
{
Ogre::Vector3 d = mCameraNode->_getDerivedOrientation() *
@@ -561,6 +565,7 @@ class Physics {
std::set<JPH::BodyID> characterBodies;
bool debugDraw;
JPH::Vec3 gravity = JPH::Vec3(0.0f, -9.8f, 0.0f);
std::unordered_map<uint32_t, JPH::Ref<JPH::GroupFilterTable> > groupFilters;
public:
class ActivationListener : public JPH::BodyActivationListener {
@@ -570,6 +575,32 @@ public:
virtual void OnBodyDeactivated(const JPH::BodyID &inBodyID,
JPH::uint64 inBodyUserData) = 0;
};
JPH::PhysicsSystem *getPhysicsSystem()
{
return &physics_system;
}
JPH::GroupFilterTable *getOrCreateGroupFilter(uint32_t groupId,
uint32_t numSubGroups)
{
auto it = groupFilters.find(groupId);
if (it != groupFilters.end())
return it->second.GetPtr();
JPH::Ref<JPH::GroupFilterTable> filter =
new JPH::GroupFilterTable(numSubGroups);
groupFilters[groupId] = filter;
return filter.GetPtr();
}
JPH::GroupFilterTable *getGroupFilter(uint32_t groupId) const
{
auto it = groupFilters.find(groupId);
if (it != groupFilters.end())
return it->second.GetPtr();
return nullptr;
}
Physics(Ogre::SceneManager *scnMgr, Ogre::SceneNode *cameraNode,
ActivationListener *activationListener = nullptr,
JPH::ContactListener *contactListener = nullptr)
@@ -772,6 +803,15 @@ public:
&temp_allocator, &job_system);
timeAccumulator -= fixedDeltaTime;
}
/* Always consume remaining time with a partial step so
* physics never sits idle for a frame. This keeps character
* movement in sync with animation root motion which advances
* every frame regardless of physics step timing. */
if (timeAccumulator > 0.0001f) {
physics_system.Update(timeAccumulator, cCollisionSteps,
&temp_allocator, &job_system);
timeAccumulator = 0;
}
for (JPH::BodyID bID : bodies) {
JPH::RVec3 p;
JPH::Quat q;
@@ -784,11 +824,15 @@ public:
if (body_interface.GetMotionType(bID) !=
JPH::EMotionType::Dynamic)
continue;
/* Skip character bodies - they are handled separately */
if (characterBodies.find(bID) != characterBodies.end())
continue;
body_interface.GetPositionAndRotation(bID, p, q);
Ogre::SceneNode *node = id2node[bID];
node->_setDerivedPosition(JoltPhysics::convert(p));
node->_setDerivedOrientation(JoltPhysics::convert(q));
}
/* Sync character positions back to scene nodes */
for (JPH::Character *ch : characters) {
JPH::BodyID bID = ch->GetBodyID();
if (body_interface.IsAdded(bID)) {
@@ -800,11 +844,12 @@ public:
ch->GetPosition()));
}
}
if (debugDraw)
if (debugDraw) {
mDebugRenderer->updateCameraPos();
physics_system.DrawBodies(
JPH::BodyManager::DrawSettings(),
mDebugRenderer);
}
mDebugRenderer->finish();
mDebugRenderer->NextFrame();
#if 0
@@ -1988,5 +2033,21 @@ void JoltPhysicsWrapper::setRootMotionCharacter(JPH::BodyID id, bool enabled)
phys->setRootMotionCharacter(id, enabled);
}
JPH::PhysicsSystem *JoltPhysicsWrapper::getPhysicsSystem() const
{
return phys->getPhysicsSystem();
}
JPH::GroupFilterTable *JoltPhysicsWrapper::getOrCreateGroupFilter(
uint32_t groupId, uint32_t numSubGroups)
{
return phys->getOrCreateGroupFilter(groupId, numSubGroups);
}
JPH::GroupFilterTable *JoltPhysicsWrapper::getGroupFilter(uint32_t groupId) const
{
return phys->getGroupFilter(groupId);
}
template <>
JoltPhysicsWrapper *Ogre::Singleton<JoltPhysicsWrapper>::msSingleton = 0;
+18 -1
View File
@@ -10,6 +10,7 @@
#include <Jolt/Physics/Collision/BroadPhase/BroadPhaseLayer.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>
#include <Jolt/Physics/EActivation.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
void physics();
namespace JPH
{
@@ -18,6 +19,7 @@ class Character;
class ContactManifold;
class ContactSettings;
class SubShapeIDPair;
class PhysicsSystem;
}
// Layer that objects can be in, determines which other objects it can collide with
// Typically you at least want to have 1 layer for moving bodies and 1 layer for static bodies, but you can have more
@@ -28,7 +30,13 @@ namespace Layers
static constexpr JPH::ObjectLayer NON_MOVING = 0;
static constexpr JPH::ObjectLayer MOVING = 1;
static constexpr JPH::ObjectLayer SENSORS = 2;
static constexpr JPH::ObjectLayer NUM_LAYERS = 3;
static constexpr JPH::ObjectLayer HAIR = 3;
static constexpr JPH::ObjectLayer HEAD = 4;
static constexpr JPH::ObjectLayer NUM_LAYERS = 5;
/* Max subgroups per character collision group. Body = 0, head = 1,
* hair joints = 2..MAX_SUBGROUPS-1. */
static constexpr uint32_t MAX_CHARACTER_SUBGROUPS = 256;
};
// Each broadphase layer results in a separate bounding volume tree in the broad phase. You at least want to have
@@ -237,5 +245,14 @@ public:
* because the scene node position is driven by root motion
* from AnimationTreeSystem. */
void setRootMotionCharacter(JPH::BodyID id, bool enabled);
JPH::PhysicsSystem *getPhysicsSystem() const;
/* Shared group filters for per-character collision filtering.
* Body = subgroup 0, head = subgroup 1, chest = subgroup 2,
* hair joints = 3+. */
JPH::GroupFilterTable *getOrCreateGroupFilter(
uint32_t groupId,
uint32_t numSubGroups = Layers::MAX_CHARACTER_SUBGROUPS);
JPH::GroupFilterTable *getGroupFilter(uint32_t groupId) const;
};
#endif
@@ -16,10 +16,13 @@ set(RECASTNAVIGATION_DEMO OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_TESTS OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE)
set(RECASTNAVIGATION_ENABLE_ASSERTS "$<CONFIG:Debug>" CACHE STRING "" FORCE)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE)
# Build the core libraries only
add_subdirectory(Recast)
add_subdirectory(Detour)
add_subdirectory(DetourTileCache)
add_subdirectory(DetourCrowd)
add_subdirectory(DebugUtils)
add_subdirectory(Recast EXCLUDE_FROM_ALL)
add_subdirectory(Detour EXCLUDE_FROM_ALL)
add_subdirectory(DetourTileCache EXCLUDE_FROM_ALL)
add_subdirectory(DetourCrowd EXCLUDE_FROM_ALL)
add_subdirectory(DebugUtils EXCLUDE_FROM_ALL)
@@ -2,6 +2,8 @@
#include "../EditorApp.hpp"
#include "BehaviorTreeSystem.hpp"
#include "ItemSystem.hpp"
#include "ItemRegistry.hpp"
#include "ItemStateRegistry.hpp"
#include "../components/Actuator.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
@@ -46,20 +48,32 @@ Ogre::Vector2 ActuatorSystem::projectToScreen(const Ogre::Vector3 &worldPoint)
float width = vpSize.x;
float height = vpSize.y;
// Convert to camera space
// 1. Convert to camera space (OGRE camera looks down -Z)
Ogre::Vector3 eyeSpacePoint = camera->getViewMatrix() * worldPoint;
// Project to clip space
Ogre::Vector3 clipSpacePoint =
camera->getProjectionMatrix() * eyeSpacePoint;
if (clipSpacePoint.z < 0.0f)
if (eyeSpacePoint.z >= 0.0f)
return Ogre::Vector2(-1, -1);
// Convert from clip space (-1 to 1) to screen space (0 to 1)
float screenX = (clipSpacePoint.x / 2.0f) + 0.5f;
float screenY = 1.0f - ((clipSpacePoint.y / 2.0f) + 0.5f);
// 2. Project to homogeneous clip space using Vector4 to preserve W
Ogre::Vector4 clipSpacePoint = camera->getProjectionMatrix() *
Ogre::Vector4(eyeSpacePoint.x, eyeSpacePoint.y,
eyeSpacePoint.z, 1.0f);
if (clipSpacePoint.w <= 0.0f)
return Ogre::Vector2(-1, -1);
// Map to actual pixel dimensions
// 3. Perspective divide to get NDC [-1, 1]
float ndcX = clipSpacePoint.x / clipSpacePoint.w;
float ndcY = clipSpacePoint.y / clipSpacePoint.w;
// 4. Convert NDC to screen space [0, 1], flipping Y for ImGui
float screenX = (ndcX * 0.5f) + 0.5f;
float screenY = 1.0f - ((ndcY * 0.5f) + 0.5f);
// 5. Reject if outside viewport bounds
if (screenX < 0.0f || screenX > 1.0f ||
screenY < 0.0f || screenY > 1.0f)
return Ogre::Vector2(-1, -1);
// 6. Map to actual pixel dimensions
return Ogre::Vector2(screenX * width, screenY * height);
}
@@ -110,6 +124,28 @@ void ActuatorSystem::executeAction(flecs::entity character,
"[ActuatorSystem] Executing action: " + actionName);
}
void ActuatorSystem::executeItemAction(flecs::entity character,
flecs::entity itemEntity,
const Ogre::String &actionName)
{
if (!character.is_alive() || !itemEntity.is_alive())
return;
if (!itemEntity.has<ItemComponent>())
return;
m_executingActuatorId = itemEntity.id();
m_executingCharacterId = character.id();
m_executingActionName = actionName;
m_actionFirstFrame = true;
// Lock player input while action executes
setPlayerInputLocked(true);
Ogre::LogManager::getSingleton().logMessage(
"[ActuatorSystem] Executing item action: " + actionName);
}
bool ActuatorSystem::isActionComplete(flecs::entity character, float deltaTime)
{
if (!m_btSystem || !character.is_alive())
@@ -324,6 +360,8 @@ void ActuatorSystem::update(float deltaTime)
// (those are handled above as actuators)
if (e.has<ActuatorComponent>())
return;
if (item.disabled)
return;
if (!trans.node)
return;
@@ -398,7 +436,11 @@ void ActuatorSystem::update(float deltaTime)
} else if (targetEntity.has<ItemComponent>()) {
// Show "E - Pick up [ItemName]" for items
auto &item = targetEntity.get<ItemComponent>();
m_labelText = "E Pick up " + item.itemName;
std::string displayName = ItemRegistry::getSingleton().getItemName(
item.itemId);
if (displayName.empty())
displayName = item.itemId;
m_labelText = "E Pick up " + displayName;
}
}
}
@@ -463,10 +505,38 @@ void ActuatorSystem::update(float deltaTime)
}
} else if (targetEntity.has<ItemComponent>() &&
input.ePressed) {
// Pick up item
if (m_itemSystem) {
m_itemSystem->pickupItem(playerCharacter,
targetEntity);
auto &item = targetEntity.get_mut<ItemComponent>();
if (!item.action.empty()) {
// Execute the item's action via behavior tree
executeItemAction(playerCharacter,
targetEntity,
item.action);
} else {
// Pick up item
if (m_itemSystem) {
m_itemSystem->pickupItem(
playerCharacter,
targetEntity);
}
// Disable the item and persist state
item.disabled = true;
if (!item.instanceId.empty()) {
ItemStateRegistry::getInstance()
.setDisabled(item.instanceId,
true);
}
// Hide the entity in the scene
if (targetEntity.has<
TransformComponent>()) {
auto &trans = targetEntity
.get_mut<
TransformComponent>();
if (trans.node) {
trans.node->setVisible(
false);
}
}
}
m_eHoldTime = 0.0f;
}
@@ -62,6 +62,9 @@ private:
void executeAction(flecs::entity character,
flecs::entity actuatorEntity,
const Ogre::String &actionName);
void executeItemAction(flecs::entity character,
flecs::entity itemEntity,
const Ogre::String &actionName);
bool isActionComplete(flecs::entity character, float deltaTime);
void drawActionMenu(flecs::entity actuatorEntity);
void setPlayerInputLocked(bool locked);
@@ -9,6 +9,19 @@
#include <cmath>
#include <iostream>
static unsigned short findBoneBlendIndex(Ogre::SkeletonInstance *skel, Ogre::Bone *bone)
{
unsigned short handle = bone->getHandle();
if (handle < skel->getNumBones() && skel->getBone(handle) == bone)
return handle;
/* Fallback: search by pointer in case handles are not sequential */
for (unsigned short i = 0; i < skel->getNumBones(); ++i) {
if (skel->getBone(i) == bone)
return i;
}
return (unsigned short)-1;
}
AnimationTreeSystem::AnimationTreeSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
: m_world(world)
@@ -87,6 +100,17 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
}
EntityAnimTreeState &state = m_states[e.id()];
/* Preserve root-motion tracking state across rebuilds so
* setupEntity() does not produce a one-frame zero-delta
* stutter when the mesh is recreated by CharacterSlotSystem.
*/
std::unordered_map<Ogre::String, std::pair<Ogre::Vector3, bool>>
prevRootMotion;
for (const auto &pair : state.animations) {
prevRootMotion[pair.first] =
{ pair.second.prevRootPos,
pair.second.hasPrevRootPos };
}
state.ogreEntity = ent;
state.ogreEntityName = ent->getName();
state.rootBone = nullptr;
@@ -128,8 +152,15 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
if (state.rootBone) {
as->destroyBlendMask();
as->createBlendMask(skel->getNumBones(), 1.0f);
as->setBlendMaskEntry(
state.rootBone->getHandle(), 0.0f);
unsigned short blendIdx = findBoneBlendIndex(skel, state.rootBone);
if (blendIdx != (unsigned short)-1) {
as->setBlendMaskEntry(blendIdx, 0.0f);
} else {
std::cout << "AnimationTreeSystem::setupEntity: WARNING could not find blend mask index for root bone "
<< state.rootBone->getName()
<< " handle=" << state.rootBone->getHandle()
<< std::endl;
}
}
AnimationRuntimeInfo info;
@@ -148,18 +179,28 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
if (info.rootTrack
->getNumKeyFrames() >=
2) {
Ogre::TransformKeyFrame *tkfBeg =
info.rootTrack
->getNodeKeyFrame(
0);
Ogre::TransformKeyFrame *tkfEnd =
info.rootTrack->getNodeKeyFrame(
info.rootTrack
->getNumKeyFrames() -
1);
/* Use interpolated keyframes at the exact
* time boundaries (0 and length) rather
* than the first/last stored keyframes.
* Exporters sometimes place keyframes
* slightly inside the boundaries, which
* would make loopTranslation slightly
* wrong and cause a backward lurch on
* wrap frames. */
Ogre::TransformKeyFrame tkfBeg(
info.rootTrack, 0.0f);
Ogre::TransformKeyFrame tkfEnd(
info.rootTrack, 0.0f);
info.rootTrack
->getInterpolatedKeyFrame(
0.0f, &tkfBeg);
info.rootTrack
->getInterpolatedKeyFrame(
as->getLength(),
&tkfEnd);
info.loopTranslation =
tkfEnd->getTranslate() -
tkfBeg->getTranslate();
tkfEnd.getTranslate() -
tkfBeg.getTranslate();
}
}
}
@@ -170,6 +211,17 @@ bool AnimationTreeSystem::setupEntity(flecs::entity e,
}
}
/* Restore root-motion tracking state for animations that
* existed before the rebuild.
*/
for (auto &pair : state.animations) {
auto it = prevRootMotion.find(pair.first);
if (it != prevRootMotion.end()) {
pair.second.prevRootPos = it->second.first;
pair.second.hasPrevRootPos = it->second.second;
}
}
/* Initialize default state machine states */
initializeTreeStates(at.root, at);
std::cout
@@ -334,6 +386,19 @@ void AnimationTreeSystem::update(float deltaTime)
if (!state.ogreEntity)
return;
/* Diagnostic: check if OGRE moved the root bone during rendering */
if (state.rootBone) {
Ogre::Vector3 currentPos = state.rootBone->getPosition();
Ogre::Vector3 diff = currentPos - state.rootBindingPosition;
if (diff.squaredLength() > 0.0001f) {
std::cout << "[ANIM_DBG] ROOT BONE MOVED! entity=" << e.id()
<< " current=" << currentPos
<< " binding=" << state.rootBindingPosition
<< " diff=" << diff
<< std::endl;
}
}
if (!at.enabled) {
disableAllAnimations(state);
return;
@@ -378,77 +443,101 @@ void AnimationTreeSystem::update(float deltaTime)
if (at.useRootMotion && info.rootTrack) {
Ogre::TransformKeyFrame tkf(nullptr,
0.0f);
0.0f);
info.rootTrack->getInterpolatedKeyFrame(
thisTime, &tkf);
Ogre::Vector3 thisPos =
tkf.getTranslate();
Ogre::Vector3 delta;
if (info.hasPrevRootPos) {
/*
* Compute delta from previous
* root bone position. Detect
* animation wrapping by checking
* if time decreased (wrapped
* around). When wrapping, add
* the loop translation to
* compensate for the jump from
* end back to start.
* For looping animations, use the average
* cycle velocity (loopTranslation / duration)
* instead of exact frame-to-frame displacement.
* The raw root bone oscillates back and forth
* within a walk cycle; transferring that
* oscillation to world position makes the
* whole character jitter. The average velocity
* captures only the net forward movement.
* Non-looping animations still use exact
* displacement so acceleration curves are
* preserved.
*/
delta = thisPos -
info.prevRootPos;
if (thisTime < lastTime) {
/* Animation wrapped */
delta +=
info.loopTranslation;
float animLen = info.ogreAnimState->getLength();
if (
animLen > 0.0001f &&
info.loopTranslation.squaredLength() > 0.0001f) {
Ogre::Vector3 avgVelLocal =
info.loopTranslation / animLen;
totalRootMotion +=
avgVelLocal *
data.timeDelta *
data.weight;
} else if (info.hasPrevRootPos) {
Ogre::Vector3 displacement =
thisPos -
info.prevRootPos;
if (thisTime < lastTime)
displacement +=
info.loopTranslation;
totalRootMotion +=
displacement *
data.weight;
} else {
/* Fallback for first frame:
* instantaneous velocity */
float sampleDt = 0.001f;
float nextTime = thisTime + sampleDt;
bool wrapped = false;
if (nextTime > info.ogreAnimState->getLength()) {
nextTime -= info.ogreAnimState->getLength();
wrapped = true;
}
Ogre::TransformKeyFrame tkfNext(nullptr, 0.0f);
info.rootTrack->getInterpolatedKeyFrame(
nextTime, &tkfNext);
Ogre::Vector3 nextPos = tkfNext.getTranslate();
if (wrapped)
nextPos += info.loopTranslation;
Ogre::Vector3 velocityLocal = (nextPos - thisPos) / sampleDt;
totalRootMotion += velocityLocal * data.timeDelta * data.weight;
}
} else {
delta = Ogre::Vector3::ZERO;
}
info.prevRootPos = thisPos;
info.hasPrevRootPos = true;
totalRootMotion += delta * data.weight;
}
}
}
if (at.useRootMotion && sceneNode) {
/*
* Compute root motion velocity from the animation
* displacement. Do NOT move the scene node directly -
* the physics character's velocity drives movement,
* and physics writes the position back to the scene
* node naturally. This avoids jitter caused by
* teleporting the physics character to match a
* root-motion-driven scene node position.
*/
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
cc.useRootMotion = true;
if (deltaTime > 0.0000001f) {
float safeDelta = Ogre::Math::Clamp(
deltaTime, 0.005f, 0.99f);
Ogre::Quaternion worldRot =
sceneNode
->_getDerivedOrientation();
cc.linearVelocity = worldRot *
totalRootMotion /
safeDelta;
cc.linearVelocity.x = Ogre::Math::Clamp(
cc.linearVelocity.x, -16.0f,
16.0f);
cc.linearVelocity.z = Ogre::Math::Clamp(
cc.linearVelocity.z, -16.0f,
16.0f);
cc.linearVelocity.y = Ogre::Math::Clamp(
cc.linearVelocity.y, -10.5f,
10.0f);
if (at.useRootMotion && sceneNode) {
/* Smooth root-motion displacement with EMA to
* filter high-frequency hip oscillation within
* the walk cycle. Alpha = 0.15 gives strong
* attenuation of ~2 Hz walk-cycle jitter while
* still responding to stops within ~6 frames. */
float alpha = 0.15f;
if (state.hasSmoothedRootMotion) {
totalRootMotion =
state.smoothedRootMotion *
(1.0f - alpha) +
totalRootMotion * alpha;
}
state.smoothedRootMotion = totalRootMotion;
state.hasSmoothedRootMotion = true;
Ogre::Quaternion worldRot =
sceneNode->_getDerivedOrientation();
Ogre::Vector3 displacementWorld =
worldRot * totalRootMotion;
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
cc.useRootMotion = true;
if (deltaTime > 0.0000001f) {
cc.linearVelocity =
displacementWorld / deltaTime;
}
}
}
}
/* Reset root bone to binding pose */
if (state.rootBone) {
state.rootBone->setPosition(state.rootBindingPosition);
@@ -460,6 +549,7 @@ void AnimationTreeSystem::update(float deltaTime)
/* Handle end-of-animation transitions */
checkEndTransitions(e, at, state, ctx);
});
}
void AnimationTreeSystem::evaluateNode(const AnimationTreeNode &node,
@@ -728,3 +818,5 @@ AnimationTreeSystem::findAnimationNode(const AnimationTreeNode &stateNode) const
}
return nullptr;
}
@@ -11,6 +11,7 @@
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
/**
* System that evaluates an AnimationTreeComponent each frame.
*
@@ -77,6 +78,12 @@ private:
Ogre::Quaternion appliedRootRotation =
Ogre::Quaternion::IDENTITY;
/* Previous root-motion velocity for smoothing */
Ogre::Vector3 prevRootMotionVel = Ogre::Vector3::ZERO;
/* Smoothed root-motion displacement (EMA) */
Ogre::Vector3 smoothedRootMotion = Ogre::Vector3::ZERO;
bool hasSmoothedRootMotion = false;
std::unordered_map<Ogre::String, AnimationRuntimeInfo>
animations;
std::unordered_map<Ogre::String, FadeInfo> fadeStates;
@@ -3,6 +3,8 @@
#include "CharacterSystem.hpp"
#include "SmartObjectSystem.hpp"
#include "ItemSystem.hpp"
#include "ItemRegistry.hpp"
#include "ItemStateRegistry.hpp"
#include "EventBus.hpp"
#include "../components/BehaviorTree.hpp"
#include "../components/ActionDatabase.hpp"
@@ -667,6 +669,8 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
if (!node.name.empty() &&
itemComp.itemId != node.name)
return;
if (itemComp.disabled)
return;
Ogre::Vector3 itemPos =
Ogre::Vector3::ZERO;
@@ -786,70 +790,28 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
if (!itemSystem || !e.has<InventoryComponent>())
return Status::failure;
// Parse params: "itemId,itemName,itemType,count,weight,value"
Ogre::String itemId = node.name;
Ogre::String itemName = node.name;
Ogre::String itemType = "misc";
// Parse params: "itemId,count"
std::string itemId = node.name.c_str();
int count = 1;
float weight = 0.1f;
int value = 1;
if (!node.params.empty()) {
// Parse comma-separated values
std::vector<Ogre::String> parts;
const char *s = node.params.c_str();
const char *start = s;
while (*s) {
if (*s == ',') {
parts.push_back(Ogre::String(
start,
static_cast<size_t>(
s - start)));
start = s + 1;
}
s++;
}
if (s > start)
parts.push_back(Ogre::String(
start, static_cast<size_t>(
s - start)));
if (parts.size() >= 1)
itemName = parts[0];
if (parts.size() >= 2)
itemType = parts[1];
if (parts.size() >= 3) {
char *end = nullptr;
long val = strtol(parts[2].c_str(),
char *end = nullptr;
long val = strtol(node.params.c_str(),
&end, 10);
if (end != parts[2].c_str() &&
*end == '\0' && val > 0)
count = static_cast<int>(val);
}
if (parts.size() >= 4) {
char *end = nullptr;
float val =
strtof(parts[3].c_str(), &end);
if (end != parts[3].c_str() &&
*end == '\0' && val >= 0.0f)
weight = val;
}
if (parts.size() >= 5) {
char *end = nullptr;
long val = strtol(parts[4].c_str(),
&end, 10);
if (end != parts[4].c_str() &&
*end == '\0' && val >= 0)
value = static_cast<int>(val);
}
if (end != node.params.c_str() &&
*end == '\0' && val > 0)
count = static_cast<int>(val);
}
itemSystem->addItemToInventory(e, itemId, itemName,
itemType, count, weight,
value);
std::cout << "[BT] addItemToInventory: added "
<< itemName << " x" << count << std::endl;
return Status::success;
bool added = itemSystem->addItemToInventory(e, itemId,
count);
if (added) {
std::cout << "[BT] addItemToInventory: added "
<< itemId << " x" << count
<< std::endl;
return Status::success;
}
return Status::failure;
}
return Status::success;
}
@@ -988,6 +950,51 @@ BehaviorTreeSystem::evaluateNode(const BehaviorTreeNode &node, flecs::entity e,
return Status::success;
}
/* --- Disable item entity (for pickup / consumption) --- */
if (node.type == "disableItem") {
if (isNewlyActive(state, &node)) {
// node.name can specify an instanceId override,
// otherwise we look for an ItemComponent on this entity
flecs::entity targetEntity = e;
if (!node.name.empty()) {
m_world.query<ItemComponent>().each(
[&](flecs::entity itemEntity,
ItemComponent &itemComp) {
if (itemComp.instanceId ==
node.name.c_str() &&
!targetEntity.is_alive()) {
targetEntity = itemEntity;
}
});
}
if (!targetEntity.is_alive() ||
!targetEntity.has<ItemComponent>()) {
std::cout << "[BT] disableItem: no item entity"
<< std::endl;
return Status::failure;
}
auto &item = targetEntity.get_mut<ItemComponent>();
item.disabled = true;
if (!item.instanceId.empty()) {
ItemStateRegistry::getInstance().setDisabled(
item.instanceId, true);
}
if (targetEntity.has<TransformComponent>()) {
auto &trans = targetEntity.get_mut<
TransformComponent>();
if (trans.node)
trans.node->setVisible(false);
}
std::cout << "[BT] disableItem: disabled item "
<< targetEntity.id() << std::endl;
return Status::success;
}
return Status::success;
}
/* --- Lua behavior tree node --- */
if (node.type == "luaTask") {
/* Call the Lua function via the forward-declared API.
@@ -0,0 +1,704 @@
#include "CharacterClassSystem.hpp"
#include "../EditorApp.hpp"
#include "CharacterRegistry.hpp"
#include "ItemRegistry.hpp"
#include "../components/CharacterClassDatabase.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/GoapBlackboard.hpp"
#include "../components/PlayerController.hpp"
#include "../components/Inventory.hpp"
#include <OgreLogManager.h>
#include <OgreFontManager.h>
#include <OgreImGuiOverlay.h>
#include <imgui.h>
#include <algorithm>
#include <fstream>
/* ---------------------------------------------------------------------------
* Helpers
* --------------------------------------------------------------------------- */
static CharacterRegistry::CharacterRecord *getRecord(flecs::entity entity)
{
if (!entity.is_alive() || !entity.has<CharacterIdentityComponent>())
return nullptr;
auto &ci = entity.get<CharacterIdentityComponent>();
return CharacterRegistry::getSingleton().findCharacter(ci.registryId);
}
static int getPoolMax(const CharacterRegistry::CharacterRecord *rec,
const Ogre::String &name)
{
if (!rec)
return 0;
const auto *def = CharacterClassDatabase::getSingleton().findStat(name);
if (!def || def->kind != CharacterClassDatabase::StatKind::ResourcePool)
return 0;
auto it = rec->stats.find(name.c_str());
if (it == rec->stats.end())
return 0;
if (it->second < def->minValue)
return def->minValue;
if (it->second > def->maxValue)
return def->maxValue;
return it->second;
}
static int getPoolCurrent(const CharacterRegistry::CharacterRecord *rec,
const Ogre::String &name)
{
int maxVal = getPoolMax(rec, name);
if (maxVal <= 0)
return 0;
auto it = rec->currentPools.find(name.c_str());
if (it == rec->currentPools.end())
return maxVal;
if (it->second < 0)
return 0;
if (it->second > maxVal)
return maxVal;
return it->second;
}
static void setPoolCurrent(CharacterRegistry::CharacterRecord *rec,
const Ogre::String &name, int value)
{
int maxVal = getPoolMax(rec, name);
if (maxVal <= 0)
return;
if (value < 0)
value = 0;
if (value > maxVal)
value = maxVal;
rec->currentPools[name.c_str()] = value;
}
/* ---------------------------------------------------------------------------
* Construction / Destruction
* --------------------------------------------------------------------------- */
CharacterClassSystem::CharacterClassSystem(flecs::world &world,
EditorApp *editorApp)
: m_world(world)
, m_editorApp(editorApp)
{
// Load inventory dialog configuration
m_config.loadFromFile("inventory_config.json");
}
CharacterClassSystem::~CharacterClassSystem()
{
// Save inventory dialog configuration only in editor mode
if (m_editorApp &&
m_editorApp->getGameMode() == EditorApp::GameMode::Editor) {
m_config.saveToFile("inventory_config.json");
}
}
/* ---------------------------------------------------------------------------
* Public API
* --------------------------------------------------------------------------- */
bool CharacterClassSystem::addXP(flecs::entity entity, int64_t amount)
{
auto *rec = getRecord(entity);
if (!rec)
return false;
rec->currentXP += amount;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
if (!cls)
return false;
int64_t needed =
CharacterClassDatabase::getSingleton().computeXPForLevel(
rec->level, *cls);
if (rec->currentXP >= needed) {
applyLevelUp(entity);
return true;
}
return false;
}
bool CharacterClassSystem::distributePoint(flecs::entity entity,
const Ogre::String &statName)
{
auto *rec = getRecord(entity);
if (!rec)
return false;
if (rec->availablePoints <= 0)
return false;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
if (!cls)
return false;
const auto *statDef =
CharacterClassDatabase::getSingleton().findStat(statName);
if (!statDef)
return false;
int current = rec->stats.count(statName.c_str()) ?
rec->stats[statName.c_str()] :
0;
int cost = CharacterClassDatabase::getSingleton().computeStatCost(
current, *cls);
if (rec->availablePoints < cost)
return false;
rec->availablePoints -= cost;
rec->stats[statName.c_str()] = current + 1;
return true;
}
/* ---------------------------------------------------------------------------
* Update
* --------------------------------------------------------------------------- */
void CharacterClassSystem::toggleCharacterSheet(flecs::entity entity)
{
if (!entity.is_alive()) {
// Null or dead entity: close all sheets
m_sheets.clear();
return;
}
flecs::entity_t id = entity.id();
if (m_sheets.count(id)) {
m_sheets.erase(id);
} else {
m_sheets.insert(id);
}
}
void CharacterClassSystem::update(float deltaTime)
{
accumulateNeeds(deltaTime);
checkLevelUps();
// "I" key handling is now done in EditorApp::frameRenderingQueued
// where iPressed is valid (set by keyPressed between frames).
}
void CharacterClassSystem::accumulateNeeds(float deltaTime)
{
auto &db = CharacterClassDatabase::getSingleton();
m_world.query<CharacterIdentityComponent>().each(
[&](flecs::entity e, CharacterIdentityComponent &ci) {
auto *rec =
CharacterRegistry::getSingleton().findCharacter(
ci.registryId);
if (!rec || rec->className.empty())
return;
const auto *cls = db.findClass(rec->className);
if (!cls)
return;
for (auto &pair : rec->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)
{
auto *rec = getRecord(entity);
if (!rec)
return;
auto &db = CharacterClassDatabase::getSingleton();
if (!entity.has<GoapBlackboard>())
entity.set<GoapBlackboard>({});
auto &bb = entity.get_mut<GoapBlackboard>();
for (const auto &pair : rec->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<CharacterIdentityComponent>().each(
[&](flecs::entity e, CharacterIdentityComponent &ci) {
auto *rec =
CharacterRegistry::getSingleton().findCharacter(
ci.registryId);
if (!rec || rec->levelUpPending ||
rec->className.empty())
return;
const auto *cls = CharacterClassDatabase::getSingleton()
.findClass(rec->className);
if (!cls)
return;
int64_t needed =
CharacterClassDatabase::getSingleton()
.computeXPForLevel(rec->level, *cls);
if (rec->currentXP >= needed) {
applyLevelUp(e);
}
});
}
void CharacterClassSystem::applyLevelUp(flecs::entity entity)
{
auto *rec = getRecord(entity);
if (!rec || rec->className.empty())
return;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
if (!cls)
return;
int64_t needed =
CharacterClassDatabase::getSingleton().computeXPForLevel(
rec->level, *cls);
rec->currentXP -= needed;
rec->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) {
rec->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(rec->level) + "!");
}
void CharacterClassSystem::applyLevelUpGrowthAndPoints(flecs::entity entity)
{
auto *rec = getRecord(entity);
if (!rec || rec->className.empty())
return;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
if (!cls)
return;
auto &db = CharacterClassDatabase::getSingleton();
// Auto-grow stats
for (const auto &pair : cls->statGrowth) {
int growth = db.computeStatGrowth(pair.first, rec->level, *cls);
rec->stats[pair.first.c_str()] += growth;
}
// Auto-grow skills
for (const auto &pair : cls->skillGrowth) {
int growth =
db.computeSkillGrowth(pair.first, rec->level, *cls);
rec->skills[pair.first.c_str()] += growth;
const auto *skillDef = db.findSkill(pair.first);
if (skillDef &&
rec->skills[pair.first.c_str()] > skillDef->maxValue)
rec->skills[pair.first.c_str()] = skillDef->maxValue;
}
// Grant points
rec->availablePoints += db.computePointsForLevel(rec->level, *cls);
}
void CharacterClassSystem::distributePointsAI(flecs::entity entity)
{
auto *rec = getRecord(entity);
if (!rec || rec->className.empty())
return;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
if (!cls)
return;
int points = rec->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 = rec->stats.count(statName.c_str()) ?
rec->stats[statName.c_str()] :
0;
int cost =
CharacterClassDatabase::getSingleton().computeStatCost(
current, *cls);
if (points >= cost) {
rec->stats[statName.c_str()] = current + 1;
points -= cost;
idx++;
} else {
idx++;
if (idx >= (int)cls->primaryStats.size() * 2)
break;
}
}
// Random distribution for remainder
if (points > 0 && !rec->stats.empty()) {
std::vector<std::string> statNames;
for (const auto &pair : rec->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 = rec->stats.count(statName) ?
rec->stats[statName] :
0;
int cost = CharacterClassDatabase::getSingleton()
.computeStatCost(current, *cls);
if (points >= cost) {
rec->stats[statName] = current + 1;
points -= cost;
} else {
// Can't afford this stat, remove from pool
statNames.erase(statNames.begin() + r);
}
}
}
rec->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<CharacterIdentityComponent>()) {
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()) {
closedSheets.push_back(id);
continue;
}
renderCharacterSheet(e);
}
for (flecs::entity_t id : closedSheets)
m_sheets.erase(id);
}
void CharacterClassSystem::renderLevelUpDialog(flecs::entity entity)
{
auto *rec = getRecord(entity);
if (!rec || rec->className.empty())
return;
const auto *cls = CharacterClassDatabase::getSingleton().findClass(
rec->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(rec->level) + ")";
bool open = true;
if (!ImGui::Begin(title.c_str(), &open)) {
ImGui::End();
return;
}
ImGui::Text("Available Points: %d", rec->availablePoints);
ImGui::Separator();
auto &db = CharacterClassDatabase::getSingleton();
// Stats
if (!rec->stats.empty()) {
ImGui::Text("Stats");
for (auto &pair : rec->stats) {
const auto *statDef = db.findStat(pair.first);
int current = pair.second;
int cost = db.computeStatCost(current, *cls);
bool canAfford = rec->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) {
rec->availablePoints -= cost;
pair.second = current + 1;
}
ImGui::PopID();
}
}
ImGui::Separator();
if (ImGui::Button("Confirm", ImVec2(100, 0))) {
rec->levelUpPending = false;
open = false;
}
ImGui::SameLine();
if (ImGui::Button("Postpone", ImVec2(100, 0))) {
open = false;
}
ImGui::End();
if (!open) {
m_levelUpDialogs.erase(entity.id());
rec->levelUpPending = false;
}
}
void CharacterClassSystem::renderCharacterSheet(flecs::entity entity)
{
auto *rec = getRecord(entity);
const CharacterClassDatabase::ClassDef *cls = nullptr;
if (rec)
cls = CharacterClassDatabase::getSingleton().findClass(
rec->className);
Ogre::String title = "Character Sheet";
bool open = true;
// Full-screen window: use the entire display area
ImGuiIO &io = ImGui::GetIO();
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(io.DisplaySize);
ImGuiWindowFlags flags =
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar;
if (!ImGui::Begin(title.c_str(), &open, flags)) {
ImGui::End();
return;
}
if (rec) {
ImGui::Text("Class: %s",
cls ? cls->name.c_str() : rec->className.c_str());
ImGui::Text("Level: %d", rec->level);
ImGui::Text("XP: %ld", (long)rec->currentXP);
ImGui::Text("Available Points: %d", rec->availablePoints);
ImGui::Separator();
// Stats
if (!rec->stats.empty()) {
ImGui::Text("Stats");
for (const auto &pair : rec->stats) {
const auto *def =
CharacterClassDatabase::getSingleton()
.findStat(pair.first);
if (def &&
def->kind ==
CharacterClassDatabase::StatKind::
ResourcePool) {
int cur =
getPoolCurrent(rec, pair.first);
ImGui::Text(" %s: %d / %d",
pair.first.c_str(), cur,
pair.second);
} else {
ImGui::Text(" %s: %d",
pair.first.c_str(),
pair.second);
}
}
}
// Skills
if (!rec->skills.empty()) {
ImGui::Text("Skills");
for (const auto &pair : rec->skills) {
ImGui::Text(" %s: %d", pair.first.c_str(),
pair.second);
}
}
// Needs
if (!rec->needs.empty()) {
ImGui::Text("Needs");
for (const auto &pair : rec->needs) {
ImGui::Text(" %s: %d", pair.first.c_str(),
pair.second);
}
}
} else {
ImGui::Text("(No character data available)");
}
ImGui::Separator();
// Inventory
if (entity.has<InventoryComponent>()) {
auto &inv = entity.get<InventoryComponent>();
ImGui::Text("Inventory (%.1f / %.1f kg)", inv.totalWeight,
inv.maxWeight);
ImGui::Separator();
if (inv.slots.empty()) {
ImGui::Text(" (empty)");
} else {
for (size_t i = 0; i < inv.slots.size(); i++) {
const auto &slot = inv.slots[i];
if (slot.isEmpty())
continue;
Ogre::String itemName =
ItemRegistry::getSingleton().getItemName(
slot.itemId);
if (itemName.empty())
itemName = slot.itemId;
ImGui::Text(" %s x%d", itemName.c_str(),
slot.stackSize);
}
}
} else {
ImGui::Text("No inventory");
}
ImGui::End();
if (!open) {
m_sheets.erase(entity.id());
// Unpause the game when the sheet is closed via the X button
if (m_editorApp &&
m_editorApp->getGameMode() == EditorApp::GameMode::Game &&
m_editorApp->getGamePlayState() ==
EditorApp::GamePlayState::Paused) {
m_editorApp->setGamePlayState(
EditorApp::GamePlayState::Playing);
}
}
}
// ---------------------------------------------------------------------------
// InventoryDialogConfig implementation
// ---------------------------------------------------------------------------
void InventoryDialogConfig::prepareFont(EditorApp *editorApp)
{
if (!editorApp)
return;
Ogre::ImGuiOverlay *overlay = editorApp->getImGuiOverlay();
if (!overlay)
return;
if (fontPath.empty())
return;
try {
Ogre::String fontName = "InventoryDialogFont";
if (Ogre::FontManager::getSingleton().resourceExists(
fontName, "General")) {
Ogre::FontManager::getSingleton().remove(fontName,
"General");
}
Ogre::FontPtr font = Ogre::FontManager::getSingleton().create(
fontName, "General");
font->setType(Ogre::FontType::FT_TRUETYPE);
font->setSource(fontPath);
font->setTrueTypeSize(fontSize);
font->setTrueTypeResolution(75);
font->addCodePointRange(Ogre::Font::CodePointRange(32, 255));
font->load();
overlay->addFont(fontName, "General");
} catch (...) {
Ogre::LogManager::getSingleton().logMessage(
"InventoryDialogConfig: Failed to load font " +
fontPath);
}
}
bool InventoryDialogConfig::loadFromFile(const std::string &filepath)
{
std::ifstream f(filepath);
if (!f.is_open())
return false;
try {
nlohmann::json j;
f >> j;
deserialize(j);
return true;
} catch (...) {
return false;
}
}
bool InventoryDialogConfig::saveToFile(const std::string &filepath)
{
try {
std::ofstream f(filepath);
if (!f.is_open())
return false;
f << serialize().dump(2);
return true;
} catch (...) {
return false;
}
}
@@ -0,0 +1,121 @@
#ifndef EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
#define EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_set>
#include <string>
#include <nlohmann/json.hpp>
class EditorApp;
/**
* Configuration for the character sheet / inventory dialog.
* Loaded from / saved to inventory_config.json.
*/
struct InventoryDialogConfig {
std::string fontPath = "";
float fontSize = 16.0f;
nlohmann::json serialize() const
{
nlohmann::json j;
j["fontPath"] = fontPath;
j["fontSize"] = fontSize;
return j;
}
void deserialize(const nlohmann::json &j)
{
if (j.contains("fontPath") && j["fontPath"].is_string())
fontPath = j["fontPath"].get<std::string>();
if (j.contains("fontSize") && j["fontSize"].is_number())
fontSize = j["fontSize"].get<float>();
}
bool loadFromFile(const std::string &filepath);
bool saveToFile(const std::string &filepath);
/**
* Load the configured font into ImGui via Ogre's ImGuiOverlay.
* Should be called once during setup, before the overlay is shown.
*/
void prepareFont(class EditorApp *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.
*
* All mutable RPG data lives in the CharacterRegistry table;
* this system looks it up via CharacterIdentityComponent.
*/
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);
/** Toggle character sheet for the given entity. */
void toggleCharacterSheet(flecs::entity entity);
/** Check if the character sheet is currently open for any entity. */
bool isSheetOpen() const
{
return !m_sheets.empty();
}
/** Get the inventory dialog configuration. */
InventoryDialogConfig &getConfig()
{
return m_config;
}
/** Set the inventory dialog configuration (from editor UI). */
void setConfig(const InventoryDialogConfig &cfg)
{
m_config = cfg;
}
private:
void accumulateNeeds(float deltaTime);
void updateNeedBits(flecs::entity entity);
void checkLevelUps();
void applyLevelUp(flecs::entity entity);
void applyLevelUpGrowthAndPoints(flecs::entity entity);
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;
// Inventory dialog configuration
InventoryDialogConfig m_config;
};
#endif // EDITSCENE_CHARACTER_CLASS_SYSTEM_HPP
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
#ifndef EDITSCENE_CHARACTERREGISTRY_HPP
#define EDITSCENE_CHARACTERREGISTRY_HPP
#pragma once
#include <Ogre.h>
#include <flecs.h>
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "MarkovNameGenerator.hpp"
#include "../components/CharacterSlots.hpp"
class EditorUISystem;
/**
* Global character and social-group registry.
*
* Characters are referenced by a persistent uint64_t ID. Each character
* stores a path to its appearance prefab (CharacterSlotsComponent data).
*
* The registry auto-saves to a fixed file after every mutation and loads
* on initialization. Character prefabs are auto-named from the ID and
* name, created from templates, and deleted along with the character.
*/
class CharacterRegistry {
public:
static CharacterRegistry &getSingleton();
static CharacterRegistry *getSingletonPtr();
private:
static CharacterRegistry *ms_singleton;
public:
/* ------------------------------------------------------------------ */
/* Column schema */
/* ------------------------------------------------------------------ */
struct ColumnDef {
enum Type { Int, Float, String };
Type type;
std::string name;
};
/* ------------------------------------------------------------------ */
/* Records */
/* ------------------------------------------------------------------ */
struct CharacterRecord {
uint64_t id = 0;
std::string firstName;
std::string lastName;
std::string prefabPath; /* relative path to prefab JSON */
/* RPG data (authoritative source for class, level, stats, etc.) */
std::string className;
int level = 1;
int64_t currentXP = 0;
int availablePoints = 0;
std::unordered_map<std::string, int> stats;
std::unordered_map<std::string, int> skills;
std::unordered_map<std::string, int> needs;
std::unordered_map<std::string, int> currentPools;
bool levelUpPending = false;
/* Age category string (e.g. "adult", "child", "elder") */
std::string age = "adult";
/* Age in years (runtime progression) */
int ageYears = 0;
/* Inline appearance (used when no prefab file exists) */
std::string inlineSex = "male";
int inlineOutfitLevel = 2;
std::unordered_map<std::string, SlotSelection>
inlineSlotSelections;
std::unordered_map<std::string, float> inlineShapeKeyWeights;
/* Runtime-only characters are NOT saved to character_registry.json */
bool persistent = true;
/* Spawn position/rotation (persisted in save games) */
Ogre::Vector3 position = Ogre::Vector3::ZERO;
Ogre::Quaternion rotation = Ogre::Quaternion::IDENTITY;
/* Pregnancy state (progresses whether spawned or not) */
uint64_t pregnantByFatherId = 0;
float pregnancyProgress = 0.0f;
float pregnancyMaxProgress = 0.0f;
float basePregnancyDuration = 0.0f; /* 0 = use global */
/* Tags */
std::vector<std::string> tags;
/* Extensible custom columns */
std::unordered_map<std::string, int64_t> intColumns;
std::unordered_map<std::string, double> floatColumns;
std::unordered_map<std::string, std::string> stringColumns;
};
struct GroupRecord {
uint64_t id = 0;
std::string name;
std::vector<uint64_t> memberIds;
/* Extensible custom columns */
std::unordered_map<std::string, int64_t> intColumns;
std::unordered_map<std::string, double> floatColumns;
std::unordered_map<std::string, std::string> stringColumns;
};
struct Relationship {
uint64_t sourceId = 0;
uint64_t targetId = 0;
bool sourceIsGroup = false;
bool targetIsGroup = false;
/* Numeric stats: friendship, love, hate, loyalty, fear … */
std::unordered_map<std::string, float> stats;
/* Tags: wife, sibling, parent, enemy, ally, mentor … */
std::unordered_set<std::string> tags;
};
/* ------------------------------------------------------------------ */
/* Life-cycle */
/* ------------------------------------------------------------------ */
CharacterRegistry();
~CharacterRegistry() = default;
/* non-copyable */
CharacterRegistry(const CharacterRegistry &) = delete;
CharacterRegistry &operator=(const CharacterRegistry &) = delete;
void setWorld(flecs::world *world)
{
m_world = world;
}
void setSceneManager(Ogre::SceneManager *sceneMgr)
{
m_sceneMgr = sceneMgr;
}
void setEditorUISystem(EditorUISystem *ui)
{
m_uiSystem = ui;
}
/**
* Load registry from auto-save file. Call after setWorld/setSceneManager.
*/
void initialize();
/* ------------------------------------------------------------------ */
/* Characters */
/* ------------------------------------------------------------------ */
uint64_t createCharacter(const std::string &firstName,
const std::string &lastName,
const std::string &templatePath = "",
bool persistent = true);
void deleteCharacter(uint64_t id);
CharacterRecord *findCharacter(uint64_t id);
const CharacterRecord *findCharacter(uint64_t id) const;
const std::unordered_map<uint64_t, CharacterRecord> &
getCharacters() const
{
return m_characters;
}
/* ------------------------------------------------------------------ */
/* Groups / Factions */
/* ------------------------------------------------------------------ */
uint64_t createGroup(const std::string &name);
void deleteGroup(uint64_t id);
void addToGroup(uint64_t groupId, uint64_t characterId);
void removeFromGroup(uint64_t groupId, uint64_t characterId);
GroupRecord *findGroup(uint64_t id);
const GroupRecord *findGroup(uint64_t id) const;
const std::unordered_map<uint64_t, GroupRecord> &getGroups() const
{
return m_groups;
}
/* ------------------------------------------------------------------ */
/* Relationships */
/* ------------------------------------------------------------------ */
Relationship *findRelationship(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup);
const Relationship *findRelationship(uint64_t sourceId,
bool sourceIsGroup,
uint64_t targetId,
bool targetIsGroup) const;
void setRelationshipStat(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup,
const std::string &stat, float value);
float getRelationshipStat(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup,
const std::string &stat) const;
void addRelationshipTag(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup,
const std::string &tag);
void removeRelationshipTag(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup,
const std::string &tag);
bool hasRelationshipTag(uint64_t sourceId, bool sourceIsGroup,
uint64_t targetId, bool targetIsGroup,
const std::string &tag) const;
std::vector<const Relationship *> getRelationships(uint64_t id) const;
void purgeRelationships(uint64_t id);
/* ------------------------------------------------------------------ */
/* Custom columns */
/* ------------------------------------------------------------------ */
void addCharacterColumn(const std::string &name, ColumnDef::Type type);
void removeCharacterColumn(const std::string &name);
const std::vector<ColumnDef> &getCharacterColumns() const
{
return m_characterColumns;
}
void addGroupColumn(const std::string &name, ColumnDef::Type type);
void removeGroupColumn(const std::string &name);
const std::vector<ColumnDef> &getGroupColumns() const
{
return m_groupColumns;
}
/* ------------------------------------------------------------------ */
/* Prefab helpers (static) */
/* ------------------------------------------------------------------ */
static bool isValidCharacterPrefab(const std::string &filepath);
static bool readPrefabSlots(const std::string &filepath,
std::string &age, std::string &sex,
int &outfitLevel);
static bool writePrefabSlots(const std::string &filepath,
const std::string &age,
const std::string &sex, int outfitLevel);
static bool copyPrefab(const std::string &src, const std::string &dst);
static bool deletePrefabFile(const std::string &filepath);
/* ------------------------------------------------------------------ */
/* Templates */
/* ------------------------------------------------------------------ */
void scanTemplates();
const std::vector<std::string> &getTemplates() const
{
return m_templates;
}
/* ------------------------------------------------------------------ */
/* Name Generation */
/* ------------------------------------------------------------------ */
void learnNamesFromRegistry();
std::string generateFirstName(const std::string &sex = "male") const;
std::string generateLastName() const;
const MarkovNameGenerator &getMaleFirstNameGen() const
{
return m_maleFirstNameGen;
}
const MarkovNameGenerator &getFemaleFirstNameGen() const
{
return m_femaleFirstNameGen;
}
std::vector<std::string> &getMaleFirstNameSeeds()
{
return m_maleFirstNameSeeds;
}
std::vector<std::string> &getFemaleFirstNameSeeds()
{
return m_femaleFirstNameSeeds;
}
std::vector<std::string> &getLastNameSeeds()
{
return m_lastNameSeeds;
}
/* Family / Birth */
/* ------------------------------------------------------------------ */
std::vector<uint64_t> getParents(uint64_t childId) const;
std::vector<uint64_t> getChildren(uint64_t parentId) const;
uint64_t createChild(uint64_t parentA, uint64_t parentB);
/* Pregnancy helpers */
bool conceive(uint64_t motherId, uint64_t fatherId);
void abortPregnancy(uint64_t motherId);
bool isPregnant(uint64_t motherId) const;
float getBasePregnancyDuration() const
{
return m_basePregnancyDuration;
}
void setBasePregnancyDuration(float v)
{
m_basePregnancyDuration = v;
}
float getPregnancyTimeScale() const
{
return m_pregnancyTimeScale;
}
void setPregnancyTimeScale(float v)
{
m_pregnancyTimeScale = v;
}
std::vector<std::string> &getBirthRandomizableShapeKeys()
{
return m_birthRandomizableShapeKeys;
}
std::vector<std::string> &getBirthExcludedShapeKeys()
{
return m_birthExcludedShapeKeys;
}
/* Spawn / Save */
/* ------------------------------------------------------------------ */
flecs::entity findSpawnedEntity(uint64_t id) const;
/**
* Mark the spawned entity for a character as dirty so its visual
* appearance (CharacterSlotsComponent) is rebuilt on the next
* update. Call this after changing outfitLevel, age, or any other
* registry field that affects the character's look.
*/
void markCharacterDirty(uint64_t id);
bool despawnCharacter(uint64_t id);
flecs::entity spawnCharacter(uint64_t id);
flecs::entity spawnInlineCharacter(const CharacterRecord &c,
const Ogre::Vector3 &pos);
bool savePrefabForCharacter(uint64_t id);
/**
* Initialize RPG data from class definition.
* Resets stats/skills/needs to class base, simulates level-ups,
* and sets pools to full.
*/
void initializeFromClass(uint64_t id);
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
/* ------------------------------------------------------------------ */
/* ImGui editor */
/* ------------------------------------------------------------------ */
void drawEditor(bool *p_open = nullptr);
/* Auto-save to fixed path (called after mutations) */
void autoSave();
private:
uint64_t m_nextId = 1;
uint64_t m_nextRuntimeId = 1ull << 32;
std::unordered_map<uint64_t, CharacterRecord> m_characters;
std::unordered_map<uint64_t, GroupRecord> m_groups;
std::vector<Relationship> m_relationships;
std::vector<ColumnDef> m_characterColumns;
std::vector<ColumnDef> m_groupColumns;
/* Fast relationship indexes (value = index into m_relationships) */
std::unordered_multimap<uint64_t, size_t> m_relBySource;
std::unordered_multimap<uint64_t, size_t> m_relByTarget;
mutable std::string m_lastError;
std::string m_autoSavePath;
std::vector<std::string> m_templates;
MarkovNameGenerator m_maleFirstNameGen;
MarkovNameGenerator m_femaleFirstNameGen;
MarkovNameGenerator m_lastNameGen;
std::vector<std::string> m_maleFirstNameSeeds;
std::vector<std::string> m_femaleFirstNameSeeds;
std::vector<std::string> m_lastNameSeeds;
std::vector<std::string> m_birthRandomizableShapeKeys;
std::vector<std::string> m_birthExcludedShapeKeys;
/* Global pregnancy config */
float m_basePregnancyDuration = 300.0f;
float m_pregnancyTimeScale = 1.0f;
void rebuildNameGenerators();
flecs::world *m_world = nullptr;
Ogre::SceneManager *m_sceneMgr = nullptr;
EditorUISystem *m_uiSystem = nullptr;
void rebuildIndexes();
void addToIndex(size_t relIndex);
void removeFromIndex(uint64_t sourceId, uint64_t targetId,
size_t relIndex);
std::string generatePrefabPath(uint64_t id,
const std::string &firstName,
const std::string &lastName) const;
/* UI state */
int m_editorTab = 0;
uint64_t m_selectedCharacterId = 0;
uint64_t m_selectedGroupId = 0;
uint64_t m_relSourceId = 0;
int m_relSourceIsGroup = 0;
};
#endif // EDITSCENE_CHARACTERREGISTRY_HPP
@@ -1,19 +1,28 @@
#include "CharacterSlotSystem.hpp"
#include "CharacterRegistry.hpp"
#include "OgreEntityHack.hpp"
#include "../components/Transform.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/CharacterSlots.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();
std::set<Ogre::String> CharacterSlotSystem::s_meshNames;
CharacterSlotSystem::CharacterSlotSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
Ogre::SceneManager *sceneMgr)
: m_world(world)
, m_sceneMgr(sceneMgr)
{
@@ -80,17 +89,19 @@ void CharacterSlotSystem::loadCatalog()
if (!s_bodyParts.contains(age))
s_bodyParts[age] = nlohmann::json::object();
if (!s_bodyParts[age].contains(sex))
s_bodyParts[age][sex] = nlohmann::json::object();
s_bodyParts[age][sex] =
nlohmann::json::object();
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 */
try {
Ogre::MeshManager::getSingleton().load(mesh,
"Characters");
Ogre::MeshManager::getSingleton().load(
mesh, "Characters");
} catch (...) {
}
} catch (...) {
@@ -116,8 +127,7 @@ std::vector<Ogre::String> CharacterSlotSystem::getAges()
return ages;
}
std::vector<Ogre::String> CharacterSlotSystem::getSexes(
const Ogre::String &age)
std::vector<Ogre::String> CharacterSlotSystem::getSexes(const Ogre::String &age)
{
std::vector<Ogre::String> sexes;
if (!s_catalogLoaded || !s_bodyParts.contains(age))
@@ -127,8 +137,8 @@ std::vector<Ogre::String> CharacterSlotSystem::getSexes(
return sexes;
}
std::vector<Ogre::String> CharacterSlotSystem::getSlots(
const Ogre::String &age, const Ogre::String &sex)
std::vector<Ogre::String> CharacterSlotSystem::getSlots(const Ogre::String &age,
const Ogre::String &sex)
{
std::vector<Ogre::String> slots;
if (!s_catalogLoaded || !s_bodyParts.contains(age) ||
@@ -139,128 +149,634 @@ std::vector<Ogre::String> CharacterSlotSystem::getSlots(
return slots;
}
std::vector<Ogre::String> CharacterSlotSystem::getMeshes(
const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot)
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 (auto &m : s_bodyParts[age][sex][slot])
meshes.push_back(m.get<Ogre::String>());
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()) {
Ogre::String label;
for (size_t i = 0; i < garments.size(); ++i) {
if (i > 0)
label += " + ";
label += garments[i].get<std::string>();
}
return label;
}
/* For non-garment slots (hair, face, etc.), derive
* a human-readable label from the mesh filename */
Ogre::String label = mesh;
/* Strip directory prefix */
auto slashPos = label.rfind('/');
if (slashPos != Ogre::String::npos)
label = label.substr(slashPos + 1);
/* Strip .mesh extension */
auto dotPos = label.rfind(".mesh");
if (dotPos != Ogre::String::npos)
label = label.substr(0, dotPos);
/* Strip sex prefix (male_/female_) */
Ogre::String sexPrefix = sex + "_";
if (label.find(sexPrefix) == 0)
label = label.substr(sexPrefix.length());
/* Replace underscores with spaces */
for (auto &c : label) {
if (c == '_')
c = ' ';
}
return label;
}
}
return mesh;
}
std::vector<Ogre::String>
CharacterSlotSystem::getMeshes(const Ogre::String &age, const Ogre::String &sex,
const Ogre::String &slot)
{
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])
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());
}
static std::vector<Ogre::String> garmentsForMesh(const Ogre::String &age,
const Ogre::String &sex,
const Ogre::String &slot,
const Ogre::String &mesh)
{
if (!CharacterSlotSystem::isCatalogLoaded())
return {};
const nlohmann::json &cat = CharacterSlotSystem::getCatalog();
if (!cat.contains(age) || !cat[age].contains(sex) ||
!cat[age][sex].contains(slot))
return {};
for (const auto &entry : cat[age][sex][slot]) {
if (entry.value("mesh", "") == mesh) {
std::vector<Ogre::String> result;
for (const auto &g :
entry.value("garments", nlohmann::json::array()))
result.push_back(g.get<std::string>());
return result;
}
}
return {};
}
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];
/* If layer 0 is explicitly selected, use it */
if (sel.layer0Mesh != "none" && !sel.layer0Mesh.empty()) {
for (const auto &entry : slotEntries) {
if (entry.value("mesh", "") == sel.layer0Mesh)
return sel.layer0Mesh;
}
}
/* outfitLevel: 0=nude, 1=lingerie, 2=clothed */
if (outfitLevel >= 2 && sel.layer2Mesh != "none" &&
!sel.layer2Mesh.empty()) {
/* If layer 1 is also selected, try to find a combined mesh
* whose garments array contains both selections */
if (sel.layer1Mesh != "none" && !sel.layer1Mesh.empty()) {
auto l1g =
garmentsForMesh(age, sex, slot, sel.layer1Mesh);
auto l2g =
garmentsForMesh(age, sex, slot, sel.layer2Mesh);
std::set<Ogre::String> required;
for (const auto &g : l1g)
required.insert(g);
for (const auto &g : l2g)
required.insert(g);
if (!required.empty()) {
Ogre::String combinedMesh;
for (const auto &entry : slotEntries) {
auto entryGarments = entry.value(
"garments",
nlohmann::json::array());
std::set<Ogre::String> eg;
for (const auto &g : entryGarments)
eg.insert(g.get<std::string>());
bool containsAll = true;
for (const auto &g : required) {
if (eg.find(g) == eg.end()) {
containsAll = false;
break;
}
}
if (containsAll) {
Ogre::String m =
entry["mesh"]
.get<Ogre::String>();
/* Prefer exact layer2 match if it already
* satisfies the requirement */
if (m == sel.layer2Mesh)
return m;
if (combinedMesh.empty())
combinedMesh = m;
}
}
if (!combinedMesh.empty())
return combinedMesh;
}
}
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 shortest name.
* Base mesh names are shortest; combined meshes add suffixes.
* We also penalise Blender duplicate names (.001, .002). */
Ogre::String bestLayer0;
size_t bestLen = 0;
for (const auto &entry : slotEntries) {
if (entry.value("layer", 0) == 0) {
Ogre::String mesh = entry["mesh"].get<Ogre::String>();
size_t effectiveLen = mesh.length();
size_t dotMesh = mesh.rfind(".mesh");
if (dotMesh != Ogre::String::npos && dotMesh >= 4) {
bool isDup = true;
for (size_t i = dotMesh - 3; i < dotMesh; ++i) {
if (!isdigit(static_cast<unsigned char>(
mesh[i]))) {
isDup = false;
break;
}
}
if (isDup && mesh[dotMesh - 4] == '.')
effectiveLen += 1000;
}
if (bestLayer0.empty() || effectiveLen < bestLen) {
bestLayer0 = mesh;
bestLen = effectiveLen;
}
}
}
if (!bestLayer0.empty())
return bestLayer0;
/* Last resort: first available entry */
if (!slotEntries.empty())
return slotEntries[0]["mesh"].get<Ogre::String>();
return "";
}
void CharacterSlotSystem::update()
{
if (!m_initialized)
return;
m_world.query<CharacterSlotsComponent>().each(
[this](flecs::entity e, CharacterSlotsComponent &cs) {
[&](flecs::entity e, CharacterSlotsComponent &cs) {
if (cs.dirty) {
std::cout << "CharacterSlotSystem: building entity "
<< e.id() << std::endl;
buildCharacter(e, cs);
cs.dirty = false;
}
});
}
void CharacterSlotSystem::buildCharacter(flecs::entity e,
CharacterSlotsComponent &cs)
static const nlohmann::json *findCatalogEntry(const Ogre::String &age,
const Ogre::String &sex,
const Ogre::String &slot,
const Ogre::String &mesh)
{
std::cout << "CharacterSlotSystem::buildCharacter: entity=" << e.id()
<< " age=" << cs.age << " sex=" << cs.sex
<< " slots=" << cs.slots.size() << std::endl;
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);
}
}
/**
* Helper: retrieve the character's age string from the registry.
* Falls back to "adult" if no registry entry is found.
*/
static Ogre::String getCharacterAge(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";
}
/**
* 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)
{
destroyCharacterParts(e);
if (!e.has<TransformComponent>()) {
std::cout << " no TransformComponent" << std::endl;
return;
Ogre::String age = getCharacterAge(e);
/* 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;
/* 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);
for (const auto &slot : slots) {
if (cs.slotSelections.find(slot) == cs.slotSelections.end())
cs.slotSelections[slot] = SlotSelection();
}
if (!e.has<TransformComponent>())
return;
auto &transform = e.get_mut<TransformComponent>();
if (!transform.node)
return;
int outfitLevel = getCharacterOutfitLevel(e);
/* 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(age, cs.sex, "face",
cs.slotSelections["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,
pair.second,
outfitLevel);
if (!mesh.empty()) {
masterSlot = pair.first;
break;
}
}
}
if (masterSlot.empty()) {
std::cout << " masterSlot empty" << std::endl;
if (masterSlot.empty())
return;
}
std::cout << " masterSlot=" << masterSlot
<< " mesh=" << cs.slots.at(masterSlot) << std::endl;
Ogre::String masterMesh = resolveMesh(age, cs.sex, masterSlot,
cs.slotSelections[masterSlot],
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(age, cs.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.
*/
prepareEntityTempBlendBuffers(masterEnt);
/* 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());
"[CharacterSlotSystem] buildCharacter: FAILED to load master mesh '" +
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(age, cs.sex, slot, sel, outfitLevel);
if (mesh.empty())
continue;
try {
Ogre::Entity *partEnt = m_sceneMgr->createEntity(mesh);
partEnt->shareSkeletonInstanceWith(masterEnt);
transform.node->attachObject(partEnt);
ensureMeshPoseAnimation(mesh);
Ogre::MeshPtr partMesh =
Ogre::MeshManager::getSingleton().load(
mesh, "Characters");
Ogre::Entity *partEnt =
m_sceneMgr->createEntity(partMesh);
/* Check if this mesh has its own skeleton
* (e.g. hair exported with a hair-specific
* armature). If so, attach to the master's
* Head bone instead of sharing skeletons. */
const nlohmann::json *entry =
findCatalogEntry(age, cs.sex, slot, mesh);
bool ownSkeleton = false;
Ogre::String attachBone = "mixamorig:Head";
if (entry) {
ownSkeleton = entry->value(
"own_skeleton", false);
attachBone = entry->value(
"attach_to_bone", attachBone);
}
if (ownSkeleton) {
/* Hair with its own skeleton: attach
* to the master entity's Head bone
* so it follows head movement while
* being animated by its own skeleton.
*/
masterEnt->attachObjectToBone(
attachBone, partEnt);
Ogre::LogManager::getSingleton()
.logMessage(
"[CharacterSlotSystem] "
"Attached hair '" +
slot +
"' with own skeleton to "
"bone '" +
attachBone + "'");
} else {
partEnt->shareSkeletonInstanceWith(
masterEnt);
transform.node->attachObject(partEnt);
}
m_entities[e.id()].parts[slot] = partEnt;
std::cout << " part loaded: " << slot << "="
<< partEnt->getName() << std::endl;
applyShapeKeys(e, partEnt, entry);
prepareEntityTempBlendBuffers(partEnt);
} 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 '" +
"[CharacterSlotSystem] buildCharacter: FAILED to load part '" +
slot + "' mesh '" + mesh +
"': " + ex.getDescription());
}
}
}
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);
}
/* Ensure the animation has at least one pose track.
* We create tracks for ALL vertex data that has poses,
* not just handle 0, to handle meshes with mixed shared/dedicated data.
*/
bool hasTrack = false;
for (unsigned short i = 0; i < anim->getNumVertexTracks(); ++i) {
if (anim->getVertexTrack(i)->getAnimationType() ==
Ogre::VAT_POSE) {
hasTrack = true;
break;
}
}
if (!hasTrack) {
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);
}
/* Ensure the entity has an animation state for ShapeKeys.
* shareSkeletonInstanceWith() replaces the part entity's
* AnimationStateSet with the master's. If the master doesn't
* have ShapeKeys, the part entity won't have it either.
* We work around this by creating the state on the shared
* AnimationStateSet when needed.
*/
Ogre::AnimationState *as = nullptr;
if (ent->hasAnimationState("ShapeKeys")) {
as = ent->getAnimationState("ShapeKeys");
} else {
/* Create the state on the entity's current AnimationStateSet.
* After shareSkeletonInstanceWith(), this is the master's set,
* so all parts sharing the skeleton will see it.
*/
Ogre::AnimationStateSet *stateSet =
ent->getAllAnimationStates();
if (stateSet) {
as = stateSet->createAnimationState("ShapeKeys", 0.0,
1.0, 1.0, false);
} else {
Ogre::LogManager::getSingleton().logMessage(
"[CharacterSlotSystem] applyShapeKeys: entity '" +
ent->getName() + "' mesh '" + mesh->getName() +
"' has no AnimationStateSet");
return;
}
}
as->setEnabled(true);
as->setLoop(false);
/* Build name -> pose index map from catalog.
*
* IMPORTANT: The catalog now skips "Basis" to match OGRE's pose indexing.
* OGRE's blender2ogre exporter skips the Basis shape key (index 0),
* so pose index 0 in OGRE = first non-Basis shape key in the catalog.
* This means catalog index i directly maps to OGRE pose index i.
*/
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
* on ALL pose tracks in the animation, not just track 0.
*/
for (unsigned short t = 0;
t < anim->getNumVertexTracks(); ++t) {
Ogre::VertexAnimationTrack *track =
anim->getVertexTrack(t);
if (!track ||
track->getAnimationType() != Ogre::VAT_POSE)
continue;
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 +799,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 +811,7 @@ 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;
@@ -1,8 +1,49 @@
#include "CharacterSystem.hpp"
#include "../components/CharacterSlots.hpp"
#include <Jolt/Physics/Character/Character.h>
#include <Jolt/Physics/Collision/GroupFilterTable.h>
#include <Jolt/Physics/Body/BodyInterface.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <OgreLogManager.h>
#include <OgreEntity.h>
#include <OgreSkeletonInstance.h>
#include <iostream>
static Ogre::Entity *getMasterEntity(flecs::entity e)
{
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
return nullptr;
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
return cs.masterEntity;
}
static bool getBoneWorldMatrix(Ogre::Entity *masterEnt,
const Ogre::String &boneName,
Ogre::Matrix4 &outMat)
{
if (!masterEnt || boneName.empty() || !masterEnt->hasSkeleton())
return false;
Ogre::SkeletonInstance *skel = masterEnt->getSkeleton();
if (!skel)
return false;
Ogre::Bone *bone = nullptr;
try {
bone = skel->getBone(boneName);
} catch (...) {
return false;
}
if (!bone)
return false;
outMat = bone->_getFullTransform();
Ogre::SceneNode *node = masterEnt->getParentSceneNode();
if (node)
outMat = node->_getFullTransform() * outMat;
return true;
}
CharacterSystem::CharacterSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr)
: m_world(world)
@@ -136,6 +177,116 @@ JPH::ShapeRefC CharacterSystem::buildCharacterShape(flecs::entity e,
rotations);
}
void CharacterSystem::createHeadCollider(flecs::entity e,
CharacterComponent &cc,
const TransformComponent &transform)
{
if (!m_physics)
return;
if (cc.headColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.headColliderBody);
m_physics->destroyBody(cc.headColliderBody);
cc.headColliderBody = JPH::BodyID();
}
if (cc.collisionGroupId == 0)
return;
float headRadius = cc.headRadius > 0.0f ? cc.headRadius : 0.13f;
Ogre::Matrix4 headWorld;
bool hasBone = getBoneWorldMatrix(getMasterEntity(e), cc.headBoneName,
headWorld);
if (hasBone) {
headWorld.setTrans(headWorld.getTrans() +
Ogre::Vector3(0.0f, cc.headOffsetY, 0.0f));
} else {
float headOffsetY = cc.headOffsetY > 0.0f ?
cc.headOffsetY :
cc.getTotalHeight() - headRadius;
Ogre::Vector3 headOffset(0.0f, headOffsetY, 0.0f);
headOffset += cc.offset;
Ogre::Vector3 headPos =
transform.node->_getDerivedPosition() +
transform.node->_getDerivedOrientation() *
Ogre::Vector3(cc.offset.x, 0.0f, cc.offset.z) +
Ogre::Vector3(0.0f, headOffsetY + cc.offset.y, 0.0f);
Ogre::Matrix4 local = Ogre::Matrix4::IDENTITY;
local.makeTransform(headPos, Ogre::Vector3::UNIT_SCALE,
Ogre::Quaternion::IDENTITY);
headWorld = local;
}
JPH::ShapeRefC sphere = m_physics->createSphereShape(headRadius);
cc.headColliderBody = m_physics->createBody(
sphere, 0.0f, headWorld.getTrans(),
Ogre::Quaternion::IDENTITY,
JPH::EMotionType::Kinematic, Layers::HEAD);
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(cc.headColliderBody,
JPH::CollisionGroup(filter, cc.collisionGroupId,
1));
}
m_physics->addBody(cc.headColliderBody,
JPH::EActivation::Activate);
}
void CharacterSystem::createChestCollider(flecs::entity e,
CharacterComponent &cc)
{
if (!m_physics)
return;
if (cc.chestColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.chestColliderBody);
m_physics->destroyBody(cc.chestColliderBody);
cc.chestColliderBody = JPH::BodyID();
}
if (cc.collisionGroupId == 0 ||
cc.chestHalfExtents.x <= 0.0f ||
cc.chestHalfExtents.y <= 0.0f ||
cc.chestHalfExtents.z <= 0.0f)
return;
Ogre::Matrix4 chestWorld;
if (!getBoneWorldMatrix(getMasterEntity(e), cc.chestBoneName,
chestWorld))
return;
Ogre::Vector3 chestPos = chestWorld.getTrans() +
Ogre::Vector3(0.0f, cc.chestOffsetY, 0.0f);
Ogre::Matrix3 chestRotMat = chestWorld.linear().orthonormalised();
Ogre::Quaternion chestRot = Ogre::Quaternion(chestRotMat);
JPH::ShapeRefC box = m_physics->createBoxShape(cc.chestHalfExtents);
cc.chestColliderBody = m_physics->createBody(
box, 0.0f, chestPos, chestRot,
JPH::EMotionType::Kinematic, Layers::HEAD);
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(cc.chestColliderBody,
JPH::CollisionGroup(filter, cc.collisionGroupId,
2));
}
m_physics->addBody(cc.chestColliderBody,
JPH::EActivation::Activate);
}
void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc)
{
if (!m_physics)
@@ -153,13 +304,37 @@ void CharacterSystem::setupEntity(flecs::entity e, CharacterComponent &cc)
if (!shape)
return;
/* Assign a non-zero collision group for per-character filtering
* (body = subgroup 0, head = subgroup 1, chest = subgroup 2,
* hair joints = 3+). */
if (cc.collisionGroupId == 0)
cc.collisionGroupId = static_cast<uint32_t>(e.id()) |
0x80000000;
JPH::CharacterBase *base =
m_physics->createCharacter(transform.node, shape);
if (!base)
return;
auto *ch = static_cast<JPH::Character *>(base);
/* Set collision group on character body so hair filtering works. */
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
if (physSystem) {
JPH::BodyInterface &bi = physSystem->GetBodyInterface();
JPH::GroupFilterTable *filter =
m_physics->getOrCreateGroupFilter(
cc.collisionGroupId);
bi.SetCollisionGroup(
ch->GetBodyID(),
JPH::CollisionGroup(filter, cc.collisionGroupId,
0));
}
ch->AddToPhysicsSystem();
createHeadCollider(e, cc, transform);
createChestCollider(e, cc);
// Don't modify gravity factor - let buoyancy handle floating/sinking
// m_physics->setGravityFactor(ch->GetBodyID(), 0.0f);
cc.hasFloor = false;
@@ -194,6 +369,20 @@ void CharacterSystem::teardownEntity(flecs::entity e)
std::shared_ptr<JPH::Character>(state.character));
}
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
if (cc.headColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.headColliderBody);
m_physics->destroyBody(cc.headColliderBody);
cc.headColliderBody = JPH::BodyID();
}
if (cc.chestColliderBody.IsInvalid() == false) {
m_physics->removeBody(cc.chestColliderBody);
m_physics->destroyBody(cc.chestColliderBody);
cc.chestColliderBody = JPH::BodyID();
}
}
m_states.erase(it);
}
@@ -301,9 +490,13 @@ void CharacterSystem::update(float deltaTime)
* writes position back to scene node normally. */
/* Apply velocity via Jolt linear velocity.
* Preserve physics-driven Y velocity when no explicit
* vertical input is given so gravity/buoyancy/jumps
* are not overwritten every frame. */
* For a dynamic character body, preserving the
* physics-driven Y velocity when on the ground
* causes gravity to continuously pull the character
* into the floor. The collision solver pushes back,
* but the body never settles, producing visible
* micro-jitter. Zero out Y when supported and no
* explicit vertical input is requested. */
JPH::Vec3 currentVel =
state.character->GetLinearVelocity();
JPH::Vec3 desiredVel = JoltPhysics::convert<JPH::Vec3>(
@@ -311,16 +504,15 @@ void CharacterSystem::update(float deltaTime)
JPH::Vec3 finalVel;
finalVel.SetX(desiredVel.GetX());
finalVel.SetZ(desiredVel.GetZ());
finalVel.SetY(desiredVel.GetY() != 0.0f ?
desiredVel.GetY() :
currentVel.GetY());
state.character->SetLinearVelocity(finalVel);
if (cc.linearVelocity.squaredLength() > 0.0001f) {
std::cout << "CharacterSystem::update: entity="
<< e.id()
<< " vel=" << cc.linearVelocity
<< std::endl;
if (desiredVel.GetY() != 0.0f) {
finalVel.SetY(desiredVel.GetY());
} else if (state.character->IsSupported()) {
finalVel.SetY(0.0f);
} else {
finalVel.SetY(currentVel.GetY());
}
state.character->SetLinearVelocity(finalVel);
/* Floor detection: raycast downward to find ground */
if (cc.useGravity && !cc.hasFloor) {
@@ -350,5 +542,58 @@ void CharacterSystem::update(float deltaTime)
/* Sync rotation from scene node */
state.character->SetRotation(JoltPhysics::convert(
state.sceneNode->_getDerivedOrientation()));
/* Sync head and chest colliders to animated bones. */
Ogre::Entity *masterEnt = getMasterEntity(e);
Ogre::Matrix4 boneWorld;
if (cc.headColliderBody.IsInvalid() == false &&
transform.node) {
float headRadius = cc.headRadius > 0.0f ?
cc.headRadius :
0.13f;
float headOffsetY = cc.headOffsetY > 0.0f ?
cc.headOffsetY :
cc.getTotalHeight() - headRadius;
Ogre::Vector3 headPos;
if (getBoneWorldMatrix(masterEnt, cc.headBoneName,
boneWorld)) {
headPos = boneWorld.getTrans() +
Ogre::Vector3(0.0f,
cc.headOffsetY,
0.0f);
} else {
headPos =
transform.node->_getDerivedPosition() +
transform.node->_getDerivedOrientation() *
Ogre::Vector3(cc.offset.x,
0.0f,
cc.offset.z) +
Ogre::Vector3(0.0f,
headOffsetY +
cc.offset.y,
0.0f);
}
m_physics->setPositionAndRotation(
cc.headColliderBody, headPos,
Ogre::Quaternion::IDENTITY, true);
}
if (cc.chestColliderBody.IsInvalid() == false &&
getBoneWorldMatrix(masterEnt, cc.chestBoneName,
boneWorld)) {
Ogre::Vector3 chestPos =
boneWorld.getTrans() +
Ogre::Vector3(0.0f, cc.chestOffsetY,
0.0f);
Ogre::Matrix3 chestRotMat =
boneWorld.linear().orthonormalised();
Ogre::Quaternion chestRot =
Ogre::Quaternion(chestRotMat);
m_physics->setPositionAndRotation(
cc.chestColliderBody, chestPos,
chestRot, true);
}
});
}
@@ -55,6 +55,9 @@ private:
JPH::ShapeRefC buildCharacterShape(flecs::entity e,
CharacterComponent &cc);
JPH::ShapeRefC createColliderShape(PhysicsColliderComponent &collider);
void createHeadCollider(flecs::entity e, CharacterComponent &cc,
const TransformComponent &transform);
void createChestCollider(flecs::entity e, CharacterComponent &cc);
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
@@ -0,0 +1,125 @@
#include "ContainerStateRegistry.hpp"
#include <OgreLogManager.h>
#include <fstream>
ContainerStateRegistry &ContainerStateRegistry::getInstance()
{
static ContainerStateRegistry instance;
return instance;
}
ContainerStateRegistry::ContainerStateRegistry()
{
m_autoSavePath = "container_state.json";
}
void ContainerStateRegistry::loadState(const std::string &containerId,
const std::vector<ContainerSlot> &slots)
{
ContainerState &state = m_states[containerId];
state.containerId = containerId;
state.slots = slots;
autoSave();
}
std::vector<ContainerStateRegistry::ContainerSlot>
ContainerStateRegistry::getState(const std::string &containerId) const
{
auto it = m_states.find(containerId);
if (it != m_states.end())
return it->second.slots;
return {};
}
bool ContainerStateRegistry::hasState(const std::string &containerId) const
{
return m_states.find(containerId) != m_states.end();
}
void ContainerStateRegistry::clearState(const std::string &containerId)
{
m_states.erase(containerId);
autoSave();
}
nlohmann::json ContainerStateRegistry::serialize() const
{
nlohmann::json j;
j["version"] = "1.0";
for (const auto &pair : m_states) {
const ContainerState &state = pair.second;
nlohmann::json stateJson;
stateJson["containerId"] = state.containerId;
nlohmann::json slotsJson = nlohmann::json::array();
for (const auto &slot : state.slots) {
nlohmann::json slotJson;
slotJson["itemId"] = slot.itemId;
slotJson["stackSize"] = slot.stackSize;
slotsJson.push_back(slotJson);
}
stateJson["slots"] = slotsJson;
j["states"].push_back(stateJson);
}
return j;
}
void ContainerStateRegistry::deserialize(const nlohmann::json &j)
{
m_states.clear();
if (!j.contains("states"))
return;
for (const auto &stateJson : j["states"]) {
ContainerState state;
state.containerId = stateJson.value("containerId", "");
if (stateJson.contains("slots") && stateJson["slots"].is_array()) {
for (const auto &slotJson : stateJson["slots"]) {
ContainerSlot slot;
slot.itemId = slotJson.value("itemId", "");
slot.stackSize = slotJson.value("stackSize", 0);
state.slots.push_back(slot);
}
}
if (!state.containerId.empty())
m_states[state.containerId] = state;
}
}
bool ContainerStateRegistry::saveToFile(const std::string &filepath)
{
try {
std::ofstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
file << serialize().dump(4);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save error: ") + e.what();
return false;
}
}
bool ContainerStateRegistry::loadFromFile(const std::string &filepath)
{
try {
std::ifstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
nlohmann::json j;
file >> j;
deserialize(j);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Load error: ") + e.what();
return false;
}
}
void ContainerStateRegistry::autoSave()
{
if (!m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
@@ -0,0 +1,61 @@
#ifndef EDITSCENE_CONTAINERSTATEREGISTRY_HPP
#define EDITSCENE_CONTAINERSTATEREGISTRY_HPP
#pragma once
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
#include <vector>
/**
* Global container state registry.
*
* Stores runtime container contents keyed by containerId.
* Used to override scene initial contents for persistent containers.
* Saves to container_state.json.
*/
class ContainerStateRegistry {
public:
static ContainerStateRegistry &getInstance();
struct ContainerSlot {
std::string itemId;
int stackSize = 0;
};
struct ContainerState {
std::string containerId;
std::vector<ContainerSlot> slots;
};
void loadState(const std::string &containerId,
const std::vector<ContainerSlot> &slots);
std::vector<ContainerSlot> getState(const std::string &containerId) const;
bool hasState(const std::string &containerId) const;
void clearState(const std::string &containerId);
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
void autoSave();
private:
ContainerStateRegistry();
~ContainerStateRegistry() = default;
ContainerStateRegistry(const ContainerStateRegistry &) = delete;
ContainerStateRegistry &operator=(const ContainerStateRegistry &) = delete;
std::unordered_map<std::string, ContainerState> m_states;
mutable std::string m_lastError;
std::string m_autoSavePath;
};
#endif // EDITSCENE_CONTAINERSTATEREGISTRY_HPP
+261 -117
View File
@@ -1,190 +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 comma-separated
// string
std::vector<Ogre::String> choices;
Ogre::String choicesStr =
params.getString("choices");
if (!choicesStr.empty()) {
Ogre::String::size_type start = 0;
Ogre::String::size_type end;
while ((end = choicesStr.find(",",
start)) !=
Ogre::String::npos) {
choices.push_back(
choicesStr.substr(
start,
end - start));
start = end + 1;
}
if (start < choicesStr.length())
choices.push_back(
choicesStr.substr(
start));
}
Ogre::String speaker = params.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();
@@ -194,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();
@@ -202,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,6 +1,8 @@
#include "../components/GeneratedPhysicsTag.hpp"
#include "EditorUISystem.hpp"
#include "DialogueSystem.hpp"
#include "PrefabSystem.hpp"
#include "ItemRegistry.hpp"
#include "../camera/EditorCamera.hpp"
#include "../components/EntityName.hpp"
#include "../components/Transform.hpp"
@@ -24,6 +26,7 @@
#include "../components/TriangleBuffer.hpp"
#include "../components/LodSettings.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/CharacterIdentity.hpp"
#include "../components/Character.hpp"
#include "../components/AnimationTree.hpp"
#include "../components/AnimationTreeTemplate.hpp"
@@ -44,12 +47,14 @@
#include "../components/PrefabInstance.hpp"
#include "../components/Item.hpp"
#include "../components/Inventory.hpp"
#include "CharacterClassSystem.hpp"
#include "../ui/TransformEditor.hpp"
#include "../ui/RenderableEditor.hpp"
#include "../ui/PhysicsColliderEditor.hpp"
#include "../ui/RigidBodyEditor.hpp"
#include "../ui/ComponentRegistration.hpp"
#include "../ui/CharacterIdentityEditor.hpp"
#include "../ui/PrefabInstanceEditor.hpp"
#include "PhysicsSystem.hpp"
#include "BuoyancySystem.hpp"
@@ -59,6 +64,8 @@
#include <algorithm>
#include <filesystem>
#include <cstring>
#include <sstream>
#include <string>
EditorUISystem::EditorUISystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
@@ -73,6 +80,11 @@ EditorUISystem::EditorUISystem(flecs::world &world,
m_gizmo = std::make_unique<Gizmo>(m_sceneMgr);
m_cursor3D = std::make_unique<Cursor3D>(m_sceneMgr);
m_serializer = std::make_unique<SceneSerializer>(m_world, m_sceneMgr);
m_characterRegistry.setWorld(&m_world);
m_characterRegistry.setSceneManager(m_sceneMgr);
m_characterRegistry.setEditorUISystem(this);
m_characterRegistry.initialize();
}
EditorUISystem::~EditorUISystem() = default;
@@ -284,6 +296,22 @@ void EditorUISystem::registerComponentEditors()
}
});
// Register CharacterIdentity component
auto characterIdentityEditor =
std::make_unique<CharacterIdentityEditor>();
m_componentRegistry.registerComponent<CharacterIdentityComponent>(
"Character Identity", "Character",
std::move(characterIdentityEditor),
[](flecs::entity e) {
if (!e.has<CharacterIdentityComponent>())
e.set<CharacterIdentityComponent>(
CharacterIdentityComponent{});
},
[](flecs::entity e) {
if (e.has<CharacterIdentityComponent>())
e.remove<CharacterIdentityComponent>();
});
// Register modular components (Light, Camera, etc.)
registerModularComponents();
}
@@ -332,6 +360,32 @@ 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 Character registry window
if (m_showCharacterRegistry) {
m_characterRegistry.drawEditor(&m_showCharacterRegistry);
}
// Render Item registry window
if (m_showItemRegistry) {
ItemRegistry::getSingleton().drawEditor(&m_showItemRegistry);
}
// Render Inventory Dialog Config window
if (m_showInventoryConfig) {
renderInventoryConfigWindow();
}
// Render FPS overlay
renderFPSOverlay(deltaTime);
}
@@ -420,6 +474,26 @@ 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;
}
if (ImGui::MenuItem("Character Registry")) {
m_showCharacterRegistry = true;
}
if (ImGui::MenuItem("Item Registry")) {
m_showItemRegistry = true;
}
ImGui::Separator();
if (ImGui::MenuItem(
"Inventory Dialog Config")) {
m_showInventoryConfig = true;
}
ImGui::EndMenu();
}
@@ -561,6 +635,64 @@ void EditorUISystem::renderHierarchyWindow()
ImGui::End();
}
void EditorUISystem::renderInventoryConfigWindow()
{
ImGui::SetNextWindowPos(ImVec2(LEFT_PANEL_WIDTH, 100),
ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(350, 200), ImGuiCond_FirstUseEver);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse;
if (!ImGui::Begin("Inventory Dialog Config", &m_showInventoryConfig,
flags)) {
ImGui::End();
return;
}
ImGui::TextWrapped(
"Configure the character sheet / inventory dialog font.");
ImGui::TextWrapped("Settings are persisted to inventory_config.json.");
ImGui::Separator();
// Local copy for editing, loaded from file
static InventoryDialogConfig editConfig;
static bool initialized = false;
if (!initialized) {
editConfig.loadFromFile("inventory_config.json");
initialized = true;
}
char fontPathBuf[256];
std::strncpy(fontPathBuf, editConfig.fontPath.c_str(),
sizeof(fontPathBuf) - 1);
fontPathBuf[sizeof(fontPathBuf) - 1] = '\0';
if (ImGui::InputText("Font Path", fontPathBuf, sizeof(fontPathBuf))) {
editConfig.fontPath = fontPathBuf;
}
if (ImGui::DragFloat("Font Size", &editConfig.fontSize, 0.5f, 8.0f,
72.0f)) {
}
ImGui::Separator();
if (ImGui::Button("Save to inventory_config.json")) {
if (editConfig.saveToFile("inventory_config.json")) {
Ogre::LogManager::getSingleton().logMessage(
"Inventory dialog config saved.");
}
}
ImGui::SameLine();
if (ImGui::Button("Load from inventory_config.json")) {
if (editConfig.loadFromFile("inventory_config.json")) {
Ogre::LogManager::getSingleton().logMessage(
"Inventory dialog config loaded.");
}
}
ImGui::End();
}
void EditorUISystem::renderEntityNode(flecs::entity entity, int depth)
{
if (!entity.is_alive())
@@ -940,6 +1072,14 @@ void EditorUISystem::renderComponentList(flecs::entity entity)
componentCount++;
}
// Render CharacterIdentity if present
if (entity.has<CharacterIdentityComponent>()) {
auto &ci = entity.get_mut<CharacterIdentityComponent>();
m_componentRegistry.render<CharacterIdentityComponent>(entity,
ci);
componentCount++;
}
// Render AnimationTree if present
if (entity.has<AnimationTreeComponent>()) {
auto &at = entity.get_mut<AnimationTreeComponent>();
@@ -2071,3 +2211,120 @@ 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,10 +8,12 @@
#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"
#include "SceneSerializer.hpp"
#include "CharacterRegistry.hpp"
// Forward declarations
class EditorPhysicsSystem;
@@ -70,6 +72,11 @@ public:
setSelectedEntity(flecs::entity::null());
}
/**
* Delete entity and all its children, cleaning up OGRE objects
*/
void deleteEntity(flecs::entity entity);
/**
* Get the currently selected entity
*/
@@ -191,6 +198,8 @@ public:
void showCreatePrefabDialog(flecs::entity entity);
void renderPrefabBrowser();
void renderCursorPanel();
void renderDialogueSettingsWindow();
void renderInventoryConfigWindow();
private:
// File menu
@@ -204,7 +213,6 @@ private:
void renderEntityContextMenu(flecs::entity entity);
void createNewEntity();
void createChildEntity(flecs::entity parent);
void deleteEntity(flecs::entity entity);
void duplicateEntity(flecs::entity entity);
// Component operations
@@ -302,6 +310,23 @@ private:
bool m_showActionDatabaseSingleton = false;
ActionDatabaseSingletonEditor m_actionDatabaseSingletonEditor;
// Dialogue settings editor state
bool m_showDialogueSettings = false;
// Inventory config editor state
bool m_showInventoryConfig = false;
// Character class database editor state
bool m_showCharacterClassDatabase = false;
CharacterClassDatabaseEditor m_characterClassDatabaseEditor;
// Character registry
bool m_showCharacterRegistry = false;
CharacterRegistry m_characterRegistry;
// Item registry
bool m_showItemRegistry = false;
// Queries
flecs::query<EntityNameComponent> m_nameQuery;
@@ -0,0 +1,587 @@
#include "HairPhysicsSystem.hpp"
#include "CharacterSlotSystem.hpp"
#include "../components/CharacterSlots.hpp"
#include "../components/Character.hpp"
#include <OgreEntity.h>
#include <OgreLogManager.h>
#include <OgreSceneNode.h>
#include <OgreTagPoint.h>
#include <queue>
namespace {
/**
* Find the master bone the hair is attached to.
* Prefer the TagPoint's parent bone; fall back to the named head bone.
*/
Ogre::Bone *findAttachBone(Ogre::Entity *hairEnt,
Ogre::Entity *masterEnt)
{
Ogre::TagPoint *tagPoint = dynamic_cast<Ogre::TagPoint *>(
hairEnt->getParentNode());
if (tagPoint) {
Ogre::Bone *parentBone = dynamic_cast<Ogre::Bone *>(
tagPoint->getParent());
if (parentBone)
return parentBone;
}
if (masterEnt && masterEnt->hasSkeleton()) {
Ogre::SkeletonInstance *skel = masterEnt->getSkeleton();
if (skel) {
try {
return skel->getBone("mixamorig:Head");
} catch (...) {
}
}
}
return nullptr;
}
/**
* World transform of the attachment bone, including the master entity's
* scene-node transform.
*/
Ogre::Matrix4 getAttachBoneWorldMatrix(Ogre::Entity *masterEnt,
Ogre::Bone *attachBone)
{
if (!attachBone)
return Ogre::Matrix4::IDENTITY;
Ogre::Matrix4 boneWorld = attachBone->_getFullTransform();
if (masterEnt) {
Ogre::SceneNode *node = masterEnt->getParentSceneNode();
if (node)
boneWorld = node->_getFullTransform() * boneWorld;
}
return boneWorld;
}
} // namespace
#include <Jolt/Physics/Ragdoll/Ragdoll.h>
#include <Jolt/Physics/Collision/Shape/CapsuleShape.h>
#include <Jolt/Physics/Constraints/SwingTwistConstraint.h>
#include <Jolt/Skeleton/SkeletonPose.h>
uint32_t HairPhysicsSystem::s_nextCollisionGroup = 1;
HairPhysicsSystem::HairPhysicsSystem(flecs::world &world,
Ogre::SceneManager *sceneMgr,
JoltPhysicsWrapper *physics,
CharacterSlotSystem *slotSystem)
: m_world(world)
, m_sceneMgr(sceneMgr)
, m_physics(physics)
, m_slotSystem(slotSystem)
{
}
HairPhysicsSystem::~HairPhysicsSystem()
{
for (auto &entityPair : m_states) {
for (auto &slotPair : entityPair.second) {
destroyRagdoll(slotPair.second);
}
}
m_states.clear();
}
void HairPhysicsSystem::initialize()
{
m_initialized = true;
}
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;
for (const auto &pair : cs.slotSelections) {
const Ogre::String &slot = pair.first;
Ogre::Entity *partEnt =
m_slotSystem->getSlotEntity(e, slot);
if (!partEnt || !partEnt->hasSkeleton())
continue;
auto &slotMap = m_states[e.id()];
auto it = slotMap.find(slot);
if (it == slotMap.end() ||
it->second.hairEntity != partEnt) {
/* Need to create or recreate ragdoll */
if (it != slotMap.end())
destroyRagdoll(it->second);
createRagdoll(e, slot, partEnt,
masterEnt);
it = slotMap.find(slot);
}
if (it != slotMap.end())
syncRootToHead(it->second);
}
/* Clean up ragdolls for slots that no longer have
* skeleton parts. */
auto &slotMap = m_states[e.id()];
std::vector<Ogre::String> toRemove;
for (auto &pair : slotMap) {
Ogre::Entity *partEnt =
m_slotSystem->getSlotEntity(e,
pair.first);
if (!partEnt || !partEnt->hasSkeleton()) {
toRemove.push_back(pair.first);
continue;
}
}
for (const auto &slot : toRemove) {
destroyRagdoll(slotMap[slot]);
slotMap.erase(slot);
}
});
}
void HairPhysicsSystem::postPhysicsUpdate()
{
if (!m_initialized || !m_physics)
return;
for (auto &entityPair : m_states) {
for (auto &slotPair : entityPair.second) {
syncPhysicsToSkeleton(slotPair.second);
}
}
}
void HairPhysicsSystem::createRagdoll(flecs::entity e,
const Ogre::String &slot,
Ogre::Entity *hairEnt,
Ogre::Entity *masterEnt)
{
Ogre::SkeletonInstance *skel = hairEnt->getSkeleton();
if (!skel)
return;
/* Build ordered bone list via BFS so parents always precede children. */
std::vector<Ogre::Bone *> orderedBones;
std::unordered_map<Ogre::Bone *, size_t> boneToIndex;
std::queue<Ogre::Bone *> bfsQueue;
for (unsigned short i = 0; i < skel->getNumBones(); ++i) {
Ogre::Bone *b = skel->getBone(i);
if (!b->getParent()) {
bfsQueue.push(b);
}
}
while (!bfsQueue.empty()) {
Ogre::Bone *b = bfsQueue.front();
bfsQueue.pop();
boneToIndex[b] = orderedBones.size();
orderedBones.push_back(b);
for (unsigned short i = 0; i < b->numChildren(); ++i) {
Ogre::Bone *child = static_cast<Ogre::Bone *>(b->getChild(i));
if (child)
bfsQueue.push(child);
}
}
if (orderedBones.empty())
return;
/* Sanity check: if the skeleton has too many bones, it's probably
* a shared body skeleton rather than a hair-specific skeleton.
* Skip ragdoll creation to avoid physics cache overflow. */
constexpr size_t maxHairBones = 32;
if (orderedBones.size() > maxHairBones)
return;
/* Build JPH skeleton and bind-world matrices. */
JPH::Ref<JPH::Skeleton> skeleton = new JPH::Skeleton;
std::vector<JPH::Mat44> bindWorldMatrices;
bindWorldMatrices.resize(orderedBones.size());
for (size_t i = 0; i < orderedBones.size(); ++i) {
Ogre::Bone *bone = orderedBones[i];
Ogre::Bone *parent = static_cast<Ogre::Bone *>(bone->getParent());
int parentIndex = -1;
if (parent) {
auto it = boneToIndex.find(parent);
if (it != boneToIndex.end())
parentIndex = (int)it->second;
}
skeleton->AddJoint(bone->getName().c_str(), parentIndex);
/* Build bind-world matrix from initial pose. */
Ogre::Matrix4 localMat = Ogre::Matrix4::IDENTITY;
localMat.makeTransform(
bone->getInitialPosition(),
bone->getInitialScale(),
bone->getInitialOrientation());
if (parent) {
bindWorldMatrices[i] =
bindWorldMatrices[boneToIndex[parent]] *
ogreMatrixToJolt(localMat);
} else {
bindWorldMatrices[i] = ogreMatrixToJolt(localMat);
}
}
/* World transform of the attachment bone (head) on the master entity. */
Ogre::Bone *attachBone = findAttachBone(hairEnt, masterEnt);
if (!attachBone) {
Ogre::LogManager::getSingleton().logMessage(
"HairPhysicsSystem: cannot find attachment bone for " +
slot + ", skipping ragdoll");
return;
}
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(masterEnt,
attachBone);
JPH::Mat44 attachBoneJolt = ogreMatrixToJolt(attachBoneMat);
/* Build ragdoll settings. */
JPH::Ref<JPH::RagdollSettings> settings = new JPH::RagdollSettings;
settings->mSkeleton = skeleton;
settings->mParts.resize(orderedBones.size());
std::vector<int> parentIndices;
parentIndices.resize(orderedBones.size(), -1);
for (size_t i = 0; i < orderedBones.size(); ++i) {
Ogre::Bone *bone = orderedBones[i];
JPH::RagdollSettings::Part &part = settings->mParts[i];
/* Capsule size heuristic from average child distance. */
float halfHeight = 0.05f;
float radius = 0.02f;
if (bone->numChildren() > 0) {
float avgLen = 0.0f;
int count = 0;
for (unsigned short ci = 0; ci < bone->numChildren();
++ci) {
Ogre::Bone *child = static_cast<Ogre::Bone *>(
bone->getChild(ci));
if (!child)
continue;
Ogre::Vector3 diff =
child->getInitialPosition();
avgLen += diff.length();
++count;
}
if (count > 0) {
avgLen /= count;
halfHeight = avgLen * 0.5f;
radius = avgLen * 0.15f;
}
}
/* Clamp to reasonable bounds. */
if (halfHeight < 0.01f)
halfHeight = 0.01f;
if (radius < 0.005f)
radius = 0.005f;
if (radius > halfHeight * 0.9f)
radius = halfHeight * 0.9f;
part.SetShape(new JPH::CapsuleShape(halfHeight, radius));
/* World-space bind position. */
JPH::Mat44 worldMat = attachBoneJolt * bindWorldMatrices[i];
part.mPosition = JPH::RVec3(worldMat.GetTranslation());
part.mRotation = worldMat.GetRotationSafe().GetQuaternion().Normalized();
part.mMotionType = (i == 0) ?
JPH::EMotionType::Kinematic :
JPH::EMotionType::Dynamic;
part.mLinearDamping = 0.5f;
part.mAngularDamping = 0.5f;
part.mGravityFactor = 0.5f;
part.mMaxLinearVelocity = 5.0f;
part.mMaxAngularVelocity = JPH::DegreesToRadians(720.0f);
Ogre::Bone *parent = static_cast<Ogre::Bone *>(bone->getParent());
if (parent) {
auto it = boneToIndex.find(parent);
if (it != boneToIndex.end())
parentIndices[i] = (int)it->second;
}
if (i > 0 && parentIndices[i] >= 0) {
JPH::SwingTwistConstraintSettings *constraint =
new JPH::SwingTwistConstraintSettings;
constraint->mSpace = JPH::EConstraintSpace::LocalToBodyCOM;
/* Position in parent local space (bone's initial pos). */
constraint->mPosition1 = JPH::RVec3(
JoltPhysics::convert(
bone->getInitialPosition()));
constraint->mPosition2 = JPH::RVec3::sZero();
constraint->mTwistAxis2 = JPH::Vec3::sAxisY();
JPH::Quat childLocalRot = JoltPhysics::convert(
bone->getInitialOrientation());
constraint->mTwistAxis1 = childLocalRot *
constraint->mTwistAxis2;
JPH::Vec3 planeAxis1 = childLocalRot *
JPH::Vec3::sAxisX();
constraint->mPlaneAxis1 = planeAxis1;
constraint->mPlaneAxis2 = JPH::Vec3::sAxisX();
constraint->mTwistMinAngle = -JPH::DegreesToRadians(
45.0f);
constraint->mTwistMaxAngle = JPH::DegreesToRadians(
45.0f);
constraint->mNormalHalfConeAngle =
JPH::DegreesToRadians(30.0f);
constraint->mPlaneHalfConeAngle =
JPH::DegreesToRadians(30.0f);
part.mToParent = constraint;
}
}
/* Collision group setup.
* Subgroup 0 = character capsule, 1 = head sphere, 2 = chest sphere,
* 3+ = hair joints. */
uint32_t collisionGroupId;
constexpr uint32_t subgroupHairStart = 3;
JPH::GroupFilterTable *groupFilter = nullptr;
if (e.has<CharacterComponent>()) {
auto &cc = e.get_mut<CharacterComponent>();
if (cc.collisionGroupId == 0)
cc.collisionGroupId =
static_cast<uint32_t>(e.id()) |
0x80000000;
collisionGroupId = cc.collisionGroupId;
groupFilter = m_physics->getOrCreateGroupFilter(
collisionGroupId);
/* Disable body (subgroup 0), head (1) and chest (2) vs every
* hair joint. These kinematic spheres sit at the attachment
* points; overlap with the hair chain creates an explosive
* feedback loop that throws the joints across the map. */
for (size_t i = 0; i < orderedBones.size(); ++i) {
uint32_t hairSub = subgroupHairStart + (uint32_t)i;
groupFilter->DisableCollision(0, hairSub);
groupFilter->DisableCollision(1, hairSub);
groupFilter->DisableCollision(2, hairSub);
}
/* Disable all hair-joint vs hair-joint collisions. Siblings
* and parents/children start close together and can otherwise
* generate explosive contact impulses. */
for (size_t i = 0; i < orderedBones.size(); ++i) {
for (size_t j = i + 1; j < orderedBones.size(); ++j) {
groupFilter->DisableCollision(
subgroupHairStart + (uint32_t)i,
subgroupHairStart + (uint32_t)j);
}
}
} else {
collisionGroupId = s_nextCollisionGroup++;
}
for (size_t i = 0; i < orderedBones.size(); ++i) {
settings->mParts[i].mObjectLayer = Layers::HAIR;
if (groupFilter) {
settings->mParts[i].mCollisionGroup.SetGroupFilter(
groupFilter);
settings->mParts[i].mCollisionGroup.SetGroupID(
collisionGroupId);
settings->mParts[i].mCollisionGroup.SetSubGroupID(
subgroupHairStart + (uint32_t)i);
}
}
if (!e.has<CharacterComponent>())
settings->DisableParentChildCollisions();
settings->Stabilize();
settings->CalculateConstraintPriorities();
settings->CalculateBodyIndexToConstraintIndex();
JPH::PhysicsSystem *physSystem = m_physics->getPhysicsSystem();
JPH::Ref<JPH::Ragdoll> ragdoll = settings->CreateRagdoll(
collisionGroupId, 0, physSystem);
/* Store skeleton bones for later pose sync. */
HairRagdollState state;
state.ragdoll = ragdoll;
state.entityId = e.id();
state.hairEntity = hairEnt;
state.masterEntity = masterEnt;
state.slotName = slot;
{
Ogre::Bone *rootBone = orderedBones[0];
Ogre::Matrix4 rootLocal = Ogre::Matrix4::IDENTITY;
rootLocal.makeTransform(rootBone->getInitialPosition(),
rootBone->getInitialScale(),
rootBone->getInitialOrientation());
state.rootBindTransform = rootLocal;
}
state.boneNames.reserve(orderedBones.size());
for (Ogre::Bone *bone : orderedBones)
state.boneNames.push_back(bone->getName());
state.parentIndices = parentIndices;
/* Ensure all bones are manually controlled. */
for (Ogre::Bone *bone : orderedBones) {
bone->setManuallyControlled(true);
}
ragdoll->AddToPhysicsSystem(JPH::EActivation::Activate);
Ogre::LogManager::getSingleton().logMessage(
"HairPhysicsSystem: created ragdoll for " + slot +
" ('" + hairEnt->getMesh()->getName() + "', " +
std::to_string(orderedBones.size()) + " bones, " +
std::to_string(ragdoll->GetConstraintCount()) +
" constraints)");
m_states[e.id()][slot] = std::move(state);
}
void HairPhysicsSystem::destroyRagdoll(HairRagdollState &state)
{
if (state.ragdoll) {
state.ragdoll->RemoveFromPhysicsSystem();
state.ragdoll = nullptr;
}
state.boneNames.clear();
state.parentIndices.clear();
}
bool HairPhysicsSystem::isStateValid(const HairRagdollState &state) const
{
if (!state.hairEntity || state.entityId == 0 || !m_slotSystem)
return false;
flecs::entity e = m_world.entity(state.entityId);
if (!e.is_alive() || !e.has<CharacterSlotsComponent>())
return false;
Ogre::Entity *current = m_slotSystem->getSlotEntity(e, state.slotName);
return current == state.hairEntity && current->hasSkeleton();
}
void HairPhysicsSystem::syncRootToHead(HairRagdollState &state)
{
if (!state.ragdoll || !isStateValid(state))
return;
flecs::entity e = m_world.entity(state.entityId);
const CharacterSlotsComponent &cs = e.get<CharacterSlotsComponent>();
Ogre::Entity *masterEnt = cs.masterEntity;
if (!masterEnt)
return;
Ogre::Bone *attachBone = findAttachBone(state.hairEntity, masterEnt);
if (!attachBone)
return;
/* The root body was created at attachBone * rootBindTransform, so we
* must keep it there each frame. */
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(masterEnt,
attachBone);
Ogre::Matrix4 rootWorldMat = attachBoneMat * state.rootBindTransform;
Ogre::Vector3 pos = rootWorldMat.getTrans();
Ogre::Quaternion rot(rootWorldMat.linear());
rot.normalise();
JPH::BodyID rootBody = state.ragdoll->GetBodyID(0);
m_physics->setPositionAndRotation(rootBody, pos, rot, true);
}
void HairPhysicsSystem::syncPhysicsToSkeleton(HairRagdollState &state)
{
if (!state.ragdoll || state.boneNames.empty() ||
!isStateValid(state))
return;
Ogre::SkeletonInstance *skel = state.hairEntity->getSkeleton();
if (!skel)
return;
JPH::SkeletonPose pose;
pose.SetSkeleton(
state.ragdoll->GetRagdollSettings()->GetSkeleton());
state.ragdoll->GetPose(pose);
const JPH::SkeletonPose::Mat44Vector &jointMatrices =
pose.GetJointMatrices();
JPH::RVec3 rootOffset = pose.GetRootOffset();
Ogre::Matrix4 rootOffsetMat = Ogre::Matrix4::IDENTITY;
rootOffsetMat.makeTransform(JoltPhysics::convert(rootOffset),
Ogre::Vector3::UNIT_SCALE,
Ogre::Quaternion::IDENTITY);
/* 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::Bone *attachBone = findAttachBone(state.hairEntity, masterEnt);
Ogre::Matrix4 attachBoneInv = Ogre::Matrix4::IDENTITY;
if (attachBone) {
Ogre::Matrix4 attachBoneMat = getAttachBoneWorldMatrix(
masterEnt, attachBone);
attachBoneInv = attachBoneMat.inverse();
}
std::vector<Ogre::Matrix4> worldMats(state.boneNames.size());
for (size_t i = 0; i < state.boneNames.size(); ++i)
worldMats[i] = rootOffsetMat * joltMatrixToOgre(jointMatrices[i]);
for (size_t i = 0; i < state.boneNames.size(); ++i) {
Ogre::Bone *bone = skel->getBone(state.boneNames[i]);
if (!bone)
continue;
Ogre::Matrix4 localMat;
if (state.parentIndices[i] >= 0) {
/* Joint transforms from Jolt are in skeleton world space;
* Ogre bones expect parent-local space. */
localMat = worldMats[state.parentIndices[i]].inverse() *
worldMats[i];
} else {
/* Root bone is in hair-entity local space. */
localMat = attachBoneInv * worldMats[i];
}
Ogre::Vector3 pos, scl(Ogre::Vector3::UNIT_SCALE);
Ogre::Quaternion rot;
pos = localMat.getTrans();
rot = Ogre::Quaternion(localMat.linear());
rot.normalise();
/* Extract scale from basis vectors. */
scl.x = Ogre::Vector3(localMat[0][0], localMat[0][1],
localMat[0][2]).length();
scl.y = Ogre::Vector3(localMat[1][0], localMat[1][1],
localMat[1][2]).length();
scl.z = Ogre::Vector3(localMat[2][0], localMat[2][1],
localMat[2][2]).length();
bone->setManuallyControlled(true);
bone->setPosition(pos);
bone->setOrientation(rot);
bone->setScale(scl);
}
}
JPH::Mat44 HairPhysicsSystem::ogreMatrixToJolt(
const Ogre::Matrix4 &m) const
{
JPH::Mat44 jm;
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
jm(row, col) = m[row][col];
}
}
return jm;
}
Ogre::Matrix4 HairPhysicsSystem::joltMatrixToOgre(
const JPH::Mat44 &m) const
{
Ogre::Matrix4 om;
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
om[row][col] = m(row, col);
}
}
return om;
}
@@ -0,0 +1,75 @@
#ifndef EDITSCENE_HAIRPHYSICSSYSTEM_HPP
#define EDITSCENE_HAIRPHYSICSSYSTEM_HPP
#pragma once
#include <flecs.h>
#include <Ogre.h>
#include <unordered_map>
#include <vector>
#include "../physics/physics.h"
#include <Jolt/Physics/Ragdoll/Ragdoll.h>
#include <Jolt/Skeleton/Skeleton.h>
class CharacterSlotSystem;
/**
* System that creates and updates Jolt Physics ragdolls for hair parts
* with their own skeleton. Replaces the previous animation-tree-based
* hair animation.
*/
class HairPhysicsSystem {
public:
HairPhysicsSystem(flecs::world &world, Ogre::SceneManager *sceneMgr,
JoltPhysicsWrapper *physics,
CharacterSlotSystem *slotSystem);
~HairPhysicsSystem();
void initialize();
/** Call before physics step to sync root bodies to head transforms. */
void prePhysicsUpdate();
/** Call after physics step to read poses back to Ogre skeletons. */
void postPhysicsUpdate();
private:
struct HairRagdollState {
JPH::Ref<JPH::Ragdoll> ragdoll;
flecs::entity_t entityId = 0;
Ogre::Entity *hairEntity = nullptr;
Ogre::Entity *masterEntity = nullptr;
Ogre::String slotName;
std::vector<Ogre::String> boneNames;
std::vector<int> parentIndices;
Ogre::Matrix4 rootBindTransform = Ogre::Matrix4::IDENTITY;
};
void createRagdoll(flecs::entity e, const Ogre::String &slot,
Ogre::Entity *hairEnt, Ogre::Entity *masterEnt);
void destroyRagdoll(HairRagdollState &state);
void syncRootToHead(HairRagdollState &state);
void syncPhysicsToSkeleton(HairRagdollState &state);
bool isStateValid(const HairRagdollState &state) const;
/* Helpers */
JPH::Mat44 ogreMatrixToJolt(const Ogre::Matrix4 &m) const;
Ogre::Matrix4 joltMatrixToOgre(const JPH::Mat44 &m) const;
flecs::world &m_world;
Ogre::SceneManager *m_sceneMgr;
JoltPhysicsWrapper *m_physics;
CharacterSlotSystem *m_slotSystem;
bool m_initialized = false;
/* Per-entity per-slot ragdoll states */
std::unordered_map<
flecs::entity_t,
std::unordered_map<Ogre::String, HairRagdollState> >
m_states;
static uint32_t s_nextCollisionGroup;
};
#endif // EDITSCENE_HAIRPHYSICSSYSTEM_HPP
@@ -0,0 +1,371 @@
#include "ItemRegistry.hpp"
#include <OgreLogManager.h>
#include <fstream>
#include <imgui.h>
#include <algorithm>
/* ===================================================================== */
/* Singleton */
/* ===================================================================== */
ItemRegistry *ItemRegistry::ms_singleton = nullptr;
ItemRegistry &ItemRegistry::getSingleton()
{
OgreAssert(ms_singleton, "ItemRegistry not created");
return *ms_singleton;
}
ItemRegistry *ItemRegistry::getSingletonPtr()
{
return ms_singleton;
}
/* ===================================================================== */
/* Construction / init */
/* ===================================================================== */
ItemRegistry::ItemRegistry()
{
ms_singleton = this;
m_autoSavePath = "items.json";
}
void ItemRegistry::initialize()
{
if (!std::filesystem::exists(m_autoSavePath))
return;
if (!loadFromFile(m_autoSavePath)) {
Ogre::LogManager::getSingleton().logMessage(
"ItemRegistry: auto-load failed: " + m_lastError);
}
}
void ItemRegistry::autoSave()
{
if (!m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
/* ===================================================================== */
/* Definitions */
/* ===================================================================== */
bool ItemRegistry::registerItem(const ItemRegistry::ItemDefinition &def)
{
if (def.itemId.empty()) {
m_lastError = "Cannot register item with empty itemId";
return false;
}
m_definitions[def.itemId] = def;
autoSave();
return true;
}
bool ItemRegistry::removeItem(const std::string &itemId)
{
auto it = m_definitions.find(itemId);
if (it == m_definitions.end()) {
m_lastError = "Item not found: " + itemId;
return false;
}
m_definitions.erase(it);
autoSave();
return true;
}
ItemRegistry::ItemDefinition *ItemRegistry::findDefinition(const std::string &itemId)
{
auto it = m_definitions.find(itemId);
if (it != m_definitions.end())
return &it->second;
return nullptr;
}
const ItemRegistry::ItemDefinition *ItemRegistry::findDefinition(const std::string &itemId) const
{
auto it = m_definitions.find(itemId);
if (it != m_definitions.end())
return &it->second;
return nullptr;
}
bool ItemRegistry::isUnique(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def && def->unique;
}
std::string ItemRegistry::getItemName(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->itemName : "";
}
std::string ItemRegistry::getItemType(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->itemType : "";
}
int ItemRegistry::getMaxStackSize(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->maxStackSize : 99;
}
float ItemRegistry::getWeight(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->weight : 0.1f;
}
int ItemRegistry::getValue(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->value : 1;
}
std::string ItemRegistry::getUseActionName(const std::string &itemId) const
{
auto *def = findDefinition(itemId);
return def ? def->useActionName : "";
}
/* ===================================================================== */
/* Columns */
/* ===================================================================== */
void ItemRegistry::addColumn(const std::string &name, ColumnDef::Type type)
{
m_columns.push_back({type, name});
}
void ItemRegistry::removeColumn(const std::string &name)
{
m_columns.erase(
std::remove_if(m_columns.begin(), m_columns.end(),
[&name](const ColumnDef &c) {
return c.name == name;
}),
m_columns.end());
}
/* ===================================================================== */
/* Persistence */
/* ===================================================================== */
nlohmann::json ItemRegistry::serialize() const
{
nlohmann::json j;
j["version"] = "1.0";
for (const auto &c : m_columns) {
nlohmann::json col;
col["name"] = c.name;
col["type"] = (c.type == ColumnDef::Int) ? "int" :
(c.type == ColumnDef::Float) ? "float" :
"string";
j["columns"].push_back(col);
}
for (const auto &pair : m_definitions) {
const ItemRegistry::ItemDefinition &d = pair.second;
nlohmann::json def;
def["itemId"] = d.itemId;
def["itemName"] = d.itemName;
def["itemType"] = d.itemType;
def["maxStackSize"] = d.maxStackSize;
def["weight"] = d.weight;
def["value"] = d.value;
def["useActionName"] = d.useActionName;
def["unique"] = d.unique;
for (const auto &kv : d.intColumns)
def["intColumns"][kv.first] = kv.second;
for (const auto &kv : d.floatColumns)
def["floatColumns"][kv.first] = kv.second;
for (const auto &kv : d.stringColumns)
def["stringColumns"][kv.first] = kv.second;
j["definitions"].push_back(def);
}
return j;
}
void ItemRegistry::deserialize(const nlohmann::json &j)
{
m_definitions.clear();
m_columns.clear();
if (j.contains("columns")) {
for (const auto &col : j["columns"]) {
ColumnDef::Type t = ColumnDef::String;
std::string ts = col.value("type", "string");
if (ts == "int")
t = ColumnDef::Int;
else if (ts == "float")
t = ColumnDef::Float;
m_columns.push_back({t, col.value("name", "")});
}
}
if (j.contains("definitions")) {
for (const auto &defJson : j["definitions"]) {
ItemRegistry::ItemDefinition d;
d.itemId = defJson.value("itemId", "");
d.itemName = defJson.value("itemName", "");
d.itemType = defJson.value("itemType", "misc");
d.maxStackSize = defJson.value("maxStackSize", 99);
d.weight = defJson.value("weight", 0.1f);
d.value = defJson.value("value", 1);
d.useActionName = defJson.value("useActionName", "");
d.unique = defJson.value("unique", false);
if (defJson.contains("intColumns")) {
for (auto &[key, val] : defJson["intColumns"].items())
d.intColumns[key] = val.get<int64_t>();
}
if (defJson.contains("floatColumns")) {
for (auto &[key, val] : defJson["floatColumns"].items())
d.floatColumns[key] = val.get<double>();
}
if (defJson.contains("stringColumns")) {
for (auto &[key, val] : defJson["stringColumns"].items())
d.stringColumns[key] = val.get<std::string>();
}
if (!d.itemId.empty())
m_definitions[d.itemId] = d;
}
}
}
bool ItemRegistry::saveToFile(const std::string &filepath)
{
try {
std::ofstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
file << serialize().dump(4);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save error: ") + e.what();
return false;
}
}
bool ItemRegistry::loadFromFile(const std::string &filepath)
{
try {
std::ifstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
nlohmann::json j;
file >> j;
deserialize(j);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Load error: ") + e.what();
return false;
}
}
/* ===================================================================== */
/* ImGui editor */
/* ===================================================================== */
void ItemRegistry::drawEditor(bool *p_open)
{
if (p_open && !*p_open)
return;
ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("Item Registry", p_open))
return ImGui::End();
// Left pane: list
ImGui::BeginChild("ItemList", ImVec2(250, 0), true);
if (ImGui::Button("Add Item")) {
std::string newId = m_newItemIdBuf;
if (!newId.empty() && m_definitions.find(newId) == m_definitions.end()) {
ItemRegistry::ItemDefinition d;
d.itemId = newId;
d.itemName = m_newItemNameBuf[0] ? m_newItemNameBuf : newId;
registerItem(d);
m_newItemIdBuf[0] = '\0';
m_newItemNameBuf[0] = '\0';
}
}
ImGui::InputText("New ID", m_newItemIdBuf, sizeof(m_newItemIdBuf));
ImGui::InputText("New Name", m_newItemNameBuf, sizeof(m_newItemNameBuf));
ImGui::Separator();
int idx = 0;
for (const auto &pair : m_definitions) {
const ItemRegistry::ItemDefinition &d = pair.second;
bool selected = (idx == m_selectedItemIndex);
if (ImGui::Selectable(
(d.itemId + " - " + d.itemName).c_str(), selected)) {
m_selectedItemIndex = idx;
}
idx++;
}
ImGui::EndChild();
ImGui::SameLine();
// Right pane: edit
ImGui::BeginChild("ItemEdit", ImVec2(0, 0), true);
ItemRegistry::ItemDefinition *selectedDef = nullptr;
idx = 0;
for (auto &pair : m_definitions) {
if (idx == m_selectedItemIndex) {
selectedDef = &pair.second;
break;
}
idx++;
}
if (selectedDef) {
ImGui::Text("Item ID: %s", selectedDef->itemId.c_str());
static char nameBuf[256];
static char typeBuf[256];
static char actionBuf[256];
snprintf(nameBuf, sizeof(nameBuf), "%s",
selectedDef->itemName.c_str());
snprintf(typeBuf, sizeof(typeBuf), "%s",
selectedDef->itemType.c_str());
snprintf(actionBuf, sizeof(actionBuf), "%s",
selectedDef->useActionName.c_str());
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf)))
selectedDef->itemName = nameBuf;
if (ImGui::InputText("Type", typeBuf, sizeof(typeBuf)))
selectedDef->itemType = typeBuf;
if (ImGui::InputInt("Max Stack", &selectedDef->maxStackSize))
selectedDef->maxStackSize = std::max(1, selectedDef->maxStackSize);
ImGui::InputFloat("Weight", &selectedDef->weight, 0.1f);
ImGui::InputInt("Value", &selectedDef->value);
if (ImGui::InputText("Use Action", actionBuf, sizeof(actionBuf)))
selectedDef->useActionName = actionBuf;
if (ImGui::Checkbox("Unique", &selectedDef->unique))
autoSave();
if (ImGui::Button("Save Changes"))
autoSave();
ImGui::SameLine();
if (ImGui::Button("Delete Item")) {
removeItem(selectedDef->itemId);
m_selectedItemIndex = -1;
}
} else {
ImGui::Text("Select an item to edit");
}
ImGui::EndChild();
ImGui::End();
}
@@ -0,0 +1,137 @@
#ifndef EDITSCENE_ITEMREGISTRY_HPP
#define EDITSCENE_ITEMREGISTRY_HPP
#pragma once
#include <Ogre.h>
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
#include <vector>
class EditorApp;
/**
* Global item definition registry.
*
* Stores authoritative item definitions keyed by itemId.
* Auto-saves to items.json after mutations.
* Loaded on initialization.
*/
class ItemRegistry {
public:
static ItemRegistry &getSingleton();
static ItemRegistry *getSingletonPtr();
private:
static ItemRegistry *ms_singleton;
public:
/* ------------------------------------------------------------------ */
/* Column schema */
/* ------------------------------------------------------------------ */
struct ColumnDef {
enum Type { Int, Float, String };
Type type;
std::string name;
};
/* ------------------------------------------------------------------ */
/* Item definition */
/* ------------------------------------------------------------------ */
struct ItemDefinition {
std::string itemId;
std::string itemName;
std::string itemType;
int maxStackSize = 99;
float weight = 0.1f;
int value = 1;
std::string useActionName;
bool unique = false;
/* Extensible custom columns */
std::unordered_map<std::string, int64_t> intColumns;
std::unordered_map<std::string, double> floatColumns;
std::unordered_map<std::string, std::string> stringColumns;
};
/* ------------------------------------------------------------------ */
/* Life-cycle */
/* ------------------------------------------------------------------ */
ItemRegistry();
~ItemRegistry() = default;
ItemRegistry(const ItemRegistry &) = delete;
ItemRegistry &operator=(const ItemRegistry &) = delete;
void initialize();
/* ------------------------------------------------------------------ */
/* Definitions */
/* ------------------------------------------------------------------ */
bool registerItem(const ItemDefinition &def);
bool removeItem(const std::string &itemId);
ItemDefinition *findDefinition(const std::string &itemId);
const ItemDefinition *findDefinition(const std::string &itemId) const;
const std::unordered_map<std::string, ItemDefinition> &getDefinitions() const
{
return m_definitions;
}
bool hasItem(const std::string &itemId) const
{
return m_definitions.find(itemId) != m_definitions.end();
}
bool isUnique(const std::string &itemId) const;
/* Lookup helpers */
std::string getItemName(const std::string &itemId) const;
std::string getItemType(const std::string &itemId) const;
int getMaxStackSize(const std::string &itemId) const;
float getWeight(const std::string &itemId) const;
int getValue(const std::string &itemId) const;
std::string getUseActionName(const std::string &itemId) const;
/* ------------------------------------------------------------------ */
/* Columns */
/* ------------------------------------------------------------------ */
void addColumn(const std::string &name, ColumnDef::Type type);
void removeColumn(const std::string &name);
const std::vector<ColumnDef> &getColumns() const
{
return m_columns;
}
/* ------------------------------------------------------------------ */
/* Persistence */
/* ------------------------------------------------------------------ */
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
void autoSave();
/* ------------------------------------------------------------------ */
/* ImGui editor */
/* ------------------------------------------------------------------ */
void drawEditor(bool *p_open = nullptr);
private:
std::unordered_map<std::string, ItemDefinition> m_definitions;
std::vector<ColumnDef> m_columns;
mutable std::string m_lastError;
std::string m_autoSavePath;
/* UI state */
int m_selectedItemIndex = -1;
char m_newItemIdBuf[128] = {};
char m_newItemNameBuf[128] = {};
};
#endif // EDITSCENE_ITEMREGISTRY_HPP
@@ -0,0 +1,109 @@
#include "ItemStateRegistry.hpp"
#include <OgreLogManager.h>
#include <fstream>
ItemStateRegistry &ItemStateRegistry::getInstance()
{
static ItemStateRegistry instance;
return instance;
}
ItemStateRegistry::ItemStateRegistry()
{
m_autoSavePath = "item_state.json";
}
void ItemStateRegistry::setDisabled(const std::string &instanceId,
bool disabled)
{
if (instanceId.empty())
return;
ItemState &state = m_states[instanceId];
state.instanceId = instanceId;
state.disabled = disabled;
autoSave();
}
bool ItemStateRegistry::isDisabled(const std::string &instanceId) const
{
if (instanceId.empty())
return false;
auto it = m_states.find(instanceId);
if (it != m_states.end())
return it->second.disabled;
return false;
}
void ItemStateRegistry::clearState(const std::string &instanceId)
{
m_states.erase(instanceId);
autoSave();
}
nlohmann::json ItemStateRegistry::serialize() const
{
nlohmann::json j;
j["version"] = "1.0";
for (const auto &pair : m_states) {
const ItemState &state = pair.second;
nlohmann::json stateJson;
stateJson["instanceId"] = state.instanceId;
stateJson["disabled"] = state.disabled;
j["states"].push_back(stateJson);
}
return j;
}
void ItemStateRegistry::deserialize(const nlohmann::json &j)
{
m_states.clear();
if (!j.contains("states"))
return;
for (const auto &stateJson : j["states"]) {
ItemState state;
state.instanceId = stateJson.value("instanceId", "");
state.disabled = stateJson.value("disabled", false);
if (!state.instanceId.empty())
m_states[state.instanceId] = state;
}
}
bool ItemStateRegistry::saveToFile(const std::string &filepath)
{
try {
std::ofstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
file << serialize().dump(4);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Save error: ") + e.what();
return false;
}
}
bool ItemStateRegistry::loadFromFile(const std::string &filepath)
{
try {
std::ifstream file(filepath);
if (!file.is_open()) {
m_lastError = "Cannot open " + filepath;
return false;
}
nlohmann::json j;
file >> j;
deserialize(j);
return true;
} catch (const std::exception &e) {
m_lastError = std::string("Load error: ") + e.what();
return false;
}
}
void ItemStateRegistry::autoSave()
{
if (!m_autoSavePath.empty())
saveToFile(m_autoSavePath);
}
@@ -0,0 +1,55 @@
#ifndef EDITSCENE_ITEMSTATEREGISTRY_HPP
#define EDITSCENE_ITEMSTATEREGISTRY_HPP
#pragma once
#include <nlohmann/json.hpp>
#include <string>
#include <unordered_map>
/**
* Global item state registry.
*
* Tracks per-instance item state keyed by instanceId.
* Used to persist whether world items have been picked up / disabled
* across scene reloads and game sessions.
*
* Saves to item_state.json.
*/
class ItemStateRegistry {
public:
static ItemStateRegistry &getInstance();
struct ItemState {
std::string instanceId;
bool disabled = false;
};
void setDisabled(const std::string &instanceId, bool disabled);
bool isDisabled(const std::string &instanceId) const;
void clearState(const std::string &instanceId);
nlohmann::json serialize() const;
void deserialize(const nlohmann::json &j);
bool saveToFile(const std::string &filepath);
bool loadFromFile(const std::string &filepath);
const std::string &getLastError() const
{
return m_lastError;
}
void autoSave();
private:
ItemStateRegistry();
~ItemStateRegistry() = default;
ItemStateRegistry(const ItemStateRegistry &) = delete;
ItemStateRegistry &operator=(const ItemStateRegistry &) = delete;
std::unordered_map<std::string, ItemState> m_states;
mutable std::string m_lastError;
std::string m_autoSavePath;
};
#endif // EDITSCENE_ITEMSTATEREGISTRY_HPP
+61 -54
View File
@@ -1,4 +1,6 @@
#include "ItemSystem.hpp"
#include "ItemRegistry.hpp"
#include "ContainerStateRegistry.hpp"
#include "../EditorApp.hpp"
#include "BehaviorTreeSystem.hpp"
#include "../components/Item.hpp"
@@ -6,6 +8,7 @@
#include "../components/Transform.hpp"
#include "../components/EntityName.hpp"
#include "../components/ActionDatabase.hpp"
#include "../components/RuntimeMarker.hpp"
#include <OgreSceneNode.h>
#include <OgreLogManager.h>
#include <cmath>
@@ -24,32 +27,29 @@ ItemSystem::~ItemSystem() = default;
// --- Inventory manipulation API ---
bool ItemSystem::addItemToInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId,
const Ogre::String &itemName,
const Ogre::String &itemType, int stackSize,
float weight, int value,
const Ogre::String &useActionName)
const std::string &itemId, int stackSize)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
if (itemId.empty() || stackSize <= 0)
return false;
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
int maxStack = ItemRegistry::getSingleton().getMaxStackSize(itemId);
// Try to stack with existing items of the same itemId
if (!itemId.empty()) {
for (int i = 0; i < (int)inv.slots.size(); i++) {
auto &slot = inv.slots[i];
if (!slot.isEmpty() && slot.itemId == itemId &&
slot.stackSize < slot.maxStackSize) {
int space = slot.maxStackSize - slot.stackSize;
int add = std::min(space, stackSize);
slot.stackSize += add;
stackSize -= add;
if (stackSize <= 0) {
inv.recalculateWeight();
return true;
}
for (int i = 0; i < (int)inv.slots.size(); i++) {
auto &slot = inv.slots[i];
if (!slot.isEmpty() && slot.itemId == itemId &&
slot.stackSize < maxStack) {
int space = maxStack - slot.stackSize;
int add = std::min(space, stackSize);
slot.stackSize += add;
stackSize -= add;
if (stackSize <= 0) {
inv.recalculateWeight();
return true;
}
}
}
@@ -67,14 +67,8 @@ bool ItemSystem::addItemToInventory(flecs::entity inventoryEntity,
auto &slot = inv.slots[slotIdx];
slot.itemEntity = 0;
slot.itemId = itemId;
slot.itemName = itemName;
slot.itemType = itemType;
slot.maxStackSize = 99;
slot.weight = weight;
slot.value = value;
slot.useActionName = useActionName;
int add = std::min(stackSize, slot.maxStackSize);
int add = std::min(stackSize, maxStack);
slot.stackSize = add;
stackSize -= add;
}
@@ -94,13 +88,19 @@ bool ItemSystem::addItemEntityToInventory(flecs::entity inventoryEntity,
auto &item = itemEntity.get_mut<ItemComponent>();
// Add to inventory
bool result = addItemToInventory(inventoryEntity, item.itemId,
item.itemName, item.itemType,
item.stackSize, item.weight,
item.value, item.useActionName);
item.stackSize);
if (result) {
// Store the world entity reference in the first matching slot
auto &inv = inventoryEntity.get_mut<InventoryComponent>();
for (auto &slot : inv.slots) {
if (slot.itemId == item.itemId && slot.itemEntity == 0) {
slot.itemEntity = itemEntity.id();
break;
}
}
// Hide the world entity
if (itemEntity.has<TransformComponent>()) {
auto &trans = itemEntity.get_mut<TransformComponent>();
@@ -113,7 +113,7 @@ bool ItemSystem::addItemEntityToInventory(flecs::entity inventoryEntity,
}
int ItemSystem::removeItemFromInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId, int count)
const std::string &itemId, int count)
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
@@ -187,10 +187,8 @@ bool ItemSystem::transferItem(flecs::entity fromInventory, int slotIndex,
int transferCount = std::min(count, slot.stackSize);
// Add to target inventory
bool added = addItemToInventory(toInventory, slot.itemId, slot.itemName,
slot.itemType, transferCount,
slot.weight, slot.value,
slot.useActionName);
bool added = addItemToInventory(toInventory, slot.itemId,
transferCount);
if (!added)
return false;
@@ -205,7 +203,7 @@ bool ItemSystem::transferItem(flecs::entity fromInventory, int slotIndex,
}
bool ItemSystem::hasItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const
const std::string &itemId) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
@@ -215,18 +213,28 @@ bool ItemSystem::hasItem(flecs::entity inventoryEntity,
}
bool ItemSystem::hasItemByName(flecs::entity inventoryEntity,
const Ogre::String &itemName) const
const std::string &itemName) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
return false;
return inventoryEntity.get<InventoryComponent>().hasItemByName(
itemName);
// Look up itemId by display name in registry
std::string foundId;
for (const auto &pair : ItemRegistry::getSingleton().getDefinitions()) {
if (pair.second.itemName == itemName) {
foundId = pair.second.itemId;
break;
}
}
if (foundId.empty())
return false;
return inventoryEntity.get<InventoryComponent>().hasItem(foundId);
}
int ItemSystem::countItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const
const std::string &itemId) const
{
if (!inventoryEntity.is_alive() ||
!inventoryEntity.has<InventoryComponent>())
@@ -251,6 +259,8 @@ bool ItemSystem::dropItem(flecs::entity inventoryEntity, int slotIndex,
return false;
int dropCount = std::min(count, slot.stackSize);
const std::string itemName =
ItemRegistry::getSingleton().getItemName(slot.itemId);
// If the item has a world entity reference, show it
if (slot.itemEntity != 0) {
@@ -273,14 +283,8 @@ bool ItemSystem::dropItem(flecs::entity inventoryEntity, int slotIndex,
} else {
// Create a new world entity for the dropped item
flecs::entity itemEntity = m_world.entity();
itemEntity.set<ItemComponent>(
ItemComponent(slot.itemName, slot.itemType));
auto &item = itemEntity.get_mut<ItemComponent>();
item.itemId = slot.itemId;
item.stackSize = dropCount;
item.weight = slot.weight;
item.value = slot.value;
item.useActionName = slot.useActionName;
itemEntity.add<RuntimeMarkerComponent>();
itemEntity.set<ItemComponent>(ItemComponent(slot.itemId, dropCount));
// Create a scene node for the dropped item
Ogre::SceneNode *node =
@@ -292,7 +296,7 @@ bool ItemSystem::dropItem(flecs::entity inventoryEntity, int slotIndex,
itemEntity.set<TransformComponent>(trans);
EntityNameComponent nameComp;
nameComp.name = slot.itemName + "_dropped";
nameComp.name = itemName + "_dropped";
itemEntity.set<EntityNameComponent>(nameComp);
}
@@ -317,17 +321,19 @@ bool ItemSystem::useItem(flecs::entity characterEntity,
return false;
const auto &slot = inv.slots[slotIndex];
if (slot.isEmpty() || slot.useActionName.empty())
if (slot.isEmpty())
return false;
std::string useAction =
ItemRegistry::getSingleton().getUseActionName(slot.itemId);
if (useAction.empty())
return false;
// Execute the use action via behavior tree
if (m_btSystem && characterEntity.is_alive()) {
// Look up the action in the singleton database
ActionDatabase *db = ActionDatabase::getSingletonPtr();
if (db) {
const GoapAction *action =
db->findAction(slot.useActionName);
const GoapAction *action = db->findAction(useAction);
if (action) {
m_btSystem->evaluatePlayerAction(
characterEntity.id(),
@@ -353,7 +359,8 @@ bool ItemSystem::pickupItem(flecs::entity characterEntity,
if (result) {
Ogre::LogManager::getSingleton().logMessage(
"[ItemSystem] Picked up: " +
itemEntity.get<ItemComponent>().itemName);
ItemRegistry::getSingleton().getItemName(
itemEntity.get<ItemComponent>().itemId));
}
return result;
}
+8 -15
View File
@@ -12,11 +12,8 @@ class BehaviorTreeSystem;
/**
* System that handles item pickup, drop, use, and inventory management.
*
* Provides a pure API for inventory operations. Proximity detection
* for player pickup is handled by ActuatorSystem (which detects
* entities with ItemComponent and shows "E - Pick up" prompts).
*
* For AI characters, behavior tree nodes provide inventory access.
* Provides a pure API for inventory operations. All item properties
* are looked up from ItemRegistry by itemId.
*/
class ItemSystem {
public:
@@ -28,11 +25,7 @@ public:
/** Add an item to an inventory by itemId. Creates a new slot. */
bool addItemToInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId,
const Ogre::String &itemName,
const Ogre::String &itemType, int stackSize = 1,
float weight = 0.1f, int value = 1,
const Ogre::String &useActionName = "");
const std::string &itemId, int stackSize = 1);
/** Add an item entity (ItemComponent) to an inventory. */
bool addItemEntityToInventory(flecs::entity inventoryEntity,
@@ -40,7 +33,7 @@ public:
/** Remove items from inventory by itemId. Returns number removed. */
int removeItemFromInventory(flecs::entity inventoryEntity,
const Ogre::String &itemId, int count = 1);
const std::string &itemId, int count = 1);
/** Remove items from inventory by slot index. */
bool removeItemFromSlot(flecs::entity inventoryEntity, int slotIndex,
@@ -52,15 +45,15 @@ public:
/** Check if an inventory has at least one of a specific itemId. */
bool hasItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const;
const std::string &itemId) const;
/** Check if an inventory has at least one of a specific itemName. */
/** Check if an inventory has an item with the given display name. */
bool hasItemByName(flecs::entity inventoryEntity,
const Ogre::String &itemName) const;
const std::string &itemName) const;
/** Count how many of a specific itemId are in an inventory. */
int countItem(flecs::entity inventoryEntity,
const Ogre::String &itemId) const;
const std::string &itemId) const;
/** Drop an item from inventory into the world at a position. */
bool dropItem(flecs::entity inventoryEntity, int slotIndex,
@@ -0,0 +1,213 @@
#include "MarkovNameGenerator.hpp"
#include <algorithm>
#include <cctype>
MarkovNameGenerator::MarkovNameGenerator()
{
std::random_device rd;
m_rng.seed(rd());
}
void MarkovNameGenerator::learn(const std::string &name)
{
if (name.empty())
return;
std::string s;
s.reserve(name.size() + 3);
for (char c : name)
s.push_back(static_cast<char>(std::tolower(
static_cast<unsigned char>(c))));
/* Pad with start/end tokens */
std::string proc = "##" + s + "$";
/* Order-2 transitions */
for (size_t i = 0; i + 2 < proc.size(); ++i) {
std::string prefix = proc.substr(i, 2);
char next = proc[i + 2];
m_order2[prefix][next]++;
}
/* Order-1 transitions (fallback) */
for (size_t i = 0; i + 1 < proc.size(); ++i) {
char prefix = proc[i];
char next = proc[i + 1];
m_order1[prefix][next]++;
}
/* Starting bigram: first two real characters after ## */
if (s.size() >= 2)
m_starters[s.substr(0, 2)]++;
else if (s.size() == 1)
m_starters[s + "$"]++;
}
void MarkovNameGenerator::learn(const std::vector<std::string> &names)
{
for (const auto &n : names)
learn(n);
}
std::string MarkovNameGenerator::generate(int minLen, int maxLen,
const std::unordered_set<std::string>
*exclude) const
{
if (m_order2.empty())
return "";
for (int attempt = 0; attempt < 200; ++attempt) {
std::string name;
std::string prefix = "##";
while (static_cast<int>(name.size()) < maxLen) {
char next = 0;
/* Try order-2 first */
auto it2 = m_order2.find(prefix);
if (it2 != m_order2.end() && !it2->second.empty()) {
next = pickNext(it2->second);
} else if (!prefix.empty()) {
/* Fallback to order-1 */
char fallback = prefix.back();
auto it1 = m_order1.find(fallback);
if (it1 != m_order1.end() && !it1->second.empty())
next = pickNext(it1->second);
}
if (next == '$' || next == 0)
break;
if (next == '#')
continue;
name.push_back(next);
prefix = prefix.substr(1) + next;
if (prefix.size() < 2)
prefix = "##";
}
if (static_cast<int>(name.size()) < minLen)
continue;
std::string result = titleCase(name);
if (exclude && exclude->find(result) != exclude->end())
continue;
return result;
}
return "";
}
std::vector<std::string> MarkovNameGenerator::generateMany(
int count, int minLen, int maxLen,
const std::unordered_set<std::string> *exclude) const
{
std::vector<std::string> results;
results.reserve(count);
std::unordered_set<std::string> localExclude;
if (exclude)
localExclude = *exclude;
for (int i = 0; i < count; ++i) {
std::string name = generate(minLen, maxLen, &localExclude);
if (!name.empty()) {
results.push_back(name);
localExclude.insert(name);
}
}
return results;
}
void MarkovNameGenerator::clear()
{
m_order2.clear();
m_order1.clear();
m_starters.clear();
}
bool MarkovNameGenerator::empty() const
{
return m_order2.empty();
}
void MarkovNameGenerator::saveToJson(nlohmann::json &j) const
{
j["order2"] = nlohmann::json::object();
for (const auto &pair : m_order2) {
nlohmann::json inner = nlohmann::json::object();
for (const auto &p2 : pair.second)
inner[std::string(1, p2.first)] = p2.second;
j["order2"][pair.first] = inner;
}
j["order1"] = nlohmann::json::object();
for (const auto &pair : m_order1) {
nlohmann::json inner = nlohmann::json::object();
for (const auto &p2 : pair.second)
inner[std::string(1, p2.first)] = p2.second;
j["order1"][std::string(1, pair.first)] = inner;
}
j["starters"] = nlohmann::json::object();
for (const auto &pair : m_starters)
j["starters"][pair.first] = pair.second;
}
void MarkovNameGenerator::loadFromJson(const nlohmann::json &j)
{
clear();
if (j.contains("order2")) {
for (auto &[k, v] : j["order2"].items()) {
for (auto &[ck, cv] : v.items()) {
if (!ck.empty())
m_order2[k][ck[0]] = cv.get<int>();
}
}
}
if (j.contains("order1")) {
for (auto &[k, v] : j["order1"].items()) {
for (auto &[ck, cv] : v.items()) {
if (!ck.empty() && !k.empty())
m_order1[k[0]][ck[0]] = cv.get<int>();
}
}
}
if (j.contains("starters")) {
for (auto &[k, v] : j["starters"].items())
m_starters[k] = v.get<int>();
}
}
char MarkovNameGenerator::pickNext(
const std::unordered_map<char, int> &freqs) const
{
int total = 0;
for (const auto &p : freqs)
total += p.second;
if (total <= 0)
return 0;
std::uniform_int_distribution<int> dist(0, total - 1);
int roll = dist(m_rng);
for (const auto &p : freqs) {
roll -= p.second;
if (roll < 0)
return p.first;
}
return freqs.begin()->first;
}
std::string MarkovNameGenerator::titleCase(const std::string &s)
{
if (s.empty())
return s;
std::string r = s;
r[0] = static_cast<char>(std::toupper(
static_cast<unsigned char>(r[0])));
return r;
}

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