Unity 6 · UGUI · 모바일 세로 1080 × 1920

주문받아라!!

손님의 주문표를 읽고 진열대에서 디저트를 집어 담는 2분짜리 주문 처리 게임. 마우스 · 터치 · 키보드 · 게임패드 네 가지 입력을 같은 화면에서 동등하게 지원하는 것을 목표로 만든 UI/UX 포트폴리오다.

10일제작 기간 · 2026.08.18 → 08.27
플레이 다운로드 Windows 64bit
Engine
Unity 6000.5.8f1
UI
UGUI + TextMeshPro
Input
Input System · UI 맵
Scene
단일 씬 · 패널 전환
Scripts
C# 32개 · 2,462줄
비동기
Awaitable · CancellationToken
외부 에셋 (저장소 미포함)
Free Casual GUI · Sweet Cakes Icon Pack
Font
케리스 케듀체 · Baloo (OFL)
Build
Windows 64bit · Releases
타이틀 화면. 시작 · 종료 버튼.
Screen_Title
플레이 화면. 상단 HUD, 주문 카드, 쟁반, 진열대.
Screen_Play
결과 화면. 점수 · 성공 · 실패와 등급.
Screen_Result

Play / Video

2분 플레이 영상

마우스 · 터치 · 키보드 · 게임패드 네 가지 입력으로 실제 플레이하는 모습을 담았다.

Core / Rules

규칙은 MonoBehaviour 바깥에 둔다

판정·시계·점수는 UnityEngine 참조가 없는 순수 C# 클래스 세 개(OrderSession, PlayClock, ScoreBoard)로 분리했다. GamePlayController는 이 셋을 소유하고 결과를 이벤트로 흘려보내기만 한다. 화면은 이벤트만 구독하므로 규칙과 표시가 서로를 모른다.

ShelfButton진열대 버튼 · 마우스 / 터치 / Submit / 숫자키
→
GamePlayController.Pick()진행 중 · 판정 중이 아닐 때만 통과
→
OrderSession.Pick()남은 개수 사전에서 차감
→
PickResult세 갈래로 갈림
Accepted

주문에 있는 품목. 쟁반에 담고 OnTrayChanged 발행.

Completed

마지막 한 개였다. 성공 판정 → 점수 +100.

Rejected

주문에 없거나 이미 다 담은 품목. 실패 판정 → 점수 −20.

Core/Rules/OrderSession.cs손님 한 명의 주문 판정 규칙
/// <summary>주문에서 아직 안 담은 개수.</summary>
private readonly Dictionary<DessertType, int> _remaining = new();

public bool IsComplete
{
    get
    {
        foreach (int left in _remaining.Values)
        {
            if (left > 0)
            {
                return false;
            }
        }

        return true;
    }
}

public PickResult Pick(DessertType type)
{
    if (!_remaining.TryGetValue(type, out int left) || left <= 0)
    {
        return PickResult.Rejected;
    }

    _remaining[type] = left - 1;
    _tray.Add(type);

    return IsComplete ? PickResult.Completed : PickResult.Accepted;
}

설계 판단

고친 자리마다 왜 그렇게 골랐는지

버그를 없애는 방법은 대개 여러 개다. 아래 네 건은 그중 무엇을 고르고 무엇을 버렸는지가 코드보다 더 중요했던 자리다.

Core/GamePlayController.cs 비동기 경합 · 세대 토큰

0.8초 뒤에 깨어난 코루틴이 새 판의 손님을 넘겨 버린다

판정 직후 곧바로 다음 손님으로 넘기면 무엇을 잘못 눌렀는지 볼 시간이 없다. 그래서 0.8초 지연 + 진열대 잠금을 넣었는데, 이 대기가 화면 수명보다 오래 살아남는 것이 문제였다.

