Xungerrrr's Blog

Unity 3D - 智能巡逻兵

3D 游戏编程与设计 第九周作业

Word count: 3kReading time: 13 min
2018/05/11 Share

游戏要求

  • 游戏设计要求:

    • 创建一个地图和若干巡逻兵(使用动画);
    • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
    • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
    • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
    • 失去玩家目标后,继续巡逻;
    • 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
  • 程序设计要求:

    • 必须使用订阅与发布模式传消息
    • 工厂模式生产巡逻兵

游戏操作方法

使用方向键或 WASD 键操控玩家,躲避巡逻兵追捕。玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞则游戏结束,在倒计时结束后仍未死亡则游戏获胜。

实现思路

订阅与发布模式

添加类 GameEventManager,并在其中定义游戏事件的处理逻辑。这是订阅及发布事件的入口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class GameEventManager : MonoBehaviour
{
// 玩家逃脱事件
public delegate void EscapeEvent(GameObject patrol);
public static event EscapeEvent OnGoalLost;
// 巡逻兵追击事件
public delegate void FollowEvent(GameObject patrol);
public static event FollowEvent OnFollowing;
// 游戏失败事件
public delegate void GameOverEvent();
public static event GameOverEvent GameOver;
// 游戏胜利事件
public delegate void WinEvent();
public static event WinEvent Win;

// 玩家逃脱
public void PlayerEscape(GameObject patrol) {
if (OnGoalLost != null) {
OnGoalLost(patrol);
}
}

// 巡逻兵追击
public void FollowPlayer(GameObject patrol) {
if (OnFollowing != null) {
OnFollowing(patrol);
}
}

// 玩家被捕
public void OnPlayerCatched() {
if (GameOver != null) {
GameOver();
}
}

// 时间结束
public void TimeIsUP() {
if (Win != null) {
Win();
}
}
}

在 FirstSceneController 中订阅事件,将不同的事件交给场记进行响应和处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// FirstSceneController.cs
void OnEnable() {
// 订阅游戏事件
GameEventManager.OnGoalLost += OnGoalLost;
GameEventManager.OnFollowing += OnFollowing;
GameEventManager.GameOver += GameOver;
GameEventManager.Win += Win;
}

void OnDisable() {
GameEventManager.OnGoalLost -= OnGoalLost;
GameEventManager.OnFollowing -= OnFollowing;
GameEventManager.GameOver -= GameOver;
GameEventManager.Win -= Win;
}

对应的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// FirstSceneController.cs
// 失去目标,巡逻兵放弃追击
public void OnGoalLost(GameObject patrol) {
patrolActionManager.Patrol(patrol);
scoreRecorder.Record();
}

// 玩家进入范围,巡逻兵开始追击
public void OnFollowing(GameObject patrol) {
patrolActionManager.Follow(player, patrol);
}

// 失败
public void GameOver() {
gameState = GameState.LOSE;
StopAllCoroutines();
patrolFactory.PausePatrol();
player.GetComponent<Animator>().SetTrigger("death");
patrolActionManager.DestroyAllActions();
}

// 胜利
public void Win() {
gameState = GameState.WIN;
StopAllCoroutines();
patrolFactory.PausePatrol();
player.GetComponent<Animator>().SetBool("pause", true);
}

游戏地图

利用 Plane, Cube 和下载的栅栏资源,设计一个具有九个正方形区域的地图。如下图:

在地图的每个区域中,放置一个空对象,并在对象上添加一个 Box Collider。通过空对象的 EnterRegion 脚本代码,检测玩家或巡逻兵进出区域的事件,并进行相应的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class EnterRegion : MonoBehaviour
{
public int region; // 当前区域的区域编号
FirstSceneController sceneController; // 当前的场记

void OnTriggerEnter(Collider collider) {
sceneController = Director.GetInstance().CurrentSceneController as FirstSceneController;
if (collider.gameObject.tag == "Player") {
// 如果玩家进入区域,则标记玩家当前区域为该区域
sceneController.playerRegion = region;
}
}

private void OnTriggerExit(Collider collider) {
if (collider.gameObject.tag == "Patrol") {
// 如果巡逻兵尝试离开区域,则标记巡逻兵发生了碰撞,以控制转向
collider.gameObject.GetComponent<PatrolData>().isCollided = true;
}
}
}

巡逻兵

在 Asset Store 中搜索 soldier,找到了一个骨骼、动画和贴图都不错的士兵模型包。其中有三种士兵可以选择。

