Unity - '한 우산 아래'

회전 플랫폼 위에서 플레이어를 자연스럽게 움직이기

망고와플 2026. 4. 16. 23:16

이전 글에서는 회전 플랫폼을 만들기 위해 AutoRotator와 MovingPlatformSurface를 만들었다.

AutoRotator는 발판을 실제로 회전시키고, MovingPlatformSurface는 발판이 한 물리 프레임 동안 얼마나 움직였는지 기록한다.

이번에는 그 값을 플레이어에게 적용해보려고 한다.

목표는 단순하다.

플레이어가 회전 플랫폼 위에 서 있으면
발판이 움직이는 만큼 플레이어도 같이 움직인다.

하지만 막상 해보면 단순히 위치만 따라가게 하는 것으로는 부족했다.

회전 플랫폼 위에 서 있으면 플레이어의 위치뿐 아니라, 바라보는 방향도 함께 생각해야 했다.

회전 플랫폼 위에서 생긴 문제

회전하지 않는 일반 이동 발판이라면 위치만 따라가도 어느 정도 자연스럽게 보인다.

하지만 회전 플랫폼은 다르다.

플랫폼 중심을 기준으로 플레이어의 위치가 원을 그리며 이동해야 한다.

예를 들어 플레이어가 원형 발판 가장자리에 서 있다고 하자.

플랫폼이 회전하면 플레이어도 그 원 위의 위치를 따라 이동해야 한다.

플랫폼만 회전함
→ 플레이어는 제자리에 남음
→ 발판이 발 아래에서 미끄러지는 느낌이 남

그래서 이전 글에서 만든 GetDeltaPositionAt()을 사용했다.

Vector3 platformDelta = activePlatform.GetDeltaPositionAt(rb.position);

이 함수는 특정 월드 위치가 플랫폼 회전에 의해 이번 프레임에 얼마나 이동해야 하는지 알려준다.

플레이어는 이 값을 자신의 위치에 더하면 된다.

rb.MovePosition(rb.position + platformDelta);

플레이어가 밟고 있는 발판 기억하기

먼저 플레이어가 현재 어떤 움직이는 발판 위에 있는지 알아야 한다.

그래서 PlayerMovement에 현재 밟고 있는 MovingPlatformSurface를 저장했다.

// 현재 밟고 있는 움직이는 표면. null이면 일반 바닥으로 본다.
private MovingPlatformSurface currentMovingPlatform;

발판 위에 올라갔는지는 충돌 정보를 통해 확인한다.

private void OnCollisionStay(Collision collision)
{
    if (!TryGetGroundContact(collision))
    {
        return;
    }

    currentMovingPlatform = collision.collider.GetComponentInParent<MovingPlatformSurface>();
}

여기서 중요한 것은 충돌했다는 사실만으로 발판 위에 있다고 보면 안 된다는 점이다.

벽에 옆으로 부딪힌 것도 충돌이고, 발판 아래쪽에 스친 것도 충돌이다.

그래서 바닥 접촉인지 확인한 뒤에만 현재 발판으로 저장했다.

private bool TryGetGroundContact(Collision collision)
{
    for (int i = 0; i < collision.contactCount; i++)
    {
        ContactPoint contact = collision.GetContact(i);

        if (contact.normal.y >= 0.55f)
        {
            return true;
        }
    }

    return false;
}

contact.normal.y가 충분히 크다는 것은, 접촉면이 어느 정도 위쪽을 향하고 있다는 뜻이다.

즉, 플레이어가 무언가 위에 올라서 있다고 볼 수 있다.

반대로 벽에 옆으로 닿은 경우라면 normal의 y값이 작기 때문에 바닥으로 보지 않는다.

움직이는 발판 이동량 적용하기

이제 현재 밟고 있는 발판이 있으면, 그 발판의 이동량을 플레이어에게 적용한다.

이 처리는 PlayerMovement의 움직이는 발판 전용 partial 파일로 분리했다.

// PlayerMovement의 움직이는 발판 처리 파트.
// 회전/이동하는 플랫폼 위에서 플레이어의 위치와 바라보는 방향을 함께 운반한다.
public partial class PlayerMovement
{
    // 현재 밟고 있는 움직이는 표면. null이면 일반 바닥으로 본다.
    private MovingPlatformSurface currentMovingPlatform;

    // 움직이는 발판에서 점프한 뒤, 착지 전까지 같은 발판 좌표계를 따라가기 위한 참조.
    private MovingPlatformSurface airborneMovingPlatform;

