derkork / godot-statecharts

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

Sorry if this seems basic but I'm struggling #141

Closed MoogieOuttaMyDepth closed 3 weeks ago

MoogieOuttaMyDepth commented 3 weeks ago

Here's a pared down example of what I'm trying to do.

I have two states: "Passing Time" and "Seeking Food". Over time, a varaible "hunger":int increases. When "hunger" is over a certain threshold, I set_expression_property("mood", "hungry") to let the statechart know that we're not "mood == "fine" anymore.

Now I want to stop "Passing TIme" and do "Seeking Food" instead. What is the best, simplest method state tree to accomplish this?

Because I feel like I've tried everything to make this work and I can't, even after watching the tutorial video multiple times and reading the documentation and looking at the examples (which I only half-understand, it's all too abstract for me). Trust me, it's not through lack of effort. I've spent the past two days solid working on this simple thing.

Specifics:

Root <-- decide_mood() called here in _state_processing() to update "Mood" expression property and send event ("mood_decided")
|_Passing Time
  |_Idle <-- _on_state_entered() randomly sets Travelling destination if randi_range(0,1) == 1, otherwise sends "mood_decided" event
    |_To Idle <-- listens for "mood_decided" and has expression guard "mood" == "fine"
    |_On Next Destination (copied from ants example)
    |_To Seeking Food <-- listens for "mood_decided" and has expression guard "mood" == "hungry"
  |_Travelling (copied from ants example, assume same child nodes here)
|_Seeking Food <-- all this stuff works so not going to comment it
  |_Idle
    |_To Idle
    |_On Next Destination
    |_To Eating

Ok so, I've tried To Idle without the expression, I've tried it without the event, I've tried every combination of with/without. It either breaks the statechart in an infinite loop, or it gets stuck in Idle and never allows a different transition to happen, or it just plays Idle once and then never transitions to anything, even To Idle.

Why do I have To Idle? Because without it, Idle is never re-triggered and mood is never evaluated and no other transitions have a chance of happening.

So clearly, I'm doing it wrong, but this is the 4th re-do of this very basic foundation I'm trying to set up before doing anything more complex, and I'm honestly just getting frustrated with it. The more I mess with it, the more code I end up writing, and needing the statechart less and less because it's not doing anything for me. I want to move in the opposite direction, but I need better examples or something because it's just not sinking in and the provided examples are too complex. I thought the ants one would be good to model off of, but they don't have an "Idle" state and my hacky attempts to modify it to include "Idle" obviously aren't working.

I read somehwhere that setting an expression property is supposed to immediately re-evaluate them, but that doesn't seem to happen. Nothing happens when I set the expression property, and if I fire a generic "mood_decided" event to all the transitions, they don't seem to listen to their expression guards and just always pick To Idle no matter what mood is. And yes, I'm using the debugger and "mood" is definitely switching to "hungry" when it should, so that's not the problem.

Sorry, I don't know how to tag this as not a bug, just a question.

derkork commented 3 weeks ago

I'm not quite sure about your requirements yet. Before I can recommend any approach I would need to know exactly what it is we want to model. And for that let's maybe take a step back from the state machines and really just draw out what we want to happen.

Here's what I gathered so far:

  1. There is a unit which can move around.
  2. The unit can be hungry.
  3. If the unit is hungry it should look for food.
  4. If the unit is not hungry it should just idle around.
  5. If the unit is looking for food and found food it should eat the food.

From this, I can come up with this high level state diagram:

image

Now this alone wont help solve the problem just yet, because we still need to answer a few questions:

If we answer these questions we can use the information we gather from that to refine our state diagram. And then we can repeat this process until we have no more open questions and have a diagram which tells us exactly how our unit should behave. And only then we start implementation. I found this way to be a lot less frustrating because it makes me think about my problem and understand it really good before getting down into the nitty-gritty of the implementation. So maybe you can help fill in the blanks on these questions and then we can find a nice solution for the problem.

MoogieOuttaMyDepth commented 3 weeks ago

Thank you, I really appreciate the help and I hope it's not too bothersome for you to entertain what is essentially just a newbie developer asking game design questions. With that in mind I don't want to take up a lot of your time so I will keep it to a few questions that should hopefully get me on a foundation I can build out from on my own.

Okay so, during Idle, it should do one of several things:

The first couple of questions arise here: Is state_entered a bad choice for this? Should I be thinking about using _processing instead? Because one of the major issues I keep running into is how do I get it to wander idly sometimes if it's in Idle and Idle never gets retriggered? (That's when I started trying to use a To Idle transition with a delay, and that caused more problems)

Then we have Seeking Food:

The other issue is, I'm hoping to avoid needlessly bloating the state chart with many many copies of "walking" or "going somewhere" nodes, so I thought to separate this out into its own "Travelling" state. But that means that when its mood changes to "Seeking Food" for instance, it then has to immediately switch into "Travelling", but if this "Travelling" is generic and is going to have a multitude of "To [mood]" transitions all listening to a single event "navigation_finished", the transitions always seem to ignore their state guards (like when I check for "mood" to be "hunger" or "fine") and always picks the first transition (i.e. back To Idle) and the logic tends to break.

