61d92052ac
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>
49 lines
1.6 KiB
GDScript
49 lines
1.6 KiB
GDScript
class_name PlateController
|
|
extends Node
|
|
|
|
@onready var dirty_node: Node3D = $"../Dirty"
|
|
@onready var container: ItemContainer = $"../Container"
|
|
|
|
@export var is_dirty: bool = false
|
|
|
|
## Synced plate contents (item ids), replacing reparented child nodes so
|
|
## contents survive replication (a MultiplayerSpawner-tracked item would
|
|
## despawn on every client the instant it was reparented out of WorldContent).
|
|
## Server writes it via ItemContainer; every peer (server included) renders
|
|
## the cosmetic result via the setter below.
|
|
@export var contained_ids: Array[String] = []: set = _set_contained_ids
|
|
|
|
|
|
func _ready() -> void:
|
|
if not dirty_node:
|
|
push_error("Plate is missing its dirty_node")
|
|
dirty_node.visible = is_dirty
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if is_dirty:
|
|
container.enabled = false
|
|
dirty_node.visible = true
|
|
else:
|
|
container.enabled = true
|
|
dirty_node.visible = false
|
|
|
|
|
|
func _set_contained_ids(value: Array[String]) -> void:
|
|
# Only rebuild when the contents actually changed. refresh_visuals() frees and
|
|
# re-instantiates a scene per item, so a redundant call is thousands of
|
|
# throwaway nodes over a session. NetReplication uses ON_CHANGE for state, so
|
|
# the synchronizer should not be assigning this unless it really changed — but
|
|
# a local write can still repeat a value, and this stays cheap either way.
|
|
if contained_ids == value:
|
|
return
|
|
contained_ids = value
|
|
# Deferred: this can be written by the replicated spawn payload before
|
|
# this node's own @onready vars (container) have resolved.
|
|
_refresh_visuals.call_deferred()
|
|
|
|
|
|
func _refresh_visuals() -> void:
|
|
if container:
|
|
container.refresh_visuals(contained_ids)
|