ミニゲーム①:Egg Race!

スマホの傾きセンサーとタッチで3Dパズルゲームを作ってみましょう。

プロジェクト仕組みの理解

ゲームの目的

スマホを傾いて、卵を転げて、巣まで届かせましょう!ただ、強くぶつかると、卵が割れてしまうので、優しくね!生まれたひよこちゃんが邪魔するので、ドアをうまく使って、移動制限させましょう!

ゲームシーン

Scenesフォルダーの中にある「EggRace」を開いてください

image.png

ヒエラルキーを見ると:

image.png

image.png

プロジェクト整理

すでにいくつかのフォルダが準備された:

初期設定

Androidスマホで実行できるため、プロジェクト設定でプラットホームを変えないといけない。「File ⇒ Build Profiles」を開き、「Windows」から「Android」へ変換し、「Switch Platform」を押しててください。

image.png

 

 

卵の操作

これからスクリプトを作成し、卵の動き処理を実装しましょう。

シーン設定

迷路の左上に Prefabs の中にある Egg を入れてください:

image.png

キーボードによる卵の動き

スマホで遊ぶ前に、まずUnityで動作を確認したいので、キーボードを使い、重力の向きを変えて、卵が転がるようにしましょう。

image.png

重力を変えるには2つの方法がある:

プロジェクト設定で重力を変える

1回だけ重力を変え、後がずっと変わらないなら、プロジェクト設定で重力ベクトルを変えることができる。「Edit ⇒ Project Settings」を開き「Physics⇒Settings」(物理設定)を選ぶと

image.png

「Gravity」(重力)という設定がある。デフォルト値としては、地球の重力になっている(真下のベクトルで、加速度は9.81)。今回は実行中に重力を変えるので、次の方法で変えましょう:

スクリプトで重力を変える

スクリプトでは、「Physics.gravity」の値を代入すれば、重力をいつでも帰ることができる。例えば:

// 地球の重力
Physics.gravity = new Vector3(0, -9.81f, 0);

// 月のの重力
Physics.gravity = new Vector3(0, -1.625f, 0);

// 木星の重力
Physics.gravity = new Vector3(0, -24.5f, 0);

スクリプト

キーボードの WASD で重力ベクトルの向きを変えましょう W と S で Z軸の前後にし、A と D でX軸の左右にしましょう。

// 実行中に重力を変える
public class GravityController : MonoBehaviour
{
	// 重力の加速度(地球)
	const float Gravity = 9.81f;

	// 重力に与える倍率(強さの調整)
	[SerializeField] 
	private float gravityScale = 1.0f;

	private void Update()
	{
		// 重力のベクトル(Yはいつも下:-1)
		Vector3 vector = new Vector3(0, -1, 0);
		
		// WASDの入力( -1 ~ 1 )
		vector.x = Input.GetAxis("Horizontal"); // 左右(x軸)
		vector.z = Input.GetAxis("Vertical"); // 前後 (z軸)
	
		// 単位ベクトルにする
		vector.Normalize();
		
		// 最後に、重力の向きを変える
		Physics.gravity = vector * (Gravity * gravityScale);
	}
}

スクリプトを空のゲームオブジェクトに追加し、操作を確認してください:

image.png

加速度センサーによる卵の動き

加速度センサー(Accelerometer)はスマホの加速と減速を測定し、3Dベクトル(向きと強さ)として返す装置です。重力は地球の方向へ引っ張る力であり、真下の加速ベクトルととして現れる。

つまり、加速度センサーを使うことにより、重力ベクトルを測定できます。なお、スマホの加速度センサーの座標系は以下の通りです:

image.png

ただし、今回のゲームでは、カメラで見た座標系は:

image.png

なので、加速度センサーの ZとY軸を交換しないといけない。

加速度センサーを有効化

加速度センサーを使うために、まず有効化しないといけない。Unityは次の命令がある:

// 加速度センサーを使えるか?
if (Accelerometer.current != null)
{
    // 有効化する
    InputSystem.EnableDevice(Accelerometer.current);
}

