fix(multiplayer): replicate per-player avatar loadout and preserve original materials
checkpoint / checkpoint (push) Successful in 5m57s
checkpoint / checkpoint (push) Successful in 5m57s
This commit is contained in:
@@ -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(), " ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user