16강애니메이션과 상태 머신
갈 수 있는 상태가 정해져 있어야 꼬이지 않는다.
이 강의 목차 (7)
AnimatedSprite2D와AnimationPlayer를 구분해서 쓴다.- 상태 머신이 왜 필요한지 이해하고 직접 만든다.
- 애니메이션과 게임 로직을 분리하는 구조를 익힌다.
애니메이션 도구 3종#
Godot에는 애니메이션 관련 노드가 여럿입니다. 역할이 다릅니다.
| 노드 | 무엇을 하나 | 언제 |
|---|---|---|
AnimatedSprite2D |
여러 장의 그림을 순서대로 넘김 | 도트 캐릭터, 이펙트 |
AnimationPlayer |
아무 속성이나 시간에 따라 바꿈 | 위치·색·크기·소리·함수 호출 |
AnimationTree |
애니메이션끼리의 전환을 관리 | 복잡한 캐릭터 |
Tween (코드) |
한 값을 A→B로 부드럽게 | 일회성 연출 (17강) |
AnimatedSprite2D= "그림 넘기기 전용". 간단하고 빠름.AnimationPlayer= "타임라인 편집기". 그림뿐 아니라 위치, 회전, 색, 콜라이더 on/off, 사운드 재생, 함수 호출까지 시간축에 올릴 수 있음.
공격 모션에서 "3프레임째에 히트박스 켜기"가 필요하면 AnimationPlayer가 답입니다.
AnimatedSprite2D — 가장 간단한 방법#
AnimatedSprite2D노드 추가- 인스펙터의
Sprite Frames→New SpriteFrames - 하단에 열린 편집기에서 애니메이션 이름을 만들고 프레임 이미지를 끌어다 놓음
Speed(FPS)와Loop설정
$Sprite.play("run")
$Sprite.play("idle")
$Sprite.flip_h = true # 좌우 반전 (5강 참고)
$Sprite.stop()한 장의 이미지에 여러 프레임이 들어 있다면, SpriteFrames 편집기의
"시트에서 프레임 추가" 버튼으로 격자 크기를 지정해 한 번에 잘라 넣을 수 있습니다.
AnimationPlayer — 타임라인 편집#
AnimationPlayer는 아무 노드의 아무 속성이나 키프레임으로 기록합니다.
AnimationPlayer노드 추가- 하단 애니메이션 패널에서
애니메이션 → 새로 만들기 - 아무 노드의 속성 옆 열쇠 아이콘을 누르면 트랙이 생김
- 시간을 옮기고 값을 바꾸면 키프레임이 추가됨
Call Method Track — 특정 시점에 함수를 호출합니다.
0.00s ─────────────────────────
0.15s → enable_hitbox()
0.30s → disable_hitbox()
0.45s → attack_finished()Audio Playback Track — 특정 시점에 소리를 냅니다. 발소리, 검 휘두르는 소리를 애니메이션에 박아둘 수 있습니다.
이 둘 덕분에 "애니메이션이 곧 스펙" 이 됩니다. 공격 판정 타이밍을 코드가 아니라 타임라인에서 조정할 수 있어, 기획자·디자이너도 만질 수 있습니다.
문제 — 애니메이션이 꼬인다#
애니메이션을 이렇게 다루기 시작하면 금방 지옥이 됩니다.
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가 얽힙니다.
"지금 무엇을 하는 중인가"라는 상태가 어디에도 저장되어 있지 않기 때문입니다. 매 프레임 조건을 다시 판단하니 직전에 무슨 일이 있었는지 모릅니다.
해결 — 상태 머신#
- 캐릭터는 항상 정확히 하나의 상태에 있다.
- 각 상태는 자기 할 일만 한다.
- 상태끼리는 정해진 길로만 이동할 수 있다.
지금 있는 역에서 갈 수 있는 역은 정해져 있습니다. "강남역에서 부산역으로 한 번에" 같은 건 없습니다.
idle→run,jump,attack,hurt가능attack→idle만 가능 (공격 중엔 못 움직임)dead→ 어디로도 못 감 (끝)
이 규칙을 코드로 못 박으면 꼬일 수가 없습니다.
간단한 상태 머신 구현#
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개를 넘으면 파일 하나가 너무 길어집니다. 그때는 상태마다 노드를 하나씩 만듭니다.
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 처럼 시간을 하드코딩하면, 애니메이션 길이를 바꿀 때마다 코드를 고쳐야 합니다.
시그널을 쓰면 자동으로 맞습니다.
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) 시그널을 씁니다.
idle, run 처럼 반복되는 애니메이션은 animation_finished가 영원히 안 옵니다.
attack, hurt, die 처럼 한 번만 재생되는 것은 SpriteFrames 편집기에서
Loop를 꺼야 합니다.
로직과 그림을 분리하기#
가장 중요한 원칙을 마지막에 둡니다.
나쁜 구조 — 애니메이션이 게임 규칙을 결정한다
if spr.animation == "attack" and spr.frame == 3:
deal_damage() # 그림이 로직을 좌우함좋은 구조 — 상태가 결정하고, 애니메이션은 그걸 보여준다
# 상태가 바뀔 때 애니메이션을 지정
change(State.ATTACK) # 여기서 play("attack")
# 판정은 상태와 시간이 결정
if state == State.ATTACK and _state_time > 0.12:
enable_hitbox()왜 중요한가? 애니메이션이 없어도 게임이 돌아가야 하기 때문입니다. 프로토타입 단계에서는 그림이 없습니다. 네모 상자만으로 게임 규칙을 완성한 뒤 나중에 그림을 얹는 게 정석입니다.
- 공격 중에 이동 키를 눌러보세요. 캐릭터가 움직입니까? (안 움직여야 정상)
_attack()의 0.4를 1.5로 늘려보세요. 답답합니까?change()에서if state == State.DEAD: return을 지우고 죽은 뒤 움직여 보세요.HURT상태에 무적 시간을 넣어보세요. (_state_time < 0.5동안take_damage를 무시)- 상태가 바뀔 때마다
print(State.keys()[state])를 찍어 흐름을 확인하세요.
- 개발자 — 상태 전환은 반드시
change()하나를 거치게 하세요.state = State.RUN을 직접 대입하는 코드가 생기는 순간 디버깅이 불가능해집니다. - 기획자 — 캐릭터 스펙을 쓸 때 상태 목록과 전환 규칙 표로 적어주세요. "공격 중 이동 가능? / 공격 중 피격 가능? / 피격 중 공격 캔슬 가능?" 이 세 질문이 액션 게임의 손맛을 거의 결정합니다.
- 디자이너 — 애니메이션 길이는 게임 규칙과 직결됩니다. 공격 모션을 0.5초로 그리면 공격 후 0.5초간 무방비입니다. 모션을 만들 때 "선딜/판정/후딜" 프레임 수를 함께 표기해 주세요.
"애니메이션이 1프레임에서 멈춰 있다"
→ 매 프레임 play("run")을 호출하고 있습니다. 같은 이름이면 play()가 재시작하지 않지만,
이름을 바꿔가며 호출하면 계속 리셋됩니다. 상태가 바뀔 때만 호출하세요.
"공격이 끝나고 안 돌아온다"
→ 루프가 켜져 있어 animation_finished가 안 옵니다.
"죽었는데 계속 움직인다"
→ DEAD 상태에서 나가는 경로가 열려 있습니다.
"두 애니메이션이 번갈아 깜빡인다"
→ 두 조건이 서로를 호출하는 무한 전환입니다.
change() 안에 if state == next: return 가드가 있는지 확인하세요.
-
AnimatedSprite2D와AnimationPlayer의 차이를 안다 - 상태 머신이 왜 필요한지 설명할 수 있다
- enum + match로 상태 머신을 만들었다
- 상태 전환을
change()하나로 통제한다 - 애니메이션 없이도 게임 규칙이 돈다
다음 강의 — 규칙도 돌고 애니메이션도 붙었습니다. 이제 같은 게임을 몇 배 재미있게 만드는 연출을 얹습니다. 게임필(Juice)입니다.