182 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:38 +03:00
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
richard 271de1b045 ps1 filter variables, crowds 2026-07-30 15:12:22 +03:00
richard 3defc9da95 UI theme 2026-07-30 13:52:50 +03:00
richard ff75dacfb6 New screens, new hud, new intro menu, some UI element changes 2026-07-30 13:52:39 +03:00
richard bf7a454ad0 Win, lose screen. Game version info. Matador a lot more aggressive/wins easier (tweaks needed). No more score/combo/waves 2026-07-30 12:39:51 +03:00
richard ebb6ec6335 Bull roll ability, console screen, tests 2026-07-28 23:10:38 +03:00
siim a0b248748c hud ja camera change 2026-07-24 15:16:21 +03:00
siim 5ef5889979 siim push 2026-07-24 14:51:57 +03:00
siim 2e9d62b964 bull uptade 2026-07-24 10:02:56 +03:00
richard 622830a6ee Hide controls by default 2026-07-22 23:00:45 +03:00
richard 603a674e08 menu interactive, refactor, perf tests, charge bar, debug menu stuff 2026-07-22 22:57:02 +03:00
siim 631df017d1 seq uptate 2026-07-22 22:25:14 +03:00
siim 2c0142d208 + intro sequence 2026-07-22 22:00:22 +03:00
richard c283eb0927 Menu, options with changeable controls, fade transition, placeholder background 2026-07-22 19:45:49 +03:00
siim d379b215e2 intro test 2026-07-21 23:36:41 +03:00
siim cd73a21ab7 intro menu alustasin 2026-07-20 22:40:50 +03:00
richard 6a67bfea36 better ragdoll, launch ragdolls on slam weee 2026-06-25 00:17:12 +03:00
richard b929ed2a7a cooldowns, sword holstering, ability colors 2026-06-25 00:10:40 +03:00
richard 708a35d51f return of the bull 2026-06-24 23:13:14 +03:00
richard 115872b98f sword holster, health 2026-06-24 23:11:19 +03:00
siim 64124990a6 +Sword 2026-06-20 10:58:35 +03:00
siim 037bfad700 matador 2026-06-20 10:53:21 +03:00
siim 661b4e470b gdignore 2026-06-16 18:30:05 +03:00
siim b564cd5a71 sword keset platsi 2026-06-14 23:13:52 +03:00
siim 7b5fcb1d1e +sword 2026-06-14 23:09:18 +03:00
siim 45e69101b6 gamepad buttons 2026-06-11 20:51:59 +03:00
siim a9810c8c9f dash 2026-06-10 22:50:07 +03:00
richard ba20cd8580 remove bounce after slam 2026-06-10 22:42:12 +03:00
richard aac0099faa Merge remote-tracking branch 'origin/main' 2026-06-10 22:36:53 +03:00
richard 4fc64dedae dash speed 3x, slam visual/damage fix 2026-06-10 22:36:47 +03:00
siim 7423665c06 dash*3 2026-06-10 22:07:11 +03:00
richard 09c39e068b Ability refinement, more and more matadors spawn after clears 2026-06-10 21:53:57 +03:00
richard c1852e8736 abilities first draft 2026-06-10 19:39:35 +03:00
richard dda9862589 fix bad references 2026-06-10 19:23:34 +03:00
richard d538679697 Merge remote-tracking branch 'origin/main' 2026-06-10 19:07:35 +03:00
siim e668895c12 bull anim 2026-06-10 18:56:17 +03:00
siim 064f07ace7 hud_pics 2026-06-10 01:23:20 +03:00
siim 9d0799083c dof update 2026-06-08 03:05:11 +03:00
siim 2bea615307 DOF test 2026-06-08 02:50:04 +03:00
siim a61f4c4689 koristasin selle lehvitaja ära 2026-06-07 19:48:33 +03:00
richard f6355605cd smarter AI 2026-06-07 17:30:58 +03:00
siim 033d26ee18 blend1 test 2026-06-07 16:58:52 +03:00
richard d3315836c3 delete autosaves 2026-06-07 16:56:20 +03:00
richard 3279645ac4 gitignore remove blender autosave 2026-06-07 16:55:50 +03:00
richard 371a2fded0 remove blender autosaves from git history 2026-06-07 16:54:15 +03:00
richard 49a9bced4b matador animationingng 2026-06-07 16:47:18 +03:00
siim f0d932eac3 testing 2026-06-06 12:08:56 +03:00
siim ca1a0ce581 - 2026-06-04 01:12:09 +03:00
siim 5ad4f64474 matador 2026-06-04 00:10:13 +03:00
siim 48cac53fc6 Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-06-04 00:07:22 +03:00
siim 5c489318a9 blender files 2026-06-04 00:07:15 +03:00
richard 1f24a750fb sounds, deploy 2026-05-29 16:41:16 +03:00
richard 33ec51625c scanlines, sounds, bumper walls, score keep, control legend, timer 2026-05-29 16:37:26 +03:00
richard 2e2b70e030 web deployment, fix stuff so works on linux too (cached non commited stuff) 2026-05-28 22:08:17 +03:00
siim 04597cdc01 publik speed offset 2026-05-26 08:42:18 +03:00
siim c407513cbd more publik 2026-05-26 01:23:49 +03:00
siim ea065e4d81 added publikum 2026-05-26 01:20:19 +03:00
siim 0e654ec0b6 publik_anim 2026-05-25 22:32:01 +03:00
siim df27e9d807 controller inputs 2026-05-25 21:49:34 +03:00
richard 866cc12c2c When charging with both buttons, see red 2026-05-25 21:08:43 +03:00
richard d98a5ba0ac Hit effect 2026-05-25 20:56:08 +03:00
richard 6abf5355c2 tests & can charge up thrust 2026-05-25 20:51:48 +03:00
richard bfb026c7ae giitignore 2026-05-25 20:51:02 +03:00
richard 245ce31eab missing ps1 gdshader 2026-05-25 20:42:24 +03:00
richard b30fb6e0a6 hold right+left, it charges ahead 2026-05-25 20:38:38 +03:00
richard 950d93ed0e PS1 filter 2026-05-25 20:36:39 +03:00
richard 85b9c86164 isometric cam 2026-05-25 20:22:43 +03:00
richard 174b14fa61 R to reset matador 2026-05-25 20:10:16 +03:00
richard 30136a9b90 bull legs not clipped, arena not scaled 2026-05-25 19:39:02 +03:00
richard 80a35a4553 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	Blender/matador_v02.blend
2026-05-25 18:37:54 +03:00
richard 98d579f733 tail wag works 2026-05-25 18:37:42 +03:00
siim f19e6980cd Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-05-20 21:16:42 +03:00
siim ab270ac04f mat blend 2026-05-20 21:16:35 +03:00
richard 22ed4f7722 missing files 2026-05-20 21:16:17 +03:00
richard af0790a1ce matadors moving, thrusty bull, slop 2026-05-20 21:07:09 +03:00
siim a8b9f166a6 matador_fbx 2026-05-20 20:43:50 +03:00
siim de5b5f3b7c blender matador 2026-05-20 20:33:31 +03:00
siim 13b027d91c matatod_mudeling 2026-05-20 00:17:49 +03:00
siim d6fe527906 + blenderfiles 2026-05-19 23:36:31 +03:00
richard 6549240337 bouncy bull 2026-05-15 22:10:58 +03:00
richard fe5d143047 Debug menu F1 (use ctrl to lock cam). Design.md doc 2026-05-10 18:40:14 +03:00
siim dc40057fe1 ragdoll skeleton 2026-05-10 17:32:56 +03:00
siim cb3a54c1cc Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-05-10 17:15:17 +03:00
siim 34b3866a5e nimi ja dash 2026-05-10 17:15:11 +03:00
richard 4f1126563c dust particles for hooves 2026-05-10 17:01:18 +03:00
richard c587b9877f charge shiftiga, suure hooga ei peatu kohe, loomad ei keera snappily 2026-05-09 23:07:02 +03:00
richard a809ed6cc4 pull liigub õiges suunas 2026-05-09 21:52:11 +03:00
siim 9675ae884d lisasin pulli ja areeni 2026-05-09 18:09:23 +03:00
richard a318e65e70 local config xmls 2026-05-09 17:56:43 +03:00
richard 8b3a816203 Restructure 2026-05-09 17:55:47 +03:00
richard 4e7bc29507 vibe code and restructure 2026-05-09 17:55:07 +03:00
siim b7c50eaae2 uuesti 2026-05-09 17:23:14 +03:00
siim 4fa965725d + tutoriali link 2026-05-09 17:19:01 +03:00
siim f4d61b7f0a importisin 3rd per cam projecti 2026-05-09 17:16:49 +03:00
richard adbd3859de clean slate 2026-05-09 17:05:59 +03:00
siim fb6ac05890 lahednsasn merge konflikti 2026-05-09 17:04:14 +03:00
siim 22d8e5df8c woh 2026-05-09 16:57:13 +03:00
353 changed files with 23761 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8
+5
View File
@@ -0,0 +1,5 @@
disable:
- class-definitions-order
- no-else-return
max-line-length: 120
max-file-lines: 3000
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+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
+25
View File
@@ -0,0 +1,25 @@
# Godot 4+ specific ignores
.godot/
/android/
# Godot editor autosave scratch files (e.g. Bear.tscn12015901897.tmp)
*.tmp
# JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user
.idea/
# Blender render previews (generated by render_anim_preview.py)
art/blender/preview/
# Blender auto-save backups (.blend1, .blend2, … — Blender rewrites these on every save)
art/blender/*.blend[0-9]
# Test output (generated screenshots, frame strips)
tests/output/
# Web export output
build/
#PRISM
BullPrism/
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.Bullosseum.iml
/projectSettingsUpdater.xml
/contentModel.xml
/modules.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+199
View File
@@ -0,0 +1,199 @@
# Bullosseum — CLAUDE.md
## About this project
A 3D arena game built in Godot 4.7 (Forward Plus renderer, Jolt Physics) where the player controls a bull. Currently features third-person camera, bull movement with charge mechanics, and a placeholder arena.
## AI role
I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scene architecture, physics, and performance. If a question suggests a suboptimal approach — wrong node type, unnecessary complexity, a pattern that fights the engine — I will say so and explain the better alternative before implementing anything.
## 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 |
|---|---|
| `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 |
| `levels/` | The level system: per-level module scenes plus the code that defines, loads and sequences them (see rows below) |
| `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_follow.gd` | Smooth camera follow (`Camera3D`) |
| `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) |
| `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 |
| `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` |
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
| `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads |
| `levels/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) |
| `addons/rider-plugin/` | JetBrains Rider IDE integration |
## Tech choices
- **Physics:** Jolt Physics (not the default Godot Physics)
- **Renderer:** Forward Plus
- **Input map:** WASD + arrow keys, Shift = charge, Space = jump, scroll = zoom, middle-click = toggle mouse capture
- **IDE:** JetBrains Rider via the rider-plugin addon
## GDScript conventions
- Typed GDScript everywhere (`var x: float`, return types on functions)
- `@onready` for node references; never `get_node()` strings when avoidable
- Constants in `SCREAMING_SNAKE_CASE`, variables in `snake_case`
- One script per scene root — keep scripts focused
- Signal names in `snake_case`; connect via `signal.connect()` not the legacy string form
- Prefer `_physics_process` for physics/movement, `_process` for visuals/camera, `_unhandled_input` for input that shouldn't bubble
- No comments that restate what the code already says; only comment non-obvious constraints or workarounds
## Best practices to enforce
- Use `CharacterBody3D` for player-controlled characters, not `RigidBody3D` (unless the design specifically needs physics simulation)
- Use `SpringArm3D` for third-person cameras to get free collision avoidance
- Prefer `move_and_slide()` with `velocity` over manual collision queries
- Export variables (`@export`) for any value a designer might tune; keep magic numbers out of logic
- Scene composition over inheritance — build behaviour from small focused scenes
- Use `autoload` (singletons) sparingly: only for truly global state (e.g. GameManager, AudioBus); not as a shortcut for passing data
- Keep `_physics_process` deterministic and frame-rate independent (always multiply by `delta`)
- Prefer signals over direct node references for decoupling
## Animation & bone conventions
### 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`
- **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).
- **Walk cycle:** 30 frames at 30 fps = 1 second loop. Legs swing ±25° X, knees bend 0–30° X (only when leg is back), arms swing ±15° X opposite phase to legs, arms lowered 55° Y from T-pose, elbows constant 25° X bend.
- **Idle pose:** Arms lowered 55° Y, elbows 25° X, everything else at rest.
### FBX export settings
- `primary_bone_axis = 'Y'`, `secondary_bone_axis = 'X'`
- `apply_scale_options = 'FBX_SCALE_ALL'`
- `add_leaf_bones = False`
- `bake_anim_use_all_actions = True`, `bake_anim_use_nla_strips = False`
### Godot animation names
After FBX import, animations appear as `Armature|walk` and `Armature|idle` in AnimationPlayer.
### Verification workflow
1. Edit bones/animations in Blender (`create_matador_anims.py`)
2. Run render preview: `blender --background art/blender/matador.blend --python art/blender/render_anim_preview.py`
3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot
4. Export FBX (done automatically by `create_matador_anims.py`)
5. Re-import in Godot and test in-game
## Running tests
```bash
# All tests (lint + logic + gameplay assertions):
bash run_tests.sh
# Individual tests:
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/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/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/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):
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
# Output: tests/output/gameplay/motion_00..04.png, ragdoll_trigger.png, ragdoll_result.png
# Difficulty simulation (balance tool, ~2 min):
godot --headless --script tests/difficulty_sim.gd # drives a bot bull vs one matador
```
### Difficulty simulation (`tests/difficulty_sim.gd`)
A balance tool, not a pass/fail test. It drives a kinematic "bot bull" against one
real matador across behaviour profiles (CHARGER / KITER / PASSIVE) and reports the
matador's win rate (how often it gores the bull). Use it to tune matador difficulty
from data. The bot bull has NO abilities (it only kills by ramming), so it can't use
roll/slam/kick — real skilled play beats the matador more than these numbers imply;
read the rates as "how well the matador threatens each movement pattern." A rough
target is CHARGER high (blind charging is punished), KITER moderate (throws are a
dodgeable threat), PASSIVE high (standing still dies). Note: as a `--script` entry it
reads the `DP` autoload via `/root/DP`, not the global identifier.
### What the gameplay test catches
| Check | What it detects |
|---|---|
| Tail Verlet chain spread | Tail bunching — consecutive nodes collapsed to same position |
| Ragdoll bone positions finite + within 15 m | Matador limbs exploding after ragdoll impulse |
| Ragdoll Y position > −10 | Bones falling through the floor |
| Frame strip (visual, manual) | Leg IK quality, animation glitches, anything that looks wrong in motion |
Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips.
### What the performance test catches
Loads the full scene with `STRESS_MATADORS` (20) matadors, samples per-frame time
during normal play and during a mass-ragdoll spike, and fails if the average
exceeds 16 ms or any single frame exceeds 100 ms. Budgets are generous regression
guards (not a target frame rate); the measured avg/peak/fps print every run so a
gradual creep shows up before it trips the ceiling.
## Godot 4.x specifics
- `wrapf` / `wrap` instead of manual modulo for angles
- `lerp_angle` for smooth rotation interpolation (handles wrap-around correctly)
- `move_toward` for speed ramps without overshooting
- `PackedStringArray`, `PackedVector3Array`, etc. for performance-sensitive arrays
- `@tool` scripts for editor helpers only — don't use in gameplay scripts
- Resource (`extends Resource`) for shared data/config; no plain `Dictionary` for structured data
- `StringName` (`&"action_name"`) for input action lookups in hot paths
+205
View File
@@ -0,0 +1,205 @@
# Bullosseum — Game Design Document
> Living document. `?` marks open questions.
---
## Elevator Pitch
You are the bull. Waves of cowboys, matadors, and gladiators pour into your arena — each with their own tricks to dodge or counter your charge. Survive, wreck things, pick upgrades. Every run is different. Goofy low-poly mayhem.
---
## Art Direction
- **Style:** Low-poly, chunky geometry, flat or minimal shading
- **Tone:** Goofy and physical — think *Gang Beasts* meets *Vampire Survivors* energy
- **Camera:** Third-person, spring-arm collision avoidance
- **Palette:** Warm sand/terracotta arena, vivid faction colours (red/gold for matadors, brown/blue for cowboys, iron/purple for gladiators)
- **Particles:** Stylised cartoon puffs — dust, impact sparks, ragdoll stars
- **Audio direction:** ?
---
## Confirmed Mechanics
- Third-person bull locomotion with momentum (walk, charge)
- Wide committed turning arc during charge — charges feel like commitments
- Visual turn lag (body leans)
- Spring-arm camera with zoom
- Sand dust particles at hooves (walk = small, charge = big)
- Jolt Physics backend
---
## Run Structure (Vampire Survivors-inspired)
```
Start run → Wave spawns → Kill enemies → Wave cleared → Pick upgrade → Next wave → ... → Die or achieve target
```
- **No hard time limit per wave** — pressure comes from enemy density escalating
- **Upgrade picks between waves** — choose 1 of 3 options (stat, ability, passive synergy)
- **Fail state is soft** — getting overwhelmed is a death spiral, not a timeout. You stop being able to charge effectively and get cornered
- **Run length:** ? (15–25 minutes for a full run feels right for web)
- **Meta progression:** ? Unlock new starting abilities or bull cosmetics across runs
### Wave Escalation — Faction Ramp
Factions are introduced one at a time so each mechanic gets space to teach itself before mixing begins.
| Waves | Faction makeup | Boss |
|---|---|---|
| 1–3 | Matadors only | — |
| 4 | Matadors only | El Presidente |
| 5–7 | Matadors + Cowboys introduced | — |
| 8 | Mixed (matadors + cowboys) | Sheriff |
| 9–11 | All three factions introduced | — |
| 12 | Mixed (all three) | Centurion |
| 13+ | Full chaos — all factions, escalating density | Repeat bosses + ? final boss |
- Each new faction's first wave is a "pure" wave so the player can learn the pattern before it mixes
- Bosses appear solo — the arena clears before a boss spawns, making it feel like a duel
- ? After wave 12, does a loop begin (harder repeat) or does a final boss end the run?
---
## Enemy Factions
### Matadors (the classics)
The trickiest faction — they're designed around baiting your charge.
| Enemy | Behaviour |
|---|---|
| Matador | Waves red cape to draw your charge, sidesteps at last moment. Must catch them off-guard or predict the dodge |
| Banderillero | Plants slow-flags in the ground — charges into one stuns the bull briefly |
| Picador | Mounted on horse, tanky, slow. Horse must be hit first before the rider is vulnerable |
| ? El Presidente (boss) | Summons matadors and teleports around the arena in bursts |
### Cowboys
The herd faction — individually easy, dangerous in groups.
| Enemy | Behaviour |
|---|---|
| Cowboy | Throws lasso to root you for 1–2s, then runs. Easy to hit, just annoying |
| Wrangler | Herds other enemies toward you — a force multiplier |
| Mounted Cowboy | Circles you on horseback, throwing dynamite. Must cut off the riding arc |
| ? Sheriff (boss) | Shotgun blast that staggers the bull; spawns deputies on death |
### Gladiators
The armour faction — charge angles matter.
| Enemy | Behaviour |
|---|---|
| Legionary | Front shield absorbs charges completely. Must hit from the side or behind |
| Net-thrower | Long-range root that holds you in place for 3s |
| Archer | Stationary on elevated platform (arena wall edge), fires slowing arrows |
| ? Centurion (boss) | Buffs nearby gladiators, has a charge-reflect shield that bounces you back |
---
## Ability System
Three slots total — always the same structure, filled differently every run.
```
[Charge ──────────] [Medium □] [Ultimate □]
always on 1 slot 1 slot
```
### Slot 1 — Charge (always on)
| | Description |
|---|---|
| **Charge** | Hold to build, release to sprint. Committed arc, hard to steer. The foundation everything else is built around |
### Slot 2 — Medium Ability
Cooldown ~8–15s. Tactical, reactive, fills gaps that charge can't. One equipped at a time.
| Ability | Description | Good against |
|---|---|---|
| **Stomp** | Slam front hooves — short-range AoE stun | Matador clusters, anything close |
| **Gore** | Upward headbutt — launches a single target | Shielded gladiators (bypasses front shield) |
| **Tail Whip** | 360° sweep behind and to the sides | Cowboys lassooing from behind |
| **Bull Roar** | Knockback shockwave — no damage, pure space creation | Net-throwers, tight groups |
| **Ground Pound** | From a jump, landing creates a shockwave crater | Mounted enemies (dismounts them) |
| **Counter** | Narrow window — if you charge as an attack lands, explosive ricochet | Picadors, centurion reflect |
### Slot 3 — Ultimate Ability
Cooldown ~35–50s, or requires a full charge meter. High impact. One equipped at a time.
| Ability | Description | Good against |
|---|---|---|
| **Stampede** | ~5s of zero deceleration — can't stop or steer well, flattens everything in the path | Boss phases, dense waves |
| **Enrage** | ~8s berserk: charge speed +60%, hitbox wider, ignores all slows and roots | Lasso/net-heavy waves |
| **Red Mist** | Every enemy on screen gets a red target marker and is pulled slightly toward you — brief guaranteed hits | Matador dodge phase |
| **Thunder Horn** | Next charge is electrified — chains damage to up to 4 nearby enemies on contact | Gladiator formations |
| **Call of the Herd** | Summon 3 ghost bulls that charge in parallel alongside you for 6s | Any — pure chaos |
### Card Pick Screen (between every wave)
Game pauses. Three cards shown — pick one.
| Card type | Examples |
|---|---|
| **New medium** | Swap current medium for a different one |
| **New ultimate** | Swap current ultimate for a different one |
| **Stat boost** | +charge speed, +turn rate, −medium cooldown, −ultimate cooldown |
| **Passive modifier** | Changes how your *current* ability behaves (see below) |
| **Synergy** | Unlocks a cross-ability combo effect |
**Passive modifier examples:**
- Stomp → "Dust Blind" — stun cloud also blocks enemy vision for 2s
- Gore → "Pinball" — launched enemy bounces off walls and damages others on landing
- Stampede → "Aftershock" — leaves a trembling ground trail that slows enemies for 5s
- Enrage → "Bloodlust" — kills during enrage reduce remaining cooldown
**Synergy examples:**
- Charge + Stomp = "Seismic Run" — charging for 2s+ turns your next stomp into a ground wave
- Gore + Thunder Horn = "Lightning Rod" — electrified gore sends a chain to the whole group
- ? Others to discover during design
---
## Arena Design
Each faction maps to an arena environment. Mixed-faction waves could transition the arena or blend themes.
| Faction | Arena | Hazards |
|---|---|---|
| Matadors | Sand bullring, wooden barrier ring | Flags slow movement, open centre is the danger zone |
| Cowboys | Frontier rodeo grounds, hay bales, fences | Lasso posts (rope traps), horse stampede lanes |
| Gladiators | Roman colosseum, stone floor, iron gates | Archer towers on walls, spike traps, wet stone (slide) |
- ? Does the arena change mid-run or is one arena selected at run start?
- ? Destructible fence/barrier sections throughout?
---
## Core Loop Revisited
```
Move → Spot threat pattern → Position approach angle → Charge/Ability → Chain hits → Wave clear
↑ ↓
Dodge/react to debuffs (lasso, net, slow-flag) Pick upgrade (1 of 3)
```
The **positioning** step is where skill expression lives: which enemy to hit first, what angle to approach a shielded gladiator, whether to charge the matador or fake him out.
---
## Open Questions
1. **Meta progression** — cosmetic unlocks only, or do you unlock new starting medium/ultimate options across runs?
2. **Arena change** — single arena per run, or does the arena theme shift when a new faction is introduced mid-run?
3. **After wave 12** — infinite loop with scaling difficulty, or a final boss that ends the run cleanly?
4. **Ability replacement** — when you pick a new medium/ultimate card, does it always replace the slot, or can you upgrade an existing ability through repeated picks (level 1→2→3)?
5. **Web scope** — vertical slice (matadors only, 4 waves, one ability tree) or full 12-wave game?
---
## References
- *Vampire Survivors* — wave escalation, upgrade pick loop, run length
- *Gang Beasts* — ragdoll slapstick commitment
- *Katamari* / *Donut County* — simple core verb scaled through variety
- *Superhot* — charge-as-commitment, every move mattering
- *Cult of the Lamb* — faction variety with distinct visual languages
+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")
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bvfa7xi0luysk
+28
View File
@@ -0,0 +1,28 @@
[gd_scene format=3 uid="uid://d0te4mwbh33ag"]
[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://art/characters/matador.glb" id="2_matfbx"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.25
height = 1.2
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 0.45
height = 1.4
[node name="Matador" type="CharacterBody3D" unique_id=1781857630]
script = ExtResource("1_matgd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=2101651042]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="." unique_id=745037545]
monitorable = false
[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)
shape = SubResource("CapsuleShape3D_hit")
[node name="matador2" parent="." unique_id=1487256381 instance=ExtResource("2_matfbx")]
+92
View File
@@ -0,0 +1,92 @@
class_name MatadorRagdoll
extends RefCounted
## Builds the matador's physical-bone ragdoll. Body joints are cone-twist with
## 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.
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
# [bone, radius] → sphere collider; [bone, radius, height] → capsule.
const _BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
["Bone", 0.04, 0.30],
["Bone.001", 0.04, 0.34],
["Bone.002", 0.04, 0.30],
]
# Reuse an existing simulator if the rig already has one, else build the bodies.
static func build(skeleton: Skeleton3D) -> PhysicalBoneSimulator3D:
for child: Node in skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
skeleton.add_child(sim)
var swing := DP.f("mat_joint_swing")
var twist := DP.f("mat_joint_twist")
var damp := DP.f("mat_ragdoll_damp")
for entry: Array in _BONES:
var bname: String = entry[0]
if skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.mass = 0.4
pb.linear_damp = 0.1
# The ragdoll only simulates once the matador is dead, so it lives on the CORPSE
# 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"):
# 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.mass = 0.15
pb.linear_damp = 0.5
pb.angular_damp = 0.7
pb.collision_layer = 0
else:
# Body: cone-twist joints cap how far each bone bends from its rest
# pose, so the silhouette holds; extra angular damp settles the flail.
pb.joint_type = PhysicalBone3D.JOINT_TYPE_CONE
pb.angular_damp = damp
pb.set(&"joint_constraints/swing_span", swing)
pb.set(&"joint_constraints/twist_span", twist)
var cs := CollisionShape3D.new()
if entry.size() >= 3:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
+1
View File
@@ -0,0 +1 @@
uid://dmf04b5exnejn
+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
+295
View File
@@ -0,0 +1,295 @@
extends Node
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
## Created at runtime by player.gd; call setup() before the first _process tick.
var _player: CharacterBody3D
var _bull: Node3D
var _skeleton: Skeleton3D
# Body wobble
var _prev_vel: Vector3 = Vector3.ZERO
var _tilt_x: float = 0.0
var _tilt_z: float = 0.0
var _aerial_pitch: float = 0.0
var charge_pitch_target: float = 0.0
var _charge_pitch_curr: float = 0.0
# When true, another system (the roll's ball-tumble) owns the bull's full
# orientation — skip writing body wobble so we don't fight it.
var external_rotation: bool = false
# Tail — Verlet chain simulation
const TAIL_BONES: Array = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
&"tail_5", &"tail_6",
&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
]
var _tail_idx: Array[int] = []
var _tail_world: Array[Vector3] = []
var _tail_prev: Array[Vector3] = []
var _tail_lengths: Array[float] = []
var _tail_rest_dir: Array[Vector3] = []
var _tail_parent_idx: int = -1
var tail_ragdoll: bool = false
var _prev_on_floor: bool = true
var _dbg_on: bool = false
var _dbg_nodes: Array[MeshInstance3D] = []
func setup(player: CharacterBody3D, bull: Node3D) -> void:
_player = player
_bull = bull
_skeleton = _find_skeleton(bull)
if not _skeleton:
push_error("BullLegs: Skeleton3D not found under bull")
return
# Disable any physical bone simulator
var phys_sim := _find_physical_bones(_skeleton)
if phys_sim:
phys_sim.active = false
_setup_tail()
func _setup_tail() -> void:
for bone_name: StringName in TAIL_BONES:
var idx: int = _skeleton.find_bone(bone_name)
if idx == -1:
push_warning("BullLegs: tail bone '%s' not found" % bone_name)
continue
_tail_idx.append(idx)
if _tail_idx.size() < 2:
push_error("BullLegs: not enough tail bones found")
return
for idx: int in _tail_idx:
var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
_tail_world.append(world)
_tail_prev.append(world)
var max_sep: float = 0.0
for i in range(_tail_world.size() - 1):
max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
if max_sep < 0.01:
push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
var root: Vector3 = _tail_world[0]
var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
for i in range(_tail_world.size()):
_tail_world[i] = root + back * (i * 0.09)
_tail_prev[i] = _tail_world[i]
const MIN_SEG: float = 0.06
for i in range(_tail_world.size() - 1):
var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
_tail_lengths.append(maxf(seg_len, MIN_SEG))
for i in range(_tail_world.size() - 1):
var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
var seg_skel: Vector3 = p2_skel - p1_skel
_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
_tail_rest_dir.append(_tail_rest_dir[-1])
_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])
func _process(delta: float) -> void:
if not _skeleton:
return
_wobble_body(delta)
_update_tail(delta)
_apply_tail()
if DP.b("show_tail") != _dbg_on:
_dbg_toggle()
if _dbg_on:
_dbg_update()
# ── Body Wobble ───────────────────────────────────────────────────────────────
func _wobble_body(delta: float) -> void:
var vel: Vector3 = _player.velocity
var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
_prev_vel = vel
var la: Vector3 = _bull.global_transform.basis.inverse() * accel
var tx := clampf(-la.z * DP.f("body_tilt_fwd"), -0.35, 0.35)
var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)
_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)
var flat_spd: float = Vector2(vel.x, vel.z).length()
var target_pitch: float = 0.0
if not _player.is_on_floor() and flat_spd > 0.5:
target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))
_charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed"))
if external_rotation:
return
_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
_bull.rotation.z = _tilt_z
# ── Tail ──────────────────────────────────────────────────────────────────────
func _update_tail(delta: float) -> void:
if _tail_world.is_empty():
return
var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)
var anchor: Vector3
if _tail_parent_idx >= 0:
var parent_t := _skeleton.get_bone_global_pose(_tail_parent_idx)
anchor = _skeleton.to_global((parent_t * _skeleton.get_bone_rest(_tail_idx[0])).origin)
else:
anchor = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
_tail_world[0] = anchor
_tail_prev[0] = anchor
var on_floor := _player.is_on_floor()
if not on_floor and _prev_on_floor:
# Bull just left the ground — push all tail particles upward with it
for i: int in range(1, _tail_world.size()):
_tail_prev[i] = _tail_world[i] - _player.velocity * delta
_prev_on_floor = on_floor
var gravity_mult: float
if tail_ragdoll:
gravity_mult = 2.5
elif not on_floor:
gravity_mult = 0.15
else:
gravity_mult = 1.0
gravity *= gravity_mult
var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
var wag_freq: float = DP.f("tail_wag_freq")
var time: float = Time.get_ticks_msec() / 1000.0
var basis_x: Vector3 = _bull.global_transform.basis.x
for i in range(1, _tail_world.size()):
var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
_tail_prev[i] = _tail_world[i]
var wag := Vector3.ZERO if tail_ragdoll \
else basis_x * sin(time * wag_freq + i * 0.5) * wag_strength
_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta
for i in range(1, _tail_world.size()):
var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
var len: float = dir.length()
var target_len: float = _tail_lengths[i - 1]
if len > 0.0001:
_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
else:
_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)
var body_radius: float = DP.f("tail_body_radius")
for i in range(1, _tail_world.size()):
var p := _bull.to_local(_tail_world[i])
var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
var diff := p - closest
var dist := diff.length()
if dist < body_radius and dist > 0.001:
p = closest + diff.normalized() * body_radius
_tail_world[i] = _bull.to_global(p)
func _apply_tail() -> void:
if _tail_world.is_empty():
return
var n := _tail_world.size()
var local_pos: Array[Vector3] = []
local_pos.resize(n)
for i in n:
local_pos[i] = _skeleton.to_local(_tail_world[i])
for i in n:
var idx: int = _tail_idx[i]
var pos_skel: Vector3 = local_pos[i]
var point_dir_skel: Vector3
if i < n - 1:
point_dir_skel = (local_pos[i + 1] - pos_skel).normalized()
else:
point_dir_skel = (pos_skel - local_pos[i - 1]).normalized()
var rest_dir: Vector3 = _tail_rest_dir[i]
var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
var new_basis: Basis
if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
else:
new_basis = rest_t.basis
_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)
# ── Debug Visualization ───────────────────────────────────────────────────────
func _dbg_toggle() -> void:
if _dbg_on:
for n: MeshInstance3D in _dbg_nodes:
if is_instance_valid(n):
n.queue_free()
_dbg_nodes.clear()
_dbg_on = false
else:
_dbg_build()
_dbg_on = true
func _dbg_build() -> void:
var root: Node = _player.get_parent()
var n: int = _tail_world.size()
for i: int in n:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.render_priority = 1
mat.albedo_color = Color.RED if i == 0 \
else Color(1.0 - float(i) / n, 0.4 + 0.6 * float(i) / n, 0.0)
var sm := SphereMesh.new()
sm.radius = 0.10 if i == 0 else 0.06
sm.height = sm.radius * 2.0
sm.radial_segments = 6
sm.rings = 3
sm.material = mat
var mi := MeshInstance3D.new()
mi.mesh = sm
root.add_child(mi)
_dbg_nodes.append(mi)
func _dbg_update() -> void:
for i: int in _dbg_nodes.size():
_dbg_nodes[i].global_position = _tail_world[i]
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
for child in node.get_children():
if child is PhysicalBoneSimulator3D:
return child as PhysicalBoneSimulator3D
return null
+1
View File
@@ -0,0 +1 @@
uid://bvtw8s41wrrx
+140
View File
@@ -0,0 +1,140 @@
class_name IsoCamera
extends Camera3D
@export var height: float = 25.0
@export var horizontal_distance: float = 25.0
@export var horizontal_angle_deg: float = 45.0
# Perspective zoom (FOV)
@export var fov_min: float = 20.0
@export var fov_max: float = 75.0
@export var default_fov: float = 30.0
@export var focus_slice_thickness: float = 6.0
@export var blur_transition: float = 15.0
var _shake_amount: float = 0.0
var _shake_decay: 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
# the bull), _drag_offset is the velocity-driven trail that slides it behind.
var _focus: Vector3 = Vector3.ZERO
var _drag_offset: Vector3 = Vector3.ZERO
var _focus_init: bool = false
func _ready() -> void:
set_as_top_level(true)
projection = Camera3D.PROJECTION_PERSPECTIVE
fov = default_fov
_user_fov = default_fov
near = 0.1
far = 150.0
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("zoom_in"):
_user_fov = clamp(_user_fov / 1.1, fov_min, fov_max)
if event.is_action_pressed("zoom_out"):
_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:
fov = lerpf(fov, _user_fov, delta * 8.0)
var angle_rad := deg_to_rad(horizontal_angle_deg)
var offset := Vector3(
sin(angle_rad) * horizontal_distance * _framing_scale,
height * _framing_scale,
cos(angle_rad) * horizontal_distance * _framing_scale
)
var target := get_parent() as Node3D
var focus := _drag_focus(target, delta)
global_position = focus + offset
look_at(focus, Vector3.UP)
_update_dof(focus)
if _shake_amount > 0.005:
global_position += Vector3(
randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount * 0.25, _shake_amount * 0.25),
randf_range(-_shake_amount, _shake_amount)
)
_shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta)
# Smoothed follow point that drags behind the bull. The target trail sits opposite
# the bull's flat velocity (cam_drag metres per m/s); the offset and the focus both
# ease toward their targets so the camera lags and settles rather than snapping.
func _drag_focus(target: Node3D, delta: float) -> Vector3:
var pos := target.global_position
if not _focus_init:
_focus = pos
_focus_init = true
var vel := Vector3.ZERO
if target is CharacterBody3D:
vel = (target as CharacterBody3D).velocity
vel.y = 0.0
var drag_target := -vel * DP.f("cam_drag")
_drag_offset = _drag_offset.lerp(drag_target, clampf(DP.f("cam_drag_speed") * delta, 0.0, 1.0))
_focus = _focus.lerp(pos + _drag_offset, clampf(DP.f("cam_follow_lerp") * delta, 0.0, 1.0))
return _focus
func trigger_hit(strength: float) -> void:
if not Settings.screen_shake:
return
_shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4
func _update_dof(target_pos: Vector3) -> void:
var cam_attr: CameraAttributesPractical = null
if attributes is CameraAttributesPractical:
cam_attr = attributes
if cam_attr == null:
return
var distance := global_position.distance_to(target_pos)
cam_attr.dof_blur_near_distance = max(distance - focus_slice_thickness * 0.5, near + 0.1)
cam_attr.dof_blur_far_distance = distance + focus_slice_thickness * 0.5
cam_attr.dof_blur_near_transition = blur_transition
cam_attr.dof_blur_far_transition = blur_transition
cam_attr.dof_blur_near_enabled = false
cam_attr.dof_blur_far_enabled = false
+1
View File
@@ -0,0 +1 @@
uid://cpyq2r5cq653g
+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
+1
View File
@@ -0,0 +1 @@
uid://bn777gguclud5
+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
+33
View File
@@ -0,0 +1,33 @@
# Contribution
Hello, thanks for taking time and helping out with the addon!
Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. Please sign the CLA before sending the PR: https://www.jetbrains.com/agreements/cla/.
#### Local setup
Open the `godot-editor-plugin/CMakeLists.txt` in Rider 2026.1+, select `rider-gdextension` target in the run configuration selector.
There are some more fixes coming in 2026.2, which will allow working on gdscript and cpp files within the same workspace.
#### Signing the binaries
Plugin binaries need to be signed to comply with modern operating system security requirements. Unsigned dynamic libraries may fail to load or trigger security warnings:
- **macOS**: Requires code signing (and often notarization) for `.dylib` files. Unsigned libraries may simply fail to load.
- **Windows**: SmartScreen may block unsigned binaries, and corporate environments often enforce signed code only.
- **Linux**: Generally allows unsigned `.so` files, but some sandboxed environments (Flatpak, Snap) impose restrictions.
**How signing is implemented:**
1. **TeamCity Configuration**: [ijplatform_master_Net_Deploy_Plugins_Public_Godot](https://buildserver.labs.intellij.net/buildConfiguration/ijplatform_master_Net_Deploy_Plugins_Public_Godot) (internal JetBrains link)
- Note: The automatic trigger on new tags is currently not working, so manual triggering is required.
2. **Release Process**:
- First, use your GitHub Action to prepare a **pre-release** with a tag
- Manually trigger the TeamCity configuration on the necessary branch/tag
- The configuration will download assets, sign them, and upload them back (removing the pre-release flag)
- The pre-release flag is required at the start; otherwise, the signing configuration will skip the release (protection against double signing)
3. **Signing existing releases**:
- You can run signing on already existing old releases/tags
- Mark them as pre-release first, then run the configuration on the necessary tag
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 JetBrains
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
# JetBrains Rider Integration – Godot addon
This addon currently provides two features:
1. Finds all Rider installations in the system and provides a selector on the `Text Editor` -> `External` tab in the settings to select one.
2. Provides the "Use Rider" toggle in the Godot toolbar and, when enabled, applies a set of editor settings recommended for working with JetBrains Rider. The goal is to make it trivial to switch between Rider‑optimized settings and stock Godot settings with a single click.
## Quick start
Requirements:
- Godot 4.2.2+
Install:
1. Inside the Godot editor, it can be installed from the AssetLib view or [downloaded](https://godotengine.org/asset-library/asset/4576)
2. [Optional] Change the initial value of `active` in the plugin.cfg
3. [Optional] Change the initial values in the presets.json file.
4. Enable "JetBrains Rider External Editor" plugin in the Project → Project Settings… → Plugins tab.
Use:
- A toolbar toggle named "Use Rider" will appear. Click it to turn the preset On/Off.
Screenshot:
![Toolbar toggle](screenshots/Toolbar.png)
## What the toggle changes
The preset values live in `presets.json`.
When ON:
- Write the values from the "on" preset into the Editor Settings.
When OFF:
- Write the values from the "off" preset into the Editor Settings.
Note: The plugin does not currently auto‑set Rider’s executable path or flags. See Plans below.
## Setting Rider to be the external editor
The plugin automatically detects installed Rider versions on your system and provides a convenient dropdown menu to
select which installation to use as your external editor.
- The plugin scans common installation locations for Rider on Windows, macOS, and Linux.
- Detected installations appear in the "Select Rider" dropdown in the toolbar.
- When you select a Rider installation, the plugin automatically updates the `dotnet/editor/external_editor_path` editor
setting.
## License
See `addons/rider-plugin/LICENCE`.
## Acknowledgements
Created by JetBrains to streamline using Rider with Godot.
Initial idea https://github.com/sszigeti/toggle_external_editor
@@ -0,0 +1,16 @@
[configuration]
entry_symbol = "rider_library_init"
compatibility_minimum = "4.2.2"
[libraries]
macos.single.debug = "./macos/librider-gdextension.macos.editor.dylib"
windows.arm64.single.debug = "./windows/rider-gdextension.windows.editor.arm64.dll"
windows.x86_64.single.debug = "./windows/rider-gdextension.windows.editor.x86_64.dll"
linux.x86_64.single.debug = "./linux/librider-gdextension.linux.editor.x86_64.so"
linux.arm64.single.debug = "./linux/librider-gdextension.linux.editor.arm64.so"
+144
View File
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.0"
id="katman_1"
x="0px"
y="0px"
viewBox="0 0 512 512"
xml:space="preserve"
sodipodi:docname="JetBrains Rider Icon.svg"
width="512"
height="512"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs43" /><sodipodi:namedview
id="namedview41"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
showgrid="false"
inkscape:zoom="1.0980553"
inkscape:cx="256.36233"
inkscape:cy="212.19333"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="22"
inkscape:window-maximized="1"
inkscape:current-layer="katman_1" />
<style
type="text/css"
id="style2">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_00000130622934180993703410000017420799098261276343_);}
.st2{fill:url(#SVGID_00000060739771362873723200000017991140373209755022_);}
.st3{fill:#FFFFFF;}
</style>
<symbol
id="rider"
viewBox="-35 -35 70 70">
<linearGradient
id="SVGID_1_"
gradientUnits="userSpaceOnUse"
x1="30.4897"
y1="5.1188998"
x2="-23.4683"
y2="-25.8451">
<stop
offset="0"
style="stop-color:#DD1265"
id="stop4" />
<stop
offset="0.483"
style="stop-color:#DD1265"
id="stop6" />
<stop
offset="0.942"
style="stop-color:#FDB60D"
id="stop8" />
</linearGradient>
<path
class="st0"
d="M 35,-7.7 -14.1,-35 18.8,13.9 25.5,9.5 Z"
id="path11" />
<linearGradient
id="SVGID_00000169517474296634577950000016895268102491994795_"
gradientUnits="userSpaceOnUse"
x1="-1.5839"
y1="-28.888"
x2="19.805099"
y2="30.174999">
<stop
offset="0.139"
style="stop-color:#087CFA"
id="stop13" />
<stop
offset="0.476"
style="stop-color:#DD1265"
id="stop15" />
<stop
offset="0.958"
style="stop-color:#087CFA"
id="stop17" />
</linearGradient>
<path
style="fill:url(#SVGID_00000169517474296634577950000016895268102491994795_)"
d="M 15.5,-18.9 9.3,-33.9 -4.3,-20.5 1.2,28.1 14.4,35 35,23 Z"
id="path20" />
<linearGradient
id="SVGID_00000135668350575375336630000014638141720088491653_"
gradientUnits="userSpaceOnUse"
x1="-17.5865"
y1="-27.071199"
x2="-1.7875"
y2="29.073799">
<stop
offset="0.278"
style="stop-color:#DD1265"
id="stop22" />
<stop
offset="0.968"
style="stop-color:#FDB60D"
id="stop24" />
</linearGradient>
<path
style="fill:url(#SVGID_00000135668350575375336630000014638141720088491653_)"
d="m -14.1,-35 -20.9,14.1 7.8,48.1 20.1,7.7 26,-21 z"
id="path27" />
<path
d="M 21,-21 H -21 V 21 H 21 Z"
id="path29" />
<path
class="st3"
d="m -0.6,13.6 h -15.8 v 2.7 h 15.8 z"
id="path31" />
<path
class="st3"
d="m 0.5,-15.8 h 6.2 c 5,0 8.4,3.4 8.4,7.7 0,4.4 -3.4,7.9 -8.4,7.9 L 0.5,0 Z m 3.5,3.2 v 9.5 h 2.7 c 2.8,0 4.8,-1.9 4.8,-4.6 0,-2.8 -1.9,-4.7 -4.8,-4.7 z"
id="path33" />
<path
class="st3"
d="m -15.7,-15.8 h 7.2 c 2,0 3.5,0.6 4.6,1.6 0.9,0.9 1.3,2.1 1.3,3.6 v 0.1 c 0,1.3 -0.3,2.3 -0.9,3.1 -0.6,0.8 -1.4,1.4 -2.4,1.8 L -2,0 h -4.1 l -3.3,-4.8 h -2.9 V 0 h -3.5 z m 7,7.7 c 0.8,0 1.5,-0.2 2,-0.6 0.5,-0.4 0.7,-1 0.7,-1.6 v -0.1 c 0,-0.8 -0.2,-1.3 -0.7,-1.7 -0.5,-0.3 -1.1,-0.6 -2,-0.6 h -3.4 v 4.5 h 3.4 z"
id="path35" />
</symbol>
<use
xlink:href="#rider"
width="70"
height="70"
x="-35"
y="-35"
transform="matrix(7.0682519,0,0,7.2481031,256,256)"
style="overflow:visible"
id="use38" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://wrdrj6wednn8"
path="res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/rider-plugin/icons/icon.svg"
dest_files=["res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+12
View File
@@ -0,0 +1,12 @@
[plugin]
name="JetBrains Rider External Editor"
description="Provides a toolbar toggle, to switch a set of settings on and off. Default set of settings helps to enable/disable the following settings recommended by the Rider documentation https://www.jetbrains.com/help/rider/Godot.html#optimize-godot-editor-for-rider"
author="JetBrains"
version="1.0.0"
script="rider-plugin.gd"
[presets]
active="on"
presets="presets.json"
+15
View File
@@ -0,0 +1,15 @@
{
"on": {
"text_editor/external/use_external_editor": true,
"interface/editor/import_resources_when_unfocused": true,
"interface/editor/save_on_focus_loss": true,
"text_editor/behavior/files/auto_reload_scripts_on_external_change": true,
"run/window_placement/game_embed_mode": -1
},
"off": {
"text_editor/external/use_external_editor": false,
"interface/editor/import_resources_when_unfocused": false,
"interface/editor/save_on_focus_loss": false,
"text_editor/behavior/files/auto_reload_scripts_on_external_change": false
}
}
+66
View File
@@ -0,0 +1,66 @@
@tool
extends EditorPlugin
var editor_settings: EditorSettings
var checkbutton: CheckButton
var _preset_applier: PresetApplier
var _settings_service: EditorSettingsService
var _locator_service: RiderLocatorService
var _plugin_cfg_path: String
var _presets_json_path: String
func _enter_tree() -> void:
editor_settings = EditorInterface.get_editor_settings()
var script_path := (get_script() as Script).resource_path
var plugin_dir := script_path.get_base_dir()
_plugin_cfg_path = plugin_dir + "/plugin.cfg"
var cfg := ConfigFile.new()
var err := cfg.load(_plugin_cfg_path)
if err != OK:
push_warning("Failed to load plugin.cfg: %s" % [err])
return
var active_str := str(cfg.get_value("presets", "active", "on"))
var is_active := active_str == "on"
var presets_rel_path := str(cfg.get_value("presets", "presets", "presets.json"))
_presets_json_path = plugin_dir + "/" + presets_rel_path
# Build UI
checkbutton = CheckButton.new()
checkbutton.text = "Use Rider"
checkbutton.tooltip_text = "Shortcut for setting recommended settings"
checkbutton.button_pressed = is_active
checkbutton.pressed.connect(_on_checkbutton_pressed)
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
# Initialize services and panel
_settings_service = EditorSettingsService.new()
_locator_service = RiderLocatorService.new()
_preset_applier = PresetApplier.new(_presets_json_path)
_locator_service.add_selector_in_editor_interface(_settings_service)
# Ensure settings reflect current state on startup
_preset_applier.apply_preset(editor_settings, is_active)
func _on_checkbutton_pressed() -> void:
var cfg := ConfigFile.new()
if cfg.load(_plugin_cfg_path) != OK:
push_warning("Failed to load plugin.cfg to update state")
return
var is_active := checkbutton.button_pressed
var key := _preset_applier.get_preset_key(is_active)
cfg.set_value("presets", "active", key)
var save_err := cfg.save(_plugin_cfg_path)
if save_err != OK:
push_warning("Failed to save plugin.cfg: %s" % [save_err])
# Apply selected preset to editor settings
_preset_applier.apply_preset(editor_settings, is_active)
func _exit_tree() -> void:
if checkbutton != null:
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
checkbutton.queue_free()
var args = OS.get_cmdline_args()
if "--rider-addon-tests" in args:
print("==== rider-addon-tests finished ====")
+1
View File
@@ -0,0 +1 @@
uid://c1x58xm2w1n20
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bro2w3ghx4xt7"
path="res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/rider-plugin/screenshots/Toolbar.png"
dest_files=["res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+23
View File
@@ -0,0 +1,23 @@
@tool
## Simple JSON helpers for editor/runtime use.
## Keeps file and JSON parsing concerns out of feature code.
class_name JsonUtils
static func load_from_file(path: String) -> Variant:
# Returns parsed JSON value (Dictionary/Array/etc.) or null on error.
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("JsonUtils: Failed to open file: %s" % path)
return null
var text := file.get_as_text()
file.close()
var data: Variant = JSON.parse_string(text)
if data == null:
push_warning("JsonUtils: Invalid JSON in file: %s" % path)
return null
return data
static func load_dict_from_file(path: String) -> Dictionary:
# Returns Dictionary or empty {} on error.
var data := load_from_file(path) as Dictionary
return data
@@ -0,0 +1 @@
uid://bci4kmk7h4j6a
@@ -0,0 +1,112 @@
@tool
extends RefCounted
class_name RiderLocatorService
var _installations_found: Array = []
var _thread: Thread = null
func get_installations() -> Array:
var result: Array = RiderLocator.new().get_installations() # from the gdextension
return result
# todo
func fix_external_editor_if_supplied_in_commandline(_settings_service: EditorSettingsService, editor_settings: EditorSettings) -> bool:
# When Godot is started from Rider (or vice versa), we may receive the Rider path
# via command-line so we can keep Godot's external editor setting in sync.
# Supported form (only this one):
# --my_rider_path="/absolute/path/to/rider with possible spaces"
var args : Array = OS.get_cmdline_args()
var provided_rider_path := ""
for a_raw in args:
var a: String = str(a_raw)
if a.begins_with("--my_rider_path="):
provided_rider_path = a.substr("--my_rider_path=".length())
break
if provided_rider_path.is_empty():
return false
provided_rider_path = trim_quotes(provided_rider_path)
# Validate existence (file or dir)
var looks_existing := FileAccess.file_exists(provided_rider_path) || DirAccess.dir_exists_absolute(provided_rider_path)
if looks_existing:
_settings_service.set_external_editor_path(editor_settings, provided_rider_path)
print("Rider path provided via CLI (my_rider_path): ", provided_rider_path)
return true
else:
push_warning("my_rider_path was provided but does not exist: %s" % [provided_rider_path])
return false
func trim_quotes(s: String) -> String:
if s.begins_with('"') and s.ends_with('"') and s.length() >= 2:
return s.substr(1, s.length() - 2)
return s
func add_selector_in_editor_interface(_settings_service: EditorSettingsService):
_update_selector(_installations_found)
if _installations_found.is_empty() and _thread == null:
_thread = Thread.new()
_thread.start(_load_installations)
func _load_installations() -> void:
var array: Array = get_installations()
call_deferred("_on_installations_loaded", array)
func _on_installations_loaded(array: Array):
_installations_found = array
if _thread:
_thread.wait_to_finish()
_thread = null
_update_selector(_installations_found)
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _thread != null:
_thread.wait_to_finish()
func _update_selector(array: Array):
var name := "text_editor/external/editor"
var settings := EditorInterface.get_editor_settings()
if !(settings.has_setting(name)):
settings.set(name, 0)
var installations: Array = ["Custom"]
for element in array:
var display_name: String = element.get("display", "")
# Replace special characters that break PROPERTY_HINT_ENUM format
# Comma is the enum delimiter, colon is used for explicit value assignment
display_name = display_name.replace(",", " •").replace(":", " -")
installations.append(display_name)
var options :String = ",".join(installations)
settings.add_property_info({
"name": name,
"type":TYPE_INT,
"hint":PROPERTY_HINT_ENUM,
"hint_string": options
})
# Connect to settings changes to update external editor path when selection changes
if not settings.settings_changed.is_connected(_on_selection_changed):
settings.settings_changed.connect(_on_selection_changed.bind())
func _on_selection_changed() -> void:
var name := "text_editor/external/editor"
var settings := EditorInterface.get_editor_settings()
var selected_index: int = settings.get_setting(name)
# Index 0 is "Custom", so user manages the path manually
if selected_index == 0:
return
# Map to actual installation (offset by 1 because of "Custom" at index 0)
var installation_index := selected_index - 1
var installations_array = _installations_found
if installation_index >= 0 and installation_index < installations_array.size():
var installation = installations_array[installation_index]
var new_path: String = installation.get("path", "")
if not new_path.is_empty():
EditorSettingsService.new().set_external_editor_path(settings, new_path)
@@ -0,0 +1 @@
uid://cfsu1rbg0ypem
@@ -0,0 +1,35 @@
@tool
extends RefCounted
class_name PresetApplier
var presets_path: String
func _init(p_path: String) -> void:
presets_path = p_path
func get_preset_key(is_active: bool) -> String:
return "on" if is_active else "off"
func apply_preset(editor_settings: EditorSettings, is_active: bool) -> void:
var data: Dictionary = JsonUtils.load_dict_from_file(presets_path)
if data.is_empty():
push_warning("Failed to load presets: %s" % presets_path)
return
var new_preset_key := get_preset_key(is_active)
var previous_preset_key := get_preset_key(not is_active)
if not data.has(new_preset_key):
push_warning("Preset '%s' not found in presets.json" % new_preset_key)
return
var new_preset := data[new_preset_key] as Dictionary
# Reset keys from previous preset that are missing in the new preset
if data.has(previous_preset_key):
var previous_preset := data[previous_preset_key] as Dictionary
for key in previous_preset:
if not new_preset.has(key):
editor_settings.set_setting(str(key), editor_settings.property_get_revert(str(key)))
# Apply the new preset
for key in new_preset:
editor_settings.set_setting(str(key), new_preset[key])
@@ -0,0 +1 @@
uid://bjiaycxso8h8u
@@ -0,0 +1,17 @@
@tool
extends RefCounted
class_name EditorSettingsService
func set_external_editor_path(editor_settings: EditorSettings, path: String) -> void:
editor_settings.set_setting("text_editor/external/exec_path", path)
func has_valid_external_editor_path(editor_settings: EditorSettings) -> bool:
var has_setting: bool = editor_settings.has_setting("text_editor/external/exec_path")
if not has_setting:
return false
var path : String = editor_settings.get_setting("text_editor/external/exec_path")
var exists := not path.is_empty() && (FileAccess.file_exists(path) || DirAccess.dir_exists_absolute(path))
return exists
func set_use_external_editor(editor_settings: EditorSettings, enabled: bool) -> void:
editor_settings.set_setting("text_editor/external/use_external_editor", enabled)
@@ -0,0 +1 @@
uid://bdiu78ot0rrkc
Binary file not shown.
File diff suppressed because it is too large Load Diff
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+151
View File
@@ -0,0 +1,151 @@
"""
Blender 5.1 script: create walk + idle animations for matador.
Run: blender --background matador.blend --python create_matador_anims.py
"""
import bpy, math, os
# ── setup ──────────────────────────────────────────────────────────────────────
arm_obj = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
bpy.context.view_layer.objects.active = arm_obj
bpy.ops.object.mode_set(mode='POSE')
if arm_obj.animation_data is None:
arm_obj.animation_data_create()
pb = arm_obj.pose.bones
# Ensure all animated bones use XYZ Euler
for bname in ['leg_L', 'leg_R', 'shin_L', 'shin_R',
'arm_L', 'arm_R', 'forearm_L', 'forearm_R',
'collarbone_L', 'collarbone_R']:
pb[bname].rotation_mode = 'XYZ'
CYCLE = 30 # frames (1 s at 30 fps)
LEG_SWING = 25 # degrees
KNEE_BEND = 30
ARM_SWING = 15
ARM_DOWN = 55 # lower arms from T-pose around bone-local Y
ELBOW_BEND = 25
# ── WALK action ────────────────────────────────────────────────────────────────
# Remove old actions if they exist
for old_name in ['walk', 'idle']:
if old_name in bpy.data.actions:
bpy.data.actions.remove(bpy.data.actions[old_name])
walk = bpy.data.actions.new(name='walk')
walk.use_fake_user = True
walk.use_cyclic = True
arm_obj.animation_data.action = walk
# Clear all pose bone transforms first
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.rot_clear()
arm_down_l = math.radians(ARM_DOWN)
arm_down_r = math.radians(-ARM_DOWN)
for f in range(0, CYCLE + 1, 2):
t = (f / CYCLE) * math.tau
# Legs – swing forward/back on bone-local X
pb['leg_L'].rotation_euler = (math.sin(t) * math.radians(LEG_SWING), 0, 0)
pb['leg_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['leg_R'].rotation_euler = (math.sin(t + math.pi) * math.radians(LEG_SWING), 0, 0)
pb['leg_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Shins – only bend backward when leg is back
val_l = max(0.0, -math.sin(t)) * math.radians(KNEE_BEND)
val_r = max(0.0, -math.sin(t + math.pi)) * math.radians(KNEE_BEND)
pb['shin_L'].rotation_euler = (val_l, 0, 0)
pb['shin_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['shin_R'].rotation_euler = (val_r, 0, 0)
pb['shin_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Arms – lower from T-pose (Y) + swing (X, opposite phase to legs)
swing_l = math.sin(t + math.pi) * math.radians(ARM_SWING)
swing_r = math.sin(t) * math.radians(ARM_SWING)
pb['arm_L'].rotation_euler = (swing_l, arm_down_l, 0)
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['arm_R'].rotation_euler = (swing_r, arm_down_r, 0)
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Forearms – constant slight bend
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Collarbones – identity
pb['collarbone_L'].rotation_euler = (0, 0, 0)
pb['collarbone_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['collarbone_R'].rotation_euler = (0, 0, 0)
pb['collarbone_R'].keyframe_insert(data_path='rotation_euler', frame=f)
print("Created 'walk' action")
# ── IDLE action ────────────────────────────────────────────────────────────────
idle = bpy.data.actions.new(name='idle')
idle.use_fake_user = True
arm_obj.animation_data.action = idle
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.rot_clear()
# Arms lowered, everything else at rest – 2 frames for a valid action
for f in [0, 1]:
pb['arm_L'].rotation_euler = (0, arm_down_l, 0)
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['arm_R'].rotation_euler = (0, arm_down_r, 0)
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
for bname in ['collarbone_L', 'collarbone_R', 'leg_L', 'leg_R', 'shin_L', 'shin_R']:
pb[bname].rotation_euler = (0, 0, 0)
pb[bname].keyframe_insert(data_path='rotation_euler', frame=f)
print("Created 'idle' action")
# ── NLA tracks for export ─────────────────────────────────────────────────────
bpy.ops.object.mode_set(mode='OBJECT')
# Clear existing NLA tracks
if arm_obj.animation_data.nla_tracks:
for t in list(arm_obj.animation_data.nla_tracks):
arm_obj.animation_data.nla_tracks.remove(t)
tw = arm_obj.animation_data.nla_tracks.new()
tw.name = 'walk'
sw = tw.strips.new('walk', 0, walk)
ti = arm_obj.animation_data.nla_tracks.new()
ti.name = 'idle'
si = ti.strips.new('idle', 0, idle)
# ── export FBX ─────────────────────────────────────────────────────────────────
fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador.fbx')
bpy.ops.export_scene.fbx(
filepath=fbx_path,
use_selection=False,
bake_anim=True,
bake_anim_use_all_actions=True,
bake_anim_use_nla_strips=False,
bake_anim_force_startend_keying=True,
bake_anim_step=1.0,
bake_anim_simplify_factor=0.0,
add_leaf_bones=False,
primary_bone_axis='Y',
secondary_bone_axis='X',
apply_scale_options='FBX_SCALE_ALL',
)
print('Exported FBX to:', fbx_path)
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print('Saved .blend')
Binary file not shown.
Binary file not shown.
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
"""
Blender 5.1 script: render preview frames of matador animations to PNG.
Run: blender --background matador.blend --python render_anim_preview.py
Outputs to art/blender/preview/ directory:
walk_frame_00.png .. walk_frame_09.png
idle_frame_00.png
Use this to visually verify bone orientations BEFORE exporting to Godot.
"""
import bpy
import os
import math
# ── Configuration ─────────────────────────────────────────────────────────────
PREVIEW_DIR = os.path.join(os.path.dirname(bpy.data.filepath), "preview")
RENDER_RES_X = 512
RENDER_RES_Y = 512
WALK_FRAMES = 10 # number of evenly-spaced frames to render from walk cycle
CAMERA_DISTANCE = 5.0
CAMERA_HEIGHT = 1.0
CAMERA_ANGLES = [0, 90] # degrees — front and side views
os.makedirs(PREVIEW_DIR, exist_ok=True)
# ── Find armature ─────────────────────────────────────────────────────────────
arm_obj = next((o for o in bpy.data.objects if o.type == 'ARMATURE'), None)
if arm_obj is None:
raise RuntimeError("No armature found in scene")
# ── Setup render settings ─────────────────────────────────────────────────────
scene = bpy.context.scene
scene.render.resolution_x = RENDER_RES_X
scene.render.resolution_y = RENDER_RES_Y
scene.render.resolution_percentage = 100
scene.render.film_transparent = True
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
scene.render.engine = 'BLENDER_EEVEE'
# ── Create or reuse camera ────────────────────────────────────────────────────
cam_data = bpy.data.cameras.get("PreviewCam") or bpy.data.cameras.new("PreviewCam")
cam_data.lens = 50
cam_obj = bpy.data.objects.get("PreviewCamObj")
if cam_obj is None:
cam_obj = bpy.data.objects.new("PreviewCamObj", cam_data)
scene.collection.objects.link(cam_obj)
scene.camera = cam_obj
# ── Create or reuse key light ─────────────────────────────────────────────────
light_data = bpy.data.lights.get("PreviewLight") or bpy.data.lights.new("PreviewLight", type='SUN')
light_data.energy = 3.0
light_obj = bpy.data.objects.get("PreviewLightObj")
if light_obj is None:
light_obj = bpy.data.objects.new("PreviewLightObj", light_data)
scene.collection.objects.link(light_obj)
light_obj.rotation_euler = (math.radians(45), math.radians(30), 0)
# ── Create or reuse fill light ────────────────────────────────────────────────
fill_data = bpy.data.lights.get("PreviewFill") or bpy.data.lights.new("PreviewFill", type='SUN')
fill_data.energy = 1.0
fill_obj = bpy.data.objects.get("PreviewFillObj")
if fill_obj is None:
fill_obj = bpy.data.objects.new("PreviewFillObj", fill_data)
scene.collection.objects.link(fill_obj)
fill_obj.rotation_euler = (math.radians(60), math.radians(-45), 0)
# ── Ensure armature is visible ────────────────────────────────────────────────
arm_obj.hide_render = False
arm_obj.hide_viewport = False
for child in arm_obj.children:
child.hide_render = False
child.hide_viewport = False
def position_camera(angle_deg: float) -> None:
"""Place camera at given horizontal angle around the armature center."""
rad = math.radians(angle_deg)
cam_obj.location = (
math.sin(rad) * CAMERA_DISTANCE,
CAMERA_HEIGHT,
math.cos(rad) * CAMERA_DISTANCE,
)
# Point camera at armature center (roughly hip height)
target = arm_obj.location.copy()
target.y = 0.9
direction = target - cam_obj.location
rot_quat = direction.to_track_quat('-Z', 'Y')
cam_obj.rotation_euler = rot_quat.to_euler()
def render_action(action_name: str, frame_count: int) -> None:
"""Render frame_count evenly-spaced frames of the named action."""
action = bpy.data.actions.get(action_name)
if action is None:
print(f"WARNING: Action '{action_name}' not found, skipping")
return
# Assign action
if arm_obj.animation_data is None:
arm_obj.animation_data_create()
arm_obj.animation_data.action = action
frame_start = int(action.frame_range[0])
frame_end = int(action.frame_range[1])
total_frames = max(frame_end - frame_start, 1)
for angle_deg in CAMERA_ANGLES:
position_camera(angle_deg)
angle_label = f"{angle_deg:03d}deg"
for i in range(frame_count):
t = i / max(frame_count - 1, 1) if frame_count > 1 else 0
frame = frame_start + int(t * total_frames)
scene.frame_set(frame)
filename = f"{action_name}_{angle_label}_frame_{i:02d}.png"
filepath = os.path.join(PREVIEW_DIR, filename)
scene.render.filepath = filepath
bpy.ops.render.render(write_still=True)
print(f" Rendered: {filename}")
# ── Render all animations ─────────────────────────────────────────────────────
print("=" * 60)
print("RENDERING ANIMATION PREVIEWS")
print("=" * 60)
render_action("walk", WALK_FRAMES)
render_action("idle", 1)
# ── Cleanup preview objects (optional — keep them for re-runs) ────────────────
print("=" * 60)
print(f"Preview images saved to: {PREVIEW_DIR}")
print(f"Total actions rendered: {len([a for a in ['walk', 'idle'] if bpy.data.actions.get(a)])}")
print("=" * 60)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cofv5dbavesis"
path="res://.godot/imported/bear.glb-75dd9ad1d0d42d959c02624cfc22996d.scn"
[deps]
source_file="res://art/characters/bear.glb"
dest_files=["res://.godot/imported/bear.glb-75dd9ad1d0d42d959c02624cfc22996d.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=false
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
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bun14xmk20pcj"
path="res://.godot/imported/bull.fbx-ffca5aad6fa2fef7c6aaeff23784274e.scn"
[deps]
source_file="res://art/characters/bull.fbx"
dest_files=["res://.godot/imported/bull.fbx-ffca5aad6fa2fef7c6aaeff23784274e.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=true
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={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
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
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://fu84fchl1im6"
path="res://.godot/imported/Cinzel.ttf-95f91c70d492f60cef14788d0547dff4.fontdata"
[deps]
source_file="res://art/fonts/Cinzel.ttf"
dest_files=["res://.godot/imported/Cinzel.ttf-95f91c70d492f60cef14788d0547dff4.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://rcfn6xhs7fur"
path="res://.godot/imported/CinzelDecorative-Bold.ttf-71cca077ba75a6c829f53e2548a2580d.fontdata"
[deps]
source_file="res://art/fonts/CinzelDecorative-Bold.ttf"
dest_files=["res://.godot/imported/CinzelDecorative-Bold.ttf-71cca077ba75a6c829f53e2548a2580d.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

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