Netcode for GameObjectsでMobをスポーンさせる

f:id:fb8r5jymw6fd:20211218054333p:plain

はじめに

エンジニアの松原です。この記事はSynamon Advent Calendar 2021の18日目の記事になります。

以前の記事(その1その2その3)、では基本となるNetcode for GameObjectsの紹介をしてきました。これまではPlayerそのものやPlayer所有の共有オブジェクトを使ってサーバーとゲームクライアント間の同期処理について扱ってきました。

今回はゲームロジックの一環として、サーバー側でMobを作成する方法の紹介と、よくあるギミックとしてMobがPlayerを追跡するギミックについて紹介したいと思います。

 

前回に引き続き、今回の内容について個人のGitHubリポジトリにアップロードしておりますので、もしよろしければ触ってみてください!

 

今回の機能を反映したサンプルシーン名は SampleScene_NetworkMobSpawn.unityになります。

f:id:fb8r5jymw6fd:20211217095338p:plain

 

サーバー実行時にのみ特定のギミックを呼び出せるようにする

これまでの方法ではPlayer Prefabが追加されている状態を起点として処理していました。つまりクライアントやホスト、またはサーバーで実行するコードを取り扱っていました。今回はサーバー側でのみ実行される処理が必要、かつクライアント側からは実行されない導線が必要になります。

そこで NetworkManager.OnServerStarted というイベントを利用します。このイベントはクライアントとして実行されているアプリケーションでは呼び出されず、アプリケーションをサーバーとして実行するときのみに呼び出されるイベントです。

以下の処理はサンプルコードの ServerObjectsSupplierという名前のMonobehaviour継承クラスで実装されているコードから一部抜粋したものになります。このコードはサーバーとして実行されたアプリケーションに対して、シリアライズされているPrefabをInstantiate()してHierarchyに登録する簡単な実装になっていますが、クライアントでは呼びされれないため、サーバーのみの処理を追加したい場合に利用できます。

    [SerializeField]
private NetworkManager? networkManager;

[SerializeField]
private List<GameObject>? supplyingObjects;

private void Start()
{
if (networkManager != null)
{
networkManager.OnServerStarted += SupplyObjects;
     ...
}
}

private void SupplyObjects()
{
if (supplyingObjects == null) return;

foreach (var supplyingObject in supplyingObjects)
{
if (supplyingObject != null)
{
Instantiate(supplyingObject);
}
}
}

 

ServerObjectsSupplierクラスがアタッチされているGameObjectはHierarchyにあります。

f:id:fb8r5jymw6fd:20211217102945p:plain

 

このGameObjectのInspectorを見てみると、MobSpawnerというPrefabがアサインされています。このPrefabにアタッチされているMobSpawnerクラスからMobが生成されるようになっています。

f:id:fb8r5jymw6fd:20211217100551p:plain

 

Mobを呼び出す

続いて、Mobを呼び出すMobSpawnerについて説明します。MobSpawnerのPrefabにはMobSpawnerという、こちらもMonobehaviour継承クラスがアタッチされています。

f:id:fb8r5jymw6fd:20211217101720p:plain

 

MobSpawner (MobSpawner.cs)は開始時、特定のNetworkObjectを持っているPrefabをスポーンさせます。ここではMobとして登場させたいPrefabがアタッチされています。

7秒毎にMob.prefabをInstantiate()でインスタンス化し、NetworkObject.Spawn()を呼び出しています。

public class MobSpawner : MonoBehaviour
{

[SerializeField]
private float spawnDelay = 7f;

[SerializeField]
private GameObject? mobPrefab;

private float elapsedTime;

void Update()
{
elapsedTime += Time.deltaTime;

if (elapsedTime > spawnDelay)
{
if (mobPrefab != null)
{
var mob = Instantiate(mobPrefab);
mob?.GetComponent<NetworkObject>().Spawn();
}
elapsedTime -= spawnDelay;
}
}
}

 

