随着Javascript引擎的一阵运转,整个浏览器都变得索然无味起来

论如何用浏览器跑Galgame

前段时间搞到一把Galgame,在例行拆包,自动化拼图之后(关于自动化拼图,还有一点背时故事,看看好久讲嘛),我看着面前的桌面,突然有了一个大胆的想法:既然我能够自动化读取一部分游戏逻辑来组装图片,那我能不能同时把文本和声音组装上去呢?

可以!当然可以!俗话说,凡是能用Javascript重写的程序,就必将用Javascript重写。在一个整个空气中都弥漫着躁动气息的夜晚,我在床上辗转反侧难以入眠,算了,睡NMB,起来编!

技术选择

我用不来Electron那一套,也用不惯JS的残废本地文件API (考虑到安全原因,理解)。于是我决定找一个便携HTTP服务器玩AJAX,一通搜索后,发现还是nginx好玩……估计是开发者懒得写安装包,windows下的nginx原生便携。

适当设置(autoindex on; autoindex_format json;)后,nginx可以直接提供json形式的文件列表。这就很爽,引入个jQuery,用AJAX直接取信息。加载游戏逻辑本身不存在不可逾越的困难,毕竟徒手解析Lua脚本的事情我前段时间干过一次。AJAX取代码后,按行切割一下,通过简单粗暴的方式切分关键字就勉强可用了。

然而我并不会写编译器。这就有点好玩了。也就是我没法玩诸如Tokenizer啊语法树啊什么的,也就谈不上什么表达式解析了,像什么JIT也是不用想了。幸好,Galgame脚本的一大特点是连循环都没几个,调用函数的种类也不多,基本上就是显示名字显示文本什么的。换句话说,我只要实现一个严重缩水的解释器和一套严重缩水的运行时环境就行。

分析

经过自动化处理后的原游戏目录结构如下:

├── bmp
│   ├── balloon.png
│   ├── ............
│   ├── battle
│   │   ├── alert.png
│   │   ├── ............
│   │   └── waku02b.png
│   ├── bin.png
│   ├── cursor.png
│   ├── effect
│   │   ├── ball_02.png
│   │   ├── ............
│   │   └── wind_03.png
│   ├── title
│   │   ├── button00a.png
│   │   ├── ............
│   │   └── title00d.png
│   ├── trans
│   │   ├── blind1.png
│   │   ├── ............
│   │   └── wipe9.png
│   └── visual      // 立绘&&背景
│       ├── bg00a.png
│       ├── ............
│       └── win02.png
├── music      // BGM
│   ├── bgm02a.wav
│   ├── ............
│   └── bgm18b.wav
├── script      // 游戏脚本
│   ├── 00.lua
│   ├── ............
│   ├── h101a.lua
│   ├── h101b.lua
│   ├── h102a.lua
│   ├── ............
│   ├── h109b.lua
│   ├── _variable.lua
│   └── _visual.lua
├── sound      // 音效
│   ├── ef005.ogg
│   ├── ............
│   └── se929.ogg
└── voice      // 语音
    ├── Lado20000.ogg
    ├── ............
    ├── NaB23.ogg
    └── Na_sys_000001.ogg

游戏脚本示例如下:

  while true do
    while true do
      local _break_ = false
      local virgin = isvirgin(S_Nazuna)
      playmusic(M_Rape)
      scene("v101a", 1)
      playvoice("Na20076")
      name("\130\200\130\184\130\200")
      _text("\129u\130\164\130\160\129A\130\177\130\204\129c\129c\130\193\129I\129@\149\250\130\181\130\200\130\179\130\162\130\230\129I\129v")
      page(1)
      name("\150\251\130\183\130\220\130\181")
      _text("\129u\130\211\130\165\130\211\130\165\130\211\130\165\129A\138\136\130\171\130\204\151\199\130\162\130\168\143\236\130\191\130\225\130\241\130\182\130\225\130\204\130\164\129v")
      page(2)
      _text("\144\237\147\172\130\197\148s\150k\130\181\129A\145S\144g\130\240\150\251\130\220\130\221\130\234\130\201\130\179\130\234\130\196\130\181\130\220\130\193\130\189\130\200\130\184\130\200\130\170\149s\137\245\138\180\130\201\138\231\130\240\152c\130\223\130\233\129B")
      page(3)
      _text("\150\251\130\183\130\220\130\181\130\205\130\187\130\241\130\200\148\222\143\151\130\240\148w\140\227\130\169\130\231\149\248\130\171\130\183\130\173\130\223\130\200\130\170\130\231\129A\138\240\129X\130\198\130\181\130\189\149\\\143\238\130\240\149\130\130\169\130\215\130\196\130\162\130\189\129B")
      page(4)

