refactor(*): Restructure game into reusable Arena/GameMode architecture with controller-driven ships, adding Free Play and Match modes

This commit is contained in:
Josh Creek
2026-07-18 15:34:11 +01:00
parent 14a8907038
commit 328831df1f
46 changed files with 664 additions and 406 deletions
+16 -14
View File
@@ -25,22 +25,26 @@ func _initialize_hud():
# Find the ship
ship = get_tree().get_first_node_in_group("ship")
if not ship:
# Try to find it as our parent (ship contains this HUD)
var parent_node = get_parent()
if parent_node and parent_node.is_in_group("ship"):
ship = parent_node
else:
push_error("HUDController: No ship found in 'ship' group")
return
push_error("HUDController: No ship found in 'ship' group")
return
print("HUDController: Found ship: ", ship.name)
_connect_ship_signals()
# Connect to game manager's timer signal
# Camera mode comes from the camera rig, not the ship
var camera_rig = get_tree().get_first_node_in_group("ship_camera")
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")
if game_manager and game_manager.has_signal("timer_updated"):
var has_timer = game_manager and game_manager.has_signal("timer_updated")
if has_timer:
game_manager.timer_updated.connect(_on_timer_updated)
print("HUDController: Connected to game timer")
if timer_label and is_instance_valid(timer_label):
timer_label.visible = has_timer
func _connect_ship_signals():
# Connect ship signals to label update methods
@@ -51,8 +55,6 @@ func _connect_ship_signals():
ship.altitude_changed.connect(_on_ship_altitude_changed)
if ship.has_signal("angular_velocity_changed"):
ship.angular_velocity_changed.connect(_on_ship_angular_velocity_changed)
if ship.has_signal("camera_mode_changed"):
ship.camera_mode_changed.connect(_on_ship_camera_mode_changed)
if ship.has_signal("attitude_changed"):
ship.attitude_changed.connect(_on_ship_attitude_changed)
if ship.has_signal("heading_changed"):
@@ -79,7 +81,7 @@ func _on_ship_camera_mode_changed(is_ball_cam: bool):
var camera_mode = "Ball Cam" if is_ball_cam else "Ship Cam"
camera_mode_label.text = "Camera: %s" % camera_mode
func _on_ship_attitude_changed(pitch: float, roll: float, yaw: float):
func _on_ship_attitude_changed(pitch: float, roll: float, _yaw: float):
if attitude_label and is_instance_valid(attitude_label):
attitude_label.text = "Pitch: %.0f° Roll: %.0f°" % [pitch, roll]
-1
View File
@@ -1 +0,0 @@
uid://scnbnslvru0b
-30
View File
@@ -1,30 +0,0 @@
extends RigidBody3D
# Speed variable can be adjusted by subclasses
var speed = 10.0
func _integrate_forces(state):
# input_vector represents the movement input relative to the vehicle
var input_vector = get_input_vector()
# Scale the input_vector by speed
input_vector = input_vector.normalized() * speed
# Set the linear velocity based on the input
state.linear_velocity = input_vector
# Get the input vector from subclasses
func get_input_vector() -> Vector3:
var input_vector = Vector3.ZERO
# Subclasses will implement this function to provide their input mappings
return input_vector
# Called when the node enters the scene tree for the first time.
func _ready():
pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
pass
-1
View File
@@ -1 +0,0 @@
uid://byy2mu4mxdlgl
+32
View File
@@ -0,0 +1,32 @@
class_name Arena
extends Node3D
# A reusable stadium: terrain, lighting, environment, two team goals, and
# spawn markers. An arena holds no rules and no state — game modes query it
# for spawn transforms and goals, then spawn ships/ball themselves.
func _ready():
add_to_group("arena")
func get_ball_spawn() -> Transform3D:
return $BallSpawn.global_transform
func get_ship_spawns(team: int) -> Array[Transform3D]:
var spawns: Array[Transform3D] = []
var container := get_node_or_null("SpawnsTeam%d" % team)
if container:
for child in container.get_children():
if child is Marker3D:
spawns.append(child.global_transform)
return spawns
func get_goals() -> Array[Goal]:
var goals: Array[Goal] = []
for child in get_children():
if child is Goal:
goals.append(child)
return goals
+1
View File
@@ -0,0 +1 @@
uid://c1kq2m6gnwjxo
+22
View File
@@ -0,0 +1,22 @@
extends GameMode
# Free Play: one player ship, one ball, no timer, no score — practice like
# Rocket League's free play. R resets the ball, Esc returns to the menu.
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship)
func _on_goal_scored(conceding_team: int) -> void:
print("Goal! (into team %d's goal)" % conceding_team)
reset_ball()
func _unhandled_input(event):
if event.is_action_pressed("reset_ball"):
reset_ball()
else:
super(event)
+1
View File
@@ -0,0 +1 @@
uid://coirkjf1pbhi7
-26
View File
@@ -1,26 +0,0 @@
extends Node3D
@onready var game_timer: Timer = get_node("Timer")
signal timer_updated(minutes: int, seconds: int)
func _ready():
# Add to group for discovery by HUD
add_to_group("game")
# Set timer to 2 minutes 30 seconds (150 seconds)
game_timer.wait_time = 150.0
game_timer.one_shot = true # Timer runs once
game_timer.start()
func _process(_delta):
if game_timer.time_left > 0:
var time_left = game_timer.time_left
var minutes = int(time_left) / 60
var seconds = int(time_left) % 60
timer_updated.emit(minutes, seconds)
else:
print("Timer finished!")
func _on_timer_timeout() -> void:
if game_timer.time_left == 0:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
-1
View File
@@ -1 +0,0 @@
uid://bpxm8ge52w5g8
+108
View File
@@ -0,0 +1,108 @@
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:
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 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
func reset_ball() -> void:
if is_instance_valid(ball):
_reset_body(ball, arena.get_ball_spawn())
func reset_ships() -> void:
for ship in ships:
if is_instance_valid(ship):
_reset_body(ship, _ship_spawn_transforms[ship])
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)
func _unhandled_input(event):
if event.is_action_pressed("ui_cancel"):
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
+1
View File
@@ -0,0 +1 @@
uid://c6qkhqup6h0pk
+21
View File
@@ -0,0 +1,21 @@
class_name Goal
extends Area3D
# A goal is a dumb sensor: it detects the ball crossing its plane and emits
# goal_scored. The game mode owns all consequences (score, resets). Two of
# these live in each arena, one per team.
# The team that concedes when the ball enters this goal.
@export var team: int = 0
signal goal_scored(team: int)
func _ready():
# Group lets AI controllers and game modes discover goals
add_to_group("goal")
func _on_body_entered(body):
if body.is_in_group("ball"):
goal_scored.emit(team)
+1
View File
@@ -0,0 +1 @@
uid://bcas8toqyr1qb
-19
View File
@@ -1,19 +0,0 @@
extends Area3D
@onready var ball = get_parent().get_node("Ball")
@export var player1: RigidBody3D
#@export var player2: RigidBody3D
# Called when the node enters the scene tree for the first time.
func _ready():
pass # Replace with function body.
# Called when an object with collision enters the bounds of this object
func _on_body_entered(body):
# Only care if it's the ball
if(body == ball):
print("Goal scored in goal 2")
# Reset the ball and player to their starting locations just for testing
ball.global_transform.origin = Vector3(0, 2, 0)
#player1.global_transform.origin = Vector3(0, 0.5, 2)
-1
View File
@@ -1 +0,0 @@
uid://2xvkyfw3v1ui
+12
View File
@@ -0,0 +1,12 @@
extends Control
# Main menu: one handler per game mode. Adding a mode later (e.g. Vs AI)
# is a new button + a one-line handler pointing at its scene.
func _on_free_play_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/free_play.tscn")
func _on_match_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/match.tscn")
+1
View File
@@ -0,0 +1 @@
uid://xcqhku3vnp8v
-5
View File
@@ -1,5 +0,0 @@
extends Button
func _on_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
@@ -1 +0,0 @@
uid://c1tfuurttt8ft
+48
View File
@@ -0,0 +1,48 @@
extends GameMode
# Timed match: two teams, score tracking, kickoff resets after each goal.
# The opponent ship is currently inert (base ShipController, zero action) —
# it becomes the AI opponent once an AIShipController exists (see TODO.md),
# and additional player ships once multiplayer lands.
signal timer_updated(minutes: int, seconds: int)
signal score_changed(score: Dictionary)
@export var match_length_seconds := 150.0
var score := {0: 0, 1: 0}
var match_timer: Timer
func _start() -> void:
spawn_ball()
var player_ship := spawn_ship(0, 0, PlayerShipController.new())
spawn_camera_rig(player_ship)
spawn_ship(1, 0, ShipController.new()) # inert placeholder opponent
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 _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]])
reset_ball()
reset_ships()
func _on_match_timer_timeout() -> void:
print("Full time! Final score: %d - %d" % [score[0], score[1]])
get_tree().change_scene_to_file(MAIN_MENU_SCENE_PATH)
+1
View File
@@ -0,0 +1 @@
uid://cgoqkvyoal2iw
-1
View File
@@ -1 +0,0 @@
uid://cprbkhe46c1be
-1
View File
@@ -1 +0,0 @@
uid://bdda0ybrdo3or
+49
View File
@@ -0,0 +1,49 @@
class_name PlayerShipController
extends ShipController
# Drives a Ship from the local player's input actions (see project.godot
# [input] and FLIGHT_MANUAL.md).
func get_action() -> ShipAction:
var action := ShipAction.new()
# Forward/Backward thrust (main engines)
if Input.is_action_pressed("move_forward"):
action.thrust.z += 1.0
if Input.is_action_pressed("move_back"):
action.thrust.z -= 1.0
# Strafe thrusters (left/right)
if Input.is_action_pressed("move_left"):
action.thrust.x -= 1.0
if Input.is_action_pressed("move_right"):
action.thrust.x += 1.0
# Vertical thrusters (up/down)
if Input.is_action_pressed("move_up"):
action.thrust.y += 1.0
if Input.is_action_pressed("move_down"):
action.thrust.y -= 1.0
# Yaw (turn left/right around Y axis)
if Input.is_action_pressed("turn_left"):
action.rotation.y += 1.0
if Input.is_action_pressed("turn_right"):
action.rotation.y -= 1.0
# Pitch (nose up/down around X axis)
if Input.is_action_pressed("pitch_up"):
action.rotation.x -= 1.0
if Input.is_action_pressed("pitch_down"):
action.rotation.x += 1.0
# Roll (bank left/right around Z axis)
if Input.is_action_pressed("roll_left"):
action.rotation.z += 1.0
if Input.is_action_pressed("roll_right"):
action.rotation.z -= 1.0
action.turbo = Input.is_action_pressed("turbo")
return action
@@ -0,0 +1 @@
uid://gxdj34cnymoy
+68 -196
View File
@@ -1,9 +1,16 @@
class_name Ship
extends RigidBody3D
# Physics-driven spaceship. All movement is force/torque-based, applied in
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
# child node (player input, AI policy, or network replication — see
# set_controller). A ship without a controller is inert but still simulated,
# which is what a placeholder opponent or a headless RL ship needs.
# Physics properties (mass, inertia, friction material) live in ship.tscn.
@export_group("Movement")
@export var thrust_power = 150.0 # Main thruster power
@export var maneuvering_thrust = 75.0 # Side/vertical thruster power
@export var maneuvering_thrust = 75.0 # Side thruster power
@export var vertical_thrust = 120.0 # Up/down thruster power
@export var turbo_multiplier = 2.5 # Turbo boost multiplier
@export var max_speed = 35.0 # Maximum velocity
@@ -12,15 +19,11 @@ extends RigidBody3D
@export var drag_coefficient = 0.98 # Linear drag (air resistance)
@export var angular_drag = 0.95 # Rotational drag
@export_group("Camera")
var camera_distance = 8.0
var camera_height = 4.0
var camera_smoothing = 10.0
# Which team this ship plays for (0 or 1). Set by the game mode on spawn.
var team: int = 0
@onready var camera : Camera3D = get_node("Camera3D")
@onready var ball = get_parent().get_node("Ball")
var ball_cam_enabled = true
var controller: ShipController
var _current_action: ShipAction = ShipAction.new()
# Instrument signals for efficient data distribution
signal speed_changed(speed: float)
@@ -28,7 +31,6 @@ signal attitude_changed(pitch: float, roll: float, yaw: float)
signal altitude_changed(altitude: float)
signal thrust_changed(thrust_percent: float)
signal angular_velocity_changed(angular_speed: float)
signal camera_mode_changed(is_ball_cam: bool)
signal heading_changed(heading_degrees: float)
# Performance optimization - track last emitted values to avoid unnecessary signals
@@ -40,7 +42,6 @@ var _last_roll: float = -999.0
var _last_yaw: float = -999.0
var _last_heading: float = -999.0
var _last_thrust: float = -1.0
var _last_camera_mode: bool = true
# Thresholds for signal emission (only emit if change is significant)
const SPEED_THRESHOLD = 0.1 # m/s
@@ -49,208 +50,84 @@ const ANGULAR_THRESHOLD = 0.01 # rad/s
const ATTITUDE_THRESHOLD = 1.0 # degrees
const THRUST_THRESHOLD = 1.0 # percent
func _ready():
mass = 5
gravity_scale = 1.0
# Add ship to group for instrument discovery
add_to_group("ship")
# Set custom inertia for better rotation
# Physics: I = m * r² (moment of inertia = mass × radius²)
# Lower inertia = easier to rotate, higher inertia = more stable
inertia = Vector3(1.0, 1.0, 1.0)
# Create and apply low-friction physics material
# Physics: F_friction = μ * N (friction force = coefficient × normal force)
# Lower μ (friction coefficient) = less resistance to sliding
var ship_material = PhysicsMaterial.new()
ship_material.friction = 0.1 # Very low friction
ship_material.bounce = 0.2 # Slight bounce
physics_material_override = ship_material
# Make sure the RigidBody is completely free to move and rotate
freeze = false
lock_rotation = false
# Ensure all axes can rotate
axis_lock_angular_x = false
axis_lock_angular_y = false
axis_lock_angular_z = false
print("Ship physics configured - Mass: ", mass, " Gravity scale: ", gravity_scale, " Inertia: ", inertia)
print("Rotation locks - X:", axis_lock_angular_x, " Y:", axis_lock_angular_y, " Z:", axis_lock_angular_z)
func _input(event):
if event.is_action_pressed("ui_accept"): # Enter key
ball_cam_enabled = !ball_cam_enabled
camera_mode_changed.emit(ball_cam_enabled)
# Pick up a controller placed in the scene, if any; game modes usually
# attach one at spawn time via set_controller instead.
for child in get_children():
if child is ShipController:
controller = child
break
func _physics_process(delta):
if camera:
_update_camera(delta)
# Attach the node that drives this ship (player, AI, or network). Replaces
# any existing controller; parents the new one under the ship if needed.
func set_controller(new_controller: ShipController) -> void:
if is_instance_valid(controller) and controller.get_parent() == self:
controller.queue_free()
controller = new_controller
if new_controller and new_controller.get_parent() == null:
add_child(new_controller)
func _physics_process(_delta):
_emit_telemetry_data()
func _update_camera(delta):
if ball_cam_enabled and ball:
_update_ball_cam(delta)
else:
_update_ship_cam(delta)
func _update_ball_cam(delta):
# In ball cam, camera positions itself so the ship is between camera and ball
# Physics: Vector mathematics for 3D positioning
var ship_pos = global_transform.origin
var ball_pos = ball.global_transform.origin
# Calculate direction from ball to ship
# Physics: Vector subtraction and normalization
# Direction vector: d̂ = (P₂ - P₁) / |P₂ - P₁|
var ball_to_ship = (ship_pos - ball_pos).normalized()
# Position camera behind the ship relative to the ball's position
# This ensures the ship is always between the camera and ball
# Physics: Vector addition for position calculation
# P_camera = P_ship + d̂ * distance + height_offset
var camera_target_pos = ship_pos + ball_to_ship * camera_distance + Vector3.UP * camera_height
# Smoothly move camera to target position
# Physics: Linear interpolation (LERP) for smooth motion
# P(t) = P₀ + t * (P₁ - P₀), where t ∈ [0,1]
# This creates exponential approach to target position
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
# Make camera look at the ball
if camera.global_transform.origin.distance_to(ball_pos) > 0.1:
# Calculate direction to ball
var camera_pos = camera.global_transform.origin
var to_ball = (ball_pos - camera_pos).normalized()
# Create look-at transform manually
# Physics: 3D rotation matrices and basis vectors
# Uses right-hand rule: forward = -Z, up = Y, right = X
# Basis matrix transforms local coordinates to world coordinates
var camera_transform = Transform3D()
camera_transform.origin = camera_pos
camera_transform.basis = Basis.looking_at(to_ball, Vector3.UP)
# Apply the rotation smoothly
# Physics: Spherical linear interpolation (SLERP) for rotation
# SLERP provides smooth rotation along great circle on unit sphere
# Maintains constant angular velocity during interpolation
camera.global_transform.basis = camera.global_transform.basis.slerp(camera_transform.basis, camera_smoothing * delta)
func _update_ship_cam(delta):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos = global_transform.origin
var ship_forward = -global_transform.basis.z
# Position camera behind and above the ship
var camera_target_pos = ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height
# Smoothly move camera
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
# Make camera look in the same direction as the ship
var look_target = ship_pos + ship_forward * 10.0 # Look ahead of the ship
camera.look_at(look_target, Vector3.UP)
func _integrate_forces(state):
# Get thruster input
var thrust_input = get_thrust_input()
var rotation_input = get_rotation_input()
# One action per physics tick, pulled from the controller (deterministic)
_current_action = controller.get_action() if controller else ShipAction.new()
# === TRANSLATION (Movement) ===
apply_thruster_forces(state, thrust_input)
apply_thruster_forces(state, _current_action)
# === ROTATION (Turning) ===
apply_rotation_forces(state, rotation_input)
apply_rotation_forces(state, _current_action.rotation)
# === DRAG AND LIMITS ===
apply_drag_and_limits(state, rotation_input)
apply_drag_and_limits(state, _current_action.rotation)
func get_thrust_input() -> Vector3:
var thrust = Vector3.ZERO
# Forward/Backward thrust (main engines)
if Input.is_action_pressed("move_forward"):
thrust.z += 1.0
if Input.is_action_pressed("move_back"):
thrust.z -= 1.0
# Strafe thrusters (left/right)
if Input.is_action_pressed("move_left"):
thrust.x -= 1.0
if Input.is_action_pressed("move_right"):
thrust.x += 1.0
# Vertical thrusters (up/down)
if Input.is_action_pressed("move_up"):
thrust.y += 1.0
if Input.is_action_pressed("move_down"):
thrust.y -= 1.0
return thrust
func get_rotation_input() -> Vector3:
var rotation = Vector3.ZERO
# Yaw (turn left/right around Y axis) - only use these if they exist
if Input.is_action_pressed("turn_left"):
rotation.y += 1.0
if Input.is_action_pressed("turn_right"):
rotation.y -= 1.0
# Pitch (nose up/down around X axis)
if Input.is_action_pressed("pitch_up"):
rotation.x -= 1.0
if Input.is_action_pressed("pitch_down"):
rotation.x += 1.0
# Roll (bank left/right around Z axis)
if Input.is_action_pressed("roll_left"):
rotation.z += 1.0
if Input.is_action_pressed("roll_right"):
rotation.z -= 1.0
return rotation
func apply_thruster_forces(state: PhysicsDirectBodyState3D, thrust_input: Vector3):
func apply_thruster_forces(state: PhysicsDirectBodyState3D, action: ShipAction):
var thrust_input := action.thrust
if thrust_input.length() < 0.01:
return
# Convert thrust input to world space forces based on ship orientation
# Physics: F = m * a (Newton's Second Law: Force = mass × acceleration)
# World force = Local force × Rotation matrix (basis transformation)
var ship_basis = global_transform.basis
var world_thrust = Vector3.ZERO
# All thrusters should work relative to ship orientation
# Physics: Vector transformation from local to world coordinates
# F_world = R * F_local (where R is rotation matrix)
# Forward/backward thrust (main engines)
world_thrust += -ship_basis.z * thrust_input.z * thrust_power
# Strafe thrust (left/right maneuvering thrusters)
world_thrust += ship_basis.x * thrust_input.x * maneuvering_thrust
# Vertical thrust (up/down thrusters relative to ship orientation)
world_thrust += ship_basis.y * thrust_input.y * vertical_thrust
# Check for turbo
var is_turbo = Input.is_action_pressed("turbo") and thrust_input.z > 0
if is_turbo:
# Turbo only boosts forward thrust
if action.turbo and thrust_input.z > 0:
world_thrust *= turbo_multiplier
# Apply the force
# Physics: Δv = F * Δt / m (change in velocity = force × time / mass)
state.apply_central_force(world_thrust)
func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
if rotation_input.length() < 0.01:
return
# Apply torque for rotation - simple and effective
# Physics: τ = I * α (torque = moment of inertia × angular acceleration)
# Also: α = τ / I (angular acceleration = torque / moment of inertia)
@@ -260,17 +137,18 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
rotation_input.y * rotation_power, # Yaw (rotation around Y-axis)
rotation_input.z * rotation_power # Roll (rotation around Z-axis)
)
# Physics: Δω = τ * Δt / I (change in angular velocity = torque × time / inertia)
state.apply_torque(torque)
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
# Linear drag (air resistance)
# Physics: F_drag = -½ * ρ * v² * C_d * A (drag force equation)
# Simplified: v_new = v_old * drag_coefficient (exponential decay)
# This simulates air resistance reducing velocity over time
state.linear_velocity *= drag_coefficient
# Angular drag (rotational resistance)
# Physics: Similar to linear drag but for rotational motion
# τ_drag = -C_angular * ω² (angular drag torque)
@@ -281,7 +159,7 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect
else:
# Normal drag when actively rotating
state.angular_velocity *= angular_drag
# Limit maximum speeds
# Physics: Terminal velocity concept - maximum achievable speed
# When thrust force = drag force, acceleration = 0, velocity = constant
@@ -289,50 +167,46 @@ func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vect
# Normalize to unit vector, then scale to max speed
# Physics: v̂ = v / |v| (unit vector), v_limited = v̂ * v_max
state.linear_velocity = state.linear_velocity.normalized() * max_speed
if state.angular_velocity.length() > max_angular_speed:
# Same concept for angular velocity
# Physics: ω̂ = ω / |ω|, ω_limited = ω̂ * ω_max
state.angular_velocity = state.angular_velocity.normalized() * max_angular_speed
func _emit_telemetry_data():
# Ship only calculates and emits data - HUD handles display
# Performance optimization: only emit signals when values change significantly
# Speed telemetry
# Physics: |v| = √(vₓ² + vᵧ² + vᵤ²) (magnitude of velocity vector)
var current_speed = linear_velocity.length()
if abs(current_speed - _last_speed) > SPEED_THRESHOLD:
speed_changed.emit(current_speed)
_last_speed = current_speed
# Altitude telemetry
# Altitude telemetry
# Physics: Height measurement from reference point (y = 0)
var current_altitude = global_transform.origin.y
if abs(current_altitude - _last_altitude) > ALTITUDE_THRESHOLD:
altitude_changed.emit(current_altitude)
_last_altitude = current_altitude
# Angular velocity telemetry
# Physics: |ω| = √(ωₓ² + ωᵧ² + ωᵤ²) (magnitude of angular velocity vector)
var angular_speed = angular_velocity.length()
if abs(angular_speed - _last_angular_speed) > ANGULAR_THRESHOLD:
angular_velocity_changed.emit(angular_speed)
_last_angular_speed = angular_speed
# Camera mode telemetry (only emit when it actually changes)
if ball_cam_enabled != _last_camera_mode:
camera_mode_changed.emit(ball_cam_enabled)
_last_camera_mode = ball_cam_enabled
# Attitude telemetry (pitch, roll, yaw from ship orientation)
# Physics: Euler angles from rotation matrix
# Pitch = rotation around X-axis, Roll = rotation around Z-axis
var ship_rotation = global_transform.basis.get_euler(EULER_ORDER_XYZ)
var pitch_deg = rad_to_deg(ship_rotation.x)
var roll_deg = rad_to_deg(ship_rotation.z)
var roll_deg = rad_to_deg(ship_rotation.z)
var yaw_deg = rad_to_deg(ship_rotation.y)
if abs(pitch_deg - _last_pitch) > ATTITUDE_THRESHOLD or \
abs(roll_deg - _last_roll) > ATTITUDE_THRESHOLD or \
abs(yaw_deg - _last_yaw) > ATTITUDE_THRESHOLD:
@@ -340,7 +214,7 @@ func _emit_telemetry_data():
_last_pitch = pitch_deg
_last_roll = roll_deg
_last_yaw = yaw_deg
# Heading telemetry (yaw - direction ship is facing)
# Physics: Yaw = rotation around Y-axis (compass heading)
# Convert to 0-360° range for traditional compass display
@@ -348,12 +222,10 @@ func _emit_telemetry_data():
if abs(heading - _last_heading) > ATTITUDE_THRESHOLD:
heading_changed.emit(heading)
_last_heading = heading
# Thrust telemetry
# Physics: Thrust output as percentage of maximum available thrust
var thrust_input = get_thrust_input()
var thrust_magnitude = thrust_input.length()
var thrust_percent = thrust_magnitude * 100.0
var thrust_percent = _current_action.thrust.length() * 100.0
if abs(thrust_percent - _last_thrust) > THRUST_THRESHOLD:
thrust_changed.emit(thrust_percent)
_last_thrust = thrust_percent
+11
View File
@@ -0,0 +1,11 @@
class_name ShipAction
extends RefCounted
# A single physics tick's worth of control input for a Ship.
# Produced by a ShipController each tick, consumed by Ship._integrate_forces.
# This is deliberately shaped as the future RL action space (a flat 7-value
# box) and the future network-replicated input payload.
var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back
var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll
var turbo := false
+1
View File
@@ -0,0 +1 @@
uid://oh6cx0f2gt6a
+107
View File
@@ -0,0 +1,107 @@
class_name ShipCameraRig
extends Node3D
# Third-person camera for a Ship. Ball Cam keeps the ship between the camera
# and the ball; Ship Cam chases behind the ship. The game mode spawns this
# rig and assigns `target` after spawning the player's ship — ships
# themselves are camera-free (a headless/AI ship never needs one).
signal camera_mode_changed(is_ball_cam: bool)
@export var camera_distance := 8.0
@export var camera_height := 4.0
@export var camera_smoothing := 10.0
var target: Ship
var ball_cam_enabled := true
@onready var camera: Camera3D = $Camera3D
var _ball: Node3D
func _ready():
# Group lets the HUD discover the rig for the camera-mode instrument
add_to_group("ship_camera")
func _input(event):
if event.is_action_pressed("ui_accept"): # Enter key
ball_cam_enabled = !ball_cam_enabled
camera_mode_changed.emit(ball_cam_enabled)
func _physics_process(delta):
if not is_instance_valid(target):
return
var ball := _get_ball()
if ball_cam_enabled and ball:
_update_ball_cam(delta, ball)
else:
_update_ship_cam(delta)
func _get_ball() -> Node3D:
if not is_instance_valid(_ball):
_ball = get_tree().get_first_node_in_group("ball")
return _ball
func _update_ball_cam(delta, ball: Node3D):
# In ball cam, camera positions itself so the ship is between camera and ball
# Physics: Vector mathematics for 3D positioning
var ship_pos = target.global_transform.origin
var ball_pos = ball.global_transform.origin
# Calculate direction from ball to ship
# Physics: Vector subtraction and normalization
# Direction vector: d̂ = (P₂ - P₁) / |P₂ - P₁|
var ball_to_ship = (ship_pos - ball_pos).normalized()
# Position camera behind the ship relative to the ball's position
# This ensures the ship is always between the camera and ball
# Physics: Vector addition for position calculation
# P_camera = P_ship + d̂ * distance + height_offset
var camera_target_pos = ship_pos + ball_to_ship * camera_distance + Vector3.UP * camera_height
# Smoothly move camera to target position
# Physics: Linear interpolation (LERP) for smooth motion
# P(t) = P₀ + t * (P₁ - P₀), where t ∈ [0,1]
# This creates exponential approach to target position
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
# Make camera look at the ball
if camera.global_transform.origin.distance_to(ball_pos) > 0.1:
# Calculate direction to ball
var camera_pos = camera.global_transform.origin
var to_ball = (ball_pos - camera_pos).normalized()
# Create look-at transform manually
# Physics: 3D rotation matrices and basis vectors
# Uses right-hand rule: forward = -Z, up = Y, right = X
# Basis matrix transforms local coordinates to world coordinates
var camera_transform = Transform3D()
camera_transform.origin = camera_pos
camera_transform.basis = Basis.looking_at(to_ball, Vector3.UP)
# Apply the rotation smoothly
# Physics: Spherical linear interpolation (SLERP) for rotation
# SLERP provides smooth rotation along great circle on unit sphere
# Maintains constant angular velocity during interpolation
camera.global_transform.basis = camera.global_transform.basis.slerp(camera_transform.basis, camera_smoothing * delta)
func _update_ship_cam(delta):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos = target.global_transform.origin
var ship_forward = -target.global_transform.basis.z
# Position camera behind and above the ship
var camera_target_pos = ship_pos - ship_forward * camera_distance + Vector3.UP * camera_height
# Smoothly move camera
camera.global_transform.origin = camera.global_transform.origin.lerp(camera_target_pos, camera_smoothing * delta)
# Make camera look in the same direction as the ship
var look_target = ship_pos + ship_forward * 10.0 # Look ahead of the ship
camera.look_at(look_target, Vector3.UP)
+1
View File
@@ -0,0 +1 @@
uid://bl1upxgeuj8to
+12
View File
@@ -0,0 +1,12 @@
class_name ShipController
extends Node
# Base class for anything that drives a Ship: local player input, an AI
# policy, or network replication. The ship pulls exactly one action per
# physics tick, so controllers stay deterministic and ordering-free.
# The base implementation returns a zero action — a ship with a base
# controller (or none) is inert but still physically simulated.
func get_action() -> ShipAction:
return ShipAction.new()
+1
View File
@@ -0,0 +1 @@
uid://hnusbl8a0rvj