学祭で作ったUnityのマルチプレイゲームでNetcode for GameObjectsを使った話

こんにちは！
今回の記事はUnityのNetcode for GameObjectsに関するものとなります。

この記事は [農工大アドベントカレンダー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/tuat) の10日目のものです。

https://qiita.com/advent-calendar/2024/tuat

## はじめに
私が所属するサークルでは、今年(2024年)の秋に開催された学祭でUnityを使ったマルチプレイゲームを制作しました。

マルチプレイを実装するにあたり、Netcode for GameObjectを使ったので実装の方法や所感などを述べようと思います。

ただし、Netcode for GameObjects以外のツール(MagicOnionなど)を使ったことがないため比較した話はできません…

## ゲームの概要
制作したゲームを簡単に説明すると、「跳弾する球を打ち合う4対4のシューティングゲーム」です。用意できるPCの数に限りがあるため、プレイアブルは最大4人となっています。

また、今回はクライアント+サーバーの構成で開発したため、プレイアブルなキャラクターの端末(クライアント)とは別にサーバー用の端末を用意しました。サーバーはヘッドレスではなく、プレイ中にステージ全体を映します。

さらに、Web上でキャラメイクやスコアのランキングの確認をできるようにしたため、Unityのみでは完結していません。

(プレイヤー視点において)ゲームの流れは次のようになっています。
1. Web上でキャラメイク
1. QRコードをゲームに読み込ませる
1. チームを選択
1. 対戦
1. 結果を確認

内部的にはキャラメイクやスコアのデータを、Supabaseを介してやり取りしています。

ここで、ゲーム中に同期すべき要素を挙げてみます。
- ゲーム全体
  - プレイヤーのUUID
  - プレイヤーのチーム
  - キャラメイクに関するデータ
  - スコアのデータ
- プレイ中
  - 各キャラクターの位置と向き
  - 球の位置と向き
  - 当たり判定
  - 各キャラクターのアニメーション

このように多くの情報を同期しなければならず、ネットワーク部分の実装は重いものとなりました。

## Netcode for GameObjectの実装
ここでは、実際の実装を基にNetcode for GameObjectsについて簡単に示します。細かい部分は、また記事を書けたらと思います。

Netcode for GameObjectsに関するドキュメントは公式のものが一番詳しく書いています。

https://docs-multiplayer.unity3d.com/netcode/current/about/

バージョン情報
Unity : 2022.3.45f1
Netcode for GameObjects : 1.10

### NetworkManager
まず、Netcodeを使うにはシーン上の任意の親オブジェクトに`NetworkManager`をアタッチします。`NetworkManager`をアタッチしたオブジェクトは`OnEnable`でDontDestroyOnLoadに移動されます。
この特性により、`NetworkManager`の存在するシーンが読み込まれるたびに`NetworkManager`が増えることになるため注意が必要です。(`NetworkManager`が複数あっても概ね問題なく動きますが、余分なオブジェクトが増えるのであまり良い状態ではありません)

そこで、制作したゲームのシーンとその呼び出し順は次のようになっています。
一番最初にのみ読み込まれるModeSelectに`NetworkManager`を配置することで2つ以上の`NetworkManager`が生成されることを防いでいます。

| シーン | 概要 |
|:-:|:-:|
| ModeSelect | サーバーorクライアントを選択 **(NetworkManagerを配置)** |
| Title | Web上で表示したUUIDのQRコードを読み取って確認(クライアントのみ) |
| Matching | マッチング |
| Play | プレイ |
| Result | 結果の表示 |

シーンの呼び出し順
- サーバー: ModeSelect -> Matching -> Play -> Result -> Matching -> …
- クライアント: ModeSelect -> Title -> Matching -> Play -> Result -> Title -> …

また、Netcodeはシングルトンパターンで実装されているため`NetworkManager`には`using Unity.Netcode;` をした上で、`NetworkManager.Sigleton`とすることでアクセスできます。

### Networkの開始
Netcodeには
- Server
- Host
- Client

の3つがあり、HostはServerでありClientです。
それぞれ、
```c#
NetworkManager.Singleton.StartServer();
NetworkManager.Singleton.StartHost();
NetworkManager.Singleton.StartClient();
```
で開始することができます。

