Concerning behavior with AnimationTree

Godot Version

Godot v4.4.1.stable - Windows 10 (build 19045) - Multi-window, 2 monitors - Vulkan (Forward+) - dedicated AMD Radeon RX 5600 XT (Advanced Micro Devices, Inc.; 32.0.21025.1024) - AMD Ryzen 9 3900X 12-Core Processor (24 threads)

Prefix

I’ve been having issues with the AnimationTree recently - trying to trigger a separate state machine to revert back to the idle state after the roll animation finished.
Sounds pretty straightforward, but it’s forced me to look into possible issues with the Animation Tree.

My Original Problem

Before I get into the issues, here’s the general outline I want:

  1. Player is in idle state
  2. Player inputs a ‘roll’, moving the state machine to the roll state
  3. The roll state waits until the roll animation is finished before moving back to the idle state (I would like to trigger this by connecting to the animation_finished signal on the AnimationMixer class)

The problem, is that this is VERY inconsistent, and will get ‘stuck’ in the roll state about 5% of the time.
This lead me to revert back to idle based on a timer instead, but I’ve now encountered some other timing issues with that system as well, which makes me want to just use the signal since it makes more since and keeps things tight.

Test Outline

So I made a simple project with the goal of testing the animation_finished signal, and I found some very concerning info:

Very briefly, my setup has a Timer node, and an AnimationTree node.
The Timer is on an automatic 0.1 second timeout interval, and the AnimationTree node has basically the same setup, but is instead using 2 separate animations; each with a length of 0.1.
The idea is that the timeout signal and the animation_finished signal should happen at the same interval, and in turn, happen the same number of times.
News flash, this is far from the truth lol

Test Results

  • 100 cycles: Test Completed in 10.0085865555556 seconds:
    • AnimationPlayer finished 85 times, with an average wait time of 0.11715984183007 (total time was: 9.95858655555555)
    • Timer finished 100 times, with an average wait time of 0.10008586555556 (total time was: 10.0085865555555)
  • 500 cycles: Test Completed in 50.0056820000008 seconds:
    • AnimationPlayer finished 426 times, with an average wait time of 0.11734510798122 (total time was: 49.9890159999998)
    • Timer finished 500 times, with an average wait time of 0.100011364 (total time was: 50.0056819999997)
  • 1000 cycles: Test Completed in 100.015247666666 seconds:
    • AnimationPlayer finished 851 times, with an average wait time of 0.11744839130435 (total time was: 99.9485810000013)
    • Timer finished 1000 times, with an average wait time of 0.10001524766667 (total time was: 100.015247666668)

In general, it looks like the AnimationPlayer only fires it’s animation_finished signal at around 85% the rate of the Timer, which is a pretty large difference imo…
NOTE: I also got similar results when using different animation/timer lengths.

My Questions

  • Am I missing something on the docs? Is the AnimationTree simply slower, leading to this? If not, I’m kind of thinking that the animation_finished signal could be firing at the correct rate, but maybe it’s just “missing” or not being triggered at all at times…? This would explain the “getting stuck in the roll state” that I mentioned earlier.

My Code (lmk if I did something wrong with this test)

Here’s my code if you care to read through it and check everything, but I think it’s all pretty straightforward.

extends Node2D

@export var timer: Timer
@export var animation_tree: AnimationTree
@onready var state_machine: AnimationNodeStateMachinePlayback = animation_tree["parameters/playback"]

var anim_finished_counter: int = 0: set = _on_anim_finished_counter_set
var timer_finished_counter: int = 0: set = _on_timer_finished_counter_set

var anim_delta: float = 0.0
var timer_delta: float = 0.0

var anim_times: Array[float] = []
var timer_times: Array[float] = []
var test_count: int = 1000
var total_time_taken: float = 0.0

func _ready() -> void:
	animation_tree.animation_finished.connect(func(name: String):
		anim_finished_counter += 1)
	timer.timeout.connect(func():
		timer_finished_counter += 1)

