WIP checkpoint: Implement avatar loader and customization UI in Godot

This commit is contained in:
2026-08-17 22:20:39 +00:00
parent ea5dd63cd7
commit a5dc5cb3b6
4 changed files with 1244 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
## AvatarLoadout - Serializable loadout model + validation.
##
## Represents a complete avatar composition: a base body plus equipped items
## per slot. Validates slot exclusivity and references against a catalog.
##
## See docs/architecture/avatar.md §5 for the loadout JSON format.
extends RefCounted
## Maximum number of accessories allowed in the accessory slot.
const MAX_ACCESSORIES: int = 8
## The base body asset ID.
var base_body: String = ""
## Equipped items per slot. Keys are slot IDs from AvatarSlotRegistry.
## Exclusive slots hold a single asset ID (or null).
## The "accessory" slot holds an array of asset IDs.
var slots: Dictionary = {}
## Validation errors found during the last validate() call.
var validation_errors: Array[String] = []
func _init() -> void:
validation_errors = []
## Create a loadout from a JSON string.
static func from_json(json_string: String) -> AvatarLoadout:
var loadout := AvatarLoadout.new()
var data: Variant = JSON.parse_string(json_string)
if data is not Dictionary:
loadout.validation_errors.append("Loadout JSON root is not an object")
return loadout
var root: Dictionary = data as Dictionary
# Parse base_body
if root.has("base_body"):
var bb = root["base_body"]
if typeof(bb) == TYPE_STRING:
loadout.base_body = bb as String
else:
loadout.validation_errors.append("base_body must be a string")
# Parse slots
if root.has("slots"):
var slots_data = root["slots"]
if typeof(slots_data) == TYPE_DICTIONARY:
loadout.slots = slots_data as Dictionary
else:
loadout.validation_errors.append("slots must be an object")
else:
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
return loadout
## Serialize this loadout to a JSON string.
func to_json() -> String:
var data := {
"base_body": base_body,
"slots": {}
}
for slot_id in AvatarSlotRegistry.SLOT_IDS:
if slot_id == "accessory":
data.slots[slot_id] = slots.get(slot_id, [])
else:
data.slots[slot_id] = slots.get(slot_id, null)
return JSON.stringify(data, " ")
## Validate this loadout against the slot registry.
## Returns true if valid; populates validation_errors if not.
func validate() -> bool:
validation_errors.clear()
# Check base_body
if base_body.is_empty():
validation_errors.append("base_body is required")
# Check each slot
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
if slot_def == null:
validation_errors.append("Unknown slot: %s" % slot_id)
continue
var value = slots.get(slot_id)
if slot_def.exclusive:
# Exclusive slots: must be null or a string
if value != null and typeof(value) != TYPE_STRING:
validation_errors.append(
"Slot '%s' must be null or a string, got %s" % [
slot_id, typeof(value)
]
)
else:
# Non-exclusive (accessory): must be null or an array
if value != null and typeof(value) != TYPE_ARRAY:
validation_errors.append(
"Slot '%s' must be null or an array, got %s" % [
slot_id, typeof(value)
]
)
elif value is Array:
if slot_id == "accessory":
if value.size() > MAX_ACCESSORIES:
validation_errors.append(
"Accessory count (%d) exceeds maximum (%d)" % [
value.size(), MAX_ACCESSORIES
]
)
# Check for duplicate accessories
if slots.has("accessory") and slots["accessory"] is Array:
var accessories = slots["accessory"] as Array
var seen := {}
for item in accessories:
if item is String:
if seen.has(item):
validation_errors.append(
"Duplicate accessory: %s" % item
)
seen[item] = true
return validation_errors.is_empty()
## Check if a specific slot is equipped with a non-null item.
func is_equipped(slot_id: String) -> bool:
if not AvatarSlotRegistry.has_slot(slot_id):
return false
var value = slots.get(slot_id)
if value == null:
return false
if typeof(value) == TYPE_STRING:
return not (value as String).is_empty()
elif typeof(value) == TYPE_ARRAY:
return (value as Array).size() > 0
return false
## Get the equipped asset ID for an exclusive slot.
## Returns empty string if not equipped or slot is not exclusive.
func get_equipment(slot_id: String) -> String:
var value = slots.get(slot_id)
if value is String:
return value
return ""
## Set equipment for an exclusive slot.
func set_equipment(slot_id: String, asset_id: String) -> void:
if not AvatarSlotRegistry.has_slot(slot_id):
push_error("Unknown slot: %s" % slot_id)
return
var slot_def = AvatarSlotRegistry.get_slot(slot_id)
if slot_def and not slot_def.exclusive:
push_error("Slot '%s' is not exclusive" % slot_id)
return
if slots.has(slot_id):
var current = slots[slot_id]
if current is Array:
# Convert array to single value
slots[slot_id] = asset_id if asset_id.is_empty() else null
else:
slots[slot_id] = asset_id if not asset_id.is_empty() else null
else:
slots[slot_id] = asset_id if not asset_id.is_empty() else null
## Add an accessory to the accessory slot.
func add_accessory(asset_id: String) -> bool:
var accessories = slots.get("accessory", []) as Array
if accessories.size() >= MAX_ACCESSORIES:
return false
if asset_id.is_empty():
return false
if accessories.has(asset_id):
return false
accessories.append(asset_id)
slots["accessory"] = accessories
return true
## Remove an accessory from the accessory slot.
func remove_accessory(asset_id: String) -> bool:
var accessories = slots.get("accessory", []) as Array
if accessories.has(asset_id):
accessories.erase(asset_id)
slots["accessory"] = accessories
return true
return false
## Compute a hash of this loadout for change detection.
## Mirrors the server's loadout_hash concept.
func compute_hash() -> int:
var hasher := HashingContext.new()
hasher.start(HashingContext.HASH_SHA256)
hasher.update(base_body.to_utf8_buffer())
for slot_id in AvatarSlotRegistry.SLOT_IDS:
hasher.update(slot_id.to_utf8_buffer())
var value = slots.get(slot_id)
if value is String:
hasher.update(value.to_utf8_buffer())
elif value is Array:
var arr_str = JSON.stringify(value)
hasher.update(arr_str.to_utf8_buffer())
else:
hasher.update("null".to_utf8_buffer())
var digest = hasher.finish()
# Return first 4 bytes as int for compact storage
return digest[0] | (digest[1] << 8) | (digest[2] << 16) | (digest[3] << 24)