Files
CosmicClash/Game/scripts/match_mode.gd
T
2026-08-07 22:45:33 +01:00

172 lines
6.2 KiB
GDScript

extends GameMode
# Timed match: two teams, score tracking, kickoff resets after each goal.
# The opponent is a trained AI bot when a policy model is configured
# (see TRAINING.md for training and promoting models into res://bots/),
# otherwise an inert placeholder ship.
signal timer_updated(minutes: int, seconds: int)
signal score_changed(score: Dictionary)
# winning_team is -1 for a draw. Never -1 when reached via overtime.
signal match_ended(winning_team: int, score: Dictionary)
# Counts 3, 2, 1, then 0 ("go", hide the label).
signal kickoff_countdown(count: int)
# Full time ended level: sudden-death golden goal, no clock.
signal overtime_started
# How long the result overlay stays up before returning to the menu.
const RESULT_SCREEN_SECONDS := 5.0
const KICKOFF_COUNTDOWN_SECONDS := 3
@export var match_length_seconds := 150.0
# Ships per team. The player always controls one ship on team 0; every other
# ship (teammates and the whole opposing team) is AI-controlled — no local
# multiplayer input. Plumbing only for this pass: there's no menu UI yet to
# pick a team size above 1.
@export_range(1, 5) var team_size: int = 1
@export_group("AI opponent")
# Trained policy for the opponent; empty = inert placeholder ship.
@export_file("*.json") var bot_model_path: String = ""
# Difficulty handicaps, applied on top of the model (see AIShipController).
@export_range(1, 60) var bot_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
var match_timer: Timer
var _in_overtime := false
var _match_over := false
var _last_timer_remaining := -1
var _pending_scoring_team := -1
var _match_time_after_goal := -1.0
func _get_arena_scene_path() -> String:
return ArenaRegistry.random_path()
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship)
for i in range(1, team_size):
spawn_ship(0, i, _make_opponent_controller())
for i in team_size:
spawn_ship(1, i, _make_opponent_controller())
await _run_kickoff_countdown()
match_timer = Timer.new()
match_timer.one_shot = true
match_timer.wait_time = match_length_seconds
match_timer.timeout.connect(_on_match_timer_timeout)
add_child(match_timer)
match_timer.start()
func _make_opponent_controller() -> ShipController:
# The main menu's dropdown selection (GameSettings autoload) wins over the
# scene's export, which stays as the fallback for direct-scene runs.
var path := bot_model_path if GameSettings.selected_bot_path.is_empty() else GameSettings.selected_bot_path
var reaction_ticks := bot_reaction_ticks if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
var action_noise := bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_bot_action_noise
return _build_opponent(path, reaction_ticks, action_noise, "MatchMode")
func _process(_delta):
if match_timer and match_timer.time_left > 0:
var remaining := ceili(match_timer.time_left)
if remaining != _last_timer_remaining:
_last_timer_remaining = remaining
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
func _on_goal_registered(conceding_team: int) -> void:
if _match_over:
return
_pending_scoring_team = 1 - conceding_team
_record_goal(_pending_scoring_team)
score_changed.emit(score.duplicate())
# Commit the score and freeze regulation time at sensor entry. The clock
# resumes after the cinematic, so a last-second goal cannot be discarded or
# reclassified as overtime while presentation is still running.
if match_timer and match_timer.time_left > 0.0:
_match_time_after_goal = match_timer.time_left
match_timer.stop()
func _on_goal_scored(_conceding_team: int) -> void:
if _match_over or _pending_scoring_team < 0:
return
var scoring_team := _pending_scoring_team
_pending_scoring_team = -1
if _in_overtime:
# Golden goal: the first score after a draw ends the match outright.
_end_match(scoring_team)
return
if match_timer and _match_time_after_goal > 0.0:
match_timer.start(_match_time_after_goal)
_match_time_after_goal = -1.0
await _run_kickoff_countdown()
# Used both for the initial kickoff and after every goal. Freezes ball/ships
# via RigidBody3D.freeze — a built-in engine property — so this needs zero
# changes to ship.gd/ball.gd/ship_controller.gd, keeping training_mode.gd
# (which never calls into this file) completely untouched.
func _run_kickoff_countdown() -> void:
if _match_over:
return
reset_ball()
reset_ships()
_set_frozen(true)
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
kickoff_countdown.emit(count)
# process_always=false: if full-time fires mid-countdown (see
# _on_match_timer_timeout's get_tree().paused = true), this stalls
# harmlessly in lockstep with the pause instead of ticking a
# countdown label over the results screen. _end_match unpauses the
# tree before the scene teardown actually lands, so the _match_over
# check below is what stops this from resuming into a dying scene.
await get_tree().create_timer(1.0, false).timeout
if _match_over:
return
kickoff_countdown.emit(0)
_set_frozen(false)
func _set_frozen(frozen: bool) -> void:
if is_instance_valid(ball):
ball.set_deferred("freeze", frozen)
for ship in ships:
if is_instance_valid(ship):
ship.set_deferred("freeze", frozen)
func _on_match_timer_timeout() -> void:
print("Full time! Score: %d - %d" % [score[0], score[1]])
if score[0] == score[1]:
await _start_overtime()
return
_end_match(0 if score[0] > score[1] else 1)
# Full time ended level: sudden-death golden goal, no clock running.
# _on_goal_scored checks _in_overtime and ends the match on the next goal.
func _start_overtime() -> void:
_in_overtime = true
overtime_started.emit()
await _run_kickoff_countdown()
func _end_match(winning_team: int) -> void:
_match_over = true
print("Match over! Final score: %d - %d" % [score[0], score[1]])
match_ended.emit(winning_team, score.duplicate())
# Freeze gameplay while the HUD (process_mode ALWAYS) shows the result.
get_tree().paused = true
await get_tree().create_timer(RESULT_SCREEN_SECONDS).timeout
# Unpause before leaving, or the menu arrives paused and unresponsive.
get_tree().paused = false
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)