🌊

今回のUnity1週間ゲームジャムで学んだ。おすすめできるもの

2024/03/31に公開

はじめに(前回とほぼ同じ)

unityroom が開催している 1 週間ゲームジャムである「Unity1 週間ゲームジャム(unity 1 week, u1w)」に参加しました。

お題に沿った Unity のゲーム作品を 1 週間で作るという体で遅刻も OK な、比較的ゆるめのイベントです。私は 12 日かけて提出しました。

https://unityroom.com/unity1weeks

前回はそこでのゲーム制作について主に設計周りからの反省点を書きました。

前回 ↓

https://zenn.dev/qemel/articles/7fe8848db11677

今回は開発で勉強になったところ・成功したところ・良かったところの中でも共有しやすそうなものを紹介していければと思います。

要件

今回作成したゲームの要件について簡単に俯瞰しておきます。

基本ルール

  • プレイヤーが画面端に行くと、反対色になって逆側から出てくる
  • 逆側から出てきた反対色のプレイヤーは、その色に沿ったコライダー(当たり判定)に沿って移動できる
  • ゴールに触れたらレベルクリア、次のレベルへ
  • 同じ色が重なったらゲームオーバー、リトライ処理

追加要素

  • 触れると重力を反転するアイテム
  • カメラを移動させてその視界に沿って画面端の処理をできるようにする機能(文章で書いても意味不!!!)
    • 要は、見えてる世界が真実になる的な、そんな感じ
    • もともと画面端に壁があるステージも、カメラを移動して壁が無いように見せれば、その通りに処理されるみたいな感じ

使用ライブラリ

ライブラリ名 概要
R3 UniRx の後継。Input 等の発行・購読に利用。
UniTask async/await 使いたかったら必須のやつ。
VContainer DI コンテナ。楽するために導入。
MessagePipe 疎結合たのしーーーするためのメッセージライブラリ。DI コンテナ必須。
LucidAudio Audio の再生・破棄等が気軽にできるライブラリ。
Alchemy インスペクタ拡張。
LitMotion Tween ライブラリ。書きやすい。

リンク

ゲーム

https://unityroom.com/games/worb

ソースコード

https://github.com/Noth827/-u1w-white-or-black?tab=readme-ov-file

MessagePipeとVContainerでお手軽疎結合化

本ゲームではゴールや重力反転等、プレイヤーの干渉によって状況が変化するオブジェクトを持っています。こういったオブジェクトを実装する際、一番簡単には以下のような実装が考えられます。

public sealed class GoalView : MonoBehaviour
{
    [SerializeField] private LevelManager levelManager; // LevelManager に依存している

    private Collider2D _collider2D;

    private void Awake()
    {
        _collider2D = GetComponent<Collider2D>();
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.TryGetComponent(out PlayerView player))
        {
            levelManager.OnGoal(player);
        }
    }
}

しかしこのような処理だと、GoalViewはLevelManagerに依存してしまっています。

こうなるとGoalViewをPrefabとして生成するたびにこの依存関係を解決しないといけない(ドラッグドロップして入れないといけない)ので、地味にめんどくさいです。

本質的には、こういうPrefabは独立して機能できるべきで、置いた瞬間から有効に働くことを期待しているはずです。

またGoalViewがLevelManagerを知っているのもそもそもどうかという問題があります。本来、GoalViewはゴールに触れたらゴールイベントを発行するだけでいいはずです。

こういう際の解決法として、今回はVContainer, MessagePipeを使いました。

GoalView.cs
public sealed class GoalView : MonoBehaviour
{
    [Inject] private readonly IPublisher<GameStateEvent> _publisher;

    private Collider2D _collider2D;

    private void Awake()
    {
        _collider2D = GetComponent<Collider2D>();
    }

    private void OnTriggerEnter2D(Collider2D other)
    {
        if (other.TryGetComponent(out PlayerView player))
        {
            _publisher.Publish(new GameStateEvent(GameState.Clear)); // イベントを発行する
        }
    }
}
StageEventService.cs
public sealed class StageEventService : IStartable, IDisposable
{
    [Inject] private readonly ISubscriber<GameStateEvent> _subscriber;
    [Inject] private readonly LevelLoader _levelLoader;

    private readonly CompositeDisposable _disposable = new();

    public void Start()
    {
        _subscriber.Subscribe(async x =>
        {
            // イベントがどっかから来たら勝手に呼ばれる
            if (x.Value == GameState.Clear)
            {
                _levelLoader.LoadNext();
            }
        }).AddTo(_disposable);
    }
}

StageEventServiceは、GameStateEventが発行されたらレベルをロードする処理を行います。これでGoalViewはLevelManagerに依存しなくてもいいようになりました。

この場合もInjectアトリビュートを解決する必要がありますが、VContainerにはLifeTimeScopeにてInjectだけしたいGameObjectを簡単に登録できるので、かなり楽になります。

下記のようにAuto Inject Game Objectsの中にGameObjectを登録するだけで、そのGameObject(とすべての子オブジェクト)にInjectアトリビュートを付けたフィールドが自動で解決されます。便利!!!

(VContainerの簡単な使い方もいつか説明できればと思います。)

こんな調子で、イベント(メッセージ)として作れる部分はイベントを使って作ってみました。これは結果的に良かったです。

  • Camera移動のイベント
  • 重力変化のイベント
  • GameState(クリア、ゲームオーバーとか)のイベント

LitMotionの使用

今までTweenライブラリとしてDOTweenを使っていました。

https://dotween.demigiant.com

しかしDOTweenに細かい不満がいくつかあったので、LitMotionというライブラリを試してみました。

