테마 전환

Cocos Creator 미니게임 프로젝트 구조: Boot, 씬, 결과 화면 나누는 법

Easton editorial illustration: one Main-scene base carrying four stacked interface layers
1.9MB
엔진 로딩 용량
Cocos Creator 엔진 자체의 크기
53%
사용자 이탈률
로딩이 3초를 넘을 때 포기하는 모바일 사용자 비율
2초 이내
최적화 후 로딩 시간
단일 씬 아키텍처로 최적화한 첫 화면 시간
数据来源: CSDN 블로그, Tencent Cloud 사례

화면에 뜬 “scene not found” 오류를 바라보다가, 이 미니게임 프로젝트의 디렉터리 구조가 완전히 통제 불능이 되었다는 사실을 깨달았습니다.

3개월 전에는 자신만만하게 시작했습니다. 메뉴는 씬 하나, 게임 메인 화면도 씬 하나, 결과 화면도 또 하나. 꽤 합리적으로 들리죠? 그런데 실제 결과는 달랐습니다. 씬을 전환할 때 플레이어 점수가 알 수 없이 0으로 초기화되고, loading 애니메이션은 프레젠테이션처럼 끊기고, 최악의 경우 어떤 스테이지는 로딩에 5초가 걸렸습니다. 사용자는 게임 화면을 보기도 전에 절반이 떠나 버렸습니다.

그때 알았습니다. 미니게임 아키텍처는 scene 파일 몇 개를 대충 쌓아 올린다고 끝나는 문제가 아니었습니다. 이후 전체 프로젝트를 단일 씬 + 네 개 Layer 구조로 리팩터링했고, 로딩 시간은 5초에서 2초 이내로 줄었으며 씬 전환도 훨씬 부드러워졌습니다.

미니게임에 단일 씬 아키텍처를 권장하는 이유

솔직히 처음 미니게임을 만들 때는 “아키텍처”라는 말을 거의 생각하지 않았습니다. 메뉴, 게임, 결과 화면. 각 화면을 별도 scene 파일로 두면 단순하고 거칠지만 빠르게 만들 수 있을 것 같았습니다.

막상 실행해 보니 문제가 줄줄이 드러났습니다.

전환 비용. director.loadScene()을 호출할 때마다 엔진은 기존 씬을 파괴하고 새 씬을 만들며 리소스를 다시 로드합니다. 이 과정은 미니게임에서는 치명적입니다. 모바일 사용자 53%는 로딩이 3초를 넘는 페이지를 포기하는데, 다중 씬 전환의 대기 시간은 이 임계값을 쉽게 넘습니다.

상태 손실. 플레이어가 한 스테이지를 끝내고 점수 1200점을 얻은 뒤 결과 화면으로 넘어갔는데 데이터가 사라집니다. 왜 그럴까요? 씬 전환은 모든 노드를 파괴하기 때문입니다. 데이터를 전역 변수나 상주 노드에 저장하지 않았다면 그대로 없어집니다. 뒤에서 이 문제를 어떻게 처리하는지 설명하겠습니다.

애니메이션 중단. 메뉴의 멋진 logo 애니메이션이 아직 끝나기 전에 사용자가 시작 버튼을 누르면 씬이 전환됩니다. 애니메이션은 강제로 끊기고 화면 경험은 크게 나빠집니다.

단일 씬 아키텍처의 장점은 분명합니다. 모든 UI 계층이 같은 씬 안에 있고, 전환은 Layer의 active 속성을 바꾸는 일에 가깝습니다. 파괴와 재생성 비용이 없고, 상태는 자연스럽게 유지되며, 애니메이션도 끊기지 않습니다. 미니게임은 보통 화면 수가 많지 않으므로 이 구조만으로도 충분합니다. 물론 대형 게임은 다릅니다. 스테이지가 많고 리소스가 크다면 다중 씬과 분할 패키지 로딩이 필요합니다. 하지만 미니게임이라면 단일 씬으로 충분합니다.

프로젝트 디렉터리 구조 템플릿

