💻

【Unity】 VContainerとMessagePipeによるショップ機能実装について

2024/03/17に公開

VContainerによるシーンを跨ぐ変数維持

VContainerとMessagePipeを用いて、Unityで開発中のシューティングゲームRAYSERのショップ機能を実装してみました。その際にシーンを跨いで値を保持する必要があったため、VContainerのRootLifetimeScopeを利用してみました。

Score(お金)管理について

今回のゲームではScoreと引き換えにサブウェポンを購入する仕様にしてみました。ゲームで獲得したスコアをお金の代わりにして、ショップでサブウェポンを購入し、ゲームの攻略に役立てるようにできる仕様にしてみました。

またスコアに関しては、今回はMessagePipeではなく、MessageBrokerを用いています。理由としてはMessagePipe利用以前からRAYSERの敵機撃破のスコア獲得などで利用していたことと、不特定多数発生する敵機に対してはMessageBrokerの方が実装がしやすかったためです。
MessagePipeについてはスコアの後に説明するショップの購入関連とアイテム管理で使用しています。

RootLifetimeScopeの定義

今回スコアを複数のシーンから参照を実現するためにVContainerのRootLifetimeScopeでスコア用のクラスをDIで読み込むようにしました。
RootLifetimeScopeでスコアのデーター(ScoreData)を管理することでシーンが変わってもScoreDataの値を維持することができます。その他ゲームのBGM音量などの情報もRootLifetimeScopeで登録することで、これらもシーンで共通で活用することができるようになりました。その他所持しているアイテムを管理するItemAcquisitionクラスも登録しています。
シーンごとにDIを行うクラスは、各シーン毎に設置しているLifeScopeで定義を行っています。これによって特定のシーンでのみ使用したいクラスを挿入することができるようになるので、たとえばチュートリアルのシーンではゲーム本編から独立したスコアの情報を別で持たせて定義するなどもできると思います。(こちらはまだ未実装なので、実装後にまとめてみようと思います。)

RootLifetimeScope.cs
using _RAYSER.Scripts.Item;
using _RAYSER.Scripts.Score;
using _RAYSER.Scripts.SubWeapon;
using BGM.Volume;
using MessagePipe;
using VContainer;
using VContainer.Unity;

namespace _RAYSER.Scripts.VContainer
{
    public class RootLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);

            // 子のLifetimeScopeに同じVolumeDataとScoreDataを引き渡す
            builder.Register<VolumeData>(Lifetime.Singleton);
            builder.Register<ScoreData>(Lifetime.Singleton);

            // MessagePipeの設定
            var messagePipeOptions = builder.RegisterMessagePipe();
            builder.RegisterMessageBroker<ItemPurchaseSignal>(messagePipeOptions);
            builder.RegisterMessageBroker<SubweaponMoveDirection>(messagePipeOptions);
            builder.RegisterMessageBroker<ItemData>(messagePipeOptions);

            // ItemAcquisitionをシングルトンとして登録
            builder.Register<ItemAcquisition>(Lifetime.Singleton);

            // SubWeaponMountedをシングルトンとして登録
            builder.Register<SubWeaponMounted>(Lifetime.Singleton);
        }
    }
}

ScoreSceneTitleLifetimeScope

スコアは各シーン毎にLifetimeScopeを定義しました。LifetimeScopeでは主にスコアを表示するための処理(ScoreService)、UIへの値の受け渡しをするプレゼンター(ScoreDataPresenter)、View(ScoreScreen)への表示を定義しています。VContainerを用いることでこのあたりの役割を分断させて実装することができるので、MonoBehaviourへの依存を最小限にすることができました。

