UNET⑥ CommandとNetworkTransformで位置を同期

前々回の記事で作ったプロジェクトを改修し、キャラクターの移動と同期を作ります。
その過程でCommandとNetworkTransformを詳しく学んでいきます。

さて、キャラクターの移動処理を作ることになったとき、普通に考えると、「移動ボタンが押されたらキャラクターを移動させるプログラム」をただちに書きたくなりますが、ちょっと待ってください!それはネットワークゲームではない普通の1人用のゲームの考え方です。ネットワークゲーム、特にUNETのネットワークゲームは、ホスト/クライアント型の、サーバー集中処理モデルを前提としています。サーバー集中処理モデルでは原則的に、クライアントでは処理を行わず、全てサーバーで処理し、クライアントは結果の反映に徹します。

f:id:motoyamablog:20171028211054g:plain

これが原則です(例外も多いのですが・・・)。
今回は原則に忠実に、この方法で実装してみましょう。

Command

クライアント上で移動ボタンが押されたということをサーバーに伝える必要があります。UNETで クライアント → サーバー のデータの流れを実装するには、Commandという機能を使います。

では作っていきましょう。
新規でC#スクリプト「Player」を作ってください。そしてそれをPlayerプレハブにアタッチしてください。
Playerスクリプトを以下のようにします。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking; // ←NetworkBehaviourのために必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承する
{
    // 移動速度(m/s)
    [SerializeField] float m_WalkSpeed = 4f;

    void Start()
    {
    }

    void Update()
    {
        // ローカルプレイヤー(つまり自分のキャラクター)の場合のみ
        // 移動処理を実施する。
        if (isLocalPlayer)
        {
            // 移動量を求める
            Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"))
                * m_WalkSpeed * Time.deltaTime;

            if (motion.magnitude > 0f)
            {
                // Commandにより、移動の依頼をサーバーに発行
                CmdMove(motion);
            }
        }
    }

    // 移動処理。これはサーバーで実行される
    [Command]
    void CmdMove(Vector3 motion)
    {
        print("CmdMove, netId=" + netId + ", motion=" + motion); // 仮。とりあえずログで確認
    }
}
スクリプトの説明

まず、このクラスは、通常のMonoBehaviourではなくNetworkBehaviourを継承しています。NetworkBehaviourはMonoBehaviourにUNET関連の機能を追加したものです。Commandなどの各種UNET HLAPIを使うにはNetworkBehaviourである必要があります。

isLocalPlayerというプロパティはNetworkBehaviourの機能で、ローカルプレイヤーの場合はtrueになり、他人のプレイヤーの場合はfalseになります以前の記事で説明しましたが、1人用のゲームと違い、ネットワークゲームでは、自分のプレイヤーキャラと他人のプレイヤーキャラがゲーム上に存在し、操作可能なのは自分のプレイヤーキャラのみです。他人のプレイヤーキャラを動かしてはいけません。そのために、isLocalPlayerプロパティを参照して、trueのときのみ処理を行うということをしょっちゅうやります。覚えておいてください。

[Command] という属性が出てきました。これは、次の関数がCommandであるということを表します。今回はCmdMoveという関数です。Command属性が付けられた関数は、サーバー側で実行されます。つまり今回だと、クライアント上でCmdMove関数を呼び出すと、直ちに実行されるわけではなく、「CmdMoveを呼び出したいです」という情報がクライアントからサーバーに送信され、サーバー側でCmdMoveが実行されるのです。通常、ネットワークゲームをプログラミングする際は、その部分を自分で実装する必要がありますが、そこはUNETがやってくれるというわけです。ありがたや~🙏
なお、Commandには色々と約束事があります。記事中盤で説明します。

netIdというのは、これもNetworkBehaviourの機能で、ネットワーク上で一意となる番号です。Aさんのプレイヤーキャラは1番、Bさんのプレイヤーキャラは2番、というふうに番号が振られます。単純に1番から生成順に割り振られるようです。

動作確認

exeをビルドし、exeとエディターでゲームを起動します。エディター側のゲームをHostで起動し、exe側をClientにしましょう。
上下左右のいずれかを押すと、その情報がホスト(つまりサーバー)側で表示されます。クライアント側で操作しても、ホスト側に情報が伝わっていることが確認できるはずです。

確認できたら、次はホスト/クライアントを交換してみましょう。exe側をホスト、エディター側をクライアントにしてみましょう。ただ、普通に実施したのでは、何も起きません。exeで実行したゲームにはログを表示する機能がありませんから。なので、前の記事で作った、ログを画面に表示する機能を使って、ログを画面に出してみましょう。これで、どちらをホストにしても、Commandによってクライアントからサーバー(ホスト)に情報が送られていることが確認できるはずです。

f:id:motoyamablog:20171028211056g:plain

