【学习笔记】Unity & C#异常处理(下)

二、【我队友呢??】——针对非预期事件的安全校验

2.1 管控非预期事件

上一章,我们讨论了有关组件的各类异常的处理。虽然“组件丢失”之类的问题显得十分具体,但它们实际也反映了更宽泛的情况——游戏中可能时常会发生“非预期事件

一款出色的游戏,应该是稳定,健壮而鲜见bug的;无论玩家作出何种复杂的操作,都应当能够避免异常问题的出现。因此,我们在游戏程序的设计与实现中,必须为程序赋予一种“警觉性”,使其能够随时发现并处理游戏内事物的非预期状态。

从这里开始,我们将会研究对游戏内“非预期事件”的查验和管控,这是保证游戏程序稳定性的关键所在;这里默认大家对Unity的操作和脚本编程具有一定经验和熟练度,并且有一些具体游戏功能的编码经验。如果你感到理解起来有难度,可以自行查阅相关资料,或者根据书本、视频教程等尝试写一两个小的游戏demo,再回来阅读下面的内容。

2.2 非预期状态:不能对已经阵亡的士兵下达指令!

Talk is cheap,在编程问题上空谈无益。我们直接来看一个情境。

在一款战术策略游戏中,我们希望将若干名我方士兵存储为一个列表,称为一个【编队】;之后,玩家可以对此【编队】内的所有士兵下达集体性指令。例如,玩家点击地图上某个地点,编队内的所有士兵都开始移动,并向该地点进发;玩家点击一名敌人,编队内的所有士兵对其发动攻击。

然而,在实际的游戏中,这些士兵的状态决不会是一成不变的。

例如,根据生命值是否为零,可以将士兵的状态分为存活死亡

      一旦士兵在某一时刻生命值归零,即判定为战死,此时该士兵仅剩的任务就是执行自身的死亡动画,而与士兵相关的其它任何事件此后都与他无关了。此时,我们显然不希望一名已经阵亡,正在执行倒地动画的士兵重新收到一份攻击指令,然后以“诈尸”的方式向敌人开出一枪——那可实在是太滑稽了。

    如果在程序中不加注意,类似的滑稽事件将会数不胜数。例如,我方牧师发射的一枚治疗飞弹可能会打中一名死去的友军,然后将其生命值从0加回正值,从而使友军起死回生;我方士兵发射一枚锁定敌人的导弹后,如果敌人在导弹命中之前就死亡并消失,导弹就会失去追踪目标,继而茫然无措地飘在半空。

是不是有够滑稽?

至此,相信你已经充分认识到了游戏内异常处理的复杂性!不过,我们可以从简单的地方做起。

例如,如何防止一名死去的士兵重新收到作战指令?很简单,我们需要在有士兵死亡时,将阵亡的个体从编队列表中移除。

到这里,我们就遇到了一个具体的问题:

如何从编队中查找并移除符合特定条件的成员?

2.3 反序遍历——将阵亡者从编队中除名

现在是游戏开发时间!在Unity中建立新场景,使用3D Objects: Capsule创建5个模拟士兵物体,分别命名为0/1/2/3/4。为每个“士兵”挂载上HitPoint生命值组件(代码见下文)。然后,将1号、2号、4号士兵的生命值设定为0,表示已经“阵亡”的士兵。(在后面的展示图中,“阵亡”的1/2/4号士兵将会呈躺倒姿态放置,便于直观地区分)

此处使用的HitPoint组件代码:(相比上一篇内容的HitPoint进行了简化调整)

using UnityEngine;

public class HitPoint : MonoBehaviour
{
    public int HitPointLimit = 100;
    [SerializeField]
    private int Hitpoint = 30;

    public int GetHitPoint()
    {
        return Hitpoint;
    }

    public void ChangeHitPoint(int hp)
    {
        Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
    }
}

建立一个新脚本并命名为Team(代码见下文),该组件将会将全部士兵存储为一个List列表。按下键盘上的C键,Team组件会进行一项遍历操作,试图查找死亡的士兵,将其在游戏中隐去,然后将其从编队中移除。

Team组件代码:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Team : MonoBehaviour {

public List<GameObject> Soldiers = new List<GameObject>();

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.C))
        {
            Check();
        }
    }

    void Check()
    {
        for (int i = 0; i < Soldiers.Count; i++)
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置为i的士兵的生命值为0
            {
                Soldiers[i].SetActive(false);//立即隐去这个士兵
                Soldiers.RemoveAt(i);//将这个士兵从列表中移除
            }
        }
    }
}