func _process(delta: float) -> void:
	anim_delta += delta
	timer_delta += delta
	
	if anim_finished_counter == test_count or timer_finished_counter == test_count:
		print("Test Completed in %s seconds:
			- AnimationPlayer finished %s times, with an average wait time of %s (total time was: %s)
			- Timer finished %s times, with an average wait time of %s (total time was: %s)" % 
			[total_time_taken,
			anim_finished_counter, get_average(anim_times), get_average(anim_times) * anim_finished_counter,
			timer_finished_counter, get_average(timer_times), get_average(timer_times) * timer_finished_counter])
		get_tree().paused = true
	total_time_taken += delta

func get_average(numbers: Array) -> float:
	var sum: float = 0.0
	for n in numbers:
		sum += n
	return sum / numbers.size()

func _on_anim_finished_counter_set(new_value: int) -> void:
	anim_finished_counter = new_value
	anim_times.append(anim_delta)
	anim_delta = 0

func _on_timer_finished_counter_set(new_value: int) -> void:
	timer_finished_counter = new_value
	timer_times.append(timer_delta)
	timer_delta = 0

Finally, I may be over-engineering my state machine, so I’d also love if anyone has any input on ways to make it simpler (maybe I’m overthinking it?).
I do need it to work with different behavior trees though, so I’m pretty sure it needs to stay the way it is.

Thanks for helping if you’ve made it this far! :smiley:

EDIT: yes the code is inneficient, but I’m tired and just trying to figure this bug out :frowning:

I am pretty sure animation trees only emit animation_finished if they are connected by Switch mode “At End”.

How did you set up your animation tree?

1 Like

So, a couple of things.

  1. A Timer’s accuracy below 0.5 seconds is not guaranteed, so it’s not something to use for unit tests that require precision measurements below that time. This is in part because timers fire a number of times per second equal to Engine.physics_ticks_per_second, which is defaulted to 60 or 0.01667 seconds per tick.
  2. You’re making this WAY more complex than it needs to be.


Here’s an example AnimationNodeStateMachine. It doesn’t have a roll in it, but I’ve done one with a dodge animation and no issues. As @gertkeno mentions, it’s how you set this up that’s important. Then, you don’t need to monitor the state machine’s signals at all.

Here’s the setup in the Character class. (Keep in mind these are all 3D examples because I usually find AnimatedSprite2D serves my needs for most 2D animation these days unless I’m using a Skeleton2D.)

@export var animation_tree: AnimationTree:
	set(value):
		animation_tree = value
		animation_state = animation_tree.get("parameters/playback")

var animation_state

Here’s all I do in both me Idle and Move states (which are separate nodes).

## 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))

For a ranged spell I just use:

character.animation_state.travel("Spellcast_Shoot")

Once the animation is over, it automatically returns to the Idle/Walk/Run state without me having to do anything for the animation itself - which is what you’d want for your dodge.

This is the transition to Spellcast_Shoot:

And back to IdleWalkRun:

My character State for spellcasting fires the spell and then the Idle state automatically resumes control if the player isn’t pressing any other buttons.

1 Like

So this is basically what I have, but I’m instead calling animation_state.travel() when a state is entered (I’m using ‘state’ here to refer to my state machine, not the animation state machine).
I guess I don’t understand you’re setup completely - are you using a separate state machine as well, or just the animation tree?

So my Animation State machine looks like this:


But since I need to execute a few different tasks when the “roll” state is entered (applying a boost to the player, adding a dodge stat buff, and handling I-frames), I had to split it into a different script:

class_name RollState extends State

func enter_state() -> void:
   anim_state.travel("roll")
   # Setup various properties for roll
   await anim_tree.animation_finished
   # Revert properties
   change_state.emit(States.IDLE)

# This is obviously the basic outline

So I’m essentially using 2 ‘state machines’ - the only reason I’m using an animation tree at this point is for blend spaces.

I could technically put this logic in a method call on an actual animation track, but I personally find that messy, especially since I’d have to copy it for 8 directions.

Interested to see your thoughts on this :slight_smile:

I’m using Sync, which does work, just only some of the time

