【Unity】Netcode for Entities 学習記録 兼 備忘録 #1 ~初期設定からプレイヤー生成まで~

ゲーム開発

Unityでヴァンパイアサバイバー風のマルチプレイゲームを作成するため、DOTSやNetcode for Entitiesの学習を進めており、その備忘録になります。
基本的なDOTSの使用方法を知っていることを前提に、主にマルチプレイに関わる部分について随時追記していきます。
Netcode for Entitiesについてはまだまだ情報が少なく、当然日本語の記事もあまり見かけないのでブログという形で超初心者ながらに学習内容をアウトプットしていこうと思います。

参考動画

以下の動画を参考に、自身の制作物に沿った形に改変しながら進めていきます。
もちろん全て英語ですが、そこまで得意ではないので自動翻訳字幕やChatGPTなどいろいろ併用しながら自分なりに理解を深めていきます。

Unityやパッケージ等のバージョン

・Unity 2022.3.16f1
・Universal 3Dテンプレート(URP)
・Cinemachine 2.9.7(※現状、使用してません)
・Burst 1.8.11
・Mathematics 1.2.6
・Entities 1.3.5
・Entities Graphics 1.3.2
・Input System 1.7.0
・Netcode for Entities 1.3.6
・Unity Physics 1.3.5
・ParrelSync 1.5.2

プロジェクトの設定

URPの設定

ProjectSettings – GraphicsのScriptable Renderer Pipeline Settingsに設定されているアセットをクリックするとプロジェクトウィンドウでそのアセットが選択されます。

その下の「URP-HighFidelity-Renderer」を選択して、インスペクターからRendering Pathを「Forward+」にする…と動画ではありますが、変更したら30分待っても読み込みが終わらなかったので「Forward」ままとしました。

Unity6では、「Forward+」がデフォルトらしく、ライティングを多用するような高画質な3Dゲームで有用そうですが、最終的には2Dっぽく仕上げていく予定なので、必要なしと判断しました。
以下の警告がずっとでていますが、このまま作業を続けて問題があるようでしたらまた追記します。

Enter Play Modeの設定

Project Setting – Editorの下の方にあるEnter Play Mode Settings – Enter Play Mode Optionsにチェックを入れる。なお、その下のReload DomainとReload Sceneはチェックを外したままにする。
当たり前の設定すぎたかもしれませんが、私のような初心者はつい最近知りました…プレイモードに入るのが早くなるみたいですね。とりあえずはプレイ毎にstatic変数が初期化されないことに注意していればよいでしょうか。

Player設定

Project Setting – PlayerのWindowsタブ内のResolution and Presentation – Resolution – Run In Backgroundにチェックを入れる
公式マニュアルを確認すると、プレイモード中にUnity外の操作(ネット検索とか)をしたときにゲームが一時停止しないようにする設定かな?

Windows の Player 設定 - Unity マニュアル
このページでは、Windows に特化した Player 設定について説明します。一般的な Player 設定の説明については、Player 設定 を参照してください。

また、Other Setting – Configuration – Scripting Backendを「IL2CPP」に設定し、Use incremental GCにチェックが入っているか確認する。
この辺はしっかり理解していないのでコメントは控えます…

NetcodeConfigファイル

Project Setting – Multiplayerで何もアセットが設定されていなかったら、「Create」を押して作成して設定する。Assetフォルダー配下にNetcodeConfigファイルが作成されます。
なんか便利だそうですが、便利さを実感した際にはまた追記することとします笑

Scene View Modeの設定

Preferences – EntitiesのBaking – Scene View Modeを「Runtime Data」に設定
シーンビューでも、ランタイムデータを閲覧できるようにするっぽい。

サーバー・クライアントワールドの作成

Netcode for Entitiesをインストールした時点で、サーバーワールドおよびクライアントワールドが自動作成されるようになりますが、それらを手動で作成します。

Window – Entities – HierarchyでEntities Hierarchyウィンドウを表示し、左上のドロップダウンからワールドを確認できます。

ロビー画面の作成

接続方法を指定するシーン(LobbyScene)を作成しました。(見にくいと思いますがご容赦ください…)
TextMeshProを使用しています。
・Connection Mode:クライアント+サーバー、サーバーのみ、クライアントのみを指定
・Connection Address:接続先のIPアドレス(デフォルトで127.0.0.1)
・Connection Port:接続先のポート番号(デフォルトで7979)

自動作成の無効化

