https://github.com/flint21c1996/Umbrella/tree/main/Assets/Scripts/Puzzles
Umbrella/Assets/Scripts/Puzzles at main · flint21c1996/Umbrella
Unity프로젝트 <한 우산 아래>. Contribute to flint21c1996/Umbrella development by creating an account on GitHub.
github.com
이번에는 퍼즐끼리 연결되는 구조를 다시 정리했다.
처음에는 무게 발판을 만들고, 발판이 눌리면 문이나 플랫폼이 움직이도록 했다.
구조는 단순했다.
WeightedButton
→ OnPressed
→ PuzzleMover.Activate()
버튼 하나가 문 하나를 여는 정도라면 이 방식도 충분하다.
Unity Inspector에서 OnPressed 이벤트에 문을 연결하면 바로 동작하기 때문이다.
하지만 퍼즐이 조금만 복잡해져도 문제가 보이기 시작했다.
예를 들어 버튼 두 개가 모두 눌려야 문이 열린다면 어떻게 해야 할까?
버튼 A가 눌림
버튼 B가 눌림
둘 다 눌렸을 때만 문 열림
이런 구조에서는 버튼 하나가 문을 직접 여는 방식이 애매해진다.
버튼 A도 문을 열 수 있고, 버튼 B도 문을 열 수 있다면 둘 다 눌렸는지를 어디서 판단해야 할지 애매해진다.
그래서 버튼이 결과를 직접 실행하지 않고, 중간에 조건을 모아서 판단하는 구조가 필요하다고 느꼈다.
기존 구조의 문제
기존에는 WeightedButton 안에 UnityEvent가 있었다.
WeightedButton
- OnPressed
- OnReleased
그리고 Inspector에서 OnPressed에 문을 연결했다.
OnPressed → PuzzleMover.Activate()
OnReleased → PuzzleMover.Deactivate()
처음에는 편했다.
버튼에서 바로 문을 연결하면 됐기 때문이다.
하지만 이 방식은 버튼이 많아질수록 연결이 복잡해진다.
예시)
//가능
버튼 A → 문 A
버튼 B → 문 B
버튼 C → 플랫폼 C 연결
//여기서부터는 가능하다고 확답이 안됨(조건 조합이 추가되니까..), 코드가 계속 추가되면서 더러워질수 있음.
버튼 A + 버튼 B → 문 D
물 저장량 + 버튼 C → 플랫폼 E 연결
이런 식으로 조건 조합이 생기면 버튼이 결과를 직접 실행하는 방식은 점점 관리하기 어려워진다.
그래서 구조를 이렇게 바꿨다.
조건 소스들
→ 조건 그룹
→ 결과 이벤트
새 구조
새 구조에서는 퍼즐을 세 부분으로 나눴다.
PuzzleConditionSource
- 조건 하나를 나타낸다.
- 예: 눌린 버튼, 충분한 물 저장량, 켜진 레버
PuzzleConditionGroup
- 여러 조건을 모아서 검사한다.
- 모든 조건이 만족되면 결과 이벤트를 실행한다.
PuzzleMover
- 실제 문이나 플랫폼을 움직인다.
즉 버튼이 문을 직접 여는 것이 아니라, 버튼은 나는 눌렸는가? 만 알려준다.
조건 그룹은 여러 조건을 보고 모두 만족되었는가? 를 판단한다.
그리고 조건이 모두 만족되면 문이나 플랫폼을 움직인다.
WeightedButton
→ PuzzleConditionGroup
→ PuzzleMover
(아래 스크린샷을 예시로 WeightedButton → PuzzleConditionGroup → WeightDoor 순)

