Posted at

ミニゲームを作ってUnityを学ぶ! [ タンクウォーズ編 - 9. 相手戦車にAIを実装する ]

More than 1 year has passed since last update.

ミニゲームを作ってUnityを学ぶ![タンクウォーズ編]


第9回目: 相手戦車にAIを実装する

ゲームの流れについては前回までにすべてが出来上がりましたので、ここではゲームが楽しいか楽しくないかに大きく影響を与える対戦相手の動き、行動AIを実装していきます。


EnemyAiの作成

戦車本体であるTankModelに対して移動や砲台の操作、弾を撃つ機能をそれぞれ別のスクリプトを作って実装してきたときと同じ用に、今回も行動Aiを戦車が持つ1つの機能としてスクリプトを作成します。


  • EnemyAiという名前でスクリプトを作成

  • EnemyTankにEnemyAiをアタッチ


EnemyAi.cs


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyAi : MonoBehaviour
{

private Transform mTrans;
private TankMovement mMoveScript;
private TurretController mTurretScript;
private FireController mFireScript;

public void Init(Transform trans, TankMovement move, TurretController turret, FireController fire)
{
mTrans = trans;
mMoveScript = move;
mTurretScript = turret;
mFireScript = fire;

// 1発目の弾を発射するまでの待機時間を決めておく
RandomSetWaitFireTime();
}

//--------
// 移動 //
//----------------------------------------------------------------------------------------

private enum MOVE_STATE
{
DEFAULT = 0,
UP = 1,
DOWN = 2
}
private MOVE_STATE mMoveState;
private float mGoalZ; // 目標とするz座標

/// <summary>
/// mMoveState==DEFAULTの場合に、目標とするz座標を計算する
/// </summary>
public void DecideMovement()
{
if(mMoveState == MOVE_STATE.DEFAULT)
{
// 現在のz座標を取得
float currentZ = mTrans.position.z;

// 移動可能なz軸の範囲から乱数取得
float rand = Random.Range(-14.0f, 15.0f);

// 取得した乱数と現在のz座標の距離を計算
float difference = 0.0f;
if(currentZ >= rand)
{
difference = Mathf.Abs(currentZ - rand);
mMoveState = MOVE_STATE.DOWN;
}else
{
difference = Mathf.Abs(rand - currentZ);
mMoveState = MOVE_STATE.UP;
}

// 距離が1.0f以上あるならば目標の座標を決定し、そうでないならば次フレームまでその場で待機
if(difference >= 1.0f)
{
mGoalZ = rand;
}else
{
mMoveState = MOVE_STATE.DEFAULT;
}

// mMoveStateを条件にして、TankMovementの移動メソッドを呼び出す
switch (mMoveState)
{
case MOVE_STATE.DEFAULT:
mMoveScript.ResetVelocity();
break;
case MOVE_STATE.UP:
mMoveScript.SetVelocityUp();
break;
case MOVE_STATE.DOWN:
mMoveScript.SetVelocityDown();
break;
}
}
}

/// <summary>
/// 移動の完了判定
/// 移動完了の判定を行い、完了しているならばmMoveStateをDEFAULTに戻す
/// </summary>
public void CheckMovement()
{
float currentZ = mTrans.position.z;
switch (mMoveState)
{
case MOVE_STATE.DEFAULT:
return;
case MOVE_STATE.UP:
if (currentZ >= mGoalZ) mMoveState = MOVE_STATE.DEFAULT;
break;
case MOVE_STATE.DOWN:
if (currentZ <= mGoalZ) mMoveState = MOVE_STATE.DEFAULT;
break;
}
}

//--------
// 砲台 //
//----------------------------------------------------------------------------------------

[SerializeField]
[Tooltip("砲台を向ける対象のTransform")]
private Transform mTargetTrans;

/// <summary>
/// 砲台をターゲットに向けるためのRotationを計算
/// </summary>
public void CalTurretRotation()
{
mTurretScript.CalRotation(mTargetTrans);
}

//--------
// 発射 //
//----------------------------------------------------------------------------------------

private readonly float MAX_WAIT_FIRE = 1.0f, MIN_WAIT_FIRE = 0.2f;
private float mWaitFireTime; // 弾を発射するまでの待機時間

/// <summary>
/// 次に弾を発射するまでの待機時間を乱数で決定する
/// </summary>
private void RandomSetWaitFireTime()
{
mWaitFireTime = Random.Range(MIN_WAIT_FIRE, MAX_WAIT_FIRE);
}

/// <summary>
/// 待機が完了した場合は弾を発射し、次の発射までの待機時間を決定する
/// </summary>
public void Fire()
{
mWaitFireTime -= Time.deltaTime;
if(mWaitFireTime <= 0.0f)
{
mFireScript.Fire();
RandomSetWaitFireTime();
}
}

}


