写在前面

就在昨天晚上,我和一个剧本作者达成了初步协议。

我打算花一笔不多也不少的预算,拜托他帮我写一个 5-8 万字的文字游戏剧本。

到现在,已经正式进入了企划阶段。期待能拿到一个不错的书面企划。

庆幸的是,据朋友牵线,这个剧本作者还是比较靠谱的。他是 Steam 上 「昙花」这款 Galgame 的作者之一,并且有过好几个不错的作品,可以说是目前国内相对来说比较靠前的剧本作者之一。而且我也速通了一下「昙花」,行文风格大概是我比较喜欢的类型,所以决定就是他了。

当然,要自己花钱做作品的念头并不是一时决定,而是在长达了好几年的准备和积累之后,终于下定了决心。除了剧本预算之外,后续还有美术预算,以及音乐预算。

这里也要感谢我老婆的支持,就连美术她都在计划帮我找靠谱的朋友帮忙。

这一决定,都要从几年前说起。

关于一个制作 AVG 的制作框架

我很久以前因为朋友的一句玩笑话,制作过一个非常简单的小框架,这件事在我以前知乎的一些记录里也提到过。

那是一个在手机上跑的简陋文字游戏制作引擎,功能十分简单,仅有基本的背景图、对话和分支跳转功能,采用纯配置的方式,以固定的流程去实现最基本的演出效果。当时甚至连文字游戏常见的立绘都没有实现,而是用了小头像形式。

这一设定源于童年时玩的一款 GBA 游戏「换装迷宫2」,在小头像基础上加上一些抖动、缩放动画,可以做出很生动的表情效果。

这种表现形式的技术实现十分简单,即使是当初没有任何前端经验的我,在短短一周的时间之内做出较为完整的作品。

当时有个朋友夸了一句「不错啊,真有点那么回事了」。

这句话极大鼓舞了我,作为一个一直喜欢图片+音乐+文字形式的游戏作品的人,并且基于这个切入点,在后来我开始了一个完整的开源项目——做一个比较像样的文字游戏引擎。

这个开源项目在最初阶段连名字都没有,它分为两个部分:avg.engineavg.renderer.

前者实现了一个脚本引擎,后者实现了游戏的表现部分。

最后,这两部分合并为了一个项目,叫做 AVGPlus.

我在最初的时候并没有想过会一直把它坚持做下来,当初更多的是一时脑热,也怀着对一些新技术、感兴趣的技术的热衷,没有进行过多的考虑,只从一个非常纯粹的程序员角度的出发点去做了这件事。

没想到的是,在耗费了好几年的业余时间之后,坚持到了今天。

这期间我有个朋友章思源,也帮我写了不少代码。

早在2018年6月16日的时候,恰逢项目开始一周年,趁着我小圈子里一年一度的程序员网友线下聚会,我顺便定做了个蛋糕,一起庆祝了一周年,以鼓舞自己。

IMG_2537
IMG_2537

关于技术选型

文字游戏有关的引擎技术其实就那些东西,看上去都是实现些显示文字、显示图片、播放声音的活儿。

也的确是这样的,对于技术上的难点的确没有什么可圈可点的,比起前些年我在游戏行业时遇到过的坑,可以说是上不了台面。

但我的出发点不一样。如果是做一个完整的游戏作品,其实很多东西不需要考虑太多。毕竟 AVG 类型的游戏所涉及到的技术点绝对不多,并且也还远远不到瓶颈的问题,而诸如 Unity3D 这类成熟且在行业上算得上是天花板的引擎可以轻易做到。

但我希望有一个能够轻易跨平台,并且也能提供 GUI 在制作 AVG 过程中达到「所见即所得」的目的。

因为这个目标,我采用了当前比较流行的技术组合:

  • 使用 HTML5 + TypeScript 来进行游戏主体
  • 使用 Electron 进行桌面端的跨平台
  • 采用 Cordova 来兼容移动端

为了跨平台,一切都顺理成章。也因为这样,开始了一场比较不容易的技术拉锯战。

说说脚本引擎

市面上有很多种类似产品的脚本系统,比如成熟的 NScripter, KAG, TJS, BKSCR 等。

这些脚本的实现各有特色,作为 DSL 来说,他们解决了自身的问题,却又总在另外一个地方产生问题,可以说在 DSL 界永远不会有一种科学又能满足绝大多数场合的设计,但凡均衡了上手程度的地方,在另一方面都是灾难。

但在文字游戏制作领域,绝大多数的人不是程序员,他们只是普普通通的创作者,他们有的可能只是个写剧本的、甚至本身是个美术、还有可能是写曲子的,或者是学生。

通过编写脚本的方式来制作游戏,可能并不是最好的做法,但也是最好的做法。

AVGPlus 最初的技术选型比较简单,脚本引擎使用了 TypeScript 编写,这一部分实现了一个 JavaScript 的 transpiler. 我在解析器的基础上做了一些预处理工作,阉割了一些不希望用户触碰的“雷区”,屏蔽了一些不希望被调用的方法。

虽然最终我把它叫做 AVScript, 但实质上它还是 JavaScript.

