「오늘 누가 쏘냐?」의 씬 파일에는 카메라 하나만 들어 있다. 버튼도, 캔버스도, 프리팹도 없다. 타이틀 화면부터 게임 15개, 결과 화면까지 화면 20여 개가 전부 C# 코드로 만들어진다.

이렇게 정한 데는 이유가 있다. 씬과 프리팹은 편집기에서 끌어다 놓아 만들고, 변경 내용은 사람이 읽기 어려운 직렬화 데이터로 남는다. 코드는 읽고, 고치고, 검토하기 쉽다. AI 코딩 도구를 함께 쓸 때도 코드만 있으면 도구가 전체 구조를 그대로 다룰 수 있다. UI를 코드로만 만들면 화면 하나를 추가하는 일이 파일 하나를 추가하는 일이 된다. 리뷰도 diff로 끝난다.

이 글에서는 그 구조를 정리한다.

진입점: 씬이 비어 있어도 게임이 뜬다

RuntimeInitializeOnLoadMethod를 붙인 정적 메서드는 씬에 아무것도 없어도 실행된다. 여기서 Bootstrap 오브젝트를 만들고, 나머지는 Awake에서 전부 코드로 만든다.

public class Bootstrap : MonoBehaviour
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    static void AutoStart()
    {
        if (FindAnyObjectByType<Bootstrap>() != null) return;
        var go = new GameObject("Bootstrap");
        DontDestroyOnLoad(go);
        go.AddComponent<Bootstrap>();
    }

    void Awake()
    {
        // 카메라, EventSystem, Canvas(+CanvasScaler) 생성
        // 오디오, 저장 데이터, 네트워크 펌프 초기화
        var sm = gameObject.AddComponent<ScreenManager>();
        sm.Root = canvas.transform;
        sm.Register<TitleScreen>();
        sm.Register<PlayerSetupScreen>();
        sm.Register<LadderGame>();
        // ... 게임 15개, 결과, 통계 화면
        sm.Show<TitleScreen>();
    }
}

처음 프로젝트를 열 때 필요한 설정(세로 화면, 씬 생성, URP 2D 파이프라인)도 에디터 스크립트 ProjectSetup이 자동으로 한다. 저장소를 받아 Unity로 열고 Play를 누르면 바로 돌아간다.

참고로 Unity 6000.5에서는 FindFirstObjectByType도 폐기 대상이 됐다. 인스턴스 ID 순서에 기대는 API라는 이유다. "있으면 쓰고 없으면 만든다" 정도의 용도라면 순서가 필요 없으니 FindAnyObjectByType으로 바꾸면 된다.

UIFactory: 모든 UI는 헬퍼 하나를 거친다

화면마다 new GameObject, AddComponent<Image>, anchorMin을 반복하면 코드가 금방 지저분해진다. 그래서 테마 색, 패널, 버튼, 라벨, 입력칸, 선을 만드는 함수를 UIFactory 한 곳에 모았다.

좌표는 앵커와 피벗을 같은 점으로 두는 방식 하나만 쓴다. "화면 위쪽 가운데에서 아래로 140" 같은 식으로 읽히고, 부모 크기가 바뀌어도 기준점에서의 거리가 유지된다.

public static RectTransform Rect(Transform parent, string name, Vector2 anchor, Vector2 pos, Vector2 size)
{
    var rt = new GameObject(name, typeof(RectTransform)).GetComponent<RectTransform>();
    rt.SetParent(parent, false);
    rt.anchorMin = anchor;
    rt.anchorMax = anchor;
    rt.pivot = anchor;          // 앵커 = 피벗
    rt.anchoredPosition = pos;
    rt.sizeDelta = size;
    return rt;
}

이 위에 Panel, Label, Button을 얹으면 화면 코드는 이렇게 된다.

UIFactory.Label(Root, "어떻게 정할까?", 72, UIFactory.TextMain, Top, new Vector2(0, -140), new Vector2(1000, 100),
    TextAnchor.MiddleCenter, FontStyle.Bold);
