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里的行数,是否继续执行(根据反射获取方法是否为可挂起函数,如果不清楚什么叫可挂起函数,详细在第三代里的挂起恢复与序列化章节),对这个数组遍历执行,停止后序列化当前下标即可。