19 Commits

Author SHA1 Message Date
richard 02aec4249f Make the Controls options menu device-aware
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Showing both a keyboard key and a gamepad button on every ability row
(and WASD movement rows that never had a gamepad equivalent anyway)
cluttered the list once a controller is in play. The rebind list now
shows only the relevant set for whichever device the player is
actually holding: on a gamepad, movement rows disappear entirely
(the D-pad/stick isn't individually remapped, so there's nothing to
show) and each ability shows only its gamepad button; on keyboard,
the reverse. Uses the same Controls.using_gamepad() last-input
heuristic as the HUD's ability keycaps, not mere connection presence,
so a controller left plugged in but unused doesn't hide keyboard
binds from a mouse-and-keyboard player. The list (and its hint text)
also swaps live if the player switches device while the panel is
open, skipped while a rebind capture is in progress so it can't be
torn out from under them.

Verified headlessly: keyboard mode shows all 8 rows, gamepad mode
shows only the 4 ability rows, and the hint text swaps between them —
confirmed with a real screenshot of both states.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:42:59 +03:00
richard 431ee295ae Full Xbox/PlayStation controller support: glyphs, remapping, vibration toggle
Extends the earlier Xbox-only ability glyphs into full brand-aware
controller support, following standard practice (position-based
button mapping, safe defaults for unrecognised pads, no per-device
special-casing):

- Controls detects PlayStation pads (DualShock/DualSense) from
  Input.get_joy_name() via a name-substring heuristic, checking for
  "xbox" first so a Bluetooth-connected Xbox pad (reports as "Xbox
  Wireless Controller") can't be misread as PlayStation through the
  generic "wireless controller" hint. Unrecognised/generic pads read
  as Xbox-style, matching the browser's Standard Gamepad Mapping.
- Ability-bar keycaps and the options menu now show ✕/○/□/△ for a
  detected PlayStation pad instead of A/B/X/Y — no index remapping
  needed, since PlayStation's face buttons sit in the exact same
  four positions as Xbox's.
- The options menu's Controls section gains a gamepad rebind button
  next to each ability's keyboard one (movement stays on the D-pad/
  stick, not individually remapped, matching convention). Clicking it
  captures the next joypad button press, mirroring the existing
  keyboard-rebind flow exactly (Esc cancels either). Gamepad rebinds
  persist to controls.cfg alongside keyboard ones and survive a
  reload.
- Added a player-facing "Controller Vibration" toggle (Settings...
  Gameplay section) — Controls.vibrate() now checks it alongside the
  existing DP debug flag, so either can silence rumble.

Verified headlessly end-to-end: default bindings show correct
brand-agnostic glyphs (kick=X, dash=A, ...), rebinding kick to a
different button through the real options-menu flow changes only
that action's glyph, leaves its keyboard binding and other abilities'
gamepad bindings untouched, and survives a reload from disk. Sanity-
checked the PlayStation name heuristic against realistic controller
name strings from Xbox, PlayStation (Windows/macOS/Bluetooth
variants), and other brands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:33:02 +03:00
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
richard e810d2e75b Fix vibration going dead for the rest of the session after the gate
Siim reported controller vibration stops working entirely after the
first playthrough. The gate's pulsed rumble (vibrate_for) fired a new
~0.3s vibration every 0.4s for 7 seconds — about 17 back-to-back start
calls. On at least one setup that appears to leave the gamepad's
rumble motor stuck reporting "busy", so every later vibrate() call
(hits included) silently does nothing for the rest of that session.

Space pulses out more (fewer, longer gaps: 0.25s on / 0.6s off instead
of 0.3s on / 0.1s off) and explicitly stop_joy_vibration() between
them on native instead of relying on each pulse's own duration to
time out, so a new effect never starts before the last one has
cleanly ended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:17:47 +03:00
richard d2a59f695e Bind only joypad button 0 for ui_accept confirm
Simplify the confirm binding to just the standard button 0 (Xbox-style
"A" / bottom face button) instead of also covering the Nintendo-layout
right-hand position.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:08:12 +03:00
richard d5c6b749d0 Bind a gamepad confirm button — ui_accept had none by default
Siim reported gamepad menu navigation works but pressing a button
does nothing. Checked Godot's actual built-in ui_accept bindings
directly: only Enter/Kp Enter/Space — no joypad button at all. That's
why D-pad/stick focus movement (ui_left/right/up/down do default to
gamepad input) worked while "confirm" never could, on any controller.

Binds both the Xbox-style bottom face button (A, index 0) and the
right-hand one (B, index 1 — physically labelled "A" on a
Nintendo-layout pad, since gamepad mapping normalizes to Xbox
*position* rather than the printed label), so either common "confirm"
button activates the focused menu/end-screen button.

Verified headlessly end-to-end: a synthetic joypad button 0 or button
1 press on the focused end-screen button now reloads the run, matching
what a real gamepad press should do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:05:55 +03:00
richard 56a4965ff7 Fix bear gate re-opening after Play Again
Clearing the arena's last wave set the DP debug flag
"gate_unlocked_bear" to true as a side effect. That flag lives on the
DP autoload, which survives scene reloads and isn't touched by
Run.start_new_run() — so once cleared for real in a play session, it
stayed true forever, permanently overriding the per-run
Run.cleared-based gate check in lobby_underground.gd. Every later
lobby load (including after Play Again reset the real run state) kept
re-opening the gate.