ScoreSceneTitleLifetimeScope.cs
using _RAYSER.Scripts.Score;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace Score
{
    /// <summary>
    /// タイトルのスコアのライフタイムスコープ
    /// タイトルシーンのGameObjectにアタッチして使用する
    /// </summary>
    public class ScoreSceneTitleLifetimeScope : LifetimeScope
    {
        [SerializeField] private ScoreScreen _scoreScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);

            builder.Register<ScoreService>(Lifetime.Singleton);
            builder.Register<ScoreDataPresenter>(Lifetime.Singleton);
            builder.RegisterEntryPoint<ScoreDataPresenter>();
            builder.RegisterComponent(_scoreScreen);
        }
    }
}

ScoreSceneGameLifetimeScope

こちらはゲームシーンで使用するLifetimeScopeです。ほとんどタイトルシーンのものと同じですが、後々処理を変えたりすることも想定して、分けて実装しました。

ScoreSceneGameLifetimeScope.cs
using _RAYSER.Scripts.Score;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace Score
{
    /// <summary>
    /// ゲームシーンのスコアのライフタイムスコープ
    /// ゲームシーンのGameObjectにアタッチして使用する
    /// </summary>
    public class ScoreSceneGameLifetimeScope : LifetimeScope
    {
        [SerializeField] private ScoreScreen _scoreScreen;

        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);

            builder.Register<ScoreService>(Lifetime.Singleton);
            builder.Register<ScoreDataPresenter>(Lifetime.Singleton);
            builder.RegisterEntryPoint<ScoreDataPresenter>();
            builder.RegisterComponent(_scoreScreen);
        }
    }
}

ScoreData

ScoreDataはスコアの実体となります。こちらはシーンを跨っても保持されます。また後述するショップのボタンと連動するためにスコア更新のイベントを持たせています。こちらスコアが変動する毎にイベントが発生するので、例えばスコアの値に応じて、ショップのアイテムを購入ボタンを活性化させたり反対に非活性化することもできます。

ScoreData.cs
using System;

namespace _RAYSER.Scripts.Score
{
    /// <summary>
    /// スコア管理クラス
    /// </summary>
    public class ScoreData
    {
        private int _score;

        /// <summary>
        /// スコア更新イベント
        /// </summary>
        public event Action<int> OnScoreChanged;

        public int GetScore()
        {
            return _score;
        }

        public void SetScore(int score)
        {
            if (_score != score)
            {
                _score = score;
                OnScoreChanged?.Invoke(_score);
            }
        }
    }
}

ScoreDataPresenter

スコアの値をViewに受け渡しをするPresenterです。スコアの値が変わった時にスコアの加算処理を実行するのは後述するScoreServiceクラスの方で実施していて、こちらではスコアの購読のみをおこなって担っています。またスコアの購読はMessageBrokerを使用しています。こちらはゲームシーンなどでMessageBrokerを用いてスコアの加算処理などをしていたため、それを継続して利用しています。

ScoreDataPresenter.cs
using System;
using Event.Signal;
using UniRx;
using VContainer.Unity;

namespace _RAYSER.Scripts.Score
{
    public class ScoreDataPresenter : IStartable, IDisposable
    {
        readonly ScoreService scoreService;
        readonly CompositeDisposable disposable = new CompositeDisposable();

        public ScoreDataPresenter(ScoreService scoreService)
        {
            this.scoreService = scoreService;
        }

        void IStartable.Start()
        {
            scoreService.ShowScore();

            MessageBroker.Default.Receive<ScoreAccumulation>()
                .Subscribe(x => this.ScoreUICalculateRefreshUI(x.Score))
                .AddTo(disposable);
        }

        private void ScoreUICalculateRefreshUI(int score)
        {
            scoreService.AddScore(score);
            scoreService.ShowScore();
        }

        public void Dispose()
        {
            disposable.Dispose();
        }
    }
}

ScoreService

スコアの加算・表示の実際の処理を実際に実行するクラスになります。

ScoreService.cs
using Score;
using VContainer;

namespace _RAYSER.Scripts.Score
{
    public class ScoreService
    {
        private ScoreData scoreData;
        private ScoreScreen scoreScreen;

