diff --git a/scripts/avatar/avatar_slot_registry.gd b/scripts/avatar/avatar_slot_registry.gd new file mode 100644 index 0000000..b7b9a2a --- /dev/null +++ b/scripts/avatar/avatar_slot_registry.gd @@ -0,0 +1,222 @@ +## AvatarSlotRegistry - Static registry of slot definitions. +## +## Each slot maps to one or more bones and defines what can be equipped there. +## This is the single source of truth for slot IDs, categories, mount bones, +## exclusivity, and body-part vs attachment classification. +## +## See docs/architecture/avatar.md §3.2 for the full slot specification. +class_name AvatarSlotRegistry extends RefCounted + + +# --------------------------------------------------------------------------- +# Slot definitions (mirrors §3.2 of the avatar spec) +# --------------------------------------------------------------------------- + +## All known slot IDs, in canonical order. +const SLOT_IDS: PackedStringArray = [ + "head", + "hair", + "face", + "torso", + "chest", + "back", + "left_arm", + "right_arm", + "left_hand", + "right_hand", + "legs", + "pants", + "left_foot", + "right_foot", + "accessory", +] + +## Body-part slots (exclusive, replace base mesh). +const BODY_PART_SLOTS: PackedStringArray = [ + "head", "torso", "legs", "left_arm", "right_arm", +] + +## Attachment slots (layered on top of base mesh). +const ATTACHMENT_SLOTS: PackedStringArray = [ + "hair", "face", "chest", "back", + "left_hand", "right_hand", "pants", + "left_foot", "right_foot", "accessory", +] + +## Slots that are multi-slot (multiple accessories can coexist). +const MULTI_SLOT_IDS: PackedStringArray = ["accessory"] + + +# Slot metadata lookup tables (indexed by SLOT_IDS position). +# Each entry: { category, mount_bones, exclusive, is_body_part } +var _slot_data: Dictionary = {} + + +func _init() -> void: + _build_registry() + + +func _build_registry() -> void: + _slot_data = { + "head": { + "category": "body_part", + "mount_bones": ["Head"], + "exclusive": true, + "is_body_part": true, + }, + "hair": { + "category": "attachment", + "mount_bones": ["HeadTop"], + "exclusive": true, + "is_body_part": false, + }, + "face": { + "category": "attachment", + "mount_bones": ["Jaw"], + "exclusive": true, + "is_body_part": false, + }, + "torso": { + "category": "body_part", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": true, + }, + "chest": { + "category": "attachment", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": false, + }, + "back": { + "category": "attachment", + "mount_bones": ["Chest"], + "exclusive": true, + "is_body_part": false, + }, + "left_arm": { + "category": "body_part", + "mount_bones": ["LeftUpperArm"], + "exclusive": true, + "is_body_part": true, + }, + "right_arm": { + "category": "body_part", + "mount_bones": ["RightUpperArm"], + "exclusive": true, + "is_body_part": true, + }, + "left_hand": { + "category": "attachment", + "mount_bones": ["LeftHand"], + "exclusive": true, + "is_body_part": false, + }, + "right_hand": { + "category": "attachment", + "mount_bones": ["RightHand"], + "exclusive": true, + "is_body_part": false, + }, + "legs": { + "category": "body_part", + "mount_bones": ["Hips"], + "exclusive": true, + "is_body_part": true, + }, + "pants": { + "category": "attachment", + "mount_bones": ["Hips"], + "exclusive": true, + "is_body_part": false, + }, + "left_foot": { + "category": "attachment", + "mount_bones": ["LeftFoot"], + "exclusive": true, + "is_body_part": false, + }, + "right_foot": { + "category": "attachment", + "mount_bones": ["RightFoot"], + "exclusive": true, + "is_body_part": false, + }, + "accessory": { + "category": "attachment", + "mount_bones": ["Root"], + "exclusive": false, + "is_body_part": false, + }, + } + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +## Returns the list of all known slot IDs. +static func get_slot_ids() -> PackedStringArray: + return SLOT_IDS + + +## Returns true if *slot_id* is a known slot. +static func has_slot(slot_id: String) -> bool: + return slot_id in SLOT_IDS + + +## Returns the category for a slot ("body_part" or "attachment"). +static func get_category(slot_id: String) -> String: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return "" + return _slot_data[slot_id]["category"] + + +## Returns the primary mount bone(s) for a slot. +static func get_mount_bones(slot_id: String) -> PackedStringArray: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return PackedStringArray() + return PackedStringArray(_slot_data[slot_id]["mount_bones"]) + + +## Returns whether the slot is exclusive (at most one item). +static func is_exclusive(slot_id: String) -> bool: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return true + return _slot_data[slot_id]["exclusive"] + + +## Returns whether the slot is a body-part slot (replaces base mesh). +static func is_body_part_slot(slot_id: String) -> bool: + if not has_slot(slot_id): + push_error("AvatarSlotRegistry: unknown slot '%s'" % slot_id) + return false + return _slot_data[slot_id]["is_body_part"] + + +## Returns whether the slot is a multi-slot (e.g. accessory). +static func is_multi_slot(slot_id: String) -> bool: + return slot_id in MULTI_SLOT_IDS + + +## Returns the list of body-part slot IDs. +static func get_body_part_slots() -> PackedStringArray: + return BODY_PART_SLOTS + + +## Returns the list of attachment slot IDs. +static func get_attachment_slots() -> PackedStringArray: + return ATTACHMENT_SLOTS + + +## Validates that an asset's declared slot matches the slot it is placed in. +static func validate_slot_assignment(asset_slot: String, slot_id: String) -> bool: + return asset_slot == slot_id + + +## Returns the canonical order index for a slot (for serialization order). +static func get_slot_index(slot_id: String) -> int: + return SLOT_IDS.find(slot_id)