Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.4 KiB
GDScript
202 lines
6.4 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
|
||
|
||
## Eye color
|
||
var eye_color: Color = Color(0.45, 0.3, 0.15) # brown default
|
||
|
||
## Skin tone index 0–8 into the 9-tone palette (pale_cool → very_deep_cool)
|
||
var skin_tone: int = 0
|
||
|
||
## 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
|