        [Inject]
        public void Construct(ScoreData scoreData, ScoreScreen scoreScreen)
        {
            this.scoreData = scoreData;
            this.scoreScreen = scoreScreen;
        }

        /// <summary>
        /// スコア加算処理
        /// </summary>
        /// <param name="score">スコア</param>
        public void AddScore(int score)
        {
            scoreData.SetScore(scoreData.GetScore() + score);
        }

        /// <summary>
        /// スコア表示処理
        /// </summary>
        public void ShowScore()
        {
            scoreScreen.ShowScore(scoreData.GetScore());
        }
    }
}

ScoreScreen

Viewを定義するクラスです。今回スコア関連だとMonoBehaviourを使用しているのはこちらのみになります。

ScoreScreen.cs
using TMPro;
using UnityEngine;

namespace _RAYSER.Scripts.Score
{
    /// <summary>
    /// スコアのViewクラス
    /// </summary>
    public class ScoreScreen : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI scoreUI;

        public void ShowScore(int score)
        {
            scoreUI.text = score.ToString();
        }
    }
}

アイテム管理について

次は実際のアイテムを管理を行う処理を実装してみました。
アイテム購入処理はタイトル画面からカスタマイズのウィンドウを表示、その後ショップのモーダルを表示して、ショップのモーダルの中に購入可能なアイテムの購入ボタンを設置しています。購入ボタンを押下すると、購入確認ダイアログが表示されて、購入ボタンを押下するとアイテム購入処理が実行されます。

アイテムのLifetimeScope

アイテムのLifetimeScopeのItemLifetimeScopeでは、アイテム処理に関する処理を定義しています。
主に以下のようなものを定義しています。

ItemLifetimeScopeでアタッチするもの

  • ItemList
    アイテムリストScriptableObjectです。ショップに並べるアイテムを定義しています
  • ItemBuyButton
    アイテム購入ボタンのPrefabです。
  • ItemModal
    ショップに並べるアイテムを表示するモーダル処理のクラスです
    モーダルを表示するためのTranformも併せて定義しています
  • ItemDialog
    アイテム購入確認ダイアログを表示する処理のクラスです
    ダイアログを表示するためのTranformも併せて定義しています

MessagePipe

  • ItemPurchaseSignal
    • Publisher
  • DialogOpenSignal
    • Publisher
  • DialogCloseSignal
    • Publisher
    • Subscriber

ItemLifetimeScope

アイテムのライフタイムスコープです、こちらでアイテム表示モーダル、アイテム購入ボタン、アイテム購入確認ダイアログ

ItemLifetimeScope.cs
using _RAYSER.Scripts.Score;
using _RAYSER.Scripts.UI.Dialog;
using _RAYSER.Scripts.UI.Modal;
using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace _RAYSER.Scripts.Item
{
    /// <summary>
    /// アイテムライフタイムスコープクラス
    /// </summary>
    public class ItemLifetimeScope : LifetimeScope
    {
        [SerializeField] private ItemList itemList;
        [SerializeField] private ItemBuyButton itemButtonPrefab;
        [SerializeField] private Transform itemModalContentTransform;
        [SerializeField] private ItemModal itemModal;
        [SerializeField] private ItemDialog itemDialog;
        [SerializeField] private Transform itemModalTransform;

        private ItemDialog _itemDialogInstance;

        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);

            var options = builder.RegisterMessagePipe();
            builder.RegisterMessageBroker<DialogOpenSignal>(options);
            builder.RegisterMessageBroker<DialogCloseSignal>(options);

            if (itemList != null)
            {
                builder.RegisterInstance(itemList);
            }

            builder.RegisterBuildCallback(container =>
            {
                _itemDialogInstance = Instantiate(itemDialog, itemModalTransform);
                var itemPurchaseSignalPublisher = container.Resolve<IPublisher<ItemPurchaseSignal>>();
                var dialogCloseSignalPublisher = container.Resolve<IPublisher<DialogCloseSignal>>();
                var dialogOpenSignalSubscriber = container.Resolve<ISubscriber<DialogOpenSignal>>();
                var dialogCloaseSignalSubscriber = container.Resolve<ISubscriber<DialogCloseSignal>>();
                _itemDialogInstance.Setup(
                    itemPurchaseSignalPublisher,
                    dialogCloseSignalPublisher,
                    dialogOpenSignalSubscriber,
                    dialogCloaseSignalSubscriber);

                var scoreData = container.Resolve<ScoreData>();
                var itemAcquisition = container.Resolve<ItemAcquisition>();
                foreach (var item in itemList.items)
                {
                    var itemBuyButton = Instantiate(itemButtonPrefab, itemModalContentTransform);
                    var publisher = container.Resolve<IPublisher<DialogOpenSignal>>();
                    itemBuyButton.Setup(item, publisher, scoreData, _itemDialogInstance, itemAcquisition);
                }

                itemModal.Setup(_itemDialogInstance);
            });
        }
    }
}