좋습니다. 단일 씬 아키텍처를 왜 써야 하는지 알았으니 이제 프로젝트 디렉터리를 어떻게 정리할지 보겠습니다.

아래는 여러 번 시행착오를 겪은 뒤 정리한 템플릿입니다. Cocos Creator 3.x를 기준으로 합니다.

assets/
├── Scenes/
│   └── Main.scene           # 유일한 메인 씬
├── Scripts/
│   ├── managers/
│   │   ├── GameManager.ts   # 게임 상태와 데이터 저장
│   │   ├── UIManager.ts     # Layer 전환 로직
│   │   └── AudioManager.ts  # 사운드 관리
│   ├── layers/
│   │   ├── BootLayer.ts     # 시작 화면 로직
│   │   ├── MenuLayer.ts     # 메인 메뉴 로직
│   │   ├── GameLayer.ts     # 게임 메인 화면
│   │   └── SettlementLayer.ts  # 결과 화면
│   └── components/
│       ├── PlayerController.ts
│       └── EnemyAI.ts
├── Prefabs/
│   ├── UI/
│   │   ├── BootPanel.prefab    # 시작 화면 UI 프리팹
│   │   ├── MenuPanel.prefab    # 메뉴 UI
│   │   ├── SettlementPanel.prefab
│   ├── Game/
│   │   ├── Player.prefab
│   │   ├── Obstacle.prefab
│   └── Effects/
│       └── Explosion.prefab
├── Resources/
│   ├── textures/             # 동적으로 로드하는 이미지
│   ├── audio/                # 사운드 파일
│   └── fonts/
└── bundles/                  # Asset Bundle 분할 패키지(WeChat 미니게임 최적화)
    ├── core/                 # 핵심 리소스 패키지
    └── levels/               # 스테이지 리소스 패키지

몇 가지 핵심 포인트가 있습니다.

Scenes 디렉터리에는 파일을 하나만 둡니다. Main.scene은 유일한 메인 씬이며, 그 안에는 Canvas, GameManager, 각 Layer 등의 노드가 들어갑니다. 이 디렉터리에 다른 scene을 계속 넣지 않는 편이 좋습니다.

managers에는 관리 클래스 스크립트를 둡니다. GameManager는 game.addPersistRootNode()로 상주 노드로 설정해 점수와 스테이지 진행도 같은 데이터를 저장합니다. UIManager는 Layer 간 전환 로직을 담당합니다.

layers 디렉터리에는 각 UI 계층의 로직 스크립트를 둡니다. 각 Layer는 Prefab 하나와 대응됩니다. Prefab은 Prefabs/UI 디렉터리에 두고, 스크립트를 Prefab에 붙인 다음 Main.scene의 Canvas 노드 아래로 끌어다 놓습니다.

bundles 디렉터리는 WeChat/Douyin 미니게임을 위한 것입니다. 미니게임에는 첫 패키지 용량 제한이 있으므로 4MB를 넘는 부분은 Asset Bundle로 분할 로드해야 합니다. 핵심 리소스는 core bundle에 넣고 스테이지 리소스는 필요할 때 로드합니다.

당신의 프로젝트 디렉터리도 이렇게 정리되어 있나요? 아니라면 한 번 조정해 볼 만합니다.

Boot 씬: 시작 화면을 어떻게 설계할까

먼저 숫자 하나를 보겠습니다. Cocos Creator 엔진 자체의 크기는 약 1.9MB입니다. 여기에 게임 리소스를 더하면 첫 화면 로딩 시간은 기본적으로 3~5초가 됩니다. 미니게임에서는 너무 긴 시간입니다. 사용자가 게임을 눌렀는데 검은 화면이나 빈 페이지를 3초 동안 보고 있으면 많은 사람이 바로 닫아 버립니다.

Boot Layer는 이 문제를 해결하기 위한 계층입니다. 역할은 단순합니다.

