Skip to main content
Summary

게임은 설정 객체에서 시작한다

Phaser 프로젝트의 출발점은 보통 한 줄입니다.

new Phaser.Game(config);

여기서 중요한 것은 new Phaser.Game() 자체보다 괄호 안에 들어가는 config입니다. config는 "이 게임을 어떤 크기로 보여 주고, 어떤 물리 엔진을 쓰고, 어떤 Scene부터 실행할지"를 Phaser에게 알려 주는 설계도입니다.

가장 기본적인 설정은 다음과 같습니다.

const config = {
  type: Phaser.AUTO,
  width: 800,
  height: 450,
  backgroundColor: '#1d2433',
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 0 },
      debug: false
    }
  },
  scene: PlayScene
};

new Phaser.Game(config);

width와 height는 게임 화면의 기준 크기입니다. 브라우저 창 크기와 항상 같은 뜻은 아닙니다. 여기서는 Phaser가 만들 캔버스와 기본 카메라 화면을 가로 800, 세로 450 크기로 시작하겠다는 의미에 가깝습니다. 처음에는 이 크기 안에서 오브젝트의 위치와 움직임을 생각하면 됩니다. 나중에 모바일 대응을 할 때는 화면에 맞춰 확대하거나 축소하고, 필요하다면 카메라나 월드 크기를 따로 다루게 됩니다.

physics는 물리 계산 방식을 정합니다. 이 책에서는 초반에 arcade를 주로 사용합니다. Arcade Physics는 Phaser에서 제공하는 가벼운 2D 물리 시스템입니다. 중력, 속도, 충돌 같은 기본 기능을 빠르게 붙일 수 있어 플랫포머, 슈팅, 캐주얼 게임에 잘 맞습니다.

scene은 실제 게임 코드가 들어갈 장소를 지정합니다. 처음에는 하나의 Scene만 넣어도 됩니다.

scene: PlayScene

게임이 커지면 배열로 여러 Scene을 등록합니다.

scene: [BootScene, TitleScene, PlayScene, GameOverScene]

이렇게 등록해 두면 제목 화면, 실제 플레이, 게임 오버 화면을 분리해서 관리할 수 있습니다. 보통 배열의 첫 번째 Scene부터 시작하고, 이후에는 코드로 다른 Scene을 실행하거나 전환합니다.

Phaser 게임의 기본 구조 이해하기AI 생성 이미지

Scene은 화면이 아니라 게임 상태다

Scene을 "화면 하나"라고만 이해하면 금방 막힙니다. 제목 화면 Scene, 플레이 화면 Scene처럼 생각하는 것은 출발점으로 좋습니다. 하지만 더 정확히는 Scene은 게임의 한 상태를 관리하는 실행 단위입니다.

예를 들어 다음과 같이 나눌 수 있습니다.

  • BootScene: 기본 설정과 공통 에셋 준비

  • TitleScene: 시작 버튼과 메뉴 표시

  • PlayScene: 플레이어, 적, 충돌, 점수 처리

  • UIScene: 체력, 점수, 남은 시간 표시

  • GameOverScene: 결과 화면과 다시 시작 처리

중요한 점은 Scene이 반드시 하나씩만 보일 필요는 없다는 것입니다. 플레이 화면 위에 UI Scene을 겹쳐 띄울 수도 있습니다. 예를 들어 PlayScene은 게임 규칙과 충돌을 처리하고, UIScene은 점수와 체력만 표시하게 만들 수 있습니다.

Phaser에서 Scene은 실행 상태와 표시 상태를 나누어 관리할 수 있습니다. 활성화된 Scene은 갱신되고, 보이는 Scene은 렌더링됩니다. 그래서 어떤 Scene은 화면에 남겨 둔 채 업데이트만 잠시 멈추게 할 수도 있고, 어떤 Scene은 완전히 잠들게 해서 갱신과 렌더링을 모두 쉬게 할 수도 있습니다.1 일시정지 화면이나 UI 오버레이를 만들 때 이런 구조가 자주 쓰입니다.

처음부터 Scene을 많이 나눌 필요는 없습니다. 하지만 "이 코드는 어떤 상태의 책임인가?"라고 묻는 습관은 초반부터 들이는 것이 좋습니다. 플레이어 이동 코드는 PlayScene에, 시작 버튼 코드는 TitleScene에 두는 식입니다. 이 구분이 생기면 게임이 커져도 코드가 한 파일 안에서 뒤엉키는 일을 줄일 수 있습니다.

preload, create, update의 역할

Phaser Scene에서 가장 자주 만나는 메서드는 세 가지입니다.

class PlayScene extends Phaser.Scene {
  preload() {
    // 에셋을 불러온다
  }

  create() {
    // 게임 오브젝트를 만들고 초기 상태를 정한다
  }

