using UnityEngine; /// 캐릭터의 이동 상태. 나중에 애니메이션 클립을 이 상태에 물리면 된다. public enum ClimbState { Falling, // 낙하 중 Standing, // 발판 위에 서 있음 Walking, // 발판 위를 걷는 중 (목표 X 로 이동) Jumping, // 다른 발판으로 뛰어오르는 중 } /// /// 캐릭터를 창 위에 올려놓고, 중력·착지·탑승·자동 올라타기를 처리한다. /// /// 모든 계산은 화면 좌표(픽셀)로 한다. 발판 스캐너가 화면 좌표를 주고, 화면 제한도 /// 화면 좌표로 하므로 일관되게 맞춘다. 마지막에 한 번만 월드 좌표로 변환한다. /// /// 자동 올라타기의 판단 기준은 "새 창이 생겼는가"가 아니라 "발판이 새로 생겼거나 /// 위치가 바뀌었는가"다. 새 창은 대개 최대화 상태로 열려 손이 닿지 않지만, 그 창을 /// 나중에 줄이거나 옮기면 그때 올라탈 수 있게 되기 때문이다. /// public class WindowClimber : MonoBehaviour { [Header("참조")] [SerializeField] DesktopPlatformScanner scanner; [SerializeField] CharacterDragger dragger; [SerializeField] VrmCharacterLoader loader; [SerializeField] Camera viewCamera; [Header("물리 (화면 픽셀 기준)")] // 2600 정도가 실제 중력에 해당한다(캐릭터 키를 화면 400px 로 보면 1m≈250px, // 9.8m/s² ≈ 2450px/s²). 그런데 실제 중력으로 떨어지면 400px 낙하가 0.56초라 // 낙하 애니메이션이 눈에 들어오지 않는다. 데스크톱 펫은 일부러 둥실하게 둔다. [Tooltip("초당 낙하 가속도. 낮출수록 천천히 떨어져 낙하 동작이 잘 보인다. " + "2600 은 실제 중력에 가까워 너무 빠르다")] [SerializeField] float gravity = 1200f; [Tooltip("최대 낙하 속도. 긴 낙하의 체감 속도를 좌우한다. " + "이 값이 크면 화면을 가로지르는 낙하가 순식간에 끝난다")] [SerializeField] float maxFallSpeed = 900f; [Tooltip("걷는 속도 (픽셀/초)")] [SerializeField] float walkSpeed = 160f; [Header("자동 올라타기")] [Tooltip("스스로 창을 찾아 올라간다. 런타임에도 끌 수 있다 " + "(트레이 아이콘 우클릭 메뉴, 또는 에디터에서는 이 체크박스)")] [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; [Tooltip("점프할 때 목표보다 얼마나 더 높이 떠오르는지(픽셀). 0 이면 직선으로 간다")] [SerializeField] float jumpArcHeight = 70f; [Tooltip("가장 짧은 점프에 걸리는 시간(초). 너무 작으면 순간이동처럼 보인다")] [SerializeField] float minJumpDuration = 0.35f; [Tooltip("가장 먼 점프에 걸리는 시간(초). 너무 크면 화면을 오래 가린다")] [SerializeField] float maxJumpDuration = 0.9f; [Header("발판 여백")] [Tooltip("발판 좌우 끝에서 최소한 이만큼 안쪽에 선다. " + "캐릭터 반폭보다 작으면 반폭이 우선 적용된다")] [SerializeField] float platformEdgeMargin = 70f; [Tooltip("화면 위쪽 여백. 캐릭터 머리가 이보다 위로 가면 발판에서 뛰어내린다")] [SerializeField] float ceilingMargin = 8f; [Header("방향 전환")] [Tooltip("걷고 뛰는 방향으로 몸을 돌린다. 끄면 늘 정면을 본 채 옆으로 미끄러진다")] [SerializeField] bool turnToWalkDirection = true; [Tooltip("얼마나 돌릴지(도). 90 이면 완전히 옆모습이라 얼굴이 안 보인다. " + "70 전후가 걷는 티는 나면서 얼굴도 살짝 보인다")] [Range(0f, 90f)] [SerializeField] float walkTurnAngle = 70f; [Tooltip("도는 속도(도/초). 낮추면 천천히 돌아선다")] [SerializeField] float turnSpeed = 540f; [Tooltip("도는 쪽이 반대라면 켠다. 모델의 정면 축에 따라 달라진다")] [SerializeField] bool invertTurnDirection = false; [Header("기타")] [Tooltip("착지 판정 여유. 발판을 살짝 지나쳐도 잡아준다")] [SerializeField] float landTolerance = 24f; ClimbState state = ClimbState.Falling; /// /// 지금 이동 상태. 값이 실제로 바뀔 때만 가 불린다. /// /// 대입하는 자리가 여러 군데라 각각에서 이벤트를 쏘면 빠뜨리기 쉽다. /// 프로퍼티 한 곳에 모아두면 새 전환을 추가해도 자동으로 알려진다. /// public ClimbState State { get => state; private set { if (state == value) return; state = value; StateChanged?.Invoke(value); } } /// 이동 상태가 바뀔 때. 애니메이션 전환이 이걸 듣는다. public event System.Action StateChanged; const string AutoClimbPrefKey = "MyCharacterAgent.AutoClimb"; /// /// 스스로 창을 찾아 올라갈지. 런타임에 바꿀 수 있고 다음 실행에도 유지된다. /// /// 저장은 PlayerPrefs 로 한다. 모델 경로와 같은 방식이고, 채팅 설정 파일에 /// 캐릭터 동작을 섞지 않으려는 이유도 있다. /// /// 끄더라도 진행 중인 이동은 끝까지 간다. 공중에서 갑자기 멈추면 더 어색하다. /// public bool AutoClimb { get => autoClimb; set { if (autoClimb == value) return; autoClimb = value; PlayerPrefs.SetInt(AutoClimbPrefKey, value ? 1 : 0); PlayerPrefs.Save(); AutoClimbChanged?.Invoke(value); } } /// 자동 올라타기 설정이 바뀔 때. 트레이 메뉴 표시 갱신 등에 쓴다. public event System.Action AutoClimbChanged; Transform character; float depth; // 카메라로부터의 깊이. 드래그와 같은 평면을 유지한다. Vector2 screenPos; // 캐릭터 발밑의 화면 좌표 float verticalVelocity; // 탑승 정보 System.IntPtr standingHwnd; float offsetFromWindowLeft; bool hasStanding; DesktopPlatform standingPlatform; /// /// 지금 서 있는 발판. 서 있지 않으면 null. /// 발판 아래를 가리는 처리(PlatformOccluder)가 이 사각형을 쓴다. /// public DesktopPlatform? CurrentPlatform => hasStanding ? standingPlatform : (DesktopPlatform?)null; // 목표 float walkTargetX; DesktopPlatform climbTarget; Vector2 climbStart; float climbProgress; float climbDuration; float nextClimbAllowedTime; // 캐릭터의 화면상 범위(원점 기준 오프셋). 깊이가 고정이라 한 번만 재면 된다. Vector2 charOffMin, charOffMax; bool hasExtents; // 로더가 정한 "정면" 자세. 걷는 방향 회전은 여기에 더해서 얹는다. Quaternion baseRotation = Quaternion.identity; float currentTurn; void Awake() { if (scanner == null) scanner = FindFirstObjectByType(); if (dragger == null) dragger = FindFirstObjectByType(); if (loader == null) loader = FindFirstObjectByType(); if (viewCamera == null) viewCamera = Camera.main; // 저장된 값이 있으면 그걸 쓰고, 없으면 인스펙터 기본값을 그대로 둔다. if (PlayerPrefs.HasKey(AutoClimbPrefKey)) { autoClimb = PlayerPrefs.GetInt(AutoClimbPrefKey, 1) != 0; } 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); // 로더가 걸어둔 회전이 "정면"이다. 여기서 잡아둬야 걷기 회전을 얹었다 뺄 수 있다. baseRotation = character.localRotation; currentTurn = 0f; 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.Jumping: TickJumping(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; standingPlatform = p; 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.Clamp(distance / Mathf.Max(1f, climbSpeed), minJumpDuration, maxJumpDuration); State = ClimbState.Jumping; } /// /// 발판 사이를 뛰어오른다. /// /// 원래는 대각선으로 미끄러지듯 올라갔는데, 그러면 화면을 오래 가리고 /// "뒤로 도는지 옆으로 도는지" 애매해서 어떤 클라이밍 클립과도 맞지 않았다. /// 포물선 도약은 짧고, 방향이 분명하고, 점프 클립 하나로 어떤 거리든 커버된다. /// (Desktop Mate 도 Shimeji 도 "올라가는 과정"을 길게 보여주지 않는다) /// void TickJumping(float dt) { climbProgress += dt / climbDuration; float t = Mathf.Clamp01(climbProgress); float targetX = ClampToPlatform(climbTarget); screenPos.x = Mathf.Lerp(climbStart.x, targetX, 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); } /// 캐릭터 반폭과 설정 여백 중 큰 쪽. 몸이 발판 밖으로 걸치지 않게 한다. float EdgeMargin() { float halfWidth = hasExtents ? (charOffMax.x - charOffMin.x) * 0.5f : 0f; return Mathf.Max(platformEdgeMargin, halfWidth); } /// /// 이 높이의 발판에 서면 머리가 화면 위로 잘리는지. /// 창을 위로 끌어올리면 발판도 따라 올라가는데, 그대로 두면 캐릭터가 /// 화면을 뚫고 나간다. 그전에 뛰어내리게 하기 위한 판정. /// bool WouldBeClipped(float platformY) { if (!hasExtents) return false; return platformY + charOffMax.y > Screen.height - ceilingMargin; } /// 발판 위에서 설 수 있는 X 범위로 제한한다. 너무 좁으면 가운데로. 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(); } } /// 서 있는 창이 움직이면 캐릭터도 따라 움직인다. 자리가 사라지면 떨어진다. 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; // 창이 움직였으면 발판 사각형도 갱신한다. 가림 처리가 이 값을 본다. standingPlatform = m; // 창을 위로 계속 끌어올리면 캐릭터가 화면 밖으로 밀려난다. 그전에 뛰어내린다. if (WouldBeClipped(m.Y)) { EnterFalling(); return; } screenPos.x = m.WindowLeft + offsetFromWindowLeft; screenPos.y = m.Y; } /// /// 올라탈 곳을 고른다. 규칙은 두 가지다. /// - 이미 창 위에 서 있으면 옮기지 않는다. 창을 옮길 때마다 캐릭터가 /// 따라다니면 정신없고, 사용자가 올려둔 자리를 존중하는 편이 낫다. /// - 바닥에 있을 때는 "지금 활성화된 창"에만 올라간다. 창이 여러 개 열려 /// 있을 때 엉뚱한 배경 창으로 올라가는 것을 막는다. /// 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.Jumping; if (State == ClimbState.Jumping) 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)); ApplyFacing(); } /// /// 걷거나 뛰는 방향으로 몸을 돌린다. /// /// 정면을 본 채 옆으로 미끄러지면 게처럼 보인다. 다만 완전히 옆(90도)으로 돌리면 /// 얼굴이 안 보여서 데스크톱 펫으로서는 손해다. 기본값을 70도로 둬서 걷는 티는 나되 /// 얼굴은 살짝 이쪽을 향하게 한다. 머리는 HeadLookAt 이 커서 쪽으로 되돌리므로 /// 몸만 돌아가고 시선은 사용자를 따라오는 그림이 된다. /// /// 회전은 캐릭터 루트에 건다. 컨트롤 리그는 이미 만들어진 뒤이므로(로더 참고) /// 자식으로서 통째로 같이 돈다. /// void ApplyFacing() { if (!turnToWalkDirection) return; float dir = 0f; if (State == ClimbState.Walking) { dir = Mathf.Sign(walkTargetX - screenPos.x); } else if (State == ClimbState.Jumping) { dir = Mathf.Sign(ClampToPlatform(climbTarget) - climbStart.x); } // 서 있거나 떨어지는 중이면 정면으로 되돌아온다. float desired = Mathf.Abs(dir) > 0.01f ? walkTurnAngle * dir * (invertTurnDirection ? 1f : -1f) : 0f; currentTurn = Mathf.MoveTowards(currentTurn, desired, turnSpeed * Time.deltaTime); character.localRotation = baseRotation * Quaternion.Euler(0f, currentTurn, 0f); } }