モーダルについて

ゲーム中で購入可能なアイテム一覧を表示するモーダルを実現するために、モーダルの定義、アイテム購入ボタンの定義、アイテム一覧の定義を以下のクラスで実現しています。

IModal

モーダルのインターフェース

IModal.cs
using Cysharp.Threading.Tasks;

namespace _RAYSER.Scripts.UI.Modal
{
    /// <summary>
    /// モーダルインターフェース
    /// </summary>
    public interface IModal {
        /// <summary>
        /// モーダル表示
        /// </summary>
        UniTask Show();

        /// <summary>
        /// モーダル非表示
        /// </summary>
        UniTask Hide();

        /// <summary>
        /// 表示状態
        /// </summary>
        bool IsActive { get; }
    }
}

ItemModal

アイテムの一覧を表示するためのモーダルの制御用クラス

ItemModal.cs
using System;
using System.Threading;
using _RAYSER.Scripts.Item;
using _RAYSER.Scripts.UI.Dialog;
using Cysharp.Threading.Tasks;
using Event.Signal;
using UniRx;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace _RAYSER.Scripts.UI.Modal
{
    /// <summary>
    /// ItemModal固有の表示・非表示処理
    /// </summary>
    public class ItemModal : MonoBehaviour, IModal
    {
        public event Action<bool> OnModalStateChanged;

        UIActiveSetter _uiActiveSetter = new UIActiveSetter();
        private IWindowUI _iuiImplementation;

        UIEffect _uiEffect = new UIEffect();
        private IUIEffect _ieffectImplementation;

        private CancellationTokenSource cts = new CancellationTokenSource();

        private IDisposable gamePadCancelSubscription;

        [SerializeField] private ItemList itemList;
        [SerializeField] private ItemBuyButton itemButtonPrefab;
        [SerializeField] private Transform contentTransform;
        [SerializeField] private CanvasGroup contentCanvasGroup;
        [SerializeField] private Button _closeButton;
        [SerializeField] private ItemDialog itemDialog;

        /// <summary>
        /// 最初にフォーカスになるUIのボタン
        /// </summary>
        [SerializeField] private GameObject firstFocusUI;
        private void OnGamePadCancel()
        {
            if (gameObject.activeSelf) // モーダルが表示されているか確認
            {
                // モーダルを閉じる処理
                Hide().Forget();
            }
        }
        private void Awake()
        {
            MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = firstFocusUI });

            _closeButton.OnClickAsObservable().Subscribe(
                _ => { OnGamePadCancel(); }
            ).AddTo(this);

            gamePadCancelSubscription = MessageBroker.Default
                .Receive<GamePadCancelButtonPush>()
                .Subscribe(_ => OnGamePadCancel())
                .AddTo(this);
        }

        private void OnDestroy()
        {
            gamePadCancelSubscription?.Dispose();
        }

        private void OnItemDialogStateChanged(bool isDialogOpen)
        {
            if (isDialogOpen)
            {
                // ItemDialogが開いている間は、Closeボタンとゲームパッドのキャンセルボタンを無効にする
                _closeButton.interactable = false;
                gamePadCancelSubscription?.Dispose();
            }
            else
            {
                // ItemDialogが閉じたら、Closeボタンとゲームパッドのキャンセルボタンを有効にする
                _closeButton.interactable = true;
                gamePadCancelSubscription = MessageBroker.Default
                    .Receive<GamePadCancelButtonPush>()
                    .Subscribe(_ => OnGamePadCancel())
                    .AddTo(this);
                MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = firstFocusUI });
                EventSystem.current.SetSelectedGameObject(null);
                EventSystem.current.SetSelectedGameObject(firstFocusUI);
            }
        }

        public void Setup(ItemDialog itemDialog)
        {
            this.itemDialog = itemDialog;
            // ItemDialogの状態変更イベントを購読
            itemDialog.OnDialogStateChanged += OnItemDialogStateChanged;
        }

        public void SetActive(bool isActive)
        {
            gameObject.SetActive(isActive);
            // _uiActiveSetter.SetActive(gameObject, isActive);
        }

        public async UniTask Show()
        {
            try
            {
                OnModalStateChanged?.Invoke(true);
                SetActive(true);
                // InitializeUI();

                await _uiEffect.FadeIn(contentCanvasGroup, cts.Token);

                // ライセンスUI表示
                // await _uiEffect.FadeIn(windowCanvasGroup, cts.Token);
                // await _uiEffect.SizeDelta(windowRectTransform, new Vector2(_initialUISizeDelta.x, setDeltaMinY),
                //     setDeltaDuration, cts.Token);
                // await _uiEffect.SizeDelta(windowRectTransform,
                //     new Vector2(_initialUISizeDelta.x, _initialUISizeDelta.y), setDeltaDuration, cts.Token);

                // 見出しイメージ表示
                // await _uiEffect.FadeIn(headerImageCanvasGroup, cts.Token);

                // テキスト表示
                // await _uiEffect.FadeIn(contentCanvasGroup, cts.Token);

                // UI内ボタン表示
                // await _uiEffect.FadeIn(insideButtonsCanvasGroup, cts.Token);

                //初期選択ボタンの再指定
                MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = firstFocusUI });
                EventSystem.current.SetSelectedGameObject(null);
                EventSystem.current.SetSelectedGameObject(firstFocusUI);
            }
            catch (OperationCanceledException)
            {
                // キャンセルされた場合の処理
                Debug.Log("FadeIn Canceled");
            }
        }

        public async UniTask Hide()
        {
            // ItemModal固有の非表示処理
            OnModalStateChanged?.Invoke(false);
            this.gameObject.SetActive(false);
        }

        public bool IsActive
        {
            get { return this.gameObject.activeSelf; }
        }
    }
}

