Skip to content

Instantly share code, notes, and snippets.

@bingeboy
Last active April 22, 2025 19:28
Show Gist options
  • Save bingeboy/6236c9c4726cf7172c46c2f425763a10 to your computer and use it in GitHub Desktop.
Save bingeboy/6236c9c4726cf7172c46c2f425763a10 to your computer and use it in GitHub Desktop.
Godot 4.* State Machine Abstracted and Ready to Scale for Player and NPC
# Example Enemy implementation (Enemy.gd)
class_name Enemy
extends KinematicBody2D
export var speed: float = 150.0
export var detection_radius: float = 300.0
var velocity: Vector2 = Vector2.ZERO
var target: Player = null
onready var state_machine: StateMachine = $StateMachine
func _physics_process(delta: float) -> void:
velocity = move_and_slide(velocity)
# Example Enemy Attack State (EnemyAttackState.gd)
class_name EnemyAttackState
extends State
var enemy: Enemy
var attack_cooldown: float = 1.0
var current_cooldown: float = 0.0
func _ready() -> void:
enemy = owner as Enemy
func enter() -> void:
enemy.velocity = Vector2.ZERO
current_cooldown = 0.0
# Play attack animation
func physics_update(delta: float) -> void:
if not enemy.target or not is_instance_valid(enemy.target):
get_parent().transition_to("Patrol")
return
current_cooldown += delta
if current_cooldown >= attack_cooldown:
current_cooldown = 0.0
# Perform attack
print("Enemy attacks player!")
var distance = enemy.global_position.distance_to(enemy.target.global_position)
if distance > 60:
get_parent().transition_to("Chase")
# Example Enemy Chase State (EnemyChaseState.gd)
class_name EnemyChaseState
extends State
var enemy: Enemy
var lost_sight_timer: float = 0.0
var max_lost_sight_time: float = 3.0
func _ready() -> void:
enemy = owner as Enemy
func enter() -> void:
lost_sight_timer = 0.0
func physics_update(delta: float) -> void:
if not enemy.target or not is_instance_valid(enemy.target):
get_parent().transition_to("Patrol")
return
var distance = enemy.global_position.distance_to(enemy.target.global_position)
if distance > enemy.detection_radius * 1.5:
lost_sight_timer += delta
if lost_sight_timer > max_lost_sight_time:
enemy.target = null
get_parent().transition_to("Patrol")
else:
lost_sight_timer = 0.0
var direction = (enemy.target.global_position - enemy.global_position).normalized()
enemy.velocity = direction * enemy.speed
if distance < 50:
get_parent().transition_to("Attack")
# Example Enemy Patrol State (EnemyPatrolState.gd)
class_name EnemyPatrolState
extends State
var enemy: Enemy
var patrol_points: Array = []
var current_point_index: int = 0
var patrol_wait_time: float = 2.0
var wait_timer: float = 0.0
var is_waiting: bool = false
func _ready() -> void:
enemy = owner as Enemy
# Collect patrol points from children of a designated node
var patrol_path = enemy.get_node_or_null("PatrolPath")
if patrol_path:
for point in patrol_path.get_children():
patrol_points.append(point.global_position)
func enter() -> void:
if patrol_points.empty():
# If no patrol points, just stand still
return
current_point_index = 0
is_waiting = false
wait_timer = 0.0
func physics_update(delta: float) -> void:
if patrol_points.empty():
return
# Check for player in detection radius
var players = get_tree().get_nodes_in_group("players")
for player in players:
var distance = enemy.global_position.distance_to(player.global_position)
if distance < enemy.detection_radius:
enemy.target = player
get_parent().transition_to("Chase")
return
if is_waiting:
wait_timer += delta
if wait_timer >= patrol_wait_time:
is_waiting = false
wait_timer = 0.0
# Move to next patrol point
current_point_index = (current_point_index + 1) % patrol_points.size()
else:
var target_point = patrol_points[current_point_index]
var direction = (target_point - enemy.global_position).normalized()
enemy.velocity = direction * enemy.speed
# If close enough to the target point, wait
if enemy.global_position.distance_to(target_point) < 10:
enemy.velocity = Vector2.ZERO
is_waiting = true
  • scripts/
    • core/
      • state_machine.gd
      • state.gd
    • player/
      • player.gd
      • states/
        • idle.gd
        • run.gd
        • attack.gd
    • enemies/
      • enemy_base.gd
      • states/
        • patrol.gd
        • chase.gd
        • attack.gd
