Lua 中实现公式计算机

1,061 阅读12分钟

一不小心就半个月没更,一方面是最近公司项目比较忙,另一方面是文明VI出了,一个手痒就想来一把。所以文章的更新拖到了现在。

今天小说君暂停游戏服务端系列的更新,打算讲讲前几天一直在做的一件事。只看标题的话,想必做过游戏的同学已经清楚小说君大概要讲什么。不过,小说君还是打算逐关键词地解释一下。

在讨论「公式计算机」之前,首先我们来看公式」。

游戏开发中,「公式」是一个很关键的元素,而且出现频率相当高。

举个简单的例子,职业A的玩家释放了B技能,打中了C类型的怪物,那么,把这个伤害结算流程看作一个公式,公式的输入项就至少有A、B、C三个输入项,输出是一个伤害值。

数据驱动」一直是推动行业程序员提升自我修养的一个主要因素。目前,国内99%以上的团队针对上面举的公式例子的需求,一定都能实践如下程度的数据驱动工作流程:

策划和程序一起讨论这个公式的结构,提取中公式中的可配置数值项,程序在代码中把公式结构写死,某些项读配置。

这是数据驱动的最初级实践方式,能解决大部分问题,但是也会带来新的问题,比如说:

由于增加一个公式的成本比较高,策划往往会尽其所能的提出一个高度通用的复杂公式。这样一方面是对策划的要求比较高,另一方面其实也降低了策划的描述能力。

还是之前技能结算的例子,如果策划提出了一个普适的公式,能应用在不同职业、不同技能、不同怪物身上,这种普适公式的描述能力显然不如一个公式组的描述能力强大——特定职业、特定技能、特定怪物可以应用一个特定公式。

在一开始,还有的团队尝试过让策划直接写静态类型语言或者DSL描述公式,然后在编译期就转换为开发语言中的一个普通函数,程序直接调用函数即可。

但是这样一来,就没办法做热更新了。

不过cocos2dx和Unity3D流行以后,大家都开始用js,用lua,只要策划能写点脚本,这问题也就没有了。

如果问题这么简单就能解决掉,那自然是天下太平,程序员可以关注更少的事情,策划可以拥有更强的表达能力

但是,对于大部分团队来说,游戏逻辑,尤其是游戏服务端的逻辑并不是以Lua/JS(下文不再讨论js)为主体在跑。如果直接引入脚本让策划配置的话,会有难以忽视的性能问题:

  • 首先,既然主体逻辑并不是Lua,那就肯定是C/C++/C#/JAVA这种静态语言,那即使上LuaJIT,脚本指令的执行速度也最多就是到主体逻辑的执行速度下限。

  • 其次,Lua与静态语言的函数互调开销不容忽视。以C#中常用的ulua为例,即使是用了静态绑定,一次C#到Lua的函数调用,仍然需要C#到C的一次marshal以及C到Lua的一次marshal。marshal的成本可要比虚拟机执行lua字节码的成本高多了。

所以,我们需要换一种思路,既可以让策划配置脚本,比如直接用Lua配置公式,又可以兼顾性能,比如公式的计算仍然在主体逻辑语言框架中进行,不发生跨语言的函数调用。

回到标题,「公式计算机」就可以解决这个问题。

何谓公式计算机」?

云风几年前写过一篇博客,标题就叫公式计算机」。公式计算机」可以理解为一个函数,输入为一些环境,比如攻击单位的属性、受击单位的属性,输出为一个伤害值。

UnitAttr -> UnitAttr -> float

也就是说,如果我们可以用Lua构造出一个公式计算机,这个计算机实际上是我们的服务端主体逻辑中的一个实例,每次需要计算的时候,传入参数,计算机就可以直接计算出一个值。

这样,就可以做到既能让策划可以灵活配置公式,又能兼顾性能问题。

云风在博客中介绍的方案比较简单,流程简单来说就是这样:

策划在Lua配置一些四则运算表达式,表达式中会涉及」和常量」既有可能是外部输入的,也有可能是由表达式定义的。

C和Lua的一部分会对这些表达式做个处理,为每个」分配寄存器

这样相当于构建出了一个计算机」,只要为其每个外部输入的」对应的寄存器」赋值,计算机」就能计算出每个」的值。

代码也比较简单直观,如果需求类似,完全可以直接拿来用。

不过,云风的这个方案针对的是策划只能配置简单的四则运算,小说君在一开始准备实现一套公式方案的时候也有考虑过参考云风的这个实现。

但是小说君找了下逻辑中现有的一些公式,发现公式中还存在各种函数(比如随机数、条件判断、读表等等),如此一来云风的方案就没办法适用了。

另一方面,小说君其实也比较同意一个观点,那就是在项目中尽量少引入DSL。更多的DSL意味着更高的学习成本,更多的DSL parser,更多的DSL runtime,自然而然的就是更高的复杂程度。因此能直接用Lua描述公式自然是最好的。

