Add CharacterVisualDescriptor (11-variant BodyType enum, wire format encode/decode, body_type_key mapping) and CharacterVisual compositor (runtime 3D character assembler with slot architecture, BoneAttachment3D, clothing coverage, recolor mask loading, skin tone tinting, bone validation). Includes 64 unit tests and Blender utility scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
class_name CharacterVisual
|
||||
extends Node3D
|
||||
|
||||
## Runtime character visual compositor.
|
||||
## Accepts a CharacterVisualDescriptor and assembles the full 3D character from
|
||||
## pre-authored asset files. No runtime deformation — selects and attaches meshes.
|
||||
##
|
||||
## All asset paths derived programmatically from BASE_PATH + descriptor fields:
|
||||
## bodies/{body_type}/seg_{name}.glb (D-160: 21 segments per body type)
|
||||
## heads/templates/{head_id}.glb (D-161: head as BoneAttachment3D)
|
||||
## hair/{hair_id}.glb (BoneAttachment3D to Head bone)
|
||||
## clothing/{item_id}/{body_type}.glb (D-162: pre-fitted per body type)
|
||||
## accessories/{accessory_id}.glb (BoneAttachment3D per slot→bone map)
|
||||
##
|
||||
## Public API:
|
||||
## load_descriptor(descriptor) — rebuild full character from descriptor
|
||||
## set_facing(direction: Vector2|String) — rotate model to match facing direction
|
||||
## get_skeleton() — returns the Skeleton3D for external use
|
||||
## is_segment_visible(seg_name) — query body segment visibility
|
||||
## get_active_coverage(item_id) — return coverage dict for a clothing item
|
||||
## get_active_torso_variant() — "full" or "upper"
|
||||
## has_slot_node(slot) — whether a named slot (hair, facial_hair) was loaded
|
||||
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
|
||||
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
|
||||
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
|
||||
##
|
||||
## D-159 (11 body types), D-160 (21 segments), D-161 (head separate),
|
||||
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
|
||||
|
||||
const BASE_PATH := "res://assets/characters/"
|
||||
const SKELETON_PATH := BASE_PATH + "skeleton/armature.glb"
|
||||
const SKIN_TONE_DIR := BASE_PATH + "skin_tones/"
|
||||
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"
|
||||
|
||||
## All body segment names in assembly order. D-160: 17 base + 2 swappable torso + 2 face.
|
||||
## torso_upper is loaded and hidden by default; clothing coverage reveals it.
|
||||
const ALL_SEGMENTS: Array[String] = [
|
||||
"head", "neck", "torso", "torso_upper",
|
||||
"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",
|
||||
"foot_l", "foot_r",
|
||||
"eyes", "eyebrows",
|
||||
]
|
||||
|
||||
## Segments that do NOT receive the skin tone shader — preserve original embedded material.
|
||||
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"]
|
||||
|
||||
## Accessory slot → Quaternius bone name (Section 3 of character-asset-organization.md).
|
||||
## Bone names verified against armature.glb — Quaternius rig uses "Head" (capital H),
|
||||
## lowercase for everything else. NOT Mixamo names (no "Hips", "LeftHand", "Spine2").
|
||||
const SLOT_TO_BONE: Dictionary = {
|
||||
"hat": "Head",
|
||||
"goggles": "Head",
|
||||
"mask": "Head",
|
||||
"earring": "Head",
|
||||
"backpack": "spine_03",
|
||||
"belt": "pelvis",
|
||||
"held_l": "hand_l",
|
||||
"held_r": "hand_r",
|
||||
}
|
||||
|
||||
## Skin tone palette: 9 tones, index 0–8 matching skin_tones/ texture filenames.
|
||||
const SKIN_TONES: Array[Dictionary] = [
|
||||
{"lit": Color("f5e8e0"), "shadow": Color("c8b4b0"), "tex": "pale_cool"},
|
||||
{"lit": Color("f0c8a0"), "shadow": Color("c89870"), "tex": "pale_warm"},
|
||||
{"lit": Color("d4a878"), "shadow": Color("a87850"), "tex": "light_olive"},
|
||||
{"lit": Color("b87840"), "shadow": Color("885520"), "tex": "medium_golden"},
|
||||
{"lit": Color("c09060"), "shadow": Color("906040"), "tex": "olive_warm"},
|
||||
{"lit": Color("8b5a2b"), "shadow": Color("5a3010"), "tex": "medium_brown"},
|
||||
{"lit": Color("5c3317"), "shadow": Color("3a1a08"), "tex": "deep_brown"},
|
||||
{"lit": Color("3d2010"), "shadow": Color("200e04"), "tex": "very_deep_warm"},
|
||||
{"lit": Color("2d1a12"), "shadow": Color("180a06"), "tex": "very_deep_cool"},
|
||||
]
|
||||
|
||||
const OUTLINE_WIDTH: float = 0.006
|
||||
const OUTLINE_COLOR: Color = Color(0.08, 0.08, 0.12)
|
||||
const OUTLINE_SIZE_THRESHOLD: float = 0.1 # skip tiny meshes (eyes, eyebrows)
|
||||
|
||||
# --- Internal state ---
|
||||
|
||||
var _skeleton: Skeleton3D = null
|
||||
var _body_root: Node3D = null
|
||||
|
||||
var _body_meshes: Array[MeshInstance3D] = []
|
||||
var _clothing_meshes: Array[MeshInstance3D] = []
|
||||
var _bone_attachments: Array[BoneAttachment3D] = []
|
||||
var _outline_nodes: Array[MeshInstance3D] = []
|
||||
var _accessory_attachments: Array[BoneAttachment3D] = []
|
||||
|
||||
# Inspectable state for tests
|
||||
var _active_torso_variant: String = "full"
|
||||
var _active_coverages: Dictionary = {} # item_id -> coverage dict
|
||||
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
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_load_shaders()
|
||||
|
||||
|
||||
func _load_shaders() -> void:
|
||||
_toon_shader = load(TOON_SHADER_PATH) as Shader
|
||||
_toon_masked_shader = load(TOON_MASKED_SHADER_PATH) as Shader
|
||||
_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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Public API
|
||||
# =============================================================================
|
||||
|
||||
## Rebuild the full character from a CharacterVisualDescriptor.
|
||||
func load_descriptor(descriptor: CharacterVisualDescriptor) -> void:
|
||||
_clear()
|
||||
_load_skeleton()
|
||||
if _skeleton == null:
|
||||
push_error("CharacterVisual: skeleton failed to load — aborting load_descriptor")
|
||||
return
|
||||
_load_body_segments(descriptor)
|
||||
_load_head(descriptor)
|
||||
_load_hair(descriptor)
|
||||
_load_facial_hair(descriptor)
|
||||
_load_eyebrows(descriptor)
|
||||
_load_clothing(descriptor)
|
||||
_load_accessories(descriptor)
|
||||
_rebuild_outlines()
|
||||
|
||||
|
||||
## Set character facing from a 2D direction vector or a named string direction.
|
||||
## Vector2(0, 1) = south (toward camera), Vector2(1, 0) = east, etc.
|
||||
## String values: "south", "north", "east", "west", "southwest", "northeast", etc.
|
||||
func set_facing(direction: Variant) -> void:
|
||||
if direction is Vector2:
|
||||
# Convert 2D game-space direction to 3D Y rotation.
|
||||
# atan2(x, y): Vector2(0,1)→0°, (1,0)→-90°, (0,-1)→180°, (-1,0)→90°
|
||||
rotation.y = -atan2((direction as Vector2).x, (direction as Vector2).y)
|
||||
elif direction is String:
|
||||
var angles: Dictionary = {
|
||||
"south": 0.0, "southwest": 45.0, "west": 90.0, "northwest": 135.0,
|
||||
"north": 180.0, "northeast": -135.0, "east": -90.0, "southeast": -45.0,
|
||||
}
|
||||
rotation_degrees.y = angles.get((direction as String).to_lower(), 0.0)
|
||||
|
||||
|
||||
## Alias kept for EntityRenderer compatibility.
|
||||
func update_facing(direction: String) -> void:
|
||||
set_facing(direction)
|
||||
|
||||
|
||||
func get_skeleton() -> Skeleton3D:
|
||||
return _skeleton
|
||||
|
||||
|
||||
## Return whether a named body segment is currently visible.
|
||||
func is_segment_visible(seg_name: String) -> bool:
|
||||
for mi in _body_meshes:
|
||||
if mi.get_meta("segment", "") == seg_name:
|
||||
return mi.visible
|
||||
return false
|
||||
|
||||
|
||||
## Return the coverage.json data for a clothing item_id, or empty dict if unavailable.
|
||||
func get_active_coverage(item_id: String) -> Dictionary:
|
||||
return _active_coverages.get(item_id, {})
|
||||
|
||||
|
||||
## Return the active torso variant: "full" or "upper".
|
||||
func get_active_torso_variant() -> String:
|
||||
return _active_torso_variant
|
||||
|
||||
|
||||
## Return whether a named slot (hair, facial_hair, eyebrow) produced a scene node.
|
||||
func has_slot_node(slot: String) -> bool:
|
||||
return slot in _loaded_slots
|
||||
|
||||
|
||||
## Return count of clothing MeshInstance3D nodes currently attached.
|
||||
func get_clothing_node_count() -> int:
|
||||
return _clothing_meshes.size()
|
||||
|
||||
|
||||
## Return count of accessory BoneAttachment3D nodes currently attached.
|
||||
func get_accessory_node_count() -> int:
|
||||
return _accessory_attachments.size()
|
||||
|
||||
|
||||
## Return the skin tone texture filename key for a given index (without .png extension).
|
||||
func get_skin_tone_texture_name(index: int) -> String:
|
||||
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — teardown
|
||||
# =============================================================================
|
||||
|
||||
func _clear() -> void:
|
||||
for att in _bone_attachments:
|
||||
if is_instance_valid(att) and att.get_parent():
|
||||
att.get_parent().remove_child(att)
|
||||
att.free()
|
||||
_bone_attachments.clear()
|
||||
_accessory_attachments.clear()
|
||||
_loaded_slots.clear()
|
||||
|
||||
for mi in _clothing_meshes:
|
||||
if is_instance_valid(mi) and mi.get_parent():
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.free()
|
||||
_clothing_meshes.clear()
|
||||
|
||||
_body_meshes.clear()
|
||||
_active_coverages.clear()
|
||||
_active_torso_variant = "full"
|
||||
|
||||
_rebuild_outlines() # clears outline nodes before removing skeleton
|
||||
|
||||
if is_instance_valid(_body_root) and _body_root.get_parent():
|
||||
_body_root.get_parent().remove_child(_body_root)
|
||||
_body_root.queue_free()
|
||||
_body_root = null
|
||||
_skeleton = null
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — skeleton
|
||||
# =============================================================================
|
||||
|
||||
func _load_skeleton() -> void:
|
||||
if not ResourceLoader.exists(SKELETON_PATH):
|
||||
push_error("CharacterVisual: skeleton not found at %s" % SKELETON_PATH)
|
||||
return
|
||||
var scene := load(SKELETON_PATH) as PackedScene
|
||||
if scene == null:
|
||||
push_error("CharacterVisual: skeleton PackedScene is null")
|
||||
return
|
||||
_body_root = scene.instantiate()
|
||||
add_child(_body_root)
|
||||
_skeleton = _find_skeleton(_body_root)
|
||||
if _skeleton == null:
|
||||
push_error("CharacterVisual: no Skeleton3D node in %s" % SKELETON_PATH)
|
||||
return
|
||||
_validate_slot_bones()
|
||||
|
||||
|
||||
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
|
||||
## Catches bone name mismatches early rather than silently failing on accessory attach.
|
||||
func _validate_slot_bones() -> void:
|
||||
for slot: String in SLOT_TO_BONE:
|
||||
var bone_name: String = SLOT_TO_BONE[slot]
|
||||
if _skeleton.find_bone(bone_name) == -1:
|
||||
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — body segments (D-160)
|
||||
# =============================================================================
|
||||
|
||||
func _load_body_segments(desc: CharacterVisualDescriptor) -> void:
|
||||
var body_dir := BASE_PATH + "bodies/%s/" % desc.body_type_key()
|
||||
var tone := SKIN_TONES[clampi(desc.skin_tone, 0, SKIN_TONES.size() - 1)]
|
||||
|
||||
for seg_name in ALL_SEGMENTS:
|
||||
var seg_path := body_dir + "seg_%s.glb" % seg_name
|
||||
if not ResourceLoader.exists(seg_path):
|
||||
continue # segment not yet authored — skip gracefully
|
||||
var seg_scene := load(seg_path) as PackedScene
|
||||
if seg_scene == null:
|
||||
continue
|
||||
var inst := seg_scene.instantiate()
|
||||
for mi in _collect_meshes(inst):
|
||||
mi.get_parent().remove_child(mi)
|
||||
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)
|
||||
_skeleton.add_child(mi)
|
||||
_body_meshes.append(mi)
|
||||
inst.queue_free()
|
||||
|
||||
|
||||
func _apply_body_shader(mi: MeshInstance3D, seg_name: String, tone: Dictionary) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
var is_skin := seg_name not in NON_SKIN_SEGMENTS
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
if not is_skin:
|
||||
# Eyes/eyebrows: preserve the original embedded texture from the GLB.
|
||||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||||
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
|
||||
var mat := ShaderMaterial.new()
|
||||
if skin_tex and _toon_masked_shader:
|
||||
mat.shader = _toon_masked_shader
|
||||
mat.set_shader_parameter("albedo_tex", skin_tex)
|
||||
mat.set_shader_parameter("tint_color", tone["lit"])
|
||||
mat.set_shader_parameter("shadow_strength", 0.25)
|
||||
elif _toon_shader:
|
||||
mat.shader = _toon_shader
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — head, hair, facial hair, eyebrows (BoneAttachment3D) (D-161)
|
||||
# =============================================================================
|
||||
|
||||
func _load_head(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.head_id.is_empty():
|
||||
return
|
||||
var path := BASE_PATH + "heads/templates/%s.glb" % desc.head_id
|
||||
if _attach_to_bone(path, "Head"):
|
||||
_loaded_slots.append("head")
|
||||
|
||||
|
||||
func _load_hair(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.hair_id.is_empty() or desc.hair_id == "bald":
|
||||
return
|
||||
var path := BASE_PATH + "hair/%s.glb" % desc.hair_id
|
||||
if _attach_to_bone(path, "Head", desc.hair_tint):
|
||||
_loaded_slots.append("hair")
|
||||
|
||||
|
||||
func _load_facial_hair(desc: CharacterVisualDescriptor) -> void:
|
||||
if desc.facial_hair_id.is_empty():
|
||||
return
|
||||
var path := BASE_PATH + "facial_hair/%s.glb" % desc.facial_hair_id
|
||||
if _attach_to_bone(path, "Head", desc.facial_hair_tint):
|
||||
_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):
|
||||
_loaded_slots.append("eyebrow")
|
||||
|
||||
|
||||
func _attach_to_bone(path: String, bone_name: String, tint: Color = Color.WHITE) -> bool:
|
||||
if _skeleton == null:
|
||||
return false
|
||||
if not ResourceLoader.exists(path):
|
||||
push_warning("CharacterVisual: asset not found (expected): %s" % path)
|
||||
return false
|
||||
var scene := load(path) as PackedScene
|
||||
if scene == null:
|
||||
return false
|
||||
var bone_idx := _skeleton.find_bone(bone_name)
|
||||
if bone_idx == -1:
|
||||
push_error("CharacterVisual: bone '%s' not found in skeleton" % bone_name)
|
||||
return false
|
||||
var attachment := BoneAttachment3D.new()
|
||||
attachment.bone_name = bone_name
|
||||
attachment.bone_idx = bone_idx
|
||||
_skeleton.add_child(attachment)
|
||||
_bone_attachments.append(attachment)
|
||||
|
||||
# Load recolor mask sidecar: {asset_name}_mask.png alongside the GLB.
|
||||
# Without this, toon_masked recolor_mask defaults to black → tint_color ignored.
|
||||
var mask_path := path.get_basename() + "_mask.png"
|
||||
var mask_tex: Texture2D = null
|
||||
if ResourceLoader.exists(mask_path):
|
||||
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)
|
||||
return true
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — clothing (D-162): coverage.json → segment hiding + torso variant
|
||||
# =============================================================================
|
||||
|
||||
func _load_clothing(desc: CharacterVisualDescriptor) -> void:
|
||||
if _skeleton == null or desc.clothing_slots.is_empty():
|
||||
return
|
||||
|
||||
var hidden_segments: Array[String] = []
|
||||
_active_torso_variant = "full"
|
||||
|
||||
for slot: String in desc.clothing_slots:
|
||||
var item_id: String = desc.clothing_slots[slot]
|
||||
var item_dir := BASE_PATH + "clothing/%s/" % item_id
|
||||
var coverage := _read_coverage(item_dir + "coverage.json")
|
||||
_active_coverages[item_id] = coverage
|
||||
|
||||
var glb_path := item_dir + "%s.glb" % desc.body_type_key()
|
||||
|
||||
# Accumulate segment coverage across all equipped clothing
|
||||
if coverage.has("hides"):
|
||||
for seg_name in coverage["hides"]:
|
||||
if (seg_name as String) not in hidden_segments:
|
||||
hidden_segments.append(seg_name)
|
||||
# Only upgrade to "upper" — never downgrade back to "full" if a later item
|
||||
# uses "full". Most-revealing torso variant wins across all equipped clothing.
|
||||
if coverage.get("torso_variant") == "upper":
|
||||
_active_torso_variant = "upper"
|
||||
|
||||
# Load recolor mask: shared across all body-type variants (same UV layout after Surface Deform).
|
||||
var mask_path := item_dir + "reference_mask.png"
|
||||
var mask_tex: Texture2D = null
|
||||
if ResourceLoader.exists(mask_path):
|
||||
mask_tex = load(mask_path) as Texture2D
|
||||
|
||||
# Load the body-type-fitted clothing GLB
|
||||
if ResourceLoader.exists(glb_path):
|
||||
var scene := load(glb_path) as PackedScene
|
||||
if scene:
|
||||
var inst := scene.instantiate()
|
||||
var tints: Array = desc.clothing_tints.get(item_id, [])
|
||||
for mi in _collect_meshes(inst):
|
||||
mi.get_parent().remove_child(mi)
|
||||
mi.owner = null
|
||||
_apply_clothing_shader(mi, tints, mask_tex)
|
||||
_skeleton.add_child(mi)
|
||||
_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
|
||||
|
||||
# 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:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return {}
|
||||
var parsed := JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if parsed is Dictionary:
|
||||
return parsed
|
||||
push_warning("CharacterVisual: coverage.json malformed at %s" % path)
|
||||
return {}
|
||||
|
||||
|
||||
func _apply_clothing_shader(mi: MeshInstance3D, tints: Array, mask_tex: Texture2D = null) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
var tint: Color = tints[0] if not tints.is_empty() else Color(0.7, 0.65, 0.6)
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||||
var tex := _get_albedo_texture(orig)
|
||||
var mat := ShaderMaterial.new()
|
||||
if tex and _toon_masked_shader:
|
||||
mat.shader = _toon_masked_shader
|
||||
mat.set_shader_parameter("albedo_tex", tex)
|
||||
mat.set_shader_parameter("tint_color", tint)
|
||||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||||
if mask_tex:
|
||||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||||
elif _toon_shader:
|
||||
mat.shader = _toon_shader
|
||||
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)
|
||||
mi.set_surface_override_material(surf, mat)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — accessories (BoneAttachment3D per slot→bone)
|
||||
# =============================================================================
|
||||
|
||||
func _load_accessories(desc: CharacterVisualDescriptor) -> void:
|
||||
for slot: String in desc.accessory_slots:
|
||||
var item_id: String = desc.accessory_slots[slot]
|
||||
var bone_name: String = SLOT_TO_BONE.get(slot, "")
|
||||
if bone_name.is_empty():
|
||||
push_warning("CharacterVisual: unknown accessory slot '%s'" % slot)
|
||||
continue
|
||||
var path := BASE_PATH + "accessories/%s.glb" % item_id
|
||||
var tint: Color = desc.accessory_tints.get(item_id, Color.WHITE)
|
||||
if _attach_to_bone(path, bone_name, tint):
|
||||
# Track accessory attachments separately for get_accessory_node_count()
|
||||
_accessory_attachments.append(_bone_attachments.back())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — tinting (hair, accessories, bone-attached assets with tint)
|
||||
# =============================================================================
|
||||
|
||||
func _apply_tinted_shader(mi: MeshInstance3D, tint: Color, mask_tex: Texture2D = null) -> void:
|
||||
if mi.mesh == null:
|
||||
return
|
||||
for surf in range(mi.mesh.get_surface_count()):
|
||||
var orig: Material = mi.mesh.surface_get_material(surf)
|
||||
var tex := _get_albedo_texture(orig)
|
||||
var mat := ShaderMaterial.new()
|
||||
if tex and _toon_masked_shader:
|
||||
mat.shader = _toon_masked_shader
|
||||
mat.set_shader_parameter("albedo_tex", tex)
|
||||
mat.set_shader_parameter("tint_color", tint)
|
||||
mat.set_shader_parameter("shadow_strength", 0.2)
|
||||
if mask_tex:
|
||||
mat.set_shader_parameter("recolor_mask", mask_tex)
|
||||
elif _toon_shader:
|
||||
mat.shader = _toon_shader
|
||||
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)
|
||||
mi.set_surface_override_material(surf, mat)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal — outline pass (inverted hull, cull_front)
|
||||
# =============================================================================
|
||||
|
||||
func _rebuild_outlines() -> void:
|
||||
for node in _outline_nodes:
|
||||
if is_instance_valid(node):
|
||||
if node.get_parent():
|
||||
node.get_parent().remove_child(node)
|
||||
node.free()
|
||||
_outline_nodes.clear()
|
||||
|
||||
if _outline_shader == null:
|
||||
return
|
||||
|
||||
var outline_mat := ShaderMaterial.new()
|
||||
outline_mat.shader = _outline_shader
|
||||
outline_mat.set_shader_parameter("outline_color", OUTLINE_COLOR)
|
||||
outline_mat.set_shader_parameter("outline_width", OUTLINE_WIDTH)
|
||||
|
||||
var all_meshes: Array[MeshInstance3D] = []
|
||||
all_meshes.append_array(_body_meshes)
|
||||
all_meshes.append_array(_clothing_meshes)
|
||||
|
||||
for mi in all_meshes:
|
||||
if mi.mesh == null or not mi.visible:
|
||||
continue
|
||||
if mi.mesh.get_aabb().size.y < OUTLINE_SIZE_THRESHOLD:
|
||||
continue
|
||||
var outline_mi := mi.duplicate() as MeshInstance3D
|
||||
outline_mi.name = "_outline_%s" % mi.name
|
||||
outline_mi.material_override = outline_mat
|
||||
mi.get_parent().add_child(outline_mi)
|
||||
_outline_nodes.append(outline_mi)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Static helpers
|
||||
# =============================================================================
|
||||
|
||||
static func _find_skeleton(root: Node) -> Skeleton3D:
|
||||
if root is Skeleton3D:
|
||||
return root as Skeleton3D
|
||||
for child in root.get_children():
|
||||
var found := _find_skeleton(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
static func _collect_meshes(root: Node) -> Array[MeshInstance3D]:
|
||||
var result: Array[MeshInstance3D] = []
|
||||
if root is MeshInstance3D:
|
||||
result.append(root as MeshInstance3D)
|
||||
for child in root.get_children():
|
||||
result.append_array(_collect_meshes(child))
|
||||
return result
|
||||
|
||||
|
||||
static func _get_albedo_texture(mat: Material) -> Texture2D:
|
||||
if mat == null:
|
||||
return null
|
||||
if mat is StandardMaterial3D:
|
||||
return (mat as StandardMaterial3D).albedo_texture
|
||||
if mat is BaseMaterial3D:
|
||||
return (mat as BaseMaterial3D).albedo_texture
|
||||
if mat is ShaderMaterial:
|
||||
for p: String in ["albedo_tex", "albedo_texture", "texture_albedo"]:
|
||||
var val: Variant = (mat as ShaderMaterial).get_shader_parameter(p)
|
||||
if val is Texture2D:
|
||||
return val as Texture2D
|
||||
return null
|
||||
@@ -0,0 +1,210 @@
|
||||
class_name CharacterVisualDescriptor
|
||||
extends RefCounted
|
||||
## Data contract for character visual state: layer choices + color overrides.
|
||||
## Defines the server-to-client data struct for compositing a character's appearance.
|
||||
## MessagePack field names must match server/src/bridge/types.rs (rmp_serde named fields).
|
||||
## D-159 (11 body types), D-160 (segmented regions), D-161 (head separate mesh).
|
||||
## See docs/architecture/character-asset-organization.md Section 6.
|
||||
|
||||
enum BodyType {
|
||||
THIN_M,
|
||||
THIN_F,
|
||||
AVERAGE_M,
|
||||
AVERAGE_F,
|
||||
MUSCULAR_M,
|
||||
MUSCULAR_F,
|
||||
TEEN_M,
|
||||
TEEN_F,
|
||||
HEAVY_M,
|
||||
HEAVY_F,
|
||||
CHILD,
|
||||
}
|
||||
|
||||
## Maps BodyType enum → file key used in asset paths (e.g. "average_m").
|
||||
const BODY_TYPE_KEYS: Dictionary = {
|
||||
BodyType.THIN_M: "thin_m",
|
||||
BodyType.THIN_F: "thin_f",
|
||||
BodyType.AVERAGE_M: "average_m",
|
||||
BodyType.AVERAGE_F: "average_f",
|
||||
BodyType.MUSCULAR_M: "muscular_m",
|
||||
BodyType.MUSCULAR_F: "muscular_f",
|
||||
BodyType.TEEN_M: "teen_m",
|
||||
BodyType.TEEN_F: "teen_f",
|
||||
BodyType.HEAVY_M: "heavy_m",
|
||||
BodyType.HEAVY_F: "heavy_f",
|
||||
BodyType.CHILD: "child",
|
||||
}
|
||||
|
||||
## Maps wire string → BodyType for MessagePack decode (rmp_serde unit enum = bare string).
|
||||
const BODY_TYPE_FROM_WIRE: Dictionary = {
|
||||
"ThinM": BodyType.THIN_M,
|
||||
"ThinF": BodyType.THIN_F,
|
||||
"AverageM": BodyType.AVERAGE_M,
|
||||
"AverageF": BodyType.AVERAGE_F,
|
||||
"MuscularM": BodyType.MUSCULAR_M,
|
||||
"MuscularF": BodyType.MUSCULAR_F,
|
||||
"TeenM": BodyType.TEEN_M,
|
||||
"TeenF": BodyType.TEEN_F,
|
||||
"HeavyM": BodyType.HEAVY_M,
|
||||
"HeavyF": BodyType.HEAVY_F,
|
||||
"Child": BodyType.CHILD,
|
||||
}
|
||||
|
||||
## Maps BodyType → wire string for MessagePack encode.
|
||||
const BODY_TYPE_TO_WIRE: Dictionary = {
|
||||
BodyType.THIN_M: "ThinM",
|
||||
BodyType.THIN_F: "ThinF",
|
||||
BodyType.AVERAGE_M: "AverageM",
|
||||
BodyType.AVERAGE_F: "AverageF",
|
||||
BodyType.MUSCULAR_M: "MuscularM",
|
||||
BodyType.MUSCULAR_F: "MuscularF",
|
||||
BodyType.TEEN_M: "TeenM",
|
||||
BodyType.TEEN_F: "TeenF",
|
||||
BodyType.HEAVY_M: "HeavyM",
|
||||
BodyType.HEAVY_F: "HeavyF",
|
||||
BodyType.CHILD: "Child",
|
||||
}
|
||||
|
||||
# -- Fields (match wire format field names) --
|
||||
|
||||
## Body type — determines which mesh variant the compositor loads (D-159)
|
||||
var body_type: BodyType = BodyType.AVERAGE_M
|
||||
|
||||
## Head template ID — resolves to heads/templates/ (D-161)
|
||||
var head_id: String = ""
|
||||
|
||||
## Hair style key ("bob", "ponytail", "bald", etc.)
|
||||
var hair_id: String = ""
|
||||
var hair_tint: Color = Color.WHITE
|
||||
|
||||
## Facial hair style key; empty string = none
|
||||
var facial_hair_id: String = ""
|
||||
var facial_hair_tint: Color = Color.WHITE
|
||||
|
||||
## Eyebrow style key
|
||||
var eyebrow_id: String = ""
|
||||
var eyebrow_tint: Color = Color.WHITE
|
||||
|
||||
## Skin tone index 0–8 into the 9-tone palette (pale_cool → very_deep_cool)
|
||||
var skin_tone: int = 0
|
||||
|
||||
## Clothing slot → item_id ("torso" -> "coveralls_basic")
|
||||
var clothing_slots: Dictionary = {}
|
||||
|
||||
## item_id → Array[Color] for clothing colorable regions
|
||||
var clothing_tints: Dictionary = {}
|
||||
|
||||
## Accessory slot → item_id ("hat" -> "hat_hardhat")
|
||||
var accessory_slots: Dictionary = {}
|
||||
|
||||
## item_id → Color for accessories
|
||||
var accessory_tints: Dictionary = {}
|
||||
|
||||
|
||||
## Get the file key for the current body type (e.g. "average_m").
|
||||
func body_type_key() -> String:
|
||||
return BODY_TYPE_KEYS[body_type]
|
||||
|
||||
|
||||
## Decode from a MessagePack-decoded Dictionary (rmp_serde named fields).
|
||||
## Returns null if required fields are missing.
|
||||
static func from_dict(data: Dictionary) -> CharacterVisualDescriptor:
|
||||
if not data.has("body_type") or not data.has("head_id"):
|
||||
push_error("CharacterVisualDescriptor: missing required fields (body_type, head_id)")
|
||||
return null
|
||||
|
||||
var desc := CharacterVisualDescriptor.new()
|
||||
|
||||
# body_type: rmp_serde sends unit enum variants as bare strings
|
||||
var wire_bt: String = data["body_type"]
|
||||
if not BODY_TYPE_FROM_WIRE.has(wire_bt):
|
||||
push_error("CharacterVisualDescriptor: unknown body_type '%s'" % wire_bt)
|
||||
return null
|
||||
desc.body_type = BODY_TYPE_FROM_WIRE[wire_bt]
|
||||
|
||||
desc.head_id = data.get("head_id", "")
|
||||
desc.hair_id = data.get("hair_id", "")
|
||||
desc.hair_tint = _decode_color(data.get("hair_tint"), Color.WHITE)
|
||||
desc.facial_hair_id = data.get("facial_hair_id", "")
|
||||
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.skin_tone = data.get("skin_tone", 0)
|
||||
desc.clothing_slots = data.get("clothing_slots", {})
|
||||
desc.clothing_tints = _decode_tint_map(data.get("clothing_tints", {}))
|
||||
desc.accessory_slots = data.get("accessory_slots", {})
|
||||
desc.accessory_tints = _decode_single_tint_map(data.get("accessory_tints", {}))
|
||||
|
||||
return desc
|
||||
|
||||
|
||||
## Encode to a Dictionary matching rmp_serde named-field format.
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"body_type": BODY_TYPE_TO_WIRE[body_type],
|
||||
"head_id": head_id,
|
||||
"hair_id": hair_id,
|
||||
"hair_tint": _encode_color(hair_tint),
|
||||
"facial_hair_id": facial_hair_id,
|
||||
"facial_hair_tint": _encode_color(facial_hair_tint),
|
||||
"eyebrow_id": eyebrow_id,
|
||||
"eyebrow_tint": _encode_color(eyebrow_tint),
|
||||
"skin_tone": skin_tone,
|
||||
"clothing_slots": clothing_slots,
|
||||
"clothing_tints": _encode_tint_map(clothing_tints),
|
||||
"accessory_slots": accessory_slots,
|
||||
"accessory_tints": _encode_single_tint_map(accessory_tints),
|
||||
}
|
||||
|
||||
|
||||
# -- Color serialization helpers --
|
||||
# Wire format: [r, g, b, a] float array (rmp_serde serializes Color as tuple).
|
||||
|
||||
static func _decode_color(value: Variant, fallback: Color) -> Color:
|
||||
if value is Array and value.size() >= 3:
|
||||
return Color(value[0], value[1], value[2], value[3] if value.size() >= 4 else 1.0)
|
||||
return fallback
|
||||
|
||||
|
||||
static func _encode_color(c: Color) -> Array:
|
||||
return [c.r, c.g, c.b, c.a]
|
||||
|
||||
|
||||
## Decode clothing_tints: item_id → Array[Color] (multi-region recolor).
|
||||
static func _decode_tint_map(data: Dictionary) -> Dictionary:
|
||||
var result := {}
|
||||
for key: String in data:
|
||||
var colors: Array[Color] = []
|
||||
if data[key] is Array:
|
||||
for c in data[key]:
|
||||
colors.append(_decode_color(c, Color.WHITE))
|
||||
result[key] = colors
|
||||
return result
|
||||
|
||||
|
||||
## Encode clothing_tints to wire format.
|
||||
static func _encode_tint_map(data: Dictionary) -> Dictionary:
|
||||
var result := {}
|
||||
for key: String in data:
|
||||
var encoded: Array = []
|
||||
if data[key] is Array:
|
||||
for c: Color in data[key]:
|
||||
encoded.append(_encode_color(c))
|
||||
result[key] = encoded
|
||||
return result
|
||||
|
||||
|
||||
## Decode accessory_tints: item_id → single Color.
|
||||
static func _decode_single_tint_map(data: Dictionary) -> Dictionary:
|
||||
var result := {}
|
||||
for key: String in data:
|
||||
result[key] = _decode_color(data[key], Color.WHITE)
|
||||
return result
|
||||
|
||||
|
||||
## Encode accessory_tints to wire format.
|
||||
static func _encode_single_tint_map(data: Dictionary) -> Dictionary:
|
||||
var result := {}
|
||||
for key: String in data:
|
||||
result[key] = _encode_color(data[key])
|
||||
return result
|
||||
@@ -0,0 +1,386 @@
|
||||
## Sprint 28 — CharacterVisualDescriptor unit tests (#703)
|
||||
##
|
||||
## Validates the GDScript data contract for character visual state.
|
||||
## Tests cover: BodyType enum (D-159), default fields, field mutation,
|
||||
## body_type_key() helper, from_dict() / to_dict() round-trips, and
|
||||
## edge cases (missing fields, unknown body_type wire string).
|
||||
##
|
||||
## The Rust side (server/src/bridge/types.rs) is out of scope for this
|
||||
## client-team file — Rust tests live server-side.
|
||||
##
|
||||
## Spec: D-159 (11 body types), D-160 (21 segments), D-161 (head separate)
|
||||
## Ticket: #703
|
||||
class_name TestCharacterVisualDescriptorSprint28
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
# -- BodyType enum — 11 variants (D-159) --------------------------------------
|
||||
|
||||
func test_body_type_enum_has_11_variants() -> void:
|
||||
# D-159: 4 adult types × 2 genders + child = 11 total.
|
||||
var bt := CharacterVisualDescriptor.BodyType
|
||||
var variants := [
|
||||
bt.THIN_M, bt.THIN_F,
|
||||
bt.AVERAGE_M, bt.AVERAGE_F,
|
||||
bt.MUSCULAR_M, bt.MUSCULAR_F,
|
||||
bt.TEEN_M, bt.TEEN_F,
|
||||
bt.HEAVY_M, bt.HEAVY_F,
|
||||
bt.CHILD,
|
||||
]
|
||||
assert_int(variants.size()).override_failure_message(
|
||||
"BodyType enum must have exactly 11 variants per D-159"
|
||||
).is_equal(11)
|
||||
|
||||
|
||||
func test_body_type_all_variants_exist() -> void:
|
||||
# Each variant must be accessible without raising an error
|
||||
var bt := CharacterVisualDescriptor.BodyType
|
||||
assert_bool(bt.THIN_M >= 0).is_true()
|
||||
assert_bool(bt.THIN_F >= 0).is_true()
|
||||
assert_bool(bt.AVERAGE_M >= 0).is_true()
|
||||
assert_bool(bt.AVERAGE_F >= 0).is_true()
|
||||
assert_bool(bt.MUSCULAR_M >= 0).is_true()
|
||||
assert_bool(bt.MUSCULAR_F >= 0).is_true()
|
||||
assert_bool(bt.TEEN_M >= 0).is_true()
|
||||
assert_bool(bt.TEEN_F >= 0).is_true()
|
||||
assert_bool(bt.HEAVY_M >= 0).is_true()
|
||||
assert_bool(bt.HEAVY_F >= 0).is_true()
|
||||
assert_bool(bt.CHILD >= 0).is_true()
|
||||
|
||||
|
||||
# -- BODY_TYPE_KEYS lookup coverage -------------------------------------------
|
||||
|
||||
func test_body_type_keys_covers_all_variants() -> void:
|
||||
# BODY_TYPE_KEYS must have an entry for every BodyType variant
|
||||
var bt := CharacterVisualDescriptor.BodyType
|
||||
var keys := CharacterVisualDescriptor.BODY_TYPE_KEYS
|
||||
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
|
||||
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
|
||||
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
|
||||
assert_bool(keys.has(variant)).override_failure_message(
|
||||
"BODY_TYPE_KEYS missing variant %d" % variant
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_body_type_key_average_m_is_average_m() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
assert_str(d.body_type_key()).is_equal("average_m")
|
||||
|
||||
|
||||
func test_body_type_key_child_is_child() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.CHILD
|
||||
assert_str(d.body_type_key()).is_equal("child")
|
||||
|
||||
|
||||
func test_body_type_keys_all_lowercase_snake_case() -> void:
|
||||
# File keys must match filesystem snake_case convention
|
||||
for key: String in CharacterVisualDescriptor.BODY_TYPE_KEYS.values():
|
||||
assert_str(key).is_equal(key.to_lower())
|
||||
|
||||
|
||||
# -- Wire format dictionaries -------------------------------------------------
|
||||
|
||||
func test_body_type_from_wire_covers_all_11_variants() -> void:
|
||||
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
|
||||
assert_int(from_wire.size()).override_failure_message(
|
||||
"BODY_TYPE_FROM_WIRE must have 11 entries (one per BodyType variant)"
|
||||
).is_equal(11)
|
||||
|
||||
|
||||
func test_body_type_to_wire_covers_all_11_variants() -> void:
|
||||
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
|
||||
assert_int(to_wire.size()).override_failure_message(
|
||||
"BODY_TYPE_TO_WIRE must have 11 entries"
|
||||
).is_equal(11)
|
||||
|
||||
|
||||
func test_wire_round_trip_all_body_types() -> void:
|
||||
# from_wire → to_wire must be the identity for every variant
|
||||
var from_wire := CharacterVisualDescriptor.BODY_TYPE_FROM_WIRE
|
||||
var to_wire := CharacterVisualDescriptor.BODY_TYPE_TO_WIRE
|
||||
for wire_str: String in from_wire:
|
||||
var bt = from_wire[wire_str]
|
||||
assert_str(to_wire[bt]).override_failure_message(
|
||||
"to_wire(from_wire('%s')) should equal '%s'" % [wire_str, wire_str]
|
||||
).is_equal(wire_str)
|
||||
|
||||
|
||||
# -- Default field values -----------------------------------------------------
|
||||
|
||||
func test_construction_does_not_crash() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_bool(d != null).is_true()
|
||||
|
||||
|
||||
func test_default_body_type_is_average_m() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_M)
|
||||
|
||||
|
||||
func test_default_head_id_is_empty_string() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_str(d.head_id).is_equal("")
|
||||
|
||||
|
||||
func test_default_hair_id_is_empty_string() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_str(d.hair_id).is_equal("")
|
||||
|
||||
|
||||
func test_default_hair_tint_is_white() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_bool(d.hair_tint == Color.WHITE).override_failure_message(
|
||||
"Default hair_tint should be Color.WHITE"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_default_facial_hair_id_is_empty_string() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_str(d.facial_hair_id).is_equal("")
|
||||
|
||||
|
||||
func test_default_eyebrow_id_is_empty_string() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_str(d.eyebrow_id).is_equal("")
|
||||
|
||||
|
||||
func test_default_eyebrow_tint_is_white() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_bool(d.eyebrow_tint == Color.WHITE).is_true()
|
||||
|
||||
|
||||
func test_default_skin_tone_is_zero() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_int(d.skin_tone).is_equal(0)
|
||||
|
||||
|
||||
func test_default_dicts_are_empty() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
assert_int(d.clothing_slots.size()).is_equal(0)
|
||||
assert_int(d.clothing_tints.size()).is_equal(0)
|
||||
assert_int(d.accessory_slots.size()).is_equal(0)
|
||||
assert_int(d.accessory_tints.size()).is_equal(0)
|
||||
|
||||
|
||||
# -- Mutation round-trips -----------------------------------------------------
|
||||
|
||||
func test_body_type_can_be_set_to_all_variants() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
var bt := CharacterVisualDescriptor.BodyType
|
||||
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
|
||||
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
|
||||
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
|
||||
d.body_type = variant
|
||||
assert_int(d.body_type).is_equal(variant)
|
||||
|
||||
|
||||
func test_head_id_round_trips() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.head_id = "head_042"
|
||||
assert_str(d.head_id).is_equal("head_042")
|
||||
|
||||
|
||||
func test_hair_id_round_trips() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.hair_id = "ponytail"
|
||||
assert_str(d.hair_id).is_equal("ponytail")
|
||||
|
||||
|
||||
func test_skin_tone_accepts_full_range_0_to_8() -> void:
|
||||
# 9-tone palette: valid indices 0–8
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
for i in range(9):
|
||||
d.skin_tone = i
|
||||
assert_int(d.skin_tone).is_equal(i)
|
||||
|
||||
|
||||
func test_clothing_slots_can_be_populated() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.clothing_slots["torso"] = "coveralls_basic"
|
||||
d.clothing_slots["legs"] = "trousers_01"
|
||||
assert_int(d.clothing_slots.size()).is_equal(2)
|
||||
assert_str(d.clothing_slots["torso"]).is_equal("coveralls_basic")
|
||||
|
||||
|
||||
func test_accessory_slots_can_be_populated() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.accessory_slots["hat"] = "hat_hardhat"
|
||||
assert_str(d.accessory_slots["hat"]).is_equal("hat_hardhat")
|
||||
|
||||
|
||||
# -- from_dict() / to_dict() round-trips --------------------------------------
|
||||
|
||||
func test_from_dict_minimal_valid_input() -> void:
|
||||
# Only required fields; optional fields fall back to defaults
|
||||
var data := {
|
||||
"body_type": "AverageF",
|
||||
"head_id": "head_007",
|
||||
}
|
||||
var d := CharacterVisualDescriptor.from_dict(data)
|
||||
assert_bool(d != null).override_failure_message(
|
||||
"from_dict must succeed with required fields present"
|
||||
).is_true()
|
||||
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
|
||||
assert_str(d.head_id).is_equal("head_007")
|
||||
|
||||
|
||||
func test_from_dict_all_fields() -> void:
|
||||
var data := {
|
||||
"body_type": "MuscularM",
|
||||
"head_id": "head_003",
|
||||
"hair_id": "buzz",
|
||||
"hair_tint": [0.2, 0.15, 0.1, 1.0],
|
||||
"facial_hair_id": "stubble",
|
||||
"facial_hair_tint": [0.1, 0.08, 0.06, 1.0],
|
||||
"eyebrow_id": "thick",
|
||||
"eyebrow_tint": [0.1, 0.08, 0.06, 1.0],
|
||||
"skin_tone": 4,
|
||||
"clothing_slots": {"torso": "shirt_02"},
|
||||
"clothing_tints": {},
|
||||
"accessory_slots": {},
|
||||
"accessory_tints": {},
|
||||
}
|
||||
var d := CharacterVisualDescriptor.from_dict(data)
|
||||
assert_bool(d != null).is_true()
|
||||
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.MUSCULAR_M)
|
||||
assert_str(d.hair_id).is_equal("buzz")
|
||||
assert_str(d.facial_hair_id).is_equal("stubble")
|
||||
assert_int(d.skin_tone).is_equal(4)
|
||||
assert_str(d.clothing_slots.get("torso", "")).is_equal("shirt_02")
|
||||
|
||||
|
||||
func test_from_dict_missing_body_type_returns_null() -> void:
|
||||
# Required field missing — must return null (not crash)
|
||||
var data := {"head_id": "head_001"}
|
||||
var d := CharacterVisualDescriptor.from_dict(data)
|
||||
assert_bool(d == null).override_failure_message(
|
||||
"from_dict must return null when body_type is absent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_from_dict_missing_head_id_returns_null() -> void:
|
||||
var data := {"body_type": "AverageM"}
|
||||
var d := CharacterVisualDescriptor.from_dict(data)
|
||||
assert_bool(d == null).override_failure_message(
|
||||
"from_dict must return null when head_id is absent"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_from_dict_unknown_body_type_returns_null() -> void:
|
||||
# Unknown wire string — must return null, not crash or silently corrupt
|
||||
var data := {"body_type": "INVALID_TYPE", "head_id": "head_001"}
|
||||
var d := CharacterVisualDescriptor.from_dict(data)
|
||||
assert_bool(d == null).override_failure_message(
|
||||
"from_dict must return null for unknown body_type wire string"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_to_dict_produces_string_body_type() -> void:
|
||||
# Wire format: body_type must be a string, not an integer
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.THIN_F
|
||||
var wire := d.to_dict()
|
||||
assert_bool(wire["body_type"] is String).override_failure_message(
|
||||
"to_dict body_type must be a wire string, not an enum int"
|
||||
).is_true()
|
||||
assert_str(wire["body_type"]).is_equal("ThinF")
|
||||
|
||||
|
||||
func test_full_round_trip_to_dict_from_dict() -> void:
|
||||
# Build a descriptor, encode, decode — all fields must survive
|
||||
var orig := CharacterVisualDescriptor.new()
|
||||
orig.body_type = CharacterVisualDescriptor.BodyType.HEAVY_F
|
||||
orig.head_id = "head_099"
|
||||
orig.hair_id = "long"
|
||||
orig.hair_tint = Color(0.5, 0.3, 0.1, 1.0)
|
||||
orig.facial_hair_id = ""
|
||||
orig.eyebrow_id = "thin"
|
||||
orig.skin_tone = 7
|
||||
orig.clothing_slots = {"torso": "jacket_01"}
|
||||
|
||||
var wire := orig.to_dict()
|
||||
var restored := CharacterVisualDescriptor.from_dict(wire)
|
||||
|
||||
assert_bool(restored != null).is_true()
|
||||
assert_int(restored.body_type).is_equal(CharacterVisualDescriptor.BodyType.HEAVY_F)
|
||||
assert_str(restored.head_id).is_equal("head_099")
|
||||
assert_str(restored.hair_id).is_equal("long")
|
||||
assert_int(restored.skin_tone).is_equal(7)
|
||||
assert_str(restored.clothing_slots.get("torso", "")).is_equal("jacket_01")
|
||||
|
||||
|
||||
func test_to_dict_hair_tint_encodes_as_float_array() -> void:
|
||||
# Color must encode as [r, g, b, a] float array for rmp_serde
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.hair_tint = Color(0.8, 0.3, 0.1, 1.0)
|
||||
var wire := d.to_dict()
|
||||
var tint = wire["hair_tint"]
|
||||
assert_bool(tint is Array).override_failure_message(
|
||||
"hair_tint in wire must be Array[float], not Color"
|
||||
).is_true()
|
||||
assert_int((tint as Array).size()).is_equal(4)
|
||||
|
||||
|
||||
# -- Edge cases ---------------------------------------------------------------
|
||||
|
||||
func test_separate_instances_have_independent_dicts() -> void:
|
||||
# Dictionary defaults must not be shared between instances (aliasing bug)
|
||||
var a := CharacterVisualDescriptor.new()
|
||||
var b := CharacterVisualDescriptor.new()
|
||||
a.clothing_slots["torso"] = "shirt_a"
|
||||
assert_bool("torso" in b.clothing_slots).override_failure_message(
|
||||
"clothing_slots must be independent across instances — not shared"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_skin_tone_boundary_min_zero() -> void:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.skin_tone = 0
|
||||
assert_int(d.skin_tone).is_equal(0)
|
||||
|
||||
|
||||
func test_skin_tone_boundary_max_eight() -> void:
|
||||
# 9-tone palette: index 8 is the maximum (very_deep_cool or very_deep_warm)
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.skin_tone = 8
|
||||
assert_int(d.skin_tone).is_equal(8)
|
||||
|
||||
|
||||
func test_facial_hair_empty_string_means_none() -> void:
|
||||
# Spec: "empty string = none" for facial_hair_id
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.facial_hair_id = ""
|
||||
assert_str(d.facial_hair_id).is_equal("")
|
||||
|
||||
|
||||
func test_hardcoded_descriptor_matches_ticket_acceptance_criterion() -> void:
|
||||
# Ticket #703: "A hardcoded test descriptor can be constructed in GDScript
|
||||
# and matched to a Rust struct without panics."
|
||||
# This validates the GDScript half. Rust half lives in server-team tests.
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
|
||||
d.head_id = "head_001"
|
||||
d.hair_id = "bob"
|
||||
d.hair_tint = Color(0.4, 0.25, 0.1, 1.0)
|
||||
d.facial_hair_id = ""
|
||||
d.eyebrow_id = "thin"
|
||||
d.eyebrow_tint = Color(0.3, 0.2, 0.1, 1.0)
|
||||
d.skin_tone = 2
|
||||
d.clothing_slots = {"torso": "coveralls_basic", "legs": "trousers_basic"}
|
||||
d.clothing_tints = {"coveralls_basic": [Color.DARK_SLATE_GRAY]}
|
||||
d.accessory_slots = {}
|
||||
d.accessory_tints = {}
|
||||
|
||||
assert_int(d.body_type).is_equal(CharacterVisualDescriptor.BodyType.AVERAGE_F)
|
||||
assert_str(d.head_id).is_equal("head_001")
|
||||
assert_str(d.hair_id).is_equal("bob")
|
||||
assert_int(d.skin_tone).is_equal(2)
|
||||
assert_int(d.clothing_slots.size()).is_equal(2)
|
||||
assert_int(d.accessory_slots.size()).is_equal(0)
|
||||
|
||||
# And it must encode to a valid wire dict without crashing
|
||||
var wire := d.to_dict()
|
||||
assert_bool(wire.has("body_type")).is_true()
|
||||
assert_str(wire["body_type"]).is_equal("AverageF")
|
||||
@@ -0,0 +1,553 @@
|
||||
## Sprint 28 — CharacterVisual compositor tests (#704)
|
||||
##
|
||||
## Validates the character visual assembler (CharacterVisual.gd) against the
|
||||
## architectural spec at docs/architecture/character-asset-organization.md.
|
||||
##
|
||||
## Most tests are test-first stubs: they push_warning and return when the
|
||||
## compositor class or required asset files are not yet present. Once Stig's
|
||||
## implementation lands, the guards are removed automatically.
|
||||
##
|
||||
## Test areas (per team lead focus list):
|
||||
## 1. Descriptor-to-node-tree construction
|
||||
## 2. Segment visibility toggling for clothing coverage
|
||||
## 3. Torso variant swap (seg_torso vs seg_torso_upper)
|
||||
## 4. Tint application (skin tone index → texture)
|
||||
## 5. Facing rotation (set_facing / rotation change)
|
||||
## 6. Edge cases: empty hair_id, no clothing, no accessories
|
||||
##
|
||||
## Spec: D-159 (body types), D-160 (15+2+2 segments), D-161 (head on Head bone),
|
||||
## D-162 (clothing pre-baked per body type), D-164 (fork skeleton)
|
||||
## Ticket: #704
|
||||
class_name TestCharacterVisualSprint28
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const COMPOSITOR_PATH := "res://scripts/rendering/character_visual.gd"
|
||||
const SKIN_TONES_DIR := "res://assets/characters/skin_tones/"
|
||||
const BODIES_DIR := "res://assets/characters/bodies/"
|
||||
|
||||
const SKELETON_PATH := "res://assets/characters/skeleton/armature.glb"
|
||||
|
||||
## Returns a minimal valid descriptor with no optional layers.
|
||||
func _make_minimal_descriptor() -> CharacterVisualDescriptor:
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
d.head_id = "head_001"
|
||||
d.hair_id = "" # empty = no hair
|
||||
d.facial_hair_id = "" # empty = none
|
||||
d.eyebrow_id = "" # empty = no eyebrows
|
||||
d.skin_tone = 0
|
||||
return d
|
||||
|
||||
## Returns true if the compositor script is present and loadable.
|
||||
func _compositor_available() -> bool:
|
||||
if not ResourceLoader.exists(COMPOSITOR_PATH):
|
||||
push_warning("TestCharacterVisualSprint28: character_visual.gd not found — test-first stub (awaiting #704)")
|
||||
return false
|
||||
return true
|
||||
|
||||
## Returns true if the skeleton GLB is present (required for segment/clothing tests).
|
||||
func _skeleton_available() -> bool:
|
||||
return ResourceLoader.exists(SKELETON_PATH)
|
||||
|
||||
## Loads and instantiates a CharacterVisual node. Returns null with warning if unavailable.
|
||||
func _make_compositor() -> Node:
|
||||
if not _compositor_available():
|
||||
return null
|
||||
var script: GDScript = load(COMPOSITOR_PATH)
|
||||
if script == null:
|
||||
push_warning("TestCharacterVisualSprint28: failed to load character_visual.gd")
|
||||
return null
|
||||
var node := Node3D.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
func after_test() -> void:
|
||||
# Clean up any nodes added during testing
|
||||
for child in get_children():
|
||||
if child != self:
|
||||
child.queue_free()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. API presence — compositor class exists and has expected public interface
|
||||
# =============================================================================
|
||||
|
||||
func test_compositor_script_file_exists() -> void:
|
||||
# Gate: is the file present at all?
|
||||
if not ResourceLoader.exists(COMPOSITOR_PATH):
|
||||
push_warning("TestCharacterVisualSprint28: character_visual.gd missing — test-first stub")
|
||||
return
|
||||
assert_bool(ResourceLoader.exists(COMPOSITOR_PATH)).is_true()
|
||||
|
||||
|
||||
func test_compositor_has_load_descriptor_method() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
assert_bool(node.has_method("load_descriptor")).override_failure_message(
|
||||
"CharacterVisual must expose load_descriptor(desc: CharacterVisualDescriptor)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_compositor_has_set_facing_method() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
assert_bool(node.has_method("set_facing")).override_failure_message(
|
||||
"CharacterVisual must expose set_facing(direction: Vector2 or int)"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_compositor_is_node3d() -> void:
|
||||
# Compositor must be a Node3D (3D scene tree, not 2D)
|
||||
if not _compositor_available():
|
||||
return
|
||||
var script: GDScript = load(COMPOSITOR_PATH)
|
||||
var node := Node3D.new()
|
||||
node.set_script(script)
|
||||
add_child(node)
|
||||
assert_bool(node is Node3D).override_failure_message(
|
||||
"CharacterVisual must extend Node3D"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. Descriptor-to-node-tree construction
|
||||
# =============================================================================
|
||||
|
||||
func test_load_descriptor_does_not_crash_with_minimal_desc() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
var desc := _make_minimal_descriptor()
|
||||
# Must not crash — assets may be missing but the call must not throw
|
||||
node.load_descriptor(desc)
|
||||
assert_bool(true).is_true() # If we got here, no crash
|
||||
|
||||
|
||||
func test_load_descriptor_produces_child_nodes() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
var desc := _make_minimal_descriptor()
|
||||
node.load_descriptor(desc)
|
||||
# A fully loaded compositor must have at least one child (body skeleton or armature)
|
||||
# Guard: skip if assets genuinely not present yet (real test once assets land)
|
||||
if node.get_child_count() == 0:
|
||||
push_warning("TestCharacterVisualSprint28: no child nodes after load_descriptor — assets may not be imported yet")
|
||||
return
|
||||
assert_bool(node.get_child_count() > 0).is_true()
|
||||
|
||||
|
||||
func test_reload_descriptor_clears_previous_nodes() -> void:
|
||||
# Loading a second descriptor must replace the first, not accumulate nodes
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
var desc1 := _make_minimal_descriptor()
|
||||
desc1.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M
|
||||
var desc2 := _make_minimal_descriptor()
|
||||
desc2.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_F
|
||||
|
||||
node.load_descriptor(desc1)
|
||||
var count_after_first := node.get_child_count()
|
||||
node.load_descriptor(desc2)
|
||||
var count_after_second := node.get_child_count()
|
||||
|
||||
if count_after_first == 0:
|
||||
push_warning("TestCharacterVisualSprint28: node count 0 — assets may not be present yet")
|
||||
return
|
||||
# Second load must not double the children (would indicate no cleanup)
|
||||
assert_bool(count_after_second <= count_after_first * 2).override_failure_message(
|
||||
"load_descriptor must clear previous node tree before building new one — children doubled, suggesting stale nodes were kept"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. Segment visibility toggling for clothing coverage
|
||||
# =============================================================================
|
||||
|
||||
func test_no_clothing_all_body_segments_visible() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not _skeleton_available():
|
||||
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
|
||||
return
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {} # no clothing
|
||||
node.load_descriptor(desc)
|
||||
# All non-swapped segments should be visible with no clothing.
|
||||
# is_segment_visible returns false for missing segments — only meaningful when skeleton loaded.
|
||||
assert_bool(node.is_segment_visible("torso")).override_failure_message(
|
||||
"seg_torso must be visible when no clothing covers it"
|
||||
).is_true()
|
||||
assert_bool(node.is_segment_visible("arm_upper_l")).is_true()
|
||||
assert_bool(node.is_segment_visible("leg_upper_l")).is_true()
|
||||
|
||||
|
||||
func test_clothing_hides_declared_segments() -> void:
|
||||
# A full coverall with hides = [torso, arm_upper_l, arm_upper_r, ...] must
|
||||
# hide those segments
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("is_segment_visible"):
|
||||
push_warning("TestCharacterVisualSprint28: is_segment_visible not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
# Edge case: coverage.json may not exist yet — guard
|
||||
if not node.has_method("get_active_coverage"):
|
||||
push_warning("TestCharacterVisualSprint28: coverage integration not yet wired — skipping segment hide check")
|
||||
return
|
||||
|
||||
# Coveralls should hide the torso segment (among others)
|
||||
# The exact hidden set is declared in coverage.json — we test the contract, not the file content
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic")
|
||||
if coverage.is_empty():
|
||||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||||
return
|
||||
|
||||
for hidden_seg: String in coverage.get("hides", []):
|
||||
assert_bool(node.is_segment_visible(hidden_seg)).override_failure_message(
|
||||
"Segment '%s' declared in coverage.json hides must not be visible" % hidden_seg
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_segments_not_in_hides_remain_visible() -> void:
|
||||
# A jacket (upper body only) must NOT hide leg segments
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not _skeleton_available():
|
||||
push_warning("TestCharacterVisualSprint28: skeleton not found — segment visibility tests require #706 assets")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {"torso": "jacket_utility"} # upper body only
|
||||
node.load_descriptor(desc)
|
||||
|
||||
var coverage: Dictionary = node.get_active_coverage("jacket_utility") if node.has_method("get_active_coverage") else {}
|
||||
if coverage.is_empty():
|
||||
push_warning("TestCharacterVisualSprint28: jacket_utility/coverage.json not found — stub")
|
||||
return
|
||||
|
||||
var hides: Array = coverage.get("hides", [])
|
||||
if not "leg_upper_l" in hides:
|
||||
# Jacket doesn't cover legs — legs must still be visible
|
||||
assert_bool(node.is_segment_visible("leg_upper_l")).override_failure_message(
|
||||
"Leg segments must remain visible when only a jacket is worn"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. Torso variant swap
|
||||
# =============================================================================
|
||||
|
||||
func test_torso_variant_full_uses_seg_torso() -> void:
|
||||
# coverage.json torso_variant: "full" → seg_torso is loaded (default)
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("get_active_torso_variant"):
|
||||
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub (awaiting #704)")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {} # no clothing → default torso
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_str(node.get_active_torso_variant()).override_failure_message(
|
||||
"With no clothing, torso variant must be 'full' (uses seg_torso)"
|
||||
).is_equal("full")
|
||||
|
||||
|
||||
func test_torso_variant_upper_uses_seg_torso_upper() -> void:
|
||||
# coverage.json torso_variant: "upper" → seg_torso_upper is loaded
|
||||
# (e.g. tank top: exposes lower abdomen)
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("get_active_torso_variant"):
|
||||
push_warning("TestCharacterVisualSprint28: get_active_torso_variant not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {"torso": "shirt_tank"} # a tank top with torso_variant: "upper"
|
||||
node.load_descriptor(desc)
|
||||
|
||||
var coverage: Dictionary = node.get_active_coverage("shirt_tank") if node.has_method("get_active_coverage") else {}
|
||||
if coverage.is_empty() or coverage.get("torso_variant") != "upper":
|
||||
push_warning("TestCharacterVisualSprint28: shirt_tank/coverage.json not found or no upper variant — stub")
|
||||
return
|
||||
|
||||
assert_str(node.get_active_torso_variant()).override_failure_message(
|
||||
"Tank top with torso_variant:'upper' must load seg_torso_upper"
|
||||
).is_equal("upper")
|
||||
|
||||
|
||||
func test_torso_hidden_when_full_coverage_clothing_worn() -> void:
|
||||
# A full coverall hides the torso segment entirely (not swapped to upper)
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not _skeleton_available():
|
||||
push_warning("TestCharacterVisualSprint28: skeleton not found — torso hide test requires #706 assets")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {"torso": "coveralls_basic"}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
var coverage: Dictionary = node.get_active_coverage("coveralls_basic") if node.has_method("get_active_coverage") else {}
|
||||
if coverage.is_empty():
|
||||
push_warning("TestCharacterVisualSprint28: coveralls_basic/coverage.json not found — stub")
|
||||
return
|
||||
|
||||
if "torso" in coverage.get("hides", []):
|
||||
# Full coverage: torso segment is hidden, not swapped
|
||||
assert_bool(node.is_segment_visible("torso")).override_failure_message(
|
||||
"Full-coverage clothing with 'torso' in hides must hide seg_torso entirely"
|
||||
).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 5. Facing rotation
|
||||
# =============================================================================
|
||||
|
||||
func test_set_facing_changes_node_rotation() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
# set_facing only writes rotation.y — no asset dependency, no load_descriptor needed
|
||||
var rot_before: float = node.rotation.y
|
||||
node.set_facing(Vector2(1, 0)) # east / right
|
||||
var rot_after: float = node.rotation.y
|
||||
# Rotation must change when facing changes (exact value is implementation-defined)
|
||||
# We only check that it differs — not the exact angle
|
||||
# (Different implementations may use 4-dir or 8-dir mappings)
|
||||
# Guard: allow no-op only if facing east == default (unlikely)
|
||||
if rot_before == rot_after:
|
||||
# Try a definitely-different facing
|
||||
node.set_facing(Vector2(-1, 0)) # west
|
||||
var rot_west: float = node.rotation.y
|
||||
assert_bool(rot_west != rot_before).override_failure_message(
|
||||
"set_facing must rotate the node — east and west must produce different rotations"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_set_facing_opposite_directions_differ() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
# set_facing only writes rotation.y — no asset dependency
|
||||
node.set_facing(Vector2(0, -1)) # north
|
||||
var rot_north: float = node.rotation.y
|
||||
node.set_facing(Vector2(0, 1)) # south
|
||||
var rot_south: float = node.rotation.y
|
||||
|
||||
assert_bool(rot_north != rot_south).override_failure_message(
|
||||
"North and south facing must produce different rotation values"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 6. Edge cases
|
||||
# =============================================================================
|
||||
|
||||
func test_empty_hair_id_adds_no_hair_node() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("has_slot_node"):
|
||||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub (awaiting #704)")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.hair_id = "" # explicit empty = no hair
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_bool(node.has_slot_node("hair")).override_failure_message(
|
||||
"Empty hair_id must not add a hair node to the character tree"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_bald_hair_id_adds_no_hair_node() -> void:
|
||||
# "bald" is the explicit key for no-hair — treated identically to empty string.
|
||||
# Stig: `if desc.hair_id.is_empty() or desc.hair_id == "bald": return`
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("has_slot_node"):
|
||||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.hair_id = "bald"
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_bool(node.has_slot_node("hair")).override_failure_message(
|
||||
"hair_id='bald' must not add a hair node (bald is the explicit no-hair sentinel)"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_empty_facial_hair_id_adds_no_facial_hair_node() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("has_slot_node"):
|
||||
push_warning("TestCharacterVisualSprint28: has_slot_node not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.facial_hair_id = ""
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_bool(node.has_slot_node("facial_hair")).override_failure_message(
|
||||
"Empty facial_hair_id must not add a facial hair node"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_no_clothing_slots_leaves_no_clothing_nodes() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("get_clothing_node_count"):
|
||||
push_warning("TestCharacterVisualSprint28: get_clothing_node_count not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.clothing_slots = {}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_int(node.get_clothing_node_count()).override_failure_message(
|
||||
"No clothing slots in descriptor must produce zero clothing nodes in the scene tree"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_no_accessories_adds_no_accessory_nodes() -> void:
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("get_accessory_node_count"):
|
||||
push_warning("TestCharacterVisualSprint28: get_accessory_node_count not found — stub")
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.accessory_slots = {}
|
||||
node.load_descriptor(desc)
|
||||
|
||||
assert_int(node.get_accessory_node_count()).override_failure_message(
|
||||
"No accessory slots in descriptor must produce zero accessory nodes"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_missing_asset_file_does_not_crash() -> void:
|
||||
# If an asset file referenced by the descriptor is missing from disk,
|
||||
# the compositor must degrade gracefully (skip the missing slot, not crash).
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.hair_id = "hair_does_not_exist_zzz" # guaranteed non-existent
|
||||
# Must not crash — should either skip the slot or substitute a placeholder
|
||||
node.load_descriptor(desc)
|
||||
assert_bool(true).is_true() # survived = pass
|
||||
|
||||
|
||||
func test_all_11_body_types_load_without_crash() -> void:
|
||||
# Each of the 11 body types must not cause load_descriptor to crash.
|
||||
# Assets may be missing — but the compositor must handle it gracefully.
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
|
||||
var bt := CharacterVisualDescriptor.BodyType
|
||||
for variant in [bt.THIN_M, bt.THIN_F, bt.AVERAGE_M, bt.AVERAGE_F,
|
||||
bt.MUSCULAR_M, bt.MUSCULAR_F, bt.TEEN_M, bt.TEEN_F,
|
||||
bt.HEAVY_M, bt.HEAVY_F, bt.CHILD]:
|
||||
var desc := _make_minimal_descriptor()
|
||||
desc.body_type = variant
|
||||
node.load_descriptor(desc)
|
||||
# If we get here without a crash, all 11 body types were handled
|
||||
assert_bool(true).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 7. Skin tone application
|
||||
# =============================================================================
|
||||
|
||||
func test_skin_tone_index_resolves_to_correct_texture_name() -> void:
|
||||
# The 9 skin tone textures are indexed 0–8 in this order (per architecture doc):
|
||||
# 0:pale_cool, 1:pale_warm, 2:light_olive, 3:medium_golden,
|
||||
# 4:olive_warm, 5:medium_brown, 6:deep_brown, 7:very_deep_warm, 8:very_deep_cool
|
||||
var node := _make_compositor()
|
||||
if node == null:
|
||||
return
|
||||
if not node.has_method("get_skin_tone_texture_name"):
|
||||
push_warning("TestCharacterVisualSprint28: get_skin_tone_texture_name not found — stub (awaiting #704)")
|
||||
return
|
||||
|
||||
# Spot-check a few indices
|
||||
assert_str(node.get_skin_tone_texture_name(0)).override_failure_message(
|
||||
"Skin tone index 0 must resolve to pale_cool.png"
|
||||
).is_equal("pale_cool")
|
||||
assert_str(node.get_skin_tone_texture_name(8)).override_failure_message(
|
||||
"Skin tone index 8 must resolve to very_deep_cool.png"
|
||||
).is_equal("very_deep_cool")
|
||||
|
||||
|
||||
func test_skin_tone_textures_exist_on_disk() -> void:
|
||||
# Verify all 9 expected skin tone PNGs are present (copied from spike in #702)
|
||||
var expected := [
|
||||
"pale_cool.png", "pale_warm.png", "light_olive.png", "medium_golden.png",
|
||||
"olive_warm.png", "medium_brown.png", "deep_brown.png",
|
||||
"very_deep_warm.png", "very_deep_cool.png",
|
||||
]
|
||||
for filename: String in expected:
|
||||
var path := SKIN_TONES_DIR + filename
|
||||
assert_bool(ResourceLoader.exists(path)).override_failure_message(
|
||||
"Skin tone texture missing: %s (should have been copied in #702)" % path
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 8. Path construction invariants (pure logic, no assets required)
|
||||
# =============================================================================
|
||||
|
||||
func test_body_type_key_produces_expected_path_fragment() -> void:
|
||||
# Confirm that CharacterVisualDescriptor.body_type_key() returns the right
|
||||
# string for use in path construction (this is the bridge between descriptor and compositor)
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
d.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F
|
||||
assert_str(d.body_type_key()).is_equal("muscular_f")
|
||||
|
||||
|
||||
func test_all_body_type_keys_match_expected_directory_names() -> void:
|
||||
# The compositor derives paths as bodies/{body_type_key}/seg_{segment}.glb
|
||||
# Every key must be lowercase and match the expected directory name convention
|
||||
var expected := {
|
||||
CharacterVisualDescriptor.BodyType.THIN_M: "thin_m",
|
||||
CharacterVisualDescriptor.BodyType.THIN_F: "thin_f",
|
||||
CharacterVisualDescriptor.BodyType.AVERAGE_M: "average_m",
|
||||
CharacterVisualDescriptor.BodyType.AVERAGE_F: "average_f",
|
||||
CharacterVisualDescriptor.BodyType.MUSCULAR_M: "muscular_m",
|
||||
CharacterVisualDescriptor.BodyType.MUSCULAR_F: "muscular_f",
|
||||
CharacterVisualDescriptor.BodyType.TEEN_M: "teen_m",
|
||||
CharacterVisualDescriptor.BodyType.TEEN_F: "teen_f",
|
||||
CharacterVisualDescriptor.BodyType.HEAVY_M: "heavy_m",
|
||||
CharacterVisualDescriptor.BodyType.HEAVY_F: "heavy_f",
|
||||
CharacterVisualDescriptor.BodyType.CHILD: "child",
|
||||
}
|
||||
var d := CharacterVisualDescriptor.new()
|
||||
for body_type: int in expected:
|
||||
d.body_type = body_type
|
||||
assert_str(d.body_type_key()).override_failure_message(
|
||||
"BodyType %d must produce key '%s'" % [body_type, expected[body_type]]
|
||||
).is_equal(expected[body_type])
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
blender_compare_bones.py
|
||||
Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <file_a.glb> <file_b.glb>
|
||||
|
||||
Compares bone names between two GLB/GLTF files. Reports missing or extra bones.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
def get_bones(path: str) -> set:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
return {b.name for b in obj.data.bones}
|
||||
return set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_compare_bones.py -- <a.glb> <b.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Need two GLB/GLTF paths.")
|
||||
sys.exit(1)
|
||||
|
||||
path_a, path_b = args[0], args[1]
|
||||
|
||||
print(f"Loading A: {path_a}")
|
||||
bones_a = get_bones(path_a)
|
||||
print(f"A has {len(bones_a)} bones")
|
||||
|
||||
print(f"Loading B: {path_b}")
|
||||
bones_b = get_bones(path_b)
|
||||
print(f"B has {len(bones_b)} bones")
|
||||
|
||||
only_in_a = bones_a - bones_b
|
||||
only_in_b = bones_b - bones_a
|
||||
shared = bones_a & bones_b
|
||||
|
||||
print(f"Shared: {len(shared)}")
|
||||
if only_in_a:
|
||||
print(f"Only in A ({len(only_in_a)}):")
|
||||
for name in sorted(only_in_a):
|
||||
print(f" - {name}")
|
||||
if only_in_b:
|
||||
print(f"Only in B ({len(only_in_b)}):")
|
||||
for name in sorted(only_in_b):
|
||||
print(f" + {name}")
|
||||
if not only_in_a and not only_in_b:
|
||||
print("BONE_MATCH=OK")
|
||||
else:
|
||||
print("BONE_MATCH=MISMATCH")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
blender_extract_armature.py
|
||||
Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>
|
||||
|
||||
Loads a Quaternius GLTF file, strips all mesh objects and animation data,
|
||||
and exports only the armature as a GLB skeleton file.
|
||||
|
||||
Requirements:
|
||||
- 65-bone hierarchy preserved
|
||||
- No mesh geometry
|
||||
- No animation data
|
||||
- Y-up, -Z forward (glTF default)
|
||||
- 1 unit = 1 meter (Quaternius convention)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
def extract_armature(input_path: str, output_path: str) -> None:
|
||||
# Clear the default scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import the GLTF/GLB
|
||||
print(f"Loading: {input_path}")
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
# Report what was imported
|
||||
all_objects = list(bpy.context.scene.objects)
|
||||
print(f"Imported {len(all_objects)} objects:")
|
||||
for obj in all_objects:
|
||||
print(f" {obj.name} ({obj.type})")
|
||||
|
||||
# Find armature objects
|
||||
armatures = [obj for obj in all_objects if obj.type == 'ARMATURE']
|
||||
if not armatures:
|
||||
print("ERROR: No armature found in the imported file.")
|
||||
sys.exit(1)
|
||||
|
||||
armature = armatures[0]
|
||||
print(f"Armature: {armature.name} — {len(armature.data.bones)} bones")
|
||||
|
||||
if len(armature.data.bones) < 60:
|
||||
print(f"WARNING: Expected ~65 bones, found {len(armature.data.bones)}. Check compatibility.")
|
||||
|
||||
# Remove all non-armature objects (meshes, lights, cameras, empties)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in all_objects:
|
||||
if obj.type != 'ARMATURE':
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.delete()
|
||||
|
||||
# Remove all animation data (skeleton only, no poses)
|
||||
if armature.animation_data:
|
||||
armature.animation_data_clear()
|
||||
for action in list(bpy.data.actions):
|
||||
bpy.data.actions.remove(action)
|
||||
|
||||
# Select only the armature for export
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
armature.select_set(True)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
|
||||
# Export as GLB — skeleton only, no animations, no meshes
|
||||
print(f"Exporting to: {output_path}")
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=output_path,
|
||||
use_selection=True,
|
||||
export_format='GLB',
|
||||
export_animations=False,
|
||||
export_skins=True,
|
||||
export_yup=True,
|
||||
)
|
||||
|
||||
# Verify: report bone count and names
|
||||
bones = sorted(armature.data.bones, key=lambda b: b.name)
|
||||
print(f"Export complete. {len(bones)} bones:")
|
||||
for bone in bones:
|
||||
print(f" {bone.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_extract_armature.py -- <input.gltf> <output.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
if len(args) < 2:
|
||||
print("ERROR: Provide input GLTF/GLB path and output GLB path.")
|
||||
sys.exit(1)
|
||||
|
||||
extract_armature(args[0], args[1])
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
blender_list_animations.py
|
||||
Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>
|
||||
|
||||
Lists all animation actions in a GLB file.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("Usage: tooling/blender --background --python tooling/blender_list_animations.py -- <input.glb>")
|
||||
sys.exit(1)
|
||||
|
||||
args = argv[argv.index("--") + 1:]
|
||||
input_path = args[0]
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=input_path)
|
||||
|
||||
actions = list(bpy.data.actions)
|
||||
print("ACTIONS_COUNT=" + str(len(actions)))
|
||||
for action in sorted(actions, key=lambda a: a.name):
|
||||
print("ACTION: " + action.name)
|
||||
|
||||
# Also report armature bone count if present
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
print("ARMATURE_BONES=" + str(len(obj.data.bones)))
|
||||
@@ -0,0 +1,10 @@
|
||||
import sys
|
||||
import bpy
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=sys.argv[sys.argv.index("--") + 1])
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == 'ARMATURE':
|
||||
for b in sorted(obj.data.bones, key=lambda x: x.name):
|
||||
print("BONE: " + b.name)
|
||||
break
|
||||
Reference in New Issue
Block a user