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) AudioManager.play_countdown(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) AudioManager.play_countdown(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)