증상
결과 화면에서 다시하기를 0.8초 안에 누르면, 새 판이 시작되자마자 손님이 한 명 그냥 넘어간다.
원인
await는 화면이 바뀌어도 대기를 취소하지 않는다. 이전 판의 대기가 뒤늦게 깨어나 새 판의 NextCustomer()를 호출한다.
판단
CancellationTokenSource를 하나 더 두는 대신 정수 세대 토큰을 골랐다. 취소는 대기를 끊을 뿐 "이 대기가 어느 판의 것인가"를 알려주지 않는다. 토큰을 비교하면 깨어난 쪽이 스스로 자기가 구세대임을 알고 조용히 빠진다.
GamePlayController.Prepare() / BeginJudgeDelay()발췌
/// <summary>판정 대기가 끝났을 때 그 사이 새 판이 시작됐는지 구분하는 표식.</summary>
private int judgeToken;

public void Prepare()
{
    clock.Reset(playTimeSeconds);
    SetRunning(false);
    // …

    // 진행 중이던 판정 대기를 무효.
    // 다시하기를 0.8초 안에 누르면 이전 판의 대기가 뒤늦게 깨어나 새 판의 손님을 넘겨 버린다.
    judgeToken++;
    SetJudging(false);

    NextCustomer();
    // …
}

/// <summary>
/// 바로 다음 손님으로 넘어가지 않고 딜레이를 줌.
///
/// 연타하면 실패 → 새 주문 → 또 실패가 0.1초 안에 줄줄이 일어나는 현상 방지.
/// 실패한 순간 주문이 바뀌면 무엇을 잘못 눌렀는지 확인 불가능하므로 플레이어가 알 수 있도록 약간의 딜레이 줌.
/// </summary>
private async void BeginJudgeDelay()
{
    int token = ++judgeToken;
    SetJudging(true);

    try
    {
        await Awaitable.WaitForSecondsAsync(judgeDelaySeconds, destroyCancellationToken);
    }
    catch (OperationCanceledException)
    {
        return;
    }

    // 대기 중에 Prepare() 가 불렸다면(다시하기) 이 대기는 이미 다른 판이 되었으므로 대기를 종료.
    if (token != judgeToken)
    {
        return;
    }

    NextCustomer();
    SetJudging(false);
}
Screens/UIInputRouter.cs 프레임 순서 · 입력 라우팅

Input System 콜백과 EventSystem이 같은 프레임에서 서로를 덮어쓴다

마우스로 누르면 키보드 선택 테두리를 지우고, 방향키를 넣으면 되살린다. 개념은 두 줄인데 콜백에서 곧바로 실행하면 둘 다 실패한다. 액션 콜백은 입력 갱신 단계에서, EventSystem은 Update에서 돌기 때문이다.

증상 A
콜백에서 SetSelectedGameObject(null)을 불러도 테두리가 그대로 남는다.
증상 B
마우스로 클릭한 뒤 Enter를 한 번 치면 포커스가 복구되는 동시에 그 버튼이 즉시 실행된다. 타이틀에서 Enter 한 번에 게임이 시작되고, 확인 팝업은 열리자마자 닫힌다.
원인
A는 콜백이 지운 선택을 같은 프레임 뒤쪽의 Selectable.OnPointerDown이 되살린다. B는 모듈이 Submit을 처리 시점의 currentSelectedGameObject에게 보내기 때문이다.
판단
둘 다 한 프레임 뒤로 미루는 것으로 해결된다. 콜백은 요청 플래그만 세우고 실제 반영은 LateUpdate가 한다. 그 프레임 입력 하나를 잃지만, 첫 입력이 포커스 복구에 소비되는 것은 콘솔 UI의 일반적인 동작이라 그대로 뒀다.
UIInputRouter.LateUpdate()주석 원문 그대로
/// <summary>
/// 선택 변경을 한 프레임 안에서 "모듈보다 늦게" 적용.
///
/// EventSystem 은 Update 에서 돌고 LateUpdate 는 그 뒤다. 액션 콜백은 반대로
/// Update 보다 앞선 입력 갱신 단계에서 오므로, 콜백에서 곧바로 선택을 바꾸면
/// 같은 프레임에 모듈이 그 결과를 덮어쓰거나 잘못 소비한다.
///
///   해제 - Selectable.OnPointerDown 이 자기 자신을 다시 선택한다.
///          콜백에서 null 로 지워도 그 직후 되살아나 테두리가 그대로 남는다.
///   복구 - 모듈은 Submit 을 "처리 시점의 currentSelectedGameObject" 에게 보낸다.
///          콜백에서 미리 복구해두면 방금 복구한 버튼이 그 프레임에 즉시 눌린다.
///          (마우스로 클릭한 뒤 Enter 를 치면 첫 버튼이 저절로 실행된다)
///
/// LateUpdate 로 미루면 그 프레임 입력은 모듈이 선택 없음으로 처리해 아무 일도
/// 일어나지 않고, 다음 프레임부터 정상 조작이 된다. 첫 입력이 복구에 쓰이는
/// 콘솔 UI 의 일반적인 동작과 같다.
/// </summary>
private void LateUpdate()
{
    EventSystem eventSystem = EventSystem.current;
    if (eventSystem == null || screenFlow == null)
    {
        clearRequested = false;
        restoreRequested = false;
        return;
    }

    if (clearRequested)
    {
        clearRequested = false;
        restoreRequested = false;
        ClearSelection(eventSystem);
        return;
    }

    if (restoreRequested)
    {
        restoreRequested = false;
        RestoreSelection(eventSystem);
    }
}
Screens/ScreenFlowController.cs · UIStateBase.cs 스택 상태 머신 · 잠금 경로 분리

