ミニゲームを作ってUnityを学ぶ![タンクウォーズ編]
###第2回目: 戦車の移動
今回は前回配置した戦車モデルについて、以下のような移動する機能を実装していきます。
- [W]または[↑]が押された場合 = 上方向へ移動
- [S]または[↓]が押された場合 = 下方向へ移動
- それ以外の場合 = 停止
#スクリプトを自作する……その前に
前回配置した戦車オブジェクトにはアセットの開発者が作ったスクリプトが初めからアタッチされていますので、このままでもキーボードやマウスで操作することができます。
- マウスの向いている方向に砲台が回転する
- 左クリックで弾を発射する
- WSで戦車の前後移動・ADで戦車が回転する
ただこれではお勉強としての意味があまりありませんので、今回はアタッチされているスクリプトを全て取り除いて必要な部分をそれぞれ自作していくことにします。
###戦車のルートオブジェクトを変更する
戦車オブジェクトはSD_Firefly_1.1という名前の空オブジェクトを親(ルート)とした、多数の部品オブジェクトの集合体です。
この状態からSD_Firefly_1.1を除外してMainBodyを部品オブジェクトたちの親とします。
さらにMainBodyの名前をPlayerTankに変更します。
- MainBodyをドラッグしてSD_Firefly_1.1から抜き出す
- Break Prefab Instanceの確認画面でContinueを選択
- MainBodyの名前をPlayerTankに変更
###スクリプトや余計なオブジェクトを取り除く
ルートオブジェクトのSD_Firefly_1.1を除外することで、ほとんどの部品オブジェクトが正常に動作しなくなります。
これはそれぞれの部品オブジェクトにアタッチされたスクリプトがSD_Firefly_1.1にアタッチされているID_Control_CSというスクリプトの参照を持っていることが原因です。
めんどくさい作業ですが、ここで初めからアタッチされているスクリプトや不要なオブジェクトを全て取り除いてしまいます。
- 孤立した以前のルートオブジェクト = SD_Firefly_1.1を削除
- TPSカメラ = 「PlayerTank/Camera_Pivot」を削除
- ガンカメラ = 「PlayerTank/Turret_Base/Cannon_Base/Barrel_Base/Gun_Camera」を削除
- エンジン音 = 「PlayerTank/Engine_Sound」を削除
- 前方部分の当たり判定 = 「PlayerTank/Armor_Collider_Front」を削除
- 初めからアタッチされている全てのスクリプト(約30個くらい)を除去
プロジェクトを実行してみてコンソールに赤字のエラーログが出ない状態になれば完了です。
###戦車のプレハブを作り直す
_MyFolder直下に新しくPrefabsフォルダを用意し、その中に先ほど修正したPlayerTankのプレハブを書き出します。
これにより以後のPlayerTankに対する修正は新しく紐づけた方のプレハブに反映されます。
#戦車本体のスクリプトを作る
それではいよいよ、スクリプトの作成に取り掛かります。
まずは戦車の本体となるTankModelクラス。
このTankModelがプレイヤーからの入力を受け取ってそれぞれの部品に指示を出すことで移動などのアクションを実現する、いわば部品に対するマネージャーとしての役割を担います。
- TankModelという名前のスクリプトを作成
- PlayerTankにTankModelをアタッチ
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TankModel : MonoBehaviour
{
[SerializeField]
// プレイヤーの場合はtrue
private bool mIsPlayer;
public bool IsPlayer
{
get { return mIsPlayer; }
}
// 操作可能な場合はtrue
private bool mIsActive = true;
public bool IsActive
{
get { return mIsActive; }
}
// HPが0の場合はtrue
private bool mIsDead;
public bool IsDead
{
get { return mIsDead; }
set { mIsDead = value; }
}
}
フィールド名がC#の命名規則であるPascalCaseではないのですが、これは筆者がJavaのCamelCaseとC#のPascalCaseを混在させてしまうために統一している自己流のクセですので、どうかご了承くださいませ。
TankModelクラスはそれがアタッチされているオブジェクトについて
- プレイヤーかどうか
- 操作可能かどうか
- HPが0かどうか
の3つのフラグを持ち、それぞれのフラグはプロパティになっています。
また後述の**[SerializeField]**によってプレイヤーフラグをインスペクタから変更できるようになっていますので、このタイミングでインスペクタに表示されたmIsPlayerの項目にチェックを入れておきます。
[SerializeField]
privateなフィールドの直前に記述することで、本来はpublicフィールドでしかできない
インスペクタからの編集ができるようになります。
#移動機能を実装する
戦車本体を表すスクリプトが出来たので、次は戦車に移動機能を実装していきます。
- TankMovementという名前のスクリプトを作成
- PlayerTankにTankMovementをアタッチ
まずはAwake()で自身のRigidbodyを取得。
インスペクターから戦車の速度を設定できるようにしておきます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TankMovement : MonoBehaviour
{
private Rigidbody mRigid;
[SerializeField]
[Tooltip("戦車の移動速度")]
private float mTankSpeed = 20.0f;
void Awake()
{
mRigid = GetComponent<Rigidbody>();
}
}
[Tooltip("表示したいコメント")]
フィールドの直前に記述することで、インスペクタ上で当該フィールドにマウスポイントを置いた際に
任意のメッセージ(コードでは「戦車の移動速度」)をポップアップ表示させることができます。
###入力を受け取る
// 更新
void Update()
{
CheckInput();
}
// キー入力を監視する
private void CheckInput()
{
if (Input.GetKey(KeyCode.UpArrow) || Input.GetKey(KeyCode.W))
{
SetVelocityUp();
}
else if (Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.S))
{
SetVelocityDown();
}
else
{
ResetVelocity();
}
}
Update毎に特定キーの入力を監視し、「そのいずれかが押されている or 全てが押されていない場合」に対応するメソッドを実行しています。
###移動の初期化と速度ベクトルの適用
// 仮の速度ベクトル
private Vector3 mTempVelocity = Vector3.zero;
// 上移動の初期化
public void SetVelocityUp()
{
mTempVelocity = new Vector3(0.0f, 0.0f, mTankSpeed);
}
// 下移動の初期化
public void SetVelocityDown()
{
mTempVelocity = new Vector3(0.0f, 0.0f, -mTankSpeed);
}
// 移動停止の初期化
public void ResetVelocity()
{
mTempVelocity = Vector3.zero;
}
void FixedUpdate()
{
ApplyVelocity();
}
// mTempVelocityをRigidbody.velocityに適用
private void ApplyVelocity()
{
mRigid.velocity = mTempVelocity;
}
入力を受けた場合はmTempVelocityに速度ベクトルを格納し、その値をFixedUpdate内でRigidbody.velocityに適用しています。
#移動操作の確認
この段階で一度プロジェクトを実行して移動操作が正しく行えるかを確認してみると、2つの問題があることがわかります。
- 何もしていない状態でも戦車が少しづつ動いてしまう
- 移動キーを押すと戦車が転がってしまう
###問題1:何もしていない状態でも戦車が少しづつ動いてしまう
原因を探るために戦車を横から眺めてみると、なぜか足回りがプルプルと震えています。
これは戦車を構成するそれぞれの部品オブジェクトのColliderがお互いに干渉していると予想して、Layer Collision Matrixで当たり判定を見てみるもわからず。
というより部品オブジェクトのLayerがDefaultでもない空白になっています。
そこで元のスクリプトをいくらか確認。
void Awake ()
{
Time.fixedDeltaTime = fixedTimestep;
tankList = new List <ID_Control_CS> ();
#if UNITY_ANDROID || UNITY_IPHONE
if (touchControlsPrefab) {
Instantiate (touchControlsPrefab);
}
float screenRate = (float)maxResolution / Screen.height;
if (screenRate > 1.0f) {
screenRate = 1.0f;
}
int width = (int)(Screen.width * screenRate);
int height = (int)(Screen.height * screenRate);
Screen.SetResolution(width, height, true);
#endif
this.tag = "GameController";
↓ ここでレイヤー毎の当たり判定を設定している!
/*
Layer Collision Settings.
Layer9 >> for wheels.
Layer10 >> for Suspensions.
Layer11 >> for MainBody.
*/
for (int i = 0; i <= 11; i++) {
Physics.IgnoreLayerCollision (9, i, false); // Reset settings.
Physics.IgnoreLayerCollision (11, i, false); // Reset settings.
}
Physics.IgnoreLayerCollision (9, 9, true); // Wheels do not collide with each other.
Physics.IgnoreLayerCollision (9, 11, true); // Wheels do not collide with MainBody.
for (int i = 0; i <= 11; i++) {
Physics.IgnoreLayerCollision (10, i, true); // Suspensions do not collide with anything.
}
}
void Awake ()
{
this.gameObject.layer = 11; // Layer11 >> for MainBody.
↑ ここでオブジェクトにレイヤーを設定している!
thisRigidbody = GetComponent < Rigidbody > ();
thisRigidbody.solverIterations = solverIterationCount;
/* for reducing Calls.
rotateScripts = GetComponentsInChildren <Wheel_Rotate_CS> ();
*/
}
なぜレイヤーとその当たり判定の設定をコード上で行っているのか。
そしてインスペクタ上のLayerが空白になっているのかはわかりませんが、とりあえずの原因らしきモノを見つけたのでTankModelを修正します。
void Awake()
{
SetLayerCollision();
}
public static int LAYER_WHEEL = 9;
public static int LAYER_SUSPENSIONS = 10;
public static int LAYER_MAIN_BODY = 11;
private void SetLayerCollision()
{
// 初期化:車輪とボディについて、全ての接触を有効にする
for (int i = 0; i <= 11; i++)
{
Physics.IgnoreLayerCollision(LAYER_WHEEL, i, false);
Physics.IgnoreLayerCollision(LAYER_MAIN_BODY, i, false);
}
// 車輪同士の接触は無効
Physics.IgnoreLayerCollision(LAYER_WHEEL, LAYER_WHEEL, true);
// 車輪と本体の接触は無効
Physics.IgnoreLayerCollision(9, 11, true);
// サスペンションと全ての物体の接触は無効
for (int i = 0; i <= 11; i++)
{
Physics.IgnoreLayerCollision(10, i, true);
}
}
元々のコードと同じようにAwake()でレイヤーの当たり判定を設定。
これで戦車がプルプルと震えてしまう現象が治まりました。
###問題2:移動キーを押すと戦車が転がってしまう
これについては、今回の戦車に必要なのが見下ろし型画面での上下の移動 = Z座標のPosition変化のみですので、Rigidbodyに対してそれ以外の変化を制限することで対応します。
- Hierarchyウインドウ上でPlayerTankを選択
- アタッチされたRigidbodyのConstraintsについて「Freeze Position: Z」以外にチェックを入れる
これで戦車が予定通りの上下移動をしてくれるようになりました。
#TankModelから指示を受ける(スクリプトの修正)
移動機能を実装するためのTankMovementでは、Update()で入力を受け取りFixedUpdate()でその結果をRigidbodyに反映しています。
これを最初のほうで示したように、マネージャー役であるTankModelがプレイヤーからの入力を受け取り、それぞれの部品に指示を出すことで移動などのアクションを実現するという構造に修正します。
private TankMovement mMovementScript;
void Awake()
{
SetLayerCollision();
mMovementScript = GetComponent<TankMovement>();
}
void Update()
{
if (IsPlayer && IsActive)
{
mMovementScript.CheckInput();
}
}
void FixedUpdate()
{
mMovementScript.ApplyVelocity();
}
TankModelのAwake()で同じくアタッチされているTankMovementを取得し、新しく追加したUpdate()とFixedUpdate()でそれぞれTankMovementのメソッドを呼び出しています。
また、入力の監視を行うのはこの戦車がプレイヤーかつ操作可能な状態の場合のみに制限しています。
次にTankMovementで不要になったUpdate()とFixedUpdate()を削除し、TankMovelから呼び出されるメソッドをprivateからpublicに変更します。
Update()とFixedUpdate()を削除
↓ publicに変更
public void CheckInput()
{
if (Input.GetKey(KeyCode.UpArrow) || Input.GetKey(KeyCode.W))
{
SetVelocityUp();
}
else if (Input.GetKey(KeyCode.DownArrow) || Input.GetKey(KeyCode.S))
{
SetVelocityDown();
}
else
{
ResetVelocity();
}
}
↓ publicに変更
public void ApplyVelocity()
{
mRigid.velocity = mTempVelocity;
}
最後にプロジェクトを実行して、修正前と同じ挙動であることを確認します。