🪈

【Unity】Message Pipeで疎結合かつテスト可能なゲームマネージャーをつくる【MessagePipe】

に公開

はじめに

Unityでゲームを開発する場合GameManagerを作成し、ゲームロジックを書くことでゲームを進行させることになると思います。
ですが、GameManagerを知っている必要があり、UIやその他のコンポーネントが密結合になるため、スッキリと書くことができません。
数年前の自分は解決することができずに、Unityでのゲーム開発を挫折しました。
今回、これを解決する方法としてイベント駆動型のゲームマネージャーを思いついたのでアイデアを共有したいと思います。

この記事で得られること

  • イベント駆動型にすることで疎結合なGameManagerを作成することができる
  • テスト可能なゲームロジックの設計
  • MessagePipeとVContainerの活用法

今回の想定・実現したいこと

  • 一般的な対戦型FPSのFree for all/Team death matchルールを想定
  • ゲームマネージャーにUIやその他コンポーネントを密結合させない
  • ゲームマネージャーをテスト可能にしたい
  • シングルトンを作らない

これらを実現するためには以下のようにすると良さそうです。

  • イベント駆動型で設計する
  • ゲームマネージャーからゲームルールを純粋なC#クラスに切り離してしてテスト可能にする
  • GameRuleを抽象クラスと実装に分ける

イベント駆動型で設計する

従来のGameManagerは他のコンポーネントが直接参照する必要がありましたが、イベント駆動型にすることで、各コンポーネントはイベントの発行と購読のみを行い、互いを知る必要がなくなります。これにより、コンポーネント間の依存関係が大幅に削減され、保守性と拡張性が向上します。

ゲームマネージャーからゲームルールを純粋なC#クラスに切り離してテスト可能にする

ゲームルールを純粋なC#クラスとして分離することで、Unityエディタを起動することなくユニットテストが実行できます。これにより、ゲームロジックの品質を保ち、リファクタリング時の安全性も確保できます。

GameRuleを抽象クラスと実装に分ける

抽象クラスでゲームルールのインターフェイスを定義し、具体的な実装を分離することで、異なるゲームモードを容易に追加・変更できるようになります。

実現するための技術スタック

  • MessagePipe: イベント駆動型の設計を実現するためのPub/Subライブラリ
  • VContainer: 依存性注入(DI)コンテナ
  • Unity Test Framework: ユニットテストの実行環境

MessagePipeとは?

MessagePipeのGithub

MessagePipeは、C#向けの高性能なインメモリメッセージングライブラリです。特にUnityでの使用に最適化されており、Zero Allocationでの動作が可能です。

MessagePipeが得意なこと

多対多のPub/Sub

例えば一般的なFPSの場合、敵をキルしたときに勝手にキルログのUIやゲームマネージャーがイベントを受け取ってそれぞれが処理することができます。
UniRxやR3などとは違い、互いを知らなくてもIPublisher<T>/ISubscriber<T>をIoCコンテナからDIすればPub/Subができるのでスッキリと疎結合にすることができます。

イベント駆動型の設計

MessagePipeを使用することで、コンポーネント間の直接的な参照を排除し、イベントベースの設計が可能になります。これにより、システム全体の結合度を下げ、テスタビリティを向上させることができます。

MessagePipeでなくてもいいこと

  • メッセージのフィルタリング
  • M-V-PなどのReactiveなイベントに使う

従来のUniRxやR3を使用したほうがフィルタリングがしやすいと思います。
もしどうしてもMessagePipeをUIで使いたいならば、Rxでメッセージをフィルタリングする形でBLoCパターンを使用すると良いと思います。

VContainerとは

VContainerは、Unity向けの軽量で高速なDIコンテナです。MessagePipeと組み合わせることで、依存性の注入とイベントの配信を効率的に管理できます。

VContainerでできること

  • 依存性の自動注入
  • オブジェクトのライフサイクル管理
  • スコープベースの管理
  • MessagePipeとのシームレスな統合

