Compare commits
28 Commits
5952a96ee6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a5edacf8a | |||
| 765dffbed0 | |||
| a553621c7f | |||
| 86310e96f2 | |||
| 1a0fb87b93 | |||
| b71b599d9c | |||
| eea50adfcb | |||
| c7ef9283cd | |||
| 3a3edf785c | |||
| fc486eea82 | |||
| b19033b557 | |||
| 9968cb8c75 | |||
| aae7620512 | |||
| 970d0f9034 | |||
| bea438bd50 | |||
| 3f40d84847 | |||
| 8630bfcf18 | |||
| 5bb20d416d | |||
| eb0d05a577 | |||
| ef49506515 | |||
| 089d13520e | |||
| 472af01e94 | |||
| f9e61dcb05 | |||
| 42f6a218fb | |||
| ce888bc5bb | |||
| 333a0b9938 | |||
| 11530dd7fc | |||
| 3fd167ebff |
@@ -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.
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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`)
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
@@ -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 ¶ms) {
|
||||
// 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 ¶ms) {
|
||||
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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user