Unity
"컨트롤러 없이 손만 흔들면 도시에 전기가 들어온다" — 유아용 기후교육 게임을 Unity로 만들다가 미쳐버린 이야기
김*겸··수정됨 2026.07.22

뭘 만들었는가
파주시 유아 기후환경 교육용 인터랙티브 게임이다. 화면 앞에 손을 갖다 대면 MediaPipe Hand Landmarker가 손을 인식하고, 손을 흔드는 동작만으로 3가지 신재생 에너지(태양광, 수력, 풍력)를 생성한다. 에너지가 채워질 때마다 어두운 도시에 하나씩 불이 켜지고, 전부 채우면 도시 전체가 밝아진다.
게임 흐름은 딱 5단계다.
Step0: 손 감지 대기 (3초 충전)
↓
Step1: 태양광 — 손 상하 흔들기 12회
↓
Step2: 수력 — 손 상하 흔들기 12회
↓
Step3: 풍력 — 손 좌우 스윙 12회
↓
Step4: 완료 → 30초 후 자동 리셋
이 5단계를 enum 하나가 지배한다.
public enum State
{
Step0, // 대기
Step1, // 태양광
Step2, // 수력
Step3, // 풍력
Step4 // 완료
}
단순해 보이지만 이 5단계를 끊김 없이 전환시키는 게 지옥이었다.
1. 손 인식 — "정상적인 방법이 아닙니다"
MediaPipe 플러그인의 손 인식 결과에 직접 접근할 수 없다. API가 닫혀있고, 버전이 바뀔 때마다 구조도 바뀐다. 그래서 택한 방법이 C# Reflection — 런타임에 내부 필드를 뒤져서 손 좌표를 꺼내는 것이다.
// 2단계 Reflection: Runner → AnnotationController → HandLandmarkerResult
foreach (var field in type.GetFields(
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance))
{
if (field.FieldType.Name.Contains("AnnotationController"))
{
_annotationController = field.GetValue(comp) as Component;
var annoType = _annotationController.GetType();
foreach (var annoField in annoType.GetFields(...))
{
if (annoField.FieldType.Name.Contains("HandLandmarkerResult"))
{
_resultField = annoField; // 매 프레임 여기서 손 좌표를 꺼냄
}
}
}
}
정석은 아니다. 하지만 플러그인 소스를 수정하지 않고도 버전 독립적으로 동작한다. 이 판단 하나가 나중에 플러그인 업데이트할 때 목숨을 살렸다.
2. 제스처 감지 — "400픽셀의 싸움"
Step1, 2는 Y축 상하 흔들기, Step3은 X축 좌우 스윙이다. "좌표가 바뀌면 1회"로 세면? 손이 떨려도 카운트된다. 그래서 피크-골짜기 패턴 매칭을 만들었다.
void DetectWave(float currentY)
{
float delta = currentY - _lastY;
bool isMovingUp = delta > 0;
// 올라가다가 내려가기 시작 → 피크!
if (_wasMovingUp && !isMovingUp)
{
_peakY = _lastY;
_hasPeak = true;
}
// 내려가다가 올라가기 시작 → 골짜기! → 웨이브 판정
else if (!_wasMovingUp && isMovingUp)
{
if (_hasPeak)
{
float waveHeight = _peakY - valleyY;
if (waveHeight >= _minimumWaveHeight) // 400px 이상만 인정
{
WaveDetected(); // 카운트 +1
}
}
}
_wasMovingUp = isMovingUp;
}
핵심은 _minimumWaveHeight = 400px 이 한 줄이다. 이게 "손 떨림은 무시하고 진짜 흔들기만 인식"하는 전부. 좌우 스윙도 동일 로직을 X축에 적용한 것뿐이다.
그런데 아이 손은 작고 움직임도 작다. 성인 기준으로 맞추면 아이는 아무리 흔들어도 카운트가 안 되고, 너무 민감하면 가만히 있어도 올라간다. 현장에서 아이들 앞에 놓고 수치를 반복 조정하는 수밖에 없었다.
3. 에너지 파티클 — "베지어 곡선으로 날아가서 폭발한다"
에너지가 생성될 때 게이지만 올라가면 심심하다. 그래서 손에서 도시 오브젝트로 에너지 파티클이 곡선을 그리며 날아가는 이펙트를 만들었다. 태양광은 노랑, 수력은 파랑, 풍력은 청록.
// 2차 베지어 곡선 궤적 + 반짝임
Vector2 control = new Vector2(
(startPos.x + endPos.x) * 0.5f + curveOffset,
Mathf.Max(startPos.y, endPos.y) + Mathf.Abs(curveOffset) * 0.5f + 50f
);
Vector2 pos = QuadraticBezier(startPos, control, endPos, easedT);
// 날아가면서 반짝반짝
float flicker = 0.9f + Mathf.Sin(elapsed * 30f) * 0.1f;
도착하면 12개 파티클이 원형으로 터진다:
for (int i = 0; i < _explosionParticles; i++)
{
float angle = (360f / _explosionParticles) * i * Mathf.Deg2Rad;
angle += Random.Range(-0.3f, 0.3f); // 약간 불규칙하게
SpawnExplosionParticle(center, angle);
}
3가지 에너지를 별도 스크립트로 만들면 지옥이다. PlayEffect(1) → 노랑, PlayEffect(2) → 파랑, PlayEffect(3) → 청록. switch문 하나로 하나의 스크립트가 3가지 에너지를 전부 처리한다.
4. 패널 전환 — "Animator 이벤트와 코드 상태 머신의 결합"
화면 전환은 Animator가 페이드 애니메이션을 재생하고, 클립 끝에 달린 이벤트가 코드를 호출하는 구조다.
// 페이드가 끝나면 Animator 이벤트가 이 함수를 호출
public void AnimatorFadeOutPlay()
{
switch (_currentState)
{
case State.Step1:
StartCoroutine(DelayedTransition(_game1ToGame2Delay, () =>
{
_currentState = State.Step2;
_handWaveController.OnEventStartCoroutine();
PlaySound(_game2Sound);
}));
return;
case State.Step3:
StartCoroutine(DelayedTransition(_game3ToEndDelay, () =>
{
_currentState = State.Step4;
_handSwingController.OnEventStartCoroutine();
StartCoroutine(PlayEndSoundsSequence());
}));
return;
}
}
AnimatorFadeInPlay()가 화면을 덮고 → Animator 클립 끝에서 AnimatorFadeOutPlay() 호출 → 상태 전진 + 다음 컨트롤러 기동 + 사운드 교체. 패널 전환, 오디오, 상태 변경이 한 프레임에 동시에 일어나야 하는데, 타이밍이 한 틱이라도 어긋나면 깜빡임이 생긴다.
5. 함정들 — "전시 환경은 실험실이 아니다"
함정 1: 아이가 도망간다
전시장에서 아이가 중간에 떠나면 게임이 Step2에서 영원히 멈춘다. 그래서 15초간 제스처 입력이 없으면 자동으로 Step0으로 복귀시켰다.
void Update()
{
if (_currentState != State.Step4 && !_animator.GetBool("Fade"))
{
if (Time.time - _lastActivityTime > _inactivityTimeout) // 15초
{
ResetToReady();
}
}
}
각 컨트롤러에서 제스처가 감지될 때마다 ReportActivity()를 호출해서 타이머를 리셋한다. 단순하지만, 이게 없으면 전시 첫날에 게임이 멈춘 채로 방치된다.
함정 2: 리셋의 늪
Step4 완료 → 30초 대기 → 자동 리셋. 간단해 보이지만 리셋해야 할 것이 한두 개가 아니다.
public void ResetAllControllers()
{
_handPanelController.ResetProgress(); // 슬라이더 + 패널
_handWaveController.ResetWaveController(); // 태양광 카운트
_handWaveController2.ResetWaveController(); // 수력 카운트
_handSwingController.ResetSwingController(); // 풍력 카운트
// 중간 패널들도 확실히 비활성화
_handWaveController._panel3.SetActive(false);
_handWaveController2._panel4.SetActive(false);
_handSwingController._nextPanel.SetActive(false);
_currentPanel.SetActive(false);
_nextPanel.SetActive(true);
}
4개 컨트롤러 × 각각의 카운트, 슬라이더, 패널, 오브젝트 상태... 하나라도 빠지면 두 번째 플레이에서 버그가 터진다. 실제로 "두 번째 판에서 오브젝트가 이미 활성화된 채로 시작"되는 버그를 잡는 데 하루를 날렸다.
함정 3: 뒤에 지나가는 사람
전시 환경에서 뒤를 지나가는 사람의 손까지 잡아버린다. Step0에서 Y좌표 1500px 이상(화면 하단 특정 영역)에 손이 3초간 머물러야 게임이 시작되게 제한을 걸었다. "아무 데나 손 흔들면 시작"이 아니라 "정확한 위치에 의도적으로 손을 갖다 대야 시작"으로.
기술 스택
항목내용엔진Unity 2022.x + URP손 인식MediaPipe Hand Landmarker (Reflection 접근)웹캠640×480 @60FPS이펙트베지어 곡선 파티클 + 12방향 폭발전환Animator 이벤트 + 코드 상태 머신설계단일 씬, 5단계 enum, 게임별 설정 분리
결론
"손 흔들면 불 켜지는 게임"이라는 한 줄 기획서가 Reflection 2단계, 피크-골짜기 알고리즘, 베지어 곡선 파티클, 12가지 리셋 함수로 돌아왔다. 단순한 게임일수록 예외 처리가 복잡하고, 아이 대상일수록 튜닝이 잔인하다. 0.00007과 400px — 이 숫자 두 개에 게임의 생사가 갈린다.
그래도 아이가 손 한 번 흔들고 도시에 불이 켜질 때 "와!" 하는 그 소리 하나에 — 다 갚아진다.
읽기 도구
약 11분 읽기
이 글이 도움이 되었나요?