Is it worth to use state machine for this?

Godot Version

4.6 Stable

Question

Just looking at this player control script for simple metroidvania style game ([Gx Boom] - Metroidvania 2D shooter) and wonder if be worth to change the controlling for state machine or not

current code for player is :

  • main challenge is those if statements as it leads issue :
    - to player is not shooting while ducking
    - to player is not duck walk while ducking
    - to player is dash for shoot/dash while ducking which is unwanted

I had look on GitHub - dragonforge-dev/dragonforge-state-machine: A base state machine class to be used in games. · GitHub but I have no clue how to transfer into from current script :

extends CharacterBody2D

var direction_x : float 
var duck : bool
var on_floor : bool
var controller_aim : bool
var target_dir: Vector2
var current_gun: Data.Gun
var frozen: bool
@export_category('move')
@export var speed:= 120
@export var acceleration: int = 600
@export var friction: int = 800
@export var dash_speed : int = 600
@export_category('jump')
@export var jump_height: float = 100
@export var jump_time_to_peak: float = 0.5
@export var jump_time_to_descent: float = 0.4
@export_category('shooting')
@export var crosshair_distance : int = 50
@export var shotgun_distance : int = 30

@onready var jump_velocity: float = ((2.0 * jump_height) / jump_time_to_peak) * -1.0
@onready var jump_gravity: float = ((-2.0 * jump_height) / (jump_time_to_peak * jump_time_to_peak)) * -1.0
@onready var fall_gravity: float = ((-2.0 * jump_height) / (jump_time_to_peak * jump_time_to_descent)) * -1.0

signal shoot(pos: Vector2, dir: Vector2, gun_type: Data.Gun)
const GUN_DIRECTIONS = {
	Vector2i(0,0):   0,
	Vector2i(1,0):   0,
	Vector2i(1,1):   1, 
	Vector2i(0,1):   2,
	Vector2i(-1,1):  3,
	Vector2i(-1,0):  4,
	Vector2i(-1,-1): 5,
	Vector2i(0,-1):  6,
	Vector2i(1,-1):  7,
}

func _ready() -> void:
	$UI.set_health(Data.player_health)

func _input(event: InputEvent) -> void:
	if Input.get_vector("aim_left","aim_right","aim_up","aim_down"):
		controller_aim = true
	if event is InputEventMouseMotion:
		controller_aim = false
func get_input():
	direction_x = Input.get_axis("left","right")
	if Input.is_action_just_pressed("jump") and (is_on_floor() or $Timer/CoyoteTimer.time_left):
		velocity.y = jump_velocity
	if Input.is_action_just_pressed("shoot") and not $Timer/ReloadTimer.time_left:
		shoot.emit(position, get_aim_dir(),current_gun)
		$Timer/ReloadTimer.start()
		if current_gun == Data.Gun.SHOTGUN:
			$ShotgunParticles.position = get_aim_dir() * shotgun_distance
			$ShotgunParticles.process_material.set('direction', get_aim_dir())
			$ShotgunParticles.emitting = true
	if Input.is_action_just_pressed("dash") and not $Timer/DashTimer.time_left:
		$Timer/DashTimer.start()
		var tween = create_tween()
		tween.tween_property(self,'velocity:x',velocity.x + direction_x * dash_speed, 0.3)
		tween.tween_callback(_dash_finish)
	duck = Input.is_action_pressed("duck") and is_on_floor()
	if Input.is_action_just_pressed("toggle"):
		current_gun = posmod(current_gun +1, Data.Gun.size()) as Data.Gun
		
func _dash_finish():
	velocity.x =move_toward(velocity.x,0,500)
func animation():
	#legs
	if direction_x !=0:
		$Sprites/LegSprite.flip_h = direction_x < 0
	if is_on_floor():
		$AnimationPlayer.current_animation = 'run' if direction_x else 'idle'
		$AnimationPlayer.current_animation = 'duck' if duck else $AnimationPlayer.current_animation
	else:
		$AnimationPlayer.current_animation = 'jump'
		
	# torso
	var raw_dir = get_aim_dir()
	var adjusted_dir = Vector2i(round(raw_dir.x),round(raw_dir.y))
	$Sprites/TorsoSprite.frame = GUN_DIRECTIONS[adjusted_dir] + int(current_gun) * $Sprites/TorsoSprite.hframes
	$Sprites/TorsoSprite.position.y = 0 if duck else -8

