HaLake Magazine

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

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

前回の記事に引き続きPhaser3+Typescriptを使ってRPGの基礎を作っていきます。
この記事は前回の記事を呼んだ前提で説明していきますので、ぜひそちらを先に読むことをお勧めします。 また、今回の記事は前回よりも難易度と内容量が上がっていますが、記事の最後に作業後のリポジトリのリンクがありますので、そちらを先にダウンロードしてそちらと比較しながら学習することができます。 前回まで作成した状態のプログラムが以下のリポジトリからダウンロードできますので前回まででうまくいっていない方は参考にしてみてください。

github.com

最終目標(再掲)

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

今回の目標

・ゲームの作り方の断片を知る ・キャラの表示できるようにする
・キャラを移動できるようにする
・当たり判定をつける ・NPCの追加してみる
・話しかけられるようにする

開発前提(再掲)

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

使用した主要Nodeモジュール(再掲)

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

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

当ブログ仕様の画像素材の注意点

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

開発順序

  1. キャラの表示
  2. キャラの移動
  3. ヒーローの座標を再設定
  4. 移動できない領域の設定
  5. NPCの追加
  6. NPCに話しかけてみる


1. キャラの表示

f:id:takumishinoda:20190706150417p:plain
想定画面

今回は、はじめにキャラつまり操作できるヒーローを表示するところから実装していきます。
使用する画像は以下の画像になるので、assets/images/フォルダにhero.pngと名付けて格納してください。

f:id:takumishinoda:20190706153150p:plain
hero.png

プログラムの修正するファイルは、src/scenes/game.tsで、追記箇所は3箇所にになります。

/* src/scenes/game.ts */

...省略
export class Game extends Phaser.Scene {
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer
  private hero?: Phaser.GameObjects.Sprite // 追加

省略...

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

    this.load.image('mapTiles', `../../assets/images/map_tile.png`)
    this.load.spritesheet('hero', '../../assets/images/hero.png', { frameWidth: 32, frameHeight: 32 }); // 追加
  }

  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)

    this.hero = this.add.sprite(420, 300, 'hero', 0) // 追加
    this.hero.setDisplaySize(40, 40) // 追加
  }
省略...

まずクラスメンバ変数に表示するヒーローを保管する変数を定義します。
型は、Phaser.GameObjects.Sprite型で定義しています。

次にpreload()でヒーローのスプライト画像をheroというキーで紐づけて読み込みます。
ここで前回の記事では画像の読み込みは、Phaser.Loader.LoaderPlugin.image()メソッドを使用していましたが、今回はPhaser.Loader.LoaderPlugin.spritesheet()メソッドを使用している点に注目します。
両者の違いは、前者は画像を一枚の画像として読み込み、後者は画像を任意のサイズで分割した状態で読み込むことができます。
お気付きの方もいらっしゃる通り、前回記事のタイルマップに似たことをしています。
このメソッドは、2Dのゲームでアニメーションを導入する際に取られる手法で、アニメーションの1コマづつの画像を用意してペラペラ漫画のように連続して表示させることでアニメーションを表現するのに役に立つメソッドになります。

次に、create()で実際にヒーローのレンダリングをしていきます。
Phaser.GameObjects.Sprite型のゲームオブジェクトの追加には、Phaser.GameObjects.GameObjectFactory.sprite()メソッドを使用します。
第一引数・第二引数にX・Y座標、第二引数にスプライト画像のキーをstring型で渡します。
上の実装では'hero'というキーで紐づいたスプライト画像を指定するために、'hero'というキーを渡しています。
そして、第三引数では表示するコマをstring型またはnumber型で渡します。
ここでいうコマとは、スプライト画像を分割した時に振られるインデックスのことで、インデックスは以下の画像のように割り振られています。

f:id:takumishinoda:20190706153704p:plain
スプライト画像のインデックス

今回は初期状態で正面を向いてほしいので、第三引数に0をnumber型で渡しています。
試しに後ろを向いてほしいので、10を渡すと以下のようになります。

f:id:takumishinoda:20190706154206p:plain
第三引数に`10`を渡した時


キャラの移動

この章ではついにヒーロを操作できるようになります。
ヒーローを操作できるようにするには以下の手順で実装できます。

  1. 歩くアニメーションの追加
  2. キーボードの入力イベントを追加
  3. ヒーローのゲームオブジェクトを移動させる(グリッドウォーク)

1. 歩くアニメーションの追加

f:id:takumishinoda:20190706173252g:plain
実装予定

まずはアニメーションの追加です。
編集するファイルはsrc/scenes/game.tsです。
さらっと理解できるように追加部分にはコメントをつけたので、そこでも理解をできればと思います。

/* src/scenes/game.ts */

...省略

export class Game extends Phaser.Scene {
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer
  private hero?: Phaser.GameObjects.Sprite

