mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4fb7ddfecf
multiplayer-todo.md and multiplayer-next.md tracked overlapping information in two places. Fold everything into multiplayer-next.md (architecture decisions, wire format, task breakdown with checkboxes, gotchas list, testing notes) and delete multiplayer-todo.md. Section numbers are unchanged, so existing code comments citing them by section/task number still resolve; update every such reference to point at the new filename.
123 lines
4.9 KiB
GDScript
123 lines
4.9 KiB
GDScript
class_name ServerMatchLoop
|
|
extends Node
|
|
|
|
# The dedicated server's match loop (multiplayer-next.md task 6.5).
|
|
#
|
|
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
|
|
# a full match headless", but nothing in the product ever started a match:
|
|
# lobby.gd has no start path, and every match in this project's history was
|
|
# begun by a test harness calling change_scene_to_file directly. The dedicated
|
|
# server booted, listened, and could never play anything. 6.5 was written as
|
|
# "arena rotation between matches", which presumes a first match that nothing
|
|
# produced — so the whole loop lives here, not just the rotation.
|
|
#
|
|
# Lifecycle:
|
|
#
|
|
# wait for --min-players (roster, not raw peers: a peer that has
|
|
# connected but not completed the hello
|
|
# handshake is not a player yet)
|
|
# -> --start-countdown seconds (so a second player joining 200ms later
|
|
# is in THIS match, not the next one)
|
|
# -> networked_match.tscn on the arena --arena-rotation picked
|
|
# -> the match runs itself and returns to the lobby at RESULTS
|
|
# -> repeat, or exit(0) once --max-matches have completed
|
|
#
|
|
# Parented to the scene tree ROOT, never to current_scene: change_scene_to_file
|
|
# frees whatever scene is live, and an orchestrator that gets freed by the
|
|
# transition it just requested cannot orchestrate the next one. This is the
|
|
# same constraint tests/networked_match_test_hooks.gd documents, arrived at the
|
|
# same way — it is a property of Godot's scene switching, not of testing.
|
|
#
|
|
# The countdown is deliberately NOT a Timer: §6.1's tick-derived-clock rule
|
|
# applies to anything whose timing a client can observe, and the wait before a
|
|
# match is exactly that.
|
|
|
|
signal match_starting(arena_path: String, match_index: int)
|
|
|
|
const POLL_INTERVAL_MS := 250
|
|
|
|
var min_players := 1
|
|
var start_countdown_seconds := 5.0
|
|
var max_matches := 0 # 0 = run forever
|
|
var rotation_mode := "sequential"
|
|
|
|
var matches_completed := 0
|
|
var _countdown_started_ms := -1
|
|
var _match_active := false
|
|
var _next_poll_ms := 0
|
|
var _shutting_down := false
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _shutting_down or not multiplayer.is_server():
|
|
return
|
|
var now := Time.get_ticks_msec()
|
|
if now < _next_poll_ms:
|
|
return
|
|
_next_poll_ms = now + POLL_INTERVAL_MS
|
|
if _match_active:
|
|
_poll_match_end()
|
|
else:
|
|
_poll_match_start(now)
|
|
|
|
|
|
# A match is over when the match scene is gone. NetworkedMatch returns both
|
|
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
|
|
# when everyone has left (§6.4), so "the scene we started is no longer the
|
|
# current scene" covers the clean end and the abandoned one identically —
|
|
# without this node having to duplicate either rule or reach into match state.
|
|
func _poll_match_end() -> void:
|
|
var scene := get_tree().current_scene
|
|
if is_instance_valid(scene) and scene.is_in_group("game"):
|
|
return
|
|
_match_active = false
|
|
matches_completed += 1
|
|
ServerLog.info("match_completed", {
|
|
"completed": matches_completed, "of": max_matches if max_matches > 0 else "unlimited",
|
|
})
|
|
if max_matches > 0 and matches_completed >= max_matches:
|
|
# §6's drain-and-exit: the point of --max-matches is that a supervisor
|
|
# can restart the process on a new build between matches instead of
|
|
# killing players mid-game. Exiting anywhere else would defeat it.
|
|
_shutting_down = true
|
|
ServerLog.info("server_draining", {"reason": "max_matches_reached", "matches": matches_completed})
|
|
get_tree().quit(0)
|
|
return
|
|
# Straight back to waiting. The countdown restarts from scratch rather than
|
|
# carrying over, so players who left during the last match are not counted
|
|
# toward starting the next one.
|
|
_countdown_started_ms = -1
|
|
|
|
|
|
func _poll_match_start(now: int) -> void:
|
|
var players := MatchNet.roster.size()
|
|
if players < min_players:
|
|
if _countdown_started_ms >= 0:
|
|
ServerLog.info("match_start_cancelled", {"players": players, "needed": min_players})
|
|
_countdown_started_ms = -1
|
|
return
|
|
if _countdown_started_ms < 0:
|
|
_countdown_started_ms = now
|
|
ServerLog.info("match_start_countdown", {
|
|
"players": players, "seconds": start_countdown_seconds,
|
|
})
|
|
return
|
|
if now - _countdown_started_ms < int(start_countdown_seconds * 1000.0):
|
|
return
|
|
_start_match()
|
|
|
|
|
|
func _start_match() -> void:
|
|
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
|
|
# The match scene picks its own arena at random by default. Handing it one
|
|
# explicitly is what makes rotation a rotation rather than a coincidence.
|
|
NetworkedMatch.server_arena_override = arena_path
|
|
_match_active = true
|
|
_countdown_started_ms = -1
|
|
ServerLog.info("match_starting", {
|
|
"index": matches_completed + 1, "arena": arena_path,
|
|
"players": MatchNet.roster.size(), "rotation": rotation_mode,
|
|
})
|
|
match_starting.emit(arena_path, matches_completed)
|
|
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
|