diff --git a/Blender/matador_v02.blend b/Blender/matador_v02.blend index 744dd98..55e65bd 100644 Binary files a/Blender/matador_v02.blend and b/Blender/matador_v02.blend differ diff --git a/Blender/matador_v02.blend1 b/Blender/matador_v02.blend1 index 8520010..744dd98 100644 Binary files a/Blender/matador_v02.blend1 and b/Blender/matador_v02.blend1 differ diff --git a/Blender/matador_v02.fbx b/Blender/matador_v02.fbx index 6733026..51e7aca 100644 Binary files a/Blender/matador_v02.fbx and b/Blender/matador_v02.fbx differ diff --git a/CLAUDE.md b/CLAUDE.md index 1db47dc..89dd319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,15 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen | `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) | +| `matador_spawn.gd` | Spawns N matadors at random arena positions | +| `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 @@ -48,6 +56,57 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen - 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 tests/*.gd # GDScript lint (gdtoolkit, installed via uv) +godot --headless --script tests/logic_test.gd # pure logic, ~2 s +godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 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 +``` + +### 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. + ## Godot 4.x specifics - `wrapf` / `wrap` instead of manual modulo for angles diff --git a/bull_legs.gd b/bull_legs.gd index af2a4b4..6e55e2b 100644 --- a/bull_legs.gd +++ b/bull_legs.gd @@ -108,18 +108,41 @@ func _setup_tail() -> void: push_error("BullLegs: not enough tail bones found") return - for i in range(_tail_idx.size() - 1): - var a: Transform3D = _skeleton.get_bone_global_rest(_tail_idx[i]) - var b: Transform3D = _skeleton.get_bone_global_rest(_tail_idx[i + 1]) - _tail_lengths.append((b.origin - a.origin).length()) - _tail_rest_dir.append((b.origin - a.origin).normalized()) - _tail_rest_dir.append(_tail_rest_dir[-1]) - + # Initialise world positions first 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) + # Detect degenerate rest pose (FBX bones all at same position) + 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] + # Backward-downward direction in world space from the skeleton + 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] + + # Segment lengths in world space — used by the Verlet length constraint + 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)) + + # Rest directions in SKELETON-LOCAL space — must match point_dir_skel in _apply_tail + # so that Quaternion(rest_dir, point_dir_skel) operates in a consistent frame. + # _tail_world holds world positions; to_local converts them to skeleton space. + 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]) + func _process(delta: float) -> void: if not _skeleton: @@ -271,7 +294,7 @@ func _step_launch(_delta: float) -> void: _foot[id] = _launch_feet[id] -func _step_ragdoll(delta: float) -> void: +func _step_ragdoll(_delta: float) -> void: # Procedural floppy dangle — NOT real physics ragdoll var time: float = _state_time var amp: float = DP.f("ragdoll_wobble_amp") @@ -335,10 +358,10 @@ func _get_tucked_foot(id: String) -> Vector3: func _apply_ik() -> void: + var skel_inv: Transform3D = _skeleton.global_transform.affine_inverse() for id: String in _ik: var ik: SkeletonIK3D = _ik[id] - var target_local: Vector3 = _skeleton.get_parent().to_local(_foot[id]) - ik.target = Transform3D(Basis.IDENTITY, target_local) + ik.target = Transform3D(Basis.IDENTITY, skel_inv * _foot[id]) # ── Tail ────────────────────────────────────────────────────────────────────── @@ -365,7 +388,8 @@ func _update_tail(delta: float) -> void: var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping _tail_prev[i] = _tail_world[i] # Add wag force (sinusoidal side-to-side) - var wag := _bull.global_transform.basis.x * sin(time * DP.f("tail_wag_freq") + i * 0.5) * wag_strength + var wag := _bull.global_transform.basis.x \ + * sin(time * DP.f("tail_wag_freq") + i * 0.5) * wag_strength _tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta # Length constraint diff --git a/debug_params.gd b/debug_params.gd index 658d198..5fcb805 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -27,7 +27,7 @@ func _register_all() -> void: _reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0) _reg_f("Movement", "kick_impulse", 8.0, 1.0, 50.0) _reg_f("Movement", "kick_interval", 0.25, 0.05, 1.0, 0.01) - _reg_f("Movement", "kick_vertical", 3.0, 0.0, 15.0) + _reg_f("Movement", "kick_vertical", 0.0, 0.0, 15.0) _reg_f("Movement", "kick_force_var", 0.2, 0.0, 1.0, 0.01) _reg_f("Movement", "kick_spread", 10.0, 0.0, 45.0, 1.0) _reg_f("Movement", "charge_kick_mult", 2.0, 1.0, 5.0) @@ -86,7 +86,7 @@ func _register_all() -> void: _reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1) _reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1) _reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0) - _reg_f("Matador", "mat_ragdoll_impulse", 8.0, 1.0, 50.0) + _reg_f("Matador", "mat_ragdoll_impulse", 3.0, 0.5, 20.0) # ── Debug ───────────────────────────────────────────────────────────────── _reg_b("Debug", "show_collisions", false) @@ -149,8 +149,13 @@ func load_saved() -> void: return for section: String in cfg.get_sections(): for key: String in cfg.get_section_keys(section): - if _params.has(key): - _params[key]["value"] = cfg.get_value(section, key) + if not _params.has(key): + continue + var p: Dictionary = _params[key] + var val: Variant = cfg.get_value(section, key) + if p["type"] == TYPE_FLOAT: + val = clampf(val as float, p["min"] as float, p["max"] as float) + _params[key]["value"] = val func reset_all() -> void: diff --git a/matador.gd b/matador.gd index dc34a8a..d34019f 100644 --- a/matador.gd +++ b/matador.gd @@ -5,18 +5,31 @@ enum State { WANDER, RAGDOLL } var _state: State = State.WANDER var _skeleton: Skeleton3D = null var _sim: PhysicalBoneSimulator3D = null +var _anim_player: AnimationPlayer = null var _wander_target: Vector3 = Vector3.ZERO var _idle_timer: float = 0.0 -@onready var _mesh: Node3D = $matador_v02 -@onready var _hit_area: Area3D = $HitArea +@onready var _mesh: Node3D = $matador_v02 +@onready var _hit_area: Area3D = $HitArea +@onready var _body_col: CollisionShape3D = $CollisionShape3D + +const _WALK_ANIM: StringName = &"Armature|walk" +const _IDLE_ANIM: StringName = &"Armature|idle" func _ready() -> void: add_to_group(&"matador") _skeleton = _find_skeleton(_mesh) + _anim_player = _find_anim_player(_mesh) if _skeleton: _sim = _setup_physical_bones() + if _anim_player: + _ensure_loop(_WALK_ANIM) + if _anim_player.has_animation(_IDLE_ANIM): + _anim_player.play(_IDLE_ANIM) + else: + push_warning("Matador: idle animation not found. Available: %s" % + str(_anim_player.get_animation_list())) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() @@ -34,6 +47,7 @@ func _tick_wander(delta: float) -> void: _idle_timer -= delta velocity.x = move_toward(velocity.x, 0.0, 10.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 10.0 * delta) + _play_anim(_IDLE_ANIM) move_and_slide() return @@ -49,12 +63,13 @@ func _tick_wander(delta: float) -> void: velocity.z = dir.z * spd _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) + _play_anim(_WALK_ANIM) move_and_slide() func _tick_ragdoll(delta: float) -> void: - velocity.x = move_toward(velocity.x, 0.0, 5.0 * delta) - velocity.z = move_toward(velocity.z, 0.0, 5.0 * delta) + velocity.x = move_toward(velocity.x, 0.0, 15.0 * delta) + velocity.z = move_toward(velocity.z, 0.0, 15.0 * delta) move_and_slide() @@ -73,8 +88,17 @@ func _on_body_entered(body: Node3D) -> void: func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: _state = State.RAGDOLL hit_dir.y = 0.0 - hit_dir = hit_dir.normalized() - velocity = Vector3(hit_dir.x, 0.5, hit_dir.z) * bull_speed * 0.4 + hit_dir = hit_dir.normalized() + + # Cap lateral slide so matadors don't cross the arena; fixed upward pop + var lateral: float = minf(bull_speed * 0.2, 4.0) + velocity = Vector3(hit_dir.x * lateral, 2.0, hit_dir.z * lateral) + + # Disable body capsule so a fast bull doesn't bounce off a ragdolling matador + _body_col.disabled = true + + if _anim_player: + _anim_player.stop() if not _sim: return @@ -83,13 +107,35 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: for child: Node in _sim.get_children(): if child is PhysicalBone3D: var scatter := Vector3( - randf_range(-0.3, 0.3), - randf_range(0.2, 0.8), - randf_range(-0.3, 0.3)) + randf_range(-0.4, 0.4), + randf_range(0.1, 0.5), + randf_range(-0.4, 0.4)) (child as PhysicalBone3D).apply_central_impulse( (hit_dir + scatter).normalized() * strength) +# ── Animation helpers ───────────────────────────────────────────────────────── + +func _ensure_loop(anim_name: StringName) -> void: + if _anim_player.has_animation(anim_name): + var anim := _anim_player.get_animation(anim_name) + anim.loop_mode = Animation.LOOP_LINEAR + + +func _play_anim(anim_name: StringName) -> void: + if not _anim_player: + return + if not _anim_player.has_animation(anim_name): + return + if _anim_player.current_animation != anim_name: + _anim_player.play(anim_name) + + +func _reset_bone_poses() -> void: + for i: int in _skeleton.get_bone_count(): + _skeleton.set_bone_pose_rotation(i, Quaternion.IDENTITY) + + func _pick_wander_target() -> void: var radius := DP.f("mat_wander_radius") var angle := randf() * TAU @@ -109,19 +155,25 @@ func _setup_physical_bones() -> PhysicalBoneSimulator3D: sim.active = false _skeleton.add_child(sim) - # [bone_name, sphere_radius] or [bone_name, capsule_radius, capsule_height] const BONES: Array = [ - ["matador", 0.18], - ["chest", 0.13, 0.30], - ["head", 0.14], - ["arm_L", 0.07, 0.22], - ["arm_R", 0.07, 0.22], - ["forearm_L", 0.06, 0.22], - ["forearm_R", 0.06, 0.22], - ["leg_L", 0.10, 0.32], - ["leg_R", 0.10, 0.32], - ["shin_L", 0.08, 0.28], - ["shin_R", 0.08, 0.28], + ["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], ] for entry: Array in BONES: @@ -130,8 +182,9 @@ func _setup_physical_bones() -> PhysicalBoneSimulator3D: continue var pb := PhysicalBone3D.new() pb.bone_name = bname - pb.linear_damp = 0.5 - pb.angular_damp = 0.5 + pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN + pb.linear_damp = 1.0 + pb.angular_damp = 1.0 var cs := CollisionShape3D.new() var use_capsule: bool = entry.size() >= 3 if use_capsule: @@ -159,3 +212,13 @@ func _find_skeleton(node: Node) -> Skeleton3D: if r: return r return null + + +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 diff --git a/player.gd b/player.gd index e5eb304..9786d60 100644 --- a/player.gd +++ b/player.gd @@ -18,8 +18,8 @@ var _kick_timer: float = 0.0 var _was_on_floor: bool = false var _pre_slide_vel_y: float = 0.0 -enum _DustState { NONE, WALK, CHARGE } -var _dust_state: _DustState = _DustState.NONE +enum DustState { NONE, WALK, CHARGE } +var _dust_state: DustState = DustState.NONE func _ready() -> void: @@ -82,7 +82,7 @@ func _setup_hoof_dust() -> void: _hoof_emitters.append(p) -func _set_dust_state(new_state: _DustState) -> void: +func _set_dust_state(new_state: DustState) -> void: if new_state == _dust_state: return _dust_state = new_state @@ -92,15 +92,15 @@ func _set_dust_state(new_state: _DustState) -> void: p.spread = DP.f("dust_spread") p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) match new_state: - _DustState.NONE: + DustState.NONE: p.emitting = false - _DustState.WALK: + DustState.WALK: p.scale_amount_min = DP.f("walk_scale_min") p.scale_amount_max = DP.f("walk_scale_max") p.initial_velocity_min = DP.f("walk_vel_min") p.initial_velocity_max = DP.f("walk_vel_max") p.emitting = true - _DustState.CHARGE: + DustState.CHARGE: p.scale_amount_min = DP.f("charge_scale_min") p.scale_amount_max = DP.f("charge_scale_max") p.initial_velocity_min = DP.f("charge_vel_min") @@ -109,10 +109,10 @@ func _set_dust_state(new_state: _DustState) -> void: func _on_dp_changed(_key: String, _val: Variant) -> void: - if _hoof_emitters.is_empty() or _dust_state == _DustState.NONE: + if _hoof_emitters.is_empty() or _dust_state == DustState.NONE: return var prev := _dust_state - _dust_state = _DustState.NONE + _dust_state = DustState.NONE _set_dust_state(prev) @@ -181,12 +181,14 @@ func _physics_process(delta: float) -> void: # Left click → thrust NE (forward-right diagonal) # Right click → thrust NW (forward-left diagonal) if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: - if Input.is_action_just_pressed(&"thrust_left"): - var ne := (flat_forward + flat_right).normalized() - _apply_thrust(ne if ne.length() > 0.1 else flat_forward) - if Input.is_action_just_pressed(&"thrust_right"): - var nw := (flat_forward - flat_right).normalized() - _apply_thrust(nw if nw.length() > 0.1 else flat_forward) + if Input.is_action_just_pressed(&"thrust_left") or Input.is_action_just_pressed(&"thrust_right"): + var raw := cube_guy.global_transform.basis.z + var bull_fwd := Vector3(raw.x, 0.0, raw.z).normalized() + var bull_right := bull_fwd.cross(Vector3.UP) + if Input.is_action_just_pressed(&"thrust_left"): + _apply_thrust((bull_fwd + bull_right).normalized()) + else: + _apply_thrust((bull_fwd - bull_right).normalized()) # Reset kick timer on landing so the first kick after a bounce is immediate if on_floor and not _was_on_floor and direction: @@ -222,8 +224,8 @@ func _physics_process(delta: float) -> void: # ── Dust ────────────────────────────────────────────────────────────────── if flat_speed > DP.f("dust_charge_spd") and on_floor: - _set_dust_state(_DustState.CHARGE) + _set_dust_state(DustState.CHARGE) elif flat_speed > DP.f("dust_walk_spd") and on_floor: - _set_dust_state(_DustState.WALK) + _set_dust_state(DustState.WALK) else: - _set_dust_state(_DustState.NONE) + _set_dust_state(DustState.NONE)