PuzzleConditionSource 만들기
먼저 모든 퍼즐 조건의 공통 기반이 되는 클래스를 만들었다.
// 퍼즐 조건으로 사용할 수 있는 컴포넌트의 공통 기반 클래스.
// 예: 눌린 발판, 켜진 레버, 충분한 물이 담긴 물체.
public abstract class PuzzleConditionSource : MonoBehaviour
{
// 현재 조건이 만족되었는지 반환한다.
// 자식 클래스가 자기 방식대로 반드시 구현해야 한다.
public abstract bool IsSatisfied { get; }
// 조건 상태가 바뀐 순간에만 발생하는 이벤트.
public event Action Changed;
// 자식 클래스가 만족/불만족 상태를 바꾼 뒤 호출한다.
protected void NotifyChanged()
{
Changed?.Invoke();
}
}
여기서 핵심은 두 가지다.
1. IsSatisfied
2. Changed
IsSatisfied는 현재 조건이 만족되었는지 알려준다.
Changed는 조건 상태가 바뀌었을 때 조건 그룹에게 알려준다.
이 구조를 만들면 조건 그룹은 버튼인지, 레버인지, 물 저장량인지 몰라도 된다.
그냥 IsSatisfied만 읽으면 된다.
SOLID 원칙 고민
이 구조를 만들면서 SOLID 원칙도 조금 고민했다.
특히 단일 책임 원칙, 즉 SRP를 생각하게 됐다.
처음에는 물을 받는 오브젝트인 UmbrellaWaterTarget을 바로 조건으로 만들 수도 있다고 생각했다.
public class UmbrellaWaterTarget : PuzzleConditionSource
{
public override bool IsSatisfied => ReceivedWater >= requiredWater;
}
이렇게 하면 편하다.
물을 일정량 이상 담은 오브젝트를 바로 PuzzleConditionGroup에 넣을 수 있기 때문이다.
하지만 곰곰이 생각해보면 UmbrellaWaterTarget의 원래 역할은 우산으로부터 물을 받는 대상이다.
(이 부분은 다음 글에서 설멸하겠다.)
이미 이 클래스는 다음 일을 맡고 있다.
물을 받는다.
- 현재 저장량을 관리한다.
- 저장량에 따라 색을 바꾼다.
- 물 무게를 Rigidbody 질량에 반영한다.
여기에 퍼즐 조건으로 동작한다는 책임까지 추가하면 클래스의 의미가 점점 커진다.
그래서 물 저장량을 퍼즐 조건으로 바꾸는 역할은 별도 컴포넌트로 분리하기로 했다.
UmbrellaWaterTarget
- 물을 받고 저장량을 관리한다.
WaterAmountCondition
- UmbrellaWaterTarget의 저장량을 읽는다.
- 그 값이 퍼즐 조건을 만족하는지 판단한다.
이렇게 하면 컴포넌트는 하나 늘어나지만 역할은 더 분명해진다.
public class WaterAmountCondition : PuzzleConditionSource
{
[SerializeField] private UmbrellaWaterTarget waterTarget;
public override bool IsSatisfied => IsWaterRequirementMet();
private bool IsWaterRequirementMet()
{
return waterTarget != null && waterTarget.ReceivedWater >= RequiredWater;
}
}
이 구조는 DIP, 의존성 역전 원칙과도 조금 맞닿아 있다.
PuzzleConditionGroup은 구체적인 버튼, 물 저장량, 레버 클래스를 직접 알 필요가 없다.
[SerializeField] private PuzzleConditionSource[] conditionSources;
조건 그룹은 오직 공통 부모인 PuzzleConditionSource만 바라본다.
나는 이 조건이 버튼인지 물인지 레버인지 몰라도 된다.
IsSatisfied만 알려주면 된다.
나중에 새로운 조건을 추가해도 PuzzleConditionGroup을 수정할 필요가 적다.
예를 들어 나중에 레버 조건을 만든다면 이런 식으로 추가할 수 있다.
public class LeverCondition : PuzzleConditionSource
{
public override bool IsSatisfied => isOn;
}
이 클래스도 PuzzleConditionSource를 상속하기만 하면, 기존 조건 그룹에 그대로 넣을 수 있다.
결국 이 구조의 핵심은 편의성보다 책임 분리를 선택한 것이다.
UmbrellaWaterTarget을 바로 조건으로 만들면 편하다.
하지만 물 받기와 퍼즐 조건 판정이 한 클래스에 섞인다.
WaterAmountCondition을 따로 만들면 컴포넌트는 하나 늘어난다.
대신 역할이 분리되고 확장하기 쉬워진다.

