読者です 読者をやめる 読者になる 読者になる

Tutti Lab

元シリコンバレー在住のおっさん技術者、モバイルVRアプリ開発に挑戦中

CardboardでVRアプリを作ってみる(4)

Android Cardboard iOS ゲーム モバイルVR Particle Event System

はじめに

前回は、スケルトンにアニメーションと動きのルールを設定することで、スケルトンが主人公(一人称視点)に向かって、移動し、攻撃をしてくるようになりました。今回は、主人公の側からスケルトンに対し攻撃し、撃退するための機能を実装していきます。
f:id:tuti107:20160324133810p:plain

状態の追加

まず、SkeletonAnimControllerに「主人公から攻撃を受けている状態」(=Damage)と、「主人公に撃退された状態」(=Dead)を追加し、前回の状態を含めて状態遷移(矢印)するようにします。
f:id:tuti107:20160328062751p:plain
また、状態遷移のためのトリガー「Damage」、「Dead」も追加します。状態遷移のルールは、以下のとおりとしてください。

  • Idle→Damage、Walk→Damage、Attack→Damage:Damageトリガーで、Damageへ遷移させる
  • Attack→Exit、Damage→Exit:Has Exit Timeにチェックを入れて、アニメーション終了時にExitへ遷移させる。なお、Exitに遷移させると、Entry(つまりIdle状態)に戻る
  • Damage→Dead:Deadトリガーで、Deadへ遷移させる

f:id:tuti107:20160328062800p:plainf:id:tuti107:20160328062805p:plain

スクリプトに、Damage・Dead状態の制御を追加

前回作成したスケルトン制御用のスクリプトSkeleton.csに、以下を加えて、Damage・Dead状態の制御を追加します。Update()メソッド内、前回は空処理となっていた"} else if (info.IsName ("Attack")) {"以降に以下を追加してください。

		} else if (info.IsName ("Attack")) {
			float t = info.normalizedTime;
			if (t > 0.35f && !damagedPlayer) {
				damagedPlayer = true;
				player.Damage ();
			}
		} else if (info.IsName ("Damage")) {
			float t = info.normalizedTime;
			if (HP <= 0f && t > 0.5f) {
				ChangeState ("Dead");
			} else {
				transform.position += transform.TransformDirection (Vector3.back) * Time.deltaTime;
			}			
		} else if (info.IsName ("Dead")) {
			float t = info.normalizedTime;
			if (t >= 2f) {
				GameObject.Destroy (gameObject);
			}
		}

また、Skeleton.csの冒頭部で、前回playerオブジェクトを定義していた箇所を以下のように修正してください。

	//public GameObject player;
	public Player player;

さらに、上記のplayerの定義の直後あたりに、以下の定義を追加してください。

	private int HP = 3;
	private bool damagingInvalid = false;
	private bool damagedPlayer = false;

	public ParticleSystem particle;

