mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-13 12:32:14 +00:00
feat(multiplayer): add Steam transport foundation
This commit is contained in:
@@ -55,3 +55,61 @@ texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.2]
|
||||
|
||||
name="Linux Steam Client"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features="steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteam.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.2.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=true
|
||||
texture_format/s3tc=true
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
[preset.3]
|
||||
|
||||
name="Linux Steam Dedicated Server"
|
||||
platform="Linux"
|
||||
runnable=true
|
||||
dedicated_server=true
|
||||
custom_features="dedicated_server,steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
export_path="../steam/build/CosmicClashSteamServer.x86_64"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
script_encryption_key=""
|
||||
|
||||
[preset.3.options]
|
||||
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_script=1
|
||||
binary_format/embed_pck=true
|
||||
texture_format/bptc=false
|
||||
texture_format/s3tc=false
|
||||
texture_format/etc=false
|
||||
texture_format/etc2=false
|
||||
binary_format/architecture="x86_64"
|
||||
|
||||
@@ -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)}
|
||||
@@ -0,0 +1,26 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const EnetTransport = preload("res://scripts/enet_transport.gd")
|
||||
const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd")
|
||||
const SteamTransport = preload("res://scripts/steam_transport.gd")
|
||||
|
||||
func test_enet_is_available_without_steam() -> void:
|
||||
var transport = EnetTransport.new()
|
||||
assert_true(transport.is_available(), "ENet remains available in a stock Godot build")
|
||||
assert_eq(transport.transport_id(), "enet", "stable selection key")
|
||||
|
||||
|
||||
func test_steam_development_app_id_has_a_safe_default() -> void:
|
||||
assert_eq(SteamBootstrap.app_id(), SteamBootstrap.SPACEWAR_APP_ID, "Spacewar is the local-development default")
|
||||
assert_true(SteamBootstrap.app_id() > 0, "Steam bootstrap never uses an invalid app ID")
|
||||
|
||||
|
||||
func test_stock_build_refuses_steam_without_falling_back_to_enet() -> void:
|
||||
var transport = SteamTransport.new()
|
||||
if transport.is_available():
|
||||
assert_true(true, "a custom Steam build is validated by the separate Steam export check")
|
||||
return
|
||||
var result: Dictionary = transport.create_server(7777, 2)
|
||||
assert_eq(int(result.error), ERR_UNAVAILABLE, "Steam request fails explicitly when its custom build is absent")
|
||||
assert_true(not result.has("peer") or result.peer == null, "an unavailable Steam request never returns an ENet peer")
|
||||
assert_true(not transport.unavailable_reason().is_empty(), "failure tells an operator what is missing")
|
||||
@@ -0,0 +1,20 @@
|
||||
extends Node
|
||||
|
||||
# This is intentionally separate from the stock test suite: it is run only
|
||||
# by scripts/verify_steam_templates.sh, where failing to supply a custom Steam
|
||||
# executable is a setup failure rather than a regression in the ENet build.
|
||||
func _ready() -> void:
|
||||
var failures: Array[String] = []
|
||||
if not OS.has_feature("steam"):
|
||||
failures.append("custom export is missing the steam feature")
|
||||
if not ClassDB.class_exists("SteamMultiplayerPeer"):
|
||||
failures.append("SteamMultiplayerPeer is missing")
|
||||
if not Engine.has_singleton("Steam") and not Engine.has_singleton("SteamServer"):
|
||||
failures.append("neither Steam nor SteamServer singleton is available")
|
||||
if failures.is_empty():
|
||||
print("STEAM TEMPLATE SMOKE PASS")
|
||||
get_tree().quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
printerr("STEAM TEMPLATE SMOKE FAIL: %s" % failure)
|
||||
get_tree().quit(1)
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/steam_template_smoke.gd" id="1"]
|
||||
|
||||
[node name="SteamTemplateSmoke" type="Node"]
|
||||
script = ExtResource("1")
|
||||
Reference in New Issue
Block a user