Unity - '한 우산 아래'

양팔저울 퍼즐 만들기 - 무게 조건과 시각 연출 분리하기

망고와플 2026. 4. 24. 01:04

이번에는 양팔저울 퍼즐을 만들었다.

처음 목표는 단순했다.

플레이어나 상자, 물이 담긴 오브젝트가 양쪽 접시에 올라갔을 때, 양쪽 무게가 비슷하면 조건이 만족되는 구조를 만들고 싶었다.

예를 들면 이런 식이다.

왼쪽 접시: 플레이어 + 우산에 담긴 물
오른쪽 접시: 물이 담긴 상자

두 무게가 비슷해지면 문이 열린다.

그런데 막상 만들려고 보니, 단순히 WeightSensor 두 개만 비교하는 방식으로 만들면 나중에 확장하기 애매했다.

지금은 접시 위에 올라온 Rigidbody의 무게를 재는 WeightSensor만 있으면 될 것 같지만, 나중에는 이런 무게도 필요할 수 있기 때문이다.

WeightSensor
- 접시 위에 올라온 Rigidbody 질량 합산

FixedWeightSource
- 고정 무게 3kg짜리 추

RigidbodyWeightSource
- 특정 Rigidbody.mass를 그대로 무게로 사용

WaterAmountWeightSource
- 물 저장량을 무게처럼 사용

WeightedButton
- 발판 위 현재 무게를 그대로 무게 소스로 사용

그래서 이번에는 양팔저울을 만들면서 먼저 무게를 제공하는 구조를 분리했다.

[영상 추천]

글 맨 위에는 양팔저울이 실제로 동작하는 영상을 넣으면 좋다.

처음에는 한쪽 접시가 내려가 있고, 플레이어가 반대쪽 접시에 올라간 뒤 우산에 물을 받아 무게가 증가하면서 반대쪽 접시가 내려가는 장면이 좋다.

F3 디버그 UI에 L 6.0 / R 3.0 같은 무게 값이 보이면 더 이해하기 쉽다.

전체 구조

이번 양팔저울 구조는 크게 네 부분으로 나뉜다.

1. 무게 소스
- 현재 무게 값을 제공한다.

2. 양팔저울 조건
- 왼쪽과 오른쪽 무게를 비교한다.

3. 시각 연출
- 접시를 위아래로 움직이고 빔을 기울인다.

4. 조건 그룹
- 조건이 만족되면 문이나 플랫폼을 실행한다.

코드 구조로 보면 이렇게 된다.

IPuzzleWeightSource
├─ PuzzleWeightSource
│  ├─ WeightSensor
│  ├─ FixedWeightSource
│  ├─ RigidbodyWeightSource
│  └─ WaterAmountWeightSource
│
├─ WeightedButton
│
BalanceScaleCondition
→ PuzzleConditionSource

BalanceScaleVisual
→ 접시/빔 연출

여기서 중요한 점은 BalanceScaleCondition이 WeightSensor를 직접 아는 구조가 아니라는 것이다.

처음에는 이런 식으로 생각할 수도 있었다.

BalanceScaleCondition
- LeftWeightSensor
- RightWeightSensor

하지만 이렇게 만들면 양팔저울은 WeightSensor만 사용할 수 있다.

그래서 구조를 바꿨다.

BalanceScaleCondition
- LeftWeightSource
- RightWeightSource

이제 양팔저울은 WeightSensor인지 아닌지를 신경 쓰지 않는다.

대신 이렇게 묻는다.

너 CurrentWeight를 제공할 수 있어?

이 약속을 나타내는 것이 IPuzzleWeightSource다.

무게 소스 인터페이스 만들기

먼저 공통 무게 인터페이스를 만들었다.

using UnityEngine;

// 양팔저울처럼 무게 값만 필요한 퍼즐에서 공통으로 읽을 수 있는 인터페이스.
// WeightSensor, WeightedButton, 고정 추, Rigidbody, 물 저장량 등 서로 다른 구현을 같은 방식으로 다루기 위해 사용한다.
public interface IPuzzleWeightSource
{
    // 양팔저울은 이 값만 읽는다.
    // 실제 무게가 센서에서 오든, 물 저장량에서 오든, Rigidbody.mass에서 오든 신경 쓰지 않게 하기 위함이다.
    float CurrentWeight { get; }
}