  // ここから追加
  // ヒーローアニメーションの設定配列
  private heroAnims: {key: string, frameStart: number, frameEnd: number}[] = [ 
    {key: 'walk_front', frameStart: 0, frameEnd: 2},
    {key: 'walk_back', frameStart: 3, frameEnd: 5},
    {key: 'walk_left',frameStart: 6, frameEnd: 8},
    {key: 'walk_right', frameStart: 9, frameEnd: 11},
  ]
  // ここまで

省略...

  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)

    this.hero = this.add.sprite(400, 300, 'hero', 0)
    this.hero.setDisplaySize(40, 40) 

    // ここから追加
    for(let heroAnim of this.heroAnims){ // ヒーローアニメーションの数だけループ
      if(this.anims.create(this.heroAnimConfig(heroAnim)) === false) continue // もしfalseが戻って来ればこの後何もしない
        
      this.hero.anims.load(heroAnim.key) // ヒーローのゲームオブジェクトにアニメーションを登録
    }

    this.hero.anims.play('walk_front')
    // ここまで
  }

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

  // ここから追加
  // アニメーションの設定からPhaser3のアニメーション設定を生成
  private heroAnimConfig(config: {key: string, frameStart: number, frameEnd: number}): Phaser.Types.Animations.Animation{
    return {
      key: config.key,
      frames: this.anims.generateFrameNumbers(
        `hero`, 
        {
          start: config.frameStart,
          end: config.frameEnd
        }
      ),
      frameRate: 8,
      repeat: -1
    }
  }
  // ここまで
}

さて、今回の編集はかなり大幅なものとなっていますが一つづつ説明していきます。

まずクラスメンバ変数としてheroAnimsを追加しています。
この変数は、ヒーローの各アニメーション用の設定を格納しています。
ヒーローが歩くのを表現するのには、『手前に歩く』『後ろに歩く』『左に歩く』『右に歩く』の四つのアニメーションが必要になります。
よってheroAnimsは配列となっており、それぞれのアニメーションの設定がオブジェクトとして格納されています。
設定オブジェクトは、keyがアニメーションに紐づける名前、frameStartframeEndが先ほど学んだスプライト画像のコマのインデックスを指定して、アニメーションのコマの開始点と終点を格納する構造になっています。

次にcreate()での編集では、実際にアニメーションを生成とヒーローのゲームオブジェクトへの適用が行われています。
ここではプログラミングらしくfor文が登場しますが、ここではヒーローのアニメーションの数だけ繰り返しの処理をしていると理解してもらえれば大丈夫です。
for文の中の処理としては、まず直後のif文にてこの文の後の処理を行うか行わないかの分岐になっています。
ここで、条件にPhaser.Animations.AnimationManager.create()メソッドを渡している点に注目してください。
このメソッドの戻り値は基本的にPhaser.Animations.Animation型ですが、うまくアニメーションを登録できなかった際はfalseが帰ってくるように設計されています。
なのでif文で評価することで、正しくアニメーションが登録されれば処理を続行し、そうでなければ処理を飛ばして次のループに入るようになっています。

さらにPhaser.Animations.AnimationManager.create()メソッドは、第一引数にPhaser.Types.Animations.Animation型のオブジェクトを渡しますが、今回この引数の渡し方としてGameシーンクラスにheroAnimConfig()メソッドを追加して、そのメソッドの戻り値を引数として渡しています。
heroAnimConfig()メソッドの戻り値は当然Phaser.Types.Animations.Animation型になり、引数は前述のクラスメンバ変数のheroAnimsの配列要素であるオブジェクトの型({key: string, frameStart: number, frameEnd: number})にあたります。

if文で処理続行が選択された場合、次に実行されるのはPhaser.GameObjects.Components.Animation.load()メソッドになります。
このメソッドでは、ゲームオブジェクトにシーンに登録されたアニメーションを適用していきます。
第一引数にアニメーションのキーをstring型で渡すことで登録されます。

そしてfor文を抜けた後に実行されるのはPhaser.GameObjects.Components.Animation.play()メソッドで、第一引数にゲームオブジェクトに登録されているアニメーションのキーを渡すことでそのアニメーションを実行することができます。
ここで、今回は引数に'walk_front'が設定されていますが、'walk_back'に編集することで後ろに歩くアニメーションに変わるのが確認できます。

2. キーボードの入力イベントを追加

f:id:takumishinoda:20190707115043g:plain
想定画面

ここではキーボードの入力によってアニメーションを切り替える実装をします。
編集するファイルは同じくsrc/scenes/game.tsです。

/* src/scenes/game.ts */

import * as Phaser from "phaser";

type WalkAnimState = 'walk_front' | 'walk_back' | 'walk_left' | 'walk_right' | '' // 追加

export class Game extends Phaser.Scene {
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer
  private hero?: Phaser.GameObjects.Sprite
  private heroAnimState: WalkAnimState // 追加
  private cursors?: Phaser.Types.Input.Keyboard.CursorKeys // 追加

省略...

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

    // ここから追加
    this.cursors = this.input.keyboard.createCursorKeys();
    this.heroAnimState = ''
    //ここまで
}

省略...

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

    // ここから追加
    let heroAnimState: WalkAnimState = '' // 前回と比較用の状態格納変数

    // ここで状態決定(ローカルな変数に格納)
    if(this.cursors.up.isDown){ 
      heroAnimState = 'walk_back'
    }else if(this.cursors.down.isDown){
      heroAnimState = 'walk_front'
    }else if(this.cursors.left.isDown){
      heroAnimState = 'walk_left'
    }else if(this.cursors.right.isDown){
      heroAnimState = 'walk_right'
    }else{
      this.hero.anims.stop()
      this.heroAnimState = ''
      return
    }

    // ここでアニメーションに適用 & メンバ変数の状態更新
    if(this.heroAnimState != heroAnimState){
      this.hero.anims.play(heroAnimState)
      this.heroAnimState = heroAnimState
    }
    //ここまで
  }

