Phaser3 + Typescriptを使ってRPGゲームの基礎を作ろう!その2
前回の記事に引き続きPhaser3+Typescriptを使ってRPGの基礎を作っていきます。
この記事は前回の記事を呼んだ前提で説明していきますので、ぜひそちらを先に読むことをお勧めします。 また、今回の記事は前回よりも難易度と内容量が上がっていますが、記事の最後に作業後のリポジトリのリンクがありますので、そちらを先にダウンロードしてそちらと比較しながら学習することができます。 前回まで作成した状態のプログラムが以下のリポジトリからダウンロードできますので前回まででうまくいっていない方は参考にしてみてください。
最終目標(再掲)
・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. キャラの表示

今回は、はじめにキャラつまり操作できるヒーローを表示するところから実装していきます。
使用する画像は以下の画像になるので、assets/images/
フォルダに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型で渡します。
ここでいうコマとは、スプライト画像を分割した時に振られるインデックスのことで、インデックスは以下の画像のように割り振られています。

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

キャラの移動
この章ではついにヒーロを操作できるようになります。
ヒーローを操作できるようにするには以下の手順で実装できます。
- 歩くアニメーションの追加
- キーボードの入力イベントを追加
- ヒーローのゲームオブジェクトを移動させる(グリッドウォーク)
1. 歩くアニメーションの追加

まずはアニメーションの追加です。
編集するファイルは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
がアニメーションに紐づける名前、frameStart
とframeEnd
が先ほど学んだスプライト画像のコマのインデックスを指定して、アニメーションのコマの開始点と終点を格納する構造になっています。
次に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. キーボードの入力イベントを追加

ここではキーボードの入力によってアニメーションを切り替える実装をします。
編集するファイルは同じく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では自分で型を作ることができ、方法としてinterface
かtype
で作成できます。
今回は後から型をマージさせるような使い方はしないので、無難にtype
で宣言していきます。
作成した型では、string型で値が 'walk_front'
か'walk_back'
か'walk_left'
か'walk_right'
か''
のどれかしか許可しない型として定義しています。
これでヒーローのアニメーションが今どの状態にあるかを管理していきます。
なお、空文字の場合は動かない状態を表すようにしています。
次にクラスメンバ変数の追加で、今回はheroAnimState
をWalkAnimState
型、cursors
をPhaser.Types.Input.Keyboard.CursorKey
型で定義しています。heroAnimState
は先ほど作成した型でヒーローのアニメーションの状態を管理していきます。cursors
はPhaser3の機能を使ってキーボードの矢印キーの情報を取得するために定義しています。
次にinit()
の編集部分で、ここではクラスメンバ変数の初期化を行います。
今回初期化させるのは、先ほどのクラスメンバ変数heroAnimState
とcursors
です。heroAnimState
は初期状態として''
を代入して動かない状態を再現します。cursors
にはPhaser.Input.Keyboard.KeyboardPlugin.createCursorKeys()
メソッドで矢印の状態を監視するオブジェクトを代入します。
最後にupdate()
ではheroAnimState
の状態を変化させ、実際にアニメーションを変化させる実装をしています。
前半のif文ではクラスメンバ変数のcursors
の状態により分岐をさせ、heroAnimState
の状態を変化させています。 今回の実装では斜めの動きに対応しないため、if文の構成はもっともシンプルなものとなっており同時にキーが押されることを想定していないため同時にキーを押すと、上・下・左・右の順に優先度があることはご了承ください。
各矢印キーが押されているかを取得するには、Phaser.Input.Keyboard.Key.isDown
変数を確認します。
プログラム上ではthis.cursor.キー.isDown
の記述でboolean
型で取得しているのがわかります。
キーの選択にはup
・down
・left
・right
があります。
上のプログラムでは、何も押されていなければアニメーションが止まりその週のフレームを早期returnするように設計しています。
そして注目すべきは、このif文では状態を格納しているのはupdate()
内で定義されたheroAnimState
変数であるということです。
これは前回と今回のキーの状態を比較するために設計したため、この後の処理でローカルなheroAnimState
はクラスメンバ変数のheroAnimState
に代入されます。
その後のif文ではクラスメンバ変数を現在の状態に変更する処理と、現在の状態に応じたアニメーションの変化処理を行っています。 これでカーソルキーによるアニメーションの変化が実装できました。
3. ヒーローのゲームオブジェクトを移動させる(グリッドウォーク)
次はヒーローを移動させるプログラムの実装です。
ただヒーローを動かすのであれば前述のupdate()
内のアニメーション状態を格納する変数を変化させる分岐で座標を一定数づつ増減させることでで実装できます。
しかしそれではRPG感が出ないので、今回はグリッドウォークを実装します。
グリッドウォークとは、RPGゲームでおなじみのマスごとに移動する動きのことです。
以下の画像を比較して、普通の移動処理とグリッドウォークの違いを見てみましょう。


