Combat overhaul: charge/gore, bear ram, matador taunt reactions
Bull charge damage: - Tunnel-proof swept detection (prev→now segment) for matador gore and bear ram, so a 66 m/s dash no longer skips clean over the thin HitArea. - Horns-first cone (bull_gore_arc): running into an enemy only wounds it when charging roughly at it, not a sideways brush. - Dash is a committed lunge: bypasses the horns cone AND the survival roll, so a dash connect is a guaranteed kill. Cruise charges keep a speed-scaled roll (mat_charge_pierce). Roll ability defers to its own pop (is_rolling guard). Bear: - Horns-first swept ram detection (edge-triggered: one charge = one wound); a tunnelling or point-blank charge now lands where body_entered wouldn't. - Overhead smash lane extends faster (0.1s) and further (20 m). - Leap slam launches the bull from anywhere in the circle (ring_frac 0). - Removed stray-quote syntax errors that broke the script. Matador: - Collider-driven stab (BladeHitbox) with recovery beat + taunt-after-hit so it stops machine-gunning the bull. - A taunting matador answers a charge: spear from range, sword up close. Spear projectile masks the BULL layer so throws connect. Tests: matador_charge/stab/spear/taunt_react, bear_ram/hitbox/fx (+ unified _fake_bull stand-in). Trimmed verbose comments. Note: bear claw-emitter/death work is still WIP (3 bear_boss_test failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,4 +2,4 @@ disable:
|
|||||||
- class-definitions-order
|
- class-definitions-order
|
||||||
- no-else-return
|
- no-else-return
|
||||||
max-line-length: 120
|
max-line-length: 120
|
||||||
max-file-lines: 1200
|
max-file-lines: 3000
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_resource type="Animation" format=3 uid="uid://bolvoh2i867gj"]
|
[gd_resource type="Animation" format=3 uid="uid://bq71flatfddll"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
resource_name = "Bear_fx_smash_hit"
|
resource_name = "Bear_fx_smash_hit"
|
||||||
|
|||||||
@@ -1767,20 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R")
|
|||||||
tracks/251/interp = 1
|
tracks/251/interp = 1
|
||||||
tracks/251/loop_wrap = true
|
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/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"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1767,23 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R")
|
|||||||
tracks/251/interp = 0
|
tracks/251/interp = 0
|
||||||
tracks/251/loop_wrap = true
|
tracks/251/loop_wrap = true
|
||||||
tracks/251/keys = PackedFloat32Array(0, 1, 1.0000002, 0.99999994, 0.99999994)
|
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"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1767,20 +1767,3 @@ tracks/251/path = NodePath("bear-rig/Skeleton3D:thigh_ik_target.R")
|
|||||||
tracks/251/interp = 1
|
tracks/251/interp = 1
|
||||||
tracks/251/loop_wrap = true
|
tracks/251/loop_wrap = true
|
||||||
tracks/251/keys = PackedFloat32Array(0, 1, 1.0000002, 0.99999994, 0.99999994)
|
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"
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
[ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://bear.gd" id="1_beargd"]
|
[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="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="3_bearfx"]
|
||||||
[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="4_clawfx"]
|
||||||
[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://Materials/M_bear_claw_fx.tres" id="5_bfebr"]
|
[ext_resource type="PackedScene" path="res://BearAttackHitboxes.tscn" id="5_hitboxes"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
|
||||||
radius = 1.0
|
radius = 1.0
|
||||||
@@ -14,12 +14,6 @@ height = 2.4
|
|||||||
radius = 1.3
|
radius = 1.3
|
||||||
height = 2.7
|
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]
|
[node name="Bear" type="CharacterBody3D" unique_id=1573237287]
|
||||||
script = ExtResource("1_beargd")
|
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="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")]
|
||||||
|
|
||||||
[node name="test_cube" type="Node3D" parent="bear_model" unique_id=646988893]
|
[node name="Bear_FX" parent="bear_model" unique_id=2070553519 instance=ExtResource("3_bearfx")]
|
||||||
script = ExtResource("3_rog1v")
|
|
||||||
|
|
||||||
[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]
|
[node name="Bear_fx_claw_attack_L" parent="bear_model/Bear_FX" index="3" unique_id=834640978]
|
||||||
visible = false
|
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]
|
[node name="AttackHitboxes" parent="bear_model" instance=ExtResource("5_hitboxes")]
|
||||||
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")
|
|
||||||
|
|
||||||
[editable path="bear_model"]
|
[editable path="bear_model"]
|
||||||
[editable path="bear_model/test_cube/Bear_FX"]
|
[editable path="bear_model/Bear_FX"]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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) |
|
| `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/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` |
|
| `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/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/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
|
||||||
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
|
| `levels/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/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/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_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
|
||||||
|
godot --headless --script tests/bear_fx_test.gd # bear attack-FX: each Bear_FX clip pops its mesh + self-hides, ~2 s
|
||||||
|
godot --headless --script tests/bear_hitbox_test.gd # bear attack colliders: leap launches everywhere (ring_frac slider) + smash lane travel, ~3 s
|
||||||
|
godot --headless --script tests/bear_ram_test.gd # a horns-first bull charge wounds the bear once; away/wide/slow spared, ~3 s
|
||||||
|
godot --headless --script tests/matador_stab_test.gd # matador's BladeHitbox thrust gores a stationary bull, and a landed stab breaks it off to taunt (anti-spam), ~2 s
|
||||||
|
godot --headless --script tests/matador_taunt_react_test.gd # a taunting matador answers a charge: spear from range, sword up close, ignores non-charges, ~2 s
|
||||||
|
godot --headless --script tests/matador_charge_test.gd # bull's charge is swept (tunnel-proof) so a fast dash still gores; a committed charge pierces the survival roll, ~2 s
|
||||||
|
godot --headless --script tests/matador_spear_test.gd # thrown spear masks the BULL layer and hits a stationary bull, ~2 s
|
||||||
|
|
||||||
# Gameplay test with real rendering (inspect frame strip visually):
|
# Gameplay test with real rendering (inspect frame strip visually):
|
||||||
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
|
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
|
||||||
|
|||||||
+15
@@ -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")
|
||||||
+20
@@ -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")
|
||||||
@@ -26,7 +26,25 @@ var _phase: Phase = Phase.WINDUP
|
|||||||
var _bull: CharacterBody3D = null
|
var _bull: CharacterBody3D = null
|
||||||
var _skeleton: Skeleton3D = null
|
var _skeleton: Skeleton3D = null
|
||||||
var _anim_player: AnimationPlayer = 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
|
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 _hp: int = 10
|
||||||
var _enraged: bool = false
|
var _enraged: bool = false
|
||||||
@@ -91,6 +109,13 @@ func _ready() -> void:
|
|||||||
health_changed.emit(_hp, _hp)
|
health_changed.emit(_hp, _hp)
|
||||||
_skeleton = _find_skeleton(_mesh)
|
_skeleton = _find_skeleton(_mesh)
|
||||||
_anim_player = _find_anim_player(_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
|
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as
|
||||||
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
|
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
|
||||||
if OS.has_feature("web"):
|
if OS.has_feature("web"):
|
||||||
@@ -111,11 +136,11 @@ func _ready() -> void:
|
|||||||
for a in [_anim_swipe, _anim_smash, _anim_leap, _anim_death]:
|
for a in [_anim_swipe, _anim_smash, _anim_leap, _anim_death]:
|
||||||
_set_no_loop(a)
|
_set_no_loop(a)
|
||||||
_anim_player.playback_default_blend_time = 0.12
|
_anim_player.playback_default_blend_time = 0.12
|
||||||
_hit_area.body_entered.connect(_on_body_entered)
|
|
||||||
_blood_burst = preload("res://blood_burst.gd").new()
|
_blood_burst = preload("res://blood_burst.gd").new()
|
||||||
add_child(_blood_burst)
|
add_child(_blood_burst)
|
||||||
add_child(_dust)
|
add_child(_dust)
|
||||||
_setup_claws()
|
_setup_claws()
|
||||||
|
_setup_attack_hitboxes()
|
||||||
_enter_neutral()
|
_enter_neutral()
|
||||||
|
|
||||||
#take hit flashing
|
#take hit flashing
|
||||||
@@ -211,6 +236,9 @@ func _physics_process(delta: float) -> void:
|
|||||||
_acquire_bull()
|
_acquire_bull()
|
||||||
_update_enrage()
|
_update_enrage()
|
||||||
_stagger_cd = maxf(0.0, _stagger_cd - delta)
|
_stagger_cd = maxf(0.0, _stagger_cd - delta)
|
||||||
|
_tick_attack_hitbox()
|
||||||
|
if _bull != null:
|
||||||
|
_sweep_bull_ram()
|
||||||
|
|
||||||
match _state:
|
match _state:
|
||||||
State.NEUTRAL: _tick_neutral(delta)
|
State.NEUTRAL: _tick_neutral(delta)
|
||||||
@@ -285,37 +313,17 @@ func _decide() -> void:
|
|||||||
# Short range: claw attack
|
# Short range: claw attack
|
||||||
options = [[State.SWIPE, 3.0]]
|
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:
|
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 = [
|
options = [
|
||||||
[State.SMASH, 3.0 if _enraged else 2.5],
|
[State.SMASH, 1.0],
|
||||||
[State.PROWL, 3.0]
|
[State.LEAP, 2.0]
|
||||||
]
|
]
|
||||||
|
|
||||||
_start_routine(_weighted_pick(options))
|
_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
|
# 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.
|
# doesn't whiff into empty air after the player rolls away.
|
||||||
func _range_allows(routine: State) -> bool:
|
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)
|
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
|
||||||
if _hits_done == 0 and frac >= 0.25:
|
if _hits_done == 0 and frac >= 0.25:
|
||||||
_hits_done = 1
|
_hits_done = 1
|
||||||
|
_play_fx(_claw_fx_clip(0))
|
||||||
_try_hit(DP.f("bear_swipe_reach"))
|
_try_hit(DP.f("bear_swipe_reach"))
|
||||||
elif _hits_done == 1 and frac >= 0.65:
|
elif _hits_done == 1 and frac >= 0.65:
|
||||||
_hits_done = 2
|
_hits_done = 2
|
||||||
|
_play_fx(_claw_fx_clip(1))
|
||||||
_try_hit(DP.f("bear_swipe_reach"))
|
_try_hit(DP.f("bear_swipe_reach"))
|
||||||
if _phase_timer <= 0.0:
|
if _phase_timer <= 0.0:
|
||||||
_begin_recover(DP.f("bear_swipe_recover"))
|
_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)
|
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
|
||||||
if _hits_done == 0 and frac >= 0.4:
|
if _hits_done == 0 and frac >= 0.4:
|
||||||
_hits_done = 1
|
_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:
|
if _phase_timer <= 0.0:
|
||||||
_begin_recover(DP.f("bear_smash_recover"))
|
_begin_recover(DP.f("bear_smash_recover"))
|
||||||
Phase.RECOVER:
|
Phase.RECOVER:
|
||||||
@@ -509,7 +522,8 @@ func _tick_leap(delta: float) -> void:
|
|||||||
elif _leap_airborne or _phase_timer <= 0.0:
|
elif _leap_airborne or _phase_timer <= 0.0:
|
||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
velocity.z = 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 = Phase.RECOVER
|
||||||
_phase_timer = _scaled(DP.f("bear_leap_recover"))
|
_phase_timer = _scaled(DP.f("bear_leap_recover"))
|
||||||
Phase.RECOVER:
|
Phase.RECOVER:
|
||||||
@@ -620,17 +634,83 @@ func _try_hit(reach: float) -> bool:
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
# Radial slam (smash / leap landing): hits any way the bull is, within `radius`, with a
|
# Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under
|
||||||
# ground-pound shake and a burst of dust kicked up at the paws. No facing arc — you're
|
# bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene.
|
||||||
# caught if you're near where it comes down.
|
func _setup_attack_hitboxes() -> void:
|
||||||
func _slam(radius: float, shake: float) -> void:
|
var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null
|
||||||
_slam_dust()
|
if root == null:
|
||||||
if _bull == null:
|
|
||||||
_shake(shake)
|
|
||||||
return
|
return
|
||||||
if _flat_dist_to_bull() <= radius:
|
_leap_hitbox = root.get_node_or_null("LeapSlamHitbox") as Hitbox
|
||||||
_bull.call(&"take_sword_hit", "mauled")
|
_smash_hitbox = root.get_node_or_null("SmashHitbox") as Hitbox
|
||||||
_shake(shake)
|
_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.
|
# 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.restart()
|
||||||
_dust.emitting = true
|
_dust.emitting = true
|
||||||
|
|
||||||
"
|
|
||||||
# Tan ground-impact dust: a fast, wide, low burst that arcs up and settles — built in code
|
# 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.
|
# so the Bear scene needs no extra particle node.
|
||||||
func _make_slam_dust() -> CPUParticles3D:
|
func _make_slam_dust() -> CPUParticles3D:
|
||||||
@@ -689,7 +769,6 @@ func _make_slam_dust() -> CPUParticles3D:
|
|||||||
p.scale_amount_min = 0.6
|
p.scale_amount_min = 0.6
|
||||||
p.scale_amount_max = 1.4
|
p.scale_amount_max = 1.4
|
||||||
return p
|
return p
|
||||||
"
|
|
||||||
|
|
||||||
|
|
||||||
# A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so
|
# 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)
|
_take_hit(hit_dir, strength)
|
||||||
|
|
||||||
|
|
||||||
# Ramming: only a real charge from the bull wounds the bear; a gentle nudge is shrugged
|
# Only a horns-first charge wounds the bear. Swept (prev → now) so a tunnelling or point-blank charge
|
||||||
# off. The bear never damages the bull by colliding — only its swings/slams do.
|
# still lands where body_entered wouldn't; edge-triggered (_bull_ramming) so one charge = one wound.
|
||||||
func _on_body_entered(body: Node3D) -> void:
|
func _sweep_bull_ram() -> void:
|
||||||
if _state == State.DEAD or not body.is_in_group(&"player"):
|
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
|
return
|
||||||
var player := body as CharacterBody3D
|
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||||
if player.velocity.length() < DP.f("bear_hit_threshold"):
|
var speed := vel.length()
|
||||||
return
|
var ramming := false
|
||||||
var flat := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
if speed >= DP.f("bear_hit_threshold") and _horns_first(seg_from):
|
||||||
var dir := flat.normalized() if flat.length() > 0.5 else \
|
var a := Vector3(seg_from.x, 0.0, seg_from.z)
|
||||||
(global_position - player.global_position).normalized()
|
var b := Vector3(now.x, 0.0, now.z)
|
||||||
_take_hit(dir, player.velocity.length())
|
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:
|
func _take_hit(hit_dir: Vector3, strength: float) -> void:
|
||||||
@@ -939,6 +1044,43 @@ func _play(anim_name: StringName) -> void:
|
|||||||
_anim_player.play(anim_name)
|
_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).
|
# 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:
|
func _play_attack(anim_name: StringName, speed: float) -> void:
|
||||||
if _anim_player == null or anim_name == &"":
|
if _anim_player == null or anim_name == &"":
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://bsbed05ifr18s
|
|
||||||
+60
-36
@@ -9,8 +9,11 @@ extends Node
|
|||||||
## bodies get picked up within one interval.
|
## bodies get picked up within one interval.
|
||||||
##
|
##
|
||||||
## Toggle from the console:
|
## Toggle from the console:
|
||||||
## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false)
|
## show_hitboxes · show_attacks · show_bones · show_raycasts (true / false)
|
||||||
## show_hitboxes also surfaces the bull's and bear's body capsules, not just Area3Ds
|
## 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_states — floating AI-state tags + velocity vectors over each matador
|
||||||
## show_animation_name — floating current-clip tag over every AnimationPlayer host
|
## show_animation_name — floating current-clip tag over every AnimationPlayer host
|
||||||
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
|
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
|
||||||
@@ -21,13 +24,14 @@ extends Node
|
|||||||
const REBUILD_INTERVAL := 0.5
|
const REBUILD_INTERVAL := 0.5
|
||||||
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
|
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) # combatant hurt volumes (cyan, translucent)
|
||||||
const COLOR_AREA := Color(0.0, 0.9, 1.0) # hit-area 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_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_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_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 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_HALF := 20 # grid extends ±GRID_HALF cells from the origin
|
||||||
const GRID_STEP := 1.0 # metres between grid lines
|
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:
|
func _process(delta: float) -> void:
|
||||||
_reconcile(delta)
|
_reconcile(delta)
|
||||||
|
_pulse_attacks()
|
||||||
if DP.b("show_bones"):
|
if DP.b("show_bones"):
|
||||||
_draw_bones()
|
_draw_bones()
|
||||||
if DP.b("show_states"):
|
if DP.b("show_states"):
|
||||||
@@ -360,11 +365,11 @@ func _si(n: int) -> String:
|
|||||||
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
|
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
|
||||||
|
|
||||||
func _reconcile(delta: float) -> void:
|
func _reconcile(delta: float) -> void:
|
||||||
var col := DP.b("show_collisions")
|
|
||||||
var hit := DP.b("show_hitboxes")
|
var hit := DP.b("show_hitboxes")
|
||||||
|
var atk := DP.b("show_attacks")
|
||||||
var bone := DP.b("show_bones")
|
var bone := DP.b("show_bones")
|
||||||
var anim := DP.b("show_animation_name")
|
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():
|
if not _overlays.is_empty():
|
||||||
_clear_overlays()
|
_clear_overlays()
|
||||||
_skeletons.clear()
|
_skeletons.clear()
|
||||||
@@ -372,7 +377,7 @@ func _reconcile(delta: float) -> void:
|
|||||||
_prev_flags = ""
|
_prev_flags = ""
|
||||||
return
|
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
|
_reconcile_t -= delta
|
||||||
if flags != _prev_flags: # a toggle just changed — refresh immediately
|
if flags != _prev_flags: # a toggle just changed — refresh immediately
|
||||||
_prev_flags = flags
|
_prev_flags = flags
|
||||||
@@ -386,7 +391,7 @@ func _reconcile(delta: float) -> void:
|
|||||||
var wanted: Dictionary = {}
|
var wanted: Dictionary = {}
|
||||||
var scene := get_tree().current_scene
|
var scene := get_tree().current_scene
|
||||||
if scene != null:
|
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
|
# 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
|
# 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:
|
for id: int in wanted:
|
||||||
if not _overlays.has(id):
|
if not _overlays.has(id):
|
||||||
var entry: Array = wanted[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
|
# 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.
|
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
|
||||||
if node.is_in_group(&"public"):
|
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:
|
if node is CollisionShape3D:
|
||||||
var cs := node as CollisionShape3D
|
var cs := node as CollisionShape3D
|
||||||
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
|
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
|
||||||
var parent := cs.get_parent()
|
var kind := _classify(cs.get_parent())
|
||||||
var is_area := parent is Area3D
|
var show := (kind == "attack" and atk) or (kind == "hitbox" and hit)
|
||||||
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
|
if show:
|
||||||
# the skeleton; otherwise every corpse floods the view with capsules.
|
wanted[cs.get_instance_id()] = [cs, kind]
|
||||||
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]
|
|
||||||
for child in node.get_children():
|
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
|
# 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)
|
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()
|
var mi := MeshInstance3D.new()
|
||||||
if is_area:
|
|
||||||
var solid := _solid_mesh(cs.shape)
|
var solid := _solid_mesh(cs.shape)
|
||||||
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
|
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
|
||||||
mi.material_override = _translucent_material(COLOR_AREA)
|
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:
|
else:
|
||||||
mi.mesh = cs.shape.get_debug_mesh()
|
# The calm translucent-blue volume — depth-tested so it sits in the world.
|
||||||
mi.material_override = _wire_material(COLOR_BODY)
|
mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false)
|
||||||
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
cs.add_child(mi)
|
cs.add_child(mi)
|
||||||
return 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:
|
func _clear_overlays() -> void:
|
||||||
for id: int in _overlays:
|
for id: int in _overlays:
|
||||||
var mi: Variant = _overlays[id]
|
var mi: Variant = _overlays[id]
|
||||||
@@ -495,19 +526,12 @@ func _line_material() -> StandardMaterial3D:
|
|||||||
return mat
|
return mat
|
||||||
|
|
||||||
|
|
||||||
func _wire_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.albedo_color = color
|
|
||||||
mat.no_depth_test = true
|
|
||||||
return mat
|
|
||||||
|
|
||||||
|
|
||||||
func _translucent_material(color: Color) -> StandardMaterial3D:
|
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
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.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
|
||||||
|
mat.no_depth_test = overlay
|
||||||
return mat
|
return mat
|
||||||
|
|||||||
+58
-12
@@ -39,6 +39,10 @@ func _register_all() -> void:
|
|||||||
# bull_hit_iframes seconds of invulnerability so a single pass can't drain it.
|
# 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_max_hp", 10.0, 1.0, 100.0, 1.0)
|
||||||
_reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1)
|
_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 ─────────────────────────────────────────────────
|
# ── Dust speed thresholds ─────────────────────────────────────────────────
|
||||||
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
|
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
|
||||||
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.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_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_idle_max", 4.0, 0.5, 10.0, 0.1)
|
||||||
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
|
_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)
|
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
|
||||||
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
|
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
|
||||||
# shape; higher angular damp settles the flail instead of flopping flat.
|
# 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_range", 8.0, 2.0, 20.0)
|
||||||
_reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.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_duration", 1.8, 0.5, 5.0, 0.1)
|
||||||
_reg_f("Matador", "mat_attack_min_dist", 4.2, 0.5, 6.0, 0.1)
|
# Reach of the reach-based melee hit and its re-strike gap. This now governs ONLY the sidestep
|
||||||
# Forward drive during the opening of a melee swing — turns the stationary stab
|
# PASS (mat charges the bull past with the blade out): a fast lateral sweep where the thin blade
|
||||||
# into an estocada lunge so the blade covers ground toward the bull.
|
# Area would tunnel, so a center-to-center distance check is the reliable gore. The stationary
|
||||||
_reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0)
|
# ATTACK stab no longer uses this — it drives off the sword's BladeHitbox collider (see below).
|
||||||
_reg_f("Matador", "mat_lunge_time", 0.22, 0.0, 0.6, 0.01)
|
_reg_f("Matador", "mat_stab_reach", 2.0, 0.5, 4.0, 0.1)
|
||||||
# Reach of the reach-based stab (the reliable melee hit) and its re-strike gap.
|
# Distance the matador plants at to swing. The ATTACK stab damages off the animated BladeHitbox,
|
||||||
# The bull dies if it comes inside mat_stab_reach of a matador that's attacking,
|
# whose thrust reaches ~3.75 m at full extension — so this MUST stay under that or the blade never
|
||||||
# bracing, or passing with a drawn blade — closing in is genuinely deadly.
|
# touches the bull and the stab whiffs. 3.5 m keeps the matador clear of the bull's ~1.8 m-long
|
||||||
_reg_f("Matador", "mat_stab_reach", 1.7, 0.5, 4.0, 0.1)
|
# 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)
|
_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.
|
# 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
|
# Stops "sword's off to the side but I still died" — you're only gored when the
|
||||||
# blade is actually presented at you.
|
# blade is actually presented at you.
|
||||||
_reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0)
|
_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)
|
_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)
|
_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 —
|
# Playback rate of the Draw_weapon clip used for both drawing and sheathing —
|
||||||
# higher = snappier unholster/holster.
|
# 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_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_recover", 0.35, 0.05, 3.0, 0.05)
|
||||||
_reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1)
|
_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.
|
# 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_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_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
|
# 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
|
# 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
|
# 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_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_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_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.
|
# 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)
|
_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).
|
# 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
|
# 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.)
|
# 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)
|
_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.
|
# 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)
|
_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
|
# 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_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
|
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
|
||||||
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
||||||
_reg_b("Debug", "show_collisions", false)
|
|
||||||
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
|
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
|
||||||
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
|
# 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
|
# 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_speed", 3.0, 0.0, 10.0, 0.1)
|
||||||
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
|
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
|
||||||
_reg_b("Debug", "show_hitboxes", false)
|
_reg_b("Debug", "show_hitboxes", false)
|
||||||
|
_reg_b("Debug", "show_attacks", false)
|
||||||
_reg_b("Debug", "show_bones", false)
|
_reg_b("Debug", "show_bones", false)
|
||||||
_reg_b("Debug", "show_raycasts", false)
|
_reg_b("Debug", "show_raycasts", false)
|
||||||
_reg_b("Debug", "show_tail", false)
|
_reg_b("Debug", "show_tail", false)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dlwjiw42q6b5f
|
||||||
+216
-84
@@ -1,13 +1,9 @@
|
|||||||
extends CharacterBody3D
|
extends CharacterBody3D
|
||||||
|
|
||||||
# WANDER → bull outside flee range, roaming freely
|
# WANDER → roam/taunt outside flee range. FLEE → bull close: back away (raycast-steered).
|
||||||
# FLEE → bull within range, matador backs away (raycast-steered around walls)
|
# BRACE → charge detected: plant + face (cite). SIDESTEP → bull commits: lateral pass (pase).
|
||||||
# BRACE → charge detected: plant feet, face the bull (cite phase)
|
# ATTACK → chase and strike. ROLL → hit but rolled: survive. THROW → wind up + hurl the spear.
|
||||||
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
|
# RAGDOLL → hit, no roll.
|
||||||
# ATTACK → post-dodge or mid-range: chase and strike
|
|
||||||
# ROLL → hit by bull but roll chance fires: survive and recover
|
|
||||||
# THROW → planted, winding up an overhand throw, then releasing the sword
|
|
||||||
# RAGDOLL → hit by bull, no roll chance
|
|
||||||
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
|
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
|
||||||
|
|
||||||
# The matador's high-level *want*, chosen by a weighted utility vote (see
|
# The matador's high-level *want*, chosen by a weighted utility vote (see
|
||||||
@@ -32,7 +28,9 @@ var _dodge_cd: float = 0.0
|
|||||||
var _will_dodge: bool = true
|
var _will_dodge: bool = true
|
||||||
var _attack_timer: float = 0.0
|
var _attack_timer: float = 0.0
|
||||||
var _swing_timer: float = 0.0
|
var _swing_timer: float = 0.0
|
||||||
var _lunge_timer: float = 0.0
|
var _attack_cd: float = 0.0
|
||||||
|
var _bull_prev_pos: Vector3 = Vector3.ZERO
|
||||||
|
var _bull_prev_seen: bool = false
|
||||||
var _roll_timer: float = 0.0
|
var _roll_timer: float = 0.0
|
||||||
var _roll_dir: Vector3 = Vector3.ZERO
|
var _roll_dir: Vector3 = Vector3.ZERO
|
||||||
var _is_spawning: bool = false
|
var _is_spawning: bool = false
|
||||||
@@ -52,7 +50,7 @@ var _drawing: bool = false
|
|||||||
var _sheathing: bool = false
|
var _sheathing: bool = false
|
||||||
var _draw_timer: float = 0.0
|
var _draw_timer: float = 0.0
|
||||||
var _draw_len: float = 0.0
|
var _draw_len: float = 0.0
|
||||||
var _sword_blade_area: Area3D = null
|
var _sword_blade_area: Hitbox = null
|
||||||
var _spear_node: Node3D = null
|
var _spear_node: Node3D = null
|
||||||
var _blade_hit_cd: float = 0.0
|
var _blade_hit_cd: float = 0.0
|
||||||
var _death_player: AudioStreamPlayer = null
|
var _death_player: AudioStreamPlayer = null
|
||||||
@@ -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.
|
# reparents from holster to hand at this point in the animation.
|
||||||
const _DRAW_GRAB_AT: float = 0.55
|
const _DRAW_GRAB_AT: float = 0.55
|
||||||
|
|
||||||
const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb")
|
# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn.
|
||||||
const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb")
|
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
|
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)
|
_dodge_cd = maxf(0.0, _dodge_cd - delta)
|
||||||
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
|
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
|
||||||
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
|
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
|
||||||
|
_attack_cd = maxf(0.0, _attack_cd - delta)
|
||||||
|
if _bull != null:
|
||||||
|
_sweep_bull_gore()
|
||||||
if _state != State.RAGDOLL and _bull != null:
|
if _state != State.RAGDOLL and _bull != null:
|
||||||
_update_ai_state(delta)
|
_update_ai_state(delta)
|
||||||
|
|
||||||
@@ -185,9 +187,8 @@ func _physics_process(delta: float) -> void:
|
|||||||
_advance_draw(delta)
|
_advance_draw(delta)
|
||||||
|
|
||||||
|
|
||||||
# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a
|
# Utility-driven brain. Committed states (brace / pass / roll / throw wind-up) run to completion on
|
||||||
# throw wind-up) run to completion on their own timers; from the free states the
|
# their own timers; from the free states the matador re-scores every mat_decide_interval.
|
||||||
# matador re-scores its options every mat_decide_interval and acts on the winner.
|
|
||||||
func _update_ai_state(delta: float) -> void:
|
func _update_ai_state(delta: float) -> void:
|
||||||
if _state == State.BRACE or _state == State.SIDESTEP \
|
if _state == State.BRACE or _state == State.SIDESTEP \
|
||||||
or _state == State.ROLL or _state == State.THROW:
|
or _state == State.ROLL or _state == State.THROW:
|
||||||
@@ -204,9 +205,8 @@ func _update_ai_state(delta: float) -> void:
|
|||||||
_enter_intent(intent)
|
_enter_intent(intent)
|
||||||
|
|
||||||
|
|
||||||
# Score each intent from weighted heuristics and return the winner. Weights (the
|
# Score each intent from weighted mat_w_* heuristics and return the winner; a small commit bonus on
|
||||||
# mat_w_* params) shape the personality; a small commit bonus on the current intent
|
# the current intent stops it flip-flopping between two near-tied options.
|
||||||
# stops it flip-flopping between two near-tied options.
|
|
||||||
func _choose_intent() -> Intent:
|
func _choose_intent() -> Intent:
|
||||||
var dist := global_position.distance_to(_bull.global_position)
|
var dist := global_position.distance_to(_bull.global_position)
|
||||||
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||||
@@ -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
|
# A matador won't commit to a strike it can't bail out of, so a spent dodge
|
||||||
# cooldown makes attacking (and dodging outright) less attractive.
|
# cooldown makes attacking (and dodging outright) less attractive.
|
||||||
var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
|
var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
|
||||||
|
# The post-stab recovery beat (mat_stab_recovery) kills ATTACK outright — the matador circles
|
||||||
|
# or gives ground between thrusts rather than machine-gunning the bull; DODGE/FLEE stay live.
|
||||||
|
var attack_ready := 0.0 if _attack_cd > 0.0 else 1.0
|
||||||
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
|
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
|
||||||
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
|
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
|
||||||
|
|
||||||
@@ -247,7 +250,7 @@ func _choose_intent() -> Intent:
|
|||||||
# Press the attack; ease off a touch when a charge is barrelling in. Supporting
|
# 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.
|
# matadors barely press — they yield the pass to whoever's engaging.
|
||||||
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
|
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
|
||||||
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
|
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult * attack_ready,
|
||||||
# Sidestep / plant against an incoming charge.
|
# Sidestep / plant against an incoming charge.
|
||||||
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
|
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
|
||||||
# Hurl the blade — an occasional ranged flourish from an engaging matador when
|
# Hurl the blade — an occasional ranged flourish from an engaging matador when
|
||||||
@@ -255,6 +258,9 @@ func _choose_intent() -> Intent:
|
|||||||
Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
|
Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
|
||||||
if (throw_ok and is_engager) else -1.0,
|
if (throw_ok and is_engager) else -1.0,
|
||||||
}
|
}
|
||||||
|
# Commit bonus keeps a near-tied choice from flip-flopping — but don't re-reinforce a
|
||||||
|
# just-swung ATTACK we're recovering from, or the matador clings to it and spams the bull.
|
||||||
|
if not (_intent == Intent.ATTACK and _attack_cd > 0.0):
|
||||||
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
|
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
|
||||||
|
|
||||||
var best: Intent = Intent.WANDER
|
var best: Intent = Intent.WANDER
|
||||||
@@ -301,9 +307,8 @@ func _enter_intent(intent: Intent) -> void:
|
|||||||
_start_throw()
|
_start_throw()
|
||||||
|
|
||||||
|
|
||||||
# How many other live matadors are closer to the bull than this one. 0 = the
|
# How many other live matadors are closer to the bull than this one (0 = the lead engager); the
|
||||||
# closest (the natural lead engager); the utility brain gives the nearest
|
# brain lets the nearest mat_engage_slots press while the rest spread out and taunt.
|
||||||
# mat_engage_slots the go-ahead to press while the rest spread out and taunt.
|
|
||||||
func _engagement_rank() -> int:
|
func _engagement_rank() -> int:
|
||||||
if _bull == null:
|
if _bull == null:
|
||||||
return 0
|
return 0
|
||||||
@@ -347,6 +352,19 @@ func _is_charge_incoming() -> bool:
|
|||||||
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
|
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
|
||||||
|
|
||||||
|
|
||||||
|
# Bull charging fast and aimed straight at the matador, at ANY range (unlike _is_charge_incoming,
|
||||||
|
# which is capped to brace range) — so a taunting matador can answer a charge from across the arena.
|
||||||
|
func _charge_aimed_at_me() -> bool:
|
||||||
|
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||||
|
if bull_flat.length() < DP.f("mat_charge_speed"):
|
||||||
|
return false
|
||||||
|
var to_me := global_position - _bull.global_position
|
||||||
|
to_me.y = 0.0
|
||||||
|
if to_me.length() < 0.1:
|
||||||
|
return true
|
||||||
|
return bull_flat.normalized().dot(to_me.normalized()) > 0.9
|
||||||
|
|
||||||
|
|
||||||
# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
|
# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
|
||||||
# Randomises among available clear angles so juke direction is unpredictable.
|
# Randomises among available clear angles so juke direction is unpredictable.
|
||||||
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
|
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
|
||||||
@@ -407,6 +425,19 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void:
|
|||||||
func _tick_wander(delta: float) -> void:
|
func _tick_wander(delta: float) -> void:
|
||||||
# Plant the feet while drawing / sheathing instead of sliding through the clip.
|
# Plant the feet while drawing / sheathing instead of sliding through the clip.
|
||||||
if _drawing or _sheathing or _idle_timer > 0.0:
|
if _drawing or _sheathing or _idle_timer > 0.0:
|
||||||
|
# A charge aimed at a taunting matador snaps it out of the pose: hurl a spear at a bull
|
||||||
|
# barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush.
|
||||||
|
if _idle_timer > 0.0 and not _drawing and not _sheathing \
|
||||||
|
and _bull != null and _charge_aimed_at_me():
|
||||||
|
var dist := global_position.distance_to(_bull.global_position)
|
||||||
|
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
|
||||||
|
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
|
||||||
|
if dist > DP.f("mat_brace_range") and throw_ok:
|
||||||
|
_start_throw() # far charge — get the spear off before it arrives
|
||||||
|
return
|
||||||
|
if dist <= DP.f("mat_brace_range"):
|
||||||
|
_start_brace() # closer rush — draw the sword and cite the charge
|
||||||
|
return
|
||||||
_idle_timer = maxf(0.0, _idle_timer - delta)
|
_idle_timer = maxf(0.0, _idle_timer - delta)
|
||||||
_decelerate(10.0, delta)
|
_decelerate(10.0, delta)
|
||||||
# Taunt toward the bull so the posturing plays to it, not off into space.
|
# 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:
|
func _tick_attack(delta: float) -> void:
|
||||||
_attack_timer -= delta
|
_attack_timer -= delta
|
||||||
|
var was_swinging := _swing_timer > 0.0
|
||||||
_swing_timer = maxf(0.0, _swing_timer - delta)
|
_swing_timer = maxf(0.0, _swing_timer - delta)
|
||||||
# Plant the feet while drawing instead of sliding toward the bull mid-clip.
|
# Plant the feet while drawing instead of sliding toward the bull mid-clip.
|
||||||
if _drawing or _sheathing:
|
if _drawing or _sheathing:
|
||||||
@@ -543,22 +575,41 @@ func _tick_attack(delta: float) -> void:
|
|||||||
to_bull.y = 0.0
|
to_bull.y = 0.0
|
||||||
var dist := to_bull.length()
|
var dist := to_bull.length()
|
||||||
|
|
||||||
# Commit to a full swing once in range instead of flipping Run/Attack at the
|
# A finished stab enforces a recovery beat (no re-commit for mat_stab_recovery), then decides
|
||||||
# distance boundary every frame (the old foot-slide jitter). When the swing
|
# what's next: a landed hit very likely breaks into a taunt/retreat, otherwise the usual roll
|
||||||
# ends the matador re-closes if the bull slipped away, or stabs again if not.
|
# to give ground / circle / throw — anything but instantly drilling the same thrust again.
|
||||||
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
|
if was_swinging and _swing_timer <= 0.0:
|
||||||
|
_attack_cd = DP.f("mat_stab_recovery")
|
||||||
|
var landed := _sword_blade_area != null and _sword_blade_area.did_hit()
|
||||||
|
if landed and randf() < DP.f("mat_taunt_after_hit"):
|
||||||
|
_do_taunt_retreat()
|
||||||
|
return
|
||||||
|
if randf() < DP.f("mat_stab_followup_chance"):
|
||||||
|
_do_stab_followup()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run in (Run clip) to mat_stab_standoff, then plant and swing on the spot — the standoff sits
|
||||||
|
# inside the sword's ~3.75 m thrust reach; killing velocity on commit stops the feet sliding.
|
||||||
|
# The recovery beat gates the next commit so back-to-back stabs can't machine-gun the bull.
|
||||||
|
if _swing_timer <= 0.0 and _attack_cd <= 0.0 and dist <= DP.f("mat_stab_standoff") and _sword_in_hand:
|
||||||
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
|
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
|
||||||
_lunge_timer = DP.f("mat_lunge_time")
|
velocity.x = 0.0
|
||||||
|
velocity.z = 0.0
|
||||||
|
# Re-arm fresh each swing — back-to-back stabs commit before the else-branch deactivate runs.
|
||||||
|
if _sword_blade_area != null:
|
||||||
|
_sword_blade_area.deactivate()
|
||||||
|
|
||||||
if _swing_timer > 0.0:
|
if _swing_timer > 0.0:
|
||||||
_lunge_timer = maxf(0.0, _lunge_timer - delta)
|
|
||||||
if _lunge_timer > 0.0 and dist > 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)
|
_play_anim(_ANIM_ATTACK)
|
||||||
_try_melee_hit(DP.f("mat_stab_reach"))
|
# Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only
|
||||||
|
# when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap.
|
||||||
|
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
|
||||||
|
_sword_blade_area.activate()
|
||||||
|
_sword_blade_area.sweep()
|
||||||
else:
|
else:
|
||||||
|
if _sword_blade_area != null:
|
||||||
|
_sword_blade_area.deactivate()
|
||||||
var dir := _steer_clear(to_bull.normalized(), delta)
|
var dir := _steer_clear(to_bull.normalized(), delta)
|
||||||
_accelerate(dir, DP.f("mat_attack_speed"), delta)
|
_accelerate(dir, DP.f("mat_attack_speed"), delta)
|
||||||
_play_anim(_ANIM_RUN)
|
_play_anim(_ANIM_RUN)
|
||||||
@@ -566,6 +617,50 @@ func _tick_attack(delta: float) -> void:
|
|||||||
move_and_slide()
|
move_and_slide()
|
||||||
|
|
||||||
|
|
||||||
|
# How far through the attack swing: 0 (just committed) → 1 (finished), tracking the clip's progress.
|
||||||
|
func _swing_phase() -> float:
|
||||||
|
var length := _anim_length(_ANIM_ATTACK, 0.9)
|
||||||
|
if length <= 0.0:
|
||||||
|
return 1.0
|
||||||
|
return clampf(1.0 - _swing_timer / length, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# Break off after a stab instead of drilling the same thrust: give ground, hurl a spear, or circle.
|
||||||
|
# The brain re-engages after; _decide_cd is reset so the pick plays out before it re-scores.
|
||||||
|
func _do_stab_followup() -> void:
|
||||||
|
_decide_cd = DP.f("mat_decide_interval")
|
||||||
|
_dodge_cd = DP.f("mat_dodge_cooldown")
|
||||||
|
_steer_cd = 0.0
|
||||||
|
var roll := randf()
|
||||||
|
if roll < 0.2 and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node):
|
||||||
|
_start_throw() # ranged flourish
|
||||||
|
elif roll < 0.6:
|
||||||
|
_state = State.FLEE # give ground after the thrust
|
||||||
|
_intent = Intent.FLEE
|
||||||
|
else:
|
||||||
|
_state = State.WANDER # circle to a fresh angle before pressing again
|
||||||
|
_intent = Intent.WANDER
|
||||||
|
_pick_wander_target()
|
||||||
|
|
||||||
|
|
||||||
|
# A stab that LANDED: milk it. Break into a long taunt held toward the bull, then drift out to the
|
||||||
|
# showmanship ring — and lock ATTACK out for the whole hold (_attack_cd) so the brain can't drag the
|
||||||
|
# matador straight back into another thrust. DODGE/FLEE stay live, so a charging bull is still
|
||||||
|
# answered. This is what stops the "stab, stab, stab" spam after the matador connects.
|
||||||
|
func _do_taunt_retreat() -> void:
|
||||||
|
_state = State.WANDER
|
||||||
|
_intent = Intent.WANDER
|
||||||
|
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3)
|
||||||
|
_idle_anim = _pick_taunt()
|
||||||
|
_taunt_swap = randf_range(1.5, 3.0)
|
||||||
|
_attack_cd = maxf(_attack_cd, _idle_timer)
|
||||||
|
_dodge_cd = DP.f("mat_dodge_cooldown")
|
||||||
|
_steer_cd = 0.0
|
||||||
|
_pick_wander_target()
|
||||||
|
if _sword_blade_area != null:
|
||||||
|
_sword_blade_area.deactivate()
|
||||||
|
|
||||||
|
|
||||||
func _enter_roll(hit_dir: Vector3) -> void:
|
func _enter_roll(hit_dir: Vector3) -> void:
|
||||||
_state = State.ROLL
|
_state = State.ROLL
|
||||||
# Match the state to the actual clip so the roll plays through once instead of
|
# Match the state to the actual clip so the roll plays through once instead of
|
||||||
@@ -579,9 +674,8 @@ func _enter_roll(hit_dir: Vector3) -> void:
|
|||||||
_anim_player.seek(0.0, true)
|
_anim_player.seek(0.0, true)
|
||||||
|
|
||||||
|
|
||||||
# Called by the spawner (deferred) to make the matador leap-roll toward a target
|
# Called (deferred) by the spawner to make the matador leap-roll toward a target on entry — the
|
||||||
# point on entry — the cinematic arena entrance. After the roll it transitions to
|
# cinematic arena entrance. After the roll it transitions to normal AI via the ROLL → FLEE path.
|
||||||
# normal AI via the standard ROLL → FLEE path.
|
|
||||||
func enter_spawn_roll(toward: Vector3) -> void:
|
func enter_spawn_roll(toward: Vector3) -> void:
|
||||||
var dir := toward - global_position
|
var dir := toward - global_position
|
||||||
dir.y = 0.0
|
dir.y = 0.0
|
||||||
@@ -770,34 +864,84 @@ func _on_body_entered(body: Node3D) -> void:
|
|||||||
if not body.is_in_group(&"player"):
|
if not body.is_in_group(&"player"):
|
||||||
return
|
return
|
||||||
var player := body as CharacterBody3D
|
var player := body as CharacterBody3D
|
||||||
if player.velocity.length() < DP.f("mat_hit_threshold"):
|
var bull_speed := player.velocity.length()
|
||||||
|
if bull_speed < DP.f("mat_hit_threshold"):
|
||||||
|
return
|
||||||
|
if not _horns_first(player.global_position):
|
||||||
return
|
return
|
||||||
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||||
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
|
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
|
||||||
(global_position - player.global_position).normalized()
|
(global_position - player.global_position).normalized()
|
||||||
|
_take_bull_charge(bull_speed, hit_dir)
|
||||||
|
|
||||||
if randf() < DP.f("mat_roll_chance"):
|
|
||||||
|
# Tunnel-proof charge detection: at dash speed the bull skips clean over the thin HitArea between
|
||||||
|
# ticks, so body_entered never fires. Sweep its actual travel (prev → now) and gore on any pass within
|
||||||
|
# mat_charge_gore_radius. Runs every frame (even ragdolled) to keep the segment fresh.
|
||||||
|
func _sweep_bull_gore() -> void:
|
||||||
|
var now := _bull.global_position
|
||||||
|
var seg_from := _bull_prev_pos if _bull_prev_seen else now
|
||||||
|
_bull_prev_pos = now
|
||||||
|
_bull_prev_seen = true
|
||||||
|
if _state == State.RAGDOLL or _state == State.ROLL:
|
||||||
|
return
|
||||||
|
var flat_vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||||
|
var bull_speed := flat_vel.length()
|
||||||
|
if bull_speed < DP.f("mat_hit_threshold"):
|
||||||
|
return
|
||||||
|
var a := Vector3(seg_from.x, 0.0, seg_from.z)
|
||||||
|
var b := Vector3(now.x, 0.0, now.z)
|
||||||
|
var p := Vector3(global_position.x, 0.0, global_position.z)
|
||||||
|
var closest := Geometry3D.get_closest_point_to_segment(p, a, b)
|
||||||
|
if p.distance_to(closest) > DP.f("mat_charge_gore_radius"):
|
||||||
|
return
|
||||||
|
if not _horns_first(seg_from):
|
||||||
|
return
|
||||||
|
var hit_dir := flat_vel.normalized() if bull_speed > 0.5 \
|
||||||
|
else (global_position - now).normalized()
|
||||||
|
_take_bull_charge(bull_speed, hit_dir)
|
||||||
|
|
||||||
|
|
||||||
|
# Horns-first gate: gore only when the bull's velocity points within bull_gore_arc of the direction
|
||||||
|
# from `from` to the matador — not a sideways brush or backing in. The dash ability skips the gate.
|
||||||
|
func _horns_first(from: Vector3) -> bool:
|
||||||
|
if _bull == null:
|
||||||
|
return false
|
||||||
|
if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"):
|
||||||
|
return true # the dash ability is a committed lunge — it gores on contact, no angle gate
|
||||||
|
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||||
|
if vel.length() < 0.5:
|
||||||
|
return false
|
||||||
|
var to_me := global_position - from
|
||||||
|
to_me.y = 0.0
|
||||||
|
if to_me.length() < 0.01:
|
||||||
|
return true
|
||||||
|
return vel.normalized().dot(to_me.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc")))
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve a connected charge: the survival roll fades as bull speed climbs (mat_charge_pierce), so a
|
||||||
|
# committed charge gores rather than getting shrugged off.
|
||||||
|
func _take_bull_charge(bull_speed: float, hit_dir: Vector3) -> void:
|
||||||
|
# The boulder roll bowls matadors over with its own launch + chain (player._roll_try_pop); don't
|
||||||
|
# pre-empt that with a quiet gore, or the ball rolls straight through without popping.
|
||||||
|
if _bull.has_method(&"is_rolling") and _bull.call(&"is_rolling"):
|
||||||
|
return
|
||||||
|
# The dash is a committed lunge — a connect is a guaranteed kill, no survival roll (it already
|
||||||
|
# bypasses the horns cone). A passive cruise-charge still leaves a roll chance that fades with speed.
|
||||||
|
var roll_chance := 0.0
|
||||||
|
if not (_bull.has_method(&"is_dashing") and _bull.call(&"is_dashing")):
|
||||||
|
var floor_spd := DP.f("mat_hit_threshold")
|
||||||
|
var full_spd := maxf(DP.f("mat_charge_speed"), floor_spd + 0.1)
|
||||||
|
var commit := clampf((bull_speed - floor_spd) / (full_spd - floor_spd), 0.0, 1.0)
|
||||||
|
roll_chance = DP.f("mat_roll_chance") * (1.0 - commit * DP.f("mat_charge_pierce"))
|
||||||
|
if randf() < roll_chance:
|
||||||
_enter_roll(hit_dir)
|
_enter_roll(hit_dir)
|
||||||
else:
|
else:
|
||||||
_enter_ragdoll(hit_dir, player.velocity.length())
|
_enter_ragdoll(hit_dir, bull_speed)
|
||||||
|
|
||||||
|
|
||||||
func _on_blade_hit(body: Node3D) -> void:
|
# Reach-based hit for the sidestep PASS (fast lateral sweep — the thin blade Area tunnels, so a
|
||||||
if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE:
|
# distance+facing check is the reliable gore; the ATTACK stab uses the blade collider instead).
|
||||||
return
|
|
||||||
if not body.is_in_group(&"player"):
|
|
||||||
return
|
|
||||||
if _blade_hit_cd > 0.0:
|
|
||||||
return
|
|
||||||
_blade_hit_cd = DP.f("mat_stab_cooldown")
|
|
||||||
body.call(&"take_sword_hit", "gored")
|
|
||||||
|
|
||||||
|
|
||||||
# Reach-based stab. A charging bull crosses metres per physics frame, so it easily
|
|
||||||
# tunnels through the thin blade Area between frames — this distance check is the
|
|
||||||
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
|
|
||||||
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
|
|
||||||
# blade actually presented, not held off to the side — it's gored.
|
|
||||||
func _try_melee_hit(reach: float) -> void:
|
func _try_melee_hit(reach: float) -> void:
|
||||||
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
|
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
|
||||||
return
|
return
|
||||||
@@ -806,8 +950,6 @@ func _try_melee_hit(reach: float) -> void:
|
|||||||
var dist := to_bull.length()
|
var dist := to_bull.length()
|
||||||
if dist > reach or dist < 0.01:
|
if dist > reach or dist < 0.01:
|
||||||
return
|
return
|
||||||
# Only lands within the frontal arc — you're gored on a presented blade, not by
|
|
||||||
# brushing a matador whose sword is pointing elsewhere.
|
|
||||||
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
|
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
|
||||||
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
|
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
|
||||||
return
|
return
|
||||||
@@ -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:
|
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
|
||||||
_state = State.RAGDOLL
|
_state = State.RAGDOLL
|
||||||
if _sword_blade_area:
|
if _sword_blade_area != null:
|
||||||
_sword_blade_area.monitoring = false
|
_sword_blade_area.deactivate()
|
||||||
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
|
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
|
||||||
killed.emit()
|
killed.emit()
|
||||||
var cleanup_timer := get_tree().create_timer(4.0)
|
var cleanup_timer := get_tree().create_timer(4.0)
|
||||||
@@ -1011,10 +1153,9 @@ func _pick_wander_target() -> void:
|
|||||||
|
|
||||||
# ── Sword attachment ──────────────────────────────────────────────────────────
|
# ── Sword attachment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand
|
# The rig (matador_v03) carries two bones: weapon_bone (right-hand grip) and weapon_rest_bone (hip
|
||||||
# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
|
# holster). The sword rides in the holster, carried to the hand in combat (_carry_sword); each bone
|
||||||
# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
|
# has its own DP-tunable offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
|
||||||
# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
|
|
||||||
func _setup_sword() -> void:
|
func _setup_sword() -> void:
|
||||||
if not _skeleton:
|
if not _skeleton:
|
||||||
return
|
return
|
||||||
@@ -1047,28 +1188,15 @@ func _spawn_sword() -> void:
|
|||||||
_drawing = false
|
_drawing = false
|
||||||
_apply_sword_grip()
|
_apply_sword_grip()
|
||||||
|
|
||||||
# Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
|
# The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
|
||||||
# Only monitors while the sword is in hand (see _carry_sword).
|
# _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
|
||||||
var blade_cap := CapsuleShape3D.new()
|
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
|
||||||
blade_cap.radius = 0.18
|
if _sword_blade_area != null:
|
||||||
blade_cap.height = 1.30
|
_sword_blade_area.deactivate()
|
||||||
var blade_cs := CollisionShape3D.new()
|
|
||||||
blade_cs.shape = blade_cap
|
|
||||||
blade_cs.position = Vector3(0.0, 0.0, 0.8)
|
|
||||||
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
|
|
||||||
|
|
||||||
_sword_blade_area = Area3D.new()
|
|
||||||
_sword_blade_area.collision_layer = 0
|
|
||||||
_sword_blade_area.collision_mask = 1
|
|
||||||
_sword_blade_area.monitoring = _sword_in_hand
|
|
||||||
_sword_blade_area.add_child(blade_cs)
|
|
||||||
_sword_node.add_child(_sword_blade_area)
|
|
||||||
_sword_blade_area.body_entered.connect(_on_blade_hit)
|
|
||||||
|
|
||||||
|
|
||||||
# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
|
# The sword is MELEE-only: drawn for ATTACK / BRACE / SIDESTEP, holstered otherwise. Ranged throws
|
||||||
# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
|
# use a spear (_spawn_spear_in_hand), so THROW keeps the sword holstered.
|
||||||
# (see _spawn_spear_in_hand), so THROW keeps the sword holstered.
|
|
||||||
func _wants_sword_drawn() -> bool:
|
func _wants_sword_drawn() -> bool:
|
||||||
return _state == State.ATTACK \
|
return _state == State.ATTACK \
|
||||||
or _state == State.BRACE or _state == State.SIDESTEP
|
or _state == State.BRACE or _state == State.SIDESTEP
|
||||||
@@ -1077,6 +1205,9 @@ func _wants_sword_drawn() -> bool:
|
|||||||
func _update_sword_carry() -> void:
|
func _update_sword_carry() -> void:
|
||||||
if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
|
if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
|
||||||
return
|
return
|
||||||
|
# The blade collider lives only during the ATTACK thrust; leaving ATTACK disarms it.
|
||||||
|
if _state != State.ATTACK and _sword_blade_area != null:
|
||||||
|
_sword_blade_area.deactivate()
|
||||||
if _wants_sword_drawn():
|
if _wants_sword_drawn():
|
||||||
if not _sword_in_hand and not _drawing:
|
if not _sword_in_hand and not _drawing:
|
||||||
_begin_draw()
|
_begin_draw()
|
||||||
@@ -1156,8 +1287,9 @@ func _carry_sword(in_hand: bool) -> void:
|
|||||||
_sword_in_hand = in_hand
|
_sword_in_hand = in_hand
|
||||||
_sword_node.reparent(target, false)
|
_sword_node.reparent(target, false)
|
||||||
_apply_sword_grip()
|
_apply_sword_grip()
|
||||||
if _sword_blade_area:
|
# Carrying never arms the blade — only the ATTACK thrust does (see _tick_attack).
|
||||||
_sword_blade_area.monitoring = in_hand
|
if _sword_blade_area != null:
|
||||||
|
_sword_blade_area.deactivate()
|
||||||
|
|
||||||
|
|
||||||
func _apply_sword_grip() -> void:
|
func _apply_sword_grip() -> void:
|
||||||
|
|||||||
@@ -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")
|
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
|
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
|
||||||
# the normal locomotion path next frame.
|
# (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:
|
func _end_roll() -> void:
|
||||||
_ability_active = false
|
_ability_active = false
|
||||||
_active_ability = -1
|
_active_ability = -1
|
||||||
@@ -1126,6 +1138,12 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
|
|||||||
died.emit(cause)
|
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
|
# 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
|
# 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.
|
# hide) with a touch of lift before gravity drags it down.
|
||||||
|
|||||||
+25
-2
@@ -10,6 +10,8 @@ extends RigidBody3D
|
|||||||
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
|
# (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.
|
# it in place until the despawn timer clears it.
|
||||||
|
|
||||||
|
const PhysicsLayers = preload("res://physics_layers.gd")
|
||||||
|
|
||||||
const _DESPAWN_SEC: float = 8.0
|
const _DESPAWN_SEC: float = 8.0
|
||||||
const _STUCK_GROUP: StringName = &"stuck_spear"
|
const _STUCK_GROUP: StringName = &"stuck_spear"
|
||||||
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
|
# 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()
|
var s := SpearProjectile.new()
|
||||||
s.gravity_scale = gravity
|
s.gravity_scale = gravity
|
||||||
s.collision_layer = 0
|
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.contact_monitor = true
|
||||||
s.max_contacts_reported = 6
|
s.max_contacts_reported = 6
|
||||||
parent.add_child(s)
|
parent.add_child(s)
|
||||||
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
|
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
|
||||||
s.global_transform = Transform3D(_aim_basis(velocity), origin)
|
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.reparent(s, false)
|
||||||
mesh.transform = Transform3D.IDENTITY
|
mesh.transform = Transform3D.IDENTITY
|
||||||
s._mesh = mesh
|
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
|
s.linear_velocity = velocity
|
||||||
_despawn(s)
|
_despawn(s)
|
||||||
return s
|
return s
|
||||||
@@ -84,6 +95,18 @@ static func shed_all(bull: Node) -> void:
|
|||||||
_despawn(rb)
|
_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:
|
static func _shaft_collider() -> CollisionShape3D:
|
||||||
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
|
# 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.
|
# can't tunnel past the bull's collision spheres.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bamw0bnk3bfvs
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://duyfxhca4np78
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cvweb2dnv23s
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://o2cdiyq77u8k
|
||||||
@@ -283,7 +283,7 @@ func _check_overlays() -> void:
|
|||||||
_note("overlay check: DP / DebugDraw autoload missing")
|
_note("overlay check: DP / DebugDraw autoload missing")
|
||||||
return
|
return
|
||||||
var flags := [
|
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",
|
"show_animation_name", "show_wireframe", "show_grid", "show_axes",
|
||||||
]
|
]
|
||||||
for flag: String in flags:
|
for flag: String in flags:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://wplxv71pxkrl
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dcwtku2xvxswa
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://deueaav5qscnc
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c1r546ktiw2ka
|
||||||
Reference in New Issue
Block a user