fix(multiplayer): replicate per-player avatar loadout and preserve original materials
checkpoint / checkpoint (push) Successful in 5m57s

This commit is contained in:
2026-09-17 16:42:15 +00:00
parent 7c77c0b447
commit 50598a0dcb
10 changed files with 395 additions and 60 deletions
+48 -9
View File
@@ -249,14 +249,42 @@ func has_loadout() -> bool:
func ensure_catalog() -> bool:
if is_catalog_loaded:
return true
return load_catalog() == OK
var ok := load_catalog() == OK
if ok and current_loadout == null:
_try_load_cached_loadout()
return ok
func _try_load_cached_loadout() -> void:
if FileAccess.file_exists("user://loadout.json"):
var f := FileAccess.open("user://loadout.json", FileAccess.READ)
if f != null:
var txt := f.get_as_text()
f.close()
var dict: Variant = JSON.parse_string(txt)
if dict is Dictionary:
set_loadout_from_dict(dict as Dictionary)
## Return the current loadout as a dictionary. If none is set, returns a default
## loadout using the first available base body, or {} if catalog is empty.
func get_current_loadout_dict() -> Dictionary:
if current_loadout != null:
return current_loadout.to_dict()
ensure_catalog()
if current_loadout != null:
return current_loadout.to_dict()
var bases := get_assets_for_slot("base_body")
if not bases.is_empty():
return {"base_body": bases[0], "slots": {}}
return {}
## Set the current player's loadout from the dict shape the backend's
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
## Returns true if it validated and was stored.
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
var lo := AvatarLoadout.from_json(JSON.stringify(loadout_dict))
var lo := AvatarLoadout.from_dict(loadout_dict)
if not lo.validate():
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
% str(lo.validation_errors))
@@ -287,24 +315,35 @@ func build_current_avatar() -> Node3D:
## Assemble a standalone avatar node from [param loadout] using a throwaway
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
## to call many times (local player + one per remote peer). Returns null only
## if the assets are genuinely missing.
## if the assets are genuinely missing. Accepts either AvatarLoadout or Dictionary.
func assemble_avatar(loadout) -> Node3D:
if loadout == null or not is_catalog_loaded:
if loadout == null:
return null
var validation := catalog.validate_loadout(loadout)
if not ensure_catalog():
return null
var lo: AvatarLoadout = null
if loadout is AvatarLoadout:
lo = loadout
elif loadout is Dictionary:
lo = AvatarLoadout.from_dict(loadout as Dictionary)
else:
return null
var validation := catalog.validate_loadout(lo)
if not validation.valid:
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
return null
var l = AvatarLoader.new()
l.init_loader(catalog)
var base := l.load_base_body(loadout.base_body)
var base := l.load_base_body(lo.base_body)
if base == null:
l.free()
return null
for slot_id in AvatarSlotRegistry.SLOT_IDS:
var v = loadout.slots.get(slot_id)
var v = lo.slots.get(slot_id)
if slot_id == "accessory":
if v is Array:
for aid in v:
@@ -314,11 +353,11 @@ func assemble_avatar(loadout) -> Node3D:
l.load_attachment(slot_id, v)
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
var v = loadout.slots.get(slot_id)
var v = lo.slots.get(slot_id)
if v is String and not (v as String).is_empty():
l.set_base_mesh_visible(slot_id, false)
l.apply_skin_tone(loadout.skin_tone)
l.apply_skin_tone(lo.skin_tone)
l.free()
return base