H5遊戲開發:套圈圈
前言
雖然本文標題為介紹一個水壓套圈h5遊戲,但是竊以為僅僅如此對讀者是沒什麼幫助的,畢竟讀者們的工作生活很少會再寫一個類似的遊戲,更多的是面對需求的挑戰。我更希望能舉一反三,給大家在編寫h5遊戲上帶來一些啟發,無論是從整體流程的把控,對遊戲框架、物理引擎的熟悉程度還是在某一個小難點上的思路突破等。因此本文將很少詳細列舉實現代碼,取而代之的是以偽代碼展現思路為主。
遊戲 demo 地址:遊戲頁面
希望能給諸位讀者帶來的啟發
- 技術選型
- 整體代碼布局
- 難點及解決思路
- 優化點
技術選型
一個項目用什麼技術來實現,權衡的因素有許多。其中時間是必須優先考慮的,畢竟效果可以減,但上線時間是死的。
本項目預研時間一周,真正排期時間只有兩周。雖然由項目特點來看比較適合走 3D 方案,但時間明顯是不夠的。最後保守起見,決定採用 2D 方案盡量逼近真實立體的遊戲效果。
從遊戲複雜度來考慮,無須用到 Egret 或 Cocos 這些「牛刀」,而輕量、易上手、團隊內部也有深厚沉澱的 CreateJS 則成為了渲染框架的首選。
另外需要考慮的是是否需要引入物理引擎,這點需要從遊戲的特點去考慮。本遊戲涉及重力、碰撞、施力等因素,引入物理引擎對開發效率的提高要大於學習使用物理引擎的成本。因此權衡再三,我引入了同事們已經玩得挺溜的 Matter.js。( Matter.js 文檔清晰、案例豐富,是切入學習 web 遊戲引擎的一個不錯的框架)
整體代碼布局
在代碼組織上,我選擇了面向對象的手法,對整個遊戲做一個封裝,拋出一些控制介面給其他邏輯層調用。
偽代碼:
<!-- index.html --><!-- 遊戲入口 canvas --><canvas id="waterfulGameCanvas" width="660" height="570"></canvas>
// game.js/*** 遊戲對象*/class Waterful { // 初始化函數 init () {} // CreateJS Tick,遊戲操作等事件的綁定放到遊戲對象內 eventBinding () {} // 暴露的一些方法 score () {} restart () {} pause () {} resume () {} // 技能 skillX () {}}/*** 環對象*/class Ring { // 於每一個 CreateJS Tick 都調用環自身的 update 函數 update () {} // 進針後的邏輯 afterCollision () {}}
// main.js// 根據業務邏輯初始化遊戲,調用遊戲的各種介面const waterful = new Waterful()waterful.init({...})
初始化
遊戲的初始化介面主要做了4件事情:
- 參數初始化
- CreateJS 顯示元素(display object)的布局
- Matter.js 剛體(rigid body)的布局
- 事件的綁定
下面主要聊聊遊戲場景里各種元素的創建與布局,即第二、第三點。
一、CreateJS 結合 Matter.js
閱讀 Matter.js 的 demo 案例,都是用其自帶的渲染引擎 Matter.Render。但是由於某些原因(後面會說到),我們需要使用 CreateJS 去渲染每個環的貼圖。
不像 Laya 配有和 Matter.js 自身用法一致的 Render,CreateJS 需要單獨創建一個貼圖層,然後在每個 Tick 里把貼圖層的坐標同步為 Matter.js 剛體的當前坐標。
偽代碼:
createjs.Ticker.addEventListener("tick", e => { 環貼圖的坐標 = 環剛體的坐標})
使用 CreateJS 去渲染後,要單獨調試 Matter.js 的剛體是非常不便的。建議寫一個調試模式專門使用 Matter.js 的 Render 去渲染,以便跟蹤剛體的運動軌跡。
二、環
本遊戲的難點是要以 2D 去模擬 3D,環是一點,進針的效果是一點,先說環。
環由一個圓形的剛體,和半徑稍大一些的貼圖層所組成。如下圖,藍色部分為剛體:
偽代碼:
class Ring { constructor () { // 貼圖 this.texture = new createjs.Sprite(...) // 剛體 this.body = Matter.Bodies.circle(...) }}
三、剛體
為什麼把剛體半徑做得稍小呢,這也是受這篇文章 推金幣 里金幣的做法所啟發。推金幣遊戲中,為了達到金幣間的堆疊效果,作者很聰明地把剛體做得比貼圖小,這樣當剛體擠在一起時,貼圖間就會層疊起來。所以這樣做是為了使環之間稍微有點重疊效果,更重要的也是當兩個緊貼的環不會因翻轉角度太接近而顯得留白太多。如圖:
為了模擬環在水中運動的效果,可以選擇給環加一些空氣摩擦力。另外在實物遊戲里,環是塑料做成的,碰撞後動能消耗較大,因此可以把環的 restitution 值調得稍微小一些。
需要注意 Matter.js 中因為各種物理參數都是沒有單位的,一些物理公式很可能用不上,只能基於其默認值慢慢進行微調。下面的 frictionAir 和 restitution 值就是我慢慢憑感覺調整出來的:
this.body = Matter.Bodies.circle(x, y, r, { frictionAir: 0.02, restitution: 0.15})
四、貼圖
環在現實世界中的旋轉是三維的,而 CreateJS 只能控制元素在二維平面上的旋轉。對於一個環來說,二維平面的旋轉是沒有任何意義的,無論如何旋轉,都只會是同一個樣子。
想要達到環繞 x 軸旋轉的效果,一開始想到的是使用 rotation + scaleY。雖然這樣能在視覺上達到目的,但是 scaleY 會導致環有被壓扁的感覺,圖片會失真:
顯然這樣的效果是不能接受的,最後我採取了逐幀圖的方式,最接近地還原了環的旋轉姿態:
注意在每個 Tick 里需要去判斷環是否靜止,若非靜止則繼續播放,並將貼圖的 rotation 值賦值為剛體的旋轉角度。如果是停止狀態,則暫停逐幀圖的播放:
// 貼圖與剛體位置的小數點後幾位有點不一樣,需要降低精度const x1 = Math.round(texture.x)const x2 = Math.round(body.position.x)const y1 = Math.round(texture.y)const y2 = Math.round(body.position.y)if (x1 !== x2 || y1 !== y2) { texture.paused && texture.play() texture.rotation = body.angle * 180 / Math.PI} else { !texture.paused && texture.stop()}texture.x = body.position.xtexture.y = body.position.y
五、舞台
舞台需要主要由物理世界、背景圖,牆壁,針所組成。
1. 物理世界
為了模擬真實世界環在水中的向下加速度,可以把 y 方向的 g 值調小:
engine.world.gravity.y = 0.2
左右重力感應對環的加速度影響同樣可以通過改變 x 方向的 g 值達到:
// 最大傾斜角度為 70 度,讓用戶不需要過分傾斜手機// 0.4 為靈敏度值,根據具體情況調整window.addEventListener("deviceorientation", e => { let gamma = e.gamma if (gamma < -70) gamma = -70 if (gamma > 70) gamma = 70 this.engine.world.gravity.x = (e.gamma / 70) * 0.4})
2. 背景圖
本遊戲布景為遊戲機及海底世界,兩者可以作為父容器的背景圖,把 canvas 的位置定位到遊戲機內即可。canvas 覆蓋範圍為下圖的藍色蒙層:
3. 牆壁
因為環的剛體半徑比貼圖半徑小,因此牆壁剛體需要有一些提前位移,環貼圖才不會溢出,位移量為 R - r(下圖紅線為牆壁剛體的一部分):
4. 針
為了模擬針的邊緣輪廓,針的剛體由一個矩形與一個圓形所組成。下圖紅線描繪了針的剛體:
為什麼針邊緣沒有像牆壁一樣有一些提前量呢?這是因為進針效果要求針頂的平台區域盡量地窄。作為補償,可以把環剛體的半徑儘可能地調得更大,這樣在視覺上環與針的重疊也就不那麼明顯了。
進針
進針是整個遊戲的核心部分,也是最難模擬的地方。
進針後
兩個二維平面的物體交錯是不能產生「穿過」效果的:
除非把環分成前後兩部分,這樣層級關係才能得到解決。但是由於環貼圖是逐幀圖,分兩部分的做法並不合適。
最後找到的解決辦法是利用視覺錯位來達到「穿過」效果:
具體做法是,當環被判定成功進針時,把環剛體去掉,環的逐幀圖逐漸播放到平放的那一幀,rotation 值也逐漸變為 0。同時利用 CreateJS 的 Tween 動畫把環平移到針底。
進針後需要去掉環剛體,平移環貼圖,這就是上文為什麼環的貼圖必須由 CreateJS 負責渲染的答案。
偽代碼:
// Object RingafterCollision (waterful) { // 平移到針底部 createjs.Tween.get(this.texture) .to({y: y}, duration) // 消去剛體 Matter.World.remove(waterful.engine.world, this.body) this.body = null // 接下來每一 Tick 的更新邏輯改變如下 this.update = function () { const texture = this.texture if 當前環貼圖就是第 0 幀 (環平放的那一幀) { texture.gotoAndStop(0) } else { 每 5 個 Tick 往前播放一幀 (相隔多少 Tick 切換一幀可以憑感覺調整, 主要是為了使切換到平放狀態的過程不顯得太突兀) } // 使針大概在環中央位置穿過 if (texture.x < 200) ++texture.x if (texture.x > 213 && texture.x < 300) --texture.x if (texture.x > 462) --texture.x if (texture.x > 400 && texture.x < 448) ++texture.x // 把環貼圖儘快旋轉到水平狀態 let rotation = Math.round(texture.rotation) % 180 if (rotation < 0) rotation += 180 if (rotation > 0 && rotation <= 90) { texture.rotation = rotation - 1 } else if (rotation > 90 && rotation < 180) { texture.rotation = rotation + 1 } else if (frame === 0) { this.update = function () {} } } // 調用得分回調函數 waterful.score()}
進針判斷
進針條件:
1. 到達針頂
到達針頂是環進針成功的必要條件。
2. 動畫幀
環必須垂直於針才能被順利穿過,水平於針時應該是與針相碰後彈開。
當然條件可以相對放寬一些,不需要完全垂直,下圖紅框內的6幀都被規定為符合條件:
為了降低遊戲難度,我規定超過針一半高度時,只循環播放前6幀:
this.texture.on("animationend", e => { if (e.target.y < 400) { e.target.gotoAndPlay("short") } else { e.target.gotoAndPlay("normal") }})
3. rotation 值
同理,為了使得環與針相垂直,rotation 值不能太接近 90 度。經試驗後規定 0 <= rotation <= 65 或 115 <= rotation <= 180 是進針的必要條件。
下圖這種過大的傾角邏輯上是不能進針成功的:
初探:
一開始我想的是把三維的進針做成二維的「圓球進桶」,進針的判斷也就歸到物理事件上面去,不需要再去考慮。
具體做法如下圖,紅線為針壁,當環剛體(藍球)掉入桶內且與 Sensor (綠線)相碰,則判斷進針成功。為了使遊戲難度不至於太大,環剛體必須設置得較小,而且針壁間距離要比環剛體直徑稍大。
這種模擬其實已經能達到不錯的效果了,但是一個技能打破了這種思路的可能性。
產品那邊想做一個放大技能,當用戶使用此技能時環會放大,更容易套中。但是在桶口直徑不變的情況下,只是環貼圖變大並不能降低遊戲難度。如果把環剛體變小,的確容易進了,但相近的環之間的貼圖重疊範圍會很大,這就顯得很不合理了。
改進:
「進桶」的思路走不通是因為不兼容放大技能,而放大技能改變的是環的直徑。因此需要找到一種進針判斷方法在環直徑小時,進針難度大,直徑大時,進針難度小。
下面兩圖分別為普通環和放大環,其中紅色虛線表示水平方向的內環直徑:
在針頂設置一小段探測線(下圖紅色虛線),當內環的水平直徑與探測線相交時,證明進針成功,然後走進針後的邏輯。在環放大時,內環的水平直徑變長,也就更容易與探測線相交。
偽代碼:
// Object Ring// 每一 Tick 都去判斷每個運動中的環是否與探測線相交update (waterful) { const texture = this.texture // 環當前中心點坐標 const x0 = texture.x const y0 = texture.y // 環的旋轉弧度 const angle = texture.rotation // 內環半徑 const r = waterful.enlarging ? 16 * 1.5 : 16 // 根據旋轉角度算出內環水平直徑的開始和結束坐標 // 注意 Matter.js 拿到的是 rotation 值是弧度,需要轉成角度 const startPoint = { x: x0 - r * Math.cos(angle * (Math.PI / 180)), y: y0 - r * Math.sin(angle * (Math.PI / 180)) } const endPoint = { x: x0 + r * Math.cos(-angle * (Math.PI / 180)), y: y0 + r * Math.sin(angle * (Math.PI / 180)) } // mn 為左側探測線段的兩點,uv 為右側探測線段的兩點 const m = {x: 206, y: 216}, n = {x: 206, y: 400}, u = {x: 455, y: 216}, v = {x: 455, y: 400} if (segmentsIntr(startPoint, endPoint, m, n) || segmentsIntr(startPoint, endPoint, u, v)) { // 內環直徑與 mn 或 uv 相交,證明進針成功 this.afterCollision(waterful) } ...}
判斷線段是否相交的演算法可以參考這篇文章:談談求線段交點的幾種演算法
這種思路有兩個不合常理的點:
1. 當環在針頂平台直到靜止時,內環水平直徑都沒有和探測線相交,或者相交了但是 rotation 值不符合進針要求,視覺上給人的感受就是環在針頂上靜止了:
解決思路一是通過重力感應,因為設置了重力感應,只要用戶稍微動一下手機環就會動起來。二是判斷環剛體在針頂平台完全靜止了,則給它施加一個力,讓它往下掉。
2.有可能環的運動軌跡是在針頂划過,但與探測線相交了,此時會給玩家一種環被吸下來的感覺。可以通過適當設置探測線的長度來減少這種情況發生的幾率。
優化
資源池
資源回收復用,是遊戲常用的優化手法,接下來通過講解氣泡動畫的實現來簡單介紹一下。
氣泡動畫是逐幀圖,用戶點擊按鈕時,即創建一個 createjs.Sprite。在 animationend 時,把該 sprite 對象從 createjs.Stage 中 remove 掉。
可想而知,當用戶不停點擊時,會不斷的創建 createjs.Sprite 對象,非常耗費資源。如果能復用之前播放完被 remove 掉的 sprite 對象,就能解決此問題。
具體做法是每當用戶按下按鈕時,先去資源池數組找有沒有 sprite 對象。如果沒有則創建,animationend 時把 sprite 對象從 stage 里 remove 掉,然後 push 進資源池。如果有,則從資源池取出並直接使用該對象。
當然用戶的點擊操作事件需要節流處理,例如至少 300ms 後才能播放下一個氣泡動畫。
偽代碼:
// Object WaterfulgetBubble = throttle(function () { // 存在空閑泡泡即返回 if (this._idleBubbles.length) return this._idleBubbles.shift() // 不存在則創建 const bubble = new createjs.Sprite(...) bubble.on("animationend", () => { this._stage.removeChild(bubble) this._idleBubbles.push(bubble) }) return bubble}, 300)
環速度過快導致飛出邊界
Matter.js 里由於沒有實現持續碰撞檢測演算法(CCD),所以在物體速度過快的情況下,和其他物體的碰撞不會被檢測出來。當環速度很快時,也就會出現飛出牆壁的 bug。
正常情況下,每次按鍵給環施加的力都是很小的。當用戶快速連續點擊時,y 方向累積的力也不至於過大。但還是有玩家反應遊戲過程中環不見了的問題。最後發現當手機卡頓時,Matter.js 的 Tick 沒有及時觸發,導致卡頓完後把卡頓時累積起來的力一次性應用到環剛體上,環瞬間獲得很大的速度,也就飛出了遊戲場景。
解決方法有兩個:
1. 給按鈕節流,300ms才能施加一次力。
2. 每次按下按鈕,只是把一個標誌位設為 true。在每個 Matter.js 的 Tick 里判斷該標誌位是否為 true,是則施力。保證每個 Matter.js 的 Tick 里只對環施加一次力。
偽代碼:
btn.addEventListener("touchstart", e => { this.addForce = true})Events.on(this._engine, "beforeUpdate", e => { if (!this.addForce) return this.addForceLeft = false // 施力 this._rings.forEach(ring => { Matter.Body.applyForce(ring.body, {x: x, y: y}, {x: 0.02, y: -0.03}) Matter.Body.setAngularVelocity(ring.body, Math.PI/24) })})
推薦閱讀: