970 lines
32 KiB
GDScript
970 lines
32 KiB
GDScript
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
|