일시정지 중에 위 방향키가 아래 화면의 버튼을 누른다

화면 전환은 Stack<UIStateBase> 하나로 처리한다. SetScreen은 스택을 비우고 새로 쌓고, Push/Pop은 위에 겹친다. 결과 화면이 최종 점수를 읽어야 해서 아래 화면을 끄지 않는 것이 의도였는데, 그 때문에 아래 버튼이 살아 있었다.

증상
일시정지 중 위 방향키 → 선택이 Screen_Play/TopNav/PauseButton으로 넘어가고 Enter로 실제 눌린다. 일시정지가 스택에 한 겹 더 쌓여 계속하기를 두 번 눌러야 빠져나온다.
원인
전체 화면 Image의 Raycast Target은 포인터만 막는다. Navigate는 레이캐스트를 쓰지 않고 Selectable.FindSelectable이 캔버스 전체를 훑으므로 패널 경계라는 개념이 없다.
판단
버튼의 interactable을 직접 만지지 않고 CanvasGroup을 썼다. 진열대 버튼은 ShelfView가 판정 잠금으로 껐다 켜는데, 여기서 값을 직접 되돌리면 판정 중이던 잠금이 풀린다. CanvasGroup은 별개의 관문이라 두 조건이 모두 참일 때만 눌린다.
Screens/UIStateBase.csConfirmPopup이 Screen_Pause의 자손이라는 함정
[RequireComponent(typeof(CanvasGroup))]
public abstract class UIStateBase : MonoBehaviour, IState
{
    public void SetInteractable(bool value)
    {
        if (group == null)
        {
            group = GetComponent<CanvasGroup>();

            // ConfirmPopup 은 Screen_Pause 의 자손이다. 위에 열렸다는 이유로 아래(Screen_Pause)를
            // 잠그면 자기 자신까지 같이 잠긴다. 부모 그룹을 무시해야 팝업 버튼이 살아 있다.
            group.ignoreParentGroups = true;
        }

        group.interactable = value;
    }
}
ScreenFlowController.PushInternal() / PopTop()겹칠 때마다 아래를 잠근다
private void PushInternal(UIStateBase next)
{
    UIStateBase below = Top;

    if (below != null)
    {
        below.SetInteractable(false);
    }

    stack.Push(next);

    next.gameObject.SetActive(true);
    next.SetInteractable(true);
    next.Enter();

    ApplyFocus();
}

private void PopTop()
{
    if (stack.Count == 0)
    {
        return;
    }

    UIStateBase top = stack.Pop();
    top.Exit();
    top.gameObject.SetActive(false);

    UIStateBase below = Top;

    if (below != null)
    {
        below.SetInteractable(true);
    }
}
Screens/FocusRing.cs 플래그 겸용이 만든 버그

화면을 넘겼는데 테두리가 이전 화면의 자리에 남는다

