🎮

【Unity】Visitorパターンを用いた条件分岐を使わないサブウェポンの切り替え

2024/03/20に公開
9

Visitorパターンをサブウェポン実装に使用した理由について

現在Unityで開発中のRAYSERという3Dシューティングゲームに遊び方の幅を持たせるためにサブウェポンを実装しました。サブウェポンの実装にあたり、なるべく条件分岐を使わない方法を検討していた際に、Unity開発者ギルドでVisitorパターンを用いることで問題を解消したという書き込みを見つけて、試したところ今回の実装にマッチしたため、手探りですがVisitorパターンを用いてサブウェポンを実装しました。
https://www.nicovideo.jp/watch/sm43301492

Visitorパターンでサブウェポン毎の条件分岐をなくしたい

今回RAYSERでVisitorパターンによるサブウェポン実装について、具体的に使用した箇所はサブウェポンのClassに対して実装を行いました。サブウェポンはバルカン、ホーミングのように複数のサブウェポンが存在しますが、それぞれのサブウェポンで処理が異なります。サブウェポンはサブウェポンの発射ボタンを押下することで発動しますが、サブウェポンの発射処理に対して、ifやswitchで条件分岐をしてしまうと、サブウェポンの種類が増えるごとに、サブウェポンの発射処理のソースコードが肥大化してしまいメンテナンスが非常にしにくくなります。
そのため、各サブウェポンの処理をどうしても各Classで実装を行い、サブウェポン発射ボタンの処理では、設定されているサブウェポンの発射の処理のみを発信するのみにするように設計をしてみました。
サブウェポンの購入処理については、前回の記事(VContainerとMessagePipeによるショップ機能実装について)を読んでいただければと思います。
https://zenn.dev/cz_mirror/articles/aff157cddd18cc

ソースコード

RAYSERのソースコードは以下の場所で公開しております
https://github.com/Czmirror/RAYSER_Script

クラス図

RAYSERのVistiorパターン実装方法について

RootLifetimeScopeの定義

サブウェポンは取得情報を複数のシーンから参照するためにRootLifetimeScopeで
サブウェポン取得情報クラスItemAcquisitionをDIで読み込むようにしました。
RootLifetimeScopeでItemAcquisitionクラスの情報をDIするのは、ショップでサブウェポンを購入した際の情報を保持して、ゲームシーンで使用できるようにするためです。

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);
        }
    }
}

サブウェポン用のLifetimeScope

ゲームシーンでサブウェポン用のLifetimeScopeを定義します。

  • InputSystemの設定(PlayerController)
  • サブウェポン発射処理のクラスの定義(SubWeaponTurret)
  • サブウェポンUI表示処理の定義(SubWeaponsUI、SubWeaponIndividual)
SubWeaponLifetimeScope.cs
using _RAYSER.Scripts.Item;
using InputSystem;
using MessagePipe;
using UI.Game;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポン用LifetimeScope
    /// </summary>
    public class SubWeaponLifetimeScope : LifetimeScope
    {
        [SerializeField] private PlayerController playerController;
        [SerializeField] private SubWeaponTurret subWeaponTurret;

        [SerializeField] private SubWeaponsUI subWeaponsUI;

        [SerializeField] private Transform subWeaponIndividualParent;
        [SerializeField] private SubWeaponIndividual subWeaponIndividualPrefab;

        protected override void Configure(IContainerBuilder builder)
        {
            builder.Register<PlayerInputSetupService>(Lifetime.Singleton);

            builder.RegisterBuildCallback(container =>
            {
                var setupService = container.Resolve<PlayerInputSetupService>();
                setupService.SetupPlayerController(playerController, container);

                var itemAcquisition = container.Resolve<ItemAcquisition>();

                var subweaponUseSignalSubscriber = container.Resolve<ISubscriber<SubweaponUseSignal>>();
                subWeaponTurret.Setup(subweaponUseSignalSubscriber, itemAcquisition);

                subWeaponsUI.Setup(itemAcquisition);
                var subweaponMoveDirectionSubscriber = container.Resolve<ISubscriber<SubweaponMoveDirection>>();

                foreach (var item in itemAcquisition.GetSubWeapon())
                {
                    if (item is ItemData data)
                    {
                        var subWeaponIndividual = Instantiate(subWeaponIndividualPrefab, subWeaponIndividualParent);
                        subWeaponIndividual.Setup(itemAcquisition, subweaponMoveDirectionSubscriber, data);
                    }
                }
            });
        }
    }
}

