diff --git a/.gdlintrc b/.gdlintrc index f05a073..5f3aaee 100644 --- a/.gdlintrc +++ b/.gdlintrc @@ -2,4 +2,4 @@ disable: - class-definitions-order - no-else-return max-line-length: 120 -max-file-lines: 1200 +max-file-lines: 3000 diff --git a/Animations/BEAR_FX_SMASH_HIT.tres b/Animations/BEAR_FX_SMASH_HIT.tres index 9eefbbf..370c153 100644 --- a/Animations/BEAR_FX_SMASH_HIT.tres +++ b/Animations/BEAR_FX_SMASH_HIT.tres @@ -1,4 +1,4 @@ -[gd_resource type="Animation" format=3 uid="uid://bolvoh2i867gj"] +[gd_resource type="Animation" format=3 uid="uid://bq71flatfddll"] [resource] resource_name = "Bear_fx_smash_hit" diff --git a/Animations/BEAR_JUMP_SMASH.tres b/Animations/BEAR_JUMP_SMASH.tres index d362099..c222f8a 100644 --- a/Animations/BEAR_JUMP_SMASH.tres +++ b/Animations/BEAR_JUMP_SMASH.tres @@ -1767,20 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R") tracks/251/interp = 1 tracks/251/loop_wrap = true tracks/251/keys = PackedFloat32Array(0, 1, 1.0000002, 0.99999994, 0.99999994, 1, 1, 1, 1, 1.0000001, 1.0333333, 1, 1.1997477, 0.9509697, 0.89452016, 1.0666667, 1, 1.2971677, 0.9081442, 0.8621103, 1.1, 1, 1.1784232, 0.939835, 0.9093719, 1.1333333, 1, 1.0531673, 0.9802574, 0.9695534, 1.1666666, 1, 1.0000002, 1, 1, 1.5666667, 1, 1.0000002, 1, 1) -tracks/252/type = "method" -tracks/252/imported = false -tracks/252/enabled = true -tracks/252/path = NodePath("test_cube") -tracks/252/interp = 1 -tracks/252/loop_wrap = true -tracks/252/keys = { -"times": PackedFloat32Array(1.0333333, 1.5666667), -"transitions": PackedFloat32Array(1, 1), -"values": [{ -"args": [], -"method": &"bear_fx_jump_smash" -}, { -"args": [], -"method": &"mesh_off" -}] -} diff --git a/Animations/BEAR_ONE_TWO_HIT.tres b/Animations/BEAR_ONE_TWO_HIT.tres index 35fee52..e2cb4a1 100644 --- a/Animations/BEAR_ONE_TWO_HIT.tres +++ b/Animations/BEAR_ONE_TWO_HIT.tres @@ -1767,23 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R") tracks/251/interp = 0 tracks/251/loop_wrap = true tracks/251/keys = PackedFloat32Array(0, 1, 1.0000002, 0.99999994, 0.99999994) -tracks/252/type = "method" -tracks/252/imported = false -tracks/252/enabled = true -tracks/252/path = NodePath("test_cube") -tracks/252/interp = 1 -tracks/252/loop_wrap = true -tracks/252/keys = { -"times": PackedFloat32Array(0.73333335, 1.0333333, 1.3), -"transitions": PackedFloat32Array(1, 1, 1), -"values": [{ -"args": [], -"method": &"bear_fx_claw_attack_L" -}, { -"args": [], -"method": &"bear_fx_claw_attack_R" -}, { -"args": [], -"method": &"mesh_off" -}] -} diff --git a/Animations/BEAR_SMASH_HIT.tres b/Animations/BEAR_SMASH_HIT.tres index 86ed88f..9eaf30d 100644 --- a/Animations/BEAR_SMASH_HIT.tres +++ b/Animations/BEAR_SMASH_HIT.tres @@ -1767,20 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R") tracks/251/interp = 1 tracks/251/loop_wrap = true tracks/251/keys = PackedFloat32Array(0, 1, 1.0000002, 0.99999994, 0.99999994) -tracks/252/type = "method" -tracks/252/imported = false -tracks/252/enabled = true -tracks/252/path = NodePath("test_cube") -tracks/252/interp = 1 -tracks/252/loop_wrap = true -tracks/252/keys = { -"times": PackedFloat32Array(0.7, 1.1333333), -"transitions": PackedFloat32Array(1, 1), -"values": [{ -"args": [], -"method": &"bear_fx_smash_hit" -}, { -"args": [], -"method": &"mesh_off" -}] -} diff --git a/Assets/fx/cubes_fx_test.glb b/Assets/fx/cubes_fx_test.glb deleted file mode 100644 index 2c4e4ff..0000000 Binary files a/Assets/fx/cubes_fx_test.glb and /dev/null differ diff --git a/Assets/fx/cubes_fx_test.glb.import b/Assets/fx/cubes_fx_test.glb.import deleted file mode 100644 index 406d2a3..0000000 --- a/Assets/fx/cubes_fx_test.glb.import +++ /dev/null @@ -1,45 +0,0 @@ -[remap] - -importer="scene" -importer_version=1 -type="PackedScene" -uid="uid://2e6fbder27w1" -path="res://.godot/imported/cubes_fx_test.glb-29490ce7011d666c1c4917a1a92679e4.scn" - -[deps] - -source_file="res://Assets/fx/cubes_fx_test.glb" -dest_files=["res://.godot/imported/cubes_fx_test.glb-29490ce7011d666c1c4917a1a92679e4.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 diff --git a/Bear.tscn b/Bear.tscn index da26d32..0a73543 100644 --- a/Bear.tscn +++ b/Bear.tscn @@ -2,9 +2,9 @@ [ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://bear.gd" id="1_beargd"] [ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://Assets/Bear.glb" id="2_bearglb"] -[ext_resource type="Script" uid="uid://bsbed05ifr18s" path="res://bear_test_cube.gd" id="3_rog1v"] -[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://Assets/fx/Bear_FX.glb" id="4_rog1v"] -[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://Materials/M_bear_claw_fx.tres" id="5_bfebr"] +[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://Assets/fx/Bear_FX.glb" id="3_bearfx"] +[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://Materials/M_bear_claw_fx.tres" id="4_clawfx"] +[ext_resource type="PackedScene" path="res://BearAttackHitboxes.tscn" id="5_hitboxes"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] radius = 1.0 @@ -14,12 +14,6 @@ height = 2.4 radius = 1.3 height = 2.7 -[sub_resource type="BoxShape3D" id="BoxShape3D_bfebr"] -size = Vector3(6.2019043, 8.561401, 7.0739746) - -[sub_resource type="BoxShape3D" id="BoxShape3D_rog1v"] -size = Vector3(14.399288, 51.902206, 5.741211) - [node name="Bear" type="CharacterBody3D" unique_id=1573237287] script = ExtResource("1_beargd") @@ -36,37 +30,15 @@ shape = SubResource("CapsuleShape3D_hit") [node name="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")] -[node name="test_cube" type="Node3D" parent="bear_model" unique_id=646988893] -script = ExtResource("3_rog1v") +[node name="Bear_FX" parent="bear_model" unique_id=2070553519 instance=ExtResource("3_bearfx")] -[node name="Bear_FX" parent="bear_model/test_cube" unique_id=1183391245 instance=ExtResource("4_rog1v")] +[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_jump_smash" parent="bear_model/test_cube/Bear_FX" index="0" unique_id=754472676] -visible = false +[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="Bear_fx_smash_hit" parent="bear_model/test_cube/Bear_FX" index="1" unique_id=1454144134] -visible = false - -[node name="RigidBody3D" type="AnimatableBody3D" parent="bear_model/test_cube/Bear_FX/Bear_fx_smash_hit" index="0" unique_id=1764208051] -transform = Transform3D(1.0000001, -1.21071935e-08, -1.8626451e-08, -9.313226e-10, 0.99999994, 7.450581e-09, 3.7252903e-09, -1.4901161e-08, 0.99999994, 0, 0, 0) -collision_layer = 0 -collision_mask = 0 - -[node name="CollisionShape3D2" type="CollisionShape3D" parent="bear_model/test_cube/Bear_FX/Bear_fx_smash_hit/RigidBody3D" unique_id=1779197150] -transform = Transform3D(0.99993557, -0.00749125, -0.010078691, 0.0077760145, 0.999569, 0.028562747, 0.009858355, -0.028639637, 0.9995405, -1.5136471, 0.9093375, 35.591957) -shape = SubResource("BoxShape3D_bfebr") - -[node name="CollisionShape3D" type="CollisionShape3D" parent="bear_model/test_cube/Bear_FX/Bear_fx_smash_hit/RigidBody3D" unique_id=1276552823] -transform = Transform3D(0.9935104, -0.040698394, -0.10621016, -0.108634494, -0.06290151, -0.9920882, 0.03369555, 0.99719334, -0.06691426, -1.6357545, -0.5093044, 12.68803) -shape = SubResource("BoxShape3D_rog1v") - -[node name="Bear_fx_claw_attack_R" parent="bear_model/test_cube/Bear_FX" index="2" unique_id=900948461] -visible = false -surface_material_override/0 = ExtResource("5_bfebr") - -[node name="Bear_fx_claw_attack_L" parent="bear_model/test_cube/Bear_FX" index="3" unique_id=1947239836] -visible = false -surface_material_override/0 = ExtResource("5_bfebr") +[node name="AttackHitboxes" parent="bear_model" instance=ExtResource("5_hitboxes")] [editable path="bear_model"] -[editable path="bear_model/test_cube/Bear_FX"] +[editable path="bear_model/Bear_FX"] diff --git a/BearAttackHitboxes.tscn b/BearAttackHitboxes.tscn new file mode 100644 index 0000000..644506f --- /dev/null +++ b/BearAttackHitboxes.tscn @@ -0,0 +1,70 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://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") diff --git a/CLAUDE.md b/CLAUDE.md index 2388236..7a4685e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,9 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen | `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. Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section | +| `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) | @@ -106,6 +108,13 @@ godot --headless --script tests/performance_test.gd # frame-budget regression 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 diff --git a/Spear.tscn b/Spear.tscn new file mode 100644 index 0000000..ca98c29 --- /dev/null +++ b/Spear.tscn @@ -0,0 +1,15 @@ +[gd_scene format=3 uid="uid://c8spearwrap01"] + +[ext_resource type="PackedScene" uid="uid://jepc74w8opbu" path="res://Assets/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") diff --git a/Sword.tscn b/Sword.tscn new file mode 100644 index 0000000..9084c24 --- /dev/null +++ b/Sword.tscn @@ -0,0 +1,20 @@ +[gd_scene format=3 uid="uid://c8swordwrap01"] + +[ext_resource type="PackedScene" uid="uid://xyvank512l5n" path="res://Assets/sword.glb" id="1_swordglb"] +[ext_resource type="Script" path="res://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") diff --git a/bear.gd b/bear.gd index e2493eb..5b0ec7b 100644 --- a/bear.gd +++ b/bear.gd @@ -26,7 +26,25 @@ var _phase: Phase = Phase.WINDUP var _bull: CharacterBody3D = null var _skeleton: Skeleton3D = null var _anim_player: AnimationPlayer = null +# Separate player baked into Assets/fx/Bear_FX.glb: each clip scales one FX mesh up from 0 +# and back at its strike frame (self-hiding), so bear.gd just fires the matching clip. +var _fx_player: AnimationPlayer = null +var _fx_root: Node = null +# Shaped attack colliders live in BearAttackHitboxes.tscn (under bear_model, so they track the +# bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash. +# Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips +# "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the +# knock-up shaping, activates monitoring, and sweeps for overlaps while the clip runs. +var _leap_hitbox: Hitbox = null +var _smash_hitbox: Hitbox = null +var _hitbox_anim: AnimationPlayer = null +var _active_hitbox: Hitbox = null var _blood_burst: CPUParticles3D = null +# Bull-ram tracking: the bull's position last frame (to sweep its travel) and whether it was mid-ram +# last frame (edge-trigger so one charge = one wound, re-arming when the bull breaks off). +var _bull_prev_pos: Vector3 = Vector3.ZERO +var _bull_prev_seen: bool = false +var _bull_ramming: bool = false var _hp: int = 10 var _enraged: bool = false @@ -91,6 +109,13 @@ func _ready() -> void: health_changed.emit(_hp, _hp) _skeleton = _find_skeleton(_mesh) _anim_player = _find_anim_player(_mesh) + _fx_root = _mesh.get_node_or_null("Bear_FX") + if _fx_root: + _fx_player = _fx_root.get_node_or_null("AnimationPlayer") as AnimationPlayer + if _fx_player: + # The smash/leap clips end held at full scale (Siim only ramped the claw clips back + # down), so snap each effect back to 0 when its clip finishes — keeps them one-shot. + _fx_player.animation_finished.connect(_on_fx_finished) # Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as # non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning. if OS.has_feature("web"): @@ -111,11 +136,11 @@ func _ready() -> void: for a in [_anim_swipe, _anim_smash, _anim_leap, _anim_death]: _set_no_loop(a) _anim_player.playback_default_blend_time = 0.12 - _hit_area.body_entered.connect(_on_body_entered) _blood_burst = preload("res://blood_burst.gd").new() add_child(_blood_burst) add_child(_dust) _setup_claws() + _setup_attack_hitboxes() _enter_neutral() #take hit flashing @@ -138,7 +163,7 @@ func _hit_flash() -> void: _hit_flash_tween = create_tween() _hit_flash_tween.tween_interval(0.10) _hit_flash_tween.tween_callback(_clear_hit_flash) - + func _make_hit_overlay() -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_color = Color(1.0, 0.05, 0.05, 1.0) @@ -211,6 +236,9 @@ func _physics_process(delta: float) -> void: _acquire_bull() _update_enrage() _stagger_cd = maxf(0.0, _stagger_cd - delta) + _tick_attack_hitbox() + if _bull != null: + _sweep_bull_ram() match _state: State.NEUTRAL: _tick_neutral(delta) @@ -285,37 +313,17 @@ func _decide() -> void: # Short range: claw attack options = [[State.SWIPE, 3.0]] - elif dist <= DP.f("bear_mid_range"): - # Mid range: jump smash or walk closer - options = [ - [State.LEAP, 2.0 if _enraged else 1.5], - [State.STALK, 3.0] - ] - else: - # Long range: smash or prowl + # At distance: overhead smash 1/3 of the time, jump smash 2/3 — both close the gap as + # they land, so the bear slams its way in rather than walking (stalk/prowl). options = [ - [State.SMASH, 3.0 if _enraged else 2.5], - [State.PROWL, 3.0] + [State.SMASH, 1.0], + [State.LEAP, 2.0] ] _start_routine(_weighted_pick(options)) - - if dist <= DP.f("bear_close_range"): - # Short range — claw attack - options = [[State.SWIPE, 3.0]] - - elif dist <= DP.f("bear_mid_range"): - # Mid range — jump smash or stalk closer - options = [[State.LEAP, 2.0 if _enraged else 1.5], [State.STALK, 3.0]] - - else: - # Long range — smash is allowed from farther away - options = [[State.SMASH, 3.0 if _enraged else 2.5], [State.PROWL, 3.0]] - - # A queued combo step only fires if the bull is still roughly in its band, so a chain # doesn't whiff into empty air after the player rolls away. func _range_allows(routine: State) -> bool: @@ -422,9 +430,11 @@ func _tick_swipe(delta: float) -> void: var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001) if _hits_done == 0 and frac >= 0.25: _hits_done = 1 + _play_fx(_claw_fx_clip(0)) _try_hit(DP.f("bear_swipe_reach")) elif _hits_done == 1 and frac >= 0.65: _hits_done = 2 + _play_fx(_claw_fx_clip(1)) _try_hit(DP.f("bear_swipe_reach")) if _phase_timer <= 0.0: _begin_recover(DP.f("bear_swipe_recover")) @@ -458,7 +468,10 @@ func _tick_smash(delta: float) -> void: var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001) if _hits_done == 0 and frac >= 0.4: _hits_done = 1 - _slam(DP.f("bear_smash_radius"), 0.8) + _play_fx(&"Bear_fx_smash_hit") + _slam_dust() + _shake(0.8) + _begin_smash_hitbox() # plays the scene's "smash_travel" clip; swept in _tick_attack_hitbox if _phase_timer <= 0.0: _begin_recover(DP.f("bear_smash_recover")) Phase.RECOVER: @@ -509,7 +522,8 @@ func _tick_leap(delta: float) -> void: elif _leap_airborne or _phase_timer <= 0.0: velocity.x = 0.0 velocity.z = 0.0 - _slam(DP.f("bear_slam_radius"), 0.7) + _play_fx(&"Bear_fx_jump_smash") + _fire_leap_hitbox() _phase = Phase.RECOVER _phase_timer = _scaled(DP.f("bear_leap_recover")) Phase.RECOVER: @@ -585,8 +599,8 @@ func _lunge_and_track(speed: float, delta: float) -> void: _accelerate(to_bull / dist, speed, delta) else: _decelerate(_PLANT_DECEL, delta) - - + + func _lunge_and_track_no_rotation(speed: float, delta: float) -> void: if _bull == null: _decelerate(_PLANT_DECEL, delta) @@ -620,17 +634,83 @@ func _try_hit(reach: float) -> bool: return true -# Radial slam (smash / leap landing): hits any way the bull is, within `radius`, with a -# ground-pound shake and a burst of dust kicked up at the paws. No facing arc — you're -# caught if you're near where it comes down. -func _slam(radius: float, shake: float) -> void: - _slam_dust() - if _bull == null: - _shake(shake) +# Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under +# bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene. +func _setup_attack_hitboxes() -> void: + var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null + if root == null: return - if _flat_dist_to_bull() <= radius: - _bull.call(&"take_sword_hit", "mauled") - _shake(shake) + _leap_hitbox = root.get_node_or_null("LeapSlamHitbox") as Hitbox + _smash_hitbox = root.get_node_or_null("SmashHitbox") as Hitbox + _hitbox_anim = root.get_node_or_null("AnimationPlayer") as AnimationPlayer + if _hitbox_anim != null: + # Advance the shape's travel in the physics step so it stays in lockstep with the sweep. + _hitbox_anim.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS + if _leap_hitbox != null: + _leap_hitbox.deactivate() + if _smash_hitbox != null: + _smash_hitbox.deactivate() + + +# Leap landing: kick up dust + shake, launch the ground-circle collider clip, and arm it so the bull +# is launched from ANYWHERE in the circle (bear_leap_ring_frac 0). Raise ring_frac to restore a +# grounded inner zone that only mauls; the launch zone then starts at that fraction of the radius. +func _fire_leap_hitbox() -> void: + _slam_dust() + _shake(0.7) + if _leap_hitbox == null: + return + _leap_hitbox.knock_up = DP.f("bear_leap_knockup") + _leap_hitbox.knock_up_min_dist = DP.f("bear_leap_ring_frac") * _shape_extent(_leap_hitbox, true) + _leap_hitbox.knock_up_max_dist = 0.0 # binary: full-height launch everywhere it launches + _start_attack_hitbox(_leap_hitbox, &"leap_slam") + + +# Overhead smash: launch the lane clip; knock-up ramps 0→full with distance (further = higher), +# maxing at the lane's authored full length so the shape and the launch curve stay in sync. +func _begin_smash_hitbox() -> void: + if _smash_hitbox == null: + return + _smash_hitbox.knock_up = DP.f("bear_smash_knockup") + _smash_hitbox.knock_up_min_dist = 0.0 + _smash_hitbox.knock_up_max_dist = _shape_extent(_smash_hitbox, false) + _start_attack_hitbox(_smash_hitbox, &"smash_travel") + + +# Activate the hitbox and play its authored motion clip; _tick_attack_hitbox sweeps for overlaps +# each frame while the clip runs, then deactivates when it ends. +func _start_attack_hitbox(hb: Hitbox, clip: StringName) -> void: + if _active_hitbox != null and _active_hitbox != hb: + _active_hitbox.deactivate() + hb.activate() + _active_hitbox = hb + if _hitbox_anim != null and _hitbox_anim.has_animation(clip): + _hitbox_anim.play(clip) + _hitbox_anim.seek(0.0, true) + + +func _tick_attack_hitbox() -> void: + if _active_hitbox == null: + return + _active_hitbox.sweep() + # The clip drives the shape's travel; when it stops, the swing is over — close the hitbox. + if _hitbox_anim == null or not _hitbox_anim.is_playing(): + _active_hitbox.deactivate() + _active_hitbox = null + + +# Full extent (planar) of a hitbox's authored shape, for the knock-up distance math: the circle's +# radius (cylinder) or the lane's length (box half-size along local +Z, forward from the bear). +func _shape_extent(hb: Hitbox, radial: bool) -> float: + var cs := hb.get_node_or_null("CollisionShape3D") as CollisionShape3D + if cs == null: + return 0.0 + if radial and cs.shape is CylinderShape3D: + return (cs.shape as CylinderShape3D).radius + if not radial and cs.shape is BoxShape3D: + # Box spans [0, size.z] because it's offset forward by half its length. + return (cs.shape as BoxShape3D).size.z + return 0.0 # Kick up a one-shot ring of dust at the bear's feet where the slam lands. @@ -641,7 +721,7 @@ func _slam_dust() -> void: _dust.restart() _dust.emitting = true -" + # Tan ground-impact dust: a fast, wide, low burst that arcs up and settles — built in code # so the Bear scene needs no extra particle node. func _make_slam_dust() -> CPUParticles3D: @@ -689,7 +769,6 @@ func _make_slam_dust() -> CPUParticles3D: p.scale_amount_min = 0.6 p.scale_amount_max = 1.4 return p -" # A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so @@ -755,18 +834,44 @@ func apply_ability_hit(hit_dir: Vector3, strength: float, _up_boost: float = 0.0 _take_hit(hit_dir, strength) -# Ramming: only a real charge from the bull wounds the bear; a gentle nudge is shrugged -# off. The bear never damages the bull by colliding — only its swings/slams do. -func _on_body_entered(body: Node3D) -> void: - if _state == State.DEAD or not body.is_in_group(&"player"): +# Only a horns-first charge wounds the bear. Swept (prev → now) so a tunnelling or point-blank charge +# still lands where body_entered wouldn't; edge-triggered (_bull_ramming) so one charge = one wound. +func _sweep_bull_ram() -> void: + var now := _bull.global_position + var seg_from := _bull_prev_pos if _bull_prev_seen else now + _bull_prev_pos = now + _bull_prev_seen = true + if _state == State.DEAD: + _bull_ramming = false return - var player := body as CharacterBody3D - if player.velocity.length() < DP.f("bear_hit_threshold"): - return - var flat := Vector3(player.velocity.x, 0.0, player.velocity.z) - var dir := flat.normalized() if flat.length() > 0.5 else \ - (global_position - player.global_position).normalized() - _take_hit(dir, player.velocity.length()) + var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + var speed := vel.length() + var ramming := false + if speed >= DP.f("bear_hit_threshold") and _horns_first(seg_from): + var a := Vector3(seg_from.x, 0.0, seg_from.z) + var b := Vector3(now.x, 0.0, now.z) + var p := Vector3(global_position.x, 0.0, global_position.z) + if p.distance_to(Geometry3D.get_closest_point_to_segment(p, a, b)) <= DP.f("bear_ram_reach"): + ramming = true + if ramming and not _bull_ramming: + var dir := vel.normalized() if speed > 0.5 else (global_position - now).normalized() + _take_hit(dir, speed) + _bull_ramming = ramming + + +# Horns-first gate: wound only when the bull's velocity points within bull_gore_arc of the direction +# from `from` to the bear. The dash ability skips the gate. +func _horns_first(from: Vector3) -> bool: + if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"): + return true # the dash ability is a committed lunge — it rams on contact, no angle gate + var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + if vel.length() < 0.5: + return false + var to_bear := global_position - from + to_bear.y = 0.0 + if to_bear.length() < 0.01: + return true + return vel.normalized().dot(to_bear.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc"))) func _take_hit(hit_dir: Vector3, strength: float) -> void: @@ -939,6 +1044,43 @@ func _play(anim_name: StringName) -> void: _anim_player.play(anim_name) +# The two swipe hits map to the L/R claw-slash clips; bear_fx_claw_swap flips the order in +# case the baked left/right clips read reversed in-game. hit 0 = first swing, 1 = second. +func _claw_fx_clip(hit: int) -> StringName: + var swap := DP.f("bear_fx_claw_swap") >= 0.5 + var first_left := not swap + if (hit == 0) == first_left: + return &"Bear_fx_claw_attack_L" + return &"Bear_fx_claw_attack_R" + + +# Fire a one-shot FX clip from the Bear_FX glb (crater / slash / debris). Each clip scales +# its mesh up at the strike and back to 0 on its own, so this is a plain fire-and-forget. +func _play_fx(clip: StringName) -> void: + if _fx_player == null or not _fx_player.has_animation(clip): + return + if _fx_root is Node3D: + (_fx_root as Node3D).rotation.y = deg_to_rad(DP.f("bear_fx_yaw")) + _fx_player.play(clip) + _fx_player.seek(0.0, true) + + +func _on_fx_finished(anim: StringName) -> void: + if _fx_root == null: + return + var mesh := _fx_root.get_node_or_null(NodePath(anim)) as Node3D + if mesh == null: + return + # The crater clip only ramps up (it ends held at full scale with no shrink), so leave it on + # the ground for a beat after the slam, then hide it. The others self-clear immediately. + if anim == &"Bear_fx_jump_smash": + var linger := DP.f("bear_fx_crater_linger") + if linger > 0.0: + get_tree().create_timer(linger).timeout.connect(func() -> void: mesh.scale = Vector3.ZERO) + return + mesh.scale = Vector3.ZERO + + # Restart a one-shot attack clip from its first frame at `speed` (even if already current). func _play_attack(anim_name: StringName, speed: float) -> void: if _anim_player == null or anim_name == &"": diff --git a/bear_test_cube.gd b/bear_test_cube.gd deleted file mode 100644 index 34e445b..0000000 --- a/bear_test_cube.gd +++ /dev/null @@ -1,35 +0,0 @@ -extends Node3D - -# Get a reference to the Bear_FX AnimationPlayer node -@onready var fx_player: AnimationPlayer = $Bear_FX/AnimationPlayer - -# Preloads the material into memory before the scene even instantiates -@onready var claw_fx_material: StandardMaterial3D = preload("res://Materials/M_bear_claw_fx.tres") - -func _ready() -> void: - # Apply it instantly to a mesh as soon as the level starts - $Bear_FX/Bear_fx_claw_attack_L.material_override = claw_fx_material - $Bear_FX/Bear_fx_claw_attack_R.material_override = claw_fx_material - - -func bear_fx_jump_smash(): - $Bear_FX/Bear_fx_jump_smash.visible = true - fx_player.play("Bear_fx_jump_smash") - -func bear_fx_claw_attack_R(): - $Bear_FX/Bear_fx_claw_attack_R.visible = true - fx_player.play("Bear_fx_claw_attack_R") - -func bear_fx_claw_attack_L(): - $Bear_FX/Bear_fx_claw_attack_L.visible = true - fx_player.play("Bear_fx_claw_attack_L") - -func bear_fx_smash_hit(): - $Bear_FX/Bear_fx_smash_hit.visible = true - fx_player.play("Bear_fx_smash_hit") - -func mesh_off(): - $Bear_FX/Bear_fx_smash_hit.visible = false - $Bear_FX/Bear_fx_claw_attack_L.visible = false - $Bear_FX/Bear_fx_claw_attack_R.visible = false - $Bear_FX/Bear_fx_jump_smash.visible = false diff --git a/bear_test_cube.gd.uid b/bear_test_cube.gd.uid deleted file mode 100644 index dee416a..0000000 --- a/bear_test_cube.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bsbed05ifr18s diff --git a/debug_draw.gd b/debug_draw.gd index 15709ed..1076cdd 100644 --- a/debug_draw.gd +++ b/debug_draw.gd @@ -9,8 +9,11 @@ extends Node ## bodies get picked up within one interval. ## ## Toggle from the console: -## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false) -## show_hitboxes also surfaces the bull's and bear's body capsules, not just Area3Ds +## show_hitboxes · show_attacks · show_bones · show_raycasts (true / false) +## show_hitboxes — the combatants' hurt volumes: each HitArea + the player/enemy body +## capsules, drawn as calm translucent-blue volumes (what abilities/blades hit) +## show_attacks — the attack hitboxes (bear claw/smash/leap, matador blade, thrown +## spear), filled translucent red and shown ONLY while the swing is actually live ## show_states — floating AI-state tags + velocity vectors over each matador ## show_animation_name — floating current-clip tag over every AnimationPlayer host ## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally @@ -21,13 +24,14 @@ extends Node const REBUILD_INTERVAL := 0.5 const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame -const COLOR_BODY := Color(0.30, 1.0, 0.45) # collision wireframes (green) -const COLOR_AREA := Color(0.0, 0.9, 1.0) # hit-area volumes (cyan, translucent) +const COLOR_AREA := Color(0.0, 0.9, 1.0) # combatant hurt volumes (cyan, translucent) +const COLOR_ATTACK := Color(1.0, 0.30, 0.15) # attack hitboxes (red, translucent, overlaid) const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta) const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber) const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue) const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5) -const AREA_ALPHA := 0.25 +const AREA_ALPHA := 0.25 +const ATTACK_ALPHA := 0.32 const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin const GRID_STEP := 1.0 # metres between grid lines @@ -81,6 +85,7 @@ func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void: func _process(delta: float) -> void: _reconcile(delta) + _pulse_attacks() if DP.b("show_bones"): _draw_bones() if DP.b("show_states"): @@ -360,11 +365,11 @@ func _si(n: int) -> String: # ── Collision / hit-area overlays (incremental) ──────────────────────────────── func _reconcile(delta: float) -> void: - var col := DP.b("show_collisions") var hit := DP.b("show_hitboxes") + var atk := DP.b("show_attacks") var bone := DP.b("show_bones") var anim := DP.b("show_animation_name") - if not (col or hit or bone or anim): + if not (hit or atk or bone or anim): if not _overlays.is_empty(): _clear_overlays() _skeletons.clear() @@ -372,7 +377,7 @@ func _reconcile(delta: float) -> void: _prev_flags = "" return - var flags := "%d%d%d%d" % [int(col), int(hit), int(bone), int(anim)] + var flags := "%d%d%d%d" % [int(hit), int(atk), int(bone), int(anim)] _reconcile_t -= delta if flags != _prev_flags: # a toggle just changed — refresh immediately _prev_flags = flags @@ -386,7 +391,7 @@ func _reconcile(delta: float) -> void: var wanted: Dictionary = {} var scene := get_tree().current_scene if scene != null: - _collect(scene, col, hit, bone, anim, wanted) + _collect(scene, hit, atk, bone, anim, wanted) # Drop overlays whose shape is gone or no longer wanted. The overlay is a child of # the collision shape, so it's freed with it — hold it as Variant, since assigning a @@ -402,10 +407,11 @@ func _reconcile(delta: float) -> void: for id: int in wanted: if not _overlays.has(id): var entry: Array = wanted[id] - _overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as bool) + _overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as String) -func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void: +func _collect(node: Node, hit: bool, atk: bool, bone: bool, anim: bool, + wanted: Dictionary) -> void: # The audience ("public") is ambient decoration, not a debug target — skip the whole # crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays. if node.is_in_group(&"public"): @@ -417,18 +423,28 @@ func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: if node is CollisionShape3D: var cs := node as CollisionShape3D if cs.shape != null and not cs.disabled and _drawable(cs.shape): - var parent := cs.get_parent() - var is_area := parent is Area3D - # Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for - # the skeleton; otherwise every corpse floods the view with capsules. - var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D) - # The bull's and bear's body capsules are combat volumes too, so they also - # surface under show_hitboxes (not just show_collisions). - var is_combatant := is_body and (parent.is_in_group(&"player") or parent.is_in_group(&"bear")) - if (is_area and hit) or (is_body and col) or (is_combatant and hit): - wanted[cs.get_instance_id()] = [cs, is_area] + var kind := _classify(cs.get_parent()) + var show := (kind == "attack" and atk) or (kind == "hitbox" and hit) + if show: + wanted[cs.get_instance_id()] = [cs, kind] for child in node.get_children(): - _collect(child, col, hit, bone, anim, wanted) + _collect(child, hit, atk, bone, anim, wanted) + + +# Sort a shape's owning body/area into an overlay bucket, or "" to skip. +# attack — a swing volume (a Hitbox area, or a thrown-spear projectile body) +# hitbox — a combatant's hurt volume: its HitArea, or its own body capsule +func _classify(parent: Node) -> String: + if parent is Hitbox or parent is SpearProjectile: + return "attack" + if parent is Area3D: + return "hitbox" + # Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for the + # skeleton; otherwise every corpse floods the view with capsules. + if parent is PhysicsBody3D and not (parent is PhysicalBone3D): + if parent.is_in_group(&"player") or parent.is_in_group(&"matador"): + return "hitbox" + return "" # Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena @@ -437,20 +453,35 @@ func _drawable(shape: Shape3D) -> bool: return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D) -func _attach(cs: CollisionShape3D, is_area: bool) -> MeshInstance3D: +func _attach(cs: CollisionShape3D, kind: String) -> MeshInstance3D: var mi := MeshInstance3D.new() - if is_area: - var solid := _solid_mesh(cs.shape) - mi.mesh = solid if solid != null else cs.shape.get_debug_mesh() - mi.material_override = _translucent_material(COLOR_AREA) + var solid := _solid_mesh(cs.shape) + mi.mesh = solid if solid != null else cs.shape.get_debug_mesh() + if kind == "attack": + # Overlaid so a brief swing volume reads clearly on top of the action; its + # visibility is pulsed per-frame in _pulse_attacks so it only shows while live. + mi.material_override = _translucent_material(COLOR_ATTACK, ATTACK_ALPHA, true) else: - mi.mesh = cs.shape.get_debug_mesh() - mi.material_override = _wire_material(COLOR_BODY) + # The calm translucent-blue volume — depth-tested so it sits in the world. + mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false) mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF cs.add_child(mi) return mi +# Attack overlays exist for as long as show_attacks is on, but a swing hitbox is only a +# hazard while its Hitbox is monitoring — so show each attack volume only when it's live. +# (A thrown spear has no monitoring gate; it stays visible for its whole flight.) +func _pulse_attacks() -> void: + for id: int in _overlays: + var mi: Variant = _overlays[id] + if not is_instance_valid(mi): + continue + var owner := (mi as Node).get_parent().get_parent() + if owner is Hitbox: + (mi as MeshInstance3D).visible = (owner as Area3D).monitoring + + func _clear_overlays() -> void: for id: int in _overlays: var mi: Variant = _overlays[id] @@ -495,19 +526,12 @@ func _line_material() -> StandardMaterial3D: return mat -func _wire_material(color: Color) -> StandardMaterial3D: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.albedo_color = color - mat.no_depth_test = true - return mat - - -func _translucent_material(color: Color) -> StandardMaterial3D: +func _translucent_material(color: Color, alpha: float, overlay: bool) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = Color(color.r, color.g, color.b, AREA_ALPHA) + mat.albedo_color = Color(color.r, color.g, color.b, alpha) mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED + mat.no_depth_test = overlay return mat diff --git a/debug_params.gd b/debug_params.gd index 828e8bd..6e0a3c5 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -39,6 +39,10 @@ func _register_all() -> void: # bull_hit_iframes seconds of invulnerability so a single pass can't drain it. _reg_f("Bull", "bull_max_hp", 10.0, 1.0, 100.0, 1.0) _reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1) + # Half-angle (deg) of the bull's horns-first cone: running into an enemy only wounds it when the + # bull's velocity points within this of the enemy (not a sideways brush). 90 = any contact at speed. + # (The dash ability bypasses this.) + _reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0) # ── Dust speed thresholds ───────────────────────────────────────────────── _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) @@ -114,6 +118,9 @@ func _register_all() -> void: _reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.0, 0.1) _reg_f("Matador", "mat_idle_max", 4.0, 0.5, 10.0, 0.1) _reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0) + # Planar reach at which the bull's swept charge path gores a matador (~bull + HitArea radii). + # Raise for a more forgiving charge, lower for a more precise one. + _reg_f("Matador", "mat_charge_gore_radius", 1.2, 0.5, 3.0, 0.05) _reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0) # Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its # shape; higher angular damp settles the flail instead of flopping flat. @@ -136,21 +143,42 @@ func _register_all() -> void: _reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0) _reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0) _reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1) - _reg_f("Matador", "mat_attack_min_dist", 4.2, 0.5, 6.0, 0.1) - # Forward drive during the opening of a melee swing — turns the stationary stab - # into an estocada lunge so the blade covers ground toward the bull. - _reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0) - _reg_f("Matador", "mat_lunge_time", 0.22, 0.0, 0.6, 0.01) - # Reach of the reach-based stab (the reliable melee hit) and its re-strike gap. - # The bull dies if it comes inside mat_stab_reach of a matador that's attacking, - # bracing, or passing with a drawn blade — closing in is genuinely deadly. - _reg_f("Matador", "mat_stab_reach", 1.7, 0.5, 4.0, 0.1) + # Reach of the reach-based melee hit and its re-strike gap. This now governs ONLY the sidestep + # PASS (mat charges the bull past with the blade out): a fast lateral sweep where the thin blade + # Area would tunnel, so a center-to-center distance check is the reliable gore. The stationary + # ATTACK stab no longer uses this — it drives off the sword's BladeHitbox collider (see below). + _reg_f("Matador", "mat_stab_reach", 2.0, 0.5, 4.0, 0.1) + # Distance the matador plants at to swing. The ATTACK stab damages off the animated BladeHitbox, + # whose thrust reaches ~3.75 m at full extension — so this MUST stay under that or the blade never + # touches the bull and the stab whiffs. 3.5 m keeps the matador clear of the bull's ~1.8 m-long + # body while the thrust still lands. Raise toward 3.75 for more gap, lower to stab from closer. + _reg_f("Matador", "mat_stab_standoff", 3.5, 0.5, 4.0, 0.1) + # Fraction through the Attack clip at which the blade's collider is armed. The clip is guard → + # wind-up (blade pulled back, phase ~0.3-0.5) → thrust (blade drives out to ~3.5 m, phase ~0.55+). + # Arming at the thrust keeps the bull from being gored during the wind-up (blade not yet extended) + # or the guard pose. The BladeHitbox overlap still decides the exact hit; this just gates timing. + _reg_f("Matador", "mat_stab_strike_at", 0.5, 0.0, 1.0, 0.05) + # Chance, each time a stab finishes, that the matador breaks off into something else (give ground, + # circle, or throw) instead of immediately stabbing again — keeps it from drilling the same thrust + # on repeat. 0 = always re-stab if in range, 1 = always break off after every stab. + _reg_f("Matador", "mat_stab_followup_chance", 0.6, 0.0, 1.0, 0.05) + # Beat after every stab where the brain won't re-commit to ATTACK, so it can't machine-gun the + # bull — it circles/gives ground instead. 0 = stab as fast as range allows. + _reg_f("Matador", "mat_stab_recovery", 0.9, 0.0, 3.0, 0.1) + # Chance, when a stab actually CONNECTS, that the matador breaks into a crowd-pleasing taunt and + # gives ground instead of pressing — milking the hit rather than spamming. High by design. + _reg_f("Matador", "mat_taunt_after_hit", 0.85, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_stab_cooldown", 0.6, 0.1, 3.0, 0.1) # Half-angle (deg) the matador must be facing the bull within for a stab to land. # Stops "sword's off to the side but I still died" — you're only gored when the # blade is actually presented at you. _reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0) + # Chance a matador rolls clear (survives) instead of ragdolling when the bull's charge connects. _reg_f("Matador", "mat_roll_chance", 0.8, 0.0, 1.0, 0.05) + # How much a committed charge pierces the survival roll: roll chance fades toward + # mat_roll_chance*(1-this) as bull speed climbs to mat_charge_speed. 1 = full-speed charge always + # gores. (A dash connect ignores the roll entirely.) + _reg_f("Matador", "mat_charge_pierce", 0.8, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05) # Playback rate of the Draw_weapon clip used for both drawing and sheathing — # higher = snappier unholster/holster. @@ -229,10 +257,22 @@ func _register_all() -> void: _reg_f("Bear", "bear_swipe_windup", 0.3, 0.05, 2.0, 0.05) _reg_f("Bear", "bear_swipe_recover", 0.35, 0.05, 3.0, 0.05) _reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1) + # Which claw-slash FX plays on the first vs second swipe hit — the L/R clips are easy to + # mix up, so 0 = first hit L then R, 1 = swap them. Purely cosmetic (no gameplay effect). + _reg_f("Bear", "bear_fx_claw_swap", 0.0, 0.0, 1.0, 1.0) + # Live yaw (deg) applied to the whole Bear_FX node before each effect fires, so the claw + # rake / debris can be spun to line up with the bear's facing by eye. Bake the good value. + _reg_f("Bear", "bear_fx_yaw", 0.0, -180.0, 180.0, 5.0) + # The baked crater clip only pops up (no shrink), so hold it on the ground this long after a + # leap slam before hiding it — otherwise it flashes for a single frame and reads as invisible. + _reg_f("Bear", "bear_fx_crater_linger", 0.8, 0.0, 3.0, 0.1) # Overhead Smash (SMASH_HIT) — slower tell, biggest punish window, AoE on impact. _reg_f("Bear", "bear_smash_windup", 0.6, 0.1, 3.0, 0.05) _reg_f("Bear", "bear_smash_recover", 0.75, 0.1, 4.0, 0.05) - _reg_f("Bear", "bear_smash_radius", 4.2, 1.0, 8.0, 0.1) + # The overhead smash's hitbox is a forward box lane whose size + travel are authored in + # BearAttackHitboxes.tscn's AnimationPlayer ("smash_travel"). Here we only set the upward + # pop the bull takes off its feet at the far end (it ramps 0→this with hit distance). + _reg_f("Bear", "bear_smash_knockup", 6.0, 0.0, 16.0, 0.5) # Leap Slam (JUMP_SMASH, 47 frames @30fps) — timed to the clip by FRAME: it plays the # crouch grounded, springs at the takeoff frame, and the arc is solved so the paws hit # ground exactly on the impact frame (25), leaving the tail frames to settle on the @@ -243,7 +283,10 @@ func _register_all() -> void: _reg_f("Bear", "bear_leap_height", 2.6, 0.5, 8.0, 0.1) _reg_f("Bear", "bear_leap_recover", 0.35, 0.0, 4.0, 0.05) _reg_f("Bear", "bear_leap_speed", 14.0, 4.0, 30.0, 0.5) - _reg_f("Bear", "bear_slam_radius", 4.0, 1.0, 8.0, 0.1) + # Where the leap-slam launch zone starts, as a fraction of the circle radius. 0 = launch anywhere + # inside; raise to carve a grounded inner zone that only mauls (0.75 = only the outer quarter ring). + _reg_f("Bear", "bear_leap_ring_frac", 0.0, 0.0, 1.0, 0.05) + _reg_f("Bear", "bear_leap_knockup", 7.0, 0.0, 16.0, 0.5) # Stalk (WALK_ON2LEGS) — how long it will keep walking the bull down before re-deciding. _reg_f("Bear", "bear_stalk_time", 2.5, 0.0, 6.0, 0.1) # Odds a close combo chains a follow-up smash after the swipe (phase 2 always chains). @@ -252,6 +295,9 @@ func _register_all() -> void: # shrugged off, only a real charge or an ability wounds it. (The bear itself never # damages the bull by colliding — only its swings and slams hurt.) _reg_f("Bear", "bear_hit_threshold", 6.0, 1.0, 30.0, 0.5) + # Planar reach at which a horns-first bull charge wounds the bear (~bear body radius + bull reach). + # The bull's swept path is tested against this, so a tunnelling or point-blank charge still lands. + _reg_f("Bear", "bear_ram_reach", 2.1, 0.5, 5.0, 0.1) # How long (s) the bear is rocked back and can't act after taking a hit. _reg_f("Bear", "bear_stagger_time", 0.5, 0.0, 2.0, 0.05) # Poise: after a stagger the bear shrugs off staggers for this long (hits still wound @@ -345,7 +391,6 @@ func _register_all() -> void: _reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop _reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop # ── Debug overlays (see debug_draw.gd) ─────────────────────────────────── - _reg_b("Debug", "show_collisions", false) # PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect): # screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a # rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the @@ -359,6 +404,7 @@ func _register_all() -> void: _reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1) _reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05) _reg_b("Debug", "show_hitboxes", false) + _reg_b("Debug", "show_attacks", false) _reg_b("Debug", "show_bones", false) _reg_b("Debug", "show_raycasts", false) _reg_b("Debug", "show_tail", false) diff --git a/hitbox.gd b/hitbox.gd new file mode 100644 index 0000000..da0dd59 --- /dev/null +++ b/hitbox.gd @@ -0,0 +1,101 @@ +@tool +extends Area3D +class_name Hitbox +## A designer-placeable attack hitbox: drop this into a scene, give it a CollisionShape3D +## child, and drag the shape to size/position the attack's reach in the editor. The shape +## itself decides what got hit — no distance/facing math in code. +## +## Inactive by default; the owning script calls activate() for the swing's active window and +## deactivate() when it ends. Each activation only hits a given body once (the bull's own +## i-frames also collapse a flurry, but this keeps a single swing from re-triggering). + +const PhysicsLayers = preload("res://physics_layers.gd") + +## How the hit is tagged on the bull's take_sword_hit ("mauled" / "gored" / …). +@export var damage_cause: String = "mauled" +## Upward launch (m/s) applied to the bull on hit — e.g. an overhead smash that pops it up. +@export var knock_up: float = 0.0 +## Knock-up distance shaping, measured planar (XZ) from this hitbox's own origin to the body: +## below `knock_up_min_dist` no pop is applied (leap slam: only the outer ring launches). If +## `knock_up_max_dist` > min, the pop ramps 0→`knock_up` from min to max (overhead smash: the +## further out the hit lands, the higher the bull flies); at/again 0 it's full past the min. +@export var knock_up_min_dist: float = 0.0 +@export var knock_up_max_dist: float = 0.0 + +var _active: bool = false +var _hit_this_swing: Dictionary = {} +# Whether the most recent active window connected. Cleared on activate(), NOT deactivate(), so the +# owner can read it after the window closes (e.g. a landed stab breaks off to taunt). +var _did_hit: bool = false + + +func _ready() -> void: + # Detect the bull's body (layer BULL) only; never collide or push physics. + collision_layer = 0 + collision_mask = PhysicsLayers.BULL + monitorable = false + monitoring = false + if Engine.is_editor_hint(): + return + body_entered.connect(_on_body_entered) + + +func activate() -> void: + if _active: + return + _active = true + _hit_this_swing.clear() + _did_hit = false + monitoring = true + + +func deactivate() -> void: + _active = false + monitoring = false + + +# Did the window that just closed connect? Valid until the next activate() clears it. +func did_hit() -> bool: + return _did_hit + + +func _on_body_entered(body: Node3D) -> void: + _apply_hit(body) + + +# For AoE slams the bull is usually already standing inside the shape when it activates, so +# body_entered never fires. The owner calls sweep() each active frame to catch current overlaps +# (still deduped per activation, so the bull is only hit once per slam). +func sweep() -> void: + if not _active: + return + for body in get_overlapping_bodies(): + _apply_hit(body) + + +func _apply_hit(body: Node3D) -> void: + if not _active or not body.is_in_group(&"player"): + return + if _hit_this_swing.has(body): + return + _hit_this_swing[body] = true + _did_hit = true + body.call(&"take_sword_hit", damage_cause) + if knock_up > 0.0: + var v := _knock_up_for(body) + if v > 0.0: + body.call(&"apply_knock_up", v) + + +# Scale the pop by how far the body is from this hitbox's origin (planar). See the export docs. +func _knock_up_for(body: Node3D) -> float: + if knock_up_min_dist <= 0.0 and knock_up_max_dist <= 0.0: + return knock_up + var to := body.global_position - global_position + var d := Vector2(to.x, to.z).length() + if d < knock_up_min_dist: + return 0.0 + if knock_up_max_dist > knock_up_min_dist: + var t := clampf((d - knock_up_min_dist) / (knock_up_max_dist - knock_up_min_dist), 0.0, 1.0) + return knock_up * t + return knock_up diff --git a/hitbox.gd.uid b/hitbox.gd.uid new file mode 100644 index 0000000..5998973 --- /dev/null +++ b/hitbox.gd.uid @@ -0,0 +1 @@ +uid://dlwjiw42q6b5f diff --git a/matador.gd b/matador.gd index 0aaec8e..4905aab 100644 --- a/matador.gd +++ b/matador.gd @@ -1,13 +1,9 @@ extends CharacterBody3D -# WANDER → bull outside flee range, roaming freely -# FLEE → bull within range, matador backs away (raycast-steered around walls) -# BRACE → charge detected: plant feet, face the bull (cite phase) -# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase) -# ATTACK → post-dodge or mid-range: chase and strike -# ROLL → hit by bull but roll chance fires: survive and recover -# THROW → planted, winding up an overhand throw, then releasing the sword -# RAGDOLL → hit by bull, no roll chance +# WANDER → roam/taunt outside flee range. FLEE → bull close: back away (raycast-steered). +# BRACE → charge detected: plant + face (cite). SIDESTEP → bull commits: lateral pass (pase). +# ATTACK → chase and strike. ROLL → hit but rolled: survive. THROW → wind up + hurl the spear. +# RAGDOLL → hit, no roll. enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } # The matador's high-level *want*, chosen by a weighted utility vote (see @@ -32,7 +28,9 @@ var _dodge_cd: float = 0.0 var _will_dodge: bool = true var _attack_timer: float = 0.0 var _swing_timer: float = 0.0 -var _lunge_timer: float = 0.0 +var _attack_cd: float = 0.0 +var _bull_prev_pos: Vector3 = Vector3.ZERO +var _bull_prev_seen: bool = false var _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _is_spawning: bool = false @@ -52,7 +50,7 @@ var _drawing: bool = false var _sheathing: bool = false var _draw_timer: float = 0.0 var _draw_len: float = 0.0 -var _sword_blade_area: Area3D = null +var _sword_blade_area: Hitbox = null var _spear_node: Node3D = null var _blade_hit_cd: float = 0.0 var _death_player: AudioStreamPlayer = null @@ -76,8 +74,9 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"] # reparents from holster to hand at this point in the animation. const _DRAW_GRAB_AT: float = 0.55 -const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") -const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb") +# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn. +const _SWORD_SCENE: PackedScene = preload("res://Sword.tscn") +const _SPEAR_SCENE: PackedScene = preload("res://Spear.tscn") const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc @@ -168,6 +167,9 @@ func _physics_process(delta: float) -> void: _dodge_cd = maxf(0.0, _dodge_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) + _attack_cd = maxf(0.0, _attack_cd - delta) + if _bull != null: + _sweep_bull_gore() if _state != State.RAGDOLL and _bull != null: _update_ai_state(delta) @@ -185,9 +187,8 @@ func _physics_process(delta: float) -> void: _advance_draw(delta) -# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a -# throw wind-up) run to completion on their own timers; from the free states the -# matador re-scores its options every mat_decide_interval and acts on the winner. +# Utility-driven brain. Committed states (brace / pass / roll / throw wind-up) run to completion on +# their own timers; from the free states the matador re-scores every mat_decide_interval. func _update_ai_state(delta: float) -> void: if _state == State.BRACE or _state == State.SIDESTEP \ or _state == State.ROLL or _state == State.THROW: @@ -204,9 +205,8 @@ func _update_ai_state(delta: float) -> void: _enter_intent(intent) -# Score each intent from weighted heuristics and return the winner. Weights (the -# mat_w_* params) shape the personality; a small commit bonus on the current intent -# stops it flip-flopping between two near-tied options. +# Score each intent from weighted mat_w_* heuristics and return the winner; a small commit bonus on +# the current intent stops it flip-flopping between two near-tied options. func _choose_intent() -> Intent: var dist := global_position.distance_to(_bull.global_position) var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) @@ -227,6 +227,9 @@ func _choose_intent() -> Intent: # A matador won't commit to a strike it can't bail out of, so a spent dodge # cooldown makes attacking (and dodging outright) less attractive. var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35 + # The post-stab recovery beat (mat_stab_recovery) kills ATTACK outright — the matador circles + # or gives ground between thrusts rather than machine-gunning the bull; DODGE/FLEE stay live. + var attack_ready := 0.0 if _attack_cd > 0.0 else 1.0 var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") @@ -247,7 +250,7 @@ func _choose_intent() -> Intent: # Press the attack; ease off a touch when a charge is barrelling in. Supporting # matadors barely press — they yield the pass to whoever's engaging. Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ - * (1.0 - 0.55 * danger) * dodge_ready * engage_mult, + * (1.0 - 0.55 * danger) * dodge_ready * engage_mult * attack_ready, # Sidestep / plant against an incoming charge. Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0), # Hurl the blade — an occasional ranged flourish from an engaging matador when @@ -255,7 +258,10 @@ func _choose_intent() -> Intent: Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \ if (throw_ok and is_engager) else -1.0, } - score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") + # Commit bonus keeps a near-tied choice from flip-flopping — but don't re-reinforce a + # just-swung ATTACK we're recovering from, or the matador clings to it and spams the bull. + if not (_intent == Intent.ATTACK and _attack_cd > 0.0): + score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") var best: Intent = Intent.WANDER var best_score := -INF @@ -301,9 +307,8 @@ func _enter_intent(intent: Intent) -> void: _start_throw() -# How many other live matadors are closer to the bull than this one. 0 = the -# closest (the natural lead engager); the utility brain gives the nearest -# mat_engage_slots the go-ahead to press while the rest spread out and taunt. +# How many other live matadors are closer to the bull than this one (0 = the lead engager); the +# brain lets the nearest mat_engage_slots press while the rest spread out and taunt. func _engagement_rank() -> int: if _bull == null: return 0 @@ -347,6 +352,19 @@ func _is_charge_incoming() -> bool: return bull_flat.normalized().dot(to_me.normalized()) > 0.85 +# Bull charging fast and aimed straight at the matador, at ANY range (unlike _is_charge_incoming, +# which is capped to brace range) — so a taunting matador can answer a charge from across the arena. +func _charge_aimed_at_me() -> bool: + var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + if bull_flat.length() < DP.f("mat_charge_speed"): + return false + var to_me := global_position - _bull.global_position + to_me.y = 0.0 + if to_me.length() < 0.1: + return true + return bull_flat.normalized().dot(to_me.normalized()) > 0.9 + + # Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds. # Randomises among available clear angles so juke direction is unpredictable. func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: @@ -407,6 +425,19 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void: func _tick_wander(delta: float) -> void: # Plant the feet while drawing / sheathing instead of sliding through the clip. if _drawing or _sheathing or _idle_timer > 0.0: + # A charge aimed at a taunting matador snaps it out of the pose: hurl a spear at a bull + # barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush. + if _idle_timer > 0.0 and not _drawing and not _sheathing \ + and _bull != null and _charge_aimed_at_me(): + var dist := global_position.distance_to(_bull.global_position) + var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ + and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") + if dist > DP.f("mat_brace_range") and throw_ok: + _start_throw() # far charge — get the spear off before it arrives + return + if dist <= DP.f("mat_brace_range"): + _start_brace() # closer rush — draw the sword and cite the charge + return _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) # Taunt toward the bull so the posturing plays to it, not off into space. @@ -531,6 +562,7 @@ func _start_attack() -> void: func _tick_attack(delta: float) -> void: _attack_timer -= delta + var was_swinging := _swing_timer > 0.0 _swing_timer = maxf(0.0, _swing_timer - delta) # Plant the feet while drawing instead of sliding toward the bull mid-clip. if _drawing or _sheathing: @@ -543,22 +575,41 @@ func _tick_attack(delta: float) -> void: to_bull.y = 0.0 var dist := to_bull.length() - # Commit to a full swing once in range instead of flipping Run/Attack at the - # distance boundary every frame (the old foot-slide jitter). When the swing - # ends the matador re-closes if the bull slipped away, or stabs again if not. - if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand: + # A finished stab enforces a recovery beat (no re-commit for mat_stab_recovery), then decides + # what's next: a landed hit very likely breaks into a taunt/retreat, otherwise the usual roll + # to give ground / circle / throw — anything but instantly drilling the same thrust again. + if was_swinging and _swing_timer <= 0.0: + _attack_cd = DP.f("mat_stab_recovery") + var landed := _sword_blade_area != null and _sword_blade_area.did_hit() + if landed and randf() < DP.f("mat_taunt_after_hit"): + _do_taunt_retreat() + return + if randf() < DP.f("mat_stab_followup_chance"): + _do_stab_followup() + return + + # Run in (Run clip) to mat_stab_standoff, then plant and swing on the spot — the standoff sits + # inside the sword's ~3.75 m thrust reach; killing velocity on commit stops the feet sliding. + # The recovery beat gates the next commit so back-to-back stabs can't machine-gun the bull. + if _swing_timer <= 0.0 and _attack_cd <= 0.0 and dist <= DP.f("mat_stab_standoff") and _sword_in_hand: _swing_timer = _anim_length(_ANIM_ATTACK, 0.9) - _lunge_timer = DP.f("mat_lunge_time") + velocity.x = 0.0 + velocity.z = 0.0 + # Re-arm fresh each swing — back-to-back stabs commit before the else-branch deactivate runs. + if _sword_blade_area != null: + _sword_blade_area.deactivate() if _swing_timer > 0.0: - _lunge_timer = maxf(0.0, _lunge_timer - delta) - if _lunge_timer > 0.0 and dist > DP.f("mat_attack_min_dist"): - _accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta) - else: - _decelerate(22.0, delta) + _decelerate(22.0, delta) _play_anim(_ANIM_ATTACK) - _try_melee_hit(DP.f("mat_stab_reach")) + # Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only + # when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap. + if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"): + _sword_blade_area.activate() + _sword_blade_area.sweep() else: + if _sword_blade_area != null: + _sword_blade_area.deactivate() var dir := _steer_clear(to_bull.normalized(), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta) _play_anim(_ANIM_RUN) @@ -566,6 +617,50 @@ func _tick_attack(delta: float) -> void: move_and_slide() +# How far through the attack swing: 0 (just committed) → 1 (finished), tracking the clip's progress. +func _swing_phase() -> float: + var length := _anim_length(_ANIM_ATTACK, 0.9) + if length <= 0.0: + return 1.0 + return clampf(1.0 - _swing_timer / length, 0.0, 1.0) + + +# Break off after a stab instead of drilling the same thrust: give ground, hurl a spear, or circle. +# The brain re-engages after; _decide_cd is reset so the pick plays out before it re-scores. +func _do_stab_followup() -> void: + _decide_cd = DP.f("mat_decide_interval") + _dodge_cd = DP.f("mat_dodge_cooldown") + _steer_cd = 0.0 + var roll := randf() + if roll < 0.2 and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node): + _start_throw() # ranged flourish + elif roll < 0.6: + _state = State.FLEE # give ground after the thrust + _intent = Intent.FLEE + else: + _state = State.WANDER # circle to a fresh angle before pressing again + _intent = Intent.WANDER + _pick_wander_target() + + +# A stab that LANDED: milk it. Break into a long taunt held toward the bull, then drift out to the +# showmanship ring — and lock ATTACK out for the whole hold (_attack_cd) so the brain can't drag the +# matador straight back into another thrust. DODGE/FLEE stay live, so a charging bull is still +# answered. This is what stops the "stab, stab, stab" spam after the matador connects. +func _do_taunt_retreat() -> void: + _state = State.WANDER + _intent = Intent.WANDER + _idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) + _idle_anim = _pick_taunt() + _taunt_swap = randf_range(1.5, 3.0) + _attack_cd = maxf(_attack_cd, _idle_timer) + _dodge_cd = DP.f("mat_dodge_cooldown") + _steer_cd = 0.0 + _pick_wander_target() + if _sword_blade_area != null: + _sword_blade_area.deactivate() + + func _enter_roll(hit_dir: Vector3) -> void: _state = State.ROLL # Match the state to the actual clip so the roll plays through once instead of @@ -579,9 +674,8 @@ func _enter_roll(hit_dir: Vector3) -> void: _anim_player.seek(0.0, true) -# Called by the spawner (deferred) to make the matador leap-roll toward a target -# point on entry — the cinematic arena entrance. After the roll it transitions to -# normal AI via the standard ROLL → FLEE path. +# Called (deferred) by the spawner to make the matador leap-roll toward a target on entry — the +# cinematic arena entrance. After the roll it transitions to normal AI via the ROLL → FLEE path. func enter_spawn_roll(toward: Vector3) -> void: var dir := toward - global_position dir.y = 0.0 @@ -770,34 +864,84 @@ func _on_body_entered(body: Node3D) -> void: if not body.is_in_group(&"player"): return var player := body as CharacterBody3D - if player.velocity.length() < DP.f("mat_hit_threshold"): + var bull_speed := player.velocity.length() + if bull_speed < DP.f("mat_hit_threshold"): + return + if not _horns_first(player.global_position): return var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z) var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \ (global_position - player.global_position).normalized() + _take_bull_charge(bull_speed, hit_dir) - if randf() < DP.f("mat_roll_chance"): + +# Tunnel-proof charge detection: at dash speed the bull skips clean over the thin HitArea between +# ticks, so body_entered never fires. Sweep its actual travel (prev → now) and gore on any pass within +# mat_charge_gore_radius. Runs every frame (even ragdolled) to keep the segment fresh. +func _sweep_bull_gore() -> void: + var now := _bull.global_position + var seg_from := _bull_prev_pos if _bull_prev_seen else now + _bull_prev_pos = now + _bull_prev_seen = true + if _state == State.RAGDOLL or _state == State.ROLL: + return + var flat_vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + var bull_speed := flat_vel.length() + if bull_speed < DP.f("mat_hit_threshold"): + return + var a := Vector3(seg_from.x, 0.0, seg_from.z) + var b := Vector3(now.x, 0.0, now.z) + var p := Vector3(global_position.x, 0.0, global_position.z) + var closest := Geometry3D.get_closest_point_to_segment(p, a, b) + if p.distance_to(closest) > DP.f("mat_charge_gore_radius"): + return + if not _horns_first(seg_from): + return + var hit_dir := flat_vel.normalized() if bull_speed > 0.5 \ + else (global_position - now).normalized() + _take_bull_charge(bull_speed, hit_dir) + + +# Horns-first gate: gore only when the bull's velocity points within bull_gore_arc of the direction +# from `from` to the matador — not a sideways brush or backing in. The dash ability skips the gate. +func _horns_first(from: Vector3) -> bool: + if _bull == null: + return false + if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"): + return true # the dash ability is a committed lunge — it gores on contact, no angle gate + var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) + if vel.length() < 0.5: + return false + var to_me := global_position - from + to_me.y = 0.0 + if to_me.length() < 0.01: + return true + return vel.normalized().dot(to_me.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc"))) + + +# Resolve a connected charge: the survival roll fades as bull speed climbs (mat_charge_pierce), so a +# committed charge gores rather than getting shrugged off. +func _take_bull_charge(bull_speed: float, hit_dir: Vector3) -> void: + # The boulder roll bowls matadors over with its own launch + chain (player._roll_try_pop); don't + # pre-empt that with a quiet gore, or the ball rolls straight through without popping. + if _bull.has_method(&"is_rolling") and _bull.call(&"is_rolling"): + return + # The dash is a committed lunge — a connect is a guaranteed kill, no survival roll (it already + # bypasses the horns cone). A passive cruise-charge still leaves a roll chance that fades with speed. + var roll_chance := 0.0 + if not (_bull.has_method(&"is_dashing") and _bull.call(&"is_dashing")): + var floor_spd := DP.f("mat_hit_threshold") + var full_spd := maxf(DP.f("mat_charge_speed"), floor_spd + 0.1) + var commit := clampf((bull_speed - floor_spd) / (full_spd - floor_spd), 0.0, 1.0) + roll_chance = DP.f("mat_roll_chance") * (1.0 - commit * DP.f("mat_charge_pierce")) + if randf() < roll_chance: _enter_roll(hit_dir) else: - _enter_ragdoll(hit_dir, player.velocity.length()) + _enter_ragdoll(hit_dir, bull_speed) -func _on_blade_hit(body: Node3D) -> void: - if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE: - return - if not body.is_in_group(&"player"): - return - if _blade_hit_cd > 0.0: - return - _blade_hit_cd = DP.f("mat_stab_cooldown") - body.call(&"take_sword_hit", "gored") - - -# Reach-based stab. A charging bull crosses metres per physics frame, so it easily -# tunnels through the thin blade Area between frames — this distance check is the -# reliable hit. Called from the offensive ticks (active swing / pass) whenever the -# blade is drawn: if the bull is inside reach AND the matador is facing it — the -# blade actually presented, not held off to the side — it's gored. +# Reach-based hit for the sidestep PASS (fast lateral sweep — the thin blade Area tunnels, so a +# distance+facing check is the reliable gore; the ATTACK stab uses the blade collider instead). func _try_melee_hit(reach: float) -> void: if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null: return @@ -806,8 +950,6 @@ func _try_melee_hit(reach: float) -> void: var dist := to_bull.length() if dist > reach or dist < 0.01: return - # Only lands within the frontal arc — you're gored on a presented blade, not by - # brushing a matador whose sword is pointing elsewhere. var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y)) if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))): return @@ -817,8 +959,8 @@ func _try_melee_hit(reach: float) -> void: func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void: _state = State.RAGDOLL - if _sword_blade_area: - _sword_blade_area.monitoring = false + if _sword_blade_area != null: + _sword_blade_area.deactivate() _clear_spear() # a spear mid-wind-up dies with the matador, not in the air killed.emit() var cleanup_timer := get_tree().create_timer(4.0) @@ -1011,10 +1153,9 @@ func _pick_wander_target() -> void: # ── Sword attachment ────────────────────────────────────────────────────────── -# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand -# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is -# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable -# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently. +# The rig (matador_v03) carries two bones: weapon_bone (right-hand grip) and weapon_rest_bone (hip +# holster). The sword rides in the holster, carried to the hand in combat (_carry_sword); each bone +# has its own DP-tunable offset/rotation (sword_* / sword_rest_*) so the poses seat independently. func _setup_sword() -> void: if not _skeleton: return @@ -1047,28 +1188,15 @@ func _spawn_sword() -> void: _drawing = false _apply_sword_grip() - # Blade collider along +Z (hilt at origin), tipped from the capsule's default Y. - # Only monitors while the sword is in hand (see _carry_sword). - var blade_cap := CapsuleShape3D.new() - blade_cap.radius = 0.18 - blade_cap.height = 1.30 - var blade_cs := CollisionShape3D.new() - blade_cs.shape = blade_cap - blade_cs.position = Vector3(0.0, 0.0, 0.8) - blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) - - _sword_blade_area = Area3D.new() - _sword_blade_area.collision_layer = 0 - _sword_blade_area.collision_mask = 1 - _sword_blade_area.monitoring = _sword_in_hand - _sword_blade_area.add_child(blade_cs) - _sword_node.add_child(_sword_blade_area) - _sword_blade_area.body_entered.connect(_on_blade_hit) + # The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see + # _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull. + _sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox + if _sword_blade_area != null: + _sword_blade_area.deactivate() -# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding -# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead -# (see _spawn_spear_in_hand), so THROW keeps the sword holstered. +# The sword is MELEE-only: drawn for ATTACK / BRACE / SIDESTEP, holstered otherwise. Ranged throws +# use a spear (_spawn_spear_in_hand), so THROW keeps the sword holstered. func _wants_sword_drawn() -> bool: return _state == State.ATTACK \ or _state == State.BRACE or _state == State.SIDESTEP @@ -1077,6 +1205,9 @@ func _wants_sword_drawn() -> bool: func _update_sword_carry() -> void: if not is_instance_valid(_sword_node) or _state == State.RAGDOLL: return + # The blade collider lives only during the ATTACK thrust; leaving ATTACK disarms it. + if _state != State.ATTACK and _sword_blade_area != null: + _sword_blade_area.deactivate() if _wants_sword_drawn(): if not _sword_in_hand and not _drawing: _begin_draw() @@ -1156,8 +1287,9 @@ func _carry_sword(in_hand: bool) -> void: _sword_in_hand = in_hand _sword_node.reparent(target, false) _apply_sword_grip() - if _sword_blade_area: - _sword_blade_area.monitoring = in_hand + # Carrying never arms the blade — only the ATTACK thrust does (see _tick_attack). + if _sword_blade_area != null: + _sword_blade_area.deactivate() func _apply_sword_grip() -> void: diff --git a/player.gd b/player.gd index e7eb8f7..55474b4 100644 --- a/player.gd +++ b/player.gd @@ -573,8 +573,20 @@ func _mat_active(mat: Node) -> bool: return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active") -# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to -# the normal locomotion path next frame. +# 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 @@ -1025,7 +1037,7 @@ func _physics_process(delta: float) -> void: _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 ──────────────────────────── @@ -1042,15 +1054,15 @@ func _physics_process(delta: float) -> void: velocity.x = flat_n.x * exit_spd velocity.z = flat_n.z * exit_spd pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z) - + # --- ADD THIS BREAKABLE BARREL DETECTION CODE --- var collision = get_slide_collision(i) var collider = collision.get_collider() - + # Check if the object we hit has the "destroy_barrel" function if collider and collider.has_method("destroy_barrel"): # Optional: Only break if moving fast (e.g., sprinting or dashing) - # if velocity.length() > 5.0: + # if velocity.length() > 5.0: collider.destroy_barrel() # ── Landing. Don't bounce ───────────────────────────────────────────────────── @@ -1126,6 +1138,12 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void 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) + + # 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. @@ -1167,7 +1185,7 @@ func _spawn_blood(hit_pos: Vector3) -> void: p.global_position = hit_pos p.restart() _free_after(p, 1.5) - + #hit flashing func _hit_flash() -> void: diff --git a/spear_projectile.gd b/spear_projectile.gd index 891ec57..ba0c371 100644 --- a/spear_projectile.gd +++ b/spear_projectile.gd @@ -10,6 +10,8 @@ extends RigidBody3D # (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://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. @@ -34,16 +36,25 @@ static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector var s := SpearProjectile.new() s.gravity_scale = gravity s.collision_layer = 0 - s.collision_mask = 1 + # 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 - s.add_child(_shaft_collider()) + 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 @@ -84,6 +95,18 @@ static func shed_all(bull: Node) -> void: _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. diff --git a/tests/_fake_bull.gd b/tests/_fake_bull.gd new file mode 100644 index 0000000..80ba4f5 --- /dev/null +++ b/tests/_fake_bull.gd @@ -0,0 +1,25 @@ +extends CharacterBody3D +## Stand-in bull for the combat tests. As a TARGET it records hitbox callbacks (hits / knock-up); +## as an ATTACKER it exposes the ability-state interface (is_dashing / is_rolling) the matador and +## bear query. Ability flags default off = an ordinary charge. + +var hits: int = 0 +var knocked_up: float = 0.0 +var dashing: bool = false +var rolling: bool = false + + +func take_sword_hit(_cause: String = "", _hit_pos: Vector3 = Vector3.ZERO) -> void: + hits += 1 + + +func apply_knock_up(v: float) -> void: + knocked_up = maxf(knocked_up, v) + + +func is_dashing() -> bool: + return dashing + + +func is_rolling() -> bool: + return rolling diff --git a/tests/_fake_bull.gd.uid b/tests/_fake_bull.gd.uid new file mode 100644 index 0000000..0cc60bc --- /dev/null +++ b/tests/_fake_bull.gd.uid @@ -0,0 +1 @@ +uid://bamw0bnk3bfvs diff --git a/tests/bear_fx_test.gd b/tests/bear_fx_test.gd new file mode 100644 index 0000000..ed788c1 --- /dev/null +++ b/tests/bear_fx_test.gd @@ -0,0 +1,98 @@ +extends SceneTree +## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash) +## live in Assets/fx/Bear_FX.glb as meshes that rest at scale 0 at Siim's baked positions. The +## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and +## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on +## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end, +## and never leaves another effect on-screen — so a mis-wired trigger or a broken clip is caught +## headlessly. Run: godot --headless --script res://tests/bear_fx_test.gd + +var _pass := 0 +var _fail := 0 +var _fx_player: AnimationPlayer = null +var _fx: Dictionary = {} # short name -> Node3D + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _run() -> void: + var bear := (load("res://Bear.tscn") as PackedScene).instantiate() + root.add_child(bear) + await physics_frame + + var fx_root := bear.find_child("Bear_FX", true, false) + _ok(fx_root != null, "Bear_FX instanced in the scene") + if fx_root == null: + _finish() + return + _fx_player = fx_root.get_node_or_null("AnimationPlayer") as AnimationPlayer + _ok(_fx_player != null, "found the Bear_FX AnimationPlayer") + if _fx_player == null: + _finish() + return + + for n: String in ["jump_smash", "smash_hit", "claw_attack_R", "claw_attack_L"]: + _fx[n] = bear.find_child("Bear_fx_" + n, true, false) + _ok(_fx[n] != null, "found Bear_fx_" + n) + if _fx.values().has(null): + _finish() + return + + _ok(not _vis("jump_smash") and not _vis("smash_hit") + and not _vis("claw_attack_R") and not _vis("claw_attack_L"), + "all FX hidden at rest (scale 0)") + + # Each clip pops only its own mesh at the strike, then self-hides, leaving nothing stale. + await _check_clip("Bear_fx_claw_attack_L", "claw_attack_L") + await _check_clip("Bear_fx_claw_attack_R", "claw_attack_R") + await _check_clip("Bear_fx_smash_hit", "smash_hit") + await _check_clip("Bear_fx_jump_smash", "jump_smash") + + _finish() + + +# Play a clip to completion in real process mode (so bear.gd's animation_finished hide runs): +# the clip's own mesh scales up somewhere in the middle, every other mesh stays hidden, and the +# mesh is back to scale 0 once the clip has finished. +func _check_clip(clip: String, owner_key: String) -> void: + _fx_player.play(clip) + _fx_player.seek(0.0, true) + var peak := 0.0 + var others_clean := true + var frames := 0 + while _fx_player.is_playing() and frames < 600: + await process_frame + frames += 1 + peak = maxf(peak, (_fx[owner_key] as Node3D).scale.length()) + for k: String in _fx.keys(): + if k != owner_key and _vis(k): + others_clean = false + # Hide happens on animation_finished (immediately for most, after a linger for the crater), + # so poll a bounded window rather than assume it's gone on the very next frame. + var settle := 0 + while _vis(owner_key) and settle < 600: + await process_frame + settle += 1 + _ok(peak > 0.05, clip + ": its mesh pops during the clip") + _ok(others_clean, clip + ": no other effect shows while it plays") + _ok(not _vis(owner_key), clip + ": hidden again after the clip ends") + + +func _vis(n: String) -> bool: + return (_fx[n] as Node3D).scale.length() > 0.05 + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/bear_fx_test.gd.uid b/tests/bear_fx_test.gd.uid new file mode 100644 index 0000000..7017405 --- /dev/null +++ b/tests/bear_fx_test.gd.uid @@ -0,0 +1 @@ +uid://duyfxhca4np78 diff --git a/tests/bear_hitbox_test.gd b/tests/bear_hitbox_test.gd new file mode 100644 index 0000000..857d6de --- /dev/null +++ b/tests/bear_hitbox_test.gd @@ -0,0 +1,121 @@ +extends SceneTree +## Bear attack-collider test. The colliders + their motion live in BearAttackHitboxes.tscn: the +## leap slam is a ground circle (cylinder) whose "leap_slam" clip pops it open; the overhead smash +## is a forward box lane whose "smash_travel" clip grows it out from the bear. bear.gd fires the +## clip, arms the knock-up shaping, and sweeps while it runs. This asserts: a bull anywhere in the +## leap circle is mauled AND launched by default (bear_leap_ring_frac 0), while raising ring_frac +## carves a grounded inner zone; the smash lane catches bulls near AND far as the front travels, +## throws the further one higher, and spares anything off the lane. +## Run: godot --headless --script res://tests/bear_hitbox_test.gd + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _make_bull(pos: Vector3) -> CharacterBody3D: + var b := CharacterBody3D.new() + b.collision_layer = 2 # PhysicsLayers.BULL — what the hitboxes mask + b.collision_mask = 0 + b.add_to_group(&"player") + b.set_script(load("res://tests/_fake_bull.gd")) + var cs := CollisionShape3D.new() + var shape := SphereShape3D.new() + shape.radius = 0.6 + cs.shape = shape + b.add_child(cs) + root.add_child(b) + b.global_position = pos + return b + + +# Drive one attack collider clip to completion, sweeping each physics frame like _physics_process. +func _drive(bear: Node, fire: String) -> void: + bear.call(fire) + for i in 60: + bear.call("_tick_attack_hitbox") + await physics_frame + if bear.get("_active_hitbox") == null: + break + + +func _run() -> void: + var bear := (load("res://Bear.tscn") as PackedScene).instantiate() as Node3D + root.add_child(bear) + bear.set_physics_process(false) # freeze the AI; we fire the colliders ourselves + await physics_frame + + var leap_hb := bear.find_child("LeapSlamHitbox", true, false) as Area3D + var smash_hb := bear.find_child("SmashHitbox", true, false) as Area3D + _ok(leap_hb != null, "leap slam cylinder hitbox exists (from BearAttackHitboxes.tscn)") + _ok(smash_hb != null, "overhead smash lane hitbox exists (from BearAttackHitboxes.tscn)") + if leap_hb == null or smash_hb == null: + _finish() + return + + var dp := root.get_node("/root/DP") + var slam_r: float = (leap_hb.get_node("CollisionShape3D").shape as CylinderShape3D).radius + var lane: float = (smash_hb.get_node("CollisionShape3D").shape as BoxShape3D).size.z + + # ── Leap slam: by default (bear_leap_ring_frac 0) the bull is launched from ANYWHERE in the + # circle — no safe inner spot. Off the circle is still spared. + dp.call("set_value", "bear_leap_ring_frac", 0.0) + var center := _make_bull(Vector3(0.0, 0.5, 0.0)) + var midring := _make_bull(Vector3(slam_r * 0.5, 0.5, 0.0)) + var outside := _make_bull(Vector3(slam_r + 3.0, 0.5, 0.0)) + await physics_frame + await _drive(bear, "_fire_leap_hitbox") + _ok(center.hits >= 1 and center.knocked_up > 0.0, "bull at the circle center is mauled AND launched") + _ok(midring.hits >= 1 and midring.knocked_up > 0.0, "bull mid-circle is mauled AND launched") + _ok(outside.hits == 0, "bull outside the slam radius is spared") + center.queue_free() + midring.queue_free() + outside.queue_free() + await physics_frame + + # Raising ring_frac restores a grounded inner zone that only mauls (the slider still works). + dp.call("set_value", "bear_leap_ring_frac", 0.75) + var frac: float = dp.f("bear_leap_ring_frac") + var inner := _make_bull(Vector3(slam_r * (frac * 0.5), 0.5, 0.0)) + var ring := _make_bull(Vector3(slam_r * (frac + (1.0 - frac) * 0.5), 0.5, 0.0)) + await physics_frame + await _drive(bear, "_fire_leap_hitbox") + _ok(inner.hits >= 1 and inner.knocked_up == 0.0, "raised ring_frac keeps the inner circle grounded") + _ok(ring.knocked_up > 0.0, "raised ring_frac still launches the outer ring") + inner.queue_free() + ring.queue_free() + dp.call("set_value", "bear_leap_ring_frac", 0.0) + await physics_frame + + # ── Overhead smash: the lane's leading edge travels the whole way, so bulls near AND far are + # both caught as the front passes; the further one is thrown higher. Off-lane is spared. + var near := _make_bull(Vector3(0.0, 0.5, lane * 0.2)) + var far := _make_bull(Vector3(0.0, 0.5, lane * 0.8)) + var beside := _make_bull(Vector3(lane * 0.5, 0.5, lane * 0.5)) + var behind := _make_bull(Vector3(0.0, 0.5, -3.0)) + await physics_frame + await _drive(bear, "_begin_smash_hitbox") + _ok(near.hits >= 1, "bull near the bear is caught as the shockwave starts") + _ok(far.hits >= 1, "bull far down the lane is caught when the front reaches it") + _ok(far.knocked_up > near.knocked_up, "the further hit is thrown higher") + _ok(beside.hits == 0, "bull beside the lane is spared (a lane, not a circle)") + _ok(behind.hits == 0, "bull behind the bear is spared") + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/bear_hitbox_test.gd.uid b/tests/bear_hitbox_test.gd.uid new file mode 100644 index 0000000..de32f53 --- /dev/null +++ b/tests/bear_hitbox_test.gd.uid @@ -0,0 +1 @@ +uid://cvweb2dnv23s diff --git a/tests/bear_ram_test.gd b/tests/bear_ram_test.gd new file mode 100644 index 0000000..cb02f39 --- /dev/null +++ b/tests/bear_ram_test.gd @@ -0,0 +1,112 @@ +extends SceneTree +## Bear ram test. Running the bull HORNS-FIRST into the bear should wound it. The bear detects this by +## sweeping the bull's actual travel (prev → now) against its body each frame (bear._sweep_bull_ram), +## gated by speed (bear_hit_threshold), reach (bear_ram_reach) and a horns-first cone (bull_gore_arc), +## and edge-triggered so one charge = one wound. This drives passes against a frozen bear and asserts: +## a horns-first charge (even one that tunnels / starts close) wounds it once; a charge moving AWAY +## (bear behind the horns), a pass out of reach, and a too-slow nudge all leave it unharmed. +## Run: godot --headless --script res://tests/bear_ram_test.gd + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _make_bull() -> CharacterBody3D: + var b := CharacterBody3D.new() + b.set_script(load("res://tests/_fake_bull.gd")) # exposes is_dashing()/is_rolling() + b.collision_layer = 2 + b.collision_mask = 0 + b.add_to_group(&"player") + var cs := CollisionShape3D.new() + var shape := CapsuleShape3D.new() + shape.radius = 0.75 + shape.height = 1.845 + cs.shape = shape + b.add_child(cs) + root.add_child(b) + return b + + +# Sweep the bull from `from` to `to` at `speed` (velocity along the travel), reseting the bear's ram +# edge-state first so each pass is isolated. The prev → now segment is what the bear tests against. +func _ram_pass(bear: Node, bull: CharacterBody3D, speed: float, from: Vector3, to: Vector3) -> void: + bear.set("_bull_prev_seen", false) + bear.set("_bull_ramming", false) + var dir := to - from + dir.y = 0.0 + bull.velocity = dir.normalized() * speed if dir.length() > 0.01 else Vector3.ZERO + bull.global_position = from + bear.call("_sweep_bull_ram") + bull.global_position = to + bear.call("_sweep_bull_ram") + + +func _run() -> void: + var bull := _make_bull() + var bear := (load("res://Bear.tscn") as PackedScene).instantiate() as Node3D + root.add_child(bear) + bear.global_position = Vector3.ZERO + bear.set_physics_process(false) # freeze the AI; we drive the ram sweep ourselves + await physics_frame + bear.call("_acquire_bull") + + var dp := root.get_node("/root/DP") + var reach: float = dp.f("bear_ram_reach") + var thr: float = dp.f("bear_hit_threshold") + _ok(bear.get("_bull") == bull, "bear acquired the bull") + + # (a) Horns-first charge straight through the bear — endpoints (3 m) sit outside the reach, so + # only the swept test catches the pass-through. + var hp0: int = bear.get("_hp") + _ok(3.0 > reach, "test pass tunnels: endpoints (3 m) exceed the ram reach (%.2f m)" % reach) + _ram_pass(bear, bull, 66.0, Vector3(-3, 0, 0), Vector3(3, 0, 0)) + _ok(bear.get("_hp") == hp0 - 1, "a horns-first charge wounds the bear once") + + # (b) One charge = one wound: sweeping the same charge again without disengaging doesn't re-hit + # mid-contact (edge-triggered). Re-seed as if the bull never left the ram — no reset. + var hp1: int = bear.get("_hp") + bull.velocity = Vector3(66, 0, 0) + bull.global_position = Vector3(0.2, 0, 0) # still on top of the bear, still charging + bear.call("_sweep_bull_ram") + _ok(bear.get("_hp") == hp1, "a sustained charge doesn't drain the bear frame-by-frame") + + # (c) Charging AWAY (bear behind the horns) is not a horns-first ram, even up close. + var hp2: int = bear.get("_hp") + _ram_pass(bear, bull, 66.0, Vector3(0.5, 0, 0), Vector3(4, 0, 0)) + _ok(bear.get("_hp") == hp2, "a charge moving away from the bear leaves it unharmed") + + # (d) A fast pass wide of the bear (out of reach) is spared. + _ram_pass(bear, bull, 66.0, Vector3(-3, 0, 3), Vector3(3, 0, 3)) + _ok(bear.get("_hp") == hp2, "a charge that passes wide of the bear is spared") + + # (e) A slow nudge below the hit threshold is shrugged off. + _ram_pass(bear, bull, thr - 1.0, Vector3(-3, 0, 0), Vector3(3, 0, 0)) + _ok(bear.get("_hp") == hp2, "a nudge below the hit threshold doesn't wound the bear") + + # (f) The DASH ability bypasses the horns-first cone: the same charge-away pass that (c) shrugged + # off DOES wound the bear while dashing (a committed lunge rams on contact). + var hp3: int = bear.get("_hp") + bull.set("dashing", true) + _ram_pass(bear, bull, 66.0, Vector3(0.5, 0, 0), Vector3(4, 0, 0)) + _ok(bear.get("_hp") == hp3 - 1, "the dash ability rams the bear on contact even when not horns-first") + bull.set("dashing", false) + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/bear_ram_test.gd.uid b/tests/bear_ram_test.gd.uid new file mode 100644 index 0000000..8498520 --- /dev/null +++ b/tests/bear_ram_test.gd.uid @@ -0,0 +1 @@ +uid://o2cdiyq77u8k diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd index dead65f..e79d04a 100644 --- a/tests/gameplay_test.gd +++ b/tests/gameplay_test.gd @@ -283,7 +283,7 @@ func _check_overlays() -> void: _note("overlay check: DP / DebugDraw autoload missing") return var flags := [ - "show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones", + "show_states", "show_stats", "show_attacks", "show_hitboxes", "show_bones", "show_animation_name", "show_wireframe", "show_grid", "show_axes", ] for flag: String in flags: diff --git a/tests/matador_charge_test.gd b/tests/matador_charge_test.gd new file mode 100644 index 0000000..3557e80 --- /dev/null +++ b/tests/matador_charge_test.gd @@ -0,0 +1,118 @@ +extends SceneTree +## Charge-reliability test. A fast bull (dash ≈ 66 m/s ≈ 1.1 m/frame) moves far enough per physics +## frame to tunnel clean through the matador's thin HitArea, so the Area's body_entered never fires +## — "my charge goes right through them." matador.gd now sweeps the bull's actual travel (prev → now) +## each frame and gores any matador on the path (_sweep_bull_gore), and a committed charge pierces the +## survival roll (mat_charge_pierce). This drives a tunnelling pass whose endpoints both sit OUTSIDE +## the gore radius — so only the swept test can catch it — and asserts: (a) the charge still connects, +## (b) a full-speed charge gores (kills) rather than being rolled off, and (c) a slow bump at the hit +## threshold still lets the matador roll clear. +## Run: godot --headless --script res://tests/matador_charge_test.gd + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _make_bull() -> CharacterBody3D: + var bull := CharacterBody3D.new() + bull.set_script(load("res://tests/_fake_bull.gd")) # exposes is_dashing()/is_rolling() + bull.collision_layer = 2 + bull.collision_mask = 0 + bull.add_to_group(&"player") + var cs := CollisionShape3D.new() + var shape := CapsuleShape3D.new() + shape.radius = 0.75 + shape.height = 1.845 + cs.shape = shape + bull.add_child(cs) + root.add_child(bull) + return bull + + +func _make_matador(_bull: CharacterBody3D) -> CharacterBody3D: + var mat := (load("res://Matador.tscn") as PackedScene).instantiate() as CharacterBody3D + root.add_child(mat) + mat.global_position = Vector3.ZERO + mat.set_physics_process(false) # we drive the swept gore ourselves, isolated from the AI + await physics_frame + mat.call("_acquire_bull") + return mat + + +# Sweep the bull straight across the matador at `speed`: seed the previous position at from_x (too +# far to gore), then teleport to to_x so the prev → now segment passes through the matador at x=0. +func _charge_pass(mat: CharacterBody3D, bull: CharacterBody3D, speed: float, from_x: float, to_x: float) -> void: + bull.global_position = Vector3(from_x, 0.0, 0.0) + bull.velocity = Vector3(speed, 0.0, 0.0) + mat.call("_sweep_bull_gore") + bull.global_position = Vector3(to_x, 0.0, 0.0) + mat.call("_sweep_bull_gore") + + +func _run() -> void: + var dp := root.get_node("/root/DP") + var gore_r: float = dp.f("mat_charge_gore_radius") + var thr: float = dp.f("mat_hit_threshold") + _ok(2.0 > gore_r, "test pass tunnels: endpoints (2 m) sit outside the gore radius (%.2f m)" % gore_r) + + # One shared bull — matadors acquire the sole node in the "player" group, so every scenario + # sweeps the same body (a second bull would leave later matadors reading the wrong, stale one). + var bull := _make_bull() + + # (a) A dash-speed pass that leaps clean over the HitArea must still connect. Force the roll so + # the reaction is a clean, ragdoll-free state change proving detection. + dp.call("set_value", "mat_charge_pierce", 0.0) + dp.call("set_value", "mat_roll_chance", 1.0) + var mat_a: CharacterBody3D = await _make_matador(bull) + _charge_pass(mat_a, bull, 66.0, -2.0, 2.0) + _ok(mat_a.call("ai_state_name") == "ROLL", "a dash that tunnels the HitArea still connects (matador reacts)") + + # (b) A full-speed charge pierces the survival roll and gores — even forced roll_chance can't save it. + dp.call("set_value", "mat_charge_pierce", 1.0) + dp.call("set_value", "mat_roll_chance", 1.0) + var mat_b: CharacterBody3D = await _make_matador(bull) + _charge_pass(mat_b, bull, 66.0, -2.0, 2.0) + _ok(mat_b.call("ai_state_name") == "RAGDOLL", "a full-speed charge pierces the roll and gores the matador") + + # (c) The pierce only bites at speed: a slow bump at the hit threshold still lets them roll clear. + var mat_c: CharacterBody3D = await _make_matador(bull) + _charge_pass(mat_c, bull, thr, -2.0, 2.0) + _ok(mat_c.call("ai_state_name") == "ROLL", "a slow bump at the threshold still rolls clear") + + # (d) Horns-first only: a fast pass with the matador BEHIND the bull's heading (it charges away, + # 0.5 → 3, matador at 0) doesn't gore, even though the swept path passes within reach. + dp.call("set_value", "mat_charge_pierce", 0.0) + dp.call("set_value", "mat_roll_chance", 1.0) + var mat_d: CharacterBody3D = await _make_matador(bull) + _charge_pass(mat_d, bull, 66.0, 0.5, 3.0) + _ok(mat_d.call("ai_state_name") == "WANDER", "a charge moving away from the matador doesn't gore (horns-first)") + + # (e) The DASH ability is a committed lunge: even the not-horns-first pass connects (is_dashing + # bypasses the cone) AND is a guaranteed kill — roll_chance forced to 1.0 still RAGDOLLS, not rolls. + dp.call("set_value", "mat_roll_chance", 1.0) + dp.call("set_value", "mat_charge_pierce", 0.0) + bull.set("dashing", true) + var mat_e: CharacterBody3D = await _make_matador(bull) + _charge_pass(mat_e, bull, 66.0, 0.5, 3.0) + _ok(mat_e.call("ai_state_name") == "RAGDOLL", "a dash connect is a guaranteed kill (no survival roll)") + bull.set("dashing", false) + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/matador_charge_test.gd.uid b/tests/matador_charge_test.gd.uid new file mode 100644 index 0000000..feafd10 --- /dev/null +++ b/tests/matador_charge_test.gd.uid @@ -0,0 +1 @@ +uid://wplxv71pxkrl diff --git a/tests/matador_spear_test.gd b/tests/matador_spear_test.gd new file mode 100644 index 0000000..6727ecc --- /dev/null +++ b/tests/matador_spear_test.gd @@ -0,0 +1,67 @@ +extends SceneTree +## Thrown-spear hit test. The bull's body sits on collision layer BULL, so a spear whose mask +## only covered WORLD flew straight through it — every throw whiffed, even against a stationary +## bull. This launches a spear dead-on at a still fake bull and asserts the projectile masks BULL +## and actually lands the hit. Run: godot --headless --script res://tests/matador_spear_test.gd + +# physics_layers.gd is pure constants (safe to preload); spear_projectile.gd references the +# Settings autoload, whose global identifier isn't registered when a --script entry compiles its +# preloads — so it's loaded at runtime inside _run instead, once the autoloads exist. +const PhysicsLayers = preload("res://physics_layers.gd") + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _run() -> void: + # Stationary fake bull at the origin, on the BULL layer like the real one. + var bull := CharacterBody3D.new() + bull.collision_layer = PhysicsLayers.BULL + bull.collision_mask = 0 + bull.add_to_group(&"player") + bull.set_script(load("res://tests/_fake_bull.gd")) + var bcs := CollisionShape3D.new() + var bshape := CapsuleShape3D.new() + bshape.radius = 0.6 + bshape.height = 1.4 + bcs.shape = bshape + bull.add_child(bcs) + root.add_child(bull) + bull.global_position = Vector3.ZERO + await physics_frame + + # Launch a spear flat, dead-on from 3 m out; low gravity so the short flight stays level. + var spear_script := load("res://spear_projectile.gd") + var mesh := (load("res://Spear.tscn") as PackedScene).instantiate() as Node3D + root.add_child(mesh) + var origin := Vector3(0.0, 0.0, -3.0) + var spear := spear_script.launch(root, mesh, origin, Vector3(0.0, 0.0, 12.0), 0.05) as RigidBody3D + + _ok(spear.collision_mask & PhysicsLayers.BULL != 0, "spear masks the BULL layer (can collide with the bull)") + + for i in 90: + await physics_frame + if bull.hits >= 1: + break + + _ok(bull.hits >= 1, "thrown spear hits a stationary bull") + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/matador_spear_test.gd.uid b/tests/matador_spear_test.gd.uid new file mode 100644 index 0000000..af1f826 --- /dev/null +++ b/tests/matador_spear_test.gd.uid @@ -0,0 +1 @@ +uid://dcwtku2xvxswa diff --git a/tests/matador_stab_test.gd b/tests/matador_stab_test.gd new file mode 100644 index 0000000..616f67a --- /dev/null +++ b/tests/matador_stab_test.gd @@ -0,0 +1,101 @@ +extends SceneTree +## Matador stab-reliability test. The matador runs in, plants at mat_stab_standoff, and its stab +## damages off the sword's own BladeHitbox collider (armed only during the thrust) — NOT a distance +## check. This drives the attack tick against a STATIONARY, real-sized bull placed beyond the +## standoff and asserts it (a) gores the bull when the thrust physically reaches it, (b) lands the +## hit during the thrust and not the wind-up/walk-up, and (c) plants at the standoff, not inside the +## bull, and (d) keeps landing on repeat stabs from the same spot (the blade re-arms each swing). +## Run: godot --headless --script res://tests/matador_stab_test.gd + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _run() -> void: + var dp := root.get_node("/root/DP") + var standoff: float = dp.f("mat_stab_standoff") + var strike_at: float = dp.f("mat_stab_strike_at") + + # Stationary, real-sized bull beyond the standoff — the matador runs in, plants at the standoff, + # and its THRUST (not the walk-up) has to reach across the gap to gore it. + var bull := CharacterBody3D.new() + bull.collision_layer = 2 + bull.collision_mask = 0 + bull.add_to_group(&"player") + bull.set_script(load("res://tests/_fake_bull.gd")) + var bcs := CollisionShape3D.new() + var bshape := CapsuleShape3D.new() + bshape.radius = 0.75 + bshape.height = 1.845 + bcs.shape = bshape + bull.add_child(bcs) + root.add_child(bull) + bull.global_position = Vector3(0.0, 0.0, standoff + 3.0) + + var mat := (load("res://Matador.tscn") as PackedScene).instantiate() as CharacterBody3D + root.add_child(mat) # _ready acquires the bull from the "player" group + mat.global_position = Vector3.ZERO + mat.set_physics_process(false) # we drive the attack tick ourselves, isolated from the AI + await physics_frame + mat.call("_acquire_bull") # normally done in _physics_process, which we disabled + + _ok(mat.get("_bull") == bull, "matador acquired the bull") + mat.call("_carry_sword", true) # actually draw into the hand so the BladeHitbox rides the thrust + mat.call("_start_attack") + + dp.call("set_value", "mat_stab_followup_chance", 0.0) # isolate repeat stabs from the break-off roll + dp.call("set_value", "mat_stab_recovery", 0.0) # no recovery beat gating back-to-back stabs + dp.call("set_value", "mat_taunt_after_hit", 0.0) # don't break off to taunt after a landed hit + + var min_dist := 999.0 + var first_hit_phase := -1.0 + var last_hits := 0 + for i in 300: # several swings' worth of ticks (approach + repeated stabs) + mat.call("_tick_attack", 1.0 / 60.0) + var d := mat.global_position.distance_to(bull.global_position) + min_dist = minf(min_dist, d) + if bull.hits > last_hits: + if first_hit_phase < 0.0: + first_hit_phase = mat.call("_swing_phase") # phase on the frame the first hit landed + last_hits = bull.hits + await physics_frame + + _ok(bull.hits >= 1, "the sword's thrust reaches across the standoff and gores the bull") + _ok(first_hit_phase >= strike_at - 0.1, "hit lands in the thrust, not the wind-up (phase %.2f)" % first_hit_phase) + _ok(bull.hits >= 2, "repeat stabs from the same spot keep landing (%d hits)" % bull.hits) + _ok(min_dist >= standoff - 0.5, "matador plants at the standoff, not inside the bull (closest %.2f m)" % min_dist) + + # Anti-spam: once a stab CONNECTS, a high mat_taunt_after_hit must break the matador off ATTACK + # (into a taunt/retreat) instead of drilling the bull again. Force the roll and confirm it leaves. + dp.call("set_value", "mat_taunt_after_hit", 1.0) + bull.hits = 0 + mat.call("_start_attack") + var broke_off := false + for i in 200: + mat.call("_tick_attack", 1.0 / 60.0) + if mat.call("ai_state_name") != "ATTACK": + broke_off = true + break + await physics_frame + _ok(broke_off, "a landed stab breaks the matador off ATTACK (taunt/retreat), not another thrust") + _ok(bull.hits >= 1, "the break-off only fires after the stab actually connected (%d hits)" % bull.hits) + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/matador_stab_test.gd.uid b/tests/matador_stab_test.gd.uid new file mode 100644 index 0000000..78f495a --- /dev/null +++ b/tests/matador_stab_test.gd.uid @@ -0,0 +1 @@ +uid://deueaav5qscnc diff --git a/tests/matador_taunt_react_test.gd b/tests/matador_taunt_react_test.gd new file mode 100644 index 0000000..5119d5c --- /dev/null +++ b/tests/matador_taunt_react_test.gd @@ -0,0 +1,91 @@ +extends SceneTree +## A taunting matador must answer a charge instead of posing through it: hurl a spear at a bull +## barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush. Drives +## _tick_wander on a matador mid-taunt (_idle_timer > 0) against a bull charging straight at it and +## asserts the state it breaks into by range; a slow / non-aimed bull leaves it taunting. +## Run: godot --headless --script res://tests/matador_taunt_react_test.gd + +var _pass := 0 +var _fail := 0 + + +func _init() -> void: + _run.call_deferred() + + +func _ok(c: bool, m: String) -> void: + if c: + _pass += 1 + print(" PASS: ", m) + else: + _fail += 1 + print(" FAIL: ", m) + + +func _make_bull() -> CharacterBody3D: + var b := CharacterBody3D.new() + b.collision_layer = 2 + b.collision_mask = 0 + b.add_to_group(&"player") + var cs := CollisionShape3D.new() + var shape := CapsuleShape3D.new() + shape.radius = 0.75 + shape.height = 1.845 + cs.shape = shape + b.add_child(cs) + root.add_child(b) + return b + + +func _make_taunting_matador(_bull: CharacterBody3D) -> CharacterBody3D: + var mat := (load("res://Matador.tscn") as PackedScene).instantiate() as CharacterBody3D + root.add_child(mat) + mat.global_position = Vector3.ZERO + mat.set_physics_process(false) # we drive _tick_wander ourselves, no brain + await physics_frame + mat.call("_acquire_bull") + mat.set("_idle_timer", 5.0) # mid-taunt: standing and posturing + mat.set("_throw_aim_cd", 0.0) # spear ready + return mat + + +# Park the bull at `dist` straight ahead (+Z) of the matador, charging back toward it at `speed`. +func _charge_from(bull: CharacterBody3D, dist: float, speed: float) -> void: + bull.global_position = Vector3(0.0, 0.0, dist) + bull.velocity = Vector3(0.0, 0.0, -speed) + + +func _run() -> void: + var bull := _make_bull() + + # (a) Far charge (10 m, past brace range but within throw range) → get the spear off. + var mat_a: CharacterBody3D = await _make_taunting_matador(bull) + _charge_from(bull, 10.0, 30.0) + mat_a.call("_tick_wander", 1.0 / 60.0) + _ok(mat_a.call("ai_state_name") == "THROW", "a taunting matador hurls a spear at a charge from range") + + # (b) Closer rush (3 m, inside brace range) → switch to the sword and cite it. + var mat_b: CharacterBody3D = await _make_taunting_matador(bull) + _charge_from(bull, 3.0, 30.0) + mat_b.call("_tick_wander", 1.0 / 60.0) + _ok(mat_b.call("ai_state_name") == "BRACE", "a taunting matador switches to the sword for a closer rush") + + # (c) A slow bull (below charge speed) isn't a charge — keep taunting. + var mat_c: CharacterBody3D = await _make_taunting_matador(bull) + _charge_from(bull, 10.0, 3.0) + mat_c.call("_tick_wander", 1.0 / 60.0) + _ok(mat_c.call("ai_state_name") == "WANDER", "a taunting matador ignores a slow (non-charging) bull") + + # (d) A fast bull aimed AWAY from the matador isn't charging at it — keep taunting. + var mat_d: CharacterBody3D = await _make_taunting_matador(bull) + bull.global_position = Vector3(0.0, 0.0, 10.0) + bull.velocity = Vector3(0.0, 0.0, 30.0) # heading further away, not at the matador + mat_d.call("_tick_wander", 1.0 / 60.0) + _ok(mat_d.call("ai_state_name") == "WANDER", "a taunting matador ignores a charge not aimed at it") + + _finish() + + +func _finish() -> void: + print("Results: %d passed, %d failed" % [_pass, _fail]) + quit(1 if _fail > 0 else 0) diff --git a/tests/matador_taunt_react_test.gd.uid b/tests/matador_taunt_react_test.gd.uid new file mode 100644 index 0000000..51d2cb4 --- /dev/null +++ b/tests/matador_taunt_react_test.gd.uid @@ -0,0 +1 @@ +uid://c1r546ktiw2ka