見出し画像

マップを作る(1)(Unityメモ)

設計内容を一部修正しました。シーン遷移については続編の方を参照してください。

シーン・マップ・演出に関する基本的な考え方は同じなので、以前の記事はそのまま残しておきます。



今までの作業の応用編。動作はこんな感じ

マップシーン上の演出の実行と、戦闘シーンへの遷移

マップシーンの扱い

戦闘要素があるゲームでは、プレイヤーが移動するシーンもだいたい存在します。その名前はマップ、ステージ、フィールドなど様々ですが、ここでは移動範囲の単位となる要素をマップと呼びます。
アクション、シューティング、シミュレーションなど多くのゲームでは、マップと戦闘が同じシーン上で処理されます。アクションRPGやシミュレーションRPGでも同様です。
一方、正統的なRPGではマップシーンと戦闘シーンが分かれています。戦闘突入でマップシーンから戦闘シーンへ遷移し、戦闘終了で戦闘シーンからマップシーンに戻ります。
本ゲームでも同様に、マップシーンと戦闘シーンを分けます。プレイヤーやユニットの「ホーム」となるシーンも加えて、本ゲームで扱う主なシーンは3種類となります。

シーン間遷移

本ゲームでのシーン間の遷移は左上図のようになります。ホームをゲームの起点として、「探索」によりホームからマップに出発します。マップ内で戦闘が発生すると戦闘シーンに移行します。戦闘が道中戦(通常戦闘)の場合、戦闘後はマップに戻ります。戦闘がボス戦の場合、戦闘後はマップを経由せずホームに帰投します。
ゲーム内ではシーン1個だけアクティブで、残りのシーンは処理を停止させます。これらのシーンを切り替えるためのクラスをSceneSwitcherと名付けます。SceneSwitcherは状態遷移によりシーンの切り替えと制御を行います。
各シーンは基本的に処理が独立していて、変数等の情報も別に扱います。そのため、シーンはサービスの一種として実装します。一方で、シーン遷移する場合はシーン間で情報を受け渡す必要があります。この仕組みはサービス間の情報伝達により実現します。本ゲームシステムではHubヤードでの処理になります。

シーン間の状態遷移。「所要育成量」は本ゲーム特有の戦闘要素

マップシーン内の状態遷移

シーン間遷移とは別に、シーン内部でも状態遷移が必要です。というのも、シーン間遷移に伴ってシーンの表示・非表示などの状態が変化するためです。特にマップシーンの場合、戦闘からマップに戻る際に、戦闘直前の状態を保持しておく必要があります。
マップシーン内の状態遷移は下図のようになります。帰投する遷移が2種類あるのが特徴です。「帰投中」はマップからホームへ帰投する場合、「戦闘後帰投」はボス戦の後に戦闘シーンからホームへ直接帰投する場合に相当します。道中戦とボス戦の区別があるので、マップ内の状態遷移でも区別しておくと便利です。実際にSceneSwitcher側ではマップシーン側の状態を見て道中戦とボス戦を区別し、戦闘後の切り替え先シーンを決めます。
注意点として、戦闘シーンからマップシーンに戻る場合は、マップシーンを外部(SceneSwitcher)から強制的に状態遷移させます。戦闘シーンに切り替えている間、マップシーンは処理停止しているためです。

マップシーン内の状態遷移

マップデータの設計

一般的に、マップシーンではプレイヤー(人間のユーザ)が操作するプレイヤーキャラクタの移動に応じてメッセージ表示、アイテム入手など、様々なイベントが起こります。
本ゲームでは自動周回を考えているため、プレイヤーキャラクタの移動操作はないものの、自動処理やプレイヤーのクリックに応じてイベントが発生します。

演出とスポット

先に用語を整理します。いわゆるマップイベントは設計上のイベント駆動と混同しやすいので、別の用語を使うことにします。
演出:メッセージ表示、アイテム入手など、ゲームの内容としてユーザに提示するもの。マップイベントの内容に相当。本ゲームでは戦闘突入も演出に含める
スポット:演出をまとめたもので、マップ上の一地点に表示される。マップイベントに相当

