mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): add Steam transport foundation
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
class_name EnetTransport
|
||||
extends NetTransport
|
||||
|
||||
func transport_id() -> String:
|
||||
return "enet"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func create_server(port: int, max_clients: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, port: int) -> Dictionary:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
@@ -0,0 +1,26 @@
|
||||
class_name NetTransport
|
||||
extends RefCounted
|
||||
|
||||
# Narrow construction boundary for Godot's MultiplayerPeer implementations.
|
||||
# NetworkManager owns polling, RPC policy, and lifecycle; a transport only
|
||||
# creates a peer. Keeping that split means ENet remains a first-class path
|
||||
# while Steam can use SDR without duplicating the rest of the networking code.
|
||||
|
||||
func transport_id() -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return false
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return "transport is unavailable"
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
|
||||
|
||||
func create_client(_address: String, _port: int) -> Dictionary:
|
||||
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Node
|
||||
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
|
||||
# transport: hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
|
||||
# hosting, joining, shutdown, and connection-state signals. Lives
|
||||
# at a fixed autoload path so RPC NodePaths never depend on which scene is
|
||||
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
|
||||
#
|
||||
@@ -52,6 +52,11 @@ signal shutting_down()
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_CLIENTS := 32
|
||||
const TRANSPORT_ENET := "enet"
|
||||
const TRANSPORT_STEAM := "steam"
|
||||
|
||||
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
|
||||
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
|
||||
|
||||
# Clock (task 1.8, §4.7): client pings the server once a second on the
|
||||
# reliable control channel; clock_offset_ms is the min-RTT sample in a
|
||||
@@ -64,7 +69,8 @@ const CLOCK_WINDOW_SEC := 5.0
|
||||
|
||||
var is_server := false
|
||||
var is_client := false
|
||||
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
|
||||
var active_transport := ""
|
||||
|
||||
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
|
||||
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
|
||||
@@ -121,31 +127,46 @@ func poll() -> void:
|
||||
multiplayer.poll()
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
|
||||
func available_transports() -> PackedStringArray:
|
||||
var transports := PackedStringArray([TRANSPORT_ENET])
|
||||
if SteamTransportScript.new().is_available():
|
||||
transports.append(TRANSPORT_STEAM)
|
||||
return transports
|
||||
|
||||
|
||||
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_server(port, max_clients)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
|
||||
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = true
|
||||
is_client = false
|
||||
return OK
|
||||
|
||||
|
||||
func join(address: String, port: int = DEFAULT_PORT) -> Error:
|
||||
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
|
||||
shutdown()
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
var implementation := _make_transport(transport)
|
||||
if implementation == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var result: Dictionary = implementation.create_client(address, port)
|
||||
var err := int(result.error)
|
||||
if err != OK:
|
||||
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
|
||||
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
|
||||
return err
|
||||
_peer = peer
|
||||
multiplayer.multiplayer_peer = peer
|
||||
_peer = result.peer as MultiplayerPeer
|
||||
multiplayer.multiplayer_peer = _peer
|
||||
multiplayer.server_relay = false
|
||||
active_transport = transport
|
||||
is_server = false
|
||||
is_client = true
|
||||
return OK
|
||||
@@ -164,6 +185,7 @@ func shutdown() -> void:
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||
_peer = null
|
||||
active_transport = ""
|
||||
is_server = false
|
||||
is_client = false
|
||||
rtt_ms = -1.0
|
||||
@@ -174,6 +196,17 @@ func shutdown() -> void:
|
||||
_last_raw_rtt_ms = -1.0
|
||||
|
||||
|
||||
func _make_transport(transport: String) -> NetTransport:
|
||||
match transport:
|
||||
TRANSPORT_ENET:
|
||||
return EnetTransportScript.new()
|
||||
TRANSPORT_STEAM:
|
||||
return SteamTransportScript.new()
|
||||
_:
|
||||
push_error("NetworkManager: unknown transport '%s'" % transport)
|
||||
return null
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _ping(client_send_ms: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
class_name SteamBootstrap
|
||||
extends RefCounted
|
||||
|
||||
# Spacewar is Valve's development App ID. It is intentionally a development
|
||||
# default, never a public-server identity or discovery configuration.
|
||||
const SPACEWAR_APP_ID := 480
|
||||
const APP_ID_ENV := "COSMIC_CLASH_STEAM_APP_ID"
|
||||
|
||||
|
||||
static func app_id() -> int:
|
||||
var configured := OS.get_environment(APP_ID_ENV).strip_edges()
|
||||
if configured.is_valid_int() and int(configured) > 0:
|
||||
return int(configured)
|
||||
return SPACEWAR_APP_ID
|
||||
|
||||
|
||||
static func is_runtime_available() -> bool:
|
||||
return OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer") and Engine.has_singleton("Steam")
|
||||
|
||||
|
||||
static func unavailable_reason() -> String:
|
||||
if not OS.has_feature("steam"):
|
||||
return "this export was not built with the steam feature"
|
||||
if not ClassDB.class_exists("SteamMultiplayerPeer"):
|
||||
return "SteamMultiplayerPeer is missing from this custom Godot build"
|
||||
if not Engine.has_singleton("Steam"):
|
||||
return "the GodotSteam Steam singleton is missing from this custom Godot build"
|
||||
return "Steam is unavailable"
|
||||
|
||||
|
||||
static func initialize() -> Dictionary:
|
||||
if not is_runtime_available():
|
||||
return {"error": ERR_UNAVAILABLE, "reason": unavailable_reason()}
|
||||
var steam := Engine.get_singleton("Steam")
|
||||
# `steamInit` is deliberately called dynamically: stock Godot must be able
|
||||
# to parse and run this project without GodotSteam symbols installed.
|
||||
var result = steam.call("steamInit")
|
||||
if result is bool and result:
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
if result is Dictionary and bool(result.get("status", false)):
|
||||
return {"error": OK, "app_id": app_id()}
|
||||
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name SteamTransport
|
||||
extends NetTransport
|
||||
|
||||
const SteamBootstrapScript = preload("res://scripts/steam_bootstrap.gd")
|
||||
|
||||
# The SteamMultiplayerPeer extension is looked up dynamically so a stock ENet
|
||||
# build never references an unavailable native class while parsing scripts.
|
||||
const VIRTUAL_PORT := 0
|
||||
|
||||
func transport_id() -> String:
|
||||
return "steam"
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
return SteamBootstrapScript.is_runtime_available()
|
||||
|
||||
|
||||
func unavailable_reason() -> String:
|
||||
return SteamBootstrapScript.unavailable_reason()
|
||||
|
||||
|
||||
func create_server(_port: int, _max_clients: int) -> Dictionary:
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_host", VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
|
||||
|
||||
func create_client(address: String, _port: int) -> Dictionary:
|
||||
var steam_id_text := address.strip_edges()
|
||||
if not steam_id_text.is_valid_int() or int(steam_id_text) <= 0:
|
||||
return {"error": ERR_INVALID_PARAMETER, "reason": "Steam transport requires the server's numeric Steam ID"}
|
||||
var boot: Dictionary = SteamBootstrapScript.initialize()
|
||||
if int(boot.error) != OK:
|
||||
return boot
|
||||
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
|
||||
if peer == null:
|
||||
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
|
||||
var err := int(peer.call("create_client", int(steam_id_text), VIRTUAL_PORT))
|
||||
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
|
||||
Reference in New Issue
Block a user