From 8bd65290fcb733c0807c70c0fd90ada9987a6038 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:45:33 +0100 Subject: [PATCH] feat(goals): add cinematic celebration sequence --- Game/scenes/HUD.tscn | 37 +++++++++++++++++++ Game/scripts/HUDController.gd | 37 +++++++++++++++++++ Game/scripts/game_mode.gd | 68 +++++++++++++++++++++++++++++++---- Game/scripts/match_mode.gd | 24 +++++++++++-- Game/scripts/ship_camera.gd | 29 +++++++++++++++ Game/scripts/spectate_mode.gd | 5 ++- TODO.md | 2 +- 7 files changed, 190 insertions(+), 12 deletions(-) diff --git a/Game/scenes/HUD.tscn b/Game/scenes/HUD.tscn index 21e783c6..8624f321 100644 --- a/Game/scenes/HUD.tscn +++ b/Game/scenes/HUD.tscn @@ -129,6 +129,43 @@ grow_vertical = 2 mouse_filter = 2 color = Color(0.72, 0.9, 1, 0) +[node name="GoalCelebration" type="Control" 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 + +[node name="Tint" type="ColorRect" parent="Control/GoalCelebration"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Center" type="CenterContainer" parent="Control/GoalCelebration"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="GoalTitle" type="Label" parent="Control/GoalCelebration/Center"] +layout_mode = 2 +theme_override_colors/font_shadow_color = Color(0, 0, 0, 0.8) +theme_override_constants/shadow_offset_x = 5 +theme_override_constants/shadow_offset_y = 5 +theme_override_font_sizes/font_size = 82 +text = "GOAL!" +horizontal_alignment = 1 + [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 4015d248..ba9a09aa 100644 --- a/Game/scripts/HUDController.gd +++ b/Game/scripts/HUDController.gd @@ -19,6 +19,9 @@ class_name HUDController @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 goal_celebration = get_node_or_null("Control/GoalCelebration") +@onready var goal_tint = get_node_or_null("Control/GoalCelebration/Tint") +@onready var goal_title = get_node_or_null("Control/GoalCelebration/Center/GoalTitle") @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") @@ -30,6 +33,7 @@ class_name HUDController var ship: Node var _last_score := {0: 0, 1: 0} var _impact_flash_tween: Tween +var _goal_tween: Tween func _ready(): # Wait one frame to ensure ship is ready and added to group @@ -184,6 +188,39 @@ func flash_impact(intensity: float) -> void: _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 show_goal_celebration(scoring_team: int) -> void: + if not goal_celebration or not goal_title or not goal_tint: + return + if _goal_tween and _goal_tween.is_valid(): + _goal_tween.kill() + var tint: Color = TeamColors.TEAM_COLORS[scoring_team] + goal_tint.color = Color(tint.r, tint.g, tint.b, 0.34) + goal_title.text = "%s GOAL!" % TeamColors.TEAM_NAMES[scoring_team].to_upper() + goal_title.add_theme_color_override("font_color", tint.lightened(0.35)) + goal_title.scale = Vector2(0.55, 0.55) + goal_title.modulate.a = 0.0 + goal_celebration.visible = true + await get_tree().process_frame + if not is_instance_valid(goal_title): + return + goal_title.pivot_offset = goal_title.size / 2.0 + _goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true) + _goal_tween.tween_property(goal_title, "scale", Vector2.ONE, 0.32).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + _goal_tween.tween_property(goal_title, "modulate:a", 1.0, 0.16) + _goal_tween.tween_property(goal_tint, "color:a", 0.12, 0.7).set_delay(0.12) + + +func hide_goal_celebration() -> void: + if not goal_celebration or not is_instance_valid(goal_celebration): + return + if _goal_tween and _goal_tween.is_valid(): + _goal_tween.kill() + _goal_tween = create_tween().set_parallel(true).set_ignore_time_scale(true) + _goal_tween.tween_property(goal_title, "modulate:a", 0.0, 0.16) + _goal_tween.tween_property(goal_tint, "color:a", 0.0, 0.2) + _goal_tween.chain().tween_callback(func(): goal_celebration.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 7f4cab8c..19028952 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -20,6 +20,13 @@ var _ship_spawn_transforms := {} var _hit_stop_generation := 0 var _hit_stop_active := false var _time_scale_before_hit_stop := 1.0 +var _camera_rig: ShipCameraRig +var _goal_slowmo_active := false +var _time_scale_before_goal := 1.0 +var _goal_in_progress := false + +const GOAL_CELEBRATION_SECONDS := 1.6 +const GOAL_SLOWMO_SCALE := 0.22 # Shared by modes that keep score (Match, Spectate); Free Play never # references this or emits a score_changed signal, and HUDController relies @@ -66,16 +73,58 @@ func _on_goal_scored(_conceding_team: int) -> void: pass +# Virtual: immediate, non-presentational consequences at sensor time. Modes +# with a score/clock override this so a last-second goal is authoritative +# before the cinematic; reset/kickoff remains in _on_goal_scored afterwards. +func _on_goal_registered(_conceding_team: int) -> void: + pass + + # Debounce: a fast ball can re-trigger the goal area before the deferred # reset teleports it away, which would double-count the goal. -var _goal_cooldown := false - func _handle_goal_scored(conceding_team: int) -> void: - if _goal_cooldown: + if _goal_in_progress: return - _goal_cooldown = true - get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false) - _on_goal_scored(conceding_team) + _goal_in_progress = true + _on_goal_registered(conceding_team) + await _play_goal_celebration(1 - conceding_team, conceding_team) + await _on_goal_scored(conceding_team) + _goal_in_progress = false + + +func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void: + # Training/headless modes never spawn a camera or HUD and must not pay a + # real-time presentation delay between episodes. + if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig): + return + _restore_hit_stop() + _goal_slowmo_active = true + _time_scale_before_goal = Engine.time_scale + Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE) + var goal_position := Vector3.ZERO + for goal in arena.get_goals(): + if goal.team == conceding_team: + goal_position = goal.global_position + break + _camera_rig.begin_goal_cut(goal_position) + if hud: + hud.show_goal_celebration(scoring_team) + await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout + if is_instance_valid(_camera_rig): + _camera_rig.end_goal_cut() + if hud and is_instance_valid(hud): + hud.hide_goal_celebration() + # Defensive unwind: impact feedback is suppressed while goal slow-mo owns + # time scale, but restore any hit-stop that was already queued this frame. + _restore_hit_stop() + _restore_goal_slowmo() + + +func _restore_goal_slowmo() -> void: + if not _goal_slowmo_active: + return + Engine.time_scale = _time_scale_before_goal + _goal_slowmo_active = false func spawn_ball() -> RigidBody3D: @@ -104,6 +153,7 @@ func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = nu func spawn_camera_rig(target: Ship) -> ShipCameraRig: var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate() add_child(rig) + _camera_rig = 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 @@ -116,10 +166,13 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig: func _on_player_impact(intensity: float) -> void: if hud: hud.flash_impact(intensity) - _run_hit_stop(intensity) + if not _goal_slowmo_active: + _run_hit_stop(intensity) func _run_hit_stop(intensity: float) -> void: + if _goal_slowmo_active: + return _hit_stop_generation += 1 var generation := _hit_stop_generation if not _hit_stop_active: @@ -145,6 +198,7 @@ func _restore_hit_stop() -> void: func _exit_tree() -> void: # A scene change during the unscaled timer must never strand global time. _restore_hit_stop() + _restore_goal_slowmo() # Given an already-resolved (path, reaction_ticks, action_noise) — callers diff --git a/Game/scripts/match_mode.gd b/Game/scripts/match_mode.gd index f1d7a43b..13465e32 100644 --- a/Game/scripts/match_mode.gd +++ b/Game/scripts/match_mode.gd @@ -37,6 +37,8 @@ var match_timer: Timer var _in_overtime := false var _match_over := false var _last_timer_remaining := -1 +var _pending_scoring_team := -1 +var _match_time_after_goal := -1.0 func _get_arena_scene_path() -> String: @@ -79,16 +81,32 @@ func _process(_delta): timer_updated.emit(floori(remaining / 60.0), remaining % 60) -func _on_goal_scored(conceding_team: int) -> void: +func _on_goal_registered(conceding_team: int) -> void: if _match_over: return - var scoring_team := 1 - conceding_team - _record_goal(scoring_team) + _pending_scoring_team = 1 - conceding_team + _record_goal(_pending_scoring_team) score_changed.emit(score.duplicate()) + # Commit the score and freeze regulation time at sensor entry. The clock + # resumes after the cinematic, so a last-second goal cannot be discarded or + # reclassified as overtime while presentation is still running. + if match_timer and match_timer.time_left > 0.0: + _match_time_after_goal = match_timer.time_left + match_timer.stop() + + +func _on_goal_scored(_conceding_team: int) -> void: + if _match_over or _pending_scoring_team < 0: + return + var scoring_team := _pending_scoring_team + _pending_scoring_team = -1 if _in_overtime: # Golden goal: the first score after a draw ends the match outright. _end_match(scoring_team) return + if match_timer and _match_time_after_goal > 0.0: + match_timer.start(_match_time_after_goal) + _match_time_after_goal = -1.0 await _run_kickoff_countdown() diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index b2977acc..e762615c 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -48,6 +48,9 @@ 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(): @@ -86,6 +89,9 @@ func _physics_process(delta): _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: @@ -199,3 +205,26 @@ func _apply_shake(delta: float) -> void: _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 diff --git a/Game/scripts/spectate_mode.gd b/Game/scripts/spectate_mode.gd index 4c8ff5b3..b006229b 100644 --- a/Game/scripts/spectate_mode.gd +++ b/Game/scripts/spectate_mode.gd @@ -31,10 +31,13 @@ func _start() -> void: spawn_camera_rig(ship_a) -func _on_goal_scored(conceding_team: int) -> void: +func _on_goal_registered(conceding_team: int) -> void: var scoring_team := 1 - conceding_team _record_goal(scoring_team) score_changed.emit(score.duplicate()) + + +func _on_goal_scored(_conceding_team: int) -> void: reset_ball() reset_ships() diff --git a/TODO.md b/TODO.md index 3951e8ee..2e393d53 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,7 @@ The largest gap between this and a AAA-feeling product is presentation, not code - [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. - [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] Goal celebration sequence: goal burst, team-tinted flash/title card, dedicated camera cut and slow-motion beat after authoritative score registration and before reset/kickoff. - [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. - [ ] Custom font + a real `Theme` resource for the HUD. The procedural instruments are well-engineered, but `ThemeDB.fallback_font` at 10-13 px reads as a debug overlay.