📘

【Unity】1週間ゲームジャム「ない」でノイズを消すゲームを提出しました

2025/01/04に公開

提出したゲームについて

お題は「ない」というテーマで、2024年12月23日(月) 0時〜2024年12月29日(日) 20時までの期間で開催された1週間ゲームジャムに「No ise」というゲームを提出しました。
こちらUIの作成やゲームロジックに難航してしまい、実際提出できたのは年明けの1/1になってしまいました。
ここ数年新しいゲームを作っていなかったこともあり、No iseというゲームはなるべくシンプルな操作にすることと、Unity6から正式サポートされたWebGLのモバイルブラウザ対応で動くゲームを作ってみようと思いました

ゲームのURL

https://unityroom.com/games/no_ise

ソースコード

ソースコードは以下になります
https://github.com/Czmirror/No_ise_Script

LitMotionを用いたUIアニメーション

UI関連の操作関連ではアニメーション部分でLitMotionを用いたRectTransform操作による拡大・縮小による演出を用いています、演出の際にUniTaskを併用して、演出の終了後にUIを無効にするなどの処理を加えるなどしています

LitMotionとやりとりをするクラスUISizeTweenerを作り、ウィンドウを管理するクラスから呼び出しています

UISizeTweener.cs
using Cysharp.Threading.Tasks;
using UnityEngine;
using LitMotion;
using LitMotion.Extensions;

namespace _No_ise.Scripts.UI.Tween
{
    public class UISizeTweener
    {
        private Vector2 _initialSize;
        private Vector2 _zeroSize = new Vector2(0, 0);
        private Vector2 _horizontalSize = new Vector2(0, 1);
        private Vector2 _verticalSize = new Vector2(1, 0);
        private float _duration;
        private Ease _ease;

        public void Initialize(RectTransform rectTransform, Ease ease = Ease.Linear)
        {
            _initialSize = rectTransform.sizeDelta;
        }

        public void SetZeroSizeUI(RectTransform rectTransform)
        {
            rectTransform.sizeDelta = _zeroSize;
        }

        public async UniTask RestoreSize(RectTransform rectTransform, float duration)
        {
            await SizeDelta(rectTransform, _zeroSize, _initialSize, duration);
        }

        public async UniTask ReductionSize(RectTransform rectTransform, float duration)
        {
            await SizeDelta(rectTransform, _initialSize, _zeroSize, duration);
        }

        private async UniTask SizeDelta(RectTransform rectTransform, Vector2 startValue, Vector2 endValue, float duration)
        {
            await LMotion.Create(startValue, endValue, duration)
                .WithEase(_ease)
                .BindToSizeDelta(rectTransform)
                .AddTo(rectTransform.gameObject);
        }
    }
}

ボタンは押された時にウィンドウに対して表示する処理を記述しています、こちらは今思うと、メッセージなどを用いてウィンドウ側の方でメッセージを受け取ったら表示処理を行うようにしたほうがよかったかもしれません。(開発当初はまだZeroMessengerを使っておらず、取り急ぎ簡単な方法で実装をしていました)

ButtonConfig.cs
using _No_ise.Scripts.UI.Window;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

namespace _No_ise.Scripts.UI.Title
{
    public class ButtonConfig : MonoBehaviour
    {
        [SerializeField] private Button button;
        [SerializeField] private WindowConfig _windowUI;
        private float _duration = 0.1f;

        private void Reset()
        {
            button = GetComponent<Button>();
        }

        private void Awake()
        {
            button.onClick.AddListener(() => PushButton().Forget());
        }

        private async UniTask PushButton()
        {
            await _windowUI.ShowUI(_duration);
        }
    }
}

ウィンドウには一応インターフェースを切っていますが、インターフェースを用いて差し替えるような作りにはしておらずあくまでメソッドの共通化のためにのみ使いました。
ボタンを押して、ShowUIメソッドを呼び出し、その中でawaitを行いUISizeTweenerからRectTransformを操作するメソッドを呼び出しています。

IWindowUI.cs
using Cysharp.Threading.Tasks;

namespace _No_ise.Scripts.UI.Window
{
    public interface IWindowUI
    {
        UniTask Initialize();

        UniTask ShowUI(float duration);

        UniTask HideUI(float duration);
    }
}

WindowConfig.cs
using _No_ise.Scripts.UI.Title;
using _No_ise.Scripts.UI.Tween;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using VContainer;

namespace _No_ise.Scripts.UI.Window
{
    public class WindowConfig : MonoBehaviour, IWindowUI
    {
        [Header("Fades & Tweens")]
        [SerializeField] private CanvasGroup canvasGroup;
        [SerializeField] private RectTransform rectTransform;
        [SerializeField] private Button closeButton;
        [SerializeField] private MenuButtons menuButtons;

