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