EcspanseStateMachine

Hex VersionGitHub CIDocumentation

ECSpanse State Machine is a component level state machine implementation for ECSpanse. It is an Ecspanse component you include in your entities.

Features

Installation

If available in Hex, the package can be installed by adding ecspanse_state_machine to your list of dependencies in mix.exs:

def deps do
  [
    {:ecspanse_state_machine, "~> 0.3.3"}
  ]
end

How to Use

  1. Systems Setup
  2. Add a state machine
  3. Listen for state changes
  4. Command a state change
  5. Stopping a state machine

Systems Setup

As part of your ESCpanse setup, you will have defined a manager with a setup(data) function. In that function, chain a call to ESCpanseStateMachine.setup

  def setup(data) do
    data
    # register the state machine's systems
    |> EcspanseStateMachine.setup()

    # Be sure to register the Ecspanse System Timer!
    |> Ecspanse.add_frame_end_system(Ecspanse.System.Timer)

    # register your systems too

ECSpanseStateMachine will add the systems it needs for you.

Add a state machine

The state machine is an ECSpanse component. You add it to your entity's spec in the components list. EcspanseStateMachine.new is a convenience API function to create the state machine component.

  1. Create a state machine component spec
    state_machine =
      EcspanseStateMachine.new(
        :idle,
        [
          [name: :idle, exits: [:patrol, :fight], timeout: 5_000],
          [name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle],
          [name: :fight, exits: [:idle, :die]],
          [name: :die]
        ]
      )
  1. Include the component in your entity
    Ecspanse.Command.spawn_entity!({
      Ecspanse.Entity,
      components: [state_machine]
    })

Defining States

A state definition is a keyword list with the following keys. :name is the only required key but most states will also include :exits.

States that have at least one exit have a default exit. The default exit is the first exit in the :exits list unless specified by the :default_exit keyword.

States that have timeout will transition to the default exit. The Api provides a convenience function for transitioning to the default exit.

Examples
  # This is a terminal state since it has no exits.  The state machine will stop once it enters a terminal state.
  [name: :die]

  # The :fight state can transition to :idle or :die. You must call a transition function on the api to change from the :fight state since there is no :timeout.
  [name: :fight, exits: [:idle, :die]],

  # :idle can transition to :patrol or :fight.  You can use the api to transition to either state.
  # After 5 seconds (the :timeout), the state will transition to :patrol.
  # :patrol is the default exit state since it is first in the :exits list and :default_exit isn't specified
  [name: :idle, exits: [:patrol, :fight], timeout: 5_000]

  # :patrol can transition to :fight or :idle.  You can use the api to transition to either state.
  # After 10 seconds (the :timeout), the state will transition to :idle.
  # :idle is the default exit state since it is specified as the :default_exit.
  [name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle]

Starting your state machine

The default behavior is to automatically start a state machine. If you don't want that behavior, then you can 'set auto_start to false' and call EcspanseStateMachine.start when you're ready.

Auto start is an option, the third parameter to EcspanseStateMachine.new(). Here's an example of turning off auto_start and then starting the state machine later.

  state_machine =
    EcspanseStateMachine.new(
      :idle,
      [
        [name: :idle, exits: [:patrol, :fight], timeout: 5_000],
        [name: :patrol, exits: [:fight, :idle], timeout: 10_000, default_exit: :idle],
        [name: :fight, exits: [:idle, :die]],
        [name: :die]
      ],
      auto_start: false
    )

  entity = Ecspanse.Command.spawn_entity!({
    Ecspanse.Entity,
    components: [ state_machine]
  })

  # some time later
  EcspanseStateMachine.start(entity.id)

Listen for state changes

ECSpanseStateMachine publishes Started, Stopped, and StateChanged events. State changed is the primary event. It's your chance to take action after a transition.

defmodule OnStateChanged do
  use Ecspanse.System,
    event_subscriptions: [EcspanseStateMachine.Events.StateChanged]

  def run(
        %EcspanseStateMachine.Events.StateChanged{
          entity_id: entity_id,
          from: from,
          to: to,
          trigger: _trigger
        },
        _frame
      ) do
      # respond to the transition
  end
end

Command a state change

State changes happen when a timeout elapses or upon request. Call EcspanseStateMachine.transition to trigger a transition.

  EcspanseStateMachine.transition(entity_id, :fight, :idle)

Here were changing state from :fight to :idle.

Stopping a state machine

The state machine will automatically stop when it reaches a state no exits.

You can stop a state machine anytime by calling EcspanseStateMachine.stop.

  EcspanseStateMachine.stop(entity_id)

Telemetry

ECSpanse State Machine implements telemetry for the following events.

event name measurement metadata description
ecspanse_state_machine.start system_time state_machine Executed on state machine start
ecspanse_state_machine_stop duration state_machine Executed on state machine stop
ecspanse_state_machine.state.start system_time state_machine, state Executed on entering a state
ecspanse_state_machine.state.stop duration state_machine, state Executed on exiting a state

Mermaid State Diagrams

ECSpanseStateMachine generates Mermaid.js state diagrams for your state machines.

  EcspanseStateMachine.format_as_mermaid_diagram(entity_id)

Here's an example output.

---
title: Simple AI
---
stateDiagram-v2
[*] --> idle
fight --> die
fight --> idle
idle --> fight
idle --> patrol: ⏲️
patrol --> fight
patrol --> idle: ⏲️
die --> [*]

Which produces the following state diagram when rendered