이전 글에서는 회전 플랫폼 위에서 플레이어가 자연스럽게 이동하도록 만들었다.
플레이어는 이제 회전 플랫폼 위에 서 있어도 발판에 잘 실려 간다.
하지만 다음 문제가 생겼다.
회전 플랫폼 위에 상자를 올려두고, 플레이어가 그 상자를 잡아 밀고 당기면 상자가 미세하게 대각선으로 움직였다.
원래 의도는 단순했다.
상자를 앞에서 잡으면
→ 앞뒤로만 밀고 당긴다.
상자를 옆에서 잡으면
→ 좌우로만 밀고 당긴다.
그런데 회전 플랫폼 위에서는 이 규칙이 조금씩 어긋났다.
처음에는 카메라 문제라고 생각했다.
플랫폼이 회전하고 있으니 카메라 기준 이동 방향이 흔들리는 것처럼 느껴졌기 때문이다.
하지만 실제 원인은 카메라가 아니었다.
문제는 좌표계였다.
일반 바닥에서는 잘 동작하던 구조
먼저 일반 바닥에서 상자를 잡는 구조부터 정리한다.
플레이어가 상자를 잡으면, 상자의 앞/뒤/좌/우 중 어느 면을 잡았는지 계산한다.
그리고 그 면을 기준으로 이동축을 하나 고정한다.
// 상자를 잡은 면 기준의 이동 축.
// 대각선으로 붙었을 때 상자를 이상하게 끌지 않도록 네 방향 중 하나만 허용한다.
private Vector3 grabbedMoveAxis;
예를 들어 상자의 앞면을 잡았다면, 상자는 앞뒤 방향으로만 움직인다.
입력 방향은 카메라 기준으로 계산하지만, 최종 이동은 grabbedMoveAxis 위로만 투영한다.
Vector3 moveDirection = playerMovement.GetCameraRelativeMoveDirection(playerMovement.MoveInput);
Vector3 grabbedVelocity = Vector3.zero;
if (moveDirection.sqrMagnitude > MoveInputThreshold && grabbedMoveAxis.sqrMagnitude > MoveInputThreshold)
{
float axisInput = Vector3.Dot(moveDirection, grabbedMoveAxis);
if (Mathf.Abs(axisInput) > MoveInputThreshold)
{
grabbedVelocity = grabbedMoveAxis * axisInput * grabbedMoveSpeed;
}
}
여기서 Vector3.Dot은 두 방향이 얼마나 같은 방향을 보고 있는지 확인하는 데 사용했다.
Dot 값이 1에 가까움
→ 입력 방향과 잡기 축이 거의 같은 방향
Dot 값이 -1에 가까움
→ 입력 방향과 잡기 축이 거의 반대 방향
Dot 값이 0에 가까움
→ 입력 방향과 잡기 축이 수직
즉, 플레이어가 상자를 미는 축과 같은 방향으로 입력하면 상자가 움직이고, 옆 방향 입력은 거의 무시된다.
이 방식 덕분에 일반 바닥에서는 상자가 대각선으로 새지 않았다.
잡은 순간의 간격도 저장했다
상자를 오래 밀고 당기면 플레이어와 상자의 간격이 조금씩 벌어지거나, 반대로 겹칠 수 있다.
그래서 잡은 순간의 간격도 저장해두었다.
// 잡은 순간의 플레이어-상자 간격.
// 오래 밀고 당길 때 서로 겹치거나 멀어지지 않게 보정하는 기준이 된다.
private Vector3 grabbedOffsetFromPlayer;
잡기 시작 시점에 상자 위치에서 플레이어 위치를 뺀 값을 저장한다.
private void StoreGrabbedOffset()
{
if (grabbedObject == null || playerMovement == null)
{
grabbedOffsetFromPlayer = Vector3.zero;
return;
}
grabbedOffsetFromPlayer = grabbedObject.RigidbodyPosition - playerMovement.RigidbodyPosition;
grabbedOffsetFromPlayer.y = 0.0f;
}
그리고 이후에는 이 간격을 유지하기 위한 보정 속도를 만든다.
private Vector3 GetHoldOffsetCorrectionVelocity()
{
if (grabbedObject == null || playerMovement == null)
{
return Vector3.zero;
}
Vector3 desiredObjectPosition = playerMovement.RigidbodyPosition + grabbedOffsetFromPlayer;
Vector3 offsetError = desiredObjectPosition - grabbedObject.RigidbodyPosition;
offsetError.y = 0.0f;
// 간격 보정도 잡기 축 위로만 제한한다.
Vector3 axisLimitedError = ProjectOntoGrabbedMoveAxis(offsetError);
return Vector3.ClampMagnitude(
axisLimitedError * HoldOffsetCorrectionStrength,
MaxHoldOffsetCorrectionSpeed);
}
이 보정값도 잡기 축 위로만 투영한다.
private Vector3 ProjectOntoGrabbedMoveAxis(Vector3 vector)
{
vector.y = 0.0f;
if (grabbedMoveAxis.sqrMagnitude <= MoveInputThreshold)
{
return Vector3.zero;
}
return grabbedMoveAxis * Vector3.Dot(vector, grabbedMoveAxis);
}
즉, 간격을 맞추는 보정도 상자를 잡은 축으로만 들어간다.
이렇게 해야 보정 때문에 상자가 대각선으로 밀리는 일을 줄일 수 있다.
여기까지는 일반 바닥에서 괜찮았다.
하지만 회전 플랫폼 위에서는 이 기준들이 문제가 됐다.
첫 번째 문제: 상자가 발판 이동량을 따라가지 않았다
플레이어는 이미 MovingPlatformSurface를 통해 회전 플랫폼의 이동량을 따라가고 있었다.
하지만 상자는 처음에는 물리 접촉에만 맡겨져 있었다.
플레이어
→ MovingPlatformSurface의 이동량을 직접 따라감
상자
→ 물리 접촉에만 맡겨짐
같은 발판 위에 있어도 플레이어와 상자가 서로 다른 기준으로 움직이고 있었던 것이다.
그래서 상자도 현재 밟고 있는 MovingPlatformSurface를 기억하게 했다.
[SerializeField] private MovingPlatformSurface currentMovingPlatform;
public MovingPlatformSurface CurrentMovingPlatform => currentMovingPlatform;
상자가 어떤 발판 위에 있는지는 충돌 정보를 통해 확인한다.
private void OnCollisionStay(Collision collision)
{
if (!TryGetGroundContact(collision))
{
return;
}
MovingPlatformSurface platform = collision.collider.GetComponentInParent<MovingPlatformSurface>();
if (platform != null)
{
currentMovingPlatform = platform;
}
}
여기서 바로 저장하지 않고 TryGetGroundContact()를 거치는 이유는, 벽이나 옆면 충돌을 바닥으로 착각하지 않기 위해서다.
private static bool TryGetGroundContact(Collision collision)
{
for (int i = 0; i < collision.contactCount; i++)
{
ContactPoint contact = collision.GetContact(i);
if (contact.normal.y >= GroundContactNormalY)
{
return true;
}
}
return false;
}
contact.normal.y가 충분히 크다는 것은 접촉면이 어느 정도 위쪽을 향하고 있다는 뜻이다.
즉, 상자가 무언가 위에 올라서 있다고 볼 수 있다.
두 번째 문제: 그냥 부딪혀도 상자가 밀렸다
상자가 발판 이동량을 따라가게 만들면서 또 다른 문제가 생겼다.
처음에는 상자를 발판 위에 태우기 위해 MovePosition()만 사용하면 된다고 생각했다.
하지만 MovePosition()으로 X/Z 이동을 하려면 상자의 X/Z 위치 잠금을 풀어야 했다.
문제는 그 다음이었다.
X/Z 위치 잠금을 풀어두면, 플레이어가 상자를 잡지 않고 그냥 몸으로 부딪혔을 때도 상자가 밀려버린다.
원래 의도는 이게 아니었다.
그냥 부딪히면
→ 상자는 벽처럼 버틴다.
우클릭으로 잡고 밀거나 당기면
→ 상자가 움직인다.
그래서 상자의 이동을 두 가지로 나눴다.
잡고 있을 때
→ X/Z 위치 잠금을 푼다.
→ MovePosition()으로 움직인다.
잡고 있지 않을 때
→ X/Z 위치 잠금을 유지한다.
→ 대신 발판 이동량만 Rigidbody.position으로 직접 보정한다.
현재 FixedUpdate()는 이렇게 되어 있다.
private void FixedUpdate()
{
ApplyGrabStateConstraints(isGrabbed);
ApplyMovingPlatformMotion();
}
여기서 중요한 것은 ApplyGrabStateConstraints(isGrabbed)다.
이제 상자는 잡고 있을 때만 X/Z 위치 잠금이 풀린다.
private void ApplyGrabStateConstraints(bool canMoveHorizontally)
{
if (targetRigidbody == null)
{
return;
}
RigidbodyConstraints constraints = baseConstraints;
if (canMoveHorizontally)
{
constraints &= ~RigidbodyConstraints.FreezePositionX;
constraints &= ~RigidbodyConstraints.FreezePositionZ;
}
else
{
constraints |= RigidbodyConstraints.FreezePositionX | RigidbodyConstraints.FreezePositionZ;
}
targetRigidbody.constraints = constraints;
}
그리고 실제 발판 이동량 적용은 이렇게 처리했다.
private void ApplyMovingPlatformMotion()
{
if (targetRigidbody == null || currentMovingPlatform == null)
{
return;
}
Vector3 platformDelta = currentMovingPlatform.GetDeltaPositionAt(targetRigidbody.position);
if (platformDelta.sqrMagnitude <= PlatformMoveThreshold)
{
return;
}
Vector3 nextPosition = targetRigidbody.position + platformDelta;
if (isGrabbed)
{
targetRigidbody.MovePosition(nextPosition);
return;
}
// 잡지 않은 상자는 X/Z 위치 잠금을 유지한다.
// 대신 발판에 실려 가는 위치 보정만 직접 반영해서 몸으로 밀리는 힘은 받지 않게 한다.
targetRigidbody.position = nextPosition;
}
이렇게 하니 역할이 분명해졌다.
플랫폼이 상자를 데려가는 이동
→ 항상 허용
플레이어가 잡아서 미는 이동
→ 잡고 있을 때만 허용
플레이어가 몸으로 부딪혀서 미는 이동
→ 허용하지 않음
상자는 회전 플랫폼 위에 있으면 발판을 따라가지만, 플레이어가 몸으로 밀 수는 없다.
[스크린샷 추천 1]
회전 플랫폼 위에 상자를 올려둔 장면.
상자가 발판과 함께 움직이지만, 플레이어가 그냥 부딪혀도 밀리지 않는 상황을 보여주면 좋다.
세 번째 문제: 잡기 기준이 월드 방향에 고정되어 있었다
상자도 발판 이동량을 따라가게 만들었지만, 여전히 미세하게 대각선으로 밀리는 느낌이 남았다.
그 이유는 grabbedMoveAxis와 grabbedOffsetFromPlayer였다.
이 두 값은 잡는 순간 저장된다.
grabbedMoveAxis
→ 상자를 어느 축으로 밀고 당길지
grabbedOffsetFromPlayer
→ 플레이어와 상자의 잡은 순간 간격
일반 바닥에서는 이 값들이 월드 방향에 고정되어 있어도 괜찮다.
바닥이 움직이지 않기 때문이다.
하지만 회전 플랫폼 위에서는 발판 자체가 계속 회전한다.
플레이어와 상자의 위치는 발판을 따라 돌고 있는데, 잡기 기준축과 간격은 처음 잡은 월드 방향 그대로 남아 있었다.
플레이어 위치
→ 발판을 따라 회전
상자 위치
→ 발판을 따라 회전
잡기 축
→ 처음 잡은 월드 방향에 고정
잡은 간격
→ 처음 잡은 월드 방향에 고정
이러면 발판이 돌수록 기준이 조금씩 어긋난다.
잡기 보정은 처음 잡았던 월드 간격으로 돌아가라고 말하고, 발판은 지금은 이만큼 회전했다고 말한다.
그 결과 보정 속도에 옆 방향 성분이 섞이면서 상자가 대각선으로 움직이는 것처럼 보였다.
그래서 잡기 기준값도 발판 회전만큼 같이 돌리기로 했다.
private void ApplyGrabbedPlatformFrameMotion()
{
if (grabbedObject == null)
{
return;
}
MovingPlatformSurface platform = grabbedObject.CurrentMovingPlatform;
if (platform == null)
{
return;
}
float platformYawDelta = platform.GetDeltaYaw();
if (Mathf.Approximately(platformYawDelta, 0.0f))
{
return;
}
Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
grabbedOffsetFromPlayer = yawDelta * grabbedOffsetFromPlayer;
grabbedOffsetFromPlayer.y = 0.0f;
grabbedMoveAxis = yawDelta * grabbedMoveAxis;
grabbedMoveAxis.y = 0.0f;
if (grabbedMoveAxis.sqrMagnitude > MoveInputThreshold)
{
grabbedMoveAxis.Normalize();
}
}
이제 발판이 회전하면 잡기 축도 같이 회전한다.
발판이 1도 회전
→ 상자 위치도 1도 회전
→ 플레이어 위치도 1도 회전
→ 잡기 축도 1도 회전
→ 잡은 간격도 1도 회전
이렇게 해야 상자, 플레이어, 잡기 기준이 모두 같은 좌표계에 남아 있게 된다.
왜 이 계산이 맞는가
플랫폼의 중심을 C, 플레이어 위치를 P, 상자 위치를 B, 플랫폼 회전을 R이라고 생각해보자.
플랫폼이 회전하면 플레이어와 상자는 이렇게 움직인다.
새 플레이어 위치 = C + R * (P - C)
새 상자 위치 = C + R * (B - C)
그럼 새 간격은 이렇게 된다.
새 간격
= 새 상자 위치 - 새 플레이어 위치
= [C + R * (B - C)] - [C + R * (P - C)]
= R * (B - P)
= R * 기존 간격
즉, 플레이어와 상자 사이의 간격도 플랫폼 회전만큼 같이 돌아야 한다.
그래서 grabbedOffsetFromPlayer에 yawDelta를 곱해준다.
grabbedOffsetFromPlayer = yawDelta * grabbedOffsetFromPlayer;
잡기 축도 마찬가지다.
발판이 회전하는데 잡기 축만 월드 방향에 고정되어 있으면, 시간이 지날수록 축이 틀어진다.
그래서 grabbedMoveAxis도 같은 yaw만큼 돌려준다.
grabbedMoveAxis = yawDelta * grabbedMoveAxis;
네 번째 문제: 잡기 중 회전 잠금이 플랫폼 회전까지 막았다
상자를 잡고 있을 때는 플레이어가 입력 방향으로 몸을 돌면 안 된다.
상자를 잡은 자세가 풀려버리는 것처럼 보이기 때문이다.
그래서 잡기 중에는 회전 잠금을 사용한다.
playerMovement?.SetRotationLocked(true);
그리고 PlayerMovement에서는 이동 방향을 바라보는 회전을 이렇게 막고 있다.
if (!isRotationLocked)
{
RotateTowardsMoveDirection(GetLookDirection(moveDirection));
}
하지만 여기서 주의해야 할 점이 있다.
잡기 중에 막아야 하는 회전은 입력 때문에 몸이 도는 회전이다.
회전 플랫폼 위에 올라타서 발판과 함께 도는 회전은 막으면 안 된다.
막아야 하는 회전
→ WASD 입력 방향을 바라보는 회전
허용해야 하는 회전
→ 플랫폼 위에 실려 가는 회전
그래서 플랫폼 회전은 isRotationLocked와 분리했다.
if (!Mathf.Approximately(platformYawDelta, 0.0f))
{
Quaternion yawDelta = Quaternion.AngleAxis(platformYawDelta, Vector3.up);
rb.MoveRotation(yawDelta * rb.rotation);
}
반면 입력 방향 회전은 여전히 잠금 조건을 따른다.
if (!isRotationLocked)
{
RotateTowardsMoveDirection(GetLookDirection(moveDirection));
}
이렇게 분리하니 상자를 잡아도 플랫폼 위에 실려 가는 회전은 유지되고, 입력 방향으로 몸이 돌아가는 것은 막을 수 있었다.
왜 카메라 문제가 아니었나
처음에는 카메라가 문제처럼 느껴졌다.
회전 플랫폼 위에서 상자가 대각선으로 밀리니, 카메라 기준 이동 방향이 흔들리는 것처럼 보였기 때문이다.
하지만 실제로 카메라를 따라 돌리는 방식은 해결책이 아니었다.
문제는 플레이어 입력 방향이 아니라, 잡기 시스템 내부에 저장된 기준값이었다.
카메라 방향
→ 입력 방향 계산에 사용됨
grabbedMoveAxis
→ 상자를 실제로 움직일 축
grabbedOffsetFromPlayer
→ 플레이어와 상자의 유지해야 할 간격
이 중에서 플랫폼 회전에 맞춰 같이 돌아야 했던 것은 카메라가 아니라 grabbedMoveAxis와 grabbedOffsetFromPlayer였다.
카메라까지 플랫폼과 같이 돌리면 오히려 Q/E 회전 기준과 WASD 입력 기준이 흔들릴 수 있었다.
그래서 카메라는 기존처럼 플레이어 입력으로만 회전하도록 두고, 잡기 기준값만 발판 좌표계에 맞췄다.
전체 흐름
최종적으로 회전 플랫폼 위에서 상자를 잡고 밀 때는 이런 흐름이 된다.
1. 회전 플랫폼이 돈다.
2. MovingPlatformSurface가 플랫폼 이동량과 yaw 회전량을 기록한다.
3. 플레이어는 플랫폼 이동량을 따라간다.
4. 상자도 플랫폼 이동량을 따라간다.
5. 잡고 있는 동안 grabbedMoveAxis와 grabbedOffsetFromPlayer도 플랫폼 yaw만큼 같이 돈다.
6. 플레이어의 일반 이동 회전은 막는다.
7. 하지만 플랫폼에 실려 가는 회전은 허용한다.
8. 잡고 있지 않은 상자는 X/Z 위치 잠금을 유지해서 몸으로 밀리지 않게 한다.
이제 상자, 플레이어, 잡기 축이 모두 같은 발판 좌표계 안에서 움직인다.
그래서 회전 플랫폼 위에서도 상자를 앞뒤 또는 좌우 축으로 밀고 당길 수 있게 되었다.
남은 엣지 케이스
현재 구조는 지금 프로토타입에서는 잘 동작한다.
하지만 코드 레벨에서 생각해볼 엣지 케이스는 몇 가지 있다.
플레이어와 상자가 서로 다른 발판 위에 있을 때
현재 ApplyGrabbedPlatformFrameMotion()은 플레이어의 발판이 아니라 상자가 참조하는 발판을 기준으로 잡기 축과 간격을 돌린다.
MovingPlatformSurface platform = grabbedObject.CurrentMovingPlatform;
정상적인 경우에는 플레이어와 상자가 같은 회전 플랫폼 위에 있으므로 문제가 없다.
하지만 이런 상황이 생길 수 있다.
플레이어
→ 회전 플랫폼 위에 있음
상자
→ 정적인 바닥 위에 있음
이 경우 상자의 CurrentMovingPlatform은 null일 수 있다.
그러면 잡기 축과 간격은 회전하지 않는데, 플레이어는 발판을 따라 계속 움직인다.
이론적으로는 둘 사이의 기준이 조금씩 틀어질 수 있다.
지금은 레벨 디자인으로 이런 상황을 피하면 된다.
나중에 실제 문제가 되면, 플레이어와 상자가 같은 MovingPlatformSurface 위에 있을 때만 잡기를 허용하거나, 잡기 시작 시점의 기준 플랫폼을 따로 저장하는 방식으로 고칠 수 있을 것 같다.
FixedUpdate 실행 순서 문제
현재 회전 플랫폼 관련 처리는 여러 컴포넌트가 나눠서 처리한다.
AutoRotator
→ 플랫폼을 회전시키고 이동량을 기록한다.
PlayerMovement
→ 플레이어를 발판 이동량만큼 이동시킨다.
PushPullObject
→ 상자를 발판 이동량만큼 이동시킨다.
PlayerPushPullInteractor
→ 잡기 축과 간격을 회전시키고, 플레이어/상자 속도를 적용한다.
대부분의 경우 문제 없지만, 같은 FixedUpdate() 안에서 실행 순서가 꼬이면 미세한 떨림이 생길 수 있다.
예를 들어 PlayerPushPullInteractor가 PushPullObject보다 먼저 실행되면, 상자 위치가 발판 이동량을 반영하기 전에 잡기 속도를 계산할 수도 있다.
지금 당장 문제가 보이지는 않지만, 나중에 떨림이 생기면 Script Execution Order를 명시적으로 잡는 것이 좋아 보인다.
예상 순서는 이런 식이다.
AutoRotator
→ 먼저 플랫폼을 움직이고 이동량을 기록한다.
PushPullObject
→ 상자를 발판 이동량만큼 이동시킨다.
PlayerMovement
→ 플레이어를 발판 이동량만큼 이동시킨다.
PlayerPushPullInteractor
→ 마지막에 잡기 축과 속도를 계산한다.
이건 지금 당장 고칠 문제라기보다는, 나중에 상자 밀기 퍼즐이 더 복잡해졌을 때 확인할 부분으로 남겨둔다. (지금은 프로토타입을 찍어 재미검증만 하면되기에..)
정리
이번 문제는 처음에는 카메라 문제처럼 보였다.
하지만 실제 원인은 좌표계였다.
플레이어와 상자는 회전 플랫폼을 따라가고 있었지만,
잡기 축과 잡은 간격은 처음 월드 방향에 고정되어 있었다.
해결은 네 단계로 이루어졌다.
1. 상자도 MovingPlatformSurface의 이동량을 따라가게 한다.
2. 잡기 축과 잡은 간격도 플랫폼 yaw만큼 같이 회전시킨다.
3. 잡기 중에는 입력 방향 회전은 막되, 플랫폼 회전은 허용한다.
4. 잡지 않은 상자는 X/Z 위치 잠금을 유지해 몸으로 밀리지 않게 한다.
이번 작업에서 가장 크게 배운 점은 회전이나 이동이라는 말을 하나로 보면 안 된다는 것이다.
입력 때문에 도는 회전
발판에 실려 가는 회전
둘은 다르다.
상자의 이동도 마찬가지였다.
플랫폼이 데려가는 이동
플레이어가 잡아서 움직이는 이동
플레이어가 몸으로 밀어서 생기는 이동
이 세 가지를 분리해야 했다.
다음 글에서는 회전 발판 위에서 점프했을 때 생긴 문제를 정리할 예정이다.
발판 위에 있을 때는 잘 따라가지만, 점프하는 순간 발판과의 접촉이 끊기면서 또 다른 문제가 생겼다.
그래서 공중에서도 잠깐 발판 기준 이동을 유지하는 처리를 추가했다.
(관성 점프!)
'Unity - '한 우산 아래'' 카테고리의 다른 글
| 양팔저울 퍼즐 만들기 - 무게 조건과 시각 연출 분리하기 (0) | 2026.04.24 |
|---|---|
| 회전 발판 위에서 점프가 이상했던 이유 (0) | 2026.04.17 |
| 회전 플랫폼 위에서 플레이어를 자연스럽게 움직이기 (7) | 2026.04.16 |
| 회전 플랫폼 만들기 - AutoRotator와 MovingPlatformSurface (1) | 2026.04.16 |
| 물 저장량으로 활성화되는 오브젝트 만들기 (0) | 2026.04.15 |