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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmuqb0yywjfyw
|
||||
@@ -0,0 +1,129 @@
|
||||
# High-level replication rebuild
|
||||
|
||||
Rebuild VRyHungry's multiplayer on Godot's stock `MultiplayerSpawner` and
|
||||
`MultiplayerSynchronizer`, so that adding a new object to the game requires no
|
||||
networking code at all. Replaces the bespoke spawn payloads, per-scene
|
||||
replication configs, and grab-authority handshake in place today.
|
||||
|
||||
Baseline being replaced: `logs/mptest_report.txt`, 146/146 checks passing as of
|
||||
2026-07-28 19:47. That is the bar the rebuild has to clear.
|
||||
|
||||
## The constraint that shapes everything
|
||||
|
||||
`MultiplayerSpawner` replicates **node creation, name, and deletion** — nothing
|
||||
more. All state comes from `MultiplayerSynchronizer` plus a
|
||||
`SceneReplicationConfig`. There is no engine switch for "sync everything
|
||||
regardless of what it is".
|
||||
|
||||
The way to get there anyway: a `SceneReplicationConfig` is a plain `Resource`,
|
||||
so it can be **built in code from a convention** and attached at runtime. One
|
||||
helper then covers every object in the game, and nothing has to be authored
|
||||
per scene.
|
||||
|
||||
## Verified engine behaviour
|
||||
|
||||
Established by `test/spike/`, run against Godot 4.7.stable. These are load
|
||||
bearing — the design below is only correct because these hold.
|
||||
|
||||
| # | Question | Result |
|
||||
|---|----------|--------|
|
||||
| Q1 | Does default auto-spawn carry the spawn properties of a synchronizer attached at **runtime**, not baked into the `.tscn`? | **Yes.** A peer joining 4s after the spawn received `pos=(5,6,7) health=42 label=hello`. |
|
||||
| Q2 | Do `ON_CHANGE` properties reach a late joiner, and do later changes propagate? | **Yes**, both. |
|
||||
| Q3 | Can `set_visibility_for(peer, false)` suppress one peer's updates without despawning the node there? | **No.** The node is despawned and respawned on every visibility flip. Unusable — it would make a held object vanish from the holder's hand. |
|
||||
| Q4 | Can handing one of two synchronizers to a client let it drive the transform locally while server-owned state keeps arriving? | **Yes.** Client showed its own `(1,1,1)` and ignored the server's competing `(-9,-9,-9)`; the server saw `(1,1,1)`; server-owned `health=123` still arrived. |
|
||||
|
||||
Two traps worth recording. `set_visibility_for` is only an *override* on top of
|
||||
`public_visibility`, which defaults to `true` — calling it alone does nothing.
|
||||
And the spike's first version chained relative timers, which drifted enough that
|
||||
the client's observations straddled the server's actions; it now runs both peers
|
||||
on one absolute clock.
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1 — `Net/net_replication.gd`
|
||||
|
||||
The convention-based config builder. `NetReplication.attach(node)` gives a node
|
||||
two generated synchronizers:
|
||||
|
||||
- **`NetSync`** — script state. Always server-owned; gameplay outcomes are the
|
||||
server's to decide. `ON_CHANGE`.
|
||||
- **`NetXform`** — `position` + `quaternion`. Server-owned while the object is
|
||||
loose, handed to a peer for the duration of a hold. `ALWAYS`.
|
||||
|
||||
What gets replicated, by convention:
|
||||
|
||||
- a `Node3D` replicates `position` and `quaternion` (never `scale` — nothing in
|
||||
this game animates scale as gameplay state)
|
||||
- every script variable, on the node and 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 cross the wire: `@onready` references, cached lookups,
|
||||
per-frame bookkeeping.
|
||||
|
||||
`ON_CHANGE` for state is a deliberate correctness choice, not just bandwidth.
|
||||
Under `ALWAYS` the synchronizer *assigns* every property every tick on receiving
|
||||
peers, so every replicated setter becomes a per-frame hot path — which is how
|
||||
this project previously ended up rebuilding every plate's visuals 60 times a
|
||||
second and rewriting four physics properties per item per tick.
|
||||
|
||||
### Layer 2 — `Net/net_world.gd`
|
||||
|
||||
- Spawnable scenes registered by **sorted** directory scan of `Items/`,
|
||||
`Containers/`, `Stations/`, `Prefabs/`. Auto-spawn transmits an *index* into
|
||||
that array, so the order must be identical on every peer or clients build the
|
||||
wrong scene.
|
||||
- `spawn()` = instantiate → set transform/name → `add_child` under the content
|
||||
root. No `spawn_function`, no payload dictionary.
|
||||
- `despawn()` = `queue_free()` on the server. The `_despawn_static_item` RPC
|
||||
disappears: with nothing baked into the world scene, the spawner covers every
|
||||
case.
|
||||
- `NetReplication.attach` runs from the content root's `child_entered_tree` on
|
||||
**both** peers. That is early enough for the synchronizers to enter the tree
|
||||
inside the spawner's own `add_child`, which is what lets them pick up the
|
||||
spawn payload (Q1).
|
||||
- **Client gating becomes one generic rule** applied at the same hook: disable
|
||||
`_process`, disable every `XRToolsSnapZone`, freeze `RigidBody3D`. This
|
||||
replaces `_gate_station` + `gate_existing_stations` and applies to anything,
|
||||
not just things that happen to be stations.
|
||||
|
||||
### Layer 3 — `Net/net_grab.gd` and `Net/net_stations.gd`
|
||||
|
||||
Two RPCs for the entire game: `request_grab(item)` and
|
||||
`request_release(item, xform, lin, ang)`.
|
||||
|
||||
A client's hand picks the item up locally the instant the player grabs — no
|
||||
round trip — and asks the server to confirm. The server grants by moving
|
||||
`NetXform` authority to that peer (Q4), which is precisely what makes the local
|
||||
copy stop applying inbound transform updates. On release, authority returns to
|
||||
the server, which decides where the item actually ends up: server-authoritative
|
||||
snapping into stations lives in `net_stations.gd`, generic over the `station`
|
||||
group rather than per-station.
|
||||
|
||||
Deleted outright: `Net/net_pickable.gd` (209 lines), `net_held_by` and its
|
||||
held-state juggling, `_set_item_authority`, the grant/reject/force-release
|
||||
negotiation, and `_gate_station`.
|
||||
|
||||
### Layer 4 — tests
|
||||
|
||||
`test/mp_test_driver.gd` (1458 lines) splits into orchestration, steps,
|
||||
asserts, snapshot, and report modules. The scenario list and the sync audit
|
||||
carry over unchanged — that audit compares what is actually *rendered* on both
|
||||
peers, which is what caught the plate-visuals desync that targeted assertions
|
||||
missed. Assertions written against old internals (`net_held_by`, "the client is
|
||||
the item's authority after grabbing") are rewritten against the new model.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Replacing a green suite.** Mitigated by running the headless suite after
|
||||
each layer rather than only at the end.
|
||||
- **The harness changes alongside the code it checks.** Mitigated by keeping
|
||||
scenario list and audit logic byte-identical wherever possible, so it remains
|
||||
an independent check rather than one shaped to fit the new code.
|
||||
- **Blanket state replication picks up more vars than the old hand-authored
|
||||
configs.** `ON_CHANGE` makes this cheap, but any setter reached this way still
|
||||
has to be idempotent and null-guarded — a `spawn = true` property fires its
|
||||
setter *before* the node is in the tree, so `@onready` refs are null there.
|
||||
- **Client→client transform** relies on `SceneMultiplayer.server_relay` (on by
|
||||
default) to forward a holder's updates to the other clients. Fine for a
|
||||
listen-server, worth remembering if the topology ever changes.
|
||||
@@ -0,0 +1,8 @@
|
||||
extends Node3D
|
||||
|
||||
## Spike fixture only. A plain scene with NO authored MultiplayerSynchronizer,
|
||||
## so the run proves the runtime-generated one is what carries the state.
|
||||
|
||||
var health: int = 0
|
||||
var label: String = ""
|
||||
var _private_should_not_replicate: int = 99
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbgmii1peyxu8
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspike0item00"]
|
||||
|
||||
[ext_resource type="Script" path="res://test/spike/spike_item.gd" id="1_spike"]
|
||||
|
||||
[node name="SpikeItem" type="Node3D"]
|
||||
script = ExtResource("1_spike")
|
||||
@@ -0,0 +1,222 @@
|
||||
extends Node3D
|
||||
|
||||
## Throwaway spike. Answers the questions about Godot's high-level replication
|
||||
## that the rebuild depends on, before any of the real code is rewritten.
|
||||
##
|
||||
## Q1 Does default (auto) MultiplayerSpawner replication carry the spawn
|
||||
## properties of synchronizers attached AT RUNTIME rather than baked into
|
||||
## the .tscn? [CONFIRMED]
|
||||
## Q2 Do ON_CHANGE properties reach a peer that joins AFTER the value was
|
||||
## set, and do later changes propagate? [CONFIRMED]
|
||||
## Q3 Can set_visibility_for(peer, false) suppress a peer's updates without
|
||||
## despawning the node on it? [REFUTED — the
|
||||
## node is despawned and respawned on every visibility flip, so this
|
||||
## cannot be used to stop the server fighting a client's held object.]
|
||||
## Q4 Does handing ONE of a node's two synchronizers (NetXform) to a client
|
||||
## let that client drive the transform locally — ignoring the server's
|
||||
## inbound updates — while the other (NetSync) keeps pushing server-owned
|
||||
## state to it? This is the grab-prediction mechanism.
|
||||
##
|
||||
## Both peers run on one ABSOLUTE clock (see _at) rather than chains of relative
|
||||
## timers: the first version of this spike drifted enough that the client's
|
||||
## reports straddled the server's actions and read as inconclusive.
|
||||
|
||||
const PORT := 24599
|
||||
const ITEM := "res://test/spike/spike_item.tscn"
|
||||
|
||||
var _log: FileAccess
|
||||
var _is_server := false
|
||||
var _start_ms: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
_is_server = args.has("--server")
|
||||
_start_ms = Time.get_ticks_msec()
|
||||
_open_log()
|
||||
|
||||
# Registered identically on both peers BEFORE connecting: auto-spawn sends an
|
||||
# index into this array, so a mismatch builds the wrong scene on the client.
|
||||
$Spawner.add_spawnable_scene(ITEM)
|
||||
|
||||
# The hook under test: every peer attaches the generated synchronizers as the
|
||||
# node enters the tree. On the server that is our own add_child; on the
|
||||
# client it is MultiplayerSpawner's.
|
||||
$Content.child_entered_tree.connect(_on_content_child)
|
||||
|
||||
if _is_server:
|
||||
_run_server()
|
||||
else:
|
||||
_run_client()
|
||||
|
||||
|
||||
func _on_content_child(node: Node) -> void:
|
||||
if node is MultiplayerSynchronizer:
|
||||
return
|
||||
NetReplication.attach(node)
|
||||
_say("attached synchronizers to %s" % node.name)
|
||||
|
||||
|
||||
## Waits until `t` seconds after this scene started. Both peers start within a
|
||||
## few hundred ms of each other, which is close enough to interleave their
|
||||
## actions and observations deterministically.
|
||||
func _at(t: float) -> void:
|
||||
var target := _start_ms + int(t * 1000.0)
|
||||
while Time.get_ticks_msec() < target:
|
||||
await get_tree().process_frame
|
||||
|
||||
|
||||
## set_multiplayer_authority is a local call, so it has to run on every peer for
|
||||
## them to agree on who is driving the transform.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _set_xform_authority(path: NodePath, peer: int) -> void:
|
||||
var node := get_node_or_null(path)
|
||||
if not node:
|
||||
return
|
||||
node.get_node(NetReplication.XFORM_NAME).set_multiplayer_authority(peer)
|
||||
_say("NetXform authority of %s -> peer %d" % [node.name, peer])
|
||||
|
||||
|
||||
func _item() -> Node3D:
|
||||
return $Content.get_node_or_null("SpikeItem") as Node3D
|
||||
|
||||
|
||||
# --- server ----------------------------------------------------------------
|
||||
|
||||
func _run_server() -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(PORT, 4)
|
||||
if err != OK:
|
||||
_say("FATAL: create_server failed: %s" % error_string(err))
|
||||
_finish()
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_say("server up on %d" % PORT)
|
||||
|
||||
# t=1: spawn BEFORE anyone joins, so the client receives it purely as a
|
||||
# late-joiner replay — the same path as joining a running game.
|
||||
await _at(1.0)
|
||||
var item: Node3D = load(ITEM).instantiate()
|
||||
item.name = "SpikeItem"
|
||||
item.position = Vector3(5, 6, 7)
|
||||
item.health = 42
|
||||
item.label = "hello"
|
||||
$Content.add_child(item)
|
||||
_say("spawned SpikeItem at %s health=%d label=%s" % [item.position, item.health, item.label])
|
||||
|
||||
await _at(7.0)
|
||||
if multiplayer.get_peers().is_empty():
|
||||
_say("FATAL: no client connected by t=7")
|
||||
_finish()
|
||||
return
|
||||
var client: int = multiplayer.get_peers()[0]
|
||||
_say("client connected: %d" % client)
|
||||
|
||||
# t=8: change an ON_CHANGE value well after the client joined (Q2b).
|
||||
await _at(8.0)
|
||||
item.health = 7
|
||||
item.label = "changed"
|
||||
_say("changed health=7 label=changed")
|
||||
|
||||
# t=12: hand the transform to the client, as if it had just grabbed the item.
|
||||
await _at(12.0)
|
||||
_set_xform_authority.rpc(item.get_path(), client)
|
||||
|
||||
# t=13: the server now tries to move it and also changes server-owned state.
|
||||
# The move must NOT reach the client (it owns the transform); the state must.
|
||||
await _at(13.0)
|
||||
item.position = Vector3(-9, -9, -9)
|
||||
item.health = 123
|
||||
_say("server set pos=%s health=123 while the CLIENT owns NetXform" % item.position)
|
||||
|
||||
await _at(15.5)
|
||||
_say("Q4 server's view of item pos (expect the CLIENT's 1,1,1) -> %s" % item.position)
|
||||
|
||||
# t=17: take it back, as if the client had released it, and move it.
|
||||
await _at(17.0)
|
||||
_set_xform_authority.rpc(item.get_path(), 1)
|
||||
await _at(17.5)
|
||||
item.position = Vector3(-3, -3, -3)
|
||||
_say("server reclaimed NetXform and set pos=%s" % item.position)
|
||||
|
||||
# Stay alive past the client's last report, so a shutdown-induced despawn
|
||||
# can't be mistaken for anything else.
|
||||
await _at(24.0)
|
||||
_say("server done")
|
||||
_finish()
|
||||
|
||||
|
||||
# --- client ----------------------------------------------------------------
|
||||
|
||||
func _run_client() -> void:
|
||||
# Deliberately late, so the spawn is replayed rather than observed live.
|
||||
await _at(5.0)
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client("127.0.0.1", PORT)
|
||||
if err != OK:
|
||||
_say("FATAL: create_client failed: %s" % error_string(err))
|
||||
_finish()
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_say("client connecting...")
|
||||
|
||||
await _at(7.5)
|
||||
_report("Q1/Q2a late-join spawn (expect pos=5,6,7 health=42 label=hello)")
|
||||
|
||||
await _at(10.0)
|
||||
_report("Q2b after change (expect health=7 label=changed)")
|
||||
|
||||
# t=13.5: we now own NetXform. Drive the item ourselves, the way a held
|
||||
# object's grab driver would, and see whether the server's competing write
|
||||
# from t=13 gets ignored here and whether ours reaches the server.
|
||||
await _at(13.5)
|
||||
var item := _item()
|
||||
if item:
|
||||
item.position = Vector3(1, 1, 1)
|
||||
_say("client set pos=(1,1,1) while owning NetXform")
|
||||
|
||||
await _at(15.0)
|
||||
_report("Q4 client owns NetXform (expect pos=1,1,1 — NOT -9; health=123 — server state still arrives)")
|
||||
|
||||
await _at(20.0)
|
||||
_report("Q4b server reclaimed (expect pos=-3,-3,-3)")
|
||||
|
||||
_say("client done")
|
||||
_finish()
|
||||
|
||||
|
||||
func _report(what: String) -> void:
|
||||
var item := _item()
|
||||
if not item:
|
||||
_say("%s -> NO NODE (item absent on client)" % what)
|
||||
return
|
||||
_say("%s -> pos=%s health=%d label=%s private=%d" % [
|
||||
what, item.position, item.health, item.label,
|
||||
item._private_should_not_replicate,
|
||||
])
|
||||
|
||||
|
||||
# --- logging ---------------------------------------------------------------
|
||||
|
||||
func _open_log() -> void:
|
||||
var who := "server" if _is_server else "client"
|
||||
var dir := ProjectSettings.globalize_path("res://logs")
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
_log = FileAccess.open(dir.path_join("spike_%s.log" % who), FileAccess.WRITE)
|
||||
|
||||
|
||||
func _say(s: String) -> void:
|
||||
var line := "[%s t=%5.1f] %s" % [
|
||||
"SERVER" if _is_server else "CLIENT",
|
||||
(Time.get_ticks_msec() - _start_ms) / 1000.0, s,
|
||||
]
|
||||
print(line)
|
||||
if _log:
|
||||
_log.store_line(line)
|
||||
_log.flush()
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if _log:
|
||||
_log.flush()
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bh6fdnthnelfa
|
||||
@@ -0,0 +1,11 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://bspike0world0"]
|
||||
|
||||
[ext_resource type="Script" path="res://test/spike/spike_world.gd" id="1_spikew"]
|
||||
|
||||
[node name="SpikeWorld" type="Node3D"]
|
||||
script = ExtResource("1_spikew")
|
||||
|
||||
[node name="Content" type="Node3D" parent="."]
|
||||
|
||||
[node name="Spawner" type="MultiplayerSpawner" parent="."]
|
||||
spawn_path = NodePath("../Content")
|
||||
Reference in New Issue
Block a user