巡逻兵预制的结构如下,由头部和身体两大部分组成:

在 Patrol 上添加一个 Capsule Collider,用于检测巡逻兵与障碍物、玩家的碰撞。在 Bip001 上添加一个 Capsule Collider,用于感知玩家。自定义 Collider 的形状,使巡逻兵具有一定视线范围,只能发现前方区域的玩家。如下图:

巡逻兵数据

1
2
3
4
5
6
7
8
9
public class PatrolData : MonoBehaviour
{
public bool isPlayerInRange; // 玩家是否在侦测范围里
public bool isFollowing; // 是否正在追击
public bool isCollided; // 是否发生碰撞
public int patrolRegion; // 巡逻兵所在区域
public int playerRegion; // 玩家所在区域
public GameObject player; // 所追击的玩家
}

巡逻兵动画控制

利用 shoot 和 pause 两个变量来控制巡逻兵的动画播放。

使用工厂模式生产巡逻兵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class PatrolFactory : MonoBehaviour
{
public GameObject patrol = null;
private List<PatrolData> used = new List<PatrolData>(); // 正在使用的巡逻兵

public List<GameObject> GetPatrols() {
List<GameObject> patrols = new List<GameObject>();
float[] pos_x = { -4.5f, 1.5f, 7.5f };
float[] pos_z = { 7.5f, 1.5f, -4.5f };
// 生产九个巡逻兵
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
patrol = Instantiate(Resources.Load<GameObject>("Prefabs/Patrol"));
patrol.transform.position = new Vector3(pos_x[j], 0, pos_z[i]);
patrol.GetComponent<PatrolData>().patrolRegion = i * 3 + j + 1;
patrol.GetComponent<PatrolData>().playerRegion = 4;
patrol.GetComponent<PatrolData>().isPlayerInRange = false;
patrol.GetComponent<PatrolData>().isFollowing = false;
patrol.GetComponent<PatrolData>().isCollided = false;
patrol.GetComponent<Animator>().SetBool("pause", true);
used.Add(patrol.GetComponent<PatrolData>());
patrols.Add(patrol);
}
}
return patrols;
}

public void PausePatrol() {
// 暂停所有侦查兵的动画
for (int i = 0; i < used.Count; i++) {
used[i].gameObject.GetComponent<Animator>().SetBool("pause", true);
}
}

public void StartPatrol() {
// 开始所有侦查兵的动画
for (int i = 0; i < used.Count; i++) {
used[i].gameObject.GetComponent<Animator>().SetBool("pause", false);
}
}
}

检测巡逻兵的碰撞

在 Patrol 上添加 PatrolCollide,检测巡逻兵的碰撞事件。若巡逻兵碰撞玩家,则游戏结束;若巡逻兵碰撞其他障碍物,则标记碰撞状态,以便在巡逻兵动作中做相应的处理。此处也用到了订阅与发布模式,在游戏结束时发布玩家被捕事件,使订阅了事件的场记能做出相应的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PatrolCollide : MonoBehaviour
{
void OnCollisionEnter(Collision collision) {
if (collision.gameObject.tag == "Player") {
// 当玩家与巡逻兵相撞
this.GetComponent<Animator>().SetTrigger("shoot");
Singleton<GameEventManager>.Instance.OnPlayerCatched();
} else {
// 当巡逻兵碰到其他障碍物
this.GetComponent<PatrolData>().isCollided = true;
}
}
}

感知玩家

在 Bip001 上添加 PlayerInRange,利用 Bip001 上的 Capsule Collider 感知玩家进入追击范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PlayerInRange : MonoBehaviour
{
void OnTriggerEnter(Collider collider) {
if (collider.gameObject.tag == "Player") {
// 玩家进入巡逻兵追捕范围
this.gameObject.transform.parent.GetComponent<PatrolData>().isPlayerInRange = true;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = collider.gameObject;
}
}
void OnTriggerExit(Collider collider) {
if (collider.gameObject.tag == "Player") {
// 玩家离开巡逻兵追捕范围
this.gameObject.transform.parent.GetComponent<PatrolData>().isPlayerInRange = false;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = null;
}
}
}

巡逻兵动作

