Skip to content

Commit

Permalink
Accept systems as triggers
Browse files Browse the repository at this point in the history
  • Loading branch information
Seldom-SE committed Jan 13, 2024
1 parent f77ccad commit dec8a58
Show file tree
Hide file tree
Showing 9 changed files with 556 additions and 724 deletions.
38 changes: 22 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ and other entities that occupy various states. It allows for greater reusability
between entities, compared to managing mutually-exclusive components directly in your systems.

A *state* is a component attached to an entity that defines its current behavior, such as `Jumping`
or `Stunned`. A *trigger* is a type that checks information about entities in the world, such as
`NearPosition` or `HealthBelowThreshold`. A *transition* links two states: one to transition from,
and one to transition to; once a given trigger has occurred. A *state machine* is a component
or `Stunned`. A *trigger* is a system that checks information about entities in the world, such as
`near_position` or `health_below_threshold`. A *transition* links two states: one to transition
from, and one to transition to; once a given trigger has occurred. A *state machine* is a component
attached to an entity that keeps track of that entity's transitions, and automatically changes the
entity's state according to those transitions.

Expand Down Expand Up @@ -41,30 +41,36 @@ improvement, feel free to submit an issue or pr!
## Features

- State machine component with user-defined states and triggers
- 15 built-in triggers
- `AlwaysTrigger`: always triggers
- 30 built-in triggers
- `always`: always triggers
- `NotTrigger`, `AndTrigger`, and `OrTrigger`: combines triggers with boolean logic
- `DoneTrigger`: triggers when the user adds the `Done` component to the entity
- 9 more triggers enabled by the `leafwing_input` feature: `ValueTrigger`,
`ClampedValueTrigger`, `AxisPairTrigger`, `ClampedAxisPairTrigger`, `JustPressedTrigger`,
`PressedTrigger`, `JustReleasedTrigger`, `ReleasedTrigger`, and `ActionDataTrigger`
- `EventTrigger`: triggers when it reads an event of the given type
- `done`: triggers when the `Done` component is added to the entity
- 24 more triggers enabled by the `leafwing_input` feature: `action_data`, `axis_pair`,
`axis_pair_length_bounds`, `axis_pair_max_length`, `axis_pair_min_length`,
`axis_pair_rotation_bounds`, `axis_pair_unbounded`, `clamped_axis_pair`,
`clamped_axis_pair_length_bounds`, `clamped_axis_pair_max_length`,
`clamped_axis_pair_min_length`, `clamped_axis_pair_rotation_bounds`,
`clamped_axis_pair_unbounded`, `clamped_value`, `clamped_value_max`, `clamped_value_min`,
`clamped_value_unbounded`, `just_pressed`, `just_released`, `pressed`, `value`, `value_max`,
`value_min`, and `value_unbounded`
- `on_event`: triggers when it reads an event of the given type
- `AnyState` state, that can be used in type parameters to represent any state
- Transition builders that allow dataflow from outgoing states and triggers to incoming states
(`StateMachine::trans_builder`)
- Automatically run events upon entering or exiting states (`StateMachine::on_enter`,
- Automatically perform behavior upon entering or exiting states (`StateMachine::on_enter`,
`StateMachine::on_exit`, `StateMachine::command_on_enter` and `StateMachine::command_on_exit`)

## Comparison with [`big-brain`](https://github.com/zkat/big-brain)

Finite state machine is an old and well-worn pattern in game AI, so its strengths and limitations
are known. It is good for entities that:

1. Do not have a large number of interconnected states, since the number of transitions can grow
1. Do not have a huge number of interconnected states, since the number of transitions can grow
quadratically. Then it becomes easy to forget to add a transition, causing difficult bugs.
2. Act rigidly, like the enemies in Spelunky, who act according to clear triggers such as
got-jumped-on-by-player and waited-for-5-seconds, and unlike the dwarves in Dwarf Fortress, who
weigh their options of what to do before taking an action.
got-jumped-on-by-player and waited-for-5-seconds and are predictable to the player, and unlike the
dwarves in Dwarf Fortress, who weigh their options of what to do before taking an action and feel
lively.

`seldom_state` is a finite state machine implementation, so it may not be suitable for all types of
game AI. If you need a solution that works with more complex states and transitions, then you may
Expand Down Expand Up @@ -105,8 +111,8 @@ Consider a 2D platformer, where the player has a sword. The player can run and j
can swing the sword. So whether you're running, jumping, or dashing, you always swing the sword the
same way, independently of movement state. In this case, you might want to have a movement state
machine and an attack state machine. Since entities can only have one state machine, spawn another
entity with its own state machine, and capture the original `Entity` in closures in
`command_on_enter` and `command_on_exit`.
entity (as a child, I would suggest) with its own state machine, and capture the original `Entity`
in closures in `command_on_enter` and `command_on_exit`.

However, perhaps your states are not so independent. Maybe attacking while dashing puts the player
in a `PowerAttack` state, or the attack cooldown doesn't count down while moving. Depending on the
Expand Down
104 changes: 48 additions & 56 deletions examples/chase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,23 @@ fn init(mut commands: Commands, asset_server: Res<AssetServer>) {
))
.id();

