DIY 拓展:從掃雷小游戲開發再探問題分解與 AI 代碼調試能力(上)
1 起因
最近在看去年剛出了第 2 版《Learn AI-assisted Python Programming》,梳理完 第七章 的知識點后,總感覺這一章的話題很好——問題分解能力的培養——美中不足的是演示案例過于簡單,有點意猶未盡。今天就用一個前端掃雷小游戲的全流程開發再來落地實操一回。
最終效果如下:
【圖 1:前端掃雷游戲最終效果圖】
2 思路分析
為了突出訓練重點,這里將掃雷游戲略作簡化,只保留核心功能:難度選擇和掃雷操作,其他像計時、聲音、排行榜等功能暫不考慮。因此整理后的需求描述如下:
- 支持 “初級”、“中級”、“高級” 三種難度;
- 記錄每局總地雷數以及實時排雷數;
- 根據選擇的難度生成指定尺寸、固定地雷數的網格區域,通過左鍵探雷、右鍵標記地雷(不考慮 “待定” 標記);
- 探雷時:
- 如果踩到地雷則游戲結束;
- 為安全區域,則:
- 周圍有雷:標記出周圍 8 個單元格存在的地雷總數;
- 周圍無雷:從當前單元格向四周擴散,直到發現標有地雷數的邊界區域。
- 排雷時,點擊鼠標右鍵即可標記為雷區,之后左鍵無法單擊繼續;
- 獲勝條件:正確探明所有安全區域才算獲勝(僅標注地雷不算勝出)。
那么按照上述需求,應該如何分類呢?每一局無論區域有多大,地雷有多少,都可以將整個界面剝離成兩類問題:
- 頁面的渲染問題;
- 頁面元素的事件綁定問題;
接下來就圍繞這兩類問題進行討論,看看問題分解如何發揮作用。
3 問題分解實戰
根據剛才的思路分析,頁面渲染和事件綁定存在明顯的先后順序和依賴關系:渲染出頁面后才能綁定事件,并且綁定事件還需要知道每個單元格的狀態(即地雷數據)。于是可以設計出如下兩個子函數:
let currentLv = 0;function start(lv = currentLv) {// init game boardconst mineCells = init(lv);// bind eventsbindEvents(lv, mineCells);
}start(currentLv);
在對這兩個任務做進一步分解前,需要先準備好游戲需要的靜態頁面。
3.1 靜態頁面與基礎樣式
這一步包括 HTML
基本結構和 CSS
基礎樣式。選型時為了更靈活地設置樣式,選用了 iconfont
+ 樣式類來實現所有的單元格圖案和標記:
【圖 2:游戲界面用到的主要圖標字體(來源:阿里圖標庫)】
使用方法:
<!-- 引入樣式 -->
<link rel="stylesheet" href="./iconfont.css">
<!-- 設置圖標 -->
<span class="mine ms-xxx"></span>
具體圖標從阿里圖標庫選取即可,這里不多贅述。對照最開始給出的頁面效果,再上網找出經典掃雷游戲需要的基本效果,于是有了如下兩個文件:
index.html
:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Mine Sweeper Game</title><link prefetch rel="stylesheet" href="assets/font/iconfont.css"><link async rel="stylesheet" href="assets/css/index.css"><link async rel="shortcut icon" href="assets/favicon.png" type="image/x-icon">
</head>
<body><div class="container"><h1>掃雷游戲</h1><section class="level"><span>難度:</span><button data-level="1" class="active">初級</button><button data-level="2">中級</button><button data-level="3">高級</button><button class="restart hidden">重新開始</button></section><section class="stats"><div class="score">地雷數:<span id="mineCount"></span></div><div class="time">已排除:<span id="mineFound"></span></div><section class="game"><table class="gameBoard"></table></section></div><script src="assets/js/index.js" type="module"></script>
</body>
</html>
以及對應的樣式文件 index.css
:
* {margin: 0;padding: 0;box-sizing: border-box;font-family: Verdana, sans-serif;--cell-size: 25px;
}.container {margin-inline: auto;text-align: center;
}h1 {font-size: 1.7rem;margin-block: 1rem;
}.level {padding: .5em 0;&>button {padding: .3em .7em;border: none;border-radius: 5px;background-color: #cdd4d8;&.active {background-color: #0856df;color: #fff;font-weight: bold;}&.hidden {display: none;}}
}.stats {padding: .5em;
}#mineCount, #mineFound {font-weight: 700;color: maroon;
}.gameBoard {border: 6px solid #d4d4d4;outline: 1px solid #808080;margin: 1em auto;background-color: #c0c0c0;
}.cell {border-top: 3px solid #ffffff;border-left: 3px solid #ffffff;border-right: 3px solid #808080;border-bottom: 3px solid #808080;width: var(--cell-size);height: var(--cell-size);line-height: var(--cell-size);font-size: 1rem;cursor: pointer;&.invalid {background-color: violet;}&.mine {background: #c0c0c0;border: 1px solid #e4e4e4;opacity: 1;&.ms-mine {font-size: 1.2em;line-height: var(--cell-size);vertical-align: middle;color: #000;}&.ms-flag {font-size: 1em;line-height: var(--cell-size);vertical-align: middle;color: #f00;}&.ms-flag.correct {background: #6ad654;}}&.fail {background: #bb3d3d;border: 1px solid #808080;opacity: 1;}&.number {border-collapse: collapse;background: #c0c0c0;border: 1px solid #dddddd;font-weight: bold;}&.mc-0 {color: #c0c0c0;}&.mc-1 {color: #0000FF;}&.mc-2 {color: #008000;}&.mc-3 {color: #FF0000;}&.mc-4 {color: #000080;}&.mc-5 {color: #800000;}&.mc-6 {color: #008080;}&.mc-7 {color: #000000;}&.mc-8 {color: #808080;}
}
3.2 頁面渲染邏輯
設計好了靜態頁面,接下來就可以安心拆解 JavaScript
核心邏輯了。
首先是 init(lv)
方法。該方法應該有三個基本任務:
- 在頁面中心位置加載出掃雷區域;
- 頁面統計指標(總地雷數、已探明雷數)均設為初始值;
- 狀態矩陣。
前兩個不用多想,第三個任務要費點腦筋。這里絕不能將單元格的狀態信息放入頁面元素(例如 data-*
屬性),否則打開 F12
調試工具就能輕松破解游戲。
因此必須單獨維護一個與頁面每個單元格綁定的 狀態矩陣(即對象數組)。
這樣一來,init(lv)
就可以拆分為以下三個板塊:
let mineFound = 0;
function init(lv) {// 1. create table elementsconst doms = renderGameBoard(lv);// 2. render stats info$('#mineCount').innerHTML = lv.mine;$('#mineFound').innerHTML = mineFound;// 3. create mine arrayconst mines = generateMineCells(lv, doms);return mines;
}
由于中間那項過于簡單,就不必另外創建函數實現了。這里需要再次拆分的是負責加載 DOM
節點的渲染函數 renderGameBoard(lv)
以及對應的狀態矩陣生成函數 generateMineCells(lv, doms)
。
要渲染單元格,既可以使用 div
也可以使用 table
表格,這里我傾向用 table
,因為掃雷的整體風格感覺就像一個 Excel
,而且單元格什么的也叫習慣了(哈哈)。到時候根據關卡對象 lv
拿到總的行列數與地雷數,就能批量生成掃雷區域了。
比較燒腦的是狀態矩陣的數據結構:每個單元格要存放哪些信息呢?這一部分只能慢慢完善:
id
:即 ID 編號。該編號值設置不僅要保持統一,更重要的是要明確制定一套轉換公式,可以很方便地知道該ID
值和頁面單元格坐標((iRow, jCol)
)的對應關系(屆時這些轉換公式都放入單獨的utils.js
模塊,以免干擾核心邏輯)。isMine
:即該單元格是否為地雷的定性標記;mineCount
:如果當前單元格不是地雷,則用它表示周圍的地雷數,這樣進行鼠標操作時就能直接顯示,無需臨時計算;flagged
:表示當前單元格是否被標為地雷,若已經標記,則該單元格不可再次點擊,除非取消標記;
先暫定上述四個基本狀態,后面需要增補再說。有了這樣的數據結構,整個掃雷游戲的數據流就清楚了:用戶通過各種操作觸發狀態矩陣的改變,狀態矩陣再將變化情況更新到掃雷區域:
【圖 3:掃雷基本數據流向示意圖】
這樣,init()
的拆分就暫告一個段落了,如下圖所示:
flowchart LRA[start] --> B[init]A --> C[bindEvents]B --> D[renderGameBoard]B --> E[renderStatsInfo(無需創建)]B --> F[generateMineCells]
【圖 4 初步確定的 init() 函數拆分方案】
3.2 事件綁定邏輯
(未完待續)