  update(time, delta) {
    // 매 프레임 상태를 갱신한다
  }
}

세 메서드는 실행 시점이 다릅니다. 그래서 아무 코드나 원하는 곳에 넣으면 안 됩니다.

preload는 이미지, 사운드, 스프라이트 시트 같은 외부 파일을 미리 불러오는 곳입니다.

preload() {
  this.load.image('player', 'assets/player.png');
  this.load.image('coin', 'assets/coin.png');
}

여기서 'player'와 'coin'은 나중에 사용할 이름표입니다. 실제 파일 경로를 매번 쓰지 않고, create에서 이 키를 사용합니다.

create() {
  this.player = this.physics.add.sprite(100, 200, 'player');
  this.coin = this.physics.add.sprite(400, 200, 'coin');
}

create는 Scene이 시작될 때 한 번 실행됩니다. 배경을 놓고, 플레이어를 만들고, 키보드 입력을 준비하고, 점수 변수를 초기화하는 코드는 대부분 이곳에 둡니다.

update는 게임이 실행되는 동안 계속 반복됩니다. 보통 초당 60번 안팎으로 호출됩니다. 그래서 플레이어가 왼쪽 키를 누르고 있는지 확인하거나, 적이 움직이게 하거나, 시간이 줄어드는 처리를 이곳에서 합니다.

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

정리하면 이렇습니다.

위치담당하는 일예시
preload파일 불러오기이미지, 사운드, 스프라이트 시트 로드
create처음 한 번 만들기플레이어 생성, 점수판 생성, 입력 준비
update매 프레임 갱신이동, 입력 확인, 시간 변화, 반복 규칙 처리

이 구분만 정확히 잡아도 Phaser 코드의 절반은 정리됩니다.

한 번만 할 일과 매 프레임 할 일

초보자가 자주 하는 실수는 update 안에서 계속 새 객체를 만드는 것입니다.

update() {
  this.add.text(20, 20, 'Score: 0'); // 나쁜 예
}

이 코드는 매 프레임 새로운 텍스트를 만듭니다. 1초에 60번 실행된다면 10초 뒤에는 텍스트가 600개 생깁니다. 화면에는 비슷하게 보일 수 있지만, 내부에서는 필요 없는 객체가 계속 쌓입니다.

점수판은 한 번 만들고, 내용만 바꿔야 합니다.

create() {
  this.score = 0;
  this.scoreText = this.add.text(20, 20, 'Score: 0', {
    fontSize: '24px',
    color: '#ffffff'
  });
}

update() {
  this.scoreText.setText(`Score: ${this.score}`);
}

더 좋은 방법은 점수가 바뀌는 순간에만 텍스트를 수정하는 것입니다.

addScore(amount) {
  this.score += amount;
  this.scoreText.setText(`Score: ${this.score}`);
}

이제 점수를 얻었을 때만 addScore(10)을 호출하면 됩니다. 매 프레임 할 필요가 없는 코드는 매 프레임 실행하지 않는 것이 기본 원칙입니다.

구분 기준은 간단합니다.

  • 처음 한 번 배치하면 되는가? → create

  • 파일을 먼저 불러와야 하는가? → preload

  • 키를 누르는 동안 계속 확인해야 하는가? → update

  • 충돌이 일어난 순간만 처리하면 되는가? → 충돌 콜백

  • 점수가 바뀌는 순간만 처리하면 되는가? → 별도 메서드

update는 게임의 심장처럼 계속 뛰는 곳입니다. 하지만 모든 코드를 심장에 넣으면 게임이 무거워지고 이해하기 어려워집니다.

작은 예제로 구조 확인하기