InputSystemの設定

InputSystemでゲームパッドからサブウェポンの発射処理、サブウェポンの切り替え処理を設定します。サブウェポンの発射処理と切り替え処理はMessagePipeを用いて、シグナルを送信できるようにしました。ゲームパッドのボタンを押下すると、MessagePipeのPublishからSubweaponUseSignalが発行され、SubWeaponTurretがSubweaponUseSignalを受信することでサブウェポン発射処理が実行されるようになります。

PlayerController.cs
using _RAYSER.Scripts.Bomb;
using _RAYSER.Scripts.Item;
using _RAYSER.Scripts.SubWeapon;
using Event;
using Event.Signal;
using MessagePipe;
using PlayerMove;
using Turret;
using UI.Game;
using UniRx;
using UnityEngine;
using UnityEngine.InputSystem;

namespace InputSystem
{
    /// <summary>
    /// InputSystem自機コントローラー用クラス
    /// </summary>
    public class PlayerController : MonoBehaviour
    {
        private PlayerInputActions _playerInputActions;
        [SerializeField] private PlayerMoveCore _playerMove;
        [SerializeField] private PlayerLaserTurret _turret;

        private IPublisher<SubweaponMoveDirection> _subweaponMoveDirectionPublisher;
        private IPublisher<SubweaponUseSignal> _subweaponUseSignalPublisher;
        private IPublisher<BombUseSignal> _bombUseSignalPublisher;
        private ItemAcquisition _itemAcquisition;

        public void Setup(
            IPublisher<SubweaponMoveDirection> subweaponMoveDirectionPublisher,
            IPublisher<SubweaponUseSignal> subweaponUseSignalPublisher,
            IPublisher<BombUseSignal> bombUseSignalPublisher,
            ItemAcquisition itemAcquisition
            )
        {
            _subweaponMoveDirectionPublisher = subweaponMoveDirectionPublisher;
            _subweaponUseSignalPublisher = subweaponUseSignalPublisher;
            _bombUseSignalPublisher = bombUseSignalPublisher;
            _itemAcquisition = itemAcquisition;
        }

        private void Awake()
        {
            _playerInputActions = new PlayerInputActions();

            MessageBroker.Default.Receive<GameStartEventEnd>()
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Enable();
                    _playerInputActions.UI.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<Stage2Start>()
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Enable();
                    _playerInputActions.UI.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<Stage3Start>()
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Enable();
                    _playerInputActions.UI.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<Stage2IntervalStart>()
                .Where(x => x._talk == TalkEnum.TalkStart)
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<Stage3IntervalStart>()
                .Where(x => x._talk == TalkEnum.TalkStart)
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<Gameover>()
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Disable();
                    _playerInputActions.UI.Enable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<GameClear>()
                .Where(x=>x._talk == TalkEnum.TalkStart)
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Disable();
                })
                .AddTo(this);

