2D Godot 2D 게임 기초
P4 화면 연출

16강애니메이션과 상태 머신

갈 수 있는 상태가 정해져 있어야 꼬이지 않는다.

이 강의 목차 (7)
  1. 애니메이션 도구 3종
  2. AnimatedSprite2D — 가장 간단한 방법
  3. AnimationPlayer — 타임라인 편집
  4. 문제 — 애니메이션이 꼬인다
  5. 해결 — 상태 머신
  6. 애니메이션 끝을 기다리기
  7. 로직과 그림을 분리하기
이번 강의의 목표
  • AnimatedSprite2DAnimationPlayer를 구분해서 쓴다.
  • 상태 머신이 왜 필요한지 이해하고 직접 만든다.
  • 애니메이션과 게임 로직을 분리하는 구조를 익힌다.

애니메이션 도구 3종#

Godot에는 애니메이션 관련 노드가 여럿입니다. 역할이 다릅니다.

노드 무엇을 하나 언제
AnimatedSprite2D 여러 장의 그림을 순서대로 넘김 도트 캐릭터, 이펙트
AnimationPlayer 아무 속성이나 시간에 따라 바꿈 위치·색·크기·소리·함수 호출
AnimationTree 애니메이션끼리의 전환을 관리 복잡한 캐릭터
Tween (코드) 한 값을 A→B로 부드럽게 일회성 연출 (17강)
가장 큰 차이
  • AnimatedSprite2D = "그림 넘기기 전용". 간단하고 빠름.
  • AnimationPlayer = "타임라인 편집기". 그림뿐 아니라 위치, 회전, 색, 콜라이더 on/off, 사운드 재생, 함수 호출까지 시간축에 올릴 수 있음.

공격 모션에서 "3프레임째에 히트박스 켜기"가 필요하면 AnimationPlayer가 답입니다.

AnimatedSprite2D — 가장 간단한 방법#

  1. AnimatedSprite2D 노드 추가
  2. 인스펙터의 Sprite FramesNew SpriteFrames
  3. 하단에 열린 편집기에서 애니메이션 이름을 만들고 프레임 이미지를 끌어다 놓음
  4. Speed(FPS)와 Loop 설정
gdscript
$Sprite.play("run")
$Sprite.play("idle")
$Sprite.flip_h = true      # 좌우 반전 (5강 참고)
$Sprite.stop()
스프라이트 시트에서 자르기

한 장의 이미지에 여러 프레임이 들어 있다면, SpriteFrames 편집기의 "시트에서 프레임 추가" 버튼으로 격자 크기를 지정해 한 번에 잘라 넣을 수 있습니다.

AnimationPlayer — 타임라인 편집#

AnimationPlayer아무 노드의 아무 속성이나 키프레임으로 기록합니다.

  1. AnimationPlayer 노드 추가
  2. 하단 애니메이션 패널에서 애니메이션 → 새로 만들기
  3. 아무 노드의 속성 옆 열쇠 아이콘을 누르면 트랙이 생김
  4. 시간을 옮기고 값을 바꾸면 키프레임이 추가됨
특별한 트랙 두 가지

Call Method Track — 특정 시점에 함수를 호출합니다.

text
0.00s  ─────────────────────────
0.15s  → enable_hitbox()
0.30s  → disable_hitbox()
0.45s  → attack_finished()

Audio Playback Track — 특정 시점에 소리를 냅니다. 발소리, 검 휘두르는 소리를 애니메이션에 박아둘 수 있습니다.

이 둘 덕분에 "애니메이션이 곧 스펙" 이 됩니다. 공격 판정 타이밍을 코드가 아니라 타임라인에서 조정할 수 있어, 기획자·디자이너도 만질 수 있습니다.

문제 — 애니메이션이 꼬인다#

애니메이션을 이렇게 다루기 시작하면 금방 지옥이 됩니다.

gdscript
func _physics_process(delta):
	if velocity.length() > 0:
		$Sprite.play("run")
	else:
		$Sprite.play("idle")

	if Input.is_action_just_pressed("attack"):
		$Sprite.play("attack")

	if hp <= 0:
		$Sprite.play("die")

문제 — 공격 중에 움직이면 다음 프레임에 run이 덮어씁니다. 죽는 중에 움직이면 run이 나옵니다. 조건을 하나씩 추가할수록 if가 얽힙니다.

근본 원인

"지금 무엇을 하는 중인가"라는 상태가 어디에도 저장되어 있지 않기 때문입니다. 매 프레임 조건을 다시 판단하니 직전에 무슨 일이 있었는지 모릅니다.

해결 — 상태 머신#

