見出し画像

Unity NetCode 入門 (1) - チュートリアル

Unityの「NetCode」のアプリの作り方を説明します。

・Unity 2019.3.14f1
・Unity NetCode 0.2.0
・Entities 0.11.1
・Hybrid Renderer 0.5.2

1. プロジェクトの準備

(1) Unityプロジェクトを名前「NetCube」、テンプレート「3D」で作成。

【注意】プロジェクト名は、自動生成される構造体の名前(NetCubeGhostSerializerCollection)に影響します。

(2) メニュー「Window → Package Manager」で「Package Manager」を開く。
(3) ウィンドウ上部の「Advances → show preview packages」をチェック。
(4) 「Unity NetCode」(0.2.0)を検索してインストール。
(5) 「Hybrid Renderer」(0.5.2)を検索してインストール。

これによって、「Entities」が依存関係として追加され、依存する関連パッケージ(BurstCollectionsJobsMathematicsなど)が再帰的に追加されます。

2. ShareDataの準備

空のゲームオブジェクト「SharedData」を作成し、クライアントワールドサーバーワールドで共有するオブジェクトを子として追加します。

今回は、「Plane」を共有オブジェクトとして追加します。

画像12

(1) Hierarchyウィンドウの「+ → Create Empty」で空のゲームオブジェクトを作成し、名前に「SharedData」を指定。

画像1

(2) 「SharedData」にコンポーネント「ConvertToClientServerEntity」を追加。
GameObjectをエンティティに変換して、クライアントワールドサーバーワールドに配置します(デフォルトワールドには配置しない)。

画像2

(3) 「SharedData」を右クリックし、「3D Object → Plane」で、「Plane」を作成。

画像3

(4) 「Main Camera」のTransformを以下のように設定。

Position = (0, 4, -7)
Rotation = (50, 0, 0)
Scale = (1, 1, 1)

(5) 「Plane」のTransformを以下のように設定。

Position = (0, −1.5, 0)
Rotation = (0, 0, 0)
Scale = (1, 1, 1)

3. Ghostの準備

Ghost」は、クライアントワールドとサーバーワールドでTransform等の属性を同期するオブジェクトです。

「Ghost」をシーンに生成(スポーン)する手順は、次のとおりです。

(1)  Ghostプレハブの準備
(2)  GhostプレハブをGhostCollectionに登録

今回は、「Cube」を「Ghost」として追加します。

画像13

4. Ghostプレハブの準備

(1) 「SharedData」を右クリックし、「3D Object → Cube」で、「Cube」を作成。
(2) 「Cube」をProjectウィンドウにドラッグ&ドロップし、プレハブ「Cube」を生成。

画像4

(3) Hierarchyウィンドウの「Cube」を削除。
(4) Projectウィンドウにスクリプト「MovableCubeComponent」を追加し、以下のように編集。
次に説明する「Ghost Authoring Comonent」で利用する「プレイヤーID」を保持するコンポーネントです。

using Unity.Entities;
using Unity.NetCode;

[GenerateAuthoringComponent]
public struct MovableCubeComponent : IComponentData
{
    [GhostDefaultField]
    public int PlayerId; // プレイヤーID
}

(5) プレハブ「Cube」に「MovableCubeComponent」を追加。

画像5

(6) プレハブ「Cube」に「Ghost Authoring Comonent」を追加。
Ghost Authoring Comonent」は、プレハブの様々なコンポーネントのデータを同期する方法を設定します。

(7)「GhostAuthoringComonent」の下端のボタン「Upate component list」を押す。
プレハブを検査し、保持するコンポーネントの一覧がリストアップされます。各コンポーネントのデータの同期設定を行うことができます。

・MovableCubeComponent : プレイヤーID。
・Unity.Transforms.LocalToWorld : ローカル座標からワールド座標へ変換。
・Unity.Transforms.Rotation : 回転。
・Unity.Transforms.Translation : 位置。

(8) 「GhostAuthoringComonent」を以下のように設定。

・Default Client Instantiation Type : Owner Predicted
・Predicting player network id : MovableCubeComponent.PlayerId
・RootPath : /Generated

画像9

「Default Client Instantiation Type」は「クライアントインスタンス化種別」を指定します。

・ Interpolated : 補間済みとして扱う。
・ Predicted : 予測済みとして扱う。
・ Owner predicted : 所有クライアントは予測済み、他のクライアントは補完済みとして扱う。

「Predicting player network id」はプレイヤーIDを指定します。「RootPath」は「Ghost」のコードを生成するフォルダを指定します。