由于下面开始的话题都比较抽象,所以小说君在这里直接上一个最终实现好的例子:

attP = Input.S.AP + 
    Math.Random(Input.S.DamMin, Input.S.DamMax)

test = 0.333*0.22*10test = Math.IfThen(Math.Equal(Input.S.Type, 2), 
    EnemyConfig.GetConfig(Input.S.Id).SkillLevelRatio)

attP = attP * test
dam = Math.Random(HurtConfig.GetConfig(Input.HurtId).DamageMin, 
    HurtConfig.GetConfig(Input.HurtId).DamageMax) 
        + attP
 dam = 10 + attP
armorFactor = 1 - Input.T.Armor / 
    (Input.T.Armor + 
        15 * LevelExpConfig.GetConfig(Input.S.Level).LevelBattle)

return armorFactor*dam

看起来就是一段非常常规的Lua代码,其中Input、Math、XXConfig这些都是可以用ulua或者luabind这些工具从主体逻辑中导出的供Lua访问的类型。

这段Lua脚本只会执行一次,执行完会自动构建出一个计算机」,应用层每次需要计算的时候,传入所有参数,即可得到一个结果值。

下面小说君介绍下这个方案的实现思路。

众所周知,Lua是一门传值调用(call-by-value)的语言,也就是说,在调用func(exp)的时候,exp会先求值,再传给func作为参数,赋值语句同理:

attP = attP * test

这句赋值执行之前,Lua虚拟机会首先求值attP和test,但是很显然,这时候的attP和test所依赖的外部输入比如Input是不存在的值,也就无法进行求值。

那如何在传值调用语言中实现传名调用(call-by-name)?

很简单,引入thunk」。

thunk = function () 
  return x + 5endfunction f(thunk)
  return thunk() * 2end--f(thunk)--f(x+5)

上面的代码示例中,借助thunk,实现了延迟求值。

继续看公式计算机。

考虑一个最简单的情况,当策划配置了下面这样一个公式,程序执行一遍脚本,应该拿到一个什么样的实例?

return Input.S.HP + 10

很显然,拿到的应该是一个Input -> float的函数,每次给这个函数传一个Input,这个函数就会输出一个float。

先尝试抽象一下概念,上面的公式中,Input.S.HP是一个概念,10是一个概念,而Input.S.HP和10还是同一个概念的subtype。

先来看下两者共同概念的定义:

public interface Monad{
    bool IsPure();
}
public abstract class Monad : Monad{
    protected bool pure = true;
     public abstract T GetValue();
     public bool IsPure()
    {        
         return pure;
    }
}

此Monad非彼Monad,名字而已,无须产生过多联想。

然后是Input.S.HP这种概念的定义:

public class Closure : Monad>{    
    publicdelegate Monad MonadFunc(Monad p0);    
    
    class Apply_ : Monad {
    private bool isUserFunc = false;
     private MonadFunc func;    
     
     public Closure(MonadFunc func)
    { this.func = func;}    
     
    public Closure(Func func)
    {        
         this.isUserFunc = true;        
         
        this.func = p0 => Help.MakePureThunk(func(p0.GetValue()));
    }    
         
