Spine

Spine Basics

스파인 관련 글을 작성한지 3개월이 지났는데 알게된 것도 많고 한번 정리할려고 글을 작성한다.

스파인을 유니티에서 유용하게 사용하고 싶다면 지금 나와있는 다양한 예제나 강의글을 참고하면 좋지만 생각보다 정보의 양이 매우 적다.

물론 스파인 애니메이션 자체를 메카님 애니메이션으로 돌려서 사용이 가능하지만 그 만큼 자유도?가 떨어지기 때문에 코드로 애니메이션을 제어해보는 걸 추천한다.

전 포스팅에서는 사용방법에 대해서 간략하게 다루었다면 이번 글에는 직접 사용해보고 유용한 점이나 다양한 기능들 위주로 알아본다.

SkeletonAnimation

이미지

Spine에서 인코딩되어 넘어오면 Skeleton Data Asset라는 스파인 객체를 씬에 드래그하게 되면 스파인 객체가 생성이된다.

해당 객체에 자동으로 생성되어 있는 컴포넌트가 위에서 보이는 SkeletonAnimation스크립트이다.

스파인 애니메이션을 다루기 위해선 이 컴포넌트를 필수적으로 참조하여 만들어야 한다.

기본적으로 layer에 대한 설정, 기본 애니메이션, 기본 스킨등을 설정할 수 있으며 Project뷰에서 볼 수 있는 Skeleton Data Asset 에서 좀 더 기본적인 세세한 설정이 가능해진다.

이미지

해당 오브젝트에서 크기를 제어할 수 있지만 왠만하면 1로두고 여기서 scale값을 만지는 걸 추천한다.

가장 중요한 점은 기본적으로 설정되어 있는 Mix Settings의 Mix Duration인데 애니메이션과 애니메이션이 섞이는 시간이라고 생각하면 된다.

저 값이 0이면 애니메이션이 바로바로 전환되고 0.2값을 주게 되면 뼈의 위치에 따라 자연스럽게 동작이 전환된다.

Skeleton Animation Handler

우선 Unity에서 Spine애니메이션을 제어하기 위해선 크게 두 가지 메서드를 사용하여 애니메이션을 제어한다고 할 수 있다.

SetAnimation(), AddAnimation()

이 두가지 메서드는 SkeletonAnimation스크립트에서 AnimationState라는 프로퍼티에서 사용가능하다.

이러한 두가지 메서드를 효율적으로 사용하기 위해선 애니메이션 Handler를 만들어 두고 해당 메서드를 호출하여 처리하는 방법이 효율적이다.

구조

이미지

SetAnimation

SetAnimation(trackIndex, anim, false); // 사용법

public TrackEntry SetAnimation (int trackIndex, Animation animation, bool loop) // 원형

  1. trackIndex: 애니메이션을 재생할 track 포토샵의 레이어라고 생각하면 된다.
  2. animation: 재생할 애니메이션(Spine의 namespace에 속해야 한다.)
  3. loop: 애니메이션을 loop로 재생하는지 여부

반환값이 TrackEntry라는 점을 이용하여 다음과 같이 사용이 가능하다.

SetAnimation(layerIndex, transition, false).TimeScale = speed;

TrackEntry

원형에서 볼 수 있듯이 TrackEntry를 반환하기 때문에 정말 여러가지를 세세하게 조정이 가능하다.

내부데이터에 프로퍼티로 값을 열어두었기 때문에 loop, TrackIndex, TimeScale, 이벤트 등등 Handler를 잘만 조작한다면 크게 문제되는게 없다.

이미지

AddAnimation

AddAnimation(layerIndex, transition, false, 0f); // 사용

public TrackEntry AddAnimation (int trackIndex, Animation animation, bool loop, float delay)

  1. trackIndex: 애니메이션을 재생할 track 포토샵의 레이어라고 생각하면 된다.
  2. animation: 재생할 애니메이션(Spine의 namespace에 속해야 한다.)
  3. loop: 애니메이션을 loop로 재생하는지 여부
  4. delay: 해당 애니메이션이 add되는 delay(mix duration의 영향을 받는다.)

