7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UnityAdvent Calendar 2024

Day 12

MagicOnion + NATS + LogicLooperでC#大統一!やってみた

Posted at

アドカレ

今年もアドカレの時期ですね!たくさん記事が上がってわくわくしますね
こちらはUnity Advent Calendar 2024の12日目の記事になります

はじめに

予てより気になっていた NATS と LogicLooperに入門すべくちょっと触ってみましたという記事になります
やってみた!という記事であり、ガチガチに解説している記事ではないのでご留意ください。

MagicOnionは2年前のアドベントカレンダーで触ってみましたが、今回はもうちょっと難しいことに挑戦しつつ、マジョリティゲームを作ってみます

元ネタはneuecc先生のコチラの記事です
AlterNats - ハイパフォーマンスな.NET PubSubクライアントと、その実装に見る.NET 6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについて
見よう見まねで作っていくのであしからず...正解なんてわかりません

今回のコードはGithubに公開しています
クライアント: https://github.com/euglenach/Miniverse.Client
サーバ : https://github.com/euglenach/Miniverse.Server
コード共有: https://github.com/euglenach/Miniverse.Shared

目次

開発環境

クライアント

  • Unity 2022.3.53
  • MagicOnion 6.1.6
  • MessagePack 2.5.192
  • YetAnotherHttpHandler 1.6.0
  • Grpc 2.67.0

MagicOnionサーバ

  • ASP.NET 9.0
  • MagicOnion 6.1.6
  • MessagePack 2.5.192
  • YetAnotherHttpHandler 1.6.0
  • Grpc 2.67.0
  • NATS 2.5.5

LogicLooperサーバ

  • ASP.NET 9.0
  • LogicLooper 1.6.0
  • MessagePack 2.5.192
  • NATS 2.5.5

サービスディスカバリサーバ

  • ASP.NET 9.0
  • MessagePack 2.5.192

CLIクライアント

  • .NET 9
  • MagicOnion 6.1.6
  • MessagePack 3.0.308 (執筆中にアップデートされた)
  • Grpc 2.67.0

コード共有/GitHubリポジトリ構成

今回は クライアント・サーバ・コード共有 でリポジトリを分ける構成にしてみました

1.png

コード共有はneuecc先生の.NETプロジェクトとUnityプロジェクトのソースコード共有最新手法を参考にしました

サービスディスカバリ

3.png

出典: https://neue.cc/2022/05/11_AlterNats.html

なんちゃってサービスディスカバリを実装してみます。(ロードバランサーなんて高価なものは作れません...)
サービスディスカバリは、いくつかのMagicOnionサーバのうち、いずれかのアドレスを返却してクライアントにつないでもらう、みたいなサービスです
今回は適当なのでランダムで返してしまいます

APIはせっかくなのでC#で。そしてプロトコルはMessagePackC#を使います。
https://www.nuget.org/packages/MessagePack.AspNetCoreMvcFormatter

ごく簡易的ではありますが、クライアントでは以下のようなメソッドを定義してみました

public static class MessagePackWebAPI
{
    private static readonly string baseUrl = "http://localhost:5277/api/";
    private static readonly HttpClient httpClient = new(new HttpClientHandler());
    
    public static async UniTask<TResponse> GetAsync<TRequest, TResponse>(CancellationToken cancellationToken = default)
    {
        var response = await httpClient.GetAsync(baseUrl + typeof(TRequest).Name, cancellationToken);
        var responseBytes = await response.Content.ReadAsByteArrayAsync();
        
        return MessagePackSerializer.Deserialize<TResponse>(responseBytes);
    }
}

APIの種類を型で判別できるようにしてる感じです。となればAPI側は以下のようになります。
APIで返ってくる型は一致させないとクライアントでデシリアライズ時にエラーになります

[FormatFilter]
[Route("api/")]
public class MagicOnionServiceDiscoveryController(IMagicOnionURLResolver urlResolver) : Controller
{
    // 型名でサブパスを決定してる /api/MagicOnionURLRequest
    [HttpGet(nameof(MagicOnionURLRequest))]
    public ActionResult<MagicOnionURLResponse> MagicOnionURL() => new MagicOnionURLResponse(urlResolver.Resolve());
}

そんでもってAPIを叩くときはこれでOK!簡単!なんちゃってサービスディスカバリでした

var response = await MessagePackWebAPI.GetAsync<MagicOnionURLRequest, MagicOnionURLResponse>();
Debug.Log(response.URL);

Unity_Kb1pZm083J.png

MagicOnion

