見出し画像

システム構成を整理する(1)(Unityメモ)

再発明した車輪を整理するのに1ヶ月かかってしまった


現状の実装

ホームシーンから「出撃」を実行し、戦闘シーンに遷移するまで

ウィンドウ表示前の処理

メニュー構造の中で、スロットに対する操作の実行時に開くウィンドウを指定できるようにしました。また操作実行時の処理に非同期処理を指定できるようにしました。
操作実行時の処理は以前からあり、これはウィンドウがOnEnableする前に実行されます。ユニット一覧ウィンドウを開く前にウィンドウのコンポーネントにユニットデータを与える、といった処理ができます。

//操作とスロットの定義

public class UIOpe
{
    public string asset;
    public string tooltip; //完成システムではローカライズアセットを指定
    public GameObject window;
    //ウィンドウを開く前に実行する処理。Awake, OnEnableなどGameObjectイベントの前に実行される。
    public Func<Task> procAsync; //非同期処理。ラムダ式でもTaskを返す必要があるため、ActionではなくFuncでラムダ式の型を定義
    public Action proc; //同期処理。procAsyncがある場合はそちらを優先
}
public class UISlot
{
    public string asset;
    public string tooltip; //完成システムではローカライズアセットを指定
    public List<UIOpe> ope = new List<UIOpe>();
    public UISlot parent;
    public List<UISlot> child = new List<UISlot>();
    public string background;
}
// CreateMenuSchema()内の記述。例示したい部分以外のメニュー構成の記述は省略

var party_schema = new UISlot
{
    asset = "party",
    tooltip = "パーティ",
    ope = new List<UIOpe> { new UIOpe { asset = "select", tooltip = "選択" }, },
    child = new List<UISlot> {
        new UISlot{asset="unit", tooltip ="ユニット", ope= new List<UIOpe>{
          new UIOpe{ asset="list", window = UIView.UnitListWindow,
            proc = ()=>{
                ExecLoadTest(); //テスト用にStorage → Modelへセーブデータをロード
                //Model → Viewへユニットデータを送信
                ExecShowUnitList(UIView.UnitListWindow.GetComponent<UnitListViewer>());
            },
            // procAsync = async ()=>{ /*非同期処理の記述を優先*/ }
          },
    }   } }
};

シーン間遷移

UIからシーン間の遷移ができるようになりました。ホームシーン→出撃→戦闘シーン→ホームに戻る、という一連の流れがようやくできました。
現時点ではホームからいきなり戦闘シーンに飛んでいますが、同じ枠組みで他のシーンも追加できます。

// 例示したい部分以外のメニュー構成の記述は省略
private UISlot CreateMenuSchema()
{
    var sortie_schema = new UISlot {
        asset = "sortie_icon",
        tooltip = "出撃",
        ope = new List<UIOpe> {
            new UIOpe { asset = "confirm", tooltip = "出撃",
                proc= ()=>{ SceneState.ForceToNext(null); } //シーンを状態遷移させる
        } },
        child = new List<UISlot> {
            party_schema,
        },
        background = "testBack",
    };
}

// シーンの状態遷移を定義しておく
private void SetState()
{
    var home_state = new SceneStateClass { Name = "home"}; //ホーム
    var departure_state = new SceneStateClass { Name = "departure"}; //出撃中
    home_state.Add(
        (arg) => false,
        departure_state);
    departure_state.Add(
        (arg) => false,
        home_state);
    SceneState = new SceneManagerClass {
        StateList = new List<SceneStateClass> { home_state, departure_state, }
    };
}

ユースケースの記述と実行(再検討中)

「セーブデータをシステム内部にロードする」、「ユニット一覧をウィンドウで表示する」というユースケースは、処理の種類が異なるレイヤを協調動作させることで実現できます。
・ユニット一覧を取得する:ゲームルールを扱うレイヤ(Model)
・セーブデータをロードする:外部ストレージを扱うレイヤ(Storage)
・GUI表示用に成形し、表示内容を設定する:GUIを扱うレイヤ(View)
これらの異なるレイヤを扱うための枠組みを整えることで、様々なユースケースを実装しやすくなります。

メニュー構造にスロットの操作実行時の処理を埋め込めるので、ただ処理を実行するだけなら現時点でもできるのですが、ユースケースを実現する処理を一か所にまとめて書いておいて、ユースケース単位で処理を呼び出したいです。
特にアセットのロードは非同期処理が求められる一方、ユースケース内の処理は順序性を守りたい、つまり同期的な処理を行いたいことが多いです。この違いを埋められるようなユースケースの処理系を設計したいです。

