ミニゲームを作ってUnityを学ぶ![ひつじコレクション編]
###第5回目: ゴールエリアとスコアを実装
前回はナビゲーションシステムを利用してフォロワーにプレイヤーを追従する機能を実装しました。
今回はゲーム内にスコアの概念を追加し、併せてそのスコアを得るために必要なゴールエリアを実装していきます。
シングルトンなクラス
スコアの概念をゲームに追加するにあたって、このタイミングでプロジェクト全体を管理するためのクラスを作成しておきます。
- 「GameController」という名前のスクリプトを作成
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class GameController
{
// 初期化タイミングでインスタンスを生成
private static readonly GameController mInstance = new GameController();
// コンストラクタをprivateにすることによって他クラスからnewできないようにする
private GameController() { }
// 他クラスからこのインスタンスを参照する
public static GameController Instance
{
get
{
return mInstance;
}
}
}
GameControllerはプロジェクト全体を通して1つしかインスタンスを生成することができません。
(このような構造のクラスをSingleton(シングルトン)なクラスと呼びます。)
それと同時に、GameControllerのインスタンスはプロジェクト内の全てのスクリプトから呼び出すことができます。
この特性を利用することで本来はシーンが切り替わる際に破棄されてしまう値やクラスを残したり、複数のスクリプトから呼び出される定数などを一元管理することが主な役割です。
#スコアを管理する
では、スコアを管理するための簡単なスクリプトを用意していきます。
- 「Scorer」という名前でスクリプトを作成
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Scorer
{
public int Score { get; private set; }
public void AddScore(int score)
{
Score += score;
}
}
int型のScoreプロパティを持ち、その値を加算させていくメソッドを持ったシンプルなクラスです。
これだけの内容ならStageManagerにスコアを管理するコードを追加する形でも良いのですが、Scorerには後程オンラインランキングに対応するコードを追加していきますので、今回は1クラスとして定義しておきます。
次に、このScorerをGameControllerに保持させてゲーム内のどのスクリプトからも呼び出せるようにします。
- GameControllerにコードを追加
public void Init()
{
Scorer = new Scorer();
}
//-------------
// スコア管理 //
//---------------------------------------------------------------------------------
public Scorer Scorer { get; private set; }
//--------------------------
// マネージャーの参照を保持 //
//---------------------------------------------------------------------------------
public StageManager StageManager { get; set; }
Init()でScorerをインスタンス化し、それを他クラスから呼び出せるようプロパティに代入しています。
またそれとは別にStageManagerの参照を保持し、こちらも他クラスから呼び出せるようにしています。
このGameControllerに対応するためにStageManagerを少し修正します。
- StageManagerを修正
void Start()
{
追加---------
GameController game = GameController.Instance;
game.Init();
game.StageManager = this;
ここまで-----
mConstructor = GetComponent<StageConstructor>();
LoadStage(1);
StartCoroutine("PopupFollower");
}
Start()にGameControllerに対応するコードを追加しました。
GameControllerは初めて呼び出されたこのタイミングで自身のインスタンスを生成し、StageManagerはこのインスタンスを使ってGameControllerのInit()やプロパティの設定を行っています。
これでGameControllerを介してどのスクリプトからでもScorerやStageManagerの参照を取得できるようになりました。
#ゴールエリアをステージに設置する
ここでスコアについては一度切り上げて、ゴールエリアの実装を行っていきます。
ゴールエリアを実装するにあたって、まずは簡単にゴールエリアの役割を確認します。
###ゴールエリアについて
ひつじコレクションのゴールエリアは赤色の半透明な立方体で表現されています。
このゴールエリアにプレイヤーが接触すると追従している全フォロワーが解放され、その数によってスコアが加算されます。
またゴールエリア内は敵キャラクターが侵入することのできない安全地帯となっていて、プレイヤーが接触ではなく完全にエリア内に侵入している場合は、安全地帯であることを表すためにゴールエリアの色が赤から緑色に変わります。
###ゴールエリアの土台を作成
以上の点を踏まえて、さっそくオブジェクトの土台を作っていきます
- 「TransparentRed」という名前で新しくMaterialを作成
- TransparentRedのシェーダーをLegacyShaders/Transparent/Diffuseに設定
- 同じく、MainColorを以下のように設定
R: 248
G: 18
B: 176
A: 100
- 「GoalArea」という名前で新しいCubeをゼロポジションに配置
- BoxColliderのIsTriggerにチェックを入れる
- GoalAreaに先ほど作成したTransparentRedをアタッチ
- 同じく、AddComponentからNavMeshSourceTagをアタッチ
- 新しいタグ「GoalArea」を作成してGoalAreaオブジェクトのタグに設定
- プレハブに書き出し
###ファイルから読み込む
続けて、出来上がったゴールエリアをStageConstructorで生成できるようにします。
- StageConstructorにコードを追加
public void LoadStage(int stageId)
{
// 指定されたステージファイルを読み込んで1行ずつ処理
string filePath = "stage_" + stageId;
TextAsset textAsset = Resources.Load(filePath) as TextAsset;
string text = textAsset.text;
string line;
using (TextReader reader = new StringReader(text)) // usingとは、処理終わりにstreamの解放を自動で行う構文(finally句でDisposeを実行するコードと同じ)
{
while ((line = reader.ReadLine()) != null)
{
// ステージサイズ
if (line.StartsWith("@size"))
{
line = REGEX_WHITE.Replace(line, "");
string[] mapSize = line.Split(DELIMITER, System.StringSplitOptions.RemoveEmptyEntries);
mStageX = int.Parse(mapSize[1]);
mStageY = int.Parse(mapSize[2]);
mStageMap = new int[mStageY, mStageX];
InstantiateFloor();
continue;
}
// ステージ構造(ブッシュ)
if (line.StartsWith("@bush"))
{
// ステージ構造は16進数表記の文字列なため、10進数のint配列に変換
StringBuilder sbStage = new StringBuilder();
for (int y = 0; y < mStageY; y++)
{
sbStage.Append(reader.ReadLine());
}
int start = 0;
for (int y = 0; y < mStageY; y++)
{
for (int x = 0; x < mStageX; x++)
{
mStageMap[y, x] = Convert.ToInt32(sbStage.ToString(start, 2), 16);
start += 2;
}
}
// ステージ構造を元にブッシュを生成
InstantiateBushs();
continue;
}
// ポップアップポイント
if (line.StartsWith("@popup"))
{
line = REGEX_WHITE.Replace(line, "");
string[] strPopupPos = line.Split(DELIMITER, System.StringSplitOptions.RemoveEmptyEntries);
InstantiatePopup(int.Parse(strPopupPos[1]), int.Parse(strPopupPos[2]));
}
// プレイヤー初期位置
if (line.StartsWith("@player"))
{
line = REGEX_WHITE.Replace(line, "");
string[] strPlayerPos = line.Split(DELIMITER, System.StringSplitOptions.RemoveEmptyEntries);
InstantiatePlayer(int.Parse(strPlayerPos[1]), int.Parse(strPlayerPos[2]));
continue;
}
以下、ゴールエリアの位置と大きさを読み込むコードを追加
// ゴールエリア
if (line.StartsWith("@goal"))
{
line = REGEX_WHITE.Replace(line, "");
string[] goalData = line.Split(DELIMITER, System.StringSplitOptions.RemoveEmptyEntries);
InstantiateGoalArea(goalData);
continue;
}
ここまで:ゴールエリアの位置と大きさを読み込むコード
}
}
}
//---------------
// ゴールエリア //
//---------------------------------------------------------------------------------
[SerializeField]
private GameObject prefabGoalArea; // ゴールエリアのプレハブを設定する
/// <summary>
/// ゴールエリアを指定されたステージ座標に生成
/// </summary>
/// <param name="goalData">
/// 0:tag
/// 1:posX
/// 2:posZ
/// 3:scaleX
/// 4:scaleZ
/// </param>
private void InstantiateGoalArea(string[] goalData)
{
float scaleY = 1.2f;
float posY = scaleY / 2.0f;
float posX = int.Parse(goalData[1]) * BLOCK_SIZE + BLOCK_SIZE_HALF;
float posZ = int.Parse(goalData[2]) * -BLOCK_SIZE - BLOCK_SIZE_HALF;
Vector3 pos = new Vector3(posX, posY, posZ);
GameObject obj = Instantiate(prefabGoalArea, pos, Quaternion.identity);
obj.transform.parent = mTrans;
float scaleX = float.Parse(goalData[3]);
float scaleZ = float.Parse(goalData[4]);
obj.transform.localScale = new Vector3(scaleX, scaleY, scaleZ);
}
LoadStage()に@goalが先頭に付いた行からゴールエリアの生成に必要な情報を取得するコードを追加し、併せて実際にインスタンス化を行うメソッドを追加しました。
ちなみにstage_1.txtで@goalの付いた行とその仕様は以下のようになっています。
@goal, 20, 29, 1.5, 1.5
@goal: 識別子
20: ステージの左上を(x:0, y:0)としたときのゴールエリアのx座標
29: 同じくゴールエリアのy座標
1.5: TransfromのScale.X
1.5: TransfromのScale.Z
#スコアを獲得する
ゴールエリアとの当たり判定をプレイヤーに追加し、追従させているフォロワーの数によってスコアを獲得する処理を実装します。
- PlayerActionを修正
追加 private readonly string TAG_GOAL_AREA = "GoalArea";
void OnTriggerEnter(Collider other)
{
if (State != STATE.IDLE && State != STATE.RUN) return;
// ポップアップポイントに接触した
if (other.tag == TAG_POPUP_POINT) TakeFollower(other.GetComponent<PopupPoint>());
// ゴールエリアに到達した
追加 if (other.tag == TAG_GOAL_AREA) ReleaseFollower();
}
/// <summary>
/// ゴールエリアに到達。
/// 引き連れているフォロワー数によって点数を獲得。
///
/// 1匹が100点、2匹以上の場合はそれぞれ100点のボーナスが加算されていく
/// 1匹目:100, 2匹目:200, 3匹目:300, 4匹目:400 = 最大で1000
/// </summary>
private void ReleaseFollower()
{
if (mFollowerList.Count <= 0) return;
int score = 0;
int index = 0;
foreach (FollowerModel model in mFollowerList)
{
// スコア計算
score += index * 100 + 100;
index++;
// フォロワーを休眠状態へ
model.Sleep();
}
// スコア加算
GameController gameCon = GameController.Instance;
Scorer scorer = gameCon.Scorer;
scorer.AddScore(score);
Debug.Log("score = " + scorer.Score);
// 速度設定
mFollowerList.Clear();
mSpeed = MAX_SPEED;
}
プレイヤーがゴールエリアに接触したタイミングでReleaseFollower()が呼ばれ、フォロワーを追従させている場合はスコアの加算やフォロワーの解放など各処理を行っています。
現在スコアの表示についてはまだUIが未実装ですのでログの表示に留めておき、続いてフォロワー側の修正を行います。
- FollowerプレハブにRigidbodyをアタッチ
- RigidbodyについてUseGravityのチェックを外す
- 同じく、IsKinematicとFreezeRotationのX, Zにチェックを入れる
- FollowerModelを修正
private Rigidbody mRigid;
private Vector3 mHomePos;
void Awake()
{
mRigid = GetComponent<Rigidbody>();
mHomePos = GetComponent<Transform>().position;
mAgent = GetComponent<NavMeshAgent>();
InitAnim();
}
/// <summary>
/// 次のリポップに備えて休眠状態へ遷移
/// </summary>
public void Sleep()
{
// 休眠状態
State = STATE.SLEEP;
mTarget = null;
gameObject.SetActive(false);
// ポジションと物理挙動を初期化
mAgent.Warp(mHomePos);
mAgent.updatePosition = true;
mRigid.isKinematic = true;
// マネージャーに通知
GameController.Instance.StageManager.OnSleepFollower();
}
Awake()の修正とSleep()の追加を行っています。
Sleep()では次のポップアップに備えた初期化を行い、最後にGameControllerを介してStageManagerに自身がSLEEP状態になったことを通知しています。
この通知に対応するため、StageManagerにメソッドを追加します。
public void OnSleepFollower()
{
mFollowerCount--;
}
StageManagerはコルーチンのPopupFollower()で最大4匹までのフォロワーをステージに出現させる処理を行っています。
今回のOnSleepFollower()によってステージ内に存在するフォロワーが減ったことを感知し、フォロワー数が最大になるまでまた新しいフォロワーをポップアップさせることができます。
スコアの実装にあたっては一度に沢山のクラスの修正や作成が重なってしまいわかりにくくなってしまったのですが、1つ1つ順を追って確認してみてください。
安全地帯を演出する
最後に、安全地帯であることを示すゴールエリアの色の変化を実装します。
###新しいマテリアルを作成
色の変化についてはオブジェクトに設定されているマテリアルを差し替えることで実装します。
まずは変化後の緑色に使用するマテリアルを用意します。
- 「TransparentGreen」という名前で新しくMaterialを作成
- TransparentGreenのシェーダーをLegacyShaders/Transparent/Diffuseに設定
- 同じく、MainColorを以下のように設定
R: 64
G: 255
B: 81
A: 100
###マテリアルを差し替える
次に、オブジェクトに設定されたマテリアルを動的に差し替えるためのスクリプトを作成します。
- 「MaterialChanger」という名前のスクリプトを作成
- MaterialChangerをGoalAreaプレハブにアタッチ
- プロパティの1番目に赤色のマテリアル、2番目に緑色のマテリアルをインスペクタから設定
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MaterialChanger : MonoBehaviour
{
[SerializeField]
private Material[] mMaterialList;
private int mId;
/// <summary>
/// マテリアルを差し替える
/// </summary>
/// <param name="i"></param>
/// <returns>差し替えに成功した場合はtrueを返す</returns>
public bool ChangeMaterial(int i)
{
if (mId == i) return false;
if (i < mMaterialList.Length)
{
mId = i;
GetComponent<Renderer>().material = mMaterialList[mId];
return true;
}
return false;
}
}
これでマテリアルを差し替える機能が完成しました。
###プレイヤーの侵入を判定
プレイヤーがゴールエリアに完全に侵入した場合は緑色のマテリアルを適用し、そうでないならば赤色のマテリアルを適用する仕組みをGoalAreaに実装します。
- 新しく「Player」レイヤーを作成
- PlayerプレハブにPlayerレイヤーを設定(子オブジェクトには設定しない)
- 「GoalArea」という名前のスクリプトを作成し、GoalAreaプレハブにアタッチ
- インスペクタ上でmLayerMaskPlayerプロパティにPlayerレイヤーを指定
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class GoalArea : MonoBehaviour
{
private readonly int CENTER = 0, NORTH = 1, EAST = 2, SOUTH = 3, WEST = 4;
private readonly float THICKNES_HALF = 0.05f; // 四方を囲む壁の厚さの半分
[SerializeField]
private LayerMask mLayerMaskPlayer;
private MaterialChanger mMtlChanger;
private Vector3[] mCenter = new Vector3[5];
private Vector3[] mHalfExtents = new Vector3[5];
void Start()
{
mMtlChanger = GetComponent<MaterialChanger>();
CreateAround();
}
/// <summary>
/// 中心の立方体を囲むように、周囲に厚みのある壁のような領域を生成
/// </summary>
private void CreateAround()
{
mCenter[CENTER] = transform.localPosition;
mHalfExtents[CENTER] = transform.localScale / 2.0f;
float posX = mCenter[CENTER].x;
float posY = mCenter[CENTER].y;
float posZ = mCenter[CENTER].z;
float halfExX = mHalfExtents[CENTER].x;
float halfExY = mHalfExtents[CENTER].y;
float halfExZ = mHalfExtents[CENTER].z;
mCenter[NORTH] = new Vector3(posX, posY, posZ + halfExZ - THICKNES_HALF);
mHalfExtents[NORTH] = new Vector3(halfExX, halfExY, THICKNES_HALF);
mCenter[SOUTH] = new Vector3(posX, posY, posZ - halfExZ + THICKNES_HALF);
mHalfExtents[SOUTH] = new Vector3(halfExX, halfExY, THICKNES_HALF);
mCenter[EAST] = new Vector3(posX - halfExX + THICKNES_HALF, posY, posZ);
mHalfExtents[EAST] = new Vector3(THICKNES_HALF, halfExY, halfExZ);
mCenter[WEST] = new Vector3(posX + halfExX - THICKNES_HALF, posY, posZ);
mHalfExtents[WEST] = new Vector3(THICKNES_HALF, halfExY, halfExZ);
}
/// <summary>
/// プレイヤーがエリアに内包しているか判定し、結果によってエリアの色を変更する
/// </summary>
void Update()
{
if (IsHitOverlapBox(CENTER))
{
for (int i = 1; i < 5; i++)
{
if (IsHitOverlapBox(i))
{
mMtlChanger.ChangeMaterial(0);
return;
}
}
mMtlChanger.ChangeMaterial(1);
}
}
private bool IsHitOverlapBox(int i)
{
Collider[] hits = Physics.OverlapBox(mCenter[i], mHalfExtents[i], Quaternion.identity, mLayerMaskPlayer);
if (hits.Length > 0) return true;
return false;
}
/*
void OnDrawGizmos()
{
Gizmos.color = Color.red;
for(int i=NORTH; i<mCenter.Length; i++)
{
Gizmos.DrawWireCube(mCenter[i], mHalfExtents[i] * 2.0f);
}
}
*/
}
ゴールエリアと同じサイズの立方体と、その四方を囲むように厚みのある壁の領域を用意し、Layer名がPlayerに設定されているコライダーの侵入をそれぞれ判定しています。
その結果が立方体には重なっているけれど四方の壁には重なっていない場合をゴールエリアにすっぽりと入っている状態と判断し、マテリアルを緑色に差し替えています。
コメントアウト部分を外すとシーンビューでは上画像のように四方の壁を示すワイヤーが表示されます。
完全侵入の判定について:
ある領域Aに対して物体Bがすっぽりと入っているのか、それとも部分的に入っているのかという判定をするような
Unityのメソッドがわからず、力業で解決しています。
適切なメソッドを使用することで、もしかしたら1行で判定することができるかもしれません。
プロジェクトを実行して、フォロワー解放とスコアの加算やゴールエリアの挙動を確認してください。
この作品はユニティちゃんライセンス条項の元に提供されています