2年前のアドベントカレンダーではStreamingHubをがっちゃんこして実装せざるを得なく苦虫をかみちぎりましたが、今回は裏でLogicLooperがまとめてくれるので容赦なく分離させます。

例えば マッチング専用の MatchingHub は以下のように定義してみます

public class MatchingHub(ILogger<MatchingHub> logger) : StreamingHubBase<IMatchingHub, IMatchingReceiver>, IMatchingHub
{
    private IGroup? room;
    private Player? player;
    private Ulid roomUlid;

    public async ValueTask CreateRoomAsync(Player player)
    {
        if(room is not null || this.player is not null) return;
        this.roomUlid = Ulid.NewUlid();
        this.player = player;
        this.room = await Group.AddAsync(player.Ulid.ToString());
        logger.ZLogInformation($"Joining matching hub... Player:{player.Ulid}: roomUlid:{roomUlid}");
    }

    public async ValueTask JoinRoomAsync(Ulid roomUlid, Player player)
    {
        if(room is not null || this.player is not null) return;
        this.roomUlid = roomUlid;
        this.player = player;
        this.room = await Group.AddAsync(player.Ulid.ToString());
        
        logger.ZLogInformation($"Joining matching hub... Player:{player.Ulid}: roomUlid:{roomUlid}");
    }
}

接続ごとにStreamingHubのインスタンスが作られるので クライアント : MagicOnion = 1 : 1 は簡単に実現できました

Group.AddAsync で一意のIDで登録したり、 NATSから何を受け取っても BroadcastToSelf(room).OnJoin() のように自分にだけ飛ばせば良いでしょう

これで赤で囲った部分はできました(ロードバランサーじゃなくてサービスディスカバリですが)

4.png

出典: https://neue.cc/2022/05/11_AlterNats.html

NATS

元ネタの記事では Cysharp/AlterNats を使っていますが、AlterNatsは Nats公式に引き取られました

ということで公式のNATSを使っていきます
https://www.nuget.org/packages/NATS.Net/

NATS関連のユーティリティ機能はMagicOnionでもLogicLooperでも使うのでサーバの中で共有するクラスライブラリを作ります
Pub/Subをラップする感じのものを作ると良いと思います

public class NatsPubSub : IAsyncDisposable
{
    public ValueTask Publish<T>(string key, T value, CancellationToken cancellationToken = default)
    {
        if(connectionPool is null) throw new InvalidOperationException("No connection pool available.");
        return connectionPool.GetConnection().PublishAsync(key + typeof(T).FullName, value, cancellationToken : cancellationToken);
    }
    
    public ValueTask Publish<T>(T value, CancellationToken cancellationToken = default)
    {
        return Publish(typeof(T).FullName!, value, cancellationToken);
    }

    public async IAsyncEnumerable<T> Subscribe<T>(string key, CancellationToken cancellationToken = default)
    {
        if(connectionPool is null) throw new InvalidOperationException("No connection pool available.");
        await foreach(var msg in connectionPool.GetConnection().SubscribeAsync<T>(key + typeof(T).FullName, cancellationToken : cancellationToken))
        {
            yield return msg.Data!;
        }
    }
    
    public IAsyncEnumerable<T> Subscribe<T>(CancellationToken cancellationToken = default)
    {
        return Subscribe<T>(typeof(T).FullName!, cancellationToken);
    }
}

ハマったのですが、Pub/Sub するとき、いろんな型で 同じキー(subject)を 使いまわすとおかしなことになったので注意が必要です。別の型のSubscriberにまったく違う型の成り損ないみたいなゴミデータがや同じメッセージが大量に届いてとても困りました。UniRxやMessagePipeと使い勝手が若干違うので注意が必要でした。なのでキーに型の名前を強制で付けるようにしました。

次に INatsSerializerRegistry を実装したクラスを作ってMessagePackをシリアライザとして登録します

public void Initialize(string url)
{
    var options = NatsOpts.Default with
    {
        Url = string.IsNullOrEmpty(url)? NatsOpts.Default.Url : url,
        SerializerRegistry = NatsMessagePackSerializerRegistry.Default,
    };
    
    connectionPool = new NatsConnectionPool(options);
}

public class NatsMessagePackSerializerRegistry : INatsSerializerRegistry
{
    public static readonly NatsMessagePackSerializerRegistry Default = new();
    public INatsSerialize<T> GetSerializer<T>() => NatsMessagePackSerializer<T>.Default;

    public INatsDeserialize<T> GetDeserializer<T>() => NatsMessagePackSerializer<T>.Default;
}