この Mob.prefab はNetworkManagerからNetworkPrefabsとして登録されており、前回の記事で紹介した弾丸オブジェクトと同様、プレイヤー間で同期可能なオブジェクトとして利用できます。

f:id:fb8r5jymw6fd:20211217101956p:plain

 

MobがPlayerを追いかけるギミックを追加する

ここからはMobがPlayerを追いかけるギミックについて説明します。今回の動作サンプルはNavMeshでMobの動作範囲を定め、 Mobの移動処理はNavMeshAgent + NetworkTransformで実現しています。以下に軽くNavMeshの作り方とNavMeshAgentを使って、一番近くにいるPlayerを追いかけるギミックについて解説します。

 

NavMeshを作成する

NavMeshの作り方についてはUnity公式のNavMeshのチュートリアルに従っています。

NavMeshのベイクに必要なNavigationのWindowはUnity2020.3ではWindow > AI > Navigation に入っています。(場所が分かりづらかったので念のため図を付けています)

f:id:fb8r5jymw6fd:20211217193702p:plain

 

基本的なところは割愛しますが、NavMeshが動く範囲の地面として書き出したい対象はStaticオブジェクトにしておく必要があります。

f:id:fb8r5jymw6fd:20211217193903p:plain

 

NavigationのBakeタブのBakeを押してNavMeshを作成します。

f:id:fb8r5jymw6fd:20211217194118p:plain

 

これでMobが動く範囲を作成できたので、次のパートでMobのPrefabの中身について説明していきます。

 

必要なコンポーネントを追加する

いくつか他のギミック用にコンポーネントがアタッチされています。ここでは最低限必要になるコンポーネントについて説明します。

まず同期可能オブジェクトとして登録するためのNetworkObjectが第一に必要になります。そしてNavMeshAgentコンポーネント、NavMeshAgentから直接Transformの操作を可能にするためのNetworkObject(またはClientNetworkObject)、そしてNavMeshAgentのふるまいを決定するためのNetworkBehaviour継承のクラス(ここではMobAISyncBehaviourクラス)が必要になります。

f:id:fb8r5jymw6fd:20211217194305p:plain

 

MobAISyncBehaviourクラスについてはまた後で説明しますので、次は各Playerの位置を取得する方法について解説します。

 

各プレイヤーの位置を取得する

各プレイヤーのNetworkObjectを取得する

NetworkMangerにはプレイヤーの位置情報を取得するために重要なプロパティが含まれています。NetworkManager.SpawnManagerで取得できるNetworkSpawnManagerクラスです。

このクラスは現在NetworkObjectとして利用しているすべてのオブジェクトの管理を行っているクラスです。このクラスによって様々な種類のNetworkObjectへの操作を行えるようになります。

このクラスを利用してPlayerとして登録されているNetworkObjectを取得します。

先ほど登場したServerObjectsSupplierクラスからNetworkManagerクラスを利用し、NetworkManagerクラスのコールバックイベントである NetworkManager.OnClientConnectedCallback イベントを利用します。(ついでに NetworkManager.OnClientDisconnectCallback イベントも受け取れるようにしています)

    private void Start()
{
if (networkManager != null)
{
...
networkManager.OnClientConnectedCallback += OnClientConnected;
networkManager.OnClientDisconnectCallback += OnClientDisconnected;
}
}

 

このコールバックの受け取り先の関数は以下のようになっています。このイベントではPlayerがネットワークに接続した際、そのPlayerを識別するClientIDを引数に受け取れるため、それを利用してNetworkSpawnManager.GetPlayerNetworkObject() のメソッドを使ってPlayerのNetworkObjectを取得し、Dictionaryに保持しています。

    private void OnClientConnected(ulong clientId)
{
if (networkManager == null) return;

var networkObject = networkManager.SpawnManager.GetPlayerNetworkObject(clientId);
if (networkObject != null)
{
playerNetworkObjects.Add(clientId, networkObject);
}
}

 

