むりこのーと

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

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

どうも。

今回も開発ログを書いていきます。前回の記事はコチラです。

www.murinote.com

それではどうぞ。

開発環境

現在の進捗

各効果音の追加

今回、ゲームの爽快感を演出するために、効果音を各所に追加しました。 コイン取得時、ジャンプ時、木箱破壊時、ゴール時など色々な場所に追加しております。

効果音やBGMはフリー音源サイトさんから拝借いたしました。

速度超上昇時の地形すり抜けバグ

プレイヤーを強化し続けると、速度があまりにも大きくなりあらゆる地形を無視して進みます。明らかなバグで、正直放置でも良かったと思っています。 プレイヤーに有利でゲームを根本から破壊するようなものでないバグは取り除くべきではないと思っているので。

アプローチ1 対症療法

ただ、一応対策はしておこうかと思い、まず本事象に対して以下の対策を講じました。

    1. コイン取得時の加速倍率を下げる
    1. 速度を下げる代わりに他の能力を上昇させる特殊能力を追加

二つとも言ってしまえば対症療法ですが、何もしないよりはマシかと思ったので実装しておきました。とりあえず速度を下げる代わりにコインでのスコア倍率を高めるようにしておきました。

アプローチ2 原因療法

すり抜けの事象自体に対する処置ですが、正直調査に時間をかけましたがわかったことが「1フレームあたりの移動速度が大きいため=速いため」となってしまいました。

冗談はさておきPhaserで用いている物理エンジンの中の話になるのでちんぷんかんぷんな方もいるかもしれませんが、一応対処法を紹介しておきます。

物理エンジン側による、「衝突対象の床(もしくは壁)から外へのプレイヤーの押し出し」の強さが足りないことがおそらく原因だったので、この値を少し大きくしました。

こうすることで、あまりに大きい速度(一フレームで床や壁の幅以上に進んでしまうような速度)ではない場合は一旦すり抜けは発生しなくなりました。

その他細かい調整

ゲームをリッチに見せるため、タイトル画面を追加したり、スマホでの操作に対応するように修正しました。

この辺はまだまだ調整のしがいがありそうですが、一旦はこの状態で最後のプレリリースをしても良いかなと思っています。

今後の構想

スマホ画面での操作ボタンの挙動(複数タッチが微妙)、ハイスコア機能、チュートリアル機能を追加しようと思います。

これで一通りやりたいことが実装できそうなので、リリースに向けて引き続き頑張っていきます。

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

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

どうも。

今回も開発ログを書いていきます。前回の記事はコチラです。

www.murinote.com

それではどうぞ。

開発環境

現在の進捗

プロトタイプ版のリリース

現在、プロトタイプ版としてitch.ioにて公開をしております。 プロトタイプ版はこちら

ゲームスタート、ステージを走り抜ける、ゴールした後にプレイヤーの強化、再スタート、を繰り返していくことになります。基本的には今までと変わりありません。

パワーパラメータの活用

プレイヤーのパラメータのうち、パワーを使用できていなかったので、これを活用するために木箱ブロックを実装しました。

このブロックは通常ブロック2×2個分の大きさであり、プレイヤーのパワーを消費することで、壊しつつ進み続けることができます。パワーがない場合は通常のブロックのように振る舞います(ぶつかって止まります)。

コードの話になりますが、木箱はプレイヤーの進行方向に、回転しながら吹き飛ぶようにしています。壊すとはいいつつ、押しのけ吹き飛ばす、といった挙動をします。

    handlePlayerBoxCollision(player, box) {
            ...
            // 箱を放物線上に回転させながら押し飛ばす
            // フェードアウトもさせる
            this.tweens.add({
                targets: brokenBox,
                alpha: 0,
                angle: 360 * 0.75,
                x:
                    brokenBox.x +
                    (player.facingRight ? 1 : -1) *
                        player.body.maxVelocity.x *
                        (1000 / 1000),
                duration: 1000,
                onComplete: () => {
                    brokenBox.destroy();
                },
                onUpdate: (tween, target) => {
                    // Y軸の放物線運動計算
                    const gravity = GAME_CONST.GRAVITY_Y;
                    const time = tween.progress * (tween.duration / 1000); // 秒単位
                    // 更新後のy座標計算
                    // y = y0 + v0*t + 0.5*g*t^2
                    const height =
                        target.startVY * time + 0.5 * gravity * time * time;
                    target.y = target.startY + height;
                },
            });
        ...
        }
    }

