Files
Bullosseum/matador.gd
T

1105 lines
40 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 _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 _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 _resword_timer: float = 0.0
var _sword_blade_area: Area3D = 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 _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_ROLL] + _ANIM_TAUNTS:
_ensure_loop(anim)
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(0.3, 1.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
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
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 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)
# Pull a replacement sword from the holster a beat after throwing the last one.
if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \
and (_sword_rest_attach != null or _sword_hand_attach != null):
_resword_timer -= delta
if _resword_timer <= 0.0:
_spawn_sword()
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")
var score := {
# Only drift when the bull is out past throw range; up close this is ~0.
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond,
# 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.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_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 whenever there's a clean lane at range — the ranged threat
# that punishes a bull for hanging back out of horn reach.
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \
* (0.35 + 0.65 * (1.0 - close))) if throw_ok 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()
# 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)
_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:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt()
_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)
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(true) # blade must be in hand for the wind-up and release
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(_sword_node):
_release_sword()
if _throw_timer <= 0.0:
_end_throw()
move_and_slide()
func _end_throw() -> void:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws out so the utility brain doesn't immediately vote another one,
# but keep them frequent enough to pressure a bull that hangs back at range.
_throw_aim_cd = randf_range(1.6, 3.2)
_intent = Intent.FLEE
# Blend straight into locomotion so the manually-posed throw arm eases out
# (a bare resume() would snap and leave the player on whatever it paused on).
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 rig notes);
# angles are DP-tunable so the arc can be dialled in per rig.
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)))
# Detach the sword from the hand and launch it as a spinning physics projectile
# that damages the bull on contact, then settles and despawns.
func _release_sword() -> void:
if not is_instance_valid(_sword_node):
return
var sword := _sword_node
var blade := _sword_blade_area
var gx := sword.global_transform
_sword_node = null
_sword_blade_area = null
_sword_in_hand = false
_resword_timer = DP.f("mat_resword_delay")
# The RigidBody owns collisions now — silence the melee blade Area it carries.
if is_instance_valid(blade):
blade.monitoring = false
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
rb.contact_monitor = true
rb.max_contacts_reported = 6
# Fly nearly straight to the aim point instead of lobbing — the old horizontal
# throw sailed clean over the bull's low body. A small gravity_scale keeps a
# touch of drop for feel without dropping short.
rb.gravity_scale = DP.f("mat_throw_gravity")
get_tree().current_scene.add_child(rb)
rb.global_transform = gx
sword.reparent(rb, true)
# Physics collider along the blade (GLB +Z), which equals rb-local +Z because
# the mesh kept its global transform and rb adopted it. Fattened so a fast throw
# reliably overlaps the bull's collision spheres instead of tunnelling past.
var cap := CapsuleShape3D.new()
cap.radius = 0.14
cap.height = 1.4
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.7)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs)
# Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the
# origin), leading a moving target so it arrives where the bull will be. The aim
# keeps its vertical component so the blade drives into the body, not over it.
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
var dir := (_throw_dir + Vector3.UP * 0.0)
if is_instance_valid(_bull):
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var flat := target - gx.origin
flat.y = 0.0
var flight := flat.length() / speed
target += _bull.velocity * flight
var to_target := target - gx.origin
if to_target.length() > 0.1:
dir = to_target.normalized()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
# a bull that keeps moving can slip them, a stationary one gets pinned.
var spread := deg_to_rad(DP.f("mat_throw_spread"))
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
rb.linear_velocity = dir * speed
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin")
var hit_done := [false]
rb.body_entered.connect(func(body: Node) -> void:
if hit_done[0]:
return
if body.is_in_group(&"player"):
hit_done[0] = true
body.call(&"take_sword_hit", "thrown")
)
var cleanup := get_tree().create_timer(6.0)
cleanup.timeout.connect(func() -> void:
if is_instance_valid(rb):
rb.queue_free()
)
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
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
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:
# 35% chance: pick a point orbiting close to the bull for a daring pass
if _bull != null and randf() < 0.35:
var angle := randf() * TAU
var r := randf_range(1.5, 4.0)
var bull_flat := _bull.global_position
bull_flat.y = 0.0
_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
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 for the sword:
# weapon_bone — the grip pose in the right hand (drawn / fighting)
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering)
# The sword lives in the holster by default and is carried to the hand while the
# matador is in a combat state (see _carry_sword). Each bone has its own
# DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the
# holster) so the two poses can be seated independently without re-exporting.
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 a fresh sword into the holster, with its own blade collider. Called
# at spawn and again after a throw, so a matador can keep drawing replacements.
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 spans the steel (hilt at local origin, blade running +Z out
# to ~1.4 m). 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)
# CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade.
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 carried by ACTION, not distance: drawn only while striking
# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting.
# THROW snaps the blade out (its arm cock-back covers the motion); everything
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW \
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 for
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
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 the hand grip and the hip holster. Reparenting follows
# whichever bone the BoneAttachment tracks; the local grip transform is reapplied
# so the seating is identical in both sockets.
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