最後に、Update()メソッドの冒頭部、前回Idle状態を記述していた部分について、以下のコメントで囲まれた部分を追加してください。

	void Update () {

		if (IsStateChanging ())
			return;

		AnimatorStateInfo info = anim.GetCurrentAnimatorStateInfo (0);

		if (info.IsName ("Idle")) {
// --- 3/27 add start ---
			damagingInvalid = false;
			damagedPlayer = false;
// --- 3/27 add end ---
			if (player != null) {

Playerの追加

次に主人公を制御するための新しいスクリプトPlayer.csを以下の通り、作成してください。

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

public class Player : MonoBehaviour {

	public  Image damageImage; 
	private Color damageImageColor;
	private float damagedTime = 0f;

	// Use this for initialization
	void Start () {
		damageImageColor = new Color (1, 0, 0, 0);
		damageImage.color = damageImageColor;
	}
	
	// Update is called once per frame
	void Update () {
	
		if (damagedTime > 0f) {
			float t = Time.time - damagedTime;
			if (t <= 1f) {
				damageImageColor.a = 1f - t;
			}
			else {
				damagedTime = 0f;
				damageImageColor.a = 0f;
			}
			damageImage.color = damageImageColor;
		}
	}

	public void Damage() {
		if (damagedTime == 0f) {
			damagedTime = Time.time;
		}
	}
}

上記を作成後、CardboardMainにPlayerをコンポーネントとして追加します。Hierarchy上のCardboardMainを左クリック→インスペクタのAdd ComponentからPlayerを追加します。f:id:tuti107:20160328064656p:plain

インスペクタで関連付け

次に、以上で作成・修正したスクリプトファイルにてpublicで定義されているフィールド(インスペクタにてドラッグ&ドロップ等でインスタンスを設定可能)に、インスタンスを設定していきます。まず、CardnoardMainに追加したPlayerコンポーネントを、Skeleton@SkinのSkeletonコンポーネントのPlayerへ追加したいのですが、この場合、インスペクタ内のPlayerコンポーネントをSkeketonコンポーネントのPlayerへドラッグ&ドロップを必要が出てきます。そこで、現在一つしか表示されていないインスペクタを二つ表示するようにします。
Scene、Game等のタブが表示されているウィンドウの右上に表示されているメニューアイコン(三本横線)から、Add Tab→Inspectorを選択します。すると、本ウィンドウにInspectorタブが追加されます。
f:id:tuti107:20160328071737p:plain
これでデフォルトで右側に表示されているウィンドウと合わせて2つのインスペクタが表示されている状態となります。しかし、これら両方のインスペクタに、Hierarchyにて選択したオブジェクトの内容が表示されてしまいます。そこで、まずはSkeleton@Skinを選択してインスペクタに表示ののち、いずれかのインスペクタのメニューアイコン左の鍵マークをクリックします。これで
表示内容がロックされます。その後、CardboardMainをHierarchyより選択してください。これで、Skeleton@Skin、CardboardMain二つのオブジェクトのインスペクタが表示されます。
f:id:tuti107:20160328072400p:plain
この状態で、右下のPlayerを左中央のSkeleton(Script)内のPlayerまでドラッグ&ドロップしてください。無事CardboardMainのPlayerコンポーネントが、Skeleton@SkinのSkeletonスクリプトのPlayerに設定されます。

主人公ダメージ表示用のPanelを追加

次にHierarchyにて右クリック→UI→Canvasを選択して、Canvasを追加します。さらにそのCanvas上に右クリック→UI→Panelを選択します(下の図のようにCanvas->Panelの木構造になるように配置します)。
f:id:tuti107:20160328072750p:plain
次に、先ほどと同じ要領で、PanelとCardboardMainをインスペクタで表示し、Panel内のImageをCardboardMain内PlayerのDamaged Imageへドラッグ&ドロップします。
f:id:tuti107:20160328072927p:plain

一体何をやったのか?

一気に実装方法の手順だけを書きましたが、上記の手順で一体何ができるようになったのか、簡単に説明します。
まず新しく追加したAttack状態ですが、AnimatorStateInfo#normalizedTimeが0.35以上になった場合、Player#damage()を呼び出しています。normalizedTimeは、現在のステート開始後の経過時刻です。しかし時間そのものではなく、アニメーションのループ回数と表示中のアニメーションの表示完了割合を、それぞれ整数部・実数部に持つ値となっています。例えば、今Walk状態に遷移後、Walkアニメーションをすでに2回表示しており、現在3回目のアニメーションの55%を表示中とします。この場合、normalizedTimeは2.55となります。ここでは、normalizedTimeが0.35(すなわちDamageアニメーションが35%完了)後、Player#damage()を呼びだして、主人公がダメージを受けた時の処理(一定時間全画面赤色表示)をしています。
Player#damage()メソッドは単に現在時刻をdamagedTime変数に設定します。Update()メソッドはこの変数が設定されている場合、現在時刻からこの値を引き、その値が1以下の場合は、damageImage(Panel)のα値(0:透明〜1:不透明、の透明度)をその値とします。つまり、damagedTime設定直後は1(不透明)で、時間が経過するほど透明になり、1秒経過後は完全透明になります。実際に実行していただければわかりますが、よく一人称ゲームで用いられる赤色がフラッシュする感じが表現されています。f:id:tuti107:20160328074347p:plain
さて、次のDamage状態を説明の前に、スケルトンのクリック処理を追加します。現状ではスケルトンに照準を合わせてクリックしても、何も起きません。

スケルトンをクリック可能にする

まずは、Skeleton.csの末尾に、以下のコードを追加します。

	public void Damage() {
		if (!damagingInvalid) {
			HP -= 1;
			damagingInvalid = true;
			ChangeState ("Damage");
			particle.Play ();
		}
	}

これは、スケルトンがダメージを受けた時の処理です。HPを減らし、状態をDamageに変え、さらにParticle#Play()(※後述)します。スケルトンに照準が当たり、かつクリックされた時に本メソッドが呼び出されれば、無事スケルトンにダメージを与えることができるようになります。
スケルトンにクリック判定を追加するために、まずはSkeleton@SkinにEvent Triggerコンポーネントを追加し、Pointer Clickを追加します。このPointer Clickには、クリックの通知先としてSkeleton@Skinを、通知するメソッドとしてSkeleton.csのDamage()を指定します。これで、スケルトン上でクリックされた際は、そのクリックされたスケルトン(Skeleton@Skin)のDamage()メソッドが呼び出されるようになります。f:id:tuti107:20160328075426p:plain
さらに、Skeleton@SkinにCapsule Colliderコンポーネントを追加します。これは、カプセル形状の「当たり判定」を定義するものです。UnityではColliderを設定を設定することでオブジェクトに当たり判定を追加できます。なお、Cupsule Collider追加直後は、当たり判定の範囲が大きすぎるので、Radiusを0.05、Heightを0.1にします(初期値の1/10)。f:id:tuti107:20160328075826p:plain
スケルトンの周りに表示された緑色の線が、Cupsule Colliderです。スケルトンはこの緑線の範囲内に当たり判定を持つようになります。
スケルトンの当たり判定については、以上ですが、他のものの当たり判定を消しておく必要があります。まず、CanvasのGraphics Raycastのチェックを外します。このチェックを外さないと、Canvasに当たり判定が持って行かれる(?)ようです。よくわかりませんが。。
また、Medium Room 2のMesh Colliderのチェックも外します。これで、建物に照準が当たらなくなります。
これで、ようやくスケルトンを攻撃し、撃退できるようになったのですが、最後にもう一つ、Particleの追加を行います。

Particleの追加

UnityにはParticleというゲーム的視覚効果を生み出す仕組みが搭載されています(Shuriken System っていうんですかね?)。これは、例えば敵を攻撃した時に火花のようなものを散らすようなやつです。ここでは、スケルトンにダメージを与えた感じを効果的に表現すべく、適当なParticleを追加してみます。ParticleはUnity Asset Storeで有料・無料様々公開されておりますが、今回は、下記のKY Magic Effects Freeという無料Assetを使うことにしました。気にいった方は有料版のAsset購入もご検討ください。
Asset Store
このアセットをダウンロード・インポート後、KY_effects/MagicEffectsPackFree/prefab内の、skillAttack2をSkeleton@Skinにドラッグ&ドロップしてください。f:id:tuti107:20160328081119p:plain
ドラッグ&ドロップ後、上記の図のように、Position、Rotate、Scaleを設定してください(これでスケルトンの中心辺りから火花が散ります)。また、Play on Awakeのチェックを外してください(こうしないと、主人公に攻撃されていないのに、登場直後スケルトンから火花が散ります)。その後、Skeleton@SkinのSkeleton(Script)内のParticleに、このParticleをドラッグ&ドロップしてください。
f:id:tuti107:20160328081545p:plain

スケルトンのダメージと撃退処理

Damage状態では、HPが0かつnormalizedTimeが0.5(Damageアニメーションが半分完了)の場合は、Dead状態へ遷移します。これにより、最後の一撃を加えた際は、Dead状態へ遷移するようにしています。そうではない場合は、主人公と逆方向へ少しずつ移動させて、後ろへ仰け反る感じを表現しています。
Dead状態では、normalizedTimeが2(Deadアニメーション2回分)経過後、GameObject.Destroy()を呼び出して、スケルトンを消しています。これにより、スケルトンが撃退された倒れこんだあと、しばらく後にスケルトンが消えます。
HierarchyにてSkeleton@Skinをコピー&ペーストし、適当なところに配置してみると、かなりゲームっぽい感じになります。
f:id:tuti107:20160328082503p:plain
Skeleton.csの完全版ソースは以下の通りです。

using UnityEngine;
using System.Collections;

public class Skeleton : MonoBehaviour {

// --- 3/27 modify start ---
	//public GameObject player;
	public Player player;
// --- 3/27 modify end ---

	private Vector3 nextDirection = Vector3.zero;
	private float nextDistance;
	private Vector3 startPoint;
	private float speed = 0.5f;

// --- 3/27 add start ---
	private int HP = 3;
	private bool damagingInvalid = false;
	private bool damagedPlayer = false;

	public ParticleSystem particle;
// --- 3/27 add end ---

	private Animator anim;

	// Use this for initialization
	void Start () {

		anim = GetComponent<Animator>();
	}

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

		if (IsStateChanging ())
			return;

		AnimatorStateInfo info = anim.GetCurrentAnimatorStateInfo (0);

		if (info.IsName ("Idle")) {
// --- 3/27 add start ---
			damagingInvalid = false;
			damagedPlayer = false;
// --- 3/27 add end ---
			if (player != null) {
				if (nextDirection == Vector3.zero) {
					float dist = Vector3.Distance (player.transform.position, transform.position);
					float randAngle = dist < 3.0f ? 0f : (Random.value - 0.5f) * dist * 8;
					nextDirection = Quaternion.LookRotation (player.transform.position - transform.position).eulerAngles;
					nextDirection.y += randAngle;
					if (nextDirection.y < 0)
						nextDirection.y += 360;
					nextDirection.x = 0;
					nextDirection.z = 0;

					nextDistance = dist < 3.0f ? dist : dist / 4 + Random.value * (dist / 4);
					startPoint = transform.position;

				} else {
					transform.rotation = Quaternion.Slerp (transform.rotation, Quaternion.Euler (nextDirection), Time.deltaTime);
					if (Mathf.Abs (Mathf.DeltaAngle (transform.rotation.eulerAngles.y, nextDirection.y)) <= 1.0f) {
						ChangeState ("Walk");
					}
				}
			}
		} else if (info.IsName ("Walk")) {
			
			float playerDist = Vector3.Distance (player.transform.position, transform.position);
			if (playerDist <= 2) {
				ChangeState ("Attack");
			} else {
				transform.position += transform.TransformDirection (Vector3.forward) * Time.deltaTime * speed;
				float dist = Vector3.Distance (startPoint, transform.position);
				if (dist >= nextDistance) {
					nextDirection = Vector3.zero;
					ChangeState ("Idle");
				}
			}
// 3/27 add start
		} else if (info.IsName ("Attack")) {
			float t = info.normalizedTime;
			if (t > 0.35f && !damagedPlayer) {
				damagedPlayer = true;
				player.Damage ();
			}
		} else if (info.IsName ("Damage")) {
			float t = info.normalizedTime;
			if (HP <= 0f && t > 0.5f) {
				ChangeState ("Dead");
			} else {
				transform.position += transform.TransformDirection (Vector3.back) * Time.deltaTime;
			}			
		} else if (info.IsName ("Dead")) {
			float t = info.normalizedTime;
			if (t >= 2f) {
				GameObject.Destroy (gameObject);
			}
		}
// 3/27 add end
	}

	private string changingStateName = null;
	private void ChangeState(string name) {
		changingStateName = name;
		anim.SetTrigger (name);
	}

	private bool IsStateChanging() {
		AnimatorStateInfo next = anim.GetNextAnimatorStateInfo (0);
		bool ret = changingStateName != null && next.IsName (changingStateName);
		if (changingStateName != null && !ret) {
			changingStateName = null;
		}
		return ret;
	}
		
// --- 3/27 add start ---
	public void Damage() {
		if (!damagingInvalid) {
			HP -= 1;
			damagingInvalid = true;
			ChangeState ("Damage");
			particle.Play ();
		}
	}
// --- 3/27 add end ---
}

まとめ

今回は、前回に引き続きスケルトンに動きとアニメーションを付与しました。特に今回はクリックによりスケルトンに攻撃できる機能を追加しました。
これでだいぶゲームらしくなってきました。次回は、敵の出現や細かい部分(ライフ表示、ゲームオーバなど)を追加する予定です。