mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
4507b6dc1b
Replace the raw checkpoint dropdown with curated Easy/Medium/Hard presets that drive GameSettings' bot model/reaction_ticks/action_noise overrides. Move raw-checkpoint testing and Spectate mode into a dev-only section hidden via OS.is_debug_build() so they disappear from release exports.
136 lines
4.9 KiB
GDScript
136 lines
4.9 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
|
|
|
|
@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 if GameSettings.selected_bot_reaction_ticks < 0 else GameSettings.selected_bot_reaction_ticks
|
|
bot.action_noise = bot_action_noise if GameSettings.selected_bot_action_noise < 0.0 else GameSettings.selected_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)
|