空のオブジェクト「Bootstrapper」を作成し、「Override Automatic Netcode Bootstrap」のコンポーネントを追加します。また、空のオブジェクト「ConnectionManager」も作成しておきます。
インスペクターからForce Automatic Bootstrap In Sceneを「Disable Automatic Bootstrap」に変更します

サーバーワールドとクライアントワールドが自動作成されなくなり、Default Worldのみとなります。

手動接続するスクリプトの作成

「ClientConnectionManager」という名前でC#スクリプトを新規作成します。
また、遷移先のシーンを何でもよいので作成しておきます。今回は「TestScene」という名前で作成済みです。

using TMPro;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

/// <summary>
/// 接続モード
/// </summary>
public enum ConnectionMode
{
    /// <summary>サーバー+クライアント</summary>
    ServerClient = 0,
    /// <summary>サーバーのみ</summary>
    Server,
    /// <summary>クライアントのみ</summary>
    Client,
}

public class ClientConnectionManager : MonoBehaviour
{

    #region 定数

    private const string MAIN_SCENE_NAME = "TestScene";
    private const string SERVER_WORLD_NAME = "Test Server";
    private const string CLIENT_WORLD_NAME = "Test Client";
    private const string CONNECTION_MODE_ERROR_MESSAGE = "存在しない接続モードが指定されています。";

    #endregion

    #region インスペクターに表示される変数

    // ロビーのUI群をインスペクターで設定しておく
    [SerializeField] private TMP_InputField _addressField;
    [SerializeField] private TMP_InputField _portField;
    [SerializeField] private TMP_Dropdown _connectionModeDropdown;
    [SerializeField] private Button _connectButton;

    #endregion

    /// <summary>
    /// ポート番号(ushort)
    /// </summary>
    /// <remarks>入力されたポート番号をushortに変換して返却</remarks>
    private ushort Port => ushort.Parse(_portField.text);

    #region 自動呼び出しされるメソッド

    private void OnEnable()
    {
        // StartボタンにOnButtonConnectメソッドを登録
        _connectButton.onClick.AddListener(OnButtonConnect);
    }

    private void OnDisable()
    {
        // Startボタンのイベントを全て削除
        _connectButton.onClick.RemoveAllListeners();
    }

    #endregion

    #region プライベートメソッド

    private void OnButtonConnect()
    {
        // デフォルトのワールドを全て破棄
        DestroyLocalSimulationWorld();
        // 遷移先のシーン名を指定してロード
        SceneManager.LoadScene(MAIN_SCENE_NAME);

        // 接続モードに応じてワールドを作成
        switch (_connectionModeDropdown.value)
        {
            case (int)ConnectionMode.ServerClient:
                StartServer();
                StartClient();
                break;
            case (int)ConnectionMode.Server:
                StartServer();
                break;
            case (int)ConnectionMode.Client:
                StartClient();
                break;
            default:
                Debug.LogError(CONNECTION_MODE_ERROR_MESSAGE, gameObject);
                break;
        }
    }

    private void DestroyLocalSimulationWorld()
    {
        foreach(var world in World.All)
        {
            if (world.Flags == WorldFlags.Game)
            {
                world.Dispose();
                break;
            }
        }
    }

    private void StartServer()
    {
        // サーバーワールド作成
        var serverWorld = ClientServerBootstrap.CreateServerWorld(SERVER_WORLD_NAME);

        // 指定されたポートで接続できるようにする
        var serverEndpoint = NetworkEndpoint.AnyIpv4.WithPort(Port);
        {
            using var networkDriverQuery = serverWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
            networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Listen(serverEndpoint);
        }
    }

    private void StartClient()
    {
        // クライアントワールド作成
        var clientWorld = ClientServerBootstrap.CreateClientWorld(CLIENT_WORLD_NAME);

        // サーバーワールドのエンドポイントに接続
        var connectionEndpoint = NetworkEndpoint.Parse(_addressField.text, Port);
        {
            using var networkDriverQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
            networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Connect(clientWorld.EntityManager, connectionEndpoint);
        }

        // Default Worldは破壊しているので、clientworldを入れておく?
        World.DefaultGameObjectInjectionWorld = clientWorld;
    }

    #endregion
}

「ConnectionManager」にアタッチし、インスペクターからUI群をD&Dしていきます。

LobbySceneを再生し、Connection Modeを「Host(Client + Server)」でStartしてみます。

無事に、TestSceneに遷移し、スクリプトで指定した「Test Server」と「Test Client」のワールドが作成できました!(画面には2Dタイルマップをテキトーに並べているので気にしないでください笑)