        private UISizeTweener _uiSizeTweener;
        private float speed = 0.1f;
        private float duration = 0.1f;

        [Inject]
        public void Construct(UISizeTweener uiSizeTweener)
        {
            _uiSizeTweener = uiSizeTweener;
        }

        private void Awake()
        {
            Initialize();
            closeButton.onClick.AddListener(() => PushButton().Forget());
        }

        private async UniTask PushButton()
        {
            await HideUI(duration);
        }
        public UniTask Initialize()
        {
            _uiSizeTweener.Initialize(rectTransform);
            gameObject.SetActive(false);
            closeButton.gameObject.SetActive(false);
            return UniTask.CompletedTask;
        }

        public async UniTask ShowUI(float duration)
        {
            menuButtons.SetInteractable(false);

            gameObject.SetActive(true);
            await _uiSizeTweener.RestoreSize(rectTransform, duration);
            closeButton.gameObject.SetActive(true);
        }

        public async UniTask HideUI(float duration)
        {
            closeButton.gameObject.SetActive(false);
            await _uiSizeTweener.ReductionSize(rectTransform, duration);
            gameObject.SetActive(false);

            menuButtons.SetInteractable(true);
        }
    }
}

VContainerを用いたMVPパターンで実装した音量調整機能

音量調整機能はVContainerを用いて、MVP(Model-View-Presenter)パターンとなるように実装しました

まず複数のシーンで取り扱いができるクラスをアタッチするためにRootLifeScopeの定義を行いました

RootLifeScopeは、音量とプレイ時間を複数のシーンで共通で扱うために使いました。

RootLifetimeScope.cs
using _No_ise.Scripts.Audio;
using _No_ise.Scripts.PlayTime;
using VContainer;
using VContainer.Unity;

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

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

音量調整機能実装について

音量を管理するクラスです、こちらは音量の設定と現在の音量を返すメソッドを持っています。

VolumeData.cs
namespace _No_ise.Scripts.Audio
{
    public class VolumeData
    {
        private float Volume { get; set; } = 1f;

        public float GetVolume()
        {
            return Volume;
        }

        public void SetVolume(float volume)
        {
            Volume = volume;
        }
    }
}

プレイ時間を管理するクラスです、こちらはプレイ時間の加算とリセットのメソッドを持っています。
プレイ時間の加算はゲームシーン中と、リセットはプレイ開始の時に使用しています

PlayTimeData.cs
namespace _No_ise.Scripts.PlayTime
{
    public class PlayTimeData
    {
        private float _totalTime = 0f;

        public float GetTotalTime() => _totalTime;

        /// <summary>
        /// プレイ時間を加算する
        /// </summary>
        /// <param name="deltaTime"></param>
        public void AddTime(float deltaTime)
        {
            _totalTime += deltaTime;
        }

        /// <summary>
        /// プレイ時間をリセットする
        /// </summary>
        public void ResetTime()
        {
            _totalTime = 0f;
        }
    }
}

タイトルシーンでの音量の保持はVolumeSceneTitleLifetimeScopeというクラスと、音量スライダーのPresenter VolumeSceneTitlePresenterクラス、実際の音量設定を行うVolumeServiceクラスを用いて実装しました。

Viewの定義はVolumeSliderクラスで行います、こちらはスライダーUIにアタッチするのみの構造となっています

VolumeSlider.cs
using UnityEngine;
using UnityEngine.UI;

namespace _No_ise.Scripts.Audio
{
    public class VolumeSlider: MonoBehaviour
    {
        public Slider slider;

    }
}

VolumeSceneTitleLifetimeScopeでは音量のView部分とAudioSourceをアタッチして、スライダーのUIとVolumeDataが連動するようにしています

音量のLifetimeScopeの定義

VolumeServiceの登録とVolumeSceneTitlePresenterの登録とエントリーポイントの生成、Viewコンポーネントの登録を行います。

VolumeSceneTitleLifetimeScope.cs
using _No_ise.Scripts.Audio;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace _No_ise.Scripts.LifeScopes
{
    public class VolumeSceneTitleLifetimeScope : LifetimeScope
    {
        [SerializeField] private VolumeSlider _volumeSlider;
        [SerializeField] private AudioSource audioSource;

        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);
            builder.Register<VolumeService>(Lifetime.Singleton);
            builder.Register<VolumeSceneTitlePresenter>(Lifetime.Singleton);
            builder.RegisterEntryPoint<VolumeSceneTitlePresenter>();
            builder.RegisterComponent(_volumeSlider);
            builder.RegisterInstance(audioSource);
        }
    }
}

