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
|
# Serialization
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
## Create a loadout from a JSON string.
|
## Create a loadout from a Dictionary.
|
||||||
static func from_json(json_string: String) -> AvatarLoadout:
|
static func from_dict(root: Dictionary) -> AvatarLoadout:
|
||||||
var loadout := _Self.new()
|
var loadout := _Self.new()
|
||||||
var data: Variant = JSON.parse_string(json_string)
|
if root == null:
|
||||||
if not (data is Dictionary):
|
loadout.validation_errors.append("Loadout root is null")
|
||||||
loadout.validation_errors.append("Loadout JSON root is not an object")
|
|
||||||
return loadout
|
return loadout
|
||||||
|
|
||||||
var root: Dictionary = data as Dictionary
|
|
||||||
|
|
||||||
# Parse base_body.
|
# Parse base_body.
|
||||||
if root.has("base_body"):
|
if root.has("base_body"):
|
||||||
var bb = root["base_body"]
|
var bb = root["base_body"]
|
||||||
@@ -111,8 +108,18 @@ static func from_json(json_string: String) -> AvatarLoadout:
|
|||||||
return loadout
|
return loadout
|
||||||
|
|
||||||
|
|
||||||
## Serialize this loadout to a JSON string.
|
## Create a loadout from a JSON string.
|
||||||
func to_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 := {
|
var data := {
|
||||||
"base_body": base_body,
|
"base_body": base_body,
|
||||||
"slots": {},
|
"slots": {},
|
||||||
@@ -133,7 +140,12 @@ func to_json() -> String:
|
|||||||
"a": skin_tone.a,
|
"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:
|
func ensure_catalog() -> bool:
|
||||||
if is_catalog_loaded:
|
if is_catalog_loaded:
|
||||||
return true
|
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
|
## Set the current player's loadout from the dict shape the backend's
|
||||||
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
## GET /api/avatar/loadout returns (i.e. AvatarLoadout.to_json() parsed).
|
||||||
## Returns true if it validated and was stored.
|
## Returns true if it validated and was stored.
|
||||||
func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
|
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():
|
if not lo.validate():
|
||||||
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
push_warning("AvatarSystem: rejecting invalid loadout dict: %s"
|
||||||
% str(lo.validation_errors))
|
% str(lo.validation_errors))
|
||||||
@@ -287,24 +315,35 @@ func build_current_avatar() -> Node3D:
|
|||||||
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
||||||
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
|
## 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
|
## 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:
|
func assemble_avatar(loadout) -> Node3D:
|
||||||
if loadout == null or not is_catalog_loaded:
|
if loadout == null:
|
||||||
return 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:
|
if not validation.valid:
|
||||||
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
var l = AvatarLoader.new()
|
var l = AvatarLoader.new()
|
||||||
l.init_loader(catalog)
|
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:
|
if base == null:
|
||||||
l.free()
|
l.free()
|
||||||
return null
|
return null
|
||||||
|
|
||||||
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
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 slot_id == "accessory":
|
||||||
if v is Array:
|
if v is Array:
|
||||||
for aid in v:
|
for aid in v:
|
||||||
@@ -314,11 +353,11 @@ func assemble_avatar(loadout) -> Node3D:
|
|||||||
l.load_attachment(slot_id, v)
|
l.load_attachment(slot_id, v)
|
||||||
|
|
||||||
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
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():
|
if v is String and not (v as String).is_empty():
|
||||||
l.set_base_mesh_visible(slot_id, false)
|
l.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
l.apply_skin_tone(loadout.skin_tone)
|
l.apply_skin_tone(lo.skin_tone)
|
||||||
|
|
||||||
l.free()
|
l.free()
|
||||||
return base
|
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 _aligned := false # snapped to the server spawn yet?
|
||||||
var _remotes: Dictionary = {} # peer_id -> Node3D
|
var _remotes: Dictionary = {} # peer_id -> Node3D
|
||||||
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
|
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
|
var _status_label: Label = null
|
||||||
|
|
||||||
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
|
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
|
||||||
@@ -408,10 +409,30 @@ func _setup_networked() -> void:
|
|||||||
return
|
return
|
||||||
_networked = true
|
_networked = true
|
||||||
_hide_status()
|
_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.
|
# Physics-process AFTER the player so net_input reflects this frame.
|
||||||
process_physics_priority = 10
|
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:
|
func _physics_process(_delta: float) -> void:
|
||||||
if not _networked or _net == null or not _net.is_connected_to_server():
|
if not _networked or _net == null or not _net.is_connected_to_server():
|
||||||
return
|
return
|
||||||
@@ -441,11 +462,15 @@ func _sync_remotes(states: Dictionary, delta: float) -> void:
|
|||||||
for pid in states:
|
for pid in states:
|
||||||
var st: Dictionary = states[pid]
|
var st: Dictionary = states[pid]
|
||||||
var node: Node3D = _remotes.get(pid)
|
var node: Node3D = _remotes.get(pid)
|
||||||
|
var loadout: Variant = st.get("loadout", null)
|
||||||
if node == null:
|
if node == null:
|
||||||
node = _make_remote(pid)
|
node = _make_remote(pid, loadout)
|
||||||
_remotes[pid] = node
|
_remotes[pid] = node
|
||||||
add_child(node)
|
add_child(node)
|
||||||
node.global_position = st.get("position", Vector3.ZERO)
|
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)
|
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.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)
|
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[pid].queue_free()
|
||||||
_remotes.erase(pid)
|
_remotes.erase(pid)
|
||||||
_remote_prev.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()
|
var root := Node3D.new()
|
||||||
root.name = "Remote_%d" % pid
|
root.name = "Remote_%d" % pid
|
||||||
|
|
||||||
# Other players get the assembled avatar too (everyone shares the default
|
_setup_remote_visual(root, pid, loadout)
|
||||||
# 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)
|
|
||||||
|
|
||||||
var tag := Label3D.new()
|
var tag := Label3D.new()
|
||||||
|
tag.name = "NameTag"
|
||||||
tag.text = "Player %d" % pid
|
tag.text = "Player %d" % pid
|
||||||
tag.position = Vector3(0, 2.1, 0)
|
tag.position = Vector3(0, 2.1, 0)
|
||||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
@@ -513,12 +509,55 @@ func _make_remote(pid: int) -> Node3D:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
|
func _setup_remote_visual(root: Node3D, pid: int, loadout: Variant) -> void:
|
||||||
if node is MeshInstance3D:
|
var old_vis := root.get_node_or_null("Visual")
|
||||||
out.append(node)
|
if old_vis != null and is_instance_valid(old_vis):
|
||||||
for c in node.get_children():
|
root.remove_child(old_vis)
|
||||||
_all_mesh_instances(c, out)
|
old_vis.queue_free()
|
||||||
return out
|
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:
|
func _on_server_lost() -> void:
|
||||||
|
|||||||
@@ -329,7 +329,15 @@ func _prime_avatar() -> void:
|
|||||||
var res: Dictionary = await ApiClient.get_avatar_loadout()
|
var res: Dictionary = await ApiClient.get_avatar_loadout()
|
||||||
if res.ok and res.data is Dictionary and (res.data as Dictionary).has("data"):
|
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"])
|
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.
|
# No saved loadout reachable -- fall back to the catalog's first base body.
|
||||||
var bases: Array = avatars.get_assets_for_slot("base_body")
|
var bases: Array = avatars.get_assets_for_slot("base_body")
|
||||||
if bases.size() > 0:
|
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 my_peer_id: int = 0
|
||||||
var _server_root: Node
|
var _server_root: Node
|
||||||
var _player_proxy: Node
|
var _player_proxy: Node
|
||||||
|
var pending_loadout: Dictionary = {}
|
||||||
|
|
||||||
var _seq: int = 0
|
var _seq: int = 0
|
||||||
## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity},
|
## 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)
|
_server_root.add_child(_player_proxy)
|
||||||
|
|
||||||
connected_to_server.emit()
|
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:
|
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):
|
if _player_proxy and is_instance_valid(_player_proxy):
|
||||||
_player_proxy.rpc_id(1, "rpc_request_input",
|
_player_proxy.rpc_id(1, "rpc_request_input",
|
||||||
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
|
{"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")
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
|
func rpc_request_input(_input: Dictionary, _seq: int) -> void:
|
||||||
pass # Real implementation is on the server.
|
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()
|
_refresh_mouse_mode()
|
||||||
_update_camera()
|
_update_camera()
|
||||||
_apply_avatar()
|
_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:
|
func _build_crosshair() -> void:
|
||||||
@@ -147,6 +150,14 @@ func _apply_avatar() -> void:
|
|||||||
if avatar == null:
|
if avatar == null:
|
||||||
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
|
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
|
||||||
return
|
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)
|
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
||||||
add_child(avatar)
|
add_child(avatar)
|
||||||
_avatar_view = 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).
|
## the client can drop acked inputs and replay the rest).
|
||||||
var last_applied_seq: int = 0
|
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}
|
## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float}
|
||||||
## appended by rpc_request_input, drained each physics frame.
|
## appended by rpc_request_input, drained each physics frame.
|
||||||
var _input_queue: Array[Dictionary] = []
|
var _input_queue: Array[Dictionary] = []
|
||||||
@@ -76,6 +79,7 @@ func get_server_state() -> Dictionary:
|
|||||||
"rotation_y": rotation.y,
|
"rotation_y": rotation.y,
|
||||||
"velocity": velocity,
|
"velocity": velocity,
|
||||||
"on_floor": is_on_floor(),
|
"on_floor": is_on_floor(),
|
||||||
|
"loadout": loadout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -84,3 +88,10 @@ func rpc_request_input(input: Dictionary, seq: int) -> void:
|
|||||||
var d := input.duplicate()
|
var d := input.duplicate()
|
||||||
d["seq"] = seq
|
d["seq"] = seq
|
||||||
_input_queue.append(d)
|
_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