Rebuild multiplayer on the stock spawner and synchronizer
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>
This commit is contained in:
+78
-373
@@ -1,36 +1,34 @@
|
||||
extends Node
|
||||
|
||||
## Client-server session manager for VRyHungry (listen-server model).
|
||||
## Session manager for VRyHungry (listen-server model: the host is peer 1 and
|
||||
## also plays).
|
||||
##
|
||||
## Registered as the "NetworkManager" autoload. Owns transport (ENet), tracks
|
||||
## the session, and is the single place that reassigns multiplayer authority
|
||||
## (only the server does so). The world scene (main.gd) registers its spawners
|
||||
## here via [method register_world]; higher layers (players, items, stations)
|
||||
## build on top of this in later phases.
|
||||
## Registered as the "NetworkManager" autoload. This owns the transport and the
|
||||
## session lifecycle, and nothing else — what exists in the world and who is
|
||||
## allowed to simulate it belongs to NetWorld, and interaction belongs to
|
||||
## NetGrab. The spawn_item/despawn_item pair below are deliberately thin
|
||||
## forwards, so game code keeps one obvious place to call.
|
||||
|
||||
const DEFAULT_PORT := 24565
|
||||
const MAX_CLIENTS := 7
|
||||
|
||||
## Emitted on every peer (including the server for its own local player) when a
|
||||
## player peer joins. On the server this fires for each remote peer; the server
|
||||
## uses it to spawn that peer's player.
|
||||
## uses it to spawn that peer's avatar.
|
||||
signal player_joined(peer_id: int)
|
||||
signal player_left(peer_id: int)
|
||||
signal session_started(is_server: bool)
|
||||
signal session_ended()
|
||||
signal connection_failed()
|
||||
|
||||
# World hooks, registered by main.gd once the scene tree exists.
|
||||
var _world: Node = null
|
||||
var _players_spawner: MultiplayerSpawner = null
|
||||
var _items_spawner: MultiplayerSpawner = null
|
||||
var _content_root: Node = null
|
||||
var _net_world: NetWorld = null
|
||||
|
||||
var _log_file: FileAccess
|
||||
|
||||
## Reason the session ended, shown by the menu on the next _ready() (see
|
||||
## Reason the session ended, shown by the menu on its next _ready() (see
|
||||
## take_status). Avoids depending on a live signal connection to a panel that
|
||||
## doesn't exist yet at the moment the session actually ends.
|
||||
## does not exist yet at the moment the session actually ends.
|
||||
var last_status := ""
|
||||
|
||||
|
||||
@@ -43,9 +41,9 @@ func _ready() -> void:
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
|
||||
|
||||
# --- Public API ------------------------------------------------------------
|
||||
# --- session ---------------------------------------------------------------
|
||||
|
||||
## Start hosting. The host is peer 1 and also plays (listen server).
|
||||
## Start hosting. The host is peer 1 and also plays.
|
||||
func host(port: int = DEFAULT_PORT) -> Error:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, MAX_CLIENTS)
|
||||
@@ -55,8 +53,7 @@ func host(port: int = DEFAULT_PORT) -> Error:
|
||||
multiplayer.multiplayer_peer = peer
|
||||
log_line("HOST started on port %d (peer id %d)" % [port, multiplayer.get_unique_id()])
|
||||
session_started.emit(true)
|
||||
# The host's own local player joins immediately.
|
||||
_on_player_present(multiplayer.get_unique_id())
|
||||
player_joined.emit(multiplayer.get_unique_id())
|
||||
return OK
|
||||
|
||||
|
||||
@@ -79,9 +76,9 @@ func leave() -> void:
|
||||
session_ended.emit()
|
||||
|
||||
|
||||
# Restore Godot's default OfflineMultiplayerPeer (rather than leaving the peer
|
||||
# null), so is_multiplayer_authority()/get_unique_id() keep working while we are
|
||||
# back in single-player / menu state.
|
||||
# Restore Godot's default OfflineMultiplayerPeer rather than leaving the peer
|
||||
# null: is_multiplayer_authority() and get_unique_id() both throw on a null peer,
|
||||
# and plenty of code keeps calling them while we are back in menu state.
|
||||
func _go_offline() -> void:
|
||||
if multiplayer.multiplayer_peer:
|
||||
multiplayer.multiplayer_peer.close()
|
||||
@@ -89,297 +86,6 @@ func _go_offline() -> void:
|
||||
unregister_world()
|
||||
|
||||
|
||||
# --- Item spawning ---------------------------------------------------------
|
||||
|
||||
## Spawn a networked item. Server-only when online (replicates to all peers via
|
||||
## the ItemsSpawner, including late joiners); works directly when offline.
|
||||
## node_name gives the spawned node a deterministic, identical name on every
|
||||
## peer (needed for NodePath-based RPCs to resolve it); props are applied to
|
||||
## the instance before it enters the tree, so exported vars land correctly.
|
||||
## Returns the new node on the machine that owns spawning, else null.
|
||||
func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node:
|
||||
if is_online() and not is_server():
|
||||
return null
|
||||
var data := {"scene": scene_path, "xform": xform, "name": node_name, "props": props}
|
||||
var via := "spawner" if (is_online() and _items_spawner) else "offline"
|
||||
log_line("spawn_item: %s (name=%s, via=%s)" % [scene_path.get_file(), node_name, via])
|
||||
if is_online() and _items_spawner:
|
||||
return _items_spawner.spawn(data)
|
||||
# Offline: instantiate directly under the registered content root, or (for
|
||||
# scenes that never call register_world, e.g. the offline menu/dev scenes)
|
||||
# the current scene, so this keeps working without every offline scene
|
||||
# needing to opt in.
|
||||
var inst := _spawn_item_from_data(data)
|
||||
if inst:
|
||||
var parent: Node = _content_root if _content_root else get_tree().current_scene
|
||||
if parent:
|
||||
parent.add_child(inst)
|
||||
return inst
|
||||
|
||||
|
||||
## Despawn a server-spawned item. MultiplayerSpawner broadcasts a despawn to
|
||||
## every peer when a tracked node exits the tree on the authority, so this is
|
||||
## the single seam for destroying spawned items (works offline too).
|
||||
func despawn_item(node: Node) -> void:
|
||||
if not owns_world() or not is_instance_valid(node):
|
||||
return
|
||||
log_line("despawn_item: %s" % node.name)
|
||||
# Items that came from the ItemsSpawner are despawned on every peer
|
||||
# automatically when they leave the tree here. Items baked into a scene file
|
||||
# are unknown to the spawner, so their removal has to be broadcast
|
||||
# explicitly — otherwise every client keeps a ghost copy of an item the
|
||||
# server has consumed, which then blocks the station it was sitting in and
|
||||
# gets grabbed instead of the real item that replaced it.
|
||||
if is_online() and not _is_spawner_tracked(node):
|
||||
_despawn_static_item.rpc(node.get_path())
|
||||
node.queue_free()
|
||||
|
||||
|
||||
# Items the ItemsSpawner replicates live under its spawn path; anything else was
|
||||
# baked into the scene file and the spawner knows nothing about it.
|
||||
func _is_spawner_tracked(node: Node) -> bool:
|
||||
return _content_root != null and _content_root.is_ancestor_of(node)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _despawn_static_item(path: NodePath) -> void:
|
||||
var node := get_node_or_null(path)
|
||||
if node:
|
||||
log_line("despawn_static_item: freeing %s (the server consumed it)" % node.name)
|
||||
node.queue_free()
|
||||
|
||||
|
||||
# MultiplayerSpawner custom spawn function: runs on every peer to build the node
|
||||
# from the replicated payload.
|
||||
func _spawn_item_from_data(data: Variant) -> Node:
|
||||
var scene: PackedScene = load(data["scene"])
|
||||
if not scene:
|
||||
push_error("spawn_item: could not load scene %s" % str(data.get("scene")))
|
||||
return null
|
||||
var inst := scene.instantiate()
|
||||
if inst is Node3D:
|
||||
inst.transform = data["xform"]
|
||||
if data.get("name", "") != "":
|
||||
inst.name = data["name"]
|
||||
for key in data.get("props", {}):
|
||||
inst.set(key, data["props"][key])
|
||||
if not owns_world():
|
||||
_gate_station(inst)
|
||||
return inst
|
||||
|
||||
|
||||
# Stations run their own logic and auto-grab (XRToolsSnapZone with
|
||||
# snap_mode=RANGE) identically on every peer by default, which would let each
|
||||
# peer independently grab/simulate the same shared object. Disable both on
|
||||
# every peer except the one that owns world logic; the server-authoritative
|
||||
# item-authority RPCs are what let clients still grab a server-held item by
|
||||
# hand. Runs before the node enters the tree, so its own _ready() sees the
|
||||
# final (disabled) state.
|
||||
func _gate_station(node: Node) -> void:
|
||||
if not (node is StaticBody3D):
|
||||
return
|
||||
for child in node.get_children():
|
||||
if child is XRToolsSnapZone:
|
||||
child.enabled = false
|
||||
child.set_process(false)
|
||||
node.set_process(false)
|
||||
log_line("gated station (non-owner peer): %s" % node.name)
|
||||
|
||||
|
||||
## Gate every station already sitting in the scene tree, for peers that don't
|
||||
## own world logic. Stations that arrive through spawn_item() are gated as they
|
||||
## are built (see _spawn_item_from_data), but ones baked into a scene file never
|
||||
## pass through there — leaving a client running its own snap zones, which then
|
||||
## grab items straight out of the local hand and fight the server's
|
||||
## authoritative placement. Idempotent, so it's safe on every session start.
|
||||
func gate_existing_stations() -> void:
|
||||
if owns_world():
|
||||
return
|
||||
for station in get_tree().get_nodes_in_group("station"):
|
||||
_gate_station(station)
|
||||
|
||||
|
||||
# --- Item grab-authority transfer -----------------------------------------
|
||||
|
||||
## Called by NetPickable when this peer grabs an item by hand. Godot rejects
|
||||
## rpc_id() targeting your own peer id ("RPC on yourself is not allowed"), so
|
||||
## when we ARE the server this runs the logic directly instead of round-
|
||||
## tripping an RPC to ourselves — otherwise every host-side grab/drop was
|
||||
## silently failing to run its server-side half (no denial checks, and
|
||||
## crucially no auto-snap-into-station on release).
|
||||
func request_item_authority_from(item_path: NodePath) -> void:
|
||||
if is_server():
|
||||
_grant_or_reject_item_authority(item_path, multiplayer.get_unique_id())
|
||||
else:
|
||||
_request_item_authority_rpc.rpc_id(1, item_path)
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _request_item_authority_rpc(item_path: NodePath) -> void:
|
||||
if not is_server():
|
||||
return
|
||||
_grant_or_reject_item_authority(item_path, multiplayer.get_remote_sender_id())
|
||||
|
||||
|
||||
## Runs on the server (called directly if the requester IS the server, or via
|
||||
## the RPC above otherwise). If the item was snapped into a station, the
|
||||
## station releases it so the grabber cleanly takes ownership.
|
||||
func _grant_or_reject_item_authority(item_path: NodePath, sender: int) -> void:
|
||||
var item := get_node_or_null(item_path)
|
||||
if item:
|
||||
var np := item.get_node_or_null("NetPickable")
|
||||
if np and np.net_held_by != 0 and np.net_held_by != sender:
|
||||
# Already legitimately held by a different live peer: reject the
|
||||
# requester's optimistic client-side grab instead of stealing it.
|
||||
log_line("request_item_authority: DENIED %s to peer %d (already held by %d)" % [item.name, sender, np.net_held_by])
|
||||
_force_release_item_to(sender, item_path)
|
||||
return
|
||||
log_line("request_item_authority: granting %s to peer %d" % [str(item.name) if item else str(item_path), sender])
|
||||
# Assign authority + held state first (disables the item on the server so its
|
||||
# snap zone won't re-grab it), then release it from any station.
|
||||
_set_item_authority.rpc(item_path, sender)
|
||||
if item:
|
||||
_release_from_snap_zones(item)
|
||||
|
||||
|
||||
## Called by NetPickable when this peer releases an item, forwarding its throw
|
||||
## velocity so the server can resume simulating it. Same self-RPC issue as
|
||||
## above: runs directly if we're the server.
|
||||
func release_item_authority_from(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
|
||||
if is_server():
|
||||
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_unique_id())
|
||||
else:
|
||||
_release_item_authority_rpc.rpc_id(1, item_path, lin, ang, xform)
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func _release_item_authority_rpc(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D) -> void:
|
||||
if not is_server():
|
||||
return
|
||||
_do_release_item_authority(item_path, lin, ang, xform, multiplayer.get_remote_sender_id())
|
||||
|
||||
|
||||
## Runs on the server. If released next to a station, the server snaps it in
|
||||
## (server-authoritative placement).
|
||||
func _do_release_item_authority(item_path: NodePath, lin: Vector3, ang: Vector3, xform: Transform3D, sender: int) -> void:
|
||||
log_line("release_item_authority: %s released by peer %d" % [str(item_path), sender])
|
||||
_set_item_authority.rpc(item_path, 1)
|
||||
var item := get_node_or_null(item_path)
|
||||
if item is RigidBody3D:
|
||||
# Adopt the releasing peer's own final transform rather than trusting our
|
||||
# copy's. That peer was the item's authority right up to this moment, and
|
||||
# its position updates travel on the synchronizer's separate, unordered
|
||||
# channel — this reliable RPC routinely overtakes them, leaving our copy
|
||||
# still sitting where the item was BEFORE the peer carried it away. The
|
||||
# snap decision below then reads that stale position and teleports the
|
||||
# item straight back into the station it was just picked up from.
|
||||
item.global_transform = xform
|
||||
item.freeze = false
|
||||
item.linear_velocity = lin
|
||||
item.angular_velocity = ang
|
||||
_try_snap_into_station.call_deferred(item)
|
||||
|
||||
|
||||
# All station snap zones in the world (every XRToolsSnapZone child of a node in
|
||||
# the "station" group — some stations, e.g. Table, have more than one).
|
||||
func _station_snap_zones() -> Array:
|
||||
var zones := []
|
||||
for station in get_tree().get_nodes_in_group("station"):
|
||||
for child in station.get_children():
|
||||
if child is XRToolsSnapZone:
|
||||
zones.append(child)
|
||||
return zones
|
||||
|
||||
|
||||
# If the item is snapped into any station, drop it from that station.
|
||||
func _release_from_snap_zones(item: Node) -> void:
|
||||
for zone in _station_snap_zones():
|
||||
if zone.picked_up_object == item:
|
||||
log_line("releasing %s from %s's snap zone (authority just granted elsewhere)" % [item.name, zone.get_parent().name])
|
||||
zone.drop_object()
|
||||
# Make the zone forget the item as well. These zones are snap_mode=RANGE,
|
||||
# so every frame they re-grab anything still listed in their grab area
|
||||
# that can be picked up — and Jolt does not emit body_exited when let_go()
|
||||
# switches the item's collision layer back out of the zone's mask, so the
|
||||
# entry goes stale and never clears. The station then snatches the item
|
||||
# straight back off the player who just took it, teleporting it home.
|
||||
# Bringing it near again re-adds it properly (a held item is on the layer
|
||||
# the zone watches), and releasing next to a station is handled
|
||||
# explicitly by _try_snap_into_station.
|
||||
if zone._object_in_grab_area.has(item):
|
||||
zone._object_in_grab_area.erase(item)
|
||||
|
||||
|
||||
# Snap the item into the nearest empty station snap zone within grab range.
|
||||
#
|
||||
# Called deferred from _do_release_item_authority: XRToolsFunctionPickup's own
|
||||
# "grab an item out of a snap zone" path calls zone.drop_object() BEFORE it
|
||||
# calls pick_up() on the hand's behalf. drop_object()'s let_go() synchronously
|
||||
# fires the pickable's `dropped` signal, which (via NetPickable) lands here —
|
||||
# if this ran synchronously it would immediately re-snap the item into the
|
||||
# very same zone it's still physically inside, stealing it away before the
|
||||
# hand's own pick_up() call (later in the same call stack) ever runs. That
|
||||
# leaves XRToolsFunctionPickup.picked_up_object pointing at an item whose
|
||||
# _grab_driver actually belongs to the zone — a stale reference that crashes
|
||||
# (null _grab_driver) the next time a controller button is pressed. Deferring
|
||||
# lets the hand's pick_up() go first; the is_picked_up() check below is a
|
||||
# second guard in case the item gets grabbed for real before this runs.
|
||||
func _try_snap_into_station(item: Node) -> void:
|
||||
if not (item is Node3D):
|
||||
return
|
||||
if item.has_method("is_picked_up") and item.is_picked_up():
|
||||
var by: Node = null
|
||||
if item.has_method("get_picked_up_by"):
|
||||
by = item.get_picked_up_by()
|
||||
log_line("skipped snapping %s: already held by %s (grab-race guard)" % [item.name, by.get_path() if by else "?"])
|
||||
return
|
||||
for zone in _station_snap_zones():
|
||||
if is_instance_valid(zone.picked_up_object):
|
||||
continue
|
||||
if zone.global_position.distance_to(item.global_position) <= zone.grab_distance:
|
||||
log_line("snapped %s into %s" % [item.name, zone.get_parent().name])
|
||||
zone.pick_up_object(item)
|
||||
return
|
||||
log_line("no station in range to snap %s into (or none empty)" % item.name)
|
||||
|
||||
|
||||
# Server broadcasts an authority assignment so every peer agrees on who owns the
|
||||
# item (set_multiplayer_authority is a local call and must run everywhere).
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func _set_item_authority(item_path: NodePath, peer: int) -> void:
|
||||
var item := get_node_or_null(item_path)
|
||||
if not item:
|
||||
return
|
||||
log_line("_set_item_authority: %s -> peer %d" % [item.name, peer])
|
||||
item.set_multiplayer_authority(peer) # recursive: item + synchronizer + NetPickable
|
||||
var np := item.get_node_or_null("NetPickable")
|
||||
if np:
|
||||
np.net_held_by = 0 if peer == 1 else peer
|
||||
np.apply_held_state()
|
||||
|
||||
|
||||
## Rejects peer's optimistic grab (the item was already legitimately held by
|
||||
## someone else). Same self-RPC concern: if the rejected peer is the server
|
||||
## itself, apply it directly rather than rpc_id-ing ourselves.
|
||||
func _force_release_item_to(peer: int, item_path: NodePath) -> void:
|
||||
if peer == 1:
|
||||
_do_force_release(item_path)
|
||||
else:
|
||||
force_release_item.rpc_id(peer, item_path)
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func force_release_item(item_path: NodePath) -> void:
|
||||
_do_force_release(item_path)
|
||||
|
||||
|
||||
func _do_force_release(item_path: NodePath) -> void:
|
||||
log_line("force_release_item: dropping %s (server rejected our grab)" % str(item_path))
|
||||
var item := get_node_or_null(item_path)
|
||||
if item and item.has_method("drop"):
|
||||
item.drop()
|
||||
|
||||
|
||||
func is_server() -> bool:
|
||||
return is_online() and multiplayer.is_server()
|
||||
|
||||
@@ -392,71 +98,69 @@ func is_online() -> bool:
|
||||
|
||||
|
||||
## True on the machine that owns authoritative world logic: the server when
|
||||
## online, or the single player when offline. Station logic and spawning should
|
||||
## only run where this is true, so state has one source of truth.
|
||||
## online, or the single player when offline. Station logic and spawning only run
|
||||
## where this is true, so state has one source of truth.
|
||||
func owns_world() -> bool:
|
||||
return not is_online() or is_server()
|
||||
|
||||
|
||||
# --- Station work-progress seam -------------------------------------------
|
||||
# --- world -----------------------------------------------------------------
|
||||
|
||||
## Reusable entry point for a client to contribute work to a station (e.g. a
|
||||
## future chopping/gesture station). The client detects the gesture locally and
|
||||
## calls this; the server validates and accumulates. Timer-driven stations like
|
||||
## the Hob don't need it, but it is the drop-in seam for input-driven ones.
|
||||
@rpc("any_peer", "reliable")
|
||||
func submit_work(station_path: NodePath, amount: float) -> void:
|
||||
if not is_server():
|
||||
return
|
||||
var station := get_node_or_null(station_path)
|
||||
if station and station.has_method("add_work"):
|
||||
log_line("submit_work: peer %d contributed %.2f to %s" % [multiplayer.get_remote_sender_id(), amount, station.name])
|
||||
station.add_work(multiplayer.get_remote_sender_id(), amount)
|
||||
|
||||
|
||||
## Called by the world scene once it's ready, passing its spawners. Must run
|
||||
## before world_ready()/host()/join() on every peer so the custom spawn
|
||||
## function is installed before any spawn packet can arrive.
|
||||
func register_world(world: Node, players_spawner: MultiplayerSpawner, items_spawner: MultiplayerSpawner) -> void:
|
||||
## Called by the world scene once its tree exists. Must run before
|
||||
## world_ready()/host()/join() on every peer, so the spawnable scene list is
|
||||
## registered before any spawn packet can arrive.
|
||||
func register_world(world: Node, net_world: NetWorld) -> void:
|
||||
_world = world
|
||||
_players_spawner = players_spawner
|
||||
_items_spawner = items_spawner
|
||||
_content_root = items_spawner.get_node(items_spawner.spawn_path) if items_spawner else world
|
||||
if _items_spawner:
|
||||
_items_spawner.spawn_function = _spawn_item_from_data
|
||||
log_line("World registered (players_spawner=%s items_spawner=%s)" % [str(players_spawner != null), str(items_spawner != null)])
|
||||
_net_world = net_world
|
||||
log_line("World registered")
|
||||
|
||||
|
||||
## Called when the world scene goes away (disconnect, leaving the session) so
|
||||
## the autoload doesn't hold stale/freed references across a scene reload.
|
||||
## Called when the world scene goes away (disconnect, leaving the session) so the
|
||||
## autoload does not hold freed references across a scene reload.
|
||||
func unregister_world() -> void:
|
||||
_world = null
|
||||
_content_root = null
|
||||
_players_spawner = null
|
||||
_items_spawner = null
|
||||
_net_world = null
|
||||
|
||||
|
||||
## Returns the reason the last session ended (if any) and clears it. The menu
|
||||
## pulls this on its own _ready() rather than depending on a live signal
|
||||
## connection to a panel that doesn't exist yet when the session ends.
|
||||
func take_status() -> String:
|
||||
var s := last_status
|
||||
last_status = ""
|
||||
return s
|
||||
## Creates a networked object. See NetWorld.spawn.
|
||||
func spawn_item(scene_path: String, xform: Transform3D, node_name: String = "", props: Dictionary = {}) -> Node:
|
||||
if not _net_world:
|
||||
push_error("spawn_item called with no world registered: %s" % scene_path)
|
||||
return null
|
||||
return _net_world.spawn(scene_path, xform, node_name, props)
|
||||
|
||||
|
||||
## Called by main.gd after it has registered the world and connected its
|
||||
## player_joined/left listeners. Kicks off any menu- or command-line-driven
|
||||
## session so that session signals never fire before the world is listening.
|
||||
## Destroys a networked object everywhere. See NetWorld.despawn.
|
||||
func despawn_item(node: Node) -> void:
|
||||
if _net_world:
|
||||
_net_world.despawn(node)
|
||||
|
||||
|
||||
## Puts an object into the right physics state for whether this peer drives it.
|
||||
func apply_physics_role(node: Node) -> void:
|
||||
if _net_world:
|
||||
_net_world.apply_physics_role(node)
|
||||
|
||||
|
||||
## Every replicated object currently in the world.
|
||||
func replicated_objects() -> Array[Node]:
|
||||
if not _net_world:
|
||||
return []
|
||||
return _net_world.objects()
|
||||
|
||||
|
||||
## Called by the world scene after it has registered itself and connected its
|
||||
## listeners. Kicks off any menu- or command-line-driven session, so session
|
||||
## signals never fire before the world is listening for them.
|
||||
func world_ready() -> void:
|
||||
consume_pending_session()
|
||||
|
||||
|
||||
# --- Menu-driven session request -------------------------------------------
|
||||
# --- menu-driven session request -------------------------------------------
|
||||
|
||||
# Set by the main menu's Host/Join buttons before switching to the multiplayer
|
||||
# scene; consumed once that scene's world is ready to listen for session
|
||||
# signals (avoids a race between change_scene_to_file and connection callbacks).
|
||||
# scene; consumed once that scene is ready to listen for session signals (avoids
|
||||
# a race between change_scene_to_file and the connection callbacks).
|
||||
var pending_action := ""
|
||||
var pending_ip := ""
|
||||
|
||||
@@ -478,18 +182,29 @@ func consume_pending_session() -> void:
|
||||
_handle_cmdline()
|
||||
|
||||
|
||||
# --- Session signal handlers ----------------------------------------------
|
||||
## Returns the reason the last session ended (if any) and clears it. The menu
|
||||
## pulls this on its own _ready() rather than depending on a live signal
|
||||
## connection to a panel that does not exist yet when the session ends.
|
||||
func take_status() -> String:
|
||||
var s := last_status
|
||||
last_status = ""
|
||||
return s
|
||||
|
||||
|
||||
# --- session signal handlers ----------------------------------------------
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
log_line("peer_connected: %d" % peer_id)
|
||||
# Only the server reacts by materialising that peer's player.
|
||||
if is_server():
|
||||
_on_player_present(peer_id)
|
||||
player_joined.emit(peer_id)
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
log_line("peer_disconnected: %d" % peer_id)
|
||||
if is_server():
|
||||
_on_player_absent(peer_id)
|
||||
# Anything still in their hand would otherwise stay frozen on every
|
||||
# remaining peer, waiting on an authority that has gone.
|
||||
NetGrab.reclaim_from(peer_id)
|
||||
player_left.emit(peer_id)
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
log_line("connected_to_server (my id=%d)" % multiplayer.get_unique_id())
|
||||
@@ -508,18 +223,7 @@ func _on_server_disconnected() -> void:
|
||||
session_ended.emit()
|
||||
|
||||
|
||||
# Player materialise/dematerialise. Phase 2 wires these to the PlayersSpawner;
|
||||
# for now they announce presence so the transport layer is independently testable.
|
||||
func _on_player_present(peer_id: int) -> void:
|
||||
log_line("player_present: %d" % peer_id)
|
||||
player_joined.emit(peer_id)
|
||||
|
||||
func _on_player_absent(peer_id: int) -> void:
|
||||
log_line("player_absent: %d" % peer_id)
|
||||
player_left.emit(peer_id)
|
||||
|
||||
|
||||
# --- Command-line driven test bootstrap -----------------------------------
|
||||
# --- command-line driven test bootstrap -----------------------------------
|
||||
|
||||
func _handle_cmdline() -> void:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
@@ -535,7 +239,7 @@ func _handle_cmdline() -> void:
|
||||
join(addr)
|
||||
|
||||
|
||||
# --- Logging ---------------------------------------------------------------
|
||||
# --- logging ---------------------------------------------------------------
|
||||
|
||||
func _open_log() -> void:
|
||||
var dir := OS.get_environment("TEMP")
|
||||
@@ -547,6 +251,7 @@ func _open_log() -> void:
|
||||
_log_file = FileAccess.open(path, FileAccess.WRITE)
|
||||
log_line("=== NetworkManager log (pid %d) ===" % OS.get_process_id())
|
||||
|
||||
|
||||
func log_line(s: String) -> void:
|
||||
var id := 0
|
||||
var p := multiplayer.multiplayer_peer
|
||||
|
||||
Reference in New Issue
Block a user