상태 머신 (State Machine)
  1. 캐릭터는 항상 정확히 하나의 상태에 있다.
  2. 각 상태는 자기 할 일만 한다.
  3. 상태끼리는 정해진 길로만 이동할 수 있다.
지하철 노선도

지금 있는 역에서 갈 수 있는 역은 정해져 있습니다. "강남역에서 부산역으로 한 번에" 같은 건 없습니다.

  • idlerun, jump, attack, hurt 가능
  • attackidle 만 가능 (공격 중엔 못 움직임)
  • dead어디로도 못 감 (끝)

이 규칙을 코드로 못 박으면 꼬일 수가 없습니다.

간단한 상태 머신 구현#

gdscript
extends CharacterBody2D

enum State { IDLE, RUN, JUMP, ATTACK, HURT, DEAD }

@export var speed := 300.0
@export var accel := 2200.0

var state: State = State.IDLE
var _state_time := 0.0

@onready var spr: AnimatedSprite2D = $Sprite

func _physics_process(delta: float) -> void:
	_state_time += delta
	match state:
		State.IDLE:   _idle(delta)
		State.RUN:    _run(delta)
		State.JUMP:   _jump(delta)
		State.ATTACK: _attack(delta)
		State.HURT:   _hurt(delta)
		State.DEAD:   _dead(delta)
	move_and_slide()

# ── 상태 전환은 이 함수 하나로만 한다 ─────
func change(next: State) -> void:
	if state == next:
		return
	if state == State.DEAD:
		return                      # 죽으면 못 나감
	state = next
	_state_time = 0.0
	match next:
		State.IDLE:   spr.play("idle")
		State.RUN:    spr.play("run")
		State.JUMP:   spr.play("jump")
		State.ATTACK: spr.play("attack")
		State.HURT:   spr.play("hurt")
		State.DEAD:   spr.play("die")

# ── 각 상태의 행동 ───────────────
func _idle(delta: float) -> void:
	velocity = velocity.move_toward(
		Vector2.ZERO, accel * delta)
	if Input.is_action_just_pressed("attack"):
		change(State.ATTACK)
	elif _input_dir() != Vector2.ZERO:
		change(State.RUN)

func _run(delta: float) -> void:
	var dir := _input_dir()
	velocity = velocity.move_toward(
		dir * speed, accel * delta)
	if dir.x != 0.0:
		spr.flip_h = dir.x < 0.0
	if Input.is_action_just_pressed("attack"):
		change(State.ATTACK)
	elif dir == Vector2.ZERO:
		change(State.IDLE)

func _attack(delta: float) -> void:
	# 공격 중엔 못 움직인다.
	velocity = velocity.move_toward(
		Vector2.ZERO, accel * 2.0 * delta)
	if _state_time >= 0.4:
		change(State.IDLE)

func _hurt(delta: float) -> void:
	velocity = velocity.move_toward(
		Vector2.ZERO, accel * 0.5 * delta)
	if _state_time >= 0.25:
		change(State.IDLE)

func _jump(_delta: float) -> void:
	pass

func _dead(_delta: float) -> void:
	velocity = Vector2.ZERO

func _input_dir() -> Vector2:
	return Input.get_vector(
		"move_left", "move_right", "move_up", "move_down")

# ── 외부에서 부르는 것 ─────────────
func take_damage(amount: int) -> void:
	if state == State.DEAD:
		return
	hp -= amount
	change(State.DEAD if hp <= 0 else State.HURT)
이 구조가 주는 것
  • 공격 중에 이동 입력이 와도 무시됩니다. _attack()이 이동을 안 하니까요.
  • 죽으면 아무 상태로도 못 갑니다. change()에서 막습니다.
  • 새 상태를 추가할 때 기존 코드를 안 건드립니다. enum에 하나, match에 하나, 함수 하나.
  • _state_time으로 "이 상태에 들어온 지 얼마나 됐나" 를 항상 알 수 있습니다.
상태를 노드로 분리하기 (규모가 커질 때)

상태가 10개를 넘으면 파일 하나가 너무 길어집니다. 그때는 상태마다 노드를 하나씩 만듭니다.

text
Player
└─ StateMachine   (Node)
    ├─ Idle       (Node, idle_state.gd)
    ├─ Run        (Node, run_state.gd)
    └─ Attack     (Node, attack_state.gd)

각 상태 스크립트는 enter(), exit(), update(delta) 세 함수를 갖고, StateMachine이 현재 상태 노드에게 위임합니다.

장점 — 상태별로 파일이 나뉘어 여럿이 동시에 작업 가능. 단점 — 파일이 많아지고, 상태 간 데이터 공유가 번거로움.

