ヴァンサバ風ゲームの作り方#3|敵を出して追いかけさせよう(ObjectPoolで軽くする)

画面の外から出てきたコゲクッキーが、列になってスライムちゃんを追いかけてくる様子

工房が出来たので、いよいよ敵の登場です!ヴァンサバ風のゲームでは、敵が何十体・何百体と出てくるのが気持ちよさの元になっています。ただ、何も考えずに出したり消したりを繰り返すと、だんだん動きがカクついてきます。今回はヴァンサバ風サバイバーゲームのシリーズの3回目として、こげたクッキーを画面の外から出して追いかけさせ、何百体出しても大丈夫な作りにしてみたいと思います。実際に測ってみたら、思っていたのと違う所が重かったので、そこも含めて書いていきます!

目次

今回作るもの

  • こげたクッキーの敵をプレハブで作る
  • 画面の外から、1秒ごとに1体ずつ出てくる
  • 出てきた敵は、スライムちゃんをまっすぐ追いかける
  • ObjectPoolで敵を使い回して、出したり消したりのムダをなくす
  • 敵どうしの当たり判定を切って、何百体出しても重くならないようにする

作るスクリプトは2つです。

スクリプト役割付ける場所
Enemyプレイヤーを追いかける。遠くへ離れたらプールに戻る敵のプレハブ
EnemySpawner一定間隔で敵を出す。プールを持っているEnemySpawner(空のオブジェクト)

前回の続きから

  • Unityのバージョンは6.3 LTS
    • 記事作成時点では6000.3.22f1で確認しています
  • #2の状態:お菓子工房の床があり、スライムちゃんが中を歩き回れる

プレハブの基本は7-1 プレハブとはと7-3 スクリプトからプレハブを生成で紹介しています。

こげたクッキーを1体作ろう

まずは敵を1体、シーンに置いてみます。

  • ProjectウインドのSurvivorsLike/Spritesから、enemy_cookieをHierarchyにドラッグ&ドロップ
  • 名前を「Enemy」にリネーム
  • Add Component>Rigidbody2D
    • Gravity Scale:0
    • Constraints>Freeze Rotation:Z にチェック
  • Add Component>Circle Collider 2D
    • Is Trigger:チェックを入れる
    • Radius:0.35
Enemyのプレハブ。Sprite Renderer、Rigidbody 2D(Gravity Scale 0)、Circle Collider 2D(Is Trigger、Radius 0.35)

敵の当たり判定をIs Triggerにしておくと、敵どうしや壁とぶつかって押し合いません。何十体も出るゲームでは、押し合いの計算だけで重くなってしまうので、当たったことが分かればいい判定にしておきます。

もうひとつ、先に直しておくことがあります。このままだと敵と重なったときに、スライムちゃんが敵の後ろに隠れてしまいます。

  • HierarchyでPlayerを選ぶ
  • Sprite RendererのOrder in Layerを1にする

これで、敵(0)より手前に描かれるようになります。#2で床を-10にしたのと同じ考え方で、奥から「床 → 敵 → プレイヤー」の順に並べています。

プレイヤーを追いかけさせよう(Enemy)

  • SurvivorsLike/Scriptsに「Enemy」を作り、中身を次のように書き換える
using UnityEngine;
using UnityEngine.Pool;