Unity 기본 ColorBlock은 Selected와 Highlighted 색이 완전히 같아 두 상태를 색으로 구분하는 것 자체가 불가능했다. 버튼 14개에 색을 하나씩 다시 넣는 대신, 선택된 하나를 따라다니는 테두리 오브젝트를 하나만 만들었다. 선택은 언제나 하나뿐이므로 테두리도 하나면 된다.

증상
타이틀에서 키보드로 시작을 누르면, 카운트다운이 끝난 뒤 테두리가 화면 중앙에 나타난다. MenuButton_Start의 자리가 그대로 남은 것이다.
원인
매 프레임 돌던 Fit()을 "선택이 바뀔 때만"으로 줄이면서 lastSelected를 선택 추적과 맞춤 완료 표시 두 용도로 겸용했다. 새 버튼으로 바뀌며 표시를 소비했는데, 그 버튼이 카운트다운 중이라 IsInteractable()이 거짓이어서 Fit() 전에 빠져나갔다.
판단
needsFit을 lastSelected와 분리해, 실제로 Fit()을 실행한 뒤에만 내린다. 화면 전환마다 초기화를 심는 방법도 검토했지만, 증상이 드러나는 시점이 전환이 아니라 카운트다운 종료라 같은 문제가 남고, 전환 경로 세 곳 중 하나만 빠뜨려도 조용히 재발한다.
FocusRing.Fit()Canvas Scaler 배율을 되돌리는 부분
private void Fit(RectTransform target)
{
    // GetWorldCorners 순서: 0 좌하 · 1 좌상 · 2 우상 · 3 우하
    target.GetWorldCorners(corners);

    rect.position = (corners[0] + corners[2]) * 0.5f;

    // Canvas Scaler 가 캔버스 전체를 확대·축소하므로 월드 길이를 그대로 쓰면
    // 해상도가 바뀔 때 테두리 크기가 어긋난다. 캔버스 배율로 나눠 되돌린다.
    float scale = canvas != null ? canvas.transform.lossyScale.x : 1f;

    if (Mathf.Approximately(scale, 0f))
    {
        scale = 1f;
    }

    float width = Vector3.Distance(corners[0], corners[3]) / scale;
    float height = Vector3.Distance(corners[0], corners[1]) / scale;

    rect.sizeDelta = new Vector2(width + padding * 2f, height + padding * 2f);
}

최적화

매 프레임 도는 코드에서 뺄 수 있는 것

타이머는 매 프레임 갱신되지만, 그 값을 받는 쪽이 전부 매 프레임 일할 필요는 없다. 아래 네 건은 "언제 갱신을 건너뛸 수 있는가"를 기준으로 정리한 것이다.

초 단위가 바뀔 때만 문자열을 만든다

TimerText를 매 프레임 갱신하면 초당 60번 int.ToString()이 새 문자열을 할당한다. 표시되는 값은 초당 1번만 바뀌므로 lastDisplayedSecond와 비교해 같으면 즉시 반환한다.

할당 60/s → 1/s

경고 색은 상태가 바뀐 순간에만

게이지 점멸은 매 프레임 Lerp가 필요하지만 글자 색은 켜짐/꺼짐 두 값뿐이다. lastDangerState로 경계를 넘는 프레임에만 timerText.color를 쓴다. TMP는 색 대입마다 메시를 다시 올린다.

메시 갱신 60/s → 경계 1회

점멸 위상을 남은 시간으로 잡는다

Mathf.PingPong(remainingTime * pulseSpeed, 1f). Time.time을 쓰면 일시정지 중에도 점멸이 계속 돌아 시간이 흐르는 것처럼 보인다. 남은 시간을 위상으로 쓰면 시계가 멈출 때 점멸도 함께 멈춘다.

일시정지 중 오해 제거

Raycast Target 45개를 껐다

씬의 Graphic 64개 중 61개가 Raycast Target 기본값(켜짐)을 그대로 쓰고 있었다. 실제 클릭 대상은 버튼 14개뿐이다. 남긴 16개는 버튼 14개와 뒤를 막아야 하는 전체 화면 차단막 2개다.

레이캐스트 후보 61 → 16