省略...

今回編集するのは、WalkAnimState型の追加・クラスメンバ変数の追加・init()の編集・update()の編集になります。

まずはWalkAnimState型の追加から説明します。
Typescriptでは自分で型を作ることができ、方法としてinterfacetypeで作成できます。
今回は後から型をマージさせるような使い方はしないので、無難にtypeで宣言していきます。
作成した型では、string型で値が 'walk_front''walk_back''walk_left''walk_right'''のどれかしか許可しない型として定義しています。
これでヒーローのアニメーションが今どの状態にあるかを管理していきます。
なお、空文字の場合は動かない状態を表すようにしています。

次にクラスメンバ変数の追加で、今回はheroAnimStateWalkAnimState型、cursorsPhaser.Types.Input.Keyboard.CursorKey型で定義しています。
heroAnimStateは先ほど作成した型でヒーローのアニメーションの状態を管理していきます。
cursorsはPhaser3の機能を使ってキーボードの矢印キーの情報を取得するために定義しています。

次にinit()の編集部分で、ここではクラスメンバ変数の初期化を行います。
今回初期化させるのは、先ほどのクラスメンバ変数heroAnimStatecursorsです。
heroAnimStateは初期状態として''を代入して動かない状態を再現します。
cursorsにはPhaser.Input.Keyboard.KeyboardPlugin.createCursorKeys()メソッドで矢印の状態を監視するオブジェクトを代入します。

最後にupdate() ではheroAnimStateの状態を変化させ、実際にアニメーションを変化させる実装をしています。
前半のif文ではクラスメンバ変数のcursorsの状態により分岐をさせ、heroAnimStateの状態を変化させています。 今回の実装では斜めの動きに対応しないため、if文の構成はもっともシンプルなものとなっており同時にキーが押されることを想定していないため同時にキーを押すと、上・下・左・右の順に優先度があることはご了承ください。
各矢印キーが押されているかを取得するには、Phaser.Input.Keyboard.Key.isDown変数を確認します。
プログラム上ではthis.cursor.キー.isDownの記述でboolean型で取得しているのがわかります。
キーの選択にはupdownleftrightがあります。
上のプログラムでは、何も押されていなければアニメーションが止まりその週のフレームを早期returnするように設計しています。
そして注目すべきは、このif文では状態を格納しているのはupdate()内で定義されたheroAnimState変数であるということです。
これは前回と今回のキーの状態を比較するために設計したため、この後の処理でローカルなheroAnimStateはクラスメンバ変数のheroAnimStateに代入されます。

その後のif文ではクラスメンバ変数を現在の状態に変更する処理と、現在の状態に応じたアニメーションの変化処理を行っています。 これでカーソルキーによるアニメーションの変化が実装できました。

3. ヒーローのゲームオブジェクトを移動させる(グリッドウォーク)

次はヒーローを移動させるプログラムの実装です。
ただヒーローを動かすのであれば前述のupdate()内のアニメーション状態を格納する変数を変化させる分岐で座標を一定数づつ増減させることでで実装できます。
しかしそれではRPG感が出ないので、今回はグリッドウォークを実装します。
グリッドウォークとは、RPGゲームでおなじみのマスごとに移動する動きのことです。
以下の画像を比較して、普通の移動処理とグリッドウォークの違いを見てみましょう。

f:id:takumishinoda:20190707122309g:plain
普通の実装(座標を少しずつ増減させる)

f:id:takumishinoda:20190707122403g:plain
グリッドウォーク(一回の入力で一定の座標まで移動する)

確かに普通の実装では小回りがきき、操作の自由度が高いかもしれません。
このような実装はアクションゲームの類でよくみられます。 しかしRPGゲームのプレイヤ移動には操作の自由度は必要なく、実際に世に出ているゲームもそのような実装になっている作品が多いのではないでしょうか。

グリッドウォークのプログラム実装に行く前に、実装の概念の説明をします。
グリッドウォークの実装にはアニメーションを利用して、入力があった時に現在位置から一定間隔を移動する実装で対応します。
アクションゲームのような実装では、入力された時一定の数値を少しづつ加算した座標に移動しますが、グリッドウォークでは一回の入力で目標座標まで移動する点に違いがあることを理解しましょう。
そこでグリッドウォークにはある問題があることがわかります。
それはアクションゲームの実装では押されている間座標を加算すれば良いところを、グリッドウォークで同じ実装するとアニメーションが終わらないまま次のアニメーションが始まってしまい、意図しない動作になってしまうところです。
この問題を解決するために、今回はコールバックを利用していきます。

入力があった時点であらかじめ用意したステート変数(状態を管理する変数)を変化させ、またアニメーション終了時にコールバックでステート変数を再度変化させてupdate()内の処理を分岐させることで一回のアニメーションの終了を保証することで問題を解決させます。
それではプログラムをみてみましょう。

/* src/scenes/game.ts */

import * as Phaser from "phaser";

type WalkAnimState = 'walk_front' | 'walk_back' | 'walk_left' | 'walk_right' | ''
type MoveDir = -1 | 0 | 1 // 追加

