DEV Community

Umbr4x
Umbr4x

Posted on

Godot 4.3+ Hierachical State Machine

In this post I am assuming you have basic knowledge of how a State Machine Works

This is a Hierarchical Finite State Machine!

This means it is setup in the editor as custom nodes!

The State Class

In this state machine, each state will extend from the State class below. Transitions between states can be called with this nice function I made called transition_to(). When using this state machine treat the update and physics_update like _process and _physics_process respectively.

class_name State
extends Node

signal transitioned(state: State, new_state_name: String)

var player: CharacterBody2D
@onready var sprite: AnimatedSprite2D = %AnimatedSprite2D

func enter() -> void:
    pass

func exit() -> void:
    pass

func update(delta: float) -> void:
    pass

func physics_update(delta: float) -> void:
    pass

func transition_to(new_state_name: String) -> void:
    transitioned.emit(self, new_state_name)

Enter fullscreen mode Exit fullscreen mode

The State Machine

The State Machine node is where we will be adding our State nodes to as children. The state machine mostly takes care of transitions, keeping track of current state, and executes the code of the states.
Must be a child of a CharacterBody2D (Aka your player)


class_name StateMachine
extends Node

@export var initial_state: State
var current_state: State
var states: Dictionary = {}

func _ready() -> void:
    _initialize_states()
    _set_initial_state()

func _initialize_states() -> void:
    for child in get_children():
        if child is State:
            states[child.name.to_lower()] = child
            child.transitioned.connect(_on_state_transition)

func _set_initial_state() -> void:
    if initial_state:
        _assign_player_reference(initial_state)
        initial_state.enter()
        current_state = initial_state
    else:
        push_warning("No initial state set for StateMachine in " + owner.name)

func _process(delta: float) -> void:
    if current_state:
        current_state.update(delta)

func _physics_process(delta: float) -> void:
    if current_state:
        current_state.physics_update(delta)

func _on_state_transition(state: State, new_state_name: String) -> void:
    if state != current_state:
        return

    var new_state = states.get(new_state_name.to_lower())
    if not new_state:
        push_warning("State '" + new_state_name + "' not found in StateMachine")
        return

    _transition_to_new_state(new_state)

func _transition_to_new_state(new_state: State) -> void:
    current_state.exit()
    current_state = new_state
    _assign_player_reference(new_state)
    new_state.enter()

func _assign_player_reference(state: State) -> void:
    var parent = get_parent()
    if parent is CharacterBody2D:
        state.player = parent
    else:
        push_warning("StateMachine's parent must be CharacterBody2D")

Enter fullscreen mode Exit fullscreen mode

Example States and proper usage:

Write code necessary only for that state.


class_name IdleState
extends State

@export_range(120, 420) var friction: float


func enter() -> void:
    print("Entered Idle")
    sprite.play("idle")

func exit() -> void:
    print("Exited Idle")

func update(delta: float) -> void:
    if Input.get_axis("ui_left", "ui_right") != 0:
        transition_to("Walk")
    if not player.is_on_floor():
        transition_to("Fall")


func physics_update(delta: float) -> void:
    if player.velocity.x != 0:
        player.velocity.x = lerp(player.velocity.x, 0, friction * delta)

Enter fullscreen mode Exit fullscreen mode

Top comments (0)