見出し画像

ゲームプログラム(クライアント)におけるオレオレDDD+MVP+CQRSレイヤードアーキテクチャ

・履歴
2025/01/16 公開

この記事は25000文字あるので、短縮版を用意しました。
まず↓を読んで気になったら読むという感じにしてください。

はじめに

一般ゲームプログラマー(ソフトウェア業界経験なし)が職場のコードにムカついて考えたレイヤードアーキテクチャについての記事です。
・テストコードが0
・シングルトンとUtilが100
の状況を改善する設計パラダイムの案について書きます。
提案手法は開発期間が半年以上になるような中規模以上向けです。

想定読者は3~5年以上プログラマーをやっていて、読書体力のある人です。
右のバーを見よ!とはいえ、長さの割にはシンプルなものが最終的に出力されているつもりです。
非プログラマーや読むのに飽きた人は下の方の実験記録辺りだけ読んで「へ~」となってください。

また、自分の趣味により、クライアントコード(≠サーバーコード)の想定で書きます。そして、チームでの運用の保証は現時点ではほぼしません
(多分それをやろうとすると、また別の長い努力が必要になる……)

あと、文章をまとめるのが難しかったんで、構成として論文っぽい流れで書きました。こうすることで、前提知識を1節に押し込んで大幅に短く出来たのでよかったです。

キーワード:ドメイン駆動設計(DDD)、DI(依存性注入)、単体テスト、テスト駆動開発(TDD)、MVP

この記事で言いたいこと(Abstract)

  • DDDっぽいレイヤードアーキテクチャを趣味コーディングではじめました

    • 目的:設計都合に対応したコード配置によって、処理の散乱を防ぎたい

    • DI導入による疎結合化

      • TDDっぽい書き方でコミット間隔(作業単位)を短く出来そう

      • 単体テストを実施して、テストプレイで見つけづらいバグが潰せる

    • 一方、この記事の長さから分かるとおり、モノがない状況では情報共有に難があり、チーム運用に課題あり

知ってるゲームプログラムの構造(Introduction)

まずオレオレ現状解釈と問題提起をします。
現行のゲームプログラムのアーキテクチャを挙げます。

静的配置型(Unity、RPGツクール)

古くはRPGツクール、今はUnityで採用される型です。
スクリプトを付与したオブジェクトをゲーム空間に配置することでシーンを組み上げます。
RPGなど、ゲーム空間内に固定されたオブジェクトと干渉するゲームジャンルとは相性が良いです。逆にゲーム内で動的に管理する要素が多い場合は作りづらくなります。経営系とか戦略SLGみたいなステージが無いゲームとか。ちなみに自分の専門はこの辺です。

私はあまりこの構造をやったことがないので、メリットデメリットについては語りません。

DTSS(Data・TransactionScript・Singleton・SmartUI)型

オレオレ命名です。構成要素のデータストアトランザクションスクリプトシングルトンスマートUIの頭文字を取っています。

構成要素
・データストア
セーブデータやマスターデータの入出力が出来る巨大なシングルトンクラスのこと。
※マスターデータ・・・プランナー(PL)がエクセルに入力する固定値のデータのこと。
例) CharacterData(キャラクターセーブ)、ItemEffect(アイテムの効果値データ)

・トランザクションスクリプト
staticなUtilクラスなどに、手続き的な(色々な事をやる)処理を書いたもの。
例) UtilCharacter(キャラクター関連の雑多な処理クラス)

・シングルトン
「Mgr~」クラスのこと。
セーブデータに乗らないような一時変数とUtil的な関数を持つ。
例) MgrBattle(戦闘シーンや戦闘開始を管理するクラス)

・スマートUI
ゲームロジックも書かれているUIクラスのこと。
例) ViewParty(編成画面クラス。ボタンを押したときに、PartyDataの配列のクリアとかのレベルもここでやる)

動作イメージ:
①起動時にデータストアとMgrを初期化する
②プレイヤーの操作と毎フレームのUpdateで、UIやUtilやMgrの手続き処理を実行

実装作業のイメージ:
例)新イベントの実装をするとき
①新イベント向けのマスターデータとセーブデータの構造を設計する
②新イベントのMgr、Utilクラスを作り、新しいUIを沢山作る
③MgrやUIのメンバ変数の位置に合わせて関数を作り、繋ぎ合わせて挙動を作る
例)戦闘シーン関連はMgrBattleに追記し、UIに今出ている値を使う部分ではUI側にロジックを実装する

メリット
・人間の自然状態に近いらしく、新人でも読み書き出来る
・ワークフローが明確
 ・新しい仕様が来たらUIとMgrとUtilを立てて、後は好きに書けばいい。
・堅苦しいコーディングルールや、壊れるような構造がない

デメリット
・難しいロジックの実装能力が低い
 ・MgrとUIに置かれたメンバ変数に合わせて処理が書かれがち
  → 本来一続きの処理が複数箇所に分散
    ある難易度から人間に追えないコードに化ける
 ・データ設計の都合によるクラス設計がされがち
  ・1つのクラス(例:キャラクター)に多すぎる処理がぶち込まれたりする
  ・汎用的な設計がされず、再利用性がないがち
   例)建物に対する攻撃と、敵部隊に対する攻撃が全く違う処理になる(仕様時点でダメージ付与可能などのトレイトやインターフェースの発想がないがち)
・(処理の散乱のため)単体テストが書けず、バグ防止がテストプレー頼み
・UIが変更されるとロジックも巻き添えを食らって書き直しになる

総じて、機動力は高く誰でも使いやすいが保守や複雑な問題には弱いという特徴を持ちます。初心者向け機体。

これらの問題を改善するため、実験中のソフトウェア本の知識を活用したレイヤードアーキテクチャを紹介するのが本記事の目的です。

前提知識(Related Work)

提案手法に使用する、ソフトウェア本の知識を簡単にまとめます。短い論文のRelatedWork程度の粒度で記載するため、知らない人はあくまでガイド程度に読んでください。逆に、完全な理解者は読み飛ばして良いです。

これらの知見の多くはゲームではなく、一般的なソフトウェアが想定されています。そのため、この節を読む際はAmazonのWebサービス辺りを念頭に置いて読むと良いと思います。
例) Amazonにはショップがあり、Kindle管理サービスがあり、社内で動く倉庫のサービスがあり……

ただし、そう注意しておいてなんですが、今回私がゲームプログラミングで使わないつもりの特性の説明は、あえて落としているということには留意してください。なぜならあまりに文章が長くなりすぎるため……。ちゃんと学びたい人向けの参考文献は最後にまとめます。

ドメイン駆動設計(DDD)

エリック・エヴァンスのドメイン駆動設計』によって体系付けられた戦略・戦術面でのソフトウェア開発手法のことです。
解説をしたいのですが、既にソフトウェア開発の中で一般的な手法となっている側面も多いため、「DDDとは何か」という事を今書くのは難しいし、ナンセンスになる側面もあります。

アーキテクチャスタイルは、芸術運動と同様、その時代の文脈の中で理解されなければならない。

ソフトウェアアーキテクチャの基礎

