HaLake Magazine

コワーキングスペースHaLakeの技術情報発信サイト!IoT,AR,VRなど最新技術情報をお届け!

Phaser3 + Typescriptを使ってRPGゲームの基礎を作ろう!その1

今回はPhaser3とTypescriptを使って簡単なRPGゲームを作る方法を紹介していきます。
内容はPhaser3およびゲーム作りについての記事なので、Nodejsの周辺モジュールなどの説明は一部省いての説明になりますのでご了承ください。 またこの記事ではPhaser2ではなくPhaser3を使用するので注意してください。 この記事は二部構成になりますので、この記事を読んだ際はぜひ次の記事も読むことをお勧めします。

最終目標

・Phaser3とTypescriptでRPGゲームの基礎を作る。
・Phaser3をTypescriptで使う方法を学ぶ
・Phaser3の使い方を学ぶ

今回の目標

・開発環境を整える
・Phaser3の開発構成を知る
・スタート画面を作る
・マップ表示をさせる

開発前提

・Nodejsの環境・知識がある
Javascript・Typescriptがある程度かける
・当ページ紹介の環境を試す場合はgit・githubの知識がある

使用した主要Nodeモジュール

・typescript(Typescriptのコンパイル用)
・phaser(フロントのJavascript用ゲームライブラリ)
・live-server(ソースを監視してブラウザのページをリロード)
・ts-loader(webpackがTypescriptをバンドルする用)webpack(言わずと知れたモジュール依存をいい感じに解決しバンドルする)
・webpack-cli(webpackをコマンドラインで使用するのに必要)

注:各Nodeモジュールバージョンは後述

開発順序

  1. 最低限の開発環境の準備
  2. Phaser3の開発構造について
  3. スタート画面の表示
  4. マップを表示


1. 最低限の開発環境の準備

今回最低限の環境を整えるために、『Typescript + Phaser3』の開発テンプレートをgithubリポジトリで公開しました。
以下からZIPをダウンロードするか、git cloneコマンドで各自環境に展開してみてください。
ここから先はリポジトリのプログラムを元に説明していきます。

github.com

展開するとファイル構造は以下のようになっているかと思われます。
注: 他にもファイルやフォルダがあるかと思われますが、表記されているのは今回使うものになっています。

- src/ (これから書くプログラムの保存領域)
    - scenes/ (ゲームのシーンごとに分割されたプログラムの保存場所)
    - main.ts (今回書くプログラムのエントリーポイント)
    - phaser.d.ts (Typescript用のPhaser3の型定義ファイル)
- index.html (ブラウザで表示するHTMLファイル)
- package.json (依存モジュールやプロジェクトについての記述ファイル)
- tsconfig.json (Typescriptのコンフィグファイル)
- webpack.config.js (webpackのコンフィグファイル)

まずはnpm iでモジュールをインストールしましょう。
もしTypescriptやwebpackがグローバルでインストールされていない場合は、npm i モジュール名 -gでインストールしておきましょう。
インストールが終わったら、npm devでプログラムをバンドル&ブラウザで確認してみましょう。
live-serverモジュールのおかげでブラウザが勝手に起動するかと思われますが、もし起動しなければ手動でブラウザを起動してURL欄にlocalhost:8080と入力して表示してみましょう。 すると以下のような画面になるかと思われます。

f:id:takumishinoda:20190704164628p:plain

画像左上の黒い枠がHTMLのcanvasに描写されたPhaser3の領域になります。 まだ何も書いていないので真っ黒になっています。

次にpackage.jsonファイルを確認して利用されているモジュールも確認しておき、ここに書かれたPhaser3のバージョンが3以上であることを改めて確認しましょう。

...省略
 "dependencies": {
    "phaser": "^3.18.1", <- ここを確認
    "webpack": "^4.35.2",
    "webpack-cli": "^3.3.5"
  },
  "devDependencies": {
    "live-server": "^1.2.1",
    "ts-loader": "^6.0.4",
    "typescript": "^3.5.2",
    "yarn": "^1.16.0"
  }
省略...

注:上の表記は先ほど紹介したリポジトリのpackage.jsonの一部を抜粋


2. Phaser3の開発構造について

