Godot Version
v4.5.stable
Question
I’m making a 2.5D shooter in Godot where enemies rotate toward the player. The game is fully 3D, but I only want the enemy to rotate horizontally on the Y axis.
The problem is that the enemy rotates correctly from some directions, especially when the player is behind or to the side, but when the player moves in front of the enemy, the rotation suddenly flips by 180 degrees. It looks like the enemy instantly thinks the opposite direction is the correct forward direction.
At one point I partially fixed it, but the rotation still flipped at certain positions. The behavior makes me think there could be an issue with forward vectors, angle wrapping, or local vs global rotation calculations.
My expected behavior is for the enemy to smoothly and consistently face the player from all directions without sudden flipping or snapping.
Things I already tried
-
look_at() -
rotating only the Y axis
-
changing between local/global rotation
-
clamping rotation
-
using direction vectors manually
The issue still happens.
Pls note im new to Godot and i dont know a lot this i heavily used AI since i could not find tutorials although i coded the PATROL and CHASE state myself
extends CharacterBody3D
var player_in_range := false
var player_target : Node3D = null
# STATES
enum State {
PATROL,
CHASE,
AIM,
SHOOT,
DEAD
}
var current_state = State.PATROL
# MOVEMENT
@export var speed: float = 2.5
@export var chase_speed: float = 4.0
@export var timer: float = 0.6
@export var dash_rest: float = 2.0
@export var edge_detection := true
var turning := false
var direction := Vector3(1,0,0)
@onready var model = $MeshInstance3D
# COMBAT
@export var can_shoot := true
@export var attack_range := 5.0
@export var retreat_range := 3.0
@export var shoot_cooldown := 0.7
# COMPONENTS
@onready var health_component = $HealthComponent
@onready var player = get_tree().get_first_node_in_group("player")
# WEAPONS
@onready var gun = $EnemyGun
@onready var muzzle = $EnemyGun/EnemyTarget
@onready var shoot_ray = $EnemyGun/EnemyTarget
# EDGE DETECTION
@onready var edge_ray = $RayCast3D
# BULLET
var bullet := preload("res://bullet.tscn")
func _ready():
add_to_group("damageable")
if health_component == null:
print("ERROR: HealthComponent missing")
return
health_component.died.connect(destroy)
health_component.dash.connect(dash)
print("Enemy Ready")
# MAIN LOOP
func _physics_process(delta: float) -> void:
# GRAVITY
if not is_on_floor():
velocity += get_gravity() * delta
# STATES
match current_state:
State.PATROL:
state_patrol()
State.CHASE:
state_chase()
State.AIM:
state_aim(delta)
State.SHOOT:
state_shoot()
State.DEAD:
pass
move_and_slide()
# PATROL
func state_patrol():
velocity.x = speed * direction.x
# WALL TURN
if is_on_wall()and turning == false:
turn_around()
# EDGE TURN
if edge_detection:
if not edge_ray.is_colliding() and is_on_floor() and turning != true:
turn_around()
if player_in_range:
current_state = State.CHASE
# CHASE
func state_chase():
if !is_instance_valid(player_target):
current_state = State.PATROL
return
var distance = global_position.distance_to(player_target.global_position)
var dir = (player_target.global_position - global_position).normalized()
face_player()
# MOVE CLOSER
if distance > attack_range:
velocity.x = dir.x * chase_speed
# BACK AWAY
elif distance < retreat_range:
velocity.x = -dir.x * chase_speed
# PERFECT RANGE
else:
velocity.x = 0
current_state = State.AIM
func face_player():
if !is_instance_valid(player_target):
return
var dir = player_target.global_position - global_position
if dir.x > 0:
model.scale.z = 1
else:
model.scale.z = -1
# AIM
func state_aim(delta):
if player == null:
current_state = State.PATROL
return
velocity.x = 0
aim_gun(delta)
if has_line_of_sight():
current_state = State.SHOOT
# player too far again
var distance = global_position.distance_to(
player.global_position
)
if distance > attack_range + 2:
current_state = State.CHASE
# SHOOT
func state_shoot():
if not can_shoot:
current_state = State.AIM
return
shoot()
current_state = State.AIM
# AIM GUN
func aim_gun(delta):
if !is_instance_valid(player_target):
return
var dir = (
player_target.global_position
- gun.global_position
)
var target_angle = atan2(dir.y, dir.x)
gun.rotation.z = lerp_angle(
gun.rotation.z,
target_angle,
6.0 * delta
)
# SHOOT
func shoot():
can_shoot = false
var instance = bullet.instantiate()
get_tree().current_scene.add_child(instance)
instance.global_position = muzzle.global_position
instance.global_transform.basis = muzzle.global_transform.basis
print("Enemy Shot")
reset_shoot()
func reset_shoot():
await get_tree().create_timer(
shoot_cooldown
).timeout
can_shoot = true
# LINE OF SIGHT
func has_line_of_sight() -> bool:
shoot_ray.force_raycast_update()
if shoot_ray.is_colliding():
var collider = shoot_ray.get_collider()
if collider.is_in_group("player"):
player_in_range = true
player_target = player
current_state = State.CHASE
print("PLAYER DETECTED")
return true
return false
# PLAYER DETECTION
func can_see_player() -> bool:
current_state = State.CHASE
return player_in_range
# TURN
func turn_around():
turning = true
var old_dir = direction
direction = Vector3.ZERO
var tween = create_tween()
tween.tween_property(
self,
"rotation_degrees",
Vector3(0,180,0),
timer
).as_relative()
await get_tree().create_timer(timer).timeout
direction.x = old_dir.x * -1
turning = false
# DASH
func dash():
print("Critical Health")
speed = 6.0
await get_tree().create_timer(
dash_rest
).timeout
speed = 2.5
# DESTROY
func destroy():
current_state = State.DEAD
print("Enemy Died")
queue_free()
func _on_area_detection_body_entered(body):
if body.is_in_group("player") and shoot_ray.is_colliding():
player_in_range = true
player_target = body
current_state = State.CHASE
print("PLAYER DETECTED")
func _on_area_detection_body_exited(body):
if body == player_target:
player_in_range = false
player_target = null
await get_tree().create_timer(1).timeout
current_state = State.PATROL
print("PLAYER LOST")
# STOMP DAMAGE
func _on_top_checker_body_entered(body):
if body.has_method("bounce"):
body.bounce()
health_component.damage(20)