そういった状況ではありますが、ここでは簡単な理解のために次の3要素に分解することにします。

①モデリングによる設計
実際にビジネス手続きに登場する概念を、そのままクラスに落とし込んで実装することでソフトウェアを作ります。
例:荷物、ユーザー情報、購入履歴
 → そのままクラスにして、振る舞い(=関数)も実装する
例:購入手続き
 → 手続きを取り扱うサービスクラスに記述する

あるいは、この手順とは逆に、綺麗にクラスに落とし込めた設計によって現実を再解釈します。
とにかく、現実の解釈とコードの両方がスッキリするようにモノと手続きベースの概念設計を行います。

②戦略DDD
①でモデリングしたものを、ビジネスエキスパート(倉庫管理ソフトを発注した倉庫のプロの人。ゲームなら仕様を決めるプランナー)と共有します。これによって、仕様書と実装を一致させ、保守性の高いソフトウェアを作ります。ビジネスエキスパートとPGが一緒にテストの内容を考えることが出来たりするわけですね。

③戦術DDD
①を実現するためのコーディング技術のこと。
原著では以下のようなレイヤー分けなどが提案されています。

・ドメインレイヤー
ビジネス手続きに登場する概念をモデリングしたドメインオブジェクトと、そのセーブ・ロードを管理するリポジトリクラスがある場所
・アプリケーションレイヤー
ビジネス手続きの流れを記述する、アプリケーションサービスがある場所
※サービスは内部状態(=メンバ変数)を持たない。それはドメインオブジェクト側がバリデーション(整合性担保)しつつ管理する。
・プレゼンテーションレイヤー
 UIなど表示周りを扱う場所

例)購入処理の実装(ぽんちコード)

// ストアの手続き関連をやるShopServiceクラス.
class ShopService {
  // 購入処理(ぽんちコード)
  public void Buy(int nUserID, int nItemID) {
    Item rItem = ItemRepository.Find(nItemID); // 在庫オブジェクト.
    if (rItem.IsBuyable()) {                   // ↑は自分が購入可能か調べられる.
      throw new Exception("");
    }
    m_rPaymentService.Pay(nUserID, rItem.PRICE); // 支払いをする.
    m_rWarehouseService.Send(nUserID, nItemID);  // 配送をする.
  }
}

こんな例外を想定していないコードを運用する会社はすぐに倒産しそうですが、さておきDDDの要素は以下です。
①在庫オブジェクト(Item)があって、購入可能か返す振る舞いがある。
(多分、内部的には在庫があるかを見ている)
②購入可能か調べる→支払いをする→配送をする といったように、命令のシーケンスとして手続きが書かれている。

今回の手法では、このドメインオブジェクト+サービスのレイヤー構成を、ゲームにもある事務処理的な挙動を表現するために利用します。

単体テスト

コードで書かれたテストを実行することによって、振る舞いや関数が想定通りに動くことを検証する手法です。
定期的に自動で走らせることも多いです。この場合、AさんのコミットでBさんのコードが壊れるとすぐに検知出来ますね。

単体テストという言葉の定義はありませんが、大体下の引用のように、「プログラムの独立した振る舞いのテスト」くらいに理解しておけば良いと思います。

ユニットテストという単語は適切に定義されておらず、合意もされていません。依存性から独立したユニットをテストするという意味で私は使っていますが、この定義もユニットの意味を明確にしておらず曖昧なままです。私はふるまいの小さなまとまりをユニットと呼んでいますが、どれくらいを小さいとするのかも定義できていません。

脳に収まるコードの書き方

例1) 購入処理の単体テスト
①在庫が無いときに例外を返すテスト
②PaymentServiceに仕事が発注されたか確認するテスト
③WarehouseServiceに仕事が発注されたか確認するテスト

例2)在庫オブジェクトから在庫を減らせることを確認するテスト

このように、色々な階層の振る舞いについてテストを行います。

PG目線での単体テストの利点としては、パーツパーツの振る舞いを保証している状態を保つことで、細かく安心感を得られることがあります。何一つ信頼できない状況では、最終的に長時間のRun & Debugを繰り返すことになります。単体テストで一つ一つ橋頭保を確保していれば、デバッグの負担を細かく軽減することが出来ます。

・補足 デメリット
仕様が古くなるとテストは死にます。そのため、特に検証目的でテストを書いている場合は、メンテナンスコストが発生する点に注意が必要です。あまりにモデリングの変動が激しい状況では、テストの維持が開発の邪魔になります。
個人的なイメージとしては、仕様が半年くらい生存してるなら別に書いても良いのでは?とは思います。やるかどうかは開発のテンポ感と相談していただければと思います。

・補足 業界による重要度の違い
前提として、Webサービスはネットワーク上だったり、分散並列環境といったデバッグしづらい環境で走らせます。また、一つのバグが大きな損害に繋がる傾向があります。そのため、あちらの業界では堅牢な設計が求められます。
そういった環境の為か、ソフトウェア業界では単体テストを想定しているケースがほとんどだと思います。

逆にゲーム業界には単体テストはほぼ存在しないという印象です。
これはバグによる損害の少なさと、画面を見れば全部分かるという相対的なデバッグのしやすさ、そして厳しいスペック競争の中で、設計のために余計なクラスを生やす負荷を避ける考えに起因していると思います(想像)。
cf. ニンテンドー3DS(2011年~2017年くらい)のRAM容量は128MB

とはいえ、最近はゲームエンジンなど、処理的に無駄のあるものを使ってゲームを作ることも現実的になって来た側面があります。
また、UnityにはデフォルトでUnityTestRunnerがありますから、この分野でも関心自体は高まってきているものと思います。

テストファースト/テスト駆動開発(TDD)

※これは面倒な話なので、ガバめです。正確な定義については参考文献を付します。

テストファースト/TDDは実装イテレーションの中で、単体テスト実装を最初の方で行う開発スタイルです。
特にTDDと言った場合、コミットサイクルを短くするためにテストを利用する意図があります。コミットサイクルを短くできると、延々と終わらないコーディング長旅をしなくて済むので頭が楽になります。

TDDのざっくりした手順
①機能のTODOリストを書く
②TODOから一つ選び、実現するクラスとその関数の設計を適度にイメージする
③単体テストを一つ実装する
④対象とするクラスや関数を上記のテストに通るように嘘実装する。
(→ 嘘実装の修正をTODOリストに追加する)
⑤TODOリストを見て次の②や③に進む
⑤'あるいは、テストが通る状態を維持して設計のリファクタリングをする。途中で設計をミスったな、と思った場合もここに来る。

また、この手法は確実に単体テストを書くという観点からも用いられます。

テスト可能なコードを書くにはテストファーストが最も有効というよりは、そうでもしないとテストは書けない、というのが現実的なところです

保守しやすく変化に強いソフトウェアを支える柱 自動テストとテスト駆動開発⁠⁠、その全体像

※単体テスト、TDD共に上の引用元の説明が非常に分かりやすいです。

