文章目录
- DIY 实战:从扫雷小游戏开发再探问题分解能力
- 3 问题分解实战(自顶向下)
- 3.2 页面渲染逻辑
- 3.3 事件绑定逻辑
- 4 代码实现(自底向上)
- 4.1 页面渲染部分
- 4.2 事件绑定部分
写在前面
本篇将利用《Learn AI-assisted Python Programming》第七章介绍的问题分解方法,完成简版扫雷游戏的后续逻辑分解。由于篇幅过长,与 AI 相关的具体交互过程和小结复盘留到下篇介绍,敬请关注!
DIY 实战:从扫雷小游戏开发再探问题分解能力
3 问题分解实战(自顶向下)
3.2 页面渲染逻辑
(接 上篇)…… init()
的拆分就暂告一个段落了,如下图所示:
图 4 初步确定的 init() 函数拆分方案
3.3 事件绑定逻辑
虽然页面上划分了三个区域:难度选择区、地雷统计区、扫雷面板区,但实际需要绑定事件的只有两个,统计区的数据更新是和游戏面板同步的,因此只拆成两个子函数即可:
先看难度选择区的事件绑定逻辑 bindLevelButtonActions()
,这个比较容易,通过切换一个标识类 active
控制按钮本身的样式,然后再触发页面初始化函数 start(currentLv)
即可。
重点是每个单元格的事件绑定,这是整个扫雷游戏最核心的部分,需要仔细讨论每一种可能出现的状态。注册事件首选 mousedown
,这样可以很方便地利用 event.which
属性知晓鼠标点击的具体按键:
event.which
为1
表示按下了鼠标左键;event.which
为2
表示按下了鼠标滚轮(一般很少用到);event.which
为3
表示按下了鼠标右键;
由于状态较多,这里建议使用排除法,先把旁枝末节的情况排除掉,剩下的就是核心逻辑了:
- 首先是禁用鼠标右键菜单;
- 接着禁用鼠标滚轮操作;
- 如果该单元格已经点开了(即不是地雷,且已经用左键点过的安全单元格),就直接中止后续操作;
- 对于未考察的单元格,分两种情况:
- 如果按下的是鼠标右键,则通过标注地雷加上小旗图标,同时更新地雷统计数据;
- 如果按下的是鼠标左键,则又分三种情况:
- 如果已经标记为地雷,则中止操作;
- 如果是地雷,则公布所有地雷,禁用所有单元格点击,并提示游戏失败;
- 如果不是地雷,再分两种情况:
- 周围八个单元格存在地雷,则根据具体数量添加不同的样式类,标记出具体数字;
- 周围不存在地雷,则依次遍历每一个周边单元格,再次按当前按下的是左键(即 3.2 步)进行递归检索;
绘制流程图如下:
因此,事件绑定函数可以拆分成这几个部分:
这样一来,事件绑定的问题分解就全部完成了。
4 代码实现(自底向上)
终于来到激动人心的代码实现环节了!根据刚才的分解情况,按照自底向上依次实现各个叶子级功能点:
4.1 页面渲染部分
先是页面渲染的三个子函数:
对应代码:
let mineFound = 0;
function init(lv) {
// 1. create table elements
const doms = renderGameBoard(lv);
// 2. render stats info
$('#mineCount').innerHTML = lv.mine;
$('#mineFound').innerHTML = mineFound;
// 3. create mine array
const mines = generateMineCells(lv, doms);
return mines;
}
先实现页面单元格的动态渲染函数 renderGameBoard(lv)
:
function renderGameBoard({ row, col }) {
const table = $('.gameBoard');
table.innerHTML = ''; // reset table content
const fragment = document.createDocumentFragment();
for (let i = 1; i <= row; i++) {
const tr = document.createElement('tr');
for (let j = 1; j <= col; j++) {
const td = document.createElement('td');
td.dataset.id = `${i},${j}`;
td.classList.add('cell');
tr.appendChild(td);
}
fragment.appendChild(tr);
}
table.appendChild(fragment);
return $$('.cell');
}
注意:
-
td.dataset.id
设置的是单元格ID
坐标,它和状态矩阵总编号之间转换关系可以写入utils.js
工具模块:/** * Converts a 2D array index to a 1D array index. * @param {string} ij The string representation of the 2D index, e.g., "1,2". * @param {number} col The number of columns in the 2D array. * @returns {number} The 1D index corresponding to the 2D index. */ export function getId(ij, col) { const [i, j] = ij.split(',').map(n => parseInt(n, 10)); return (i - 1) * col + j; } /** * Converts a 1D array index to a 2D array index. * @param {number} id The 1D index. * @param {number} col The number of columns in the 2D array. * @returns {Array<number>} An array containing the row and column indices. */ export function getIJ(id, col) { const j = id % col === 0 ? col : id % col; const i = (id - j) / col + 1; return [i, j]; }
-
$$
是一个简化后的工具函数,从utils.js
模块导入:/** * Selects all elements matching a CSS selector. * @param {string} selector The CSS selector to match elements. * @returns {NodeList} A NodeList of elements matching the selector. */ export const $$ = document.querySelectorAll.bind(document);
接着实现地雷统计指标的初始化 renderStatsInfo
。由于只有两句话,因此不单独创建新的子函数:
// 2. render stats info
$('#mineCount').innerHTML = lv.mine;
$('#mineFound').innerHTML = mineFound;
然后是渲染部分的最后一项 generateMineCells(lv, doms)
:
function generateMineCells(lv, doms) {
// 1. create mine cells
const mines = initMineCells(lv);
// 2. populate neighboring ids
populateNeighboringIds(mines, lv, doms);
return mines;
}
这里之所以又分出两个子函数,是因为实现过程中发现可以将部分点击事件的逻辑(例如计算周边区域的地雷数)分摊到状态矩阵的初始化来处理,没必要在每次按下鼠标时再算。因此,对于每个状态矩阵的元素而言,还应该有个新增属性 neighbors
,用于存放周边元素 ID
的子数组。于是 initMineCells
负责生成状态矩阵,populateNeighboringIds
负责填充每个状态元素的初始状态值(当前位置的周边地雷数、紧邻单元格的 ID
数组):
function initMineCells({row, col, mine}) {
const size = row * col; // total number of cells
const mines = range(size)
.sort(() => Math.random() - 0.5)
.slice(-mine);
// console.log(mines);
const mineCells = range(size)
.map(id => {
const isMine = mines.includes(id),
mineCount = isMine ? 9 : 0,
neighbors = isMine ? null : []; // neighbors of the cell
return {
id,
isMine,
mineCount,
neighbors, // neighbors of the cell
checked: false, // whether the cell is checked or not
flagged: false // whether the cell is flagged or not
};
});
return mineCells;
}
上述代码有两个地方需要注意:
-
地雷的乱序算法:使用随机值实现:
() => Math.random() - 0.5
; -
快速生成
[1, n]
的正整数数组:/** * Generates an array of numbers from 1 to size (inclusive). * @param {number} size The size of the range. * @returns {Array<number>} An array of numbers from 0 to size. */ export function range(size) { return [...Array(size).keys()].map(n => n + 1); }
紧接着填充状态值 mineCount
和 neighbors
:
function populateNeighboringIds(mineCells, {col, row}, doms) {
const safeCells = mineCells.filter(({ isMine }) => !isMine);
// 1. Get neighbor ids for each cell
safeCells.forEach(cell => {
const [i, j] = getIJ(cell.id, col);
for (let r = Math.max(1, i - 1), rows = Math.min(row, i + 1); r <= rows; r++) {
for (let c = Math.max(1, j - 1), cols = Math.min(col, j + 1); c <= cols; c++) {
if (r === i && c === j) continue;
const neighborId = getId(`${r},${c}`, col);
cell.neighbors.push(neighborId);
}
}
});
// 2. Calculate total number of neighboring mines
safeCells.forEach(cell => {
// get neighbor ids for each cell
const mineCount = cell.neighbors.reduce((acc, neighborId) => {
const {isMine} = mineCells[neighborId - 1];
return acc + (isMine ? 1 : 0);
}, 0);
cell.mineCount = mineCount;
});
}
注意,这里出现了第一个比较繁琐的逻辑(L6–L7):判定周边单元格的上、下、左、右边界。如果当前单元格坐标为 (i, j)
,不考虑雷区边框的情况下,其周边单元格的行号范围是 [i-1, i+1]
、列数范围是 [j-1, j+1]
。现在考虑边框,则需要用 Math.max
和 Math.min
限制一下。这个写法其实是 Copilot
根据我的注释自动生成的。可见 Copilot
在小范围内对这样非常确定的需求理解得很到位,我们只需要略微检查一下边界条件的取值就行了。
4.2 事件绑定部分
再来回顾一下事件绑定逻辑的总结构:
由于层次过深,盲目按照自底向上的思路实现子函数可行性不大,因为还没有对每个函数的参数及返回值做进一步确认。因此这里还是自顶向下实现。
先来看最外层:
function bindEvents(lv, mines) {
// when selecting a level
bindLevelButtonActions();
// when clicking on the game board
$('.gameBoard').onmousedown = (ev) => {
ev.preventDefault();
if(ev.target.classList.contains('gameBoard')) {
// 禁用 table 元素上的右键菜单
ev.target.oncontextmenu = (e) => {
e.preventDefault();
};
}
};
// when clicking on cells
bindCellMousedownActions(lv, mines);
}
之所以中间多了一部分,是因为实测时发现单元格之间还存在少量间隔,如果不小心在这些地方点击右键,仍然会出现上下文菜单,因此特地做了补救。
接着先来实现难度选择区的事件绑定:
function bindLevelButtonActions() {
const btns = Array.from($$('.level [data-level]'));
btns.forEach((btn, _, arr) => {
btn.onclick = ({ target }) => {
// toggle active class
arr.forEach(bt => (target !== bt) ?
bt.classList.remove('active') :
bt.classList.add('active'));
// reset mines found
mineFound = 0;
currentLv = getCurrentLevel(target.dataset.level);
// reload game
start(currentLv);
};
});
// when clicking on the restart button
$('.restart').onclick = (ev) => {
ev.preventDefault();
mineFound = 0; // reset mine found count
start(currentLv);
ev.target.classList.add('hidden');
};
}
这里又增补了一个按钮:.restart
。这是每局结束时才会出现的按钮,专门用于重新开始游戏。同理,也是实测时发现的细节。
最后是扫雷区的鼠标事件 bindCellMousedownActions
:
function bindCellMousedownActions(lv, mines) {
// when clicking on cells
$$('.cell').forEach(cell => {
cell.onmousedown = ({ target, which }) => {
// 禁用右键菜单
target.oncontextmenu = e => e.preventDefault();
// 禁用鼠标滚轮
if(which === 2) return;
const cellObj = findMineCellById(target.dataset.id, lv, mines);
if(cellObj.checked) {
// already checked or flagged
console.log('Already checked, abort');
return;
}
if (which === 3) {
// 右击:添加/删除地雷标记
handleRightClick(target, lv, mines);
return;
}
if (which === 1) {
// 左击
// 1. 如果已插旗,则不处理
if (cellObj.flagged) {
console.log('Already flagged, abort');
return;
}
// 2. 踩雷,游戏结束:
if (cellObj.isMine) {
showMinesAndCleanup(target, mines, lv);
// 提示重启游戏
setTimeout(() => {
$('.restart').classList.remove('hidden');
alert('游戏结束!你踩到地雷了!');
}, 0);
return;
}
// 3. 若为安全区域,标记为已检查
searchAround(cellObj, target, lv.col, mines);
// 4. 查看是否胜利
const allChecked = mines.filter(e => !e.isMine && !e.checked).length === 0;
if (allChecked) {
congratulateVictory(mines, lv);
}
}
};
});
}
根据问题拆分情况,这里又分出了 5 个具体的子函数,除了 findMineCellById
是临时新增外,其余都是游戏运行必不可少的核心逻辑:
// 1. 根据单元格坐标 id 获取对应的状态矩阵元素
function findMineCellById(id, {col}, mines) {
const index = getId(id, col) - 1;
return mines[index];
}
// 2. 右键标记为地雷以及取消地雷标记的处理逻辑
function handleRightClick(target, lv, mines) {
target.classList.toggle('mine');
target.classList.toggle('ms-flag');
const cellObj = findMineCellById(target.dataset.id, lv, mines);
cellObj.flagged = !cellObj.flagged; // toggle flagged status
// 更新地雷标记数
if (cellObj.flagged) {
$('#mineFound').innerHTML = (++mineFound);
} else {
$('#mineFound').innerHTML = (--mineFound);
}
}
// 3. 踩到地雷时的处理逻辑
function showMinesAndCleanup(target, mines, lv) {
// 1. 标记当前踩雷的单元格
target.classList.add('fail');
// 2. 公布所有地雷
showFinalResult(mines, lv);
}
// 4. 游戏胜利的处理逻辑
function congratulateVictory(mines, lv) {
showFinalResult(mines, lv);
setTimeout(() => {
alert('恭喜你,成功扫除所有地雷!');
$('.restart').classList.remove('hidden');
}, 0);
}
function showFinalResult(mines, lv) {
// 1. 渲染出所有地雷
renderAllMines(mines, lv.col);
// 2. 标记所有单元格为已检查(防止误操作)
mines.forEach(mine => mine.checked = true);
// 3. 所有标记正确的单元格背景色变为绿色
renderAllCorrectFlagged(mines, lv);
}
// 5. 当前单元格及周边都没有地雷时的处理逻辑
function searchAround(curCell, curDom, colSize, mines) {
curCell.checked = true;
// Render the current cell
curDom.classList.add('number', `mc-${curCell.mineCount}`);
curDom.innerHTML = curCell.mineCount;
// 如果是空白单元格,则递归显示周围的格子,直到遇到非空白单元格
if (curCell.mineCount === 0) {
curDom.innerHTML = '';
curCell.neighbors.forEach(nbId => {
const nbCell = mines[nbId - 1];
const nbDom = $(`[data-id="${getIJ(nbId, colSize)}"]`);
if(!nbCell.checked && !nbCell.flagged && !nbCell.isMine) {
searchAround(nbCell, nbDom, colSize, mines);
}
});
}
}
这样就实现了所有的处理逻辑,完整代码及最终页面已经放到了 InsCode 上,感兴趣的朋友可以 Fork
到本地试试。
本来计划把后续和 Copilot
的交互过程也梳理一下,结果又写了这么多内容,只有放到下一篇继续了。
(未完待续)