はじめに、webpackがバンドルのエントリーポイントとして捉えるsrc/main.tsファイルから説明していきます。
ここではPhaser3のゲームを生成していきます。 紹介したリポジトリのプログラムは以下のようになっているかと思います。

/* src/main.ts */

// Phaser3とシーンプログラムのインポート
import * as Phaser from "phaser";
import { Preload } from "./scenes/preload";
import { Game } from "./scenes/game";


// Phaser3のゲームクラスの記述(Phaser.Gameクラスを継承したMainクラスの記述)
class Main extends Phaser.Game {
  constructor() {

    // Phaser.Gameのコンフィグ
    const config: Phaser.Types.Core.GameConfig = {
      type: Phaser.AUTO,
      width: 800, // ゲーム横幅
      height: 600, // ゲーム縦幅
    };
    super(config); // Phaser.Gameクラスにコンフィグを渡す

    // シーンにキーを割り振って登録
    this.scene.add("preload", Preload, false);
    this.scene.add("game", Game, false);

    // シーンをスタート
    this.scene.start("preload");
  }
}

// ブラウザでDOM描写終了直後に呼び出される
window.onload = () => {

  // Mainクラスのインスタンスを生成(ここで初めてゲームが生成)
  const GameApp: Phaser.Game = new Main();
};

説明はコメントに書いている通りですが、重要なポイントとしてPhaser.Scenes.ScenePlugin.add()メソッドがあります。
第一引数がシーンを見分けるキーで、第二引数はシーンクラス、第三引数は自動でそのシーンをスタートさせるかになります。
今回はそのあとのPhaser.Scenes.ScenePlugin.start()メソッドで手動でシーンを起動するので、第三引数は全てfalseを指定しています。
Phaser.Scenes.ScenePlugin.start()メソッドは引数にシーンクラスに割り振ったキーを渡すことで、そのシーンを起動します。 プログラムでは'preload'が渡されているので、一番初めのシーンはproloadであるとわかります。

次はシーンクラスの作成についての説明です。 Phaser3では、まず一つのゲームがありその中に複数のシーンという概念が基本となっています。
シーンの中では、大まかに4つのフェーズが用意されておりこれらにゲームアセットのロード・描写・更新といった順で記述していきます。
実際のプログラム上ではこれらの流れを、initpreloadcreateupdateの順で記述していきます。
シンプルな例として、リポジトリsrc/scenes/game.tsを開くと以下のようになります。

/* src/scenes/game.ts */

import * as Phaser from "phaser";

export class Game extends Phaser.Scene {
  init() {
    console.log('Initializing.') // クラスのメンバ変数の初期化
  }

  preload() {
    console.log('Load assets.') // アセットのロード
  }

  create() {
    console.log('Draw objects to canvas.') // ゲームオブジェクトの描写
  }

  update() {
    console.log('Call at every frames.') // ゲームの各フレーム更新毎に呼びだされる
  }
}

注意として、preload()でロードしたアセットはPhaserオブジェクトに後に説明するアセットの読み込み時に決めたキーに結びついてキャッシュされるため、別のシーンで読み込んだアセットについては他のシーンで読み込む必要は無くなります。
これを利用することで、あらかじめプレイヤ(ゲームをする人)が必ず通るであろうシーンのアセットをあらかじめ一番最初のシーンでロードしておけば、プレイヤーに与えるロード時間のストレスを軽減することができます。


3. スタート画面の表示

f:id:takumishinoda:20190704183411p:plain
想定する画面

さて、それではここからは実際にゲームを作っていきます。
最初に編集するのはsrc/scenes/preload.tsです。
今回preloadシーンではゲームでよく使うであろうアセットのロードとスタート画面の表示をしていきます。 スタート画面は画像を使用しない方法で表示していきます。
よってアセットのロードが不要なので、いきなりシーンクラスのcreate()から編集していきます。

/* src/scenes/preload.ts */

import * as Phaser from "phaser";

export class Preload extends Phaser.Scene {
  private startText?: Phaser.GameObjects.Text // 追加

  private bk_color: string = '0xe08734' // 追加
  private fontStyle: Phaser.Types.GameObjects.Text.TextSyle = { color: 'red', fontSize: '70px' } //追加

  init() {
    console.log("Preloading");
  }

  preload () {
    console.log("Load things necessary for Game scene")
    // this.scene.start('game') // 削除
  }