export class Game extends Phaser.Scene {
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer
  private hero?: Phaser.GameObjects.Sprite
  private heroAnimState: WalkAnimState
  private cursors?: Phaser.Types.Input.Keyboard.CursorKeys
  // ここから追加
  private heroIsWalking: boolean
  private heroWalkSpeed: number = 40
  // ここまで

省略...

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

    this.cursors = this.input.keyboard.createCursorKeys();
    this.heroAnimState = ''
    
    this.heroIsWalking = false // 追加
 }

省略...

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

    if(this.heroIsWalking) return // 追加

    let heroAnimState: WalkAnimState = ''
    // 追加
    let heroXDir: MoveDir = 0  // x座標の移動方向を表すための変数
    let heroYDir: MoveDir = 0  // y座標の移動方向を表すための変数
    // ここまで

    if(this.cursors.up.isDown){ 
      heroAnimState = 'walk_back'
      heroYDir = -1 // 追加
    }else if(this.cursors.down.isDown){
      heroAnimState = 'walk_front'
      heroYDir = 1 // 追加
    }else if(this.cursors.left.isDown){
      heroAnimState = 'walk_left'
      heroXDir = -1 // 追加
    }else if(this.cursors.right.isDown){
      heroAnimState = 'walk_right'
      heroXDir = 1 // 追加
    }else{
      this.hero.anims.stop()
      this.heroAnimState = ''
      return
    }

    if(this.heroAnimState != heroAnimState){
      this.hero.anims.play(heroAnimState)
      this.heroAnimState = heroAnimState
    }

    // ここから追加
    this.heroIsWalking = true
    this.gridWalkTween(this.hero, this.heroWalkSpeed, heroXDir, heroYDir, () => {this.heroIsWalking = false})
    // ここまで
  }

  // 追加
  // グリッドウォークを実装するアニメーション
  private gridWalkTween(target: any, baseSpeed: number, xDir: MoveDir, yDir: MoveDir, onComplete: () => void){
    if(target.x === false) return 
    if(target.y === false) return

    let tween: Phaser.Tweens.Tween = this.add.tween({
      // 対象のオブジェクト
      targets: [target],
      // X座標の移動を設定
      x: {
        getStart: () => target.x,
        getEnd: () => target.x + (baseSpeed * xDir)
      },
      // X座標の移動を設定
      y: {
        getStart: () => target.y,
        getEnd: () => target.y + (baseSpeed * yDir)
      },
      // アニメーションの時間
      duration: 300,
      // アニメーション終了時に発火するコールバック
      onComplete: () => {
        tween.stop() // Tweenオブジェクトの削除
        onComplete() // 引数の関数実行
      }
    })
  }
  // ここまで追加
省略...

まずはじめに、新しくMoveDir型を定義します。
これは実際に移動の実装をする際、XまたはYの+(プラス)方向に移動するのか-(マイナス)方向に移動するのかを表すための型になります。
0の場合は動かないことを表しています。

次にクラスメンバ変数はヒーローの移動状況を格納するheroIsMovingと一回の移動で進む数値を格納するheroWalkSpeedを定義しています。
heroIsMovingは前述のアニメーションの終了を保証するためのboolean型のステート変数になり、init()ではヒーローは移動していないので初期値としてfalseが格納されます。

次にupdate()では呼ばれた直後にheroIsMovingtrueだった場合は早期リターンでその週のupdate()処理をスキップするように設定されています。
もしここで早期リターンされなければ、直後にX・Y座標の移動方向を格納する変数heroXDirheroYDirが先ほど定義したMoveDir型で定義されます。
その後if文の条件分岐で入力に応じてheroXDirheroYDirの値が変化します。

この条件分岐を抜けるということはどの方向かには移動するということが前述の説明からわかるので、ここですかさずheroIsWalking変数にtrueを格納します。
こうすることでアニメーションが終了するまで次回以降のupdate()は突入直後に早期リターンによりスキップされます。
そしてそのあとにアニメーションを追加していきます。

アニメーションは今回新しく追加したシーンクラスのメソッドのgridWalkTween()にて行います。
ここでいうアニメーションとは、当記事のはじめに実装したヒーローのアニメーションとは異なるものを実装していきます。
gridWalkTween()クラスメソッドを見ると、Phaser3のPhaser.GameObjects.GameObjectFactory.tween()メソッドが呼ばれているのがわかります。
これはゲームオブジェクト自体にアニメーションを追加するメソッドで、第一引数に配列でゲームオブジェクトを渡し第二引数に設定用のオブジェクトを渡します。
設定オブジェクトの説明はとても長くなってしまうので今回は詳しくは説明できませんが、コメントに今回設定する部分の説明を加えています。
ここでお気付きかと思われますが、gridWalkTween()クラスメソッドは実はPhaser.GameObjects.GameObjectFactory.tween()メソッドをグリッドウォーク用にカスタマイズしたものになっています。

gridWalkTween()の第一引数は対象のゲームオブジェクトで、第二引数は一回で移動する距離、第三・四引数は移動するX・Y方向をMoveDir型で渡します。
そして、今回のキモになる第五引数にはアニメーション終了時のコールバックを渡していきます。
上のプログラムでは、コールバック内でthis.heroIsWalking = falseと実装することでヒーローの移動状況を止まっている状態に変化させています。
この実装でヒーロがやっと移動できるようになりました。


3. ヒーローの座標を再設定

ここまできたら次はヒーローが進めない場所を設定していきます。
ですがその前に現在の状態で木ああるところまで行ってみましょう。

