10 Commits

Author SHA1 Message Date
richard 1ea4171863 Polish the bear's thank-you ending screen; add a video debug command
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
- 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 <win|loss|finish>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:36:03 +03:00
richard cd54242de0 Make gamepad vibration work on web export
Input.start_joy_vibration() is a documented engine no-op on Godot's
HTML5 export (never wired to the browser side). Controls.vibrate()
wraps it: native platforms go straight through as before, web drives
the browser's Gamepad Haptic Actuator API directly via JavaScriptBridge.
Gamepad movement/ability input already worked on desktop browsers via
the standard Web Gamepad API, so only vibration needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:35:54 +03:00
richard 91d88cd5c7 Bump version to 0.5.0
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 16:44:04 +03:00
richard 6dff45c659 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
2026-09-10 16:28:57 +03:00
siim 2fd41f4f69 bull follow light
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
2026-09-08 19:15:14 +03:00
richard a147527e91 Remove dead code: the map system and editor cruft
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Delete the unused Slay-the-Spire map screen (superseded by the lobby hub),
leftover editor autosave *.tmp scenes, and a stray reference-link file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:22 +03:00
richard 09d4a60f43 Reorganize into role-based folders and consolidate art
Move every script and scene into role-based directories (actors/, systems/, ui/,
levels/, props/) and all raw art under art/ (grouped by role, snake_case,
Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are
snake_case (PascalCase reserved for class_name) to avoid case-insensitive
filesystem aliasing on the web/macOS targets. Routes the bull's hits through the
typed DamageEvent pipeline and wires the bull's abilities to the Fx library.
Updates CLAUDE.md's folder-layout overview to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:22 +03:00
richard c0a022fed4 Add scene-authored FX library and bull ability effects
An Fx autoload + one_shot_effect base so every effect is an inspector-editable
.tscn (the systems/fx/ authoring dir): blood/slam/spark/flash primitives and the
bull's kick/dash/slam/roll + movement effects (bull_*.tscn), fired via
Fx.spawn() / Fx.attach(). Plus a preview gallery + smoke test, and
bull_impact_fx.gd for the coded, non-particle slam rings + kick flash-pop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:01 +03:00
richard 9458085abb Add typed DamageEvent hit pipeline
A typed DamageEvent contract (systems/combat/damage_event.gd) as a single
entry point for every attacker, plus a characterization test locking the bull's
damage reception. Attacker/receiver wiring rides along with the moved actor
scripts in the reorg commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:46 +03:00
richard 565c132848 Add CI pipeline and reusable test harness
A Gitea Actions workflow (.gitea/workflows/ci.yml) running the headless test
suite on push, plus shared test scaffolding: seeded-RNG / approx / tally helpers
(test_helpers.gd), a harness self-test, and a resolution + pseudo-locale
UI-layout robustness guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:38 +03:00
20 changed files with 311 additions and 23 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -3
View File
@@ -835,9 +835,8 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7)
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
if DP.b("gamepad_vibration"):
Input.start_joy_vibration(0, 0.5, 1, 0.2)
# Vibrate controller 0 with half weak strength, full strong strength, for 0.2 seconds
Controls.vibrate(0, 0.5, 1, 0.2)
# A spear gores a spot — blood spurts from the entry wound (where it struck the
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
+1 -1
View File
@@ -23,7 +23,7 @@ script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996]
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1057197468) index="0" unique_id=840295370]
bones/0/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1)
+14
View File
@@ -0,0 +1,14 @@
extends Node3D
var player: Node3D = null
func _process(delta: float) -> void:
# If we don't have the player yet, try to find them in the group
if not is_instance_valid(player):
var players = get_tree().get_nodes_in_group("player")
if players.size() > 0:
player = players[0] as Node3D
# If the player exists, follow their position
if is_instance_valid(player):
global_position = player.global_position
+1
View File
@@ -0,0 +1 @@
uid://cmapsaj010q40
+14
View File
@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://4v25hb0qu5l0"]
[ext_resource type="Script" uid="uid://cmapsaj010q40" path="res://actors/player/player_follow_light.gd" id="1_w7wrt"]
[node name="PlayerFollowLight" type="Node3D" unique_id=1889971494]
script = ExtResource("1_w7wrt")
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1523840927]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 2.6136062, 1.0431258e-07)
light_color = Color(0.9999998, 0.8702623, 0.60548127, 1)
light_energy = 8.0
shadow_enabled = true
distance_fade_length = 30.45
spot_angle = 72.62192
+7 -3
View File
@@ -2,6 +2,7 @@
[ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://props/torch.tscn" id="1_w7uyf"]
[ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://props/barrel_simple.tscn" id="2_3o0md"]
[ext_resource type="PackedScene" uid="uid://4v25hb0qu5l0" path="res://actors/player/player_follow_light.tscn" id="2_8bh2m"]
[ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://art/levels/lobby.glb" id="2_w7uyf"]
[ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://levels/lobby_underground.gd" id="4_73452"]
[ext_resource type="Script" uid="uid://dn77mjyxllgl6" path="res://levels/to_the_bear_level.gd" id="5_6j2sa"]
@@ -45,8 +46,8 @@ size = Vector3(4.1274414, 8.299622, 6.9958496)
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=955995990]
transform = Transform3D(-0.502238, -0.82287306, 0.26577574, 0.36594617, 0.07621761, 0.9275096, -0.78347975, 0.5630903, 0.26284787, 0, 0, 0)
light_color = Color(0.78431374, 0.8117647, 1, 1)
light_energy = 0.228
light_color = Color(0.5266291, 0.5549962, 0.9999985, 1)
light_energy = 0.466
shadow_enabled = true
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1783040386]
@@ -99,6 +100,9 @@ transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.3
[node name="Torch8" parent="Lights/Torches" unique_id=987701581 instance=ExtResource("1_w7uyf")]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.349617, 1.9073486e-06, -0.31505227)
[node name="PlayerFollowLight" parent="Lights" unique_id=1889971494 instance=ExtResource("2_8bh2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.1712027, 0)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=939963056]
environment = SubResource("Environment_lobby")
@@ -240,7 +244,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.1928787, 0.11255789, 2.8063
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, 0, 0)
script = ExtResource("4_73452")
[node name="Plane" parent="Lobby_underground" index="1" unique_id=1962555538]
[node name="Plane" parent="Lobby_underground" index="1" unique_id=2038841618]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2352347, 0)
[node name="AnimationPlayer" type="AnimationPlayer" parent="Lobby_underground" unique_id=2070502042]
+1 -2
View File
@@ -11,5 +11,4 @@ func _process(_delta: float) -> void:
if Run.bear_gate_open() or DP.b("gate_unlocked_bear"):
bear_gate_open = true
$AnimationPlayer.play("Gate_opening")
if DP.b("gamepad_vibration"):
Input.start_joy_vibration(0, 0.5, .5, 7)
Controls.vibrate(0, 0.5, .5, 7)
+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.
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
+1
View File
@@ -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)
+1 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="Bullosseum"
config/version="0.4.0"
config/version="0.5.0"
run/main_scene="uid://bmnmenu0intr1"
config/features=PackedStringArray("4.7", "GL Compatibility")
boot_splash/bg_color=Color(0, 0, 0, 1)
+1
View File
@@ -167,6 +167,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.011785, -2.371254, 16.2322
[node name="OmniLight3D" type="OmniLight3D" parent="MeshInstance3D/TorchLight" unique_id=1094582672]
transform = Transform3D(0.9999986, -0.001596809, 0.00045388562, 0.0014380338, 0.96984345, 0.24372463, -0.00082937966, -0.24372365, 0.9698444, 9.096397, 6.2542057, -15.77264)
light_color = Color(0.92773837, 0.6304423, 0, 1)
light_energy = 2.0
shadow_enabled = true
omni_range = 13.657384
+24 -6
View File
@@ -20,6 +20,7 @@ const _COMMANDS := {
"list": "print params (optional section)",
"level": "select level and reload (level <id> — 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 "<nothing>"
_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()
+29
View File
@@ -222,6 +222,35 @@ func rigid_skin_enabled() -> bool:
return DP.b("use_rigid_skin")
## Rumble a connected gamepad, gated on the DP "gamepad_vibration" flag. Off web this is a thin
## wrapper over Input.start_joy_vibration(); on web that call is a documented engine no-op
## (godotengine/godot#96985 — never wired to the browser side), so there we drive the Gamepad
## Haptic Actuator API ourselves. Safe no-op wherever it's unsupported (Safari, most Firefox
## versions, or a controller with no rumble motors) — desktop Chromium/Edge is the target.
func vibrate(device: int, weak_magnitude: float, strong_magnitude: float, duration: float) -> void:
if not DP.b("gamepad_vibration"):
return
if not OS.has_feature("web"):
Input.start_joy_vibration(device, weak_magnitude, strong_magnitude, duration)
return
JavaScriptBridge.eval("""
(function(){
try {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
var pad = pads[%d];
var act = pad && pad.vibrationActuator;
if (act && act.playEffect) {
act.playEffect('dual-rumble', {
duration: %d,
weakMagnitude: %f,
strongMagnitude: %f
});
}
} catch (e) {}
})();
""" % [device, int(duration * 1000.0), weak_magnitude, strong_magnitude], true)
## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action):
+167 -7
View File
@@ -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.
@@ -66,6 +70,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
@@ -75,16 +83,27 @@ 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()
@@ -92,6 +111,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 +226,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():
@@ -343,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:
@@ -373,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"
@@ -434,6 +505,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 +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
_over_label.text = "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).
_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
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()
@@ -468,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)
@@ -524,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)")
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
uid://b5rupuoe1lk5j