창 올라타기

This commit is contained in:
2026-08-26 11:01:56 +09:00
parent 65024110df
commit cab0d772b1
18 changed files with 1335 additions and 31 deletions

View 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));
}
}