Files
CosmicClash/Game/scripts/ship_camera.gd
T
Josh Creek 076d27a564 feat(input): full controller support, rebindable controls, and rotation fixes
Playing with a gamepad did not work: all six move_* actions had no joypad
event at all, so a pad could yaw/pitch/roll/turbo but could not translate.
Nothing caught it because every action existed and the game booted fine —
no assertion checked that an action is reachable on *both* devices.

Controller layout, on the 6DOF convention (left stick aims, right stick
translates), using all six of the pad's analog axes for the ship's six
degrees of freedom:

  left stick   yaw + pitch        right stick  strafe + vertical
  LB / RB      roll               RT / LT      forward / back
  L3           turbo              R3           ball camera

Input is now read with Input.get_axis instead of is_action_pressed, so
triggers and sticks are proportional. Keyboard values are unchanged.

Three rotation bugs found by measuring a real Ship rather than reading the
code:

- apply_torque() is world-space and the torque was never rotated into the
  hull's frame (unlike thrust, which uses -ship_basis.z). Roll input became
  pitch after a 90 degree turn and inverted at 180, so the controls were
  correct flying up-field and backwards flying back.
- ship.tscn's inertia is Vector3(7, 1, 7) but a flat torque was applied to
  every axis, giving yaw 7x the angular acceleration of pitch and roll
  (172 deg/s vs 52). Torque is now scaled per-axis by inertia, so
  rotation_acceleration means rad/s^2 and all three axes match. Yaw is
  unchanged.
- pitch_down pitched the nose UP: get_axis's arguments were reversed, so
  the I/K keys and the stick each did the opposite of their label.

Menus were unusable on a pad for a separate reason: Godot 4.7 gives
ui_up/down/left/right joypad events by default but leaves ui_accept and
ui_cancel with none (verified against a pristine project), so a controller
could move the highlight and never press anything. A confirms and B goes
back. Gameplay exits on a new leave_gameplay action (Escape / Start) rather
than ui_cancel, so carrying B for menus cannot abandon a live match.

Bindings for both devices are rebindable in Settings -> Controls, persisted
to user://input.cfg — a separate file from settings.cfg because
VideoSettings.save() rewrites that file wholesale and would drop any
section it does not know about. project.godot stays the source of truth for
defaults; overrides are only ever a delta on top of a boot-time snapshot.

Verified: 268 unit tests, the ENet integration gate, and a 16-sample
before/after comparison of networked prediction residuals showing the
physics change does not regress them (median 0.083m -> 0.065m).

Note for follow-up: every policy in Game/bots/ was trained against the old
sluggish, world-axis rotation and will over-rotate until retrained.
2026-09-06 20:41:20 +01:00

360 lines
16 KiB
GDScript

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):
# A dedicated action rather than ui_accept, so the camera toggle is
# rebindable and A stays purely a menu-confirm button. Space / R3.
if event.is_action_pressed("toggle_ball_cam"):
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)