こんにちは。Annulus Gamesです。
今回はUnityにおけるUIの設計について。UnityでUIを実装するとき、全ての処理をUpdate関数に詰め込んでしまうことはありませんか?
この記事では、UniRxを用いた「MV(R)Pパターン」と呼ばれる設計パターンを用いて、UnityのGUI周りを実装する方法について解説していきます。
MV(R)Pパターンって何?
実装の解説を始める前に、まず「MV(R)Pパターンとは何か」についてを軽く説明します。
「Model-View-(Reactive)Presenterパターン」とはUnityにおけるGUI周りの実装に用いられる設計パターンです。
Unityに限った話ではないですが、GUI周りの実装は複雑になりがちです。多くのデータを扱う必要があるだけでなく、それらが相互に連動し、さらにはユーザーから干渉されることもあります。
そのため、そういった部分をわかりやすく実装するために、MVCやMVVMなど、様々な設計パターンが考案されてきました。その中のMVPパターンをもとに、UniRxを用いて設計されたものが「MV(R)Pパターン」です。
パターンなんかなくてもいいんじゃね?
とは言いましたが、「そもそも設計パターンなんてなくても書けるじゃん?」「全部Updateに書くことの何がダメなの?」という疑問もあるかと思います。
確かに、シンプルなゲームのUIであれば「プレイヤーのHPを表示する」「コインの総数を表示する」など、一つ一つの実装はさして複雑でない場合が多いです。
しかし、それらの要素が数百個あり、それら全てを表示しなければならない場合はどうでしょう。それらをすべてUpdate関数に詰め込めば、訳がわからなくなってしまいます。
例えばこのようなRPGのUIがあったとします。
(ものすごく既視感のあるUIですが、気にしないでおきましょう)
このUIを一つのクラスで実装してみると、こんな感じになります。
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class StatusView : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI playerNameText;
[SerializeField] private TextMeshProUGUI levelText;
[SerializeField] private TextMeshProUGUI hpText;
[SerializeField] private Slider hpSlider;
//外から読み書きされる値
public string PlayerName { get; set; }
public int Level { get; set; } = 1;
public int HP { get; set; } = 10;
public int HPMax { get; set; } = 10;
void Update()
{
//変更がなくても毎フレーム更新を行う
playerNameText.text = PlayerName;
levelText.text = Level.ToString();
hpText.text = HP.ToString() + "/" + HPMax.ToString();
hpSlider.maxValue = HPMax;
hpSlider.value = HP;
}
}
まあ、なんというか...ぐちゃぐちゃですね。uGUIの管理とデータの管理を同じクラスが行なっているし、毎フレーム無駄な更新処理が走るし...
まだ処理が少ないだけマシですが、より複雑な処理を扱おうとすると、さらにクラスが肥大化していきます。
このように、設計パターンを考えずにUIの処理を実装していくと、無駄が多く読みづらいコードが出来上がってしまうのです。
ModelとViewの分離
ではここからが本題。このUIをMV(R)Pパターンを用いて実装していきます。
...が、その前に。まずはMVPパターン、すなわちModel-View-Presenterパターンについて説明します。
MVPパターンにおいてはまず、GUI周りの要素を以下の三つに分類します。
先ほどの例で言うと、
・HPやレベルなどの値を「Model」
・TextやSliderなどのuGUIを「View」
として扱うことができるでしょう。
ここで重要なのは「ModelとViewがお互いを知らない」ということです。
両者をつなぐのは「Presenter」のみなので、Presenterが存在しなければModelとViewは完全に独立した状態になります。
これにより各クラスの責務が明確になる他、ViewとModelが互いに依存していない(疎結合である)ため、どちらかを差し替えることが簡単にできるようになります。
Model-View-(Reactive)Presenter
長くなりましたが、ようやくここからMV(R)Pパターンの説明に入ります。
MV(R)Pパターンでは、先ほどのMVPパターンと同様にModel、View、Presenterに分け、それらをUniRxの機能を使って連携させます。
UniRxには、ReactivePropertyという非常に便利な機能が存在します。
例えば、「ある値が変更された時に何らかの処理を行いたい」とします。これをReactivePropertyを使って実装するとこんな感じです。
using UnityEngine;
using UniRx;
public class SampleA : MonoBehaviour
{
//値を読み取り専用にして公開する
public IReadOnlyReactiveProperty<int> Hoge => hoge;
private ReactiveProperty<int> hoge = new ReactiveProperty<int>(0);
void Start()
{
//普通の変数のように値を変更できる
hoge.Value = 10;
}
void OnDestroy()
{
//Disposeを忘れずに
hoge.Dispose();
}
}
public class SampleB : MonoBehaviour
{
[SerializeField] private SampleA sampleA;
void Awake()
{
//処理を登録(購読)
sampleA.Hoge.Subscribe(value => OnValueChanged(value))
.AddTo(this);
}
void OnValueChanged(float value)
{
//なんらかの処理
//値を表示してみたり
Debug.Log(value);
}
}
このように、ReactivePropertyを使うと、hogeの値の変更を外部に通知することができます。
これがMV(R)Pパターンの基本的な考え方になります。
MR(R)Pパターンを使ってみよう
それでは、先ほどのRPGのUIをMV(R)Pパターンを用いて作り直してみましょう。
まずは「Model」を用意します。今回はPlayerクラスに値を保持させ、それらをReactivePropertyとして公開することでModelとして扱います。
using UnityEngine;
using UniRx;
public class Player : MonoBehaviour
{
public IReadOnlyReactiveProperty<int> Level => level;
public IReadOnlyReactiveProperty<string> PlayerName => playerName;
public IReadOnlyReactiveProperty<int> HP => hp;
public IReadOnlyReactiveProperty<int> HPMax => hpMax;
private ReactiveProperty<int> level = new ReactiveProperty<int>(1);
private ReactiveProperty<string> playerName = new ReactiveProperty<string>("Chara");
private ReactiveProperty<int> hp = new ReactiveProperty<int>(10);
private ReactiveProperty<int> hpMax = new ReactiveProperty<int>(10);
//ダメージを与える処理とか
public void Damage(int value)
{
hp.Value -= value;
}
...
void OnDestroy()
{
//Disposeを忘れずに
level.Dispose();
playerName.Dispose();
hp.Dispose();
hpMax.Dispose();
}
}
こんな感じでしょうか。
次に「View」ですが、今回はUnityのuGUIをそのままViewとして扱います。
ViewはPresenterから参照できればなんでもいいので、uGUIでも良いし、複数のテキストやボタンなどを管理するコンポーネントでも構いません。
ただし、Viewがゲームの構成に必要な値(HPやレベルなど)を持たないようにしてください。重要なのはModelとViewを分離することです。
最後に「Presenter」
Presenterでは、ViewとModel間でのデータの変換や、状態の反映を行います。PresenterはあくまでViewとModelを繋ぐだけの役割なので、それ以外のロジックは持たせないようにしましょう。
実装はこんな感じ。
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using UniRx;
public class Presenter : MonoBehaviour
{
//Model
[SerializeField] private Player player;
//View
[SerializeField] private TextMeshProUGUI playerNameText;
[SerializeField] private TextMeshProUGUI levelText;
[SerializeField] private TextMeshProUGUI hpText;
[SerializeField] private Slider hpSlider;
void Start()
{
//値を監視
player.PlayerName
.Subscribe(value =>
{
//値をViewに反映させる
playerNameText.text = value;
})
.AddTo(this);
player.Level
.Subscribe(value =>
{
levelText.text = value.ToString();
})
.AddTo(this);
player.HP
.Subscribe(value =>
{
hpText.text = value + "/" + player.HPMax.Value;
hpSlider.value = value;
})
.AddTo(this);
player.HPMax
.Subscribe(value =>
{
hpText.text = player.HP.Value + "/" + value;
hpSlider.maxValue = value;
})
.AddTo(this);
}
}
あとは、Presenterを適当なオブジェクト(Canvasなど)にアタッチしてInspectorからModel(Player)とView(uGUI)を割り当てることで、実装は完了です。
どうでしょうか。最初のコードと比べると、それぞれのクラスの役割が明確になり、わかりやすい設計になったのではないでしょうか。
また、今回はModelの値をViewに反映させるのみ(Model→Presenter→View)でしたが、それとは逆にViewの値をModelに反映させたい場合にも、同じようにPresenterを介して実装することができます。
まとめ
・MV(R)PパターンとはUnityにおけるGUI周りの設計パターン
・ModelとViewを分離し、Presenterで繋ぐ
・変更の通知にUniRxを用いる
いかがだったでしょうか。ModelとViewの分離は慣れるまで少々難しいかもしれませんが、複雑なUIを組む上ではほぼ必須となる手法です。ぜひ覚えて、使いこなしていきましょう。





コメント
コメントを投稿