游戏对象与图形基础

    科技2024-07-27  16

    游戏对象与图形基础

    这是有游戏编程的第四次作业,对MVC深入

    文章目录

    游戏对象与图形基础说明文档作业内容`1.` 基本操作演练【建议做】`2.` 编程实践需求分析新版的设计与实现

    说明文档

    本次实验完成了所有基本要求,尽量将步骤展示出。 闪光点: 新增类图以及详细的代码注释来解析动作分离 对游戏改造时出现的问题进行分析和解决

    作业内容

    1. 基本操作演练【建议做】

    下载 Fantasy Skybox FREE, 构建自己的游戏场景

    选择天空盒:

    在菜单栏中依次点击Window > Rendering > Lighting Setting。在跳出的弹窗中选择Environment选项,将采用的天空盒拖入下面的红框中: 所选择的天空盒是:

    创建普通地形,并种花种草:

    补充说明上一步中如何种花草:

    在地形的Inspector中点击如下图标: 在Details栏中点击红框所示选项: 在弹出的二级菜单中点击Add Grass Texture。在跳出的窗口中,将花草的材质拖入图中上方的红框中,并点击右下方的Add:

    效果展示:

    写一个简单的总结,总结游戏对象的使用

    空对象:空对象多被用于挂载游戏脚本。3D Object:是游戏世界里面的组成元素,通过Transform等属性来改变他们的位置和状态。地形等:既是游戏世界的组成元素,又是一个编辑工具,附带了各种工具用于编辑地形和种花种草。摄像头:是游戏世界的视角观察工具,通常或作为玩家的眼睛,比如第一人称游戏。还有的是跟随游戏人物移动的地图摄像机。

    2. 编程实践

    牧师与魔鬼 动作分离版 【2019开始的新要求】:设计一个裁判类,当游戏达到结束条件时,通知场景控制器游戏结束

    需求分析

      这一次的作业建立在上一次作业的基础上,在这次的设计里面,我们需要把动作的管理抽取为一个裁判的角色,进行进一步的角色专职管理。

      首先看看上一次作业的UML类图,我们可以看到:

      MoveController控制着游戏对象的移动,但是运动不论是基本还是组合动作都耦合在一起,同时,裁判的判断职责以及具体控制对象的移动的逻辑都在FirstController中,这将导致FirstController的职责不再单一,我们考虑使用恰当的接口设计来使得这些角色进一步分离。

      基于以上判断,动作分离版的新特性可列举如下:

    使用专门的对象来管理运动,包括基本动作、组合动作。并且,不需要再像旧版一样每个对象都加上Move组件。游戏中新增裁判类,当游戏达到结束条件时,通知场景控制器游戏结束。在旧版中,FirstController使用Check函数判断游戏是否结束。现在将游戏的胜利与失败交给裁判管理,场景控制器新增处理裁判的反馈的回调函数即可,进一步解耦合职责。

    新版的设计与实现

    新版的设计思想里借鉴了老师课件,我觉得总结的很好,我摘抄并加上自己的理解展示如下:

    通过门面模式(控制器模式)来输出几个预先设计好的动作,供场景控制器使用 好处:动作的具体组合逻辑变成动作模块的内部事务,不需要场景控制器操心具体运动在新版设计中,这个门面也就是CCActionManager。 通过组合模式实现动作组合,按照组合模式的设计方法 必须要有一个接口表示事物的共性和行为,例如新版设计中的SSAction,表示动作,它可以表示基本动作也可以表示组合基本动作。基本动作:用户设计的基本动作类,如CCMoveToAction。组合动作:由(基本或组合)动作组合的类,如CCSequenceAction。 接口回调(函数回调)实现管理者与被管理者解耦 如组合对象实现一个事件抽象接口(ISSActionCallback),作为监听器监听子动作的事件。被组合对象使用监听器传递消息给管理者。至于管理者如何控制子动作顺序或者逻辑就是实现这个监听器接口的负责人说了算,但”别人敢实现,我就敢用“。前两点是很重要的,子动作序列操控着物体移动,而顺序便是最开始定义的,然而每一次移动之间的衔接如何进行呢?其实就是通过接口回调通知组合动作对象说:嘿,我这个子动作完成了,你可以接着下一个子动作了。 通过模板方法,让使用者减少对动作管理过程细节的要求 SSActionManager作为CCActionManager基类。

    最终,我们将可以很方便的定义动作并实现动作的自用组合,程序更易于适应需求变化和易于维护,对象更容易被复用。

    旧版的代码结构图以及类图可参考我上一篇作业。

    新版的代码结构图:

    在上一次的MVC结构上,这一次新增Actions。

    新版的UML图

    新增代码的讲解(我的注释都在代码中,都是细节呀!)

    动作基类(SSAction):// 动作基类SSAction // ScriptableObject 是不需要绑定 GameObject 对象的可编程基类。 // 这些对象受 Unity 引擎场景管理 public class SSAction : ScriptableObject { public bool enable = true; public bool destroy = false; public GameObject gameObject { get; set; } public Transform transform { get; set; } // 利用接口(ISSACtionCallback)实现消息通知, // 避免与动作管理者直接依赖 public ISSActionCallback callback { get; set; } // protected 防止用户自己 new 抽象的对象 protected SSAction() { } // 使用 virtual 申明虚方法,通过重写实现多态。 // 这样继承者就明确使用 Start 和 Update 编程游戏对象行为 // Start is called before the first frame update public virtual void Start() { throw new System.NotImplementedException(); } // Update is called once per frame public virtual void Update() { throw new System.NotImplementedException(); } } 简单动作实现(CCMoveToAction):// 简单动作实现 public class CCMoveToAction : SSAction { public Vector3 target; // 目的地 public float speed;// 速度 private CCMoveToAction() { } // 工厂函数 public static CCMoveToAction GetSSAction(Vector3 target, float speed) { // 让Unity来创建动作类,确保内存正确回收。 CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction>(); action.target = target; action.speed = speed; return action; } // Start is called before the first frame update public override void Start() { } // Update is called once per frame public override void Update() { // 首先判断是否符合移动条件 if (this.gameObject == null || this.transform.localPosition == target) { this.destroy = true; this.callback.SSActionEvent(this); return; } // 可以移动 this.transform.localPosition = Vector3.MoveTowards(this.transform.localPosition, target, speed * Time.deltaTime); } } 顺序动作组合类实现(CCSequenceAction):public class CCSequenceAction : SSAction, ISSActionCallback { public List<SSAction> sequence; // 动作序列 public int repeat = -1; // 重复次数,默认为无限 public int start = 0; // 工厂函数 public static CCSequenceAction GetSSAction(int repeat, int start, List<SSAction> sequence) { CCSequenceAction action = ScriptableObject.CreateInstance<CCSequenceAction>(); action.repeat = repeat; action.start = start; action.sequence = sequence; return action; } // 对序列中的动作进行初始化 // Start is called before the first frame update public override void Start() { // 执行动作前,为每个动作注入当前动作游戏对象, // 并将自己作为动作事件的接收者 foreach (SSAction action in sequence) { action.gameObject = this.gameObject; action.transform = this.transform; action.callback = this; action.Start(); } } // 运行序列中的当前动作 // Update is called once per frame public override void Update() { if (sequence.Count == 0) { return; } if (start < sequence.Count) { sequence[start].Update(); } } // 回调处理,当有动作完成时触发该函数 public void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Computed, int intParam = 0, string strParam = null, Object objectParam = null) { // 收到当前动作完成的通知, // 应该进行下一个动作 source.destroy = false; this.start++; if (this.start >= sequence.Count) { // 如果完成一次循环,减掉次数 this.start = 0; if (repeat > 0) { repeat--; } // 如果次数减为0,代表所有动作结束, /// 应该通知接收者。 if (repeat == 0) { this.destroy = true; this.callback.SSActionEvent(this); } } } void onDestroy() { } } 动作事件接口定义(ISSActionCallback): 接口作为接收通知对象(参数source)的抽象类型。// 定义事件类型 public enum SSActionEventType : int { Started, Computed } public interface ISSActionCallback { // 事件处理接口,所有事件管理者都必须实现这个接口 // 来实现事件调度。 // 所以sequenceAction、ActionManager都必须实现它 void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Computed, int intParam = 0, string strParam = null, Object objectParam = null); } 动作管理基类(SSActionManager): 这是动作对象管理器的基类,实现了所有动作的基本管理。public class SSActionManager : MonoBehaviour { // 动作字典集 public Dictionary<int, SSAction> actions = new Dictionary<int, SSAction>(); // 等待被加入的动作队列,这是即将开始的动作 private List<SSAction> waitingAdd = new List<SSAction>(); // 等待被删除的动作队列,这是已经完成的动作 private List<int> waitingDelete = new List<int>(); // Start is called before the first frame update protected void Start() { } // Update is called once per frame protected void Update() { // 首先将waitingAdd中的动作保存 foreach (SSAction action in waitingAdd) { actions[action.GetInstanceID()] = action; } waitingAdd.Clear(); // 运行被保存的动作 foreach (KeyValuePair<int, SSAction> kv in actions) { SSAction ac = kv.Value; if (ac.destroy) { waitingDelete.Add(ac.GetInstanceID()); } else if (ac.enable) { ac.Update(); } } // 删除waitingdelete中的动作 foreach (int key in waitingDelete) { SSAction ac = actions[key]; actions.Remove(key); Destroy(ac); } waitingDelete.Clear(); } // 此方法用于添加新动作,该方法把游戏对象与动作绑定 // 并绑定该动事件的消息接收者 public void RunAction(GameObject gameObject, SSAction action, ISSActionCallback manager) { action.gameObject = gameObject; action.transform = gameObject.transform; action.callback = manager; waitingAdd.Add(action); action.Start(); } } 这里回答一下老师的问题,即动作集合设计中字典是线程不安全的,有影响吗?其实是没影响的,首先能够管理动作集合的类对象只有一个,而单个游戏对象的事件循环都是单线程的,对动作集合的修改没有并发访问或修改的问题。

      上面就是动作管理者的实现。除此之外,我们还设计一个裁判类,裁判类其实是一个控制器:

    public class JudgeController : MonoBehaviour { public FirstController mainController; public LandModel leftLandModel; public LandModel rightLandModel; public BoatModel boatModel; // Start is called before the first frame update void Start() { mainController = (FirstController)SSDirector.GetInstance().CurrentSenceController; this.leftLandModel = mainController.leftLandRoleController.GetLandModel(); this.rightLandModel = mainController.rightLandRoleController.GetLandModel(); this.boatModel = mainController.boatModelController.GetBoatModel(); } // Update is called once per frame void Update() { if (!mainController.isRunning) { return; } // 如果时间消耗尽 if (mainController.time <= 0) { mainController.JudgeCallback(false, "Game Over!"); } // 否则游戏还在进行 this.gameObject.GetComponent<UserGUI>().gameMessage = ""; // 判断是否已经胜利 if (leftLandModel.priestNum == 3) { mainController.JudgeCallback(false, "You Win!"); return; } else { int leftPriestNum, leftDevilNum, rightPriestNum, rightDevilNum; leftPriestNum = leftLandModel.priestNum + (boatModel.onRight ? 0 : boatModel.priestNum); leftDevilNum = leftLandModel.devilNum + (boatModel.onRight ? 0 : boatModel.devilNum); if (leftPriestNum != 0 && leftDevilNum > leftPriestNum) { mainController.JudgeCallback(false, "Game Over!"); return; } rightPriestNum = rightLandModel.priestNum + (boatModel.onRight ? boatModel.priestNum : 0); rightDevilNum = rightLandModel.devilNum + (boatModel.onRight ? boatModel.devilNum : 0); if (rightPriestNum != 0 && rightDevilNum > rightPriestNum) { mainController.JudgeCallback(false, "Game Over!"); return; } } } }

    那么对应的,我们的场景控制器FirstController也要进一步修改,来接入裁判和动作管理,代码有点长,我类似diff一个列出变化,突出这一次的新设计的接入。

    public class FirstController : MonoBehaviour, ISceneController, IUserAction { // 新增动作管理类 public CCActionManager actionManager; // 不在需要 // public MoveController moveController; // 新增函数,用于让裁判通知游戏状态以及消息 public void JudgeCallback(bool isRunning, string msg) { this.gameObject.GetComponent<UserGUI>().gameMessage = msg; this.gameObject.GetComponent<UserGUI>().time = (int)time; this.isRunning = isRunning; } public void LoadResources() { // 移动控制器实例化已经不需要 // moveController = new MoveController(); } public void MoveBoat() { if ((!isRunning) || actionManager.IsMoving()) { return; } Vector3 target = boatModelController.GetBoatModel().onRight ? PositionModel.left_boat : PositionModel.right_boat; // 直接交给动作管理进行 actionManager.MoveBoat(boatModelController.GetBoatModel().boat, target, 5); // 方向取反 boatModelController.GetBoatModel().onRight = !boatModelController.GetBoatModel().onRight; } public void MoveRole(RoleModel roleModel) { if ((!isRunning) || actionManager.IsMoving()) { return; } Vector3 destination, mid_destination; if (roleModel.onBoat) { // 在船上 if (boatModelController.GetBoatModel().onRight) { destination = rightLandRoleController.AddRole(roleModel); } else { destination = leftLandRoleController.AddRole(roleModel); } if (roleModel.role.transform.localPosition.y > destination.y) { mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z); } else { mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z); } // 直接交给动作管理进行 actionManager.MoveRole(roleModel.role, mid_destination, destination, 5); // 角色的方向不能每次取反,因为可以在同一边重复上下船 roleModel.onRight = boatModelController.GetBoatModel().onRight; boatModelController.RemoveRole(roleModel); } else { // 在陆地并且船未满 if (boatModelController.GetBoatModel().onRight == roleModel.onRight && boatModelController.GetBoatModel().devilNum + boatModelController.GetBoatModel().priestNum < 2) { if (roleModel.onRight) { rightLandRoleController.RemoveRole(roleModel); } else { leftLandRoleController.RemoveRole(roleModel); } destination = boatModelController.AddRole(roleModel); if (roleModel.role.transform.localPosition.y > destination.y) { mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z); } else { mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z); } // 直接交给动作管理进行 actionManager.MoveRole(roleModel.role, mid_destination, destination, 5); } } } public void Restart() { // 必须实例化一个新的裁判,否则使用的是历史数据 Destroy(this.gameObject.GetComponent<JudgeController>()); this.gameObject.AddComponent<JudgeController>(); } private void Awake() { SSDirector.GetInstance().CurrentSenceController = this; LoadResources(); this.gameObject.AddComponent<UserGUI>(); this.gameObject.AddComponent<CCActionManager>(); this.gameObject.AddComponent<JudgeController>(); } private void Update() { if (isRunning) { time -= Time.deltaTime; this.gameObject.GetComponent<UserGUI>().time = (int)time; // 不再具体管控判断和运动逻辑 } } }

    其他改动:

    IUserAction接口移除 Check()方法,修改过后,程序即可运行,效果与上个版本一样,就不重复贴图了。

    遇到的问题与解决办法:

    在这次的实验里面,由于相隔了一个星期,所以不免有点记不清之前的代码逻辑,所以在调试游戏时,出现了一个问题:第一次进入游戏的时候,一切正常,船可以左右移动,但是,当船移动到左边并输掉游戏时点击Restart之后,新游戏里面如果我让牧师上船,船还没移动就显示游戏结束了。但是我通过Debug.Log输出此时右边(包括岸和船上)的牧师数量时,却发现没问题: 分析以及解决: 第一次是怀疑在运动过程中,对象没有在加到船上时裁判就介入判断,所以出现了误判。所以我在裁判的判断的加入了:void Update() { if (mainController.actionManager.isMoving //这是防止在运动时判断 || !mainController.isRunning) { return; } // .. } 但是这没有解决问题,为什么呢?因为阅读船的AddRole源码就知道人物在移动前其引用已经添加到船模型中,所以根本不存在运动时角色引用不在船上的问题(不然和上面的日志矛盾了),而且上面的补充代码也没啥用,反而使得游戏的输赢判断在运动结束时才进行。后来通过分析刚才的日志,根据经验以及分析,发现场景控制器在Restart的时候,并没有新实例化一个裁判类!这将导致裁判类仍然引用着上一次游戏的角色、船的模型,是旧的数据,这不仅会导致内存无法释放,还会使得游戏发生错误,于是发现问题后,我在场景控制器的重启函数中加入了如下代码:public void Restart() { // 必须实例化一个新的裁判,否则使用的是历史数据 Destroy(this.gameObject.GetComponent<JudgeController>()); this.gameObject.AddComponent<JudgeController>(); } 这样子问题就解决了。

    如果想要了解更多的代码细节,请移步我的gitee仓库。

    Processed: 0.040, SQL: 8