加速度センサーの値を読み込む

そして、実行中に、以下のように加速度センサーのベクトルを読む:

// 加速度センサーを使えるか?
if (Accelerometer.current != null)
{
	// 加速度センサーの向きを取得
	Vector3 acc = Accelerometer.current.acceleration.ReadValue();
}

スクリプト

GravityController を更新し、スマホの加速度センサーを使える場合は、実際の重力データを利用しましょう:

// 実行中に重力を変える
public class GravityController : MonoBehaviour
{
	// 重力の加速度(地球)
	const float Gravity = 9.81f;

	// 重力に与える倍率(強さの調整)
	[SerializeField] 
	private float gravityScale = 1.0f;

	private void Start()
	{
		// 加速度センサーを使えるか? → 有効化
		if (Accelerometer.current != null)
			InputSystem.EnableDevice(Accelerometer.current);
	}
	
	private void Update()
	{
		// 重力のベクトル(Yはいつも下:-1)
		Vector3 vector = new Vector3(0, -1, 0);
		
		// 加速度センサーを使えるか?
		if (Accelerometer.current != null)
		{
			// 加速度センサーの向きを取得
			Vector3 acc = Accelerometer.current.acceleration.ReadValue();

			// センサーの軸をゲームの軸へ変換
			vector.x = acc.x;  // X はそのまま
			vector.z = acc.y;  // ゲームの Z は、加速度センサーの Y である
 		}
		else
		{
			// WASDの入力
			vector.x = Input.GetAxis("Horizontal"); // 左右(x軸)
			vector.z = Input.GetAxis("Vertical"); // 前後 (z軸)
		}
	
		// 単位ベクトルにする
		vector.Normalize();
		
		// 最後に、重力の向きを変える
		Physics.gravity = vector * (Gravity * gravityScale);
	}
}

実機で確認

スマホとPCをUSBケーブルで接続し、Build Profiles を開いてください。「Run Device」で端末を選び、「Build And Run」でビルドし、実行を確認してください。

image.png

扉の操作

画面のタッチ機能を使い、指のスライドで、扉を動かしてみましょう。

タッチ機能

加速度センサーと同様に、タッチ機能を使う前には、機能の有効化をしないといけない。Unityでは:

// タッチ機能を有効化
EnhancedTouchSupport.Enable();

その次に、タッチのイベントに登録しなければならない:

// 画面を触れ始めたとき
Touch.onFingerDown += 処理メソッド;

// 指は移動中のとき
Touch.onFingerMove += 処理メソッド;

// 指が画面から放したとき
Touch.onFingerUp += 処理メソッド;

また、処理するメソッドは、「Finger」という引数を受け取り、指の画面の位置などが渡される。例:

private void Start()
{
	// タッチ機能を有効化
	EnhancedTouchSupport.Enable();

	// イベントに登録
	Touch.onFingerDown += OnFingerDown;
}

// 画面をタッチし始めた
private void OnFingerDown(Finger finger)
{
	// 指の画面の位置を表示
	Debug.Log(finger.screenPosition);
}

水平扉をスライド

扉を指でスライドするのを次の考えで実現できる:

image.png

初めて画面を触れるとき、指データを保管しておく。Unityがマルチタッチ(多数の指)に対応できるので、同じ指かどうかの確認が必要。そして、指が動くときに、移動量を計算し、その分を使って扉をスライドする。最後に、指を離したら、保管したデータを解放し、処理終了にする。

スクリプト

DoorSlider のスクリプトを作成し、まず水平(Horizontal)の扉を移動しましょう:

using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch; // << ここ注意!

// 扉を移動(スライド)する
public class DoorSlider : MonoBehaviour
{
    // 水平の扉のグループ(親オブジェクト)
    [SerializeField] private Transform horizontalDoors;

    // 使った指
    private Finger useFinger = null;

    // 指の初期位置
    private Vector2 fingerStart;
    