将Team组件挂载到任意的非士兵物体上,然后在Inspector面板中操作Team组件,将五名士兵录入Soldiers列表。

运行游戏并按下C键,观察五名士兵的变化。

奇怪的现象出现了:阵亡士兵中的1号和4号被正确移除,但是2号却仍然存留。

怎么会这样?

我们来梳理一下上面那段“查删”代码的工作流程。

    void Check()
    {
        for (int i = 0; i < Soldiers.Count; i++)
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置为i的士兵的生命值为0
            {
                Soldiers.RemoveAt(i);//将这个士兵从列表中移除
            }
        }
    }

Check()方法开始执行后:

-------------

i = 0;

检查列表0号位的士兵;

不符合删除条件;

-------------

i = 1;

检查列表1号位的士兵;

符合删除条件!删除它;

此时,列表中原先的1号成员消失了,而原先的2号成员,即2号士兵则会立即回落一位,成为列表新的1号成员。类似地,3号士兵成为2号成员,4号士兵成为3号成员。

------------

i = 2;

检查列表2号位的士兵?

......

这时,我们发现问题出现了。for循环下一个将要检查的是列表的2号成员,但由于先前1号士兵被删除引起的补位现象,列表的2号成员已经不再是2号士兵,而是3号士兵。而真正的2号士兵则躲到了列表内的1号位——这个位置再也不会被检查一遍了。于是,这个看似规范合理的遍历过程就出现了错误,2号士兵堂而皇之地逃过了检查,没有从列表中被删除。

搞清楚了嘛?有没有如梦初醒的感觉?

那么,怎样才算是正确的查删操作呢?方法非常简单,只要对列表进行反序遍历即可——从列表最后一名成员开始,遍历到第一名成员时结束。

修改Team组件的代码,使用反序遍历:

再次运行,发现躺倒的士兵全都被正确地移除了。

实际上,你以后不必一直记得前面讲过的“补位现象”是怎么回事。只要牢牢记住以下规则,并在编码时死记硬背即可:

对于任何一项遍历列表的操作,只要有可能在中途删除成员,那么必须进行反序遍历:

for (int i = list.count - 1;  i >= 0;  i--)   {......}

至于反序遍历是如何在校验时避免遗漏的,请大家自行验算——原理非常简单。下面提供了一种比较清晰的理解方式:

如果将列表成员编号看成数轴,i看成一个查询游标的话,当我们进行反序遍历时,删除成员所引起的补位现象,只会发生在游标i的右侧。而在反序遍历时,游标是从右往左移动的,因此,游标右侧的成员都是已经被校验过并确认保留的成员,它们在列表中的编号发生何种改变已经无关紧要。而对于尚未校验过的成员,它们一直都会乖乖地待在游标的左侧,编号不发生改变,直到接受完校验过程为止。

此外,你还可以记住如下结论:

·如果使用正序遍历来删除成员,那么每一名被删除的成员的下一名成员将会逃过检查;

·foreach的遍历顺序与for的正序循环相同,因此用foreach进行查删会产生错误。根据编译器的不同,编译器可能会直接报错,或者发出“遍历结果可能不正确的警告。

2.4 实时校验的解决方案

现在,我们掌握了针对【可能出现异常成员】的列表的校验方法。在游戏中,一名士兵的阵亡可能会发生在任何时候。那么,为了及时移除阵亡士兵,我们应当在何种时机,对编队执行校验操作呢?

比较容易想到的方案是,一刻不停地执行查删。就好像,针对一群士兵组成的编队,军官可以不停地进行点名,一旦发现处于异常状态的成员,就令其离队。

为了演示实时校验的效果,首先我们为HitPoint组件补充一点内容,让我们可以在游戏运行时用鼠标左键单击士兵,对其造成10点伤害。

using UnityEngine;

public class HitPoint : MonoBehaviour {

    public int HitPointLimit = 100;
    [SerializeField]
    private int Hitpoint = 30;

    public int GetHitPoint()
    {
        return Hitpoint;
    }

    public void ChangeHitPoint(int hp)
    {
        Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
    }

    private void OnMouseOver()//当鼠标指针停留在物体上时
    {
        if (Input.GetMouseButtonDown(0))//单击鼠标左键
        {
            ChangeHitPoint(-10);//自身扣除10点生命值
        }
    }
}

然后,修改Team组件中的Update方法,使得Team每一帧都会对编队进行校验(而不需要按下C键),并试图剔除阵亡的士兵。

