UNET⑩ Spawnで敵キャラなどを生成

今回はNPCを作りながら、Spawnという機能について学びます。

NPCとはNon Player Characterの略で、プレイヤーが操作するキャラクター以外のキャラクター全般のことです。村人とかです。広義では敵キャラ等もNPCです。

準備

  1. 3Dの新規プロジェクトを作成
  2. 空のオブジェクトを作成し、名前をNetworkManagerなどとする
  3. NetworkManagerオブジェクトに、NetworkManagerコンポーネントとNetworkManagerHUDコンポーネントをアタッチする

ここまでは、UNETでネットワークゲームを作る際の典型的な作業です。続けて、このプロジェクトのための準備作業をいくつか行います。

  1. ここから3Dモデルをダウンロードし、Unityにインポートする
  2. 床があったほうが雰囲気出るので、Planeを (0, 0, 0) に設置しておいてください
  3. カメラはお好みで俯瞰視点に

f:id:motoyamablog:20171030130932p:plain

プレイヤーキャラが不要な場合の設定

大抵のゲームではプレイヤーキャラが存在すると思いますが、今回はとりあえず、プレイヤーキャラ無しで作っていきたいと思います。そういう場合は、以下の設定を変更する必要があります。

NetworkManagerのAuto Create Playerのチェックをオフにします。

f:id:motoyamablog:20171030131117p:plain

ここがONの場合、NetworkManagerが自動でプレイヤーキャラを生成しようとし、「でもプレイヤーキャラ登録されてないぞコルァ!」と警告が出てしまいます。

Enemyの作成

では、いよいよ、各クライアント間で同期するNPCを作っていきます。今回は敵キャラクターのようなものを作ります。MMORPGの敵とか、モンハンの恐竜みたいなイメージです。(といっても攻撃せずに動き回るだけなので、RPGの村人みたいなもののほうが近いかもしれません)

まず、インポートした3Dモデル(kamatoo.fbx)をSceneに設置してください。
オブジェクト名をEnemyとします。
場所は (0, 0, 0) にでもしといてください。

NetworkIdentityコンポーネントをアタッチします。クライアント間で同期させたいオブジェクトにはこのコンポーネントが必須です。逆に、同期の必要がない背景オブジェクトとか、使い捨てのパーティクルエフェクトとかには不要です。

今回はNetworkTransformを使って位置を同期させたいと思います。NetworkTransformをアタッチしてください。

新規スクリプト「Enemy」を作成&アタッチします。

using UnityEngine;
using UnityEngine.Networking;

public class Enemy : NetworkBehaviour
{
    // 移動速度(m/s)
    [SerializeField] float m_Speed = 3f;

    // 目的地
    Vector3 m_Destination;

    void Start()
    {
    }

    void Update()
    {
        // サーバー以外(ローカルクライアント)は何もしない
        if (!isServer)
            return;

        // 目的地の方を見る
        transform.LookAt(m_Destination);

        // 今回の移動距離を算出
        float moveDistance = m_Speed * Time.deltaTime;

        // 目的地までの距離を算出
        float distanceToDestination = Vector3.Distance(transform.position, m_Destination);

        // 目的地までの距離が今回の移動距離未満なら、到着ということ
        if (distanceToDestination <= moveDistance)
        {
            // 目的地に移動
            transform.position = m_Destination;
            // ランダムで新しい目的地を決める
            m_Destination = new Vector3(Random.Range(-5f, 5f), transform.position.y, Random.Range(-5f, 5f));
        }
        else
        {
            // 前進
            transform.Translate(Vector3.forward * moveDistance);
        }
    }
}

 

f:id:motoyamablog:20171030221845g:plain

完成です!簡単ですね!

説明していきます。

isServer

スクリプト中で isServer というプロパティを参照しています。これはNetworkBehaviourのプロパティで、サーバー(ホスト)で実行されているときのみtrueを返却します。普通、NPCはサーバーで管理(制御)します。つまりローカルクライアントではNPCに対しては何も処理しません。なので、isServerがfalseのときは何もせずにreturnしています。これを忘れると大変です。ローカルクライアントでもEnemyを動かしちゃうと、こんなことになります。

f:id:motoyamablog:20171030224918g:plain

ローカルクライアントではサーバーが決めた場所とは別の場所に動かそうとしてるけど、サーバーに位置を上書きされている、という状態です・・・。というわけで、isServerで処理を行うかどうかを決めるのはとても大切、という話でした。