It looks like you’re using At End for rollidle or run, and Sync for idlerun. I’ve had better success with animation_started but keep in mind the animation_finished and started signals are for specific animations, not animation state machine nodes. How are your sub-nodes set up? If they are statemachines too, then you could add a single frame animation before the exit to call your method.

I’m surprised and dissapointed the only signal I can find for animation state machine nodes is advance_condition_changed which is seldom useful. Seems like transitions would be useful to emit out

2 Likes

I’m using a node-based StateMachine that I created, and an AnimationNodeStateMachine.


In 2D I use the same node-based StateMachine, but an AnimatedSprite2D.

My Idle/Walk/Run is a BlendSpace2D inside the AnimationNodeStateMachine looks like this.

There’s forward walking and running, strafe left, right, and walk backwards. Idle sits right in the middle. I don’t know if you’re in a platformer or top-down so it’s hard to say how I would construct one for you, but you could get multiple idles in there too.

Yup, as you can hopefully see from the screenshots above, that’s what I do with each of my states. Here’s the big difference between mine and yours though.

You have a push method. Each state appears to know about other states and push a state change to another state. My StateMachine uses a pull method. A state only ever knows about itself. It monitors when it needs to change, and tells the StateMachine that it needs to change. The StateMachine checks to see if the current State can be exited. Most can. (The Hurt and Dead states typically cannot be interrupted.) If it does, the StateMachine switches state.

This is my PlayerIdleState for a CharacterBody3D-based Player.

class_name PlayerIdleState extends PlayerState


func _activate_state() -> void:
	super()
	set_process(true)
	set_physics_process(true)


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


## 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))

_activate_state() is called when the StateMachine itself is initialized. It replaces the _ready() function for my States. What this allows me to do is ensure that the entire object (in this case the Player) is fully constructed before we turn anything on. The inherited _ready() state actually turns off all input and processing.

_process() monitors the Player to which a reference is stored in the character variable. If the Player isn’t giving any directional input, we switch state to ourselves (Idle).

_physics_process() handles slowing the Player down if necessary because you can enter this state from moving and still have velocity to deal with. We then run the animation and move_and_slide() for the Player.

do_animation() does the animation. Based on the direction the Player is moving and the way in which the 3D model is facing. It then passes in a Vector2 telling it where the animation should be in the BlendSpace2D.

Thus while the PlayerIdleState knows about the Player it’s hanging off of, it knows nothing about the other states.

I think two state machines is fine. In that game I have two for every character (players and enemies). I also have one for the game itself. They all use the same base code. You can find that code here GitHub - dragonforge-dev/dragonforge-state-machine: A base state machine class to be used in games. and are welcome to peruse it and/or use it. (It’s a Godot Add On distributed with the MIT license.)

In my case, the Character state machine drives the AnimationTree state machine. If I were implementing a Roll state, it wouldn’t need to know anything about Idle if it was uninterruptable, I would set can_transition = false until the animation was done.

## Set to false if this [State] cannot be transitioned to (or alternately, from).
## For example when waiting for a cooldown timer to expire, when a
## character is dead, or when the splash screens have been completed.
var can_transition = true

Going back to your initial question about detecting animations being finished, my PlayerJumpState does something similar to what you want. You’ll noticed that when we enter the state, we start monitoring the animation_tree.animation_finished signal. When we exit the state, we disconnect from monitoring it. In this way we only detect it when it is necessary. I also make sure that no other State can move us away from the jumping animation until we have landed back on the ground.

class_name PlayerJumpState extends PlayerState


## Jump Velocity defaults to zero unless the JumpVelocity stat is assigned,
## then that number is used.
var jump_velocity: float


@onready var timer: Timer = $Timer


var can_land = false
var landing = false


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


func _activate_state() -> void:
	super()
	set_process(true)
	set_physics_process(true)
	jump_velocity = character.get_stat_value(StatResource.Type.JumpVelocity)


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():
		switch_state()
		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.animation_finished.connect(_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.animation_finished.disconnect(_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.