12 Commits

Author SHA1 Message Date
JonShard 34a649f507 Kanban 2026-07-26 16:14:29 +02:00
JonShard e04c5519d0 Move stations to floor 2026-07-26 16:00:30 +02:00
algodoogle 7ac87984bc Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-26 14:33:20 +01:00
algodoogle 776aaa3020 bug fix 2026-07-26 14:33:15 +01:00
JonShard 5ca64b8dff Fix error about custom_solver_bias not supported 2026-07-26 15:05:05 +02:00
algodoogle ae3e7ec674 jiff 2026-07-26 13:49:46 +01:00
algodoogle 23ec41c1d6 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>
2026-07-26 02:00:50 +01:00
algodoogle 33ef81306d asd 2026-07-25 21:58:14 +01:00
JonShard 50efeeb353 Merge branch 'multi2' of https://git.offcoursegames.com/JonShard/VRyHungry1 into multi2 2026-07-25 22:11:03 +02:00
JonShard 71d01e5be1 Add game export preset 2026-07-25 22:09:46 +02:00
algodoogle 87641dd55c Enable file logging to logs/, gitignore its output
Pairs with the debug-log-heavy investigation workflow: Godot writes its
own engine log to logs/godot.log alongside the existing NetworkManager
temp-file log, and the directory is excluded from version control since
it's runtime output, not source.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:07:29 +01:00
algodoogle d65b2ca863 Add diagnostic logging for item grab/authority state transitions
The snap-zone crash fixed just before this was invisible in the logs
until traced through addon source by hand — the log showed grabs,
drops, and authority handoffs, but nothing about which peer/hand/zone
actually held an item at each step, or when a guard clause silently
changed behavior.