マップのマスタデータ

これらの概念を踏まえたうえで、マップデータを設計します。依存関係の方向に気を付けて、クラスの参照関係とヤードへの所属を決めます。マップのマスタデータではViewヤード側に演出オブジェクトのインタフェースを持たせ、Contentヤードで実装します。これは、演出がViewの要素であり、Domainであるゲームルールとは直接関係しないためです。
またUnityの事情として、マップデータの作成はExcelやDBツールを使うよりも、Unityエディタ上でゲーム画面を見ながら作成するほうが作業しやすいです。そのため、マップデータはUnityのプレハブとして作成します。スポットや演出のデータはUnityのコンポーネントとして実装します。このとき、GameObjectの物理的な階層構造とコンポーネントの論理的な階層構造を分けて考えると、設計を整理しやすくなります。

マップデータのクラス構造

マップのプレハブが持つコンポーネントは以下の通り。これがマスタデータになります。
SpotAccessor:スポット1個のデータを保持。スポット単位のゲームデータや、演出オブジェクト実装への参照を持つ
MapAccessor:マップのデータを保持。マップ単位のゲームデータやマップ変数、SpotAccessorへの参照を持つ

本ゲームシステムの設計では、GameObjectにあるコンポーネントにアクセス(参照をショートカット)するための窓口となるクラスをAccessorと名付けるという規約を設けています。上記のコンポーネントも窓口として機能するため、Accessorという名前を付けています。またSpotAccessorはエディタ拡張のSubclassSelectorを使用することで、演出オブジェクト実装を直接指定できます。スキル等のマスタデータの設計と同様です。

なおヤード(保守責任の分掌)の概念は、ライブラリの階層とは独立です。GUIフレームワークに依存するクラスであっても、ViewヤードではなくContentヤードやHubヤードに属する場合もあります。

マップサービス

マップデータと共に、それを処理するサービスも設計します。サービスはHubヤードに所属します。振る舞いとデータの依存関係にも気を付けてクラス構造を決めます。
主要なクラスは以下の通りです。
Env:Environmentの略で、ゲームルールで用いる変数値を保持。セーブの対象。Domainヤードに所属
Behaviour:ユーザへの情報提示・イベント駆動を扱う。MonoBehaviourの派生クラス。Viewヤードに所属
SceneService:異なるヤード間やサービス間の情報伝達を扱う。Hubヤードに所属

その他、ゲームルールの実装、マップデータをロードするDBサービス、Unity寄りの処理を行うSceneDirectorを、マップシーンは参照します。

マップを扱うサービスのクラス構造。四角で囲んだクラスがマップ内の処理に直接関与する

演出オブジェクトの基本設計

ユーザに提示する演出については、以下の設計が必要です。
・演出の処理の実装方法
    ・演出内容の洗い出し
    ・演出の処理内容の記述方法と実行方法
・スポットが持つ演出の実行・制御方法

演出の処理の実装方法については、演出の内容をまずオブジェクト単位に分割し、オブジェクトごとに実装を組むことにします。内部命令を定義する感じです。ただし複雑な演出についてはオブジェクトの組み合わせではなくコードを直に記述できる方が融通が効きそうです。
そのため、演出インタフェースを定義しておき、スポットにはその実装のオブジェクトのリストを持たせることにします。単純な演出については組み込みでインタフェースの実装クラスを記述しておきます。オブジェクトを組み合わせれば、ある程度数の多い演出も実行できます。複雑な演出についてはその都度1個のインタフェース実装としてコードを記述するようにします。
演出オブジェクトのリストについては、Unityだとインスペクタでパラメータを表示・設定できます。(これもUnityエディタ上で作業をしたい理由)

右側の"Direction List"で演出を設定。某ツクールの劣化版