なぜこのような仕様にしたのかというと、今回の実装よりも木箱がばらばらになるエフェクトを作るコストが高そうだったからです。まあ言ってしまえば面倒だからです。

まあ後関係ないことを言うと、ゲームを作りたい人はぜひ数学と物理を学生のうちに真面目に学んでおくことをお勧めします。今回でいうと箱が放物線上に吹っ飛ぶ様子を再現するために落下の法則を使いましたので。

UIの改善

フォントの変更・基本情報の表示方法変更

以前まではデフォルトの何の変哲もないフォントを使用していましたが、スタイリッシュでカッコいいフォントを使用するように変更しました。 また、右側に表示しているパラメータの表示も変えました。特にプレイヤーの能力を、基本能力と特殊能力に分けて表示するように変更しました。

これによってプレイヤーがパッと見たときでもある程度すぐに理解できるようになったかと思います。

強化選択肢の追加

しれっと大きい改修になりましたが、ゴール後に選択肢を選ぶことで特殊能力を獲得できるようにしました。 現在実装しているのは、コインを取得することで速度を上げる能力と、空中で追加でジャンプできるようになる能力です。特に空中ジャンプをたくさん獲得すると、ジャンプするだけでゴールできるようになります。友人曰く「ずるしている感が素晴らしい」とのことです。

このへんはゲーム開発全般的な話かと思うのですが、ゲームを作る際は「現実では得られない(得にくい)体験」を入れ込むことで、楽しんでもらえる確率が高まると思っています。現実(仕事や勉強)ではズルってなかなかできないものですし、よしんばできたとしても罰が待っていたり、まあつけが回ってくるものですよね。それを何のペナルティもなしに体験できるのですから、これがしっかりできているゲームは長く楽しんでもらえるというわけです。

今後の構想

今後はゲームに効果音や演出をつけたり、簡単なチュートリアル系を導入するなどを進めていこうかと思います。 また、スマートフォンd芽生本格的に操作できるようにしていきたいと思います。

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

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

どうも。前回に続き、開発中の横スクロールアクションゲームの開発ログを書いていきます。前回の記事はこちらです。↓

www.murinote.com

開発環境

現在の進捗

前回との比較

前回は、UIの改修と、ゴール後のプレイヤー強化に関する改修を行いました。

今週は諸事情によりあまり開発自体を進められませんでした。 一応今後の実装方針について考えているものを書いていきます。

パワーパラメータの活用

前回の実装で、ゴール時に「スピードアップ」または「パワーアップ」を選択できるようにしました。スピードアップはそのままの意味なのですが、現状パワーのパラメータは活用できていません。

現時点では以下のような方針をとろうと考えています。

  • パターン1 特定のブロックについて、パワーを一定数消費することで破壊できる(再スタート時に回復)
  • パターン2 特定のブロックについて、パワーが一定数以上であれば消費なしで破壊できる

パターン1ではプレイヤーがパワーアップを選択肢としてより長く選び続ける動機ができます。 パターン2ではプレイヤーはパワーを一定数取ってしまえばあとは選択肢として無視することができます。

最終的にどちらのパターンにするかはじっくり考えようと思いますが、一旦パターン1で試してみようかと思います。

取得コインの活用

現在コインを取得しても何ら影響はありませんが、今後以下のような方針を考えています。

  • 一定数コインを消費することで選択できる、通常よりもレアな選択肢を実装する(ライフ保証、スピード上昇×複数、など)
  • 別フィールドや別プレイヤーの解放など