  // ここから追加
  create() {
    this.cameras.main.setBackgroundColor(this.bk_color)
    this.startText = this.add.text(400, 300, 'START', this.fontStyle)

    this.startText.setOrigin(0.5)
  }
  // ここまで
}

追加した中で重要なのは、Phaser.Scene.cameras.main.setBackgroundColor()Phaser.Scene.add.text()Phaser.GameObject.Text.setOrigin()メソッドです。

Phaser.Cameras.Scene2D.BaseCamera.setBackgroundColor()メソッドはゲームの背景の色を変えます。
色は第一引数にstring型で渡すことができます。

Phaser.GameObjects.GameObjectFactory.text()メソッドではテキストのゲームオブジェクトを生成できます。
第一・第二引数にXとY座標を、第三引数に表示させたいテキスト、第四引数にテキストのスタイルを渡します。

Phaser.GameObjects.Text.setOrigin()メソッドではゲームオブジェクトのどの部分を表示座標の中心とするかを設定します。
デフォルトでは、テキストの左上が指定した座標になっていてゲーム画面の中心に設置したい場合ゲームオブジェクト生成時、ゲームの中心座標を指定してもテキストの左上が中心にきてしまい、テキスト自体を中心に設置するにはいちいち計算が必要になってしまいます。
そこで、このメソッドを使うことでゲームオブジェクトの真ん中を座標の位置とすることで計算なしでゲームの中心にテキストが生成できます。

f:id:takumishinoda:20190704184844p:plain
setOriginを呼ばなかった場合

f:id:takumishinoda:20190704183411p:plain
setOriginを呼んだ場合

表示させるところまでできましたが、このままではここからゲーム本編へ行くことができません。
そこで表示させたテキストにイベントを設置して、押された場合次のシーンへ移動する実装をしてみましょう。 編集するのはsrc/scenes/preload.tscreate()の中です。

/* src/scenes/preload.ts */

...省略
create() {
    this.cameras.main.setBackgroundColor(this.bk_color)
    this.startText = this.add.text(400, 300, 'START', this.fontStyle)

    this.startText.setOrigin(0.5)

    // ここから追加
    this.startText.setInteractive()
    this.startText.on('pointerdown', () => {
      this.scene.start('game')
    })
    // ここまで
}
...省略

ゲームオブジェクトにイベントをセットするには、ゲームオブジェクトのon()メソッドを使います。
第一引数にstring型でイベント名、第二引数にイベント発火時のコールバックを渡します。
第一引数に'pointerdown'を渡すことでゲームオブジェクトにクリック(マウスボタンが押された瞬間)かタッチ(指が触れた瞬間)にイベントが発火するようになります。
厳密にマウスクリックやタッチを認識するには別の方法がありますが、今回はもっとも簡単に実装しています。
コールバックでは前述のメソッドを呼び出して'game'キーに紐付けされたシーンに飛ぶようになっています。

しかしこれだけを実装した状態でテストしてみると、何も起こらないのが確認できます。
実はゲームオブジェクトにユーザ操作からのイベントを発火するには、ゲームオブジェクトのsetInteractive()メソッドを呼ばなければいけません。
これを呼ばないと、ゲームオブジェクトはユーザ操作による割り込みが無効となっておりイベントが発火しません。 setInteractive()メソッドを実装すると、イベントが発火してシーンが移動し再び真っ暗な画面になったかと思います。 これでひとまずpreloadの実装が終わりました。


4. マップを表示

ここまでで、preloadシーンからgameシーンに移動するところまでができました。
次はgameシーンの編集をしていきます。
今回の実装では、ついに画像をロードして表示していきます。
編集するのはsrc/scenes/game.tsファイルになります。
また、今回使用する画像は前述で説明したリポジトリには含まれていないので、試したい方は各自下のマップチップ画像をpng形式で、map_tile.pngという名前でリポジトリ直下にassets/images/ディレクトリを作成して配置しましょう。
マップチップ画像とは、マップを表示するために必要な一定のサイズの小さな画像を一枚の画像として合わせて作られたマップの元になる画像を示します。

注:以下の画像素材は『ピポヤ倉庫』より許可なしで無償再配布・改変が認められたものを改変して作成されたものです。中には許可なく再配布・改変してはいけない素材もインターネット上には多く存在するのでそれらを使用するときは十分に規約を呼んでから使用しましょう。