確かに普通の実装では小回りがきき、操作の自由度が高いかもしれません。
このような実装はアクションゲームの類でよくみられます。 しかし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()
では呼ばれた直後にheroIsMoving
がtrue
だった場合は早期リターンでその週のupdate()
処理をスキップするように設定されています。
もしここで早期リターンされなければ、直後にX・Y座標の移動方向を格納する変数heroXDir
・heroYDir
が先ほど定義したMoveDir
型で定義されます。
その後if文の条件分岐で入力に応じてheroXDir
・heroYDir
の値が変化します。
この条件分岐を抜けるということはどの方向かには移動するということが前述の説明からわかるので、ここですかさずheroIsWalking
変数にtrue
を格納します。
こうすることでアニメーションが終了するまで次回以降のupdate()
は突入直後に早期リターンによりスキップされます。
そしてそのあとにアニメーションを追加していきます。
アニメーションは今回新しく追加したシーンクラスのメソッドのgridWalkTween()
にて行います。
ここでいうアニメーションとは、当記事のはじめに実装したヒーローのアニメーションとは異なるものを実装していきます。gridWalkTween()
クラスメソッドを見ると、Phaser3のPhaser.GameObjects.GameObjectFactory.tween()
メソッドが呼ばれているのがわかります。
これはゲームオブジェクト自体にアニメーションを追加するメソッドで、第一引数に配列でゲームオブジェクトを渡し第二引数に設定用のオブジェクトを渡します。
設定オブジェクトの説明はとても長くなってしまうので今回は詳しくは説明できませんが、コメントに今回設定する部分の説明を加えています。
ここでお気付きかと思われますが、gridWalkTween()
クラスメソッドは実はPhaser.GameObjects.GameObjectFactory.tween()
メソッドをグリッドウォーク用にカスタマイズしたものになっています。
gridWalkTween()
の第一引数は対象のゲームオブジェクトで、第二引数は一回で移動する距離、第三・四引数は移動するX・Y方向をMoveDir
型で渡します。
そして、今回のキモになる第五引数にはアニメーション終了時のコールバックを渡していきます。
上のプログラムでは、コールバック内でthis.heroIsWalking = false
と実装することでヒーローの移動状況を止まっている状態に変化させています。
この実装でヒーロがやっと移動できるようになりました。
3. ヒーローの座標を再設定
ここまできたら次はヒーローが進めない場所を設定していきます。
ですがその前に現在の状態で木ああるところまで行ってみましょう。

