Knockback + StateMachine = weird floating behavior

Godot Version

4.4

Question

Fairly inexperienced in Godot, but I like to think I’m getting a decent handle on problem-solving, but I’ve got a bug that I can’t figure out:

Sequence 01_3

Making a 2d side-scrolling shooter.
My player controller has an all-in-one-code state machine that works perfectly 98% of the time, as well as a simple knockback system that also provides some kickback when the guns are fired. However, I can reliably create a bug where if the player is firing from midair and then lands, the falling state takes a second or two to transition back to one of the grounded states, and occasionally can get stuck for several seconds as long as the button is held. It’s not game-breaking, but I like to understand what’s happening with these things and work out how to solve them early so they don’t become big problems later on.

I thought possibly the y vector of the knockback was causing the character to float off the ground, but I checked the velocity.y and it doesn’t seem to be stalling in that way, and I can reproduce the bug even when the knockback variable of the gun in use is set to 0.

Any ideas as to what’s going on? Including the player’s state machine, movement controls:

extends CharacterBody2D
class_name Player

# This enum lists all the possible states the character can be in.
enum States {IDLE, CROUCHING, RUNNING, CRAWLING, JUMPING, FALLING, WALLGRABBING, WALLJUMPING}
# This variable keeps track of the character's current state.
var state: States = States.IDLE
#state index
var state_names = {	
States.IDLE:"IDLE",
States.CROUCHING:"CROUCHING",
States.RUNNING:"RUNNING",
States.CRAWLING:"CRAWLING",
States.JUMPING:"JUMPING",
States.FALLING:"FALLING",
States.WALLGRABBING:"WALLGRABBING",
States.WALLJUMPING:"WALLJUMPING"
}
#movement
@export var crawl_speed = 800.0
@export var speed = 1600.0
@export var jump_height = 650
@export var jump_time_to_peak = .4
@export var jump_time_to_descent = .3
@export var walljump_force: Vector2 = Vector2(2000,3500)
#acceleration must remain above 250, or state machine fidgets between run and idle against walls--cannot tell why
var acceleration = 250
var deceleration = 0.15
#jump mechanics
@onready var jump_velocity = ((2.0 * jump_height) / jump_time_to_peak) * -1.0
@onready var jump_gravity = ((-2.0 * jump_height) / (jump_time_to_peak * jump_time_to_peak)) * -1.0
@onready var fall_gravity = ((-2.0 * jump_height) / (jump_time_to_descent * jump_time_to_descent)) * -1.0
var has_double_jumped = false
var can_jump = true
var can_wall_grab = false
var is_wall_grabbing_left = false
var is_wall_grabbing_right = false
#component measures
var burn_health = 15
var knockback = Vector2.ZERO
var look_rotation: float = 0.0
@onready var burn_health_component: BurnHealthComponent = $Components/BurnHealthComponent
#gun mechanics
@onready var gun = %Gun
#timers
@onready var jump_timer: Timer = $MovementTimers/JumpTimer
@onready var wall_grab_timer: Timer = $MovementTimers/WallGrabTimer
@onready var wall_jump_timer: Timer = $MovementTimers/WallJumpTimer
#sprites
@onready var sprite_controller: Node2D = $SpriteController
@onready var body_sprite: AnimatedSprite2D = $SpriteController/BodySprite
@onready var head_sprite: AnimatedSprite2D = $SpriteController/HeadSprite
#checkers and collisions
@onready var standing_collision: CollisionShape2D = $StandingCollision
@onready var crouching_collision: CollisionShape2D = $CrouchingCollision
@onready var crouch_check_raycasts: Node2D = $CheckRaycasts/CrouchCheckRaycasts
@onready var crouch_check_left: RayCast2D = $CheckRaycasts/CrouchCheckRaycasts/CrouchCheckLeft
@onready var crouch_check_right: RayCast2D = $CheckRaycasts/CrouchCheckRaycasts/CrouchCheckRight
@onready var wall_check_raycasts: Node2D = $CheckRaycasts/WallCheckRaycasts
@onready var raycast_top_left: RayCast2D = $CheckRaycasts/WallCheckRaycasts/RaycastTopLeft
@onready var raycast_bottom_left: RayCast2D = $CheckRaycasts/WallCheckRaycasts/RaycastBottomLeft
@onready var raycast_top_right: RayCast2D = $CheckRaycasts/WallCheckRaycasts/RaycastTopRight
@onready var raycast_bottom_right: RayCast2D = $CheckRaycasts/WallCheckRaycasts/RaycastBottomRight
@onready var floor_check_raycasts: Node2D = $CheckRaycasts/FloorCheckRaycasts
@onready var floorcheck_right: RayCast2D = $CheckRaycasts/FloorCheckRaycasts/floorcheck_right
@onready var floorcheck_left: RayCast2D = $CheckRaycasts/FloorCheckRaycasts/floorcheck_left
#particles
@onready var burn_particles: CPUParticles2D = $Components/BurningStateComponent/BurnParticles
@onready var run_particles: CPUParticles2D = $ParticleEffects/RunParticles
@onready var jump_particles_left: CPUParticles2D = $ParticleEffects/JumpParticles_left
@onready var jump_particles_right: CPUParticles2D = $ParticleEffects/JumpParticles_right
var jump_particle_emitted = false
#sounds
@onready var jump_sound: AudioStreamPlayer2D = $JumpSound