아이콘은 풀에서 꺼내되 순서를 다시 잡는다

ObjectPool<DessertIconView>로 주문 카드와 쟁반이 아이콘을 공유한다. 재사용된 것은 예전 sibling index를 그대로 들고 오므로 SetAsLastSibling()이 없으면 Layout Group이 뒤섞인 순서로 배치한다.

Instantiate / Destroy 0회

토스트는 일부러 풀링하지 않았다

판정 잠금(0.8초)이 표시 시간(0.5초)보다 길어 토스트가 동시에 둘 존재할 수 없다. 인스턴스 하나를 켜고 끄면 충분하고, 생성·파괴가 없으니 풀이 할 일이 없다. 안 쓰는 것도 판단이다.

오브젝트 1개 · 코드 없음
Views/HudController.cs갱신 빈도를 표시 단위에 맞춘다
// 마지막으로 화면에 쓴 정수 초. -1 은 "아직 아무것도 안 썼다" 는 뜻.
private int lastDisplayedSecond = -1;

// 경고 상태가 바뀐 순간에만 글자 색을 변경. -1 은 아직 모름.
private int lastDangerState = -1;

private void HandleTimeChanged(float remainingTime, float totalTime)
{
    if (timerGauge != null)
    {
        // Slider 의 MaxValue 가 1 이므로 비율을 그대로 넣는다. 매 프레임 갱신해도 된다.
        timerGauge.value = totalTime > 0f ? remainingTime / totalTime : 0f;
    }

    ApplyTimeWarning(remainingTime);

    int second = Mathf.CeilToInt(remainingTime);
    if (second == lastDisplayedSecond)
    {
        return;
    }

    lastDisplayedSecond = second;

    if (timerText != null)
    {
        timerText.text = second.ToString();
    }
}

private void ApplyTimeWarning(float remainingTime)
{
    bool isDanger = remainingTime <= dangerTimeThreshold;

    if (gaugeFillImage != null)
    {
        gaugeFillImage.color = isDanger
            ? Color.Lerp(gaugeNormalColor, dangerColor, Mathf.PingPong(remainingTime * pulseSpeed, 1f))
            : gaugeNormalColor;
    }

    int dangerState = isDanger ? 1 : 0;
    if (dangerState == lastDangerState)
    {
        return;
    }

    lastDangerState = dangerState;

    if (timerText != null)
    {
        timerText.color = isDanger ? dangerColor : timerNormalColor;
    }
}

Awaitable

연출은 화면보다 오래 살면 안 된다

코루틴 대신 Unity 6의 Awaitable을 썼다. 대기 하나하나가 취소 토큰을 들고 다니고, 오브젝트가 파괴되는 경우까지 destroyCancellationToken으로 함께 끊는다. 되돌려야 하는 상태가 있으면 try/finally로 묶는다.

Views/CountdownView.cs호출자 토큰 + destroyCancellationToken을 하나로 묶는다
/// <summary>
/// 3 → 2 → 1 → Start! 를 보여주고 패널을 끔.
/// 중간에 화면을 떠나면 token 이 취소되어 OperationCanceledException.
/// </summary>
public async Awaitable PlayIntro(CancellationToken token)
{
    await ShowSteps(introSteps, introStepSeconds, true, token);
}

public async Awaitable PlayTimeout(CancellationToken token)
{
    await ShowSteps(new[] { timeoutMessage }, timeoutSeconds, false, token);
}

