- 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
- core/
Last active
April 22, 2025 19:28
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 "" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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