Files
CosmicClash/Game/scripts/lobby.gd
T
Josh Creek 4533da34e0 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.
2026-08-20 08:18:59 +01:00

117 lines
3.8 KiB
GDScript

extends Control
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
# after NetworkManager.host()/join() succeeds — this scene must always be
# loaded that way (as the real current_scene), not instantiated as a child
# of something else: change_scene_to_file() operates on
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
# below call it themselves, which hangs if this scene isn't actually the
# tree's current_scene when that happens (see multiplayer-todo.md §9
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
@onready var _status_label: Label = %StatusLabel
@onready var _team0_list: VBoxContainer = %Team0List
@onready var _team1_list: VBoxContainer = %Team1List
@onready var _controls_row: HBoxContainer = %ControlsRow
@onready var _switch_team_button: Button = %SwitchTeamButton
@onready var _ready_button: CheckButton = %ReadyButton
@onready var _leave_button: Button = %LeaveButton
func _ready() -> void:
MatchNet.welcomed.connect(_on_welcomed)
MatchNet.player_joined.connect(_on_roster_changed)
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
# gets a read-only view, no team/ready controls to operate on itself.
_controls_row.visible = NetworkManager.is_client
_refresh()
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_leave()
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
_refresh()
func _on_welcomed() -> void:
_refresh()
func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_disconnected_from_server() -> void:
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_switch_team_pressed() -> void:
var my_id := multiplayer.get_unique_id()
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if info == null:
return
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
func _on_ready_toggled(pressed: bool) -> void:
MatchNet.request_set_ready(pressed)
func _on_leave_pressed() -> void:
_leave()
func _leave() -> void:
NetworkManager.shutdown()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _refresh() -> void:
if NetworkManager.is_server:
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
elif NetworkManager.is_client:
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
else:
_status_label.text = "Not connected"
for child in _team0_list.get_children():
child.queue_free()
for child in _team1_list.get_children():
child.queue_free()
var my_id := multiplayer.get_unique_id()
var infos: Array = MatchNet.roster.values()
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
for info: MatchNet.PlayerInfo in infos:
var row := Label.new()
var marker := " (you)" if info.peer_id == my_id else ""
var ready_mark := "✓" if info.ready else "…"
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
var target_list := _team0_list if info.team == 0 else _team1_list
target_list.add_child(row)
if NetworkManager.is_client:
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if my_info != null:
_ready_button.set_pressed_no_signal(my_info.ready)