Files
VRyHungry1/Net/net_stations.gd
algodoogle 61d92052ac Rebuild multiplayer on the stock spawner and synchronizer
Adding an object to the game no longer requires any networking code. The
MultiplayerSpawner runs in its default mode — no spawn_function, no payload
dictionary — and NetReplication builds each object's SceneReplicationConfig
from a convention, so scenes carry no hand-authored replication at all.

Every replicated node gets two generated synchronizers: NetSync for script
state, always server-owned, and NetXform for position, handed to whoever is
holding the object. That split is what makes grab prediction work — a
synchronizer never applies inbound state on the peer that owns it, so a
player's own hand drives an object with no round trip while is_dirty and
friends keep flowing one way from the server.

Interaction is now two RPCs for the whole game (NetGrab), and client gating is
one rule applied to every object (NetWorld). Deleted: net_pickable.gd, the
replicated net_held_by field and its held-state juggling, the
grant/reject/force-release negotiation, the static-item despawn RPC, and the
per-scene replication configs. Authority is the single source of truth for who
simulates an object.

Two things the convention had to learn, both found by the test suite:

  * Addon scripts are excluded. godot-xr-tools' snap zones and pickables expose
    a public `enabled`, which is exactly the flag each peer must set for itself
    — so replicating it meant the server sent `enabled = true` back over every
    client's gate, and stations went on grabbing objects out of the local
    player's hands.
  * Arrays of nodes are excluded. Array[Node3D] and Array[FoodItem] would
    otherwise try to serialise live node references.

table.gd's replicated state loses its underscore prefix, which now marks a
variable as private and unreplicated; two in-place array mutations there were
skipping their setters, and the progress bar could divide by zero on a client.

Suite: 146/146 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:04:22 +01:00

65 lines
2.9 KiB
GDScript

extends Node
class_name NetStations
## Server-side placement rules for stations. Generic over the "station" group —
## there is nothing per-station here, and nothing any individual station scene
## has to opt into.
##
## Placement is a server decision for the same reason cooking is: every peer runs
## its own copy of a snap zone, so letting each decide independently means the
## same object gets grabbed in two places at once.
## Every snap zone belonging to a station. Some stations (Table) have more than
## one, so this is not a one-per-station lookup.
static func zones(context: Node) -> Array:
var found := []
for station in context.get_tree().get_nodes_in_group("station"):
for child in station.get_children():
if child is XRToolsSnapZone:
found.append(child)
return found
## Frees an object from whatever station is currently holding it, because
## someone has just taken it by hand.
static func release_from_zones(context: Node, item: Node) -> void:
for zone in zones(context):
if zone.picked_up_object == item:
zone.drop_object()
# Make the zone forget it as well. These zones are snap_mode=RANGE, so
# every frame they re-grab anything still listed in their grab area — and
# Jolt does NOT emit body_exited when let_go() switches the object's
# collision layer back out of the zone's mask, so the entry never clears
# on its own. The station then snatches the object straight back off the
# player who just took it and teleports it home. Bringing it near again
# re-adds it properly, and releasing next to a station is handled
# explicitly by try_snap below.
if zone._object_in_grab_area.has(item):
zone._object_in_grab_area.erase(item)
## Snaps a just-released object into the nearest empty station zone in range.
##
## Call this DEFERRED. XRToolsFunctionPickup's own "grab an object out of a snap
## zone" path calls zone.drop_object() before it calls pick_up() on the hand's
## behalf, and drop_object() synchronously fires the `dropped` signal that lands
## here. Running inline would immediately re-snap the object into the very zone
## it is still physically inside, stealing it before the hand's own pick_up()
## later in the same call stack ever runs — leaving the hand pointing at an
## object whose grab driver belongs to the zone, which crashes on the next
## controller press. The is_picked_up() check below is the second guard, for the
## case where something grabs it for real before this runs.
static func try_snap(context: Node, item: Node) -> void:
if not (item is Node3D) or not is_instance_valid(item):
return
if item.has_method("is_picked_up") and item.is_picked_up():
return
for zone in zones(context):
if is_instance_valid(zone.picked_up_object):
continue
if zone.global_position.distance_to(item.global_position) <= zone.grab_distance:
NetworkManager.log_line("snapped %s into %s" % [item.name, zone.get_parent().name])
zone.pick_up_object(item)
return