Unity3d學習之路-簡單巡邏兵
簡單巡邏兵
遊戲規則與遊戲要求
遊戲規則
使用WSAD或方向鍵上下左右移動player,進入巡邏兵的追捕後逃脫可積累一分,若與巡邏兵碰撞則遊戲結束,收集完地圖上的所有水晶即可獲勝。遊戲設計要求:
- 建立一個地圖和若干巡邏兵(使用動畫);
- 每個巡邏兵走一個3~5個邊的凸多邊型,位置資料是相對地址。即每次確定下一個目標位置,用自己當前位置為原點計算;
- 巡邏兵碰撞到障礙物,則會自動選下一個點為目標;
- 巡邏兵在設定範圍內感知到玩家,會自動追擊玩家;
- 失去玩家目標後,繼續巡邏;
- 計分:玩家每次甩掉一個巡邏兵計一分,與巡邏兵碰撞遊戲結束;
程式設計要求:
- 必須使用訂閱與釋出模式傳訊息
- 工廠模式生產巡邏兵
遊戲UML類圖
新增了訂閱與釋出模式
遊戲實現
巡邏兵部分
巡邏兵部分實現了進行矩形路線的自動巡邏移動,當玩家進入它的觸發器範圍後,如果玩家當前在自己巡邏區域內,則跟隨追捕玩家。如果巡邏兵與玩家碰撞,則雙方都播放碰撞後動畫,遊戲結束。
巡邏兵預製體
為巡邏兵新增兩個
Collider
,一個是Capsule Collider
,新增在預製體父節點上,用於檢測巡邏兵與玩家的碰撞。另一個是Box Collider
,新增在預製體的子節點上,用於檢測玩家進入巡邏兵巡邏的範圍
巡邏兵建立
- PatrolData
儲存了巡邏兵的一些基本資料
public class PatrolData : MonoBehaviour
{
public int sign; //標誌巡邏兵在哪一塊區域
public bool follow_player = false; //是否跟隨玩家
public int wall_sign = -1; //當前玩家所在區域標誌
public GameObject player; //玩家遊戲物件
public Vector3 start_position; //當前巡邏兵初始位置
}
- PropFactory
道具工廠,建立了9個巡邏兵,因為巡邏兵的位置有規律所以用迴圈就可以賦值初始位置,還設定了每個巡邏兵所在區域的標誌。當遊戲結束時候,需要工廠將巡邏兵的動畫設定為初始狀態。部分程式碼如下
public List<GameObject> GetPatrols()
{
int[] pos_x = { -6, 4, 13 };
int[] pos_z = { -4, 6, -13 };
int index = 0;
//生成不同的巡邏兵初始位置
for(int i=0;i < 3;i++)
{
for(int j=0;j < 3;j++)
{
vec[index] = new Vector3(pos_x[i], 0, pos_z[j]);
index++;
}
}
for(int i=0; i < 9; i++)
{
patrol = Instantiate(Resources.Load<GameObject>("Prefabs/Patrol"));
patrol.transform.position = vec[i];
patrol.GetComponent<PatrolData>().sign = i + 1;
patrol.GetComponent<PatrolData>().start_position = vec[i];
used.Add(patrol);
}
return used;
}
public void StopPatrol()
{
//切換所有巡邏兵的動畫
for (int i = 0; i < used.Count; i++)
{
used[i].gameObject.GetComponent<Animator>().SetBool("run", false);
}
}
巡邏兵巡邏與追捕
- GoPatrolAction
巡邏兵巡邏的動作,根據四個方向來選擇要去到的目的地,噹噹前位置與目的地相差0.9f的時候,換一個方向繼續巡邏
public class GoPatrolAction : SSAction
{
private enum Dirction { EAST, NORTH, WEST, SOUTH };
private float pos_x, pos_z; //移動前的初始x和z方向座標
private float move_length; //移動的長度
private float move_speed = 1.2f; //移動速度
private bool move_sign = true; //是否到達目的地
private Dirction dirction = Dirction.EAST; //移動的方向
private PatrolData data; //巡邏兵的資料
private GoPatrolAction() { }
public static GoPatrolAction GetSSAction(Vector3 location)
{
GoPatrolAction action = CreateInstance<GoPatrolAction>();
action.pos_x = location.x;
action.pos_z = location.z;
//設定移動矩形的邊長
action.move_length = Random.Range(4, 7);
return action;
}
public override void Update()
{
//防止碰撞發生後的旋轉
if (transform.localEulerAngles.x != 0 || transform.localEulerAngles.z != 0)
{
transform.localEulerAngles = new Vector3(0, transform.localEulerAngles.y, 0);
}
if (transform.position.y != 0)
{
transform.position = new Vector3(transform.position.x, 0, transform.position.z);
}
//巡邏兵移動
Gopatrol();
//如果巡邏兵需要跟隨玩家並且玩家就在偵察兵所在的區域,偵查動作結束
if (data.follow_player && data.wall_sign == data.sign)
{
this.destroy = true;
this.callback.SSActionEvent(this,0,this.gameobject);
}
}
public override void Start()
{
this.gameobject.GetComponent<Animator>().SetBool("run", true);
data = this.gameobject.GetComponent<PatrolData>();
}
void Gopatrol()
{
if (move_sign)
{
//不需要轉向則設定一個目的地,按照矩形移動
switch (dirction)
{
case Dirction.EAST:
pos_x -= move_length;
break;
case Dirction.NORTH:
pos_z += move_length;
break;
case Dirction.WEST:
pos_x += move_length;
break;
case Dirction.SOUTH:
pos_z -= move_length;
break;
}
move_sign = false;
}
this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z));
//當前位置與目的地距離浮點數的比較
if (distance > 0.9)
{
transform.position = Vector3.MoveTowards(this.transform.position, new Vector3(pos_x, 0, pos_z), move_speed * Time.deltaTime);
}
else
{
dirction = dirction + 1;
if(dirction > Dirction.SOUTH)
{
dirction = Dirction.EAST;
}
move_sign = true;
}
}
}
- PatrolFollowAction
巡邏兵朝著玩家的位置移動,移動結束的條件是玩家離開了巡邏兵觸發器的範圍或是玩家已經不在該區域內了
public class PatrolFollowAction : SSAction
{
private float speed = 2f; //跟隨玩家的速度
private GameObject player; //玩家
private PatrolData data; //偵查兵資料
private PatrolFollowAction() { }
public static PatrolFollowAction GetSSAction(GameObject player)
{
PatrolFollowAction action = CreateInstance<PatrolFollowAction>();
action.player = player;
return action;
}
public override void Update()
{
if (transform.localEulerAngles.x != 0 || transform.localEulerAngles.z != 0)
{
transform.localEulerAngles = new Vector3(0, transform.localEulerAngles.y, 0);
}
if (transform.position.y != 0)
{
transform.position = new Vector3(transform.position.x, 0, transform.position.z);
}
Follow();
//如果偵察兵沒有跟隨物件,或者需要跟隨的玩家不在偵查兵的區域內
if (!data.follow_player || data.wall_sign != data.sign)
{
this.destroy = true;
this.callback.SSActionEvent(this,1,this.gameobject);
}
}
public override void Start()
{
data = this.gameobject.GetComponent<PatrolData>();
}
void Follow()
{
transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
this.transform.LookAt(player.transform.position);
}
}
- SSActionManager
從上面可以看到,巡邏動作結束條件是需要追捕玩家,所以呼叫了回撥函式,用回撥函式來進行追捕動作。而當玩家離開追捕範圍後,需要重新巡邏,也需要呼叫回撥函式,從初始的位置和方向繼續巡邏。除此之外,SSActionManager還實現了遊戲結束後,摧毀所有動作,巡邏兵不再移動。部分程式碼如下
public void SSActionEvent(SSAction source, int intParam = 0, GameObject objectParam = null)
{
if(intParam == 0)
{
//偵查兵跟隨玩家
PatrolFollowAction follow = PatrolFollowAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().player);
this.RunAction(objectParam, follow, this);
}
else
{
//偵察兵按照初始位置開始繼續巡邏
GoPatrolAction move = GoPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().start_position);
this.RunAction(objectParam, move, this);
//玩家逃脫
Singleton<GameEventManager>.Instance.PlayerEscape();
}
}
public void DestroyAll()
{
foreach (KeyValuePair<int, SSAction> kv in actions)
{
SSAction ac = kv.Value;
ac.destroy = true;
}
}
- PatrolActionManager
初始的時候場景控制器呼叫PatrolActionManager中的方法,讓巡邏兵開始巡邏,當遊戲結束的時候,呼叫方法讓巡邏兵停止巡邏
public class PatrolActionManager : SSActionManager
{
private GoPatrolAction go_patrol; //巡邏兵巡邏
public void GoPatrol(GameObject patrol)
{
go_patrol = GoPatrolAction.GetSSAction(patrol.transform.position);
this.RunAction(patrol, go_patrol, this);
}
//停止所有動作
public void DestroyAllAction()
{
DestroyAll();
}
}
玩家部分
該部分實現了玩家上下移動和左右旋轉,並播放對應的動畫以及相機的跟隨。(相機跟隨與上一個遊戲程式碼一樣)
- UserGUI
在UserGUI得到使用者的輸入,然後呼叫場景控制器移動玩家的函式。部分程式碼如下
void Update()
{
//獲取方向鍵的偏移量
float translationX = Input.GetAxis("Horizontal");
float translationZ = Input.GetAxis("Vertical");
//移動玩家
action.MovePlayer(translationX, translationZ);
}
- FirstSceneController
獲得偏移量進行上下的移動,左右的旋轉,播放對應的動畫。部分程式碼如下
//玩家移動
public void MovePlayer(float translationX, float translationZ)
{
if(!game_over)
{
if (translationX != 0 || translationZ != 0)
{
player.GetComponent<Animator>().SetBool("run", true);
}
else
{
player.GetComponent<Animator>().SetBool("run", false);
}
//移動和旋轉
player.transform.Translate(0, 0, translationZ * player_speed * Time.deltaTime);
player.transform.Rotate(0, translationX * rotate_speed * Time.deltaTime, 0);
//防止碰撞帶來的移動
if (player.transform.localEulerAngles.x != 0 || player.transform.localEulerAngles.z != 0)
{
player.transform.localEulerAngles = new Vector3(0, player.transform.localEulerAngles.y, 0);
}
if (player.transform.position.y != 0)
{
player.transform.position = new Vector3(player.transform.position.x, 0, player.transform.position.z);
}
}
}
區域部分
遊戲場景中有9個格子,每個格子為一個區域,每個區域設定了一個
Box Collider
用於檢測玩家是否進入該區域,(防止玩家在另一個區域但是進入了其他區域的巡邏兵的觸發器時,巡邏兵隔著牆去追捕玩家的情況)
- AreaCollide
每個區域有自己的標識(將指令碼掛載在每個區域上進行設定),當玩家進入該區域的時候,會設定場景控制器的區域標識為自己的標識,這樣其他的巡邏兵就知道玩家現在在哪個區域了
public class AreaCollide : MonoBehaviour
{
public int sign = 0;
FirstSceneController sceneController;
private void Start()
{
sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
}
void OnTriggerEnter(Collider collider)
{
//標記玩家進入自己的區域
if (collider.gameObject.tag == "Player")
{
sceneController.wall_sign = sign;
}
}
}
訂閱與釋出模式部分
該部分的數值變化是通過訂閱與釋出模式實現的,FirstSceneController是模式中的訂閱者。
釋出事件類
- GameEventManager
定義一個專門釋出事件的類,訂閱者可以訂閱該類的事件,當其他類發生改變的時候,會使用GameEventManager的方法釋出訊息,觸發相應事件
public class GameEventManager : MonoBehaviour
{
//分數變化
public delegate void ScoreEvent();
public static event ScoreEvent ScoreChange;
//遊戲結束變化
public delegate void GameoverEvent();
public static event GameoverEvent GameoverChange;
//水晶數量變化
public delegate void CrystalEvent();
public static event CrystalEvent CrystalChange;
//玩家逃脫
public void PlayerEscape()
{
if (ScoreChange != null)
{
ScoreChange();
}
}
//玩家被捕
public void PlayerGameover()
{
if (GameoverChange != null)
{
GameoverChange();
}
}
//減少水晶數量
public void ReduceCrystalNum()
{
if (CrystalChange != null)
{
CrystalChange();
}
}
}
訂閱者
- FirstSceneController
場景控制器作為訂閱者,訂閱了
GameEventManager
中的事件,只要相應事件發生,就會導致場景控制器呼叫註冊的方法,部分程式碼如下
void OnEnable()
{
//註冊事件
GameEventManager.ScoreChange += AddScore;
GameEventManager.GameoverChange += Gameover;
GameEventManager.CrystalChange += ReduceCrystalNumber;
}
void OnDisable()
{
//取消註冊事件
GameEventManager.ScoreChange -= AddScore;
GameEventManager.GameoverChange -= Gameover;
GameEventManager.CrystalChange -= ReduceCrystalNumber;
}
void ReduceCrystalNumber()
{
recorder.ReduceCrystal();
}
void AddScore()
{
recorder.AddScore();
}
void Gameover()
{
game_over = true;
patrol_factory.StopPatrol();
action_manager.DestroyAllAction();
}
水晶觸碰
- CrystalCollide
當玩家觸碰到水晶的時候,水晶消失,水晶數量減少
public class CrystalCollide : MonoBehaviour
{
void OnTriggerEnter(Collider collider)
{
if (collider.gameObject.tag == "Player" && this.gameObject.activeSelf)
{
this.gameObject.SetActive(false);
//減少水晶數量,釋出訊息
Singleton<GameEventManager>.Instance.ReduceCrystalNum();
}
}
}
玩家擺脫巡邏兵
- SSActionManager
當玩家逃離巡邏兵觸發器的範圍的時候(此時巡邏兵的跟隨玩家的動作會呼叫回撥函式),分數會增加
public void SSActionEvent(SSAction source, int intParam = 0, GameObject objectParam = null)
{
if(intParam == 0)
{
//偵查兵跟隨玩家
PatrolFollowAction follow = PatrolFollowAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().player);
this.RunAction(objectParam, follow, this);
}
else
{
//偵察兵按照初始位置開始繼續巡邏
GoPatrolAction move = GoPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().start_position);
this.RunAction(objectParam, move, this);
//玩家逃脫,釋出訊息
Singleton<GameEventManager>.Instance.PlayerEscape();
}
}
玩家和巡邏兵碰撞
- PlayerCollide
巡邏兵身上的碰撞器觸碰到玩家的碰撞器將會使遊戲結束,並且各自播放對應動畫
public class PlayerCollide : MonoBehaviour
{
void OnCollisionEnter(Collision other)
{
//當玩家與巡邏兵相撞
if (other.gameObject.tag == "Player")
{
other.gameObject.GetComponent<Animator>().SetTrigger("death");
this.GetComponent<Animator>().SetTrigger("shoot");
//遊戲結束,釋出訊息
Singleton<GameEventManager>.Instance.PlayerGameover();
}
}
}
上述就是訂閱與釋出模式所涉及到的類,如果對該模式還不是很理解,拿出上面的一種情況當做一個例子:假如大明星(
CrystalCollide
)的動態都由公眾號(GameEventManager
)來發布,小粉絲(FirstSceneController
)訂閱了公眾號(GameEventManager
)的明星掉粉(CrystalChange
)這個事件,並且為掉粉事件註冊了哭泣(ReduceCrystalNumber
)這個方法。那麼大明星(CrystalCollide
)釋出訊息給公眾號(呼叫公眾號的ReduceCrystalNum
方法)說它掉粉了,則公眾號(GameEventManager
)看有沒有人訂閱掉粉了這個事件,如果有人訂閱則告訴它明星掉粉了(觸發事件CrystalChange
),那訂閱了該事件的小粉絲(FirstSceneController
)就會哭泣(呼叫自己的ReduceCrystalNumber
方法)。
音樂部分
該部分實現了迴圈播放背景音樂以及與巡邏兵觸碰時候發出碰撞的聲音,設定了一個音樂管理者
- AudioManager
音樂管理者有自己的音訊,然後在觸發了某種狀態的時候播放對應的音樂,本次遊戲在音樂管理者中只有一個碰撞時候的音訊,使用了一個靜態方法
PlayClipAtPoint
,可以在設定的位置播放設定音量的音訊片段,詳見官方文件或部落格連結。使用這個函式播放音樂的時候會自動生成一個名為”One shot audio”的物體,在播放完一次音訊後銷燬這個物體
public class AudioManager : MonoBehaviour
{
public AudioClip gameoverClip;
public void PlayMusic(AudioClip clip)
{
FirstSceneController scene = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
//在一個玩家的位置播放音樂
AudioSource.PlayClipAtPoint(clip, scene.player.transform.position);
}
void OnEnable()
{
GameEventManager.GameoverChange += Gameover;
}
void OnDisable()
{
GameEventManager.GameoverChange -= Gameover;
}
//播放遊戲結束時音樂
void Gameover()
{
PlayMusic(gameoverClip);
}
}
- Main Camera
因為PlayClipAtPoint只是播放一次音樂,對於bgm是需要迴圈播放的,所以在Main Camera新增元件
Audio Source
和Audio Listener
,詳見官方文件AudioSource和AudioListener。如下圖設定
補充
遊戲執行時候,指令碼的執行順序不太一樣,為了防止在其他需要用到
FirstSceneController
的指令碼在FirstSceneController
執行之前就已經執行了,所以我進行了下面的設定,將FirstSceneController
的執行順序提到了最前面。
實現效果
小結
這次的遊戲使用了委託和事件,至少是從完全不懂到已經理解的地步了吧。釋出與訂閱模式定義了一種一對多的依賴關係,實現了讓多個訂閱者物件(本次遊戲只有FirstSceneController)同時監聽某一個主題物件。這個物件在狀態發生變化時會通知所有訂閱者物件,使它們能夠自動更新自己。
完整專案請點選傳送門,Assets/Scenes/中的myScene是本次遊戲場景