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:
2026-09-03 00:59:23 +03:00
parent 4efc6763f0
commit 5c1e881a53
39 changed files with 1555 additions and 376 deletions
+195 -53
View File
@@ -26,7 +26,25 @@ var _phase: Phase = Phase.WINDUP
var _bull: CharacterBody3D = null
var _skeleton: Skeleton3D = null
var _anim_player: AnimationPlayer = null
# Separate player baked into Assets/fx/Bear_FX.glb: each clip scales one FX mesh up from 0
# and back at its strike frame (self-hiding), so bear.gd just fires the matching clip.
var _fx_player: AnimationPlayer = null
var _fx_root: Node = null
# Shaped attack colliders live in BearAttackHitboxes.tscn (under bear_model, so they track the
# bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash.
# Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips
# "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the
# knock-up shaping, activates monitoring, and sweeps for overlaps while the clip runs.
var _leap_hitbox: Hitbox = null
var _smash_hitbox: Hitbox = null
var _hitbox_anim: AnimationPlayer = null
var _active_hitbox: Hitbox = null
var _blood_burst: CPUParticles3D = null
# Bull-ram tracking: the bull's position last frame (to sweep its travel) and whether it was mid-ram
# last frame (edge-trigger so one charge = one wound, re-arming when the bull breaks off).
var _bull_prev_pos: Vector3 = Vector3.ZERO
var _bull_prev_seen: bool = false
var _bull_ramming: bool = false
var _hp: int = 10
var _enraged: bool = false
@@ -91,6 +109,13 @@ func _ready() -> void:
health_changed.emit(_hp, _hp)
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
_fx_root = _mesh.get_node_or_null("Bear_FX")
if _fx_root:
_fx_player = _fx_root.get_node_or_null("AnimationPlayer") as AnimationPlayer
if _fx_player:
# The smash/leap clips end held at full scale (Siim only ramped the claw clips back
# down), so snap each effect back to 0 when its clip finishes — keeps them one-shot.
_fx_player.animation_finished.connect(_on_fx_finished)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
if OS.has_feature("web"):
@@ -111,11 +136,11 @@ func _ready() -> void:
for a in [_anim_swipe, _anim_smash, _anim_leap, _anim_death]:
_set_no_loop(a)
_anim_player.playback_default_blend_time = 0.12
_hit_area.body_entered.connect(_on_body_entered)
_blood_burst = preload("res://blood_burst.gd").new()
add_child(_blood_burst)
add_child(_dust)
_setup_claws()
_setup_attack_hitboxes()
_enter_neutral()
#take hit flashing
@@ -138,7 +163,7 @@ func _hit_flash() -> void:
_hit_flash_tween = create_tween()
_hit_flash_tween.tween_interval(0.10)
_hit_flash_tween.tween_callback(_clear_hit_flash)
func _make_hit_overlay() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.05, 0.05, 1.0)
@@ -211,6 +236,9 @@ func _physics_process(delta: float) -> void:
_acquire_bull()
_update_enrage()
_stagger_cd = maxf(0.0, _stagger_cd - delta)
_tick_attack_hitbox()
if _bull != null:
_sweep_bull_ram()
match _state:
State.NEUTRAL: _tick_neutral(delta)
@@ -285,37 +313,17 @@ func _decide() -> void:
# Short range: claw attack
options = [[State.SWIPE, 3.0]]
elif dist <= DP.f("bear_mid_range"):
# Mid range: jump smash or walk closer
options = [
[State.LEAP, 2.0 if _enraged else 1.5],
[State.STALK, 3.0]
]
else:
# Long range: smash or prowl
# At distance: overhead smash 1/3 of the time, jump smash 2/3 — both close the gap as
# they land, so the bear slams its way in rather than walking (stalk/prowl).
options = [
[State.SMASH, 3.0 if _enraged else 2.5],
[State.PROWL, 3.0]
[State.SMASH, 1.0],
[State.LEAP, 2.0]
]
_start_routine(_weighted_pick(options))
if dist <= DP.f("bear_close_range"):
# Short range — claw attack
options = [[State.SWIPE, 3.0]]
elif dist <= DP.f("bear_mid_range"):
# Mid range — jump smash or stalk closer
options = [[State.LEAP, 2.0 if _enraged else 1.5], [State.STALK, 3.0]]
else:
# Long range — smash is allowed from farther away
options = [[State.SMASH, 3.0 if _enraged else 2.5], [State.PROWL, 3.0]]
# A queued combo step only fires if the bull is still roughly in its band, so a chain
# doesn't whiff into empty air after the player rolls away.
func _range_allows(routine: State) -> bool:
@@ -422,9 +430,11 @@ func _tick_swipe(delta: float) -> void:
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
if _hits_done == 0 and frac >= 0.25:
_hits_done = 1
_play_fx(_claw_fx_clip(0))
_try_hit(DP.f("bear_swipe_reach"))
elif _hits_done == 1 and frac >= 0.65:
_hits_done = 2
_play_fx(_claw_fx_clip(1))
_try_hit(DP.f("bear_swipe_reach"))
if _phase_timer <= 0.0:
_begin_recover(DP.f("bear_swipe_recover"))
@@ -458,7 +468,10 @@ func _tick_smash(delta: float) -> void:
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
if _hits_done == 0 and frac >= 0.4:
_hits_done = 1
_slam(DP.f("bear_smash_radius"), 0.8)
_play_fx(&"Bear_fx_smash_hit")
_slam_dust()
_shake(0.8)
_begin_smash_hitbox() # plays the scene's "smash_travel" clip; swept in _tick_attack_hitbox
if _phase_timer <= 0.0:
_begin_recover(DP.f("bear_smash_recover"))
Phase.RECOVER:
@@ -509,7 +522,8 @@ func _tick_leap(delta: float) -> void:
elif _leap_airborne or _phase_timer <= 0.0:
velocity.x = 0.0
velocity.z = 0.0
_slam(DP.f("bear_slam_radius"), 0.7)
_play_fx(&"Bear_fx_jump_smash")
_fire_leap_hitbox()
_phase = Phase.RECOVER
_phase_timer = _scaled(DP.f("bear_leap_recover"))
Phase.RECOVER:
@@ -585,8 +599,8 @@ func _lunge_and_track(speed: float, delta: float) -> void:
_accelerate(to_bull / dist, speed, delta)
else:
_decelerate(_PLANT_DECEL, delta)
func _lunge_and_track_no_rotation(speed: float, delta: float) -> void:
if _bull == null:
_decelerate(_PLANT_DECEL, delta)
@@ -620,17 +634,83 @@ func _try_hit(reach: float) -> bool:
return true
# Radial slam (smash / leap landing): hits any way the bull is, within `radius`, with a
# ground-pound shake and a burst of dust kicked up at the paws. No facing arc — you're
# caught if you're near where it comes down.
func _slam(radius: float, shake: float) -> void:
_slam_dust()
if _bull == null:
_shake(shake)
# Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under
# bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene.
func _setup_attack_hitboxes() -> void:
var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null
if root == null:
return
if _flat_dist_to_bull() <= radius:
_bull.call(&"take_sword_hit", "mauled")
_shake(shake)
_leap_hitbox = root.get_node_or_null("LeapSlamHitbox") as Hitbox
_smash_hitbox = root.get_node_or_null("SmashHitbox") as Hitbox
_hitbox_anim = root.get_node_or_null("AnimationPlayer") as AnimationPlayer
if _hitbox_anim != null:
# Advance the shape's travel in the physics step so it stays in lockstep with the sweep.
_hitbox_anim.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS
if _leap_hitbox != null:
_leap_hitbox.deactivate()
if _smash_hitbox != null:
_smash_hitbox.deactivate()
# Leap landing: kick up dust + shake, launch the ground-circle collider clip, and arm it so the bull
# is launched from ANYWHERE in the circle (bear_leap_ring_frac 0). Raise ring_frac to restore a
# grounded inner zone that only mauls; the launch zone then starts at that fraction of the radius.
func _fire_leap_hitbox() -> void:
_slam_dust()
_shake(0.7)
if _leap_hitbox == null:
return
_leap_hitbox.knock_up = DP.f("bear_leap_knockup")
_leap_hitbox.knock_up_min_dist = DP.f("bear_leap_ring_frac") * _shape_extent(_leap_hitbox, true)
_leap_hitbox.knock_up_max_dist = 0.0 # binary: full-height launch everywhere it launches
_start_attack_hitbox(_leap_hitbox, &"leap_slam")
# Overhead smash: launch the lane clip; knock-up ramps 0→full with distance (further = higher),
# maxing at the lane's authored full length so the shape and the launch curve stay in sync.
func _begin_smash_hitbox() -> void:
if _smash_hitbox == null:
return
_smash_hitbox.knock_up = DP.f("bear_smash_knockup")
_smash_hitbox.knock_up_min_dist = 0.0
_smash_hitbox.knock_up_max_dist = _shape_extent(_smash_hitbox, false)
_start_attack_hitbox(_smash_hitbox, &"smash_travel")
# Activate the hitbox and play its authored motion clip; _tick_attack_hitbox sweeps for overlaps
# each frame while the clip runs, then deactivates when it ends.
func _start_attack_hitbox(hb: Hitbox, clip: StringName) -> void:
if _active_hitbox != null and _active_hitbox != hb:
_active_hitbox.deactivate()
hb.activate()
_active_hitbox = hb
if _hitbox_anim != null and _hitbox_anim.has_animation(clip):
_hitbox_anim.play(clip)
_hitbox_anim.seek(0.0, true)
func _tick_attack_hitbox() -> void:
if _active_hitbox == null:
return
_active_hitbox.sweep()
# The clip drives the shape's travel; when it stops, the swing is over — close the hitbox.
if _hitbox_anim == null or not _hitbox_anim.is_playing():
_active_hitbox.deactivate()
_active_hitbox = null
# Full extent (planar) of a hitbox's authored shape, for the knock-up distance math: the circle's
# radius (cylinder) or the lane's length (box half-size along local +Z, forward from the bear).
func _shape_extent(hb: Hitbox, radial: bool) -> float:
var cs := hb.get_node_or_null("CollisionShape3D") as CollisionShape3D
if cs == null:
return 0.0
if radial and cs.shape is CylinderShape3D:
return (cs.shape as CylinderShape3D).radius
if not radial and cs.shape is BoxShape3D:
# Box spans [0, size.z] because it's offset forward by half its length.
return (cs.shape as BoxShape3D).size.z
return 0.0
# Kick up a one-shot ring of dust at the bear's feet where the slam lands.
@@ -641,7 +721,7 @@ func _slam_dust() -> void:
_dust.restart()
_dust.emitting = true
"
# Tan ground-impact dust: a fast, wide, low burst that arcs up and settles — built in code
# so the Bear scene needs no extra particle node.
func _make_slam_dust() -> CPUParticles3D:
@@ -689,7 +769,6 @@ func _make_slam_dust() -> CPUParticles3D:
p.scale_amount_min = 0.6
p.scale_amount_max = 1.4
return p
"
# A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so
@@ -755,18 +834,44 @@ func apply_ability_hit(hit_dir: Vector3, strength: float, _up_boost: float = 0.0
_take_hit(hit_dir, strength)
# Ramming: only a real charge from the bull wounds the bear; a gentle nudge is shrugged
# off. The bear never damages the bull by colliding — only its swings/slams do.
func _on_body_entered(body: Node3D) -> void:
if _state == State.DEAD or not body.is_in_group(&"player"):
# Only a horns-first charge wounds the bear. Swept (prev → now) so a tunnelling or point-blank charge
# still lands where body_entered wouldn't; edge-triggered (_bull_ramming) so one charge = one wound.
func _sweep_bull_ram() -> void:
var now := _bull.global_position
var seg_from := _bull_prev_pos if _bull_prev_seen else now
_bull_prev_pos = now
_bull_prev_seen = true
if _state == State.DEAD:
_bull_ramming = false
return
var player := body as CharacterBody3D
if player.velocity.length() < DP.f("bear_hit_threshold"):
return
var flat := Vector3(player.velocity.x, 0.0, player.velocity.z)
var dir := flat.normalized() if flat.length() > 0.5 else \
(global_position - player.global_position).normalized()
_take_hit(dir, player.velocity.length())
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var speed := vel.length()
var ramming := false
if speed >= DP.f("bear_hit_threshold") and _horns_first(seg_from):
var a := Vector3(seg_from.x, 0.0, seg_from.z)
var b := Vector3(now.x, 0.0, now.z)
var p := Vector3(global_position.x, 0.0, global_position.z)
if p.distance_to(Geometry3D.get_closest_point_to_segment(p, a, b)) <= DP.f("bear_ram_reach"):
ramming = true
if ramming and not _bull_ramming:
var dir := vel.normalized() if speed > 0.5 else (global_position - now).normalized()
_take_hit(dir, speed)
_bull_ramming = ramming
# Horns-first gate: wound only when the bull's velocity points within bull_gore_arc of the direction
# from `from` to the bear. The dash ability skips the gate.
func _horns_first(from: Vector3) -> bool:
if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"):
return true # the dash ability is a committed lunge — it rams on contact, no angle gate
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if vel.length() < 0.5:
return false
var to_bear := global_position - from
to_bear.y = 0.0
if to_bear.length() < 0.01:
return true
return vel.normalized().dot(to_bear.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc")))
func _take_hit(hit_dir: Vector3, strength: float) -> void:
@@ -939,6 +1044,43 @@ func _play(anim_name: StringName) -> void:
_anim_player.play(anim_name)
# The two swipe hits map to the L/R claw-slash clips; bear_fx_claw_swap flips the order in
# case the baked left/right clips read reversed in-game. hit 0 = first swing, 1 = second.
func _claw_fx_clip(hit: int) -> StringName:
var swap := DP.f("bear_fx_claw_swap") >= 0.5
var first_left := not swap
if (hit == 0) == first_left:
return &"Bear_fx_claw_attack_L"
return &"Bear_fx_claw_attack_R"
# Fire a one-shot FX clip from the Bear_FX glb (crater / slash / debris). Each clip scales
# its mesh up at the strike and back to 0 on its own, so this is a plain fire-and-forget.
func _play_fx(clip: StringName) -> void:
if _fx_player == null or not _fx_player.has_animation(clip):
return
if _fx_root is Node3D:
(_fx_root as Node3D).rotation.y = deg_to_rad(DP.f("bear_fx_yaw"))
_fx_player.play(clip)
_fx_player.seek(0.0, true)
func _on_fx_finished(anim: StringName) -> void:
if _fx_root == null:
return
var mesh := _fx_root.get_node_or_null(NodePath(anim)) as Node3D
if mesh == null:
return
# The crater clip only ramps up (it ends held at full scale with no shrink), so leave it on
# the ground for a beat after the slam, then hide it. The others self-clear immediately.
if anim == &"Bear_fx_jump_smash":
var linger := DP.f("bear_fx_crater_linger")
if linger > 0.0:
get_tree().create_timer(linger).timeout.connect(func() -> void: mesh.scale = Vector3.ZERO)
return
mesh.scale = Vector3.ZERO
# Restart a one-shot attack clip from its first frame at `speed` (even if already current).
func _play_attack(anim_name: StringName, speed: float) -> void:
if _anim_player == null or anim_name == &"":