プレイヤーの生成

クライアントがサーバーに接続した際に、指定された場所にプレイヤーが生成されるようにします。

クライアントからのリクエスト準備

まずは、どこに生成するかのデータを持たせるコンポーネントを「ClientConnectRequest」という名前で作成しました。

using Unity.Entities;
using Unity.Mathematics;

/// <summary>
/// クライアントがサーバー接続時にスポーン場所を指定する用コンポーネント
/// </summary>
public struct ClientConnectRequest : IComponentData
{
    public float3 SpawnPosition;
}

「ClientConnectionManager」の一部を書き換え、クライアントワールドを作成するとともにプレイヤー生成をリクエストするエンティティを作成します。このエンティティが作成されたことを検知して、クライアントからサーバーへプレイヤーの生成をリクエストします。

private void StartClient()
{
    // クライアントワールド作成
    var clientWorld = ClientServerBootstrap.CreateClientWorld(CLIENT_WORLD_NAME);

    // サーバーワールドのエンドポイントに接続
    var connectionEndpoint = NetworkEndpoint.Parse(_addressField.text, Port);
    {
        using var networkDriverQuery = clientWorld.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDriver>());
        networkDriverQuery.GetSingletonRW<NetworkStreamDriver>().ValueRW.Connect(clientWorld.EntityManager, connectionEndpoint);
    }

    // サーバー接続リクエスト用エンティティを作成
    var connectRequestEntity = clientWorld.EntityManager.CreateEntity();
    // わかりやすいように少しずれた場所(10, 0, 0)をスポーン地点に指定
    clientWorld.EntityManager.AddComponentData(connectRequestEntity, new ClientConnectRequest
    {
        SpawnPosition = new float3(10, 0, 0)
    });
}

クライアントからサーバーへのリクエスト

クライアントからサーバーへのリクエストはRPC(Remote Procedure Call)コマンドを用いるので、「RpcComponents」という名前のスクリプトを作成しました。

using Unity.Mathematics;
using Unity.NetCode;

public struct ConnectRequest : IRpcCommand
{
    public float3 SpawnPositioin;
}

続いて、実際にクライアントからサーバーへリクエストをするシステムを「ClientRequestGameEntrySystem 」という名前で作成しました。

using Unity.Entities;
using Unity.Collections;
using Unity.NetCode;

// クライアントおよびシンクライアント(テストのためのNPCみたいな)のみで実行
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation | WorldSystemFilterFlags.ThinClientSimulation)]
public partial struct ClientRequestGameEntrySystem : ISystem
{
    // まだサーバーに接続していないネットワークIDを持つコンポーネントを探すクエリ
    private EntityQuery _pendingNetworkIdQuery;

    public void OnCreate(ref SystemState state)
    {
        // 接続待機中のエンティティがある時だけOnUpdateの処理を実行
        var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<NetworkId>().WithNone<NetworkStreamInGame>();
        _pendingNetworkIdQuery = state.GetEntityQuery(builder);
        state.RequireForUpdate(_pendingNetworkIdQuery);
        state.RequireForUpdate<ClientConnectRequest>();
    }

    public void OnUpdate(ref SystemState state)
    {
        // リクエストされたスポーン場所を取得
        var requestedSpawnPosition = SystemAPI.GetSingleton<ClientConnectRequest>().SpawnPosition;
        // エンティティを操作(コンポーネントの追加・削除・編集など)するためのバッファー
        var ecb = new EntityCommandBuffer(Allocator.Temp);
        // 接続待機中のエンティティをすべて取得
        var pendingNetworkIds = _pendingNetworkIdQuery.ToEntityArray(Allocator.Temp);

        foreach(var pendingNetworkId in pendingNetworkIds)
        {
            // NetworkStreamInGameコンポーネントを待機中のエンティティに追加して、接続状態にする
            ecb.AddComponent<NetworkStreamInGame>(pendingNetworkId);

            // サーバーへのリクエスト用エンティティを作成
            var requestConnectEntity = ecb.CreateEntity();
            // RPCコマンドに取得したスポーン場所を込めてサーバーへ送信
            ecb.AddComponent(requestConnectEntity, new ConnectRequest { SpawnPositioin = requestedSpawnPosition });
            ecb.AddComponent(requestConnectEntity, new SendRpcCommandRequest { TargetConnection = pendingNetworkId });
        }
        ecb.Playback(state.EntityManager);
    }
}

仮プレイヤーの作成

