27 Commits

Author SHA1 Message Date
richard a4d18e4928 Move bull ability FX into the authorable systems/fx/ library
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
The bull's kick/dash/slam/roll and movement particles were ~112 lines of
hand-built CPUParticles3D in player.gd, bypassing the Fx system entirely
(Fx.spawn had zero callers) — so Siim couldn't author them in the inspector.

- Add six inspector-editable effect scenes (bull_slam/kick/dash one-shots +
  bull_hoof_dust/charge_trail/roll_sparks continuous); wire player.gd to
  Fx.spawn() / new Fx.attach(); register them + the gallery + smoke test.
- Extract the coded, non-particle slam rings + kick flash into a new
  bull_impact_fx.gd component (bull_legs.gd pattern).
- Drop 12 now-obsolete DP dust look-params (the .tscn owns the look now).
- Trim god-file comment noise: rewrite an AI-placeholder barrel block to typed
  code, drop dead commented-out bounce/topple code, fix stale post-reorg file
  paths (Assets/… → art/…, PascalCase scene names → snake_case).
- Document the Siim workflow in systems/fx/README.md; update CLAUDE.md.

player.gd 1307 → 944 lines. Full suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:35:25 +03:00
richard c742c4ae0e Remove the dead Slay-the-Spire map system (superseded by the lobby)
The lobby hub replaced the between-fights map, leaving the whole map
subsystem unused. Remove it:

- Delete MapScreen.tscn + map_screen.gd (the dead screen) and map_node.gd
  (the MapNode class).
- Strip map generation + navigation from run_state.gd (~90 lines:
  _generate_map/_link_rows/reachable/select_node/node_at/current_node/
  run_complete/complete_current_level, plus map/player_row/col/pending_row/col).
  ensure_run() now guards on a _run_active flag instead of the map array, so
  the shell reloading for each fight still can't wipe the run in progress.
- Simplify active_level() to the lobby flow (override -> lobby -> gate -> arena).
- Drop the now-unread LevelDef.color / is_boss fields and the _make_level args.
- Remove the map test from logic_test.gd.

Live flow unchanged (lobby gates via choose_level, wins via return_to_lobby).
Full headless suite green; menu + shell boot into the lobby clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbGXbzg3SD6ZBSadPPF5H
2026-09-07 01:23:12 +03:00
richard 5caccd318c Drop version suffixes from Blender source filenames
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Rename the Blender working files to plain role names, dropping the version
noise: bull_v11 -> bull, matador_v03 -> matador, publik_mees_v01 ->
publik_mees, and collapse anim_bull2 into anim_bull (identical rig, newer
save kept; older duplicate dropped). Repoint create_matador_anims.py (blend
input + fbx export name), render_anim_preview.py and CLAUDE.md at the new
names.