UNETでネットワークゲームを作る際は、常にエディター側をホストにするのではなく、入れ替えてみたり、3個以上のゲームを起動して接続してみたりするということをたまに試すと良いです。そうすると、よくバグに気づきます😓

NetworkTransformを使う

クライアント側で移動操作が行われたことが、サーバー側に伝達できました。あとは、サーバー側で移動処理を行い、その結果をクライアントに通知します。
この、サーバー→クライアント の通知の実現方法はいくつか考えられます。例えば・・・

  1. ClientRPCを使ってサーバーからクライアントへ通知
  2. SyncVarを使ってサーバーからクライアントへ通知
  3. NetworkTransformを使って位置を同期

他にも、Messageという機能を使った方法なども考えられますが、今回は最もお手軽な3番の方法でやりたいと思います。

さて、NetworkTransformを使って位置の同期をすることにしましたが、その前に、サーバー側でキャラクターの移動を行う処理を実装しなければなりません。
Unityを使ってキャラクターの移動処理を作る方法は、大きく分けて3通りあると思います。

  1. CharacterControllerを使う
  2. Rigidbodyを使う
  3. 自力実装

今回は1番のCharacterControllerを使った移動を使ってみましょう(NetworkTransformとの相性が良いから)。

というわけで、PlayerプレハブにCharacterControllerをアタッチします。

続いて、Playerスクリプトを改修します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking; // ←NetworkBehaviourのために必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承する
{
    // 移動速度(m/s)
    [SerializeField] float m_WalkSpeed = 4f;

    CharacterController m_CharacterController;

    void Start()
    {
        m_CharacterController = GetComponent<CharacterController>();
    }

    void Update()
    {
        // ローカルプレイヤー(つまり自分のキャラクター)の場合のみ
        // 移動処理を実施する。
        if (isLocalPlayer)
        {
            // 移動量を求める
            Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"))
                * m_WalkSpeed * Time.deltaTime;

            if (motion.magnitude > 0f)
            {
                // Commandにより、移動の依頼をサーバーに発行
                CmdMove(motion);
            }
        }
    }

    // 移動処理。これはサーバーで実行される
    [Command]
    void CmdMove(Vector3 motion)
    {
        // ↓もう不要なので削除してOK
        // print("CmdMove, netId=" + netId + ", motion=" + motion); // 仮。とりあえずログで確認

        m_CharacterController.Move(motion);
    }
}

まだ未完成ですが、現時点での動作を確認してみましょう。exeとエディターでゲームを起動してください。

f:id:motoyamablog:20171028211100g:plain

上図では、左側の画面がホストなのですが、どちらの画面で操作してもホスト側でしかキャラクターが動きません。つまり、サーバー(ホスト)でのキャラクターの移動処理は実現できていますが、移動結果がクライアントに通知されていない状態です。想定通りです。

では、移動結果をクライアントに通知する機能を実装しましょう。といっても、NetworkTransformを使う場合は簡単です。
PlayerプレハブにNetworkTransformコンポーネントをアタッチしてください。
そして、今回はCharacterControllerを使った移動なので、NetworkTransformのTransform Sync Mode を Sync Character Controller にします。

f:id:motoyamablog:20171028211101p:plain

以上です。

ではまた動作を確認してみましょう。exeとエディターでゲームを起動してください。

f:id:motoyamablog:20171028211104g:plain

完成です!
NetworkTransformを使うと、とても簡単に位置の同期が行えます
今回NetworkTransformがやってくれたことは2つあります。

  1. サーバーからクライアントへの位置情報の送信
  2. クライアントでの受信処理とオブジェクトの移動処理

ネットワーク越し(LAN)で動作確認する

今までは、1台のPC内で通信を行いましたが、環境が整えられる場合は、是非、複数のPCを使って動作確認をしてみてください!
いきなりインターネット越しの接続を行うのは、諸々の問題によりハードルが高いので、まずはLAN内での通信を試してみてください。
やり方は以下の通りです。

  1. ゲームのexeをビルドし、他のPCにコピーする
  2. それぞれのPCでexeを実行する(Unityエディタでも可)
  3. ホストとするマシンは「LAN Host」を押す
  4. リモートクライアントとなるマシンは「LAN Client」の右側の入力欄にホストのIPアドレスを入力してから、「LAN Client」ボタンを押す

f:id:motoyamablog:20171028213953p:plain

これで、ネットワーク越しに複数の端末が通信して、キャラクターの位置が同期している様子を確認できるはずです。きっと、1台のPC上で確認するより感動するはず!

Commandの制約

今回は、クライアントでの入力をサーバーに伝えるために、Commandを使いました。このCommand、結構使い方に癖があるというか、制約が多いので、ここで詳しく説明します。

[Command]属性を付ける

Commandとして機能させたい関数には[Command]属性を付けます。

関数名はCmdで始める