音量の受け渡し用クラスと、UIと連動するプレゼンタークラスの定義

音量をVolumeDataに受け渡しを行うクラスです

VolumeService.cs
using UnityEngine;
using VContainer;

namespace _No_ise.Scripts.Audio
{
    public class VolumeService
    {
        private VolumeData _volumeData;
        private AudioSource _audioSource;

        [Inject]
        public void Construct(
            VolumeData volumeData,
            AudioSource audioSource
        )
        {
            _volumeData = volumeData;
            _audioSource = audioSource;
        }

        public float GetVolume()
        {
            return _volumeData.GetVolume();
        }

        public void SetVolume(float volume)
        {
            _volumeData.SetVolume(volume);
            _audioSource.volume = volume;
        }
    }
}

音量のスライダーの情報をVolumeServiceに渡すクラスです

VolumeSceneTitlePresenter.cs
using VContainer.Unity;

namespace _No_ise.Scripts.Audio
{
    public class VolumeSceneTitlePresenter : IStartable
    {
        readonly VolumeService volumeService;
        readonly VolumeSlider volumeSlider;

        public VolumeSceneTitlePresenter(VolumeService volumeService, VolumeSlider volumeSlider)
        {
            this.volumeService = volumeService;
            this.volumeSlider = volumeSlider;
        }

        void IStartable.Start()
        {
            // スライダーの値をVolumeServiceに渡す
            volumeSlider.slider.onValueChanged.AddListener(volumeService.SetVolume);
        }
    }
}

設定されたVolumeDataは、RootLifescopeで定義されているので、別のシーンでも使うことができます
GameObjectはInjectアトリビュートの追記と、LifeScopeへのアタッチを行うことで別シーンに遷移した際にもAudioSourceに用いるVolume情報やプレイ時間を保持するPlayTimeDataを維持しています。
ゲームシーンでは、VolumeSceneGameLifetimeScopeというクラスで、VolumeDataを使うゲームオブジェクトに挿入するようにしています
VolumeSceneGameSetterというクラスを作っていますが、こちらは使わなくても良いクラスで、VContainerのAuto Inject GameObjectsで、直接VolumeDataクラスをInjectするようにした方がシンプルだったので、途中からそのように変更しました。AudioSourceもVolumeSceneGameLifetimeScope内でアタッチできるようにしていますが、こちらも不要です。VolumeServiceもここでは登録しなくても動作します。

using _No_ise.Scripts.Audio;
using UnityEngine;
using VContainer;
using VContainer.Unity;

namespace _No_ise.Scripts.LifeScopes
{
    public sealed class VolumeSceneGameLifetimeScope : LifetimeScope
    {
        //[SerializeField] private AudioSource audioSource;

        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);
            //builder.Register<VolumeService>(Lifetime.Singleton);
            //builder.Register<VolumeSceneGameSetter>(Lifetime.Singleton);
            //builder.RegisterEntryPoint<VolumeSceneGameSetter>();
            //builder.RegisterInstance(audioSource);
        }
    }
}

直接VolumeDataクラスをInjectしたいAudioSourceに対してアタッチするクラスです、こちらはAuto Inject GameObjectsにゲームオブジェクトを登録して、クラスでVolumeDataに対してInjectアトリビュートを付与することで、呼び出された際にVolumeDataをアタッチして、AudioSourceのVolumeに値を付与する処理を行っています

AudioSourceSynchronization.cs
using UnityEngine;
using VContainer;

namespace _No_ise.Scripts.Audio
{
    /// <summary>
    /// 音量の同期(他のDIしているAudioSourceのvolumeを同期用クラス)
    /// </summary>
    public class AudioSourceSynchronization : MonoBehaviour
    {
        [SerializeField] private AudioSource audioSource;

        [Inject]
        private VolumeData volumeData;

        private void Start()
        {
            audioSource.volume = volumeData.GetVolume();
        }
    }
}

プレイ時間の保持について

プレイ時間はゲームプレイ中のみ進むようにしたかったので、ゲームシーンで動作するPlayTimeLifeScopeとタイトルでゲームスタートをした時にプレイ時間をリセットするPlayTimeResetLifeScope、エンディングでプレイ時間を送信するPlayTimeSendLifeScopeのようにLifeScopeを分けました。

プレイ時間管理用クラス

プレイ時間の加算とリセットを行うクラスです、こちらはRootLifescopeで定義されているのでゲーム中に一つしか存在しないクラスになります

