derkork / godot-statecharts

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

Option to retry state transition on gate opening? #78

Closed WeaselOnaStick closed 5 months ago

WeaselOnaStick commented 6 months ago

I want to propose a feature for state transitions described in the title.

Basically, we have a transition with both an event and a closed gate. I want there to be a toggle that makes the transition happen when a gate opens even after the event has been sent. That is without sending the event again, only sending it once before the gate opens.

I think the most optimal way would be is for a gate to somehow hook to relevant expression properties -> re-evaluate and send an internal transition event if gate is satisfied. Feel free to let me know if there's an easy way around with current tools (I tried transition delay but it only evaluates the gate at the start, not after delay) and if my proposed feature has any practical value

derkork commented 6 months ago

I'm not sure I fully understand the problem but since the latest version 0.13.0 fully automatic transitions are actually possible. So you can make a transition which has an empty event property and just an expression guard. This transition will be evaluated under the following conditions:

If this is not what you need, could you maybe give a concrete example of what you are trying to implement?

WeaselOnaStick commented 6 months ago

I'll try to give a simple example. I have a character with following state chart:

And an animation tree that has path between tree states that makes the character stand up and sit down before doing looping Sit_Floor_Idle or Walking animations.

I have an event "start_walking" that is, (let's say) sent via button, that triggers both ToLookout and ToWalkingAnim transitions. But I also want for ToLookout to wait until the getting up animation is finished. (animation(s) between Sit_Floor_Idle and Walking states inside my tree)

Currently I work around that by sending a separate "anim_finished_getting_up" event, that's sent by animation tree signal. It works as intended, but I'm afraid this could get cluttered quickly.

If my proposed feature were to be implemented I could only need to check single expression property that holds current animation with something like this:

func _process(_delta):
    state_chart.set_expression_property("anim_cur",(animation_tree["parameters/playback"] as AnimationNodeStateMachinePlayback).get_current_node())

Have a gate on ToLookout transition with the "retry" feature enabled and with following expression:

anim_cur == "Walking"

and ToLookout would be triggered again once the animation is finished.

Another way to fix my issue, would be to have a toggle option (let's call it "check gate after delay") which would be available if transition delay is > 0. But I imagine that would be much less flexible, and much harder to implement.

I understand this might be a niche request, maybe better implemented just for animationtree states, but I can think of a few other examples where this feature might be useful. Once again, let me know if there's an easier way and if my proposal isn't practical enough.

derkork commented 6 months ago

The thing with the "retry" is that it would get in the way of some invariants that currently hold true (e.g. that only one transition can be pending at any given time and that a transition will never be taken if its guards are not evaluating to true), so this would open up the possibility for all kinds of edge cases. Also when you think about this, if your character is currently getting up or sitting down, he is neither in lookout nor resting state. So let's make it explicit:

image

Now you have a nice sequence of states. When the player gets up, it gets into "Getting Up state" which will trigger playing the animation and will go into Lookout state automatically after 1 second (or however long the animation takes). And for sitting down it pretty much works the same way. Also now you have the possibility to abort getting up/sitting down (i have added two more transitions "through the middle"). As an additional bonus you need no sync back from the animation player to your state chart.

WeaselOnaStick commented 6 months ago

Yeah I was mainly asking because I had experience in programming where "gates" meant slightly different mechanism, and I was wondering if it would make sense to have that behavior here.

I ended up changing Lookout(Atomic) state into a compound chain of states (getting_up→rotate_to_outpost→go_to_outpost→lookout→rotate_to_rest_place→go_to_rest_place) and it didn't make sense to hardcode animation length values (since most animations were procedural), so I just used the gate I mentioned.

It's just that I didn't want to add these intermediate states, because it's not obvious if they should be inside Lookout or Resting.

derkork commented 6 months ago

Hmm okay, I think i have an idea now how this could work. Basically we have two parts in a transition:

  1. the guard - this determines if the transition will be taken at all.
  2. the delay - this determines when the transition actually will be executed after it has been taken

Now the delay currently can only be a timer, but if there was an option to use an expression as a delay, this could nicely solve the issue without introducing a lot of edge cases (except for the migration headache as this would be a breaking change... ). So this would look like:

image

And in code:

# called when the unit should travel somewhere
func travel_to(target):
     _state_chart.set_expression_property("target", target)
     _state_chart.send_event("begin_travel")

func on_orienting_physics_processing(delta):
     # code here to orient the unit 
     ...
     if [unit is oriented]:
          _state_chart.send_event("oriented")

func on_moving_physics_processing(delta):
     # code here to move the unit where it needs to be
     ...
     if  [unit has arrived at target]:
         _state_chart.send_event("arrived")

What do you think?

WeaselOnaStick commented 6 months ago

Yeah, I think this makes much more sense than my initial approach. I think when I started I just looked at the platformer example where animation states and logic states are parallel/independent of each other and assumed that was the only right way to do it. Where as in my example animations do in fact affect the logic.

derkork commented 6 months ago

With the new automatic transitions, you can actually do this without any custom delays. If you write a bit of code like this:

@onready var _animation_player:AnimationPlayer = %AnimationPlayer
@onready var _state_chart:StateChart = %StateChart

func _ready():
    animation_player.animation_changed.connect(_on_animation_changed)

func _on_animation_changed(_old_animation:StringName, new_animation:StringName):
   # set the name of the currently running animation as expression property.
   _state_chart.set_expression_property("animation", new_animation)

And then you can use automatic transitions from Arriving at home , Arriving at lookout and Preparing for travel based on the current animation.