Unity/프로젝트

[유니티/UNITY] VR · 기업협약 팀 프로젝트 개발일지 겸 후기

도톰락 2026. 5. 15. 21:38

UNITY VR · 기업협약 팀 프로젝트

뛰뛰빵빵 VR (D2B2) 개발일지

이동 시스템 · 신호등 판정 · 미션 NPC · 타임라인 안전가이드 · 기획 오류 수정 경험

8인팀 · 9주 Unity 2021.3 + XRI 2.6.4 Photon PUN2 Firebase DOTween · UniTask
 
1

이동 시스템 — 3차에 걸친 구조 개선

 

VR 교육 콘텐츠에서 이동 시스템을 어떻게 설계할지가 초반의 가장 큰 고민이었습니다.
단순 조이스틱 이동은 VR 멀미를 유발하고, 몰입감을 해쳤습니다.
실제로 팔을 흔드는 동작으로 이동해야 한다는 방향을 잡고, 세 번에 걸쳐 구조를 개선했습니다.

1차 시도 이중 충돌체 스윙 판정 (ScControllerCtrl)

손이 Body 충돌체 → MoveFront 충돌체를 순서대로 통과해야만 이동 허용.
swingTime(0.5초) 이내에 양쪽이 모두 활성화되지 않으면 ResetFlags()로 초기화.
'우연한 접촉'과 '의도한 스윙'을 구분하기 위한 첫 번째 시도였습니다.

// ScControllerCtrl.cs
private float swingTime = 0.5f; // 타임윈도우
private bool isBody  = false;   // Body 콜라이더 접촉 여부
private bool isFront = false;   // MoveFront 콜라이더 접촉 여부

private void HandleTrigger(ref bool flag)
{
    flag = true;
    // 양쪽 모두 true여야 이동 허용
    if (isBody && isFront && !isMoving)
        isMoving = true;

    // swingTime 이후 자동 리셋
    if (resetRoutine != null) StopCoroutine(resetRoutine);
    resetRoutine = StartCoroutine(ResetFlags());
}

IEnumerator ResetFlags()
{
    yield return new WaitForSeconds(swingTime);
    isBody = false; isFront = false; isMoving = false;
}

⚠️ 발견된 문제점
① 손이 우연히 두 콜라이더를 스치면 이동 트리거 (오작동)
② 이동 방향이 XR Origin forward 기준 → 고개를 돌리면 엉뚱한 방향으로 이동
③ 단일 구조로는 양손 개별 판별 불가

2차 개선 양손 개별 판별 + 스틱 보조 이동 (ScPlayerCtrl.HandSwing)

ScControllerCtrlrightHand / leftHand 오브젝트에 각각 부착.
이동 방향을 transform.forward(플레이어 body 기준)로 고정해 고개 방향 문제 해소.
교육 대상이 어린이·노약자 포함이라 팔 스윙이 어려운 사용자를 위해 스틱 보조 이동도 병행 추가.

// ScPlayerCtrl.HandSwing.cs
[SerializeField] ScControllerCtrl rightHand;
[SerializeField] ScControllerCtrl leftHand;
CharacterController characterController;

private void Update()
{
    isRightHandMoving = rightHand.IsMoving;
    isLeftHandMoving  = leftHand.IsMoving;

    // 어느 한 손만 스윙해도 이동
    if (isRightHandMoving || isLeftHandMoving)
        Moving();
}

private void Moving()
{
    // body forward 기준 — 고개 방향 무관
    characterController.Move(
        transform.forward * Time.deltaTime * moveSpeed);
}

🔧 git 수정 이력: [fix] 이동 인식 버그 수정
초기에 leftHand.IsMoving 갱신 타이밍이 Update와 맞지 않아 이동이 씹히는 버그 발생.
InputMgr 이벤트 콜백 시점에 IsMoving 상태를 읽도록 수정해 해결.

추가 구현 손 올림 판별 & 고개 좌우 확인 (HandUp / LookAround)

