マスタデータを作る(3)(Unityメモ)

遅れた夏休みの宿題を片付ける気分


用語について

ドメイン駆動設計の特徴として、ドメインの用語を使ってソフトウェアの設計を行う点があります。
そのためドメイン駆動設計に沿ってゲームを設計する際も、同様にゲームの用語を使ってクラス名などを定義することになります。ただしゲームの場合は一般的な業務システムとは異なり、そのゲーム固有のルールや世界観に沿った概念を用いることがよくあります。そのためゲームシステムの設計では、ゲーム固有の概念を一般的な概念と対応付けることが難しい場合があり、その場合はゲーム固有の用語をそのまま使用することになります。
そのため以下の記事でも一部は本ゲーム特有の用語が出てきて、その語については理解に苦しむ部分があるかと思います(これまでの記事でも特有の語が出てますが…)。ご了承ください。

今回の記事で扱う用語

ユニット(unit):ゲームの「駒」にあたる存在。
プロトタイプ(prototype):ユニットの種別。いわゆる戦士や魔法使い、モンスターの種類など。
形質(trait, imitrait):スキル、装備、アイテムの総称。世界観とも関連。
    プログラミング用語のtraitと区別するため、imitated trait → imitraitという造語を作成。
効果(effect):ユニットに対して及ぼす変化の内容。いわゆるダメージや回復、バフ、デバフなど

厳密にはドメインの用語ではなく設計上の用語
ヤード(yard):ソフトウェアの保守責任の分掌。おおむね名前空間に相当
操作オブジェクト(operator):ユニットへの効果をシステム上で実現するためのオブジェクト。
エントリ(entry):実行時に用いるマスタデータを定義するクラス、特にシリアライズを想定しているクラス。プロトタイプのエントリ、効果のエントリのように用いる。
アセット(asset):狭義にはUnityのAssetを指す。広義にはエントリのインスタンスを生成するためのクラス。

ユニットとマスタデータ

本ゲームでは周回ゲームのようなものを想定していて、以下のようなデータ構造を持ちます。

マスタデータの構造と各インスタンス


ゲームの駒にあたるのがユニット、ユニットの種別がプロトタイプです。
ユニットはスキルや装備などを使用しますが、本ゲームではそれらの扱いが一般的なゲームとは少し異なります。
・スキル、装備、アイテムを、本ゲームでは総称して「形質」という用語で呼ぶ。設計上も同じクラス(ImiTrait)で扱う
・個々のユニットではなくプロトタイプごとに「形質」を設定する
ユニットのマスタデータの設計というのは、プロトタイプと形質(スキル類)やそれらに関連するクラスの定義を指します。

「スキル」の効果とユニット操作

一般的なゲームでは、スキルは使用時に何らかの効果を発揮します。装備(装備品)は装備中に何らかの効果を発揮しつづけます。アイテムもまた使用時に何らかの効果を発揮し、使用すると消滅します。
本ゲームでも同様に、形質(スキル、装備、アイテムの総称)は何らかの効果を発揮します。

形質のクラス設計では以下のように定義しました。
・形質クラスは1個以上の効果インスタンスをもつ
・効果クラスはユニットに対する操作オブジェクトを1個以上持つ
・操作オブジェクトはインタフェースとして定義し、効果インスタンスは実際の操作内容を実装した操作インスタンスを参照する

効果クラスをインタフェースとしないのは、派生クラスの数を抑えたいためです。例えば「毒付与」や「麻痺付与」といった操作を1種類の「ステータス異常付与」操作としてまとめ、付与するステータス異常の種類を効果インスタンス側に持たせます。そうすることで、実装する派生クラスを「ステータス異常付与」操作の1種類に抑えられます。
要するに、効果の実装にはCommandパターンを用いています。効果がClient、ユニットがReceiver、そして操作がCommandインタフェースにあたります。操作(Operator)という名前を付けているのは、ゲームでよく使われる「コマンド」(「攻撃/魔法/逃げる」やキー入力など)の仕組みとの混同を避けるためです。

マスタデータの保守分掌(ヤード)

単純に考えた場合

マスタデータのインスタンスをRepositoryヤードに所属させる場合