IModalable

モーダルを使用する箇所で定義するインターフェース

IModalable.cs
using Cysharp.Threading.Tasks;

namespace _RAYSER.Scripts.UI.Modal
{
    /// <summary>
    /// モーダル利用インターフェース
    /// </summary>
    public interface IModalable {
        UniTask ToggleModal(IModal modal);
    }
}

モーダルではアイテムの一覧を表示します。表示するアイテムの制御はScriptableObjectで定義したアイテム一覧とアイテムの情報を元に動的にアイテムのボタンを生成しています。

ItemData

アイテム情報を定義しているScriptableObject用クラスです
アイテムにはサブウェポンのインターフェース(ISubWeaponVisitor)をSerializeReferenceで定義しています。

こちらはItemDataの一つバルカンを定義したものになります。SubWeaponVisitorでは各サブウェポンのPrefab(弾)をItemDataのSerializeReferenceで定義できるようにしています。

ホーミング弾も同じように定義することで、ItemDataで定義されたScriptableObjectを増やしていくことでサブウェポンの実装ができるようになりました。(サブウェポンの実装方法については、次回の記事でまとめてみます。)

【2024/03/20追記】サブウェポンの実装方法は以下の記事にまとめてみました。
https://zenn.dev/cz_mirror/articles/f6b7a813a8afeb