(处于效果直观的需要,使用OnGUI方法来实时显示存活士兵及其生命值)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Team : MonoBehaviour {

public List<GameObject> Soldiers = new List<GameObject>();
    
    void Update()
    {
        Check();//每一帧都会校验
    }
    
    void Check()
    {
        for (int i = Soldiers.Count - 1; i >= 0; i--)//反序遍历
        {
            if (Soldiers[i].GetComponent<HitPoint>().GetHitPoint() == 0)//如果列表中位置为i的士兵的生命值为0
            {
                Soldiers[i].SetActive(false);//立即隐去这个士兵
                Soldiers.RemoveAt(i);//将这个士兵从列表中移除
            }
        }
    }

    void OnGUI()
    {
        GUIStyle style = new GUIStyle();
        style.fontSize = 30;
        style.normal.textColor = Color.white;

        GUILayout.BeginArea(new Rect(0, 0, 500, 1000));
        for (int i = 0; i < Soldiers.Count; i++)
        {
            GameObject soldier = Soldiers[i];
            GUILayout.Label("HP of Soldier " + soldier.name + " :" + soldier.GetComponent<HitPoint>().GetHitPoint().ToString(), style);
        }
        GUILayout.EndArea();
    }
}

将前面场景中的三个“阵亡士兵”扶正,统一设置生命值为30。运行游戏,用鼠标左键点击来对任意士兵造成伤害。可以看到,只要一名士兵的生命值降至0,它就会立刻消失不见,并从编队中被剔除;这说明Update()中每帧都会执行的Check()方法发挥了作用,达到了对编队进行实时校验的效果。

至此,我们采用逐帧校验的方式,初步实现了对编队内成员的异常管控。

----------------------

当然,还有一项问题很容易发现:逐帧校验是一种对系统性能的较大浪费。要想对校验的性能进行优化,不妨采用我们熟悉的事件驱动方式。其大致思路是,每当一名士兵身上出现敏感事件时,例如——

·生命值归零

·涉及自身功能的关键组件被移除

就触发相应的事件,并通知游戏的管理模块(也就是Team组件),要求对自身的状态进行核查。

关于事件驱动的原理和用法,前面的文章中有过详细介绍,所以这里就留给大家自行尝试。

如果你认真地尝试了使用事件驱动来重构程序,那么你很可能会发现,事件驱动架构下的代码复杂程度,远远高于逐帧校验的方案。于是,我想在这里插一句嘴。

毋庸置疑,逐帧校验是一种十分耗费性能的处理方式;然而在游戏开发的背景下,我们并不应该在看到类似逻辑时,贸然断定这是一个不好的方案。因为运算性能的最优化,经常也会导致代码风格和可理解性的劣化。

在游戏中,好用&能玩永远是第一位的;如果某个方法能简明、直接地保证游戏功能的正确性,那么往往不必对性能锱铢必较。在某些情况下,游戏开发者可以选择不去照顾少数性能极差的玩家终端。

2.5 寻求简洁的校验逻辑

以上内容,我们讨论了如何将处于非预期状态的物体从数据结构中剔除;但在实际游戏中,我们还需要在更多的合适时机,对一个物体是否处于预期状态进行判断。下面再来设想一个情境。

在射击类游戏中,一枚子弹击中某个目标时,需要对目标进行扣血操作。但是,子弹击中的不一定是一名活着的敌方单位,还有可能是已经阵亡,正在执行死亡动画的单位;当项目中有很多环境物件时,子弹击中的还可能是一块石头、一棵树。显然,扣血操作只应该应用于具有生命值,并且活着(生命值大于0)的目标;试图扣减一块路边的石头的生命值是徒劳无益的,而且会引发错误。

要想确保扣血操作应用于正确的目标,我们就需要对子弹命中的物体进行核查,看看这个物体是否应当受到子弹的伤害。按照传统的编程思路,我们的核查应该是由类似下面这样的一连串判定组成的。

——该目标是否具有游戏角色的标签?(如果目标是石头或树,则子弹的命中无效)

——该目标是否具有生命值组件?(如果命中了一个不可伤害的单位,则子弹的命中无效)

——该目标的生命值是否为零?(如果目标已经死了,则攻击无效)

......

然而,在大型的项目中,为了使代码尽可能地简洁可读,我们希望此类啰里啰嗦的判定流程在代码里出现得越少越好。我们能否找到一种便捷的方法,对一个物体是否处于预期状态进行核验呢?

