グレンジ Advent Calendar 2021 20日担当の村田です。
今回はUnityのNetcode for GameObjectsを使い、オンラインマルチプレイのゲームを作ってみました。簡単ではありますが、そのNetcodeの紹介記事となります。
Unity Netcode for GameObjectsについて
「Netcode for GameObjects」はマルチプレイの開発を行いやすくするためのパッケージです。
以前はMLAPIという名称でしたが、「Netcode for GameObjects」に変更されました。GameObjectをオンライン同期してくれそうな感じがしますね。
2021年10月21日にバージョン1.0として「1.0.0-pre.2」がプレリリースされ、現在は次バージョンの「1.0.0-pre.3」が最新です。Package Managerからインストールが可能で、またGithubにオープンソースとして配布されています。
ホスト・クライアント構成
Netcodeで実現できる1つ目の構成が、同一ネットワーク内で1台がホストになり、他のクライアントがそのホストに接続する構成です。
ローカルネットワークを超えて接続したい場合は、UnityのRelayを使うことで同様の構成を実現できます。Realyはまだベータ版のため最大接続台数は10台までだそうです。
サーバー・クライアント構成
Unityアプリをサーバー用にビルドし、VPSなどサーバーで実行させて、各クライアントはそのサーバーに接続する構成も可能です。
作ったゲーム
ユニティちゃんを操作して、動く障害物を避けながらゴール地点を目指すゲームです。
各プレイヤーが操作しているユニティちゃんはぶつかり押し合うことができ、落下したらスタート地点にリスポーンします。画面奥ゴール地点に到達すると、1周クリアとしてスコアを更新し、またスタート地点に戻りひたすら周回してクリア数を競います。
サーバー・クライアント構成で動作することを想定して作っています。
Netcodeでどのようにマルチプレイのゲームを作ったのか
簡単にはなりますが、要点を書き記しておきます。Netcodeのインストール方法は、後述の入門オススメのコンテンツにあるので割愛します。
空のGameObjectにNetworkManagerスクリプトを追加
NetcodeにNetworkManagerというスクリプトが用意されているので追加します。GameObjectの名称もNetworkManagerにします。
Network Transportの選択
Network TransportはUnityTransportやWebSocketなど低レイヤーに当たる通信の土台を指します。
Network ManagerのNetwork Transportのリストから選択できます。
初めはUnityTransportで開発していたのですが、最終的にUNetTransportを選択しました。
UnityTransportはNetcodeの一部として開発されており今後推奨されるとは思いますが、Dedicated ServerのLinuxビルドにしたら接続できず、UNetTransportなら問題なく接続できたという経緯です。UNetはNetcode前衛のMLAPIで使われているもので、UNetTransportとUnityTransportもUDPを使ったTransportです。
補足ですが、UNetTransportはデフォルトで選択可能ではありますが、UnityTransportはデフォルトでは選択できないので、インストールする必要があります。
また、contributionsにPhoton RealtimeやSteamNetworkingもあります。
Transportの通信設定
IPアドレスやポートの設定項目があります。接続先のサーバーやホストのIPアドレスを設定します。IPアドレスは基本的にアプリ実行中に変更することになると思います。
同期したいPrefabにNetworkObjectスクリプトを追加
各クライアントの画面でオブジェクトを同期させたい場合は、NetworkObjectスクリプトを追加したPrefabを使います。
また、必要に応じて以下のスクリプトも追加していきます。
NetworkTransformスクリプトを追加
位置を同期したい場合に追加します。Scale、Rotationなど、同期する項目も選択できます。
NetworkAnimatorスクリプトを追加
Animatorを使っていて、同期させたい場合は追加します。同期するパラメータも選択できます。
NetworkRigidBodyスクリプトを追加
RigidBodyを使っているなら追加しましょう。
NetworkManagerにPrefabを設定
「Player Prefab」はプレイヤーが操作するオブジェクトという扱いとされ、設定しておくとサーバーに接続した時にそのPrefabのオブジェクトが生成されます。また切断した時にそのオブジェクトが破棄されます。
「NetworkPrefabs」には、他にネットワーク同期させたいPrefabを設定しておきます。Prefabに前述のNetworkObjectを追加しておくだけでなく、利用するPrefabはこの「NetworkPrefabs」に追加します。
サーバー、ホスト、クライアントの開始方法
NetworkManagerに各種メソッドが用意されています。
if (GUILayout.Button("Server")) NetworkManager.Singleton.StartServer();
if (GUILayout.Button("Host")) NetworkManager.Singleton.StartHost();
if (GUILayout.Button("Client")) NetworkManager.Singleton.StartClient();
GUIで各種選択できるようにしていると動作確認が捗ります。StartHostはServerServer兼StartClientといった感じになります。
プレイヤーオブジェクトの移動
キー入力によってプレイヤーオブジェクトを移動させる場合、クライアントからServerRpcを使ってサーバーへ移動処理の依頼を行うことで、各クライアントの端末で反映されるようになります。ServerRpcを使わずクライアント側だけでtranformの移動処理を行っても移動しません。
下記のように、クライアントの処理もサーバーの処理も同一のファイルで書けます。
// NetworkBehaviourを継承する
public class PlayerController : NetworkBehaviour
{
private Vector2 _moveInput;
private void FixedUpdate()
{
// 接続時に生成されたPlayerPrefabsのオブジェクトは、接続したクライアントがオーナー属性を持っています
// IsOwner判定処理を加えないと、他プレイヤーのオブジェクトも操作してしまうことになります
if (IsOwner)
{
// クライアント側はプレイヤーのキー入力をサーバー側にも伝える
SetMoveInputServerRPc(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical"));
}
if (IsServer)
{
// サーバー側は移動処理を実行
Move();
}
}
[Unity.Netcode.ServerRpc]
private void SetMoveInputServerRPc(float x, float y)
{
// 代入した値は、サーバー側のオブジェクトにセットされる
_moveInput = new Vector2(x, y);
}
private void Move()
{
// ServerRpcによってクライアント側から変更されている_moveInput
var moveVector = new Vector3(_moveInput.x, 0, _moveInput.y);
// 以後、RigidbodyやTransformを変更すると、サーバーに接続している全てのクライアントで反映される
サーバー側で移動処理が行われた後、自身のクライアント画面にも反映されるため、通信環境が悪い場合はその分だけ遅れて反映されることになります。
NetcodeのsampleにはClientNetworkTransformというのがあり、先にクライアント側で移動処理を行ってからサーバーに同期させることを可能にしてるクラスがあります。キー入力に対するレスポンスを良くしたい場合は有用ですが、各クライアントの画面でプレイヤーの表示位置ずれが発生するようになるので、採用できるかはゲーム性との兼ね合いになります。
落下した時にスタート位置に戻る処理
サーバー側にて、Colliderのトリガーでスタート位置に戻る処理を実行します。サーバー側で実行しているため、ServerRpcを使う必要はないです。
private void OnTriggerEnter(Collider col)
{
if (IsServer)
{
if (col.gameObject.CompareTag("OutArea"))
{
MoveToStartPosition();
}
}
}
private void MoveToStartPosition()
{
transform.position = new Vector3(Random.Range(-4f, 4f), 0.5f, -1);
}
移動する障害物の生成
サーバー側で生成を実行します。NetworkManagerのNetworkPrefabsに登録しておいたPrefabであれば、GameObject.Instantiateで生成して、そのNetworkObjectのSpwanを実行すると各クライアントの画面で表示されます。
private void SpawnPrefab(Vector3 position, Vector3 scale)
{
var gameObj = GameObject.Instantiate(_moveXFloorPrefab, position, Quaternion.identity);
gameObj.transform.localScale = scale;
var networkObj = gameObj.GetComponent<NetworkObject>();
networkObj.Spawn(true);
}
プレイヤーにカメラを追従させる
サーバー側でプレイヤーオブジェクトが生成されると、その後にクライアント側ではイベントが実行されます。そのイベントでカメラの追従スクリプトにtransformを紐付けさせています。
// プレイヤーオブジェクトが生成された時に実行されるイベント
public override void OnNetworkSpawn()
{
if (IsOwner)
{
var camera = Camera.main.GetComponent<PlayerFollowCamera>();
camera.Player = transform;
}
}
プレイヤー名の表示
ユニティちゃんの頭上にプレイヤー名を表示しています。サーバーに接続後、生成されたプレイヤーオブジェクトのTextMeshを変更するためクライアントからサーバーへ変更を依頼します。
NetworkVariableという変数同期を行うクラスを使って、プレイヤー名のstringを変更します。プレイヤー名の変更依頼を出すと、登録しておいたイベントが各クライアントで実行されて、プレイヤー名の変更を知ることができます。
// 変数同期を行うクラス NetworkVariable
private NetworkVariable<Unity.Collections.FixedString64Bytes> _playerName =
new NetworkVariable<Unity.Collections.FixedString64Bytes>();
// プレイヤーオブジェクトが生成された時に実行されるイベント
public override void OnNetworkSpawn()
{
if (IsOwner)
{
var gameManager = GameObject.Find("GameManager").GetComponent<GameManager>();
// サーバーにプレイヤー名の変更を依頼
SetPlayerNameServerRpc(gameManager.PlayerName);
}
}
// サーバー側で実行される
[Unity.Netcode.ServerRpc(RequireOwnership = true)]
private void SetPlayerNameServerRpc(string playerName)
{
// 変数同期を行うクラスに値をセットすると、各クライアントにも反映される
_playerName.Value = playerName;
}
void Start()
{
// サーバー側で値が変更された後に、クライアント側でその反映を知らせるイベントを登録
_playerName.OnValueChanged += OnChangePlayerName;
// 先に接続済みのプレイヤーオブジェクトはplayerNameがセットされているので代入する。
playerNameTextMesh.text = _playerName.Value.Value;
}
// クライアント側で実行される
void OnChangePlayerName(Unity.Collections.FixedString64Bytes prev, Unity.Collections.FixedString64Bytes current)
{
if (playerNameTextMesh != null)
{
playerNameTextMesh.text = current.Value;
}
}
プレイヤーオブジェクトのメンバ変数は、全てが同期されるわけではありません。
// 同期されない
private string _playerName;
// 同期される
private NetworkVariable<Unity.Collections.FixedString64Bytes> _playerName;
NetworkVariableを使えば同期するメンバ変数を作れます。変更すれば各クライアントにも変更が伝わり、また後から接続したクライアントにもその値がセットされます。
サーバー側のビルド方法
Unity2021はPlatformをDedicated Serverにしてビルドします。Unity2020であればServer Buildをチェックしてビルドします。ヘッドレスモード、いわゆるコマンドラインアプリケーションが生成されます。
Dedicated Serverビルドだと、UNITY_SERVERが定義されるので、それを利用してStartServerを自動で実行させると良いです。
#if UNITY_SERVER
// サーバービルドは自動でStartServer
if (!NetworkManager.Singleton.IsServer)
{
NetworkManager.Singleton.StartServer();
}
#else
// クライアントビルドはGUIボタンで選択させる例
if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer)
{
if (GUILayout.Button("Server")) NetworkManager.Singleton.StartServer();
if (GUILayout.Button("Host")) NetworkManager.Singleton.StartHost();
if (GUILayout.Button("Client")) NetworkManager.Singleton.StartClient();
}
#endif
Linuxでサーバービルドの実行方法
Linux向けにビルドした場合はdebianで実行できることを確認しています。NetcodeLinuxServerという名称でビルドすると以下のようにファイルができています(mono)。
% ls
NetcodeLinuxServer.x86_64*
NetcodeLinuxServer_BurstDebugInformation_DoNotShip/
NetcodeLinuxServer_Data/
UnityPlayer.so*
NetcodeLinuxServer.x86_64を実行します。
% docker run -it -v $(pwd):/temp -p 7777:7777/udp debian:buster-20211201 /temp/NetcodeLinuxServer.x86_64
最後に、Netcodeを使ってみた感想
サーバー側も同一ソースで開発可能なため、サーバー側で物理演算の判定を行なったり、クライアントと同一のマスターデータを使って同一の演算処理でチート判定をするなど、自由度の高い開発ができそうな印象があります。Unityが開発とサポートを行ってくれるので、今後のアップデートにも期待できますね。
今回作ったアプリはGitHubに公開しています。
入門にオススメ
Netcodeの入門としてオススメなのが先日のUnityステーションの「Netcode for GameObjectsを使ってみよう」です。
また、公式ページの「Hello World」も参考になります。