Commandとして機能させたい関数は、必ずCmdから始まる名前にしなくてはいけません。そういうお約束です。試しに今回作ったCmdMove関数の名前をMoveなどに変えてみるとわかりますが、ビルドエラーとなります。C#の文法上の問題ではなく、UNETのビルドの仕組みがエラーを吐きます。

引数で情報を渡せる

既に、しれっと使っていますが、Commandの関数には自由に引数を渡せます(引数なしでももちろん構いません)。引数の数に制限はありませんが、使える型は限定されています。といっても、けっこう色々使えます:

  • 基本型 (byte、int、float、string、など)
  • 基本型の配列
  • Unity にビルトインしている数学に関する型 (Vector3、Quaternion、など)
  • NetworkIdentity
  • NetworkInstanceId
  • NetworkHash128
  • NetworkIdentity コンポーネントがアタッチされたGameObject
  • 上記のみを含む自作の構造体

他にもあるかもしれませんが、こんなところです。まあ、これだけ使えれば十分でしょう。

ローカルプレイヤーからしか呼べない

これが非常に重要、しかし忘れやすい特徴です。Commandの関数はローカルプレイヤーオブジェクトからしか呼べません。
まず、UNETにおけるプレイヤーオブジェクトとは何かというと、NetworkManagerのここ↓に登録したやつです。

f:id:motoyamablog:20171028211108p:plain

ここに登録したプレハブから自動で生成されるオブジェクトが、プレイヤーオブジェクトとなります。で、プレイヤーオブジェクトの中でも、自分のプレイヤーオブジェクトは特別で、ローカルプレイヤーと呼ばれます。
ローカルプレイヤーは、UNETの仕組みの内部で特別扱いとなっていて、ローカルプレイヤーだけがCommandを呼び出せます。

より正確に言い直すと、ローカルプレイヤーのみがCommandの関数を持つことができます。だから例えば、敵キャラのスクリプトとか、爆弾のスクリプトとかはCommand関数を持てません。でも、それらからプレイヤーのpublicなCommand関数を呼び出すことは可能です。

※なお、プレイヤーオブジェクト以外にCommand関数を持たせて実行させる方法は一応あります。プレイヤーオブジェクトと同様の、特別な権限をオブジェクトに与えると使えるようになります。これこれを使います。

※Webや書籍で、「LocalPlayerAuthorityがtrueじゃないとCommandは使えない」と書いてある物が多い気がしますが誤りです。LocalPlayerAuthorityはCommandと関係無いです。LocalPlayerAuthorityの設定はNetworkTransformやNetworkAnimatorの動作に影響しますが、Commandとは関係無いです。

NetworkTransformの詳細

NetworkTransformの各パラメータについて説明します

f:id:motoyamablog:20171028211111p:plain

Network Send Rate

情報伝達の頻度です。単位は回/秒です。つまり、数値を上げるほど情報伝達の頻度が上がりますが、ネットワーク負荷も高まります。もし作るゲームがLAN接続前提なのであれば、最大値まで上げちゃえば良いと思います。インターネット接続のゲームなのであれば、慎重に決定する必要があります。

Transform Sync Mode

Sync None:同期しません(?)
Sync Transform:位置と向きを同期します。補間はしてくれないため、カクカクな動きになります。
Sync Rigidbody2D:Rigidbody2Dによって動くオブジェクトの位置を同期します。
Sync Rigidbody3D:Rigidbody3Dによって動くオブジェクトの位置を同期します。
Sync Character Controller:CharacterControllerによって動くオブジェクトの位置を同期します。補間してくれるので、滑らかに動きます。

Movement Threshold

ここで設定した以上の距離を移動して初めて情報の同期処理を行います。つまり、値を大きくすれば、細かい移動は無視され、ネットワーク帯域が節約できます。

Snap Threshold

TransformSyncModeでSyncCharacterControllerなどを選んだ場合、補間によりヌルっと動いてくれるのですが、ラグなどの問題により情報伝達が滞り、いきなり10メートル移動したという命令が飛んでくるかもしれません。そういう場合は、補間せずに、いっそ瞬間移動させたほうが自然だったりします。その瞬間移動させると判断する距離の閾値です。
・・・しかし、この機能バグっていて、発動すると異常な動きをします。本当に困っています。

f:id:motoyamablog:20180114092358g:plain

↑左側の画面にて瞬間的に移動させている。移動距離がSnap Thresholdを超えると、右側の画面にて異常な挙動をする。

フィールド上に壁などが無いゲームの場合は、とりあえずこの機能が発動しないように10000とか大きな数値を入れておけば良いでしょう。問題は、壁などがたくさんあったり、迷宮のような場所が舞台のゲームです。現状、良い対処法が思い付きません・・・。UNETのソースを改修するか、NetworkTransformを使わずに自力実装する必要があると思います。

Interpolate Movement Factor

