🃏

続:UniRx + MVP でサンプルコードを作成してみた

に公開

やること

MVP っぽい設計で、比較的シンプルなプログラムを作成してみる。
MVP については こちらの記事 で、とても丁寧に説明されている。

  • 作るプログラム(カードゲームの土台となるようなプログラム)
    • 山札と手札を作成し、画面に表示する
    • Draw ボタンを押すと、山札から手札にカードを移動し、画面に表示する
    • カードをクリックすると、手札から山札にカードを移動し、画面に表示する

結果

良い感じに実装できた。

背景

元々はカードゲームを作ってみたいということがあり、始めた。
しかし、前回に UniRx でプログラムを組んでみたが、MVP というより、MVVM 寄りの作りになっていた。
そのため、下記のスクラップにて MVP 寄りのコードを検討したうえで、再度サンプルプログラムを作ってみることにした。

やってみる

GameObject, Prefab を作成する

  • UI Canvas
    • まずは UI を表示するための Canvas を作成
    • 詳細は割愛します。他の方が自分より上手く解説してくれているはず
  • 手札と山札
    • カードに見立てているものは、ただのボタンであり、サイズを調整しただけです
    • Prefab にすることで、スクリプトから Instantiate でインスタンスを生成できるようにします

スクリプトを作成する

作成したスクリプトは以下。
設計を明確にして組んでいるため、見やすいコードになっている・・・はず。
上記の MVP の参考記事の通り、全体を取りまとめているのは Presenter なので、こちらを軸に見ていくと良いと思います。
コードの解説は後日するかも?という感じで・・・。

List っぽいデータを扱う時は ReactiveProperty ではなく、ReactiveCollection を使う必要があるため、少々躓きました。

SamplePresenter.cs
using System;
using System.Collections.Generic;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

public class SamplePresenter : MonoBehaviour
{
    [SerializeField] private SampleView _view;
    private CardsModel _model;
    void Start()
    {
        initializeModelAndView();
    }
    private void initializeModelAndView()
    {
        _model = new CardsModel();
        initializeView();

        bindModelDataToView();
        bindViewEventsToModelData();
    }
    private void initializeView()
    {
        _view.Hands.UpdateView(_model.Hands);
        _view.DrawPile.UpdateView(_model.DrawPile);
    }
    private void bindModelDataToView()
    {
        _model.ObservableHands.Subscribe(_ =>
        {
            _view.Hands.UpdateView(_model.Hands);
            addListeners2HandButton(onPlayCardButton);
        }).AddTo(this);
        _model.ObservableDrawPile.Subscribe(_ =>
        {
            _view.DrawPile.UpdateView(_model.DrawPile);
        }).AddTo(this);
    }
    private void bindViewEventsToModelData()
    {
        _view.DrawCardButton.onClick.AddListener(onDrawCardButtonClicked);
        addListeners2HandButton(onPlayCardButton);
    }
    private void addListeners2HandButton(Action<int> onPlayCardButton)
    {
        for (int i = 0; i < _view.Hands.Buttons.Count; i++)
        {
            int index = i;
            _view.Hands.Buttons[index].GetComponent<Button>().onClick.AddListener(() => onPlayCardButton(index));
        }
    }
    private void onPlayCardButton(int index)
    {
        _model.PlayCard(index);
    }
    private void onDrawCardButtonClicked()
    {
        _model.DrawCard();
    }
}
public class CardsModel
{
    private ReactiveCollection<SampleCard> _hands = new ReactiveCollection<SampleCard>();
    private ReactiveCollection<SampleCard> _drawPile = new ReactiveCollection<SampleCard>();

    // 値が追加された時のイベントを公開するためのプロパティ
    public IObservable<int> ObservableHands => _hands.ObserveCountChanged();
    public IObservable<int> ObservableDrawPile => _drawPile.ObserveCountChanged();
    // 公開する値は読み取り専用にすることで、外部からの変更を防ぐ
    public IReadOnlyList<SampleCard> Hands => _hands;
    public IReadOnlyList<SampleCard> DrawPile => _drawPile;
    public CardsModel()
    {
        createDrawPile(10);
        createHands(3);
    }