    // 水平扉の初期位置(X軸のみ)
    private float doorStartX;
    
    private void Start()
    {
        // タッチ機能有効化
        EnhancedTouchSupport.Enable();

        // 各種のタッチイベント登録
        Touch.onFingerDown += OnFingerDown;
        Touch.onFingerMove += OnFingerMove;
        Touch.onFingerUp += OnFingerUp;
    }

    private void OnDestroy()
    {
        // 各種のタッチイベント登録解除
        Touch.onFingerDown -= OnFingerDown;
        Touch.onFingerMove -= OnFingerMove;
        Touch.onFingerUp -= OnFingerUp;
        
        // タッチ機能無効化
        EnhancedTouchSupport.Disable();
    }

    private void OnFingerDown(Finger finger)
    {
        // マルチタッチできるため、すでに指一本を使っていたら、2本目から無視する
        if (useFinger != null) return;

        // 指を保管
        useFinger = finger;
        
        // 初期位置も取っておく
        fingerStart = finger.screenPosition;
        doorStartX = horizontalDoors.position.x;
    }

    private void OnFingerMove(Finger finger)
    {
        // マルチタッチできるので、同じ指かどうかを確認
        if (useFinger != finger) return;
        
        // 指の移動量を計算(画面のピクセル単位)
        Vector2 delta = useFinger.screenPosition - fingerStart;
        
        // 様々な画面サイズがあるので、移動量正規化しましょう(最大値:1)
        delta.x /= Screen.width;
        delta.y /= Screen.height;
        
        // 現在の位置からを取得し、X軸だけを更新
        Vector3 hPos = horizontalDoors.position;
        hPos.x = doorStartX + delta.x;
        horizontalDoors.position = hPos;
    }
    
    private void OnFingerUp(Finger finger)
    {
        // 指を解放する
        if (useFinger == finger)
            useFinger = null;
    }
}

正規化に関して

いろんなスマホがあり、それぞれの画面サイズが異なるので、同じ指の動きでも大幅に異なる移動量が発生する可能性がある。例えば:

image.png

スワイプの長さを画面サイズ(Screen.widthScreen.height)で割れば、必ず 0 と 1の間になり、どの画面サイズであっても、同じ数値になる。この計算が「正規化」という。

Unityで確認

スクリプトを「Stage > Doors」にアタッチし、「Horizontal Doors」に「Stage > Doors > Horizontal」を入れてください

image.png

タッチをシミュレーションする

タッチで扉を動くようにしたので、どれだけマウスを使っても、扉が全く動かない。そのため、Unityはマウスを使ってタッチを疑似する機能がある。もちろん、マウス1つしかないので、指1本のみで、マルチタッチが再現できないが、今回のゲームなら十分。

疑似タッチを有効化するのは Window > Analysis > Input Debugger を選択し、次のウィンドウを開く。

image.png

このウィンドウの「Options」を開く、「Simulate Touch Input From Mouse or Pen」を選択すれば、マウスでタッチを疑似する。

image.png

速度調整、移動制限を追加

これで、扉が移動できるようになったが、2つの問題が現れた。

  1. 扉の動きが多少遅く感じる
  2. 画面から消えるぐら、無限にスライドできる

この2つの問題を解決しましょう:

速度調整

スクリプトに調整できる「speed」変数を追加し、OnFingerMove で使いましょう。先頭に:

// スライドの速度
[SerializeField] private float speed;

OnFingerMove では:

// 現在の位置からを取得し、X軸だけを更新
Vector3 hPos = horizontalDoors.position;
hPos.x = doorStartX + delta.x * speed; // 速度を適用
horizontalDoors.position = hPos;

Unity で適切に速度を設定し、確認してください

image.png

移動制限

Unityで最大Xと最小Xの位置を設定できるようにし、OnFingerMove で制限をかける。まず、スクリプトの先頭に:

// 最低 X 位置
[SerializeField] 
private float xMin;

