Bilibili《七夕之约 - Double;7》技术剖析

少女dtysky

世界Skill

时刻2017.08.27

今年B站七夕活动的GalgameDouble;7由我负责原案、程序和演出三部分,深度使用了白鹭游戏引擎的2D部分并为之写了一个galgame插件egret-galgame,下面就这次活动的各个方面我将输出一篇心得,各位稍安勿躁,待我娓娓道来。

前言

本人的开发之路算是比较崎岖,到现在为止从底层硬件开发到WEB前端都干过一些,而现在基本确定了WEB前端的道路,虽说如此,这条道路却和我梦想的道路并非一致,本质上来讲,我还是想做游戏、尤其是做GAL。但这并不代表我舍弃了这个追求,而是一直在需找契机,看是否有机会争取到资源进行尝试。

众所周知,B站去年七夕就有这样一个尝试,然而那时候我入职尚早,尚在后台开发组中,在公司内也没有资源和话语权,所以并未能参与。而这次的七夕,我终于有机会并果断抓住了机会,和一帮同样很有想法和干劲的同事进行了这次游戏的开发,虽然受制于H5活动本身的性质,此次也有所遗憾,但终归还是做出来了不错的作品,对这些同事表示由衷的感谢。

对于本人而言,负责GAL的原案实在是一件非常具有诱惑力的事情,自己过去也做过很多尝试,加之也有不少GAL的游戏经验,所以对游戏质量自然有一定要求。但和正常游戏不同,这次游戏本质上是一个H5活动页,所以有存在着很多问题。

其中一个问题,就是如何在有限的时长和资源内做出一个比较合格的GAL,而这一点是比较困难的。对于一个H5的活动页面,本次活动预计的十分钟的平均通关时间实际上已是超常规,上面也有所微词,但考虑这其实又是一个游戏,所以最终还是争取到了这个时间。虽然十分钟对于一个正统GAL还是太短,但尚可比较完整得走完一套流程。

第二个问题是游戏风格倾向。作为资深GAL玩家,虽然对废萌也不反感,但自身还是倾向于深刻向的正剧或者电波作,所以一开始基调定的是偏向悲剧中透着淡淡的关怀的、对死宅人文关怀的类型。但和编剧讨论后,最终还是由于这个活动的官方欢乐性质妥协了,这一点确实没有办法,但编剧菊苣很好得使用了之前B站相关的一些梗,无厘头也算是一个比较安全的倾向吧。

第三个则是设备问题。此次平台是移动端,而且是WEB上,加之在APP内主推,所以横屏模式下可用空间有限,最终选择了竖屏模式。竖屏模式和传统GAL的演出是相悖的,因为其不符合一个舞台相对于人眼的空间成像习惯,同时也限制了一个舞台(屏幕)中出现的任务、物体数量,这相对于传统GAL是一个瓶颈,而白鹭也只提供了锁定竖屏而非横屏自适应模式。所以必须有一个方案来在竖屏模式下模拟横屏演出,我做到了这一点,具体细节会在下文中说明。

原案

本游戏主标题是《Double;7》,这一个标题承载着多重的含义,其直接影响到了游戏的主题和形式,也算是对某两个经典系列的致敬。

首先,“Double7”即“双7”也即“77”,即七夕,这意味着这个游戏是一个七夕的游戏。

其次,Word + Number形式的标题是业界眼泪KID的的无限轮回系列每一作的命名方式,其中最著名的而是《Ever17》,然后是首作《Never7》,后面还有《Remeber11》、《12River》等。这一系列的主要特征就是带有轮回概念的科学幻想。这也意味着本作有轮回的要素。

最后,XX;YY形式的标题是KID的精神后继者5PB开发的科学妄想系列每一作的命名方式,比如最著名的《Steins;Gate》,本人最爱的《CHAOS;HEAD》、《Chaos;Child》,以及某RN和社长亲自下笔的《Occultic;Nine》等。这一系列核心是对扭曲真死宅的人文关怀与自我救赎。这一点,是本作最初的核心立意。

