智能巡逻兵
游戏设计要求:
创建一个地图和若干巡逻兵(使用动画);
每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
巡逻兵在设定范围内感知到玩家,会自动追击玩家;
失去玩家目标后,继续巡逻;
计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
程序设计要求:
- 必须使用订阅与发布模式传消息
- 工厂模式生产巡逻兵
游戏玩法说明:
玩家通过上下左右箭头控制角色的移动;
地图上每个隔间有一个巡逻兵,巡逻轨迹为随机生成的,巡逻范围是整个隔间;
当玩家控制的角色进入某一隔间,该隔间的巡逻兵就会来追捕玩家;
追捕上了,则游戏结束;如果玩家成功逃离(进入另一隔间),则获得一分。
游戏效果图:
首先,制作预制。可以在Asset Store中搜索我们想要的免费资源~
在本游戏中,地图预制如下:
共有六个隔间,每个隔间将会有一个巡逻兵。
巡逻兵预制如下(是不是看起来就很像反派):
玩家控制的英雄预制如下:
除此之外,为了便于分数的显示,我将Text也做成了预制。
接下来是代码的设计过程。
· PatrolFactory
巡逻兵工厂,延续前几次游戏的设计思路,使用工厂模式来管理游戏对象的生成,以此达到良好的管理资源的目的。工厂是单例模式,只进行一次实例化。
巡逻兵初始的位置是设定好的,游戏开始后,他们开始做随机巡逻。
public class PatrolFactory : System.Object {
private static PatrolFactory instance;
private GameObject PatrolItem;
private Vector3[] PatrolPosSet = new Vector3[] { new Vector3(-6, 0, 16), new Vector3(-1, 0, 19),
new Vector3(6, 0, 16), new Vector3(-5, 0, 7), new Vector3(0, 0, 7), new Vector3(6, 0, 7)};
public static PatrolFactory getInstance() {
if (instance == null)
instance = new PatrolFactory();
return instance;
}
public void initItem(GameObject _PatrolItem) {
PatrolItem = _PatrolItem;
}
public GameObject getPatrol() {
GameObject newPatrol = Camera.Instantiate(PatrolItem);
return newPatrol;
}
public Vector3[] getPosSet() {
return PatrolPosSet;
}
}
· FirstController
本类主要负责:加载游戏场景(产生玩家控制的英雄以及巡逻兵)、玩家控制英雄移动、巡逻兵巡逻。这里涉及到了本次游戏的精髓——巡逻兵的巡逻设计。
巡逻兵有两种巡逻模式:
- 当游戏玩家没有出现在巡逻范围内,做随机方向的巡逻;
- 当英雄出现在巡逻范围内了,开始追捕英雄。
先来看随机方向的巡逻。实现的方法是,用addRandomMovement方法给巡逻兵添加随机方向动作,但是,我们需要确保两个条件:
- 巡逻兵不会走出自己的巡逻范围;
- 巡逻兵不会卡在围墙上,即撞到墙后需要变换方向继续巡逻。
每个巡逻兵当前只有一个动作,加入新的动作之前,需要销毁当前的动作。
控制巡逻范围的方法是,给每个巡逻兵编号(一共有六个),给六个隔间也编号,因为我们知道隔间的坐标信息,以此来限制巡逻兵就可以了。
控制撞墙返回的方法是,检测巡逻兵是否发生Collision,如果检测到,则销毁当前的动作,增加一个新的随机巡逻动作。
核心代码:
//FirstController.cs
...
public void addRandomMovement(GameObject sourceObj, bool isActive) {
int index = getIndexOfObj(sourceObj);
int randomDir = getRandomDirection(index, isActive);
PatrolLastDir[index] = randomDir;
sourceObj.transform.rotation = Quaternion.Euler(new Vector3(0, randomDir * 90, 0));
Vector3 target = sourceObj.transform.position;
switch (randomDir) {
case Diretion.UP:
target += new Vector3(0, 0, 1);
break;
case Diretion.DOWN:
target += new Vector3(0, 0, -1);
break;
case Diretion.LEFT:
target += new Vector3(-1, 0, 0);
break;
case Diretion.RIGHT:
target += new Vector3(1, 0, 0);
break;
}
addSingleMoving(sourceObj, target, PERSON_SPEED_NORMAL, false);
}
int getIndexOfObj(GameObject sourceObj) {
string name = sourceObj.name;
char cindex = name[name.Length - 1];
int result = cindex - '0';
return result;
}
int getRandomDirection(int index, bool isActive) {
int randomDir = Random.Range(-1, 3);
if (!isActive) {
while (PatrolLastDir[index] == randomDir || PatrolOutOfArea(index, randomDir)) {
randomDir = Random.Range(-1, 3);
}
}
else {
while (PatrolLastDir[index] == 0 && randomDir == 2
|| PatrolLastDir[index] == 2 && randomDir == 0
|| PatrolLastDir[index] == 1 && randomDir == -1
|| PatrolLastDir[index] == -1 && randomDir == 1
|| PatrolOutOfArea(index, randomDir)) {
randomDir = Random.Range(-1, 3);
}
}
return randomDir;
}
//本函数用以判断巡逻兵是否走出自己的巡逻范围
bool PatrolOutOfArea(int index, int randomDir) {
Vector3 patrolPos = PatrolSet[index].transform.position;
float posX = patrolPos.x;
float posZ = patrolPos.z;
switch (index) {
case 0:
if (randomDir == 1 && posX + 1 > FenchLocation.FenchVertLeft
|| randomDir == 2 && posZ - 1 < FenchLocation.FenchHori)
return true;
break;
case 1:
if (randomDir == 1 && posX + 1 > FenchLocation.FenchVertRight
|| randomDir == -1 && posX - 1 < FenchLocation.FenchVertLeft
|| randomDir == 2 && posZ - 1 < FenchLocation.FenchHori)
return true;
break;
case 2:
if (randomDir == -1 && posX - 1 < FenchLocation.FenchVertRight
|| randomDir == 2 && posZ - 1 < FenchLocation.FenchHori)
return true;
break;
case 3:
if (randomDir == 1 && posX + 1 > FenchLocation.FenchVertLeft
|| randomDir == 0 && posZ + 1 > FenchLocation.FenchHori)
return true;
break;
case 4:
if (randomDir == 1 && posX + 1 > FenchLocation.FenchVertRight
|| randomDir == -1 && posX - 1 < FenchLocation.FenchVertLeft
|| randomDir == 0 && posZ + 1 > FenchLocation.FenchHori)
return true;
break;
case 5:
if (randomDir == -1 && posX - 1 < FenchLocation.FenchVertRight
|| randomDir == 0 && posZ + 1 > FenchLocation.FenchHori)
return true;
break;
}
return false;
}
检测撞墙:
//PatrolBehavior.cs
//挂载在Patrol预设上
void OnCollisionStay(Collision e) {
//撞击围栏,选择下一个点移动
if (e.gameObject.name.Contains("Patrol") || e.gameObject.name.Contains("fence")
|| e.gameObject.tag.Contains("FenceAround")) {
isCatching = false;
addAction.addRandomMovement(this.gameObject, false);
}
//撞击hero,游戏结束
if (e.gameObject.name.Contains("Hero")) {
gameStatusOp.patrolHitHeroAndGameover();
Debug.Log("Game Over!");
}
}
其中,SSActionManager类负责管理动作的创建和销毁:
//SSActionManager.cs
public class SSActionManager : MonoBehaviour {
private Dictionary<int, SSAction> actions = new Dictionary<int, SSAction>();
private List<SSAction> waitingAdd = new List<SSAction>();
private List<int> waitingDelete = new List<int>();
protected void Start () {
}
protected void Update() {
foreach (SSAction ac in waitingAdd) {
actions[ac.GetInstanceID()] = ac;
}
waitingAdd.Clear();
foreach (KeyValuePair<int, SSAction> kv in actions) {
SSAction ac = kv.Value;
if (ac.destroy)
waitingDelete.Add(kv.Key);
else if (ac.enable)
ac.Update();
}
foreach (int key in waitingDelete) {
SSAction ac = actions[key];
actions.Remove(key);
DestroyObject(ac);
}
waitingDelete.Clear();
}
public void runAction(GameObject gameObj, SSAction action, ISSActionCallback manager) {
//先把该对象现有的动作销毁
for (int i = 0; i < waitingAdd.Count; i++) {
if (waitingAdd[i].gameObject.Equals(gameObj)) {
SSAction ac = waitingAdd[i];
waitingAdd.RemoveAt(i);
i--;
DestroyObject(ac);
}
}
foreach (KeyValuePair<int, SSAction> kv in actions) {
SSAction ac = kv.Value;
if (ac.gameObject.Equals(gameObj)) {
ac.destroy = true;
}
}
action.gameObject = gameObj;
action.transform = gameObj.transform;
action.callBack = manager;
waitingAdd.Add(action);
action.Start();
}
}
再来看巡逻兵发现英雄后的追捕活动。检测英雄是否进入巡逻区域很简单:在英雄预制上面挂载一个脚本,可以返回英雄当前走到的隔间编号:
//HeroStatus.cs
...
public int standOnArea = -1;
...
void modifyStandOnArea() {
float posX = this.gameObject.transform.position.x;
float posZ = this.gameObject.transform.position.z;
if (posZ >= FenchLocation.FenchHori) {
if (posX < FenchLocation.FenchVertLeft)
standOnArea = 0;
else if (posX > FenchLocation.FenchVertRight)
standOnArea = 2;
else
standOnArea = 1;
}
else {
if (posX < FenchLocation.FenchVertLeft)
standOnArea = 3;
else if (posX > FenchLocation.FenchVertRight)
standOnArea = 5;
else
standOnArea = 4;
}
}
用addDirectMovement方法来实现巡逻兵的追捕:
//FirstController.cs
public void addDirectMovement(GameObject sourceObj) {
int index = getIndexOfObj(sourceObj);
PatrolLastDir[index] = -2;
sourceObj.transform.LookAt(sourceObj.transform);
Vector3 oriTarget = myHero.transform.position - sourceObj.transform.position;
Vector3 target = new Vector3(oriTarget.x / 4.0f, 0, oriTarget.z / 4.0f);
target += sourceObj.transform.position;
addSingleMoving(sourceObj, target, PERSON_SPEED_CATCHING, true);
}
void addSingleMoving(GameObject sourceObj, Vector3 target, float speed, bool isCatching) {
this.runAction(sourceObj, CCMoveToAction.CreateSSAction(target, speed, isCatching), this);
}
· CCMoveToAction
public class CCMoveToAction: SSAction {
public Vector3 target;
public float speed;
public bool isCatching; //追捕或巡逻
public static CCMoveToAction CreateSSAction(Vector3 _target, float _speed, bool _isCatching) {
CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction>();
action.target = _target;
action.speed = _speed;
action.isCatching = _isCatching;
return action;
}
public override void Start() {
}
public override void Update() {
this.transform.position = Vector3.MoveTowards(this.transform.position, target, speed);
if (this.transform.position == target) {
this.destroy = true;
if (!isCatching)
this.callBack.SSActionEvent(this);
else
this.callBack.SSActionEvent(this, SSActionEventType.Completed, SSActionTargetType.Catching);
}
}
}
· UserInterface
接收用户的“上下左右”输入,通知FirstController来控制英雄的移动:
//UserInterface.cs
void detectKeyInput() {
if (Input.GetKey(KeyCode.UpArrow)) {
action.heroMove(Diretion.UP);
}
if (Input.GetKey(KeyCode.DownArrow)) {
action.heroMove(Diretion.DOWN);
}
if (Input.GetKey(KeyCode.LeftArrow)) {
action.heroMove(Diretion.LEFT);
}
if (Input.GetKey(KeyCode.RightArrow)) {
action.heroMove(Diretion.RIGHT);
}
}
//FirstController.cs
public void heroMove(int dir) {
myHero.transform.rotation = Quaternion.Euler(new Vector3(0, dir * 90, 0));
switch (dir) {
case Diretion.UP:
myHero.transform.position += new Vector3(0, 0, 0.1f);
break;
case Diretion.DOWN:
myHero.transform.position += new Vector3(0, 0, -0.1f);
break;
case Diretion.LEFT:
myHero.transform.position += new Vector3(-0.1f, 0, 0);
break;
case Diretion.RIGHT:
myHero.transform.position += new Vector3(0.1f, 0, 0);
break;
}
}
本次游戏项目中,要求使用订阅/发布模式来传递信息,定义如下:
订阅者把自己想订阅的事件注册到调度中心,当该事件触发时候,发布者发布该事件到调度中心(顺带上下文),由调度中心统一调度订阅者注册到调度中心的处理代码。
UML图:
具体实现如下:
首先来考虑所有的游戏事件,其实只有两个:
- 英雄从一个隔间进入另一个隔间,加一分;
- 英雄被巡逻兵追上,游戏结束。
我们可以用一个GameEventManager来管理这两个事件,它就可以作为信息的发布者(Publisher),来告知订阅了信息的其他类(Subscriber)。比如说管理分数的类GameStatusText,如果GameEventManager发布了英雄进入另一隔间的消息,则显示的分数+1;如果GameEventManager发布了英雄被巡逻兵追上的消息,则显示“Game Over”字样。
这样就可以在触发玩家得分、游戏结束时,改变显示的文本内容,并且有效地降低类之间的耦合度。
实现到这一步,简单的巡逻兵游戏已经完成了,但是我们还可以有改进,比如加入动画。正好,我使用的人物模型自带动画(可以在Assets Store搜索warrior,免费素材噢),下面是添加奔跑动画的过程:
- 首先,我们可以先看看下载的素材都有哪些动画效果:
以我的Hero模型为例,进入animations目录,可以看到有以下动画:
- 然后,在Assets目录下新建文件夹Animator,新建Animator Controller文件并命名(比如Hero)。再找到动画文件,拖入Hero的Base Layer中,如图所示:
- 做完这些,给做好的Hero预制添加Animator组件,将刚才完成的Animator Controller文件(Hero)拖入组件的"Controller"部分;再找到游戏模型的目录下的Avatr文件,拖入组件的"Avatr"部分,动画的植入就完成啦。
以上是巡逻兵游戏的主要实现过程,完整的代码见Github传送门