Rebuild multiplayer on the stock spawner and synchronizer
Adding an object to the game no longer requires any networking code. The
MultiplayerSpawner runs in its default mode — no spawn_function, no payload
dictionary — and NetReplication builds each object's SceneReplicationConfig
from a convention, so scenes carry no hand-authored replication at all.
Every replicated node gets two generated synchronizers: NetSync for script
state, always server-owned, and NetXform for position, handed to whoever is
holding the object. That split is what makes grab prediction work — a
synchronizer never applies inbound state on the peer that owns it, so a
player's own hand drives an object with no round trip while is_dirty and
friends keep flowing one way from the server.
Interaction is now two RPCs for the whole game (NetGrab), and client gating is
one rule applied to every object (NetWorld). Deleted: net_pickable.gd, the
replicated net_held_by field and its held-state juggling, the
grant/reject/force-release negotiation, the static-item despawn RPC, and the
per-scene replication configs. Authority is the single source of truth for who
simulates an object.
Two things the convention had to learn, both found by the test suite:
* Addon scripts are excluded. godot-xr-tools' snap zones and pickables expose
a public `enabled`, which is exactly the flag each peer must set for itself
— so replicating it meant the server sent `enabled = true` back over every
client's gate, and stations went on grabbing objects out of the local
player's hands.
* Arrays of nodes are excluded. Array[Node3D] and Array[FoodItem] would
otherwise try to serialise live node references.
table.gd's replicated state loses its underscore prefix, which now marks a
variable as private and unreplicated; two in-place array mutations there were
skipping their setters, and the progress bar could divide by zero on a client.
Suite: 146/146 passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
extends Node
|
||||
|
||||
## The whole interaction layer: two RPCs, for every object in the game.
|
||||
##
|
||||
## Registered as the "NetGrab" autoload so its RPCs resolve to the same node path
|
||||
## on every peer.
|
||||
##
|
||||
## A hand picks something up locally the instant the player grabs it — no round
|
||||
## trip, no waiting — and tells the server. The server answers by moving the
|
||||
## object's NetXform to that peer, which is what actually makes the prediction
|
||||
## work: a MultiplayerSynchronizer never applies inbound state on the peer that
|
||||
## owns it, so from that moment the holder's own hand drives the object and the
|
||||
## server's copy follows instead of fighting it. Releasing hands NetXform back,
|
||||
## and the server decides where the object really ends up.
|
||||
##
|
||||
## Note what is NOT here. There is no per-object networking component, no
|
||||
## replicated "who is holding this" field to keep in step with the authority, and
|
||||
## no grant/reject/force-release negotiation. Authority is the single source of
|
||||
## truth for who simulates an object, and NetWorld.apply_physics_role reads it.
|
||||
|
||||
|
||||
## Starts watching a pickable. Called by NetWorld for every replicated object, so
|
||||
## no scene has to include a networking node to take part.
|
||||
func watch(item: XRToolsPickable) -> void:
|
||||
if item.picked_up.is_connected(_on_picked_up):
|
||||
return
|
||||
item.picked_up.connect(_on_picked_up)
|
||||
item.dropped.connect(_on_dropped)
|
||||
|
||||
|
||||
func _on_picked_up(item: Node3D) -> void:
|
||||
if not NetworkManager.is_online():
|
||||
return
|
||||
# A station snap zone grabbing something is the server placing it, not a
|
||||
# player taking it, so it must not move authority anywhere.
|
||||
if not (item.get_picked_up_by() is XRToolsFunctionPickup):
|
||||
return
|
||||
NetworkManager.log_line("%s grabbed by hand, claiming it" % item.name)
|
||||
if NetworkManager.is_server():
|
||||
_claim(item.get_path(), 1)
|
||||
else:
|
||||
# rpc_id() to ourselves is rejected by Godot, which is why the server
|
||||
# branch calls straight through instead.
|
||||
_request_grab.rpc_id(1, item.get_path())
|
||||
|
||||
|
||||
func _on_dropped(item: Node3D) -> void:
|
||||
if not NetworkManager.is_online():
|
||||
return
|
||||
# Only the peer that was actually driving the object reports a release. A drop
|
||||
# caused by losing authority (see NetWorld.apply_physics_role) must not be
|
||||
# echoed back as if the player had let go.
|
||||
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
|
||||
if not xform or not xform.is_multiplayer_authority():
|
||||
return
|
||||
# Our own final transform travels with the release. We were driving the object
|
||||
# right up to this moment and our position updates ride the synchronizer's
|
||||
# separate, unordered channel, which this reliable message routinely
|
||||
# overtakes — so the server's copy can still be back where the object was
|
||||
# before we carried it away. Deciding the snap from that stale position
|
||||
# teleports the object straight back into the station it was just taken from.
|
||||
if NetworkManager.is_server():
|
||||
_settle(item.get_path(), item.global_transform, item.linear_velocity, item.angular_velocity)
|
||||
else:
|
||||
_request_release.rpc_id(
|
||||
1, item.get_path(), item.global_transform,
|
||||
item.linear_velocity, item.angular_velocity
|
||||
)
|
||||
|
||||
|
||||
# --- server side -----------------------------------------------------------
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _request_grab(item_path: NodePath) -> void:
|
||||
if not NetworkManager.is_server():
|
||||
return
|
||||
_claim(item_path, multiplayer.get_remote_sender_id())
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _request_release(item_path: NodePath, xform: Transform3D, lin: Vector3, ang: Vector3) -> void:
|
||||
if not NetworkManager.is_server():
|
||||
return
|
||||
_settle(item_path, xform, lin, ang)
|
||||
|
||||
|
||||
func _claim(item_path: NodePath, peer: int) -> void:
|
||||
var item := get_node_or_null(item_path)
|
||||
if not item:
|
||||
return
|
||||
NetworkManager.log_line("grant %s to peer %d" % [item.name, peer])
|
||||
# Order matters: hand the object over first, so the station's zone has already
|
||||
# stopped owning it by the time it is told to let go.
|
||||
_set_xform_authority.rpc(item_path, peer)
|
||||
NetStations.release_from_zones(self, item)
|
||||
|
||||
|
||||
func _settle(item_path: NodePath, xform: Transform3D, lin: Vector3, ang: Vector3) -> void:
|
||||
var item := get_node_or_null(item_path)
|
||||
if not item:
|
||||
return
|
||||
NetworkManager.log_line("release %s" % item.name)
|
||||
if item is Node3D:
|
||||
item.global_transform = xform
|
||||
_set_xform_authority.rpc(item_path, 1)
|
||||
if item is RigidBody3D:
|
||||
item.linear_velocity = lin
|
||||
item.angular_velocity = ang
|
||||
NetStations.try_snap.call_deferred(self, item)
|
||||
|
||||
|
||||
## set_multiplayer_authority is a local call, so every peer has to run it for
|
||||
## them to agree on who is driving the object.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _set_xform_authority(item_path: NodePath, peer: int) -> void:
|
||||
var item := get_node_or_null(item_path)
|
||||
if not item:
|
||||
return
|
||||
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
|
||||
if not xform:
|
||||
return
|
||||
xform.set_multiplayer_authority(peer)
|
||||
NetworkManager.apply_physics_role(item)
|
||||
|
||||
|
||||
## Hands everything a departing peer was holding back to the server. Without
|
||||
## this, anything still in their hand when they dropped out would be frozen
|
||||
## forever on every remaining peer, waiting on an authority that has gone.
|
||||
func reclaim_from(peer: int) -> void:
|
||||
if not NetworkManager.is_server():
|
||||
return
|
||||
for item in NetworkManager.replicated_objects():
|
||||
var xform := item.get_node_or_null(NetReplication.XFORM_NAME)
|
||||
if xform and xform.get_multiplayer_authority() == peer:
|
||||
NetworkManager.log_line("reclaiming %s from departed peer %d" % [item.name, peer])
|
||||
_settle(item.get_path(), item.global_transform, Vector3.ZERO, Vector3.ZERO)
|
||||
Reference in New Issue
Block a user