後者はかなり時間と労力がかかりそうなので、まずはコイン消費が必要な選択肢の実装を考えたいと思います。

UI,UXの調整

見た目や演出の調整も大事なので、もちろんやっていきたいです。特にスコア欄やタイム欄のフォントや見た目、その意味なども考えていきたいです。

特にプレイをし続けてプレイヤー側が「気持ちいい、楽しい」と感じるようなものを作ろうと思っていますので。(このゲームのコンセプトでもあります)

次の報告に向けて

言わずもがな、次の報告までには先述した項目に取り組む予定です。

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

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

どうも。前回に続き、開発中の横スクロールアクションゲームの開発ログを書いていきます。前回の記事はこちらです。↓

www.murinote.com

開発環境

現在の進捗

前回との比較

前回はマップの生成やカメラの設定を行いました。いわばゲームの基礎部分です。

今回はゴール後のプレイヤー強化に関する改修を行いました。

ゲーム情報表示UIの作成

まずゲーム画面右側にゲームに関する情報の表示用エリアを設けました。今はスコア(取得したコイン数)、経過時間、プレイヤーのパラメータ(詳細は後述)を表示しています。

このエリアはPhaserのコンテナという機能を使っています。これは複数のオブジェクトを一つの座標系で管理するというものです。文字通りコンテナを想像していただけると理解に難くないかと思います。

まずPhaser.GameObjects.Containerを継承したUI用のクラスを作成します。これがPhaserにおけるコンテナ機能にあたります。

export class GameInfoUI extends Phaser.GameObjects.Container {
    /**
     *
     * @param {Phaser.Scene} scene シーン
     */
    constructor(scene) {
        super(
            scene,
            COMMON_CONST.SCREEN_WIDTH - UI_CONST.GAME_INFO_UI_WIDTH,
            0
        );
        this.scene = scene;

        // UIの背景
        const background = scene.add.rectangle(
            UI_CONST.GAME_INFO_UI_WIDTH / 2,
            COMMON_CONST.SCREEN_HEIGHT / 2,
            UI_CONST.GAME_INFO_UI_WIDTH,
            COMMON_CONST.SCREEN_HEIGHT,
            UI_CONST.CHOICE_OF_POWERUP_UI_BACKGROUND_COLOR,
            1
        );
        this.add(background);

        // コンテナをシーンに追加
        scene.add.existing(this);

        let yOffset = 15;

        // スコアテキスト
        this.scoreText = this.scene.add.text(15, yOffset, "Score: 0", {
            fontFamily: "Arial Black",
            fontSize: 28,
            color: "#ffffff",
            stroke: "#000000",
            strokeThickness: 8,
            align: "center",
        });
        this.add(this.scoreText);
        yOffset += 40;

        // タイマー
        this.timeText = this.scene.add.text(
            15,
            yOffset,
            `${GAME_CONST.FORMAT_TIMER_PRE}0${GAME_CONST.FORMAT_TIMER_POST}`,
            {
                fontFamily: "Arial Black",
                fontSize: 28,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
                align: "center",
            }
        );
        this.add(this.timeText);
        yOffset += 100;
    }
    ...
}

コンテナにまず黒の長方形を描画し、この上に要素を生成していくイメージとなります。ここで注意したいのは、コンテナのクラス生成時に指定したx座標、y座標が、コンテナ内での基準の座標となるということです。

つまりどういうことかというと、例えばコンテナの座標を(100, 50)で生成し、そのコンテナ内の左上に要素を生成したい場合は要素の座標を(0, 0)として生成し(コンテナ).add()でコンテナに追加するのですが、実際のゲーム画面上では(0, 0)の位置ではなく、(100, 50)に表示されます。コンテナに追加する要素の座標は、コンテナ上での相対位置を表しているというわけです。