マスタデータを作成する際には、マスタデータ定義のコードだけでなくそのインスタンスの永続化データについても、どのヤード(保守責任の分掌)に所属させるかを決めておく必要があります。
単純に考えると、インスタンスの永続化データは永続化データを扱うRepositoryヤードに所属させることになります。オニオンアーキテクチャの場合はこの考え方であり、マスタデータの永続化データはRepositoryに所属し、Domainに直接依存します。
一方で本ゲームシステムの場合、マスタデータの永続化データをRepositoryヤードに置くことは不適切です。外部ライブラリに依存するヤードとそうでないヤードとの間に依存関係を持たせない方針で設計しているためです。

Contentヤードとして独立させる

マスタデータのインスタンスを置くためにContentヤードを増設

そのため、マスタデータの永続化データはHubヤードに所属させることにします。これにより外見上はDomainヤードとRepositoryヤードの依存関係がなくなります。しかしHubヤードがサービスとマスタデータの両方を保守するのはボリュームが多すぎます。
そのため、Hubヤードからマスタデータを所属させるためのヤードを独立させます。分離したヤードはContentヤードと名付けます(以前の記事で書いた方のヤードはUseCaseヤードと名付け直します)。レイヤ構造で見ると、HubとContentはどちらもDomainとRepositoryとの結合層のような位置づけになります。サービスを扱うHubがDomain寄り、実データを扱うContentがRepository寄りといった立ち位置です。

アセット定義とアセットインスタンスを分ける

アセット定義とアセットインスタンスの所属ヤードを分割

ただしContentヤードを設けても、マスタデータの永続化データのロード処理は依然としてDomainヤードに依存します。ロード処理はマスタデータ定義に依存するためです。そこで依存性を分離するため、マスタデータに関してインスタンス生成と永続化データのロード処理を分離することを検討します。
まず永続化データのロード処理については、ジェネリクスを用いることでデータ定義への依存性を分離・逆転することができます。つまりRepository側ではジェネリクスを用いてロード処理を実装し、Content側でマスタデータ定義をジェネリクスに適用してロード処理オブジェクトを生成するようにします。これでRepositoryはDomainに依存しません。
次にマスタデータのインスタンスについては、マスタデータ定義とは別に専用のクラス定義を設けることにします。これをアセットと呼ぶことにします。Unityのアセットの概念を借りました。

マスタデータ定義とアセット定義の違いは次の通りです。
・マスタデータ定義:Domainヤードに所属。ゲームルールに沿って定義し、他のヤードには依存しない
・アセット定義:Contentヤードに所属。マスタデータのインスタンスを生成し、かつデータ永続化されるためのクラス。DomainとRepositoryに依存する

マスタデータのインスタンスには、マスタデータ定義のインスタンスと、インタフェース実装クラスのインスタンスが含まれます。前者は数値化可能、つまりシリアライズ可能なフィールドの集合で、後者は数値化できずコードからの生成が必要です。アセットはこれらの違いを吸収してマスタデータのインスタンス生成を行います。そしてアセットインスタンスがデータ永続化の対象となります。UnityのScriptableObjectを用いることで、アセットインスタンスを容易に永続化できます。

これでDomainとRepositoryの依存性は分離できました。しかし実際のところ、細かな問題は残っています。シリアライズ指定の暗黙の依存性と、ScriptableObjectへの依存性です。
マスタデータ定義のシリアライズ指定と永続化データ処理は暗黙的にC#の言語仕様へ依存しています。とはいえ、これらがゲーム固有のクラスから言語仕様へとより汎用的なものへ依存するようになったので、言語仕様への依存については甘受します。
一方、ScriptableObjectへの依存性は対策を考えたいです。Unity以外のフレームワークを使用できないのは問題です。

(採用)インポータを含めて考える

アセットインスタンスの依存性を外部データに移動

そこでインポータの概念の出番です。ScriptableObjectへの依存性をゼロにはできませんが、減らすことができます。
シリアライズ可能なマスタデータについてはCSVデータなど汎用的なデータ形式で外部化し、インポータにより外部データをアセットインスタンスへ取り込みます。
ScriptableObjectへの依存性はインポータが肩代わりし、外部化したデータについてはマスタデータ定義への依存性のみを持ちます。このデータについてはUnity以外のフレームワークでもそのまま利用できます(できるような形式で保存する)。
マスタデータのうちインタフェースの実装のインスタンスはシリアライズできず、ScriptableObjectへの依存は残ってしまいます。とはいえ間接的な依存なので、こちらは甘受することにします。