            MessageBroker.Default.Receive<GameClear>()
                .Where(x=>x._talk == TalkEnum.TalkEnd)
                .Subscribe(x =>
                {
                    _playerInputActions.Player.Disable();
                    _playerInputActions.UI.Enable();
                })
                .AddTo(this);

        }

        private void OnEnable()
        {
            _playerInputActions.Player.Fire.performed += OnFire;
            _playerInputActions.Player.Fire.canceled += OnFireStop;
            _playerInputActions.Player.Move.performed += OnMove;
            _playerInputActions.Player.Move.canceled += OnMoveStop;
            _playerInputActions.Player.SubWeapon.performed += OnSubWeaponUse;
            _playerInputActions.Player.SubWeapon.canceled += OnSubWeaponStop;
            _playerInputActions.Player.Bomb.performed += OnBombUse;
            _playerInputActions.Player.Bomb.canceled += OnBombStop;
            _playerInputActions.Player.WeaponSwitchRight.performed += OnWeaponSwitchRight;
            _playerInputActions.Player.WeaponSwitchLeft.performed += OnWeaponSwitchLeft;
        }

        private void OnDisable()
        {
            _playerInputActions.Player.Fire.performed -= OnFire;
            _playerInputActions.Player.Fire.canceled -= OnFireStop;
            _playerInputActions.Player.Move.performed -= OnMove;
            _playerInputActions.Player.Move.canceled -= OnMoveStop;
            _playerInputActions.Player.SubWeapon.performed -= OnSubWeaponUse;
            _playerInputActions.Player.SubWeapon.canceled -= OnSubWeaponStop;
            _playerInputActions.Player.Bomb.performed -= OnBombUse;
            _playerInputActions.Player.WeaponSwitchRight.performed -= OnWeaponSwitchRight;
            _playerInputActions.Player.WeaponSwitchLeft.performed -= OnWeaponSwitchLeft;
        }

        private void OnFire(InputAction.CallbackContext obj)
        {
            _turret.Fire();
        }

        private void OnFireStop(InputAction.CallbackContext obj)
        {
            _turret.FireStop();
        }

        private void OnMoveStop(InputAction.CallbackContext obj)
        {
            _playerMove.SetDirection(Vector2.zero);
        }

        private void OnMove(InputAction.CallbackContext obj)
        {
            var moveValue = obj.ReadValue<Vector2>();
            _playerMove.SetDirection(moveValue);
        }

        /// <summary>
        /// サブウェポンの使用
        /// </summary>
        /// <param name="context"></param>
        private void OnSubWeaponUse(InputAction.CallbackContext context)
        {
            var selectedSubWeapon = _itemAcquisition.GetSelectingSubWeapon();
            if (selectedSubWeapon == null)
            {
                Debug.LogError("Selected subweapon is null");
                return;
            }

            _subweaponUseSignalPublisher.Publish(new SubweaponUseSignal(SubweaponUseType.Use));
        }

        /// <summary>
        /// サブウェポンの使用停止
        /// </summary>
        /// <param name="context"></param>
        private void OnSubWeaponStop(InputAction.CallbackContext context)
        {
            _subweaponUseSignalPublisher.Publish(new SubweaponUseSignal(SubweaponUseType.Stop));
        }

        /**
         * ボムの使用
         */
        private void OnBombUse(InputAction.CallbackContext context)
        {
            Debug.Log("OnBombUse");
            _bombUseSignalPublisher.Publish(new BombUseSignal(BombUseType.Use));

        }

        /**
         * ボムの使用停止
         */
        private void OnBombStop(InputAction.CallbackContext context)
        {
            _bombUseSignalPublisher.Publish(new BombUseSignal(BombUseType.Stop));
        }

        /**
         * 次のサブウェポンの切り替え
         */
        private void OnWeaponSwitchRight(InputAction.CallbackContext context)
        {
            _subweaponMoveDirectionPublisher.Publish(SubweaponMoveDirection.Right);
        }

        /**
         * 前のサブウェポンの切り替え
         */
        private void OnWeaponSwitchLeft(InputAction.CallbackContext context)
        {
            _subweaponMoveDirectionPublisher.Publish(SubweaponMoveDirection.Left);
        }
    }
}

サブウェポン発射処理

InputSystemからサブウェポン発射・停止のシグナルを発信して、サブウェポン側で受信して発射・停止処理を実行します。

SubweaponUseSignal

サブウェポン発射のシグナル。MessagePipeで使用する想定。SubweaponUseTypeのenumで使用・停止の情報を持たせる。

SubweaponUseSignal.cs
using _RAYSER.Scripts.Item;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポン使用シグナル
    /// </summary>
    public class SubweaponUseSignal
    {
        public SubweaponUseType SubweaponUseType { get; }
        public SubweaponUseSignal(SubweaponUseType subweaponUseType)
        {
            SubweaponUseType = subweaponUseType;
        }
    }

    public enum SubweaponUseType
    {
        Use,
        Stop,
    }
}

SubWeaponTurret

サブウェポンの発射処理です、MessagePipeでSubweaponUseSignalを受信すると、設定中のサブウェポンのクラスから発射処理を実施します。ゲームパッドのボタンを押下中は継続できるようにUniTaskを用いて、StartFiringAsyncメソッドを実行して、ボタンを離すとStopFiringAsyncメソッドを実行してサブウェポンを停止しています。(使用・停止の判定はSubweaponUseTypeのenumの値で行っています。)
サブウェポンの発射処理はVisitorパターンを用いて、設定されたサブウェポンのClass毎(例 Vulkanクラスなど)に実行できるようにしてみました。

