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 @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 var _in_overtime := false 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()) 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 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]]) if _in_overtime: # Golden goal: the first score after a draw ends the match outright. _end_match(scoring_team) return 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: 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. await get_tree().create_timer(1.0, false).timeout 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: 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(MAIN_MENU_SCENE_PATH)