ItemData.cs
using _RAYSER.Scripts.SubWeapon;
using _Vendor.baba_s.SubclassSelector;
using UnityEngine;

namespace _RAYSER.Scripts.Item
{
    /// <summary>
    /// アイテムデータークラス
    /// </summary>
    [CreateAssetMenu(fileName = "ItemData", menuName = "Items/New Item")]
    public class ItemData : ScriptableObject, IItem
    {
        public string itemName;
        public string itemDescription;
        public Sprite icon;
        public int _requiredScore;

        [SerializeField] private ItemType _itemType;

        public string name => itemName;
        public string description => itemDescription;
        public Sprite iconImage => icon;
        public ItemType itemType => _itemType;

        /// <summary>
        /// サブウェポンインターフェース
        /// </summary>
        [SerializeReference, SubclassSelector(true)]
        ISubWeaponVisitor SubWeaponVisitor;

        public int requiredScore
        {
            get { return _requiredScore; }
        }

        public ISubWeaponVisitor subWeaponVisitor
        {
            get { return SubWeaponVisitor; }
        }
    }
}

ItemList

ItemDataをリストとして定義するScriptableObjectです。こちらで定義したListをアイテムのモーダルで呼び出すことでショップのアイテム表示を実現しています。

ItemList.cs
using System.Collections.Generic;
using UnityEngine;

namespace _RAYSER.Scripts.Item
{
    /// <summary>
    /// アイテムリストScriptableObject
    /// </summary>
    [CreateAssetMenu(fileName = "ItemList", menuName = "Items/New Item List")]
    public class ItemList : ScriptableObject {
        public List<ItemData> items;
    }
}

ItemBuyButton

モーダル内に表示するアイテム購入ボタンを定義するClassです。ボタンはダイアログの開閉状態で有効無効を定義しています。またお金として使用するスコアの増減のイベントを元にボタンの有効・無効の切り替えも行っております。

