mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 20:32:07 +00:00
feat(goals): add cinematic celebration sequence
This commit is contained in:
@@ -129,6 +129,43 @@ grow_vertical = 2
|
|||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
color = Color(0.72, 0.9, 1, 0)
|
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"]
|
[node name="ResultOverlay" type="Control" parent="Control"]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class_name HUDController
|
|||||||
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/ResultLabel")
|
@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 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 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 heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
|
||||||
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
|
@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 ship: Node
|
||||||
var _last_score := {0: 0, 1: 0}
|
var _last_score := {0: 0, 1: 0}
|
||||||
var _impact_flash_tween: Tween
|
var _impact_flash_tween: Tween
|
||||||
|
var _goal_tween: Tween
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Wait one frame to ensure ship is ready and added to group
|
# 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_property(impact_flash, "color:a", 0.0, lerpf(0.08, 0.18, intensity))
|
||||||
_impact_flash_tween.tween_callback(func(): impact_flash.visible = false)
|
_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():
|
func _on_overtime_started():
|
||||||
if timer_label and is_instance_valid(timer_label):
|
if timer_label and is_instance_valid(timer_label):
|
||||||
timer_label.text = "OT"
|
timer_label.text = "OT"
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ var _ship_spawn_transforms := {}
|
|||||||
var _hit_stop_generation := 0
|
var _hit_stop_generation := 0
|
||||||
var _hit_stop_active := false
|
var _hit_stop_active := false
|
||||||
var _time_scale_before_hit_stop := 1.0
|
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
|
# Shared by modes that keep score (Match, Spectate); Free Play never
|
||||||
# references this or emits a score_changed signal, and HUDController relies
|
# references this or emits a score_changed signal, and HUDController relies
|
||||||
@@ -66,16 +73,58 @@ func _on_goal_scored(_conceding_team: int) -> void:
|
|||||||
pass
|
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
|
# Debounce: a fast ball can re-trigger the goal area before the deferred
|
||||||
# reset teleports it away, which would double-count the goal.
|
# reset teleports it away, which would double-count the goal.
|
||||||
var _goal_cooldown := false
|
|
||||||
|
|
||||||
func _handle_goal_scored(conceding_team: int) -> void:
|
func _handle_goal_scored(conceding_team: int) -> void:
|
||||||
if _goal_cooldown:
|
if _goal_in_progress:
|
||||||
return
|
return
|
||||||
_goal_cooldown = true
|
_goal_in_progress = true
|
||||||
get_tree().create_timer(0.5).timeout.connect(func(): _goal_cooldown = false)
|
_on_goal_registered(conceding_team)
|
||||||
_on_goal_scored(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:
|
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:
|
func spawn_camera_rig(target: Ship) -> ShipCameraRig:
|
||||||
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
var rig: ShipCameraRig = CAMERA_RIG_SCENE.instantiate()
|
||||||
add_child(rig)
|
add_child(rig)
|
||||||
|
_camera_rig = rig
|
||||||
rig.target = target
|
rig.target = target
|
||||||
rig.impact_feedback.connect(_on_player_impact)
|
rig.impact_feedback.connect(_on_player_impact)
|
||||||
# Also wires the scene's static HUD (if any) to the same ship, rather
|
# 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:
|
func _on_player_impact(intensity: float) -> void:
|
||||||
if hud:
|
if hud:
|
||||||
hud.flash_impact(intensity)
|
hud.flash_impact(intensity)
|
||||||
_run_hit_stop(intensity)
|
if not _goal_slowmo_active:
|
||||||
|
_run_hit_stop(intensity)
|
||||||
|
|
||||||
|
|
||||||
func _run_hit_stop(intensity: float) -> void:
|
func _run_hit_stop(intensity: float) -> void:
|
||||||
|
if _goal_slowmo_active:
|
||||||
|
return
|
||||||
_hit_stop_generation += 1
|
_hit_stop_generation += 1
|
||||||
var generation := _hit_stop_generation
|
var generation := _hit_stop_generation
|
||||||
if not _hit_stop_active:
|
if not _hit_stop_active:
|
||||||
@@ -145,6 +198,7 @@ func _restore_hit_stop() -> void:
|
|||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
# A scene change during the unscaled timer must never strand global time.
|
# A scene change during the unscaled timer must never strand global time.
|
||||||
_restore_hit_stop()
|
_restore_hit_stop()
|
||||||
|
_restore_goal_slowmo()
|
||||||
|
|
||||||
|
|
||||||
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ var match_timer: Timer
|
|||||||
var _in_overtime := false
|
var _in_overtime := false
|
||||||
var _match_over := false
|
var _match_over := false
|
||||||
var _last_timer_remaining := -1
|
var _last_timer_remaining := -1
|
||||||
|
var _pending_scoring_team := -1
|
||||||
|
var _match_time_after_goal := -1.0
|
||||||
|
|
||||||
|
|
||||||
func _get_arena_scene_path() -> String:
|
func _get_arena_scene_path() -> String:
|
||||||
@@ -79,16 +81,32 @@ func _process(_delta):
|
|||||||
timer_updated.emit(floori(remaining / 60.0), remaining % 60)
|
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:
|
if _match_over:
|
||||||
return
|
return
|
||||||
var scoring_team := 1 - conceding_team
|
_pending_scoring_team = 1 - conceding_team
|
||||||
_record_goal(scoring_team)
|
_record_goal(_pending_scoring_team)
|
||||||
score_changed.emit(score.duplicate())
|
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:
|
if _in_overtime:
|
||||||
# Golden goal: the first score after a draw ends the match outright.
|
# Golden goal: the first score after a draw ends the match outright.
|
||||||
_end_match(scoring_team)
|
_end_match(scoring_team)
|
||||||
return
|
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()
|
await _run_kickoff_countdown()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ var _shake_strength := 0.0
|
|||||||
var _shake_noise := FastNoiseLite.new()
|
var _shake_noise := FastNoiseLite.new()
|
||||||
var _shake_time := 0.0
|
var _shake_time := 0.0
|
||||||
var _last_shake_offset := Vector3.ZERO
|
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():
|
func _ready():
|
||||||
@@ -86,6 +89,9 @@ func _physics_process(delta):
|
|||||||
_last_shake_offset = Vector3.ZERO
|
_last_shake_offset = Vector3.ZERO
|
||||||
if not is_instance_valid(target):
|
if not is_instance_valid(target):
|
||||||
return
|
return
|
||||||
|
if _goal_cut_active:
|
||||||
|
_smooth_look_at(_goal_cut_look_at, delta)
|
||||||
|
return
|
||||||
_update_speed_feel(delta)
|
_update_speed_feel(delta)
|
||||||
var ball := _get_ball()
|
var ball := _get_ball()
|
||||||
if ball_cam_enabled and 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
|
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
|
||||||
camera.global_position += _last_shake_offset
|
camera.global_position += _last_shake_offset
|
||||||
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
|
_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
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ func _start() -> void:
|
|||||||
spawn_camera_rig(ship_a)
|
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
|
var scoring_team := 1 - conceding_team
|
||||||
_record_goal(scoring_team)
|
_record_goal(scoring_team)
|
||||||
score_changed.emit(score.duplicate())
|
score_changed.emit(score.duplicate())
|
||||||
|
|
||||||
|
|
||||||
|
func _on_goal_scored(_conceding_team: int) -> void:
|
||||||
reset_ball()
|
reset_ball()
|
||||||
reset_ships()
|
reset_ships()
|
||||||
|
|
||||||
|
|||||||
@@ -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] 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] 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.
|
- [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] 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.
|
- [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.
|
- [ ] 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user