SubWeaponTurret.cs
using System;
using System.Collections.Generic;
using System.Threading;
using _RAYSER.Scripts.Item;
using Cysharp.Threading.Tasks;
using MessagePipe;
using UnityEngine;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポン発射処理
    /// </summary>
    public class SubWeaponTurret : MonoBehaviour, IDisposable
    {
        private ISubscriber<SubweaponUseSignal> _subweaponUseSignalSubscriber;
        private IDisposable _subweaponUseSignalSubscriberDisposable;
        private ItemAcquisition _itemAcquisition;
        private FireAction action;
        private CancellationTokenSource firingCancellationTokenSource;
        private float interval = 0.1f;
        private Dictionary<SubweaponUseType, Func<CancellationToken, UniTask>> subweaponActions;

        public void Setup(ISubscriber<SubweaponUseSignal> subweaponUseSignalSubscriber,
            ItemAcquisition itemAcquisition)
        {
            _subweaponUseSignalSubscriber = subweaponUseSignalSubscriber;
            var d = DisposableBag.CreateBuilder();
            _subweaponUseSignalSubscriber.Subscribe(OnSubweaponUseSignal).AddTo(d);
            _subweaponUseSignalSubscriberDisposable = d.Build();
            firingCancellationTokenSource = new CancellationTokenSource();
            _itemAcquisition = itemAcquisition;

            // SubweaponUseTypeに応じたActionの設定
            subweaponActions = new Dictionary<SubweaponUseType, Func<CancellationToken, UniTask>>
            {
                { SubweaponUseType.Use, StartFiringAsync },
                { SubweaponUseType.Stop, StopFiringAsync }
            };
        }

        private void OnSubweaponUseSignal(SubweaponUseSignal signal)
        {
            if (subweaponActions.TryGetValue(signal.SubweaponUseType, out var action))
            {
                action(firingCancellationTokenSource.Token).Forget();
            }
        }

        private async UniTask StartFiringAsync(CancellationToken cancellationToken)
        {
            firingCancellationTokenSource.Cancel();
            firingCancellationTokenSource = new CancellationTokenSource();

            // サブウェポン インスタンスのリセット
            var subWeapon = _itemAcquisition.GetSelectingSubWeapon();
            if (subWeapon == null)
            {
                return;
            }
            subWeapon.subWeaponVisitor.Reset();

            await FireContinuously(firingCancellationTokenSource.Token);
        }

        private async UniTask StopFiringAsync(CancellationToken cancellationToken)
        {
            firingCancellationTokenSource.Cancel();
            // 処理を終了
            await UniTask.Yield();
        }

        private async UniTask FireContinuously(CancellationToken cancellationToken)
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                var subWeapon = _itemAcquisition.GetSelectingSubWeapon();
                if (subWeapon == null)
                {
                    break;
                }

                // 発射位置を更新
                action = new FireAction(transform.position, transform.rotation);

                // サブウェポン発射処理
                action.Accept(subWeapon.subWeaponVisitor);

                // 次の発射まで待機
                await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: cancellationToken);
            }
        }

        public void Dispose()
        {
            _subweaponUseSignalSubscriberDisposable.Dispose();
            firingCancellationTokenSource.Cancel();
            firingCancellationTokenSource.Dispose();
        }

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

サブウェポン共通処理のElement化処理

FireAction

サブウェポンを発射するためのVisitorパターンのElementを定義しています。サブウェポンの位置情報を保持して、サブウェポンのVisitorの処理Visitメソッドを実行できるように定義しています。

FireAction.cs
using UnityEngine;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポンの発射処理(Element)
    /// </summary>
    public class FireAction
    {
        public Vector3 Position { get; private set; }
        public Quaternion Rotation { get; private set; }

        public FireAction(Vector3 position, Quaternion rotation)
        {
            Position = position;
            Rotation = rotation;
        }

        public void Accept(ISubWeaponVisitor visitor)
        {
            visitor.Visit(this);
        }
    }
}

サブウェポン毎のVisitorパターンの作成

ISubWeaponVisitor

サブウェポンの弾を発射するサブウェポンのVisitorとして定義しているインターフェースです。Visitorパターンを用いることで条件分岐を定義しないで、処理を各サブウェポンのクラスから呼び出しができるようになりました。サブウェポンが増えても、サブウェポンのクラスを増やすだけでサブウェポンの種類を増やすことができるようになります。

Visitが実際のサブウェポンの発射処理のためのメソッドになります。
一点サブウェポンの定義にScriptableObjectを使用したため、サブウェポンの発射間隔情報をリセットしないと、ScriptableObjectに各サブウェポンのlastFireTimeの情報が残ったままになってしまうため、苦肉の策でResetする処理を定義しています。