いつもとは違って今回はスクリプトの完成形を最初から掲示していますので、以下より具体的な内容を見ていきます。


初期化

まずはAIがAIとして機能するために必要な事前準備を行います。


EnemyAi.cs


private Transform mTrans;
private TankMovement mMoveScript;
private TurretController mTurretScript;
private FireController mFireScript;

public void Init(Transform trans, TankMovement move, TurretController turret, FireController fire)
{
mTrans = trans;
mMoveScript = move;
mTurretScript = turret;
mFireScript = fire;

// 1発目の弾を発射するまでの待機時間を決めておく
RandomSetWaitFireTime();
}



TankModel.cs


private TankMovement mMovementScript;
private TurretController mTurretScript;
private FireController mFireScript;
private TankHealth mHealthScript;
追加 private EnemyAi mAi;

void Awake()
{
mMovementScript = GetComponent<TankMovement>();
mTurretScript = GetComponent<TurretController>();
mFireScript = GetComponent<FireController>();
mHealthScript = GetComponent<TankHealth>();

追加 // 敵戦車はAIコンポーネントを取得し、初期化する
if (!mIsPlayer)
{
mAi = GetComponent<EnemyAi>();
mAi.Init(GetComponent<Transform>(), mMovementScript, mTurretScript, mFireScript);
}
}


TankModelに新しくEnemyAiのフィールドを追加し、Awake()でプレイヤーフラグが立っていない場合はアタッチされているEnemyAiを取得してInit()を呼び出すように修正しています。

EnemyAiのInit()では引数で自身のTransformのほか、戦車に実装しているそれぞれの機能のスクリプトを取得し、最後にRandomSetWaitFireTime()で最初に弾を撃ち出すまでの待機時間を決定しています。


弾を発射する

弾を発射する仕組みはかなりシンプルです。


TankModel.cs


private readonly float MAX_WAIT_FIRE = 1.0f, MIN_WAIT_FIRE = 0.2f;
private float mWaitFireTime; // 弾を発射するまでの待機時間

/// <summary>
/// 次に弾を発射するまでの待機時間を乱数で決定する
/// </summary>
private void RandomSetWaitFireTime()
{
mWaitFireTime = Random.Range(MIN_WAIT_FIRE, MAX_WAIT_FIRE);
}

/// <summary>
/// 待機が完了した場合は弾を発射し、次の発射までの待機時間を決定する
/// </summary>
public void Fire()
{
mWaitFireTime -= Time.deltaTime;
if(mWaitFireTime <= 0.0f)
{
mFireScript.Fire();
RandomSetWaitFireTime();
}
}


0.2から1.0までのランダムな秒数を待機時間として予め設定しておき、その待機時間が経過したタイミングで1発の弾を撃ち出します。

そして弾を撃ち出したタイミングと同時に次の待機時間を再度設定しなおすことで、自身の機能が停止するまでいつまでも弾を発射し続けます。

このときに最初の1発目を撃つタイミングを決定しているのがInit()で実行されているRandomSetWaitFireTime()になります。


移動する

移動についても同様に、とてもシンプルです。


EnemyAi.cs


private enum MOVE_STATE
{
DEFAULT = 0,
UP = 1,
DOWN = 2
}
private MOVE_STATE mMoveState;
private float mGoalZ; // 目標とするz座標

/// <summary>
/// mMoveState==DEFAULTの場合に、目標とするz座標を計算する
/// </summary>
public void DecideMovement()
{
if(mMoveState == MOVE_STATE.DEFAULT)
{
// 現在のz座標を取得
float currentZ = mTrans.position.z;

// 移動可能なz軸の範囲から乱数取得
float rand = Random.Range(-14.0f, 15.0f);

// 取得した乱数と現在のz座標の距離を計算
float difference = 0.0f;
if(currentZ >= rand)
{
difference = Mathf.Abs(currentZ - rand);
mMoveState = MOVE_STATE.DOWN;
}else
{
difference = Mathf.Abs(rand - currentZ);
mMoveState = MOVE_STATE.UP;
}

// 距離が1.0f以上あるならば目標の座標を決定し、そうでないならば次フレームまでその場で待機
if(difference >= 1.0f)
{
mGoalZ = rand;
}else
{
mMoveState = MOVE_STATE.DEFAULT;
}

// mMoveStateを条件にして、TankMovementの移動メソッドを呼び出す
switch (mMoveState)
{
case MOVE_STATE.DEFAULT:
mMoveScript.ResetVelocity();
break;
case MOVE_STATE.UP:
mMoveScript.SetVelocityUp();
break;
case MOVE_STATE.DOWN:
mMoveScript.SetVelocityDown();
break;
}
}
}