Unityプロジェクトのセッティング

必要なパッケージをインストールする

openupmをダウンロードしていない場合はこちらからインストールしてください。

# MessagePipeをインストール
openupm add com.cysharp.messagepipe

# VContainerをインストール
openupm add jp.hadashikick.vcontainer

実践!イベント駆動型GameManagerの作り方

セットアップが終わったところで、実際に一般的な対戦型FPSのFree For Allルールを想定してコードを考えていきましょう。
今回考えるルールは以下のとおりです。

Free For Allルール

  • 全員が敵
  • 制限時間がある
  • n人以上キルすると勝利
  • 制限時間を超えた場合、一番キルした人が勝利

CoDやValorantのデスマッチをやったことがある人であればおなじみのルールかと思います。
また、このルールを拡張することで様々な対戦型のゲームに対応可能です。

GameLifeTimeScope

using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class GameLifeTimeScope : LifetimeScope
{
    [SerializeField] private GameSettings gameSettings;
    
    protected override void Configure(IContainerBuilder builder)
    {
        // MessagePipeを登録
        builder.AddMessagePipe();
        
        // ゲーム設定を登録
        builder.RegisterInstance(gameSettings);
        
        // ゲームルールを登録
        builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
        
        // GameManagerを登録
        builder.RegisterEntryPoint<GameManager>();
        
        // その他のサービスを登録
        builder.Register<PlayerManager>(Lifetime.Singleton);
        builder.Register<TimeManager>(Lifetime.Singleton);
    }
}

GameManager

using MessagePipe;
using System;
using VContainer.Unity;

public class GameManager : IInitializable, IDisposable
{
    private readonly IGameRule gameRule;
    private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
    private readonly ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber;
    private readonly IPublisher<GameStartedEvent> gameStartedPublisher;
    private readonly IPublisher<GameEndedEvent> gameEndedPublisher;
    private readonly DisposableBag disposableBag = new();
    
    public GameManager(
        IGameRule gameRule,
        ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
        ISubscriber<GameTimeExpiredEvent> gameTimeExpiredSubscriber,
        IPublisher<GameStartedEvent> gameStartedPublisher,
        IPublisher<GameEndedEvent> gameEndedPublisher)
    {
        this.gameRule = gameRule;
        this.playerKilledSubscriber = playerKilledSubscriber;
        this.gameTimeExpiredSubscriber = gameTimeExpiredSubscriber;
        this.gameStartedPublisher = gameStartedPublisher;
        this.gameEndedPublisher = gameEndedPublisher;
    }
    
    public void Initialize()
    {
        // イベント購読の設定
        playerKilledSubscriber.Subscribe(OnPlayerKilled).AddTo(disposableBag);
        gameTimeExpiredSubscriber.Subscribe(OnGameTimeExpired).AddTo(disposableBag);
        
        // ゲームルールの初期化
        gameRule.Initialize();
        gameRule.OnGameEnded += OnGameEnded;
        
        // ゲーム開始イベントを発行
        gameStartedPublisher.Publish(new GameStartedEvent());
    }
    
    private void OnPlayerKilled(PlayerKilledEvent playerKilledEvent)
    {
        gameRule.OnPlayerKilled(playerKilledEvent.KillerId, playerKilledEvent.VictimId);
    }
    
    private void OnGameTimeExpired(GameTimeExpiredEvent gameTimeExpiredEvent)
    {
        gameRule.OnTimeExpired();
    }
    
    private void OnGameEnded(GameResult result)
    {
        gameEndedPublisher.Publish(new GameEndedEvent(result));
    }
    
    public void Dispose()
    {
        gameRule.OnGameEnded -= OnGameEnded;
        disposableBag?.Dispose();
    }
}

GameRule

// 抽象クラス
using System;
using System.Collections.Generic;

public abstract class GameRule : IGameRule
{
    public event Action<GameResult> OnGameEnded;
    
