feat(goals): add cinematic celebration sequence

This commit is contained in:
Josh Creek
2026-08-07 22:45:33 +01:00
parent 28eb45338a
commit 8bd65290fc
7 changed files with 190 additions and 12 deletions
+61 -7
View File
@@ -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