// Since we use this trigger twice, let's declare it out here so we can reuse it
let near_player = Near {
target: player,
range: 300.,
// This is our trigger, which is a Bevy system that returns a `bool`, `Option`, or `Result`. We
// define the trigger as a closure within this function so it can use variables in the scope
// (namely, `player`). For the sake of example, we also define this trigger as an external
// function later.
let near_player = move |In(entity): In<Entity>, transforms: Query<&Transform>| {
let distance = transforms
.get(player)
.unwrap()
.translation
.truncate()
.distance(transforms.get(entity).unwrap().translation.truncate());

// Check whether the target is within range. If it is, return `Ok` to trigger!
match distance <= 300. {
true => Ok(distance),
false => Err(distance),
}
};

// The enemy
Expand All @@ -45,8 +58,8 @@ fn init(mut commands: Commands, asset_server: Res<AssetServer>) {
// priority, but triggers after the first accepted one may still be checked.
StateMachine::default()
// Add a transition. When they're in `Idle` state, and the `near_player` trigger occurs,
// switch to that instance of the `Follow` state
.trans::<Idle>(
// switch to this instance of the `Follow` state
.trans::<Idle, _>(
near_player,
// Transitions accept specific instances of states
Follow {
Expand All @@ -57,70 +70,26 @@ fn init(mut commands: Commands, asset_server: Res<AssetServer>) {
// Add a second transition. When they're in the `Follow` state, and the `near_player`
// trigger does not occur, switch to the `Idle` state. `.not()` is a combinator that
// negates the trigger. `.and(other)` and `.or(other)` also exist.
.trans::<Follow>(near_player.not(), Idle)
.trans::<Follow, _>(near_player.not(), Idle)
// Enable transition logging
.set_trans_logging(true),
// The initial state is `Idle`
Idle,
));
}

// Let's define our trigger!. `Clone` and `Copy` are not necessary, but it's nicer to do so here.

// This trigger checks if the entity is within the the given range of the target
#[derive(Clone, Copy)]
struct Near {
target: Entity,
range: f32,
}

// Also see `OptionTrigger` and `BoolTrigger`
impl Trigger for Near {
// Put the parameters that your trigger needs here. `Param` is read-only; you may not access
// system params that write to the `World`. `Time` is included here to demonstrate how to get
// multiple system params.
type Param<'w, 's> = (Query<'w, 's, &'static Transform>, Res<'w, Time>);
// These types are used by transition builders, for dataflow from triggers to transitions. See
// `StateMachine::trans_builder`
type Ok = f32;
type Err = f32;

// This function checks if the given entity should trigger. It runs once per potential
// transition for each entity that is in a state that can transition on this trigger. return
// `Ok` to trigger or `Err` to not trigger.
fn trigger(
&self,
entity: Entity,
(transforms, _time): Self::Param<'_, '_>,
) -> Result<f32, f32> {
// Find the distance between the target and this entity
let distance = transforms
.get(self.target)
.unwrap()
.translation
.truncate()
.distance(transforms.get(entity).unwrap().translation.truncate());

// Check whether the target is within range. If it is, return `Ok` to trigger!
match distance <= self.range {
true => Ok(distance),
false => Err(distance),
}
}
}

// Now let's define our states! States must implement `Component` and `Clone`. `MachineState` is
// Now let's define our states! States must implement `Component` and `Clone`. `EntityState` is
// implemented automatically for valid states. Feel free to mutate/insert/remove states manually,
// but don't put it in multiple or zero states, else it will panic. Manually inserted/removed
// states will not trigger `on_enter`/`on_exit` events registered to the `StateMachine`.
// but don't put your entity in multiple or zero states, else it will panic. Manually inserted/
// removed states will not trigger `on_enter`/`on_exit` events registered to the `StateMachine`.

// Entities in the `Idle` state do nothing
#[derive(Clone, Component, Reflect)]
#[derive(Clone, Component)]
#[component(storage = "SparseSet")]
struct Idle;

// Entities in the `Follow` state move toward the given entity at the given speed
#[derive(Clone, Component, Reflect)]
#[derive(Clone, Component)]
#[component(storage = "SparseSet")]
struct Follow {
target: Entity,
Expand All @@ -147,6 +116,29 @@ fn follow(
}
}

// For the sake of example, this is a function that returns the `near_player` trigger from before.
// This may be useful so that triggers that accept case-by-case values may be used across the
// codebase. Triggers that don't need to accept any values from local code may be defined as normal
// Bevy systems (see the `done` example). Also consider implementing the `Trigger` trait directly.
#[allow(dead_code)]
fn near(target: Entity) -> impl Trigger<Out = Result<f32, f32>> {
(move |In(entity): In<Entity>, transforms: Query<&Transform>| {
let distance = transforms
.get(target)
.unwrap()
.translation
.truncate()
.distance(transforms.get(entity).unwrap().translation.truncate());

// Check whether the target is within range. If it is, return `Ok` to trigger!
match distance <= 300. {
true => Ok(distance),
false => Err(distance),
}
})
.into_trigger()
}

// The code after this comment is not related to `seldom_state`. It's just player movement.

#[derive(Component)]
Expand Down
28 changes: 10 additions & 18 deletions examples/done.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,25 @@ fn init(mut commands: Commands, asset_server: Res<AssetServer>) {
Player,
StateMachine::default()
// When the player clicks, go there
.trans_builder(Click, |_: &AnyState, pos| {
.trans_builder(click, |_: &AnyState, pos| {
Some(GoToSelection {
speed: 200.,
target: pos,
})
})
// `DoneTrigger` triggers when the `Done` component is added to the entity. When they're
// done going to the selection, idle.
.trans::<GoToSelection>(DoneTrigger::Success, Idle)
// `done` triggers when the `Done` component is added to the entity. When they're done
// going to the selection, idle.
.trans::<GoToSelection, _>(done(Some(Done::Success)), Idle)
.set_trans_logging(true),
Idle,
));
}

#[derive(Clone, Reflect)]
struct Click;

impl OptionTrigger for Click {
type Param<'w, 's> = (Res<'w, Input<MouseButton>>, Res<'w, CursorPosition>);
type Some = Vec2;

fn trigger(&self, _: Entity, (mouse, cursor_position): Self::Param<'_, '_>) -> Option<Vec2> {
mouse
.just_pressed(MouseButton::Left)
.then_some(())
.and(**cursor_position)
}
fn click(mouse: Res<Input<MouseButton>>, cursor_position: Res<CursorPosition>) -> Option<Vec2> {
mouse
.just_pressed(MouseButton::Left)
.then_some(())
.and(**cursor_position)
}

#[derive(Clone, Component, Reflect)]
Expand All @@ -77,7 +69,7 @@ fn go_to_target(
if movement.length() > delta.length() {
transform.translation = target.extend(transform.translation.z);
// The player has reached the target! Add the `Done` component to the player, causing
// `DoneTrigger` to trigger. It will be automatically removed later this frame.
// `done` to trigger. It will be automatically removed later this frame.
commands.entity(entity).insert(Done::Success);
info!("Done!")
} else {
Expand Down
38 changes: 14 additions & 24 deletions examples/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,45 +46,35 @@ fn init(mut commands: Commands, asset_server: Res<AssetServer>) {
// in Castlevania and Celeste, and the attacks in a fighting game.
StateMachine::default()
// Whenever the player presses jump, jump
.trans::<Grounded>(
JustPressedTrigger(Action::Jump),
.trans::<Grounded, _>(
just_pressed(Action::Jump),
Falling {
velocity: JUMP_VELOCITY,
},
)
// When the player hits the ground, idle
.trans::<Falling>(GroundedTrigger, Grounded::Idle)
.trans::<Falling, _>(grounded, Grounded::Idle)
// When the player is grounded, set their movement direction
.trans_builder(
ValueTrigger::unbounded(Action::Move),
|_: &Grounded, value| {
Some(match value {
value if value > 0.5 => Grounded::Right,
value if value < -0.5 => Grounded::Left,
_ => Grounded::Idle,
})
},
),
.trans_builder(value_unbounded(Action::Move), |_: &Grounded, value| {
Some(match value {
value if value > 0.5 => Grounded::Right,
value if value < -0.5 => Grounded::Left,
_ => Grounded::Idle,
})
}),
Grounded::Idle,
));
}

#[derive(Actionlike, Clone, Reflect)]
#[derive(Actionlike, Clone, Eq, Hash, PartialEq, Reflect)]
enum Action {
Move,
Jump,
}

#[derive(Reflect)]
struct GroundedTrigger;

impl BoolTrigger for GroundedTrigger {
type Param<'w, 's> = Query<'w, 's, (&'static Transform, &'static Falling)>;

fn trigger(&self, entity: Entity, fallings: Self::Param<'_, '_>) -> bool {
let (transform, falling) = fallings.get(entity).unwrap();
transform.translation.y <= 0. && falling.velocity <= 0.
}
fn grounded(In(entity): In<Entity>, fallings: Query<(&Transform, &Falling)>) -> bool {
let (transform, falling) = fallings.get(entity).unwrap();
transform.translation.y <= 0. && falling.velocity <= 0.
}

#[derive(Clone, Copy, Component, Reflect)]
Expand Down
16 changes: 9 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,19 @@ pub mod prelude {

#[cfg(feature = "leafwing_input")]
pub use crate::trigger::{
ActionDataTrigger, AxisPairTrigger, ClampedAxisPairTrigger, ClampedValueTrigger,
JustPressedTrigger, JustReleasedTrigger, PressedTrigger, ReleasedTrigger, ValueTrigger,
action_data, axis_pair, axis_pair_length_bounds, axis_pair_max_length,
axis_pair_min_length, axis_pair_rotation_bounds, axis_pair_unbounded, clamped_axis_pair,
clamped_axis_pair_length_bounds, clamped_axis_pair_max_length,
clamped_axis_pair_min_length, clamped_axis_pair_rotation_bounds,
clamped_axis_pair_unbounded, clamped_value, clamped_value_max, clamped_value_min,
clamped_value_unbounded, just_pressed, just_released, pressed, value, value_max, value_min,
value_unbounded,
};
pub use crate::{
machine::StateMachine,
state::{AnyState, MachineState},
state::{AnyState, EntityState},
state_machine_plugin,
trigger::{
AlwaysTrigger, BoolTrigger, Done, DoneTrigger, EventTrigger, Never, OptionTrigger,
Trigger,
},
trigger::{always, done, on_event, Done, IntoTrigger, Never, Trigger},
StateMachinePlugin,
};
}
Loading

0 comments on commit dec8a58

Please sign in to comment.