はじめに

※これは、Unityゲーム開発者ギルド Advent Calendar 2025の1日目の記事です。

VitalRouterのススメ

どうも、個人でゲームを作り続けている弓猫です!

突然ですが、Unityのプログラミング設計って難しいですよね。
頑張って疎結合にしようとしても、MonoBehaviourやシリアライズの制約によって、なかなか思うようにいかないことはままあります。
SerializeFieldに貼り付けたり、VContainerを使ったり、Find系を使ったり……。

このような解決方法で問題になるのが、「全く別のモジュール間で連携を取りたい時」です。
例えば、こんなユースケースがあるとしましょう。

  • プレイヤーキャラを操作して、3D空間を自由に移動できる
  • 以下のような特定のタイミングで、その移動操作を禁止したい
    • ストーリーイベントの再生時
    • ショップ画面を開いた時

本来、「ショップ機能」「ストーリー機能」「プレイヤー機能」には、依存関係はないほうが好ましいです。
しかし上記の方法(SerializeFieldなど)の場合、どうにかしてインスタンスを引っ張ってきて、メソッドを呼び出す必要がでてしまいます。

本来なら切り離しておきたい型とシグネチャに依存するため、結合度が高くなり凝集度も低下します。
この解決策のアプローチのひとつとして、VitalRouterを使ってみようという記事です。

VitalRouterとは?

Unity および .NET 用の、ソースジェネレーターを搭載したゼロ割り当ての高速インメモリメッセージングライブラリです(ドキュメント直訳)。

https://vitalrouter.hadashikick.jp/

これだけで分かる人には以降の説明は不要ですが、「はて……?」という方も多いと思うので具体例で説明します。

例1:ショップ画面を開く時

ショップ画面を開いた際に、プレイヤーの移動を禁止させるユースケースを考えてみましょう。

プレイヤーを停止

ショップを開く

VitalRouterを使わない場合

Shopスクリプトがプレイヤーのインスタンスを直接参照して、移動を無効にします。

public class Shop : MonoBehaviour
{
    [SerializeField] private Player player;

    public void OpenShop()
    {
        player.DisableMovement();
        // ショップを開く処理
    }
}

Shop

Player

シンプルではありますが、本来なら独立していてほしいモジュール間で深い依存関係が発生しており、結合度が高いです。

VitalRouterを使う場合

まず、プレイヤーに指示を出すためのコマンド用オブジェクトを定義します。
C#11が使えるのであれば、readonly record structがおすすめです。

using VitalRouter;
public readonly record struct SwitchMovementCommand(bool Enabled) : ICommand;

Shop側は、このコマンドをVitalRouterで送信するだけです。
プレイヤーのインスタンスを取得する必要はおろか、コマンド用オブジェクト以外の一切の知識が不要です。

public class Shop : MonoBehaviour
{
    public async UniTask OpenShop()
    {
        await Router.Default.PublishAsync(new SwitchMovementCommand(false));
        // ショップを開く処理
    }
}

ショップ側がやるのは、これ一行だけです。
「どこの誰かは知らんが、プレイヤー止めてくれってコマンド送ったで~」というコードです。

そして、なんとプレイヤー側もショップの仕様やインスタンスが一切不要です。


// この属性をつけ、ICommandを実装した型を引数にしたメソッドがあると、MapToメソッドをSourceGeneratorで自動生成します。
[Routes]
public partial class Player : MonoBehaviour
{
    void Start()
    {
        // Router.Defaultからの送信を受け取るために必要
        MapTo(Router.Default);
    }

    public async UniTask OnSwitchMovement(SwitchMovementCommand cmd)
    {
        if (cmd.Enabled)
        {
            // 移動を有効にする
            EnableMovement();
        }
        else
        {
            // 移動を無効にする
            await IdleAnimation();
            DisableMovement();
        }
    }
}

