pvzclass是如何实现的?pvzclass源代码初步分析(11)events 组件 下
上篇说到,events 组件扩充了 pvzclass 的判定功能,方便不擅长高级代码的创作者实现复杂功能。对于一些创作者而言,events 组件无疑是一件利器。
然而,events 组件也受人诟病,被批“性能还是欠佳,还有其他潜在的问题”,甚至有人想要“把pvzclass events组件的代码重写一遍”。
为何 events 组件会受到如此批判?本篇会给出一份解答。
强烈建议阅读本文前先阅读上一篇专栏。

注:本文以2021.10.8更新的版本为准。

Events 组件运行机制
这是上篇遗漏的内容。本篇补上。
Events 组件的运行机制其实很简单,可以概括为三步:
从 PVZ 本体抓取数据;
比对数据,判定事件是否发生;
将注册于对应事件类型的所有函数依次触发。
上述过程由 EventHandler 一手包办。
上篇提到的 events(.h/.cpp) 实现了第三步内容,而 LevelEvent.cpp 和下文将要分析的剩余三份源代码则完成了前两步。
虽然看上去一切都很好,但是别忘了,EventHandler 的原理是高频比较内存变化。如果注册的函数效率不高,就会拖累 EventHandler ,使其有误判的风险。
Events 组件的代码编写,更导致它的三大缺点:判定条件不严谨、判定算法不高效、对不了解底层的创作者不友好。
注:最近有部分更新优化了判定算法,“不高效”的问题相对没那么严重了,但这个算法缺乏能验证其效力的应用程序,未必可靠。
下面就上述瑕疵,对剩下的三份源代码文件一一分析。
ProjectileEvent.cpp
顾名思义,这个文件包含的代码都与子弹有关。
开头部分是文件引用,以及为 vector 特制的 GetAllProjectiles() 。

需要注意的是,GetAllProjectiles() 生成的动态数组中,子弹按照在 PVZ 中的序号从小到大排列,而且这个序号正常情况下不会变化。
这种有序性对于写程序有一定的帮助作用。
剩下的就是 UpdateProjectiles(),用于收集子弹数据,并判定是否有事件发生的函数。
首先,EventHandler 会考虑是否重置变量(这些变量用于防止重复触发事件):

清空的条件仅仅是 Address 为空,这意味着重开可能会导致误判,没能重置临时变量。
实际上关卡重开长期以来都是 Events 组件潜在的漏洞,但是修复进展不大,仅仅是有一个新的重开事件而已。
然后,EventHandler 获取一份新的子弹名单。旧名单的子弹数目记为 lastn, 新名单的子弹数目记为 nown 。
接下来,EventHandler 判定“子弹发射”和“子弹生成”两种事件是否发生。

这一部分代码确认新名单上哪些子弹是旧名单上没有的。如果新名单上有部分子弹是旧名单上没有的,那么 EventHandler 就认为这是新生成的子弹,并确认应该触发哪个事件。
只要比对频率够高,就能保证子弹的产生一定会被某次名单比对察觉。
在旧版本的 pvzclass 中,Events 组件采取的枚举方式,是分别顺序枚举子弹 A 和子弹 B ,判断两者是否相同。
因为名单的有序性,这种枚举方法的时间复杂度是 O(n²) 的,而且一定会跑满。
目前版本已经优化了这一枚举方法,原理也很简单。
根据 GetAllProjectiles() 的代码,我们可以确定子弹的序号和子弹的基址存在线性关系,而且对于同一关卡这一线性关系是稳定的,因此基址可以作为一个判据(当然,比对频率得足够高)。
如果在某次比对中,子弹 B 的基址大于子弹 A 的基址,则序号比 B 大的所有子弹,其基址必然也大于 A 的。那么序号比 B 大的所有子弹便没有与 A 比较基址的必要,可以略去。
因此,每次从旧名单中枚举子弹(和新名单的子弹配对)时,只需枚举到不小于当前子弹基址的子弹就可以了。
而名单本就是有序的,连排序都不用,直接按顺序枚举就行,时间复杂度降至 O(n)。
上面的图片展示的,就是这一算法的具体实现。
以下是确认发生的事件是“子弹发射”(源自某一植物)还是“子弹生成”(不源于某一植物,其他方式生成)

说白了,就是看看场上哪个植物离子弹最近(根据曼哈顿距离判定),谁最近就是谁发射的。
如果场上没有植物,说明这个子弹是生成的。
原版中确实可能发生这种情况,在无植物时豌豆僵尸发射僵尸豌豆就是一例。
但如果场上有植物,这颗僵尸豌豆可能会被误认为是植物发射的。
除此之外,修改器设计的新效果也可能会导致误判。
这就是我前面所说“判定条件不严谨”的体现。
再然后,EventHandler 判定“子弹击中僵尸”是否发生。

这一部分获取了一张新的子弹名单。不同于前面名单的是,这张名单可能包括实际上已经消失的子弹。