SetAnimMation과 다른 점은 앞에서 설정한 애니메이션이 종료되고 이후에 실행된다는 점이다.

앞서 설장한 Set애니메이션이 loop라면 문제가 되지 않을까? 라는 궁금증이 생기지만 SkeletonAnimation에서 애니메이션을 제어할 경우 생기는 문제점에 대해서 미리 생각하고 만든 것 같다.

SetAnimation으로 총을 쏘는 애니메이션을 단발성으로(loop = false;) 재생한다면 이후에 재생되는 애니메이션 값을 조절할 때는 TrackEntry로 전 애니메이션 실행값을 구하고 해당 애니메이션 이후에 SetAnimation통해 실행하는 것 보다 전 애니메이션을 AddAnimation으로 loop로 걸어두게 되면 oneShot같은 애니메이션 처리가 간단해진다.

뒤에 핸들러에서 자세하게 다룰 예정


다시 Skeleton Animation Handler로 돌아와서 이 핸들러의 주된 기능은 Player뿐만 아니라 몬스터 보스 다른 객체에도 달아서 애니메이션을 spine애니메이션을 효율적으로 사용하기 위함이다.

using System.Collections.Generic;
using Spine;
using Spine.Unity;
using UnityEngine;
public class SkeletonAnimationHandler : MonoBehaviour
{
    public Spine.Animation TargetAnimation { get; private set; }

    public SkeletonAnimation skeletonAnimation;
    public List<StateNameToAnimationReference> statesAndAnimations = new List<StateNameToAnimationReference>();
    public List<AnimationTransition> transitions = new List<AnimationTransition>();

    [System.Serializable]
    public class StateNameToAnimationReference
    {
        public string stateName;
        public AnimationReferenceAsset animation;
    }

    [System.Serializable]
    public class AnimationTransition {
        public AnimationReferenceAsset from;
        public AnimationReferenceAsset to;
        public AnimationReferenceAsset transition;
    }

    private void Awake ()
    {
      // 초기화 과정
        foreach (var entry in statesAndAnimations) {
            entry.animation.Initialize();
        }

        foreach (var entry in transitions) {
            entry.from.Initialize();
            entry.to.Initialize();
            entry.transition.Initialize();
        }
    }
    
    public void SetFlip(float horizontal)
    {
        if (horizontal != 0)
        {
            skeletonAnimation.skeleton.ScaleX = horizontal > 0 ? 1f : -1f;
        }
    }

    public void PlayAnimationForState (string stateShortName, int layerIndex, bool oneshot, float speed) {
        PlayAnimationForState(StringToHash(stateShortName), layerIndex, oneshot, speed);
    }

    /// <summary>
    /// PlayAnimationForState Overloading 해당 애니메이션을 실행
    /// </summary>
    /// <param name="stateShortName">실행하고자 하는 애니메이션 이름</param>
    /// <param name="layerIndex">트랙/레이어 번호</param>
    public void PlayAnimationForState (int shortNameHash, int layerIndex, bool oneshot, float speed) {
        var foundAnimation = GetAnimationForState(shortNameHash);
        if (foundAnimation == null)
            return;

        if (oneshot)
        {
            PlayOneShot(foundAnimation, layerIndex, speed);
        }
        else
        {
            PlayNewAnimation(foundAnimation, layerIndex, speed);
        }
    }

    public Spine.Animation GetAnimationForState (string stateShortName) {
        return GetAnimationForState(StringToHash(stateShortName));
    }
    
    /// <summary>
    /// GetAnimationForState Overloading 해당 애니메이션을 반환(없다면 null)
    /// </summary>
    /// <param name="stateShortName">찾고자 하는 애니메이션 이름(정수로 들어옴)</param>
    /// <returns>해당 애니메이션</returns>
    public Spine.Animation GetAnimationForState (int shortNameHash) {
        var foundState = statesAndAnimations.Find(entry => StringToHash(entry.stateName) == shortNameHash);
        return (foundState == null) ? null : foundState.animation;
    }