// Inspector에 붙일 수 있는 무게 소스 컴포넌트의 공통 기반 클래스.
// WeightedButton처럼 이미 다른 기반 클래스를 상속 중인 컴포넌트는 이 클래스 대신 IPuzzleWeightSource만 직접 구현하면 된다.
public abstract class PuzzleWeightSource : MonoBehaviour, IPuzzleWeightSource
{
    // MonoBehaviour로 붙일 수 있는 무게 소스들은 이 값을 자기 방식대로 계산해서 반환한다.
    public abstract float CurrentWeight { get; }
}

여기서 IPuzzleWeightSource는 무게를 제공할 수 있는 컴포넌트라면 CurrentWeight를 가져야 한다는 약속이다.

PuzzleWeightSource는 Unity Inspector에 붙일 수 있는 무게 소스용 기반 클래스다.

예를 들어 고정 무게 소스는 이렇게 만들 수 있다.

public class FixedWeightSource : PuzzleWeightSource
{
    [Tooltip("양팔저울 퍼즐에서 사용할 고정 무게 값.")]
    [SerializeField] private float weight = 1.0f;

    // 고정 추처럼 항상 같은 무게를 제공한다.
    public override float CurrentWeight => weight;

    // Inspector에서 실수로 음수를 넣어도 저울 계산에는 0kg 이상만 들어가게 한다.
    private void OnValidate()
    {
        weight = Mathf.Max(0.0f, weight);
    }
}

이제 FixedWeightSource는 항상 같은 무게를 제공하는 컴포넌트가 된다.

예를 들어 한쪽 접시에 3kg짜리 기준 추를 놓고 싶을 때 사용할 수 있다.

WeightSensor도 무게 소스로 만들기

기존 WeightSensor는 Trigger 안에 들어온 Rigidbody들의 질량을 합산하는 컴포넌트였다.

양팔저울 접시도 결국 위에 올라온 것들의 무게가 필요하기 때문에, WeightSensor를 무게 소스로 사용할 수 있게 했다.

// Trigger 영역 안에 들어온 Rigidbody들의 질량을 합산하는 무게 센서.
// 발판, 양팔저울 접시처럼 위에 올라온 것들의 총 무게가 필요한 곳에 붙인다.
[DisallowMultipleComponent]
[RequireComponent(typeof(Collider))]
public class WeightSensor : PuzzleWeightSource
{
    [SerializeField] private float currentWeight;

    // 같은 Rigidbody가 여러 Collider로 센서에 닿을 수 있어서 접촉 횟수를 함께 기록한다.
    private readonly Dictionary<Rigidbody, int> bodyContactCounts = new Dictionary<Rigidbody, int>();
    private readonly List<Rigidbody> missingBodies = new List<Rigidbody>();

    public override float CurrentWeight => currentWeight;
    public int BodyCount => bodyContactCounts.Count;
}

핵심은 이 부분이다.

public override float CurrentWeight => currentWeight;

이제 WeightSensor는 PuzzleWeightSource를 상속하고, 현재 감지한 무게를 CurrentWeight로 제공한다.

무게 계산은 매 물리 프레임마다 다시 한다.

// Rigidbody.mass가 런타임에 바뀔 수 있으므로 물리 주기마다 합산값을 다시 계산한다.
private void FixedUpdate()
{
    RefreshCurrentWeight();
}

여기서 매번 다시 읽는 이유가 있다.

이 프로젝트에서는 우산에 물을 담으면 플레이어 Rigidbody.mass가 바뀐다.

즉, 같은 플레이어가 센서 위에 올라와 있어도 시간이 지나면서 무게가 바뀔 수 있다.

그래서 Trigger에 들어온 순간의 무게만 저장하면 안 되고, FixedUpdate마다 현재 body.mass를 다시 읽어야 한다.

private void RefreshCurrentWeight()
{
    float totalWeight = 0.0f;
    missingBodies.Clear();

    foreach (KeyValuePair<Rigidbody, int> entry in bodyContactCounts)
    {
        Rigidbody body = entry.Key;
        if (body == null)
        {
            missingBodies.Add(body);
            continue;
        }

        // 우산에 물이 담기면 Player Rigidbody.mass가 바뀌므로 매 FixedUpdate마다 다시 읽는다.
        totalWeight += body.mass;
    }

    for (int i = 0; i < missingBodies.Count; i++)
    {
        bodyContactCounts.Remove(missingBodies[i]);
    }

    currentWeight = totalWeight;
}

