mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(multiplayer): Phase 1 transport, connection, and lobby
Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner, net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet transport, manual polling, min-RTT clock sync), MatchNet (handshake, protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team columns, switch team, ready toggle), server_boot.tscn (headless dedicated server with structured logging and an overrun watchdog), and main_menu.gd's Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path). Followed by an adversarial review (Opus subagent) that found and fixed two real bugs - an unvalidated player_name broadcast that let one client's oversized name head-of-line-block the reliable channel for everyone, and a server-side roster leak across a host/re-host cycle - plus three gaps in the test suite itself where a claim of "verified" wasn't actually backed by what the test checked. All five two-process smoke tests plus the pure-function suite are green with the strengthened assertions in place.
This commit is contained in:
@@ -31,6 +31,10 @@ const DIFFICULTIES := [
|
||||
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
|
||||
@onready var bot_a_dropdown: OptionButton = %BotADropdown
|
||||
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
|
||||
@onready var join_address_edit: LineEdit = %JoinAddressEdit
|
||||
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
|
||||
@onready var connecting_overlay: Control = %ConnectingOverlay
|
||||
@onready var connecting_status_label: Label = %ConnectingStatusLabel
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -46,9 +50,27 @@ func _ready() -> void:
|
||||
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
|
||||
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
|
||||
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
|
||||
NetworkManager.connected_to_server.connect(_on_connected_to_server)
|
||||
NetworkManager.connection_failed.connect(_on_connection_failed)
|
||||
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
|
||||
|
||||
|
||||
# main_menu.gd's first async flow (task 1.7): Host is synchronous
|
||||
# (NetworkManager.host() either succeeds immediately or fails immediately),
|
||||
# but Join is not — it can take anywhere from a clean local-network round
|
||||
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
|
||||
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
|
||||
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
|
||||
# path that returns the player to a sane, retryable menu state rather than
|
||||
# just hanging with no feedback.
|
||||
func _process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
NetworkManager.poll()
|
||||
|
||||
|
||||
func _populate_difficulty_dropdown() -> void:
|
||||
difficulty_dropdown.clear()
|
||||
for tier in DIFFICULTIES:
|
||||
@@ -158,3 +180,91 @@ func _on_spectate_pressed() -> void:
|
||||
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
|
||||
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
|
||||
_leave_to_gameplay("res://scenes/spectate.tscn")
|
||||
|
||||
|
||||
func _on_host_pressed() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var err := NetworkManager.host()
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not host: %s" % error_string(err))
|
||||
return
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_join_pressed() -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
func _on_join_address_submitted(_new_text: String) -> void:
|
||||
_start_join()
|
||||
|
||||
|
||||
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
|
||||
# anything a menu should make a player wait for — verified empirically
|
||||
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
|
||||
# refused loopback connection: connection_failed never fired within 14s.
|
||||
# This timer is what actually guarantees "connection-refused reaches a sane
|
||||
# UI state" rather than leaving the overlay up indefinitely.
|
||||
const CONNECT_TIMEOUT_SECONDS := 6.0
|
||||
|
||||
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
|
||||
|
||||
|
||||
func _start_join() -> void:
|
||||
_clear_multiplayer_error()
|
||||
var address := join_address_edit.text.strip_edges()
|
||||
if address.is_empty():
|
||||
_show_multiplayer_error("Enter an IP address to join")
|
||||
return
|
||||
var err := NetworkManager.join(address)
|
||||
if err != OK:
|
||||
_show_multiplayer_error("Could not join: %s" % error_string(err))
|
||||
return
|
||||
connecting_status_label.text = "Connecting to %s..." % address
|
||||
connecting_overlay.visible = true
|
||||
_connect_timeout_token += 1
|
||||
var my_token := _connect_timeout_token
|
||||
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
|
||||
|
||||
|
||||
func _on_connect_timeout(token: int) -> void:
|
||||
if token != _connect_timeout_token or not connecting_overlay.visible:
|
||||
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _on_connecting_cancel_pressed() -> void:
|
||||
_connect_timeout_token += 1
|
||||
NetworkManager.shutdown()
|
||||
connecting_overlay.visible = false
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return # e.g. a stray/late signal after Cancel already shut the peer down
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_leave_to_lobby()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
if not connecting_overlay.visible:
|
||||
return
|
||||
_connect_timeout_token += 1
|
||||
connecting_overlay.visible = false
|
||||
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
|
||||
|
||||
|
||||
func _leave_to_lobby() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
|
||||
|
||||
|
||||
func _show_multiplayer_error(message: String) -> void:
|
||||
multiplayer_error_label.text = message
|
||||
multiplayer_error_label.visible = true
|
||||
|
||||
|
||||
func _clear_multiplayer_error() -> void:
|
||||
multiplayer_error_label.visible = false
|
||||
|
||||
Reference in New Issue
Block a user