PlayTimeData.cs
namespace _No_ise.Scripts.PlayTime
{
    public class PlayTimeData
    {
        private float _totalTime = 0f;

        public float GetTotalTime() => _totalTime;

        /// <summary>
        /// プレイ時間を加算する
        /// </summary>
        /// <param name="deltaTime"></param>
        public void AddTime(float deltaTime)
        {
            _totalTime += deltaTime;
        }

        /// <summary>
        /// プレイ時間をリセットする
        /// </summary>
        public void ResetTime()
        {
            _totalTime = 0f;
        }
    }
}

プレイ時間加算処理

プレイ時間の加算はPlayTimeLifeScopeで必要な処理を登録してエントリーポイントを作り実装しています。

PlayTimeLifeScope.cs
using _No_ise.Scripts.PlayTime;
using VContainer;
using VContainer.Unity;

namespace _No_ise.Scripts.LifeScopes
{
    public class PlayTimeLifeScope: LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);
            builder.Register<PlayTimeUpdater>(Lifetime.Singleton);
            builder.RegisterEntryPoint<PlayTimeUpdater>();
        }
    }
}

プレイ時間の加算処理を行うクラスです、PlayTimeLifeScopeからエントリーポイントを作られる形で生成されます

PlayTimeUpdater.cs
using UnityEngine;
using VContainer.Unity;

namespace _No_ise.Scripts.PlayTime
{
    public class PlayTimeUpdater : ITickable
    {
        private readonly PlayTimeData _playTimeData;

        public PlayTimeUpdater(PlayTimeData playTimeData)
        {
            _playTimeData = playTimeData;
        }

        // 毎フレーム呼ばれる
        public void Tick()
        {
            _playTimeData.AddTime(UnityEngine.Time.deltaTime);
        }
    }
}

ZeroMessengerを用いたメッセージ処理

ステージクリア、リトライなどのイベントに対してZeroMessengerというメッセージングライブラリを用いて実装を行いました。
ZeroMessengerを使用することで、例えばステージクリアをしたタイミングでメッセージを発行して、ステージクリアの演出側でそのメッセージを受け取ったら、次のステージへ遷移するという処理が簡単に実装することができます
同様にリトライもボタンを押したタイミングでメッセージを発行することでリトライの演出が実行されるようになります。

メッセージを用いた演出のための定義

TweenerLifetimeScopeクラスでは、演出で使用するUISizeTweenerの登録と演出で使用するクラスをアタッチしています

TweenerLifetimeScope.cs
using _No_ise.Scripts.UI.Tween;
using VContainer;
using VContainer.Unity;

namespace _No_ise.Scripts.LifeScopes
{
    public class TweenerLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            base.Configure(builder);
            builder.Register<UISizeTweener>(Lifetime.Singleton);
        }
    }
}

ゲームのクリア判定をする審判用クラスです、こちらはステージごとにクリア条件が異なりますが、ステージクリアのメッセージを発行する処理は他の審判クラスでも同様になります。

審判用クラスでメッセージの発行を行い、メッセージを受け取ったSceneMoverでステージクリアの演出後に次のステージに遷移したり、リトライで元のステージをリロードする流れになります。

ステージクリアの際に、進む次のステージの定義は、遷移したいステージのenumを持たせたStageClearクラスのメッセージを発行することで実現しています。

using _No_ise.Scripts.Enum;
using _No_ise.Scripts.Message;
using _No_ise.Scripts.Spawner;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Serialization;
using VContainer;
using ZeroMessenger;

namespace _No_ise.Scripts.Umpire
{
    public class Stage1Umpire : MonoBehaviour
    {
        [SerializeField] private WaterDropletSpawner dropletSpawner;

        [SerializeField] private Collider2D faucetCollider;
        [SerializeField] private Collider2D spannerCollider;

        [SerializeField] private AudioSource audioSource;
        [SerializeField] private AudioClip stageClearAudio;

        private bool stageCleared = false;

        private IMessagePublisher<StageClear> _stageClearPublisher;

        private int _delay = 1000;

        [Inject]
        public void Construct(IMessagePublisher<StageClear> stageClearPublisher)
        {
            _stageClearPublisher = stageClearPublisher;
        }

        void Update()
        {
            if (!stageCleared)
            {
                // スパナがドラッグで動いている間、蛇口( faucetCollider ) と当たったらステージクリア
                if (spannerCollider.bounds.Intersects(faucetCollider.bounds))
                {
                    StageClear();
                }
            }
        }