private async Awaitable ShowSteps(string[] steps, float secondsPerStep, bool hideWhenDone,
    CancellationToken token)
{
    if (countPanel == null || countText == null || steps == null)
    {
        return;
    }

    // 오브젝트가 파괴되는 경우까지 같이 끊어야 파괴된 패널을 건드리지 않는다.
    using CancellationTokenSource linked =
        CancellationTokenSource.CreateLinkedTokenSource(token, destroyCancellationToken);

    SetPanelActive(true);

    try
    {
        foreach (string step in steps)
        {
            countText.text = step;
            await Awaitable.WaitForSecondsAsync(secondsPerStep, linked.Token);
        }
    }
    finally
    {
        if (hideWhenDone)
        {
            SetPanelActive(false);
        }
    }
}
hideWhenDone
시작 카운트다운은 끝나면 패널을 끄고, 타임아웃은 켠 채로 둔다. 끄면 페이드가 시작되기 전 한 프레임 동안 플레이 화면이 드러난다.
패널 재사용
Timeout!!은 시작 카운트다운과 같은 CountPanel을 쓴다. 위치·크기·폰트를 두 번 맞추지 않는다.
연출 중 잠금
시작과 종료를 PlayState.isPlayingSequence 하나로 묶었다. 플래그를 두 개 두면 한쪽만 검사하는 실수가 난다.
Views/ToastController.cs이전 대기를 반드시 끊고 새로 시작
private async void RunHide()
{
    CancelHide(); // 이전 대기를 반드시 끊어야 됨. 안 끊으면 두 번째 Toast가 첫 번째의 타이머에 걸려 일찍 사라짐.

    // 오브젝트가 파괴되는 경우까지 같이 끊어 파괴된 Toast를 건드리지 않게.
    hideCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);

    try
    {
        await Awaitable.WaitForSecondsAsync(showSeconds, hideCts.Token);
    }
    catch (OperationCanceledException)
    {
        // 새 토스트가 덮었거나 화면을 떠남.
        return;
    }

    SetVisible(false);
}

입력

네 가지 입력을 같은 화면에서

포인터 계열(마우스·터치)과 선택 계열(키보드·게임패드)은 화면을 다루는 방식이 다르다. 포인터에는 "지금 선택된 것"이 없고, 선택 계열에는 "지금 가리키는 것"이 없다. 두 갈래를 한 화면에서 섞으면 서로의 상태를 지운다.

입력동작구현 경로
마우스 좌클릭 / 터치버튼 누르기, 진열대에서 디저트 담기UGUI Button onClick — 터치도 동일 경로
방향키 · WASD / 좌스틱 · D-pad선택 이동 (Navigate)InputSystemUIInputModule
Enter / 게임패드 A확인 (Submit)InputSystemUIInputModule
Esc / 게임패드 BPlay에서는 일시정지, 팝업이 열려 있으면 팝업만 닫기UIInputRouter가 액션 직접 구독 — 선택된 오브젝트와 무관해야 하므로
숫자키 1 ~ 5진열대 버튼 1~5번 즉시 누르기GetBindingIndexForControl로 슬롯을 역산 후 submitHandler 실행
Views/ShelfView.cs단축키가 포커스까지 함께 옮긴다
/// <summary>
/// 숫자 키 1~5 로 진열대 버튼을 곧바로 누를 수 있게 키 입력 대응.
/// </summary>
private void HandleSlotShortcut(InputAction.CallbackContext context)
{
    if (shelfButtons == null || EventSystem.current == null)
    {
        return;
    }

    int index = context.action.GetBindingIndexForControl(context.control);

    if (index < 0 || index >= shelfButtons.Length || shelfButtons[index] == null)
    {
        return;
    }

    // 잠금 중에는 FocusRing도 옮기지 않게.
    if (!shelfButtons[index].Button.IsInteractable())
    {
        return;
    }

    GameObject target = shelfButtons[index].gameObject;

    EventSystem.current.SetSelectedGameObject(target);

    ExecuteEvents.Execute(target, new BaseEventData(EventSystem.current), ExecuteEvents.submitHandler);
}

점검 기록

기대 · 실제 · 원인 · 수정 · 재확인

발견한 문제 12건을 다섯 칸 형식으로 남겼다. "고쳤다"가 아니라 다시 확인한 결과까지 적는 것이 형식의 핵심이다. 게임패드는 실물 기기가 없어 미검증으로 남겼다.