少し難しいかもしれませんが、結果から言うと要素の実際の座標は、生成時に設定した座標+コンテナの座標、と考えればいいです。

パラメータと強化用選択肢の作成

前回の続きとなりますが、プレイヤーがゴールしたあとに強化用の選択肢を出現させるようにしました。現在実装できているのは「スピードアップ」「パワーアップ」の2種類となっています。

スピードアップを選択するとプレイヤーの最大速度が上昇し、より速く動けるようになります。パワーアップを選択した場合、プレイヤーのパワーが上昇します。これはまだ実装途中ですが、破壊可能なブロックを破壊することができるようになります。

実装方法としては先ほどと同じコンテナを使用しています。この選択肢の一つ一つがコンテナとなっており、ゴール時に表示されるようになっています。

        ...
        // パワーアップの種類をランダム選択する
        const powerUpTypes = [
            POWERUP_TYPE.SPEED,
            POWERUP_TYPE.SPEED,
            POWERUP_TYPE.POWER,
        ];
        for (const [index, type] of powerUpTypes.entries()) {
            console.log("PowerUpType:", type);
            // パワーアップの選択肢を表示する
            const choiceUI = new ChoiceOfPowerUpUI(
                this,
                this.screenCenterX,
                200 + index * 120,
                type
            );
            choiceUI.setScrollFactor(0);
            choiceUI.setDepth(200);
            this.uiLayer.add(choiceUI);
        }
        ...

この選択肢のクラスにて、クリックしたときの処理を紹介します。

export class ChoiceOfPowerUpUI extends Phaser.GameObjects.Container {
    /**
     *
     * @param {Phaser.Scene} scene シーン
     * @param {number} x x座標
     * @param {number} y y座標
     * @param {string} powerUpType パワーアップのタイプ
     */
    constructor(scene, x, y, powerUpType) {
        super(scene, x, y);
        this.scene = scene;
        this.powerUpType = powerUpType;

        // UIの背景
        const background = scene.add.rectangle(
            0,
            0,
            UI_CONST.CHOICE_OF_POWERUP_UI_WIDTH,
            UI_CONST.CHOICE_OF_POWERUP_UI_HEIGHT,
            UI_CONST.CHOICE_OF_POWERUP_UI_BACKGROUND_COLOR,
            0.5
        );
        this.add(background);

        // クリック判定用の矩形
        background.setInteractive(
            new Phaser.Geom.Rectangle(
                0,
                0,
                UI_CONST.CHOICE_OF_POWERUP_UI_WIDTH,
                UI_CONST.CHOICE_OF_POWERUP_UI_HEIGHT
            ),
            Phaser.Geom.Rectangle.Contains
        );

        ...

        // クリック時の処理
        background.on("pointerdown", () => {
            // パワーアップが選択されたことをシーンに通知
            this.scene.events.emit("powerupSelected", this.powerUpType);
        });
    }

ソースコードの最後のon(...)で、この選択肢がクリックされたときにシーンに対してイベントが送信されることを示しています。そしてシーン側ではイベントを受け取ったときの処理を記載します。

    /**
     * イベントリスナー設定
     */
    initEventListeners() {
        // パワーアップ選択UIのイベントリスナー
        this.events.on("powerupSelected", this.handlePowerupSelected, this);
        // シーン終了時にリスナー解除
        this.events.once("shutdown", this.cleanUpEventListeners, this);
    }

this.events.on(...)でイベントを受け取ったときに実行する処理を書いています。handlePowerupSelectedを今回は実行します。これはプレイヤーのパラメータ強化の関数なので今回は詳細説明を割愛します。

次の報告に向けて

次の報告までには以下の項目に取り組む予定です。

