Phaser3で倉庫番もどきを作ってみた

前回、Phaser3をつかって、ロボットタートルを作りました。
　[Phaser3でロボットタートルを作ってみた](https://qiita.com/poruruba/items/debb471ed652dd8b2a50)

簡単に作れたので、勢い余って今度は「倉庫番」もどきを作ってみました。
昔に流行ったので知っている人は懐かしいかもしれません。

カーソルで左右に向きを変えつつ、上キーで進みながら、ゴールの宝石までたどり着けば成功です。
途中、箱がありますが、押して動かすことができます。
水たまりがあったら、シフトを押しながら進めば飛び越えられます。また、氷があったら、スペースを押すとレーザー光線を発射して溶かします。
もし鍵が置いてある場合はそれを拾ってからでないと宝石に到着しても成功となりません。

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

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

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

#フロントエンド部
以下に、ざっとソースコードを載せちゃいます。
前回と違って、今回はカードの順序リストを作って自動進行ではなく、カーソルキーを使って自由に動けるようにしています。

＜まずはお父さん＞
・block_selectでマップブロックを選択します。これを繰り返してマップを作ります。
＜今度はお子さん＞
・「始める」ボタンを押すと始まります。カーソルキーとシフトキーを使ってプレイヤを動かしましょう。

```js:start.js
'use strict';

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

        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: '',
        state: {}
    },
    computed: {
    },
    methods: {
        //ゲーム盤操作部を初期化します。
        game_init: function(){
            //マップ情報を生成します。
            var config = {
                map: [],
                player: null,
                goal: null,
            };
            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: // box
                        case 2: // ice
                        case 3: // puddle
                        case 4: // wall
                        case 7: // key
                            config.map[j][i] = block_info_list[this.block_map[j][i]].type;
                            break;
                        case 5:{
                            if( config.player != null){
                                alert('複数のプレイヤが登録されています。');
                                return;
                            }
                            config.player = {
                                x : i,
                                y : j,
                                angle: Number(this.player_angle)
                            };
                            config.map[j][i] = null;
                            break;
                        }
                        case 6:{
                            if( config.goal != null){
                                alert('複数のゴールが登録されています。');
                                return;
                            }
                            config.goal = {
                                x : i,
                                y : j
                            };
                            config.map[j][i] = null;
                            break;
                        }
                        default:
                            config.map[j][i] = null;
                            break;
                    }
                }
            }
            if( config.player == null || config.goal == null ){
                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_set_callback(this.callback);
        },
        callback: function(state){
            this.state = state;

            // ゴールにたどり着いたか、失敗したかを判別
            if( state.type == 'goal' && state.num_keys == 0 ){
                game_over();

                // ゴールに行きついていたら成功です。
                this.game_state = 3;
                this.result_message = 'おめでとう！';
                this.result_reason = 'ゴールにたどり着き宝石を手に入れました。';
                this.result_image = 'assets/congraturations.png';
                this.dialog_open('#result-dialog');
            }else if( state.over ){
                game_over();

                // ゴールに行きついていない場合は失敗です。
                this.game_state = 4;
                this.result_message = '残念';
                this.result_image = 'assets/failure.png';
                this.result_reason = state.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));
        },
        // 保持しておいた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;
        },
        // マップ情報をリセットします。
        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
            });
            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 );
```

#ゲーム盤操作部

前回とほぼ変わりませんが、移動要求を待ち受けるのではなく、updateのループの中でカーソルキーの入力をチェックし、入力されていれば自分で移動要求を設定しています。

```js:index.js
'use strict';

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

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

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

// ゲーム情報の初期化
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);
    this.load.image('block_key', block_info_list[7].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();

    g_num_keys = 0;

    // マップ情報の登録
    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;
                case 'key':
                    g_num_keys++;
                    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,
    });

    // カーソル入力の設定
    cursors = this.input.keyboard.createCursorKeys();

    // 衝突検知の登録
    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);

    g_callback_player_move_complete();
}

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

var g_request = Request.IDLE;

var g_callback = null;

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

function game_over(){
    gameEnd = true;
}

// フロントエンドへのコールバック登録
function player_set_callback(callback){
    g_callback = callback;
}

// 移動完了後のコールバック関数
function g_callback_player_move_complete(){
    if( gameOver ){
        // プレイヤの色を変えます
        player.setTint(0xff0000);
    }

    // 鍵の場所に着いたら、残り鍵数をデクリメントして、鍵を消します。
    if( g_map[g_player.y][g_player.x] != null && g_map[g_player.y][g_player.x].type == 'key' ){
        g_num_keys--;
        g_map[g_player.y][g_player.x].object.disableBody(true, true);
        g_map[g_player.y][g_player.x] = null;
    }
    
    g_request = Request.IDLE;
    g_state = State.IDLE;

    // コールバックを呼び出します。
    if( g_callback != null ){
        g_callback({
            x: g_player.x,
            y: g_player.y,
            angle: g_player.angle,
            over: gameOver,
            reason: gameOverReason,
            num_keys: g_num_keys,
            type: g_map[g_player.y][g_player.x] ? g_map[g_player.y][g_player.x].type : null
        });
    }
}

// 次の移動先の計算
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 ){
        // キー入力のチェック
        if (cursors.up.isDown ){
            if( cursors.shift.isDown)
                g_request = Request.JUMP;
            else
                g_request = Request.GO;
        }else
        if(cursors.space.isDown){
            g_request = Request.LASER;
        }else
        if (cursors.right.isDown){
            g_request = Request.TURN_RIGHT;
        }else
        if( cursors.left.isDown ){
            g_request = Request.TURN_LEFT;
        }

        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;

    // 移動完了後に鍵を消すので、移動中は何もしない
    if( g_map[g_player.y][g_player.x].type == 'key' )
        return;
        
    gameOverReason = 'ぶつかってけがをしました。';
    gameOver = true;
}

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

#参考マップ

参考マップも挙げておきます。
「JSON」ボタンを押下して、以下をペーストしてロードしてみてください。

```
{
	"block_map":[[5,4,0,0,4,0,0,7],[0,4,0,4,0,1,1,0],[0,3,0,4,0,1,0,0],[4,4,0,4,0,4,0,0],[0,0,1,4,0,4,0,0],[1,4,0,4,0,1,0,4],[0,0,4,0,0,1,0,4],[0,0,0,0,3,4,0,6]],
	"player_angle":180
}
```

以下のようなマップです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9ec33fb5-0245-51a9-b65f-c51fd5afc6c6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/2df4060b-6f62-c370-be8a-49a365e49071.png)

#最後に

前回のソースコードをベースに、一瞬で完成しました。
なんか楽しくなってきました。いろんな仕掛けを追加していきたいと思います。

以上