Files
CosmicClash/Game/scripts/game_mode.gd
T
Josh Creek 08a0f74391 refactor(*): DRY up arena scenes, game modes, and HUD instruments
Arenas inherit from a new arena_base.tscn instead of restating ~40 shared
lines each; only sky/ambient/glow/tint/decoration vary, exposed via new
Arena exports since nested Environment properties aren't overridable
through scene inheritance. Bot construction and score-keeping move onto
GameMode, shared by match and spectate modes while preserving their
differing GameSettings-override behavior and the HUD's score-row
duck-typing. HUD instruments share a HudInstrument base for the
smoothing-weight calc and angle-lerp helper. Also dedupes
MAIN_MENU_SCENE_PATH into ScenePaths and documents why DIFFICULTIES
tiers share one checkpoint.
2026-08-04 15:07:33 +01:00

172 lines
6.2 KiB
GDScript

class_name GameMode
extends Node3D
# Base for game modes (Free Play, Match; later Vs-AI and multiplayer).
# A mode's scene contains an Arena (the stadium) and a HUD; the mode itself
# spawns the ball, ships, controllers, and camera in code — variable ship
# counts with mixed controller types (player/AI/network) is exactly what
# future modes need. Subclasses override _start() and _on_goal_scored().
@export var ship_scene: PackedScene = preload("res://objects/ship.tscn")
@export var ball_scene: PackedScene = preload("res://objects/ball.tscn")
const CAMERA_RIG_SCENE = preload("res://scenes/ship_camera_rig.tscn")
var arena: Arena
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
# on has_signal("score_changed") to decide whether to show a score row — so
# that signal stays declared per-subclass, not here.
var score := {0: 0, 1: 0}
func _ready():
# Group lets the HUD discover the game mode for timer/score signals
add_to_group("game")
for child in get_children():
if child is Arena:
arena = child
break
if not arena:
var path := _get_arena_scene_path()
if not path.is_empty():
arena = (load(path) as PackedScene).instantiate()
add_child(arena)
if not arena:
push_error("GameMode: scene has no Arena child")
return
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
_start()
# Virtual: subclasses whose scene has no fixed Arena child override this to
# pick which arena scene to instantiate in code (see ArenaRegistry). Modes
# that keep a fixed Arena child (training.tscn) never call this.
func _get_arena_scene_path() -> String:
return ""
# Virtual: subclasses spawn their ball/ships/camera here.
func _start() -> void:
pass
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
func _on_goal_scored(_conceding_team: int) -> void:
pass
# Debounce: a fast ball can re-trigger the goal area before the deferred
# reset teleports it away, which would double-count the goal.
var _goal_cooldown := false
func _handle_goal_scored(conceding_team: int) -> void:
if _goal_cooldown:
return
_goal_cooldown = true
get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false)
_on_goal_scored(conceding_team)
func spawn_ball() -> RigidBody3D:
ball = ball_scene.instantiate()
add_child(ball)
ball.global_transform = arena.get_ball_spawn()
return ball
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
var ship: Ship = ship_scene.instantiate()
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
add_child(ship)
var spawns := arena.get_ship_spawns(team)
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
ship.global_transform = spawn_transform
ship.team = team
if controller:
ship.set_controller(controller)
ships.append(ship)
_ship_spawn_transforms[ship] = spawn_transform
return ship
func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
rig.target = target
return rig
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
# apply their own GameSettings-override logic first, which differs between
# modes (Match lets GameSettings override all three fields, Spectate only
# the path) — builds a trained-policy controller, or an inert placeholder if
# the path is empty or missing.
func _build_opponent(model_path: String, reaction_ticks: int, action_noise: float, label: String = "GameMode") -> ShipController:
if not model_path.is_empty() and FileAccess.file_exists(model_path):
var bot := AIShipController.new()
bot.model_path = model_path
bot.reaction_ticks = reaction_ticks
bot.action_noise = action_noise
return bot
if not model_path.is_empty():
push_warning("%s: bot model not found at %s, spawning inert opponent" % [label, model_path])
return ShipController.new() # inert placeholder
func _record_goal(scoring_team: int) -> void:
score[scoring_team] += 1
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
# Tiny per-reset randomization, well below anything a player would notice as
# "not a real kickoff" — just enough that two ships running the identical
# deterministic AI policy (action_noise = 0) don't start every kickoff from a
# bit-for-bit mirror-symmetric state. A perfectly symmetric state feeds both
# controllers identical (canonicalized) observations, so they emit mirrored
# actions and can lock into a repetitive, non-scoring stalemate — much more
# visible bot-vs-bot (same model both sides) than bot-vs-human, since a human
# never satisfies "identical policy" in the first place. See training_mode.gd's
# _end_eval_episode, which randomizes eval episode states for the same reason.
const KICKOFF_POSITION_JITTER := 0.3
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
func reset_ball() -> void:
if is_instance_valid(ball):
_reset_body(ball, _jittered(arena.get_ball_spawn(), KICKOFF_POSITION_JITTER, 0.0))
func reset_ships() -> void:
for ship in ships:
if is_instance_valid(ship):
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
var basis := to.basis
if yaw_jitter > 0.0:
basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter))
return Transform3D(basis, to.origin + offset)
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
# Deferred: a RigidBody3D transform can't be set mid-physics-step
body.set_deferred("global_transform", to)
body.set_deferred("linear_velocity", Vector3.ZERO)
body.set_deferred("angular_velocity", Vector3.ZERO)
# A kickoff reset is a teleport: without this, physics interpolation
# smears the body across the arena for a frame
body.call_deferred("reset_physics_interpolation")
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)