|
|
|
@@ -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()
|