・補足1
APIの外側からテスト駆動で開発する場合、アウトサイドインTDDという言葉が使われることもあります。嘘実装を交えながら外側から設計を固めていくことで、利用者目線の良い設計を検討します。そして、全体としてこれで大丈夫そうだと確認できるまで、中身の実装作業を遅らせたりします。

・補足2
ここでは上辺だけ議論しました。
あれって何が正解?といった疑問が起こりやすい手法であるため、原著に近い参考ページを付しておきます。
参考:【翻訳】テスト駆動開発の定義

とはいえ、個人的にはやりやすいようにやれば良いと思います。手を滑らせて1ストロークで書きすぎてしまっても、それは反省すれば良いだけですし、脳に収まる作業が出来ているなら、原著が指示するほど細かくステップを刻まなくてもよいかもしれません。十分に楽できてて結果が同じならそれなりに良いはずです。タブン。

依存性注入(DI=Dependency Injection)

クラスの内側から他所のクラスが呼ばれると、われわれは光の速度で単体テストを書く気が失います。シングルトンやUtilなど、staticの呼び出しがそれに該当します。
これを防ぐために、コンストラクタ引数などで使用する外部クラスや外部値を見える化するテクが依存性注入です。

// ストアは決済サービスと倉庫サービスを使って仕事をするのが一目でわかる.
class ShopService {
  public ShopService(PaymentService rPaymentService, WarehouseService rWarehouseService) {
    m_rPaymentService = rPaymentService;
    m_rWarehouseService = rWarehouseService;
  }
}

単体テストではテストダブルなりで嘘のPaymentServiceを投入することで、ShopService単体の挙動を確認できるようになります。

今回のように、シングルトンとUtilクラスといったstaticな連中を使わない設計をする場合、DIは当然不可欠になります。具体的にはサービスとリポジトリのクラスをDIによって注入します。

・補足
より疎結合を目指す場合のやり方があります。
(というか、DIは一般にはこちらで定義されている気もする)
①自身(ShopService)が使う関数のみを持つインターフェースクラスを作る。
(IShopPayment、IWarehouseSend)
②それを注入したいクラスに被せて注入する
(PaymentService implements IShopPayment)

こうすることで、外の世界の実装に一切依存しない設計になります。
しかし、単純にインターフェースクラスが増えて面倒になるので、そこまでやるかはケースごとに考える必要があります。

DIコンテナ

サービスクラスやリポジトリのインスタンスは基本的にプログラム内に一つで十分です。これらの置き場としての機能を持つクラスをDIコンテナと呼びます。
DIコンテナのライブラリは、以下のような機能を持っている印象です。

  • 型を指定してインスタンスを登録する機能(DI.Register<Hoge>())

  • ↑で登録したインスタンスをGetする機能(DI.Get<Hoge>())

  • 登録したクラスを自動で注入しながら新しいインスタンスを生成する機能
    (DI.Create<NewClass>() ← NewClassのコンストラクタにHogeを自動で入れつつ生成&登録)

・(分かる人向け補足)
……個人開発なら上二つだけで良いような気もしてます。
3番目のAPIでDIコンテナに生成を任せると、コンテナ内に何が入っているのかよく分からなくなったり、他にも循環参照の問題とか、定数引数の注入との共存などなど複雑性があります。なので、注入自体はGetで取ったものを自分で入れるという方針もありかなと。
(ただし、このようにする場合は、「Getするのは注入時のみ」という鉄則を守る必要があります。そうしないとやっていることがシングルトン取得と同じになり、「内部から知らん他所のクラスを参照して」、「光の速度で単体テストを書く気が失せ」ることになります)

処理と表示の分離手法

表示と処理担当のクラスを分離することで、各々のコードを簡単にすることが出来ます。
よく登場する3つの分離パターンについて整理します。
用語
Model・・・ロジック層
View・・・表示機能だけを持つクラス

・MVC(Model-View-Controller)
Web文脈でよく使われる概念です。View(Webページ)から発行されたHTTP通信を受けたControllerクラスが、Model層に発注してコマンド実行やクエリ処理を行います。入力のハンドルという視点が強調されます。
Phalcon辺りを入れると最初からControllerを書く場所があるので、多分みんなそのイメージだと思います(Laravelもあるらしい?)。

・MVP(Model-View-Presenter)
Presenterクラスがロジック層とViewの仲介をします。機能はControllerと大体同じですが、この呼び名ではViewのロジックを肩代わりすることが強調されます。PresenterはModel層とViewの両者を知っているという点がポイントです。

ざっくりしたフロー
①画面や場面用のPresenterを生成する
②PresenterがModel(ゲームロジック層)から得られる値を使ってViewを生成する
③Viewでユーザー操作があったら(ボタンが押されたら)、Presenterのコールバック関数を介してModelにコマンドやクエリを投げる。

ゲームでは、実はシーンがPresenterの機能を持つ位置にあると思います。場面内のUnityオブジェクトの表示管理や、Updateの起点といった仕事をしている点がPresenter的です。

・MVVM(Model-View-ViewModel)
Presenterの代わりにViewModelを使用する構造です。
ViewModelはリアクティブプロパティとしてViewに出す値の変数を持ちます(=ViewModelの変数に値をセットするとViewの内容が自動で変わる)。
このリアクティブプロパティとViewとのBindは外で行われるため、Presenterと違って、ViewModelはViewに対する知識を持ちません。

ざっくりしたフロー
①誰か(多くはフレームワーク)がViewとViewModelを作って、Bindする
②ViewModel自身か、あるいは上位のPresenterやシーン、UIロジックが値をセットする → 自動でViewの表示内容が変わる

ゲーム開発的にはUIというより、リアルタイムで内容が変わる3Dモデルの変数管理辺りに挟んで使うと良いかもしれません。ゲームUIは動的に値が変わる側面が強く、Update関数が必要になるケースが多いです。Updateを持つなら、VMというよりかはシーン≒Presenter機能のUIロジックによる管理という側面が強いと考えます。
実際、画面に対して素直にVMを作ると、持つ変数が多くなりすぎると思います。世間では、その辺はフレームワークが自動生成することで上手くやってるイメージですね。

CQS(コマンドクエリ分離)

全てのメソッドはアクションを実行する「コマンド」か、データを要求する「クエリ」かであるという実装ルールのことです。このルールを守ることで、プログラムがシンプルになるため、基本的に守っていきます。
例)Get関数で内部状態を変えるのを禁止

ただし、実際には反した方が楽なケースも多い事には注意です。
例)購入手続きの返り値で、ユーザーにレスポンスを返すために失敗理由のenumを返す。

CQRS(コマンドクエリ責務分離)in DDD

CQRSはCQSを設計レベルで適用したものです。ここではDDDのDLCとして扱います。

素のDDDでは内部処理(コマンド)と表示値の返却(クエリ)の両方をドメインオブジェクトが持ちます。
しかし、ゲームではドメインオブジェクトのメンバ変数のほとんどをUIに表示するケースが多いです。キャラクターのクラスがあったら、恐らく多くのメンバ変数を表示することになるでしょう。
そうすると、全メンバがGet出来ることになり、ロジックが流出しやすくなったり、分かりにくくなったりします。

