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
+22 -10
View File
@@ -65,16 +65,13 @@ func _init() -> void:
# Serialization
# ---------------------------------------------------------------------------
## Create a loadout from a JSON string.
static func from_json(json_string: String) -> AvatarLoadout:
## Create a loadout from a Dictionary.
static func from_dict(root: Dictionary) -> 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")
if root == null:
loadout.validation_errors.append("Loadout root is null")
return loadout
var root: Dictionary = data as Dictionary
# Parse base_body.
if root.has("base_body"):
var bb = root["base_body"]
@@ -111,8 +108,18 @@ static func from_json(json_string: String) -> AvatarLoadout:
return loadout
## Serialize this loadout to a JSON string.
func to_json() -> String:
## Create a loadout from a JSON string.
static func from_json(json_string: String) -> AvatarLoadout:
var data: Variant = JSON.parse_string(json_string)
if not (data is Dictionary):
var loadout := _Self.new()
loadout.validation_errors.append("Loadout JSON root is not an object")
return loadout
return from_dict(data as Dictionary)
## Serialize this loadout to a Dictionary.
func to_dict() -> Dictionary:
var data := {
"base_body": base_body,
"slots": {},
@@ -133,7 +140,12 @@ func to_json() -> String:
"a": skin_tone.a,
}
return JSON.stringify(data, " ")
return data
## Serialize this loadout to a JSON string.
func to_json() -> String:
return JSON.stringify(to_dict(), " ")
# ---------------------------------------------------------------------------
+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
+79 -40
View File
@@ -26,6 +26,7 @@ var _leaving := false # a scene change is already in flight
var _aligned := false # snapped to the server spawn yet?
var _remotes: Dictionary = {} # peer_id -> Node3D
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
var _remote_loadouts: Dictionary = {} # peer_id -> Dictionary (last known loadout)
var _status_label: Label = null
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
@@ -408,10 +409,30 @@ func _setup_networked() -> void:
return
_networked = true
_hide_status()
# Replicate local avatar to server and remote peers
var avatars := get_node_or_null("/root/AvatarSystem")
if avatars != null:
if avatars.has_method("ensure_catalog"):
avatars.ensure_catalog()
if avatars.has_method("get_current_loadout_dict"):
_net.send_loadout(avatars.get_current_loadout_dict())
if avatars.has_signal("loadout_changed") and not avatars.loadout_changed.is_connected(_on_local_loadout_changed):
avatars.loadout_changed.connect(_on_local_loadout_changed)
# Physics-process AFTER the player so net_input reflects this frame.
process_physics_priority = 10
func _on_local_loadout_changed(lo: Variant) -> void:
if _networked and _net != null and is_instance_valid(_net):
if lo != null and lo.has_method("to_dict"):
_net.send_loadout(lo.to_dict())
elif lo is Dictionary:
_net.send_loadout(lo)
func _physics_process(_delta: float) -> void:
if not _networked or _net == null or not _net.is_connected_to_server():
return
@@ -441,11 +462,15 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
for pid in states:
var st: Dictionary = states[pid]
var node: Node3D = _remotes.get(pid)
var loadout: Variant = st.get("loadout", null)
if node == null:
node = _make_remote(pid)
node = _make_remote(pid, loadout)
_remotes[pid] = node
add_child(node)
node.global_position = st.get("position", Vector3.ZERO)
elif loadout is Dictionary and not (loadout as Dictionary).is_empty() and _remote_loadouts.get(pid) != loadout:
_setup_remote_visual(node, pid, loadout)
var target: Vector3 = st.get("position", node.global_position)
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
node.rotation.y = st.get("rotation_y", node.rotation.y)
@@ -463,46 +488,17 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
_remotes[pid].queue_free()
_remotes.erase(pid)
_remote_prev.erase(pid)
_remote_loadouts.erase(pid)
func _make_remote(pid: int) -> Node3D:
func _make_remote(pid: int, loadout: Variant = null) -> Node3D:
var root := Node3D.new()
root.name = "Remote_%d" % pid
# Other players get the assembled avatar too (everyone shares the default
# look for now -- per-player loadout replication is the follow-up), tinted
# by peer id so they're still tellable apart, plus a name tag.
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
var avatars := get_node_or_null("/root/AvatarSystem")
var visual: Node3D = null
if avatars and avatars.has_method("build_current_avatar"):
visual = avatars.build_current_avatar()
if visual != null:
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
for mi in _all_mesh_instances(visual):
var m := StandardMaterial3D.new()
m.albedo_color = tint
mi.material_override = m
root.add_child(visual)
var skel := visual.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
anim.name = "LimbAnim"
root.add_child(anim)
anim.call("setup", skel)
else:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.7
body.mesh = capsule
body.position = Vector3(0, 0.85, 0)
var mat := StandardMaterial3D.new()
mat.albedo_color = tint
body.material_override = mat
root.add_child(body)
_setup_remote_visual(root, pid, loadout)
var tag := Label3D.new()
tag.name = "NameTag"
tag.text = "Player %d" % pid
tag.position = Vector3(0, 2.1, 0)
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
@@ -513,12 +509,55 @@ func _make_remote(pid: int) -> Node3D:
return root
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
if node is MeshInstance3D:
out.append(node)
for c in node.get_children():
_all_mesh_instances(c, out)
return out
func _setup_remote_visual(root: Node3D, pid: int, loadout: Variant) -> void:
var old_vis := root.get_node_or_null("Visual")
if old_vis != null and is_instance_valid(old_vis):
root.remove_child(old_vis)
old_vis.queue_free()
var old_anim := root.get_node_or_null("LimbAnim")
if old_anim != null and is_instance_valid(old_anim):
root.remove_child(old_anim)
old_anim.queue_free()
var avatars := get_node_or_null("/root/AvatarSystem")
var visual: Node3D = null
if avatars != null:
if avatars.has_method("ensure_catalog"):
avatars.ensure_catalog()
if loadout is Dictionary and not (loadout as Dictionary).is_empty():
if avatars.has_method("assemble_avatar"):
visual = avatars.assemble_avatar(loadout)
if visual == null and avatars.has_method("get_assets_for_slot") and avatars.has_method("assemble_avatar"):
var bases: Array = avatars.get_assets_for_slot("base_body")
if not bases.is_empty():
visual = avatars.assemble_avatar({"base_body": bases[0], "slots": {}})
if visual != null:
visual.name = "Visual"
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
root.add_child(visual)
var skel := visual.find_child("Skeleton3D", true, false)
if skel is Skeleton3D:
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
anim.name = "LimbAnim"
root.add_child(anim)
anim.call("setup", skel)
else:
# Fallback capsule if assets/catalog are unavailable (e.g. headless)
var body := MeshInstance3D.new()
body.name = "Visual"
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.7
body.mesh = capsule
body.position = Vector3(0, 0.85, 0)
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
var mat := StandardMaterial3D.new()
mat.albedo_color = tint
body.material_override = mat
root.add_child(body)
_remote_loadouts[pid] = (loadout as Dictionary).duplicate(true) if (loadout is Dictionary) else loadout
func _on_server_lost() -> void:
+9 -1
View File
@@ -329,7 +329,15 @@ func _prime_avatar() -> void:
var res: Dictionary = await ApiClient.get_avatar_loadout()
if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"):
avatars.set_loadout_from_dict((res.data as Dictionary)["data"])
else:
elif 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:
avatars.set_loadout_from_dict(dict as Dictionary)
elif not avatars.has_loadout():
# No saved loadout reachable -- fall back to the catalog's first base body.
var bases: Array = avatars.get_assets_for_slot("base_body")
if bases.size() > 0:
+143
View File
@@ -0,0 +1,143 @@
extends SceneTree
## Client process for the multiplayer avatar replication test
## (scripts/run_multiplayer_avatar_test.sh).
##
## Role "b": Uncustomized character (no hair, no attachments).
## Observes Client A. Verifies Client A's remote model HAS hair/attachments
## and does NOT have a solid material_override tint.
##
## Role "a": Customized character (with hair + chest).
## Observes Client B. Verifies Client B's remote model has NO hair/attachments
## and does NOT have a solid material_override tint.
var role := "b"
var port := 7789
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
for i in args.size():
if args[i] == "--role" and i + 1 < args.size():
role = args[i + 1]
elif args[i] == "--port" and i + 1 < args.size():
port = int(args[i + 1])
_run()
func _run() -> void:
await process_frame
var avatars = root.get_node_or_null("AvatarSystem")
if avatars == null:
print("RESULT %s avatar_system_missing" % role)
quit(1)
return
await avatars.ensure_catalog()
var bases: Array = avatars.get_assets_for_slot("base_body")
if bases.is_empty():
print("RESULT %s no_base_bodies" % role)
quit(1)
return
if role == "a":
# Machine A: customized character (hair + chest)
avatars.set_loadout_from_dict({
"base_body": bases[0],
"slots": {
"hair": "hair_ponytail_01",
"chest": "chest_bandit_01",
}
})
else:
# Machine B: uncustomized character (no attachments)
avatars.set_loadout_from_dict({
"base_body": bases[0],
"slots": {}
})
var session = root.get_node("Session")
session.begin(1, "127.0.0.1", port)
change_scene_to_file("res://scenes/game.tscn")
for i in 120:
await process_frame
var game := current_scene
var netc = game.get_node_or_null("NetworkClient") if game else null
if netc == null or not netc.is_connected_to_server():
print("RESULT %s not_connected" % role)
quit(1)
return
print("RESULT %s in_world" % role)
# Search for remote player
var remote_node: Node3D = null
for i in 1200:
await process_frame
for c in game.get_children():
if c is Node3D and c.name.begins_with("Remote_"):
var vis = c.get_node_or_null("Visual")
if vis != null:
remote_node = c as Node3D
break
if remote_node != null:
# Give it 10 frames to ensure loadout replication snapshot has arrived and applied
for j in 10:
await process_frame
break
if remote_node == null:
print("RESULT %s remote_not_found" % role)
quit(1)
return
var visual: Node = remote_node.get_node_or_null("Visual")
if visual == null:
print("RESULT %s visual_not_found" % role)
quit(1)
return
var skel: Skeleton3D = visual.find_child("Skeleton3D", true, false) as Skeleton3D
if skel == null:
print("RESULT %s skeleton_not_found" % role)
quit(1)
return
var mount_count := 0
for c in skel.get_children():
if c is BoneAttachment3D:
mount_count += 1
var has_override := false
var meshes: Array[MeshInstance3D] = []
_gather_meshes(visual, meshes)
for m in meshes:
if m.material_override != null:
has_override = true
break
if role == "b":
# Observer B observing A: A must have customizations (mounts >= 2) and no tint
var a_customized := mount_count >= 2
var tinted := has_override
print("RESULT b mounts=%d tinted=%s ok=%s" % [mount_count, str(tinted), str(a_customized and not tinted)])
# Stay connected long enough for A to finish its verification
for i in 300:
await process_frame
quit(0 if (a_customized and not tinted) else 1)
else:
# Observer A observing B: B must NOT have customizations (mounts == 0) and no tint
var b_uncustomized := mount_count == 0
var tinted := has_override
print("RESULT a mounts=%d tinted=%s ok=%s" % [mount_count, str(tinted), str(b_uncustomized and not tinted)])
quit(0 if (b_uncustomized and not tinted) else 1)
func _gather_meshes(node: Node, out: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
out.append(node as MeshInstance3D)
for c in node.get_children():
_gather_meshes(c, out)
+12
View File
@@ -42,6 +42,7 @@ var peer: ENetMultiplayerPeer
var my_peer_id: int = 0
var _server_root: Node
var _player_proxy: Node
var pending_loadout: Dictionary = {}
var _seq: int = 0
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
@@ -100,6 +101,8 @@ func _on_connected() -> void:
_server_root.add_child(_player_proxy)
connected_to_server.emit()
if not pending_loadout.is_empty():
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
func disconnect_from_server() -> void:
@@ -258,3 +261,12 @@ func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> vo
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_request_input",
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
## Send the local player's avatar loadout to the server so it can be replicated
## to other clients.
func send_loadout(loadout_dict: Dictionary) -> void:
pending_loadout = loadout_dict.duplicate(true)
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)
+6
View File
@@ -9,3 +9,9 @@ extends Node
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
pass # Real implementation is on the server.
@rpc("any_peer", "call_remote", "reliable")
func rpc_set_loadout(_loadout: Dictionary) -> void:
pass # Real implementation is on the server.
+11
View File
@@ -112,6 +112,9 @@ func _ready() -> void:
_refresh_mouse_mode()
_update_camera()
_apply_avatar()
var avatars := get_node_or_null("/root/AvatarSystem")
if avatars != null and avatars.has_signal("loadout_changed"):
avatars.loadout_changed.connect(func(_lo): _apply_avatar())
func _build_crosshair() -> void:
@@ -147,6 +150,14 @@ func _apply_avatar() -> void:
if avatar == null:
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
return
if _avatar_view != null and is_instance_valid(_avatar_view):
remove_child(_avatar_view)
_avatar_view.queue_free()
if _limb_anim != null and is_instance_valid(_limb_anim):
remove_child(_limb_anim)
_limb_anim.queue_free()
_limb_anim = null
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
add_child(avatar)
_avatar_view = avatar
+11
View File
@@ -22,6 +22,9 @@ var spawn_position: Vector3 = Vector3(0, 1.5, 0)
## the client can drop acked inputs and replay the rest).
var last_applied_seq: int = 0
## Replicated avatar loadout (base_body, slots, skin_tone).
var loadout: Dictionary = {}
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
## appended by rpc_request_input, drained each physics frame.
var _input_queue: Array[Dictionary] = []
@@ -76,6 +79,7 @@ func get_server_state() -> Dictionary:
"rotation_y": rotation.y,
"velocity": velocity,
"on_floor": is_on_floor(),
"loadout": loadout,
}
@@ -84,3 +88,10 @@ func rpc_request_input(input: Dictionary, seq: int) -> void:
var d := input.duplicate()
d["seq"] = seq
_input_queue.append(d)
@rpc("any_peer", "call_remote", "reliable")
func rpc_set_loadout(p_loadout: Dictionary) -> void:
var sender := multiplayer.get_remote_sender_id()
if sender == peer_id or sender == 1 or sender == 0:
loadout = p_loadout.duplicate(true)
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Two real client processes with different avatar customizations against
# one real dedicated server:
# Machine A (customized character) and Machine B (uncustomized character).
# Client A must render Client B with NO customizations and NO pink/purple tint.
# Client B must render Client A WITH customizations and NO pink/purple tint.
set -uo pipefail
cd "$(dirname "$0")/.."
GODOT_BIN="${GODOT_BIN:-godot}"
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
PORT=7789
cleanup() { kill "${SRV_PID:-}" "${B_PID:-}" "${A_PID:-}" 2>/dev/null; wait 2>/dev/null; }
trap cleanup EXIT
echo "=== starting dedicated server on $PORT ==="
"$GODOT_BIN" --headless --path . res://scenes/server.tscn -- --port "$PORT" >/tmp/mp_avatar_server.log 2>&1 &
SRV_PID=$!
sleep 3
echo "=== starting client B (uncustomized character) ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_avatar_test_client.gd -- --role b --port "$PORT" >/tmp/mp_avatar_b.log 2>&1 &
B_PID=$!
for _ in $(seq 1 30); do grep -q 'RESULT b in_world' /tmp/mp_avatar_b.log && break; sleep 1; done
grep -q 'RESULT b in_world' /tmp/mp_avatar_b.log || { echo "B failed to enter world:"; cat /tmp/mp_avatar_b.log; exit 1; }
echo "=== running client A (customized character) ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_avatar_test_client.gd -- --role a --port "$PORT" >/tmp/mp_avatar_a.log 2>&1 &
A_PID=$!
wait "$A_PID" 2>/dev/null
A_STATUS=$?
wait "$B_PID" 2>/dev/null
B_STATUS=$?
A_LINE=$(grep '^RESULT a' /tmp/mp_avatar_a.log || true)
B_LINE=$(grep '^RESULT b' /tmp/mp_avatar_b.log || true)
echo
echo "server: $(grep -c 'joined' /tmp/mp_avatar_server.log) joins seen"
echo "A (Machine A observing Machine B): ${A_LINE:-<none>}"
echo "B (Machine B observing Machine A): ${B_LINE:-<none>}"
if [ $A_STATUS -eq 0 ] && [ $B_STATUS -eq 0 ] \
&& echo "$A_LINE" | grep -q 'ok=true' \
&& echo "$B_LINE" | grep -q 'ok=true'; then
echo "=== MULTIPLAYER AVATAR REPLICATION RESULT: PASS ==="
exit 0
fi
echo "=== MULTIPLAYER AVATAR REPLICATION RESULT: FAIL ==="
exit 1