이제 양팔저울 접시에 WeightSensor를 붙이면, 접시 위에 올라온 플레이어와 상자의 질량 합을 그대로 읽을 수 있다.

WeightedButton도 무게 소스로 사용할 수 있게 하기

기존 WeightedButton은 PuzzleConditionSource였다.

즉, 버튼이 눌렸는지 아닌지를 조건 그룹에 알려주는 역할이었다.

public class WeightedButton : PuzzleConditionSource

그런데 양팔저울 입장에서는 버튼도 현재 올라온 무게를 알고 있는 오브젝트다.

그래서 WeightedButton도 IPuzzleWeightSource를 구현하게 했다.

public class WeightedButton : PuzzleConditionSource, IPuzzleWeightSource
{
    [SerializeField] private bool isPressed;
    [SerializeField] private float currentWeight;

    public bool IsPressed => isPressed;
    public override bool IsSatisfied => IsPressed;

    // 양팔저울이 WeightedButton 자체를 무게 소스로 읽을 수 있게 현재 센서 무게를 공개한다.
    public float CurrentWeight => currentWeight;
}

여기서 중요한 점은 WeightedButton이 PuzzleWeightSource를 상속하지 않았다는 것이다.

이미 PuzzleConditionSource를 상속하고 있기 때문이다.

C#은 클래스 다중 상속을 지원하지 않는다.

그래서 이런 식으로는 만들 수 없다.

public class WeightedButton : PuzzleConditionSource, PuzzleWeightSource

대신 인터페이스는 여러 개 구현할 수 있다.

그래서 이렇게 만들었다.

public class WeightedButton : PuzzleConditionSource, IPuzzleWeightSource

이제 WeightedButton은 두 가지 역할을 할 수 있다.

1. PuzzleConditionSource
- 버튼이 눌렸는지 조건으로 제공한다.

2. IPuzzleWeightSource
- 버튼 위의 현재 무게를 제공한다.

이렇게 해두면 나중에 “버튼 위의 무게 자체를 다른 퍼즐에서 읽는” 구조도 가능하다.

물 저장량과 Rigidbody도 무게 소스로 만들기

양팔저울은 꼭 접시 위의 물리 무게만 읽을 필요는 없다.

가끔은 특정 Rigidbody의 mass를 직접 읽고 싶을 수 있다.

// Rigidbody.mass를 그대로 무게 소스로 제공하는 컴포넌트.
// 플레이어 Rigidbody.mass가 우산 물 무게까지 반영하고 있으므로, 플레이어 자체를 저울 값으로 쓸 때도 사용할 수 있다.
[DisallowMultipleComponent]
public class RigidbodyWeightSource : PuzzleWeightSource
{
    [Tooltip("현재 무게 값으로 사용할 Rigidbody. 이 Rigidbody의 mass를 읽는다.")]
    [SerializeField] private Rigidbody targetRigidbody;

    // PlayerUmbrellaController가 플레이어 Rigidbody.mass에 물 무게를 더하므로,
    // 플레이어를 저울에 올리고 싶을 때는 이 값을 그대로 읽으면 된다.
    public override float CurrentWeight => targetRigidbody != null ? targetRigidbody.mass : 0.0f;
}

현재 프로토타입에서는 접시 위에 WeightSensor를 두는 방식이 더 직관적이다.

하지만 나중에 특정 Rigidbody 자체를 직접 무게 값으로 쓰고 싶다”면 RigidbodyWeightSource를 사용할 수 있다.

물 저장량도 별도 무게 소스로 만들었다.

// UmbrellaWaterTarget에 저장된 물의 양을 무게 소스로 제공하는 컴포넌트.
// 물 저장 오브젝트 자체를 저울 한쪽의 가상 무게로 쓰고 싶을 때 사용한다.
[DisallowMultipleComponent]
public class WaterAmountWeightSource : PuzzleWeightSource
{
    [Tooltip("저장된 물의 양을 무게로 변환할 물 저장 대상.")]
    [SerializeField] private UmbrellaWaterTarget waterTarget;