ちなみに [ServerCallback] という属性があり、これをUpdateに付けると、ServerでしかUpdateが呼ばれなくなります。つまり今回 if(!isServer) return; としているのと同じ効果が得られます。まあ好みで好きな方を使えば良いんじゃないでしょうか。たぶん。

位置の更新がぎこちない件

今回、ホスト側では、Enemyは当然滑らかに移動しますが、ローカルクライアント側ではカクカクですね。これは、NetworkTransformで位置を同期しているからです。デフォルト設定だと、NetworkTransformの同期は1秒間に9回だし、補間もしてくれないので、このようにカクカクとなります。対策としては次のいずれかが考えられます。

  • NetworkTransformのNetworkSendRateを上げる
  • NetworkTransformのTransformSyncModeをSyncCharacterControllerにする(補間してくれるようになります。CharacterControllerが必要です)
  • NetworkTransformを使わずに自力実装して補間などを頑張る
NetworkIdentityがアタッチされたオブジェクトがアクティブになるタイミング

実行してみればわかりますが、NetworkIdentityがアタッチされたオブジェクトは、それ以外の静的なオブジェクトと違い、特殊な動きをします。

ゲームが起動後、未接続の時点では、自動的に非アクティブになります。で、接続が完了すると自動的にアクティブになります。

 

Spawnで動的に生成する

Spawnとは

さて、今回のように、ゲーム開始時から存在するNPCなら、このような作り方でOKです。例えば、村人とかならこれでOKかもしれません。しかし、無限に湧いて出てくるザコ敵や、条件を満たすと出現するボス敵などは、初めからシーンに置いておくわけにはいかず、プログラムから動的に生成する必要があるでしょう。そういった場合、通常の一人用のゲームの開発であれば、迷うことなくInstantiateメソッドを使いますね。しかし、UNETのゲームの場合、Instantiateでは駄目です。試しにInstantiateしてみればわかりますが、Instantiateを行ったクライアント上にしかオブジェクトは生成されません。では、ClientRPCなどを使って、全クライアント上で同時にInstantiateすれば良いじゃないかと思うかもしれませんが(過去の私です)、それも駄目です。それをやると、たしかに全クライアント上で同時にオブジェクトが生成されますが、それらはサーバーの管理下に置かれず、各クライアント上で勝手に存在するだけのオブジェクト、見た目は同じでも別々のオブジェクト、となります。同期できずにパラレルワールドになってしまいます。

前置きが長くなりましが、じゃあどうすれば良いのかというと、Spawnというメソッドが用意されているので、これを使います。ゲーム用語でSpawnというのは「プレイヤーが生成される、登場する」ということを表す単語ですが、UNETにおいては特別な意味合いがあり、同期対象のオブジェクトを、全クライアント上に生成するという意味になります。

Spawn対象プレハブの登録

Spawnしたいプレハブを事前に登録しておく必要があります。Enemyオブジェクトをプレハブ化し、NetworkManagerのSpawn InfoのRegistered Spawnable PrefabsにEnemyプレハブを登録しておいてください。

f:id:motoyamablog:20171105233247p:plain

この登録作業を行っておかないと、Spawnできません。

なお、SceneからEnemyオブジェクトは削除しておきます。 

EnemySpawnerの作成

EnemyをSpawnする係を作ります。 

空のオブジェクトを作成し、名前を「EnemySpawner」とでもしておきます。

EnemySpawnerオブジェクトにNetworkIdentityをアタッチします。そして、Server Onlyをオンにします。

f:id:motoyamablog:20171105233920p:plain

Server Onlyをオンにしたオブジェクトは、サーバー(ホスト)でのみ有効となります。今回、Enemyの管理は全てサーバー側で行うため、ローカルクライアントでは特にすることはありません。なので、Server Onlyをオンにします。

EnemySpawnerオブジェクトに新規スクリプトを追加します。スクリプト名は「EnemySpawner」とでもしておきます。

using UnityEngine;
using UnityEngine.Networking; // ←NetworkServerのために必要

public class EnemySpawner : MonoBehaviour
{
    // 生成するプレハブ
    [SerializeField] GameObject m_EnemyPrefab;

    // 生成する間隔(秒)
    [SerializeField] float m_SpawnInterval = 3f;

    // 最後に生成した時間
    float m_LastSpawnTime = float.MinValue;

