• Andrew Jonhardt

I'm embarrassed by my player state machine in Godot

When I was first researching state machines, and actively trying to find the simplest examples I could, I was struck by the frequency of non-player character examples. This makes a certain kind of sense, as a basic state machine isolates possible actions and helps avoid odd behaviors (so long as your logic is sound). This is generally good for programming enemy behavior. However, this approach was not initially useful to me, as my starting point with a new game project is always the player character.


To make a long story short, I saw in my second project an opportunity to experiment with player state machines. My primary game project already has a functioning player character, and I'm hesitant to tear it apart just to implement a state machine. So, for the past few weeks, I've been working to iron out and implement the logic of 2nd project's player character using only a state machine.


I'm not happy with the state machine I've come up with yet, but it's what I've been working on so here we go:

extends Area2D
#onready
onready var anim = $AnimationPlayer
onready var ground_check = $GroundCheck
onready var ground_distance = $RayCast2D
#exports
export(int) var ladder_height = 32
export(int) var speed = 100
export(int) var grav_val = 600
#state
enum {
	IDLE,
	WALKING,
	LADDER,
	LADDERWALKING,
	FALLING,
	HAMMER,
	HURT
}
var state = IDLE
var on_ground = false
var direction = Vector2.ZERO
var height_multiple = 0
var dist_check = 0
signal ladder_rise(dist_check)

The variables I'm using for this character have been in flux, and are by no means complete. You'll notice this script is attached to an Area2D: For my second project, I wanted to step away from the usual Kinematic2D node type for the player. The game's design is fairly simple, doesn't require all the physics elements/movement functions of a Kinematic body, and it's the kind of thing I've been wanting to experiment with for a while.


Aside from the Area2D node type associated with this script, the main variables to notice are associated with the actual state machine:

enum {

IDLE,

WALKING,

LADDER,

LADDERWALKING,

FALLING,

HAMMER,

HURT

}

var state = IDLE


An enum is like a list, but it cannot be edited during runtime. Put another way: using an enum for your state machine ensures you cannot accidentally edit your list of possible states and break the game while it's running. You can give a name to your enum like so:

enum states{

IDLE,

WALKING,

LADDER,

LADDERWALKING,

FALLING,

HAMMER,

HURT

}

However, doing so would mean you would have to reference possible states like this

states.IDLE

instead of just calling states like this

IDLE

and I'm too lazy.

Finally, "state" is a variable that tracks what state your supposed to be in. As you can tell from the code, the player in my game starts in the IDLE state:

var state = IDLE


My next function will look very similar to the example of a state machine I posted last time. This is the section where the logic of the state machine is run via additional, appropriately named functions.

func _process(delta):
	match state:
		IDLE:
			idle()
		WALKING:
			walking(delta)
		HAMMER:
			hammer()
		LADDERWALKING:
			ladder_walking(delta)
		HURT:
			hurt()

You'll notice that some states are missing from the above match statement. This is because I've put them under a separate _physics_process function:

func _physics_process(delta):
	if state == FALLING:
		falling()
		player_gravity(delta)
	if state == LADDER:
		ladder()

I've separated my states out like this because I'm using a Raycast2D to judge the distance between the player and the ground. As a Raycast2D operates as a physics object, I need it to update every time the physics is calculated, which is a different timing from the calculations run under _process. If I don't try to update the Raycast2D when physics is calculated, weird and unwanted behavior ensues.


Now, I know there's a way to force a Raycast to update every physics frame even if it's not being called from a _physics_process function. However, there's a mess of new-to-me things I'm trying here, so I want to get the operations of the player falling and climbing ladders right before I experiment with removing the _physics_process entirely.


In this second project of mine, the player can essentially spawn a ladder or fall off the ladder anytime they want while they aren't hurt. I don't know if this feels good yet, because I haven't gotten everything working well enough to effectively test it. In order to effectively manage the state switching required, I've decided to simply respond to player input:

func ladder_check():
	if Input.is_action_just_pressed("ladder_up"):
		state = LADDER

func fall_check():
	if Input.is_action_just_pressed("ladder_down"):
		state = FALLING

The above 2 functions kinda clutter my code, but separating them from the rest of my input code has made state management alot easier. This is my remaining player input code:


func player_input():
	direction.x = Input.get_action_strength("right") - \
	Input.get_action_strength("left")
	if Input.is_action_just_pressed("hammer"):
		state = HAMMER