/// <summary>
/// 移動の完了判定
/// 移動完了の判定を行い、完了しているならばmMoveStateをDEFAULTに戻す
/// </summary>
public void CheckMovement()
{
float currentZ = mTrans.position.z;
switch (mMoveState)
{
case MOVE_STATE.DEFAULT:
return;
case MOVE_STATE.UP:
if (currentZ >= mGoalZ) mMoveState = MOVE_STATE.DEFAULT;
break;
case MOVE_STATE.DOWN:
if (currentZ <= mGoalZ) mMoveState = MOVE_STATE.DEFAULT;
break;
}
}


EnemyAiは移動に関する状態としてDEFAULT, UP, DOWNの3つの値を持っています。

この状態がDEFAULTのときにステージの上下範囲内で目標とするZ座標をランダムに決定し、その目標が現在位置から上の場合はUP、下の場合はDOWNに状態をそれぞれ変更した後、その方向に向かって移動していきます。

そして目標地点に到達したタイミングで状態がDEFAULTに戻ることで、また新しい目標地点が決まり、その方向にむかって再度移動を開始します。

このとき、小刻みな上下運動を繰り返すといった(個人的に)かっこ悪い動きをさせないために、目標地点と現在位置の差が1.0f未満な場合は移動を見送る制限をかけています。


狙いをさだめる

AIとは名ばかりの単純な仕組み続きですが、狙いを定める = 砲台を回転させる仕組みはその中でも最も単純です。


EnemyAi.cs


[SerializeField]
[Tooltip("砲台を向ける対象のTransform")]
1: private Transform mTargetTrans;

/// <summary>
/// 砲台をターゲットに向けるためのRotationを計算
/// </summary>
public void CalTurretRotation()
{
mTurretScript.CalRotation(mTargetTrans);
}


1: PlayerTankのTransformをインスペクタから設定


TurretController.cs


メソッドを追加

/// <summary>
/// 砲台を与えられた対象に向ける場合のRotation値(Vector3)を計算する
/// </summary>
/// <param name="target"></param>
public void CalRotation(Transform target)
{
// 対象座標 - 観測座標 = 観測座標から対象座標へ向かうベクトル
Vector3 direction = target.position - mTurretTrans.position;

// 砲台の正面が対象を向くための回転角度を計算
Quaternion quaternion = Quaternion.FromToRotation(transform.forward, direction);

// QuaternionをVector3に変換し、ここでx,z角度を制限する(yは必ず範囲内になるため制限の必要がない)
mTurretRotation = quaternion.eulerAngles;
mTurretRotation.x = 0.0f;
mTurretRotation.z = 0.0f;
}


TurretControllerに新しく追加したCalRotation(Transform)によって、砲台が引数として与えられた対象を常に向き続けるような動作をします。


AIを機能させる

出来上がったAIを機能させるために、TankModelをもう少しだけ修正します。


TankModel.cs

void Update()
{
if (mIsActive && mIsPlayer) // アクティブかつプレイヤーの場合は操作を受け付け
{
// 移動入力の受付
mMovementScript.CheckInput();

// 砲台向きを計算
mTurretScript.CalRotation();

// 発射
mFireScript.CheckInput();
}
else if(mIsActive && !mIsPlayer) // アクティブかつ敵の場合はAI行動を決定&実行
{
// 移動目標を決定
mAi.DecideMovement();

// 移動完了の判定
mAi.CheckMovement();

// 砲台向きを計算
mAi.CalTurretRotation();

// 発射
mAi.Fire();
}

// HP描画はアクティブに影響を受けないで更新
mHealthScript.RenewHealthBar();
}


Update()の処理内容をプレイヤーフラグによって分岐しました。

これでゲームが開始されると同時に相手戦車が動き出すようになります。


ゲームの完成

tankwars_ss_1.jpg

9回にわたって解説を行ってきたタンクウォーズですが、今回行った敵AIの実装によって遂にゲームとして完成となります。

筆者自身の後学のためにと書き始めた記事ですが、Unityを始めて間もない方にとって少しでも参考になれば幸いでございます。

またコードやテキストの不備などございましたら、ぜひぜひお知らせくださいませ。

それでは最後に、プロジェクトを実行してタンクウォーズが完成していることを確認します。

ご覧いただき、ありがとうございました。


イントロダクションに戻る