(9) 「Ghost Authoring Comonent」の下端のボタン「Generate code」を押す。
Generateフォルダにコードが生成されます。

画像10

5. GhostプレハブをGhostCollectionに登録

GhostCollection」を作成し、「Ghostプレハブ」を登録します。 

(1) 「SharedData」下に空のGameObjectを作成し、名前に「GhostCollection」を指定。

画像7

(2) 「GhostCollection」に「GhostCollectionAuthoringComponent」を追加し、以下ように設定。

RootPath : /Generated

画像13

(3)「GhostCollectionAuthoringComponent」の下端のボタン「Upate ghost list」を押す。
プロジェクトを検査し、保持するゴーストの一覧がリストアップされます。

(4) 「GhostCollectionAuthoringComponent」の下端のボタン「Generate collection code」ボタンを押す。
Generateフォルダにコードが追加されます。

画像11

7. Gameシステムの準備

クライアントとサーバーで接続して、クライアントからサーバにゲーム参加要求を送信し、サーバがCubeを生成するシステム群を作成します。

(1) Projectウィンドウにスクリプト「Game」を作成し、次のように編集。

using System;
using AOT;
using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using Unity.Burst;

// デフォルトワールドでの制御システムの更新
[UpdateInWorld(UpdateInWorld.TargetWorld.Default)]
public class Game : ComponentSystem
{
    // Gameを1度だけ実行するためのフラグ
    struct InitGameComponent : IComponentData
    {
    }

    // 生成時に呼ばれる
    protected override void OnCreate()
    {
        // InitGameComponentを持つ場合のみGameを有効化
        RequireSingletonForUpdate<InitGameComponent>();
 
        // InitGameComponentを持つエンディティを作成し、Gameを実行
        EntityManager.CreateEntity(typeof(InitGameComponent));
    }

    // 1フレーム毎に呼ばれる
    protected override void OnUpdate()
    {
        // InitGameComponentを破棄して、Gameが再度実行されないようにする
        EntityManager.DestroyEntity(GetSingletonEntity<InitGameComponent>());
        
        // ワールドの反復
        foreach (var world in World.All)
        {
            var network = world.GetExistingSystem<NetworkStreamReceiveSystem>();
            if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
            {
                // クライアントワールドはローカルホストにあるサーバーに接続
                NetworkEndPoint ep = NetworkEndPoint.LoopbackIpv4;
                ep.Port = 7979; #if  UNITY_EDITOR
                ep = NetworkEndPoint.Parse(ClientServerBootstrap.RequestedAutoConnect, 7979); #endif 
                network.Connect(ep);
            }
            #if  UNITY_EDITOR || UNITY_SERVER
            else if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
            {
                // サーバーワールドはクライアント接続のリッスンを開始
                NetworkEndPoint ep = NetworkEndPoint.AnyIpv4;
                ep.Port = 7979;
                network.Listen(ep);
            }
            #endif 
        }
    }
}

InitGameComponent
RequireSingletonForUpdate<T>()は、任意のコンポーネントを保つ場合のみシステムを有効化にする設定です。今回は、Gameを1度だけ実行するためのフラグとして「InitGameComponent」を使っています。

◎ Game
「Game」は、デフォルトワールド生成時に1回だけ実行されるシステムです。処理内容は、次のとおりです。

・クライアントワールドはローカルホストにあるサーバーに接続。
・サーバーワールドはクライアント接続のリッスンを開始(Unityエディタのみ)。

(2) スクリプト「Game」に以下のコードを追加。
サーバーにゲーム参加の準備ができていることを伝えるには、ゲーム参加リクエスト「RpcCommand」を生成して、サーバーに送信する必要があります。

// ゲーム参加リクエスト
[BurstCompile]
public struct GoInGameRequest : IRpcCommand
{
    // デモ用の未使用の整数
    public int value;

    // デシリアライズ
    public void Deserialize(ref DataStreamReader reader)
    {
        value = reader.ReadInt();
    }

    // シリアライズ
    public void Serialize(ref DataStreamWriter writer)
    {
        writer.WriteInt(value);
    }
    
    // 呼び出し実行
    [BurstCompile]
    [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))]
    private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
    {
        // RPCリクエストの生成
        RpcExecutor.ExecuteCreateRequestComponent<GoInGameRequest>(ref parameters);
    }

    // 呼び出し実行の関数ポインタの取得
    static PortableFunctionPointer<RpcExecutor.ExecuteDelegate> InvokeExecuteFunctionPointer =
        new PortableFunctionPointer<RpcExecutor.ExecuteDelegate>(InvokeExecute);
    public PortableFunctionPointer<RpcExecutor.ExecuteDelegate> CompileExecute()
    {
        return InvokeExecuteFunctionPointer;
    }
}

