extends CharacterBody3D # WANDER → bull outside flee range, roaming freely # FLEE → bull within range, matador backs away (raycast-steered around walls) # BRACE → charge detected: plant feet, face the bull (cite phase) # SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase) # ATTACK → post-dodge or mid-range: chase and strike # ROLL → hit by bull but roll chance fires: survive and recover # THROW → planted, winding up an overhand throw, then releasing the sword # RAGDOLL → hit by bull, no roll chance enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } signal killed var _state: State = State.WANDER var _skeleton: Skeleton3D = null var _sim: PhysicalBoneSimulator3D = null var _anim_player: AnimationPlayer = null var _wander_target: Vector3 = Vector3.ZERO var _idle_timer: float = 0.0 var _bull: CharacterBody3D = null var _step_dir: Vector3 = Vector3.ZERO var _brace_timer: float = 0.0 var _step_timer: float = 0.0 var _dodge_cd: float = 0.0 var _will_dodge: bool = true var _attack_timer: float = 0.0 var _swing_timer: float = 0.0 var _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _throw_timer: float = 0.0 var _throw_aim_cd: float = 0.0 var _throw_dir: Vector3 = Vector3.ZERO var _steer_dir: Vector3 = Vector3.ZERO var _steer_cd: float = 0.0 var _ole_player: AudioStreamPlayer = null var _sword_hand_attach: BoneAttachment3D = null var _sword_rest_attach: BoneAttachment3D = null var _sword_node: Node3D = null var _sword_in_hand: bool = false var _drawing: bool = false var _sheathing: bool = false var _draw_timer: float = 0.0 var _draw_len: float = 0.0 var _resword_timer: float = 0.0 var _sword_blade_area: Area3D = null var _blade_hit_cd: float = 0.0 var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @onready var _mesh: Node3D = $matador2 @onready var _hit_area: Area3D = $HitArea const _ANIM_RUN: StringName = &"Run" const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" const _ANIM_DRAW: StringName = &"Draw_weapon" # Fraction of the draw clip at which the hand grabs the sword — the sword # reparents from holster to hand at this point in the animation. const _DRAW_GRAB_AT: float = 0.55 const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec # Side angles (radians) tried when the straight-ahead path is blocked. const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] # Movement tuning const _ACCEL_FACTOR: float = 8.0 # velocity ramp = speed * factor * delta const _TURN_MOVE: float = 8.0 # face the travel direction while roaming const _TURN_FACE: float = 12.0 # face the bull while engaging const _TURN_SHARP: float = 20.0 # snap onto a sidestep / roll direction func _ready() -> void: add_to_group(&"matador") _skeleton = _find_skeleton(_mesh) _anim_player = _find_anim_player(_mesh) if _skeleton: _sim = MatadorRagdoll.build(_skeleton) if _anim_player: for anim in [_ANIM_RUN, _ANIM_IDLE, _ANIM_ATTACK, _ANIM_ROLL]: _ensure_loop(anim) if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % str(_anim_player.get_animation_list())) _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") _anim_player.play(_ANIM_IDLE) _setup_sword() DP.any_changed.connect(_on_dp_changed) _throw_aim_cd = randf_range(0.8, 2.5) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _blood_burst = preload("res://blood_burst.gd").new() add_child(_blood_burst) if ResourceLoader.exists("res://sounds/ole.ogg"): _ole_player = AudioStreamPlayer.new() _ole_player.stream = load("res://sounds/ole.ogg") _ole_player.volume_db = 0.0 add_child(_ole_player) if ResourceLoader.exists("res://sounds/matador_death.ogg"): _death_player = AudioStreamPlayer.new() _death_player.stream = load("res://sounds/matador_death.ogg") _death_player.volume_db = 2.0 add_child(_death_player) func _on_dp_changed(_key: String, _value: Variant) -> void: _apply_sword_grip() if _anim_player: _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") func _acquire_bull() -> void: if _bull != null: return var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _bull = players[0] as CharacterBody3D func _physics_process(delta: float) -> void: _acquire_bull() if not is_on_floor(): velocity += get_gravity() * delta _dodge_cd = maxf(0.0, _dodge_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) # Pull a replacement sword from the holster a beat after throwing the last one. if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \ and (_sword_rest_attach != null or _sword_hand_attach != null): _resword_timer -= delta if _resword_timer <= 0.0: _spawn_sword() if _state != State.RAGDOLL and _bull != null: _update_ai_state() match _state: State.WANDER: _tick_wander(delta) State.FLEE: _tick_flee(delta) State.BRACE: _tick_brace(delta) State.SIDESTEP: _tick_sidestep(delta) State.ATTACK: _tick_attack(delta) State.ROLL: _tick_roll(delta) State.THROW: _tick_throw(delta) State.RAGDOLL: _tick_ragdoll(delta) _update_sword_carry() _advance_draw(delta) func _update_ai_state() -> void: if _state == State.THROW: return var dist := global_position.distance_to(_bull.global_position) if (_state == State.WANDER or _state == State.FLEE or _state == State.ATTACK) \ and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range"): # Roll for a throw; on a miss, wait before becoming eligible again so the # matador commits to chasing for a melee Attack instead of throwing early. if randf() < DP.f("mat_throw_chance"): _start_throw() return _throw_aim_cd = randf_range(2.5, 5.0) match _state: State.WANDER: if dist < DP.f("mat_flee_range"): _state = State.FLEE elif _dodge_cd <= 0.0 and dist < DP.f("mat_attack_range") and not _is_charge_incoming(): _steer_cd = 0.0 _start_attack() State.FLEE: if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()): _start_brace() elif dist > DP.f("mat_flee_range") * 1.3: _state = State.WANDER _steer_cd = 0.0 _pick_wander_target() State.BRACE: pass # brace timer drives transition State.SIDESTEP: pass # step timer drives transition State.ATTACK: if _attack_timer <= 0.0 or dist > DP.f("mat_attack_range") * 1.5: _state = State.FLEE State.ROLL: pass # roll timer drives transition # Bull is moving fast and aimed within ~30° of the matador func _is_charge_incoming() -> bool: var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) if bull_flat.length() < DP.f("mat_charge_speed"): return false var to_me := (global_position - _bull.global_position) to_me.y = 0.0 if to_me.length() > DP.f("mat_brace_range"): return false return bull_flat.normalized().dot(to_me.normalized()) > 0.85 # Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds. # Randomises among available clear angles so juke direction is unpredictable. func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: _steer_cd -= delta if _steer_cd > 0.0 and _steer_dir != Vector3.ZERO: return _steer_dir _steer_cd = _STEER_INTERVAL var space := get_world_3d().direct_space_state var origin := global_position + Vector3.UP * 0.6 var params := PhysicsRayQueryParameters3D.new() params.exclude = [get_rid()] params.collision_mask = 1 # Try straight ahead first; if blocked, pick randomly from the side candidates. params.from = origin params.to = origin + desired_dir * _STEER_LOOKAHEAD if space.intersect_ray(params).is_empty(): _steer_dir = desired_dir return desired_dir var candidates := _STEER_CANDIDATES.duplicate() candidates.shuffle() for angle: float in candidates: var dir := desired_dir.rotated(Vector3.UP, angle) params.from = origin params.to = origin + dir * _STEER_LOOKAHEAD if space.intersect_ray(params).is_empty(): _steer_dir = dir return dir _steer_dir = desired_dir return desired_dir # Ramp horizontal velocity toward dir*spd; vertical (gravity) is left untouched. func _accelerate(dir: Vector3, spd: float, delta: float) -> void: velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta) velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta) func _decelerate(rate: float, delta: float) -> void: velocity.x = move_toward(velocity.x, 0.0, rate * delta) velocity.z = move_toward(velocity.z, 0.0, rate * delta) # Turn the mesh to face travel direction — skipped at near-zero speed so a # stopping matador doesn't snap to atan2(0, 0) == facing +Z (a jitter source). func _face_velocity(delta: float, rate: float) -> void: if Vector2(velocity.x, velocity.z).length_squared() > 0.04: _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * rate) func _face_point(point: Vector3, delta: float, rate: float) -> void: var to := point - global_position to.y = 0.0 if to.length_squared() > 0.01: _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(to.x, to.z), delta * rate) func _tick_wander(delta: float) -> void: # Plant the feet while drawing / sheathing instead of sliding through the clip. if _drawing or _sheathing or _idle_timer > 0.0: _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) _play_anim(_ANIM_IDLE) move_and_slide() return var to_target := Vector3( _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) if to_target.length() < 0.8: _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _pick_wander_target() else: var dir := _steer_clear(to_target.normalized(), delta) _accelerate(dir, DP.f("mat_walk_speed"), delta) _face_velocity(delta, _TURN_MOVE) _play_anim(_ANIM_RUN) move_and_slide() func _tick_flee(delta: float) -> void: # 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 dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0: if _will_dodge: _state = State.SIDESTEP _step_timer = DP.f("mat_step_duration") if _ole_player: _ole_player.pitch_scale = randf_range(0.88, 1.12) _ole_player.play() else: _state = State.FLEE _dodge_cd = DP.f("mat_dodge_cooldown") # Pase phase: sharp lateral step, then immediately exploit with an attack func _tick_sidestep(delta: float) -> void: _step_timer -= delta if _step_timer <= 0.0: _start_attack() return var spd := DP.f("mat_step_speed") velocity.x = _step_dir.x * spd velocity.z = _step_dir.z * spd _face_velocity(delta, _TURN_SHARP) _play_anim(_ANIM_ROLL) move_and_slide() func _start_attack() -> void: _state = State.ATTACK _attack_timer = DP.f("mat_attack_duration") _swing_timer = 0.0 _dodge_cd = DP.f("mat_dodge_cooldown") func _tick_attack(delta: float) -> void: _attack_timer -= delta _swing_timer = maxf(0.0, _swing_timer - delta) # 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) if _swing_timer > 0.0: _decelerate(22.0, delta) _play_anim(_ANIM_ATTACK) else: var dir := _steer_clear(to_bull.normalized(), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta) _play_anim(_ANIM_RUN) move_and_slide() func _enter_roll(hit_dir: Vector3) -> void: _state = State.ROLL # Match the state to the actual clip so the roll plays through once instead of # cutting to Run partway. mat_roll_duration is only a fallback. _roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration")) hit_dir.y = 0.0 var side := 1.0 if randf() > 0.5 else -1.0 _roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side) if _anim_player and _anim_player.has_animation(_ANIM_ROLL): _anim_player.play(_ANIM_ROLL) _anim_player.seek(0.0, true) func _tick_roll(delta: float) -> void: _roll_timer -= delta if _roll_timer <= 0.0: _state = State.FLEE _dodge_cd = DP.f("mat_dodge_cooldown") return var spd := DP.f("mat_step_speed") velocity.x = _roll_dir.x * spd velocity.z = _roll_dir.z * spd _face_velocity(delta, _TURN_SHARP) _play_anim(_ANIM_ROLL) move_and_slide() func _start_throw() -> void: _state = State.THROW _throw_timer = DP.f("mat_throw_windup") velocity = Vector3.ZERO _cancel_transitions() _carry_sword(true) # blade must be in hand for the wind-up and release if _anim_player: _anim_player.pause() var to_bull := _bull.global_position - global_position to_bull.y = 0.0 _throw_dir = to_bull.normalized() if to_bull.length() > 0.1 else _mesh.global_transform.basis.z func _tick_throw(delta: float) -> void: _decelerate(20.0, delta) _face_point(_bull.global_position, delta, _TURN_FACE) var to_bull := _bull.global_position - global_position to_bull.y = 0.0 if to_bull.length() > 0.1: _throw_dir = to_bull.normalized() var dur := maxf(DP.f("mat_throw_windup"), 0.001) var prev := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) _throw_timer -= delta var phase := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) _pose_throw_arm(phase) var t_rel := DP.f("mat_throw_release") if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node): _release_sword() if _throw_timer <= 0.0: _end_throw() move_and_slide() func _end_throw() -> void: _state = State.FLEE _dodge_cd = DP.f("mat_dodge_cooldown") # Blend straight into locomotion so the manually-posed throw arm eases out # (a bare resume() would snap and leave the player on whatever it paused on). if _anim_player: _anim_player.play(_ANIM_RUN) # Drive arm_R + forearm_R through a cock-back → release arc while the # AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md rig notes); # angles are DP-tunable so the arc can be dialled in per rig. func _pose_throw_arm(phase: float) -> void: if not _skeleton: return var t_rel := DP.f("mat_throw_release") var cock := DP.f("mat_throw_arm_cock") var release := DP.f("mat_throw_arm_release") var elbow := DP.f("mat_throw_elbow") var arm_ang: float var elbow_ang: float if phase < t_rel: var u := phase / maxf(t_rel, 0.001) arm_ang = lerpf(0.0, cock, u) elbow_ang = lerpf(0.0, elbow, u) else: var u := (phase - t_rel) / maxf(1.0 - t_rel, 0.001) arm_ang = lerpf(cock, release, u) elbow_ang = lerpf(elbow, 0.0, u) _set_bone_swing("arm_R", arm_ang) _set_bone_swing("forearm_R", elbow_ang) func _set_bone_swing(bone: String, deg: float) -> void: var idx := _skeleton.find_bone(bone) if idx < 0: return var rest := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion() _skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg))) # Detach the sword from the hand and launch it as a spinning physics projectile # that damages the bull on contact, then settles and despawns. func _release_sword() -> void: if not is_instance_valid(_sword_node): return var sword := _sword_node var blade := _sword_blade_area var gx := sword.global_transform _sword_node = null _sword_blade_area = null _sword_in_hand = false _resword_timer = DP.f("mat_resword_delay") # The RigidBody owns collisions now — silence the melee blade Area it carries. if is_instance_valid(blade): blade.monitoring = false var rb := RigidBody3D.new() rb.collision_layer = 0 rb.collision_mask = 1 rb.contact_monitor = true rb.max_contacts_reported = 4 get_tree().current_scene.add_child(rb) rb.global_transform = gx sword.reparent(rb, true) # Physics collider along the blade (GLB +Z), which equals rb-local +Z because # the mesh kept its global transform and rb adopted it. var cap := CapsuleShape3D.new() cap.radius = 0.06 cap.height = 1.4 var cs := CollisionShape3D.new() cs.shape = cap cs.position = Vector3(0.0, 0.0, 0.7) cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) rb.add_child(cs) var dir := (_throw_dir + Vector3.UP * 0.12).normalized() rb.linear_velocity = dir * DP.f("mat_throw_speed") rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") var hit_done := [false] rb.body_entered.connect(func(body: Node) -> void: if hit_done[0]: return if body.is_in_group(&"player"): hit_done[0] = true body.call(&"take_sword_hit") ) var cleanup := get_tree().create_timer(6.0) cleanup.timeout.connect(func() -> void: if is_instance_valid(rb): rb.queue_free() ) func _tick_ragdoll(_delta: float) -> void: velocity.x = 0.0 velocity.z = 0.0 move_and_slide() func apply_ability_hit(hit_dir: Vector3, strength: float, 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.RAGDOLL or _state != State.ATTACK: return if not body.is_in_group(&"player"): return if _blade_hit_cd > 0.0: return _blade_hit_cd = 0.5 body.call(&"take_sword_hit") func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void: _state = State.RAGDOLL if _sword_blade_area: _sword_blade_area.monitoring = false killed.emit() var cleanup_timer := get_tree().create_timer(4.0) cleanup_timer.timeout.connect(func() -> void: if is_instance_valid(self): queue_free() ) hit_dir.y = 0.0 hit_dir = hit_dir.normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir) if _death_player: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() if _bull != null: var cam := _bull.get_node_or_null("Camera3D") if cam: cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0)) velocity = Vector3.ZERO collision_layer = 0 if _anim_player: _anim_player.pause() if not _sim: return for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var bone_idx := _skeleton.find_bone(pb.bone_name) if bone_idx >= 0: pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx) _sim.active = true _sim.physical_bones_start_simulation() _pin_cape_to_body() var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3) var right := throw_dir.cross(Vector3.UP).normalized() for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var impulse := Vector3.ZERO match pb.bone_name: "COG", "matador": impulse = throw_dir * strength "chest": impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9 "head": impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7 "leg_L": impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "leg_R": impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "arm_L": impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "arm_R": impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "Bone", "Bone.001", "Bone.002": # Scale by mass so the light cape gets the same velocity as the body # (Δv = impulse/mass) and flies along with it instead of rocketing off. impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass _: continue var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) pb.apply_central_impulse(impulse + noise * strength * 0.2) # A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv # (impulse = mass · speed) so the joints carry every bone up together. if up_boost > 0.0: for child: Node in _sim.get_children(): if child is PhysicalBone3D: var pbb := child as PhysicalBone3D pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass) # Pin the cape's root link to the chest so it stays attached at the shoulders # while ragdolling (its bone chain is a skeleton root, so it has no joint to the # body otherwise). The chain hangs and swings from this point. func _pin_cape_to_body() -> void: var cape := _physical_bone(&"Bone") var chest := _physical_bone(&"chest") if cape == null or chest == null: return var joint := PinJoint3D.new() _sim.add_child(joint) joint.global_position = cape.global_position joint.node_a = chest.get_path() joint.node_b = cape.get_path() func _physical_bone(bone: StringName) -> PhysicalBone3D: if not _sim: return null for child: Node in _sim.get_children(): if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone: return child as PhysicalBone3D return null # ── Animation helpers ───────────────────────────────────────────────────────── func _ensure_loop(anim_name: StringName) -> void: if _anim_player.has_animation(anim_name): var anim := _anim_player.get_animation(anim_name) anim.loop_mode = Animation.LOOP_LINEAR func _anim_length(anim_name: StringName, fallback: float) -> float: if _anim_player and _anim_player.has_animation(anim_name): return _anim_player.get_animation(anim_name).length return fallback func _play_anim(anim_name: StringName) -> void: if _anim_player == null: return if _drawing or _sheathing: return # let the draw / sheathe clip play through uninterrupted if _anim_player.current_animation == anim_name: return _anim_player.play(anim_name) func _pick_wander_target() -> void: # 35% chance: pick a point orbiting close to the bull for a daring pass if _bull != null and randf() < 0.35: var angle := randf() * TAU var r := randf_range(1.5, 4.0) var bull_flat := _bull.global_position bull_flat.y = 0.0 _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) return var radius := DP.f("mat_wander_radius") var angle := randf() * TAU var dist := randf_range(2.0, radius) _wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist) # ── Sword attachment ────────────────────────────────────────────────────────── # The rig (matador_v03) carries two purpose-built bones for the sword: # weapon_bone — the grip pose in the right hand (drawn / fighting) # weapon_rest_bone — the holstered pose at the hip (sheathed / wandering) # The sword lives in the holster by default and is carried to the hand while the # matador is in a combat state (see _carry_sword). Each bone has its own # DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the # holster) so the two poses can be seated independently without re-exporting. func _setup_sword() -> void: if not _skeleton: return var has_hand := _skeleton.find_bone("weapon_bone") != -1 var has_rest := _skeleton.find_bone("weapon_rest_bone") != -1 if not has_hand and not has_rest: return if has_hand: _sword_hand_attach = BoneAttachment3D.new() _sword_hand_attach.bone_name = "weapon_bone" _skeleton.add_child(_sword_hand_attach) if has_rest: _sword_rest_attach = BoneAttachment3D.new() _sword_rest_attach.bone_name = "weapon_rest_bone" _skeleton.add_child(_sword_rest_attach) _spawn_sword() # Instantiate a fresh sword into the holster, with its own blade collider. Called # at spawn and again after a throw, so a matador can keep drawing replacements. func _spawn_sword() -> void: if is_instance_valid(_sword_node): return if _sword_rest_attach == null and _sword_hand_attach == null: return _sword_node = _SWORD_SCENE.instantiate() as Node3D # Start holstered (fall back to the hand if the rig only has one of the bones). var start_attach := _sword_rest_attach if _sword_rest_attach else _sword_hand_attach start_attach.add_child(_sword_node) _sword_in_hand = start_attach == _sword_hand_attach _drawing = false _apply_sword_grip() # Blade collider spans the steel (hilt at local origin, blade running +Z out # to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword). var blade_cap := CapsuleShape3D.new() blade_cap.radius = 0.10 blade_cap.height = 1.10 var blade_cs := CollisionShape3D.new() blade_cs.shape = blade_cap blade_cs.position = Vector3(0.0, 0.0, 0.8) # CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade. blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) _sword_blade_area = Area3D.new() _sword_blade_area.collision_layer = 0 _sword_blade_area.collision_mask = 1 _sword_blade_area.monitoring = _sword_in_hand _sword_blade_area.add_child(blade_cs) _sword_node.add_child(_sword_blade_area) _sword_blade_area.body_entered.connect(_on_blade_hit) # The sword is carried by ACTION, not distance: drawn only while striking # (ATTACK / THROW) and holstered while running, dodging, bracing or taunting. # THROW snaps the blade out (its arm cock-back covers the motion); everything # else animates with the Draw_weapon clip — forward to draw, reversed to sheathe. func _wants_sword_drawn() -> bool: return _state == State.ATTACK or _state == State.THROW func _update_sword_carry() -> void: if not is_instance_valid(_sword_node) or _state == State.RAGDOLL: return if _wants_sword_drawn(): if not _sword_in_hand and not _drawing: _begin_draw() elif _sword_in_hand or _drawing: # Animate the sheathe only into the unhurried locomotion states; snap for # a dodge / roll / brace where the body clip can't spare the time. if _state == State.WANDER or _state == State.FLEE: if not _sheathing: _begin_sheathe() else: _cancel_transitions() _carry_sword(false) func _begin_draw() -> void: if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): _carry_sword(true) # no draw clip on this rig — snap to hand return _sheathing = false _drawing = true _draw_timer = 0.0 _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() _anim_player.play(_ANIM_DRAW, -1.0, _draw_speed()) _anim_player.seek(0.0, true) func _begin_sheathe() -> void: if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): _carry_sword(false) # no clip — snap to holster return _drawing = false _sheathing = true _draw_timer = 0.0 _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() # Draw_weapon reversed from its end frame = a put-the-sword-away motion. _anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true) # Progress a draw or sheathe; the blade reparents at the grab point (mirrored for # the reverse sheathe), and the transition ends when the clip's runtime elapses. func _advance_draw(delta: float) -> void: if not _drawing and not _sheathing: return _draw_timer += delta var t := _draw_timer / maxf(_draw_len, 0.001) if _drawing: if not _sword_in_hand and t >= _DRAW_GRAB_AT: _carry_sword(true) if t >= 1.0: _drawing = false _carry_sword(true) else: if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT: _carry_sword(false) if t >= 1.0: _sheathing = false _carry_sword(false) func _cancel_transitions() -> void: _drawing = false _sheathing = false func _draw_speed() -> float: return maxf(DP.f("mat_draw_speed"), 0.1) # Move the sword between the hand grip and the hip holster. Reparenting follows # whichever bone the BoneAttachment tracks; the local grip transform is reapplied # so the seating is identical in both sockets. func _carry_sword(in_hand: bool) -> void: if not is_instance_valid(_sword_node): return if in_hand == _sword_in_hand: return var target := _sword_hand_attach if in_hand else _sword_rest_attach if target == null: return _sword_in_hand = in_hand _sword_node.reparent(target, false) _apply_sword_grip() if _sword_blade_area: _sword_blade_area.monitoring = in_hand func _apply_sword_grip() -> void: if not is_instance_valid(_sword_node): return if _sword_in_hand: _sword_node.position = Vector3( DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z")) _sword_node.rotation_degrees = Vector3( DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z")) else: _sword_node.position = Vector3( DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z")) _sword_node.rotation_degrees = Vector3( DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z")) # ── Utility ─────────────────────────────────────────────────────────────────── func _find_skeleton(node: Node) -> Skeleton3D: if node is Skeleton3D: return node as Skeleton3D for child: Node in node.get_children(): var r := _find_skeleton(child) if r: return r return null func _find_anim_player(node: Node) -> AnimationPlayer: if node is AnimationPlayer: return node as AnimationPlayer for child: Node in node.get_children(): var r := _find_anim_player(child) if r: return r return null