巡逻兵巡逻动作如下。巡逻兵每次寻找新位置时,会随机选择其附近的一个位置,然后转向并移动到新的位置。若移动过程中碰到障碍物,则向后转,重新选择新的位置。其中采用了订阅与发布模式。当玩家进入追捕范围,巡逻兵开始追捕时,触发 FollowPlayer 事件,通知订阅者采取相应的操作(这里对应的操作是巡逻兵停止巡逻)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class PatrolAction : Action
{
private float pos_x, pos_z; // 移动前的初始x和z方向坐标
private bool turn = true; // 是否选择新方向
private PatrolData data; // 巡逻兵的数据

public static PatrolAction GetAction(Vector3 location) {
PatrolAction action = CreateInstance<PatrolAction>();
action.pos_x = location.x;
action.pos_z = location.z;
return action;
}

public override void Start() {
data = this.gameObject.GetComponent<PatrolData>();
}

public override void Update() {
if (Director.GetInstance().CurrentSceneController.getGameState().Equals(GameState.RUNNING)) {
// 巡逻兵巡逻
Patrol();
// 如果满足要求,而巡逻兵未开始追击,则停止巡逻,开始追击
if (!data.isFollowing && data.isPlayerInRange && data.patrolRegion == data.playerRegion && !data.isCollided) {
this.destroy = true;
this.enable = false;
this.callback.ActionEvent(this);
this.gameObject.GetComponent<PatrolData>().isFollowing = true;
Singleton<GameEventManager>.Instance.FollowPlayer(this.gameObject);
}
}
}

void Patrol() {
if (turn) {
// 随机选择新位置
pos_x = this.transform.position.x + Random.Range(-5f, 5f);
pos_z = this.transform.position.z + Random.Range(-5f, 5f);
this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
this.gameObject.GetComponent<PatrolData>().isCollided = false;
turn = false;
}
float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z));

if (this.gameObject.GetComponent<PatrolData>().isCollided) {
// 碰撞,则向后转,寻找新位置
this.transform.Rotate(Vector3.up, 180);
GameObject temp = new GameObject();
temp.transform.position = this.transform.position;
temp.transform.rotation = this.transform.rotation;
temp.transform.Translate(0, 0, Random.Range(0.5f, 3f));
pos_x = temp.transform.position.x;
pos_z = temp.transform.position.z;
this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
this.gameObject.GetComponent<PatrolData>().isCollided = false;
Destroy(temp);
} else if (distance <= 0.1) {
// 到达目的,重新选择位置
turn = true;
} else {
// 向前移动巡逻兵
this.transform.Translate(0, 0, Time.deltaTime);
}
}
}

巡逻兵追捕玩家的动作如下。其中采用了订阅与发布模式。当玩家离开追捕范围,巡逻兵放弃追捕时,触发 PlayerEscape 事件,通知订阅者采取相应的操作(这里对应的操作是巡逻兵开始巡逻,游戏增加一分)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class PatrolFollowAction : Action
{
private float speed = 1.5f; // 跟随玩家的速度
private GameObject player; // 玩家
private PatrolData data; // 巡逻兵数据

public static PatrolFollowAction GetAction(GameObject player) {
PatrolFollowAction action = CreateInstance<PatrolFollowAction>();
action.player = player;
return action;
}

public override void Start() {
data = this.gameObject.GetComponent<PatrolData>();
}

public override void Update() {
if (Director.GetInstance().CurrentSceneController.getGameState().Equals(GameState.RUNNING)) {
// 追击玩家
transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
this.transform.LookAt(player.transform.position);
// 如果满足要求,而巡逻兵正在追击,则停止追击,开始巡逻
if (data.isFollowing && (!(data.isPlayerInRange && data.patrolRegion == data.playerRegion) || data.isCollided)) {
this.destroy = true;
this.enable = false;
this.callback.ActionEvent(this);
this.gameObject.GetComponent<PatrolData>().isFollowing = false;
Singleton<GameEventManager>.Instance.PlayerEscape(this.gameObject);
}
}
}
}

通过 PatrolActionManager 统一管理巡逻兵的两个动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PatrolActionManager : ActionManager, ActionCallback
{
public PatrolAction patrol;
public PatrolFollowAction follow;

// 巡逻
public void Patrol(GameObject ptrl) {
this.patrol = PatrolAction.GetAction(ptrl.transform.position);
this.RunAction(ptrl, patrol, this);
}

// 追击
public void Follow(GameObject player, GameObject patrol) {
this.follow = PatrolFollowAction.GetAction(player);
this.RunAction(patrol, follow, this);
}

//停止所有动作
public void DestroyAllActions() {
DestroyAll();
}

public void ActionEvent(Action source, ActionEventType events = ActionEventType.Completed, int intParam = 0, string strParam = null, object objectParam = null){ }
}