ここまではNATSクライアントのユーティリティを作りましたが、
NATSサーバ自体はC#で書かれているわけではなく、僕たちユーザはただただdockerコンテナを実行するだけで終わりです
今回はNATSのチュートリアルにあるコマンドをそのまま借りてきます

docker run -p 4222:4222 -p 8222:8222 -p 6222:6222 --name nats-server -ti nats:latest

ちなみにコンテナを立ち上げた後に http://localhost:8222 にアクセスするとNATSサーバの状態を確認することができます。
例えばこれは接続状況を見れたり
chrome_CBwYsexyJw.png

LogicLooper

めっちゃざっくりLogicLooperの説明をすると、「サーバでUnityのUpdateっぽいものを使える」ライブラリです。

使ってみる前に一応NATSでMagicOnionと疎通できているかを確認してみました

// 部屋作成用の型を作って...
[MessagePackObject]
public readonly record struct CreateRoomMsg(Ulid RoomUlid, Player Player)
{
    [Key(0)] public readonly Ulid RoomUlid = RoomUlid;
    [Key(1)] public readonly Player Player = Player;
}

// MagicOnion
public async ValueTask CreateRoomAsync(Player player)
{
    if(room is not null || this.player is not null) return;
    this.roomUlid = Ulid.NewUlid();
    this.player = player;
    this.room = await Group.AddAsync(player.Ulid.ToString());
    logger.ZLogInformation($"Joining matching hub... Player:{player.Ulid}: roomUlid:{roomUlid}");
    
    // natsで部屋を生成したことをLogicLooperに投げたい
    await nats.Publish(new CreateRoomMsg(roomUlid, player));
}

// LogicLooper
public class NatsReceiver(NatsPubSub nats, ILogger<NatsPubSub> logger)
{
    public async ValueTask StartSubscribe()
    {
        // NATSからのメッセージを受信する
        await foreach (var msg in nats.Subscribe<CreateRoomMsg>())
        {
            logger.ZLogInformation($"Received: {msg}");
        }
    }
}

5.png

独自の型もちゃんとMessagePackで疎通できることが確認できました

これでこの図が完成しました!
image.png

出典: https://neue.cc/2022/05/11_AlterNats.html

やっとこさこの記事の本題です。ではLogicLooperでゲームロジックを動かす準備へ...

LogicLooperでは以下のようにしてUpdateに処理を登録することができます

// フレームレートの設定
var option = new LooperActionOptions(60);
// 登録
looperPool.RegisterActionAsync(Update, option);

bool Update(in LogicLooperActionContext ctx)
{
    logger.ZLogInformation($"Update!");
    return true;
}

今回は部屋1つにつき1ループにしようと思います。
1ループ1スレッドなのでスレッドセーフが守られやすいし2つ以上にする理由も一旦無いということで

複数の部屋を管理したり、LogicLooperに登録するロジックがあるクラスを作ったり...

// LogicLooper部屋の管理クラス
public class MajorityGameRoomManager(ILogicLooperPool looperPool, ILogger<MajorityGameRoomManager> logger, IServiceScopeFactory scopeFactory, NatsPubSub nats)
{
    private readonly Dictionary<Ulid, MajorityGameRoom> gameRooms = new();
    private readonly AsyncLock roomListLock = new();

    public async ValueTask CreateRoomAsync(Ulid roomUlid, Player player, CancellationToken token = default)
    {
        using var __ = await roomListLock.EnterScope();
        if(gameRooms.ContainsKey(roomUlid)) return;
        
        // 部屋ごとのIDスコープの作成
        var scope = scopeFactory.CreateScope();

        // 部屋作成
        var room = scope.ServiceProvider.GetRequiredService<MajorityGameRoom>();
        gameRooms.Add(roomUlid, room);
        
        var roomInfo = new MajorityGameRoomInfo(roomUlid, [player]);
        await room.InitializeAsync(roomInfo, token);
        
        // フレームレートの設定
        var option = new LooperActionOptions(60);
        
        // LogicLooperに部屋のUpdateを追加
        _ = looperPool.RegisterActionAsync(room.Update, option);
    }

    public async ValueTask JoinRoomAsync(Ulid roomUlid, Player player, CancellationToken token = default)
    {
        using var __ = await roomListLock.EnterScope();
        
        if(!gameRooms.TryGetValue(roomUlid, out var room)) return;
        
        // すでにある部屋に入室する
        await room.RoomJoin(player);
    }
}
// ゲームのコア部分のクラス
public class MajorityGameRoom(ILogger<MajorityGameRoom> logger)
{
    private MajorityGameRoomInfo roomInfo;
    
