むりこのーと

創作活動の記録や、日々思ったことをゆるく書いています。

【ゲーム開発】気持ちいい横スクロールアクションを作るPart2

どうも。

開発環境

現在の進捗

前回との比較

前回はサンプルゲームを少しいじっただけになっていました。↓こんな状態です。

今回、色々と手を加えた結果以下のようになりました。

実装内容

実装したのは以下の通りとなります。

  • プレイヤーのテクスチャ変更
  • プレイヤー移動時のカメラ処理
  • マップシステムの実装
  • ゴール後再スタート処理の実装(解説は次回)

ゴール後再スタート処理は次回に回そうかと思います。すべて解説しようとするとまあまあ長くなりそうなので。

プレイヤーのテクスチャ変更

以前はサンプルそのままのコウモリのようなキャラクターでしたが、今回は横スクロールアクションで地面を走ることを想定していたので、お友達にアニメーションを描いてもらい(サンプルをうまくいじってもらい)、走っているようなテクスチャ/アニメーションに変更しました。

ここは特に頑張ったところはないかなと思います。phaserないしほとんどのゲームエンジンやライブラリではアニメーションを割と簡単に実装することができるので。

プレイヤー移動時のカメラ処理

プレイヤーが左右に移動する時、カメラがプレイヤーについてくる、いわゆるカメラの追従も実装しました。当たり前の機能ですがなくてはならないですね。

phaser側で追従設定をするとともに、ルックアヘッドの設定も行います。プレイヤーが右に移動している時はカメラがプレイヤーの左よりも右側に少し寄る、あれです。これがなくてもゲームは成り立ちますが、あるとプレイヤーにとってはまあありがたいのかなと思います。

UI要素の固定表示

実装にあたって、まずはレイヤー機能を使用しました。レイヤー機能を使うとphaserで使用するオブジェクトをレイヤーごとに管理することができます。

そこでプレイヤーやマップ、アイテムなどはレイヤーなし(デフォルト状態のまま)、ゲームスタート時のテキストや画面上部に常に固定して表示される文言など、いわばカメラの追従に関係ない要素はUIレイヤーにまとめることにしました。

そしてこれと併せてUIカメラも実装しました。phaserでは複数のカメラを取り扱うことができるので、UIカメラにだけスコアなどのUI要素を表示するようにしました。以下がカメラの設定関連のソースとなります。

        // UIカメラの作成
        this.uiCamera = this.cameras.add(
            0,
            0,
            COMMON_CONST.SCREEN_WIDTH,
            COMMON_CONST.SCREEN_HEIGHT
        );
        // UIレイヤーの設定
        this.uiLayer = this.add.layer();

        // Create tutorial text
        this.tutorialText = this.add
            .text(this.screenCenterX, this.screenCenterY, "Tap to start!", {
                fontFamily: "Arial Black",
                fontSize: 42,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
                align: "center",
            })
            .setOrigin(0.5);
        this.uiLayer.add(this.tutorialText);

        // Create score text
        this.scoreText = this.add
            .text(COMMON_CONST.SCREEN_WIDTH / 2, 50, "Score: 0", {
                fontFamily: "Arial Black",
                fontSize: 28,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
                align: "center",
            })
            .setOrigin(0.5);
        this.uiLayer.add(this.scoreText);

        // メインカメラからUI要素を除外
        this.cameras.main.ignore(this.uiLayer);

まずUIカメラとUIレイヤーを作成します。レイヤーを作成するときはthis.add.layer()を使います。

        ...
        this.uiLayer.add(this.scoreText);

        // メインカメラからUI要素を除外
        this.cameras.main.ignore(this.uiLayer);

UIレイヤーにUI要素となるオブジェクトを追加していき、最後にthis.cameras.main.ignore(this.uiLayer)でUIレイヤーのオブジェクトをメインカメラで非表示になるように設定します。

一方UIカメラでもプレイヤーなどを非表示にしなければいけませんので、ignore()を使用して以下のように記述します。

        this.player = new Player( ... );
        this.uiCamera.ignore([this.player]);

複数同時に非表示登録することもでき、その場合はignore([..., ..., ...])のように記述します。

追従設定とルックアヘッド

カメラの追従設定は簡単です。以下のように記述します。

        // カメラの追従設定
        this.cameras.main.startFollow(this.player, true, 0.1, 0.1);
        // 先読み距離の定義
        this.lookahead = this.scale.width * 0.1;
        this.cameraTargetOffsetX = 0;