    [Tooltip("UmbrellaWaterTarget의 AddedWeight 값을 사용한다. 끄면 ReceivedWater * Weight Multiplier를 사용한다.")]
    [SerializeField] private bool useTargetAddedWeight = true;

    [Tooltip("Use Target Added Weight를 끈 경우 물 저장량에 곱할 무게 배율.")]
    [SerializeField] private float weightMultiplier = 1.0f;

    public override float CurrentWeight
    {
        get
        {
            if (waterTarget == null)
            {
                return 0.0f;
            }

            if (useTargetAddedWeight)
            {
                return waterTarget.AddedWeight;
            }

            return waterTarget.ReceivedWater * weightMultiplier;
        }
    }
}

여기서 WaterAmountWeightSource는 물 저장 오브젝트를 직접 퍼즐 무게로 사용하고 싶을 때 쓴다.

예를 들어 이런 퍼즐에 사용할 수 있다.

왼쪽 저울 값 = 물 저장량
오른쪽 저울 값 = 고정 무게 5kg

물 저장량이 5kg에 가까워지면 문이 열린다.

하지만 물을 담은 상자를 실제 접시 위에 올리는 구조라면 굳이 이 컴포넌트가 필요하지 않다.

그 경우에는 물이 상자의 Rigidbody.mass를 증가시키고, 접시의 WeightSensor가 그 mass를 읽으면 된다.

물 담은 상자
→ Rigidbody.mass 증가
→ WeightSensor가 감지
→ BalanceScaleCondition이 비교

즉, WaterAmountWeightSource는 “물리 오브젝트의 무게”가 아니라 “물 저장량 자체를 직접 무게 값으로 쓰고 싶을 때” 사용하는 선택지다.

양팔저울 조건 만들기

이제 실제 양팔저울 조건을 만들었다.

역할은 단순하다.

왼쪽 무게 읽기
오른쪽 무게 읽기
둘의 차이 계산
허용 오차 안이면 조건 만족
// 두 무게 소스의 값을 비교해서 양팔저울 퍼즐 조건으로 사용하는 컴포넌트.
// 양쪽 무게 차이가 허용 오차 안에 들어오면 PuzzleConditionGroup에 만족 상태를 알려준다.
[DisallowMultipleComponent]
public class BalanceScaleCondition : PuzzleConditionSource
{
    [Header("Weight Sources")]
    [Tooltip("왼쪽 접시의 무게 소스. IPuzzleWeightSource를 구현한 컴포넌트를 넣어야 한다.")]
    [FormerlySerializedAs("leftSensor")]
    [SerializeField] private MonoBehaviour leftWeightSource;

    [Tooltip("오른쪽 접시의 무게 소스. IPuzzleWeightSource를 구현한 컴포넌트를 넣어야 한다.")]
    [FormerlySerializedAs("rightSensor")]
    [SerializeField] private MonoBehaviour rightWeightSource;

    [Header("Balance")]
    [Tooltip("양쪽 접시가 균형으로 인정될 수 있는 최대 무게 차이.")]
    [SerializeField] private float allowedDifference = 0.25f;

    [Tooltip("양쪽이 모두 0kg일 때 실수로 균형 성공 처리되는 것을 막기 위한 최소 총 무게.")]
    [SerializeField] private float minimumTotalWeight = 0.1f;
}

여기서 왼쪽/오른쪽 소스 타입이 WeightSensor가 아니라 MonoBehaviour인 이유가 있다.

Unity Inspector는 인터페이스 필드를 기본적으로 편하게 직렬화하지 않는다.

즉, 이런 식으로 쓰면 Inspector에서 다루기 애매하다.

[SerializeField] private IPuzzleWeightSource leftWeightSource;

그래서 Inspector에는 MonoBehaviour로 받는다.

[SerializeField] private MonoBehaviour leftWeightSource;

그리고 실제로 값을 읽을 때 “이 컴포넌트가 IPuzzleWeightSource를 구현했는지” 확인한다.

