The generic unit is sufficient for basic movement and very basic states. For complex cases, we have to define a state machine system for our units, to manage their states and reduce the problems in future. There are tons of approaches to making a state machine. I am using one of them.
I also came across many problems I found in my own approach, and ways to correct them. The code however, is not changed by me, but I will give tips of how you can prevent these problems in your code.
What is State Management?
Before we dive into creating it, lets see what it is exactly. In short, we have a unit that can be in different states, such as moving, standing (idle), collecting, fighting. But here is the problem, a unit can be moving for tons of different reasons. It can be moving while fighting, while collecting, while depositing, or while chasing and so on. Also, a unit can be fighting while standing, while moving and so on.
If we keep using if-statements directly, we will soon get stuck in problems, where there will be multiple conditions becoming true at same time. In that case what our unit will do? For example, if an enemy appears in view, and we have given user a command of patrolling, what the unit will do? As both the conditions are true now (condition for chasing the enemy and condition for patrolling).
State machine is one of the ways to solve this problem. A state machine is a way of programming in which we restrict our unit to have exactly 1 state at a given time. And on certain conditions, it can transit to other pre-defined states.
What are States and Transitions
States are like nodes and transitions are like edges in a graph. Transition is a link between 2 states. Transition is a condition that, if gets true, causes the state to change from A to B.
Re-Creating Our Unit with State Machine System
Breakdown of Our State Management Approach
In our RTS unit state management approach, we will start with a base class called Unit
. This base class will define the fundamental states that are common to all units, such as IDLE
, WALKING
, DYING
, and DEAD
. It will also include methods to handle actions and transitions based on the unit’s current state. For example, if the current state is WALKING
, the base class can trigger a walking animation.
# PSEUDOCODE:
enum {
IDLE, MOVING, DYING, DIED
}
# Current state of the unit
var current_state = IDLE
func _process(delta):
_check_state_transitions(delta) # Finalize current state
_process_all_states(delta) # Act based on current state
func _check_state_transitions(delta: float):
# decide what state should the unit be in
func _process_all_states(delta: float):
# decide what to do in current state
When creating specific types of units, like a Warrior, we will extend the Unit
class. The Warrior
class will inherit all the base states and methods from Unit
and will also define its own unique states, such as FIGHTING
and CHASING
.
To integrate these additional states, the Warrior
class will override the methods in Unit
responsible for handling actions and state transitions. For example:
- Override
_process_all_states
: TheWarrior
can extend the behavior of the base method_process_all_states
to process its new states. Base states such asWALKING
are already handled in baseUnit
class, but sinceWarrior
adds new states (such asFIGHTING
), it must define the actions that should occur when state becomesFIGHTING
, in this method. - Override
_check_state_transitions
: TheWarrior
can add new transitions to the state machine by overriding this method. Base transitions are already handled in baseUnit
, and additional ones will be added by theWarrior
.
# PSEUDOCODE:
class Warrior extends Unit
enum {
FIGHTING=10, CHASING=11 # Additional states for warrior
}
# no need to call overridden functions (below) again in game-loop as base class calls them, just add super(delta)
func _process(delta):
super(delta)
# overriding
func _check_state_transitions(delta: float):
super(delta) # call base class implementation
# handle only warrior specific transitions (base states handled by super() call)
func _process_all_states(delta: float):
super(delta) # call base class implementation
# process only warrior specific states (base states handled by super() call)
This way, we can add more and more specialized states in extended classes, while retaining existing states, and adding new ones on top.
Implementing our State Management Approach
Now its time to re-create our unit with this better approach. Start by creating a script named “Unit.gd” (if it already exists, remove all the previous code).
Lay down the fundamental structure of our state machine system, like this (in Unit.gd):
extends CharacterBody3D
class_name Unit
# The possible states our Unit can be in
enum UnitState {
IDLE, MOVING, DYING, DIED
}
# Current state of the unit
var current_state = UnitState.IDLE
# Current path agent is following (if it exists)
var current_agent_path = []
var current_agent_path_index = 0
# Give correct collision_layer to unit so RaycastSystem can detect it
func _ready():
add_to_group("units")
# Units are on layer 2. So selection-box must check for layer 2 via collision_mask
collision_layer = 0b00000000_00000000_00000000_00000010
collision_mask = 0b00000000_00000000_00000000_11111111
func _physics_process(delta):
_check_state_transitions(delta) # Finalize current state
_process_all_states(delta) # Act based on current state
func _check_state_transitions(delta: float):
pass
func _process_all_states(delta: float):
pass
Now, all kinds of Units in your game, for example Warrior, Villager, Soldier, Archer, etc will be created as a separate scene with their own scripts, which extends the base Unit
. They will override _check_state_transitions
and _process_all_states
functions in their scripts to check for their own states and act for their own transitions.
For now, just complete the Unit
class implementation. In _check_state_transitions
, add following code:
func _check_state_transitions(delta: float):
if Input.is_action_just_pressed("left_mouse_button") and self in get_tree().get_nodes_in_group("selected_units"):
# reset path when clicked
current_agent_path.clear() # Can be a source of bug, if extended script expects path to be there
current_agent_path_index = 0
current_state = UnitState.MOVING
# Clear the path when the destination is reached
elif current_agent_path_index >= current_agent_path.size():
current_state = UnitState.IDLE
The above function checks for conditions (mouse click, and unit is be selected) and changes current state to MOVING
. And it changes current state back to IDLE
if there is no path or if path is already traversed.
In _process_all_states
, for now, add following code:
func _process_all_states(delta: float):
_process_died_state()
_process_dying_state()
_process_moving_state()
_process_idle_state()
We have not yet defined the above functions, but we will define them slowly. For now, just define the function to handle IDLE
and MOVING
states as we have only created transitions for these two:
# What to do when current_state is MOVING
func _process_moving_state():
if current_state == UnitState.MOVING:
if current_agent_path.size() > 0 and current_agent_path_index < current_agent_path.size():
var target_pos = current_agent_path[current_agent_path_index]
target_pos.y = self.global_position.y # DO NOT TOUCH IT
var direction = (target_pos - self.global_position).normalized()
var distance = self.global_position.distance_to(target_pos)
# Update velocity for move_and_slide
velocity.x = direction.x * speed
velocity.z = direction.z * speed
# Play walk animation
animation_player.play("Walk")
# Rotate towards the target only along y-axis
look_at(target_pos, Vector3.UP)
# Check if agent reached the current target position
if distance < 0.5:
current_agent_path_index += 1 # Move to the next waypoint
print("Reached waypoint:", current_agent_path_index)
else:
# (re)calculate the path if no path
calculate_path(RaycastSystem.get_mouse_world_position())
# What to do when current_state is IDLE
func _process_idle_state():
if current_state == UnitState.IDLE:
current_agent_path.clear()
velocity.x = 0
velocity.z = 0
# Ensure idle animation is enforced
animation_player.play("Idle")
animation_player.get_animation("Idle").loop_mode = Animation.LOOP_LINEAR
Explanation: For MOVING
state, the path is calculated to where the mouse is clicked. If there exists a path, the unit moves to next point in the path, until it reaches the end of the path. – For IDLE, state, it makes the velocity 0.0
and clears the path. Additionally, animations are being played, but this part can be omitted.
What about _process_died_state
and _process_dying_state
, for now, define them like this, we will later come across them once we create health bar system:
func _process_died_state():
pass
func _process_dying_state():
pass
Testing our Unit
Create a scene, with CharacterBody3D
root, a MeshInstance3D
for visualize the character model & a CollisionShape3D
. Overall scene should look like this:
Instance this unit scene in game world, and you should see the unit moving.
Note that, for now, we are applying Unit.gd (which is a base class) directly to the Warrior
. Later, we will create a separate script for warrior (that extends Unit
), and assign that to Warrior
instead.
Thank you for reading <3
Problems in My Approach & their Solution
I am highlighting the problems I faced in the development so you can prevent them beforehand. Such as: transitions must check predecessor state & transit to new state only if the new state can be reached from predecessor state.
Right now, transitions between states are happening as soon as a trigger condition is met, without considering what the previous state was. This can lead to illogical behavior, like transitioning to a “walking” state immediately after being in a “dead” state, which doesn’t make sense.
To fix this, every state should define a list of valid previous states that the unit must be in to transition to the current state. For example:
- To transition to “walking,” the previous state must be “idle” or “running,” but not “dead.”
- To transition to “dead,” the unit could come from any state except already being “dead.”
This setup ensures that transitions follow a logical flow, like how in a graph, a node is connected only to certain other nodes. A state should only transition to a specific set of valid states, creating a well-defined flow of legal transitions.
Pseudocode:
if user_click and etc and is_in_states(IDLE or FIGHTING or COLLECTING):
current_state = NEW_STATE
The problem with this approach is that we will have to define too many states, such as unit can be in any state such as collecting, taking items back to town center, harvesting and many tons of states. So another work to make it less tedious to developer is to check for is_not_in_states(STATE1 or STATE2)
or to check their combination.
Another approach would be to group the related states. The ones which are often checked as a whole. But the best option would be to stick with the above two methods or to omit the previous-state check if the state can be reached globally with just a trigger and previous state doesn’t matters.