mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
231 lines
8.7 KiB
GDScript
231 lines
8.7 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
|
|
|
|
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()
|
|
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
|
|
var _turbo_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 _goal_cut_active := false
|
|
var _goal_cut_position := Vector3.ZERO
|
|
var _goal_cut_look_at := Vector3.ZERO
|
|
|
|
|
|
func _ready():
|
|
# Group lets the HUD discover the rig for the camera-mode instrument
|
|
add_to_group("ship_camera")
|
|
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)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if is_instance_valid(target) and target.ball_contact.is_connected(_on_target_ball_contact):
|
|
target.ball_contact.disconnect(_on_target_ball_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 _physics_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)
|
|
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)
|
|
|
|
|
|
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 * _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
|
|
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 * _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
|
|
_turbo_blend = lerpf(_turbo_blend, turbo_target, feel_t)
|
|
var target_fov := base_fov + target.get_speed_ratio() * 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
|
|
)
|
|
|
|
|
|
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
|
|
_shake_strength = maxf(_shake_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 _apply_shake(delta: float) -> void:
|
|
if _shake_strength <= 0.001:
|
|
_shake_strength = 0.0
|
|
return
|
|
_shake_time += delta * 60.0
|
|
var amplitude := max_shake_offset * _shake_strength * _shake_strength
|
|
var noise := Vector2(
|
|
_shake_noise.get_noise_1d(_shake_time),
|
|
_shake_noise.get_noise_1d(_shake_time + 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
|
|
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
|
|
|
|
|
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)
|
|
|
|
|
|
func end_goal_cut() -> void:
|
|
_goal_cut_active = false
|