Bilibili圣诞游戏剖析-用pixi.js实现鬼畜音游

Bilibili圣诞游戏剖析-用pixi.js实现鬼畜音游

H光大小姐H光大小姐

在我的BLOG上观看获得最佳体验。


在正式看这篇文章之前,希望大家先去此处体验一下游戏,这样才能更好地理解我的此次分享:

咳咳,这里本来是这个个性二维码,你乎不让贴就不让帖吧,可以去BLOG这篇扫或者访问:

Jingle Beats


继七夕活动之后,我又负责了圣诞活动Jingel Beats的开发,这次放弃了白鹭引擎,使用了侵入性比较低的pixi.js。下面我就此次活动的一些心得。

不过在了解到有Phaser之后,其实本游戏应该用它来做,比起PIXI这个渲染引擎,它才是一个实际的游戏引擎,自带音频、物理引擎、摄像机、输入归一化、全局画布管理、内置状态管理等等设计也更符合游戏的需求,希望大家可以尝试一下。不过对于一般的轻量级游戏,还是可以直接用PIXI,毕竟Phaser的mini版本也有1.7m了...

前言

在活动开发方面,我一直致力于形式的探索,而由于国内审美环境特别,现在国际主流的设计无法很好使用(这个也和跳过了桌面端而直接到移动端有关),所以需要另辟蹊径。不难想到,交互形式的极致其实就是游戏,而游戏同时也是受众最为广泛的一种形式,加之本人本身就对游戏有着极高的兴趣和爱好,所以这次在这个方向又做了一个尝试。

当然,如果大家谁有有趣的、纯公益或者纯艺术性与新媒体结合的路子,可以找我,视情况我可以提供无偿的技术支持,不仅仅是web前端,硬件我也可以视情况奉陪。

这次的游戏形式是MUG,即音乐节奏类游戏,这类游戏的代表有Project Diva太鼓达人DJMAXOSU等。其形式各有所长,输入方式也千门百类,且每种都有大量fans(当然,真fan无论哪种都很喜欢)。而由于是此游戏主打移动端,并且是运行在浏览器内,所以考虑到输入方式和一些边边角角的问题后,我初步给出了两种形式:

  1. 玩法一:太鼓达人式的游戏模式,其核心在于一个和音频同步、不断向一侧移动的元素序列,由于整体是一个长条,所以绘制较为简单,但玩家控制并非直接作用于元素之上,而是利用两个按钮进行输入,所以逻辑有些复杂。在正常模式之后,还会有一个随意点击屏幕可视区域的奖励模式。这种玩家操作都规约到两个按钮的游戏模式比较亲民,而且操作上也不会有和浏览器以及设备本身有过多的冲突,中规中矩,比较保险。
  2. 玩法二:有些像PSV上的那一作DJMAX,或者是触控模式的OSU。元素不像太鼓达人在一个长条内,而是多出一个空间的属性,铺满了整个屏幕。随后,我将给每个元素赋予不同的类型,每种类型代表一个手势,当元素出现时,玩家需要去定位元素,之后遵守元素自身的手势去拖动它,完成判定,最终也有个奖励模式,就是模仿地雷社的限界凸骑中的撸动屏幕,带来整个游戏的高潮——这个方案充分挖掘了触屏的优势,可玩性也很高,不过也有其自身的问题。

玩法二的问题在于当玩家投入游玩后,可能会手势越界,唤醒浏览器自身的手势行为,亦或是唤醒设备本身的通知栏等。而MUG本身是一种不能被中断的游戏,所以为了保险,最终还是选择了玩法一——这也是在浏览器中做游戏的限制。不过,忙过这阵子我将尝试实现这种模式(作为个人项目)。

架构

一个MUG,或者说几乎任何一个游戏,都可以被拆解为逻辑展示这两个部分,而逻辑部分和试图部分的分离也是复杂度得以控制的根源。拿此游戏为例,运行时的状态和判定等,并非是由元素的位置决定,而是由当前的时间决定的,而这个时间也同时控制着元素位置和状态的绘制——这样一种状态视图解耦、状态驱动视图、单向数据流的模型,和现代前端开发的MVC等视图框架没有太大区别。所以接下来,我将从控制层表示层这两层来描述。