private float GetCurrentWeight(MonoBehaviour source)
{
    // Inspector에는 MonoBehaviour로 넣지만, 실제로는 IPuzzleWeightSource를 구현한 컴포넌트만 유효하다.
    // 잘못된 컴포넌트를 넣어도 게임이 터지지 않도록 0kg으로 취급한다.
    if (source is not IPuzzleWeightSource weightSource)
    {
        return 0.0f;
    }

    // 퍼즐 무게는 음수로 내려가면 조건 판정이 헷갈리므로 최종적으로 0 이상만 사용한다.
    return Mathf.Max(0.0f, weightSource.CurrentWeight);
}

이 구조 덕분에 Inspector에는 다양한 컴포넌트를 넣을 수 있다.

WeightSensor
WeightedButton
FixedWeightSource
RigidbodyWeightSource
WaterAmountWeightSource

전부 CurrentWeight를 제공할 수 있다면 양팔저울의 입력으로 쓸 수 있다.

균형 판정하기

균형 판정은 FixedUpdate에서 처리했다.

이유는 무게 소스 대부분이 Rigidbody나 Trigger 기반이기 때문이다.

// 무게 소스 대부분이 Rigidbody/Trigger 기반이라 물리 주기에서 비교한다.
// 이렇게 해야 WeightSensor가 갱신한 값과 저울 판정이 같은 타이밍에 맞는다.
private void FixedUpdate()
{
    RefreshBalanceState(true);
}

실제 판정은 이 함수에서 한다.

private void RefreshBalanceState(bool notifyChanged)
{
    leftWeight = GetCurrentWeight(leftWeightSource);
    rightWeight = GetCurrentWeight(rightWeightSource);
    weightDifference = Mathf.Abs(leftWeight - rightWeight);

    float totalWeight = leftWeight + rightWeight;
    bool hasEnoughWeight = totalWeight >= minimumTotalWeight;
    bool nextBalanced = hasEnoughWeight && weightDifference <= allowedDifference;

    // 상태가 그대로라면 조건 그룹에 같은 이벤트를 반복해서 보내지 않는다.
    if (isBalanced == nextBalanced)
    {
        return;
    }

    isBalanced = nextBalanced;

    if (notifyChanged)
    {
        NotifyChanged();
    }
}

조건은 두 가지다.

1. 양쪽 총 무게가 minimumTotalWeight 이상인가?
2. 왼쪽/오른쪽 차이가 allowedDifference 이하인가?

minimumTotalWeight를 둔 이유는 0 대 0 문제 때문이다.

왼쪽 0kg
오른쪽 0kg
차이 0kg

이 상태를 균형이라고 보면 아무것도 안 올렸는데 퍼즐이 풀려버린다.

그래서 최소 총 무게를 두었다.

총 무게가 0.1kg 이상이어야 균형 판정 가능

또 하나 중요한 점은 상태가 바뀔 때만 NotifyChanged()를 호출한다는 것이다.

if (isBalanced == nextBalanced)
{
    return;
}

이렇게 하지 않으면 조건이 만족된 상태에서 매 FixedUpdate마다 이벤트가 반복 호출될 수 있다.

문이 계속 열리거나, 플랫폼이 같은 명령을 반복해서 받는 문제가 생길 수 있다.

PuzzleConditionGroup과 연결하기

BalanceScaleCondition은 PuzzleConditionSource를 상속한다.

public class BalanceScaleCondition : PuzzleConditionSource

그래서 기존 PuzzleConditionGroup에 그대로 넣을 수 있다.

BalanceConditionGroup
- Condition Sources
  - BalanceScaleCondition

그리고 결과는 기존 방식처럼 연결한다.

On Satisfied
→ Door.PuzzleMover.Activate()

On Unsatisfied
→ Door.PuzzleMover.Deactivate()

즉, 양팔저울은 새로운 결과 시스템을 만들지 않고 기존 퍼즐 조건 시스템에 들어간다.

WeightSensor / WeightSource
→ BalanceScaleCondition
→ PuzzleConditionGroup
→ PuzzleMover

디버그 표시

양팔저울은 F3 디버그 오버레이에서도 현재 상태를 볼 수 있게 했다.

string stateText = isBalanced ? "Balanced" : "Unbalanced";
PuzzleDebugOverlay.DrawLabel(
    labelPoint,
    $"{name}\\n{stateText}\\nL {leftWeight:F1} / R {rightWeight:F1}\\nDiff {weightDifference:F2} <= {allowedDifference:F2}",
    LabelWidth,
    LabelHeight);

