95 Commits

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 19:33:02 +03:00
richard a34989c49e Show Xbox-style A/B/X/Y glyphs on the ability bar for a gamepad
Controls now tracks whether the player's last deliberate press was a
gamepad button/stick or a keyboard key/mouse click (ignoring plain
mouse motion, since the mouse still drives the camera regardless of
which device moves the bull). The HUD's ability-bar keycaps read from
that: keyboard letters normally, Xbox-style face-button glyphs while
a gamepad is in use, swapping live the moment the player switches
devices.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:56:17 +03:00
richard 1ea4171863 Polish the bear's thank-you ending screen; add a video debug command
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
- The finish screen's continue button now reads "Play Again" and starts
  a fresh run instead of "Continue"-ing to a lobby gate that no longer
  exists past the run's last fight.
- The "Your Time" readout moves from the top (overlapping the bull in
  thankyou.ogv) to just under the clip's own baked title, restyled to
  match its flat dark-ink look instead of the gold HUD outline, and
  pulses on the same ~1.4s breathing cycle sampled off the source clip
  so it reads as part of the poster rather than a readout stamped on it.
- Added a `video <win|loss|finish>` debug console command to preview
  any outcome screen without playing through a fight, via a new
  hud.gd debug entry point (`debug_force_outcome`).
- Dropped the console's per-frame "focus stolen by ... — reclaiming"
  log line — its own comment flagged it as temporary diagnostic
  instrumentation, and it fired on every console open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:36:03 +03:00
