How to avoid clutter in animationtree statemachine?

Godot Version

4.2.1

Question

So I’m in the process of figuring out how to code enemies in a way that can handle a lot of different use cases. And I’ve been trying some different ways, but no matter how I make them, they almost always feel like a cluttered patchwork. I have yet to find a tutorial that really explains how to handle this type of problem.

What I want to make is a 2d roguelike/metroidvania where the unique selling point is that as the player progresses will acquire many different abilities that interact with the player, environment, enemies and so forth (think magic spells, fighting moves, grenades, whatever else you can think of. It isn’t fleshed out yet).

And I want to set up my enemies in such a way that they aren’t so rigid that I would need to remake them every time a new feature is added, but that they also have at least the following six states: Run, idle, attack, stunned, hurt and death.

I have been experimenting extensively with the AnimationTree state machine and trying different things to see how it works with the player. But so far, all of my enemies feel very rigid, cluttered and tough to make adjustments to without having to remake it entirely

I know this isn’t an easy question, and I’m not expecting a perfect solution. But I was wondering which steps you would begin taking to solve an issue like this, and/or what experiences have worked for you

tl;dr: How do I make a 2d enemy that can handle a lot of different use cases? What’s your experience with making enemies?

I learned a trick from Godot 4 C# Action Adventure: Build your own 2.5D RPG and then ported it to GDScript. It’s a node system where the StateMachine is a node, and then each child node is a State. Add the state, and the enemy or player can do the thing. Take it away and it can’t. I even implemented it so that you could gain new abilities from chests.

I’m currently working on updating my code to turn it into a plugin. (Which I will be making available with an MIT license on GitHub.) But here’s my current version. (Keep in mind this is for 3D.)

class_name StateMachine extends Node
## This node is intended to be attached to a character and manage the various
## states of the character. If a charatcer has a state it should be added as a
## child node of the state machine.


## The initial CharacterState for the character when it is added to the game.
@export var starting_state: CharacterState


# The current state of the character. Initially defaults to the first node it
# finds beneath itself if starting_state is not defined.
@onready var _current_state: CharacterState = starting_state if starting_state != null else self.get_child(0)


## Sets up all the states for this character.
func _ready() -> void:
	switch_state(_current_state)
	for state in get_children():
		if state is CharacterState:
			state.activate_state()
	self.connect("child_entered_tree", _on_state_added)
	self.connect("child_exiting_tree", _on_state_removed)


## Switch to the target state from the current state. Fails if:
## 1. The character does not have the passed state.
## 2. The character is already in that state.
## 3. The current state won't allow a transition to happen.
## 4. The target state won't allow a transition (e.g. cooldown timers).
func switch_state(state: CharacterState) -> void:
	if not _character_has_state(state): return
	if _current_state == state: return
	if not _current_state.can_transition: return
	if not state.can_transition: return
	
	_current_state.exit_state()
	_current_state = state
	_current_state.enter_state()


# Returns whether or not the character has this state.
# (A character has a state if the state is a child node of this StateMachine.)
func _character_has_state(state: CharacterState) -> bool:
	for element in get_children():
		if element == state:
			return true
	return false


# Activates a state.
# (Called when a node enters the tree as a child node of this StateMachine.)
func _on_state_added(node: Node) -> void:
	if not node is CharacterState:
		return
	node.activate_state()


# Deactivates a state.
# (Called when a child node of this StateMachine leaves the tree.)
func _on_state_removed(node: Node) -> void:
	if not node is CharacterState:
		return
	node.deactivate_state()

Then I’ve got CharacterState that players and enemies inherit from.

class_name CharacterState extends Node
# A virtual state for states to inherit.


## Stores any relevant statistics for the state.
@export var stats: Array[StatResource]


## Stores a reference to the character to which this state is attached.
@onready var character: Character = get_owner()


## Set to false if a character cannot use an ability or take an action,
## for example when waiting for a cooldown timer to expire.
var can_transition = true


## Initialize the state. Process mode is set so the state can be paused.
## Then all processing (including input and physics) is turned off.
func _ready() -> void:
	process_mode = ProcessMode.PROCESS_MODE_PAUSABLE
	set_physics_process(false)
	set_process(false)
	set_process_input(false)
	set_process_unhandled_input(false)


## Turn processing on for everything but physics when the state is active.
func activate_state() -> void:
	set_process(true)
	set_process_input(true)
	set_process_unhandled_input(true)


## Turn processing off when the state is deactivated.
func deactivate_state() -> void:
	set_process(false)
	set_process_input(false)
	set_process_unhandled_input(false)
	set_physics_process(false)


## Virtual function for inherited classes to implement, for example starting an animation.
func enter_state() -> void:
	set_physics_process(true)
	print("Enter State: %s" % self.name)


## Virtual function for inherited classes to implement.
func exit_state() -> void:
	set_physics_process(false)
	print("Exit State: %s" % self.name)


func get_stat(stat: StatResource.Type) -> StatResource:
	for element in stats:
		if element.stat_type == stat:
			return element
	return null


func _get_initial_stat_value(stat: StatResource.Type, default: float = 0.0):
	var stat_resource = get_stat(stat)
	if stat_resource:
		return stat_resource.stat_value
	return default

And StatResource may not be soemthing you need, but I’m including it for completeness.

class_name StatResource extends Resource


signal zero
signal update


enum Type {
	Speed,
	JumpVelocity
}


@export var stat_type: Type
@export var stat_value: float:
	set(value):
		stat_value = clampf(value, 0, INF)
		update.emit()
		if stat_value == 0.0:
			zero.emit()