// 最大 X 位置
[SerializeField] 
private float xMax;

そして、OnFingerMove では:

// 現在の位置からを取得し、X軸だけを更新
Vector3 hPos = horizontalDoors.position;
hPos.x = doorStartX + delta.x * speed;     // 速度を適用
hPos.x = Mathf.Clamp(hPos.x, xMin, xMax); // 移動制限を適用
horizontalDoors.position = hPos;

Unity で適切に数値を設定し、確認してください

image.png

垂直をスライド

同じ処理を使って、DoorSlider スクリプトに垂直の扉が Z 軸で移動できるようにしましょう。スクリプトの先頭に:

// 垂直の扉のグループ(親オブジェクト)
[SerializeField] 
private Transform verticalDoors;

// 最低 Z 位置
[SerializeField] 
private float zMin;

// 最大 Z 位置
[SerializeField] 
private float zMax;

//(他の[SerializeField])

// 垂直扉の初期位置(Z軸のみ)
private float doorStartZ;

つぎ、OnFingerDown では、垂直扉の初期位置を取る

// 初期位置も取っておく
fingerStart = finger.screenPosition;
doorStartX = horizontalDoors.position.x;
doorStartZ = verticalDoors.position.z;

そして、OnFingerMove では:

// 現在の位置からを取得し、Z軸だけを更新
Vector3 vPos = verticalDoors.position;
vPos.z = doorStartZ + delta.y * speed;     // 速度を適用
vPos.z = Mathf.Clamp(vPos.z, zMin, zMax); // 移動制限を適用
verticalDoors.position = vPos;

最後に Unity でパラメータを設定して、試してみてください:

image.png

実機で確認

つぎ、端末を接続し、「Build And Run」でタッチと傾きを使用し、卵を巣まで動かしてみてください。

卵の「体力」

次、卵が強く衝突したら体力が減り、ゼロになったら卵が消える処理を追加しましょう。

スクリプト

まず、単純な考え方で、「ぶつけたら、体力が減る」処理をにしましょう。

// 卵の体力
public class EggHP : MonoBehaviour
{
    // ぶつかるときに受けるダメージ
    [SerializeField] 
    private float hitDamage = 30;
    
    // 体力
    private float hp = 100;
    
    // 衝突の時
    private void OnCollisionEnter(Collision other)
    {
        // ぶつけたら、体力が減る
        hp -= hitDamage;

        // UIまだないため、Debug.Logで確認
        Debug.Log(hp);
      
        // 体力ゼロ以下の場合、ゲームオブジェクトを廃止
        if (hp <= 0)
            Destroy(gameObject);
    }
}

スクリプトを卵のプレハブにアタッチし、転がしてみましょう。

誤って、プレハブではなく、シーンのゲームオブジェクトを編集した場合
誤って、プレハブではなく、シーンのゲームオブジェクトを編集した場合

プレハブを編集すると、シーンのゲームオブジェクト(インスタンス)が変わるが、逆、ゲームオブジェクトをいじれば、プレハブが変わらない。

万が一、誤った操作でゲームオブジェクトを編集してしまった場合、2つの解決がある

① ゲームオブジェクトで編集した分をプレハブに反映させる

シーンにあるゲームオブジェクトの編集分をプレハブに適用できる。もちろん、同じプレハブの元であるゲームオブジェクトも変わる。

編集した分を適用するのは「Overrides」で適用できる:

image.png

「Apply All」を押すと、変わった数値やコンポーネントをプレハブに反映される

② ゲームオブジェクトをプレハブの状態に戻す

逆に、編集した分をなしにし、プレハブの状態に戻したいなら、「Revert All」を押してください

問題1:すぐ消えてしまう!

ぶつかる時に体力が減るようにしたが、床面ともぶつかるので、転がるだけで死んでしまう!床面に新しい「Floor」タグを付けて、スクリプトで床を無視しましょう。

image.png