const DEATH = preload("res://scenes/scenes_enemies/fly_dead.tscn")



func _ready() -> void:
	burn_particles.emitting = false
	head_sprite.visible = true
	gun.visible = true

	
func _process(delta: float) -> void:
	Global.player_position.emit(self.global_position)


func _physics_process(delta):
	knockback = lerp(knockback, Vector2.DOWN, 0.5)
	if is_on_floor():
		knockback.y = 0
	#State machine decision tree (no sprite management)
	if is_on_floor():
		if velocity.x == 0 or (Input.is_action_just_released("input_left") or Input.is_action_just_released("input_right")):
			if Input.is_action_pressed("input_down") or (crouch_check_left.is_colliding() or crouch_check_right.is_colliding()):
				state = States.CROUCHING
			else:
				state = States.IDLE
		if velocity.x != 0 and (Input.is_action_pressed("input_left") or Input.is_action_pressed("input_right")):
			if Input.is_action_pressed("input_down") or (crouch_check_left.is_colliding() or crouch_check_right.is_colliding()):
				state = States.CRAWLING
			else:
				state = States.RUNNING
	if is_on_wall_only() and (can_wall_grab == true):
		if (raycast_bottom_left.is_colliding() and raycast_top_left.is_colliding()) and Input.is_action_pressed("input_left"):
			state = States.WALLGRABBING
			is_wall_grabbing_left = true
			#gun.wallgrab_left = true
		elif (raycast_bottom_right.is_colliding() and raycast_top_right.is_colliding()) and Input.is_action_pressed("input_right"):
			state = States.WALLGRABBING
			is_wall_grabbing_right = true
			#gun.wallgrab_right = true
	elif (!is_on_floor() and velocity.y < 0) and state != States.WALLJUMPING:
		state = States.JUMPING
	elif !is_on_floor() and velocity.y > 0.0 and state != States.WALLJUMPING:
		state = States.FALLING
	
				
	# Set Gravity
	velocity.y += find_gravity() * delta + (knockback.y * .3)
	
	#Horizontal Control
	if state in [States.IDLE, States.CROUCHING, States.RUNNING, States.CRAWLING, States.JUMPING, States.FALLING]:	
		if get_input_velocity() == 1:
			if state == States.CROUCHING or state == States.CRAWLING:
				velocity.x = min(velocity.x + acceleration, crawl_speed) + knockback.x
			else:
				velocity.x =  min(velocity.x + acceleration, speed) + knockback.x
		elif get_input_velocity() == -1:
			if state == States.CROUCHING or state == States.CRAWLING:
				velocity.x =  max(velocity.x - acceleration, -crawl_speed) + knockback.x
			else:
				velocity.x =  max(velocity.x - acceleration, -speed) + knockback.x
		else:
			velocity.x = lerp(velocity.x, 0.0, deceleration) + knockback.x
		can_wall_grab = false

And here is the gun code, feeding the knockback:

extends CharacterBody2D

# This enum lists all the possible states the character can be in. LENGTH is used to restart cycle
enum States {SHOTGUN, FIRE, ICE, SPORE, LENGTH}

# This variable keeps track of the character's current state.
var state: States = States.SHOTGUN

var state_names = {	
States.SHOTGUN:"SHOTGUN",
States.FIRE:"FIRE",
States.ICE:"ICE",
States.SPORE:"SPORE",
States.LENGTH:"LENGTH"
}
const sporebulletPath = preload("res://scenes/scenes_bullets/spore_bullet.tscn")
const shotgunbulletPath = preload("res://scenes/scenes_bullets/shotgun_bullet.tscn")
const firebulletPath = preload("res://scenes/scenes_bullets/fire_bullet.tscn")
const icebulletPath = preload("res://scenes/scenes_bullets/ice_bullet.tscn")
var can_shoot = true
var wallgrab_left = false
var wallgrab_right = false
var gun_rotation: float = 0.0
var gun_available = true

@onready var shoot_delay: Timer = $ShootDelay
@onready var gun_sprite: AnimatedSprite2D = $GunSprite
@onready var gun_state_label: Label = $GunStateLabel
@onready var player: CharacterBody2D = $".."
@onready var shotgun_shoot_sound: AudioStreamPlayer2D = $ShotgunShootSound
@onready var spore_shoot_sound: AudioStreamPlayer2D = $SporeShootSound


func _physics_process(_delta):


	move_and_slide()
	gun_state_label.text = str(state_names[state])
	# and ((rotation_degrees > -90) and (rotation_degrees < 90))