실제로 화면에는 이런 식으로 보인다.

BalanceScaleCondition
Unbalanced
L 6.0 / R 3.0
Diff 3.00 <= 0.25

이 디버그가 굉장히 중요했다.

처음 테스트할 때 플레이어가 접시에 올라갔는데 왜 접시가 안 올라오는 거지.. 같은 상황이 생겼다.

이때 디버그에서 L 0.0 / R 3.0처럼 보이면, 문제는 시각 연출이 아니라 왼쪽 센서가 플레이어를 감지하지 못하는 것이다.

반대로 L 6.0 / R 3.0처럼 제대로 잡히는데 움직임이 이상하면, 그때는 BalanceScaleVisual이나 접시 세팅을 의심하면 된다.

접시와 빔 연출 만들기

조건 판정만 있으면 퍼즐은 동작한다.

하지만 양팔저울인데 접시가 움직이지 않으면 느낌이 없다.

그래서 조건 판정과 별개로 시각 연출을 담당하는 BalanceScaleVisual을 만들었다.

// 양팔저울의 조건 판정과 별개로, 접시와 빔이 실제 저울처럼 움직이게 하는 시각 연출 컴포넌트.
// BalanceScaleCondition이 있으면 그 조건의 왼쪽/오른쪽 무게를 읽고,
// 없으면 직접 지정한 IPuzzleWeightSource 컴포넌트에서 무게를 읽는다.
// 접시에 MovingPlatformSurface를 붙여두면 플레이어와 PushPullObject도 접시 이동량을 따라간다.
[DefaultExecutionOrder(-50)]
[DisallowMultipleComponent]
public class BalanceScaleVisual : MonoBehaviour
{
}

여기서 중요한 점은 BalanceScaleVisual이 조건을 직접 판단하지 않는다는 것이다.

조건 판단은 BalanceScaleCondition이 한다.

BalanceScaleVisual은 그 값을 읽어서 접시와 빔을 움직이는 역할만 한다.

BalanceScaleCondition
- 조건 판단

BalanceScaleVisual
- 접시와 빔 연출

이렇게 분리하면 나중에 연출을 바꿔도 조건 로직은 건드리지 않아도 된다.

무게 차이를 -1~1 값으로 바꾸기

시각 연출의 핵심은 왼쪽과 오른쪽 무게 차이를 -1 ~ 1 사이 값으로 바꾸는 것이다.

// 오른쪽이 더 무거우면 양수, 왼쪽이 더 무거우면 음수로 본다.
float weightDelta = rightWeight - leftWeight;
visualBalance = Mathf.Clamp(weightDelta / maxWeightDifference, -1.0f, 1.0f);

예를 들어:

왼쪽 6kg
오른쪽 3kg

weightDelta = 3 - 6 = -3

왼쪽이 더 무거우므로 음수가 나온다.

반대로:

왼쪽 1kg
오른쪽 3kg

weightDelta = 3 - 1 = 2

오른쪽이 더 무거우므로 양수가 나온다.

maxWeightDifference는 무게 차이가 얼마일 때 최대 연출까지 갈까를 정하는 값이다.

Max Weight Difference = 5
무게 차이 = 3

visualBalance = 3 / 5 = 0.6

즉, 최대 움직임의 60%만 움직인다.

더 민감하게 만들고 싶으면 Max Weight Difference를 줄이면 된다.

Max Weight Difference를 줄임
→ 같은 무게 차이에도 더 크게 움직임

접시 위아래 이동

접시는 visualBalance 값을 이용해 위아래로 움직인다.

private void ApplyPlateMotion(float followFactor)
{
    // 오른쪽이 무거우면 오른쪽 접시는 내려가고, 왼쪽 접시는 올라간다.
    float leftOffset = visualBalance * maxPlateOffset;
    float rightOffset = -visualBalance * maxPlateOffset;

    if (leftPlate != null)
    {
        Vector3 targetPosition = initialLeftPlateLocalPosition + Vector3.up * leftOffset;
        leftPlate.localPosition = Vector3.Lerp(leftPlate.localPosition, targetPosition, followFactor);
    }

    if (rightPlate != null)
    {
        Vector3 targetPosition = initialRightPlateLocalPosition + Vector3.up * rightOffset;
        rightPlate.localPosition = Vector3.Lerp(rightPlate.localPosition, targetPosition, followFactor);
    }
}

