프로젝트 개요

늦잠 잔 여행 당일. 8시 30분에 눈을 떴고 9시 기차를 타야 한다. 캐리어 공간은 부족하다. 무엇을 넣고 무엇을 포기할지가 이 게임의 유일한 의사결정이다.

항목내용
개발 기간2026.07.26 ~ 07.30 — 한 달 일정의 게임잼을 5일에 완주
엔진Unity 6000.0.68f1 / URP 17.0.4 / Input System 1.18
빌드WebGL (WebGL2 · Brotli + Decompression Fallback) · Windows Standalone
역할1인 개발 — 기획 · 시스템 설계 · C# 구현 · 연출 · 프로시저럴 레벨 생성 · 배포
주제INVENTORY
리소스프리팹 · 가구 · 아이템 전부 코드 생성. 폰트만 무료 에셋(Pretendard, OFL)
열린 캐리어가 놓인 방 전경
게임이 시작되는 방. 열린 캐리어와, 큰 짐을 날라다 놓는 현관 앞 자리가 보인다.

인벤토리를 장식이 아니라 게임으로

주제가 인벤토리였다. 대부분의 게임에서 인벤토리는 창을 열고 아이템을 확인하는 부속품이다. 그걸 게임 그 자체로 만들려면 인벤토리에서 내리는 결정이 곧 게임의 결정이어야 한다. 그래서 설계를 이렇게 잡았다.

손은 개수가 아니라 칸으로 센다

타이머와 체크리스트, 손 4칸 바가 보이는 탐색 화면
탐색 화면. 위쪽에 남은 시간, 오른쪽에 '꼭 챙길 것' 체크리스트, 아래쪽에 손 4칸 바가 있다.

5×6 격자에 2단으로 쌓는다

물건마다 차지하는 모양과 두께가 다르다. 얇은 물건 위에는 다른 물건을 얹을 수 있고, 밑에 깔린 것은 위를 먼저 빼야 꺼낼 수 있다. 배치가 되돌리기 비용을 만든다.

5×6 격자에 물건을 배치하는 정리 화면
정리 화면. 물건마다 차지하는 모양이 다르고, 위쪽 '뚜껑 압박' 게이지가 함께 차오른다.

어떻게 쌌느냐가 닫기 난이도가 된다

채움률 · 초과 높이 · 울퉁불퉁함을 섞어 뚜껑 압박도를 계산하고, 그 값이 지퍼 QTE의 바늘 속도와 성공 구간 폭을 결정한다. 무리해서 쌓으면 가방이 터지고 물건이 방바닥에 쏟아진다.

지퍼 잠그기 QTE 화면
지퍼 QTE. 무리하게 쌀수록 바늘이 빨라지고 초록 구간이 좁아진다.

1막의 결과가 2막으로 돌아온다

캐리어에 안 들어가는 큰 짐은 현관 앞으로 날라 둔다. 2막에서는 그것들을 완성한 캐리어와 함께 택시 트렁크에 다시 싣는다. 1막에서 얼마나 빵빵하게 쌌는지가 2막에서 덩어리 크기로 돌아온다.

골목에 서 있는 택시
2막. 골목에서 기다리는 택시 트렁크에 캐리어와 큰 짐을 다시 싣는다.

놓고 온 물건이 문장으로 남는다

아이템 31종 전부가 고유한 회고 문구를 갖는다. "여권을 두고 왔다"가 숫자가 아니라 문장으로 남는다.

시간 압박을 UI 없이 체감시키기벽시계 바늘이 08:30에서 09:00으로 실제로 돈다. 남은 시간에 따라 심장박동 · 화면 흔들림 · 색보정이 단계적으로 올라간다. 숫자를 크게 키우는 대신 방 자체가 조여 오게 만드는 쪽을 골랐다.

씬을 손으로 만들지 않았다

5일이라는 기간에서 가장 큰 속도는 여기서 나왔다. 가구·아이템 프리팹 제작부터 방 배치, 시스템 배선까지 전부 에디터 스크립트(JamSceneBuilder)로 굽는다.

속도만의 문제가 아니었다. 좌표가 코드에 있으니 검증도 코드로 할 수 있다. 그래서 배치 실수를 빌드 시점에 경고로 잡는 장치를 함께 만들었다.