로딩 진행률 표시. 사용자가 게임이 멈춘 것이 아니라 로딩 중임을 알 수 있게 합니다. 진행률 막대나 퍼센트 숫자 정도면 충분합니다. 너무 화려하게 만들 필요는 없습니다. 이 시점에는 이미지 리소스도 아직 다 로드되지 않았기 때문입니다.

핵심 리소스 사전 로드. resources.preload()나 Asset Bundle의 loadBundle()을 사용해 곧 필요한 이미지와 오디오를 먼저 로드합니다.

브랜드 노출. logo를 배치하고 간단한 애니메이션을 더합니다. 예를 들어 logo가 확대되며 서서히 나타나는 효과만으로도 충분합니다. 동시에 브랜드를 자연스럽게 보여줄 수 있습니다.

시작 흐름은 대략 다음과 같습니다.

엔진 초기화 → Boot Layer 표시 → 핵심 리소스 사전 로드 → 로딩 완료 → MenuLayer로 전환

코드 예시입니다. BootLayer.ts입니다.

import { _decorator, Component, Node, resources, ProgressBar } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('BootLayer')
export class BootLayer extends Component {
    @property(ProgressBar)
    progressBar: ProgressBar | null = null;

    start() {
        // 핵심 리소스 사전 로드
        this.preloadCoreAssets();
    }

    preloadCoreAssets() {
        resources.preloadDir('textures', (err, items) => {
            if (err) {
                console.error('사전 로드 실패:', err);
                return;
            }
            // 로딩 완료 후 메뉴로 전환
            this.switchToMenu();
        });
    }

    updateProgress(current: number, total: number) {
        if (this.progressBar) {
            this.progressBar.progress = current / total;
        }
    }

    switchToMenu() {
        // UIManager에 MenuLayer로 전환하라고 알림
        // 코드는 뒤에서 보여줍니다
    }
}

WeChat 미니게임 첫 패키지 최적화: 게임을 WeChat 미니게임 플랫폼에 올릴 예정이라면 첫 패키지 제한은 4MB입니다. 초과분은 Asset Bundle로 분할해야 합니다. Boot Layer에서 이런 bundle을 로드할 수 있습니다.

assetManager.loadBundle('levels', (err, bundle) => {
    if (err) return;
    console.log('스테이지 패키지 로딩 완료');
});

H5 플랫폼 커스텀 시작 화면: Cocos Creator는 build-templates 메커니즘을 제공하므로 H5 배포 후의 시작 화면을 직접 커스터마이즈할 수 있습니다. 프로젝트 루트에 build-templates/web-mobile 디렉터리를 만들고 커스텀 index.html을 넣으면 기본 검은색 시작 화면을 교체할 수 있습니다. loading 애니메이션이나 브랜드 이미지를 추가해도 좋습니다.

구체적인 방법은 공식 포럼 튜토리얼을 참고하세요. Creator | H5 커스텀 시작 화면.

Boot Layer를 설계했다면 다음은 전체 아키텍처의 핵심입니다. 네 개 Layer를 어떻게 전환할까요?

4계층 아키텍처 구현: 메뉴에서 결과 화면까지

가장 중요한 부분입니다. 전체 메인 씬의 구조는 다음과 같습니다.

Main.scene
├── Canvas (UI 컨테이너)
│   ├── BootLayer      → 로딩 진행률, 브랜드 노출
│   ├── MenuLayer      → 시작 화면, 스테이지 선택
│   ├── GameLayer      → 게임 메인 화면
│   └── SettlementLayer → 결과 화면(점수, 시간, 계속하기/재시도)
├── GameManager (상주 노드)
│   └─ 저장: 점수, 스테이지 진행도, 플레이어 설정
└── AudioRoot (오디오 관리 노드)

보시다시피 네 개 Layer는 모두 같은 Canvas 아래에 있습니다. 전환할 때는 특정 Layer의 active 속성만 바꿉니다. 다른 Layer는 계속 존재하므로 상태가 사라지지 않고 애니메이션도 끊기지 않습니다.

Layer 전환의 핵심 로직은 UIManager에 둡니다.