実行順とイベント駆動

演出オブジェクトの実行方法については、現時点では図のように組んでいますが、今後変更するかもしれません。いまいち自由度が高くない気がしています。
ポイントとしては2点あります。いずれもプログラミング言語の設計に近いです。
・クリック待ちなどの非同期処理と、変数操作などの同期処理を両立
・演出オブジェクト同士の参照と、演出実行順の制御構造の設計

演出オブジェクトの実行方法の大枠は、コマンドパターンで実装できます。
非同期処理の扱いは戦闘シーンの実装と同様に、シグナル受信と状態機械の仕組みを用いて実現できます。ユーザ入力を扱う場合はawaitよりもUnityのイベントシステムを使って待機・再開する方がよいです。
ただ非同期処理の演出オブジェクトだけだと待機を伴わない処理を実行できません。そのため、演出オブジェクトのリストを順に実行する、という仕組みも併用します。これにより同期処理を順に実行しつつ、非同期処理の部分で待機に入ります。

演出オブジェクトの実行方法。シグナル待機の部分で内部的に状態遷移する

具体的なコードは以下の通り。シグナル待機するかどうかのフラグを演出オブジェクトに持たせ、演出リストの処理ループの中でreturnとcontinueを使い分けます。returnするとループ終了=シグナルを待機、continueで次の演出を実行、となります。

// 演出関数のインタフェース
[System.Serializable]
public abstract class IDirection
{
    public bool UsingSignal = false;
    public abstract void Exec(MapSceneBehaviour behaviour, SceneDirector director);
}

//マップシーン状態
public enum MapStateID
{
    None = 0,
    Init,
    Play,
    BattleStop,
    HomeStop,
    BattleStopToHome,
}

// マップ内の処理。演出実行処理の部分だけ抜き出した
public class MapSceneBehaviour : SceneBehaviour<MapStateID>
{

    [SerializeField] private SceneDirector Director;

    public MapAccessor MapAccessor { get; private set; }
    public List<SpotAccessor> SpotAccessorList { get; private set; }

    //演出ポインタ
    private int CurrentSpot = 0;
    private int CurrentDirection = 0;

    //次の演出を実行。シグナルのハンドラとしても使用
    public void DoNext()
    {
        SceneState.Do(MapStateID.Play);
    }

    //演出リストを順に実行。Doアクティビティとして呼び出す。引数は現状不使用
    private void Play(MapStateID arg)
    {
        while (CurrentSpot < SpotAccessorList.Count)
        {
            //演出を実行
            Map.IDirection direction
                = SpotAccessorList[CurrentSpot].DirectionList[CurrentDirection];
            direction.Exec(this, Director);

            //ポインタを進める
            AdvancePointer();

            if (direction.UsingSignal == true)
            {
                //シグナルを待機する
                return;
            }
            else
            {
                //次の演出を実行
                continue;
            }
        }
    }

    //ポインタを進める
    private void AdvancePointer()
    {
        CurrentDirection++;

        //現在のスポットの演出を再生し終えたら、次のスポットの先頭に移動
        if (CurrentDirection >= SpotAccessorList[CurrentSpot].DirectionList.Count)
        {
            CurrentSpot++;
            CurrentDirection = 0;
        }
    }
}

演出オブジェクトの実装方針

個々の演出オブジェクトの実装についても考えます。まずは演出内容の洗い出しから行います。以下の要素があれば最低限ゲームの骨組みは作れるでしょう。機能が足りなければ追加すればよいので。(実際まだ足りてない)
・マップ変数の取得、値の書き換え
・Envメンバの取得
・変数値による条件分岐
・シーン遷移(特に戦闘突入)
・メッセージ表示=画像表示+文字列表示+クリック待機
同時に情報伝達の向きとヤードの依存関係の向きを確認し、依存性逆転が必要な部分を確認しておきます。向きが一致しない部分が依存性逆転が必要な部分となります。依存性逆転の具体的な方法は後の詳細設計で決めます。
図に関して、厳密にはシーケンス図を書くのですが、書くのに手間がかかるのと縦方向の長さにあまり意味がないので、スイムレーンっぽい図を書くようにしました。処理の順序と情報の受け渡しが分かれば十分です。

