Unity에서 2D 캐릭터 사공 스파인 애니메이션 상태 머신 연결하기
Spine-Unity의 SkeletonAnimation을 코드 상태 머신과 연결해 대기, 이동, 공격 애니메이션을 안정적으로 전환하는 방법을 정리합니다.

왜 상태 머신이 필요한가
Spine 애니메이션은 AnimationState를 통해 재생할 수 있지만 입력 처리 코드 곳곳에서 SetAnimation()을 직접 호출하면 전환 조건이 쉽게 흩어진다. 예를 들어 이동 중 공격을 시작했을 때 이동 애니메이션이 즉시 덮어쓰이거나 공격 종료 뒤 어떤 상태로 돌아가야 하는지 알기 어려워진다.
상태 머신은 캐릭터의 현재 행동과 전환 규칙을 한곳에 모은다. 게임 로직은 상태를 바꾸고 상태는 Spine 애니메이션을 재생하는 구조로 나누면 애니메이션 제어가 단순해진다.

상태와 전환 규칙 먼저 정하기
간단한 플레이어 캐릭터라면 Idle, Move, Attack 세 상태부터 시작할 수 있다. 공격 중에는 이동 입력이 들어와도 공격을 유지하고 공격 애니메이션이 끝난 뒤에 현재 입력에 따라 대기 또는 이동으로 돌아가도록 정한다.
flowchart LR
Idle[Idle] -->|이동 입력| Move[Move]
Move -->|이동 입력 없음| Idle
Idle -->|공격 입력| Attack[Attack]
Move -->|공격 입력| Attack
Attack -->|애니메이션 완료| Idle
Attack -->|애니메이션 완료 + 이동 입력| Move
이 규칙에서 중요한 점은 공격 완료가 전환의 기준이라는 것이다. 시간 값을 추측해 기다리기보다 Spine 트랙 엔트리의 완료 이벤트를 사용하면 애니메이션 길이가 바뀌어도 코드가 유지된다.
Spine-Unity 준비
Unity 프로젝트에 Spine 런타임을 추가한 뒤 캐릭터 오브젝트에 SkeletonAnimation 컴포넌트를 붙인다. Skeleton Data Asset을 연결하고 Spine 에디터에서 만든 애니메이션 이름이 idle, run, attack이라고 가정하겠다.
애니메이션 이름은 대소문자를 구분하므로 코드와 Spine 에디터의 이름을 정확히 맞춰야 한다. 여러 레이어를 쓰지 않는 기본 캐릭터는 보통 트랙 0만으로 충분하다.

코드 상태 머신 구현
아래 예제는 상태가 실제로 바뀔 때만 SetAnimation()을 호출한다. 매 프레임 같은 애니메이션을 다시 설정하지 않으므로 트랙이 불필요하게 초기화되지 않는다.
using Spine;
using Spine.Unity;
using UnityEngine;
public class PlayerSpineStateMachine : MonoBehaviour
{
private enum PlayerState
{
Idle,
Move,
Attack
}
[SerializeField] private SkeletonAnimation skeletonAnimation;
[SerializeField] private float moveThreshold = 0.01f;
private PlayerState currentState;
private float horizontalInput;
private void Awake()
{
if (skeletonAnimation == null)
{
skeletonAnimation = GetComponent<SkeletonAnimation>();
}
ChangeState(PlayerState.Idle);
}
private void Update()
{
horizontalInput = Input.GetAxisRaw("Horizontal");
if (currentState != PlayerState.Attack)
{
ChangeState(Mathf.Abs(horizontalInput) > moveThreshold
? PlayerState.Move
: PlayerState.Idle);
}
if (Input.GetButtonDown("Fire1") && currentState != PlayerState.Attack)
{
ChangeState(PlayerState.Attack);
}
}
private void ChangeState(PlayerState nextState)
{
if (currentState == nextState)
{
return;
}
currentState = nextState;
switch (currentState)
{
case PlayerState.Idle:
skeletonAnimation.state.SetAnimation(0, "idle", true);
break;
case PlayerState.Move:
skeletonAnimation.state.SetAnimation(0, "run", true);
break;
case PlayerState.Attack:
TrackEntry attackTrack = skeletonAnimation.state.SetAnimation(0, "attack", false);
attackTrack.Complete += OnAttackComplete;
break;
}
}
private void OnAttackComplete(TrackEntry trackEntry)
{
ChangeState(Mathf.Abs(horizontalInput) > moveThreshold
? PlayerState.Move
: PlayerState.Idle);
}
}
attack은 한 번만 재생해야 하므로 세 번째 인수에 false를 전달한다. 반대로 idle과 run은 반복 재생해야 하므로 true를 사용한다.
전환이 어색할 때 확인할 항목
믹스 시간을 설정한다
애니메이션이 즉시 끊겨 보인다면 AnimationStateData의 믹스 시간을 설정할 수 있다. 값이 너무 크면 반응이 늦어지는 느낌이 나므로 짧은 값부터 조정하는 편이 좋다.
private void Start()
{
skeletonAnimation.AnimationState.Data.SetMix("idle", "run", 0.1f);
skeletonAnimation.AnimationState.Data.SetMix("run", "idle", 0.1f);
skeletonAnimation.AnimationState.Data.SetMix("run", "attack", 0.05f);
skeletonAnimation.AnimationState.Data.SetMix("attack", "idle", 0.08f);
}
프로젝트에서 사용하는 Spine 런타임 버전에 따라 속성 표기가 다를 수 있으므로 현재 설치된 런타임의 API도 함께 확인한다.
완료 이벤트가 여러 번 연결되지 않게 한다
공격 상태에 진입할 때마다 새 TrackEntry를 받아 해당 엔트리에만 Complete 콜백을 연결했다. skeletonAnimation.state.Complete처럼 전역 이벤트를 사용하면 다른 트랙이나 이후 애니메이션의 완료까지 처리할 수 있으므로 어떤 애니메이션 완료를 감지할지 분명히 구분해야 한다.
애니메이션과 게임 판정을 분리한다
공격 판정은 애니메이션의 특정 프레임과 맞춰야 하는 경우가 많다. 이때 공격 애니메이션이 끝났다는 이벤트만으로 판정을 처리하기보다 Spine의 이벤트 타임라인에 hit 같은 이벤트를 넣고 해당 이벤트를 받아 판정을 실행하는 방식이 더 정확하다.
확장 방향
상태가 늘어나면 Hurt, Dead, Jump처럼 우선순위가 높은 상태를 추가할 수 있다. 특히 Dead는 다른 입력 전환을 막고 Hurt는 피격 중 재피격 가능 여부를 별도 규칙으로 관리하는 편이 좋다.
핵심은 Spine을 상태 판단 도구로 쓰기보다 표현 계층으로 두는 것이다. 상태 머신이 행동을 결정하고 Spine은 결정된 상태를 재생하며 이벤트 타임라인은 타격이나 발소리처럼 정확한 시점이 필요한 게임 로직에 연결한다. 이 경계를 유지하면 애니메이션 수가 늘어나도 전환 규칙을 추적하기 쉬워진다.