func move(delta):
	if not duck:
		if direction_x :
			velocity.x = move_toward(velocity.x ,direction_x * speed, acceleration * delta)
		else:
			velocity.x = move_toward(velocity.x, 0, friction * delta)
	else:
		velocity.x = move_toward(velocity.x, 0, friction * delta * 2)
	if not is_on_floor():
		velocity.y += get_custom_gravit() * delta

func get_custom_gravit() -> float:
	return jump_gravity if velocity.y < 0.0 else fall_gravity

func _physics_process(delta: float) -> void:
	if not frozen:
		get_input()
		move(delta)
		animation()
		on_floor  = is_on_floor()
		move_and_slide()

func _process(_delta: float) -> void:
	$Sprites/Crosshair.update(get_aim_dir(), crosshair_distance, duck)
	if on_floor and not is_on_floor() and velocity.y >=0:
		$Timer/CoyoteTimer.start()
		
func hit():
	Data.player_health -= 1
	$UI.set_health(Data.player_health)

func get_aim_dir() -> Vector2:
	if controller_aim:
		var contoller_aim_dir = Input.get_vector("aim_left","aim_right","aim_up","aim_down")
		if contoller_aim_dir.length():
			target_dir = contoller_aim_dir.normalized()
	else:
		target_dir = get_local_mouse_position().normalized()
	return target_dir
	
func freeze():
	frozen = true
	$AnimationPlayer.pause()

Making a state machine, while more effort than throwing all the code down is always worth it in my opinion.

You would want to store all the stats of the player in the player script itself and have the state machine have a reference to the player, so you can access them
then each state can grab things like jump height and speed when needed

1 Like

Without even considering the solution, like the one posted in your question in the title of this post, you should first identify what issues you have that you’d like to resolve.

To me, the most jarring issue with your script is that it tries to do everything at once - it handles character shooting, aiming, taking damage, walking, jumping, dashing, animations, etc. It makes it very hard to follow the logic and God forbid you want to add another state or functionality into the mix, like let’s say climbing, or changing weapons - you’ll spend hours debugging and trying to make sure all works together.

We can see that this clearly breaks the Single Responsibility Principle, which can be loosely interpreted that your scripts should handle just one thing at a time. You don’t always have to (or dare I even say you shouldn’t) stick to that perfectly and to-the-dot, but keeping this in mind when designing your architecture usually results in a much cleaner code.

Now, State Machine is just one of the means of achieving this goal and the one I can recommend in your case. I mentioned a couple of other potential solutions in this post: Modularity. Splitting the player into its “moves” and move them out as separate scenes - #5 by wchc.

Reimplementing your current setup into the State Machine like @dragonforge-dev’s GitHub - dragonforge-dev/dragonforge-state-machine: A base state machine class to be used in games. · GitHub might not be straight forward and easy, but something that should be worth an effort in the long run.
Simply separating your states code into their own functions or even separate scripts would already be a huge improvement and a step towards the implementation of the State Machine. Once you have identified what each of the States is actually responsible for, it should be easier then to translate it to the State Machine format. Take it step by step - start with just the movement (you can even utilize the provided examples in the project), see if it works, then implement another State, and another, etc. until you have translated all your States.

Worth mentioning also is that you don’t need to be confined to just 1 State Machine. You can (and I’d argue that you even should) have multiple State Machines that handle different actions in parallel, e.g. moving and shooting. This is something that’s usually overlooked, but opens up a lot of possibilities.

2 Likes

issues to be resolved :

  • to player is not shooting while ducking
  • to player is not duck walk while ducking
  • to player is dash for shoot/dash while ducking which is unwanted

but this leads me to question of rewrite it and simplify as you mentioned :

Which one of them would be simplest for not much experienced person ?
currently use enums in global script for Gun type, Level to be loaded, Enemy Type for spawner .

extends Node

enum Gun {SINGLE, SHOTGUN, ROCKET}
enum Level {SUBWAY, SKY, SUBWAY_2}
enum Enemy {DRONE,SOLDIER}

How would enums splitting would looks like within same script?

These issues are just a consequence of the bigger problem that I mentioned that should be addressed first and foremost:


The simplest would be an enum based State Machine.

Try to first understand what State Machines are, this link from @tibaverus explains it really well, see if you can draw inspiration from it for your own implementation:

3 Likes