idanarye / bevy-yoetz

Apache License 2.0
8 stars 2 forks source link

Better state handling #5

Open seivan opened 1 month ago

seivan commented 1 month ago

How do I describe that an Action is starting, running and failed? The failed part is a bit silly because Utility AI always rechecks scores to be dynamic, while e.g GOAP only does it when the when the actor_state or goal_state changed from an external source, but also if an action returns failed or replan state. But just bear with me for a second.

We need a way to describe starting, running, failing states for the Action components

Load sets things up, like adding a Movement component, while Unload could remove things. You could obviously do it in the Run state when it's Success but this makes it cleaner and useful for debugging purposes as well to have an unload.

    action_component.handle_task(|state| match state {
        ActionState::Load => {
            if let Some(enemy) = enemy {
                commands.entity(entity).insert(Movement { target: enemy });
                ActionResult::Success
            } else {
                ActionState::Failure
            }
        }
        ActionState::Run => {
            if let Some(movement) = movement_component.get(entity) {
              if (movement.is_done()) {
                  ActionResult::Success
              } 
              else {
                  ActionResult::Running
              }
            }
            else {
             ActionState::Failure;
            }
        }
        ActionState::Unload => {
            commands.entity(entity).remove::<Movement>();
            ActionResult::Success
        }
    });

The problem right now is, there is too much boilerplate expected to do banal stuff that the AI should have built in for. Handling state for the action in terms of progress isn't there, so you would have to add it for every single action. You would also have to look it up on every single YoetzAdvisor/Suggestion if there is a in progress task.

This can all be fixed by a lot of boilerplate but this should inherently be builtin. Not necessarily like this design I've shown here. Though I like it because it forces a return state on the programmer so you won't forget.

There are some other examples on issues that can occur because there isn't any state handling, but they're all related to the fact that there isn't a way to have a an in progress task associated with an action with a ton of boilerplate.

idanarye commented 1 month ago

What will Yoetz do with that state? Currently the Act systems don't decide when the action ends - it ends automatically when the suggest systems stop suggesting it, or when an action gets suggested with a higher score.

Since you've mentioned GOAP - do you mean that Yoetz should add the ability to register sequences of actions, where when one action ends its components get replaced by the next action in the sequence?

seivan commented 1 month ago

What will Yoetz do with that state? Currently the Act systems don't decide when the action ends - it ends automatically when the suggest systems stop suggesting it, or when an action gets suggested with a higher score.

When I say "no way", "not have" or "not possible" I mean it in a way that it's not without a ton of hand holding or boilerplate. These can be solved right now, but it's unnecessary boilerplate and since the problem domain is common, it's going to be a lot of it.

Right now there's a few consequences with not having a clear state:

Some of these can be remedied with the concept of a clearer action state rather than designing state keys as properties for the action itself.

Obviously they can be solved by writing a bunch of state code and inject it in all the suggestions to flag for no-op, or baby sitting scores by temporarily using f32::MAX for scores and etc, but it's wasteful and messes things up.

Earlier I mentioned register_component_hooks#hooks.on_remove but it doesn't tell you the reason the component was removed, so you have to code that yourself by adding a bunch of states so you can track it.

In your example code you have something like

fn enemies_follow_player(mut query: Query<(&EnemyBehaviorChase, &mut Transform)>, time: Res<Time>) {
    for (chase, mut transform) in query.iter_mut() {
        let Some(direction) = chase.vec_to_target.try_normalize() else {
            continue;
        };
        transform.translation += 5.0 * time.delta_seconds() * direction.extend(0.0);
    }
}

What if you have ally_follow_player? Or animal_follow_player? They will have their own behaviours and Yoetz suggestions.

Let say we revisit this in order to generalize movement and write a Movement component. This is a bit contrived but shows why you need it

fn enemies_follow_player(mut query: ...) {
 let target =  .. 
 let command =  ..
 let enemy = ..
 command(enemiy).insert(Movement {
                       target: player,
                       is_interruptible: true
                       cancel_distance: Some(10.0) // Default None, otherwise handled by movement
                    });
  // Assume other components are added: e.g sneaking, emoting, sounds and etc all very specific to EnemyBehaviourChase. 

}

So we've created a set of resources.

Where do we clean them up? When do we clean them up? Why do we clean them up?

 ActionState::Load => {
           // Safe programming... 
           // Assume it's zipped options because of the conditional checks. 
           .map(|(target, enemy)| enemy.insert(Movement {
                       target: target,
                       is_interruptible: true
                       cancel_distance: Some(10.0) // Default None, otherwise handled by movement
                    }))
           .map(|_| ActionResult::Success)
           .unwrap_or(ActionState::Failure)
        }
        ActionState::Run => {
            movement_components
            .get(entity)
            .map(|m| if m.is_done() { ActionResult::Success } else {ActionResult::Running } )
             .unwrap_or(ActionState::Failure)
        }
        ActionState::Unload => {
            movement_components
            .get(entity)
            .filter(|m| m.interruptible())
            .map(commands.entity.remove)
            .unwrap_or(ActionResult::Success)
        }

Since you've mentioned GOAP - do you mean that Yoetz should add the ability to register sequences of actions, where when one action ends its components get replaced by the next action in the sequence?

Not at all. That defeats the purpose of being reactive for 90% of the time. There are instances with the need for a long running non-interruptible sequences actions, these ideas will help. But it's not a "plan" and is outside of the scope of Yoetz.

If the user wants to leak outside the abstraction for a long running task they're gonna have to make sure that the suggestion responsible for this:

Doesn't even have to be for a "long plan". It could be as simple as for just Movement.

idanarye commented 1 month ago

So... this is actually two things, right?

  1. The handlers of the running action should be able to override Yoetz general decision making, to do things like ensuring it's not replaced before done or to explicitly switch to a followup action without having to go through the regular scoring mechanism.
  2. An action should be able to declare companion components (like Movement) that get automatically removed when the action is done (replaced by another action)
idanarye commented 1 month ago

I've created discussions for these two things: #9 and #10. Let's discuss there.