웹 브라우저에서 돌아가는 2D 게임을 만들고 싶어서 Phaser 3를 선택했다. Unity나 Godot 같은 본격적인 엔진도 있지만, 간단한 2D 게임이라면 Phaser로 충분하다. 특히 웹 배포가 기본이라 별도의 빌드 과정 없이 브라우저에서 바로 테스트할 수 있는 게 큰 장점이다.

이번에 만들 건 탑다운 뷰의 간단한 RPG 스타일 게임이다. 캐릭터가 맵을 돌아다니고, 장애물에 충돌하고, 아이템을 줍는 정도의 기본 기능을 구현해본다.

프로젝트 세팅

mkdir topdown-game && cd topdown-game
npm init -y
npm install phaser

Vite를 번들러로 쓰면 개발이 편하다:

npm install -D vite

index.html을 만든다:

<!DOCTYPE html>
<html>
<head>
  <title>Topdown Game</title>
  <style>
    body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
  </style>
</head>
<body>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

게임 설정

src/main.js에서 Phaser 게임을 초기화한다:

import Phaser from 'phaser';
import { GameScene } from './scenes/GameScene';

const config = {
  type: Phaser.AUTO,
  width: 800,
  height: 600,
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 0 }, // 탑다운이니까 중력 없음
      debug: false,
    },
  },
  scene: [GameScene],
  pixelArt: true, // 픽셀 아트 스타일이면 이걸 켜야 안티앨리어싱이 안 먹힌다
};

new Phaser.Game(config);

탑다운 게임은 중력이 없다는 게 핵심이다. 플랫포머와 달리 위에서 내려다보는 시점이니까 gravity.y를 0으로 설정한다.

타일맵 만들기

맵을 만들려면 Tiled라는 무료 맵 에디터를 쓰는 게 좋다. Tiled에서 만든 JSON 맵 파일을 Phaser에서 바로 로드할 수 있다.

간단하게 설명하면:

  1. Tiled에서 타일셋 이미지를 불러온다 (16x16 또는 32x32 타일)
  2. 바닥 레이어, 장애물 레이어, 오브젝트 레이어를 나눠서 만든다
  3. JSON 형식으로 내보낸다

Phaser에서 맵을 로드하는 코드:

export class GameScene extends Phaser.Scene {
  constructor() {
    super('GameScene');
  }

  preload() {
    this.load.image('tiles', '/assets/tileset.png');
    this.load.tilemapTiledJSON('map', '/assets/map.json');
    this.load.spritesheet('player', '/assets/player.png', {
      frameWidth: 32,
      frameHeight: 32,
    });
  }

  create() {
    // 맵 생성
    const map = this.make.tilemap({ key: 'map' });
    const tileset = map.addTilesetImage('tileset', 'tiles');
    
    const groundLayer = map.createLayer('Ground', tileset);
    const wallLayer = map.createLayer('Walls', tileset);
    
    // 벽 레이어에 충돌 설정
    wallLayer.setCollisionByProperty({ collides: true });

    // 플레이어 생성
    this.player = this.physics.add.sprite(400, 300, 'player');
    this.player.setCollideWorldBounds(true);
    
    // 벽과 플레이어 충돌
    this.physics.add.collider(this.player, wallLayer);

    // 키보드 입력
    this.cursors = this.input.keyboard.createCursorKeys();

    // 카메라가 플레이어를 따라가게
    this.cameras.main.startFollow(this.player);
    this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
  }

  update() {
    const speed = 160;
    this.player.setVelocity(0);

    if (this.cursors.left.isDown) {
      this.player.setVelocityX(-speed);
    } else if (this.cursors.right.isDown) {
      this.player.setVelocityX(speed);
    }

    if (this.cursors.up.isDown) {
      this.player.setVelocityY(-speed);
    } else if (this.cursors.down.isDown) {
      this.player.setVelocityY(speed);
    }

    // 대각선 이동 시 속도 정규화
    this.player.body.velocity.normalize().scale(speed);
  }
}

여기서 중요한 부분이 대각선 이동 속도 정규화다. 이걸 안 하면 대각선으로 이동할 때 가로+세로 속도가 합쳐져서 다른 방향보다 약 1.4배 빨라진다. normalize().scale(speed) 한 줄로 해결된다.

캐릭터 애니메이션

스프라이트시트를 만들어서 방향별 걷기 애니메이션을 넣는다:

// create() 안에서
this.anims.create({
  key: 'walk-down',
  frames: this.anims.generateFrameNumbers('player', { start: 0, end: 3 }),
  frameRate: 8,
  repeat: -1,
});

this.anims.create({
  key: 'walk-up',
  frames: this.anims.generateFrameNumbers('player', { start: 4, end: 7 }),
  frameRate: 8,
  repeat: -1,
});

this.anims.create({
  key: 'walk-left',
  frames: this.anims.generateFrameNumbers('player', { start: 8, end: 11 }),
  frameRate: 8,
  repeat: -1,
});

this.anims.create({
  key: 'walk-right',
  frames: this.anims.generateFrameNumbers('player', { start: 12, end: 15 }),
  frameRate: 8,
  repeat: -1,
});

update()에서 이동 방향에 따라 애니메이션을 전환한다:

if (this.cursors.left.isDown) {
  this.player.anims.play('walk-left', true);
} else if (this.cursors.right.isDown) {
  this.player.anims.play('walk-right', true);
} else if (this.cursors.up.isDown) {
  this.player.anims.play('walk-up', true);
} else if (this.cursors.down.isDown) {
  this.player.anims.play('walk-down', true);
} else {
  this.player.anims.stop();
}

아이템 수집 시스템

맵에 코인을 배치하고 플레이어가 닿으면 수집되게 만든다:

// create() 안에서
this.coins = this.physics.add.group();
this.score = 0;
this.scoreText = this.add.text(16, 16, 'Score: 0', {
  fontSize: '18px',
  fill: '#fff',
}).setScrollFactor(0); // 카메라 이동해도 UI는 고정

// 맵에서 코인 위치를 불러와서 배치
const coinObjects = map.getObjectLayer('Coins');
coinObjects.objects.forEach((coin) => {
  const c = this.coins.create(coin.x, coin.y, 'coin');
  c.body.setAllowGravity(false);
});

// 플레이어가 코인에 닿으면 수집
this.physics.add.overlap(this.player, this.coins, (player, coin) => {
  coin.destroy();
  this.score += 10;
  this.scoreText.setText(`Score: ${this.score}`);
});

빌드와 배포

npx vite build

dist 폴더가 생기고, 이걸 아무 정적 호스팅에 올리면 된다. Cloudflare Pages에 올리면 무료로 빠르게 배포할 수 있다.

정리

Phaser 3로 탑다운 게임의 기본 골격을 만들어봤다. 타일맵, 캐릭터 이동, 충돌 처리, 아이템 수집까지 핵심 기능이 모두 들어있다. 여기에 NPC 대화 시스템이나 인벤토리, 전투 시스템을 얹으면 꽤 그럴듯한 게임이 된다.

Phaser의 장점은 웹 기술 스택을 그대로 쓸 수 있다는 점이다. React로 UI를 입히거나, 서버와 WebSocket으로 멀티플레이어를 구현하는 것도 가능하다. 가벼운 2D 게임을 빠르게 프로토타이핑하고 싶다면 Phaser 3를 추천한다.