feat(client): character creator overhaul — manifest, segments, eye color

Major changes from runtime testing and iteration:
- Asset manifest (manifest.json) controls all available content —
  replaces filesystem scanning, solves DirAccess/PCK export issue
- Dynamic tabs: only show tabs with manifest content
- Body segmentation: face-based exclusive assignment, hips split from
  torso, torso_upper independent (not a variant)
- Eye color with iris-only mask from T_Eye_Split.png green channel
- Eyebrows tinted with hair color (from body segment, not separate GLB)
- Hair/clothing loaded as skinned meshes on shared skeleton
- Segment hiding disabled for clothing (solidify handles coverage)
- Gender-aware randomizer (no facial hair on female/teen)
- Clothing slots hidden when empty in manifest
- UI: color docks docked to bottom on all tabs, padding, flow layout
- Debug tab with per-segment visibility toggles
- Screenshot automation with test config JSON

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:42:33 +01:00
co-authored by Claude Opus 4.6
parent 8218d5a942
commit 4d9840b68f
4 changed files with 604 additions and 135 deletions
+93 -40
View File
@@ -31,6 +31,7 @@ const BASE_PATH := "res://assets/characters/"
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
const ANIM_LIBRARY_PATH := BASE_PATH + "animations/ual_standard.glb"
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
const EYE_IRIS_MASK_PATH := BASE_PATH + "bodies/eye_iris_mask.png"
const TOON_SHADER_PATH := BASE_PATH + "shaders/toon.gdshader"
const TOON_MASKED_SHADER_PATH := BASE_PATH + "shaders/toon_masked.gdshader"
const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
@@ -38,7 +39,7 @@ const OUTLINE_SHADER_PATH := BASE_PATH + "shaders/outline.gdshader"
## All body segment names in assembly order. D-160: 14 base + 2 swappable torso + 2 face = 18.
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
const ALL_SEGMENTS: Array[String] = [
"head", "neck", "torso", "torso_upper",
"head", "neck", "torso_upper", "torso", "hips",
"arm_upper_l", "arm_upper_r", "arm_lower_l", "arm_lower_r",
"hand_l", "hand_r",
"leg_upper_l", "leg_upper_r", "leg_lower_l", "leg_lower_r",
@@ -50,7 +51,7 @@ const ALL_SEGMENTS: Array[String] = [
const NON_SKIN_SEGMENTS: Array[String] = ["eyes", "eyebrows"]
## Segments hidden by default; shown only when a clothing coverage rule requests it.
const HIDDEN_BY_DEFAULT: Array[String] = ["torso_upper"]
const HIDDEN_BY_DEFAULT: Array[String] = [] # all segments visible by default — no swappable variants
## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md).
## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H),
@@ -109,6 +110,8 @@ var _loaded_slots: Array[String] = [] # "hair", "facial_hair", etc.
var _toon_shader: Shader = null
var _toon_masked_shader: Shader = null
var _outline_shader: Shader = null
var _white_mask: ImageTexture = null # 1x1 white pixel — forces full-body tinting
var _iris_mask: Texture2D = null # iris-only mask from T_Eye_Split.png
func _ready() -> void:
@@ -121,6 +124,13 @@ func _load_shaders() -> void:
_outline_shader = load(OUTLINE_SHADER_PATH) as Shader
if not _toon_shader or not _toon_masked_shader or not _outline_shader:
push_error("CharacterVisual: shader load failed — check %s" % BASE_PATH)
# 1x1 white pixel texture — used as recolor_mask to enable full-body skin tinting
var img := Image.create(1, 1, false, Image.FORMAT_R8)
img.set_pixel(0, 0, Color.WHITE)
_white_mask = ImageTexture.create_from_image(img)
# Iris mask — generated from T_Eye_Split.png green channel
if ResourceLoader.exists(EYE_IRIS_MASK_PATH):
_iris_mask = load(EYE_IRIS_MASK_PATH) as Texture2D
# =============================================================================
@@ -274,6 +284,12 @@ func _load_skeleton() -> void:
if _skeleton == null:
push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH)
return
# Log any meshes that came with the armature import
var armature_meshes := _collect_meshes(_body_root)
if not armature_meshes.is_empty():
print("--- Armature import contains ", armature_meshes.size(), " meshes ---")
for m in armature_meshes:
print(" armature mesh: ", m.name, " visible=", m.visible)
_validate_slot_bones()
@@ -307,7 +323,14 @@ func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
mi.owner = null
mi.set_meta("segment", seg_name)
mi.visible = seg_name not in HIDDEN_BY_DEFAULT
_apply_body_shader(mi, seg_name, tone)
if seg_name == "eyebrows":
_apply_tinted_shader(mi, desc.eyebrow_tint, _white_mask)
elif seg_name == "eyes":
# Iris-only tinting using mask derived from T_Eye_Split.png green channel
var eye_mask: Texture2D = _iris_mask if _iris_mask else null
_apply_tinted_shader(mi, desc.eye_color, eye_mask)
else:
_apply_body_shader(mi, seg_name, tone)
_skeleton.add_child(mi)
_body_meshes.append(mi)
inst.queue_free()
@@ -324,13 +347,23 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary)
if orig != null:
mi.set_surface_override_material(surf, orig)
continue
# Skin body segments: apply skin tone via toon_masked or flat toon fallback.
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
var skin_tex := load(tex_path) as Texture2D
# Skin body segments: use embedded texture for per-body-type detail,
# with skin tone tint applied via full white recolor mask.
var orig_mat: Material = mi.mesh.surface_get_material(surf)
var orig_tex: Texture2D = null
if orig_mat is StandardMaterial3D:
orig_tex = (orig_mat as StandardMaterial3D).albedo_texture
elif orig_mat is BaseMaterial3D:
orig_tex = (orig_mat as BaseMaterial3D).albedo_texture
# Fallback to generic skin tone texture if no embedded texture found
if orig_tex == null:
var tex_path := SKIN_TONE_DIR + "%s.png" % tone["tex"]
orig_tex = load(tex_path) as Texture2D
var mat := ShaderMaterial.new()
if skin_tex and _toon_masked_shader:
if orig_tex and _toon_masked_shader:
mat.shader = _toon_masked_shader
mat.set_shader_parameter("albedo_tex", skin_tex)
mat.set_shader_parameter("albedo_tex", orig_tex)
mat.set_shader_parameter("recolor_mask", _white_mask)
mat.set_shader_parameter("tint_color", tone["lit"])
mat.set_shader_parameter("shadow_strength", 0.25)
elif _toon_shader:
@@ -338,7 +371,11 @@ func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary)
mat.set_shader_parameter("base_color", tone["lit"])
mat.set_shader_parameter("shadow_color", tone["shadow"])
mat.set_shader_parameter("shadow_threshold", 0.4)
mi.set_surface_override_material(surf, mat)
# Use material_override to fully replace ALL surface materials.
# set_surface_override_material can leave the original StandardMaterial3D
# partially active, which may prevent visible=false from working.
mi.material_override = mat
return # material_override applies to all surfaces — no need to loop
# =============================================================================
@@ -369,17 +406,14 @@ func _load_facial_hair(desc: CharacterVisualDescriptor) -> void:
_loaded_slots.append("facial_hair")
func _load_eyebrows(desc: CharacterVisualDescriptor) -> void:
if desc.eyebrow_id.is_empty():
return
# Convention: eyebrow_id uses the short visual name (e.g. "regular", "thick", "female"),
# NOT the full filename prefix. Path: eyebrows/{eyebrow_id}.glb → e.g. eyebrows/regular.glb
var path := BASE_PATH + "eyebrows/%s.glb" % desc.eyebrow_id
if _attach_to_bone(path, "Head", desc.eyebrow_tint) != null:
_loaded_slots.append("eyebrow")
func _load_eyebrows(_desc: CharacterVisualDescriptor) -> void:
# Eyebrows come from the body segment (seg_eyebrows) — not loaded separately.
# The seg_eyebrows mesh is part of the body and matches the body type's head shape.
# Loading separate eyebrow GLBs caused z-fighting and size mismatches across body types.
pass
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> BoneAttachment3D:
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE, render_priority: int = 0) -> BoneAttachment3D:
if _skeleton == null:
return null
if not ResourceLoader.exists(path):
@@ -406,9 +440,38 @@ func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE)
mask_tex = load(mask_path) as Texture2D
var inst := scene.instantiate()
attachment.add_child(inst)
for mi in _collect_meshes(inst):
_apply_tinted_shader(mi, tint, mask_tex)
var meshes := _collect_meshes(inst)
# Hair/eyebrow GLBs are skinned meshes with their own armature.
# Reparent the mesh onto OUR skeleton so the shared bone names drive it.
# This avoids the BoneAttachment3D double-offset problem.
var has_skin := false
for mi in meshes:
if mi.skin != null:
has_skin = true
break
if has_skin:
# Skinned mesh (hair, eyebrows) — same structure as body segments.
# Add mesh directly to our skeleton; matching bone names drive it.
_skeleton.remove_child(attachment)
attachment.free()
_bone_attachments.pop_back()
for mi in meshes:
mi.get_parent().remove_child(mi)
mi.owner = null
_skeleton.add_child(mi)
_apply_tinted_shader(mi, tint, mask_tex)
inst.queue_free()
return null
else:
# Unskinned — rigid attachment via BoneAttachment3D
for mi in meshes:
mi.get_parent().remove_child(mi)
mi.owner = null
attachment.add_child(mi)
_apply_tinted_shader(mi, tint, mask_tex)
inst.queue_free()
return attachment
@@ -461,22 +524,9 @@ func _load_clothing(desc: CharacterVisualDescriptor) -> void:
_clothing_meshes.append(mi)
inst.queue_free()
# Apply segment hiding from coverage
for mi in _body_meshes:
var seg: String = mi.get_meta("segment", "")
if seg in hidden_segments:
mi.visible = false
# Segment hiding disabled — solidified clothing sits on top of body.
# Hiding is reserved for amputation/prosthetics via the debug tab or game logic.
# Apply torso variant selection
if _active_torso_variant == "upper":
for mi in _body_meshes:
match mi.get_meta("segment", ""):
"torso": mi.visible = false
"torso_upper": mi.visible = true
else:
for mi in _body_meshes:
if mi.get_meta("segment", "") == "torso_upper":
mi.visible = false
func _read_coverage(path: String) -> Dictionary:
@@ -541,7 +591,7 @@ func _load_accessories(desc: CharacterVisualDescriptor) -> void:
# Internal — tinting (hair, accessories, bone-attached assets with tint)
# =============================================================================
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null) -> void:
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null, render_priority: int = 0) -> void:
if mi.mesh == null:
return
for surf in range(mi.mesh.get_surface_count()):
@@ -560,6 +610,7 @@ func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D =
mat.set_shader_parameter("base_color", tint)
mat.set_shader_parameter("shadow_color", tint.darkened(0.4))
mat.set_shader_parameter("shadow_threshold", 0.4)
mat.render_priority = render_priority
mi.set_surface_override_material(surf, mat)
@@ -630,10 +681,12 @@ func _load_animations() -> void:
push_warning("CharacterVisual: no AnimationPlayer found in animation library")
anim_root.queue_free()
return
# Create our own AnimationPlayer on the skeleton
# Parent AnimationPlayer to _body_root (the imported scene root) so that
# animation track paths like "Armature/Skeleton3D:bone_name" resolve correctly.
# The imported GLB has structure: root > Armature > Skeleton3D.
_anim_player = AnimationPlayer.new()
_anim_player.name = "AnimPlayer"
_skeleton.add_child(_anim_player)
_body_root.add_child(_anim_player)
# Copy animation libraries from the source player
for lib_name in source_player.get_animation_library_list():
var lib: AnimationLibrary = source_player.get_animation_library(lib_name)
@@ -699,7 +752,7 @@ static func _get_albedo_texture(mat: Material) -> Texture2D:
if mat is BaseMaterial3D:
return (mat as BaseMaterial3D).albedo_texture
if mat is ShaderMaterial:
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo"]:
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo", "Hair_Texture", "BaseColor"]:
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
if val is Texture2D:
return val as Texture2D
@@ -85,6 +85,9 @@ var facial_hair_tint: Color = Color.WHITE
var eyebrow_id: String = ""
var eyebrow_tint: Color = Color.WHITE
## Eye color
var eye_color: Color = Color(0.45, 0.3, 0.15) # brown default
## Skin tone index 08 into the 9-tone palette (pale_cool → very_deep_cool)
var skin_tone: int = 0
@@ -130,6 +133,7 @@ static func from_dict(data: Dictionary) -> CharacterVisualDescriptor:
desc.facial_hair_tint = _decode_color(data.get("facial_hair_tint"), Color.WHITE)
desc.eyebrow_id = data.get("eyebrow_id", "")
desc.eyebrow_tint = _decode_color(data.get("eyebrow_tint"), Color.WHITE)
desc.eye_color = _decode_color(data.get("eye_color"), Color(0.45, 0.3, 0.15))
desc.skin_tone = clampi(data.get("skin_tone", 0), 0, 8)
desc.clothing_slots = data.get("clothing_slots", {})
desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {}))
@@ -150,6 +154,7 @@ func to_dict() -> Dictionary:
"facial_hair_tint": _encode_color(facial_hair_tint),
"eyebrow_id": eyebrow_id,
"eyebrow_tint": _encode_color(eyebrow_tint),
"eye_color": _encode_color(eye_color),
"skin_tone": skin_tone,
"clothing_slots": clothing_slots,
"clothing_tints": _encode_tint_map(clothing_tints),