(3) スクリプト「Game」に以下のコードを追加。
NetCodeがコマンドを処理することを確認するには、「RpcCommandRequestSystem」を作成する必要があります。

// ゲーム参加リクエストの転送システム
public class GoInGameRequestSystem : RpcCommandRequestSystem<GoInGameRequest>
{
}

(4) スクリプト「Game」に以下のコードを追加。
GoInGameClientSystem」は、クライアントワールドで動作する、ゲーム参加リクエストの送信システムです。クライアントがサーバーと接続した時、ゲーム参加リクエストをサーバに送信します。

// ゲーム参加リクエストの送信システム
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
public class GoInGameClientSystem : ComponentSystem
{
    // 生成時に呼ばれる
    protected override void OnCreate()
    {
        // GoInGameClientSystemを持つ場合のみGoInGameClientSystemを有効化
        RequireSingletonForUpdate<EnableNetCubeGhostReceiveSystemComponent>();
    }

    // 1フレーム毎に呼ばれる
    protected override void OnUpdate()
    {
        Entities.WithNone<NetworkStreamInGame>().ForEach((Entity ent, ref NetworkIdComponent id) =>
        {
            // ゲーム参加リクエストの送信
            PostUpdateCommands.AddComponent<NetworkStreamInGame>(ent);
            var req = PostUpdateCommands.CreateEntity();
            PostUpdateCommands.AddComponent<GoInGameRequest>(req);
            PostUpdateCommands.AddComponent(req, new SendRpcCommandRequestComponent { 
                TargetConnection = ent 
            });
        });
    }
}

(5) スクリプト「Game」に以下のコードを追加。
GoInGameServerSystem」は、サーバーワールドで動作する、ゲーム参加リクエストの受信システムです。クライアントからゲーム参加リクエストを受信した時、「Cube」を生成し、ゲーム参加リクエストを破棄しています。

// ゲーム参加リクエストの受信システム
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public class GoInGameServerSystem : ComponentSystem
{
    // 生成時に呼ばれる
    protected override void OnCreate()
    {
        // EnableNetCubeGhostSendSystemComponentがある場合のみSystem動作
        RequireSingletonForUpdate<EnableNetCubeGhostSendSystemComponent>();
    }

    // 更新時に呼ばれる
    protected override void OnUpdate()
    {
        Entities.WithNone<SendRpcCommandRequestComponent>().ForEach((Entity reqEnt, ref GoInGameRequest req, ref ReceiveRpcCommandRequestComponent reqSrc) =>
        {
            // ゲーム参加リクエストの受信
            PostUpdateCommands.AddComponent<NetworkStreamInGame>(reqSrc.SourceConnection);
            UnityEngine.Debug.Log(String.Format("Server setting connection {0} to in game", EntityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value)); #if  true
            // Cubeの生成
            var ghostCollection = GetSingleton<GhostPrefabCollectionComponent>();
            var ghostId = NetCubeGhostSerializerCollection.FindGhostType<CubeSnapshotData>();
            var prefab = EntityManager.GetBuffer<GhostPrefabBuffer>(ghostCollection.serverPrefabs)[ghostId].Value;
            var player = EntityManager.Instantiate(prefab);
            EntityManager.SetComponentData(player, new MovableCubeComponent { 
                PlayerId = EntityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value
            });
            PostUpdateCommands.AddBuffer<CubeInput>(player);
            PostUpdateCommands.SetComponent(reqSrc.SourceConnection, new CommandTargetComponent {targetEntity = player}); #endif 

            // ゲーム参加リクエストの破棄
            PostUpdateCommands.DestroyEntity(reqEnt);
        });
    }
}

8. CubeInputシステムの準備

クライアントからサーバーにキー入力を送信するシステムを作成します。

(1) Projectウィンドウにスクリプト「CubeInput」を作成し、次のように編集。
クライアントからサーバーへのキー入力の送信に利用するコマンドデータ「ICommandData」を定義します。これは、キー入力データのシリアライズとデシリアライズを担当します。

using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;
using UnityEngine;

// キー入力データ
public struct CubeInput : ICommandData<CubeInput>
{
    public uint Tick => tick;
    public uint tick;
    public int horizontal;
    public int vertical;