    /// <summary>
    /// 애니메이션 재생 메서드
    /// 현재 진행중인 애니메이션이 없다면 || 전환 애니메이션이 없다면 바로 애니메이션 전환
    /// 있다면 전환 애니메이션 우선 재생 후 재생
    /// </summary>
    /// <param name="target"></param>
    /// <param name="layerIndex"></param>
    public void PlayNewAnimation (Spine.Animation target, int layerIndex, float speed) {
        Spine.Animation transition = null;
        Spine.Animation current = null;

        current = GetCurrentAnimation(layerIndex);
        if (current != null)
            transition = TryGetTransition(current, target);

        if (transition != null)
        {
            skeletonAnimation.AnimationState.SetAnimation(layerIndex, transition, false).TimeScale = speed;
            skeletonAnimation.AnimationState.AddAnimation(layerIndex, target, true, 0).TimeScale = speed;
        } else {
            skeletonAnimation.AnimationState.SetAnimation(layerIndex, target, true).TimeScale = speed;
        }

        this.TargetAnimation = target;
    }

    /// <summary>
    /// OneShot 애니메이션 메서드
    /// </summary>
    /// <param name="oneShot">한번 재생할 애니메이션 클립</param>
    /// <param name="layerIndex">null</param>
    public void PlayOneShot (Spine.Animation oneShot, int layerIndex, float speed) {
        var state = skeletonAnimation.AnimationState;
        TrackEntry a = state.SetAnimation(layerIndex, oneShot, false);
        a.TimeScale = speed;

        var transition = TryGetTransition(oneShot, TargetAnimation);
        if (transition != null)
            state.AddAnimation(layerIndex, transition, false, 0f).TimeScale = speed;
        if (layerIndex > 0)
        {
            state.AddEmptyAnimation(layerIndex, 0,a.Animation.Duration);
        }

        state.AddAnimation(0, this.TargetAnimation, true, a.Animation.Duration).TimeScale = speed;
    }

    /// <summary>
    /// 현재 애니메이션에서 다음 애니메이션으로 전환될 때 전환 애니메이션이 있는지 판단
    /// </summary>
    /// <param name="from">현재 애니메이션</param>
    /// <param name="to">다음 애니메이션</param>
    /// <returns>없다면 null 있다면 전환애니메이션(ex)ldel-to-jump)</returns>
    Spine.Animation TryGetTransition (Spine.Animation from, Spine.Animation to) {
        foreach (var transition in transitions) {
            if (transition.from.Animation == from && transition.to.Animation == to) {
                return transition.transition.Animation;
            }
        }
        return null;
    }

    /// <summary>
    /// 현재 애니메이션을 불러오는 메서드
    /// </summary>
    /// <param name="layout">해당 애니메이션 트랙/layout</param>
    /// <returns>Spine.Animation</returns>
    private Spine.Animation GetCurrentAnimation(int layerIndex)
    {
        var currentTrackEntry = skeletonAnimation.AnimationState.GetCurrent(layerIndex);
        return (currentTrackEntry != null) ? currentTrackEntry.Animation : null;
    }
    
    int StringToHash (string s) {
        return Animator.StringToHash(s);
    }
    
}

일단 내부클래스두가지를 사용하여 데이터를 보관하는데 이 클래스를 직렬화를 통해 인스펙터에 노출시키고 인스펙터에서 애니메이션을 등록시키고 사용한다.

public Spine.Animation TargetAnimation { get; private set; }  // 현재 애니메이션 외부에선 값만 노출

    public SkeletonAnimation skeletonAnimation; // 여기에 SkeletonAnimation참조 public으로 노출해서 이스크립트를 통해서만 접근가능하게

    // 리스트로 만들어서 인스펙터에서 쉽게 늘리고 줄일 수 있음
    public List<StateNameToAnimationReference> statesAndAnimations = new List<StateNameToAnimationReference>();
    public List<AnimationTransition> transitions = new List<AnimationTransition>();
    
    // 한가지 애니메이션을 가짐
    [System.Serializable]
    public class StateNameToAnimationReference 
    {
        public string stateName;
        public AnimationReferenceAsset animation;
    }

    // 애니메이션간의 전환의 경우 이어지는 애니메이션의 경우 ex) 점프
    [System.Serializable]
    public class AnimationTransition {
        public AnimationReferenceAsset from;
        public AnimationReferenceAsset to;
        public AnimationReferenceAsset transition;
    }