玩家

玩家预制的结构如下,由头部、身体和背包三大部分组成。在顶层添加 Capsule Collider,检测玩家的碰撞。

玩家动画控制

利用 run、pause 和 death 变量来控制玩家动画播放。

移动玩家

在 UserGUI 类中的 Update 方法获取键盘方向输入,并调用 UserAction 中的 MovePlayer 方法实现玩家的移动。

1
2
3
4
5
6
7
8
9
10
11
12
// UserGUI.cs
private void Update() {
action = Director.GetInstance().CurrentSceneController as UserAction;
controller = Director.GetInstance().CurrentSceneController as SceneController;
if (controller.getGameState().Equals(GameState.RUNNING)) {
// 获取键盘输入
float translationX = Input.GetAxis("Horizontal");
float translationZ = Input.GetAxis("Vertical");
//移动玩家
action.MovePlayer(translationX, translationZ);
}
}

在 FirstSceneController 中实现 MovePlayer 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// FirstSceneController.cs
public void MovePlayer(float translationX, float translationZ) {
if (translationX != 0 || translationZ != 0) {
player.GetComponent<Animator>().SetBool("run", true);
} else {
player.GetComponent<Animator>().SetBool("run", false);
}
translationX *= Time.deltaTime;
translationZ *= Time.deltaTime;

// 转向指定的方向并向前移动
player.transform.LookAt(new Vector3(player.transform.position.x + translationX, player.transform.position.y, player.transform.position.z + translationZ));
if (translationX == 0)
player.transform.Translate(0, 0, Mathf.Abs(translationZ) * 2);
else if (translationZ == 0)
player.transform.Translate(0, 0, Mathf.Abs(translationX) * 2);
else
player.transform.Translate(0, 0, Mathf.Abs(translationZ) + Mathf.Abs(translationX));
}

游戏倒计时

在导演中控制游戏倒计时,初始时间为 60 秒。当倒计时结束时,发布 TimeIsUp 事件,使订阅了事件的场记能够做出响应(游戏胜利)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Director : System.Object
{
public int leaveSeconds = 60; // 当前剩余时间
// ...
// 游戏倒计时
public IEnumerator CountDown() {
while (leaveSeconds > 0) {
yield return new WaitForSeconds(1f);
leaveSeconds--;
if (leaveSeconds == 0) {
Singleton<GameEventManager>.Instance.TimeIsUP();
}
}
}
}

镜头跟随

在 CameraFollowAction 中控制镜头跟随玩家移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CameraFollowAction : MonoBehaviour
{
public GameObject player; //相机跟随的物体
public float smothing = 5f; //相机跟随的平滑速度
Vector3 offset; //相机与物体相对偏移位置

void Start() {
offset = new Vector3(0, 5, -5);
}

void FixedUpdate() {
// 设置摄像机目标位置
Vector3 target = player.transform.position + offset;
// 摄像机自身位置到目标位置平滑过渡
transform.position = Vector3.Lerp(transform.position, target, smothing * Time.deltaTime);
}
}

游戏效果预览

游戏视频

http://v.youku.com/v_show/id_XMzYwNTg1OTA1Mg==.html?spm=a2h3j.8428770.3416059.1

项目地址

GitHub.

参考博客

[1] Unity3d 学习之路 - 简单巡逻兵.

[2] [Unity3D 课堂作业] 巡逻兵 GetAwayFromPatrols.

返回 Unity 3D Learning

CATALOG
  1. 1. 游戏要求
  2. 2. 游戏操作方法
  3. 3. 实现思路
    1. 3.1. 订阅与发布模式
    2. 3.2. 游戏地图
    3. 3.3. 巡逻兵
      1. 3.3.1. 巡逻兵数据
      2. 3.3.2. 巡逻兵动画控制
      3. 3.3.3. 使用工厂模式生产巡逻兵
      4. 3.3.4. 检测巡逻兵的碰撞
      5. 3.3.5. 感知玩家
      6. 3.3.6. 巡逻兵动作
    4. 3.4. 玩家
      1. 3.4.1. 玩家动画控制
      2. 3.4.2. 移动玩家
    5. 3.5. 游戏倒计时
    6. 3.6. 镜头跟随
  4. 4. 游戏效果预览
  5. 5. 游戏视频
  6. 6. 项目地址
  7. 7. 参考博客