5c1e881a53
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>
164 lines
14 KiB
Markdown
164 lines
14 KiB
Markdown
# 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
|
||
|
||
| Path | Purpose |
|
||
|---|---|
|
||
| `scene.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` |
|
||
| `Player.tscn` | Player scene root (`CharacterBody3D`) |
|
||
| `player.gd` | Movement, charge, turning logic |
|
||
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
|
||
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
|
||
| `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` |
|
||
| `Bear.tscn` / `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 |
|
||
| `BearAttackHitboxes.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`. `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`) |
|
||
| `Sword.tscn` / `hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `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`) — Slay-the-Spire map + player position across fights |
|
||
| `levels/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
|
||
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
|
||
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
|
||
| `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 |
|
||
| `crowd_billboards.gd` / `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 |
|
||
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
|
||
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
|
||
| `Blender/` | Blender source files, animation scripts, FBX exports |
|
||
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
|
||
| `Blender/render_anim_preview.py` | Renders animation preview PNGs for visual verification |
|
||
| `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_v02)
|
||
- **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 Blender/matador_v02.blend --python Blender/render_anim_preview.py`
|
||
3. Inspect PNGs in `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 *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
||
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
|
||
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
|
||
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
||
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
|
||
godot --headless --script tests/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
|