Files
Bullosseum/matador.gd
T
richard 5c1e881a53 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>
2026-09-03 01:01:42 +03:00

1330 lines
50 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends CharacterBody3D
# WANDER → roam/taunt outside flee range. FLEE → bull close: back away (raycast-steered).
# BRACE → charge detected: plant + face (cite). SIDESTEP → bull commits: lateral pass (pase).
# ATTACK → chase and strike. ROLL → hit but rolled: survive. THROW → wind up + hurl the spear.
# RAGDOLL → hit, no roll.
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
# The matador's high-level *want*, chosen by a weighted utility vote (see
# _choose_intent). Each intent maps onto one or more concrete States below.
enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW }
signal killed
var _state: State = State.WANDER
var _skeleton: Skeleton3D = null
var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0
var _taunt_swap: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO
var _brace_timer: float = 0.0
var _step_timer: float = 0.0
var _dodge_cd: float = 0.0
var _will_dodge: bool = true
var _attack_timer: float = 0.0
var _swing_timer: float = 0.0
var _attack_cd: float = 0.0
var _bull_prev_pos: Vector3 = Vector3.ZERO
var _bull_prev_seen: bool = false
var _roll_timer: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO
var _is_spawning: bool = false
var _throw_timer: float = 0.0
var _throw_aim_cd: float = 0.0
var _throw_dir: Vector3 = Vector3.ZERO
var _steer_dir: Vector3 = Vector3.ZERO
var _steer_cd: float = 0.0
var _intent: Intent = Intent.WANDER
var _decide_cd: float = 0.0
var _ole_player: AudioStreamPlayer = null
var _sword_hand_attach: BoneAttachment3D = null
var _sword_rest_attach: BoneAttachment3D = null
var _sword_node: Node3D = null
var _sword_in_hand: bool = false
var _drawing: bool = false
var _sheathing: bool = false
var _draw_timer: float = 0.0
var _draw_len: float = 0.0
var _sword_blade_area: Hitbox = null
var _spear_node: Node3D = null
var _blade_hit_cd: float = 0.0
var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null
@onready var _mesh: Node3D = $matador2
@onready var _hit_area: Area3D = $HitArea
const _ANIM_RUN: StringName = &"Run"
const _ANIM_IDLE: StringName = &"Taunt"
const _ANIM_ATTACK: StringName = &"Attack"
const _ANIM_ROLL: StringName = &"Roll"
const _ANIM_DRAW: StringName = &"Draw_weapon"
# Idle taunt clips — one is picked at random each time the matador pauses to
# taunt while wandering, so the crowd doesn't see the same gesture every time.
const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
# Fraction of the draw clip at which the hand grabs the sword — the sword
# reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55
# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn.
const _SWORD_SCENE: PackedScene = preload("res://Sword.tscn")
const _SPEAR_SCENE: PackedScene = preload("res://Spear.tscn")
const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc
const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall
const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
# Side angles (radians) tried when the straight-ahead path is blocked.
const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI]
# Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as
# a quick, dangerous opponent rather than a sluggish one.
const _ACCEL_FACTOR: float = 13.0 # velocity ramp = speed * factor * delta
const _TURN_MOVE: float = 11.0 # face the travel direction while roaming
const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void:
add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
if _skeleton:
_sim = MatadorRagdoll.build(_skeleton)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned
# bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones).
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
_ensure_loop(anim)
_flatten_roll_travel()
if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" %
str(_anim_player.get_animation_list()))
_anim_player.playback_default_blend_time = DP.f("mat_anim_blend")
_anim_player.play(_ANIM_IDLE)
_setup_sword()
DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(2.0, 5.0)
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_blood_burst = preload("res://blood_burst.gd").new()
add_child(_blood_burst)
if ResourceLoader.exists("res://sounds/ole.ogg"):
_ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg")
_ole_player.volume_db = 0.0
_ole_player.bus = &"Effects"
add_child(_ole_player)
if ResourceLoader.exists("res://sounds/matador_death.ogg"):
_death_player = AudioStreamPlayer.new()
_death_player.stream = load("res://sounds/matador_death.ogg")
_death_player.volume_db = 2.0
_death_player.bus = &"Effects"
add_child(_death_player)
func _on_dp_changed(_key: String, _value: Variant) -> void:
_apply_sword_grip()
if _anim_player:
_anim_player.playback_default_blend_time = DP.f("mat_anim_blend")
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
func _physics_process(delta: float) -> void:
_acquire_bull()
if _is_spawning and is_on_floor():
_is_spawning = false
collision_layer = 1
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
var o := global_position + Vector3.UP * 0.6
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
if not is_on_floor():
velocity += get_gravity() * delta
_dodge_cd = maxf(0.0, _dodge_cd - delta)
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
_attack_cd = maxf(0.0, _attack_cd - delta)
if _bull != null:
_sweep_bull_gore()
if _state != State.RAGDOLL and _bull != null:
_update_ai_state(delta)
match _state:
State.WANDER: _tick_wander(delta)
State.FLEE: _tick_flee(delta)
State.BRACE: _tick_brace(delta)
State.SIDESTEP: _tick_sidestep(delta)
State.ATTACK: _tick_attack(delta)
State.ROLL: _tick_roll(delta)
State.THROW: _tick_throw(delta)
State.RAGDOLL: _tick_ragdoll(delta)
_update_sword_carry()
_advance_draw(delta)
# Utility-driven brain. Committed states (brace / pass / roll / throw wind-up) run to completion on
# their own timers; from the free states the matador re-scores every mat_decide_interval.
func _update_ai_state(delta: float) -> void:
if _state == State.BRACE or _state == State.SIDESTEP \
or _state == State.ROLL or _state == State.THROW:
return
_decide_cd -= delta
if _decide_cd > 0.0:
return
_decide_cd = DP.f("mat_decide_interval")
var intent := _choose_intent()
_intent = intent
if not _realizes_intent(intent):
_enter_intent(intent)
# Score each intent from weighted mat_w_* heuristics and return the winner; a small commit bonus on
# the current intent stops it flip-flopping between two near-tied options.
func _choose_intent() -> Intent:
var dist := global_position.distance_to(_bull.global_position)
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var bull_speed := bull_flat.length()
# close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures
# how far past throw range the bull is (0 point-blank, 1 at/outside throw range):
# the matador only idles when the bull is out there, and engages otherwise.
var reach := maxf(DP.f("mat_attack_range"), 0.5)
var throw_range := maxf(DP.f("mat_throw_range"), 1.0)
var close := clampf(1.0 - dist / reach, 0.0, 1.0)
var beyond := clampf(dist / throw_range, 0.0, 1.0)
# danger blends "the bull is fast and near" with "a charge is aimed at me".
var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close
if _is_charge_incoming():
danger = clampf(danger + 0.7, 0.0, 1.0)
# A matador won't commit to a strike it can't bail out of, so a spent dodge
# cooldown makes attacking (and dodging outright) less attractive.
var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
# The post-stab recovery beat (mat_stab_recovery) kills ATTACK outright — the matador circles
# or gives ground between thrusts rather than machine-gunning the bull; DODGE/FLEE stay live.
var attack_ready := 0.0 if _attack_cd > 0.0 else 1.0
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
# Only the nearest few matadors work the bull at once; the rest hang back and
# taunt (see _engagement_rank). This turns a crowd into a bullring — matadors
# spreading out and taking turns — instead of a swarm all piling on together.
var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots")))
# Supporting matadors strut and circle constantly; the lead only struts between
# passes, once the bull is back out past horn reach.
var strut := 0.65 if not is_engager else 0.3 * beyond
var engage_mult := 1.0 if is_engager else 0.15
var score := {
# Showmanship: circle the bull at a taunting distance and pose for the crowd.
Intent.WANDER: DP.f("mat_w_showmanship") * strut,
# Retreat only when the bull is close AND actually dangerous.
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
# Press the attack; ease off a touch when a charge is barrelling in. Supporting
# matadors barely press — they yield the pass to whoever's engaging.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult * attack_ready,
# Sidestep / plant against an incoming charge.
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
# Hurl the blade — an occasional ranged flourish from an engaging matador when
# there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
if (throw_ok and is_engager) else -1.0,
}
# Commit bonus keeps a near-tied choice from flip-flopping — but don't re-reinforce a
# just-swung ATTACK we're recovering from, or the matador clings to it and spams the bull.
if not (_intent == Intent.ATTACK and _attack_cd > 0.0):
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
var best: Intent = Intent.WANDER
var best_score := -INF
for intent: int in score:
if float(score[intent]) > best_score:
best_score = float(score[intent])
best = intent
return best
# True when the current State already carries out the intent, so we needn't restart
# it (restarting ATTACK every tick would reset its swing and stutter the animation).
func _realizes_intent(intent: Intent) -> bool:
match intent:
Intent.WANDER:
return _state == State.WANDER
Intent.FLEE:
return _state == State.FLEE
Intent.ATTACK:
return _state == State.ATTACK
Intent.DODGE:
return _state == State.BRACE or _state == State.SIDESTEP
Intent.THROW:
return _state == State.THROW
return false
func _enter_intent(intent: Intent) -> void:
match intent:
Intent.WANDER:
_state = State.WANDER
_steer_cd = 0.0
_pick_wander_target()
Intent.FLEE:
_state = State.FLEE
_steer_cd = 0.0
Intent.ATTACK:
_steer_cd = 0.0
_start_attack()
Intent.DODGE:
_start_brace()
Intent.THROW:
_start_throw()
# How many other live matadors are closer to the bull than this one (0 = the lead engager); the
# brain lets the nearest mat_engage_slots press while the rest spread out and taunt.
func _engagement_rank() -> int:
if _bull == null:
return 0
var my_d := global_position.distance_squared_to(_bull.global_position)
var rank := 0
for node: Node in get_tree().get_nodes_in_group(&"matador"):
if node == self:
continue
var other := node as Node3D
if other == null or not is_instance_valid(other) or not other.call(&"is_active"):
continue
if other.global_position.distance_squared_to(_bull.global_position) < my_d:
rank += 1
return rank
# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot.
func is_active() -> bool:
return _state != State.RAGDOLL
# True when this matador is safely out of the action — not one of the engaging few,
# and with the bull far enough off to strike a long, unhurried taunt for the crowd.
func _out_of_harm() -> bool:
if _bull == null:
return true
if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))):
return false
return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6
# Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if bull_flat.length() < DP.f("mat_charge_speed"):
return false
var to_me := (global_position - _bull.global_position)
to_me.y = 0.0
if to_me.length() > DP.f("mat_brace_range"):
return false
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
# Bull charging fast and aimed straight at the matador, at ANY range (unlike _is_charge_incoming,
# which is capped to brace range) — so a taunting matador can answer a charge from across the arena.
func _charge_aimed_at_me() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if bull_flat.length() < DP.f("mat_charge_speed"):
return false
var to_me := global_position - _bull.global_position
to_me.y = 0.0
if to_me.length() < 0.1:
return true
return bull_flat.normalized().dot(to_me.normalized()) > 0.9
# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
# Randomises among available clear angles so juke direction is unpredictable.
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
_steer_cd -= delta
if _steer_cd > 0.0 and _steer_dir != Vector3.ZERO:
return _steer_dir
_steer_cd = _STEER_INTERVAL
var space := get_world_3d().direct_space_state
var origin := global_position + Vector3.UP * 0.6
var params := PhysicsRayQueryParameters3D.new()
params.exclude = [get_rid()]
params.collision_mask = 1
# Try straight ahead first; if blocked, pick randomly from the side candidates.
params.from = origin
params.to = origin + desired_dir * _STEER_LOOKAHEAD
if space.intersect_ray(params).is_empty():
_steer_dir = desired_dir
return desired_dir
var candidates := _STEER_CANDIDATES.duplicate()
candidates.shuffle()
for angle: float in candidates:
var dir := desired_dir.rotated(Vector3.UP, angle)
params.from = origin
params.to = origin + dir * _STEER_LOOKAHEAD
if space.intersect_ray(params).is_empty():
_steer_dir = dir
return dir
_steer_dir = desired_dir
return desired_dir
# Ramp horizontal velocity toward dir*spd; vertical (gravity) is left untouched.
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)
# Turn the mesh to face travel direction — skipped at near-zero speed so a
# stopping matador doesn't snap to atan2(0, 0) == facing +Z (a jitter source).
func _face_velocity(delta: float, rate: float) -> void:
if Vector2(velocity.x, velocity.z).length_squared() > 0.04:
_mesh.rotation.y = lerp_angle(
_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * rate)
func _face_point(point: Vector3, delta: float, rate: float) -> void:
var to := point - global_position
to.y = 0.0
if to.length_squared() > 0.01:
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(to.x, to.z), delta * rate)
func _tick_wander(delta: float) -> void:
# Plant the feet while drawing / sheathing instead of sliding through the clip.
if _drawing or _sheathing or _idle_timer > 0.0:
# A charge aimed at a taunting matador snaps it out of the pose: hurl a spear at a bull
# barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush.
if _idle_timer > 0.0 and not _drawing and not _sheathing \
and _bull != null and _charge_aimed_at_me():
var dist := global_position.distance_to(_bull.global_position)
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
if dist > DP.f("mat_brace_range") and throw_ok:
_start_throw() # far charge — get the spear off before it arrives
return
if dist <= DP.f("mat_brace_range"):
_start_brace() # closer rush — draw the sword and cite the charge
return
_idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta)
# Taunt toward the bull so the posturing plays to it, not off into space.
if _bull != null and _idle_timer > 0.0:
_face_point(_bull.global_position, delta, _TURN_MOVE)
# Cycle gestures through a long hold so a posing matador stays lively.
_taunt_swap -= delta
if _taunt_swap <= 0.0 and not _drawing and not _sheathing:
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_play_anim(_idle_anim)
move_and_slide()
return
var to_target := Vector3(
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8:
# A matador out of harm's way settles into a long taunt for the crowd; one still
# in the mix only pauses briefly between passes.
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \
if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_pick_wander_target()
else:
var dir := _steer_clear(to_target.normalized(), delta)
_accelerate(dir, DP.f("mat_walk_speed"), delta)
_face_velocity(delta, _TURN_MOVE)
_play_anim(_ANIM_RUN)
move_and_slide()
func _tick_flee(delta: float) -> void:
# Plant the feet while sheathing instead of sliding away through the clip.
if _drawing or _sheathing:
_decelerate(18.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
move_and_slide()
return
var away := global_position - _bull.global_position
away.y = 0.0
if away.length() < 0.01:
away = Vector3(randf() - 0.5, 0.0, randf() - 0.5)
var dir := _steer_clear(away.normalized(), delta)
_accelerate(dir, DP.f("mat_flee_speed"), delta)
_face_velocity(delta, _TURN_MOVE)
_play_anim(_ANIM_RUN)
move_and_slide()
# Cite phase: plant feet, face the bull — wait for it to commit
func _start_brace() -> void:
_steer_cd = 0.0
_state = State.BRACE
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var charge_dir: Vector3
if bull_flat.length() > 1.0:
charge_dir = bull_flat.normalized()
else:
charge_dir = (_bull.global_position - global_position)
charge_dir.y = 0.0
charge_dir = charge_dir.normalized()
_will_dodge = randf() < DP.f("mat_dodge_chance")
var side := 1.0 if randf() > 0.5 else -1.0
_step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side)
_brace_timer = DP.f("mat_brace_duration")
func _tick_brace(delta: float) -> void:
_decelerate(18.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
_play_anim(_ANIM_ATTACK)
move_and_slide()
_brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position)
if _will_dodge:
# Dodger: wait for the bull to commit, then whip into the lateral pass.
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
_state = State.SIDESTEP
_step_timer = DP.f("mat_step_duration")
if _ole_player:
_ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play()
else:
# Planter (estocada): the bull only dies to an active, presented thrust, not to
# brushing a braced matador — so when it commits in, break into a real attack
# SWING (which lunges the blade forward and stabs), otherwise hold then press.
if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()):
_dodge_cd = DP.f("mat_dodge_cooldown")
_start_attack()
# Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps
# across its path as it charges by; face the bull so the sword points at it.
func _tick_sidestep(delta: float) -> void:
_step_timer -= delta
if _step_timer <= 0.0:
_start_attack()
return
var dir := _step_dir
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
if to_bull.length() > 0.1:
dir = (_step_dir + to_bull.normalized() * DP.f("mat_pass_lunge")).normalized()
var spd := DP.f("mat_step_speed")
velocity.x = dir.x * spd
velocity.z = dir.z * spd
_face_point(_bull.global_position, delta, _TURN_SHARP)
_play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
move_and_slide()
func _start_attack() -> void:
_state = State.ATTACK
_attack_timer = DP.f("mat_attack_duration")
_swing_timer = 0.0
_dodge_cd = DP.f("mat_dodge_cooldown")
func _tick_attack(delta: float) -> void:
_attack_timer -= delta
var was_swinging := _swing_timer > 0.0
_swing_timer = maxf(0.0, _swing_timer - delta)
# Plant the feet while drawing instead of sliding toward the bull mid-clip.
if _drawing or _sheathing:
_decelerate(22.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
move_and_slide()
return
_face_point(_bull.global_position, delta, _TURN_FACE)
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
var dist := to_bull.length()
# A finished stab enforces a recovery beat (no re-commit for mat_stab_recovery), then decides
# what's next: a landed hit very likely breaks into a taunt/retreat, otherwise the usual roll
# to give ground / circle / throw — anything but instantly drilling the same thrust again.
if was_swinging and _swing_timer <= 0.0:
_attack_cd = DP.f("mat_stab_recovery")
var landed := _sword_blade_area != null and _sword_blade_area.did_hit()
if landed and randf() < DP.f("mat_taunt_after_hit"):
_do_taunt_retreat()
return
if randf() < DP.f("mat_stab_followup_chance"):
_do_stab_followup()
return
# Run in (Run clip) to mat_stab_standoff, then plant and swing on the spot — the standoff sits
# inside the sword's ~3.75 m thrust reach; killing velocity on commit stops the feet sliding.
# The recovery beat gates the next commit so back-to-back stabs can't machine-gun the bull.
if _swing_timer <= 0.0 and _attack_cd <= 0.0 and dist <= DP.f("mat_stab_standoff") and _sword_in_hand:
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
velocity.x = 0.0
velocity.z = 0.0
# Re-arm fresh each swing — back-to-back stabs commit before the else-branch deactivate runs.
if _sword_blade_area != null:
_sword_blade_area.deactivate()
if _swing_timer > 0.0:
_decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK)
# Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only
# when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap.
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
_sword_blade_area.activate()
_sword_blade_area.sweep()
else:
if _sword_blade_area != null:
_sword_blade_area.deactivate()
var dir := _steer_clear(to_bull.normalized(), delta)
_accelerate(dir, DP.f("mat_attack_speed"), delta)
_play_anim(_ANIM_RUN)
move_and_slide()
# How far through the attack swing: 0 (just committed) → 1 (finished), tracking the clip's progress.
func _swing_phase() -> float:
var length := _anim_length(_ANIM_ATTACK, 0.9)
if length <= 0.0:
return 1.0
return clampf(1.0 - _swing_timer / length, 0.0, 1.0)
# Break off after a stab instead of drilling the same thrust: give ground, hurl a spear, or circle.
# The brain re-engages after; _decide_cd is reset so the pick plays out before it re-scores.
func _do_stab_followup() -> void:
_decide_cd = DP.f("mat_decide_interval")
_dodge_cd = DP.f("mat_dodge_cooldown")
_steer_cd = 0.0
var roll := randf()
if roll < 0.2 and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node):
_start_throw() # ranged flourish
elif roll < 0.6:
_state = State.FLEE # give ground after the thrust
_intent = Intent.FLEE
else:
_state = State.WANDER # circle to a fresh angle before pressing again
_intent = Intent.WANDER
_pick_wander_target()
# A stab that LANDED: milk it. Break into a long taunt held toward the bull, then drift out to the
# showmanship ring — and lock ATTACK out for the whole hold (_attack_cd) so the brain can't drag the
# matador straight back into another thrust. DODGE/FLEE stay live, so a charging bull is still
# answered. This is what stops the "stab, stab, stab" spam after the matador connects.
func _do_taunt_retreat() -> void:
_state = State.WANDER
_intent = Intent.WANDER
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3)
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_attack_cd = maxf(_attack_cd, _idle_timer)
_dodge_cd = DP.f("mat_dodge_cooldown")
_steer_cd = 0.0
_pick_wander_target()
if _sword_blade_area != null:
_sword_blade_area.deactivate()
func _enter_roll(hit_dir: Vector3) -> void:
_state = State.ROLL
# Match the state to the actual clip so the roll plays through once instead of
# cutting to Run partway. mat_roll_duration is only a fallback.
_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
hit_dir.y = 0.0
var side := 1.0 if randf() > 0.5 else -1.0
_roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side)
if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
_anim_player.play(_ANIM_ROLL)
_anim_player.seek(0.0, true)
# Called (deferred) by the spawner to make the matador leap-roll toward a target on entry — the
# cinematic arena entrance. After the roll it transitions to normal AI via the ROLL → FLEE path.
func enter_spawn_roll(toward: Vector3) -> void:
var dir := toward - global_position
dir.y = 0.0
_roll_dir = dir.normalized() if dir.length() > 0.1 else -_mesh.global_transform.basis.z
_state = State.ROLL
_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
_is_spawning = true
collision_layer = 0
velocity.y = 4.0
if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
_anim_player.play(_ANIM_ROLL)
_anim_player.seek(0.0, true)
func _tick_roll(delta: float) -> void:
_roll_timer -= delta
if _roll_timer <= 0.0:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
return
var spd := DP.f("mat_step_speed")
velocity.x = _roll_dir.x * spd
velocity.z = _roll_dir.z * spd
_face_velocity(delta, _TURN_SHARP)
_play_anim(_ANIM_ROLL)
move_and_slide()
func _start_throw() -> void:
_state = State.THROW
_throw_timer = DP.f("mat_throw_windup")
velocity = Vector3.ZERO
_cancel_transitions()
_carry_sword(false) # the sword is melee-only — holster it for the throw
_spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw
if _anim_player:
_anim_player.pause()
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
_throw_dir = to_bull.normalized() if to_bull.length() > 0.1 else _mesh.global_transform.basis.z
func _tick_throw(delta: float) -> void:
_decelerate(20.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
if to_bull.length() > 0.1:
_throw_dir = to_bull.normalized()
var dur := maxf(DP.f("mat_throw_windup"), 0.001)
var prev := clampf(1.0 - _throw_timer / dur, 0.0, 1.0)
_throw_timer -= delta
var phase := clampf(1.0 - _throw_timer / dur, 0.0, 1.0)
_pose_throw_arm(phase)
var t_rel := DP.f("mat_throw_release")
if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
_release_spear()
if _throw_timer <= 0.0:
_end_throw()
move_and_slide()
func _end_throw() -> void:
_state = State.FLEE
_clear_spear() # drop any spear still in hand if the wind-up was cut short
_dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws well apart so a hurl stays a flourish, not a barrage.
_throw_aim_cd = randf_range(4.0, 7.0)
_intent = Intent.FLEE
# Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
if _anim_player:
_anim_player.play(_ANIM_RUN)
# Drive arm_R + forearm_R through a cock-back → release arc while the
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
func _pose_throw_arm(phase: float) -> void:
if not _skeleton:
return
var t_rel := DP.f("mat_throw_release")
var cock := DP.f("mat_throw_arm_cock")
var release := DP.f("mat_throw_arm_release")
var elbow := DP.f("mat_throw_elbow")
var arm_ang: float
var elbow_ang: float
if phase < t_rel:
var u := phase / maxf(t_rel, 0.001)
arm_ang = lerpf(0.0, cock, u)
elbow_ang = lerpf(0.0, elbow, u)
else:
var u := (phase - t_rel) / maxf(1.0 - t_rel, 0.001)
arm_ang = lerpf(cock, release, u)
elbow_ang = lerpf(elbow, 0.0, u)
_set_bone_swing("arm_R", arm_ang)
_set_bone_swing("forearm_R", elbow_ang)
func _set_bone_swing(bone: String, deg: float) -> void:
var idx := _skeleton.find_bone(bone)
if idx < 0:
return
var rest := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion()
_skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg)))
# Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
func _spawn_spear_in_hand() -> void:
if is_instance_valid(_spear_node) or _sword_hand_attach == null:
return
_spear_node = _SPEAR_SCENE.instantiate() as Node3D
_sword_hand_attach.add_child(_spear_node)
_spear_node.position = Vector3(
DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
_spear_node.rotation_degrees = Vector3(
DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))
func _clear_spear() -> void:
if is_instance_valid(_spear_node):
_spear_node.queue_free()
_spear_node = null
# Hurl the held spear on a ballistic arc that lands on the bull's low body. The
# launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
# lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
func _release_spear() -> void:
if not is_instance_valid(_spear_node):
return
var spear := _spear_node
var origin := spear.global_position
_spear_node = null
var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed
var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05)
# Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it
# across the arc's flight time.
var target := origin + _throw_dir * 5.0
if is_instance_valid(_bull):
target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var lead := target - origin
lead.y = 0.0
target += _bull.velocity * (lead.length() / hs)
var flat := target - origin
var rise := flat.y
flat.y = 0.0
var dist := flat.length()
var hdir := flat.normalized() if dist > 0.01 else _throw_dir
# Aim scatter so the arc can be read and sidestepped, not a hitscan.
var spread := deg_to_rad(DP.f("mat_throw_spread"))
hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
# Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
# pick the vertical launch speed that lands the arc on the aim point's height.
var t := dist / hs
var vy := (rise + 0.5 * g * t * t) / t
var vel := hdir * hs + Vector3.UP * vy
SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
maxf(DP.f("mat_throw_gravity"), 0.05))
func _tick_ragdoll(_delta: float) -> void:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
# Current state as its enum name (WANDER / FLEE / …), for the debug state overlay.
func ai_state_name() -> String:
return State.keys()[_state]
func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void:
if _state == State.RAGDOLL or _state == State.ROLL:
return
_acquire_bull()
_enter_ragdoll(hit_dir, strength, up_boost)
func _on_body_entered(body: Node3D) -> void:
if _state == State.RAGDOLL or _state == State.ROLL:
return
if not body.is_in_group(&"player"):
return
var player := body as CharacterBody3D
var bull_speed := player.velocity.length()
if bull_speed < DP.f("mat_hit_threshold"):
return
if not _horns_first(player.global_position):
return
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
(global_position - player.global_position).normalized()
_take_bull_charge(bull_speed, hit_dir)
# Tunnel-proof charge detection: at dash speed the bull skips clean over the thin HitArea between
# ticks, so body_entered never fires. Sweep its actual travel (prev → now) and gore on any pass within
# mat_charge_gore_radius. Runs every frame (even ragdolled) to keep the segment fresh.
func _sweep_bull_gore() -> void:
var now := _bull.global_position
var seg_from := _bull_prev_pos if _bull_prev_seen else now
_bull_prev_pos = now
_bull_prev_seen = true
if _state == State.RAGDOLL or _state == State.ROLL:
return
var flat_vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var bull_speed := flat_vel.length()
if bull_speed < DP.f("mat_hit_threshold"):
return
var a := Vector3(seg_from.x, 0.0, seg_from.z)
var b := Vector3(now.x, 0.0, now.z)
var p := Vector3(global_position.x, 0.0, global_position.z)
var closest := Geometry3D.get_closest_point_to_segment(p, a, b)
if p.distance_to(closest) > DP.f("mat_charge_gore_radius"):
return
if not _horns_first(seg_from):
return
var hit_dir := flat_vel.normalized() if bull_speed > 0.5 \
else (global_position - now).normalized()
_take_bull_charge(bull_speed, hit_dir)
# Horns-first gate: gore only when the bull's velocity points within bull_gore_arc of the direction
# from `from` to the matador — not a sideways brush or backing in. The dash ability skips the gate.
func _horns_first(from: Vector3) -> bool:
if _bull == null:
return false
if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"):
return true # the dash ability is a committed lunge — it gores on contact, no angle gate
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if vel.length() < 0.5:
return false
var to_me := global_position - from
to_me.y = 0.0
if to_me.length() < 0.01:
return true
return vel.normalized().dot(to_me.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc")))
# Resolve a connected charge: the survival roll fades as bull speed climbs (mat_charge_pierce), so a
# committed charge gores rather than getting shrugged off.
func _take_bull_charge(bull_speed: float, hit_dir: Vector3) -> void:
# The boulder roll bowls matadors over with its own launch + chain (player._roll_try_pop); don't
# pre-empt that with a quiet gore, or the ball rolls straight through without popping.
if _bull.has_method(&"is_rolling") and _bull.call(&"is_rolling"):
return
# The dash is a committed lunge — a connect is a guaranteed kill, no survival roll (it already
# bypasses the horns cone). A passive cruise-charge still leaves a roll chance that fades with speed.
var roll_chance := 0.0
if not (_bull.has_method(&"is_dashing") and _bull.call(&"is_dashing")):
var floor_spd := DP.f("mat_hit_threshold")
var full_spd := maxf(DP.f("mat_charge_speed"), floor_spd + 0.1)
var commit := clampf((bull_speed - floor_spd) / (full_spd - floor_spd), 0.0, 1.0)
roll_chance = DP.f("mat_roll_chance") * (1.0 - commit * DP.f("mat_charge_pierce"))
if randf() < roll_chance:
_enter_roll(hit_dir)
else:
_enter_ragdoll(hit_dir, bull_speed)
# Reach-based hit for the sidestep PASS (fast lateral sweep — the thin blade Area tunnels, so a
# distance+facing check is the reliable gore; the ATTACK stab uses the blade collider instead).
func _try_melee_hit(reach: float) -> void:
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
return
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
var dist := to_bull.length()
if dist > reach or dist < 0.01:
return
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
return
_blade_hit_cd = DP.f("mat_stab_cooldown")
_bull.call(&"take_sword_hit", "gored")
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
_state = State.RAGDOLL
if _sword_blade_area != null:
_sword_blade_area.deactivate()
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
killed.emit()
var cleanup_timer := get_tree().create_timer(4.0)
cleanup_timer.timeout.connect(func() -> void:
if is_instance_valid(self):
queue_free()
)
hit_dir.y = 0.0
hit_dir = hit_dir.normalized()
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
if _death_player:
_death_player.pitch_scale = randf_range(0.9, 1.1)
_death_player.play()
if _bull != null:
var cam := _bull.get_node_or_null("Camera3D")
if cam:
cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0))
velocity = Vector3.ZERO
collision_layer = 0
if _anim_player:
_anim_player.pause()
if not _sim:
return
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var bone_idx := _skeleton.find_bone(pb.bone_name)
if bone_idx >= 0:
pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx)
_sim.active = true
_sim.physical_bones_start_simulation()
_pin_cape_to_body()
var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3)
var right := throw_dir.cross(Vector3.UP).normalized()
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var impulse := Vector3.ZERO
match pb.bone_name:
"COG", "matador":
impulse = throw_dir * strength
"chest":
impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9
"head":
impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7
"leg_L":
impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"leg_R":
impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"arm_L":
impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"arm_R":
impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"Bone", "Bone.001", "Bone.002":
# Scale by mass so the light cape gets the same velocity as the body
# (Δv = impulse/mass) and flies along with it instead of rocketing off.
impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass
_:
continue
var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15))
pb.apply_central_impulse(impulse + noise * strength * 0.2)
# A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv
# (impulse = mass · speed) so the joints carry every bone up together.
if up_boost > 0.0:
for child: Node in _sim.get_children():
if child is PhysicalBone3D:
var pbb := child as PhysicalBone3D
pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass)
# Pin the cape's root link to the chest so it stays attached at the shoulders
# while ragdolling (its bone chain is a skeleton root, so it has no joint to the
# body otherwise). The chain hangs and swings from this point.
func _pin_cape_to_body() -> void:
var cape := _physical_bone(&"Bone")
var chest := _physical_bone(&"chest")
if cape == null or chest == null:
return
var joint := PinJoint3D.new()
_sim.add_child(joint)
joint.global_position = cape.global_position
joint.node_a = chest.get_path()
joint.node_b = cape.get_path()
func _physical_bone(bone: StringName) -> PhysicalBone3D:
if not _sim:
return null
for child: Node in _sim.get_children():
if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone:
return child as PhysicalBone3D
return null
# ── Animation helpers ─────────────────────────────────────────────────────────
func _ensure_loop(anim_name: StringName) -> void:
if _anim_player.has_animation(anim_name):
var anim := _anim_player.get_animation(anim_name)
anim.loop_mode = Animation.LOOP_LINEAR
# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the
# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel.
func _flatten_roll_travel() -> void:
if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL):
return
var anim := _anim_player.get_animation(_ANIM_ROLL)
if anim.has_meta(&"_travel_flattened"):
return
anim.set_meta(&"_travel_flattened", true)
var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D)
if cog < 0:
return
# Snapshot the COG travel before mutating (flattening COG first would zero it).
var travel: Dictionary = {}
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var t := anim.track_get_key_time(i, k)
travel[t] = anim.position_track_interpolate(cog, t).z
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var v: Vector3 = anim.track_get_key_value(i, k)
anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)]))
func _pick_taunt() -> StringName:
var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS:
if _anim_player != null and _anim_player.has_animation(anim):
choices.append(anim)
if choices.is_empty():
return _ANIM_IDLE
return choices[randi() % choices.size()]
func _anim_length(anim_name: StringName, fallback: float) -> float:
if _anim_player and _anim_player.has_animation(anim_name):
return _anim_player.get_animation(anim_name).length
return fallback
func _play_anim(anim_name: StringName) -> void:
if _anim_player == null:
return
if _drawing or _sheathing:
return # let the draw / sheathe clip play through uninterrupted
if _anim_player.current_animation == anim_name:
return
_anim_player.play(anim_name)
func _pick_wander_target() -> void:
# Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
# crowd out around it), with an occasional close pass for daring flair.
if _bull != null:
var bull_flat := _bull.global_position
bull_flat.y = 0.0
var r: float
if randf() < 0.2:
r = randf_range(1.5, 4.0) # close daring pass
else:
var ring := DP.f("mat_taunt_ring")
r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring
var angle := randf() * TAU
_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
# Keep the pose inside the fighting floor rather than backing onto the benches.
var arena_r := DP.f("arena_spawn_radius")
var flat := Vector2(_wander_target.x, _wander_target.z)
if flat.length() > arena_r:
flat = flat.normalized() * arena_r
_wander_target = Vector3(flat.x, 0.0, flat.y)
return
var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU
var dist := randf_range(2.0, radius)
_wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)
# ── Sword attachment ──────────────────────────────────────────────────────────
# The rig (matador_v03) carries two bones: weapon_bone (right-hand grip) and weapon_rest_bone (hip
# holster). The sword rides in the holster, carried to the hand in combat (_carry_sword); each bone
# has its own DP-tunable offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
func _setup_sword() -> void:
if not _skeleton:
return
var has_hand := _skeleton.find_bone("weapon_bone") != -1
var has_rest := _skeleton.find_bone("weapon_rest_bone") != -1
if not has_hand and not has_rest:
return
if has_hand:
_sword_hand_attach = BoneAttachment3D.new()
_sword_hand_attach.bone_name = "weapon_bone"
_skeleton.add_child(_sword_hand_attach)
if has_rest:
_sword_rest_attach = BoneAttachment3D.new()
_sword_rest_attach.bone_name = "weapon_rest_bone"
_skeleton.add_child(_sword_rest_attach)
_spawn_sword()
# Instantiate the matador's sword into the holster, with its own blade collider.
func _spawn_sword() -> void:
if is_instance_valid(_sword_node):
return
if _sword_rest_attach == null and _sword_hand_attach == null:
return
_sword_node = _SWORD_SCENE.instantiate() as Node3D
# Start holstered (fall back to the hand if the rig only has one of the bones).
var start_attach := _sword_rest_attach if _sword_rest_attach else _sword_hand_attach
start_attach.add_child(_sword_node)
_sword_in_hand = start_attach == _sword_hand_attach
_drawing = false
_apply_sword_grip()
# The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
# _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
if _sword_blade_area != null:
_sword_blade_area.deactivate()
# The sword is MELEE-only: drawn for ATTACK / BRACE / SIDESTEP, holstered otherwise. Ranged throws
# use a spear (_spawn_spear_in_hand), so THROW keeps the sword holstered.
func _wants_sword_drawn() -> bool:
return _state == State.ATTACK \
or _state == State.BRACE or _state == State.SIDESTEP
func _update_sword_carry() -> void:
if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
return
# The blade collider lives only during the ATTACK thrust; leaving ATTACK disarms it.
if _state != State.ATTACK and _sword_blade_area != null:
_sword_blade_area.deactivate()
if _wants_sword_drawn():
if not _sword_in_hand and not _drawing:
_begin_draw()
elif _sword_in_hand or _drawing:
# Animate the sheathe only into the unhurried locomotion states; snap for
# a dodge / roll / brace where the body clip can't spare the time.
if _state == State.WANDER or _state == State.FLEE:
if not _sheathing:
_begin_sheathe()
else:
_cancel_transitions()
_carry_sword(false)
func _begin_draw() -> void:
if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
_carry_sword(true) # no draw clip on this rig — snap to hand
return
_sheathing = false
_drawing = true
_draw_timer = 0.0
_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
_anim_player.play(_ANIM_DRAW, -1.0, _draw_speed())
_anim_player.seek(0.0, true)
func _begin_sheathe() -> void:
if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
_carry_sword(false) # no clip — snap to holster
return
_drawing = false
_sheathing = true
_draw_timer = 0.0
_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
# Draw_weapon reversed from its end frame = a put-the-sword-away motion.
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
func _advance_draw(delta: float) -> void:
if not _drawing and not _sheathing:
return
_draw_timer += delta
var t := _draw_timer / maxf(_draw_len, 0.001)
if _drawing:
if not _sword_in_hand and t >= _DRAW_GRAB_AT:
_carry_sword(true)
if t >= 1.0:
_drawing = false
_carry_sword(true)
else:
if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT:
_carry_sword(false)
if t >= 1.0:
_sheathing = false
_carry_sword(false)
func _cancel_transitions() -> void:
_drawing = false
_sheathing = false
func _draw_speed() -> float:
return maxf(DP.f("mat_draw_speed"), 0.1)
# Move the sword between hand grip and hip holster, reapplying the local seating.
func _carry_sword(in_hand: bool) -> void:
if not is_instance_valid(_sword_node):
return
if in_hand == _sword_in_hand:
return
var target := _sword_hand_attach if in_hand else _sword_rest_attach
if target == null:
return
_sword_in_hand = in_hand
_sword_node.reparent(target, false)
_apply_sword_grip()
# Carrying never arms the blade — only the ATTACK thrust does (see _tick_attack).
if _sword_blade_area != null:
_sword_blade_area.deactivate()
func _apply_sword_grip() -> void:
if not is_instance_valid(_sword_node):
return
if _sword_in_hand:
_sword_node.position = Vector3(
DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z"))
_sword_node.rotation_degrees = Vector3(
DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z"))
else:
_sword_node.position = Vector3(
DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z"))
_sword_node.rotation_degrees = Vector3(
DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z"))
# ── Utility ───────────────────────────────────────────────────────────────────
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