Phaser3でロボットタートルを作ってみた

Javascriptで2Dゲームを作れないかと探したところ、「Phaser3」を見つけました。

Phaser Labs
　https://phaser.io/phaser3

動作理解のためのサンプルは以下です。
　http://labs.phaser.io/

チュートリアルがわかりやすく、動作理解のためのサンプルもたくさんあったので、試しに使ってみました。

どんなゲームを作ったかというと、以下にある「ロボットタートル」です。
　https://www.thinkfun.com/robot-turtles/index.php

単純なルールだったので、Phaser3とJavascriptで実装してみます。

GitHubにもソースコードを置きました。
　https://github.com/poruruba/robot-turtles

以下にデモを置いておきました。
ちょっと遊んでみてください。（Phaser3を試したかったのがモチベーションでしたので、見た目はチープです。。。。）
　https://poruruba.github.io/robot-turtles/

ルールは単純です。

・プレイヤは、最初の場所からマス目を移動し、ゴールの宝石までたどり着ければゴールです。全体で8×8のゲーム盤の広さが良いようです。
・ただし、移動する順序を決められるのは最初だけで、あとは自動的に動きます。ですので、最初にいくつかの順序を組み合わせて移動する準備をする必要があります。
・順序には以下の種類があります。
　・左へ向く
　・まっすぐ進む
　・右へ向く
　・ジャンプする
　・レーザーを発射する
※これだけでも小さなお子様は楽しいと思います。
・さらに、宝石までの道のりには障害物がある場合があります。
・箱
　　障害物ではありますが、箱の向こうに何もなければ、押すことができます。
・氷
　　障害物で通れませんが、レーザーを使えば壊せます。
・水たまり
　　ジャンプで飛び越せます。
・壁
　　どうあがいても通れません。

※実は本物の「ロボットタートル」とはルールを変えています。上記の水たまりとジャンプは本当はありません。

# 全体構成

サーバは一切使っておらず、クライアント側のJavascriptだけで動かしています。
Vue+Bootstrapを使ったフロントエンド部と、Phaser3を使ったゲーム盤操作部からなります。

#フロントエンド部

以下に、ざっとソースコードを載せちゃいます。
マップ情報の生成と、カード情報の生成の2つのフェーズがあります。

大まかに流れを示すと、

＜まずはお父さん＞
・block_selectでマップブロックを選択します。これを繰り返してマップを作ります。
・最後に、game_initで初期化します。
＜今度はお子さん＞
・card_selectでカードを選択します。これを繰り返して順序リストを作ります。
・最後に、player_startで出発進行です。card_listにあるカードを1枚ずつゲーム盤操作部に移動要求します。

