« 路漫漫其修远兮 | 返回首页 | 新的名片 »

思维的惯性

晚上在办公室晃荡,对面的同事在加班写代码。我凑上去看看在写什么。我向他了解了后明白了,大约是服务器上角色 buff 的实现吧。

BUFF 这个术语是现在网络游戏中非常常见的。给角色加一个 BUFF 通常意味着对虚拟角色的一些数值上的临时修正:例如,攻击力 +5 ,防御 -10% ,速度加倍,等等。

玩过魔兽世界的朋友应该很容易理解这些。通常游戏里的 BUFF 设定比我上述的例子更加的复杂。

这里不谈游戏设定,谈谈实现。

同事在做的实现框架,给 BUFF 留了几个接口,其中有两个吸引了我的目光。

一个叫做 start 一个 stop 。分别用于 BUFF 产生的时候需要处理的逻辑,和一个用于 BUFF 消失时处理的逻辑。这是一个很自然的设计。尤其在 C++ 程序编写的习惯里,就相当于一个构造过程,一个析构过程。

比如,一个攻击 +5 的 BUFF ,可以在 start 事件处理里修正攻击值(+5),然后在 stop 里做一次逆运算(-5)。

我突然有一种直觉,感觉这个设计不是特别合理。因为它要求程序员去实现一对互逆的逻辑。这段时间我对需要编写事物处理的成对可逆处理的设计特别反感。所以立刻就意识到了实际问题所在。

首先,许多逻辑上成立的可逆运算都是事实上有漏洞的。比如某些特定条件下的浮点加减运算就有可能不可逆(暂时没有去构造一个例子,但是我认为是可以构造出来的);定点数的乘除运算就很容易导致不可逆。

还有更严重的,如果一个 BUFF 会把某个值设置成固定值(比如清 0 或设为 max),那么不借助缓存变量,这个效果就完全不可逆。

当然有经验的数值策划,会把公式里的乘法规则提取出来,安排合理的次序去计算。比如大多数游戏中,乘法加成系数都是先累加再一次乘起来的。(btw, 早期刚接触 diablo 的时候,我完全不能理解:为啥两个加 20% 的装备装到一起是 40% 的效果而不是 44% :D 也有游戏设计的更复杂一些,例如 Eve 里,就有所谓的叠加惩罚。哦,好象又写跑题了。)

我问了一下同事,诸如一些特殊情况如何处理的问题:比如玩家顶着 BUFF 断线,重新上线的数值恢复问题。还有如果在 BUFF 有效期间,人物升级或更换装备导致基本数值变更,怎么保证 BUFF 消失时的逆运算正确性……

这些自然可以找到方法在现在的框架下正确的处理。但前提是实现的人考虑完备,或是多记录一些中间数据。当两件有关联的操作(BUFF 出现和消失)在不同的代码中实现时,BUG 就有可能滋生。

接下来我又询问了一下别的项目的同事,咨询了一下类似的设计他们是如何实现的。居然,大家都用的相同的手段。看来,思维还真是有惯性啊。


我后来想到的解决方案其实很简单。取消 start / stop 接口,改提供一个 apply 。角色永远记录两套数值,一套基础数值,一套产生实际效果的临时数值。

每次状态改变(增加新的 BUFF、改变基础数值,或是改变装备等),都激发所有存在的 BUFF 的 apply 方法在基础数值上全部重新计算一遍,得到新的临时数值。这样有可能会增加一些计算量,但是系统设计会更简洁一点。

Comments

C++程序员多这么写吧,PythonJs等程序员第一反应大多应该都是apply而不是构造析构

赞同base+offset的方法。
以前写过一个光栅画正方体,使用旋转矩阵在上次旋转后的矩阵上进行计算。结果由于浮点误差,还没过5秒(60FPS)正方体就旋转成了一个像四棱名锥的东西。所以后来的计算中都考虑过误差累计。

这个用装饰者模式啊

嗯,现在我们服务器的属性计算就是由 加法段,乘法段 这些组成,每次改变都重新计算一遍。