오른쪽이 더 무거우면 visualBalance가 양수다.

visualBalance = 0.6
maxPlateOffset = 1.0

leftOffset = 0.6
rightOffset = -0.6

그러면 왼쪽은 올라가고 오른쪽은 내려간다.

반대로 왼쪽이 더 무거우면 visualBalance가 음수이므로 왼쪽이 내려간다.

Vector3.Lerp를 사용한 이유는 접시가 순간이동하지 않고 부드럽게 따라가게 하기 위해서다.

빔 기울이기

빔은 접시와 같은 visualBalance 값을 이용해 회전한다.

처음에는 빔을 고정된 Z축 기준으로만 기울이게 만들었다.

그런데 실제 씬에서는 빔의 배치 방향에 따라 X축 기준으로 기울여야 자연스럽게 보였다.

그래서 기울일 축을 Inspector에서 고를 수 있게 만들었다.

private enum BeamTiltAxis
{
    LocalX,
    LocalNegativeX,
    LocalY,
    LocalNegativeY,
    LocalZ,
    LocalNegativeZ
}

이제 +X, -X, +Y, -Y, +Z, -Z 중에서 고를 수 있다.

private Vector3 GetBeamTiltAxis()
{
    switch (beamTiltAxis)
    {
        case BeamTiltAxis.LocalX:
            return Vector3.right;
        case BeamTiltAxis.LocalNegativeX:
            return Vector3.left;
        case BeamTiltAxis.LocalY:
            return Vector3.up;
        case BeamTiltAxis.LocalNegativeY:
            return Vector3.down;
        case BeamTiltAxis.LocalZ:
            return Vector3.forward;
        case BeamTiltAxis.LocalNegativeZ:
            return Vector3.back;
        default:
            return Vector3.right;
    }
}

실제 빔 회전은 이 부분에서 처리한다.

private void ApplyBeamTilt(float followFactor)
{
    if (beam == null)
    {
        return;
    }

    // 씬에서 빔을 어떤 축 기준으로 배치했는지에 따라 기울이는 로컬 축을 선택한다.
    // 예를 들어 빔이 X축 기준으로 기울어져야 자연스럽다면 Beam Tilt Axis를 LocalX로 둔다.
    Quaternion targetRotation = initialBeamLocalRotation * Quaternion.AngleAxis(
        visualBalance * maxBeamTiltAngle,
        GetBeamTiltAxis());

    beam.localRotation = Quaternion.Slerp(beam.localRotation, targetRotation, followFactor);
}

maxBeamTiltAngle은 빔이 최대로 기울어질 각도다.

예를 들어:

Max Beam Tilt Angle = 20
visualBalance = 0.5

이면 실제 기울기는 약 10도다.

20도 * 0.5 = 10도

접시 위 물체도 같이 움직이게 하기

처음에는 BalanceScaleVisual이 접시 Transform만 움직였다.

겉보기에는 접시가 위아래로 움직였지만, 문제가 있었다.

접시 위에 올라간 플레이어나 상자가 접시 이동을 따라가지 못했다.

이유는 단순하다.

접시 Transform은 움직였지만, 플레이어나 상자 입장에서는 “내가 밟고 있는 발판이 이번 프레임에 얼마나 움직였는지”를 알 수 없었다.

이미 이전에 회전 플랫폼을 만들 때 같은 문제를 해결하기 위해 MovingPlatformSurface를 만들었다.

그래서 양팔저울 접시도 같은 시스템을 사용하게 했다.

private MovingPlatformSurface leftPlateSurface;
private MovingPlatformSurface rightPlateSurface;

접시를 움직이기 전 위치를 기록하고:

private void CapturePlateMotionBefore()
{
    leftPlateSurface?.CaptureBeforeMotion();
    rightPlateSurface?.CaptureBeforeMotion();
}

접시를 움직인 뒤 위치를 다시 기록한다.