f:id:takumishinoda:20190707135848p:plain
木と木の間にヒーローがいる

木とヒーロが重なってしまうのは当然のことですが、グリッドウォークを実装したのに以下のように木のマスとヒーローが少しずれて配置されるかと思われます。
これはヒーローの初期位置がタイルの縦横のサイズ(縦横40)の倍数の座標にないため起こることです。
計算をして正しい座標に表示することは可能ですが、これらをいちいち計算するのは賢くないので今回はタイル単位の座標を指定して配置できるようにします。
わかりやすく言うと、このマップは横縦が600 * 800でタイル一枚の横縦は40 * 40で構成されているので、タイルマップ単位でいうと横20枚縦は15枚のタイルで構成されていることがわかるかと思います。
これを踏まえた上でプログラムを編集していきましょう。

/* src/scenes/game.ts */

...省略

export class Game extends Phaser.Scene {
  private map?: Phaser.Tilemaps.Tilemap
  private tiles?: Phaser.Tilemaps.Tileset
  private map_ground_layer?: Phaser.Tilemaps.StaticTilemapLayer
  private hero?: Phaser.GameObjects.Sprite
  private heroAnimState: WalkAnimState
  private cursors?: Phaser.Types.Input.Keyboard.CursorKeys
  private heroIsWalking: boolean
  private heroWalkSpeed: number = 40
  private heroTilePos: {tx: number, ty: number}

  private heroAnims: {key: string, frameStart: number, frameEnd: number}[] = [
    {key: 'walk_front', frameStart: 0, frameEnd: 2},
    {key: 'walk_back', frameStart: 9, frameEnd: 11},
    {key: 'walk_left', frameStart: 3, frameEnd: 5},
    {key: 'walk_right',frameStart: 6, frameEnd: 8},
  ]

省略...

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

    this.cursors = this.input.keyboard.createCursorKeys();
    this.heroAnimState = ''
    
    this.heroIsWalking = false

    this.heroTilePos = {tx: 10, ty: 8}
  }

省略...

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

    let heroPos: Phaser.Math.Vector2 // 追加

    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)

    heroPos = this.map_ground_layer.tileToWorldXY(this.heroTilePos.tx, this.heroTilePos.ty) // 追加

    this.hero = this.add.sprite(400, 300, 'hero', 0) // 編集前 ここは消す
    this.hero = this.add.sprite(heroPos.x, heroPos.y, 'hero', 0) // 編集後 ここを追加
    this.hero.setOrigin(0) // 追加
    this.hero.setDisplaySize(40, 40)

    for(let heroAnim of this.heroAnims){
      if(this.anims.create(this.heroAnimConfig(heroAnim)) === false) continue
        
      this.hero.anims.load(heroAnim.key)
    }

    this.hero.anims.play('walk_front')
  }

まずクラスメンバ変数には、プレイヤーのタイル座標を格納するheroTilePosを用意します。 プロパティはtxtynumber型で定義されていますが、これはタイル座標を意味します。
そしてheroTilePosinit()にてtxプロパティを10tyプロパティを8で初期化していて、プレイヤーの初期位置をタイル座標のXY(10, 8)にしているのがわかります。

次にcreate()では冒頭で変数heroPosPhaser.Math.Vector2型定義していますが、これはタイル座標を実際のゲームワールド座標に変換したものを格納するためのものです。
実際にタイル座標からゲームワールド座標に変換するには、前回記事でマップを作成した際にPhaser.Tilemaps.Tilemapオブジェクトが登場しましたが、そのメソッドであるPhaser.Tilemaps.Tilemap.createStaticLayer()を使用します。
このメソッドの第一・第二引数にタイル座標のX・Y座標を渡すことで、戻り値として渡した座標のタイルのゲームワールド座標を取得できます。
この戻り値を先ほどの変数heroPosに代入し、ヒーローを描画をするメソッドの引数にheroPosxプロパティとyプロパティを渡すことでタイル座標にヒーローを配置できます。
しかしここで注意しなければいけないのは、タイル座標はタイル一枚の左上を中心とする座標を示しているのに対して、ヒーローを描画するPhaser.GameObjects.GameObjectFactory.sprite()メソッドは表示領域の中心を座標としているということです。
つまり前回の記事のように、Phaser.GameObjects.Sprite.setOrigin()メソッドにてヒーローの座標基準を今回は左上にするために第一引数に0を渡して対応しています。
これでヒーローが正しく配置できたかと思われます。

f:id:takumishinoda:20190707140247p:plain
ヒーローがマスにはまるようになった


4. 移動できない領域の設定

それでは移動できない領域の設定をしていきます。
今回はゲーム画面の外側と木がある場所へは移動できないようにします。
指定した場所への進入禁止はマップ作成に利用した二次元配列クラスメンバ変数のmap_groundを利用する方法で実装していきます。

/* src/scenes/game.ts */