# Example Player implementation (Player.gd)
class_name Player
extends KinematicBody2D
export var speed: float = 200.0
export var acceleration: float = 800.0
export var friction: float = 600.0
var velocity: Vector2 = Vector2.ZERO
onready var state_machine: StateMachine = $StateMachine
onready var animation_player: AnimationPlayer = $AnimationPlayer
onready var sprite: Sprite = $Sprite
func _ready() -> void:
# Connect to state machine signals if needed
state_machine.connect("state_changed", self, "_on_state_changed")
func _physics_process(delta: float) -> void:
# The state machine handles most logic, but common movement can be here
velocity = move_and_slide(velocity)
func move_toward(target_velocity: Vector2, delta: float) -> void:
velocity = velocity.move_toward(target_velocity, acceleration * delta)
func apply_friction(delta: float) -> void:
velocity = velocity.move_toward(Vector2.ZERO, friction * delta)
func _on_state_changed(state_name: String) -> void:
print("Player changed to state: " + state_name)
# Player Attack State (PlayerAttackState.gd)
class_name PlayerAttackState
extends State
var player: Player
var attack_duration: float = 0.4
var current_attack_time: float = 0.0
var attack_combo_count: int = 0
var max_combo: int = 3
var combo_window: float = 0.8
var combo_timer: float = 0.0
var can_combo: bool = false
func _ready() -> void:
# Cache the player reference
player = owner as Player
func enter() -> void:
current_attack_time = 0.0
# Determine which attack animation to play based on combo
var anim_name = "attack" + str(attack_combo_count + 1)
player.animation_player.play(anim_name)
# Stop horizontal movement during attack but maintain some momentum
player.velocity *= 0.5
func exit() -> void:
if combo_timer > combo_window:
# Reset combo if we exit and combo window has expired
attack_combo_count = 0
func update(delta: float) -> void:
current_attack_time += delta
# After a certain point in the animation, allow combo input
if current_attack_time >= attack_duration * 0.6 and not can_combo:
can_combo = true
if current_attack_time >= attack_duration:
# When attack animation ends
if not can_combo or attack_combo_count >= max_combo - 1:
# Reset combo and return to idle
attack_combo_count = 0
get_parent().transition_to("Idle")
else:
# Start combo window timer
combo_timer = 0.0
func physics_update(delta: float) -> void:
# Apply friction during attack to slow down movement
player.apply_friction(delta * 0.5)
# If we're in combo window, count down
if current_attack_time >= attack_duration and can_combo:
combo_timer += delta
if combo_timer >= combo_window:
# Combo window expired, return to idle
attack_combo_count = 0
get_parent().transition_to("Idle")
func handle_input(event: InputEvent) -> String:
# Check for attack input during combo window
if event.is_action_pressed("attack") and can_combo and current_attack_time >= attack_duration:
# Advance combo
attack_combo_count = (attack_combo_count + 1) % max_combo
return "Attack" # Transition to the next attack in combo
return ""
# Example Player Idle State (PlayerIdleState.gd)
class_name PlayerIdleState
extends State
var player: Player
func _ready() -> void:
# Cache the player reference
player = owner as Player
func enter() -> void:
player.animation_player.play("idle")
func physics_update(delta: float) -> void:
# Apply friction when idle
player.apply_friction(delta)
# Check if we should transition to another state
var input_vector = Vector2(
Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
Input.get_action_strength("move_down") - Input.get_action_strength("move_up")
).normalized()
if input_vector != Vector2.ZERO:
get_parent().transition_to("Run")
# Example Player Run State (PlayerRunState.gd)
class_name PlayerRunState
extends State
var player: Player
func _ready() -> void:
# Cache the player reference
player = owner as Player
func enter() -> void:
player.animation_player.play("run")
func physics_update(delta: float) -> void:
var input_vector = Vector2(
Input.get_action_strength("move_right") - Input.get_action_strength("move_left"),
Input.get_action_strength("move_down") - Input.get_action_strength("move_up")
).normalized()
if input_vector == Vector2.ZERO:
get_parent().transition_to("Idle")
else:
# Update sprite direction
if input_vector.x != 0:
player.sprite.flip_h = input_vector.x < 0
# Move the player
var target_velocity = input_vector * player.speed
player.move_toward(target_velocity, delta)
# Base State Class (State.gd)
class_name State
extends Node
# Virtual function to be overridden by concrete states
func enter() -> void:
pass
# Virtual function to be overridden by concrete states
func exit() -> void:
pass
# Virtual function to be overridden by concrete states
func update(delta: float) -> void:
pass
# Virtual function to be overridden by concrete states
func physics_update(delta: float) -> void:
pass
# Virtual function to be overridden by concrete states
# Returns the next state name if a transition should occur
func handle_input(event: InputEvent) -> String:
return ""
# State Machine Class (StateMachine.gd)
class_name StateMachine
extends Node
signal state_changed(state_name)
export var initial_state: NodePath
var states: Dictionary = {}
var current_state: State
var current_state_name: String = ""
func _ready() -> void:
# Map all child state nodes to their names
for child in get_children():
if child is State:
states[child.name] = child
# Set initial state
if initial_state:
current_state = get_node(initial_state)
current_state_name = current_state.name
current_state.enter()
func _process(delta: float) -> void:
if current_state:
current_state.update(delta)
func _physics_process(delta: float) -> void:
if current_state:
current_state.physics_update(delta)
func _unhandled_input(event: InputEvent) -> void:
if current_state:
var new_state_name = current_state.handle_input(event)
if new_state_name and states.has(new_state_name):
transition_to(new_state_name)
func transition_to(state_name: String) -> void:
if not states.has(state_name):
push_error("State '" + state_name + "' not found in state machine!")
return
if current_state:
current_state.exit()
current_state_name = state_name
current_state = states[state_name]
emit_signal("state_changed", state_name)
current_state.enter()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment