애니메이션 추가

This commit is contained in:
2026-09-01 20:19:59 +09:00
parent 01c852fef9
commit 7ec587353a
1542 changed files with 585048 additions and 21 deletions

View File

@@ -0,0 +1,87 @@
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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 66d845d6208449845a736ee85c49ddbe

View File

@@ -0,0 +1,178 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 이동 상태를 몸짓으로 옮기고, 가만히 있을 때 자세를 바꿔준다. 애니메이션 배선의 유일한 자리.
///
/// 클립이 없어도 아무것도 깨지지 않는다. 컨트롤러 슬롯이 비어 있으면 지금처럼
/// ProceduralIdle 이 계속 대기 자세를 만든다. 모델을 안 넣으면 캐릭터가 안 뜨는 것과
/// 같은 방식이다 — 없으면 없는 대로 돌아가고, 넣으면 그때부터 쓰인다.
///
/// 클립 준비 방법:
/// 1. Mixamo 등에서 FBX 를 받는다 (걷기는 In Place 로)
/// 2. 임포트 설정에서 Rig > Animation Type = Humanoid (필수. Generic 은 리타게팅 안 됨)
/// 3. 메뉴 11 로 컨트롤러를 만들고 각 상태에 클립을 넣는다
/// 4. 그 컨트롤러를 이 컴포넌트의 Motion Controller 에 연결한다
/// </summary>
public class CharacterMotionDirector : MonoBehaviour
{
[Header("참조 (비우면 씬에서 탐색)")]
[SerializeField] VrmCharacterLoader loader;
[SerializeField] WindowClimber climber;
[Header("애니메이션")]
[Tooltip("Humanoid 클립을 담은 Animator Controller. 비우면 ProceduralIdle 로 동작한다")]
[SerializeField] RuntimeAnimatorController motionController;
// 짧은 동작일수록 크로스페이드가 차지하는 비중이 커진다. 0.5초짜리 낙하에
// 0.15초를 쓰면 30% 가 전환에 묻힌다. 0.1 정도가 부드러움과 또렷함의 절충.
[Tooltip("몸짓이 바뀔 때 섞이는 시간(초). 0 이면 뚝 끊긴다. " +
"낙하처럼 짧은 동작이 묻히면 더 줄인다")]
[Range(0f, 1f)]
[SerializeField] float crossFadeSeconds = 0.1f;
[Header("Animator 상태 이름 (비우면 그 몸짓을 쓰지 않는다)")]
[SerializeField] string idleState = "Idle";
[SerializeField] string fallState = "Fall";
[SerializeField] string walkState = "Walk";
[SerializeField] string jumpState = "JumpUp";
[SerializeField] string sitState = "Sit";
[SerializeField] string lieDownState = "LieDown";
[Header("쉬는 자세")]
[Tooltip("발판에 선 뒤 자세를 바꾸기까지 기다리는 시간의 최소/최대(초). " +
"매번 다르게 해야 기계적으로 보이지 않는다")]
[SerializeField] float restDelayMin = 5f;
[SerializeField] float restDelayMax = 14f;
[Tooltip("서 있을 때 고를 자세들. Idle 을 섞어둬야 계속 누워만 있지 않는다")]
[SerializeField]
CharacterMotionKind[] restPoses =
{
CharacterMotionKind.Idle,
CharacterMotionKind.Sit,
CharacterMotionKind.LieDown,
};
[Header("동작")]
[Tooltip("클립이 준비되면 ProceduralIdle 을 끈다. 둘 다 같은 본을 써서 함께 두면 싸운다")]
[SerializeField] bool disableProceduralIdle = true;
ICharacterMotion motion;
ClimbState currentState = ClimbState.Falling;
float nextRestChangeTime;
Dictionary<CharacterMotionKind, string> BuildNames() => new Dictionary<CharacterMotionKind, string>
{
{ CharacterMotionKind.Idle, idleState },
{ CharacterMotionKind.Fall, fallState },
{ CharacterMotionKind.Walk, walkState },
{ CharacterMotionKind.Jump, jumpState },
{ CharacterMotionKind.Sit, sitState },
{ CharacterMotionKind.LieDown, lieDownState },
};
void Awake()
{
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
if (climber == null) climber = FindFirstObjectByType<WindowClimber>();
}
void OnEnable()
{
if (loader != null)
{
loader.Loaded += OnCharacterLoaded;
// 이 컴포넌트보다 캐릭터가 먼저 로드됐을 수도 있다.
if (loader.Current != null) OnCharacterLoaded(loader.Current);
}
if (climber != null)
{
climber.StateChanged += OnStateChanged;
currentState = climber.State;
}
}
void OnDisable()
{
if (loader != null) loader.Loaded -= OnCharacterLoaded;
if (climber != null) climber.StateChanged -= OnStateChanged;
}
void Update()
{
// 서 있을 때만 자세를 바꾼다. 걷거나 떨어지는 중에 눕게 할 수는 없다.
if (motion == null || currentState != ClimbState.Standing) return;
if (Time.unscaledTime < nextRestChangeTime) return;
ScheduleNextRestChange();
if (restPoses != null && restPoses.Length > 0)
{
motion.Play(restPoses[Random.Range(0, restPoses.Length)]);
}
}
void OnCharacterLoaded(ICharacterAvatar avatar)
{
motion = null;
if (avatar == null || avatar.Animator == null) return;
if (motionController == null)
{
// 클립이 없는 것은 오류가 아니다. 다만 조용히 아무 일도 없으면
// "왜 안 움직이지"가 되므로 한 번 알려준다.
Debug.Log("[CharacterMotionDirector] Motion Controller 가 비어 있어 " +
"ProceduralIdle 로 동작합니다. Humanoid 클립을 넣으면 그때부터 쓰입니다.");
return;
}
var animatorMotion = new AnimatorCharacterMotion(
avatar.Animator, motionController, BuildNames(), crossFadeSeconds);
if (!animatorMotion.IsReady) return;
motion = animatorMotion;
// 같은 본을 매 프레임 덮어쓰는 컴포넌트를 끈다. 남겨두면 실행 순서상
// ProceduralIdle 이 Animator 결과를 이겨서 클립이 안 보인다.
// HeadLookAt 은 머리만 건드리고, 몸짓 위에 시선을 얹는 쪽이 자연스러워 그대로 둔다.
if (disableProceduralIdle && avatar.Root != null)
{
var idle = avatar.Root.GetComponent<ProceduralIdle>();
if (idle != null) idle.enabled = false;
}
currentState = climber != null ? climber.State : ClimbState.Standing;
motion.Play(ToMotionKind(currentState));
ScheduleNextRestChange();
}
void OnStateChanged(ClimbState state)
{
currentState = state;
motion?.Play(ToMotionKind(state));
// 서기 시작한 시점부터 다시 센다. 착지하자마자 눕지 않게.
if (state == ClimbState.Standing) ScheduleNextRestChange();
}
void ScheduleNextRestChange()
{
float min = Mathf.Max(0.5f, restDelayMin);
float max = Mathf.Max(min, restDelayMax);
nextRestChangeTime = Time.unscaledTime + Random.Range(min, max);
}
static CharacterMotionKind ToMotionKind(ClimbState state)
{
switch (state)
{
case ClimbState.Falling: return CharacterMotionKind.Fall;
case ClimbState.Walking: return CharacterMotionKind.Walk;
case ClimbState.Jumping: return CharacterMotionKind.Jump;
default: return CharacterMotionKind.Idle;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5c2c205cc11a32a43b411e62acd7c65e

View File

@@ -0,0 +1,37 @@
/// <summary>
/// 캐릭터가 지을 수 있는 몸짓.
///
/// <see cref="ClimbState"/> 와 일부러 분리했다. ClimbState 는 "몸이 어디에 있는가"이고
/// 이쪽은 "어떻게 보이는가"다. 서 있는 상태 하나에도 그냥 서기 / 걸터앉기 / 눕기가
/// 있으므로 일대일로 묶으면 이동 로직에 표현이 섞여 들어간다.
/// </summary>
public enum CharacterMotionKind
{
Idle, // 서서 대기
Fall, // 떨어지는 중
Walk, // 걷는 중
Jump, // 다른 발판으로 뛰어오르는 중
Sit, // 발판에 걸터앉기
LieDown, // 눕기
}
/// <summary>
/// 캐릭터 몸짓을 재생하는 계층의 창구.
///
/// 왜 인터페이스로 두는가:
/// 지금은 Unity Animator + Humanoid FBX 클립으로 간다. 전환(CrossFade)과 레이어를
/// 공짜로 얻을 수 있어서다. 하지만 나중에 "사용자가 자기 모션(.vrma)을 넣게 하고 싶다"가
/// 되면 VRM Animation 로더를 하나 더 붙이게 된다. 그때 이 인터페이스만 다시 구현하면
/// WindowClimber 와 상위 로직은 손대지 않는다. IChatBackend 와 같은 이유다.
///
/// 참고: .vrma 는 Vrm10Runtime.VrmAnimation 하나에 통째로 물리는 구조라
/// 크로스페이드가 없다. 그쪽으로 갈 때는 전환 처리를 구현체가 직접 해야 한다.
/// </summary>
public interface ICharacterMotion
{
/// <summary>재생할 준비가 됐는지. 클립이나 컨트롤러가 없으면 false.</summary>
bool IsReady { get; }
/// <summary>해당 몸짓으로 넘어간다. 같은 것을 다시 줘도 안전하다.</summary>
void Play(CharacterMotionKind kind);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 78cdee1c101f71640a4745ecce074721

View File

@@ -179,9 +179,9 @@ void Setup(Vrm10Instance vrm)
currentRoot = vrm.gameObject;
currentRoot.transform.SetParent(transform, false);
currentRoot.transform.localPosition = spawnPosition;
currentRoot.transform.localRotation = faceCamera
? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다
: Quaternion.identity;
// 컨트롤 리그를 만드는 동안에는 회전을 걸어두지 않는다. 아래 설명 참고.
currentRoot.transform.localRotation = Quaternion.identity;
// 중요: Vrm10Instance.Runtime 은 지연 생성 프로퍼티다. 여기서 한 번 접근해
// 컨트롤 리그를 미리 만들어 둔다. 컨트롤 리그가 생성될 때 Animator.avatar 가
@@ -190,6 +190,24 @@ void Setup(Vrm10Instance vrm)
// 가한 회전이 전부 지워진다. 반드시 컴포넌트 부착보다 먼저 접근해야 한다.
_ = vrm.Runtime;
// 돌려세우는 것은 반드시 컨트롤 리그가 만들어진 "뒤"라야 한다.
//
// Vrm10RuntimeControlRig 는 리그 루트를 이렇게 만든다:
// _controlRigRoot = new GameObject(...).transform;
// _controlRigRoot.SetParent(vrmRoot); // 인자 하나 = worldPositionStays:true
//
// 새 오브젝트는 월드 회전 identity 로 생기고, SetParent 의 기본값이 월드 트랜스폼
// 유지라서 부모에 붙어도 월드 identity 를 지킨다. 즉 리그 루트의 로컬 회전이
// inverse(vrmRoot.rotation) 이 되어, 미리 걸어둔 회전을 정확히 상쇄해 버린다.
// 그러면 애니메이션 클립이 만든 포즈가 루트 회전을 무시하고 늘 월드 +Z 를 향한다
// (= 카메라에 등을 보인다. faceCamera 를 꺼도 달라지지 않는다).
//
// 리그가 다 만들어진 뒤에 돌리면 리그 전체가 자식으로서 같이 돌아가므로 문제없다.
// ProceduralIdle 만 쓸 때는 기존 포즈에 작은 델타만 얹어서 이 함정이 안 보였다.
currentRoot.transform.localRotation = faceCamera
? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다
: Quaternion.identity;
var avatar = currentRoot.AddComponent<VrmAvatar>();
avatar.Bind(vrm);
Current = avatar;

View File

@@ -6,7 +6,7 @@ public enum ClimbState
Falling, // 낙하 중
Standing, // 발판 위에 서 있음
Walking, // 발판 위를 걷는 중 (목표 X 로 이동)
Climbing, // 다른 발판으로 올라가는 중
Jumping, // 다른 발판으로 뛰어오르는 중
}
/// <summary>
@@ -28,11 +28,16 @@ public class WindowClimber : MonoBehaviour
[SerializeField] Camera viewCamera;
[Header("물리 (화면 픽셀 기준)")]
[Tooltip("초당 낙하 가속도")]
[SerializeField] float gravity = 2600f;
// 2600 정도가 실제 중력에 해당한다(캐릭터 키를 화면 400px 로 보면 1m≈250px,
// 9.8m/s² ≈ 2450px/s²). 그런데 실제 중력으로 떨어지면 400px 낙하가 0.56초라
// 낙하 애니메이션이 눈에 들어오지 않는다. 데스크톱 펫은 일부러 둥실하게 둔다.
[Tooltip("초당 낙하 가속도. 낮출수록 천천히 떨어져 낙하 동작이 잘 보인다. " +
"2600 은 실제 중력에 가까워 너무 빠르다")]
[SerializeField] float gravity = 1200f;
[Tooltip("최대 낙하 속도")]
[SerializeField] float maxFallSpeed = 1800f;
[Tooltip("최대 낙하 속도. 긴 낙하의 체감 속도를 좌우한다. " +
"이 값이 크면 화면을 가로지르는 낙하가 순식간에 끝난다")]
[SerializeField] float maxFallSpeed = 900f;
[Tooltip("걷는 속도 (픽셀/초)")]
[SerializeField] float walkSpeed = 160f;
@@ -63,6 +68,15 @@ public class WindowClimber : MonoBehaviour
[Tooltip("올라가는 속도 (픽셀/초). 거리에 비례해 시간이 정해진다")]
[SerializeField] float climbSpeed = 420f;
[Tooltip("점프할 때 목표보다 얼마나 더 높이 떠오르는지(픽셀). 0 이면 직선으로 간다")]
[SerializeField] float jumpArcHeight = 70f;
[Tooltip("가장 짧은 점프에 걸리는 시간(초). 너무 작으면 순간이동처럼 보인다")]
[SerializeField] float minJumpDuration = 0.35f;
[Tooltip("가장 먼 점프에 걸리는 시간(초). 너무 크면 화면을 오래 가린다")]
[SerializeField] float maxJumpDuration = 0.9f;
[Header("발판 여백")]
[Tooltip("발판 좌우 끝에서 최소한 이만큼 안쪽에 선다. " +
"캐릭터 반폭보다 작으면 반폭이 우선 적용된다")]
@@ -75,7 +89,27 @@ public class WindowClimber : MonoBehaviour
[Tooltip("착지 판정 여유. 발판을 살짝 지나쳐도 잡아준다")]
[SerializeField] float landTolerance = 24f;
public ClimbState State { get; private set; } = ClimbState.Falling;
ClimbState state = ClimbState.Falling;
/// <summary>
/// 지금 이동 상태. 값이 실제로 바뀔 때만 <see cref="StateChanged"/> 가 불린다.
///
/// 대입하는 자리가 여러 군데라 각각에서 이벤트를 쏘면 빠뜨리기 쉽다.
/// 프로퍼티 한 곳에 모아두면 새 전환을 추가해도 자동으로 알려진다.
/// </summary>
public ClimbState State
{
get => state;
private set
{
if (state == value) return;
state = value;
StateChanged?.Invoke(value);
}
}
/// <summary>이동 상태가 바뀔 때. 애니메이션 전환이 이걸 듣는다.</summary>
public event System.Action<ClimbState> StateChanged;
Transform character;
float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다.
@@ -159,7 +193,7 @@ void LateUpdate()
case ClimbState.Falling: TickFalling(dt); break;
case ClimbState.Standing: TickStanding(dt); break;
case ClimbState.Walking: TickWalking(dt); break;
case ClimbState.Climbing: TickClimbing(dt); break;
case ClimbState.Jumping: TickJumping(dt); break;
}
Apply();
@@ -249,21 +283,36 @@ void BeginClimbArc()
climbProgress = 0f;
float distance = Vector2.Distance(climbStart, new Vector2(ClampToPlatform(climbTarget), climbTarget.Y));
climbDuration = Mathf.Max(0.25f, distance / Mathf.Max(1f, climbSpeed));
State = ClimbState.Climbing;
// 점프는 거리에 정비례해 느려지지 않는다. 아주 짧은 도약이 순간이동처럼 보이거나
// 먼 도약이 하염없이 길어지지 않도록 위아래를 막는다.
climbDuration = Mathf.Clamp(distance / Mathf.Max(1f, climbSpeed),
minJumpDuration, maxJumpDuration);
State = ClimbState.Jumping;
}
void TickClimbing(float dt)
/// <summary>
/// 발판 사이를 뛰어오른다.
///
/// 원래는 대각선으로 미끄러지듯 올라갔는데, 그러면 화면을 오래 가리고
/// "뒤로 도는지 옆으로 도는지" 애매해서 어떤 클라이밍 클립과도 맞지 않았다.
/// 포물선 도약은 짧고, 방향이 분명하고, 점프 클립 하나로 어떤 거리든 커버된다.
/// (Desktop Mate 도 Shimeji 도 "올라가는 과정"을 길게 보여주지 않는다)
/// </summary>
void TickJumping(float dt)
{
climbProgress += dt / climbDuration;
float t = Mathf.Clamp01(climbProgress);
float targetX = ClampToPlatform(climbTarget);
// 수평은 일정하게, 수직은 뒤로 갈수록 느려지게 해서 "올라타는" 느낌을 준다.
screenPos.x = Mathf.Lerp(climbStart.x, targetX, t);
screenPos.y = Mathf.Lerp(climbStart.y, climbTarget.Y, Mathf.SmoothStep(0f, 1f, t));
// 4t(1-t) 는 t=0.5 에서 1, 양 끝에서 0 이다. 그래서 시작점과 도착점을
// 정확히 지나면서 가운데만 부풀어 오른다 — 목표보다 높이 떠올랐다 내려앉는다.
float straight = Mathf.Lerp(climbStart.y, climbTarget.Y, t);
screenPos.y = straight + jumpArcHeight * 4f * t * (1f - t);
if (t >= 1f) Land(climbTarget);
}
@@ -398,8 +447,8 @@ void ConsiderClimb()
walkTargetX = ClampToPlatform(best);
nextClimbAllowedTime = Time.unscaledTime + climbCooldown;
State = Mathf.Abs(walkTargetX - screenPos.x) > 2f ? ClimbState.Walking : ClimbState.Climbing;
if (State == ClimbState.Climbing) BeginClimbArc();
State = Mathf.Abs(walkTargetX - screenPos.x) > 2f ? ClimbState.Walking : ClimbState.Jumping;
if (State == ClimbState.Jumping) BeginClimbArc();
}
// ---------------- 반영 ----------------