之所以基于 JavaScript 本身,是因为我希望这个脚本引擎有更高的上限。

我一开始的想法是,拼命去魔改 JavaScript, 让它变得不像 JavaScript, 这样用户就少了很多直接编写高级语言的机会,应该能降低很多学习成本。比如说,在 AVScript 中,你只能写有限的几种语法:

1
2
3
4
5
6
scene.load("image.jpg")
text.show("辛苦了啊!")

if (dialog.choices(["选项1", "选项2"]).selectedIndex === 0) {
// ...
}

基本上,除了我暴露的若干个全局类对象以及它的拥有的方法之外,就只有 JavaScript 本身的流程控制语句了。但是你没办法使用 async/await 来破坏脚本的流程,这在转译阶段的时候我就会抛异常,并且你也不能使用 JavaScript 内建的 setTimeoutsetInterval 等方法,因为这可能会让游戏脚本的执行顺序不可控,对于一些 JavaScript 基础没那么好的用户而言,要管理这些细节实在是太难了。

这就是我所认为的雷区。

果然,后面找了一些没有太多编程基础的朋友作为种子用户,他们在用搜索引擎找到很多用法之后,自作聪明地使用了一些他们认为可以达到目的的技巧,结果导致整个脚本引擎都出现了极其不可控的 BUG.

面对种种问题,我也一直在试图降低编写脚本的学习成本,却又在试图提高脚本引擎所能带来的上限。

在这种矛盾的心里下,我终于把脚本设计得一塌糊涂了。最终变成了最糟糕的结果——学起来不简单,但也做不了什么大事的感觉。这回不仅仅对不懂 JavaScript 的人不友好了,甚至对已经掌握 JavaScript 的人也变得反直觉了起来。

所以我提出了心中的疑问,既然采用了高级脚本语言去取代 DSL, 那么降低难度还有意义吗?

在经过一番心里挣扎之后,我决定放开地去设计脚本。

我去掉了一些限制,让它回归 JavaScript 本身的自然 ,瞬间,觉得心里的矛盾似乎被解开了,我不再去考虑普通用户如何使用的问题,我纯粹考虑如果是一个相对有经验的程序员来用这样的脚本系统,能有多好的体验。

基于这样的理念之后,我也大概知道自己应该要做出一个什么样的东西。

关于图形渲染

前面有说到过关于技术选型的问题。但是一开始我也急于快速完成原型,从而忽略了上限的问题,想着场景、立绘都是纯图片,并不吃性能。于是前期所有关于图片的渲染,都是直接走了 HTML + CSS 的形式,直接在 DOM 上呈现出来,一把梭。而这种方法的确也完全实现了我的需要,一个简易的框架很正常地运作了起来。

而后来,因为支持了 1080p 的图片之后,并且对立绘以及场景的渲染有了更高的要求,甚至 CSS3 动画也已经满足不了需要,而 CSS3 本身的 filter 有很多致命的性能缺陷,稍微复杂一些的场景很容易导致丢帧。

要知道一个简单的 AVG 画面都能有各种性能问题的话,很容易给人造成不好的印象。

后来我进行了重构,文本的排版依然是基于 DOM, 因为我再也想不到有什么样的文字排版引擎能比 DOM 本身做得更好的了。而且文字渲染本身并没有多大的性能瓶颈。

但是图形本身,包括立绘、场景图片等,我都基于 pixi.js 去采用 WebGL 的方式进行渲染,这样一来,性能有了不错的提升。在这个基础上,还写了一堆 shader 作为滤镜,实现了大量 CSS3 无法做到的画面效果,并基于这样的渲染能力,设计了一个相对比较完善的链式动画系统,目前能做到的演出上限瞬间就提高了。

在这里也感谢喵酱帮忙写不少 shader, 能得到图形学专业的高手帮忙,我是十分高兴的。

一些难点

前面提到用 JavaScript 本身来作为脚本引擎所使用的语言。

在 AVGPlus 中,脚本引擎通过 I/O 读取 *.avs 脚本文件之后,会通过解析、通过转译,最终才变成可以执行的 JavaScript. 如果是在带有 Node.js 环境的 Electron 下,会使用 vm2 执行,在浏览器环境中则使用 eval().

因为动态语言的特性,并且无法具体得到 JavaScript 的 Sandbox 的上下文,以至于无法像其它 DSL 那样轻易实现一个可以进行快照的沙箱系统。事情又变得复杂了起来。

传统的 DSL 的实现,必然会实现一个类似沙箱系统,沙箱中会储存脚本的上下文状态。不管执行到哪一步,都能轻易做到暂停、播放、快照。

存档的时候会直接对沙箱做一个快照,当前它的指令所运行的结果,都会储存在沙箱中,存档的时候先进行快照,而下次读档时,直接读取快照的内容,还原沙箱中上下文数据的渲染即可。

在说存档之前,我们先从 AVScript 的调用链机制开始说起。

默认情况下,AVGPlus 的入口脚本是 main.avs,通过 flow.call("...") 方法可以进行脚本的跳转,而我推送的流程控制,也是以一个脚本文件为单位的。如:

1
2
3
4
5
/* main.avs */
text.show("一些文本 1...")
text.hide()
flow.call("story-1.avs") // 跳到 story-1.avs 脚本
text.show("一些文本 2...")
1
2
3
4
5
6
/* story-1.avs */
scene.load("s.jpg")
character.show("name", "char.jpg")
text.show("一些对话...")

// 回到 main.avs 的 flow.call() 后面

在上面的脚本中,执行 main.avs 时如果遇到 flow.call() 方法,会中断执行,从而转到执行 story-1.avs 中。

但是在 story-1.avs 中,我们调用了 scenechracter 类,这两个类会进行图片的渲染,它在画面上渲染了一个场景以及角色立绘。如果玩家此时存档,场景和立绘是必须被记录下来的,而当前的对话内容,也应该被记录下来。

所以预期结果应该是在下一次读档的时候,能够还原读档时的画面和文本。

已知无法直接拿到 JavaScript 的上下文,怎么办呢?

我和我朋友章思源讨论了一下,决定设计一个「回放」机制。

在游戏的运行过程中,我们实现了一个「运行时」(Runtime),也就是类似沙箱的一个机制。

在每个可能需要储存状态的 API 被调用时,比如显示场景、显示立绘、正在播放的声音,这些都是有状态的 API. 但凡这些 API 被调用之后,都会在 Runtime 中记录其状态,并且每一个有状态的 API 被调用,就会让 Runtime 里的 progressAt 递增 1.

于是整个「运行时」的对象的结构类似:

1
2
3
4
5
6
7
8
9
Runtime = {
progressAt: 50,
data: {
characters: [...],
scenes: [...],
sounds: [...],
text: [...]
}
}

在执行存档时,会把 Runtime 中的所有数据写到本次磁盘上。当下次读档时,脚本引擎会进入「读档模式」。

所谓「读档模式」,就是回放系统的一个快进模式。这个模式会完完整整地从 main.avs 这个入口脚本开始,重新运行一次游戏脚本,即时你已经进行到了游戏的大后期,脚本也会从入口脚本开始。

和平时不一样的是,这个模式不会进行任何图形的渲染,也不会播放声音,更不会渲染任何耗时的动画,只是静默地执行所有脚本代码,包括其过程产生的临时变量、都会随着这个过程的进行而重新写到内存中。

这个回放的过程非常快,因为几乎不带任何比较耗时的操作。遇到有状态的 API时,progressAt 也会持续递增,直至计数和存档内的一致,我们可以认为游戏已经回放到了之前存档的状态了。

此时,把存档中的所有状态,包括场景、立绘、文本,一次过渲染出来。

当然,这个回放过程,其实还有一些小细节需要处理。比如说对于一些有随机数产生的场合,回放过程是没办法知道当时的随机数种子的,因此,我在设计 AVScript 的时候,把 JavaScript 内置的 Math.random() 给屏蔽了,而是提供一个全新的随机函数来进行随机数生成,这样我可以有办法接管所生成的随机数,并且写到 Runtime 中,下次遇到同一位置的随机数时,直接把保存下来的随机值塞进去,而不是重新生成一个随机数。

包括一些让玩家进行选择的剧情选项,也是在存档时记录下选项的 Index, 在读档时直接跳过剧情选项,直接赋值记录下来的 Index, 而不是重新让玩家选择。

至此,整个读档过程完成。

回到独立游戏上

我为什么要花钱制作一个 AVG 呢?

比起传统游戏,或者大多数人所知的网游来说,AVG 是比较小众的题材,当然它耗资也不多。

在我的预算里,5-8万字的剧本,包括全部美术、音乐下来,大概花10万块钱可以有个质量还可以的雏形。当然,这个还不是最高规格的作品,我甚至有朋友在他的作品上整整花了过百万人民币。

这个上限可以非常大。这其中还不包括主创本身的时间成本,而且整个制作过程中甚至也会出现一些边际成本较低的决策。所以说这个行业大多数人在用爱发电是一点都不假。

而对于我这样的一个上班族来说,能够利用的时间只能是下班时间,并且下班时间的利用率算不上十分高,而且做的内容和日常工作中用到的内容相差太远,也会产生割裂感,从而导致精神上的疲惫。

实际能够高效利用的时间,只能是周末。

我之所以想做一个作品,一个是因为我想把一个有血有肉的故事通过一种全新的形式表达出来,也算是圆了我一直以来的梦想。其次是因为开始做了 AVGPlus, 也希望尝试能通过自己用职业技能创造出来的东西,去给自己一个交待,算是给自己这些年的业余时间的坚持交出一份答卷。

还有十多天就是我的生日,到时候就整整28岁了。我上次有创作激情的时候,可以追溯到18岁。这些年来,很多东西都变了,但我的创作激情没有变,虽然有时候也会想生活的各种繁琐事,但它以另外一种方式始终伴随着我,而我自身的能力也随着时间的增长而变得成熟,我相信我现在再来做这些事,风险会变得更低。

很庆幸即使过了这么久,我还有初心。

愤怒的泡面
2020年7月22日,凌晨