The anim_* and sword files link the model blends as Blender libraries
(//bull_v11.blend, //matador_v03.blend, //publik_mees_v01.blend), so those
links need repointing once in Blender. The game is unaffected — it ships the
exported fbx/glb, not the .blend sources (art/blender/ is Godot-ignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbGXbzg3SD6ZBSadPPF5H
2026-09-07 01:05:09 +03:00
richard 78efca445a clean up yt link
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
2026-09-07 00:56:05 +03:00
richard 5078502228 Move Blender sources under art/blender and snake_case them
Relocate the Blender working files into art/blender/ (Godot-ignored via
the .gdignore that travels with them) and lowercase the ANIM_* files to
snake_case: anim_bull, anim_bull2, anim_matador, anim_publik_mees. Drop
the stray matador2.gd.uid (no matador2.gd exists).

Repoint .gitignore (preview + blend-backup patterns), render_anim_preview.py
and CLAUDE.md at art/blender/, and update the anim-doc references from the
deleted matador_v02 to matador_v03.

No game impact: art/blender/ sits outside Godot's import graph (confirmed:
no .blend imported, preview stays gitignored, menu boots clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbGXbzg3SD6ZBSadPPF5H
2026-09-07 00:54:34 +03:00
richard 7bc241bbe6 Consolidate raw art under art/ and prune unused assets
Group all raw art by role under art/ (characters, levels, weapons, fx,
anim, textures, ui, icons, fonts, hdri, materials), matching the earlier
code reorg. Files are snake_case; asset uids are preserved so uid:// refs
still resolve, and every res:// path reference is rewritten across scenes,
scripts and project.godot.

- Move the used assets out of Assets/ Animations/ fonts/ HUD/ HDRI/
  Materials/ into art/; remove those dirs.
- Materials snake_cased: barrel_metal, barrel_wood, bear_claw_fx, fire.
- Delete unreferenced assets: alternate models, empty stub scripts,
  superseded BEAR_*.tres, marketing/spare PNGs, a spare font weight and
  the orphan M_Barrel_wood.gdshader.
- Blender: drop .blend auto-backups and matador_v01/v02 (+ stale v02 fbx);
  keep matador_v03 and repoint create_matador_anims.py to it. .gitignore
  now blocks all Blender/*.blend[0-9] backups.
- GLB Extract-Textures siblings (art/levels/*.png) kept and tracked.
- Document the art/ layout in CLAUDE.md.

Headless suite green; menu and gameplay shell boot clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbGXbzg3SD6ZBSadPPF5H
2026-09-07 00:46:34 +03:00
richard 534b7f45ca Untrack the stray Assets/bear_level.tscn swept in during the reorg
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
An unreferenced duplicate of levels/bear_level.tscn (the canonical bear level) that
git add -A pulled in by accident. Return it to untracked — it's kept on disk so
Richard can decide whether it's an accidental editor save-out to delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 01:32:04 +03:00
richard 524a222b6c Point tests/README.md at the Gitea CI path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 01:31:40 +03:00
richard 9ca6f282f5 Drop the stale GitHub-workflow reference from the Gitea CI header
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 01:31:29 +03:00
richard 64a8a9863d Remove the GitHub Actions workflow; Gitea is the CI host
The team hosts on Gitea, so .github/workflows/ci.yml is dead weight. The Gitea
workflow (.gitea/workflows/ci.yml) is the single source of CI: lint + headless
tests on push/PR, plus the daily safety-net run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 01:31:09 +03:00
richard c5ef68e82a Add a daily safety-net run to Gitea CI that skips already-tested commits
Add a schedule trigger (04:00 UTC daily) + workflow_dispatch to the Gitea CI, plus a
guard job: on push/PR it passes straight through, but on the daily run it asks the
Gitea API whether main's tip already carries a successful 'ci/headless' commit status
and, if so, skips the test job. The test job stamps that status when green, so a
commit already verified at push time is never re-tested — the nightly only spends a
runner on a tip whose push-time run was missed, skipped, or failed.

Also fix a cold-cache bug the nightly would have hit anyway: CI always starts without
a .godot cache, and a single 'godot --import' pass leaves .fbx/.glb-derived scenes
unresolved (the first pass imports base assets, the second resolves dependents), so
the headless tests couldn't load them. Import now runs twice.

Needs Gitea >= 1.21 (schedule) / >= 1.23 (the manual-run button); the auto-injected
GITEA_TOKEN needs statuses:write for the marker, granted via workflow permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 01:22:10 +03:00
richard 4569d3f5d4 Update CLAUDE.md paths + folder-layout overview for the reorg
Repoint the project-structure table and lint command at the new role-based tree,
and add a folder-layout summary up top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 00:06:33 +03:00
richard 7b6a141767 Reorganize the codebase into role-based folders (snake_case)
Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.

Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.

Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 00:04:41 +03:00
richard b839252fe0 Track the UID sidecars for the new DamageEvent and test-harness scripts
These .gd files were committed earlier without their generated .uid siblings; the
repo tracks .uid everywhere else, so commit them too and stop each machine
regenerating (and churning) the resource UIDs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq1VX2o1ZfiNRdCjD294wf
2026-09-05 10:52:23 +03:00
richard ac0da2a6fc Add Gitea Actions CI mirroring the GitHub workflow
Gitea Actions is GitHub-Actions-compatible and reads .gitea/workflows/, so the
lint + headless-test gate runs on the team's own Gitea host. Unlike the GitHub
workflow it installs Godot (curl) and gdtoolkit (venv) with plain shell steps, so
it doesn't depend on the chickensoft setup-godot action being reachable from a
self-hosted runner — only actions/checkout and upload-artifact remain external,
both covered by Gitea's default action proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq1VX2o1ZfiNRdCjD294wf
2026-09-05 10:52:23 +03:00
richard c3f92d8758 Move the FX placeholder seeder into tools/
_seed_placeholders.gd is a one-off generator, not a shipped effect — it belongs
with the other headless tooling (bake_crowd_sheet, preview_*), not in the fx/
runtime dir Siim edits. Rename it to tools/seed_fx_placeholders.gd (matching the
dir's verb_noun convention) and repoint the two doc-comment references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq1VX2o1ZfiNRdCjD294wf
2026-09-05 10:52:23 +03:00
richard 9c4563a973 Complete the DamageEvent migration: one deal() entry point for every attacker
The bull's Hitbox already routed through DamageEvent, but the spear, the bear's
maul, and the matador's reach-gore still did raw Object.call(&"take_sword_hit", ...)
— the exact scattered string paths DamageEvent was meant to retire. Add a single
static DamageEvent.deal(target, cause, at, source, knock_up) that builds the typed
event and hands it to receive_damage(), falling back to the legacy string call for
any not-yet-migrated victim, and route all four attackers (Hitbox included) through
it. The damage_pipeline characterization test proves the bull's outcomes are
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq1VX2o1ZfiNRdCjD294wf
2026-09-05 10:52:10 +03:00
richard ffe3052468 Fix stale logic-test reference to renamed param
mat_spawn_count was renamed mat_wave_size; the test still queried the old key
(returning 0.0). Point it at the current param + its registered range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:19:19 +03:00
richard f02185e859 Fix pre-existing lint errors blocking the new CI gate
A too-long line in console.gd and trailing whitespace in matador_spawn.gd
predate this branch but fail gdlint, which the new CI runs on every push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:18:21 +03:00
richard 70274f1773 Route the bull's hits through the typed DamageEvent pipeline
Hitbox now builds a DamageEvent (cause + hit position + shaped knock-up) and
calls the bull's new receive_damage(), instead of two stringly Object.call()s.
receive_damage adapts over the existing pip/knock-up logic so behaviour is
identical -- the characterization test drives both the string and typed paths
and asserts the same outcomes. Wire fx/damage/harness tests into run_tests.sh
+ CLAUDE.md. All hitbox guard tests green (stab, bear hitbox, ram, charge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:17:04 +03:00
richard 973db7d8b8 Add scene-authored FX effect library and Fx autoload
Replace code-buried CPUParticles with inspector-editable effect scenes fired
through one Fx autoload: code names the effect (Fx.spawn(&"blood_burst", xform)),
the .tscn owns the look. Ships four placeholder effects (blood_burst, slam_dust,
spark, hit_flash) seeded from the old code params, a self-freeing one_shot_effect
root helper, a live preview gallery, and an FX style guide for Siim. CPUParticles
only (mobile GLES3 constraint). Existing call sites untouched -- migrated later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 00:13:43 +03:00
richard 1ea7f7a744 Add characterization test locking the bull's damage reception
Pins take_sword_hit / apply_knock_up behaviour (pip loss, i-frames, single
tagged death, upward-only knock-up) before WS2 reroutes them through
DamageEvent. 12/12 headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:13:23 +03:00
richard 64fabb688f Add typed DamageEvent contract for the unified hit pipeline
The single payload every attack will route through, replacing the stringly
Object.call(&"take_sword_hit", "gored") paths. Standalone for now; the
per-actor slices wire hitbox.gd and the bull/matador/bear receivers onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:09:35 +03:00
richard 9122a601d0 Wire harness self-test into run_tests.sh
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:07:46 +03:00
richard a6f938a0da Add CI workflow and reusable test-harness foundation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-05 00:07:03 +03:00
richard 994ce3be73 Add headless UI-layout robustness test
Asserts computed Control geometry instead of pixels: HUD/menu stay on-screen,
text fits its box, tap targets >=44px, and the options scroll can reach its
overflow -- across five resolutions plus a +40% pseudo-locale pass. Wired into
run_tests.sh and the CLAUDE.md test list. 16/16 pass headless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:01:06 +03:00
richard 39aacc3c87 Remove editor autosave cruft and orphan scenes
Delete 17 tracked *.tmp editor autosaves and three verified-unreferenced
orphans (Assets/bear_level.tscn, Assets/matador_2.tscn, matador_2.gd), and
gitignore *.tmp so they never return. The real Assets/bear_level_arena.glb is
kept — it still backs levels/bear_level.tscn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-05 00:01:06 +03:00
23 changed files with 40 additions and 622 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -835,8 +835,9 @@ 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.2 seconds # Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
Controls.vibrate(0, 0.5, 1, 0.2) if DP.b("gamepad_vibration"):
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, 1057197468) index="0" unique_id=840295370] [node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996]
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
@@ -1,14 +0,0 @@
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
@@ -1 +0,0 @@
uid://cmapsaj010q40
-14
View File
@@ -1,14 +0,0 @@
[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
+3 -7
View File
@@ -2,7 +2,6 @@
[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"]
@@ -46,8 +45,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.5266291, 0.5549962, 0.9999985, 1) light_color = Color(0.78431374, 0.8117647, 1, 1)
light_energy = 0.466 light_energy = 0.228
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]
@@ -100,9 +99,6 @@ 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")
@@ -244,7 +240,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=2038841618] [node name="Plane" parent="Lobby_underground" index="1" unique_id=1962555538]
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]
+2 -1
View File
@@ -11,4 +11,5 @@ 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")
Controls.vibrate_for(0, 0.5, .5, 7.0) if DP.b("gamepad_vibration"):
Input.start_joy_vibration(0, 0.5, .5, 7)
+1
View File
@@ -83,6 +83,7 @@ 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,14 +33,6 @@ 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
@@ -113,9 +105,6 @@ 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()
@@ -165,39 +154,3 @@ 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,6 +8,5 @@ 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.5.0" config/version="0.4.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,7 +167,6 @@ 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,9 +12,6 @@ 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
@@ -65,14 +62,6 @@ 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))
@@ -107,7 +96,6 @@ 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)
@@ -122,7 +110,6 @@ 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
+6 -24
View File
@@ -20,7 +20,6 @@ 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",
} }
@@ -207,6 +206,12 @@ 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()
@@ -471,8 +476,6 @@ 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
@@ -605,27 +608,6 @@ 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()
+6 -220
View File
@@ -7,7 +7,6 @@ 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 = [
@@ -21,162 +20,12 @@ 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
@@ -373,59 +222,8 @@ 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
@@ -434,7 +232,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()
@@ -443,20 +241,16 @@ 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)
@@ -471,20 +265,12 @@ 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
+8 -191
View File
@@ -3,10 +3,6 @@ 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.
@@ -44,11 +40,7 @@ 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
@@ -74,10 +66,6 @@ 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
@@ -87,27 +75,16 @@ 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()
@@ -115,7 +92,6 @@ 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()
@@ -230,42 +206,8 @@ 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():
@@ -401,7 +343,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:
_on_win_button_pressed() _continue_to_lobby()
else: else:
_restart() _restart()
elif event.physical_keycode == KEY_TAB and not _game_over: elif event.physical_keycode == KEY_TAB and not _game_over:
@@ -431,24 +373,6 @@ 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"
@@ -474,18 +398,6 @@ 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
@@ -522,10 +434,6 @@ 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)
@@ -542,78 +450,12 @@ 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
_is_final_boss = _result_win and Run.active_level().id == &"bear" _over_label.text = "Bull Won!" if _result_win else "Matadors Won!"
_present_outcome() # A win advances the run (on to the map); a loss ends it (retry from the top).
## 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 := _THANKYOU_VIDEO if _is_final_boss else (_WIN_VIDEO if _result_win else _LOSS_VIDEO) var path := _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()
@@ -626,29 +468,6 @@ func _present_outcome() -> 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)
@@ -705,11 +524,10 @@ 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, or — beating the bear, the run's last fight — start a # Win → continue to the run map; loss → restart from the top. Shown per-result in
# fresh run; loss → restart from the top. Text is set per-outcome in _show_game_over; # _show_game_over; both share the Enter shortcut and label.
# both routes share the Enter shortcut.
_over_continue = _make_button("Continue (Enter)") _over_continue = _make_button("Continue (Enter)")
_over_continue.pressed.connect(_on_win_button_pressed) _over_continue.pressed.connect(_continue_to_lobby)
row.add_child(_over_continue) row.add_child(_over_continue)
_over_again = _make_button("Continue (Enter)") _over_again = _make_button("Continue (Enter)")
@@ -919,7 +737,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.ability_key_label(_ABILITY_ACTIONS[idx]) key_lbl.text = Controls.get_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
@@ -929,7 +747,6 @@ 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
+3 -77
View File
@@ -80,11 +80,6 @@ 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:
@@ -189,7 +184,6 @@ 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()
@@ -213,7 +207,6 @@ 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)
@@ -298,24 +291,10 @@ 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
@@ -330,7 +309,6 @@ 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)
if not gamepad:
var key_button := Button.new() var key_button := Button.new()
key_button.custom_minimum_size = Vector2(160, 0) key_button.custom_minimum_size = Vector2(160, 0)
key_button.text = Controls.get_key_label(action) key_button.text = Controls.get_key_label(action)
@@ -338,37 +316,9 @@ func _build_rebind_rows() -> void:
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button)) key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
row.add_child(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
@@ -419,26 +369,17 @@ 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 != &"" and event is InputEventKey and event.pressed and not event.echo: if _listening_action == &"":
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:
@@ -449,18 +390,3 @@ 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
@@ -1 +0,0 @@
uid://b5rupuoe1lk5j