camera.startFollow(target, roundPixels, lerpX, lerpY, offsetX, offsetY);を使用しています。2番目の引数以降は任意です。重要なのは3番目以降で、3,4番目のlerpXlerpYは、カメラ追従の速度です。この値が小さいとカメラがプレイヤーにゆっくり追従するようになります。おすすめは0.1程度です。

5,6番目はカメラのオフセットで、カメラがプレイヤーを中心にとらえているときが0となります。このオフセットを設定するとプレイヤーが中心からずれていきます。今回は最初はゼロに設定しています。そして先読み距離を設定します。今回は画面の幅の0.1倍をプレイヤーからずらすように設定します。

次にupdate()でカメラのオフセットを設定します。なぜ先ほどオフセットの設定をしなかったかというと、phaserの標準のオフセット設定ではプレイヤーの向きが変わったときにカメラの動きが滑らかに切り替わらないので若干見づらいです。それを防ぐために手動でオフセット設定をします。

        // カメラの位置設定(プレイヤーが画面の中央やや後ろに位置するように)
        // 進行方向が右の時
        if (this.player.facingRight) {
            this.cameraTargetOffsetX = -this.lookahead;
        } else {
            this.cameraTargetOffsetX = this.lookahead;
        }

        // 現在のオフセットを取得
        const currentOffsetX = this.cameras.main.followOffset.x;
        // オフセットを徐々に目標値に近づける
        const newOffsetX =
            currentOffsetX +
            (this.cameraTargetOffsetX - currentOffsetX) *
                GAME_CONST.CAMERA_OFFSET_LERP;
        this.cameras.main.followOffset.x = newOffsetX;

進行方向に応じて、あるべきオフセットの値を取得します。次に、現在のオフセットの値を取得し、あるべきオフセットの値と現在の値の差分を取得し、徐々にそれを埋めるようにします。こうすることでプレイヤーの向きが変わったときでも滑らかにカメラ位置が変わるようになります。

マップシステムの実装

マップの実装は一番時間がかかりました。今回の進捗のメインとなります。

今回この実装にあたってはTiledというツールを使用しました。

https://www.mapeditor.org/

Tiledは汎用マップエディタで、わかりやすく言うとマリオメーカーのステージ作成画面のようなものです。

床などの素材をあらかじめ準備しておき、それを好きなように配置してマップを作成します。作成したマップはTiled側でjsonファイルにエクスポートすることもできるので、それをphaserで使用する、といった形で運用しています。

phaserでTiledのマップを使用するためには、マップを作ったときのタイルマップ(床などの素材集)とマップのjsonファイルが必要になります。

    preload() {
        // Tiledによって作成されたマップの読み込み
        this.load.tilemapTiledJSON("map_default_1", "maps/map_default_1.json");
        ...
    }

まずはこのようにtilemapTiledJSON()を使用し、Tiledでエクスポートしたタイルマップをロードします。

そしてゲームシーンのcreate()にてこのマップをゲームにて再現していきます。

    initMap() {
        // マップデータ読み込み
        const mapData = this.make.tilemap({ key: "map_default_1" });
        // タイルセットの読み込み
        const tileset = mapData.addTilesetImage("tileset_1", "tileset_1");

        // 地形レイヤーを作成 (JSONのレイヤー名 "Ground")
        const groundLayer = mapData.createLayer("Ground", tileset, 0, 0);
        this.uiCamera.ignore([groundLayer]);

        // (オプション) 衝突判定
        groundLayer.setCollisionByExclusion([-1]);
        this.physics.add.collider(this.player, groundLayer);

        // アイテムレイヤーを作成 (JSONのレイヤー名 "Items")
        const itemsLayer = mapData.getObjectLayer("Items");
        // アイテムレイヤーからアイテムを生成
        itemsLayer.objects.forEach((item) => {
            if (item.name === "coin") {
                // コインの生成
                this.addCoin(item.x, item.y);
            } else if (item.name === "flag") {
                // ゴールフラグの生成
                const flag = this.physics.add.sprite(
                    item.x,
                    item.y,
                    ASSETS.spritesheet.flag.key
                );
                this.uiCamera.ignore([flag]);
                flag.anims.play(ANIMATION.flag.key, true);
                flag.body.setAllowGravity(false);
                // プレイヤーとフラグの衝突判定
                this.physics.add.overlap(
                    this.player,
                    flag,
                    () => {
                        // フラグ消去
                        flag.destroy();
                        // ゲームクリア処理
                        this.GameClear();
                    },
                    null,
                    this
                );
            }
        });

        // 背景レイヤー
        const tileset_bg_1 = mapData.addTilesetImage(
            "background_1",
            "background_1"
        );
        const bgLayer = mapData.createLayer("Background_1", tileset_bg_1, 0, 0);
        bgLayer.setDepth(-100);
        this.uiCamera.ignore([bgLayer]);

        // マップの幅と高さを取得
        const mapWidth = mapData.widthInPixels;
        const mapHeight = mapData.heightInPixels;
        // ワールドの境界をマップのサイズに設定
        this.physics.world.setBounds(0, 0, mapWidth, mapHeight);
        this.cameras.main.setBounds(0, 0, mapWidth, mapHeight);
    }