非同期処理を扱う枠組みとしては、まずイベントドリブンのフレームワークがあります。UnityでのStart()ハンドラの実装などがそれにあたります。
これをゲームシステムに当てはめてみたものを考えてみました。すると、発生イベントごとにどのレイヤ間で情報伝達と処理が生じるのかを、イベントとレイヤの表、つまり「イベントチャート」として定義できます。

ロード処理のイベントチャート
画像やユニット一覧など内部データ(Modelデータ)表示を行う際のイベントチャート

あとはイベントを定義し、レイヤ間で授受する情報をイベントでラップし、イベントの授受関係を記述すればユースケースの記述が出来ます。
…できますが、イベントクラスをユースケースごとに定義しないといけないので、保守が大変です。そこでイベントの定義なしにヤード間の情報伝達を記述する方法を考えました。

結論としては、C#のTask、特にasync/awaitを使うのが一番書きやすいと思いました。正攻法が一番です。
レイヤ間で授受する情報を直に記述出来て、かつ非同期処理を待つ場合をawaitを使って同期処理の見た目で書けるので、コードが読みやすいです。
そもそも非同期処理を行いたいのはUIスレッド側、つまりOSやUnityの都合であって、ゲームシステム側で行いたいのは順序性を守る処理、つまり同期処理です。したがってゲームシステムで扱うユースケースも同期処理の書き方をするのが自然です。

現状のゲームシステム構成

今作っているのはゲームシステムの最小構成です。完成品へ向けて拡張しやすくするため、クラス関係や呼び出し規約をまとめようとしています。

2023年6月現在のゲームシステム構成

クラスの関係図です。あえて雑に矢印を書いています。これがある程度見やすく書きやすい構成になれば、設計面でも見通しが良くなるはずです。
例えばStorageの部分(セーブデータ周り)は図がそれなりに見やすいと思います。実際に、この部分は実装の差し替えやすさを意識して設計しました。

モジュールの分類

制作中のゲームシステムのアーキテクチャ。矢印は情報伝達の向き、つまり双方向に伝達

上は何度か出している図ですが、この構成を考えてから実装に取り掛かりやすくなりました。今回改めて図の説明をします。

ソフトウェアを組む際、機能(function)同士の依存性や関係をできるだけ減らすことで設計や保守がしやすくなります。
その方法として、モジュール(機能のグループ)をレイヤ(層)に分割し、レイヤごとに機能を設計・実装することが一般的です。機能の依存性をレイヤ間に限定することで、アプリケーション全体の結合性を減らすことを狙っています。

人間のユーザが使うGUIアプリケーションについて、歴史的なMVCや、それを発展させたMVP, MVVMなどのアーキテクチャがよく使われてきました。構成の違いは以下の記事が分かりやすいです。

ただし上記のアーキテクチャではデータの永続化(外部保存)については言及していません。通常はModel要素の一部として扱います。しかしゲームではマップ・シナリオなどのマスタデータやユーザのセーブデータの取り扱い、つまり永続化データの取り扱いが大きな設計要素となります。
永続化データの管理は、ストレージの管理だと言えます。そこで、MVVMにストレージの要素を独立のレイヤとして加え、レイヤの意味付けを2軸に整理・再構成してみたものが冒頭の図です。

データ処理・ユーザ出入力の軸

アプリケーションに必要な機能は、ユーザに直接関わる部分と、純粋にデータを処理する部分に大別できます。
ゲームシステムにおけるデータ処理の部分は、ゲームルールやパラメータの定義に相当します。RPGなら能力値の種類やダメージ計算式、カードゲームならゲーム進行の手順や役の種類、といったものを指します。
アクションなどGUIが密接に関わるゲームはユーザに関わる部分とデータ処理を分けづらいですが、「穴に落ちたら残機が1機減る」というルールやユニットの移動速度といったパラメータをデータ処理の部分として分離できると思います。

システム内部・外部の軸

現代のアプリケーションはOS含めてすべての機能を一から実装することはなく、何らかのライブラリを使用しつつ、必要な機能を実現します。一方で、業務知識などドメイン固有の部分は外部ライブラリに依存せずに設計・実装できます。
そのためアプリケーションを実現するシステムは、外部ライブラリに依存する部分と、システム内部で完結できる部分とに分けることができます。ゲームシステムも同様に、GUIライブラリやストレージAPI等に依存する部分と、ゲームルールなどシステム内部で完結できる部分に分けられます。

モジュールの再分類

モジュールを2軸2項目で分類すると、分類の組み合わせは4種類になります。
システム内部・ユーザ出入力:GUIで扱う情報を定義 → Content (後述)
システム内部・データ処理:ドメイン固有のデータや演算の定義 → Model
システム外部・ユーザ出入力:GUIの実装 → View
システム外部・データ処理:ストレージ処理の実装 → Storage

