이번에는 플레이어가 손으로 직접 밀고 당길 수 있는 오브젝트를 만들었다.
앞에서 무게 발판을 만들었고, 물을 담으면 무게가 늘어나는 오브젝트도 만들었다.
그렇다면 자연스럽게 다음 퍼즐은 무거운 오브젝트를 옮겨서 발판 위에 올리는 구조가 될 수 있다.(확장!)
처음에는 Rigidbody 상자를 두면 될 거라고 생각했다.
플레이어가 몸으로 부딪히면 상자가 밀리기 때문이다.
하지만 실제로 해보니 원하는 느낌과 달랐다.
그냥 충돌로 밀리는 상자는 너무 우연적이었고, 플레이어가 지금 이 상자를 잡고 있다는 느낌이 부족했다.
그래서 우산이 닫힌 상태에서 우클릭을 누르면 상자를 잡고, WASD 입력에 따라 상자를 밀고 당기는 구조로 만들었다.
[영상 추천]
우산이 닫힌 상태에서 상자를 잡고, 무게 발판 위로 밀어 올리는 장면을 글 맨 위에 넣으면 좋다.
컴포넌트 나누기
이번 기능은 두 컴포넌트로 나눴다.
PlayerPushPullInteractor
- 플레이어에게 붙는다.
- 주변의 잡을 수 있는 오브젝트를 찾는다.
- 우클릭 입력으로 잡기/놓기를 처리한다.
- 플레이어 입력을 상자 이동으로 바꾼다.
PushPullObject
- 움직일 상자에 붙는다.
- Rigidbody를 가진다.
- 잡혔을 때만 움직일 수 있다.
- 놓이면 다시 밀리지 않게 잠긴다.
플레이어는 무엇을 잡을지 판단하고, 상자는 잡혔을 때 어떻게 움직일지 담당하게 했다.
손을 사용할 수 있는 조건
우산을 펼친 상태나 공중 상태에서 상자를 잡을 수 있으면 조작이 복잡해질 것 같았다.
그래서 손 조작은 기본적으로 지상에 있고, 우산이 닫힌 상태에서만 가능하게 했다.
private bool CanUseHands()
{
// 공중에서 상자를 끌어당기는 상황을 막는다.
if (requireGrounded && playerMovement != null && !playerMovement.isGrounded)
{
return false;
}
// 우산 제한을 사용하지 않거나, 우산이 없으면 손 조작을 허용한다.
if (!requireClosedUmbrella || umbrellaController == null || !umbrellaController.HasUmbrella)
{
return true;
}
// 기본 규칙: 우산이 닫힌 상태에서만 잡을 수 있다.
return umbrellaController.CurrentState == PlayerUmbrellaController.UmbrellaState.Closed;
}
이렇게 해두면 우산 조작과 손 조작이 서로 겹치지 않는다.
주변 오브젝트 찾기
잡을 수 있는 상자는 플레이어 주변 범위 안에서 찾는다.
이때 Physics.OverlapSphereNonAlloc을 사용했다.
int hitCount = Physics.OverlapSphereNonAlloc(
origin,
grabRange,
candidateHits,
pushPullMask,
QueryTriggerInteraction.Ignore);
일반 OverlapSphere를 쓰면 호출할 때마다 배열이 새로 만들어질 수 있다.
이 코드는 매 프레임 후보를 찾기 때문에, 배열을 재사용하는 NonAlloc 버전을 사용했다.
// 매 프레임 새 배열을 만들지 않기 위한 재사용 버퍼.
private readonly Collider[] candidateHits = new Collider[MaxCandidateColliders];
이전 글에서 디버그 오버레이 때문에 프레임이 튀는 문제를 겪었기 때문에, 반복 호출되는 부분은 조금 더 조심해서 만들었다.