Adds a _holder_desc() helper (reports "loose", "hand(<path>)",
"zone(<station>)", or "other(...)") and logs every net_held_by
transition, every reclaim that has to restore freeze_mode/collision_mask
from a stuck state, every time the grab-race guard protects an actively-
held item from a stale sync, every force-drop triggered by a losing
authority race, and every pickup/drop event including the ones that
don't get forwarded (picked up by something other than a hand, or
dropped while not authority — exactly the silent case behind the
snap-zone crash). NetworkManager's snap/release-from-zone calls get the
same treatment: which station an item is pulled from, and whether a
snap attempt succeeded, was skipped by the race guard, or found nothing
in range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:07:21 +01:00
23 changed files with 2172 additions and 68 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
.build/
/android/ /android/
.log /logs
*.log
+41 -4
View File
@@ -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:
@@ -129,6 +141,9 @@ func refresh_visuals(ids: Array[String]) -> void:
_make_cosmetic(visual) _make_cosmetic(visual)
container_root.add_child(visual) container_root.add_child(visual)
# Must happen after add_child: entering the world is what puts the body
# into the physics space, so it can only be taken out again afterwards.
_remove_from_physics(visual)
visual.position = positions[idx].position visual.position = positions[idx].position
visual.rotation = positions[idx].rotation visual.rotation = positions[idx].rotation
@@ -140,10 +155,18 @@ func refresh_visuals(ids: Array[String]) -> void:
func _make_cosmetic(visual: Node3D) -> void: func _make_cosmetic(visual: Node3D) -> void:
var net_pickable := visual.get_node_or_null("NetPickable") var net_pickable := visual.get_node_or_null("NetPickable")
if net_pickable: if net_pickable:
net_pickable.queue_free() # Detach and free it outright rather than queue_free(): this runs before
# `visual` is added to the tree, and a merely-queued node still enters the
# tree with its parent and runs _ready() (which starts syncing and logging)
# before the queued deletion lands at the end of the frame.
visual.remove_child(net_pickable)
net_pickable.free()
if visual is RigidBody3D: if visual is RigidBody3D:
visual.freeze = true visual.freeze = true
visual.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC # STATIC, not KINEMATIC: a kinematic body is still driven by the physics
# engine (see _remove_from_physics), and "static decoration" is what this
# actually is.
visual.freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
visual.collision_layer = 0 visual.collision_layer = 0
visual.collision_mask = 0 visual.collision_mask = 0
if visual is XRToolsPickable: if visual is XRToolsPickable:
@@ -152,6 +175,20 @@ func _make_cosmetic(visual: Node3D) -> void:
visual.set_physics_process(false) visual.set_physics_process(false)
# Take a display-only copy out of the physics simulation completely.
#
# Freezing is not enough. Under Jolt (this project's physics engine) a frozen
# KINEMATIC RigidBody3D is still simulated: it is driven toward its target
# transform by velocity rather than being teleported. Parented to a plate that
# gets picked up and carried, it therefore lags behind, keeps its velocity, and
# overshoots — so the food visibly slid off the plate and ended up metres away,
# differently on each peer since each simulates its own copy. A body with no
# space is never touched by the engine, so it simply follows its parent.
func _remove_from_physics(visual: Node3D) -> void:
if visual is RigidBody3D:
PhysicsServer3D.body_set_space((visual as RigidBody3D).get_rid(), RID())
#plate (Pickalbe) #plate (Pickalbe)
#XRGrapPoints #XRGrapPoints
#container (script) (meal positions[1], side positions[4]) #container (script) (meal positions[1], side positions[4])
+7
View File
@@ -30,6 +30,13 @@ func _process(_delta: float) -> void:
func _set_contained_ids(value: Array[String]) -> void: func _set_contained_ids(value: Array[String]) -> void:
# Only rebuild when the contents actually changed. This property is
# replicated in ALWAYS mode, so the synchronizer assigns it every network
# tick on every peer that doesn't own the plate — and refresh_visuals()
# frees and re-instantiates a scene per item each time. That was thousands
# of throwaway nodes per run (and a log line from each one's NetPickable).
if contained_ids == value:
return
contained_ids = value contained_ids = value
# Deferred: this can be written by the replicated spawn payload before # Deferred: this can be written by the replicated spawn payload before
# this node's own @onready vars (container) have resolved. # this node's own @onready vars (container) have resolved.
+1
View File
@@ -22,3 +22,4 @@ static func get_random_side() -> String:
var rand_index = randi() % sides_in_play.size() var rand_index = randi() % sides_in_play.size()
print("GameManager: get_random_side() returning ", sides_in_play[rand_index]) print("GameManager: get_random_side() returning ", sides_in_play[rand_index])
return sides_in_play[rand_index] return sides_in_play[rand_index]
-1
View File
@@ -11,7 +11,6 @@
[ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"] [ext_resource type="Script" path="res://Net/net_pickable.gd" id="20_netpk"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"] [sub_resource type="CylinderShape3D" id="CylinderShape3D_gkni8"]
custom_solver_bias = 0.1
height = 0.1 height = 0.1
radius = 0.1 radius = 0.1
+113 -4
View File
@@ -20,6 +20,15 @@ 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
# Whether the "our own hand still holds this" guard has already been logged for
# the current grab. apply_held_state() runs every network tick, so without this
# the guard message repeats for as long as you hold the item.
var _grab_race_logged := false
func _ready() -> void: func _ready() -> void:
_pickable = get_parent() as XRToolsPickable _pickable = get_parent() as XRToolsPickable
@@ -27,6 +36,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/
@@ -38,17 +48,31 @@ func _ready() -> void:
func _set_net_held_by(value: int) -> void: func _set_net_held_by(value: int) -> void:
var old := net_held_by
net_held_by = value net_held_by = value
if old != value and NetworkManager.is_online():
print("%s net_held_by: %d -> %d (local state: %s, authority=%d)" % [
_pickable.name, old, value, _holder_desc(), get_multiplayer_authority()
])
apply_held_state() apply_held_state()
## Puts the item in the right physics state for whether this peer currently ## Puts the item in the right physics state for whether this peer currently
## owns it. Called locally after net_held_by changes, and directly by ## owns it. Called locally after net_held_by changes, and directly by
## NetworkManager._set_item_authority right after an authority handoff. ## NetworkManager._set_item_authority right after an authority handoff.
##
## IMPORTANT: this runs on every network tick, not just on a real change.
## net_held_by is replicated in ALWAYS mode, so the synchronizer assigns it every
## tick on non-authority peers — unchanged value included — and that assignment
## lands in _set_net_held_by(), which calls this. So every branch here has to be
## idempotent and silent when there is nothing to do: otherwise each item logs a
## line and rewrites four physics properties every tick on every peer that
## doesn't own it.
func apply_held_state() -> void: func apply_held_state() -> void:
if not _pickable: if not _pickable:
return return
if not NetworkManager.is_online() or is_multiplayer_authority(): if not NetworkManager.is_online() or is_multiplayer_authority():
_grab_race_logged = false
# We own this item's simulation (offline, loose+server, or currently # We own this item's simulation (offline, loose+server, or currently
# holding it). If it's not actively in our own hand right now, make # holding it). If it's not actively in our own hand right now, make
# sure it isn't still left frozen/collision-less from a previous # sure it isn't still left frozen/collision-less from a previous
@@ -56,8 +80,34 @@ func apply_held_state() -> void:
# client released it) — while actually held, XRToolsPickable's own # client released it) — while actually held, XRToolsPickable's own
# pick_up()/let_go() already manage these fields, so leave those be. # pick_up()/let_go() already manage these fields, so leave those be.
if not _pickable.is_picked_up(): if not _pickable.is_picked_up():
var changed := _pickable.freeze_mode != _original_freeze_mode \
or _pickable.collision_mask != _pickable.original_collision_mask
if changed:
if NetworkManager.is_online():
print(
"%s: reclaiming ownership, restoring freeze_mode %d->%d collision_mask %d->%d" % [
_pickable.name, _pickable.freeze_mode, _original_freeze_mode,
_pickable.collision_mask, _pickable.original_collision_mask
]
)
_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
@@ -66,14 +116,43 @@ func apply_held_state() -> void:
# hand mid-grab — only an explicit force_release_item rejection, or # hand mid-grab — only an explicit force_release_item rejection, or
# actually losing authority for real, should end a grab we initiated. # actually losing authority for real, should end a grab we initiated.
if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup: if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup:
# Log once per grab, not once per tick.
if NetworkManager.is_online() and not _grab_race_logged:
_grab_race_logged = true
print(
"%s: ignoring non-authority sync (net_held_by=%d) — still actively held by our own hand (grab-race guard)" % [
_pickable.name, net_held_by
]
)
return return
_grab_race_logged = false
# Someone else owns it: stop simulating locally, just follow the sync. # Someone else owns it: stop simulating locally, just follow the sync.
if _pickable.is_picked_up(): if _pickable.is_picked_up():
if NetworkManager.is_online():
print(
"%s: was held by %s on this peer, but authority now says peer %d owns it — force-dropping" % [
_pickable.name, _holder_desc(), net_held_by
]
)
_pickable.drop() _pickable.drop()
# Bail out when we're already in the follow-the-sync state. Without this the
# writes below (and the line logged with them) repeated every tick for every
# item on every non-authority peer — 90% of the log, plus four redundant
# physics-property writes per item per tick. The comparison also means we
# still re-apply if something else perturbs the state (e.g. let_go()
# restoring the collision mask after a force-drop).
var want_enabled := (net_held_by == 0)
if _pickable.freeze \
and _pickable.freeze_mode == RigidBody3D.FREEZE_MODE_KINEMATIC \
and _pickable.collision_mask == 0 \
and _pickable.enabled == want_enabled:
return
if NetworkManager.is_online():
print("%s: freezing (non-authority, owner=peer %d)" % [_pickable.name, net_held_by])
_pickable.freeze = true _pickable.freeze = true
_pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC _pickable.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
_pickable.collision_mask = 0 _pickable.collision_mask = 0
_pickable.enabled = (net_held_by == 0) _pickable.enabled = want_enabled
## Local hand grab (not a station snap zone, which is server-only): request ## Local hand grab (not a station snap zone, which is server-only): request
@@ -82,9 +161,14 @@ func apply_held_state() -> void:
func _on_picked_up(_p) -> void: func _on_picked_up(_p) -> void:
var by := _pickable.get_picked_up_by() var by := _pickable.get_picked_up_by()
if not (by is XRToolsFunctionPickup): if not (by is XRToolsFunctionPickup):
# e.g. a station snap zone grabbed it (server-side auto-snap, or the
# addon's own "grab out of a snap zone" shortcut mid-cascade) — not a
# player-initiated hand grab, so no authority request from here.
if NetworkManager.is_online():
print("%s picked up by %s (not a hand) — no authority request" % [_pickable.name, _holder_desc()])
return return
if NetworkManager.is_online(): if NetworkManager.is_online():
NetworkManager.log_line("Grabbed %s by hand, requesting authority" % _pickable.name) print("%s grabbed by hand (authority was peer %d), requesting authority" % [_pickable.name, net_held_by])
NetworkManager.request_item_authority_from(_pickable.get_path()) NetworkManager.request_item_authority_from(_pickable.get_path())
@@ -92,9 +176,34 @@ func _on_dropped(_p) -> void:
# Only forward if we're actually still the authority — a drop caused by # Only forward if we're actually still the authority — a drop caused by
# apply_held_state() losing authority (see above) must not re-report. # apply_held_state() losing authority (see above) must not re-report.
if not is_multiplayer_authority(): if not is_multiplayer_authority():
if NetworkManager.is_online():
print("%s dropped locally, but we aren't its authority (peer %d is) — not reporting" % [_pickable.name, net_held_by])
return return
if NetworkManager.is_online(): if NetworkManager.is_online():
NetworkManager.log_line("Dropped %s, reporting release to server" % _pickable.name) print("%s dropped, reporting release to server (lin=%s ang=%s)" % [
_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
) )
## Human-readable description of what's currently holding this item on THIS
## peer, for diagnosing desyncs between a hand's own "what am I holding"
## bookkeeping and the item's actual grab state (see the snap-zone-grab race
## in NetworkManager._try_snap_into_station for a real example).
func _holder_desc() -> String:
if not _pickable or not _pickable.is_picked_up():
return "loose"
var by := _pickable.get_picked_up_by()
if not by:
return "held(no grabber?)"
if by is XRToolsFunctionPickup:
return "hand(%s)" % by.get_path()
if by is XRToolsSnapZone:
var station := by.get_parent()
return "zone(%s)" % (station.name if station else str(by.get_path()))
return "other(%s: %s)" % [by.get_class(), by.get_path()]
+73 -8
View File
@@ -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):
return
log_line("despawn_item: %s" % node.name) 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,31 +245,39 @@ 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
_try_snap_into_station(item) _try_snap_into_station.call_deferred(item)
# All station snap zones in the world (every XRToolsSnapZone child of a node in # All station snap zones in the world (every XRToolsSnapZone child of a node in
@@ -251,13 +295,33 @@ func _station_snap_zones() -> Array:
func _release_from_snap_zones(item: Node) -> void: func _release_from_snap_zones(item: Node) -> void:
for zone in _station_snap_zones(): for zone in _station_snap_zones():
if zone.picked_up_object == item: if zone.picked_up_object == item:
log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name])
zone.drop_object() zone.drop_object()
# Snap the item into the nearest empty station snap zone within grab range. # Snap the item into the nearest empty station snap zone within grab range.
#
# Called deferred from _do_release_item_authority: XRToolsFunctionPickup's own
# "grab an item out of a snap zone" path calls zone.drop_object() BEFORE it
# calls pick_up() on the hand's behalf. drop_object()'s let_go() synchronously
# fires the pickable's `dropped` signal, which (via NetPickable) lands here —
# if this ran synchronously it would immediately re-snap the item into the
# very same zone it's still physically inside, stealing it away before the
# hand's own pick_up() call (later in the same call stack) ever runs. That
# leaves XRToolsFunctionPickup.picked_up_object pointing at an item whose
# _grab_driver actually belongs to the zone — a stale reference that crashes
# (null _grab_driver) the next time a controller button is pressed. Deferring
# lets the hand's pick_up() go first; the is_picked_up() check below is a
# second guard in case the item gets grabbed for real before this runs.
func _try_snap_into_station(item: Node) -> void: func _try_snap_into_station(item: Node) -> void:
if not (item is Node3D): if not (item is Node3D):
return return
if item.has_method("is_picked_up") and item.is_picked_up():
var by: Node = null
if item.has_method("get_picked_up_by"):
by = item.get_picked_up_by()
log_line("skipped snapping %s: already held by %s (grab-race guard)" % [item.name, by.get_path() if by else "?"])
return
for zone in _station_snap_zones(): for zone in _station_snap_zones():
if is_instance_valid(zone.picked_up_object): if is_instance_valid(zone.picked_up_object):
continue continue
@@ -265,6 +329,7 @@ func _try_snap_into_station(item: Node) -> void:
log_line("snapped %s into %s" % [item.name, zone.get_parent().name]) log_line("snapped %s into %s" % [item.name, zone.get_parent().name])
zone.pick_up_object(item) zone.pick_up_object(item)
return return
log_line("no station in range to snap %s into (or none empty)" % item.name)
# Server broadcasts an authority assignment so every peer agrees on who owns the # Server broadcasts an authority assignment so every peer agrees on who owns the
-3
View File
@@ -72,13 +72,10 @@ func _on_body_entered(body: Node3D) -> void:
# Instantiate the result of combination and free the two ingredient items # Instantiate the result of combination and free the two ingredient items
func _combine(other_body: Node3D, result: PackedScene) -> void: func _combine(other_body: Node3D, result: PackedScene) -> void:
print("CombinableItem _combine: combining %s + %s into %s" % [_food_item.id, _find_food_item(other_body).id, result.resource_path]) print("CombinableItem _combine: combining %s + %s into %s" % [_food_item.id, _find_food_item(other_body).id, result.resource_path])
_combining = true
# Get Snapzone # Get Snapzone
var snap_zone := _pickable.get_picked_up_by() var snap_zone := _pickable.get_picked_up_by()
if not snap_zone or not snap_zone.has_method("pick_up_object"): if not snap_zone or not snap_zone.has_method("pick_up_object"):
push_warning("CombineZone: base item is not held by a snap zone; cannot combine.")
_combining = false
return return
# Spawn the result through the server (so it replicates to every peer, # Spawn the result through the server (so it replicates to every peer,
+35 -35
View File
@@ -41,7 +41,7 @@ script = ExtResource("1_72gy5")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.36171648, 0, 0.81835127)
[node name="MainMenuPanel3D" parent="." unique_id=1448860532 instance=ExtResource("16_vp")] [node name="MainMenuPanel3D" parent="." unique_id=1448860532 instance=ExtResource("16_vp")]
transform = Transform3D(5, 0, 0, 0, 5, 0, 0, 0, 5, 0.36171648, 2.543398, -1.9758987) transform = Transform3D(5, 0, 0, 0, 5, 0, 0, 0, 5, 0.36171648, 2.0238447, -1.9758987)
screen_size = Vector2(0.6, 0.4) screen_size = Vector2(0.6, 0.4)
scene = ExtResource("17_panel") scene = ExtResource("17_panel")
viewport_size = Vector2(600, 400) viewport_size = Vector2(600, 400)
@@ -61,106 +61,106 @@ shape = SubResource("BoxShape3D_vlqg6")
mesh = SubResource("BoxMesh_24d3s") mesh = SubResource("BoxMesh_24d3s")
[node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_5kvh0")] [node name="Hob" parent="." unique_id=1687971542 instance=ExtResource("4_5kvh0")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.8981018, -1.484) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.29336345, 0.5, -1.484)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250] [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1177077250]
environment = SubResource("Environment_bvwq1") environment = SubResource("Environment_bvwq1")
[node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("6_bktvt")] [node name="BurgerBuns" parent="." unique_id=1088240294 instance=ExtResource("6_bktvt")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.8162017, 1.6081157, -1.4714175) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5530176, 1.0505146, -1.3074328)
[node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_1nkd0")] [node name="BurgerBunsDispenser" parent="." unique_id=1720683779 instance=ExtResource("7_1nkd0")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.832131, 0.40028095, -1.4813508) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.568947, -0.018512607, -1.317366)
[node name="Plate" parent="." unique_id=190487773 instance=ExtResource("8_l1owj")] [node name="Plate" parent="." unique_id=190487773 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5717233, 1.5454081, 1.5373346) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5717233, 1.1548845, 1.5373346)
[node name="burger" parent="." unique_id=1417604760 instance=ExtResource("9_220hi")] [node name="burger" parent="." unique_id=1417604760 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.4195822, -1.7110313) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6888188, 1.0325116, -1.7110313)
[node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("9_220hi")] [node name="burger2" parent="." unique_id=1584460510 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.5300478, -1.71225) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.6931299, 1.1429771, -1.71225)
[node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("9_220hi")] [node name="burger3" parent="." unique_id=1884095916 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.4969791, -1.7210286) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69027674, 1.1099085, -1.7210286)
[node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("9_220hi")] [node name="burger4" parent="." unique_id=1844818864 instance=ExtResource("9_220hi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.4543622, -1.7210286) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.69134104, 1.0672915, -1.7210286)
[node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("10_qacki")] [node name="Hamburger" parent="." unique_id=761091445 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9352558, 1.623975, 1.2447833) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9352558, 1.2334514, 1.2447833)
[node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("11_npf8s")] [node name="PickableObject" parent="." unique_id=1675596942 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.6225724, 1.0654817, -1.0473135)
[node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("11_npf8s")] [node name="PickableObject2" parent="." unique_id=713087634 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.6359743, 1.0501236, -1.1673055)
[node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("11_npf8s")] [node name="PickableObject3" parent="." unique_id=879935619 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.5142721, 1.0654817, -1.0473135)
[node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("11_npf8s")] [node name="PickableObject4" parent="." unique_id=198541902 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.5276739, 1.0501236, -1.1673055)
[node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("10_qacki")] [node name="Hamburger2" parent="." unique_id=1349934579 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9857153, 1.5329368, 0.9472374) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.9857153, 1.1424131, 0.9472374)
[node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("11_npf8s")] [node name="PickableObject5" parent="." unique_id=1021333509 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.4792972, -1.0473135) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, 0.73287535, 1.0922265, -1.0473135)
[node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("11_npf8s")] [node name="PickableObject6" parent="." unique_id=1268843669 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.4639391, -1.1673055) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, 0.7462772, 1.0768684, -1.1673055)
[node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_8apyq")] [node name="Sink" parent="." unique_id=2055277359 instance=ExtResource("12_8apyq")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3147688, 0.9045367, -1.4940417) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.3147688, 0.5, -1.4940417)
[node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_jnwcx")] [node name="DirtStation" parent="." unique_id=160842153 instance=ExtResource("13_jnwcx")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.8981018, -1.244947) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.0864775, 0.5, -1.244947)
[node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("8_l1owj")] [node name="Plate2" parent="." unique_id=356790445 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5730225, 1.499024, 0.59790254) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5730225, 1.1085004, 0.59790254)
[node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("14_1lg2m")] [node name="CookedBurger" parent="." unique_id=1127807542 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4131018, -1.0928738) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30782473, 1.1446649, -1.0928738)
[node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("14_1lg2m")] [node name="CookedBurger2" parent="." unique_id=160088590 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.4496142, -1.0928738) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.0522771, -1.0928738)
[node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("14_1lg2m")] [node name="CookedBurger3" parent="." unique_id=992183367 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344773, 1.4398065, -0.7096845) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344773, 1.0492828, -0.7096845)
[node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("14_1lg2m")] [node name="CookedBurger4" parent="." unique_id=1837607086 instance=ExtResource("14_1lg2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.525444, -1.0928738) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.30671906, 1.098119, -1.0928738)
[node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("6_bktvt")] [node name="BurgerBuns2" parent="." unique_id=1210358958 instance=ExtResource("6_bktvt")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3596323, 1.4110342, -0.22482127) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3596323, 1.0205106, -0.22482127)
[node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("11_npf8s")] [node name="PickableObject7" parent="." unique_id=641653545 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.4792972, 0.28881657) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.3556751, 1.0887735, 0.28881657)
[node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("11_npf8s")] [node name="PickableObject8" parent="." unique_id=1733819363 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.4639391, 0.16882455) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.3422732, 1.0734154, 0.16882455)
[node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("11_npf8s")] [node name="PickableObject9" parent="." unique_id=12419746 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.4792972, 0.28881657) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, 0, -2.6077032e-08, 1.4901161e-08, 1, -1.4639754, 1.0887735, 0.28881657)
[node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("11_npf8s")] [node name="PickableObject10" parent="." unique_id=313160217 instance=ExtResource("11_npf8s")]
transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.4639391, 0.16882455) transform = Transform3D(1, 0, -2.2351742e-08, -2.9802322e-08, 1.0000001, -5.293956e-23, -2.6077032e-08, 1.4901161e-08, 1, -1.4505737, 1.0734154, 0.16882455)
[node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("15_yy81s")] [node name="Counter" parent="." unique_id=1487893288 instance=ExtResource("15_yy81s")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.7124918, 0.90304357, -1.4886917) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.7124918, 0.5, -1.4886917)
[node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_yy81s")] [node name="Counter2" parent="." unique_id=368890752 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, -0.4458799) transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, -0.4458799)
[node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_yy81s")] [node name="Counter3" parent="." unique_id=1498817646 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, 0.55367994) transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 0.55367994)
[node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_yy81s")] [node name="Counter4" parent="." unique_id=906748771 instance=ExtResource("15_yy81s")]
transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.90304357, 1.5539298) transform = Transform3D(-4.371139e-08, 0, 1, 0, 1, 0, -1, 0, -4.371139e-08, -1.7648025, 0.5, 1.5539298)
[node name="Hamburger3" parent="." unique_id=369573848 instance=ExtResource("10_qacki")] [node name="Hamburger3" parent="." unique_id=369573848 instance=ExtResource("10_qacki")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.7201865, 1.5329367, 0.14773655) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.7201865, 1.142413, 0.14773655)
[node name="Plate3" parent="." unique_id=2004250522 instance=ExtResource("8_l1owj")] [node name="Plate3" parent="." unique_id=2004250522 instance=ExtResource("8_l1owj")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5818124, 1.499024, -0.4634577) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.5818124, 1.1085004, -0.4634577)
+8 -1
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
[runnable_presets]
"Windows Desktop"="Windows Desktop"
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path=".build/game_debug.exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="x86_64"
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
Remove-Item -Recurse -Force '{temp_dir}'"
+57 -8
View File
@@ -9,30 +9,41 @@
"stages": [ "stages": [
{ {
"uuid": "94333de9-ebf5-4f38-b95b-9477f4bb9dde", "uuid": "94333de9-ebf5-4f38-b95b-9477f4bb9dde",
"title": "Todo", "title": "Backlog",
"tasks": [ "tasks": [
"854c7e1e-7521-4bcd-83ff-bb3fecec3142", "854c7e1e-7521-4bcd-83ff-bb3fecec3142",
"4e2ca8d1-84e1-4913-ad8f-834007d52160", "4e2ca8d1-84e1-4913-ad8f-834007d52160",
"dd822d14-88e5-4790-808e-7d2cf7f79133", "dd822d14-88e5-4790-808e-7d2cf7f79133",
"784d5cd3-333a-40a9-b35f-3d0c73f00761" "784d5cd3-333a-40a9-b35f-3d0c73f00761",
"21bc19f1-ee3c-4bbb-ab9a-07ec6123a823",
"d93a31cd-d475-4c5a-87fc-983174b2594f"
] ]
}, },
{ {
"uuid": "211e4050-31cd-4425-a2ba-8ca56b4764cd", "uuid": "211e4050-31cd-4425-a2ba-8ca56b4764cd",
"title": "Doing", "title": "Todo",
"tasks": [ "tasks": [
"d9cebd68-792e-4d01-a916-7df5f128b4a8", "55ac73b8-4378-4b58-bf3c-33f64590c804"
"1c3b9543-cb67-431d-bf71-c8753e816776"
] ]
}, },
{ {
"uuid": "fab8a81b-7ca9-4026-96bb-ed66ea58ef2e", "uuid": "fab8a81b-7ca9-4026-96bb-ed66ea58ef2e",
"title": "Doing",
"tasks": [
"f8073eb2-8786-47b7-b63f-fa70c3f7115a",
"6942c45a-53a4-484f-9885-4f249cb4572b"
]
},
{
"uuid": "0d743057-955a-473a-8123-a3f805505d5d",
"title": "Done", "title": "Done",
"tasks": [ "tasks": [
"4ab36c89-a377-4f25-86a1-ac276aadf4a2", "a4dd35ae-dc8e-42a3-9044-bf1cb1af281a",
"cde44fb1-3fdf-4ea0-8267-d28d894e4e7d", "1c3b9543-cb67-431d-bf71-c8753e816776",
"d9cebd68-792e-4d01-a916-7df5f128b4a8",
"eee1990e-f957-4499-84b6-e68003fcb78e", "eee1990e-f957-4499-84b6-e68003fcb78e",
"a4dd35ae-dc8e-42a3-9044-bf1cb1af281a" "4ab36c89-a377-4f25-86a1-ac276aadf4a2",
"cde44fb1-3fdf-4ea0-8267-d28d894e4e7d"
] ]
} }
], ],
@@ -196,6 +207,41 @@
"done": false "done": false
} }
] ]
},
{
"uuid": "21bc19f1-ee3c-4bbb-ab9a-07ec6123a823",
"title": "Visual indicator Hob",
"description": "",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": []
},
{
"uuid": "d93a31cd-d475-4c5a-87fc-983174b2594f",
"title": "Visual indicator Sink",
"description": "",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": []
},
{
"uuid": "6942c45a-53a4-484f-9885-4f249cb4572b",
"title": "Stations progress bar",
"description": "",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": []
},
{
"uuid": "f8073eb2-8786-47b7-b63f-fa70c3f7115a",
"title": "Game over screen / effect with restart button",
"description": "",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": []
},
{
"uuid": "55ac73b8-4378-4b58-bf3c-33f64590c804",
"title": "Restart / game reset button",
"description": "",
"category": "bf4ec62e-526e-4027-876f-0b22bd17f79a",
"steps": []
} }
], ],
"layout": { "layout": {
@@ -208,6 +254,9 @@
], ],
[ [
"fab8a81b-7ca9-4026-96bb-ed66ea58ef2e" "fab8a81b-7ca9-4026-96bb-ed66ea58ef2e"
],
[
"0d743057-955a-473a-8123-a3f805505d5d"
] ]
] ]
} }
+4
View File
@@ -22,6 +22,10 @@ XRToolsRumbleManager="*uid://by853dk86g1qw"
NetworkManager="*res://Net/network_manager.gd" NetworkManager="*res://Net/network_manager.gd"
GlobalKeyEvents="*uid://c60unagog5oi1" GlobalKeyEvents="*uid://c60unagog5oi1"
[debug]
file_logging/enable_file_logging=true
[display] [display]
window/size/viewport_width=1800 window/size/viewport_width=1800
+89
View File
@@ -0,0 +1,89 @@
# Stitches the per-step screenshots from a test run into one side-by-side GIF,
# server on the left, client on the right.
#
# powershell -File test\make_gif.ps1
#
# Reads logs\mptest_frames_server\ and logs\mptest_frames_client\ (written when
# the driver runs with --mptest-frames) and writes logs\mptest_run.gif.
# run_mp_test_windowed.ps1 calls this automatically; run it by hand to rebuild
# the GIF after a manual session, or to re-render at a different speed.
#
# Each frame already carries its own caption: the on-screen overlay in the
# capture starts with [SERVER] or [CLIENT] and shows that step's log lines.
param(
[string]$FFmpeg = "ffmpeg",
# Seconds each step is held on screen.
[double]$SecondsPerStep = 1.2,
# Width of each peer's half of the frame, in pixels.
[int]$HalfWidth = 900,
[string]$Out = ""
)
$ErrorActionPreference = "Stop"
$proj = Split-Path -Parent $PSScriptRoot
$serverDir = Join-Path $proj "logs/mptest_frames_server"
$clientDir = Join-Path $proj "logs/mptest_frames_client"
if (-not $Out) { $Out = Join-Path $proj "logs/mptest_run.gif" }
if (-not (Get-Command $FFmpeg -EA SilentlyContinue)) {
Write-Host "ffmpeg not found. Install it, or pass -FFmpeg <path to ffmpeg.exe>."
exit 1
}
function Frame-Count($dir) {
if (-not (Test-Path $dir)) { return 0 }
return (Get-ChildItem (Join-Path $dir "frame_*.png") -EA SilentlyContinue).Count
}
$ns = Frame-Count $serverDir
$nc = Frame-Count $clientDir
Write-Host "server frames: $ns"
Write-Host "client frames: $nc"
if ($ns -eq 0 -and $nc -eq 0) {
Write-Host ""
Write-Host "No frames found. Run the test with frame capture first:"
Write-Host " powershell -File test\run_mp_test_windowed.ps1"
Write-Host "(frame capture needs a real window - a headless run renders nothing to grab)"
exit 1
}
$fps = [Math]::Round(1.0 / $SecondsPerStep, 4)
if ($ns -gt 0 -and $nc -gt 0) {
if ($ns -ne $nc) {
# hstack stops at the shorter input, so the tail of the longer one is lost.
Write-Host "note: frame counts differ, the GIF will stop after $([Math]::Min($ns,$nc)) steps"
}
# Two passes in one command: build a palette from the stacked frames, then
# apply it. A single global palette keeps the GIF small and stops colours
# shifting from frame to frame.
$filter = "[0:v]scale=${HalfWidth}:-1:flags=lanczos[l];" +
"[1:v]scale=${HalfWidth}:-1:flags=lanczos[r];" +
"[l][r]hstack=inputs=2[v];" +
"[v]split[a][b];[a]palettegen=max_colors=192[p];[b][p]paletteuse=dither=bayer:bayer_scale=3"
$args = @(
"-y", "-hide_banner", "-loglevel", "error",
"-framerate", $fps, "-i", (Join-Path $serverDir "frame_%04d.png"),
"-framerate", $fps, "-i", (Join-Path $clientDir "frame_%04d.png"),
"-filter_complex", $filter, "-loop", "0", $Out
)
} else {
# Only one peer produced frames (e.g. a solo manual session).
$dir = if ($ns -gt 0) { $serverDir } else { $clientDir }
$filter = "[0:v]scale=${HalfWidth}:-1:flags=lanczos[v];" +
"[v]split[a][b];[a]palettegen=max_colors=192[p];[b][p]paletteuse=dither=bayer:bayer_scale=3"
$args = @(
"-y", "-hide_banner", "-loglevel", "error",
"-framerate", $fps, "-i", (Join-Path $dir "frame_%04d.png"),
"-filter_complex", $filter, "-loop", "0", $Out
)
}
& $FFmpeg @args
if ($LASTEXITCODE -ne 0) { Write-Host "ffmpeg failed ($LASTEXITCODE)"; exit $LASTEXITCODE }
$size = [Math]::Round((Get-Item $Out).Length / 1MB, 2)
Write-Host ""
Write-Host "wrote $Out (${size} MB)"
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://biu4qr3nr1eny
+47
View File
@@ -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)
}
+112
View File
@@ -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="." unique_id=860178119 instance=ExtResource("12_j5uvh")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 0.5, 0)
+61
View File
@@ -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)."
+60
View File
@@ -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
+82
View File
@@ -0,0 +1,82 @@
# 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,
# Passed through to make_gif.ps1. HalfWidth 900 keeps the on-screen
# step log readable in the GIF; lower it to shrink the file.
[int]$HalfWidth = 900,
[double]$SecondsPerStep = 1.2,
# Skip building the GIF (frames are still captured).
[switch]$NoGif
)
$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-frames --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)" }
}
if (-not $NoGif) {
Write-Host ""
Write-Host "======================== GIF ========================"
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") `
-HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep
}
$server.WaitForExit(2000) | Out-Null
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
Write-Host ""
Write-Host "server exit code: $code"
exit $code