Files
MyCharacterAgent/Assets/02_Scripts/Character/AnimatorCharacterMotion.cs
2026-09-01 20:19:59 +09:00

88 lines
3.7 KiB
C#

using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Unity Animator 로 몸짓을 재생한다. Humanoid 클립을 담은 컨트롤러가 필요하다.
///
/// VRM 과 어떻게 맞물리는가:
/// 모델을 불러올 때 Vrm10Instance.Runtime 에 접근하면서 컨트롤 리그가 만들어지고,
/// 그때 Animator.avatar 가 컨트롤 리그용으로 교체된다. 그래서 이 Animator 에
/// Humanoid 클립을 틀면 컨트롤 리그의 본이 움직이고, Vrm10Runtime.Process() 가
/// LateUpdate 에서 그걸 실제 메시 본으로 옮긴다. ProceduralIdle 이 팔을 내리는 것과
/// 똑같은 경로다 — 그래서 둘은 같은 본을 두고 싸운다. 이 구현이 살아 있는 동안
/// ProceduralIdle 은 꺼야 한다(CharacterMotionDirector 가 처리한다).
///
/// 클립은 반드시 임포트 설정에서 Rig > Animation Type = Humanoid 여야 한다.
/// Generic 으로 들어온 클립은 본 이름으로 묶여서 다른 모델에 리타게팅되지 않는다.
///
/// MonoBehaviour 가 아닌 이유: 씬 수명주기가 필요 없고, 캐릭터가 새로 로드될 때마다
/// 새로 만들어 갈아끼우는 편이 상태가 남지 않아 깔끔하다.
/// </summary>
public class AnimatorCharacterMotion : ICharacterMotion
{
readonly Animator animator;
readonly IReadOnlyDictionary<CharacterMotionKind, string> stateNames;
readonly float crossFade;
CharacterMotionKind? current;
/// <summary>이름이 틀린 상태를 매 프레임 경고하지 않도록, 한 번 알린 것은 기억한다.</summary>
readonly HashSet<string> warned = new HashSet<string>();
public bool IsReady { get; }
public AnimatorCharacterMotion(Animator animator, RuntimeAnimatorController controller,
IReadOnlyDictionary<CharacterMotionKind, string> stateNames,
float crossFadeSeconds)
{
this.animator = animator;
this.stateNames = stateNames;
crossFade = Mathf.Max(0f, crossFadeSeconds);
if (animator == null || controller == null || stateNames == null)
{
IsReady = false;
return;
}
animator.runtimeAnimatorController = controller;
// 컨트롤 리그 본을 직접 만지는 다른 컴포넌트와 섞이므로, 컬링으로 꺼지면
// 화면 밖에서 자세가 굳어버린다. 항상 돌게 둔다.
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
// 위치는 WindowClimber 가 화면 좌표로 정한다. 클립이 캐릭터를 끌고 가면 안 된다.
animator.applyRootMotion = false;
IsReady = true;
}
public void Play(CharacterMotionKind kind)
{
if (!IsReady || animator == null) return;
if (current == kind) return;
if (!stateNames.TryGetValue(kind, out string stateName) || string.IsNullOrEmpty(stateName))
{
return; // 그 몸짓을 쓰지 않기로 한 것. 지금 자세를 유지한다.
}
// 없는 상태로 CrossFade 하면 Unity 는 조용히 무시한다. 그러면 "왜 안 움직이지"가
// 되므로 한 번은 알려준다.
int hash = Animator.StringToHash(stateName);
if (!animator.HasState(0, hash))
{
if (warned.Add(stateName))
{
Debug.LogWarning($"[AnimatorCharacterMotion] 컨트롤러에 '{stateName}' 상태가 없습니다 " +
$"({kind} 에 해당). Animator 의 상태 이름을 맞추거나 " +
$"CharacterMotionDirector 에서 이름을 바꿔주세요.");
}
return;
}
current = kind;
animator.CrossFade(hash, crossFade, 0);
}
}