補間の強さに影響します。初期値は1ですが、より小さな値にすると、よりゆっくり、ネバーっとした補間になります。まあ初期値で良いと思います。

Rotation Axis

同期する回転の軸を制限します。初期値のXYZだと、X軸回転量、Y軸回転量、Z軸回転量の3つ角度を同期しますが、多くのゲームのオブジェクトは、それほど縦横無尽には回転しないものです。例えば人型の3Dキャラクターであれば、大抵Y軸回転だけでこと足ります。Y軸回転のみに制限すれば、情報量が3分の1で済むというわけです。つまりネットワーク帯域に優しくなります。ゲーム内容に応じて適宜設定しましょう。

Interpolate Rotation Factor

回転の同期の補間の強さに影響します。小さい値にすると、よりゆっくり、ネバーっとした補間になります。まあ初期値で良いと思います。

Compress Rotation

回転角度の値を圧縮するかどうかの設定です。Noneの場合、float型(4バイト)で回転量を扱いますが、LowおよびHighにするとShort(2バイト整数)で扱うようです。
回転角度は大抵の場合、それほどの精度は求められないでしょうから、Shortでも十分かと思います。ソースを見る限り、LowとHighの違いは無さそうです💦

Sync Angular Velocity

RigidbodyのAngularVelocityを同期するかどうか

ラグの問題

というわけで、今回はCommandとNetworkTransformを使って、ホスト/クライアント型のサーバー集中処理モデルの原則に忠実な感じでプレイヤーキャラクターの位置同期処理を作ってみました。

さて、作ったのは良いのですが・・・実は、プレイヤーキャラクターの移動処理に関しては、このような手法で作ることは実際は少ないと思われます。
今回は、1台の端末内でゲームを複数起動して動作確認したと思いますが、ネットワーク越しの複数の端末上でゲームを起動して接続すると、通信遅延(ラグ)の影響で、リモートクライアント(ホスト以外のクライアント)での操作がかなりモッサリに感じるはずです。通信が1往復して戻ってくるまで、自分のキャラクターに操作が反映されないのだから当然です。
ゆっくりとしたテンポのゲームならそれでも問題無いかもしれませんが、アクション性の高いゲームでは致命的です。よって、大抵のゲームでは、まず、ローカルにてプレイヤーオブジェクトの移動処理を実施します。そして、その結果をサーバーに通知し、サーバーはそれを他のクライアントにも通知します。

そういったパターンの実装にするには、どうしたら良いのでしょうか?
実は、NetworkTransformはそのパターンにも対応しており、非常に簡単に実装できます。やってみましょう。

まずは、Playerスクリプトを改修します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking; // ←NetworkBehaviourのために必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承する
{
    // 移動速度(m/s)
    [SerializeField] float m_WalkSpeed = 4f;

    CharacterController m_CharacterController;

    void Start()
    {
        m_CharacterController = GetComponent<CharacterController>();
    }

    void Update()
    {
        // ローカルプレイヤー(つまり自分のキャラクター)の場合のみ
        // 移動処理を実施する。
        if (isLocalPlayer)
        {
            // 移動量を求める
            Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"))
                * m_WalkSpeed * Time.deltaTime;

            m_CharacterController.Move(motion);
        }
    }
}

Command関連の実装は、せっかく作ったのですが、ゴッソリ削除してしまいました😂
普通の1人用のゲームを作るように、ローカルにて移動処理を実施します。

次に、PlayerプレハブのNetworkIdentityコンポーネントのLocalPlayerAuthorityをONにします。

f:id:motoyamablog:20171028211113p:plain

Authorityとは「権限」、「所有権」、「支配権」といったような意味です。
何度も述べた通り、UNETでは、サーバーが全てを管理・制御するのが原則です。しかし、プレイヤーキャラのように、クライアント上で制御したい場合もあります。そういう場合はこのLocalPlayerAuthorityをONにします。これはNetworkTransformやNetworkAnimator(後日紹介)に対しての「このオブジェクトはクライアント側で制御すっから✋」という意思表示です。
NetworkTransformはLocalPlayerAuthorityを参照しており、このプロパティがONだと挙動が大幅に変わり、情報の伝達が
クライアント→サーバー→その他のクライアント
となります。

f:id:motoyamablog:20171028211114g:plain

というわけで、最終的にはCommandも使わないし、コードもすごく短くなってしまいました。しかし「最初からこうすれば良かったじゃないか!」と責めないでください。原則的には、最初に試した実装方法(サーバー側で処理を行う実装)をまず考えるべきなのです。そして、それでは問題があるという場合のみ、クライアント側で処理を行う実装にします。これがUNET(というかクライアント/サーバー型ネットワークゲーム)の基本です。
あと、Commandに慣れるためにも、あえて最初の実装方法を試しました。

 

次へ進む

目次へ