diff --git a/levels/run_state.gd b/levels/run_state.gd index f5fe743..4a562e3 100644 --- a/levels/run_state.gd +++ b/levels/run_state.gd @@ -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. var bonus_hp: int = 0 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 ## when the shell reloads for each fight. var _run_active: bool = false @@ -105,6 +113,9 @@ func start_new_run() -> void: lobby_choice = null cleared = {} bonus_hp = 0 + _timer_start_msec = -1 + _timer_end_msec = -1 + _timer_running = false _run_active = true run_started.emit() @@ -154,3 +165,39 @@ func add_bonus_hp(amount: int) -> int: var before := bonus_hp bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP) 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 diff --git a/levels/to_the_arena_level.gd b/levels/to_the_arena_level.gd index 6b4152e..ae8960f 100644 --- a/levels/to_the_arena_level.gd +++ b/levels/to_the_arena_level.gd @@ -8,5 +8,6 @@ func _ready() -> void: func _on_body_entered(body: Node3D) -> void: if not body.is_in_group("player"): return + Run.start_run_timer() Run.choose_level(&"arena") get_tree().change_scene_to_file(Run.active_level().scene_path) diff --git a/ui/hud.gd b/ui/hud.gd index 98787fb..77ed3e8 100644 --- a/ui/hud.gd +++ b/ui/hud.gd @@ -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). const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull 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 # 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_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: layer = 11 @@ -92,6 +102,7 @@ func _ready() -> void: _build_ability_bar() _build_health() _build_version_label() + _build_run_timer() _build_damage_vignette() _build_game_over() _build_touch_controls() @@ -206,8 +217,41 @@ func _build_version_label() -> void: 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: _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: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): @@ -434,6 +478,10 @@ func _on_player_died(cause: String = "") -> 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) @@ -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 - _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). _over_continue.visible = _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): _over_video.stream = load(path) _over_video.play() diff --git a/videos/thankyou.ogv b/videos/thankyou.ogv new file mode 100644 index 0000000..e920a5c Binary files /dev/null and b/videos/thankyou.ogv differ diff --git a/videos/thankyou.ogv.uid b/videos/thankyou.ogv.uid new file mode 100644 index 0000000..a38fb5c --- /dev/null +++ b/videos/thankyou.ogv.uid @@ -0,0 +1 @@ +uid://b5rupuoe1lk5j