손 높이: handPos.y > headTransform.position.y 조건으로 손 올림 인식.
고개 좌우 확인: Quaternion.Inverse(body) * head의 Y 오일러각으로 상대 회전각 계산.
50° 이상 → isLookRight / isLookLeft 활성화.

// HandUp: 손 Y > 머리 Y 이면 손 올림 인식
private void OnLeftHandUp(Vector3 handPos)
{
    float headY = headTransform.position.y;
    isLeftHandUp = handPos.y > headY;
}

// LookAround: 플레이어 body 기준 상대 회전각 계산
Quaternion relativeRot =
    Quaternion.Inverse(transform.rotation) * headTransform.rotation;
float currentLookAngle = relativeRot.eulerAngles.y;
if (currentLookAngle > 180f) currentLookAngle -= 360f; // -180 ~ 180
 
2

미션 시스템 — 하드코딩 제거 리팩토링

 

기업협약 프로젝트라 기획 변경이 잦았다. 미션 텍스트를 코드에 직접 하드코딩하다 보니 챕터나 미션 수가 바뀔 때마다 개발자가 코드를 수정해야 하는 병목이 생겼습니다.
git 커밋 기록: [fix] MissionList 키값 쌩으로 넣던거 수정 — 이 커밋이 구조 전환의 계기였습니다.

❌ Before — 챕터별 하드코딩
// 키를 직접 삽입 (실제 코드에 주석으로 남아있음)
string titleKey   = "Ch1_MissionText_0";
string mission1Key = "Ch1_MissionText_1";
string mission2Key = "Ch1_MissionText_2";

missionTextMap[titleKey]   = langMgr.GetText(titleKey);
missionTextMap[mission1Key] =
    $"1.{langMgr.GetText(mission1Key)}";

// Ch2도 동일하게 반복 → 중복 코드 누적
✅ After — enum 자동 필터링
// LocalizationTable 자동 탐색
foreach (var kv in table.Values)
{
    var parts = kv.Key.Split('_');
    if (parts.Length > 2 &&
        parts[0] == ChapterPrefix.ToString() &&
        parts[1] == CategoryFilter.ToString())
    {
        string text = parts[2] == "0"
            ? kv.Value
            : $"{parts[2]}. {kv.Value}";
        missionTextMap[kv.Key] = text;
    }
}
// 새 챕터 = CSV 행 추가만으로 끝

코드 수정 없이 확장

새 챕터/미션 = CSV 한 줄 추가만으로 UI 자동 반영

언어 자동 연동

SelectedLocale 기준 한국어·영어 자동 전환

중복 코드 제거

챕터별 반복 블록 완전 제거, 유지보수성 향상

 
3

미션 체크 UI · 클리어 UI

 

미션 텍스트 하나하나를 ScMissionBoxTextCheck 컴포넌트로 관리하고,
MissionBoxTextCheckType enum(none / Title / Base / Clear)으로 상태를 명확히 분리했습니다.

SetCheck() — 상태 변경 흐름

Title 타입 보호: 미션 제목이 실수로 완료 처리되는 버그를 원천 차단.
완료 시 취소선 + 회색 텍스트로 시각적 피드백. 내부 타입을 Clear로 전환해 클리어 UI와 자동 연동.

public void SetCheck(bool check)
{
    // Title 타입은 변경 차단 — 실수 방지
    if (checkType == MissionBoxTextCheckType.Title)
    {
        Debug.LogError("Cannot change Title type");
        return;
    }
    isCheck = check;
    // 완료 시 취소선 / 미완료 시 Normal
    tmp.fontStyle = check
        ? FontStyles.Strikethrough
        : FontStyles.Normal;
    // 타입 전환: Clear or Base
    checkType = check
        ? MissionBoxTextCheckType.Clear
        : MissionBoxTextCheckType.Base;
    SetTypeChange(checkType);
    // 색상: Base=White, Clear=Gray, Title=White+Bold
}
미션 클리어 UI — UniTask 비동기 연출

모든 미션 완료 시 GetMissionState() == true인 항목만 복제해 클리어 패널에 출력.
코루틴 대신 UniTask async/await로 팝업 타이밍 제어. 닉네임 개인화 메시지 포함.

