UNET⑨ SyncVarで変数を同期する

UNETのSyncVarという機能を使って、各クライアント間で変数を同期します。

これまでの記事で、CommandとClientRPCについて学びました。
Commandを使えばクライアントからサーバーへ情報が送信でき、ClientRPCを使えばサーバーからクライアントへ情報が送信できます。
なので・・・

CommandとClientRPCさえあれば、何でも出来る!
他のゴチャゴチャした機能なんて要らん!!

 

  ,j;;;;;j,. ---一、 `  ―--‐、_ l;;;;;;
 {;;;;;;ゝ T辷iフ i    f'辷jァ  !i;;;;;  
  ヾ;;;ハ    ノ       .::!lリ;;r゛
   `Z;i   〈.,_..,.      ノ;;;;;;;;>  そんなふうに考えていた時期が
   ,;ぇハ、 、_,.ー-、_',.    ,f゛: Y;;f.   俺にもありました
    ~''戈ヽ   `二´    r'´:::. `!

 

いや、実際、頑張ればCommandとClientRPCだけでもネットワークゲーム作れると思いますが・・・やっぱつらい・・・不便です・・・
少ない機能だけ使って縛りプレイするなら、そもそもUNET使う意味無いので、もっと色んな便利な機能を使っていきたいと思います。

例えば、UNETにはSyncVarという、クライアント間で変数を同期する仕組みがあるので、今回はこれを使って、下図のようなものを作りたいと思います。

f:id:motoyamablog:20171028234138g:plain

各プレイヤーキャラの頭上に数が表示されていて、ボタンを押すとカウントアップするというものです。

この程度のものなら、CommandとClientRPCで簡単に作れる・・・と思っていたのですが、なかなか、そうもいかないのです。
次の動画をご覧ください。

f:id:motoyamablog:20171028234228g:plain

これはCommandとClientRPCのみを使って実装した例ですが、ゲームの途中で他のプレイヤーが参入した場合、後から入ったプレイヤーの画面には、先に始めていたプレイヤーの情報が正しく伝わっていません。
SyncVarを使えば、この問題も自動的に解決します。

では、作っていきましょう。
前回サクッと作ったプロジェクトをそのまま改造していきたいと思います。

プレイヤーの頭上にテキストを追加

プレイヤーキャラクターの頭上にテキストを追加したいと思います。普通はUGUIかTextMeshProを使うのが良いと思いますが、面倒なので今回は、古くからあるTextMeshを使います。

Playerプレハブを一度Sceneに設置します。

Hierarchyビューにて、Playerオブジェクトを右クリックし、3D Object>3D Text を選択します。

初期Textは「0」にしておきましょう。

位置や文字サイズは適当に調整してください。

f:id:motoyamablog:20171028234318p:plain

テキストの調整が済んだら、変更をプレハブにApplyして、SceneからPlayerオブジェクトは削除します。

f:id:motoyamablog:20171028234325p:plain

Playerのスクリプトを作成

Playerプレハブに対しAdd Componentを押し、新規スクリプトを追加します。
スクリプト名はPlayerとします。

using UnityEngine;
using UnityEngine.Networking; // ←NetworkBehaviour使うのに必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承
{
    // SyncVar属性を付けた変数は各クライアント間で同期される
    [SyncVar]
    int m_Count = 0;
  
    TextMesh m_CountText;

    void Start()
    {
        // 自分の子からTextMeshコンポーネントを取得する
        m_CountText = GetComponentInChildren<TextMesh>();
    }

    void Update()
    {
        // ローカルプレイヤーのみ(他人のプレイヤーでは、やらない)
        if (isLocalPlayer)
        {
            // スペースキー押下でカウントアップ
            if (Input.GetKeyDown(KeyCode.Space))
            {
                CmdCountUp();
            }
        }

        // テキストを更新する
        m_CountText.text = m_Count.ToString();
    }

    // カウントアップ処理はサーバーで行う
    [Command]
    void CmdCountUp()
    {
        m_Count++;
    }
}

以上で完成です!😃
ゲームを複数起動して、動作確認してみましょう!

f:id:motoyamablog:20171028234331g:plain

SyncVarの解説

使い方

SyncVarの使い方は、上記のスクリプトを見てもらえばわかる通り、とても簡単です。
クライアント間で同期したい変数に [SyncVar] 属性を付けるだけです。

情報の流れる方向

SyncVarで情報が流れる方向については注意が必要です。
常に サーバー → クライアント です。逆はできません

f:id:motoyamablog:20171028234930g:plain