    public async ValueTask InitializeAsync(MajorityGameRoomInfo roomInfo, CancellationToken token = default)
    {
        this.roomInfo = roomInfo;
    }
    
    public void RoomJoin(Player player)
    {
        logger.ZLogInformation($"{player.Name} is joined. room:{roomInfo.Ulid}");
    }

    // このメソッドがLogicLooperに登録される
    public bool Update(in LogicLooperActionContext context)
    {
        logger.ZLogInformation($"Update!");
        return true;
    }
}

プレイヤーが入室してLogicLooperのUpdateが起動するまでを確認できました
6.png

次にLogicLooperからMagicOnionへのメッセージですが、neuecc先生の記事の僕的気になるポイントとして、通信のカリングがあります。

また、MagicOnion自体はステートを持てるシステムであり、各ユーザーそれぞれのステートを持つのは容易です(サーバーを越えなければいい)。そこで、LogicLooperから届いたデータのうち、繋がってるユーザーに届ける必要がないデータは、MagicOnionの持つユーザーのステートを使ってカリング処理をして、そもそも転送しなかったり間引いたりして通信量を削減することで、ユーザーの体験が良くなります。

こういうことなんじゃない?という自分なりの解釈ですが、ちょうど入室完了通知でそれっぽいことをすることになってので紹介します。

「自分の入室完了通知にはゲーム自体の情報がほしい」けど「他人の入室通知はその人の情報だけあれば良い」みたいな感じ

public async ValueTask InitializeAsync(MajorityGameRoomInfo roomInfo, CancellationToken token = default)
{
    logger.ZLogInformation($"created MajorityGame room. ulid: {roomInfo.Ulid}");
    this.roomInfo = roomInfo;
    
    // MagicOnionにOnJoin通知する
    await nats.Publish(roomInfo.Ulid.ToString(), new OnJoinSelfMsg(roomInfo, roomInfo.Players[0]));
}

public async ValueTask RoomJoin(Player player)
{
    logger.ZLogInformation($"{player.Name} is joined. room:{roomInfo.Ulid}");
    roomInfo.Players.Add(player);
    
    // MagicOnionにOnJoin通知する
    await nats.Publish(roomInfo.Ulid.ToString(), new OnJoinSelfMsg(roomInfo, player));
    await nats.Publish(roomInfo.Ulid.ToString(), new OnJoinMsg(roomInfo.Ulid, player));
}

そうなると OnJoinSelfMsgOnJoinMsg みたいな感じで分けて通知することになると思います。

MatchingHubで以下のようにメッセージ別にクライアントにブロードキャストするかしないか、みたいなifを入れると。

public class MatchingHub(ILogger<MatchingHub> logger, NatsPubSub nats) : StreamingHubBase<IMatchingHub, IMatchingReceiver>, IMatchingHub
{
    // ~~中略~~
    
    protected override ValueTask OnConnected()
    {
        // BroadcastToSelfでOnJoinをクライアントに流す
        Observable.CreateFrom(this, static (token, state) => state.nats.Subscribe<OnJoinMsg>(state.roomUlid.ToString(), token))
            .Subscribe(this, (msg, state) =>
            {
                // OnJoinSelfMsgは他人だけ
                if(msg.Player.Ulid == state.player!.Ulid) return;
                state.BroadcastToSelf(state.room!).OnJoin(msg.Player);
                state.logger.ZLogInformation($"Joined room:{state.roomUlid} player:{msg.Player}");
            }).RegisterTo(cancellation.Token);
        
        // BroadcastToSelfでOnJoinSelfをクライアントに流す
        Observable.CreateFrom(this, static (token, state) => state.nats.Subscribe<OnJoinSelfMsg>(state.roomUlid.ToString(), token))
                  .Subscribe(this, (msg, state) =>
                  {
                      // OnJoinSelfMsgは自分だけ
                      if(msg.Player.Ulid != state.player!.Ulid) return;
                      state.BroadcastToSelf(state.room!).OnJoinSelf(msg.RoomInfo);
                      state.logger.ZLogInformation($"Joined room:{state.roomUlid} player:{msg.Player}");
                  }).RegisterTo(cancellation.Token);
        
        return ValueTask.CompletedTask;
    }
}

気になってたポイントも回収でき、まだほぼ側だけですが、ここまできてしまえばあとはゲームロジックを実装していくだけでしょう!

docker-compose

サーバがいくつもあって大変なのでdocker-composeで一緒に起動/停止できるようにしておくと便利でしょう

フロント作る時間がない..