https://github.com/AnnulusGames/LitMotion

これがかなり書きやすいTweenライブラリで大満足でした!今後も使っていく予定です。

記法が楽

LitMotionは、先に変化する値を設定してあとで何を変化させるのかを書きます。

private void Start()
{
    LMotion
        .Create(Vector3.zero, Vector3.one, 1f) // 変化量の前後、変化する時間の長さ
        .BindToPosition(transform); // 変化させるもの
}

メソッド名が分かりやすいので、DOTweenで書いていたころよりも細かいミスが少なく済みました。

awaitしやすい

awaitを書くだけです。

private async void Start()
{
    await LMotion
        .Create(Vector3.zero, Vector3.one, 1f) 
        .BindToPosition(transform); 
}

UniTask使ってるならCancellationTokenも渡せます。 便利。

private CancellationToken _token;

private async UniTask Start()
{
    _token = this.GetCancellationTokenOnDestroy();
    await LMotion
        .Create(Vector3.zero, Vector3.one, 1f)
        .BindToPosition(transform)
        .ToUniTask(cancellationToken: _token);
}

これを使えば連続するTweenも簡単に書けます。

今回の使用例

今回はチュートリアルのふわふわ浮いたUIや、画面遷移等に使用しました。マジで楽。

public sealed class FloatingUI : MonoBehaviour
{
    [SerializeField] private float _height;
    [SerializeField] private float _duration;
    
    private void Start()
    {
        LMotion.Create(new Vector3(0, 0, 0), new Vector3(0, _height, 0), _duration)
            .WithLoops(-1, LoopType.Yoyo) // yoyoループで
            .WithEase(Ease.InOutSine) // イージング指定して
            .BindToPosition(transform) // transformを変化させる
            .AddTo(gameObject); // gameObjectが死んだらアニメーション解除
    }
}

Playerの情報をScriptableObject化する

本ゲームは、白色のPlayerと黒色のPlayerが登場し、互いに違うCollider上を動けるという性質があります。

ここで以下のような設計が考えられます。

using UnityEngine;

public interface IPlayer
{
    Color Color { get; }
    LayerMask WallLayerMask { get; }
    LayerMask IgnoreLayerMask { get; }
    void Init();
}

public sealed class WhitePlayer : MonoBehaviour, IPlayer
{
    public Color Color { get; } = Color.white;
    public LayerMask WallLayerMask => _wallLayerMask;
    public LayerMask IgnoreLayerMask => _ignoreLayerMask;

    [SerializeField] private LayerMask _wallLayerMask;
    [SerializeField] private LayerMask _ignoreLayerMask;

    private Collider2D _collider2D;
    private SpriteRenderer _spriteRenderer;

    public void Init()
    {
        _collider2D = GetComponent<Collider2D>();
        _spriteRenderer = GetComponent<SpriteRenderer>();

        // colorの設定
        _spriteRenderer.color = Color;
        
        // layerMaskの設定
        _collider2D.includeLayers = WallLayerMask;
        _collider2D.excludeLayers = IgnoreLayerMask;
    }
}

public sealed class BlackPlayer : MonoBehaviour, IPlayer
{
    public Color Color { get; } = Color.black;
    public LayerMask WallLayerMask => _wallLayerMask;
    public LayerMask IgnoreLayerMask => _ignoreLayerMask;

    [SerializeField] private LayerMask _wallLayerMask;
    [SerializeField] private LayerMask _ignoreLayerMask;

    private Collider2D _collider2D;
    private SpriteRenderer _spriteRenderer;

    public void Init()
    {
        _collider2D = GetComponent<Collider2D>();
        _spriteRenderer = GetComponent<SpriteRenderer>();

        // colorの設定
        _spriteRenderer.color = Color;
        
        // layerMaskの設定
        _collider2D.includeLayers = WallLayerMask;
        _collider2D.excludeLayers = IgnoreLayerMask;
    }
}

これでもいい気がしますが、今回はあくまで「Playerは1つで、その中身が違うだけ」という意識だったことや、データを分離させたい、という考えから、PlayerのデータをScriptableObjectにまとめました。

PlayerData.cs
[CreateAssetMenu(menuName = "u1w 2024/PlayerDataByColor")]
public class PlayerData : ScriptableObject
{
    public Color Color => _color;

    /// <summary>
    /// 壁のレイヤーマスク
    /// </summary>
    public LayerMask WallLayerMask => _wallLayerMask;
    
    /// <summary>
    /// 当たり判定を無視するレイヤーマスク
    /// </summary>
    public LayerMask IgnoreLayerMask => _ignoreLayerMask;

    [SerializeField] private Color _color;
    [SerializeField] private LayerMask _wallLayerMask;
    [SerializeField] private LayerMask _ignoreLayerMask;
}

Playerを作る際は、これをアタッチするような作りにすればいいです。もしくはInit(PlayerData data)のようなメソッドを用意します。

PlayerView.cs
public sealed class PlayerView : MonoBehaviour
{
    /*...*/

    public void Init(PlayerData data)
    {
        _spriteRenderer.color = data.Color;
        _collider2D.includeLayers = WallLayerMask;
        _collider2D.excludeLayers = IgnoreLayerMask;
    }
}

今回の要件ではどっちでもよかった気がしますが、選択肢としてどちらも持っておくのは悪くないと思いました。

後者の方が重複は減らしやすいと思います。

おわりに

色々な技術を使えて今回は楽しかったです!

今後VContainerやLitMotionについてさらに入門しやすい記事等が書ければなと思っております。

Discussion