近期參與開發的一款「京東11.11推金幣贏現金」(已下線)小遊戲一經發布上線就在朋友圈引起大量傳播。看到大家玩得不亦樂乎,同時也引發不少網友激烈討論,有的說很帶勁,有的大呼被套路被耍猴(無奈臉),這都與我的預期相去甚遠。在相關業務資料呈呈上漲過程中,曾一度被微信「有關部門」盯上並要求做出調整,真是受寵若驚。接下來就跟大家分享下開發這款遊戲的心路歷程。
背景介紹
一年一度的雙十一狂歡購物節即將拉開序幕,H5 互動類小遊戲作為京東微信手Q營銷特色玩法,在今年預熱期的第一波造勢中,勢必要玩點新花樣,主要肩負著社交傳播和發券的目的。推金幣以傳統街機推幣機為原型,結合手機強大的能力和生態衍生出可玩性很高的玩法。
前期預研
在體驗過 AppStore 上好幾款推金幣遊戲 App 後,發現遊戲核心模型還是挺簡單的,不過 H5 版本的實現在網上很少見。由於團隊一直在做 2D 類互動小遊戲,在 3D 方向暫時沒有實際的專案輸出,然後結合此次遊戲的特點,一開始想挑戰用 3D 來實現,並以此專案為突破口,跟設計師進行深度合作,抹平開發過程的各種障礙。
由於時間緊迫,需要在短時間內敲定方案可行性,否則專案延期人頭不保。在快速嘗試了 Three.js + Ammo.js
方案後,發現不盡人意,最終因為各方面原因放棄了 3D 方案,主要是不可控因素太多:時間上、設計及技術經驗上、移動端 WebGL 效能表現上,主要還是業務上需要對遊戲有絕對的控制,加上是第一次接手複雜的小遊戲,擔心專案無法正常上線,有點保守,此方案遂卒。
如果讀者有興趣的話可以嘗試下 3D 實現,在建模方面,首推 Three.js ,入手非常簡單,文件和案例也非常詳實。當然入門的話必推這篇 Three.js入門指南,另外同事分享的這篇 Three.js 現學現賣 也可以看看,這裡奉上粗糙的 推金幣 3D 版 Demo
技術選型
放棄了 3D 方案,在 2D 技術選型上就很從容了,最終確定用 CreateJS + Matter.js
組合作為渲染引擎和物理引擎,理由如下:
- CreateJS 在團隊內用得比較多,有一定的沉澱,加上有老司機帶路,一個字「穩」;
- Matter.js 身材纖細、文件友好,也有同事試玩過,完成需求綽綽有餘。
技術實現
因為是 2D 版本,所以不需要建各種模型和貼圖,整個遊戲場景通過 canvas 繪製,覆蓋在背景圖上,然後再做下機型適配問題,遊戲主場景就處理得差不多了,其他跟 3D 思路差不多,核心元素包含障礙物、推板、金幣、獎品和技能,接下來就分別介紹它們的實現思路。
障礙物
通過審稿確定金幣以及獎品的活動區域,然後把活動區域之外的區域都作為障礙物,用來限制金幣的移動範圍,防止金幣碰撞時超出邊界。這裡可以用 Matter.js 的 Bodies.fromVertices
方法,通過傳入邊界各轉角的頂點座標一次性繪製出形狀不規則的障礙物。 不過 Matter.js 在渲染不規則形狀時存在問題,需要引入 poly-decomp 做相容處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
World.add(this.world, [ Bodies.fromVertices(282, 332,[ // 頂點座標 { x: 0, y: 0 }, { x: 0, y: 890 }, { x: 140, y: 815 }, { x: 208, y: 614 }, { x: 548, y: 614 }, { x: 612, y: 815 }, { x: 750, y: 890 }, { x: 750, y: 0 } ]) ]); |
推板
- 建立:CreateJS 根據推板圖片建立 Bitmap 物件比較簡單,就不詳細講解了。這裡著重講下推板剛體的建立,主要是跟推板 Bitmap 資訊進行同步。因為推板視覺上表現為梯形,所以這裡用的梯形剛體,實際上方形也可以,只要能跟周圍障礙物形成封閉區域,防止出現縫隙卡住金幣即可,建立的剛體直接掛載到推板物件上,方便後續隨時提取(金幣的處理也是一樣),程式碼大致如下:
12345678var bounds = this.pusher.getBounds();this.pusher.body = Matter.Bodies.trapezoid(this.pusher.x,this.pusher.y,bounds.width,bounds.height});Matter.World.add(this.world, [this.pusher.body]);
- 伸縮:由於推板會沿著視線方向前後移動,為了達到近大遠小效果,所以需要在推板伸長和收縮過程中進行縮放處理,這樣也可以跟兩側的障礙物邊沿進行貼合,讓場景看起來更具真實感(偽 3D),當然金幣和獎品也需要進行同樣的處理。由於推板是自驅動做前後伸縮移動,所以需要對推板及其對應的剛體進行位置同步,這樣才會與金幣剛體產生碰撞達到推動金幣的效果。同時在外部改變(伸長技能)推板最大長度時,也需要讓推板保持均勻的縮放比而不至於突然放大/縮小,所以整個推板程式碼邏輯包含方向控制、長度控制、速度控制、縮放控制和同步控制,程式碼大致如下:
1234567891011121314151617181920212223242526var direction, velocity, ratio, deltaY, minY = 550, maxY = 720, minScale = .74;Matter.Events.on(this.engine, 'beforeUpdate', function (event) {// 長度控制(點選伸長技能時)if (this.isPusherLengthen) {velocity = 90;this.pusherMaxY = maxY;} else {velocity = 85;this.pusherMaxY = 620;}// 方向控制if (this.pusher.y >= this.pusherMaxY) {direction = -1;// 移動到最大長度時結束伸長技能this.isPusherLengthen = false;} else if (this.pusher.y <= this.pusherMinY) {direction = 1;}// 速度控制this.pusher.y += direction * velocity;// 縮放控制,在最大長度變化時保持同樣的縮放量,防止突然放大/縮小ratio = (1 - minScale) * ((this.pusher.y - minY) / (maxY - minY))this.pusher.scaleX = this.pusher.scaleY = minScale + ratio;// 同步控制,剛體跟推板位置同步Body.setPosition(this.pusher.body, { x: this.pusher.x, y: this.pusher.y });})
- 遮罩:推板伸縮實際上是通過改變座標來達到位置上的變化,這樣存在一個問題,就是在其伸縮時必然會導致縮排的部分「溢位」邊界而不是被遮擋。
所以需要做遮擋處理,這裡用 CreateJS 的 mask 遮罩屬性可以很好的做「溢位」裁剪:
1 2 3 |
var shape = new createjs.Shape(); shape.graphics.beginFill('#ffffff').drawRect(0, 612, 750, 220); this.pusher.mask = shape |
最終效果如下:
金幣
按正常思路,應該在點選螢幕時就在出幣口建立金幣剛體,讓其在重力作用下自然掉落和回彈。但是在除錯過程中發現,金幣掉落後跟檯面上其他金幣產生碰撞會導致亂飛現象,甚至會卡到障礙物裡面去(原因暫未知),後面改成用 TweenJS 的 Ease.bounceOut
來實現金幣掉落動畫,讓金幣掉落變得更可控,同時儘量接近自然掉落效果。這樣金幣從建立到消失過程就被拆分成了三個階段:
- 第一階段
點選螢幕從左右移動的出幣口建立金幣,然後掉落到檯面。需要注意的是,由於建立金幣時是通過 appendChild
方式加入到舞臺的,這樣金幣會非常有規律的在 z 軸方向上疊加,看起來非常怪異,所以需要隨機設定金幣的 z-index,讓金幣疊加更自然,虛擬碼如下:
1 2 |
var index = Utils.getRandomInt(1, Game.coinContainer.getNumChildren()); Game.coinContainer.setChildIndex(this.coin, index); |
- 第二階段
由於金幣已經不需要重力場,所以需要設定物理世界的重力為 0,這樣金幣不會因為自身重量(需要設定重量來控制碰撞時移動的速度)做自由落體運動,安安靜靜的平躺在臺面上,等待跟推板、其他金幣和障礙物之間產生碰撞:
1 2 |
this.engine = Matter.Engine.create(); this.engine.world.gravity.y = 0; |
由於遊戲主要邏輯都集中這個階段,所以處理起來會稍微複雜些。真實情況下如果金幣掉落並附著在推板上後,會跟隨推板的伸縮而被帶動,最終在推板縮排到最短時被背後的牆壁阻擋而擠下推板,此過程看起來簡單但實現起來會非常耗時,最後因為時間上緊迫的這裡也做了簡化處理,就是不管推板是伸長還是縮排,都讓推板上的金幣向前「滑行」儘快脫離推板。一旦金幣離開推板則立即為其建立同步的剛體,為後續的碰撞做準備,這樣就完成了金幣的碰撞處理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
Matter.Events.on(this.engine, 'beforeUpdate', function (event) { // 處理金幣與推板碰撞 for (var i = 0; i < this.coins.length; i++) { var coin = this.coins[i]; // 金幣在推板上 if (coin.sprite.y < this.pusher.y) { // 無論推板伸長/縮排金幣都往前移動 if (deltaY > 0) { coin.sprite.y += deltaY; } else { coin.sprite.y -= deltaY; } // 金幣縮放 if (coin.sprite.scaleX < 1) { coin.sprite.scaleX += 0.001; coin.sprite.scaleY += 0.001; } } else { // 更新剛體座標 if (coin.body) { Matter.Body.set(coin.body, { position: { x: coin.sprite.x, y: coin.sprite.y } }) } else { // 金幣離開推板則建立對應剛體 coin.body = Matter.Bodies.circle(coin.sprite.x, coin.sprite.y); Matter.World.add(this.world, [coin.body]); } } } }) |
- 第三階段
隨著金幣不斷的投放、碰撞和移動,最終金幣會從檯面的下邊沿掉落並消失,此階段的處理同第一階段,這裡就不重複了。
獎品
由於獎品需要根據業務情況進行控制,所以把它跟金幣進行了分離不做碰撞處理(內心是拒絕的),所以產生了「螃蟹步」現象,這裡就不做過多介紹了。
技能設計
寫好遊戲主邏輯之後,技能就屬於錦上添花的事情了,不過讓遊戲更具可玩性,想想金幣嘩啦啦往下掉的感覺還是很棒的。
抖動:這裡取了個巧,是給舞臺容器新增了 CSS3 實現的抖動效果,然後在抖動時間內讓所有的金幣的 y 座標累加固定值產生整體慢慢前移效果,由於安卓下支援系統震動 API,所以加了個彩蛋讓遊戲體驗更真實。
CSS3 抖動實現主要是參考了 csshake 這個樣式,非常有意思的一組抖動動畫集合。
JS 抖動 API
1 2 3 4 5 6 |
// 安卓震動 if (isAndroid) { window.navigator.vibrate = navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate; window.navigator.vibrate([100, 30, 100, 30, 100, 200, 200, 30, 200, 30, 200, 200, 100, 30, 100, 30, 100]); window.navigator.vibrate(0); // 停止抖動 } |
伸長:伸長處理也很簡單,通過改變推板移動的最大 y 座標值讓金幣產生更大的移動距離,不過細節上有幾點需要注意的地方,在推板最大 y 座標值改變之後需要保持移動速度不變,不然就會產生「瞬移」(不平滑)問題。
除錯方法
由於用了物理引擎,當在建立剛體時需要跟 CreateJS 圖形保持一致,這裡可以利用 Matter.js 自帶的 Render 為物理場景獨立建立一個透明的渲染層,然後覆蓋在 CreateJS 場景之上,這裡貼出大致程式碼:
1 2 3 4 5 6 7 8 9 10 |
Matter.Render.create({ element: document.getElementById('debugger-canvas'), engine: this.engine, options: { width: 750, height: 1206, showVelocity: true, wireframes: false // 設定為非線框,剛體才可以渲染出顏色 } }); |
設定剛體的 render 屬性為半透明色塊,方便觀察和除錯,這裡以推板為例:
1 2 3 4 5 6 7 8 9 |
this.pusher.body = Matter.Bodies.trapezoid( ... // 略 { isStatic: true, render: { opacity: .5, fillStyle: 'red' } }); |
效果如下,除錯起來還是很方便的:
效能/體驗優化
控制物件數量
隨著遊戲的持續檯面上累積的金幣數量會不斷增加,金幣之間的碰撞計算量也會陡增,必然會導致手機卡頓和發熱。這時就需要控制金幣的重疊度,而金幣之間重疊的區域大小是由金幣剛體的尺寸大小決定的,通過適當的調整剛體半徑讓金幣分佈得比較均勻,這樣可以有效控制金幣數量,提升遊戲效能。
安卓卡頓
一開始是給推板一個固定的速度進行伸縮處理,發現在 iOS 上表現流暢,但是在部分安卓機上卻顯得差強人意。由於部分安卓機型 FPS 比較低,導致推板在單位時間內位移比較小,表現出來就顯得卡頓不流暢。後面讓推板位移根據重新整理時間差進行遞增/減,保證不同幀頻機型下都能保持一致的位移,程式碼大致如下:
1 2 3 4 5 6 7 |
var delta = 0, prevTime = 0; Matter.Events.on(this.engine, 'beforeUpdate', function (event) { delta = event.timestamp - prevTime; prevTime = event.timestamp; // ... 略 this.pusher.y += direction * velocity * (delta / 1000) }) |
物件回收
這也是遊戲開發中常用的優化手段,通過回收從邊界消失的物件,讓物件得以複用,防止因頻繁建立物件而產生大量的記憶體消耗。
事件銷燬
由於金幣和獎品生命週期內使用了 Tween,當他們從螢幕上消失後記得移除掉:
1 |
createjs.Tween.removeTweens(this.coin); |
至此,推金幣各個關鍵環節都有講到了,最後附上一張實際遊戲效果:
結語
感謝各位耐心讀完,希望能有所收穫,有考慮不足的地方歡迎留言指出。