    public void PlayCard(int index)
    {
        if (index >= 0 && index < _hands.Count)
        {
            moveCardHands2DrawPile(index);
        }

    }
    public void DrawCard()
    {
        if (_drawPile.Count != 0)
        {
            moveCardDrawPile2Hands();
        }
    }
    private void createDrawPile(int count)
    {
        for (int i = 0; i < count; i++)
        {
            var name = "Card_" + i.ToString();
            var num = i;

            var card = new SampleCard
            {
                Name = name,
                value = num
            };
            _drawPile.Add(card);
        }
    }
    private void createHands(int count)
    {
        for (int i = 0; i < count; i++)
        {
            moveCardDrawPile2Hands();
        }
    }
    private void moveCardHands2DrawPile(int index)
    {
        var pop = _hands[index];
        _hands.RemoveAt(index);

        _drawPile.Add(pop);
    }
    private void moveCardDrawPile2Hands()
    {
        var pop = _drawPile[0];
        _drawPile.RemoveAt(0);

        _hands.Add(pop);
    }
}

public class SampleCard
{
    public string Name { get; set; }
    public int value { get; set; }
}
SampleView.cs
using UnityEngine;
using UnityEngine.UI;

public class SampleView : MonoBehaviour
{
    public Button DrawCardButton;
    public HandsView Hands;
    public DrawPileView DrawPile;
}
HandsView.cs
using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class HandsView : MonoBehaviour
{
    [SerializeField] private Canvas canvas;
    [SerializeField] private GameObject cardPrefab;
    public List<GameObject> Buttons = new List<GameObject>();

    private void deleteAllButton()
    {
        foreach (var button in Buttons)
        {
            Destroy(button);
        }
        Buttons.Clear();
    }
    public void UpdateView(IReadOnlyList<SampleCard> Cards)
    {
        // 一旦すべて削除する
        deleteAllButton();

        for (int i = 0; i < Cards.Count; i++)
        {
            GameObject newSelectorObj = Instantiate(cardPrefab, canvas.transform);
            Buttons.Add(newSelectorObj);

            Buttons[i].GetComponentInChildren<TextMeshProUGUI>().text = Cards[i].Name;

            RectTransform rect = newSelectorObj.GetComponent<RectTransform>();
            var offsetX = -400;
            var offsetY = -230;
            rect.anchoredPosition = new Vector2(offsetX, i * 50 + offsetY);
        }
    }
}
DrawPileView.cs
using System.Collections.Generic;
using TMPro;
using UnityEngine;

// HandsView.cs と同じであり、異なるのは画面に表示するための Offsetのみ。
// 共通化したいがサンプルなので良いかという感じ。
public class DrawPileView : MonoBehaviour
{
    [SerializeField] private Canvas canvas;
    [SerializeField] private GameObject cardPrefab;
    public List<GameObject> Buttons = new List<GameObject>();

    private void deleteAllButton()
    {
        foreach (var button in Buttons)
        {
            Destroy(button);
        }
        Buttons.Clear();
    }
    public void UpdateView(IReadOnlyList<SampleCard> Cards)
    {
        // 一旦すべて削除する
        deleteAllButton();

        for (int i = 0; i < Cards.Count; i++)
        {
            GameObject newSelectorObj = Instantiate(cardPrefab, canvas.transform);
            Buttons.Add(newSelectorObj);

            Buttons[i].GetComponentInChildren<TextMeshProUGUI>().text = Cards[i].Name;

            RectTransform rect = newSelectorObj.GetComponent<RectTransform>();
            var offsetX = -250;
            var offsetY = -230;
            rect.anchoredPosition = new Vector2(offsetX, i * 50 + offsetY);
        }
    }
}

GUI のインスペクター上で必要なデータの紐づけをする

説明するより、見てもらった方が分かりやすそうなので、以下に画像をぺたぺた張りつけました。


まとめ

  • いきなり全部を作ろうとすると混乱することが予想されるため、まずシンプルなプログラムを作った
    • 考えがまとまってきたので、良かった
  • 良い感じ?に C# のプログラムを作成することができた
    • 毎回ボタンを削除する処理等、改善点はあるように思うが、基本的には問題ないと思っている
    • 自分はまだまだ初心者であり、本記事を読んでいただいた方からのご意見・改善案等は大歓迎なので、是非お願いします

Discussion