玩法核心

控制层的核心便是围绕玩法的逻辑,本游戏逻辑相对单纯却也不失丰富:

  1. 元素的类型,有左按右按一起按三种。
  2. 在操作维度上,有点击长按两种。
  3. 判定方面,有missgoodperfect三种。
  4. 游戏流程,则有普通模式奖励模式,还有基于能量fever机制失败判定,以及得分Combo判定类型的统计。
  5. 难度方面,我们提供了easynormalhard三种模式。
  6. 而在数值设计上,参照魔界战记大额伤害带来的爽快感,这里一个good积分666、perfect积分1000,fever状态翻倍。

以上要素加起来,其实已然不仅仅是一个HTML5小游戏,而可以说是一个完整版音游了。加之后面会说的可以灵活切换的谱面设计,套个壳再多加几首歌都可以上架了(逃。

图层管理

作为一个MUG,玩家的注意力应当是高度集中在玩法本身上的,所以应当给节奏条和控制器很高的优先度,而其它元素则是以客场的身份——它们只是作为背景,并且不宜过度复杂,否则将会干扰视觉(这一点在歌姬计划的某些曲目有所体现),所以经过讨论,加之B站背景的限制,我们选择了今天妈妈不在家这个鬼畜的梗(bgm本身也是鬼畜版的),让玩家的操作来控制22和33的动作,并利用一些小的元素(比如烤箱喷射的食物、fever时候降下的灯)等来烘托氛围。基于以上,图层实际被分割为:

  1. background: 背景图层,用于放置在最底层元素的基础元素,比如屋子的图片、闪烁的灯等。
  2. chars: 人物图层,放置22和33.
  3. magic: 核心元素图层,用于放置节奏条和控制器。
  4. ui: UI图层,记分板、引导页和结果页放在这里。

这些图层自底而上,一层一层构成了整个游戏的视觉部分。

自适应

本游戏和七夕游戏一样,也遇到了强制横屏的问题,但pixi并没有提供官方的强制竖屏和自适应方案。一开始我按照egret的方案去做,但遇到了一些问题,而解决方案我确实也找到了,如有需要,请参照我写的这篇文章:在pixi.js实现设备自适应和强制竖屏

实现

架构说完了,让我们进入细节来看看具体的实现吧。

谱面设计

将可配置的尽量提出来变成可配置的,是降低工程复杂度的最佳方式之一,无论这个配置看起来和程序主题耦合的多么深——在此游戏中,这个最大的配置就是谱面本身。如果我只是为了一个谱面来单纯写一个游戏,那它只是一段是毫无价值的堆砌式代码,而倘若我将谱面作为一个配置项来服务于程序主体,那我就做了了一个引擎似的东西——它高度复用,以后有新的需求或者需求的修改,只需要修改配置即可。做好架子,将核心逻辑和形式内容分离,当产品或内容想修改的时候将只需要将配置拿来,这是最合理的分工。

好,废话不多说,先来看一看下面的类型定义:

export type TRhythm = {
  level: number,
  bps: number,
  direction: TDirection,
  award: {
    start: number,
    end: number
  },
  // 0 ~ 1
  fever: number,
  duration: number;
  sequence: TRhythmElement[]
};

TRhythm即为谱面的数据结构,它最核心的几个要素是:

  1. bps: 一秒的拍数,用于控制谱面整体的速度。
  2. award: 奖励模式的起始和结束拍数定义。
  3. fever: fever阈值定义,其为一个比例,这个比例代表的是fever所需能量占据游戏可获取的所有能量的比例。
  4. duration: 谱面长度,以拍数记。
  5. sequence:核心,谱面序列,这个定义其实是解析后的元素序列,解析前则是一个字符串,解析过程将会在下一节讲到。

这些属性加起来就定义了一个谱面,若有需求变更,只需要修改这个谱面即可。

谱面解析

有了原始的谱面定义,程序便可以将其解析,转换为运行时实际的谱面。而这个解析的核心,实际上是对sequence这个序列的转换。让我们来看看一段原始的、字符串描述的谱面和转换后的TRhythmElement[]序列是怎样的:

export type TRhythmElement = {
  type: TElement,
  // int, beats from last element
  start: number,
  // 1 is short, others are long-touch element
  beats: TBeats
};

const squence = '010|121|233';
// bps = 1
const convertedSequence = [
  {type: 0, beats: 1, start: 0},
  {type: 1, beats: 2, start: 1},
  {type: 2, beats: 3, start: 3}
];

可以看到,原始的谱面实际上是由一段由|分割的字符串描述的,其定义为${type}${beats}${start}|...,它们正好对应TRhythmElement中的三个属性,分别表示节奏元素的类型拍数距离上一个元素结束时的拍数,在本游戏这种类型的MUG中,这三个属性足以描述一个元素。

从标准化的字符串向TRhythmElement[]的转换只是第一步,在拿到了转换后的TRhythm后,对谱面的解析才真正开始。第二步解析的目的是——将拍数描述的谱面转换为时间描述,并同时生成每个元素对应的视觉容器,之后将它们放入顶层容器中。视觉方面的实现下一节再讲,这里先说一下逻辑上的实现,来看看下面的这个新类型:

export type TRhythmPlayElement = {
  type: TElement,
  start: number,
  end: number,
  beats: TBeats,
  element: PIXI.Container
};

这是经过解析后元素的最终形态,和TRhythmElement不同,这里的start和新多出的end的单位都是ms,并且它们的值都不再是相对于上一个元素的偏移,而是绝对时间,这是游戏运行时进行判定操作所必要的。与此同时,这里还多出了一个element属性,它用于保存元素在视图层容器的引用。

元素序列条

谱面被解析后,生成了被各种属性描述的元素,被保存在一个序列中。这个序列除了逻辑,还保存着其视图容器的引用。而这个视图容器其实就是游戏过程中的那一个个圆形或者长条的元素,这些容器的也是伴随着谱面解析同步生成的。下面我就来说说这些容器生成的细节。

节奏元素以及其外部容器分为四个部分,其一是最底层的那个半透明轨道,其二是圆形的判定框,第三是那一个个元素构成的序列条,第四就是元素被命中时判定框环测的闪光和星星。在游戏过程中,元素在轨道上不断从右向左运动,到了判定框后,随着玩家的操作或者自发发起操作来引起游戏判定,判定后元素消失,如果是长按型的元素(长条状),则要保证该元素不会穿过判定框,即产生一个进洞的效果。这三部分中,半透明轨道直接使用了PIXI.Graphics来绘制:

rail.beginFill(config.railColor, config.railAlpha);
rail.drawRect(0, 0, width, config.rhythmRailHeight);
rail.endFill();

而判定框,则是一个PIXI.Sprite,它的宽高是和轨道相关的,比轨道的高略小。这里要注意的是判定框并非真的主导了“判定”操作,“判定”操作实际上是和核心逻辑、也就是每个元素自身存储的startend决定的,这里的判定框不过是给了玩家一个视觉上的预期,让他们有了操作的依据。

在这四个部分当中,元素序列条最为重要。在游戏过程中,从玩家角度来看,元素似乎是一个一个从屏幕右侧生成的,但实际上,实时生成元素对于性能和复杂度而言都是不太可取的。我这里采用的策略是预生成,也就是说,我先生成了一个名为elements的容器,之后在谱面解析时、对一个一个节奏点的循环中去生成元素element,并按照它们的拍数去计算相对位置,然后将它们放入elements中:

const element = new Element(textures[`elements-${type}`], beats, diam);
const diffBeats = start + preBeats;
center += diffBeats * diam;
element.x = center - radius;
preBeats = beats;

Element是我自己定义的一个类,其通过类型、拍数和直径绘制出一个元素,绘制原理本质上也很简单,如果beats为1,即一拍的元素,则直接当做一个Sprite处理,如果是多拍的元素,则套上一个容器,在内部利用Graphics画好白色的外框背景,然后再把一个Sprite放到背景上即可。绘制好了之后便要调整她的位置,由于只有一个维度的运动,所以只需要调整x的值。我是用了一个center变量来存储每个元素中心的位置,利用中心同步而非边缘有利于降低心智负担,其中radiusdiam是元素的半径,这个是可以调整的,preBeats是上一个元素的拍数,start是此元素距离上一个元素结束时的拍数,通过这样不断的循环计算,我们便可以得到一个element的序列,在实际运行时,运动的实际上是外层的容器,而不是元素自身,这样也减少了计算和更新的逻辑。

至此,元素序列条本身的绘制已然完成,但如果仅仅如此,当这个序列条的容器运动时,元素还是会越过判定框,没有符合进洞这个效果的预期。为了实现这一点,我是用了mask这个属性:

elementsContainer.addChild(elementsMask);
elementsContainer.addChild(elements);
elementsContainer.mask = elementsMask;

创建一个更顶层的容器elementsContainer,将elements作为child加入其中,之后对其设置一个mask。容器的位置固定,和轨道的宽高一致,而mask本身是一个Graphics,其形状为一个半圆和一个长方形的拼接,直接将判定框以及其右侧的轨道纳入其中。如此一来,elements这个序列条超出判定框左侧的内容将不会被展示,这样就达到了一个“进洞”的效果。

以上三部分完成后,便可以考虑第四部分的实现。第四部分本质上是一个光罩和其上的星星,光罩没什么特别的,就是一个Sprite,而星星则是一个序列帧动画,这个可以用PIXI.extras.AnimatedSprite实现。这二者的绘制和定位非常简单,唯一的麻烦在于如何让它们配合每一次的判定结果进行显示——即玩家命中后会有发光和星星散出的效果。这里有两种方案备选:其一,在命中时再去设置光罩和星星的visiable属性,并开始播放星星的动画,这种方式要频繁触发播放动作,需要进行变动的地方也较多;其二,将这两部分放入一个容器中,初始化完毕后不变动它们,而是变动容器的visiable属性,这意味着星星始终在闪,只不过由外层的容器控制它是否可见,这里也有一个潜在的问题——加入在切换容器显示状态时,动画只播了一半怎么办?所以这种控制实际上虽然简单,但不精确。我最终选择了第二种方案,虽然有风险,但考虑星星播放速度较快,并且切换也足够迅速,人眼几乎无法觉察,所以这个障眼法可行。

动起来

准备工作做完,便要考虑如何让序列条动起来了。上面讲到过,序列中元素的运动其实就是其外层容器的运动,所以我只需要在游戏过程中不断根据时间修改容器的x即可,而时间,实际上是由一个计时器ticker驱动的:

this.ticker = new PIXI.ticker.Ticker();
this.ticker.add(this.update);
this.ticker.start();

这段代码中,我创建了一个ticker,而后在其中注册了一个回调update,最后启动它,开始计时。之后在计时器的每一次更新时,它都回去调用update,我便在其中更新容器的x

const preTime = this.currentTime;
const diffTime = this.ticker.elapsedMS;
this.currentTime += diffTime;
const diff = this.pixelsPerMs * diffTime;
elements.x -= diff;

其中this.ticker.elapsedMS为距离上一次回调经过的时间,而this.pixelsPerMs是一个定量,表示容器每毫秒需要移动的像素值,它是在元素序列被初始化完毕时计算的。如此便可以完成容器位置的更新,不过需要注意的是当浏览器被切出时,ticker的计时将会休眠,在这个过程中,虽然其计时没有停止,但将不会触发回调。当再次切回页面时,回调会再次被触发,但此时的this.ticker.elapsedMS将会是一个很大的值,这对于本MUG是无法接受的,所以我这里用了一个相对粗暴的手段——直接弹出提示让玩家重新开始。这也是无奈之举,如果有更好的方案请务必告诉我。

玩家输入

玩家输入指的便是开始说过的那六种操作——左按钮、右按钮、一起按,以及这三个的长按模式。将这六种操作映射到玩家这边的两个按钮、两对事件(分别的touchstarttouchend)上,并不是一件特别简单的事情,会涉及到一个稍显复杂的状态机。

首先我们定义原始输入原始输入指的通过监听玩家对两个按钮的touchstarttouchend事件得到的输入。在这一步,程序将会对玩家的输入进行预处理,将其处理为左按开始左按结束右按开始右按结束同时按开始同时按结束,来为下一步处理做准备。其具体的实现见状态机:


当接收到玩家对某个按钮的输入后,先缓存输入状态,将其保存为一个类的属性中,之后合理利用setTimeout函数,在delta时间(这个时间自行调整,此处为30ms,基本是两个主循环周期)后做一次延迟判断,如果在这段时间内玩家没有另一个按钮的输入、还保持着这个操作的状态,则直接触发其对应按钮的全局事件(这个全局事件是本游戏状态同步机制的基础,下面会细说)。但如果在delta时间内,用户又触发了另一个按钮的操作,则在另一个按钮的事件回调中先获取当前缓存的输入状态,此时可以检测到在短短时间内,玩家前后对两个按钮触发了touchstart或者touchend事件,这在宏观上可以等价为用户同时触发了两个按钮的相同事件,那么此时我就可以将两个事件合并成一个事件,触发全局的同时touchstart或者touchend事件。

这一段可能有点晕,但原理其实很简单。比如,玩家按了左按钮,并且delta内没有其他操作,则触发左按钮的touchstart的全局事件;如果delta内玩家又按了右按钮,则触发一起按的touchstart事件。

合并了玩家的输入后,便可以进入下一步的判定,从而真正实现输入和六种操作的映射,这将会在下一节说到。

背景和前景人物动画

在具体的判定逻辑分析之前,让我们来点轻松的——画面中随着玩家输入而动的22、33是如何实现的呢?其实并不复杂,合理运用序列帧动画AnimatedSprite和补间动画库Tween.js便可。

22和33分别都是一个AnimatedSprite,只需要按照其规范初始化和定位即可,但其中33需要特别注意,细心的玩家应该注意到了她所持烤箱的盖子其实是和她一体化的——这是为了在后续食物喷射出的时候,让食物能处于烤箱和盖子之间。所以33和背景上烤箱的定位其实有一定的耦合关系。

初始化只是第一步,要让22和33能响应玩家的输入做出动作,就要实现一个跟随玩家输入播放/停止动画的逻辑。我实现了act22act33方法来完成这个逻辑,以22的为例:

public act22(mode: 'start' | 'end') {
  if (mode === 'start') {
    if (this.playing.two) {
      return;
    }
    this.loop.two = true;
    this.two.play();
    this.playing.two = true;
  } else {
    this.loop.two = false;
  }
}

这里的mode参数对应前面合成时间中的touchstarttouchend(以及touchendoutside),22对应左按钮,33对应右按钮,一起按的操作则两个都对应。可以看到,这里本质上是利用AnimatedSpriteplay方法来控制播放的,如果输入只有短按一种,那么每次在touchstart的时候直接调用play方法即可,但输入不止短按,还有长按,而长按时我的设计是让动画不断loop,当touchend的时候取消loop即可。一个显而易见的方法是利用AnimatedSprite自身的loop属性和其stop方法、onComplete回调方法进行控制,然而这种方式在当前版本的PIXI下会出现难以理解的行为(我认为这是一个bug),所以必须用替代方案。我这里是利用一个自己保存的this.loop属性结合AnimatedSpriteonLoop回调方法,来实现一个自定义的loop方案,并使用this.playing方法来防治动画播放过程中用户的意外输入:

this.two.loop = true;
this.two.onLoop = () => {
  if (!this.loop.two) {
    this.two.stop();
    this.playing.two = false;
  }
};

如此一来,便实现了22和33响应用户输入的功能。除此之外,还有一个在fever状态下、33拉烤箱门时同时喷射食物的效果,食物总共有五种,需求是随机选取其中的一个喷射而出,喷射位置也最好有所不同。我的做法是建立一个用于容纳食物的容器,当进入fever状态后,在触发33动画的同时调用一个actFood方法,在这个方法中,我先随机取出五种食物中的一种texture,之后生成其对应的Sprite,,为其指定初始位置和大小,将其添加到食物的容器中。之后用Tween.js生成一个Tween对象,为其添加变换位置(xy,从预定义目标位置集合中随机选取)和大小(scale)的动画,然后在onComplete回调中将其从容器中移除即可:

const tw = new TWEEN.Tween({x: food.x, y: food.y, scale: food.scale.x})
  .to({x: end.x, y: end.y, scale: food.scale.x * 2}, 500)
  .onUpdate(({x, y, scale}) => {
    food.x = x;
    food.y = y;
    food.scale.x = scale;
    food.scale.y = scale;
  })
  .onComplete(() => {
    this.foods.removeChild(food);
  })
  .start();

操作判定

好,让我们回到用户输入到操作判定的逻辑。在前面,程序已经完成了对用户输入的合并,那么接下来这一步就是对已经合并的输入进一步处理了。在这一步,用户输入将会被转为missgood或者perfect的判定,而这个判定对于短按和长按元素的逻辑又不相同,我们来一个一个分析:

判定流程

首先理清整个判定的流程,如下图:


元素序列条随着时间的推进不断更新位置,而在更新的同时,玩家或者程序自发(这个后面会说到)触发输入事件,去诱发判定逻辑,在判定结束后视情况更新玩家本次游戏状态,然后将当次判定过的元素从队列中移除......如此如此,进入一次又一次的判定循环。

1. 取出要判定元素

判定的第一步是要得知接下来要判定的是短按还是长按,由于已然判定过的元素会被从队列中移除,所以当下从队列中取出的第一个元素一定是接下来要判定的新元素,而这个新元素中有它自身的所有属性,其中一个就是beats属性——它决定着当前元素的长度,若为1,则按照短按判定,否则按照长按判定。

2. 对操作进行判定

若按照短按判定,那么很简单,首先,程序将比较当前输入事件的操作是否和待判定元素的type一致(比如元素type0对应左按,那它对应的输入类型就是左按钮的touchstart事件,在本游戏中我设定为MAGIC.FIRE_START),之后在将当前游戏时间(即前文所示的this.currentTime)和元素的startend属性进行比对,在距离中心±1/4的时间差内时,判定为perfect,在±1/2的时间差内时,判定为good。如果时间差在范围内但类型不对,则判定为miss

长按判定比起短按要复杂许多。程序维护一个状态actionPre,这个状态用于存储上一个输入,而输入分别为三种操作的startend。当接收到start输入时,先判定其是否和当前元素的type一致,如果一致则直接将状态存储下来,否则忽略。而当接收到end输入时,首先也判定是否和当前元素的type一致,倘若是则进入下一轮判断,否则忽略。下一轮判断对比的是当前输入的typeactionPre中存储的type是否一致,如果是,则表明它们是同一类型操作的开始和结束,这也正好描述了一个“长按”的过程,如果不是,则说明此次长按失败,判定为miss

长按的goodperfectstartend两次输入的结合,在startend输入判定成功时,程序都会按照短按的逻辑对其进行一次状态的归类,并且当start时,会有一个属性statPre来保存其状态,当end判定为成功后,便会结合statPre和当前的状态综合计算出这次的状态,两次都是perfect则最终判定为perfect,否则为good

3. 自发的输入

光依靠用户输入并不能构成一个完备的有限状态机,还需要考虑用户不输入这种异常的状况。为此,我加入了一个保留输入类型None,这种类型的输入事件将在满足一定条件时、在ticker触发的update回调中被触发。而这个条件,就是当当前时间大于当前元素的end。此时可以认为为用户错过了一个节奏元素,那么接下来可以直接跳过所有判定逻辑、直接判定为miss即可。

状态同步

在上面的分析中,不止一次说到了自定义全局事件这个关键词,这其实是本游戏实现状态同步的机制。而状态同步,其实是全局跨组件状态同步,它的目的是解决不同组件间的通信,比如上面说到的“用户控制器中用户的输入事件”和“节奏元素条的判定”问题,在游戏逻辑中实际代码如下:

// main.ts
eventManger.on(EVENTS.HERO_ACTION, ({magic}) => {
  boss.judge(magic);
});

//Hero.ts
......
eventManger.emit(EVENTS.HERO_ACTION, {magic: ......});

对于前端同学,这段代码的含义言简意赅——这其实就是事件机制,在设计模式上被称为发布订阅模型观察者模式,也可以说是RXJS的理念来源。当然,这种模式和redux等的思想也是一样的,只不过它做的事情更多,而这个比较灵活,当然灵活也会带来碎片化的问题,不过这个问题在本游戏这种规模的工程上,尚可接受。

在游戏过程中,我定义了很多个类似的事件,利用事件的监听和触发维护着全局的状态同步。而为了减轻自己的心智负担,我并没有把事件监听的逻辑去中心化地分散到各个组件,而是将其集中在一个文件中管理,像这样:

eventManger.on(EVENTS.HERO_ACTION, ({magic}) => {
  boss.judge(magic);
  chars.act(magic);
});

eventManger.on(EVENTS.BOSS_ACTION, ({stat, beats}) => {
  scoreboard.judge(stat, beats, boss.feverPower);
});

eventManger.on(EVENTS.CALCULATE, (summary: TSummary) => {
  if (summary.currentCombo >= 0 && !chars.withFood) {
    chars.withFood = true;
  }
});

eventManger.on(EVENTS.GAME_END, () => {
  boss.stop();
  result.show(scoreboard.summary);
});

......

如此一来,整体的流程便十分清晰,一目了然。

计分板

在上一节中,可以看到EVENTS.BOSS_ACTION这个事件的订阅中,有一个scoreboard对象触发了自身的judge方法。这个对象就是计分板,而这个方法就是对一次判定进行结算的逻辑,它接受当前判定状态stat(即perfectgoodmiss)和当前判定元素的拍数beats,按照一开始说的规则结算生成summary: TSummaryTSummary的定义如下:

export type TSummary = {
  power: number,
  goals: number,
  maxCombo: number,
  currentCombo: number,
  miss: number,
  good: number,
  perfect: number,
  currentStat: TMagicStat
};

这里面定义了一些结算需要的属性,像是玩家每个命中判定每个状态的统计、最大连击数、总得分等。结算完成后,方法内部将会触发EVENTS.CALCULATE事件,将结果广播到全局。

以上是计分板在逻辑上的工作,而在视图上,它也控制了一些元素的显示,其分为左上角的能量球、右上角的积分和中间的判定状态

1. 能量球:

如果你细心观察,会发现能量球其实是由背景的玻璃球,一层白色的水、一层蓝色的水和夹在其中的小电视构成的,其中水的高度和summary.power绑定。其中背景和小电视都很简单,直接用Sprite元素做好定位即可,水的实现则相对复杂,因为要实现一个波动的效果。一开始,我准备试试用置换映射来做这件事,不过发现得不偿失,所以最后还是用老路子,首先利用两种颜色的、有水面波动效果的长方形texture初始化两层水的Sprite,之后借用Tween分别对两层水实现了无限循环的、方向相反错位的旋转和位移,比如蓝色那一层的实现:

blue1.to({rotation: -wave, x: blue.x - 12}, time)
  .easing(TWEEN.Easing.Sinusoidal.InOut)
  .chain(blue2);
blue2.to({rotation: wave, x: blue.x + 12}, time)
  .easing(TWEEN.Easing.Sinusoidal.InOut)
  .chain(blue1)
  .start();

这样便有了水面波动的效果。而两层水本身超出背景玻璃球的部分,则和节奏条一样,用一个圆形的mask来解决。在power更新时,只需要在加上一个短暂的动画,去修改两层水的y即可。

2. 积分:

积分比较简单,直接用PIXI.Text展示,在更新时去修改其text属性即可,这里需要注意的是左侧0字符的补齐。

3. 判定状态:

判定状态即当判定结束时,在屏幕中间出现的state + combo的组合,这里唯一值得一提的是combo的绘制用到了BitmapText,一个坑是PIXI支持的BitmapText描述文件是一个xml文件二分fnt,这个xml文件可以用ShoesBox生成。

奖励模式

到这里完成的逻辑已然足以支撑一个正常的游戏流程。然而在正常流程外,还有一个奖励模式,这类似于太鼓的连打。在这种模式下,玩家交互方式不变,还是点击触摸,但输入将从按钮变为全屏幕,并且不再有正常的判定,任何一个点击都会被记为good算入积分,但combo并不会更新,简而言之——就是刷分用的。

奖励模式的启动和结束依托于TRhythm中的award.startaward.end,进入奖励模式时,先会有前置动画提示玩家,然后便进入玩家的操作流程。在这种模式下,正常流程的控制器将被隐藏,取而代之的是扭动的小电视、背景的闪光以及一个盖于其上的、透明的全屏遮罩,玩家此时的输入事件实际上由此遮罩监听,监听到输入后,控制器将会绕过正常的判定流程而直接触发EVENTS.BOSS_ACTION事件,直接完成判定。

音频

除了逻辑和画面,音频对于MUG也至关重要,前面所言的音画同步也主要是画面同步音频而非相反。本游戏中对音频的操作很简单——就是加载一个音乐然后播放和停止而已。我在这里使用的是pixi-sound这个插件,它本质上是利用WebAudio API来操作音频。为了使PIXI支持音频,我们需要在资源加载之前导入这个包:

// main.ts
import 'pixi.js';
import 'pixi-sound';

之后便可以像其他资源一样,用loader.add方法将音频加入队列,进行正常的预加载和使用。

注意,ios设备并不原生支持ogg格式的音频编码,为了保证兼容性(又是SB的兼容性),我在这里使用了mp3这种落后的编码模式。

虽然WebAudio API支持已然很广泛,但为了防止一些兼容问题,我们一开始要调用PIXI.sound.supported来检查当前环境是否支持音频播放,不支持的话就...只有无声游戏了,这也是我为什么使用ticker进行音画同步而不是用音频播放时的onUpdate回调的原因之一(另一个原因是这个回调在不同设备下调用周期差距过大,难以使用)。

当然,你可能会疑惑——如果不利用音频的播放时间来同步进度,那么如何保证真正的音画同步呢?这个问题很好,我也有所考虑,但经过测试和思考,我发现这个一个没有必要烦恼的问题——音频已然预加载完成,出现卡顿的概率微乎其微,如果因为这点小担心而去采用风险更大的音频同步手段(风险已在上面声明),非常得不偿失。再者,如果一定要关注音频同步问题,那其实可以在onUpdate回调中对this.currentTime属性进行修正,但这又会带来潜在的竞争问题,加大了系统复杂度,所以我认为,对于这个游戏而言,现在的处理逻辑已然妥当。

经过以上分析,音频在本游戏中做的事情很简单,就是在开始的时候play一下,结束的时候stop一下,结束。

结算

有开始,有经过,有结束,才构成一个完整的游戏闭环。虽然有些高贵的独立游戏去挑战这个定则,但本游戏毕竟还是一个商业作品,自然也免不了俗——所以,在游戏过后,就进入了结算页面:


结算页绘制

最终结算在游戏结束时,由一个事件触发:

eventManger.on(EVENTS.GAME_END, () => {
  boss.stop();
  result.show(scoreboard.summary);
});

这里我创建了一个Result组件来绘制结算页,在组件被实例化并加入ui容器时,结算页的所有元素会被绘制一遍,它们分为解算部分分享部分抽奖部分STAFF部分。而show方法被调用时,实际上会调用一个私有方法update,这个方法将会更新结算部分的一些元素的值,比如最大连击数、分数等,并重新计算定位来使其保持一个相对合理的位置。在修改完毕后,组件内部将会触发一个复杂的动画去将绘制后的元素显示出来。

不难发现,和前面的组件不同,结算组件的长度超过了屏幕长度,所以需要能够拖动,而和DOM页面不同,这里并没有原生的scrollbar,只能自己去模拟,我写了一个ScrollableContainer去捕获touchmove事件来完成scroll操作,也不复杂。

分享图生成

在一次结算完成后,玩家如果点击分享到其他平台,会发现分享图就是自己当前得分对应的结算部分,这个本质上和BML2017那次一样,是先生成base64的图、上传到后端实现的,不过和那次不同,如何将PIXI内的一个DOC转换为base64的图其实是一个问题。在寻觅许久后,我找到了在当前版本V4.6.1完成这个操作的方法:

this.base64 = this.game.renderer.extract.canvas(this.goalsLayers.container).toDataURL('image/png');

如此一来,结算页的逻辑基本就搞定了。

后话

不得不说现在国内直接跳过桌面端直接到移动端,使得很多有趣的设计效果无法实现,移动端由于性能和标准等各种问题,表现力也很有限,而WebAR/VR尚未Ready也进一步加大了这种尴尬。当然,如果大家谁有有趣的、纯公益或者纯艺术性与新媒体结合的路子,可以找我,视情况我可以提供无偿的技术支持,不仅仅是web前端,硬件我也可以视情况奉陪。

13 条评论