    protected Dictionary<string, int> playerKills = new();
    protected bool isGameEnded = false;
    
    public abstract void Initialize();
    public abstract void OnPlayerKilled(string killerId, string victimId);
    public abstract void OnTimeExpired();
    
    protected void EndGame(GameResult result)
    {
        if (isGameEnded) return;
        
        isGameEnded = true;
        OnGameEnded?.Invoke(result);
    }
}

// インターフェイス
public interface IGameRule
{
    event Action<GameResult> OnGameEnded;
    void Initialize();
    void OnPlayerKilled(string killerId, string victimId);
    void OnTimeExpired();
}

// Free For All実装
public class FreeForAllRule : GameRule
{
    private readonly GameSettings settings;
    
    public FreeForAllRule(GameSettings settings)
    {
        this.settings = settings;
    }
    
    public override void Initialize()
    {
        playerKills.Clear();
        isGameEnded = false;
    }
    
    public override void OnPlayerKilled(string killerId, string victimId)
    {
        if (isGameEnded) return;
        
        if (!playerKills.ContainsKey(killerId))
            playerKills[killerId] = 0;
            
        playerKills[killerId]++;
        
        // 勝利条件をチェック
        if (playerKills[killerId] >= settings.killsToWin)
        {
            EndGame(new GameResult
            {
                WinnerId = killerId,
                WinType = WinType.KillLimit,
                FinalScores = new Dictionary<string, int>(playerKills)
            });
        }
    }
    
    public override void OnTimeExpired()
    {
        if (isGameEnded) return;
        
        // 最多キル数のプレイヤーを勝者にする
        string winnerId = "";
        int maxKills = -1;
        
        foreach (var kvp in playerKills)
        {
            if (kvp.Value > maxKills)
            {
                maxKills = kvp.Value;
                winnerId = kvp.Key;
            }
        }
        
        EndGame(new GameResult
        {
            WinnerId = winnerId,
            WinType = WinType.TimeLimit,
            FinalScores = new Dictionary<string, int>(playerKills)
        });
    }
}

GameEvent

using System.Collections.Generic;

// ゲームイベントの定義
public struct PlayerKilledEvent
{
    public string KillerId { get; }
    public string VictimId { get; }
    
    public PlayerKilledEvent(string killerId, string victimId)
    {
        KillerId = killerId;
        VictimId = victimId;
    }
}

public struct GameStartedEvent
{
    // 必要に応じてデータを追加
}

public struct GameEndedEvent
{
    public GameResult Result { get; }
    
    public GameEndedEvent(GameResult result)
    {
        Result = result;
    }
}

public struct GameTimeExpiredEvent
{
    // 必要に応じてデータを追加
}

// ゲーム結果の定義
public class GameResult
{
    public string WinnerId { get; set; }
    public WinType WinType { get; set; }
    public Dictionary<string, int> FinalScores { get; set; }
}

public enum WinType
{
    KillLimit,
    TimeLimit
}

// ゲーム設定
[System.Serializable]
public class GameSettings
{
    public int killsToWin = 10;
    public float gameTimeLimit = 300f; // 5分
}

FFAルールをテストする

using NUnit.Framework;
using System.Collections.Generic;

public class FreeForAllRuleTest
{
    private FreeForAllRule rule;
    private GameSettings settings;
    private GameResult capturedResult;
    
    [SetUp]
    public void SetUp()
    {
        settings = new GameSettings
        {
            killsToWin = 3,
            gameTimeLimit = 300f
        };
        
        rule = new FreeForAllRule(settings);
        rule.OnGameEnded += (result) => capturedResult = result;
        rule.Initialize();
        capturedResult = null;
    }
    
