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:
@@ -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 == &"":
|
||||
|
||||
Reference in New Issue
Block a user