My current PlayerState is a placeholder. I don’t have anything in it yet, but that may change so it’s there.

class_name PlayerState extends CharacterState

So here’s an idle state. The whole method is what I call “pull instead of push”. It’s a term used in project management for something called Kanban. The idea is that instead of telling a state when it should happen, you let the state tell you when it should happen. All the logic is there. This also means that if you don’t attach a node, the code never runs on that character.

class_name PlayerIdleState extends PlayerState


## If the player stops moving, move to the Idle state.
func _process(_delta: float) -> void:
	if character.direction == Vector3.ZERO:
		character.state_machine.switch_state(self)


## Handles slowing movement and idle animation
func _physics_process(_delta: float) -> void:
	character.velocity.x = move_toward(character.velocity.x, 0, character.speed)
	character.velocity.z = move_toward(character.velocity.z, 0, character.speed)

	do_animation()
	character.move_and_slide()


## Handles Idle/Walk/Run Animation
func do_animation() -> void:
	var vl = character.direction * character.rig.transform.basis
	character.animation_tree.set(character.IDLE_WALK_RUN_BLEND_POSITION, Vector2(vl.x, -vl.z))

Then we have the move state.

class_name PlayerMoveState extends PlayerState


## If the player has directional input, move to the Move state.
func _process(_delta: float) -> void:
	if character.direction:
		character.state_machine.switch_state(self)


## Handles movement and animation
func _physics_process(_delta: float) -> void:
	character.velocity.x = character.direction.x * character.speed
	character.velocity.z = character.direction.z * character.speed
	
	do_animation()
	character.move_and_slide()


## Handles Idle/Walk/Run Animation
func do_animation() -> void:
	var vl = character.direction * character.rig.transform.basis
	character.animation_tree.set(character.IDLE_WALK_RUN_BLEND_POSITION, Vector2(vl.x, -vl.z))

You’ll notice that the input isn’t in these states. Instead it’s in the player state. I’m actually thinking about changing that. The jump ability handles all its input alone. Obviously, enemies don’t get input from the player, but the point is that the states themselves be self-contained.

class_name PlayerJumpState extends PlayerState


## Jump Velocity defaults to zero unless the JumpVelocity stat is assigned,
## then that number is used.
@onready var jump_velocity: float = _get_initial_stat_value(StatResource.Type.JumpVelocity)
@onready var timer: Timer = $Timer


var can_land = false
var landing = false


func _ready() -> void:
	timer.timeout.connect(_on_timeout)


func _on_timeout():
	print("Timeout")
	can_land = true


# If the player presses the Jump action, switch to the Jump state.
func _process(_delta: float) -> void:
	if Input.is_action_just_pressed("jump") and character.is_on_floor():
		character.state_machine.switch_state(self)
		character.velocity.y = jump_velocity


# Process the jump every frame and trigger the landing when we land.
func _physics_process(_delta: float) -> void:
	if character.is_on_floor() and can_land and not landing:
		print("Land")
		land()
	character.move_and_slide()


func land():
	landing = true
	character.animation_state.travel("Jump_Land")


# Starts the jump animation, and turns off the ability to transition to
# another state mid-jump.
func enter_state() -> void:
	super()
	character.animation_tree.connect("animation_finished", _on_animation_finished)
	character.animation_state.travel("Jump_Start")
	can_transition = false
	timer.start()
	can_land = false
	landing = false


func exit_state() -> void:
	super()
	character.animation_tree.disconnect("animation_finished", _on_animation_finished)


# Allows state transition only after the initial Jump_Start animation has been called. This is
# because otherwise the landing animation is called because the first physics frame this class runs
# is from the floor and so is_on_floor() is still true.
func _on_animation_finished(animation_name: String) -> void:
	match animation_name:
		"Jump_Land":
			can_transition = true

Hope that helps.

2 Likes

“pull instead of push”. It’s a term used in project management for something called Kanban. The idea is that instead of telling a state when it should happen, you let the state tell you when it should happen.

Yeah, the idea that instead of planning for every single situation/use case, I would like to have a more “fluid” structure that can more easily adapt to different situations.
Instead of having states only act in a certain way I want them to react to their environment and that is certainly something I want to work towards with my project.

I have actually been using something similar for my player state machine (see pictures), but I have for a while been wanting to remake it (due to it being not very well done and probably very unoptimized)

It definitely helps! After reviewing what I have already made with my player at least a lot of it is more rigid than I would like and I will definitely be looking at this again :slight_smile:

1 Like

@dragonforge-dev
That is what I do in my state machines too! It is a really good approach and works brilliantly. The only difference is I don’t use the pull/push idea. My states neither care about the other states nor even know about them but also have no idea about what the character is doing either. So I set all the processes to false except for the current state. I have a separate script I call a behaviour_machine that has all the info needed to decide about which state the character should be in or be switched to. However I really do like the pull idea, I will definitely be considering that further, it has some nice benefits over the normal push setup.

@jup
It is definitely worth the time making this work for your situation. You will thank yourself in the future for the flexibility and simplicity it offers, and what’s best is that it is completely re-usable across projects.

2 Likes

There are definitely some gotchas with this idea - like two abilities using the same action and conflicting. The upside is I do not need a manager class. Which means if I want a new state, the only code I have to update is in that state. (In theory)

One of the things I’m reconsidering is the states themselves knowing too much about the player and animations. Obviously you need the player to have a jump animation, but the state doesn’t need to know how to make that happen, just to tell the character to do it.

1 Like