Files
CosmicClash/Game/scripts/matchmaking.gd
T

221 lines
8.8 KiB
GDScript

extends Control
const CLIENT_BUILD := "dev"
const PROTOCOL_VERSION := 1
const HEARTBEAT_SECONDS := 10.0
const RECOVERY_POLL_SECONDS := 2.0
@onready var playlist_dropdown: OptionButton = %PlaylistDropdown
@onready var status_label: Label = %StatusLabel
@onready var detail_label: Label = %DetailLabel
@onready var ranked_profile_label: Label = %RankedProfileLabel
@onready var queue_button: Button = %QueueButton
@onready var cancel_button: Button = %CancelButton
@onready var accept_button: Button = %AcceptButton
@onready var decline_button: Button = %DeclineButton
@onready var back_button: Button = %BackButton
var _elapsed_seconds := 0.0
var _heartbeat_seconds := 0.0
var _recovery_poll_seconds := 0.0
func _ready() -> void:
playlist_dropdown.add_item("Casual")
playlist_dropdown.set_item_metadata(0, "casual")
playlist_dropdown.add_item("Ranked")
playlist_dropdown.set_item_metadata(1, "ranked")
playlist_dropdown.item_selected.connect(_on_playlist_selected)
ControlPlaneClient.state.changed.connect(_on_state_changed)
ControlPlaneClient.request_failed.connect(_on_request_failed)
ControlPlaneClient.request_succeeded.connect(_on_request_succeeded)
ControlPlaneClient.session_expired.connect(_on_session_expired)
_refresh_ranked_profile()
_render(ControlPlaneClient.state.snapshot())
func _process(delta: float) -> void:
if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]:
_elapsed_seconds += delta
_heartbeat_seconds += delta
_recovery_poll_seconds += delta
if _recovery_poll_seconds >= RECOVERY_POLL_SECONDS:
_recovery_poll_seconds = 0.0
var recovery_err := ControlPlaneClient.recover_proposal(ControlPlaneClient.state.proposal_id) if ControlPlaneClient.state.has_open_proposal() else ControlPlaneClient.recover_queue(ControlPlaneClient.state.ticket_id)
if recovery_err != OK and recovery_err != ERR_BUSY:
_on_local_error("State recovery unavailable: %s" % error_string(recovery_err))
if ControlPlaneClient.state.phase == MatchmakingState.QUEUED and _heartbeat_seconds >= HEARTBEAT_SECONDS:
_heartbeat_seconds = 0.0
var err := ControlPlaneClient.heartbeat(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Heartbeat unavailable: %s" % error_string(err))
_render(ControlPlaneClient.state.snapshot())
func _on_queue_pressed() -> void:
if ControlPlaneClient.can_retry_queue_create():
var retry_err := ControlPlaneClient.retry_queue_create()
if retry_err != OK:
_on_local_error("Could not retry matchmaking: %s" % error_string(retry_err))
return
if ControlPlaneClient.can_retry_last_mutation():
var mutation_err := ControlPlaneClient.retry_last_mutation()
if mutation_err != OK:
_on_local_error("Could not retry matchmaking action: %s" % error_string(mutation_err))
return
if not _can_start_new_search(ControlPlaneClient.state.phase):
return
_elapsed_seconds = 0.0
_heartbeat_seconds = 0.0
_recovery_poll_seconds = 0.0
var playlist := String(playlist_dropdown.get_selected_metadata())
var ticket_id := "ticket-%s-%s" % [str(Time.get_ticks_usec()), str(randi())]
var err := ControlPlaneClient.queue_create(ticket_id, playlist, CLIENT_BUILD, PROTOCOL_VERSION)
if err != OK:
_on_local_error("Could not start matchmaking: %s" % error_string(err))
func _on_cancel_pressed() -> void:
if not ControlPlaneClient.state.can_cancel():
return
var err := ControlPlaneClient.cancel_queue(ControlPlaneClient.state.ticket_id, ControlPlaneClient.state.revision)
if err != OK:
_on_local_error("Could not cancel matchmaking: %s" % error_string(err))
func _on_accept_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, true, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not accept proposal: %s" % error_string(err))
func _on_decline_pressed() -> void:
var err := ControlPlaneClient.respond_to_proposal(ControlPlaneClient.state.proposal_id, false, ControlPlaneClient.state.proposal_revision)
if err != OK:
_on_local_error("Could not decline proposal: %s" % error_string(err))
func _on_playlist_selected(_index: int) -> void:
_refresh_ranked_profile()
func _refresh_ranked_profile() -> void:
var ranked := String(playlist_dropdown.get_selected_metadata()) == "ranked"
ranked_profile_label.visible = ranked
if not ranked:
return
var err := ControlPlaneClient.fetch_ranked_profile()
if err != OK and err != ERR_BUSY:
ranked_profile_label.text = "Ranked profile unavailable: %s" % error_string(err)
func _on_back_pressed() -> void:
if ControlPlaneClient.state.can_cancel():
status_label.text = "Cancel the active search before leaving"
return
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_state_changed(snapshot: Dictionary) -> void:
_render(snapshot)
func _on_request_succeeded(_operation: String, _payload: Dictionary) -> void:
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_request_failed(_operation: String, _http_code: int, detail: String) -> void:
detail_label.text = detail
ranked_profile_label.text = ControlPlaneClient.ranked_profile.display_text()
_render(ControlPlaneClient.state.snapshot())
func _on_session_expired() -> void:
status_label.text = "Session expired"
detail_label.text = "Sign in again before searching for a match"
queue_button.disabled = true
func _on_local_error(detail: String) -> void:
detail_label.text = detail
static func phase_label(phase: String) -> String:
match phase:
MatchmakingState.IDLE:
return "Ready to search"
MatchmakingState.QUEUED:
return "Searching for players"
MatchmakingState.PROPOSED:
return "Match found — confirm"
MatchmakingState.ACCEPTED:
return "Match accepted — preparing server"
MatchmakingState.ALLOCATING:
return "Preparing match server"
MatchmakingState.PROCESS_READY:
return "Match server started"
MatchmakingState.ASSIGNMENT_READY:
return "Match assigned"
MatchmakingState.CONNECTING:
return "Connecting to match"
MatchmakingState.LIVE:
return "Match in progress"
MatchmakingState.RESULT_PENDING:
return "Recording match result"
MatchmakingState.COMPLETED:
return "Match complete"
MatchmakingState.ASSIGNED:
return "Match assigned"
MatchmakingState.CANCELLED:
return "Search cancelled"
MatchmakingState.EXPIRED:
return "Search expired"
MatchmakingState.FAILED:
return "Matchmaking unavailable"
_:
return "Recovering matchmaking state"
func _render(snapshot: Dictionary) -> void:
var phase := String(snapshot.get("phase", MatchmakingState.IDLE))
status_label.text = phase_label(phase)
if String(snapshot.get("message", "")) != "":
detail_label.text = String(snapshot["message"])
elif phase == MatchmakingState.QUEUED:
var waited := _elapsed_seconds
if int(snapshot.get("enqueued_at_unix", 0)) > 0:
waited = float(ControlPlaneClient.state.waited_seconds(int(Time.get_unix_time_from_system())))
detail_label.text = "Waiting %.0fs · revision %d" % [waited, int(snapshot.get("revision", 0))]
elif phase == MatchmakingState.PROPOSED:
detail_label.text = proposal_countdown_text(int(snapshot.get("expires_at_unix", 0)), int(Time.get_unix_time_from_system()))
elif phase == MatchmakingState.ACCEPTED:
detail_label.text = "All players accepted; preparing the match server"
elif phase == MatchmakingState.RESULT_PENDING:
detail_label.text = "The server is confirming the final result"
elif phase == MatchmakingState.COMPLETED:
detail_label.text = "The match result has been recorded"
elif phase == MatchmakingState.IDLE:
detail_label.text = "Choose a playlist to begin"
cancel_button.visible = ControlPlaneClient.state.can_cancel()
accept_button.visible = phase == MatchmakingState.PROPOSED
decline_button.visible = phase == MatchmakingState.PROPOSED
var retry_search := ControlPlaneClient.can_retry_queue_create()
var retry_mutation := ControlPlaneClient.can_retry_last_mutation()
queue_button.disabled = ControlPlaneClient.auth_expired or not (_can_start_new_search(phase) or retry_search or retry_mutation)
queue_button.text = "Retry Search" if retry_search else ("Retry Request" if retry_mutation else "Search")
static func _is_terminal(phase: String) -> bool:
return phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.LIVE, MatchmakingState.COMPLETED]
static func proposal_countdown_text(expires_at_unix: int, now_unix: int) -> String:
if expires_at_unix <= 0:
return "Review the proposal before the countdown expires"
return "Review proposal · %ds remaining" % maxi(0, expires_at_unix - now_unix)
static func _can_start_new_search(phase: String) -> bool:
return phase == MatchmakingState.IDLE or phase in [MatchmakingState.CANCELLED, MatchmakingState.EXPIRED, MatchmakingState.FAILED, MatchmakingState.COMPLETED]