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>
80 lines
2.7 KiB
GDScript
80 lines
2.7 KiB
GDScript
extends Node
|
|
class_name WorldLayout
|
|
|
|
## Reads the kitchen a scene file authored and describes it as data, so the
|
|
## server can respawn it as replicated objects.
|
|
##
|
|
## The authored nodes are a template, not the live world. Baking them into the
|
|
## scene would mean each peer ran its own unsynced copy; spawning them means the
|
|
## client receives the server's, through the same path as a mid-session join.
|
|
|
|
## Directories whose scenes count as world content, and the base type to look for
|
|
## in each. Anything instanced from these is picked up automatically — adding a
|
|
## new station or item needs no change here.
|
|
const SOURCES := [
|
|
{"dir": "res://Stations/", "type": "StaticBody3D"},
|
|
{"dir": "res://Items/", "type": "XRToolsPickable"},
|
|
{"dir": "res://Containers/", "type": "XRToolsPickable"},
|
|
]
|
|
|
|
|
|
## Every authored node in the current scene, stations first.
|
|
##
|
|
## Order matters: stations have to exist before items, so an item spawning on top
|
|
## of one lands in a snap zone that is already there.
|
|
func get_authored_nodes() -> Array[Node]:
|
|
var found: Array[Node] = []
|
|
for source in SOURCES:
|
|
for node in _instances_of(source["dir"], source["type"]):
|
|
if not found.has(node):
|
|
found.append(node)
|
|
return found
|
|
|
|
|
|
func _instances_of(dir: String, type: String) -> Array[Node]:
|
|
var scenes := []
|
|
for file in ResourceLoader.list_directory(dir):
|
|
scenes.append(dir + file)
|
|
var found: Array[Node] = []
|
|
for node in get_tree().root.find_children("*", type, true, false):
|
|
if node.scene_file_path in scenes:
|
|
found.append(node)
|
|
return found
|
|
|
|
|
|
## Turns authored nodes into spawn descriptions.
|
|
func describe(nodes: Array[Node]) -> Array[Dictionary]:
|
|
var data: Array[Dictionary] = []
|
|
for node in nodes:
|
|
data.append(_describe_one(node))
|
|
return data
|
|
|
|
|
|
func _describe_one(node: Node) -> Dictionary:
|
|
return {
|
|
"scene": node.scene_file_path,
|
|
"name": node.name,
|
|
# Global, not local: the copies are respawned under one content root, so
|
|
# an authored node nested inside another (JonScene parents Counter5 under
|
|
# Counter3) would otherwise land in the wrong place.
|
|
"xform": node.global_transform,
|
|
"props": _authored_props(node),
|
|
}
|
|
|
|
|
|
# Only authored configuration — @export vars, the ones the editor exposes. Plain
|
|
# script variables are live runtime state, and replaying those into a fresh
|
|
# instance re-runs their setters before the node is in the tree, where any setter
|
|
# touching an @onready reference blows up.
|
|
func _authored_props(node: Node) -> Dictionary:
|
|
var props := {}
|
|
var script: Script = node.get_script() as Script
|
|
if not script:
|
|
return props
|
|
for prop in script.get_script_property_list():
|
|
var prop_name := str(prop["name"])
|
|
if not (prop["usage"] & PROPERTY_USAGE_EDITOR) or prop_name.begins_with("_"):
|
|
continue
|
|
props[prop_name] = node.get(prop_name)
|
|
return props
|