WeightedButton을 조건 소스로 바꾸기
이제 WeightedButton은 PuzzleConditionSource를 상속한다.
public class WeightedButton : PuzzleConditionSource
{
[SerializeField] private bool isPressed;
[SerializeField] private float currentWeight;
public bool IsPressed => isPressed;
// ConditionGroup은 이 값을 보고 버튼 조건이 만족되었는지 판단한다.
public override bool IsSatisfied => IsPressed;
}
버튼 입장에서는 단순하다.
눌렸으면 true
안 눌렸으면 false
그리고 버튼 상태가 바뀌는 순간 NotifyChanged()를 호출한다.
private void SetPressed(bool pressed)
{
// 상태가 같으면 같은 이벤트를 반복해서 보내지 않는다.
if (isPressed == pressed)
{
return;
}
isPressed = pressed;
// 조건 그룹에게 상태가 바뀌었다고 알린다.
NotifyChanged();
}
이제 버튼은 더 이상 문을 직접 열지 않는다.
버튼은 자기 상태만 관리한다.
이게 중요한 변화였다.
이전:
버튼이 문을 직접 움직임
이후:
버튼은 조건만 제공함
조건 그룹이 결과를 실행함

PuzzleConditionGroup
조건들을 모아서 판단하는 역할은 PuzzleConditionGroup이 맡는다.
public class PuzzleConditionGroup : MonoBehaviour
{
[Header("Conditions")]
[SerializeField] private PuzzleConditionSource[] conditionSources;
[Header("Events")]
[SerializeField] private UnityEvent onSatisfied;
[SerializeField] private UnityEvent onUnsatisfied;
[SerializeField] private bool satisfied;
}
여기서 conditionSources 배열에 필요한 조건들을 넣는다.
예를 들어 버튼 두 개가 모두 눌려야 문이 열리는 퍼즐이라면:
Condition Sources
- WeightButton
- WeightButton (1)
이렇게 넣으면 된다.
그리고 On Satisfied에는 문을 여는 이벤트를 연결한다.
On Satisfied
→ WeightDoor.PuzzleMover.Activate()
필요하다면 On Unsatisfied에는 문을 멈추거나 닫는 이벤트를 연결할 수 있다.
On Unsatisfied
→ WeightDoor.PuzzleMover.Pause()

매 프레임 검사하지 않기
처음 생각만 하면 조건 그룹이 매 프레임 모든 조건을 검사해도 될 것처럼 보인다.
private void Update()
{
RefreshSatisfiedState();
}
하지만 굳이 그럴 필요가 없었다.
조건은 매 프레임 바뀌는 것이 아니라, 버튼이 눌리거나 풀리는 순간에만 바뀐다.
그래서 Changed 이벤트를 구독하는 방식으로 만들었다.
private void SubscribeConditions()
{
if (conditionSources == null)
{
return;
}
for (int i = 0; i < conditionSources.Length; i++)
{
PuzzleConditionSource condition = conditionSources[i];
if (condition == null)
{
continue;
}
// 중복 구독을 막기 위해 먼저 제거한 뒤 다시 등록한다.
condition.Changed -= OnConditionChanged;
condition.Changed += OnConditionChanged;
}
}
조건이 바뀌면 OnConditionChanged()가 호출된다.
private void OnConditionChanged()
{
RefreshSatisfiedState(true);
}
즉, 조건 그룹은 평소에는 가만히 있다가 조건이 바뀌는 순간에만 다시 계산한다.
버튼 눌림
→ WeightedButton.NotifyChanged()
→ PuzzleConditionGroup.OnConditionChanged()
→ 모든 조건 검사
→ 결과 이벤트 실행
이 방식이 더 깔끔하다.
불필요하게 매 프레임 조건을 검사하지 않아도 되고, 조건이 바뀐 순간만 처리할 수 있다.
모든 조건 검사하기
조건 그룹은 모든 조건이 만족되었는지 검사한다.
private bool AreAllConditionsSatisfied()
{
// 조건이 하나도 없으면 실수로 항상 성공하지 않게 false로 본다.
if (conditionSources == null || conditionSources.Length == 0)
{
return false;
}
for (int i = 0; i < conditionSources.Length; i++)
{
PuzzleConditionSource condition = conditionSources[i];
// 하나라도 비어 있거나 만족되지 않았으면 실패.
if (condition == null || !condition.IsSatisfied)
{
return false;
}
}
return true;
}
중요한 점은 조건이 하나도 없으면 false로 처리했다는 것이다.
조건이 비어 있는데 자동으로 성공해버리면, 실수로 문이 열리는 퍼즐이 생길 수 있기 때문이다.
상태가 바뀔 때만 이벤트 실행하기
조건 그룹은 상태가 바뀌었을 때만 이벤트를 호출한다.
private void RefreshSatisfiedState(bool invokeEvents)
{
bool nextSatisfied = AreAllConditionsSatisfied();
// 이미 같은 상태라면 이벤트를 반복 호출하지 않는다.
if (satisfied == nextSatisfied)
{
return;
}
satisfied = nextSatisfied;
if (!invokeEvents)
{
return;
}
if (satisfied)
{
onSatisfied.Invoke();
return;
}
onUnsatisfied.Invoke();
}
이렇게 하지 않으면 조건이 만족된 상태에서 같은 이벤트가 계속 반복될 수 있다.
문이 열리는 이벤트가 매번 호출되면, 문이 계속 위로 올라가거나 의도하지 않은 움직임이 생길 수 있다.
그래서 조건이 만족된 순간과 조건이 풀린 순간에만 이벤트를 실행하게 했다.
이 구조 덕분에 문을 여는 방식도 더 유연해졌다.
On Satisfied
→ Activate()
On Unsatisfied
→ Deactivate()
또는:
On Satisfied
→ Activate()
On Unsatisfied
→ Pause()
이렇게 연결 방식에 따라 퍼즐 반응을 다르게 만들 수 있다.
디버그 연결선
구조를 바꾸면서 F3 디버그 오버레이도 같이 정리했다.
이제 버튼이 문을 직접 연결하지 않기 때문에, 연결선도 이렇게 보이면 된다.
WeightedButton
→ PuzzleConditionGroup
→ PuzzleMover
조건 그룹은 자신에게 연결된 조건과 결과 이벤트를 알고 있으므로, F3를 눌렀을 때 연결 관계를 보여줄 수 있다.
// 조건 소스에서 ConditionGroup으로 향하는 선을 그린다.
// 노란색은 아직 대기 중, 초록색은 만족된 조건이다.
PuzzleDebugOverlay.DrawWorldLine(
targetCamera,
conditionPosition,
sourcePosition,
color,
debugLineThickness);
이렇게 해두면 나중에 기획자가 맵을 설계할 때도 연결 관계를 눈으로 확인하기 쉬워진다.
Inspector를 하나씩 열어보지 않아도, 어떤 버튼이 어떤 조건 그룹에 들어가고, 그 조건 그룹이 어떤 문을 움직이는지 볼 수 있다.

