author : Kevin Lynx
需求
受WOW的影响,LUA越来越多地被应用于游戏中。脚本被用于游戏中主要用于策划编写游戏规则相关。实际运用中,
我们会将很多宿主语言函数绑定到LUA脚本中,使脚本可以更多地控制程序运行。例如我们可以绑定NPCDialog之类的函数
到LUA中,然后策划便可以在脚本里控制游戏中弹出的NPC对话框。
我们现在面临这样的需求:对于宿主程序而言,某些功能是不能阻塞程序逻辑的(对于游戏程序尤其如此),但是为
了方便策划,我们又需要让脚本看起来被阻塞了。用NPCDialog举个例子,在脚本中有如下代码 :
ret = NPCDialog( "Hello bitch" )
if ret == OK then print("OK") end
对于策划而言,NPCDialog应该是阻塞的,除非玩家操作此对话框,点击OK或者关闭,不然该函数不会返回。而对于
宿主程序C++而言,我们如何实现这个函数呢:
static int do_npc_dialog( lua_State *L )
{
const char *content = lua_tostring( L, -1 );
lua_pushnumber( ret );
return 1;
}
显然,该函数不能阻塞,否则它会阻塞整个游戏线程,这对于服务器而言是不可行的。但是如果该函数立即返回,那
么它并没有收集到玩家对于那个对话框的操作。
综上,我们要做的是,让脚本感觉某个操作阻塞,但事实上宿主程序并没有阻塞。
事件机制
一个最简单的实现(对于C程序员而言也许也是优美的),就是使用事件机制。我们将对话框的操作结果作为一个事件。
脚本里事实上没有哪个函数是阻塞的。为了处理一些“阻塞”函数的处理结果,脚本向宿主程序注册事件处理器(同GUI事件
处理其实是一样的),例如脚本可以这样:
function onEvent( ret )
if ret == OK then print("OK") end
end
-- register event handler
SetEventHandler( "onEvent" )
NPCDialog("Hello bitch")
宿主程序保存事件处理器onEvent函数名,当玩家操作了对话框后,宿主程序回调脚本中的onEvent,完成操作。
事实上我相信有很多人确实是这么做的。这样做其实就是把一个顺序执行的代码流,分成了很多块。但是对于sleep
这样的脚本调用呢?例如:
--do job A
sleep(10)
--do job B
sleep(10)
--do job C
那么采用事件机制将可能会把代码分解为:
function onJobA
--do job A
SetEventHandlerB("onJobB")
sleep(10)
end
function onJobB
--do job B
SetEventHandlerC("onJobC")
end
function onJobC
--do job C
end
-- script starts here
SetEventHandlerA( "onJobA" )
sleep(10)
代码看起来似乎有点难看了,最重要的是它不易编写,策划估计会抓狂的。我想,对于非专业程序员而言,程序的
顺序执行可能理解起来更为容易。
SOLVE IT
我们的解决方案,其实只有一句话:当脚本执行到阻塞操作时(如NPCDialog),挂起脚本,当宿主程序某个操作完
成时,让脚本从之前的挂起点继续执行。
这不是一种假想的功能。我在刚开始实现这个功能之前,以为LUA不支持这个功能。我臆想着如下的操作:
脚本:
ret = NPCDialog("Hello bitch")
if ret == 0 then print("OK") end
宿主程序:
static int do_npc_dialog( lua_State *L )
{
lua_suspend_script( L );
}
某个地方某个操作完成了:
lua_resume_script( L );
当我实现了这个功能后,我猛然发现,实际情况和我这里想的差不多(有点汗颜)。
认识Coroutine
coroutine是LUA中类似线程的东西,但是它其实和fiber更相似。也就是说,它是一种非抢占式的线程,它的切换取决
于任务本身,也就是取决你,你决定它们什么时候发生切换。建议你阅读lua manual了解更多。
coroutine支持的典型操作有:lua_yield, lua_resume,也就是我们需要的挂起和继续执行。
lua_State似乎就是一个coroutine,或者按照LUA文档中的另一种说法,就是一个thread。我这里之所以用’似乎‘是
因为我自己也无法确定,我只能说,lua_State看起来就是一个coroutine。
LUA提供lua_newthread用于手工创建一个coroutine,然后将新创建的coroutine放置于堆栈顶,如同其他new出来的
对象一样。网上有帖子说lua_newthread创建的东西与脚本里调用coroutine.create创建出来的东西不一样,但是根据我
的观察来看,他们是一样的。lua_newthread返回一个lua_State对象,所以从这里可以看出,“lua_State看起来就是一个
coroutine”。另外,网上也有人说创建新的coroutine代价很大,但是,一个lua_State的代价能有多大?当然,我没做过
测试,不敢多言。
lua_yield用于挂起一个coroutine,不过该函数只能用于coroutine内部,看看它的参数就知道了。
lua_resume用于启动一个coroutine,它可以用于coroutine没有运行时启动之,也可以用于coroutine挂起时重新启动
之。lua_resume在两种情况下返回:coroutine挂起或者执行完毕,否则lua_resume不返回。
lua_yield和lua_resume对应于脚本函数:coroutine.yield和coroutine.resume,建议你写写脚本程序感受下coroutine,
例如:
function main()
print("main start")
coroutine.yield()
print("main end")
end
co=coroutine.create( main );
coroutine.resume(co)
REALLY SOLVE IT
你可能会想到,我们为脚本定义一个main,然后在宿主程序里lua_newthread创建一个coroutine,然后将main放进去,
当脚本调用宿主程序的某个’阻塞‘操作时,宿主程序获取到之前创建的coroutine,然后yield之。当操作完成时,再resume
之。
事实上方法是对的,但是没有必要再创建一个coroutine。如之前所说,一个lua_State看上去就是一个coroutine,
而恰好,我们始终都会有一个lua_State。感觉上,这个lua_State就像是main coroutine。(就像你的主线程)
思路就是这样,因为具体实现时,还是有些问题,所以我罗列每个步骤的代码。
初始lua_State时如你平时所做:
lua_State *L = lua_open();
luaopen_base( L );
注册脚本需要的宿主程序函数到L里:
lua_pushcfunction( L, sleep );
lua_setglobal( L, "my_sleep" );
载入脚本文件并执行时稍微有点不同:
luaL_loadfile( L, "test.lua" );
lua_resume( L, 0 ); /**//* 调用resume */
在你的’阻塞‘函数里需要挂起coroutine:
return lua_yield( L, 0 );
注意,lua_yield函数非常特别,它必须作为return语句被调用,否则会调用失败,具体原因我也不清楚。而在这里,
它作为lua_CFunction的返回值,会不会引发错误?因为lua_CFunction约定返回值为该函数对于脚本而言的返回值个数。
实际情况是,我看到的一些例子里都这样安排lua_yield,所以i do what they do。
在这个操作完成后(如玩家操作了那个对话框),宿主程序需要唤醒coroutine:
lua_resume( L, 0 );
大致步骤就这些。如果你要单独创建新的lua_State,反而会搞得很麻烦,我开始就是那样的做的,总是实现不了自己
预想中的效果。
相关下载:
例子程序中,我给了一个sleep实现。脚本程序调用sleep时将被挂起,宿主程序不断检查当前时间,当时间到时,resume
挂起的coroutine。下载例子
8.13补充
可能有时候,我们提供给脚本的函数需要返回一些值给脚本,例如NPCDialog返回操作结果,我们只需要在宿主程序里lua_resume
之前push返回值即可,当然,需要设置lua_resume第二个参数为返回值个数。
2.9.2010
lua_yield( L, nResults )第二个参数指定返回给lua_resume的值个数。如下:
lua_pushnumber( L, 3 );
return lua_yield( L, 1 );
..
int ret = lua_resume( L, 0 );
if( ret == LUA_YIELD )
{
lua_Number r = luaL_checknumber( L, -1 );
}