From 1ea41718631996711a33d8965ac458e771d2fbfb Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Thu, 10 Sep 2026 17:36:03 +0300 Subject: [PATCH] Polish the bear's thank-you ending screen; add a `video` debug command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The finish screen's continue button now reads "Play Again" and starts a fresh run instead of "Continue"-ing to a lobby gate that no longer exists past the run's last fight. - The "Your Time" readout moves from the top (overlapping the bull in thankyou.ogv) to just under the clip's own baked title, restyled to match its flat dark-ink look instead of the gold HUD outline, and pulses on the same ~1.4s breathing cycle sampled off the source clip so it reads as part of the poster rather than a readout stamped on it. - Added a `video ` debug console command to preview any outcome screen without playing through a fight, via a new hud.gd debug entry point (`debug_force_outcome`). - Dropped the console's per-frame "focus stolen by ... — reclaiming" log line — its own comment flagged it as temporary diagnostic instrumentation, and it fired on every console open. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV --- systems/debug/console.gd | 30 +++++++-- ui/hud.gd | 128 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 16 deletions(-) diff --git a/systems/debug/console.gd b/systems/debug/console.gd index a1d7771..89b26bd 100644 --- a/systems/debug/console.gd +++ b/systems/debug/console.gd @@ -20,6 +20,7 @@ const _COMMANDS := { "list": "print params (optional section)", "level": "select level and reload (level — arena / arena_boss / bear)", "restart": "reload the current level scene", + "video": "preview an outcome screen (video win / loss / finish)", "clear": "clear the log", "help": "show this help", } @@ -206,12 +207,6 @@ func _input(event: InputEvent) -> void: # while the console is open — cheap, and it never lets the command line go dark. func _process(_delta: float) -> void: if _is_open and not _input_field.has_focus(): - # Diagnostic: name whatever currently holds focus before we snatch it back, so - # a persistent thief (a HUD button, a menu control) shows up in the log instead - # of us guessing. Remove once the culprit is identified. - var owner: Control = get_viewport().gui_get_focus_owner() - var who: String = String(owner.name) if owner != null else "" - _log_warn("focus stolen by %s — reclaiming" % who) _input_field.grab_focus() @@ -476,6 +471,8 @@ func _run_command(parts: PackedStringArray) -> bool: _cmd_list(parts) "level": _cmd_level(parts) + "video": + _cmd_video(parts) "restart": _log_ok("Restarting…") get_tree().paused = false @@ -608,6 +605,27 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void: _log_warn("Cannot set param of type %d" % meta["type"]) +# Jumps straight to an outcome screen (win / loss / the bear's thankyou.ogv finish clip) +# via hud.gd's debug-only entry point — a quick way to preview a clip's framing/timing +# without actually clearing (or dying to) a fight. +const _VIDEO_KINDS := ["win", "loss", "finish"] + + +func _cmd_video(parts: PackedStringArray) -> void: + if parts.size() < 2 or not _VIDEO_KINDS.has(parts[1].to_lower()): + _log_warn("Usage: video <%s>" % "|".join(_VIDEO_KINDS)) + return + var hud: Node = get_tree().get_first_node_in_group(&"hud") + if hud == null: + _log_warn("No HUD in the current scene.") + return + var kind := parts[1].to_lower() + get_tree().paused = false + _toggle() # close the console so it doesn't sit on top of the outcome screen + hud.debug_force_outcome(kind) + _log_ok("Playing '%s' outcome…" % kind) + + func _cmd_level(parts: PackedStringArray) -> void: if parts.size() == 1: var cur := Run.active_level() diff --git a/ui/hud.gd b/ui/hud.gd index 77ed3e8..db38780 100644 --- a/ui/hud.gd +++ b/ui/hud.gd @@ -3,6 +3,10 @@ extends CanvasLayer const _GOLD := Color(1.00, 0.78, 0.22) const _CREAM := Color(0.93, 0.88, 0.72) const _MUTED := Color(0.66, 0.60, 0.46) +# Sampled from thankyou.ogv's baked "THANK YOU FOR PLAYING!" title — a flat dark ink on the +# clip's sunburst poster art, no outline/glow. The run-time label mimics it so it reads as +# part of that same poster instead of a HUD readout stamped on top. +const _INK := Color(0.12, 0.08, 0.02) # Per-ability trim colour, tying each tile (and its key cap) to that ability's FX. # Order matches _ABILITY_ICONS: kick, dash, slam, roll. @@ -79,11 +83,15 @@ const _WIN_DELAY := 3.0 var _game_over: bool = false var _result_win: bool = false +# Set per-outcome in _show_game_over: the bear is the run's last fight, so its win screen +# offers a fresh run instead of a lobby gate to continue through. +var _is_final_boss: bool = false var _over_root: Control var _over_video: VideoStreamPlayer var _over_label: Label var _over_continue: Button var _over_again: Button +var _over_label_tween: Tween # the finish screen's breathing "Your Time" pulse # 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 @@ -95,6 +103,7 @@ var _run_timer_label: Label func _ready() -> void: layer = 11 process_mode = Node.PROCESS_MODE_ALWAYS + add_to_group(&"hud") # lets the debug console's `video` command find this instance _build_controls_panel() _build_tab_hint() _build_bottom_hud() @@ -387,7 +396,7 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: if _result_win: - _continue_to_lobby() + _on_win_button_pressed() else: _restart() elif event.physical_keycode == KEY_TAB and not _game_over: @@ -417,6 +426,24 @@ func _continue_to_lobby() -> void: get_tree().change_scene_to_file(Run.active_level().scene_path) +# The bear is the run's last fight — there's no further lobby gate to continue through, so +# the win screen offers a fresh run instead. Mirrors main_menu.gd's Play button. +func _play_again() -> void: + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + Run.start_new_run() + get_tree().change_scene_to_file("res://levels/game_shell.tscn") + + +# Shared by the win-screen button and its Enter-key shortcut, routing to whichever the +# current outcome needs (set in _show_game_over). +func _on_win_button_pressed() -> void: + if _is_final_boss: + _play_again() + else: + _continue_to_lobby() + + func _toggle_controls() -> void: _controls_visible = not _controls_visible _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" @@ -498,16 +525,73 @@ 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 - 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 \ + _is_final_boss = _result_win and Run.active_level().id == &"bear" + _present_outcome() + + +## Debug-only entry point for the console's `video` command — skips the fight and the +## outcome delay, jumping straight to an outcome screen so a clip can be previewed +## (`video finish` for thankyou.ogv) without clearing the bear for real. kind: one of +## "win" / "loss" / "finish". +func debug_force_outcome(kind: String) -> void: + if _controls_panel: + _controls_panel.visible = false + if _tab_hint: + _tab_hint.visible = false + _game_over = true + match kind: + "finish": + _result_win = true + _is_final_boss = true + "win": + _result_win = true + _is_final_boss = false + _: + _result_win = false + _is_final_boss = false + _present_outcome() + + +# Populates and reveals the outcome screen from _result_win / _is_final_boss, set by +# either _show_game_over (a real fight ending) or debug_force_outcome (console preview). +func _present_outcome() -> void: + # 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. Anchored as a fraction of the + # screen (not fixed pixels) at ~83% down — just under the clip's own baked title (which + # sits at ~72-81% in the source video) and clear of the bull it frames, instead of the + # shared top-of-screen spot "Bull Won!"/"Matadors Won!" use. Styled to match the baked + # title's flat dark-ink poster look rather than the gold HUD outline style. + _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). + if _is_final_boss: + _over_label.anchor_top = 0.83 + _over_label.anchor_bottom = 0.83 + _over_label.grow_vertical = Control.GROW_DIRECTION_END + _over_label.offset_top = 0.0 + _over_label.offset_bottom = 0.0 + _over_label.add_theme_font_size_override("font_size", 40) + _over_label.add_theme_color_override("font_color", _INK) + _over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.0)) + _over_label.add_theme_constant_override("outline_size", 0) + _start_over_label_pulse() + else: + _over_label.anchor_top = 0.0 + _over_label.anchor_bottom = 0.0 + _over_label.grow_vertical = Control.GROW_DIRECTION_END + _over_label.offset_top = 80.0 + _over_label.offset_bottom = 0.0 + _over_label.add_theme_font_size_override("font_size", 64) + _over_label.add_theme_color_override("font_color", _GOLD) + _over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9)) + _over_label.add_theme_constant_override("outline_size", 6) + _stop_over_label_pulse() + # A win advances the run (on to the map, or — for the bear, the run's last fight — into + # a fresh run); a loss ends it (retry from the top). + _over_continue.text = "Play Again (Enter)" if _is_final_boss else "Continue (Enter)" _over_continue.visible = _result_win _over_again.visible = not _result_win - var path := _THANKYOU_VIDEO if is_final_boss else (_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() @@ -520,6 +604,29 @@ func _show_game_over(win: bool, _cause: String = "") -> void: create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5) +# thankyou.ogv's baked title isn't static — it breathes, scaling gently in and out on a loop +# (measured off the source clip: roughly a 1.4 s in/out cycle, matching the clip's own loop +# length). The "Your Time" label mimics that same pulse so it reads as part of the clip +# rather than a HUD readout stamped over it. Scales around the label's own centre (its size +# only settles a frame after the anchor/text changes above, hence the deferred pivot read). +func _start_over_label_pulse() -> void: + _stop_over_label_pulse() + _over_label.scale = Vector2.ONE + await get_tree().process_frame + _over_label.pivot_offset = _over_label.size * 0.5 + _over_label_tween = create_tween().set_loops() + _over_label_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + _over_label_tween.tween_property(_over_label, "scale", Vector2(1.08, 1.08), 0.7) + _over_label_tween.tween_property(_over_label, "scale", Vector2(0.92, 0.92), 0.7) + + +func _stop_over_label_pulse() -> void: + if _over_label_tween: + _over_label_tween.kill() + _over_label_tween = null + _over_label.scale = Vector2.ONE + + func _build_game_over() -> void: _over_root = Control.new() _over_root.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -576,10 +683,11 @@ func _build_game_over() -> void: row.offset_bottom = -48.0 _over_root.add_child(row) - # Win → continue to the run map; loss → restart from the top. Shown per-result in - # _show_game_over; both share the Enter shortcut and label. + # Win → continue to the run map, or — beating the bear, the run's last fight — start a + # fresh run; loss → restart from the top. Text is set per-outcome in _show_game_over; + # both routes share the Enter shortcut. _over_continue = _make_button("Continue (Enter)") - _over_continue.pressed.connect(_continue_to_lobby) + _over_continue.pressed.connect(_on_win_button_pressed) row.add_child(_over_continue) _over_again = _make_button("Continue (Enter)")