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") const MAIN_MENU_SCENE_PATH = "res://scenes/main_menu.tscn" var arena: Arena var ball: RigidBody3D var ships: Array[Ship] = [] var _ship_spawn_transforms := {} 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 # 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(MAIN_MENU_SCENE_PATH)