smarter AI

This commit is contained in:
2026-06-07 17:30:58 +03:00
parent 033d26ee18
commit f6355605cd
3 changed files with 145 additions and 212 deletions
+136 -39
View File
@@ -1,11 +1,13 @@
extends CharacterBody3D
# WANDER → bull outside flee range, roaming freely
# FLEE → bull within range, matador backs away
# 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)
# RAGDOLL → hit by bull at speed
enum State { WANDER, FLEE, BRACE, SIDESTEP, RAGDOLL }
# 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
@@ -21,8 +23,13 @@ var _brace_timer: float = 0.0
var _step_timer: float = 0.0
var _dodge_cd: float = 0.0
var _will_dodge: bool = true
var _ole_player: AudioStreamPlayer = null
var _death_player: AudioStreamPlayer = null
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
@@ -34,6 +41,11 @@ 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")
@@ -82,6 +94,8 @@ func _physics_process(delta: float) -> void:
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)
@@ -91,16 +105,25 @@ func _update_ai_state() -> void:
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
@@ -115,6 +138,37 @@ func _is_charge_incoming() -> bool:
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
@@ -130,10 +184,10 @@ func _tick_wander(delta: float) -> void:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_pick_wander_target()
else:
var dir := Vector3(dx, 0.0, dz).normalized()
var dir := _steer_clear(Vector3(dx, 0.0, dz).normalized(), delta)
var spd := DP.f("mat_walk_speed")
velocity.x = dir.x * spd
velocity.z = dir.z * spd
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)
@@ -145,10 +199,10 @@ func _tick_flee(delta: float) -> void:
away.y = 0.0
if away.length() < 0.01:
away = Vector3(randf() - 0.5, 0.0, randf() - 0.5)
away = away.normalized()
var dir := _steer_clear(away.normalized(), delta)
var spd := DP.f("mat_flee_speed")
velocity.x = away.x * spd
velocity.z = away.z * spd
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()
@@ -156,9 +210,9 @@ func _tick_flee(delta: float) -> void:
# 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)
# Use bull velocity if it's moving, else use bull→matador direction as proxy
var charge_dir: Vector3
if bull_flat.length() > 1.0:
charge_dir = bull_flat.normalized()
@@ -166,7 +220,6 @@ func _start_brace() -> void:
charge_dir = (_bull.global_position - global_position)
charge_dir.y = 0.0
charge_dir = charge_dir.normalized()
# Decide whether to dodge at all and which side — fixed before the bull arrives
_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)
@@ -174,10 +227,8 @@ func _start_brace() -> void:
func _tick_brace(delta: float) -> void:
# Decelerate to a stop
velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta)
# Face the bull
var to_bull := (_bull.global_position - global_position)
to_bull.y = 0.0
if to_bull.length() > 0.1:
@@ -188,7 +239,6 @@ func _tick_brace(delta: float) -> void:
_brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position)
# Execute the step when the bull is almost on top or the brace window expires
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge:
_state = State.SIDESTEP
@@ -197,22 +247,70 @@ func _tick_brace(delta: float) -> void:
_ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play()
else:
# Stand and eat it — no juke
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Pase phase: sharp lateral step as the bull commits to its line
# 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:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
_start_attack()
return
var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd
velocity.z = _step_dir.z * spd
# Snap facing quickly to sell the movement
_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()
@@ -225,7 +323,7 @@ func _tick_ragdoll(_delta: float) -> void:
func _on_body_entered(body: Node3D) -> void:
if _state == State.RAGDOLL:
if _state == State.RAGDOLL or _state == State.ROLL:
return
if not body.is_in_group(&"player"):
return
@@ -235,20 +333,23 @@ func _on_body_entered(body: Node3D) -> void:
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()
_enter_ragdoll(hit_dir, player.velocity.length())
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 _t := get_tree().create_timer(4.0)
_t.timeout.connect(func() -> void:
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()
# Arc upward so the body lifts before falling — feels like a real goring throw
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_spawn_blood_burst(hit_dir)
@@ -260,22 +361,14 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
if cam:
cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0))
# Keep the CharacterBody3D stationary: physics bones simulate relative to the
# skeleton root, so sliding the root makes every bone pose diverge.
velocity = Vector3.ZERO
# Remove from collision layer so the bull passes through, but keep the
# shape enabled — move_and_slide() still needs it to detect the floor.
collision_layer = 0
if _anim_player:
_anim_player.pause() # pause keeps current frame; stop() resets to T-pose
_anim_player.pause()
if not _sim:
return
# Teleport each physics body to its current bone world transform, then
# enable per-bone simulation. _sim.active alone does NOT set simulate_physics;
# without it _process_modification() skips every bone and writes nothing.
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
@@ -294,7 +387,6 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
var impulse := Vector3.ZERO
match pb.bone_name:
"COG", "matador":
# Core of mass takes the full hit
impulse = throw_dir * strength
"chest":
impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9
@@ -309,10 +401,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
"arm_R":
impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
_:
# Distal bones (forearms, shins, hands, feet) get no direct impulse;
# they trail naturally from the joints above them
continue
# Small random noise per bone so no two look identical
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)
@@ -385,6 +474,14 @@ func _play_anim(anim_name: StringName) -> void:
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)