using System.Collections.Generic; using UnityEngine; /// /// 이동 상태를 몸짓으로 옮기고, 가만히 있을 때 자세를 바꿔준다. 애니메이션 배선의 유일한 자리. /// /// 클립이 없어도 아무것도 깨지지 않는다. 컨트롤러 슬롯이 비어 있으면 지금처럼 /// ProceduralIdle 이 계속 대기 자세를 만든다. 모델을 안 넣으면 캐릭터가 안 뜨는 것과 /// 같은 방식이다 — 없으면 없는 대로 돌아가고, 넣으면 그때부터 쓰인다. /// /// 클립 준비 방법: /// 1. Mixamo 등에서 FBX 를 받는다 (걷기는 In Place 로) /// 2. 임포트 설정에서 Rig > Animation Type = Humanoid (필수. Generic 은 리타게팅 안 됨) /// 3. 메뉴 11 로 컨트롤러를 만들고 각 상태에 클립을 넣는다 /// 4. 그 컨트롤러를 이 컴포넌트의 Motion Controller 에 연결한다 /// public class CharacterMotionDirector : MonoBehaviour { [Header("참조 (비우면 씬에서 탐색)")] [SerializeField] VrmCharacterLoader loader; [SerializeField] WindowClimber climber; [SerializeField] CharacterDragger dragger; [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] PoseMenuUI poseMenu; [Tooltip("우클릭 목록에 나올 포즈들. 첫 항목이 기본 자세이고, " + "걷거나 떨어졌다 돌아오면 항상 첫 항목으로 되돌아온다. " + "각 항목의 Hide Behind Platform 을 켜면 그 포즈일 때 발판 아래가 가려진다")] [SerializeField] PoseOption[] poses = { new PoseOption { kind = CharacterMotionKind.Idle }, new PoseOption { kind = CharacterMotionKind.Sit }, new PoseOption { kind = CharacterMotionKind.LieDown }, }; [Header("발판 뒤로 숨기기")] [Tooltip("비우면 씬에서 탐색. 없으면 가림 기능만 조용히 꺼진다")] [SerializeField] PlatformOccluder occluder; [Header("동작")] [Tooltip("클립이 준비되면 ProceduralIdle 을 끈다. 둘 다 같은 본을 써서 함께 두면 싸운다")] [SerializeField] bool disableProceduralIdle = true; ICharacterMotion motion; ClimbState currentState = ClimbState.Falling; int poseIndex; Dictionary BuildNames() => new Dictionary { { 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(); if (climber == null) climber = FindFirstObjectByType(); if (dragger == null) dragger = FindFirstObjectByType(); if (occluder == null) occluder = FindFirstObjectByType(); if (poseMenu == null) poseMenu = FindFirstObjectByType(); WarnIfOccluderMissing(); } /// 가림을 켜둔 포즈가 있는데 가림 컴포넌트가 없으면, 조용히 안 되는 대신 알린다. void WarnIfOccluderMissing() { if (poses == null || occluder != null) return; foreach (var pose in poses) { if (!pose.hideBehindPlatform) continue; Debug.LogWarning($"[CharacterMotionDirector] '{pose.kind}' 에 발판 가림이 켜져 있지만 " + "씬에 PlatformOccluder 가 없어 동작하지 않습니다. " + "메뉴 10 을 다시 실행하세요."); return; } } void OnEnable() { if (loader != null) { loader.Loaded += OnCharacterLoaded; // 이 컴포넌트보다 캐릭터가 먼저 로드됐을 수도 있다. if (loader.Current != null) OnCharacterLoaded(loader.Current); } if (climber != null) { climber.StateChanged += OnStateChanged; currentState = climber.State; } if (dragger != null) dragger.RightClicked += OnRightClicked; } void OnDisable() { if (loader != null) loader.Loaded -= OnCharacterLoaded; if (climber != null) climber.StateChanged -= OnStateChanged; if (dragger != null) dragger.RightClicked -= OnRightClicked; } /// /// 캐릭터를 우클릭하면 포즈 목록을 띄운다. /// /// 서 있을 때만 받는다. 걷거나 떨어지는 중에 눕힐 수는 없다. /// void OnRightClicked() { if (motion == null || currentState != ClimbState.Standing) return; if (poses == null || poses.Length == 0) return; if (poseMenu == null) { // 메뉴가 없으면 최소한 다음 포즈로라도 넘어간다. 아무 반응이 없는 것보다 낫다. poseIndex = (poseIndex + 1) % poses.Length; PlayPose(poses[poseIndex]); return; } if (poseMenu.IsOpen) { poseMenu.Hide(); return; } if (!DesktopCursor.TryGetScreenPosition(CursorHwnd, out Vector2 cursor)) return; poseMenu.Show(cursor, poses, PlayPose); } System.IntPtr CursorHwnd { get { var window = FindFirstObjectByType(); return window != null ? window.Hwnd : System.IntPtr.Zero; } } 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(); if (idle != null) idle.enabled = false; } currentState = climber != null ? climber.State : ClimbState.Standing; poseIndex = 0; ApplyState(currentState); } void OnStateChanged(ClimbState state) { currentState = state; // 움직이기 시작하면 고른 포즈는 버린다. 걷다가 다시 서면 기본 자세부터. poseIndex = 0; ApplyState(state); } /// /// 이동 상태에 맞는 몸짓으로 간다. 서 있는 상태만 "포즈"로 취급해 /// 목록의 첫 항목(가림 설정 포함)을 쓰고, 나머지는 그냥 이동 몸짓이다. /// void ApplyState(ClimbState state) { if (state == ClimbState.Standing) PlayDefaultPose(); else PlayMotion(ToMotionKind(state)); } /// /// 고른 포즈를 재생하고, 그 포즈의 가림 설정도 함께 반영한다. /// 포즈를 바꾸는 곳이 여러 군데라 한 곳으로 모은다 — 따로 두면 가림만 남는 사고가 난다. /// void PlayPose(PoseOption pose) { motion?.Play(pose.kind); if (occluder != null) occluder.Active = pose.hideBehindPlatform; } /// 이동 중의 몸짓. 걷거나 떨어지는 동안에는 가리지 않는다. void PlayMotion(CharacterMotionKind kind) { motion?.Play(kind); if (occluder != null) occluder.Active = false; } /// 발판에 섰을 때의 기본 자세. 목록의 첫 항목이다. void PlayDefaultPose() { if (poses != null && poses.Length > 0) PlayPose(poses[0]); else PlayMotion(CharacterMotionKind.Idle); } 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; } } }