3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-09

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

この記事は 農工大アドベントカレンダー Advent Calendar 2024 の10日目のものです。

はじめに

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

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

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

ゲームの概要

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

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

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

(プレイヤー視点において)ゲームの流れは次のようになっています。

  1. Web上でキャラメイク
  2. QRコードをゲームに読み込ませる
  3. チームを選択
  4. 対戦
  5. 結果を確認

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

ここで、ゲーム中に同期すべき要素を挙げてみます。

  • ゲーム全体
    • プレイヤーのUUID
    • プレイヤーのチーム
    • キャラメイクに関するデータ
    • スコアのデータ
  • プレイ中
    • 各キャラクターの位置と向き
    • 球の位置と向き
    • 当たり判定
    • 各キャラクターのアニメーション

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

Netcode for GameObjectの実装

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

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

バージョン情報
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です。
それぞれ、

NetworkManager.Singleton.StartServer();
NetworkManager.Singleton.StartHost();
NetworkManager.Singleton.StartClient();

で開始することができます。

また、プログラム中でNetworkManagerの状態を確かめる場合はそれぞれ

NetworkManager.Singleton.IsServer;
NetworkManager.Singleton.IsHost; // IsServerとIsClientもtrueとなる
NetworkManager.Singleton.IsClient;

がtrueになっているかを見てあげればよいです。

接続チェック

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

// サーバー開始前に接続チェック用の関数を登録しておく
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で接続拒否されたときは呼び出されません。

// サーバー開始前に接続直後の処理を行う関数を登録
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で接続拒否されたときにも呼び出されます。

// サーバー開始前に接続解除時の処理を行う関数を登録
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の
物理演算をサーバーのみで行う
NetworkRigidbody2D NetworkRigidbodyの2D版
NetworkAnimator NetworkObjectにアタッチされたAnimatorのStateの同期

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

NetworkVariable

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

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

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

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

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

Rpc

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

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

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

[ServerRpc]
public void PingRpc(int pingCount) { /* ... */ }

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

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

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

[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など他のツールも使って比べてみたいです。

3
0
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
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?