Files
Bullosseum/ui/hud.gd
T
richard a34989c49e Show Xbox-style A/B/X/Y glyphs on the ability bar for a gamepad
Controls now tracks whether the player's last deliberate press was a
gamepad button/stick or a keyboard key/mouse click (ignoring plain
mouse motion, since the mouse still drives the camera regardless of
which device moves the bull). The HUD's ability-bar keycaps read from
that: keyboard letters normally, Xbox-style face-button glyphs while
a gamepad is in use, swapping live the moment the player switches
devices.

Godot's joypad button indices are already Xbox-position-normalized
(by SDL natively, by the browser's Standard Gamepad Mapping on web),
so the glyph mapping (button 0/1/2/3 -> A/B/X/Y) reads correctly for
any controller brand with no per-device special-casing.

Verified headlessly: labels start as the bound keyboard letters
(F/G/H/J), switch to X/A/B/Y on a synthetic joypad button press
(matching project.godot's kick/dash/slam/roll bindings), and switch
back on a keyboard press.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:20:54 +03:00

1070 lines
42 KiB
GDScript

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.
const _ABILITY_ACCENT: Array[Color] = [
Color(1.00, 0.62, 0.16), # Kick — ember orange
Color(0.45, 0.75, 1.00), # Dash — sky blue
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(1.00, 0.82, 0.32), # Roll — gold
]
const _ABILITY_ICONS: Array[String] = [
"res://art/icons/kick.png",
"res://art/icons/dash.png",
"res://art/icons/slam.png",
"res://art/icons/roll.png",
]
const _ABILITY_ACTIONS: Array[StringName] = [
&"ability_kick", &"ability_dash", &"ability_slam", &"ability_roll",
]
# Fallback label shown in a slot whose icon file fails to load.
const _ABILITY_NAMES: Array[String] = ["Kick", "Dash", "Slam", "Roll"]
var _controls_visible: bool = false
var _controls_panel: PanelContainer
var _toggle_btn: Button
var _tab_hint: Label
var _spawner: Node
var _player: Node = null
var _died_connected: bool = false
var _cd_overlays: Array[ColorRect] = []
var _cd_labels: Array[Label] = []
var _hp_label: Label
var _hp_segments: HBoxContainer
var _hp_seg_fills: Array[ColorRect] = []
var _bottom_col: VBoxContainer
var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = []
var _ability_key_labels: Array[Label] = []
var _bull_bg: TextureRect
# Cached so the ability keycaps only get rewritten on an actual keyboard<->gamepad switch,
# not every frame.
var _showing_gamepad_hints: bool = false
# Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts,
# throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
# fires once per lobby visit.
var _barrels_remaining: int = 0
var _barrel_reward_given: bool = false
const _BARREL_BONUS_HP: int = 2
# Red damage vignette — a full-screen shader ColorRect flashed on every hit.
const _VIGNETTE_SHADER := "res://ui/damage_vignette.gdshader"
var _vignette: ColorRect
var _vignette_mat: ShaderMaterial
# Bull-head backdrop for the gameplay HUD. The image is a wide horned silhouette; its
# solid "face" occupies the centred middle band, with horns flaring above. We size the
# image so that face band wraps the ability+HP cluster (horns crowning it above).
const _BULL_BG := "res://art/ui/bull_hud_bg.png"
const _BULL_FACE_W: float = 0.529 # face width as a fraction of image width
const _BULL_ASPECT: float = 0.2741 # image height / width (267 / 974)
const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within the face
# Win/lose screen. _game_over latches so the result is shown once; _result_win
# 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
# arena before the screen (and the pause) takes over.
const _LOSS_DELAY := 2.0
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
# 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
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()
_build_bull_hud_bg()
_build_ability_bar()
_build_health()
_build_version_label()
_build_run_timer()
_build_damage_vignette()
_build_game_over()
_build_touch_controls()
_build_mobile_hud()
_update_control_hints()
_find_spawner.call_deferred()
_watch_barrels.call_deferred()
# On-screen joystick + ability buttons for touch devices. Self-gating: the overlay
# only shows itself when a touchscreen is present (or DP force_touch_controls is on),
# so this is a no-op on desktop.
func _build_touch_controls() -> void:
add_child((load("res://systems/input/touch_controls.gd") as Script).new())
# Mobile-only health readouts (bull pips top-left + bear boss bar top-centre). On touch the
# bottom pip cluster is hidden (see _build_health), so this is the only health display there;
# on desktop it's never created.
func _build_mobile_hud() -> void:
if Controls.use_touch_ui():
add_child((load("res://ui/mobile_hud.gd") as Script).new())
# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health
# directly beneath) so both read as one cluster instead of scattered corners.
func _build_bottom_hud() -> void:
_bottom_col = VBoxContainer.new()
_bottom_col.add_theme_constant_override("separation", 8)
_bottom_col.alignment = BoxContainer.ALIGNMENT_END
# Collapsed horizontal anchors so the column shrinks to its content and stays
# bottom-centred; the bull backdrop is sized against this content rect.
_bottom_col.anchor_left = 0.5
_bottom_col.anchor_right = 0.5
_bottom_col.anchor_top = 1.0
_bottom_col.anchor_bottom = 1.0
_bottom_col.grow_horizontal = Control.GROW_DIRECTION_BOTH
_bottom_col.grow_vertical = Control.GROW_DIRECTION_BEGIN
_bottom_col.offset_bottom = -16.0
_bottom_col.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_bottom_col)
# Bull-head plate that sits behind the ability + health cluster. It's a sibling of the
# cluster (not a parent) so it never constrains the cluster's layout; we size and place
# it against the cluster's rect in _position_bull_bg.
# Skipped on touch/phone: the on-screen thumb cluster already crowds the bottom edge, so
# the wide horned plate just adds clutter there.
func _build_bull_hud_bg() -> void:
if Controls.use_touch_ui():
return
_bull_bg = TextureRect.new()
if ResourceLoader.exists(_BULL_BG):
_bull_bg.texture = load(_BULL_BG)
# We size the rect ourselves to the image's aspect, so STRETCH_SCALE fills it
# exactly with no letterboxing.
_bull_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_bull_bg.stretch_mode = TextureRect.STRETCH_SCALE
_bull_bg.modulate.a = 0.95
_bull_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_bull_bg)
# Draw the plate behind the cluster.
move_child(_bull_bg, _bottom_col.get_index())
_bottom_col.resized.connect(_position_bull_bg)
get_viewport().size_changed.connect(_position_bull_bg)
_position_bull_bg.call_deferred()
# Wrap the bull's solid "face" band around the current cluster rect: scale the image up
# so its face spans the cluster (plus padding), centre it horizontally on the cluster,
# and pin its bottom edge to the bottom of the screen so the plate is always seated flush
# against the screen edge on any resolution (horns crowning the cluster above).
func _position_bull_bg() -> void:
if _bull_bg == null or _bottom_col == null:
return
var r := _bottom_col.get_global_rect()
if r.size.x <= 1.0:
return
var vp_h := get_viewport().get_visible_rect().size.y
var bw := (r.size.x * _BULL_FACE_PAD) / _BULL_FACE_W
var bh := bw * _BULL_ASPECT
_bull_bg.size = Vector2(bw, bh)
_bull_bg.position = Vector2(
r.position.x + r.size.x * 0.5 - bw * 0.5,
vp_h - bh)
# Early-build tag. Bottom-right on desktop; on touch it moves to the bottom centre, clear of
# the ability cluster + joystick that own the bottom corners, and reads with an outline.
func _build_version_label() -> void:
var lbl := Label.new()
lbl.text = GameVersion.full()
lbl.add_theme_color_override("font_color", Color(0.90, 0.86, 0.72))
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
lbl.add_theme_constant_override("outline_size", 4)
lbl.add_theme_font_size_override("font_size", 16)
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
lbl.offset_bottom = -10.0
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
if Controls.use_touch_ui():
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.anchor_left = 0.5
lbl.anchor_right = 0.5
lbl.anchor_top = 1.0
lbl.anchor_bottom = 1.0
lbl.grow_horizontal = Control.GROW_DIRECTION_BOTH
else:
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lbl.offset_right = -20.0
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()
_update_ability_key_labels()
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():
_player = players[0]
return
if not _died_connected:
_died_connected = true
_player.died.connect(_on_player_died)
_player.health_changed.connect(_on_health_changed)
_player.hit_taken.connect(_on_hit_taken)
_on_health_changed(_player.call(&"get_hp"), _player.call(&"get_max_hp"))
for i: int in _cd_overlays.size():
var fraction: float = _player.call(&"ability_cooldown_fraction", i)
var ov := _cd_overlays[i]
ov.anchor_top = 1.0 - fraction
ov.anchor_bottom = 1.0
var remaining: float = _player.call(&"ability_cooldown_remaining", i)
var lbl := _cd_labels[i]
if remaining > 0.0:
lbl.text = "%.1fs" % remaining
lbl.visible = true
else:
lbl.visible = false
func _find_spawner() -> void:
var spawners := get_tree().get_nodes_in_group(&"matador_spawn")
if spawners.is_empty():
return
_spawner = spawners[0]
_spawner.all_defeated.connect(_on_all_defeated)
# ── Lobby barrel-smash reward ───────────────────────────────────────────────────
# Tally the barrels the current level loaded with and listen for each one bursting. Only
# the lobby has barrels, so on every other level this simply finds none and stands down.
func _watch_barrels() -> void:
var barrels := get_tree().get_nodes_in_group(&"barrel")
_barrels_remaining = barrels.size()
if _barrels_remaining == 0:
return
for barrel: Node in barrels:
barrel.destroyed.connect(_on_barrel_destroyed)
func _on_barrel_destroyed() -> void:
_barrels_remaining -= 1
if _barrels_remaining > 0 or _barrel_reward_given:
return
_barrel_reward_given = true
# Bank the reward run-wide (capped at +2). Only heal the bull for the pips that were
# actually added — if it already carried the cap over from an earlier lobby, it keeps
# the cheer but no new health.
var granted := Run.add_bonus_hp(_BARREL_BONUS_HP)
if granted > 0 and _player != null and _player.has_method(&"grant_bonus_hp"):
_player.call(&"grant_bonus_hp", granted)
_celebrate()
# The "cleared the lobby" flourish: a burst of confetti and a big springy "WOooOoW!".
func _celebrate() -> void:
_spawn_confetti()
_show_wow()
func _show_wow() -> void:
var wow := Label.new()
wow.text = "WOooOoW!"
wow.set_anchors_preset(Control.PRESET_CENTER)
wow.grow_horizontal = Control.GROW_DIRECTION_BOTH
wow.grow_vertical = Control.GROW_DIRECTION_BOTH
wow.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wow.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
wow.mouse_filter = Control.MOUSE_FILTER_IGNORE
wow.add_theme_font_override("font", UiFonts.title())
wow.add_theme_font_size_override("font_size", 96)
wow.add_theme_color_override("font_color", _HP_BONUS)
wow.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
wow.add_theme_constant_override("outline_size", 8)
wow.pivot_offset = Vector2.ZERO
wow.scale = Vector2(0.2, 0.2)
add_child(wow)
# Keep the pop growing from the label's centre once it has a real size.
await get_tree().process_frame
wow.pivot_offset = wow.size * 0.5
var tw := create_tween()
tw.tween_property(wow, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_BACK).set_ease(
Tween.EASE_OUT)
tw.tween_interval(1.1)
tw.parallel().tween_property(wow, "modulate:a", 0.0, 0.6)
tw.parallel().tween_property(wow, "position:y", wow.position.y - 40.0, 0.6)
tw.tween_callback(wow.queue_free)
func _spawn_confetti() -> void:
const _CONFETTI_COLORS: Array[Color] = [
Color(0.98, 0.24, 0.28), # red
Color(1.00, 0.78, 0.20), # gold
Color(0.30, 0.80, 0.40), # green
Color(0.30, 0.62, 1.00), # blue
Color(0.85, 0.40, 0.95), # violet
Color(1.00, 1.00, 1.00), # white
]
var vp := get_viewport().get_visible_rect().size
# One emitter per colour, raining down across the top of the screen.
for col: Color in _CONFETTI_COLORS:
var p := CPUParticles2D.new()
p.position = Vector2(vp.x * 0.5, -20.0)
p.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
p.emission_rect_extents = Vector2(vp.x * 0.5, 6.0)
p.amount = 40
p.lifetime = 2.6
p.one_shot = true
p.explosiveness = 0.35
p.direction = Vector2(0.0, 1.0)
p.spread = 25.0
p.gravity = Vector2(0.0, 420.0)
p.initial_velocity_min = 120.0
p.initial_velocity_max = 260.0
p.angular_velocity_min = -360.0
p.angular_velocity_max = 360.0
p.scale_amount_min = 4.0
p.scale_amount_max = 8.0
p.color = col
add_child(p)
# Self-clean once the burst has fully fallen.
get_tree().create_timer(p.lifetime + 0.5).timeout.connect(p.queue_free)
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:
_on_win_button_pressed()
else:
_restart()
elif event.physical_keycode == KEY_TAB and not _game_over:
_toggle_controls()
elif event.physical_keycode == KEY_ESCAPE:
_return_to_menu()
func _return_to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file("res://ui/main_menu.tscn")
func _restart() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
get_tree().reload_current_scene()
# A cleared level drops the player back into the lobby hub: bank the win (which opens the
# gates it unlocks) and reload the shell so it rebuilds the lobby.
func _continue_to_lobby() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
Run.return_to_lobby()
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"
_update_control_hints()
# The Tab hint and the keyboard controls plaque only make sense with a keyboard, so
# they stand down on touch devices — where touch_controls.gd shows the on-screen
# joystick and ability buttons instead. Shares Controls.use_touch_ui() with that
# overlay so exactly one control scheme shows on any given device.
func _update_control_hints() -> void:
var touch := Controls.use_touch_ui()
# The desktop tiles carry keyboard keycaps that mean nothing on a phone, and their
# STOP-filtered panels would sit over the joystick zone eating touches — touch_controls
# shows its own ability buttons instead, so drop the desktop bar on touch devices.
if _ability_bar != null:
_ability_bar.visible = not touch
if _game_over or touch:
_controls_panel.visible = false
_tab_hint.visible = false
else:
_controls_panel.visible = _controls_visible
_tab_hint.visible = not _controls_visible
# Swaps the ability-bar keycaps between keyboard letters and Xbox-style face-button glyphs
# (A/B/X/Y) as the player switches between keyboard and gamepad. Only rewrites the labels on
# an actual switch, not every frame — Controls.using_gamepad() itself is cheap either way.
func _update_ability_key_labels() -> void:
var gamepad := Controls.using_gamepad()
if gamepad == _showing_gamepad_hints:
return
_showing_gamepad_hints = gamepad
for i in _ability_key_labels.size():
_ability_key_labels[i].text = Controls.ability_key_label(_ABILITY_ACTIONS[i])
# ── Damage vignette ───────────────────────────────────────────────────────────
# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red
# toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built
# before the game-over layer so the outcome screen always draws over it.
func _build_damage_vignette() -> void:
_vignette = ColorRect.new()
_vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
_vignette.color = Color.WHITE # the shader writes COLOR outright; this is just a base
_vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
if ResourceLoader.exists(_VIGNETTE_SHADER):
_vignette_mat = ShaderMaterial.new()
_vignette_mat.shader = load(_VIGNETTE_SHADER)
_vignette_mat.set_shader_parameter("flash", 0.0)
_vignette.material = _vignette_mat
add_child(_vignette)
func _on_hit_taken(_cause: String) -> void:
if _vignette_mat == null:
return
var set_flash := func(v: float) -> void:
_vignette_mat.set_shader_parameter("flash", v)
create_tween().tween_method(set_flash, 0.9, 0.0, 0.5)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
# ── Win / lose ──────────────────────────────────────────────────────────────
# The bull takes one clean blade and it's over; clear the last matador and it's a
# victory. Both routes end here — the arena freezes and the result is proclaimed.
func _on_player_died(cause: String = "") -> void:
_show_game_over(false, cause)
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)
func _show_game_over(win: bool, _cause: String = "") -> void:
if _game_over:
return
_game_over = true
_result_win = win
if _controls_panel:
_controls_panel.visible = false
if _tab_hint:
_tab_hint.visible = false
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
_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!")
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
# Nothing has focus by default when this screen appears, so a gamepad's ui_accept/ui_left/
# ui_right (bound to Godot's built-in ui_* actions) would have nothing to act on — grab it
# on the default button, same as main_menu.gd does for its own panels. Geometry-based focus
# neighbors (Godot's default) then let ui_left/ui_right reach Main Menu from here.
(_over_continue if _result_win else _over_again).grab_focus()
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()
_over_root.visible = true
_over_root.modulate.a = 0.0
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().paused = true
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)
_over_root.process_mode = Node.PROCESS_MODE_ALWAYS
_over_root.visible = false
add_child(_over_root)
# Full-screen outcome clip (bull win / matador win), looping.
# AspectRatioContainer keeps the 16:9 video's ratio and crops overflow (COVER),
# so it fills any screen shape without stretching or leaving dead bars.
var arc := AspectRatioContainer.new()
arc.set_anchors_preset(Control.PRESET_FULL_RECT)
arc.ratio = 16.0 / 9.0
arc.stretch_mode = AspectRatioContainer.STRETCH_COVER
arc.alignment_horizontal = AspectRatioContainer.ALIGNMENT_CENTER
arc.alignment_vertical = AspectRatioContainer.ALIGNMENT_CENTER
arc.process_mode = Node.PROCESS_MODE_ALWAYS
arc.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_root.add_child(arc)
_over_video = VideoStreamPlayer.new()
_over_video.set_anchors_preset(Control.PRESET_FULL_RECT)
_over_video.expand = true
_over_video.process_mode = Node.PROCESS_MODE_ALWAYS
_over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_video.finished.connect(_over_video.play) # loop the outcome clip
arc.add_child(_over_video)
# Outcome text centred over the video.
_over_label = Label.new()
_over_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
_over_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_over_label.grow_vertical = Control.GROW_DIRECTION_END
_over_label.offset_top = 80.0
_over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_over_label.add_theme_font_override("font", UiFonts.title())
_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)
_over_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_root.add_child(_over_label)
# Buttons overlaid at the bottom of the clip.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 32 if Controls.use_touch_ui() else 16)
row.anchor_left = 0.5
row.anchor_right = 0.5
row.anchor_top = 1.0
row.anchor_bottom = 1.0
row.grow_horizontal = Control.GROW_DIRECTION_BOTH
row.grow_vertical = Control.GROW_DIRECTION_BEGIN
row.offset_bottom = -48.0
_over_root.add_child(row)
# 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(_on_win_button_pressed)
row.add_child(_over_continue)
_over_again = _make_button("Continue (Enter)")
_over_again.pressed.connect(_restart)
row.add_child(_over_again)
var menu := _make_button("Main Menu")
menu.pressed.connect(_return_to_menu)
row.add_child(menu)
# Thumbs need a far bigger target than a mouse pointer, and the outcome clip has room
# for it — grow the whole row on touch only.
for btn: Button in [_over_continue, _over_again, menu]:
_grow_for_touch(btn)
# ── Bull health ───────────────────────────────────────────────────────────────
# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or
# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward
# crimson as the bull is worn down; spent pips read as empty dark sockets.
const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05)
const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
# Bonus pips (barrel-smash reward) read as bright gold-yellow so they stand apart from the
# red life bar.
const _HP_BONUS: Color = Color(1.00, 0.84, 0.18)
const _HP_PIP_W: float = 22.0
const _HP_PIP_H: float = 14.0
func _build_health() -> void:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
# On touch the bottom edge is crowded by the joystick + ability cluster and mobile_hud.gd
# paints health up top instead, so drop this bottom pip cluster there.
panel.visible = not Controls.use_touch_ui()
_bottom_col.add_child(panel)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
panel.add_child(row)
_hp_label = Label.new()
_hp_label.text = "BULL"
_hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_hp_label.add_theme_font_override("font", UiFonts.body())
_hp_label.add_theme_color_override("font_color", _GOLD)
_hp_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_hp_label.add_theme_constant_override("outline_size", 3)
_hp_label.add_theme_font_size_override("font_size", 14)
row.add_child(_hp_label)
_hp_segments = HBoxContainer.new()
_hp_segments.add_theme_constant_override("separation", 4)
_hp_segments.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_hp_segments.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(_hp_segments)
# The gold-underlined socket behind each pip — shared, non-mutating, so one instance
# is safe across every pip.
func _hp_socket_style() -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = _HP_SOCKET
s.set_corner_radius_all(3)
s.corner_detail = 4
s.border_width_bottom = 2
s.border_color = Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45)
s.content_margin_left = 2.0
s.content_margin_right = 2.0
s.content_margin_top = 2.0
s.content_margin_bottom = 2.0
return s
func _rebuild_hp_pips(max_hp: int) -> void:
for child in _hp_segments.get_children():
child.queue_free()
_hp_seg_fills.clear()
var socket_style := _hp_socket_style()
for _i: int in maxi(max_hp, 0):
var socket := PanelContainer.new()
socket.custom_minimum_size = Vector2(_HP_PIP_W, _HP_PIP_H)
socket.clip_contents = true
socket.add_theme_stylebox_override("panel", socket_style)
socket.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_segments.add_child(socket)
var fill := ColorRect.new()
fill.color = _HP_FULL
fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
socket.add_child(fill)
_hp_seg_fills.append(fill)
func _on_health_changed(current: int, max_hp: int) -> void:
if _hp_segments == null:
return
if _hp_seg_fills.size() != max_hp:
_rebuild_hp_pips(max_hp)
var cur := maxi(current, 0)
var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0)
var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac)
# Bonus pips sit at the high end of the bar; paint those top pips yellow when lit.
var bonus := 0
if _player != null and _player.has_method(&"get_bonus_hp"):
bonus = _player.call(&"get_bonus_hp")
var base_max := max_hp - bonus
for i: int in _hp_seg_fills.size():
var fill := _hp_seg_fills[i]
fill.visible = i < cur
fill.color = _HP_BONUS if i >= base_max else lit
const _SLOT_SEPARATION: int = 10
func _build_ability_bar() -> void:
_ability_bar = HBoxContainer.new()
_ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION)
_ability_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_ability_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bottom_col.add_child(_ability_bar)
for i: int in 4:
_ability_bar.add_child(_build_ability_slot(i))
_layout_ability_bar()
get_viewport().size_changed.connect(_layout_ability_bar)
# Size the ability slots as a fraction of the viewport (clamped) so the bar scales
# with the window; the bottom column keeps the bar horizontally centred.
func _layout_ability_bar() -> void:
var vp := get_viewport().get_visible_rect().size
var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0)
for panel: PanelContainer in _ability_panels:
panel.custom_minimum_size = Vector2(slot, slot)
func _build_ability_slot(idx: int) -> Control:
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 3)
var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(72.0, 72.0)
panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, _ABILITY_ACCENT[idx], 4, 5.0, 5.0))
vbox.add_child(panel)
_ability_panels.append(panel)
var stack := Control.new()
stack.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_child(stack)
if ResourceLoader.exists(_ABILITY_ICONS[idx]):
var icon := TextureRect.new()
icon.texture = load(_ABILITY_ICONS[idx])
# Ignore the texture's native size so a large source PNG doesn't blow the
# slot's minimum size out; scale-to-fit the box while keeping aspect.
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.set_anchors_preset(Control.PRESET_FULL_RECT)
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
stack.add_child(icon)
else:
var name_lbl := Label.new()
name_lbl.text = _ABILITY_NAMES[idx]
name_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_lbl.add_theme_color_override("font_color", _GOLD)
name_lbl.add_theme_font_size_override("font_size", 16)
stack.add_child(name_lbl)
var cd_overlay := ColorRect.new()
cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70)
cd_overlay.anchor_left = 0.0
cd_overlay.anchor_right = 1.0
cd_overlay.anchor_top = 1.0
cd_overlay.anchor_bottom = 1.0
cd_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
stack.add_child(cd_overlay)
_cd_overlays.append(cd_overlay)
var cd_lbl := Label.new()
cd_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
cd_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
cd_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
cd_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
cd_lbl.visible = false
cd_lbl.add_theme_color_override("font_color", Color.WHITE)
cd_lbl.add_theme_font_size_override("font_size", 18)
stack.add_child(cd_lbl)
_cd_labels.append(cd_lbl)
# Keycap tucked directly beneath the tile — a solid dark plate wearing the
# ability's accent underline, so it reads as attached to its box without ever
# covering the artwork. Centred under the tile and tightly spaced.
var keycap := PanelContainer.new()
keycap.add_theme_stylebox_override("panel", _keycap_style(idx))
keycap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
keycap.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(keycap)
var key_lbl := Label.new()
key_lbl.text = Controls.ability_key_label(_ABILITY_ACTIONS[idx])
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
key_lbl.add_theme_font_override("font", UiFonts.body_bold())
key_lbl.add_theme_color_override("font_color", _CREAM)
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
key_lbl.add_theme_constant_override("outline_size", 3)
key_lbl.add_theme_font_size_override("font_size", 14)
keycap.add_child(key_lbl)
_ability_key_labels.append(key_lbl)
return vbox
# Solid keycap plate for an ability bind: near-black fill with the ability's accent
# as a chunky top edge, so the accent bridges up to the tile it sits under (reads as
# attached) while the key stays on a high-contrast background against busy scenes.
func _keycap_style(idx: int) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = Color(0.04, 0.03, 0.02, 0.94)
s.set_corner_radius_all(4)
s.corner_detail = 4
s.set_border_width_all(1)
s.border_width_top = 3
s.border_color = _ABILITY_ACCENT[idx]
s.content_margin_left = 7.0
s.content_margin_right = 7.0
s.content_margin_top = 2.0
s.content_margin_bottom = 1.0
return s
func _build_tab_hint() -> void:
_tab_hint = Label.new()
_tab_hint.text = "Press 'Tab' to see controls"
_tab_hint.anchor_left = 0.0
_tab_hint.anchor_top = 1.0
_tab_hint.anchor_right = 0.0
_tab_hint.anchor_bottom = 1.0
_tab_hint.grow_vertical = Control.GROW_DIRECTION_BEGIN
_tab_hint.offset_left = 20.0
_tab_hint.offset_bottom = -20.0
_tab_hint.add_theme_color_override("font_color", _MUTED)
_tab_hint.add_theme_font_size_override("font_size", 12)
add_child(_tab_hint)
func _build_controls_panel() -> void:
_controls_panel = PanelContainer.new()
_controls_panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 16.0, 12.0))
_controls_panel.anchor_left = 0.0
_controls_panel.anchor_top = 1.0
_controls_panel.anchor_right = 0.0
_controls_panel.anchor_bottom = 1.0
_controls_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
_controls_panel.offset_left = 20.0
_controls_panel.offset_bottom = -20.0
_controls_panel.visible = false
add_child(_controls_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 5)
_controls_panel.add_child(vbox)
var move_keys := "%s %s %s %s" % [
Controls.get_key_label(&"move_forward"),
Controls.get_key_label(&"move_left"),
Controls.get_key_label(&"move_back"),
Controls.get_key_label(&"move_right"),
]
_row(vbox, move_keys, "Move", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM)
_row(vbox, "Scroll", "Zoom", _CREAM)
_row(vbox, "Tab", "Toggle controls", _MUTED)
_row(vbox, "Esc", "Back to menu", _MUTED)
var sep := HSeparator.new()
sep.add_theme_stylebox_override("separator", UiTheme.rule())
vbox.add_child(sep)
_toggle_btn = _make_button("Show controls")
_toggle_btn.pressed.connect(_toggle_controls)
vbox.add_child(_toggle_btn)
const _TOUCH_BTN_FONT: int = 34
const _TOUCH_BTN_MIN: Vector2 = Vector2(320.0, 110.0)
func _make_button(label_text: String) -> Button:
var btn := Button.new()
btn.text = label_text
btn.process_mode = Node.PROCESS_MODE_ALWAYS
var normal := UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_stylebox_override("focus", hover)
btn.add_theme_font_override("font", UiFonts.body())
btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _CREAM)
btn.add_theme_font_size_override("font_size", 18)
return btn
# Scales a game-over button up to a comfortable thumb target on touch devices; a no-op
# with mouse/gamepad, where the desktop sizing already reads fine.
func _grow_for_touch(btn: Button) -> void:
if not Controls.use_touch_ui():
return
btn.add_theme_font_size_override("font_size", _TOUCH_BTN_FONT)
btn.custom_minimum_size = _TOUCH_BTN_MIN
func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
parent.add_child(row)
var badge := PanelContainer.new()
badge.add_theme_stylebox_override("panel", UiTheme.badge())
badge.custom_minimum_size = Vector2(100.0, 0.0)
row.add_child(badge)
var key_lbl := Label.new()
key_lbl.text = key
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.add_theme_color_override("font_color", _GOLD)
key_lbl.add_theme_font_size_override("font_size", 12)
badge.add_child(key_lbl)
var desc_lbl := Label.new()
desc_lbl.text = desc
desc_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
desc_lbl.add_theme_color_override("font_color", desc_color)
desc_lbl.add_theme_font_size_override("font_size", 12)
row.add_child(desc_lbl)