    // デシリアライズ
    public void Deserialize(uint tick, ref DataStreamReader reader)
    {
        this.tick = tick;
        horizontal = reader.ReadInt();
        vertical = reader.ReadInt();
    }

    // シリアライズ
    public void Serialize(ref DataStreamWriter writer)
    {
        writer.WriteInt(horizontal);
        writer.WriteInt(vertical);
    }

    // ディリアライズ
    public void Deserialize(uint tick, ref DataStreamReader reader, CubeInput baseline,
        NetworkCompressionModel compressionModel)
    {
        Deserialize(tick, ref reader);
    }

    // シリアライズ
    public void Serialize(ref DataStreamWriter writer, CubeInput baseline, NetworkCompressionModel compressionModel)
    {
        Serialize(ref writer);
    }
}

(2) スクリプト「CubeInput」に以下のコードを追加。
コマンドストリームは、現在の位置と水平および垂直方向の動きで構成されます。RPCと同じように、「ICommandData」を設定して、処理する必要があります。

// コマンド送信システム
public class NetCubeSendCommandSystem : CommandSendSystem<CubeInput>
{
}

// コマンド受信システム
public class NetCubeReceiveCommandSystem : CommandReceiveSystem<CubeInput>
{
}

(3) スクリプト「CubeInput」に以下のコードを追加。
キー入力データのサンプリングを行い、サーバーに送信しています。

// キー入力データのサンプリング
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
public class SampleCubeInput : ComponentSystem
{
    // 生成時に呼ばれる
    protected override void OnCreate()
    {
        RequireSingletonForUpdate<NetworkIdComponent>();
        
        // GoInGameClientSystemを持つ場合のみSampleCubeInputを有効化
        RequireSingletonForUpdate<EnableNetCubeGhostReceiveSystemComponent>();
    }

    // 1フレーム毎に呼ばれる
    protected override void OnUpdate()
    {
        // localInputの取得
        var localInput = GetSingleton<CommandTargetComponent>().targetEntity;
        if (localInput == Entity.Null)
        {
            var localPlayerId = GetSingleton<NetworkIdComponent>().Value;
            Entities.WithNone<CubeInput>().ForEach((Entity ent, ref MovableCubeComponent cube) =>
            {
                if (cube.PlayerId == localPlayerId)
                {
                    PostUpdateCommands.AddBuffer<CubeInput>(ent);
                    PostUpdateCommands.SetComponent(GetSingletonEntity<CommandTargetComponent>(), new CommandTargetComponent {targetEntity = ent});
                }
            });
            return;
        }
        
        // キー入力データの生成
        var input = default(CubeInput);
        input.tick = World.GetExistingSystem<ClientSimulationSystemGroup>().ServerTick;
        if (Input.GetKey("a"))
            input.horizontal -= 1;
        if (Input.GetKey("d"))
            input.horizontal += 1;
        if (Input.GetKey("s"))
            input.vertical -= 1;
        if (Input.GetKey("w"))
            input.vertical += 1;
            
        // サーバーに送信
        var inputBuffer = EntityManager.GetBuffer<CubeInput>(localInput);
        inputBuffer.AddCommandData(input);
    }
}

9. MoveCubesystemシステムの準備

CommandData」を読み取って、「Cube」を移動させるシステムを作成します。

(1) Projectウィンドウにスクリプト「MoveCubesystem」を作成し、次のように編集。

using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;

// Cube移動システム
[UpdateInGroup(typeof(GhostPredictionSystemGroup))]
public class MoveCubeSystem : ComponentSystem
{
    // 1フレーム毎に呼ばれる
    protected override void OnUpdate()
    {
        var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
        var tick = group.PredictingTick;
        var deltaTime = Time.DeltaTime;
        Entities.ForEach((DynamicBuffer<CubeInput> inputBuffer, ref Translation trans, ref PredictedGhostComponent prediction) =>
        {
            if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
                return;
            CubeInput input;
            inputBuffer.GetDataAtTick(tick, out input);
            if (input.horizontal > 0)
                trans.Value.x += deltaTime;
            if (input.horizontal < 0)
                trans.Value.x -= deltaTime;
            if (input.vertical > 0)
                trans.Value.z += deltaTime;
            if (input.vertical < 0)
                trans.Value.z -= deltaTime;
        });
    }
}

10. 実行

実行すると、立方体が表示され、w(↑)a(←)s(↓)d(→)キーで移動できます。
アプリをビルドし、アプリ(クライアント)とUnityエディタ(サーバ)を同時に実行すると、2つの立方体が表示され、位置が同期されます。

画像11


この記事が気に入ったらサポートをしてみませんか?