...省略

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

    if(this.heroIsWalking) return

    let heroAnimState: WalkAnimState = ''
    let heroXDir: MoveDir = 0
    let heroYDir: MoveDir = 0
    let heroNewTilePos: {tx: number, ty: number} = this.heroTilePos // 追加

    if(this.cursors.up.isDown){ 
      heroAnimState = 'walk_back'
      heroYDir = -1
    }else if(this.cursors.down.isDown){
      heroAnimState = 'walk_front'
      heroYDir = 1
    }else if(this.cursors.left.isDown){
      heroAnimState = 'walk_left'
      heroXDir = -1
    }else if(this.cursors.right.isDown){
      heroAnimState = 'walk_right'
      heroXDir = 1
    }else{
      this.hero.anims.stop()
      this.heroAnimState = ''
      return
    }

    if(this.heroAnimState != heroAnimState){
      this.hero.anims.play(heroAnimState)
      this.heroAnimState = heroAnimState
    }

    // ここから追加
    heroNewTilePos = {tx: heroNewTilePos.tx + heroXDir, ty: heroNewTilePos.ty + heroYDir}

    if(heroNewTilePos.tx < 0) return 
    if(heroNewTilePos.ty < 0) return
    if(heroNewTilePos.tx >= 20) return 
    if(heroNewTilePos.ty >= 15) return
    // ここまで

    if(this.map_ground[heroNewTilePos.ty][heroNewTilePos.tx] == 1) return // 追加

    this.heroTilePos = heroNewTilePos // 追加
    this.heroIsWalking = true
    this.gridWalkTween(this.hero, this.heroWalkSpeed, heroXDir, heroYDir, () => {this.heroIsWalking = false})
  }

省略...

今回の編集箇所はupdate()内のみになります。
まず冒頭で変数heroNewTileMapを宣言します。
これは新しいヒーローのタイル座標を保管します。
ただしこの後の処理でこの変数に代入される値がマップに存在しない場所を示す可能性があるので、if文にて各条件を設定して早期リターンさせているプログラムが実装されています。
これで画面外にヒーローが出ることがなくなります。

また、最後の編集箇所ではもしヒーローが進む予定の座標に木がある、つまりマップの二次元配列であるmap_groundの指定アドレスに1が代入されている場合は早期リターンすることで、その後の処理でヒーローのタイル座標の更新とヒーローが実際に移動するのを防いでいます。
これで木のあるところへは進入できなくなったかとおもわれます。

f:id:takumishinoda:20190714164555g:plain
木のある場所に行けなくなった!

f:id:takumishinoda:20190714164654g:plain
画面外にも出なくなった!


5. NPCの追加

f:id:takumishinoda:20190721095157p:plain
実装画面

今度はNPCを追加してみましょう。
よく見るRPGゲームのNPCは一定の場所を移動するように実装していますが、今回の趣旨ではそこまでは説明しないので動かないNPCを実装していきます。
以下のスプライトを使っていきますので、assets/images/にスプライト画像が保存されていない場合は以下の画像をあらかじめnpcs.pngと名前をつけて保存しておきましょう。

f:id:takumishinoda:20190721091610p:plain

それではプログラムの編集です。

/* src/scenes/game.ts */

...省略

export class Game extends Phaser.Scene {
  private map: Phaser.Tilemaps.Tilemap
  private mapEvent: Phaser.Tilemaps.Tilemap // 追加
  private tiles: Phaser.Tilemaps.Tileset
  private map_ground_layer: Phaser.Tilemaps.StaticTilemapLayer
  private map_event_layer: Phaser.Tilemaps.StaticTilemapLayer  // 追加
  private hero: Phaser.GameObjects.Sprite 
  private heroAnimState: WalkAnimState
  private cursors: Phaser.Types.Input.Keyboard.CursorKeys
  private heroIsWalking: boolean
  private heroWalkSpeed: number = 40
  private heroTilePos: {tx: number, ty: number}

省略...

  // ここから追加
  private map_event: number[][] = [
    [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, 2, 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, 3, 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, 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, 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]
  ] // 20 * 15
  // ここまで追加

  省略...

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

    this.load.image('mapTiles', `../../assets/images/map_tile.png`)
    this.load.image('mapEventTiles', `../../assets/images/npcs.png`) // 追加
    this.load.spritesheet('hero', '../../assets/images/hero.png', { frameWidth: 32, frameHeight: 32 });
  }

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

    let heroPos: Phaser.Math.Vector2

    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)

    // ここから追加
    this.mapEvent = this.make.tilemap({ data: this.map_event, tileWidth:40, tileHeight: 40 })
    this.tiles = this.mapEvent.addTilesetImage(`mapEventTiles`) 
    this.map_event_layer = this.mapEvent.createStaticLayer(0, this.tiles, 0, 0)

    this.map_ground_layer.tilemap.getTileAt(10, 10)
    // ここまで

    heroPos = this.map_ground_layer.tileToWorldXY(this.heroTilePos.tx, this.heroTilePos.ty)

    this.hero = this.add.sprite(heroPos.x + 20, heroPos.y + 20, 'hero', 0)
    this.hero.setDisplaySize(40, 40)

  省略...
  }

  update() {

    省略...

    heroNewTilePos = {tx: heroNewTilePos.tx + heroXDir, ty: heroNewTilePos.ty + heroYDir}

    if(heroNewTilePos.tx < 0) return 
    if(heroNewTilePos.ty < 0) return
    if(heroNewTilePos.tx >= 20) return 
    if(heroNewTilePos.ty >= 15) return

    if(this.map_ground[heroNewTilePos.ty][heroNewTilePos.tx] == 1) return
    if(this.map_event[heroNewTilePos.ty][heroNewTilePos.tx] != 0) return // 追加

    this.heroTilePos = heroNewTilePos
    this.heroIsWalking = true
    this.gridWalkTween(this.hero, this.heroWalkSpeed, heroXDir, heroYDir, () => {this.heroIsWalking = false})
  }

