diff --git a/Game/scenes/HUD.tscn b/Game/scenes/HUD.tscn index bad3f146..21e783c6 100644 --- a/Game/scenes/HUD.tscn +++ b/Game/scenes/HUD.tscn @@ -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 diff --git a/Game/scripts/HUDController.gd b/Game/scripts/HUDController.gd index 606bdc81..4015d248 100644 --- a/Game/scripts/HUDController.gd +++ b/Game/scripts/HUDController.gd @@ -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" diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index bf8daf11..7f4cab8c 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -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 diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 315ec120..cb6a226d 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -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 diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index a59de588..b2977acc 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -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) diff --git a/TODO.md b/TODO.md index 556e3bca..3951e8ee 100644 --- a/TODO.md +++ b/TODO.md @@ -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.