mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-16 15:03:44 +08:00
mod
This commit is contained in:
180
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第19讲 | 如何嵌入脚本语言?.md
Normal file
180
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第19讲 | 如何嵌入脚本语言?.md
Normal file
@@ -0,0 +1,180 @@
|
||||
<audio id="audio" title="第19讲 | 如何嵌入脚本语言?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/48/7fea12ffdf335e92000c5730677ee148.mp3"></audio>
|
||||
|
||||
从2005年开始,逐渐流行使用C/C++语言结合脚本语言(Lua、Python、Ruby等等)编写游戏。这是因为用C/C++编写游戏的传统方式,硬代码太多,而使用硬代码编写的游戏,更新难度很大,除非重新编译一次程序。
|
||||
|
||||
于是,就有人开始使用配置文件来做活动逻辑。比如填写好配置表、玩家等级多少、攻击力如何、等于多少的伤害力等等,一开始就将这些内容都读取进代码,在游戏中实时计算出来。
|
||||
|
||||
但是这种方法其实也并不方便。很久以前的游戏,由于硬件资源限制,所以一般都加载WAV格式。而加载MP3则需要机器对音乐文件进行解压缩再播放,如果机器硬件计算能力不好的话,会由于解压缩而导致整个游戏的运行效率下降。
|
||||
|
||||
脚本语言也是如此,如果机器硬件能力不好的话,会由于脚本语言的虚拟机要解释程序,导致游戏运行效率下降。随着电脑硬件的提升,我们在游戏中加载MP3音乐文件成为可能,而在游戏中加载脚本语言进行逻辑编写当然也是可以的。
|
||||
|
||||
《魔兽世界》就是使用Lua脚本语言编写的。类似《GTA》等大型游戏,都拥有一套自己的脚本语言和体系。 **使用脚本语言,是为了能够在编写硬代码的同时,也能很方便地、不需要重新编译地编写逻辑代码。** 事实上,现在很多大型游戏都使用这种方式来编写代码,甚至一些游戏引擎本身,也支持脚本语言和引擎本身所提供的语言分离编写。比如引擎用C++语言编写,脚本语言用Lua编写。
|
||||
|
||||
## 为什么使用Lua脚本嵌入C/C++硬代码?
|
||||
|
||||
今天我就来教你使用Lua脚本来嵌入C/C++硬代码。为什么我要选择Lua脚本语言来编写代码呢?
|
||||
|
||||
因为**Lua脚本足够轻量级,几乎没有冗余的代码。Lua虚拟机的执行效率几乎可以媲美C/C++的执行效率**。如果选择Python、Ruby等常用脚本语言来嵌入,并不是不行,而是要付出执行效率作为代价。因为Python、Ruby的执行效率远逊于Lua。
|
||||
|
||||
如果没有非常多的编码经验,你可能会问,为什么Python、Ruby的执行效率远逊于Lua呢?这个问题,用一本书的篇幅恐怕才能彻底讲明白。我这里只简要说一下原因。
|
||||
|
||||
Lua的虚拟机很简单,指令设计得也精简,Lua本身是基于寄存器的虚拟机实现,而Python等其他脚本语言是基于堆栈的虚拟机,而基于寄存器的虚拟机字节码更简单、高效。因为字节码,一般会同时包含指令、操作数、操作目标等内容。
|
||||
|
||||
另一方面,Python、Ruby之所以应用范围广,是因为它们拥有大量的成熟库和框架,而Lua只是一种很纯粹的脚本语言。因为Lua没有过多的第三方库,只提供最基础的I/O处理、数学运算处理、字符串处理等,别的与操作系统相关度密切的,例如网络、多线程、音频视频处理等等都不提供。
|
||||
|
||||
我在[第6讲](https://time.geekbang.org/column/article/8782)里,已经非常详细地讲过,如何将Lua脚本编译成为静态库,如果不记得的话,可以回去复习一下。编译好静态库liblua.a之后,我们就可以在编程中使用它了。
|
||||
|
||||
你也可以选择在解压缩出来的目录内,使用make命令来直接编译,编译会生成Lua虚拟机的执行文件lua.exe、luac.exe,当然这需要一整套MinGW的环境支持。
|
||||
|
||||
开始,我们还是使用MinGW Development Studio来创建一个工程。由于只是示例,所以名字可以任意取。我取一个叫作lua_test的工程名,并且将工程设置为Win32 Console Application。你可以看这个示例图。<br>
|
||||
<img style="margin: 0 auto" src="https://static001.geekbang.org/resource/image/c2/51/c214aaccf9b6cd231d73304beea8ba51.jpg">
|
||||
|
||||
建立好了工程之后,我们新建一个test.c文件。这个文件位于lua源代码路径下。我们将liblua.a 文件也放到同一个目录下,以方便后续链接时候调用。
|
||||
|
||||
在包含Lua头文件之前,我们需要将头文件写在某一个.hpp文件下,以便一次性包含进去,我们的代码可以这么写。
|
||||
|
||||
```
|
||||
#ifdef __CPLUSPLUS
|
||||
extern "C" {
|
||||
#endif
|
||||
#include "src/lua.h"
|
||||
#include "src/lualib.h"
|
||||
#include "src/lauxlib.h"
|
||||
#ifdef __CPLUSPLUS
|
||||
}
|
||||
#endi
|
||||
|
||||
```
|
||||
|
||||
你可以看到,这里面包含了三个代码。这三个代码来自src目录下,其中最后一个lauxlib.h包含了大量的C语言形式的接口以及扩展接口。而定义extern "C"的意思是,使用C的方式进行链接,前置条件是,你的语言是C++语言(ifdef __CPLUSPLUS)。
|
||||
|
||||
定义好了这个hpp文件后,我们可以在C或者C++语言中进行包含。
|
||||
|
||||
```
|
||||
#include “lua.hpp”
|
||||
|
||||
```
|
||||
|
||||
## 你需要了解三个Lua语言的细节问题
|
||||
|
||||
写完定义之后,我们就可以开始对Lua进行一系列的绑定操作了。在编程之前,我先用一些你能看得懂的语言,对Lua语言的细节进行一些描述。有三个点,需要你着重记一下。
|
||||
|
||||
首先,**Lua的下标都是以1为最初始的值**(当然反向可以使用-1为下标),而不是我们所熟悉的0。有个传言说,是因为作者当时编写最初版本的Lua时,计算错误才导致的,所以就这么一直沿用下来了,这个说法虽然不可考,但也算是一种解释。
|
||||
|
||||
其次,在C/C++内嵌Lua的做法中,**Lua有两种读取脚本的方法。**
|
||||
|
||||
<li>
|
||||
一种方式是**读取后直接运行,调用的函数是luaL_dofile**。使用这个函数,脚本会在读取完毕后直接运行。当然如果出现错误,你也不知道错误的具体位置在哪里,调试起来不是很方便。
|
||||
</li>
|
||||
<li>
|
||||
第二种方式是**将脚本代码压到栈顶,然后使用pcall操作运行脚本,这个函数叫luaL_loadfile**。事实上第一种方式也是使用这种方式并且将pcall操作直接调用起来,第一种方式的代码一看你就能明了。
|
||||
</li>
|
||||
|
||||
```
|
||||
#define luaL_dofile(L, fn) \
|
||||
(luaL_loadfile(L, fn) || lua_pcall(L, 0, LUA_MULTRET, 0))
|
||||
|
||||
```
|
||||
|
||||
这行代码在lauxlib.h中能找到。这段代码写得非常精妙,它的意思是,如果loadfile成功,那么就运行pcall函数,中间这个 || (或者)已经直接判断了loadfile是否成功。因为loadfile函数操作成功就返回0,否则就返回1。
|
||||
|
||||
而在“或者”这个逻辑判断下,只要是0,就继续往下判断;只要是1,就直接返回条件为真。所以,在这行代码下,只要是1,就中断dofile这个宏的操作;只要是0,就进行pcall操作。
|
||||
|
||||
最后,我要说一下**Lua的堆栈**。理解了堆栈的计数方式,就能很容易地理解我后续要讲解的代码中的计数方式。Lua的堆栈可以从这个图里看出来,从栈底往上表示可以用1、2、3、4、5,而从栈顶往下表示是-1、-2、-3、-4、-5。<br>
|
||||
<img style="margin: 0 auto" src="https://static001.geekbang.org/resource/image/af/ad/af00612d1f227cac1900b5c9e153c6ad.jpg">
|
||||
|
||||
## 如何使用Lua以及liblua.a来进行与C语言的绑定操作?
|
||||
|
||||
我们现在开始使用Lua以及liblua.a来进行与C语言的绑定操作。
|
||||
|
||||
首先,我们需要包含之前我们所定义的lua.hpp头文件,随后我们开始在main入口函数处,定义一些变量。
|
||||
|
||||
```
|
||||
#include "lua.hpp"
|
||||
int main(int argc, char ** argv)
|
||||
{
|
||||
int r;
|
||||
const char* err;
|
||||
lua_State* ls;
|
||||
….
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们定义了三个变量,其中r是用来接收返回值的;err是一个常量字符串,用来接收错误字符串并打印出来;而lua_State* ls就是Lua虚拟机的指针了。
|
||||
|
||||
我们再来看接下来的代码。
|
||||
|
||||
```
|
||||
ls = luaL_newstate();
|
||||
luaL_openlibs(ls);
|
||||
|
||||
```
|
||||
|
||||
在这两行代码中,首先初始化一个虚拟机(在Lua 5.1中,使用的函数是 lua_open来新建虚拟机),并且将虚拟机地址赋值给ls指针。随后,我们拿到这个指针之后,就在之后的代码中“打开”Lua所需要用到的各种库。我们用到luaL_openlibs。我现在只是给你示范,你可以一个一个库单独打开。
|
||||
|
||||
我们新建了虚拟机,并且打开了Lua类库。我们继续看下面的代码。
|
||||
|
||||
```
|
||||
r = luaL_loadfile(ls, argv[1]);
|
||||
if(r)
|
||||
{
|
||||
err = lua_tostring(ls, -1);
|
||||
if(err)
|
||||
printf("err1: %s\n", err);
|
||||
return 1;
|
||||
}
|
||||
r = lua_pcall(ls, 0, 0, 0);
|
||||
if(r)
|
||||
{
|
||||
err = lua_tostring(ls, -1);
|
||||
if(err)
|
||||
printf("err2: %s\n", err);
|
||||
return 1;
|
||||
}
|
||||
lua_close(ls);
|
||||
|
||||
```
|
||||
|
||||
我来具体解释一下。这段代码中,argv[1]的是命令行输入的第一个内容。比如我们的程序叫lua_test,那么我们在Windows命令行中,输入lua_test a.lua,那么其中a.lua 就是argv[1] 这个内容。
|
||||
|
||||
luaL_loadfile我们在前面介绍过,就是载入文件并不运行。当然在这个期间,它会检查基础的语法。如果你少一个括号或者多一个引号,就会在这个时候给你一个错误信息,这个错误信息就是利用r这个变量判断的。如果r的返回值不等于0的话,那就是出错了。出错的时候,Lua会将出错信息压栈顶,而栈顶是从-1开始表示,所以我们要取出栈顶的错误信息lua_tostring(ls, -1);,并且将它赋值给err,最后由err打印出来。
|
||||
|
||||
认为没有错误之后,就是过了这一关。第二关我们需要使用lua_pcall函数,来调用Lua脚本文件,其中第一个参数是虚拟机指针,第二个参数是传递多少参数给Lua,第三个参数是这个脚本返回多少值,第四个是错误处理函数,可以是0,那就是无处理函数。
|
||||
|
||||
pcall的返回值也是一样,如果不是0的话,就说明出错了。和之前的luaL_loadfile不同,这时候一般是运行时错误,比如运行时类型错误等等。同样的,pcall也会把错误信息压到栈顶,我们直接去将栈顶的内容转成string就可以打印出来了。最后,我们将Lua虚拟机通过lua_close关闭。
|
||||
|
||||
按常理来说,我们现在可以来运行一下效果了,你可以先等等,我们先写一段错误的Lua代码,来看看执行起来会发生什么情况。
|
||||
|
||||
```
|
||||
print "test running")
|
||||
|
||||
```
|
||||
|
||||
我们故意少写一个括号,然后将源代码命名为 a.lua,我们来运行看看。会出现一个这样的错误信息:<br>
|
||||
<img style="margin: 0 auto" src="https://static001.geekbang.org/resource/image/cf/2a/cfad6d423a3c95bacba12b5e8dc3782a.jpg">
|
||||
|
||||
在发现语法错误后,程序就会报错,另外,如果你输入了一个根本不存在的文件,比如我们这么运行,test_lua xxx.lua,也会在loadfile的时候出错。
|
||||
|
||||
## 小结
|
||||
|
||||
我们今天的内容就到这里。下次我会进一步把Lua的脚本嵌入的细节呈现在你面前。我们来总结一下今天的内容。
|
||||
|
||||
<li>
|
||||
因为Lua脚本足够轻量级,几乎没有冗余的代码。Lua虚拟机的执行效率几乎可以媲美C/C++的执行效率。所以我们选择使用Lua脚本来嵌入C/C++硬代码。
|
||||
</li>
|
||||
<li>
|
||||
Lua脚本在C/C++语言里面嵌入,需要先声明一个虚拟机并且赋值给指针。
|
||||
</li>
|
||||
<li>
|
||||
Lua脚本需要先loadfile再pcall调用脚本文件,loadfile会检查最基本的脚本文件内容,比如文件是否存在,比如脚本代码是否出错,而pcall会在运行时出错的时候将错误压至栈顶。
|
||||
</li>
|
||||
<li>
|
||||
Lua错误会将错误压制栈顶,我们要取出来,需要使用-1下标取出栈顶的内容,并转成string打印。
|
||||
</li>
|
||||
|
||||
给你留一个小问题吧。
|
||||
|
||||
如果直接使用luaL_dofile,相对于把loadfile和pcall分开写,这样有什么优劣呢?
|
||||
|
||||
欢迎留言说出你的看法。我在下一节的挑战中等你!
|
166
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第20讲 | 脚本语言在游戏开发中有哪些应用?.md
Normal file
166
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第20讲 | 脚本语言在游戏开发中有哪些应用?.md
Normal file
@@ -0,0 +1,166 @@
|
||||
<audio id="audio" title="第20讲 | 脚本语言在游戏开发中有哪些应用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/3b/dee9c9b0ac13b8adef126d53d397b53b.mp3"></audio>
|
||||
|
||||
上一次,我们谈到了如何在游戏中嵌入脚本语言,我们用的语言是Lua。Lua语言具有轻量级、速度快的特点,而且API的调用也非常方便和直观。现在,我们仍然拿Lua脚本,试着把它应用在我们开发的游戏中。
|
||||
|
||||
我们使用C语言来对Lua脚本的绑定做一次深入的剖析,然后来看一下,在游戏开发中绑定了脚本语言后,脚本语言能做些什么事情。
|
||||
|
||||
首先,我们要明白一点,事实上**任何模块都可以使用脚本语言编写**。当然在游戏开发的过程中,需要分工明确,如果不分工的话,效率可能会比较低。
|
||||
|
||||
在需要某些效率要求非常高的情况下,一般是用C、C++或ASM语言,将底层模块搭建好,然后将一些逻辑部分分出来,给脚本语言处理。比如我们熟知的服务器端,可以使用C/C++来编写服务器端的IOCP或者epoll处理;而接收、发送、逻辑处理等等,都可以使用绑定脚本的方式编写。
|
||||
|
||||
我们在编写的过程中,需要对C/C++的语言和代码有个了解,我们需要先考虑这个函数。
|
||||
|
||||
```
|
||||
int test_func(lua_State *L)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这只是一个空的C函数,在这个函数里面,我们看到它的传入参数是lua_State,接受一个指针L。随后,这个函数返回一个0。
|
||||
|
||||
lua_State是Lua虚拟机的对象指针,也就是我们需要把前面new出来的一个虚拟机传进去,才可以保证在这个函数里面,使用的是一致的虚拟机。
|
||||
|
||||
这个函数的作用是,**只要注册到了Lua虚拟机里面,它就是lua的一个函数,其中在lua函数中,传入的参数由函数内部决定**。
|
||||
|
||||
比如我可以这么写:
|
||||
|
||||
```
|
||||
int test_func(lua_State *L)
|
||||
{
|
||||
const char *p1 = lua_tostring(L, 1);
|
||||
const char *p2 = lua_tostring(L, 2);
|
||||
// .... do something
|
||||
lua_pushstring(L, "something");
|
||||
return 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,lua_tosting 就是这个函数的传入参数,传入的是一个字符串的参数;第二个参数也是字符串参数,其中 lua_tosting 的第二个参数1或者2,表明的是在Lua虚拟机的堆栈中从栈底到栈顶开始计数,一般先压入的参数在第一个,后压入的在第二个,以此类推。返回1的意思是,这个函数会返回一个参数,这个参数就是我们前面 lua_pushstring 后压入的这个内容something,这就是返回的参数。
|
||||
|
||||
那么这个函数究竟怎么注册成为Lua函数呢?我们来看这段代码。
|
||||
|
||||
```
|
||||
lua_register(L, "test", &test_func);
|
||||
|
||||
```
|
||||
|
||||
lua_register函数的功能是,注册C函数到Lua虚拟机。其中L是虚拟机指针。这个在前面的代码都有说到,而第二个参数test就是注册在Lua虚拟机中的函数名,所以这个函数名叫test。第三个参数是函数指针,我们把test_func这个函数传入到lua_register函数中。这样,一个函数就注册好了。
|
||||
|
||||
那么,如果我们在游戏中有许多许多的函数需要注册到Lua中,那么这种写法是不是太慢了,有没有一种快捷的写法来支持注册等操作呢?
|
||||
|
||||
如果你没有C/C++的语言基础,或者C/C++语言基础比较薄弱,下面的内容可能需要花一点时间消化,我也会竭尽所能解释清楚代码的意思,但如果你已经是个C/C++程序员,那么下面的代码对你来说应该不会太难。
|
||||
|
||||
我们需要使用lua_register,我们先看它里面有什么参数。第一个是**字符串**,也就是**char*;<strong>第二个是**函数指针</strong>,也就是**int (**)(lua_State**)** 这种形式的。
|
||||
|
||||
那么,我们需要定义一个struct结构,这个结构可以这么写:
|
||||
|
||||
```
|
||||
#define _max 256
|
||||
typedef struct _ph_func
|
||||
{
|
||||
char ph_name[_max];
|
||||
int (*ph_p_func)(lua_State*);
|
||||
} ph_func;
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们定义了一个struct结构,这个结构的名字叫_ph_func,名字叫什么并没有关系,但是最开始有一个typedef,这说明在这个结构声明完后,接下来最后一行ph_func就是替代最初定义的那个_ph_func的名字,替代的结果是,**ph_func 等同于struct _ph_func**,这在很多C语言的代码里面经常能见到。
|
||||
|
||||
接下来,我们看到char ph_name[_max]。其中_max的值为256。我相信你应该能理解这句话。第二个变量就是我们所看到的函数指针,其中ph_p_func是函数指针,其中函数指针指向的内容目前暂时还没有确定,我们将在后续初始化这个结构变量的时候进行赋值。
|
||||
|
||||
我们来仔细看一下这两段宏的内容。
|
||||
|
||||
```
|
||||
#define func_reg(fname) #fname, &ph_##fname
|
||||
#define func_lua(fname) int ph_##fname(lua_State* L)
|
||||
|
||||
```
|
||||
|
||||
其中func_reg是在给前面那个结构体初始化赋值的时候使用的,因为我们知道,如果我们需要给这个结构体赋值,看起来的代码是这样:
|
||||
|
||||
```
|
||||
ph_func pobj = {"test", &test_func};
|
||||
|
||||
```
|
||||
|
||||
那么由于我们有大量的函数需要注册,所以我们将之拆分为宏,其中#fname的意思是,将fname变为字符串,而ph_##fname的意思是使用##字符,将前后内容连接起来。
|
||||
|
||||
通过这个宏,比如我们输入一个a赋值给 fname,那么#fname就变成字符串"a",通过 ph_##fname,结果就是ph_a。
|
||||
|
||||
接下来的代码,是方便在代码中编写一个一个lua注册函数用的,所以很明显,和上述的宏一样,我们只需要输入a,那么这个函数就变成了 int ph_a(lua_State* L);
|
||||
|
||||
定义好了这两个宏,我们怎么来应用呢?
|
||||
|
||||
```
|
||||
func_lua(test_func);
|
||||
|
||||
ph_func p_funcs[] =
|
||||
{
|
||||
{ func_reg(test_func) },
|
||||
};
|
||||
func_lua(test_func)
|
||||
{
|
||||
const char *p1 = lua_tostring(L, 1);
|
||||
const char *p2 = lua_tostring(L, 2);
|
||||
// .... do something
|
||||
lua_pushstring(L, "something");
|
||||
return 1;
|
||||
}
|
||||
void register_func(lua_State* L)
|
||||
{
|
||||
int i;
|
||||
for(i=0; i<sizeof(p_funcs)/sizeof(p_funcs[0]); i++)
|
||||
lua_register(L, p_funcs[i].ph_name, p_funcs[i].ph_p_func);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,联系上面的宏,第一行代码是使用func_lua,所以func_lua输入的宏参数是test_func。于是,通过这个宏,我们最终得到的函数名字是int ph_test_func(lua_State* L); 。
|
||||
|
||||
```
|
||||
ph_func p_funcs[] =
|
||||
{
|
||||
{ func_reg(test_func) },
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这段代码,使用的是func_reg的宏。test_func最终在宏里面,变成了 “test_func”,以及&ph_test_func函数指针。
|
||||
|
||||
最后我们来看一个重要的函数,**register_func**,这个函数在后续将会输入一个Lua虚拟机指针,但是我们要知道它在函数内部它做了什么东西。
|
||||
|
||||
```
|
||||
int i;
|
||||
for(i=0; i<sizeof(p_funcs)/sizeof(p_funcs[0]); i++)
|
||||
lua_register(L, p_funcs[i].ph_name, p_funcs[i].ph_p_func)
|
||||
|
||||
```
|
||||
|
||||
在循环里面,我们计算p_funcs的结构数组的长度,怎么计算的呢?
|
||||
|
||||
首先,我们使用sizeof编译器内置函数,来取得p_funcs整个数组的长度,这整个长度等于sizeof(ph_func)的值乘以数组长度。而ph_func结构体拥有一个字符串数组,每个数组长度是256,加上一个函数指针为4字节长,所以长度是260。而如果有两个数组元素,那就是520的长度。
|
||||
|
||||
以此类推,/sizeof(p_funcs[0]的意思是,我们取出第一个数组的长度作为被除数。事实上就是结构体本身的长度,所以就是结构体数组总长度除以结构体长度,就是一共有多少数组元素,随后进行循环。
|
||||
|
||||
在循环的过程中,我们看到,我们填入了结构体里面的两个变量ph_name以及ph_p_func,这样一来,我们只需要通过宏加上一些小技巧,就可以把Lua的函数都注册到C程序里面,我们假设这个C程序就是游戏的话,那么我们很容易就可以和Lua进行互通了。
|
||||
|
||||
## 小结
|
||||
|
||||
我总结一下今天所讲的内容。
|
||||
|
||||
<li>
|
||||
在Lua与C的结合过程中,C语言需要新建一个Lua虚拟机,然后使用虚拟机的指针来操作Lua函数。
|
||||
</li>
|
||||
<li>
|
||||
在程序的应用中,使用C语言中的一些宏的技巧,可以使代码能够便利地应用在程序里。
|
||||
</li>
|
||||
|
||||
最后,给你留一个小问题。
|
||||
|
||||
如果使用Lua往C语言传递一些内容,比如从C语言获取Lua脚本中某个变量的值,应该怎么做?
|
||||
|
||||
欢迎留言说出你的看法。我在下一节的挑战中等你!
|
224
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第21讲 | 如何使用脚本语言编写周边工具?.md
Normal file
224
极客时间专栏/从0开始学游戏开发/第四章:脚本语言/第21讲 | 如何使用脚本语言编写周边工具?.md
Normal file
@@ -0,0 +1,224 @@
|
||||
<audio id="audio" title="第21讲 | 如何使用脚本语言编写周边工具?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/a9/6668f24a830dd8e9f9a604e0878f75a9.mp3"></audio>
|
||||
|
||||
上一节,我们讲了脚本语言在游戏开发中的应用,我列举了很多C语言代码,这些代码做了这样一些事情:
|
||||
|
||||
<li>
|
||||
使用C语言和Lua语言进行沟通;
|
||||
</li>
|
||||
<li>
|
||||
在C语言代码里,使用了宏和结构,方便批量注册和导入C语言函数;
|
||||
</li>
|
||||
<li>
|
||||
Lua代码如何传输内容给C语言;
|
||||
</li>
|
||||
<li>
|
||||
Lua虚拟机堆栈的使用。
|
||||
</li>
|
||||
|
||||
这一节,我们要用Lua脚本来编写一个游戏周边工具Makefile。游戏周边工具有很多种,并没有一个统一的说法,比如在线更新工具、补丁打包工具、人物模型编辑工具、游戏环境设置工具等等。
|
||||
|
||||
你或许就会问了,那我为什么选择Makefile工具来编写,而不选择别的周边工具来编写呢?
|
||||
|
||||
因为这个工具简单、小巧,我们可以将Lua脚本语句直接拿来用作Makefile语句,而在这个过程中,我们同时还可以通过Lua语句来了解Lua的工作机理。 而且这个编写过程我们一篇文章差不多就可以说清楚。
|
||||
|
||||
而别的周边工具编写起来可能会比较复杂,比如如果要编写类似Awk的工具的话,就要编写文本解析和文件查找功能;如果编写游戏更新工具的话,就必须涉及网络基础以及压缩解压缩的功能。
|
||||
|
||||
简单直白地说,Makefile是一种编译器的配置脚本文件。这个文件被GNU Make命令读取,并且解析其中的意义,调用C/C++(绝大部分时候)或者别的编译器(小部分)来将源代码编译成为执行文件或者动态、静态链接库。
|
||||
|
||||
我们可以自己定义一系列的规则,然后通过顺利地运行gcc、cl 等命令来进行源代码编译。
|
||||
|
||||
我们先定义一系列函数,来固定我们在Lua中所使用的函数。
|
||||
|
||||
```
|
||||
int compiler(lua_State*);
|
||||
int linker(lua_State*);
|
||||
int target(lua_State*);
|
||||
int source_code(lua_State*);
|
||||
int source_object(lua_State*);
|
||||
int shell_command(lua_State*);
|
||||
int compile_param(lua_State*);
|
||||
int link_param(lua_State*);
|
||||
int make(lua_State*);
|
||||
|
||||
```
|
||||
|
||||
这些都是注册到Lua内部的C/C++函数。我们现在要将这些函数封装给Lua使用,但是在这之前,我们要将大部分的功能都在C/C++里编写好。
|
||||
|
||||
随后,我们来看一下,在Lua脚本里面,具体是怎么实现Make命令操作的。
|
||||
|
||||
```
|
||||
target("test.exe");
|
||||
linker("c:\\develop\\dm\\bin\\dmc.exe");
|
||||
compiler("c:\\develop\\dm\\bin\\dmc.exe");
|
||||
|
||||
source_code("c.cpp", "fun.cpp", "x.cpp");
|
||||
source_object("c.obj", "fun.obj", "x.obj");
|
||||
|
||||
compile_param( "$SRC", "-c",
|
||||
"-Ic:/develop/dm/stlport/stlport",
|
||||
"c:/develop/dm/lib/stlp45dm_static.lib");
|
||||
|
||||
link_param("$TARGET", "$OBJ");
|
||||
make();
|
||||
shell_command("del *.obj");
|
||||
|
||||
```
|
||||
|
||||
首先,第一行对应的就是目标文件target函数,后续的每一个Lua函数都能在最初的函数定义里找到。
|
||||
|
||||
在这个例子当中,我们使用的是DigitalMars的C/C++编译器,执行文件叫dmc.exe。我们可以看到,在linker和compiler函数里都填写了dmc.exe,说明编译器和链接器都是dmc.exe文件。
|
||||
|
||||
现在来看一下在C/C++里面是如何定义这个类的。
|
||||
|
||||
```
|
||||
struct my_make
|
||||
{
|
||||
string target;
|
||||
string compiler;
|
||||
string linker;
|
||||
vector<string> source_code;
|
||||
vector<string> source_object;
|
||||
vector<string> c_param;
|
||||
vector<string> l_param;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
为了便于理解,我将C++类声明改成了struct,也就是把成员变量改为公有变量,你可以通过一个对象直接访问到。
|
||||
|
||||
随后,我们来看一下如何将target、compiler和linker传入到C函数里面。
|
||||
|
||||
```
|
||||
int compiler(lua_State* L)
|
||||
{
|
||||
string c = lua_tostring(L, 1);
|
||||
get_my_make().compiler = c;
|
||||
return 0;
|
||||
}
|
||||
int linker(lua_State* L)
|
||||
{
|
||||
string l = lua_tostring(L, 1);
|
||||
get_my_make().linker = l;
|
||||
return 0;
|
||||
}
|
||||
int target(lua_State* L)
|
||||
{
|
||||
string t = lua_tostring(L, 1);
|
||||
get_my_make().target = t;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
在这三个函数里面,我们看到,get_my_make函数就是返回一个my_make类的对象。这个具体就不进行说明了,因为返回对象有多种方式,比如new一个对象并且return,或者直接返回一个静态对象。
|
||||
|
||||
随后,我们直接使用了Lua函数lua_tostring,来得到Lua传入的参数,比如如果是target的话,我们就会得到”test.exe”,并且将这个字符串传给my_make对象的 string target 变量。后续的compiler、linker也是一样的道理。
|
||||
|
||||
我们接着看下面两行。
|
||||
|
||||
```
|
||||
source_code("c.cpp", "fun.cpp", "x.cpp");
|
||||
source_object("c.obj", "fun.obj", "x.obj");
|
||||
|
||||
```
|
||||
|
||||
这两行填入了cpp源文件以及obj中间文件,这些填入的参数并没有一个固定值,可能是1个,也可能是100个,那在C/C++和Lua的结合里面,我们应该怎么做呢?
|
||||
|
||||
我们看到一个函数lua_gettop。这个函数是取得在当前函数中,虚拟机中堆栈的大小,所以返回的值,就是堆栈的大小值,比如我们传入3个参数,那么返回的就是3。
|
||||
|
||||
接下来可以看到,使用Lua的计数方式,从1开始计数,并且循环结束的条件是和堆栈大小一样大,然后就在循环内,将传入的参数字符串,压入到C++的vector中。
|
||||
|
||||
随后的source_object、compile_param和link_param都是相同的方法,将传入的参数压入到vector中。
|
||||
|
||||
你可能要问了,我在Lua的代码中看到了$TARGET、$OBJ、$SRC等字样的字符串,这些字符串的处理在哪里,这些字符串又是做什么的呢?
|
||||
|
||||
这些字符串是替代符号,你可以理解为C语言中printf函数的格式化符号,例如 “%d %s”等等,虽然在这里,这些符号都是自己定义的,但是我们仍然需要解析它们。
|
||||
|
||||
其实解析的步骤并不难,我们只需要将vector内的内容提取出来,对比是不是字符串$TARGET等,如果是的话,就被替代为前面我们在target函数或者source_code函数中所定义的内容。
|
||||
|
||||
我们拿source_code部分来举例,来看一下部分代码。
|
||||
|
||||
```
|
||||
void run()
|
||||
{
|
||||
string command_line;
|
||||
string src = "$SRC";
|
||||
string tar = "$TARGET";
|
||||
string obj = "$OBJ";
|
||||
for(int i = 0; i < source_code.size(); i++)
|
||||
{
|
||||
..............
|
||||
for(int j=0; j<c_param.size(); j++)
|
||||
{
|
||||
if(c_param[j] == src)
|
||||
{
|
||||
command_line += source_code[i];
|
||||
.....
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这部分的代码里面可以看到,我们将压入的source_code内容进行循环。在循环之后,必须对c_param(compile_param),也就是编译参数进行循环。当我们发现编译参数里面出现了$SRC这个替代字符串的时候,就将source_code的内容(其实就是源代码文件)合并到command_line(命令行)里面去,然后整合成为一个完整的、可以运行的命令行。
|
||||
|
||||
随后我再贴一部分代码,可以看到别的可替代字符串是怎么做的。
|
||||
|
||||
```
|
||||
else if(c_param[j] == obj)
|
||||
{
|
||||
command_line += source_object[i];
|
||||
}
|
||||
else if(c_param[j] == tar)
|
||||
{
|
||||
command_line += target;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们对替代字符串做了相同的比较,如果是一致的话,就将被替代内容添加到command_line变量里面,组成一个完整的可运行命令行。
|
||||
|
||||
这个run函数其实就是在make的时候调用的函数。至于如何调用这一串command命令,在C里面最简单的方式就是调用system函数,或者使用execl函数系列。注意,这个execl并不是来自微软的excel表格,而是C语言的函数。
|
||||
|
||||
我们封装完了Lua部分的代码之后,就需要将Lua的函数注册到Lua虚拟机里面,这个我上一节已经具体说过了。
|
||||
|
||||
最后,由于我们的Lua源代码本身就是一个Makefile文件,所以我们不需要做过多的解析,直接将这个源代码输入给Lua虚拟机即可。
|
||||
|
||||
```
|
||||
string makefile;
|
||||
ifstream in("my_makefile");
|
||||
makefile = "my_makefile";
|
||||
if(!in.is_open())
|
||||
{
|
||||
in.close();
|
||||
}
|
||||
else luaL_dofile(L, makefile.c_str());
|
||||
|
||||
```
|
||||
|
||||
在这段代码里面,我们首先使用C++的fstream库中的ifstream来尝试读取是不是有这个my_makefile文件,如果没有的话,就跳过,并且关闭文件句柄,如果存在的话,就把这个文件填入到Lua虚拟机中,让Lua虚拟机直接运行这个源文件。所以这种方式是最简单快捷的。
|
||||
|
||||
代码有点多,不要担心,我带你梳理一下今天的内容。
|
||||
|
||||
<li>
|
||||
**利用C/C++语言和Lua源代码进行交互,从Lua代码中获取数据并且在C语言里面进行算法的封装和计算,最后将结果返回给Lua。** 我们在C/C++语言里面进行大量的封装和算法提取,并且也利用C/C++进行调用和结果的呈现,这是一种常用的方式,也就是C语言占比60%~70%,Lua代码占比30%~40%。
|
||||
</li>
|
||||
<li>
|
||||
另一种比较好的方式是,**使用C/C++编写底层实现逻辑,随后将数据传输给Lua,让Lua来做逻辑运算,最终将结果返回给C语言并且呈现出来**。这是很多人在游戏开发中都会做的事情,比如我们编写地图编辑器,先在Lua中编写好逻辑,用C语言在界面中呈现出来即可。如果反过来做的话,那就会出现大量的硬代码,是很不合适的。所以这种情况下,C语言占比30%~40%,Lua代码占比60%~70%。
|
||||
</li>
|
||||
<li>
|
||||
<p>**Lua可以是一种胶水语言。严谨地说,像Python、Ruby等脚本语言,都是合格的胶水语言。** 在这种情况下,胶水语言起到的作用就是粘合系统语言(C/C++)和上层脚本逻辑。所以,使用胶水语言,就像是一种动态的配置文件。<br>
|
||||
按照普通的配置文件来讲,你需要手工解析比如类似INI、XML、JSON等配置文件,随后按照这些文件的内容来做出一系列的配置,但是胶水语言不需要,它本身就是一种动态的语言。<br>
|
||||
你也可以把它当作一种配置的文件,就像今天讲的Makefile,它可以不需要你检测语法问题,这些问题在Lua虚拟机本身就已经做掉了,你需要做的就是将我们脑海里想让它做的事情,通过C和Lua的库代码进行整合,直接使用就可以了。所以,**胶水语言的本身就是一个配置文件,同时它也是一个脚本语言源代码。**</p>
|
||||
</li>
|
||||
|
||||
## 小结
|
||||
|
||||
在使用C/C++结合脚本语言的时候,需要梳理这些内容,比如哪些是放在C/C++硬代码里写的,那些可以放到脚本语言里写,梳理完后,就可以将脚本语言和C/C++结合起来,编写出易于修改脚本逻辑(如果有不同需求,可以很方便地改写脚本而不需要动C/C++硬代码)、易于使用的工具。
|
||||
|
||||
现在给你留一个小问题吧。
|
||||
|
||||
在Lua当中有table表的存在,如何在C语言中,给Lua源代码生成一个table表,并且可以在Lua中正常使用呢?
|
||||
|
||||
欢迎留言说出你的看法。我在下一节的挑战中等你!
|
Reference in New Issue
Block a user