406 lines
13 KiB
GDScript
406 lines
13 KiB
GDScript
extends Node
|
|
|
|
## Owns this process's network role: a dedicated authoritative server (started
|
|
## with `--server`) or a client that talks to one.
|
|
##
|
|
## The transport is WebSocket rather than raw UDP so the same server can serve a
|
|
## browser client over HTTP(S). A dedicated server listens as a WebSocket server
|
|
## (usually behind a reverse proxy that terminates TLS); clients connect to a
|
|
## `ws://` or `wss://` URL. On the web the client defaults to the page's own
|
|
## origin, so a server and its web client can share one host and port.
|
|
##
|
|
## Creating a new game connects this process to an authoritative server as its
|
|
## first player, who becomes the game's admin and configures it. On the web the
|
|
## server is the dedicated instance already serving the page (a browser cannot
|
|
## spawn processes); elsewhere a dedicated headless instance is spawned and the
|
|
## client connects to that. Admin stays with the player across reconnections,
|
|
## because admin is tied to the player's name rather than their peer id.
|
|
##
|
|
## Joining requires a name and a password. A new name sets its password on its
|
|
## first join; a known name must present the same password again, which is how
|
|
## a player returns to an ongoing game (and reclaims their civilisation). There
|
|
## is no other registration.
|
|
##
|
|
## Both roles expose this node at /root/Network so the RPCs below resolve on
|
|
## either side of the connection.
|
|
|
|
signal session_started
|
|
signal session_failed(message: String)
|
|
signal server_disconnected
|
|
signal players_updated(players: Dictionary)
|
|
signal game_configured
|
|
signal game_state_received(state: Dictionary)
|
|
|
|
const DEFAULT_PORT := 27015
|
|
## Path the reverse proxy forwards to the game server (e.g. wss://host/ws).
|
|
const DEFAULT_PATH := "/ws"
|
|
const CONNECT_TIMEOUT := 15.0
|
|
const SERVER_SCENE := "res://scenes/server_main.tscn"
|
|
const READY_FILE := "user://server_%d.ready"
|
|
|
|
## Set by the GameServer node in the server process so incoming order RPCs can
|
|
## be handed to it. Null on a client.
|
|
var game_server: GameServer = null
|
|
|
|
var _is_server := false
|
|
var _is_admin := false
|
|
var _local_player_id := 0
|
|
var _players: Dictionary = {}
|
|
## Peer id -> player name, for peers that have authenticated this session.
|
|
var _player_names: Dictionary = {}
|
|
var _admin_peer_id := 0
|
|
## Set when the server refuses a login, so the disconnect that follows reports
|
|
## the real reason instead of a generic one.
|
|
var _login_failure := ""
|
|
var _server_process_id := 0
|
|
|
|
|
|
func _ready() -> void:
|
|
var args := OS.get_cmdline_user_args()
|
|
if args.has("--server"):
|
|
_start_server(args)
|
|
|
|
|
|
func is_server() -> bool:
|
|
return _is_server
|
|
|
|
|
|
func is_admin() -> bool:
|
|
return _is_admin
|
|
|
|
|
|
## True when this process is a client with a live connection to a server. False
|
|
## when running standalone, which the world map uses to fall back to local play.
|
|
func is_client_connected() -> bool:
|
|
return not _is_server and _local_player_id != 0
|
|
|
|
|
|
func get_local_player_id() -> int:
|
|
return _local_player_id
|
|
|
|
|
|
func get_players() -> Dictionary:
|
|
return _players
|
|
|
|
|
|
func get_admin_peer_id() -> int:
|
|
return _admin_peer_id
|
|
|
|
|
|
func get_peer_ids() -> Array:
|
|
return _players.keys()
|
|
|
|
|
|
## Registers the authoritative GameServer so RPCs can reach it (server process).
|
|
func register_game_server(server: GameServer) -> void:
|
|
game_server = server
|
|
|
|
|
|
## Sends the new-game choices to the server. Only the admin (host) may do this.
|
|
func send_game_setup(setup: Dictionary) -> void:
|
|
if _is_server:
|
|
return
|
|
_rpc_configure_game.rpc_id(1, setup)
|
|
|
|
|
|
## Sends a gameplay order to the server.
|
|
func send_order(order: Dictionary) -> void:
|
|
if _is_server:
|
|
return
|
|
_rpc_submit_order.rpc_id(1, order)
|
|
|
|
|
|
## Pushes a state snapshot to one connected peer (server process only).
|
|
func send_game_state(peer_id: int, snapshot: Dictionary) -> void:
|
|
if not _is_server or peer_id <= 0:
|
|
return
|
|
_rpc_game_state.rpc_id(peer_id, snapshot)
|
|
|
|
|
|
## Starts a new game. The web client cannot spawn a server, so it creates the
|
|
## game on the dedicated server already serving the page; elsewhere a dedicated
|
|
## server process is spawned and the client connects to it.
|
|
func create_game() -> Error:
|
|
if OS.has_feature("web"):
|
|
return join_game_url(default_server_url())
|
|
return host_game()
|
|
|
|
|
|
## Spawns a dedicated server process and connects to it as a client.
|
|
func host_game() -> Error:
|
|
var port := _find_free_port()
|
|
var pid := _spawn_server(port)
|
|
if pid <= 0:
|
|
return ERR_CANT_CREATE
|
|
_server_process_id = pid
|
|
_connect_when_ready("127.0.0.1", port)
|
|
return OK
|
|
|
|
|
|
## Connects to a server given a host and port. `host` may be a bare hostname
|
|
## (a `ws://host:port/ws` URL is built) or a full `http(s)://` / `ws(s)://` URL.
|
|
func join_game(host: String, port: int = DEFAULT_PORT) -> Error:
|
|
return join_game_url(_resolve_url(host, port))
|
|
|
|
|
|
## Connects to an explicit WebSocket URL, e.g. `wss://example.com/ws`.
|
|
func join_game_url(url: String) -> Error:
|
|
var peer := WebSocketMultiplayerPeer.new()
|
|
var error := peer.create_client(url)
|
|
if error != OK:
|
|
session_failed.emit("Could not reach the game server.")
|
|
return error
|
|
_install_client_peer(peer)
|
|
return OK
|
|
|
|
|
|
## Default server to join: the page's own origin when running in a browser (so
|
|
## the web client and its server can share one host), otherwise a local server.
|
|
func default_server_url() -> String:
|
|
if OS.has_feature("web"):
|
|
var origin := str(JavaScriptBridge.eval("window.location.origin"))
|
|
if not origin.is_empty() and origin != "null":
|
|
return _websocket_url(origin)
|
|
return _websocket_url("http://127.0.0.1:%d" % DEFAULT_PORT)
|
|
|
|
|
|
func _resolve_url(host: String, port: int) -> String:
|
|
var h := host.strip_edges()
|
|
if (
|
|
h.begins_with("ws://")
|
|
or h.begins_with("wss://")
|
|
or h.begins_with("http://")
|
|
or h.begins_with("https://")
|
|
):
|
|
return _websocket_url(h)
|
|
return _websocket_url("http://%s:%d" % [h, port])
|
|
|
|
|
|
## Normalises an http(s) origin or ws(s) base into a ws(s) URL ending in
|
|
## DEFAULT_PATH unless the URL already carries a path of its own.
|
|
func _websocket_url(base: String) -> String:
|
|
var url := base.strip_edges().rstrip("/")
|
|
if url.begins_with("https://"):
|
|
url = "wss://" + url.substr("https://".length())
|
|
elif url.begins_with("http://"):
|
|
url = "ws://" + url.substr("http://".length())
|
|
elif not url.begins_with("ws://") and not url.begins_with("wss://"):
|
|
url = "ws://" + url
|
|
var after_scheme := url.substr(url.find("://") + 3)
|
|
if not after_scheme.contains("/"):
|
|
url += DEFAULT_PATH
|
|
return url
|
|
|
|
|
|
## Stops the server process this client spawned, if any.
|
|
func stop_hosted_server() -> void:
|
|
if _server_process_id > 0:
|
|
OS.kill(_server_process_id)
|
|
_server_process_id = 0
|
|
|
|
|
|
func _start_server(args: PackedStringArray) -> void:
|
|
_is_server = true
|
|
var port := _int_arg(args, "--port", DEFAULT_PORT)
|
|
# Bind locally by default so a reverse proxy is the only public entry point.
|
|
# Pass `--bind *` to accept direct connections from other machines.
|
|
var bind := _string_arg(args, "--bind", "127.0.0.1")
|
|
var peer := WebSocketMultiplayerPeer.new()
|
|
var error := peer.create_server(port, bind)
|
|
if error != OK:
|
|
push_error("Game server could not listen on %s:%d (error %d)." % [bind, port, error])
|
|
get_tree().quit(1)
|
|
return
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.peer_connected.connect(_on_peer_connected)
|
|
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
|
_write_ready_file(port)
|
|
print("Game server listening on ws://%s:%d%s." % [bind, port, DEFAULT_PATH])
|
|
|
|
|
|
func _connect_when_ready(host: String, port: int) -> void:
|
|
var deadline := Time.get_ticks_msec() + int(CONNECT_TIMEOUT * 1000.0)
|
|
var ready_path := READY_FILE % port
|
|
while Time.get_ticks_msec() < deadline:
|
|
if FileAccess.file_exists(ready_path):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(ready_path))
|
|
join_game(host, port)
|
|
return
|
|
await get_tree().create_timer(0.2).timeout
|
|
session_failed.emit("Timed out waiting for the game server to start.")
|
|
|
|
|
|
func _install_client_peer(peer: WebSocketMultiplayerPeer) -> void:
|
|
# The server owns the clock; this client mirrors the time it is sent.
|
|
GameClock.auto_advance = false
|
|
multiplayer.multiplayer_peer = peer
|
|
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
|
multiplayer.connection_failed.connect(_on_connection_failed)
|
|
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
|
|
|
|
|
## A raw connection is not a player yet: it must send credentials via _rpc_login
|
|
## before it is added to the roster, receives state or may place orders.
|
|
func _on_peer_connected(_id: int) -> void:
|
|
pass
|
|
|
|
|
|
func _on_peer_disconnected(id: int) -> void:
|
|
_players.erase(id)
|
|
_player_names.erase(id)
|
|
if id == _admin_peer_id:
|
|
# The admin's identity is remembered, so they resume as admin when they
|
|
# log back in. Until then the game keeps running under nobody.
|
|
_admin_peer_id = 0
|
|
_sync_players.rpc(_players)
|
|
if game_server:
|
|
game_server.on_peer_left(id)
|
|
|
|
|
|
## Admits a peer that supplied valid credentials and announces it to everyone.
|
|
func _accept_login(peer_id: int, player_name: String, is_admin: bool) -> void:
|
|
_player_names[peer_id] = player_name
|
|
if is_admin:
|
|
_admin_peer_id = peer_id
|
|
_players[peer_id] = {"id": peer_id, "name": player_name, "admin": is_admin}
|
|
_assigned.rpc_id(peer_id, peer_id, is_admin, player_name)
|
|
_sync_players.rpc(_players)
|
|
if game_server:
|
|
game_server.on_peer_joined(peer_id, player_name)
|
|
|
|
|
|
func _on_connected_to_server() -> void:
|
|
# Identify ourselves before the server will assign us a peer id or state.
|
|
_rpc_login.rpc_id(1, GameConfig.player_name, GameConfig.player_password)
|
|
|
|
|
|
func _on_connection_failed() -> void:
|
|
session_failed.emit("Could not connect to the game server.")
|
|
|
|
|
|
func _on_server_disconnected() -> void:
|
|
_is_admin = false
|
|
if not _login_failure.is_empty():
|
|
var reason := _login_failure
|
|
_login_failure = ""
|
|
session_failed.emit(reason)
|
|
return
|
|
server_disconnected.emit()
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _assigned(player_id: int, is_admin: bool, player_name: String) -> void:
|
|
_local_player_id = player_id
|
|
_is_admin = is_admin
|
|
GameConfig.player_name = player_name
|
|
session_started.emit()
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _sync_players(players: Dictionary) -> void:
|
|
_players = players
|
|
players_updated.emit(players)
|
|
|
|
|
|
## First message a client sends. The server validates or creates the password
|
|
## for that name and either admits the peer or turns it away.
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _rpc_login(player_name: String, password: String) -> void:
|
|
if not _is_server or game_server == null:
|
|
return
|
|
var peer_id := multiplayer.get_remote_sender_id()
|
|
var result := game_server.authenticate(peer_id, player_name, password)
|
|
if not bool(result.get("ok", false)):
|
|
var reason := str(result.get("reason", "Login failed."))
|
|
_login_rejected.rpc_id(peer_id, reason)
|
|
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
|
|
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).disconnect_peer(peer_id)
|
|
return
|
|
_accept_login(peer_id, str(result["name"]), bool(result["admin"]))
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _login_rejected(reason: String) -> void:
|
|
_login_failure = reason
|
|
if multiplayer.multiplayer_peer is WebSocketMultiplayerPeer:
|
|
(multiplayer.multiplayer_peer as WebSocketMultiplayerPeer).close()
|
|
# Either this arrives before the disconnect and reports the reason here, or
|
|
# _on_server_disconnected has already consumed it. Never report twice.
|
|
if _login_failure == reason:
|
|
_login_failure = ""
|
|
session_failed.emit(reason)
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _rpc_configure_game(setup: Dictionary) -> void:
|
|
if not _is_server or game_server == null:
|
|
return
|
|
if multiplayer.get_remote_sender_id() != _admin_peer_id:
|
|
return
|
|
game_server.configure_game(setup)
|
|
_rpc_game_configured.rpc()
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _rpc_game_configured() -> void:
|
|
game_configured.emit()
|
|
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _rpc_submit_order(order: Dictionary) -> void:
|
|
if not _is_server or game_server == null:
|
|
return
|
|
game_server.handle_order(multiplayer.get_remote_sender_id(), order)
|
|
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _rpc_game_state(state: Dictionary) -> void:
|
|
game_state_received.emit(state)
|
|
|
|
|
|
func _find_free_port() -> int:
|
|
var tcp := TCPServer.new()
|
|
if tcp.listen(0, "127.0.0.1") != OK:
|
|
return DEFAULT_PORT
|
|
var port := tcp.get_local_port()
|
|
tcp.stop()
|
|
return port
|
|
|
|
|
|
func _spawn_server(port: int) -> int:
|
|
var args := PackedStringArray(["--headless"])
|
|
if OS.has_feature("editor"):
|
|
args.append_array(["--path", ProjectSettings.globalize_path("res://"), SERVER_SCENE])
|
|
args.append_array(["--", "--server", "--port", str(port)])
|
|
_forward_profiling_args(args)
|
|
return OS.create_process(OS.get_executable_path(), args)
|
|
|
|
|
|
## Passes a profiling interval chosen on the client command line through to the
|
|
## spawned server, so one launch profiles both sides of the connection.
|
|
func _forward_profiling_args(args: PackedStringArray) -> void:
|
|
var own := OS.get_cmdline_user_args()
|
|
var index := own.find("--profile-interval")
|
|
if index >= 0 and index + 1 < own.size():
|
|
args.append_array(["--profile-interval", own[index + 1]])
|
|
|
|
|
|
func _write_ready_file(port: int) -> void:
|
|
var file := FileAccess.open(READY_FILE % port, FileAccess.WRITE)
|
|
if file:
|
|
file.store_string(str(port))
|
|
|
|
|
|
func _int_arg(args: PackedStringArray, name: String, fallback: int) -> int:
|
|
var index := args.find(name)
|
|
if index >= 0 and index + 1 < args.size():
|
|
return int(args[index + 1])
|
|
return fallback
|
|
|
|
|
|
func _string_arg(args: PackedStringArray, name: String, fallback: String) -> String:
|
|
var index := args.find(name)
|
|
if index >= 0 and index + 1 < args.size():
|
|
return args[index + 1]
|
|
return fallback
|