游戏中的使用脚本语言已经成为了一个标准应用。脚本语言能够在游戏开发中扮演一个重要的角色,并且让数据结构化,计划事件,测试和调试这些工作更加容易。脚本语言也能够允许像美术,策划这些非程序专家通过一个高层的抽象脚本来为游戏编写代码。这个抽象层的一部分也能够允许提供给玩家来定制整个游戏。
从程序员的角度上来看,把一个脚本语言嵌入到游戏中最主要的问题是如果为脚本语言提供对宿主对象的访问(通常是C/C++对象)。在选择一个脚本语言的时候有两个关键的特性:嵌入相关问题和绑定相关问题。而这些是Lua语言的一些设计的初衷。可是,Lua语言并没有提供任何自动创建绑定的工具,因为这是出于另外一个设计初衷:Lua只是提供机制,而不是策略。
因而,就有许多种策略可以用来在Lua中绑定宿主对象。每一种策略都有它的优点和缺点,游戏开发者必须在得到在脚本环境中所需要的功能需求之后确定***的策略。一些开发者可能只是把C/C++对象映射成简单的数值,但是其他人可能需要实现运行期类型检查机制,甚至是在Lua中扩展宿主的应用。另外一个需要处理的重要问题是,是否允许Lua来控制宿主对象的生命周期。在这篇文章中,我们将探究使用Lua的API来实现不同的宿主对象绑定策略。
绑定函数
为了说明不同策略的实现,让我们考虑把一个简单的C++类绑定到Lua中。实现的目标是在Lua中实现对类的访问,因此允许脚本通过导出的函数来使用宿主所提供的服务。这里主要的想法是使用一个简单的类来引导我们的讨论。下面讨论的是一个虚构游戏中的英雄类,有几个将会被映射到Lua中的公用方法。
- class Hero{
- public:
- Hero( const char* name );
- ~Hero();
- const char* GetName();
- void SetEnergy( double energy );
- double GetEnergy();
- };
要把类方法绑定到Lua中,我们必须使用Lua的API来编写绑定功能。每一个绑定函数都负责接收Lua的值作为输入参数,同时把它们转化成相应的C/C++数值,并且调用实际的函数或者方法,同时把它们的返回值给回到Lua中。从标准发布版本的Lua中,Lua API和辅助库提供了不少方便的函数来实现Lua到C/C++值的转换,同样,也为C/C++到Lua值的转换提供了函数。例如,luaL_checknumber提供了把输入参数转换到相对应的浮点值的功能。
如果参数不能对应到Lua中的数值类型,那么函数将抛出一个异常。相反的,lua_pushnumber把给定的浮点值添加到Lua参数栈的顶端。还有一系列相类似的函数来映射其他的基本的Lua类型和C/C++数据类型。我们目前最主要的目标提出不同的策略来扩展标准Lua库和它为转换C/C++类型对象所提供的功能。为了使用C++的习惯,让我们创建一个叫做Binder的类来封装在Lua和宿主对象中互相转化值的功能。这个类也提供了一个把将要导出到Lua中的模块初始化的方法。
- class Binder
- {
- public:
- // 构造函数
- Binder( lua_state *L );
- // 模块(库) 初始化
- int init( const char* tname, const luaL_reg* first );
- // 映射基本的类型
- void pushnumber( double v );
- double checknumber( int index );
- void pushstring( const char s );
- const char* checkstring( int index );
- ….
- // 映射用户定义类型
- void pushusertype( void* udata, const char* tname );
- void* checkusertype( int index, const char* tname );
- };
类的构造函数接收Lua_state来映射对象。初始化函数接收了将被限制的类型名字,也被表示为库的名称(一个全局变量名来表示在Lua中的类表),并且直接调用了标准的Lua库。例如,映射一个数值到Lua中,或者从Lua映射出来的方法可能是这样的:
- void Binder::pushnumber( double v )
- {
- lua_pushnumber( L,v );
- }
- double Binder::checknumber( int index )
- {
- return luaL_checknumber( L,index );
- }
真正的挑战来自把用户自定义类型互相转换的函数:pushusertype和checkusertype。这些方法必须保证映射对象的绑定策略和目前使用中的一致。每一种策略都需要不同的库的装载方法,因而要给出初始化方法init的不同实现。
一旦我们有了一个binder的实现,那么绑定函数的代码是非常容易写的。例如,绑定函数相关的类的构造函数和析构函数是如下代码:
- static int bnd_Create( lua_state* L ){
- LuaBinder binder(L);
- Hero* h = new Hero(binder.checkstring(L,1));
- binder.pushusertype(h,”Hero”);
- return i;
- }
- static int bnd_Destroy( lua_state* L ){
- LuaBinder binder(L);
- Hero * hero = (Hero*)binder.checkusertype( 1, “Hero” );
- delete hero;
- return 0;
- }
同样的,和GetEnergy和SetEnergy方法的绑定函数能够像如下编码:
- static int bnd_GetEnergy( lua_state* L ){
- LuaBinder binder(L);
- Hero* hero = (Hero*)binder.checkusertype(1,”Hero”);
- binder.pushnumber(hero->GetEnergy());
- return 1;
- }
- static int bnd_SetEnery( lua_State* L ){
- LuaBinder binder(L);
- Hero* hero = (Hero*)binder.checkusertype(1,”Hero”);
- Hero.setGetEnergy( binder.checknumer(2) );
- return 1;
- }
注意绑定函数的封装策略将被用于映射对象:宿主对象使用对应的check和push方法组来进行映射,同时这些方法也用于以接收关联类型为输入参数。在我们为所有的绑定函数完成编码。我们可以来编写打开库的方法:
- static const luaL_reg herolib[] = {
- { “Create”, bnd_Create },
- {“Destroy”, bnd_Destory },
- {“GetName”, bnd_GetName},
- …
- };
- int luaopen_hero( lua_State *L ) {
- LuaBinder binder(L);
- Binder.init( “hero”, herolib );
- return i;
- }
绑定宿主对象和Lua数值
把C/C++对象和Lua绑定的方法就是把它的内存地址映射成轻量的用户数据。一个轻量的用户数据可以用指针来表示(void *)并且它在Lua中只是作为一个普通的值。从脚本环境中,能够得到一个对象的值,做比较,并且能够把它传回给宿主。我们要在binder类中所实现的这个策略所对应的方法通过直接调用在标准库中已经实现的函数来实现:
- void Binder::init( const char *tname, const luaL_reg *flist ){
- luaL_register( L, tname, flist );
- }
- void Binder::pushusertype( void* udata, const char* tname ){
- lua_pushlightuserdata( L, udata );
- }
- void *Binder::checkusertype( int index, const char* tname ){
- void *udata = lua_touserdata( L, index );
- if ( udata ==0 ) luaL_typerror( L, index, tname );
- return udata;
- }
函数luaL_typerror在上面的实现中用于抛出异常,指出输入参数没有一个有效的相关对象。
通过这个映射我们英雄类的策略,以下的Lua便是可用的:
- Local h = Hero.Create(“myhero”)
- Local e = Hero.GetEnergy(h)
- Hero.SetEnergy(h, e-1)
- Hero.Destroy()
把对象映射成简单值至少有三个好处:简单,高效和小的内存覆盖。就像我们上面所见到的,这种策略是很直截了当的,并且Lua和宿主语言之间的通信也是***效的,那是因为它没有引入任何的间接访问和内存分配。然而,作为一个实现,这种简单的策略因为用户数据的值始终被当成有效的参数而变得不安全。传入任何一个无效的对象都将回导致宿主程序的直接崩溃。
加入类型检查
我们能够实现一个简单的实时的类型检查机制来避免在Lua环境中导致宿主程序崩溃。当然,加入类型检查会降低效率并且增加了内存的使用。如果脚本只是用在游戏的开发阶段,那么类型检查机制可以在发布之前始终关闭。
换句话说,如果脚本工具要提供给最终用户,那么类型检查就变得非常重要而且必须和产品一起发布。
要添加类型检查机制到我们的绑定到值的策略中,我们能够创建一个把每一个对象和Lua相对应类型名字映射的表。(在这篇文章中所有提到的策略里,我们都假定地址是宿主对象的唯一标识)。在这张表中,轻量的数据可以作为一个键,而字符串(类型的名称)可以作为值。
初始化方法负责创建这张表,并且让它能够被映射函数调用到。然而,保护它的独立性也是非常重要的:从Lua环境中访问是必须不被允许的;另外,它仍然有可能在Lua脚本中使宿主程序崩溃。使用注册表来存储来确保它保持独立性是一个方法,它是一个全局的可以被Lua API单独访问的变量。然而,因为注册表是唯一的并且全局的,用它来存储我们的映射对象也阻止了其他的C程序库使用它来实现其他的控制机制。
另一个更好的方案是只给绑定函数提供访问类型检查表的接口。直到Lua5.0,这个功能才能够被实现。在Lua5.1中,有一个更好的(而且更高效)方法:环境表的使用直接和C函数相关。我们把类型检查表设置成绑定函数的环境表。这样,在函数里,我们对表的访问就非常高效了。每一个函数都需要注册到Lua中,从当前的函数中去继承它的环境表。因而,只需要改变初始化函数的环境表关联就足够了――并且所有注册过的办定函数都会拥有同样一个关联的环境表。
现在,我们可以对binder类的执行类型检测的方法进行编码了:
- void Binder::init(const char* tname, const luaL_reg* flist){
- lua_newtable(L); //创建类型检查表
- lua_replace(L,LUA_ENVIRONINDEX ); // 把表设置成为环境表
- luaL_register( L,tname, flist ); //创建库表
- }
- void Binder::pushusertype(void *udata, const char* tname){
- lua_pushlightuserdata(L,udata); //压入地址
- lua_pushvalue(L,-1); //重复地址
- lua_pushstring(L,tname); //压入类型名称
- lua_rawset(L,LUA_ENVIRONINDEX); //envtable[address] = 类型名称
- }
- void* Binder::checkusertype( int index, const char* tname ){
- void* udata = lua_touserdata( L,index );
- if ( udata ==0 || !checktype(udata, tname) )
- luaL_typeerror(L,index,tname);
- return udata;
- }
面代码使用一个私有的方法来实现类型检查:
- int Binder::checktype(void *udata, const char* tname){
- lua_pushlightuserdata(L,udata); //压入地址
- lua_rawget( L, LUA_ENVIRONINDEX); //得到env[address]
- const char* stored_tname = lua_tostring(t,-1);
- int result = stored_tname && strcmp(stored_tname, tname) ==0;
- lua_pop(L,1);
- return result;
- }
通过这些做法,我们使得绑定策略仍然非常高效。同样,内存负载也非常低――所有对象只有一个表的实体。然而,为了防止类型检查表的膨胀,我们必须在销毁对象的绑定函数中释放这些表。在bnd_Destroy函数中,我们必须调用这个私有方法:
- void Binder::releaseusertype( void* udata ){
- lua_pushlightuserdata(L,udata);
- lua_pushnil(L);
- lua_settable(L,LUA_ENVIRONINDEX);
- }
小结:详解如何把C++对象绑定到Lua轻量级的内容介绍完了,希望通过本文的学习能对你有所帮助!