また、UIでの表示しやすさを考えた結果、ドメイン設計がUIの作りに引きずられて柔軟性を失う可能性もあります。
例)テクノロジーツリーを実装する際に、ツリーのノード機能と解放されるテクノロジーの内容の両方を表現するクラスを作ってしまう。テクノロジーツリーの再利用性が失われることと、テクノロジーのコードにツリーの仕様が混ざることでクラスの複雑性が上がる問題がある。
(とはいえ、ここで一緒くたに作る対処の方が、実装者としてはPrimitiveでとっつきやすいという妥当性はあります)

この問題への対策にはCQRSが有効です。CQRSではクエリサービスとクエリモデル(クエリ専用のオブジェクト)を作り、表示用の値はそこから取ります。
これによって、コマンド周りのカプセル化を強化出来ますし、表示の方もよりUIに適したクラス設計を行えるようになります。

ただし、当然、コマンドモデルとクエリモデル間のデータ同期の問題が発生するため、この点が設計上の課題になります。

DTO(Data Transfer Object)

いわゆる構造体です。データの受け渡し用のメソッドを持たないクラスです。値のSetとGetだけ出来ます。UI表示用のパラメータなどで用います。

DPO(Domain Payload Object)

実践ドメイン駆動設計』で考案されたクラスで、こちらはゲームロジック→表示へのデータ転送に用います。privateメンバでドメインオブジェクトを保持し、publicでGetterを持ちます。これによって、クエリの返却値の実装を簡略化することが出来ます。


おさらい

DDD・・・仕様書と実装が一致したドメインオブジェクトとサービスで作るよ。ビジネス振る舞いを持つオブジェクトはドメインレイヤーに入れて、ビジネス手続きサービスはアプリケーションレイヤーに入れるよ。UIはこれらから独立したレイヤーに置くよ。

単体テスト・・・コードでテストを書くと、分かりづらいバグ潰しやエンバグ回避が出来てデバッグの苦労を軽減したり、堅牢性の向上が出来るよ。

テスト駆動開発・・・単体テストを足場にした実装イテレーションを回すことで、コミット単位を短くしやすくなって楽になるよ。あと単体テストも確実に用意出来るよ。

DI・・・コンストラクタ辺りで、使っている外部のクラスを注入するパターンだよ。内部から他所のクラスを呼ぶとテストもTDDも出来ないのが嫌だからね。
DIコンテナ・・・注入用のオブジェクトをプールしておくコンテナだよ。

MVC・・・コントローラーがユーザー入力を捌いてModelとViewを繋げるよ
MVP・・・PresenterがUIロジックをやってModelとViewを仲介するよ
MVVM・・・ViewとデータバインディングしたViewModelを操作して表示内容と一緒に更新するよ
※ここでいうModelは内部ロジックくらいの意

CQS・・・内部状態を変えるコマンド関数は基本voidを返すよ。逆に何か返すクエリ関数は内部状態を変更しないよ。これによって、可読性を高めるよ。
CQRS・・・コマンド担当とクエリ担当を設計上、別の場所に作るよ。これによって、ロジックとUIの都合を分離してクラス設計を作りこむことが出来るよ。

DTO・・・データ転送オブジェクトだよ。メソッドのない構造体みたいなやつだよ。
DPO・・・ドメインオブジェクトをprivateで持つDTOだよ。Getterを取り付けてクエリに利用するよ。

提案するレイヤードアーキテクチャ(Method)

前提知識(Related Work)を元に、DTSSのコード散乱問題の緩和として、ドメイン駆動設計(DDD)的なアプローチを提案します。

今回、これから述べるアーキテクチャについては以下が分かりやすくて良いなと思っています。
・さまざまな設計都合が、自然な形で分離している点
・コーディングルールがそれなりにうまく定められる点
・クラスの作り方と並べ方に気を付けるだけで、この構造が作れる点
(継承だとかMgrだとかが要らない)
・実はDTSSと書き方がそんなには遠くないという点

下のようなレイヤー分割とアクセス許可(矢印)を行います。

図. 提案するレイヤードアーキテクチャの構造図。
大体ここに書いた種類のクラスで全体の設計が作れるので、コードにすると意外とシンプルになる

データレイヤー、ドメインレイヤー、アプリケーションレイヤー、UIロジックレイヤー、Viewレイヤーに分けて、アクセスの制限をかけています。

具体的に一つづつ見ていきます。

データレイヤー:セーブデータとマスターデータの置き場

セーブデータマスターデータ(エクセル入力固定値データ)だけを隔離したレイヤーです。

// キャラクターデータ.
class Character : SerializableMitainaBase {
  int ID;
  string NAME;
  S_CHARACTER_PARAMETER PARAM;
}

まず、セーブデータのクラスとしてここでセーブ/ロードに特化したクラスを作ります。そうすることで、セーブ/ロードの都合がこれ以降のゲームロジック向けのクラス設計に漏れ出さないようにします。

この実装を行うことで、仕様領域(ドメイン)に応じたクラスを設計する発想が得られるようになります。
例)編制時と戦闘時で別のキャラクターオブジェクトを使う
・編制用キャラクター:装備変更など編制関連のロジックを持つ
・戦闘用キャラクター:現HP、ダメージを受ける処理などのロジックを持つ

セーブデータだけを見ていると「キャラクター」は一つの概念でしかありません。しかし、ここで行うようにセーブ/ロードを都合分離することで、ドメインに応じてクラスを分けることが容易になります。

また、マスターデータの分離は、テストの起動高速化と値の注入のためです。大きなcsvファイルなどをテストの度に読み込むのは起動が遅くなります。また、固定値は注入できる方がテストをしやすくなります。

とはいえ、上記で挙げた理由が気にならないならこのレイヤーは不要です。
この手法には、セーブデータクラスと1対1対応しない関係上、ドメインオブジェクトの構築が少し複雑になるという問題があります。なので、大型でないゲームならこのレイヤーを分ける必要性はあまりないと思います。

ドメインレイヤー:ロジックデータの整合性担保

ゲームロジックのアクター(登場人物)として、カプセル化されたドメインオブジェクトを作ります。

// 編成ドメインオブジェクト.
class Party {
  internal int[] MEMBER_ID;
  public int GetMember(int nOrder);
  public void SetMember(int nOrder, int nCharacterID); // Characterクラスを直で突っ込まない.
}

// 軍団(複数パーティの整合性担保)ドメインオブジェクト.
class Gundan {
  internal Party[] PARTY;   // こっちは内部に処理があるのでPartyを突っ込んでも良い.
  public void SetMember(int nPartyID, int nOrderID, int nCharacterID); // 整合性担保=編成済みのキャラクターは自動で引っこ抜いてくれる.
}

また、他の住人としてリポジトリも存在します。リポジトリは以下の機能を持ちます
・ドメインオブジェクトの保持(ArrayなりDictionaryなり)
・検索
・セーブ/ロード
 ※クラスが1対1対応してないので、複数のセーブクラスを読み書きする

