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 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 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) 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"]) # 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]: _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() # 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 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_idle) # 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