derkork / godot-statecharts

A state charts extension for Godot 4
MIT License
734 stars 35 forks source link

Properly polling input for _on_[state]_state_input signals #52

Closed Phanterm closed 10 months ago

Phanterm commented 10 months ago

I recorded a quick Youtube video that walks through the issue. More info below.

When I first installed this plugin, I made the mistake of putting all of my input polling in the physics_processing signals, which is not ideal for obvious reasons.

I've been going through the process (pun intended) of moving every oneshot-style input to a state's given _input signal, and any other inputs that need to be polled every frame into _process. I'm having issues where the state machine is performing its transitions correctly as it did before, but the behavior is now broken, with states entering and exiting so quickly that an animation is never fired, and states are re-entered almost immediately.


EXAMPLE

The Roll State

I have a state, Roll, where the player rolls. The animation tree is set to return to the Idle animation once the roll finishes, which is how we check to see if we should return to Idle:

func _on_roll_state_entered():
    _timer_roll.start()
    _animations.travel("Roll")
    print("Roll state entered.")

func _on_roll_state_physics_processing(delta):
    velocity = _dir_to_vector(direction) * roll_speed
    if _animations.get_current_node() != "Roll":
        _state_chart.send_event("idling")
        print("roll done")

And the transition: image


The Idle State

_on_idle_state_input(event)

func _on_idle_state_input(event):   
    if event.is_action_pressed("Evade") && _is_off_cooldown(_timer_roll):
        _state_chart.send_event("rolling")
        print("we roll now")

RESULT Fails. State processes transition, goes to Roll for one frame, returns to Idle immediately before an animation fires.

_on_idle_state_physics_processing(delta)

    if Input.is_action_just_pressed("Evade") && _is_off_cooldown(_timer_roll):
        _state_chart.send_event("rolling")
        print("we roll now")    

RESULT Works, but is physics_processing, so user input is at risk of being dropped.

_on_idle_state_processing(delta)

    if Input.is_action_just_pressed("Evade") && _is_off_cooldown(_timer_roll):
        _state_chart.send_event("rolling")
        print("we roll now")    

RESULT Fails. State processes transition, goes to Roll for one frame, returns to Idle immediately before an animation fires.


Processing input outside of FSM

What is strange is that I get a different result when I poll inputs outside of the state charts.

_input(event)

func  _input(event):    
    if event.is_action_pressed("Evade") && _is_off_cooldown(_timer_roll):
        _state_chart.send_event("rolling")
        print("we roll now")

RESULT Fails. State processes transition, goes to Roll for one frame, returns to Idle immediately before an animation fires.

_process(delta)

    if Input.is_action_just_pressed("Evade") && _is_off_cooldown(_timer_roll):
        _state_chart.send_event("rolling")
        print("we roll now")    

RESULT Works. State processing and behavior occurs as intended and expected.

I won't post about physics_processing, since that works in the state chart and isn't the solution I want anyway.


CONCLUSION

I'm not quite sure what is going on. I have a Jump state that also works just fine. I wanted to say that

    if _animations.get_current_node() != "Roll":
        _state_chart.send_event("idling")

was the culprit, because my Jump animation immediately sets a boolean which that state's physics_processing checks every frame of the jump (if you're grounded, go back to idle). And that works just fine in state_input. But this seems less likely when I can use the same logic in a generic _input function and it runs as expected.

I'm pretty lost at this point, and would love some help. Thanks in advance.

BadBoyGodot commented 10 months ago

Hi Phanterm!

The _process outside FSM works is because it fires multiple times when you pressed the key. derkork's statechart _input() and Godot's input() will fire only once on the press, thus it's very likely that when you use "_animations.travel("Roll")" in the roll state_entered of the state chart, the animation tree takes some frames to transit to "roll" so when you immediately check in roll state if "roll" is playing it returns "not playing".

I don't think checking animation if _animations.get_current_node() != "Roll": is a good idea to see if it finishes.

You may try connecting the animation_finished of your roll animation state in your logic handling script, and when roll finished, tell the statechart to go back to idle.

or have you tried derkork's Animation tree states?

Phanterm commented 10 months ago

I don't think checking animation if _animations.get_current_node() != "Roll": is a good idea to see if it finishes. You may try connecting the animation_finished of your roll animation state in your logic handling script, and when roll finished, tell the statechart to go back to idle.

Ahh I was afraid of that. Okay, it's easy enough to transition with an animation property function call that fires send_event(). Thanks!

or have you tried derkork's Animation tree states?

I started out with those, actually! derkork was the one who mentioned to me that it was more trouble than it was worth to use them after I had run into a few issues.😅

Phanterm commented 10 months ago

Okay, I confirmed that using if _animations.get_current_node() != "Roll": was definitely not the way to go. And everything that wasn't working was indeed using that method for polling for transitions. Thanks for your help!

derkork commented 10 months ago

I personally tend to think of animations as visual representations of the internal game state for the most part. That is to say, that control only ever flows into one direction - from the internal game state to the animation node.

E.g. in a fighting game the exact timings of each move have to be carefully designed to make the input feel nice and allow for precise timing. And the animations really are only secondary expressions of these timings. Which means that the game code will never wait on any animation to finish.

The game code controls the timings and will just play animations on certain events to give visual feedback to the player. And if you do things this way, then you really don't need any special magic built into the state machine. You just fire animations when needed, or even just use a blend tree to submit data to the animation nodes and the animation state machine does its own thing. No need to wait for any animation_finished event.

I think this approach is a lot less headache as you don't need a constant ping-pong and synchronization between your input and your animation. Plus you don't need to change your animation timings to correct input timings that feel off. You fire the animation, after 0.3 seconds you move out of the roll state using a timed transition and if the animation lenght differs from 0.3 well your visual feedback may be a bit off but that is something you can fix later once you dialed in all the timings so the game is fun to play. Until then the animations are just placeholders anyways.

What are your thoughts about this? Is there something I am missing/overlooking?

Phanterm commented 10 months ago

I think mainly I am very gun shy from having used Unity's animation tools for so many years and have been consistently let down by its inability to properly time events and property changes specifically on the first and last frames of an animation. However, the more I use Godot's tools, the more I realize that they Just Work™️ without needing do much thinking about it.

Here is how I now have the rolling set up. I've been using more and more function call tracks to fire send_event. They work consistently. image

The only reason why I wanted to initially avoid this is, of course, that changing the animation in any way also means having to reposition the appropriate calls (which is why I wanted to poll the animation state machine's current node instead). But if I were to rely only on a delay timer in-code, it would be the same issue if I adjusted an animation and had to reposition appropriate keyframes; the difference is that I'd be making the adjustment from a different place.

The problem with relying on the code (something like a delayed transition) is that it requires a lot of guess and check to get the timing just right. So if I hang on the end of an animation, it's nice for me to be able to control the exact frame which I want something to happen, especially if I'm also controlling animation properties in tandem with the state charts potentially firing a transition before the animations get there and having certain things not happen.


At the end of the day it seems like there are many paradigms one could consider (though thankfully Godot is by default more inclined to keep some guard rails in place compared to Unity, which I very much prefer).

I may change my tune as I continue development in this way, so we'll see. I'm just about to start implementing attacks and collisions, which means interrupting existing states and going to a universal "Hurt" state, so we'll see how that goes. :P

derkork commented 9 months ago

I see, thanks a lot for giving me some insight into your process. Calling send_event from an animation is actually a pretty neat trick!