State machines tend to require a central state that all other states return to when the player isn't doing anything. The Action RPG tutorial from Heartbeast uses a player's walking state as the central state that all others return to, reasoning that the player will almost always be moving. This is fine, but I'm looking for a little more control in my state machine. So, the central state for my machine is the IDLE state:

func idle():
	player_input()
	ladder_check()
	if direction != Vector2.ZERO:
		state = WALKING

All this state does is check for player input. If the player tries to climb a ladder or move at all, the state instantly switches to any state that is possible from a standing position. This doesn't include FALLING, so I've effectively created a central state that shouldn't be called often once the player is playing, as my current vision for the game would be for the player to be constantly climbing ladders and falling off them as appropriate. This might be a logical problem later, but for now it's not a big deal.


My next function is related to walking around on the ground. Again, this isn't something that should happen too much. I decided to separate this from movement on the x axis while the player is on a ladder (the player can move, or hop, left and right while on ladders) because I wanted to ensure movement on the y axis, or up and down, can only happen when a ladder is present for the player to climb. At the moment I'm also changing the player's left and right speed while on a ladder, but I'm not sold on the way this feels.

func walking(delta):
	player_input()
	ladder_check()
	anim.play("walking")
	position.x += direction.x * delta * speed

The ladder_walking function is the same as regular walking save for 2 points: the ability to use the y axis to climb up and down, and the ability to fall.

func ladder_walking(delta):
	fall_check()
	ladder_check()
	player_input()
	direction.y = Input.get_action_strength("down") - \
	Input.get_action_strength("up")
	position.y += direction.y * delta * speed
	position.x += direction.x * delta * speed/2

Eventually, I'll need to add a ceiling to the ladder-walking function, so the player cannot climb higher than the ladder, but I don't even have the ladder working the way I want it to yet so such concerns are postponed.


Now, before we get into the other ladder and falling related functions, I'm quickly going to go over 2 key functions.


The function for the player's hammer freezes the player, starts an animation, and then stops anything else from happening until the animation is finished. I just found out about

yield(anim,"animation_finished")

recently, and I'm a fan of how easy it is to use to force the player to wait until an animation is finished!

func hammer():
	direction = Vector2.ZERO
	anim.play("hammer") 
	yield(anim,"animation_finished")
	state = IDLE

I haven't really tested the hurt state, for when the player is damaged, but here it is anyway.

func hurt():
	direction = Vector2.ZERO
	anim.play("hurt")
	yield(anim,"animation_finished")
	state = IDLE

Ok, now the ladder state:

func ladder():
	direction = Vector2.ZERO
	height_multiple +=1
	position.y = -ladder_height * height_multiple
	emit_signal("ladder_rise",height_multiple)
	state = LADDERWALKING

Once called, the ladder state locks out player input briefly, increases the player's height, and spawns a ladder via an emitted signal. The signal includes a variable that says how high the ladder should be. It's doesn't all work yet.


Once the player, intentionally or unintentionally, falls off a ladder, the falling state is set and the falling and gravity functions start running:

func falling():
	if ground_distance.is_colliding():
		var ground_global = ground_distance.get_collision_point()
		dist_check = self.global_position.distance_to(ground_global)
		height_multiple = int(dist_check/ladder_height)
	ladder_check()

func player_gravity(delta):
	if ground_check.off_ground():
		position.y += grav_val * delta
	elif !ground_check.off_ground():
		height_multiple = 0
		state = IDLE

Technically, these could be part of the same function. However, I know gravity works and I'm still messing with what should happen while the player is falling, so I've kept the functions separate.


The gravity function works with a call to another Area2D node in my player scene, ground_check, to confirm if the player is touching ground. If the player is off the ground, they are sent plummeting downwards. If they are touching ground, it's back to the idle state. Easy peasy.


The falling function, by contrast, tries to update that Raycast2D I mentioned earlier to judge the distance to the ground while also checking if the player ever mashes the ladder key to try and recover from the fall.


That's it, my player state machine example. It's not all working in terms of what the player is supposed to be able to do, but the logic works. Hopefully this is helpful for someone.


I've spent the last few weeks working on this second project, and I think it's time I returned to my primary project, project splatter. My next post will be about it, and I'm hoping to've decided on a name. Until the 16th.

© 2023 by Andrew Jonhardt. Proudly created with Wix.com