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
233 lines
7.4 KiB
GDScript
233 lines
7.4 KiB
GDScript
## AvatarSystem - Global avatar orchestrator (autoload singleton).
|
|
##
|
|
## Owns the AvatarCatalog, AvatarLoader, and the current player's AvatarLoadout.
|
|
## Provides a single entry point for the rest of the game to:
|
|
## - Load the asset catalog.
|
|
## - Load a player's avatar from a loadout.
|
|
## - Equip / unequip items and persist the resulting loadout.
|
|
##
|
|
## Registered as an autoload singleton named "AvatarSystem" in project.godot.
|
|
|
|
extends Node
|
|
|
|
# Sibling avatar scripts are referenced via preload rather than by their global
|
|
# class_name so this autoload parses correctly from a bare clone -- a headless
|
|
# `godot <scene>` run does not rebuild the global class registry (only an editor
|
|
# scan / `--import` does), and this script is parsed at startup before any such
|
|
# scan could have happened. See docs/architecture/avatar.md.
|
|
const AvatarCatalog := preload("res://scripts/avatar/avatar_catalog.gd")
|
|
const AvatarLoader := preload("res://scripts/avatar/avatar_loader.gd")
|
|
const AvatarLoadout := preload("res://scripts/avatar/avatar_loadout.gd")
|
|
const AvatarSlotRegistry := preload("res://scripts/avatar/avatar_slot_registry.gd")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Emitted when the catalog has finished loading.
|
|
signal catalog_loaded
|
|
|
|
## Emitted when the catalog fails to load.
|
|
signal catalog_load_failed(error_message: String)
|
|
|
|
## Emitted when the avatar has been fully assembled and is ready to render.
|
|
signal avatar_ready(avatar_node: Node3D)
|
|
|
|
## Emitted when the loadout changes (after equip/unequip).
|
|
signal loadout_changed(loadout: AvatarLoadout)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## The asset catalog (id -> metadata + path).
|
|
var catalog: AvatarCatalog = null
|
|
|
|
## The loader used to import GLB assets and assemble the avatar.
|
|
var loader: AvatarLoader = null
|
|
|
|
## The current player's loadout.
|
|
var current_loadout: AvatarLoadout = null
|
|
|
|
## The assembled avatar node (null until avatar_ready).
|
|
var avatar_node: Node3D = null
|
|
|
|
## Whether the catalog has been loaded.
|
|
var is_catalog_loaded: bool = false
|
|
|
|
## Whether an avatar is currently assembled.
|
|
var is_avatar_ready: bool = false
|
|
|
|
## Default catalog path.
|
|
const DEFAULT_CATALOG_PATH := "res://assets/avatars/catalog.json"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
catalog = AvatarCatalog.new()
|
|
loader = AvatarLoader.new()
|
|
loader.init_loader(catalog)
|
|
add_child(loader)
|
|
|
|
|
|
## Load the asset catalog. Call this once at startup (or on demand).
|
|
func load_catalog(path: String = DEFAULT_CATALOG_PATH) -> Error:
|
|
var err := catalog.load_catalog(path)
|
|
if err == OK:
|
|
is_catalog_loaded = true
|
|
catalog_loaded.emit()
|
|
else:
|
|
catalog_load_failed.emit("Failed to load catalog: %s" % err)
|
|
return err
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Avatar assembly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Load and assemble an avatar from a loadout.
|
|
## Returns the assembled avatar Node3D on success, or null on failure.
|
|
func load_avatar(loadout: AvatarLoadout) -> Node3D:
|
|
if not is_catalog_loaded:
|
|
push_error("AvatarSystem: catalog not loaded; call load_catalog() first")
|
|
return null
|
|
|
|
if loadout == null:
|
|
push_error("AvatarSystem: loadout is null")
|
|
return null
|
|
|
|
# Validate the loadout against the catalog.
|
|
var validation := catalog.validate_loadout(loadout)
|
|
if not validation.valid:
|
|
push_error("AvatarSystem: invalid loadout: %s" % str(validation.errors))
|
|
return null
|
|
|
|
current_loadout = loadout
|
|
|
|
# Reset any previous avatar.
|
|
loader.reset()
|
|
|
|
# Load the base body.
|
|
var base_body_id := loadout.base_body
|
|
var base_node := loader.load_base_body(base_body_id)
|
|
if base_node == null:
|
|
push_error("AvatarSystem: failed to load base body '%s'" % base_body_id)
|
|
return null
|
|
|
|
# Load each equipped attachment.
|
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
|
var slot_value = loadout.slots.get(slot_id)
|
|
|
|
if slot_id == "accessory":
|
|
# Accessory is an array of asset ids.
|
|
if slot_value is Array:
|
|
for asset_id in slot_value:
|
|
if asset_id is String and not (asset_id as String).is_empty():
|
|
loader.load_attachment(slot_id, asset_id)
|
|
else:
|
|
# Exclusive slot: single asset id or null.
|
|
if slot_value is String and not (slot_value as String).is_empty():
|
|
loader.load_attachment(slot_id, slot_value)
|
|
|
|
# Hide base meshes for equipped body-part slots.
|
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
|
var slot_value = loadout.slots.get(slot_id)
|
|
if slot_value is String and not (slot_value as String).is_empty():
|
|
loader.set_base_mesh_visible(slot_id, false)
|
|
|
|
avatar_node = loader.avatar_node
|
|
is_avatar_ready = avatar_node != null
|
|
if is_avatar_ready:
|
|
avatar_ready.emit(avatar_node)
|
|
|
|
return avatar_node
|
|
|
|
|
|
## Equip an item into a slot and rebuild the avatar.
|
|
## Returns true on success.
|
|
func equip(slot_id: String, asset_id: String) -> bool:
|
|
if current_loadout == null:
|
|
push_error("AvatarSystem: no loadout loaded")
|
|
return false
|
|
|
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
|
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
|
return false
|
|
|
|
if not catalog.has_asset(asset_id):
|
|
push_error("AvatarSystem: unknown asset '%s'" % asset_id)
|
|
return false
|
|
|
|
var asset := catalog.resolve(asset_id)
|
|
if asset.get("slot") != slot_id:
|
|
push_error("AvatarSystem: asset '%s' is for slot '%s', not '%s'" % [
|
|
asset_id, asset.get("slot"), slot_id])
|
|
return false
|
|
|
|
if slot_id == "accessory":
|
|
if not current_loadout.add_accessory(asset_id):
|
|
push_error("AvatarSystem: could not add accessory '%s'" % asset_id)
|
|
return false
|
|
else:
|
|
current_loadout.set_equipment(slot_id, asset_id)
|
|
|
|
loadout_changed.emit(current_loadout)
|
|
return true
|
|
|
|
|
|
## Unequip an item from a slot and rebuild the avatar.
|
|
## Returns true on success.
|
|
func unequip(slot_id: String, asset_id: String = "") -> bool:
|
|
if current_loadout == null:
|
|
push_error("AvatarSystem: no loadout loaded")
|
|
return false
|
|
|
|
if not AvatarSlotRegistry.has_slot(slot_id):
|
|
push_error("AvatarSystem: unknown slot '%s'" % slot_id)
|
|
return false
|
|
|
|
if slot_id == "accessory":
|
|
if asset_id.is_empty():
|
|
push_error("AvatarSystem: accessory unequip requires an asset id")
|
|
return false
|
|
if not current_loadout.remove_accessory(asset_id):
|
|
push_error("AvatarSystem: accessory '%s' not equipped" % asset_id)
|
|
return false
|
|
else:
|
|
current_loadout.set_equipment(slot_id, "")
|
|
|
|
loadout_changed.emit(current_loadout)
|
|
return true
|
|
|
|
|
|
## Serialize the current loadout to JSON.
|
|
func save_loadout() -> String:
|
|
if current_loadout == null:
|
|
return ""
|
|
return current_loadout.to_json()
|
|
|
|
|
|
## Load a loadout from a JSON string.
|
|
func load_loadout_from_json(json_string: String) -> AvatarLoadout:
|
|
var loadout := AvatarLoadout.from_json(json_string)
|
|
if not loadout.validate():
|
|
push_error("AvatarSystem: invalid loadout JSON: %s" % str(loadout.validation_errors))
|
|
return null
|
|
return loadout
|
|
|
|
|
|
## Get the list of available assets for a given slot.
|
|
func get_assets_for_slot(slot_id: String) -> Array[String]:
|
|
return catalog.get_assets_by_slot(slot_id)
|
|
|
|
|
|
## Get the display name for an asset id.
|
|
func get_asset_display_name(asset_id: String) -> String:
|
|
var asset := catalog.resolve(asset_id)
|
|
return asset.get("display_name", asset_id)
|