木とヒーロが重なってしまうのは当然のことですが、グリッドウォークを実装したのに以下のように木のマスとヒーローが少しずれて配置されるかと思われます。
これはヒーローの初期位置がタイルの縦横のサイズ(縦横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
を用意します。 プロパティはtx
とty
がnumber
型で定義されていますが、これはタイル座標を意味します。
そしてheroTilePos
をinit()
にてtx
プロパティを10
、ty
プロパティを8
で初期化していて、プレイヤーの初期位置をタイル座標のXY(10, 8)にしているのがわかります。
次にcreate()
では冒頭で変数heroPos
をPhaser.Math.Vector2
型定義していますが、これはタイル座標を実際のゲームワールド座標に変換したものを格納するためのものです。
実際にタイル座標からゲームワールド座標に変換するには、前回記事でマップを作成した際にPhaser.Tilemaps.Tilemap
オブジェクトが登場しましたが、そのメソッドであるPhaser.Tilemaps.Tilemap.createStaticLayer()
を使用します。
このメソッドの第一・第二引数にタイル座標のX・Y座標を渡すことで、戻り値として渡した座標のタイルのゲームワールド座標を取得できます。
この戻り値を先ほどの変数heroPos
に代入し、ヒーローを描画をするメソッドの引数にheroPos
のx
プロパティとy
プロパティを渡すことでタイル座標にヒーローを配置できます。
しかしここで注意しなければいけないのは、タイル座標はタイル一枚の左上を中心とする座標を示しているのに対して、ヒーローを描画するPhaser.GameObjects.GameObjectFactory.sprite()
メソッドは表示領域の中心を座標としているということです。
つまり前回の記事のように、Phaser.GameObjects.Sprite.setOrigin()
メソッドにてヒーローの座標基準を今回は左上にするために第一引数に0
を渡して対応しています。
これでヒーローが正しく配置できたかと思われます。

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
が代入されている場合は早期リターンすることで、その後の処理でヒーローのタイル座標の更新とヒーローが実際に移動するのを防いでいます。
これで木のあるところへは進入できなくなったかとおもわれます。


5. NPCの追加

今度はNPCを追加してみましょう。
よく見るRPGゲームのNPCは一定の場所を移動するように実装していますが、今回の趣旨ではそこまでは説明しないので動かないNPCを実装していきます。
以下のスプライトを使っていきますので、assets/images/
にスプライト画像が保存されていない場合は以下の画像をあらかじめnpcs.png
と名前をつけて保存しておきましょう。
それではプログラムの編集です。
/* 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
で保存しておきましょう。
それではプログラムの編集です。
/* 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
が定義されています。
また、セリフを表示する領域をserif
・serifFrame
・serifArea
で定義しています。serif
がテキスト部分で、serifFrame
がセリフを表示するための枠そしてserifArea
はテキスト部分と枠部分をまとめて格納するためにPhaser.GameObjects.Container
で定義されています。
次にserifStyle
変数がオブジェクト型で定義されていますが、表示するテキストのスタイルを格納しています。
特筆すべきはwordWrap
プロパティで、ここには文字の折り返しを指定する部分になります。width
には折り返す長さが、useAdvanceWrap
は折り返しを適用するかが格納されます。
次にinit()
では、先ほどのheroIsTalking
が初期化されています。preload()
では、先ほどの画像をロードしているのがわかります。
そして編集量の多いcreate()
では簡単に説明すると、 セリフエリアの作成
・はなしかけた時のイベント
の処理が追加されています。
まずはセリフエリアの作成です。
前述のメンバ変数serif
とserifFrame
はこれまでの説明を元に生成と調整を行います。
今回説明するのは、Phaser.GameObjects.Container
型についてです。
ざっくり説明するとこのオブジェクトは他の複数のゲームオブジェクトをまとめることでき、このオブジェクトに対して行った変更は格納されたゲームオブジェクト全てに適用されます。
他のゲームオブジェクトを格納するにはPhaser.GameObjects.Container.add()
メソッドを呼び出し、第一引数にゲームオブジェクト単体またはその配列を渡すことで格納できます。
上のプログラムではserif
とserifFrame
を格納しているのがわかります。
その後Phaser.GameObjects.Container.setVisible()
メソッドで非表示にしていますが、これらは格納されたserif
とserifFrame
の両方に適用されているのが確認できます。
これでゲーム開始直後にセリフエリアが表示されなくなります。
次に話しかける処理の実装部分です。
今回は話しかけるトリガーをEnter
キーを押した瞬間にするためにイベントをセットしていきます。
原理から説明すると、Enter
キーを押した瞬間にヒーローが向いている先のタイル座標を取得し、NPCを表示するために定義した二次元配列のmap_event
上の先ほどのタイル座標にNPCがいるかを判定し、いた場合はセリフが表示されるといった実装になります。
まずヒーローが向いた先のタイル座標を知るために、クラスメソッドとしてgetNowHeroFaceTilePos()
が追加されています。
このメソッドの説明は当記事では長くなってしまうので、上記プログラム中のコメントを確認してみてください。
イベント処理の中では前述のメンバ変数heroIsTalking
がtrue
だった場合はセリフが表示されていることを表すため、セリフエリアを非表示にしてheroIsTalking
にfalse
を代入しています。
もし表示されていないと判断した場合は、heroIsTalking
にture
にして会話状態にして、セリフエリアを表示させます。
セリフの選択は、前述のgetNowHeroFaceTilePos()
メソッドの戻り値とメンバ変数のmap_events
を利用してヒーローの向いた先にどのNPCがいるかを判定します。
この時ヒーローの最後のアニメーションのキーを取ることで、NPCはどの位置から話しかけられたかを判定できます。
今回は正面から話しかけられた場合とそれ以外で分岐させているので、正面から話しかけられた場合は最後のアニメーションがwalk_back
、つまり後ろに歩いた場合で分岐させていきます。
これで話しかける実装の概要が説明できましたが、これではセリフが出た後も移動できてしまうのでupdate()
においてヒーロが会話中であった場合移動させないように上のプログラムのように条件式を追加しましょう。

最後にここまでの編集をすべて反映して実際に動作するようになっているプログラムを下記のリポジトリからダウンロードできるので、ここまででうまくいかなかった方やもう一回ぷrグラムを比較しながら学習したい方はぜひるようしてみてください。
まとめ
・Phaser3とTypescriptでゲーム作りの基礎を学べた
・2Dゲームのアニメーションはスプライト画像のコマ再生で再現できる
・マップ表示にはスプライト画像・タイルマップ・レイヤーの知識が必要
・グリッドウォークを実装できた
コメント
コメントを投稿