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) signal impact_feedback(intensity: float) @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 @export_group("Speed Feel") @export var base_fov := 70.0 @export var speed_fov_add := 11.0 @export var turbo_fov_kick := 7.0 @export var turbo_distance_kick := 0.9 @export var feel_smoothing := 7.0 @export_group("Impact Shake") @export var max_shake_offset := 0.32 @export var shake_decay := 8.0 @export_group("Impact Punch") # Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd): # a camera-only FOV kick + PostFX flash that decays over real time, so it # works identically for hit-stop and goal moments without touching global # simulation speed — which a networked client could never do to a shared sim. @export var punch_fov_kick := 9.0 @export var punch_vignette_kick := 0.28 @export var punch_chroma_kick := 0.012 @export var punch_decay := 5.0 var target: Ship: set(value): if target == value: return if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact): target.ball_contact.disconnect(_on_target_ball_contact) target = value _connect_target() # Priming: a freshly assigned target's Visual has no interpolation # history yet (or is about to be reparented mid-spawn), and the rig # itself would otherwise lerp in from wherever it was previously # (world origin on first spawn, the old target on a Spectate switch) # over camera_smoothing seconds. Both read as a visible swoop/smear; # neither is a real camera move. if is_instance_valid(target) and is_instance_valid(target.visual): target.visual.reset_physics_interpolation() if is_inside_tree(): snap_to_target() var ball_cam_enabled := true @onready var camera: Camera3D = $Camera3D @onready var post_material: ShaderMaterial = $PostProcess/PostFX.material as ShaderMaterial 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 var _turbo_blend := 0.0 var _speed_blend := 0.0 var _effective_distance := 8.0 var _shake_strength := 0.0 var _shake_noise := FastNoiseLite.new() var _shake_time := 0.0 var _last_shake_offset := Vector3.ZERO var _punch_strength := 0.0 var _goal_cut_active := false var _goal_cut_position := Vector3.ZERO var _goal_cut_look_at := Vector3.ZERO const SHAKE_UPDATE_HZ := 60.0 func _ready(): # Group lets the HUD discover the rig for the camera-mode instrument add_to_group("ship_camera") # The rig moves itself every rendered frame in _process now (task 0.16), # not on the physics tick — Godot's built-in physics interpolation would # otherwise smooth between _physics_process-era transforms this rig no # longer writes, fighting the manual smoothing below. physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF camera.fov = base_fov _effective_distance = camera_distance _shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _shake_noise.frequency = 2.5 _connect_target() func _connect_target() -> void: if not is_inside_tree() or not is_instance_valid(target): return if not target.ball_contact.is_connected(_on_target_ball_contact): target.ball_contact.connect(_on_target_ball_contact) if not target.wall_contact.is_connected(_on_target_wall_contact): target.wall_contact.connect(_on_target_wall_contact) func _exit_tree() -> void: AudioManager.stop_engine() if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact): target.ball_contact.disconnect(_on_target_ball_contact) if is_instance_valid(target) and target.wall_contact.is_connected(_on_target_wall_contact): target.wall_contact.disconnect(_on_target_wall_contact) 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 _process(delta): # Camera position smoothing operates on the unshaken chase position. Remove # last frame's presentation-only offset first so shake never accumulates or # exceeds its configured amplitude during a low-time-scale hit-stop. camera.global_position -= _last_shake_offset _last_shake_offset = Vector3.ZERO if not is_instance_valid(target): return if _goal_cut_active: _smooth_look_at(_goal_cut_look_at, delta) # The cinematic cut freezes camera position, but leftover shake from # an impact right before the goal must still bleed off in real time — # otherwise it resumes at full pre-cut magnitude when play returns. _decay_shake(delta) return _update_speed_feel(delta) var ball := _get_ball() if ball_cam_enabled and ball: _update_ball_cam(delta, ball) else: _update_ship_cam(delta) _apply_punch(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. # Reads target.visual, not target itself: once prediction correction # (task 0.14) lands, the body can be offset from what's on screen — the # camera must always frame what the player sees, not the collider. # get_global_transform_interpolated() (not global_transform) because this # now runs in _process: the physics body only moves once per 60Hz tick, # so sampling its raw transform every rendered frame at 240fps would # repeat the same value 4 times in a row and read as stutter. var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().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 * _effective_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) _apply_shake(delta) # 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. # Reads target.visual, not target itself, via the interpolated transform — # see _update_ball_cam. var visual_xform := target.visual.get_global_transform_interpolated() var ship_pos: Vector3 = visual_xform.origin var ship_forward: Vector3 = -visual_xform.basis.z # Position camera behind and above the ship var camera_target_pos := ship_pos - ship_forward * _effective_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) _apply_shake(delta) # 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() func _update_speed_feel(delta: float) -> void: var feel_t := 1.0 - exp(-feel_smoothing * delta) var turbo_target := 1.0 if target.is_turbo_active() else 0.0 var engine_action := target.get_current_action_copy() AudioManager.set_engine_state(maxf(engine_action.thrust.z, 0.0), turbo_target > 0.5) _turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t) _speed_blend = lerpf(_speed_blend, target.get_speed_ratio(), feel_t) var target_fov := base_fov + _speed_blend * speed_fov_add + _turbo_blend * turbo_fov_kick camera.fov = lerpf(camera.fov, target_fov, feel_t) _effective_distance = lerpf( _effective_distance, camera_distance + _turbo_blend * turbo_distance_kick, feel_t ) post_material.set_shader_parameter("chromatic_aberration", _turbo_blend * 0.007) post_material.set_shader_parameter("vignette_strength", 0.22 + _turbo_blend * 0.16) func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void: _shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0)) _punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0)) for device in Input.get_connected_joypads(): Input.start_joy_vibration( device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity), lerpf(0.08, 0.2, intensity) ) impact_feedback.emit(intensity) func _on_target_wall_contact(intensity: float) -> void: AudioManager.play_wall_scrape(intensity) func _apply_shake(delta: float) -> void: if _shake_strength <= 0.001: _shake_strength = 0.0 return _shake_time += delta # Quantized to a fixed 60Hz cadence rather than sampled once per rendered # frame. The noise domain's total distance travelled per second is the # same either way, but that's not what "reads the same" means here: at # 60fps, consecutive samples are frequency (2.5) domain-units apart — # far enough that FastNoiseLite's simplex correlation has decayed, so it # reads as sharp, uncorrelated jitter. At 240fps the same per-second # distance is split across 4x the samples, so consecutive samples are # ~4x closer together and highly correlated — a completely different, # much gentler wobble. Freezing the domain input to whole 60Hz ticks # makes every render frame within one tick reuse the exact same sample, # so the perceived shake texture is identical at any frame rate. var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ) var amplitude := max_shake_offset * _shake_strength * _shake_strength var noise := Vector2( _shake_noise.get_noise_1d(tick), _shake_noise.get_noise_1d(tick + 100.0) ).limit_length(1.0) * amplitude _last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y camera.global_position += _last_shake_offset _decay_shake(delta) func _decay_shake(delta: float) -> void: _shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta) # Additive FOV kick + PostFX flash on top of _update_speed_feel's base # values, decaying over real time. Replaces the weight that Engine.time_scale # hit-stop used to sell on ball impact — see the Impact Punch export group. func _apply_punch(delta: float) -> void: if _punch_strength <= 0.001: _punch_strength = 0.0 return camera.fov += punch_fov_kick * _punch_strength var chroma: float = post_material.get_shader_parameter("chromatic_aberration") var vignette: float = post_material.get_shader_parameter("vignette_strength") post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength) post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength) _punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta) # Places the camera at its resting chase position instantly, bypassing # camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the # thing being framed just teleported (kickoff, target reassignment) — without # this the rig would lerp smoothly across the whole arena over # camera_smoothing seconds, which reads as an unintended camera move rather # than a reset. func snap_to_target() -> void: if not is_instance_valid(target) or not is_instance_valid(camera): return _shake_strength = 0.0 camera.global_position -= _last_shake_offset _last_shake_offset = Vector3.ZERO var visual_xform := target.visual.get_global_transform_interpolated() var ship_pos: Vector3 = visual_xform.origin var ball := _get_ball() if ball_cam_enabled and ball: var ball_pos: Vector3 = ball.global_transform.origin var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z) _orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height camera.global_position.y = maxf(camera.global_position.y, min_camera_height) camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP) else: var ship_forward: Vector3 = -visual_xform.basis.z camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height camera.global_position.y = maxf(camera.global_position.y, min_camera_height) camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP) func begin_goal_cut(goal_position: Vector3) -> void: _goal_cut_active = true # The hard cut replaces the chase camera's presentation offset entirely; # don't let the next physics frame subtract a pre-cut shake sample from the # new cinematic position. camera.global_position -= _last_shake_offset _last_shake_offset = Vector3.ZERO var inward := Vector3(-goal_position.x, 0.0, -goal_position.z) if inward.length_squared() < 0.01: inward = Vector3.BACK else: inward = inward.normalized() _goal_cut_position = goal_position + inward * 10.5 + Vector3.UP * 5.5 _goal_cut_look_at = goal_position + Vector3.UP * 0.8 camera.global_position = _goal_cut_position camera.fov = 58.0 camera.look_at(_goal_cut_look_at, Vector3.UP) # A hard, stationary cinematic camera must not inherit the scoring frame's # turbo chroma; keep only a deliberate cinematic edge. post_material.set_shader_parameter("chromatic_aberration", 0.0) post_material.set_shader_parameter("vignette_strength", 0.3) func end_goal_cut() -> void: _goal_cut_active = false _speed_blend = 0.0 _turbo_blend = 0.0 post_material.set_shader_parameter("chromatic_aberration", 0.0) post_material.set_shader_parameter("vignette_strength", 0.22)