一、作业要求
游戏设计要求:
创建一个地图和若干巡逻兵(使用动画);
每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
巡逻兵在设定范围内感知到玩家,会自动追击玩家;
失去玩家目标后,继续巡逻;
计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束。
程序设计要求:
必须使用订阅与发布模式传消息
工厂模式生产巡逻兵
二、项目演示地址
https://www.bilibili.com/video/BV1AP4y1B7Np/?vd_source=66cc21f92131571125d69a5f24344dcd
三、运行说明
1.创建unity 3d 项目 , 载入Assets文件夹
2.双击Scenes文件夹下的mySence
3.启动游戏
4.WASD或上下左右箭头控制转向与前进和后退
四、效果截图
五、设计思路与代码分析
巡逻兵基本数据实体类PatrolData
public class PatrolData : MonoBehaviour
{
public int sign; //标志巡逻兵在哪一块区域
public bool follow_player = false; //是否跟随玩家
public int wall_sign = -1; //当前玩家所在区域标志
public GameObject player; //玩家游戏对象
public Vector3 start_position; //当前巡逻兵初始位置
}
PropFactory类-道具工厂,创建了9个巡逻兵,因为巡逻兵的位置有规律所以用循环就可以赋值初始位置,还设置了每个巡逻兵所在区域的标志。当游戏结束时候,需要工厂将巡逻兵的动画设置为初始状态。部分代码如下
public List<GameObject> GetPatrols()
{
int[] pos_x = { -6, 4, 13 };
int[] pos_z = { -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_z[j]);
index++;
}
}
for(int i=0; i < 9; i++)
{
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];
used.Add(patrol);
}
return used;
}
public void StopPatrol()
{
//切换所有巡逻兵的动画
for (int i = 0; i < used.Count; i++)
{
used[i].gameObject.GetComponent<Animator>().SetBool("run", false);
}
}
巡逻兵巡逻与追捕GoPatrolAction
巡逻兵巡逻的动作,根据四个方向来选择要去到的目的地,当当前位置与目的地相差0.9f的时候,换一个方向继续巡逻
public class GoPatrolAction : SSAction
{
private enum Dirction { EAST, NORTH, WEST, SOUTH };
private float pos_x, pos_z; //移动前的初始x和z方向坐标
private float move_length; //移动的长度
private float move_speed = 1.2f; //移动速度
private bool move_sign = true; //是否到达目的地
private Dirction dirction = Dirction.EAST; //移动的方向
private PatrolData data; //巡逻兵的数据
private GoPatrolAction() { }
public static GoPatrolAction GetSSAction(Vector3 location)
{
GoPatrolAction action = CreateInstance<GoPatrolAction>();
action.pos_x = location.x;
action.pos_z = location.z;
//设定移动矩形的边长
action.move_length = Random.Range(4, 7);
return action;
}
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.follow_player && data.wall_sign == data.sign)
{
this.destroy = true;
this.callback.SSActionEvent(this,0,this.gameobject);
}
}
public override void Start()
{
this.gameobject.GetComponent<Animator>().SetBool("run", true);
data = this.gameobject.GetComponent<PatrolData>();
}
void Gopatrol()
{
if (move_sign)
{
//不需要转向则设定一个目的地,按照矩形移动
switch (dirction)
{
case Dirction.EAST:
pos_x -= move_length;
break;
case Dirction.NORTH:
pos_z += move_length;
break;
case Dirction.WEST:
pos_x += move_length;
break;
case Dirction.SOUTH:
pos_z -= move_length;
break;
}
move_sign = false;
}
this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z));
//当前位置与目的地距离浮点数的比较
if (distance > 0.9)
{
transform.position = Vector3.MoveTowards(this.transform.position, new Vector3(pos_x, 0, pos_z), move_speed * Time.deltaTime);
}
else
{
dirction = dirction + 1;
if(dirction > Dirction.SOUTH)
{
dirction = Dirction.EAST;
}
move_sign = true;
}
}
}
PatrolFollowAction
巡逻兵朝着玩家的位置移动,移动结束的条件是玩家离开了巡逻兵触发器的范围或是玩家已经不在该区域内了
public class PatrolFollowAction : SSAction
{
private float speed = 2f; //跟随玩家的速度
private GameObject player; //玩家
private PatrolData data; //侦查兵数据
private PatrolFollowAction() { }
public static PatrolFollowAction GetSSAction(GameObject player)
{
PatrolFollowAction action = CreateInstance<PatrolFollowAction>();
action.player = player;
return action;
}
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);
}
Follow();
//如果侦察兵没有跟随对象,或者需要跟随的玩家不在侦查兵的区域内
if (!data.follow_player || data.wall_sign != data.sign)
{
this.destroy = true;
this.callback.SSActionEvent(this,1,this.gameobject);
}
}
public override void Start()
{
data = this.gameobject.GetComponent<PatrolData>();
}
void Follow()
{
transform.position = Vector3.MoveTowards(this.transform.position, player.transform.position, speed * Time.deltaTime);
this.transform.LookAt(player.transform.position);
}
}
SSActionManager
从上面可以看到,巡逻动作结束条件是需要追捕玩家,所以调用了回调函数,用回调函数来进行追捕动作。而当玩家离开追捕范围后,需要重新巡逻,也需要调用回调函数,从初始的位置和方向继续巡逻。除此之外,SSActionManager还实现了游戏结束后,摧毁所有动作,巡逻兵不再移动。部分代码如下
public void SSActionEvent(SSAction source, int intParam = 0, GameObject objectParam = null)
{
if(intParam == 0)
{
//侦查兵跟随玩家
PatrolFollowAction follow = PatrolFollowAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().player);
this.RunAction(objectParam, follow, this);
}
else
{
//侦察兵按照初始位置开始继续巡逻
GoPatrolAction move = GoPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().start_position);
this.RunAction(objectParam, move, this);
//玩家逃脱
Singleton<GameEventManager>.Instance.PlayerEscape();
}
}
public void DestroyAll()
{
foreach (KeyValuePair<int, SSAction> kv in actions)
{
SSAction ac = kv.Value;
ac.destroy = true;
}
}
PatrolActionManager
初始的时候场景控制器调用PatrolActionManager中的方法,让巡逻兵开始巡逻,当游戏结束的时候,调用方法让巡逻兵停止巡逻
public class PatrolActionManager : SSActionManager
{
private GoPatrolAction go_patrol; //巡逻兵巡逻
public void GoPatrol(GameObject patrol)
{
go_patrol = GoPatrolAction.GetSSAction(patrol.transform.position);
this.RunAction(patrol, go_patrol, this);
}
//停止所有动作
public void DestroyAllAction()
{
DestroyAll();
}
}
玩家部分
该部分实现了玩家上下移动和左右旋转,并播放对应的动画以及相机的跟随。(相机跟随与上一个游戏代码一样)
UserGUI
在UserGUI得到用户的输入,然后调用场景控制器移动玩家的函数。部分代码如下
void Update()
{
//获取方向键的偏移量
float translationX = Input.GetAxis("Horizontal");
float translationZ = Input.GetAxis("Vertical");
//移动玩家
action.MovePlayer(translationX, translationZ);
}
FirstSceneController
获得偏移量进行上下的移动,左右的旋转,播放对应的动画。部分代码如下
//玩家移动
public void MovePlayer(float translationX, float translationZ)
{
if(!game_over)
{
if (translationX != 0 || translationZ != 0)
{
player.GetComponent<Animator>().SetBool("run", true);
}
else
{
player.GetComponent<Animator>().SetBool("run", false);
}
//移动和旋转
player.transform.Translate(0, 0, translationZ * player_speed * Time.deltaTime);
player.transform.Rotate(0, translationX * rotate_speed * Time.deltaTime, 0);
//防止碰撞带来的移动
if (player.transform.localEulerAngles.x != 0 || player.transform.localEulerAngles.z != 0)
{
player.transform.localEulerAngles = new Vector3(0, player.transform.localEulerAngles.y, 0);
}
if (player.transform.position.y != 0)
{
player.transform.position = new Vector3(player.transform.position.x, 0, player.transform.position.z);
}
}
}
区域部分
游戏场景中有9个格子,每个格子为一个区域,每个区域设置了一个Box Collider用于检测玩家是否进入该区域,(防止玩家在另一个区域但是进入了其他区域的巡逻兵的触发器时,巡逻兵隔着墙去追捕玩家的情况)
AreaCollide
每个区域有自己的标识(将脚本挂载在每个区域上进行设置),当玩家进入该区域的时候,会设置场景控制器的区域标识为自己的标识,这样其他的巡逻兵就知道玩家现在在哪个区域了
public class AreaCollide : MonoBehaviour
{
public int sign = 0;
FirstSceneController sceneController;
private void Start()
{
sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
}
void OnTriggerEnter(Collider collider)
{
//标记玩家进入自己的区域
if (collider.gameObject.tag == "Player")
{
sceneController.wall_sign = sign;
}
}
}
订阅与发布模式部分
该部分的数值变化是通过订阅与发布模式实现的,FirstSceneController是模式中的订阅者。
发布事件类
GameEventManager
定义一个专门发布事件的类,订阅者可以订阅该类的事件,当其他类发生改变的时候,会使用GameEventManager的方法发布消息,触发相应事件
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)
{
ScoreChange();
}
}
//玩家被捕
public void PlayerGameover()
{
if (GameoverChange != null)
{
GameoverChange();
}
}
//减少水晶数量
public void ReduceCrystalNum()
{
if (CrystalChange != null)
{
CrystalChange();
}
}
}
订阅者
FirstSceneController
场景控制器作为订阅者,订阅了GameEventManager中的事件,只要相应事件发生,就会导致场景控制器调用注册的方法,部分代码如下
void OnEnable()
{
//注册事件
GameEventManager.ScoreChange += AddScore;
GameEventManager.GameoverChange += Gameover;
GameEventManager.CrystalChange += ReduceCrystalNumber;
}
void OnDisable()
{
//取消注册事件
GameEventManager.ScoreChange -= AddScore;
GameEventManager.GameoverChange -= Gameover;
GameEventManager.CrystalChange -= ReduceCrystalNumber;
}
void ReduceCrystalNumber()
{
recorder.ReduceCrystal();
}
void AddScore()
{
recorder.AddScore();
}
void Gameover()
{
game_over = true;
patrol_factory.StopPatrol();
action_manager.DestroyAllAction();
}
水晶触碰
CrystalCollide
当玩家触碰到水晶的时候,水晶消失,水晶数量减少
public class CrystalCollide : MonoBehaviour
{
void OnTriggerEnter(Collider collider)
{
if (collider.gameObject.tag == "Player" && this.gameObject.activeSelf)
{
this.gameObject.SetActive(false);
//减少水晶数量,发布消息
Singleton<GameEventManager>.Instance.ReduceCrystalNum();
}
}
}
玩家摆脱巡逻兵
SSActionManager
当玩家逃离巡逻兵触发器的范围的时候(此时巡逻兵的跟随玩家的动作会调用回调函数),分数会增加
public void SSActionEvent(SSAction source, int intParam = 0, GameObject objectParam = null)
{
if(intParam == 0)
{
//侦查兵跟随玩家
PatrolFollowAction follow = PatrolFollowAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().player);
this.RunAction(objectParam, follow, this);
}
else
{
//侦察兵按照初始位置开始继续巡逻
GoPatrolAction move = GoPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<PatrolData>().start_position);
this.RunAction(objectParam, move, this);
//玩家逃脱,发布消息
Singleton<GameEventManager>.Instance.PlayerEscape();
}
}
玩家和巡逻兵碰撞
PlayerCollide
巡逻兵身上的碰撞器触碰到玩家的碰撞器将会使游戏结束,并且各自播放对应动画
public class PlayerCollide : MonoBehaviour
{
void OnCollisionEnter(Collision other)
{
//当玩家与巡逻兵相撞
if (other.gameObject.tag == "Player")
{
other.gameObject.GetComponent<Animator>().SetTrigger("death");
this.GetComponent<Animator>().SetTrigger("shoot");
//游戏结束,发布消息
Singleton<GameEventManager>.Instance.PlayerGameover();
}
}
}
上述就是订阅与发布模式所涉及到的类,如果对该模式还不是很理解,拿出上面的一种情况当做一个例子:假如大明星(CrystalCollide)的动态都由公众号(GameEventManager)来发布,小粉丝(FirstSceneController)订阅了公众号(GameEventManager)的明星掉粉(CrystalChange)这个事件,并且为掉粉事件注册了哭泣(ReduceCrystalNumber)这个方法。那么大明星(CrystalCollide)发布消息给公众号(调用公众号的ReduceCrystalNum方法)说它掉粉了,则公众号(GameEventManager)看有没有人订阅掉粉了这个事件,如果有人订阅则告诉它明星掉粉了(触发事件CrystalChange),那订阅了该事件的小粉丝(FirstSceneController)就会哭泣(调用自己的ReduceCrystalNumber方法)。
音乐部分
该部分实现了循环播放背景音乐以及与巡逻兵触碰时候发出碰撞的声音,设置了一个音乐管理者
AudioManager
音乐管理者有自己的音频,然后在触发了某种状态的时候播放对应的音乐,本次游戏在音乐管理者中只有一个碰撞时候的音频,使用了一个静态方法PlayClipAtPoint,可以在设置的位置播放设置音量的音频片段,详见官方文档或博客链接。使用这个函数播放音乐的时候会自动生成一个名为”One shot audio”的物体,在播放完一次音频后销毁这个物体
public class AudioManager : MonoBehaviour
{
public AudioClip gameoverClip;
public void PlayMusic(AudioClip clip)
{
FirstSceneController scene = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
//在一个玩家的位置播放音乐
AudioSource.PlayClipAtPoint(clip, scene.player.transform.position);
}
void OnEnable()
{
GameEventManager.GameoverChange += Gameover;
}
void OnDisable()
{
GameEventManager.GameoverChange -= Gameover;
}
//播放游戏结束时音乐
void Gameover()
{
PlayMusic(gameoverClip);
}
}
Main Camera
因为PlayClipAtPoint只是播放一次音乐,对于bgm是需要循环播放的,所以在Main Camera添加组件Audio Source 和 Audio Listener,详见官方文档AudioSource和AudioListener。如下图设置
补充
游戏运行时候,脚本的执行顺序不太一样,为了防止在其他需要用到FirstSceneController 的脚本在FirstSceneController 执行之前就已经执行了,所以我进行了下面的设置,将FirstSceneController 的执行顺序提到了最前面。
六、总结
本次游戏使用了委托和事件,一方面主要学习了设计方式中的发布者订阅者模式,降低耦合度。另一方面学习了人物模型和动画的使用。发布与订阅模式定义了一种一对多的依赖关系,实现了让多个订阅者对象(本次游戏只有FirstSceneController)同时监听某一个主题对象。这个对象在状态发生变化时会通知所有订阅者对象,使它们能够自动更新自己。