また、このDictonaryのKeyにClientIDを指定しており、ServerObjectsSupplierクラスのプロパティとして参照できるようにしています。

public IReadOnlyList<ulong> PlayerClientIds => playerNetworkObjects.Keys.ToList();

 

他、各プレイヤーに割り振られているClientIDを使ってDictonaryからNetworkObjectを取得できるメソッドも用意しています。

public NetworkObject? GetPlayerNetworkObject(ulong clientId) => playerNetworkObjects[clientId];

 

この仕組みによって、Mob側の処理で各ユーザーの位置を取得できるようになります。

 

NetworkObjectからPlayerの位置情報を取り出してNavMeshAgentに処理させる

NetworkObject.Spawn() で作成された同期可能オブジェクトはGameObjectの実体を取得できます。これを利用してNetworkObjectのコンポーネントがアタッチされているGameObjectの位置を取得できます。

MobAISyncBehaviourクラスのUpdateで先ほどのServerObjectsSupplierの公開プロパティやメソッドを使って各PlayerのNetworkObjectを取得しています。そしてそのNetworkObjectのGameObjectの位置情報を取得します。

取得したGameObjectの位置情報を利用し、NavMesh.CaluculatePath() メソッドを使って、そのPlayerまでの通路が確保されているかをチェックしています。

チェックがOKだった場合、Playerの位置とMobの位置を Vector3.Distance() で距離の長さを測り、最も近いPlayerの位置がどれかを計算します。

そして最後に NavMeshAgent.SetDestination() のメソッドを使って、NavMeshAgentを使ってMobをPlayerに向かって動かすようにしています。

また、このUpdate処理はサーバー側でのみ実行されるようにしています。


private void Update()
{
       if (!IsServer) return;
if (serverObjectsSupplier == null || navMeshAgent == null) return;
elapsedTime += Time.deltaTime;

if (elapsedTime > updateRate)
{
NavMeshPath path = new NavMeshPath();

float minDistance = 1000f;
Vector3 targetPosition = transform.position;
bool foundTarget = false;

foreach (var clientId in serverObjectsSupplier.PlayerClientIds)
{
var networkObject = serverObjectsSupplier.GetPlayerNetworkObject(clientId);
if (networkObject == null) continue;

var source = transform.position;
var target = networkObject.transform.position;

if (NavMesh.CalculatePath(source, target, navMeshAgent.areaMask, path))
{
float distance = Vector3.Distance(source, target);
if (distance < minDistance)
{
minDistance = distance;
targetPosition = target;
foundTarget = true;
}
}
}

if (foundTarget)
{
navMeshAgent.SetDestination(targetPosition);
}

elapsedTime -= updateRate;
}
}

 

その他今回紹介しきれなかったギミック

前回作った弾丸のオブジェクトをMobにぶつけた時、Mobが消滅するギミックも追加しています。ただし、クライアント側で若干挙動がいまいちなので、ここはいつか作り直したいかと思っています。

 

今回作ったものの動画

f:id:fb8r5jymw6fd:20211217224226g:plain f:id:fb8r5jymw6fd:20211217224257g:plain

最後に

これまでのNetcode for GameObjects関連の記事と比べ、今回は少し複雑なギミックを取り扱いました。UnityのNetcode for GameObjectsではサーバー側でのみ処理を行うことで、クライアントの処理負荷を減らしたり、クライアント側に処理ギミックを隠蔽することに応用できそうです。

実際にゲームとして成立させるなら、Mobが障害物の迂回したり、ロコモーションをつけたり、もっと複雑なAIをつける必要はありそうです。

今後はAIについても今後考えていくのも良いかもしれません。

 

宣伝

今年のSynamonはアドベントカレンダーを実施しております、よろしければ他の記事もお読みいただけると嬉しいです!

qiita.com

 

また、弊社ではUnityエンジニアを募集しています。興味がある方は是非以下のページを覗いてみてください!

twitter.com

meety.net

herp.careers