Enemy AI State Machine
Description
The enemy state machine makes use of a variety of classes all linking together from a base enemy state. The base enemy state stores a bunch of widely used variables and the base of the functions.
Features
The enemy state machine handles the response of the enemies to player behaviour. The current implementation has a basic idle, chase, attack setup.
Implementation
State machine Handles which state is being used and swapping between states.
extends Node
@export var starting_state: BaseEnemyState@export var enemy: Enemy@export var player: ChickenPlayer
var states: Dictionary[EnemyEnums.EnemyStates, BaseEnemyState] = {}
@onready var current_state: BaseEnemyState = _get_initial_state()
# Called when the node enters the scene tree for the first time.func _ready() -> void: if enemy == null: push_error(owner.name + ": No enemy reference set")
if player == null: player = GameManager.chicken_player
# Connect the signal to the transition function SignalManager.enemy_transition_state.connect(_transition_to_next_state)
# We wait for the owner to be ready to guarantee all the data and nodes are available. await owner.ready
# Get all states in the scene tree for state_node: BaseEnemyState in get_children(): states[state_node.STATE_TYPE] = state_node state_node.setup(enemy, player)
if current_state: current_state.enter(current_state.STATE_TYPE)
# Called every frame. 'delta' is the elapsed time since the previous frame.func _process(delta: float) -> void: if current_state == null: push_error(owner.name + ": No state set.") return current_state.process(delta)
func _physics_process(delta: float) -> void: if current_state == null: push_error(owner.name + ": No state set.") return current_state.physics_process(delta)
func _transition_to_next_state(target_state: EnemyEnums.EnemyStates, information: Dictionary = {}) -> void: if target_state == current_state.STATE_TYPE: push_error(owner.name + ": Trying to transition to the same state: " + str(target_state) + ". Falling back to idle.") target_state = EnemyEnums.EnemyStates.IDLE_STATE
var previous_state := current_state previous_state.exit()
current_state = states.get(target_state) if current_state == null: push_error(owner.name + ": Trying to transition to state " + str(target_state) + " but it does not exist. Falling back to: " + str(previous_state)) current_state = previous_state current_state.enter(previous_state.STATE_TYPE, information)
func _get_initial_state() -> BaseEnemyState: return starting_state if starting_state != null else get_child(0)
Base state: Sets a bunch of widely used variables and basis for certain functions.
class_name BaseEnemyStateextends BaseState
#Instantiate globally used variables around the enemy states@export var DELTA_MODIFIER: float = 100@export var enemy: Enemy@export var chase_distance: float = 20@export var STATE_TYPE: EnemyEnums.EnemyStates@export var ANIMATION_NAME: String
var player: ChickenPlayervar previous_state: EnemyEnums.EnemyStatesvar weapon: MeleeWeapon
func setup(_enemy: Enemy, _player: ChickenPlayer) -> void: if _enemy == null: push_error(owner.name + ": No enemy reference set" + str(STATE_TYPE)) enemy = _enemy if _player == null: push_error(owner.name + ": No player reference set" + str(STATE_TYPE)) player = _player
func enter(_previous_state: EnemyEnums.EnemyStates, _information: Dictionary = {}) -> void: previous_state = _previous_state
Idle: This state makes the enemy wander around in random directions and checks to see if the player is near to transition to the chase state.
extends BaseEnemyState
@export var wander_interval: float = 3.0 ## Time between choosing new wander directions@export var wander_speed: float = 3.0@export var wander_radius: float = 8.0 ## Max distance from starting point@export var rotation_speed: float = 5.0 ## How quickly enemy turns toward target
var target_position: Vector3 ## Target position for wanderingvar wander_timer: float = wander_interval ## Timer for choosing new targetvar origin_position: Vector3 ## Starting position of the enemy
func enter(_previous_state: EnemyEnums.EnemyStates, _information: Dictionary = {}) -> void: origin_position = enemy.position _choose_new_wander_target()
func process(_delta: float) -> void: if enemy.position.distance_to(player.position) < chase_distance: SignalManager.enemy_transition_state.emit(EnemyEnums.EnemyStates.CHASE_STATE, {}) return
func physics_process(delta: float) -> void: wander_timer -= delta if wander_timer <= 0: _choose_new_wander_target() wander_timer = wander_interval
var direction: Vector3 = (target_position - enemy.position).normalized() if direction.length() > 0: _rotate_toward_direction(direction, delta)
enemy.velocity.x = direction.x * wander_speed enemy.velocity.z = direction.z * wander_speed
func _choose_new_wander_target() -> void: var random_angle: float = randf_range(0, 2 * PI) # Random angle in radians var random_distance: float = randf_range(0, wander_radius) # Random distance from the origin position
# Calculate the target position based on the random angle and distance target_position = origin_position + Vector3( cos(random_angle) * random_distance, 0, sin(random_angle) * random_distance )
# Ensure the target position is within the wander radius, if not, adjust it if origin_position.distance_to(target_position) > wander_radius: target_position = origin_position + (target_position - origin_position).normalized() * wander_radius
func _rotate_toward_direction(direction: Vector3, delta: float) -> void: var target_angle: float = atan2(-direction.x, -direction.z) # Calculate the angle to the target direction var current_angle: float = enemy.rotation.y # Get the current angle of the enemy
# Lerp the angle to smoothly rotate towards the target direction var new_angle : float = lerp_angle(current_angle, target_angle, rotation_speed * delta) enemy.rotation.y = new_angle
Chase: Follows the player around until a certain threshold is reached. If that is close to the player, enter attack state. If that is far away from the player, enter idle state.
extends BaseEnemyState@export var speed: int = 10@export var rotation_speed: float = 5.0 ## How quickly enemy turns toward targetvar target_position: Vector3
#Check what conditions are fulfilled to shift the enemy in state to certain behaviour patterns.#This would be the place to change behaviour, for example a ranged attack.func physics_process(delta: float) -> void: target_position = (player.position - enemy.position).normalized() if enemy.position.distance_to(player.position) < chase_distance: if target_position.length() > 0: _rotate_toward_direction(target_position, delta) enemy.velocity.x = target_position.x * speed enemy.velocity.z = target_position.z * speed else: SignalManager.enemy_transition_state.emit(EnemyEnums.EnemyStates.IDLE_STATE, {})
func _rotate_toward_direction(direction: Vector3, delta: float) -> void: var target_angle: float = atan2(-direction.x, -direction.z) # Calculate the angle to the target direction var current_angle: float = enemy.rotation.y # Get the current angle of the enemy
# Lerp the angle to smoothly rotate towards the target direction var new_angle : float = lerp_angle(current_angle, target_angle, rotation_speed * delta) enemy.rotation.y = new_angle
Signal-Based Transitioning
The state machine listens for the SignalManager.enemy_transition_state
signal, ensuring clean and modular state changes. This event-driven approach makes the system flexible and easy to expand.
Key Features
- Modular State System: Each state is self-contained, making the system easily extendable.
- Signal-Based Transitions: Ensures smooth state changes without hard dependencies.
- Error Handling: Prevents invalid state transitions and logs issues when they occur.