에셋은 게임의 재료다
Phaser에서 에셋은 게임에 들어가는 모든 외부 파일을 뜻합니다. 배경 이미지, 캐릭터 스프라이트시트, 효과음, 배경 음악, 아틀라스 JSON 파일이 모두 에셋입니다. 코드는 게임의 규칙을 만들고, 에셋은 그 규칙이 화면과 소리로 드러나게 합니다.
중요한 점은 에셋을 사용하기 전에 먼저 불러와야 한다는 것입니다. Phaser에서는 보통 Scene의 preload()에서 에셋을 등록하고, create()에서 불러온 에셋을 화면에 배치합니다. 조금 더 정확히 말하면, preload() 안에서 파일을 바로 사용하는 것이 아니라 "이 파일들을 불러와 주세요"라고 로더 큐에 넣는 것입니다. 이후 Phaser 로더가 자동으로 실행되고, 로딩이 완료된 뒤에야 create()가 실행됩니다.1
이 흐름을 지키면 "이미지가 아직 없는데 화면에 그리려고 해서 실패하는 문제"를 피할 수 있습니다.
class GameScene extends Phaser.Scene {
preload() {
this.load.image('bg-forest', 'assets/images/bg-forest.png');
}
create() {
this.add.image(400, 225, 'bg-forest');
}
}여기서 'bg-forest'는 파일 이름이 아니라 Phaser 안에서 사용할 에셋 키입니다. 한 번 등록한 키는 이후 this.add.image(), this.add.sprite(), 애니메이션 생성 등에서 계속 사용합니다.
웹에서 에셋을 다룰 때는 파일 경로도 생각보다 중요합니다. 특히 배포 환경에 따라 파일 이름의 대소문자를 구분할 수 있습니다. 예를 들어 실제 파일이 Bg-Forest.png인데 코드에는 bg-forest.png라고 쓰면, 개발 중에는 괜찮아 보이다가 서버에 올린 뒤 로딩에 실패할 수 있습니다. 에셋 이름과 경로는 처음부터 소문자와 하이픈 중심으로 통일해 두면 이런 문제를 줄일 수 있습니다.
AI 생성 이미지
preload에서 자주 쓰는 로딩 문법
가장 기본은 이미지입니다.
this.load.image('bg-forest', 'assets/images/bg-forest.png');
this.load.image('ui-button-start', 'assets/images/ui-button-start.png');이미지는 배경, 버튼, 아이콘처럼 한 장짜리 그림에 적합합니다.
캐릭터처럼 여러 프레임이 한 이미지에 나란히 들어 있는 경우에는 spritesheet를 사용합니다.
this.load.spritesheet('player', 'assets/sprites/player.png', {
frameWidth: 32,
frameHeight: 48
});frameWidth와 frameHeight는 한 프레임의 크기입니다. 예를 들어 가로 128px, 세로 48px 이미지 안에 32x48 캐릭터가 4개 들어 있다면, Phaser는 이 이미지를 4개의 프레임으로 잘라 사용할 수 있습니다.
아틀라스는 스프라이트시트보다 조금 더 유연합니다. 큰 이미지 한 장에 여러 그림을 모아 두고, 별도의 JSON 파일이 각 그림의 위치와 크기를 설명합니다.
this.load.atlas(
'characters',
'assets/atlas/characters.png',
'assets/atlas/characters.json'
);아틀라스는 UI 아이콘, 캐릭터 부품, 다양한 효과 이미지를 한 번에 관리할 때 유용합니다. 파일이 많아질수록 개별 이미지를 하나씩 불러오는 방식보다 정리하기 쉽습니다.
오디오는 audio로 불러옵니다.
this.load.audio('sfx-jump', [
'assets/audio/jump.ogg',
'assets/audio/jump.mp3'
]);
this.load.audio('bgm-stage-1', [
'assets/audio/stage-1.ogg',
'assets/audio/stage-1.mp3'
]);배열로 여러 형식을 넣는 이유는 브라우저마다 지원하는 오디오 형식이 다를 수 있기 때문입니다. Phaser는 브라우저에서 사용할 수 있는 형식을 골라 재생합니다.
파일 경로가 반복된다면 setPath()로 기본 경로를 지정할 수 있습니다.
preload() {
this.load.setPath('assets/');
this.load.image('bg-forest', 'images/bg-forest.png');
this.load.spritesheet('player', 'sprites/player.png', {
frameWidth: 32,
frameHeight: 48
});
this.load.audio('sfx-jump', ['audio/jump.ogg', 'audio/jump.mp3']);
}작은 프로젝트에서는 차이가 크지 않지만, 파일이 늘어나면 경로를 한곳에서 정리하는 습관이 도움이 됩니다. setPath('assets/')처럼 기본 경로를 지정하면 뒤에 적는 경로는 그 아래부터 이어 쓰면 됩니다.
에셋 키 이름을 규칙으로 관리하기
에셋 키는 프로젝트가 커질수록 중요해집니다. 키가 뒤섞이면 어떤 파일이 어디에 쓰이는지 찾기 어렵고, 오타도 자주 납니다. Phaser는 이미 로딩 큐에 올라와 있거나 같은 캐시에 등록된 키와 충돌하는 파일을 다시 추가하지 않습니다. 그래서 키 중복은 예상과 다른 결과를 만들 수 있습니다.2
좋은 규칙은 단순해야 합니다.
// 좋은 예
'bg-forest'
'player'
'player-run'
'sfx-jump'
'bgm-stage-1'
'ui-button-start'
// 피하고 싶은 예
'forest'
'forest2'
'newPlayer'
'jumpSoundFinal'
'button'추천하는 방식은 종류-이름-상태 순서입니다.
this.load.image('bg-sky', 'assets/images/bg-sky.png');
this.load.image('bg-mountain', 'assets/images/bg-mountain.png');
this.load.image('ui-heart-empty', 'assets/images/ui-heart-empty.png');
this.load.image('ui-heart-full', 'assets/images/ui-heart-full.png');
this.load.audio('sfx-coin', 'assets/audio/coin.ogg');더 안전하게 관리하고 싶다면 키를 상수로 모아 둘 수 있습니다.
const AssetKeys = {
BG_SKY: 'bg-sky',
PLAYER: 'player',
SFX_JUMP: 'sfx-jump'
};
class GameScene extends Phaser.Scene {
preload() {
this.load.image(AssetKeys.BG_SKY, 'assets/images/bg-sky.png');
this.load.spritesheet(AssetKeys.PLAYER, 'assets/sprites/player.png', {
frameWidth: 32,
frameHeight: 48
});
}
create() {
this.add.image(400, 225, AssetKeys.BG_SKY);
this.add.sprite(100, 350, AssetKeys.PLAYER);
}
}처음에는 코드가 조금 길어 보일 수 있습니다. 하지만 여러 Scene에서 같은 에셋을 사용할 때 오타를 줄이고, 이름을 바꿀 때 한곳만 수정할 수 있습니다.
x, y, origin으로 화면에 놓기
에셋을 불러온 뒤에는 Game Object로 화면에 배치합니다. Game Object는 화면에 존재하는 이미지, 스프라이트, 텍스트, 도형 같은 객체입니다.
this.add.image(400, 225, 'bg-forest');
this.add.sprite(100, 340, 'player');앞의 두 숫자는 x, y 좌표입니다. Phaser의 좌표는 기본적으로 왼쪽 위가 (0, 0)이고, 오른쪽으로 갈수록 x가 커지며 아래로 갈수록 y가 커집니다.
다만 주의할 점이 있습니다. 이미지를 this.add.image(400, 225, 'bg-forest')로 배치하면 이미지의 왼쪽 위가 아니라 가운데가 (400, 225)에 놓입니다. 기본 origin이 (0.5, 0.5)이기 때문입니다.
배경처럼 왼쪽 위를 기준으로 붙이고 싶다면 setOrigin(0, 0)을 사용합니다.
this.add.image(0, 0, 'bg-forest')
.setOrigin(0, 0);캐릭터는 보통 가운데 아래쪽을 기준으로 두면 다루기 쉽습니다.
this.player = this.add.sprite(100, 360, 'player')
.setOrigin(0.5, 1);이렇게 하면 캐릭터의 발밑이 y = 360에 맞춰집니다. 나중에 바닥 충돌이나 점프를 구현할 때 위치 감각이 더 자연스럽습니다.
크기는 scale로 조절합니다.
this.add.image(400, 225, 'bg-forest')
.setScale(2);
this.add.sprite(100, 360, 'player')
.setScale(1.5);정확히 화면 크기에 맞추고 싶다면 setDisplaySize()가 더 직관적입니다.
this.add.image(0, 0, 'bg-forest')
.setOrigin(0, 0)
.setDisplaySize(800, 450);겹치는 순서는 depth로 조절합니다. 숫자가 클수록 앞에 그려집니다.
this.add.image(400, 225, 'bg-sky').setDepth(0);
this.add.image(400, 300, 'bg-mountain').setDepth(1);
this.player = this.add.sprite(100, 360, 'player').setDepth(10);
this.add.text(20, 20, 'Score: 0').setDepth(100);배경, 지형, 캐릭터, UI처럼 역할별로 depth 범위를 정해 두면 화면이 복잡해져도 관리하기 쉽습니다.
간단한 Loading Scene 만들기
에셋이 적을 때는 로딩 화면이 없어도 됩니다. 하지만 파일이 많아지면 흰 화면만 보이는 시간이 생깁니다. 이때 Loading Scene을 만들면 플레이어에게 "게임이 멈춘 것이 아니라 준비 중"이라는 신호를 줄 수 있습니다.
구조는 간단합니다.
LoadingScene에서 공용 에셋을 불러옵니다.로딩 진행률을 화면에 표시합니다.
로딩이 끝나면
GameScene으로 넘어갑니다.
class LoadingScene extends Phaser.Scene {
constructor() {
super('LoadingScene');
}
preload() {
const width = this.scale.width;
const height = this.scale.height;
const centerX = width / 2;
const centerY = height / 2;
const loadingText = this.add.text(centerX, centerY - 50, 'Loading...', {
fontSize: '24px',
color: '#ffffff'
}).setOrigin(0.5);
const percentText = this.add.text(centerX, centerY + 35, '0%', {
fontSize: '18px',
color: '#ffffff'
}).setOrigin(0.5);
this.add.rectangle(centerX, centerY, 320, 24, 0x333333);
const bar = this.add.graphics();
this.load.on('progress', (value) => {
percentText.setText(`${Math.floor(value * 100)}%`);
bar.clear();
bar.fillStyle(0x4caf50, 1);
bar.fillRect(centerX - 160, centerY - 12, 320 * value, 24);
});
this.load.on('fileprogress', (file) => {
loadingText.setText(`Loading: ${file.key}`);
});
this.load.setPath('assets/');
this.load.image('bg-sky', 'images/bg-sky.png');
this.load.image('bg-mountain', 'images/bg-mountain.png');
this.load.spritesheet('player', 'sprites/player.png', {
frameWidth: 32,
frameHeight: 48
});
this.load.audio('sfx-jump', ['audio/jump.ogg', 'audio/jump.mp3']);
}
create() {
this.scene.start('GameScene');
}
}progress 이벤트는 전체 로딩 진행률을 0에서 1 사이의 값으로 알려 줍니다. fileprogress는 현재 처리 중인 파일 정보를 확인할 때 사용합니다.3 그래서 진행 바에는 progress를 쓰고, 현재 파일 이름 표시에는 fileprogress를 쓰면 자연스럽습니다.
GameScene에서는 이미 로딩된 키를 바로 사용할 수 있습니다. Phaser에서 로딩된 텍스처나 캐시 데이터는 특정 Scene 안에만 갇히지 않고 게임 전체에서 접근할 수 있기 때문입니다.2
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
this.add.image(400, 225, 'bg-sky');
this.add.image(400, 300, 'bg-mountain');
this.player = this.add.sprite(120, 360, 'player')
.setOrigin(0.5, 1)
.setDepth(10);
}
}그리고 게임 설정에는 두 Scene을 순서대로 넣습니다.
const config = {
type: Phaser.AUTO,
width: 800,
height: 450,
backgroundColor: '#1d1f27',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: [LoadingScene, GameScene]
};
new Phaser.Game(config);공용 에셋은 Loading Scene에서 한 번 불러오고, 특정 스테이지에서만 쓰는 에셋은 해당 Scene에서 불러오는 식으로 나누면 초기 로딩 시간을 조절하고 프로젝트 구조를 더 깔끔하게 만들 수 있습니다. 다만 한 번 로딩된 에셋은 기본적으로 전역 캐시에 남아 있으므로, Scene을 나눈다고 해서 메모리가 자동으로 비워지는 것은 아닙니다. 정말 메모리를 줄여야 하는 상황에서는 사용이 끝난 텍스처나 오디오를 명시적으로 정리하는 전략까지 함께 생각해야 합니다.
화면 비율이 달라질 때 생기는 문제
개발할 때는 보통 800x450 같은 기준 해상도를 정합니다. 하지만 실제 플레이어는 노트북, 태블릿, 휴대폰처럼 서로 다른 화면에서 게임을 실행합니다. 이때 이미지가 어긋나거나 UI가 잘리는 문제가 생길 수 있습니다.
먼저 기준 크기를 정하세요.
const config = {
width: 800,
height: 450,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
}
};Phaser.Scale.FIT은 게임 화면의 비율을 유지한 채 가능한 크게 맞춥니다. 덕분에 게임 내부 좌표는 800x450 기준으로 유지하면서, 실제 캔버스만 브라우저 크기에 맞게 확대 또는 축소됩니다.
그래도 배치 규칙은 신경 써야 합니다. 화면 가장자리에 붙는 UI는 숫자를 고정하기보다 기준 화면 크기에서 계산하는 편이 좋습니다.
const { width, height } = this.scale;
this.add.text(20, 20, 'Score: 0', {
fontSize: '20px',
color: '#ffffff'
});
this.add.image(width - 24, 24, 'ui-heart-full')
.setOrigin(1, 0);이렇게 하면 오른쪽 위에 붙어야 하는 UI를 x = 776처럼 직접 쓰지 않아도 됩니다. 나중에 기준 해상도를 바꾸거나 다른 화면 크기 전략을 적용할 때 수정할 부분이 줄어듭니다.
배경 이미지는 두 가지 방식 중 하나를 선택합니다.
화면 안에 모두 보이게 하려면 setDisplaySize()를 씁니다.
this.add.image(0, 0, 'bg-sky')
.setOrigin(0, 0)
.setDisplaySize(this.scale.width, this.scale.height);반대로 화면을 꽉 채우되 일부가 잘려도 괜찮다면, 더 큰 비율로 확대합니다.
const bg = this.add.image(this.scale.width / 2, this.scale.height / 2, 'bg-sky');
const scaleX = this.scale.width / bg.width;
const scaleY = this.scale.height / bg.height;
const scale = Math.max(scaleX, scaleY);
bg.setScale(scale);첫 번째 방식은 화면 크기에 딱 맞출 수 있지만, 원본 이미지와 화면 비율이 다르면 그림이 늘어나 보일 수 있습니다. 두 번째 방식은 비율을 유지하지만 이미지 가장자리가 잘릴 수 있습니다. 배경 일러스트에는 두 번째 방식이 자연스러운 경우가 많고, UI나 픽셀아트에는 첫 번째 방식을 조심해서 써야 합니다.
미니 프로젝트: 첫 화면 구성하기
마지막으로 지금까지의 내용을 하나로 묶어 봅시다. 목표는 로딩 화면을 거쳐 간단한 게임 첫 화면을 보여 주는 것입니다.
class GameScene extends Phaser.Scene {
constructor() {
super('GameScene');
}
create() {
const { width, height } = this.scale;
this.add.image(0, 0, 'bg-sky')
.setOrigin(0, 0)
.setDisplaySize(width, height)
.setDepth(0);
this.add.image(width / 2, height - 80, 'bg-mountain')
.setOrigin(0.5, 1)
.setDepth(1);
this.player = this.add.sprite(120, height - 40, 'player')
.setOrigin(0.5, 1)
.setScale(2)
.setDepth(10);
this.add.text(20, 20, 'Phaser Adventure', {
fontSize: '28px',
color: '#ffffff'
}).setDepth(100);
}
}이 예제에서 배경은 화면 전체에 맞췄고, 산 이미지는 아래쪽 중앙에 붙였으며, 플레이어는 바닥 기준으로 세웠습니다. 텍스트는 가장 앞에 보이도록 depth를 높였습니다.
이제 우리는 게임 화면을 구성하는 기본 재료를 다룰 수 있습니다. 다음 단계는 정적인 이미지를 움직이는 캐릭터로 바꾸는 일입니다. 스프라이트시트를 프레임 단위로 나누고, 걷기와 점프 같은 애니메이션을 붙이면 화면은 비로소 게임답게 움직이기 시작합니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.