ISubWeaponVisitor.cs

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポンの発射処理(Visitor)
    /// </summary>
    public interface ISubWeaponVisitor
    {
        void Visit(FireAction action);
        void Reset();
    }
}

以下が実際の各サブウェポンのクラスの一部になります。インターフェースを用いて、サブウェポンとして設定された以下のクラスがサブウェポンの弾を生成する処理を実行します。

Vulcan.cs
using UnityEngine;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// バルカン : サブウェポンの一種 Visitor
    /// </summary>
    [System.Serializable]
    public class Vulcan : ISubWeaponVisitor
    {
        [SerializeField] private VulkanBullet bulletPrefab;
        private float firingInterval = 0.1f;
        private float lastFireTime = 0f;

        public void Visit(FireAction action)
        {
            if (Time.time < lastFireTime + firingInterval)
            {
                return;
            }

            GameObject.Instantiate(bulletPrefab, action.Position, action.Rotation);
            lastFireTime = Time.time;
        }

        public void Reset()
        {
            lastFireTime = 0f;
        }
    }
}

ThreeWay.cs
using UnityEngine;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// MultiWay : サブウェポンの一種 Visitor
    /// </summary>
    [System.Serializable]
    public class ThreeWay : ISubWeaponVisitor
    {
        [SerializeField] private VulkanBullet bulletPrefab;
        private float firingInterval = 1f;
        private float lastFireTime = 0f;

        // 角度の配列
        private float[] angles = new float[] { -15f, 0f, 15f };

        public void Visit(FireAction action)
        {
            if (Time.time < lastFireTime + firingInterval)
            {
                return;
            }

            // 各角度で弾丸を発射
            foreach (float angleY in angles)
            {
                // Y軸での回転
                foreach (float angleZ in angles)
                {
                    // Z軸での回転
                    Quaternion rotation = Quaternion.Euler(0, angleY, angleZ) * action.Rotation;
                    GameObject.Instantiate(bulletPrefab, action.Position, rotation);
                }
            }

            lastFireTime = Time.time;
        }

        public void Reset()
        {
            lastFireTime = 0f;
        }
    }
}

Homing.cs
using UnityEngine;

namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// ホーミング : サブウェポンの一種 Visitor
    /// </summary>
    [System.Serializable]
    public class Homing : ISubWeaponVisitor
    {
        [SerializeField] private HomingBullet bulletPrefab;
        private float firingInterval = 0.5f;
        private float lastFireTime = 0f;

        public void Visit(FireAction action)
        {
            if (Time.time < lastFireTime + firingInterval)
            {
                return;
            }

            GameObject.Instantiate(bulletPrefab, action.Position, action.Rotation);
            lastFireTime = Time.time;
        }

        public void Reset()
        {
            lastFireTime = 0f;
        }
    }
}

サブウェポン切り替え処理

サブウェポンの切り替えはサブウェポン保持情報クラス(ItemAcquisition)に対して、MessagePipeを受信した際に、切り替えの処理を実行できるようにしています。SubweaponMoveDirectionを受信したら、OnMoveDirectionReceivedメソッドを実行してサブウェポンを切り替えできるようにしています。サブウェポンの切り替えシグナルはゲームパッドのLボタン、Rボタンで発信するようにして、それぞれ前後にサブウェポンが切り替わるようにしています。

SubweaponMoveDirection

サブウェポン切り替えのシグナル

SubweaponMoveDirection.cs
namespace _RAYSER.Scripts.SubWeapon
{
    /// <summary>
    /// サブウェポン切り替え方向
    /// </summary>
    public enum SubweaponMoveDirection
    {
        Left,
        Right,
    }
}

ItemAcquisition

サブウェポンの保持情報を定義するクラスです。サブウェポンの保持情報、切り替え情報をもっています。

ItemAcquisition.cs
using System;
using System.Collections.Generic;
using System.Linq;
using _RAYSER.Scripts.Score;
using _RAYSER.Scripts.SubWeapon;
using Event.Signal;
using MessagePipe;
using UniRx;

namespace _RAYSER.Scripts.Item
{
    /// <summary>
    /// 所有アイテム管理クラス
    /// </summary>
    public class ItemAcquisition : IDisposable
    {
        /// <summary>
        /// 所有中アイテム
        /// </summary>
        private List<ItemData> _items = new List<ItemData>();