class PartyRepository {
  Dictionary<int, Party> PARTY;
  public Party FindByID(int nID);
  public List<PartySave> MakeSave();
}

以下はドメインオブジェクトを作る際の重要なルールです。

  • サービスにアクセスしない

  • リポジトリにアクセスしない

  • 他のドメインオブジェクトを保持しない(基本、IDで参照を持つ)

これらを守るとドメインオブジェクト自体が他のクラスに依存せず、非常にシンプルになります。他のサービスを使う処理や、リポジトリを使う処理はサービス側に書いていくことになります。

このルールを守った場合、ドメインオブジェクトに残る仕事は意外と少なくなり、消去法でデータ整合性の担保が残ります。これはpublic関数を叩く限り、内容が不正な状態にならないようにすることです。

ドメインオブジェクトの例)Party(編成オブジェクト)
 Join(キャラクターの加入)、Remove(解除)を持つ
 リーダーのいないPartyを作ろうとすると、例外を返す

整合性担保の例)PartySet(軍団オブジェクト)
パーティは4つあり、キャラクターが排他的に所属する仕様を考えます。
このとき、PartySetに重複配置のチェックや解除ロジックを持たせることで、サービス側の実装を簡単にすることが出来ます。

逆に、これらのようにバリデーション出来るかどうかがドメインオブジェクトを考案する目安にもなると思います。

※階層化を避けて、平坦に扱う
ここでPartySet>Partyという階層構造を作りました。普通にこういう階層を作るとPartySetのprivate子でPartyを持たせる作りにしがちです。しかし、そうするとPartyの持つpublic関数を全てPartySetにも実装しないといけなくなって大変面倒です。
私としては、こういう階層は平たくしてしまうほうが好きです。
ここでは、PartySetは「Party内のキャラクター配置のバリデーションの責任のみを負うクラス」と解釈してしまいましょう。そして、サービスからはPartyも見えるようにします。こうすることで、結果的にシンプルなコードになると思います。
ただし、この手法の問題として、PartySetのJoinを無視してPartyのJoinを直接叩いてしまうケースが考えられます。このミスを避ける何らかの工夫は必要となります。C#ならParty.Joinをドメインレイヤー内のinternal関数にして、アプリケーションサービスから触れなくすれば一発です。

アプリケーションレイヤー:ゲームロジック世界のAPI置き場

ゲーム内の事務手続き的な処理を行うサービスクラスを配置します。ここにゲームロジックのフロー系の処理を集めることで、DTSSで問題になっていた手続きの散乱を解消します。
例) キャラクターのレベルアップ処理(CharacterService)
 ・キャラクターのレベルアップの実行(Character.LevelUp)
 ・所持アイテムのチェックと消費(ItemServiceに外注)

サービスは(DI以外の)内部状態(=メンバ変数)を持たないことがポイントです。内部状態は整合性担保のため、ドメインレイヤー側に置いてください。

また、内部で使う他サービスやリポジトリはDIで生成します。

// 編成サービス.
class PartyService {
  PartyRepository REPO;
  CharacterService CharacterService;
  // DI.
  public Init(PartyRepository rRepo, CharacterService rCharacterService) {
    REPO = rRepo;
    CharacterService = rCharacterService;
  }

  // public関数はゲームで実行できるAPIに合わせて用意する.
  public SetMember(int nPartyID, int nOrderID, int nCharacterID); // キャラクター個別追加.
  public ClearMember(int nPartyID, int nOrderID); // キャラクター個別解除.
  public ClearParty(int nPartyID);  // 1編成の解散.
  public ClearGundan();             // 全編成の解散.
}

・補足 循環参照が起きるので、サービス同士はInitで注入する。
DIはコンストラクタで行うのが一番シンプルです。しかし、AのコンストラクタにBが必要で、BのコンストラクタにAが必要だとどちらも生成できないという循環参照の問題が存在します。
特に、サービス同士は循環参照が発生しやすいです。そのため、個人的には循環をムチャして解決するより、コンストラクタインジェクションを諦めて、Init関数を生やして生成後に注入してしまうルールの方が簡単で良いと思っています。

クエリレイヤー:UI表示情報の仲介役

表示用のデータを作るためのレイヤーです。UI以降のレイヤーは任意性があります。この記事では一例ということで書きます。

まずクエリサービスはUI表示用の値を返す機能を持ちます。適宜、パラメータクラスなどを返すと良いでしょう。

class PartyQueryService {
  public S_PARTY_VIEW_PARAM GetPartyViewParam(); // 画面に合わせたparamを返す.
  public QueryParty GetParty(int nPartyID);      // クエリモデルを返す.
}

次に、クエリモデルを作ります。現状の好みは、ここでQueryParty(編成)や、QueryBattleChara(戦闘時キャラクター)など、コマンドモデルと近しいクラスを作って返してしまう方針です。ある程度汎用的なものを作ることで、複数のUIやPresenterにこのデータ型を渡して済ませることが出来ます。

public QueryParty {
  private Party;
  public int[] MEMBER { get { return Party.MEMBER; }}
}

対立案. UIやPresenterに応じて、パラメータのクラスを全てオーダーメイドする方針
こちらでは、レイヤー分担は綺麗ですが、クラス定義が面倒になると思います。

さて、このクエリモデルは次のように作ります。
①private変数でコマンドモデルを持たせて、そこから値を公開する(DPO)
(C#ならクエリレイヤーにコマンドモデルのinternalを公開すれば一発)
②コマンドモデルとは関係ない独自変数を返す

①で実装した変数は、常に最新の状態であるため、管理が楽です。コマンドモデルが持つ情報の転送は、こちらで楽に実現できるでしょう。
例)戦闘中キャラクターのHP表示
BattleCharacterDTOのようなクエリモデルを作り、HPの値を読み出す。

一方、②の独自変数は必要なときだけ値を計算したい場合などに用います。例えば計算が重い場合など。
こちらは、コマンドモデルの内容が変わった際に同期が必要です。同期は、ドメインレイヤーかアプリケーションレイヤーがイベントを発行するようにして、それをクエリレイヤー側から購読して行えば良いでしょう。こうすれば、ゲームロジック側からクエリレイヤーが見えなくても出来事が画面に反映出来ます。

これらの汎用的なクエリモデルや、画面情報に特化したパラメータなどをPresenterに渡せばこのレイヤーの仕事は終わりです。

UIロジックレイヤー:全てのUI管理の複雑さと対峙する場所

シーンシーン管理Presenterを置きます。
ここではUIロジックだけを扱います。

// 編成画面Presenter.
class PartyViewPresenter {
  public void Init(); // UIクラスの生成など.
  public void Update(); // 毎フレームの更新.

  public void OnBtnPress(Event e); // ボタンが押されたときのコールバック.

  void SyncParty(int nPartyID);                 // 1編成を全て更新する関数.
  void SyncMember(int nPartyID, int nOrderID);  // 編制の一か所だけ表示更新する関数.
}

以下の仕事をします。
①シーン管理(割愛)
②Viewの生成
③Updateによる更新
④イベントによる更新
⑤Viewのコールバックのハンドル

