« September 2012 | Main | November 2012 »

October 26, 2012

近期攀岩小记

最近半年基本上维持着一周三次的运动量。 不过似乎到了瓶颈了,水平接近 5.11 后就很难再看到大幅度的提高。抱石馆里还没能完成一条 V3 的线。

不过攀岩的乐趣之一就在于,你不需要和别人竞技,只需要一点点完成自己的提高就好了。每次去爬都能感受到一些新东西,比如突然有了灵感,一个动作一个姿势做对了就过了以前过不去的难点。控制身体的动态平衡是个很奇妙的感觉。

最近又去了趟英西,明显比上次去有了很大的进步。基本上 5.10a 以下的线都能先锋一次过了。大庙那条 5.11a 的水脊,顶绳磨了四趟后完成了。让我欣喜的是,这四趟一次比一次状态好,一点都没有累的感觉,最后完成的很轻松。而这段时间我一直在苦恼耐力不足,最近爬上五六趟手就软了。

我觉得问题是出在热身做的不够上。

昨天在岩馆试了一下用所有自己熟悉的线路热身,并从最简单的 5.9 的线起,一条条增加难度,逐步适应难度提高。在爬了四条线后,发现热身做的很充分,一点都没有肌肉疲劳感。接着抱石差点过了一条 V3 的线。有些以前过不起的难点很顺利就通过了。


ps, 前两个月寻岩中国在英西观音谷开了许多不错的新线,非常不错。英西风景不错,不输于阳朔,唯一遗憾的是缺了条大河。

ps2, 攀爬前相互检查保护很重要。当疲劳的时候更容易疏忽。这次我在一次攀爬前,绳结和安全带的连接不太对(少绑了个地方),下来才发现,就不贴照片了。另外,在一次做顶时也违规了,应该挂好两个确保点再做其它动作(当然先锋比较安全,下面还有一串保护点)。我只挂好一个就开始做顶了。

yingxi2.jpg yingxi.jpg

October 25, 2012

让 LuaJIT 2.0 支持 Lua 5.2 中的 _ENV 特性

我们的项目是用 Lua 5.2 标准来写的, 最近想迁移到 LuaJIT 2.0 中。其中碰到的最大障碍是,LuaJIT 2.0 不支持 Lua 5.2 中的 _ENV 特性。而且,看起来将来也不会支持了。

在邮件列表中,LuaJIT 的作者 Mike 看起来很不喜欢这个新特性

可是我真的需要它,所以只好自己阅读 luajit 的源代码,给它打了个 patch 支持这个特性。

patch (基于 luajit 2.0 的 beta 11) 如下:

diff --git a/src/lib_base.c b/src/lib_base.c
index 568216e..b58a5a4 100644
--- a/src/lib_base.c
+++ b/src/lib_base.c
@@ -363,6 +363,10 @@ static int load_aux(lua_State *L, int status, int envarg)
       GCtab *t = tabV(L->base+envarg-1);
       setgcref(fn->c.env, obj2gco(t));
       lj_gc_objbarrier(L, fn, t);