順番に解説していきます。まずはマップデータとタイルセット(マップを使用するために使った、床などの素材集)をロードします。

        // マップデータ読み込み
        const mapData = this.make.tilemap({ key: "map_default_1" });
        // タイルセットの読み込み
        const tileset = mapData.addTilesetImage("tileset_1", "tileset_1");

次に各レイヤーを再現していきます。背景レイヤー、地形レイヤー、アイテムレイヤーなどがあるので必要な分だけロードします。レイヤーを作成するときは(マップデータ).createLayer()を使うか、オブジェクトレイヤーであれば(マップデータ).getObjectLayer()を使用します。

地形レイヤーであれば衝突判定が必要となる(ないと床や壁のすり抜けが発生してしまう)ので、衝突判定も実装していきます。

        // 地形レイヤーを作成 (JSONのレイヤー名 "Ground")
        const groundLayer = mapData.createLayer("Ground", tileset, 0, 0);
        this.uiCamera.ignore([groundLayer]);

        // (オプション) 衝突判定
        groundLayer.setCollisionByExclusion([-1]);
        this.physics.add.collider(this.player, groundLayer);

アイテムレイヤーの場合は、アイテムレイヤーを取得したあと、生成するアイテムごとに配置時の処理を定義しておきます。今回はゴールのためのアイテムとしてゲームフラグを生成していますが、こちらは重力を無視するのでsetAllowGravity(false)で重力無視の設定をしたり、overlap()で衝突時の判定を書いておきます。

        // アイテムレイヤーを作成 (JSONのレイヤー名 "Items")
        const itemsLayer = mapData.getObjectLayer("Items");
        // アイテムレイヤーからアイテムを生成
        itemsLayer.objects.forEach((item) => {
            if (item.name === "coin") {
                // コインの生成
                ...
            } else if (item.name === "flag") {
                // ゴールフラグの生成
                const flag = this.physics.add.sprite(
                    item.x,
                    item.y,
                    ASSETS.spritesheet.flag.key
                );
                this.uiCamera.ignore([flag]);
                flag.anims.play(ANIMATION.flag.key, true);
                flag.body.setAllowGravity(false);
                // プレイヤーとフラグの衝突判定
                this.physics.add.overlap(
                    this.player,
                    flag,
                    () => {
                        // フラグ消去
                        flag.destroy();
                        // ゲームクリア処理
                        this.GameClear();
                    },
                    null,
                    this
                );
            }
        });

最後に背景レイヤーを設定し、マップの幅と高さの設定を行います。この辺はおまじないだと思っておいていいと思います。これを設定することで追従カメラがマップよりも外側を移さなくなるので、必須となります。

        // 背景レイヤー
        const tileset_bg_1 = mapData.addTilesetImage(
            "background_1",
            "background_1"
        );
        const bgLayer = mapData.createLayer("Background_1", tileset_bg_1, 0, 0);
        bgLayer.setDepth(-100);
        this.uiCamera.ignore([bgLayer]);

        // マップの幅と高さを取得
        const mapWidth = mapData.widthInPixels;
        const mapHeight = mapData.heightInPixels;
        // ワールドの境界をマップのサイズに設定
        this.physics.world.setBounds(0, 0, mapWidth, mapHeight);
        this.cameras.main.setBounds(0, 0, mapWidth, mapHeight);

これにてTiledで作成したマップが使用できるようになりました。正直私も今回が初めてなのですが、かなり便利に感じました。

次の報告に向けて

次の報告までには以下の項目に取り組む予定です。また、プレイヤーの強化に関しても次回まとめて説明していきます。

  • ゴール後の選択肢の検討

では、次回の進捗にてまた会いましょう。それでは。