すみません。ゲーム画面を作るのがめんどくさい、もとい〆切に間に合わなそうなのでここで終わります...とはならないのです!
MagicOnionはUnityだけがクライアントではないのです!
そう、CLIでも良い!!!
ということで〆切に兄合わなそうなのは本当なのでサボってマジョリティゲームのクライアントはCLIだけ実装していきます

マッチング

こう、マッチングまわりはこんな雰囲気で...流れをそのままコーディングしてみます

クライアント
public async ValueTask Run(CancellationToken cancellationToken = default)
{
    LogManager.Global.ZLogInformation($"Start MajorityGame CLI...");
    
    var playerNum = 4;
    var majorityGamePayers = new MajorityGamePayer[playerNum];
    // プレイヤーを作って初期化する
    for(var i = 0; i < majorityGamePayers.Length; i++)
    {
        var player = new MajorityGamePayer(i);
        majorityGamePayers[i] = player;
        // MatchingHubに接続し行く
        await player.ConnectMatchingAsync(cancellationToken);
    }
    var host = majorityGamePayers[0];
    var guests = majorityGamePayers[1..];
    // ルーム作成
    await host.MatchingHub.CreateRoomAsync();
    (await Wait.Until(() => host.RoomInfo is not null, cancellationToken : cancellationToken)).Should().BeTrue();
    var roomUlid = host.RoomInfo.Ulid;
    // ゲストの入室
    foreach(var guest in guests)
    {
        await guest.MatchingHub.JoinRoomAsync(roomUlid);
        (await Wait.Until(() => guest.RoomInfo is not null, cancellationToken : cancellationToken)).Should().BeTrue();
    }
    
    LogManager.Global.ZLogInformation($"Complete Matching!!");
}

Unityのときみたいにビルドしたバイナリやエディタを同時起動するように、CLIでもクライアントを複数起動しても良かったのですが、それもめんどくさいのでプロセスは1つだけ。楽でイイネ。
そのかわり、ちゃんとMagicOnionの接続は人数分行います

クライアント
await player.ConnectMatchingAsync(cancellationToken);

public async ValueTask ConnectMatchingAsync(CancellationToken cancellationToken = default)
{
    Hub = await MatchingHub.CreateAsync(player);
    EventSubscribe();
}

void EventSubscribe()
{
    MatchingHub.OnJoinSelf.Subscribe(this, static (roomInfo, state) =>
    {
        state.RoomInfo = roomInfo;
        state.logger.ZLogInformation($"{nameof(MatchingHub.OnJoinSelf)}: room:{roomInfo.Ulid} me:{state.Player.Name}");
    }).AddTo(disposable);
    
    MatchingHub.OnJoin.Subscribe(this, static (player, state) =>
    {
        if(state.RoomInfo is null) return;
        state.RoomInfo.Players.Add(player);
        state.logger.ZLogInformation($"{nameof(MatchingHub.OnJoin)}: {player.Name}");
    })
    .AddTo(disposable);

}

ちゃんと疎通できてますね。人数分の StreamingHubBaseインスタンス が 生まれていて BroadcastToSelf も想定通りに動いています。

7.png

インゲーム

というわけで、やっとこさ3年前に作ったマジョリティーゲームを作り直していきます
軽く説明しますと、ここでいうマジョリティーゲームとは、何人かに質問を出して多数派と少数派を出して一喜一憂するパーティゲームのようなものです

StreamingHubとReceiverは以下のように定義してみました。まあなんとなくわかるでしょう

public interface IMajorityGameHub : IStreamingHub<IMajorityGameHub, IMajorityGameReceiver>
{
    ValueTask AskQuestion(string questionText, string[] choices);
    ValueTask Select(int index);
    ValueTask ResultOpen();
}

public interface IMajorityGameReceiver
{
    void OnConnected();
    void OnAskedQuestion(MajorityGameQuestion question);
    void OnSelected(Ulid selectedPlayerUlid, int index);
    void OnResult(MajorityGameResult result);
}

前回はクライアントサイドでリザルトを集計して...みたいなことをやっていましたが、もちろん今回クライアントがやることはサーバから受け取った情報をもとに結果を表示するだけです。ロジックは全てサーバ任せにします

流れは

  1. 誰かが質問する
  2. 他のメンバーが選択肢のうちどれか選ぶ
  3. 質問者にのみ他のメンバーの選択が通知される
  4. 質問者が結果発表すると全員に結果がブロードキャストされる
  5. 結果を表示する

そして完成したCLIクライアントでのシナリオがこちら

クライアント
// 全員の接続を待つ
await ValueTaskEx.WhenAll(
    majorityGamePayers.Select(x => x.ConnectGameAsync(cancellationToken))
    );