/// <summary>가구의 실제 렌더러 범위가 매트를 침범하는가(공중에 뜬 것은 제외).</summary>
private static bool OverlapsMat(GameObject target, Rect mat)
{
    Renderer[] renderers = target.GetComponentsInChildren<Renderer>(true);
    if (renderers.Length == 0) return false;

    Bounds bounds = renderers[0].bounds;
    for (int i = 1; i < renderers.Length; i++) bounds.Encapsulate(renderers[i].bounds);

    if (bounds.min.y > LoadingSpotClearHeight) return false;

    Rect footprint = new Rect(bounds.min.x, bounds.min.z, bounds.size.x, bounds.size.z);
    return footprint.Overlaps(mat);
}

Transform 위치가 아니라 렌더러를 전부 합친 실제 범위로 판정한다. 프리팹의 피벗이 중심에 있으리라는 보장이 없기 때문이다. 높이 조건 하나로 공중에 매달린 오브젝트(조명, 벽걸이)는 걸러 낸다.

손 = 칸

손 인벤토리는 UnityEngine 의존이 없는 순수 C# 로직으로 분리했다. 테스트하기 쉽고 고치기도 쉽다.

/// <summary>지금 차지하고 있는 칸 수.</summary>
public int UsedSlots
{
    get
    {
        int used = 0;
        for (int i = 0; i < items.Count; i++)
        {
            if (items[i] != null) used += items[i].HandCost;
        }
        return used;
    }
}

public int FreeSlots => Capacity - UsedSlots;

/// <summary>이 물건을 들 자리가 있는지. 큰 물건은 빈 칸이 여러 개 있어야 한다.</summary>
public bool CanAdd(ItemDataSO item) => item != null && item.HandCost <= FreeSlots;

규칙이 HandCost <= FreeSlots 한 줄로 끝난다. 개수가 아니라 비용으로 세기 때문에 나중에 3칸짜리, 4칸짜리 물건을 추가해도 이 코드는 그대로다.

뚜껑 압박도

"많이 담았다"가 아니라 "무리하게 쌓았다" 를 수치화해야 했다. 그런데 컨테이너마다 성립하는 항목이 다르다는 문제가 있었다.

public float ComputeLidStrain(float fillWeight, float overHeightWeight, float unevenWeight)
{
    float total = fillWeight + overHeightWeight + unevenWeight;
    if (total <= 0f) return 0f;

    // 방 캐리어(2단)처럼 '초과 높이' 자체가 성립하지 않는 컨테이너에서는
    // 그 항목이 항상 0 이라 압박도가 구조적으로 낮게 깔린다.
    // 그러면 꽉 채워도 지퍼가 쉬워지므로, 남는 가중치를 채움률로 넘긴다.
    float effectiveFillWeight = fillWeight;
    float effectiveOverWeight = overHeightWeight;
    if (FlushLayers >= MaxLayers)
    {
        effectiveFillWeight += overHeightWeight;
        effectiveOverWeight = 0f;
    }

    float strain = FillRate * effectiveFillWeight
                 + OverHeightRatio * effectiveOverWeight
                 + UnevenRatio * unevenWeight;
    return Clamp01(strain / total);
}
가중치를 버리지 않고 넘긴다방 캐리어는 2단이 최대라 '초과 높이'가 항상 0이다. 그 항목을 그냥 두면 압박도가 구조적으로 낮게 깔려 꽉 채워도 지퍼가 쉬워진다. 성립하지 않는 항목의 가중치를 0으로 만드는 대신 채움률로 넘겨 총합을 유지했다. 마지막에 total 로 나누기 때문에 어떤 컨테이너든 0~1 범위가 보장된다.

밸런스 값(가중치 · 제한시간 · 등급 컷)은 전부 GameConfig / Difficulty ScriptableObject 에 뒀다. 코드에 매직넘버를 심지 않았다.

넘친 칸을 잔상으로 보여주기

초기 손 슬롯 UI는 2칸짜리 물건이 두 칸을 하나로 병합해 그렸다. 그러다 보니 "몇 칸을 먹는 중인지"가 화면에서 사라졌다. 칸 4개를 언제나 테두리로 그려 두고 물건이 덮은 칸만 색을 바꾸는 구조로 바꿨다.

/// <summary>칸을 끄지 않고 색만 바꾼다 — 칸 개수는 언제나 보여야 한다.</summary>
private void SetSlotState(int index, SlotState state)
{
    switch (state)
    {
        case SlotState.Occupied:
            slotFills[index].color = OccupiedSlotColor;
            slotFrames[index].color = OccupiedBorderColor;
            break;

        case SlotState.Overflow:
            slotFills[index].color = OverflowSlotColor;
            slotFrames[index].color = OverflowBorderColor;
            break;

        default:
            slotFills[index].color = EmptySlotColor;
            slotFrames[index].color = EmptyBorderColor;
            break;
    }
}

