23ec41c1d6
Adds test/multiPlayerTest.tscn plus a driver that runs the kitchen flow across two game instances: grab/drop, dirt station, sink washing, hob cooking, counter combining and plating. Runs headless (run_mp_test.ps1), in two visible windows (run_mp_test_windowed.ps1), or by hand with keyboard controls (play_mp_test.ps1). 108 checks, exits non-zero on failure. After every step both peers snapshot every item's position and rendered state and the server diffs them. Targeted assertions only look at the thing a step touched, which misses desyncs elsewhere - that audit is what caught the last bug below. Bugs found and fixed: - net_pickable: apply_held_state() only wrote `enabled` in its non-authority branch, so once a client grabbed an item every other peer set enabled=false and regaining authority never restored it. The server could then never pick that item up again, and a station would "snap" it (emitting has_picked_up, so a plate still got marked dirty) while pick_up() bailed out on the disabled item - leaving the zone holding an item with no grab driver. - network_manager: station gating only happened in the spawn path, so stations baked into a scene file kept running their snap zones on clients and grabbed items straight out of the local hand. Added gate_existing_stations(). - network_manager: despawn_item() only freed the server's copy. Items baked into a scene aren't tracked by the MultiplayerSpawner, so consuming one left a ghost on every client, which then blocked the station it sat in and got grabbed instead of its replacement. - network_manager: the snap-into-station decision read the server's own copy of the item position, but the reliable release RPC routinely overtakes the synchronizer's unordered position updates - so it acted on a stale position and teleported items back into the station they had just been carried away from. The releasing peer now sends its final transform and the server adopts it first. - container: contained_ids.append()/erase() mutate the array in place, which never fires the setter that rebuilds the plate's visuals. The peer that put food on a plate was the only peer that never redrew it; remote peers looked right because the synchronizer assigns there. Also null-guards XRServer.get_tracker() in the vendored xr-tools hand grab point, which threw on every successful grab without an XR runtime, and adds multiplayer_world.populate_from_layout so debug scenes can bake their own content instead of spawning the whole kitchen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
4.8 KiB
GDScript
118 lines
4.8 KiB
GDScript
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.
|
|
##
|
|
## 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.
|
|
|
|
const PLAYER_SCENE := preload("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.
|
|
@export var populate_from_layout: bool = true
|
|
|
|
var xr_interface: XRInterface
|
|
var _populated := false
|
|
|
|
|
|
func _ready() -> void:
|
|
xr_interface = XRServer.find_interface("OpenXR")
|
|
if xr_interface and xr_interface.is_initialized():
|
|
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
|
|
get_viewport().use_xr = true
|
|
|
|
NetworkManager.register_world(self, $PlayersSpawner, $ItemsSpawner)
|
|
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
|
|
# 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.
|
|
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 _exit_tree() -> void:
|
|
NetworkManager.unregister_world()
|
|
|
|
|
|
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.
|
|
func _populate_world_if_owner() -> void:
|
|
if not NetworkManager.owns_world() or _populated or not populate_from_layout:
|
|
return
|
|
_populated = true
|
|
GameManager.meals_in_play = ["hamburger"]
|
|
var stations := WorldLayout.get_stations()
|
|
var items := WorldLayout.get_items()
|
|
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.spawn_item(d["scene"], d["xform"], d["name"], d["props"])
|
|
NetworkManager.log_line("World populated")
|
|
|
|
|
|
## Only the server (or the single offline machine) materialises player
|
|
## avatars; MultiplayerSpawner replicates the result to everyone else,
|
|
## including late joiners.
|
|
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()
|
|
p.name = str(peer_id)
|
|
$Players.add_child(p, true)
|
|
NetworkManager.log_line("Spawned avatar for peer %d" % peer_id)
|
|
|
|
|
|
func _on_player_left(peer_id: int) -> void:
|
|
if not NetworkManager.owns_world():
|
|
return
|
|
var p := $Players.get_node_or_null(str(peer_id))
|
|
if p:
|
|
p.queue_free()
|
|
NetworkManager.log_line("Despawned avatar for peer %d" % peer_id)
|
|
|
|
|
|
func _on_session_ended() -> void:
|
|
NetworkManager.log_line("Session ended, returning to main menu")
|
|
get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn")
|
|
|
|
|
|
func _on_connection_failed() -> void:
|
|
NetworkManager.log_line("Connection failed, returning to main menu")
|
|
get_tree().change_scene_to_file("res://Scenes/mainMenu.tscn")
|