스킬 시전 애니메이션 생명주기 제어 구조
1. 시스템 요구 사항
스킬 입력이 허용된 이후의 문제는 단순히 애니메이션을 재생하는 것이 아니었다.
스킬은 하나의 버튼 입력으로 끝나는 행위가 아니라, '시작 → 전이 → 재생 → 종료' 라는 명확한 시간 흐름을 가지는 행동이다.
이 흐름이 코드로 명확히 표현되지 않으면, 이동 애니메이션과 공격 애니메이션이 같은 레이어에서 섞이며 끊겨 보이거나, 애니메이션 전이가 끝나기도 전에 스킬이 종료되어 이펙트와 모션이 어긋나기도 한다.
또한 스킬이 언제부터 언제까지 시전 중인가가 코드 상에서 드러나지 않으면, 이후 이동 제한, 피격 무시, 후딜레이 같은 규칙을 추가하기 어려워진다.
따라서 스킬 시스템은 단순히 애니메이션 파라미터를 켜고 끄는 구조가 아니라, 스킬의 생명주기를 코드 흐름 자체로 표현할 수 있는 구조가 필요했다.
2. 설계 목표
- 스킬 애니메이션을 이동 애니메이션과 분리된 레이어에서 관리할 것
- 스킬 시전의 시작·전이·재생·종료 흐름을 코드로 명확히 드러낼 것
- 애니메이션 전이 완료 시점을 보장한 뒤 실제 재생 시간에 진입할 것
3. 흐름도

