derkork / godot-statecharts

A state charts extension for Godot 4
MIT License
761 stars 39 forks source link

Add the ability for AnimationPlayerState to automatically transition to other states when the animation ends. #131

Open shanayyy opened 2 months ago

shanayyy commented 2 months ago

A common use case for AnimationPlayerState is transitioning to a different state at the end of an animation, e.g. returning to the idle state after an attack animation. Currently we have to handle this transition manually. This requires tracking the completion states of different animations and mapping them to transitions.

To make AnimationPlayerState easier to use, I suggest adding a Next State property to it. If a state is assigned to it, when the AnimationPlayer emits the animation_finished signal, if the anim_name is equal to the animation_name of the AnimationPlayerState, and the AnimationPlayerState is active, it automatically transitions to the state specified by the Next State.

01
derkork commented 1 month ago

The animation control is a rather tricky part of the library. I added it as an experimental feature a while ago but animation control can become very complex and this implementation isn't going to work for all cases.

In many cases (I would even go so far as to say in most cases) the animation length correlates with some game mechanic and has direct influence on how the game feels. E.g. firing animation of a gun needs to be properly aligned to the gun's firing rate, the strike animation of a sword strike needs to correlate with the intended fighting speed of the player or an invulnerability or stun animation is only as long as the designer said these effects would last.

In these cases I think it is not good to synchronize the animation state back to the state chart because the game design will dictate how long these animations play. So the state chart would not "wait" until the animation is finished - but rather control the timing itself. This way the animations are only visualizations of the game state to the player but have no impact on gameplay. For this the AnimationPlayerState works fine, because when you work like this, animations are basically fire and forget. So in your concrete example, say you have a series of attacks that should play in sequence you can do a ...

image

... and just need to make sure the animation lengths match the designed timings in your state chart.

Then no manual signal mapping is needed. And for this example I think this is the better way to do it as you can tweak the timings until everything feels right and when you're done you just change the animation one time to have the proper length rather than having to modify the animation every time you want to tweak the attack timing.

However there are also cases when you want callbacks from your animation (either through a call track or just by listening to the signals of the animation player / animation tree). This is mainly the case for purely visual things (e.g. UI animations) but there are also some use cases for quick time events. However these are usually very specific to the game and I'm having a really hard time coming up with a solution that is universally useful rather than catering only for a specific use case, like the suggested solution with the Next State flag. Also I really don't want to mix transitions with states which this implementation would do. If you want to keep this approach, I'd suggest you do it this way:

  1. write a small script which handles the animation finished event from the animation player and sends an event to the state chart that corresponds to the current anim_name, e.g.
func on_animation_player_animation_finished(anim_name:StringName):
    _state_chart.send_event("animation_finished_" + anim_name)
  1. then in your state chart just add transitions that correspond to the events:

image

That's 2 lines of code to get the specific behaviour you want and you can just use the built-in transitions to do the rest of the work. Would that be a workable solution for you?

shanayyy commented 1 month ago

Thank you for taking the time to write this, it's easy to understand and i agree with you. In fact I'm using your solution.It works really well and doesn't require much boilerplate code, as I've found that I only really need to create one animation_finished event for all animations.

image

I have tried all the state machine plugins in godot assetlib, and finally found that only this plugin really solved my problem, but I found that ExpressionGuard seemed too slow, so that Auto Transition sometimes missed the input check, so I now send input events every frame instead. This makes the condition state pattern less useful.

derkork commented 1 month ago

I'm not sure what you mean by "expression guard is too slow". A guard never triggers a transition it can only prevent a transition from happening. There are only two ways to trigger a transition:

  1. send an event
  2. change an expression property

Could you elaborate on your use case? Maybe there is a nice solution for that that doesn't require sending events every frame.

shanayyy commented 1 month ago

Haha, that's a typo. I mean "too slow". I drew a diagram to show what I want to do:

image
derkork commented 1 month ago

I don't think this setup will work because the resolver doesn't actually wait to resolve. When the animations finish the resolver state is entered and the resolver state will immediately walk through all the transitions. If you haven't pressed the key yet, it will not wait but just straight evaluate the other guards and pick the first one matching. One example (TI = Time index in milliseconds):

Or if you press the key slightly before the animation ends:

So this is not caused by the execution speed of the evaluations, they are always fully evaluated within a single frame.

shanayyy commented 1 month ago

What you describe is exactly how I would expect it to work. There are two places where I need to explain a little more.

TI=50 - attack is pressed, nothing happens anymore because we are already in idle.

If we are in idle, pressing attack will send Input Changed event, so we will jump to Resolver immediately.

they are always fully evaluated within a single frame.

I'm not sure about this, because I found that the delay of a single evaluation can exceed 6ms, so when there are multiple evaluations to be performed it may take more than 1 frame, but it may be that I'm doing it wrong. I will make a demo project later when I have time to see if I can reproduce this.