文章目录
写在前面
游戏规则与要求
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
以上只是老师课程网站的基本要求,可是仅仅把躲避巡逻兵作为目标来获取高分未免太过单调了,所以我还添加了一点胜利的条件:
- 地图共分为9个单元(9宫格形式),玩家所在的初始格子为安全区,对角线上的格子为目标区。达到目标区为胜利。
- 目标区开始的时候封闭,只有触发条件才能开启缺口:
条件就是需要获取场景内的3个红球,获取方式是触碰加鼠标点击。
游戏实现
订阅发布模式
首先是课上讲到的订阅发布模式(观察者模式)的应用:
首先是需要定义一个信息发布者,也就是类似于现在的公众号、报刊中心等等。
我的理解是它只是作为一个平台去发布消息,但是真正消息的拥有者或者说触发者是别的类。一旦消息被触发,他就会发出一个通知,所有订阅了消息的人都会收到通知,然后去执行相应的动作。
所以说这个发布者可以看作是一个被动的角色,所有需要发布消息的类都通过发布者来实现。举个例子,某粉丝关注了微博大V,相当于订阅了该事件,当大V发微博的时候就会通过这个微博平台发布,粉丝就可以获取信息。注意的是,微博平台并不会替大V编辑消息内容,它只负责传递,真正通信的其实是除了发布平台的双方。
处理逻辑方面,就是当平台发布了消息,那么订阅者就会采取相应的措施,比如这个游戏中,平台发布了游戏结束的信息,那么订阅者可能就会停止工作,等待下一轮游戏开始。
发布者代码:
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 Finished() {
if (Win != null) {
Win();
}
}
}
订阅者代码
这里订阅者主要是Controller:
public class FirstController : MonoBehaviour, SceneController, Interaction{
....
....
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;
}
public void OnGoalLost(GameObject patrol) {
patrolActionManager.Patrol(patrol);
judger.addScore();
}
public void OnFollowing(GameObject patrol) {
patrolActionManager.Follow(player, patrol);
}
public void GameOver() {
state = 0;
StopAllCoroutines();
patrolFactory.PausePatrol();
player.GetComponent<Animator>().SetTrigger("death");
patrolActionManager.DestroyAllActions();
}
public void Win() {
state = 2;
StopAllCoroutines();
patrolFactory.PausePatrol();
patrolActionManager.DestroyAllActions();
}
}
这里定义了几种事件:游戏结束、玩家被捕、巡逻兵追击、玩家逃脱事件。每一种分别执行不同的行为,这些行为需要在订阅者中实现。通知者发出通知时,就会由订阅者执行相应的代码。
(注:其实很直观的(不正确)一个理解就是,通知者调用了订阅者的方法,在之前的游戏中我们都是直接由通知者指定调用某个类的方法,因为此时订阅者往往就是固定的一个类,但是订阅模式中,由于订阅者可能会很多,每个订阅者响应的事件以及对应的方法可能不同,会增加通知者的负担,但是如果在中间增加一层抽象,也就是说,通知者只管发布,不需要知道具体订阅者,订阅者接受通知并且执行方法,这样就能够减少耦合,使类的职责更加单一。)
巡逻兵设计
这里巡逻兵用了资源商店一个僵尸的模型:
僵尸模型上加了两个胶囊体碰撞盒(在模型的不同的部件上添加),一个主要是检测自身的物理碰撞(需要在主体上添加刚体属性),也就是实际碰撞的检测,另一个范围比较大的碰撞盒主要是Trigger检测,检测是否有玩家进入视野区域。
对于这两个碰撞器也需要分别加上碰撞检测处理的脚本:
物理碰撞:
public class PatrolCollision : MonoBehaviour
{
public float time = 0;
void OnCollisionEnter(Collision collision) {
// Debug.Log(collision.gameObject.name);
if (collision.gameObject.tag == "Player") {
this.GetComponent<Animator>().SetTrigger("attack");
Singleton<GameEventManager>.Instance.OnPlayerCatched();
} else {
if (collision.gameObject.name != "Plane") {
this.GetComponent<PatrolData>().onCollison = true;
}
if (collision.gameObject.name == "Zombie") {
this.GetComponent<PatrolData>().withTeammate = true;
}
}
}
void OnCollisionStay(Collision collision) {
if (collision.gameObject.name != "Plane") {
time += Time.deltaTime;
if (time > 1.5) {
this.GetComponent<PatrolData>().onCollison = true;
time = 0;
}
}
}
}
这里用到的是onCollision的检测,也就是说使用了刚体本身的物理碰撞系统,会产生反弹的效果。当僵尸碰到非玩家的其他物体时,就会将自身属性设置为碰撞,然后会选择一些避障的措施。而如果触碰的是玩家,就会执行攻击这个动作,并且发出一个通知“玩家被捕”,游戏就会结束了。
触发碰撞检测:
public class SeePlayer : MonoBehaviour {
public FirstController controller;
void OnTriggerEnter(Collider collider) {
controller = Director.getInstance().currentSceneController as FirstController;
if (collider.gameObject.tag == "Player") {
this.gameObject.transform.parent.GetComponent<PatrolData>().seePlayer = true;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = collider.gameObject;
}
}
private void OnTriggerExit(Collider collider) {
if (collider.gameObject.tag == "Player") {
this.gameObject.transform.parent.GetComponent<Animator>().SetBool("track", false);
this.gameObject.transform.parent.GetComponent<PatrolData>().seePlayer = false;
this.gameObject.transform.parent.GetComponent<PatrolData>().player = null;
}
}
}
这里用的是OnTrigger的检测,需要在碰撞盒上勾选isTrigger的选项:
这样物体碰撞的时候就不会使用刚体的碰撞检测,也就是说虽然碰撞了,但是依旧会穿过物体,此时执行的是用户自己设定的逻辑。仅仅只是检测碰撞,并没有物理的碰撞处理。
僵尸巡逻行为:
public class PatrolAction : SSAction {
private float x, z;
private bool turn = false;
private PatrolData info;
public static PatrolAction GetAction(Vector3 pos) {
PatrolAction action = CreateInstance<PatrolAction>();
action.x = pos.x;
action.z = pos.z;
return action;
}
public override void Start() {
info = this.gameObject.GetComponent<PatrolData>();
}
public override void Update(){
if (Director.getInstance().currentSceneController.getState() == 1) {
PatrolWalk();
if (!info.tracking && info.seePlayer && info.patrolArea == info.playerArea) {
this.destroy = true;
this.enable = false;
this.callBack.SSActionEvent(this);
this.gameObject.GetComponent<PatrolData>().tracking = true;
Singleton<GameEventManager>.Instance.FollowPlayer(this.gameObject);
}
}
}
void PatrolWalk() {
if (turn) {
x = this.transform.position.x + Random.Range(-7f, 7f);
z = this.transform.position.z + Random.Range(-7f, 7f);
this.transform.LookAt(new Vector3(x, 0, z));
this.gameObject.GetComponent<PatrolData>().onCollison = false;
turn = false;
}
float distance = Vector3.Distance(transform.position, new Vector3(x, 0, z));
if (this.gameObject.GetComponent<PatrolData>().onCollison) {
float angle = Random.Range(175, 185);
this.transform.Rotate(Vector3.up, angle);
GameObject tmp = new GameObject();
tmp.transform.position = this.transform.position;
tmp.transform.rotation = this.transform.rotation;
tmp.transform.Translate(0, 0, Random.Range(0.5f, 2f));
x = tmp.transform.position.x;
z = tmp.transform.position.z;
this.transform.LookAt(new Vector3(x, 0, z));
this.gameObject.GetComponent<PatrolData>().onCollison = false;
Destroy(tmp);
} else if (distance <= 0.1) {
turn = true;
} else {
this.transform.Translate(0, 0, Time.deltaTime );
}
}
}
巡逻行为则是按照游戏规则:
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
每次随机选择周围的一个点作为目标前进。
僵尸追击动作代码:
public class TrackingAction : SSAction
{
private float speed = 2.5f; // 跟随玩家的速度
private GameObject player; // 玩家
private PatrolData info; // 巡逻兵数据
public static TrackingAction GetAction(GameObject player) {
TrackingAction action = CreateInstance<TrackingAction>();
action.player = player;
return action;
}
public override void Start() {
info = this.gameObject.GetComponent<PatrolData>();
this.gameObject.GetComponent<Animator>().SetBool("track", true);
}
public override void Update() {
FirstController controller = Director.getInstance().currentSceneController as FirstController;
if (controller.getState() == 1) {
transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
this.transform.LookAt(player.transform.position);
if (info.tracking && (!(info.seePlayer && info.patrolArea == info.playerArea) || (info.onCollison && !info.withTeammate))) {
this.destroy = true;
this.enable = false;
this.callBack.SSActionEvent(this);
this.gameObject.GetComponent<PatrolData>().tracking = false;
this.gameObject.GetComponent<PatrolData>().withTeammate = false;
Singleton<GameEventManager>.Instance.PlayerEscape(this.gameObject);
}
}
}
}
每次都跟着玩家的位置进行移动,知道玩家走出范围,才恢复巡逻姿态。
巡逻与追击的不同之处在于:执行动画不同、速度不同、行走的方向不同(有无目的性)
动作管理器
就是根据不同的调用,执行不同的动作代码。
public class PatrolActionManager : SSActionManager, ISSActionCallback
{
public PatrolAction patrol;
public TrackingAction 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 = TrackingAction.GetAction(player);
this.RunAction(patrol, follow, this);
}
//停止所有动作
public void DestroyAllActions() {
DestroyAll();
}
public void SSActionEvent(SSAction source){ }
}
游戏场景设计
主要是用Cube围成一个九宫格的形状,中间使用了多个Cude进行分隔,作为墙壁。
另外还在每一个格子中添加了一个方形碰撞器区域,简单限制了僵尸的活动范围,不至于全图追着你乱跑。(但是这里有一个bug,就是僵尸在边边的时候偶尔会卡出去,到别的格子去)
区域碰撞检测代码:
public class AreaCollision : MonoBehaviour {
public int areaNum = 0;
public FirstController controller;
private void Awake() {
}
void OnTriggerEnter(Collider collider) {
controller = Director.getInstance().currentSceneController as FirstController;
if (collider.gameObject.transform.parent != null && collider.gameObject.transform.parent.tag == "Player") {
controller.playerArea = areaNum;
}
}
private void OnTriggerStay(Collider collider)
{
if (areaNum == 9) {
if (collider.gameObject.tag == "Player") {
controller.playerArea = areaNum;
Singleton<GameEventManager>.Instance.Finished();
}
}
}
private void OnTriggerExit(Collider collider) {
if (collider.gameObject.tag == "Patrol") {
collider.gameObject.GetComponent<PatrolData>().onCollison = true;
} else if (collider.gameObject.transform.parent != null && collider.gameObject.transform.parent.tag == "Player") {
controller = Director.getInstance().currentSceneController as FirstController;
controller.playerArea = 0;
}
}
}
当玩家进入某个区域就会标记号码,位于该区域的僵尸看到了玩家就会去追,别的区域则不会隔着墙追。当玩家进入目标的安全区中,就会获得游戏胜利,发出通知。
而僵尸本来就会在区域内,如果想要离开就会被标记为碰撞,执行向后转的避障行为,所以一定程度上限制了僵尸的活动区域。
另外在区域内会放置3个红色小球,作为触发“开门”的条件。
小球碰撞检测
public class BallCollision : MonoBehaviour {
float time = 0;
bool start = false;
void OnCollisionEnter(Collision collision) {
if (collision.gameObject.tag == "Player") {
Debug.Log("Click to pick it!");
time = 0;
start = false;
// if (Input.GetButtonDown("Fire1")) {
// Destroy(this.gameObject);
// }
}
}
void OnCollisionStay(Collision collision) {
if (collision.gameObject.tag == "Player") {
if (start == true) time += Time.deltaTime;
if (Input.GetButtonDown("Fire1")) {
start = true;
}
if (start == true && time > 0.6) {
time = 0;
start = false;
FirstController controller = Director.getInstance().currentSceneController as FirstController;
controller.addBall();
Destroy(this.gameObject);
}
}
}
}
触碰着小球并且按下鼠标则视为拾取小球成功,这里做了一个延迟的检测,目的是让击打的动作动画能够播放完毕之后,小球才消失。
Controller与UI设计
Controller设计根据之前的类的说明,已经差不多描述完了,主要加载资源,然后让僵尸随即移动,并且检测玩家所在区域。还有控制玩家的移动。
玩家的移动是通过UI检测用户输入的键盘信息(WASD或者方向键)来移动人物,鼠标点击来执行击打的动作。
UI还有就是根据游戏状态来显示界面,比如游戏结束输出提示,胜利也同样如此。
public class FirstController : MonoBehaviour, SceneController, Interaction{
public int playerArea;
public PatrolActionManager patrolActionManager;
Judger judger;
public UI ui;
public PatrolFactory patrolFactory;
public GameObject player;
private List<GameObject> patrols;
private GameObject moveWall;
private int state = 0;
bool flag = true;
private int ballCount = 0;
void Awake() {
Director director = Director.getInstance();
director.currentSceneController = this;
judger = gameObject.AddComponent<Judger>();
patrolFactory = gameObject.AddComponent<PatrolFactory>();
playerArea = 5;
patrolActionManager = gameObject.AddComponent<PatrolActionManager>();
gameObject.AddComponent<GameEventManager>();
ui = gameObject.AddComponent<UI>() as UI;
}
private void Start()
{
loadResources();
patrolFactory.StartPatrol();
for (int i = 0; i < patrols.Count; i++) {
patrolActionManager.Patrol(patrols[i]);
}
moveWall = GameObject.FindGameObjectWithTag("move");
}
public void loadResources() {
Instantiate(Resources.Load<GameObject>("Prefabs/Plane")).name = "Plane";
player = Instantiate(Resources.Load("Prefabs/Player"), new Vector3(13, 0, 13), Quaternion.identity) as GameObject;
player.name = "Player";
Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(8, 0.5f, -17), Quaternion.identity).name = "Ball";
Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(-18, 0.5f, 12), Quaternion.identity).name = "Ball";
Instantiate(Resources.Load<GameObject>("Prefabs/Ball"), new Vector3(-4, 0.5f, -17), Quaternion.identity).name = "Ball";
patrols = patrolFactory.getPatrols();
Camera.main.GetComponent<CameraView>().follow = player;
}
void Update() {
if (state != 1) return;
if (ballCount == 3 && flag) {
moveWall.transform.localPosition += new Vector3(0,0,-2);
flag = false;
}
for (int i = 0; i < patrols.Count; i++) {
patrols[i].GetComponent<PatrolData>().playerArea = playerArea;
}
}
public int getState() {
return state;
}
public void changeState(int a) {
state = a;
}
public void PlayerPick() {
player.GetComponent<Animator>().SetTrigger("attack");
}
public void movePlayer(Vector3 pos) {
if (pos.x != 0 || pos.z != 0) {
player.GetComponent<Animator>().SetBool("run", true);
} else {
player.GetComponent<Animator>().SetBool("run", false);
}
pos.x *= -2*Time.deltaTime;
pos.z *= -2*Time.deltaTime;
player.transform.Rotate(Vector3.up, -pos.x*50, Space.Self);
// new Vector3(player.transform.localPosition.x + pos.x, player.transform.position.y, player.transform.localPosition.z + pos.z)
// player.transform.LookAt();
player.transform.Translate(-0.05f*pos.x, 0, -pos.z * 2);
}
public void addBall() {
ballCount += 1;
}
public void reset() {
SceneManager.LoadScene("Scenes/SampleScene");
}
public int GetScore() {
return 1;
}
}
玩家运动主要是通过检测键盘按下来判断移动的距离,这里前后主要是控制运动,左右是控制方向,也就是说光按左右会原地打转,需要配合前后才会运动起来。
实现也不难,只需要获取按下左右键对应的x,再将其乘以一个系数,作为旋转角度,然后使用rotate的方式旋转就好,而移动则是通过Translate的函数,默认是参照本地坐标系运动,所以只需要设置z坐标(本地坐标系的前进方向)运动即可。
镜头跟随设置
为了更好的获取玩家运动的画面,使用了镜头跟随的设置(这里参考了学长学姐的代码)。
由于玩家运动是通过本地坐标系运动,也就是说类似于第一人称运动,简单的坐标跟随是不行的,还需要连同坐标轴也一并跟随,实现镜头随着人转动的的视角,也就是类似吃鸡游戏的第三人称视角,人物的转动,也会使得镜头转动,区别于上帝视角(绝对坐标系运动)
所以不仅位置需要跟随,连角度也要跟随。
public class CameraView : MonoBehaviour
{
public GameObject follow;
public float smothing = 5f;
Vector3 offset;
Quaternion rotate = Quaternion.Euler(30, 0, 0);
void Start()
{
offset = new Vector3(0, 3, -3);
}
void FixedUpdate()
{
// Vector3 target = follow.transform.localPosition + offset;
Vector3 target = follow.transform.TransformPoint(offset);
Vector3 tmp = rotate.eulerAngles + follow.transform.rotation.eulerAngles;
Quaternion r = Quaternion.Euler(tmp);
transform.position = Vector3.Lerp(transform.position, target, smothing * Time.deltaTime);
transform.rotation = Quaternion.Lerp(transform.rotation, r, smothing/2 * Time.deltaTime);
}
}
使用到了Lerp函数,直观来讲就是通过内插的方法使其出现从快到慢的一个变化过度,使得过度更加自然不生硬。
还有就是摄像头永远位于目标的后上方,但是目标的坐标轴是在变换的,所以需要用到本地坐标与绝对坐标的变换函数:TransformPoint,将摄像头与目标的相对位置转化为绝对位置,才能确定摄像头移动的位置。
至于旋转角度,则需要摄像机的x轴朝向与目标的x轴相同,(因为摄像机会有向下的朝向,所以另外两个坐标的角度不同)
最终效果
画面经过压缩,画质差,不代表实际效果。
本次实验到此结束!