(await Wait.Until(() => majorityGamePayers.All(p => p.IsConnectedMajorityGame))).Should().BeTrue();

// ここからマジョリティゲーム。プログラムで直接シナリオを書く

// 質問開始
var choices = new[]{"犬", "猫", "うさぎ", "虚無"};
await host.MajorityGameHub.AskQuestion("飼うなら?", choices);

// 全員に質問が受信されるか確認
(await Wait.Until(() => majorityGamePayers.All(p => p.RoomInfo!.Question is not null))).Should().BeTrue();

foreach(var guest in guests)
{
    // ランダムで1つ選択させる
    guest.MajorityGameHub.Select(Random.Shared.Next(choices.Length)).Forget();
    
    // ホストだけに届く通知を待機
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    cts.CancelAfter(2000);
    
    await host.MajorityGameHub.OnSelected.Where(guest, static (x, g) => x.AnswerPlayerUlid == g.Player.Ulid).FirstAsync(cts.Token);
}

// ちょっと待ってから結果発表
await Task.Delay(1000, cancellationToken);
host.MajorityGameHub.ResultOpen().Forget();

// リザルトが来るのを待つ
(await Wait.Until(() => majorityGamePayers.All(p => p.Result is not null))).Should().BeTrue();

LogManager.Global.ZLogInformation($"Complete MajorityGame!!");

ちなみに、結果表示やサーバから通知されたデータの反映は各Playerクラスが内部で自分で行っています。
例えば結果の表示。

クライアントはサーバから来るメッセージを元にデータを保存・成形して表示。本来のクライアントの姿です。

クライアント
logger.ZLogInformation($"【結果】");
foreach(var (count, choice) in result.NumTable.Zip(RoomInfo.Question.Choices, (count, choice) => (count, choice))
                                .OrderByDescending(x => x.count))
{
    logger.ZLogInformation($"{choice}:{count}人...({(int)(count / total * 100)}%)");
}

8.png

これでもうほぼ完成といっても過言

サーバをちょっと深堀り

クライアントの方は他に解説することはないと思うのでサーバの解説を少々

LogicLooperActionContext

LogicLooperのUpdateの LogicLooperActionContext からは現在のフレームに関する情報を取ることができます。
「質問者が質問を開始してから一定時間経ったら自動で結果発表する」という機能を入れてみました
LogicLooperから毎フレーム渡されるLogicLooperActionContextを使ってUnityライクに書くことができます。

LogicLooper
private double elapsed;

public void Update(in LogicLooperActionContext ctx)
{
    // 前フレームからの経過時間を使って質問が始まってからの経過時間を計測する
    elapsed += ctx.ElapsedTimeFromPreviousFrame.TotalSeconds;
    
    // 開始から10秒立ってたら結果を送る
    if(elapsed >= 10)
    {
        elapsed = 0;
        ResultOpenCore().Forget();
    }
}

LogicLooperActionContext.ElapsedTimeFromPreviousFrame は前回のフレームからの経過時間を取ることができます。つまりUnityの Time.deltaTime みたいなやつが使えるのです!

他にも、現在のフレーム数(UnityでいうTime.frameCount)を取れたり、Unityのコルーチンのような機能を使えたり、などの機能があります

通信のカリング

今回は、「質問者は他の人の選択が見える」という仕様を付けました。このサーバからの通知は質問者以外のクライアントに通知する必要がありませんね。
入室のときにも書きましたが、ここは顕著でわかりやすいです。

LogicLooper
// 選択をMagicOnionに送る(カリング用に質問者のIDも送る)
await nats.Publish(roomInfo.Ulid.ToString(), new OnSelectedMsg(session.PlayerUlid, playerUlid, index));
logger.ZLogInformation($"{playerUlid}{index}を選択しました。");
MagicOnion
// 選択された通知
nats.Subscribe<OnSelectedMsg>(roomUlid.ToString()).ToObservable()
    .Subscribe(this, static (msg, state) =>
    {        
        // 質問者にしか通知しない
        if(msg.TargetUlid != state.playerUlid) return;
        state.BroadcastToSelf(state.room!).OnSelected(msg.SelectedPlayerUlid, msg.Index);
        state.logger.LogInformation($"{nameof(OnSelectedMsg)}: Selected:{msg}");
    }).RegisterTo(cancellation.Token);

やってることは単純で送るべきかそうでないかをifで判断するだけ
チートもされませんね!!!

ユーティリティ

やっぱりLogicLooperでもUniTaskっぽい機能は使いたくなるときありますよね。
DelayはTaskでも良かったりするんですが、WaitUntil とか 使いたくなるときが来るはず