また、プログラム中で`NetworkManager`の状態を確かめる場合はそれぞれ
```c#
NetworkManager.Singleton.IsServer;
NetworkManager.Singleton.IsHost; // IsServerとIsClientもtrueとなる
NetworkManager.Singleton.IsClient;
```
がtrueになっているかを見てあげればよいです。

### 接続チェック
ServerまたはHostでネットワークをホストとするとき、ネットワークを開始する前に`NetworkManager.Singleton.ConnectionApprovalCallback`へ関数を登録することで、Clientの接続をチェックすることができます。

```c#:例
// サーバー開始前に接続チェック用の関数を登録しておく
private InitializeServer()
{
    NetworkManager.Singleton.ConnectionApprovalCallback = ApprovalCheck;
    NetworkManager.Singleton.StartServer();
}

// サーバーで使用される関数
private void ApprovalCheck(
    NetworkManager.ConnectionApprovalRequest request,
    NetworkManager.ConnectionApprovalResponse response
    )
    {
        response.Pending = true; // 一度response.Pendingをtrueにした後にfalseとすると処理が終了したとみなされる

        // ゲーム(Server)のステートがマッチングなら拒否
        if (gameStateManager.GameState != GameState.Matching)
        {
            response.Approved = false; // response.Approvedがfalseなら拒否
            response.Reason = $"Server is not Matching. Now: {gameStateManager.GameState}"; // 拒否理由を入れることができます
        }
        else if (NetworkManager.Singleton.ConnectedClients.Count >= maxClientCount) // 最大接続人数に達していたら
        {
            response.Approved = false;
            response.Reason = $"Client count reached maxClientCount: {maxClientCount}";
        }
        else
        {
            response.Approved = true; // response.Approvedがtrueなら受け入れ
        }

        Debug.Log($"[Server] Approve client: {response.Approved}");
        response.Pending = false; // response.Pendingをfalseにして処理終了
    }
```

### 接続直後の処理
クライアントの接続直後に実行する関数をあらかじめ`NetworkManager.Singleton.OnClientConnectedCallback`に登録しておくことができます。ここに登録した関数は、接続されたServerまたはHostと接続したClientで実行されます。

登録する関数の引数は`ulong`型の値で、これは接続したClientに振られたユニークなIDです。

なお、前述したApprovalCallbackで接続拒否されたときは呼び出されません。

```c#:例
// サーバー開始前に接続直後の処理を行う関数を登録
private InitializeServer()
{
    NetworkManager.Singleton.OnClientConnectedCallback = OnClientConnected;
    NetworkManager.Singleton.StartServer();
}

// クライアント開始前に接続直後の処理を行う関数を記述
private InitializeClient()
{
    NetworkManager.Singleton.OnClientConnectedCallback = OnConnectedToServer;
    NetworkManager.Singleton.StartClient();
}

private OnClientConnected(ulong clientId)
{
    // クライアントが接続してきたときの処理を記述
}

private OnConnectedToServer(ulong clientId)
{
    // サーバーに接続したときの処理を記述
}

```

### 接続解除前の処理
接続直後の処理と同様に、`NetworkManager.Singleton.OnClientDisconnectCallback`にClientが接続を解除する直前に呼び出す関数を登録することができます。

登録する関数の引数は`ulong`型の値で、これは接続を解除するClientに振られていたユニークなIDです。

なお、Clientでは前述したApprovalCallbackで接続拒否されたときにも呼び出されます。

```c#:例
// サーバー開始前に接続解除時の処理を行う関数を登録
private InitializeServer()
{
    NetworkManager.Singleton.OnClientDisconnectCallback = OnClientDisconnect;
    NetworkManager.Singleton.StartServer();
}

// クライアント開始前に接続解除時の処理を行う関数を記述
private InitializeClient()
{
    NetworkManager.Singleton.OnClientDisconnectCallback = OnDisconnectFromServer;
    NetworkManager.Singleton.StartClient();
}

private OnClientDisconnect(ulong clientId)
{
    // クライアントが接続を解除する前の処理を記述
}

private OnDisconnectFromServer(ulong clientId)
{
    // 接続を解除する前の処理、または接続が拒否された際の処理を記述
}
```