Model, ViewについてはMVVMと同様の説明ができて、Storageについても分かりやすい位置づけになったと思います。ただ、最初の「システム内部・ユーザ出入力」の項目が他のアーキテクチャであまり類を見ない役割となりました。
現状では構造化文書やマークアップ言語の概念になぞらえて、Contentという名前をつけています。スタイルに左右されない文書の情報と構造を表すのがContentで、Viewはスタイルシートとその処理系(下記のPresentation)に相当します。

本ゲームシステムにおいては、システム全体が持つ情報を表すModelから、ある局面で取り扱う情報を抽出したものを、Contentとして定義しています。そしてContentで定義した情報をViewを使ってユーザに提示します。

ヤード(仮名、保守責任の分掌)の概念

各レイヤを独立させるため、レイヤ間の情報を仲介する要素を設けています。図の上で各レイヤの真ん中に位置するので、Hub(車軸)と名付けました。当初はPub/Subアーキテクチャで各レイヤを協調動作させようとしたので、韻を踏んだ、という事情もあります。
この構成では、もはや各レイヤは層状の関係になっていません。図を見ると広い土地を仕切っている構図になっていたので、空き地という意味で「ヤード」と呼ぶことにしました。空き地の中で所属インスタンス(エージェント)が活動するイメージです。
また元々はヤードの概念をレイヤの一種、つまりクラスやインスタンスの関係づけの一種だと考えていたのですが、現在はヤードの概念を保守責任の分掌に近いものだと考えています。つまりどの開発主体が機能の実装を保有し保守を続けるのか、という責任分担です。機能側から見ると、機能がどの主体に所属するか、という概念になります。ヤードの概念を言語仕様で実装する場合、クラス定義よりも名前空間の方が意味的に近いです。
ただし多くの場合、モジュールの概念とその実装物であるレイヤは一対一対応している方が開発しやすいです。ヤードの概念についても、実装物としてのレイヤとだいたい一対一対応するものとします。

ライブラリ構造の比較

ヤード間の関係は層状ではないとはいえ、ゲームシステムの実装においてはやはり層状構造で考えるのが作業しやすいです。そこでヤードに対応するレイヤを図示してみました。

ゲームシステムの実装のレイヤ構造

各ヤードは各々の内部ライブラリや、外部ライブラリを呼び出すレイヤを持ちます。そしてHubを介して情報伝達を行います。
Hub以外の各ヤードがHubとやり取りをするためには、ヤードとHubとのインタフェースが必要となります。このインタフェースがどこに所属するのかが問題となりますが、Hubに所属させることで依存性が減ります。
Hubヤードについては、インタフェースの呼び出しに関わる部分は抽象化できますが、イベントシステムに関わる部分はUnityなど外部フレームワークに依存して実行速度を保ちたいです。

ドメイン駆動設計との比較

さてMVVMアーキテクチャを元にヤードという概念をひねり出してシステムを設計しましたが、この区分はドメイン駆動設計(domain-driven design)の用語でそのまま言い換えられます。特にオニオンアーキテクチャの中でも、下の記事の構成がよく似ていました。

構成を比較したのが下の図です。おおむね以下の対応関係になります。
ContentとView → Presentation層
Storage → Infrastructure層
Hub → Use Case層とDomain Service層
Model → Entity層

ヤード構成とドメイン駆動設計のアーキテクチャの比較

ただし依存関係の方向に違いがあるので、完全に同じというわけでもありません。本ゲームシステムの場合、依存関係・参照関係の方向を揃え、かつHubヤードから他ヤードへ向くように設計します。一方、一般的なアーキテクチャでは依存性の方向と参照関係の方向は逆向きになります。
記事の図では依存関係と参照関係がさらっと書かれているのですが、この関係の方向の違いを把握しておくことがシステムの基本設計では重要です。
システムのモジュールを連携させる際には、モジュール間の関係性の種類の違いを踏まえたうえで情報伝達方法の実装を考える必要があります。ただメソッドを用意するだけではモジュール間の依存性が整理されません。
…ということに記事を書いている途中で気づきました。

まとめ

・ウィンドウ表示前の処理・シーン遷移など、ゲーム進行上のユースケースを実装できるようになった
・ユースケースの記述方法については再検討。クラス関係も整理したい
・モジュールを整理するため、ヤードという概念を導入。機能の保守責任の分掌、つまり所属に近い概念。名前空間による実装が意味的に近い
    ・基本的にはドメイン駆動設計でのレイヤの概念に等しい
・モジュール間の依存性の整理をする方法が必要だと気づいた

次の記事に続く

例によって記事が長くなったので、次に続きます。

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