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 := 3.0 @export var camera_smoothing := 10.0 @export var orbit_smoothing := 6.0 # How fast the camera swings around the ship in ball cam @export var look_smoothing := 20.0 # How fast the camera re-centres its look target @export var min_camera_height := 1.0 # Keeps the camera from dipping through the arena floor var target: Ship var ball_cam_enabled := true @onready var camera: Camera3D = $Camera3D var _ball: Node3D # Smoothed horizontal direction from ball to ship; the ball-cam orbits along # this so the camera swings smoothly around the ship instead of chasing a # raw position (which whips when ball and ship are close together). var _orbit_dir := Vector3.BACK 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): # Ball cam keeps the ship between the camera and the ball: the camera sits # on the ball→ship line (horizontal component only), looking at the ball, # so the ship stays low-centre in frame and the ball stays centred. var ship_pos: Vector3 = target.global_transform.origin var ball_pos: Vector3 = ball.global_transform.origin # Smooth the orbit direction in angle space rather than lerping the camera # position directly — when ball and ship pass close to each other the raw # direction flips instantly, and slerping the direction turns that into a # controlled swing around the ship. The vertical component is dropped so # a ball flying overhead pitches the camera up instead of shoving it into # the floor or the sky. var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z) if flat.length() > 0.25: var t := 1.0 - exp(-orbit_smoothing * delta) # frame-rate independent # Both directions are horizontal, so swing about the exact UP axis. # (Vector3.slerp derives its axis from the cross product, which # degenerates when the directions are nearly parallel — the common # case here — and spams normalization errors every frame.) var swing := _orbit_dir.signed_angle_to(flat.normalized(), Vector3.UP) _orbit_dir = _orbit_dir.rotated(Vector3.UP, swing * t).normalized() var camera_target_pos := ship_pos + _orbit_dir * camera_distance + Vector3.UP * camera_height camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height) var pos_t := 1.0 - exp(-camera_smoothing * delta) camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t) # Look slightly above the ball's centre; look smoothing is faster than # position smoothing so the ball never drifts out of frame while the # camera is still swinging into place. _smooth_look_at(ball_pos + Vector3.UP * 0.5, delta) func _update_ship_cam(delta): # In ship cam, camera follows and looks in the same direction as the ship var ship_pos: Vector3 = target.global_transform.origin var ship_forward: Vector3 = -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 camera_target_pos.y = maxf(camera_target_pos.y, min_camera_height) var pos_t := 1.0 - exp(-camera_smoothing * delta) camera.global_position = camera.global_position.lerp(camera_target_pos, pos_t) # Look ahead of the ship _smooth_look_at(ship_pos + ship_forward * 10.0, delta) func _smooth_look_at(point: Vector3, delta: float) -> void: var to_point := point - camera.global_position if to_point.length() < 0.1: return var dir := to_point.normalized() if absf(dir.dot(Vector3.UP)) > 0.99: return # Nearly vertical: looking_at would be degenerate, keep last frame var look_basis := Basis.looking_at(dir, Vector3.UP) var look_t := 1.0 - exp(-look_smoothing * delta) camera.global_basis = camera.global_basis.slerp(look_basis, look_t).orthonormalized()