    // 움직이는 발판 위에 있거나, 그 발판에서 점프한 직후라면 발판의 이동량만큼 플레이어를 옮긴다.
    void ApplyMovingPlatformMotion()
    {
        MovingPlatformSurface activePlatform = GetActiveMovingPlatform();

        if (activePlatform == null)
        {
            return;
        }

        Vector3 platformDelta = activePlatform.GetDeltaPositionAt(rb.position);
        float platformYawDelta = activePlatform.GetDeltaYaw();

        // 위치 변화가 거의 없으면 MovePosition을 생략해서 불필요한 물리 갱신을 줄인다.
        if (platformDelta.sqrMagnitude > 0.000001f)
        {
            rb.MovePosition(rb.position + platformDelta);
        }

        // 발판이 Y축으로 회전하면 플레이어가 바라보는 방향도 같은 각도만큼 더한다.
        // 잡기 중에는 일반 이동 회전은 막지만, 발판 위에 실려 가는 회전은 계속 허용한다.
        if (!Mathf.Approximately(platformYawDelta, 0.0f))
        {
            Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
            rb.MoveRotation(yawDelta * rb.rotation);
        }
    }
}

핵심은 두 줄이다.

Vector3 platformDelta = activePlatform.GetDeltaPositionAt(rb.position);
float platformYawDelta = activePlatform.GetDeltaYaw();

platformDelta는 플레이어가 이번 프레임에 발판을 따라 이동해야 하는 위치 변화량이다.

platformYawDelta는 발판이 이번 프레임에 Y축으로 얼마나 회전했는지다.

위치는 MovePosition()으로 적용했다.

rb.MovePosition(rb.position + platformDelta);

회전은 MoveRotation()으로 적용했다.

Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
rb.MoveRotation(yawDelta * rb.rotation);

이렇게 하면 플레이어는 회전 플랫폼 위에서 위치뿐 아니라 바라보는 방향도 발판에 실려 움직인다.

왜 방향도 같이 돌렸나

처음에는 위치만 따라가면 충분할 줄 알았다.

하지만 회전 플랫폼 위에서는 위치만 따라가면 어딘가 어색하다.

예를 들어 플레이어가 탑 중앙을 바라보고 서 있다고 하자.

플랫폼이 회전하면 플레이어도 플랫폼 위에 실려 원을 그리며 이동한다.

이때 몸 방향은 그대로인데 위치만 회전하면, 플레이어가 점점 엉뚱한 방향을 보는 것처럼 느껴진다.

위치만 따라감
→ 몸 방향은 월드 기준으로 고정
→ 플랫폼 위에 붙어 있다는 느낌이 약함

그래서 발판의 Y축 회전량도 플레이어 회전에 더했다.

rb.MoveRotation(yawDelta * rb.rotation);

이렇게 하면 플레이어가 플랫폼 위에 얹혀 있는 느낌이 더 자연스럽다.

중요한 점은 X/Z축 기울기는 따라가지 않았다는 것이다.

이번 프로토타입에서 회전 플랫폼은 수평 발판을 기준으로 만들었고, 플레이어가 발판의 기울기에 따라 몸이 쓰러지거나 기울어지는 것은 원하지 않았다.

그래서 플레이어의 몸 방향에는 Y축 회전만 반영했다.

카메라는 왜 같이 돌리지 않았나

처음에는 이런 생각도 했다.

플랫폼이 돌면 카메라도 같이 돌아야 하지 않을까?

플레이어가 회전 플랫폼 위에 있으니 카메라도 같이 돌면, 발판 위에 더 잘 붙어 있는 느낌이 날 것 같았다.

하지만 실제로 해보니 카메라까지 플랫폼 회전에 따라가면 조작감이 이상해졌다.

이 프로젝트의 카메라는 Q/E로 스냅 회전한다.

즉, 플레이어가 직접 시점을 45도씩 돌리는 구조다.

그런데 플랫폼 회전까지 카메라에 더하면, 플레이어가 Q/E를 누르지 않았는데도 카메라 기준 방향이 계속 변한다.

(그리고 솔직하게 말하면 조작이 좀 어색해졌다.)

플랫폼 회전
→ 카메라도 같이 회전
→ WASD 기준이 계속 바뀜
→ 조작 방향이 흔들림

그래서 카메라는 회전 플랫폼을 따라 돌리지 않았다.

카메라는 오직 Q/E 입력으로만 회전한다.

private void ApplyYawRotation()
{
    // 카메라 회전은 Q/E 입력으로 정한 targetYaw만 따라간다.
    // 움직이는 발판의 회전값은 플레이어 몸 방향에만 적용하고 카메라에는 섞지 않는다.
    float currentYaw = transform.eulerAngles.y;

    float smoothYaw = Mathf.SmoothDampAngle(
        currentYaw,
        targetYaw,
        ref rotationVelocity,
        smoothTime
    );

    transform.rotation = Quaternion.Euler(0.0f, smoothYaw, 0.0f);
}

이렇게 정리하니 역할이 분명해졌다.

플랫폼 회전
→ 플레이어 몸 방향에는 반영한다.

카메라 회전
→ Q/E 입력으로만 바꾼다.

카메라까지 같이 돌리지 않으니, 회전 플랫폼 위에서도 WASD 입력 기준이 안정적으로 유지됐다.

일반 이동 회전과 플랫폼 회전 분리하기

플레이어는 평소에 이동 방향을 바라본다.