ここで、プレイヤーのプレハブを作成します。「Player」というCubeの3Dオブジェクトをシーンに追加し、当たり判定が欲しいので「RigidBody」「Ghost Authoring Component」をアタッチし、下図の通りに設定してプレハブ化しておきます。この「Ghost Authoring Component」がマルチプレイゲームを作成する上で非常に重要で、これがないとサーバーワールドのみにプレイヤーが生成され、クライアントワールドには生成されないため、画面に表示されなくなります。

また、オーサリング用のスクリプト「PlayerAuthoring」も作成してアタッチしています。数値が同期することを確認するため、HPの概念を追加するなどしています。これは好みによると思いますが、オーサリング用のスクリプトで関連したコンポーネントデータを定義しています。複数人で作業する場合とかはしっかりファイルごとに定義した方が良いのかな?

using Unity.Entities;
using Unity.NetCode;
using Unity.Rendering;
using UnityEngine;

// プレイヤー認識用(インスペクターでPlayerタグつけているようなもの)
public struct PlayerTag : IComponentData { }
// InitializePlayerSystemで初期化する用の認識タグ
public struct NewPlayerTag : IComponentData { }
// プレイヤーステータス
public struct PlayerStatus : IComponentData
{
    // GhostFieldをつけることで、サーバーからクライアントに値が同期される
    [GhostField] public int HP;
}

public class PlayerAuthoring : MonoBehaviour
{
    public class PlayerAuthoringBaker : Baker<PlayerAuthoring>
    {
        public override void Bake(PlayerAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.Dynamic);
            AddComponent<PlayerTag>(entity);
            AddComponent<NewPlayerTag>(entity);
            AddComponent<PlayerStatus>(entity);
            // 動的に色を変更するため
            AddComponent<URPMaterialPropertyBaseColor>(entity);
        }
    }
}

次に、プレイヤーは動的に生成する必要があるため、生成用のオブジェクトを作成していきます。ECSのオブジェクトはサブシーン上に作成するため、「TestSubScene」という名前でサブシーンを追加し、子オブジェクトに空のオブジェクトで「PrefabContainer」を追加しています。

このPrefabContainerをオーサリングするスクリプト「PlayerPrefabsAuthoring」を作成します。(今更ですが、ECSの基本となる部分は説明を省略します。スミマセン。)

using Unity.Entities;
using UnityEngine;

public struct PlayerPrefabs : IComponentData
{
    public Entity Player;
}

class PlayerPrefabsAuthoring : MonoBehaviour
{
    public GameObject Player;

    public class PlayerPrefabsAuthoringBaker : Baker<PlayerPrefabsAuthoring>
    {
        public override void Bake(PlayerPrefabsAuthoring authoring)
        {
            var entity = GetEntity(TransformUsageFlags.None);
            AddComponent(entity, new PlayerPrefabs
            {
                Player = GetEntity(authoring.Player, TransformUsageFlags.Dynamic)
            });
        }
    }
}

PrefabContainerへアタッチし、プレハブ化したPlayerも忘れずにインスペクターから登録します。

サーバーでプレイヤー生成処理

次に、サーバーでクライアントからのリクエストを処理する「ServerProcessGameEntryRequestSystem」を作成しました。

using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
using UnityEngine;

// サーバーでのみ実行
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct ServerProcessGameEntryRequestSystem : ISystem
{
    // サーバーへの通信(RPCリクエスト)がある場合のみ実行
    public void OnCreate(ref SystemState state)
    {
        // クライアントが送信したSendRpcCommandRequestをサーバーがReceiveRpcCommandRequestで受け取る
        var builder = new EntityQueryBuilder(Allocator.Temp).WithAll<ConnectRequest, ReceiveRpcCommandRequest>();
        state.RequireForUpdate(state.GetEntityQuery(builder));
        state.RequireForUpdate<PlayerPrefabs>();
    }

    public void OnUpdate(ref SystemState state)
    {
        var ecb = new EntityCommandBuffer(Allocator.Temp);
        var playerPrefab = SystemAPI.GetSingleton<PlayerPrefabs>().Player;

        foreach(var (connectRequest, requestSource, requestEntity) in 
            SystemAPI.Query<ConnectRequest, ReceiveRpcCommandRequest>().WithEntityAccess())
        {
            // RPCリクエストは1回だけ処理したいので、そのリクエストのエンティティは削除する
            ecb.DestroyEntity(requestEntity);
            ecb.AddComponent<NetworkStreamInGame>(requestSource.SourceConnection);

            // クライアントのネットワークIDを取得
            var clientId = SystemAPI.GetComponent<NetworkId>(requestSource.SourceConnection).Value;
            Debug.Log($"サーバーに接続しました。 ID:{clientId}");

            // プレイヤーの生成
            var newPlayer = ecb.Instantiate(playerPrefab);
            // わかりやすいようにオブジェクトの名前を付けておきます
            ecb.SetName(newPlayer, "Player");
            // リクエストされた場所に移動
            var newTransform = LocalTransform.FromPosition(connectRequest.SpawnPositioin);
            ecb.SetComponent(newPlayer, newTransform);
            // ゴースト(クライアントに表示される方オブジェクト)の所有者を生成をリクエストしたクライアントに指定
            ecb.SetComponent(newPlayer, new GhostOwner { NetworkId = clientId });
            ecb.SetComponent(newPlayer, new PlayerStatus { HP = 100 });

            ecb.AppendToBuffer(requestSource.SourceConnection, new LinkedEntityGroup { Value = newPlayer });
        }
        ecb.Playback(state.EntityManager);
    }
}