private void Start()
{
    // Clear 상태 미션만 복제 출력
    foreach (var mission in missionListPanel.MissionListTests)
    {
        if (mission.GetMissionState()) // true = Clear 상태
            Instantiate(mission.gameObject, clearPanel.transform);
    }
    MissionClearImageFadeOut().Forget();
}

private async UniTask MissionClearImageFadeOut()
{
    await UniTask.Delay((int)(missionClearUIDelay * 1000));
    missionClearUI.gameObject.SetActive(true); // 팝업 등장
}
 
4

신호등 & 횡단보도 — 복합 조건 판정

 

📌 시행착오: 처음에는 횡단보도 진입 즉시 실패 처리 → 발이 조금이라도 빨간불 영역에 닿으면 바로 안전가이드 씬 전환.
테스트 시 불만 다수 발생. limitTime(1초) 판정 유예 코루틴 추가로 개선.
또한 [fix] 움직임 멈출 때도 OnPlayerMoving 이벤트 발생하도록 수정 커밋으로 isWalk false 처리 버그도 해결.

ScTrafficCtrl — 신호등 점멸 구현

Material의 Shader를 Lit ↔ Unlit으로 교체해서 점멸 효과 구현.
MaxTime / 4 이하 남으면 점멸 시작 + isBlink = true 설정.

IEnumerator ApplyBlink()
{
    bool highlightOff = false;
    while (true)
    {
        highlightOff = !highlightOff;
        m_MeshRenderer.material.shader =
            highlightOff ? unlitShader : litShader;
        yield return new WaitForSeconds(blinkInterval);
    }
}
ScCrossWalkCtrl — 4가지 복합 조건 판정

isColorRed · isBlink · isLeftHandUp · isWalk 네 가지 조건을 OnTriggerStay에서 순서대로 체크.

private void OnTriggerStay(Collider other)
{
    // 1순위: 빨간불 or 점멸 → 즉시 실패
    if (isColorRed || isBlink)
    {
        ScRespawn.Instance.Respawn();
        if (isBlink)
        { LoadScene(Sg04_TrafficBlink); return; }
        if (isColorRed)
        { LoadScene(Sg08_Jaywalking);  return; }
    }
    // 2순위: 손 올림 미션 클리어 체크
    if (Hand.isLeftHandUp && !isHandUp)
    { OnCheckMissionClear?.Invoke(); isHandUp = true; }

    // 3순위: 멈춤 감지 → 판정 유예 코루틴 (1초 후 미이동이면 실패)
    if (!isWalk && coroutine == null)
        coroutine = StartCoroutine(StopMoveTimeLimit());

    // 4순위: 손 내림 감지 → 판정 유예 코루틴
    if (!Hand.isLeftHandUp && coroutine == null)
        coroutine = StartCoroutine(HandDownTimeLimit());
}
조건 의미 이동 씬
isColorRed 신호등 빨간불 상태 Sg08_Jaywalking
isBlink 점멸 중 (전환 경고) Sg04_TrafficBlink
!isLeftHandUp 손 올림 미확인 Sg07_HandSignal
isWalk == false 횡단 중 멈춤 Sg05_SafeWalk
ScSafetyLineCtrl — 노란 안전선 판정 유예

안전선 진입 감지 → limitTime초 코루틴 시작.
시간 내 빠져나오면 OnTriggerExit에서 StopAllCoroutines()로 취소.
판정 유예: 발이 살짝 걸렸다가 즉시 빠져나오면 실패하지 않도록 수정한 이유는 테스터들이 이건 너무 가혹하다 애들인데 짜증나서 바로 꺼버릴거다 해서 추가된 기능입니다.

IEnumerator TimeLimit(Collider other)
{
    yield return new WaitForSeconds(limitTime);
    if (isSafety) // 아직도 안전선 위에 있으면 실패
        LoadScene(ScScene.Sg01_SafetyLine);
}

private void OnTriggerExit(Collider other)
{
    isSafety = false;
    StopAllCoroutines(); // 벗어나면 판정 취소
    coroutine = null;
}
 
