이번 장에서 만들 게임
이번 장에서는 지금까지 배운 내용을 묶어 작은 횡스크롤 플랫폼 게임을 만든다. 플레이어는 왼쪽과 오른쪽으로 이동하고, 점프해서 발판을 건너며, 코인을 모으고, 적을 피한 뒤 도착 지점에 들어간다. 중간에 체크포인트를 지나면 떨어졌을 때 그 위치에서 다시 시작한다.
이 프로젝트의 목표는 기능을 많이 넣는 것이 아니다. 중요한 것은 Phaser 게임을 완성형 구조로 묶는 방법이다. 플랫폼 게임은 입력, 물리, 충돌, 타일맵, 카메라, UI, 적, 아이템이 모두 연결되어야 한다. 그래서 작은 게임이라도 전체 흐름을 익히기에 좋다.
완성형 플랫폼 게임은 보통 하나의 Play Scene 안에서 플레이어, 지형, 아이템, 적, 출구, 카메라, HUD를 함께 관리하고, 레벨 클리어 시 다음 레벨 데이터를 불러오는 구조를 사용한다1. 우리는 이 구조를 단순하게 줄여서 구현한다.
AI 생성 이미지
프로젝트 뼈대 준비하기
먼저 게임 설정에서 Arcade Physics와 중력을 켠다. 플랫폼 게임에서는 중력이 조작감의 중심이다. 값이 낮으면 캐릭터가 붕 뜨고, 값이 높으면 빠르게 떨어진다. 처음에는 y: 1000 정도로 시작한 뒤 점프 속도와 함께 조절하자.
const config = {
type: Phaser.AUTO,
width: 960,
height: 540,
backgroundColor: '#1d2433',
physics: {
default: 'arcade',
arcade: {
gravity: { y: 1000 },
debug: false
}
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
scene: [PlayScene, ClearScene]
};
new Phaser.Game(config);레벨은 Tiled에서 만든 JSON 타일맵을 사용한다고 가정한다. 타일맵에는 다음 이름의 레이어를 둔다.
Ground: 충돌이 있는 지형 레이어Objects: 플레이어 시작점, 코인, 적, 체크포인트, 도착 지점Decor: 충돌이 없는 장식 레이어
Tiled에서 충돌 타일에는 사용자 속성으로 collides = true를 넣어 둔다. Phaser에서는 이 속성을 기준으로 충돌 타일을 한 번에 설정할 수 있다.
const LEVELS = ['level-1', 'level-2'];
class PlayScene extends Phaser.Scene {
constructor() {
super('PlayScene');
}
init(data) {
this.levelIndex = data.levelIndex ?? 0;
this.score = data.score ?? 0;
}
preload() {
this.load.image('tiles', 'assets/tiles/platform-tiles.png');
this.load.tilemapTiledJSON('level-1', 'assets/maps/level-1.json');
this.load.tilemapTiledJSON('level-2', 'assets/maps/level-2.json');
this.load.spritesheet('hero', 'assets/sprites/hero.png', {
frameWidth: 32,
frameHeight: 32
});
this.load.image('coin', 'assets/items/coin.png');
this.load.image('enemy', 'assets/enemies/slime.png');
this.load.image('checkpoint', 'assets/items/checkpoint.png');
this.load.image('goal', 'assets/items/goal.png');
this.load.image('moving-platform', 'assets/tiles/moving-platform.png');
}이 코드는 모든 레벨을 미리 불러온다. 레벨 수가 많아지면 Loading Scene을 따로 두는 편이 좋지만, 미니 프로젝트에서는 단순한 구조가 더 이해하기 쉽다.
타일맵으로 지형 만들기
create()에서는 현재 레벨 키를 선택하고, 타일맵을 만든다. 타일맵의 크기를 기준으로 물리 월드와 카메라 범위도 함께 설정한다.
create() {
this.cursors = this.input.keyboard.createCursorKeys();
const levelKey = LEVELS[this.levelIndex];
const map = this.make.tilemap({ key: levelKey });
const tileset = map.addTilesetImage('platform-tiles', 'tiles');
map.createLayer('Decor', tileset, 0, 0);
this.groundLayer = map.createLayer('Ground', tileset, 0, 0);
this.groundLayer.setCollisionByProperty({ collides: true });
this.levelHeight = map.heightInPixels;
// 낙하 판정을 위해 물리 월드는 카메라보다 아래쪽에 여유를 둔다.
this.physics.world.setBounds(0, 0, map.widthInPixels, map.heightInPixels + 300);
this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
this.createGroups();
this.spawnObjectsFromMap(map);
if (!this.player) {
throw new Error('Objects 레이어에 type이 player인 오브젝트가 필요합니다.');
}
this.createCollisions();
this.setupCamera();
this.respawnPoint = { x: this.player.x, y: this.player.y };
this.wasOnGround = false;
}여기서 중요한 순서는 지형을 먼저 만들고, 그 다음 플레이어와 아이템을 만드는 것이다. 그래야 플레이어와 지형의 충돌을 바로 연결할 수 있다. Tiled 기반 플랫폼 게임에서는 오브젝트 레이어를 읽어 플레이어, 적, 목표 지점 같은 게임 요소를 생성하는 방식이 자주 쓰인다2.
한 가지 더 조심할 점이 있다. map.addTilesetImage('platform-tiles', 'tiles')에서 첫 번째 값은 이미지 파일명이 아니라 Tiled 안에 등록된 타일셋 이름이다. Tiled의 타일셋 이름이 다르면 레이어가 보이지 않으므로, 코드와 Tiled 설정의 이름을 꼭 맞춰 두자.
또 물리 월드의 높이를 map.heightInPixels + 300으로 잡은 이유도 있다. 플레이어에게 setCollideWorldBounds(true)를 적용하면 월드 바깥으로 나가지 못한다. 월드 높이를 맵 높이와 똑같이 두면 플레이어가 아래로 떨어지는 대신 보이지 않는 바닥에 막힐 수 있다. 그래서 카메라 범위는 맵 크기에 맞추고, 물리 월드만 아래쪽으로 조금 더 넓혀 낙하 판정이 자연스럽게 일어나도록 했다.
오브젝트 레이어에서 게임 요소 만들기
이제 Objects 레이어를 읽어 게임 오브젝트를 만든다. Tiled의 각 오브젝트에는 type을 넣어 둔다고 가정한다.
player: 플레이어 시작 위치coin: 코인enemy: 적checkpoint: 체크포인트goal: 도착 지점movingPlatform: 이동 발판
createGroups() {
this.coins = this.physics.add.staticGroup();
this.enemies = this.physics.add.group();
this.checkpoints = this.physics.add.staticGroup();
this.goals = this.physics.add.staticGroup();
this.movingPlatforms = this.physics.add.group({
allowGravity: false,
immovable: true
});
}
spawnObjectsFromMap(map) {
const layer = map.getObjectLayer('Objects');
if (!layer) {
throw new Error('Objects 레이어를 찾을 수 없습니다.');
}
layer.objects.forEach((obj) => {
const x = obj.x;
const y = obj.y;
if (obj.type === 'player') {
this.player = this.physics.add.sprite(x, y, 'hero');
this.player.setCollideWorldBounds(true);
this.player.body.setSize(20, 28).setOffset(6, 4);
}
if (obj.type === 'coin') {
this.coins.create(x, y, 'coin');
}
if (obj.type === 'enemy') {
const enemy = this.enemies.create(x, y, 'enemy');
enemy.setVelocityX(-60);
enemy.setBounceX(1);
enemy.setCollideWorldBounds(true);
}
if (obj.type === 'checkpoint') {
this.checkpoints.create(x, y, 'checkpoint');
}
if (obj.type === 'goal') {
this.goals.create(x, y, 'goal');
}
if (obj.type === 'movingPlatform') {
const platform = this.movingPlatforms.create(x, y, 'moving-platform');
platform.body.setAllowGravity(false);
platform.body.setImmovable(true);
platform.body.setVelocityX(80);
platform.startX = x;
platform.range = obj.properties?.find((p) => p.name === 'range')?.value ?? 160;
}
});
}좌표가 약간 어긋나 보이면 Tiled에서 오브젝트의 기준점과 Phaser에서 스프라이트의 origin이 다르기 때문이다. 이때는 setOrigin(0.5, 1)처럼 기준점을 맞추거나, 생성할 때 y - 16 같은 보정값을 넣는다. 중요한 것은 좌표 보정을 여기저기 흩어 놓지 않는 것이다. 오브젝트 생성 함수 안에서 한 번만 처리하자.
충돌과 겹침 연결하기
플랫폼 게임에서 모든 접촉이 같은 의미는 아니다. 지형과 플레이어는 서로 통과하면 안 되므로 collider를 쓴다. 코인, 체크포인트, 도착 지점은 닿았다는 사실만 필요하므로 overlap을 쓴다.
createCollisions() {
this.physics.add.collider(this.player, this.groundLayer);
this.physics.add.collider(this.enemies, this.groundLayer);
this.physics.add.collider(this.player, this.movingPlatforms);
this.physics.add.overlap(this.player, this.coins, this.collectCoin, null, this);
this.physics.add.overlap(this.player, this.enemies, this.hitEnemy, null, this);
this.physics.add.overlap(this.player, this.checkpoints, this.activateCheckpoint, null, this);
this.physics.add.overlap(this.player, this.goals, this.clearLevel, null, this);
}
collectCoin(player, coin) {
coin.disableBody(true, true);
this.score += 1;
}
activateCheckpoint(player, checkpoint) {
this.respawnPoint = { x: checkpoint.x, y: checkpoint.y - 20 };
checkpoint.setTint(0x66ff99);
}적과 닿았을 때는 단순히 실패로 처리하지 말고, 밟았는지 부딪혔는지를 나눈다. 위에서 아래로 떨어지며 적을 밟으면 적을 없애고, 옆에서 닿으면 플레이어가 피해를 입는다.
hitEnemy(player, enemy) {
const stomped = player.body.velocity.y > 120 && player.y < enemy.y - 10;
if (stomped) {
enemy.disableBody(true, true);
player.setVelocityY(-260);
this.score += 3;
return;
}
this.respawnPlayer();
}
respawnPlayer() {
this.player.setPosition(this.respawnPoint.x, this.respawnPoint.y);
this.player.setVelocity(0, 0);
}여기서는 적을 아주 단순하게 만들었다. 적은 좌우로 움직이고, 벽이나 월드 경계에 닿으면 방향을 바꾼다. 낭떠러지 앞에서 되돌아가게 만들거나, 플레이어를 감지해 따라오게 만드는 행동은 다음 단계에서 추가하면 된다.
기본 조작감 만들기
플랫폼 게임의 조작감은 대부분 세 숫자에서 시작한다.
좌우 이동 속도
점프 속도
중력
Arcade Physics에서는 수평 이동에 setVelocityX(), 점프에 setVelocityY()를 쓰면 간단히 조작을 만들 수 있다. 더 부드러운 움직임이 필요하면 가속도와 드래그를 사용할 수 있지만, 첫 미니 프로젝트에서는 즉각 반응하는 방식이 좋다. 중력, 점프 속도, 드래그, 최대 속도는 플랫폼 게임의 느낌을 결정하는 대표적인 조정값이다3.
update() {
this.handlePlayerInput();
this.updateMovingPlatforms();
this.checkLanding();
this.checkFall();
}
handlePlayerInput() {
const speed = 190;
const jumpVelocity = -410;
const body = this.player.body;
const onGround = body.blocked.down || body.touching.down;
if (this.cursors.left.isDown) {
this.player.setVelocityX(-speed);
this.player.setFlipX(true);
} else if (this.cursors.right.isDown) {
this.player.setVelocityX(speed);
this.player.setFlipX(false);
} else {
this.player.setVelocityX(0);
}
if (Phaser.Input.Keyboard.JustDown(this.cursors.up) && onGround) {
this.player.setVelocityY(jumpVelocity);
}
if (Phaser.Input.Keyboard.JustUp(this.cursors.up) && body.velocity.y < -160) {
this.player.setVelocityY(-160);
}
}onGround를 body.blocked.down || body.touching.down으로 둔 이유는 지형과 이동 플랫폼의 판정이 조금 다르게 들어올 수 있기 때문이다. 타일 지형 위에서는 blocked.down이 주로 쓰이고, 다른 물리 바디 위에 올라선 상황에서는 touching.down이 필요할 때가 있다. 둘을 함께 보면 일반 지형과 이동 플랫폼 위에서 모두 점프가 자연스럽게 동작한다.
마지막 JustUp 조건은 짧게 누르면 낮게 뛰고, 길게 누르면 높게 뛰게 만드는 간단한 장치다. 점프 키를 빨리 떼면 위로 올라가는 속도를 줄인다. 이것만으로도 조작감이 훨씬 좋아진다.
착지 판정도 넣자. 착지는 애니메이션, 먼지 파티클, 효과음과 연결하기 좋은 순간이다. 여기서는 구조만 만든다.
checkLanding() {
const body = this.player.body;
const onGround = body.blocked.down || body.touching.down;
if (!this.wasOnGround && onGround) {
// 나중에 착지 사운드나 먼지 파티클을 연결할 수 있다.
}
this.wasOnGround = onGround;
}낙하 구간과 이동 플랫폼
낙하 구간은 별도의 오브젝트가 없어도 만들 수 있다. 맵 아래로 떨어졌는지만 확인하면 된다. 앞에서 this.levelHeight에 타일맵의 실제 높이를 저장해 두었으므로, 그보다 조금 더 아래로 내려가면 리스폰시키면 된다.
checkFall() {
const bottomLimit = this.levelHeight + 120;
if (this.player.y > bottomLimit) {
this.respawnPlayer();
}
}이동 플랫폼은 움직이지만 중력의 영향을 받지 않는 물리 오브젝트로 만든다. 일정 범위를 벗어나면 방향을 바꾼다.
updateMovingPlatforms() {
this.movingPlatforms.children.iterate((platform) => {
if (!platform) return;
const moved = Math.abs(platform.x - platform.startX);
if (moved > platform.range) {
platform.body.setVelocityX(platform.body.velocity.x * -1);
}
});
}처음에는 좌우 이동만 구현하자. 위아래 이동, 플레이어를 자연스럽게 싣고 가는 보정, 멈췄다 움직이는 패턴은 나중에 추가하면 된다. 미니 프로젝트에서는 기능보다 안정성이 먼저다.
카메라 따라가기
횡스크롤 게임에서는 카메라가 플레이어를 따라가야 한다. 맵 크기를 카메라 범위로 지정해 두었으므로, startFollow()만 연결하면 된다.
setupCamera() {
const camera = this.cameras.main;
camera.startFollow(this.player, true, 0.12, 0.12);
camera.setDeadzone(120, 80);
camera.fadeIn(300);
}setDeadzone()은 카메라가 너무 민감하게 흔들리는 것을 줄여 준다. 플레이어가 화면 중앙 근처에서 조금 움직일 때는 카메라가 움직이지 않고, 일정 범위를 벗어나면 따라간다. 플랫폼 게임에서는 작은 점프마다 화면이 흔들리면 피로감이 커지므로 데드존을 두는 편이 좋다.
조작감 다듬기
게임이 돌아간다고 끝난 것이 아니다. 플랫폼 게임은 숫자 조정이 절반이다. 다음 순서로 조정하면 시행착오가 줄어든다.
첫째, 충돌 박스를 먼저 맞춘다. 스프라이트 그림 전체를 충돌 영역으로 쓰면 발끝이나 머리카락 때문에 억울한 충돌이 생긴다. 플레이어 몸통보다 살짝 작은 크기로 맞춘다.
this.player.body.setSize(20, 28).setOffset(6, 4);둘째, 점프 높이를 정한다. 원하는 발판 높이를 기준으로 점프 속도를 바꾼다. 낮으면 -430, 너무 높으면 -380처럼 조정한다. 점프 값은 음수다. Phaser의 y좌표는 아래로 갈수록 커지기 때문이다.
셋째, 떨어지는 속도를 본다. 캐릭터가 너무 오래 공중에 있으면 중력을 높인다. 너무 빨리 떨어져 제어하기 어렵다면 중력을 낮춘다.
넷째, 좌우 속도를 정한다. 발판 사이 간격이 좁은데 속도가 빠르면 미끄러지는 느낌이 난다. 반대로 맵이 넓은데 속도가 느리면 답답하다. 레벨 크기와 발판 간격을 함께 보며 조정해야 한다.
좋은 기준값은 다음과 같다.
const PLAYER_TUNING = {
speed: 190,
jumpVelocity: -410,
gravityY: 1000,
shortJumpCut: -160
};이 값은 정답이 아니다. 레벨 디자인과 함께 바뀌는 출발점이다.
레벨 클리어와 다음 맵으로 이동하기
도착 지점에 닿으면 다음 레벨로 넘어간다. 마지막 레벨이면 Clear Scene으로 이동한다.
clearLevel() {
const nextLevelIndex = this.levelIndex + 1;
if (nextLevelIndex >= LEVELS.length) {
this.scene.start('ClearScene', {
score: this.score
});
return;
}
this.scene.restart({
levelIndex: nextLevelIndex,
score: this.score
});
}
}여기서 score를 같이 넘기는 이유는 레벨이 바뀌어도 점수를 유지하기 위해서다. 체력, 남은 시간, 획득한 열쇠 같은 데이터도 같은 방식으로 넘길 수 있다. 프로젝트가 커지면 Registry나 별도 저장 객체로 분리하면 된다.
Clear Scene은 단순해도 충분하다.
class ClearScene extends Phaser.Scene {
constructor() {
super('ClearScene');
}
create(data) {
this.add.text(480, 220, 'CLEAR!', {
fontSize: '48px',
color: '#ffffff'
}).setOrigin(0.5);
this.add.text(480, 290, `Score: ${data.score ?? 0}`, {
fontSize: '24px',
color: '#ffdd66'
}).setOrigin(0.5);
}
}작은 완성 기준 세우기
이번 프로젝트는 다음 조건을 만족하면 완성으로 본다.
플레이어가 좌우 이동과 점프를 할 수 있다.
타일맵 지형 위에서 떨어지지 않고 충돌한다.
코인을 먹으면 점수가 오른다.
적을 밟으면 제거되고, 옆에서 닿으면 체크포인트로 돌아간다.
낙하 구간에 빠지면 체크포인트에서 재시작한다.
도착 지점에 닿으면 다음 레벨로 넘어간다.
마지막 레벨을 클리어하면 Clear Scene이 나온다.
이 기준을 먼저 만족시킨 뒤 애니메이션, 사운드, 파티클, UI를 붙이면 된다. 반대로 처음부터 모든 효과를 넣으려 하면 어디에서 문제가 생겼는지 찾기 어렵다.
플랫폼 게임은 작은 숫자의 게임이다. 속도 몇 픽셀, 점프 속도 몇 단위, 충돌 박스 몇 픽셀이 게임의 느낌을 바꾼다. 코드를 완성한 뒤에는 반드시 직접 플레이하며 조정하자. Phaser의 핵심 기능은 이미 준비되어 있다. 이제 중요한 것은 그 기능들을 한 게임 안에서 일관된 규칙으로 묶는 일이다.
참고
1Phaser Platformer Tutorial: Build a Complete 2D Platformer Game
2Platformer Tutorial with Phaser and Tiled
3Phaser Physics Tutorial: Complete Guide to Arcade Physics & Box2D
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.