Unity用ネットワークライブラリSynicSugar Vol.2

# SynicSugar
SynicSugarというネットワークライブラリを作って1年以上経ちました。Epic online servicesをリレーとマッチングサーバーに使ったUnity用のライブラリです。

https://github.com/skeyll/SynicSugar

### 特徴
 - 無料
 - CCU制限なし
 - フルメッシュのp2p
 - ボイスチャット対応（最大16人）
 - PC(LinuxはVC非対応）、モバイル、CSに対応しておりクロスプラットフォーム
 - マッチメイクやホストマイグレーション、復活機能をライブラリ側でフルサポート


以下でSynicSugarの特色についての紹介をします。

　サンプルについては３つほど用意してあるので、それを参考にしてください。機能を一通り見たいならChatというサンプルが全て詰め込んであってわかりやすいと思います。3D風のゲームになる予定のTank、初期に動作確認用に作ったReadHeartというターン制対戦ゲームの三つです。
　ドキュメントにRPCを送るまでのチュートリアルもあります。最初に書いてから触ってないのでSampleのほうがいいかもしれません。
[SynicSugar- Helloworld](https://skeyll.github.io/SynicSugar/GettingStarted/helloworld/)


　私のプロジェクトでも通信部分のStartまではログインもマッチメイクも条件が違うぐらいでほぼ同じ処理を使っています。マッチング後だいたいAllSpawnで一括生成しています。

```csharp:matchingmono.cs
//mono内でこんな感じでマッチングしています。

        MatchingGUI gui;
        void Start() {
            gui = this.GetComponent<MatchingGUI>();
            //Set event
            MatchMakeManager.Instance.MatchMakingGUIEvents = GenerateGUIEvents();
            if(!Config.Instance.MatchInfo.isOfflineMode){
                DisplayGUIs().Forget();
            }
            MatchingProcess().Forget();
        }
        async UniTask MatchingProcess(){
            MatchMakeManager.Instance.lobbyIDMethod.Register(() => Config.Instance.MatchInfo.Save(), () => Config.Instance.MatchInfo.Delete());
            await UniTask.Yield();
            //最近オフラインモードに対応しました！
            //同一コードでオフラインで動かせます
            //そのうちログインなしでも完全に機能するようにします
            //今はLocalのUserIDを使っているのでEOSにログインしないと機能しません・・・
            if(Config.Instance.MatchInfo.isOfflineMode){
                StartTutorial().Forget();
                return;
            }
            //Reconnecter
            if(Config.Instance.MatchInfo.isReconnecter){
                DebugManager.UpdateLog("/ MatchMaking1");
                Reconnect().Forget();
                return;
            } 
            DebugManager.UpdateLog("/ MatchMaking2");
            MatchingConditionMaker maker = new();
            Lobby condition = maker.GenerateLobbyCondition();
            //Casual 
            if(Config.Instance.MatchOptions.Mode is MatchOptions.MODE.Casual){
                StartCasualMatchmake(condition).Forget();
                return;
            }
            ...
            //Custom マッチメイクのコード
        }
        
        /// <summary>
        /// Start matchmaking with SearchAndCreateLobby api.
        /// </summary>
        async UniTask StartCasualMatchmake(Lobby condition){
            //これは次のバージョンで（Bool, Result)を返すように変更予定
            bool isSuccess = await MatchMakeManager.Instance.SearchAndCreateLobby(condition);
            if(!isSuccess){
                Config.Instance.MatchingErrorCode = (int)MatchMakeManager.Instance?.GetLastErrorCode();
                await BackToMainMenu();
                return;
            }
            await StartBattle();
        }
```
　ユーザーに依存しない、ゲームシステム系のものはNetworkCommonsにMonoもつけずに書いています。そしてコンストラクタでConnectHub.Instance.RegisterInstance(this)で登録、使用時はConnectHub.Instance.GetInstance<XXX>()で呼び出すみたいな使い方です。
プレイヤーの行動を同期したい時はそれぞれのシーンに応じたRpcを呼び出すクラスを生成して、そのクラス内の関数でConnectHub.Instance.GetPlayerInstance<XXX>(p2pinfo.Instance.LocalUserId)でネットワーク属性付きのRpcを呼び出すクラスを取得して、RPCを呼び出すという感じです。



```csharp:DisconnectedUserProcess.cs
    //落ちたユーザーがいた場合にホストがそのユーザーの処理を補完するときに使うクラス
    //メインクラスのStart内でnew ()しています
    [NetworkCommons(true)]
    public partial class DisconnectedUserProcess {
        public DisconnectedUserProcess(){
            ConnectHub.Instance.RegisterInstance(this);
        }
        ...
        ...
        //これがやっているのはNetworkCommonsという誰でもRPCできる処理をHostが作ったcomplementsで起動しています
        //NetworkPlayerは本人が呼び出さないとRPCとして送信されないのでターゲットのID付きで送信して
        //ここ経由で直接書き換えています
        //このクラス自体も必要なときに
        //ConnectHub.Instance.GetInstance<DisconnectedUserProcess>().HostComplementProblems(...);
        //こんなかんじで呼んで使っています。もちろん直接Player側で書き換えてもいいですが・・・
        /// <summary>
        /// Host generate disconnected user problems
        /// </summary>
        /// <param name="complements"></param>
        [Rpc]
        public void HostComplementProblems(ComplementProblem complements){
            Player target = ConnectHub.Instance.GetUserInstance<Player>(UserId.GetUserId(complements.UserId));
            target.SubmitProblem(complements.Problems);
        }
    }
```

　SyncVarは他のライブラリみんな持ってるので追加してみたものの全く使っていないので実装当初から中身が変わっておらずパフォーマンスがかなり悪くいくつかバグもあります。そのうちなんとかします。


  
### 去年時点との違い（宣伝を含む）
　去年の夏時点で自作ゲームと共に正式リリースしてるはずでしたが、夏頃からライブラリのテスト用ゲームを開発していました。
　オンライン対応予定で作っていたとはいえオフライン用のプロジェクトに復帰機能を実装するのは難しく、無料+ゲーム内課金のゲームでいきなりライブラリを試すのは不安があったためです。

　そして今回ライブラリのために作ったのがPonolfという人狼風のゲームです。最大12人対戦に対応しています。価格は500円です。デモ版では4人ランダムマッチングで遊べます。有料版に関しては一部アセットの暗号化のみで難読化の予定はありません。6月28日にリリース予定なので、よければ一度プレイしてください。
[Ponolf - Steam](https://store.steampowered.com/app/2901540/)

　ポノルフは簡単にいうとマイノリティを探す人狼です。問題を作って、解いて、誰が間違ったかを話し合います。ギスギスしそうな内容ですが、そうならないように努力はしました。このゲームではVC機能の使用や描いた絵を送信する必要があり、VCやLarge Packet周りの対応を行いました。一応プッシュトゥトークにもライブラリ側で対応しています。パケットは通常1170byteまでしか送信できませんが、RPCの属性に(true)を渡すとデータをMemoryPackで圧縮後300KBまでなら送信できます。だいたい1000x700のpng一枚です。ゲーム用の常識的なデータならほぼ全て送ることができます。


　その他受信面でも強化されています。SynicSugarは取得処理を毎フレームUpdate内で呼んでパケットを取得しています。PCではそれで問題ないのですが、モバイルは60fps制限、またゲームによっては電力消費を抑える、fpsを合わせるためなどでfpsに上限を設けるはずです。その時最大60回では1000バイトに分割される300KBものパケットを受信しきれません。そこでfps間に複数回受信可能なBurstFPSという設定があります。バッファーにパケットがなければ1fpsで一回だけ取得を試み、パケットがあれば最大指定回数分取得するという感じです。パケットはライブラリ内のクラスで筑瀬されて、データの復元に必要なだけ取得し終わったら元のデータにしてRPCを呼びます。


### Synic
　SynicSugarの最大の特徴はこのSynicになります。Synic Sugarではホストマイグレーションと復帰に関する機能をフルサポートしており、復帰時に必要なデータにSynicとつけるだけでこのSynicとつけたNetworkPlayerとNetworkCommons内の全ての変数を一括で送信できます。（LargePacketと同じ扱いなので300KB制限はあります。Ponolfでは復帰時に絵もこれで同期するのですが、だいたい30枚分ぐらいでバグるはずです。）


　意味合い的には実装がSonicなSyncです。Synicをつけたらライブラリのソースジェネレータでそれらを一括同期するための処理を書きます。内部的には10個のシリアライズ用JSONクラスをあらかじめ用意しておき、JSONとして圧縮してstringでやり取りするという方法で実現しています。これはソースジェネレータで生成したコードにさらにソースジェネレータを走らせることはできないため、Synic用のMemoryPackクラスを作ることができずこうなりました。そのため無駄にjsonにしてからbyteにするため、パフォーマンスは決していいとは言えず、復帰時や大量にデータを送りたい時用で、多人数向けに多用することは考えていません。

```csharp:ChatPlayer.cs
[NetworkPlayer(true)].
    [NetworkPlayer(true)]
    public partial class ChatPlayer : MonoBehaviour {
        //こんな感じで復帰時に同期が必要そうなデータにSynicとつけます
        //Unity標準のJsonUtilityでJson化して送るので、dictionaryなどは別途クラスでラップしないといけません
        //そのうちサンプル書きますがクラスでもなんにでもつけれます
        //PSyncVarとは併用できませんが、自分が使わないので修正が後回しになっています
        //Privateで使えたり自作ClassのListだとFullPathじゃじないといけなかったりするのもそのうち直します
        //どうにか頑張れば動くので直していませんでした
        [Synic(1)]
        public string LargePacket;
        [Synic(0)]
        public int submitCount;
        [Synic(2)] public List<int> intList;
        [Synic(2)] public List<NameSpace.Fullpath.DataClass> dataClasses;
        [Synic(3)] public NameSpace.Fullpath.DataClass dataClass;
        ...

        // これは復帰時通知にイベント登録して勝手に誰かが再接続したら呼び出されます
        // 各自自分のデータを復帰者に送り
        //　SynicType.WithOthersならホストが落ちている他のプレイヤーのデータも送ります
        //　Synicの後の数字は同期のフェーズを示しています。以下の場合は1番までのものしか送りません
        // 自分がターン制のゲームが好きなのでフェーズを追加したのですが区分する必要があるのかどうかはわかりません
        // 誰かが落ちても接続は途切れずホストマイグレーションはEOSのロビー側が勝手に行います
        //　落ちてからDisconnect通知まで10秒ぐらいラグがあるので格ゲーで復帰機能を使うのは難しいかもしれません
        // 落ちて、死んで、次のラウンドで復帰を待つとかならいけます
        void OnConnected(UserId id){
            chatText.text += $"{id} Join {System.Environment.NewLine}";
            //Send local data
            ConnectHub.Instance.SyncSynic(id, SynicType.WithOthers, 1, false);
        }
```


　そしてもう一つ、Cynicという意味も持っています。どんなネットワークライブラリでもおそらく自分の所有権があるものしか操作できないはずです。他者のPlayerオブジェクトを好き放題操作させるわけにはいけません。SyncSynicという関数を呼ぶことで最大10つの段階で指定していつでもSynicを一括送信できるのですが、他者が本人以外のデータを上書きできるのはそのユーザーが復帰した時に一度のみ、ホストが送信した場合のみです。それ以外の場合はパケットを破棄します。

　どんなライブラリでも落ちたユーザーにデータを引き渡すために、ホスト（もしくはそのユーザーのデータを持ってるユーザー）が落ちたユーザーからデータを集めて直接データを上書きする機能を書く必要があります。専用サーバーがあるのならそうした処理はサーバーからしか送られてこないため安易にデータが書き換えられる心配はありません。しかし、p2p通信でそうした機能を用意してしまうと、それを呼び出しさえすれば他のユーザーが数値をいつでも書き換えられるということを意味します。ホストがチーターだった場合に落ちてしまうとデータ書き換えを防げませんが、その場合は運が悪いので仕方ないです。

　もちろんこれで完全にチートを防げるわけではなく、例えば特定のユーザーが攻撃力が100倍になったり攻撃頻度が間断なく続いたりはします。しかし、所有権のあるインスタンスしか扱えないを徹底し、データの変更はその所有者経由のみにすることで誰がチーターかはわかりやすくなります。サーバーを持たないp2p通信ではチート対策することはできません。ですが、HPやゲームの状態を直接書き換えられることがないのなら、異常な振る舞いをするユーザーにゲーム外で対処することならできるはずです。


### おすすめのネットワーク（ライブラリ）
　では積極的に使用をおすすめかというと私としては個人開発ならMirror、Photon、Netcode for Objectのどれかをおすすめします。理由としてはSynic Sugarの通信部分がEOSしか対応していないためです。そのうちなんとかなるかもしれませんが、今現在はEOS次第でオンライン機能が使えなくなってしまいます。（規約によると使えなくなる場合は告知から３年。しかしアカウント停止などは予測不能）

- Mirror
一番おすすめです。
ホストマイグレーションが必要なければなければ、EOS relayをエディタでアタッチするだけで無料でオンラインゲームが作れます。（EOS SDKのバージョンが古いのでPCAndroid以外で動かす場合は手直しが必要）通信部分だけを他のものに差し替えることができるのでsteam対応したものをeosに変えたりなどの融通も効きます。ホストが落ちてしまうと部屋自体解散されてしまいます。しかし、それゆえにデータを完全に同期する必要がなく、クライアントホスト方式で書くことができ、その場合は専用サーバー方式への移行も簡単です。

- Photon
同説100人まで無料です。同説100人のオンラインゲームならおそらく収益は10万ぐらいはあるはずで、photon代も支払えます。ティックレートに基づいた正確な同期ができます。ホストマイグレーションなども対応しており（難易度高め、落ちた時点にサーバーが巻き戻る）、サポートも情報も手厚いです。もし、モバイルゲームを開発していて、金銭的な余裕があるのなら最もおすすめです。書き方がやや特殊で難しいイメージはあります。

 - Netcode for Object
Unityが開発しています。元々MLAPIという名前の個人開発OSSをUnityが買収して公式プロジェクトになりました。EOS unity pluginがあるのですが、そのトランスポートはこのライブラリに対応しています。そのためSynicSugarと同じ様々なプラットフォームで使うことができます。ホストマイグレーションは対応していませんが、おそらくホストが落ちても部屋が解散されることはないです。UnityのRelayを使った場合は最大200人まで接続可能です（UGSは有料）。さまざまな接続方式やトランスポートに対応しており、Unity公式のプロジェクトなので安心感もあります。実用を想定したサンプルプロジェクトも最近でました。


　SynicSugarは実際にゲーム開発で使う機能をライブラリ側に追加して行っているので、必要そうな機能があるという点は良いところかもしれません。書き方はunet系のライブラリから大きく離れないようにはしたので、Mirrorなどのunity系ライブラリとは直感で置換できるはずです。あとは無料です。
　ホストが落ちてもゲームを続けたいのでMirrorではダメ、Photonは初動の資金繰りが怖いし難しい、サーバー管理はしたくないので他のライブラリはなし（サーバー管理は必要ですがMagicOnionはハイパフォーマンスで比較的簡単(難しい）にできます。
[MagicOnionでリアルタイム通信する話](
https://speakerdeck.com/pulse1923/metabasupuratutohuomu-inspix-world-haphpmoc-plus-plus-momatometec-number-nitong-magiconiongazhi-erubatukuendozui-shi-hua-shou-fa)　）、少人数ゲーム、そういう場合にはおすすめかもしれません。要するに簡単な個人開発ゲームです。セキュリティーに関しては他のEOS系ライブラリと違い通信に必要なSocketID（他ライブラリでは基本的に固定でUserIDだけわかればp2pリクエストの送信が可能）をロビー（マッチング）が締め切られた後にランダムの文字列で発行してp2p通信に移行するので比較的安全だと思います。その代わり自由度が著しく低く、マッチングが締め切られた後はそのロビーIDがわかるユーザーしか再接続できないので乱入的システム的なのもAmoungUS的なマッチング待ち時間中の交流みたいなのもできません。
　そのうち細かな送信設定、受信設定に加えて、タイトルストレージ、アセットの暗号化等もつける予定です。ロールバックなども検討していますが、まだわかりません。お手軽オンラインゲーム開発キットのようなものを目指しています。