zkat / big-brain

Utility AI library for the Bevy game engine
Other
1.04k stars 62 forks source link

Need guide for real beginner #83

Closed ghost closed 1 year ago

ghost commented 1 year ago

Hi,

I want to try making a simulation game and I tried looking at the example with "thirst", but I don't really understand how all of that works. I think I'm lacking knowledge in AI to understand, does some people have recommendation of article or publication I could read to understand ?

Thanks

Joey92 commented 1 year ago

I also had to sit down for multiple hours to wrap my head around this. I also find the documentation lacking, but it has been a major reason for me to dive into this project tbh 😄 . Here's how I see it:

There is a Thinker that has a list of things it can do (actions), but it needs to know what the top priority (scorers) is for it to know what to do next. A scorer decides, with a number from 0 to 1, how important something is to do.

Then you have Pickers. They kinda give the Thinker a behavior on what to choose next:

Thinker::build()
        .picker(FirstToScore { threshold: 0.8 })

That one basically means: If a Scorer is above 0.8, do that. If not, ignore it. So if no Score is over 0.8, the AI basically does nothing.

Thinker::build()
        .picker(First)

This one chooses what to do based on any score above 0. It will do whatever has the highest score.

Scorers

In the examples there are Thirst and Thirsty or Hunger and Hungry, they kinda mean the same thing. But there can be something more complex which might make you understand it better:

For example: Money and Broke.

With Money being multiple things your Entity can have: Cash, BankBalance, FriendsThatCanHelpYouOut, Stocks ...etc and Broke being the scorer for the Thinker for it to evaluate what it should do based on the score on how Broke the entity is.

Maybe that's a bad example though.. What would an Action for that be, if the Thinker decided to execute on it? 😄 That said, a scorer does not necessarily have to track a single Component of an Entity.

Here are some of my scorers..

This one checks if it's time to go to work based on how many workers are currently working at its workplace. If it's unmanned, the score is higher:

#[derive(Clone, Component, Debug, ScorerBuilder)]
pub struct TimeToWork;

pub fn work_scorer_system(
    workers: Query<&Workplace>,
    workplaces: Query<&AvailableWorkers>,
    mut query: Query<(&Actor, &mut Score), With<TimeToWork>>,
) {
    for (Actor(actor), mut score) in query.iter_mut() {
        if let Ok(workplace) = workers.get(*actor) {
            // check workplace working times
            if let Ok(work) = workplaces.get(workplace.0) {
                score.set(1. - (work.current_workers / work.max_workers) as f32);
                continue;
            }

            score.set(0.);
        }
    }
}

This one checks if the inventory is low. The action could be to go to the store, hunt or go to some water source perhaps?

#[derive(Clone, Component, Debug, ScorerBuilder)]
pub struct InventoryLow;

pub fn inventory_scorer_system(
    inventories: Query<Ref<Inventory>>,
    mut query: Query<(&Actor, &mut Score), With<InventoryLow>>,
) {
    for (Actor(actor), mut score) in query.iter_mut() {
        if let Ok(inventory) = inventories.get(*actor) {
            if inventory.is_changed() || inventory.is_added() || score.is_added() {
                score.set(1. - (inventory.food + inventory.drink) as f32 * 0.1);
            }
        }
    }
}

To wrap it up:

let go_shopping = Steps::build()
        .label("GoToStoreAndShop")
        .step(FindClosestStore)
        .step(GoToDestination)
        .step(Shopping {
            time_to_buy: Timer::new(Duration::from_secs(1), TimerMode::Repeating),
        });

    let go_work = Steps::build()
        .label("GoWork")
        .step(GoWork)
        .step(GoToDestination)
        .step(Work {
            shift: Timer::new(Duration::from_secs(60), TimerMode::Once),
        });

Thinker::build()
        .label("PeopleThinker")
        .picker(Highest)
        .when(InventoryLow, go_shopping)
        .when(TimeToWork, go_work);
        // there is more stuff here but I didn't paste it here

GoToDestination is also an action that just goes to the location of a Destination component on the Actor and can be universally used. The GoWork and FindClosestStore are actions which set that component on the Actor and succeed instantly.

Actions

Once you have a Thinker with multiple Scorers and a First Picker, it will start executing Actions if the scores are above 0.

It will create a new Entity that tracks the progress, with a way to select the initiator (The Actor) via a separate Query. That way you can mutate the Entity you added the Thinker on.

Here is an action of mine that that is related to the work scorer above 😄

#[derive(Clone, Component, Debug, ActionBuilder)]
pub struct Work {
    pub shift: Timer,
}

pub fn work_action_system(
    time: Res<Time>,
    workers: Query<(&Name, &Workplace)>,
    mut workplaces: Query<(&mut AvailableWorkers, Option<&Name>), Without<Workplace>>,
    mut query: Query<(&Actor, &mut ActionState, &mut Work, &ActionSpan)>,
) {
    for (Actor(actor), mut state, mut work, span) in query.iter_mut() {
        let _guard = span.span().enter();

        let (actor_name, workplace) = workers
            .get(*actor)
            .expect("actor for action not found. Maybe it's missing some components we need?");

        let (mut available_workers, workplace_name) = workplaces
            .get_mut(workplace.0)
            .expect("Workplace not found");

        match *state {
            ActionState::Requested => {
                match workplace_name {
                    Some(name) => println!("{:?} starting work shift at {:?}..", actor_name, name),
                    None => println!("{:?} starting work shift..", actor_name),
                }

                available_workers.current_workers += 1;
                *state = ActionState::Executing;
            }
            ActionState::Executing | ActionState::Cancelled => {
                if !work.shift.tick(time.delta()).finished() {
                    continue;
                }

                work.shift.reset();
                println!("{:?} is done working.", actor_name);
                available_workers.current_workers -= 1;
                *state = ActionState::Success;
            }
            _ => {}
        }
    }
}

All it actually does is wait for a timer to pass and increases/decreases a counter. A separate system, outside of big-brain, handles the workplace output based on available workers.

Since the ActionState::Executing and ActionState::Cancelled are together, the Thinker will have to wait till the shift is over before being able to decide on what to do next 😄 I haven't yet figured out what would be a better way to handle the Cancelled step.. Maybe the shift can pause and continue later again. Hmm.. 🤔

Behavior

I often noticed actions being cancelled very often.. If some other scorer in the meantime got higher than the scorer of the action currently being executed, the Thinker might cancel the current action, since it's got more important things to do. I think it's often not necessary to cancel actions. To do this you can often match the executing and cancelled action state together like so:

ActionState::Executing | ActionState::Cancelled => {}

That way your action will continue until you set the state to Failure or Success.

I hope this helps you out a bit! I might edit this post later since it's late and I'm currently very tired 😄

zkat commented 1 year ago

I'm gonna close this as a duplicate of #9