  • パワーパラメータを使用したブロック破壊などの処理

現在プレイヤーのパラメータのうちパワーが使われていないので、これを使ってギミックを作ろうかと思っています。

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

【ゲーム開発】気持ちいい横スクロールアクションを作る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で作成したマップが使用できるようになりました。正直私も今回が初めてなのですが、かなり便利に感じました。

次の報告に向けて

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

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

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

【Phaser入門】見下ろし2Dレトロアクションゲームを作るPart4 キャラ動作編

どうも。

前回はキャラクターの画像を読み込み、表示するところまでを実装しました。まだ見ていない方はこちらもご覧ください。

www.murinote.com

今回はキャラクターを動かしたり、他のスプライトとのアクションを実装していきます。

目次

今回の記事は以下の内容になっています。

それでは見ていきましょう!

1. キャラクターを動かす

早速、表示したキャラクターを動かしてみましょう。前回まででスライムのスプライトを作成したので、今回はこのスライムをカーソルキーで動かせるように設定していきます。

実際にゲームを作るときは、ゲームがどのように遊ばれるのかを考えて実装する必要があります。例えばスマホゲームなら縦向き横向きで画面のサイズを変える必要があったりします。

が、今回はPCゲームであるとしましょう。理由は実装が楽だからです。

キー操作

ではまず、キー操作をすることをゲーム内に書きましょう。「私はキー入力を使うので、キー入力を受け付けてください」とPhaser側に申告するイメージです。

    create() {
        ...
        ...
        // カーソルキー押下を有効にする
        this.cursors = this.input.keyboard.createCursorKeys();
    }

create()の最後に、input.keyboard.createCursorKeys();を記載します。名前からわかる通り、これはカーソルキーを使えるようにする関数です。カーソルキーに特化したものなので、別のキーを使う場合は別の関数を使う必要がありますが、今は割愛します。

では実際にキー入力を使ったスライム移動のコードを見ていきましょう。

    update() {
        // 下キー押下時
        if (this.cursors.down.isDown) {
            // 下向きのアニメーションを再生
            this.slime.anims.play("moveDown", true);
            // スライムを下に移動
            this.slime.y += 3;
        }
        // 右キー押下時
        else if (this.cursors.right.isDown) {
            // 右向きのアニメーションを再生
            this.slime.anims.play("moveRight", true);
            // スライムを右に移動
            this.slime.x += 3;
        }
        // 上キー押下時
        else if (this.cursors.up.isDown) {
            // 上向きのアニメーションを再生
            this.slime.anims.play("moveUp", true);
            // スライムを上に移動
            this.slime.y -= 3;
        }
        // 左キー押下時
        else if (this.cursors.left.isDown) {
            // 左向きのアニメーションを再生
            this.slime.anims.play("moveLeft", true);
            // スライムを左に移動
            this.slime.x -= 3;
        } else {
            // いずれのキーも押されていない場合はアニメーションを停止
            this.slime.anims.stop();
        }
    }

ここでpreload()create()に次ぐ新たな関数update()が登場しました。これはcreate()が終わった後、つまりゲームシーン開始の準備が整ったら、毎フレーム実施される関数です。ここに、それぞれのキーが押されたときにどのような処理を行うのかを書いています。

まずthis.cursors.down.isDownのように記載をしています。これはdownキー(下キー)が押されている間、trueになります。カーソルキーの気持ちになってみるとわかると思いますが、キーが押されている=沈んでいる=isDownですね。逆にキーが離されているときはisUptrueになっています。

次に移動方向に対応するアニメーションの再生をします。ここは前回使ったanims.play()を使います。以前と違い、アニメーション名のあとにtrueを引数で指定していますが、これは説明すると難しくなるので一旦無視でいいです。そういうものだと思っておいてください。

最後に、slime.xなどをいじって移動方向にスプライトの座標を移動させます。

では実際にゲーム再生して確認してみましょう。

スライム君が上下左右にふよふよ動いていることがわかりますね。

2. 他のスプライトとの衝突

では次にこのスライム君がアイテムを獲得できるように衝突判定を実装していきます。今回取得するアイテムは以下のクリスタルです。大きさは48ピクセル×48ピクセルです。

アイテムの作成

ではこれをassetsフォルダに保存し、実際に表示してみましょう。