        private readonly CompositeDisposable _disposable = new CompositeDisposable();
        private readonly ISubscriber<ItemPurchaseSignal> _itemPurchaseSubscriber;
        private readonly ISubscriber<SubweaponMoveDirection> _moveDirectionSubscriber;
        private readonly IPublisher<CurrentSubWeaponIndex> _inputPublisher;
        private readonly ScoreData _scoreData;
        private readonly ItemAcquisition _itemAcquisition;
        private List<ItemData> _subWeapon = new List<ItemData>();
        private int _selectingSubWeaponIndex;

        /// <summary>
        /// 現在選択されているサブウェポン
        /// </summary>
        private ItemData _selectedSubWeapon;

        public ItemAcquisition(ISubscriber<ItemPurchaseSignal> itemPurchaseSubscriber,
            ISubscriber<SubweaponMoveDirection> moveDirectionSubscriber,
            ScoreData scoreData)
        {
            _itemPurchaseSubscriber = itemPurchaseSubscriber;
            _moveDirectionSubscriber = moveDirectionSubscriber;
            _scoreData = scoreData;

            _itemPurchaseSubscriber.Subscribe(OnItemPurchased).AddTo(_disposable);
            _moveDirectionSubscriber.Subscribe(OnMoveDirectionReceived).AddTo(_disposable);
        }

        /**
         * アイテム購入処理
         */
        private void OnItemPurchased(ItemPurchaseSignal signal)
        {
            var itemData = signal.Item as ItemData;
            if (itemData == null)
            {
                // signal.Item が ItemData 型でない場合、処理を中断
                return;
            }

            if (_scoreData.GetScore() >= itemData.requiredScore)
            {
                // スコアが十分な場合、アイテムをリストに追加してスコアを消費させる
                if (HasItem(itemData) == false)
                {
                    _items.Add(itemData);
                    OnItemAdded?.Invoke(itemData);

                    MessageBroker.Default.Publish(new ScoreAccumulation { Score = -itemData.requiredScore });
                }
            }
        }

        /// <summary>
        /// サブウェポン切り替え処理
        /// </summary>
        /// <param name="direction"></param>
        private void OnMoveDirectionReceived(SubweaponMoveDirection direction)
        {
            var subWeapons = GetSubWeapon();
            if (subWeapons.Length == 0) return; // サブウェポンがない場合は何もしない

            int currentIndex = Array.IndexOf(subWeapons, _selectedSubWeapon);
            if (currentIndex == -1) currentIndex = 0; // 現在選択されているサブウェポンがない場合、最初の要素を選択

            // 方向に応じてインデックスを更新
            if (direction == SubweaponMoveDirection.Left)
            {
                currentIndex--;
                if (currentIndex < 0) currentIndex = subWeapons.Length - 1; // リストの最後にループ
            }
            else if (direction == SubweaponMoveDirection.Right)
            {
                currentIndex++;
                if (currentIndex >= subWeapons.Length) currentIndex = 0; // リストの最初にループ
            }

            _selectedSubWeapon = subWeapons[currentIndex] as ItemData;
        }


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

        /// <summary>
        /// SubWeaponの値を持つデーターを取得する
        /// </summary>
        /// <returns></returns>
        public IItem[] GetSubWeapon()
        {
            return GetItemsByType(ItemType.SubWeapon);
        }

        /// <summary>
        /// Bombの値を持つデーターを取得する
        /// </summary>
        /// <returns></returns>
        public IItem[] GetBomb()
        {
            return GetItemsByType(ItemType.Bomb);
        }

        /// <summary>
        /// Shieldの値を持つデーターを取得する
        /// </summary>
        /// <returns></returns>
        public IItem[] GetShield()
        {
            return GetItemsByType(ItemType.Shield);
        }

        // public IItem GetItemByName(string name) {
        //     return _items.FirstOrDefault(item => item.Name == name);
        // }

        public IItem[] GetItemsByType(ItemType itemType)
        {
            return _items.Where(item => item.itemType == itemType).ToArray();
        }


        // public IItem[] GetAllItems() {
        //     return _items;
        // }

        public event Action<ItemData> OnItemAdded;
        public event Action<ItemData> OnItemRemoved;

        public void AddItem(ItemData item)
        {
            _items.Add(item);
            OnItemAdded?.Invoke(item);
        }

        public void RemoveItem(ItemData item)
        {
            if (_items.Remove(item))
            {
                OnItemRemoved?.Invoke(item);
            }
        }

