Files
Bullosseum/matador.gd
T

1190 lines
43 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 → bull outside flee range, roaming freely
# FLEE → bull within range, matador backs away (raycast-steered around walls)
# BRACE → charge detected: plant feet, face the bull (cite phase)
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
# ATTACK → post-dodge or mid-range: chase and strike
# ROLL → hit by bull but roll chance fires: survive and recover
# THROW → planted, winding up an overhand throw, then releasing the sword
# RAGDOLL → hit by bull, no roll chance
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 _lunge_timer: float = 0.0
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: Area3D = 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
const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb")
const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb")
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
func _ready() -> void:
add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
if _skeleton:
_sim = MatadorRagdoll.build(_skeleton)
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)
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. The physically committed states (a brace, a pass, a roll, a
# throw wind-up) run to completion on their own timers; from the free states the
# matador re-scores its options every mat_decide_interval and acts on the winner.
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 heuristics and return the winner. Weights (the
# mat_w_* params) shape the personality; 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
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,
# 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,
}
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
# closest (the natural lead engager); the utility brain gives the nearest
# mat_engage_slots the go-ahead to 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
# 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:
_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
_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()
# Commit to a full swing once in range instead of flipping Run/Attack at the
# distance boundary every frame (the old foot-slide jitter). When the swing
# ends the matador re-closes if the bull slipped away, or stabs again if not.
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
_lunge_timer = DP.f("mat_lunge_time")
if _swing_timer > 0.0:
_lunge_timer = maxf(0.0, _lunge_timer - delta)
if _lunge_timer > 0.0 and dist > 0.1:
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
else:
_decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
else:
var dir := _steer_clear(to_bull.normalized(), delta)
_accelerate(dir, DP.f("mat_attack_speed"), delta)
_play_anim(_ANIM_RUN)
move_and_slide()
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 by the spawner (deferred) to make the matador leap-roll toward a target
# point on entry — the cinematic arena entrance. After the roll it transitions to
# normal AI via the standard 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
if player.velocity.length() < DP.f("mat_hit_threshold"):
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()
if randf() < DP.f("mat_roll_chance"):
_enter_roll(hit_dir)
else:
_enter_ragdoll(hit_dir, player.velocity.length())
func _on_blade_hit(body: Node3D) -> void:
if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE:
return
if not body.is_in_group(&"player"):
return
if _blade_hit_cd > 0.0:
return
_blade_hit_cd = DP.f("mat_stab_cooldown")
body.call(&"take_sword_hit", "gored")
# Reach-based stab. A charging bull crosses metres per physics frame, so it easily
# tunnels through the thin blade Area between frames — this distance check is the
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
# blade actually presented, not held off to the side — it's gored.
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
# Only lands within the frontal arc — you're gored on a presented blade, not by
# brushing a matador whose sword is pointing elsewhere.
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:
_sword_blade_area.monitoring = false
_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 purpose-built bones: weapon_bone (right-hand
# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
# local 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()
# Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
# Only monitors while the sword is in hand (see _carry_sword).
var blade_cap := CapsuleShape3D.new()
blade_cap.radius = 0.18
blade_cap.height = 1.30
var blade_cs := CollisionShape3D.new()
blade_cs.shape = blade_cap
blade_cs.position = Vector3(0.0, 0.0, 0.8)
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
_sword_blade_area = Area3D.new()
_sword_blade_area.collision_layer = 0
_sword_blade_area.collision_mask = 1
_sword_blade_area.monitoring = _sword_in_hand
_sword_blade_area.add_child(blade_cs)
_sword_node.add_child(_sword_blade_area)
_sword_blade_area.body_entered.connect(_on_blade_hit)
# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
# (see _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
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()
if _sword_blade_area:
_sword_blade_area.monitoring = in_hand
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