```start.js
'use strict';

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        card_info_list: card_info_list,
        card_list: [],
        block_map: [],
        block_selecting_item: 1,
        player_angle: 0,
        block_info_list: block_info_list,
        game_state: 0,
        result_image: '',
        result_message: '',
        result_reason: '',
        map_json: ''
    },
    computed: {
    },
    methods: {
        //ゲーム盤操作部を初期化します。
        game_init: function(){
            //マップ情報を生成します。
            var config = {
                map: []
            };
            for( var j = 0 ; j < SLOT_NUM ; j++ ){
                config.map[j] = [];
                for(var i = 0 ; i < SLOT_NUM ; i++ ){
                    switch( this.block_map[j][i] ){
                        case 1:
                        case 2:
                        case 3:
                        case 4:
                            config.map[j][i] = block_info_list[this.block_map[j][i]].type;
                            break;
                        case 5:{
                            config.player = {
                                x : i,
                                y : j,
                                angle: Number(this.player_angle)
                            };
                            config.map[j][i] = null;
                            break;
                        }
                        case 6:{
                            config.goal = {
                                x : i,
                                y : j
                            };
                            config.map[j][i] = null;
                            break;
                        }
                        default:
                            config.map[j][i] = null;
                            break;
                    }
                }
            }
            if( !config.player || !config.goal ){
                alert('プレイヤまたはゴールが設定されていません。');
                return;
            }

            // 次回またマップ情報を復元できるようにCookiesに保存しておきます。
            Cookies.set('block_map', JSON.stringify({
                block_map : this.block_map, player_angle: Number(this.player_angle)
            } ));

            config.div = 'phaser_canvas';  //id=phaser_canvasのところにゲーム盤を表示します。
            this.game_state = 1;
            game_initialize(config);
        },
        player_start: async function(){
            if( this.card_list.length <= 0 ){
                alert('カードを加えてください。');
                return;
            }

            // 次回またマップ情報を復元できるようにCookiesに保存しておきます。
            Cookies.set('block_map', JSON.stringify({
                block_map : this.block_map, player_angle: Number(this.player_angle), card_list: this.card_list
            } ));

            // カード情報から1枚ずつゲーム盤操作部に操作要求を投げます。
            this.game_state = 2;
            var ret = null;
            for( var i = 0 ; i < this.card_list.length ; i++ ){
                ret = await player_move(this.card_info_list[this.card_list[i]].direct);
                // もし途中でゲームおーばだった場合はループを抜けます。
                if( ret.over )
                    break;
            }

            // すべてのカードを使ったので、終了します。
            game_over();
            if( ret && !ret.over && ret.type == 'goal' ){
                // ゴールに行きついていたら成功です。
                this.game_state = 3;
                this.result_message = 'おめでとう！';
                this.result_reason = 'ゴールにたどり着き宝石を手に入れました。';
                this.result_image = 'assets/congraturations.png';
                this.dialog_open('#result-dialog');
            }else{
                // ゴールに行きついていない場合は失敗です。
                this.game_state = 4;
                this.result_message = '残念';
                this.result_image = 'assets/failure.png';
                if( ret && ret.over )
                    this.result_reason = ret.reason;
                else
                    this.result_reason = 'ゴールにたどり着けませんでした。';
                this.dialog_open('#result-dialog');
            }
        },
        // ブロックを選択しました。
        block_select: function(x, y){
            this.block_map[y][x] = this.block_selecting_item;
            this.block_map = JSON.parse(JSON.stringify(this.block_map));
        },
        // 選んだカードリストをリセットします。
        card_list_reset: function(){
            this.card_list = [];
        },
        // 選んだカードの一部をリストから抜きます。
        card_remove: function(index){
            this.card_list.splice(index, 1);
        },
        // 順序リストに選んだカードを追加します。
        card_select: function(card){
            this.card_list.push(card);
            this.dialog_close('#card-add');
        },
        // 保持しておいたCookieからマップ情報を復元します。
        cookie_reload: function(){
            var temp = Cookies.get('block_map');
            if( !temp )
                return;
            this.map_load(JSON.parse(temp));
        },
        // JSONからマップ情報を復元します。
        json_load: function(){
            this.dialog_close('#map-dialog');
            var map = JSON.parse(this.map_json);
            this.map_load(map);
        },
        // マップ情報復元の共通関数
        map_load: function(map){
            if( !map || !map.block_map || map.block_map[0] == undefined || map.block_map[0][0] == undefined ){
                alert('フォーマットエラー');
                return;
            }
            this.block_map = map.block_map;
            this.player_angle = map.player_angle;
            if( map.card_list && map.card_list[0] != undefined )
                this.card_list = map.card_list;
        },
        // マップ情報をリセットします。
        map_list_reset: function(){
            this.block_map = Array(SLOT_NUM);
            for( var i = 0 ; i < this.block_map.length ; i++ ){
                this.block_map[i] = Array(SLOT_NUM);
                this.block_map[i].fill(0);
            }
            this.player_angle = 0;
        },
        // マップ情報をJSON形式にしてダイアログ表示します。
        map_dialog: function(){
            this.map_json = JSON.stringify({
                block_map : this.block_map, player_angle: this.player_angle, card_list: this.card_list
            });
            this.dialog_open('#map-dialog');
        },
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        // マップ情報を初期化します。
        this.map_list_reset();
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );
```

※これ以外にもソースがありますが、GitHubを参照ください。

#ゲーム盤操作部

