게임은 화면 전환까지 포함해 완성된다
플레이어가 움직이고, 코인을 먹고, 적과 충돌하는 장면만으로는 아직 게임이 완성되지 않는다. 독자는 현재 점수가 몇 점인지 알아야 하고, 체력이 얼마나 남았는지 봐야 한다. 시작하기 전에는 메뉴가 필요하고, 실패하면 게임 오버 화면이 나와야 한다. 잠시 멈추고 다시 이어 하는 일시정지도 필요하다.
이 장에서는 플레이 장면 위에 점수판과 체력바를 올리고, 시작 메뉴, 일시정지, 게임 오버, 재시작 흐름을 만든다. 핵심은 간단하다. 게임 규칙을 처리하는 Scene과 상태를 보여 주는 UI Scene의 책임을 나누는 것이다.
AI 생성 이미지
Text, BitmapText, Image로 기본 UI 만들기
가장 먼저 점수 텍스트를 만들어 보자. Phaser에서 글자를 표시할 때는 보통 Text를 사용한다.
create() {
this.score = 0;
this.scoreText = this.add.text(16, 16, 'SCORE 0', {
fontFamily: 'Arial',
fontSize: '24px',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 4
});
}
addScore(amount) {
this.score += amount;
this.scoreText.setText(`SCORE ${this.score}`);
}Text는 사용하기 쉽다. 브라우저 폰트를 그대로 사용할 수 있고, 크기와 색도 코드로 빠르게 바꿀 수 있다. 점수, 안내 문구, 버튼 라벨처럼 일반적인 UI에는 충분하다.
반면 픽셀 게임처럼 글자 모양이 항상 일정해야 하거나, 많은 글자를 반복해서 표시해야 한다면 BitmapText를 고려할 수 있다. BitmapText는 글자를 이미지 조각처럼 찍어 내는 방식이다. 먼저 비트맵 폰트 파일을 불러온 뒤 사용한다.
preload() {
this.load.bitmapFont(
'pixel-font',
'assets/fonts/pixel.png',
'assets/fonts/pixel.xml'
);
}
create() {
this.scoreText = this.add.bitmapText(16, 16, 'pixel-font', 'SCORE 0', 24);
}체력은 텍스트만으로 보여 줄 수도 있지만, 막대나 하트 아이콘을 쓰면 더 직관적이다. 가장 단순한 체력바는 Rectangle 두 개로 만들 수 있다.
create() {
this.maxHealth = 100;
this.health = 100;
this.healthBack = this.add.rectangle(16, 52, 104, 14, 0x222222)
.setOrigin(0, 0);
this.healthFill = this.add.rectangle(18, 54, 100, 10, 0xff3366)
.setOrigin(0, 0);
}
setHealth(value) {
this.health = Phaser.Math.Clamp(value, 0, this.maxHealth);
const ratio = this.health / this.maxHealth;
this.healthFill.width = 100 * ratio;
}하트 이미지로 체력을 표시하고 싶다면 Image를 여러 개 배치하면 된다.
create() {
this.hearts = [];
for (let i = 0; i < 3; i++) {
const heart = this.add.image(24 + i * 32, 24, 'heart');
this.hearts.push(heart);
}
}
setLife(life) {
this.hearts.forEach((heart, index) => {
heart.setVisible(index < life);
});
}정리하면 Text는 빠른 글자 UI, BitmapText는 고정된 스타일의 글자 UI, Image는 아이콘과 버튼에 적합하다. 실제 게임 UI는 이 세 가지를 섞어서 만든다.
카메라가 움직여도 UI는 고정되어야 한다
8장에서 카메라가 플레이어를 따라가도록 만들었다. 이때 점수판을 플레이어와 같은 월드에 그냥 배치하면 문제가 생긴다. 카메라가 이동할 때 점수판도 월드의 일부처럼 움직여 화면 밖으로 사라질 수 있다.
Play Scene 안에서 간단히 UI를 만들 때는 setScrollFactor(0)을 사용한다. scrollFactor가 0이면 카메라 이동의 영향을 받지 않고 화면 좌표에 고정된다.1
create() {
this.scoreText = this.add.text(16, 16, 'SCORE 0', {
fontSize: '24px',
color: '#ffffff'
})
.setScrollFactor(0)
.setDepth(1000);
}setDepth(1000)은 다른 오브젝트보다 앞에 그리기 위한 설정이다. 배경, 타일맵, 플레이어보다 UI가 뒤에 깔리면 안 되므로 UI에는 높은 depth를 주는 편이 안전하다.
다만 게임이 커지면 Play Scene 안에 UI 코드까지 계속 쌓인다. 점수 처리, 체력 표시, 일시정지 버튼, 미니맵, 대화창이 모두 한 Scene에 들어가면 create()와 update()가 금방 복잡해진다. 이때는 UI를 별도의 Scene으로 분리한다.
Play Scene과 UI Scene 분리하기
Scene을 분리하면 역할이 명확해진다.
PlayScene: 플레이어, 적, 타일맵, 충돌, 게임 규칙 처리UIScene: 점수판, 체력바, 버튼, 안내 문구 표시MenuScene: 시작 화면PauseScene: 일시정지 화면GameOverScene: 결과와 재시작 화면
Phaser에서는 한 Scene 위에 다른 Scene을 함께 실행할 수 있다. 예를 들어 Play Scene이 시작될 때 UI Scene을 launch()로 올릴 수 있다.2
class PlayScene extends Phaser.Scene {
constructor() {
super('PlayScene');
}
create() {
this.score = 0;
this.health = 3;
this.scene.launch('UIScene', {
score: this.score,
health: this.health
});
}
}UI Scene은 전달받은 초기 데이터로 화면을 구성한다.
class UIScene extends Phaser.Scene {
constructor() {
super('UIScene');
}
init(data) {
this.score = data.score ?? 0;
this.health = data.health ?? 3;
}
create() {
this.scoreText = this.add.text(16, 16, `SCORE ${this.score}`, {
fontSize: '24px',
color: '#ffffff'
});
this.hearts = [];
for (let i = 0; i < 3; i++) {
this.hearts.push(this.add.image(24 + i * 32, 56, 'heart'));
}
this.updateHealth(this.health);
}
updateScore(score) {
this.scoreText.setText(`SCORE ${score}`);
}
updateHealth(health) {
this.hearts.forEach((heart, index) => {
heart.setVisible(index < health);
});
}
}UI Scene은 별도의 Scene이므로 기본적으로 플레이 월드의 카메라를 따라가지 않는다. 그래서 화면 위에 고정된 인터페이스를 만들기 좋다. 단, UI Scene 안에서도 카메라를 따로 움직이거나 여러 카메라를 쓰는 구조라면 setScrollFactor(0) 개념을 다시 확인해야 한다.
게임 상태를 UI에 알려 주기
이제 Play Scene에서 점수가 바뀔 때 UI Scene도 갱신해야 한다. 가장 직접적인 방법은 UI Scene을 가져와 메서드를 호출하는 것이다.
addScore(amount) {
this.score += amount;
const ui = this.scene.get('UIScene');
ui.updateScore(this.score);
}
takeDamage() {
this.health -= 1;
const ui = this.scene.get('UIScene');
ui.updateHealth(this.health);
if (this.health <= 0) {
this.gameOver();
}
}이 방식은 이해하기 쉽다. 작은 게임에서는 충분히 괜찮다. 하지만 UI 메서드 이름이 Play Scene에 직접 드러난다. UI 구조를 바꾸면 Play Scene 코드도 같이 바뀔 수 있다.
조금 더 느슨하게 연결하려면 이벤트를 사용한다. Play Scene은 "점수가 바뀌었다"고 알리고, UI Scene은 그 신호를 듣고 자기 화면을 갱신한다.
// PlayScene
addScore(amount) {
this.score += amount;
this.events.emit('score-changed', this.score);
}
takeDamage() {
this.health -= 1;
this.events.emit('health-changed', this.health);
}// UIScene
create() {
const playScene = this.scene.get('PlayScene');
playScene.events.on('score-changed', this.updateScore, this);
playScene.events.on('health-changed', this.updateHealth, this);
this.events.once('shutdown', () => {
playScene.events.off('score-changed', this.updateScore, this);
playScene.events.off('health-changed', this.updateHealth, this);
});
}여기서 중요한 부분은 shutdown에서 이벤트 연결을 해제하는 것이다. Scene을 재시작할 때 이전 리스너가 남아 있으면 점수 갱신이 두 번씩 실행되는 문제가 생길 수 있다. 특히 한 Scene이 다른 Scene의 이벤트를 듣는 구조에서는 "내 Scene이 종료될 때 내가 걸어 둔 리스너를 직접 해제한다"는 습관을 들이는 편이 안전하다.
Registry로 전역 상태 관리하기
모든 상태를 Scene 안에만 둘 필요는 없다. 최고 점수, 음량 설정, 해금된 레벨처럼 여러 Scene에서 함께 쓰는 값은 Phaser의 Registry에 보관할 수 있다. Registry는 게임 전체에서 접근 가능한 데이터 저장소로 생각하면 된다.3
// MenuScene 또는 BootScene
create() {
this.registry.set('highScore', 0);
this.registry.set('musicVolume', 0.7);
}점수를 Registry에 저장하면 UI Scene이 값 변화를 직접 감지할 수 있다.
// PlayScene
addScore(amount) {
const current = this.registry.get('score') ?? 0;
this.registry.set('score', current + amount);
}// UIScene
create() {
const score = this.registry.get('score') ?? 0;
this.scoreText = this.add.text(16, 16, `SCORE ${score}`, {
fontSize: '24px',
color: '#ffffff'
});
this.registry.events.on('changedata-score', this.onScoreChanged, this);
this.events.once('shutdown', () => {
this.registry.events.off('changedata-score', this.onScoreChanged, this);
});
}
onScoreChanged(parent, value) {
this.scoreText.setText(`SCORE ${value}`);
}그렇다고 모든 값을 Registry에 넣는 것은 좋지 않다. 플레이어의 현재 속도, 점프 중인지 여부, 적의 임시 상태처럼 Play Scene 안에서만 필요한 값은 Scene 내부에 두는 편이 낫다.
간단한 기준은 다음과 같다.
한 Scene 안에서만 쓰는 값:
this.score,this.player,this.healthScene을 시작할 때 한 번 넘기는 값:
this.scene.start('PlayScene', data)여러 Scene에서 오래 공유하는 값:
this.registry.set()실시간 UI 갱신 신호: Scene 이벤트 또는 Registry 변경 이벤트
메뉴, 일시정지, 게임 오버 흐름 만들기
게임의 기본 흐름은 다음처럼 잡을 수 있다.
MenuScene → PlayScene + UIScene → PauseScene
↓
GameOverScene
↓
PlayScene시작 메뉴는 가장 단순한 버튼 하나로 만들 수 있다.
class MenuScene extends Phaser.Scene {
constructor() {
super('MenuScene');
}
create() {
this.add.text(400, 220, 'PHASER GAME', {
fontSize: '40px',
color: '#ffffff'
}).setOrigin(0.5);
const startText = this.add.text(400, 320, 'START', {
fontSize: '28px',
color: '#ffff66'
}).setOrigin(0.5).setInteractive();
startText.on('pointerdown', () => {
this.registry.set('score', 0);
this.scene.start('PlayScene', { level: 1 });
});
}
}Play Scene에서는 init(data)에서 시작 데이터를 받는다. Scene을 다시 시작할 때 이전 값이 남지 않도록, 초기화할 값은 init()이나 create()에서 분명히 다시 설정한다.
class PlayScene extends Phaser.Scene {
constructor() {
super('PlayScene');
}
init(data) {
this.level = data.level ?? 1;
this.health = 3;
}
create() {
this.scene.launch('UIScene', { health: this.health });
this.input.keyboard.on('keydown-ESC', this.pauseGame, this);
this.events.once('shutdown', () => {
this.input.keyboard.off('keydown-ESC', this.pauseGame, this);
});
}
pauseGame() {
this.scene.pause('PlayScene');
this.scene.pause('UIScene');
this.scene.launch('PauseScene');
}
gameOver() {
const score = this.registry.get('score') ?? 0;
this.scene.stop('UIScene');
this.scene.start('GameOverScene', { score });
}
}pause()된 Scene은 업데이트가 멈추지만 화면은 남아 있다. 반대로 sleep()은 Scene을 비활성 상태로 보내고 화면에도 그리지 않는다.4 일시정지 화면 뒤에 게임 화면을 흐리게 남기고 싶다면 pause()가 어울린다. UI까지 숨기고 싶다면 sleep('UIScene')을 사용해도 된다. 다만 위 예제처럼 UIScene을 pause()했다면 다시 이어 할 때는 resume('UIScene')으로 깨우고, sleep('UIScene')으로 숨겼다면 wake('UIScene')으로 다시 보이게 해야 한다.
Pause Scene은 현재 게임을 멈춘 상태로 위에 올라오는 화면이다.
class PauseScene extends Phaser.Scene {
constructor() {
super('PauseScene');
}
create() {
this.add.rectangle(400, 300, 800, 600, 0x000000, 0.5);
this.add.text(400, 260, 'PAUSED', {
fontSize: '40px',
color: '#ffffff'
}).setOrigin(0.5);
const resumeText = this.add.text(400, 340, 'RESUME', {
fontSize: '28px',
color: '#ffff66'
}).setOrigin(0.5).setInteractive();
resumeText.on('pointerdown', () => this.resumeGame());
this.input.keyboard.once('keydown-ESC', () => this.resumeGame());
}
resumeGame() {
this.scene.resume('PlayScene');
this.scene.resume('UIScene');
this.scene.stop();
}
}게임 오버 화면은 최종 점수를 받고, 다시 시작할지 메뉴로 돌아갈지 선택하게 만든다.
class GameOverScene extends Phaser.Scene {
constructor() {
super('GameOverScene');
}
init(data) {
this.finalScore = data.score ?? 0;
}
create() {
this.add.text(400, 230, 'GAME OVER', {
fontSize: '44px',
color: '#ff6666'
}).setOrigin(0.5);
this.add.text(400, 300, `SCORE ${this.finalScore}`, {
fontSize: '28px',
color: '#ffffff'
}).setOrigin(0.5);
const retryText = this.add.text(400, 380, 'RETRY', {
fontSize: '28px',
color: '#ffff66'
}).setOrigin(0.5).setInteractive();
retryText.on('pointerdown', () => {
this.registry.set('score', 0);
this.scene.start('PlayScene', { level: 1 });
});
}
}미니 프로젝트: 코인 점수와 체력 UI 붙이기
이제 이전 장에서 만든 타일맵 레벨에 점수와 체력을 붙여 보자. 코인과 겹치면 점수가 오르고, 적과 닿으면 체력이 줄어드는 구조다.
create() {
this.registry.set('score', 0);
this.health = 3;
this.isInvincible = false;
this.scene.launch('UIScene', { health: this.health });
this.physics.add.overlap(
this.player,
this.coins,
this.collectCoin,
null,
this
);
this.physics.add.overlap(
this.player,
this.enemies,
this.hitEnemy,
null,
this
);
}
collectCoin(player, coin) {
coin.disableBody(true, true);
const score = this.registry.get('score') ?? 0;
this.registry.set('score', score + 10);
}
hitEnemy(player, enemy) {
if (this.isInvincible) return;
this.health -= 1;
this.events.emit('health-changed', this.health);
this.isInvincible = true;
player.setTint(0xff6666);
this.time.delayedCall(1000, () => {
this.isInvincible = false;
player.clearTint();
});
if (this.health <= 0) {
this.gameOver();
}
}여기서 isInvincible은 짧은 무적 시간이다. 이 값이 없으면 플레이어가 적과 겹쳐 있는 동안 체력이 매 프레임 줄어든다. UI와 상태 관리는 화면 표시만의 문제가 아니다. 게임 규칙이 언제 한 번만 실행되어야 하는지도 함께 정리해 준다.
상태가 정리되면 게임이 읽힌다
UI는 장식이 아니다. 점수는 목표를 보여 주고, 체력은 위험을 알려 주고, 메뉴는 플레이 흐름을 정리한다. 일시정지와 게임 오버 화면은 플레이어가 게임을 통제하고 있다고 느끼게 만든다.
작은 게임에서는 Play Scene 안에 텍스트 몇 개를 놓는 것으로 시작해도 된다. 하지만 점수판, 체력바, 버튼, 메뉴가 늘어나면 UI Scene을 분리하자. Scene 간 데이터 전달, 이벤트, Registry를 적절히 나누면 코드가 훨씬 읽기 쉬워진다.
다음 장에서는 이렇게 정리된 플레이 흐름 위에 사운드, 트윈, 파티클을 더해 게임의 반응을 강화한다. 같은 점수 증가라도 소리와 움직임, 작은 효과가 붙으면 플레이어는 훨씬 더 선명하게 게임을 느낀다.
참고
1Phaser GameObject setScrollFactor API
2Phaser ScenePlugin launch API
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.