【Unity】UniRxを利用したMVPパターン(MVRP)の実装例の紹介

UniRx 利用した MVP パターン、いわゆる MV(R)P パターンで(Model-View-(Reactive)Presenterパターン)UI の実装例を紹介したいと思います。

今回の記事で実装した UI は以下の通りです。

初めに

MV(R)Pパターンは、表示を View, ロジックを Model, 双方の関係を結びつける Presenter の 3つの階層構造を持ち、各オブジェクト間で UniRx を使ったイベントベースの通知を行います。 View は Presenter と Model を知らない、Model は Presenter と View を知らない、Presenter だけが両者を知っている構成で各々の変更がお互いに影響しないようにシステムを分離する設計・実装パターンです。

Presenter は View - Model 間の関係性を成立させるため実行時に UniRx のイベント経由でオブジェクトを結合します。ただし結合するといってもお互い自分の状態が変わったらイベントを発行するイベントベースの結合となり、自分の状態(変数値など)を変更すると(UniRxが半自動で)相手に通知を行うため View, Model はそれぞれお互いの実装の詳細を知ること無く実装や処理をすることができます。

MV(R)Pパターンは方法論は複数ありますが、この記事では Presneter と Model は Monobehaviour を継承しています。各コンポーネントはインターフェースを使用しません。現在主流の Monobehaviour を継承しない実装は DI コンテナ(Zenject | VContainer)を用いますがこの記事では触れません。

また、動作が実際のゲーム環境に近くなるように UI を DOTween で簡単なアニメーションをさせた実装例とします。

★始める前に

以下紹介の実装方法を用いれば、ボタンは「押されたことを誰かに通知するだけでボタン内には具体的な実装をしない」、バーのほうは「メソッドが呼ばれたら所定の動作を行うだけでボタンイベントだとかを監視する実装をしないで済む」、という風に実装が分離できます。これでコンポーネント間の依存関係を減らすことができます。ただ、実装例は小規模のためイベント発生元と通知先が1対1の関係のためメリットが分かりにくいかもしれませんが、これが1対多など大規模化するとメリットも受けやすくなります。

また、以下 UI の設定周りの詳細なコンポーネントの説明は詳細の説明は本筋と関係ないため省いています。UIの基本的な見た目は目コピ、TextMeshProのフォントデータは各自で準備をお願いします。使用しているアセットやフォントの権利の関係でこのプロジェクトの公開は予定していません。

実装環境

この記事の実装環境は以下の通りです。

  • Unity 2021.2.3f1
  • Visual Studio 2019
  • Windows10

Editor上のみで動作を確認しています。

利用する外部アセットは以下の通りです。

  • UniRx 7.1.0
  • DOTween (HOTween v2)

仕様

今回のHPバー・SPバーの仕様は概ね以下の通りです。

  • 画面上の「こうげき」ボタンを押すとHPバーが減る
  • 画面上の「かいふく」ボタンを押すとHPバーが増える, SPが減る
  • HPバー・SPバーは0未満もしくは最大値を超えることができない
  • 現在HP・SPはバーとテキストの2種類で表示する
  • HP・SPの増減を DOTween でアニメーション表示する
  • SPが足りない時に「かいふく」するとメッセージダイアログが表示される。

UIの作成

先ずはGUIの見た目を以下の通り作成します。

全体の構成

今回のシーンの構成は以下の通りです。画面バーツは以下の通り配置しています。

HPバー・SPバー

HPバーとSPバーを以下のような構成で作成します。

HpBar, SpBar 自体は RectTransform 以外はコンポーネントを何も設定していない空のゲームオブジェクトです。

Image_ProgressBar_B と Image_ProgressBar_F はあとでスクリプトでアニメーションを加えるので以下の画像の通りアンカーを左中央, Pivot を X=0 に設定します。

上記画像では画面例では Image_ProgressBar_F は短く表示されていますが初期状態は一番右端まで長く伸ばしておきます。

メッセージダイアログ

メッセージダイアログのUI構成は以下の通りです。

ボタン

ボタンは Unity の標準のボタンを使用します(=UnityEngine.UI.Button)

スクリプト

【View】HP・SPバー:ProgressViewクラス

プログレスバーの画面表示を行う ProgressView クラスです。

現在値の文字とバーが値が減ったときだけ DOTween でアニメーションして動くようになっています。

using DG.Tweening;
using TMPro;
using UnityEngine;

