Fix five multiplayer sync bugs, add automated two-instance test
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>
This commit is contained in:
+14
-2
@@ -73,7 +73,16 @@ func _add_item(item: Node3D) -> void:
|
|||||||
|
|
||||||
var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
|
var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
|
||||||
if plate_controller:
|
if plate_controller:
|
||||||
plate_controller.contained_ids.append(food_node.id)
|
# Reassign rather than append in place. contained_ids has a setter that
|
||||||
|
# rebuilds the plate's visuals, and mutating the array never triggers it —
|
||||||
|
# so the peer that actually added the food was the one peer that never
|
||||||
|
# redrew the plate. Remote peers looked right (the synchronizer assigns
|
||||||
|
# the value there, which does fire the setter), and the stale peer only
|
||||||
|
# caught up if someone else took the plate and sent the value back.
|
||||||
|
# duplicate() keeps the Array[String] typing that the property requires.
|
||||||
|
var updated := plate_controller.contained_ids.duplicate()
|
||||||
|
updated.append(food_node.id)
|
||||||
|
plate_controller.contained_ids = updated
|
||||||
|
|
||||||
NetworkManager.despawn_item(item)
|
NetworkManager.despawn_item(item)
|
||||||
|
|
||||||
@@ -82,7 +91,10 @@ func erase_item(item: FoodItem) -> void:
|
|||||||
contained_items.erase(item)
|
contained_items.erase(item)
|
||||||
var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
|
var plate_controller := xr_pickable.get_node_or_null("PlateController") as PlateController
|
||||||
if plate_controller:
|
if plate_controller:
|
||||||
plate_controller.contained_ids.erase(item.id)
|
# Same reason as _add_item: assign so the visuals actually refresh.
|
||||||
|
var remaining := plate_controller.contained_ids.duplicate()
|
||||||
|
remaining.erase(item.id)
|
||||||
|
plate_controller.contained_ids = remaining
|
||||||
|
|
||||||
|
|
||||||
func clear() -> void:
|
func clear() -> void:
|
||||||
|
|||||||
+25
-1
@@ -20,6 +20,10 @@ var _pickable: XRToolsPickable
|
|||||||
# apply_held_state() last forced it to while non-authority.
|
# apply_held_state() last forced it to while non-authority.
|
||||||
var _original_freeze_mode: int
|
var _original_freeze_mode: int
|
||||||
|
|
||||||
|
# Same idea for the pickable's authored `enabled` flag, which the non-authority
|
||||||
|
# branch of apply_held_state() clears while someone else is holding the item.
|
||||||
|
var _original_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_pickable = get_parent() as XRToolsPickable
|
_pickable = get_parent() as XRToolsPickable
|
||||||
@@ -27,6 +31,7 @@ func _ready() -> void:
|
|||||||
push_error("NetPickable must be a child of an XRToolsPickable")
|
push_error("NetPickable must be a child of an XRToolsPickable")
|
||||||
return
|
return
|
||||||
_original_freeze_mode = _pickable.freeze_mode
|
_original_freeze_mode = _pickable.freeze_mode
|
||||||
|
_original_enabled = _pickable.enabled
|
||||||
_pickable.picked_up.connect(_on_picked_up)
|
_pickable.picked_up.connect(_on_picked_up)
|
||||||
_pickable.dropped.connect(_on_dropped)
|
_pickable.dropped.connect(_on_dropped)
|
||||||
# Deferred: the pickable root captures its own original_collision_mask/
|
# Deferred: the pickable root captures its own original_collision_mask/
|
||||||
@@ -72,6 +77,22 @@ func apply_held_state() -> void:
|
|||||||
)
|
)
|
||||||
_pickable.freeze_mode = _original_freeze_mode
|
_pickable.freeze_mode = _original_freeze_mode
|
||||||
_pickable.collision_mask = _pickable.original_collision_mask
|
_pickable.collision_mask = _pickable.original_collision_mask
|
||||||
|
# Unlike freeze/collision (which XRToolsPickable manages itself while
|
||||||
|
# held), `enabled` is only ever written by the non-authority branch
|
||||||
|
# below, so it must be restored here or it stays false forever: once a
|
||||||
|
# client grabbed this item, every other peer set enabled=false, and
|
||||||
|
# regaining authority left it that way. On the server that silently
|
||||||
|
# broke everything downstream — hands couldn't pick the item up again,
|
||||||
|
# and a station snap zone would "snap" it (emitting has_picked_up, so
|
||||||
|
# e.g. 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 that then fell out of the station.
|
||||||
|
if _pickable.enabled != _original_enabled:
|
||||||
|
if NetworkManager.is_online():
|
||||||
|
print("%s: reclaiming ownership, restoring enabled %s->%s" % [
|
||||||
|
_pickable.name, _pickable.enabled, _original_enabled
|
||||||
|
])
|
||||||
|
_pickable.enabled = _original_enabled
|
||||||
return
|
return
|
||||||
# A net_held_by/position sync update can race ahead of the
|
# A net_held_by/position sync update can race ahead of the
|
||||||
# authority-handoff RPC that's about to confirm a grab we just made
|
# authority-handoff RPC that's about to confirm a grab we just made
|
||||||
@@ -132,8 +153,11 @@ func _on_dropped(_p) -> void:
|
|||||||
print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
|
print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
|
||||||
_pickable.name, _pickable.linear_velocity, _pickable.angular_velocity
|
_pickable.name, _pickable.linear_velocity, _pickable.angular_velocity
|
||||||
])
|
])
|
||||||
|
# Send our own final transform too: we were the authority until now, and the
|
||||||
|
# server's copy may not have received our last position sync yet.
|
||||||
NetworkManager.release_item_authority_from(
|
NetworkManager.release_item_authority_from(
|
||||||
_pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity
|
_pickable.get_path(), _pickable.linear_velocity, _pickable.angular_velocity,
|
||||||
|
_pickable.global_transform
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+52
-8
@@ -121,8 +121,31 @@ func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "",
|
|||||||
## every peer when a tracked node exits the tree on the authority, so this is
|
## every peer when a tracked node exits the tree on the authority, so this is
|
||||||
## the single seam for destroying spawned items (works offline too).
|
## the single seam for destroying spawned items (works offline too).
|
||||||
func despawn_item(node: Node) -> void:
|
func despawn_item(node: Node) -> void:
|
||||||
if owns_world() and is_instance_valid(node):
|
if not owns_world() or not is_instance_valid(node):
|
||||||
log_line("despawn_item: %s" % node.name)
|
return
|
||||||
|
log_line("despawn_item: %s" % node.name)
|
||||||
|
# Items that came from the ItemsSpawner are despawned on every peer
|
||||||
|
# automatically when they leave the tree here. Items baked into a scene file
|
||||||
|
# are unknown to the spawner, so their removal has to be broadcast
|
||||||
|
# explicitly — otherwise every client keeps a ghost copy of an item the
|
||||||
|
# server has consumed, which then blocks the station it was sitting in and
|
||||||
|
# gets grabbed instead of the real item that replaced it.
|
||||||
|
if is_online() and not _is_spawner_tracked(node):
|
||||||
|
_despawn_static_item.rpc(node.get_path())
|
||||||
|
node.queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
# Items the ItemsSpawner replicates live under its spawn path; anything else was
|
||||||
|
# baked into the scene file and the spawner knows nothing about it.
|
||||||
|
func _is_spawner_tracked(node: Node) -> bool:
|
||||||
|
return _content_root != null and _content_root.is_ancestor_of(node)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _despawn_static_item(path: NodePath) -> void:
|
||||||
|
var node := get_node_or_null(path)
|
||||||
|
if node:
|
||||||
|
log_line("despawn_static_item: freeing %s (the server consumed it)" % node.name)
|
||||||
node.queue_free()
|
node.queue_free()
|
||||||
|
|
||||||
|
|
||||||
@@ -163,6 +186,19 @@ func _gate_station(node: Node) -> void:
|
|||||||
log_line("gated station (non-owner peer): %s" % node.name)
|
log_line("gated station (non-owner peer): %s" % node.name)
|
||||||
|
|
||||||
|
|
||||||
|
## Gate every station already sitting in the scene tree, for peers that don't
|
||||||
|
## own world logic. Stations that arrive through spawn_item() are gated as they
|
||||||
|
## are built (see _spawn_item_from_data), but ones baked into a scene file never
|
||||||
|
## pass through there — leaving a client running its own snap zones, which then
|
||||||
|
## grab items straight out of the local hand and fight the server's
|
||||||
|
## authoritative placement. Idempotent, so it's safe on every session start.
|
||||||
|
func gate_existing_stations() -> void:
|
||||||
|
if owns_world():
|
||||||
|
return
|
||||||
|
for station in get_tree().get_nodes_in_group("station"):
|
||||||
|
_gate_station(station)
|
||||||
|
|
||||||
|
|
||||||
# --- Item grab-authority transfer -----------------------------------------
|
# --- Item grab-authority transfer -----------------------------------------
|
||||||
|
|
||||||
## Called by NetPickable when this peer grabs an item by hand. Godot rejects
|
## Called by NetPickable when this peer grabs an item by hand. Godot rejects
|
||||||
@@ -209,27 +245,35 @@ func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void:
|
|||||||
## Called by NetPickable when this peer releases an item, forwarding its throw
|
## Called by NetPickable when this peer releases an item, forwarding its throw
|
||||||
## velocity so the server can resume simulating it. Same self-RPC issue as
|
## velocity so the server can resume simulating it. Same self-RPC issue as
|
||||||
## above: runs directly if we're the server.
|
## above: runs directly if we're the server.
|
||||||
func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3) -> void:
|
func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
|
||||||
if is_server():
|
if is_server():
|
||||||
_do_release_item_authority(item_path, lin, ang, multiplayer.get_unique_id())
|
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_unique_id())
|
||||||
else:
|
else:
|
||||||
_release_item_authority_rpc.rpc_id(1, item_path, lin, ang)
|
_release_item_authority_rpc.rpc_id(1, item_path, lin, ang, xform)
|
||||||
|
|
||||||
|
|
||||||
@rpc("any_peer", "reliable")
|
@rpc("any_peer", "reliable")
|
||||||
func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3) -> void:
|
func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
|
||||||
if not is_server():
|
if not is_server():
|
||||||
return
|
return
|
||||||
_do_release_item_authority(item_path, lin, ang, multiplayer.get_remote_sender_id())
|
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_remote_sender_id())
|
||||||
|
|
||||||
|
|
||||||
## Runs on the server. If released next to a station, the server snaps it in
|
## Runs on the server. If released next to a station, the server snaps it in
|
||||||
## (server-authoritative placement).
|
## (server-authoritative placement).
|
||||||
func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, sender: int) -> void:
|
func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D, sender: int) -> void:
|
||||||
log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender])
|
log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender])
|
||||||
_set_item_authority.rpc(item_path, 1)
|
_set_item_authority.rpc(item_path, 1)
|
||||||
var item := get_node_or_null(item_path)
|
var item := get_node_or_null(item_path)
|
||||||
if item is RigidBody3D:
|
if item is RigidBody3D:
|
||||||
|
# Adopt the releasing peer's own final transform rather than trusting our
|
||||||
|
# copy's. That peer was the item's authority right up to this moment, and
|
||||||
|
# its position updates travel on the synchronizer's separate, unordered
|
||||||
|
# channel — this reliable RPC routinely overtakes them, leaving our copy
|
||||||
|
# still sitting where the item was BEFORE the peer carried it away. The
|
||||||
|
# snap decision below then reads that stale position and teleports the
|
||||||
|
# item straight back into the station it was just picked up from.
|
||||||
|
item.global_transform = xform
|
||||||
item.freeze = false
|
item.freeze = false
|
||||||
item.linear_velocity = lin
|
item.linear_velocity = lin
|
||||||
item.angular_velocity = ang
|
item.angular_velocity = ang
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ extends Node3D
|
|||||||
|
|
||||||
const PLAYER_SCENE := preload("res://Player/net_player.tscn")
|
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 xr_interface: XRInterface
|
||||||
var _populated := false
|
var _populated := false
|
||||||
|
|
||||||
@@ -59,13 +65,14 @@ func _exit_tree() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_session_started(_is_server: bool) -> void:
|
func _on_session_started(_is_server: bool) -> void:
|
||||||
|
NetworkManager.gate_existing_stations()
|
||||||
_populate_world_if_owner()
|
_populate_world_if_owner()
|
||||||
|
|
||||||
|
|
||||||
## Spawns the world's stations/items exactly once, on the machine that owns
|
## 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.
|
## world logic (server or offline). Safe to call multiple times/entry points.
|
||||||
func _populate_world_if_owner() -> void:
|
func _populate_world_if_owner() -> void:
|
||||||
if not NetworkManager.owns_world() or _populated:
|
if not NetworkManager.owns_world() or _populated or not populate_from_layout:
|
||||||
return
|
return
|
||||||
_populated = true
|
_populated = true
|
||||||
GameManager.meals_in_play = ["hamburger"]
|
GameManager.meals_in_play = ["hamburger"]
|
||||||
|
|||||||
@@ -186,6 +186,12 @@ func _is_correct_hand(grabber : Node3D) -> bool:
|
|||||||
# Get the positional tracker
|
# Get the positional tracker
|
||||||
var tracker := XRServer.get_tracker(controller.tracker) as XRPositionalTracker
|
var tracker := XRServer.get_tracker(controller.tracker) as XRPositionalTracker
|
||||||
|
|
||||||
|
# Without an XR runtime (desktop/headless testing) there is no tracker, so
|
||||||
|
# we can't tell which hand this is. Treat it as "not the correct hand" —
|
||||||
|
# the same result the null deref below used to produce after erroring.
|
||||||
|
if not tracker:
|
||||||
|
return false
|
||||||
|
|
||||||
# If left hand then verify left controller
|
# If left hand then verify left controller
|
||||||
if hand == Hand.LEFT and tracker.hand != XRPositionalTracker.TRACKER_HAND_LEFT:
|
if hand == Hand.LEFT and tracker.hand != XRPositionalTracker.TRACKER_HAND_LEFT:
|
||||||
return false
|
return false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
uid://biu4qr3nr1eny
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Shared helper for placing the two game windows side by side.
|
||||||
|
#
|
||||||
|
# Godot's --position flag is ignored on this setup (the window lands at x=-7
|
||||||
|
# whatever you pass), so the windows are moved with the Win32 API after they
|
||||||
|
# come up. Dot-source this file to get Move-GameWindow.
|
||||||
|
|
||||||
|
if (-not ('MpWin' -as [type])) {
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
public class MpWin {
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags);
|
||||||
|
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
|
||||||
|
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
||||||
|
public struct RECT { public int Left, Top, Right, Bottom; }
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
# Godot's windows are DPI aware; PowerShell's process is not by default, so
|
||||||
|
# GetWindowRect/SetWindowPos would otherwise be talking in virtualised
|
||||||
|
# coordinates and the windows land nowhere near where we asked.
|
||||||
|
[void][MpWin]::SetProcessDPIAware()
|
||||||
|
|
||||||
|
# Moves a just-launched game window to (x, y) and returns its width, so the
|
||||||
|
# caller can place the next window immediately to its right. Never resizes:
|
||||||
|
# changing the window size independently of Godot's --resolution distorts the
|
||||||
|
# rendered aspect ratio.
|
||||||
|
function Move-GameWindow($proc, [int]$x, [int]$y) {
|
||||||
|
for ($i = 0; $i -lt 60 -and $proc.MainWindowHandle -eq 0; $i++) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
$proc.Refresh()
|
||||||
|
}
|
||||||
|
if ($proc.MainWindowHandle -eq 0) {
|
||||||
|
Write-Host " (window never appeared; leaving it where it is)"
|
||||||
|
return 620
|
||||||
|
}
|
||||||
|
# The handle shows up before Godot has finished sizing/positioning the
|
||||||
|
# window - moving it too early gets overwritten by Godot's own setup.
|
||||||
|
Start-Sleep -Milliseconds 2500
|
||||||
|
$h = $proc.MainWindowHandle
|
||||||
|
# SWP_NOSIZE (0x1) | SWP_NOZORDER (0x4)
|
||||||
|
[void][MpWin]::SetWindowPos($h, [IntPtr]::Zero, $x, $y, 0, 0, 0x0005)
|
||||||
|
$r = New-Object MpWin+RECT
|
||||||
|
[void][MpWin]::GetWindowRect($h, [ref]$r)
|
||||||
|
return [int]($r.Right - $r.Left)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
[gd_scene format=3 uid="uid://bodj8op527o2c"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" uid="uid://d1cefhe3yyyds" path="res://Scenes/multiplayer_world.gd" id="1_6uucx"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://j5s5wus7tuhb" path="res://XROrigin.tscn" id="2_j7vd1"]
|
||||||
|
[ext_resource type="Material" uid="uid://cmia50cfqxxo4" path="res://Textures/dev_material_3d.tres" id="3_ssbaf"]
|
||||||
|
[ext_resource type="Sky" uid="uid://c1abtpwhdm2d5" path="res://Textures/sky/NightSkyHDRI009_16K.tres" id="4_081u3"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://j7caslh27nor" path="res://Stations/Hob.tscn" id="5_t1fa7"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://dvrk268s7gkxh" path="res://Stations/sink.tscn" id="6_6jhmh"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://bp3v1jl8pctro" path="res://Containers/plate.tscn" id="7_bowes"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://cnjwtnhwh0i8q" path="res://Stations/dirt_station.tscn" id="8_pvl84"]
|
||||||
|
[ext_resource type="Script" uid="uid://biu4qr3nr1eny" path="res://test/mp_test_driver.gd" id="9_mptst"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://dpot5qie20vf6" path="res://Items/burger.tscn" id="10_51k0c"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://c0lknik4noobs" path="res://Items/BurgerBuns.tscn" id="11_psgbv"]
|
||||||
|
[ext_resource type="PackedScene" uid="uid://efaec6ymgabo" path="res://Stations/Counter.tscn" id="12_j5uvh"]
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_vlqg6"]
|
||||||
|
size = Vector3(15, 0.1, 15)
|
||||||
|
|
||||||
|
[sub_resource type="BoxMesh" id="BoxMesh_24d3s"]
|
||||||
|
material = ExtResource("3_ssbaf")
|
||||||
|
size = Vector3(15, 0.1, 15)
|
||||||
|
|
||||||
|
[sub_resource type="Environment" id="Environment_bvwq1"]
|
||||||
|
background_mode = 2
|
||||||
|
sky = ExtResource("4_081u3")
|
||||||
|
reflected_light_source = 2
|
||||||
|
ssr_enabled = true
|
||||||
|
ssao_enabled = true
|
||||||
|
ssil_enabled = true
|
||||||
|
sdfgi_enabled = true
|
||||||
|
|
||||||
|
[sub_resource type="BoxShape3D" id="BoxShape3D_arao0"]
|
||||||
|
size = Vector3(15, 20, 0.1)
|
||||||
|
|
||||||
|
[node name="Main" type="Node3D" unique_id=1312265607]
|
||||||
|
script = ExtResource("1_6uucx")
|
||||||
|
populate_from_layout = false
|
||||||
|
|
||||||
|
[node name="XROrigin3D" parent="." unique_id=2055526621 groups=["local_xr_origin"] instance=ExtResource("2_j7vd1")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
|
||||||
|
|
||||||
|
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=1376644384]
|
||||||
|
transform = Transform3D(-0.8660254, -0.43301278, 0.24999999, 0.3022632, -0.05510214, 0.9516306, -0.39829266, 0.8997021, 0.17860365, 0, 0, 0)
|
||||||
|
|
||||||
|
[node name="Floor" type="StaticBody3D" parent="." unique_id=122279514]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor" unique_id=958841648]
|
||||||
|
shape = SubResource("BoxShape3D_vlqg6")
|
||||||
|
|
||||||
|
[node name="MeshInstance3D" type="MeshInstance3D" parent="Floor/CollisionShape3D" unique_id=1939997056]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.00018164515, 0.0006352663, -0.002532661)
|
||||||
|
mesh = SubResource("BoxMesh_24d3s")
|
||||||
|
|
||||||
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
|
||||||
|
environment = SubResource("Environment_bvwq1")
|
||||||
|
|
||||||
|
[node name="WorldContent" type="Node3D" parent="." unique_id=291550153]
|
||||||
|
|
||||||
|
[node name="Players" type="Node3D" parent="." unique_id=1595463693]
|
||||||
|
|
||||||
|
[node name="ItemsSpawner" type="MultiplayerSpawner" parent="." unique_id=627119248]
|
||||||
|
spawn_path = NodePath("../WorldContent")
|
||||||
|
|
||||||
|
[node name="PlayersSpawner" type="MultiplayerSpawner" parent="." unique_id=106645565]
|
||||||
|
_spawnable_scenes = PackedStringArray("res://Player/net_player.tscn")
|
||||||
|
spawn_path = NodePath("../Players")
|
||||||
|
|
||||||
|
[node name="StaticBody3D" type="StaticBody3D" parent="." unique_id=4404969]
|
||||||
|
|
||||||
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="StaticBody3D" unique_id=998476672]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, 7.589958)
|
||||||
|
shape = SubResource("BoxShape3D_arao0")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D2" type="CollisionShape3D" parent="StaticBody3D" unique_id=340303902]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 9.197384, -7.509142)
|
||||||
|
shape = SubResource("BoxShape3D_arao0")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D3" type="CollisionShape3D" parent="StaticBody3D" unique_id=1193703037]
|
||||||
|
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -7.438612, 9.197384, -0.018813243)
|
||||||
|
shape = SubResource("BoxShape3D_arao0")
|
||||||
|
|
||||||
|
[node name="CollisionShape3D4" type="CollisionShape3D" parent="StaticBody3D" unique_id=1431367494]
|
||||||
|
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 7.502467, 9.197384, -0.018813023)
|
||||||
|
shape = SubResource("BoxShape3D_arao0")
|
||||||
|
|
||||||
|
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("5_t1fa7")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.5, 0)
|
||||||
|
|
||||||
|
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("6_6jhmh")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0.5, 0)
|
||||||
|
|
||||||
|
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("7_bowes")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.4050963, 1.1702834, 0.049627244)
|
||||||
|
|
||||||
|
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("8_pvl84")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2, 0.5, 0)
|
||||||
|
|
||||||
|
[node name="TestDriver" type="Node" parent="." unique_id=1002011928]
|
||||||
|
script = ExtResource("9_mptst")
|
||||||
|
|
||||||
|
[node name="raw_burger" parent="." unique_id=1675596942 instance=ExtResource("10_51k0c")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.28692234, 1.0149999, 0.3505687)
|
||||||
|
|
||||||
|
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("11_psgbv")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.40037438, 1.0094403, 0.34120744)
|
||||||
|
|
||||||
|
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("12_j5uvh")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1, 0.5, 0)
|
||||||
|
|
||||||
|
[node name="Counter2" parent="." instance=ExtResource("12_j5uvh")]
|
||||||
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0.5, 0)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Opens two windows on the multiplayer test scene in MANUAL mode, so you can
|
||||||
|
# drive the plate / dirt-station flow yourself and watch both peers.
|
||||||
|
#
|
||||||
|
# powershell -File test\play_mp_test.ps1
|
||||||
|
#
|
||||||
|
# No scripted sequence runs. Click a window to focus it, then:
|
||||||
|
#
|
||||||
|
# 1 grab the plate 2 drop it
|
||||||
|
# 3 carry it to the dirt zone 4 drop it at the dirt zone
|
||||||
|
# 5 check it snapped + went dirty
|
||||||
|
# 6 run the whole automatic sequence (server window only)
|
||||||
|
# 0 dump the current state of everything
|
||||||
|
# C toggle between the fixed debug camera and the XR rig camera
|
||||||
|
#
|
||||||
|
# Keys act on whichever window has focus, so you can grab on the CLIENT and
|
||||||
|
# watch the SERVER window follow. Each window opens on a fixed camera looking
|
||||||
|
# at the test area, overlays its own step log, and writes it to
|
||||||
|
# logs\mptest_server.log / logs\mptest_client.log.
|
||||||
|
#
|
||||||
|
# The typical repro: on the CLIENT press 1, 2 (grab and drop), then on the
|
||||||
|
# SERVER press 1, 2. Then on the CLIENT press 1, 3, 4 and press 5 on both.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
|
||||||
|
# Pass -Solo to open a single window with no networking, to compare the
|
||||||
|
# same steps against single-player behaviour.
|
||||||
|
[switch]$Solo
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$proj = Split-Path -Parent $PSScriptRoot
|
||||||
|
$scene = "res://test/multiPlayerTest.tscn"
|
||||||
|
|
||||||
|
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||||
|
|
||||||
|
function Start-Instance($extraArgs) {
|
||||||
|
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene"
|
||||||
|
if ($extraArgs) { $a += " -- $($extraArgs -join ' ')" }
|
||||||
|
return Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Solo) {
|
||||||
|
Write-Host "Opening a single offline window (no networking)."
|
||||||
|
$null = Move-GameWindow (Start-Instance $null) 300 100
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Opening SERVER window (left)..."
|
||||||
|
$w = Move-GameWindow (Start-Instance @("--server")) 20 60
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
Write-Host "Opening CLIENT window (right)..."
|
||||||
|
$null = Move-GameWindow (Start-Instance @("--join", "127.0.0.1")) (20 + $w + 12) 60
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Both windows are up. Click one to focus it, then press:"
|
||||||
|
Write-Host " 1 grab 2 drop 3 carry-to-dirt 4 drop-at-dirt 5 verify 0 dump"
|
||||||
|
Write-Host " 6 runs the whole automatic sequence (server window only) C toggles the camera"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Step logs: $(Join-Path $proj 'logs\mptest_server.log')"
|
||||||
|
Write-Host " $(Join-Path $proj 'logs\mptest_client.log')"
|
||||||
|
Write-Host "Close the windows when you're done (Esc quits)."
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Runs the headless two-instance multiplayer test (test/multiPlayerTest.tscn).
|
||||||
|
#
|
||||||
|
# powershell -File test\run_mp_test.ps1
|
||||||
|
#
|
||||||
|
# Starts a server instance and a client instance of the game with --xr-mode off
|
||||||
|
# (SteamVR's OpenXR runtime crashes a headless process), lets test/mp_test_driver.gd
|
||||||
|
# drive the scripted plate/dirt-station sequence, then prints both logs.
|
||||||
|
# Exits non-zero if any check failed.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
|
||||||
|
[int]$TimeoutSec = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$proj = Split-Path -Parent $PSScriptRoot
|
||||||
|
$scene = "res://test/multiPlayerTest.tscn"
|
||||||
|
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||||
|
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||||
|
|
||||||
|
foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||||
|
|
||||||
|
function Start-Instance($extraArgs) {
|
||||||
|
# Single argument string with the project path quoted: Start-Process does
|
||||||
|
# not quote array elements, so the space in the path would split it.
|
||||||
|
$a = "--headless --xr-mode off --path `"$proj`" $scene -- $($extraArgs -join ' ')"
|
||||||
|
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru -NoNewWindow
|
||||||
|
# Touching .Handle caches it, which is what makes .ExitCode readable later;
|
||||||
|
# without this it comes back empty even after the process has exited.
|
||||||
|
$null = $p.Handle
|
||||||
|
return $p
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting server..."
|
||||||
|
$server = Start-Instance @("--server", "--mptest")
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
Write-Host "Starting client..."
|
||||||
|
$client = Start-Instance @("--join", "127.0.0.1", "--mptest")
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 }
|
||||||
|
|
||||||
|
foreach ($p in @($server, $client)) {
|
||||||
|
if (-not $p.HasExited) { Write-Host "Killing pid $($p.Id) (still running)"; $p.Kill() }
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================== $($pair[0]) ========================"
|
||||||
|
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ExitCode is only populated on the process object after a WaitForExit() call,
|
||||||
|
# even when HasExited is already true - without this it reads back empty.
|
||||||
|
$server.WaitForExit(2000) | Out-Null
|
||||||
|
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "server exit code: $code"
|
||||||
|
exit $code
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Runs the two-instance multiplayer test in two VISIBLE windows, side by side,
|
||||||
|
# pausing between steps so you can watch what happens on each peer.
|
||||||
|
#
|
||||||
|
# powershell -File test\run_mp_test_windowed.ps1
|
||||||
|
#
|
||||||
|
# Same test as run_mp_test.ps1 (headless); this one is for watching it. Each
|
||||||
|
# window shows an on-screen overlay of the step log for that peer.
|
||||||
|
#
|
||||||
|
# To drive it yourself instead, see test\play_mp_test.ps1 (manual mode).
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Godot = "C:\Users\Aqua Aurora\Desktop\Godot_v4.7-stable_win64.exe",
|
||||||
|
# Seconds to pause between steps, and to hold the final state on screen.
|
||||||
|
[double]$Pause = 1.5,
|
||||||
|
[double]$Hold = 20,
|
||||||
|
[int]$TimeoutSec = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$proj = Split-Path -Parent $PSScriptRoot
|
||||||
|
$scene = "res://test/multiPlayerTest.tscn"
|
||||||
|
$serverLog = Join-Path $proj "logs/mptest_server.log"
|
||||||
|
$clientLog = Join-Path $proj "logs/mptest_client.log"
|
||||||
|
|
||||||
|
foreach ($f in @($serverLog, $clientLog)) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||||
|
|
||||||
|
. (Join-Path $PSScriptRoot "mp_window_layout.ps1")
|
||||||
|
|
||||||
|
function Start-Instance($extraArgs) {
|
||||||
|
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||||
|
# takes over, and two instances can't share a headset anyway).
|
||||||
|
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " +
|
||||||
|
"$($extraArgs -join ' ') --mptest --mptest-pause $Pause --mptest-hold $Hold"
|
||||||
|
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||||
|
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||||
|
$null = $p.Handle
|
||||||
|
return $p
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting SERVER window (left)..."
|
||||||
|
$server = Start-Instance @("--server")
|
||||||
|
$w = Move-GameWindow $server 20 60
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
Write-Host "Starting CLIENT window (right)..."
|
||||||
|
$client = Start-Instance @("--join", "127.0.0.1")
|
||||||
|
$null = Move-GameWindow $client (20 + $w + 12) 60
|
||||||
|
|
||||||
|
Write-Host "Watch the two windows (each has a fixed camera on the test area)."
|
||||||
|
Write-Host "Step logs: $serverLog"
|
||||||
|
Write-Host " $clientLog"
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||||
|
while ((Get-Date) -lt $deadline -and -not $server.HasExited) { Start-Sleep -Milliseconds 500 }
|
||||||
|
|
||||||
|
foreach ($p in @($server, $client)) {
|
||||||
|
if (-not $p.HasExited) { Write-Host "Killing pid $($p.Id) (still running)"; $p.Kill() }
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "======================== $($pair[0]) ========================"
|
||||||
|
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$server.WaitForExit(2000) | Out-Null
|
||||||
|
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "server exit code: $code"
|
||||||
|
exit $code
|
||||||
Reference in New Issue
Block a user