Top-Down 2D Game - Animating CharacterBodies in a State Machine Architecture / Dependency Injection

Godot Version

Godot Engine v4.7.stable.official.5b4e0cb0f

Question

Hi,

I am making a top-down 2d game using animated sprites.

I’ve come across a problem in my code which is affecting my player character’s ability to move accurately. I believe I have nailed down the problem, I just need a solution for it that works with the project’s current architecture, and everything I’ve tried doesn’t fix it (I’ll try to show these solutions that didn’t work.)

Most of my work is based around 3 sources:

I’m going to share my code architecture so there is a baseline understanding, then I will point out where the problem actually is. (I will avoid trying to show too much of dragonforge-dev’s own code since it doesn’t belong to me, I didn’t write it and there is not tutorial for making it yourself.)

In my code, I’ve done my best to follow Dependency Injection as I’d like to have a composition mentality rather than inheritance.

As a baseline, here is the scene hierarchy for the Player

Currently, there is nothing in the script attached to the player.

The StateMachine and State Nodes are the ones provided by dragonforge-dev’s addon. This uses a “pull” methodology, where States tell the StateMachine when they should be activated. This is a really nice design by dragonforge-dev. But this does mean that the StateMachineisn’t really meant to be used that much.

The WalkState and IdleState are extended from the State node provided by dragonforge-dev, They are PlayerStatenodes, which are extensions of CharacterStatenodes, which themselves are extensions of the State node.

class_name CharacterState
extends State

var character: CharacterBody2D
var movement: MovementInterface


func _activate_state() -> void:
	super()

	character = _state_machine.subject

	for child in character.get_children():
		if child is MovementInterface:
			movement = child

The PlayerState literally has no extra coded currently, but the CharacterState passes a MovementInterface to the Player States. This MovementInterface is inspired by ShaggyDev’s video on the topic of state machines. The CharacterState is meant to be used on any character; N.P.Cs and players included. TheMovementInterface allows the State to not worry about where the input logic is coming from, it simply knows that it receives a direction and velocity information from something, and is able to process that into moving the character.

class_name MovementInterface
extends Node

var direction: Vector2 = Vector2.ZERO
var cardinal_direction: Vector2 = Vector2.DOWN
const DIR_4 = [ Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT, Vector2.UP ]
var speed: float = 100.0

As I’m currently working on the player, my main focus is on the player controller, which is a script attached to the PlayerController Node, and is an extension of the MovementInterface, so that it can provide information to a PlayerState.

class_name PlayerController extends MovementInterface

@export var movement_speed := 100.0

func _ready():
	process_priority = -100

func _process(_delta):
	direction.x = Input.get_action_strength("move_right") - Input.get_action_strength("move_left")
	direction.y = Input.get_action_strength("move_down") - Input.get_action_strength("move_up")

	speed = movement_speed
	
	
func SetDirection() -> bool: 
	if direction == Vector2.ZERO:
		return false
		
	var direction_id :int = int( round ( (direction + cardinal_direction * 0.1 ).angle() / TAU * DIR_4.size()) )
	var new_direction = DIR_4[ direction_id ]
		
	if new_direction == cardinal_direction:
		return false
	
	cardinal_direction = new_direction
	return true

This code is from Michael Game’s 2-D AARPG.

I’ll explain what this code fulfills:

  • By having subtractive movement (left - right), (down - up) if the user presses opposite keys simultaneously, the character in game does not move.
  • the weird “direction_id” is to prevent rare sliding movement AND helps to respect the order of keys that were input. In this game, character’s are allowed to move diagonally; but there is no special animation for diagonal movement; characters only have 4-cardinal facing directions. When the player presses “UP” and “RIGHT”, we want the character to respect the first key that was pressed, so the character faces up whilst moving diagonally up-and-to-the-right.
  • SetDirection() is also used, in general, for knowing which direction the character should be facing, so we can correctly pick the corresponding animation in the AnimationPlayer Node.

But this is where problems start to appear. In Michael Game’s tutorial series, this logic is held in the player.gd script. He also animates his character from player.gd. This is completed within the same _process() call. This here is the problem. Because moving the character and setting their facing direction happens in two different process calls, things become out-of-sync, and sliding and weird shenanigans occur. I tried using signals to do this, but I can’t guarantee that the order of events is respected between process() and emit()

I originally tried having a CharacterAnimationPlayerscript that extended the AnimationPlayer and have information from the PlayerController and individual PlayerState contribute to it, but again, things just weirdly become out of sync and the character’s movement is slide-y and delayed.

class_name CharacterAnimationPlayer
extends AnimationPlayer


var movement: MovementInterface

var last_direction: Vector2 = Vector2.DOWN

var current_foot: StringName = "right"


func _ready():

	for child in get_parent().get_children():
		if child is MovementInterface:
			movement = child
			break


func play_animation(animation: StringName) -> void:

	if movement.direction != Vector2.ZERO:
		last_direction = movement.cardinal_direction


	var directional_animation := (
		animation +
		_get_direction_suffix()
	)


	if current_animation == directional_animation:
		return


	play(directional_animation)



func _get_direction_suffix() -> StringName:

	match last_direction:

		Vector2.UP:
			return &"Up"

		Vector2.DOWN:
			return &"Down"

		Vector2.LEFT:
			return &"Left"

		Vector2.RIGHT:
			return &"Right"

	return &"Down"



func UpdateAnimation(state: String) -> void: 
	animation_player.play( state + "_" + AnimDirection() )
	pass
	
func AnimDirection() -> String:
	if cardinal_direction == Vector2.DOWN:
		return "Down"
	elif cardinal_direction == Vector2.UP:
		return "Up"
	elif cardinal_direction == Vecto2.LEFT:
		return "Left"
	else:
		return "Right"
		
	
	

I realize some of this is duplicate code, or may have problem with incorrect types being passed, but those were functional with the use of other helper functions and such. My problem is currently in the architecture and execution times, I believe.

I also tried looking at process priority, but I don’t want to mess with that too much, since I think it would be liable to create problems in the future.

If anybody has any ideas on how can I solve this while still keeping my same architecture, I’d be very appreciative!