奔跑吧!台北:程式幕後分享

Chiunhau You
13 min readDec 28, 2017

--

有幸參與最近推出的遊戲🤘奔跑吧!台北(https://game.glory.taipei/),上線當天即達到 28 萬瀏覽量,也陸續在網路社群掀起一波波討論(謝謝大家花那麼多時間奔跑+等劇情)。相信很多人對於遊戲製作的幕後都蠻好奇的,設計方面的花絮可參考這裡

而我是這次專案的前端主要工程師👨‍💻,在這邊謹代表奔跑吧團隊分享一下,遊戲程式實作過程中的一些心得與淺見。

前情提要

  • 這是我們公司(簡訊設計)做的第二個網頁遊戲,第一個是規模較小的自主遊戲「全能古蹟燒毀王
  • 這是我人生第一次寫網頁遊戲(抖👋
  • 參與實作的主要成員有美術*1、動態*1、前端*1、後端*1、遊戲企劃*1
  • 執行流程大致上是企劃訂出 wireframe 和遊戲機制,接著美術畫出元件,動態把美術元件畫成連續圖,最後由前端實作出來,企劃再進行細部微調
  • 此專案沒有開源,但本篇會選一些值得分享的原始碼做示範

Q1: 遊戲該用什麼寫?

在寫下第一行程式碼之前,要先清楚知道這次的專案目的、機制、元素等等需求是什麼,再來決定要用什麼技術,以及架構的設計方式。

遊戲要素

  1. 2D橫向捲軸式遊戲,視覺上表現強烈的遊戲感與街機懷舊風格
  2. 分為跑步與 Bonus 關,以兩者相互連接的形式,串起政策亮點與台北景觀

遊戲需求

  1. 電腦與行動裝置都要能使用(上線後發現行動裝置玩的比例快到一半)
  2. 會有很多靜態與動態的圖
  3. 每個跑步關的景觀和路上物品都不同,需讓企劃得以方便的設定這些細節
  4. (還有很多…

之前圖文不符推出的全能古蹟燒毀王是用 React 寫的,但以奔跑吧台北的質量來看,場景中相對多很多的圖和動態要呈現, 因此我們跳過 DOM-based 和 Canvas-based 直接往 WebGL 找,最後選中了目前 2D 遊戲貌似最堅若磐石的 library:Pixi.js。

  1. 它看起來是專為 2D 遊戲所設計的
  2. 它看起來只專注在快速圖形渲染,其他的邏輯架構等沒有多加限制
  3. 它看起來很能滿足跨平台跨螢幕的需求
  4. 它整個看起來就堅若磐石,而且論壇都有人回覆

由於是第一次寫 pixi,所以對於這些「看起來」其實也沒多少把握,只能矇著眼睛過河。一步一步走出我們的最佳實踐。

附帶一題,前端架構決定使用 create-react-app 來建立,因為開發體驗好而且熟悉,而後端使用 Laravel。

Q2: 動態圖要怎麼做?

個人覺得這個遊戲最可愛的地方是有很多美美的像素風插畫和動態(拜 zuzu 和 OURO 所賜),人物、交通工具、美食、建築、UI 等等,因此特別介紹這部分的實作方式。

動態圖在前端程式上是使用 spritesheet 來做。它的概念是一張圖包含了該動態所需的分解動作,再用程式選擇要顯示哪一段,串連起來就成為動態了。

這個 spritesheet 是柯 P 奔跑時的動態分解圖
從左到右連續播放就是我們要的角色動態啦

在製作 spritesheet 上我們是使用點陣繪圖工具 Piskel App,它容易上手、免費且非常適合用來輸出動態。他還能直接匯出 pixi 相容的zip,解壓縮後會有 .png 與 .json 兩個檔案。

若要在 pixi 直接使用,可以用紅框理的選項

.png 就是我們要的 spritesheet,而另外的 .json 則是告訴程式「如何使用這個 spritesheet」

用 pixi 播放動態圖像:PIXI.extras.AnimatedSprite

在備妥 xxx.png 和 xxx.json 之後,就可以進 pixi 播放了。PIXI.AnimatedSprite 原理跟 PIXI.Sprite 一樣,但前者能儲存多個 Texture 並帶有播放相關的 API,例如 play()、gotoAndPlay()、onComplete、animationSpeed 等等,相當方便也滿足大多數動畫需求。

這邊以 kp_run 為例:

  1. loader 讀入 kp_run.json。
PIXI.loader.add('kp_run.json')

2. loder 會透過該 json 的描述,將 kp_run.png 分為多個 texture 存在 cache裡,我們再透過 PIXI.Texture.fromeFrame(id) 把他們抓出來預備使用。

let textures = [];//the spritesheet is divided into 6 frames
for(let i = 0; i < 6; i ++) {
textures.push(PIXI.Texture.fromFrame(`kp_run${i}.png`))
}
return textures

3. 建立 AnimatedSprite,設定為循環播放,即大功告成。

const kp = new PIXI.extras.AnimatedSprite(textures);
kp.infinite = true;
kp.play();

Q3: 切換場景的實作小技巧

奔跑吧台北總共有九個關卡,因此需要有個 class 負責管理這些場景間的切換、過場效果,同時也要考慮內部開發人員直接跳關的需求(這點在之後的證明非常重要)。

一開始在設計場景管理器的時候是參考這篇,這個作法的概念是:

  1. 將所有場景在遊戲開始前都先以 `ScenesManager.createScene(…)` 建立,備存在 `ScenesManager.scenes` 裡但不做任何更新或渲染。
  2. 要開始某場景時執行 `ScenesManager.setScene(sceneName)`,會觸發該 Scene 的 `init()` 方法,所以場景開始更新。接著會把場景加進 renderer stage 開始渲染。
  3. 要過場時,會呼叫 `ScenesManager.connect(nextSceneName, connector)`,將下一個場景加到渲染最底層,並將指定的過場畫面 connector 由右往左飛入後飛出,隨即進入下一關。

而在優化開發流程的部分,我們外掛了 dat.gui,他可以在畫面上直接調整程式內部的變數,是互動式網頁開發非常常用的工具。例如我很久以前練習寫的玩具

開發模式中右上角有 dat.gui 可以切換場景

Q4: 有遇到什麼技術上的困難嗎?

  1. 在鍵盤操作上,我們使用傳統的 addListener,雖然直覺但在比較需要複雜鍵盤邏輯的 bonus 關卡時,會遇到一些麻煩。
  2. Pixi.js 比較像是 renderer 而非 game engine,個別操作渲染很方便但沒有強架構或祖傳的 pattern 可以依循(很像在寫 jQuery),當遊戲複雜度變高時,以直覺 OOP 的思路寫到最後會很難維護。
  3. 非同步的事件不容易控制。例如開頭劇情。
  4. 雖然陸續有在優化,但跑步關的效能問題在硬體等級較低的裝置上還是會影響到遊戲體驗。

到了遊戲上線後,我們開始檢討並綜合了開發過程中遇到的問題,整理出個工程面可以改進&研究的方向。

1. 遊戲架構的改進方向

良好的架構設計並不一定要套用什麼框架。框架確實能解決某些問題,但也可能會限制彈性。而在不熟悉新框架的情況下,學習它的時間成本是必須被審慎考慮的,同時也會有被框架本身的 bug 雷的可能。因此在需求未知、經驗不足且時間預算低的情況下,不宜貿然跳進不熟悉的框架。即便他很潮。

(關於這點可以一讀 Redux 發明人要大家不要用 Redux 的勸世文

像我們一開始是有考慮過 Phaser,Phaser 是基於 Pixi.js 而生的熱門遊戲框架,適用於開發傳統遊戲,但我們最後決定先使用更底層的 Pixi ,因為不確定我們的遊戲機制適不適用 Phaser,也希望讓程式盡量透明且保留彈性。

那說好的改善遊戲架構要從哪來呢?與其好高騖遠的談哪個架構好,不如先從小地方的改進說起。

  • Coding style:什麼樣的 coding style 會更適合前端遊戲開發?需要導入 TypeScriptFlow 嗎?個人認為強型別的 js 是值得嘗試看看的,特別是在遊戲這種複雜度較高的前端開發上。其他小細節如變數的命名規則,甚至是美術素材的檔名默契等等,都值得進一步釐清。
  • Pattern:依這次的開發經驗可知,適度的以 functional programming(FP) 或資料驅動(data-driven)思維來設計某些功能是很合適的。但要不要完全走向 FP 是可以再討論的,因為 Pixi 本身是個 OOP 味道很濃的庫。(若想做 FP 的 webgl 渲染可以先看看 regl,號稱是 declarative and stateless webgl,個人覺得很有潛力)
  • Architecture:若將 Pattern 放大來看就是架構問題了。經過這次開發之後,我們改進的方向是偏向 Elm Architecture (model -> update -> view)的:以 plain object 作為 state (即 model),以 Rxjs stream 串連各種非同步事件(包括使用者輸入、動畫計時器等)產生新的 state (即 update),最後將 pixi 抽出成為專注渲染的 pure function (即 view),將新的 state 渲染出來然後進入下一次 loop。

當然以上都沒有標準答案,不然當今前端界就不會這麼欣欣向榮了(望

技術的使用沒有最佳解,只有最適解。而且最適解會隨時間、空間、人員、預算和專案需求而變。

2. 時間軸度的事件處理

一般在做思考網頁設計的時候只會考慮到 2D 的視覺呈現,然而遊戲卻多了時間這個軸度,而且是不間斷地一直在此軸度上做狀態的變化。我們一開始在遊戲規劃時忽略了它,直到實際開發時才理解時間軸所衍生的複雜性。

姑且稱之為「動態管理」吧。

以開頭劇情為例,柯 P 會先跑到辦公室門口(花了幾格?),喘個氣(喘幾格?),停頓一下(停幾格?)然後打開門,看到不知名人士後要打雷(閃多快?),打雷後進入對話(間隔幾格?)…等等有很多時序性的非同步需求,以一般作法可能會用很多 setTimeout 再用有 setTimeout 的 callback 再呼叫另一個 setTimeout……很難想像一個開頭劇情下來所累積的 callback hell 會是長什麼樣子。

為了避免這種憾事發生,我寫了一個 class 嘗試簡化這個流程,叫做 SimpleTicker。

const ticker = new SimpleTicker()
.wait(200) // 先等待 200 幅的時間
.loop(10, counts => {
// 在接下來的 10 幅做這件事
console.log(counts); // 10, 9, 8, ......, 1
})
.end(() => {
// 全部結束後做下一件事
});
ticker.go(); //觸發此計時器

當然也可以純粹延遲某個動作的觸發:

const ticker = new SimpleTicker()
.wait(200) // 先等待 200 幅的時間
.doOnce(() => {
// 等待完後做下一件事
});
ticker.go(); //觸發此計時器

雖然看起來蠻漂亮的,但其實 SimpleTicker 並沒有完全解決 callback hell 的問題呦,只是讓一切寫起來比較清晰一點。真正有解決到的是後來要做對話框時才想到的作法(還差點用 SimpleTicker 硬寫下去),用了 queue 的概念設計一個彈性較高且更單純的容器來串起所有對話。極簡的 API 在開發效率上幫助很大(例如:臨時要更動對話順序或新增刪除對話),也能直接實作跳對話的功能。使用方式長這樣:

this.dialogue.chatQueue
.addChat({person: 'KP', imagePath: '...', first: true})
.addChat({person: 'NONAME', imagePath: '...'})
.addChat({person: 'KP', imagePath: '...'})
.addChat({person: 'NONAME', imagePath: '...'})
.addChat({person: 'NONAME', imagePath: '...'})
.play(someCB);
// 註:this 是對話場景的 instance,imagePath 就是該對話要顯示的文字圖檔
對話框

思考重點在於把預期的功能抽象化為一層一層相同的 function 串起來,讓彼此的差異只在於傳入的 value。類似尋找最大公因數的感覺。這在對話這種同質性高的功能特別適用,我想也是想朝 FP 優化架構時該有的思路。

話題回到時間軸,其實這些林林總總的問題就是傳說中的「非同步」事件,有沒有更合適的 pattern 能適用上述的問題呢?Rx.js 看起來會是非同步的救星,但要如何滿足遊戲中的各類非同步需求還有待我們努力。

(這部分可以參考個人正在實驗的新專案,試圖結合 Rx 與 Pixi 做出更容易維護的遊戲程式)

3. 渲染效能的提升

這塊其實是到後來才冒出來的問題,當遊戲場景中的物件變多時,伯伯每撞到障礙物都會頓一下,用手機玩的時候更明顯。效能問題一般可以用 Chrome DevTool 的 Performance 來檢測是 scripting 還是 render 拖垮效能。這邊隨意列出幾個我們發現值得注意的小地方:

  • 是不是該犧牲某些動畫效果來換回遊戲順暢度?
  • 避免對不在場景內的東西做不必要的運算(例如:還沒出現的補給品就不要做碰撞運算也不要渲染)
  • 是否有看不到的圖層正在默默渲染?
  • 演算法能否更有效率?
  • (…還有很多呢)

效能的提升可以是沒有範圍也沒有終點的,取決於要支出(犧牲)多少以換取多少,建議可以翻翻 Google 提供的效能建議,或是好好與你選擇的渲染引擎或更底層的API 培養感情。

(若想研究 requestAnimationFrame 的話可以看這篇以及這篇

小結

從前面的分享可以知道,我們在前端遊戲開發的領域還是很年輕的。在人員、資源、經驗有限的情況下,一切的成果都是靠自己摸索而來。也許白做工、也許多繞路、甚至走錯路,但相信這種經驗也是許多開發者正在經歷的。因此這篇文章並不是發表什麼前端界的新遠見或 best practice,而是我們 trial and error 的過程寫照,並試著藉由檢討與實驗,提出一些想法和可能,並撰寫成短文與設計界、資訊界的各位一起砥礪一起進步。

謝謝你的閱讀🤘如果你喜歡我們的遊戲、喜歡我們的理念,也歡迎加入簡訊設計 x 圖文不符,年後可能會有前後端的職缺,有興趣的朋友可以關注簡訊設計的粉專。一起透過資訊設計讓世界變的更美好😉

如果對於網頁開發或美術、動態設計有任何想法,也很歡迎留言亂聊或至我的 GithubBehance 逛逛或寫信 💌chiunhau@simpleinfo.cc 💌

另外,我應該會把這次的遊戲開發經驗整裡成完整的簡報,並投稿至 SITCON 2018,幸運的話 3/10 中研院見囉 😍

--

--