    void Start()
    {
    }
    
    void Update()
    {
        // 最後に生成してからm_SpawnInterval以上経過していたら
        if (Time.time >= m_LastSpawnTime + m_SpawnInterval)
        {
            // まずサーバー内で普通にInstantiateする
            GameObject go = Instantiate(m_EnemyPrefab);

            // 位置を指定
            go.transform.position = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f));

            // Spawnを実施。その結果、全クライアント上で生成される。
            NetworkServer.Spawn(go);

            // 最後に生成した時間を記録
            m_LastSpawnTime = Time.time;
        }
    }
}

Spawnを使う場合も、サーバー内でまず普通にInstantiateします。それで生成したGameObjectをNetworkServer.Spawnメソッドの引数に渡します。すると、全クライアント上で生成されます。なお、Spawnを実施できるのはサーバー(ホスト)だけです。ローカルクライアントで実行しようとするとエラーとなります。

Unityエディターに戻ったら、忘れずにEnemySpawnerのEnemyPrefab欄にEnemyプレハブを登録しましょう。

f:id:motoyamablog:20171105234735p:plain

 

ゲームを複数起動して、動作確認してみてください。

f:id:motoyamablog:20171105235220g:plain

 

NetworkServer.Destroy()

Spawnを使って、全クライアント上に同期するオブジェクトを生成することが出来ました。

では、オブジェクトの削除はどうすれば良いでしょうか?

サーバー内で単純にDestroy()したのでは駄目です。サーバー内でのみ削除され、他のローカルクライアントでは生き続けてしまいます。

ClientRPCで全クライアントに命令を出して削除すればOKです。生成時と違い、削除時は、その後のことは考えなくて良いので、ClientRPCで削除すればOKです。・・・が、もっと簡単な方法も用意されています。

NetworkServerクラスのDestroyメソッドです。これを使うと、全クライアント上で一斉に削除が行われます。

Enemyスクリプトを改修して確認してみましょう。今回は、目的地に到着したら、50%の確率で消滅するようにしてみます。

using UnityEngine;
using UnityEngine.Networking;

public class Enemy : NetworkBehaviour
{
    // 移動速度(m/s)
    [SerializeField] float m_Speed = 3f;

    // 目的地
    Vector3 m_Destination;

    void Start()
    {
    }

    void Update()
    {
        // サーバー以外(ローカルクライアント)は何もしない
        if (!isServer)
            return;

        // 目的地の方を見る
        transform.LookAt(m_Destination);

        // 今回の移動距離を算出
        float moveDistance = m_Speed * Time.deltaTime;

        // 目的地までの距離を算出
        float distanceToDestination = Vector3.Distance(transform.position, m_Destination);

        // 目的地までの距離が今回の移動距離未満なら、到着ということ
        if (distanceToDestination <= moveDistance)
        {
            // 50%の確率で消滅する
            if (Random.value < 0.5f)
            {
                // 全クライアント上から消滅する
                NetworkServer.Destroy(gameObject);
                return;
            }

            // 目的地に移動
            transform.position = m_Destination;
            // ランダムで新しい目的地を決める
            m_Destination = new Vector3(Random.Range(-5f, 5f), transform.position.y, Random.Range(-5f, 5f));
        }
        else
        {
            // 前進
            transform.Translate(Vector3.forward * moveDistance);
        }
    }
}

 

f:id:motoyamablog:20171106002255g:plain

 

まとめ

  • NPCの管理はサーバーで行う
  • NPCを動的に生成するにはSpawnを使う
  • Spawnはサーバーでしか呼べない。もしローカルクライアントの行動を契機にSpawnを行いたい場合は、まずCommandでサーバーに通知し、サーバーでSpawnしてください。
  • Spawn対象のプレハブはNetworkManagerに登録する必要がある
  • Spawnしたオブジェクトを削除するにはClientRPCで削除するか、NetworkServer.Destroy()を使う

なお、今回は「NPCを実装する」という形で説明してきましたが、大抵の場合、「アイテム」も同様に実装します。大抵のネットワークゲームでは、アイテムが1個落ちていたとしたら、それを獲得できるプレイヤーは1人だけです。誰かがアイテムを取ったら、他のプレイヤーの画面上からアイテムは消滅します。つまり同期対象なので、今回のNPCの例と同様に、アイテムもSpawnやNetworkServer.Destroyを使って実現します。

 

 

次へ進む

目次へ