演出オブジェクトの洗い出しと基本設計。赤い点線の矢印が依存性逆転の必要な部分

演出オブジェクトの詳細設計

演出オブジェクトの基本設計を元に、コードに落とせるレベルの詳細設計を書きました。ここで依存性逆転の方法を決めます。

実行方法の大枠

まず、演出オブジェクトの実行方法の大枠をまとめました。
1. MapBehaviourから演出オブジェクトの実行メソッドを呼び出す(コマンドパターン)。その際に自身を演出オブジェクトへ渡す
2. 演出オブジェクトがMapBehaviourや別のヤードに対して処理を行わせたい場合、受け取ったMapBehaviour(参照元)のメソッドを呼び出し、戻り値を得る。この部分が依存性逆転(正確には制御反転)となる

演出の実行方法の大枠。凡例は以下の図でも同様

依存性逆転といえばコールバックですが、演出オブジェクトについては
採用が困難です。というのも、演出オブジェクトからMapBehaviour側に依存しているため、各々の演出オブジェクトにコールバックを定義する必要があり、コード記述が煩雑になります。
そのため、コールバックではない方法で依存性逆転(というよりその目的である制御逆転)を実現します。単に参照先から参照元を呼び出すことで制御反転ができます。MapBehaviourをインタフェース化しておくとより汎用的になりますが、速度との兼ね合いです。

また、図では参照関係(呼出し応答)と情報伝達(送受信)を区別して書いている点に注意です。これらの向きの違いでも処理の内容が異なります。特にコールバックの種類に関わってきます。
・呼出し→応答の向きと送信→受信の向きが異なる:Get
・呼出し→応答の向きと送信→受信の向きが同じ:Set

マップ変数の操作

ここから、具体的な処理の詳細設計を考えます。演出オブジェクトから実行する依存元メソッド、つまりMapBehaviourに持たせるメソッドを考えていきます。

まずはMapBehaviourで閉じる操作である、マップ変数の操作から述べます。「マップ変数」とは演出オブジェクト内で使用する変数で、マップごとに用意しておくものです。ゲーム進捗や条件による演出の分岐に用います。なお「スポット変数」も考えられますが、現状では用意していません。

マップ変数・Envメンバの参照を取得

マップ変数の参照の取得は比較的単純です。マップ変数名を引数にとり、変数名に対応する参照を返すだけの処理です。
次に、変数の値の書き換えもできると、ゲーム進捗の更新ができるようになります。これは変数の参照を取得できれば容易です。
さらに、マップのEnvのメンバを取得する処理があると、アイテム入手などゲームルールに関わる処理を組めるようになります。Envメンバの値の書き換えは、変数の値の書き換えと同様です。
マップ変数1個だけを更新するような単純な処理は、組み込みで演出オブジェクトを用意しておけばよいでしょう。一方でパーティメンバの変更など複雑な処理は、演出インタフェースを個別に実装し、メソッドを直接呼び出すことになります。

演出の分岐

演出の分岐を考えるにあたり、まずジャンプ命令にあたる演出位置の切り替えを考えます。
演出位置の保持は「演出ポインタ」により実現しています。これは演出を現在実行中のスポットの要素と、そのスポット内の演出リストの要素をインデックスで指します。演出位置を切り替える際は演出ポインタを書き換えます。
演出の切り替えでは、スポット単位で実行位置を飛ばすこととします。現状、スポット内の演出リストの位置は指定できません。「ラベル」の仕組みを導入しない限り使い勝手は悪い上に、ラベルの仕組みも使いやすいわけではないためです。
その代わりスポット単位で位置指定できることで、スポットをコードブロックのように扱えます。

