10連休も近いですし、Unity で自分の好きなシューティングゲームでも作成しようと考えました。
これまで Unity で 簡単なアニメーション を試したり、Unity ちゃんを動かしたり して遊んできましたし、他の環境で ゲームを作成した 経験はあるので、まあなんとか作れるのではないか、と。
先週は uGUI の基礎を理解 してみたので、今回は公式チュートリアルにある Space Shooter tutorial を対象に、その構造とコードを眺めてみたいとおもいます。
実は以前にコース全体(動画)はざっと見た記憶があるので、今回はアセットとして 公開されている 完成されたゲームのほうから、より具体的に理解していく、という感じです。あわよくばコードなど流用させていただきたい。
プロジェクト作成
Unity の Asset Store から無料の公式アセット Space Shooter tutorial をダウンロードします。
私は Unity Hub を利用しているので、ダウンロードしたアセットを最初から指定して新規プロジェクトを作成しました。
普通に Unity を起動して新規プロジェクトを作成し、Space Shooter tutorial アセットを後から追加しても結果は同じです。
実行してみる
Assets に _Complete-Game
というフォルダが作成されており、これが Space Shooter ゲームの完成形になります。scens にある Done_Main
シーンをダブルクリックで読み込みます。
Done_Main
シーンを読み込んだ後のヒエラルキーとシーンは以下のような感じ。
実行すると以下のような感じ。
大まかな構成の理解
完成プロジェクトに含まれているマテリアルは以下の3種類、2種類のビームの色と、背景画像ですね。ちょっとわかりにくいですが、敵が cyan 色のビーム、自機が orange 色のビームを撃っています。
そして利用されているモデル、Prefab は7種類。隕石が3種類、ビームが2種類、敵と自機が用意されています。サブフォルダには爆発などエフェクト系があります。
そして今回、利用されている uGUI はテキストだけで3種類。左上に常に表示されているスコアと、ゲームオーバー時に表示される2つのみです。
またゲーム中に表示される隕石や敵キャラは、ゲーム中で動的に作成されていることが、ヒエラルキーを見るとわかります。ゲーム開始前は存在しないのに、ゲームが開始すると表示されるたびにヒエラルキーに追加され、不要になるとヒエラルキーから消えます。
背景のスクロール
まずは背景のスクロールから見ていきましょう。まずヒエラルキーを見ると、背景を描画した板が2枚、用意されているのがわかります。親子関係になっており、親を動かせば一緒に移動します。
スクロールの仕組みは簡単で、ループする一枚絵を2つ繋げて、時間によって移動させているだけです。実際に背景の板である Background
に追加されたスクロール用のスクリプトを見てみましょう。
Tile Size Z
にはスクロールさせる背景(板)のz軸方向の長さを指定しています。ただ今回の例において、実際に指定されているのはy軸方向の長さです。というのは、背景画像の回転のためか、背景(板)がx軸方向に90度回転されていて、それに対応するためです。
さて、実際の Done_BGScroller
スクリプトを見てみましょう。とてもシンプルで、経過時間にあわせて背景(板)の位置を、初期位置 startPosition
からズラしているのがわかります。Mathf.Repeat は剰余演算ですね。
public class Done_BGScroller : MonoBehaviour
{
public float scrollSpeed;
public float tileSizeZ;
private Vector3 startPosition;
void Start ()
{
startPosition = transform.position; // 初期位置を覚えておく
}
void Update ()
{
float newPosition = Mathf.Repeat(Time.time * scrollSpeed, tileSizeZ); // 移動距離を計算
transform.position = startPosition + Vector3.forward * newPosition; // 初期位置から移動ぶんをズラす
}
}
さて、スクロール速度をざっと計算してみましょう。Time.time
は経過時間で単位は秒数です。scrollSpeed
が 0.25 m/秒、tileSizeZ
が 30 m とすると、30 を 0.25 で割って 120秒 が用意された背景画像がループするのにかかる時間となります。
試しにエディタのインスペクターから scrollSpeed 欄を今の -0.25 から -25 に変更したら、今の100倍の高速スクロールになり、ゲームのスピード感がかなり変わりました。ただし背景の使いまわしがすぐわかっちゃいますので、この場合はもう少し長い背景画像を用意する必要がありそうですw
また背景の上に被せている星の瞬き、パーティクルの部分の速度もあわせてあげる必要がありそうですね。
自機の操作
自機がビームを撃つ
さてここからはゲームの中心、自機の移動について見ていきます。さすが公式チュートリアルだけあって、シンプルかつ拡張性の高い作りになっていますので、しっかり学んでおきましょう。
まずはビームを撃つ部分のコードです。
public class Done_PlayerController : MonoBehaviour
{
public GameObject shot;
public Transform shotSpawn;
public float fireRate;
private float nextFire;
void Update ()
{
if (Input.GetButton("Fire1") && Time.time > nextFire) // ボタン1でビーム発射
{
nextFire = Time.time + fireRate; // 次のビームの発射可能な時間を決める
Instantiate(shot, shotSpawn.position, shotSpawn.rotation); // ビーム発射
GetComponent<AudioSource>().Play (); // ビーム発射音を鳴らす
}
}
}
Time.time
は経過秒数なので、fireRate
に指定された秒数で、次のビームまでの発射時間が制御されているのがわかります。fireRate
を短くすれば、ビームの連射速度が上がって、ゲームがより簡単になるはず。
ここで注目すべきなのは、Instantiate関数 の部分です。
shot
にはエディタのインスペクターで Prefab の Done_Bolt
が指定されています。これは自機のビームのモデル。つまりビームが発射されるということは、この Done_Bolt
が Instantiate関数でコピーされ、新たなオブジェクトがゲームシーン中に生成されることになります。
で、この新しいオブジェクトが生成される位置が2番目の引数である shotSpawn.position
で、向きが 3番目の引数である shotSpawn.rotation
です。この shotSpawn
ですがエディタのヒエラルキー上で定義され、自機の少し前に配置されているのがわかります。
この「シーン上にオブジェクトを配置しておき、その位置を実行時に参照する」という手法は、ゲーム制作時に良いデザインパターンであると思います。エディタ上で自機のビームの発射位置がわかりやすいですし、変更するのも簡単です。
この shotSpawn
を自機から見て右に少しローテーションさせてみると、発射されるビームもそれを引き継ぎますので、自機から発射されるビームが全部、右側に飛んでいくことになります。例えば自機の左右の傾きにあわせて、shotSpawn
を左右にローテーションさせるロジックを加えれば、移動によってビームを左右に撃ち分けられることになり、また違ったゲーム性をもったゲームになりそうです。
自機のビームが移動する
さて発射されたビームですが、新たな独立したオブジェクトですので、自機とは独立して存在し動作します。その動作を確認するために Prefab の Done_Bolt
を確認してみましょう。
そして、この自機のビームに追加されたスクリプトは非常に単純で、同じ速度で前進し続けるだけ、となっています。
public class Done_Mover : MonoBehaviour
{
public float speed;
void Start ()
{
GetComponent<Rigidbody>().velocity = transform.forward * speed;
}
}
自機が移動する
さて Done_PlayerController.cs に戻り、自機の移動に関する処理を見ていきましょう。
[System.Serializable]
public class Done_Boundary
{
public float xMin, xMax, zMin, zMax;
}
public class Done_PlayerController : MonoBehaviour
{
public float speed;
public float tilt;
public Done_Boundary boundary;
void FixedUpdate ()
{
float moveHorizontal = Input.GetAxis ("Horizontal"); // 上下の入力
float moveVertical = Input.GetAxis ("Vertical"); // 左右の入力
Vector3 movement = new Vector3 (moveHorizontal, 0.0f, moveVertical); // 移動方向の決定
GetComponent<Rigidbody>().velocity = movement * speed; // 移動量の決定
GetComponent<Rigidbody>().position = new Vector3
(
Mathf.Clamp (GetComponent<Rigidbody>().position.x, boundary.xMin, boundary.xMax),
0.0f,
Mathf.Clamp (GetComponent<Rigidbody>().position.z, boundary.zMin, boundary.zMax)
); // boundary の範囲内から出ないように
GetComponent<Rigidbody>().rotation = Quaternion.Euler (0.0f, 0.0f, GetComponent<Rigidbody>().velocity.x * -tilt); // 左右の移動にあわせて自機を傾ける
}
}
まあ、すごく基本的な移動の処理ですよね。移動範囲を boundary
に保持しておいて、各座標ごとに Mathf.Clamp で値を調整する(閉じ込める)のも良く見るコードですよね。
最後の処理で、自機を移動にあわせて左右に少し傾けているのが、ちょっとこのゲームっぽいところかもしれませんね。
敵や岩石の表示
自機の操作がわかったので、次は敵側です。ゲーム全体を制御する Done_GameController
オブジェクトを見ていきましょう。
コードは Done_GameController.cs
に記載されていますので、敵を生成している主要部分を見てみます。
IEnumerator SpawnWaves()
{
yield return new WaitForSeconds(startWait);
while (true)
{
for (int i = 0; i < hazardCount; i++) // 敵の生成ループ
{
GameObject hazard = hazards[Random.Range(0, hazards.Length)]; // 敵の種類の決定
Vector3 spawnPosition = new Vector3(Random.Range(-spawnValues.x, spawnValues.x), spawnValues.y, spawnValues.z); // 配置場所の決定
Quaternion spawnRotation = Quaternion.identity; // 回転の初期化
Instantiate(hazard, spawnPosition, spawnRotation); // 敵オブジェクトの新規コピー
yield return new WaitForSeconds(spawnWait); // 待ち
}
yield return new WaitForSeconds(waveWait); // 待ち
}
}
ここで生成される敵なのですが、以下のとおり岩石3種類、敵1種類の合計4種類からランダムで生成されています。
例えばサイズを5にして要素4に Done_Enemy Ship
を設定すれば、敵の出現確率がかなり上がります。1/4で25%だったのが、2/5で40%になる計算ですね。
また新しい敵を定義した場合には、この Hazards
リストに追加するだけでゲームに登場させられることがわかります。
ちなみにこのロジックで、パラメータのみでゲームの難易度を上げるには、以下のような設定の変更が考えられます。
- hazardCount を増やして敵の生成数を増やす
- spawnWait を減らして敵の生成待ち時間を減らす (減らしすぎると敵が固まって表示される)
- waveWait を減らして次のウェーブまでの待ち時間を減らす
このゲームでは難易度の変化が無いので、上手な人だと永遠にプレイできてしまいます。時間経過で上記の値をコントロールすることにより、難易度調整機能を実装して、だんだん敵の攻撃が激しくなるように改良しても良さそうですね。
岩石の移動について
では、さきほどのロジックで岩石が作成された場合、その後の岩石の動きについて見ておきましょう。
Done_Mover
スクリプトは自機のビームでも使われていましたが、真っすぐ進むだけの処理です。岩石のほうは速度が遅めに指定されているのがわかります。
そして同様に指定されているのが Done_RandomRotator
スクリプトで、こちらは名前のとおり、ランダムに回転を与えるためのコードですね。
public class Done_RandomRotator : MonoBehaviour
{
public float tumble;
void Start ()
{
GetComponent<Rigidbody>().angularVelocity = Random.insideUnitSphere * tumble;
}
}
なおこの回転ですが、tumble
で回転の度合いを調整することができるようです。今回の3種類の岩石では特に値は変えてありませんでしたが…
そして、もうひとつ設定してある Done_DestroyByContact
スクリプトが、シューティングゲームにおける最も重要な処理、つまりは衝突に関するロジックだったりします。
public class Done_DestroyByContact : MonoBehaviour
{
public GameObject explosion;
public GameObject playerExplosion;
public int scoreValue;
private Done_GameController gameController;
void OnTriggerEnter (Collider other)
{
if (other.tag == "Boundary" || other.tag == "Enemy")
{
return; // バウンダリや敵同士の接触は無視する
}
if (explosion != null)
{
Instantiate(explosion, transform.position, transform.rotation); // 自分の爆発
}
if (other.tag == "Player")
{
Instantiate(playerExplosion, other.transform.position, other.transform.rotation); // プレイヤーの爆発
gameController.GameOver(); // ゲームオーバー処理
}
gameController.AddScore(scoreValue); // この敵に指定されたスコアを加算
Destroy (other.gameObject); // 衝突相手のオブジェクトを削除
Destroy (gameObject); // 自身を削除
}
}
実際にロジックを見ると、シンプルで良いですね。まず今回のサンプルゲームの特徴として「弾や敵など、衝突したものはたいてい両方とも消える」ということがあげられます。そして同じ敵同士の接触を無視するため、Enemy
などタグ付けを実施しておき、それをみて接触時の対応を変えています。
例えばこの岩石が、自機の発射したビームに衝突したとします。OnTriggerEnter
関数の処理が始まりますが、成立する if 文は explosion != null
の条件のみのため、岩石の爆発が表示され、あとは最後の共有処理でスコアが加算されて両オブジェクトが削除されます。ビームのほうの爆発表現はないことがポイントですね。
敵の移動について
同様に、生成された敵の移動についても見ていきましょう。Done_Mover
および Done_DestroyByContact
スクリプトについては岩石と同じですね。
まず Done_WeaponController
スクリプトは敵のビーム発射ですが、基本的には自機のビーム発射と同様です。ただ発射のタイミングですが、「一定時間に一発発射」と非常に単純なロジックでコントロールされていることがわかります。
public class Done_WeaponController : MonoBehaviour
{
public GameObject shot;
public Transform shotSpawn;
public float fireRate;
public float delay;
void Start ()
{
InvokeRepeating ("Fire", delay, fireRate); // 一定時間ごとに発射ロジックを呼ぶ
}
void Fire ()
{
Instantiate(shot, shotSpawn.position, shotSpawn.rotation); // 敵ビームの発射
GetComponent<AudioSource>().Play(); // ビーム発射音の再生
}
}
発射された敵のビームに関しては、岩石とほぼ同じ(回転ロジックがなく、自身の爆発表示がなく、速度が速いだけ)なので説明は省略します。
もうひとつ、敵に特有の動きとして、たまに左右に移動する処理があります。 Done_EvasiveManeuver
スクリプトですね。
public class Done_EvasiveManeuver : MonoBehaviour
{
public Done_Boundary boundary;
public float tilt;
public float dodge;
public float smoothing;
public Vector2 startWait;
public Vector2 maneuverTime;
public Vector2 maneuverWait;
private float currentSpeed;
private float targetManeuver;
void Start ()
{
currentSpeed = GetComponent<Rigidbody>().velocity.z;
StartCoroutine(Evade());
}
IEnumerator Evade ()
{
yield return new WaitForSeconds (Random.Range (startWait.x, startWait.y));
while (true)
{
targetManeuver = Random.Range (1, dodge) * -Mathf.Sign (transform.position.x);
yield return new WaitForSeconds (Random.Range (maneuverTime.x, maneuverTime.y));
targetManeuver = 0;
yield return new WaitForSeconds (Random.Range (maneuverWait.x, maneuverWait.y));
}
}
void FixedUpdate ()
{
float newManeuver = Mathf.MoveTowards (GetComponent<Rigidbody>().velocity.x, targetManeuver, smoothing * Time.deltaTime);
GetComponent<Rigidbody>().velocity = new Vector3 (newManeuver, 0.0f, currentSpeed);
GetComponent<Rigidbody>().position = new Vector3
(
Mathf.Clamp(GetComponent<Rigidbody>().position.x, boundary.xMin, boundary.xMax),
0.0f,
Mathf.Clamp(GetComponent<Rigidbody>().position.z, boundary.zMin, boundary.zMax)
);
GetComponent<Rigidbody>().rotation = Quaternion.Euler (0, 0, GetComponent<Rigidbody>().velocity.x * -tilt);
}
}
こちらも眺めてみると、わりとシンプルなロジックですね。敵ごとに一定時間ごとに、今いる場所とは反対側(画面の左に居たら右に)にランダム距離だけスーッと横移動する、という感じ。この時の移動範囲を dodge
で設定できたり、移動時間や、移動の合間の時間も設定できたり。
色を変えた敵キャラを用意して、このへんの設定を変えておくだけでも、ゲームにかなりの幅が出せそうです。またこの動きのロジックは独立しているので、新しいロジックを追加していくのも容易ですね!
最後は全体の管理
ゲーム全体の処理を知るには 敵や岩石の表示 のところで紹介した Done_GameController
スクリプトの残りを見てみましょう。
public class Done_GameController : MonoBehaviour
{
public Text scoreText;
public Text restartText;
public Text gameOverText;
private bool gameOver;
private bool restart;
private int score;
void Start()
{
gameOver = false;
restart = false;
restartText.text = "";
gameOverText.text = "";
score = 0;
UpdateScore();
StartCoroutine(SpawnWaves()); // 敵の生成を開始
}
void Update()
{
if (restart)
{
if (Input.GetKeyDown(KeyCode.R))
{
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
}
}
public void AddScore(int newScoreValue)
{
score += newScoreValue;
UpdateScore();
}
void UpdateScore()
{
scoreText.text = "Score: " + score;
}
public void GameOver()
{
gameOverText.text = "Game Over!";
gameOver = true;
}
}
関数は多いですが、それぞれの処理は単純です。特に uGUI のテキストに対し、表示コントロールを実施せず、単に中身を空にして非表示にしているあたり、なかなか小気味良い、手の抜きっぷりです。
そして最も驚くのは「ゲームオーバー時に特別な処理をしていない」ということです。単に Game Over!
という文字列を表示しているだけ。何故って?自機がもう画面上に存在しないので、ユーザーは操作もできないし、敵も破壊されないので、ゲームをこれまで通り続けているだけ、なんです。そういえばゲームオーバーになっても、敵はしばらく出てきますし、BGM も止まりませんよね!
※ 実はウェーブの終わりに一応は終了チェックをしていて、敵は無限には出ないようになっています
もしゲームオーバーでゲームを止めたい場合は、GameOver
関数の中に BGM を止めるロジックを追加したり、各キャラの Update/FixedUpdate
関数で gameOver
フラグをチェックさせたり、いろいろ修正が必要になるとおもわれます。
※ Time.timeScale を 0 にする方法は今回のサンプルとは相性が悪い気がします
そして R キーでゲームを再プレイする場合は SceneManager.LoadScene
関数を使って、シーン自体を読み込み直しています。大胆ですが、まあ確実な初期化方法ですね。
ちょっと改良してみよう
このゲーム、長時間プレイを続けると、動的に生成された岩石オブジェクトでヒエラルキーが埋まってしまうんですよね。いわゆる、メモリーリークに近い問題を抱えているわけなんです。
ゲーム全体を眺めると、ゲーム画面を囲んでいる Boundary
に以下のスクリプトが定義されているので、岩石もこれに該当して消えるのかと思っていましたが…?
public class Done_DestroyByBoundary : MonoBehaviour
{
void OnTriggerExit (Collider other)
{
Destroy(other.gameObject);
}
}
よくよく眺めてみると、Prefab の岩石にはコライダー設定がなく、上記のロジックが動作していないのではないか?と。
そこで Prefab の岩石のほうに、適当なコライダーを設定したところ、上記ロジックがうまく動作するようになり、不要なオブジェクトが消されるようになりました。
これでメモリーリークの心配は無くなりましたね!
まとめ
今回は公式チュートリアル&アセットの Space Shooter tutorial を対象に、実際に動作するシューティングゲームの実装方法について見ていきました。
シンプルかつ汎用性のある作りで、これをベースに拡張して自分のゲームを作成するのは容易だとおもいます。私も自分のゲームを作成する際に参考にしてみたいと思いますので、その際の経験なども別途まとめられたらいいな、と考えています。
【追記】まとめました> Unity 公式チュートリアル「Space Shooter tutorial」をベースに自作ゲームを作る【前編】
それではまた!