欢迎光临散文网 会员登陆 & 注册

【我帮鹰角修bug】明明是我先来的:buff遍历逻辑引发的一系列bug

2022-06-28 10:04 作者:等--等灯等灯  | 我要投稿

前言和简介

这个系列内容大概是科学分析一些游戏bug或"bug"的原理并给出可能的解决方案,往期内容欢迎查看本人专栏。

本期涉及的bug比较多,内容比较杂,但这些内容都是由一个根本原因导致的:buff的结束/回收问题。这个看似不起眼的问题在过去大半年内造成了一堆bug,并且由于近大半年来(可能是新接手的)方舟程序员特别爱加各种buff事件,导致这个问题有愈演愈烈的趋势,因此有必要对这个问题引起重视。

本期部分内容为合作完成,非常感谢@fux2@朝梦cfx两位大佬的帮助(特别是动态调试方面)。


缓存的列表

设想一个问题:

现在你需要遍历一个列表(动态数组)a,对其中部分/每个元素执行一定的操作,这些操作根据元素有所不同,且在遍历前是未知的,其中可能包含修改a的操作,甚至可能包含类似的遍历行为。这种情况下,该怎么做才能保证这个遍历的可靠性?

这个问题就是方舟buff系统所面临的,也是这期所有内容的起源。在战斗中,各种不同的事件可以触发buff在对应事件上的行为。例如,傀影血色乐章的buff,设置在成功造成伤害后减少一层自身。在傀影成功造成伤害后的这个事件上,程序会遍历傀影buff池中的所有buff,并依次执行对应的行为,而在遍历至血色乐章buff时,会执行减少一层对应buff的行为。

但这里存在之前所说的问题:有一些需要执行的行为是创建/结束buff,这就必然涉及到对buff列表的修改。这种情况下,怎样确保遍历可以继续稳定进行呢?

方舟中使用了一个非常简单且符合直觉的办法:遍历前把原列表(internalList)拷贝一份,遍历的时候遍历这个缓存的列表(cachedBuffer),在遍历的过程中如果有需要执行的操作可以直接对原列表进行修改。这样就避免了容器修改导致的遍历失效的问题。

但是,不知是出于什么考虑,这个缓存列表只被允许缓存一次,已缓存的情况下再次尝试缓存列表,获取的缓存列表会是之前缓存的列表,而非即时从原列表(internalList)拷贝的列表。

以上的逻辑在部分情况下可能会导致bug。一个例子是:42的自动撤回和黑夜图的视野扩大同帧触发时,会导致视野常驻。这个bug的表现可见BV1ai4y1y7Kf

这个bug的原因概括起来比较简单:大致是由于缓存列表更新不及时,导致在触发撤回尝试结束并清空buff时,新创建的视野扩大buff没有被正常结束。甚至在buff列表清空后,这个buff依然不会被结束,而是处在一种类似被弄丢的状态。因此视野也会一直维持着点亮状态。

这个bug的大致图解

比较详细的解释也可以参考@朝梦cfx这个动态。这里就不再多赘述了,毕竟这不是重点。

那么,是不是允许多层的缓存列表,让缓存能够比较及时的更新,就能解决这个问题呢?

答案是,能解决,但也只能解决这个问题,还有其它的问题在后面等着


嵌套的逻辑

不知道各位有没有听说过当初贝娜+古堡的子嗣的bug,贝娜本体替身切换可以直接叠一层加防buff,最后可以达到上万防御。具体的表现可见视频BV1E44y157xZ。但稍微对机制有所了解的朋友应该知道,傀儡师切替身/切本体的时候会清空身上除白名单外所有buff。那么,这个加防buff是怎样逃过清空buff的行为叠上去的呢?

问题版本的古堡的子嗣,它的行为大致是这样的:我方单位出生时(傀儡师切替身/本体后在这里也视作出生)创建一层持续100s的计时buff;计时buff会在结束时(onBuffFinish)创建一层永久的加防buff。

看上去似乎没有问题。那么我们可以推一下,傀儡师清空自身buff时,发生了什么。

发生什么事了?

和上面视野bug原因类似,都是缓存列表导致的结束/清空buff不彻底。但这次,由于buff结束事件的存在,即时缓存的情况下仍然触发了类似的bug。而程序员最后选择的修复方案并不是修改清空buff的逻辑(比如获取原列表逐个结束移除),而是修改了古堡的子嗣的实现,绕开了buff结束事件创建加防buff。隐患仍然存在。

可能也是认为buff的结束与移除在嵌套逻辑中非常难以把控,除去缓存列表外,程序员还做出了另一项优化:除清空buff池外,其它任何时候结束buff,都只是将buff标记为已结束并令其失效,正式的结束/移除/回收需要在各事件末尾才会统一检查/执行。

然而,这在某些地方带来了大麻烦。


延迟的结束

统一结束这个措施,造成的最直接后果就是:在部分情况下,buff的结束事件存在延迟,往往是延迟到下一个事件结束,这在部分情况下,尤其是多个单位参与时,造成的影响是非常巨大的。