可以通过人工阅读游戏脚本文件推测函数功能,并实现之。游戏脚本可由一个功能萎靡的虚拟机来执行。游戏立绘的各个变种采用了差分形式,需要将各个”碎片”按特定坐标组装回去以形成正确的立绘,组装可以用CSS排版实现(也可以Canvas拼图,但是我懒,没做。在另一个实验项目中立绘使用的是Canvas拼图,但是为了兼顾动画,仍然使用CSS进行定位),坐标值可以在一段单独的脚本中读取。部分函数(例如播放BGM)使用了全局变量,也可在一段单独的脚本中读取。字符串采用的Lua表示方式,需转换为SJIS编码,再用编码库转换为UTF8。

实现

聊胜于无的预处理器

为了降低写解释器本身的难度,我选择先将脚本预处理一下,剔除未实现功能,将文本形式的脚本转换为数组形式的命令序列。

[["scene","v101a","1"],["playvoice","Na20076"],["name","\130\200\130\184\130\200"]]

转换过程很简单:按行切割,把特定字符转换为空格,按空格切割,去除无用指令。考虑到这实际上是反编译器的输出,因此不需要考虑兼容不同代码风格的事情,这种做法还是可行……在此之后,还需要把特定指令中的字符转换为标准UTF8形式。

字符串转码

Lua字符串不原生支持多字节文字,Lua字符串中的多字节文字必须以\ddd形式的转义符表示。其中ddd为十进制数,表示该字节的值。此游戏使用ShiftJIS编码,因此在将Lua字符串转换为字节序列后,需要再次转码为UTF-8。实现中有以下几点:

  • Javascript可使用UInt8Array作为char[]。
  • TextDecoder类不会以\000为字符串结束,而是读取到UInt8Array的最后一位为止。
  • Lua字符串中’\'(0x5c)本身也以’\\’形式出现,需要特殊处理。
  • JS取ASCII码是用的String.charCodeAt(),不应使用类型转换。
function cvtstr(str) {
	var buf = new Uint8Array(new ArrayBuffer(str.length));
	var p = 0;
	var w = 0;
	var ch;
	var int;
	while (p < str.length && w < str.length) {
		ch = str[p];
		if (ch == '\\') {
			int = parseInt(str.substring(p + 1, p + 4));
			if (isNaN(int)) {
				p++;
				buf[w] = ch.charCodeAt();
			}
			else {
				p += 4;
				buf[w] = int;
			}
		}
		else {
			p++;
			buf[w] = ch.charCodeAt();
		}
		w++;
	}
	return new TextDecoder('sjis').decode(buf.strip(0,w));
}

小霸王解释器

考虑到这脚本不存在goto,不存在循环,,我也就没有实现goto。很幸运,Lua用end关键字标识if-else, function, while等结构的结束,不用我去读花括号。对于if-else,我采用了很投机取巧的解决方法:读到if时,手动输入表达式的值,如果为假,跳到下一个else后或者下一个end处,如果为真,继续执行,读取到else时,跳转到下一个end。我知道这很投机取巧,但是在这破玩意上它能用。当读取到非语言关键字的指令(例如:playmusic)时,将指令原样转发给运行时环境执行。

function vm() {
	hang = false;
	while (pc < program.length) {
		if (hang) return true;
		arg = program[pc].slice();
		switch (arg[0]) {
			case 'if':
				// ask for variable here
				depth++;
				if (variables[arg[1]] === undefined) {
					variables[arg[1]] = true;
				}
				else if (variables[arg[1]] != true) {
					// goto else
					if (!toNextCmd('else')) return false;
					pc++; // skip else itself
				}
				// else run as normal
				break;
			case 'else': // you come from an if and exec the if stmt
				if (!toNextCmd('end')) return false;
				break;
			case 'end': // end, pop fake stack
				depth--;
				if (depth === 0) return false; // stack check, no code is running
				break;
			case 'break': // break to next end
				if (!toNextCmd('end')) return false;
				break;
			case 'while': // keep stack happy
				depth++;
				break;
			case 'function': // another function, you goes too far
				return false;
			default:
				runtime.call(arg);
		}
		pc++;
	}
}

假运行库

这怕是整个项目中最长的部分。运行库包含了解释脚本所必需的所有函数的实现,从显示立绘到开关BGM再到等待用户输入。对运行库功能的调用全部通过统一的入口函数实现,由该入口函数通过跳转表跳转到对应函数。叠图是在position:absolute的div上面不断叠加position:fixed的img。其他功能都是拿jQuery逮住元素直接改就是。虽然又臭又长,但是好在逻辑简单。此处不详写,源代码里面啥都有。

发表评论

电子邮件地址不会被公开。 必填项已用*标注