今回の作業はマップ描写の手順とほぼ同じで、メンバ変数にタイルマップとマップレイヤーを格納するメンバ変数の定義とマップを作るための二次元配列map_eventを定義し、preload()ではスプライト画像をロードそしてcreate()でマップタイル作成とレイヤーの生成を追加しました。
これで猫・老婆・死神?の画像が表示されますがこのままだとヒーローがこれらを貫通してしまうので、update()の追記でNPCのいる場所に進めなくします。


NPCに話しかけてみる

ついに最後の章で、NPCに話しかけると喋るようにしましょう。
下の画像を使用するのであらかじめassets/images/frame.pngで保存しておきましょう。

f:id:takumishinoda:20190721104613p:plain

それではプログラムの編集です。

/* src/scene/game.ts */

...省略

export class Game extends Phaser.Scene {
  private map: Phaser.Tilemaps.Tilemap
  private mapEvent: Phaser.Tilemaps.Tilemap
  private tiles: Phaser.Tilemaps.Tileset
  private map_ground_layer: Phaser.Tilemaps.StaticTilemapLayer
  private map_event_layer: Phaser.Tilemaps.StaticTilemapLayer
  private hero: Phaser.GameObjects.Sprite 
  private heroAnimState: WalkAnimState
  private cursors: Phaser.Types.Input.Keyboard.CursorKeys
  private heroIsWalking: boolean
  private heroIsTalking: boolean
  private heroWalkSpeed: number = 40
  private heroTilePos: {tx: number, ty: number}

  // ここから追加
  private serifFrame: Phaser.GameObjects.Image
  private serif: Phaser.GameObjects.Text
  private serifArea: Phaser.GameObjects.Container

  private serifStyle: object = {
    fontSize: '40px',
    color: 'black',
    wordWrap: { 
      width: 730, 
      useAdvancedWrap: true 
    }
  }
  //ここまで

  省略...

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

    this.cursors = this.input.keyboard.createCursorKeys();
    this.heroAnimState = ''
    
    this.heroIsWalking = false
    this.heroIsTalking = false // 追加

    this.heroTilePos = {tx: 10, ty: 8}
  }

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

    this.load.image('mapTiles', `../../assets/images/map_tile.png`)
    this.load.image('mapEventTiles', `../../assets/images/npcs.png`)
    this.load.spritesheet('hero', '../../assets/images/hero.png', { frameWidth: 32, frameHeight: 32 });
    this.load.image('serifFrame', '../../assets/images/frame.png') // 追加
  }

  create() {

    省略...

    this.hero = this.add.sprite(heroPos.x + 20, heroPos.y + 20, 'hero', 0)
    this.hero.setDisplaySize(40, 40)
 
    // ここから追加  セリフエリアの実装
    this.serifFrame = this.add.image(0, 0, 'serifFrame') // 生成
    this.serifFrame.setDisplaySize(800, 120) // リサイズ

    this.serif = this.add.text(-370, -50, '', this.serifStyle) // 生成

    this.serifArea = this.add.container(400, 540) // 生成
    this.serifArea.add([this.serifFrame, this.serif]) // ゲームオブジェクトを格納
    this.serifArea.setVisible(false) // 非表示化

    for(let heroAnim of this.heroAnims){
      if(this.anims.create(this.heroAnimConfig(heroAnim)) === false) continue
        
      this.hero.anims.load(heroAnim.key)
    }
    // ここまで

    this.hero.anims.play('walk_front')

    // ここから追加  話しかける処理
    this.input.keyboard.addKey('Enter').on('down', () => {
      const heroFacing: {tx: number, ty: number} | undefined = this.getNowHeroFaceTilePos()
      const heroLastAnim: WalkAnimState | string = this.hero.anims.getCurrentKey()
      const eventIndex: number = this.map_event[heroFacing.ty][heroFacing.tx]
      let serif: string = ``

      if(heroFacing === undefined) return
      if(eventIndex <= 0) return

      if(this.heroIsTalking){
        this.heroIsTalking = false
        this.serifArea.setVisible(false)
      }else{

        this.heroIsTalking = true

        if(eventIndex == 2){
          if(heroLastAnim == 'walk_back') serif = 'にゃ〜' // 正面から話しかけられた場合
          else serif = 'しょうめんからはなしかけてくれにゃ〜' // それ以外から話しかけられた場合
        }else if(eventIndex == 1){
          if(heroLastAnim == 'walk_back') serif = 'あらあら、こんにちわ'
          else serif = 'しょうめんからはなしかけてもらえるとうれしいんだけどねぇ〜'
        }else if(eventIndex == 3){
          if(heroLastAnim == 'walk_back') serif = 'よくきたな'
          else serif = 'しょうめんからはなしかけないとたましいをいただく'
        }else return

        this.serifArea.setVisible(true)
        this.serif.text = serif
      }
    })
    // ここまで
  }

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

    if(this.heroIsWalking) return
    if(this.heroIsTalking) return  // 追加

  省略...

  }

  // ここから追加  ヒーローの向いた先のタイル座標を取得
  private getNowHeroFaceTilePos(): {tx: number, ty: number} | undefined{
    if(this.heroIsWalking) return undefined // 歩いていたら取得できないこととする

    const heroLastAnim: WalkAnimState | string = this.hero.anims.getCurrentKey()
    let dir: {dx: MoveDir, dy: MoveDir} = {dx: 0, dy: 0}
    let nowPos: {tx: number, ty: number} = this.heroTilePos
    let facing: {tx: number, ty: number} = {tx: 0, ty: 0}

    if(heroLastAnim == 'walk_front') // 手前に歩いていたら
      dir.dy = 1
    else if(heroLastAnim == 'walk_left') // 左に歩いていたら
      dir.dx = -1
    else if(heroLastAnim == 'walk_right') // 右に歩いていたら
      dir.dx = 1
    else if(heroLastAnim == 'walk_back') // 奥に歩いていたら
      dir.dy = -1

    facing = {tx: nowPos.tx + dir.dx, ty: nowPos.ty + dir.dy} // 現在座標から向いてる方向に応じて計算

    // 画面外を示していたらundefinedを返す
    if(facing.tx > 20 || facing.tx < 0) return undefined 
    if(facing.ty > 15 || facing.ty < 0) return undefined

    return facing
  }
  // ここまで
  
 省略...