아래 예제는 에셋 파일 없이 실행할 수 있는 아주 작은 구조 예제입니다. 플레이어는 초록색 사각형이고, 방향키로 움직입니다.

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

  preload() {
    // 지금은 외부 에셋이 없으므로 비워 둔다.
    // 예: this.load.image('player', 'assets/player.png');
  }

  create() {
    this.score = 0;

    this.player = this.add.rectangle(400, 225, 40, 40, 0x4ade80);
    this.physics.add.existing(this.player);
    this.player.body.setCollideWorldBounds(true);

    this.cursors = this.input.keyboard.createCursorKeys();

    this.scoreText = this.add.text(20, 20, 'Score: 0', {
      fontSize: '24px',
      color: '#ffffff'
    });
  }

  update() {
    const speed = 220;
    const body = this.player.body;

    body.setVelocity(0);

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

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

const config = {
  type: Phaser.AUTO,
  width: 800,
  height: 450,
  backgroundColor: '#1d2433',
  physics: {
    default: 'arcade',
    arcade: {
      gravity: { y: 0 },
      debug: false
    }
  },
  scene: PlayScene
};

new Phaser.Game(config);

이 예제에서 구조를 다시 보면 다음과 같습니다.

config는 게임 전체의 실행 조건을 정합니다. 화면 크기, 배경색, 물리 엔진, 시작 Scene이 여기 들어갑니다.

constructor는 Scene의 이름을 정합니다. 여기서는 'PlayScene'이라는 키를 붙였습니다. 나중에 다른 Scene에서 이 이름으로 전환할 수 있습니다.

preload는 비어 있지만 일부러 남겨 두었습니다. 실제 게임에서는 이곳에 이미지와 사운드 로딩 코드가 들어갑니다.

create에서는 플레이어 사각형, 물리 몸체, 키보드 입력, 점수 텍스트를 만듭니다. 모두 처음 한 번 준비하면 되는 것들입니다.

update에서는 방향키 상태를 계속 확인합니다. 키 입력은 매 순간 바뀔 수 있으므로 반복해서 확인해야 합니다.

전역 변수 대신 Scene 내부 상태 사용하기

작은 예제에서는 전역 변수를 써도 당장 문제없어 보입니다.

let score = 0;
let player;

하지만 게임이 커지면 전역 변수는 서로 영향을 주기 쉽습니다. 제목 화면에서도 score를 건드리고, 플레이 화면에서도 score를 건드리고, 게임 오버 화면에서도 같은 이름을 쓰면 어디서 값이 바뀌었는지 추적하기 어렵습니다.

Phaser에서는 Scene 객체 내부에 상태를 보관하는 방식이 자연스럽습니다.

create() {
  this.score = 0;
  this.player = this.physics.add.sprite(100, 200, 'player');
  this.cursors = this.input.keyboard.createCursorKeys();
}

this.score는 현재 Scene의 점수입니다. this.player는 현재 Scene의 플레이어입니다. 이렇게 묶어 두면 "이 데이터는 PlayScene이 관리한다"는 사실이 코드에 드러납니다.

관련 동작도 Scene 안의 메서드로 묶을 수 있습니다.

addScore(amount) {
  this.score += amount;
  this.scoreText.setText(`Score: ${this.score}`);
}

이 패턴은 단순하지만 중요합니다. 데이터와 그 데이터를 바꾸는 코드를 가까이 둡니다. 그러면 나중에 점수 저장, UI 분리, 게임 오버 처리로 확장할 때도 구조가 무너지지 않습니다.

Scene 사이에 데이터를 넘겨야 할 때는 전역 변수부터 만들기보다 Scene 전환 데이터나 별도의 저장 객체를 고려하는 것이 좋습니다. 예를 들어 플레이가 끝날 때 점수를 게임 오버 Scene에 넘길 수 있습니다.

this.scene.start('GameOverScene', {
  score: this.score
});

받는 쪽 Scene에서는 init 메서드로 데이터를 받을 수 있습니다.

init(data) {
  this.finalScore = data.score;
}

init은 Scene이 시작될 때 전달된 데이터를 먼저 받을 수 있는 자리입니다. 그래서 create에서 화면을 만들기 전에 필요한 값, 예를 들어 최종 점수나 현재 레벨 번호 같은 값을 준비해 두기 좋습니다.

이 방식은 "누가 데이터를 만들고, 누가 받는지"가 코드에 남습니다. 전역 변수처럼 아무 곳에서나 바꿀 수 있는 구조보다 안전합니다.

기본 구조를 알면 확장이 쉬워진다

Phaser 게임은 겉으로 보면 이미지, 애니메이션, 충돌, 사운드가 한꺼번에 움직이는 것처럼 보입니다. 하지만 코드의 기본 흐름은 단순합니다.

먼저 config로 게임 전체 조건을 정합니다. 그다음 Scene이 시작됩니다. Scene은 preload에서 필요한 파일을 불러오고, create에서 초기 오브젝트를 만들고, update에서 매 프레임 변화를 처리합니다.

이 흐름을 알고 있으면 새 기능을 넣을 때 위치를 판단할 수 있습니다.

  • 새 이미지를 추가한다면 preload

  • 새 적을 처음 배치한다면 create

  • 적이 계속 따라오게 하려면 update

  • 점수가 바뀌는 순간만 처리하려면 별도 메서드

  • 제목, 플레이, 게임 오버를 나누려면 Scene 분리

Phaser 코드를 잘 쓴다는 것은 API 이름을 많이 외우는 일이 아닙니다. 코드를 놓을 자리를 아는 일입니다. 자리를 알면 작은 예제도 게임답게 커질 수 있고, 커진 게임도 다시 읽을 수 있는 구조를 유지할 수 있습니다.

참고

1Scenes | Phaser Help

책 목록

Phaser로 시작하는 웹 2D 게임 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기