というわけでUniTaskを参考にそれっぽいものを作ってみました
https://github.com/euglenach/Miniverse.Server/tree/main/Miniverse.LogicLooper/Miniverse.LogicLooper/Utility/LooperTask

ちょっと ManualResetValueTaskSource の作りが怪しい気もしますが。。。まあ一旦良いでしょう。

LooperHelper を マニュアルでアップデートするという形に

LogicLooper
public bool Update(in LogicLooperActionContext context)
{
    looperHelper.Update(context);
    return true;
}

あとはDIで取ってくるなりで普通に使えます。

LogicLooper
private bool isResultOpenRequested;

private async ValueTask ResultOpenWaitAsync()
{
    // 10秒たつか質問者から結果発表通知が来るまで待つ
    await looperTask.WaitUntil(() => elapsed >= 10 || isResultOpenRequested);
    elapsed = 0;
    await ResultOpenCore();
}

自動アップデートの方が良い場合はこんな感じで拡張メソッドを定義すると良いかも

LogicLooper
public static LooperTask CreateLooperTask(this ILogicLooper looper, LooperHelper? looperHelper = null, LooperActionOptions? options = null)
{
    looperHelper ??= new LooperHelper();
    var looperTask = new LooperTask(looperHelper);
    
    looper.RegisterActionAsync(static (in LogicLooperActionContext ctx, LooperHelper helper) =>
    {
        if(ctx.CancellationToken.IsCancellationRequested) return false;
        
        helper.Update(ctx);
        return true;
    }, looperHelper, options ?? LooperActionOptions.Default);

    return looperTask;
}

public static LooperTask CreateLooperTask(this LogicLooperActionContext context, LooperHelper? looperHelper = null, LooperActionOptions? options = null)
{
    looperHelper ??= new LooperHelper();
    var looperTask = new LooperTask(looperHelper);
    options ??= new((int)context.Looper.TargetFrameRate);
    
    context.Looper.RegisterActionAsync(static (in LogicLooperActionContext ctx, (LooperHelper, LogicLooperActionContext) state) =>
    {
        var (helper, context) = state;
        if(ctx.CancellationToken.IsCancellationRequested) return false;
        if(context.CancellationToken.IsCancellationRequested) return false;
        
        helper.Update(ctx);
        return true;
    }, (looperHelper, context), options);

    return looperTask;
}

Update停止 & 退室処理

オマケ程度ですが。
LogicLooperのUpdateは手動で止めるまでずっと動き続けるのでちゃんと止めてやる必要があります
止め方はとても簡単。Updateメソッドの返り値をfalseにするだけです。

LogicLooper
public bool Update(in LogicLooperActionContext context)
{
    if(なんか条件) return false;
    return true;
}

MatchingHub(MagicOnion)からの切断通知を使ってLogicLooperに退室を知らせるようにしてみました

MagicOnion
public class MatchingHub(ILogger<MatchingHub> logger, NatsPubSub nats) : StreamingHubBase<IMatchingHub, IMatchingReceiver>, IMatchingHub
{
    // ~~中略~~
    protected override async ValueTask OnDisconnected()
    {
        // LogicLooperに切断を通知
        await nats.Publish(roomUlid.ToString(), new DisconnectMsg(player.Ulid));
        cancellation.Cancel();
        cancellation.Dispose();
    }
}

LogicLooperでは管理している自作のプレイヤーのリストがあるので、そこから削除します
リストが空ならゲームを終わらせる、という感じです

LogicLooper
public async ValueTask RoomLeave(Ulid playerUlid)
{
    // 退室通知のあったプレイヤーをリストから削除する
    if(roomInfo.Players.RemoveAll(p => p.Ulid == playerUlid) < 1) return;
    
    logger.ZLogInformation($"{playerUlid} is leaved.");
    await nats.Publish(roomInfo.Ulid.ToString(), new OnLeaveRoomMsg(playerUlid));
}

public bool Update(in LogicLooperActionContext context)
{
    // ルームから全員が退室していたらUpdateを止める
    if(roomInfo.Players.Count == 0) return false;
    questionService.Update(context);
    logger.ZLogInformation($"Update!!!");
    return true;
}

LogicLooperサーバのログを見ると全員が退室したあとはUpdateのログがでなくなっているのでちゃんと機能してそうです

10.png

ただし、これには欠陥があって、仮にMagicOnionのサーバに障害があってLogicLooperに通知を送れない場合は困ります。逆もまたしかり
NATSを経由しているので相互に状態を確認しに行くことができないので、HeartBeatやKeepAliveの設定が必要になるのだと思います

