1. 概要
最近は生成AIにお願いすればなんでも作れる時代になりました。この課題やって~、とかこんなWebアプリ作って~とか。そんな中で、「Unityでマルチプレイのオンライン対戦ゲームを作って~」と頼むと必ず必要になるのが NGO (Netcode for GameObjects) です。生成AIが出してくる処理内容はできるだけ把握しながらゲーム制作を進めていきたいですよね。ということで今回はネットワークに関連する用語をおさらいしながら、NGOが内部的にどう動作しているのかを"なんとなく"理解するためにまとめてみました。また、3Dのサンプルゲームを作りながら実際に動かすところまでやってみたいと思います。
内容はUnity中級者向け~からのものです。 if文やfor文, instantiate(), GetComponent<>()とかが分からないよ~という方はまず他の記事などで勉強されてからこの記事を参考にしていただけるとよいと思います。
2. 用語の説明
2.1. Host / Client / Server
普通Clientといえば、「サービスを利用する側」の人間です。これに対してHostは「サービスを提供する側」といえます。Clientが自分でHPを計算できる、とかしてしまうとなんでもし放題ですよね。つまりマルチプレイのゲーム開発では、シングルプレイヤーのゲームでは考えなかった権限の問題が生まれてきます。NGOでは、最終的にServerが状態を決定するServer Authoritative Modelを採用しています。
Server : すべてのプレイヤーの情報を管理する役割を担うところ。
Client : 「ここに動かしたい」などの要求をServerに送信するプレイヤー。
Host : ServerとClientの役割を兼任するプレイヤー。
以下に関係図を示します(https://soft-rime.com/post-16698/ 様のページを参考にさせていただきました)。

2.2. IsOwner
2.1.節の画像で出てきたIsOwnerとは、オブジェクトの持ち主かを表すbool値です。誰がOwnerになるかは、Spawnするときに決まります。つまり IsOwner は「プロパティ」であって「設定する値」ではありません。自分で IsOwner = true と書くことはできず、Spawn 時の Owner 設定が決め手になります。
なお、これと同じような用語にIsServer, IsClient, IsHostがあります。意味は見たままの意味ですが、これらはマシン単位の値なのに対して、IsOwnerはオブジェクト単位の値であることに注意が必要です。
2.3. Spawn vs Instantiate
1人プレイのゲームではGameObjectを生成するときにInstantiateというメソッドを使ったと思います。これはネットワークに関係なく自分の環境だけにGameObjectを生成する動作になります。これに対してSpawn()は、Serverがプレイヤー全員にGameObjectを置く指示をするような動作になります。この結果、接続している全てのプレイヤーにGameObjectが配置され、以後は同期されるようになります。ネットワークで同期される GameObjectにはNetworkObjectというコンポーネントが必要です。ちなみに、Spawn()はServer(Hostを含む)しか呼び出せません。これもServer Authoritative Modelに基づいたものといえます。
2.4. NetworkVariable
NetworkVariable<T>で、ネットワークで同期される型付きの変数を宣言することができます。読み取り/書き込み権限はNetworkVariableReadPermission / NetworkVariableWritePermissionを変更することで変えることができます。実際の例で見てみましょう。
private NetworkVariable<int> score = new NetworkVariable<int>(
0,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
この例では、Read(読み取り)権限を全員に与え、Write(書き込み)権限をServerのみに渡しています。なお、最初の0は初期値として設定しています。
2.5. ServerRpc / ClientRpc
RPCとは、別のマシン上のメソッドを呼び出す仕組みです。「Remote Procedure Call(遠隔手続き呼び出し)」の略で、NGOではRpc属性をメソッドに付けることで使えます。実際のプログラムを見てみましょう。
public class PlayerController : NetworkBehaviour
{
// bulletPrefabやgoalEffectなどの定義
...
// クライアント → サーバーへの呼び出し
// 「弾を撃ちたい」とサーバーにリクエストする
[Rpc(SendTo.Server)]
void FireBulletServerRpc(Vector3 position, Vector3 direction)
{
// この処理はサーバー上で実行される
GameObject bullet = Instantiate(bulletPrefab, position, Quaternion.identity);
bullet.GetComponent<NetworkObject>().Spawn();
}
// サーバー → 全クライアントへの呼び出し
// 「ゴール演出を再生して」と全員に通知する
[Rpc(SendTo.Everyone)]
void PlayGoalEffectRpc()
{
// この処理は全マシンで実行される
goalEffect.Play();
}
}
主なSendToの種類は以下の通りです。
| SendTo | 実行される場所 |
|---|---|
| SendTo.Server | サーバーのみ |
| SendTo.Everyone | 全員(サーバー含む) |
| SendTo.NotServer | クライアント全員(サーバー除く) |
| SendTo.Owner | そのオブジェクトのOwnerのみ |
生成AIを使っているとServerRpc / ClientRpcという属性がつくことがあります。これは現在レガシー扱いとなっていて、推奨されていない書き方です。ServerRpcはRpc(SendTo.Server)に対応しており、ClientRpcはRpc(SendTo.NotServer)とほぼ同じ意味です(全クライアントで実行される)。また、これらを用いたメソッドを作る場合はメソッド名の最後にRpcと付けなければいけません。
3. 動かしてみよう
3.1. 導入
Editor Version: 6000.0.73f1
Netcode for GameObjects: 2.12.0
の環境で実際に試してみます。
Unity Editorを開き、画面上のWindow->Package ManagerからNetcode for GameObjectsをインストールしましょう。

インストールが終わると、コンポーネントとしてNetworkManagerが使えるようになります。試しに、Inspector上でCreate Emptyをして空のオブジェクトを配置してからNetworkManagerコンポーネントをアタッチしてみましょう。

このコンポーネントのNetwork Prefabs Listsには、デフォルトでDefaultNetworkPrefabというものがアタッチされています。ここにはネットワークに対応しているオブジェクトをPrefabの形で全て入れておくものです。PlayerのPrefabをつくったらここに入れるのを忘れないようにしましょう。なお、DefaultPlayerPrefabはカスタムスポーンを使う場合は空にしてください(表を参照)。
また、NetworkTransportはUnity Transportを選択しておきましょう。ここを設定しないとNGOは動きません。

その他の項目は以下にまとめておきます。
| 項目 | デフォルト値 | 説明 |
|---|---|---|
| Tick Rate | 30 | 1秒間に何回ネットワークの状態を更新するか。数値を上げるほど滑らかになるが通信量も増える。アクションゲームなら60、カジュアルゲームなら30が目安 |
| Spawn Timeout | 10 | Spawn処理のタイムアウト時間(秒)。通常はデフォルトのままでOK |
| Connection Approval | オフ | 接続時にサーバー側でクライアントを審査する機能。パスワード制のルームやBANシステムを実装したいときに使う |
| NetworkVariable Length Security | オフ | NetworkVariableのデータ長を検証してチート対策をする機能 |
| Recycle Network Ids | オン | DespawnされたオブジェクトのNetworkObjectIdを再利用する。オンでメモリ効率が良くなる |
| Network Id Recycle Delay | 120 | Despawnしてから何秒後にIDを再利用するか |
| Rpc Hash Size | Var Int Four Bytes | RPCのメソッドを識別するハッシュ値のサイズ。通常はデフォルトのままでOK |
| Network Profiling Metrics | オン | Unity ProfilerでネットワークのパフォーマンスをUnity Profilerで計測できる。開発中はオンにしておくと便利 |
| Force Same Prefabs | オン | HostとClientで同じPrefabリストを持っているか検証する。登録漏れによるバグを接続時に検出できる |
| Default Player Prefab | None | 接続時に自動スポーンするPrefab。PlayerSpawnerなどカスタムスポーンを使う場合は必ず空にする(二重スポーン防止) |
| Network Prefabs Lists | DefaultNetworkPrefabs | Spawn()で生成できるPrefabのリスト。ここに登録されていないPrefabはClient側に表示されない |
| Enable Scene Management | オン | Hostがシーン切り替えをするときClient全員のシーンも自動で切り替わる |
| Load Scene Time Out | 120 | シーン読み込みのタイムアウト時間(秒) |
3.2. Host / Client をボタンで選ぶUIを作る
NetworkManagerを用いて2人以上のプレイヤーを生成するには、HostかClientなのかを明示的に宣言して複数のGameObjectをSpawnする必要があります。その宣言をコードで行ってみましょう。
using UnityEngine;
using Unity.Netcode;
using UnityEngine.UI;
public class NetworkConnect : MonoBehaviour
{
public Button createServerButton;
public Button joinServerButton;
private void Start()
{
createServerButton.onClick.AddListener(CreateServer);
joinServerButton.onClick.AddListener(JoinServer);
}
private void CreateServer()
{
NetworkManager.Singleton.StartHost();
}
private void JoinServer()
{
NetworkManager.Singleton.StartClient();
}
}
ネットワークに関連するものにはNetworkBehaviourを継承させよ、という話がありました。ですが、このスクリプトでは普通にMonoBehaviourを継承しています。これはなぜでしょうか?というと、このスクリプトは接続前のUIを操作するだけの役割でネットワーク機能は一切関係ないからです。ネットワークに参加する前に動くスクリプトなので、ネットワーク機能は持てないし、持つ必要もない、というわけです。
一つずつメソッドを見ていきましょう。まずStartメソッドでは、ボタンのOnClickイベントにメソッドを登録しています(ということはUnity Editor内ではOnClickに何も登録しなくてよいです)。あまり気にしなくてよいですが、ここが Awake() ではなく Start() なのは他のコンポーネントの初期化が先に済んでいることを期待しているためです。Buttonコンポーネントが確実に存在してから機能の登録ができます。
次に、CreateServer()とJoinServer()について見てみましょう。NetworkManagerがNGOの中枢コンポーネントという話はしましたが、実は勝手にシングルトン化してくれています。NetworkManagerは複数あったら矛盾するので自然な実装ともいえます。StartHost()を呼び出すことでServerとClientを同時に起動します。ここからプレイヤーが生成されるまでのフローチャートを以下に示します。

ここまでを全てNGOが自動でやってくれます。親切すぎて追えなくなりそうです。裏側で何が起こっているのかコードでも確認してみましょう。
このコードは勝手にNGOがやっている処理を書いているだけなのでどこかに実装する必要はないです。
// まずサーバーのシーンにオブジェクトが生まれる(ローカルのみ)
GameObject player = Instantiate(playerPrefab);
// 次にNGO がこのオブジェクトを認識し、全クライアントに同期開始
// 同時に clientId を Owner として登録する
player.GetComponent<NetworkObject>().SpawnWithOwnership(clientId);
// 各クライアントは号令を受けて自分のシーンに同じ Prefab を生成する
//(NetworkManager の Network Prefabs リストから該当 Prefab を探して Instantiate する)
SpawnWithOwnerShipというのが急に出てきましたが、これは特定クライアントを Owner にしてスポーンさせる、という意味です。StartHost()ならHostのclientIDを見てOwnerを設定します。clientIdとはクライアントが接続した瞬間に NGO が自動で割り当てる値です。接続順に 0, 1, 2... と連番で振られます。NGO が管理するので自分でセットする必要はありません。
Inspector上でUIのButton (Text Mesh Pro)を2つ作り、また空のオブジェクトを作ってNetworkConnectをアタッチしたらボタンを2つ割り当ててみてください。写真の様になればおっけーです。

※写真に写っているCubeはいらないです
3.3. 基本的な操作ができるプレイヤーを作る
まずはPlayerを作りましょう。Hierarchy で Cube とかを作って名前をPlayerに変更すれば十分です。次に、Player Prefab に以下の3つのコンポーネントをアタッチします。
| コンポーネント | 役割 |
|---|---|
NetworkObject |
ネットワーク同期の必須コンポーネント |
NetworkTransformClient |
位置・回転を自動同期する自作スクリプト |
PlayerController |
自分で書く移動スクリプト |
Player Input |
キー入力を受け取るためのコンポーネント |
普通、位置や回転を同期するにはNetworkTransformが必要です。が、これはサーバー権威なので操作にラグが生じます。具体的には
クライアントが移動入力
↓
サーバーに送信
↓
サーバーが位置を計算
↓
全クライアントに同期
という順序で動作します。Server Authoritative Model なので正確ですが、通信の往復分だけ遅延が生じます。代わりに以下のコードをplayerにアタッチしてください。
using Unity.Netcode.Components;
public class NetworkTransformClient : NetworkTransform
{
protected override bool OnIsServerAuthoritative()
{
return false; // Owner のマシンが位置を決定する
}
}
次にプレイヤーを動かすためのスクリプトです。これもPlayerにアタッチしてください。
using Unity.Netcode; // ネットワーク系の処理を使うときに必要です
using UnityEngine;
using UnityEngine.InputSystem; // 新しいInput Systemを使うときに必要です
// MonobihaviourではなくNetworkBehaviourを継承させます
public class PlayerController : NetworkBehaviour
{
[SerializeField] private float moveSpeed = 5f;
private Vector2 moveInput;
// OnNetworkSpawnは、ネットワーク上でオブジェクトが生成されたときに呼ばれる関数です。
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
// 自分のPlayerでなければPlayer Inputを無効化します
GetComponent<PlayerInput>().enabled = false;
enabled = false; // このスクリプト自体も止めます
return;
}
}
private void Update()
{
// 自分のプレイヤーでなければ以降の処理をしません。超重要です
if (!IsOwner) return;
// 移動
Vector3 move = new Vector3(moveInput.x, 0, moveInput.y) * moveSpeed * Time.deltaTime;
transform.Translate(move);
}
// WASD入力があるたびに自動で呼ばれる関数です。Input Systemの機能を使っています。
void OnMove(InputValue value)
{
moveInput = value.Get<Vector2>();
}
}
Player Inputのおかげで、WASDを押したら勝手に動いてくれます。便利な時代だなぁ...
色々アタッチしたらPlayerをPrefabにしましょう。また、NetworkManagerのDefaultplayerPrefabにPrefabをアタッチしておくことも忘れずに。余談ですがこれをすることでDefaultNetworkPrefabsには自動でplayer Prefabが追加されます。

設定が完了したら早速テストしたいところですが、2人プレイってどうテストすればいいんだ?となりますよね。オブジェクトごとにIsOwnerが違うので2つPlayerを生成しないといけないですが、それはGameビュー1つでは足りません。ここで便利なのがMultiPlayer Play Modeです。

写真のようにMultplayer Play Modeを導入してください。使うときは、Window->MultiPlayer->Multplayer Play Modeを選択すれば使うことができます。

画像の様にPlayer 2にチェックを入れれば、2人で遊べるようになります。実際に自分が試した結果が次の動画のような感じです。
https://youtu.be/-ZSD9UW3-m8
4. その他の処理まとめ
4.1. NetworkBehaviour のライフサイクル
MonoBehaviour に Awake() や Start() があるように、NetworkBehaviour にもネットワーク特有のタイミングで呼ばれるメソッドがあります。
| メソッド | 発火タイミング |
|---|---|
OnNetworkSpawn() |
NetworkObject がネットワーク上に Spawn されたとき |
OnNetworkDespawn() |
NetworkObject がネットワーク上から Despawn されたとき |
public class PlayerController : NetworkBehaviour
{
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
enabled = false;
return;
}
// ネットワーク関係の初期化はここに書く
}
public override void OnNetworkDespawn()
{
// NetworkVariable の購読解除などはここに書く
}
}
4.2. NetworkManager のコールバック
接続・切断のタイミングで処理を行いたいときは NetworkManager のコールバックを購読します。
| コールバック | 発火タイミング | 発火するマシン |
|---|---|---|
OnClientConnectedCallback |
新しいクライアントが接続したとき | サーバーのみ |
OnClientDisconnectCallback |
クライアントが切断したとき | サーバーのみ |
public class PlayerSpawner : NetworkBehaviour
{
private void Start()
{
// 接続時のコールバックを登録
NetworkManager.Singleton.OnClientConnectedCallback += OnClientConnected;
// 切断時のコールバックを登録
NetworkManager.Singleton.OnClientDisconnectCallback += OnClientDisconnected;
}
private void OnClientConnected(ulong clientId)
{
// clientId = 接続してきたクライアントのID
// ここでプレイヤーをSpawnするのが定番
if (!IsServer) return;
GameObject player = Instantiate(playerPrefab);
player.GetComponent<NetworkObject>().SpawnWithOwnership(clientId);
}
private void OnClientDisconnected(ulong clientId)
{
// ここで切断したプレイヤーをDespawnするのが定番
if (!IsServer) return;
}
private void OnDestroy()
{
// コールバックの登録解除を忘れずに
if (NetworkManager.Singleton == null) return;
NetworkManager.Singleton.OnClientConnectedCallback -= OnClientConnected;
NetworkManager.Singleton.OnClientDisconnectCallback -= OnClientDisconnected;
}
}
4.3. Spawn 管理
| メソッド / プロパティ | 説明 |
|---|---|
NetworkObject.Spawn() |
サーバーが Owner になる形でSpawnする。敵・アイテムなどに使う |
NetworkObject.SpawnWithOwnership(clientId) |
指定したクライアントを Owner にしてSpawnする。プレイヤーキャラに使う |
NetworkObject.Despawn() |
ネットワーク上からオブジェクトを削除する |
NetworkManager.Singleton.SpawnManager.SpawnedObjects |
現在Spawn中の全NetworkObjectを保持するマップ |
// 二重スポーン防止の定番パターン
private void OnClientConnected(ulong clientId)
{
if (!IsServer) return;
// すでにそのクライアントのオブジェクトがSpawnされていないか確認
foreach (var obj in NetworkManager.Singleton.SpawnManager.SpawnedObjects.Values)
{
if (obj.OwnerClientId == clientId) return; // 既にあればスキップ
}
GameObject player = Instantiate(playerPrefab);
player.GetComponent<NetworkObject>().SpawnWithOwnership(clientId);
}
5. まとめ
いかがでしたでしょうか。特に全く同じGameObject、全く同じスクリプトで動いている2つのPlayerが、一つのパソコンで別々に動かせる理由についてこの記事を読んで自分の言葉で説明できるようになっていたらとてもうれしいです。
参考