「消滅星星」是一款很經典的「消除類遊戲」,它的玩法很簡單:消除相連通的同色磚塊。
1. 遊戲規則
「消滅星星」存在多個版本,不過它們的規則除了「關卡分值」有些出入外,其它的規則都是一樣的。筆者介紹的版本的遊戲規則整理如下:
1. 色磚分佈
- 10 x 10 的表格
- 5種顏色 —— 紅、綠、藍,黃,紫
- 每類色磚個數在指定區間內隨機
- 5類色磚在 10 x 10 表格中隨機分佈
2. 消除規則
兩個或兩個以上同色磚塊相連通即是可被消除的磚塊。
3. 分值規則
- 消除總分值 = n * n * 5
- 獎勵總分值 = 2000 – n * n * 20
「n」表示磚塊數量。上面是「總」分值的規則,還有「單」個磚塊的分值規則:
- 消除磚塊得分值 = 10 * i + 5
- 剩餘磚塊扣分值 = 40 * i + 20
「i」表示磚塊的索引值(從 0 開始)。簡單地說,單個磚塊「得分值」和「扣分值」是一個等差數列。
4. 關卡分值
關卡分值 = 1000 + (level – 1) * 2000;「level」即當前關卡數。
5. 通關條件
- 可消除色塊不存在
- 累計分值 >= 當前關卡分值
上面兩個條件同時成立遊戲才可以通關。
2. MVC 設計模式
筆者這次又是使用了 MVC 模式來寫「消滅星星」。星星「磚塊」的資料結構與各種狀態由 Model 實現,遊戲的核心在 Model 中完成;View 對映 Model 的變化並做出對應的行為,它的任務主要是展示動畫;使用者與遊戲的互動由 Control 完成。
從邏輯規劃上看,Model 很重而View 與 Control 很輕,不過,從程式碼量上看,View 很重而 Model 與 Control 相對很輕。
3. Model
10 x 10 的表格用長度為 100 的陣列可完美對映遊戲的星星「磚塊」。
1 2 3 4 5 6 7 8 9 10 11 12 |
[ R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P, R, R, G, G, B, B, Y, Y, P, P ] |
R – 紅色,G – 綠色,B – 藍色,Y – 黃色,P – 紫色。Model 的核心任務是以下四個:
- 生成磚牆
- 消除磚塊 (生成磚塊分值)
- 夯實磚牆
- 清除殘磚 (生成獎勵分值)
3.1 生成磚牆
磚牆分兩步生成:
- 色磚數量分配
- 打散色磚
理論上,可以將 100 個格子可以均分到 5 類顏色,不過筆者玩過的「消滅星星」都不使用均分策略。通過分析幾款「消滅星星」,其實可以發現一個規律 —— 「色磚之間的數量差在一個固定的區間內」。
如果把傳統意義上的均分稱作「完全均分」,那麼「消滅星星」的分配是一種在均分線上下波動的「不完全均分」。
筆者把上面的「不完全均分」稱作「波動均分」,演算法的具體實現可以參見「波動均分演算法」。
「打散色磚」其實就是將陣列亂序的過程,筆者推薦使用「 費雪耶茲亂序演算法」。
以下是虛擬碼的實現:
1 2 3 4 5 6 7 |
// 波動均分色磚 waveaverage(5, 4, 4).forEach( // tiles 即色牆陣列 (count, clr) => tiles.concat(generateTiles(count, clr)); ); // 打散色磚 shuffle(tiles); |
3.2 消除磚塊
「消除磚塊」的規則很簡單 —— 相鄰相連通相同色即可以消除。
前兩個組合符合「相鄰相連通相同色即可以消除」,所以它們可以被消除;第三個組合雖然「相鄰相同色」但是不「相連通」所以它不能被消除。
「消除磚塊」的同時有一個重要的任務:生成磚塊對應的分值。在「遊戲規則」中,筆者已經提供了對應的數學公式:「消除磚塊得分值 = 10 * i + 5」。
「消除磚塊」演算法實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function clean(tile) { let count = 1; let sameTiles = searchSameTiles(tile); if(sameTiles.length > 0) { deleteTile(tile); while(true) { let nextSameTiles = []; sameTiles.forEach(tile => { nextSameTiles.push(...searchSameTiles(tile)); makeScore(++count * 10 + 5); // 標記當前分值 deleteTile(tile); // 刪除磚塊 }); // 清除完成,跳出迴圈 if(nextSameTiles.length === 0) break; else { sameTiles = nextSameTiles; } } } } |
清除的演算法使用「遞迴」邏輯上會清晰一些,不過「遞迴」在瀏覽器上容易「棧溢位」,所以筆者沒有使用「遞迴」實現。
3.3 夯實磚牆
磚牆在消除了部分磚塊後,會出現空洞,此時需要對牆體進行夯實:
向下夯實 | 向左夯實 | 向左下夯實(先下後左) |
一種快速的實現方案是,每次「消除磚塊」後直接遍歷磚牆陣列(10×10陣列)再把空洞夯實,虛擬碼表示如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
for(let row = 0; row < 10; ++row) { for(let col = 0; col < 10; ++col) { if(isEmpty(row, col)) { // 水平方向(向左)夯實 if(isEmptyCol(col)) { tampRow(col); } // 垂直方向(向下)夯實 else { tampCol(col); } break; } } } |
But… 為了夯實一個空洞對一張大陣列進行全量遍歷並不是一種高效的演算法。在筆者看來影響「牆體夯實」效率的因素有:
- 定位空洞
- 磚塊移動(夯實)
掃描牆體陣列的主要目的是「定位空洞」,但能否不掃描牆體陣列直接「定位空洞」?
牆體的「空洞」是由於「消除磚塊」造成的,換種說法 —— 被消除的磚塊留下來的坑位就是牆體的空洞。在「消除磚塊」的同時標記空洞的位置,這樣就無須全量掃描牆體陣列,虛擬碼如下:
1 2 3 4 5 6 |
function deleteTile(tile) { // 標記空洞 markHollow(tile.index); // 刪除磚塊邏輯 ... } |
在上面的夯實動圖,其實可以看到它的夯實過程如下:
- 空洞上方的磚塊向下移動
- 空列右側的磚塊向左移動
牆體在「夯實」過程中,它的邊界是實時在變化,如果「夯實」不按真實邊界進行掃描,會產生多餘的空白掃描:
如何記錄牆體的邊界?
把牆體拆分成一個個單獨的列,那麼列最頂部的空白格片段就是牆體的「空白」,而其餘非頂部的空白格片段即牆體的「空洞」。
筆者使用一組「列集合」來描述牆體的邊界並記錄牆體的空洞,它的模型如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/* @ count - 列磚塊數 @ start - 頂部行索引 @ end - 底部行索引 @ pitCount - 坑數 @ topPit - 最頂部的坑 @ bottomPit - 最底部的坑 */ let wall = [ {count, start, end, pitCount, topPit, bottomPit}, {count, start, end, pitCount, topPit, bottomPit}, ... ]; |
這個模型可以描述牆體的三個細節:
- 空列
- 列的連續空洞
- 列的非連續空洞
123456789101112// 空列if(count === 0) {...}// 連續空洞else if(bottomPit - topPit + 1 === pitCount) {...}// 非連續空洞else {...}
磚塊在消除後,對映到單個列上的空洞會有兩種分佈形態 —— 連續與非連續。
「連續空洞」與「非連續空洞」的夯實過程如下:
其實「空列」放大於牆體上,也會有「空洞」類似的分佈形態 —— 連續與非連續。
它的夯實過程與空洞類似,這裡就不贅述了。
3.4 消除殘磚
上一小節提到了「描述牆體的邊界並記錄牆體的空洞」的「列集合」,筆者是直接使用這個「列集合」來消除殘磚的,虛擬碼如下:
1 2 3 4 5 6 7 8 9 10 11 |
function clearAll() { let count = 0; for(let col = 0, len = this.wall.length; col < len; ++col) { let colInfo = this.wall[col]; for(let row = colInfo.start; row <= colInfo.end; ++row) { let tile = this.grid[row * this.col + col]; tile.score = -20 - 40 * count++; // 標記獎勵分數 tile.removed = true; } } } |
4. View
View 主要的功能有兩個:
- UI 管理
- 對映 Model 的變化(動畫)
UI 管理主要是指「介面繪製」與「資源載入管理」,這兩項功能比較常見本文就直接略過了。View 的重頭戲是「對映 Model 的變化」並完成對應的動畫。動畫是複雜的,而對映的原理是簡單的,如下虛擬碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
update({originIndex, index, clr, removed, score}) { // 還沒有 originIndex 或沒有色值,直接不處理 if(originIndex === undefined || clr === undefined) return ; let tile = this.tiles[originIndex]; // tile 存在,判斷顏色是否一樣 if(tile.clr !== clr) { this.updateTileClr(tile, clr); } // 當前索引變化 ----- 表示位置也有變化 if(tile.index !== index) { this.updateTileIndex(tile, index); } // 設定分數 if(tile.score !== score) { tile.score = score; } if(tile.removed !== removed) { // 移除或新增當前節點 true === removed ? this.bomb(tile) : this.area.addChild(tile.sprite); tile.removed = removed; } } |
Model 的磚塊每次資料的更改都會通知到 View 的磚塊,View 會根據對應的變化做對應的動作(動畫)。
5. Control
Control 要處理的事務比較多,如下:
- 繫結 Model & View
- 生成通關分值
- 判斷通關條件
- 對外事件
- 使用者互動
初始化時,Control 把 Model 的磚塊單向繫結到 View 的磚塊了。如下:
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 30 31 32 33 34 35 36 37 38 |
Object.defineProperties(model.tile, { originIndex: { get() {...}, set(){ ... view.update({originIndex}) } }, index: { get() {...}, set() { ... view.update({index}) } }, clr: { get() {...}, set() { ... view.update({clr}) } }, removed: { get() {...}, set() { ... view.update({removed}) } }, score: { get() {...}, set() { ... view.update({score}) } } }) |
「通關分值」與「判斷通關條件」這對邏輯在本文的「遊戲規則」中有相關介紹,這裡不再贅述。
對外事件規劃如下:
name | detail |
---|---|
pass | 通關 |
pause | 暫停 |
resume | 恢復 |
gameover | 遊戲結束 |
使用者互動 APIs 規劃如下:
name | type | deltail |
---|---|---|
init | method | 初始化遊戲 |
next | method | 進入下一關 |
enter | method | 進入指定關卡 |
pause | method | 暫停 |
resume | method | 恢復 |
destroy | method | 銷燬遊戲 |
6. 問題
在知乎有一個關於「消滅星星」的話題:popstar關卡是如何設計的?
這個話題在最後提出了一個問題 —— 「無法消除和最大得分不滿足過關條件的矩陣」。
「無法消除的矩陣」其實就是最大得分為0的矩陣,本質上是「最大得分不滿足過關條件的矩陣」。
最大得分不滿足過關條件的矩陣
求「矩陣」的最大得分是一個 「揹包問題」,求解的演算法不難:對當前矩陣用「遞迴」的形式把所有的消滅分支都執行一次,並取最高分值。但是 javascript 的「遞迴」極易「棧溢位」導致演算法無法執行。
其實在知乎的話題中提到一個解決方案:
網上查到有程式提出做個工具隨機生成關卡,自動計算,把符合得分條件的關卡篩選出來
這個解決方案代價是昂貴的!筆者提供有原始碼並沒有解決這個問題,而是用一個比較取巧的方法:進入遊戲前檢查是事為「無法消除矩陣」,如果是重新生成關卡矩陣。
注意:筆者使用的取巧方案並沒有解決問題。
7. 結語
下面是本文介紹的「消滅星星」的線上 DEMO 的二維碼:
遊戲的原始碼託管在:https://github.com/leeenx/popstar
感謝耐心閱讀完本文章的讀者。本文僅代表筆者的個人觀點,如有不妥之處請不吝賜教。
如果對「H5遊戲開發」感興趣,歡迎關注我們的專欄。