Contents

从研发角度上看,游戏的实质可以认为是游戏机制和游戏数据的整合。

其中机制的部分毫无疑问需要程序猿去实现,至于游戏数据——但凡不是那种简单到连设计一个数据表格都会严重延长开发时间的游戏(比如说没有关卡概念的经典俄罗斯方块)——通常会把数据从程序中剥离出来,以配置表的形式进行管理。

而对于大多数数值体系稍微复杂的游戏项目来说,一个只能配置简单数值的表格常常是不够用的,比较典型的比如以下几种场景:

  • 角色的状态或者技能效果和随从或者目标的某种属性相关
  • 某个系统的数值受到角色身上某个属性的加成
  • 自身的某个属性值的产生依赖于另一个属性值(比如梦幻西游中的宝宝成长率、资质和最终属性的关系)

我们有很多种方法去实现这些功能,比如:

  1. 直接把相应的计算公式固化在代码中。
  2. 额外配置了一份数值映射表,数值换算先由策划自行计算完成再搬过来,其中的变量部分采用变量=>最终值的映射表来完成
  3. 在配置表使用一些简单代数作为变量标记,程序执行的时候通过分别传递不同含义的变量来产出最终数值
  4. 写个DSL

在选择方案时,从理想化角度来看,我们希望所有和具体功能相关的数据都能从机制中剥离出去,使它成为无关程序 & 外部控制的东西。这一点在实际工作中可以帮助程序猿从没完没了配合策划改数据的无聊工作中解脱出来,而快捷随意对数据进行尝试对策划同学来说则更是求之不得。

为此我们毫不犹豫的去掉了第一种方案,公式所包含的系数毫无疑问是一种特定数据(没完没了的定义各种常量也很搓),连公式本身也应当被看作一种数据。

第二种方案尝试回避在配置表中配置公式和变量,但同时也极大的限制数据配置的灵活性。这种映射型的关系,碰到f(a,b,c..) => y这种映射关系时需要配置a*b*c*…个条目才行,碰到多变量的复杂公式实现起来比较麻烦。

第三种方案假定了配置表中支持填写公式,即至少支持基本的四则运算、计算优先级(括号)等。然后通过定义有限通用变量即特殊变量,可以满足大多数需求,灵活性较强。缺点一方面是实现起来较麻烦,各种特殊变量挨个去写解析起来工作量也不小。

第四种方案个人觉得没必要这么大动干戈,毕竟配置表用到只是关于取值和计算的一点点功能,犯不着直接提供在配置表上写逻辑代码的功能。

看起来第三种方案已经接近我们最终想要的东西了,实现起来麻烦一点算什么呢~ 毕竟机制一旦做好就是一劳永逸的事情。

如何实现:

由于我们平时代码中的公式都是编译器搞定的表达式解析和执行,所以轮到自己要去解析和执行的时候可能会突然发现这是一个既熟悉又陌生的东西。

这部分的工作和编译器前端部分做的工作有很多类似的地方(或者可以说是它的一个子集),基础工作都是字符串的处理,而识别哪些是值、哪些是变量、哪些是运算符我们需要一份符号表。根据运算符的优先级&结合性组织运算关系我们把文本转换成一种包含逻辑的数据结构——通常会被弄成一棵表达式树(二叉),其中中间节点和根节点放运算符,叶节点放值。计算的顺序体现在节点的摆放位置上,比如我们使用中序遍历进行求值,那么左子树的计算优先级就高于右子树。

这整个过程一般分为两个阶段进行:1、loadcfg时解析 2、运行到特定逻辑时(即用到这条公式时)求值

特殊符号的处理:

特殊符号我们视作一种值类型,由于意义不明无法直接计算,我们需要为每一个特殊符号定义编写取值函数:

如“主角的等级”,当求值过程走到这个部分时,就需要跳到你为这个符号编写的取值函数中,由特定函数调用其他接口返回这个结果。这样一来,我们就可以通过在配置表中填写代数(占位符)获取指定的动态的数值。

游戏中的变量含义常常包含多个纬度,仔细推敲就会发现诸如“主角的等级”这样的符号定义并不理想,它包含了“谁的?”、“什么东西?”这2个维度,这种定义的规则会导致我们需要定义的特殊符号成倍增长(维度相乘)。为了更好的复用代码,我们还需要实现一个分级机制,比如

主角.基本属性.人物等级

依次解析后取得最终结果。

具体代码有空写写看(我也没写过XD),这里先跳过。

另一种取巧的实现方案——LuaStringFunction:

这真是解释性语言亮瞎眼的闪光点之一啊,可以在运行时动态编译文本马上执行(在运行时注入代码)。这里拿Lua举例,类似的功能脚本语言应该都有的。

首先我们写个栗子:1+2*(3-1)-4/2

对于这样难搞的公式,我们只需…

local str = '1+2*(3-1)-4/2'
local res = loadstring('return '..str)()
print(res)

这样就能很方便的得出结果了。

由于loadstring涉及编译过程,效率低下是可想而知的,它并不适合用于实时计算,所以我们同样把这个过程分两步:1、载入时解析 2、运行时计算

g_CfgFunc = { }

-- 载入过程模拟
local str = '1+2*(3-1)-4/2'
local GetStrValFunc = loadstring('return '..str)()
g_CfgFunc['str'] = GetStrValFunc

-- 计算过程模拟
print(g_CfgFunc['str']())

这样就避免了每次计算时都loadstring,对于载入配置时的一次性的loadstring开销我们是可以接受的。

把特殊符号引入到这个体系中,处理起来同样容易

-- 数据的定义
local Role = 
{
	Attr = 
	{
		Level = 5,
		Exp = 1024,
	},
}

-- 数据的访问
local str = '1 + 2 + (Role.Attr.Level - 1) * Role.Attr.Exp'
local GetStrValFunc = loadstring('return function (Role) return '..str..' end')()

-- 计算
print(GetStrValFunc(Role))

注意一点,执行loadstring中的函数时,实际上是在另一个scope,访问不到上一层(也就是调用loadstring()()的那个函数)的local变量的,只有全局变量可以访问,有需求的话要通过参数来传递。

同样这个可以改为解析、计算分离的方式,str从配置表中读进来,于是结合脚本语言,配置表可以轻松对游戏内各种数据进行访问(怕策划乱来也可以做一层封装)。

这个方案最明显的缺陷是不支持中文,原因是Lua的变量命名规范:

Names (also called identifiers) in Lua can be any string of letters, digits, and underscores, not beginning with a digit. Identifiers are used to name variables, table fields, and labels.

然后中文既不属于digit,同样也不是letters,没办法作为变量名,这导致尽量追求中文化的配置表满满的维和感。

每次载入的时候进行字符替换后再解析是一种折中方案,相关问题有人讨论过了,后面附上链接,这里不再赘述。

更彻底方案是让Lua认为中文是有效变量——这当然要改语言源码才能做到,不过看起来并没有太大改动量,不过也要相应承担一些兼容性风险。相关的尝试也有人做过了,感兴趣点开后面链接自己瞧咯。

参考资料:

《开发笔记 (8) : 策划公式的 DSL 设计》: http://blog.codingnow.com/2012/01/dev_note_8.html
《开发笔记(17) : 策划表格公式处理》:http://blog.codingnow.com/2012/04/dev_note_17.html
《让 Lua 支持中文变量名》:http://blog.codingnow.com/2012/06/lua_support_utf8.html

Contents