已经有了这个设计之后,接受起来很容易自然,但是如果自己从头设计,肯定又不是这么容易自然了。

有的BUFF是增加值,有的BUFF是增加%比,所以需要分别记录值和%比值才可以逆。

我后来想到的解决方案其实很简单。取消 start / stop 接口,改提供一个 apply 。角色永远记录两套数值,一套基础数值,一套产生实际效果的临时数值。

每次状态改变(增加新的 BUFF、改变基础数值,或是改变装备等),都激发所有存在的 BUFF 的 apply 方法在基础数值上全部重新计算一遍,得到新的临时数值。这样有可能会增加一些计算量,但是系统设计会更简洁一点。

————————————————
这不就 是传奇的 做法
想不到阿 居然在这还能 看见传奇的 技术

我做类似系统时也是完全照着dnd做的。

单用加减想不出Bug基本不可能...

如果buff又有加减又有乘除
那么运算的操作先后也要考虑一下
(a+b)*c != a*c+b (当c!=1)

偶认为BUFF的设计实现都不应该牵涉数值部分,数值上的加成或者减益不是BUFF设计所需要考虑的部分,一个BUFF系统设计所需要考虑的接口包括:add,cancel, addwithtime, etc..
而对于一个Role的数值部分应该独立出来,这部分我赞同云风的策略:base value + runtime value。并且每个会引起数值变化的BUFF的状态变化都应该触发整个数值的重新计算。

如果色子的面都一样的话,掷色子应该等到的是一个随机分布,而均匀分布应该属于随机分布的最佳状态,色子是有可能掷出均匀分布的

start和stop的接口,和你说的apply是两回事吧。根据我的理解,其实你的核心思想是每次数值变化时,不要在当前数值上做运算,而是从基本数值按照固定公式重新算一次,这样即便有误差,也是同一种误差,不会由于计算次序不同而导致结果的不同。这又和start和stop接口有什么关系呢?那只是buff在添加和消失的时候的两个接口事件罢了,总是需要在这样的时刻做数值运算的,只是运算的方法不同罢了

那叫正态分布

Diablo是一个伪D&D系统,或者说是缩水版的D&D系统。例如一件武器的攻击力是150-210,事实上攻击力不是均匀分布在这个区间的,而是……那叫什么分布呢?忘记了。

直接解释吧,在D&D里面,一把武器的攻击力是掷两次20面色子的数目加上8,那就是10-48,但绝对不是平均分布,这很容易理解。在Diablo的背后,所有区间值都是用这种方式表示的,因此都是非均匀分布的。

这应该算作用查询替代临时变量的重构手法。
赞成这种做法,原来的做法绝对一大漏洞源,这在wow bug中占有很大比重,比如几个战场bug。

浮点加减法,若造成了指数的改变,运算很可能就不可逆了。
CSAPP解释得很清楚。

你这样做的话所有的状态技能的数值变化必须满足交换率,即个个状态技能产生的各种攻防变化不能够因为加BUFF的先后顺序改变 。从玩家角度讲操作降低 ,但是相对竞技性较强的游戏未必是好事。。。

原来如此,云风考虑得更多。在设计上来减少实现时出现错误的可能性。而程序员在具体编写代码时出错的可能性会更小。而对于非计算机专业的人员,则更有益处。我则将看法局限在了更为狭小的范围内。

接口的设计反应着设计者对问题的理解和实现思路。我们承认殊途同归,但归不归的了跟行者和铺路人都有关系。

这也是我们在项目中选择不同语言工具、选择不同的指导策略的目的。对于分工合作的多人项目尤其如此。

我们不能要求每个人做每件事情都考虑到每个细节(虽然这是每个设计人员应该对自己做的要求),那么就只能把自己做的这一块实现的足够 robust 。

浮点计算不是问题,我们尽可以用一些更 robust 的库:比如

http://gmplib.org/

性能上的问题就是另一个要考虑的了。