ItemBuyButton.cs
using System;
using _RAYSER.Scripts.Score;
using _RAYSER.Scripts.UI;
using _RAYSER.Scripts.UI.Dialog;
using MessagePipe;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace _RAYSER.Scripts.Item
{
    /// <summary>
    /// カスタマイズ画面アイテム購入ボタンUIクラス
    /// </summary>
    public class ItemBuyButton : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI itemNameText;
        [SerializeField] private Image thumbnailImage;
        [SerializeField] private TextMeshProUGUI priceText;
        [SerializeField] private TextMeshProUGUI getText;
        [SerializeField] private Button button;

        private ItemData itemData;
        public int Id { get; set; }

        private IPublisher<DialogOpenSignal> _itemPurchasePublisher;
        private IDisposable _disposable;

        private ScoreData _scoreData;
        private ItemAcquisition _itemAcquisition;

        /// <summary>
        /// ダイアログが開いているかどうか
        /// </summary>
        private bool isDialogOpen = false;

        private void OnEnable()
        {
            MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = gameObject });
        }

        public void Setup(
            IItem item,
            IPublisher<DialogOpenSignal> itemPurchasePublisher,
            ScoreData scoreData,
            ItemDialog itemDialog,
            ItemAcquisition itemAcquisition
        )
        {
            _itemPurchasePublisher = itemPurchasePublisher;
            _itemAcquisition = itemAcquisition;

            if (item is ItemData data) // セーフキャストを使用
            {
                itemData = data;

                itemNameText.text = itemData.name;
                thumbnailImage.sprite = itemData.iconImage;
                priceText.text = itemData.requiredScore.ToString();

                // スコア変更時にボタンの状態を更新
                _scoreData = scoreData;
                _scoreData.OnScoreChanged += UpdateButtonState;
                UpdateButtonState(scoreData.GetScore());

                itemDialog.OnDialogStateChanged += dialogState =>
                {
                    isDialogOpen = dialogState;
                    UpdateButtonInteractable();
                };

                // 購入ボタン押下時処理
                button.onClick.AddListener(() => OnPurchase(itemData));
            }
            else
            {
                Debug.LogError("提供されたアイテムは ItemData 型ではありません。");
            }
        }

        /// <summary>
        /// 購入ボタン押下時処理
        /// </summary>
        /// <param name="itemData"></param>
        public void OnPurchase(ItemData itemData)
        {
            if (itemData != null)
            {
                _itemPurchasePublisher.Publish(new DialogOpenSignal(itemData));
            }
            else
            {
                Debug.LogError("アイテムデータが設定されていません。");
            }
        }

        /// <summary>
        /// ボタンの有効・無効を更新
        /// </summary>
        private void UpdateButtonInteractable()
        {
            if (button == null)
            {
                Debug.LogError("Button is null.");
                return;
            }

            if (_scoreData == null)
            {
                Debug.LogError("ScoreData is null.");
                return;
            }

            if (itemData == null)
            {
                Debug.LogError("ItemData is null.");
                return;
            }

            bool alreadyPurchased = _itemAcquisition.HasItem(itemData);
            button.interactable =
                !isDialogOpen && !alreadyPurchased && (_scoreData.GetScore() >= itemData.requiredScore);

            // 購入済みの場合は購入済みテキストを表示
            getText.gameObject.SetActive(alreadyPurchased);
        }

        /// <summary>
        /// スコアの更新メソッドでボタンの状態を更新
        /// </summary>
        /// <param name="currentScore"></param>
        private void UpdateButtonState(int currentScore)
        {
            UpdateButtonInteractable();
        }

        private void OnDestroy()
        {
            if (_scoreData != null)
            {
                _scoreData.OnScoreChanged -= UpdateButtonState;
            }
        }
    }
}

確認ダイアログの実装について

モーダルに表示したアイテム購入ボタンを押下した後に、購入確認ダイアログを表示しています。

IDialog

ダイアログ表示用のインターフェースを定義します

IDialog.cs
namespace _RAYSER.Scripts.UI.Dialog
{
    public interface IDialog
    {
        
    }
}

ItemDialog

アイテム購入・キャンセル処理を定義するClass

