Week09-Animator & View Pattern-patrols

gameView

游戏设计要求

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

设计要点

  • 关于游戏对象动作的控制,继承SSAction(动作基类)定义巡逻兵巡逻和追逐两个动作;
  • 对于玩家直接在FirstController中实现movePlayer方法根据UserGUI传入的键盘输入数据控制玩家的移动即可;
  • 这次任务的关键是控制游戏对象和游戏对象的接触、游戏对象和场景的接触(包括collision和trigger);
  • 动画的控制取store找一些models,自己建立Animator并在代码中设置相应参数即可;
  • 本游戏中引入了一种在之前作业中没有过的观察者模式,即事件的发布者与订阅者,采用的方法是构建GameEventManager管理所有事件,发布者在特定情况下发布GameEventManager中相应事件,订阅者用OnEnable()对相关事件进行响应。

实现过程

UML图

UML

场景搭建

我设计了六个格子的场景,用栅栏分割,并防止树木作为障碍物,在距离player最远的格子设置终点——宝箱。

障碍物属性

为了增加游戏的挑战性,我对巡逻兵的巡逻路线是随机设定的,不是沿着固定的矩形行走,因而会导致巡逻兵撞障碍物的事件发生。我用横三纵四,共计7个tage为gridtriggered collider解决了这一问题。

3*4

方法是用观察者模式,如果patrol进入到tag == "grid"的触发器中,则发布hitWall事件,并传递撞墙patrol给订阅者。订阅者在patrolAction中获得具体的patrol信息,并在动作中让其掉头。

区域划分

考虑到巡逻兵应当在自己的管辖范围内巡逻,也就是说,巡逻兵不应走出巡逻区域,因而给每个格子设置了一个透明度collider。

每个collider和在其范围内巡逻的一个巡逻兵有相同的号码,player当前所在的区域号码记录在FirstController的CurSec字段中(后用于判断追逐条件时,巡逻兵应和player在同一区域)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AreaCollider : MonoBehaviour {
public int sign;
FirstController sceneController;
void Start () {
sceneController = (FirstController)SSDirector.GetInstance().currentSceneController;
}

void OnTriggerEnter (Collider collider) {
if(collider.gameObject.tag == "Player") {
sceneController.curAreaSign = sign;
}
}
}

这里出过一个小Bug,由于AreaCollider是直接挂在在场景中的collider上的,如果firstController的Start比这里Start后被调用,则会出现sceneControllernull的情况,我使用把FirstController中的Start改为Awake解决的这一问题。

巡逻兵数据

1
2
3
4
5
6
7
public class PatrolData : MonoBehaviour
{
public int PatSec; //标志巡逻兵在哪一块区域
public int PlySec = -1; //当前玩家所在区域标志
public Vector3 startPosition; //当前巡逻兵初始位置
...
}

巡逻兵工厂

这里和飞碟工厂近似,重要是初始化时参数的设计。比如由于player最开始的位置是固定的,所以在该格子随机巡逻兵的初始位置时要控制其不能距离player太近:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PatrolFactory : MonoBehaviour {
private List<GameObject> products = new List<GameObject> ();
public List<GameObject> getPatrols() {
for(int i = 0; i < 6; i++) {
GameObject newPatrol = Instantiate(Resources.Load<GameObject>("Prefabs/Patrol"), new Vector3(0,0,0), Quaternion.identity) as GameObject;
if(i == 2) {
newPatrol.transform.position = new Vector3(-30 + 20*(i%3) + 10 + Random.Range(-3, 0), 0, -20 + + 20*(i/3) + 10 + Random.Range(0, 3));
}
else
newPatrol.transform.position = new Vector3(-30 + 20*(i%3) + 10 + Random.Range(-3, 3), 0, -20 + + 20*(i/3) + 10 + Random.Range(-3, 3));

newPatrol.AddComponent<PatrolData>();
newPatrol.GetComponent<PatrolData>().startPosition = newPatrol.transform.position;
newPatrol.GetComponent<PatrolData>().rangeX = -30 + 20*(i%3);
newPatrol.GetComponent<PatrolData>().rangeZ = -20 + 20*(i/3);
newPatrol.GetComponent<PatrolData>().PatSec = i+1;
products.Add(newPatrol);
}
return products;
}
}