    [Test]
    public void キル数による勝利条件のテスト()
    {
        // Arrange
        string playerId = "Player1";
        
        // Act
        rule.OnPlayerKilled(playerId, "Enemy1");
        rule.OnPlayerKilled(playerId, "Enemy2");
        
        // まだゲームは終了していない
        Assert.IsNull(capturedResult);
        
        rule.OnPlayerKilled(playerId, "Enemy3");
        
        // Assert
        Assert.IsNotNull(capturedResult);
        Assert.AreEqual(playerId, capturedResult.WinnerId);
        Assert.AreEqual(WinType.KillLimit, capturedResult.WinType);
        Assert.AreEqual(3, capturedResult.FinalScores[playerId]);
    }
    
    [Test]
    public void 時間切れによる勝利条件のテスト()
    {
        // Arrange
        rule.OnPlayerKilled("Player1", "Enemy1");
        rule.OnPlayerKilled("Player1", "Enemy2");
        rule.OnPlayerKilled("Player2", "Enemy3");
        
        // Act
        rule.OnTimeExpired();
        
        // Assert
        Assert.IsNotNull(capturedResult);
        Assert.AreEqual("Player1", capturedResult.WinnerId);
        Assert.AreEqual(WinType.TimeLimit, capturedResult.WinType);
        Assert.AreEqual(2, capturedResult.FinalScores["Player1"]);
        Assert.AreEqual(1, capturedResult.FinalScores["Player2"]);
    }
    
    [Test]
    public void ゲーム終了後は追加のキルを受け付けない()
    {
        // Arrange
        string playerId = "Player1";
        
        // 3キルして勝利
        rule.OnPlayerKilled(playerId, "Enemy1");
        rule.OnPlayerKilled(playerId, "Enemy2");
        rule.OnPlayerKilled(playerId, "Enemy3");
        
        var firstResult = capturedResult;
        
        // Act - ゲーム終了後の追加キル
        rule.OnPlayerKilled(playerId, "Enemy4");
        
        // Assert
        Assert.AreEqual(firstResult.FinalScores[playerId], capturedResult.FinalScores[playerId]);
    }
}

おまけ

コードを変更せずにDebug.Log()を追加する

UnityのDebug.Logはそのままにしておくとパフォーマンス低下やセキュリティ上問題になることがあります。また、Debug.Log()が各コンポーネント偏在することでコードが汚くなります。

#if UNITY_EDITOR
using MessagePipe;
using UnityEngine;
using VContainer.Unity;

public class EventDebugger : IInitializable, System.IDisposable
{
    private readonly ISubscriber<PlayerKilledEvent> playerKilledSubscriber;
    private readonly ISubscriber<GameStartedEvent> gameStartedSubscriber;
    private readonly ISubscriber<GameEndedEvent> gameEndedSubscriber;
    private readonly DisposableBag disposableBag = new();
    
    public EventDebugger(
        ISubscriber<PlayerKilledEvent> playerKilledSubscriber,
        ISubscriber<GameStartedEvent> gameStartedSubscriber,
        ISubscriber<GameEndedEvent> gameEndedSubscriber)
    {
        this.playerKilledSubscriber = playerKilledSubscriber;
        this.gameStartedSubscriber = gameStartedSubscriber;
        this.gameEndedSubscriber = gameEndedSubscriber;
    }
    
    public void Initialize()
    {
        playerKilledSubscriber.Subscribe(evt => 
            Debug.Log($"[DEBUG] Player killed: {evt.KillerId} -> {evt.VictimId}")
        ).AddTo(disposableBag);
        
        gameStartedSubscriber.Subscribe(evt => 
            Debug.Log("[DEBUG] Game started")
        ).AddTo(disposableBag);
        
        gameEndedSubscriber.Subscribe(evt => 
            Debug.Log($"[DEBUG] Game ended: Winner={evt.Result.WinnerId}, Type={evt.Result.WinType}")
        ).AddTo(disposableBag);
    }
    
    public void Dispose()
    {
        disposableBag?.Dispose();
    }
}
#endif

ゲームルールをインスペクタから差し替え可能にする

UnityにはSerializeReferenceというアトリビュートが存在しています。
これはUnity 2019.3で追加された、インターフェイスの実装や抽象クラスのサブクラスをインスペクタから編集可能にするためのものです。