#구분점검 항목재확인
1입력마우스 클릭 후 선택 하이라이트 잔존○
2입력마우스 클릭 직후 게임패드 · 키보드 입력 무반응△ 키보드만
3입력선택 복구 직후 첫 버튼이 저절로 눌림○
4입력EventSystem이 패키지 기본 액션 에셋을 보고 있었음○
5입력진열대에서 상단 PauseButton으로 이동 불가○
6입력일시정지 중 위 방향키가 아래 화면의 PauseButton에 도달○
7입력클릭하지 않는 Graphic이 포인터 입력 후보로 검사됨○
9HUD영업시간 게이지가 키보드 · 게임패드 포커스를 가로챔○
10피드백판정 알림 토스트의 글자가 배경 없이 떠서 읽히지 않음○
11피드백어느 버튼에 포커스가 있는지 구별되지 않음○
12피드백화면 전환 후 포커스 링이 이전 화면의 위치에 남음○
13전환영업시간 종료 시 결과 화면으로 즉시 전환됨○

원안에서 바꾼 것

기획 원안을 그대로 만들지 않고, 구현 가능성 검토에서 13건을 바꿨다. 아래는 그중 이유가 분명한 다섯 건이다.

원안문제변경
담기 → 제출 버튼으로 판정주문 1건에 최소 2탭 필요, 조작 리듬이 끊김즉시 판정
판정 즉시 다음 손님왜 틀렸는지 볼 시간이 없고, 연타 시 주문이 연쇄 실패0.8초 지연 + 진열대 잠금
쟁반 슬롯 클릭으로 담기 취소즉시 판정에서는 담긴 것이 항상 정답이라 뺄 이유가 없음쟁반을 표시 전용으로
랭크 산식에 점수·성공·실패를 모두 사용score ≡ successCount × 100이라 점수는 독립 정보가 없음처리량·정확도 두 축만
10초 이하 → Danger 색 정적 전환한 번 바뀌고 나면 시간이 흐른다는 느낌을 주지 못함점멸. 위상은 남은 시간

리소스 규칙

enum 순서가 곧 스프라이트 인덱스다

DessertType의 선언 순서를 DessertTable 스프라이트 배열의 인덱스로 그대로 쓴다. 조회가 sprites[(int)type] 한 줄로 끝나는 대신, 순서를 바꾸면 모든 아이콘이 어긋난다. 그래서 설계 문서에 순서 고정을 명시하고, Awake()에서 배열 길이를 검사해 경고를 남긴다.

초코 케이크ChocoCake0
키위 홀케이크KiwiBigCake1
레몬 사각 케이크LemonRectangularCake2
레인보우 컵케이크RainbowCupCake3
해골 조각 케이크SkullCakePiece4

아이콘: Sweet Cakes Icon Pack (Unity Asset Store). 저장소에는 포함하지 않았다.

Core/RankTable.cs모든 입력이 반드시 하나에 걸리게
[Header("등급 테이블")]
[SerializeField]
private RankRule[] rules =
{
    new RankRule { label = "A", minRankScore = 2800, color = new Color(1f, 0.824f, 0.290f) },
    new RankRule { label = "B", minRankScore = 2200, color = new Color(0.435f, 0.820f, 1f) },
    new RankRule { label = "C", minRankScore = 1600, color = new Color(0.494f, 0.827f, 0.494f) },
    new RankRule { label = "D", minRankScore = 1000, color = new Color(1f, 0.690f, 0.404f) },
    new RankRule { label = "E", minRankScore = 400, color = new Color(1f, 0.541f, 0.420f) },

    // 최하위는 int.MinValue 로 둬서 모든 입력이 반드시 하나에 걸리게. 빈 문자열이 표시될 여지를 없앰.
    new RankRule { label = "F", minRankScore = int.MinValue, color = new Color(0.604f, 0.627f, 0.651f) }
};

/// <summary>
/// rankScore = 성공 × successWeight − 실패 × failPenalty.
/// 음수가 될 수 있음(0성공 20실패 = −1000). 정상이며, 이럴 경우 결과는 F.
/// </summary>
Views/AspectRatioLetterbox.cs1080 × 1920을 벗어나는 만큼 레터박스
private void Apply()
{
    lastWidth = Screen.width;
    lastHeight = Screen.height;

    // 인스펙터를 비워두거나 창이 최소화된 순간에 0 나눗셈이 나지 않게
    if (targetAspect.x <= 0f || targetAspect.y <= 0f || lastWidth <= 0 || lastHeight <= 0)
    {
        return;
    }

    float target = targetAspect.x / targetAspect.y;
    float current = (float)lastWidth / lastHeight;
    float scale = current / target;

    if (scale < 1f)
    {
        camera.rect = new Rect(0f, (1f - scale) * 0.5f, 1f, scale);
    }
    else
    {
        float width = 1f / scale;
        camera.rect = new Rect((1f - width) * 0.5f, 0f, width, 1f);
    }
}