本作的核心立意除了源于科学妄想系列外,也源于《四叠半神话大系》这一作品。本质上,我还是想传达一个“走出去尝试看看吧,无论如何,只要去做了,即便是后悔,也比什么都不做强”的观点。无论是轮回前期的尝试还是最后放弃时的反差、以及最后的最后的配对、和另一个玩家的相遇和协作,都是为了这一个主题而服务的。

当然,由于本作特殊的官方活动性质,剧情无法加入偏悲剧和些许恶意的情节,无法参入毒电波,更无法表现阴暗的放弃挣扎和自我的憎恶与反思,只能走无厘头的道路,所以自然就无法有最后反思爆发的高潮。这一点确实有些遗憾,但考虑基本走向和玩法还是保留了,也难为编剧了。

程序架构

技术方面可说的点比较多。此次活动虽然是一个WEB页面,但本质上其实是游戏开发。作为一个游戏,自己从头写一套渲染虽未尝不可,但毕竟是开发周期有限的商业活动,所以我选用了现成H5引擎Egret,这个引擎用下来虽然有一些小问题,但整体还是不错的。不过如果现在让我重选,我应该会选择对工作流侵入性不高的PIXI等框架吧——毕竟对于一个H5应用,Egret做的事情还是太多了,这导致使用第三方库、打包等都难以和现在的标准工作流结合,最后打包合并、版本控制还是我自己写了个小脚本做的,这点确实不怎么合理。

由于本人自身有着比较丰富的GAL开发经验,所以借助于一些优秀的设计(比如Renpy)后,我将自己在此次游戏中使用的核心代码封装成了一个Egret的扩展,其按照规范,被封装在GAL的namespace内,开箱即用,读者可以在此处egret-galgame来获取它。但做一个非常完美的扩展毕竟耗时巨大,本人工作较忙,所以此物有很多地方并不完整,如果要用在自己项目中,请读完下面的经验介绍并按照自己需求对其做出更改即可。

顶层框架

由于游戏的形式是GAL,所以和传统游戏相比,其交互的侧重点和表现形式大为不同。GAL大多都是2D的,其基本表现方式是一个作为场景的背景、一个或多个背景前的人物、一个NVL或是ADV模式的对话框,通过人物的形态、表情、动画和对话框中的文本、还有场景间的过度效果来进行话剧式的演出。所以比起传统的游戏,我更愿意将其视作一个“虚拟的话剧”。针对这样的情境,一个简洁有效的图层分层方案必须被提供,这也就是本扩展中Scene的概念。

这种演出方式也决定了它和一般游戏交互的不同。GAL的主要流程是由一个单一的输入决定的,在本作中,这个输入是“对屏幕的点击”。用户不断触摸屏幕,游戏程序响应这个触摸进行对应的演出。换言之,GAL的操作方式是相对单一的,而建立在这种单一之上的,则是由大量文本堆积出的巨量演出,针对这种简单的输入和复杂的效果,一个大幅简化流程的脚本DSL毫无疑问是必要的。

下面就针对以上两个问题做详细的说明。

图层

图层设计是建立在Egret本身的图层概念上的,其图层排序是按照子级加入到父级容器的顺序来确定的,并且其图层的维护是一个数组,所以并不能简单得设置一个很大的值和很小的值以及中间值来简单排序,只能在得知原图层index的情况下进行交换或是简单的置顶和置底,所以一个最佳的策略其实是在运行过程中尽量不要去更改图层顺序,这也就要求这个扩展的设计要一开始将层次区分明确。我对图层的区分如下图所示:

图层设计

Scene作为整个扩展的核心,负责维护游戏场景中的所有子元素,这其中包括主要元素main、对话框dialog、纯文本text和选择支branchmain下面又有背景bg和人物chars。其中main层级最低,而其中的bg又是最低的,接下来是charstextdialogbranch,这个顺序是按照功能性排列的。这些子元素都以对象的形式存在,而这些对象的类中必有一个addToScene方法,此方法使得这些类可以保存Scene对象的引用并将其自身添加到场景中。