アセット定義とアセットインスタンスの作成

マスタデータのヤードとクラス構造の方針が決まりました。その中で出てきたアセットのクラス構造を設計します。以下、Unityでの話になります。

基本的には次の方法をとります。
1. ScriptableObjectの派生クラスとして、抽象アセットクラスのジェネリクスを定義する。
このジェネリクスは型引数としてマスタデータのクラスを取る。そしてマスタデータのインスタンスを生成する抽象メソッドを定義しておく。インタフェースではなく抽象クラスを用いるのはC#の仕様の都合。
2. マスタデータ定義に対応づけてアセットクラスを定義する。このクラスは抽象アセットジェネリクスの派生クラスとする(後述の模式図を参照)。マスタデータ同士の参照関係は、アセットクラス側ではアセット同士の参照関係に読み替える。そして抽象メソッドを実装する。
3. アセットクラスの定義に[CreateAssetMenu]属性を記述し、Unityのエディタ上でアセットインスタンスを生成できるようにしておく。
4. Unityのエディタ上で、アセットクラスからアセットインスタンスを作成する。あるアセットインスタンスが別のアセットインスタンスを参照する場合も、Unityのエディタ上で参照設定する。

ScriptableObjectの依存性を下げつつもScriptableObjectの使用にこだわったのは、アセットインスタンスをUnityのエディタ上で作成・参照設定ができるようにするためです。参照設定を手作業で行おうとすると、参照に用いるIDを手で管理する必要があり保守しづらくなります。

なおマスタデータの種類によってアセットクラスの定義方法が一部異なります。
・Entry系のマスタデータ:メンバをすべてシリアライズできるもの
・Operator系のマスタデータ:コード記述が必要なメンバをもつもの

Entry系:メンバをすべてシリアライズできる場合

説明用にクラス名を一部変更・省略

図は説明用にクラス名を一部変更・省略しています。
・スキル(Skill)の部分は、本ゲームでは「形質(ImiTrait)」という名前
・Dictionaryは {能力値名→能力値} の連装配列

マスタデータのPrototype.Entryはユニットの種別を表します。これに対応するアセットクラスがPrototypeAssetです。
まずPrototype.Entryには[System.Serializable]属性をつけておきます。これでシリアライズ対象となることを指定します。
PrototypeAssetはPrototype.Entryと基本的に同じメンバを持ちます。冗長になるのですが他によい方法が見つかりませんでした。[UnityEngine.CreateAssetMenu]属性をつけることで、エディタ上でPrototypeAssetのインスタンスを生成できるようになります。作成したアセットインスタンスは.assetファイルとして保存されます。これにAddressablesのラベルをつけておくと、アセットの一括ロードが容易になります。
Prototype.EntryはスキルのマスタデータであるSkill.Entryをメンバとして持ちます。これに対応させるために、PrototypeAssetではSkillAssetをメンバとして持ちます。そしてPrototypeAssetの中で、Prototype.Entryのインスタンスを生成する抽象メソッド(図でのCreateEntry()メソッド)を実装します。

なおマスタデータではDomain名前空間(ヤード)の中で更に名前空間を切っているのですが、アセットクラスは現状ではContent名前空間の中でひとくくりにしています。名前空間を切るメリットがまだないためですが、設計要素が増えたらDomainと同様に区切るかもしれません。

(仮案)Operator系:コード記述が必要なメンバがある場合

メンバをすべてシリアライズできるマスタデータではアセットクラスを定義するだけでアセットインスタンスも作成できます。しかしインタフェースを参照するマスタデータでは、インタフェース型のメンバの関連付けが難しくなります。
根本的には、インタフェース自体からはインスタンスを生成できないことが原因です。そのためインタフェース型のメンバを持つアセットクラスは定義できてもそのアセットインスタンスを生成できません。
インタフェースを実装したクラスのメンバを持つアセットクラスの定義・アセットインスタンス生成は可能ですが、特定のインタフェースの実装クラスを指定すると、別のインタフェースの実装クラスのオブジェクトを代入できなくなり、多態性を実現できません。

しかしUnityでは[SerializeReference]属性という仕組みで、インタフェースをメンバに持つクラスのインスタンス生成(正確にはシリアライズ)を実現できます。