대각선에서는 잡지 못하게 하기
처음에는 대각선에서도 상자를 잡을 수 있었다.
그런데 대각선에서 잡으면 상자를 어느 방향으로 밀고 당겨야 하는지 애매했다.
그래서 상자의 앞, 뒤, 왼쪽, 오른쪽 네 방향에서만 잡을 수 있게 했다.
private bool TryGetCardinalGrabAxis(PushPullObject targetObject, out Vector3 moveAxis)
{
moveAxis = Vector3.zero;
// 상자에서 플레이어를 향하는 방향을 구한다.
Vector3 toPlayer = transform.position - targetObject.transform.position;
toPlayer.y = 0.0f;
if (toPlayer.sqrMagnitude <= MoveInputThreshold)
{
return false;
}
toPlayer.Normalize();
Vector3 right = GetHorizontalAxis(targetObject.transform.right, Vector3.right);
Vector3 forward = GetHorizontalAxis(targetObject.transform.forward, Vector3.forward);
// 앞, 뒤, 좌, 우 중 플레이어와 가장 가까운 방향을 찾는다.
Vector3 bestAxis = right;
float bestDot = Vector3.Dot(toPlayer, right); //내적사용
CheckBetterAxis(-right, toPlayer, ref bestAxis, ref bestDot);
CheckBetterAxis(forward, toPlayer, ref bestAxis, ref bestDot);
CheckBetterAxis(-forward, toPlayer, ref bestAxis, ref bestDot);
// 기준보다 낮으면 대각선으로 보고 잡기를 막는다.
if (bestDot < MinCardinalGrabDot)
{
return false;
}
moveAxis = bestAxis;
return true;
}
여기서는 Vector3.Dot을 사용했다.(내적을 사용)
플레이어가 상자의 네 방향 중 어디에 가장 가까운지 확인하고, 너무 대각선이면 잡기를 거부한다.
이렇게 하니 상자를 잡는 위치와 이동 방향이 훨씬 명확해졌다.
잡기 시작하기
잡기가 시작되면 상자를 바라보게 하고, 플레이어의 일반 이동/회전을 잠시 막는다.
이 상태에서는 PlayerPushPullInteractor가 플레이어와 상자의 이동을 담당한다.
private void TryBeginGrab()
{
if (currentCandidate == null || !CanUseHands())
{
return;
}
// 상자를 어느 축으로 밀고 당길지 정한다.
if (!TryGetCardinalGrabAxis(currentCandidate, out Vector3 moveAxis))
{
return;
}
if (!currentCandidate.TryBeginGrab(this))
{
return;
}
grabbedObject = currentCandidate;
grabbedMoveAxis = moveAxis;
StoreGrabbedOffset();
FaceGrabbedObject();
// 잡고 있는 동안 기본 이동은 막고, 잡기 로직이 이동을 처리한다.
playerMovement?.SetExternalMoveSpeedMultiplier(0.0f);
playerMovement?.SetRotationLocked(true);
}
상자를 잡은 상태에서 플레이어가 계속 회전하면 어색해 보였다.
그래서 잡는 순간 상자를 바라보게 하고, 잡고 있는 동안 회전을 잠갔다.

