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:
@@ -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 0–8 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),
|
||||
|
||||
Reference in New Issue
Block a user