つまり、ホストではないリモートクライアント上で変数を書き換えても、その変更は他のクライアントには伝わりません。SyncVarの変数をリモートクライアント上で書き換えてはいけないのです。常にサーバー側で書き換えるようにしてください。これに従い、上記スクリプトでは、Commandを使い、サーバー側でカウントアップ処理を行っています。

使える型

SyncVarは何でもかんでも同期できるわけではなく、使える変数の型には制限があります。
公式マニュアルによると

SyncVar は int、string、float などのベーシックタイプで使用可能です。Vector3 やユーザー定義の構造体などの Unity タイプでも使用可能

とあります😃ほー・・・

 

公式がなどでごまかすのやめろマジで😂

 

このあたりの処理はオープンソースになってはいないので、泣きなから動作検証した結果、次のような型が使えるようです。

  • プリミティブ型(byte、char、short、ushort、int、uint、long、float、doubleなど)
  • string
  • Vector3
  • Quaternion
  • NetworkInstanceId
  • NetworkIdentity
  • NetworkIdentity コンポーネントがアタッチされたGameObject
  • 上記を含む独自の構造体、クラス

構造体やクラスはネストしててもOK。
配列やListは使えません(SyncListという機能がUNETには用意されているので、そちらを使いましょう)。

同期のタイミング

同期(サーバーからクライアントへの送信)処理は毎フレーム行われているわけではありません。
まず、ゲームに新たにリモートクライアントが接続してくると、サーバーは、全てのSyncVar変数の内容を、新しいリモートクライアントに送信します。その後は、SyncVar変数が変更されたときのみ送信します。
SyncVarの変数が複数ある場合は、変更された変数のみ送信します。無駄がなくて素晴らしいですね。

同期の頻度

上で、SyncVarの変数は、変更されたときに同期されると書きましたが、変更された瞬間に毎回同期されるのかというと、そうではありません。例えば、60FPSで動くゲームで、毎フレーム変数を書き換えたら、秒間60回の送信処理が行われるのかというと、行われません。デフォルトでは、0.1秒毎に送信するようになっています。ネットワーク帯域の節約のためです。
この送信頻度を変更するには、以下のようにNetworkBehaviour継承クラスに [NetworkSettings] 属性を追加し、sendInterval (単位は秒)を指定します。

using UnityEngine;
using UnityEngine.Networking;

[NetworkSettings(sendInterval = 0.5f)]
public class Player : NetworkBehaviour
{
    [SyncVar]
    int m_Count = 0;

    ・・・以下略・・・

上記の指定だと、0.5秒間隔での送信となります。リアルタイム性がそれほど重要でない場合は、このように同期の頻度を落とすことで、帯域やサーバーに優しいゲームとなります。逆にリアルタイム性が要求される場合は送信頻度を上げてください(sendIntervalを小さくする)。

送信頻度の指定方法はもう一種類あります。NetworkBehaviourのGetNetworkSendIntervalメソッドをオーバーライドするという方法です。

    public override float GetNetworkSendInterval()
    {
        return 0.5f;
    }

これでも、 [NetworkSettings] 属性で指定した場合と同じ効果があります(というか、属性で指定すると、Unityが裏でこのメソッドを自動生成する模様。そのため、属性の指定とメソッドのオーバーライドの両方があるとビルドエラーとなる)。
こちらの方法の利点は、ゲーム実行中に動的に送信頻度を変えられるというところです。

なお、変数毎に異なる送信頻度を設定することはできません。そういったことがしたい場合は、別のNetworkBehaviourコンポーネントとして分けて作りましょう。

構造体を同期させる際の注意

SyncVarで構造体やクラスを同期させたい場合、注意点があります。構造体(またはクラス)内の変数を書き換えただけでは、同期が行われません。構造体(またはクラス)が別のインスタンスになる必要があります。
例えば、SyncVar属性を付けたVector3型の変数vがあったとして、

同期が行われない例:

v.x = 100;
v.y = 200;
v.z = 300;

同期が行われる例:

v = new Vector3(100, 200, 300);

なお、同期(送信)は構造体丸ごと行われます。構造体の中の一部だけしか変化してないから、その部分だけ送信する、ということはできません。

持てるSyncVar変数の上限

SyncVarの変数は、1クラスにつき最大で32個まで持つことができます。常識的に考えて、1クラスのフィールドが、それも同期しなければならない値が32個を超えることはないので、充分でしょう。

値の変更をフックするhook機能

実は上記のPlayerスクリプトでは、かなり無駄な部分があります。
下記の部分です。