这其中,其他的顺序都比较容易理解,而有一个特殊的点是背景图层bg和人物图层chars并未打平,而是合并放在main下面,这是因为游戏中既有单独移动背景和人物,也有让背景和人物同步移动的需求。

在实际运行中,Scene先初始化,清空当前所有显示的内容,然后通过背景图片以及一个过渡效果创建一个新的场景,之后根据需求对bg进行位置或缩放的动画控制,当需要人物出现时,便将人物对应的对象添加到chars图层中,并在后续控制其位置、缩放、姿势和表情等。如果有对话、分支等需求,则只要再在对应的图层中添加对象即可。

脚本化

场景管理只解决了基本的绘制问题,但并未解决开发效率的问题。在理想的开发策略中,当我想进行演出,比如想切换背景、显示一个人物、或是让某人说一段话的时候,我并不需要去写复杂的类似:

scene.addChild(charABodyA);
scene.addChild(charAFaceA);
charABodyA.x = 0;
charBBodyA.y = 0;
charAFaceA.x = 200;
......
name.text = 'A';
name.x = 300;
name.y = 0;
words.text = '蛤蛤蛤蛤';
words.x = 200;
......

这样的代码。而是想去写出类似这样的简单的脚本:

const charA = new Character('A')
charA.addToScene(scene);
charA.posture('A').face('A').at(0, 0).show();
charA.say('蛤蛤蛤蛤');

这种脚本无疑更加简洁明晰,也更符合人类的思维模式。事实上,很多老牌GAL游戏引擎都是这么做的,比如前面提到的Renpy,其提供的脚本时这样的:

scene bg SE02C at truecenter with move
show Song AB01A at GridXfL(3,0.5)
SongA '偶尔也要释放一下嘛。'

相比我上面的写法,Renpy语法更加简洁,属于标准的DSL(作者自己写了一个解释器来处理这个DSL)。但无论是从开发周期、还是从稳定性、扩展性而言,这样的DSL都显然不适用于我现在的场景,硬上的话也不过是杀鸡焉用牛刀,我需要的实际上是类似于JQuery那样的语言内DSL,即通过链式调用来达到近似DSL的描述能力,但却有可以灵活地插入原生js代码中进行混用,并且开发和维护也较为便捷。

此扩展的DSL核心是一句一句的描述性语句,像是上面第二段代码的第三和第四行那样,我通过面向对象的思想将其设计为了由链式调用构成的语句。比如第三句,就表示人物A将展示A姿势A表情、并显示在(0, 0)的位置,第四局则是这个人物A将会说出蛤蛤蛤蛤这句话,至于具体渲染出的逻辑,则由这个扩展引擎的后端部分来实现。

有了基本的DSL,就需要一个集合来管理它们,完成它们的解析,而这一切则需要一个集合的描述结构和对应的解释器来完成。我选择的结构是最简单的数组,数组中的每一个元素都是一个匿名函数,而解释器的功能就是不断地读取这些匿名函数执行,并通过执行结果来决定下一步的行为。匿名函数集合在实际的编写中如下所示:

const map1 = [
    () => scene.with(600, 'fade').then(() => next()).toMode('NVL').at(370).create('home'),
    () => boy.think('什么...我是主角?'),
    () => boy.think('开什么玩笑,当主人公岂不是会很累...我可没那么多精力。'),
    () => boy.think('蛤?还是让我当,我...'),
    () => branch.open([
      {
        content: '搞什么玩意,我就是不想当怎么着了?',
        callback: () => {
          scene.with().toMode('ADV');
          interpreter.next();
        }
      },
      {
        content: '没办法,看来还是逃不过命运啊~',
        callback: () => {
          scene.with().toMode('ADV');
          interpreter.next();
        }
      }
    ]),
    () => boy.say('...不对,我是和谁在说话?');
];

在以上这段代码中,我将数组map1称为一段剧本,其中的每一元素都是一个匿名函数。解释器读取剧本中的每一句进行执行,而这个执行方法的触发源就是用户的触摸事件,用户触摸一下,解释器便将内部维护的指针加一并读取执行,执行的效用由每个对象的内部实现,比如场景切换、人物显示、说话等。而每一句函数执行的返回值,也决定着用户下一次行为的功能,这也是解释器运行的核心。

