第21讲 如何使用脚本语言编写周边工具? 上一节,我们讲了脚本语言在游戏开发中的应用,我列举了很多C语言代码,这些代码做了这样一些事情:

  • 使用C语言和Lua语言进行沟通;
  • 在C语言代码里,使用了宏和结构,方便批量注册和导入C语言函数;
  • Lua代码如何传输内容给C语言;
  • Lua虚拟机堆栈的使用。

这一节,我们要用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 source_code; vector source_object; vector c_param; vector 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虚拟机直接运行这个源文件。所以这种方式是最简单快捷的。

代码有点多,不要担心,我带你梳理一下今天的内容。

  • 利用C/C++语言和Lua源代码进行交互,从Lua代码中获取数据并且在C语言里面进行算法的封装和计算,最后将结果返回给Lua。 我们在C/C++语言里面进行大量的封装和算法提取,并且也利用C/C++进行调用和结果的呈现,这是一种常用的方式,也就是C语言占比60%~70%,Lua代码占比30%~40%。
  • 另一种比较好的方式是,使用C/C++编写底层实现逻辑,随后将数据传输给Lua,让Lua来做逻辑运算,最终将结果返回给C语言并且呈现出来。这是很多人在游戏开发中都会做的事情,比如我们编写地图编辑器,先在Lua中编写好逻辑,用C语言在界面中呈现出来即可。如果反过来做的话,那就会出现大量的硬代码,是很不合适的。所以这种情况下,C语言占比30%~40%,Lua代码占比60%~70%。
  • Lua可以是一种胶水语言。严谨地说,像Python、Ruby等脚本语言,都是合格的胶水语言。 在这种情况下,胶水语言起到的作用就是粘合系统语言(C/C++)和上层脚本逻辑。所以,使用胶水语言,就像是一种动态的配置文件。- 按照普通的配置文件来讲,你需要手工解析比如类似INI、XML、JSON等配置文件,随后按照这些文件的内容来做出一系列的配置,但是胶水语言不需要,它本身就是一种动态的语言。- 你也可以把它当作一种配置的文件,就像今天讲的Makefile,它可以不需要你检测语法问题,这些问题在Lua虚拟机本身就已经做掉了,你需要做的就是将我们脑海里想让它做的事情,通过C和Lua的库代码进行整合,直接使用就可以了。所以,胶水语言的本身就是一个配置文件,同时它也是一个脚本语言源代码。

小结

在使用C/C++结合脚本语言的时候,需要梳理这些内容,比如哪些是放在C/C++硬代码里写的,那些可以放到脚本语言里写,梳理完后,就可以将脚本语言和C/C++结合起来,编写出易于修改脚本逻辑(如果有不同需求,可以很方便地改写脚本而不需要动C/C++硬代码)、易于使用的工具。

现在给你留一个小问题吧。

在Lua当中有table表的存在,如何在C语言中,给Lua源代码生成一个table表,并且可以在Lua中正常使用呢?

欢迎留言说出你的看法。我在下一节的挑战中等你!

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e4%bb%8e0%e5%bc%80%e5%a7%8b%e5%ad%a6%e6%b8%b8%e6%88%8f%e5%bc%80%e5%8f%91/%e7%ac%ac21%e8%ae%b2%20%e5%a6%82%e4%bd%95%e4%bd%bf%e7%94%a8%e8%84%9a%e6%9c%ac%e8%af%ad%e8%a8%80%e7%bc%96%e5%86%99%e5%91%a8%e8%be%b9%e5%b7%a5%e5%85%b7%ef%bc%9f.md