智能巡逻兵要求
游戏设计要求:
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
程序设计要求:
- 必须使用订阅与发布模式传消息
- subject:OnLostGoal
- Publisher: ?
- Subscriber: ?
- 工厂模式生产巡逻兵
友善提示1:生成 3~5个边的凸多边型 随机生成矩形 ;在矩形每个边上随机找点,可得到 3 - 4 的凸多边型
友善提示2:参考以前博客,给出自己新玩法
游戏规则
主角被置身于一个长满各种植物的花园中,为了逃离这个困境,需要集齐隐藏于花丛中的12颗水晶。通过方向键控制主角移动,同时躲避每个区域的巡逻兵,开启召唤神龙之旅巡逻兵可能也会藏在草丛中哦
UML图
游戏设计
这次游戏设计比之前复杂挺多,代码也较多,这里只把核心部分展示出来,完整代码可见github。
整个游戏实现思路如下:单例SSDirector导演持有currentSceneController对象,场记通过IUserAction接口与用户交互,通过ScoreController管理记分操作,摄像师CameraMan负责跟踪主角移动;场记还通过巡逻兵工厂和水晶工厂来获得巡逻兵实例和水晶实例,然后巡逻兵动作管理员管理巡逻动作和跟踪动作;同时场记还订阅了事件管理器,当水晶碰撞器和玩家碰撞器发布碰撞通知时,事件管理器接收并通知场记发生了具体的碰撞事件,从而执行相应的操作。
控制主角移动
用户GUI中检测方向键,这里把上下左右分别用整数0,2,-1,1表示,好处是当按下方向键需要改变主角的朝向的时候可能根据这几个整数,将其rotation设为欧拉角new Vector3(0, dir * 90, 0)对应的四元数实例。然后判断方向改变主角在对应方向的坐标。
// UserInterface
void Update () {
if (Input.GetKey(KeyCode.UpArrow))
{
action.MovePlayer(Diretion.UP);
}
if (Input.GetKey(KeyCode.DownArrow))
{
action.MovePlayer(Diretion.DOWN);
}
if (Input.GetKey(KeyCode.LeftArrow))
{
action.MovePlayer(Diretion.LEFT);
}
if (Input.GetKey(KeyCode.RightArrow))
{
action.MovePlayer(Diretion.RIGHT);
}
}
// SceneController
public void MovePlayer(int dir)
{
if (!isGameOver)
{
player.transform.rotation = Quaternion.Euler(new Vector3(0, dir * 90, 0));
player.GetComponent<Animator>().SetBool("run", true);
switch (dir)
{
case Diretion.UP:
player.transform.position += new Vector3(0, 0, 0.1f);
break;
case Diretion.DOWN:
player.transform.position += new Vector3(0, 0, -0.1f);
break;
case Diretion.LEFT:
player.transform.position += new Vector3(-0.1f, 0, 0);
break;
case Diretion.RIGHT:
player.transform.position += new Vector3(0.1f, 0, 0);
break;
}
}
}
主摄像机跟踪主角移动
实现思路是在初始化的时候保存摄像机到主角的相对位置,在之后每一帧中保持这个相对位置不变,即可实现相机跟随。
public class CameraMan : MonoBehaviour {
public GameObject target;
// 相机跟随玩家的速度
public float followSpeed = 5f;
Vector3 distance;
// Use this for initialization
void Start () {
distance = transform.position - target.transform.position;
}
// Update is called once per frame
void Update () {
transform.position = Vector3.Lerp(transform.position, target.transform.position + distance, followSpeed * Time.deltaTime);
}
}
巡逻兵工厂与水晶工厂
由于整个游戏场景的x和z坐标范围均不会超出(-12,12),随机生成水晶坐标便可以设定在这个范围内。巡逻兵的初始位置我设置为每个小区域的左上角的坐标,后面巡逻也会从这个坐标开始。
// 水晶工厂
public class CrystalFactory : MonoBehaviour {
private GameObject crystal = null;
private List<GameObject> usedCrystal = new List<GameObject>();
private float range = 12; // 水晶生成的坐标范围
private static CrystalFactory instance = null;
public List<GameObject> getCrystals()
{
for(int i = 0; i < 12; i++)
{
crystal = Instantiate(Resources.Load<GameObject>("Prefabs/Crystal"));
float ranx = UnityEngine.Random.Range(-range, range);
float ranz = UnityEngine.Random.Range(-range, range);
crystal.transform.position = new Vector3(ranx, 0, ranz);
usedCrystal.Add(crystal);
}
return usedCrystal;
}
public static CrystalFactory getInstance()
{
if (instance == null) instance = new CrystalFactory();
return instance;
}
}
// 巡逻兵工厂
public class PatrolFactory : MonoBehaviour {
private List<GameObject> usedPatrols = new List<GameObject>();
private Vector3[] vec = new Vector3[9]; // 保存每个巡逻兵的初始位置
public List<GameObject> getPatrols()
{
int[] pos_x = { -6, 4, 13 };
int[] pos_y = { -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_y[j]);
index++;
}
}
for(int i = 0; i < 9; i++)
{
GameObject 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];
usedPatrols.Add(patrol);
}
return usedPatrols;
}
public void stopPatrol()
{
for(int i = 0; i < usedPatrols.Count; i++)
{
usedPatrols[i].gameObject.GetComponent<Animator>().SetBool("run", false);
}
}
}
巡逻兵巡逻动作
在获取一个巡逻动作的时候会随机生成一个长度为4到7的移动距离,之后巡逻兵执行这个动作的时候就会以这个长度作长方形的运行。由于前面设定了巡逻兵的初始位置在每个区域的左上角,可以通过设定好巡逻的循序使得巡逻兵不会撞到墙上。在巡逻动作开始时,触发巡逻兵Animator的跑的动作,然后每一次执行Update()函数,判断是否达到一个巡逻路径的端点,若达到则改变方向,否则向目标点移动。
public class PatrolAction : SSAction {
private enum Direction { EAST, NORTH, WEST, SOUTH };
// 移动前的初始x和z坐标
private float posX, posZ;
// 移动的长度
private float moveLength;
// 移动速度
private float speed = 1.2f;
// 是否达到一个目标点
private bool isArrivedAnEndPoint = true;
// 移动的方向
private Direction direction = Direction.EAST;
// 侦察兵的数据
private PatrolData data;
private PatrolAction() { }
public static PatrolAction getSSAction(Vector3 location)
{
PatrolAction action = CreateInstance<PatrolAction>();
action.posX = location.x;
action.posZ = location.z;
action.moveLength = UnityEngine.Random.Range(4, 7);
return action;
}
public override void Start()
{
this.gameObject.GetComponent<Animator>().SetBool("run", true);
data = this.gameObject.GetComponent<PatrolData>();
}
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.ifFollowPlayer && data.areaSign == data.sign)
{
this.destory = true;
this.callback.SSActoinEvent(this, 0, this.gameObject);
}
}
private void goPatrol()
{
// 到达一个点之后改变方向及设置下一个目标点
if (isArrivedAnEndPoint)
{
switch (direction)
{
case Direction.EAST:
posX -= moveLength;
break;
case Direction.NORTH:
posZ += moveLength;
break;
case Direction.WEST:
posX += moveLength;
break;
case Direction.SOUTH:
posZ -= moveLength;
break;
}
isArrivedAnEndPoint = false;
}
this.transform.LookAt(new Vector3(posX, 0, posZ));
float distance = Vector3.Distance(transform.position, new Vector3(posX, 0, posZ));
// 当前位置与目标点距离比较
if(distance > 0.9)
{
transform.position = Vector3.MoveTowards(this.transform.position, new Vector3(posX, 0, posZ), speed * Time.deltaTime);
}
else
{
direction += 1;
if(direction > Direction.SOUTH)
{
direction = Direction.EAST;
}
isArrivedAnEndPoint = true;
}
}
}
巡逻兵追踪动作
首先通过给巡逻兵的子对象设定一个范围,只要主角进入这个范围,就告诉巡逻兵要追踪主角;当主角离开这个范围,说明主角成功逃离追捕,得分加一。这个范围的大小可视每个区域大小决定。把以下脚本挂载到巡逻兵子对象上。
public class PatrolCollider : MonoBehaviour {
// 玩家进入巡逻兵的巡逻范围
void OnTriggerEnter(Collider other)
{
if(other.gameObject.tag == "Player")
{
this.gameObject.transform.parent.GetComponent<PatrolData>().ifFollowPlayer = true;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = other.gameObject;
}
}
// 玩家逃离巡逻兵巡逻范围
void OnTriggerExit(Collider other)
{
if(other.gameObject.tag == "Player")
{
this.gameObject.transform.parent.GetComponent<PatrolData>().ifFollowPlayer = false;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = null;
}
}
}
从巡逻状态切换到追踪状态:
巡逻动作每次执行Update()的时候在最后检查巡逻兵是否需要跟随玩家并且玩家跟巡逻兵在同一个区域,如果满足条件则通过回调接口来实现切换动作:告诉动作管理器销毁巡逻动作并产生追踪动作。
// PatrolAction中
public override void Update()
{
...
goPatrol();
// 如果侦察兵需要跟随玩家并且玩家就在侦察兵所在区域
if(data.ifFollowPlayer && data.areaSign == data.sign)
{
this.destory = true;
this.callback.SSActoinEvent(this, 0, this.gameObject);
}
}
// SSActionManager中
public void SSActoinEvent(SSAction source, int param = 0, GameObject objParam = null)
{
if(param == 0)
{
// 侦察兵跟随玩家
FollowAction followAction = FollowAction.getSSAction(objParam.gameObject.GetComponent<PatrolData>().player);
this.RunAction(objParam, followAction, this);
}
else
{
PatrolAction patrolAction = PatrolAction.getSSAction(objParam.gameObject.GetComponent<PatrolData>().start_position);
this.RunAction(objParam, patrolAction, this);
Singleton<GameEventManager>.Instance.playerEscape();
}
}
以下是巡逻动作实现代码,思路上每次Update()调用Vector3.MoveTowards(),控制巡逻兵向主角位置移动,并调用transform.LookAt()使巡逻兵保持面朝主角。最后还要判断是否不需要追踪及主角和巡逻兵不在一个区域,以销毁追踪动作,切换成巡逻动作。
public class FollowAction : SSAction {
private float speed = 2f;
private GameObject player; // 玩家
private PatrolData data; // 侦察兵数据
private FollowAction() { }
public static FollowAction getSSAction(GameObject player)
{
FollowAction action = CreateInstance<FollowAction>();
action.player = player;
return action;
}
public override void Start()
{
data = this.gameObject.GetComponent<PatrolData>();
}
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);
}
transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
this.transform.LookAt(player.transform.position);
if (!data.ifFollowPlayer || data.areaSign != data.sign)
{
this.destory = true;
this.callback.SSActoinEvent(this, 1, this.gameObject);
}
}
}
最后给巡逻兵加上碰撞监听事件,这里注意要使用OnCollisionEnter()而不是OnTriggerEnter(),原因是当主角触发了巡逻兵子对象的OnTriggerEnter()事件后会向上冒泡,从而触发巡逻兵的OnTriggerEnter()。
public class PlayerCollider : MonoBehaviour {
private void OnCollisionEnter(Collision other)
{
if(other.gameObject.tag == "Player")
{
Debug.Log("death");
other.gameObject.GetComponent<Animator>().SetTrigger("death");
this.GetComponent<Animator>().SetTrigger("shoot");
Singleton<GameEventManager>.Instance.gameOver();
}
}
}
订阅与发布模式
发布者
当特定事件发生时,事件管理器会向订阅者发布消息,从而订阅者接收发布的消息并作出一定的响应。
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)
{
Debug.Log("Add score!");
ScoreChange();
}
}
public void gameOver()
{
if(GameOverChange != null)
{
GameOverChange();
}
}
public void ReduceCrystalNum()
{
if(CrystalChange != null)
{
CrystalChange();
}
}
}
订阅者
在游戏设计中,我把场记设定为一个订阅者,当发布者通知它发生了某个事件,它便会调用注册的方法进行处理。
void OnEnable()
{
Debug.Log("Scene controller onEnable");
GameEventManager.ScoreChange += AddScore;
GameEventManager.GameOverChange += GameOver;
GameEventManager.CrystalChange += ReduceCrystalNumber;
}
private void ReduceCrystalNumber()
{
scoreController.crystal_num--;
}
private void AddScore()
{
scoreController.score++;
}
private void GameOver()
{
isGameOver = true;
patrolFactory.stopPatrol();
actionManager.DestroyAll();
}
void OnDisable()
{
GameEventManager.ScoreChange -= AddScore;
GameEventManager.GameOverChange -= GameOver;
GameEventManager.CrystalChange -= ReduceCrystalNumber;
}
光线问题
这里还需要注意下,当使用SceneManager.LoadScene ()来重新加载游戏场景时,可能会遇到重新加载场景之后光线变暗的问题,可以通过Window-lighting-settings,把下图中的Auto Generate前面的方框的勾去掉,再点击Generate Lighting即可。之后再重新加载就不会变暗了。
总结
这次作业基本上涵盖了前面所学到的所有知识,从mvc到门面模式到动作分离到工厂模式到发布订阅模式,真的锻炼面向对象的编程能力。总体来说难度挺大的,其中有些功能的实现参考了去年学长们的博客,前人的经验对学习unity3d编程挺有帮助的。
代码详见:https://github.com/liuqcloud/Unity-3D/tree/master/hw7