Godot 상태 머신 설계: 캐릭터 상태를 안전하게 분리하는 방법
Godot 4에서 Node 기반 유한 상태 머신을 설계하고 이동, 점프, 낙하 상태를 구현한다. 상태 전환 규칙과 입력·물리 처리의 책임을 분리하는 실용적인 구조를 살펴본다.
상태 머신이 필요한 이유
캐릭터가 단순히 걷기만 할 때는 몇 개의 조건문으로도 충분하다. 하지만 점프, 낙하, 공격, 피격처럼 행동이 늘어나면 _physics_process() 안의 조건문이 서로 얽히기 시작한다.
유한 상태 머신(Finite State Machine, FSM)은 현재 실행할 수 있는 행동을 하나의 상태로 제한한다. 각 상태는 자신의 입력과 물리 처리를 담당하고, 필요한 순간에 다른 상태로 전환한다.
예를 들어 기본적인 플랫포머 캐릭터는 다음 관계를 가진다.
stateDiagram-v2
[*] --> Idle
Idle --> Run: 이동 입력
Run --> Idle: 입력 없음
Idle --> Jump: 점프 입력
Run --> Jump: 점프 입력
Jump --> Fall: velocity.y > 0
Fall --> Idle: is_on_floor()
이 구조의 핵심은 상태마다 코드 파일을 나누는 것보다 상태의 책임과 전환 조건을 명확히 하는 것이다.
Node 기반 구조
Godot에서는 각 상태를 자식 Node로 구성하면 에디터에서 상태 목록을 확인하기 쉽고, 상태별 설정값도 Inspector에 노출할 수 있다.
classDiagram
class CharacterBody2D {
+velocity: Vector2
+move_and_slide()
+is_on_floor() bool
}
class PlayerStateMachine {
+current_state: PlayerState
+change_state(new_state)
}
class PlayerState {
+player: CharacterBody2D
+enter()
+exit()
+physics_update(delta)
}
class IdleState {
+physics_update(delta)
}
class RunState {
+physics_update(delta)
}
class JumpState {
+physics_update(delta)
}
CharacterBody2D *-- PlayerStateMachine
PlayerStateMachine *-- PlayerState
PlayerState <|-- IdleState
PlayerState <|-- RunState
PlayerState <|-- JumpState
예제는 Godot 4와 GDScript를 기준으로 한다.
상태 기반 클래스
모든 상태가 같은 인터페이스를 사용하도록 기반 클래스를 만든다.
# player_state.gd
class_name PlayerState
extends Node
var player: CharacterBody2D
var state_machine: PlayerStateMachine
func setup(
owner_player: CharacterBody2D,
machine: PlayerStateMachine
) -> void:
player = owner_player
state_machine = machine
func enter(_previous_state: PlayerState) -> void:
pass
func exit(_next_state: PlayerState) -> void:
pass
func handle_input(_event: InputEvent) -> void:
pass
func update(_delta: float) -> void:
pass
func physics_update(_delta: float) -> void:
pass
각 메서드의 역할은 다음과 같다.
enter: 애니메이션 재생이나 속도 초기화처럼 진입 시 한 번 수행할 작업exit: 타이머 정리처럼 상태를 떠날 때 필요한 작업handle_input: 이벤트 단위 입력 처리update: 물리 프레임에 종속되지 않는 처리physics_update: 이동과 충돌처럼 고정 물리 프레임에서 수행할 처리
상태 머신
상태 머신은 현재 상태를 보관하고 전환 순서를 통제한다.
# player_state_machine.gd
class_name PlayerStateMachine
extends Node
@export var initial_state: PlayerState
var current_state: PlayerState
var player: CharacterBody2D
var is_transitioning := false
func _ready() -> void:
player = get_parent() as CharacterBody2D
assert(player != null, "StateMachine의 부모는 CharacterBody2D여야 합니다.")
assert(initial_state != null, "initial_state를 지정해야 합니다.")
for child in get_children():
if child is PlayerState:
child.setup(player, self)
current_state = initial_state
current_state.enter(null)
func _unhandled_input(event: InputEvent) -> void:
current_state.handle_input(event)
func _process(delta: float) -> void:
current_state.update(delta)
func _physics_process(delta: float) -> void:
current_state.physics_update(delta)
func transition_to(next_state: PlayerState) -> void:
if next_state == null or next_state == current_state:
return
if is_transitioning:
return
is_transitioning = true
var previous_state := current_state
previous_state.exit(next_state)
current_state = next_state
current_state.enter(previous_state)
is_transitioning = false
exit()을 호출한 뒤 현재 상태를 교체하고 enter()를 호출하는 순서를 모든 전환에서 동일하게 유지하는 것이 중요하다. is_transitioning은 진입·종료 코드에서 다시 전환을 요청해 중첩 전환이 발생하는 것을 막는다.
이동 상태 구현
프로젝트의 Input Map에는 다음 액션이 등록되어 있다고 가정한다.
move_leftmove_rightjump
Idle 상태
# idle_state.gd
class_name IdleState
extends PlayerState
@export var run_state: PlayerState
@export var jump_state: PlayerState
func enter(_previous_state: PlayerState) -> void:
player.velocity.x = 0.0
# player.animation_player.play("idle")
func physics_update(_delta: float) -> void:
if Input.is_action_just_pressed("jump") and player.is_on_floor():
state_machine.transition_to(jump_state)
return
var direction := Input.get_axis("move_left", "move_right")
if not is_zero_approx(direction):
state_machine.transition_to(run_state)
전환을 요청한 뒤에는 return하는 편이 안전하다. 전환 직후 기존 상태의 나머지 로직이 실행되면 새 상태가 설정한 값을 덮어쓸 수 있기 때문이다.
Run 상태
# run_state.gd
class_name RunState
extends PlayerState
@export var idle_state: PlayerState
@export var jump_state: PlayerState
@export var fall_state: PlayerState
@export var speed := 240.0
func enter(_previous_state: PlayerState) -> void:
# player.animation_player.play("run")
pass
func physics_update(_delta: float) -> void:
if Input.is_action_just_pressed("jump") and player.is_on_floor():
state_machine.transition_to(jump_state)
return
var direction := Input.get_axis("move_left", "move_right")
if is_zero_approx(direction):
state_machine.transition_to(idle_state)
return
player.velocity.x = direction * speed
player.move_and_slide()
if not player.is_on_floor():
state_machine.transition_to(fall_state)
CharacterBody2D.move_and_slide()는 velocity를 사용해 본체를 이동시키고 충돌 결과를 갱신한다. 따라서 is_on_floor()를 이용한 착지·낙하 판정은 해당 물리 프레임의 move_and_slide() 이후에 수행하는 것이 명확하다.
Jump 상태
# jump_state.gd
class_name JumpState
extends PlayerState
@export var fall_state: PlayerState
@export var move_speed := 240.0
@export var jump_velocity := -420.0
@export var gravity := 1200.0
func enter(_previous_state: PlayerState) -> void:
player.velocity.y = jump_velocity
# player.animation_player.play("jump")
func physics_update(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
player.velocity.x = direction * move_speed
player.velocity.y += gravity * delta
player.move_and_slide()
if player.velocity.y >= 0.0:
state_machine.transition_to(fall_state)
Godot 2D 좌표에서는 아래쪽이 양의 Y 방향이므로, 음수 Y 속도가 위쪽 점프를 뜻한다.
Fall 상태
# fall_state.gd
class_name FallState
extends PlayerState
@export var idle_state: PlayerState
@export var run_state: PlayerState
@export var move_speed := 240.0
@export var gravity := 1200.0
@export var max_fall_speed := 900.0
func enter(_previous_state: PlayerState) -> void:
# player.animation_player.play("fall")
pass
func physics_update(delta: float) -> void:
var direction := Input.get_axis("move_left", "move_right")
player.velocity.x = direction * move_speed
player.velocity.y = min(
player.velocity.y + gravity * delta,
max_fall_speed
)
player.move_and_slide()
if player.is_on_floor():
if is_zero_approx(direction):
state_machine.transition_to(idle_state)
else:
state_machine.transition_to(run_state)
각 상태의 export 필드에는 에디터에서 대응하는 상태 노드를 연결한다. 문자열이나 NodePath로 상태를 찾는 방식보다 이름 변경에 강하고 잘못된 참조를 Inspector에서 발견하기 쉽다.
책임을 어디에 둘 것인가
상태 머신을 적용했다고 해서 모든 코드를 상태 노드로 옮겨야 하는 것은 아니다. 여러 상태가 공유하는 기능은 캐릭터나 별도 컴포넌트가 담당하는 편이 낫다.
캐릭터 본체에 두기 적합한 데이터는 다음과 같다.
- 체력과 피격 처리
- 공통 이동 속도와 중력 설정
- 애니메이션, 사운드 노드 참조
- 다른 시스템이 구독할 신호
상태에 두기 적합한 데이터는 다음과 같다.
- 상태 진입 시 적용할 초기 속도
- 해당 상태에서만 유효한 타이머
- 상태별 입력 해석
- 다음 상태로 넘어가는 조건
중력처럼 여러 공중 상태가 공유하는 로직은 캐릭터 메서드로 추출할 수 있다.
# player.gd
class_name Player
extends CharacterBody2D
@export var gravity := 1200.0
@export var max_fall_speed := 900.0
func apply_gravity(delta: float) -> void:
velocity.y = min(velocity.y + gravity * delta, max_fall_speed)
이 경우 기반 상태의 player 타입도 CharacterBody2D 대신 Player로 구체화하면 상태에서 공통 메서드를 안전하게 호출할 수 있다.
실전에서 자주 생기는 문제
한 프레임에 여러 번 전환하기
하나의 상태에서 조건을 연속된 if로 검사하면 첫 전환 이후 두 번째 전환까지 실행될 수 있다. 전환 직후 return하거나 우선순위를 if와 elif로 표현해야 한다.
if health <= 0:
state_machine.transition_to(dead_state)
return
if not player.is_on_floor():
state_machine.transition_to(fall_state)
return
죽음처럼 우선순위가 높은 조건은 먼저 검사한다.
상태가 상태 머신 내부를 직접 수정하기
다음과 같이 현재 상태를 직접 대입하면 exit()과 enter()가 누락된다.
# 피해야 할 코드
state_machine.current_state = fall_state
모든 전환은 반드시 transition_to() 같은 단일 진입점을 거쳐야 한다. 그래야 로깅, 전환 검증, 신호 발생을 나중에 한곳에 추가할 수 있다.
애니메이션 완료 신호가 남아 있기
공격 상태에서 animation_finished 신호를 연결했다면 상태 종료 시 연결을 해제하거나, 콜백에서 현재 상태인지 확인해야 한다. 그렇지 않으면 공격이 취소된 뒤 늦게 도착한 신호가 잘못된 전환을 만들 수 있다.
func _on_animation_finished(animation_name: StringName) -> void:
if state_machine.current_state != self:
return
if animation_name == &"attack":
state_machine.transition_to(idle_state)
입력과 물리 처리를 섞기
키 이벤트는 handle_input()에서 받을 수 있지만 실제 이동과 충돌 처리는 physics_update()에서 수행하는 편이 안정적이다. 입력 이벤트에서 직접 move_and_slide()를 호출하면 입력 이벤트 발생 빈도에 따라 이동 처리가 달라질 수 있다.
확장 전략
상태가 많아지면 모든 상태를 서로 직접 참조하는 구조도 복잡해진다. 이때는 다음 단계를 고려할 수 있다.
- 공통 이동·중력 로직을 캐릭터 메서드로 추출한다.
- 전환 가능 여부를 상태 머신에서 검증한다.
- 지상과 공중처럼 공통 동작을 가진 계층형 상태를 도입한다.
- 공격과 이동을 동시에 표현해야 한다면 이동 상태와 행동 상태를 별도 상태 머신으로 나눈다.
다만 계층형 상태나 병렬 상태 머신은 전환 규칙을 더 어렵게 만든다. 기본 FSM에서 실제 중복이나 표현 한계가 확인된 뒤 도입하는 것이 좋다.
정리
Godot 상태 머신의 안정성을 결정하는 기준은 세 가지다.
- 현재 상태만 입력과 업데이트를 처리한다.
- 모든 상태 전환은 상태 머신의 단일 메서드를 통과한다.
- 공통 기능은 캐릭터에, 상태별 규칙은 상태 노드에 둔다.
Node 기반 FSM은 Godot 에디터와 잘 맞고 상태별 설정과 참조를 Inspector에서 관리할 수 있다. 작은 캐릭터에서는 다소 많은 코드처럼 보일 수 있지만, 행동과 전환이 늘어날수록 조건문의 충돌을 줄이고 디버깅할 위치를 분명하게 만든다.