So is having a generic Travelling state not the way to go? If not, that prompts me to ask: If this creature ends up with 5+ different moods, any number of which may need to travel to a target first, am I going to need them all to have their own travel nodes?

The more I thought about this, the more I felt moods should not be states, and instead my states should be limited to actual "I'm doing something" concepts, agnostic of whatever mood it's in. For example, "I'm fine and not doing anything" vs. "I'm hungry and not doing anything" should both just be "Idle". Similarly, "I'm fine and wandering somewhere" vs. "I'm hungry and seeking food" should both be "Travelling", and the statechart doesn't care what the target object or position is.

That seemed like a good idea at first, but then I started to realise I'm inching back towards "everything handled in code" territory, and the statechart becomes less and less useful. I stop setting expression properties and just trigger states based on mood, and that's all handled in code, and the chart ends up doing nothing for me. I want to move in the opposite direction, exactly what this addon was developed for, to handle the logic of switching to appropriate states so I don't have to be telling it explicitly every time.

That's where I'm at right now. Again, I'm sorry to bother you with this, and I have no expectations or demands to be helped, but I am truly appreciative that you responded kindly and offered to help. If there is a better place to ask about all this, I'll happily go there and you can close this thread. Thank you again!

derkork commented 3 weeks ago

Well no need to be sorry for anything these are all interesting questions to raise. So after reading through it, this is a first draft of what might work in your case:

image

As you rightly point out we would get a bit of a state explosion if every "mood" had its copy of the travel tree. But since we have state charts here, we can factor this out into a parallel state which does its own thing. So traveling now is a subsystem that can be incorporated into other systems. We do this by using events to signal our needs. So whenever a subsystem needs to travel somewhere, it will acquire the travel target (which is depending on the use case, e.g. the Foraging system would acquire the next food item as target ,while the Idle system would just pick a random point nearby) and send the target_acquired event. Then the travel subsystem will handle the movement towards the target and in turn send a target_reached event when it has reached the target. This way we don't need any direct connections between the systems, because events are propagated into all parallel states - so if we send a target_acquired or target_reached event, all parallel states will see it and can react independently to it.

With this set up, we can create the rest of the behaviour. We have an Idle state. When this is entered it will randomly pick one of three actions (we can use two transitions with an expression guard randf() <= 0.33 and a third unconditional transition, which is taken when none of the others is taken) :

  1. Do nothing. This will return back to the idle state after some random amount of time (no code needed, can be done with the state chart's built-in timed transitions).
  2. Play an idle animation. There needs to be some code playing the animation when the state is entered and the animation player will need to send an event when it is finished so we can go back to idle.
  3. Wander around. This state would have code on state_entered which picks a random target and sets this as the current target (in some variable in the code). It would then also call send_event("target_acquired") so our movement system can start walking towards the target. When the movement system is done it will send the target_reached event which will in turn leave this state and we're back at picking an idle option.

Now for the foraging. We said there should be some hunger system that triggers foraging behaviour. I have modeled this as another parallel "state" below. On state_enter there is code that will increase the hunger amount and then send the hungry event if the hunger is above a certain level. Then it will just sleep for a few seconds. This doesn't necessarily need to be a state it can also be done with a periodic timer.

Now whenever we're in some idle state and the hungry signal is sent, we change to the Foraging state. There the objective is to get some food, so we enter Acquire target state. This works very similarly to the wandering around we had in idle, the only difference is that this time the code on state_entered would try to pick a nearby food location instead of a random target. If no suitable location is found, then the code just sends the done event and we`re back in idle.

If a location is found the code sets it as current target and then sends the target_acquired event. This will trigger the Movement system to walk towards the target and also move the Foraging system into the Move to target state. In this state the Foraging system will simply wait until the Movement system has sent the target_reached event, so no code is needed here. Once we have arrived the Foraging system now switches to Eat food which has a state_enter which will pick up and consume the now nearby food and decrease the hunger accordingly. After the food is eaten, this sends the done event and we're back in idle. If the food was not enough or we simply didn't find any food, our hunger subsystem will find it out in 5 seconds and send another "hungry" event, so again we don't need to add a ton of connections to our state chart.

And now this can be extended with all kinds of other behaviours. The division of work between code and state chart is that our code controls what happens while our state chart controls when it happens. So all our code does is to pick targets, play animations, increase hunger, move towards a given target and signals important events like hungry, target_acquired, target_reached, etc. to the state chart. Our state chart in turn is in charge of picking the right action to do based on the events the code is sending.

I hope this is somewhat helpful. Please feel free to ask additional questions, it's always very interesting and useful for me to get a peek into how other people approach their projects. This helps a lot in designing the library and writing useful documentation.

MoogieOuttaMyDepth commented 3 weeks ago

This is incredibly helpful, thank you so much for taking the time. I'll read through this a few times and experiment with it, and will let you know how I get on with it. I think I should be able to learn enough from this to expand it further without additional help, but if I run into any specific issues, I'll ask about them.

You are awesome :)

MoogieOuttaMyDepth commented 3 weeks ago

