Contents
前言
制作脚本解释器最初的动机,是为了两个需求。一是想让编辑制作人员可以脱离游戏引擎的束缚,减少文字录入还需要学习游戏引擎的成本;二是制作思路,目前还没有一款能够在运行时将程序状态序列化的语言,并且像krkr那种标记式的语言并不能拥有高级语言的各种特性。由此开始,开启了我制作脚本解释器,学习编译原理的旅程。
简陋的第一代解释器
在制作第一代解释器时,我还没有学习编译原理,其实也并不光是没学过编译原理,可能编程都没怎么学过,基本上都是在用C#最基本的字符串去切割去匹配,替换等操作,非常的简陋,实现的功能也是非常简单。
函数调用
执行的规则为一行一句,直接使用换行符分割脚本后,一条一条按字符串去去识别命令和参数,在存档时储存正在运行的行数,等读档的时候直接从这个位置开始执行。
标题页面的脚本,用来初始化和设置UI,添加按钮等操作:
//title文件 //AddUI{ mn_tt_bga_bg00_1-1 , true , 0 , 0 } ShowWhite{ false , 0.5 } AddUIEX{ mn_tt_bga_bg00_1-1 , true , 0 , 0 , down , 3 } AddBtn{ mn_tt_rlogo00_1-1 ,true , 0 , -100 , AddUI[ mn_tt_menu00_1-1 \ true \ -340 \ -300 ]} //AddUI{ mn_tt_rlogo00_1-1 , true , 0 , -100 } Active{ false } AddBtn{ mn_tt_menu00_1-1 , true , -340 , -300, GameStart[start]} AddBtn{ mn_tt_menu03_1-1 , true , -170 , -300 , PageShow[saveload]} AddBtn{ mn_tt_menu04_1-1 , true , 0 , -300 ,PageShow[galleryx]} AddBtn{ mn_tt_menu05_1-1 , true , 170 , -300, PageShow[config] } AddBtn{ mn_tt_menu06_1-1 , true , 340 , -300 , Exit[]} Active{ true }
剧本的开始的一小部分:
TNextEA{ false,2, 我回来了————%next%正要说出口的话不知为何哽在了喉咙里。%next%独自伫立在家门前,我静静地凝望着那扇熟悉而陌生的大门。%next%铁质的把手已经镀上了点点锈迹,不显眼的角落里隐现着%next%幼时涂鸦的痕迹。%crlf%被时间夺走的往昔,留下了丝丝印记。%next%自己……究竟有多久没有说过那句话了呢。} PlayVideo{ op ,true } BGP{ BG010_1-1 } PlayMusic{kotoribg2} TNext{ 我回来了。,小鸟,,,} TNext{ 宣告自己拥有归属,被人等待着的这句话语,从什么时候起变得没办法说出口了呢。我很清楚…。,小鸟,,,}
整体上看起来非常的诡异,比如函数需要用大括号来调用,分隔符使用逗号。类似AddUI[]可能会被认为是访问数组,其实是在大括号内调用的函数,分隔符使用反斜杠,当然,如果你想在中括号的函数内需要调用函数,就需要用到小括号了,而小括号内无法在内嵌函数调用了。
这么设计纯粹是因为字符串分割非常方便,尤其是在我当时编程水平比较弱的情况下,虽然局限性非常大,代码也非常丑,不过能用。
字符变量
还支持了一些变量的功能,你可以在参数或字符串中插入这些变量,当然也是使用暴力替换实现的:
%01% , //英文逗号 %02% { //左大括号 %03% } //右大括号 %04% [ //右小括号 %05% ] //右中括号 %06% \ //反斜杠 %crlf% //换行符 %next% //换页符 仅限TNextEA
高级功能
甚至你可以看到一些高级的函数可以使用:
//加载AssetsBundle中的对象(文件名,对象名,对象类型[TextAsset / Sprite / Prefab]) LoadAB(name,object , Sprite) //加载AssetsBundle场景(文件名,场景名) LoadABScene(name,scene)
第一代解释器仅重置了小粉棉制作的Rewrite同人游戏《if I were a bird》,该游戏因使用了古老且启动有联机验证的引擎,现已无法打开,所以对其进行了重置。在https://www.bilibili.com/video/BV1Pb411W7uG中的前半部分有演示。
看起来是门语言的第二代解释器
第二代的脚本语言的语法看起来也是非常的奇妙,此时依旧没有学习编译原理的理论知识,不过要比一代整体上了一个档次。
宏定义
首先为了美观和易用,抛去了一代中像用大括号中括号小括号来嵌入函数的方式,因为这一代,并没有支持函数嵌套,但也却给了编辑者更多的其他特性。
首先,为了增加语言的功能和自由度,新增预处理阶段,宏:
#define @bg Game.Bg #define @t Game.Text #define @s Game.Say #define : Sys.Label
在设计第二代的语言时,碰巧接触到了C语言这门短小精悍的语言,就发现宏这个东西可以实现很多语言原本没有的特性,各个框架类库、甚至C语言标准里也有宏的出现,我就把这个神仙特性请了过来。
当然使用宏的后遗症就是:遇到问题时,没办法准确的定位脚本行与列的位置。
游戏脚本
来看一小段主脚本main的代码:
Sys.Title( "Summer Pockets 镜子" ) Sys.Icon( SPICON ) //Return() Scpt.New(GameNormalStyle) Sys.PlayVideo("title" ,false) Sys.Page(title) Snd.Msc( "Summer Pockets" ) UI.AddImg( __sys_tm_bg01a_1-1 , 0 , -420 , 1 , titleback ) UI.MoveTo( titleback , 0 , 420 , 9 , title , true ) Scpt.Sleep(1) UI.AddImg( __sys_logo02_1-1 , 0 , 0 , 1 ) UI.AddImg( bs1_ky010201_1-1 , -1004 , -265 , 0.5 ) UI.AddBtnS( __sys_tm_btn01_1-1 , __sys_tm_btn01_1-3 , , ,780,200,start ) func Sys.Page(title,false) Snd.StopMsc(true) Sys.Page(game) Scpt.New(game_chunk2) end func
最开始还是一样,调用一些设置的函数,比如设置窗口标题。
在这个版本中,每个脚本文件会被看做一个模块,这个模块也会像函数一样被调用,其中Scpt.New(GameNormalStyle)就是去执行GameNormalStyle这个模块,该模块包含了各种样式属性,这里放GameNormalStyle一小段代码:
//清除原设置 Gstl.Clear() //设置背景图 Gstl.BG( "__sys_mw01a_1-1" , 1 , 0 , -365 ) //设置名字框属性 Gstl.Title(-332,-260,520,75,36,1,"__sys_mw01b_1-1",-323,-266) //设置内容框属性 Gstl.Text(0, -400, 1200, 180, 36 , 1.2) //设置等待图标属性( 格式 ) Gstl.WaitIcon( "__sys_mw01_key00_1-#" , 1 , 24 , 12 , 681 , -449 ) //设置Log界面背景图 Gstl.History("__sys_bk_bg00_1-1") //设置左右括号 Gstl.Bracket( "「" , "」" )
匿名函数与回调
在主函数中你可以看到一个让人匪夷所思的语法:
UI.AddBtnS( __sys_tm_btn01_1-1 , __sys_tm_btn01_1-3 , , ,780,200,start ) func Sys.Page(title,false) Snd.StopMsc(true) Sys.Page(game) Scpt.New(game_chunk2) end func
这函数后面的func到end func之间,可以看做一个匿名函数回调,作为AddBtnS的最后一个形参传入。
实际上的实现只是把这段匿名函数储存成了字符串传了进去,回调触发时,便会把这些字符串扔到解释器中执行。这么做的后遗症和宏一样,因为这个是匿名的,所以在遇到错误时几乎很难获得有用的信息。
宏的易用性与问题
一小段脚本演绎:
@s("……", "羽依里" ) @t("出发吧") @bg("bg999a_1-1") Snd.Mus( "Sea, You & Me" ) @t( "路上已经没有行人在走了。" ) @t( "青空中的太阳,正在从遥不可及的彼方俯视着整座小岛。" )
因为使用了宏,可以把常用的指令替换成更短的,用的不多的直接使用原名。
:(label1)
可以使用goto函数跳转到声明label的地方,使用了宏的label也被简化成了冒号,不过因为是函数所以还是要加括号,声明标签是函数就有个显而易见的缺陷:只能往上跳,不能往下跳。
字符变量
第二代的变量样例:
@t("欢迎%UserName%")
和第一代一样,同样支持变量,不过增加了更多可用变量,甚至是系统时间,windows用户名等内容,玩过心跳文学部的玩家都知道,Npc会说出你windows的用户名,如果使用windows用户名使用的是自己的名字,那么玩家都会被吓一跳。
总结
使用该代解释器制作了SummerPockets的同人游戏《SummerPockets镜子线》,在https://www.bilibili.com/video/BV1Pb411W7uG中的后半部分有演示,同时也增加了如cg相册和丰富的设置等功能。
在本代语言使用过后存在的主要问题就是:
- 使用宏和匿名函数的实现导致错误无法准确定位
- goto只能往上跳
- 在制作galgame时,有非常多的对话情节,而对话的名字则是重复的,但却要在每句话后面传入对话人名字。
为了解决问题,开始学习编译原理,并展开重新编写第三代解释器的工作。
支持面向对象的第三代解释器
相比前两代的解释器,这代的解释器是极其复杂的。
这时博主已经学习了一部分C++知识,编程水平也相比之前有很大的提高,并且习得了一些编译原理的皮毛,做这一代的时候就觉得为了个galgame制作已经越来越离谱,除了用作galgame脚本以外可能还有其他用途,所以为了更大的万一,使用了C++语言。
https://github.com/JomiXedYu/JxCode.AtomScript
在编译原理中,一共有几个阶段,经过我优化(偷懒)之后的解释器的各个阶段:
输入 –(源代码)>> 词法分析 –(Tokens)>> 语法预处理器 –>> 中间码生成 –(中间代码)>> 解释器
首先就是字符串的模式匹配,每个Token(词法记号)都保存着完整信息,一个词法对象是关键字?还是字符串、数字、标识符?在哪个文件,几行几列等信息,非常的全面,也为后续准确的错误查找提供基础,本代解释器同时也删除了宏。
在词法分析之后新增了个语法预处理阶段,这个阶段主要是扫描一些Label信息,以供后续阶段使用。
在获取Tokens之后,没有编写AST的阶段,而是直接通过Token流来匹配语法的各种形式,生成中间代码(或叫三地址代码),解释器实际运行的就是中间代码,类似比较高级的汇编一样。
在生成中间代码之后,为了提升下次加载速度,把该中间代码直接缓存起来,这样就不用重新经历前几个步骤了。
解释器的实现:
在解释器启动时要求传入中间代码和Label信息,准备好后一个while从头运行到尾。
但是还记得galgame的需求吗?每点击一下,剧本才会向下一句,在这个过程是可以存档的,所以为了这个需求,该脚本语言一出生的基本要求就是:可以将运行状态序列化。
对象
解释器支持着面向对象,主要是为了解决前两代解释器中,Npc名字输入过于频繁的问题。而该解释器由C++编写,为了更好的储存其他语言的对象,使用一个整数索引当做指针,C#的对象C#自己存,然后给C++一个Id保管。当序列化时,C++也会通知C#返回一个C#侧的序列化结果。
为了更方便的函数调用,取消了使用括号的形式,而是改成使用冒号。如
@ch.say: "hello", 3
字符串
拥有一个字符串池,添加前会先查找池里有没有这个字符串,字符串变量和对象一样,也是一个整形当做指针使用。
内存
在该解释器启动时会创建一个对象池,该池会保存脚本中所有的变量(脚本所有变量均为全局变量),而字符串和其他语言实例对象保存的是整数Id,并非真正的指针地址,所以只需要把对象池序列化,就算完成一半了。
挂起、恢复与序列化
程序挂起,就是运行到当前位置,不继续运行了,跳出了解释器的while,在挂起情况下,只要将当前运行的位置以及上述的内存一起序列化,那么解释器状态的保存需求就得以完成了!
实现如下:
如果一个函数第一个形参为Interpreter解释器对象,第二个形参为引用的bool类型,那么这个函数被称作“可挂起函数”(我自己起的)
一个可挂起函数的函数签名样例:
public float Max(Interperter i, ref bool isNext, float x, float y);
如果一个可挂起函数在执行过程中,将isNext设置为false后,解释器将在把return返回值设置到变量池后,退出while循环打断脚本继续向下执行,而如果想继续执行,就需要有人恢复解释器,去调用解释器的Next方法。
在这个方法中就可以将Interpreter对象保存下来,或者使用闭包匿名函数作为其他事件的回调,来等待解释器的恢复。如
public float Max(Interperter i, ref bool isNext, float x, float y) { TriggerManager.Instance.Wait(()=>{ i.Next(); }); isNext = false; return Math.Max(x, y); }
而脚本则会在调用Max后被挂起,等待着TriggerManager的事件去恢复解释器,在这期间,可以进行存档序列化等其他工作。
标准库
标准库主要增加了两个函数封装,一个是数字库,一个是字符串操作库,函数名和C函数库一致,字符串连接使用如
$v = "world" @str.strcat: "hello", v
垃圾回收机制
你没有看错!因为引用的存在,所以必定会有垃圾的问题,引入了垃圾回收机制,虽然有,非常烂,但能用。
来给大家现个丑:
//每隔256行执行一次GC if (this->exec_ptr_ > 0 && this->exec_ptr_ % 256 == 0) { this->GCollect(); }
语法
语法开始使用了一部分符号来代表着语句的功能
:: 代表标签 @ 代表可执行的语句 $ 代表赋值 >> 代表goto到某个label ? 代表if判断表达式
支持的数据类型
- 数字(4位浮点)
- 字符串
- 用户引用
其中会有自带的一些常量如True和False会被赋值为1和0,像C语言一样作为布尔存在。
还有一些拥有特殊功能的变量,如__return,代表着函数调用的返回值。
你甚至可以使用这个语言去执行unity方法
@UnityEngine.GameObject.Find("Button") $btn = __return @btn.name = "new name"
你甚至也可以写出类似函数的功能,通过函数名_arg1,函数名_arg2等变量赋值传参,并最后传入函数名_ret作为会跳的标签。而?代表if。语法里的缩进是没有意义的,为了易于查看使用。
:: max //max_arg1, max_arg2, max_ret ? max_arg1 > max_arg2 >>max_j1_t else >>max_j1_f ::max_j1_t __return = max_arg1 >>max_j1_end ::max_j1_f __return = max_arg2 >>max_j1_end ::max_j1_end >> max_ret //back
使用面向对象特性的一小部分游戏脚本:
@chara.get: "老王" $person = __return person.say: "这是一句由老王说出的人物对白"
总结
在第三代的游戏上也取得很大进步,文本历史,成就系统,分支选择,舞台管理,动画系统等等功能。
放弃治疗的第四代解释器与最终解决方案
在第三代解释器使用期间,仍然要不断的完善和修改解释器系统,后续的维护量一眼望不到头,想想最开始的目的是什么,就放下了解释器后续的升级与维护,并且现版本的脚本语言对文本演出制作人员并不友好,所以最后还是更换了解决方案。
解决方案Excel+xLua
使用Excel录入指令、文本后,程序将在启动时读取Excel并转换为Lua后缓存起来,最后还是遍历Lua数组执行。
转换后的Lua代码:
_script_launch={ [1]={line=2,func=function() local isNext=true isNext = CS.sys.title(nil, isNext, "Summer Pockets Short Story") return isNext end}, [2]={line=3,func=function() local isNext=true isNext = CS.cfg.bracket(nil, isNext, "「", "」") return isNext end}, [3]={line=5,func=function() local isNext=true isNext = CS.sys.showpic(nil, isNext, "about.jpg", 5, true) return isNext end}, [4]={line=6,func=function() local isNext=true isNext = CS.sys.video(nil, isNext, "title.mp4") return isNext end}, [5]={line=8,func=function() local isNext=true isNext = CS.sys.procedure(nil, isNext, "ProcedureTitle") return isNext end}, }
该Lua数组记录了脚本在Excel里的行数,是否继续执行(根据反射获取方法是否为可挂起函数,如果不清楚什么叫可挂起函数,详细在第三代里的挂起恢复与序列化章节),对这个数组遍历执行,停止后序列化当前下标即可。