State Machine
State machines are one of the basic tools for organizing your code. States allow you to switch between different behaviors, values, and setup/teardown logic based on user-defined conditions. A state machine is made up of three main components:
- States
- Triggers
- Groups
A state machine is always in exactly one state at a time, and will transition between states when certain conditions (defined by the triggers) are met. State groups are a convenient way to bundle shared logic between multiple states, but the groups are not states themselves. They provide much of the same API as a state, but distribute behavior and triggers across all of their substates.
You can add a state machine to your custom component using the stateMachine
property in the component's definition. It must be a StateMachineDefiner
or StateMachineDefinition
. These state machines exist on each entity with the component, and will be automatically created and destroyed when the object is added to and removed from the world.
ecs.registerComponent({
...
stateMachine: ({world, eid}) => {
// Define states here
},
})
You can also create and manage an independent state machine by calling the ecs.createStateMachine
function directly:
const machineId = ecs.createMachine(world, eid, machineDef)
...
ecs.deleteStateMachine(world, machineId)
A state machine is a combination of states and the logic that transitions between those states. They are typically defined within a StateMachineDefiner
function that is called when the machine is created. Here is an example of a state machine to move an object back and forth until it hits something.
const BackAndForth = ecs.registerComponent({
name: 'back-and-forth',
schema: {
forthX: ecs.f32,
forthY: ecs.f32,
forthZ: ecs.f32,
duration: ecs.f32,
},
stateMachine: ({world, eid, schemaAttribute}) => {
const position = {...ecs.Position.get(world, eid)}
const backX = position.x
const backY = position.y
const backZ = position.z
const {forthX, forthY, forthZ, duration} = schemaAttribute.get(eid)
const waitBeforeBack = ecs.defineState('waitBeforeBack').wait(1000, 'back')
const back = ecs.defineState('back').initial()
.onEvent('position-animation-complete', 'waitBeforeForth')
.onEnter(() => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: forthX,
toY: forthY,
toZ: forthZ,
duration,
})
})
const waitbeforeForth = ecs.defineState('waitBeforeForth').wait(1000, 'forth')
const forth = ecs.defineState('forth')
.onEvent('position-animation-complete', waitBeforeBack)
.onEnter(() => {
ecs.PositionAnimation.set(world, eid, {
autoFrom: true
toX: backX,
toY: backY,
toZ: backZ,
duration,
})
})
const stopped = ecs.defineState('stopped').onEnter(() => {
ecs.PositionAnimation.remove(world, eid)
})
ecs.defineStateGroup([back, forth]).onEvent(ecs.Physics.COLLISION_START_EVENT, stopped)
},
})
Here is another example of a machine of changing a character state:
const Character = ecs.registerComponent({
name: 'character',
schema: {
walkingSpeed: ecs.f32,
runningSpeed: ecs.f32,
staminaRate: ecs.f32,
jumpForce: ecs,f32,
},
stateMachine: ({world, eid, schemaAttribute}) => {
const idle = ecs.defineState('idle').initial()
const walking = ecs.defineState('walking')
const running = ecs.defineState('running')
const airborne = ecs.defineState('airborne')
const moving = ecs.defineStateGroup([walking, running])
const canJump = ecs.defineStateGroup([idle, walking, running])
const recovering = ecs.defineStateGroup([idle, walking])
const all = ecs.defineStateGroup()
const startMoving = ecs.defineTrigger()
const stopMoving = ecs.defineTrigger()
idle.onTrigger(startMoving, walking)
.onTick(() => {
if (world.input.getAction('forward')) {
startMoving.toggle()
}
})
moving.onTrigger(stopMoving, idle)
.onTick((currentState) => {
const {walkingSpeed, runningSpeed} = schemaAttrbiute.get(eid)
let speed = currentState === 'running' ? runningSpeed : walkingSpeed
ecs.physics.applyForce(world, eid, 0, 0, speed)
if (!world.input.getAction('forward')) {
stopMoving.toggle()
}
})
walking.onEvent('toggle-sprint', running)
running.onEvent('toggle-sprint', walking)
canJump.onEvent('jump', airborne)
airborne.onEnter(() => ecs.physics.applyForce(world, eid, 0, schemaAttribute.get(eid).jumpForce, 0))
.onEvent(ecs.Physics.COLLISION_START_EVENT, walking, {where: () => world.input.getAction('forward')})
.onEvent(ecs.Physics.COLLISION_START_EVENT, idle)
let stamina = 1.0
const updateStamina = (add: boolean) => {
const diff = schemaAttribute.get(eid).staminaRate * world.time.delta
stamina += add ? diff : -diff
}
running.onTick(() => updateStamina(false))
recovering.onTick(() => updateStamina(true))
all.listen(eid, 'powerup-collected', () => stamina = 1.0)
},
})
StateMachineDefiner
A StateMachineDefiner
is a function that creates a state machine and all of its parts. Inside the function you define states and groups, as well as locally scoped variables and functions to use in them. It can be passed into a component's stateMachine
prop and will be called when an entity is added to the world. Alternatively you can directly pass a StateMachineDefinition
, which is outlined below.
The StateMachineDefiner
function accepts an object with the following properties:
Key | Type | Description |
---|---|---|
world | World | The world the machine is created in |
eid | Eid | The entity owner of the machine |
schemaAttribute | WorldAttribute | Access to the component's schema |
dataAttribute | WorldAttribute | Access to the component's data |
defineState()
Use this function to create a State
from within a StateMachineDefiner
Parameter | Type | Description |
---|---|---|
name | string | The unique name of the state |
Returns a StateDefiner
Example
stateMachine: ({world, eid}) => {
const foo = ecs.defineState('foo')
...
}
StateDefiner
StateDefiner functions are 'fluent', so they all return the instance itself. This means you can chain any of the following consecutively in a single statement.
.initial()
Mark this state to be the current state of the state machine when it is created.
Example
ecs.defineState('name').initial()
.onEnter()
Set a callback to run when entering this state.
Parameter | Type | Description |
---|---|---|
cb | () => void | A function called when the state is entered |
Example
ecs.defineState('name').onEnter(() => {
// Do something
})
.onTick()
Set a callback to run every frame.
Parameter | Type | Description |
---|---|---|
cb | () => void | A function called once each frame while in this state |
Example
ecs.defineState('name').onTick(() => {
// Do something
})
.onExit()
Set a callback to run when exiting this state.
Parameter | Type | Description |
---|---|---|
cb | () => void | A function called when the state exits |
Example
ecs.defineState('name').onExit(() => {
// Do something
})
.onEvent()
Call to add an EventTrigger
from this state to another that can transition when a specific event is invoked
Parameter | Type | Description |
---|---|---|
event | string | The event to listen for |
nextState | StateId | The next state to transition to |
args | object (see below) | Optional arguments for determining when to transition |
args
Key | Type | Required | Description |
---|---|---|---|
target | Eid | no | The entity that is expected to receive an event (defaults to the state machine owner) |
where | (QueuedEvent) => boolean | no | An optional predicate to check before transitioning; returning false will prevent the transition from occurring |
Example
ecs.defineState('example').onEvent(
ecs.input.SCREEN_TOUCH_START,
'other',
{
target: world.events.globalId,
where: (event) => event.data.position.y > 0.5
}
)