f51c49fcfa
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed to the repo (last regenerated before the avatar system merged), and Godot does not rebuild the global class registry on a plain headless scene run -- only an editor scan / `--import` does. So every cross-file `class_name` reference in the avatar module failed to resolve from a fresh clone, and the AvatarSystem autoload failed to instantiate entirely. - Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were force-added during old WIP checkpoints) - Reference sibling avatar scripts via preload() constants instead of their global class_name, so the module parses independent of scan order. Keeps the class_name declarations for editor/tooling use. - avatar_loadout.gd's static from_json() factory self-references via a preload const too (a script can't resolve its own class_name mid-compile on a cold cache). - smoke_test_avatar_system.gd: same preload treatment so it runs from a bare `--script` invocation. Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15, smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
316 lines
9.4 KiB
GDScript
316 lines
9.4 KiB
GDScript
## 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.
|
|
|
|
class_name AvatarLoadout
|
|
extends RefCounted
|
|
|
|
# Sibling avatar scripts are referenced via preload rather than by their
|
|
# global class_name so this module parses correctly from a bare clone (a
|
|
# headless `godot <scene>` run does not rebuild the global class registry;
|
|
# only an editor scan / `--import` does). See docs/architecture/avatar.md.
|
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
|
# Self-reference for the static factory below -- referring to our own
|
|
# class_name doesn't resolve during a cold-cache compile (see note above).
|
|
const _Self := preload("res://scripts/avatar/avatar_loadout.gd")
|
|
|
|
|
|
## 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] = []
|
|
|
|
## Skin tone color override (default: transparent = use asset colors).
|
|
var skin_tone: Color = Color.TRANSPARENT
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inner classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Represents a single equipped item in a slot.
|
|
## Used by consumers to iterate over equipped items.
|
|
class SlotItem:
|
|
## The slot this item belongs to.
|
|
var slot_id: String
|
|
## The catalog asset ID of the equipped item.
|
|
var asset_id: String
|
|
|
|
func _init(p_slot_id: String, p_asset_id: String) -> void:
|
|
slot_id = p_slot_id
|
|
asset_id = p_asset_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _init() -> void:
|
|
validation_errors = []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Serialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Create a loadout from a JSON string.
|
|
static func from_json(json_string: String) -> AvatarLoadout:
|
|
var loadout := _Self.new()
|
|
var data: Variant = JSON.parse_string(json_string)
|
|
if not (data is 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:
|
|
var parsed_slots: Dictionary = slots_data as Dictionary
|
|
for slot_id in parsed_slots:
|
|
if slot_id is String:
|
|
loadout.slots[slot_id] = parsed_slots[slot_id]
|
|
else:
|
|
loadout.validation_errors.append("slots must be an object")
|
|
else:
|
|
loadout.validation_errors.append("Missing 'slots' in loadout JSON")
|
|
|
|
# Parse skin_tone.
|
|
if root.has("skin_tone"):
|
|
var st = root["skin_tone"]
|
|
if typeof(st) == TYPE_DICTIONARY:
|
|
var tone: Dictionary = st as Dictionary
|
|
loadout.skin_tone = Color(
|
|
float(tone.get("r", 0.0)),
|
|
float(tone.get("g", 0.0)),
|
|
float(tone.get("b", 0.0)),
|
|
float(tone.get("a", 1.0))
|
|
)
|
|
|
|
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)
|
|
|
|
# Include skin_tone if set.
|
|
if skin_tone != Color.TRANSPARENT:
|
|
data["skin_tone"] = {
|
|
"r": skin_tone.r,
|
|
"g": skin_tone.g,
|
|
"b": skin_tone.b,
|
|
"a": skin_tone.a,
|
|
}
|
|
|
|
return JSON.stringify(data, " ")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## 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 known 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" and (value as Array).size() > MAX_ACCESSORIES:
|
|
validation_errors.append(
|
|
"Accessory count (%d) exceeds maximum (%d)" % [
|
|
(value as Array).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()
|
|
|
|
|
|
## Convenience alias for validate().
|
|
func is_valid() -> bool:
|
|
return validate()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Accessors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## 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
|
|
|
|
slots[slot_id] = null if asset_id.is_empty() else asset_id
|
|
|
|
|
|
## 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
|
|
|
|
|
|
## Iterate over equipped items as SlotItem objects.
|
|
## Returns an array of SlotItem for each equipped slot.
|
|
func get_equipped_items() -> Array[SlotItem]:
|
|
var items: Array[SlotItem] = []
|
|
|
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
|
var value = slots.get(slot_id)
|
|
if value == null:
|
|
continue
|
|
|
|
if typeof(value) == TYPE_STRING:
|
|
var asset_id := value as String
|
|
if not asset_id.is_empty():
|
|
items.append(SlotItem.new(slot_id, asset_id))
|
|
elif typeof(value) == TYPE_ARRAY:
|
|
for item_id in value:
|
|
if item_id is String and not (item_id as String).is_empty():
|
|
items.append(SlotItem.new(slot_id, item_id))
|
|
|
|
return items
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## 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 as String).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)
|