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 # RAGDOLL → hit by bull, no roll chance enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, 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 _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO var _steer_dir: Vector3 = Vector3.ZERO var _steer_cd: float = 0.0 var _ole_player: AudioStreamPlayer = null var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @onready var _mesh: Node3D = $matador2 @onready var _hit_area: Area3D = $HitArea @onready var _body_col: CollisionShape3D = $CollisionShape3D const _ANIM_RUN: StringName = &"Run" const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" # Candidate steering offsets (radians) tried in order when a wall is ahead const _STEER_ANGLES: Array = [0.0, -0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec func _ready() -> void: add_to_group(&"matador") _skeleton = _find_skeleton(_mesh) _anim_player = _find_anim_player(_mesh) if _skeleton: _sim = _setup_physical_bones() if _anim_player: for anim in [_ANIM_RUN, _ANIM_IDLE, _ANIM_ATTACK, _ANIM_ROLL]: _ensure_loop(anim) if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % str(_anim_player.get_animation_list())) _anim_player.play(_ANIM_IDLE) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _setup_blood_burst() if ResourceLoader.exists("res://sounds/ole.ogg"): _ole_player = AudioStreamPlayer.new() _ole_player.stream = load("res://sounds/ole.ogg") _ole_player.volume_db = 0.0 add_child(_ole_player) if ResourceLoader.exists("res://sounds/matador_death.ogg"): _death_player = AudioStreamPlayer.new() _death_player.stream = load("res://sounds/matador_death.ogg") _death_player.volume_db = 2.0 add_child(_death_player) func _physics_process(delta: float) -> void: if _bull == null: var players := get_tree().get_nodes_in_group(&"player") if not players.is_empty(): _bull = players[0] as CharacterBody3D if not is_on_floor(): velocity += get_gravity() * delta _dodge_cd = maxf(0.0, _dodge_cd - delta) 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.RAGDOLL: _tick_ragdoll(delta) func _update_ai_state() -> void: var dist := global_position.distance_to(_bull.global_position) 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: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] 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 func _tick_wander(delta: float) -> void: if _idle_timer > 0.0: _idle_timer -= delta velocity.x = move_toward(velocity.x, 0.0, 10.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 10.0 * delta) _play_anim(_ANIM_IDLE) move_and_slide() return var dx: float = _wander_target.x - global_position.x var dz: float = _wander_target.z - global_position.z if Vector2(dx, dz).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(Vector3(dx, 0.0, dz).normalized(), delta) var spd := DP.f("mat_walk_speed") velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) _play_anim(_ANIM_RUN) move_and_slide() func _tick_flee(delta: float) -> void: var away := (global_position - _bull.global_position) away.y = 0.0 if away.length() < 0.01: away = Vector3(randf() - 0.5, 0.0, randf() - 0.5) var dir := _steer_clear(away.normalized(), delta) var spd := DP.f("mat_flee_speed") velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) _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: velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta) var to_bull := (_bull.global_position - global_position) to_bull.y = 0.0 if to_bull.length() > 0.1: _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 14.0) _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 _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) _play_anim(_ANIM_ROLL) move_and_slide() func _start_attack() -> void: _state = State.ATTACK _attack_timer = DP.f("mat_attack_duration") _dodge_cd = DP.f("mat_dodge_cooldown") func _tick_attack(delta: float) -> void: _attack_timer -= delta var to_bull := (_bull.global_position - global_position) to_bull.y = 0.0 var dist := to_bull.length() if dist > 0.1: _mesh.rotation.y = lerp_angle( _mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 10.0) if dist > DP.f("mat_attack_min_dist"): var dir := _steer_clear(to_bull.normalized(), delta) var spd := DP.f("mat_attack_speed") velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) else: velocity.x = move_toward(velocity.x, 0.0, 14.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 14.0 * delta) _play_anim(_ANIM_ATTACK) move_and_slide() func _enter_roll(hit_dir: Vector3) -> void: _state = State.ROLL _roll_timer = 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) 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 _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) _play_anim(_ANIM_ROLL) move_and_slide() func _tick_ragdoll(_delta: float) -> void: velocity.x = 0.0 velocity.z = 0.0 move_and_slide() 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 _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: _state = State.RAGDOLL killed.emit() var cleanup_timer := get_tree().create_timer(4.0) cleanup_timer.timeout.connect(func() -> void: if is_instance_valid(self): queue_free() ) hit_dir.y = 0.0 hit_dir = hit_dir.normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() _spawn_blood_burst(hit_dir) if _death_player: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() var cam := _bull.get_node_or_null("Camera3D") if cam: cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0)) velocity = Vector3.ZERO collision_layer = 0 if _anim_player: _anim_player.pause() if not _sim: return for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var bone_idx := _skeleton.find_bone(pb.bone_name) if bone_idx >= 0: pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx) _sim.active = true _sim.physical_bones_start_simulation() var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3) var right := throw_dir.cross(Vector3.UP).normalized() for child: Node in _sim.get_children(): if not (child is PhysicalBone3D): continue var pb := child as PhysicalBone3D var impulse := Vector3.ZERO match pb.bone_name: "COG", "matador": impulse = throw_dir * strength "chest": impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9 "head": impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7 "leg_L": impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "leg_R": impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6 "arm_L": impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "arm_R": impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 _: continue var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) pb.apply_central_impulse(impulse + noise * strength * 0.2) # ── Blood burst ─────────────────────────────────────────────────────────────── func _setup_blood_burst() -> void: var mat := StandardMaterial3D.new() mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED var sphere := SphereMesh.new() sphere.radius = 0.07 sphere.height = 0.14 sphere.radial_segments = 4 sphere.rings = 2 sphere.material = mat var ramp := Gradient.new() ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0)) ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0)) var scale_curve := Curve.new() scale_curve.add_point(Vector2(0.0, 1.0)) scale_curve.add_point(Vector2(0.6, 0.5)) scale_curve.add_point(Vector2(1.0, 0.0)) _blood_burst = CPUParticles3D.new() _blood_burst.mesh = sphere _blood_burst.color_ramp = ramp _blood_burst.scale_amount_curve = scale_curve _blood_burst.one_shot = true _blood_burst.explosiveness = 0.9 _blood_burst.amount = 25 _blood_burst.lifetime = 1.1 _blood_burst.randomness = 0.4 _blood_burst.local_coords = false _blood_burst.spread = 80.0 _blood_burst.gravity = Vector3(0.0, -7.0, 0.0) _blood_burst.initial_velocity_min = 4.0 _blood_burst.initial_velocity_max = 11.0 _blood_burst.scale_amount_min = 0.5 _blood_burst.scale_amount_max = 1.6 _blood_burst.emitting = false add_child(_blood_burst) func _spawn_blood_burst(hit_dir: Vector3) -> void: _blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0) _blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized() _blood_burst.restart() # ── Animation helpers ───────────────────────────────────────────────────────── func _ensure_loop(anim_name: StringName) -> void: if _anim_player.has_animation(anim_name): var anim := _anim_player.get_animation(anim_name) anim.loop_mode = Animation.LOOP_LINEAR func _play_anim(anim_name: StringName) -> void: if _anim_player == null: return 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) # ── Ragdoll setup ───────────────────────────────────────────────────────────── func _setup_physical_bones() -> PhysicalBoneSimulator3D: for child: Node in _skeleton.get_children(): if child is PhysicalBoneSimulator3D: (child as PhysicalBoneSimulator3D).active = false return child as PhysicalBoneSimulator3D var sim := PhysicalBoneSimulator3D.new() sim.active = false _skeleton.add_child(sim) const BONES: Array = [ ["matador", 0.18], ["COG", 0.12], ["chest", 0.13, 0.30], ["head", 0.14], ["collarbone_L", 0.06, 0.18], ["collarbone_R", 0.06, 0.18], ["arm_L", 0.07, 0.22], ["arm_R", 0.07, 0.22], ["forearm_L", 0.06, 0.22], ["forearm_R", 0.06, 0.22], ["hand_L", 0.05, 0.12], ["hand_R", 0.05, 0.12], ["leg_L", 0.10, 0.32], ["leg_R", 0.10, 0.32], ["shin_L", 0.08, 0.28], ["shin_R", 0.08, 0.28], ["foot_L", 0.07, 0.18], ["foot_R", 0.07, 0.18], ] for entry: Array in BONES: var bname: String = entry[0] if _skeleton.find_bone(bname) == -1: continue var pb := PhysicalBone3D.new() pb.bone_name = bname pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN pb.mass = 0.4 pb.linear_damp = 0.1 pb.angular_damp = 0.2 pb.collision_layer = 1 pb.collision_mask = 1 var cs := CollisionShape3D.new() var use_capsule: bool = entry.size() >= 3 if use_capsule: var cap := CapsuleShape3D.new() cap.radius = entry[1] cap.height = entry[2] cs.shape = cap else: var sph := SphereShape3D.new() sph.radius = entry[1] cs.shape = sph pb.add_child(cs) sim.add_child(pb) return sim # ── Utility ─────────────────────────────────────────────────────────────────── func _find_skeleton(node: Node) -> Skeleton3D: if node is Skeleton3D: return node as Skeleton3D for child: Node in node.get_children(): var r := _find_skeleton(child) if r: return r return null func _find_anim_player(node: Node) -> AnimationPlayer: if node is AnimationPlayer: return node as AnimationPlayer for child: Node in node.get_children(): var r := _find_anim_player(child) if r: return r return null