3D 썸네일은 아이템별 전용 직교 카메라로 RenderTexture 에 굽고(ItemThumbnailStage), 화면에 떠 있는 것만 렌더하도록 카메라를 켜고 끈다.

에디터는 되는데 빌드는 검은 화면

이 프로젝트에서 가장 오래 붙잡은 문제다. WebGL과 Windows 빌드 양쪽에서, 유니티 로고가 지나간 뒤 화면이 어두운 채로 아무것도 뜨지 않았다.

라이팅 베이크를 의심했다. 퀄리티 레벨을 의심했다. WebGL 설정을 뒤졌다. 전부 무관했다.

Player.log 를 열자 한 번에 나왔다.

ArgumentNullException: Value cannot be null. Parameter name: shader
  at UnityEngine.Material..ctor (Shader shader)
  at RushHour.Player.SuitcaseStation.CreateUnlitMaterial
  at RushHour.Core.GameDirector.Awake ()

Shader.Find("Universal Render Pipeline/Unlit")빌드에서만 null 이었다. 이 셰이더를 참조하는 머티리얼 에셋이 프로젝트에 하나도 없었기 때문이다. 코드에서 new Material(Shader.Find(...)) 로만 만들어 쓰다 보니, 빌드 과정에서 "아무도 안 쓰는 셰이더"로 판정되어 통째로 스트리핑됐다. 에디터는 프로젝트 전체 에셋을 검색하므로 언제나 찾아진다.

왜 '검은 화면'으로 보였는가

그 예외가 하필 GameDirector.Awake() 에서 터졌다. 이후 초기화가 전부 중단되면서 타이틀 루틴이 돌지 않았고, ScreenFader(알파 1, 검정)가 화면에 그대로 남았다. 초기화 실패가 화면이 어둡다로 위장한 것이다.

고친 방법은 두 단계다.

여기서 얻은 교훈초기화 예외는 거의 항상 검은 화면으로 위장한다. 추측하지 말고 Player.log(WebGL은 브라우저 콘솔)를 먼저 볼 것. 라이팅을 의심하며 보낸 시간이 로그 한 번 읽는 시간보다 훨씬 길었다.

"자꾸 충전기가 사라져요"

플레이테스터에게 받은 제보다. 정확히는 침대 밑에 있는데 보이지도 않고 주워지지도 않는다 였다.

항목
바닥 아이템 자리 Slot_Floor_E(−2.05, 1.05)
침대 점유 범위x −3.74 ~ −1.76 · z 0.86 ~ 2.24
침대 프레임 밑면y ≈ 0.17

슬롯이 침대 점유 범위 안에 완전히 들어가 있었다. 프레임 밑면이 낮아 물건이 아래에 갇히고, 프레임 콜라이더가 시선 레이캐스트를 먼저 맞는다. 아이템 위치는 매 판 셔플되므로 충전기 버그가 아니라 무엇이든 사라질 수 있는 버그였다.

좌표를 옮기는 것으로 끝내지 않고, 앞에서 만든 빌드타임 겹침 검증을 바닥 슬롯 전체 × 배치안 전체로 확장했다. 현관 앞 매트가 책상·의자 밑에 깔려 있던 문제를 같은 방식으로 이미 잡았기 때문에 그대로 재사용할 수 있었다.

금지 대신 대가

설계 판단 중 가장 마음에 드는 변경이다. 원래는 손에 물건이 남아 있으면 가방 닫기가 거부됐다. 시간에 쫓기는 게임에서 "지금은 닫을 수 없다"는 벽은 재미가 아니라 답답함이었다.

그래서 닫기를 막지 않기로 했다. 대신 손에 든 것을 실제로 내던지고 닫는다. 가방이 터질 때 쓰던 포물선 연출을 재사용해 물건이 바닥에 남고, 시간이 남으면 다시 주워 담을 수 있다. 던진 물건은 격자에 없으니 자동으로 '못 챙긴 것'으로 채점된다.

잼 마감 이후 고친 것들

화장실 — 두 번째 공간

방 하나에 아이템 31종을 밀어 넣다 보니 물건이 가구 밑에 끼는 사고가 났고, "다 보이는 한 화면"이라 탐색의 재미도 적었다. 북쪽 벽에 문 구멍을 내고 2.7 × 2.4 m 화장실을 붙였다. 세면대·변기·샤워부스·거울을 전부 프리미티브 조합으로 세웠다.

