Design + spike for high-level replication rebuild
Rebuild the multiplayer layer on stock MultiplayerSpawner/Synchronizer so adding an object to the game needs no networking code. MultiplayerSpawner only replicates node creation and deletion, so "sync everything regardless of what it is" has to come from a SceneReplicationConfig built by convention in code. NetReplication does that, giving every node two generated synchronizers: NetSync for script state (always server-owned) and NetXform for position (handed to whoever is holding the object). test/spike/ establishes the four engine behaviours the design rests on. Two are worth flagging: per-peer visibility CANNOT be used to stop the server fighting a client's held object, because MultiplayerSpawner despawns and respawns the node on every visibility flip; and set_visibility_for is only an override on top of public_visibility, so calling it alone does nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
extends Node
|
||||
class_name NetReplication
|
||||
|
||||
## Builds MultiplayerSynchronizers for any node by convention, so no scene needs
|
||||
## a hand-authored replication config.
|
||||
##
|
||||
## Every replicated node gets TWO synchronizers, both generated here:
|
||||
##
|
||||
## NetSync script state (is_dirty, contained_ids, time_cooked, ...).
|
||||
## Always owned by the server. Gameplay outcomes are the server's to
|
||||
## decide, so this never changes hands.
|
||||
## NetXform position + quaternion. Owned by the server while the object is
|
||||
## loose, and handed to a peer for as long as that peer holds it.
|
||||
##
|
||||
## Splitting them is what makes client-side grab prediction work. A
|
||||
## MultiplayerSynchronizer never applies inbound state on the peer that owns it,
|
||||
## so giving the holder NetXform means their own hand drives the object with no
|
||||
## round trip and no fight with the server's copy — while is_dirty and friends
|
||||
## keep flowing one way, server to client, exactly as before.
|
||||
##
|
||||
## The obvious alternative, per-peer visibility (set_visibility_for), does NOT
|
||||
## work: MultiplayerSpawner also uses synchronizer visibility to decide whether
|
||||
## a node should exist on a peer, so hiding an object from its holder despawns
|
||||
## it in their hand. Verified in test/spike/.
|
||||
##
|
||||
## The convention for what gets replicated:
|
||||
## * a Node3D replicates `position` and `quaternion` (never `scale` — nothing
|
||||
## in this game animates scale as gameplay state)
|
||||
## * every script variable, on the node and on any scripted descendant, whose
|
||||
## name does not start with "_" and whose declared type is serialisable
|
||||
##
|
||||
## Underscore-prefixed vars are the opt-out, and they already mark exactly the
|
||||
## state that must not be replicated: @onready node references, cached lookups,
|
||||
## per-frame bookkeeping.
|
||||
|
||||
## SceneReplicationConfig's replication modes. The enum is not exposed under a
|
||||
## friendly name in GDScript, so they are spelled out here.
|
||||
const MODE_ALWAYS := 1
|
||||
const MODE_ON_CHANGE := 2
|
||||
|
||||
const SYNC_NAME := "NetSync"
|
||||
const XFORM_NAME := "NetXform"
|
||||
|
||||
## Types that survive a network round trip. Object/Callable/Signal/RID cannot be
|
||||
## serialised, and are exactly what @onready and cached references hold.
|
||||
const SYNCABLE_TYPES := [
|
||||
TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME,
|
||||
TYPE_VECTOR2, TYPE_VECTOR3, TYPE_QUATERNION, TYPE_TRANSFORM3D,
|
||||
TYPE_COLOR, TYPE_PACKED_STRING_ARRAY, TYPE_ARRAY,
|
||||
]
|
||||
|
||||
|
||||
## Adds both synchronizers to `node`, unless they are already there. Idempotent.
|
||||
##
|
||||
## Must run on EVERY peer, not just the authority: the client's copy is built by
|
||||
## MultiplayerSpawner straight from the .tscn, so it only has synchronizers if
|
||||
## something puts them there. NetWorld calls this from the content root's
|
||||
## child_entered_tree on both sides — early enough that they enter the tree
|
||||
## inside the spawner's own add_child(), which is what lets them pick up the
|
||||
## spawn payload (verified in test/spike/: a late joiner receives position and
|
||||
## script state from a synchronizer that exists only at runtime).
|
||||
static func attach(node: Node) -> void:
|
||||
if node is Node3D and not node.has_node(XFORM_NAME):
|
||||
_add_sync(node, XFORM_NAME, _transform_config())
|
||||
if not node.has_node(SYNC_NAME):
|
||||
_add_sync(node, SYNC_NAME, state_config(node))
|
||||
|
||||
|
||||
static func _add_sync(node: Node, sync_name: String, config: SceneReplicationConfig) -> void:
|
||||
var sync := MultiplayerSynchronizer.new()
|
||||
sync.name = sync_name
|
||||
sync.replication_config = config
|
||||
# Sync every network tick. ON_CHANGE properties are only sent when they
|
||||
# actually change regardless of this interval.
|
||||
sync.replication_interval = 0.0
|
||||
node.add_child(sync)
|
||||
|
||||
|
||||
## Transform-only config. ALWAYS rather than ON_CHANGE: a carried or simulated
|
||||
## object changes every tick anyway, so ON_CHANGE would only add a comparison
|
||||
## per property per tick. spawn = true so a replicated object arrives already in
|
||||
## the right place instead of sitting at the origin for a frame.
|
||||
static func _transform_config() -> SceneReplicationConfig:
|
||||
var config := SceneReplicationConfig.new()
|
||||
_add(config, ".:position", true, MODE_ALWAYS)
|
||||
_add(config, ".:quaternion", true, MODE_ALWAYS)
|
||||
return config
|
||||
|
||||
|
||||
## Script-state config for `node` and its scripted descendants. Public so the
|
||||
## tests can inspect what the convention picked up without spinning up a session.
|
||||
##
|
||||
## ON_CHANGE, not ALWAYS: the value is then only sent — and, crucially, only
|
||||
## ASSIGNED on the receiving peer — when it actually changes. Under ALWAYS every
|
||||
## replicated setter becomes a per-tick hot path, which is how this project
|
||||
## previously ended up rebuilding every plate's visuals 60 times a second.
|
||||
static func state_config(node: Node) -> SceneReplicationConfig:
|
||||
var config := SceneReplicationConfig.new()
|
||||
for path in _script_var_paths(node, node):
|
||||
_add(config, path, true, MODE_ON_CHANGE)
|
||||
return config
|
||||
|
||||
|
||||
static func _add(config: SceneReplicationConfig, path: String, spawn: bool, mode: int) -> void:
|
||||
var np := NodePath(path)
|
||||
config.add_property(np)
|
||||
config.property_set_spawn(np, spawn)
|
||||
config.property_set_replication_mode(np, mode)
|
||||
|
||||
|
||||
## Every "<relative path>:<var>" on `node` and its scripted descendants.
|
||||
## Descends through children but stops at anything carrying its own synchronizer
|
||||
## — that subtree replicates itself and must not be replicated twice.
|
||||
static func _script_var_paths(root: Node, node: Node) -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
var prefix: String = "." if node == root else str(root.get_path_to(node))
|
||||
var script: Script = node.get_script() as Script
|
||||
if script:
|
||||
for prop in script.get_script_property_list():
|
||||
var prop_name := str(prop["name"])
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if not (prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
||||
continue
|
||||
if not SYNCABLE_TYPES.has(prop["type"]):
|
||||
continue
|
||||
paths.append("%s:%s" % [prefix, prop_name])
|
||||
for child in node.get_children():
|
||||
if child is MultiplayerSynchronizer or child is MultiplayerSpawner:
|
||||
continue
|
||||
if child.has_node(SYNC_NAME) or child.has_node(XFORM_NAME):
|
||||
continue
|
||||
paths.append_array(_script_var_paths(root, child))
|
||||
return paths
|
||||
Reference in New Issue
Block a user