f:id:takumishinoda:20190706131315p:plain
マップチップ画像

/* src/scenes/game.ts */
import * as Phaser from "phaser";

export class Game extends Phaser.Scene {
  // ここから追加
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer

  private map_ground: number[][] = [
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
    [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
  ] // 20 * 15
  // ここまで

  init() {
    console.log('Initializing.')
  }

  preload() {
    console.log('Load assets.')

    this.load.image('mapTiles', `../../assets/images/map_tile.png`) // 追加
  }

  create() {
    console.log('Draw objects to canvas.')

    // ここから追加
    this.map = this.make.tilemap({ data: this.map_ground, tileWidth: 40, tileHeight: 40 }) // タイルマップ生成
    this.tiles = this.map.addTilesetImage(`mapTiles`) // マップチップ画像のキーを渡す
    this.map_ground_layer = this.map.createStaticLayer(0, this.tiles, 0, 0) // レイヤー作成
    // ここまで
  }

  update() {
    console.log('Call at every frames.')
  }
}

f:id:takumishinoda:20190706135541p:plain
表示されたマップ

src/scenes/game.tsファイルを編集してブラウザで確認してみると、先ほどまでスタート画面のSTARTを押すと真っ黒な画面が表示されましたが、今回は以下の画像のようにマップが表示されたかと思われます。 それではプログラムの説明をしていきます。

まずクラスメンバ変数として追記したプログラムの中にnumber型の二次元配列が定義されていますがこれは表示するマップを示していて、数値は先ほどの画像をタイルマップにした時のインデックスで構成されています。
タイルマップとは、先ほどのマップチップ画像をプログラミングしやすいように再び分割して、それぞれにインデックスを割り振られたものになります。 先ほどの画像はサイズが40 * 80で構成されていますが、後述のプログラムで40 * 40のタイルマップに分割します。
つまり、今回作成しているゲームは横縦が800 * 600で作成しているので、互いを40で割ると20 * 15になるので二次元配列の構成も20 * 15で表されています。

次にpreload()では画像をPhaser.Loader.LoaderPlugin.image()メソッドで読み込んでいます。
第一引数に読み込む画像に紐づけるキーをstring型で指定して、第二引数に画像のディレクトリを渡しています。

実際にプログラムでタイルマップを生成してるのは、Phaser.GameObjects.GameObjectCreator.tilemap()メソッドになります。
第一引数に設定オブジェクトを渡していますが今回はdataに先ほどのマップ生成用の二次元配列、tileWidthtileHeightに分割サイズを指定しています。
分割サイズは、前述通り40 * 40を指定しています。

次に、Phaser.Tilemaps.Tilemap.addTilesetImage()メソッドではPhaser3でロードされたマップチップ画像のキーを渡し、タイムマップを生成します。 preload()に追記したプログラムから、保存したマップタイル画像をmapTilesというキーで紐づけているので、追記したプログラムでもmapTilesというキーを渡しているのがわかります。

そして最後に、ここまでで設定したマップを実際にレイヤーとして生成するのはPhaser.Tilemaps.Tilemap.createStaticLayer()メソッドになります。
第一引数にこのレイヤーのインデックスまたはキーを設定できますが、今回はインデックスとして0を渡しています。
第二引数は先ほど生成したタイルマップを渡し、第三引数・第四引数はX・Y座標を渡して表示する座標を設定します。
なお、このゲームオブジェクトについても座標の始点は左上になるので注意が必要です。

これでマップ表示までできました。
ここまでのソースコードは以下のリポジトリからダウンロードできますので、興味のある方は実際に動かして研究してみてください。

github.com

次回はキャラの表示と移動などの実装を説明していきます。


まとめ・注意点

・インターネット上にはPhaser2とPhaser3の情報が混在しているので注意。
・インターネットに落ちているゲーム素材を使うときは規約などの注意が必要。
・ゲームオブジェクトのイベントを発火させるにはsetInteractive()メソッドが必須。
・ゲームオブジェクトの配置座標がどこを始点にしているかは注意が必要。
・ゲーム制作ではマップを表示するのに、マップチップ画像をタイルマップに分割して表示する。