Add thank-you ending screen and a run timer
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled

Defeating the bear (the run's final boss) now plays a dedicated
"Thank You For Playing" clip on the completion screen instead of the
generic bull-win video, with the run's finish time shown in place of
the usual "Bull Won!" title.

A run clock also starts when the player enters the arena (the run's
first fight), shown top-left on desktop only, and freezes the instant
the bear falls. Re-entering the arena after finishing a run restarts
the clock instead of leaving it stuck on the previous run's time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
This commit is contained in:
2026-09-10 16:28:57 +03:00
parent 2fd41f4f69
commit 6dff45c659
5 changed files with 103 additions and 2 deletions
+47
View File
@@ -33,6 +33,14 @@ var cleared: Dictionary = {}
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP. ## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
var bonus_hp: int = 0 var bonus_hp: int = 0
const BONUS_HP_CAP: int = 2 const BONUS_HP_CAP: int = 2
## Wall-clock run timer: starts the moment the player walks through the arena gate (the
## run's first fight) and freezes the instant the bear falls, so the completion screen can
## show a final time. Time.get_ticks_msec()-based — real time, not delta-summed — so it
## keeps ticking correctly across the scene reload between the arena and the bear's den.
var _timer_start_msec: int = -1
var _timer_end_msec: int = -1
var _timer_running: bool = false
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run ## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
## when the shell reloads for each fight. ## when the shell reloads for each fight.
var _run_active: bool = false var _run_active: bool = false
@@ -105,6 +113,9 @@ func start_new_run() -> void:
lobby_choice = null lobby_choice = null
cleared = {} cleared = {}
bonus_hp = 0 bonus_hp = 0
_timer_start_msec = -1
_timer_end_msec = -1
_timer_running = false
_run_active = true _run_active = true
run_started.emit() run_started.emit()
@@ -154,3 +165,39 @@ func add_bonus_hp(amount: int) -> int:
var before := bonus_hp var before := bonus_hp
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP) bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
return bonus_hp - before return bonus_hp - before
## Begin the run clock. No-op while the clock is already running, so walking back through
## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead
## and the player has looped back to the arena to go again), the next call restarts it from
## zero rather than staying stuck showing the previous run's final time.
func start_run_timer() -> void:
if _timer_running:
return
_timer_start_msec = Time.get_ticks_msec()
_timer_end_msec = -1
_timer_running = true
## Freeze the run clock — called the instant the bear falls, so the recorded time is the
## kill moment itself, not whatever the win-screen's celebratory delay adds on top.
func stop_run_timer() -> void:
if not _timer_running:
return
_timer_end_msec = Time.get_ticks_msec()
_timer_running = false
## Seconds elapsed since the timer started (live while running, held steady once
## stop_run_timer() has been called). 0 before the timer has ever started.
func run_elapsed() -> float:
if _timer_start_msec < 0:
return 0.0
var end_msec := Time.get_ticks_msec() if _timer_running else _timer_end_msec
return float(end_msec - _timer_start_msec) / 1000.0
## Whether the run clock has been started (the player has entered the arena at least once
## this run). Lets the HUD hide the timer readout until there's a time worth showing.
func run_timer_started() -> bool:
return _timer_start_msec >= 0
+1
View File
@@ -8,5 +8,6 @@ func _ready() -> void:
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"): if not body.is_in_group("player"):
return return
Run.start_run_timer()
Run.choose_level(&"arena") Run.choose_level(&"arena")
get_tree().change_scene_to_file(Run.active_level().scene_path) get_tree().change_scene_to_file(Run.active_level().scene_path)
+54 -2
View File
@@ -66,6 +66,10 @@ const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within
# records which way it went (read by the gameplay test). # records which way it went (read by the gameplay test).
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
# Clearing the bear — the run's final boss — replaces the usual bull-win clip with the
# closing "Thank You For Playing" screen. That clip already bakes its own title text in,
# so _over_label is left blank for this outcome (see _show_game_over).
const _THANKYOU_VIDEO := "res://videos/thankyou.ogv"
# Beat between the run ending and the outcome screen. A loss is a short freeze on the # Beat between the run ending and the outcome screen. A loss is a short freeze on the
# dead bull; a win gives the player a longer stretch to strut a victory lap around the # dead bull; a win gives the player a longer stretch to strut a victory lap around the
@@ -81,6 +85,12 @@ var _over_label: Label
var _over_continue: Button var _over_continue: Button
var _over_again: Button var _over_again: Button
# Run clock readout — top-left, desktop only (touch's top-left corner is already the
# mobile HP readout from mobile_hud.gd). Tracks Run.run_elapsed(); hidden until the timer
# has actually started (the player hasn't entered the arena yet) and while the game-over
# screen is up.
var _run_timer_label: Label
func _ready() -> void: func _ready() -> void:
layer = 11 layer = 11
@@ -92,6 +102,7 @@ func _ready() -> void:
_build_ability_bar() _build_ability_bar()
_build_health() _build_health()
_build_version_label() _build_version_label()
_build_run_timer()
_build_damage_vignette() _build_damage_vignette()
_build_game_over() _build_game_over()
_build_touch_controls() _build_touch_controls()
@@ -206,8 +217,41 @@ func _build_version_label() -> void:
add_child(lbl) add_child(lbl)
# Run clock, top-left. Desktop only — touch already owns that corner for the mobile HP
# readout, and a run timer is a nice-to-have there, not worth the crowding.
func _build_run_timer() -> void:
if Controls.use_touch_ui():
return
_run_timer_label = Label.new()
_run_timer_label.text = _format_time(0.0)
_run_timer_label.visible = false
_run_timer_label.add_theme_font_override("font", UiFonts.body())
_run_timer_label.add_theme_color_override("font_color", _CREAM)
_run_timer_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_run_timer_label.add_theme_constant_override("outline_size", 4)
_run_timer_label.add_theme_font_size_override("font_size", 20)
_run_timer_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_run_timer_label.offset_left = 20.0
_run_timer_label.offset_top = 16.0
_run_timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_run_timer_label)
# mm:ss.hh — matches how a speedrun-style clock reads at a glance.
static func _format_time(seconds: float) -> String:
var total_cs := int(round(seconds * 100.0))
var minutes := total_cs / 6000
var secs := (total_cs / 100) % 60
var centis := total_cs % 100
return "%02d:%02d.%02d" % [minutes, secs, centis]
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_control_hints() _update_control_hints()
if _run_timer_label != null:
_run_timer_label.visible = Run.run_timer_started() and not _game_over
if _run_timer_label.visible:
_run_timer_label.text = _format_time(Run.run_elapsed())
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
@@ -434,6 +478,10 @@ func _on_player_died(cause: String = "") -> void:
func _on_all_defeated() -> void: func _on_all_defeated() -> void:
# Freeze the run clock the instant the bear falls — before _show_game_over's
# celebratory delay — so the recorded time is the kill moment, not the victory lap.
if Run.active_level().id == &"bear":
Run.stop_run_timer()
_show_game_over(true) _show_game_over(true)
@@ -450,12 +498,16 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
_over_label.text = "Bull Won!" if _result_win else "Matadors Won!" var is_final_boss := _result_win and Run.active_level().id == &"bear"
# The thank-you clip already bakes in its own title, so this label carries the
# winning time instead — the one thing that clip can't show.
_over_label.text = ("Your Time: %s" % _format_time(Run.run_elapsed())) if is_final_boss \
else ("Bull Won!" if _result_win else "Matadors Won!")
# A win advances the run (on to the map); a loss ends it (retry from the top). # A win advances the run (on to the map); a loss ends it (retry from the top).
_over_continue.visible = _result_win _over_continue.visible = _result_win
_over_again.visible = not _result_win _over_again.visible = not _result_win
var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO var path := _THANKYOU_VIDEO if is_final_boss else (_WIN_VIDEO if _result_win else _LOSS_VIDEO)
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
_over_video.stream = load(path) _over_video.stream = load(path)
_over_video.play() _over_video.play()
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
uid://b5rupuoe1lk5j