// 衝突の時
private void OnCollisionEnter(Collision other)
{
    // 床面を無視する
    if (other.gameObject.CompareTag("Floor"))
        return;
    
    // ぶつけたら、体力が減る
    hp -= hitDamage;

    // UIまだないため、Debug.Logで確認
    Debug.Log(hp);
    
    // 体力ゼロ以下の場合、ゲームオブジェクトを廃止
    if (hp <= 0)
        Destroy(gameObject);
}

これでまた確認してみてください。

問題2:優しくにしても、すぐ体力が減る

たしか、これで床で転がしても体力が減らないが、優しく壁を触れても、体力が30を減る!衝突の強さに合わせて体力が減る処理を追加しましょう。このため Collision の  impulse を使用できる。impulse を確認するため、一旦 OnCollisionEnter を以下のように変えてください:

// 衝突の時
private void OnCollisionEnter(Collision other)
{
    // 床面を無視する
    if (other.gameObject.CompareTag("Floor"))
        return;
    
    // ぶつけたら、体力が減る
    hp -= hitDamage;

    // 一時的に impulse を表示
    // impulseはベクトル(強さと向き)なので、ベクトルの長さを求める
    float hitForce = other.impulse.magnitude;
    Debug.Log($"衝突の強さ:{hitForce}");
    
    // UIまだないため、Debug.Logで確認
    //Debug.Log(hp);
    
    // 体力ゼロ以下の場合、ゲームオブジェクトを廃止
    //if (hp <= 0)
    //    Destroy(gameObject);
}

で実行するとこのように表示される(転がり方による)

image.png

これを使って、スクリプトを直しましょう。最小値と最大値の強さを Unityで設定し、

ならば、以下のようにスクリプトを直す:

// 卵の体力
public class EggHP : MonoBehaviour
{
    // ぶつかるときに受けるダメージ
    [SerializeField] 
    private float hitDamage = 30;
    
    // 最低の衝突の強さ
    [SerializeField] 
    private float minImpulse = 4;
    
    // 最大の衝突の強さ
    [SerializeField] 
    private float maxImpulse = 20;
    
    // 体力
    private float hp = 100;
    
    // 衝突の時
    private void OnCollisionEnter(Collision other)
    {
        // 床面を無視する
        if (other.gameObject.CompareTag("Floor"))
            return;
        
        // impulseはベクトル(強さと向き)なので、ベクトルの長さを求める
        float hitForce = other.impulse.magnitude;
        
        // 最低と最大の間に正規化する
        float hitNorm = (hitForce - minImpulse) / (maxImpulse - minImpulse);
        hitNorm = Mathf.Clamp01(hitNorm); // 絶対に0と1の間にする
        
        // ぶつけたら、体力が減る
        hp -= hitDamage * hitNorm;

        // UIまだないため、Debug.Logで確認
        Debug.Log($"衝突の強さ:{hitForce}, 残りの体力:{hp}");

        // 体力ゼロ以下の場合、ゲームオブジェクトを廃止
        if (hp <= 0)
            Destroy(gameObject);
    }
}

これで試して、体力の減り方を見てください。

UIを作成

体力を可視化するため、新しい Canvas を作成し、必要な素材を追加しましょう。Canvasが画面サイズに合わせて自動的にリサイズするように設定しましょう:

image.png

ゲージの作り方

「スライス(Sliced)」スプライトについて

次の事例について考えてください:セリフ用の枠を作成したいが、文字の数は固定ではなく、長い文章もあるし、短い文章もありえるので、枠のサイズも固定できない。文章に合わせようとして、枠を強制的にリサイズすると…

image.png

image.png

…スプライトが変に伸びてしまい、おかしくみえます。これを解決するにな、スプライトを「スライス」し、伸びると伸びない領域を指定できます。スプライトエディタでは:

image.png

「Border」を指定し、伸ばしてはいけない角と伸ばしても良い中央部分を定義できる。Imageコンポーネントで「Image Type:Sliced」にすると、問題なく、きれいに枠をリサイズできる:

