24 Commits

Author SHA1 Message Date
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
55 changed files with 1062 additions and 1235 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -7
View File
@@ -43,9 +43,7 @@ art/ all raw art, grouped by role (snake_case):
| `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` | | `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` |
| `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` | | `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` |
| `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) | | `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) |
| `actors/player/player.gd` | Movement, charge, turning, and abilities (kick/dash/slam/roll). Ability & movement particle FX are **not** built here — they're authored scenes in `systems/fx/` (`bull_*.tscn`), fired via `Fx.spawn()` (one-shots) / `Fx.attach()` (continuous) | | `actors/player/player.gd` | Movement, charge, turning logic |
| `actors/player/bull_impact_fx.gd` | Bull's coded, non-particle impact FX (the slam shockwave rings + kick flash-pop — tweened meshes, not CPUParticles3D, so they can't be `systems/fx/` scenes). Created by `player.gd` |
| `actors/player/bull_legs.gd` | Bull body-wobble + tail Verlet-chain simulation. Created by `player.gd` |
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) | | `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) | | `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
| `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) | | `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) |
@@ -54,8 +52,10 @@ art/ all raw art, grouped by role (snake_case):
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section | | `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) | | `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher | | `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
| `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads | | `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights |
| `levels/level_def.gd` | `LevelDef` resource — a level's name / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) | | `levels/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
| `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group | | `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
| `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed | | `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
| `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag | | `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
@@ -101,7 +101,7 @@ art/ all raw art, grouped by role (snake_case):
## Animation & bone conventions ## Animation & bone conventions
### Blender rig (matador) ### Blender rig (matador_v03)
- **Armature bones:** `matador` (root) → `COG` → `chest` → `head`, `collarbone_L/R` → `arm_L/R` → `forearm_L/R` → `hand_L/R`, `leg_L/R` → `shin_L/R` → `foot_L/R` - **Armature bones:** `matador` (root) → `COG` → `chest` → `head`, `collarbone_L/R` → `arm_L/R` → `forearm_L/R` → `hand_L/R`, `leg_L/R` → `shin_L/R` → `foot_L/R`
- **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`) - **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`)
- **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward). - **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward).
@@ -119,7 +119,7 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
### Verification workflow ### Verification workflow
1. Edit bones/animations in Blender (`create_matador_anims.py`) 1. Edit bones/animations in Blender (`create_matador_anims.py`)
2. Run render preview: `blender --background art/blender/matador.blend --python art/blender/render_anim_preview.py` 2. Run render preview: `blender --background art/blender/matador_v03.blend --python art/blender/render_anim_preview.py`
3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot 3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot
4. Export FBX (done automatically by `create_matador_anims.py`) 4. Export FBX (done automatically by `create_matador_anims.py`)
5. Re-import in Godot and test in-game 5. Re-import in Godot and test in-game
+15 -3
View File
@@ -26,11 +26,11 @@ var _phase: Phase = Phase.WINDUP
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _skeleton: Skeleton3D = null var _skeleton: Skeleton3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
# Separate player baked into art/fx/bear_fx.glb: each clip scales one FX mesh up from 0 # Separate player baked into Assets/fx/Bear_FX.glb: each clip scales one FX mesh up from 0
# and back at its strike frame (self-hiding), so bear.gd just fires the matching clip. # and back at its strike frame (self-hiding), so bear.gd just fires the matching clip.
var _fx_player: AnimationPlayer = null var _fx_player: AnimationPlayer = null
var _fx_root: Node = null var _fx_root: Node = null
# Shaped attack colliders live in bear_attack_hitboxes.tscn (under bear_model, so they track the # Shaped attack colliders live in BearAttackHitboxes.tscn (under bear_model, so they track the
# bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash. # bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash.
# Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips # Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips
# "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the # "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the
@@ -635,7 +635,7 @@ func _try_hit(reach: float) -> bool:
return true return true
# Grab the designer-authored collider sub-scene (bear_attack_hitboxes.tscn instanced under # Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under
# bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene. # bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene.
func _setup_attack_hitboxes() -> void: func _setup_attack_hitboxes() -> void:
var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null
@@ -920,6 +920,18 @@ func _die(hit_dir: Vector3) -> void:
velocity = Vector3.ZERO velocity = Vector3.ZERO
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
_play(_anim_death) _play(_anim_death)
# No death clip on the rig — topple the beast onto its side (about the hit direction,
# so it falls the way it was struck) and sink it away, then free.
#var fall_axis := Vector3(-hit_dir.z, 0.0, hit_dir.x)
#if fall_axis.length() < 0.01:
# fall_axis = Vector3.RIGHT
#var tween := create_tween()
#tween.tween_property(_mesh, "rotation",
# _mesh.rotation + fall_axis.normalized() * deg_to_rad(88.0), 0.7)\
# .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
#tween.tween_interval(2.5)
#tween.tween_property(_mesh, "position:y", _mesh.position.y - 3.0, 1.0)
#tween.tween_callback(queue_free)
# ── Interface shared with the matador (guarded elsewhere via has_method) ───────────── # ── Interface shared with the matador (guarded elsewhere via has_method) ─────────────
+3 -3
View File
@@ -74,7 +74,7 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
# reparents from holster to hand at this point in the animation. # reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55 const _DRAW_GRAB_AT: float = 0.55
# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see sword.tscn / spear.tscn. # Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn.
const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn") const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn")
const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn") const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn")
@@ -603,7 +603,7 @@ func _tick_attack(delta: float) -> void:
if _swing_timer > 0.0: if _swing_timer > 0.0:
_decelerate(22.0, delta) _decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK) _play_anim(_ANIM_ATTACK)
# Damage off the sword's own BladeHitbox (sword.tscn), riding the animated blade — gored only # Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only
# when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap. # when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap.
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"): if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
_sword_blade_area.activate() _sword_blade_area.activate()
@@ -1189,7 +1189,7 @@ func _spawn_sword() -> void:
_drawing = false _drawing = false
_apply_sword_grip() _apply_sword_grip()
# The blade hitbox (sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see # The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
# _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull. # _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox _sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
if _sword_blade_area != null: if _sword_blade_area != null:
-110
View File
@@ -1,110 +0,0 @@
extends Node
## Procedural (non-particle) bull impact FX: the expanding ground-slam shockwave rings and the
## kick flash-pop. These are tween-animated MeshInstance3D flashes, not CPUParticles3D, so they
## can't be authored as systems/fx/ scenes and live in code here. Created by player.gd; the
## transient meshes are parented into the level (the bull's parent) so they hold their world
## position while the bull keeps moving.
var _player: Node3D
var _ring_mesh: ArrayMesh = null
func setup(player: Node3D) -> void:
_player = player
# Three nested shockwave rings racing outward from a ground slam, each fading as it grows.
func slam_rings(origin: Vector3, max_r: float) -> void:
if _ring_mesh == null:
_ring_mesh = _make_ring_mesh()
_launch_ring(origin, max_r, 0.00, 0.55, Color(1.0, 0.70, 0.10, 0.85))
_launch_ring(origin, max_r, 0.10, 0.52, Color(1.0, 0.50, 0.10, 0.65))
_launch_ring(origin, max_r, 0.20, 0.48, Color(0.9, 0.30, 0.10, 0.50))
# A quick bright flash-pop at the kick's contact point.
func kick_flash(origin: Vector3) -> void:
var mat := _flat_material(Color(1.0, 0.97, 0.40, 0.0))
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0
sphere.radial_segments = 8
sphere.rings = 4
sphere.material = mat
var mi := MeshInstance3D.new()
mi.mesh = sphere
mi.material_override = mat
mi.scale = Vector3(0.05, 0.05, 0.05)
_host().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, 0.92, 0.0, 0.14)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(0.16)
func _launch_ring(origin: Vector3, max_r: float, delay: float, duration: float, col: Color) -> void:
var mat := _flat_material(Color(col.r, col.g, col.b, 0.0))
var mi := MeshInstance3D.new()
mi.mesh = _ring_mesh
mi.material_override = mat
mi.scale = Vector3(0.02, 1.0, 0.02)
_host().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(max_r, 1.0, max_r), duration)\
.set_delay(delay).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, col.a, 0.0, duration)\
.set_delay(delay).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
func _flat_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = color
return mat
# A flat annulus in the XZ plane (unit radius), scaled up at runtime into the shockwave ring.
func _make_ring_mesh() -> ArrayMesh:
const INNER_R: float = 0.82
const OUTER_R: float = 1.00
const SEGMENTS: int = 48
var verts := PackedVector3Array()
var indices := PackedInt32Array()
for i: int in SEGMENTS:
var a0 := i / float(SEGMENTS) * TAU
var a1 := (i + 1) / float(SEGMENTS) * TAU
var vi := i * 4
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _host() -> Node:
if _player != null and _player.get_parent() != null:
return _player.get_parent()
return _player
-1
View File
@@ -1 +0,0 @@
uid://sf5etma4kjt0
+427 -63
View File
@@ -8,7 +8,7 @@ const HOOF_OFFSETS: Array = [
Vector3( 0.30, -0.25, 0.60), Vector3( 0.30, -0.25, 0.60),
] ]
# Bull animation clips (Armature|* action names in art/characters/bull.fbx). IDLE is the # Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the
# looping base pose; the others are one-shot ability clips that return to it. # looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE" const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK" const _ANIM_KICK: StringName = &"Armature|KICK"
@@ -27,11 +27,12 @@ var _legs: Node
var _was_on_floor: bool = false var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0 var _pre_slide_vel_y: float = 0.0
# Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL var _slam_ring_mesh: ArrayMesh = null
# driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop. # Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh
# StandardMaterial3D / SphereMesh per attack makes a mobile WebGL driver recompile the shader and
# re-upload the mesh each time — a main-thread hitch "when attacking" that never shows on desktop.
var _unshaded_mat: StandardMaterial3D = null var _unshaded_mat: StandardMaterial3D = null
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared) var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
var _impact_fx: Node = null # bull_impact_fx.gd — the coded (non-particle) slam rings + kick flash
# Ability system — kick=0, dash=1, slam=2, roll=3 # Ability system — kick=0, dash=1, slam=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
@@ -82,12 +83,8 @@ var _huff_timer: float = 0.0
enum DustState { NONE, WALK, CHARGE } enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE var _dust_state: DustState = DustState.NONE
# Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn). var _walk_dust_ramp: Gradient = null
# A charge sprint kicks up bigger, faster dust than a walk. var _charge_dust_ramp: Gradient = null
const _WALK_DUST_SCALE: Vector2 = Vector2(0.20, 0.40)
const _WALK_DUST_VEL: Vector2 = Vector2(0.8, 1.8)
const _CHARGE_DUST_SCALE: Vector2 = Vector2(0.35, 0.70)
const _CHARGE_DUST_VEL: Vector2 = Vector2(2.8, 5.5)
const RigidSkin = preload("res://systems/render/rigid_skin.gd") const RigidSkin = preload("res://systems/render/rigid_skin.gd")
@@ -109,7 +106,6 @@ func _ready() -> void:
_max_hp += _bonus_hp _max_hp += _bonus_hp
_hp += _bonus_hp _hp += _bonus_hp
_cube_scale = cube_guy.scale _cube_scale = cube_guy.scale
_setup_impact_fx()
_setup_hoof_dust() _setup_hoof_dust()
_setup_legs() _setup_legs()
_setup_charge_trail() _setup_charge_trail()
@@ -117,6 +113,7 @@ func _ready() -> void:
_setup_audio() _setup_audio()
_bull_anim = _find_anim_player(cube_guy) _bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle() _setup_bull_idle()
DP.any_changed.connect(_on_dp_changed)
func _setup_legs() -> void: func _setup_legs() -> void:
@@ -126,35 +123,113 @@ func _setup_legs() -> void:
_legs.call(&"setup", self, cube_guy) _legs.call(&"setup", self, cube_guy)
func _setup_impact_fx() -> void:
_impact_fx = Node.new()
_impact_fx.set_script(load("res://actors/player/bull_impact_fx.gd"))
add_child(_impact_fx)
_impact_fx.call(&"setup", self)
# The bull's ability/movement particle effects are authored as scenes in systems/fx/ (so Siim can
# tune them in the inspector). The continuous ones follow the bull — instanced once here as
# children, then toggled via `emitting`. The one-shots are fired on demand through Fx.spawn().
func _setup_hoof_dust() -> void: func _setup_hoof_dust() -> void:
var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
_walk_dust_ramp = Gradient.new()
_walk_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_walk_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
# White-beige dust while running
_charge_dust_ramp = Gradient.new()
_charge_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_charge_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2))
scale_curve.add_point(Vector2(0.4, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
for offset: Vector3 in HOOF_OFFSETS: for offset: Vector3 in HOOF_OFFSETS:
var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D var p := CPUParticles3D.new()
if p == null:
continue
p.position = offset p.position = offset
p.emitting = false
p.amount = 12
p.lifetime = DP.f("dust_lifetime")
p.explosiveness = DP.f("dust_explosiveness")
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = DP.f("dust_spread")
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.mesh = sphere
p.color_ramp = _walk_dust_ramp
p.scale_amount_curve = scale_curve
cube_guy.add_child(p)
_hoof_emitters.append(p) _hoof_emitters.append(p)
func _setup_charge_trail() -> void: func _setup_charge_trail() -> void:
_charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D # Blue energy trail — streams backward during a fast run / roll
if _charge_trail_emitter != null: var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material())
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
var trail_ramp := Gradient.new()
trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0))
trail_ramp.set_color(1, Color(0.30, 0.65, 1.0, 0.0))
var trail_scale := Curve.new()
trail_scale.add_point(Vector2(0.0, 1.0))
trail_scale.add_point(Vector2(0.6, 0.8))
trail_scale.add_point(Vector2(1.0, 0.0))
_charge_trail_emitter = CPUParticles3D.new()
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
_charge_trail_emitter.emitting = false
_charge_trail_emitter.one_shot = false
_charge_trail_emitter.amount = 18
_charge_trail_emitter.lifetime = 0.30
_charge_trail_emitter.randomness = 0.4
_charge_trail_emitter.local_coords = false
_charge_trail_emitter.direction = Vector3(0.0, 0.2, 1.0).normalized()
_charge_trail_emitter.spread = 25.0
_charge_trail_emitter.gravity = Vector3(0.0, 2.0, 0.0)
_charge_trail_emitter.initial_velocity_min = 4.0
_charge_trail_emitter.initial_velocity_max = 9.0
_charge_trail_emitter.scale_amount_min = 0.4
_charge_trail_emitter.scale_amount_max = 1.2
_charge_trail_emitter.mesh = trail_sphere
_charge_trail_emitter.color_ramp = trail_ramp
_charge_trail_emitter.scale_amount_curve = trail_scale
cube_guy.add_child(_charge_trail_emitter)
func _setup_roll_sparks() -> void: func _setup_roll_sparks() -> void:
_roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D # Rammus Powerball ground spray — dirt/sparks flung outward from the spinning
if _roll_spark_emitter != null: # ball. World-space (local_coords off) so they peel off into a trail behind it.
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0) var spark := _particle_sphere(0.05, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(1.00, 0.78, 0.32, 1.0))
ramp.set_color(1, Color(0.70, 0.42, 0.14, 0.0))
var sc := Curve.new()
sc.add_point(Vector2(0.0, 0.30))
sc.add_point(Vector2(0.3, 1.00))
sc.add_point(Vector2(1.0, 0.00))
_roll_spark_emitter = CPUParticles3D.new()
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
_roll_spark_emitter.emitting = false
_roll_spark_emitter.one_shot = false
_roll_spark_emitter.amount = 46
_roll_spark_emitter.lifetime = 0.45
_roll_spark_emitter.randomness = 0.7
_roll_spark_emitter.local_coords = false
_roll_spark_emitter.direction = Vector3(0.0, 1.0, 0.0)
_roll_spark_emitter.spread = 180.0
_roll_spark_emitter.gravity = Vector3(0.0, -6.0, 0.0)
_roll_spark_emitter.initial_velocity_min = 3.0
_roll_spark_emitter.initial_velocity_max = 8.0
_roll_spark_emitter.scale_amount_min = 0.5
_roll_spark_emitter.scale_amount_max = 1.3
_roll_spark_emitter.mesh = spark
_roll_spark_emitter.color_ramp = ramp
_roll_spark_emitter.scale_amount_curve = sc
cube_guy.add_child(_roll_spark_emitter)
func _setup_audio() -> void: func _setup_audio() -> void:
@@ -176,20 +251,42 @@ func _setup_audio() -> void:
add_child(_crowd_player) add_child(_crowd_player)
# Toggle the hoof emitters and swap their scale/velocity for the walk vs charge feel. The blue
# charge trail is the roll/dash ability's, driven from the roll loop, not by dust state.
func _set_dust_state(new_state: DustState) -> void: func _set_dust_state(new_state: DustState) -> void:
if new_state == _dust_state: if new_state == _dust_state:
return return
_dust_state = new_state _dust_state = new_state
var scale := _CHARGE_DUST_SCALE if new_state == DustState.CHARGE else _WALK_DUST_SCALE
var vel := _CHARGE_DUST_VEL if new_state == DustState.CHARGE else _WALK_DUST_VEL
for p: CPUParticles3D in _hoof_emitters: for p: CPUParticles3D in _hoof_emitters:
p.scale_amount_min = scale.x p.lifetime = DP.f("dust_lifetime")
p.scale_amount_max = scale.y p.explosiveness = DP.f("dust_explosiveness")
p.initial_velocity_min = vel.x p.spread = DP.f("dust_spread")
p.initial_velocity_max = vel.y p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
p.emitting = new_state != DustState.NONE match new_state:
DustState.NONE:
p.emitting = false
DustState.WALK:
p.color_ramp = _walk_dust_ramp
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.emitting = true
DustState.CHARGE:
p.color_ramp = _charge_dust_ramp
p.scale_amount_min = DP.f("charge_scale_min")
p.scale_amount_max = DP.f("charge_scale_max")
p.initial_velocity_min = DP.f("charge_vel_min")
p.initial_velocity_max = DP.f("charge_vel_max")
p.emitting = true
# Blue energy trail belongs to the roll/dash ability only, not to fast WASD
# running — driven explicitly in the roll loop / _end_roll, not by dust state.
func _on_dp_changed(_key: String, _val: Variant) -> void:
if _hoof_emitters.is_empty() or _dust_state == DustState.NONE:
return
var prev := _dust_state
_dust_state = DustState.NONE
_set_dust_state(prev)
func _find_anim_player(node: Node) -> AnimationPlayer: func _find_anim_player(node: Node) -> AnimationPlayer:
@@ -584,10 +681,13 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost) mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
# ── Blood-spurt particle helpers ────────────────────────────────────────────── # ── Particle helpers ──────────────────────────────────────────────────────────
# The ability effects now live in systems/fx/; only the spear-wound blood below is still built in # All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
# code. Shared material, built once so the mobile driver compiles this particle shader a single # these three helpers remove the boilerplate each spawn function used to repeat.
# time (colour comes per-particle from the colour_ramp via vertex colour, not this material).
# One shared unshaded material for every particle FX (per-particle colour comes from the
# CPUParticles3D colour_ramp via vertex colour, not the material) — built once so the mobile
# driver compiles this shader a single time instead of on every attack.
func _unshaded_material() -> StandardMaterial3D: func _unshaded_material() -> StandardMaterial3D:
if _unshaded_mat == null: if _unshaded_mat == null:
_unshaded_mat = StandardMaterial3D.new() _unshaded_mat = StandardMaterial3D.new()
@@ -598,7 +698,8 @@ func _unshaded_material() -> StandardMaterial3D:
return _unshaded_mat return _unshaded_mat
# Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload. # Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so
# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one.
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh: func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var key := "%.4f:%d:%d" % [radius, segments, rings] var key := "%.4f:%d:%d" % [radius, segments, rings]
var cached: SphereMesh = _particle_sphere_cache.get(key) var cached: SphereMesh = _particle_sphere_cache.get(key)
@@ -623,31 +724,281 @@ func _free_after(node: Node, delay: float) -> void:
func _spawn_slam_fx() -> void: func _spawn_slam_fx() -> void:
if not _slam_ring_mesh:
_slam_ring_mesh = _make_slam_ring_mesh()
var max_r := DP.f("slam_range")
var origin := global_position + Vector3(0.0, -0.5, 0.0) var origin := global_position + Vector3(0.0, -0.5, 0.0)
_impact_fx.call(&"slam_rings", origin, DP.f("slam_range")) _launch_slam_ring(origin, max_r, 0.00, 0.55, Color(1.00, 0.70, 0.10, 0.85))
Fx.spawn(&"bull_slam", Transform3D(Basis(), origin)) _launch_slam_ring(origin, max_r, 0.10, 0.52, Color(1.00, 0.50, 0.10, 0.65))
_launch_slam_ring(origin, max_r, 0.20, 0.48, Color(0.90, 0.30, 0.10, 0.50))
_spawn_slam_dust(origin)
camera_pivot.call(&"trigger_hit", 0.60) camera_pivot.call(&"trigger_hit", 0.60)
func _launch_slam_ring(origin: Vector3, max_r: float,
delay: float, duration: float, col: Color) -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(col.r, col.g, col.b, 0.0)
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var mi := MeshInstance3D.new()
mi.mesh = _slam_ring_mesh
mi.material_override = mat
mi.scale = Vector3(0.02, 1.0, 0.02)
get_parent().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale",
Vector3(max_r, 1.0, max_r), duration)\
.set_delay(delay)\
.set_trans(Tween.TRANS_EXPO)\
.set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, col.a, 0.0, duration)\
.set_delay(delay)\
.set_trans(Tween.TRANS_SINE)\
.set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
func _spawn_slam_dust(origin: Vector3) -> void:
var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0))
ramp.set_color(1, Color(0.88, 0.74, 0.46, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.25))
scale_curve.add_point(Vector2(0.35, 1.6))
scale_curve.add_point(Vector2(1.00, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.85
p.amount = 48
p.lifetime = 1.1
p.randomness = 0.5
p.local_coords = false
p.direction = Vector3.UP
p.spread = 85.0
p.gravity = Vector3(0.0, -7.0, 0.0)
p.initial_velocity_min = 3.5
p.initial_velocity_max = 10.0
p.scale_amount_min = 0.5
p.scale_amount_max = 2.0
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.5)
func _spawn_kick_fx(back_dir: Vector3) -> void: func _spawn_kick_fx(back_dir: Vector3) -> void:
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0) var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir)) _spawn_kick_dust(origin, back_dir)
_impact_fx.call(&"kick_flash", origin) _spawn_kick_stones(origin, back_dir)
_spawn_kick_sparks(origin, back_dir)
_spawn_kick_flash(origin)
camera_pivot.call(&"trigger_hit", 0.35) camera_pivot.call(&"trigger_hit", 0.35)
func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95))
ramp.set_color(1, Color(1.0, 0.45, 0.05, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.3))
scale_curve.add_point(Vector2(0.3, 1.5))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.80
p.amount = 40
p.lifetime = 1.2
p.randomness = 0.5
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.7, 0.0)).normalized()
p.spread = 65.0
p.gravity = Vector3(0.0, -6.0, 0.0)
p.initial_velocity_min = 3.0
p.initial_velocity_max = 9.0
p.scale_amount_min = 0.5
p.scale_amount_max = 2.2
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.5)
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0))
ramp.set_color(1, Color(0.62, 0.58, 0.52, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.92
p.amount = 14
p.lifetime = 0.65
p.randomness = 0.6
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.35, 0.0)).normalized()
p.spread = 45.0
p.gravity = Vector3(0.0, -9.8, 0.0)
p.initial_velocity_min = 5.0
p.initial_velocity_max = 14.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.8
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.0)
func _spawn_kick_flash(origin: Vector3) -> void:
var mat := _unshaded_material()
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
var sphere := _particle_sphere(1.0, 8, 4, mat)
var mi := MeshInstance3D.new()
mi.mesh = sphere
mi.material_override = mat
mi.scale = Vector3(0.05, 0.05, 0.05)
get_parent().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, 0.92, 0.0, 0.14)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(0.16)
func _spawn_dash_burst() -> void: func _spawn_dash_burst() -> void:
Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir)) var box := BoxMesh.new()
box.size = Vector3(0.045, 0.045, 0.30)
box.material = _unshaded_material()
var ramp := Gradient.new()
ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0))
ramp.set_color(1, Color(0.50, 0.82, 1.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.92
p.amount = 18
p.lifetime = 0.22
p.randomness = 0.15
p.local_coords = false
p.direction = _dash_dir
p.spread = 15.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 18.0
p.initial_velocity_max = 35.0
p.scale_amount_min = 0.8
p.scale_amount_max = 2.8
p.mesh = box
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
p.restart()
_free_after(p, 0.6)
# A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
# effects bake their spray in local space, so aiming the spawn transform points the burst the var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material())
# right way. Falls back to an unrotated transform when `forward` has no horizontal component.
func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D: var ramp := Gradient.new()
var flat := Vector3(forward.x, 0.0, forward.z) ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0))
if flat.length_squared() < 0.0001: ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0))
return Transform3D(Basis(), origin)
return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP) var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.5, 0.6))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.95
p.amount = 22
p.lifetime = 0.28
p.randomness = 0.4
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.5, 0.0)).normalized()
p.spread = 30.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 8.0
p.initial_velocity_max = 18.0
p.scale_amount_min = 0.6
p.scale_amount_max = 2.0
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 1.0)
func _make_slam_ring_mesh() -> ArrayMesh:
const INNER_R: float = 0.82
const OUTER_R: float = 1.00
const SEGMENTS: int = 48
var verts := PackedVector3Array()
var indices := PackedInt32Array()
for i: int in SEGMENTS:
var a0 := i / float(SEGMENTS) * TAU
var a1 := (i + 1) / float(SEGMENTS) * TAU
var vi := i * 4
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
@@ -761,14 +1112,23 @@ func _physics_process(delta: float) -> void:
velocity.z = flat_n.z * exit_spd velocity.z = flat_n.z * exit_spd
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z) pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
# Ram straight through a barrel that formed the wall we bounced off. # --- ADD THIS BREAKABLE BARREL DETECTION CODE ---
var hit := col.get_collider() var collision = get_slide_collision(i)
if hit != null and hit.has_method(&"destroy_barrel"): var collider = collision.get_collider()
hit.call(&"destroy_barrel")
# Check if the object we hit has the "destroy_barrel" function
if collider and collider.has_method("destroy_barrel"):
# Optional: Only break if moving fast (e.g., sprinting or dashing)
# if velocity.length() > 5.0:
collider.destroy_barrel()
# ── Landing. Don't bounce ───────────────────────────────────────────────────── # ── Landing. Don't bounce ─────────────────────────────────────────────────────
if is_on_floor() and not _was_on_floor: if is_on_floor() and not _was_on_floor:
velocity.y = 0.0 velocity.y = 0.0
#Bouncing landing below
#var impact := -_pre_slide_vel_y
#if impact > DP.f("bounce_min_impact") and not _slam_pending:
#velocity.y = impact * DP.f("bounce_restitution")
# ── Slam landing ────────────────────────────────────────────────────────── # ── Slam landing ──────────────────────────────────────────────────────────
if _slam_pending and is_on_floor() and not _was_on_floor: if _slam_pending and is_on_floor() and not _was_on_floor:
@@ -797,6 +1157,8 @@ func _physics_process(delta: float) -> void:
if vel_flat.length_squared() > 0.1: if vel_flat.length_squared() > 0.1:
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized() _charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
# ── Hoof sounds ───────────────────────────────────────────────────────────
func get_hp() -> int: func get_hp() -> int:
return _hp return _hp
@@ -835,8 +1197,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.
@@ -911,6 +1274,7 @@ func _spawn_blood(hit_pos: Vector3) -> void:
_free_after(p, 1.5) _free_after(p, 1.5)
#hit flashing
func _hit_flash() -> void: func _hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false) var meshes := find_children("*", "MeshInstance3D", true, false)
+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
Binary file not shown.
+3 -3
View File
@@ -1,6 +1,6 @@
""" """
Blender 5.1 script: create walk + idle animations for matador. Blender 5.1 script: create walk + idle animations for matador_v03.
Run: blender --background matador.blend --python create_matador_anims.py Run: blender --background matador_v03.blend --python create_matador_anims.py
""" """
import bpy, math, os import bpy, math, os
@@ -130,7 +130,7 @@ ti.name = 'idle'
si = ti.strips.new('idle', 0, idle) si = ti.strips.new('idle', 0, idle)
# ── export FBX ───────────────────────────────────────────────────────────────── # ── export FBX ─────────────────────────────────────────────────────────────────
fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador.fbx') fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador_v03.fbx')
bpy.ops.export_scene.fbx( bpy.ops.export_scene.fbx(
filepath=fbx_path, filepath=fbx_path,
use_selection=False, use_selection=False,
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
""" """
Blender 5.1 script: render preview frames of matador animations to PNG. Blender 5.1 script: render preview frames of matador animations to PNG.
Run: blender --background matador.blend --python render_anim_preview.py Run: blender --background matador_v03.blend --python render_anim_preview.py
Outputs to art/blender/preview/ directory: Outputs to art/blender/preview/ directory:
walk_frame_00.png .. walk_frame_09.png walk_frame_00.png .. walk_frame_09.png
+10
View File
@@ -0,0 +1,10 @@
[gd_scene format=3 uid="uid://map_screen_v1"]
[ext_resource type="Script" path="res://levels/map_screen.gd" id="1_map"]
[node name="MapScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_map")
+8 -3
View File
@@ -1,8 +1,9 @@
class_name LevelDef class_name LevelDef
extends Resource extends Resource
## Definition of one playable level — the arena, the bear's den, the lobby hub, and later ## Definition of one playable level on the run map — the arena today, later a cave with
## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef ## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and
## (plus its scene) built in run_state.gd and served through the lobby. ## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its
## scene) handed out by the map generator in run_state.gd.
@export var id: StringName = &"" @export var id: StringName = &""
@export var display_name: String = "Level" @export var display_name: String = "Level"
@@ -14,10 +15,14 @@ extends Resource
## colliders and any level-specific subscripts. Swapped wholesale between levels so no ## colliders and any level-specific subscripts. Swapped wholesale between levels so no
## previous level's geometry (or its colliders) can leak into the next. ## previous level's geometry (or its colliders) can leak into the next.
@export var level_scene: PackedScene = null @export var level_scene: PackedScene = null
## Placeholder pip tint on the map until per-level icon art exists.
@export var color: Color = Color(0.80, 0.62, 0.24)
## Camera framing multiplier when this level loads — 1.0 is the shell camera's default ## Camera framing multiplier when this level loads — 1.0 is the shell camera's default
## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole ## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole
## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom. ## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom.
@export var camera_zoom: float = 1.0 @export var camera_zoom: float = 1.0
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
@export var is_boss: bool = false
## The enemy this level pits the bull against. Null → the usual matador wave (the ## The enemy this level pits the bull against. Null → the usual matador wave (the
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g. ## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
## the bear), and the fight is won when it falls. The arena scene is shared either way; ## the bear), and the fight is won when it falls. The arena scene is shared either way;
+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)
+13
View File
@@ -0,0 +1,13 @@
class_name MapNode
extends RefCounted
## One node on the run map. Regenerated every run and never persisted, so RefCounted
## rather than Resource. `pos` is normalized (0-1) so the map screen can lay the node out
## at any resolution; `links` are column indices in the *next* row this node connects to
## (the Slay-the-Spire branching path).
var row: int = 0
var col: int = 0
var pos: Vector2 = Vector2.ZERO
var level: LevelDef = null
var links: PackedInt32Array = PackedInt32Array()
var cleared: bool = false
+1
View File
@@ -0,0 +1 @@
uid://emq8i2bmkekn
+280
View File
@@ -0,0 +1,280 @@
extends Control
## Slay-the-Spire-style run map shown between fights. Placeholder art — coloured pips
## joined by paths — but fully interactive: the player picks one of the reachable nodes
## and is sent into that level. Every node is the arena for now (see run_state.gd); the
## screen is level-agnostic, so richer levels just need a LevelDef with its own colour.
const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.93, 0.88, 0.72)
const _EDGE_DIM := Color(0.45, 0.36, 0.18, 0.55)
const _EDGE_ACTIVE := Color(1.00, 0.82, 0.32, 0.95)
const _NODE_SIZE := 58.0
const _BOSS_SIZE := 88.0
const _MENU_SCENE := "res://ui/main_menu.tscn"
var _map_area: Control
var _completion: PanelContainer
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().paused = false
Run.ensure_run()
_build_ui()
_relayout.call_deferred() # defer so _map_area has its real size first
func _build_ui() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var bg := ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.09, 0.06, 0.03) # opaque leather, no scene showing through
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var title := Label.new()
title.text = "Choose Your Path"
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.offset_top = 22.0
title.add_theme_font_override("font", UiFonts.title())
title.add_theme_font_size_override("font_size", 46)
title.add_theme_color_override("font_color", _GOLD)
title.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
title.add_theme_constant_override("outline_size", 6)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(title)
_map_area = Control.new()
_map_area.set_anchors_preset(Control.PRESET_FULL_RECT)
_map_area.offset_top = 108.0
_map_area.offset_bottom = -92.0
_map_area.offset_left = 48.0
_map_area.offset_right = -48.0
_map_area.clip_contents = false
add_child(_map_area)
_map_area.resized.connect(_relayout)
var menu_btn := _make_button("Main Menu")
menu_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
menu_btn.grow_vertical = Control.GROW_DIRECTION_BEGIN
menu_btn.offset_left = 32.0
menu_btn.offset_bottom = -26.0
menu_btn.pressed.connect(_to_menu)
add_child(menu_btn)
_build_completion()
# ── Layout ──────────────────────────────────────────────────────────────────
# Rebuilt from the run state on first show and whenever the map area resizes. Edges are
# added first so the node pips draw on top of the paths.
func _relayout() -> void:
if _map_area == null:
return
for child in _map_area.get_children():
child.queue_free()
var size := _map_area.size
if size.x <= 0.0 or size.y <= 0.0:
return
var reachable := Run.reachable()
var reach_set := {}
for n: MapNode in reachable:
reach_set[n] = true
var here := Run.current_node()
if Run.player_row < 0:
var start_pos := Vector2(size.x * 0.5, size.y - 8.0)
_add_start_pip(start_pos)
for n: MapNode in reachable:
_add_edge(start_pos, _local(n, size), true)
for row: Array in Run.map:
for from_node: MapNode in row:
for to_col: int in from_node.links:
var to_node := Run.node_at(from_node.row + 1, to_col)
if to_node == null:
continue
var active := from_node == here and reach_set.has(to_node)
_add_edge(_local(from_node, size), _local(to_node, size), active)
for row: Array in Run.map:
for node: MapNode in row:
_add_node(node, size, reach_set.has(node))
_completion.visible = Run.run_complete() or reachable.is_empty()
func _local(node: MapNode, size: Vector2) -> Vector2:
return Vector2(node.pos.x * size.x, node.pos.y * size.y)
func _add_edge(a: Vector2, b: Vector2, active: bool) -> void:
var line := Line2D.new()
line.add_point(a)
line.add_point(b)
line.width = 4.0 if active else 2.5
line.default_color = _EDGE_ACTIVE if active else _EDGE_DIM
line.antialiased = true
_map_area.add_child(line)
func _add_start_pip(center: Vector2) -> void:
var pip := PanelContainer.new()
var sz := 22.0
pip.custom_minimum_size = Vector2(sz, sz)
pip.size = Vector2(sz, sz)
pip.position = center - Vector2(sz, sz) * 0.5
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
pip.add_theme_stylebox_override("panel", UiTheme.disc(_GOLD, _CREAM, 2))
_map_area.add_child(pip)
func _add_node(node: MapNode, size: Vector2, is_reachable: bool) -> void:
var is_current := node.row == Run.player_row and node.col == Run.player_col
var sz := _BOSS_SIZE if node.level.is_boss else _NODE_SIZE
var center := _local(node, size)
var btn := Button.new()
btn.custom_minimum_size = Vector2(sz, sz)
btn.size = Vector2(sz, sz)
btn.position = center - Vector2(sz, sz) * 0.5
btn.focus_mode = Control.FOCUS_NONE
btn.disabled = not is_reachable
btn.mouse_filter = Control.MOUSE_FILTER_STOP if is_reachable else Control.MOUSE_FILTER_IGNORE
var fill := node.level.color
var border := UiTheme.BORDER
if is_current:
border = _CREAM
elif is_reachable:
border = _GOLD
elif node.cleared:
fill = fill.darkened(0.35)
border = Color(_GOLD.r, _GOLD.g, _GOLD.b, 0.5)
else: # locked — a future row not yet reachable
fill = fill.darkened(0.6)
border = Color(0.40, 0.34, 0.20, 0.6)
var border_w := 4 if (is_reachable or is_current) else 2
var normal := UiTheme.disc(fill, border, border_w)
var hover := UiTheme.disc(fill.lightened(0.18), _CREAM, border_w)
btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_stylebox_override("disabled", normal)
_map_area.add_child(btn)
if is_reachable:
btn.pressed.connect(_enter_node.bind(node))
_pulse(btn)
if is_reachable or is_current or node.level.is_boss:
_add_caption(node.level.display_name, center + Vector2(0.0, sz * 0.5 + 6.0), is_reachable)
if is_current:
_add_caption("You are here", center - Vector2(0.0, sz * 0.5 + 18.0), false)
func _add_caption(text: String, center_top: Vector2, bright: bool) -> void:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.size = Vector2(200.0, 0.0)
lbl.position = center_top - Vector2(100.0, 0.0)
lbl.add_theme_font_override("font", UiFonts.body())
lbl.add_theme_font_size_override("font_size", 15)
lbl.add_theme_color_override("font_color", _CREAM if bright else UiTheme.MUTED)
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
lbl.add_theme_constant_override("outline_size", 4)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_area.add_child(lbl)
# Slow glow on the reachable pips so the eye lands on where the player can go next.
func _pulse(node: CanvasItem) -> void:
var tw := create_tween().set_loops()
tw.tween_property(node, "modulate:a", 0.6, 0.75).set_trans(Tween.TRANS_SINE)
tw.tween_property(node, "modulate:a", 1.0, 0.75).set_trans(Tween.TRANS_SINE)
# ── Actions ───────────────────────────────────────────────────────────────────
func _enter_node(node: MapNode) -> void:
Run.select_node(node)
get_tree().paused = false
get_tree().change_scene_to_file(node.level.scene_path)
func _to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file(_MENU_SCENE)
# ── Run-complete overlay ──────────────────────────────────────────────────────
func _build_completion() -> void:
_completion = PanelContainer.new()
_completion.set_anchors_preset(Control.PRESET_CENTER)
_completion.grow_horizontal = Control.GROW_DIRECTION_BOTH
_completion.grow_vertical = Control.GROW_DIRECTION_BOTH
_completion.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, _GOLD, 4, 32.0, 24.0)
)
_completion.visible = false
add_child(_completion)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 16)
col.alignment = BoxContainer.ALIGNMENT_CENTER
_completion.add_child(col)
var heading := Label.new()
heading.text = "Run Complete!"
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_override("font", UiFonts.title())
heading.add_theme_font_size_override("font_size", 40)
heading.add_theme_color_override("font_color", _GOLD)
heading.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
heading.add_theme_constant_override("outline_size", 5)
col.add_child(heading)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16)
col.add_child(row)
var again := _make_button("New Run")
again.pressed.connect(_new_run)
row.add_child(again)
var menu := _make_button("Main Menu")
menu.pressed.connect(_to_menu)
row.add_child(menu)
func _new_run() -> void:
Run.start_new_run()
_relayout()
# Shared thematic button, matching the HUD / menu plaques.
func _make_button(text: String) -> Button:
var btn := Button.new()
btn.text = text
btn.focus_mode = Control.FOCUS_NONE
btn.add_theme_stylebox_override(
"normal", UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
)
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_font_override("font", UiFonts.body())
btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _CREAM)
btn.add_theme_font_size_override("font_size", 18)
return btn
+1
View File
@@ -0,0 +1 @@
uid://dt3ffakbwlkmc
+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:
+182 -69
View File
@@ -1,11 +1,12 @@
extends Node extends Node
## Persistent run progression across scene changes — which hub/level the player is in. ## Persistent run progression across scene changes — the Slay-the-Spire-style map and
## The level scene is reloaded for every fight, so this state must outlive scene changes: ## where the player sits on it. The arena scene is reloaded for every fight, so this
## genuinely global run state earns an autoload (`Run`). ## state must outlive scene changes: genuinely global run state earns an autoload (`Run`).
## ##
## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win → ## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the
## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena ## first frontier → pick a node → its level scene → win → back to MapScreen advanced one
## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate. ## row, and on until a boss node ends the map. Every node is the arena for now; adding a
## level later is a new LevelDef in the catalog and pointing the generator at it.
signal run_started signal run_started
@@ -16,7 +17,21 @@ const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn") const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn") const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn")
## Debug override — when set, active_level() returns this regardless of run state. # Map shape. Small + placeholder for now; the final row is a single boss node.
const _ROWS := 5
const _MIN_PER_ROW := 2
const _MAX_PER_ROW := 4
## map[row] is an Array[MapNode]; empty until a run starts.
var map: Array = []
## Node the player currently occupies. (-1, -1) = the start gate below the first row —
## i.e. straight after the intro fight, before any map node has been chosen.
var player_row: int = -1
var player_col: int = -1
## Node picked on the map whose level is being played now; committed to on the next win.
var pending_row: int = -1
var pending_col: int = -1
## Debug override — when set, active_level() returns this regardless of map state.
## Set via the console `level` command; cleared on start_new_run(). ## Set via the console `level` command; cleared on start_new_run().
var debug_level_override: LevelDef = null var debug_level_override: LevelDef = null
## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead ## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
@@ -34,21 +49,10 @@ var cleared: Dictionary = {}
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
## when the shell reloads for each fight.
var _run_active: bool = false
# Level catalog. Every level shares the shell scene; what changes is the level module # Level catalog. Every level shares the shell scene; what changes is the level module
# (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss # (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss
# flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot # flavours) and the bear (a lone boss enemy that can turn up as a normal node) live here.
# in the same way: a new module + a LevelDef entry. # Future levels — china shop — slot in the same way: a new module + a LevelDef entry.
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn") const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
var _arena: LevelDef var _arena: LevelDef
@@ -58,27 +62,33 @@ var _lobby: LevelDef
func _ready() -> void: func _ready() -> void:
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL) _arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false)
_arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL) _arena_boss = _make_level(
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL) &"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true
)
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
_bear.enemy_scene = _BEAR_SCENE _bear.enemy_scene = _BEAR_SCENE
# The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose. # The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose.
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL) _lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL, Color(0.62, 0.66, 0.72), false)
# It's a roomy hub with nothing to fight — pull the camera back so more of it reads. # It's a roomy hub with nothing to fight — pull the camera back so more of it reads.
_lobby.camera_zoom = 1.6 _lobby.camera_zoom = 1.6
func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> LevelDef: func _make_level(
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
) -> LevelDef:
var l := LevelDef.new() var l := LevelDef.new()
l.id = id l.id = id
l.display_name = level_name l.display_name = level_name
l.scene_path = _SHELL_SCENE l.scene_path = _SHELL_SCENE
l.level_scene = level_scene l.level_scene = level_scene
l.color = color
l.is_boss = boss
return l return l
## The level being served right now: debug override wins, then the lobby hub or the level ## The level being fought right now: debug override wins, then the pending/current map
## picked at a gate, falling back to the plain arena for a direct scene launch. ## node, falling back to the plain arena for the intro fight or a direct scene launch.
func active_level() -> LevelDef: func active_level() -> LevelDef:
if debug_level_override != null: if debug_level_override != null:
return debug_level_override return debug_level_override
@@ -86,7 +96,10 @@ func active_level() -> LevelDef:
return _lobby return _lobby
if lobby_choice != null: if lobby_choice != null:
return lobby_choice return lobby_choice
return _arena var n := node_at(pending_row, pending_col)
if n == null:
n = current_node()
return n.level if n != null and n.level != null else _arena
## Return a level by id, or null if unknown. Used by the debug console `level` command. ## Return a level by id, or null if unknown. Used by the debug console `level` command.
@@ -104,30 +117,80 @@ func level_ids() -> Array[StringName]:
return [&"arena", &"arena_boss", &"bear", &"lobby"] return [&"arena", &"arena_boss", &"bear", &"lobby"]
## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu ## Begin a fresh run: build a new map and place the player at the start gate. Called from
## Play button, not from a level scene (which reloads for every fight). ## the main-menu Play button, not from the arena scene (which reloads for every fight).
func start_new_run() -> void: func start_new_run() -> void:
_generate_map()
player_row = -1
player_col = -1
pending_row = -1
pending_col = -1
debug_level_override = null debug_level_override = null
# The run opens in the lobby hub; its gates lead into the fights. # The run opens in the lobby hub; its gates lead into the fights.
enter_lobby = true enter_lobby = true
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_started.emit() run_started.emit()
## Build a run if none is active — a safety net for entering a level directly (e.g. running ## Build a run if none exists — a safety net for entering the map or arena directly (e.g.
## game_shell.tscn straight from the editor) without coming through the menu. Guarded so the ## running scene.tscn straight from the editor) without coming through the menu.
## shell reloading for each fight doesn't wipe the run in progress.
func ensure_run() -> void: func ensure_run() -> void:
if not _run_active: if map.is_empty():
start_new_run() start_new_run()
func has_run() -> bool:
return not map.is_empty()
func node_at(row: int, col: int) -> MapNode:
if row < 0 or row >= map.size():
return null
var r: Array = map[row]
if col < 0 or col >= r.size():
return null
return r[col]
func current_node() -> MapNode:
return node_at(player_row, player_col)
## Nodes the player may pick next: the whole first row from the start gate, otherwise the
## nodes the current node links forward to.
func reachable() -> Array:
if map.is_empty():
return []
if player_row < 0:
return (map[0] as Array).duplicate()
var here := current_node()
if here == null or player_row + 1 >= map.size():
return []
var out: Array = []
for c: int in here.links:
var n := node_at(player_row + 1, c)
if n != null:
out.append(n)
return out
## True once the player has cleared a boss node — the map is finished.
func run_complete() -> bool:
var here := current_node()
return here != null and here.level != null and here.level.is_boss
## Mark a node as the one being played (its level scene is about to load).
func select_node(node: MapNode) -> void:
if node == null:
return
enter_lobby = false
pending_row = node.row
pending_col = node.col
## Send the player to the lobby hub instead of a fight — active_level() serves the lobby ## Send the player to the lobby hub instead of a fight — active_level() serves the lobby
## until a gate is entered. ## until a gate is entered.
func go_to_lobby() -> void: func go_to_lobby() -> void:
@@ -167,37 +230,87 @@ func add_bonus_hp(amount: int) -> int:
return bonus_hp - before return bonus_hp - before
## Begin the run clock. No-op while the clock is already running, so walking back through ## Commit the level just won: move the player onto the pending node (or leave them at the
## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead ## start gate after the intro fight, which has no pending node).
## and the player has looped back to the arena to go again), the next call restarts it from func complete_current_level() -> void:
## zero rather than staying stuck showing the previous run's final time. if pending_row >= 0:
func start_run_timer() -> void: player_row = pending_row
if _timer_running: player_col = pending_col
var n := current_node()
if n != null:
n.cleared = true
pending_row = -1
pending_col = -1
# ── Map generation ────────────────────────────────────────────────────────────
# A small branching lattice: each row gets a handful of nodes, each node links to the
# nearest node(s) in the next row, and every next-row node is guaranteed a parent so the
# path is always connected (no orphan the player can never reach). The final row is a
# single boss node the whole map funnels toward.
func _generate_map() -> void:
map.clear()
for r: int in _ROWS:
var count := 1 if r == _ROWS - 1 else randi_range(_MIN_PER_ROW, _MAX_PER_ROW)
var y := 1.0 - float(r + 1) / float(_ROWS + 1) # row 0 near the bottom, boss on top
var row_nodes: Array = []
for c: int in count:
var node := MapNode.new()
node.row = r
node.col = c
node.pos = Vector2(_column_x(c, count), y)
# Boss row is always the Grand Arena; normal nodes are a mix of matador
# arenas and the occasional Bear's Den for variety.
if r == _ROWS - 1:
node.level = _arena_boss
else:
node.level = _bear if randf() < 0.35 else _arena
row_nodes.append(node)
map.append(row_nodes)
_link_rows()
func _column_x(col: int, count: int) -> float:
if count <= 1:
return 0.5
return lerpf(0.18, 0.82, float(col) / float(count - 1))
func _link_rows() -> void:
for r: int in _ROWS - 1:
var cur: Array = map[r]
var nxt: Array = map[r + 1]
var incoming := PackedInt32Array()
incoming.resize(nxt.size()) # zero-filled
for from_node: MapNode in cur:
var nearest := _nearest_index(from_node, nxt)
_add_link(from_node, nearest, incoming)
# Occasionally fan out to an adjacent next-row node so paths branch.
if nxt.size() > 1 and randf() < 0.45:
var step := 1 if randf() < 0.5 else -1
_add_link(from_node, clampi(nearest + step, 0, nxt.size() - 1), incoming)
# Guarantee every next-row node has at least one parent (no orphans).
for c: int in nxt.size():
if incoming[c] == 0:
_add_link(cur[_nearest_index(nxt[c], cur)], c, incoming)
func _add_link(from_node: MapNode, to_col: int, incoming: PackedInt32Array) -> void:
if to_col in from_node.links:
return return
_timer_start_msec = Time.get_ticks_msec() from_node.links.append(to_col)
_timer_end_msec = -1 incoming[to_col] += 1
_timer_running = true
## Freeze the run clock — called the instant the bear falls, so the recorded time is the # Index of the node in `others` whose column position is closest to `node` on the x axis —
## kill moment itself, not whatever the win-screen's celebratory delay adds on top. # used both to link a node forward and to adopt an orphan back to its nearest parent.
func stop_run_timer() -> void: func _nearest_index(node: MapNode, others: Array) -> int:
if not _timer_running: var best := 0
return var best_d := INF
_timer_end_msec = Time.get_ticks_msec() for i: int in others.size():
_timer_running = false var d: float = absf((others[i] as MapNode).pos.x - node.pos.x)
if d < best_d:
best_d = d
## Seconds elapsed since the timer started (live while running, held steady once best = i
## stop_run_timer() has been called). 0 before the timer has ever started. return best
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()
+13 -2
View File
@@ -46,10 +46,21 @@ func _register_all() -> void:
# (The dash ability bypasses this.) # (The dash ability bypasses this.)
_reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0) _reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0)
# ── Dust speed thresholds ───────────────────────────────────────────────── # ── Dust speed thresholds ─────────────────────────────────────────────────
# The dust LOOK (colour, size, velocity) lives in systems/fx/bull_hoof_dust.tscn now; these
# two only decide when walk vs charge dust kicks in by ground speed.
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
# ── Dust ──────────────────────────────────────────────────────────────────
_reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0)
_reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0)
_reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0)
_reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0)
_reg_f("Dust", "walk_scale_min", 0.20, 0.05, 3.0)
_reg_f("Dust", "walk_scale_max", 0.40, 0.05, 3.0)
_reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0)
_reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0)
_reg_f("Dust", "charge_scale_min", 0.35, 0.05, 6.0)
_reg_f("Dust", "charge_scale_max", 0.70, 0.05, 6.0)
_reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0)
_reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0)
# ── Tail ────────────────────────────────────────────────────────────────── # ── Tail ──────────────────────────────────────────────────────────────────
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0) _reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01) _reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
+3 -32
View File
@@ -6,9 +6,8 @@ change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code
```gdscript ```gdscript
# anywhere in gameplay code # anywhere in gameplay code
Fx.spawn(&"blood_burst", global_transform) # one-shot: fire and forget (self-frees) Fx.spawn(&"blood_burst", global_transform) # at a node
Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point
var trail := Fx.attach(&"bull_charge_trail", node) # continuous: caller keeps it + toggles .emitting
``` ```
## Siim's workflow ## Siim's workflow
@@ -26,33 +25,6 @@ var trail := Fx.attach(&"bull_charge_trail", node) # continuous: caller keeps i
New effect? Add a `fx/<name>.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy New effect? Add a `fx/<name>.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy
an existing one), then register its name → path in `fx/fx.gd`. an existing one), then register its name → path in `fx/fx.gd`.
## Bull ability effects
The bull's moves are wired to effect scenes here, so **you can retune every one in the inspector** —
open the `.tscn`, drag its colour ramp / curves / emission, save. No code.
| Ability | Effect scene(s) | Kind |
|---|---|---|
| Ground slam | `bull_slam.tscn` (dust) | one-shot burst |
| Back-kick | `bull_kick.tscn` — dust **+** its `stones` and `sparks` child emitters | one-shot burst |
| Dash / roll launch | `bull_dash.tscn` | one-shot burst |
| Hoof dust (walking / sprinting) | `bull_hoof_dust.tscn` | continuous |
| Charge trail (the blue streak) | `bull_charge_trail.tscn` | continuous |
| Roll sparks | `bull_roll_sparks.tscn` | continuous |
**Two kinds, edited the same way.** *One-shot* effects fire once and clean themselves up — they show
up in the gallery, so preview and tune them there. *Continuous* effects follow the bull and switch on
and off with what it's doing (dust while moving, the trail during a roll); the gallery can't show
those, so preview one by opening its `.tscn` and ticking **Emitting** on in the inspector's viewport.
Two things stay in code and aren't yours to tune here: the slam's expanding shockwave **rings** and the
kick's white **flash-pop** — those are stretched flat meshes, not particles (`actors/player/bull_impact_fx.gd`).
The dust's *walk-vs-sprint size* is also code (a fixed multiplier); its colour and shape are the `.tscn`.
**Composite effect** (like the kick): one scene, several `CPUParticles3D` — a root plus child emitters.
`one_shot_effect.gd` fires every emitter in the scene together, so you can build a layered hit
(dust + debris + sparks) as one file and tune each layer separately.
## House style (keep effects reading as one game) ## House style (keep effects reading as one game)
**Palette** — pull from these, don't invent per-effect colours: **Palette** — pull from these, don't invent per-effect colours:
@@ -80,9 +52,8 @@ the inspector and safe on-device. Keep this constraint when adding effects.
| File | What it is | | File | What it is |
|---|---| |---|---|
| `fx.gd` | The `Fx` autoload — `spawn()` (one-shot) / `attach()` (continuous); name→path registry | | `fx.gd` | The `Fx` autoload — `spawn(name, xform, parent=null)`; name→path registry |
| `one_shot_effect.gd` | Root helper: fires every emitter in the scene once, frees it when done | | `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done |
| `blood_burst.tscn` etc. | The effects themselves — **edit these** | | `blood_burst.tscn` etc. | The effects themselves — **edit these** |
| `bull_*.tscn` | The bull's ability effects (see the table above) — **edit these** |
| `effect_gallery.tscn` | Preview stage (run it to review) | | `effect_gallery.tscn` | Preview stage (run it to review) |
| `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime | | `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime |
-42
View File
@@ -1,42 +0,0 @@
[gd_scene format=3]
; Bull charge trail — the blue energy streak that streams off the bull during a roll/dash.
; Continuous: the player instances it once under the bull, toggles `emitting`, and steers
; `direction` opposite the travel direction each frame. Tune the look here; see README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.065
height = 0.13
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 0.8), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.3, 0.65, 1, 1, 0.3, 0.65, 1, 0)
[node name="bull_charge_trail" type="CPUParticles3D"]
emitting = false
amount = 18
lifetime = 0.3
local_coords = false
randomness = 0.4
mesh = SubResource("mesh")
direction = Vector3(0, 0.2, 1)
spread = 25.0
gravity = Vector3(0, 2, 0)
initial_velocity_min = 4.0
initial_velocity_max = 9.0
scale_amount_min = 0.4
scale_amount_max = 1.2
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
-44
View File
@@ -1,44 +0,0 @@
[gd_scene format=3]
; Bull dash burst — the one-shot streak of speed shards left when a dash/roll launches. Fired
; via Fx.spawn(&"bull_dash", xform); the spawn transform aims local -Z along the dash direction,
; so the shards streak the right way. Tune the look here; see systems/fx/README.md.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="BoxMesh" id="mesh"]
material = SubResource("mat")
size = Vector3(0.045, 0.045, 0.3)
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 2
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.5, 0.82, 1, 1, 0.5, 0.82, 1, 0)
[node name="bull_dash" type="CPUParticles3D"]
emitting = false
amount = 18
lifetime = 0.22
one_shot = true
local_coords = false
explosiveness = 0.92
randomness = 0.15
mesh = SubResource("mesh")
direction = Vector3(0, 0, -1)
spread = 15.0
gravity = Vector3(0, 0, 0)
initial_velocity_min = 18.0
initial_velocity_max = 35.0
scale_amount_min = 0.8
scale_amount_max = 2.8
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
script = ExtResource("oneshot")
-43
View File
@@ -1,43 +0,0 @@
[gd_scene format=3]
; Bull hoof dust — the little kick-up under each hoof while moving. Continuous (not one-shot):
; the player instances one per hoof, positions it, and toggles `emitting`. Charge sprint bumps
; scale/velocity in code. Tune the look here; see systems/fx/README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.07
height = 0.14
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.2), 0.0, 0.0, 0, 0, Vector2(0.4, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.94, 0.9, 0.83, 1, 0.94, 0.9, 0.83, 0)
[node name="bull_hoof_dust" type="CPUParticles3D"]
emitting = false
amount = 12
lifetime = 0.9
local_coords = false
explosiveness = 0.35
randomness = 0.6
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 45.0
gravity = Vector3(0, -1.5, 0)
initial_velocity_min = 0.8
initial_velocity_max = 1.8
scale_amount_min = 0.2
scale_amount_max = 0.4
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
-115
View File
@@ -1,115 +0,0 @@
[gd_scene format=3]
; Bull back-kick impact — the one-shot combined dust + stones + sparks thrown behind the kick.
; A composite effect: the root emitter is the dust; the "stones" and "sparks" child emitters fire
; with it (one_shot_effect.gd arms every CPUParticles3D in the scene). Fired via
; Fx.spawn(&"bull_kick", xform); the spawn transform aims local -Z along the kick-back direction.
; The bright flash-pop is a separate coded effect (bull_impact_fx.gd). Tune all three looks here.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="dust_mesh"]
material = SubResource("mat")
radius = 0.12
height = 0.24
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="dust_curve"]
_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1.5), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="dust_grad"]
colors = PackedColorArray(1, 0.78, 0.18, 0.95, 1, 0.45, 0.05, 0)
[sub_resource type="SphereMesh" id="stone_mesh"]
material = SubResource("mat")
radius = 0.055
height = 0.11
radial_segments = 3
rings = 1
[sub_resource type="Curve" id="stone_curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="stone_grad"]
colors = PackedColorArray(0.62, 0.58, 0.52, 1, 0.62, 0.58, 0.52, 0)
[sub_resource type="SphereMesh" id="spark_mesh"]
material = SubResource("mat")
radius = 0.04
height = 0.08
radial_segments = 3
rings = 1
[sub_resource type="Curve" id="spark_curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.5, 0.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="spark_grad"]
colors = PackedColorArray(1, 0.98, 0.6, 1, 1, 1, 1, 0)
[node name="bull_kick" type="CPUParticles3D"]
emitting = false
amount = 40
lifetime = 1.2
one_shot = true
local_coords = false
explosiveness = 0.8
randomness = 0.5
mesh = SubResource("dust_mesh")
direction = Vector3(0, 0.7, -1)
spread = 65.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 3.0
initial_velocity_max = 9.0
scale_amount_min = 0.5
scale_amount_max = 2.2
scale_amount_curve = SubResource("dust_curve")
color_ramp = SubResource("dust_grad")
script = ExtResource("oneshot")
[node name="stones" type="CPUParticles3D" parent="."]
emitting = false
amount = 14
lifetime = 0.65
one_shot = true
local_coords = false
explosiveness = 0.92
randomness = 0.6
mesh = SubResource("stone_mesh")
direction = Vector3(0, 0.35, -1)
spread = 45.0
gravity = Vector3(0, -9.8, 0)
initial_velocity_min = 5.0
initial_velocity_max = 14.0
scale_amount_min = 0.5
scale_amount_max = 1.8
scale_amount_curve = SubResource("stone_curve")
color_ramp = SubResource("stone_grad")
[node name="sparks" type="CPUParticles3D" parent="."]
emitting = false
amount = 22
lifetime = 0.28
one_shot = true
local_coords = false
explosiveness = 0.95
randomness = 0.4
mesh = SubResource("spark_mesh")
direction = Vector3(0, 0.5, -1)
spread = 30.0
gravity = Vector3(0, -14, 0)
initial_velocity_min = 8.0
initial_velocity_max = 18.0
scale_amount_min = 0.6
scale_amount_max = 2.0
scale_amount_curve = SubResource("spark_curve")
color_ramp = SubResource("spark_grad")
-42
View File
@@ -1,42 +0,0 @@
[gd_scene format=3]
; Bull roll sparks — the dirt/ember spray flung outward from the spinning roll ("Powerball").
; Continuous: the player instances it once under the bull and toggles `emitting` during a roll.
; World-space so the spray peels off into a trail. Tune the look here; see README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.05
height = 0.1
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(1, 0.78, 0.32, 1, 0.7, 0.42, 0.14, 0)
[node name="bull_roll_sparks" type="CPUParticles3D"]
emitting = false
amount = 46
lifetime = 0.45
local_coords = false
randomness = 0.7
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 180.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 3.0
initial_velocity_max = 8.0
scale_amount_min = 0.5
scale_amount_max = 1.3
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
-47
View File
@@ -1,47 +0,0 @@
[gd_scene format=3]
; Bull ground-slam dust — the one-shot dirt burst thrown up when the slam lands. Fired via
; Fx.spawn(&"bull_slam", xform) at the impact point. The expanding shockwave RINGS are a
; separate coded effect (actors/player/bull_impact_fx.gd), not this. Tune the look here.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.09
height = 0.18
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.25), 0.0, 0.0, 0, 0, Vector2(0.35, 1.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.88, 0.74, 0.46, 1, 0.88, 0.74, 0.46, 0)
[node name="bull_slam" type="CPUParticles3D"]
emitting = false
amount = 48
lifetime = 1.1
one_shot = true
local_coords = false
explosiveness = 0.85
randomness = 0.5
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 85.0
gravity = Vector3(0, -7, 0)
initial_velocity_min = 3.5
initial_velocity_max = 10.0
scale_amount_min = 0.5
scale_amount_max = 2.0
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
script = ExtResource("oneshot")
+1 -4
View File
@@ -4,10 +4,7 @@ extends Node3D
## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md. ## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md.
const _CYCLE_SEC := 1.6 const _CYCLE_SEC := 1.6
const _EFFECTS: Array[StringName] = [ const _EFFECTS: Array[StringName] = [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"]
&"blood_burst", &"slam_dust", &"spark", &"hit_flash",
&"bull_slam", &"bull_kick", &"bull_dash",
]
@onready var _label: Label = $UI/Label @onready var _label: Label = $UI/Label
# Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this # Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this
-20
View File
@@ -7,18 +7,10 @@ extends Node
## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd. ## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd.
const _PATHS: Dictionary = { const _PATHS: Dictionary = {
# Generic hit effects.
&"blood_burst": "res://systems/fx/blood_burst.tscn", &"blood_burst": "res://systems/fx/blood_burst.tscn",
&"slam_dust": "res://systems/fx/slam_dust.tscn", &"slam_dust": "res://systems/fx/slam_dust.tscn",
&"spark": "res://systems/fx/spark.tscn", &"spark": "res://systems/fx/spark.tscn",
&"hit_flash": "res://systems/fx/hit_flash.tscn", &"hit_flash": "res://systems/fx/hit_flash.tscn",
# Bull ability effects — one-shots fired by spawn(), continuous ones held via attach().
&"bull_slam": "res://systems/fx/bull_slam.tscn",
&"bull_kick": "res://systems/fx/bull_kick.tscn",
&"bull_dash": "res://systems/fx/bull_dash.tscn",
&"bull_hoof_dust": "res://systems/fx/bull_hoof_dust.tscn",
&"bull_charge_trail": "res://systems/fx/bull_charge_trail.tscn",
&"bull_roll_sparks": "res://systems/fx/bull_roll_sparks.tscn",
} }
var _cache: Dictionary = {} var _cache: Dictionary = {}
@@ -40,18 +32,6 @@ func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node:
return inst return inst
# Instance a CONTINUOUS effect as a child of `parent` and hand it back. Unlike spawn(), it neither
# positions nor auto-frees the effect — the caller keeps the reference to place it and toggle
# `emitting` (hoof dust that follows the bull, the charge trail). Returns null if unknown/missing.
func attach(effect: StringName, parent: Node) -> Node:
var scene := _scene(effect)
if scene == null or parent == null:
return null
var inst := scene.instantiate()
parent.add_child(inst)
return inst
func _scene(effect: StringName) -> PackedScene: func _scene(effect: StringName) -> PackedScene:
if _cache.has(effect): if _cache.has(effect):
return _cache[effect] return _cache[effect]
+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
+1 -1
View File
@@ -1,6 +1,6 @@
extends SceneTree extends SceneTree
## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash) ## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash)
## live in art/fx/bear_fx.glb as meshes that rest at scale 0 at Siim's baked positions. The ## live in Assets/fx/Bear_FX.glb as meshes that rest at scale 0 at Siim's baked positions. The
## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and ## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and
## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on ## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on
## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end, ## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end,
+1 -7
View File
@@ -26,16 +26,10 @@ func _run() -> void:
root.add_child(host) root.add_child(host)
current_scene = host current_scene = host
for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash", for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"]:
&"bull_slam", &"bull_kick", &"bull_dash"]:
var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host) var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host)
_ok(inst != null and is_instance_valid(inst), "spawns %s" % id) _ok(inst != null and is_instance_valid(inst), "spawns %s" % id)
# Continuous bull effects are held (attach), not fired-and-forgotten (spawn).
for id: StringName in [&"bull_hoof_dust", &"bull_charge_trail", &"bull_roll_sparks"]:
var inst: Node = fx.call("attach", id, host)
_ok(inst != null and is_instance_valid(inst), "attaches %s" % id)
var missing: Node = fx.call("spawn", &"does_not_exist", Transform3D.IDENTITY, host) var missing: Node = fx.call("spawn", &"does_not_exist", Transform3D.IDENTITY, host)
_ok(missing == null, "unknown effect returns null instead of crashing") _ok(missing == null, "unknown effect returns null instead of crashing")
+55
View File
@@ -30,6 +30,7 @@ func _run() -> void:
test_kick_spread() test_kick_spread()
test_gait_phase_crossing() test_gait_phase_crossing()
test_tail_verlet_constraint() test_tail_verlet_constraint()
test_run_map()
print("=" .repeat(60)) print("=" .repeat(60))
print("Results: %d passed, %d failed" % [_passed, _failed]) print("Results: %d passed, %d failed" % [_passed, _failed])
@@ -171,6 +172,60 @@ func test_tail_verlet_constraint() -> void:
_assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable") _assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable")
# ── Run map (Slay-the-Spire progression) ──────────────────────────────────────
func test_run_map() -> void:
print("\n-- test_run_map --")
var run: Node = root.get_node_or_null("/root/Run")
if run == null:
_assert_true(false, "Run autoload should exist")
return
run.start_new_run()
_assert_true(run.has_run(), "start_new_run builds a map")
_assert_true(run.map.size() >= 2, "map has multiple rows")
# Final row is a single boss node the map funnels toward.
var last: Array = run.map[run.map.size() - 1]
_assert_eq(last.size(), 1, "final row is a single boss node")
_assert_true(last[0].level.is_boss, "final node is a boss level")
# Connectivity: every node links forward, and every next-row node has a parent.
for r: int in run.map.size() - 1:
var nxt: Array = run.map[r + 1]
var reached := {}
for from_node in run.map[r]:
_assert_true(from_node.links.size() >= 1, "row %d node links forward" % r)
for c: int in from_node.links:
_assert_true(c >= 0 and c < nxt.size(), "link column in range")
reached[c] = true
for c: int in nxt.size():
_assert_true(reached.has(c), "row %d col %d has a parent" % [r + 1, c])
# From the start gate the whole first row is the frontier.
_assert_eq(run.reachable().size(), run.map[0].size(), "start frontier is row 0")
# Advancing follows the chosen node's links.
var first = run.map[0][0]
run.select_node(first)
run.complete_current_level()
_assert_eq(run.player_row, 0, "player advanced to row 0")
_assert_true(first.cleared, "cleared node is flagged")
_assert_eq(run.reachable().size(), first.links.size(),
"frontier follows the chosen node's links")
# Walking to a boss node latches run completion.
var guard := 0
while not run.run_complete() and guard < 64:
var opts: Array = run.reachable()
if opts.is_empty():
break
run.select_node(opts[0])
run.complete_current_level()
guard += 1
_assert_true(run.run_complete(), "reaching a boss node completes the run")
# ── Shared helpers ──────────────────────────────────────────────────────────── # ── Shared helpers ────────────────────────────────────────────────────────────
func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void: func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void:
+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
+9 -83
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,45 +309,16 @@ 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) _style_button(key_button, false)
_style_button(key_button, false) 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