这一部分的原理很简单,如果子弹还在场,就将对应的 lastDead 重置为 false。
如果子弹不在场,就将离它最近(按曼哈顿距离计)的僵尸视为它击中的单位,并且将 lastDead 设为 true,防止重复触发。
当然,子弹真的击中僵尸时,这一判定是可靠的。
但是如果子弹只是飞出了屏幕呢(同样消失,但不是通过击中僵尸消失的)?这时明明没击中僵尸的子弹却还是会触发这一事件,只要场上还有僵尸。
由此可见 Events 组件的判定有多么不严格。
最后,EventHandler 判定“子弹消失”是否发生。
因为原理很简单(就是比对名单),而且优化在前面也讲过,这里就不再详细解读了。
综上所述,ProjectileEvent.cpp 在判定规则方面存在较大问题。创作者在相关事件下注册函数时应当尤其注意。
(或者直接去 Github 上提交更改)
ZombieEvent.cpp
这份文件包含的代码与僵尸有关。
开头和重置临时变量的代码与 ProjectileEvent.cpp 相似,这里略去。
与 GetAllProjectiles() 相同,GetAllZombies() 生成的僵尸名单也具有有序性。
ZombieEvent.cpp 中使用的防重复变量同样使用 std::map<int, bool> 。
在 UpdateZombies() 中,EventHandler 依次进行如下操作(除重置变量):
首先判定“僵尸被击杀”是否发生。代码如下:

EventHandler 获取一张新的僵尸名单(包括实际上已经不在场的僵尸),通过僵尸的状态确认僵尸是否是因为被击杀而不在场上。
这个过程中 EventHanler 通过 isPostedZombieDead 防止事件重复触发。
接着,EventHandler 获取一张新的僵尸名单,新名单上的僵尸数目记为 nown, 旧名单上僵尸的数目为 lastn。
然后,EventHandler 分析新名单上的僵尸,确认剩余所有事件(除了“僵尸被击杀”和“僵尸消失”)是否发生。
判定“僵尸生成”是否发生的算法过去是分别枚举新旧名单上的僵尸,现在也使用了单调性优化,时间复杂度降至 O(n)。
判定其余事件是否发生的原理和判定“僵尸被击杀”事件是否发生的原理类似,比对条件是否成立,成立就视为事件发生,用临时变量防止事件重复触发。
这里对应的代码在文件第 59 行至第 94 行,不再展示了。
最后判定“僵尸消失”是否发生。这里的“消失”包括所有消失的情况,不只是前面提到的“被击杀”,还包括其他移除情形(比如大嘴花的秒杀)。
因为原理很简单(比对名单),这里略去。
ZombieEvent.cpp 的漏洞相对而言少一些。
PlantEvent.cpp
这份文件包含的代码与植物相关。
开头和重置临时变量的代码与 ProjectileEvent.cpp 相似,这里略去。
与 GetAllProjectiles() 相同,GetAllPlants() 生成的植物名单也具有有序性。
PlantEvent.cpp 中使用的防重复变量使用 std::map,另外有一些变量记录旧名单上植物的属性:

在 UpdatePlants() 中,EventHandler 依次进行如下操作(除重置变量):
首先获取一份新的植物名单,记为 plants。新名单上的植物数目记为 list_nun, 旧名单上植物的数目为 plant_num。
接着,EventHandler 判定“植物受伤”是否发生,顺便刷新部分记录植物属性的变量,把“土豆雷出土”是否发生也判定一下。代码如下图:

从“植物受伤”的判定条件中可以看出,这个事件实际上更像是“植物被近身攻击”。因为它对僵尸的判定范围只有 100。
这个范围对于啃咬和碾压肯定是够了,但是僵尸豌豆、篮球对植物的伤害就未必能准确记录。
而且这个判定没有筛去气球僵尸,可能还会发生误判。
这还只是判定不严格的潜在问题。
实际上,如果你眼睛够尖,就能发现生命值判定和生命值记录竟然使用不同的下标!
这个漏洞导致的失配可能造成很严重的误判,无论是误判发生还是误判不发生。
触发这个漏洞并不困难。实际上,在 2021.10.8 更新换掉示例代码前,旧的示例代码就可以展现这一判定漏洞。
因此,EventPlantDamage 实际上完全是不可靠的,直到有谁修复上述问题为止。
剩下的代码就没啥问题了。
这一部分过后,EventHandler 判定“植物种下”和“植物升级”是否发生。

代码开头设了一个 pardon。
通过观察,可以看出 pardon 记录了紫卡升级植物的位置。它的作用会在后面的部分展现。
当然,Events 组件对紫卡的定义仅限于图鉴最后一排的植物,忽略了创作者人为修改紫卡的可能。
同样可以看出,紫卡植物的种植一定会触发"植物升级"。
接下来判定的是“植物被击杀”事件。
与前面判定“僵尸被击杀”是类似的,但不同的是“植物被击杀”事件可以记录一切被移除的情况,而且事件会记录植物被移除时的位置(包括坐标和行列)。
最终判定“植物消失”事件。(总感觉和上一个事件有些重复?)
这里除了常规的消失判定外,还会参考之前 pardon 记录的位置。
如果植物在 pardon 记录过的位置上,就不会触发消失判定。这筛掉了因紫卡植物种植而消失的植物。
还是那句话,只要比对够快,就不会出 bug。

虽然 Events 组件有这样那样的瑕疵,但不可否认的是,它确实为创作者提供了支持。
希望各位创作者在利用 Events 组件的过程中对组件的潜在问题加以注意,慎重运用它的力量。

至此,pvzclass 的绝大部分内容都已分析完毕。
《pvzclass是如何实现的?pvzclass源代码初步分析》系列也将告一段落。
如果有新的内容加入到 pvzclass 且能保证稳定,本系列有可能继续更新。
感谢你看到了最后。