richard cd54242de0 Make gamepad vibration work on web export
Input.start_joy_vibration() is a documented engine no-op on Godot's
HTML5 export (never wired to the browser side). Controls.vibrate()
wraps it: native platforms go straight through as before, web drives
the browser's Gamepad Haptic Actuator API directly via JavaScriptBridge.
Gamepad movement/ability input already worked on desktop browsers via
the standard Web Gamepad API, so only vibration needed a fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 17:35:54 +03:00
richard 91d88cd5c7 Bump version to 0.5.0
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 16:44:04 +03:00
richard 6dff45c659 Add thank-you ending screen and a run timer
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Defeating the bear (the run's final boss) now plays a dedicated
"Thank You For Playing" clip on the completion screen instead of the
generic bull-win video, with the run's finish time shown in place of
the usual "Bull Won!" title.

A run clock also starts when the player enters the arena (the run's
first fight), shown top-left on desktop only, and freezes the instant
the bear falls. Re-entering the arena after finishing a run restarts
the clock instead of leaving it stuck on the previous run's time.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:38 +03:00
richard 400ae24f92 Remove the persistent blood-decal system
Strip only the floor/wall blood-splat decals added in 981ebf1, keeping the
older death-spray, spear-wound spurt, and "Blood & Gore" toggle:

- Delete blood_decals.gd + shader, the Gore autoload (gore.gd), and
  tests/blood_decals_test.gd
- Drop the Gore autoload and the Blood DP section
- Remove all Gore.splat() call sites (matador.gd, bear.gd, player.gd);
  the _blood_burst spray and _spawn_blood spurt stay
- game_shell no longer spawns a per-level decal pool
- Update level_switch test + run_tests.sh accordingly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
2026-09-04 22:41:00 +03:00
richard 79ea5bb2be Bump version to 0.4.0
The in-game version banner (GameVersion.full) reads config/version from
project.godot; it had lagged at 0.2.0 behind the 0.3.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
2026-09-04 14:19:19 +03:00
richard 981ebf1910 Add blood decals, gore, mobile HUD, web start gate + touch/perf tests
Remove tools/fstest.html scratch page used to probe browser
fullscreen/orientation APIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
2026-09-04 14:07:57 +03:00
siim 01d6ecf475 go to bear level fix and vibration of controller when gate is opening 2026-09-03 18:20:19 +03:00
siim 931ddf0eeb can turn "gamepad_vibration" on and off in debug mode 2026-09-03 18:04:58 +03:00
siim 50073b2574 controller vibrate when taking damage 2026-09-03 17:53:44 +03:00
siim 07f8a71f2c torches light animation 2026-09-03 07:53:55 +03:00
richard 34dc5b026e Lobby barrel-smash reward + roll/slam barrel destruction
Bull's boulder roll now homes on and ploughs through lobby barrels, and
the slam shockwave bursts them within its radius. HUD tallies barrels on
load and fires a confetti "WOooOoW!" + two gold bonus HP pips when the
last one breaks; gates open from run state as fights are cleared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 01:38:57 +03:00
richard 5c1e881a53 Combat overhaul: charge/gore, bear ram, matador taunt reactions
Bull charge damage:
- Tunnel-proof swept detection (prev→now segment) for matador gore and bear
  ram, so a 66 m/s dash no longer skips clean over the thin HitArea.
- Horns-first cone (bull_gore_arc): running into an enemy only wounds it when
  charging roughly at it, not a sideways brush.
- Dash is a committed lunge: bypasses the horns cone AND the survival roll, so a
  dash connect is a guaranteed kill. Cruise charges keep a speed-scaled roll
  (mat_charge_pierce). Roll ability defers to its own pop (is_rolling guard).

Bear:
- Horns-first swept ram detection (edge-triggered: one charge = one wound);
  a tunnelling or point-blank charge now lands where body_entered wouldn't.
- Overhead smash lane extends faster (0.1s) and further (20 m).
- Leap slam launches the bull from anywhere in the circle (ring_frac 0).
- Removed stray-quote syntax errors that broke the script.

Matador:
- Collider-driven stab (BladeHitbox) with recovery beat + taunt-after-hit so it
  stops machine-gunning the bull.
- A taunting matador answers a charge: spear from range, sword up close.

Spear projectile masks the BULL layer so throws connect.

Tests: matador_charge/stab/spear/taunt_react, bear_ram/hitbox/fx (+ unified
_fake_bull stand-in). Trimmed verbose comments.

Note: bear claw-emitter/death work is still WIP (3 bear_boss_test failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 01:01:42 +03:00
siim 4efc6763f0 lobby little tweak 2026-09-01 21:34:02 +03:00
siim 76d4fee33c Lobby level gates trigger areas 2026-09-01 21:22:16 +03:00
siim 271be0b26b + torches 2026-09-01 08:58:56 +03:00
siim 98c6f1285a underground_lobby 2026-08-31 21:52:13 +03:00
siim 4d66df8c7e bear long range slam. and not rotating while attacking animore. but dash and movement damage not working now 2026-08-29 09:30:03 +03:00
siim 41f18cb5d6 bear fx v2 2026-08-28 21:43:59 +03:00
siim 6b6a926715 bear_fx 2026-08-28 20:23:13 +03:00
siim dae7ec06da barrel materials 2026-08-28 19:43:09 +03:00
siim 1eb06876e9 breakable barrels 2026-08-28 19:02:19 +03:00
richard 1021a07b04 Put dead matadors on a corpse collision layer
A killed matador's ragdoll shared the WORLD layer with the bull, so the
bull collided with the bodies and spazzed out on them. Give corpses a
dedicated CORPSE layer masked to itself, and tag the ground/walls with
CORPSE too (at level load, so it also reaches colliders baked into the
instanced GLB arenas). The corpse still rests on the arena, but the bull
— which only masks WORLD — passes straight through the bodies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 23:27:10 +03:00
richard c365f76d80 Merge remote-tracking branch 'origin/main' 2026-08-27 21:34:23 +03:00
richard 32ac46f840 lobby cam 2026-08-27 21:34:18 +03:00
siim bd10d1789b bear max hp 30 2026-08-27 21:30:48 +03:00
siim 170d8ff3f1 siim timmimised väiksed 2026-08-27 21:15:36 +03:00
siim 550bf2c5ad adjustable wave size with debug parameter "mat_wave_size" 2026-08-27 20:36:00 +03:00
siim 02f3e283de landing. don't bounce! 2026-08-27 08:30:03 +03:00
siim 9123574947 crowd billbord placement + mat sword run fix 2026-08-26 22:32:30 +03:00
siim aadf59d2d0 lobby collisions 2026-08-26 21:09:41 +03:00
richard d57c3dc2a2 Merge remote-tracking branch 'origin/main' 2026-08-26 20:38:14 +03:00
richard 09ae6bfbf4 lobby level 2026-08-26 20:38:07 +03:00
richard 40c1b8e680 debug param save works, bull/bear hitbox 2026-08-26 20:37:50 +03:00
siim a1c77fed5e Update debug_params.gd 2026-08-26 20:02:23 +03:00
siim 1a8af92d88 matador attack fix 2026-08-26 19:53:43 +03:00
siim 8166a6c18e Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-08-26 19:41:35 +03:00
siim 46d6dda325 capsule 2026-08-26 19:41:23 +03:00
richard fc9348551d fix sword holster 2026-08-26 19:18:52 +03:00
richard c4a19c7443 crowd markers, unsheathed sword correct 2026-08-26 19:13:39 +03:00
siim 95c4c0ddf9 BEAR ARENA UPDATE 2026-08-26 19:12:27 +03:00
siim 6a4f104655 bear arena model updeit 2026-08-26 18:30:09 +03:00
siim d4b67888d7 controller zoom with right stick 2026-08-26 18:12:12 +03:00
siim b177e0aee3 matadors dont come inside the player anymore with sword attack. also got rid of matador.gd dublicate.. that i made for testing animation 2026-08-26 08:39:40 +03:00
siim 2a6f4c0f7b bear_FX 2026-08-25 21:58:43 +03:00
siim 32d0c58f5c bear_ Call method track testing with spears 2026-08-25 08:49:44 +03:00
siim 251002ea7c bull and bear flashing red when take damage 2026-08-25 08:04:39 +03:00
siim 986496097f arena cleanup 2026-08-24 18:45:03 +03:00
siim d976deeb6a arena cleanup+tex modification 2026-08-24 18:41:34 +03:00
siim d632670092 +death animation bear 2026-08-24 08:36:48 +03:00
siim e1899e49f4 level lobby 2026-08-23 23:40:28 +03:00
siim 6f5f9e6bba ps1 filter tuun 2026-08-23 22:26:58 +03:00
siim 91cd027a98 adj 2026-08-23 22:05:57 +03:00
siim ef2fb24969 cooldowns increase 2026-08-23 21:39:19 +03:00
siim 4c75776c70 debug_param_cooldown adjusts 2026-08-23 21:28:19 +03:00
siim 7688084b7e spear update 2026-08-23 21:15:25 +03:00
richard ccc92f201f mobile support tweaks 2026-08-23 20:32:52 +03:00
richard 008650a87e Merge remote-tracking branch 'origin/main' 2026-08-23 17:34:05 +03:00
richard b9e3147519 Bear, level selector (to be removed), new matador spawn, new bear level, saving config updates source files 2026-08-23 17:33:47 +03:00
richard cdba320cbc bear 2026-08-23 17:33:32 +03:00
richard ee7b1617c1 Bear, level selector (to be removed), new matador spawn, new bear level, saving config updates source files 2026-08-23 17:33:15 +03:00
richard d749485126 Map screen version 2026-08-23 13:16:04 +03:00
richard 45427b7070 Merge remote-tracking branch 'origin/main' 2026-08-23 12:37:35 +03:00
richard d514435dd8 touch controls and hud 2026-08-23 12:37:30 +03:00
siim 3eb9fa50f3 +bear boss 2026-08-23 11:18:45 +03:00
siim aee900c009 bear boss, aga broken 2026-08-21 18:23:30 +03:00
siim 4cf714f516 Update spear.glb 2026-08-17 23:49:29 +03:00
siim b78736a1f4 icon update 2026-08-10 04:43:48 +03:00
siim 212263c858 credits tuun 2026-08-10 03:53:17 +03:00
siim 8c4eeb0861 cooldowns? 2026-08-10 03:30:53 +03:00
siim 95def2d527 loading text + fullscreen default 2026-08-10 01:04:57 +03:00
siim 9a43a9ff8b icon 2026-08-09 23:58:01 +03:00
siim 7238cbf49e death anim speed .8 2026-08-09 23:19:23 +03:00
siim 44c667f097 collision scale 2026-08-09 23:15:43 +03:00
siim 3cd347d420 walk anim, death anim ja loading screen 2026-08-09 23:05:55 +03:00
richard 6414b2e7cc uids 2026-08-09 19:40:39 +03:00
richard 56593c58cf Release 0.2.0: device-aware controls, spear, settings, shaders
Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 19:39:37 +03:00
richard ce6f3d7075 Deployment script update 2026-08-09 15:26:01 +03:00
siim a74182255b light tim 2026-08-08 11:47:36 +03:00
siim ebf5046461 credits 2026-07-30 20:21:10 +03:00
324 changed files with 14219 additions and 7768 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ disable:
- class-definitions-order - class-definitions-order
- no-else-return - no-else-return
max-line-length: 120 max-line-length: 120
max-file-lines: 1200 max-file-lines: 3000
+139
View File
@@ -0,0 +1,139 @@
name: CI
# Gitea Actions is GitHub-Actions-compatible: drop this in .gitea/workflows/, enable Actions on the
# repo ([actions] ENABLED=true in app.ini, Gitea >= 1.19) and register an act_runner — it then runs
# on every push / PR. Godot and gdtoolkit are installed with plain shell steps (not the chickensoft
# setup-godot action), because a self-hosted runner usually can't reach the GitHub action registry
# (unless DEFAULT_ACTIONS_URL is set to proxy github.com). Only actions/checkout and upload-artifact
# remain as external actions — both ship with Gitea's default proxy; if yours is offline, mirror them
# into your instance.
#
# It also runs once a day (schedule) as a safety net over main's tip. The `guard` job skips that daily
# run when the commit already passed — so an idle main isn't re-tested, but a commit whose push-time
# run was missed/skipped still gets verified. schedule needs Gitea >= 1.21; workflow_dispatch (the
# manual "Run workflow" button) needs >= 1.23; from 1.23 the cron is evaluated in UTC.
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "0 4 * * *" # 04:00 UTC daily
# statuses:write lets the test job stamp a `ci/headless` commit status the guard reads back.
permissions:
contents: read
statuses: write
env:
# The Godot build the team runs (godot --version -> 4.7.beta.custom_build.417cd33bf). Pin this to a
# PUBLISHED build from https://github.com/godotengine/godot-builds/releases. If you run a custom
# build with no public binary, host the linux headless zip where the runner can reach it and set
# GODOT_URL to that (it overrides the constructed URL below).
GODOT_VERSION: "4.7-beta1"
GODOT_URL: ""
jobs:
# On push/PR this passes straight through. On the daily schedule it asks the API whether main's tip
# already carries a successful `ci/headless` status (stamped by an earlier run) and, if so, tells the
# test job to skip — that's the "don't re-test a commit that already ran" rule.
guard:
name: Decide whether to run
runs-on: ubuntu-latest
outputs:
run: ${{ steps.decide.outputs.run }}
steps:
- name: Check for an existing pass on this commit
id: decide
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -u
if [ "${{ github.event_name }}" != "schedule" ]; then
echo "event=${{ github.event_name }} -> run"
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1"
curl -sfL -H "Authorization: token ${GITEA_TOKEN}" \
"$api/repos/${{ github.repository }}/commits/${{ github.sha }}/statuses?limit=100" \
-o /tmp/statuses.json || echo '[]' > /tmp/statuses.json
python3 - <<'PY'
import json, os
try:
data = json.load(open("/tmp/statuses.json"))
except Exception:
data = []
items = data.get("statuses", data) if isinstance(data, dict) else data
done = any(
s.get("context") == "ci/headless" and (s.get("state") or s.get("status")) == "success"
for s in (items or [])
)
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write("run=%s\n" % ("false" if done else "true"))
print("commit already passed ci/headless -> skip" if done else "no prior pass -> run")
PY
test:
name: Lint & headless tests
runs-on: ubuntu-latest # must match a label your act_runner registered with
needs: guard
if: ${{ needs.guard.outputs.run == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Godot ${{ env.GODOT_VERSION }}
run: |
set -eux
url="${GODOT_URL:-https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_linux.x86_64.zip}"
curl -fL "$url" -o /tmp/godot.zip
unzip -o /tmp/godot.zip -d /tmp/godot
install -m 755 /tmp/godot/Godot_v*_linux.x86_64 /usr/local/bin/godot
godot --version
- name: Install gdlint (gdtoolkit)
# A venv sidesteps PEP-668 "externally managed" pip on recent runner images; putting its bin on
# GITHUB_PATH lets run_tests.sh call bare `gdlint` in later steps.
run: |
set -eux
python3 -m venv /tmp/venv
/tmp/venv/bin/pip install "gdtoolkit==4.*"
echo "/tmp/venv/bin" >> "$GITHUB_PATH"
# A cold .godot cache (CI always starts cold) needs two import passes: the first imports base
# assets, the second resolves scenes that depend on them (the .fbx/.glb -> .scn imports). One pass
# leaves those unresolved and the headless tests fail to load them. Warnings exit non-zero.
- name: Import resources
run: |
godot --headless --import || true
godot --headless --import || true
- name: gdlint
run: gdlint actors systems ui props levels tests
- name: Headless tests
env:
GODOT: godot
run: bash run_tests.sh --no-gameplay
# Stamp the commit so the daily guard recognizes it as already tested. Best-effort: a fork PR whose
# token can't write statuses just skips this (the build still passes).
- name: Mark commit as tested
if: success()
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
api="${{ github.api_url }}"; [ -n "$api" ] || api="${{ github.server_url }}/api/v1"
curl -sfL -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
"$api/repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-d '{"context":"ci/headless","state":"success","description":"headless suite green"}' \
>/dev/null || echo "could not post commit status (token lacks statuses:write?)"
- name: Upload test output on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-output
path: tests/output/
if-no-files-found: ignore
+6 -2
View File
@@ -2,13 +2,17 @@
.godot/ .godot/
/android/ /android/
# Godot editor autosave scratch files (e.g. Bear.tscn12015901897.tmp)
*.tmp
# JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user # JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user
.idea/ .idea/
# Blender render previews (generated by render_anim_preview.py) # Blender render previews (generated by render_anim_preview.py)
Blender/preview/ art/blender/preview/
Blender/*.blend1 # Blender auto-save backups (.blend1, .blend2, … — Blender rewrites these on every save)
art/blender/*.blend[0-9]
# Test output (generated screenshots, frame strips) # Test output (generated screenshots, frame strips)
tests/output/ tests/output/
Binary file not shown.
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
extends Node3D
func _ready() -> void:
add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
anim.play(&"Juubeldab")
-1
View File
@@ -1 +0,0 @@
uid://wj6tgh8nb0ka
-5
View File
@@ -1,5 +0,0 @@
extends Node3D
func _ready() -> void:
$AnimationPlayer.play("ArmatureAction")
-1
View File
@@ -1 +0,0 @@
uid://gt3kfchac3m3
Binary file not shown.
BIN
View File
Binary file not shown.
-5
View File
@@ -1,5 +0,0 @@
extends Node3D
func _ready() -> void:
$AnimationPlayer.play("Taunt")
-1
View File
@@ -1 +0,0 @@
uid://bn3xwnlktwdlf
-38
View File
@@ -1,38 +0,0 @@
[gd_scene format=3 uid="uid://bl6jlbq1dpkc"]
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://Assets/Matador.glb" id="1_m7x6l"]
[ext_resource type="Script" uid="uid://dpoq87w5pvqa" path="res://matador_2.gd" id="2_mftd2"]
[node name="matador2" unique_id=1179710042 instance=ExtResource("1_m7x6l")]
script = ExtResource("2_mftd2")
[node name="Skeleton3D" parent="Armature" parent_id_path=PackedInt32Array(1269629432) index="0" unique_id=978366810]
bones/0/position = Vector3(0.056660548, 0.6424767, -0.0167365)
bones/0/rotation = Quaternion(0.080643006, 0, 0, 0.996743)
bones/1/rotation = Quaternion(0.083861314, 0, 0, 0.9964774)
bones/3/rotation = Quaternion(0.04833339, 0.5302496, -0.71466804, 0.4535955)
bones/4/rotation = Quaternion(0.031473875, -0.17018215, -0.15981929, 0.97185665)
bones/5/rotation = Quaternion(0.6747468, -0.021427644, -0.06245957, 0.7350895)
bones/6/rotation = Quaternion(3.1828083e-08, -9.5969504e-08, -0.0587008, 0.99827564)
bones/7/rotation = Quaternion(0.1907551, 0.20328633, 0.7258648, 0.6288143)
bones/8/rotation = Quaternion(0.16639966, -0.05474137, 0.010937729, 0.984477)
bones/9/rotation = Quaternion(0.5914298, 0.005839911, -0.041949864, 0.8052435)
bones/10/rotation = Quaternion(2.3105285e-08, 1.0152064e-07, 0.05870079, 0.99827564)
bones/11/rotation = Quaternion(0.39202613, 0.47060952, 0.5799399, 0.53713316)
bones/12/rotation = Quaternion(0.62021786, 0.40894383, 0.44191495, 0.5027981)
bones/13/rotation = Quaternion(-0.11584477, 0.0021730124, 0.6343679, 0.7642989)
bones/14/rotation = Quaternion(0.1454883, 0.14013468, -0.7254941, 0.65791625)
bones/15/rotation = Quaternion(0.71668345, 0.065085255, -0.6923973, -0.05210323)
bones/16/rotation = Quaternion(-0.10161893, -0.0019664483, -0.65947086, 0.7448275)
bones/17/rotation = Quaternion(0.06964938, -0.042099692, 0.74388325, 0.6633358)
bones/18/rotation = Quaternion(0.5305965, -0.28927496, 0.76720744, -0.21489602)
bones/19/position = Vector3(0.16341288, 0.31487355, 0.3966543)
bones/20/position = Vector3(0.22012731, 0.38421977, 0.95168185)
bones/21/position = Vector3(-0.09492579, 0.40446192, -0.46390092)
bones/21/rotation = Quaternion(0.7216726, 0.064735115, -0.6891729, 0.0062273443)
bones/22/position = Vector3(-0.22012731, 0.38421977, 0.95168185)
bones/23/position = Vector3(-0.0382514, 1.7423625, 0.0026792884)
bones/23/rotation = Quaternion(0.8626007, 0.0068245474, -0.099159576, -0.49602506)
bones/23/scale = Vector3(0.6647317, 1.001677, 1.0066445)
bones/24/rotation = Quaternion(-0.007878712, 8.3020435e-09, -0.0023950166, 0.9999661)
bones/25/rotation = Quaternion(5.5870206e-09, 5.5879354e-09, -2.0518176e-09, 1)
-12
View File
@@ -1,12 +0,0 @@
extends Node3D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready():
animation_player.play("Taunt")
animation_player.animation_finished.connect(_on_animation_finished)
func _on_animation_finished(anim_name):
if anim_name == "Taunt":
animation_player.play("Taunt")
-1
View File
@@ -1 +0,0 @@
uid://dycnoiadljbb1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
uid://ymaeifakqovf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+74 -15
View File
@@ -10,21 +10,64 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
## Project structure ## Project structure
Files are grouped by **role**, not by type. Scripts and scenes are `snake_case`;
`PascalCase` is reserved for `class_name` identifiers (this avoids case-insensitive
filesystem aliasing on the web/macOS targets).
```
actors/{player,matador,bear}/ the three characters (script + scene together)
systems/combat/ hitbox.gd, damage_event.gd, physics_layers.gd
systems/fx/ Fx autoload + effect scenes (Siim's authoring dir)
systems/input/ controls_manager.gd, touch_controls.gd
systems/debug/ debug_params.gd (DP), debug_draw.gd, console.gd
systems/render/ rigid_skin.gd, ps1_filter (post-process)
systems/crowd/ billboard audience
systems/config/ settings.gd (Settings autoload)
ui/ hud, mobile_hud, main_menu, ui_theme/fonts, overlays
props/ barrels, torch
levels/ game_shell.tscn + level modules
tools/ tests/ editor/bake scripts, headless tests
art/ all raw art, grouped by role (snake_case):
characters/ levels/ weapons/ bull/matador/bear, arena/lobby geometry, spear/sword
fx/ anim/ textures/ bear_fx, crowd_clap, baked sheets
ui/ icons/ fonts/ menu art, ability icons, Cinzel fonts
hdri/ materials/ sky HDRIs, shared StandardMaterial3D resources
blender/ Siim's .blend working files (Godot-ignored)
```
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `scene.tscn` | Main scene (arena + player) | | `levels/game_shell.tscn` | The reusable **shell** loaded for every level: player, camera, HUD, PS1 filter, and an empty `LevelRoot`. Its `game_shell.gd` instances the active level's module into `LevelRoot` at runtime |
| `Player.tscn` | Player scene root (`CharacterBody3D`) | | `levels/` | The level system: per-level module scenes plus the code that defines, loads and sequences them (see rows below) |
| `player.gd` | Movement, charge, turning logic | | `levels/game_shell.gd` | Shell controller — on load, frees any current level module and instances `Run.active_level().level_scene` into `LevelRoot`. Swapping the whole subtree (never hiding it) is what keeps one level's meshes/colliders/spawners from leaking into the next |
| `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` |
| `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/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`) |
| `matador.gd` | Matador AI (wander / ragdoll state machine) | | `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) |
| `matador_spawn.gd` | Spawns N matadors at random arena positions | | `levels/matador_spawn.gd` | Arena spawner — one matador per designer-placed child `Node3D` spawn point (each enters with a jump-roll toward center); falls back to a programmatic spread if no spawn points are present. Also handles the split-on-death hydra logic |
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) | | `levels/bear_spawn.gd` | Bear-level spawner — instantiates `enemy_scene` at the arena center; emits the same `all_defeated` / `matador_killed` signals as `matador_spawn.gd` |
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | | `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 |
| `Blender/` | Blender source files, animation scripts, FBX exports | | `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`) |
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX | | `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 |
| `Blender/render_anim_preview.py` | Renders animation preview PNGs for visual verification | | `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads |
| `Blender/preview/` | Output directory for animation preview images | | `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) |
| `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/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/input/touch_controls.gd` | On-screen thumb UI for touch/web (`CanvasLayer`, instanced by `ui/hud.gd`, self-gating on `Controls.use_touch_ui()`): a floating left-side joystick feeding the `move_*` actions + a right-side ability cluster, drawn/hit-tested in one IGNORE-filtered Control from raw `_input()`. The joystick self-heals — the latest left-zone touch re-acquires it (`_stick_index`), and a relayout / focus-out / vanished touch resets state so a dropped touchend can't strand a stuck stick. Ability buttons use grown hit-rects + a press flash. `_draw_debug()` (gated on `Controls.debug_overlay()` — DP `touch_debug` or `?debug=1`) is a live on-device profiler reading Godot's `Performance` monitors: FPS/frame-ms with a bottleneck verdict, `process`(main-thread GDScript) vs `physics`(ragdoll/Jolt) ms with 2 s peak-hold, draw calls/objects/primitives, node count, render resolution, and `crossOriginIsolated` — so a lag spike is pinned to CPU-script vs CPU-physics vs GPU-fill instead of guessed |
| `ui/web_start_gate.gd` | Autoload `WebStartGate` — "TAP TO PLAY" panel shown on web+touch before play; the tap drives `Controls.request_fullscreen_landscape()` so the fullscreen/orientation churn happens with no in-flight touch to lose. Re-armable: polls browser fullscreen state and returns if the player leaves it (iPhone Safari can't fullscreen, so it stops nagging once a request never takes) |
| `ui/mobile_hud.gd` | Mobile-only health readouts (`CanvasLayer`, instanced by `ui/hud.gd` only on touch): big bull pip row top-left + a Souls-style bear boss bar top-centre (discovered via group `&"bear"`, tracks `health_changed`). The desktop bottom pip cluster is hidden on touch |
| `ui/orientation_guard.gd` | Autoload `OrientationGuard` — full-screen "rotate your device" prompt shown on touch devices while portrait |
| `art/` | All raw art, grouped by role (`characters/ levels/ weapons/ fx/ anim/ textures/ ui/ icons/ fonts/`). GLBs use **Extract Textures** import, so their embedded textures land beside them (e.g. `art/levels/arena_*.png`) named from the GLB — expected, keep them tracked |
| `art/blender/` | Blender source files, animation scripts, FBX exports (Godot-ignored via `.gdignore`) |
| `art/blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
| `art/blender/render_anim_preview.py` | Renders animation preview PNGs for visual verification |
| `art/blender/preview/` | Output directory for animation preview images |
| `tests/` | Headless test scripts (screenshot capture, logic validation) | | `tests/` | Headless test scripts (screenshot capture, logic validation) |
| `addons/rider-plugin/` | JetBrains Rider IDE integration | | `addons/rider-plugin/` | JetBrains Rider IDE integration |
@@ -58,7 +101,7 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
## Animation & bone conventions ## Animation & bone conventions
### Blender rig (matador_v02) ### Blender rig (matador)
- **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).
@@ -76,8 +119,8 @@ 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 Blender/matador_v02.blend --python Blender/render_anim_preview.py` 2. Run render preview: `blender --background art/blender/matador.blend --python art/blender/render_anim_preview.py`
3. Inspect PNGs in `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
@@ -88,10 +131,26 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
bash run_tests.sh bash run_tests.sh
# Individual tests: # Individual tests:
gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv) gdlint actors systems ui props levels tests # GDScript lint (gdtoolkit, installed via uv)
godot --headless --script tests/logic_test.gd # pure logic, ~2 s godot --headless --script tests/logic_test.gd # pure logic, ~2 s
godot --headless --script tests/harness_selftest.gd # shared test_helpers: seeded RNG + approx + tally, ~1 s
godot --headless --script tests/damage_pipeline_test.gd # bull's hit reception (take_sword_hit + typed receive_damage/DamageEvent), ~3 s
godot --headless --script tests/fx_gallery_test.gd # every registered Fx effect spawns + self-frees, ~2 s
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
godot --headless --script tests/ragdoll_perf_test.gd # on-hit spike breakdown: ragdoll build / sim-start / blood / mass hit, ~90 s
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
godot --headless --script tests/touch_controls_test.gd # joystick self-heals: relayout/focus-out/vanished-touch never strand a stuck stick, ~2 s
godot --headless --script tests/ui_layout_test.gd # UI layout invariants (no pixels): HUD/menu stay on-screen, text fits, tap targets ≥44px, scroll works — across resolutions + pseudo-locale, ~6 s
godot --headless --script tests/rigid_skin_test.gd # web mesh conversion keeps every material (no bald / recoloured matadors), ~2 s
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
godot --headless --script tests/bear_ram_test.gd # a horns-first bull charge wounds the bear once; away/wide/slow spared, ~3 s
godot --headless --script tests/matador_stab_test.gd # matador's BladeHitbox thrust gores a stationary bull, and a landed stab breaks it off to taunt (anti-spam), ~2 s
godot --headless --script tests/matador_taunt_react_test.gd # a taunting matador answers a charge: spear from range, sword up close, ignores non-charges, ~2 s
godot --headless --script tests/matador_charge_test.gd # bull's charge is swept (tunnel-proof) so a fast dash still gores; a committed charge pierces the survival roll, ~2 s
godot --headless --script tests/matador_spear_test.gd # thrown spear masks the BULL layer and hits a stationary bull, ~2 s
# Gameplay test with real rendering (inspect frame strip visually): # Gameplay test with real rendering (inspect frame strip visually):
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
-137
View File
@@ -1,137 +0,0 @@
[gd_scene format=3 uid="uid://c4df4qgpc3d5g"]
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://Assets/Matador.glb" id="1_eicjn"]
[ext_resource type="Script" uid="uid://dycnoiadljbb1" path="res://Assets/matador_intro_menu.gd" id="2_5jr0i"]
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://Assets/arena_placeholder_v01.glb" id="3_1v6n4"]
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="3_5jr0i"]
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://Player.tscn" id="4_eicjn"]
[ext_resource type="Texture2D" uid="uid://3syhacswepyj" path="res://HDRI/kloppenheim_06_puresky_4k.hdr" id="6_1v6n4"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_eicjn"]
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_w7j4y"]
panorama = ExtResource("6_1v6n4")
energy_multiplier = 5.6
[sub_resource type="Sky" id="Sky_3el7n"]
sky_material = SubResource("PanoramaSkyMaterial_w7j4y")
[sub_resource type="Environment" id="Environment_5jr0i"]
background_mode = 2
background_energy_multiplier = 0.18
sky = SubResource("Sky_3el7n")
ambient_light_color = Color(0.31061712, 0.31061712, 0.3106171, 1)
ambient_light_energy = 16.0
ssao_enabled = true
ssao_radius = 1.17
ssao_intensity = 0.0
glow_intensity = 0.34
glow_strength = 0.29
glow_bloom = 0.1
fog_light_color = Color(2.211, 2.211, 2.211, 1)
fog_light_energy = 0.01
fog_density = 0.0399
volumetric_fog_enabled = true
volumetric_fog_density = 0.0005
volumetric_fog_emission = Color(1, 1, 1, 1)
volumetric_fog_emission_energy = 7.85
volumetric_fog_anisotropy = 0.9
volumetric_fog_length = 69.07
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_5jr0i"]
dof_blur_far_enabled = true
dof_blur_far_distance = 26.01
dof_blur_near_enabled = true
dof_blur_near_distance = 2.6
dof_blur_near_transition = 5.0
[node name="IntroMenu" type="Node3D" unique_id=2130618263]
[node name="Matador" parent="." unique_id=166852612 instance=ExtResource("1_eicjn")]
transform = Transform3D(-0.56715536, 0, -0.8236109, 0, 1, 0, 0.8236109, 0, -0.56715536, 5.2114463, -0.08998108, 1.8927763)
script = ExtResource("2_5jr0i")
[node name="Skeleton3D" parent="Matador/Armature" parent_id_path=PackedInt32Array(166852612, 1269629432) index="0" unique_id=978366810]
bones/0/position = Vector3(0.056660548, 0.6424767, -0.0167365)
bones/0/rotation = Quaternion(0.013179442, -0.2660563, 0.073319815, 0.9610747)
bones/1/rotation = Quaternion(0.06234612, 0.049236443, 0.08183311, 0.99347484)
bones/2/rotation = Quaternion(-0.073883414, -0.049860783, -0.17668208, 0.9802238)
bones/3/rotation = Quaternion(0.1986702, -0.17794952, -0.71790075, 0.6430262)
bones/4/rotation = Quaternion(0.34491858, -0.4473084, -0.25076818, 0.7861691)
bones/5/rotation = Quaternion(0.2577085, 0.103201896, -0.6748433, 0.68375605)
bones/6/rotation = Quaternion(1.7417646e-08, -1.0305205e-07, -0.05870078, 0.99827564)
bones/7/rotation = Quaternion(0.24033272, 0.3103031, 0.5222128, 0.7571301)
bones/8/rotation = Quaternion(5.5530215e-08, 1.2911333e-07, 0.055464897, 0.99846065)
bones/9/rotation = Quaternion(-6.3051346e-09, -3.6333955e-07, 0.005667881, 0.999984)
bones/10/rotation = Quaternion(2.5839595e-08, 9.468444e-08, 0.058700778, 0.99827564)
bones/11/rotation = Quaternion(0.39202607, 0.47060946, 0.57993996, 0.53713304)
bones/12/rotation = Quaternion(0.8712108, 0.008231261, 0.48523128, 0.073990606)
bones/13/rotation = Quaternion(-0.056263044, 0.0027001738, 0.3080972, 0.9496859)
bones/14/rotation = Quaternion(0.016082464, 0.024660494, -0.86586916, 0.4994036)
bones/15/rotation = Quaternion(0.6067664, -0.3062479, -0.7163194, 0.15790276)
bones/16/rotation = Quaternion(-0.0731876, -0.0023153822, -0.47496143, 0.87695485)
bones/17/rotation = Quaternion(-0.11824294, -0.17776872, 0.80557114, 0.55269533)
bones/18/rotation = Quaternion(0.5305965, -0.28927496, 0.76720744, -0.21489605)
bones/20/position = Vector3(0.22012731, 0.38421977, 0.36399037)
bones/21/position = Vector3(-0.23671798, 0.052281797, -0.027087089)
bones/21/rotation = Quaternion(0.5078295, -0.4920459, -0.4920459, 0.50782955)
bones/22/position = Vector3(-0.39721552, 0.31719273, 0.3595722)
bones/23/position = Vector3(-0.9167187, 1.3206767, 0.42621902)
bones/23/rotation = Quaternion(0.9993525, -0.0021175682, 0.03502641, -0.00795782)
bones/24/rotation = Quaternion(0.08874816, -0.018719967, -0.04671622, 0.99478185)
bones/25/rotation = Quaternion(0.09634846, -0.0268489, -0.04050248, 0.9941608)
[node name="matador" parent="Matador/Armature/Skeleton3D" index="0" unique_id=256196413]
transform = Transform3D(0.8642424, 0, 0.50307554, 0, 1, 0, -0.50307554, 0, 0.8642424, 0, 0, 0)
[node name="Muleta" parent="Matador/Armature/Skeleton3D" index="1" unique_id=988244930]
cast_shadow = 0
[node name="Camera3D" type="Camera3D" parent="." unique_id=1531255459]
transform = Transform3D(0.42176014, 0.2362673, 0.8753835, -0.10687317, 0.97167695, -0.21076548, -0.900387, -0.0046625203, 0.4350652, 8.41945, 0.17132203, 2.0652685)
fov = 41.6
near = 0.001
[node name="arena model2026" parent="." unique_id=179544183 instance=ExtResource("3_5jr0i")]
transform = Transform3D(-0.83746713, 0, 0.54648757, 0, 0.99999994, 0, -0.54648757, 0, -0.83746713, 3.8733478, -0.6, -1.1080065)
[node name="arena_placeholder_v01" parent="." unique_id=1343590504 instance=ExtResource("3_1v6n4")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344436, 0, -1.5401263)
visible = false
[node name="StaticBody3D" type="StaticBody3D" parent="arena_placeholder_v01" unique_id=340133176]
[node name="CollisionShape3D" type="CollisionShape3D" parent="arena_placeholder_v01/StaticBody3D" unique_id=201484167]
transform = Transform3D(-33.265, 4.9526625e-07, -2.9081186e-06, 5.0228755e-06, 3.28, 0, -2.9081186e-06, 4.329755e-14, 33.265, 0, -3.3065948, 0)
shape = SubResource("CylinderShape3D_eicjn")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=660229295]
transform = Transform3D(0.9491592, 0.31479403, -0.0014368258, -0.28033537, 0.84731936, 0.45106864, 0.14321098, -0.4277331, 0.89248836, 1.1162033, 7.3956842, -4.480528)
light_energy = 1.32
shadow_enabled = true
shadow_blur = 8.689
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="." unique_id=498871251]
transform = Transform3D(0.835524, -0.5376153, 0.11344351, -0.48260456, -0.6193575, 0.6192651, -0.26266432, -0.572159, -0.7769435, 1.1162033, 7.3956842, -4.480528)
visible = false
light_energy = 0.415
light_indirect_energy = 16.0
light_volumetric_fog_energy = 1.11
shadow_blur = 10.0
[node name="Player" parent="." unique_id=165510044 instance=ExtResource("4_eicjn")]
transform = Transform3D(0.9563367, 0, 0.29226705, 0, 1, 0, -0.29226705, 0, 0.9563367, 3.0920272, 0.39692497, -4.302477)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=114793752]
environment = SubResource("Environment_5jr0i")
camera_attributes = SubResource("CameraAttributesPractical_5jr0i")
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1428020323]
transform = Transform3D(0.94930077, -0.05846006, -0.30888584, 0.21308771, 0.8420559, 0.4955155, 0.23113126, -0.53621286, 0.81182146, -20.807917, 10.690277, 20.135067)
visible = false
light_energy = 16.0
light_volumetric_fog_energy = 16.0
shadow_enabled = true
spot_range = 84.316696
[editable path="Matador"]
@@ -1,2 +0,0 @@
[InternetShortcut]
URL=https://www.youtube.com/watch?v=ZCb12AHKMfE
-38
View File
@@ -1,38 +0,0 @@
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://player.gd" id="1_xhfnw"]
[ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://camera_iso.gd" id="2_camera_iso"]
[ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://Assets/bull.fbx" id="4_e80uo"]
[sub_resource type="SphereShape3D" id="SphereShape3D_chest"]
radius = 0.35
[sub_resource type="SphereShape3D" id="SphereShape3D_belly"]
radius = 0.4
[sub_resource type="SphereShape3D" id="SphereShape3D_hips"]
radius = 0.3
[node name="Player" type="CharacterBody3D" unique_id=165510044]
collision_layer = 3
script = ExtResource("1_xhfnw")
[node name="CollisionChest" type="CollisionShape3D" parent="." unique_id=447032296]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, -0.2)
shape = SubResource("SphereShape3D_chest")
[node name="CollisionBelly" type="CollisionShape3D" parent="." unique_id=571780801]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.1)
shape = SubResource("SphereShape3D_belly")
[node name="CollisionHips" type="CollisionShape3D" parent="." unique_id=717874116]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.4)
shape = SubResource("SphereShape3D_hips")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[editable path="bull"]
+1098
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://cn7fhjkro7637
+44
View File
@@ -0,0 +1,44 @@
[gd_scene format=3 uid="uid://o8ocjfprxa7u"]
[ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://actors/bear/bear.gd" id="1_beargd"]
[ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://art/characters/bear.glb" id="2_bearglb"]
[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://art/fx/bear_fx.glb" id="3_bearfx"]
[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://art/materials/bear_claw_fx.tres" id="4_clawfx"]
[ext_resource type="PackedScene" path="res://actors/bear/bear_attack_hitboxes.tscn" id="5_hitboxes"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 1.0
height = 2.4
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 1.3
height = 2.7
[node name="Bear" type="CharacterBody3D" unique_id=1573237287]
script = ExtResource("1_beargd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=830443382]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="." unique_id=365327397]
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=2111867348]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.35, 0)
shape = SubResource("CapsuleShape3D_hit")
[node name="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")]
[node name="Bear_FX" parent="bear_model" unique_id=2070553519 instance=ExtResource("3_bearfx")]
[node name="Bear_fx_claw_attack_R" parent="bear_model/Bear_FX" index="2" unique_id=1939248432]
surface_material_override/0 = ExtResource("4_clawfx")
[node name="Bear_fx_claw_attack_L" parent="bear_model/Bear_FX" index="3" unique_id=834640978]
surface_material_override/0 = ExtResource("4_clawfx")
[node name="AttackHitboxes" parent="bear_model" instance=ExtResource("5_hitboxes")]
[editable path="bear_model"]
[editable path="bear_model/Bear_FX"]
+70
View File
@@ -0,0 +1,70 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="1_qxn4a"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_6dow8"]
height = 3.0
radius = 4.0
[sub_resource type="BoxShape3D" id="BoxShape3D_tmmfg"]
size = Vector3(2.6, 2.5, 20)
[sub_resource type="Animation" id="Animation_wcwtv"]
length = 0.25
tracks/0/type = "scale_3d"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("LeapSlamHitbox/CollisionShape3D")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = PackedFloat32Array(0, 1, 0.12, 1, 0.12, 0.06, 1, 1, 1, 1, 0.25, 1, 1, 1, 1)
[sub_resource type="Animation" id="Animation_bg5u7"]
length = 0.3
tracks/0/type = "scale_3d"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("SmashHitbox/CollisionShape3D")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = PackedFloat32Array(0, 1, 1, 1, 0.02, 0.1, 1, 1, 1, 1, 0.3, 1, 1, 1, 1)
tracks/1/type = "position_3d"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("SmashHitbox/CollisionShape3D")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = PackedFloat32Array(0, 1, 0, 0.9, 0.2, 0.1, 1, 0, 0.9, 10, 0.3, 1, 0, 0.9, 10)
[sub_resource type="AnimationLibrary" id="AnimationLibrary_u1id2"]
_data = {
&"leap_slam": SubResource("Animation_wcwtv"),
&"smash_travel": SubResource("Animation_bg5u7")
}
[node name="AttackHitboxes" type="Node3D" unique_id=2010404706]
[node name="LeapSlamHitbox" type="Area3D" parent="." unique_id=1751893032]
collision_layer = 0
collision_mask = 2
monitoring = false
monitorable = false
script = ExtResource("1_qxn4a")
[node name="CollisionShape3D" type="CollisionShape3D" parent="LeapSlamHitbox" unique_id=1320172052]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
shape = SubResource("CylinderShape3D_6dow8")
[node name="SmashHitbox" type="Area3D" parent="." unique_id=1579648726]
collision_layer = 0
collision_mask = 2
monitoring = false
monitorable = false
script = ExtResource("1_qxn4a")
[node name="CollisionShape3D" type="CollisionShape3D" parent="SmashHitbox" unique_id=528637962]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 10)
shape = SubResource("BoxShape3D_tmmfg")
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=2139876757]
libraries/ = SubResource("AnimationLibrary_u1id2")
+418 -192
View File
@@ -1,13 +1,9 @@
extends CharacterBody3D extends CharacterBody3D
# WANDER → bull outside flee range, roaming freely # WANDER → roam/taunt outside flee range. FLEE → bull close: back away (raycast-steered).
# FLEE → bull within range, matador backs away (raycast-steered around walls) # BRACE → charge detected: plant + face (cite). SIDESTEP → bull commits: lateral pass (pase).
# BRACE → charge detected: plant feet, face the bull (cite phase) # ATTACK → chase and strike. ROLL → hit but rolled: survive. THROW → wind up + hurl the spear.
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase) # RAGDOLL → hit, no roll.
# ATTACK → post-dodge or mid-range: chase and strike
# ROLL → hit by bull but roll chance fires: survive and recover
# THROW → planted, winding up an overhand throw, then releasing the sword
# RAGDOLL → hit by bull, no roll chance
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
# The matador's high-level *want*, chosen by a weighted utility vote (see # The matador's high-level *want*, chosen by a weighted utility vote (see
@@ -22,6 +18,7 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0 var _idle_timer: float = 0.0
var _taunt_swap: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO var _step_dir: Vector3 = Vector3.ZERO
@@ -31,9 +28,12 @@ var _dodge_cd: float = 0.0
var _will_dodge: bool = true var _will_dodge: bool = true
var _attack_timer: float = 0.0 var _attack_timer: float = 0.0
var _swing_timer: float = 0.0 var _swing_timer: float = 0.0
var _lunge_timer: float = 0.0 var _attack_cd: float = 0.0
var _bull_prev_pos: Vector3 = Vector3.ZERO
var _bull_prev_seen: bool = false
var _roll_timer: float = 0.0 var _roll_timer: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO
var _is_spawning: bool = false
var _throw_timer: float = 0.0 var _throw_timer: float = 0.0
var _throw_aim_cd: float = 0.0 var _throw_aim_cd: float = 0.0
var _throw_dir: Vector3 = Vector3.ZERO var _throw_dir: Vector3 = Vector3.ZERO
@@ -50,8 +50,8 @@ var _drawing: bool = false
var _sheathing: bool = false var _sheathing: bool = false
var _draw_timer: float = 0.0 var _draw_timer: float = 0.0
var _draw_len: float = 0.0 var _draw_len: float = 0.0
var _resword_timer: float = 0.0 var _sword_blade_area: Hitbox = null
var _sword_blade_area: Area3D = null var _spear_node: Node3D = null
var _blade_hit_cd: float = 0.0 var _blade_hit_cd: float = 0.0
var _death_player: AudioStreamPlayer = null var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null var _blood_burst: CPUParticles3D = null
@@ -59,6 +59,7 @@ var _blood_burst: CPUParticles3D = null
@onready var _mesh: Node3D = $matador2 @onready var _mesh: Node3D = $matador2
@onready var _hit_area: Area3D = $HitArea @onready var _hit_area: Area3D = $HitArea
const _ANIM_RUN: StringName = &"Run" const _ANIM_RUN: StringName = &"Run"
const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_IDLE: StringName = &"Taunt"
const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ATTACK: StringName = &"Attack"
@@ -73,7 +74,11 @@ 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
const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") # Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see sword.tscn / spear.tscn.
const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn")
const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn")
const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc
const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall
const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
@@ -88,15 +93,24 @@ const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
const RigidSkin = preload("res://systems/render/rigid_skin.gd")
func _ready() -> void: func _ready() -> void:
add_to_group(&"matador") add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh) _skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh) _anim_player = _find_anim_player(_mesh)
if _skeleton: if _skeleton:
_sim = MatadorRagdoll.build(_skeleton) _sim = MatadorRagdoll.build(_skeleton)
# Native GPU skinning by default; rigid_skin is an opt-in fallback now (the Mali/ANGLE
# invisible-skinning bug is fixed in the 4.7 web templates). See Controls.rigid_skin_enabled().
# When on, it runs after the ragdoll sim is built (both drive the same bones).
if Controls.rigid_skin_enabled():
RigidSkin.convert_tree(self)
if _anim_player: if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS: for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
_ensure_loop(anim) _ensure_loop(anim)
_flatten_roll_travel()
if not _anim_player.has_animation(_ANIM_RUN): if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" % push_warning("Matador: expected animations not found. Available: %s" %
str(_anim_player.get_animation_list())) str(_anim_player.get_animation_list()))
@@ -104,20 +118,22 @@ func _ready() -> void:
_anim_player.play(_ANIM_IDLE) _anim_player.play(_ANIM_IDLE)
_setup_sword() _setup_sword()
DP.any_changed.connect(_on_dp_changed) DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(0.3, 1.0) _throw_aim_cd = randf_range(2.0, 5.0)
_hit_area.body_entered.connect(_on_body_entered) _hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target() _pick_wander_target()
_blood_burst = preload("res://blood_burst.gd").new() _blood_burst = preload("res://systems/fx/blood_burst.gd").new()
add_child(_blood_burst) add_child(_blood_burst)
if ResourceLoader.exists("res://sounds/ole.ogg"): if ResourceLoader.exists("res://sounds/ole.ogg"):
_ole_player = AudioStreamPlayer.new() _ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg") _ole_player.stream = load("res://sounds/ole.ogg")
_ole_player.volume_db = 0.0 _ole_player.volume_db = 0.0
_ole_player.bus = &"Effects"
add_child(_ole_player) add_child(_ole_player)
if ResourceLoader.exists("res://sounds/matador_death.ogg"): if ResourceLoader.exists("res://sounds/matador_death.ogg"):
_death_player = AudioStreamPlayer.new() _death_player = AudioStreamPlayer.new()
_death_player.stream = load("res://sounds/matador_death.ogg") _death_player.stream = load("res://sounds/matador_death.ogg")
_death_player.volume_db = 2.0 _death_player.volume_db = 2.0
_death_player.bus = &"Effects"
add_child(_death_player) add_child(_death_player)
@@ -138,6 +154,10 @@ func _acquire_bull() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_acquire_bull() _acquire_bull()
if _is_spawning and is_on_floor():
_is_spawning = false
collision_layer = 1
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO: if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
var o := global_position + Vector3.UP * 0.6 var o := global_position + Vector3.UP * 0.6
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9)) DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
@@ -148,12 +168,9 @@ func _physics_process(delta: float) -> void:
_dodge_cd = maxf(0.0, _dodge_cd - delta) _dodge_cd = maxf(0.0, _dodge_cd - delta)
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
# Pull a replacement sword from the holster a beat after throwing the last one. _attack_cd = maxf(0.0, _attack_cd - delta)
if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \ if _bull != null:
and (_sword_rest_attach != null or _sword_hand_attach != null): _sweep_bull_gore()
_resword_timer -= delta
if _resword_timer <= 0.0:
_spawn_sword()
if _state != State.RAGDOLL and _bull != null: if _state != State.RAGDOLL and _bull != null:
_update_ai_state(delta) _update_ai_state(delta)
@@ -171,9 +188,8 @@ func _physics_process(delta: float) -> void:
_advance_draw(delta) _advance_draw(delta)
# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a # Utility-driven brain. Committed states (brace / pass / roll / throw wind-up) run to completion on
# throw wind-up) run to completion on their own timers; from the free states the # their own timers; from the free states the matador re-scores every mat_decide_interval.
# matador re-scores its options every mat_decide_interval and acts on the winner.
func _update_ai_state(delta: float) -> void: func _update_ai_state(delta: float) -> void:
if _state == State.BRACE or _state == State.SIDESTEP \ if _state == State.BRACE or _state == State.SIDESTEP \
or _state == State.ROLL or _state == State.THROW: or _state == State.ROLL or _state == State.THROW:
@@ -190,9 +206,8 @@ func _update_ai_state(delta: float) -> void:
_enter_intent(intent) _enter_intent(intent)
# Score each intent from weighted heuristics and return the winner. Weights (the # Score each intent from weighted mat_w_* heuristics and return the winner; a small commit bonus on
# mat_w_* params) shape the personality; a small commit bonus on the current intent # the current intent stops it flip-flopping between two near-tied options.
# stops it flip-flopping between two near-tied options.
func _choose_intent() -> Intent: func _choose_intent() -> Intent:
var dist := global_position.distance_to(_bull.global_position) var dist := global_position.distance_to(_bull.global_position)
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
@@ -213,24 +228,40 @@ func _choose_intent() -> Intent:
# A matador won't commit to a strike it can't bail out of, so a spent dodge # A matador won't commit to a strike it can't bail out of, so a spent dodge
# cooldown makes attacking (and dodging outright) less attractive. # cooldown makes attacking (and dodging outright) less attractive.
var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35 var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
# The post-stab recovery beat (mat_stab_recovery) kills ATTACK outright — the matador circles
# or gives ground between thrusts rather than machine-gunning the bull; DODGE/FLEE stay live.
var attack_ready := 0.0 if _attack_cd > 0.0 else 1.0
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
# Only the nearest few matadors work the bull at once; the rest hang back and
# taunt (see _engagement_rank). This turns a crowd into a bullring — matadors
# spreading out and taking turns — instead of a swarm all piling on together.
var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots")))
# Supporting matadors strut and circle constantly; the lead only struts between
# passes, once the bull is back out past horn reach.
var strut := 0.65 if not is_engager else 0.3 * beyond
var engage_mult := 1.0 if is_engager else 0.15
var score := { var score := {
# Only drift when the bull is out past throw range; up close this is ~0. # Showmanship: circle the bull at a taunting distance and pose for the crowd.
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond, Intent.WANDER: DP.f("mat_w_showmanship") * strut,
# Retreat only when the bull is close AND actually dangerous. # Retreat only when the bull is close AND actually dangerous.
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger), Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
# Press the attack; ease off a touch when a charge is barrelling in. # Press the attack; ease off a touch when a charge is barrelling in. Supporting
# matadors barely press — they yield the pass to whoever's engaging.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_ready, * (1.0 - 0.55 * danger) * dodge_ready * engage_mult * attack_ready,
# Sidestep / plant against an incoming charge. # Sidestep / plant against an incoming charge.
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0), Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
# Hurl the blade whenever there's a clean lane at range — the ranged threat # Hurl the blade — an occasional ranged flourish from an engaging matador when
# that punishes a bull for hanging back out of horn reach. # there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \ Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
* (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0, if (throw_ok and is_engager) else -1.0,
} }
# Commit bonus keeps a near-tied choice from flip-flopping — but don't re-reinforce a
# just-swung ATTACK we're recovering from, or the matador clings to it and spams the bull.
if not (_intent == Intent.ATTACK and _attack_cd > 0.0):
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
var best: Intent = Intent.WANDER var best: Intent = Intent.WANDER
@@ -277,6 +308,39 @@ func _enter_intent(intent: Intent) -> void:
_start_throw() _start_throw()
# How many other live matadors are closer to the bull than this one (0 = the lead engager); the
# brain lets the nearest mat_engage_slots press while the rest spread out and taunt.
func _engagement_rank() -> int:
if _bull == null:
return 0
var my_d := global_position.distance_squared_to(_bull.global_position)
var rank := 0
for node: Node in get_tree().get_nodes_in_group(&"matador"):
if node == self:
continue
var other := node as Node3D
if other == null or not is_instance_valid(other) or not other.call(&"is_active"):
continue
if other.global_position.distance_squared_to(_bull.global_position) < my_d:
rank += 1
return rank
# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot.
func is_active() -> bool:
return _state != State.RAGDOLL
# True when this matador is safely out of the action — not one of the engaging few,
# and with the bull far enough off to strike a long, unhurried taunt for the crowd.
func _out_of_harm() -> bool:
if _bull == null:
return true
if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))):
return false
return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6
# Bull is moving fast and aimed within ~30° of the matador # Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool: func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
@@ -289,6 +353,19 @@ func _is_charge_incoming() -> bool:
return bull_flat.normalized().dot(to_me.normalized()) > 0.85 return bull_flat.normalized().dot(to_me.normalized()) > 0.85
# Bull charging fast and aimed straight at the matador, at ANY range (unlike _is_charge_incoming,
# which is capped to brace range) — so a taunting matador can answer a charge from across the arena.
func _charge_aimed_at_me() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if bull_flat.length() < DP.f("mat_charge_speed"):
return false
var to_me := global_position - _bull.global_position
to_me.y = 0.0
if to_me.length() < 0.1:
return true
return bull_flat.normalized().dot(to_me.normalized()) > 0.9
# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds. # Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
# Randomises among available clear angles so juke direction is unpredictable. # Randomises among available clear angles so juke direction is unpredictable.
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
@@ -349,8 +426,29 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void:
func _tick_wander(delta: float) -> void: func _tick_wander(delta: float) -> void:
# Plant the feet while drawing / sheathing instead of sliding through the clip. # Plant the feet while drawing / sheathing instead of sliding through the clip.
if _drawing or _sheathing or _idle_timer > 0.0: if _drawing or _sheathing or _idle_timer > 0.0:
# A charge aimed at a taunting matador snaps it out of the pose: hurl a spear at a bull
# barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush.
if _idle_timer > 0.0 and not _drawing and not _sheathing \
and _bull != null and _charge_aimed_at_me():
var dist := global_position.distance_to(_bull.global_position)
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
if dist > DP.f("mat_brace_range") and throw_ok:
_start_throw() # far charge — get the spear off before it arrives
return
if dist <= DP.f("mat_brace_range"):
_start_brace() # closer rush — draw the sword and cite the charge
return
_idle_timer = maxf(0.0, _idle_timer - delta) _idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta) _decelerate(10.0, delta)
# Taunt toward the bull so the posturing plays to it, not off into space.
if _bull != null and _idle_timer > 0.0:
_face_point(_bull.global_position, delta, _TURN_MOVE)
# Cycle gestures through a long hold so a posing matador stays lively.
_taunt_swap -= delta
if _taunt_swap <= 0.0 and not _drawing and not _sheathing:
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_play_anim(_idle_anim) _play_anim(_idle_anim)
move_and_slide() move_and_slide()
return return
@@ -358,8 +456,12 @@ func _tick_wander(delta: float) -> void:
var to_target := Vector3( var to_target := Vector3(
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8: if to_target.length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) # A matador out of harm's way settles into a long taunt for the crowd; one still
# in the mix only pauses briefly between passes.
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \
if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt() _idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_pick_wander_target() _pick_wander_target()
else: else:
var dir := _steer_clear(to_target.normalized(), delta) var dir := _steer_clear(to_target.normalized(), delta)
@@ -461,6 +563,7 @@ func _start_attack() -> void:
func _tick_attack(delta: float) -> void: func _tick_attack(delta: float) -> void:
_attack_timer -= delta _attack_timer -= delta
var was_swinging := _swing_timer > 0.0
_swing_timer = maxf(0.0, _swing_timer - delta) _swing_timer = maxf(0.0, _swing_timer - delta)
# Plant the feet while drawing instead of sliding toward the bull mid-clip. # Plant the feet while drawing instead of sliding toward the bull mid-clip.
if _drawing or _sheathing: if _drawing or _sheathing:
@@ -473,22 +576,41 @@ func _tick_attack(delta: float) -> void:
to_bull.y = 0.0 to_bull.y = 0.0
var dist := to_bull.length() var dist := to_bull.length()
# Commit to a full swing once in range instead of flipping Run/Attack at the # A finished stab enforces a recovery beat (no re-commit for mat_stab_recovery), then decides
# distance boundary every frame (the old foot-slide jitter). When the swing # what's next: a landed hit very likely breaks into a taunt/retreat, otherwise the usual roll
# ends the matador re-closes if the bull slipped away, or stabs again if not. # to give ground / circle / throw — anything but instantly drilling the same thrust again.
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand: if was_swinging and _swing_timer <= 0.0:
_attack_cd = DP.f("mat_stab_recovery")
var landed := _sword_blade_area != null and _sword_blade_area.did_hit()
if landed and randf() < DP.f("mat_taunt_after_hit"):
_do_taunt_retreat()
return
if randf() < DP.f("mat_stab_followup_chance"):
_do_stab_followup()
return
# Run in (Run clip) to mat_stab_standoff, then plant and swing on the spot — the standoff sits
# inside the sword's ~3.75 m thrust reach; killing velocity on commit stops the feet sliding.
# The recovery beat gates the next commit so back-to-back stabs can't machine-gun the bull.
if _swing_timer <= 0.0 and _attack_cd <= 0.0 and dist <= DP.f("mat_stab_standoff") and _sword_in_hand:
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9) _swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
_lunge_timer = DP.f("mat_lunge_time") velocity.x = 0.0
velocity.z = 0.0
# Re-arm fresh each swing — back-to-back stabs commit before the else-branch deactivate runs.
if _sword_blade_area != null:
_sword_blade_area.deactivate()
if _swing_timer > 0.0: if _swing_timer > 0.0:
_lunge_timer = maxf(0.0, _lunge_timer - delta)
if _lunge_timer > 0.0 and dist > 0.1:
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
else:
_decelerate(22.0, delta) _decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK) _play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach")) # 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.
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
_sword_blade_area.activate()
_sword_blade_area.sweep()
else: else:
if _sword_blade_area != null:
_sword_blade_area.deactivate()
var dir := _steer_clear(to_bull.normalized(), delta) var dir := _steer_clear(to_bull.normalized(), delta)
_accelerate(dir, DP.f("mat_attack_speed"), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta)
_play_anim(_ANIM_RUN) _play_anim(_ANIM_RUN)
@@ -496,6 +618,50 @@ func _tick_attack(delta: float) -> void:
move_and_slide() move_and_slide()
# How far through the attack swing: 0 (just committed) → 1 (finished), tracking the clip's progress.
func _swing_phase() -> float:
var length := _anim_length(_ANIM_ATTACK, 0.9)
if length <= 0.0:
return 1.0
return clampf(1.0 - _swing_timer / length, 0.0, 1.0)
# Break off after a stab instead of drilling the same thrust: give ground, hurl a spear, or circle.
# The brain re-engages after; _decide_cd is reset so the pick plays out before it re-scores.
func _do_stab_followup() -> void:
_decide_cd = DP.f("mat_decide_interval")
_dodge_cd = DP.f("mat_dodge_cooldown")
_steer_cd = 0.0
var roll := randf()
if roll < 0.2 and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node):
_start_throw() # ranged flourish
elif roll < 0.6:
_state = State.FLEE # give ground after the thrust
_intent = Intent.FLEE
else:
_state = State.WANDER # circle to a fresh angle before pressing again
_intent = Intent.WANDER
_pick_wander_target()
# A stab that LANDED: milk it. Break into a long taunt held toward the bull, then drift out to the
# showmanship ring — and lock ATTACK out for the whole hold (_attack_cd) so the brain can't drag the
# matador straight back into another thrust. DODGE/FLEE stay live, so a charging bull is still
# answered. This is what stops the "stab, stab, stab" spam after the matador connects.
func _do_taunt_retreat() -> void:
_state = State.WANDER
_intent = Intent.WANDER
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3)
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_attack_cd = maxf(_attack_cd, _idle_timer)
_dodge_cd = DP.f("mat_dodge_cooldown")
_steer_cd = 0.0
_pick_wander_target()
if _sword_blade_area != null:
_sword_blade_area.deactivate()
func _enter_roll(hit_dir: Vector3) -> void: func _enter_roll(hit_dir: Vector3) -> void:
_state = State.ROLL _state = State.ROLL
# Match the state to the actual clip so the roll plays through once instead of # Match the state to the actual clip so the roll plays through once instead of
@@ -509,6 +675,22 @@ func _enter_roll(hit_dir: Vector3) -> void:
_anim_player.seek(0.0, true) _anim_player.seek(0.0, true)
# Called (deferred) by the spawner to make the matador leap-roll toward a target on entry — the
# cinematic arena entrance. After the roll it transitions to normal AI via the ROLL → FLEE path.
func enter_spawn_roll(toward: Vector3) -> void:
var dir := toward - global_position
dir.y = 0.0
_roll_dir = dir.normalized() if dir.length() > 0.1 else -_mesh.global_transform.basis.z
_state = State.ROLL
_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
_is_spawning = true
collision_layer = 0
velocity.y = 4.0
if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
_anim_player.play(_ANIM_ROLL)
_anim_player.seek(0.0, true)
func _tick_roll(delta: float) -> void: func _tick_roll(delta: float) -> void:
_roll_timer -= delta _roll_timer -= delta
if _roll_timer <= 0.0: if _roll_timer <= 0.0:
@@ -528,7 +710,8 @@ func _start_throw() -> void:
_throw_timer = DP.f("mat_throw_windup") _throw_timer = DP.f("mat_throw_windup")
velocity = Vector3.ZERO velocity = Vector3.ZERO
_cancel_transitions() _cancel_transitions()
_carry_sword(true) # blade must be in hand for the wind-up and release _carry_sword(false) # the sword is melee-only — holster it for the throw
_spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw
if _anim_player: if _anim_player:
_anim_player.pause() _anim_player.pause()
var to_bull := _bull.global_position - global_position var to_bull := _bull.global_position - global_position
@@ -552,8 +735,8 @@ func _tick_throw(delta: float) -> void:
_pose_throw_arm(phase) _pose_throw_arm(phase)
var t_rel := DP.f("mat_throw_release") var t_rel := DP.f("mat_throw_release")
if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node): if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
_release_sword() _release_spear()
if _throw_timer <= 0.0: if _throw_timer <= 0.0:
_end_throw() _end_throw()
@@ -562,20 +745,18 @@ func _tick_throw(delta: float) -> void:
func _end_throw() -> void: func _end_throw() -> void:
_state = State.FLEE _state = State.FLEE
_clear_spear() # drop any spear still in hand if the wind-up was cut short
_dodge_cd = DP.f("mat_dodge_cooldown") _dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws out so the utility brain doesn't immediately vote another one, # Space throws well apart so a hurl stays a flourish, not a barrage.
# but keep them frequent enough to pressure a bull that hangs back at range. _throw_aim_cd = randf_range(4.0, 7.0)
_throw_aim_cd = randf_range(1.6, 3.2)
_intent = Intent.FLEE _intent = Intent.FLEE
# Blend straight into locomotion so the manually-posed throw arm eases out # Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
# (a bare resume() would snap and leave the player on whatever it paused on).
if _anim_player: if _anim_player:
_anim_player.play(_ANIM_RUN) _anim_player.play(_ANIM_RUN)
# Drive arm_R + forearm_R through a cock-back → release arc while the # Drive arm_R + forearm_R through a cock-back → release arc while the
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md rig notes); # AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
# angles are DP-tunable so the arc can be dialled in per rig.
func _pose_throw_arm(phase: float) -> void: func _pose_throw_arm(phase: float) -> void:
if not _skeleton: if not _skeleton:
return return
@@ -605,82 +786,59 @@ func _set_bone_swing(bone: String, deg: float) -> void:
_skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg))) _skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg)))
# Detach the sword from the hand and launch it as a spinning physics projectile # Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
# that damages the bull on contact, then settles and despawns. func _spawn_spear_in_hand() -> void:
func _release_sword() -> void: if is_instance_valid(_spear_node) or _sword_hand_attach == null:
if not is_instance_valid(_sword_node):
return return
var sword := _sword_node _spear_node = _SPEAR_SCENE.instantiate() as Node3D
var blade := _sword_blade_area _sword_hand_attach.add_child(_spear_node)
var gx := sword.global_transform _spear_node.position = Vector3(
_sword_node = null DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
_sword_blade_area = null _spear_node.rotation_degrees = Vector3(
_sword_in_hand = false DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))
_resword_timer = DP.f("mat_resword_delay")
# The RigidBody owns collisions now — silence the melee blade Area it carries.
if is_instance_valid(blade):
blade.monitoring = false
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
rb.contact_monitor = true
rb.max_contacts_reported = 6
# Fly nearly straight to the aim point instead of lobbing — the old horizontal
# throw sailed clean over the bull's low body. A small gravity_scale keeps a
# touch of drop for feel without dropping short.
rb.gravity_scale = DP.f("mat_throw_gravity")
get_tree().current_scene.add_child(rb)
rb.global_transform = gx
sword.reparent(rb, true)
# Physics collider along the blade (GLB +Z), which equals rb-local +Z because func _clear_spear() -> void:
# the mesh kept its global transform and rb adopted it. Fattened so a fast throw if is_instance_valid(_spear_node):
# reliably overlaps the bull's collision spheres instead of tunnelling past. _spear_node.queue_free()
var cap := CapsuleShape3D.new() _spear_node = null
cap.radius = 0.14
cap.height = 1.4
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.7)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs)
# Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the
# origin), leading a moving target so it arrives where the bull will be. The aim # Hurl the held spear on a ballistic arc that lands on the bull's low body. The
# keeps its vertical component so the blade drives into the body, not over it. # launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
var speed := maxf(DP.f("mat_throw_speed"), 0.1) # lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
var dir := (_throw_dir + Vector3.UP * 0.0) func _release_spear() -> void:
if not is_instance_valid(_spear_node):
return
var spear := _spear_node
var origin := spear.global_position
_spear_node = null
var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed
var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05)
# Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it
# across the arc's flight time.
var target := origin + _throw_dir * 5.0
if is_instance_valid(_bull): if is_instance_valid(_bull):
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0) target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var flat := target - gx.origin var lead := target - origin
lead.y = 0.0
target += _bull.velocity * (lead.length() / hs)
var flat := target - origin
var rise := flat.y
flat.y = 0.0 flat.y = 0.0
var flight := flat.length() / speed var dist := flat.length()
target += _bull.velocity * flight var hdir := flat.normalized() if dist > 0.01 else _throw_dir
var to_target := target - gx.origin # Aim scatter so the arc can be read and sidestepped, not a hitscan.
if to_target.length() > 0.1:
dir = to_target.normalized()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
# a bull that keeps moving can slip them, a stationary one gets pinned.
var spread := deg_to_rad(DP.f("mat_throw_spread")) var spread := deg_to_rad(DP.f("mat_throw_spread"))
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized() hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
rb.linear_velocity = dir * speed # Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") # pick the vertical launch speed that lands the arc on the aim point's height.
var t := dist / hs
var hit_done := [false] var vy := (rise + 0.5 * g * t * t) / t
rb.body_entered.connect(func(body: Node) -> void: var vel := hdir * hs + Vector3.UP * vy
if hit_done[0]: SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
return maxf(DP.f("mat_throw_gravity"), 0.05))
if body.is_in_group(&"player"):
hit_done[0] = true
body.call(&"take_sword_hit", "thrown")
)
var cleanup := get_tree().create_timer(6.0)
cleanup.timeout.connect(func() -> void:
if is_instance_valid(rb):
rb.queue_free()
)
func _tick_ragdoll(_delta: float) -> void: func _tick_ragdoll(_delta: float) -> void:
@@ -707,34 +865,84 @@ func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group(&"player"): if not body.is_in_group(&"player"):
return return
var player := body as CharacterBody3D var player := body as CharacterBody3D
if player.velocity.length() < DP.f("mat_hit_threshold"): var bull_speed := player.velocity.length()
if bull_speed < DP.f("mat_hit_threshold"):
return
if not _horns_first(player.global_position):
return return
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z) var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \ var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
(global_position - player.global_position).normalized() (global_position - player.global_position).normalized()
_take_bull_charge(bull_speed, hit_dir)
if randf() < DP.f("mat_roll_chance"):
# Tunnel-proof charge detection: at dash speed the bull skips clean over the thin HitArea between
# ticks, so body_entered never fires. Sweep its actual travel (prev → now) and gore on any pass within
# mat_charge_gore_radius. Runs every frame (even ragdolled) to keep the segment fresh.
func _sweep_bull_gore() -> void:
var now := _bull.global_position
var seg_from := _bull_prev_pos if _bull_prev_seen else now
_bull_prev_pos = now
_bull_prev_seen = true
if _state == State.RAGDOLL or _state == State.ROLL:
return
var flat_vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var bull_speed := flat_vel.length()
if bull_speed < DP.f("mat_hit_threshold"):
return
var a := Vector3(seg_from.x, 0.0, seg_from.z)
var b := Vector3(now.x, 0.0, now.z)
var p := Vector3(global_position.x, 0.0, global_position.z)
var closest := Geometry3D.get_closest_point_to_segment(p, a, b)
if p.distance_to(closest) > DP.f("mat_charge_gore_radius"):
return
if not _horns_first(seg_from):
return
var hit_dir := flat_vel.normalized() if bull_speed > 0.5 \
else (global_position - now).normalized()
_take_bull_charge(bull_speed, hit_dir)
# Horns-first gate: gore only when the bull's velocity points within bull_gore_arc of the direction
# from `from` to the matador — not a sideways brush or backing in. The dash ability skips the gate.
func _horns_first(from: Vector3) -> bool:
if _bull == null:
return false
if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"):
return true # the dash ability is a committed lunge — it gores on contact, no angle gate
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if vel.length() < 0.5:
return false
var to_me := global_position - from
to_me.y = 0.0
if to_me.length() < 0.01:
return true
return vel.normalized().dot(to_me.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc")))
# Resolve a connected charge: the survival roll fades as bull speed climbs (mat_charge_pierce), so a
# committed charge gores rather than getting shrugged off.
func _take_bull_charge(bull_speed: float, hit_dir: Vector3) -> void:
# The boulder roll bowls matadors over with its own launch + chain (player._roll_try_pop); don't
# pre-empt that with a quiet gore, or the ball rolls straight through without popping.
if _bull.has_method(&"is_rolling") and _bull.call(&"is_rolling"):
return
# The dash is a committed lunge — a connect is a guaranteed kill, no survival roll (it already
# bypasses the horns cone). A passive cruise-charge still leaves a roll chance that fades with speed.
var roll_chance := 0.0
if not (_bull.has_method(&"is_dashing") and _bull.call(&"is_dashing")):
var floor_spd := DP.f("mat_hit_threshold")
var full_spd := maxf(DP.f("mat_charge_speed"), floor_spd + 0.1)
var commit := clampf((bull_speed - floor_spd) / (full_spd - floor_spd), 0.0, 1.0)
roll_chance = DP.f("mat_roll_chance") * (1.0 - commit * DP.f("mat_charge_pierce"))
if randf() < roll_chance:
_enter_roll(hit_dir) _enter_roll(hit_dir)
else: else:
_enter_ragdoll(hit_dir, player.velocity.length()) _enter_ragdoll(hit_dir, bull_speed)
func _on_blade_hit(body: Node3D) -> void: # Reach-based hit for the sidestep PASS (fast lateral sweep — the thin blade Area tunnels, so a
if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE: # distance+facing check is the reliable gore; the ATTACK stab uses the blade collider instead).
return
if not body.is_in_group(&"player"):
return
if _blade_hit_cd > 0.0:
return
_blade_hit_cd = DP.f("mat_stab_cooldown")
body.call(&"take_sword_hit", "gored")
# Reach-based stab. A charging bull crosses metres per physics frame, so it easily
# tunnels through the thin blade Area between frames — this distance check is the
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
# blade actually presented, not held off to the side — it's gored.
func _try_melee_hit(reach: float) -> void: func _try_melee_hit(reach: float) -> void:
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null: if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
return return
@@ -743,19 +951,18 @@ func _try_melee_hit(reach: float) -> void:
var dist := to_bull.length() var dist := to_bull.length()
if dist > reach or dist < 0.01: if dist > reach or dist < 0.01:
return return
# Only lands within the frontal arc — you're gored on a presented blade, not by
# brushing a matador whose sword is pointing elsewhere.
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y)) var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))): if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
return return
_blade_hit_cd = DP.f("mat_stab_cooldown") _blade_hit_cd = DP.f("mat_stab_cooldown")
_bull.call(&"take_sword_hit", "gored") DamageEvent.deal(_bull, DamageEvent.Cause.GORED, _bull.global_position, self)
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void: func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
_state = State.RAGDOLL _state = State.RAGDOLL
if _sword_blade_area: if _sword_blade_area != null:
_sword_blade_area.monitoring = false _sword_blade_area.deactivate()
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
killed.emit() killed.emit()
var cleanup_timer := get_tree().create_timer(4.0) var cleanup_timer := get_tree().create_timer(4.0)
cleanup_timer.timeout.connect(func() -> void: cleanup_timer.timeout.connect(func() -> void:
@@ -866,6 +1073,32 @@ func _ensure_loop(anim_name: StringName) -> void:
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the
# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel.
func _flatten_roll_travel() -> void:
if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL):
return
var anim := _anim_player.get_animation(_ANIM_ROLL)
if anim.has_meta(&"_travel_flattened"):
return
anim.set_meta(&"_travel_flattened", true)
var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D)
if cog < 0:
return
# Snapshot the COG travel before mutating (flattening COG first would zero it).
var travel: Dictionary = {}
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var t := anim.track_get_key_time(i, k)
travel[t] = anim.position_track_interpolate(cog, t).z
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var v: Vector3 = anim.track_get_key_value(i, k)
anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)]))
func _pick_taunt() -> StringName: func _pick_taunt() -> StringName:
var choices: Array[StringName] = [] var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS: for anim in _ANIM_TAUNTS:
@@ -893,13 +1126,25 @@ func _play_anim(anim_name: StringName) -> void:
func _pick_wander_target() -> void: func _pick_wander_target() -> void:
# 35% chance: pick a point orbiting close to the bull for a daring pass # Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
if _bull != null and randf() < 0.35: # crowd out around it), with an occasional close pass for daring flair.
var angle := randf() * TAU if _bull != null:
var r := randf_range(1.5, 4.0)
var bull_flat := _bull.global_position var bull_flat := _bull.global_position
bull_flat.y = 0.0 bull_flat.y = 0.0
var r: float
if randf() < 0.2:
r = randf_range(1.5, 4.0) # close daring pass
else:
var ring := DP.f("mat_taunt_ring")
r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring
var angle := randf() * TAU
_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
# Keep the pose inside the fighting floor rather than backing onto the benches.
var arena_r := DP.f("arena_spawn_radius")
var flat := Vector2(_wander_target.x, _wander_target.z)
if flat.length() > arena_r:
flat = flat.normalized() * arena_r
_wander_target = Vector3(flat.x, 0.0, flat.y)
return return
var radius := DP.f("mat_wander_radius") var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU var angle := randf() * TAU
@@ -909,13 +1154,9 @@ func _pick_wander_target() -> void:
# ── Sword attachment ────────────────────────────────────────────────────────── # ── Sword attachment ──────────────────────────────────────────────────────────
# The rig (matador_v03) carries two purpose-built bones for the sword: # The rig (matador_v03) carries two bones: weapon_bone (right-hand grip) and weapon_rest_bone (hip
# weapon_bone — the grip pose in the right hand (drawn / fighting) # holster). The sword rides in the holster, carried to the hand in combat (_carry_sword); each bone
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering) # has its own DP-tunable offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
# The sword lives in the holster by default and is carried to the hand while the
# matador is in a combat state (see _carry_sword). Each bone has its own
# DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the
# holster) so the two poses can be seated independently without re-exporting.
func _setup_sword() -> void: func _setup_sword() -> void:
if not _skeleton: if not _skeleton:
return return
@@ -934,8 +1175,7 @@ func _setup_sword() -> void:
_spawn_sword() _spawn_sword()
# Instantiate a fresh sword into the holster, with its own blade collider. Called # Instantiate the matador's sword into the holster, with its own blade collider.
# at spawn and again after a throw, so a matador can keep drawing replacements.
func _spawn_sword() -> void: func _spawn_sword() -> void:
if is_instance_valid(_sword_node): if is_instance_valid(_sword_node):
return return
@@ -949,38 +1189,26 @@ func _spawn_sword() -> void:
_drawing = false _drawing = false
_apply_sword_grip() _apply_sword_grip()
# Blade collider spans the steel (hilt at local origin, blade running +Z out # The blade hitbox (sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
# to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword). # _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
var blade_cap := CapsuleShape3D.new() _sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
blade_cap.radius = 0.18 if _sword_blade_area != null:
blade_cap.height = 1.30 _sword_blade_area.deactivate()
var blade_cs := CollisionShape3D.new()
blade_cs.shape = blade_cap
blade_cs.position = Vector3(0.0, 0.0, 0.8)
# CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade.
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
_sword_blade_area = Area3D.new()
_sword_blade_area.collision_layer = 0
_sword_blade_area.collision_mask = 1
_sword_blade_area.monitoring = _sword_in_hand
_sword_blade_area.add_child(blade_cs)
_sword_node.add_child(_sword_blade_area)
_sword_blade_area.body_entered.connect(_on_blade_hit)
# The sword is carried by ACTION, not distance: drawn only while striking # The sword is MELEE-only: drawn for ATTACK / BRACE / SIDESTEP, holstered otherwise. Ranged throws
# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting. # use a spear (_spawn_spear_in_hand), so THROW keeps the sword holstered.
# THROW snaps the blade out (its arm cock-back covers the motion); everything
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
func _wants_sword_drawn() -> bool: func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW \ return _state == State.ATTACK \
or _state == State.BRACE or _state == State.SIDESTEP or _state == State.BRACE or _state == State.SIDESTEP
func _update_sword_carry() -> void: func _update_sword_carry() -> void:
if not is_instance_valid(_sword_node) or _state == State.RAGDOLL: if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
return return
# The blade collider lives only during the ATTACK thrust; leaving ATTACK disarms it.
if _state != State.ATTACK and _sword_blade_area != null:
_sword_blade_area.deactivate()
if _wants_sword_drawn(): if _wants_sword_drawn():
if not _sword_in_hand and not _drawing: if not _sword_in_hand and not _drawing:
_begin_draw() _begin_draw()
@@ -1019,8 +1247,7 @@ func _begin_sheathe() -> void:
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true) _anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for # Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
func _advance_draw(delta: float) -> void: func _advance_draw(delta: float) -> void:
if not _drawing and not _sheathing: if not _drawing and not _sheathing:
return return
@@ -1049,9 +1276,7 @@ func _draw_speed() -> float:
return maxf(DP.f("mat_draw_speed"), 0.1) return maxf(DP.f("mat_draw_speed"), 0.1)
# Move the sword between the hand grip and the hip holster. Reparenting follows # Move the sword between hand grip and hip holster, reapplying the local seating.
# whichever bone the BoneAttachment tracks; the local grip transform is reapplied
# so the seating is identical in both sockets.
func _carry_sword(in_hand: bool) -> void: func _carry_sword(in_hand: bool) -> void:
if not is_instance_valid(_sword_node): if not is_instance_valid(_sword_node):
return return
@@ -1063,8 +1288,9 @@ func _carry_sword(in_hand: bool) -> void:
_sword_in_hand = in_hand _sword_in_hand = in_hand
_sword_node.reparent(target, false) _sword_node.reparent(target, false)
_apply_sword_grip() _apply_sword_grip()
if _sword_blade_area: # Carrying never arms the blade — only the ATTACK thrust does (see _tick_attack).
_sword_blade_area.monitoring = in_hand if _sword_blade_area != null:
_sword_blade_area.deactivate()
func _apply_sword_grip() -> void: func _apply_sword_grip() -> void:
+8 -9
View File
@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://matador_scene_v1"] [gd_scene format=3 uid="uid://d0te4mwbh33ag"]
[ext_resource type="Script" path="res://matador.gd" id="1_matgd"] [ext_resource type="Script" uid="uid://bvfa7xi0luysk" path="res://actors/matador/matador.gd" id="1_matgd"]
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://Assets/Matador.glb" id="2_matfbx"] [ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://art/characters/matador.glb" id="2_matfbx"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.25 radius = 0.25
@@ -11,19 +11,18 @@ height = 1.2
radius = 0.45 radius = 0.45
height = 1.4 height = 1.4
[node name="Matador" type="CharacterBody3D"] [node name="Matador" type="CharacterBody3D" unique_id=1781857630]
script = ExtResource("1_matgd") script = ExtResource("1_matgd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."] [node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=2101651042]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_body") shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="."] [node name="HitArea" type="Area3D" parent="." unique_id=745037545]
monitoring = true
monitorable = false monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea"] [node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=1497356275]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_hit") shape = SubResource("CapsuleShape3D_hit")
[node name="matador2" parent="." instance=ExtResource("2_matfbx")] [node name="matador2" parent="." unique_id=1487256381 instance=ExtResource("2_matfbx")]
@@ -4,6 +4,8 @@ extends RefCounted
## limited swing/twist (DP-tunable) so the corpse keeps its silhouette instead of ## limited swing/twist (DP-tunable) so the corpse keeps its silhouette instead of
## folding into a flat "deflated" pile; the cape stays free (PIN) so it drapes. ## folding into a flat "deflated" pile; the cape stays free (PIN) so it drapes.
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
# [bone, radius] → sphere collider; [bone, radius, height] → capsule. # [bone, radius] → sphere collider; [bone, radius, height] → capsule.
const _BONES: Array = [ const _BONES: Array = [
["matador", 0.18], ["matador", 0.18],
@@ -53,10 +55,15 @@ static func build(skeleton: Skeleton3D) -> PhysicalBoneSimulator3D:
pb.bone_name = bname pb.bone_name = bname
pb.mass = 0.4 pb.mass = 0.4
pb.linear_damp = 0.1 pb.linear_damp = 0.1
pb.collision_layer = 1 # The ragdoll only simulates once the matador is dead, so it lives on the CORPSE
pb.collision_mask = 1 # layer and masks to CORPSE alone: it rests on the ground/walls (which also carry
# CORPSE) but the bull — which collides only with WORLD — passes straight through
# the bodies instead of tripping over them.
pb.collision_layer = PhysicsLayers.CORPSE
pb.collision_mask = PhysicsLayers.CORPSE
if bname.begins_with("Bone"): if bname.begins_with("Bone"):
# Cape: floppy cloth — free swing, light, draggy, one-way collision. # Cape: floppy cloth — free swing, light, draggy, one-way collision (layer 0
# so nothing collides with it; it still drapes on the CORPSE-masked ground).
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.15 pb.mass = 0.15
pb.linear_damp = 0.5 pb.linear_damp = 0.5
+15
View File
@@ -0,0 +1,15 @@
[gd_scene format=3 uid="uid://c8spearwrap01"]
[ext_resource type="PackedScene" uid="uid://jepc74w8opbu" path="res://art/weapons/spear.glb" id="1_spearglb"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_shaft"]
radius = 0.13
height = 2.2
[node name="Spear" type="Node3D"]
[node name="spear" parent="." instance=ExtResource("1_spearglb")]
[node name="HitShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.79)
shape = SubResource("CapsuleShape3D_shaft")
+253
View File
@@ -0,0 +1,253 @@
class_name SpearProjectile
extends RigidBody3D
# A thrown spear. It flies a ballistic arc (gravity does the work — the launch
# velocity is solved by the matador to land on the bull) and keeps its shaft aimed
# along its own velocity, so it noses over at the apex and drives in point-first.
# On impact it embeds where it struck: the tip buried just past the contact point
# with the shaft trailing back along the flight line, so it reads as stuck-in rather
# than floating alongside. A hit on the bull leaves the spear riding it like a spine
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
# it in place until the despawn timer clears it.
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
const _DESPAWN_SEC: float = 8.0
const _STUCK_GROUP: StringName = &"stuck_spear"
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
const _TIP_Z: float = 1.915
const _EMBED_DEPTH: float = 0.72 # how far past the surface the tip buries
# Bone the bull's spears ride on. The collision spheres sit outside the hide, so a
# spear stuck to the body root reads as floating; anchoring to COG also lets it bob
# and rotate with the animated body instead of only the character root.
const _COG_BONE: String = "COG"
const _ANCHOR_NAME: String = "SpearAnchor"
var _mesh: Node3D = null
var _stopped: bool = false
var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction
# Build, seat and launch a spear from an already-instantiated mesh (the one that was
# in the matador's hand). `origin` is the release point; `velocity` is the full
# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc.
static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3,
gravity: float) -> SpearProjectile:
var s := SpearProjectile.new()
s.gravity_scale = gravity
s.collision_layer = 0
# Mask WORLD so a miss sticks in the arena, and BULL so it can actually hit the
# bull — the bull's body is on layer BULL, not WORLD, so a WORLD-only mask flew
# straight through it (every throw whiffed, even against a stationary bull).
s.collision_mask = PhysicsLayers.WORLD | PhysicsLayers.BULL
s.contact_monitor = true
s.max_contacts_reported = 6
parent.add_child(s)
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
s.global_transform = Transform3D(_aim_basis(velocity), origin)
# Take the editor-tweakable collider from the spear wrapper (Spear.tscn's HitShape)
# before reparenting, then drop the now-nested template.
var collider := _collider_from(mesh)
mesh.reparent(s, false)
mesh.transform = Transform3D.IDENTITY
s._mesh = mesh
var tmpl := mesh.get_node_or_null("HitShape")
if tmpl != null:
tmpl.queue_free()
s.add_child(collider)
s.linear_velocity = velocity
_despawn(s)
return s
# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear
# and clears itself on the despawn timer. Called by the bull's slam.
static func shed_all(bull: Node) -> void:
if not (bull is Node3D):
return
var scene: Node = bull.get_tree().current_scene
if scene == null:
scene = bull
var center: Vector3 = (bull as Node3D).global_position
# Stuck spears now live under a BoneAttachment3D deep in the skeleton, so walk the
# whole subtree by group rather than only the bull's direct children.
for node in bull.get_tree().get_nodes_in_group(_STUCK_GROUP):
if not (bull as Node).is_ancestor_of(node):
continue
var mesh := node as Node3D
var gx := mesh.global_transform
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
scene.add_child(rb)
rb.global_transform = gx
mesh.reparent(rb, false)
mesh.transform = Transform3D.IDENTITY
mesh.remove_from_group(_STUCK_GROUP)
rb.add_child(_shaft_collider())
# Fling outward from the bull with a bit of lift and spin.
var out := gx.origin - center
out.y = 0.0
out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5)
rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0)
rb.angular_velocity = Vector3(randf_range(-10.0, 10.0),
randf_range(-10.0, 10.0), randf_range(-10.0, 10.0))
_despawn(rb)
# Clone the wrapper's editor-tweakable HitShape (Spear.tscn) into a fresh CollisionShape3D
# for the projectile body; falls back to the built-in shaft collider if it's missing.
static func _collider_from(source: Node) -> CollisionShape3D:
var tmpl := source.get_node_or_null("HitShape") as CollisionShape3D
if tmpl != null and tmpl.shape != null:
var cs := CollisionShape3D.new()
cs.shape = tmpl.shape
cs.transform = tmpl.transform
return cs
return _shaft_collider()
static func _shaft_collider() -> CollisionShape3D:
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
# can't tunnel past the bull's collision spheres.
var cap := CapsuleShape3D.new()
cap.radius = 0.13
cap.height = 2.2
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.79)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
return cs
# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out
# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull
# moves; world-space particles (local_coords off) so they arc off and fall behind as a
# trail. Dies with the mesh when the spear is shed or despawned.
static func _wound_emitter() -> CPUParticles3D:
var ramp := Gradient.new()
ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0))
var sc := Curve.new()
sc.add_point(Vector2(0.0, 1.0))
sc.add_point(Vector2(0.6, 0.7))
sc.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
# Seat at the wound (where the shaft meets the hide) and spray back out along the
# shaft: mesh local -Z is the exit direction, rotated into world by the node basis.
p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH)
p.emitting = true
p.amount = 22
p.lifetime = 0.6
p.explosiveness = 0.0
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 0.55, -1.0).normalized()
p.spread = 32.0
p.gravity = Vector3(0.0, -12.0, 0.0)
p.initial_velocity_min = 1.6
p.initial_velocity_max = 4.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.4
p.mesh = _blood_mesh()
p.color_ramp = ramp
p.scale_amount_curve = sc
return p
# Small unshaded, vertex-coloured sphere for blood gobs.
static func _blood_mesh() -> SphereMesh:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var s := SphereMesh.new()
s.radius = 0.045
s.height = 0.09
s.radial_segments = 4
s.rings = 2
s.material = mat
return s
static func _despawn(node: Node) -> void:
node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void:
if is_instance_valid(node):
node.queue_free()
)
# Basis whose local +Z points along `v` (looking_at points -Z at its target).
static func _aim_basis(v: Vector3) -> Basis:
var dir := v.normalized()
if dir.length_squared() < 0.0001:
return Basis.IDENTITY
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
return Basis.looking_at(-dir, up)
# While flying, keep the shaft aligned with the velocity; on the first contact freeze
# and hand off to _embed so the spear sticks where it struck instead of bouncing off.
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
if _stopped:
return
if state.get_contact_count() > 0:
_stopped = true
# Use the remembered pre-impact heading — state.linear_velocity is already the
# bounced (post-collision) velocity by now, which would aim the shaft wrong.
var contact := state.get_contact_collider_position(0)
var other := state.get_contact_collider_object(0)
state.linear_velocity = Vector3.ZERO
state.angular_velocity = Vector3.ZERO
_embed.call_deferred(other, contact, _flight_dir)
return
var v := state.linear_velocity
if v.length_squared() > 0.04:
_flight_dir = v.normalized()
var t := state.transform
t.basis = _aim_basis(v)
state.transform = t
state.angular_velocity = Vector3.ZERO
# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the
# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides
# along as a spine; anything else pins the body in place for the despawn timer.
func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
var f := flight.normalized()
if f.length_squared() < 0.0001:
f = global_transform.basis.z.normalized()
var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z))
var bull := other as Node
if bull != null and bull.is_in_group(&"player"):
DamageEvent.deal(bull, DamageEvent.Cause.THROWN, contact, self)
if is_instance_valid(_mesh) and is_instance_valid(bull):
_mesh.reparent(_spear_anchor(bull), false)
_mesh.global_transform = pose
_mesh.add_to_group(_STUCK_GROUP)
if Settings.gore:
_mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged
queue_free()
return
freeze = true
global_transform = pose
# Node embedded spears ride on the bull: a BoneAttachment3D pinned to the COG bone so
# they follow the animated body (bob, turn) rather than only the character root. One
# anchor is shared by every spear; falls back to the bull root if there's no skeleton.
static func _spear_anchor(bull: Node) -> Node3D:
var existing := bull.find_child(_ANCHOR_NAME, true, false)
if existing is Node3D:
return existing as Node3D
var skel := bull.find_child("Skeleton3D", true, false) as Skeleton3D
if skel == null:
return bull as Node3D
var ba := BoneAttachment3D.new()
ba.name = _ANCHOR_NAME
skel.add_child(ba)
ba.bone_name = _COG_BONE
return ba
+1
View File
@@ -0,0 +1 @@
uid://dwjakh1is1jtj
+20
View File
@@ -0,0 +1,20 @@
[gd_scene format=3 uid="uid://c8swordwrap01"]
[ext_resource type="PackedScene" uid="uid://xyvank512l5n" path="res://art/weapons/sword.glb" id="1_swordglb"]
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="2_hitbox"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_blade"]
radius = 0.18
height = 1.3
[node name="Sword" type="Node3D"]
[node name="sword" parent="." instance=ExtResource("1_swordglb")]
[node name="BladeHitbox" type="Area3D" parent="."]
script = ExtResource("2_hitbox")
damage_cause = "gored"
[node name="CollisionShape3D" type="CollisionShape3D" parent="BladeHitbox"]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.8)
shape = SubResource("CapsuleShape3D_blade")
+110
View File
@@ -0,0 +1,110 @@
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
@@ -0,0 +1 @@
uid://sf5etma4kjt0
+26 -3
View File
@@ -1,3 +1,4 @@
class_name IsoCamera
extends Camera3D extends Camera3D
@export var height: float = 25.0 @export var height: float = 25.0
@@ -16,6 +17,11 @@ var _shake_amount: float = 0.0
var _shake_decay: float = 0.0 var _shake_decay: float = 0.0
var _user_fov: float = 0.0 var _user_fov: float = 0.0
# Per-level framing multiplier: 1.0 = the designer's base shot, >1 dollies the camera
# back for a wider view (the lobby uses this). Set by game_shell when a level loads;
# kept separate from the scroll-wheel FOV zoom so the two don't fight.
var _framing_scale: float = 1.0
# Trailing-camera state: _focus is the smoothed point the camera frames (it lags # Trailing-camera state: _focus is the smoothed point the camera frames (it lags
# the bull), _drag_offset is the velocity-driven trail that slides it behind. # the bull), _drag_offset is the velocity-driven trail that slides it behind.
var _focus: Vector3 = Vector3.ZERO var _focus: Vector3 = Vector3.ZERO
@@ -42,15 +48,30 @@ func _unhandled_input(event: InputEvent) -> void:
_user_fov = clamp(_user_fov * 1.1, fov_min, fov_max) _user_fov = clamp(_user_fov * 1.1, fov_min, fov_max)
# Pinch-to-zoom hook for the touch UI. `ratio` is the two-finger spread this frame over
# last frame: >1 (fingers parting) narrows the FOV to zoom in, <1 widens it to pull back,
# mirroring the scroll-wheel zoom_in/zoom_out feel.
func adjust_zoom(ratio: float) -> void:
if ratio <= 0.0:
return
_user_fov = clampf(_user_fov / ratio, fov_min, fov_max)
## Dolly the camera in/out for the level being loaded. 1.0 is the base framing; >1 pulls
## back for a wider shot (the lobby hub uses this). Independent of the scroll-wheel zoom.
func set_framing_scale(scale: float) -> void:
_framing_scale = maxf(scale, 0.1)
func _process(delta: float) -> void: func _process(delta: float) -> void:
fov = lerpf(fov, _user_fov, delta * 8.0) fov = lerpf(fov, _user_fov, delta * 8.0)
var angle_rad := deg_to_rad(horizontal_angle_deg) var angle_rad := deg_to_rad(horizontal_angle_deg)
var offset := Vector3( var offset := Vector3(
sin(angle_rad) * horizontal_distance, sin(angle_rad) * horizontal_distance * _framing_scale,
height, height * _framing_scale,
cos(angle_rad) * horizontal_distance cos(angle_rad) * horizontal_distance * _framing_scale
) )
var target := get_parent() as Node3D var target := get_parent() as Node3D
@@ -92,6 +113,8 @@ func _drag_focus(target: Node3D, delta: float) -> Vector3:
func trigger_hit(strength: float) -> void: func trigger_hit(strength: float) -> void:
if not Settings.screen_shake:
return
_shake_amount = strength * 0.5 _shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4 _shake_decay = _shake_amount / 0.4
+943
View File
@@ -0,0 +1,943 @@
extends CharacterBody3D
# Hoof positions in bull local space — dust emitter offsets
const HOOF_OFFSETS: Array = [
Vector3(-0.30, -0.25, -0.50),
Vector3( 0.30, -0.25, -0.50),
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
# looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK"
const _ANIM_SLAM: StringName = &"Armature|SLAM"
const _ANIM_DASH: StringName = &"Armature|DASH"
const _ANIM_WALK: StringName = &"Armature|WALK"
const _ANIM_ROLL: StringName = &"Armature|ROLL"
const _ANIM_DEATH: StringName = &"Armature|DEATH"
@onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull
var _hoof_emitters: Array[CPUParticles3D] = []
var _legs: Node
var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0
# Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL
# driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop.
var _unshaded_mat: StandardMaterial3D = null
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
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
var _ability_active: bool = false
var _ability_timer: float = 0.0
var _active_ability: int = -1
var _dash_dir: Vector3 = Vector3.ZERO
var _dash_speed_cur: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO
var _roll_ramp: float = 0.0
var _roll_spin: float = 0.0
var _roll_target: Node3D = null
var _roll_chains_left: int = 0
var _roll_hit: Array[Node] = []
var _bull_anim: AnimationPlayer = null
# Locomotion animation state: true while the looping DASH clip is driving WASD
# movement, false while idling. Flipping it cross-fades between the two clips.
var _locomotion_moving: bool = false
var _slam_pending: bool = false
var _charge_trail_emitter: CPUParticles3D = null
var _roll_spark_emitter: CPUParticles3D = null
var _cube_scale: Vector3 = Vector3.ONE
# One clean hit ends the run — see take_sword_hit(). The HUD listens for `died`
# to raise the lose screen (the `cause` tags how it happened, for the death notice);
# _dead latches so a flurry of hits fires it only once.
signal died(cause: String)
var _dead: bool = false
# HP system — the bull soaks several clean hits instead of dying to the first.
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
signal health_changed(current: int, max_hp: int)
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
signal hit_taken(cause: String)
var _max_hp: int = 10
var _hp: int = 10
# Bonus pips granted by lobby rewards (e.g. smashing every barrel). Tacked on top of the
# base bar; the HUD paints these top pips yellow instead of red.
var _bonus_hp: int = 0
var _hit_iframes: float = 0.0
var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0
enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE
# Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn).
# A charge sprint kicks up bigger, faster dust than a walk.
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")
var _hit_flash_tween: Tween
func _ready() -> void:
add_to_group(&"player")
# Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the
# 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled().
if Controls.rigid_skin_enabled():
RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp
# Carry any run-long bonus pips (barrel reward) into this level so they survive the
# scene reload between fights; the HUD paints these top pips yellow.
_bonus_hp = Run.bonus_hp
_max_hp += _bonus_hp
_hp += _bonus_hp
_cube_scale = cube_guy.scale
_setup_impact_fx()
_setup_hoof_dust()
_setup_legs()
_setup_charge_trail()
_setup_roll_sparks()
_setup_audio()
_bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle()
func _setup_legs() -> void:
_legs = Node.new()
_legs.set_script(load("res://actors/player/bull_legs.gd"))
add_child(_legs)
_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:
for offset: Vector3 in HOOF_OFFSETS:
var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D
if p == null:
continue
p.position = offset
_hoof_emitters.append(p)
func _setup_charge_trail() -> void:
_charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D
if _charge_trail_emitter != null:
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
func _setup_roll_sparks() -> void:
_roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D
if _roll_spark_emitter != null:
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
func _setup_audio() -> void:
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
_huff_player = AudioStreamPlayer.new()
_huff_player.stream = load("res://sounds/bull_huff.ogg")
_huff_player.volume_db = 0.0
_huff_player.bus = &"Effects"
add_child(_huff_player)
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
_crowd_player = AudioStreamPlayer.new()
var s := load("res://sounds/crowd_ambient.ogg") as AudioStreamOggVorbis
if s:
s.loop = true
_crowd_player.stream = s
_crowd_player.volume_db = -14.0
_crowd_player.autoplay = true
_crowd_player.bus = &"Ambient"
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:
if new_state == _dust_state:
return
_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:
p.scale_amount_min = scale.x
p.scale_amount_max = scale.y
p.initial_velocity_min = vel.x
p.initial_velocity_max = vel.y
p.emitting = new_state != DustState.NONE
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node as AnimationPlayer
for child: Node in node.get_children():
var r := _find_anim_player(child)
if r:
return r
return null
# IDLE ships one-shot; make it loop and start it so the bull breathes at rest
# instead of freezing on a bind pose. It's the base every ability blends back to.
func _setup_bull_idle() -> void:
if _bull_anim == null:
return
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
# locomotion cycle); the remaining ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
if _bull_anim.has_animation(clip):
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_play_idle()
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
# finishing ability clip eases back to idle instead of snapping.
func _play_idle() -> void:
_locomotion_moving = false
if _bull_anim == null:
return
_bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.05)
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
# on each transition so idle↔run (and steering into it after an ability) blend rather
# than snap. Abilities own the animation while active, so this yields to them.
func _update_locomotion_anim(moving: bool) -> void:
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
return
_locomotion_moving = moving
if moving:
if _bull_anim.has_animation(_ANIM_WALK):
_bull_anim.speed_scale = 2.0
_bull_anim.play(_ANIM_WALK, 1)
var anim = _bull_anim.get_animation(_ANIM_WALK)
anim.loop_mode = Animation.LOOP_LINEAR
else:
_play_idle()
func _unhandled_input(event: InputEvent) -> void:
if _dead:
return
# Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown.
if event.is_action_pressed(&"ability_kick"):
try_activate_ability(0)
elif event.is_action_pressed(&"ability_dash"):
try_activate_ability(1)
elif event.is_action_pressed(&"ability_slam"):
try_activate_ability(2)
elif event.is_action_pressed(&"ability_roll"):
try_activate_ability(3)
# Public ability entry point shared by keyboard/gamepad input and the on-screen
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
# No-ops if the bull is dead or the slot is still cooling down.
func try_activate_ability(slot: int) -> void:
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
return
match slot:
0: _activate_kick()
1: _activate_dash()
2: _activate_slam()
3: _activate_roll()
func ability_cooldown_fraction(slot: int) -> float:
var maxcd := 0.0
match slot:
0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("dash_cooldown")
2: maxcd = DP.f("slam_cooldown")
3: maxcd = DP.f("roll_cooldown")
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
func ability_cooldown_remaining(slot: int) -> float:
return ability_cd[slot]
func _activate_kick() -> void:
ability_cd[0] = DP.f("kick_cooldown")
_ability_active = true
_active_ability = 0
_ability_timer = 0.3
if _bull_anim:
_bull_anim.speed_scale = 2.0
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
_bull_anim.play(_ANIM_KICK)
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
var raw := cube_guy.global_transform.basis.z
var back := -Vector3(raw.x, 0.0, raw.z).normalized()
_spawn_kick_fx(back)
get_tree().create_timer(0.2).timeout.connect(
func() -> void: _hit_matadors_cone(DP.f("kick_range"), deg_to_rad(85.0), DP.f("kick_strength"), back)
)
func _activate_slam() -> void:
ability_cd[2] = DP.f("slam_cooldown")
_ability_active = true
_active_ability = 2
_legs.set(&"tail_ragdoll", true)
_slam_pending = true
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
var jump_vel := DP.f("slam_jump_velocity")
velocity.y = jump_vel
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01)
var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult))
_ability_timer = air_time + 0.3
if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM):
var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
_bull_anim.play(_ANIM_SLAM)
func _activate_dash() -> void:
ability_cd[1] = DP.f("dash_cooldown")
_ability_active = true
_active_ability = 1
_ability_timer = 0.45
if _bull_anim:
_bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
_bull_anim.play(_ANIM_DASH)
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
var raw := cube_guy.global_transform.basis.z
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
_dash_speed_cur = DP.f("dash_speed")
velocity.x = _dash_dir.x * _dash_speed_cur
velocity.z = _dash_dir.z * _dash_speed_cur
_spawn_dash_burst()
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
if _huff_player:
_huff_player.pitch_scale = randf_range(0.9, 1.1)
_huff_player.play()
func _activate_roll() -> void:
ability_cd[3] = DP.f("roll_cooldown")
_ability_active = true
_active_ability = 3
_ability_timer = DP.f("roll_duration")
_roll_ramp = 0.0
_roll_spin = 0.0
_roll_chains_left = int(DP.f("roll_chain_count"))
_roll_hit.clear()
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
# is empty, launch along the current heading (or facing when standing still).
_roll_target = _roll_pick_target()
var flat := Vector3(velocity.x, 0.0, velocity.z)
if _roll_target != null:
var to_t := _roll_target.global_position - global_position
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
elif flat.length() > 1.0:
_roll_dir = flat.normalized()
else:
var f := cube_guy.global_transform.basis.z
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
var launch := DP.f("roll_base_speed")
velocity.x = _roll_dir.x * launch
velocity.z = _roll_dir.z * launch
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = 1.0
_bull_anim.play(_ANIM_ROLL)
_legs.set(&"tail_ragdoll", true) # tail streams out behind
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
_legs.set(&"external_rotation", true) # roll owns the model's full orientation (tumble)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = true
_spawn_dash_burst()
camera_pivot.call(&"trigger_hit", 0.35)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.8, 1.0)
_huff_player.play()
# One rolling frame: home on the current target matador, accelerate the longer the
# roll lasts, move (walls just slide), then pop the ball on contact and chain to the
# next closest. Fully replaces the normal locomotion path while active. `steer` is
# ignored — the roll auto-targets — but kept so the caller stays uniform.
func _tick_roll(delta: float, _steer: Vector3) -> void:
# Reacquire if the target ragdolled or was freed before we reached it.
if not _roll_target_valid():
_roll_target = _roll_pick_target()
if _roll_target != null:
var to_t := _roll_target.global_position - global_position
var flat := Vector3(to_t.x, 0.0, to_t.z)
if flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized()
# Speed builds over time: base → max across roll_rampup_time, so a long roll
# winds up into a runaway boulder while a quick tap barely gets going.
_roll_ramp += delta
var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0)
var top := DP.f("roll_max_speed")
var speed := lerpf(DP.f("roll_base_speed"), top, ramp)
velocity.x = _roll_dir.x * speed
velocity.z = _roll_dir.z * speed
# Tumble the whole model like a Powerball: face the travel direction (yaw) then
# spin forward about the horizontal axis perpendicular to it, angular speed = v/r
# so it "rolls" without slipping. Legs yield rotation control while active.
var radius := maxf(DP.f("roll_spin_radius"), 0.1)
_roll_spin = wrapf(_roll_spin + (speed / radius) * delta, 0.0, TAU)
var facing := Basis(Vector3.UP, atan2(_roll_dir.x, _roll_dir.z))
var tumble_axis := Vector3.UP.cross(_roll_dir).normalized()
cube_guy.basis = (Basis(tumble_axis, _roll_spin) * facing).scaled(_cube_scale)
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
_set_dust_state(DustState.CHARGE)
if _charge_trail_emitter:
_charge_trail_emitter.emitting = true
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
move_and_slide()
_was_on_floor = is_on_floor()
_smash_barrels_in_radius(DP.f("roll_hit_radius"))
_roll_try_pop(speed)
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
# outward, then either CHAIN to the next closest (if chains remain and a target is
# left) or end the roll. Returns true if it popped so the caller can stop touching
# roll state this frame.
func _roll_try_pop(speed: float) -> bool:
var radius := DP.f("roll_hit_radius")
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if _roll_hit.has(mat) or not _mat_active(mat):
continue
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0
if to_mat.length() > radius:
continue
# Record everyone this pop reaches so the chain never re-picks a downed body.
for m: Node in get_tree().get_nodes_in_group(&"matador"):
var d: Vector3 = (m as Node3D).global_position - global_position
d.y = 0.0
if d.length() <= radius and not _roll_hit.has(m):
_roll_hit.append(m)
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play()
if _roll_chains_left > 0:
var next := _roll_pick_target()
if next != null:
_roll_chains_left -= 1
_roll_target = next
return true # keep rolling — the ball carries on to the next target
_end_roll()
return true
return false
# Closest thing the roll should home on — an active matador (arena) or an intact barrel
# (lobby) we haven't already smashed this roll. Null if nothing is left to hit.
func _roll_pick_target() -> Node3D:
var best: Node3D = null
var best_d := INF
for cand: Node3D in _roll_targets():
var d: float = cand.global_position.distance_squared_to(global_position)
if d < best_d:
best_d = d
best = cand
return best
# Live roll targets: active matadors plus standing barrels, minus anything already popped.
func _roll_targets() -> Array[Node3D]:
var out: Array[Node3D] = []
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if not _roll_hit.has(mat) and _mat_active(mat):
out.append(mat as Node3D)
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if _barrel_alive(barrel):
out.append(barrel as Node3D)
return out
func _roll_target_valid() -> bool:
if not is_instance_valid(_roll_target):
return false
if _roll_target.is_in_group(&"barrel"):
return _barrel_alive(_roll_target)
return _mat_active(_roll_target)
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
# is_active() == false, so a popped target is skipped on the next chain search.
func _mat_active(mat: Node) -> bool:
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
# A lobby barrel still standing — valid and not already queued for removal by an earlier hit.
func _barrel_alive(barrel: Node) -> bool:
return is_instance_valid(barrel) and not barrel.is_queued_for_deletion()
# Burst every barrel whose centre sits within range_m on the ground plane. Shared by the
# boulder roll (which ploughs straight through them) and the slam shockwave. Each barrel
# self-removes and leaves the &"barrel" group, so the HUD's clear-the-lobby tally counts it
# exactly once.
func _smash_barrels_in_radius(range_m: float) -> void:
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if not _barrel_alive(barrel):
continue
var to_b: Vector3 = (barrel as Node3D).global_position - global_position
to_b.y = 0.0
if to_b.length() <= range_m:
barrel.call(&"destroy_barrel")
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
# (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring.
func is_rolling() -> bool:
return _active_ability == 3 and _ability_active
# True while the dash ability is live. A dash is a committed lunge — it gores/rams on contact
# regardless of angle (enemies bypass their horns-first cone while it's set).
func is_dashing() -> bool:
return _active_ability == 1 and _ability_active
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to the normal
# locomotion path next frame.
func _end_roll() -> void:
_ability_active = false
_active_ability = -1
_ability_timer = 0.0
_roll_ramp = 0.0
_roll_target = null
_roll_chains_left = 0
_roll_hit.clear()
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_legs.set(&"external_rotation", false)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = false
if _charge_trail_emitter:
_charge_trail_emitter.emitting = false
velocity.x *= 0.15
velocity.z *= 0.15
_play_idle()
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
dir: Vector3 = Vector3.ZERO) -> void:
var raw := cube_guy.global_transform.basis.z
var forward := dir if dir.length_squared() > 0.01 else Vector3(raw.x, 0.0, raw.z).normalized()
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0
var dist := to_mat.length()
if dist > range_m:
continue
if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad):
continue
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0) -> void:
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0
var dist := to_mat.length()
if dist > range_m:
continue
var away := to_mat / dist if dist > 0.01 \
else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized()
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
# ── Blood-spurt particle helpers ──────────────────────────────────────────────
# The ability effects now live in systems/fx/; only the spear-wound blood below is still built in
# code. Shared material, built once so the mobile driver compiles this particle shader a single
# time (colour comes per-particle from the colour_ramp via vertex colour, not this material).
func _unshaded_material() -> StandardMaterial3D:
if _unshaded_mat == null:
_unshaded_mat = StandardMaterial3D.new()
_unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_unshaded_mat.vertex_color_use_as_albedo = true
_unshaded_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return _unshaded_mat
# Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload.
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var key := "%.4f:%d:%d" % [radius, segments, rings]
var cached: SphereMesh = _particle_sphere_cache.get(key)
if cached != null:
return cached
var sphere := SphereMesh.new()
sphere.radius = radius
sphere.height = radius * 2.0
sphere.radial_segments = segments
sphere.rings = rings
sphere.material = mat
_particle_sphere_cache[key] = sphere
return sphere
# Queue-free a transient FX node once its particles have finished.
func _free_after(node: Node, delay: float) -> void:
get_tree().create_timer(delay).timeout.connect(func() -> void:
if is_instance_valid(node):
node.queue_free()
)
func _spawn_slam_fx() -> void:
var origin := global_position + Vector3(0.0, -0.5, 0.0)
_impact_fx.call(&"slam_rings", origin, DP.f("slam_range"))
Fx.spawn(&"bull_slam", Transform3D(Basis(), origin))
camera_pivot.call(&"trigger_hit", 0.60)
func _spawn_kick_fx(back_dir: Vector3) -> void:
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir))
_impact_fx.call(&"kick_flash", origin)
camera_pivot.call(&"trigger_hit", 0.35)
func _spawn_dash_burst() -> void:
Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir))
# A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash
# effects bake their spray in local space, so aiming the spawn transform points the burst the
# right way. Falls back to an unrotated transform when `forward` has no horizontal component.
func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D:
var flat := Vector3(forward.x, 0.0, forward.z)
if flat.length_squared() < 0.0001:
return Transform3D(Basis(), origin)
return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP)
func _physics_process(delta: float) -> void:
# ── Dead bull ─────────────────────────────────────────────────────────────
# The run is over the instant HP hits zero, but the lose screen holds off a
# beat before it appears (see HUD._show_game_over). Freeze the carcass through
# that delay — no input, no abilities, just coast to a stop under gravity — so a
# dead bull can't keep charging around while the screen is pending.
if _dead:
velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta)
if not is_on_floor():
velocity += get_gravity() * delta
move_and_slide()
return
# ── Gravity ───────────────────────────────────────────────────────────────
if not is_on_floor():
var gravity := get_gravity()
# Crash a slam down hard: full leap on the way up, accelerated descent so
# the bull spends less time floating before the impact.
if _slam_pending and velocity.y < 0.0:
gravity *= DP.f("slam_fall_mult")
velocity += gravity * delta
# ── Camera-relative input direction ───────────────────────────────────────
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var cam_basis := camera_pivot.global_transform.basis
var flat_forward := -Vector3(cam_basis.z.x, 0.0, cam_basis.z.z).normalized()
var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized()
var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized()
var on_floor := is_on_floor()
# ── Ability cooldowns ────────────────────────────────────────────────────────
_hit_iframes = maxf(0.0, _hit_iframes - delta)
for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active:
_ability_timer -= delta
if _ability_timer <= 0.0:
if _active_ability == 3:
_end_roll() # tears down roll-specific state (sparks, tumble control)
else:
_ability_active = false
_active_ability = -1
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_slam_pending = false
_play_idle()
# ── Boulder roll ──────────────────────────────────────────────────────────
# While rolling, the ability owns movement entirely (its own accel, low friction
# and wall ricochet) — bypass the normal input/kick/damping path below.
if _active_ability == 3 and _ability_active:
_tick_roll(delta, direction)
return
_was_on_floor = on_floor
# ── Ground locomotion — smooth accelerate / friction ──────────────────────
# WASD/arrows drive the bull directly: hold a direction to ramp up to max_speed
# and steer, release to coast to a stop. Ability bursts (dash) and wall bounces
# can exceed max_speed — while a direction is held we steer that momentum without
# braking it (cap tracks the current speed); friction only bleeds the excess once
# input is released.
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
var max_speed := DP.f("max_speed")
if _active_ability == 1 and _ability_active:
# Dash: leave the barrel fast like a bullet, then bleed exponentially back
# down to cruise speed (fastest drop right at the start), so it settles into
# normal running by the time the clip ends instead of holding top speed.
_dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta))
flat_vel = _dash_dir * _dash_speed_cur
elif direction.length_squared() > 0.01:
var cap := maxf(max_speed, flat_vel.length())
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
else:
flat_vel = flat_vel.move_toward(Vector3.ZERO, DP.f("move_friction") * delta)
velocity.x = flat_vel.x
velocity.z = flat_vel.z
# ── Visual rotation follows velocity direction ─────────────────────────────
var flat_speed := Vector2(velocity.x, velocity.z).length()
if flat_speed > 0.4:
var target_angle := atan2(velocity.x, velocity.z)
cube_guy.rotation.y = lerp_angle(
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
# Looping WALK clip while genuinely moving on the ground, idle otherwise.
_update_locomotion_anim(on_floor and flat_speed > 1.0)
_pre_slide_vel_y = velocity.y
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide()
# ── Wall bounce — no sliding, guaranteed exit ────────────────────────────
# pre_wall_vel is captured before move_and_slide strips the into-wall component,
# so into_spd correctly reflects how hard the bull hit the wall even when stationary.
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if abs(normal.y) < 0.5:
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
var into_spd := -pre_wall_vel.dot(flat_n)
var exit_spd := maxf(into_spd * DP.f("wall_bounce_restitution"),
DP.f("wall_min_bounce_speed"))
velocity.x = flat_n.x * exit_spd
velocity.z = flat_n.z * exit_spd
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
# Ram straight through a barrel that formed the wall we bounced off.
var hit := col.get_collider()
if hit != null and hit.has_method(&"destroy_barrel"):
hit.call(&"destroy_barrel")
# ── Landing. Don't bounce ─────────────────────────────────────────────────────
if is_on_floor() and not _was_on_floor:
velocity.y = 0.0
# ── Slam landing ──────────────────────────────────────────────────────────
if _slam_pending and is_on_floor() and not _was_on_floor:
_slam_pending = false
_ability_active = false
_active_ability = -1
_ability_timer = 0.0
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_play_idle()
_spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
_smash_barrels_in_radius(DP.f("slam_range"))
# ── Dust ──────────────────────────────────────────────────────────────────
if flat_speed > DP.f("dust_charge_spd") and on_floor:
_set_dust_state(DustState.CHARGE)
elif flat_speed > DP.f("dust_walk_spd") and on_floor:
_set_dust_state(DustState.WALK)
else:
_set_dust_state(DustState.NONE)
# ── Charge trail direction ────────────────────────────────────────────────
if _dust_state == DustState.CHARGE and _charge_trail_emitter:
var vel_flat := Vector3(velocity.x, 0.0, velocity.z)
if vel_flat.length_squared() > 0.1:
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
func get_hp() -> int:
return _hp
func get_max_hp() -> int:
return _max_hp
# Bonus pips currently on the bar — the HUD colours this many top pips yellow.
func get_bonus_hp() -> int:
return _bonus_hp
# Award extra health (from a lobby reward): grows both the cap and the current bar so the
# new pips arrive full, and marks them as bonus so the HUD paints them yellow.
func grant_bonus_hp(amount: int) -> void:
if amount <= 0:
return
_bonus_hp += amount
_max_hp += amount
_hp += amount
health_changed.emit(_hp, _max_hp)
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
# not the whole bar; `_dead` latches so the lose screen is raised exactly once.
func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void:
if _dead or _hit_iframes > 0.0:
return
_hp -= 1
_hit_flash()
_hit_iframes = DP.f("bull_hit_iframes")
health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7)
# Vibrate controller 0 with half weak strength, full strong strength, for 0.2 seconds
Controls.vibrate(0, 0.5, 1, 0.2)
# A spear gores a spot — blood spurts from the entry wound (where it struck the
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
if cause == "thrown" and Settings.gore:
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.7, 0.9)
_huff_player.play()
if _hp <= 0:
if _bull_anim.current_animation != _ANIM_DEATH:
_bull_anim.speed_scale = 0.8
_bull_anim.play(_ANIM_DEATH)
_dead = true
died.emit(cause)
# Pop the bull upward — a smash hitbox knocks it off its feet. Only ever raises the
# vertical speed so a downward-moving bull still gets launched.
func apply_knock_up(v: float) -> void:
velocity.y = maxf(velocity.y, v)
# Typed entry point for the unified hit pipeline: a Hitbox (or any attacker) hands the bull a
# DamageEvent instead of calling take_sword_hit directly. A thin adapter over the existing pip /
# knock-up logic, so behaviour is unchanged while callers migrate off the stringly path.
func receive_damage(event: DamageEvent) -> void:
take_sword_hit(event.cause_name(), event.hit_position)
if event.knock_up > 0.0:
apply_knock_up(event.knock_up)
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
# entry point, biased outward from the bull's centre (so it reads as bursting from the
# hide) with a touch of lift before gravity drags it down.
func _spawn_blood(hit_pos: Vector3) -> void:
var out := hit_pos - global_position
out.y = 0.0
out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0)
var dir := (out + Vector3.UP * 0.8).normalized()
var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.28, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.7))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.9
p.amount = 34
p.lifetime = 0.7
p.randomness = 0.5
p.local_coords = false
p.direction = dir
p.spread = 42.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 3.5
p.initial_velocity_max = 8.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.6
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = hit_pos
p.restart()
_free_after(p, 1.5)
func _hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = _make_hit_overlay()
if _hit_flash_tween:
_hit_flash_tween.kill()
_hit_flash_tween = create_tween()
_hit_flash_tween.tween_interval(0.08)
_hit_flash_tween.tween_callback(_clear_hit_flash)
func _make_hit_overlay() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.03, 0.03, 1.0)
return mat
func _clear_hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = null
+52
View File
@@ -0,0 +1,52 @@
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://actors/player/player.gd" id="1_xhfnw"]
[ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://actors/player/camera_iso.gd" id="2_camera_iso"]
[ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://art/characters/bull.fbx" id="4_e80uo"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_kne1u"]
radius = 0.7495117
height = 1.845398
[node name="Player" type="CharacterBody3D" unique_id=165510044]
collision_layer = 2
collision_mask = 9
script = ExtResource("1_xhfnw")
[node name="CollisionBull" type="CollisionShape3D" parent="." unique_id=571780801]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.29183745, 0.3197594)
shape = SubResource("CapsuleShape3D_kne1u")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1057197468) index="0" unique_id=840295370]
bones/0/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1)
bones/1/position = Vector3(0.003526625, -0.0043296535, 0.0040827994)
bones/1/rotation = Quaternion(-0.00038647873, 0.06909673, 0.9868542, -0.14609687)
bones/2/rotation = Quaternion(-0.06929122, 3.2175163e-07, -0.112670615, 0.99121344)
bones/4/position = Vector3(0.0045358227, -0.002137695, -0.004238624)
bones/4/rotation = Quaternion(-0.24948503, 0.19590352, 0.9416978, -0.11217985)
bones/4/scale = Vector3(1.0000001, 0.9999999, 1.0000001)
bones/5/rotation = Quaternion(-0.58943665, -0.3660725, -0.22564478, 0.683842)
bones/5/scale = Vector3(1, 1.0000001, 0.99999994)
bones/6/rotation = Quaternion(0.52910197, 0.15997827, 0.120245, 0.8246207)
bones/6/scale = Vector3(1, 1, 1.0000001)
bones/17/position = Vector3(0, 0.0009373814, 0.007552617)
bones/17/rotation = Quaternion(-9.4156974e-08, 0.7071068, 0.7071067, -9.4156974e-08)
bones/19/position = Vector3(-0.003526625, -0.0043296535, 0.0040827994)
bones/19/rotation = Quaternion(0.00038647873, 0.06909673, 0.9868542, 0.14609687)
bones/20/rotation = Quaternion(-0.06929122, -3.2227587e-07, 0.11267061, 0.99121344)
bones/22/position = Vector3(-0.0045358227, -0.002137695, -0.004238624)
bones/22/rotation = Quaternion(0.24948503, 0.19590352, 0.9416978, 0.11217985)
bones/23/rotation = Quaternion(-0.58943665, 0.3660725, 0.22564478, 0.683842)
bones/23/scale = Vector3(1, 1.0000001, 1)
bones/24/rotation = Quaternion(0.529102, -0.15997836, -0.120245025, 0.82462066)
bones/24/scale = Vector3(1, 1.0000001, 1)
[editable path="bull"]
+14
View File
@@ -0,0 +1,14 @@
extends Node3D
var player: Node3D = null
func _process(delta: float) -> void:
# If we don't have the player yet, try to find them in the group
if not is_instance_valid(player):
var players = get_tree().get_nodes_in_group("player")
if players.size() > 0:
player = players[0] as Node3D
# If the player exists, follow their position
if is_instance_valid(player):
global_position = player.global_position
+1
View File
@@ -0,0 +1 @@
uid://cmapsaj010q40
+14
View File
@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://4v25hb0qu5l0"]
[ext_resource type="Script" uid="uid://cmapsaj010q40" path="res://actors/player/player_follow_light.gd" id="1_w7wrt"]
[node name="PlayerFollowLight" type="Node3D" unique_id=1889971494]
script = ExtResource("1_w7wrt")
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1523840927]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 2.6136062, 1.0431258e-07)
light_color = Color(0.9999998, 0.8702623, 0.60548127, 1)
light_energy = 8.0
shadow_enabled = true
distance_fade_length = 30.45
spot_angle = 72.62192
+1 -1
View File
@@ -8,5 +8,5 @@ script="rider-plugin.gd"
[presets] [presets]
active="off" active="on"
presets="presets.json" presets="presets.json"
-1
View File
@@ -1 +0,0 @@
uid://sbf260u32upu
@@ -4,12 +4,12 @@ importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://cmxqocsdbgufr" uid="uid://cmxqocsdbgufr"
path="res://.godot/imported/mees_plaksutab.glb-0ce4e4ee8c8f92069abfac9352f0f4b9.scn" path="res://.godot/imported/crowd_clap.glb-8bb3f532af711440142a67228d380ce0.scn"
[deps] [deps]
source_file="res://Animations/mees_plaksutab.glb" source_file="res://art/anim/crowd_clap.glb"
dest_files=["res://.godot/imported/mees_plaksutab.glb-0ce4e4ee8c8f92069abfac9352f0f4b9.scn"] dest_files=["res://.godot/imported/crowd_clap.glb-8bb3f532af711440142a67228d380ce0.scn"]
[params] [params]
Binary file not shown.
@@ -1,6 +1,6 @@
""" """
Blender 5.1 script: create walk + idle animations for matador_v02. Blender 5.1 script: create walk + idle animations for matador.
Run: blender --background matador_v02.blend --python create_matador_anims.py Run: blender --background matador.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_v02.fbx') fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador.fbx')
bpy.ops.export_scene.fbx( bpy.ops.export_scene.fbx(
filepath=fbx_path, filepath=fbx_path,
use_selection=False, use_selection=False,
@@ -1,8 +1,8 @@
""" """
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_v02.blend --python render_anim_preview.py Run: blender --background matador.blend --python render_anim_preview.py
Outputs to 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
idle_frame_00.png idle_frame_00.png
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://eu2lqjhowj4t" uid="uid://cofv5dbavesis"
path="res://.godot/imported/Matador.glb-84387af784b13addc4467fc8edace9ff.scn" path="res://.godot/imported/bear.glb-75dd9ad1d0d42d959c02624cfc22996d.scn"
[deps] [deps]
source_file="res://Assets/Matador.glb" source_file="res://art/characters/bear.glb"
dest_files=["res://.godot/imported/Matador.glb-84387af784b13addc4467fc8edace9ff.scn"] dest_files=["res://.godot/imported/bear.glb-75dd9ad1d0d42d959c02624cfc22996d.scn"]
[params] [params]
Binary file not shown.
@@ -4,12 +4,12 @@ importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://bun14xmk20pcj" uid="uid://bun14xmk20pcj"
path="res://.godot/imported/bull.fbx-50f6d03a92c862695bbeaeba563be0db.scn" path="res://.godot/imported/bull.fbx-ffca5aad6fa2fef7c6aaeff23784274e.scn"
[deps] [deps]
source_file="res://Assets/bull.fbx" source_file="res://art/characters/bull.fbx"
dest_files=["res://.godot/imported/bull.fbx-50f6d03a92c862695bbeaeba563be0db.scn"] dest_files=["res://.godot/imported/bull.fbx-ffca5aad6fa2fef7c6aaeff23784274e.scn"]
[params] [params]
@@ -28,7 +28,7 @@ meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
meshes/light_baking=1 meshes/light_baking=1
meshes/lightmap_texel_size=0.2 meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false meshes/force_disable_compression=true
skins/use_named_skins=true skins/use_named_skins=true
animation/import=true animation/import=true
animation/fps=30 animation/fps=30
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://eu2lqjhowj4t"
path="res://.godot/imported/matador.glb-d61955edf618ff60a81602b44843dd9c.scn"
[deps]
source_file="res://art/characters/matador.glb"
dest_files=["res://.godot/imported/matador.glb-d61955edf618ff60a81602b44843dd9c.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=true
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
@@ -3,12 +3,12 @@
importer="font_data_dynamic" importer="font_data_dynamic"
type="FontFile" type="FontFile"
uid="uid://fu84fchl1im6" uid="uid://fu84fchl1im6"
path="res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata" path="res://.godot/imported/Cinzel.ttf-95f91c70d492f60cef14788d0547dff4.fontdata"
[deps] [deps]
source_file="res://fonts/Cinzel.ttf" source_file="res://art/fonts/Cinzel.ttf"
dest_files=["res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata"] dest_files=["res://.godot/imported/Cinzel.ttf-95f91c70d492f60cef14788d0547dff4.fontdata"]
[params] [params]
@@ -3,12 +3,12 @@
importer="font_data_dynamic" importer="font_data_dynamic"
type="FontFile" type="FontFile"
uid="uid://rcfn6xhs7fur" uid="uid://rcfn6xhs7fur"
path="res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata" path="res://.godot/imported/CinzelDecorative-Bold.ttf-71cca077ba75a6c829f53e2548a2580d.fontdata"
[deps] [deps]
source_file="res://fonts/CinzelDecorative-Bold.ttf" source_file="res://art/fonts/CinzelDecorative-Bold.ttf"
dest_files=["res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata"] dest_files=["res://.godot/imported/CinzelDecorative-Bold.ttf-71cca077ba75a6c829f53e2548a2580d.fontdata"]
[params] [params]
View File
Binary file not shown.
@@ -3,13 +3,13 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://dxe72ig8gfbcg" uid="uid://bdbx0wwa73rhp"
path="res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn" path="res://.godot/imported/bear_fx.glb-ec36223896f7444a4dbe06fe70380d22.scn"
[deps] [deps]
source_file="res://Assets/arena model2026.glb" source_file="res://art/fx/bear_fx.glb"
dest_files=["res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn"] dest_files=["res://.godot/imported/bear_fx.glb-ec36223896f7444a4dbe06fe70380d22.scn"]
[params] [params]
@@ -3,16 +3,17 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://bxm7c6ldrdiq5" uid="uid://bxm7c6ldrdiq5"
path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex" path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-227da4c15c620e5b14eb76c27dcd326f.bptc.ctex"
path.astc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-227da4c15c620e5b14eb76c27dcd326f.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://HDRI/citrus_orchard_road_puresky_4k.hdr" source_file="res://art/hdri/citrus_orchard_road_puresky_4k.hdr"
dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"] dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-227da4c15c620e5b14eb76c27dcd326f.bptc.ctex", "res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-227da4c15c620e5b14eb76c27dcd326f.astc.ctex"]
[params] [params]
@@ -3,16 +3,17 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://3syhacswepyj" uid="uid://3syhacswepyj"
path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex" path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-8cc5d2aeb82da4bd42ed9b64393cbf3a.bptc.ctex"
path.astc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-8cc5d2aeb82da4bd42ed9b64393cbf3a.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://HDRI/kloppenheim_06_puresky_4k.hdr" source_file="res://art/hdri/kloppenheim_06_puresky_4k.hdr"
dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"] dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-8cc5d2aeb82da4bd42ed9b64393cbf3a.bptc.ctex", "res://.godot/imported/kloppenheim_06_puresky_4k.hdr-8cc5d2aeb82da4bd42ed9b64393cbf3a.astc.ctex"]
[params] [params]
View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

@@ -3,15 +3,15 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://cyscxku0nw055" uid="uid://cyscxku0nw055"
path="res://.godot/imported/DASH.png-02cbc39ec03a0c79526d7dc333659c48.ctex" path="res://.godot/imported/dash.png-f29ea607d0f55bfdf36de8ed38bf1a09.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/DASH.png" source_file="res://art/icons/dash.png"
dest_files=["res://.godot/imported/DASH.png-02cbc39ec03a0c79526d7dc333659c48.ctex"] dest_files=["res://.godot/imported/dash.png-f29ea607d0f55bfdf36de8ed38bf1a09.ctex"]
[params] [params]
View File

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

@@ -3,15 +3,15 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://bdnc3jw1ykydi" uid="uid://bdnc3jw1ykydi"
path="res://.godot/imported/KICK.png-5482be80e8176ed7a0ae69a48ca1f5ed.ctex" path="res://.godot/imported/kick.png-8a80f588c63b84c72b33cb0d47b50412.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/KICK.png" source_file="res://art/icons/kick.png"
dest_files=["res://.godot/imported/KICK.png-5482be80e8176ed7a0ae69a48ca1f5ed.ctex"] dest_files=["res://.godot/imported/kick.png-8a80f588c63b84c72b33cb0d47b50412.ctex"]
[params] [params]
View File

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

@@ -3,15 +3,15 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://d25i88cixy4pn" uid="uid://d25i88cixy4pn"
path="res://.godot/imported/ROLL.png-306dafcb4b6c13eded9a925aa6d2f62a.ctex" path="res://.godot/imported/roll.png-dc2c913f1361acd63205c54ed9ce4acf.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/ROLL.png" source_file="res://art/icons/roll.png"
dest_files=["res://.godot/imported/ROLL.png-306dafcb4b6c13eded9a925aa6d2f62a.ctex"] dest_files=["res://.godot/imported/roll.png-dc2c913f1361acd63205c54ed9ce4acf.ctex"]
[params] [params]

Some files were not shown because too many files have changed in this diff Show More