②と③
クエリレイヤーから受け取った情報をUIに投入します。リアルタイムで同期されるクエリモデルを使うことで、Updateによる情報の更新も簡単です。

④ イベントによる更新
クエリモデルの監視で対応しづらいケースは、ゲームロジック層のイベントを購読することで解決出来ます。
例)時間で起きるランダムイベントが発生したときにダイアログを出す。

⑤ コールバックのハンドル
ボタン押上時など、ユーザー操作時に呼ばれる関数を作り、コマンド処理やクエリ処理を行います。

こういった機能を持つPresenterですが、作り方(粒度や構成)にはだいぶ任意性があります。
一案として、以下のように書くと良いと思います。

・ゲーム内のシーン
Sceneクラスとしてシーンの制御を書く

・個別の画面が全体を占有する場合
Presenter1クラスに任せる。

・ホーム画面や戦闘画面など、大きな画面モード
全体管理用の大きなPresenterを作る。Contextとかそんな感じの名前にして、子Presenterを管理する形にしても良い。

・手続き的な画面フロー
Wizard辺りの名前のクラスで、複数の画面の遷移を管理する。

ゲームUIの特徴として、はっきりしたモーダルダイアログではない画面モードが多いことがあります。
例)ホーム画面、戦闘画面
どちらも細かいパーツが沢山があったり、表示物が切り替わったりする

こういった画面の制御ロジックで、パーツに合わせてクラスを階層化すると関係性が複雑になりがちです。そのため、まずは様子見として1クラスで書いてみるのもお勧めです。
ゲームロジックや表示値の計算ロジックは既に別レイヤーに分離しているため、この書き方でも意外とコードは難しくなりません。
Initや表示更新のSync、コールバックのOn~系の決まりきった関数を並べることでカタが付くはずです。

Viewレイヤー:Primitive型のみを扱う純粋な表示物

UIや3Dモデルなどの表示クラスを置きます。

class ViewParty : UI_VIEW {
  // メンバー表示更新.
  public void SyncMember(int nPartyID, int nOrderID, S_PARAM_MEMBER rParam);

  // キャラクター表示欄のパラメータクラス. 内部定義.
  class S_PARAM_MEMBER {
    public AsyncSprite rSprite;
    public int nLevel;
    public string strName;
    // ...
  }
}

私としては、画面の多いゲームではプリミティブ型(intとかstringとか)と、このレイヤー内で定義したパラメータ構造体以外は使用禁止という強いルールを採用した方が良いと考えます。そうしたゲームではUIロジックは煩雑かつ物量が多い、というのがこの考え方をする理由です。
Viewのクラスは基本的には何も考えないように作ることで、そういった複雑さは全てPresenterに背負わせます。これによって、非常にコードの見通しが良くなります。

※もちろん、アクションゲームなどで画面が数個くらいしか無いという場合は適当で良いと思います。この記事では画面が20個以上あるようなゲームを想定しています。

※例外として、リストやスクロールビューなど、UIコンポーネント自身の表示ロジックをパーツに実装するのは許容です。ゲームではこういったものを作る機会は多いです。

・補足 Viewの不必要な階層化を避ける
例えば画面の中に3つの領域があるとき、全体を管理するViewと3種類のWindowパーツのクラスを作ることにしたとします。

図. 赤線の3か所を異なるクラスにするケース
private子にしてしまうと、転送関数が増えて面倒になるので、管理責任は無いと考えて露出させる

こういうとき、Viewのprivateな子として3種のWindowのクラスを作って、Setを転送する関数を作って……という実装が浮かびます。
しかし、実際には3種のWindowは露出させてしまって、Presenterから直接値を投入する方が手っ取り早いです。

結局、こういった親子関係はHTMLタグ上の親子関係のようなもので、論理的な階層ではないと考えます。このようにViewにはおおむね子パーツの管理責任は無いと考えてしまう方が楽だと思います。
(※一方、内包する方が自然だったり便利なケースももちろんあります)

構造のまとめ

ここまでで、全レイヤーの解説を終えました。もう一度冒頭の図を貼っておきます。

図. 提案するレイヤードアーキテクチャの構造図。復習

最大のポイントとして、DTSSでまとめて扱っていたゲームデータは、セーブデータドメインオブジェクトクエリモデルの3つに分離され、それぞれ自由に設計することが出来るようになる点があります。このことでデータ設計とロジック設計と表示設計を独立して作ることが出来るのが嬉しいです。

ゲームロジックの手続きはアプリケーションサービスに一本化され、ドメインオブジェクトがデータ整合性の担保を行うため、ゲーム内処理が書きやすくなります。
また、アプリケーションサービスのpublic関数はゲームロジックのAPIにもなっています。この部分に対して単体テストを行うことで、ゲーム全体の堅牢性の担保もやりやすいです。

Viewレイヤーには一切のUI管理ロジックを作らず、ゲームUIの複雑さはPresenterに押し付けます。ゲーム・表示ロジックがなくなったUI制御は、ある程度パターン化された書き方が出来るようになって実装が楽になります。

登場クラスまとめ

  • ドメインオブジェクト
    ゲームロジックの扱うデータはここで全て整合性担保する。

  • リポジトリ
    ドメインオブジェクトのセーブロードとメモリ保持をする。

  • アプリケーションサービス
    ゲーム内のフロー処理を持つ。public関数はゲームのAPIと一致する。

  • クエリモデル
    ドメインオブジェクトをUI読み取り用にしたもの。UI設計によってはドメインオブジェクトとは違う構成にしても良い。

  • クエリサービス
    画面用のデータを返したり、表示用の値の計算を行う。

  • Presenter(Scene/Wizard/Context)
    UI管理を全部やる。

  • View(画面、UIパーツ)
    プリミティブ型と自分で定義した型のみを扱い、純粋な表示物として作る。

  • DTO

    • セーブデータとマスターデータ(シリアライズ関数を持つ)

    • UIパラメーター用の構造体

この構造のメリット/デメリット

メリット
・単体テストが可能
 staticが無く、疎結合でもあるため

・CPUプレイヤーや自動プレイAIが実装しやすい
 ゲームロジックAPIがアプリケーションサービスに明示されているため

・コードをシンプルにしやすい
 セーブロード/ゲームロジック/表示を独立して実装出来るため

・手続き処理を一か所にまとめやすい
 ゲーム手続きはアプリケーションサービス、UI手続きはPresenterに集約されるため、DTSSのようにメンバ変数を追って散乱することが少ない

・分業性が高い
 UI班の責任を独立させやすい。個人開発でも脳に収まりやすい

・仕様書と実装を一致させやすい
 下のようなものがあれば、実装構造と一致してくれる。
 セーブ/マスターデータのエクセル
 整合性担保付きのデータ仕様書
 ゲーム内ロジックの手続き処理の仕様書
 UIの仕様書

デメリット
・DTSSよりは構造が複雑
・現状、人に伝えやすくはない
 ・今の段階では、この長さの記事を書く必要がある
 ・痛い目に合わないと冗長に感じる部分もそれなりにある
 → チーム運用で維持していくには課題がある