AnimationReferenceAsset클래스는 using통해 spine을 끌어와야 한다.

    public void PlayAnimationForState (int shortNameHash, int layerIndex, bool oneshot, float speed) {
      // 애니메이션이 존재하는지 확인(할당과정)
        var foundAnimation = GetAnimationForState(shortNameHash);
        if (foundAnimation == null)
            return;

        // oneshot애니메이션 구분
        if (oneshot)
        {
            PlayOneShot(foundAnimation, layerIndex, speed);
        }
        else
        {
            PlayNewAnimation(foundAnimation, layerIndex, speed);
        }
    }

외부에서 이 메서드를 사용해서 애니메이션을 실행하게 된다.(한번 매핑을 하긴 했지만)

oneshot으로 구분한 이유는 hit, attack등 즉발의 애니메이션의 경우

    public void PlayNewAnimation (Spine.Animation target, int layerIndex, float speed) {
        Spine.Animation transition = null;
        Spine.Animation current = null;

        // 마찬가지로 현재 애니메이션을 얻어옴
        current = GetCurrentAnimation(layerIndex);
        if (current != null)
            transition = TryGetTransition(current, target); // 현재랑 타켓애니메이션에 연결애니메이션 확인

        if (transition != null)
        { 
          // 있다면 set으로 loop=false로 전환 애니메이션 실행하고 add애니메이션으로 target loop실행
            skeletonAnimation.AnimationState.SetAnimation(layerIndex, transition, false).TimeScale = speed;
            skeletonAnimation.AnimationState.AddAnimation(layerIndex, target, true, 0).TimeScale = speed;
        } else {
          // 아닌 경우 그냥 target loop실행
            skeletonAnimation.AnimationState.SetAnimation(layerIndex, target, true).TimeScale = speed;
        }

        this.TargetAnimation = target;
    }
    public void PlayOneShot (Spine.Animation oneShot, int layerIndex, float speed) {
        var state = skeletonAnimation.AnimationState;
        TrackEntry a = state.SetAnimation(layerIndex, oneShot, false); // Duration값을 알아야 하기 때문에
        a.TimeScale = speed;

        var transition = TryGetTransition(oneShot, TargetAnimation);
        if (transition != null)
            state.AddAnimation(layerIndex, transition, false, 0f).TimeScale = speed;
        if (layerIndex > 0) // layer 값이 다르다면 oneshot의 경우 해당 레이어를 초기화해야함
        {
            state.AddEmptyAnimation(layerIndex, 0,a.Animation.Duration); // 애니메이션 비우기
        }

        state.AddAnimation(0, this.TargetAnimation, true, a.Animation.Duration).TimeScale = speed; // 이후에 애니메이션 loop재생
    }

이미지

Player Spine Handler

이 스크립트는 Skeleton Animation Handler와 PlayerMovement의 값으로 애니메이션을 제어하는 스크립트이다.

PlayerMovement에선 현재 상태를 반환받고 Skeleton Animation Handler에 값을 전달하여 플레이어의 애니메이션을 제어한다.

이러한 spine관련 스크립트를 하나 더 매핑하여 만들어서 spine관련 코드를 넣어두는게 좋다.

여기서는 spine이벤트, 이펙트, spine애니메이션 핸들링, 스킨 전환, 사운드를 관리한다.

전부 스파인과 관련되어 있기 때문에 응집도와 결합성을 생각해서 정리했다.

이미지

Change Animation

앞서 말한 PlayerMovement에서 현재 상태를 받고 SkeletonAnimationHandler에서 애니메이션을 제어한다.