func _process(_delta):
	if Input.is_action_pressed("input_shoot"):
		shoot()

	$".".look_at(get_global_mouse_position())
	
	if wallgrab_left == true:
		if get_rotation_degrees() < -70:
			set_rotation_degrees(-70)
		if get_rotation_degrees() > 70:
			set_rotation_degrees(70)
			
	if wallgrab_right == true:
		if (get_rotation_degrees() > -110) and (get_rotation_degrees()< 0):
			set_rotation_degrees(-110)
		if (get_rotation_degrees() > 0) and (get_rotation_degrees() < 110):
			set_rotation_degrees(110)

	gun_rotation = get_rotation_degrees()

func _input(_event):
	#weapon picking via state machine
	if Input.is_action_just_pressed("cycle_weapon_backward"):
		@warning_ignore("int_as_enum_without_cast")
		state = wrapi(state - 1, States.SHOTGUN, States.LENGTH)
	if Input.is_action_just_pressed("cycle_weapon_forward"):
		@warning_ignore("int_as_enum_without_cast")
		state = wrapi(state + 1, States.SHOTGUN, States.LENGTH)

func shoot():
	if state == States.SPORE:
		if can_shoot == true:
			var sporebullet = sporebulletPath.instantiate()
			get_tree().get_root().add_child(sporebullet)
			sporebullet.position = $Marker2D.global_position
			sporebullet.velocity = Vector2.RIGHT.rotated(deg_to_rad(rotation_degrees))
			sporebullet.rotation = %Gun.rotation
			shoot_delay.set_wait_time(0.1)
			shoot_delay.start()
			can_shoot = false
			spore_shoot_sound.play()
			gun_sprite.play("blast")
			player.knockback = Vector2.LEFT.rotated(deg_to_rad(rotation_degrees)) * 100
			
		
	if state == States.SHOTGUN:
		if gun_available == true && can_shoot == true:
			var shotgunbullet = shotgunbulletPath.instantiate()
			get_tree().get_root().add_child(shotgunbullet)
			shotgunbullet.position = $Marker2D.global_position
			shotgunbullet.velocity = Vector2.RIGHT.rotated(deg_to_rad(rotation_degrees))
			shotgunbullet.rotation = %Gun.rotation
			shoot_delay.set_wait_time(0.7)
			shoot_delay.start()
			can_shoot = false
			shotgun_shoot_sound.play()
			gun_sprite.play("blast")
			player.knockback = Vector2.LEFT.rotated(deg_to_rad(rotation_degrees)) * 600
			
	if state == States.FIRE:
		if gun_available == true && can_shoot == true:
			var firebullet = firebulletPath.instantiate()
			get_tree().get_root().add_child(firebullet)
			firebullet.position = $Marker2D.global_position
			firebullet.velocity = firebullet.velocity.rotated(deg_to_rad(rotation_degrees))
			firebullet.rotation = %Gun.rotation
			shoot_delay.set_wait_time(0.1)
			shoot_delay.start()
			can_shoot = false
			spore_shoot_sound.play()
			gun_sprite.play("blast")
			player.knockback = Vector2.LEFT.rotated(deg_to_rad(rotation_degrees)) * 100
			
	if state == States.ICE:
		if gun_available == true && can_shoot == true:
			var icebullet = icebulletPath.instantiate()
			get_tree().get_root().add_child(icebullet)
			icebullet.position = $Marker2D.global_position
			icebullet.velocity = Vector2.RIGHT.rotated(deg_to_rad(rotation_degrees))
			icebullet.rotation = %Gun.rotation
			shoot_delay.set_wait_time(1)
			shoot_delay.start()
			can_shoot = false
			spore_shoot_sound.play()
			gun_sprite.play("blast")
			player.knockback = Vector2.LEFT.rotated(deg_to_rad(rotation_degrees)) * 700
			


func _on_shoot_delay_timeout() -> void:
	can_shoot = true
	gun_sprite.play("rest")

If I look at your video, I can see that after you fire the gun in mid-air and make contact with the ground, there is still a teeny-tiny bit of movement in the x-direction, probably caused by the gun’s knockback instead of player input. This section of code then becomes relevant:

The falling character eventually makes contact with the floor, so is_on_floor is true.

The character’s velocity in the x-direction is not 0; there’s still some tiny movement left. So this if-statement is not entered:

if velocity.x == 0 or (Input.is_action_just_released("input_left") or Input.is_action_just_released("input_right")):

The next if-statement is likely also ignored:

if velocity.x != 0 and (Input.is_action_pressed("input_left") or Input.is_action_pressed("input_right")):

The velocity in the x-direction is indeed not 0, but the other requirements (input_left or input_right) pressed are not met.

So even though the player is on the floor, no new state is set. The player remains stuck inside the falling state until either the x-velocity becomes 0, or until the velocity is not zero and the player presses input_right or input_left.

1 Like

THANK YOU! Haven’t tested a fix yet, but you’re right that when the X velocity hits actual 0 it tends to move states, so I’m guessing this is the issue. I’d been looking at the Y velocity and everything related to that, hadn’t even thought of looking at X.

Can confirm–this fixed it. Far easier solution than I had hoped for. Cheers!