    preload() {

        ...
        ...

        // 画像を読み込む
        this.load.image("crystal", "assets/crystal.png");
    }

まずpreload()内で画像を使えるようにします。今回はスプライトシートではないので、this.add.image()を使います。

次にcreate()内で実際にクリスタルを表示しますが、今回はグループという機能を使います。Phaserにおいてグループというのは複数のスプライトをまとめるための構造のことです。まずはコードを見てみましょう。

    create() {
        ...
        ...

        // クリスタルのグループを作成する
        this.crystals = this.add.group();

        // クリスタルを一つ作成
        this.crystals.create(100, 100, "crystal");
    }

まずthis.add.group()にてグループを作成します。次に(グループ).create()で実際にそのグループに対してスプライトを作成します。今回は座標が(100, 100)の位置に作成してみます。

ではゲームを起動して確認してみましょう。

クリスタルが表示されていれば成功です。

衝突判定

では最後に衝突判定を実装していきます。

        // スライムのスプライトを作る
        this.slime = this.physics.add.sprite(
            this.scale.width / 2,
            this.scale.height / 2,
            "slime"
        );
        ...
        ...

        // クリスタルのグループを作成する
        this.crystals = this.physics.add.group();
        ...

スライムやグループの作成時に使用していたthis.addを、this.physics.addに変更しました。こうすることで、スプライトが物理ボディを持つようになります。物理ボディを持つと通常のスプライトよりもできることが増えます。まずはそれくらいの認識でいいと思います。

そして次に衝突判定を実装していきます。

        // スライムとクリスタルの衝突判定
        this.physics.add.existing(this.slime);
        this.physics.add.existing(this.crystals);
        this.physics.add.overlap(
            this.slime,
            this.crystals,
            (slime, crystal) => {
                // クリスタルに触れたときの処理
                crystal.destroy(); // クリスタルを消す
            },
            null,
            this
        );

this.physics.add.existing()は物理演算を有効化させるためのおまじないだと思っていて大丈夫です。そしてthis.physics.add.overlap()で、スプライト同士が重なったときの処理を定義していきます。

今回は重なったときにクリスタルを削除するようにします。細かい引数はそこまで気にしなくていいですが、衝突判定をしたい対象を二つ指定し、衝突した時の処理内容を書きます。今回はクリスタルを削除するので、(スプライト).destroy()を使用します。

最後にmain.jsのconfigに以下の記述を追加しましょう。

    // 物理演算の設定
    physics: {
        default: "arcade",
        arcade: {
            debug: false,
            gravity: { y: 0 },
        },
    },

これを追加することで前述の物理演算系がすべて有効化されます。では本当に削除されるか試してみましょう。

クリスタルとスライムの衝突判定ができました。まるで取得したように見えますね。

最後に

いったん基本的なところは実装できたとおもいます。ここまでできれば皆さんは独力でも(調べたりしながら)ゲームを作れるようになっているかと思います。

とはいえまだまだゲームには程遠いので、いつになるかはわかりませんが、引き続き応用講座的なものを投稿していきます!それでは!

【Phaser入門】見下ろし2Dレトロアクションゲームを作るPart3 キャラ表示編

どうも。

前回はPhaserでシーンの作り方について解説しました。まだ見ていない方はこちらもご覧ください。

www.murinote.com

今回はPhaserでキャラクターなどを配置し、動かす方法を説明していきます!

今回の記事は以下の内容になっています。

では早速見ていきましょう!

1. ゲームシーンの作成

前回はゲームを起動したらタイトルシーンが始まるところまでを実装しました。ただこれだとタイトルを表示しただけなので当然ゲームをするも何もありません。

そこでタイトル画面の次に表示される、いわゆるゲームシーンを作成し、タイトルシーンから遷移するように設定していきます。

ゲームシーンの定義

前回と同じように、まずはゲームシーンのソースコードを作りましょう。名前はGameSceneとしておきましょう。

タイトルシーンの場合は画面の中央に文字列を表示するだけでしたが、一旦ゲームシーンでもまずは文字列表示されるように実装してみましょう。ガワだけ作っておくというのもゲーム開発においては重要な工程になります。

/* GameScene.js */
export class GameScene extends Phaser.Scene {
    constructor() {
        super('GameScene');
    }