image.png

image.png

UIマスクについて

単純に、「マスク」は、UIの一部を隠すためのコンポーネントである。例えば、ポーションのチャージを可視化したく、以下のように設計した:

image.png

この場合は、ポーションの「溜まり具合」はビンの形に合わせる必要があるので、マスクが最適です。具体的に:

image.png

ばらば、最後のポーションUIは、各層(レイヤー)のサンドイッチになる:

image.png

image.png

image.png

ゲージの仕組み

いろんな画面サイズを対応しないといけないので、体力ゲージが伸びたら、短くなったりするので、スライス画像を使用し、ゲージの形に合わせるため、マスクを使いましょう。また。ゲージのすぐ隣に卵の絵も追加しましょう。

image.png

image.png
image.png image.png image.png

image.png

image.png


UIマネージャー

つぎ、UI管理スクリプトを作成しましょう。ゲーム内でUI1つしかないので、シングレトンとして実現できる。ここで、体力ゲージを更新しましょう

// UI 管理するクラス(シングレトン)
public class UIManager : MonoBehaviour
{
    // シングレトンのインスタンス
    public static UIManager Instance { get; private set; }

    // 体力のゲージ
    [SerializeField] 
    private Image hpGauge;
    
    // 起動のとき
    private void Awake()
    {
        // シングレトンあれば、廃止
        if (Instance != null)
            Destroy(Instance.gameObject);
        
        // シングレトンインスタンスを設定
        Instance = this;
    }
    
    // 体力を更新
    // value:0から1までの正規化された体力
    public void UpdateEggHP(float value)
    {        
        // ゲージの溜まり具合を更新
        hpGauge.fillAmount = value;
    }
}

Unityで参照の設定を忘れずに~

image.png

EggHP のスクリプト編集

体力が減ったら、UIManager を呼び出し、ゲージを更新しましょう。

private void Start()
{
    // ゲージをリセット
    UIManager.Instance.UpdateEggHP(1.0f);
}

// 衝突の時
private void OnCollisionEnter(Collision other)
{
    // (省略)

    // ぶつけたら、体力が減る
    hp -= hitDamage * hitNorm;        
    
    // UIまだないため、Debug.Logで確認
    // Debug.Log($"衝突の強さ:{hitForce}, 残りの体力:{hp}");
    
    // UIを更新
    UIManager.Instance.UpdateEggHP(hp / 100);
    
    // 体力ゼロ以下の場合、ゲームオブジェクトを廃止
    if (hp <= 0)
        Destroy(gameObject);
}

卵の絵を更新

つぎ、体力が減れば減るほど、卵が割れていくように、画像を更新してみましょう。考えとしては、以下の図のようになる:

image.png

// 卵の画像
[SerializeField] 
private Image eggDisplay;

// 各種の卵のスプライト (0% → 100% の順番)
[SerializeField] 
private Sprite [] eggSprites;

// (省略)

// 体力を更新
// value:0から1までの正規化された体力
public void UpdateEggHP(float value)
{
    hpGauge.fillAmount = value;
    
    // valueが 0~0.999 の間を保証する (1.0f にしない理由を考えましょう) 
    value = Mathf.Clamp(value, 0, 0.999f);
    
    // 配列の指数を求める
    int index = Mathf.FloorToInt(eggSprites.Length * value);
    
    // スプライトを変える
    eggDisplay.sprite = eggSprites[index];
}

そして、Unityでは参照を設定。スプライトの順番は大事なので、注意してください。

image.png

次々の卵を生成

卵を無事に巣まで届けたら、ひょこが生まれ、ランダムに歩くようにしましょう。

卵を検知する「EggDetector」

まず、卵が巣に届く検知スクリプトを作成しましょう。卵かどか「Egg」というタグで識別すると:

// 卵を検知する
public class EggDetector : MonoBehaviour
{
    // 検知したら発生するイベント
    public event System.Action<GameObject> Detected;

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Egg"))
            Detected?.Invoke(other.gameObject);
    }
}

