[游戏开发笔记]buff与属性计算逻辑重构
最近项目针对buff系统做了一些重构,主要针对属性修改的逻辑,这里做一些总结。
之前的设计
目前属性是有依赖关系的,程序这边提供了公式的配置方式,比如当属性A是需要用其他属性来计算时,可以配置B + C * 0.1
表示A 和 B,C的公式关系,然后通过导表可以得出所有属性的依赖关系,顺便检测下死循环等。
在制作Buff时的效果时,有时会需要修改属性的值,修改方式有按固定值的,也有按百分比的。最开始设计的时候,给策划提供了set_attr和get_attr两个函数,策划可以写出很多种逻辑:
- 增加10:
set_attr(A, get_attr(拥有者, A) + 10)
- 变为80%:
set_attr(A, get_attr(拥有者, A) * 0.8)
- 关联另一个属性:
set_attr(A, get_attr(拥有者, A) - get_attr(拥有者, A) * 0.2)
- 关联另一个角色的属性:
set_attr(生命, get_attr(拥有者, 生命) - get_attr(攻击者, 力量) * 0.2)
我们把配置转换成了可执行的代码,每个影响属性的效果,会到目标属性下注册一个函数,buff消失后,就会注销相关的函数,然后按需重算属性,大概过程如下:
按公式计算属性的基础值
遍历buff的相关修改函数
return 结果
这种设计起初也挺好用,策划写起来比较自由,但随着复杂度的增加,开始发现一些问题。
修改函数执行顺序对结果的影响比较大
当存在多个效果作用于同一个属性时,计算的顺序会导致计算结果不一致。比如先加再乘和先乘再加会有明显的不同。关于还存一个set的功能,策划希望的是直接修改为指定的值,比如攻击变为1点之类的,这种优先级会超过公式计算,和buff的加成,虽然目前也可以用set_attr函数达到相同的效果,但必须放最后一个后才行。
对于这个问题,我们把属性计算的过程重新梳理了一下。buff不再按顺序执行,而是y = a * x + b的方式,buff改变只是这个公式中的乘法因子a和加法因子b,而x继续套用原本的公式逻辑,过程大概如下:
if buff 中存在set操作 then
return set操作的值
end
按公式计算属性的基础值x
遍历buff计算出a和b的值
return a * x + b
对于set,用流程保证优先级,然后再增加了两个函数用于修改a和b值。为了防止利用set函数填写出加法或者乘法的逻辑,或者用加法函数写出乘法的逻辑,则直接利用语法解析器,在导表检查时禁止掉了这种写法。
属性关联
对于一个属性来说,除了基础的公式,buff的效果相当会新增属性之间的依赖关系,特别当该效果是光环类型时(卸载时要还原效果)。除了对自身的属性会有依赖,还可能对其他对象的属性产生依赖,我们对此进行了一些讨论,做了下如下分类:
- 依赖其他对象的属性
为了降低复杂度,做成了执行一次立即缓存,不再其他对象的属性更新,而导致buff这边连锁更新
- 依赖当前对象的属性本身
通过导表检查和修改函数的改进,已经填不出来,所以不再有这种情况
- 依赖当前对象的其他属性,要继续分两种情况:
- 执行结果是不需要还原的。比如每隔1s按自己生命上限的10%增加HP。这种是不会因为卸载buff而需要扣血,所以不会有依赖
- 执行结果是需要还原的,比如按自己力量的10%增加攻击力。如果想做成力量变化了,对应增加的攻击力也变化,意味着增加一种攻击力和力量的关系。这种关系和之前公式关系类似,只不过是动态增删的,并且会随着buff动态增加和删除的,并且可能带来循环依赖。我们认真查了查目前项目的几百种buff,发现目前并没有这种情况。实在有需求,也存在还有个简单的解法,就是新增一些属性,作为要改变属性公式的因子,然后buff去改变这个因子,这样就不会产生动态的依赖规则了,更新触发的逻辑也会有。
DSL解析
这几天除了讨论改进方案,最有意思还是DSL解析这块。准确来说,其实之前策划填写的是lua,只不过这次希望解析这些lua,方便做更加精准的控制。比如只允许用指定的语法,不允许填出一些依赖或者死循环等,将填好的set_attr配置替换成其他的函数等。
主要元素是函数,变量,字符串,数字,和 加减乘除 操作之类的,最后选择了lpeg库,刚开始用的时候需要多想一想,熟悉后发现还是挺方便的,相对正则表达式好用太多。导出数据结果是类似lisp的方式,即[op, arg1, arg2, ...]
的形式,这样后期处理变得非常方便。比如想禁止set_attr函数的第二个参数(可能是一个表达式)中包含第一个参数,只要遍历语法树检查就行了。再比如,想把do_action(exp_a, exp_b, exp_c)
中的3个表达式拆分出来,只要先转成语法树,然后根据语法树反转为文本就好。具体的代码可以看这里,PEG定义的规则大概如下:
local syntax = P {
"Exp",
Func = namedpat("func", name * Space * P"(" * Space * pat_list(V"Exp", P",") * P")" * Space),
List = namedpat("list", P"{" * Space * pat_list(V"Exp", P",") * P"}" * Space),
Exp = V"OrTerm",
OrTerm = namedpat("or", lpeg.Ct(V"AndTerm" * (OrOp * V"AndTerm")^0)),
AndTerm = namedpat("and", lpeg.Ct(V"CmpTerm" * (AndOp * V"CmpTerm")^0)),
CmpTerm = namedpat("cmp", lpeg.Ct(V"AddSubTerm" * (CmpOp * V"AddSubTerm")^-1)),
AddSubTerm = namedpat("addsub", lpeg.Ct(V"MulDivTerm" * (AddSubOp * V"MulDivTerm")^0)),
MulDivTerm = namedpat("muldiv", lpeg.Ct(V"Factor" * (MulDivOp * V"Factor")^0)),
Factor = V("Func") + V("List") + V"Str" + V("Bool") + V("Var") + V("Num") + Open * V"Exp" * Close,
Var = namedpat("var", lpeg.Cmt((C((alpha+"."+alnum)^1)-(name+Number)), error_var) + name) * Space,
Bool = namedpat("bool", Boolean) * Space,
Str = namedpat("str", String) * Space,
Num = namedpat("num", Number) * Space,
}
期间和公司的另一个项目的同事聊了聊,他们由于支持的语法规则比较复杂,出于不想维护过于复杂DSL解析器和提高解析效率,直接利用静态检查工具luacheck提取出的语法树,然后就可以各种魔改了。感觉这个思路也不错,唯一的问题就是需要熟悉下没有官方说明的语法树,以后有需求可以试试。