// UIManager.ts
import { _decorator, Component, Node, tween, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('UIManager')
export class UIManager extends Component {
    private layers: Map<string, Node> = new Map();
    private currentLayer: string = 'BootLayer';

    onLoad() {
        // 모든 Layer 노드 수집
        const canvas = this.node.getChildByName('Canvas');
        if (!canvas) return;

        canvas.children.forEach(child => {
            if (child.name.endsWith('Layer')) {
                this.layers.set(child.name, child);
                child.active = false; // 처음에는 모두 숨김
            }
        });

        // BootLayer 표시
        this.switchLayer('BootLayer');
    }

    switchLayer(targetLayer: string) {
        // 현재 계층 숨김
        const current = this.layers.get(this.currentLayer);
        if (current) {
            current.active = false;
        }

        // 대상 계층 표시
        const target = this.layers.get(targetLayer);
        if (target) {
            target.active = true;
            // 페이드인 애니메이션 추가(선택)
            this.playFadeIn(target);
        }

        this.currentLayer = targetLayer;
    }

    playFadeIn(node: Node) {
        // 간단한 스케일 페이드인 애니메이션
        node.setScale(new Vec3(0.9, 0.9, 1));
        tween(node)
            .to(0.2, { scale: new Vec3(1, 1, 1) })
            .start();
    }
}

핵심은 Map으로 모든 Layer 참조를 저장하는 것입니다. 이렇게 하면 전환할 때마다 getChildByName()으로 찾을 필요가 없어 효율이 더 좋습니다.

결과 화면으로 데이터 전달하기: 플레이어가 한 스테이지를 끝내고 점수 1200점, 플레이 시간 45초를 얻었다고 해 봅시다. 이 데이터는 결과 화면에 어떻게 전달할까요?

답은 GameManager입니다.

GameManager는 상주 노드이며 생명주기가 게임 전체에 걸쳐 유지됩니다. 플레이어가 GameLayer에서 스테이지를 끝내면 점수와 시간을 GameManager에 저장합니다. SettlementLayer로 전환될 때는 GameManager에서 데이터를 꺼내 표시합니다.

// GameManager.ts (간단한 버전)
import { _decorator, Component, game } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('GameManager')
export class GameManager extends Component {
    public currentScore: number = 0;
    public currentLevel: number = 1;
    public playTime: number = 0;

    onLoad() {
        // 상주 노드로 설정
        game.addPersistRootNode(this.node);
    }

    // GameLayer가 이 메서드를 호출해 데이터 저장
    saveResult(score: number, time: number) {
        this.currentScore = score;
        this.playTime = time;
    }

    // SettlementLayer가 이 메서드를 호출해 데이터 읽기
    getResult() {
        return {
            score: this.currentScore,
            time: this.playTime
        };
    }
}

SettlementLayer가 표시될 때는 GameManager의 데이터를 바로 읽습니다.

// SettlementLayer.ts
onEnable() {
    const gameManager = find('GameManager')?.getComponent(GameManager);
    if (!gameManager) return;

    const result = gameManager.getResult();
    this.scoreLabel.string = `점수: ${result.score}`;
    this.timeLabel.string = `소요 시간: ${result.time}초`;
}

이렇게 하면 데이터 전달 문제가 해결됩니다. 복잡한 이벤트 시스템이 꼭 필요한 것은 아닙니다. GameManager가 중심을 잡아 주면 충분합니다.

상주 노드: Layer 간 데이터 전달

위에서 이미 상주 노드를 언급했지만, 여기서 조금 더 자세히 보겠습니다.

game.addPersistRootNode()는 Cocos Creator가 공식 제공하는 API입니다. 특정 노드가 씬 전환 시 파괴되지 않게 하는 역할을 합니다. 그런데 우리는 단일 씬 아키텍처를 쓰고 있으니 씬이 전환되지 않습니다. 그렇다면 왜 상주 노드가 필요할까요?

사실 상주 노드는 단일 씬 아키텍처에서도 유용합니다. 전역 데이터 센터와 이벤트 센터로 사용할 수 있기 때문입니다.

GameManager의 역할:

플레이어 데이터 저장. 점수, 스테이지 진행도, 최고 기록, 잠금 해제된 스테이지 목록을 보관합니다.
설정 데이터 저장. 효과음 켜기/끄기, 음악 켜기/끄기, 언어 선택을 보관합니다.
전역 이벤트 제공. 예를 들어 “스테이지 완료” 이벤트를 두면 각 Layer가 이 이벤트를 감지해 반응할 수 있습니다.

주의할 점:

상주 노드는 Canvas 아래에 두면 안 됩니다. Canvas는 UI 컨테이너이며 Layer의 active 상태 변화에 따라 조작될 수 있습니다. 상주 노드는 독립적으로 씬 루트 계층에 두어야 합니다.

상주 노드는 직접 만들어야 합니다. Main.scene에 빈 노드를 만들고 이름을 “GameManager”로 지정한 뒤 GameManager 스크립트를 붙입니다. 그리고 스크립트의 onLoad()에서 game.addPersistRootNode(this.node)를 호출합니다.

상주 노드는 신중하게 사용해야 합니다. 모든 것을 다 집어넣지 말고, 정말 화면 간 공유가 필요한 데이터만 저장하세요. 각 Layer만의 UI 상태, 예를 들어 버튼 표시 여부 같은 것은 GameManager에 넣지 말고 해당 Layer가 직접 관리하게 두는 편이 낫습니다.

완성된 데이터 구조 예시는 다음과 같습니다.

// GameManager.ts (완성 버전)
interface PlayerData {
    highestScore: number;
    unlockedLevels: number[];
    currentLevel: number;
}

interface GameSettings {
    soundEnabled: boolean;
    musicEnabled: boolean;
    language: 'zh' | 'en';
}

@ccclass('GameManager')
export class GameManager extends Component {
    private playerData: PlayerData = {
        highestScore: 0,
        unlockedLevels: [1],
        currentLevel: 1
    };

    private settings: GameSettings = {
        soundEnabled: true,
        musicEnabled: true,
        language: 'zh'
    };

    onLoad() {
        game.addPersistRootNode(this.node);
        // 로컬 저장소에서 데이터 읽기
        this.loadData();
    }

    loadData() {
        const saved = localStorage.getItem('playerData');
        if (saved) {
            this.playerData = JSON.parse(saved);
        }
    }

    saveData() {
        localStorage.setItem('playerData', JSON.stringify(this.playerData));
    }

    // getters와 setters...
}

여기에는 localStorage 읽기와 쓰기도 추가해 플레이어 데이터를 로컬에 영속화했습니다. 미니게임 플랫폼은 대부분 localStorage를 지원합니다. WeChat 미니게임에서는 wx.setStorageSync를 사용하지만, 래핑된 localStorage도 사용할 수 있습니다.

정리

지금까지 꽤 많은 이야기를 했습니다. 마지막으로 자신의 프로젝트와 대조해 볼 수 있는 아키텍처 의사결정 체크리스트를 정리합니다.

미니게임을 만들고 있다면:

  • 단일 씬 아키텍처를 사용합니다. 즉, 하나의 Main.scene을 둡니다.
  • 모든 UI 계층은 Canvas 아래에 두고 Layer로 전환합니다.
  • BootLayer는 로딩 진행률과 사전 로드를 담당합니다.
  • GameManager는 상주 노드로 두고 데이터를 저장합니다.
  • 디렉터리 구조는 assets/Scripts/managers/layers/Prefabs 기준으로 정리합니다.

대형 게임을 만들고 있다면:

  • 다중 씬은 필요합니다. 스테이지가 많고 리소스가 크기 때문입니다.
  • 다만 UI 계층에는 단일 씬 + Layer의 사고방식을 적용할 수 있습니다.
  • Asset Bundle로 분할 패키지 로딩을 사용합니다.

이 구조는 제가 여러 미니게임 프로젝트에서 사용하며 시행착오를 거쳐 다듬은 방식이라 꽤 성숙한 편이라고 봅니다. 궁금한 점이 있다면 댓글을 남기거나 GitHub에서 예시 프로젝트를 찾아 비교해 보세요.

다음 글에서는 Layer 전환 애니메이션을 다뤄 보려고 합니다. 페이드인/페이드아웃, 슬라이드 전환, scale 확대와 축소 같은 효과로 전환을 더 부드럽게 만드는 방법입니다.

Cocos Creator 미니게임 단일 씬 아키텍처 구축하기

Boot, 메인 화면, 결과 화면으로 구성된 미니게임 프로젝트 아키텍처를 처음부터 구축합니다.

⏱️ Estimated time: 30 min

  1. 1

    Step 1: 프로젝트 디렉터리 구조 만들기

    assets 아래에 Scenes, Scripts/managers, Scripts/layers, Scripts/components, Prefabs/UI, Prefabs/Game, Resources, bundles 등의 디렉터리를 만들고 역할별로 파일을 분리합니다.
  2. 2

    Step 2: Main.scene과 GameManager 만들기

    유일한 메인 씬 Main.scene을 만들고 루트 계층에 GameManager 노드를 생성해 스크립트를 붙입니다. onLoad()에서 game.addPersistRootNode(this.node)를 호출해 상주 노드로 설정합니다.
  3. 3

    Step 3: 네 개의 Layer 프리팹 만들기

    BootLayer, MenuLayer, GameLayer, SettlementLayer 네 개의 Prefab을 만들고 각 Prefab에 대응하는 로직 스크립트를 붙인 뒤 Prefabs/UI 디렉터리에 모아 둡니다.
  4. 4

    Step 4: UIManager 전환 로직 구현하기

    UIManager.ts에서 Map으로 모든 Layer 참조를 저장하고 switchLayer(targetLayer) 메서드를 구현합니다. active 속성을 제어해 화면을 전환하고 페이드인 애니메이션을 더해 경험을 개선합니다.
  5. 5

    Step 5: Boot Layer 로딩 흐름 구현하기

    BootLayer.ts에서 resources.preloadDir()를 호출해 핵심 리소스를 사전 로드하고 진행률 막대를 갱신합니다. 로딩이 끝나면 UIManager를 호출해 MenuLayer로 전환합니다.

FAQ

미니게임에 단일 씬 아키텍처를 권장하는 이유는 무엇인가요?
단일 씬 아키텍처는 씬 전환 시 발생하는 파괴와 재생성 비용, 상태 손실, 애니메이션 중단 문제를 피할 수 있습니다. Layer의 active 속성만 제어해 화면을 바꾸므로 상태가 자연스럽게 유지되고 전환도 더 부드럽습니다.
씬 전환 시 데이터는 어떻게 전달하나요?
GameManager를 상주 노드로 사용해 화면 간 데이터를 저장합니다. 플레이어 점수와 스테이지 진행도 등을 GameManager에 두고 결과 화면에서는 gameManager.getResult()로 데이터를 읽습니다.
상주 노드는 어느 계층에 두어야 하나요?
상주 노드는 씬의 루트 계층에 두어야 하며 Canvas 아래에 두면 안 됩니다. Canvas는 UI 컨테이너라 Layer 전환 과정에서 조작될 수 있으므로 상주 노드는 독립적으로 존재해야 합니다.
Boot Layer의 역할은 무엇인가요?
Boot Layer는 로딩 진행률 표시, 핵심 리소스 사전 로드, 브랜드 Logo 노출을 담당합니다. 사용자가 게임이 멈춘 것이 아니라 로딩 중임을 알 수 있어 대기 경험이 좋아집니다.
WeChat 미니게임 첫 패키지가 4MB를 넘으면 어떻게 하나요?
Asset Bundle로 분할 패키지 로딩을 사용합니다. 핵심 리소스는 core bundle에, 스테이지 리소스는 levels bundle에 넣고 Boot Layer에서 필요에 따라 로드해 첫 패키지에는 필수 리소스만 남깁니다.

3분 읽기 · 게시일: 2026년 5월 19일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog