tanaka's Programming Memo

プログラミングについてのメモ。

(8)スコアと残機の実装(2015.1改訂版)

←(7)ミスの処理(2015.1改訂版)

方針の確認

ゲームらしくするために、スコアと残機を実装しよう。

これまで作ってきたスクリプトは個別のシーンやオブジェクトを制御するものだった。Unityでシーンを切り替えると通常のゲームオブジェクトは消去されて、それらのデータはなくなってしまう。スコアなどはシーンが切り替わっても値を保持しておかなければならないので、そのようなデータを扱うためのゲームオブジェクトを作成することにする。

いくつか方法は考えられるが、今回はObject.DontDestroyOnLoadメソッド(
Unity - Scripting API: Object.DontDestroyOnLoad)を使って、
シーン切り替えが発生しても消えないゲームオブジェクトを作成して、そこにゲーム管理用のスコアや残機のデータを持たせることにする。

ゲーム管理用のゲームオブジェクトの作成

ゲームオブジェクトとスクリプトを作成

  • [Project]ビュー>[Scenes]>[Title]をダブルクリックしてタイトルシーンを開く
  • [GameObject]>[Create Empty]を選んで空のゲームオブジェクトを作成
  • 追加したゲームオブジェクトの名前を「GameMan」に変更する
  • [GameMan]オブジェクトを選択して、[Inspector]ビューの[Tag]から、[GameController]を選択する
  • [Project]ビューの[Scripts]フォルダを右クリックして、C#スクリプトを新規作成する
  • 作成したスクリプトのファイル名を「CGameMan」に変更する
  • [CGameMan]スクリプトをドラッグして、[Hierarchy]ビューの[GameMan]オブジェクトにドロップする
  • [CGameMan]をダブルクリックしてエディタで開く

ゲームオブジェクトの永続化

  • シーンが変更されてもゲームオブジェクトが消えないように以下のコードを追加する
	// このオブジェクトを読み込み時に破壊させない
	void Awake() {
		DontDestroyOnLoad (this);
	}
  • Awake()メソッドは、クラスが生成された時に1度呼び出される
  • 全てのゲームオブジェクトがシーンに生成された後に呼び出される
  • Awake()の呼び出し順は不定なので、他のゲームオブジェクトのAwake()の実行に依存する処理は書いてはいけない
  • Awake()は全てのStart()より先に実行される。Start()に先立って初期化したい処理を書く

Unity - スクリプティング API: MonoBehaviour.Awake()

  • 上書き保存
  • Unityに戻って実行し、以下の動作を確認しよう
    • [Hierarchy]ビューに[GameMan]オブジェクトがあることを確認する
    • スペースキーでゲームを開始
    • [Hierarchy]ビューに[GameMan]オブジェクトがあり、消えていないことを確認する
    • ボールを落としてゲームオーバーにする
    • [Hierarchy]ビューに[GameMan]オブジェクトがあり、消えていないことを確認する
    • スペースキーでタイトル画面に戻る
    • [Hierarchy]ビューの[GameMan]オブジェクトが2つになることを確認する
  • 最初の1回目は思い通りに動いたが、タイトル画面に戻ったら[GameMan]が2つになってしまった。この症状を解決する。

GameManが増えないようにする

シーンをタイトルに切り替える時、本来は古い「GameMan」が削除されて、新しい「GameMan」が生成される。それにより「GameMan」は増えないのだが、今回は「GameMan」を消えないようにしたため、シーンが切り替わるたびに新しい「GameMan」が加わって増えてしまうのである。

解決策として、Awake時に[GameController]タグを持ったゲームオブジェクトがあるかを調べて、すでにある場合は新しい「GameMan」は破棄するようにする。

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CGameMan」をダブルクリックして【MonoDevelop】で開く
  • Awake()関数を探して、以下の通り修正する
	// このオブジェクトを読み込み時に破壊させない
	void Awake() {
		// 1つより多かったらすでに追加済みなので、このGameManは削除する
		if (GameObject.FindGameObjectsWithTag ("GameController").Length > 1) {
			Destroy (gameObject);
			return;
		}
		// 永続化
		DontDestroyOnLoad (this);
	}
  • 上書き保存

再度テストしてみよう。今度は2回目のタイトル画面でも[GameMan]が増えないはずである。

以上で、予定通りのゲーム全体を制御するためのゲームオブジェクトを永続させることができるようになった。続いて、ゲーム管理用の変数を用意する。

変数の定義方針

以下のような変数を作成する。

  • ゲームのスコアを記録しておくためのint型の変数「iScore」「CGameMan」クラスに作成
  • 残機数を記録しておくためのint型の変数「iLeft」「CGameMan」クラスに作成
  • ゲームスタート時の残機を表すためのint型の変数「INIT_LEFT」「CGameMan」クラスに作成
  • ブロックを壊した時の点数を設定しておくint型の変数「ADD_SCORE」「CBlock」クラスに作成
  • 変数は全て「private」にする
  • 「INIT_LEFT」と「ADD_SCORE」はインスペクターから変更できるようにしたい。privateな変数をインスペクタに表示するには、変数定義時に以下の属性を設定する
[SerializeField]

以上を実装していく。

「CGameMan」に変数を追加

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CGameMan」をダブルクリックして【MonoDevelop】で開く
  • 「public class CGameMan : MonoBehaviour {」という行を探して、下の行に以下の変数定義を追加する。
	// 点数
	private int iScore;
	// 現在の残機。マイナスになるとゲームオーバー。
	private int iLeft;
	// 面が開始する時点での残機(初期設定では3回ミスでゲームオーバー)
	[SerializeField]
	private int INIT_LEFT = 2;
  • 上書き保存

Unityを表示して、[GameMan]オブジェクトを選択して[Inspector]を確認しよう。private属性にしている[INIT_LEFT]フィールドが表示されていることが確認できる

「CBlock」に変数を追加

  • MonoDevelop】で「CBlock.cs」を開く。
  • 「public class CBlock : MonoBehaviour {」という行を探して、下の行に以下の変数定義を追加する。
	// 壊したら入る点数(初期値は10点)
	[SerializeField]
	private int ADD_SCORE = 10;
  • 上書き保存

Unityに戻り、[Block]プレハブを選択する。[Inspector]ビューに[ADD_SCORE]フィールドが表示されていることが確認できる。

ゲーム開始時の初期化関数を作成する

ゲームが開始する時に以下の処理を行いたい。

  • スコアを0にする
  • 残機を規定の数(INIT_LEFT)にする

ゲームを開始する時に呼び出す関数「initGame()」を作成して、その中で処理することにしよう。この関数は[SendMessage()]関数を使って呼び出したいので公開(public)する。

初期化関数「initGame」を作る
  • MonoDevelop】で「CGameMan.cs」を開く。
  • スクリプトファイルの一番最後の「}」の上の行に以下のプログラムを追加する。
	// ゲーム開始の初期化
	public void initGame() {
		iScore = 0;
		iLeft = INIT_LEFT;
	}

作成した関数を、プレイヤーが開始する際に呼び出す。

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CPlayer」をダブルクリックする
  • [void Start()]関数を探して、以下のコードを追加する
		GameObject.Find ("GameMan").SendMessage ("initGame");
  • 上書き保存

パラメータを表示するインタフェースの作成

データが出来たのでスコアと残機を画面に表示しよう。文字描画は、TITLEやGAME OVERを表示するためにOnGUIで描画する方法を示したが、これは古いやり方である。Unity4.6から、パラメータなどのユーザインターフェースを描画するためのUI機能が実装された。ここではそれを利用する。

完成イメージを以下に示す。

必要なUIを追加する

  • UIはゲーム画面に表示したいので、【プロジェクト(Project)】ビューで【Scene】フォルダを開き、【Game】シーンをダブルクリックして、シーンを切り替える
  • 【GameObject】→【UI】→【Canvas】で、Canvasを追加する
    • Canvasは様々なUIをとりまとめるためのゲームオブジェクト
    • 登録しなくても、何かUIを登録すると自動的に登録される
  • 【GameObject】→【UI】→【Text】で、文字を表示するためのTextを追加する
  • 名前を「LabelScore」に変更する

Textを調整する

作成した[LabelScore]の位置やサイズ、色を調整する。Gameビューの大きさを変更して、LabelScoreのサイズや表示位置の変化を確認しよう。文字サイズや上下の位置は変化せず、左右の位置が変化するのが確認できる。これでは、遊ぶ環境によってテキストの表示位置が変化してしまうので調整する。詳細はこちら→
Unity - マニュアル: 複数の解像度のための UI 設計

画面のサイズ変更によってUIが調整されるようにする
  • 【階層(Hierarchy)】ビューから【Canvas】を選択
  • 【インスペクター(Inspector)】ビューで、【Canvas Scaler(Script)】コンポーネントの【Ui Scale Mode】を[Scale With Screen Size]に変更する
  • 【Reference Resolution】の【X】を1024、【Y】を768にする

以上を設定したら、【Game】ビューのサイズを変更してみよう。テキストが画面の同じ位置に、同じ比率で表示されるのが確認できるだろう。あとはこれで表示位置を調整する。

スコアラベルの文字表示を調整
  • 【階層(Hierarchy)】ビューから【LabelScore】を選択
  • 【Text】の欄に「SCORE」と入力
  • 【Color】を選択して、文字を赤くする
  • 【Font Size】を40にする
    • 上記を設定すると文字が消えてしまう。これはLabelScoreのWidthとHeightに文字が入らずに改行などが行われたため。これを直すために以下を設定する
  • 【Horizontal Overflow】と【Vertical Overflow】の設定をどちらも【Overflow】にする
  • 見本を見ながら【Pos X】と【Pos Y】を設定する(350,330ぐらい)
  • 文字に影を付けるために、【Add Component】>【UI】>【Effects】>【Shadow】を選択
  • 【Effect Color】を暗い赤とか白など、適当な色をつけて影とする
残機ラベルを作成
  • 完成した【LabelScore】を選んで、右クリック>【Duplicate】を選ぶ
  • 【LabelScore】が2つになるので、どちらか一方の名前を「LabelLeft」に変更
  • 【LabelLeft】を選択して、【Text】欄を「LEFT」に書き換える
  • 見本を見て、【Pos Y】で「LEFT」の表示位置を調整する(-270ぐらい)
スコアの値を表示するためのTextを作成
  • 【LabelScore】を右クリックして【Duplicate】する
  • 【LabelScore】が2つになるので、どちらか一方の名前を「TextScore」に変更
  • 【TextScore】を選択
  • 【Text】欄を0に書き換える
  • 【Color】を白、【Effect Color】を灰色にする
  • 数値は右揃えにしたいので、【Alignment】の【Right Alignment】を選択する
  • 【Pos X】と【Pos Y】を調整して、見本のようにレイアウトする(280,400ぐらい)
残機の値を表示するためのTextを作成
  • 【TextScore】を右クリックして【Duplicate】する
  • 【TextScore】が2つになるので、どちらか一方の名前を「TextLeft」に変更
  • 【TextScore】を選択
  • 【Pos Y】を調整して、見本のようにレイアウトする(-300ぐらい)

以上でパラメータの配置は完了した。あとは、TextScoreとTextLeftを実際のスコアと残機数に書き換えればよい。

パラメータを画面に表示する

スコアと残機数を画面に反映させる。

表示先のTextのインスタンスを取得する

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CGameMan」をダブルクリックして開く
  • Textを利用するために、最初の行に以下を追加する
using UnityEngine.UI;

【TextScore】と【TextLeft】のインスタンスを渡すためのpublic変数を以下のように追加する

  • 「public class CGameMan : MonoBehaviour {」という行を探して、下の行に以下のプログラムを追加する。
	// TextScoreのインスタンス
	private Text textScore;
	// TextLeftのインスタンス
	private Text textLeft;

ゲーム開始時に、textScoreとtextLeftのインスタンスを取得する

  • initGame()関数を探して、以下のように「ゲームオブジェクトを探す」の部分を追加する
	// ゲーム開始の初期化
	public void initGame() {
		iScore = 0;
		iLeft = INIT_LEFT;
		// ゲームオブジェクトを探す
		textScore = GameObject.Find("TextScore").GetComponent<Text>();
		textLeft = GameObject.Find ("TextLeft").GetComponent<Text>();
		// 初期値を表示
		textScore.text = iScore.ToString ();
		textLeft.text = iLeft.ToString ();
}

点数の加算処理

点数を実際に入れるプログラムを追加して、値を反映させてみよう。点数の加算は簡単で、ブロックが壊れる時にスコアの加算処理を追加すればよい。

「CGameMan.cs」に引き続きコードを書いていく

  • 最後の「}」の上の行に以下の点数を加算して、更新したスコアをtextScoreに表示するプログラムを追加
	public void addScore(int add) {
		iScore += add;
		textScore.text = iScore.ToString();
	}

外部から簡単にコードを呼び出せるように、自分自身のインスタンスを持たせるようにする。

  • 「public class CGameMan : MonoBehaviour {」の行の下に、以下の変数宣言を追加する
	// 自分自身のインスタンス
	public static CGameMan me = null;
  • Awake時に自分のインスタンスを記録しておく。Awake関数の最後に以下を追加する
		// インスタンスを記録
		me = this;
  • 上書き保存をする

次に、ブロックが壊れた時に点数加算を呼び出すプログラムを作成する。

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CBlock」をダブルクリックして開く。
  • 「 void OnCollisionEnter(…から始まる行を探して、下の行に以下のプログラムを追加する。
		CGameMan.me.addScore (ADD_SCORE);
  • 上書き保存する

以上で完了である。Unityに戻って、タイトル画面を開いてから、実行して遊んでみよう。点数が入れば成功である。

ミスの実装

残機の管理を行う。ミスした時に残機が残っている時は、ゲームオーバーにならずにリスタートするようにする。また、残機の表示も行う。

ミスの処理を実装

「CBall」クラスのミス判定に残機チェックを追加する。「CGameMan」クラスに残機を減らして、ゲームオーバーかどうかを返す関数を追加して、それを呼び出すようにする。

  • 【プロジェクト(Project)】ビューの「Scripts」フォルダ内の「CGameMan」をダブルクリックする。
  • 最後の「}」の上の行に以下のプログラムを追加する。
	/** 残機を減らす処理
	 * @return bool true=ゲームオーバー / false=ゲーム継続
	 */
	public bool decLeft() {
		// 残機がなければゲームオーバー
		if (iLeft <= 0) {
			return true;
		}
		// 残機があれば減らす
		iLeft--;
		// 結果を表示
		textLeft.text = iLeft.ToString ();
		return false;
	}
    • 「iLeft」が0以下になっていた時はゲームオーバーなので「true」を返す
    • 「iLeft」が1以上で残機があれば、残機を減らして、表示を更新して、ゲーム続行のために「false」を返す
  • 上書き保存
  • MonoDevelop】で「CBall.cs」を開く。
  • 「Application.LoadLevel("GameOver");」という行を見つけたら、その行を消す
  • 消した場所に、以下のプログラムを入力する
			if (CGame.decLeft()) {
				Application.LoadLevel("GameOver");
			}
			else {
				Destroy(gameObject);
				GameObject.Find("Player").SendMessage("createHoldBall");
			}
  • 上書き保存

以上が出来たら、Unityに戻って、タイトルシーンから実行してみよう。残機が残っている状態でボールを落としたらリスタート。残機が無い状態でボールを落としたらゲームオーバーになれば成功である。

以上で、ひとまず完成である。あとは、タイトル画面やゲームオーバー画面をUIのImageで表示させたり、ボールが跳ね返るごとに速度を速くしたり、面を増やしたり、BGMやSEを入れたり、アイテムを追加するなど、改造をしてみよう。

ここまでの「CBall.cs」

using UnityEngine;
using System.Collections;

public class CBall : MonoBehaviour {
	public float INIT_DEGREE = 75f;
	public float INIT_SPEED = 25f;

	// Use this for initialization
	void Start () {
		// shotBall();
	}
	
	// Update is called once per frame
	void Update () {
	
	}

	void shotBall() {
		Vector3 vel = Vector3.zero;
		vel.x = INIT_SPEED * Mathf.Cos (INIT_DEGREE * Mathf.PI / 180f);
		vel.y = INIT_SPEED * Mathf.Sin (INIT_DEGREE * Mathf.PI / 180f);
		rigidbody.velocity = vel;
	}

	/** ミス判定*/
	void OnTriggerEnter(Collider other) {
		if (other.CompareTag("Miss")) {
			if (CGameMan.me.decLeft()) {
				Application.LoadLevel("GameOver");
			}
			else {
				Destroy(gameObject);
				GameObject.Find("Player").SendMessage("createHoldBall");
			}
		}
	}
}

ここまでの「CBlock.cs」

	// 壊したら入る点数(初期値は10点)
	[SerializeField]
	private int ADD_SCORE = 10;

	static int iBlockCnt=0;

	// Use this for initialization
	void Start () {
		iBlockCnt++;
	}
	
	// Update is called once per frame
	void Update () {
	
	}

	/** 衝突イベント*/
	void OnCollisionEnter(Collision col) {
		CGameMan.me.addScore (ADD_SCORE);
		// 自分を削除する
		Destroy (gameObject);
		// ブロックを減らして、全滅したかを確認
		iBlockCnt--;
		if (iBlockCnt <= 0) {
			Application.LoadLevel("Game");
		}
	}
}

ここまでの「CGameMan.cs」

using UnityEngine;
using System.Collections;
using UnityEngine.UI;

public class CGameMan : MonoBehaviour {
	// 自分自身のインスタンス
	public static CGameMan me = null;

	// TextScoreのインスタンス
	private Text textScore;
	// TextLeftのインスタンス
	private Text textLeft;

	// 点数
	private int iScore;
	// 現在の残機。マイナスになるとゲームオーバー。
	private int iLeft;
	// 面が開始する時点での残機(初期設定では3回ミスでゲームオーバー)
	[SerializeField]
	private int INIT_LEFT = 2;

	// このオブジェクトを読み込み時に破壊させない
	void Awake() {
		// すでに追加済み
		if (GameObject.FindGameObjectsWithTag ("GameController").Length > 1) {
			Destroy (gameObject);
			return;
		}
		// 永続化
		DontDestroyOnLoad (this);
		// インスタンスを記録
		me = this;
	}

	// Use this for initialization
	void Start () {

	}
	
	// Update is called once per frame
	void Update () {
	
	}

	// ゲーム開始の初期化
	public void initGame() {
		iScore = 0;
		iLeft = INIT_LEFT;
		// ゲームオブジェクトを探す
		textScore = GameObject.Find("TextScore").GetComponent<Text>();
		textLeft = GameObject.Find ("TextLeft").GetComponent<Text>();
		// 初期値を表示
		textScore.text = iScore.ToString ();
		textLeft.text = iLeft.ToString ();
	}

	public void addScore(int add) {
		iScore += add;
		textScore.text = iScore.ToString();
	}

	/** 残機を減らす処理
	 * @return bool true=ゲームオーバー / false=ゲーム継続
	 */
	public bool decLeft() {
		// 残機がなければゲームオーバー
		if (iLeft <= 0) {
			return true;
		}
		// 残機があれば減らす
		iLeft--;
		// 結果を表示
		textLeft.text = iLeft.ToString ();
		return false;
	}
}

ここまでの「CGameOver.cs」

using UnityEngine;
using System.Collections;

public class CGameOver : MonoBehaviour {

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
		if (Input.GetButtonDown ("Jump")) {
			Application.LoadLevel("Title");
		}
	}

	// GUI描画
	void OnGUI() {
		GUI.Label(new Rect(0,0,100,20),"ゲームオーバー");
	}
	
}