ItemDialog.cs
using System;
using _RAYSER.Scripts.Item;
using Event.Signal;
using MessagePipe;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace _RAYSER.Scripts.UI.Dialog
{
    /// <summary>
    /// アイテム購入・キャンセルダイアログクラス
    /// </summary>
    public class ItemDialog : MonoBehaviour, IDisposable
    {
        /// <summary>
        /// 開閉状態変更イベント
        /// </summary>
        public event Action<bool> OnDialogStateChanged;

        private IPublisher<ItemPurchaseSignal> _itemPurchaseProcessingPublisher;
        private IPublisher<DialogCloseSignal> _dialogCloseSignalPublisher;
        private ISubscriber<DialogOpenSignal> _dialogOpenSignalSubscriber;
        private ISubscriber<DialogCloseSignal> _dialogCloseSignalSubscriber;
        private IDisposable _dialogOpenSignalDisposable;
        private IDisposable _dialogCloseSignalDisposable;
        private IDisposable gamePadCancelSubscription;
        private ItemData _currentItemData;

        [SerializeField] private TextMeshProUGUI itemNameText;
        [SerializeField] private TextMeshProUGUI itemPriceText;
        [SerializeField] private Image itemImage;

        [SerializeField] private Button purchaseButton;
        [SerializeField] private Button closeButton;

        /// <summary>
        /// 最初にフォーカスになるUIのボタン
        /// </summary>
        [SerializeField] private GameObject firstFocusUI;

        private void OnGamePadCancel()
        {
            // ダイアログが表示されている場合にのみ、ダイアログを閉じる
            if (gameObject.activeSelf)
            {
                _dialogCloseSignalPublisher.Publish(new DialogCloseSignal());
            }
        }

        public void Setup(
            IPublisher<ItemPurchaseSignal> itemPurchaseSignalPublisher,
            IPublisher<DialogCloseSignal> dialogCloseSignalPublisher,
            ISubscriber<DialogOpenSignal> openDialogsubscriber,
            ISubscriber<DialogCloseSignal> closeDialogsubscriber)
        {
            _itemPurchaseProcessingPublisher = itemPurchaseSignalPublisher;
            _dialogCloseSignalPublisher = dialogCloseSignalPublisher;
            _dialogOpenSignalSubscriber = openDialogsubscriber;
            _dialogCloseSignalSubscriber = closeDialogsubscriber;

            var d = DisposableBag.CreateBuilder();



            gameObject.SetActive(false);

            purchaseButton.onClick.AddListener(() =>
            {
                if (_currentItemData != null)
                {
                    _itemPurchaseProcessingPublisher.Publish(new ItemPurchaseSignal(_currentItemData));
                    _dialogCloseSignalPublisher.Publish(new DialogCloseSignal());
                }
            });


            closeButton.onClick.AddListener(() => { _dialogCloseSignalPublisher.Publish(new DialogCloseSignal()); });

            _dialogOpenSignalSubscriber
                .Subscribe(signal => { show(signal.Item); }).AddTo(d);
            _dialogOpenSignalDisposable = d.Build();

            _dialogCloseSignalSubscriber
                .Subscribe(signal => { hide(); })
                .AddTo(d);
            _dialogCloseSignalDisposable = d.Build();

            // ゲームパッドのキャンセルボタンの購読を開始
            gamePadCancelSubscription = MessageBroker.Default
                .Receive<GamePadCancelButtonPush>()
                .Subscribe(_ => OnGamePadCancel())
                .AddTo(this);
        }

        /// <summary>
        /// 購読解除
        /// </summary>
        public void Dispose()
        {
            _dialogOpenSignalDisposable?.Dispose();
            _dialogCloseSignalDisposable?.Dispose();
        }

        private void show(IItem item)
        {
            OnDialogStateChanged?.Invoke(true);

            _currentItemData = item as ItemData; // itemがItemData型であれば、それを_currentItemDataに割り当てる
            gameObject.SetActive(true);
            itemNameText.text = item.name;
            itemPriceText.text = item.requiredScore.ToString();
            itemImage.sprite = item.iconImage;

            MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = purchaseButton.gameObject });
            MessageBroker.Default.Publish(new UISelectorSignal { forcusUIGameObject = closeButton.gameObject });
            EventSystem.current.SetSelectedGameObject(null);
            EventSystem.current.SetSelectedGameObject(firstFocusUI);
        }

        private void hide()
        {
            OnDialogStateChanged?.Invoke(false);

            gameObject.SetActive(false);
        }

        private void OnDestroy()
        {
            Dispose();
        }
    }
}

DialogOpenSignal

MessagePipeで使用するダイアログオープンのシグナル用構造体です

DialogOpenSignal.cs
using _RAYSER.Scripts.Item;

namespace _RAYSER.Scripts.UI.Dialog
{
    /// <summary>
    /// ダイアログオープンシグナルクラス
    /// </summary>
    public struct DialogOpenSignal
    {
        public IItem Item { get; private set; }

        public DialogOpenSignal(IItem item)
        {
            Item = item;
        }
    }
}

DialogCloseSignal

MessagePipeで使用するダイアログクローズのシグナル用構造体です

namespace _RAYSER.Scripts.UI.Dialog
{
    /// <summary>
    /// ダイアログクローズシグナルクラス
    /// </summary>
    public struct DialogCloseSignal
    {

    }
}

最後

もしよかったら、いいね、コメントをお願いいたします。

Discussion