+      if (LJ_52) {
+        lua_pushvalue(L, envarg);
+        lua_setupvalue(L, -2, 1);
+     }
     }
     return 1;
   } else {
diff --git a/src/lj_lex.c b/src/lj_lex.c
index b54d2a2..59b2b84 100644
--- a/src/lj_lex.c
+++ b/src/lj_lex.c
@@ -381,6 +381,7 @@ int lj_lex_setup(lua_State *L, LexState *ls)
   ls->lookahead = TK_eof;  /* No look-ahead token. */
   ls->linenumber = 1;
   ls->lastline = 1;
+  ls->env = NULL;
   lj_str_resizebuf(ls->L, &ls->sb, LJ_MIN_SBUF);
   next(ls);  /* Read-ahead first char. */
   if (ls->current == 0xef && ls->n >= 2 && char2int(ls->p[0]) == 0xbb &&
diff --git a/src/lj_lex.h b/src/lj_lex.h
index d16461a..37c15eb 100644
--- a/src/lj_lex.h
+++ b/src/lj_lex.h
@@ -73,6 +73,7 @@ typedef struct LexState {
   BCInsLine *bcstack;  /* Stack for bytecode instructions/line numbers. */
   MSize sizebcstack;   /* Size of bytecode stack. */
   uint32_t level;  /* Syntactical nesting level. */
+  GCstr *env;  /* const _ENV */
 } LexState;

 LJ_FUNC int lj_lex_setup(lua_State *L, LexState *ls);
diff --git a/src/lj_load.c b/src/lj_load.c
index e30421e..5a389af 100644
--- a/src/lj_load.c
+++ b/src/lj_load.c
@@ -40,6 +40,9 @@ static TValue *cpparser(lua_State *L, lua_CFunction dummy, void *ud)
   }
   pt = bc ? lj_bcread(ls) : lj_parse(ls);
   fn = lj_func_newL_empty(L, pt, tabref(L->env));
+  if (LJ_52) {
+    settabV(L, uvval(&gcref(fn->l.uvptr[0])->uv), tabref(L->env)); /* Set env table to upvalue 1 */
+  }
   /* Don't combine above/below into one statement. */
   setfuncV(L, L->top++, fn);
   return NULL;
diff --git a/src/lj_parse.c b/src/lj_parse.c
index 29def7b..2f435a5 100644
--- a/src/lj_parse.c
+++ b/src/lj_parse.c
@@ -1112,6 +1112,12 @@ static MSize var_lookup_(FuncState *fs, GCstr *name, ExpDesc *e, int first)
    fscope_uvmark(fs, reg);  /* Scope now has an upvalue. */
       return (MSize)(e->u.s.aux = (uint32_t)fs->varmap[reg]);
     } else {
+      if (LJ_52 && name == fs->ls->env && fs->prev == NULL) {
+        fscope_uvmark(fs,0);
+        expr_init(e, VUPVAL, 0);
+        e->u.s.aux = 0;
+        return 0;
+      }
       MSize vidx = var_lookup_(fs->prev, name, e, 0);  /* Var in outer func? */
       if ((int32_t)vidx >= 0) {  /* Yes, make it an upvalue here. */
    e->u.s.info = (uint8_t)var_lookup_uv(fs, vidx, e);
@@ -1128,7 +1134,9 @@ static MSize var_lookup_(FuncState *fs, GCstr *name, ExpDesc *e, int first)

 /* Lookup variable name. */
 #define var_lookup(ls, e) \
-  var_lookup_((ls)->fs, lex_str(ls), (e), 1)
+  var_lookup_((ls)->fs, lex_str(ls), (e), 1); if (LJ_52 && (e)->k == VGLOBAL) var_global_((ls), (e))
+
+static void var_global_(LexState *ls, ExpDesc *e);

 /* -- Goto an label handling ---------------------------------------------- */

@@ -1687,6 +1695,18 @@ static void expr_index(FuncState *fs, ExpDesc *t, ExpDesc *e)
   t->u.s.aux = expr_toanyreg(fs, e);  /* 0..255: register */
 }

+/* Convert global to _ENV index. */
+static void var_global_(LexState *ls, ExpDesc *e)
+{
+  FuncState *fs = ls->fs;
+  ExpDesc key;
+  expr_init(&key, VKSTR, 0);
+  key.u.sval = e->u.sval;
+  var_lookup_(fs, ls->env, e, 1);
+  expr_toanyreg(fs, e);
+  expr_index(fs, e, &key);
+}
+
 /* Parse index expression with named field. */
 static void expr_field(LexState *ls, ExpDesc *v)
 {
@@ -2732,6 +2752,16 @@ GCproto *lj_parse(LexState *ls)
   fs.bcbase = NULL;
   fs.bclim = 0;
   fs.flags |= PROTO_VARARG;  /* Main chunk is always a vararg func. */
+
+  if (LJ_52) {
+    /* Create upvalue named _ENV */
+    ls->env = lj_parse_keepstr(ls, "_ENV", 4);  /* Anchor _ENV string, 4 is sizeof _ENV */ 
+    var_new(ls, 0, ls->env);
+    fs.uvmap[0] = 0;
+    fs.uvtmp[0] = 0;
+    fs.nuv = 1;
+  }
+
   fscope_begin(&fs, &bl, 0);
   bcemit_AD(&fs, BC_FUNCV, 0, 0);  /* Placeholder. */
   lj_lex_next(ls);  /* Read-ahead first token. */

October 19, 2012

星际争霸2编辑器的初接触

最近在接手改进我们的怪物 AI 的底层模块。这部分策划是希望可以由他们来直接配置而不是提交需求文档让程序实现。

我们的前一个版本有较大的性能问题,光是空跑一些场景,没有玩家的情况下 CPU 符合都相当之高了。我觉得是允许策划的配置项过于细节,以及底层模块实现的方式不对,导致了大量无用的 lua 脚本空转导致的。

目前的 AI 脚本是每个挂在独立的 NPC 上,利用心跳(大约是 0.5s 一次),定期让 NPC 去思考现在应该去干些什么。这些干些什么的具体逻辑在很细节的层面都是要去运行一个策划配置的脚本在一个沙盒中运行的。在实际监测中,一个心跳的一段 AI 脚本居然会跑上万行 lua 代码,想不慢都难啊。

游戏开发和很多其他软件开发的一个巨大区别就是,你无法把程序得到正确结果当成任务的完成。运行时间往往成为重要的约束条件。如果一件事情在规定的时间片执行不完,代码实现的再正确都没有意义了。而局部的优化热点往往也意义不大。因为如果只是需要小幅度的提高性能,那么采购好一些的硬件就够了。一个模块的性能,要从数量级上的提高,必须重新思考需求,改变需求,重定义我们要做什么。

我决定看看星际争霸2 的地图编辑器是如何工作的。

我没有玩过魔兽争霸3 的编辑器,也没有玩过星际 2 的。但似乎,它们可以让用户自定义出各种形式的游戏来,并不局限在 RTS 一种类型中。我相信这个发展了超过十年的自定义地图的编辑模式,一定有很多成熟的业务抽象。

有限的时间内,我没有从网上找到太多的相关资料。在暴雪的官方网站也没能看到完整的文档。星际2 的编辑器内建了一个叫做银河(Galaxy)的脚本语言,似乎所有 GUI 界面上的编辑器操作,都可以完整的对应成一段 galaxy 脚本。很可惜的是,暴雪似乎鼓励玩家用 GUI 编辑器创造地图,而脚本只是背后之物,galaxy 的手册我并没有找到。

我只好自己把弄编辑器,在自己的使用中,推想暴雪解决问题的思路。短短两天的研究肯定会有许多错误和遗漏,也希望借公开的 blog ,有行家看到能够赐教。


简单而言,自定义星际2 的地图,编写控制脚本的人是以上帝视角来看待世界的。脚本并不单独挂在单个 NPC 单位上。这和我们之前的设计很不一样。

自定义游戏,是由数据加代码构成的。数据包括了地形数据,放置在地图上的单位,还有路径、点、区域等等类型的对象构成。这些可以很方便的用 GUI 编辑器生成。后面在代码中引用。

而代码,是由一个个 Trigger 构成的。官方翻译为触发器。每张地图有若干 Trigger ,每个都有全局的名字,平坦的存在于地图的数据结构中。从编辑器角度看,这些 Trigger 必须一开始全部实现好,在地图加载时加载到内存中。后续代码能做的事情只是开启或关闭一些 Trigger。

从脚本角度看,Trigger 似乎是一个动态对象,可以动态生成,而不是一段静态的代码。但是编辑器里似乎做不到动态创建 Trigger ,看起来暴雪也不鼓励这种动态创建过程。

Trigger 由事件(Event) 条件(Condition) 动作(Action) 局部变量四部分构成。

Event 全部是可以枚举出来的东西,不存在自定义事件这种东西。所以在编辑器里可以通过菜单选取。暴雪在编辑器设计上下了一番功夫。所有的事件都有一个英文短词用于脚本方法的定义,同时有一个长句子用来显示在编辑器选单中。根据使用者的语言,可以配置为比如中文的。另还有一段长说明,方便使用者了解用途。

Event 可以说全部是全局事件,比如单位死亡,单位受到攻击,单位进入区域等等,可以指定一个对象,也可以监控一类或全部对象。UI 事件也被纳入同一体系,比如有 UI 按钮被按下这种事件。

Event 可以说是 Trigger 的过滤器,系统知道什么时候该考虑哪些 Trigger 可能需要执行。

Trigger 是否被触发还需要看条件是否被满足,这就要看 Condition 的配置了。如果你需要刺蛇被攻击时做一些什么,也可以定义 Event 为任意单位被攻击,再在条件里写上单位类型为刺蛇。

Action 就是需要完成的一系列事情了。这些事情都是瞬发的,比如把单位移到你定义好的点,删除单位等等。

不同的 Trigger 可以拥有相同的 Event 以及相同的条件,那么事件触发,且条件满足时,多个 Trigger 会同时运行。当然这个同时是逻辑上的,内部还是有一个先后,我推测是一个类似 coroutine 的机制来驱动的。因为 Action 里的事情都可以立刻完成(这里有个例外,下面会提到),所以不会有冲突。

那些,如果要执行的事情是一个需要很长时间才能做完的动作怎么办?比如你需要把一个单位按定义好的移动速度,沿正常的寻路得到的路径移动到一个目的地。这就得引入指令序列这个概念。

每个单位身上都有一个指令队列,保存的是一系列的指令 (order) 。指令和 Action 是两类完全不同的东西。比如让一个单位从当前位置移动到一个点就是一个 order ,而把单位(不通过游戏内在机制)瞬移到那个点就是一个 action 。order 是在队列中循序执行的。在 Trigger 里能做的是添加一个 action,这个 action 是把 order 加入队列中。当然加的方法有三类:清空现有队列,加入一条指令;把指令加到队列最后;把指令插入到当前位置(立刻执行,但不清掉原有指令)。

Trigger 里可以有多个 Action ,它们只有执行次序的。但 Trigger 之间可以并发。大部分 Action 都会立刻执行结束,只有一类特殊的 Action 会把当前的 Trigger 挂起,只到条件满足才会执行后续的 action 。

这类特殊的 action 叫做等待。目前我发现有三种等待指令。

最常用的是等待一段(游戏)时间,比如我们可以利用它在游戏开始后 10 秒显示一行字。只需要把游戏开始事件加到一个 Trigger 中,在 action 里依次加入等待 10 秒,显示文字。

其它两个是等待一个 Trigger 被触发,以及等待一个条件表达式成立。我怀疑它们可以用于 Trigger 间的协作,但没有深入研究到底怎么做。不过可以确定的是,Trigger 是图灵完备的,它可以有分支和循环(它们是特殊的 Action)。Trigger 也可以看成是某种意义上的传统函数,可以不给 Trigger 设置事件和条件,而用一个 Action 直接运行一个 Trigger 。


指令和动作的概念分离对我的启发很大。

也就是说,基于 Trigger 的流程控制是面对 action 的,而 Trigger 属于整个世界而不是单个对象。order 则是附属在对象上,对象的流程控制用 order 组成。order 只可以循序的排列在队列中,Action 则可以有复杂的控制结构。

假设我们需要让一个单位移动到 A 点,然后驻留 5 秒,再移动到 B 点。目前在星际的编辑器中很难简单做到。关键在于让单位停留一段时间,我没有在 order 列表中找到。如果要实现,大约是这样的。添加一个 order 让单位移动到 A 点。另外写一个 trigger 在单位到达 A 点时触发,在这个 trigger 内等待 10 秒,再发送下一个指令让其移动到 B 点。

这里,假设支持让一个让单位等待一段时间的指令,它和现有的等待一段时间的 Action 一定是两样东西。而我们之前的设计没有区分这两者,所以实现的很不干净。


关于 Event ,如果我们仔细研究编辑器还能发现,Event 其实是定义了一系列事件,而事件发生在谁身上并不是一级考虑的要素。而大部分事件的确有发生的对象,可以是单体,也可以是群体等等。当 Condition 和 Action 在运作的时候,是需要取得这个发生对象这个参数的。

那么 Event 其实可以为发生对象做一次前置过滤。比如说,当发生单位死亡事件,那么有时候我们需要了解是哪个具体单位死亡。这个放在 Condition 里写固然可以,但失去了性能优化的余地。

Event 本身是可以内置一个过滤器的。从编辑器上看,可以设置五类过滤:预设,值,变量,函数,自定义脚本。

比如单位死亡事件,就可以使用预设的任意单位。也可以设置指定值。这个值就是在地图编辑时放在场景中的对象,通过交互界面可以很轻易的绑定过去。

也可以只一个变量,我猜想是一个声明好的全局变量。在别的 Trigger 中,可以对变量赋上值,引用具体的对象。

还可以是一个函数,比如离点最近对象(绑定了“点”的一个 closure),就是一个函数。

这么看来,从逻辑上讲,只是允许在特定事件发生的时候,若事件会携带一个参数(这个例子里是单位对象),可以在 Condition 判断前,先用一个过滤器过滤这个参数看看是不是有效触发 Trigger 的事件,这个过滤器为每个参数对象返回真或假即可。但从实现上,完全可以做一系列优化,让 Event 在实现层面不是针对世界,而是绑定在特定对象上的。

October 12, 2012

并发问题 bug 小记

今天解决了一个遗留多时的 bug , 想明白以前出现过的许多诡异的问题的本质原因。心情非常好。

简单来说,今天的一个新需求导致了我重读过去的代码,发现了一个设计缺陷。这个设计缺陷会在特定条件下给系统的一个模块带来极大的压力。不经意间给它做了一次高强度的压力测试。通过这个压力测试,我完善了之前并发代码中写的不太合理的部分。

一两句话说不清楚,所以写篇 blog 记录一下。

最开始是在上周,阿楠同学发现:在用机器人对我们的服务器做压力测试时的一个异常状况:机器人都在线的时候,CPU 占用率不算特别高。但是一旦所以机器人都被关闭,系统空跑时,CPU 占用率反而飚升上去。但是,经过我们一整天的调试分析,却没有发现有任何线程死锁的情况。换句话说,不太像 bug 导致的。

但是,一旦出现这种情况,新的玩家反而登陆不进去。所以这个问题是不可以放任不管的。

后来,我们发现,只要把 skynet 的工作线程数降低到 CPU 的总数左右,问题就似乎被解决了。不会有 CPU 飚升的情况,后续用户也可以登陆系统。

虽然问题得到了解决,但我一直没想明白原因何在,心里一直有点不爽。

待到开发过了一段落,我又想回头来分析这个问题。发现,在 CPU 占用率很高的时候,大量 CPU 都消耗在对全局消息队列进出的操作上了。看起来是工作线程空转造成的。这个时候,如果设置了上百个工作线程,消息队列中有没有太多消息的话,处理消息队列的 spin lock 的碰撞就恶化了。那些真正有工作的线程有很大几率拿不到锁。

我的全局消息队列处理的业务很简单,就是存放着系统所有服务的二级消息队列。每个工作线程都是平等的,从中取得一个二级消息队列,处理完其中的一个消息,然后将其压回去。

这时我发现,其实,这个全局队列完全可以实现成无锁的结构,这样就不会再有锁碰撞的问题了。

原来的锁最重要的用途是在全局消息队列容量不够时,保护重新分配内存的过程不被干扰。但实际上,全局消息队列的预容量大于系统中服务体的数量的话,是永远够用的。我设置了单台机器支撑的服务体数量上限为 64K 个,那么消息队列预分配 64K 个单元,就无需动态调整。

昨天我着手实现无锁版的循环队列,原本以为也就是几行代码的事情,但事实上隐藏的坑挺深。

当线程特别多时,任何一个线程都可能暂时被饿死。那么,即使我们原子的移动了循环队列的指针,也无法保证立刻就从队列头部弹出数据。这时,纵然队列容量大到了 64K ,而队列中的数据只有几个,也无法完全避免队列的回绕,头尾指针碰撞。

为了保证在进队列操作的时序。我们在原子递增队列尾指针后,还需要额外一个标记位指示数据已经被完整写入队列,这样才能让出队列线程取得正确的数据。

一开始,我让读队列线程忙等写队列线程完成写入标记。我原本觉得写队列线程递增队列尾指针和写入完成标记间只有一两条机器指令,所以忙等是完全没有问题的。但是我错了,再极端情况下(队列中数据很少,并发线程非常多),也会造成问题。

后来的解决方法是,修改了出队列 api 的语义。原来的语义是,当队列为空的时候,返回一个 NULL ,否则就一定从队列头部取出一个数据来。修改后的语义是:返回 NULL 表示取队列失败,这个失败可能是因为队列空,也可能是遇到了竞争。

我们这个队列仅在一处使用,在使用环境上看,修改这个 api 语义是完全成立的,修改后完全解决了我前面的问题,并极大的简化了并发队列处理的代码。

有兴趣的同学可以看看代码 。最前面 30 行操作 globalmq 的代码既是。


由于简化了代码,使得我上周的问题更清晰的展现出来。但问题的解决并非这么直接。

今天, mike 同学要求在底层增加一个接口,可以把一个消息同时发给许多目标。虽然我之前实现了组播服务,但 make 同学不希望额外维护一个组对象,而是希望每次主动提交一个目标地址列表。

我开始为组播服务增加新接口。

这时,我发现原来的代码写的有些过于复杂了。这个复杂性来至于优化。

在 skynet 系统中,大都不直接引用服务对象,而是记录一个数字 handle 。等到要发送消息时,再从 handle 转为一个 C 对象。这个查询转换有一定的代价(hash 查询),但可以保证 C 对象的生命期管理容易实现。

事实上,这个简化方案,在复杂的并发环境中也坑过我一次。可见前面一篇 blog

没想到这次的问题还是和这里有关。

我为了优化组播过程,缓存了 C 对象,相对应的,对这些保持的 C 对象指针做了引用计数加一。等到它们离组的时候再减少。

另外,分组并不是用 hash set 实现的,离组操作代价比较高。所以又是在组播过程中成批处理的。也就是说,当一个对象退出,并不能保证它即使的从分组中拿掉,那么其引用记数就无法正确的减少到 0 。

而之前我在删除这些对象附着的消息队列时,采用的策略是,先删除对象,再将其消息队列标记成可删除,并把消息队列压入全局队列,让全局队列分发函数去检查标记并真正删除。

到现在,我就完全明白了上周问题的成因:当用户退出,系统删除了它对应的 handle ,并试图删除对应的消息队列。而对象被某个组播分组引用,阻止了退出流程,迟迟未能标记消息队列为可删。然后工作线程就反复的做退出队列,重回队列的操作,浪费了大量的 CPU 。同时,在这种情况下,全局消息队列的冲突变得非常严重,早先的 spinlock 未能合理的处理这种不健康状态,导致了整个系统性能的下降。

October 11, 2012

开发笔记(27) : 公式计算机

我们的项目的第二里程碑在中秋前结束了。这次耗时三个月的开发周期,堆砌了大量的游戏特性,大部分任务都是在追赶进度中完成的,导致最终有很多问题。

所以,节后我们决定调整一个月,专门用来重构不满意的代码以及修理 bug 。好在这次我们的代码总量并不算太大。服务器部分,所有已有的 C 底层之外的代码,全部 Lua 代码,总量在五万行左右。这是一个非常可控的规模。即使全部重写,在已有的开发经验基础上,都不是什么接受不了的事情。所以我对改进这些代码很有信心。

有部分代码有较严重的性能问题,节前我给底层增加了一些统计和检测模块,发现部分逻辑模块,单条消息的处理居然超过了一万条 Lua 指令。比较严重的是 AI 和怪物管理模块,以及战斗的 buff 和属性计算。

这几个问题,接下来我会一个个着手解决。这两天在优化属性计算的部分。

在半年前,我曾经参于过这部分的设计与实现。参见之前的一篇开发笔记

不过在写完这个模块后,我的工作从这部分移开。其他同学接手正式实现策划的需求时,并没有使用我实现好的模块。因为策划的需求更为复杂,最终采用了更间接和可配置的方案。相关的实现我未能跟进,Mike 同学就用 Lua 实现了一个基本等价的东西。

到目前,各种参于战斗的属性个数已经到了 200 个左右的规模,它们相互依赖,用一些属性计算出另外一些来。这使得在 Lua 中计算这些公式变成了相当大的负担。但另一方面也有一些好消息,就是很多原来预想的需求并没有被具体使用。比如在属性计算这个层次上。策划并不需要做任何查表运算(那是在上一层,Buff 处理中用到的)。这些需要计算的属性虽然量很大,但它们类型单一,可以全部用浮点数表示,并用基本的四则运算就可以搞定。并不需要更复杂的功能。

我想了一下,干脆把这一整块东西封装到 C 模块中。给 Lua 提供一个基于寄存器模式的纯函数式计算器即可。上层只需要构造一个表达式组的对象,输入若干简单的四则运算表达式,由模块本身做拓扑排序后,得到公式间的相互依赖关系。以及每条公式用到的寄存器号。然后,我们就可以通过简单的寄存器访问接口更新指定寄存器的值,就可以自动去做相关的公式运算得到那些最终需要通过计算得到的其它寄存器的值了。

对于 C 模块,它处理的公式中的变量只是一系列的寄存器编号。用 Lua 做一个简单的封装,把原始公式中的可读的属性名转换成内部编号,仅仅是一个简单的文本替换工作。

整个任务需求非常清晰,实现也不难。不需要动用 tcc 这种重量的外部库。只需要把策划提供的表达式转换为规整的逆波兰形式。计算的时候,一遍扫描就可以得到结果。

ps. 我在设计内部数据结构的时候取了一个巧。内部公式中只保存正的浮点常量(负常量可以通过正常量加一个取负的一元运算替代),然后用 union 来保存寄存器变量或操作符。这样,任何一个逆波兰表达式都可以用一个浮点数组来表达了。

作为惯例,我把花了一天时间实现的这个模块开源了。有兴趣的同学可以去 github 上拿到源代码


不太需要多解释,如果你运行这样一段程序:

local attrib = require "attrib"

local e = attrib.expression {
    "攻击 = 力量 * 10",
    "HP = (耐力 +10) *2",
    "FOO = 攻击 + HP",
}

local a = attrib.new(e)

a["力量"] = 3
a["耐力"] = 4

for k,v in pairs(a) do
    print(k,v)
end

会得到这样的输出:

力量    3
攻击    30
FOO 58
耐力    4
HP  28

做完这个以后,Mike 同学觉得能在底层把游戏中最常用的一个特性顺带实现了比较好。

简单说是这样:

往往,游戏中 “力量”这个值有一个基础数值。另外,各种装备和 Buff 有可能给这个数值加上一个数字,以及乘上一个比例。我们最常改变的是这个递增值,以及递增比例。有时候,还需要把力量设置为一个绝对值,不受前面那些值的影响。也就是说,默认会有一组公式:

力量 = ( 基础力量 + 力量增加 ) * (1 + 力量增比)
if ( exist 力量绝对值) then 力量 = 力量绝对值 end

同理,其它属性,比如智力,敏捷,都有相关的公式。如果能隐藏这些细节会更好一些。

我觉得,这个需求应该在 Lua 层封装好,自动生成“力量增加”这样的中间属性,并给出对应的接口去修改它们。不用在目前的模块中增加代码。至于力量绝对值这种设置,借助一些小技巧。增加一个变量“设置力量”,默认为 0 ,可以改写为 1 。然后:

力量公式 = ( 基础力量 + 力量增加 ) * (1 + 力量增比)
力量 = 力量公式 * (1 - 设置力量) + 力量绝对值 * 设置力量

在不增加新特性的前提下,这也算是一个折中的解决方案了吧。