Files
settled-reach/client/scripts/rendering/character_visual_descriptor.gd
T
jpmschweitzerandClaude Opus 4.6 4d9840b68f 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>
2026-03-23 17:42:33 +01:00

203 lines
6.4 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
## Eye color
var eye_color: Color = Color(0.45, 0.3, 0.15) # brown default
## Skin tone index 08 into the 9-tone palette (pale_cool → very_deep_cool)
var skin_tone: int = 0
## 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 → Array[Color] for accessories (Primary + Secondary). Matches clothing_tints shape.
## Changed from single Color per Tyre architecture review (Task #6).
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.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", {}))
desc.accessory_slots = data.get("accessory_slots", {})
desc.accessory_tints = _decode_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),
"eye_color": _encode_color(eye_color),
"skin_tone": skin_tone,
"clothing_slots": clothing_slots,
"clothing_tints": _encode_tint_map(clothing_tints),
"accessory_slots": accessory_slots,
"accessory_tints": _encode_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