창 올라타기
This commit is contained in:
111
Assets/02_Scripts/Character/CharacterCameraFit.cs
Normal file
111
Assets/02_Scripts/Character/CharacterCameraFit.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 카메라를 직교 투영으로 바꾸고, 불러온 캐릭터 크기에 맞춰 자동으로 구도를 잡는다.
|
||||
///
|
||||
/// 왜 직교 투영인가:
|
||||
/// 원근 투영에서는 화면 중앙을 벗어날수록 캐릭터를 비스듬히 옆에서 보게 되어
|
||||
/// 형태가 왜곡된다. 데스크톱 캐릭터는 화면 어디로든 이동하므로 이 왜곡이 그대로
|
||||
/// 드러난다. 직교 투영은 위치와 무관하게 항상 같은 각도로 보인다.
|
||||
///
|
||||
/// 왜 자동인가:
|
||||
/// 사용자가 임의의 VRM 을 넣는 구조라 캐릭터 키를 미리 알 수 없다. 모델마다
|
||||
/// 직교 크기를 손으로 맞추면 모델을 바꿀 때마다 다시 맞춰야 한다.
|
||||
/// </summary>
|
||||
public class CharacterCameraFit : MonoBehaviour
|
||||
{
|
||||
[Header("참조")]
|
||||
[Tooltip("비우면 같은 오브젝트의 Camera 또는 Camera.main")]
|
||||
[SerializeField] Camera targetCamera;
|
||||
|
||||
[Tooltip("비우면 씬에서 탐색")]
|
||||
[SerializeField] VrmCharacterLoader loader;
|
||||
|
||||
[Header("구도")]
|
||||
[Tooltip("직교 투영으로 강제한다. 끄면 현재 투영 방식을 유지한 채 크기만 맞춘다")]
|
||||
[SerializeField] bool forceOrthographic = true;
|
||||
|
||||
[Tooltip("캐릭터가 화면 높이에서 차지할 비율. 0.30면 화면의 30%")]
|
||||
[Range(0.1f, 1f)]
|
||||
[SerializeField] float screenHeightFraction = 0.30f;
|
||||
|
||||
[Tooltip("캐릭터가 처음에 화면 중앙에 오도록 카메라 위치도 맞춘다")]
|
||||
[SerializeField] bool centerOnCharacter = true;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (targetCamera == null) targetCamera = GetComponent<Camera>();
|
||||
if (targetCamera == null) targetCamera = Camera.main;
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
|
||||
if (loader != null) loader.Loaded += OnCharacterLoaded;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (loader != null) loader.Loaded -= OnCharacterLoaded;
|
||||
}
|
||||
|
||||
void OnCharacterLoaded(ICharacterAvatar avatar)
|
||||
{
|
||||
if (avatar != null) Fit(avatar.Root);
|
||||
}
|
||||
|
||||
/// <summary>캐릭터를 감싸도록 카메라 크기와 위치를 맞춘다.</summary>
|
||||
public void Fit(Transform character)
|
||||
{
|
||||
if (targetCamera == null || character == null) return;
|
||||
|
||||
var renderers = character.GetComponentsInChildren<Renderer>(true);
|
||||
if (renderers.Length == 0)
|
||||
{
|
||||
Debug.LogWarning("[CharacterCameraFit] 렌더러가 없어 구도를 잡지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
Bounds bounds = renderers[0].bounds;
|
||||
for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);
|
||||
|
||||
float characterHeight = Mathf.Max(0.01f, bounds.size.y);
|
||||
|
||||
if (forceOrthographic) targetCamera.orthographic = true;
|
||||
|
||||
if (targetCamera.orthographic)
|
||||
{
|
||||
// orthographicSize 는 화면에 보이는 세계 높이의 절반이다.
|
||||
// 보이는 높이 = 캐릭터 키 / 비율 이므로, 그 절반이 필요한 값이다.
|
||||
targetCamera.orthographicSize = characterHeight / (2f * screenHeightFraction);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 원근이면 거리로 맞춘다. 왜곡은 남으므로 권장하지 않는다.
|
||||
float visibleHeight = characterHeight / screenHeightFraction;
|
||||
float halfFovRad = targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad;
|
||||
float distance = visibleHeight * 0.5f / Mathf.Tan(halfFovRad);
|
||||
|
||||
Vector3 dir = targetCamera.transform.forward;
|
||||
targetCamera.transform.position = bounds.center - dir * distance;
|
||||
}
|
||||
|
||||
if (centerOnCharacter)
|
||||
{
|
||||
// 카메라의 시선 방향(깊이)은 유지하고, 화면상 중앙만 캐릭터에 맞춘다.
|
||||
Vector3 camPos = targetCamera.transform.position;
|
||||
Vector3 toCharacter = bounds.center - camPos;
|
||||
Vector3 forward = targetCamera.transform.forward;
|
||||
|
||||
// 깊이 성분만 남기고 나머지를 상쇄해 캐릭터가 화면 중앙에 오게 한다.
|
||||
Vector3 depthOnly = Vector3.Project(toCharacter, forward);
|
||||
targetCamera.transform.position = bounds.center - depthOnly;
|
||||
}
|
||||
|
||||
// 직교 투영에서 near 가 캐릭터보다 앞에 있으면 잘려 보인다.
|
||||
if (targetCamera.orthographic && targetCamera.nearClipPlane > 0.05f)
|
||||
{
|
||||
targetCamera.nearClipPlane = 0.05f;
|
||||
}
|
||||
|
||||
Debug.Log($"[CharacterCameraFit] 구도 적용. 캐릭터 키 {characterHeight:F2}m, " +
|
||||
$"직교={targetCamera.orthographic}, size={targetCamera.orthographicSize:F2}");
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/CharacterCameraFit.cs.meta
Normal file
2
Assets/02_Scripts/Character/CharacterCameraFit.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de48ad5f60a02124cbb13459f90c9a3f
|
||||
168
Assets/02_Scripts/Character/CharacterDragger.cs
Normal file
168
Assets/02_Scripts/Character/CharacterDragger.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터를 마우스로 집어 옮긴다.
|
||||
///
|
||||
/// 구현 노트 1 — 왜 Unity Input 을 쓰지 않는가:
|
||||
/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않는다. 클릭 자체는 들어오지만
|
||||
/// 포커스 없는 상태의 입력은 신뢰도가 떨어지고, 커서가 창 밖으로 나가면 Unity 는
|
||||
/// 위치를 아예 모른다. GetAsyncKeyState 와 GetCursorPos 로 OS 에 직접 물으면
|
||||
/// 포커스와 무관하게 항상 정확하다.
|
||||
///
|
||||
/// 구현 노트 2 — 드래그 중 클릭 통과 잠금:
|
||||
/// 커서를 빠르게 움직이면 캐릭터 콜라이더를 벗어나는데, 그 순간 히트테스트가
|
||||
/// 클릭 통과를 다시 켜버리면 입력이 뒤 창으로 새면서 드래그가 끊긴다.
|
||||
/// 드래그 동안 ForceInteractive 로 히트테스트를 무력화한다.
|
||||
///
|
||||
/// 여기서 만든 "화면 좌표 -> 월드 위치" 배선은 창 올라타기에서도 그대로 쓴다.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(ClickThroughHitTest))]
|
||||
public class CharacterDragger : MonoBehaviour
|
||||
{
|
||||
[Header("참조")]
|
||||
[Tooltip("비우면 같은 오브젝트의 Camera 또는 Camera.main")]
|
||||
[SerializeField] Camera viewCamera;
|
||||
|
||||
[Tooltip("비우면 씬에서 탐색")]
|
||||
[SerializeField] TransparentWindow window;
|
||||
|
||||
[Tooltip("비우면 씬에서 탐색")]
|
||||
[SerializeField] VrmCharacterLoader loader;
|
||||
|
||||
[Header("동작")]
|
||||
[Tooltip("0 이면 커서에 즉시 붙는다. 값을 올리면 살짝 끌려오는 느낌이 난다")]
|
||||
[Range(0f, 30f)]
|
||||
[SerializeField] float followSpeed = 0f;
|
||||
|
||||
[Tooltip("이 거리 이상 움직여야 드래그로 인정한다. 클릭과 드래그를 구분한다")]
|
||||
[SerializeField] float dragThresholdPixels = 4f;
|
||||
|
||||
[Tooltip("화면 가장자리에서 추가로 띄울 여백(픽셀). 캐릭터가 잘리지 않도록 제한한다")]
|
||||
[SerializeField] float screenMargin = 0f;
|
||||
|
||||
ClickThroughHitTest hitTest;
|
||||
|
||||
Transform target;
|
||||
bool pressing; // 버튼이 눌린 상태(아직 드래그는 아닐 수 있음)
|
||||
bool dragging; // 임계값을 넘겨 실제 드래그 중
|
||||
Vector2 pressStartPos;
|
||||
Vector2 grabScreenOffset; // 캐릭터 원점 - 커서 (화면 좌표)
|
||||
Vector2 boundsOffMin, boundsOffMax; // 원점 기준 캐릭터의 화면상 범위
|
||||
float dragDepth;
|
||||
|
||||
/// <summary>드래그 중인지. 창 올라타기 등 자동 이동 로직이 이 동안 양보한다.</summary>
|
||||
public bool IsDragging => dragging;
|
||||
|
||||
/// <summary>드래그가 끝난 순간 호출. 창 올라타기가 착지 지점을 다시 계산할 때 쓴다.</summary>
|
||||
public event System.Action<Transform> DragEnded;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
hitTest = GetComponent<ClickThroughHitTest>();
|
||||
if (viewCamera == null) viewCamera = GetComponent<Camera>();
|
||||
if (viewCamera == null) viewCamera = Camera.main;
|
||||
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
bool buttonDown = Win32.IsKeyDown(Win32.VK_LBUTTON);
|
||||
#else
|
||||
var mouse = UnityEngine.InputSystem.Mouse.current;
|
||||
bool buttonDown = mouse != null && mouse.leftButton.isPressed;
|
||||
#endif
|
||||
|
||||
if (!pressing && buttonDown && hitTest.IsOverCharacter)
|
||||
{
|
||||
BeginPress();
|
||||
}
|
||||
else if (pressing && !buttonDown)
|
||||
{
|
||||
EndPress();
|
||||
}
|
||||
|
||||
if (pressing) UpdatePress();
|
||||
}
|
||||
|
||||
void BeginPress()
|
||||
{
|
||||
target = loader != null && loader.Current != null ? loader.Current.Root : null;
|
||||
if (target == null) return;
|
||||
if (!TryGetCursor(out Vector2 sp)) return;
|
||||
|
||||
pressing = true;
|
||||
dragging = false;
|
||||
pressStartPos = sp;
|
||||
|
||||
// 캐릭터가 놓인 깊이를 유지한다. 이 평면 위에서만 움직이므로 크기가 변하지 않는다.
|
||||
dragDepth = Vector3.Dot(target.position - viewCamera.transform.position,
|
||||
viewCamera.transform.forward);
|
||||
|
||||
// 깊이가 고정이라 화면상 크기도 고정이다. 시작할 때 한 번만 재면 된다.
|
||||
if (!CharacterScreenBounds.TryMeasure(viewCamera, target, out boundsOffMin, out boundsOffMax))
|
||||
{
|
||||
boundsOffMin = boundsOffMax = Vector2.zero;
|
||||
}
|
||||
|
||||
Vector2 originScreen = viewCamera.WorldToScreenPoint(target.position);
|
||||
grabScreenOffset = originScreen - sp;
|
||||
}
|
||||
|
||||
void UpdatePress()
|
||||
{
|
||||
if (target == null) { EndPress(); return; }
|
||||
if (!TryGetCursor(out Vector2 sp)) return;
|
||||
|
||||
if (!dragging)
|
||||
{
|
||||
// 임계값을 넘기 전까지는 클릭으로 본다. 제자리 클릭에 캐릭터가 튀지 않게.
|
||||
if (Vector2.Distance(sp, pressStartPos) < dragThresholdPixels) return;
|
||||
|
||||
dragging = true;
|
||||
hitTest.ForceInteractive = true;
|
||||
}
|
||||
|
||||
// 화면 좌표에서 제한한다. 월드에서 제한하면 카메라 각도에 따라 계산이 복잡해진다.
|
||||
Vector2 desiredScreen = sp + grabScreenOffset;
|
||||
desiredScreen = CharacterScreenBounds.Clamp(desiredScreen, boundsOffMin, boundsOffMax, screenMargin);
|
||||
|
||||
Vector3 desired = ScreenToWorld(desiredScreen);
|
||||
|
||||
if (followSpeed <= 0.01f)
|
||||
{
|
||||
target.position = desired;
|
||||
}
|
||||
else
|
||||
{
|
||||
float t = 1f - Mathf.Exp(-followSpeed * Time.deltaTime); // 프레임레이트 독립
|
||||
target.position = Vector3.Lerp(target.position, desired, t);
|
||||
}
|
||||
}
|
||||
|
||||
void EndPress()
|
||||
{
|
||||
bool wasDragging = dragging;
|
||||
Transform dragged = target;
|
||||
|
||||
pressing = false;
|
||||
dragging = false;
|
||||
hitTest.ForceInteractive = false;
|
||||
target = null;
|
||||
|
||||
if (wasDragging && dragged != null) DragEnded?.Invoke(dragged);
|
||||
}
|
||||
|
||||
bool TryGetCursor(out Vector2 screenPos)
|
||||
{
|
||||
System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
|
||||
return DesktopCursor.TryGetScreenPosition(hwnd, out screenPos);
|
||||
}
|
||||
|
||||
/// <summary>화면 좌표를 드래그 평면(고정 깊이) 위의 월드 좌표로 변환한다.</summary>
|
||||
Vector3 ScreenToWorld(Vector2 screenPos)
|
||||
{
|
||||
return viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, dragDepth));
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/CharacterDragger.cs.meta
Normal file
2
Assets/02_Scripts/Character/CharacterDragger.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2ae16f85f0c0304eb3c60b5172e5306
|
||||
75
Assets/02_Scripts/Character/CharacterScreenBounds.cs
Normal file
75
Assets/02_Scripts/Character/CharacterScreenBounds.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터가 화면 밖으로 나가 잘리지 않도록 화면 좌표를 제한한다.
|
||||
///
|
||||
/// 드래그와 창 올라타기 양쪽에서 쓴다. 캐릭터를 화면 가장자리 창의 타이틀바에
|
||||
/// 올려놓을 때도 같은 제한이 필요하기 때문에 별도 유틸로 분리했다.
|
||||
///
|
||||
/// 캐릭터는 고정된 깊이 평면 위에서만 움직이므로, 화면상 크기는 드래그 중에
|
||||
/// 변하지 않는다. 그래서 시작할 때 한 번만 재면 된다.
|
||||
/// </summary>
|
||||
public static class CharacterScreenBounds
|
||||
{
|
||||
/// <summary>
|
||||
/// 캐릭터 원점(transform.position) 기준으로, 화면상 어디까지 뻗어 있는지 잰다.
|
||||
/// 반환되는 offMin/offMax 는 원점의 화면 좌표에 대한 상대 오프셋이다.
|
||||
/// </summary>
|
||||
public static bool TryMeasure(Camera cam, Transform character, out Vector2 offMin, out Vector2 offMax)
|
||||
{
|
||||
offMin = offMax = Vector2.zero;
|
||||
if (cam == null || character == null) return false;
|
||||
|
||||
var renderers = character.GetComponentsInChildren<Renderer>(true);
|
||||
if (renderers.Length == 0) return false;
|
||||
|
||||
Bounds world = renderers[0].bounds;
|
||||
for (int i = 1; i < renderers.Length; i++) world.Encapsulate(renderers[i].bounds);
|
||||
|
||||
Vector3 c = world.center;
|
||||
Vector3 e = world.extents;
|
||||
|
||||
// AABB 를 화면에 투영하면 축 정렬이 깨지므로 8개 꼭짓점을 모두 변환해 감싼다.
|
||||
var min = new Vector2(float.MaxValue, float.MaxValue);
|
||||
var max = new Vector2(float.MinValue, float.MinValue);
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
var corner = new Vector3(
|
||||
c.x + ((i & 1) == 0 ? -e.x : e.x),
|
||||
c.y + ((i & 2) == 0 ? -e.y : e.y),
|
||||
c.z + ((i & 4) == 0 ? -e.z : e.z));
|
||||
|
||||
Vector3 sp = cam.WorldToScreenPoint(corner);
|
||||
min = Vector2.Min(min, sp);
|
||||
max = Vector2.Max(max, sp);
|
||||
}
|
||||
|
||||
Vector2 origin = cam.WorldToScreenPoint(character.position);
|
||||
offMin = min - origin;
|
||||
offMax = max - origin;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터 원점의 화면 좌표를, 캐릭터 전체가 화면 안에 들어오도록 제한한다.
|
||||
/// </summary>
|
||||
/// <param name="margin">가장자리에서 추가로 띄울 여백(픽셀)</param>
|
||||
public static Vector2 Clamp(Vector2 origin, Vector2 offMin, Vector2 offMax, float margin = 0f)
|
||||
{
|
||||
return new Vector2(
|
||||
ClampAxis(origin.x, offMin.x, offMax.x, Screen.width, margin),
|
||||
ClampAxis(origin.y, offMin.y, offMax.y, Screen.height, margin));
|
||||
}
|
||||
|
||||
static float ClampAxis(float value, float offMin, float offMax, float screenSize, float margin)
|
||||
{
|
||||
float lo = margin - offMin;
|
||||
float hi = screenSize - margin - offMax;
|
||||
|
||||
// 캐릭터가 화면보다 큰 경우 범위가 뒤집힌다. 그때는 가운데로 보낸다.
|
||||
if (lo > hi) return (lo + hi) * 0.5f;
|
||||
|
||||
return Mathf.Clamp(value, lo, hi);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 75b72713dd560d3459533109b137171a
|
||||
@@ -52,6 +52,14 @@ public class HeadLookAt : MonoBehaviour
|
||||
[Tooltip("대상을 놓쳤을 때 정면으로 돌아가기까지의 유예 시간(초)")]
|
||||
[SerializeField] float returnDelay = 1.5f;
|
||||
|
||||
[Header("주의 범위")]
|
||||
[Tooltip("이 거리 안에서는 완전히 쳐다본다. 캐릭터의 화면상 키를 1 로 하는 배수. " +
|
||||
"픽셀이 아니라 배수라서 캐릭터 크기나 해상도가 달라져도 체감이 유지된다")]
|
||||
[SerializeField] float attentionRadius = 0.8f;
|
||||
|
||||
[Tooltip("반경 바깥으로 이만큼 더 멀어지는 동안 서서히 정면으로 돌아간다")]
|
||||
[SerializeField] float attentionFalloff = 0.7f;
|
||||
|
||||
Transform head;
|
||||
Transform neck;
|
||||
Transform root;
|
||||
@@ -59,6 +67,9 @@ public class HeadLookAt : MonoBehaviour
|
||||
// 기준(정지) 자세. 회전 누적을 막기 위해 매 프레임 여기로 되돌린 뒤 적용한다.
|
||||
Quaternion restHead, restNeck;
|
||||
|
||||
// 주의 반경의 기준 단위. 캐릭터가 화면에서 차지하는 높이(픽셀).
|
||||
float characterScreenHeight = 200f;
|
||||
|
||||
Vector2 currentAngles; // x = yaw, y = pitch
|
||||
float lastSeenTime = -999f;
|
||||
|
||||
@@ -93,6 +104,14 @@ bool TryResolveBones()
|
||||
{
|
||||
restHead = head.localRotation;
|
||||
if (neck != null) restNeck = neck.localRotation;
|
||||
|
||||
// 캐릭터는 고정 깊이 평면 위에 있으므로 화면상 크기가 변하지 않는다. 한 번만 잰다.
|
||||
if (viewCamera != null &&
|
||||
CharacterScreenBounds.TryMeasure(viewCamera, root, out Vector2 offMin, out Vector2 offMax))
|
||||
{
|
||||
characterScreenHeight = Mathf.Max(1f, offMax.y - offMin.y);
|
||||
}
|
||||
|
||||
resolved = true;
|
||||
return true;
|
||||
}
|
||||
@@ -112,10 +131,10 @@ void LateUpdate()
|
||||
if (head == null) return;
|
||||
|
||||
Vector2 desired;
|
||||
if (TryGetTargetPosition(out Vector3 targetPos))
|
||||
if (TryGetTargetPosition(out Vector3 targetPos, out float attention))
|
||||
{
|
||||
lastSeenTime = Time.time;
|
||||
desired = ComputeAngles(targetPos);
|
||||
desired = ComputeAngles(targetPos) * attention;
|
||||
}
|
||||
else if (Time.time - lastSeenTime < returnDelay)
|
||||
{
|
||||
@@ -133,9 +152,11 @@ void LateUpdate()
|
||||
ApplyRotation();
|
||||
}
|
||||
|
||||
bool TryGetTargetPosition(out Vector3 worldPos)
|
||||
/// <param name="attention">0 = 관심 없음(정면), 1 = 완전히 쳐다봄</param>
|
||||
bool TryGetTargetPosition(out Vector3 worldPos, out float attention)
|
||||
{
|
||||
worldPos = default;
|
||||
attention = 1f;
|
||||
|
||||
if (!followCursor)
|
||||
{
|
||||
@@ -149,6 +170,14 @@ bool TryGetTargetPosition(out Vector3 worldPos)
|
||||
System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
|
||||
if (!DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 screenPos)) return false;
|
||||
|
||||
// 커서가 얼마나 가까운지로 관심도를 정한다. 멀면 굳이 쳐다보지 않는다.
|
||||
Vector2 headScreen = viewCamera.WorldToScreenPoint(head.position);
|
||||
float distance = Vector2.Distance(screenPos, headScreen);
|
||||
|
||||
float inner = attentionRadius * characterScreenHeight;
|
||||
float outer = inner + Mathf.Max(1f, attentionFalloff * characterScreenHeight);
|
||||
attention = 1f - Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(inner, outer, distance));
|
||||
|
||||
// 머리와 같은 깊이 평면에 커서를 투영한다.
|
||||
Vector3 camForward = viewCamera.transform.forward;
|
||||
float depth = Vector3.Dot(head.position - viewCamera.transform.position, camForward);
|
||||
|
||||
420
Assets/02_Scripts/Character/WindowClimber.cs
Normal file
420
Assets/02_Scripts/Character/WindowClimber.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>캐릭터의 이동 상태. 나중에 애니메이션 클립을 이 상태에 물리면 된다.</summary>
|
||||
public enum ClimbState
|
||||
{
|
||||
Falling, // 낙하 중
|
||||
Standing, // 발판 위에 서 있음
|
||||
Walking, // 발판 위를 걷는 중 (목표 X 로 이동)
|
||||
Climbing, // 다른 발판으로 올라가는 중
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터를 창 위에 올려놓고, 중력·착지·탑승·자동 올라타기를 처리한다.
|
||||
///
|
||||
/// 모든 계산은 화면 좌표(픽셀)로 한다. 발판 스캐너가 화면 좌표를 주고, 화면 제한도
|
||||
/// 화면 좌표로 하므로 일관되게 맞춘다. 마지막에 한 번만 월드 좌표로 변환한다.
|
||||
///
|
||||
/// 자동 올라타기의 판단 기준은 "새 창이 생겼는가"가 아니라 "발판이 새로 생겼거나
|
||||
/// 위치가 바뀌었는가"다. 새 창은 대개 최대화 상태로 열려 손이 닿지 않지만, 그 창을
|
||||
/// 나중에 줄이거나 옮기면 그때 올라탈 수 있게 되기 때문이다.
|
||||
/// </summary>
|
||||
public class WindowClimber : MonoBehaviour
|
||||
{
|
||||
[Header("참조")]
|
||||
[SerializeField] DesktopPlatformScanner scanner;
|
||||
[SerializeField] CharacterDragger dragger;
|
||||
[SerializeField] VrmCharacterLoader loader;
|
||||
[SerializeField] Camera viewCamera;
|
||||
|
||||
[Header("물리 (화면 픽셀 기준)")]
|
||||
[Tooltip("초당 낙하 가속도")]
|
||||
[SerializeField] float gravity = 2600f;
|
||||
|
||||
[Tooltip("최대 낙하 속도")]
|
||||
[SerializeField] float maxFallSpeed = 1800f;
|
||||
|
||||
[Tooltip("걷는 속도 (픽셀/초)")]
|
||||
[SerializeField] float walkSpeed = 160f;
|
||||
|
||||
[Header("자동 올라타기")]
|
||||
[SerializeField] bool autoClimb = true;
|
||||
|
||||
[Tooltip("바닥에 있을 때만 올라탄다. 이미 창 위에 있으면 다른 창으로 옮기지 않는다")]
|
||||
[SerializeField] bool stayOnCurrentWindow = true;
|
||||
|
||||
[Tooltip("올라탈 대상을 지금 활성화된 창으로 한정한다. " +
|
||||
"여러 창이 열려 있어도 사용자가 보고 있는 창에만 올라간다")]
|
||||
[SerializeField] bool activeWindowOnly = true;
|
||||
|
||||
[Tooltip("올라갈 수 있는 최대 높이차. 0 이면 제한 없음 — 캐릭터가 화면 안에 들어가기만 하면 올라간다. " +
|
||||
"실제 제약은 머리가 화면 위로 잘리는지(WouldBeClipped)이므로 대개 0 으로 둬도 된다")]
|
||||
[SerializeField] float climbHeightLimit = 0f;
|
||||
|
||||
[Tooltip("이보다 멀리 떨어진 발판은 포기한다")]
|
||||
[SerializeField] float maxClimbDistance = 1000f;
|
||||
|
||||
[Tooltip("현재 발판보다 이만큼은 높아야 올라갈 가치가 있다고 본다")]
|
||||
[SerializeField] float minClimbGain = 60f;
|
||||
|
||||
[Tooltip("올라타기 사이의 최소 간격(초). 창을 만질 때마다 뛰어다니지 않게 한다")]
|
||||
[SerializeField] float climbCooldown = 3f;
|
||||
|
||||
[Tooltip("올라가는 속도 (픽셀/초). 거리에 비례해 시간이 정해진다")]
|
||||
[SerializeField] float climbSpeed = 420f;
|
||||
|
||||
[Header("발판 여백")]
|
||||
[Tooltip("발판 좌우 끝에서 최소한 이만큼 안쪽에 선다. " +
|
||||
"캐릭터 반폭보다 작으면 반폭이 우선 적용된다")]
|
||||
[SerializeField] float platformEdgeMargin = 70f;
|
||||
|
||||
[Tooltip("화면 위쪽 여백. 캐릭터 머리가 이보다 위로 가면 발판에서 뛰어내린다")]
|
||||
[SerializeField] float ceilingMargin = 8f;
|
||||
|
||||
[Header("기타")]
|
||||
[Tooltip("착지 판정 여유. 발판을 살짝 지나쳐도 잡아준다")]
|
||||
[SerializeField] float landTolerance = 24f;
|
||||
|
||||
public ClimbState State { get; private set; } = ClimbState.Falling;
|
||||
|
||||
Transform character;
|
||||
float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다.
|
||||
Vector2 screenPos; // 캐릭터 발밑의 화면 좌표
|
||||
float verticalVelocity;
|
||||
|
||||
// 탑승 정보
|
||||
System.IntPtr standingHwnd;
|
||||
float offsetFromWindowLeft;
|
||||
bool hasStanding;
|
||||
|
||||
// 목표
|
||||
float walkTargetX;
|
||||
DesktopPlatform climbTarget;
|
||||
Vector2 climbStart;
|
||||
float climbProgress;
|
||||
float climbDuration;
|
||||
|
||||
float nextClimbAllowedTime;
|
||||
|
||||
// 캐릭터의 화면상 범위(원점 기준 오프셋). 깊이가 고정이라 한 번만 재면 된다.
|
||||
Vector2 charOffMin, charOffMax;
|
||||
bool hasExtents;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (scanner == null) scanner = FindFirstObjectByType<DesktopPlatformScanner>();
|
||||
if (dragger == null) dragger = FindFirstObjectByType<CharacterDragger>();
|
||||
if (loader == null) loader = FindFirstObjectByType<VrmCharacterLoader>();
|
||||
if (viewCamera == null) viewCamera = Camera.main;
|
||||
|
||||
if (loader != null) loader.Loaded += OnCharacterLoaded;
|
||||
if (scanner != null) scanner.Rescanned += OnRescanned;
|
||||
if (dragger != null) dragger.DragEnded += OnDragEnded;
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
if (loader != null) loader.Loaded -= OnCharacterLoaded;
|
||||
if (scanner != null) scanner.Rescanned -= OnRescanned;
|
||||
if (dragger != null) dragger.DragEnded -= OnDragEnded;
|
||||
}
|
||||
|
||||
void OnCharacterLoaded(ICharacterAvatar avatar)
|
||||
{
|
||||
character = avatar?.Root;
|
||||
if (character == null || viewCamera == null) return;
|
||||
|
||||
depth = Vector3.Dot(character.position - viewCamera.transform.position,
|
||||
viewCamera.transform.forward);
|
||||
screenPos = viewCamera.WorldToScreenPoint(character.position);
|
||||
|
||||
hasExtents = CharacterScreenBounds.TryMeasure(viewCamera, character,
|
||||
out charOffMin, out charOffMax);
|
||||
EnterFalling();
|
||||
}
|
||||
|
||||
void OnDragEnded(Transform t)
|
||||
{
|
||||
// 손에서 놓으면 그 자리에서 다시 떨어진다.
|
||||
if (character == null) return;
|
||||
screenPos = viewCamera.WorldToScreenPoint(character.position);
|
||||
EnterFalling();
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (character == null || viewCamera == null) return;
|
||||
|
||||
// 드래그 중에는 사용자가 위치를 정한다. 물리는 쉰다.
|
||||
if (dragger != null && dragger.IsDragging)
|
||||
{
|
||||
screenPos = viewCamera.WorldToScreenPoint(character.position);
|
||||
return;
|
||||
}
|
||||
|
||||
float dt = Time.deltaTime;
|
||||
|
||||
switch (State)
|
||||
{
|
||||
case ClimbState.Falling: TickFalling(dt); break;
|
||||
case ClimbState.Standing: TickStanding(dt); break;
|
||||
case ClimbState.Walking: TickWalking(dt); break;
|
||||
case ClimbState.Climbing: TickClimbing(dt); break;
|
||||
}
|
||||
|
||||
Apply();
|
||||
}
|
||||
|
||||
// ---------------- 상태별 처리 ----------------
|
||||
|
||||
void EnterFalling()
|
||||
{
|
||||
State = ClimbState.Falling;
|
||||
verticalVelocity = 0f;
|
||||
hasStanding = false;
|
||||
}
|
||||
|
||||
void TickFalling(float dt)
|
||||
{
|
||||
verticalVelocity = Mathf.Max(verticalVelocity - gravity * dt, -maxFallSpeed);
|
||||
|
||||
float prevY = screenPos.y;
|
||||
screenPos.y += verticalVelocity * dt;
|
||||
|
||||
// 이번 프레임에 지나친 발판이 있으면 거기에 착지한다.
|
||||
// 위치만 비교하면 빠른 낙하에서 발판을 뚫고 지나간다.
|
||||
if (scanner != null && TryFindCrossedPlatform(prevY, screenPos.y, out var landed))
|
||||
{
|
||||
Land(landed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면 아래로 빠지면 바닥으로 되돌린다.
|
||||
if (screenPos.y < -200f) screenPos.y = Screen.height * 0.5f;
|
||||
}
|
||||
|
||||
bool TryFindCrossedPlatform(float fromY, float toY, out DesktopPlatform result)
|
||||
{
|
||||
result = default;
|
||||
bool any = false;
|
||||
|
||||
foreach (var p in scanner.Platforms)
|
||||
{
|
||||
if (screenPos.x < p.XMin || screenPos.x > p.XMax) continue;
|
||||
|
||||
// 위에서 아래로 내려오면서 발판 높이를 통과했는가
|
||||
if (p.Y > fromY + landTolerance) continue;
|
||||
if (p.Y < toY - landTolerance) continue;
|
||||
if (WouldBeClipped(p.Y)) continue; // 서면 머리가 잘리는 발판
|
||||
|
||||
if (any && p.Y <= result.Y) continue; // 여러 개면 가장 높은 것
|
||||
result = p;
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
void Land(DesktopPlatform p)
|
||||
{
|
||||
screenPos.y = p.Y;
|
||||
verticalVelocity = 0f;
|
||||
|
||||
standingHwnd = p.Hwnd;
|
||||
offsetFromWindowLeft = screenPos.x - p.WindowLeft;
|
||||
hasStanding = true;
|
||||
|
||||
State = ClimbState.Standing;
|
||||
}
|
||||
|
||||
void TickStanding(float dt)
|
||||
{
|
||||
// 서 있는 동안 할 일은 없다. 탑승 갱신은 스캔 시점에 처리한다.
|
||||
}
|
||||
|
||||
void TickWalking(float dt)
|
||||
{
|
||||
float dir = Mathf.Sign(walkTargetX - screenPos.x);
|
||||
screenPos.x += dir * walkSpeed * dt;
|
||||
|
||||
if (Mathf.Abs(walkTargetX - screenPos.x) <= walkSpeed * dt)
|
||||
{
|
||||
screenPos.x = walkTargetX;
|
||||
BeginClimbArc();
|
||||
}
|
||||
}
|
||||
|
||||
void BeginClimbArc()
|
||||
{
|
||||
climbStart = screenPos;
|
||||
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;
|
||||
}
|
||||
|
||||
void TickClimbing(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));
|
||||
|
||||
if (t >= 1f) Land(climbTarget);
|
||||
}
|
||||
|
||||
/// <summary>캐릭터 반폭과 설정 여백 중 큰 쪽. 몸이 발판 밖으로 걸치지 않게 한다.</summary>
|
||||
float EdgeMargin()
|
||||
{
|
||||
float halfWidth = hasExtents ? (charOffMax.x - charOffMin.x) * 0.5f : 0f;
|
||||
return Mathf.Max(platformEdgeMargin, halfWidth);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 높이의 발판에 서면 머리가 화면 위로 잘리는지.
|
||||
/// 창을 위로 끌어올리면 발판도 따라 올라가는데, 그대로 두면 캐릭터가
|
||||
/// 화면을 뚫고 나간다. 그전에 뛰어내리게 하기 위한 판정.
|
||||
/// </summary>
|
||||
bool WouldBeClipped(float platformY)
|
||||
{
|
||||
if (!hasExtents) return false;
|
||||
return platformY + charOffMax.y > Screen.height - ceilingMargin;
|
||||
}
|
||||
|
||||
/// <summary>발판 위에서 설 수 있는 X 범위로 제한한다. 너무 좁으면 가운데로.</summary>
|
||||
float ClampToPlatform(DesktopPlatform p)
|
||||
{
|
||||
float margin = EdgeMargin();
|
||||
float lo = p.XMin + margin;
|
||||
float hi = p.XMax - margin;
|
||||
if (lo > hi) return p.Center;
|
||||
return Mathf.Clamp(screenPos.x, lo, hi);
|
||||
}
|
||||
|
||||
// ---------------- 스캔 반응 ----------------
|
||||
|
||||
void OnRescanned()
|
||||
{
|
||||
if (character == null || scanner == null) return;
|
||||
if (dragger != null && dragger.IsDragging) return;
|
||||
|
||||
if (State == ClimbState.Standing)
|
||||
{
|
||||
UpdateRiding();
|
||||
if (autoClimb) ConsiderClimb();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>서 있는 창이 움직이면 캐릭터도 따라 움직인다. 자리가 사라지면 떨어진다.</summary>
|
||||
void UpdateRiding()
|
||||
{
|
||||
if (!hasStanding) return;
|
||||
if (standingHwnd == System.IntPtr.Zero) return; // 바닥은 움직이지 않는다
|
||||
|
||||
DesktopPlatform? match = null;
|
||||
foreach (var p in scanner.Platforms)
|
||||
{
|
||||
if (p.Hwnd != standingHwnd) continue;
|
||||
|
||||
float wanted = p.WindowLeft + offsetFromWindowLeft;
|
||||
if (wanted >= p.XMin && wanted <= p.XMax) { match = p; break; }
|
||||
}
|
||||
|
||||
if (match == null)
|
||||
{
|
||||
// 창이 닫혔거나, 다른 창에 가려져 설 자리가 없어졌다.
|
||||
EnterFalling();
|
||||
return;
|
||||
}
|
||||
|
||||
var m = match.Value;
|
||||
|
||||
// 창을 위로 계속 끌어올리면 캐릭터가 화면 밖으로 밀려난다. 그전에 뛰어내린다.
|
||||
if (WouldBeClipped(m.Y))
|
||||
{
|
||||
EnterFalling();
|
||||
return;
|
||||
}
|
||||
|
||||
screenPos.x = m.WindowLeft + offsetFromWindowLeft;
|
||||
screenPos.y = m.Y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 올라탈 곳을 고른다. 규칙은 두 가지다.
|
||||
/// - 이미 창 위에 서 있으면 옮기지 않는다. 창을 옮길 때마다 캐릭터가
|
||||
/// 따라다니면 정신없고, 사용자가 올려둔 자리를 존중하는 편이 낫다.
|
||||
/// - 바닥에 있을 때는 "지금 활성화된 창"에만 올라간다. 창이 여러 개 열려
|
||||
/// 있을 때 엉뚱한 배경 창으로 올라가는 것을 막는다.
|
||||
/// </summary>
|
||||
void ConsiderClimb()
|
||||
{
|
||||
if (Time.unscaledTime < nextClimbAllowedTime) return;
|
||||
|
||||
// 이미 창 위 — 그대로 둔다. (바닥은 Hwnd 가 Zero 라 여기 걸리지 않는다)
|
||||
if (stayOnCurrentWindow && hasStanding && standingHwnd != System.IntPtr.Zero) return;
|
||||
|
||||
System.IntPtr active = activeWindowOnly ? Win32.GetForegroundWindow() : System.IntPtr.Zero;
|
||||
|
||||
bool found = false;
|
||||
float bestScore = float.MaxValue;
|
||||
DesktopPlatform best = default;
|
||||
|
||||
foreach (var p in scanner.Platforms)
|
||||
{
|
||||
if (p.IsFloor) continue;
|
||||
if (activeWindowOnly && p.Hwnd != active) continue;
|
||||
|
||||
float gain = p.Y - screenPos.y;
|
||||
if (gain < minClimbGain) continue; // 지금보다 충분히 높지 않다
|
||||
if (climbHeightLimit > 0f && gain > climbHeightLimit) continue;
|
||||
if (WouldBeClipped(p.Y)) continue; // 올라가봐야 머리가 잘린다
|
||||
|
||||
float margin = EdgeMargin();
|
||||
if (p.Width < margin * 2f) continue; // 캐릭터가 설 만큼 넓지 않다
|
||||
|
||||
float targetX = Mathf.Clamp(screenPos.x, p.XMin + margin, p.XMax - margin);
|
||||
float horizontal = Mathf.Abs(targetX - screenPos.x);
|
||||
if (horizontal > maxClimbDistance) continue;
|
||||
|
||||
// 가로 이동이 적은 쪽을 우선한다. 높이는 약하게만 반영해서,
|
||||
// 화면에 들어가기만 하면 높은 곳도 후보로 남게 한다.
|
||||
float score = horizontal + gain * 0.15f;
|
||||
if (found && score >= bestScore) continue;
|
||||
|
||||
best = p;
|
||||
bestScore = score;
|
||||
found = true;
|
||||
}
|
||||
|
||||
if (!found) return;
|
||||
|
||||
climbTarget = best;
|
||||
walkTargetX = ClampToPlatform(best);
|
||||
nextClimbAllowedTime = Time.unscaledTime + climbCooldown;
|
||||
|
||||
State = Mathf.Abs(walkTargetX - screenPos.x) > 2f ? ClimbState.Walking : ClimbState.Climbing;
|
||||
if (State == ClimbState.Climbing) BeginClimbArc();
|
||||
}
|
||||
|
||||
// ---------------- 반영 ----------------
|
||||
|
||||
void Apply()
|
||||
{
|
||||
// 화면 좌우로 잘리지 않게 제한한다. 드래그와 같은 규칙.
|
||||
// Y 는 제한하지 않는다. 낙하와 착지 높이를 물리가 정해야 하기 때문이다.
|
||||
// 대신 WouldBeClipped 로 위쪽 잘림을 사전에 막는다.
|
||||
if (hasExtents)
|
||||
{
|
||||
screenPos.x = CharacterScreenBounds.Clamp(screenPos, charOffMin, charOffMax).x;
|
||||
}
|
||||
|
||||
character.position = viewCamera.ScreenToWorldPoint(
|
||||
new Vector3(screenPos.x, screenPos.y, depth));
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/WindowClimber.cs.meta
Normal file
2
Assets/02_Scripts/Character/WindowClimber.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a3b279de6756ff64e81895090bb5c3cd
|
||||
Reference in New Issue
Block a user