一个有经验的游戏设计人员可以很快的意识到大数乘法会导致溢出,或是经验值上溢变成负数。这是他们的经验给他们的直觉,并非完全理解计算机的运算法则。

但是即使一个程序员,也很容易忽略浮点加减的误差问题。这就是构造一个好的例子提示这一点的意义:一个看似游戏实际运行中可能发生的事情,却因为计算机的内在工作规则导致跟实际不相符的结果。

我们更不能强求非程序员对这些有足够的敏感。

对于程序员,计算机没有变数,任何结果都可以被预知;但对于非程序人员或是经验不足者,可能连“让计算机产生一个 1 到 10 万的随机数”这件简单的事情上都会犯错误,或是需要一个详尽的解释应该怎么去做。

ps. 这是一个有趣的例子,游戏设计人员设计了一个 10 万分之一的凋落率。任务程序员写下代码。 QA 抱怨着凋落率不对。这是谁的错?

作为程序实现上的设计人员,就应该多想想:应该怎样做,可以避免让其他给游戏添砖加瓦的人犯错误,让他们尽量少犯错误。

浮点数加减法精度损失的例子很容易构造,一个绝对值很大的数加减一个绝对值很小的数,必然会有精度损失。这个道理很容易理解,也很直观。

我的意思是想说
云风使用浮点数运算的例子来证明这句话:
“许多逻辑上成立的可逆运算都是事实上有漏洞的”
似乎不妥。

的确像楼下所说,计算机浮点数的运算在理论上就是存在精度损失的。

但是构造加减法的例子比构造乘除的例子不直观的多

由于计算机的特点,浮点数的运算在理论上就是存在精度损失的。看来云风最近真的很忙,没时间对自己的文章反复斟酌。

接口从 start/stop 改为 apply,本质上并没有什么改变,因为在 apply 以后仍然需要一种手段来回到 apply 以前的状态。接口的设计当然有好坏之分,但是在这里,云风所提的,只是个人的习惯问题。

start/stop 的接口定义,在语义上并没有要求数值公式运算的含义,在实现上完全也可以按照云风所说的办法进行,而且也应当按照云风的实现方法去做,否则可能会出现数值运算精度所带来的问题。

计算机的缺陷不是由于二进制造成的,而是在于计算机是离散系统,并且存在字长的限制。计算机系统的离散性,导致了计算机无法精确的描述和表示连续的数值,从而导致运算精度的问题。而计算机字长的限制,则导致了计算机数据类型的数值范围的限制。

似乎这个问题的关键不是接口的定义,而是在于接口的实现。

为什么不做成buff链,最终数值计算总是从链条顶端(基本数值)向链条末尾进行,buff失效脱链或者新增或者变化后总是从顶端重新计算一次.类似directshow的filter流.

可以尝试使用 float 类型,把 0.1251 加上 1000 再减去 1000 ,
结果是 0.125122 。

如果肯多花一些时间,应该还可以构造出更为有趣的的例子。
===========
我实验过是0.125100的?

这些公式的可逆性本来就没办法保证,谁叫2进制有这样的缺陷呢。存两份信息倒是可以解决但是内存却白白用了两份:( ...

感觉有点像大唐豪侠!我也玩大唐的,说实在的设计不完美!大唐如果开发完整了,我想是一款很好的游戏!只是情节有点...努力吧.加油

可以尝试使用 float 类型,把 0.1251 加上 1000 再减去 1000 ,
结果是 0.125122 。

如果肯多花一些时间,应该还可以构造出更为有趣的的例子。

加减运算不可逆的最可能情况就是加法时溢出的情况了吧

不过浮点加减法运算,为什么要强调浮点呢?

的确用加减实现非常不科学。

cs项目是跟buff无关
如果有数值变动,那么角色从头到尾把数值刷一遍就好拉.

"某些特定条件下的浮点加减运算就有可能不可逆",这个能举个例子么,一直以为加减运算是可逆的呢~~~

不要忘了顺序哦。

另外,buff过期的时间也是需要考虑的。

Post a comment

非这个主题相关的留言请到:留言本