ここまでの「CPlayer.cs」

using UnityEngine;
using System.Collections;

public class CPlayer : MonoBehaviour {
	public float VEL = 40f;
	public GameObject prefBall = null;
	GameObject insBall = null;

	// Use this for initialization
	void Start () {
		createHoldBall ();
		GameObject.Find ("GameMan").SendMessage ("initGame");
	}
	
	// Update is called once per frame
	void Update () {
		Vector3 vel = Vector3.zero;
		vel.x = VEL*Input.GetAxisRaw ("Horizontal");
		rigidbody.velocity = vel;

		// ボールの発射
		if (insBall != null) {
			if (Input.GetButtonDown("Jump")) {
				// ボールの物理シミュレーションを有効にする
				insBall.rigidbody.isKinematic = false;

				// ボールを発射する
				insBall.SendMessage ("shotBall");
				insBall = null;
			}
		}
	}

	// ボールを生成して、プレイヤーバーにくっつける
	void createHoldBall() {
		Vector3 bpos = transform.position;
		bpos.y += (collider.bounds.size.y + prefBall.transform.localScale.y) / 2f;
		insBall = (GameObject)Instantiate (prefBall, bpos, Quaternion.identity);
		insBall.transform.parent = transform;

		// ボールの物理シミュレーションを無効にする
		insBall.rigidbody.isKinematic = true;
	}
}

ここまでの「CTitle.cs」

using UnityEngine;
using System.Collections;

public class CTitle : MonoBehaviour {

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
		if (Input.GetButtonDown ("Jump")) {
			Application.LoadLevel("Game");
		}
	}

	// GUI描画
	void OnGUI() {
		GUI.Label(new Rect(0,0,100,20),"タイトル");
	}

}

←(7)ミスの処理(2015.1改訂版)