まあこんな感じで今回はかなり適当に作っているところも多く
「メッセージを抽象化してNatsのサブスクライブは少なくした方が良いんじゃない?」とか「そもそもStreamingHubってやっぱり同時接続は1つの方が良いんじゃない?」みたいな考えもありました
僕は見様見真似のド素人なので最適解はわからず。。。ですが、割と数少ない MagicOnion ⇔ NATS ⇔ LogicLooper の貴重なサンプルを提示できたのではないかと思います
それでは、ご参考までに

おわりに

Miniverseというプロジェクト名にしてこの記事を書き始めましたが、師走は忙しなく、あまり良い感じのサンプルを残せなくて後悔...
ちょっとしたメタバースっぽい感じにしてUnityでも十分な画面を見せられたらと思ったのですが...
「MagicOnion, NATS, LOgicLooperも使って、サービスディスカバリも作って、ふかマケ程度ですが。
LogicLooperのUpdateは手動で止めるまでずっと動き続けるのでちゃんと止めてやる必要があります
止め方はとても簡単。Updateメソッドの返り値をfalseにするだけです。

LogicLooper
public bool Update(in LogicLooperActionContext context)
{
    if(なんか条件) return false;
    return true;
}

MatchingHub(MagicOnion)からの切断通知を使ってLogicLooperに退室を知らせるようにしてみました

MagicOnion
public class MatchingHub(ILogger<MatchingHub> logger, NatsPubSub nats) : StreamingHubBase<IMatchingHub, IMatchingReceiver>, IMatchingHub
{
    // ~~中略~~
    protected override async ValueTask OnDisconnected()
    {
        // LogicLooperに切断を通知
        await nats.Publish(roomUlid.ToString(), new DisconnectMsg(player.Ulid));
        cancellation.Cancel();
        cancellation.Dispose();
    }
}

LogicLooperでは管理している自作のプレイヤーのリストがあるので、そこから削除します
リストが空ならゲームを終わらせる、という感じです

LogicLooper
public async ValueTask RoomLeave(Ulid playerUlid)
{
    // 退室通知のあったプレイヤーをリストから削除する
    if(roomInfo.Players.RemoveAll(p => p.Ulid == playerUlid) < 1) return;
    
    logger.ZLogInformation($"{playerUlid} is leaved.");
    await nats.Publish(roomInfo.Ulid.ToString(), new OnLeaveRoomMsg(playerUlid));
}

public bool Update(in LogicLooperActionContext context)
{
    // ルームから全員が退室していたらUpdateを止める
    if(roomInfo.Players.Count == 0) return false;
    questionService.Update(context);
    logger.ZLogInformation($"Update!!!");
    return true;
}

LogicLooperサーバのログを見ると全員が退室したあとはUpdateのログがでなくなっているのでちゃんと機能してそうです

10.png

ただし、これには欠陥があって、仮にMagicOnionのサーバに障害があってLogicLooperに通知を送れない場合は困ります。逆もまたしかり
NATSを経由しているので相互に状態を確認しに行くことができないので、HeartBeatやKeepAliveの設定が必要になるのだと思います

まあこんな感じで今回はかなり適当に作っているところも多く
「メッセージを抽象化してNatsのサブスクライブは少なくした方が良いんじゃない?」とか「そもそもStreamingHubってやっぱり同時接続は1つの方が良いんじゃない?」みたいな考えもありました
僕は見様見真似のド素人なので最適解はわからず。。。ですが、割と数少ない MagicOnion ⇔ NATS ⇔ LogicLooper の貴重なサンプルを提示できたのではないかと思います
それでは、ご参考までに

おわりに

Miniverseというプロジェクト名にしてこの記事を書き始めましたが、師走は忙しなく、あまり良い感じのサンプルを残せなくて後悔...
ちょっとしたメタバースっぽい感じにしてUnityでも十分な画面を見せられたらと思ったのですが...
「MagicOnion, NATS, LOgicLooperも使って、サービスディスカバリも作って、docker-composeも挑戦してみたり!負荷テストのDFrameとかもつかっちゃおーかなーー!ミニゲームもマジョリティーゲームだけじゃなくて色んなサンプル増やしちゃおー!」みたいにウキウキしてたものです
そういう思いをこめてこんな大層な名前にしましたが、今回はこれでご容赦ください
UnityのアドカレなのにUnity要素がかなり少なく申し訳ない...

そしていつになっても文章を書くのが下手すぎてゴメーーン🙇‍♂️

最後に
いつも素晴らしいOSSを手掛けていただいている方々に感謝!!!!!!!!!

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?