解释器

由于是语言内的DSL,所以解释器构造很简单,不同于传统的语法分析需要构造语法树,此扩展的解释器的核心功能只需要维护一个内部列表和一个数组的指针便可实现。为了进行内部剧本的载入,我实现了load: (script: TScript[]) => void方法,其加载一个剧本并准备对齐进行解析;解释器中的指针则由next: () => voidgoto: (line: number) => void方法控制,next方法将指针加一,之后执行指针指向的那句脚本,而goto方法则是next方法的一个扩展,可以跳转到并执行指定行。

除了以上基本功能之外,解释器还必须提供现场保护和恢复的功能。比如在一段剧本中,有一个语句是使用load方法加载别的剧本并执行,而新加载的剧本执行完后我们显然希望回到原来的剧本并继续执行下一句,这个状况在分支存在的情况下非常常见。这就要求解释器内部维护的并非一个单纯的剧本,而是一个剧本构成的,这和传统底层开发的汇编中保护现场的姿势一致。每当加载一个新的剧本,我就会把当前剧本以及对应指针分别入栈,然后再执行新的剧本,当新剧本执行完毕后我便从栈中出栈一个剧本和对应的指针,并继续执行。

至此,一个满足需求的基本的解释器便已完成,但考虑GAL的特殊演出形式,此扩展必须提供一个类似于快进的功能来使得玩家可以加速语句显示或是动画的表现,使其快速到达结束的状态。比如我在角色说话的过程中再触摸一下屏幕,当前的这句话就会被直接跳过逐字动画而直接显示;除此之外,有时候我们还希望一些效果无法被略过,也即能够有一些方法卡住脚本的执行,在不频繁使用事件的绑定与解绑的情况下,一个统一的解决方案需要被提出,而我的方案,就是依赖于剧本中每一句匿名函数的返回值——

export type TSPScriptResult = {check: () => boolean, exec: () => void};
export type TScript = () => any | TSPScriptResult;

此处类型定义并非BUG,这只是表明匿名函数的返回值可以是任意类型,但其中有一个类型是TSPScriptResult。当返回值的类型是其他时,解释器不做操作,正常响应接下来的所有行为,而如果是TSPScriptResult,则会进入特殊的处理流程:

  1. 如果check的执行结果是true,则正常响应接下来的操作。
  2. 如果check的执行结果是false,则执行exec

这个简单的规则可以解决上面提出的几乎所有的问题,当有可以被跳过的动画相关的语句被执行时,可以返回一个TSPScriptResult的结果,其中由check方法告知解释器是否要卡住解释流程,而exec方法则在允许快速跳过的情形下、给出一个快速跳过的方法。比如在一开始的性别选择页面,我就是通过check卡住接下来的操作,直到用户选择完毕才改变其返回结果,重启解释流程。

资源管理

资源管理也是游戏中非常重要的一部分,我在这里基本就用了Egret自身的资源管理模式。核心是用它提供的TextureMeger来进行图像资源的合并,而后利用其RES单例进行资源加载和管理。比如如下这张图和其对应的json文件:

merge后的图