        /// <summary>
        /// サブウェポンの格納(未使用になるかもしれない)
        /// </summary>
        /// <param name="index"></param>
        /// <param name="item"></param>
        public void SetSubWeapon(int index, ItemData item)
        {
            if (_subWeapon[index] == null)
            {
                _subWeapon[index] = item;
            }
        }

        /// <summary>
        /// サブウェポンの切り替え処理(未使用になるかもしれない)
        /// </summary>
        /// <param name="direction"></param>
        public void SwitchSubWeapon(SubweaponMoveDirection direction)
        {
            var move = direction == SubweaponMoveDirection.Left ? -1 : 1;
            var index = _selectingSubWeaponIndex + move;
            if (index < 0) index = _subWeapon.Count - 1;
            if (index > _subWeapon.Count - 1) index = 0;
            _selectingSubWeaponIndex = index;

            // メッセージを作成
            var inputParams = new CurrentSubWeaponIndex(_selectingSubWeaponIndex);

            // メッセージ送信
            _inputPublisher.Publish(inputParams);
        }

        /// <summary>
        /// 選択中のサブウェポンを取得する
        /// </summary>
        public ItemData GetSelectingSubWeapon()
        {
            if (_selectedSubWeapon != null)
            {
                return _selectedSubWeapon;
            }
            else
            {
                // サブウェポンが未選択の場合、選択をリセットする
                return ResetSubWeaponSelection();
            }

            // if (_subWeapon[_selectingSubWeaponIndex] == null) return null;
            //
            // return _subWeapon[_selectingSubWeaponIndex];
        }

        /// <summary>
        /// サブウェポン選択をリセットし、最初のサブウェポンを返す(存在する場合)
        /// </summary>
        /// <returns>リセット後の現在のサブウェポン、またはnull</returns>
        public ItemData ResetSubWeaponSelection()
        {
            var subWeapons = GetSubWeapon();
            if (subWeapons.Any())
            {
                // 最初のサブウェポンを現在の選択に設定し、それを返す
                _selectedSubWeapon = subWeapons.First() as ItemData;
                return _selectedSubWeapon;
            }
            else
            {
                // サブウェポンが存在しない場合はnullを返す
                return null;
            }
        }

        /// <summary>
        /// 特定のアイテムが所持リストに含まれているか確認するメソッド
        /// </summary>
        /// <param name="item"></param>
        /// <returns></returns>
        public bool HasItem(ItemData item)
        {
            return _items.Contains(item);
        }

        /// <summary>
        /// _itemsが一つ以上あるか判定するメソッド
        /// </summary>
        /// <returns></returns>
        public bool HasItem()
        {
            return _items.Count > 0;
        }

        public void debugAddItem(ItemData item)
        {
            if (item == null) return;
            if (_items.Contains(item)) return;

            _items.Add(item);
        }
    }
}

サブウェポンUI表示処理

保持しているサブウェポンのUIを表示して、現在選択しているサブウェポンを強調表示するようにしてみました。

SubWeaponsUI

サブウェポンのUIを表示

SubWeaponsUI.cs
using _RAYSER.Scripts.Item;
using UnityEngine;

namespace UI.Game
{
    /// <summary>
    /// ゲームシーンのサブウェポンUI表示処理
    /// </summary>
    public class SubWeaponsUI : MonoBehaviour
    {
        private ItemAcquisition _itemAcquisition;

        public void Setup(ItemAcquisition itemAcquisition)
        {
            _itemAcquisition = itemAcquisition;
            gameObject.SetActive(_itemAcquisition.HasItem());
        }
    }
}

SubWeaponIndividual

サブウェポンUIで表示される各サブウェポンのUIのクラスです。サブウェポンの名前の表示、現在設定中のサブウェポンの強調表示などを行っています。

SubWeaponIndividual.cs
using System;
using _RAYSER.Scripts.Item;
using _RAYSER.Scripts.SubWeapon;
using MessagePipe;
using TMPro;
using UniRx;
using UnityEngine;
using UnityEngine.UI;

namespace UI.Game
{
    /// <summary>
    /// ゲームシーンの個々のサブウェポンUI
    /// </summary>
    public class SubWeaponIndividual : MonoBehaviour, IDisposable
    {
        private CompositeDisposable _disposable = new CompositeDisposable();
        private ISubscriber<SubweaponMoveDirection> _moveDirectionSubscriber;
        private ItemAcquisition _itemAcquisition;
        private ItemData _itemData;
        [SerializeField] private TextMeshProUGUI nameText;

        [SerializeField] private Image focusImage;