public class ProgressView : MonoBehaviour
{
    [SerializeField] TextMeshProUGUI _textCurrent;
    [SerializeField] TextMeshProUGUI _textMax;
    [SerializeField] RectTransform _imageRectProgressBack;
    [SerializeField] RectTransform _imageRectProgressFront;

    // バーの最長の長さ
    float _maxProgressWidth;
    // バーの最大値
    int _maxValue;
    // 現在の値(目標値としても使用する)
    int _currentValue;
    // アニメーション時に使用する一時的な現在値
    int _tempCurrentValue;
    // アニメーション用
    Tween _anim;

    private void Awake()
    {
        // 最大の横の長さを覚えておく
        _maxProgressWidth = _imageRectProgressFront.GetWidth();
    }

    public void SetMax(int value)
    {
        _maxValue = value;
        _textMax.text = _maxValue.ToString();
    }

    public void SetCurrent(int newValue, bool useAnimation = false)
    {
        bool isPlus = newValue > _currentValue;

        _currentValue = newValue;
        _anim.Kill();

        // 現在値のバーの長さを更新
        _imageRectProgressFront.SetWidth(GetBarWidth(_currentValue));

        if (!useAnimation || isPlus)
        {
            _textCurrent.text = _currentValue.ToString();
            _tempCurrentValue = _currentValue;
            _imageRectProgressBack.SetWidth(GetBarWidth(newValue));
        }
        else
        {
            _anim = DOTween.To(() => _tempCurrentValue,
                value =>
                {
                    // 背景バーはアニメーションで更新
                    _tempCurrentValue = value;
                    _textCurrent.text = _tempCurrentValue.ToString();
                    _imageRectProgressBack.SetWidth(GetBarWidth(value));
                },
                _currentValue, 0.35f);
        }
    }

    // 指定した値に対応するバーの横幅を取得する
    private float GetBarWidth(int value)
    {
        float per = Mathf.InverseLerp(0, _maxValue, value);
        return Mathf.Lerp(0, _maxProgressWidth, per);
    }
}

public static class UIExtensions
{
    public static float GetWidth(this RectTransform self)
    {
        return self.sizeDelta.x;
    }

    public static void SetWidth(this RectTransform self, float width)
    {
        Vector2 s = self.sizeDelta;
        s.x = width;
        self.sizeDelta = s;
    }
}

インスペクターにはコンポーネントを以下の通り事前に設定します。

【View】ダイアログ:MessageDialogクラス

メッセージを表示するための MessageDialog クラスです。

ルート要素の Message 自体が CanvasGroup を持っているため それを DOTween で 1秒間表示した後 1秒かけてフェードアウトして消えるようにしています。

using DG.Tweening;
using UnityEngine;

public class MessageDialog : MonoBehaviour
{
    CanvasGroup _canvasGroup;
    Tween _anim;

    private void Awake()
    {
        _canvasGroup = GetComponent<CanvasGroup>();
        gameObject.SetActive(false); // 最初は消しておく
    }

    public void ShowDialog()
    {
        gameObject.SetActive(true);
        _canvasGroup.alpha = 1;
        _anim.Kill();

        // 1秒待機してから1秒かけてフェードアウト
        _anim = DOTween.Sequence().
            AppendInterval(1).
            Append(_canvasGroup.DOFade(0, 1)).
            OnComplete(() => gameObject.SetActive(false));
    }
}

【Model】Modelクラス

Model クラスは HP や SP を持ち View からのイベントを受けて自分の UniRx のプロパティを変更します。

using System;
using UniRx;
using UnityEngine;

public class Model : MonoBehaviour
{
    [SerializeField] int _debugMaxHp; // 300
    [SerializeField] int _debugCurrentHp; // 300
    [SerializeField] int _debugMaxSp; // 22
    [SerializeField] int _debugCurrentSp; // 22

    public int MaxHp { get => _maxHp.Value; set => _maxHp.Value = value; }
    public IObservable<int> MaxChanged => _maxHp;
    private readonly ReactiveProperty<int> _maxHp = new();

    public int CurrentHp { get => _currentHp.Value; set => _currentHp.Value = value; }
    public IObservable<int> CurrentChanged => _currentHp;
    private readonly ReactiveProperty<int> _currentHp = new();

    public int MaxSp { get => _maxSp.Value; set => _maxSp.Value = value; }
    public IObservable<int> MaxSpChanged => _maxSp;
    private readonly ReactiveProperty<int> _maxSp = new();

