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 hud: HUDController var ball: RigidBody3D var ships: Array[Ship] = [] var _ship_spawn_transforms := {} var _camera_rig: ShipCameraRig var _goal_in_progress := false const GOAL_CELEBRATION_SECONDS := 1.6 # 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") # At the default 8, a client hitching to ~20 fps runs up to 8 physics # ticks in one rendered frame — and each of those ticks costs roughly as # much as the frame that caused the hitch, so the client can spiral # further behind instead of recovering. 4 trades a lower worst-case # catch-up rate for bounded per-frame cost. Engine.max_physics_steps_per_frame = 4 # A fresh RandomNumberGenerator defaults to a fixed internal state (unlike # the global randf_range, which Godot auto-randomizes at startup), so an # explicit randomize() is required unless a seed was set for reproducible # kickoffs (see kickoff_rng_seed above). if kickoff_rng_seed == 0: _kickoff_rng.randomize() for child in get_children(): if child is Arena: arena = child elif child is HUDController: hud = child 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 if _owns_goal_logic(): 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: whether this mode decides goals from its own local Goal sensors. # True for every mode today. A future networked client mode overrides this # false — it must learn a goal happened from an authoritative server message, # not from an interpolated remote ball wandering through its local Goal # Area3D, which would score client-side against no one. func _owns_goal_logic() -> bool: return true # Virtual: whether this mode simulates and enforces its own world (escaped- # body respawn runs locally in _physics_process). True for every mode today. # A future networked client mode overrides this false — the server is # authoritative for body positions, and a client respawning a body itself # would fight that authority. func _owns_world_simulation() -> bool: return true # Virtual: how long the goal cinematic holds before resuming play. Subclasses # that want a different cadence override this instead of touching # GOAL_CELEBRATION_SECONDS directly. func _goal_pause_seconds() -> float: return GOAL_CELEBRATION_SECONDS # Virtual: the ball entered the goal owned (conceded) by `_conceding_team`. func _on_goal_scored(_conceding_team: int) -> void: pass # Virtual: immediate, non-presentational consequences at sensor time. Modes # with a score/clock override this so a last-second goal is authoritative # before the cinematic; reset/kickoff remains in _on_goal_scored afterwards. func _on_goal_registered(_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. func _handle_goal_scored(conceding_team: int) -> void: if _goal_in_progress: return _goal_in_progress = true _on_goal_registered(conceding_team) await _play_goal_celebration(1 - conceding_team, conceding_team) # A scene change (Esc, match end) queued during the celebration removes # this node from the tree before the await chain finishes; resuming past # that point would touch arena/hud state that is mid-teardown. if not is_inside_tree(): return await _on_goal_scored(conceding_team) if not is_inside_tree(): return _goal_in_progress = false func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void: # Training/headless modes never spawn a camera or HUD and must not pay a # real-time presentation delay between episodes. if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig): return AudioManager.play_goal() var goal_position := Vector3.ZERO for goal in arena.get_goals(): if goal.team == conceding_team: goal_position = goal.global_position break # The cinematic camera cut itself (hard FOV change, cut to a fixed angle) # carries the "moment" that Engine.time_scale slow-mo used to sell — # world simulation speed is never touched, so this behaves identically # for a future networked client watching a shared server sim. _camera_rig.begin_goal_cut(goal_position) if hud: hud.show_goal_celebration(scoring_team) await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout if is_instance_valid(_camera_rig): _camera_rig.end_goal_cut() if hud and is_instance_valid(hud): hud.hide_goal_celebration() 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 = "Ship_T%d_S%d" % [team, spawn_index] 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 ship.spawn_index = spawn_index 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) _camera_rig = rig rig.impact_feedback.connect(AudioManager.play_impact) rig.target = target # Also wires the scene's static HUD (if any) to the same ship, rather # than letting it guess via the "ship" group. if hud: hud.ship = 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) # Owned rather than global `randf_range`, so a fixed seed makes kickoffs # exactly reproducible (replay logs, deterministic tests) without disturbing # any other system's random stream. @export var kickoff_rng_seed: int = 0: set(value): kickoff_rng_seed = value if value != 0: _kickoff_rng.seed = value var _kickoff_rng := RandomNumberGenerator.new() 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)) if is_instance_valid(_camera_rig): # _reset_body's queue_teleport defers the actual transform write to # the ship's next _integrate_forces (task 0.15) — snapping the camera # now would read the pre-teleport position. Wait one physics tick so # the teleport has already landed; without this the camera would also # smoothly chase the teleported ship across the arena instead of # cutting with it. await get_tree().physics_frame if is_instance_valid(_camera_rig): _camera_rig.snap_to_target() func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D: var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter)) var basis := to.basis if yaw_jitter > 0.0: basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter)) return Transform3D(basis, to.origin + offset) func _reset_body(body: RigidBody3D, to: Transform3D) -> void: # Queued and applied inside the body's own _integrate_forces — the only # Jolt-safe place to write state.transform — instead of racing the # physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and # Ball both implement queue_teleport(), but RigidBody3D itself doesn't, # so a statically-typed call here won't resolve. body.call("queue_teleport", to) func _unhandled_input(event): if event.is_action_pressed("ui_cancel"): get_tree().change_scene_to_file(ScenePaths.MAIN_MENU) # Escape failsafe. The arena is meant to be fully enclosed, but the goal # mouths are now a real navigable hole in the end walls (see # ArenaBoundary._build_end_wall_colliders) sized to the ball, not the ship — # a ship's 1x1 cross-section fits through it, and there is nothing behind the # net to stop it. Previously this only existed in TrainingMode (a physics # regression there just wastes training time); now that any ship can # genuinely fly out through an open goal, every mode needs it, or a stray # ship/ball falls into the void with no way back short of quitting. Runs by # default every tick; TrainingMode overrides _physics_process entirely and # calls this itself alongside its own episode logic. const ESCAPE_MARGIN := 15.0 func _physics_process(_delta: float) -> void: if _owns_world_simulation(): _respawn_escaped_bodies() func _respawn_escaped_bodies() -> void: var respawned := false for ship in ships: if is_instance_valid(ship) and _is_escaped(ship.global_position): push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders") _reset_body(ship, _ship_spawn_transforms[ship]) respawned = true if is_instance_valid(ball) and _is_escaped(ball.global_position): push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders") _reset_body(ball, arena.get_ball_spawn()) respawned = true if respawned: _on_bodies_respawned() # Virtual (task 5.9). An escape respawn is a teleport, and a networked client # interpolating toward it would smoothly slide a body the width of the arena # and then fight the correction. NetworkedMatch overrides this to bump # reset_gen so clients hard-snap instead. Single-player modes need nothing. func _on_bodies_respawned() -> void: pass func _is_escaped(position: Vector3) -> bool: return absf(position.x) > ArenaBoundary.INNER_HALF_X + ESCAPE_MARGIN \ or absf(position.z) > ArenaBoundary.INNER_HALF_Z + ESCAPE_MARGIN \ or position.y < -ESCAPE_MARGIN \ or position.y > ArenaBoundary.INNER_HEIGHT + ESCAPE_MARGIN