Doc: Add "State Machines" page.

Also update rst class docs.
This commit is contained in:
Serhii Snitsaruk 2024-03-01 23:44:29 +01:00
parent 543f2bb32c
commit 6d877c800a
3 changed files with 229 additions and 0 deletions

View File

@ -51,6 +51,23 @@ Methods
.. rst-class:: classref-descriptions-group
Signals
-------
.. _class_BehaviorTreeView_signal_task_selected:
.. rst-class:: classref-signal
**task_selected** **(** String p_type_name, String p_script_path **)**
Emitted when a task item is selected in **BehaviorTreeView**.
.. rst-class:: classref-section-separator
----
.. rst-class:: classref-descriptions-group
Property Descriptions
---------------------

View File

@ -0,0 +1,211 @@
.. _hsm:
State Machines
==============
This guide will show how to set up and use a state machine using :ref:`LimboHSM<class_LimboHSM>`.
Initialization
--------------
To use the :ref:`LimboHSM<class_LimboHSM>` state machine, you first need to initialize it in your code.
This is typically done in the ``_ready`` function of your script. Here's an example of how to do this:
.. code:: gdscript
@onready var hsm: LimboHSM = $LimboHSM
@onready var idle_state: LimboState = $LimboHSM/IdleState
@onready var move_state: LimboState = $LimboHSM/MoveState
func _ready() -> void:
_init_state_machine()
func _init_state_machine() -> void:
hsm.add_transition(idle_state, move_state, idle_state.EVENT_FINISHED)
hsm.add_transition(move_state, idle_state, move_state.EVENT_FINISHED)
hsm.initialize(self)
hsm.set_active(true)
In this example, we first declare the state machine and the states we want to use.
Then, in the ``_init_state_machine`` function, we add transitions between the states.
Finally, we initialize the state machine and set it to active.
State example
-------------
You can define the behavior of a state in a script and attach it to the state node.
Here's an example of a state that plays an animation on an ``AnimationPlayer`` and waits for it to finish:
.. code:: gdscript
extends LimboState
## PlayAnimation: Play an animation on AnimationPlayer, and wait for it to finish.
@export var animation_player: AnimationPlayer
@export var animation_name: StringName
func _enter() -> void:
animation_player.play(animation_name)
func _update(_delta: float) -> void:
if not animation_player.is_playing() \
or animation_player.assigned_animation != animation_name:
dispatch(EVENT_FINISHED)
In this example, the ``_enter`` method is called when the state is entered, and it plays the specified animation.
The ``_update`` method is called every frame, and it checks if the animation is finished.
If it is, it dispatches the ``EVENT_FINISHED`` event, which can result in a transition to the next state.
Events and transitions
----------------------
The :ref:`LimboHSM<class_LimboHSM>` comes with an **event system** that helps
to **decouple transitions** from the state implementations.
A transition is associated with a specific event, a starting state, and a destination state,
and it is performed automatically when such an event is dispatched.
To register a transition and associate it with a specific event, you can use the
:ref:`LimboHSM.add_transition<class_LimboHSM_method_add_transition>` method:
.. code:: gdscript
hsm.add_transition(idle_state, move_state, "movement_started")
In this example, we're registering a transition from the ``idle_state`` to the ``move_state``
when the ``movement_started`` event is dispatched.
A transition can be also associated with no particular starting state:
.. code:: gdscript
hsm.add_transition(hsm.ANYSTATE, move_state, "movement_started")
**Events are dispatched** with the :ref:`LimboState.dispatch<class_LimboState_method_dispatch>` method.
It's important to note that this method can be called from anywhere in the state machine hierarchy and outside of it.
Events are **propagated from the leaf to the root** state. This means that if an event is consumed by a state,
it won't be propagated to its parent states.
States can also define **event handlers**, which are methods that react to specific events.
These event handlers typically don't result in a state transition;
they are simply a mechanism for states to react to particular events.
You can use the :ref:`LimboState.add_event_handler<class_LimboState_method_add_event_handler>` method
to register event handlers in your states:
.. code:: gdscript
extends LimboState
func _setup() -> void:
add_event_handler("movement_started", _on_movement_started)
func _on_movement_started(cargo = null) -> bool:
# Handle the "movement_started" event here.
# `cargo` can be passed with the event when calling `dispatch()`.
# It's quite handy when you need to pass some data to the event handler.
return true
If the event handler returns ``true``, the event will be considered as consumed,
and it won't propagate further or result in a state transition.
State anatomy
-------------
.. code:: gdscript
extends LimboState
## Called once, when state is initialized.
func _setup() -> void:
pass
## Called when state is entered.
func _enter() -> void:
pass
## Called when state is exited.
func _exit() -> void:
pass
## Called each frame when this state is active.
func _update(delta: float) -> void:
pass
Using behavior trees with state machines
----------------------------------------
The :ref:`BTState<class_BTState>` is a specialized state node in :ref:`LimboHSM<class_LimboHSM>` that can host a behavior tree.
When a :ref:`BTState<class_BTState>` is active, it executes the hosted behavior tree each frame,
effectively using the behavior tree as its implementation.
This allows you to combine the power of behavior trees with the structure and control of state machines.
Behavior trees are excellent for defining complex, hierarchical behaviors,
while state machines are great for managing the flow and transitions between different behaviors.
Single-file state machine setup
-------------------------------
In certain scenarios, such as prototyping or during game jams,
it's practical to keep the state machine code in a single file.
For such cases, :ref:`LimboHSM<class_LimboHSM>` **supports delegation** and provides **chained methods** for easier setup.
Let's illustrate this with a practical code example:
.. code:: gdscript
extends CharacterBody2D
var hsm: LimboHSM
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready() -> void:
_init_state_machine()
func _init_state_machine() -> void:
hsm = LimboHSM.new()
add_child(hsm)
# Use chained methods and delegation to set up states:
var idle_state := LimboState.new().named("Idle") \
.call_on_enter(func(): animation_player.play("idle")) \
.call_on_update(_idle_update)
var move_state := LimboState.new().named("Move") \
.call_on_enter(func(): animation_player.play("walk")) \
.call_on_update(_move_update)
hsm.add_child(idle_state)
hsm.add_child(move_state)
hsm.add_transition(idle_state, move_state, "movement_started")
hsm.add_transition(move_state, idle_state, "movement_ended")
hsm.initialize(self)
hsm.set_active(true)
func _idle_update(delta: float) -> void:
var dir: Vector2 = Input.get_vector(
&"ui_left", &"ui_right", &"ui_up", &"ui_down")
if dir.is_zero_approx():
hsm.dispatch("movement_started")
func _move_update(delta: float) -> void:
var dir: Vector2 = Input.get_vector(
&"ui_left", &"ui_right", &"ui_up", &"ui_down")
var desired_velocity: Vector2 = dir * 200.0
velocity = desired_velocity
move_and_slide()
if desired_velocity.is_zero_approx():
hsm.dispatch("movement_ended")

View File

@ -45,6 +45,7 @@ the other any time. For more information on this topic, see :ref:`gdextension`.
getting-started/gdextension
getting-started/c-sharp
getting-started/accessing-nodes
getting-started/hsm
getting-started/featured-classes
.. toctree::