ダークモード
第16回 オプション
この回でつくるもの:自機が通った道をあとからついてくる、光る玉「オプション」を作ります。オプションは最大2つまで増え、自機と同じ弾を撃ちます。
この回で学ぶこと
- Queue(キュー)で「通った場所を順番に覚えて、古い順に取り出す」
LateUpdateとUpdateのちがい- List(リスト)で、数が増えたり減ったりするものをまとめる
foreachで、リストの中身を全部順番に使う
前回までのふりかえり
前回は SPEED・MISSILE・DOUBLE・LASER・?(シールド)のパワーアップを作りました。PlayerShooter の弾を出す処理は Fire(position) にまとめ、やられたら ResetAll() で元に戻るようにしました。 ゲージの OPTION だけは、まだ Console に OPTION と出るだけです。
1. オプションとは
1-1. 自機の「分身」
オプションは、グラディウスでいちばん人気のパワーアップです。
- 自機のうしろに、光る玉がついてくる
- 自機が撃つと、オプションも 同じ弾 を撃つ
- 敵に当たってもこわれない(当たり判定がない)
- 自機が 通った道をそのままたどって ついてくる
自機が上に動いてから右へ進むと…
○ ○ ●→ ● 自機
○ ○ オプション(通った道をあとからたどる)
○ ○2つ目のオプションは、1つ目のオプションの通った道をたどります。だから、自機を動かすと ヘビのしっぽ のようにくねくねついてきます。
2. Option プレハブ
2-1. 見た目を作る
- Hierarchy で右クリック 3D Object > Sphere を選び、名前を
Optionにします - Position を (0, 0, 0)、Scale を (0.5, 0.5, 0.5) にします
Assets/_Game/Materialsで右クリック Create > Material、名前をOption_Orangeにします- Base Map をオレンジにし、Emission にチェックを入れて Emission Map の色もオレンジにします
Option_Orangeを Scene ビューのOptionにドラッグします
2-2. Collider を外す
Optionを選び、Inspector の Sphere Collider の右の ⋮(3つの点)を押します- Remove Component を選びます
注意
オプションには Collider を付けません。付いていると、カプセルを取るじゃまをしたり、自機の弾とぶつかったりしてしまいます。オプションは「見た目」と「弾の出る場所」だけの存在です。Collider がないので Rigidbody もいりません。
スクリプトを作ってから、最後にプレハブにします(4-3)。
3. Queue(行列)の考え方
3-1. Queue とは
Queue(キュー)は「行列」という意味です。ラーメン屋さんの行列を思い出してください。
入口 出口
新しく来た人 → [ C ][ B ][ A ] → お店に入る
Enqueue Dequeue
(うしろにならぶ) (先頭が出ていく)- 先に入ったものが、先に出る(いちばん古いものから出てくる)
Enqueue(もの)… 行列のうしろに足す(エンキュー)Dequeue()… 行列の先頭を取り出す(デキュー)Count… 今、何個ならんでいるか
3-2. オプションに使うと
オプションは、自機の位置 を行列にならべていきます。
フレーム1 自機の位置を Enqueue → [P1]
フレーム2 自機の位置を Enqueue → [P1][P2]
フレーム3 自機の位置を Enqueue → [P1][P2][P3]
...
行列が delay(例: 12個)より長くなったら
→ 先頭(いちばん古い位置)を Dequeue して、そこへオプションを動かす行列にはいつも「自機の最近の位置」が12個ほどならんでいます。オプションはその いちばん古い位置 にいるので、ちょうど 少し前に自機がいた場所 をたどることになります。
| 行列の長さ(delay) | オプションの様子 |
|---|---|
| 小さい(例: 4) | 自機のすぐうしろにぴったりつく |
| 大きい(例: 20) | 自機から遠く離れてついてくる |
たとえ話
雪の上を歩く人の足あとを、うしろの人が12歩おくれてふんでいくようなものです。前の人がどんなにくねくね歩いても、うしろの人は同じ道を通ります。
4. OptionFollower スクリプト
4-1. LateUpdate とは
Unity は毎フレーム、全部のスクリプトの Update を呼んだあとで、全部のスクリプトの LateUpdate を呼びます。
1フレームの中の順番
① 全員の Update … 自機が PlayerController で動く
② 全員の LateUpdate … オプションが「動いたあとの自機の位置」を記録する
③ 画面に描くもしオプションも Update を使うと、自機とオプションの どちらが先に動くか決まっていません。
| 自機より先にオプションが動いた場合 | 自機より後にオプションが動いた場合 | |
|---|---|---|
| Update を使う | 1フレーム前の位置を記録してしまう | 今の位置を記録する |
| LateUpdate を使う | — | いつも 今の位置を記録できる |
順番がフレームによってバラバラだと、オプションがガタガタふるえることがあります。LateUpdate を使えば「自機が動き終わったあと」が保証されるので、なめらかについてきます。
4-2. OptionFollower を書く
Assets/_Game/Scriptsで右クリック Create > Scripting > MonoBehaviour Script を選び、名前をOptionFollowerにします- 次のように書きます
csharp
using System.Collections.Generic;
using UnityEngine;
// 自機(または前のオプション)が通った道をあとから追いかける
public class OptionFollower : MonoBehaviour
{
public Transform target; // 追いかける相手
public int delay = 12; // どれくらい遅れてついてくるか
Queue<Vector3> history = new Queue<Vector3>();
Vector3 lastTargetPos;
void Start()
{
lastTargetPos = target.position;
}
void LateUpdate()
{
// 相手が動いたときだけ記録する(止まるとオプションも止まる)
if (target.position != lastTargetPos)
{
history.Enqueue(target.position);
lastTargetPos = target.position;
if (history.Count > delay)
{
transform.position = history.Dequeue();
}
}
}
}- 保存します
コードの解説
| 行 | 意味 |
|---|---|
using System.Collections.Generic; | Queue や List を使うための1行 |
Queue<Vector3> history | 位置(Vector3)をならべる行列。名前は history(りれき) |
lastTargetPos | 前のフレームの相手の位置。動いたかどうかを調べるのに使う |
target.position != lastTargetPos | 相手が動いたときだけ記録する |
history.Count > delay | 行列が delay 個より長くなったら |
transform.position = history.Dequeue(); | いちばん古い位置を取り出して、そこへ移動する |
止まっているときは記録しない理由
自機が止まっているあいだも記録し続けると、行列が「同じ位置」だらけになり、オプションが自機の上に重なってしまいます。動いたときだけ記録すると、止まってもオプションはうしろに少し離れたまま待っていてくれます。
4-3. プレハブにする
- Hierarchy の
Optionを選び、Add Component でOptionFollowerを付けます - Target は空のままで OK です(スクリプトから入れます)
Optionを Hierarchy からAssets/_Game/Prefabsへドラッグしてプレハブにします- Hierarchy の
Optionは消します - シーンを保存します(Ctrl+S、Macは Cmd+S)
5. PlayerShooter でオプションからも撃つ
5-1. List と foreach
オプションは 0個 → 1個 → 2個 と増え、やられると 0個 に戻ります。配列([])は最初に数を決めるので、こういうときは List(リスト)を使います。List は あとから自由に足したり消したりできる配列 です。
| 書き方 | 意味 |
|---|---|
List<Transform> options = new List<Transform>(); | Transform を入れる空のリストを作る |
options.Add(x) | うしろに x を足す |
options.Count | 今、何個入っているか |
options[0] | 0番目(最初)の中身 |
options.Clear() | 全部消して空にする |
foreach(フォーイーチ)は、リストの中身を 1つずつ順番に全部 取り出してくり返す書き方です。
csharp
foreach (Transform option in options)
{
Fire(option.position); // option には、1回目は0番、2回目は1番…が入る
} options = [ オプション1, オプション2 ]
1回目のくり返し: option = オプション1 → そこから Fire
2回目のくり返し: option = オプション2 → そこから Fire
(中身が0個なら、1回もくり返さない)たとえ話
foreach は、先生が「出席番号順に、全員プリントを1枚ずつ取りに来て」と言うようなものです。何人いても、全員に1回ずつ順番が回ってきます。
5-2. PlayerShooter を書きかえる
PlayerShooterを開き、次のように書きかえます
csharp
using System.Collections.Generic; // ★追加
using UnityEngine;
using UnityEngine.InputSystem;
// 弾の種類(第15回)
public enum ShotType
{
Normal,
Double,
Laser
}
// 弾を撃つスクリプト
public class PlayerShooter : MonoBehaviour
{
public GameObject bulletPrefab; // ふつうの弾
public Transform muzzle; // 弾が出る場所
public float fireInterval = 0.15f; // 連射の間隔(秒)
public AudioClip shotSound; // 発射音(第9回)
public GameObject laserPrefab; // レーザー(第15回)
public GameObject missilePrefab; // ミサイル(第15回)
public ShotType shotType = ShotType.Normal;
public bool hasMissile = false;
public List<Transform> options = new List<Transform>(); // オプション(第16回) // ★追加
float timer;
InputAction attackAction;
AudioSource audioSource;
void Start()
{
attackAction = InputSystem.actions.FindAction("Attack");
audioSource = GetComponent<AudioSource>();
}
void Update()
{
timer -= Time.deltaTime;
if (attackAction.IsPressed() && timer <= 0f)
{
Shoot();
timer = fireInterval;
}
}
void Shoot()
{
Fire(muzzle.position);
// オプションからも撃つ // ★追加
foreach (Transform option in options) // ★追加
{ // ★追加
Fire(option.position); // ★追加
} // ★追加
if (shotSound != null)
{
audioSource.PlayOneShot(shotSound);
}
}
// 指定した場所から、今の装備に合わせて弾を出す
void Fire(Vector3 position)
{
if (shotType == ShotType.Laser)
{
Instantiate(laserPrefab, position, Quaternion.identity);
}
else
{
Instantiate(bulletPrefab, position, Quaternion.identity);
if (shotType == ShotType.Double)
{
// 斜め上(45度)にも撃つ
Instantiate(bulletPrefab, position, Quaternion.Euler(0f, 0f, 45f));
}
}
if (hasMissile)
{
// 斜め下(-45度)にミサイル
Instantiate(missilePrefab, position, Quaternion.Euler(0f, 0f, -45f));
}
}
}- 保存します
コードの解説
- 前回
Fire(position)にまとめておいたので、オプションの場所を引数にして呼ぶだけで、ダブルもレーザーもミサイルも オプションから出ます - これが「関数にまとめておくと便利」な理由です
6. PowerUpManager に AddOption を作る
6-1. 2つ目は1つ目を追う
オプションを出すときに、だれを追いかけるか(target)を決めます。
1つ目のオプション:target = 自機
2つ目のオプション:target = 1つ目のオプション(リストの最後)
● 自機 ← ○ オプション1 ← ○ オプション2リストの最後の中身は options[options.Count - 1] で取れます。(1個入っていれば options[0]、2個なら options[1])
6-2. PowerUpManager を書きかえる
PowerUpManagerを開き、次のように書きかえます
csharp
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
// グラディウス風パワーアップゲージ
public class PowerUpManager : MonoBehaviour
{
// ゲージのマス(0:SPEED 1:MISSILE 2:DOUBLE 3:LASER 4:OPTION 5:?)
public Image[] gaugeSlots;
public Color normalColor = new Color(0.2f, 0.2f, 0.5f);
public Color selectedColor = Color.yellow;
// パワーアップの中身(第15回)
public PlayerController controller;
public PlayerShooter shooter;
public float speedUpAmount = 1.5f;
public float maxSpeed = 12f;
public GameObject shield;
// オプション(第16回) // ★追加
public GameObject optionPrefab; // ★追加
public int maxOptions = 2; // ★追加
int cursor = -1; // 光っているマス(-1 はどこも光っていない)
float startSpeed;
InputAction powerUpAction;
void Start()
{
powerUpAction = InputSystem.actions.FindAction("PowerUp");
startSpeed = controller.speed;
UpdateGauge();
}
void Update()
{
if (powerUpAction.WasPressedThisFrame() && cursor >= 0)
{
Activate(cursor);
cursor = -1;
UpdateGauge();
}
}
// カプセルを取ったとき:光るマスを1つ右へ
public void AddCapsule()
{
cursor++;
if (cursor >= gaugeSlots.Length)
{
cursor = 0;
}
UpdateGauge();
}
void Activate(int index)
{
switch (index)
{
case 0: // SPEED UP
controller.speed = Mathf.Min(controller.speed + speedUpAmount, maxSpeed);
break;
case 1: // MISSILE
shooter.hasMissile = true;
break;
case 2: // DOUBLE
shooter.shotType = ShotType.Double;
break;
case 3: // LASER
shooter.shotType = ShotType.Laser;
break;
case 4: // OPTION
AddOption(); // ★変更
break;
case 5: // ?(シールド)
shield.SetActive(true);
break;
}
}
// オプションを増やす(第16回) // ★追加
void AddOption() // ★追加
{ // ★追加
if (shooter.options.Count >= maxOptions) return; // ★追加
// 1つ目は自機を、2つ目は1つ目のオプションを追いかける // ★追加
Transform target = transform; // ★追加
if (shooter.options.Count > 0) // ★追加
{ // ★追加
target = shooter.options[shooter.options.Count - 1]; // ★追加
} // ★追加
GameObject option = Instantiate(optionPrefab, target.position, Quaternion.identity); // ★追加
option.GetComponent<OptionFollower>().target = target; // ★追加
shooter.options.Add(option.transform); // ★追加
} // ★追加
// シールドがあれば使う。使えたら true を返す
public bool UseShield()
{
if (shield.activeSelf)
{
shield.SetActive(false);
return true;
}
return false;
}
// やられたときに全部元に戻す
public void ResetAll()
{
controller.speed = startSpeed;
shooter.shotType = ShotType.Normal;
shooter.hasMissile = false;
shield.SetActive(false);
foreach (Transform option in shooter.options) // ★追加
{ // ★追加
Destroy(option.gameObject); // ★追加
} // ★追加
shooter.options.Clear(); // ★追加
cursor = -1;
UpdateGauge();
}
void UpdateGauge()
{
for (int i = 0; i < gaugeSlots.Length; i++)
{
if (i == cursor)
{
gaugeSlots[i].color = selectedColor;
}
else
{
gaugeSlots[i].color = normalColor;
}
}
}
}- 保存します
コードの解説
| 行 | 意味 |
|---|---|
if (shooter.options.Count >= maxOptions) return; | もう2つあれば、それ以上は増やさない |
Transform target = transform; | まずは「自機(この PowerUpManager が付いている Player)」を追う相手にする |
shooter.options[shooter.options.Count - 1] | すでにオプションがあれば、リストの最後のオプションを追う |
Instantiate(optionPrefab, target.position, ...) | 追う相手と同じ場所にオプションを出す |
option.GetComponent<OptionFollower>().target = target; | 出したオプションに「この相手を追いかけてね」と教える |
shooter.options.Add(option.transform); | PlayerShooter のリストに足す。これで撃つときにオプションからも弾が出る |
ResetAll の foreach | オプションを全部 Destroy してから、Clear() でリストも空にする |
先生へ
Instantiate した直後に target を入れていますが、OptionFollower の Start() は次のフレームの前に呼ばれるので、Start() の時点では target がちゃんと入っています。生徒から「Start で target が空にならないの?」と聞かれたら、この順番を説明してください。
6-3. Inspector でつなぐ
- Hierarchy で
Playerを選びます - PowerUpManager の Option Prefab に、Project ウィンドウの
Optionプレハブをドラッグします - Max Options が 2 になっていることを確認します
- シーンを保存します
6-4. 動作確認
▶ Play ボタンを押して、次のようになればOK。
- カプセルを5個取って OPTION を光らせ、X キーで決定すると、自機の位置にオレンジの玉が出る
- 自機を動かすと、オプションが自機の通った道をたどってついてくる
- 自機を止めると、オプションも止まる
- 撃つと、オプションからも同じ弾が出る(DOUBLE や LASER のときも同じ弾)
- もう一度 OPTION を決定すると2つ目が出て、1つ目のうしろをついてくる
- 3回目の OPTION では増えない
- やられると、オプションが全部消える
テストのコツ
カプセルを何度も集めるのが大変なときは、第14回と同じように EnemySpawner の Enemy Prefabs を一時的に Enemy_Red だけにすると、すぐにカプセルが集まります。テストが終わったら必ず元に戻しましょう。
チャレンジ課題
- OptionFollower の Delay を変えて、オプションの間かくを好みに調整しよう(Option プレハブで変える)
- Max Options を 4 にして、4つのオプションを連れて飛んでみよう
- オプションがゆっくり光ったり大きさが変わったりするようにしてみよう(ヒント: 第13回の
Mathf.SinとTime.timeで Scale を変える) - オプションが自機のまわりをぐるぐる回る「回転オプション」に挑戦しよう(ヒント:
Mathf.SinとMathf.Cos)
つまずきポイント
| こんなときは | ここを確認 |
|---|---|
OPTION を決定すると NullReferenceException | Option Prefab にプレハブが入っているか。Option プレハブに OptionFollower が付いているか |
| オプションが出るが、ついてこない | LateUpdate のつづり(L と U が大文字)。AddOption で target を入れているか |
| オプションが自機にぴったり重なる | Delay が 0 や小さすぎないか。自機が止まっているときに記録していないか |
| オプションに当たってカプセルが取れない/自機の弾が消える | Option プレハブの Sphere Collider を消したか |
| オプションから弾が出ない | PlayerShooter の Shoot() に foreach を書いたか。shooter.options.Add(...) を書いたか |
List や Queue が赤い線でエラー | ファイルの先頭に using System.Collections.Generic; があるか |
まとめ
- Queue は「先に入れたものが先に出る」行列。通った場所を覚えて古い順に取り出すと、あとからついてくる動きが作れる
LateUpdateは全員のUpdateのあとに呼ばれるので、「動いたあとの位置」を使うときに便利- List は数が増えたり減ったりするものに使い、
foreachで全部に同じことができる - これでグラディウス風パワーアップが全部そろいました。次回はいよいよボス戦です