はじめに
アプリを開発する際、最初の設計が与える影響はとても大きいと感じています。
最初にしっかり設計を決めておけば、後々の開発が楽になるだけではなく、不具合も少なくすることができます。
業務でアプリを0から設計する機会はほとんどないので、今回は練習のためにリバーシ(オセロ)を題材にしっかり設計して作ってみようと思い立ちました。
もう一つの理由として、VContainerに触れてみたかったということもあります。
環境
Unity 2021.3.14f1
デモ
デモ動画はこちらになります。
プロジェクトはGitHubで公開しています。
使っているライブラリ
VContainer
UnityでDIを実現するためのライブラリです。
オブジェクトとオブジェクトの依存関係を減らして、それぞれ再利用しやすくするために利用します。
今回は詳細な使い方は書きません。
公式ドキュメントに分かりやすくまとめられているので、こちらを見て頂ければと思います。
UniTask
Unityでasync/awaitを使用した、非同期処理を高パフォーマンスで書きやすくするためのライブラリです。
今回はアニメーションなどの演出を制御するために利用しています。
以前私が書いた記事があるので、使い方はこちらを参照していただければと思います。
MessagePipe
Pub/Subのライブラリです。
イベントを通知する側と受け取る側がそれぞれお互いのことを知る必要性をなくして、柔軟にイベント通知をするために利用します。
(今回ですと、マスの状態が変更されたイベント・ユーザー入力のイベントを通知するために使用しています)
UniRxでも同じことはできますが、MessagePipeはVContainerで利用するための拡張が用意されていたり、UniTaskの非同期処理とも相性が良いので今回利用してみようと思いました。
細かい使い方は、@toRisouPさんの記事が分かりやすいです。
今回の本題ではないが、利用しているライブラリ
ライブラリ名 | 用途 |
---|---|
DOTween | アニメーションのため |
ProBuilder | さくっとリバーシに必要なモデルを作るため |
設計について
概要
まず初めに全体の設計は以下のようになっています。
MonoBehaviourを継承しているか分かりやすいように、CとMで分けています。
主要なクラスのリンクをこちらでまとめておきます。
ロジックとMonoBehaviour
Unityで処理の起点になるのは、MonoBehaviourです。
しかし、リバーシのロジックなどはMonoBehaviourとは分離しておいたほうが良いと私は思います。
大きい理由としては以下があります。
- ロジック・MonoBehaviourのそれぞれの変更がお互いに影響しにくい
- ロジックを他のところで再利用できる
- MonoBehaviourは確認用のコードを書くことで、それぞれ単独でアニメーションなどの確認ができる
もしリバーシロジックを、UnityではないC#プロジェクトで利用したいとなっても、今回のサンプルの場合VContainerとデバッグログの依存を解決すれば利用できるようになります。
DI(Dependency Injection)
今回のプロジェクトでは、VContainerを利用してDIを導入しています。
ロジックと表示の分離はVContainerで実現できていますが、現段階ではDIのメリットをそこまで受けられていません。
今回のプロジェクトをベースにして機能追加をする予定なので、次の記事からそのメリットを受けていくことができると思っています。
例えば、今回はマウスのクリックによる入力ですが、コントローラーで入力を受け付けたいとなった場合、BoardInputProviderの他にコントローラー用のクラスを実装して注入することにより対応が可能になります。
MonoBehaviourの中に直接入力の処理を書いてしまうと、入力回りの拡張や変更が大変になってしまったケースは少なくないと思います。
ロジックと表示の結びつけ役(GamePresenter)
ロジックと表示が分離できたとしても、その二つをつなげる役割を持つものは必要になります。
GamePresenterがその役割を担っており、ボタン押下時の処理・マスをクリックしたときの処理・アニメーションや表示更新のタイミングなどを管理しています。
大きく見るとゲームの進行を管理しているクラスになると思います。
もしオンライン対応などでゲームの進行が変更される場合など、GamePresenterを違うものに入れ替えればほぼほぼ対応はできると思います。
GamePresenterの参照を持つものは大元のGameLifetimeScopeしかないので、違うものに入れ替えられたとしても他のクラスには影響がありません。
図の点線矢印は、直接参照を持っているという意味ではないので次のPub/Subで解説します。
Pub/Sub
イベントの通知はMessagePipeを利用しています。
こちらを利用する最大のメリットは、イベントを通知する側と受け取る側がお互いの参照を持たなくて良いということです。
イメージとしては以下のような感じで、一人が大声で叫んでる感じです。
関係する人だけが反応して、処理を実行するイメージです。
MessagePipeはDIと一緒に使うことを前提にしているライブラリなのでイベントを通知したい・受け取りたいと思ったクラスにフィールドを用意すれば自動的に注入されます。
VContainerでMessagePipeを利用する場合は、事前に通知する構造体を登録する必要があります。
GameLifetimeScopeは、以下のように登録ししています。
using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class GameLifetimeScope : LifetimeScope
{
[SerializeField] private ReversiBoard _reversiBoard;
[SerializeField] private UiManager _uiManager;
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterComponent(_reversiBoard);
builder.RegisterComponent(_uiManager);
builder.Register<ReversiService>(Lifetime.Singleton);
builder.RegisterEntryPoint<GamePresenter>();
// MessagePipeの設定>>>
var options = builder.RegisterMessagePipe();
builder.RegisterMessageBroker<CellStateParams>(options);
builder.RegisterMessageBroker<BoardInputParams>(options);
// MessagePipeの設定<<<
builder.RegisterEntryPoint<BoardInputProvider>();
}
}
VContainerに登録されたものに関しては以下のように記述することで、自由にイベントの通知と購読が可能になります。
// 通知するオブジェクト
[Inject] private IPublisher<CellStateParams> _cellStatePublisher;
// 通知を購読するオブジェクト
[Inject] private ISubscriber<CellStateParams> _stonePutSubscriber;
// 通知
_cellStatePublisher.Publish(new CellStateParams(stoneAction, row, col, cellState));
// 購読
_stonePutSubscriber.Subscribe(OnCellChanged).AddTo(_reversiBoard.GetCancellationTokenOnDestroy());
フィールドではなくコンストラクタの引数にしても自動注入されますが、こちらの書き方のほうが楽なのでフィールドインジェクションにしています。
リフレクションに馴染みのない方はどうして、勝手に代入されるのかと疑問に思うと思います。
[Inject]という属性を付けると、実行時でも[Inject]がついているフィールド一覧を取得できるリフレクションという機能がありまして、型の情報も知ることができるので、登録された型と一致するものをVContainer側で自動的に代入しているはずです。
(詳しく実装は見ていないですが、おそらくそうなっているでしょう)
参考までにリフレクションについての記事を以前書いたのでよろしければ!
ReversiBoardやUiManagerで通知を受け取って、処理する事も可能ですが、VContainerの作者がMonoBehaviourに注入することを推奨していなかったので通知を受け取って処理をしないようにしています。
MonoBehaviourに注入してしまうと、VContainerとMessagePipeを使わないと動かないMonoBehaviourになってしまうので注入しないことが推奨されていると思います。
しかし、注入が必要になるケースも出てくるとは思うので、この辺りは柔軟に設計したほうが良いかと思います。
盤面の履歴(Undo/Redo)
今回のサンプルプロジェクトはちょっと待った等にも対応できるように、Undo/Redoにも対応しています。
一つ一つモデルを書いていくと長くなってしまうので、ルートのクラスで解説します。
public class BoardHistoryModel
{
// 変更後の盤面の状態
public BoardModel postBoardModel { get; }
// 変更があったマスの情報
public IReadOnlyList<CellStateHistoryModel> histories => _histories;
private readonly CellStateHistoryModel[] _histories;
public BoardHistoryModel(BoardModel postBoardModel, CellStateHistoryModel[] histories)
{
this.postBoardModel = postBoardModel;
_histories = histories;
}
}
1手ごとの盤面を残しているので、デザインパターン的にはメメントパターンに該当すると思います。
盤面ごとの情報を残せば、マスごとの履歴を残す必要はなさそうですが、演出の都合上、マスが変更された順番の履歴を残す必要があったのでこのようにしています。
上記のクラスを1手ごとに上記のモデルをストックして履歴を残しています。
private Stack<BoardHistoryModel> _boardUndoHistoryModels = new();
Undoボタンが押されたときに、上記のストックから一つ取り出して、変更を打ち消していきます。
打ち消した後の状態は、その時のスタックの一番上にあるデータになるので、取り出さずに参照してロジック上の盤面データを更新します。
表示上の盤面のデータとロジック上の盤面のデータは別で管理しているので、盤面データの更新が必要になります。
Redoも別のフィールドで管理していて、処理が逆になるだけで基本的にUndoと考え方は一緒です。
リバーシに限らず、パズルゲームにも該当すると思いますが、表示とロジックのデータを一緒にしてしまうと機能追加の際、取り扱いが難しくなってしまいます。
3マッチなどのパズルゲームで演出を飛ばしたいとなったときに、演出とロジックの処理が混ざっていると演出を進めないとロジックを進められなくなってしまいます。
別々にすると、ロジックだけを進めることができるようになるので、ロジックを実行後、画面上の表示をロジックのデータに合わせるだけで良くなります。
演出中にアプリが中断されてしまっても、ロジックを演出前にすべて回して結果を確定することが可能なので、再開時にやり直しさせなくするといったことも可能です。
要するにデータと表示は分けたほうがいいということです。
アニメーション・演出の制御
今回のサンプルではUniTaskとDoTweenでアニメーションを実装しています。
UniTaskで演出を制御することのメリットとして、処理が見やすくなるのとアニメーションのキャンセル処理が楽になるというものがあります。
例として、石を置いたときの処理を見てみましょう。
private async void OnPutStone(BoardInputParams param)
{
// 何かしら演出中であった場合は処理をしない
// _cellStateQueueはロジックからの通知を受け取って1マスごとの状態の変更を保持しているもの
if (_cellStateQueue.Any() || _isPlayingAnimation)
{
return;
}
// ヒントをオフにする
_reversiBoard.SetAllHintOff();
// ボタン類を押せなくする
_uiManager.SetButtonEnable(false);
// リバーシのロジックを実行
_reversiService.PutStone(param.row, param.col);
// 石のアニメーションを実行&実行終了待ち(_cellStateQueueから一つずつ取り出して再生)
await PlayQueuedBoardAnimation(_reversiBoard.GetCancellationTokenOnDestroy());
// UIの表示を更新
UpdateUi();
// ボタンを押せるように
_uiManager.SetButtonEnable(true);
// ヒントを表示
SetHint();
}
処理の前後関係が分かりやすいと思います。
MonoBehaviour側のアニメーション再生関数の戻り値をUniTaskにしています。
こちらも一例で石をひっくり返すときの関数です。
(CellStateは白・黒・何もない状態を表します)
public async UniTask PlayReverseAnimation(CellState state)
{
// 指定された色とは反対の回転角度を取得して設定
transform.rotation = GetStateRotation(state.GetReversed());
var seq = DOTween.Sequence();
// 指定された色になるように回転するアニメーション
seq.Append(transform.DOLocalRotate(GetStateRotation(state).eulerAngles, 0.3f));
// 石の上がり下がりアニメーション
seq.Join(transform.DOLocalMove(new Vector3(0, 1f, 0f), 0.15f).SetLoops(2, LoopType.Yoyo));
// アニメーション終了待ち
await seq.ToUniTask(cancellationToken: this.GetCancellationTokenOnDestroy());
}
UniTask側でDOTweenの拡張がされているので、awaitでアニメーションを終了待ちすることができます。
また、キャンセレーショントークンも渡すことができるので、アニメーションを途中でキャンセルすることも可能です。
上の場合は、GameObjectが破棄されたときにアニメーションを止める書き方です。
これからの機能追加
これから行っていく機能追加で、今回の設計が本当に正しいものであったかどうかが見えてくると思います。
機能追加の際に既存部分の変更を少なくするのがベストです。
ぱっと思いつくもので、以下の二つの対応がこれから出来ると思います。
AI対戦機能
入力は自分の色のターンだけ受け入れ、AIのロジックをGamePresenterに注入することで、AI対戦の機能追加が可能になるかと思います。
また、AIロジックのインターフェースを定義することで、AIロジックを自由に変更する事も可能になるかと思います。
また、表示なしでもロジックを実行できるので、機械学習も回すことも可能かと思います。
オンライン対戦機能
BoardInputProviderとGamePresenterあたりの変更で実現可能になるかと思います。
通信用のBoardInputProviderを別に実装して、ホストはロジックを今まで通りに回して、クライアントはホストの盤面の表示をそのまま再現するようにすれば実現できるかなと思います。
最後に
今回作成したプロジェクトをベースに、機能追加で記事を書いていきたいと考えています。
設計に関しては、これまで良くない設計を見てどうするか考えたり、予想外の変更や機能追加される仕様を受け取る経験を積んでいって上達していくものかなと感じています。
Unityを触り始めたころは、Update関数の中がすごいことになっていますが、そういったコードを書いた経験も今につながっていると思います。