### GameObjectの同期
GameObjectを同期したい場合、NetworkObjectコンポーネントを追加します。
そのうえで、同期したい内容によって以下のコンポーネントをアタッチします。
| Conponent | 目的 |
|:-:|:-:|
| `NetworkTransform` | NetworkObjectのTransformの同期 |
| `NetworkRigidbody` | RigidBodyを持つNetworkObjectの<br>物理演算をサーバーのみで行う |
| `NetworkRigidbody2D` | NetworkRigidbodyの2D版 |
| `NetworkAnimator` | NetworkObjectにアタッチされたAnimatorのStateの同期 |

これらのコンポーネントを追加した場合、ServerまたはHostからのみ`NetworkObject`がアタッチされたGameObjectの`Transform`や`Animator`のStateを変更することができるようになります。

### NetworkVariable
スクリプト内の変数を同期したい場合、`NetworkVariable`を使うことができます。
`NetworkVariable`を使う場合、`MonoBehavior`の代わりに`NetworkBehavior`を継承します。

`NetworkVariable`で同期できる変数の型は以下に記載されていますが、`int`や`float`などのプリミティブな型に加えて`Vector3`や`Quaternion`などのUnityビルトインの型も同期することができます。

同様に、リストを同期することができる`NetworkList`もあります。同期可能な型は`NetworkVariable`と基本的に同じです。

また、自分で定義したクラスや構造体も同期することができます。今回のプロジェクトではクラスのリストを同期できるようにカスタムして使いました。(これはまた別で記事を書きたいと思います)

使い方は公式のドキュメントに詳しく書かれているのでコードは省略します。

https://docs-multiplayer.unity3d.com/netcode/current/basics/networkvariable/#supported-types

### Rpc
データのやり取りをしたい場合、Rpcを使います。Rpcを使う場合、`MonoBehavior`の代わりに`NetworkBehavior`を継承したスクリプトを、NetworkObjectをアタッチしたGameObjectに追加します。

使い方は公式ドキュメントに書かれています。(これも別の記事で詳しく書きたい)

https://docs-multiplayer.unity3d.com/netcode/current/advanced-topics/message-system/rpc/

古い記事では次のような書き方がありますが、これは現在推奨されていません。

```c#:例
[ServerRpc]
public void PingRpc(int pingCount) { /* ... */ }

[ClientRpc]
void PongRpc(int pingCount, string message) { /* ... */ }
```

現在は次のように書くことが推奨されています。こちらの方がRpcをどこに送信するか分かり易いのでこの方法で書くのが良いと思います。

メソッド名に"Rpc"をつけないとコンパイル時に怒られてしまいます。

```c#:例
[Rpc(SendTo.Server)]
public void PingRpc(int pingCount) { /* ... */ }

[Rpc(SendTo.NotServer)]
void PongRpc(int pingCount, string message) { /* ... */ }
```

## 実際に動かしてみて
実環境でのテストを行ったのは学祭当日です。

はじめはAndroidタブレットのWi-Fiテザリングで各端末を繋いで接続しましたが、クライアントが2端末以上になるとデータの送受信が間に合わず、ゲームがカクカクになってしまいました。

そこで、送信頻度を30回/秒に落とすなど送受信するデータ量の削減を試みましたがそれでもだめでした。

最終手段としてルーターに各端末を有線で接続したところ、大きな遅延なく動くようになりました。

無線でも問題なく動くだろうと思っていたので無線で動かなかったのは意外な結果でした。ネットワークのプロトコルや実装を変えたら改善するかもしれませんが…。

多人数のマルチプレイゲームがどのようにして低遅延を実現しているのか気になりますね。

## おわりに
Netcode for GameObjectsは位置や向き、アニメーションの同期を簡単に実装できる点では有用だと感じた一方、Zenjectとの組み合わせが難しかったり、無線だと大きな遅延があったりと簡単ではない部分も多々ありました。

3人以上のマルチプレイゲームを作るのは初めてだったので、今回の制作はいろいろと勉強になりました。

今後はMagicOnionなど他のツールも使って比べてみたいです。