[SerializeReference]属性は[SerializeField]属性の別バージョンです。[SerializeField]属性はオブジェクトを値で保持するのに対し、[SerializeReference]属性はオブジェクトを参照の形で保持し、さらにアップキャスト(基底クラス側へのキャスト)に対応しています。まさに多態性のための属性です。
[SerializeReference]属性の使い方はテラシュールブログの記事を参考にしました。

具体的には以下の通りです。
1. アセットクラスのメンバのうち、インタフェースを参照するものに[SerializeReference]属性をつける。
2. インタフェースの実装クラスに[Serializable]属性をつけておく。
3. 手順2のインスタンスが手順1のアセットクラスのメンバ(の参照)に設定できる。

SerializeReference属性を使ったアセットのインスタンス化を行う時のクラス図

これを実際のOperator系のマスタデータに対応するアセットに応用します。しかし、そのままだと問題点があります。

Operator系のマスタデータに対応するアセットの設計案と問題点

まず、Operatorの実装に対応するアセットクラスを一つずつ定義し、アセットインスタンスを作成する方法を考えます(案1)。
この方法だと、実装クラスの種類だけアセットクラスを記述する必要があります。特にスキル効果など数が多くなることが予想されるクラスだと煩雑です。
また、ContextMenu属性を使い、アセットインスタンス(アセットファイル)の生成時にOperatorの実装インスタンスを設定してしまう方法があります。上記テラシュールブログの記事の「アクションを複数登録する」を参考にしました(案2)。
この方法だと、アセットクラスの数を1個で済ますことができます。しかしOperatorの実装の分だけ設定処理を手作業で記述する必要があります。partial classを用いればファイルを分割できるものの、やはりOperatorが増えると煩雑です。

(採用)Operator系:コード記述が必要なメンバがある場合

なんと、インタフェースの派生クラスのインスタンスをコード記述なしで指定するためのEditor拡張があります。この拡張を使うと、インタフェースの派生クラス(サブクラス)を自動検索し、ドロップダウンリストで選ぶことができます。

@makihiro_devさんのGitHubリポジトリからUnityへインストールするのがおすすめです。公式に欲しい…!

この拡張を使った場合のクラス図が以下です。機能の指定には[SubclassSelector]という名前の属性を使います。前述のクラス図と比較すると分かるように、このEditor拡張を使うと設計がすっきりします。Unity依存になるのは仕方ない。

Operator系のマスタデータに対応するアセットの設計。SubclassSelectorのEditor拡張を使用

ユニットのマスタデータのクラス構造

以上の考察を踏まえて、本ゲームで使うユニットのマスタデータのクラス構造を決めました。エントリのデータベース機能を含めて設計しています。

ユニットのマスタデータのクラス構造


(参考)能力と技能の違いについて考察した

スキル、装備、アイテムを総称して形質というゲーム内用語を名付けたのは、これらの概念を統合してクラス化する際に一つの単語にまとめたい、という意図の副産物です。

人間の能力と技能に関連する英単語の考察

参考:「技術と技能の違い」※テクノロジーとスキルの違い

参考:陸上競技での、技術(※テクニック)と技能(スキル)の違い

参考:linguistic competence(言語能力)とlinguistic performance(言語運用)

スキルは図のskill、装備はtool(手道具)やdevice(装置)、アイテムはstuff(物品、特に薬や消耗品)に相当します。
人間の技能は練習や経験により習得されます。能力についても、先天的な素質(aptitude)に加えて後天的に向上する部分(competence)が大きいです。そのため、技能や後天的な能力は、acquisition(獲得されるもの)という名前で総称できそうです。また装備やアイテムは技能や能力を補助・代替する物品であり、これらもacquisitionに含めます。
一方で、本ゲームの世界観におけるキャラクタは我々の世界の人間とは異なり、スキル、装備、アイテム等は決まったものがあらかじめ付与されます。そのためacquisitionに相当する概念を、本ゲームにおいては先天的な意味合いを含む形質(trait)という名前で呼ぶことにしました。

しかし、traitという語はプログラミング用語としても既に使われているため、コーディングではゲーム用語とプログラミング用語を区別したいです。
ところで、ゲーム世界というものは我々の世界を多少なりとも模擬(imitate)したものです、少なくとも制作中のゲームの世界観は。そのため、ゲーム用語のtraitにはimitatedを冠することにして、さらに imitated trait → imitraitという造語を作成しました。

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