코드가 커질 때 가장 먼저 무너지는 것
작은 예제에서는 PlayScene 하나에 모든 코드를 넣어도 괜찮다. 플레이어를 만들고, 적을 만들고, 점수를 올리고, 충돌을 처리해도 파일 하나 안에서 흐름을 따라갈 수 있다.
하지만 플랫폼 게임이나 슈팅 게임 미니 프로젝트처럼 기능이 늘어나면 금방 문제가 생긴다. Scene 안에 입력, 물리, UI, 적 AI, 아이템, 사운드, 레벨 전환 코드가 한꺼번에 섞인다. 이 상태에서 "코인 점수 규칙만 바꾸기" 또는 "적 종류 하나 추가하기"를 하려면 파일 전체를 뒤져야 한다.
프로젝트 구조화의 목적은 코드를 멋있게 나누는 것이 아니다. 바꿀 일이 생겼을 때 어디를 고치면 되는지 바로 알게 만드는 것이다.
이번 장에서는 Phaser 프로젝트를 실제 게임처럼 관리하기 위한 기본 구조를 만든다. 그리고 TypeScript를 적용해 자동완성과 오류 검사를 활용하는 방법까지 연결한다.
AI 생성 이미지
폴더를 역할별로 나누기
처음부터 복잡한 아키텍처를 만들 필요는 없다. Phaser 프로젝트에서는 다음 정도의 구분만으로도 유지보수가 훨씬 쉬워진다.
src/
main.ts
scenes/
BootScene.ts
PlayScene.ts
UIScene.ts
GameOverScene.ts
entities/
Player.ts
Enemy.ts
Coin.ts
systems/
EnemySpawner.ts
ScoreSystem.ts
EventBus.ts
ui/
HeartHud.ts
ScoreText.ts
data/
assetKeys.ts
gameEvents.ts
gameSettings.ts
levelData.ts각 폴더의 기준은 다음과 같다.
scenes에는 Phaser Scene을 둔다. Scene은 게임의 큰 흐름을 관리한다. 에셋 로딩, 레벨 시작, 충돌 연결, 다른 Scene으로 전환 같은 일을 담당한다. 단, 플레이어의 세부 이동 코드나 적의 행동 규칙까지 모두 Scene에 넣지는 않는다.
entities에는 게임 월드 안에 존재하는 주요 객체를 둔다. 플레이어, 적, 코인, 총알, 문, 체크포인트처럼 화면에 나타나고 자체 동작을 가진 대상이다.
systems에는 여러 객체를 관리하거나 게임 규칙을 처리하는 코드를 둔다. 적 스폰, 점수 계산, 웨이브 진행, 데미지 처리처럼 특정 스프라이트 하나에만 속하지 않는 로직이 여기에 어울린다.
ui에는 점수판, 체력바, 버튼, 메뉴 패널처럼 화면 정보를 보여 주는 구성요소를 둔다. UI 전용 Scene과 함께 사용하면 PlayScene이 훨씬 가벼워진다.
data에는 문자열 키, 설정값, 레벨 목록, 이벤트 이름 같은 데이터를 둔다. 특히 에셋 키와 이벤트 이름은 문자열 오타가 자주 나는 부분이므로 한곳에서 관리하는 것이 좋다.
중요한 기준은 "이 코드의 주인은 누구인가?"이다. 플레이어 자신이 알아야 할 이동과 피격 처리는 Player에 둔다. 여러 적을 언제 만들지 결정하는 코드는 EnemySpawner에 둔다. 점수 표시 방법은 ScoreText에 둔다. Scene은 이들을 조립하고 서로 연결하는 역할에 집중한다.
Scene을 얇게 만들기
구조화 전의 PlayScene은 보통 이렇게 커진다.
create() {
this.player = this.physics.add.sprite(100, 300, 'player');
this.player.setCollideWorldBounds(true);
this.enemy = this.physics.add.sprite(500, 300, 'enemy');
this.coin = this.physics.add.sprite(700, 250, 'coin');
this.physics.add.overlap(this.player, this.coin, () => {
this.score += 10;
this.scoreText.setText(`Score: ${this.score}`);
this.coin.destroy();
});
}예제가 짧을 때는 괜찮다. 하지만 코인 종류가 늘고, 적이 늘고, UI가 분리되면 Scene이 모든 것을 직접 알아야 한다. 이제 Player 클래스를 만들어 보자.
// src/entities/Player.ts
import Phaser from 'phaser';
import { ASSET_KEYS } from '../data/assetKeys';
import { GAME_SETTINGS } from '../data/gameSettings';
export class Player extends Phaser.Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, ASSET_KEYS.player);
scene.add.existing(this);
scene.physics.add.existing(this);
this.setCollideWorldBounds(true);
}
moveLeft() {
this.setVelocityX(-GAME_SETTINGS.playerSpeed);
this.setFlipX(true);
}
moveRight() {
this.setVelocityX(GAME_SETTINGS.playerSpeed);
this.setFlipX(false);
}
stopMove() {
this.setVelocityX(0);
}
jump() {
const body = this.body as Phaser.Physics.Arcade.Body;
if (body.blocked.down) {
this.setVelocityY(GAME_SETTINGS.jumpVelocity);
}
}
}이제 PlayScene은 플레이어가 어떻게 움직이는지 자세히 몰라도 된다.
// src/scenes/PlayScene.ts
import Phaser from 'phaser';
import { Player } from '../entities/Player';
export class PlayScene extends Phaser.Scene {
private player!: Player;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
constructor() {
super('PlayScene');
}
create() {
this.player = new Player(this, 100, 300);
this.cursors = this.input.keyboard!.createCursorKeys();
}
update() {
if (this.cursors.left.isDown) {
this.player.moveLeft();
} else if (this.cursors.right.isDown) {
this.player.moveRight();
} else {
this.player.stopMove();
}
if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) {
this.player.jump();
}
}
}코드 양이 크게 줄지는 않았지만 책임이 분명해졌다. 점프 속도를 바꾸거나 착지 조건을 수정할 때는 Player.ts를 보면 된다. Scene은 "입력을 읽고 플레이어에게 명령한다"는 역할만 맡는다.
그렇다고 Scene이 아무 일도 하지 않아야 한다는 뜻은 아니다. Scene은 여전히 월드를 만들고, 객체들을 배치하고, 충돌 관계를 연결한다. 다만 각 객체의 세부 행동까지 Scene이 모두 떠안지 않게 만드는 것이 핵심이다.
적과 아이템도 클래스로 분리하기
적도 같은 방식으로 분리할 수 있다.
// src/entities/Enemy.ts
import Phaser from 'phaser';
import { ASSET_KEYS } from '../data/assetKeys';
import { GAME_SETTINGS } from '../data/gameSettings';
export class Enemy extends Phaser.Physics.Arcade.Sprite {
private direction = -1;
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, ASSET_KEYS.enemy);
scene.add.existing(this);
scene.physics.add.existing(this);
}
patrol() {
this.setVelocityX(this.direction * GAME_SETTINGS.enemySpeed);
}
turnAround() {
this.direction *= -1;
this.setFlipX(this.direction > 0);
}
}코인은 더 단순하다.
// src/entities/Coin.ts
import Phaser from 'phaser';
import { ASSET_KEYS } from '../data/assetKeys';
export class Coin extends Phaser.Physics.Arcade.Sprite {
readonly value = 10;
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y, ASSET_KEYS.coin);
scene.add.existing(this);
scene.physics.add.existing(this);
}
collect() {
this.disableBody(true, true);
}
}이런 클래스 분리는 모든 객체를 무조건 클래스로 만들라는 뜻이 아니다. 배경 장식처럼 아무 동작이 없는 이미지는 Scene에서 바로 만들어도 된다. 하지만 다음 조건 중 하나라도 해당하면 별도 클래스로 빼는 편이 좋다.
자기만의 상태가 있다.
여러 Scene이나 여러 레벨에서 반복 사용된다.
생성 코드보다 행동 코드가 더 길다.
충돌, 애니메이션, 사운드가 함께 묶인다.
나중에 종류가 늘어날 가능성이 높다.
플레이어, 적, 아이템, 총알은 대부분 이 조건에 해당한다.
문자열 키와 설정값을 한곳에 모으기
Phaser 코드는 문자열 키를 자주 쓴다.
this.load.image('player', 'assets/player.png');
this.add.sprite(100, 100, 'plaeyr'); // 오타JavaScript에서는 이런 오타가 실행 전에는 잘 보이지 않는다. TypeScript를 써도 Phaser의 에셋 키는 기본적으로 문자열이기 때문에 완전히 막아 주지는 않는다. 그래서 키를 상수로 모아 둔다.
// src/data/assetKeys.ts
export const ASSET_KEYS = {
player: 'player',
enemy: 'enemy',
coin: 'coin',
tiles: 'tiles',
mapStage1: 'map-stage-1',
sfxCoin: 'sfx-coin'
} as const;사용할 때는 직접 문자열을 쓰지 않는다.
this.load.image(ASSET_KEYS.player, 'assets/player.png');
this.add.sprite(100, 100, ASSET_KEYS.player);이렇게 하면 ASSET_KEYS.plaeyr처럼 잘못된 속성 이름을 썼을 때 에디터가 바로 알려 준다. 문자열 자체를 완전히 없애는 것은 아니지만, 오타가 흩어지는 범위를 크게 줄일 수 있다.
설정값도 같은 방식으로 관리한다.
// src/data/gameSettings.ts
export const GAME_SETTINGS = {
playerSpeed: 220,
jumpVelocity: -430,
gravityY: 900,
enemySpeed: 80,
invincibleTime: 1000
} as const;이렇게 하면 속도를 조정하기 위해 여러 파일을 뒤질 필요가 없다. 밸런스를 바꿀 때는 gameSettings.ts를 먼저 보면 된다.
이벤트 이름도 상수로 만든다.
// src/data/gameEvents.ts
export const GAME_EVENTS = {
scoreChanged: 'score-changed',
playerDamaged: 'player-damaged',
gameOver: 'game-over'
} as const;작은 프로젝트에서는 이런 파일이 귀찮아 보인다. 하지만 게임이 커질수록 문자열을 한곳에 모아 두는 습관이 디버깅 시간을 크게 줄인다.
TypeScript를 적용하는 이유
TypeScript는 JavaScript에 타입 검사를 더한 언어다. Phaser API를 사용할 때 자동완성이 잘 되고, 잘못된 인자나 없는 속성을 에디터가 미리 알려 준다.
예를 들어 this.physics.add.까지 입력하면 사용할 수 있는 메서드가 목록으로 뜬다. setVelocityX()에 문자열을 넣는 실수도 바로 표시된다. 큰 프로젝트에서는 이런 작은 검사가 큰 차이를 만든다.
Phaser는 TypeScript와 함께 쓰기 좋은 개발 흐름을 제공한다. 공식 Phaser + TypeScript + Vite 템플릿은 빠른 개발 서버, 변경 즉시 반영되는 개발 경험, 프로덕션 빌드 흐름을 제공하기 위해 만들어졌다.1 직접 구성한다면 Vite, TypeScript, Phaser를 설치하고 main.ts를 진입점으로 쓰는 방식이 일반적이다.2
기본 진입 파일은 다음처럼 작성할 수 있다.
// src/main.ts
import Phaser from 'phaser';
import { PlayScene } from './scenes/PlayScene';
import { UIScene } from './scenes/UIScene';
import { GAME_SETTINGS } from './data/gameSettings';
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 960,
height: 540,
backgroundColor: '#1d1d2b',
physics: {
default: 'arcade',
arcade: {
gravity: { y: GAME_SETTINGS.gravityY },
debug: false
}
},
scene: [PlayScene, UIScene]
};
new Phaser.Game(config);여기서 중요한 부분은 Phaser.Types.Core.GameConfig다. 이 타입을 붙이면 config에 어떤 속성을 넣을 수 있는지 에디터가 알려 준다. 잘못된 속성 이름을 넣으면 오류가 난다.
Scene 클래스도 타입을 명확히 한다.
export class UIScene extends Phaser.Scene {
private scoreText!: Phaser.GameObjects.Text;
constructor() {
super('UIScene');
}
create() {
this.scoreText = this.add.text(20, 20, 'Score: 0', {
fontSize: '24px',
color: '#ffffff'
});
}
}scoreText 뒤의 !는 "이 값은 생성자에서는 비어 있지만, 사용하기 전에 반드시 할당할 것"이라는 뜻이다. Phaser에서는 create()에서 객체를 만드는 경우가 많기 때문에 자주 사용한다. 다만 남용하면 타입 검사의 의미가 약해지므로 실제로 create()에서 반드시 할당되는 값에만 쓰는 것이 좋다.
Scene끼리 직접 부르지 않기
프로젝트가 커질수록 Scene끼리 직접 의존하는 코드는 조심해야 한다.
// 좋지 않은 예
const uiScene = this.scene.get('UIScene') as UIScene;
uiScene.updateScore(this.score);이 방식은 처음에는 편하다. 하지만 UIScene의 메서드 이름이 바뀌거나 UI 구조가 바뀌면 PlayScene도 함께 수정해야 한다. PlayScene이 UI의 내부 구현을 너무 많이 알게 된다.
대신 이벤트를 사용할 수 있다. 이벤트는 "어떤 일이 일어났다"고 알리는 방식이다. 누가 듣고 처리할지는 보내는 쪽이 몰라도 된다.
먼저 공용 이벤트 버스를 만든다.
// src/systems/EventBus.ts
import Phaser from 'phaser';
export const EventBus = new Phaser.Events.EventEmitter();PlayScene은 점수가 바뀌었다는 사실만 알린다.
import { EventBus } from '../systems/EventBus';
import { GAME_EVENTS } from '../data/gameEvents';
private score = 0;
private addScore(value: number) {
this.score += value;
EventBus.emit(GAME_EVENTS.scoreChanged, this.score);
}UIScene은 그 이벤트를 듣고 화면을 갱신한다.
import { EventBus } from '../systems/EventBus';
import { GAME_EVENTS } from '../data/gameEvents';
create() {
this.scoreText = this.add.text(20, 20, 'Score: 0');
const updateScore = (score: number) => {
this.scoreText.setText(`Score: ${score}`);
};
EventBus.on(GAME_EVENTS.scoreChanged, updateScore);
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
EventBus.off(GAME_EVENTS.scoreChanged, updateScore);
});
}이 구조의 장점은 분리다. PlayScene은 UI가 텍스트인지, 이미지인지, 애니메이션 숫자인지 몰라도 된다. UIScene은 점수가 어디서 계산되는지 몰라도 된다. 둘은 이벤트 이름만 공유한다.
단, 이벤트는 편한 만큼 관리가 필요하다. Scene이 종료될 때 리스너를 해제하지 않으면 같은 이벤트가 여러 번 실행될 수 있다. 특히 Scene을 재시작하거나 같은 UI Scene을 다시 열 때 이런 문제가 잘 드러난다. 그래서 shutdown 시점에 off()를 호출하는 습관을 들여야 한다.
한 번에 다 바꾸지 않기
이미 JavaScript로 만든 프로젝트를 TypeScript 구조로 옮긴다면 한 번에 전부 바꾸지 않는 것이 좋다. 다음 순서가 안전하다.
assetKeys,gameSettings,gameEvents부터 만든다.Player처럼 가장 많이 수정하는 객체 하나를 클래스로 분리한다.점수나 체력 UI를 이벤트 기반으로 연결한다.
main.ts와 Scene 클래스에 Phaser 타입을 붙인다.새로 추가하는 파일부터 TypeScript로 작성한다.
구조화는 완성된 코드를 보기 좋게 정리하는 작업이 아니다. 다음 기능을 더 쉽게 추가하기 위한 준비다. 폴더가 역할별로 나뉘고, 객체가 자기 행동을 갖고, 문자열과 설정값이 한곳에 모이면 게임은 훨씬 덜 불안해진다.
TypeScript는 여기에 안전망을 더한다. 실행해 보기 전에 많은 실수를 잡아 주고, Phaser API를 더 빠르게 탐색하게 해 준다. 이제부터는 기능을 추가할 때 "어디에 넣을지"를 먼저 생각하자. 그 습관이 작은 예제를 실제 프로젝트로 바꾸는 첫 단계다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.