若干コードが長くなってしまいましたが、MonoBehaviourの場合なら、

  1. ICommandを実装した型を引数にしたメソッドを用意する(ここではOnSwitchMovement
  2. クラスをpartialにし、Routes属性をつける
  3. SourceGeneratorが走った後に、MapToメソッドにRouter.Defaultを登録する。

受信側がやることはこれだけです。
図に起こすと以下の通り。

Shop

SwitchMovementCommand

Player

PlayerShopの双方が相手の知識を持たず、SwitchMovementCommandという安定したデータオブジェクトだけに依存しています。
加えて、Observerパターンと比較すると、登録と解放を簡単に書けます。

  • 関連度の低いモジュール同士の結合度を下げられる
  • 実装を持たないコマンドに依存することで依存性逆転の原則にも準拠
  • シンプルな記載

これが、VitalRouterのメリットの一部です。

例2:ストーリー再生時

これだけでは、かえって複雑になっただけだと思うかもしれませんね。
では、もう1つ例を見せましょう。
ストーリー再生時に移動を禁止させたい場合です。

public class StoryPlayer : MonoBehaviour
{
  public async UniTask PlayAsync(string fileName)
  {
    // 移動禁止コマンドを送信
    await Router.Default.PublishAsync(new SwitchMovementCommand(false));
    await // ストーリー再生処理
    //  移動許可コマンドを送信
    await await Router.Default.PublishAsync(new SwitchMovementCommand(true));
  }
}

はい、これだけです。
だって、コマンドや、コマンド受信側のコード(PlayerクラスのOnSwitchMovementメソッド)は例1で書きましたからね。
ショップの例から発展させた図を見ると、オープン・クローズの原則を満たせていることが分かると思います。

Shop

SwitchMovementCommand

StoryPlayer

Player

これで、「オプション画面開いたときも止めたいな」「アイテム使用中も止めたいな」と、仕様がどんどん増えたとしても、プレイヤー側もユースケース側も一切お互いを認識せず、データコマンドだけを送受信して粛々と処理できます。

その他の使い道

先ほどの例はユースケースが増えていくパターンでしたが、逆に単一のユースケースから複数の対象に指示を出したい時にも便利です。

例えば、ゲームオーバー時に「UIの表示」「音声の停止」「エフェクトの再生」を同時に行いたい場合。
VitalRouterなら、1回のPublishAsyncで複数の対象を同時に制御できます。

GameManager

GameOverCommand

UIController

AudioManager

EffectManager

public readonly record struct GameOverCommand : ICommand;

public class GameManager : MonoBehaviour
{
    public async UniTask TriggerGameOver()
    {
        // ゲームオーバーになったことを送信
        await Router.Default.PublishAsync(new GameOverCommand());
    }
}

[Routes]
public partial class UIController : MonoBehaviour
{
    void Start() => MapTo(Router.Default);

    public async UniTask OnGameOver(GameOverCommand cmd)
    {
        // ゲームオーバーUIを表示
    }
}


[Routes]
public partial class AudioManager : MonoBehaviour
{
    void Start() => MapTo(Router.Default);

    public async UniTask OnGameOver(GameOverCommand cmd)
    {
        // BGMを停止
    }
}


[Routes]
public partial class EffectManager : MonoBehaviour
{
    void Start() => MapTo(Router.Default);

    public async UniTask OnGameOver(GameOverCommand cmd)
    {
        // ゲームオーバーエフェクトを再生
    }
}

同様に、アイテム取得時に「スコア更新」「インベントリ更新」「サウンド再生」などを同時に行う場合にも有効です。

応用

作者のhadashiAさんは、VitalRouterを応用し、VitalRouter.MRubyという拡張ライブラリも公開しています。

UnityでMRubyをコンパイル・実行できるもので、MRubyの行をVitalRouterのコマンドで送信することで、非常にシンプルな記述でDSLイベント再生機構を実現しています。

詳しい説明は、hadashiAさんの記事に譲ります。
こちらも、特にツクールのようなイベントを実装したい時に非常に強力ですので、気になった方はぜひ読んでみてください。

Unityでmrubyスクリプティング

まとめ

どうでしょう? 少しでも便利さ・強力さが伝われば幸いです。
他にも説明していない機能はたくさんあるので、ぜひ公式ドキュメントも一読してみてください!

2日目は、ゆっち〜さんが「Unity 6.1で使えるProjectAuditorの概要とか」を書いてくれるそうです。楽しみ!