// こげたお菓子の敵。スライムちゃんを追いかけ、遠くに離れたらプールに戻る
[RequireComponent(typeof(Rigidbody2D))]
public class Enemy : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 1.5f;      // 1秒に進むマス数
    [SerializeField] private float despawnDistance = 22f; // これより離れたら引っ込む

    private Rigidbody2D body;
    private Transform target;
    private IObjectPool<Enemy> pool;

    private void Awake()
    {
        body = GetComponent<Rigidbody2D>();
    }

    // 出てくるときに、EnemySpawnerから呼んでもらう
    public void Spawn(Vector2 position, Transform newTarget, IObjectPool<Enemy> newPool)
    {
        transform.position = position;
        target = newTarget;
        pool = newPool;
    }

    private void FixedUpdate()
    {
        if (target == null)
        {
            return;
        }

        Vector2 toTarget = (Vector2)target.position - body.position;
        body.linearVelocity = toTarget.normalized * moveSpeed;

        if (toTarget.magnitude > despawnDistance)
        {
            pool.Release(this);
        }
    }
}
Enemyのポイント
  • toTarget.normalized
    • プレイヤーへ向かうベクトルの長さを1にしたもの。「向きだけ」を取り出しています
    • これに速さを掛けると、どんなに離れていても同じ速さで近づいてきます
  • Spawnで外から渡してもらう
    • 敵が自分でプレイヤーを探しに行く(FindFirstObjectByType)ことも出来ますが、何百体も出るので、探す処理は1回でも減らしたい
    • 出すときにスポナーが教えてあげる形にしています
  • despawnDistance
    • スライムちゃんの足は速い(#1で5)ので、遅い敵(1.5)は置いていかれます
    • 遠くに取り残された敵は、追いつけないまま残っても仕方がないので引っ込ませます

画面の外から出そう(EnemySpawner)

  • SurvivorsLike/Scriptsに「EnemySpawner」を作る
using UnityEngine;
using UnityEngine.Pool;

// 画面の外から敵を出す。引っ込んだ敵はプールに戻して使い回す
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private Enemy enemyPrefab;
    [SerializeField] private Transform target;
    [SerializeField] private float spawnInterval = 1f; // 何秒に1体出すか
    [SerializeField] private float spawnDistance = 12f; // プレイヤーから何マス離れた所に出すか

    private ObjectPool<Enemy> pool;
    private float timer;

    private void Awake()
    {
        pool = new ObjectPool<Enemy>(
            createFunc: () => Instantiate(enemyPrefab, transform),
            actionOnGet: enemy => enemy.gameObject.SetActive(true),
            actionOnRelease: enemy => enemy.gameObject.SetActive(false),
            actionOnDestroy: enemy => { if (enemy != null) Destroy(enemy.gameObject); },
            defaultCapacity: 50,
            maxSize: 500);
    }

    private void Update()
    {
        timer += Time.deltaTime;
        if (timer < spawnInterval)
        {
            return;
        }
        timer -= spawnInterval;

        float angle = Random.value * Mathf.PI * 2f;
        Vector2 around = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
        Vector2 position = (Vector2)target.position + around * spawnDistance;

        Enemy enemy = pool.Get();
        enemy.Spawn(position, target, pool);
    }
}
EnemySpawnerのポイント
  • プレイヤーの周りの円に出す
    • Random.valueで0〜1の数を作り、360度のうちのどこかの角度にしています
    • Mathf.CosとMathf.Sinで、その角度の方向を出しています。画面の外を1周ぐるりと囲む位置から出てくる形です
  • 12マス離す理由
    • 画面に映るのは横18マス・縦10マスぶん。12マス離せば、どの向きでも画面の外から出てきます
    • 近すぎると目の前に湧いて理不尽になり、遠すぎると来るまでに時間がかかります

ObjectPoolって何をしているの?

ここがこの回の山場です。

敵を出すたびにInstantiateで作り、消すときにDestroyする――素直な書き方ですが、これを1秒に何体も繰り返すと問題が出ます。

Instantiateと Destroy を繰り返すと、だんだんカクつく

  • Instantiateはオブジェクトを新しく作る処理で、そこそこ重い
  • Destroyした分はゴミ(使い終わったメモリ)になり、たまるとUnityが掃除(GC)を始めます
  • この掃除が走った瞬間に一瞬だけ画面が止まるのが、カクつきの正体です

そこでObjectPoolの出番です。考え方はシンプルで、

  • 敵を消すのではなく、見えなくして取っておく(SetActive(false))
  • 次に敵が必要になったら、取っておいたものを出して使い回す(SetActive(true))

これだけです。作るのは最初の何体かだけで、あとはずっと同じオブジェクトが行ったり来たりします。

素直な書き方ObjectPool
敵を出すInstantiate(毎回作る)pool.Get()(あれば使い回す)
敵を引っ込めるDestroy(毎回捨てる)pool.Release()(しまっておく)
ゴミたまるほとんど出ない

UnityにはUnityEngine.Poolという標準のObjectPoolが入っているので、自分で作る必要はありません。上のコードで使っているのがそれです。

actionOnDestroyでは、先にenemy != nullを確かめる

再生を止めると、Unityはシーンのオブジェクトを片づけてから、プールの後始末(Clear)をします。すでに消えたものに対してactionOnDestroyが呼ばれるので、そのままenemy.gameObjectを触るとMissingReferenceExceptionが出ます。nullチェックを1つ入れておけば出ません。

  • createFunc:足りないときに新しく作る処理
  • actionOnGet:プールから出すときの処理(ここで見えるようにする)
  • actionOnRelease:プールに戻すときの処理(ここで見えなくする)
  • actionOnDestroy:プールがあふれて本当に捨てるときの処理
  • defaultCapacity / maxSize:最初に用意しておく数と、しまっておける最大の数

ObjectPoolそのものの詳しい話は、ObjectPoolを使って、メモリスパイクを回避せよ!でも紹介しています。

スポナーを置こう

  • Hierarchyで右クリック>Create Empty、名前をEnemySpawnerにする
  • Add Component>EnemySpawner
  • Inspectorで設定する
    • Enemy Prefab:あとで作るプレハブを入れます(次の手順)
    • Target:Playerをドラッグ
    • Spawn Interval:1
    • Spawn Distance:12

敵をプレハブにする

  • HierarchyのEnemyに、Add Component>Enemy(さっき書いたスクリプト)
  • EnemyをSurvivorsLike/Prefabsフォルダにドラッグ&ドロップしてプレハブにする
  • Hierarchyに残っているEnemyは削除する(出すのはスポナーの役目なので)
  • EnemySpawnerのEnemy Prefabに、作ったプレハブをドラッグ
EnemySpawnerのInspector。Enemy Prefab・Target・Spawn Interval 1・Spawn Distance 12