private void Update()
{
    ChangeAnimation();
}

private void ChangeAnimation()
{
    bool stateChanged = previousState != playerMovement.CurrentState;
    previousState = playerMovement.CurrentState;

    if (stateChanged)
        HandleStateChanged();
}

현재 상태가 변화 되었다면 애니메이션을 Change해준다.

private void HandleStateChanged()
{
    string stateName = null;
    bool oneShot = false;
    int track = 0;
    int speed = 1;

    switch (playerMovement.CurrentState)
    {
        case CharacterState.Idle:
            stateName = CharacterState.Idle.ToString();
            break;
        case CharacterState.Walk:
            stateName = CharacterState.Walk.ToString();
            break;
        case CharacterState.Rise:
            stateName = CharacterState.Rise.ToString();
            break;
        case CharacterState.Fall:
            stateName = CharacterState.Fall.ToString();
            break;
        case CharacterState.Dash:
            stateName = CharacterState.Dash.ToString();
            break;
        case CharacterState.Slid:
            stateName = CharacterState.Slid.ToString();
            break;
        case CharacterState.AirAttack:
            stateName = CharacterState.AirAttack.ToString();
            oneShot = true;
            track = 1;
            break;
        case CharacterState.Hit:
            stateName = CharacterState.Hit.ToString();
            break;
        default:
            break;
    }

    animationHandler.PlayAnimationForState(stateName, track, oneShot, speed);
}

Change Skin

[Space(5)]
[Header("ReinForce Skin")]
[SpineSkin] public string baseSkin;
[SpineSkin] public string reinForce20;
[SpineSkin] public string reinForce40;
[SpineSkin] public string reinForce60;
[SpineSkin] public string reinForce80;
[SpineSkin] public string reinForce100;

private void UpdateSkin()
{
    var skeleton = animationHandler.skeletonAnimation.skeleton;
    
    skeleton.SetSkin(skeleton.Data.FindSkin(reinForce20));
    skeleton.SetSlotsToSetupPose();
}

스킨을 바꾸는 메서드는 animationHandler.skeletonAnimation.skeleton 프로퍼티에 SetSkin으로 호출하며 skeleton.Data(SkeletonData)내부에서 FindSkin을 통해 Skin 데이터를 받아서 적용한다.

스킨을 적용한다고 해서 바로 바뀌는게 아니라 한번 초기화를 해줘야 한다.

skeleton.SetSlotsToSetupPose();

SpineEvent

spine에디터에서 한 프레임에 이벤트를 걸어두면 unity에서 해당 이벤트를 트리거로 잡아서 사용이 가능하다..?

[Space(5)]
[Header("Spine Event")]
[SpineEvent(dataField: "skeletonAnimation", fallbackToTextField: true)]
public string attackEvent;

private void Start()
{
    // 이벤트 검색해서 이벤트 저장
    attackEventData = animationHandler.skeletonAnimation.skeleton.Data.FindEvent(attackEvent);
    // 이벤트 등록
    animationHandler.skeletonAnimation.AnimationState.Event += HandleAnimationStateEvent;
}

private void HandleAnimationStateEvent(TrackEntry trackEntry, Spine.Event e)
{
    if (e.Data == attackEventData)
    {
        Debug.Log("이벤트 호출");
    }
}

마찬가지로 skeleton.Data를 통해서 Find메서드로 event롤 찾아내고 해당 이벤트를 잡아낸다.

SkeletonUtility

이미지

스파인객체에 Add Component로 SkeletonUtility를 추가하면 Spawn Hierarchv통해서 객체에 Bone을 생성할 수 있게 된다.

이렇게 되면 해당 본에 스프라이트를 붙이거나 이펙트를 붙이는 등 다양한 연출적인 것들이 가능해지기 때문에 매우매우 추천하는 방법

이미지

++ BoneFollower이라는 스크립트를 추가하면 좀 더 편하게 가시성이 좋은 방법으로 사용 가능하다.

태그: ,

카테고리:

업데이트:

댓글남기기