Files
Bullosseum/matador.gd
T
2026-06-24 23:11:19 +03:00

970 lines
32 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 }
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 _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 _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 _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 _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"
# 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
const _ACCEL_FACTOR: float = 8.0 # velocity ramp = speed * factor * delta
const _TURN_MOVE: float = 8.0 # face the travel direction while roaming
const _TURN_FACE: float = 12.0 # face the bull while engaging
const _TURN_SHARP: float = 20.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 = _setup_physical_bones()
if _anim_player:
for anim in [_ANIM_RUN, _ANIM_IDLE, _ANIM_ATTACK, _ANIM_ROLL]:
_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.8, 2.5)
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_setup_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 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()
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)
func _update_ai_state() -> void:
if _state == State.THROW:
return
var dist := global_position.distance_to(_bull.global_position)
if (_state == State.WANDER or _state == State.FLEE or _state == State.ATTACK) \
and _throw_aim_cd <= 0.0 and _sword_in_hand and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range"):
# Roll for a throw; on a miss, wait before becoming eligible again so the
# matador commits to chasing for a melee Attack instead of throwing early.
if randf() < DP.f("mat_throw_chance"):
_start_throw()
return
_throw_aim_cd = randf_range(2.5, 5.0)
match _state:
State.WANDER:
if dist < DP.f("mat_flee_range"):
_state = State.FLEE
elif _dodge_cd <= 0.0 and dist < DP.f("mat_attack_range") and not _is_charge_incoming():
_steer_cd = 0.0
_start_attack()
State.FLEE:
if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()):
_start_brace()
elif dist > DP.f("mat_flee_range") * 1.3:
_state = State.WANDER
_steer_cd = 0.0
_pick_wander_target()
State.BRACE:
pass # brace timer drives transition
State.SIDESTEP:
pass # step timer drives transition
State.ATTACK:
if _attack_timer <= 0.0 or dist > DP.f("mat_attack_range") * 1.5:
_state = State.FLEE
State.ROLL:
pass # roll timer drives transition
# 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 unsheathing instead of sliding through the draw clip.
if _drawing or _idle_timer > 0.0:
_idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta)
_play_anim(_ANIM_IDLE)
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"))
_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:
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 dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge:
_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:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Pase phase: sharp lateral step, then immediately exploit with an attack
func _tick_sidestep(delta: float) -> void:
_step_timer -= delta
if _step_timer <= 0.0:
_start_attack()
return
var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd
velocity.z = _step_dir.z * spd
_face_velocity(delta, _TURN_SHARP)
_play_anim(_ANIM_ROLL)
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)
_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"):
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
if _swing_timer > 0.0:
_decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK)
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
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")
# 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 = 4
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.
var cap := CapsuleShape3D.new()
cap.radius = 0.06
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)
var dir := (_throw_dir + Vector3.UP * 0.12).normalized()
rb.linear_velocity = dir * DP.f("mat_throw_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")
)
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()
func apply_ability_hit(hit_dir: Vector3, strength: float) -> void:
if _state == State.RAGDOLL or _state == State.ROLL:
return
_acquire_bull()
_enter_ragdoll(hit_dir, strength)
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.RAGDOLL or _state != State.ATTACK:
return
if not body.is_in_group(&"player"):
return
if _blade_hit_cd > 0.0:
return
_blade_hit_cd = 0.5
body.call(&"take_sword_hit")
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> 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()
_spawn_blood_burst(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()
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
_:
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)
# ── Blood burst ───────────────────────────────────────────────────────────────
func _setup_blood_burst() -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.5))
scale_curve.add_point(Vector2(1.0, 0.0))
_blood_burst = CPUParticles3D.new()
_blood_burst.mesh = sphere
_blood_burst.color_ramp = ramp
_blood_burst.scale_amount_curve = scale_curve
_blood_burst.one_shot = true
_blood_burst.explosiveness = 0.9
_blood_burst.amount = 25
_blood_burst.lifetime = 1.1
_blood_burst.randomness = 0.4
_blood_burst.local_coords = false
_blood_burst.spread = 80.0
_blood_burst.gravity = Vector3(0.0, -7.0, 0.0)
_blood_burst.initial_velocity_min = 4.0
_blood_burst.initial_velocity_max = 11.0
_blood_burst.scale_amount_min = 0.5
_blood_burst.scale_amount_max = 1.6
_blood_burst.emitting = false
add_child(_blood_burst)
func _spawn_blood_burst(hit_dir: Vector3) -> void:
_blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0)
_blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized()
_blood_burst.restart()
# ── 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 _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:
return # let the unholster 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). Both bones are oriented in
# Blender to seat the sword directly, so the local offset/rotation default to
# identity and stay DP-tunable for fine-tuning 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.10
blade_cap.height = 1.10
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)
# Draw the sword as the bull approaches and re-sheathe it once the bull is far
# again — so a matador wanders with the sword holstered and pulls it out to
# engage. Drawing plays the Draw_weapon clip; re-sheathing snaps back. Hysteresis
# (1.25× on the re-sheathe) avoids flicker at the threshold.
func _update_sword_carry() -> void:
if not is_instance_valid(_sword_node):
return
if _state == State.RAGDOLL or _bull == null:
_cancel_draw()
_carry_sword(false)
return
var dist := global_position.distance_to(_bull.global_position)
var draw_r := DP.f("mat_draw_range")
if dist <= draw_r:
if not _sword_in_hand and not _drawing:
_begin_draw()
elif dist > draw_r * 1.25:
_cancel_draw()
_carry_sword(false)
func _begin_draw() -> void:
if _anim_player and _anim_player.has_animation(_ANIM_DRAW):
_drawing = true
_draw_len = _anim_length(_ANIM_DRAW, 1.0)
_draw_timer = 0.0
_anim_player.play(_ANIM_DRAW)
_anim_player.seek(0.0, true)
else:
_carry_sword(true) # no draw clip on this rig — snap to hand
func _advance_draw(delta: float) -> void:
if not _drawing:
return
# A combat state needs the body animations back at once — finish instantly.
if _state != State.WANDER and _state != State.FLEE:
_finalize_draw()
return
_draw_timer += delta
if not _sword_in_hand and _draw_timer >= _draw_len * _DRAW_GRAB_AT:
_carry_sword(true)
if _draw_timer >= _draw_len:
_finalize_draw()
func _finalize_draw() -> void:
if not _drawing:
return
_drawing = false
_carry_sword(true)
# Abort an in-progress draw without forcing the sword into the hand, so the
# caller can re-holster (the suppressed locomotion anim resumes next frame).
func _cancel_draw() -> void:
_drawing = false
# 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
_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"))
# ── Ragdoll setup ─────────────────────────────────────────────────────────────
func _setup_physical_bones() -> PhysicalBoneSimulator3D:
for child: Node in _skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
_skeleton.add_child(sim)
const BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
]
for entry: Array in BONES:
var bname: String = entry[0]
if _skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.4
pb.linear_damp = 0.1
pb.angular_damp = 0.2
pb.collision_layer = 1
pb.collision_mask = 1
var cs := CollisionShape3D.new()
var use_capsule: bool = entry.size() >= 3
if use_capsule:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
# ── 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