Fix join-floor fall-through, grab race, drop physics, and remote hand sync

Four bugs from real cross-network play:

- Player/net_player.gd: the per-peer spawn-spread offset (added to avoid
  stacking joiners on top of each other) could push a joining client up
  to 12 units from center, but the floor is only 15x15 (~7.5 unit
  half-extent) — landing a client off the edge. Bounded to ~2.7 units.

- Player/net_player.gd: the glove scenes used for remote-hand visuals
  carry hand.gd (XRToolsHand), whose root node sets top_level = true and
  repositions itself to its parent's transform every physics frame,
  expecting to be parented under a live XRController3D. Parented under
  the plain avatar node instead, it fought both the local transform copy
  and the replicated sync every tick — whichever wrote last that frame
  won, which read as hands stuck near origin except momentarily. Disabled
  physics processing on those nodes; nothing else in the script matters
  without a real controller ancestor.

- Net/net_pickable.gd: grabbing an item sends an RPC to the server to
  confirm authority, but the item's regular state sync travels on a
  different channel with no ordering guarantee against that RPC. A
  stale "still loose" sync packet could arrive after an optimistic local
  grab but before the confirmation, and was being treated as a real
  authority loss, force-dropping the item — explaining "first attempt
  fails, second succeeds". Now a sync update can't yank an item out of
  our own hand mid-grab; only an explicit rejection or an actual loss of
  authority can.

- Net/net_pickable.gd: the non-authority path zeroes collision_mask and
  forces freeze_mode to KINEMATIC, but nothing ever restored either when
  a peer regained authority (e.g. the server after a client's release) —
  so a released item stayed collision-less forever, looking physics-less.
  Now both are restored from the item's own baked values whenever a peer
  owns a loose (not actively held) item again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
algodoogle
2026-07-25 20:16:29 +01:00
parent 17d20440da
commit f6ac103233
2 changed files with 44 additions and 9 deletions
+23 -1
View File
@@ -14,12 +14,19 @@ var net_held_by: int = 0: set = _set_net_held_by
var _pickable: XRToolsPickable
# This item's own baked freeze_mode (e.g. plate.tscn bakes KINEMATIC, not the
# RigidBody3D default of STATIC) — captured once so it can be restored when
# this peer regains ownership, instead of getting stuck on whatever
# apply_held_state() last forced it to while non-authority.
var _original_freeze_mode: int
func _ready() -> void:
_pickable = get_parent() as XRToolsPickable
if not _pickable:
push_error("NetPickable must be a child of an XRToolsPickable")
return
_original_freeze_mode = _pickable.freeze_mode
_pickable.picked_up.connect(_on_picked_up)
_pickable.dropped.connect(_on_dropped)
# Deferred: the pickable root captures its own original_collision_mask/
@@ -43,7 +50,22 @@ func apply_held_state() -> void:
return
if not NetworkManager.is_online() or is_multiplayer_authority():
# We own this item's simulation (offline, loose+server, or currently
# holding it): leave physics alone, XRToolsPickable manages the rest.
# 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
# non-authority period (e.g. right after regaining authority when a
# client released it) — while actually held, XRToolsPickable's own
# pick_up()/let_go() already manage these fields, so leave those be.
if not _pickable.is_picked_up():
_pickable.freeze_mode = _original_freeze_mode
_pickable.collision_mask = _pickable.original_collision_mask
return
# 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
# optimistically (they travel on different channels with no ordering
# guarantee). Don't let a stale sync value yank an item out of our own
# hand mid-grab — only an explicit force_release_item rejection, or
# actually losing authority for real, should end a grab we initiated.
if _pickable.is_picked_up() and _pickable.get_picked_up_by() is XRToolsFunctionPickup:
return
# Someone else owns it: stop simulating locally, just follow the sync.
if _pickable.is_picked_up():