private void CapturePlateMotionAfter()
{
    leftPlateSurface?.CaptureAfterMotion();
    rightPlateSurface?.CaptureAfterMotion();
}

실제 흐름은 이렇다.

private void ApplyVisualMotion(float deltaTime)
{
    if (!hasInitialPose)
    {
        CacheInitialPose();
    }

    // 접시를 움직이기 전 위치를 기록해, 위에 올라간 플레이어/상자가 같은 이동량을 받을 수 있게 한다.
    CapturePlateMotionBefore();

    float weightDelta = rightWeight - leftWeight;
    visualBalance = Mathf.Clamp(weightDelta / maxWeightDifference, -1.0f, 1.0f);

    if (invertDirection)
    {
        visualBalance = -visualBalance;
    }

    float followFactor = GetFollowFactor(deltaTime);
    ApplyPlateMotion(followFactor);
    ApplyBeamTilt(followFactor);

    // 접시를 움직인 뒤 위치를 기록한다.
    // MovingPlatformSurface는 이 전후 차이를 플레이어/상자에게 전달한다.
    CapturePlateMotionAfter();
}

이렇게 하면 접시가 위아래로 움직였을 때, 그 위에 올라간 플레이어나 PushPullObject도 같은 이동량을 따라갈 수 있다.

그래서 양팔저울 접시 Root에는 MovingPlatformSurface를 붙여야 한다.

LeftPlateRoot
- MovingPlatformSurface

RightPlateRoot
- MovingPlatformSurface

BalanceScaleVisual에는 이 Root들을 넣는다.

Left Plate  → LeftPlateRoot
Right Plate → RightPlateRoot

이렇게 해야 접시와 그 위의 센서, 실제 발판이 같이 움직인다.

실제 하이어라키 세팅

시행착오

처음에는 양팔저울 접시가 내려가고 올라가는 것처럼 보였지만, 위에 올라간 플레이어나 상자가 자연스럽게 같이 움직이지 않았다.

처음 생각은 그냥 물리 엔진이 알아서 해주겠지~ 였다.

하지만 접시를 Transform으로 직접 움직이면, Unity 물리 입장에서는 위 물체를 안정적으로 태워주는 움직이는 발판이라고 보장하기 어렵다.

그래서 기존 회전 플랫폼에서 사용하던 MovingPlatformSurface를 접시에도 사용했다.

접시 이동 전 위치 기록
접시 이동
접시 이동 후 위치 기록
위에 올라간 플레이어/상자가 그 이동량을 따라감

이 구조 덕분에 양팔저울 접시는 단순 시각 오브젝트가 아니라, 실제로 플레이어와 상자를 태우는 움직이는 발판처럼 동작한다.

정리

이번 작업의 핵심은 양팔저울을 하나의 거대한 스크립트로 만들지 않은 것이다.

역할을 나누었다.

IPuzzleWeightSource
- 무게 값을 제공하는 공통 약속

WeightSensor
- 접시 위에 올라온 Rigidbody 질량 합산

FixedWeightSource
- 고정 무게 제공

RigidbodyWeightSource
- 특정 Rigidbody.mass 제공

WaterAmountWeightSource
- 물 저장량을 무게로 변환

BalanceScaleCondition
- 왼쪽/오른쪽 무게 비교

BalanceScaleVisual
- 접시 이동과 빔 기울기 연출

PuzzleConditionGroup
- 조건 만족 시 결과 이벤트 실행

이렇게 나누니 양팔저울은 단순히 WeightSensor 두 개만 비교하는 퍼즐이 아니라, 다양한 무게 소스를 조합할 수 있는 퍼즐 장치가 되었다.

지금은 접시 위에 올라온 물리 오브젝트의 무게를 비교하는 구조로 쓰고 있지만, 나중에는 물 저장량 자체를 무게로 쓰거나, 고정 추와 비교하거나, 플레이어의 우산 물 무게를 직접 조건으로 쓰는 퍼즐도 만들 수 있다.

이번 구현에서 가장 중요한 배움은 이것이었다.

무게를 재는 것
무게를 비교하는 것
저울이 움직이는 것
퍼즐 결과를 실행하는 것

이 네 가지를 한 스크립트에 넣지 않고 분리하면, 이후 퍼즐을 훨씬 유연하게 확장할 수 있다.