I learned a trick from Godot 4 C# Action Adventure: Build your own 2.5D RPG and then ported it to GDScript. It’s a node system where the StateMachine is a node, and then each child node is a State. Add the state, and the enemy or player can do the thing. Take it away and it can’t. I even implemented it so that you could gain new abilities from chests.
I’m currently working on updating my code to turn it into a plugin. (Which I will be making available with an MIT license on GitHub.) But here’s my current version. (Keep in mind this is for 3D.)
class_name StateMachine extends Node
## This node is intended to be attached to a character and manage the various
## states of the character. If a charatcer has a state it should be added as a
## child node of the state machine.
## The initial CharacterState for the character when it is added to the game.
@export var starting_state: CharacterState
# The current state of the character. Initially defaults to the first node it
# finds beneath itself if starting_state is not defined.
@onready var _current_state: CharacterState = starting_state if starting_state != null else self.get_child(0)
## Sets up all the states for this character.
func _ready() -> void:
switch_state(_current_state)
for state in get_children():
if state is CharacterState:
state.activate_state()
self.connect("child_entered_tree", _on_state_added)
self.connect("child_exiting_tree", _on_state_removed)
## Switch to the target state from the current state. Fails if:
## 1. The character does not have the passed state.
## 2. The character is already in that state.
## 3. The current state won't allow a transition to happen.
## 4. The target state won't allow a transition (e.g. cooldown timers).
func switch_state(state: CharacterState) -> void:
if not _character_has_state(state): return
if _current_state == state: return
if not _current_state.can_transition: return
if not state.can_transition: return
_current_state.exit_state()
_current_state = state
_current_state.enter_state()
# Returns whether or not the character has this state.
# (A character has a state if the state is a child node of this StateMachine.)
func _character_has_state(state: CharacterState) -> bool:
for element in get_children():
if element == state:
return true
return false
# Activates a state.
# (Called when a node enters the tree as a child node of this StateMachine.)
func _on_state_added(node: Node) -> void:
if not node is CharacterState:
return
node.activate_state()
# Deactivates a state.
# (Called when a child node of this StateMachine leaves the tree.)
func _on_state_removed(node: Node) -> void:
if not node is CharacterState:
return
node.deactivate_state()
Then I’ve got CharacterState that players and enemies inherit from.
class_name CharacterState extends Node
# A virtual state for states to inherit.
## Stores any relevant statistics for the state.
@export var stats: Array[StatResource]
## Stores a reference to the character to which this state is attached.
@onready var character: Character = get_owner()
## Set to false if a character cannot use an ability or take an action,
## for example when waiting for a cooldown timer to expire.
var can_transition = true
## Initialize the state. Process mode is set so the state can be paused.
## Then all processing (including input and physics) is turned off.
func _ready() -> void:
process_mode = ProcessMode.PROCESS_MODE_PAUSABLE
set_physics_process(false)
set_process(false)
set_process_input(false)
set_process_unhandled_input(false)
## Turn processing on for everything but physics when the state is active.
func activate_state() -> void:
set_process(true)
set_process_input(true)
set_process_unhandled_input(true)
## Turn processing off when the state is deactivated.
func deactivate_state() -> void:
set_process(false)
set_process_input(false)
set_process_unhandled_input(false)
set_physics_process(false)
## Virtual function for inherited classes to implement, for example starting an animation.
func enter_state() -> void:
set_physics_process(true)
print("Enter State: %s" % self.name)
## Virtual function for inherited classes to implement.
func exit_state() -> void:
set_physics_process(false)
print("Exit State: %s" % self.name)
func get_stat(stat: StatResource.Type) -> StatResource:
for element in stats:
if element.stat_type == stat:
return element
return null
func _get_initial_stat_value(stat: StatResource.Type, default: float = 0.0):
var stat_resource = get_stat(stat)
if stat_resource:
return stat_resource.stat_value
return default
And StatResource may not be soemthing you need, but I’m including it for completeness.
class_name StatResource extends Resource
signal zero
signal update
enum Type {
Speed,
JumpVelocity
}
@export var stat_type: Type
@export var stat_value: float:
set(value):
stat_value = clampf(value, 0, INF)
update.emit()
if stat_value == 0.0:
zero.emit()
My current PlayerState is a placeholder. I don’t have anything in it yet, but that may change so it’s there.
class_name PlayerState extends CharacterState
So here’s an idle state. The whole method is what I call “pull instead of push”. It’s a term used in project management for something called Kanban. The idea is that instead of telling a state when it should happen, you let the state tell you when it should happen. All the logic is there. This also means that if you don’t attach a node, the code never runs on that character.
class_name PlayerIdleState extends PlayerState
## If the player stops moving, move to the Idle state.
func _process(_delta: float) -> void:
if character.direction == Vector3.ZERO:
character.state_machine.switch_state(self)
## Handles slowing movement and idle animation
func _physics_process(_delta: float) -> void:
character.velocity.x = move_toward(character.velocity.x, 0, character.speed)
character.velocity.z = move_toward(character.velocity.z, 0, character.speed)
do_animation()
character.move_and_slide()
## Handles Idle/Walk/Run Animation
func do_animation() -> void:
var vl = character.direction * character.rig.transform.basis
character.animation_tree.set(character.IDLE_WALK_RUN_BLEND_POSITION, Vector2(vl.x, -vl.z))
Then we have the move state.
class_name PlayerMoveState extends PlayerState
## If the player has directional input, move to the Move state.
func _process(_delta: float) -> void:
if character.direction:
character.state_machine.switch_state(self)
## Handles movement and animation
func _physics_process(_delta: float) -> void:
character.velocity.x = character.direction.x * character.speed
character.velocity.z = character.direction.z * character.speed
do_animation()
character.move_and_slide()
## Handles Idle/Walk/Run Animation
func do_animation() -> void:
var vl = character.direction * character.rig.transform.basis
character.animation_tree.set(character.IDLE_WALK_RUN_BLEND_POSITION, Vector2(vl.x, -vl.z))
You’ll notice that the input isn’t in these states. Instead it’s in the player state. I’m actually thinking about changing that. The jump ability handles all its input alone. Obviously, enemies don’t get input from the player, but the point is that the states themselves be self-contained.
class_name PlayerJumpState extends PlayerState
## Jump Velocity defaults to zero unless the JumpVelocity stat is assigned,
## then that number is used.
@onready var jump_velocity: float = _get_initial_stat_value(StatResource.Type.JumpVelocity)
@onready var timer: Timer = $Timer
var can_land = false
var landing = false
func _ready() -> void:
timer.timeout.connect(_on_timeout)
func _on_timeout():
print("Timeout")
can_land = true
# If the player presses the Jump action, switch to the Jump state.
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("jump") and character.is_on_floor():
character.state_machine.switch_state(self)
character.velocity.y = jump_velocity
# Process the jump every frame and trigger the landing when we land.
func _physics_process(_delta: float) -> void:
if character.is_on_floor() and can_land and not landing:
print("Land")
land()
character.move_and_slide()
func land():
landing = true
character.animation_state.travel("Jump_Land")
# Starts the jump animation, and turns off the ability to transition to
# another state mid-jump.
func enter_state() -> void:
super()
character.animation_tree.connect("animation_finished", _on_animation_finished)
character.animation_state.travel("Jump_Start")
can_transition = false
timer.start()
can_land = false
landing = false
func exit_state() -> void:
super()
character.animation_tree.disconnect("animation_finished", _on_animation_finished)
# Allows state transition only after the initial Jump_Start animation has been called. This is
# because otherwise the landing animation is called because the first physics frame this class runs
# is from the floor and so is_on_floor() is still true.
func _on_animation_finished(animation_name: String) -> void:
match animation_name:
"Jump_Land":
can_transition = true
Hope that helps.