まずは動かしてみよう

再生すると、1秒に1体ずつ、画面の外からこげたクッキーが出てきてスライムちゃんを追いかけてきます。

再生中のHierarchy。EnemySpawnerの子のEnemy(Clone)のうち、待機中のものが灰色になっている

Hierarchyを見ると、EnemySpawnerの子にEnemy(Clone)が並んでいます。灰色になっているものがプールの中で、出番を待っている敵です。消えずに残って使い回されているのが目で見えます。

たくさん出して測ってみよう

ObjectPoolを入れたので、もう安心・・・と言いたいところですが、本当に軽いかどうかは測らないと分かりません。数を増やして確かめてみましょう。

  • Gameビューの右上のStatsを押す(FPSなどが表示されます)
  • 再生中に、EnemySpawnerのSpawn Intervalを0.05にする(1秒に20体)
  • 1分ほど放っておいて、敵が数百体になった状態でFPSを見る
敵513体で、Statsパネルに1.2 FPS(812.0ms)と出ている状態

自分の環境では、敵が513体になったところで1.2FPS(1フレームに812ミリ秒)まで落ちました。ObjectPoolを入れても重いものは重いのです。

スライム

えっ、ObjectPoolを入れたのに1.2FPS・・・?作り方を間違えたんでしょうか・・・

フェアリー

間違ってないよ!ObjectPoolが減らすのはInstantiate / Destroy のムダだけ。動いている間の処理は軽くならないから、重さの原因は別にあるってことね

敵どうしの当たり判定を切ろう

原因は当たり判定です。敵はプレイヤーに向かって集まってくるので、後半は全員がひとかたまりに重なっている状態になります。

  • 敵が513体いて、みんな重なっている
  • 物理エンジンは「どの敵とどの敵が当たっているか」を、総当たりで調べる
  • 組み合わせの数は513×512÷2で約13万通り。毎フレームこれをやっていた

敵どうしはぶつける必要がないので、当たり判定そのものを切ってしまいましょう。

レイヤーを作る

  • ProjectウインドでSurvivorsLike/PrefabsのEnemyのプレハブを選び、Inspector右上のLayerのドロップダウンからAdd Layer…
  • 空いている行(User Layer 8など)に「Enemy」と入力する
  • もう一度Enemyのプレハブを選び、LayerのドロップダウンでEnemyを選ぶ
    • レイヤーは作っただけでは付きません。再生中のEnemy(Clone)ではなく、プレハブのほうに付けてください
Project SettingsのTags and Layers。User Layer 8にEnemyが入っている

当たり判定の表からチェックを外す

  • Edit>Project Settings>Physics 2Dを開く
  • Layer Collision Matrixで、EnemyとEnemyが交わるマスのチェックを外す
Project SettingsのPhysics 2D。Layer Collision MatrixのEnemy×Enemyのチェックが外れている
  • この表は「どのレイヤーどうしが当たるか」の一覧
    • チェックが入っている所だけ、当たり判定が計算されます
    • Enemy×Enemyのチェックを外しても、Enemyとほかのレイヤーとの当たりは残ります。プレイヤーはDefaultレイヤーのままなので、Enemy×Defaultの当たりは#9でプレイヤーがダメージを受けるときに使います

もう一度、敵を数百体まで増やして測ってみましょう。

同じ敵513体で、Statsパネルに452.5 FPS(2.2ms)と出ている状態

自分の環境では、同じ513体で 1.2FPS → 452FPSになりました(1フレーム812ミリ秒 → 2.2ミリ秒)。チェックを1つ外しただけです。

数字はUnityエディタでの実測なので、PCによって変わります。大事なのはどれだけ変わったかのほうです。

「重い」と感じたら、まず何が重いのかを測る。ObjectPoolのような対策を先に入れても、原因が別なら効きません。UnityにはStatsのほかにProfilerという道具もあるので、原因を探すときに使ってみてください。

動かして確認!

#3の動作確認

  • 画面の外から敵が出てきて、まっすぐ追いかけてくる
  • 逃げ回ると、遠くの敵は引っ込んで(プールに戻って)いる
  • HierarchyのEnemy(Clone)の数が、出した回数ほどには増えない(灰色のものが使い回されている)
  • Spawn Intervalを0.05にして数百体出しても、FPSが落ちない

確認が終わったら、Spawn Intervalは1に戻しておきましょう。

スライム

うしろから追いかけられるの、思ったよりこわいです・・・

フェアリー

まだ反撃出来ないからね!次回は調理器具で追い払えるようにしよう

あとがき

今回は敵を出して追いかけさせるところまで作りました。ObjectPoolで作り直しのムダをなくし、敵どうしの当たり判定を切って、何百体出しても大丈夫な土台が出来ています。まだ倒せないので、ひたすら逃げるだけのゲームですが、画面に敵がわいてくるだけで一気にそれらしくなりましたね!次回はいよいよ反撃です。一番近い敵を自動で狙って、計量スプーンを投げるところを作りたいと思います。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメント一覧 (1件)

コメントする

目次