What do I do about transitions being triggered multiple times in the same frame? I send the "target_acquired" event to set the Travelling parallel state going, and while this works, my wander() function is being called two, three, sometimes ten times in a single frame before the statechart decides to take the event seriously and actually do the thing.

Edit: I think I fixed it. I removed all the transitions from the main Idle state, and I also had to set a 0.1 delay on the transitions within each sub-state. So my Idle tree now looks like this:

Idle
|_Do Nothing
  |_To Interact (i.e. eat) (listens for mood_changed event)
  |_To Idle (couldn't have this directly under Idle or it spammed itself) (randf() < 0.5 guard)
  |_To Wander (0.1 delay was necessary to prevent spam)
|_Wander
  |_Target Reached (listens for event)

Another lesson I believe I've picked up correctly is that the order of transitions is very important, moreso than any of the documentation I've read would suggest. I had to make sure To Interact was at the top, otherwise it was always ignored in favour of one of the other transitions.

derkork commented 3 weeks ago

The idea is to have a handler that is called on state_enter for the Walk around state. This handler will pick a target and then send a target_acquired event. After this the state chart stays in the Walk around state until the travelling system sends a target_reached. So your method would only be called once.

MoogieOuttaMyDepth commented 3 weeks ago

That's what I had, but I had a print in the handler to tell me when a target was being acquired and it was triggering multiple times per frame before it would decide to actually start moving to the target.

derkork commented 3 weeks ago

Let me build this up here locally, maybe there is a bug...

derkork commented 3 weeks ago

Ok I definitely just found a problem here: https://github.com/derkork/godot-statecharts/issues/143

As a workaround, you can delay sending the event by one frame, e.g. state_chart.send_event.call_deferred("target_acquired").

MoogieOuttaMyDepth commented 3 weeks ago

That's good to know. I'm actually quite glad this is a bug and not just me being a dummy, lol! It looked like it should have worked and yet wasn't, I couldn't figure out what I was missing.

derkork commented 3 weeks ago

I can't seem to reproduce that. Could you maybe open another issue for this and give me some details on the setup that is triggering this behaviour? Thanks a lot!

MoogieOuttaMyDepth commented 3 weeks ago

Apologies, I deleted that comment as I was mistaken. There's no problem.

MoogieOuttaMyDepth commented 3 weeks ago

Is this not supposed to work? image All of those transitions specify events, except the last one which has a "randf() < 0.50" expression guard.

It was my understanding from your previous advice that I could have transitions outside of the specific sub-states and they would propogate, so for example, regardless of if I'm inside Idle00 or Idle01, if an event "target_acquired" was received, any of these animation states would transition to "Walk".

Instead, this just completely breaks the entire behaviour. Not only does it not play animations, but it doesn't even play the behaviours (which doesn't even make sense, the animation system is a parallel state...)

I put the transitions back into their sub-states and the behaviour works again. But this means once again having to bloat the tree with masses of duplicate transitions... I was hoping for a cleaner solution.

derkork commented 3 weeks ago

The last entry in your transitions list (Idle01 Variant) is probably not going to work the way you intend it to work. I assume you want to pick a random idle animation, so you have added an automatic transition that randomly transitions to the idle variant. However automatic transitions are only triggered when the state that contains them is entered or an expression property is set. Since your Animations state is below a parallel state it will always be active so this transition will never be run again on state enter because you will not enter the Animations state again. You can model this with another compound state that wraps your idle variants:

image

And the you transition to your Idle compound state which in turn will pick a random variant of Idle00 or Idle01.

Also the automatic transition at the end will always evaluate when you set an expression property which probably is the reason why your animations seem to be broken. Whenever you set an expression property there is a 50% chance that your whole animation subtree transitions to the Idle01 state. If you do this every frame then this effectively breaks the whole animation subtree. With the new structure it will only break the idle subtree. To fix this problem I'd recommend making picking the idle animation an explicit event that is sent when the idle state is entered, e.g. like this:

image

So this way when you enter idle state the pick_idle event is sent and the transition that randomly picks the idle animation will only be triggered by this event and not every expression property change.

MoogieOuttaMyDepth commented 3 weeks ago

Hey again. I'm going to close this issue, as I don't think I'm going to be continuing to use the addon for the time being.

After spending several days with it, I do feel as though I fairly understand how to use it. But I have several usability issues with it that I feel make creating behaviours more convoluted and bothersome than they should be. If you check the Youtube video, I have a comment there (user MoogieSRO) that I've edited to give my feedback thoughts, which you may or may not find useful. I don't think there's necessarily anything wrong with the addon, but it's not fitting my needs, and I think I need to find a different solution to work with. But maybe some of the notes I made could be useful for improving the addon.

I may come back to the addon at some point, if exploring other options doesn't yield anything better, but for now I wish you luck with continuing the development of the addon. Thanks again for all the time you took to handhold me and explaining the concepts. This time was not wasted on me, as it's helped me expand my knowledge on the subject of state-based mechanics in general, which is going to help me no matter what solution I settle with. I found everything you explained in this thread enlightening and helpful.

derkork commented 2 weeks ago

Sure, if it isn't a good fit for your use case there is really no point in using it. Thanks for sharing your insights!