MagicOnionを使ってリアルタイム通信を試す。【Unity】
はじめに
1年ほど前に使ったことがあったので、 久しぶりに導入してみたら躓いたので、 記録として残しておきます。
とりあえず覚えながら作ってみたやつ
マッチングシステムができた。はず pic.twitter.com/eL2IfDtwad
— tasky (@fromtasky) 2020年4月17日
別々に動いた。とりあえずローカルではOK pic.twitter.com/TtDQyUkb0N
— tasky (@fromtasky) 2020年4月21日
MagicOnionについて
Web API ライクな通信や、リアルタイム通信ができるフレームワーク
特徴
サーバー側もクライアント側もC#で書ける。
gRPCを使用。(HTTP/2や、Protocol Buffersを使っていて、高速)
MessagePack for C#を使用。(LZ4で圧縮されてサイズが小さく、高速)
フィルタ機能を持ち、多段に重ねて処理を行うことができる。 ←この様子がMagicOnionの由来だとか
(↑通信の前後に追加の処理を行うことができる。)
色々調べていたら、チャットとか、マルチプレイを実現できそうなものとして、下記のようなものもありました。
SignalR
Photon(PUN2)
MagicOnionを選んだ理由は、1度勉強会で扱ったことがあるからです。
使うイメージ
クライアント側とサーバー側が共有するインターフェースを用意して、 それぞれ実装します。 互いのメソッドを呼び出してあげれば通信できます。
準備
- Unity
2018.4.2f1
各種ダウンロード
ダウンロードファイルの***.zipファイルですが、MagicOnionをIL2CPP環境で使う場合に使用します。 この対応については、最後の方に後述します。
MagicOnion
https://github.com/cysharp/MagicOnion/releases
バージョン
3.0.11
取得するファイル
MagicOnion.Client.Unity.unitypackage
moc.zip
gRPC
バージョン
2.26.0
取得するファイル
- grpc_unity_package.2.26.0-dev.zip
その時最新だった2.29.0で試したが、VisualStudioでプロジェクト作成して作業しようとしたら、「予期せぬエラーです。」と表示されてしまった。
分からなかったので、このバージョンに落とした。
もしかしたら、System.Memory.dllバージョンが噛み合ってないからだろうか・・・。
https://github.com/grpc/grpc/issues/21908
↑2.26.0で動いたというコメントがあったので、このバージョンを使用した。
MessagePack for C#
https://github.com/neuecc/MessagePack-CSharp/releases
バージョン
2.1.90
取得するファイル
MessagePack.Unity.2.1.90.unitypackage
mpc.zip
導入
Unityの設定変更
「File」→「Build Settings」から、 「Player Settings...」 を押す。
ビルド対象OSの 「Other Settings」項目 → 「Configuration」項目の
「Api Compatibility Level」 → .NET 4.x 「Allow 'unsafe' Code」 → チェック
に設定する。
用意したファイルのインポート
Unityプロジェクトを立ち上げ、**.unitypackageのファイルをインポートします。
MagicOnion.Client.Unity.unitypackage
MessagePack.Unity.2.1.90.unitypackage
ファイルの競合
導入時に、Assets/Plugins/ 直下の
System.Buffers
System.Memory
System.Runtime.CompilerServices.Unsafe
System.Threading.Tasks.Extensions
上記のファイルが競合するので、
名前を変えて、競合を回避する。
なんか消すのが怖かったので、実際どうなんだろう・・・。
これらを行えば、とりあえずUnity Editor上のエラーは消えた。
ひとまずテキトーにスクリプトを開いてVisual Studioを立ち上げる。
VisualStudioの設定
VisualStudioに.Net Core SDKとランタイムをインストールしていない場合は、インストールしておきます。 私の環境だと入っていたので、多分この辺りを確認すればよいと思います。
クライアント側の用意
特になし
サーバー側の用意
Unityのソリューションから右クリックで、
新規のプロジェクトを作成する。
サーバー側にパッケージをインストールする。
MagicOnion.Hosting
MessagePack.UnityShims
図:パッケージインストール例
(今思えば、これらのパッケージを参考にクライアント側のバージョンを合わせてダウンロードすればよかったのでは?と思う自分であった。)
クライアント - サーバー間の共有ファイルの設定
サーバー側のプロジェクトファイルを編集して、共有するフォルダの設定を行います。
- クライアント側
- サーバー側
サーバー用プロジェクトの設定に、
Unityプロジェクトの共有用フォルダのパスを指定します。
クイックスタートをやってみる
GitHubに載ってたクイックスタートを参考にやってみる。
https://github.com/cysharp/MagicOnion
サーバーのスタート処理
- Program.cs(サーバー側)
using System; using Grpc.Core; using MagicOnion.Server; namespace MagicOnionDemoServer { class Program { static void Main(string[] args) { GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger()); // setup MagicOnion and option. var service = MagicOnionEngine.BuildServerServiceDefinition(isReturnExceptionStackTraceInErrorDetail: true); var server = new global::Grpc.Core.Server { Services = { service }, Ports = { new ServerPort("localhost", 12345, ServerCredentials.Insecure) } }; // launch gRPC Server. server.Start(); // and wait. Console.ReadLine(); } } }
API通信
コーディング
共有するインターフェースを作成する。
- IMyFirstService.cs
using Grpc.Core; using MagicOnion; using MagicOnion.Server; using System; namespace ServerShared.Services { public interface IMyFirstService : IService<IMyFirstService> { /// <summary> /// 足し算を行う /// </summary> /// <param name="x"></param> /// <param name="y"></param> /// <returns></returns> UnaryResult<int> SumAsync(int x, int y); } }
サーバー側実装
- MyFirstService.cs
using System.Collections.Generic; using System.Text; using Grpc.Core; using MagicOnion.Hosting; using MagicOnion; using MagicOnion.Server; using ServerShared.Services; class MyFirstService : ServiceBase<IMyFirstService>, IMyFirstService { public async UnaryResult<int> SumAsync(int x, int y) { Logger.Debug($"Received:{x}, {y}"); return x + y; } }
クライアント側実装
- ApiConnectionTest.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; using Grpc.Core; using MagicOnion.Client; using ServerShared.Services; public class ApiConnectionTest : MonoBehaviour { async void Start() { // standard gRPC channel var channel = new Channel("localhost", 12345, ChannelCredentials.Insecure); // get MagicOnion dynamic client proxy var client = MagicOnionClient.Create<IMyFirstService>(channel); // call method. var result = await client.SumAsync(100, 200); Debug.Log("Client Received:" + result); } }
動作確認
サーバー側のプロジェクトをスタートアッププロジェクトに設定し、
プログラムを実行します。
クライアント側は、 空のGameObjectを作成して、ApiConnectionTest.csをアタッチして実行します。
通信できました。
リアルタイム通信
コーディング
インターフェースを作成
- IGamingHub.cs
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using MagicOnion; using ServerShared.MessagePackObjects; using UnityEngine; namespace ServerShared.GamingHub { /// <summary> /// クライアント → サーバー 処理 /// </summary> public interface IGamingHub : IStreamingHub<IGamingHub, IGamingHubReceiver> { /// <summary> /// 入室処理 /// </summary> /// <param name="roomName"></param> /// <param name="userName"></param> /// <param name="position"></param> /// <param name="rotation"></param> /// <returns></returns> Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation); /// <summary> /// 退出処理 /// </summary> /// <returns></returns> Task LeaveAsync(); /// <summary> /// 移動処理 /// </summary> /// <param name="position"></param> /// <param name="rotation"></param> /// <returns></returns> Task MoveAsync(Vector3 position, Quaternion rotation); } /// <summary> /// サーバー → クライアント 処理 /// </summary> public interface IGamingHubReceiver { /// <summary> /// 入室を接続クライアント全員に通知 /// </summary> /// <param name="player"></param> void OnJoin(Player player); /// <summary> /// 退出を接続クライアント全員に通知 /// </summary> /// <param name="player"></param> void OnLeave(Player player); /// <summary> /// 移動したことを接続クライアント全員に通知 /// </summary> /// <param name="player"></param> void OnMove(Player player); } }
サーバー側実装
- GamingHub.cs
using System; using System.Collections.Generic; using System.Text; using MagicOnion; using MagicOnion.Server.Hubs; using Grpc.Core; using ServerShared.MessagePackObjects; using ServerShared.GamingHub; using System.Threading.Tasks; using UnityEngine; using System.Linq; namespace MagicOnionDemoServer { public class GamingHub : StreamingHubBase<IGamingHub, IGamingHubReceiver>, IGamingHub { IGroup room; Player self; IInMemoryStorage<Player> storage; public async Task<Player[]> JoinAsync(string roomName, string userName, Vector3 position, Quaternion rotation) { self = new Player() { Name = userName, Position = position, Rotation = rotation }; // Group can bundle many connections and it has inmemory-storage so add any type per group. (room, storage) = await Group.AddAsync(roomName, self); // Typed Server->Client broadcast. Broadcast(room).OnJoin(self); return storage.AllValues.ToArray(); } public async Task LeaveAsync() { await room.RemoveAsync(this.Context); Broadcast(room).OnLeave(self); } public async Task MoveAsync(Vector3 position, Quaternion rotation) { self.Position = position; self.Rotation = rotation; Broadcast(room).OnMove(self); } /// <summary> /// 切断処理 /// </summary> /// <returns></returns> protected override ValueTask OnDisconnected() { // on disconnecting, if automatically removed this connection from group. return CompletedTask; } } }
クライアント側実装
- GamingHubClient.cs
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using Grpc.Core; using MagicOnion.Client; using ServerShared.GamingHub; using ServerShared.MessagePackObjects; using UnityEngine; public class GamingHubClient : IGamingHubReceiver { Dictionary<string, GameObject> players = new Dictionary<string, GameObject>(); IGamingHub client; public async Task<GameObject> ConnectAsync(Channel grpcChannel, string roomName, string playerName) { client = StreamingHubClient.Connect<IGamingHub, IGamingHubReceiver>(grpcChannel, this); var roomPlayers = await client.JoinAsync(roomName, playerName, Vector3.zero, Quaternion.identity); /* 今いるプレイヤーの生成(自分も含めて生成される) foreach (var player in roomPlayers) { (this as IGamingHubReceiver).OnJoin(player); } */ return players[playerName]; } // methods send to server. public Task LeaveAsync() { return client.LeaveAsync(); } public Task MoveAsync(Vector3 position, Quaternion rotation) { return client.MoveAsync(position, rotation); } // dispose client-connection before channel.ShutDownAsync is important! public Task DisposeAsync() { return client.DisposeAsync(); } // You can watch connection state, use this for retry etc. public Task WaitForDisconnect() { return client.WaitForDisconnect(); } // Receivers of message from server. void IGamingHubReceiver.OnJoin(Player player) { Debug.Log("Join Player:" + player.Name); var cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.name = player.Name; cube.transform.SetPositionAndRotation(player.Position, player.Rotation); players[player.Name] = cube; } void IGamingHubReceiver.OnLeave(Player player) { Debug.Log("Leave Player:" + player.Name); if (players.TryGetValue(player.Name, out var cube)) { GameObject.Destroy(cube); } } void IGamingHubReceiver.OnMove(Player player) { Debug.Log("Move Player:" + player.Name); if (players.TryGetValue(player.Name, out var cube)) { cube.transform.SetPositionAndRotation(player.Position, player.Rotation); } } }
- RealTimeConnectionTest.cs
テキトーなGameObjectを作成して、アタッチします。
using System.Collections; using System.Collections.Generic; using UnityEngine; using Grpc.Core; using ServerShared.GamingHub; public class RealTimeConnectionTest : MonoBehaviour { public static GamingHubClient gamingHubClient; Channel channel; async void Start() { this.channel = new Channel("localhost:12345", ChannelCredentials.Insecure); RealTimeConnectionTest.gamingHubClient = new GamingHubClient(); await gamingHubClient.ConnectAsync(channel, "SampleRoom", "tarou"); } }
動作確認
API通信と同様、サーバー側を実行しておいて、 クライアント側を実行します。
うまくいけば、四角いプレイヤーオブジェクトが生成されます。 これに、プレイヤーを動かすスクリプトを実行中にアタッチして、 確認してみます。
- PlayerController.cs
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PlayerController : MonoBehaviour { float speed = 3f; Vector3 pos, currentPos; void Update() { Move(); } void Move() { pos = transform.position; currentPos = pos; if (Input.GetKey("left")) { pos.x -= speed * Time.deltaTime; } if (Input.GetKey("right")) { pos.x += speed * Time.deltaTime; } if (Input.GetKey("up")) { pos.y += speed * Time.deltaTime; } if (Input.GetKey("down")) { pos.y -= speed * Time.deltaTime; } if (currentPos == pos) //値が変わらなかったら何もしない { return; } //サーバーを通して移動できるか確認したいので、コメントアウト //transform.position = pos; //移動情報送信 RealTimeConnectionTest.gamingHubClient.MoveAsync(pos, transform.rotation); } }
↑*静止画です
移動はガタガタですが、情報のやりとりができました。
IL2CPP対応
MagicOnionをIL2CPP環境で使う場合は、事前のコードジェネレートが必要になります。
mpc.zipとmoc.zipファイルの中にコードジェネレートのための実行ファイルがあるので、
それを設定します。
minamiさんの記事が参考になりました。
- Unity+.NET Core+MagicOnionの環境構築ハンズオン
https://qiita.com/_y_minami/items/c7899fdf1db505c06ba2
その中のMenuItems.csを、こちらが使うファイルパスに変更して、InitialSettings.csは下記のように設定してみました。
(このあたりのインスタンス登録処理は手探り状態で、どれを登録したらよいか、よくわかっていない。)
- InitialSettings.cs
using MagicOnion.Resolvers; using MessagePack.Resolvers; using MessagePack.Unity; using MessagePack; using UnityEngine; class InitialSettings { [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] static void RegisterResolvers() { StaticCompositeResolver.Instance.Register( UnityResolver.Instance, MagicOnionResolver.Instance, GeneratedResolver.Instance, BuiltinResolver.Instance, PrimitiveObjectResolver.Instance ); var options = MessagePackSerializerOptions.Standard.WithResolver(StaticCompositeResolver.Instance); MessagePackSerializer.DefaultOptions = options; } }
MagicOnionResolver.Instance、GeneratedResolver.Instanceあたりは、
はじめは参照エラーだったが、
コードジェネレートした後に確認したら、参照エラーが解決していた。
さいごに
Taskや、await/asyncなどの理解をもう少し深めようと思った。
設計って難しい。
ここの処理はクライアント側orサーバー側に持たせる。とか
レスポンス速度これくらいで大丈夫かな。とか
通信が途中で切断したときどうしよう。とか
そういうところ意識しないとな。
参考
- MagicOnion入門
https://www.slideshare.net/torisoup/magiconion-174973732
https://tech.cygames.co.jp/archives/3181/
- Unity+.NET Core+MagicOnionの環境構築ハンズオン