前回紹介したSMCPで登場するサンプルプロジェクトの解説記事です。
サンプルプロジェクト
SMCPを使いパズルゲームのデモを作成しました。
完成プロジェクトなのでダウンロードして実際に動かす事が可能です。
プロジェクトの詳細
- Unityバージョン : 2021.x
- 画面 : 16:9 Portrait スマートフォンの縦持ちを想定
- 使用プラグイン : VContainer
開発時の自分ルール
- ドメインを意識した書き方をする
- 必要最低限に実装する。必要な箇所以外の拡張性は考えない
- 基本パフォーマンスを優先する
- DIはLogic/Modelレイヤー以外での利用は最低限必要な箇所のみで利用する
ゲーム完成までの流れ
少々長くて申し訳ありませんが、完成までの流れを見るのが一番手っ取り早いと思ったので掻い摘んで説明します。実装のイメージが湧きますと幸いです。
1. ゲームのルールをまとめる
まずゲームのルールをまとめました。
2. ドメインモデル図を作る
ゲームのルールを元に要素の名称を決めたり必要な処理をまとめました。
3. モデル図を作る
ドメインモデル図から簡単なモデル図を作成しました。
細かいところは開発しながら作るので必要最低限のモデルです。
そのままクラス名になるので命名だけはしっかりやりました。
命名決定後に上記のドメインモデル図に落とし込んでます。
モデル図を見て思いついたアイディア Matrixクラス
ブロックとボードには共通するデータ構造があります。それが列、行、ピース[,] です。
それだけでは無くブロックとボードは同じ処理が必要な可能性が高いのでMatrix(この時は名前は決まっていなかった)という概念を取り入れれば 「MatrixとMatrix同士をマージする = ブロックをボードに設置する」 という事もできるし、整列系の処理もMatrix内に閉じ込められそうだなと思いMatirxクラスを用意する事にしました。開発後に思う事は責務がはっきりして使いやすいので導入して良かったと思います。
※ Matrixクラスは元々PieceMatrixというクラスでした。
Viewレイヤーの開発でジェネリッククラスに変更しました。
4. 作成した図を参考に実装
まず初めにLogic/Modelレイヤーにモデル図から各クラスを作成し、その後にMatrixクラスを作成しました。実際のコードは下記です。
試行錯誤しながらMatrixクラスを作成する為に使用したコード
[Test]
public void PieceMatrix()
{
// ボード
var matrix = new PieceMatrix(new MatrixSize(10, 10));
// ブロック
var matrix2 = new PieceMatrix(new Piece[,]
{
{ new (PieceColor.Black), default },
{ new (PieceColor.Black), new (PieceColor.Black)}
});
// ブロック
var matrix3 = new PieceMatrix(new Piece[,]
{
{ new (PieceColor.Black), new (PieceColor.Black) },
{ default, new (PieceColor.Black)}
});
Debug.Log(matrix2);
Debug.Log(matrix3);
if (matrix.CanIMerge(matrix2, 8, 0))
{
matrix.Merge(matrix2, 8, 0);
}
if (matrix.CanIMerge(matrix3, 0, 1))
{
matrix.Merge(matrix3, 0, 1);
}
Debug.Log(matrix);
}
あとは、そこからBoardクラス、Boardクラス完成後GameクラスとTest Runnerでテストしながら完成させました。この間、1度もEditorを再生していません。
実装のワンポイント ToString()のoverride
Logic/Modelレイヤーの開発中はDebug.Log
で見ていくしかないので図として表示した方が分かり易いクラスはToString()をoverrideすると便利でした。
例) MatrixのToString()
public override string ToString()
{
var msg = $"- {GetType()} -\n";
for (var column = 0; column < Size.Column; column++)
{
for (var row = 0; row < Size.Row; row++)
{
msg += $"|{(Has(column,row) ? "O" : " ")}";
}
msg += "|\n";
}
return msg;
}
ToString()をoverrideすると下記のように確認しながら進められます。
コードは、上記のLogicAndModelTest.PieceMatrix()
の実行結果です。
※ スクリーンショットはRiderのUnit Testsのものですが、Test Runnerでも同じように確認できます。
5. Viewレイヤーの実装
Logic/ModelのオブジェクトとViewのオブジェクト(Prefabより生成)を対になるようにして、Board
、BoardObject
クラスというように命名しました。最終的に〇〇Object
はMonoBehaviour
を継承しているViewレイヤーのクラスという住み分けになったのでかなり分かりやすくなりました。
6. ブロック生成のABテスト
ゲームを開始するとゲーム終了まで3つのスロットにブロックが次々と置かれます。どのブロックが置かれるかをIBlockOrder
というインターフェイスに切り出しABテストできるように実装しました。
using LogicAndModel;
using VContainer;
using VContainer.Unity;
namespace Others
{
public class LifeTimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.Register<IGameProgressRepository, PlayerPrefsGameProgressRepository>(Lifetime.Scoped);
// 他のパターンをテストしたい場合は、IBlockOrderを継承して
// 別クラスを作る、SimpleBlockOrderを新しいクラス名に変更する事で動作する
builder.Register<IBlockOrder, SimpleBlockOrder>(Lifetime.Scoped);
builder.Register<Game>(Lifetime.Scoped);
}
}
}
7. セーブデータ
セーブデータはOthersレイヤーなので必ずinterfaceでアクセスします。
セーブシステムはまだ決まっていないので仮で作成しました。
セーブシステムが決定次第差し替える想定です。
using LogicAndModel;
using VContainer;
using VContainer.Unity;
namespace Others
{
public class LifeTimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
// セーブシステムが決まったら〇〇GameProgressRepositoryを作成して
// PlayerPrefsGameProgressRepositoryを書き換える
// ちなみに何もセーブしないDummyGameProgressRepositoryも用意している
builder.Register<IGameProgressRepository, PlayerPrefsGameProgressRepository>(Lifetime.Scoped);
builder.Register<IBlockOrder, SimpleBlockOrder>(Lifetime.Scoped);
builder.Register<Game>(Lifetime.Scoped);
}
}
}
最終的なクラス図
下記のようになりました。
書いてみて思ったのは Logic/ModelレイヤーとViewレイヤーで責務がかなり明確になる ので書きやかったです。
例えば、「オブジェクトを生成(PrefabをInstantiate)」「オブジェクトを動かす」「動かしたオブジェクトが置けるか確認」「置く」という一連の流れが頭の中にありながら実装を進めますが、Logic/Modelレイヤーでは「オブジェクトを生成」「オブジェクトを動かす」を完全に無視できるので頭の中もすっきりして書きやすかったです。
あと、 テストのしやすさはマジで神です。 newするだけでテストできるって素晴らしい。
ViewレイヤーでもテストしたければDIを通す必要は無く、Logic/Modelレイヤーの必要なクラスをnewして突っ込むだけで楽にテストできます。クラスも多くなり何をnewすべきか分からなくなっても実装時に使ったテストコードが残るのでそこからコピペするだけです。
※ 図がわかりにくくなるのでMonoBehaviour
の継承は記載していません。実際にはView側のほとんどのクラスが継承しています。
※ すべての〇〇Matrixt
はMatrix<T>
を継承していますが、図に記載していません。
※ UIなどもありますが、図に記載していません。
SMCPを導入して思った事
今回は、かなりシンプルなゲームですがある程度の規模になってもルールさえしっかりしていれば問題無さそうだと手応えを感じました。例えばノベルゲームやUIであればMVPを必須にしたり、SOLID原則を徹底したり、ゲームや規模に合わせて決めていけば良いと思いました。
SMCPを考案した理由
世の中には素晴らしいアーキテクチャが既に存在します。
ほとんどのアーキテクチャに共通するのは、 重要な処理やデータモデルをどのように外部の影響から守るか という事です。
ただ、それを素直にゲームで実践したとしてもゲームデザインが重要な処理やデータモデルに影響する可能性のあるゲームにとって、その構造自体が足枷になる事があります。
別の見方をするとアーキテクチャでのUIは入力を受けたり必要な情報を表示するだけの存在。一方ゲームでは、アーキテクチャのUIの部類に入るキャラクターがどういった攻撃を繰り出すかなどが主な関心事で、HP管理やダメージ計算などはゲーム内容が確定した後に決めたい事です。
後々決めたいと言っても大切なのは同じです。
ちゃんとダメージを与えれているか目に見えないデータ上でも確認したい。
決定後は、アーキテクチャ同様HP管理やダメージ計算が外部からの影響を受けたくない。
シーンで再生しないと確認できないのは手間。
色々考えて辿り着いたのが MonoBehaviourとpureなクラスを分離するSMCPというアーキテクチャパターンです。
以上となります。
今後は、SMCPを自身の開発プロジェクトに導入して成熟させていきたいと思います。
今後も有用な情報があれば記事にしたいと思います。
最後まで読んでいただきありがとうございました!
[追記] 実際にプロジェクトに導入する際の知見を記事にしました。