936 lines
32 KiB
GDScript
936 lines
32 KiB
GDScript
extends CharacterBody3D
|
|
## Dark-Souls-style boss bear with a learnable moveset. From NEUTRAL it weighs its
|
|
## options by the range band to the bull and commits to one telegraphed routine —
|
|
## wind-up (tell) → active (the hit) → recovery (the player's punish window). A routine
|
|
## always plays to the end (hyper-armour: it takes damage mid-swing but isn't knocked out
|
|
## of it), so every attack is readable and punishable. Variety comes from which routine
|
|
## NEUTRAL picks (weighted per range band); predictability from each routine being fixed
|
|
## once entered. Below bear_enrage_frac HP it enters phase 2: snappier tells/recovery and
|
|
## longer combo chains.
|
|
##
|
|
## The bear only wounds the bull with its swings and slams — walking or leaping into the
|
|
## bull does nothing. It plugs into the bull's combat interface: it joins the &"matador"
|
|
## group so every bull ability already targets it (apply_ability_hit), and mauls the bull
|
|
## via take_sword_hit.
|
|
|
|
# Positioning / neutral states, then the three attack routines, then reactions.
|
|
enum State { NEUTRAL, STALK, PROWL, SWIPE, SMASH, LEAP, STAGGER, DEAD }
|
|
# Every attack routine runs through these three beats.
|
|
enum Phase { WINDUP, ACTIVE, RECOVER }
|
|
|
|
signal killed
|
|
signal health_changed(current: int, max_hp: int)
|
|
|
|
var _state: State = State.NEUTRAL
|
|
var _phase: Phase = Phase.WINDUP
|
|
var _bull: CharacterBody3D = null
|
|
var _skeleton: Skeleton3D = null
|
|
var _anim_player: AnimationPlayer = null
|
|
var _blood_burst: CPUParticles3D = null
|
|
|
|
var _hp: int = 10
|
|
var _enraged: bool = false
|
|
var _phase_timer: float = 0.0
|
|
var _active_len: float = 0.8
|
|
var _think_timer: float = 0.0
|
|
var _state_timer: float = 0.0
|
|
var _stagger_timer: float = 0.0
|
|
var _stagger_cd: float = 0.0 # poise window after a stagger during which hits don't stun
|
|
var _hits_done: int = 0
|
|
var _leap_airborne: bool = false
|
|
# Solved per-leap so the arc lands on the clip's impact frame: the launch vertical speed
|
|
# and the custom gravity used only while airborne (so peak height and airtime are tuned
|
|
# independently of world gravity).
|
|
var _leap_up: float = 0.0
|
|
var _leap_grav: float = 0.0
|
|
# Attack routines queued to fire back-to-back as a combo, popped when the current one
|
|
# finishes its recovery. Element type is State.
|
|
var _combo_queue: Array = []
|
|
|
|
# Resolved at runtime from the rig's clip list — the Bear.glb ships Rigify-named clips
|
|
# (RUN_ON4LEGS, IDLE_standing, SMASH_HIT, …) that may import with any prefix, so we match
|
|
# by substring rather than hard-coding the full track names.
|
|
var _anim_idle: StringName = &"" # IDLE_ON4LEGS — neutral / recovery (the punish pose)
|
|
var _anim_run: StringName = &"" # RUN_ON4LEGS — prowl approach / neutral coast
|
|
var _anim_walk2: StringName = &"" # WALK_ON2LEGS — the 2-leg stalk-in
|
|
var _anim_stand: StringName = &"" # IDLE_standing — reared-up wind-up / roar
|
|
var _anim_swipe: StringName = &"" # ONE_TWO_HIT — double swipe
|
|
var _anim_smash: StringName = &"" # SMASH_HIT — overhead smash
|
|
var _anim_leap: StringName = &"" # JUMP_SMASH — leap slam
|
|
var _anim_death: StringName = &"" # DEATH — death
|
|
|
|
#take hit flashing
|
|
var _hit_flash_active := false
|
|
var _hit_flash_tween: Tween
|
|
|
|
const _ACCEL_FACTOR: float = 9.0
|
|
# Hard deceleration for planted states (wind-up / recovery / neutral settle) so the bear
|
|
# never coasts across the ground with a stationary pose playing (no foot-sliding).
|
|
const _PLANT_DECEL: float = 22.0
|
|
# The Bear.glb clips are authored at 30 fps (see Bear.glb.import); leap timing is keyed by
|
|
# frame, so convert frames→seconds with this.
|
|
const _ANIM_FPS: float = 30.0
|
|
# How close the bear closes on the bull before it stops advancing (roughly the bear+bull
|
|
# body radii). Its swings still reach well past this; it just never tries to occupy the
|
|
# bull's spot, so it doesn't shove up onto it.
|
|
const _BULL_STANDOFF: float = 1.9
|
|
|
|
@onready var _mesh: Node3D = $bear_model
|
|
@onready var _hit_area: Area3D = $HitArea
|
|
var _dust: CPUParticles3D = null
|
|
# Claw-slash bursts bound to the paw bones, fired one per hit of the 1-2 punch.
|
|
var _claw_l: CPUParticles3D = null
|
|
var _claw_r: CPUParticles3D = null
|
|
|
|
|
|
const RigidSkin = preload("res://rigid_skin.gd")
|
|
|
|
|
|
func _ready() -> void:
|
|
add_to_group(&"matador") # so every existing bull ability already targets the bear
|
|
add_to_group(&"bear")
|
|
_hp = maxi(1, int(DP.f("bear_max_hp")))
|
|
health_changed.emit(_hp, _hp)
|
|
_skeleton = _find_skeleton(_mesh)
|
|
_anim_player = _find_anim_player(_mesh)
|
|
# 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"):
|
|
RigidSkin.convert_tree(self)
|
|
if _anim_player:
|
|
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
|
|
_anim_run = _resolve_anim(["RUN_ON4LEGS", "RUN"])
|
|
_anim_walk2 = _resolve_anim(["WALK_ON2LEGS", "WALK", "IDLE_standing"])
|
|
_anim_stand = _resolve_anim(["IDLE_standing", "WALK_ON2LEGS"])
|
|
_anim_swipe = _resolve_anim(["ONE_TWO_HIT", "SMASH_HIT"])
|
|
_anim_smash = _resolve_anim(["SMASH_HIT", "ONE_TWO_HIT"])
|
|
_anim_leap = _resolve_anim(["JUMP_SMASH", "SMASH_HIT"])
|
|
_anim_death = _resolve_anim(["DEATH"])
|
|
# Loop the locomotion / idle clips; the attack clips (swipe/smash/leap) are one-shot,
|
|
# so their on-ground settle frames play once and then hold instead of looping.
|
|
for a in [_anim_idle, _anim_run, _anim_walk2, _anim_stand]:
|
|
_ensure_loop(a)
|
|
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)
|
|
_dust = _make_slam_dust()
|
|
add_child(_dust)
|
|
_setup_claws()
|
|
_enter_neutral()
|
|
|
|
#take hit flashing
|
|
func _hit_flash() -> void:
|
|
if _mesh == null:
|
|
return
|
|
|
|
if _hit_flash_tween:
|
|
_hit_flash_tween.kill()
|
|
|
|
_hit_flash_active = true
|
|
|
|
# Make the bear red
|
|
for child in _mesh.find_children("*", "MeshInstance3D", true, false):
|
|
var mesh := child as MeshInstance3D
|
|
if mesh:
|
|
mesh.material_overlay = _make_hit_overlay()
|
|
|
|
# Flash for 0.1 sec, then remove
|
|
_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)
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
return mat
|
|
|
|
func _clear_hit_flash() -> void:
|
|
if _mesh == null:
|
|
return
|
|
|
|
for child in _mesh.find_children("*", "MeshInstance3D", true, false):
|
|
var mesh := child as MeshInstance3D
|
|
if mesh:
|
|
mesh.material_overlay = null
|
|
|
|
_hit_flash_active = false
|
|
|
|
|
|
|
|
# Bind a claw-slash emitter to each paw bone (hand_l / hand_r) via a BoneAttachment3D, so
|
|
# a burst fired on a swipe hit erupts from that paw wherever the animation has it.
|
|
func _setup_claws() -> void:
|
|
if _skeleton == null:
|
|
return
|
|
_claw_r = _attach_claw("hand_r")
|
|
_claw_l = _attach_claw("hand_l")
|
|
|
|
|
|
func _attach_claw(bone: String) -> CPUParticles3D:
|
|
if _skeleton.find_bone(bone) == -1:
|
|
return null
|
|
var attach := BoneAttachment3D.new()
|
|
attach.bone_name = bone
|
|
_skeleton.add_child(attach)
|
|
var claw := _make_claw_slash()
|
|
attach.add_child(claw)
|
|
return claw
|
|
|
|
|
|
func get_hp() -> int:
|
|
return _hp
|
|
|
|
|
|
func _acquire_bull() -> void:
|
|
if _bull != null:
|
|
return
|
|
var players := get_tree().get_nodes_in_group(&"player")
|
|
if not players.is_empty():
|
|
_bull = players[0] as CharacterBody3D
|
|
# The bear only ever hits via distance / area checks, never body collision, so let
|
|
# it pass through the bull. Otherwise the big capsule rides up and gets stuck on top
|
|
# of the bull when it lunges or lands a leap on it.
|
|
if _bull != null:
|
|
add_collision_exception_with(_bull)
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if not is_on_floor():
|
|
# The leap uses its own gravity mid-flight so its arc height and airtime can be
|
|
# tuned to land on the clip's impact frame; everything else falls under world gravity.
|
|
if _state == State.LEAP and _phase == Phase.ACTIVE and _leap_grav > 0.0:
|
|
velocity.y -= _leap_grav * delta
|
|
else:
|
|
velocity += get_gravity() * delta
|
|
|
|
if _state == State.DEAD:
|
|
velocity.x = 0.0
|
|
velocity.z = 0.0
|
|
move_and_slide()
|
|
return
|
|
|
|
_acquire_bull()
|
|
_update_enrage()
|
|
_stagger_cd = maxf(0.0, _stagger_cd - delta)
|
|
|
|
match _state:
|
|
State.NEUTRAL: _tick_neutral(delta)
|
|
State.STALK: _tick_stalk(delta)
|
|
State.PROWL: _tick_prowl(delta)
|
|
State.SWIPE: _tick_swipe(delta)
|
|
State.SMASH: _tick_smash(delta)
|
|
State.LEAP: _tick_leap(delta)
|
|
State.STAGGER: _tick_stagger(delta)
|
|
|
|
move_and_slide()
|
|
|
|
|
|
# ── Phase 2 ──────────────────────────────────────────────────────────────────────
|
|
|
|
func _update_enrage() -> void:
|
|
if _enraged:
|
|
return
|
|
if _hp <= int(ceil(DP.f("bear_max_hp") * DP.f("bear_enrage_frac"))):
|
|
_enraged = true
|
|
# Roar: a beat of stagger-free wind-up with a screen shake, so the phase flip reads.
|
|
_blood_burst.burst(global_position + Vector3(0.0, 1.6, 0.0), Vector3.UP)
|
|
_shake(0.9)
|
|
|
|
|
|
# Timers that shape readability (tells + recovery) shrink in phase 2; active windows and
|
|
# the clip-locked leap timings are left alone.
|
|
func _scaled(t: float) -> float:
|
|
return t * (DP.f("bear_enrage_scale") if _enraged else 1.0)
|
|
|
|
|
|
# ── NEUTRAL: think, then commit ────────────────────────────────────────────────────
|
|
|
|
func _enter_neutral() -> void:
|
|
_state = State.NEUTRAL
|
|
# A short, lightly-varied beat so the bear flows from one action into the next; phase 2
|
|
# shrinks it further. Keep the jitter small so it reads as eager, not hesitant.
|
|
var think := DP.f("bear_think_time")
|
|
_think_timer = _scaled(think * randf_range(0.7, 1.3))
|
|
|
|
|
|
func _tick_neutral(delta: float) -> void:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
if _bull != null:
|
|
_face_point(_bull.global_position, delta)
|
|
# Coast on the run clip while residual speed bleeds off, then settle to idle — never
|
|
# hold the idle pose while still sliding.
|
|
_play(_anim_run if _horizontal_speed() > 1.0 else _anim_idle)
|
|
_think_timer -= delta
|
|
if _think_timer <= 0.0:
|
|
_decide()
|
|
|
|
|
|
# Pop the next queued combo step, or pick a fresh routine weighted by the current range
|
|
# band. Weights lean into pressure (and chaining) in phase 2.
|
|
func _decide() -> void:
|
|
if not _combo_queue.is_empty():
|
|
var next: State = _combo_queue.pop_front()
|
|
if _range_allows(next):
|
|
_start_routine(next)
|
|
return
|
|
_combo_queue.clear()
|
|
|
|
if _bull == null:
|
|
_start_routine(State.PROWL)
|
|
return
|
|
|
|
var dist := _flat_dist_to_bull()
|
|
var options: Array
|
|
if dist <= DP.f("bear_close_range"):
|
|
# In strike range: punch or overhead (both step in during the swing).
|
|
options = [[State.SWIPE, 3.0], [State.SMASH, 3.0 if _enraged else 2.5]]
|
|
elif dist <= DP.f("bear_mid_range"):
|
|
# Mid: mostly walk the bull down on two legs; occasionally leap the gap.
|
|
options = [[State.STALK, 3.0], [State.LEAP, 2.0 if _enraged else 1.5]]
|
|
else:
|
|
# Far: lope in on all fours, or spring across with a leap.
|
|
options = [[State.PROWL, 3.0], [State.LEAP, 1.5]]
|
|
_start_routine(_weighted_pick(options))
|
|
|
|
|
|
# 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:
|
|
if _bull == null:
|
|
return routine == State.PROWL
|
|
var dist := _flat_dist_to_bull()
|
|
match routine:
|
|
State.SWIPE, State.SMASH:
|
|
return dist <= DP.f("bear_close_range") * 1.4
|
|
State.LEAP:
|
|
return dist <= DP.f("bear_mid_range") * 1.5
|
|
return true
|
|
|
|
|
|
func _start_routine(routine: State) -> void:
|
|
match routine:
|
|
State.SWIPE: _start_swipe()
|
|
State.SMASH: _start_smash()
|
|
State.LEAP: _start_leap()
|
|
State.STALK: _start_stalk()
|
|
State.PROWL: _start_prowl()
|
|
_: _enter_neutral()
|
|
|
|
|
|
# When a routine's recovery ends: fire the next combo step if one is queued, else think.
|
|
func _finish_routine() -> void:
|
|
if not _combo_queue.is_empty():
|
|
_decide()
|
|
else:
|
|
_enter_neutral()
|
|
|
|
|
|
# ── STALK: a menacing 2-leg walk-in that closes the gap into strike range ────────────
|
|
|
|
func _start_stalk() -> void:
|
|
_state = State.STALK
|
|
_state_timer = DP.f("bear_stalk_time")
|
|
|
|
|
|
func _tick_stalk(delta: float) -> void:
|
|
_state_timer -= delta
|
|
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_close_range"):
|
|
_enter_neutral()
|
|
return
|
|
var to_bull := _flat_to_bull()
|
|
_accelerate(to_bull.normalized(), DP.f("bear_stalk_speed"), delta)
|
|
_face_point(_bull.global_position, delta)
|
|
_play(_anim_walk2)
|
|
|
|
|
|
# ── PROWL: lope in on all fours to close a big gap ─────────────────────────────────
|
|
|
|
func _start_prowl() -> void:
|
|
_state = State.PROWL
|
|
_state_timer = 1.6
|
|
|
|
|
|
func _tick_prowl(delta: float) -> void:
|
|
_state_timer -= delta
|
|
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_mid_range") * 0.9:
|
|
_enter_neutral()
|
|
return
|
|
var to_bull := _flat_to_bull()
|
|
_accelerate(to_bull.normalized(), DP.f("bear_run_speed"), delta)
|
|
_face_point(_bull.global_position, delta)
|
|
_play(_anim_run)
|
|
|
|
|
|
# ── SWIPE: Double Swipe (ONE_TWO_HIT), two quick hits ──────────────────────────────
|
|
|
|
func _start_swipe() -> void:
|
|
_state = State.SWIPE
|
|
_phase = Phase.WINDUP
|
|
_phase_timer = _scaled(DP.f("bear_swipe_windup"))
|
|
_hits_done = 0
|
|
_play(_anim_stand)
|
|
# Sometimes chain the big smash after the swipe (always in phase 2) — the signature
|
|
# close string the player learns to bait and punish.
|
|
if _combo_queue.is_empty() and (_enraged or randf() < DP.f("bear_combo_chance")):
|
|
_combo_queue.append(State.SMASH)
|
|
|
|
|
|
func _tick_swipe(delta: float) -> void:
|
|
match _phase:
|
|
Phase.WINDUP:
|
|
_decelerate(_PLANT_DECEL, delta) # rear up in place — no slide
|
|
_face_point_if_bull(delta) # track hard before committing
|
|
_play(_anim_stand)
|
|
_phase_timer -= delta
|
|
if _phase_timer <= 0.0:
|
|
_begin_attack_active(_anim_swipe, 0.8)
|
|
Phase.ACTIVE:
|
|
_lunge_and_track(DP.f("bear_lunge_speed"), delta)
|
|
_phase_timer -= delta
|
|
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
|
|
if _hits_done == 0 and frac >= 0.25:
|
|
_hits_done = 1
|
|
_try_hit(DP.f("bear_swipe_reach"))
|
|
_claw_slash(true) # first paw (right) rakes across
|
|
elif _hits_done == 1 and frac >= 0.65:
|
|
_hits_done = 2
|
|
_try_hit(DP.f("bear_swipe_reach"))
|
|
_claw_slash(false) # back-hand with the left paw
|
|
if _phase_timer <= 0.0:
|
|
_begin_recover(DP.f("bear_swipe_recover"))
|
|
Phase.RECOVER:
|
|
_tick_recover(delta)
|
|
|
|
|
|
# ── SMASH: Overhead Smash (SMASH_HIT), slower tell, big punish window ────────────────
|
|
|
|
func _start_smash() -> void:
|
|
_state = State.SMASH
|
|
_phase = Phase.WINDUP
|
|
_phase_timer = _scaled(DP.f("bear_smash_windup"))
|
|
_hits_done = 0
|
|
_play(_anim_stand)
|
|
|
|
|
|
func _tick_smash(delta: float) -> void:
|
|
match _phase:
|
|
Phase.WINDUP:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
_face_point_if_bull(delta)
|
|
_play(_anim_stand)
|
|
_phase_timer -= delta
|
|
if _phase_timer <= 0.0:
|
|
_begin_attack_active(_anim_smash, 0.9)
|
|
Phase.ACTIVE:
|
|
# The overhead steps in less than the swipe — it's a commitment, not a rush.
|
|
_lunge_and_track(DP.f("bear_lunge_speed") * 0.5, delta)
|
|
_phase_timer -= delta
|
|
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)
|
|
if _phase_timer <= 0.0:
|
|
_begin_recover(DP.f("bear_smash_recover"))
|
|
Phase.RECOVER:
|
|
_tick_recover(delta)
|
|
|
|
|
|
# Shared wind-up → active handoff for the melee swings: start the clip (sped up for snappy
|
|
# punches) and size the active window to the sped-up clip length.
|
|
func _begin_attack_active(clip: StringName, fallback_len: float) -> void:
|
|
_phase = Phase.ACTIVE
|
|
var spd := maxf(DP.f("bear_attack_anim_speed"), 0.1)
|
|
_active_len = _anim_length(clip, fallback_len) / spd
|
|
_phase_timer = _active_len
|
|
_play_attack(clip, spd)
|
|
|
|
|
|
# ── LEAP: Leap Slam (JUMP_SMASH) — clip-synced ballistic gap-closer + AoE ────────────
|
|
# The one JUMP_SMASH clip runs start-to-finish across the whole routine: it plays the
|
|
# crouch on the ground (wind-up), springs at the takeoff frame, the arc is solved so the
|
|
# paws hit ground exactly on the impact frame, and the last frames settle on the ground.
|
|
|
|
func _start_leap() -> void:
|
|
_state = State.LEAP
|
|
_phase = Phase.WINDUP
|
|
_hits_done = 0
|
|
_leap_airborne = false
|
|
_leap_grav = 0.0
|
|
_active_len = _anim_length(_anim_leap, 1.0)
|
|
_play_attack(_anim_leap, 1.0) # play from frame 0 at real speed, grounded crouch
|
|
_phase_timer = DP.f("bear_leap_takeoff_frame") / _ANIM_FPS # ground time before the spring
|
|
# In phase 2 a leap sometimes follows straight into a swipe once it lands close.
|
|
if _enraged and _combo_queue.is_empty() and randf() < 0.5:
|
|
_combo_queue.append(State.SWIPE)
|
|
|
|
|
|
func _tick_leap(delta: float) -> void:
|
|
match _phase:
|
|
Phase.WINDUP:
|
|
_decelerate(_PLANT_DECEL, delta) # crouch in place
|
|
_face_point_if_bull(delta) # aim the pounce, tracking the bull
|
|
_phase_timer -= delta
|
|
if _phase_timer <= 0.0:
|
|
_launch_leap()
|
|
Phase.ACTIVE:
|
|
_phase_timer -= delta
|
|
if not is_on_floor():
|
|
_leap_airborne = true
|
|
elif _leap_airborne or _phase_timer <= 0.0:
|
|
velocity.x = 0.0
|
|
velocity.z = 0.0
|
|
_slam(DP.f("bear_slam_radius"), 0.7)
|
|
_phase = Phase.RECOVER
|
|
_phase_timer = _scaled(DP.f("bear_leap_recover"))
|
|
Phase.RECOVER:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
# Let the clip's on-ground settle frames finish first, then idle out the recovery.
|
|
if _anim_player != null and _anim_player.is_playing() \
|
|
and _anim_player.current_animation == _anim_leap:
|
|
return
|
|
_play(_anim_idle)
|
|
_phase_timer -= delta
|
|
if _phase_timer <= 0.0:
|
|
_finish_routine()
|
|
|
|
|
|
func _launch_leap() -> void:
|
|
_phase = Phase.ACTIVE
|
|
_leap_airborne = false
|
|
var target := _bull.global_position if _bull != null else global_position
|
|
var flat := target - global_position
|
|
flat.y = 0.0
|
|
var dist := flat.length()
|
|
var dir := flat.normalized() if dist > 0.1 else _facing()
|
|
_face_dir(dir, 1.0)
|
|
# Airtime = the clip span between the takeoff and impact frames, so the paws hit ground
|
|
# on the impact frame (25). A custom leap gravity lets the arc reach bear_leap_height in
|
|
# exactly that airtime regardless of world gravity: for a symmetric hop of airtime T to
|
|
# peak height H, v_up = 4H/T and g = 8H/T².
|
|
var air_t := maxf(
|
|
(DP.f("bear_leap_impact_frame") - DP.f("bear_leap_takeoff_frame")) / _ANIM_FPS, 0.05)
|
|
var h := maxf(DP.f("bear_leap_height"), 0.1)
|
|
_leap_up = 4.0 * h / air_t
|
|
_leap_grav = 8.0 * h / (air_t * air_t)
|
|
# Land just in front of the bull, not on top of it — the slam radius still catches it.
|
|
var land_dist := maxf(dist - _BULL_STANDOFF, 0.0)
|
|
var h_speed := clampf(land_dist / air_t, 0.0, DP.f("bear_leap_speed"))
|
|
velocity = dir * h_speed + Vector3.UP * _leap_up
|
|
_phase_timer = air_t + 1.0 # safety fallback if a landing is missed
|
|
|
|
|
|
func _begin_recover(secs: float) -> void:
|
|
_phase = Phase.RECOVER
|
|
_phase_timer = _scaled(secs)
|
|
|
|
|
|
func _tick_recover(delta: float) -> void:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
_play(_anim_idle)
|
|
_phase_timer -= delta
|
|
if _phase_timer <= 0.0:
|
|
_finish_routine()
|
|
|
|
|
|
func _tick_stagger(delta: float) -> void:
|
|
_decelerate(12.0, delta)
|
|
_stagger_timer -= delta
|
|
_play(_anim_idle)
|
|
if _stagger_timer <= 0.0:
|
|
_stagger_cd = DP.f("bear_stagger_cd") # poise window so it can act before the next stun
|
|
_enter_neutral()
|
|
|
|
|
|
# Step toward the bull during a swing (so a strike lands even when the bull is a touch out
|
|
# of reach) while tracking its facing — but plant once close so the bear doesn't shove
|
|
# through it. Motion here reads as the strike's lunge, under the attack clip.
|
|
func _lunge_and_track(speed: float, delta: float) -> void:
|
|
if _bull == null:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
return
|
|
_face_point(_bull.global_position, delta, DP.f("bear_track_active"))
|
|
var to_bull := _flat_to_bull()
|
|
var dist := to_bull.length()
|
|
if speed > 0.0 and dist > _BULL_STANDOFF:
|
|
_accelerate(to_bull / dist, speed, delta)
|
|
else:
|
|
_decelerate(_PLANT_DECEL, delta)
|
|
|
|
|
|
# ── Hitting the bull ───────────────────────────────────────────────────────────────
|
|
|
|
# Directional strike: the bull is mauled inside `reach` of the bear that's facing it
|
|
# (a fast bull tunnels a thin collider between frames, so distance + facing is the
|
|
# reliable hit). Returns whether it connected.
|
|
func _try_hit(reach: float) -> bool:
|
|
if _bull == null:
|
|
return false
|
|
var to_bull := _flat_to_bull()
|
|
var dist := to_bull.length()
|
|
if dist > reach or dist < 0.01:
|
|
return false
|
|
if _facing().dot(to_bull / dist) < cos(deg_to_rad(DP.f("bear_hit_arc"))):
|
|
return false
|
|
_bull.call(&"take_sword_hit", "mauled")
|
|
_shake(0.55)
|
|
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)
|
|
return
|
|
if _flat_dist_to_bull() <= radius:
|
|
_bull.call(&"take_sword_hit", "mauled")
|
|
_shake(shake)
|
|
|
|
|
|
# Kick up a one-shot ring of dust at the bear's feet where the slam lands.
|
|
func _slam_dust() -> void:
|
|
if _dust == null:
|
|
return
|
|
_dust.global_position = global_position + Vector3(0.0, 0.15, 0.0)
|
|
_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:
|
|
var p := CPUParticles3D.new()
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
mat.vertex_color_use_as_albedo = true
|
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
|
|
var sphere := SphereMesh.new()
|
|
sphere.radius = 0.16
|
|
sphere.height = 0.32
|
|
sphere.radial_segments = 4
|
|
sphere.rings = 2
|
|
sphere.material = mat
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.72, 0.60, 0.40, 0.9))
|
|
ramp.set_color(1, Color(0.55, 0.45, 0.30, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 0.4))
|
|
scale_curve.add_point(Vector2(0.35, 1.0))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
p.mesh = sphere
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
p.emitting = false
|
|
p.one_shot = true
|
|
p.explosiveness = 1.0
|
|
p.amount = 36
|
|
p.lifetime = 0.9
|
|
p.randomness = 0.5
|
|
p.local_coords = false
|
|
# A flat-ish disc kicked outward and up from the ground.
|
|
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
|
|
p.emission_sphere_radius = 0.5
|
|
p.direction = Vector3(0.0, 1.0, 0.0)
|
|
p.spread = 75.0
|
|
p.gravity = Vector3(0.0, -6.0, 0.0)
|
|
p.initial_velocity_min = 2.5
|
|
p.initial_velocity_max = 6.5
|
|
p.scale_amount_min = 0.6
|
|
p.scale_amount_max = 1.4
|
|
return p
|
|
|
|
|
|
# Fire the claw-slash burst on one paw for a swipe hit (right paw first, then left).
|
|
func _claw_slash(right: bool) -> void:
|
|
var claw := _claw_r if right else _claw_l
|
|
if claw == null:
|
|
return
|
|
claw.restart()
|
|
claw.emitting = true
|
|
|
|
|
|
# A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so
|
|
# they read as claw marks slashing outward. Built in code and parented to a hand bone.
|
|
func _make_claw_slash() -> CPUParticles3D:
|
|
var p := CPUParticles3D.new()
|
|
var mat := StandardMaterial3D.new()
|
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
|
mat.vertex_color_use_as_albedo = true
|
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
|
|
|
# A thin, long box becomes a streak once aligned to its velocity.
|
|
var streak := BoxMesh.new()
|
|
streak.size = Vector3(0.03, 0.55, 0.03)
|
|
streak.material = mat
|
|
|
|
var ramp := Gradient.new()
|
|
ramp.set_color(0, Color(0.85, 0.95, 1.0, 0.95))
|
|
ramp.set_color(1, Color(0.5, 0.75, 1.0, 0.0))
|
|
|
|
var scale_curve := Curve.new()
|
|
scale_curve.add_point(Vector2(0.0, 1.0))
|
|
scale_curve.add_point(Vector2(0.7, 1.0))
|
|
scale_curve.add_point(Vector2(1.0, 0.0))
|
|
|
|
p.mesh = streak
|
|
p.color_ramp = ramp
|
|
p.scale_amount_curve = scale_curve
|
|
p.emitting = false
|
|
p.one_shot = true
|
|
p.explosiveness = 1.0 # all at once = a single rake, not a stream
|
|
p.amount = 5 # a handful of parallel claw marks
|
|
p.lifetime = 0.22
|
|
p.randomness = 0.2
|
|
p.local_coords = false # streaks stay in the air where they were raked, then fade
|
|
p.set_particle_flag(CPUParticles3D.PARTICLE_FLAG_ALIGN_Y_TO_VELOCITY, true)
|
|
# Rake across the paw's swing plane: a tight fan of fast streaks.
|
|
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
|
|
p.emission_sphere_radius = 0.12
|
|
p.direction = Vector3(0.0, 0.0, 1.0)
|
|
p.spread = 22.0
|
|
p.gravity = Vector3.ZERO
|
|
p.initial_velocity_min = 6.0
|
|
p.initial_velocity_max = 9.0
|
|
return p
|
|
|
|
|
|
func _shake(amount: float) -> void:
|
|
if _bull == null:
|
|
return
|
|
var cam := _bull.get_node_or_null("Camera3D")
|
|
if cam:
|
|
cam.call(&"trigger_hit", clampf(amount, 0.2, 1.0))
|
|
|
|
|
|
# ── Taking damage ──────────────────────────────────────────────────────────────────
|
|
|
|
# Called by every bull ability (kick / slam / roll) that iterates the matador group.
|
|
func apply_ability_hit(hit_dir: Vector3, strength: float, _up_boost: float = 0.0) -> void:
|
|
_acquire_bull()
|
|
_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"):
|
|
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())
|
|
|
|
|
|
func _take_hit(hit_dir: Vector3, strength: float) -> void:
|
|
if _state == State.DEAD:
|
|
return
|
|
_hp -= 1
|
|
_hit_flash()
|
|
health_changed.emit(_hp, int(DP.f("bear_max_hp")))
|
|
hit_dir.y = 0.0
|
|
hit_dir = hit_dir.normalized()
|
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
|
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
|
if _hp <= 0:
|
|
_die(hit_dir)
|
|
return
|
|
# Hyper-armour: a committed swing takes the wound but isn't knocked out of it — only
|
|
# the vulnerable states (neutral / positioning / a routine's recovery) stagger. This
|
|
# is what makes the recovery the real punish window.
|
|
if not _can_stagger():
|
|
return
|
|
velocity.x = hit_dir.x * 4.0
|
|
velocity.z = hit_dir.z * 4.0
|
|
_combo_queue.clear()
|
|
_state = State.STAGGER
|
|
_stagger_timer = DP.f("bear_stagger_time")
|
|
|
|
|
|
func _can_stagger() -> bool:
|
|
if _stagger_cd > 0.0:
|
|
return false # poise: recently staggered, so it rides out hits instead of stun-locking
|
|
match _state:
|
|
State.NEUTRAL, State.STALK, State.PROWL, State.STAGGER:
|
|
return true
|
|
State.SWIPE, State.SMASH, State.LEAP:
|
|
return _phase == Phase.RECOVER # airborne / mid-swing can't be staggered
|
|
return false
|
|
|
|
|
|
func _die(hit_dir: Vector3) -> void:
|
|
_state = State.DEAD
|
|
killed.emit()
|
|
collision_layer = 0
|
|
if _hit_area:
|
|
_hit_area.monitoring = false
|
|
velocity = Vector3.ZERO
|
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
|
_play(_anim_death)
|
|
# No death clip on the rig — topple the beast onto its side (about the hit direction,
|
|
# so it falls the way it was struck) and sink it away, then free.
|
|
#var fall_axis := Vector3(-hit_dir.z, 0.0, hit_dir.x)
|
|
#if fall_axis.length() < 0.01:
|
|
# fall_axis = Vector3.RIGHT
|
|
#var tween := create_tween()
|
|
#tween.tween_property(_mesh, "rotation",
|
|
# _mesh.rotation + fall_axis.normalized() * deg_to_rad(88.0), 0.7)\
|
|
# .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
|
#tween.tween_interval(2.5)
|
|
#tween.tween_property(_mesh, "position:y", _mesh.position.y - 3.0, 1.0)
|
|
#tween.tween_callback(queue_free)
|
|
|
|
|
|
# ── Interface shared with the matador (guarded elsewhere via has_method) ─────────────
|
|
|
|
func is_active() -> bool:
|
|
return _state != State.DEAD
|
|
|
|
|
|
func ai_state_name() -> String:
|
|
var n := State.keys()[_state] as String
|
|
if _state in [State.SWIPE, State.SMASH, State.LEAP]:
|
|
n += ":" + (Phase.keys()[_phase] as String)
|
|
if _enraged:
|
|
n += "*"
|
|
return n
|
|
|
|
|
|
# ── Movement / geometry helpers ─────────────────────────────────────────────────────
|
|
|
|
func _flat_to_bull() -> Vector3:
|
|
var to := _bull.global_position - global_position
|
|
to.y = 0.0
|
|
return to
|
|
|
|
|
|
func _flat_dist_to_bull() -> float:
|
|
return _flat_to_bull().length()
|
|
|
|
|
|
func _horizontal_speed() -> float:
|
|
return Vector2(velocity.x, velocity.z).length()
|
|
|
|
|
|
func _facing() -> Vector3:
|
|
return Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
|
|
|
|
|
|
func _accelerate(dir: Vector3, spd: float, delta: float) -> void:
|
|
velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta)
|
|
velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta)
|
|
|
|
|
|
func _decelerate(rate: float, delta: float) -> void:
|
|
velocity.x = move_toward(velocity.x, 0.0, rate * delta)
|
|
velocity.z = move_toward(velocity.z, 0.0, rate * delta)
|
|
|
|
|
|
func _face_point_if_bull(delta: float, rate: float = -1.0) -> void:
|
|
if _bull != null:
|
|
_face_point(_bull.global_position, delta, rate)
|
|
|
|
|
|
func _face_point(point: Vector3, delta: float, rate: float = -1.0) -> void:
|
|
_face_dir(point - global_position, delta, rate)
|
|
|
|
|
|
func _face_dir(dir: Vector3, delta: float, rate: float = -1.0) -> void:
|
|
if rate < 0.0:
|
|
rate = DP.f("bear_turn_rate")
|
|
dir.y = 0.0
|
|
if dir.length_squared() > 0.01:
|
|
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(dir.x, dir.z), delta * rate)
|
|
|
|
|
|
func _weighted_pick(options: Array) -> State:
|
|
var total := 0.0
|
|
for o: Array in options:
|
|
total += o[1]
|
|
var r := randf() * total
|
|
for o: Array in options:
|
|
r -= o[1]
|
|
if r <= 0.0:
|
|
return o[0]
|
|
return options[0][0]
|
|
|
|
|
|
# ── Animation helpers ───────────────────────────────────────────────────────────────
|
|
|
|
func _resolve_anim(candidates: Array) -> StringName:
|
|
if _anim_player == null:
|
|
return &""
|
|
var list := _anim_player.get_animation_list()
|
|
for want: String in candidates:
|
|
for have: String in list:
|
|
if have.to_upper().contains(want.to_upper()):
|
|
return StringName(have)
|
|
return &""
|
|
|
|
|
|
func _ensure_loop(anim_name: StringName) -> void:
|
|
if anim_name != &"" and _anim_player.has_animation(anim_name):
|
|
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_LINEAR
|
|
|
|
|
|
func _set_no_loop(anim_name: StringName) -> void:
|
|
if anim_name != &"" and _anim_player.has_animation(anim_name):
|
|
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_NONE
|
|
|
|
|
|
func _anim_length(anim_name: StringName, fallback: float) -> float:
|
|
if _anim_player and anim_name != &"" and _anim_player.has_animation(anim_name):
|
|
return _anim_player.get_animation(anim_name).length
|
|
return fallback
|
|
|
|
|
|
func _play(anim_name: StringName) -> void:
|
|
if _anim_player == null or anim_name == &"":
|
|
return
|
|
if _anim_player.current_animation == anim_name:
|
|
return
|
|
_anim_player.play(anim_name)
|
|
|
|
|
|
# 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 == &"":
|
|
return
|
|
_anim_player.play(anim_name, -1.0, speed)
|
|
_anim_player.seek(0.0, true)
|
|
|
|
|
|
func _find_skeleton(node: Node) -> Skeleton3D:
|
|
if node is Skeleton3D:
|
|
return node as Skeleton3D
|
|
for child: Node in node.get_children():
|
|
var r := _find_skeleton(child)
|
|
if r:
|
|
return r
|
|
return null
|
|
|
|
|
|
func _find_anim_player(node: Node) -> AnimationPlayer:
|
|
if node is AnimationPlayer:
|
|
return node as AnimationPlayer
|
|
for child: Node in node.get_children():
|
|
var r := _find_anim_player(child)
|
|
if r:
|
|
return r
|
|
return null
|