extends CharacterBody3D # WANDER → bull outside flee range, roaming freely # FLEE → bull within range, matador backs away (raycast-steered around walls) # BRACE → charge detected: plant feet, face the bull (cite phase) # SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase) # ATTACK → post-dodge or mid-range: chase and strike # ROLL → hit by bull but roll chance fires: survive and recover # THROW → planted, winding up an overhand throw, then releasing the sword # RAGDOLL → hit by bull, no roll chance enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } # The matador's high-level *want*, chosen by a weighted utility vote (see # _choose_intent). Each intent maps onto one or more concrete States below. enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW } signal killed var _state: State = State.WANDER var _skeleton: Skeleton3D = null var _sim: PhysicalBoneSimulator3D = null var _anim_player: AnimationPlayer = null var _wander_target: Vector3 = Vector3.ZERO var _idle_timer: float = 0.0 var _taunt_swap: float = 0.0 var _idle_anim: StringName = _ANIM_IDLE var _bull: CharacterBody3D = null var _step_dir: Vector3 = Vector3.ZERO var _brace_timer: float = 0.0 var _step_timer: float = 0.0 var _dodge_cd: float = 0.0 var _will_dodge: bool = true var _attack_timer: float = 0.0 var _swing_timer: float = 0.0 var _lunge_timer: float = 0.0 var _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _is_spawning: bool = false var _throw_timer: float = 0.0 var _throw_aim_cd: float = 0.0 var _throw_dir: Vector3 = Vector3.ZERO var _steer_dir: Vector3 = Vector3.ZERO var _steer_cd: float = 0.0 var _intent: Intent = Intent.WANDER var _decide_cd: float = 0.0 var _ole_player: AudioStreamPlayer = null var _sword_hand_attach: BoneAttachment3D = null var _sword_rest_attach: BoneAttachment3D = null var _sword_node: Node3D = null var _sword_in_hand: bool = false var _drawing: bool = false var _sheathing: bool = false var _draw_timer: float = 0.0 var _draw_len: float = 0.0 var _sword_blade_area: Area3D = null var _spear_node: Node3D = null var _blade_hit_cd: float = 0.0 var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @onready var _mesh: Node3D = $matador2 @onready var _hit_area: Area3D = $HitArea const _ANIM_RUN: StringName = &"Run" const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" const _ANIM_DRAW: StringName = &"Draw_weapon" # Idle taunt clips — one is picked at random each time the matador pauses to # taunt while wandering, so the crowd doesn't see the same gesture every time. const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"] # Fraction of the draw clip at which the hand grabs the sword — the sword # reparents from holster to hand at this point in the animation. const _DRAW_GRAB_AT: float = 0.55 const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb") const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec # Side angles (radians) tried when the straight-ahead path is blocked. const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] # Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as # a quick, dangerous opponent rather than a sluggish one. const _ACCEL_FACTOR: float = 13.0 # velocity ramp = speed * factor * delta const _TURN_MOVE: float = 11.0 # face the travel direction while roaming const _TURN_FACE: float = 16.0 # face the bull while engaging const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction func _ready() -> void: add_to_group(&"matador") _skeleton = _find_skeleton(_mesh) _anim_player = _find_anim_player(_mesh) if _skeleton: _sim = MatadorRagdoll.build(_skeleton) if _anim_player: for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS: _ensure_loop(anim) _flatten_roll_travel() if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % str(_anim_player.get_animation_list())) _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") _anim_player.play(_ANIM_IDLE) _setup_sword() DP.any_changed.connect(_on_dp_changed) _throw_aim_cd = randf_range(2.0, 5.0) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _blood_burst = preload("res://blood_burst.gd").new() add_child(_blood_burst) if ResourceLoader.exists("res://sounds/ole.ogg"): _ole_player = AudioStreamPlayer.new() _ole_player.stream = load("res://sounds/ole.ogg") _ole_player.volume_db = 0.0 _ole_player.bus = &"Effects" add_child(_ole_player) if ResourceLoader.exists("res://sounds/matador_death.ogg"): _death_player = AudioStreamPlayer.new() _death_player.stream = load("res://sounds/matador_death.ogg") _death_player.volume_db = 2.0 _death_player.bus = &"Effects" add_child(_death_player) func _on_dp_changed(_key: String, _value: Variant) -> void: _apply_sword_grip() if _anim_player: _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") func _acquire_bull() -> void: if _bull != null: return var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _bull = players[0] as CharacterBody3D func _physics_process(delta: float) -> void: _acquire_bull() if _is_spawning and is_on_floor(): _is_spawning = false collision_layer = 1 if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO: var o := global_position + Vector3.UP * 0.6 DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9)) if not is_on_floor(): velocity += get_gravity() * delta _dodge_cd = maxf(0.0, _dodge_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) if _state != State.RAGDOLL and _bull != null: _update_ai_state(delta) match _state: State.WANDER: _tick_wander(delta) State.FLEE: _tick_flee(delta) State.BRACE: _tick_brace(delta) State.SIDESTEP: _tick_sidestep(delta) State.ATTACK: _tick_attack(delta) State.ROLL: _tick_roll(delta) State.THROW: _tick_throw(delta) State.RAGDOLL: _tick_ragdoll(delta) _update_sword_carry() _advance_draw(delta) # Utility-driven brain. The physically committed states (a brace, a pass, a roll, a # throw wind-up) run to completion on their own timers; from the free states the # matador re-scores its options every mat_decide_interval and acts on the winner. func _update_ai_state(delta: float) -> void: if _state == State.BRACE or _state == State.SIDESTEP \ or _state == State.ROLL or _state == State.THROW: return _decide_cd -= delta if _decide_cd > 0.0: return _decide_cd = DP.f("mat_decide_interval") var intent := _choose_intent() _intent = intent if not _realizes_intent(intent): _enter_intent(intent) # Score each intent from weighted heuristics and return the winner. Weights (the # mat_w_* params) shape the personality; a small commit bonus on the current intent # stops it flip-flopping between two near-tied options. func _choose_intent() -> Intent: var dist := global_position.distance_to(_bull.global_position) var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) var bull_speed := bull_flat.length() # close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures # how far past throw range the bull is (0 point-blank, 1 at/outside throw range): # the matador only idles when the bull is out there, and engages otherwise. var reach := maxf(DP.f("mat_attack_range"), 0.5) var throw_range := maxf(DP.f("mat_throw_range"), 1.0) var close := clampf(1.0 - dist / reach, 0.0, 1.0) var beyond := clampf(dist / throw_range, 0.0, 1.0) # danger blends "the bull is fast and near" with "a charge is aimed at me". var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close if _is_charge_incoming(): danger = clampf(danger + 0.7, 0.0, 1.0) # A matador won't commit to a strike it can't bail out of, so a spent dodge # cooldown makes attacking (and dodging outright) less attractive. var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35 var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") # Only the nearest few matadors work the bull at once; the rest hang back and # taunt (see _engagement_rank). This turns a crowd into a bullring — matadors # spreading out and taking turns — instead of a swarm all piling on together. var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))) # Supporting matadors strut and circle constantly; the lead only struts between # passes, once the bull is back out past horn reach. var strut := 0.65 if not is_engager else 0.3 * beyond var engage_mult := 1.0 if is_engager else 0.15 var score := { # Showmanship: circle the bull at a taunting distance and pose for the crowd. Intent.WANDER: DP.f("mat_w_showmanship") * strut, # Retreat only when the bull is close AND actually dangerous. Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger), # Press the attack; ease off a touch when a charge is barrelling in. Supporting # matadors barely press — they yield the pass to whoever's engaging. Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ * (1.0 - 0.55 * danger) * dodge_ready * engage_mult, # Sidestep / plant against an incoming charge. Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0), # Hurl the blade — an occasional ranged flourish from an engaging matador when # there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt. Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \ if (throw_ok and is_engager) else -1.0, } score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") var best: Intent = Intent.WANDER var best_score := -INF for intent: int in score: if float(score[intent]) > best_score: best_score = float(score[intent]) best = intent return best # True when the current State already carries out the intent, so we needn't restart # it (restarting ATTACK every tick would reset its swing and stutter the animation). func _realizes_intent(intent: Intent) -> bool: match intent: Intent.WANDER: return _state == State.WANDER Intent.FLEE: return _state == State.FLEE Intent.ATTACK: return _state == State.ATTACK Intent.DODGE: return _state == State.BRACE or _state == State.SIDESTEP Intent.THROW: return _state == State.THROW return false func _enter_intent(intent: Intent) -> void: match intent: Intent.WANDER: _state = State.WANDER _steer_cd = 0.0 _pick_wander_target() Intent.FLEE: _state = State.FLEE _steer_cd = 0.0 Intent.ATTACK: _steer_cd = 0.0 _start_attack() Intent.DODGE: _start_brace() Intent.THROW: _start_throw() # How many other live matadors are closer to the bull than this one. 0 = the # closest (the natural lead engager); the utility brain gives the nearest # mat_engage_slots the go-ahead to press while the rest spread out and taunt. func _engagement_rank() -> int: if _bull == null: return 0 var my_d := global_position.distance_squared_to(_bull.global_position) var rank := 0 for node: Node in get_tree().get_nodes_in_group(&"matador"): if node == self: continue var other := node as Node3D if other == null or not is_instance_valid(other) or not other.call(&"is_active"): continue if other.global_position.distance_squared_to(_bull.global_position) < my_d: rank += 1 return rank # Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot. func is_active() -> bool: return _state != State.RAGDOLL # True when this matador is safely out of the action — not one of the engaging few, # and with the bull far enough off to strike a long, unhurried taunt for the crowd. func _out_of_harm() -> bool: if _bull == null: return true if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))): return false return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6 # Bull is moving fast and aimed within ~30° of the matador func _is_charge_incoming() -> bool: var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) if bull_flat.length() < DP.f("mat_charge_speed"): return false var to_me := (global_position - _bull.global_position) to_me.y = 0.0 if to_me.length() > DP.f("mat_brace_range"): return false return bull_flat.normalized().dot(to_me.normalized()) > 0.85 # Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds. # Randomises among available clear angles so juke direction is unpredictable. func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: _steer_cd -= delta if _steer_cd > 0.0 and _steer_dir != Vector3.ZERO: return _steer_dir _steer_cd = _STEER_INTERVAL var space := get_world_3d().direct_space_state var origin := global_position + Vector3.UP * 0.6 var params := PhysicsRayQueryParameters3D.new() params.exclude = [get_rid()] params.collision_mask = 1 # Try straight ahead first; if blocked, pick randomly from the side candidates. params.from = origin params.to = origin + desired_dir * _STEER_LOOKAHEAD if space.intersect_ray(params).is_empty(): _steer_dir = desired_dir return desired_dir var candidates := _STEER_CANDIDATES.duplicate() candidates.shuffle() for angle: float in candidates: var dir := desired_dir.rotated(Vector3.UP, angle) params.from = origin params.to = origin + dir * _STEER_LOOKAHEAD if space.intersect_ray(params).is_empty(): _steer_dir = dir return dir _steer_dir = desired_dir return desired_dir # Ramp horizontal velocity toward dir*spd; vertical (gravity) is left untouched. func _accelerate(dir: Vector3, spd: float, delta: float) -> void: velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta) velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta) func _decelerate(rate: float, delta: float) -> void: velocity.x = move_toward(velocity.x, 0.0, rate * delta) velocity.z = move_toward(velocity.z, 0.0, rate * delta) # Turn the mesh to face travel direction — skipped at near-zero speed so a # stopping matador doesn't snap to atan2(0, 0) == facing +Z (a jitter source). func _face_velocity(delta: float, rate: float) -> void: if Vector2(velocity.x, velocity.z).length_squared() > 0.04: _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * rate) func _face_point(point: Vector3, delta: float, rate: float) -> void: var to := point - global_position to.y = 0.0 if to.length_squared() > 0.01: _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(to.x, to.z), delta * rate) func _tick_wander(delta: float) -> void: # Plant the feet while drawing / sheathing instead of sliding through the clip. if _drawing or _sheathing or _idle_timer > 0.0: _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) # Taunt toward the bull so the posturing plays to it, not off into space. if _bull != null and _idle_timer > 0.0: _face_point(_bull.global_position, delta, _TURN_MOVE) # Cycle gestures through a long hold so a posing matador stays lively. _taunt_swap -= delta if _taunt_swap <= 0.0 and not _drawing and not _sheathing: _idle_anim = _pick_taunt() _taunt_swap = randf_range(1.5, 3.0) _play_anim(_idle_anim) move_and_slide() return var to_target := Vector3( _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) if to_target.length() < 0.8: # A matador out of harm's way settles into a long taunt for the crowd; one still # in the mix only pauses briefly between passes. _idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \ if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _idle_anim = _pick_taunt() _taunt_swap = randf_range(1.5, 3.0) _pick_wander_target() else: var dir := _steer_clear(to_target.normalized(), delta) _accelerate(dir, DP.f("mat_walk_speed"), delta) _face_velocity(delta, _TURN_MOVE) _play_anim(_ANIM_RUN) move_and_slide() func _tick_flee(delta: float) -> void: # Plant the feet while sheathing instead of sliding away through the clip. if _drawing or _sheathing: _decelerate(18.0, delta) _face_point(_bull.global_position, delta, _TURN_FACE) move_and_slide() return var away := global_position - _bull.global_position away.y = 0.0 if away.length() < 0.01: away = Vector3(randf() - 0.5, 0.0, randf() - 0.5) var dir := _steer_clear(away.normalized(), delta) _accelerate(dir, DP.f("mat_flee_speed"), delta) _face_velocity(delta, _TURN_MOVE) _play_anim(_ANIM_RUN) move_and_slide() # Cite phase: plant feet, face the bull — wait for it to commit func _start_brace() -> void: _steer_cd = 0.0 _state = State.BRACE var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) var charge_dir: Vector3 if bull_flat.length() > 1.0: charge_dir = bull_flat.normalized() else: charge_dir = (_bull.global_position - global_position) charge_dir.y = 0.0 charge_dir = charge_dir.normalized() _will_dodge = randf() < DP.f("mat_dodge_chance") var side := 1.0 if randf() > 0.5 else -1.0 _step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side) _brace_timer = DP.f("mat_brace_duration") func _tick_brace(delta: float) -> void: _decelerate(18.0, delta) _face_point(_bull.global_position, delta, _TURN_FACE) _play_anim(_ANIM_ATTACK) move_and_slide() _brace_timer -= delta var dist := global_position.distance_to(_bull.global_position) if _will_dodge: # Dodger: wait for the bull to commit, then whip into the lateral pass. if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0: _state = State.SIDESTEP _step_timer = DP.f("mat_step_duration") if _ole_player: _ole_player.pitch_scale = randf_range(0.88, 1.12) _ole_player.play() else: # Planter (estocada): the bull only dies to an active, presented thrust, not to # brushing a braced matador — so when it commits in, break into a real attack # SWING (which lunges the blade forward and stabs), otherwise hold then press. if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()): _dodge_cd = DP.f("mat_dodge_cooldown") _start_attack() # Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps # across its path as it charges by; face the bull so the sword points at it. func _tick_sidestep(delta: float) -> void: _step_timer -= delta if _step_timer <= 0.0: _start_attack() return var dir := _step_dir var to_bull := _bull.global_position - global_position to_bull.y = 0.0 if to_bull.length() > 0.1: dir = (_step_dir + to_bull.normalized() * DP.f("mat_pass_lunge")).normalized() var spd := DP.f("mat_step_speed") velocity.x = dir.x * spd velocity.z = dir.z * spd _face_point(_bull.global_position, delta, _TURN_SHARP) _play_anim(_ANIM_ATTACK) _try_melee_hit(DP.f("mat_stab_reach")) move_and_slide() func _start_attack() -> void: _state = State.ATTACK _attack_timer = DP.f("mat_attack_duration") _swing_timer = 0.0 _dodge_cd = DP.f("mat_dodge_cooldown") func _tick_attack(delta: float) -> void: _attack_timer -= delta _swing_timer = maxf(0.0, _swing_timer - delta) # Plant the feet while drawing instead of sliding toward the bull mid-clip. if _drawing or _sheathing: _decelerate(22.0, delta) _face_point(_bull.global_position, delta, _TURN_FACE) move_and_slide() return _face_point(_bull.global_position, delta, _TURN_FACE) var to_bull := _bull.global_position - global_position to_bull.y = 0.0 var dist := to_bull.length() # Commit to a full swing once in range instead of flipping Run/Attack at the # distance boundary every frame (the old foot-slide jitter). When the swing # ends the matador re-closes if the bull slipped away, or stabs again if not. if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand: _swing_timer = _anim_length(_ANIM_ATTACK, 0.9) _lunge_timer = DP.f("mat_lunge_time") if _swing_timer > 0.0: _lunge_timer = maxf(0.0, _lunge_timer - delta) if _lunge_timer > 0.0 and dist > 0.1: _accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta) else: _decelerate(22.0, delta) _play_anim(_ANIM_ATTACK) _try_melee_hit(DP.f("mat_stab_reach")) else: var dir := _steer_clear(to_bull.normalized(), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta) _play_anim(_ANIM_RUN) move_and_slide() func _enter_roll(hit_dir: Vector3) -> void: _state = State.ROLL # Match the state to the actual clip so the roll plays through once instead of # cutting to Run partway. mat_roll_duration is only a fallback. _roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration")) hit_dir.y = 0.0 var side := 1.0 if randf() > 0.5 else -1.0 _roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side) if _anim_player and _anim_player.has_animation(_ANIM_ROLL): _anim_player.play(_ANIM_ROLL) _anim_player.seek(0.0, true) # Called by the spawner (deferred) to make the matador leap-roll toward a target # point on entry — the cinematic arena entrance. After the roll it transitions to # normal AI via the standard ROLL → FLEE path. func enter_spawn_roll(toward: Vector3) -> void: var dir := toward - global_position dir.y = 0.0 _roll_dir = dir.normalized() if dir.length() > 0.1 else -_mesh.global_transform.basis.z _state = State.ROLL _roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration")) _is_spawning = true collision_layer = 0 velocity.y = 4.0 if _anim_player and _anim_player.has_animation(_ANIM_ROLL): _anim_player.play(_ANIM_ROLL) _anim_player.seek(0.0, true) func _tick_roll(delta: float) -> void: _roll_timer -= delta if _roll_timer <= 0.0: _state = State.FLEE _dodge_cd = DP.f("mat_dodge_cooldown") return var spd := DP.f("mat_step_speed") velocity.x = _roll_dir.x * spd velocity.z = _roll_dir.z * spd _face_velocity(delta, _TURN_SHARP) _play_anim(_ANIM_ROLL) move_and_slide() func _start_throw() -> void: _state = State.THROW _throw_timer = DP.f("mat_throw_windup") velocity = Vector3.ZERO _cancel_transitions() _carry_sword(false) # the sword is melee-only — holster it for the throw _spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw if _anim_player: _anim_player.pause() var to_bull := _bull.global_position - global_position to_bull.y = 0.0 _throw_dir = to_bull.normalized() if to_bull.length() > 0.1 else _mesh.global_transform.basis.z func _tick_throw(delta: float) -> void: _decelerate(20.0, delta) _face_point(_bull.global_position, delta, _TURN_FACE) var to_bull := _bull.global_position - global_position to_bull.y = 0.0 if to_bull.length() > 0.1: _throw_dir = to_bull.normalized() var dur := maxf(DP.f("mat_throw_windup"), 0.001) var prev := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) _throw_timer -= delta var phase := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) _pose_throw_arm(phase) var t_rel := DP.f("mat_throw_release") if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node): _release_spear() if _throw_timer <= 0.0: _end_throw() move_and_slide() func _end_throw() -> void: _state = State.FLEE _clear_spear() # drop any spear still in hand if the wind-up was cut short _dodge_cd = DP.f("mat_dodge_cooldown") # Space throws well apart so a hurl stays a flourish, not a barrage. _throw_aim_cd = randf_range(4.0, 7.0) _intent = Intent.FLEE # Blend to locomotion so the manually-posed throw arm eases out (resume() snaps). if _anim_player: _anim_player.play(_ANIM_RUN) # Drive arm_R + forearm_R through a cock-back → release arc while the # AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable. func _pose_throw_arm(phase: float) -> void: if not _skeleton: return var t_rel := DP.f("mat_throw_release") var cock := DP.f("mat_throw_arm_cock") var release := DP.f("mat_throw_arm_release") var elbow := DP.f("mat_throw_elbow") var arm_ang: float var elbow_ang: float if phase < t_rel: var u := phase / maxf(t_rel, 0.001) arm_ang = lerpf(0.0, cock, u) elbow_ang = lerpf(0.0, elbow, u) else: var u := (phase - t_rel) / maxf(1.0 - t_rel, 0.001) arm_ang = lerpf(cock, release, u) elbow_ang = lerpf(elbow, 0.0, u) _set_bone_swing("arm_R", arm_ang) _set_bone_swing("forearm_R", elbow_ang) func _set_bone_swing(bone: String, deg: float) -> void: var idx := _skeleton.find_bone(bone) if idx < 0: return var rest := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion() _skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg))) # Draw a fresh spear into the throwing hand for the wind-up (no melee collider). func _spawn_spear_in_hand() -> void: if is_instance_valid(_spear_node) or _sword_hand_attach == null: return _spear_node = _SPEAR_SCENE.instantiate() as Node3D _sword_hand_attach.add_child(_spear_node) _spear_node.position = Vector3( DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z")) _spear_node.rotation_degrees = Vector3( DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z")) func _clear_spear() -> void: if is_instance_valid(_spear_node): _spear_node.queue_free() _spear_node = null # Hurl the held spear on a ballistic arc that lands on the bull's low body. The # launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the # lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit. func _release_spear() -> void: if not is_instance_valid(_spear_node): return var spear := _spear_node var origin := spear.global_position _spear_node = null var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05) # Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it # across the arc's flight time. var target := origin + _throw_dir * 5.0 if is_instance_valid(_bull): target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0) var lead := target - origin lead.y = 0.0 target += _bull.velocity * (lead.length() / hs) var flat := target - origin var rise := flat.y flat.y = 0.0 var dist := flat.length() var hdir := flat.normalized() if dist > 0.01 else _throw_dir # Aim scatter so the arc can be read and sidestepped, not a hitscan. var spread := deg_to_rad(DP.f("mat_throw_spread")) hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized() # Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t; # pick the vertical launch speed that lands the arc on the aim point's height. var t := dist / hs var vy := (rise + 0.5 * g * t * t) / t var vel := hdir * hs + Vector3.UP * vy SpearProjectile.launch(get_tree().current_scene, spear, origin, vel, maxf(DP.f("mat_throw_gravity"), 0.05)) func _tick_ragdoll(_delta: float) -> void: velocity.x = 0.0 velocity.z = 0.0 move_and_slide() # Current state as its enum name (WANDER / FLEE / …), for the debug state overlay. func ai_state_name() -> String: return State.keys()[_state] func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void: if _state == State.RAGDOLL or _state == State.ROLL: return _acquire_bull() _enter_ragdoll(hit_dir, strength, up_boost) func _on_body_entered(body: Node3D) -> void: if _state == State.RAGDOLL or _state == State.ROLL: return if not body.is_in_group(&"player"): return var player := body as CharacterBody3D if player.velocity.length() < DP.f("mat_hit_threshold"): return var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z) var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \ (global_position - player.global_position).normalized() if randf() < DP.f("mat_roll_chance"): _enter_roll(hit_dir) else: _enter_ragdoll(hit_dir, player.velocity.length()) func _on_blade_hit(body: Node3D) -> void: if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE: return if not body.is_in_group(&"player"): return if _blade_hit_cd > 0.0: return _blade_hit_cd = DP.f("mat_stab_cooldown") body.call(&"take_sword_hit", "gored") # Reach-based stab. A charging bull crosses metres per physics frame, so it easily # tunnels through the thin blade Area between frames — this distance check is the # reliable hit. Called from the offensive ticks (active swing / pass) whenever the # blade is drawn: if the bull is inside reach AND the matador is facing it — the # blade actually presented, not held off to the side — it's gored. func _try_melee_hit(reach: float) -> void: if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null: return var to_bull := _bull.global_position - global_position to_bull.y = 0.0 var dist := to_bull.length() if dist > reach or dist < 0.01: return # Only lands within the frontal arc — you're gored on a presented blade, not by # brushing a matador whose sword is pointing elsewhere. var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y)) if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))): return _blade_hit_cd = DP.f("mat_stab_cooldown") _bull.call(&"take_sword_hit", "gored") func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void: _state = State.RAGDOLL if _sword_blade_area: _sword_blade_area.monitoring = false _clear_spear() # a spear mid-wind-up dies with the matador, not in the air killed.emit() var cleanup_timer := get_tree().create_timer(4.0) cleanup_timer.timeout.connect(func() -> void: if is_instance_valid(self): queue_free() ) hit_dir.y = 0.0 hit_dir = hit_dir.normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir) if _death_player: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() if _bull != null: var cam := _bull.get_node_or_null("Camera3D") if cam: cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0)) velocity = Vector3.ZERO collision_layer = 0 if _anim_player: _anim_player.pause() if not _sim: return for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var bone_idx := _skeleton.find_bone(pb.bone_name) if bone_idx >= 0: pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx) _sim.active = true _sim.physical_bones_start_simulation() _pin_cape_to_body() var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3) var right := throw_dir.cross(Vector3.UP).normalized() for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var impulse := Vector3.ZERO match pb.bone_name: "COG", "matador": impulse = throw_dir * strength "chest": impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9 "head": impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7 "leg_L": impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "leg_R": impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "arm_L": impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "arm_R": impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "Bone", "Bone.001", "Bone.002": # Scale by mass so the light cape gets the same velocity as the body # (Δv = impulse/mass) and flies along with it instead of rocketing off. impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass _: continue var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) pb.apply_central_impulse(impulse + noise * strength * 0.2) # A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv # (impulse = mass · speed) so the joints carry every bone up together. if up_boost > 0.0: for child: Node in _sim.get_children(): if child is PhysicalBone3D: var pbb := child as PhysicalBone3D pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass) # Pin the cape's root link to the chest so it stays attached at the shoulders # while ragdolling (its bone chain is a skeleton root, so it has no joint to the # body otherwise). The chain hangs and swings from this point. func _pin_cape_to_body() -> void: var cape := _physical_bone(&"Bone") var chest := _physical_bone(&"chest") if cape == null or chest == null: return var joint := PinJoint3D.new() _sim.add_child(joint) joint.global_position = cape.global_position joint.node_a = chest.get_path() joint.node_b = cape.get_path() func _physical_bone(bone: StringName) -> PhysicalBone3D: if not _sim: return null for child: Node in _sim.get_children(): if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone: return child as PhysicalBone3D return null # ── Animation helpers ───────────────────────────────────────────────────────── func _ensure_loop(anim_name: StringName) -> void: if _anim_player.has_animation(anim_name): var anim := _anim_player.get_animation(anim_name) anim.loop_mode = Animation.LOOP_LINEAR # Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the # mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel. func _flatten_roll_travel() -> void: if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL): return var anim := _anim_player.get_animation(_ANIM_ROLL) if anim.has_meta(&"_travel_flattened"): return anim.set_meta(&"_travel_flattened", true) var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D) if cog < 0: return # Snapshot the COG travel before mutating (flattening COG first would zero it). var travel: Dictionary = {} for i in anim.get_track_count(): if anim.track_get_type(i) == Animation.TYPE_POSITION_3D: for k in anim.track_get_key_count(i): var t := anim.track_get_key_time(i, k) travel[t] = anim.position_track_interpolate(cog, t).z for i in anim.get_track_count(): if anim.track_get_type(i) == Animation.TYPE_POSITION_3D: for k in anim.track_get_key_count(i): var v: Vector3 = anim.track_get_key_value(i, k) anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)])) func _pick_taunt() -> StringName: var choices: Array[StringName] = [] for anim in _ANIM_TAUNTS: if _anim_player != null and _anim_player.has_animation(anim): choices.append(anim) if choices.is_empty(): return _ANIM_IDLE return choices[randi() % choices.size()] func _anim_length(anim_name: StringName, fallback: float) -> float: if _anim_player and _anim_player.has_animation(anim_name): return _anim_player.get_animation(anim_name).length return fallback func _play_anim(anim_name: StringName) -> void: if _anim_player == null: return if _drawing or _sheathing: return # let the draw / sheathe clip play through uninterrupted if _anim_player.current_animation == anim_name: return _anim_player.play(anim_name) func _pick_wander_target() -> void: # Circle the bull for showmanship: mostly settle on the taunt ring (spreading the # crowd out around it), with an occasional close pass for daring flair. if _bull != null: var bull_flat := _bull.global_position bull_flat.y = 0.0 var r: float if randf() < 0.2: r = randf_range(1.5, 4.0) # close daring pass else: var ring := DP.f("mat_taunt_ring") r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring var angle := randf() * TAU _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) # Keep the pose inside the fighting floor rather than backing onto the benches. var arena_r := DP.f("arena_spawn_radius") var flat := Vector2(_wander_target.x, _wander_target.z) if flat.length() > arena_r: flat = flat.normalized() * arena_r _wander_target = Vector3(flat.x, 0.0, flat.y) return var radius := DP.f("mat_wander_radius") var angle := randf() * TAU var dist := randf_range(2.0, radius) _wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist) # ── Sword attachment ────────────────────────────────────────────────────────── # The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand # grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is # carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable # local offset/rotation (sword_* / sword_rest_*) so the poses seat independently. func _setup_sword() -> void: if not _skeleton: return var has_hand := _skeleton.find_bone("weapon_bone") != -1 var has_rest := _skeleton.find_bone("weapon_rest_bone") != -1 if not has_hand and not has_rest: return if has_hand: _sword_hand_attach = BoneAttachment3D.new() _sword_hand_attach.bone_name = "weapon_bone" _skeleton.add_child(_sword_hand_attach) if has_rest: _sword_rest_attach = BoneAttachment3D.new() _sword_rest_attach.bone_name = "weapon_rest_bone" _skeleton.add_child(_sword_rest_attach) _spawn_sword() # Instantiate the matador's sword into the holster, with its own blade collider. func _spawn_sword() -> void: if is_instance_valid(_sword_node): return if _sword_rest_attach == null and _sword_hand_attach == null: return _sword_node = _SWORD_SCENE.instantiate() as Node3D # Start holstered (fall back to the hand if the rig only has one of the bones). var start_attach := _sword_rest_attach if _sword_rest_attach else _sword_hand_attach start_attach.add_child(_sword_node) _sword_in_hand = start_attach == _sword_hand_attach _drawing = false _apply_sword_grip() # Blade collider along +Z (hilt at origin), tipped from the capsule's default Y. # Only monitors while the sword is in hand (see _carry_sword). var blade_cap := CapsuleShape3D.new() blade_cap.radius = 0.18 blade_cap.height = 1.30 var blade_cs := CollisionShape3D.new() blade_cs.shape = blade_cap blade_cs.position = Vector3(0.0, 0.0, 0.8) blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) _sword_blade_area = Area3D.new() _sword_blade_area.collision_layer = 0 _sword_blade_area.collision_mask = 1 _sword_blade_area.monitoring = _sword_in_hand _sword_blade_area.add_child(blade_cs) _sword_node.add_child(_sword_blade_area) _sword_blade_area.body_entered.connect(_on_blade_hit) # The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding # (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead # (see _spawn_spear_in_hand), so THROW keeps the sword holstered. func _wants_sword_drawn() -> bool: return _state == State.ATTACK \ or _state == State.BRACE or _state == State.SIDESTEP func _update_sword_carry() -> void: if not is_instance_valid(_sword_node) or _state == State.RAGDOLL: return if _wants_sword_drawn(): if not _sword_in_hand and not _drawing: _begin_draw() elif _sword_in_hand or _drawing: # Animate the sheathe only into the unhurried locomotion states; snap for # a dodge / roll / brace where the body clip can't spare the time. if _state == State.WANDER or _state == State.FLEE: if not _sheathing: _begin_sheathe() else: _cancel_transitions() _carry_sword(false) func _begin_draw() -> void: if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): _carry_sword(true) # no draw clip on this rig — snap to hand return _sheathing = false _drawing = true _draw_timer = 0.0 _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() _anim_player.play(_ANIM_DRAW, -1.0, _draw_speed()) _anim_player.seek(0.0, true) func _begin_sheathe() -> void: if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): _carry_sword(false) # no clip — snap to holster return _drawing = false _sheathing = true _draw_timer = 0.0 _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() # Draw_weapon reversed from its end frame = a put-the-sword-away motion. _anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true) # Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse). func _advance_draw(delta: float) -> void: if not _drawing and not _sheathing: return _draw_timer += delta var t := _draw_timer / maxf(_draw_len, 0.001) if _drawing: if not _sword_in_hand and t >= _DRAW_GRAB_AT: _carry_sword(true) if t >= 1.0: _drawing = false _carry_sword(true) else: if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT: _carry_sword(false) if t >= 1.0: _sheathing = false _carry_sword(false) func _cancel_transitions() -> void: _drawing = false _sheathing = false func _draw_speed() -> float: return maxf(DP.f("mat_draw_speed"), 0.1) # Move the sword between hand grip and hip holster, reapplying the local seating. func _carry_sword(in_hand: bool) -> void: if not is_instance_valid(_sword_node): return if in_hand == _sword_in_hand: return var target := _sword_hand_attach if in_hand else _sword_rest_attach if target == null: return _sword_in_hand = in_hand _sword_node.reparent(target, false) _apply_sword_grip() if _sword_blade_area: _sword_blade_area.monitoring = in_hand func _apply_sword_grip() -> void: if not is_instance_valid(_sword_node): return if _sword_in_hand: _sword_node.position = Vector3( DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z")) _sword_node.rotation_degrees = Vector3( DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z")) else: _sword_node.position = Vector3( DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z")) _sword_node.rotation_degrees = Vector3( DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z")) # ── Utility ─────────────────────────────────────────────────────────────────── func _find_skeleton(node: Node) -> Skeleton3D: if node is Skeleton3D: return node as Skeleton3D for child: Node in node.get_children(): var r := _find_skeleton(child) if r: return r return null func _find_anim_player(node: Node) -> AnimationPlayer: if node is AnimationPlayer: return node as AnimationPlayer for child: Node in node.get_children(): var r := _find_anim_player(child) if r: return r return null