    void Update()
    {
        (略)

        // テキストを更新する
        m_CountText.text = m_Count.ToString();
    }

値が変わっていなくても、毎フレーム、テキストを更新しています。本来は、変化があったときのみ更新するべきでしょう。
それを実現するには、古い値を記憶しておくための変数と、変化があったか調べるためのif文が追加で必要になるでしょう。難しくはないですが、面倒だし、ゴチャゴチャしますね。

実はSyncVarにはhookという機能があって、これを使うと簡単に解決します。
hookを使って改善した例は以下の通りです。

using UnityEngine;
using UnityEngine.Networking; // ←NetworkBehaviour使うのに必要

public class Player : NetworkBehaviour // ←NetworkBehaviourを継承
{
    // SyncVar属性を付けた変数は各クライアント間で同期される
    [SyncVar(hook = "HookCountChanged")]
    int m_Count = 0;

    TextMesh m_CountText;

    void Start()
    {
        // 自分の子からTextMeshコンポーネントを取得する
        m_CountText = GetComponentInChildren<TextMesh>();
    }

    void Update()
    {
        // ローカルプレイヤーのみ(他人のプレイヤーにはやらない)
        if (isLocalPlayer)
        {
            // スペースキー押下でカウントアップ
            if (Input.GetKeyDown(KeyCode.Space))
            {
                CmdCountUp();
            }
        }

        // テキストを更新する
        // m_CountText.text = m_Count.ToString(); ←これはもう不要
    }

    // カウントアップ処理はサーバーで行う
    [Command]
    void CmdCountUp()
    {
        m_Count++;
    }

    // m_Countが同期された時に呼ばれる
    void HookCountChanged(int newValue)
    {
        m_Count = newValue;

        // テキストを更新する
        m_CountText.text = m_Count.ToString();
    }
}

動作確認してみてください。表面的には、hookを使わないときと変わりません。

hookの使い方について説明していきます。

hook時に実行される関数を定義する

hook時に実行される関数を作ります。関数名は自由です。CommandやClientRPCのように指定の文字列から始めなければいけないということはありません。しかし、パッと見でhookに使ってる関数なんだな、ということがわかる名前にしといたほうが良いかと思います。

戻り値は無し、voidにしてください。

引数は、監視対象のSyncVar変数と同じ型の値を受け取らなければなりません。引数名は自由です。
サーバーから送信されてきた新しい値が引数で渡されるので、受け取った引数をそのまま(ないし必要であれば加工して)フィールドに設定する必要があります。上記コードの

m_Count = newValue;

の部分です。これを行わないと、リモートクライアント上のSyncVar変数に変更が反映されず、永久に初期値のままとなります。hookを使わない場合は不要な処理なので、忘れやすいです。気を付けましょう。

SyncVar属性にhookの指定を追加する

変数に付けた [SyncVar] 属性に、hookの指定を追加します。

[SyncVar(hook = "関数名")]

hook時に実行して欲しい関数の名前を文字列で指定します。文字列での指定なので、スペルミスやリファクタリング時の漏れに注意しましょう。

hook処理は全クライアントで実行される

リモートクライアントだけではなく、ホスト上のローカルクライアントでも、ちゃんとhook関数は実行されます。素晴らしい。

クライアント接続時の初回同期の際は呼ばれない

やや注意が必要な挙動です。上述の通り、クライアントとして新規でサーバーに接続した際は全てのSyncVar変数が同期されますが、その際はhook処理が呼ばれません。頭の片隅に入れておかないと、つまんない不具合を作りそうです。
・・・と書いてて、まさにそのつまんない不具合を作ってしまったことに気づきました。
上記のPlayerスクリプトでは、ゲームの途中でプレイヤーが参加した場合、その後から入った人の画面で、先に始めていたプレイヤーのテキストが初期値のままとなってしまいます。内部的には、SyncVar変数は同期完了していますが、それをテキストに反映していないのです。
Start()やOnStartClient()が呼ばれるタイミングではSyncVar変数の同期は完了しているので、必要に応じて適切な初期化処理を行いましょう。

例:

    void Start()
    {
        // 自分の子からTextMeshコンポーネントを取得する
        m_CountText = GetComponentInChildren<TextMesh>();
      
        // テキストを更新する
        m_CountText.text = m_Count.ToString();
    }

SyncVarおよびhookの使い方は以上です。

なお、SyncVarが裏でどのような仕組みで動いているかについては、公式マニュアルが参考になるので、気になる人は見てみてください。SyncListの使い方も書いてあります。

 

次へ進む

目次へ