    init() {
        // Initialize scene
    }

    preload() {
        // Load assets
    }

    create() {
        this.cameras.main.setBackgroundColor("#404040");
        this.textTitle = this.add.text(
            this.scale.width / 2,
            this.scale.height / 2,
            "This is GameScene !",
            {
                fontFamily: "Arial Brack",
                fontSize: 42,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
            }
        );
        this.textTitle.setOrigin(0.5, 0.5);
    }
}

シーンの遷移

次はこのゲームシーンを実際に動かしてみましょう。

まずはこのゲームシーンを使用できるようにするため、タイトルシーンの時と同じようにmain.jsに記載しておきましょう。

// ここでゲームに必要なシーンをインポートする
import { Start } from './scenes/Start.js';
import { TitleScene } from './scenes/TitleScene.js';
// 追加
import { GameScene } from './scenes/GameScene.js';

const config = {
    type: Phaser.AUTO,
    title: 'Overlord Rising',
    description: '',
    parent: 'game-container',
    width: 1280,
    height: 720,
    backgroundColor: '#000000',
    pixelArt: false,
    // ここで使用するシーンを並べる。一番初めに定義したものが最初に起動される
    scene: [
        TitleScene,
        // 追加
        GameScene,
        Start,
    ],
    scale: {
        mode: Phaser.Scale.FIT,
        autoCenter: Phaser.Scale.CENTER_BOTH
    },
}

new Phaser.Game(config);

次に、タイトルシーンからこのシーンに遷移するための設定をします。タイトルシーンのテキストをクリックしたらゲームシーンに遷移するようにしてみましょう。

    create() {
        // 背景色を灰色にセット
        this.cameras.main.setBackgroundColor("#808080");
        // タイトルの文字列を画面中央に表示
        this.textTitle = this.add.text(
            this.scale.width / 2,
            this.scale.height / 2,
            "Game Start",
            {
                fontFamily: "Arial Brack",
                fontSize: 42,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
            }
        );
        // 指定した座標を、その要素のどの部分を起点にするか決める。
        // 左上を起点にするなら(0, 0)、中央なら(0.5, 0.5)、右下なら(1, 1)
        this.textTitle.setOrigin(0.5, 0.5);
        // クリックを有効にする
        this.textTitle.setInteractive();
        // クリック時の処理を定義
        this.textTitle.on('pointerdown', () => {
            // シーンを遷移
            this.scene.start('GameScene');
        });
    }

setInteractive()で、そのオブジェクトはマウスのクリックに対して反応することが可能になります。そしてon('pointerdown', () => {})で実際にクリックがされたときの挙動を定義し、最後にthis.scene.start()を行うことで、任意のシーンに遷移することができます。

これでシーン遷移の設定ができました。では実際に動かしてみましょう。

2. キャラの表示

ゲームシーンで文字列を表示することができました。ただ、これではとてもとてもゲームとは言えません。そこでこんなものを用意しました。

スライムが左右上下に動くアニメーション用のスプライトシート(複数の画像を一つにまとめたものを一般的にこう呼びます)sです。サイズは32ピクセル×24ピクセルで、これが4×2で並んでいます。これを実際に表示してみましょう。

キャラを表示する

まずはこれをダウンロードし、適当な名前でいいですがとりあえずslimes.pngという名前でassetsフォルダに配置してください。

それではゲームシーンのpreload()にて読み込んでみましょう。

