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

敵の当たり判定を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);
}
}
}
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);
}
}
- プレイヤーの周りの円に出す
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に、作ったプレハブをドラッグ

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

Hierarchyを見ると、EnemySpawnerの子にEnemy(Clone)が並んでいます。灰色になっているものがプールの中で、出番を待っている敵です。消えずに残って使い回されているのが目で見えます。
たくさん出して測ってみよう
ObjectPoolを入れたので、もう安心・・・と言いたいところですが、本当に軽いかどうかは測らないと分かりません。数を増やして確かめてみましょう。
- Gameビューの右上のStatsを押す(FPSなどが表示されます)
- 再生中に、EnemySpawnerのSpawn Intervalを0.05にする(1秒に20体)
- 1分ほど放っておいて、敵が数百体になった状態でFPSを見る

自分の環境では、敵が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)ではなく、プレハブのほうに付けてください


当たり判定の表からチェックを外す
- Edit>Project Settings>Physics 2Dを開く
- Layer Collision Matrixで、EnemyとEnemyが交わるマスのチェックを外す


- この表は「どのレイヤーどうしが当たるか」の一覧
- チェックが入っている所だけ、当たり判定が計算されます
- Enemy×Enemyのチェックを外しても、Enemyとほかのレイヤーとの当たりは残ります。プレイヤーはDefaultレイヤーのままなので、Enemy×Defaultの当たりは#9でプレイヤーがダメージを受けるときに使います
もう一度、敵を数百体まで増やして測ってみましょう。


自分の環境では、同じ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で作り直しのムダをなくし、敵どうしの当たり判定を切って、何百体出しても大丈夫な土台が出来ています。まだ倒せないので、ひたすら逃げるだけのゲームですが、画面に敵がわいてくるだけで一気にそれらしくなりましたね!次回はいよいよ反撃です。一番近い敵を自動で狙って、計量スプーンを投げるところを作りたいと思います。
コメント
コメント一覧 (1件)
[…] 前回:#3 敵を出して追いかけさせよう […]