もちろん、卵のプレハブに「Egg」タグを追加しなければならない。

image.png

そして、巣にスクリプトをアタッチしてください(すでにトリガー付き)

image.png

GameManager で生成

次、ゲームマネージャークラスを作成し、卵が巣に届いたら卵を消し、新しい卵を生成しましょう。まず、ゲームマネージャークラスを作成

スクリプト

public class GameManager : MonoBehaviour
{
    // 卵のプレハブ
    [SerializeField] 
    private GameObject eggPrefab;

    // 卵の開始位置
    [SerializeField] 
    private Transform eggStart;
    
    // 卵のゴール(巣)
    [SerializeField] 
    private EggDetector goal;

    // 新しい卵を生成
    private void CreateEgg()
    {
        // プレハブから卵を生成し、開始位置にする
        var egg = Instantiate(eggPrefab);
        egg.transform.position = eggStart.position;
    }
    
    // 卵が巣に届いたときのイベントを受け取る
    private void OnEggDetected(GameObject egg)
    {
        // 卵を廃止し、次のを生成
        Destroy(egg);
        CreateEgg();
    }
    
    private void Start()
    {
        // 開始の卵を生成
        CreateEgg();

        // イベントに登録
        goal.Detected += OnEggDetected;
    }

    private void OnDestroy()
    {
        // イベント登録解除
        goal.Detected -= OnEggDetected;
    }
}

Unityの設定

今まで使ってきた卵をシーンから削除し、GameManager で卵のプレハブを設定してください。なお、卵の初期位置を空のゲームオブジェクトで指定してください:

image.png

image.png

実行し、確認してください。

卵体力の対応も

無事に巣まで届くだけではなく、体力ゼロになった場合でも新しい卵を出しましょう。まず EggHP ではイベントを追加し…

// 卵の体力
public class EggHP : MonoBehaviour
{
    // 卵が割れたときに発生するイベント
    public event System.Action<EggHP> Destroyed; 

    // (省略)

    // 衝突の時
    private void OnCollisionEnter(Collision other)
    {
        // (省略)

        // 体力ゼロ以下の場合、ゲームオブジェクトを廃止し、イベント発生
        if (hp <= 0)
        {
            Destroy(gameObject);
            Destroyed?.Invoke(this);
        }
    }
}

そして、GameManager では:

// 新しい卵を生成
private void CreateEgg()
{
    // プレハブから卵を生成し、開始位置にする
    var egg = Instantiate(eggPrefab);
    egg.transform.position = eggStart.position;
    
    // 廃止イベントに登録
    EggHP eggHp = egg.GetComponent<EggHP>();
    eggHp.Destroyed += OnEggDestroyed;
}

// 卵が割れたときのイベントを受け取る
private void OnEggDestroyed(EggHP eggHp)
{
    // イベント登録解除し、新しいのを生成
    eggHp.Destroyed -= OnEggDestroyed;
    CreateEgg();
}

// 卵が巣に届いたときのイベントを受け取る
private void OnEggDetected(GameObject egg)
{
    // 廃止イベント登録解除する
    EggHP eggHp = egg.GetComponent<EggHP>();
    eggHp.Destroyed -= OnEggDestroyed;

    // 卵を廃止し、次のを生成
    Destroy(egg);
    CreateEgg();
}

ひょこ生まれ!

ゲームマネージャーを編集し、卵が無事に巣まで届いたら、ひょこが生まれ、ランダムの目的地まで歩くようにしましょう。

ランダムに歩く

アイディアとして、ひょこが現在位置からある半径いないのランダム位置を選択する。ただし、その位置がナビゲーションメッシュの中にあること保証できないので、ランダム位置を選択し、そこから最も近いナビゲーションメッシュの位置を求める:

image.png

このため、Unity の2つの機能を使用

・単位円以内の

 ランダム位置

Vector2 point = Random.insideUnitCircle;

・ナビゲーションメッシュ

 に一番近い点を探す