하이어라키 세팅 예시
실제로는 이런 식으로 배치할 수 있다.

WeightButton(1), (2)들은 조건이다.
DoorConditionGroup(1)은 조건을 모으는 관리자다.
WeightDoor(3)는 실제 결과다.
이렇게 나누니 각 오브젝트의 역할이 더 분명해졌다.
정리
이번 리팩터링의 핵심은 버튼이 문을 직접 열지 않게 만든 것이다.
이전 구조는 단순했지만, 조건이 여러 개가 되는 순간 확장하기 어려웠다.
그래서 조건과 결과 사이에 PuzzleConditionGroup을 두었다.
이전 구조:
Button → Door
새 구조:
ConditionSource → ConditionGroup → Result
이렇게 바꾸면서 얻은 장점은 다음과 같다.
1. 버튼 여러 개가 동시에 눌려야 하는 퍼즐을 만들 수 있다.
2. 버튼뿐 아니라 다른 조건도 같은 구조에 넣을 수 있다.
3. 조건이 바뀐 순간에만 검사하므로 불필요한 Update가 필요 없다.
4. 결과 이벤트를 Inspector에서 자유롭게 연결할 수 있다.
5. F3 디버그 오버레이로 연결 관계를 확인하기 쉬워졌다.
이번 작업으로 퍼즐 구조가 조금 더 확장 가능해졌다.
이제 버튼뿐 아니라 물 저장량, 레버, 특정 위치 도달 같은 조건들도 같은 방식으로 연결할 수 있다.
다음 글에서는 이 구조를 실제 퍼즐에 적용해서, 물을 일정량 이상 부었을 때 조건이 만족되는 오브젝트를 더 자세히 정리해보려고 한다.
'Unity - '한 우산 아래'' 카테고리의 다른 글
| 회전 플랫폼 만들기 - AutoRotator와 MovingPlatformSurface (1) | 2026.04.16 |
|---|---|
| 물 저장량으로 활성화되는 오브젝트 만들기 (0) | 2026.04.15 |
| 맨손으로 밀고 당길 수 있는 오브젝트 만들기 (0) | 2026.04.15 |
| Unity Profiler로 디버그 UI 프레임 드랍 원인 찾기 (0) | 2026.04.15 |
| 이단 점프와 우산 활공 구현하기 (0) | 2026.04.14 |