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