ポイントだけ補足します。

・今回は重力(Gravity)は使っていません。
・tweensを使うことで、決まった期間で移動や回転を制御できます。

大まかな流れは以下の通りです。

・game_initializeで初期化します。
　そうすると、preloadとcreateが呼ばれますので、必要な初期化を行います。
・あとは、updateが無限に呼び出されます。
・一方で、フロントエンド部から、以下の呼び出しがあります。
　・player_move：プレイヤの移動要求です。Promiseを返しているので、終わったら、g_callback_player_move_completeを呼び出してPromiseから抜け出してあげます。実際には、updateの中で移動処理を行います。
　・game_over：updateループを終了し、ゲームを終了します。

```index.js
'use strict';

// パラメータ
const SPRITE_SIZE = 80;
const BLOCK_SIZE = 397;
const PLAYER_SIZE = 378;
const SLOT_NUM = 8;
const MOVE_DURATION = 1000;

// グローバル変数
var g_game = null;
var g_map = null;
var g_player = null;
var g_goal = null;

// ゲームオブジェクト
var player;
var goal;
var stars;
var bombs;
var blocks;
var goals;
var blocks_jumpable;
var platforms;

// ゲーム情報の初期化
function game_initialize(config){
    var game_config = {
        type: Phaser.AUTO,
        width: SLOT_NUM * SPRITE_SIZE,
        height: SLOT_NUM * SPRITE_SIZE,
        physics: {
            default: 'arcade',
            arcade: {
                debug: false,
            }
        },
        scene: {
            preload: preload,
            create: create,
            update: update
        }
    };
    g_map = config.map;
    g_player = config.player;
    g_goal = config.goal;
    game_config.parent = config.div;
    
    // PhaserGameの生成
    g_game = new Phaser.Game(game_config);
}

// スプライトインデックスと座標の変換
function index2pos(index){
    return SPRITE_SIZE * index + SPRITE_SIZE /2;
}

// Phasr.Gameで設定したので、Phaserから呼ばれる
function preload (){
    //画像情報の事前ロード
    this.load.image('background', 'assets/background.png');
    this.load.image('block_box', block_info_list[1].image);
    this.load.image('block_ice', block_info_list[2].image);
    this.load.image('block_puddle', block_info_list[3].image);
    this.load.image('block_wall', block_info_list[4].image);
    this.load.image('player', block_info_list[5].image);
    this.load.image('goal', block_info_list[6].image);
}

// Phasr.Gameで設定したので、Phaserから呼ばれる
function create (){
    // 背景画像の設定
    this.add.image(0, 0, 'background').setOrigin(0, 0);

    // スプライト境界線の描画
    var g = this.add.graphics();
    g.clear();
    g.lineStyle(1, 0x0000ff);
    g.setPosition(0, 0);
    for( var i = 1 ; i < SLOT_NUM ; i++ ){
        g.moveTo(SPRITE_SIZE * i, 0);
        g.lineTo(SPRITE_SIZE * i, SPRITE_SIZE * SLOT_NUM - 1);
        g.moveTo(0, SPRITE_SIZE * i);
        g.lineTo(SPRITE_SIZE * SLOT_NUM - 1, SPRITE_SIZE * i);
    }
    g.strokePath();

    platforms = this.physics.add.staticGroup();

    blocks = this.physics.add.group();
    blocks_jumpable = this.physics.add.group();

    // マップ情報の登録
    for( var j = 0 ; j < SLOT_NUM ; j++ ){
        if( !g_map[j] )
            continue;
        for( var i = 0 ; i < SLOT_NUM ; i++ ){
            switch(g_map[j][i]){
                case 'box':
                case 'ice':
                case 'wall':
                    var block = blocks.create(index2pos(i), index2pos(j), 'block_' + g_map[j][i]).setScale(SPRITE_SIZE/BLOCK_SIZE);
                    g_map[j][i] = {
                        type: g_map[j][i],
                        object: block
                    };
                    break;
                case 'puddle':
                    var block = blocks_jumpable.create(index2pos(i), index2pos(j), 'block_' + g_map[j][i]).setScale(SPRITE_SIZE/BLOCK_SIZE);
                    g_map[j][i] = {
                        type: g_map[j][i],
                        object: block
                    };
                    break;
            }
        }
    }

    // ゴールの登録
    goals = this.physics.add.group();
    goal = goals.create(index2pos(g_goal.x), index2pos(g_goal.y), 'goal').setScale(SPRITE_SIZE/BLOCK_SIZE);
    g_map[g_goal.y][g_goal.x] = {
        type: 'goal',
        object: goal
    };

    // プレイヤの登録
    player = this.physics.add.group().create(index2pos(g_player.x), index2pos(g_player.y), 'player').setScale(SPRITE_SIZE/BLOCK_SIZE);
    this.tweens.add({
        targets: player,
        angle: g_player.angle,
        duration: 1,
    });

    // 衝突検知の登録
    player.setCollideWorldBounds(true);

    this.physics.add.collider(player, platforms);
    this.physics.add.collider(blocks, platforms);
    this.physics.add.collider(blocks_jumpable, platforms);
    this.physics.add.collider(goals, platforms);

    this.physics.add.collider(player, blocks, hitBomb, null, this);
    this.physics.add.collider(player, blocks_jumpable, hitJumpable, null, this);
    this.physics.add.collider(player, goals, hitGoal, null, this);
}

// 状態管理
var State = {
    IDLE: 0,
    MOVING: 1,
    ROLLING: 2,
};
var g_state = State.IDLE;

var g_request = Request.IDLE;

var g_resolve = null;
var g_reject = null;

var gameEnd = false;
var gameOver = false;
var gameOverReason = '';

function game_over(){
    gameEnd = true;
}

// フロントエンドからのプレイヤ移動要求
async function player_move(direction){
    if( g_request != Request.IDLE ){
        throw 'now moving';
    }

    // 完了に時間がかかるので、とりあえすPromiseを返します。
    return new Promise((resolve, reject) =>{
        // 要求情報をグローバル変数にセット
        g_resolve = resolve;
        g_reject = reject;        
        g_request = direction;
    });
}

// 移動完了後のコールバック関数
function g_callback_player_move_complete(){
    if( gameOver ){
        // プレイヤの色を変えます
        player.setTint(0xff0000);
    }
    
    // Promise状態をResolveします。
    g_resolve({
        x: g_player.x,
        y: g_player.y,
        angle: g_player.angle,
        over: gameOver,
        reason: gameOverReason,
        type: g_map[g_player.y][g_player.x] ? g_map[g_player.y][g_player.x].type : null
    });
    g_request = Request.IDLE;
    g_state = State.IDLE;
}

// 次の移動先の計算
function next_dir(count = 1){
    var dir = {
        x: g_player.x,
        y: g_player.y
    };

    if( player.angle == 270 || player.angle == -90 ){
        dir.x -= count;
    }else
    if( player.angle == 180 || player.angle == -180){
        dir.y += count;
    }else
    if( player.angle == 90 || player.angle == -270){
        dir.x += count;
    }else{
        dir.y -= count;
    }

    // 枠をはみ出ていないかの確認
    if( dir.x < 0 || dir >= SLOT_NUM || dir.y < 0 || dir.y >= SLOT_NUM)
        return null;
    
    return dir;
}

//現在位置の更新
function update_dir(dir){
    g_player.x = dir.x;
    g_player.y = dir.y;

    return dir;
}

// ゲームの再描画要求
function update (){
    if( gameEnd ){
        this.physics.pause();
        return;
    }
    if (gameOver){
        return;
    }

    if( g_state == State.IDLE ){
        switch( g_request ){
            //レーザー光線
            case Request.LASER: {
                var dir = next_dir();
                if( dir == null ){
                    g_callback_player_move_complete();
                    return;
                }
                // 氷の場合はオブジェクトを削除する
                if( g_map[dir.y][dir.x] != null && g_map[dir.y][dir.x].type == 'ice' ){
                    g_map[dir.y][dir.x].object.disableBody(true, true);
                    g_map[dir.y][dir.x] = null;
                }
                g_callback_player_move_complete();
                break;
            }
            //ジャンプ
            case Request.JUMP: {
                var dir = next_dir();
                if( dir == null || (g_map[dir.y][dir.x] != null && g_map[dir.y][dir.x].type != 'puddle' )){
                    g_callback_player_move_complete();
                    return;
                }

                // 2つ先のジャンプ先の確認
                var dir_jumped = next_dir(2);
                if( dir_jumped == null ){
                    gameOverReason = 'ジャンプした先でけがをしました。';
                    gameOver = true;
                    g_callback_player_move_complete();
                    return;
                }

                g_state = State.MOVING;
                update_dir(dir_jumped);
                this.tweens.add({
                    targets: player,
                    x: index2pos(dir_jumped.x),
                    y: index2pos(dir_jumped.y),
                    duration: MOVE_DURATION,
                    onComplete: g_callback_player_move_complete
                });
                break;
            }
            //右へ向く
            case Request.TURN_RIGHT: {
                g_state = State.ROLLING;
                this.tweens.add({
                    targets: player,
                    angle: '+=90', //指定の仕方に注意
                    duration: MOVE_DURATION,
                    onComplete: g_callback_player_move_complete
                });
                break;
            }
            //左へ向く
            case Request.TURN_LEFT: {
                g_state = State.ROLLING;
                this.tweens.add({
                    targets: player,
                    angle: '-=90', //指定の仕方に注意
                    duration: MOVE_DURATION,
                    onComplete: g_callback_player_move_complete
                });
                break;
            }
            // 直進する
            case Request.GO: {
                var dir = next_dir();
                if( dir == null ){
                    g_callback_player_move_complete();
                    return;
                }

                // 目の前が箱だった場合
                if( g_map[dir.y][dir.x] != null && g_map[dir.y][dir.x].type == 'box' ){
                    var dir_pushed = next_dir(2);
                    // 2つ先に何かある場合は動かさない
                    if( dir_pushed == null || g_map[dir_pushed.y][dir_pushed.x] != null ){
                        g_callback_player_move_complete();
                        return;
                    }
                    // 箱を動かす
                    this.tweens.add({
                        targets: g_map[dir.y][dir.x].object,
                        x: index2pos(dir_pushed.x),
                        y: index2pos(dir_pushed.y),
                        duration: MOVE_DURATION,
                    });
                    // マップ情報の更新
                    g_map[dir_pushed.y][dir_pushed.x] = g_map[dir.y][dir.x];
                    g_map[dir.y][dir.x] = null;
                }
    
                // プレイヤを動かす
                g_state = State.MOVING;
                update_dir(dir);
                this.tweens.add({
                    targets: player,
                    x: index2pos(dir.x),
                    y: index2pos(dir.y),
                    duration: MOVE_DURATION,
                    onComplete: g_callback_player_move_complete
                });
                break;
            }
        }
    }
}

// 衝突検知
function hitBomb (player, bomb){
    console.log('hitBomb');
    gameOverReason = 'ぶつかってけがをしました。';
    gameOver = true;
}

// 衝突検知(水たまり)
function hitJumpable (player, bomb){
    console.log('hitJumpable')
    // ジャンプ中だったら衝突しない
    if( g_request == Request.JUMP )
        return;

    gameOverReason = 'ぶつかってけがをしました。';
    gameOver = true;
}

// 衝突検知(ゴール)
function hitGoal (player, bomb){
    console.log('hitGoal')
}
```

#最後に

長ったらしいソースコードですみません。
Phaserを使いこなしていないですし、まだまだバグがあるかもしれせん。
ですが、ルールは単純ですので、なんとなく理解できると思います。これをマスターすれば、あとは自由にルールを作っていけるはずです。

作ってみて気づいたのですが、懐かしい倉庫番に似てますね。次は倉庫番を作ってみてみようと思います。

(追記 2019/4/24)
作ってみました。
　[Phaser3で倉庫番を作ってみた](https://qiita.com/poruruba/items/52edaabc0012cd411246)

以上