The DP flag is meant to be a manual designer override for testing the
animation (per its own doc comment), not something gameplay code sets
persistently — Run.bear_gate_open() is already the real per-run source
of truth. Removed the stray set_value() call.

Verified in isolation: clearing the arena's last wave no longer
touches the DP flag (stays false), so the gate only opens through the
run-scoped Run.cleared state, which already resets correctly on Play
Again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 18:31:29 +03:00
richard d8d6b11508 Let a gamepad drive the end-screen buttons (Play Again / Main Menu)
Nothing had focus by default when the outcome screen appeared, so a
gamepad's ui_accept/ui_left/ui_right (bound to Godot's built-in ui_*
actions) had no control to act on — same reason main_menu.gd grabs
focus on its own default button per panel. Grab it on whichever
button is visible (Play Again/Continue on a win, Retry on a loss);
Godot's geometry-based focus neighbors then let ui_left/ui_right reach
Main Menu from there. Verified headlessly: a synthetic ui_accept on
the focused button reloads the run, same as a real gamepad A press
would.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:56:24 +03:00
richard a9440c6a43 Fix gate-opening vibration by pulsing instead of one long effect
Siim reported the hit-vibration works but the lobby gate's rumble
doesn't. Godot's start_joy_vibration() docs call this out directly:
long single effects are unreliable on much gamepad hardware/drivers,
and recommend a short effect repeated instead. Added
Controls.vibrate_for() to do that and pointed the gate at it in place
of its single 7-second call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:56:17 +03:00
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
23 changed files with 622 additions and 40 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) health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause) hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7) camera_pivot.call(&"trigger_hit", 0.7)
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds # Vibrate controller 0 with half weak strength, full strong strength, for 0.2 seconds
if DP.b("gamepad_vibration"): Controls.vibrate(0, 0.5, 1, 0.2)
Input.start_joy_vibration(0, 0.5, 1, 0.2)
# A spear gores a spot — blood spurts from the entry wound (where it struck the # 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. # 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")] [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) 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/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067) bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1) 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://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://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="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://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"] [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] [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) 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_color = Color(0.5266291, 0.5549962, 0.9999985, 1)
light_energy = 0.228 light_energy = 0.466
shadow_enabled = true shadow_enabled = true
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1783040386] [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")] [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) 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] [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=939963056]
environment = SubResource("Environment_lobby") 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) transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, 0, 0)
script = ExtResource("4_73452") 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) 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] [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"): if Run.bear_gate_open() or DP.b("gate_unlocked_bear"):
bear_gate_open = true bear_gate_open = true
$AnimationPlayer.play("Gate_opening") $AnimationPlayer.play("Gate_opening")
if DP.b("gamepad_vibration"): Controls.vibrate_for(0, 0.5, .5, 7.0)
Input.start_joy_vibration(0, 0.5, .5, 7)
-1
View File
@@ -83,7 +83,6 @@ func _on_matador_killed() -> void:
if _alive > 0 or _resolved: if _alive > 0 or _resolved:
return return
if _wave >= DP.f("mat_wave_size"): if _wave >= DP.f("mat_wave_size"):
DP.set_value("gate_unlocked_bear", true)
_resolved = true _resolved = true
all_defeated.emit() all_defeated.emit()
else: else:
+47
View File
@@ -33,6 +33,14 @@ var cleared: Dictionary = {}
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP. ## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
var bonus_hp: int = 0 var bonus_hp: int = 0
const BONUS_HP_CAP: int = 2 const BONUS_HP_CAP: int = 2
## Wall-clock run timer: starts the moment the player walks through the arena gate (the
## run's first fight) and freezes the instant the bear falls, so the completion screen can
## show a final time. Time.get_ticks_msec()-based — real time, not delta-summed — so it
## keeps ticking correctly across the scene reload between the arena and the bear's den.
var _timer_start_msec: int = -1
var _timer_end_msec: int = -1
var _timer_running: bool = false
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run ## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
## when the shell reloads for each fight. ## when the shell reloads for each fight.
var _run_active: bool = false var _run_active: bool = false
@@ -105,6 +113,9 @@ func start_new_run() -> void:
lobby_choice = null lobby_choice = null
cleared = {} cleared = {}
bonus_hp = 0 bonus_hp = 0
_timer_start_msec = -1
_timer_end_msec = -1
_timer_running = false
_run_active = true _run_active = true
run_started.emit() run_started.emit()
@@ -154,3 +165,39 @@ func add_bonus_hp(amount: int) -> int:
var before := bonus_hp var before := bonus_hp
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP) bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
return bonus_hp - before return bonus_hp - before
## Begin the run clock. No-op while the clock is already running, so walking back through
## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead
## and the player has looped back to the arena to go again), the next call restarts it from
## zero rather than staying stuck showing the previous run's final time.
func start_run_timer() -> void:
if _timer_running:
return
_timer_start_msec = Time.get_ticks_msec()
_timer_end_msec = -1
_timer_running = true
## Freeze the run clock — called the instant the bear falls, so the recorded time is the
## kill moment itself, not whatever the win-screen's celebratory delay adds on top.
func stop_run_timer() -> void:
if not _timer_running:
return
_timer_end_msec = Time.get_ticks_msec()
_timer_running = false
## Seconds elapsed since the timer started (live while running, held steady once
## stop_run_timer() has been called). 0 before the timer has ever started.
func run_elapsed() -> float:
if _timer_start_msec < 0:
return 0.0
var end_msec := Time.get_ticks_msec() if _timer_running else _timer_end_msec
return float(end_msec - _timer_start_msec) / 1000.0
## Whether the run clock has been started (the player has entered the arena at least once
## this run). Lets the HUD hide the timer readout until there's a time worth showing.
func run_timer_started() -> bool:
return _timer_start_msec >= 0
+1
View File
@@ -8,5 +8,6 @@ func _ready() -> void:
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"): if not body.is_in_group("player"):
return return
Run.start_run_timer()
Run.choose_level(&"arena") Run.choose_level(&"arena")
get_tree().change_scene_to_file(Run.active_level().scene_path) get_tree().change_scene_to_file(Run.active_level().scene_path)
+1 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="Bullosseum" config/name="Bullosseum"
config/version="0.4.0" config/version="0.5.0"
run/main_scene="uid://bmnmenu0intr1" run/main_scene="uid://bmnmenu0intr1"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
boot_splash/bg_color=Color(0, 0, 0, 1) 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] [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) 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_color = Color(0.92773837, 0.6304423, 0, 1)
light_energy = 2.0
shadow_enabled = true shadow_enabled = true
omni_range = 13.657384 omni_range = 13.657384
+13
View File
@@ -12,6 +12,9 @@ const SECTION := "options"
var screen_shake: bool = true var screen_shake: bool = true
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed). ## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
var gore: bool = true var gore: bool = true
## Controller rumble toggle — player-facing; Controls.vibrate() also respects the DP
## "gamepad_vibration" debug flag, so either can silence it.
var vibration: bool = true
## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change. ## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change.
var volume_master: float = 1.0 var volume_master: float = 1.0
@@ -62,6 +65,14 @@ func set_gore(value: bool) -> void:
save() save()
func set_vibration(value: bool) -> void:
if vibration == value:
return
vibration = value
changed.emit("vibration", value)
save()
func set_volume_master(value: float) -> void: func set_volume_master(value: float) -> void:
volume_master = clampf(value, 0.0, 1.0) volume_master = clampf(value, 0.0, 1.0)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master)) AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
@@ -96,6 +107,7 @@ func save() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
cfg.set_value(SECTION, "screen_shake", screen_shake) cfg.set_value(SECTION, "screen_shake", screen_shake)
cfg.set_value(SECTION, "gore", gore) cfg.set_value(SECTION, "gore", gore)
cfg.set_value(SECTION, "vibration", vibration)
cfg.set_value(SECTION, "volume_master", volume_master) cfg.set_value(SECTION, "volume_master", volume_master)
cfg.set_value(SECTION, "volume_ambient", volume_ambient) cfg.set_value(SECTION, "volume_ambient", volume_ambient)
cfg.set_value(SECTION, "volume_effects", volume_effects) cfg.set_value(SECTION, "volume_effects", volume_effects)
@@ -110,6 +122,7 @@ func load_saved() -> void:
return return
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
gore = cfg.get_value(SECTION, "gore", true) as bool gore = cfg.get_value(SECTION, "gore", true) as bool
vibration = cfg.get_value(SECTION, "vibration", true) as bool
volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float
volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float
volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float
+24 -6
View File
@@ -20,6 +20,7 @@ const _COMMANDS := {
"list": "print params (optional section)", "list": "print params (optional section)",
"level": "select level and reload (level <id> — arena / arena_boss / bear)", "level": "select level and reload (level <id> — arena / arena_boss / bear)",
"restart": "reload the current level scene", "restart": "reload the current level scene",
"video": "preview an outcome screen (video win / loss / finish)",
"clear": "clear the log", "clear": "clear the log",
"help": "show this help", "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. # while the console is open — cheap, and it never lets the command line go dark.
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _is_open and not _input_field.has_focus(): 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() _input_field.grab_focus()
@@ -476,6 +471,8 @@ func _run_command(parts: PackedStringArray) -> bool:
_cmd_list(parts) _cmd_list(parts)
"level": "level":
_cmd_level(parts) _cmd_level(parts)
"video":
_cmd_video(parts)
"restart": "restart":
_log_ok("Restarting…") _log_ok("Restarting…")
get_tree().paused = false 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"]) _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: func _cmd_level(parts: PackedStringArray) -> void:
if parts.size() == 1: if parts.size() == 1:
var cur := Run.active_level() var cur := Run.active_level()
+220 -6
View File
@@ -7,6 +7,7 @@ extends Node
const SAVE_PATH := "user://controls.cfg" const SAVE_PATH := "user://controls.cfg"
const SECTION := "keybinds" const SECTION := "keybinds"
const JOY_SECTION := "joybinds"
## Actions the options menu lets the player rebind (keyboard only). ## Actions the options menu lets the player rebind (keyboard only).
const REBINDABLE_ACTIONS: PackedStringArray = [ const REBINDABLE_ACTIONS: PackedStringArray = [
@@ -20,12 +21,162 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
&"ability_roll", &"ability_roll",
] ]
## Actions the options menu also lets the player rebind on a gamepad. Movement stays on the
## D-pad/stick (standard, not individually remapped); only the face-button abilities are.
const JOY_REBINDABLE_ACTIONS: PackedStringArray = [
&"ability_kick",
&"ability_dash",
&"ability_slam",
&"ability_roll",
]
func _ready() -> void: func _ready() -> void:
_ensure_gamepad_confirm_binding()
load_saved() load_saved()
_install_web_fullscreen_hook() _install_web_fullscreen_hook()
# ── Active input device ──────────────────────────────────────────────────────────
# Tracks whether the player's last deliberate input was a gamepad or keyboard/mouse, so the
# HUD can swap its ability-bar keycaps to match whichever the player is actually holding.
# Mouse motion is deliberately ignored — the mouse still drives the camera regardless of
# which device is moving the bull, so panning the camera shouldn't flip the hint away from
# gamepad glyphs. A small deadzone on joypad axes avoids stick drift/noise doing the same.
const _JOY_AXIS_DEADZONE := 0.5
var _last_input_is_gamepad: bool = false
## Device id of whichever gamepad was pressed most recently — which controller's name we
## check to pick Xbox vs PlayStation glyphs when more than one is connected.
var _last_joy_device: int = 0
func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton:
if event.pressed:
_last_input_is_gamepad = true
_last_joy_device = event.device
elif event is InputEventJoypadMotion:
if absf(event.axis_value) > _JOY_AXIS_DEADZONE:
_last_input_is_gamepad = true
_last_joy_device = event.device
elif event is InputEventKey:
if event.pressed:
_last_input_is_gamepad = false
elif event is InputEventMouseButton:
if event.pressed:
_last_input_is_gamepad = false
## Whether the player's last button/key/stick press was on a gamepad rather than
## keyboard/mouse. Drives which glyph set the HUD's ability keycaps show.
func using_gamepad() -> bool:
return _last_input_is_gamepad
# Godot's joypad button indices are already Xbox-position-normalized — by SDL on native, by
# the browser's Standard Gamepad Mapping on web — so a position-based glyph table reads
# correctly for any controller brand with no per-device special-casing (a Nintendo pad's
# physically-labelled "A" is button 1 here, same as "B" on Xbox; PlayStation's ✕/○/□/△ sit
# in the exact same four positions as Xbox's A/B/X/Y, so the same button-index keys apply).
const _XBOX_BUTTON_GLYPHS := {
JOY_BUTTON_A: "A",
JOY_BUTTON_B: "B",
JOY_BUTTON_X: "X",
JOY_BUTTON_Y: "Y",
}
const _PLAYSTATION_BUTTON_GLYPHS := {
JOY_BUTTON_A: "✕",
JOY_BUTTON_B: "○",
JOY_BUTTON_X: "□",
JOY_BUTTON_Y: "△",
}
## Substrings (checked lower-case) that identify a controller as a PlayStation pad from
## Input.get_joy_name() — which varies a lot by OS/browser/driver, so this is necessarily a
## heuristic. "wireless controller" is what macOS/some browsers call a DualShock 4 with no
## further detail — but a Bluetooth-connected Xbox pad reports as "Xbox Wireless Controller",
## so _is_playstation_pad checks for "xbox" first and bails out before these hints, or that
## exact string would misfire as PlayStation. Anything that matches neither reads as
## Xbox-style — the safe default, since unrecognised/generic pads and the browser's Standard
## Gamepad Mapping are Xbox-shaped.
const _PLAYSTATION_NAME_HINTS := [
"sony", "playstation", "dualshock", "dualsense", "ps3", "ps4", "ps5", "wireless controller",
]
## True if the given gamepad's reported name looks like a PlayStation pad (DualShock/
## DualSense), false (Xbox-style) for everything else, including unknown controllers.
func _is_playstation_pad(device: int) -> bool:
var name := Input.get_joy_name(device).to_lower()
if name.contains("xbox"):
return false
for hint: String in _PLAYSTATION_NAME_HINTS:
if name.contains(hint):
return true
return false
## Returns the InputEventJoypadButton bound to `action`, or null.
func _get_joy_button_event(action: StringName) -> InputEventJoypadButton:
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventJoypadButton:
return event
return null
## Face-button glyph for the action's bound joypad button — Xbox letters (A/B/X/Y) or
## PlayStation shapes (✕/○/□/△) depending on whichever pad was pressed most recently — or
## "—" if the action has no joypad binding.
func get_joy_button_label(action: StringName) -> String:
var event := _get_joy_button_event(action)
if event == null:
return "—"
var glyphs := _PLAYSTATION_BUTTON_GLYPHS if _is_playstation_pad(_last_joy_device) \
else _XBOX_BUTTON_GLYPHS
return glyphs.get(event.button_index, "Btn %d" % event.button_index)
## Replaces the joypad button bound to `action`, keeping every other event (keyboard,
## alternates) in place.
func rebind_joy(action: StringName, event: InputEventJoypadButton) -> void:
_rebind_joy_no_save(action, event)
save()
## Rebuilds the action's event list, swapping the first joypad-button event for `event` in
## place so ordering is preserved, same as _rebind_no_save() does for keyboard.
func _rebind_joy_no_save(action: StringName, event: InputEventJoypadButton) -> void:
var events := InputMap.action_get_events(action)
InputMap.action_erase_events(action)
var replaced := false
for existing: InputEvent in events:
if not replaced and existing is InputEventJoypadButton:
InputMap.action_add_event(action, event)
replaced = true
else:
InputMap.action_add_event(action, existing)
if not replaced:
InputMap.action_add_event(action, event)
## Ability-bar keycap label for `action`: the bound Xbox-style face button while a gamepad
## is in use, else the bound keyboard key — so the on-screen prompt always matches whichever
## the player is actually holding.
func ability_key_label(action: StringName) -> String:
return get_joy_button_label(action) if using_gamepad() else get_key_label(action)
## Godot's built-in ui_accept (menu/button confirm) ships with zero joypad bindings —
## checked directly: only Enter/Kp Enter/Space. ui_left/right/up/down, by contrast, do
## default to the D-pad and stick, which is why gamepad focus navigation on a menu works out
## of the box while "confirming" the focused button does nothing. Bind joypad button 0 (the
## bottom face button in Xbox-style position mapping) as the confirm button.
func _ensure_gamepad_confirm_binding() -> void:
var alt := InputEventJoypadButton.new()
alt.button_index = JOY_BUTTON_A
if not InputMap.action_has_event(&"ui_accept", alt):
InputMap.action_add_event(&"ui_accept", alt)
# ── Web fullscreen + landscape lock ───────────────────────────────────────────── # ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls # The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
# requestFullscreen() synchronously inside the real user-gesture event — the same thing # requestFullscreen() synchronously inside the real user-gesture event — the same thing
@@ -222,8 +373,59 @@ func rigid_skin_enabled() -> bool:
return DP.b("use_rigid_skin") return DP.b("use_rigid_skin")
## Rumble a connected gamepad, gated on the player-facing Settings.vibration toggle and the
## DP "gamepad_vibration" debug flag (either can silence it). 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 Settings.vibration or 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)
## Godot's own start_joy_vibration() docs warn that a single long effect is unreliable —
## "not every hardware is compatible with long effects; you may want to make a shorter
## effect and make it repeat instead." That's exactly what broke the lobby gate's 7-second
## rumble (the short 0.2 s hit-vibration works fine): this repeats vibrate() as short pulses
## to cover the same total duration instead of one long call.
## Explicitly stopping between pulses (rather than letting each one time out on its own) and
## spacing them out avoids re-triggering the effect faster than some drivers/browsers can
## retire the previous one — back-to-back-to-back start calls left the gamepad's rumble
## motor stuck reporting "busy" for the rest of the session on at least one setup.
func vibrate_for(device: int, weak_magnitude: float, strong_magnitude: float,
total_duration: float, pulse: float = 0.25, gap: float = 0.6) -> void:
var elapsed := 0.0
while elapsed < total_duration:
vibrate(device, weak_magnitude, strong_magnitude, pulse)
await get_tree().create_timer(pulse).timeout
if not OS.has_feature("web"):
Input.stop_joy_vibration(device)
await get_tree().create_timer(gap).timeout
elapsed += pulse + gap
## Returns the first keyboard event bound to an action, or null. ## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey: func _get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action): for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventKey: if event is InputEventKey:
return event return event
@@ -232,7 +434,7 @@ func get_key_event(action: StringName) -> InputEventKey:
## Human-readable label for the action's current key (e.g. "W", "Space"). ## Human-readable label for the action's current key (e.g. "W", "Space").
func get_key_label(action: StringName) -> String: func get_key_label(action: StringName) -> String:
var event := get_key_event(action) var event := _get_key_event(action)
if event == null: if event == null:
return "—" return "—"
return event.as_text_physical_keycode() return event.as_text_physical_keycode()
@@ -241,16 +443,20 @@ func get_key_label(action: StringName) -> String:
## Replaces the primary keyboard event of `action` with `event`, keeping ## Replaces the primary keyboard event of `action` with `event`, keeping
## every other event (e.g. arrow-key alternates, joypad) in its place. ## every other event (e.g. arrow-key alternates, joypad) in its place.
func rebind(action: StringName, event: InputEventKey) -> void: func rebind(action: StringName, event: InputEventKey) -> void:
rebind_no_save(action, event) _rebind_no_save(action, event)
save() save()
func save() -> void: func save() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
for action: StringName in REBINDABLE_ACTIONS: for action: StringName in REBINDABLE_ACTIONS:
var event := get_key_event(action) var event := _get_key_event(action)
if event != null: if event != null:
cfg.set_value(SECTION, String(action), event.physical_keycode) cfg.set_value(SECTION, String(action), event.physical_keycode)
for action: StringName in JOY_REBINDABLE_ACTIONS:
var joy_event := _get_joy_button_event(action)
if joy_event != null:
cfg.set_value(JOY_SECTION, String(action), joy_event.button_index)
cfg.save(SAVE_PATH) cfg.save(SAVE_PATH)
@@ -265,12 +471,20 @@ func load_saved() -> void:
var keycode: int = cfg.get_value(SECTION, key) var keycode: int = cfg.get_value(SECTION, key)
var event := InputEventKey.new() var event := InputEventKey.new()
event.physical_keycode = keycode as Key event.physical_keycode = keycode as Key
rebind_no_save(action, event) _rebind_no_save(action, event)
for action: StringName in JOY_REBINDABLE_ACTIONS:
var joy_key := String(action)
if not cfg.has_section_key(JOY_SECTION, joy_key):
continue
var button_index: int = cfg.get_value(JOY_SECTION, joy_key)
var joy_event := InputEventJoypadButton.new()
joy_event.button_index = button_index
_rebind_joy_no_save(action, joy_event)
## Rebuilds the action's event list, swapping the first keyboard event for ## Rebuilds the action's event list, swapping the first keyboard event for
## `event` in place so ordering (and thus the "primary" key) is preserved. ## `event` in place so ordering (and thus the "primary" key) is preserved.
func rebind_no_save(action: StringName, event: InputEventKey) -> void: func _rebind_no_save(action: StringName, event: InputEventKey) -> void:
var events := InputMap.action_get_events(action) var events := InputMap.action_get_events(action)
InputMap.action_erase_events(action) InputMap.action_erase_events(action)
var replaced := false var replaced := false
+191 -8
View File
@@ -3,6 +3,10 @@ extends CanvasLayer
const _GOLD := Color(1.00, 0.78, 0.22) const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.93, 0.88, 0.72) const _CREAM := Color(0.93, 0.88, 0.72)
const _MUTED := Color(0.66, 0.60, 0.46) 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. # Per-ability trim colour, tying each tile (and its key cap) to that ability's FX.
# Order matches _ABILITY_ICONS: kick, dash, slam, roll. # Order matches _ABILITY_ICONS: kick, dash, slam, roll.
@@ -40,7 +44,11 @@ var _hp_seg_fills: Array[ColorRect] = []
var _bottom_col: VBoxContainer var _bottom_col: VBoxContainer
var _ability_bar: HBoxContainer var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = [] var _ability_panels: Array[PanelContainer] = []
var _ability_key_labels: Array[Label] = []
var _bull_bg: TextureRect 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, # 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 # throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
@@ -66,6 +74,10 @@ const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within
# records which way it went (read by the gameplay test). # records which way it went (read by the gameplay test).
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
# Clearing the bear — the run's final boss — replaces the usual bull-win clip with the
# closing "Thank You For Playing" screen. That clip already bakes its own title text in,
# so _over_label is left blank for this outcome (see _show_game_over).
const _THANKYOU_VIDEO := "res://videos/thankyou.ogv"
# Beat between the run ending and the outcome screen. A loss is a short freeze on the # Beat between the run ending and the outcome screen. A loss is a short freeze on the
# dead bull; a win gives the player a longer stretch to strut a victory lap around the # dead bull; a win gives the player a longer stretch to strut a victory lap around the
@@ -75,16 +87,27 @@ const _WIN_DELAY := 3.0
var _game_over: bool = false var _game_over: bool = false
var _result_win: 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_root: Control
var _over_video: VideoStreamPlayer var _over_video: VideoStreamPlayer
var _over_label: Label var _over_label: Label
var _over_continue: Button var _over_continue: Button
var _over_again: 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: func _ready() -> void:
layer = 11 layer = 11
process_mode = Node.PROCESS_MODE_ALWAYS process_mode = Node.PROCESS_MODE_ALWAYS
add_to_group(&"hud") # lets the debug console's `video` command find this instance
_build_controls_panel() _build_controls_panel()
_build_tab_hint() _build_tab_hint()
_build_bottom_hud() _build_bottom_hud()
@@ -92,6 +115,7 @@ func _ready() -> void:
_build_ability_bar() _build_ability_bar()
_build_health() _build_health()
_build_version_label() _build_version_label()
_build_run_timer()
_build_damage_vignette() _build_damage_vignette()
_build_game_over() _build_game_over()
_build_touch_controls() _build_touch_controls()
@@ -206,8 +230,42 @@ func _build_version_label() -> void:
add_child(lbl) add_child(lbl)
# Run clock, top-left. Desktop only — touch already owns that corner for the mobile HP
# readout, and a run timer is a nice-to-have there, not worth the crowding.
func _build_run_timer() -> void:
if Controls.use_touch_ui():
return
_run_timer_label = Label.new()
_run_timer_label.text = _format_time(0.0)
_run_timer_label.visible = false
_run_timer_label.add_theme_font_override("font", UiFonts.body())
_run_timer_label.add_theme_color_override("font_color", _CREAM)
_run_timer_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_run_timer_label.add_theme_constant_override("outline_size", 4)
_run_timer_label.add_theme_font_size_override("font_size", 20)
_run_timer_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_run_timer_label.offset_left = 20.0
_run_timer_label.offset_top = 16.0
_run_timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_run_timer_label)
# mm:ss.hh — matches how a speedrun-style clock reads at a glance.
static func _format_time(seconds: float) -> String:
var total_cs := int(round(seconds * 100.0))
var minutes := total_cs / 6000
var secs := (total_cs / 100) % 60
var centis := total_cs % 100
return "%02d:%02d.%02d" % [minutes, secs, centis]
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_control_hints() _update_control_hints()
_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: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
@@ -343,7 +401,7 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: 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 _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
if _result_win: if _result_win:
_continue_to_lobby() _on_win_button_pressed()
else: else:
_restart() _restart()
elif event.physical_keycode == KEY_TAB and not _game_over: elif event.physical_keycode == KEY_TAB and not _game_over:
@@ -373,6 +431,24 @@ func _continue_to_lobby() -> void:
get_tree().change_scene_to_file(Run.active_level().scene_path) 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: func _toggle_controls() -> void:
_controls_visible = not _controls_visible _controls_visible = not _controls_visible
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
@@ -398,6 +474,18 @@ func _update_control_hints() -> void:
_tab_hint.visible = not _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 ─────────────────────────────────────────────────────────── # ── Damage vignette ───────────────────────────────────────────────────────────
# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red # 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 # toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built
@@ -434,6 +522,10 @@ func _on_player_died(cause: String = "") -> void:
func _on_all_defeated() -> void: func _on_all_defeated() -> void:
# Freeze the run clock the instant the bear falls — before _show_game_over's
# celebratory delay — so the recorded time is the kill moment, not the victory lap.
if Run.active_level().id == &"bear":
Run.stop_run_timer()
_show_game_over(true) _show_game_over(true)
@@ -450,12 +542,78 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
_over_label.text = "Bull Won!" if _result_win else "Matadors Won!" _is_final_boss = _result_win and Run.active_level().id == &"bear"
# A win advances the run (on to the map); a loss ends it (retry from the top). _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_continue.visible = _result_win
_over_again.visible = not _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 := _WIN_VIDEO if _result_win else _LOSS_VIDEO var path := _THANKYOU_VIDEO if _is_final_boss else (_WIN_VIDEO if _result_win else _LOSS_VIDEO)
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
_over_video.stream = load(path) _over_video.stream = load(path)
_over_video.play() _over_video.play()
@@ -468,6 +626,29 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5) 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: func _build_game_over() -> void:
_over_root = Control.new() _over_root = Control.new()
_over_root.set_anchors_preset(Control.PRESET_FULL_RECT) _over_root.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -524,10 +705,11 @@ func _build_game_over() -> void:
row.offset_bottom = -48.0 row.offset_bottom = -48.0
_over_root.add_child(row) _over_root.add_child(row)
# Win → continue to the run map; loss → restart from the top. Shown per-result in # Win → continue to the run map, or — beating the bear, the run's last fight — start a
# _show_game_over; both share the Enter shortcut and label. # 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 = _make_button("Continue (Enter)")
_over_continue.pressed.connect(_continue_to_lobby) _over_continue.pressed.connect(_on_win_button_pressed)
row.add_child(_over_continue) row.add_child(_over_continue)
_over_again = _make_button("Continue (Enter)") _over_again = _make_button("Continue (Enter)")
@@ -737,7 +919,7 @@ func _build_ability_slot(idx: int) -> Control:
vbox.add_child(keycap) vbox.add_child(keycap)
var key_lbl := Label.new() var key_lbl := Label.new()
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx]) key_lbl.text = Controls.ability_key_label(_ABILITY_ACTIONS[idx])
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
@@ -747,6 +929,7 @@ func _build_ability_slot(idx: int) -> Control:
key_lbl.add_theme_constant_override("outline_size", 3) key_lbl.add_theme_constant_override("outline_size", 3)
key_lbl.add_theme_font_size_override("font_size", 14) key_lbl.add_theme_font_size_override("font_size", 14)
keycap.add_child(key_lbl) keycap.add_child(key_lbl)
_ability_key_labels.append(key_lbl)
return vbox return vbox
+83 -9
View File
@@ -80,6 +80,11 @@ const ACTION_LABELS: Dictionary = {
var _listening_action: StringName = &"" var _listening_action: StringName = &""
var _listening_button: Button = null var _listening_button: Button = null
var _listening_joy_action: StringName = &""
var _listening_joy_button: Button = null
# Cached so the rebind list only rebuilds on an actual keyboard<->gamepad switch, not
# every frame the Options panel is open.
var _rebind_rows_show_gamepad: bool = false
func _ready() -> void: func _ready() -> void:
@@ -184,6 +189,7 @@ func _plaque(hot: bool) -> StyleBoxFlat:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_rebind_rows_for_device()
if not main_panel.visible or video.stream == null: if not main_panel.visible or video.stream == null:
return return
var frame_count := CAPE_POS.size() var frame_count := CAPE_POS.size()
@@ -207,6 +213,7 @@ func _build_options_toggles() -> void:
_add_section_label("── Gameplay ──", idx); idx += 1 _add_section_label("── Gameplay ──", idx); idx += 1
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1 _add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1 _add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
_add_toggle_row("Controller Vibration", Settings.vibration, Settings.set_vibration, idx); idx += 1
_add_section_label("── Controls ──", idx) _add_section_label("── Controls ──", idx)
@@ -291,10 +298,24 @@ func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index
options_vbox.move_child(row, at_index) options_vbox.move_child(row, at_index)
## Rebuilds the Controls rebind list for whichever device the player is actually holding
## (Controls.using_gamepad(), the same last-input heuristic the HUD's ability keycaps use —
## not mere connection presence, so a controller left plugged in but unused doesn't hide
## keyboard rebinds from a mouse-and-keyboard player). On a gamepad: movement rows drop
## entirely (the D-pad/stick isn't individually remapped, so there's nothing to show), and
## each ability shows only its gamepad button. On keyboard: the reverse — WASD-style
## keyboard binds only, no gamepad buttons cluttering the list.
func _build_rebind_rows() -> void: func _build_rebind_rows() -> void:
for child in rebind_list.get_children(): for child in rebind_list.get_children():
child.queue_free() child.queue_free()
var gamepad := Controls.using_gamepad()
_rebind_rows_show_gamepad = gamepad
_update_rebind_hint(gamepad)
for action: StringName in ACTION_LABELS: for action: StringName in ACTION_LABELS:
var is_joy_rebindable := Controls.JOY_REBINDABLE_ACTIONS.has(String(action))
if gamepad and not is_joy_rebindable:
continue # movement: nothing to show/rebind on a gamepad
var row := HBoxContainer.new() var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -309,16 +330,45 @@ func _build_rebind_rows() -> void:
name_label.add_theme_constant_override("outline_size", 5) name_label.add_theme_constant_override("outline_size", 5)
row.add_child(name_label) row.add_child(name_label)
var key_button := Button.new() if not gamepad:
key_button.custom_minimum_size = Vector2(160, 0) var key_button := Button.new()
key_button.text = Controls.get_key_label(action) key_button.custom_minimum_size = Vector2(160, 0)
_style_button(key_button, false) key_button.text = Controls.get_key_label(action)
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button)) _style_button(key_button, false)
row.add_child(key_button) key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
row.add_child(key_button)
if gamepad and is_joy_rebindable:
var joy_button := Button.new()
joy_button.custom_minimum_size = Vector2(72, 0)
joy_button.text = Controls.get_joy_button_label(action)
_style_button(joy_button, false)
joy_button.pressed.connect(_on_rebind_joy_pressed.bind(action, joy_button))
row.add_child(joy_button)
rebind_list.add_child(row) rebind_list.add_child(row)
func _update_rebind_hint(gamepad: bool) -> void:
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
if hint != null:
hint.text = "Click a button, then press a new one on your controller. Esc cancels." \
if gamepad else "Click a key, then press a new key. Esc cancels."
# Live-swaps the rebind list if the player switches device while the Options panel is open
# (e.g. they were clicking through with a mouse, then pick up a controller). Skipped while
# actively listening for a rebind so an in-progress capture never gets torn out from under
# the player.
func _update_rebind_rows_for_device() -> void:
if not options_panel.visible:
return
if _listening_action != &"" or _listening_joy_action != &"":
return
if Controls.using_gamepad() != _rebind_rows_show_gamepad:
_build_rebind_rows()
func _on_play_pressed() -> void: func _on_play_pressed() -> void:
_set_buttons_disabled(true) _set_buttons_disabled(true)
Run.start_new_run() # fresh map for this run; the arena is the intro fight Run.start_new_run() # fresh map for this run; the arena is the intro fight
@@ -369,17 +419,26 @@ func _on_rebind_pressed(action: StringName, button: Button) -> void:
button.text = "Press a key…" button.text = "Press a key…"
func _on_rebind_joy_pressed(action: StringName, button: Button) -> void:
_cancel_listening()
_listening_joy_action = action
_listening_joy_button = button
button.text = "Press…"
func _cancel_listening() -> void: func _cancel_listening() -> void:
if _listening_button != null: if _listening_button != null:
_listening_button.text = Controls.get_key_label(_listening_action) _listening_button.text = Controls.get_key_label(_listening_action)
if _listening_joy_button != null:
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
_listening_action = &"" _listening_action = &""
_listening_button = null _listening_button = null
_listening_joy_action = &""
_listening_joy_button = null
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if _listening_action == &"": if _listening_action != &"" and event is InputEventKey and event.pressed and not event.echo:
return
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_ESCAPE: if event.physical_keycode == KEY_ESCAPE:
_cancel_listening() _cancel_listening()
else: else:
@@ -390,3 +449,18 @@ func _unhandled_input(event: InputEvent) -> void:
_listening_action = &"" _listening_action = &""
_listening_button = null _listening_button = null
get_viewport().set_input_as_handled() get_viewport().set_input_as_handled()
return
if _listening_joy_action != &"":
if event is InputEventKey and event.pressed and not event.echo \
and event.physical_keycode == KEY_ESCAPE:
_cancel_listening()
get_viewport().set_input_as_handled()
elif event is InputEventJoypadButton and event.pressed:
var new_joy_event := InputEventJoypadButton.new()
new_joy_event.button_index = event.button_index
Controls.rebind_joy(_listening_joy_action, new_joy_event)
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
_listening_joy_action = &""
_listening_joy_button = null
get_viewport().set_input_as_handled()
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
uid://b5rupuoe1lk5j