・データ3重定義(セーブ・ドメイン・クエリ)が冗長になりうる
 ・自由度のメリットが少ないケースでは簡略化した方が無難

全体的に見て、中~大規模なゲーム向けなアーキテクチャ特性ですね。

実験記録(Experiment)

(Experimentと言っても、計測や評価はないです)

1.簡略化版

最近個人で作っているマスターデータエディタでは、提案手法の簡略化版を試しています。

以下がレイヤー図です。

固定データエディタの構造図。データレイヤーとクエリレイヤーを省略している。
ドメインオブジェクトをそのままセーブロードし、読み取りも行う。

省略点
①セーブデータを省略
ドメインオブジェクトをそのまま保存すれば十分なので、データレイヤーは不要でした。
②クエリレイヤーを省略
こちらは実験的に行ってみました。UI向けpublicは混ざりますが、コードの規模は大きくないですし、個人開発なので意志が∞でUI層へのロジックの流出も起きないので大丈夫です。ただし、サービスのクエリ関数は意識しないとPresenterでドメインオブジェクトから値を作りそうになるといった混乱はあります。
③レイヤーを省略
実は物理的にはレイヤーを分けていません。簡単だし、個人開発なので意思で何とか出来るという想定です。internal修飾子を使いたいタイミングは少しありましたが。

意図として、このツールはコンポジション的なマスターデータを設計するために作っています。
例)ダメージ付与・回復・状態異常回復のように任意個の効果を持ったアイテムのデータを入力したい
(エクセルだとこういうデータ設計はそれなりに面倒)

ドメインオブジェクトとしては、FixData(マスターデータ)、Column(列プロパティ)、RowとCell(打ち込まれたデータ)などが存在します。これらをそのままcsvに保存してしまう感じですね。

今回は1画面しかないので、UIロジックは全てPresenterという1ファイルで管理しています。行数はぼちぼち行きそうですが、やってることはシンプルなので十分かなと。雑にメンバ変数共有できますし。
持っている関数を紹介。

Init群。今回は各ViewはDIしている。
コールバック群。何か触られるとここに来る。
見ているデータが変わったときの表示更新系

ViewはUIToolkitで作っています。今回はかなり動的なのでUIBuilderの意味が無いですし、変なバグも踏むのでuGUIの方がやりやすいかもですね。

View部分をレイヤー分けしているおかげで、Unityエディタ拡張だけでなくゲーム内にも実装出来ますし、Viewクラスを作り直せばUIToolkit以外でも動かすことが出来るようになっています。

2.フルセット

作っているゲームはフルセット版のレイヤー分けで作っています。

こちらは年単位で開発するつもりだったので、書いていて気持ちが良いようにデータレイヤーとクエリレイヤーも作っています。

画面に映っているものだと、Party(編成)やMember(人員)などのドメインオブジェクトが存在しています。
ホーム画面はPresenterの一種としてMainContextクラスが管理していて(編成⇔人員画面の横移動とか)、子としてPresenter(編制画面の左側、人員画面の右側、共通の人員リスト)を持っている感じです。

レイヤーはUnityのAssemblyDefinitionでレイヤー図の通り切っています。ドメインレイヤーのinternal変数をクエリレイヤーに公開することで、クエリモデルを構成しています。

※どうでもいいですが、こういうアニメーションで全部遷移してやるぜ!絶対暗転切り替えしてやんねー!っていう作りは個人じゃないと怒られそうで、やってて楽しいです。
こういう挙動をスマートUIになっている状態でうまく実装するのは大分シビアだと思います。

議論(Discussion)

本当にこういうことをした方が楽になるの?

場合によります。

ソフトウェアアーキテクチャはトレードオフがすべてだ。

ソフトウェアーキテクチャの第一法則 
ソフトウェアアーキテクチャの基礎

アーキテクチャ特性としては、やはり中~大規模向けです。
特にDDDの構造とMVPの構造を持っているので、「事務手続き的な層が厚い」「UIが複雑で多い」といった要件向きです。
UI操作の多い戦略SLGや経営ゲーム辺りには良いと思います。その一方、アクションなどリソースで解決する側面が強いゲームでは課題が異なるので、構造を作るコストの方が高くなるかもしれません。

複雑じゃない?

DTSSの持つ概念よりは複雑です。
とはいえ、コード自体は一度整備してしまえば、書き味はシンプルであまり考えることは無いというのが私の肌感覚です。

ただし、いくつか整備が必要です。
①物理的なレイヤー分け。UnityでのAssemblyDefinition切りは割と事故りました。
②クエリモデルの自動生成ツール。ドメインオブジェクトとクエリモデルが1対1になっている場合も多いので、それ用にあると便利です。

言葉ではいろいろ言っていますが、労力の関係でサンプルのgithubなどを出してないのは申し訳ないです。

開発は早くなるの?

とりあえず一旦遅くなると思います。新しいやり方に不慣れなので当たり前ですね。そして、多くの設計技法がそうであるように、その後早くなることの保証はありません。
多くの設計本が言う、「コードは書くより読む方が多い」を信じるなら、疎結合に近づく分、バグが減って開発が早くなりそうな気はします。とはいえ、現時点でこの構造を推している理由は勘と、何はともあれDTSSから受けた痛みのためです。
一応、いくつか冗長になりうる工夫は省略可出来るようになっていると思うので、この構造がダメってなるときは多分レイヤードアーキテクチャ自体がダメになる状況ではないかと思います。

〇〇はソフトウェア業界のAという問題向けの解決策なのであって、ゲームにはAの問題はないから適用するのはナンセンスなのでは?

例) ドメイン駆動設計は現実世界のビジネス上のモデルをプログラムに落とし込むことに意味があるのであって、ゲーム上にはビジネスモデルは存在しないし、あるとしても現実よりずっと変化しやすいため、適用するのはナンセンスなのでは?

提案手法では、ソフトウェア業界におけるテクの効果をそのまま期待して持ってきた部分もありますが、それぞれ単体でゲームプログラム内で効果を発揮するように配置しているつもりです。この際に、意味付けがもともとの文脈とは多少変化しているかもしれません。

・余談 上記のDDDに対する反論
ゲームでもビジネス手続きのようなものはありますし、ビジネスモデルのようなものは整合性担保の観点から構成することが出来ます。とはいえ、ソフトウェア業界とは違ってモデルの表現するREALが人の頭の中にしかなく、現実の問題より変化しやすいのは本当です。そこはトレードオフを考慮しつつという形になります。とはいえ、モデルを共有・維持できないということはないと思います。開発中ゲームの仕様でも半年くらい持つことも多いですし。維持コスト的には中規模以上なら割に合うんじゃないでしょうか。

境界付けられたコンテキスト、どこにやった?

(分かる人向け)
Amazonだったらストア/Kindle/倉庫みたいに世界を大きく分けるのは極めて重要ですが、ゲームだと分けにくいんで説明を落としました。
編制と戦闘を違う世界に分けられないか?は考えたことがあるんですが、パッシブスキル辺りをどうしても共有しちゃうんですよね。集約の分割辺りで満足しておくほうが良いんじゃないでしょうか。一応フィールドと編制・戦闘辺りなら分けられないでもないですが、そんなにうまみもないような。

