🎮

Godotのコードにドメインモデリングの手法を取り入れる

2025/01/13に公開

目的

  • ゲーム開発はロジックの実装、ノードの実装、セーブやロード、マルチプレイ、スレッド管理など多岐にわたるが、特にコアのロジック部分においては他の影響からできる限り切り離したい。例えば、シーンを改修することでゲームロジックが破綻しないようにしたい。このためにDDDのノウハウを適用して、コアロジックを守りつつ、機能拡張や、マルチプレイなどの拡張に耐えられるような設計を行う。

コンポーネント設計

  • GDScriptにはコンポーネントの依存関係を記述するような仕様はないため、規約を設けて人間が従う必要がある。
  • 基本的にはドメインモデル用のコンポーネントを依存の中心として他のロジックを構成することになる
  • 依存をスキップして利用することは問題ない

ディレクトリ構成

/tscn
  /シーンごとのディレクトリ
/model
  /Bounded Contextごとにディレクトリを切る
    /モデル、集約ルートごとにディレクトリ
/usecase
  /UseCase層
/infrastructure
  /ファイルアクセスなどの仕組みを抽象化

tscn -> usecase -> model
-> infrastructure
の方向に依存するようにする。

ドメイン層のコード規約

  • クラスprivateのメソッド、パラメータは__をつけること
  • package privateのメソッド、パラメータは_をつけること
  • メモ:lintほしい、、

ドメイン層

モデルクラスの設計

  • ドメインモデルを扱うクラスはなにか継承するべきか?
    • 素のObjectはfreeを呼ばないとメモリリークするため使いづらい
    • RefCountedを利用するとシンプルに参照カウント方式でリリースされる
    • Resourceを利用すると値のDeepCopy(duplicate)が簡単に実現できる。
      • DeepCopyを利用するためにはアトリビュートに@exportを付与する必要がある点に注意
      • DeepCopyでも配列、Dictionary内のオブジェクトはDeepCopyされない点に注意
      • Resourceではchangedなどのシグナルも利用できるが、これはドメイン層では排除したい。
    • 以上からモデルで利用するクラスはRefCountedを継承することとする。

ValueObject

  • RefCountedを継承すること
  • 属性によって一意性を定義する
    • ただし実装大変なので必要になったタイミングで都度equalsを実装することにする
  • immutableにする
    • これも実装大変だが必要になったらcopyメソッドをはやすこと
  • メモ:適切な継承作って自動的にequalsとcopy生えてくるようにしたい

Entity

  • Entityを継承する
  • IDによる同一性を担保する
  • mutableとする
    • 本来はこれもimmutableにしたいがgdscriptでの実装が大変すぎるので妥協する

集約ルート

  • RootEntityを継承する
    • これ自体はとくに実装はなく人間が判別する用のマーカ
  • 集約ルートのメソッドを通してのみ変更を行うように注意する
    • package privateのようなattributeがほしいが、無いので人間が頑張ること

トランザクション境界

  • 集約ルートの設計にはトランザクション境界に注意すること
  • RDBと違って保存時のトランザクションは気にしなくていいが、マルチプレイでの同期の粒度はトランザクション境界に従う。つまり集約ルート単位でマルチプレイのデータ同期がされていく。

Bounded Context

  • ゲームロジックのコアドメインとサポートドメインをまずはわけておく。複数のコアロジックが含まれるほど巨大化する予定はないが、問題ないようにはしておきたい。
  • サポートドメインの例
    • セーブ・ロード
    • 実績解除
    • メインメニュー
    • 設定まわり

ドメイン層でのその他のルール

  • ドメイン層については単体テストを実装すること。最低限全行通過すること、分岐やテストの粒度は設計時に任せる
  • ドメイン層ではSignalを利用しないこと。各ドメインオブジェクト間の連携はusecaseで表現していく。
    • とにかくSignalが入るとテストのしようがないので。簡単に手に負えなくなりそう。

UseCase

  • 実質的には動作のコマンドを表す
  • ドメイン層の呼び出しと、ファイル、マルチプレイ、ThreadなどのIOとの結合が責務。
    • マルチプレイはNodeと密結合になっているので適切な型を定義してトップレイヤで結合する形にする。
      • トップレイヤはシーン層やマイグレーション用のロジック層など
    • Threadはサブスレッドを利用する点に限りUseCase内で処理できる。マルチプレイ、Node管理はメインスレッドに制約されているため、これらもトップレイヤで結合する必要がある。つまり、UseCase自体はどのスレッドで動作しているかは気にしない実装にする。具体的にはcall_deferred(必ずメインスレッドで動作する)は使ってはいけない。

シーンレイヤ

  • 具体的なシーンを記述する
  • UseCaseやドメイン層を叩いて処理を進める
  • privateなメソッドを叩かないように注意する
    • lintがほしい

インフラストラクチャレイヤ

  • クリーンアーキテクチャに由来。
    • ヘキサゴナルの方柔軟だがGDScriptでinterfaceの実装がほぼ管理できないので妥協してシンプルにする
  • 具象実装をこちらにまとめる
      • ドメインモデルのシリアライズ、デシリアライズ
      • File IO

Discussion