首先设想一下,我们心目中优雅的状态核验代码,应该是什么样子?

——当子弹命中【目标】时

——如果【目标】.可以被攻击

——则命中有效!

对!就是这样!我们希望为【目标】物体加入一种简洁的自检机制,来直接反映出该物体当前是不是一个可攻击的物体。

2.6 运用扩展方法

下面介绍C#提供的扩展方法功能。

【扩展方法】是一种在命名空间内的静态类中定义的静态方法;此类方法会指定一个现有类型,并为该类型的所有实例在命名空间内提供新的方法成员。

现在,我们修改HitPoint.cs文件,添加名为MyGame的命名空间。为GameObject类写入扩展方法InFlesh(),该方法用于直接表示一个GameObject是否:具有生命值组件,且为存活状态

修改后的HitPoint.cs内容如下:

using UnityEngine;

namespace MyGame
{
    public static class Checker//这个名字取什么都可以,定义这个类只是为了满足扩展方法的格式要求,实际上不会用到
    {
        public static bool InFlesh(this GameObject obj)//扩展方法:此物体是不是一个处于存活状态的士兵?
        {
            if (obj.GetComponent<HitPoint>())//判定1:具有生命值组件
            {
                if (obj.GetComponent<HitPoint>().GetHitPoint() > 0)//判定2:生命值大于0
                {
                    return true;//符合以上条件则返回true
                }
            }
            return false;//不全部符合则返回false
        }
    }

    public class HitPoint : MonoBehaviour
    {
        public int HitPointLimit = 100;
        [SerializeField]
        private int Hitpoint = 30;

        public int GetHitPoint()
        {
            return Hitpoint;
        }

        public void ChangeHitPoint(int hp)
        {
            Hitpoint = Mathf.Clamp(Hitpoint + hp, 0, HitPointLimit);
        }

        private void OnMouseOver()
        {
            if (Input.GetMouseButtonDown(0))
            {
                ChangeHitPoint(-10);
            }
        }
    }
}

然后,在其它所有代码的开头部分都加上如下语句,来应用命名空间:

using MyGame;

现在,当你在代码中声明任何GameObject实例时,你会发现该实例多出了一个InFlesh方法成员。

对于任何一个GameObject obj,只要obj.InFlesh()的返回值为true,即说明该物体是一个可被攻击的目标。

这下可就不一样啦!现在修改Team组件中的Check方法,使用新鲜出炉的扩展方法来实现对士兵状态的校验。

结合InFlesh方法的内容,我们不难看出,修改后的Check()方法将会拥有真正完善的异常应对能力;它不仅用最少、最易理解的代码实现了对士兵是否存活的检查,而且还能够抵御士兵必要组件(HitPoint)丢失的状况。

或者,还可以这样理解——

在使用扩展方法之前,对一个物体进行校验,代码风格是这样的:

-----------------------------

目标.生命值组件.存在?(这种表述是很啰嗦)

目标.生命值组件.生命值 > 0?(这种表述更加啰嗦)

-----------------------------

使用扩展方法之后,就变成了这样:

目标.可以被攻击? (极其简洁!)

所以说——扩展方法可以提供一种代码风格的转变,使得关于某个类型的常用操作看起来像是该类型本身的自带功能一样。这种表述风格的转变,能够为程序带来极佳的可读性,使原本复杂的逻辑变得简单易懂。

在项目的开发中,一个诸如“士兵”这样的游戏物体,其构成可能相当复杂;其上往往挂载着动画系统、导航系统等为数众多的组件,其物体本身和每个组件都可能会被不同的脚本模块所调用,对代码内部异常处理的要求更高。在这种情况下,扩展方法的价值更能得到体现:它可以避免繁琐的异常处理流程在代码中占用大量篇幅,从而使代码结构清晰,易于理解和调试。

2.7 未完待续

Unity代码中的异常处理策略,讲到这里先告一段落;实际上,游戏的类型花样繁多,开发中可能会出现的异常更是千奇百怪,而上面讲过的内容只能算是一些基础级别的注意事项。

总体来说,要想在Unity中做到有效管控异常,既需要足够的代码量积累、丰富的Debug经验,也需要身为游戏玩家的一些直觉和感性思维;此外,扎实的算法和逻辑能力也必不可少。这真的是开发过程中,一件既让你焦头烂额,也让你乐此不疲的事情。

关于更多的异常处理策略和方法,我会在这里随时编辑补充。我们后面再会!

猜你喜欢

转载自blog.csdn.net/qq_35587645/article/details/106761796