상태가 5개 이하면 enum, 그 이상이면 노드 분리가 실용적인 기준입니다.

애니메이션 끝을 기다리기#

_state_time >= 0.4 처럼 시간을 하드코딩하면, 애니메이션 길이를 바꿀 때마다 코드를 고쳐야 합니다. 시그널을 쓰면 자동으로 맞습니다.

gdscript
func _ready() -> void:
	spr.animation_finished.connect(_on_anim_finished)

func _on_anim_finished() -> void:
	match state:
		State.ATTACK, State.HURT:
			change(State.IDLE)

AnimationPlayer라면 animation_finished(anim_name) 시그널을 씁니다.

루프 애니메이션은 finished가 안 온다

idle, run 처럼 반복되는 애니메이션은 animation_finished영원히 안 옵니다. attack, hurt, die 처럼 한 번만 재생되는 것은 SpriteFrames 편집기에서 Loop를 꺼야 합니다.

로직과 그림을 분리하기#

가장 중요한 원칙을 마지막에 둡니다.

애니메이션은 결과이지 원인이 아니다

나쁜 구조 — 애니메이션이 게임 규칙을 결정한다

gdscript
if spr.animation == "attack" and spr.frame == 3:
	deal_damage()      # 그림이 로직을 좌우함

좋은 구조 — 상태가 결정하고, 애니메이션은 그걸 보여준다

gdscript
# 상태가 바뀔 때 애니메이션을 지정
change(State.ATTACK)   # 여기서 play("attack")
# 판정은 상태와 시간이 결정
if state == State.ATTACK and _state_time > 0.12:
	enable_hitbox()

왜 중요한가? 애니메이션이 없어도 게임이 돌아가야 하기 때문입니다. 프로토타입 단계에서는 그림이 없습니다. 네모 상자만으로 게임 규칙을 완성한 뒤 나중에 그림을 얹는 게 정석입니다.

직접 바꿔보기
  1. 공격 중에 이동 키를 눌러보세요. 캐릭터가 움직입니까? (안 움직여야 정상)
  2. _attack()의 0.4를 1.5로 늘려보세요. 답답합니까?
  3. change()에서 if state == State.DEAD: return 을 지우고 죽은 뒤 움직여 보세요.
  4. HURT 상태에 무적 시간을 넣어보세요. (_state_time < 0.5 동안 take_damage를 무시)
  5. 상태가 바뀔 때마다 print(State.keys()[state]) 를 찍어 흐름을 확인하세요.
  • 개발자 — 상태 전환은 반드시 change() 하나를 거치게 하세요. state = State.RUN 을 직접 대입하는 코드가 생기는 순간 디버깅이 불가능해집니다.
  • 기획자 — 캐릭터 스펙을 쓸 때 상태 목록과 전환 규칙 표로 적어주세요. "공격 중 이동 가능? / 공격 중 피격 가능? / 피격 중 공격 캔슬 가능?" 이 세 질문이 액션 게임의 손맛을 거의 결정합니다.
  • 디자이너 — 애니메이션 길이는 게임 규칙과 직결됩니다. 공격 모션을 0.5초로 그리면 공격 후 0.5초간 무방비입니다. 모션을 만들 때 "선딜/판정/후딜" 프레임 수를 함께 표기해 주세요.
자주 나는 오류

"애니메이션이 1프레임에서 멈춰 있다" → 매 프레임 play("run")을 호출하고 있습니다. 같은 이름이면 play()가 재시작하지 않지만, 이름을 바꿔가며 호출하면 계속 리셋됩니다. 상태가 바뀔 때만 호출하세요.

"공격이 끝나고 안 돌아온다" → 루프가 켜져 있어 animation_finished가 안 옵니다.

"죽었는데 계속 움직인다"DEAD 상태에서 나가는 경로가 열려 있습니다.

"두 애니메이션이 번갈아 깜빡인다" → 두 조건이 서로를 호출하는 무한 전환입니다. change() 안에 if state == next: return 가드가 있는지 확인하세요.

여기까지 확인
  • AnimatedSprite2DAnimationPlayer의 차이를 안다
  • 상태 머신이 왜 필요한지 설명할 수 있다
  • enum + match로 상태 머신을 만들었다
  • 상태 전환을 change() 하나로 통제한다
  • 애니메이션 없이도 게임 규칙이 돈다

다음 강의 — 규칙도 돌고 애니메이션도 붙었습니다. 이제 같은 게임을 몇 배 재미있게 만드는 연출을 얹습니다. 게임필(Juice)입니다.