スペック的な懸念はない?

そんなにないと思います。
多少転送が増えたり、データ3重化でクラスが増えたりする程度でしょうか。10万個くらいUnityのGameObjectがあってもゲームは動くし、ただのオブジェクトインスタンスはそれより軽いです。
一方、重いアルゴリズムだけはこのレイヤリングとは違う場所で書く選択肢もありだと思います。

セーブ・ドメイン・クエリの3重データ設計をする複雑さの目安は?

難しい問題です。

まず、セーブとドメインはゲームロジックが複雑そうなら分けます。「今回は編成用キャラクターと戦闘用キャラクター(保存しない)を分けた方が作りやすそうだな」といった感覚がレイヤー分けをする理由になります。

ちなみに、普通にやるとゲーム仕様の設計はデータ設計の時点で大部分を考えることになります。
しかし、今回のように分けておくとアウトサイドインTDD的にAPI→ドメイン→セーブデータの順で設計を考えることも可能だったりします。

ドメインレイヤーとクエリレイヤーについては、ビジネスロジック向け変数とUI表示向け変数が大きく違うなら分けます。あるいは、ドメイン側で概念設計を工夫する意図が強い場合も分けます。どこかでUIの都合に引っ張られることが嫌になるタイミングがあります。
とはいえ、ゲームなら大体ほぼ全てのメンバをUI向けに公開する事になると思うので、後者がなくとも分けた方が良いケースが結構多いんじゃないかと思います。

単体テストはやるの?

現状は、アプリケーションレイヤーとドメインレイヤーだけテストファースト/TDDで作るなりしてやっておけば良いと思います。
他は正直なところ、そんなにやらなくても良いんじゃないかなと。データレイヤーはシリアライズを見守るだけです(テスト済みフレームワークを作っておけば個別のテストは不要)。クエリレイヤーは一部の表示用の値の計算関数のテストをしても良いくらいですかね。Presenterはテストの労力が高い割に得られるものは少ないかなと。
UI以下は高速な視覚テスト環境を作っておけば足りるのではと思います。

単体テストがあってもQAによるモニタープレイが必要なら、そっちだけで良いんじゃないですか?

QAによるモニタープレイが必要な程度が下がりますし、テストプレイで見つけにくいバグが発見しやすくなります。
また、プログラマーが挙動に安心感を持って組むことが出来るようになり、コーディングが楽になる側面もあります。
ただし、テストには仕様変更に伴う維持コストがあるので、それと要相談です。
戦闘仕様など潜在的なバグが多く重要な場所では運用して、それ以外は放置する選択もアリです。

ゲーム以外でも使える?

DDDと同じく、入り口と出口がいっぱいある事務手続き的なプログラムには使えると思います。逆に適用しづらいのは、GUIを持たないエージェント的なプログラムです。Start叩いたら終わりの○○機みたいな。

この設計で可能になるあれこれ

基本的に「割に合うかは知らない」を付しておきます。

・ゲームロジックの高速自動デバッグ
UIなしで起動できるため、全プレイヤーをCPUに出来れば高速で自動デバッグを回すことが出来る。

・AIによる機械学習分析
 ゲームロジックAPIがアプリケーションサービスに公開されているため、ゲームを起動せずに外部ツールで最適編制の発見などが出来る。
【レポート】『学園アイドルマスター』における適応的ゲームAIとグレーボックス最適化を用いたバランス調整支援システムの実現 #CEDEC2024

・Undo/Redo機能
Presenterの操作コールバックで操作イベントを記録する。この履歴を見て、適切なPresenterの操作コールバックを叩けばUndo/Redoが実装できる。

・状態復元デバッグ
最終セーブ以降のアプリケーションサービスのコマンド履歴を保存する。
こうすることで、最終セーブ・コマンド履歴データ・操作履歴データ辺りから、バグが出る少し前の状況を自動で再現するシステムが作れる。
(コマンド履歴が無いと操作以外で発生するコマンドが再現できないことに注意。CPUの動きなどが全て決定的なら不要かも)

・ホットリロード化
ゲーム内データはドメインオブジェクトに集約されているので、Presenter側のデータも頑張ってシリアライズ可能にすればホットリロード化が出来る。
大変さの割にやる価値があるかは知らない。
既に設計関係なくホットリロードが出来るUnityアセットがあった気もする。

まとめと感想

この記事ではソフトウェア業界の知識(主にDDD)をゲーム向けにアレンジ・具体化したレイヤードアーキテクチャについて説明しました。
議論には結構ガバがあるかもしれませんし、冗長に見える工夫がいくつか入っているので、その点で人を説得することには課題があるかもしれません。

勘ですが多分作りやすいと思います(Conclusion)

参考文献(Reference)

設計の価値観についての本

レガシーコードからの脱却
コード例がほとんどなく文系的な本であるため、PG目線ではやや退屈ですが、最も尺を割いて価値観を書いている本だと思います。

ソフトウェアアーキテクチャの基礎
ソフトウェアアーキテクトの仕事と、よく知られているアーキテクチャパターンの分類を行っている本です。全てはトレードオフである。

Clean Architecture 達人に学ぶソフトウェアの構造と設計
主にSOLID原則について書かれた本です。

プリンシプルオブプログラミング
よく知られた価値観が列挙してある本です。

ドメイン駆動設計についての本

オブジェクト設計スタイルガイド
ドメイン駆動設計とは書いていませんが、DDD由来と思しきコーディング規約がまとめられている本です。理由の解説はやや短いきらいがありますが、DDDに形から入るならこの本も悪くはないと思います。

エリック・エヴァンスのドメイン駆動設計
ドメイン駆動設計の原著です。内容を全てを知っていないと解読できない哲学書のような構成をしており、大変読みづらいことに注意です。

実践ドメイン駆動設計
ドメイン駆動設計にCQRSなどの発展を加えた本です。ソフトウェア業界分からないマンには具体例がつらかったです。

モノリスからマイクロサービスへ
最近流行りのマイクロサービスと、その移行について書かれた本です。マイクロサービスはDDDをサーバープログラムに適用したものと言えるため、そちらの応用について学ぶことが出来ます。各々の実装技法のソフトウェア業界における立ち位置を理解することに役立ちました。

テストに関する本

保守しやすく変化に強いソフトウェアを支える柱 自動テストとテスト駆動開発⁠⁠、その全体像
単体テストとTDDについて分かりやすくまとまったウェブページです。

テスト駆動開発
TDDの原著です。TDDを行う手順について、例をメインに詳しく解説しています。

脳に収まるコードの書き方
1つのコードで7つより多くのことをするな、といった汎用的な工夫について書かれた本ですが、アウトサイドインTDD成分が強いです。そのためTDDをより広範に適用した例としても読むことが出来ます。

【翻訳】テスト駆動開発の定義
テスト駆動開発へのレスバに対するレスです。定義を読んで混乱があればこちらを読んで整理することが出来ます。

いいなと思ったら応援しよう!