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>
265 lines
8.4 KiB
GDScript
265 lines
8.4 KiB
GDScript
extends Node
|
|
|
|
## Session manager for VRyHungry (listen-server model: the host is peer 1 and
|
|
## also plays).
|
|
##
|
|
## 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 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()
|
|
|
|
var _world: Node = null
|
|
var _net_world: NetWorld = null
|
|
|
|
var _log_file: FileAccess
|
|
|
|
## 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
|
|
## does not exist yet at the moment the session actually ends.
|
|
var last_status := ""
|
|
|
|
|
|
func _ready() -> void:
|
|
_open_log()
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
|
|
# --- session ---------------------------------------------------------------
|
|
|
|
## 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)
|
|
if err != OK:
|
|
log_line("HOST failed to create_server on port %d: %s" % [port, error_string(err)])
|
|
return err
|
|
multiplayer.multiplayer_peer = peer
|
|
log_line("HOST started on port %d (peer id %d)" % [port, multiplayer.get_unique_id()])
|
|
session_started.emit(true)
|
|
player_joined.emit(multiplayer.get_unique_id())
|
|
return OK
|
|
|
|
|
|
## Join an existing host.
|
|
func join(address: String = "127.0.0.1", port: int = DEFAULT_PORT) -> Error:
|
|
var peer := ENetMultiplayerPeer.new()
|
|
var err := peer.create_client(address, port)
|
|
if err != OK:
|
|
log_line("JOIN failed to create_client %s:%d: %s" % [address, port, error_string(err)])
|
|
return err
|
|
multiplayer.multiplayer_peer = peer
|
|
log_line("JOIN connecting to %s:%d ..." % [address, port])
|
|
return OK
|
|
|
|
|
|
## Leave the session and tear down transport.
|
|
func leave() -> void:
|
|
_go_offline()
|
|
log_line("Session ended")
|
|
session_ended.emit()
|
|
|
|
|
|
# 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()
|
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
|
unregister_world()
|
|
|
|
|
|
func is_server() -> bool:
|
|
return is_online() and multiplayer.is_server()
|
|
|
|
|
|
## True only when a real ENet session is active. Godot installs a default
|
|
## OfflineMultiplayerPeer, so a non-null peer alone does not mean "online".
|
|
func is_online() -> bool:
|
|
var p := multiplayer.multiplayer_peer
|
|
return p != null and not (p is OfflineMultiplayerPeer)
|
|
|
|
|
|
## True on the machine that owns authoritative world logic: the server when
|
|
## 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()
|
|
|
|
|
|
# --- world -----------------------------------------------------------------
|
|
|
|
## 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
|
|
_net_world = net_world
|
|
log_line("World registered")
|
|
|
|
|
|
## 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
|
|
_net_world = null
|
|
|
|
|
|
## 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)
|
|
|
|
|
|
## 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 -------------------------------------------
|
|
|
|
# Set by the main menu's Host/Join buttons before switching to the multiplayer
|
|
# 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 := ""
|
|
|
|
func request_host() -> void:
|
|
pending_action = "host"
|
|
|
|
func request_join(ip: String) -> void:
|
|
pending_action = "join"
|
|
pending_ip = ip
|
|
|
|
func consume_pending_session() -> void:
|
|
if pending_action == "host":
|
|
pending_action = ""
|
|
host()
|
|
elif pending_action == "join":
|
|
pending_action = ""
|
|
join(pending_ip)
|
|
else:
|
|
_handle_cmdline()
|
|
|
|
|
|
## 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)
|
|
if is_server():
|
|
player_joined.emit(peer_id)
|
|
|
|
func _on_peer_disconnected(peer_id: int) -> void:
|
|
log_line("peer_disconnected: %d" % peer_id)
|
|
if is_server():
|
|
# 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())
|
|
session_started.emit(false)
|
|
|
|
func _on_connection_failed() -> void:
|
|
log_line("connection_failed")
|
|
_go_offline()
|
|
last_status = "Could not connect"
|
|
connection_failed.emit()
|
|
|
|
func _on_server_disconnected() -> void:
|
|
log_line("server_disconnected")
|
|
_go_offline()
|
|
last_status = "Host disconnected"
|
|
session_ended.emit()
|
|
|
|
|
|
# --- command-line driven test bootstrap -----------------------------------
|
|
|
|
func _handle_cmdline() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
if args.has("--server"):
|
|
log_line("cmdline: --server")
|
|
host()
|
|
elif args.has("--join"):
|
|
var idx := args.find("--join")
|
|
var addr := "127.0.0.1"
|
|
if idx + 1 < args.size():
|
|
addr = args[idx + 1]
|
|
log_line("cmdline: --join %s" % addr)
|
|
join(addr)
|
|
|
|
|
|
# --- logging ---------------------------------------------------------------
|
|
|
|
func _open_log() -> void:
|
|
var dir := OS.get_environment("TEMP")
|
|
if dir.is_empty():
|
|
dir = OS.get_environment("TMPDIR")
|
|
if dir.is_empty():
|
|
dir = "user://"
|
|
var path := dir.path_join("vryhungry_net_%d.log" % OS.get_process_id())
|
|
_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
|
|
if p != null and p.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED:
|
|
id = multiplayer.get_unique_id()
|
|
var line := "[NET %d] %s" % [id, s]
|
|
print(line)
|
|
if _log_file:
|
|
_log_file.store_line(line)
|
|
_log_file.flush()
|