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. signal match_ended(winning_team: int, score: Dictionary) # How long the result overlay stays up before returning to the menu. const RESULT_SCREEN_SECONDS := 5.0 @export var match_length_seconds := 150.0 @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 score := {0: 0, 1: 0} var match_timer: Timer func _start() -> void: spawn_ball() var player_ship := spawn_ship(0, 0, PlayerShipController.new()) spawn_camera_rig(player_ship) spawn_ship(1, 0, _make_opponent_controller()) 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 if not path.is_empty() and FileAccess.file_exists(path): var bot := AIShipController.new() bot.model_path = path bot.reaction_ticks = bot_reaction_ticks bot.action_noise = bot_action_noise return bot if not path.is_empty(): push_warning("MatchMode: bot model not found at %s, spawning inert opponent" % path) return ShipController.new() # inert placeholder func _process(_delta): if match_timer and match_timer.time_left > 0: var remaining := ceili(match_timer.time_left) timer_updated.emit(floori(remaining / 60.0), remaining % 60) func _on_goal_scored(conceding_team: int) -> void: var scoring_team := 1 - conceding_team score[scoring_team] += 1 score_changed.emit(score.duplicate()) print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]]) reset_ball() reset_ships() func _on_match_timer_timeout() -> void: print("Full time! Final score: %d - %d" % [score[0], score[1]]) var winning_team := -1 if score[0] != score[1]: winning_team = 0 if score[0] > score[1] else 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(MAIN_MENU_SCENE_PATH)