模型与动画
这是
3D
游戏编程的第七次作业
说明文档
本次实验完成了所有基本要求,尽量将步骤展示出。
闪光点:
动画状态机制作细节、预制制作细节
详细类图以及代码注释
作业内容
智能巡逻兵
- 游戏设计要求:
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
- 程序设计要求:
- 必须使用订阅与发布模式传消息
subject:OnLostGoal
Publisher: ?
Subscriber: ? - 工厂模式生产巡逻兵
- 必须使用订阅与发布模式传消息
效果展示
声明:本次实验使用了优秀博客中的预制,偷了个懒,但是游戏的状态机动画及碰撞体设置等都由自己完成,并基于优秀博客给出了我认为比较合适的设计(原优秀博客的代码设计是比较乱的)
- 巡逻兵只在自己的区域内巡逻,不会跨区域追踪
- 脱离追踪时能够获得一分(动图中在最后脱离第一个巡逻兵后分数从3变成4)
- 碰到巡逻兵后游戏结束,其他巡逻兵停止动画
设计与实现
状态机制作
-
玩家状态机
hasExitTime:是否有退出时间。简单理解:开启表示等待当前动画进行完才可进行下一个动画;关闭表示可以立即打断当前动画并播放下一个动画
所以只有取消勾选才能在持续按前进的时候马上切换到Run动画
-
巡逻兵状态机
与玩家状态机类似,但是巡逻兵没有死亡动画,只有在于玩家碰撞时的射击动画。
预制制作
预制的制作有两个重点,一个是巡逻兵的碰撞检测,一个是地图的区域检测。
-
巡逻兵碰撞检测:
巡逻兵的碰撞检测分为两部分:- 巡逻区域检测:
加入一个盒式碰撞体,并为其载入碰撞逻辑:
public class PatrolZoneCollider : MonoBehaviour { void OnTriggerEnter(Collider collider) { if (collider.gameObject.tag == "Player") { //玩家进入巡逻兵的巡逻范围 this.gameObject.transform.parent.GetComponent<PatrolData>().follow_player = true; this.gameObject.transform.parent.GetComponent<PatrolData>().player = collider.gameObject; } } void OnTriggerExit(Collider collider) { if (collider.gameObject.tag == "Player") { //玩家离开巡逻兵的巡逻范围 this.gameObject.transform.parent.GetComponent<PatrolData>().follow_player = false; this.gameObject.transform.parent.GetComponent<PatrolData>().player = null; } } }
也就是当玩家进入巡逻区域时,会马上去追踪玩家。
- 身体碰撞检测:
加入一个胶囊碰撞体,并为其载入碰撞逻辑:
public class PatrolCollider : 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(); } } }
在玩家与巡逻兵直接碰撞后,玩家播放死亡动画、巡逻兵播放射击动画,并且游戏结束。
- 巡逻区域检测:
-
地图内区域检测:
这个检测是为了实现最开始动图的效果,防止巡逻兵跨区域追踪。所以需要场景控制器维护一个关于玩家处于哪个区域的标价变量,并在玩家进入各个区域的碰撞体时触发区域标记更新。public class AreaCollider : MonoBehaviour { public int sign = 0; void OnTriggerEnter(Collider collider) { //标记玩家进入自己的区域 if (collider.gameObject.tag == "Player") { FirstController firstController = SSDirector.GetInstance().CurrentScenceController as FirstController; firstController.wall_sign = sign; } } }
关键细节
-
动作管理:
游戏要求我们巡逻兵会在玩家进入其巡逻区域时追踪,离开区域时继续其普通巡逻动作。那么根据之前动作管理者的职责,巡逻、追踪这两个小动作,在完成时应该通知动作管理者,于是,在通知的时候我们就可以来判断此时玩家的状态,从而使得决定下一个动作应该是追踪还是巡逻。
于是动作管理者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(); } }
而在两个动作简单类中,可以根据当前动作进行的状态判断未来动作应该是什么,举个例子就是说,如果当前巡逻兵正在追踪同一个区域内的玩家,如果被玩家逃离了,那么它应该通知动作管理者下一个动作是巡逻,于是向动作管理者发送的intParam参数为1.
相对应的代码为:// PatrolFollowAction 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.follow_player || data.wall_sign != data.sign) { this.destroy = true; this.callback.SSActionEvent(this, 1, this.gameobject); } }
-
订阅者模式:
-
事件发布者:专门发布事件的类,订阅者可以订阅该类的事件,通知者可以用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; // ... }
-
订阅者:
订阅者就是主场景控制器。订阅的方法如下,只要事件发布者检测到有人通知,就会调用这里注册的方法。void OnEnable() { GameEventManager.ScoreChange += AddScore; GameEventManager.GameoverChange += Gameover; GameEventManager.CrystalChange += ReduceCrystalNumber; } void OnDisable() { GameEventManager.ScoreChange -= AddScore; GameEventManager.GameoverChange -= Gameover; GameEventManager.CrystalChange -= ReduceCrystalNumber; }
-
通知者:
前面其实已经提到过,通知者就是每个碰撞检测代码,比如上面提到的巡逻兵身体碰撞检测:public class PatrolCollider : 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(); } } }
-
相机跟随:
为了视野更加广,需要将相机跟随人物,这样就不必显示和当前玩家位置太远的区域。public class CameraTrans : MonoBehaviour { public GameObject follow; //跟随的物体 public float smothing = 5f; //相机跟随的速度 public bool getInit = false; //表明是否正确初始化了跟随物体 Vector3 offset; //相机与物体相对偏移位置 void FixedUpdate() { // 等待正确获得跟随物体 if (!getInit) { if(follow == null) { return; } offset = transform.position - follow.transform.position; getInit = true; } //Vector3(0, 9, 0);主要是用于调整摄像机的高度 Vector3 target = follow.transform.position + offset + new Vector3(0, 9, 0); //摄像机自身位置到目标位置平滑过渡 transform.position = Vector3.Lerp(transform.position, target, smothing * Time.deltaTime); } }
代码结构
- 文件结构:
Model Controller View Action
- 类图:
传送门
本次作业也是进行的很坎坷,主要是对于动画状态机的不熟悉造成的,另外感谢前文提到的优秀博客,通过模仿学习到了很多,并且也改进了一些代码,如果想要知道代码细节请前往gitee仓库。