애니메이션 추가
This commit is contained in:
87
Assets/02_Scripts/Character/AnimatorCharacterMotion.cs
Normal file
87
Assets/02_Scripts/Character/AnimatorCharacterMotion.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66d845d6208449845a736ee85c49ddbe
|
||||
178
Assets/02_Scripts/Character/CharacterMotionDirector.cs
Normal file
178
Assets/02_Scripts/Character/CharacterMotionDirector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c2c205cc11a32a43b411e62acd7c65e
|
||||
37
Assets/02_Scripts/Character/ICharacterMotion.cs
Normal file
37
Assets/02_Scripts/Character/ICharacterMotion.cs
Normal 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);
|
||||
}
|
||||
2
Assets/02_Scripts/Character/ICharacterMotion.cs.meta
Normal file
2
Assets/02_Scripts/Character/ICharacterMotion.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 78cdee1c101f71640a4745ecce074721
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
// ---------------- 반영 ----------------
|
||||
|
||||
82
Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs
Normal file
82
Assets/02_Scripts/Editor/CharacterMotionSetupMenu.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 배선을 씬에 넣고, 상태 이름이 맞춰진 빈 Animator Controller 를 만들어 준다.
|
||||
///
|
||||
/// 컨트롤러를 손으로 만들면 상태 이름을 CharacterMotionDirector 의 필드와 일일이
|
||||
/// 맞춰야 하는데, 오타 하나면 조용히 아무 일도 안 일어난다. 그 자리를 없앤다.
|
||||
/// </summary>
|
||||
public static class CharacterMotionSetupMenu
|
||||
{
|
||||
const string ObjectName = "CharacterMotion";
|
||||
const string ControllerPath = "Assets/99_Settings/CharacterMotion.controller";
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/10. Add Character Motion To Scene")]
|
||||
public static void AddMotionDirector()
|
||||
{
|
||||
var existing = Object.FindFirstObjectByType<CharacterMotionDirector>();
|
||||
if (existing != null)
|
||||
{
|
||||
Selection.activeGameObject = existing.gameObject;
|
||||
EditorGUIUtility.PingObject(existing.gameObject);
|
||||
Debug.Log($"[CharacterMotionSetupMenu] 이미 있습니다: {existing.gameObject.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
var go = new GameObject(ObjectName);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Add Character Motion");
|
||||
go.AddComponent<CharacterMotionDirector>();
|
||||
|
||||
Selection.activeGameObject = go;
|
||||
EditorSceneManager.MarkSceneDirty(go.scene);
|
||||
|
||||
Debug.Log(
|
||||
"[CharacterMotionSetupMenu] CharacterMotion 을 추가했습니다. 씬을 저장(Ctrl+S)하세요.\n" +
|
||||
"Motion Controller 가 비어 있으면 지금처럼 ProceduralIdle 로 동작합니다.\n" +
|
||||
"메뉴 11 로 빈 컨트롤러를 만들고 클립을 채워 넣으세요.");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/11. Create Motion Controller")]
|
||||
public static void CreateController()
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<RuntimeAnimatorController>(ControllerPath) != null)
|
||||
{
|
||||
Debug.LogWarning($"[CharacterMotionSetupMenu] 이미 있습니다: {ControllerPath}\n" +
|
||||
"덮어쓰지 않습니다. 지우고 다시 실행하세요.");
|
||||
Selection.activeObject = AssetDatabase.LoadAssetAtPath<Object>(ControllerPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(ControllerPath);
|
||||
var layer = controller.layers[0];
|
||||
var machine = layer.stateMachine;
|
||||
|
||||
// CharacterMotionDirector 의 기본 이름과 같아야 한다.
|
||||
var idle = machine.AddState("Idle");
|
||||
machine.AddState("Fall");
|
||||
machine.AddState("Walk");
|
||||
machine.AddState("Jump");
|
||||
machine.AddState("Sit");
|
||||
machine.AddState("LieDown");
|
||||
|
||||
// 전환은 코드에서 CrossFade 로 직접 하므로 트랜지션을 만들지 않는다.
|
||||
// 상태만 있으면 된다.
|
||||
machine.defaultState = idle;
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
Selection.activeObject = controller;
|
||||
EditorGUIUtility.PingObject(controller);
|
||||
|
||||
Debug.Log(
|
||||
$"[CharacterMotionSetupMenu] 컨트롤러를 만들었습니다: {ControllerPath}\n" +
|
||||
"각 상태(Idle / Fall / Walk / Jump / Sit / LieDown)에 Humanoid 클립을 넣으세요.\n" +
|
||||
"당장 없는 것은 비워둬도 됩니다 — 그 몸짓은 건너뛰고 이전 자세를 유지합니다.\n" +
|
||||
"FBX 는 임포트 설정에서 Rig > Animation Type = Humanoid 여야 리타게팅됩니다.\n" +
|
||||
"다 채운 뒤 CharacterMotionDirector 의 Motion Controller 에 연결하세요.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2ffe87e09d3787a44ac9f6cb3c4cb4cf
|
||||
@@ -12,7 +12,7 @@ public static class ChatSetupMenu
|
||||
{
|
||||
const string ObjectName = "ChatSystem";
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/6. Add Chat Window To Scene")]
|
||||
[MenuItem("Tools/Desktop Overlay/8. Add Chat Window To Scene")]
|
||||
public static void AddChatSystem()
|
||||
{
|
||||
var existing = Object.FindFirstObjectByType<ChatController>();
|
||||
@@ -44,7 +44,7 @@ public static void AddChatSystem()
|
||||
$"(파일로 직접 넣으려면 {ChatConfig.ConfigPath}, 또는 환경 변수 ANTHROPIC_API_KEY)");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/7. Open Chat Config Folder")]
|
||||
[MenuItem("Tools/Desktop Overlay/9. Open Chat Config Folder")]
|
||||
public static void OpenConfigFolder()
|
||||
{
|
||||
string dir = ChatConfig.ConfigDirectory;
|
||||
|
||||
Reference in New Issue
Block a user