이 흐름에서 중요한 점 전이(Transition)와 실제 재생을 명확히 구분해 기다린다는 점이다.
이를 통해 스킬의 시작과 종료 시점이 프레임 단위로 어긋나지 않는다.
4. 구현
4.1. 스킬 애니메이션 해시 분리
private enum SkillID
{
FireBall,
Explosion,
Blaze,
Meteor
}
static class SkillAnimHash
{
public static readonly int FireBall = Animator.StringToHash("FireBall");
public static readonly int Explosion = Animator.StringToHash("Explosion");
public static readonly int Blaze = Animator.StringToHash("Blaze");
public static readonly int Meteor = Animator.StringToHash("Meteor");
}
private static int GetAnimHash(SkillID id)
{
return id switch
{
SkillID.FireBall => SkillAnimHash.FireBall,
SkillID.Explosion => SkillAnimHash.Explosion,
SkillID.Blaze => SkillAnimHash.Blaze,
SkillID.Meteor => SkillAnimHash.Meteor,
_ => 0
};
}Animator.StringToHash는 문자열 파라미터를 내부 해시 값으로 변환해 애니메이터 파라미터 접근을 정수 기반으로 수행하게 만든다.
이 방식은 매 프레임 문자열 비교/탐색 비용을 줄여 성능과 GC 측면에서 유리하고, 오타로 인한 런타임 문제를 줄여준다.
다만 해시 값 자체는 사람이 읽기 어렵고, 파라미터 이름을 잘못 적어도 컴파일 단계에서 잡히지 않는 한계는 남는다.
그럼에도 이 프로젝트에서는 입력이 잦고 매 프레임 접근하는 구조이기 때문에, 문자열 대신 해시를 표준으로 삼아 호출 비용과 실수를 동시에 줄이는 쪽을 선택했다.
스킬은 문자열이나 애니메이션 이름이 아니라, SkillID라는 논리적 식별자로 먼저 정의된다.
애니메이션 파라미터는 이 SkillID를 변환한 결과일 뿐이며, 스킬의 정체성은 항상 SkillID에 귀속된다.
이 구조를 통해 스킬 로직은 이 스킬이 어떤 애니메이션을 쓰는가보다 지금 어떤 스킬을 시전 중인가에 집중할 수 있다.
GetAnimHash에서 _ => 0 처리는 유효하지 않은 SkillID가 들어왔을 때 조용히 무시하도록 만든 선택이다.
실제 프로젝트에서는 Debug.LogWarning으로 누락을 빠르게 찾게 할 수 있다.
4.2. 스킬 시작 진입점
void StartSkill(SkillID id)
{
int hash = GetAnimHash(id);
if (hash == 0) return;
if (anim.GetBool(hash)) return; // 이미 같은 스킬 중이면 무시
anim.SetLayerWeight(1, 0);
StartCoroutine(CastSkill(hash));
}
StartSkill은 스킬 시전의 단일 진입점이다.
이미 같은 스킬이 재생 중이라면 즉시 차단하고, 그 외의 경우에만 스킬 생명주기 흐름으로 진입한다.
여기서 중요한 점은, 이 함수가 애니메이션을 직접 재생하지 않는다는 것이다.
실제 흐름 제어는 코루틴으로 위임되며, StartSkill은 오직 이 스킬의 시전을 시작한다는 선언 역할만 수행한다.
이때, Animator의 SetLayerWeight를 통해 공격 레이어(상체 레이어)를 비활성화하여 공격 모션을 초기화한다.
4.3. 스킬 시전 생명주기 (Coroutine)
IEnumerator CastSkill(int animHash)
{
// 1. 공격 레이어 활성화
anim.SetLayerWeight(1, 1f);
anim.SetBool(animHash, true);
// 2. 전이 시작까지 대기
yield return null;
// 3. 전이 중이면 끝날 때까지 대기
while (anim.IsInTransition(1))
yield return null;
// 4. 현재 스테이트 길이만큼 대기
AnimatorStateInfo state = anim.GetCurrentAnimatorStateInfo(1);
float length = state.length;
yield return new WaitForSeconds(length);
// 5. 종료
anim.SetBool(animHash, false);
anim.SetLayerWeight(1, 0f);
}
이 코루틴은 스킬 시전의 전체 생명주기를 시간 순서대로 그대로 표현한다.
먼저 공격 전용 애니메이션 레이어를 활성화한 뒤, 스킬 애니메이션 파라미터를 켠다.
Animator의 SetLayerWeight(1, 1f)는 1번 레이어를 활성화해 스킬 애니메이션이 이동 레이어와 독립적으로 블렌딩되도록 만든다.
레이어 분리는 이동 모션이 공격 모션을 덮어버리는 문제를 구조적으로 해결하고, 상체만 공격하고 하체는 이동을 유지하는 형태로 확장하기도 쉽다.
반대로 레이어 구성 자체(마스크, 블렌딩 모드, 전이 설정)가 잘못되면 특정 프레임에서 포즈가 튀거나, 레이어가 꺼지지 않아 상체가 굳는 문제도 생길 수 있다.
하지만 이 프로젝트에서는 전투 모션은 전투 레이어에서만 재생한다는 규칙을 강하게 가져가야 했고, 레이어 가중치 조절이 그 규칙을 가장 단순한 코드로 강제할 수 있는 방법이라 선택했다.
이후 한 프레임을 대기해 전이가 시작될 때 까지 yield return null을 통해 기다린다.
yield return null은 코루틴을 다음 프레임까지 넘겨, 이번 프레임에서 SetBool로 트리거한 전이가 실제로 적용될 시간을 확보한다.
이 한 프레임 대기는 전이 시작 전 state 정보를 읽어버리는 타이밍 문제를 피하는 데 유리하다.
단점은 전이가 즉시 확정되지 않는 구조에서는 1프레임이 항상 충분하다고 보장할 수 없고, 결과적으로 전이 대기 로직(while IsInTransition)과 함께 써야 안정적이다.
하지만 이 프로젝트는 전이와 재생 구간을 명확히 나누는 게 목적이었고, 1프레임 대기 후 전이 상태를 확인하는 흐름이 가장 직관적으로 타이밍을 고정해준다.
이후 전이가 시작되면 전이 상태가 끝날 때까지 반복적으로 대기한다.
전이가 시작되면 Animator의 IsInTransition 함수를 사용하여 현재 전이 중인지를 반환한다.
Animator.IsInTransition은 특정 레이어에서 현재 상태가 전이 중인지 여부를 반환한다.
이 값을 기반으로 전이가 끝날 때까지 기다리면, 블렌딩 중간에 state.length를 읽어 잘못된 지속 시간을 얻는 문제를 예방할 수 있다.
단점은 전이 설정이 꼬여 전이가 계속 유지되는 경우 코루틴이 길게 잡히거나, 조건이 풀리지 않는 상황이 발생할 수 있다는 점이다.
그럼에도 이 방식은 전이 완료 이후에만 재생 시간을 계산한다는 규칙을 코드로 강제할 수 있고, 애니메이션 클립 교체나 전이 시간 조정이 있어도 코드 수정 없이 안정적으로 동작한다.
이 시점에서 SetLayerWeight(1, 0)을 한 번 호출해 이전 프레임에 남아 있을 수 있는 공격 레이어의 잔여 블렌딩을 초기화한다.
이를 통해 이전 스킬 시전 상태가 다음 스킬 시전에 영향을 주지 않도록 안전한 시작 상태를 보장한다.
전이가 완전히 종료된 시점에서 현재 스테이트 정보를 조회해, 실제 재생 길이만큼 정확히 유지한다.
GetCurrentAnimatorStateInfo는 해당 레이어에서 현재 재생 중인 스테이트의 정보를 제공하며, state.length는 그 스테이트에 매핑된 애니메이션 클립의 길이를 의미한다.
이를 사용하면 스킬이 재생되는 동안 얼마를 기다릴지를 하드코딩하지 않고 애니메이션 자산 자체에 종속시킬 수 있어 유지보수성이 좋다.
다만 length는 클립 길이 기준이라 speed, timeScale, Animator 파라미터에 의한 재생 속도 변화가 있으면 실제 체감 시간과 차이가 날 수 있고, 루프 스테이트에서는 종료 기준이 모호해질 수 있다.
하지만 이 프로젝트에서는 스킬 애니메이션이 루프가 아닌 단발 동작이며, 재생 시간은 애니메이션과 일치해야 한다는 요구가 강했기 때문에, length 기반으로 흐름을 맞추는 선택이 가장 단순하면서도 수정 비용이 낮다.
이 방식 덕분에 애니메이션 클립 길이가 바뀌더라도 코드는 항상 실제 재생 시간과 동기화된다.
마지막으로 애니메이션 파라미터와 레이어 가중치를 되돌리며 스킬 시전 상태를 명확히 종료한다.
5. 개발 의도
이 게시글에서 보여주고자 한 것은 스킬 애니메이션을 재생하는 방법이 아니라, 스킬이라는 행동의 시간적 경계를 코드로 명확히 표현하는 방식이다.
스킬이 언제 시작되고, 언제 전이를 끝내고, 언제 재생 중이며, 언제 완전히 종료되는지가 코드를 위에서 아래로 읽는 것만으로 드러나도록 구성했다.
