Skip to content

Instantly share code, notes, and snippets.

@alkemann
Forked from firebelley/callable_state_machine.gd
Last active December 22, 2025 16:51
Show Gist options
  • Select an option

  • Save alkemann/481252a7f8d3d84e606a049a3d6c6b9d to your computer and use it in GitHub Desktop.

Select an option

Save alkemann/481252a7f8d3d84e606a049a3d6c6b9d to your computer and use it in GitHub Desktop.
## A state machine that manages state transitions and executes callbacks.
class_name StateMachine
## The currently active state.
var current_state: State
## Sets the initial state and calls its enter callback.
## Should be called once during initialization (e.g., in _ready()).
func set_initial_state(state: State):
_set_state(state)
## Updates the current state by calling its update callback.
## Should be called every frame (e.g., in _process()).
func update():
if not current_state.update.is_null():
current_state.update.call()
## Transitions to a new state. Calls leave callback on current state, then enter callback on new state.
## Uses call_deferred to ensure safe state transitions.
func change_state(state: State):
_set_state.call_deferred(state)
## Internal method that handles the actual state transition logic.
func _set_state(state: State):
if current_state:
var leave_callable := current_state.leave as Callable
if not leave_callable.is_null():
leave_callable.call()
current_state = state
var enter_callable = current_state.enter as Callable
if not enter_callable.is_null():
enter_callable.call()
## Represents a single state with update, enter, and leave callbacks.
class State extends RefCounted:
## Callback executed every frame while this state is active.
var update: Callable
## Callback executed when entering this state.
var enter: Callable
## Callback executed when leaving this state.
var leave: Callable
var _name: StringName
## Creates a new state with the provided callbacks.
## [param call_on_update] Required callback for state updates.
## [param call_on_enter] Optional callback called when entering the state.
## [param call_one_leave] Optional callback called when leaving the state.
func _init(call_on_update: Callable, call_on_enter: Callable = Callable(), call_one_leave: Callable = Callable()) -> void:
update = call_on_update
enter = call_on_enter
leave = call_one_leave
_name = call_on_update.get_method()
## Returns the name of the state derived from the update callback method name.
func name() -> StringName:
return _name
extends Node
# Create a StateMachine instance to manage state transitions
var state_machine := StateMachine.new()
# Define states using StateMachine.State.new() with three callbacks:
# 1. update: Called every frame while this state is active (required)
# 2. enter: Called when transitioning into this state (optional)
# 3. leave: Called when transitioning out of this state (optional)
var state_idle := StateMachine.State.new(
# Update callback: runs every frame while in idle state
func _state_idle_tick():
label.text = "IDLE: %.1fs" % timer.time_left,
# Enter callback: runs when entering idle state
func _state_idle_enter():
counter += 1
if counter > 5:
state_machine.change_state(state_end)
# Connect signal to trigger state transition
timer.timeout.connect(state_machine.change_state.bind(state_dragging)),
# Leave callback: cleanup signal connections to prevent memory leaks
func _state_idle_leave():
if timer.timeout.is_connected(state_machine.change_state):
timer.timeout.disconnect(state_machine.change_state)
)
var state_dragging := StateMachine.State.new(
# Update callback: runs every frame while dragging
func(): label.text = "DRAGGING: %.1fs" % timer.time_left,
# Enter callback: setup signal for transition back to idle
func(): timer.timeout.connect(state_machine.change_state.bind(state_idle)),
# Leave callback: cleanup signal connection
func():
if timer.timeout.is_connected(state_machine.change_state):
timer.timeout.disconnect(state_machine.change_state)
)
# Example of a state with no update callback (use Callable() to skip)
var state_end := StateMachine.State.new(
Callable(), # No update callback - state does nothing each frame
# Enter callback: finalize when reaching end state
func():
timer.stop()
label.text = "Done"
)
var counter: int = 0
@onready var timer: Timer = $Timer
@onready var label: Label = $"../UI/Control/PanelContainer/Label"
func _ready() -> void:
# Set the initial state - this will call the enter callback
state_machine.set_initial_state(state_idle)
func _process(_delta: float) -> void:
# Update the state machine each frame - this calls the current state's update callback
state_machine.update()
extends Node
var sm := StateMachine.new()
var idle := StateMachine.State.new(
func(): label.text = "IDLE: %.1fs" % t.time_left,
func():
counter += 1
if counter > 5: sm.change_state(done)
t.timeout.connect(sm.change_state.bind(drag)),
func():
if t.timeout.is_connected(sm.change_state): t.timeout.disconnect(sm.change_state)
)
var drag := StateMachine.State.new(
func(): label.text = "DRAGGING: %.1fs" % t.time_left,
func(): t.timeout.connect(sm.change_state.bind(idle)),
func():
if t.timeout.is_connected(sm.change_state): t.timeout.disconnect(sm.change_state)
)
var done := StateMachine.State.new(
Callable(),
func():
t.stop()
label.text = "Done"
)
var counter = 0
@onready var t = %Timer
@onready var label = %Label
func _ready(): sm.set_initial_state(idle)
func _process(_delta): sm.update()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment