AI Module that can control movement

Godot Version

4.3 Stable

Question

I am creating modules to sort of plug and play into many different scenes(of CharacterBody3D. For example the scene would look something like this.

CharacterBody3D
|-Model
|-MovementModule
|-AIModule

I have a working MovementModule (in the sense it receives WASD inputs and moves in directions with player input. I was wondering how to get the AIModule to simulate player inputs to do something similar? I can sort of get it to hit W to move but I am having trouble getting it to rotate in the right direction (lots of errors related to rotation) and I am fairly new to Godot. I understand this might be a weird way of doing things but I am curious about it. When I tried controlling the AI more directly it sort of ignored physics but this way it doesn't so was hoping to get it to work. Code is below

class_name AIController extends Node

enum AIStates {Idle, Wander}

@export var movement_controller:MovementController
@export var idle_min_duration: float = 0.0
@export var idle_max_duration: float = 15.0
@export var wander_radius: float = 10.0
@export var destination_tolerance: float = 0.5

var target_position: Vector3 = Vector3.ZERO

var current_state: AIStates = AIStates.Idle
var state_timer: float = 2.0

func _ready() -> void:
	#Initialzize the AI in Idle state.
	print("AI ALIVE. Going Idle")

func _process(delta: float) -> void:
	handle_behavior(delta)

func switch_state(new_state: AIStates) -> void:
	current_state = new_state
	state_timer = 0.0
	print("Switched to state:", AIStates.keys()[new_state])
	if new_state == AIStates.Idle:
		state_timer = randf_range(idle_min_duration, idle_max_duration)
	elif new_state == AIStates.Wander:
		pass

func handle_behavior(delta: float) -> void:
	state_timer -= delta
	match current_state:
		AIStates.Idle:
			if state_timer <= 0.0:
				switch_state(AIStates.Wander)
		AIStates.Wander:
			move_towards_target(delta)
			if is_at_target():
				switch_state(AIStates.Idle)

#set target
func get_random_point_nearby()-> Vector3:
	var parent_position = get_parent().global_transform.origin
	var random_offset = Vector3(randf_range(-wander_radius, wander_radius), 0 , randf_range(-wander_radius,wander_radius))
	return parent_position + random_offset

#ordering movement
func move_towards_target(_delta:float)->void:
	var parent = get_parent()
	if not parent or not parent is CharacterBody3D:
		return
	var direction = (target_position - parent.global_transform.origin).normalized()
	if direction.is_zero_approx():
		return
	var transform = Transform3D()
	var target_rotation = transform.looking_at(direction,Vector3.UP).basis #gave me recommended changes... might be broken now
	parent.global_transform.basis = Basis(target_rotation)
#using the movement controller to move in the forward direction
	movement_controller.handle_acceleration(parent, Vector2(direction.x,direction.z))

#has it arrived?
func is_at_target()-> bool:
	var parent = get_parent()
	if not parent or not parent is CharacterBody3D:
		return false
	return parent.global_transform.origin.distance_to(target_position) <= destination_tolerance

I tried finding videos on creating AI but they all seem to want to make it focus on interactions around the player? This is more neutral and act more like animals and such then hostile mobs in a horror game. Any advice is appreciated

Suppose this boils down to “what do you want the NPC to do?” and there are some standard techniques for implementing this question, such as Goal Oriented Action Planning or GOAP.

You have some fundamentals like moving towards a target, this target doesn’t have to be the player. Moving towards the closest carrot could be a target, and a important first step in “Goal: eat food” as a deer or horse.

I have implemented my characters’ AI using a stack-based goal-oriented system. In Ostrich Onslaught, for example, I can summarize like this:

  1. The top level AI determines what to do. For instance, find an available rider. When found, instantiate the AI class which has the sole goal of flying to the rider (it can be any target, though), and add it to the AI stack. When the stack unwinds, return here to determine whether to continue or to determine the next action.

  2. While flying to the rider, check if there is an obstacle (such as a floating island) between the ostrich and the rider. If so, instantiate the AI class which has the sole goal of flying around the island, and add it to the stack. When the stack unwinds, return here to continue flying to the rider.

  3. The island avoidance AI class determines whether to fly up, down, left, right, or forward to get around the island. When the ostrich the AI class controls clears the island, pull the AI class off the stack and start working its way back up the AI stack.

This approach allows a lot of flexibility, as the AI classes are told which object they are controlling. In this case, it’s an ostrich, but it could very well be a flying toaster or a smart-missile.

Each goal is implemented by a separate class file, so it becomes a matter of chaining goals through the stack.

Check out the book AI for Games by Ian Millington - if you can get your hands on a copy of that, it’ll teach you all the fundamentals.

You say you’re getting a lot of errors related to rotation - can you give some examples?

Looking at your code:

  • I don’t see where you’re calling the get_random_point_nearby function, but maybe I’m just blind
  • It seems odd to me to call transform.looking_at with a direction - it would make more sense to call it with a position, no? But maybe it’s the same thing, if you’re using a freshly created Transform with position (0,0,0).
1 Like

Response to Tayacan

  • The get_random_point_nearby() function is under handle behavior and above move_towards_target.
  • I did the transform.looking_at because its what I did with the camera and that seemed to have worked in that instance. Not working here obviously.

This is the first error
E 0:00:11:0492 AI_controller.gd:61 @ move_towards_target(): The target vector and up vector can’t be parallel to each other.
<C++ Error> Condition “v_x.is_zero_approx()” is true. Returning: Basis()
<C++ Source> core/math/basis.cpp:1053 @ looking_at()
AI_controller.gd:61 @ move_towards_target()
AI_controller.gd:42 @ handle_behavior()
AI_controller.gd:24 @ _process()
(from my understanding I think it gives this error when its already facing the direction it wants to go? so I might just be calling the rotation code to many times? admittidely its not spamming the error out, just once or twice before it gives up).
I cant get the other error to generate but it was something along the lines of looking directly at 0 (I think this error generated when it arrived at the location and starts looking straight down)

The concept seems nice, but I am just starting with AI. I have a somewhat barely functioning statemachine (it can switch modes… just not moving yet). So doing things based on your example seems to be 2-3 steps above me right now.

not… completely wrong, that feels like a step or two ahead of what im currently doing right now. Right now I am just trying to get it to rotate toward its target and press W to move forward. GOAP sounds interesting and will look into it when I start getting more advanced stuff.

What I am trying to accomplish currently is

  1. Make the CharacterBody3D rotate to face the direction of the target (in this case a Vector2, though I guess it could be face a node or general direction?)
  2. Make the AI press W (like a player would to move forward)
  3. When arrived at destination release W (stops forward movement)

So far I think I have it rotating? based on the prints I have. It just seems that after it rotates it freaks out with an error then stops (without crashing). AI is very new to me and this is basically babies first state machine.

Your NPC will not “press W” it must move to it’s target without inputs. You could use look_at to look at the target, then it seems like your movement_controller is in charge of actually moving towards the target. What isn’t working about your current implementation?

Maybe it would be better to show how movement_controller.handle_acceleration works?

The specific part in the code that I am having trouble with is
“var target_rotation = transform.looking_at(direction,Vector3.UP).basis #gave me recommended changes… might be broken now”
most of my errors seem to be stemming around this part here. I think I am calling the code to much and it might be erroring when its already looking at target? I havent tried troubleshooting it yet. as I havent had the time, I should be able to start touching it wednesday and will update what I find then. Was just gathering some ideas to try to troubleshoot it.

I got it moving but rotation still a problem lol, ran out of time today, will revisit on friday/saturday. If anyone has any ideas im all ears~ (not much in the way of code has changed, just had it print the error inside code and it seemed to let it move? it was weird). not even 100% sure its rotating or just followin the path, I will have to bring in a different model and see I guess. Isn’t trouble shooting fun!

I have finally got it working.
it might look a little strange but… thats kinda how I had to get it to work for an AI
to explain it simply the structure looks like this
(CharacterBody3D)<
|-(MovementController)-Its pretty much handles movement that modifies the |parent (works for both player and AI inputs)
|-(Controller:AI in this case, but can be player)
I also have a resource listed as AIInput (basically acts like a bus)
I know this is probably very overkill and would be solved with fundementally alot simpler solutions, but I was having fun messing around. It works and thiers that. putting the code below and be mindful its a work in progress
Code 1 (AIinput)

# Simulates movement input
var target_direction: Vector2 = Vector2.ZERO
var target_rotation: float = 0.0
var target_position: Vector3 = Vector3.ZERO
var is_walking: bool = true
var is_sprinting: bool = false
var is_jumping: bool = false

func get_movement_input() -> Vector2:
	return target_direction

func get_rotation_input() -> float:
	return target_rotation

func get_target() -> Vector3:
	print(target_position)
	return target_position

func get_walk_input() -> bool:
	return is_walking

func get_sprint_input() -> bool:
	return is_sprinting

func get_jump_input() -> bool:
	return is_jumping

Code 2 (AIController) < simple state machine (very much prototyping)

class_name AIController extends Node

enum AIStates {Idle, Wander}

@export var movement_controller:MovementController
@export var idle_min_duration: float = 0.0
@export var idle_max_duration: float = 5.0
@export var wander_radius: float = 30.0
@export var destination_tolerance: float = 0.5

@onready var ai_input: AIInput = AIInput.new() #this needs to be here so each AIInput is unique and all the controllers don't spam!

var current_state: AIStates = AIStates.Idle
var state_timer: float = 2.0

func _ready() -> void:
	print("AI ALIVE. Going Idle")

func _process(delta: float) -> void:
	handle_behavior(delta)

func switch_state(new_state: AIStates) -> void:
	current_state = new_state
	state_timer = 0.0
	#print("Switched to state:", AIStates.keys()[new_state])
	if new_state == AIStates.Idle:
		state_timer = randf_range(idle_min_duration, idle_max_duration)
	elif new_state == AIStates.Wander:
		var random_direction: Vector3 = Vector3(
			randf_range(-wander_radius,wander_radius), #X
			0, #Y
			randf_range(-wander_radius,wander_radius) #Z
		)
		set_target(random_direction)
		state_timer = randf_range(idle_min_duration, idle_max_duration)

func handle_behavior(delta: float) -> void:
	state_timer -= delta
	match current_state:
		AIStates.Idle:
			stop_moving()
			if state_timer <= 0.0:
				switch_state(AIStates.Wander)
		AIStates.Wander:
			move_forward()
			if state_timer <= 0.0:
				switch_state(AIStates.Idle)
			

func stop_moving() -> void:
	ai_input.target_direction = Vector2(0,0)

func move_forward() -> void:
	ai_input.target_direction = Vector2(0,1)

func set_target(target_position: Vector3) -> void:
	ai_input.target_position = target_position
	#print(ai_input.target_position)

func get_ai_input() -> AIInput:
	return ai_input

Code 3(Movement controller)<had to make the rotation “unique”, couldnt figure out how to make it work like a player, so failed in that aspect

class_name MovementController extends Node

var base_speed:float
var walk_speed:float
var sprint_speed:float
var jump_height:float

var current_speed:float =0.0

enum movement_states {Neutral,Walking,Sprinting}

var current_movement_state:movement_states = movement_states.Neutral

func _ready() -> void:
	var parent  = get_parent()
	if parent and parent is CharacterBody3D:
		# Directly access the variables
		base_speed = parent.base_speed
		walk_speed = parent.walk_speed
		sprint_speed = parent.sprint_speed
		jump_height = parent.jump_height

func handle_movement_state()->void:
	match current_movement_state:
		movement_states.Neutral:current_speed=base_speed
		movement_states.Walking:current_speed=walk_speed
		movement_states.Sprinting:current_speed=sprint_speed

func set_movement_state(is_walking:bool,is_sprinting:bool)->void:
	if is_walking:current_movement_state=movement_states.Walking
	elif is_sprinting: current_movement_state=movement_states.Sprinting
	else:current_movement_state=movement_states.Neutral

func handle_acceleration(entity:CharacterBody3D,target_direction:Vector2)->void:
	var direction:Vector3 = (entity.transform.basis*Vector3(target_direction.x,0,target_direction.y))
	direction.normalized()
	if direction:
		entity.velocity.x=direction.x*current_speed
		entity.velocity.z=direction.z*current_speed
	else: 
		entity.velocity.x=move_toward(entity.velocity.x,0,current_speed)
		entity.velocity.z=move_toward(entity.velocity.z,0,current_speed)

func handle_rotation(entity:CharacterBody3D,target_direction:Vector3)->void:
	var current_forward: Vector3 = -entity.transform.basis.z
	target_direction = target_direction.normalized()
	var angle:float = current_forward.angle_to(target_direction) #get the rotation angle (radians) between current forward and target
	var cross:Vector3= current_forward.cross(target_direction) #determine the cross product to check rotation direction (clockwise/counterclockwise)
	if cross.y <0:
		angle = -angle #rotate clockwise if cross product points downward
	entity.rotate_y(angle)
	#print("Rotating entity to face:", target_direction) #test prints

func handle_jump(entity:CharacterBody3D,is_jumping:bool)->void:
	if is_jumping:
		entity.velocity.y=jump_height

#TEST ZONE

func handle_ai_rotation(entity:CharacterBody3D,target_position:Vector3, delta:float)-> void:
	var direction_to_target = (target_position - entity.global_transform.origin).normalized()
	if direction_to_target.is_equal_approx(Vector3.UP) or direction_to_target.is_equal_approx(-Vector3.UP):
		direction_to_target += Vector3(0.01, 0, 0.01)
		direction_to_target = direction_to_target.normalized()
	var current_yaw = entity.rotation.y
	var target_yaw = atan2(direction_to_target.x, direction_to_target.z)
	if abs(target_yaw - current_yaw) < 0.01:
		return
	var rotation_speed = 5.0
	entity.rotation.y = lerp_angle(current_yaw, target_yaw, delta * rotation_speed)

func handle_target_rotation(entity: CharacterBody3D, target_position: Vector3) -> Vector3:
	var direction_to_target = (target_position - entity.global_transform.origin).normalized()
	return direction_to_target

Code 4 (CharacterBody3D)<where the magic happens

extends CharacterBody3D

#artifical intelligence
@onready var AIinput:AIInput = AIInput.new()
@export var ai_controller:AIController=null
#movement 
@onready var movement_input_controller:MovementInputController = MovementInputController.new()
@export var movement_controller:MovementController= MovementController.new()

#stats
@export var base_speed:float = 3.0
@export var walk_speed:float = 3.0
@export var sprint_speed:float = 10.0
@export var jump_height:float = 8.0

func _ready() -> void:
	print(AIinput)
	if ai_controller:
		AIinput = ai_controller.get_ai_input() #order might be wrong so might need changing
	#print(self, " -> ", movement_input_controller)
	#print(self, " -> ", movement_controller)

func _physics_process(delta: float) -> void:
	handle_gravity(delta)
	movement_controller.handle_movement_state()
	movement_controller.handle_acceleration(self, AIinput.get_movement_input())
	movement_controller.handle_ai_rotation(self,AIinput.target_position,delta)
	movement_controller.handle_jump(self,AIinput.get_jump_input())
	move_and_slide()

#loving the ground
func handle_gravity(delta:float)->void:
	if not is_on_floor():
		velocity += get_gravity()*delta

Overall its pretty simple and took alot of trouble shooting to get it to this point.
I dont know why I struggled with handling the AI rotation so much. took me almost 2 full weekends (full time college student so only like 4 hours after I finished schoolwork). I am curious about everyones thoughts on it since I have it working!
No errors, debugs or weird stuff (did have a weird problem where it was spinning in circles at one point but fixed that pretty quickly).