    public int CurrentSp { get => _currentSp.Value; set => _currentSp.Value = value; }
    public IObservable<int> CurrentSpChanged => _currentSp;
    private readonly ReactiveProperty<int> _currentSp = new();

    public IObservable<int> SpEmpty => _spEmpty;
    private readonly Subject<int> _spEmpty = new();

    public void Start()
    {
        // デバッグ用の初期値を設定
        _maxHp.Value = _debugMaxHp;
        _currentHp.Value = _debugCurrentHp;
        _maxSp.Value = _debugMaxSp;
        _currentSp.Value = _debugCurrentSp;
    }

    public void Damage()
    {
        int _next = _currentHp.Value;
        _next -= UnityEngine.Random.Range(10, 30);
        if (_next < 0)
        {
            _next = 0;
        }
        _currentHp.Value = _next;
    }

    public void Recovery()
    {
        int _spNext = _currentSp.Value - 5; // 固定値
        if (_spNext < 0)
        {
            _spEmpty.OnNext(0);
            return;
        }
        _currentSp.Value = _spNext;

        int _next = _currentHp.Value;
        _next += UnityEngine.Random.Range(10, 30);
        if (_next > _maxHp.Value)
        {
            _next = _maxHp.Value;
        }
        _currentHp.Value = _next;
    }
}

【Presenter】Presenterクラス

Presenter クラスは Start 時に View と Mode を結びつけています。

using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class Presenter : MonoBehaviour
{
    // Views
    [SerializeField] Button _buttonAttack;
    [SerializeField] Button _buttonRecovery;
    [SerializeField] ProgressView _hpProgress;
    [SerializeField] ProgressView _spProgress;
    [SerializeField] MessageDialog _dialog;

    // Models
    [SerializeField] Model _progressModel;

    public void Start()
    {
        // View → Model
        _buttonAttack.onClick.AsObservable().Subscribe(_ => _progressModel.Damage());
        _buttonRecovery.onClick.AsObservable().Subscribe(_ => _progressModel.Recovery());

        // Model → View(HP)
        _progressModel.MaxChanged.
            Subscribe(value => _hpProgress.SetMax(value));
        _progressModel.CurrentChanged.
            First().Subscribe(value => _hpProgress.SetCurrent(value));
        _progressModel.CurrentChanged.
            Skip(1).Subscribe(value => _hpProgress.SetCurrent(value, true));

        // Model → View(SP)
        _progressModel.MaxSpChanged. // 最大値の設定
            Subscribe(value => _spProgress.SetMax(value));
        _progressModel.CurrentSpChanged. // 初回の初期化
            First().Subscribe(value => _spProgress.SetCurrent(value));
        _progressModel.CurrentSpChanged. // 2回目以降はアニメーション付きで
            Skip(1).Subscribe(value => _spProgress.SetCurrent(value, true));
        _progressModel.SpEmpty. // SPが空の時のダイアログ表示
            Subscribe(_ => _dialog.ShowDialog());
    }
}

PresenterとModelの配置

最後に Presenter と Model クラスをシーン上の空のGameObject にアタッチして Presenter にコンポーネントを設定します。

コンポーネントの設定は以下の通りです。

これで実行すると最初のような実行状態になります。

最後に

MV(R)Pの中心の一番大切なが Presenter に集約されていて肝心な個所は 数十行程度とコンパクトですが UI の部品点数が増えたり動的な要素の表示があるとこの部分がどんどん肥大化するので整理分類は必要です。ただ、この考え方であらかじめイベントを登録しておけば Update で変数を常時監視する方式から値が変更されたときだけイベントが発生するのを View が待機していればいいだけなので複雑になりがちが View がシンプル化できる上、モデルのロジック処理と表示処理を分離して実装できるようになります。

常に表示されている UI はこのパターンが適用しやすいです。まずは出たり消えたりしない UI だけでも MV(R)Pで実装できないか検討してもよいかもしれません。逆に動的に変わる UI はちょっと切り替えやイベントの設定などが少し難しくなるので慣れてくるまで手を出さない方がいいです。同じ理由で出たり消えたりが頻繁なゲーム中のオブジェクトに対してこのパターンを適用するのは少し何度が高めですが無理なわけではなくちゃんと管理さえすれば運用で切ると思います。

以上です。