     public override Closure GetValue()
    {        
         return this;
    }    
     public Monad Apply(Monad p0)
    {        
         
        if (Help.IsAllPure(p0))
        {            
             return func(p0);
        }        
         if (!isUserFunc)
        {            
             return func(p0);
        }       
         return new Apply_(this, p0);
    }
}

虽然看起来写了这么多,实际上只是定义了一个函数,在Apply的时候会视情况直接求值还是包裹为一个thunk(Apply结果),在用户显式对结果GetValue的时候再做求值。

这样,之前的脚本:

return Input.S.HP + 10

实际上执行完之后,C#这边拿到的就是一个Closure实例。每次需要计算的时候,给这个实例Apply一个Input,就可以拿到一个结果值。

这个方案的核心思想就是这样,不过,还需要做一些其他的处理。

首先我们需要在Lua和C#的粘合层中做一些处理。

这部分工作很简单。对Lua熟悉的同学,肯定知道是用元表的方式hook住导出符号和Lua的literal value的一些常用数学操作。

导出工具一般会把宿主语言中的类型导出为userdata,那我们就需要用Lua API设置userdata的元表,接管四则运算以及其他的一些常用数学操作符即可。

而比如:

test = 0.333*0.22*10

这样的表达式就无须接管,Lua会直接计算出来数值。

虽然大部分操作符都能hook住,但是判断相等或者小于这些操作符就无能为力了。Lua标准规定==「<」只有在两个操作数都是userdata的情况下才会去元表查元方法。

所以,也有了最开始示例中,用IfThen代替if语句的丑陋实现:

test = Math.IfThen(Math.Equal(Input.S.Type, 2), 
    EnemyConfig.GetConfig(Input.S.Id).SkillLevelRatio)

至于说为什么是IfThen而不是IfThenElse,还有其他原因,下文再介绍。

Lua的元表特性非常方便,我们不需要对策划配置的Lua脚本做额外的预处理或者parse,就能保证最后拿到一棵Closure嵌套的树。

再看一个稍微复杂些的例子:

srcAP = S.AP + 10dstAP = T.AP + 15return srcAP - dstAP

其中,S.AP表示SourceUnitAttr(一次攻击的发起者的属性)的攻击力,T.AP表示TargetUnitAttr(一次攻击的承受者的属性)的攻击力。

这样,srcAP和dstAP的signature都是UnitAttr -> float。

那最后,srcAP - dstAP的signature会是什么?

答案比较直观,是UnitAttr -> UnitAttr -> float,也就是接受两个UnitAttr作为参数,返回一个float的函数。

这个看起来比较像类型推导,但是实际上只是一种类型合并。原理很简单,但是实现起来非常繁琐,也是这个方案中的代码量最多的部分。

之所以复杂,是因为这个表达式是运行时才能组合出来的,我们没办法借助编译器的类型推导特性来找到合适的组合函数。在运行时,需要多个参数才能确定一个组合函数,是一个多分派问题(multiple dispatch)。

我们处理单分派的时候,最自然的思路是查表。而如果想要处理任意维度的多分派,那不论是表结构还是查表逻辑的复杂度简直无法想象。

不过好在由于公式的需求比较简单,这样一是我们导出的符号就非常有限,二是运算符有限,因此closure的组合方式是可以枚举的。

下面依次介绍下。

最常见的是函数的apply。signature如下:

(T0 -> T1) -> T0 -> T1

意思是一个T0 -> T1类型的函数,接受一个T0类型的参数,返回一个T1类型的参数(下文举例子就不再做额外解释了)。

应用举例:

EnemyConfig.GetConfig(1000001)

EnemyConfig.GetConfig的类型是uint-> EnemyConfig,那给一个常量,自然是应该返回1000001这个ID对应的EnemyConfig了。

然后是函数连续调用,signature如下:

(T0 -> T1) -> (T2 -> T1) -> (T2 -> T0)

应用举例:

EnemyConfig.GetConfig(Input.S.Id)

EnemyConfig.GetConfig的类型前面说过了,uint -> EnemyConfig,Input.S.Id的类型是Input -> uint。如此一组合,是一个Input -> EnemyConfig类型的closure。

接下来是比较复杂,但是又最常用的。

在Lua中,策划有可能用到的所有操作符实际上都是一个两参数函数,操作符与两个参数表达式的组合通常是用的最多的。

最简单的例子,同样是函数的apply:

(T0 -> T1 -> TR) -> T0 -> T1 -> TR

每个双目运算符的signature都是T0 -> T1 -> TR,当然,如果特指数学运算符,那甚至可以更简单:T0 -> T0 -> TR。

在Lua中,策划也有可能配置出这样的情况,比如我们导出了一个Random函数:

return Math.Random(10, 100)

当然,大部分应用都是比较复杂的,有下面三种情况:

(T0 -> T1 -> TR) -> (T2 -> T0) -> T1 -> (T2 -> TR)
(T0 -> T1 -> TR) -> T0 -> (T2 -> T1) -> (T2 -> TR)
(T0 -> T1 -> TR) -> (T2 -> T0 ) -> (T3 -> T1) -> (T2 -> T3 -> TR)

分别对应于下面三个例子:

a = Input.S.AP + 10b = 10 + Input.S.APc = Input.T.AP + Input.S.AP

当然,由于Input是特殊的,还可以做下化简,组合为Input->float,省去了一个额外的Input输入。

如果是有三参数函数,那情况就更复杂了,实现起来非常蛋疼。这也是为什么前文提到的要改用IfThen来表达IfThenElse的原因。

原理性的东西大概就这么多,其他的就是具体实现的细枝末节了,实在没什么好讲的了。

组合函数的实现通常也比较简单,每种组合方式写一个泛型函数,借助编译器的类型推导来拿到不同的组合函数实例,在编译期注册在表中。运行时由于只能通过元信息拿到类型数据,所以只能查表找具体的组合函数。

举个简单的推导函数的例子:

// (T1->T2->TR) -> (T3->T1) -> (T3->T2)
// => T3->TR
public static Closure InferredClosure(
    Closure op, 
    Closure t1Generator, 
    Closure t2Generator)
{
    return new Closure(p3 => Apply(
        op,
        t1Generator.Apply(p3),
        t2Generator.Apply(p3)));
}

对于小说君在最开始贴的Lua公式例子,最终实现好就是一个Closure,给Apply一个Input实例,直接就计算出值了。

个人订阅号:gamedev101「说给开发游戏的你」,聊聊服务端,聊聊游戏开发。