// position        :定めた位置
// hit             :探索の結果
// distance        :探索の最大距離
// NavMesh.AllAreas:探索したいナビゲーションメッシュの種類(AllAreas = すべてのメッシュ)
NavMesh.SamplePosition(position, out var hit, distance, NavMesh.AllAreas);

スクリプト

以下のスクリプトをひょこのプレハブに付けてください:

// ランダムに歩く
public class RandomWalk : MonoBehaviour
{
    // 円の半径
    [SerializeField] 
    private float radius;

    // ナビゲーションエージェント
    private NavMeshAgent agent;
    
    // 目的地を設定
    private void SetDestination()
    {
        // ランダム位置
        Vector2 point = Random.insideUnitCircle * radius;
        
        // 3Dにする
        Vector3 target = new Vector3(point.x, 0, point.y);
        
        // ナビゲーションメッシュに一番近い点にする
        NavMesh.SamplePosition(target, out var hit, 100, NavMesh.AllAreas);
        
        // 目的地にする
        agent.SetDestination(hit.position);
    }
    
    private void Start()
    {
        // コンポーネントを取得
        agent = GetComponent<NavMeshAgent>();
        SetDestination();
    }
}

確認のため、ひょこ1羽を手で追加し、動きを見てみましょう

次々の位置を設定

これで1回動くが、着いたら止まるので、次々に新しい位置を設定しましょう。考えとしては「しばらくに動ていなければ、新しい目的地にする」。この作戦で、閉じた扉で動かなくなった場合でも、別の位置に移動ができる。実現するには:

  1. 前回の Update と今の Update の位置の差分を求める
  2. 差分(移動量)が大きければ、まだ動いている → なにもしない
  3. 差分(移動量)が小さければ、止まっている → タイマー開始
  4. タイマーが切れたら → 別の目的地にする
// ランダムに歩く
public class RandomWalk : MonoBehaviour
{
    // 円の半径
    [SerializeField] 
    private float radius;

    // ナビゲーションエージェント
    private NavMeshAgent agent;
    
    // 最後の位置
    private Vector3 lastPosition;
    
    // 止まっている時間
    private float stopTimer;
    
    // SetDestination()が変わらない
    
    private void Start()
    {
        // コンポーネントを取得
        agent = GetComponent<NavMeshAgent>();
        SetDestination();
        
        // 現在の位置を保管
        lastPosition = transform.position;
    }

    private void Update()
    {
        // 現在の位置
        Vector3 nowPosition = transform.position;
        
        // 差分を計算
        float distance = Vector3.Distance(nowPosition, lastPosition);

        // 最後の位置を上書きする
        lastPosition = nowPosition;

        // 少しでも移動していれば、問題ない
        if (distance > 0.01f)
        {
            stopTimer = 0; // タイマーをリセット
            return;        // 終了
        }
        
        // タイマーを更新
        stopTimer += Time.deltaTime;
        
        // 0.5秒以上動いていなければ、新しい位置を求める
        const float waitTime = 0.5f;
        if (stopTimer > waitTime)
        {
            stopTimer = 0;
            SetDestination();
        }
    }
}

GameManager の更新

テスト用のひょこをシーンから削除し、卵が巣まで行ったら、ひょこが生まれるようにしましょう。

public class GameManager : MonoBehaviour
{
    // ひょこのプレハブ
    [SerializeField] 
    private GameObject chickPrefab;

    // (省略)

    // 卵が巣に届いたときのイベントを受け取る
    private void OnEggDetected(GameObject egg)
    {
        // 廃止イベント登録解除する
        EggHP eggHp = egg.GetComponent<EggHP>();
        eggHp.Destroyed -= OnEggDestroyed;

        // 卵の位置でひょこを生成する
        GameObject copy = Instantiate(chickPrefab);
        copy.transform.position = egg.transform.position;

        // 卵を廃止し、次のを生成
        Destroy(egg);
        CreateEgg();
    }
}