巡逻兵动作

Patrolling

这个函数的关键在于要根据巡逻兵当前的朝向生成他下一步移动的目标,还有要处理巡逻兵离目标位置过远的情况以控制巡逻兵的巡逻范围。

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PatrolAction : SSAction {
// 巡逻四个方向
private enum Direction {backward, right, forward, left};
// 目的地坐标
private float posX, posZ;
// 随机步长
private float stepLength;
public float speed = 5f;
// 常规巡逻根据方向走矩形,否则就要直接向目的地靠近
private bool regular = true;
private Direction direction = Direction.forward;
private PatrolData data;
private FirstController sceneController;
...

private void patrolling() {
if(!handle) { //这里暂时不用管,在处理撞墙事件的时候不进行巡逻操作
if(regular) {
// 用朝向和随机步长确定下一小节的目标位置
switch(direction) {
case Direction.forward:
posZ -= stepLength;
break;
case Direction.backward:
posZ += stepLength;
break;
case Direction.left:
posX -= stepLength;
break;
case Direction.right:
posX += stepLength;
break;
}
regular = false;
}

gameobject.transform.LookAt(new Vector3(posX, 0, posZ));
// 如果巡逻兵走远了,向巡逻中心靠拢
if (Vector3.Distance(gameobject.transform.position, new Vector3 (posX, 0, posZ)) > 1) {
// 走远
gameobject.transform.position = Vector3.MoveTowards(gameobject.transform.position, new Vector3(posX,0,posZ), Time.deltaTime*speed);
}
else {
// 拐弯
direction = direction + 1;
if(direction > Direction.left)
direction = Direction.backward;
regular = true;
}
}
}
}

Following

巡逻动作比较简单,一直moveTowards(player)即可。

1
2
3
4
5
6
7
8
9
10
11
12
public class PatrolFollowAction : SSAction {
public float speed = 3f;
private GameObject player;
private int stepLength;
private PatrolData data;
private FirstController sceneController;

private void follow() {
this.gameobject.transform.LookAt(sceneController.player.transform.position);
this.gameobject.transform.position = Vector3.MoveTowards(gameobject.transform.position, sceneController.player.transform.position, Time.deltaTime*speed);
}
}

Player控制

