Bilibili聖誕遊戲剖析-用pixi.js實現鬼畜音游

在我的BLOG上觀看獲得最佳體驗。

在正式看這篇文章之前,希望大家先去此處體驗一下遊戲,這樣才能更好地理解我的此次分享:

咳咳,這裡本來是這個個性二維碼,你乎不讓貼就不讓帖吧,可以去BLOG這篇掃或者訪問:

Jingle Beats

繼七夕活動之後,我又負責了聖誕活動Jingel Beats的開發,這次放棄了白鷺引擎,使用了侵入性比較低的pixi.js。下面我就此次活動的一些心得。

不過在了解到有Phaser之後,其實本遊戲應該用它來做,比起PIXI這個渲染引擎,它才是一個實際的遊戲引擎,自帶音頻、物理引擎、攝像機、輸入歸一化、全局畫布管理、內置狀態管理等等設計也更符合遊戲的需求,希望大家可以嘗試一下。不過對於一般的輕量級遊戲,還是可以直接用PIXI,畢竟Phaser的mini版本也有1.7m了...

前言

在活動開發方面,我一直致力於形式的探索,而由於國內審美環境特別,現在國際主流的設計無法很好使用(這個也和跳過了桌面端而直接到移動端有關),所以需要另闢蹊徑。不難想到,交互形式的極致其實就是遊戲,而遊戲同時也是受眾最為廣泛的一種形式,加之本人本身就對遊戲有著極高的興趣和愛好,所以這次在這個方向又做了一個嘗試。

當然,如果大家誰有有趣的、純公益或者純藝術性與新媒體結合的路子,可以找我,視情況我可以提供無償的技術支持,不僅僅是web前端,硬體我也可以視情況奉陪。

這次的遊戲形式是MUG,即音樂節奏類遊戲,這類遊戲的代表有Project Diva、太鼓達人、DJMAX、OSU等。其形式各有所長,輸入方式也千門百類,且每種都有大量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 = 1const 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.tseventManger.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.tsimport 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前端,硬體我也可以視情況奉陪。


推薦閱讀:

如何評價音樂遊戲作曲家kors k(
斎藤広佑)?

舞萌maimai是一款怎樣的遊戲?
音樂遊戲如何保證歌曲難度評級的合理性?
如何評價同步音律喵賽克?
音樂遊戲 beatmaniaIIDX 中,開啟隨機後取得的成績為何是被承認的?

TAG:哔哩哔哩 | HTML5 | 音乐游戏MusicGame |