물론 이 방식은 일반 플레이어 이동처럼 서서히 가속되고 감속되는 느낌은 약하다.
나중에 더 묵직한 손맛이 필요하다면 linearVelocity를 바로 넣는 대신, 목표 속도까지 천천히 보간하거나 AddForce 방식으로 바꿀 수 있을 것 같다.
(근데 지금 방식도 꽤 자연스럽다고 생각은 한다. 퍼즐용 오브젝트를 더 정확하게 배치하는데 유리하기도 하고..)
입력을 상자 이동으로 바꾸기
잡은 상태에서는 플레이어의 WASD 입력을 상자 이동 축으로 투영한다.
예를 들어 상자 앞면을 잡고 있다면 앞/뒤 입력만 상자 이동에 사용하고, 좌/우 입력은 무시한다.
private void FixedUpdate()
{
if (grabbedObject == null || playerMovement == null)
{
return;
}
if (!CanUseHands())
{
EndGrab();
return;
}
Vector3 moveDirection = playerMovement.GetCameraRelativeMoveDirection(playerMovement.MoveInput);
Vector3 grabbedVelocity = Vector3.zero;
if (moveDirection.sqrMagnitude > MoveInputThreshold)
{
// 입력 방향을 잡기 축에 투영한다.
float axisInput = Vector3.Dot(moveDirection, grabbedMoveAxis);
if (Mathf.Abs(axisInput) > MoveInputThreshold)
{
grabbedVelocity = grabbedMoveAxis * axisInput * grabbedMoveSpeed;
}
}
Vector3 correctionVelocity = GetHoldOffsetCorrectionVelocity();
Vector3 objectVelocity = grabbedVelocity + correctionVelocity;
Vector3 appliedObjectVelocity = grabbedObject.SetHorizontalVelocity(objectVelocity);
Vector3 playerVelocity = grabbedVelocity;
playerMovement.SetHorizontalVelocity(playerVelocity);
}
이동 자체는 Rigidbody 속도를 이용한다.
그래야 벽, 바닥, 발판과의 물리 충돌을 그대로 사용할 수 있기 때문이다.
잡혔을 때만 움직이는 상자
상자는 평소에 플레이어 몸에 밀려 움직이면 안 된다.
내가 원한 것은 몸통박치기를 했을때 움직이는 상자가 아닌, 잡고 있을 때만 움직이는 상자였다.
그래서 PushPullObject에서는 잡히지 않은 상태일 때 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 Vector3 GetHoldOffsetCorrectionVelocity()
{
if (grabbedObject == null || playerMovement == null)
{
return Vector3.zero;
}
// 잡은 순간의 간격을 기준으로 상자의 목표 위치를 계산한다.
Vector3 desiredObjectPosition = playerMovement.RigidbodyPosition + grabbedOffsetFromPlayer;
// 실제 상자 위치와 목표 위치의 차이를 보정한다.
Vector3 offsetError = desiredObjectPosition - grabbedObject.RigidbodyPosition;
offsetError.y = 0.0f;
return Vector3.ClampMagnitude(
offsetError * HoldOffsetCorrectionStrength,
MaxHoldOffsetCorrectionSpeed);
}
이 보정은 상자와 플레이어를 완전히 붙여두기 위한 것은 아니다.
물리적인 느낌은 남기면서, 너무 심하게 겹치거나 멀어지는 상황을 줄이기 위한 장치다.
정리
이번 작업은 단순히 상자를 움직이는 기능이 아니라, 플레이어가 손으로 오브젝트를 조작하는 기본 구조를 만드는 작업이었다.
앞으로 이 구조는 다른 퍼즐과 연결될 수 있다.
1. 물을 담아 무거워진 오브젝트를 만든다.
2. 플레이어가 그 오브젝트를 손으로 밀고 당긴다.
3. 무게 발판 위에 올린다.
4. 발판 조건이 만족된다.
5. 문이나 플랫폼이 움직인다.
이번 기능으로 퍼즐에서 사용할 수 있는 상호작용이 하나 늘었다.
이제 플레이어가 직접 오브젝트를 옮기고, 그 오브젝트의 무게를 이용해 장치를 작동시키는 퍼즐을 만들 수 있게 되었다.
'Unity - '한 우산 아래'' 카테고리의 다른 글
| 물 저장량으로 활성화되는 오브젝트 만들기 (0) | 2026.04.15 |
|---|---|
| 퍼즐 연결 구조 리팩터링하기 (0) | 2026.04.15 |
| Unity Profiler로 디버그 UI 프레임 드랍 원인 찾기 (0) | 2026.04.15 |
| 이단 점프와 우산 활공 구현하기 (0) | 2026.04.14 |
| 우산에 담긴 물의 무게로 문을 여는 퍼즐 만들기 (1) | 2026.04.12 |