feat(feedback): add responsive camera and impacts

This commit is contained in:
Josh Creek
2026-08-07 22:37:49 +01:00
parent 78ef0cb1f8
commit 28eb45338a
6 changed files with 160 additions and 5 deletions
+11
View File
@@ -118,6 +118,17 @@ label_settings = SubResource("LabelSettings_kickoff")
horizontal_alignment = 1
vertical_alignment = 1
[node name="ImpactFlash" type="ColorRect" parent="Control"]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.72, 0.9, 1, 0)
[node name="ResultOverlay" type="Control" parent="Control"]
visible = false
layout_mode = 1
+14
View File
@@ -18,6 +18,7 @@ class_name HUDController
@onready var result_panel = get_node_or_null("Control/ResultOverlay/Center/ResultPanel")
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/ResultLabel")
@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/FinalScoreLabel")
@onready var impact_flash = get_node_or_null("Control/ImpactFlash")
@onready var heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
@@ -28,6 +29,7 @@ class_name HUDController
var ship: Node
var _last_score := {0: 0, 1: 0}
var _impact_flash_tween: Tween
func _ready():
# Wait one frame to ensure ship is ready and added to group
@@ -170,6 +172,18 @@ func _flash_score_label(label: Label) -> void:
tween.tween_property(label, "scale", Vector2(1.4, 1.4), 0.1)
tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.2)
func flash_impact(intensity: float) -> void:
if not impact_flash or not is_instance_valid(impact_flash):
return
if _impact_flash_tween and _impact_flash_tween.is_valid():
_impact_flash_tween.kill()
impact_flash.color = Color(0.72, 0.9, 1.0, lerpf(0.06, 0.24, intensity))
impact_flash.visible = true
_impact_flash_tween = create_tween()
_impact_flash_tween.tween_property(impact_flash, "color:a", 0.0, lerpf(0.08, 0.18, intensity))
_impact_flash_tween.tween_callback(func(): impact_flash.visible = false)
func _on_overtime_started():
if timer_label and is_instance_valid(timer_label):
timer_label.text = "OT"
+38
View File
@@ -17,6 +17,9 @@ var hud: HUDController
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
var _hit_stop_generation := 0
var _hit_stop_active := false
var _time_scale_before_hit_stop := 1.0
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
@@ -102,6 +105,7 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
add_child(rig)
rig.target = target
rig.impact_feedback.connect(_on_player_impact)
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
if hud:
@@ -109,6 +113,40 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
return rig
func _on_player_impact(intensity: float) -> void:
if hud:
hud.flash_impact(intensity)
_run_hit_stop(intensity)
func _run_hit_stop(intensity: float) -> void:
_hit_stop_generation += 1
var generation := _hit_stop_generation
if not _hit_stop_active:
_time_scale_before_hit_stop = Engine.time_scale
_hit_stop_active = true
Engine.time_scale = minf(
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
)
await get_tree().create_timer(
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
).timeout
if generation == _hit_stop_generation:
_restore_hit_stop()
func _restore_hit_stop() -> void:
if not _hit_stop_active:
return
Engine.time_scale = _time_scale_before_hit_stop
_hit_stop_active = false
func _exit_tree() -> void:
# A scene change during the unscaled timer must never strand global time.
_restore_hit_stop()
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
# apply their own GameSettings-override logic first, which differs between
# modes (Match lets GameSettings override all three fields, Spectate only
+8
View File
@@ -326,6 +326,14 @@ func _update_movement_vfx() -> void:
_engine_cores[i].scale = Vector3.ONE * (0.8 + thrust * 0.3 + (0.25 if turbo else 0.0))
func get_speed_ratio() -> float:
return clampf(linear_velocity.length() / maxf(max_speed, 0.001), 0.0, 1.0)
func is_turbo_active() -> bool:
return _current_action.turbo and _current_action.thrust.z > 0.05
func _on_body_entered(body: Node) -> void:
if not body is Ball:
return
+87 -3
View File
@@ -7,6 +7,7 @@ extends Node3D
# 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
@@ -14,8 +15,24 @@ signal camera_mode_changed(is_ball_cam: bool)
@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
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
@@ -25,11 +42,34 @@ var _ball: Node3D
# 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
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):
@@ -39,8 +79,14 @@ func _input(event):
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
_update_speed_feel(delta)
var ball := _get_ball()
if ball_cam_enabled and ball:
_update_ball_cam(delta, ball)
@@ -77,11 +123,12 @@ func _update_ball_cam(delta, ball: Node3D):
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
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
@@ -95,11 +142,12 @@ func _update_ship_cam(delta):
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
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)
@@ -115,3 +163,39 @@ func _smooth_look_at(point: Vector3, delta: float) -> void:
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)
+2 -2
View File
@@ -17,8 +17,8 @@ The largest gap between this and a AAA-feeling product is presentation, not code
- [ ] **Audio — there is none.** Zero sound files, zero `AudioStreamPlayer` nodes, no bus layout. Needs: engine hum pitched to throttle, turbo whoosh, ball impacts scaled by collision impulse, wall scrapes, goal explosion, crowd bed, UI clicks, countdown beeps, music. Can be driven off `Ship`'s existing telemetry signals.
- [x] SSAO/SSIL in the arena Environments — cheapest single perceived-quality win available; grounds the ships against the deck and gives the fillets and goal recesses real depth.
- [x] VFX on anything that moves: throttle-reactive engine cores and plumes, distinct turbo flames, a speed-scaled ball trail, contact sparks and team-tinted goal bursts.
- [ ] Impact feedback — screen shake, hit-stop, flash, controller rumble on ball contact.
- [ ] Camera feel in `ship_camera.gd`fixed distance/height/FOV today. Speed-based FOV widening, turbo kick, impact shake.
- [x] Impact feedback — intensity-scaled screen shake, unscaled hit-stop, HUD flash and controller rumble on ball contact.
- [x] Camera feel in `ship_camera.gd`speed-based FOV widening, turbo kick-back and decaying impact shake.
- [ ] Goal celebration sequence: today it's a `print()` and a label scale-pop. Wants an explosion, team-tinted screen flash, camera cut, slow-mo, title card. `HUDController`'s `ResultOverlay` animation is a reasonable template.
- [x] Local lighting / dynamic GI — four local pitch lights plus SDFGI provide bounce and contact lighting for the runtime-generated arena shell; the shader's `hull_fill` stand-in is retired. (The shell is generated in `_ready`, so it cannot participate in an editor LightmapGI bake.)
- [x] Dress `arena_03` — its asteroid field now has mining stations, debris clusters and a distant planet.