var panel = UIFactory.Panel(Root, "Amount", Top, new Vector2(0, -330), new Vector2(960, 150), UIFactory.PanelBg);

둥근 모서리 패널에 쓰는 9-slice 스프라이트와 룰렛 원판에 쓰는 원 스프라이트도 이미지 파일 없이 런타임에 Texture2D로 그린다. 픽셀마다 모서리 중심까지의 거리를 재서 알파를 정하는 몇 줄짜리 코드다.

몇 가지 제약도 함께 정했다.

  • TextMeshPro 대신 레거시 uGUI Text를 쓴다. TextMeshPro는 한글을 쓰려면 폰트 에셋을 따로 만들어야 하는데, 이 작업이 코드만으로 끝나지 않는다.
  • 입력은 구 Input Manager를 쓴다. 새 Input System 패키지를 추가하지 않는다.
  • 이름 충돌에 주의한다. UIFactory 안에 Button, Rect, Input이라는 메서드가 있다. 그래서 그 클래스 안에서 같은 이름의 Unity 타입을 식으로 쓸 때는 UnityEngine.Sprite.Create(...)처럼 완전한 이름을 쓴다.

화면 하나 = 클래스 하나

화면은 ScreenBase를 상속한 클래스 하나다. MonoBehaviour가 아니라 일반 클래스라서 파일 이름과 클래스 이름을 맞출 필요가 없고, 생성자로 바로 만들 수 있다.

public abstract class ScreenBase
{
    public RectTransform Root { get; private set; }
    public ScreenManager Manager { get; private set; }

    protected abstract void Build();          // 최초 1회 UI 구성
    public virtual void OnShow() { }          // 보일 때마다 초기화
    public virtual void OnHide() { }
    public virtual bool OnBack() => false;    // Android 뒤로가기

    protected Coroutine Run(IEnumerator routine) => Manager.StartCoroutine(routine);
}

ScreenManager.Show<T>()는 지금 화면을 끄고 다음 화면을 켠 뒤 OnShow()를 부른다. 코루틴은 화면 클래스가 MonoBehaviour가 아니므로 ScreenManager에 위임한다.

새 게임을 추가하는 절차는 네 단계로 정해져 있다.

  1. Games/XxxGame.cs를 만든다. Build()에서 UI를, OnShow()에서 라운드를 초기화한다.
  2. Bootstrap에 sm.Register<XxxGame>()을 넣는다.
  3. 게임 선택 화면에 카드를 하나 추가한다.
  4. 결과 화면의 「한 번 더」 분기에 게임 이름을 추가한다.

이 절차를 저장소의 작업 지침 문서(CLAUDE.md)에 적어 두었다. 그 덕분에 새 게임을 넣을 때마다 같은 패턴을 그대로 따를 수 있었다. 열다섯 번째 게임까지 이 순서는 한 번도 바뀌지 않았다.

카드가 늘어날 때마다 다시 짠 게임 선택 화면

게임 선택 화면은 이 프로젝트에서 가장 자주 고친 화면이다.

시기 배치 수용량
처음 세로로 한 줄씩, 카드 높이 270 → 210 → 186 6장
9/22 2열 그리드 (470x225) 8장
9/24 2열, 카드 높이 225 → 190 12장
9/26 3열 그리드 (310x220) 15장

2열로 바꿀 때 카드가 좌표 대신 순번(index)으로 자리를 잡게 했다. 게임을 추가할 때 좌표를 계산할 필요 없이 다음 번호만 주면 된다.

const int CardCols = 3;
const float CardStepX = 325f;   // 카드 폭 + 가로 간격
const float CardStepY = 234f;   // 카드 높이 + 세로 간격
const float CardTop = -505f;

Button Card(int index, string title, string desc, Color color, int charIndex, UnityAction onClick)
{
    int col = index % CardCols, row = index / CardCols;
    var pos = new Vector2((col - (CardCols - 1) * 0.5f) * CardStepX, CardTop - row * CardStepY);
    // ...
}

3열 그리드에 게임 15개가 꽉 찬 지금의 게임 선택 화면

