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:
+56
-60
@@ -1,26 +1,26 @@
|
||||
extends Node3D
|
||||
|
||||
## World script for the multiplayer scene. Consumes the host/join request set
|
||||
## by the main menu, spawns the world's stations/items on whichever machine
|
||||
## owns the world (server, or the local player when offline), spawns/despawns
|
||||
## a player avatar per connected peer, and returns to the menu if the session
|
||||
## ends.
|
||||
## World script for the multiplayer scene. Starts the host/join the menu asked
|
||||
## for, turns the authored kitchen into replicated objects, gives every connected
|
||||
## peer an avatar, and returns to the menu when the session ends.
|
||||
##
|
||||
## The world's actual content (stations, items) is NOT baked into this scene —
|
||||
## it's spawned at runtime from Net/world_layout.gd via NetworkManager, so a
|
||||
## joining client receives it from the server (MultiplayerSpawner replays
|
||||
## existing spawns to late joiners) instead of relying on its own local copy
|
||||
## matching.
|
||||
## The world's contents are NOT left baked into the scene file. Whatever the
|
||||
## scene authors is treated as a template: the server spawns real copies of it
|
||||
## through NetWorld and every peer drops its own authored originals. A client
|
||||
## therefore shows the server's world rather than assuming its local copy of the
|
||||
## scene matches — which is also exactly the path a player joining mid-session
|
||||
## takes.
|
||||
|
||||
const PLAYER_SCENE := preload("res://Player/net_player.tscn")
|
||||
const PLAYER_SCENE := "res://Player/net_player.tscn"
|
||||
|
||||
## Whether to spawn the full WorldLayout on the machine that owns the world.
|
||||
## The real game scene wants this; focused debug scenes (test/) bake their own
|
||||
## handful of stations and items instead and turn it off, so the thing under
|
||||
## test isn't sharing the world with a second copy of the whole kitchen.
|
||||
## Whether to build the full kitchen on the machine that owns the world. The real
|
||||
## game scene wants this; focused debug scenes bake their own handful of objects
|
||||
## and turn it off, so the thing under test is not sharing the world with a
|
||||
## second copy of the whole kitchen.
|
||||
@export var populate_from_layout: bool = true
|
||||
|
||||
var xr_interface: XRInterface
|
||||
var _net_world: NetWorld
|
||||
var _populated := false
|
||||
|
||||
|
||||
@@ -30,34 +30,38 @@ func _ready() -> void:
|
||||
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
|
||||
get_viewport().use_xr = true
|
||||
|
||||
NetworkManager.register_world(self, $PlayersSpawner, $ItemsSpawner)
|
||||
# Both roots go through the same generic replication hook, so a player avatar
|
||||
# is just another replicated object — nothing about it is special-cased.
|
||||
_net_world = NetWorld.new()
|
||||
_net_world.name = "NetWorld"
|
||||
add_child(_net_world)
|
||||
_net_world.setup($ItemsSpawner, $WorldContent)
|
||||
$PlayersSpawner.add_spawnable_scene(PLAYER_SCENE)
|
||||
$Players.child_entered_tree.connect(_on_player_entered)
|
||||
|
||||
NetworkManager.register_world(self, _net_world)
|
||||
NetworkManager.player_joined.connect(_on_player_joined)
|
||||
NetworkManager.player_left.connect(_on_player_left)
|
||||
NetworkManager.session_started.connect(_on_session_started)
|
||||
NetworkManager.session_ended.connect(_on_session_ended)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
|
||||
# world_ready() is what actually calls host()/join() (or the cmdline
|
||||
# equivalent). Populating before this point is wrong for EVERY case, not
|
||||
# world_ready() is what actually calls host()/join() (or the command-line
|
||||
# equivalent). Populating before this point would be wrong for every case, not
|
||||
# just offline: is_online() is still false until host()/join() runs, so
|
||||
# owns_world() would read true for a joining client too, and it would
|
||||
# build its own local copy instead of receiving the server's via the
|
||||
# spawner. host() emits session_started synchronously, which populates
|
||||
# via _on_session_started below; the explicit call after world_ready()
|
||||
# only matters for the case where neither host() nor join() ran (no
|
||||
# pending session, no cmdline args) — running this scene directly offline.
|
||||
# owns_world() reads true for a joining client too and it would build its own
|
||||
# copy instead of receiving the server's. host() emits session_started
|
||||
# synchronously, which populates via _on_session_started; the call after
|
||||
# world_ready() only covers the case where neither ran — this scene opened
|
||||
# directly, offline.
|
||||
NetworkManager.world_ready()
|
||||
_populate_world_if_owner()
|
||||
|
||||
get_tree().create_timer(3.0).timeout.connect(_log_world_state)
|
||||
|
||||
|
||||
# Temporary-ish sanity check: confirms WorldContent actually ended up
|
||||
# populated on this peer (whether by spawning it or by receiving it via
|
||||
# replication), so a silent replication failure shows up in the net log
|
||||
# instead of just an empty-looking world.
|
||||
func _log_world_state() -> void:
|
||||
NetworkManager.log_line("World state: WorldContent=%d children, Players=%d children" % [$WorldContent.get_child_count(), $Players.get_child_count()])
|
||||
func _on_player_entered(node: Node) -> void:
|
||||
if node is MultiplayerSynchronizer:
|
||||
return
|
||||
NetReplication.attach(node)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
@@ -65,29 +69,23 @@ func _exit_tree() -> void:
|
||||
|
||||
|
||||
func _on_session_started(_is_server: bool) -> void:
|
||||
NetworkManager.gate_existing_stations()
|
||||
_populate_world_if_owner()
|
||||
|
||||
|
||||
## Spawns the world's stations/items exactly once, on the machine that owns
|
||||
## world logic (server or offline). Safe to call multiple times/entry points.
|
||||
## Turns the authored template into replicated objects, exactly once, on the
|
||||
## machine that owns world logic. Safe to call from several entry points.
|
||||
func _populate_world_if_owner() -> void:
|
||||
if _populated or not populate_from_layout:
|
||||
return
|
||||
# WorldLayout reads the live scene tree to find what to replicate, so it
|
||||
# needs to be an instance sitting in that tree — its methods can't be called
|
||||
# on the class itself.
|
||||
# WorldLayout reads the live scene tree to find what to replicate, so it has
|
||||
# to be an instance sitting in that tree.
|
||||
var layout := WorldLayout.new()
|
||||
add_child(layout)
|
||||
var authored := layout.get_station_nodes()
|
||||
authored.append_array(layout.get_item_nodes())
|
||||
var authored := layout.get_authored_nodes()
|
||||
|
||||
# The stations and items authored into the scene file are a *template*, not
|
||||
# the live world. Only the server turns them into real objects, spawned
|
||||
# through NetworkManager so they replicate. Every peer therefore drops its
|
||||
# own authored copies: the client would otherwise show its local originals
|
||||
# on top of the server's replicated ones, and the two sets would drift apart
|
||||
# because only the server's are synced.
|
||||
# Every peer drops its authored copies. The client would otherwise show its
|
||||
# local originals on top of the server's replicated ones, and the two sets
|
||||
# would drift apart because only the server's are synced.
|
||||
if not NetworkManager.owns_world():
|
||||
NetworkManager.log_line("Clearing %d authored nodes; the server's copies replace them" % authored.size())
|
||||
_remove_authored(authored)
|
||||
@@ -96,21 +94,17 @@ func _populate_world_if_owner() -> void:
|
||||
|
||||
_populated = true
|
||||
GameManager.meals_in_play = ["hamburger"]
|
||||
var stations := layout.get_stations()
|
||||
var items := layout.get_items()
|
||||
var objects := layout.describe(authored)
|
||||
layout.queue_free()
|
||||
_remove_authored(authored)
|
||||
NetworkManager.log_line("Populating world: %d stations, %d items" % [stations.size(), items.size()])
|
||||
for d in stations:
|
||||
NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
|
||||
for d in items:
|
||||
NetworkManager.log_line("Populating world: %d objects" % objects.size())
|
||||
for d in objects:
|
||||
NetworkManager.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
|
||||
NetworkManager.log_line("World populated")
|
||||
|
||||
|
||||
# Free the authored template nodes. Done immediately rather than with
|
||||
# queue_free() so the names are released before the replicated copies are
|
||||
# spawned under the same ones.
|
||||
# Freed immediately rather than queue_free()d, so the names are released before
|
||||
# the replicated copies are spawned under the same ones.
|
||||
func _remove_authored(nodes: Array[Node]) -> void:
|
||||
for node in nodes:
|
||||
if is_instance_valid(node):
|
||||
@@ -118,15 +112,17 @@ func _remove_authored(nodes: Array[Node]) -> void:
|
||||
node.free()
|
||||
|
||||
|
||||
## Only the server (or the single offline machine) materialises player
|
||||
## avatars; MultiplayerSpawner replicates the result to everyone else,
|
||||
## including late joiners.
|
||||
## Only the server (or the single offline machine) creates avatars;
|
||||
## MultiplayerSpawner replicates the result to everyone else, late joiners
|
||||
## included.
|
||||
func _on_player_joined(peer_id: int) -> void:
|
||||
if not NetworkManager.owns_world() or $Players.has_node(str(peer_id)):
|
||||
return
|
||||
var p := PLAYER_SCENE.instantiate()
|
||||
var p: Node = load(PLAYER_SCENE).instantiate()
|
||||
# The name is the peer id, which is how net_player.gd knows whose avatar it
|
||||
# is on every peer — MultiplayerSpawner replicates the name.
|
||||
p.name = str(peer_id)
|
||||
$Players.add_child(p, true)
|
||||
$Players.add_child(p)
|
||||
NetworkManager.log_line("Spawned avatar for peer %d" % peer_id)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user