if (!isRotationLocked)
{
    RotateTowardsMoveDirection(GetLookDirection(moveDirection));
}

여기서 isRotationLocked는 상자를 잡고 있을 때처럼, 플레이어가 입력 방향으로 돌아가면 안 되는 상황에서 사용한다.

처음에는 이 회전 잠금이 플랫폼 회전에도 같이 적용되어 있었다.

//문제의 코드
if (!isRotationLocked && !Mathf.Approximately(platformYawDelta, 0.0f))
{
    Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
    rb.MoveRotation(yawDelta * rb.rotation);
}

하지만 이렇게 하면 문제가 있었다.

상자를 잡고 있을 때는 isRotationLocked가 켜진다.

그러면 이동 방향을 바라보는 회전은 막히지만, 동시에 플랫폼 회전까지 막힌다.

회전 플랫폼 위에서는 자연스럽지 않았다.

상자 잡기 중
-> 입력 방향 회전은 막아야 함
-> 하지만 플랫폼에 실려 가는 회전은 허용해야 함

그래서 플랫폼 회전은 isRotationLocked와 분리했다.

if (!Mathf.Approximately(platformYawDelta, 0.0f))
{
    Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
    rb.MoveRotation(yawDelta * rb.rotation);
}

그리고 입력 방향 회전은 여전히 isRotationLocked의 영향을 받는다.

if (!isRotationLocked)
{
    RotateTowardsMoveDirection(GetLookDirection(moveDirection));
}

이렇게 나누니 의미가 분명해졌다.

일반 이동 회전
→ 플레이어 입력 방향을 바라보는 회전
→ 잡기 중에는 막는다.

플랫폼 회전
→ 발판 위에 실려 가는 회전
→ 잡기 중에도 허용한다.

이 구분은 나중에 상자 밀고 당기기 문제를 해결할 때도 중요했다.

FixedUpdate에서 먼저 처리하기

움직이는 발판 처리는 PlayerMovement.FixedUpdate() 초반에 호출했다.
void FixedUpdate()
{
    if (rb == null || cameraRig == null)
    {
        return;
    }

    ClearPhysicsAngularVelocity();
    ApplyMovingPlatformMotion();

    Vector3 moveDirection = GetCameraRelativeMoveDirection(moveInput);

    ...
}

이동 입력을 처리하기 전에 발판 이동량을 먼저 적용한다.

그래야 플레이어의 기본 이동 로직은 “이미 발판에 실려 이동한 뒤의 위치”를 기준으로 계산된다.

만약 순서가 반대라면, 플레이어 이동과 발판 이동이 서로 어긋나 보일 수 있다.

발판 이동량 적용
→ 그 위에서 플레이어 입력 이동 처리

이 순서가 더 자연스럽다고 판단했다.

또한 회전하는 바닥과 충돌하면서 생긴 물리 각속도는 매 프레임 지워준다.

void ClearPhysicsAngularVelocity()
{
    if (rb == null || rb.isKinematic)
    {
        return;
    }

    rb.angularVelocity = Vector3.zero;
}

플레이어의 몸 방향은 이동 입력과 플랫폼 회전으로 직접 제어한다.

물리 충돌이 만든 각속도를 그대로 두면, 플랫폼 위에서 멈춘 뒤에도 캐릭터가 빙글빙글 도는 일이 생길 수 있었다.

그래서 각속도는 명시적으로 제거했다.

정리

이번 글에서는 이전 글에서 만든 MovingPlatformSurface의 이동량을 플레이어에게 적용했다.

핵심은 위치와 회전을 나눠서 생각하는 것이었다.

GetDeltaPositionAt()
→ 플레이어 위치를 발판 이동량만큼 옮긴다.

GetDeltaYaw()
→ 플레이어 몸 방향을 발판 Y축 회전만큼 돌린다.

그리고 카메라는 플랫폼을 따라 돌리지 않았다.

카메라까지 같이 돌리면 WASD 기준이 계속 바뀌어서 조작감이 불안정해졌기 때문이다.

또 하나 중요한 점은 회전 잠금을 분리해서 생각한 것이다.

입력 방향을 바라보는 회전
→ 잡기 중에는 막는다.

플랫폼 위에 실려 가는 회전
→ 잡기 중에도 허용한다.

처음에는 회전을 잠근다라는 말을 하나로 생각했다.

하지만 실제로는 어떤 회전을 막고, 어떤 회전을 허용해야 하는지를 나눠서 봐야 했다.

이번 작업으로 회전 플랫폼 위에서도 플레이어가 발판에 밀려나지 않고, 몸 방향도 자연스럽게 함께 움직이게 되었다.

다음 글에서는 이 구조가 상자 밀고 당기기와 만나면서 생긴 문제를 정리할 예정이다.

회전 플랫폼 위에서 상자를 잡고 밀었을 때, 상자가 미세하게 대각선으로 밀리는 문제가 있었다. (실제 원인은 잡기 축과 좌표계에 있었다.)