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>
211 lines
6.5 KiB
GDScript
211 lines
6.5 KiB
GDScript
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
|