5

GuideNPC & 타임라인 안전가이드

 
ScGuideNpc — DOTween 재귀 팔로우

왜 InverseTransformPoint를 썼나?

NPC를 플레이어 '뒤 1m' 위치에 고정하려면 월드 좌표로 저장하면 플레이어 회전 시 오프셋이 틀어집니다.
InverseTransformPoint로 로컬 오프셋 저장 → TransformPoint로 매 프레임 정확한 월드 목표 재계산.
DOTween OnComplete 재귀로 이동 중 계속 추적, 멈추면 자동 종료.

private void Awake()
{
    Manager.Instance.GameMgr.OnPlayerMoving += OnPlayerMoving;
    // 초기 상대 오프셋 저장 (플레이어 로컬 좌표계)
    offset = player.transform.InverseTransformPoint(transform.position);
}

private void FollowPlayer()
{
    // 매번 월드 목표 좌표 재계산
    Vector3 newTarget = player.transform.TransformPoint(offset);

    if (moveTween != null && moveTween.IsActive())
        moveTween.Kill(); // 기존 트윈 즉시 제거

    moveTween = transform
        .DOMove(newTarget, 1f)
        .SetEase(Ease.Linear)
        .SetAutoKill(true)
        .OnComplete(() =>
        {
            if (isMove)      // 아직 이동 중이면
                FollowPlayer(); // 재귀 호출
        });
}
타임라인 기반 안전가이드 연출

실패 후 단순 UI가 아닌 NPC가 직접 설명하는 영상형 교육이 필요했습니다.
Unity Timeline을 활용해 캐릭터 애니메이션 · 자막(커스텀 TextTrack) · 카메라 · SFX · VFX를 하나의 타임라인에서 시간축 기준으로 통합 관리.

GuideNpc 트랙

SignalReceiver로 애니메이션 전환 시점 제어. VFX(Stylized Smoke) 활성화도 Timeline 관리.

자막 트랙 (커스텀)

TextTrackHsy.cs — TMP TextMesh를 Timeline Clip으로 제어. 각 대사가 정확한 타임코드에 출력/숨김.

카메라 전환

ScreenFader로 씬 전환 페이드 연출. 모든 트랙이 동일 Timeline Asset에서 동기화.

도입 배경

기획자 요청 교육 흐름에 맞춰 정밀하게 타이밍을 잡으려면 Timeline이 최적이었다.

 
6

기획 오류 수정 경험

 

이 경험이 협업 방식을 가장 많이 바꿔놓은 사건이었습니다.

 
1

기획서 수령 & 첫 구현

기획서에 '횡단보도 진입 전 좌우 확인 후 이동'이라고 기재.
'고개를 좌우로 50° 이상 돌리면 이동 허용'으로 해석.
ChekLookAround() 코루틴으로 headTransform 회전각을 매 프레임 감지하도록 구현 완료.

2

중간 점검 — 기획자 직접 플레이

기획자가 시연하며 '손을 들고 좌우 확인해야 한다'고 설명.
'좌우 확인'이 고개 회전 단독이 아니라 '손을 올린 채 좌우 확인하는 복합 동작'임을 파악.
isLeftHandUp AND isLookAround 두 조건을 함께 충족해야 하는 구조로 변경 필요.

3

빠른 수정 & 협업 방식 개선

isLeftHandUp 조건을 AND로 추가해 수정 완료.
이후부터 기획안에 모호한 부분이 있으면 플로우차트로 먼저 작성해 기획자에게 공유 후 확인받는 방식으로 협업 개선.
기획·개발 간 해석 충돌을 사전 차단.

💡 이 경험에서 배운 것

기획서 한 줄이 개발자-기획자 간 다르게 해석될 수 있다.
구현을 시작하기 전에 '내가 이해한 동작이 맞나요?'를 플로우차트로 공유하고 확인받는 습관이 나중의 수정 비용을 가장 효과적으로 줄이는 방법이었습니다.

CONTACT

조영락

GitHub: github.com/jylack
Blog: wh011202.tistory.com
Email: jyl011202@gmail.com