最後に、物理挙動の設定を行うため、「InitializePlayerSystem」を作成しました。こちらはサーバー、クライアントどちらも処理が実行されます。

using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Physics;
using Unity.Rendering;

// SimulationSystemGroup処理の中で最初に行う処理とする
[UpdateInGroup(typeof(SimulationSystemGroup), OrderFirst = true)]
public partial struct InitializePlayerSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var ecb = new EntityCommandBuffer(Allocator.Temp);
        foreach(var (physicsMass, newPlayerEntity) in SystemAPI.Query<RefRW<PhysicsMass>>().WithAny<NewPlayerTag>().WithEntityAccess())
        {
            // 物理演算による回転の無効化:[0], [1], [2] はそれぞれ X, Y, Z 軸
            physicsMass.ValueRW.InverseInertia[0] = 0;
            physicsMass.ValueRW.InverseInertia[1] = 0;
            physicsMass.ValueRW.InverseInertia[2] = 0;

            // わかりやすいように色を赤に変更
            ecb.SetComponent(newPlayerEntity, new URPMaterialPropertyBaseColor { Value = new float4(1, 0, 0, 1) });
            // 繰り返さないようにNewPlayerTagは削除
            ecb.RemoveComponent<NewPlayerTag>(newPlayerEntity);
        }
        ecb.Playback(state.EntityManager);
    }
}

動作確認

長くなりましたが、プレイしてみます。

無事に少しずれた位置に赤い四角が生成されました!(2Dの見え方にしたいので、Main CameraのProjectionは「Orthographic」に変更してあります)
Entities HierarchyからPlayerStatusのHPを確認すると、こちらも設定した100になっています。

クライアントワールド側も同じように生成されていて、ここで試しにサーバーワールド側でHPの値を200に変更してみて、クライアント側と同期することを確認しました。

まとめ

シングルプレイヤーでプレイヤー生成する10倍くらいは大変ですね…笑
でも参考動画はまだ1/4程度しか進んでいません…笑
大雑把に実装手順をまとめると以下の通りかな?

  1. クライアント側でリクエスト用のエンティティを作成
  2. クライアント側のシステムがリクエストデータを受け取り、RPCコマンドに詰め替えて「SendRpcCommandRequest」とともにサーバーへ送信
  3. サーバー側のシステムが「ReceiveRpcCommandRequest」とともにRPCコマンドを受け取って処理

最後にもう少しだけ、動画内で紹介があった便利コードを載せておきます。
これで少しは開発が捗ること間違いなしです!

#if UNITY_EDITOR

using Unity.Entities;
using UnityEngine.SceneManagement;

/// <summary>
/// エディターでどのシーンからプレイしてもロビーに飛ばしてくれる開発便利システム
/// </summary>
public partial class LoadConnectionSceneSystem : SystemBase
{
    protected override void OnCreate()
    {
        // Updateは必要ないので無効化
        Enabled = false;
        // 現在のシーンがロビーなら問題なし
        if (SceneManager.GetActiveScene() == SceneManager.GetSceneByName("LobbyScene")) return;
        // それ以外ならロビーをロードする
        SceneManager.LoadScene("LobbyScene");
    }

    protected override void OnUpdate()
    {
        
    }
}

#endif

次回はプレイヤーを動かしてみます!下のリンクから是非どうぞ!

タイトルとURLをコピーしました