Phaserでブラウザゲー制作 Part.2 ジャンピングゲーム


[前回](http://qiita.com/d2cd-ytakada/items/dd4a5bf20d3066bf8c3f)は、準備編ということでPhaserを利用できる状態にまで持っていきました。

今回は、かんたんなゲームのサンプルとして、ジャンピングゲーム（というのかは知りませんが・・・）を作ります。


# まずは完成品


[こちらで遊べます](https://alf-ytakada.github.io/phaser-jumpgame/dist/)

1万メートル登ればクリアです。
何回目のチャレンジ（日数）でクリアできるかな！


# ゲームの構成

ゲームは3つの状態を持っています。

* preload
	* リソースをロードする
* main
	* メインのゲーム画面
* shop
	* アイテム購入画面

# 遷移画面の登録
画面は、Phaser.Gameオブジェクトのstateプロパティに生えている```add()```で行います。

```ts:main.ts
/// <reference path="../node_modules/phaser/typescript/phaser.d.ts"/>

import {PreloadState} from "./preloadState";
import {MainState} from "./mainState";
import {ShopState} from "./shopState";

// new Phaser.Game(width, height, レンダラ(Phaser.AUTOで自動選択), DOMエレメント指定)
let game    = new Phaser.Game(360, 640, Phaser.AUTO, "game");

game.state.add("preloadState", PreloadState);
game.state.add("mainState", MainState);
game.state.add("shopState", ShopState);
```


遷移画面を全て登録したら、
最初の画面であるpreloadStateに遷移します。


```ts
window.onload   = () => {
    // 登録した状態のうち、"preloadState"をスタートする
    game.state.start("preloadState");
}
```


# preload画面

preload状態では、ゲームに使うリソースファイルをダウンロードします。
必要に応じてプログレスバーを表示することも可能です(```loadUpdate()```をオーバーライドする)。

リソースのロードには、

```ts
   // 画像
   this.load.image("key", "url");
   // スプライトシート
   this.load.spritesheet("key", "url");
   // 音声 第二引数には、同じ音でフォーマットが違うURL（mp3とoggなど）をarrayで渡す。
   this.load.audio("key", ["url1", "url2", ..]);
```

といったように、``` this.load ```を通して行います。

preload()で指定したリソースのロードが完了すると、```create()```が呼ばれますので、

```ts
    create() {
        this.game.state.start("mainState");
    }
```

で次のmain画面に遷移します。


# main画面

この画面では、メインとなるジャンピングゲームの処理を行います。

## 状態間でのデータ受け渡し

状態遷移時に、別の画面に対してデータを渡したい場合は多々あると思います。
グローバルオブジェクトを作って引き回すのも手ではありますが、Phaserでは、

```ts
    this.game.state.start("遷移先状態名", true, false, arg1, arg2, ...);
      
```

という形で、argを渡せます。
渡した引数は、```init()```に引数として渡されますので、
受け取った状態側で```init(arg)```の形でオーバーライドしておき、その値を保持します。
([Phaser.StateManager.html#start](http://phaser.io/docs/2.6.2/Phaser.StateManager.html#start))


今回のゲームでは、所持金や日数、アイテム、総ジャンプ距離を引き回しています。


## create()

ゲームオブジェクトの初期化を行います。

* Phaser組み込みの物理演算機能を有効にします。

```ts
        // 物理演算を有効化
        this.game.physics.startSystem(Phaser.Physics.ARCADE);
        // 画面上下の衝突はOFFにする
        this.game.physics.arcade.checkCollision.down    = false;
        this.game.physics.arcade.checkCollision.up      = false;
```

### プレイヤーキャラのセットアップ


```ts
    // プレイヤーキャラのスプライト作成
    loadPlayer(spriteName : string) : Phaser.Sprite {
        const sprite  = this.add.sprite(this.world.centerX, this.world.centerY, spriteName, 2);
        sprite.anchor.setTo(0.5, 0.5);
        sprite.animations.add("left",  [3, 4, 5, 4], 10, true);
        sprite.animations.add("right", [6, 7, 8, 7], 10, true);
        sprite.animations.add("jump",  [0, 1, 2, 1], 30, true);

        this.game.physics.arcade.enable(sprite);

        return sprite;
    }
```


キャラのスプライトは、preload画面にてロードした際のキー名でアクセスできます。
左右移動、ジャンプのアニメーションは、スプライトシートのセル位置（左上→右下）を配列で渡すことで追加可能です。
最後にこのスプライトの物理演算を有効化しておきます。

```ts:create()内の処理
        // キャラクターロード
        this.player = this.loadPlayer("chara1");
        this.player.body.gravity.y  = 500 - this.data.item["cloak"] * 20;
        this.player.body.collideWorldBounds = true;
        // 最大速度制限を解除しておく
        this.player.body.maxVelocity.y  = 100000;
```

キャラのスプライトには、重力として```body.gravity.y```に適当な値を設定します。
phaserの物理演算(Arcadeエンジン?)では、最大の速度がデフォルトで制限されているので、 ```body.maxVelocity.y``` を適当な大きい値に設定しています。


### キー入力

キー入力に関する機能は、```game.input```を通してアクセス可能です。
キーごとに、```Phaser.Key```インスタンスを作成しておきます。
カーソルキーだけは、```Phaser.CursorKeys```としてまとめられています。

```ts
       // キー入力
        this.cursors    = this.input.keyboard.createCursorKeys();
        this.spaceBar   = this.input.keyboard.addKey(Phaser.Keyboard.SPACEBAR);
        this.rKey       = this.input.keyboard.addKey(Phaser.Keyboard.R);
        this.sKey       = this.input.keyboard.addKey(Phaser.Keyboard.S);
```

### 床の生成

キャラが登る床を生成します。

```ts
        // 床グループ生成
        this.steps  = this.add.group();
        this.world.sendToBack(this.steps);
        this.steps.enableBody   = true;
...

        // 初期床を配置
        this.placeStep(0, this.world.width, this.world.height, "stepStart");
        for (let h = -this.game.height + this.placeInterval ; h < 0 ; h++) {
            this.placeClimbSteps(h);
        }
```




### 背景

キャラが登るごとに、登った感を出すため背景をずらします。
背景は、スプライトの重なり的に、一番後ろに配置します```sendToBack()```。
また、計算をやりやすくするために、画面左下に、画像の左下をあわせています(```anchor.setTo(0, 1)```)。

```ts
        // 背景ロード
        // 背景は、登った距離に応じてずらす。
        // 左下を起点に設定する
        this.backgroundSprite   = this.add.sprite(0, this.world.height, "background");
        this.backgroundSprite.sendToBack();
        this.backgroundSprite.anchor.setTo(0, 1);
```

anchorは

* x軸（第一引数）　＝　0 : 左 〜 1 : 右
* y軸（第二引数）　＝　0 : 上 〜 1 : 下

となります。


## update()

updateは、毎フレーム呼ばれる関数です。
ここでゲームのメイン処理を行います。
この画面では、状態を

* ゲーム中
* クリア済み
* 死亡済み

の3つにわけて、それぞれのupdate処理を行っています。

```ts
    // メインループ
    update() {
        // 死亡済み
        if (this.state === State.DEAD) {
            this.updateWhenDead();
        }
        // クリア済み
        else if (this.state === State.CLEARED) {
            this.updateWhenCleared();
        }
        // 生存中
        else {
            this.updateWhenAlive();
        }
    }
```


### 衝突判定

プレイヤーは床を登っていきますが、
その衝突判定は

```ts
    // キャラと床の衝突判定
    this.physics.arcade.collide(this.player, this.steps, this.onCollideStep, null, this);

```

だけでOKです。
プレイヤーが床と衝突した場合は、```this.onCollideStep()```が、thisのコンテキストで呼び出されます。

床との接触判定時に、プレイヤーが下から床にあたってもすり抜けられるようにするため、床のスプライトでは下の衝突判定をなくしています。

```ts
    step.body.checkCollision.down   = false;
```


### 入力チェック

　```create()```でセットアップした入力用クラスを用いて、入力チェックを行います。


```
        ////////////
        // 入力判定
        if (this.cursors.left.isDown) {
            this.player.body.velocity.x  = -200;
            this.player.animations.play("left");
        }
        else if (this.cursors.right.isDown) {
            this.player.body.velocity.x  = 200;
            this.player.animations.play("right");
        }
        else {
            this.player.body.velocity.x  = 0;
            if (this.isJumping == false) {
                this.player.animations.stop();
            }
        }
        // 減速機能
        if (this.cursors.down.isDown) {
            this.player.body.velocity.y += 50;
        }

        // ジャンプ
        if (this.spaceBar.isDown && this.isJumping == false) {
            const vel   = this.calcJumpVelocity(this.data.item["ring"]);
            // 上に登るので、負の値となる。ので、現在の速度が新速度より大きければ、新しい速度に更新する
            if (this.player.body.velocity.y > vel) {
                this.player.body.velocity.y  = vel;
            }
            this.player.animations.play("jump");
            this.isJumping  = true;
        }
```

Phaserでは、Keyクラスに```repeat```プロパティがあり、このプロパティ値を用いることで、何フレーム押され続けているかをチェックすることもできます。
これは
　「ボタンを一度のみ押した場合の処理」
を作る際や、
　「ボタンを長押した際の処理」
を作る場合に便利です。
[Phaser.Key.html#repeats](https://phaser.io/docs/2.6.2/Phaser.Key.html#repeats)


### 床の削除

プレイヤーがジャンプするごとに、新しい床の生成と、古い床の削除を行います。

床は削除してあげないと、処理がものすごく重くなってしまいます。


```ts
            // 適当なところで、見えない床を削除
            if (Math.floor(this.climbHeight) % 100 == 0) {
                for (let step of this.steps.children) {
                    if (! step.visible) {
                        this.steps.remove(step, true);
                    }
                }
            }
```


### 死亡判定

プレイヤーキャラが画面下に行ってしまった場合、死亡処理を行います。
状態(```this.state```)を死亡状態に設定します。
死亡時には、登った距離に応じた賞金を付与します。
また、ダイアログを表示し、再チャレンジかショップに行くかを選択することができるようにします。

元がjavascriptなので、```setTimeout()```も問題なく使用可能です。

```ts

        // 死亡判定
        if (this.player.y > this.world.height) {
            // 画面外に落ちた
            this.player.alive   = false;
            this.state          = State.DEAD;

            const reward        = Math.floor(this.climbHeight / 10);
            this.data.money     += reward;
            this.data.totalClimbHeight  += this.climbHeight;

            // 結果ダイアログ生成
            const dialog  = this.createResultDialog({
                climbHeight : sprintf("%.2f", this.climbHeight / 100),
                reward      : reward,
                state       : this,
            });
            dialog.x    = (this.world.width  - dialog.width ) /2;
            dialog.y    = (this.world.height - dialog.height) /2;
            // ちょっとあとにダイアログを開く
            setTimeout(() => {
                this.game.world.addChild(dialog);
            }, 500);
            return;
        }            
```


### クリア処理

10000m登ることができたらクリアです。
状態をクリア状態に設定します。

```
        // クリア判定
        if (this.climbHeight >= this.clearHeight) {
            this.data.totalClimbHeight  += this.climbHeight;
            this.state  = State.CLEARED;
            return;
        }
```

クリア状態にしたあとは、次の```update()```でクリア状態でのupdate処理が実行されます。

```ts
    // クリア時のupdate()
    updateWhenCleared() {
        if (this.isClearedShown) {
            return;
        }

        // クリア時の表示
        this.showClearedText();
        this.showClearedScore();

        this.isClearedShown = true;
    }
```

# shop画面

この画面では、お金でアイテムを購入／レベルアップスルことができます。

## アイテム一覧の作成

アイテムは、定義ファイルを作成し、管理します。

```ts:shopItemDefs.ts
// ショップで販売するアイテムの定義
class ItemDef {
    id  : number;
    key : string;
    resource    : string;
    name        : string;
    description : string;
    price       : number;
    incremental : number;
    maxLevel    : number;

    static leveledPrice(item : ItemDef, lv : number) : number {
        return item.price + lv * item.incremental;
    }
};

const ShopItemDefs : [ItemDef] = [
    {
        id  : 1,
        key : "ring",
        resource    : "./images/大地の指輪.png",
        name        : "ゆびわ",
        description : "ジャンプ力が増加",
        price       : 700,
        incremental : 300,
        maxLevel    : 30,
    },
    {
        id  : 2,
        key : "cloak",
        resource    : "./images/白銀の外套.png",
        name        : "がいとう",
        description : "落下速度が減少",
        price       : 500,
        incremental : 300,
        maxLevel    : 15,
    },
    {
        id  : 3,
        key : "talisman",
        resource    : "./images/罠除けの護符.png",
        name        : "おまもり",
        description : "特殊床の出現率UP",
        price       : 3000,
        incremental : 1000,
        maxLevel    : 3,
    },
];


export {ShopItemDefs, ItemDef};
```


新しいアイテムを追加する場合は、定義ファイルを編集します。

UI側では、定義ファイルをもとに列を生成します。

```ts
        // アイテム一覧
        for (let i = 0 ; i < ShopItemDefs.length ; i++) {
            const item      = ShopItemDefs[i];
            const itemSprite = this.createItemRow(item);
            itemSprite.y    = this.headerHeight + (i * this.itemHeight);
            sprite.addChild(itemSprite);
        }
```





# 感想

Phaserはさすがゲームライブラリだけあって、ゲームを作るのが楽になる機能が豊富だなと感じます。
特に物理演算が組み込みなため、スクラッチで書く際に面倒な衝突処理を書く必要が無いのがとても有り難かったです。

今回はキーボードを使っているため、スマートフォンのみでは操作できませんが、タッチ処理も取り扱えるようになっているため、スマホ向けゲームを作ることも問題なく可能そうでした。

Phaser自身がtypescriptで書かれているため、typescriptでソースを書きやすいのもgoodでした。
vscodeとの親和性がとても高いです。


それにしてもゲームを作るとなると、リソースの準備が大変でした・・・。
リソースを公開してくださっている、
[ぴぽや様](http://piposozai.blog76.fc2.com/)、[あとらそふと様](http://atorasoft.blog18.fc2.com/)に深く感謝致します。


ちなみに、普通にコーディングせずにブラゲーを作るなら、[RPGツクールMV](https://tkool.jp/mv/)が簡単だと思います。