여기서 한 가지 결정을 했다. 세면도구 · 상비약 · 수건 · 슬리퍼는 일부러 셔플하지 않는다. 세면도구가 침대 위에 있으면 그냥 랜덤이지만, 세면대 옆에 있으면 "아 맞다 화장실" 이 된다. 탐색이 추리가 되는 지점이다.

문 기믹은 난이도가 아니라 시간을 쓰게 하는 장치

난이도화장실 문
튜토리얼 · EASY잠김. 방 안에서만 플레이
NORMAL열린 채 시작
HARD열 때마다 60% 확률로 뻑뻑해져 QTE

안에 들어가면 등 뒤에서 한 번 닫힌다. 필수품(세면도구)이 그 안에 있으니 "지금 가지러 갈 것인가"가 진짜 판단이 된다. 자동 닫힘을 한 번으로 제한한 이유는, 왕복마다 QTE면 학습이 아니라 고문이 되기 때문이다.

같은 입력을 여러 곳이 동시에 읽었다

이 프로젝트 UI는 EventSystem 없이 각 뷰가 wasPressedThisFrame 을 직접 읽는다. 그 대가를 세 가지 버그로 치렀다.

이벤트의 의미를 잘못 읽었다

지퍼 위젯의 Failed 는 "실패로 끝났다"가 아니라 "한 번 놓쳤다" 다. QTE는 3~4회 반복되기 때문이다. 그런데 그 시점에 QTE 용도를 되돌려 버려서, 문 QTE 중 한 번 놓치면 뒤이은 Completed가방을 닫은 것으로 처리돼 택시 막으로 넘어갔다.

용도 해제를 최종 이벤트(Completed · Burst)에서만 하도록 나눴다. 이벤트 이름이 상태를 정확히 말해 주지 않으면 이런 일이 생긴다.

타이틀에서 걷어낸 것

잼 이름 · 주제 · 규칙 4줄 · 조작키 4줄을 전부 지우고 제목 + 한 줄 + 세로 버튼만 남겼다. 규칙은 튜토리얼이, 조작은 인게임 프롬프트가 가르친다. 타이틀에 조작키를 나열하는 건 설명서지 타이틀이 아니다.

온라인 랭킹

Firebase Realtime Database 를 쓰는데, 공식 Unity SDK 가 WebGL 을 지원하지 않는다. REST + 익명 인증으로 직접 구현했다. 플러그인 0개라 에디터에서도 그대로 테스트된다. 난이도별로 판을 나누고, 이름은 클라이언트와 서버 규칙 양쪽에서 영문·숫자만 허용한다.

정리

한 달 일정의 게임잼 규모를 5일에 완주하고 배포까지 마쳤다. 프로토타입이 아니라 인트로 컷씬 → 탐색 → 정리 → QTE → 2막 → 채점 → 엔딩까지 이어지는 완결된 루프다.

속도의 원천은 씬을 손으로 만들지 않은 것이었다. 프리팹·배치·배선을 전부 코드로 굽는 구조 덕분에 방 구성을 몇 번이고 다시 만들 수 있었고, 좌표 실수를 빌드 시점 경고로 잡는 안전망까지 같은 코드에서 얻었다.

가장 값진 교훈은 디버깅 태도였다. "에디터에서는 되는데 빌드에서는 안 된다"는 증상 앞에서 라이팅·퀄리티·플랫폼 설정을 먼저 의심했지만, 정답은 로그 한 줄에 있었다. 이후 이 유형(셰이더 스트리핑 · Resources 경로 · OS 폰트 의존)을 표로 정리해 프로젝트 문서에 남겼다.

그리고 금지보다 대가를 주는 설계가 낫다는 것을 체감했다. 손에 물건이 남으면 닫기를 막던 규칙을 "내던지고 닫되 점수로 대가를 치른다"로 바꾼 뒤 플레이 흐름이 눈에 띄게 매끄러워졌다.

다음 목표는 점수 산정의 가시화와 캐리어 내부 표현 교체(칸 단위 컬러 블록 → 실제 물품 메시)다. 둘 다 "이 게임이 무엇을 요구하는지"를 더 정직하게 보여 주는 작업이다. 자체 테스트에서 필수품인 여권을 놓쳤는데도 S 등급이 나오는 밸런스 문제를 발견했는데, 점수를 눈에 보이게 만들면 그것도 함께 드러난다. 등급 컷 재조정이나 "필수품 누락 시 상위 등급 불가" 하드캡을 같이 검토하고 있다.