    preload() {
        // スプライトシートを読み込み、「slime」という名前で登録しておく
        this.load.spritesheet(
            "slime",
            "assets/slimes.png",
            {
                frameWidth: 32,
                frameHeight: 24,
            }
        );
    }

load.spritesheet()でスプライトシートをゲームに読み込みます。各引数では、読み込んだスプライトシートの名前、実際のスプライトシートのパス、プロパティを指定します。

このコードのframeWidthframeHeightでは、それぞれフレームあたりの幅と高さを指定しています。今回は幅と高さが32pxと24pxでしたのでこれを指定します。

読み込めたら実際にキャラを表示できるよう、スライムのスプライトを生成してみましょう。スプライトは、実体のある、画面上に表示されるもの(キャラやアイテムなど)と認識しておいてください。

    create() {
        this.cameras.main.setBackgroundColor("#404040");
        // テキストは邪魔なのでいったんコメントアウト
        /*
        this.textTitle = this.add.text(
            this.scale.width / 2,
            this.scale.height / 2,
            "This is GameScene !",
            {
                fontFamily: "Arial Brack",
                fontSize: 42,
                color: "#ffffff",
                stroke: "#000000",
                strokeThickness: 8,
            }
        );
        this.textTitle.setOrigin(0.5, 0.5);
        */

        // スライムのスプライトを作る
        this.slime = this.add.sprite(this.scale.width / 2, this.scale.height / 2, 'slime');
        // アニメーションを定義しておく
        // 下向き
        this.slime.anims.create({
            key: 'moveDown',
            frames: this.anims.generateFrameNumbers('slime', { start: 0, end: 1 }),
            frameRate: 10,
            repeat: -1
        });
        // 右向き
        this.slime.anims.create({
            key: 'moveRight',
            frames: this.anims.generateFrameNumbers('slime', { start: 2, end: 3 }),
            frameRate: 10,
            repeat: -1
        });
        // 上向き
        this.slime.anims.create({
            key: 'moveUp',
            frames: this.anims.generateFrameNumbers('slime', { start: 4, end: 5 }),
            frameRate: 10,
            repeat: -1
        });
        // 左向き
        this.slime.anims.create({
            key: 'moveLeft',
            frames: this.anims.generateFrameNumbers('slime', { start: 6, end: 7 }),
            frameRate: 10,
            repeat: -1
        });

        // デフォルトで下向きアニメーションを再生
        this.slime.anims.play('moveDown');
    }

キャラを表示するため、以前設定した文字列の表示はコメントアウト、もしくは削除しておいてください。それでは一気に解説していきます。

まずadd.sprite()で位置とそのスプライトに使用するイメージの名前をつけてスプライトを生成します。今回は先ほど読み込んだスライムを使用します。

次にスプライトに対して、アニメーションの設定を行います。せっかく何コマも作ったのに使わないのはもったいないので。

(スプライト).anims.create()で、アニメーションに名前をつけつつアニメーションの定義をします。各引数について解説します。

keyはアニメーションの名前、framesではどのフレームを使用するか指定します(下で詳細解説します)。frameRateは1秒間に何回フレームを切り替えるか、要するにアニメーションの速度です。そしてrepeatで何回繰り返すかを指定します。-1であればループし、3を指定すれば3回再生した時点でアニメーション終了となります。

this.anims.generateFrameNumbers()では、スプライトシートの名前と、フレーム番号の指定をしてアニメーションを生成できます。

フレーム番号はどう識別するかですが、今回のスライムの場合、一番左上が1で、その右が2、3、4となり、折り返しが発生して下の列の一番左が5、その右が6、、、というように番号が振られます。よって一番右下が最後の番号になります。横書きの文章のような進み方になると言えばイメージしやすいでしょうか。

では設定が終わったので早速ゲームを起動して、ゲームシーンを見てみましょう。

スライムが小さくなってしまったのが誤算でしたが、無事ふにふにアニメーションしていることがわかります。

最後に

次回はこのスライムを実際に動かしていきます。いよいよここからゲーム感が強く出てくるようになると思います。それではお楽しみに!