演出ポインタの切替・条件分岐

条件分岐は変数取得、条件判定、演出ポインタの切り替えを組み合わせて実現します。判定の部分は、現状 a > 0 のような定数値との比較だけ実現できます。条件式をコードで記述できないため、比較演算をリストから選択するようにしています。

全体的に、演出オブジェクト同士の参照と、演出実行順の制御構造の設計についてはまだ不十分です。オブジェクト指向以前の構造化プログラミングの段階としても組みづらいです(後述)。今後改善したいです。

シーン遷移

ここからは具体的な演出の処理を考えていきます。具体的なぶん他のヤードとのやりとりや非同期処理などの概念が加わってきます。

まず、マップシーンから別のシーンへ遷移する処理を考えます。この処理では、演出オブジェクトから上流のマップサービスや別ヤードへの通知が生じます。そのため処理の依存性逆転が必要です。

シーン遷移。状態機械を用いる

ポイントとしては、状態監視を用いることで、非同期処理とMapBehaviour-SceneSwitcher間の制御反転を行っています。そして演出オブジェクトからMapBehaviourを呼び出すことで、この2者間の依存性逆転を行っています。

全体的な流れとしては次の通りです。SceneSwitcherがMapBehaviour等のBehaviourを常時(毎フレーム)状態監視しています。その状況でMapBehaviourが演出オブジェクトから呼び出されます。MapBehaviourが次状態へ遷移するとSceneSwitcherがサービスを介してMapBehaviourの状態変化を検出します。そして状態変化に応じて現在シーンの切り替えと、各シーンの遷移処理を実行します。MapBehaviourに関してはシーン遷移時に処理停止されます。

表示関係

ようやく、いわゆる演出らしい演出を考えられます。メッセージ表示は画像表示、テキスト表示、クリック待機を組み合わせた演出なので、これらを実装するとメッセージ以外にも様々な演出ができるようになります。
表示に関わる処理はSceneDirectorというクラスが実行します。そのため、できれば演出オブジェクトからSceneDirectorを直接呼び出したいです。結果として、演出オブジェクトからMapBehaviourを呼び出すときと同様に、演出オブジェクトの実行メソッドの引数にSceneDirectorを追加することにしました。
またクリック待機については、演出オブジェクトにシグナルを待機するフラグを立てておき、クリックされるウィジェットに次の演出を実行するイベントハンドラを設定すれば、所望の動作をしてくれます。

メッセージ表示。一つの演出のように見えて、いくつかの演出の組み合わせ

現状の課題

演出オブジェクトの実行の分岐

まず演出オブジェクトの実行の分岐に関して、現状の仕様では以下の限界があります。
・演出オブジェクト(式)のネストが出来ない
・比較演算の中で計算(特に右辺値に式を記述)ができない
・スポット(コードブロック)のネストが出来ない
・演出リストの関数化ができない(マップ変数を使えば模擬できるが煩雑)

演出オブジェクトのネストが出来ないと、a * 2 + 3 のような計算だけでなく、b = a + 2 のような変数の代入もできません。特に条件式の中で式のネストができないと、a > 0 のように比較の基準値に定数値しか指定できず、高度な判定が出来ません。(Enumの比較のような事しかできない)
スポットのネストができないと、条件分岐が必要・不要になるたびにスポットを追加・削除する必要があります。スポットの管理が大変です。

いずれも演出をコードで直接記述する場合はC#の文法で書けるのですが、Unityエディタ上で演出を作成する際に使い勝手が良くありません。このあたりの仕様はもう少し煮詰めたいです。

テキスト表示

現状の実装では変数の値をテキスト表示できません。これは "{0}" のC#のフォーマット文字列に対応し、演出オブジェクト側で変数リストを用意すれば実現できそうです。TextMeshProのマークアップが使えるかどうかは要検証です。

この記事が気に入ったらサポートをしてみませんか?