ですが標準のSerializeReferenceにはサブクラスを差し替えできないという欠点があります。
そこで、Unity-SerializeReferenceExtensionsを使用することで、この問題を解決できます。

# Unity-SerializeReferenceExtensionsをインストール
openupm add com.mackysoft.serializereference-extensions
using UnityEngine;
using MackySoft.SerializeReferenceExtensions;

[System.Serializable]
public abstract class GameRuleBase
{
    public abstract void Initialize();
    public abstract void OnPlayerKilled(string killerId, string victimId);
    public abstract void OnTimeExpired();
}

[System.Serializable]
public class FreeForAllRuleSettings : GameRuleBase
{
    [SerializeField] private int killsToWin = 10;
    [SerializeField] private float gameTimeLimit = 300f;
    
    public int KillsToWin => killsToWin;
    public float GameTimeLimit => gameTimeLimit;
    
    public override void Initialize() { /* 初期化処理 */ }
    public override void OnPlayerKilled(string killerId, string victimId) { /* キル処理 */ }
    public override void OnTimeExpired() { /* 時間切れ処理 */ }
}

[System.Serializable]
public class TeamDeathMatchRuleSettings : GameRuleBase
{
    [SerializeField] private int teamKillsToWin = 50;
    [SerializeField] private int maxTeamSize = 5;
    
    public int TeamKillsToWin => teamKillsToWin;
    public int MaxTeamSize => maxTeamSize;
    
    public override void Initialize() { /* チーム戦初期化処理 */ }
    public override void OnPlayerKilled(string killerId, string victimId) { /* チーム戦キル処理 */ }
    public override void OnTimeExpired() { /* チーム戦時間切れ処理 */ }
}

// GameManagerに組み込む
public class GameRuleConfiguration : MonoBehaviour
{
    [SerializeReference, SubclassSelector]
    private GameRuleBase gameRuleSettings = new FreeForAllRuleSettings();
    
    public GameRuleBase GameRuleSettings => gameRuleSettings;
}

この方法により、インスペクタから直感的にゲームルールを切り替えることができ、それぞれのルール固有のパラメータも編集可能になります。

さらに、VContainerと組み合わせることで、設定されたルールに基づいて適切な実装を注入できます:

public class GameLifeTimeScope : LifetimeScope
{
    [SerializeField] private GameRuleConfiguration ruleConfiguration;
    
    protected override void Configure(IContainerBuilder builder)
    {
        builder.AddMessagePipe();
        
        // 設定されたルールに基づいて実装を登録
        switch (ruleConfiguration.GameRuleSettings)
        {
            case FreeForAllRuleSettings ffaSettings:
                builder.RegisterInstance(ffaSettings);
                builder.Register<IGameRule, FreeForAllRule>(Lifetime.Singleton);
                break;
            case TeamDeathMatchRuleSettings tdmSettings:
                builder.RegisterInstance(tdmSettings);
                builder.Register<IGameRule, TeamDeathMatchRule>(Lifetime.Singleton);
                break;
        }
        
        builder.RegisterEntryPoint<GameManager>();
    }
}

まとめ

MessagePipeとVContainerを組み合わせることで、従来のGameManagerの課題を解決することができました。

得られた効果:

  • 疎結合化: コンポーネント間の直接的な参照を排除し、保守性が向上
  • テスタビリティ: ゲームルールを純粋なC#クラスとして分離し、ユニットテストが容易に
  • 拡張性: 新しいゲームルールの追加が簡単
  • デバッガビリティ: イベントログを一元管理できる

疎結合にすることができ、ゲームルールもVContainerに登録することで切り替えられます。
また、GameRuleとして純粋なC#にしたことでテスト可能になりました。

この設計パターンを応用することで、より複雑なゲームシステムにも対応できるでしょう。イベント駆動型の設計は最初は慣れが必要ですが、一度慣れてしまえば非常に強力な武器になります。

Discussion