最後にふさわしく編集箇所が多数存在していますが、一つづつ説明します。
まずメンバ変数には、ヒーロが会話中かを格納する変数heroIsTalkingが定義されています。
また、セリフを表示する領域をserifserifFrameserifAreaで定義しています。
serifがテキスト部分で、serifFrameがセリフを表示するための枠そしてserifAreaはテキスト部分と枠部分をまとめて格納するためにPhaser.GameObjects.Containerで定義されています。
次にserifStyle変数がオブジェクト型で定義されていますが、表示するテキストのスタイルを格納しています。
特筆すべきはwordWrapプロパティで、ここには文字の折り返しを指定する部分になります。
widthには折り返す長さが、useAdvanceWrapは折り返しを適用するかが格納されます。

次にinit()では、先ほどのheroIsTalkingが初期化されています。
preload()では、先ほどの画像をロードしているのがわかります。

そして編集量の多いcreate()では簡単に説明すると、 セリフエリアの作成はなしかけた時のイベントの処理が追加されています。
まずはセリフエリアの作成です。
前述のメンバ変数serifserifFrameはこれまでの説明を元に生成と調整を行います。
今回説明するのは、Phaser.GameObjects.Container型についてです。
ざっくり説明するとこのオブジェクトは他の複数のゲームオブジェクトをまとめることでき、このオブジェクトに対して行った変更は格納されたゲームオブジェクト全てに適用されます。
他のゲームオブジェクトを格納するにはPhaser.GameObjects.Container.add()メソッドを呼び出し、第一引数にゲームオブジェクト単体またはその配列を渡すことで格納できます。
上のプログラムではserifserifFrameを格納しているのがわかります。
その後Phaser.GameObjects.Container.setVisible()メソッドで非表示にしていますが、これらは格納されたserifserifFrameの両方に適用されているのが確認できます。
これでゲーム開始直後にセリフエリアが表示されなくなります。

次に話しかける処理の実装部分です。
今回は話しかけるトリガーをEnterキーを押した瞬間にするためにイベントをセットしていきます。
原理から説明すると、Enterキーを押した瞬間にヒーローが向いている先のタイル座標を取得し、NPCを表示するために定義した二次元配列のmap_event上の先ほどのタイル座標にNPCがいるかを判定し、いた場合はセリフが表示されるといった実装になります。
まずヒーローが向いた先のタイル座標を知るために、クラスメソッドとしてgetNowHeroFaceTilePos()が追加されています。
このメソッドの説明は当記事では長くなってしまうので、上記プログラム中のコメントを確認してみてください。
イベント処理の中では前述のメンバ変数heroIsTalkingtrueだった場合はセリフが表示されていることを表すため、セリフエリアを非表示にしてheroIsTalkingfalseを代入しています。
もし表示されていないと判断した場合は、heroIsTalkingtureにして会話状態にして、セリフエリアを表示させます。
セリフの選択は、前述のgetNowHeroFaceTilePos()メソッドの戻り値とメンバ変数のmap_eventsを利用してヒーローの向いた先にどのNPCがいるかを判定します。
この時ヒーローの最後のアニメーションのキーを取ることで、NPCはどの位置から話しかけられたかを判定できます。
今回は正面から話しかけられた場合とそれ以外で分岐させているので、正面から話しかけられた場合は最後のアニメーションがwalk_back、つまり後ろに歩いた場合で分岐させていきます。
これで話しかける実装の概要が説明できましたが、これではセリフが出た後も移動できてしまうのでupdate()においてヒーロが会話中であった場合移動させないように上のプログラムのように条件式を追加しましょう。

f:id:takumishinoda:20190721115701g:plain
最終実装

最後にここまでの編集をすべて反映して実際に動作するようになっているプログラムを下記のリポジトリからダウンロードできるので、ここまででうまくいかなかった方やもう一回ぷrグラムを比較しながら学習したい方はぜひるようしてみてください。

github.com


まとめ

・Phaser3とTypescriptでゲーム作りの基礎を学べた
・2Dゲームのアニメーションはスプライト画像のコマ再生で再現できる
・マップ表示にはスプライト画像・タイルマップ・レイヤーの知識が必要
・グリッドウォークを実装できた