初版令的3技能召唤物在部分情况下可以攻击自己,这就是一个非常典型的,由延迟结束造成的bug。关于这个bug详情和触发方式可以见视频BV1Q44y1p79s。初版的实现中,召唤物的模式切换是通过来自令本体的光环实现的,光环buff的buff结束事件是让召唤物切换回原模式。但是,由于延迟结束的逻辑,这个buff并不是在光环被结束时立刻触发buff结束事件,而是延迟到了下一个事件结束。一般情况下,这个事件会是下一帧的buff的tick,这种情况下切换模式不会造成任何问题。但是如果下一帧刚好是新一轮普攻的开始,那么这个事件就会被提前到新一轮普攻的OnBeforeAttack。由于程序员在设计character类时可能根本没有考虑过在OnBeforeAttack切换模式的问题,在这个事件上切换攻击模式,会导致传入的攻击目标在一系列奇妙的化学反应下变成0,然后又在一系列奇妙的化学反应变成攻击自己。

不过很可惜,程序员依然选择了治标的方案,直接修改了令3技能和召唤物的实现绕过了这个问题。但延迟结束造成的问题可远不止令一个。

在将进酒版本更新后,有人发现:在部分情况下,火球术士的火球在爆炸后可能使部分单位停止攻击。这个bug非常奇怪的一点在于,火球术士早在9月就已实装,且自身的实现没有任何更改。但在将进酒版本更新前,并没有任何与这个bug相关的记录。

那么原因在哪呢?翻看火球的实现,可以发现,火球拥有一个限制生命时长的buff,这个buff持续一定时间,并且会在结束时使火球退场。而火球的爆炸,会通过爆炸ability中的action提前结束这个buff来让火球退场。一般情况下,这个结束事件会被延迟到火球自身下一帧的buff tick,这个时候撤回不会造成任何bug,但是异常情况下呢?

回看将进酒活动,其中实装了老鲤,而老鲤的1天赋让这个游戏加了一个名为OnOwnerBlockeeChanged的buff事件,大概是在阻挡/被阻挡状态发生变化时触发。而火球,不知道为什么是个可阻挡敌人,而阻挡的判定,由于火球通常是后于阻挡单位生成,也位于火球的buff tick前。

下图为上图中RegisterBlocker函数的部分展开

从图中不难发现,如果火球在OnOwnerBlockeeChanged的过程中退场,在退场过程完成后仍然会被添加至character的阻挡列表中。而之后这个火球会在未被清除阻挡的情况下变为null,相当于角色阻挡了一个不存在的单位。根据和上面令bug中相似的原理,攻击目标被设置为0后一定条件下会导致打自己的bug,具体表现可见视频BV1834y187Vj。这就是火球bug的原理。而将进酒之前没有这个bug,是因为将进酒之前根本不存在这个buff事件。

除此之外还有诸多类似的问题,比如老鲤2技能奇葩的撤回伤害判定,流明2天赋可同时奶2,流明+老鲤造成的buff回收错乱,以上这些均和buff延迟回收有关。具体原理这里就不细说了也没什么意义。在buff事件越来越多的现在,buff的延迟结束也有着更大的可能造成未知的影响。


修复方案

虽然看起来挺灾难的,但仔细分析了下问题主要集中在几点上,修修还能用,应该还用不着重构。以下是个人的修改建议:

1. buff池的清空方法需要修改。目前的方法是遍历缓存列表来结束/清空,导致部分情况下结束不干净。个人认为,需要使用非enumerator的方式,例如每次对内部列表的最后一个元素进行操作,来结束/清空buff。这样可以保证清的干净。但是需要注意一点,这种情况下,左脚踩右脚上天的嵌套buff,例如流明2天赋,buff A在结束时会创建buff B,buff B在结束时创建buff A,是坚决不能被容忍的。旧逻辑下,这样的buff仅仅是清不干净,如果没有属性变化不会造成问题;但在新逻辑下,这样的嵌套buff在清空buff池时是必然会造成无限循环的(除非能提前判断这是个无限循环然后跳出去,但这太麻烦了)。

2. buff结束逻辑需要修改。我暂时没有什么更好的方法,只有一条:在目标buff池的enumerator外,也就是e_counter == 0,结束buff时,完全可以直接结束+移除,而不仅仅是标记为结束。改掉这一条上面绝大部分与延迟结束相关的bug应该都能修好。

3. 未来的开发的中尽量少使用OnFinish这个buff事件,这个直接和buff移除相关联的事件真的容易造成问题。也许用OnDisable和OnTrigger替代会好一些。

4. 也许可以尝试修改掉OnFinish事件执行行为的位置,让它在buff被首次标记为结束时立刻触发,而不是等到统一移除/回收时。

最后,关于重构,虽然不推荐,但是我还是试着写了个究极简化版的基于链表的buff池,这样可以不需要使用缓存列表,让对buff池的遍历完全实时更新。buff池本身并不需要随机访问,用链表速度应该也能快些。上面的问题应该都能解决,但是很可能整出一堆新bug就是。

https://wtools.io/paste-code/bCWa

当然,最稳的方法还是将对buff池进行修改的操作弄一个队列,然后统一放到最外层遍历循环之外执行。但是这样的话可能很多原有的buff逻辑都要大改。

【我帮鹰角修bug】明明是我先来的:buff遍历逻辑引发的一系列bug的评论 (共 条)

分享到微博请遵守国家法律