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 = 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_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