        public void Setup(ItemAcquisition itemAcquisition,
            ISubscriber<SubweaponMoveDirection> moveDirectionSubscriber,
            ItemData itemData)
        {
            _itemAcquisition = itemAcquisition;
            _moveDirectionSubscriber = moveDirectionSubscriber;
            _itemData = itemData;
            nameText.text = _itemData.name;
            // _moveDirectionSubscriber.Subscribe(_ => OnMoveDirectionReceived()).AddTo(_disposable);
            _moveDirectionSubscriber.Subscribe(_ => OnMoveDirectionReceived()).AddTo(this);
            IsMounted(_itemData);
        }

        /// <summary>
        /// サブウェポン装着表示判定
        /// </summary>
        /// <param name="itemData"></param>
        private void IsMounted(ItemData itemData)
        {
            if (_itemAcquisition.GetSelectingSubWeapon() == itemData)
            {
                focusImage.gameObject.SetActive(true);
            }
            else
            {
                focusImage.gameObject.SetActive(false);
            }
        }

        private void OnMoveDirectionReceived()
        {
            IsMounted(_itemData);
        }

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

Visitorパターンについての参考記事

Visitorパターンについてはとりすーぷさんの記事が大変参考になります。
https://qiita.com/toRisouP/items/d96a09fab827af17fb37

最後

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

Discussion

NekoxsGamesNekoxsGames

知見をシェアしてくださりありがとうございます
とても素晴らしいと思いますが、これほど膨大なソースですと、GitHubでMITライセンスで公開されて、ZENNではクラス図と主要なポイントのみ解説でソースはリンクだともっと伝えられたい「組んで見たらこうなったよ、良かった」みたいなのが伝わりやすいと思いました

詳しくコード説明までつけてくださりありがとうございます😊

Cz_mirrorCz_mirror

コメントありがとうございます!
全体のソースコードのGitHub公開と、クラス図の追加もしておいた方が確かにわかりやすいので、後ほど追加してみます。貴重なご意見感謝です。

Cz_mirrorCz_mirror

ソースコードの追加とクラス図を追加してみました。(クラス図に不慣れなので、不備があるかもしれませんが。。)

NekoxsGamesNekoxsGames

ありがとうございます!コードを俯瞰しやすいです!
ゲームは割とデザインパターンまで落とし込んで説明する方少ないので嬉しいです

クラス図を書くと、コードを見返すきっかけになるので説明する時にいいそうですよ
わざわざ作図を鬼の首ちったように文句を言う人はいないと思うので大丈夫ですよ

今後の記事も楽しみです!

Cz_mirrorCz_mirror

コメントありがとうございます!普段デザインパターンを活用したことがなかったのですが、うまく活用すると相当便利なのを実感しました。備忘録で作った記事でしたが、クラス図にすることで改めて振り返りになったので、今後もなるべく書くことにしようと思います。

NekoxsGamesNekoxsGames

ちなみに、今回もし手書き(ZENN上で描き起こしされた)のでしたら、簡易であれば、コードからクラス図を書き起こすツールもありますので、そういうのもいいかもしれません。

Unityアセットでも、VSCodeのエディタからでもできますよ(私はUnityアセットはつかったことないですが)。無理して使われる必要はないですが、一応、ご参考まで。

まだ私は大きなコードをUnityでがっつり書いてないので、入り組んできたらシェアいただいたデザインパターンなど是非参考にしたいと思います。ありがとうございました。

https://qiita.com/Tanakancolle/items/cb5cc76f84da3dc97f24
https://qiita.com/yuik1012/items/30cfd5df6ac3ef5d1429

Cz_mirrorCz_mirror

今回クラス図はChatGPT4でソースから生成してみましたが、ご紹介いただいたツールも、ソースからUMLできるのでなかなか便利そうな印象を受けました。情報ありがとうございます!

NekoxsGamesNekoxsGames

そうか、GPT4使う手がありましたね
うーん、これからはそっちが主流かもしれないですね・・・
※ローカル生成はJAVA入れたり面倒くさいので

Cz_mirrorCz_mirror

ChatGPTの生成はソースを張ってMermaid
形式でクラス図を出力をするように指定したら、今回のようなクラス図が出来上がったので、なかなか便利でした。ただ次のボムの実装をした際のクラス図は間違っているものが一度生成されてしまったので、場合によっては数回再生したり、生成物を自分で調整はしないとダメかもしれませんが、それでもかなり早く作れるので今後も活用できればと思います。