Files
VRyHungry1/Net/net_world.gd
algodoogle 61d92052ac 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>
2026-07-28 22:04:22 +01:00

252 lines
10 KiB
GDScript

extends Node
class_name NetWorld
## Owns the replicated contents of the world: what exists, where it lives, and
## which peer is allowed to simulate it.
##
## Everything here runs on Godot's stock MultiplayerSpawner in its DEFAULT mode.
## There is no spawn_function and no payload dictionary: the server instantiates
## a scene, sets it up, and adds it under the spawn path — the spawner replicates
## the creation (and later the deletion) to every peer, including late joiners,
## and the synchronizers NetReplication attaches carry the state.
##
## The two hooks that make this generic, rather than something each object opts
## into:
##
## * content_root.child_entered_tree -> NetReplication.attach, on EVERY peer.
## The client's copy is built by the spawner straight from the .tscn, so this
## is what gives it synchronizers at all. It also runs early enough for them
## to pick up the spawn payload.
## * the same hook, on clients only -> _gate. One rule decides what a client is
## not allowed to simulate, for any object, whatever it happens to be.
## Directories scanned for spawnable scenes. Anything in them can be spawned
## over the network without being registered by hand anywhere.
const SPAWNABLE_DIRS := [
"res://Items/",
"res://Containers/",
"res://Stations/",
"res://Prefabs/",
]
var _spawner: MultiplayerSpawner
var _content_root: Node
## Wires this up to the world scene's spawner and content root. Must run on every
## peer BEFORE it connects, so the spawnable list is identical by the time any
## spawn packet can arrive.
func setup(spawner: MultiplayerSpawner, content_root: Node) -> void:
_spawner = spawner
_content_root = content_root
_register_spawnables()
_content_root.child_entered_tree.connect(_on_content_child_entered)
## Registers every scene under SPAWNABLE_DIRS, sorted.
##
## The sort is not cosmetic. Auto-spawn puts an INDEX into this list on the wire,
## not a path, so a peer whose list is ordered differently instantiates the wrong
## scene entirely. Directory listing order is not guaranteed to match across
## machines, so it is pinned here.
func _register_spawnables() -> void:
var scenes: Array[String] = []
for dir in SPAWNABLE_DIRS:
for file in ResourceLoader.list_directory(dir):
if file.ends_with(".tscn"):
scenes.append(dir + file)
scenes.sort()
for scene in scenes:
_spawner.add_spawnable_scene(scene)
NetworkManager.log_line("Registered %d spawnable scenes" % scenes.size())
func _on_content_child_entered(node: Node) -> void:
# Our own synchronizers re-enter here as children of the node, not of the
# content root, so this only ever sees spawned roots — but the guard is cheap
# and makes the intent explicit.
if node is MultiplayerSynchronizer:
return
NetReplication.attach(node)
# Grab handling is wired up centrally, so no scene has to carry a networking
# component to be pickable over the network.
if node is XRToolsPickable:
NetGrab.watch(node)
if not NetworkManager.owns_world():
_gate(node)
# Deferred, in this order, because both depend on the node being fully
# constructed: XRToolsPickable captures original_collision_mask in an @onready,
# which has not run yet at child_entered_tree time. Reading it now would
# record 0 as the object's authored collision mask and it would never collide
# with anything again.
_remember_authored.call_deferred(node)
apply_physics_role.call_deferred(node)
# --- spawning --------------------------------------------------------------
## Creates a networked object. Server-only when online (the spawner replicates
## it from there); works directly when offline.
##
## `props` are applied BEFORE the node enters the tree, so they ride the spawn
## packet as the synchronizer's spawn properties and every peer builds the object
## already configured. Note that this means their setters run before the node is
## in the tree, where @onready references are still null — any setter reachable
## this way has to be null-guarded.
func spawn(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node:
if not NetworkManager.owns_world():
return null
var scene: PackedScene = load(scene_path)
if not scene:
push_error("NetWorld.spawn: could not load scene %s" % scene_path)
return null
var inst := scene.instantiate()
if node_name != "":
inst.name = node_name
for key in props:
inst.set(key, props[key])
if inst is Node3D:
inst.transform = xform
_content_root.add_child(inst)
# global_transform can only be honoured once the node has a parent to be
# global relative to. Content roots are normally at the origin, but a debug
# scene is free to move one.
if inst is Node3D:
inst.global_transform = xform
NetworkManager.log_line("spawn: %s as %s" % [scene_path.get_file(), inst.name])
return inst
## Destroys a networked object everywhere. The spawner broadcasts the despawn
## when a tracked node leaves the tree on the authority, so freeing it here is
## the whole implementation — there is no despawn RPC any more.
##
## Callers reach this holding all sorts of nodes (a component such as
## DespawningItem, or a visual deep inside a plate), so it walks up to the object
## the spawner actually knows about. Getting that wrong used to leave a ghost
## copy on every client, which then blocked the station it was sitting in.
func despawn(node: Node) -> void:
if not NetworkManager.owns_world() or not is_instance_valid(node):
return
var root := _spawned_root(node)
if not root:
# Not part of the replicated world at all (a cosmetic copy parented under
# a plate, say). Freeing it locally is all that was ever meant.
node.queue_free()
return
NetworkManager.log_line("despawn: %s" % root.name)
root.queue_free()
# The ancestor that is a direct child of the content root — i.e. the node the
# spawner tracks — or null if this node is not part of the replicated world.
func _spawned_root(node: Node) -> Node:
var current := node
while current:
if current.get_parent() == _content_root:
return current
current = current.get_parent()
return null
## Every replicated object currently in the world.
func objects() -> Array[Node]:
var found: Array[Node] = []
if _content_root:
for child in _content_root.get_children():
found.append(child)
return found
# --- what a client is not allowed to simulate ------------------------------
## The one gating rule, applied to every replicated object on peers that do not
## own world logic.
##
## Stations decide things (what has cooked, what is clean, what snaps where) and
## those decisions are the server's, so a client neither runs their logic nor
## lets their snap zones grab anything. Without this each peer independently
## grabs and simulates the same shared object, and the copies drift apart.
##
## Note what is NOT here: display code. A client still has to show a lit hob and
## a dirty plate, so anything visual must be driven from a replicated value's
## setter rather than from _process — which is where it belongs anyway, since
## that is the only version that also works for a late joiner.
func _gate(node: Node) -> void:
if node.is_in_group("station"):
node.set_process(false)
var zones := _snap_zones_of(node)
for zone in zones:
zone.enabled = false
zone.set_process(false)
NetworkManager.log_line("gated %s (%d snap zones)" % [node.name, zones.size()])
func _snap_zones_of(node: Node) -> Array:
var zones := []
for child in node.get_children():
if child is XRToolsSnapZone:
zones.append(child)
return zones
## Puts a body into the right physics state for whether this peer is currently
## driving it, which is exactly "do we own its NetXform".
##
## The authority IS the state here. The old implementation carried a replicated
## `net_held_by` peer id alongside the authority and reconciled the two by hand,
## which is what made held items get stuck frozen, or disabled forever, when the
## two disagreed. There is only one source of truth now.
##
## Called when an object is created and again whenever its NetXform changes
## hands, which is the complete set of moments the answer can change.
func apply_physics_role(node: Node) -> void:
if not (node is RigidBody3D):
return
var xform := node.get_node_or_null(NetReplication.XFORM_NAME)
if not xform:
return
var body: RigidBody3D = node
if xform.is_multiplayer_authority():
# We simulate it: restore whatever the scene authored. XRToolsPickable
# manages freeze and collision itself while an object is actually in a
# hand, so a held object is left alone.
if body is XRToolsPickable and body.is_picked_up():
return
body.freeze = false
body.freeze_mode = body.get_meta("net_freeze_mode", body.freeze_mode)
if body is XRToolsPickable:
body.collision_mask = body.original_collision_mask
# `enabled` has to be restored explicitly. Nothing else ever writes it
# back: the non-authority branch below clears it, so once any peer had
# held this object every other peer left it disabled forever. On the
# server that quietly broke everything downstream — hands could no
# longer pick the object up, while a snap zone still reported having
# grabbed it.
body.enabled = body.get_meta("net_enabled", body.enabled)
return
# Someone else drives it: stop simulating and just follow the sync. Kinematic
# rather than static so the incoming transform can still move it.
if body is XRToolsPickable and body.is_picked_up():
body.drop()
body.freeze = true
body.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
body.collision_mask = 0
if body is XRToolsPickable:
# Still grabbable if the SERVER owns it, because that just means the object
# is lying around loose — being able to pick those up is the entire point.
# Only an object held by another player is off limits, and that is exactly
# when NetXform belongs to a peer other than 1.
body.enabled = xform.get_multiplayer_authority() == 1
# Records the state the scene authored, before gating has a chance to overwrite
# it, so reclaiming an object restores what it was built with rather than
# whatever the frozen-follower state last forced on it.
func _remember_authored(node: Node) -> void:
if not (node is RigidBody3D) or node.has_meta("net_freeze_mode"):
return
node.set_meta("net_freeze_mode", node.freeze_mode)
if node is XRToolsPickable:
node.set_meta("net_enabled", node.enabled)