작업 순서

2026.08.18 → 08.27

08.18Day 1

프로젝트 셋업 · 씬 생성 · UI 1차 배치

Unity 6 프로젝트 구성, 기준 해상도 1080×1920 확정, 케이크 아이콘 에셋 반입.

08.19Day 2

Canvas Scaler · 버튼 반응 HUD · 뼈대 스크립트

Match 0(Width 기준) 결정. 화면 목록과 전환 흐름을 설계 문서로 먼저 고정.

08.20Day 3

스택 기반 UI Flow 상태 머신

ScreenFlowController + UIStateBase. 인스펙터 배선과 버튼 이벤트 연결.

08.21Day 4—6

설계 검토 · 구현 가능성 점검

커밋 없음. 원안 13건을 바꾸기로 결정한 구간.

08.24Day 7

게임 규칙 구현 · 토스트 · 오브젝트 풀 · 입력 배선 · 포커스 링

하루에 커밋 7개. OrderSession · PlayClock · ScoreBoard 분리, DessertIconPool, FocusRing 신설.

08.25Day 8

코드 정리 · 주석 보강 · 점검 문서

점검 12건을 기대/실제/원인/수정/재확인 형식으로 기록. GUI 가이드와 구현 가능성 검토 작성.

08.26Day 9

README · 에셋 리임포트 · 용량 정리

PSD · SVG 원본 제거. 스크린샷 3종 반영.

08.27Day 10

제출

문서 4종(설계 · GUI 가이드 · 점검 체크리스트 · README) 최종 확인.

구조

스크립트 32개 · 2,462줄

Core는 규칙, Screens는 화면 전환과 입력, Views는 표시. 아래로만 참조하고 위로는 이벤트만 흘린다.

Core — 규칙

  • GamePlayController.cs242

    규칙 세 개를 소유하고 이벤트 9종을 발행

  • Rules/OrderSession.cs68

    주문 판정. 남은 개수 사전

  • Rules/ScoreBoard.cs47

    점수 · 성공 · 실패. Score ≥ 0

  • Rules/PlayClock.cs35

    카운트다운. 만료 프레임만 true

  • Rules/PickResult.cs17

    Accepted · Completed · Rejected

  • RankTable.cs86

    성공 · 실패 두 축으로 A~F 판정

  • OrderGenerator.cs40

    고정 3개 주문 생성

  • DessertTable.cs33

    enum → 스프라이트

Screens — 전환과 입력

  • ScreenFlowController.cs219

    Stack 기반 화면 전환 · 페이드 · 포커스

  • UIInputRouter.cs185

    Cancel · 선택 해제 / 복구를 LateUpdate에서

  • FocusRing.cs174

    선택된 버튼을 따라다니는 테두리 하나

  • PlayState.cs164

    인트로 · 타임아웃 연출과 취소

  • UIStateBase.cs39

    CanvasGroup 잠금 · ignoreParentGroups

  • ScreenFade.cs45

    unscaledDeltaTime 기반 페이드

  • PauseState.cs42

    진입에 시계 정지, 이탈에 재개

Views — 표시

  • HudController.cs158

    점수 · 게이지 · 경고 색

  • ToastController.cs133

    성공 / 실패 알림. 인스턴스 1개

  • ShelfView.cs126

    진열대 잠금 · 숫자키 단축키

  • OrderCardView.cs96

    종류별로 묶어 ×N 표시

  • DessertIconPool.cs88

    주문 카드와 쟁반이 공유하는 풀

  • TrayView.cs81

    담긴 것만 표시. 클릭 없음

  • CountdownView.cs81

    3·2·1·Start! / Timeout!!

  • AspectRatioLetterbox.cs61

    비율 벗어난 만큼 카메라 rect 축소