这里是用物理引擎控制的,其实直接修改transform.position进行移动会更好控制(鉴于这和Roll a Ball不同之处在于ball是用键盘施加力后一直存在,而player是只有键盘有输入的时候才会移动。虽然这里用velocity属性可以解决这个问题,但与其这样不如用transform.position。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FirstController : MonoBehaviour, ISceneController, IUserAction {
...

public void movePlayer (float inputX, float inputZ) {
player.transform.LookAt(player.transform.position + 10000 * new Vector3(inputX, 0, inputZ));

// 如果玩家让player移动,则player的动画为奔跑状态
if(inputX != 0 || inputZ != 0) {
player.GetComponent<Animator>().SetBool("run", true);
Vector3 movement = new Vector3(inputX, 0, inputZ);
player.GetComponent<Rigidbody>().velocity = new Vector3(inputX*playerSpeed, 0, inputZ*playerSpeed) ;
}
}
}

在UserGUI中用FixedUpdate接收键盘输入并传入movePlayer函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserGUI : MonoBehaviour {

IUserAction userAction;

void FixedUpdate () {
if(userAction.getGameState() == 0) {
float inputX = Input.GetAxis("Horizontal");
float inputZ = Input.GetAxis("Vertical");
//移动玩家
userAction.movePlayer(inputX, inputZ);
}

}
...
}

防止碰撞带来的移动和旋转

这是刚刚实现好上述代码,初步开始玩游戏时遇到的坑。无论是player还是巡逻兵,撞到障碍物会反弹、旋转甚至飞起。

所以需要在FirstController中加入以下代码对player的角度和y轴坐标进行控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if(player.transform.position.y != 0) {
player.transform.position = new Vector3(player.transform.position.x, 0,player.transform.position.z);
}

if(player.transform.rotation.x != 0 || player.transform.rotation.z != 0) {
switch((int)(player.transform.rotation.y*10)) {
case 0:
player.transform.rotation = Quaternion.Euler(new Vector3(0,0,0));
break;
case 7:
player.transform.rotation = Quaternion.Euler(new Vector3(0,90,0));
break;
case 9:
player.transform.rotation = Quaternion.Euler(new Vector3(0,180,0));
break;
case -7:
player.transform.rotation = Quaternion.Euler(new Vector3(0,-90,0));
break;
}
}

对于Patrol对象,在两个动作:PatrolActionPatrolFollowAction中加入类似函数即可。这里不多赘述。

Patrol的动作切换

本游戏中Patrol有两个动作分别为patrolling和following,正常情况下六个巡逻兵均在patrolling状态,只有当player到达某一section且距离该section的巡逻兵足够近的时候才会使该Patrol进行follow动作;同理,当player在被某区域巡逻兵follow的情况下,player逃离该区域或距离该patrol足够远都会使该patrol停止追逐继续巡逻。

为了使动作管理器更好地管理这两个动作,采用向callback.SSActionEvent()中传递参数来制定当前是什么动作(1->patrolling, 0->following). 这里注意当传入下一个动作的时候需要将当前动作摧毁掉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//PatrolAction.cs
public override void Update () {
...
if(sceneController.curAreaSign == this.gameobject.GetComponent<PatrolData>().PatSec &&
Vector3.Distance( sceneController.player.transform.position, this.gameobject.transform.position) < 7) {
this.destroy = true;
this.callback.SSActionEvent(this,0,this.gameobject);
}
}
//PatrolFollowAction.cs
public override void Update() {
...
if(sceneController.curAreaSign != data.PatSec || Vector3.Distance(
this.gameobject.transform.position, sceneController.player.transform.position) >= 7) {
this.destroy = true;
this.callback.SSActionEvent(this, 1, this.gameobject);
}
}

还需要在SSActioManager中实现ISSActionCallback接口的void SSActionEvent(SSAction source,int intParam = 0, GameObject objectParam = null)方法对传入的两个参数进行响应:

1
2
3
4
5
6
7
8
9
10
11
12
public void SSActionEvent(SSAction source,  
int intParam,
GameObject objectParam) {
if(intParam == 0) {
PatrolFollowAction follow = PatrolFollowAction.getSSAction(objectParam);
this.RunAction(objectParam, follow, this);
}
else {
PatrolAction move = PatrolAction.getSSAction(objectParam.transform.position);
this.RunAction(objectParam, move, this);
}
}

订阅与发布模式

整个游戏过程中主要涉及到三个事件:输、赢、撞墙(在场景搭建中提到过这个问题)。

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
public class GameEventManager : MonoBehaviour {
public delegate void GameOverEvent();
public static event GameOverEvent gameoverChange;

public delegate void winEvent();
public static event winEvent win;

public delegate void HitWall(GameObject a);
public static event HitWall hitWall;

public void gameOver() {
if(gameoverChange != null) {
gameoverChange();
}
}

public void hitWallEvent(GameObject a) {
if(hitWall != null) {
hitWall(a);
}
}

public void winning() {
if(win != null) {
win();
}
}
}

输的实现方法是FirstController订阅GameOver事件,该事件的发布者为挂在patrol上的playerCollider脚本,代码如下:

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
//PlayerCollider.cs
void OnCollisionEnter(Collision other) {
if(other.gameObject.tag == "Player") {
other.gameObject.GetComponent<Animator> ().SetBool("death", true);
other.gameObject.GetComponent<Rigidbody>().AddForce(Vector3.zero);
other.gameObject.GetComponent<Rigidbody>().velocity = Vector3.zero;
Singleton<GameEventManager>.Instance.gameOver();
}
}

//FirstControler.cs
void OnEnable() {
GameEventManager.gameoverChange += gameOverEvent;
...
}

void gameOverEvent() {
gameJudge = -1;
actionManager.DestroyAll();
for(int i = 0; i < patrols.Count; i++) {
if(patrols[i].transform.position.y != 0) {
patrols[i].transform.position = new Vector3(patrols[i].transform.position.x, 0,patrols[i].transform.position.z);
}
patrols[i].GetComponent<Animator>().SetBool("run",false);
}
timer.End();
player.GetComponent<Rigidbody>().velocity = Vector3.zero;
}

赢的实现方法是FirstController订阅win事件,该事件的发布者为挂在treasure上的TreasureCollider脚本,代码如下:

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
//TreasureCollider.cs
public class TreasureCollider : MonoBehaviour {
void OnCollisionEnter (Collision collider) {
if(collider.gameObject.tag == "Player") {
this.GetComponent<Animator>().SetBool("open", true);
Singleton<GameEventManager>.Instance.winning();
}
}
}

//FirstController.cs
void OnEnable() {
GameEventManager.win += win;
}

public void win() {
gameJudge = 1;
actionManager.DestroyAll();
for(int i = 0; i < patrols.Count; i++) {
if(patrols[i].transform.position.y != 0) {
patrols[i].transform.position = new Vector3(patrols[i].transform.position.x, 0,patrols[i].transform.position.z);
}
patrols[i].GetComponent<Animator>().SetBool("run",false);
}
timer.End();
player.GetComponent<Rigidbody>().velocity = Vector3.zero;
}

撞墙事件是当patrol进入前文提到的七个grid的时候发布的,接受者为PatrolAction:

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
void OnTriggerEnter(Collider other) {
if(other.gameObject.tag=="grid") {
Singleton<GameEventManager>.Instance.hitWallEvent(this.gameObject);
}
}
// PatrolAction.cs
void OnEnable() {
GameEventManager.hitWall += handleHitWall;
}

void handleHitWall(GameObject a) {
handle = true;
if(a == gameobject) {
switch(direction) {
case Direction.backward:
direction = Direction.forward;
break;
case Direction.forward:
direction = Direction.backward;
break;
case Direction.left:
direction = Direction.right;
break;
case Direction.right:
direction = Direction.left;
break;
}
regular = true;
}
handle = false;
}

其他类

摄像机跟随

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CameraController : MonoBehaviour {

private GameObject player;
private Vector3 offset;//it's a vector from player to camera
//offset can be obtained by transform(camera) - player
// Use this for initialization
void Start () {
player = ((FirstController)SSDirector.GetInstance().currentSceneController).player;
offset = transform.position - player.transform.position;
}

// Update is called once per frame
void LateUpdate () {
transform.position = player.transform.position + offset;
}
}

计时器

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class Timer : MonoBehaviour {
//开始时间
public float StartTime { get; private set; }
//持续时间
public float Duration { get; private set; }
//结束时间
public float EndTime { get; private set; }
//当前时间
public float CurTime { get; private set; }

//运行标识
public bool IsStart { get; private set; }

//开始和结束事件,这里直接用System.Action(相当于空返回值无参数委托)
public Action OnStart { get; set; }
public Action OnEnd { get; set; }
public Action OnUpdate { get; set; }

//构造函数,设置计时器
public Timer(float duration)
{
Duration = 0;
}

public void Start()
{
IsStart = true;
StartTime = Time.time;
CurTime = StartTime;
EndTime = StartTime + Duration;
if(OnStart != null) OnStart();
}

public void Update()
{
if (!IsStart) return;
Duration = CurTime/3;
CurTime += Time.deltaTime;
if (OnUpdate != null)
OnUpdate();
}

//计时器结束
public void End()
{
IsStart = false;
if(OnEnd!= null) OnEnd();
}
}

这样的话这个游戏就大体完成了,至于FirstController和UserGUI配合控制游戏逻辑、OnGUI()等等的细节之处这篇blog就不过多赘述了,这里附上Github链接,可以到这里查看:https://github.com/Yuandi-Sherry/3DGameDesign-Patrols