        private async UniTask StageClear()
        {
            stageCleared = true;

            // ボルトを締める音を再生
            audioSource.clip = stageClearAudio;
            audioSource.Play();

            // 水滴の生成を止める
            if (dropletSpawner != null)
            {
                dropletSpawner.StopDroplets();
            }

            await UniTask.Delay(_delay);

            _stageClearPublisher.Publish(new StageClear(Stage.Stage2));
        }
    }
}

審判から受け取ったステージクリアやリトライボタンのメッセージを受信して、ステージクリアやリトライの演出を実行後にシーンを呼び出すクラスです。

using _No_ise.Scripts.Effect;
using _No_ise.Scripts.Enum;
using _No_ise.Scripts.Message;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
using VContainer;
using ZeroMessenger;

namespace _No_ise.Scripts.Scene
{
    public class SceneMover : MonoBehaviour
    {
        [SerializeField] private StageClearEffect _stageClearEffect;
        [SerializeField] private RetryEffect _retryEffect;
        private IMessageSubscriber<StageClear> _stageClearSubscriber;
        private IMessageSubscriber<StageRetry> _stageRetrySubscriber;

        [Inject]
        public void Construct(
            IMessageSubscriber<StageClear> stageClearSubscriber,
            IMessageSubscriber<StageRetry> stageRetrySubscriber)
        {
            _stageClearSubscriber = stageClearSubscriber;
            _stageRetrySubscriber = stageRetrySubscriber;
        }

        private void Start()
        {
            // stageclearメッセージを受け取ったらシーン遷移
            _stageClearSubscriber.Subscribe<StageClear>(x =>
            {
                SceneMove(x.Stage);
            });

            _stageRetrySubscriber.Subscribe<StageRetry>(x =>
            {
                ReloadScene();
            });
        }

        private async UniTask SceneMove(Stage stage)
        {
            await _stageClearEffect.ShowUI();

            // シーン遷移処理
            var sceneName = stage.ToString();
            SceneManager.LoadScene(sceneName);
        }

        private async UniTask ReloadScene()
        {
            await _retryEffect.ShowUI();
            var currentSceneName = SceneManager.GetActiveScene().name;
            SceneManager.LoadScene(currentSceneName);
        }
    }
}

ステージクリア情報のクラスです、ステージのenumを情報として持たせています

StageClear.cs
using _No_ise.Scripts.Enum;

namespace _No_ise.Scripts.Message
{
    /// <summary>
    /// ステージクリアメッセージ
    /// </summary>
    public class StageClear
    {
        // stage enum
        public Stage Stage { get; private set; }

        public StageClear(Stage stage)
        {
            Stage = stage;
        }
    }
}

ステージの名称を定義しているenumです

Stage.cs
namespace _No_ise.Scripts.Enum
{
    public enum Stage
    {
        Title,
        Stage1,
        Stage2,
        Stage3,
        Stage4,
        Stage5,
        End
    }
}

ステージクリアの演出を実行するクラスです、UISizeTweenerを挿入して審判用クラスから呼び出しを行います

using _No_ise.Scripts.UI.Tween;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VContainer;

namespace _No_ise.Scripts.Effect
{
    public class StageClearEffect : MonoBehaviour
    {
        [SerializeField] AudioSource audioSource;
        [SerializeField] AudioClip audioClip;
        private RectTransform rectTransform;
        private CanvasGroup canvasGroup;
        private UISizeTweener _uiSizeTweener;
        private float duration = 0.75f;
        private int delay = 1000;

        [Inject]
        public void Construct(UISizeTweener uiSizeTweener)
        {
            _uiSizeTweener = uiSizeTweener;
        }

        private void Awake()
        {
            rectTransform = GetComponent<RectTransform>();
            canvasGroup = GetComponent<CanvasGroup>();
            Initialize();
        }

        public void Initialize()
        {
            _uiSizeTweener.Initialize(rectTransform);
            canvasGroup.alpha = 0;
            gameObject.SetActive(false);
        }

        public async UniTask ShowUI()
        {
            audioSource.PlayOneShot(audioClip);

            canvasGroup.alpha = 1;
            gameObject.SetActive(true);
            await _uiSizeTweener.RestoreSize(rectTransform, duration);

            await UniTask.Delay(delay);
        }
    }
}

反省点

ゲーム自体は正直あまりロジックなども考慮されておらず、繰り返し遊びたいというような仕掛けなどは作れなかったので、次回はゲームの面白さをより追求していくようにできればと思います。
今回VContainerの使い方の学び直しやZeroMessenger、LitMotionの導入の知見は得られたと思うので次回以降のゲーム開発に活かせればと考えています

Discussion