3열로 바꾸면서 카드 폭이 좁아졌다. 설명 문구를 "한 줄 11자 안팎, 세 줄"로 맞추고, 캐릭터는 설명과 겹치지 않게 오른쪽 아래 구석으로 옮겼다. 다섯 번째 줄은 y = -1661에서 끝나고 아래 버튼은 -1730에서 시작한다. 열다섯 장이 꽉 찬 지금은 다음 게임부터 스크롤을 검토해야 한다는 메모를 코드 주석에 남겨 두었다.

갤럭시 폴드에서 화면 아래가 잘렸다

Android 빌드를 실제 폰에 깔아 보니 일반 폰에서는 멀쩡한데, 갤럭시 Z 폴드를 펼친 화면에서는 게임 선택 화면 아래쪽 카드가 「참가자 수정」 버튼 뒤로 숨어 버렸다.

폴드 화면에서 다섯 번째 카드가 아래 버튼에 가려진 모습

원인은 CanvasScaler 설정이었다. 기준 해상도는 1080x1920(9:16)이고, matchWidthOrHeight를 0.5로 두고 있었다. 너비와 높이를 반반씩 맞춘다는 뜻이다.

  • 화면이 기준보다 길면(20:9 폰) 너비에 덜 맞춰져 좌우가 살짝 넘칠 수 있다.
  • 화면이 기준보다 넓으면(폴더블을 펼친 화면, 태블릿) 높이에 덜 맞춰져 위아래가 넘친다. 아래쪽 앵커에 붙은 버튼은 그대로 화면 아래에 붙어 있고, 위쪽 앵커에서 내려온 카드는 화면 밖까지 내려가니 둘이 겹친다.

해결은 간단하다. 화면 비율을 보고 기준 영역(1080x1920)이 항상 전부 보이는 쪽으로 맞춘다. 넓은 화면은 높이에, 긴 화면은 너비에 맞춘다. 남는 공간은 양옆이나 위아래 여백이 된다.

public class CanvasFitter : MonoBehaviour
{
    const float RefAspect = 1080f / 1920f;
    CanvasScaler _scaler;
    int _w, _h;

    void Awake() { _scaler = GetComponent<CanvasScaler>(); Apply(); }

    // 폴더블은 접었다 펴면 해상도가 바뀌므로 매 프레임 확인한다
    void Update() { if (Screen.width != _w || Screen.height != _h) Apply(); }

    void Apply()
    {
        _w = Screen.width; _h = Screen.height;
        if (_scaler == null || _h == 0) return;
        float aspect = (float)_w / _h;
        _scaler.matchWidthOrHeight = aspect > RefAspect ? 1f : 0f;  // 넓으면 높이, 길면 너비
    }
}

이 방식이 통하는 전제가 하나 있다. 모든 화면을 세로 1920 안에 들어가게 짜는 것이다. 화면을 위·아래·가운데 앵커로만 배치하고 1920을 넘기지 않으면, 어느 비율에서도 잘리지 않는다. 덕분에 화면 20여 개를 하나도 고치지 않고 컴포넌트 하나만 붙여서 끝났다. 수정 후 폴드와 일반 폰 두 대에 동시에 깔아 확인했다.

정리

  • 씬은 비워 두고 RuntimeInitializeOnLoadMethod로 모든 것을 코드로 만든다.
  • UI 생성은 UIFactory 한 곳을 거친다. 좌표는 앵커 = 피벗 방식 하나만 쓴다.
  • 화면은 ScreenBase 클래스 하나. 게임 추가 절차를 문서로 고정해 두면 화면이 늘어나도 구조가 흐트러지지 않는다.
  • 여러 화면 비율은 matchWidthOrHeight를 비율에 따라 0 또는 1로 고르는 것으로 대응한다. 대신 모든 화면을 기준 높이 안에 짠다.

다음 글 3편에서는 이미지 파일 하나 없이 캐릭터, 효과음, 배경음악, 한글 폰트를 Python으로 생성한 이야기를 다룬다.