{"file":"dialog.png","frames":{
"altair-s":{"x":542,"y":289,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"altair-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"boy-s":{"x":1084,"y":0,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"boy-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"fff-s":{"x":1084,"y":289,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"fff-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"girl-s":{"x":1084,"y":578,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"girl-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"npc-s":{"x":542,"y":0,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"npc-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"nvl":{"x":0,"y":0,"w":540,"h":960,"offX":0,"offY":0,"sourceW":540,"sourceH":960},
"vega-s":{"x":542,"y":578,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287},
"vega-t":{"x":542,"y":867,"w":540,"h":287,"offX":0,"offY":0,"sourceW":540,"sourceH":287}}}

这样能够大幅减少网络请求,提高响应速度。对于大部分资源这样处理都是没有问题的,但对于人物的立绘资源,由于我的特别设计,需要另一套方式而非工具合并,这一点会在下面说明。

资源管理没问题以后,便会出现资源预加载的问题。对于一个游戏,资源量肯定不会少,然而对于一个WEB应用,响应速度又很重要。为了去一个平衡,本游戏的处理方式是——将资源加载分为两个部分。用户在JS加载完毕后首先只预加载标题和基础UI相关的素材,加载完毕后先展示首屏,用户操作后再加载剩下的资源,这样至少能够保证用户能在短时间内看到东西,不至于快速流失。除此之外,我还是用了optimizilla来压缩图片,普遍能压缩60%+,来达到效果和大小的平衡。

细节实现

顶层的大框架设计说完了,下面就来说一部分细节的实现吧。

场景

场景类是Scene,其负责管理场景图层(已在上文说过)和控制背景以及整体的位置、动画等操作,其最基本的方法就是create方法,配合withatfromshow等方法能达到更丰富的控制。以下是一句基本的脚本:

scene.with(600, 'normal').at(0, 0).from('right').then(() => next()).create('home');

这句话代表我要新建一个场景,这个场景的背景是home,以normal模式、600ms过渡,并且背景的位置是(0, 0),过渡从右侧开始,并且在场景创建完成后执行作为then参数的回调函数。这几个操作基本能实现场景管理的所有功能,下面来一个一个分析:

  1. withfrom方法:with的第一个参数是时间,第二个是模式,决定着下一次创建场景时使用的过渡模式,而from则决定着过渡方向。过渡方法此处暂时有normalfadenone三种,fade顾名思义是先渐隐第一个场景,再渐显第二个场景;none是直接变换场景,没有任何过渡效果;normal则是一种模仿传统GAL的过渡的效果(就是游戏中选择完大场景后的切换效果),在传统引擎中,这个效果是由一张渐变图加上ImageDissolve函数定义,并在过渡过程中实时生成中间图像的,但考虑WEB的性能问题,我此处选择的方式是利用一个具有羽化毛边黑色图作为mask,先将其移入,然后在场景被盖住的时候变换场景,之后再移出,便可模拟那种传统的效果。
  2. at方法用于定位场景,决定下一次将场景移动到某个坐标(x, y)处。
  3. then接受一个回调函数,这个回调函数将在场景变换结束后调用,这是为了提供一个调度的方法。
  4. create方法是一个终结方法,其不是一个链式调用的节点,功能是利用参数中的标示,去寻找资源中的bg_${mark}图像,作为新场景的背景进行变换,在变化之时,对象还会调用一个clear方法,来清空子图层的所有对象。

如上所言,像是at这种方法其实只是决定下一次场景在create或是另一个方法show后的状态,换言之,这些方法是惰性的,它们只会修改Scene对象的成员变量,这些成员变量真正生效要等到下一次场景的实际变换。除了上面的几个方法,还有两个方法对于场景(特别是此游戏)十分重要:

scene.toMode('ADV').at(100, 100).animate(600, t => t).show();
  1. show方法,此方法会在不进行过渡的情况下对场景进行变换,也就是说,不改变背景,但可以修改场景的位置,这在某些场景下非常有用。
  2. animate方法,此方法参数是一个时间函数和一个时间,它可以为下一次变换指定一个动画,如果其被指定,则下一次show的时候,背景的位置在移动两点之间将会有一段平滑的动画。
  3. scale方法,和at类似,但提供的是缩放功能。
  4. toMode方法则是切换对话模式,这一点在下面会详细说明。

除了以上的功能,我在实现背景初始化时,并非直接使用素材和场景左上角对齐的模式,而是令其中心对齐,这对于人类而言比较合理,但代价就是要去实时算anchor和背景的位置。

人物

人物由Character类生成,其比较复杂。一方面,它决定着人物自身的固有属性,比如对话时的字体、对话框背景等,另一方面它又组织着人物的资源,而人物的资源又和别的资源不太一样。这里我就详细说说这个类的具体实现。

说详细实现,不得不先提一下人物的素材,为了使一个人物能有生动的演出,最基本的方法就是对其立绘的姿态和表情进行变更,结合上面提到的惰性方法结合show方法的模式,我显示一个人物立绘的脚本如下:

vega.posture('普通').face('微笑').at(0, 0).from('right', 400).show();

这里的posture方法定义了立绘姿态,face定义表情,这两者将会在实际显示时先和上一次的状态做一次差分,只有真正有更新的时候才会进行实际的图层重绘。在更新时,对象先拼接出两个资源标识,然后通过这个标识去引擎获取对应的素材,素材分为两部分——一部分是躯体,其标识是${resName}-${posture}.body,一部分是表情,标识是${resName}-${posture}.${face}

这里就要说起人物资源的详细定义了,根据Egret的资源规范,我将人物的每个姿势以及其表情集合拼合到了一张图内,然后将其资源命名为${resName}-${posture}的模式,之后在这个资源对应的json内写好身体和各个表情的偏移:

vege人物图

{
    "frames": {
        "body": {
            "offY": 0,
            "sourceW": 724,
            "w": 724,
            "x": 0,
            "y": 0,
            "h": 1280,
            "sourceH": 1280,
            "offX": 0
        },
        "生气": {
            "offY": 144,
            "sourceW": 136,
            "w": 136,
            "x": 764,
            "y": 324,
            "h": 95,
            "sourceH": 95,
            "offX": 289
        },
        ......
    },
    "file": "vega.png"
}}

通过这些定义,我便可以轻易得从资源图中获取到身体和表情的图像,并将它们绘制到对应的bodyface子图层去。

这些操作实现了基本的姿势和表情切换,但如果直接切换又太过生硬,连若干年前GAL的演出标准都无法达到,为了达到GAL的底线,姿势或者表情变化时的fade效果必须被实现。我实现了一个类Transition,专门用来做这个效果,其可以传入之前和之后的两个图像,直接实现二者的交错fade效果。

当然,除了主要角色,我们还可能有一些次要角色,这些角色并不需要姿势和表情的变化,而仅仅需要一张图作为基本立绘即可,对于这种角色,我在构造函数中设定了参数multiPosmultiFace来做细节控制,若设置为false,则不会去进行姿势和表情的区分,而仅仅是绘制resKey对应的基本图像:

export const boy = new GAL.Character('npc.boy', '少年', 'boy', 0x0078a4, 0x0078a4, cps, false, false);

在姿势和表情之后,at方法用于定义下一次显示的位置,不过和Scene不同,它的两个参数是格点而非像素,这是由于立绘定位比较特殊,设计为格点模式比较方便,而格点的约束则写在types.ts中。from方法定义的是立绘从何处出现,这个和Scene也不同,它实际上决定的是当立绘从无到有是、从某一侧的某个偏移边移动边逐渐显示的效果(这一点在游戏中处处都有体现),我认为这也是一个合格的GAL应该拥有的底线演出。

除了以上方法,Character类同样提供了很多其它方法以供显示的细节控制:

vega.size('large').opacity(.5).face('生气').at(3).with(600, t => t).animate().show();
vega.hide();

size方法的参数可以是largemiddlesmall等,它本质上用于控制立绘的远近感,具体的效果也是在types.ts中定义的,opaticy用于修改下一次的透明度,with则决定了姿势和表情变换的时间和时间函数,animate决定在角色sizeposition变换时是否要有动画,hide则是隐藏当前立绘,和show相同,它也会有淡出的效果。

显示问题解决后便是角色的另一个重要行为——对话,角色的对话分为两种,一种是,一种是,并且对话形式也分为ADV模式(对话框置底,一句话占据一个对话框,主要用于普通对话)和NVL模式(对话框居中且大,一屏有多段对话,主要用于独白和描述),其脚本如下:

scene.toMode('NVL');
vega.think('我,我为什么会在这里?');
scene.ADV('NVL');
boy.say('因为你是笨蛋啊。')
vega.say('我才不是!');
vega.think('这家伙是谁啊,这么没礼貌......');

脚本写起来非常简洁优雅,而这简洁的背后是大量的工作,这工作依赖于另两个类——ADVDialogNVLDialog

对话框

对话框分为ADVDialogNVLDialog,这两个都继承自基类Dialog,其核心方法有两个:say: (id: string, words: string) => TSPScriptResultthink: (id: string, words: string) => TSPScriptResult,它们分别对应Character类的saythink方法。

在实际的调用链中,首先执行Character对象的say方法,然后再内部通过Scene对象中的say方法将该对象的id和当前说的话words传递给Dialog类的say方法:

vega.say('蛤蛤');

scene.say('vega', '蛤蛤');

dialog.say('vega', '蛤蛤');

最后对话框的绘制和文字的显示实际上由dialog完成,而对话框的样式与当前文本的样式,一方面由types.ts中定义的静态变量决定,比如文本偏移、字体大小,另一方面则在Character对象被构造时、由其构造函数的参数决定,比如cps(每秒显示多少个字符)、文字颜色等,这些变量被存在一个全局单例中,使用时直接从中拉取。

在绘制时,两种对话框的形式截然不同。对于ADVDialog,实际上是先绘制底层的对话框图片,然后在直接绘制姓名,然后在文本区启动文本绘制动画,动画则是利用requestAnimationFrame加上和绘制开始的时间差来实现的,加上一个快速结束动画的方法,便可以实现功能。

对于NVLDialog,显示情况就复杂得多。由于可以容纳多段对话,需要考虑的问题就有很多:首先是名字和文本的排版、包含多行文本如何定位的问题,涉及到这个问题,就必须引入各种变量来保存位置信息;其次是清屏的问题,也就是判断文本将会超出时清空当前对话框,并在下一个对话框中显示新内容,这本质上是一个预判的问题,就是必须在绘制前先判断是否会超出,然后再按需绘制。这两个问题只能通过无尽的小学数学和文本测量来解决,好在并不是特别复杂,最终都获取了比较完美的解决。

选择支

选择支是GAL最基本也是最核心的操作,本质上就是一个一个的按钮,每个按钮都有自己的背景图片和文本还有回调,而文本和容器有可能有不同的样式,但这些样式在大多数状况下又是中规中矩、基本相同的。所以我提供了两种方式——基本模式和用户自定义模式。

在基本模式中,用户可以只给一个文本或背景图、加上一个回调便可定义一个选项,像是这样:

branch.open([
  {
    content: '成神,我不做人啦!',
    callback: () => loadAndNext(god)
  },
  {
    content: '不,我想回归那平凡的生活',
    callback: () => loadAndNext(human)
  }
]);

在这种状况下,选择支所有的属性都会又types.ts中的一个静态变量控制,不过在此处还可以传入一个autoClose参数,用于确定选择完成后是否应当直接关闭选择支,其默认为true,当有特殊需求时可以将其设为false;另外还有一个参数eachTransform,可以为每一个选项指定同样的属性,这些属性将会覆盖默认属性。

而在自定义模式时,每个选择支中的transform属性便会生效,用户可以通过此精确控制每个分支的一个属性,来达到一些特殊的效果:

branch.custom().open([
  {
    content: '我是好人',
    callback: () => loadAndNext(good);
    bg: '1',
    transform: {width: 200, height: 100, x: 120, y: 200, rotation: 30}
  },
  {
    content: '我是坏人',
    callback: () => interpreter.load(bad);
    bg: '2',
    transform: {width: 200, height: 100, x: 240, y: 400}
  }
]);

再配合类似于上面类的animatefrom方法,便可以实现丰富的出现动画,不过现在每个选择支的单独动画尚未完成,做起来也不难,不过我觉得没有必要——选择支一起出现,黄油都是这么干的。

音乐

由于此次游戏没有用到音乐,所以只是做了一个基本的BGM播放,按理说BGM和效果音应该是分轨的,效果音也可以多音轨,基本使用方法如下,不再过多介绍:

bgm.play('bgm-home');
bgm.pause();
bgm.resume();

其他

其他一些游戏中的演出,比如标题轮回画面银河都是该游戏特例化的对象,业务耦合度过深,基本不可能抽象,对于这些对象,我都是直接在扩展外另写几个类,并按照解释器要求的形式进行方法的编写,使其能够无缝融入剧本,所以这几个类可以看做是这个GAL扩展的扩展。这几个类除了银河之外没什么好说的,难度基本都在玄学调动画而非设计上,银河我会在下面单独说明。

演出

上面的实现细节其实已经说了大部分演出的原理,这里还要单独说的就是这个竖屏的问题。由于游戏是移动端游戏,并且移动端浏览器千奇百怪,顶部的bar很可能无法隐藏,如此情况下纵向可视区域就非常之效了。所以即便是违背传统GAL的模式,我还是选择了竖屏。竖屏的话就有两个问题——一个问题是演出画布过窄,很难做到多人同台;另一个问题则是用户横屏的问题,比如在B站IPAD客户端内就是强制横屏。

适配

对于画布过窄的问题,我是通过演出的一个trick来解决的。读者是否记得,在游戏中经常出现人物和场景同步移动的情景?这就是我的解决方案——既然画布过窄,我就变相扩展画布,将画布扩展到屏幕之外,然后通过移动屏幕中显示的内容来决定现在舞台上的对象。这个操作的核心是在Sceneat方法后加上第三个参数,这个参数是一个bool值,决定下一次移动是否要背景带着人物一起移动,这也就是一开始说的那个main子图层存在的意义,当一起移动时,移动的是main图层,否则移动的是bg图层自身。

而对于强制竖屏的问题,我利用的是Egret自身的方法:缩放模式和旋转模式说明。我将data-orientation设置为portrait来强制竖屏,然后选用fixedWidth这种缩放模式来进行不同分辨率的适配,加之在渲染时我的图层实时计算,基本完美解决了适配问题。

银河

银河也就是最后的CG和一系列操作,前面的CG动画就不说了,调参罢了。值得说明的主要是留言板和仰望星空的实现。

留言板其实就是一条条文字数据,我拿到数据后把这些文字画到画布上而已。但为了在均匀铺满星空的同时又尽量避免那种人工的规则感,我先将星空那张图的画布区域分割成了7x7块,然后再每一块中放置一条留言,每条留言的位置会在这个区块内进行随机,同时随机的还有留言的scale,这样就可以造成一种不规则却又铺满的感觉(虽然还是有点假)。

仰望星空这一个操作显然是用了陀螺仪,在做这一个功能并将其作为查看留言的唯一方式时我就已经预料到了争议,但为了一些情怀的仪式感,我还是这么做了。其基本原理很简单,其实就是在那个仰望星空的按钮出现时,程序就开始不断记录用户当前陀螺仪的三个角度偏移值,当用户按下按钮时,程序判断当前的偏移值是否在合理范围内,如果合理,则以当前位置为起点开始进行观测,横向依赖的是alpha的变换,纵向则依赖于beta。这里有一个坑是不同的系统和浏览器对陀螺仪角度的解释不一样,只能UA判断做适配了= =。

观测时移动的背景实际上是二维图像进行的伪全景模拟,而并非三维的Skybox,这一点我深表遗憾——毕竟为了这样一个功能再将Egret3D引入,还是得不偿失,而且也由于时间原因无暇再改,这一点下一次会有所计划。

另外后端临时和我商量加入了一个留言板实时更新的功能,这是一个新的功能,基于弹幕系统的实时推送,基于WebSocket。其难点在于我每次收到后端通知后,总是要找到用户当前观看区域中、随机的一块,将其中的留言移除再更新,以此保证用户的基本体验。这个功能在此处只是做实验,后续活动会有更大应用。

彩蛋

这次彩蛋比较少,一个是在用户留言不足、或是一定概率下,星空留言的一部分将由我mock的数据填充,这些数据都是一些ACG作品中的人物以及其名言。另一个就是我给我女朋友的专属菜单啦,我们只能互相匹配到对方,并且结尾有一段专门的彩蛋剧情,虐下狗(逃

彩蛋

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。