Table of Contents
当年刚学完 JavaScript 的时候写了个扫雷。那时还不会 Vue 之类的框架,全是原生。界面也比较简陋,以至于现在不得不重构。
软件介绍
重构之后的《minesweeper(扫雷)》功能没有太大的变化,只是新增了页面背景设置和 pwa 支持。界面从简陋变成了简洁。项目地址:https://github.com/tonyzhou1890/minesweeper-v2。应用地址:https://minesweeper.tony93.top/。
功能
- 设置(难度、页面背景、皮肤)
- 游戏记录
- pwa
截图

实现解析
技术栈
vue、localforage、font-face、pwa、canvas
项目结构
扫雷:
│ jsconfig.json
│ package.json
│ vue.config.js
│
├─public
│ │ favicon.ico
│ │ index.html
│ │ robots.txt
│ │
│ └─img
│ ├─assets
│ │ ├─backgrounds
│ │ │ brick.png
│ │ │ default.png
│ │ │ draw.png
│ │ │
│ │ └─skins
│ │ ├─default
│ │ │ boom.png
│ │ │ cover.png
│ │ │ flag.png
│ │ │ mine.png
│ │ │ thumbnail.png
│ │ │
│ │ └─handpainted
│ │ boom.png
│ │ cover.png
│ │ flag.png
│ │ mine.png
│ │ thumbnail.png
│ │
│ └─icons
│ android-chrome-192x192.png
│ android-chrome-512x512.png
│ android-chrome-maskable-192x192.png
│ android-chrome-maskable-512x512.png
│ apple-touch-icon-152x152.png
│ favicon-16x16.png
│ favicon-32x32.png
│ msapplication-icon-144x144.png
│ safari-pinned-tab.svg
│
└─src
│ App.vue
│ main.js
│ registerServiceWorker.js
│
├─assets
│ │ logo.png
│ │
│ └─fonts
│ FX-LED.TTF
│ 汉仪霹雳体简.subset.ttf
│
├─components
│ │ Content.vue
│ │ Header.vue
│ │ Notify.vue
│ │
│ └─SvgIcon
│ index.vue
│
├─icons
│ │ index.js
│ │
│ └─svg
│ 404.svg
│ clasp.svg
│ record.svg
│ reset.svg
│ setting.svg
│
└─utils
config.js
main.js
validate.js
页面主要由 Content.vue 和 Header.vue 组成。相关配置在 config.js 中。游戏主体逻辑在 utils/main.js 中。
组件通信
App.vue、Header.vue 和 Content.vue 这三个组件之间需要通信。这种简单的结构可以直接使用 props 和 $emit 解决。但我并不想这样做,特别是 Header.vue 和 Content.vue 这两个兄弟组件之间的通信。可我也不想使用 vuex,因为这个项目真的很简单。那就只能使用 Event Bus 了。实际上之前我并没有用过 Event Bus,所以这也是一个练习的机会。
// 事件总线 Vue.prototype.bus = new Vue()
// Header.vue
this.bus.$emit('changeSetting', this.setting)
// Content.vue
created() {
this.bus.$on('changeSetting', this.handleChangeSetting)
},
beforeDestroy() {
this.bus.$off('changeSetting', this.handleChangeSetting)
},
以上三段代码就是 Event Bus 的使用方法。当然,Event Bus 还有其他的使用方法,比如创建一个单独的文件 EventBus.js,然后需要使用的组件引入。但我还是喜欢将事件总线挂载到 Vue 原型上,这样组件就不需要 import 了。
需要注意的是,组件销毁的时候需要取消事件监听。否则下次组件创建的时候会重复绑定。
游戏设置
设置面板(弹出层)打开的时候会将当前设置备份,这样在点击非面板区或者点击取消按钮的时候可以恢复原来的设置。
this.settingBackup = JSON.parse(JSON.stringify(this.setting))
设置改变后需要触发相关事件:
this.bus.$emit('changeSetting', this.setting)
点击非面板区域关闭面板的功能是通过点击蒙层(打开面板的时候也会打开一个透明蒙层)实现的。这种方法有一个缺点——非面板区点击两次才是正常的点击功能。有一个更好的方式——监听全局点击事件,判断事件路径有没有面板元素。如果某天我改成第二种再修改文档吧。(第一种虽然不优雅,但能用。)
游戏主体
Content.vue 是游戏的主体显示部分,包括计时/雷数显示和雷区。
计时/雷数的显示字体是 FX-LED,标题是汉仪霹雳体。一般情况下客户端是不会有这两种字体的,所以需要通过 font-face 实现。
// App.vue
@font-face {
font-family: '汉仪霹雳体';
src: url('./assets/fonts/汉仪霹雳体简.subset.ttf');
}
@font-face {
font-family: 'FX-LED';
src: url('./assets/fonts/FX-LED.TTF');
}
数字、英文字体一般不会很大,直接引用没什么问题,但中文字体就有点望而生畏了。1M 小水管(😭)真的扛不住几 M 的字体文件。还好现在字体裁剪也不麻烦。https://font-subset.disidu.com/
然后,正文来了。
雷区的绘制和游戏的逻辑我单独放在了 main.js 中,Content.vue 只负责显示部分以及事件监听和通知。将逻辑和表现分离既后期维护扩展,也方便移植。
游戏逻辑
一局完整的游戏可以分为以下几个过程:
- 皮肤加载
- 数据初始化
- 界面重绘
- 生成地雷
- 翻开格子
- 标记格子
- 结果判定
这些过程会有多个函数,而 Content.vue 需要调用的只有四个:skinLoader(皮肤加载)、initGames(数据初始化/界面重绘)、onClick(翻开格子)和 onContextMenu(标记格子)。
皮肤加载
const COVER = new Image() // 封面--格子未翻开的状态
const FLAG = new Image() // 旗帜
const MINE = new Image() // 地雷
const BOOM = new Image() // 触雷
/**
* 加载图片
* @param {string} skin 皮肤
*/
export const skinLoader = (skin) => {
return new Promise((resolve, reject) => {
COVER.src = `${skinPath}${skin}/cover.png`
FLAG.src = `${skinPath}${skin}/flag.png`
MINE.src = `${skinPath}${skin}/mine.png`
BOOM.src = `${skinPath}${skin}/boom.png`
let num = 0;
[COVER, FLAG, MINE, BOOM].map(item => {
item.onload = () => {
num++
if (num >= 4) {
resolve([COVER, FLAG, MINE, BOOM])
}
}
item.onerror = () => {
reject()
}
})
})
}
在加载皮肤的过程中,页面需要显示加载状态。所以加载皮肤这个函数是由 Content.vue 调用的。
数据初始化
import { skins, skinPath, levels, cellWidth } from './config'
let mineArray = [] // 实际状态数组,0-9,其中 0-8 表示周边雷的数量,9 表示自身为雷
let showArray = [] // 显示数组,0-cover,1-flag,2-mine,3-boom,4-number,5-background(空白)
let restMines = 0 // 剩下的雷数量
let restMinesShow = 0 // 剩下的雷数量(显示)
/**
* 开始游戏
* @param {object} ctx
* @param {string} skin
* @param {string} level
* @returns {object} { size: { width, height }, mines }
*/
export const initGame = (ctx, skin, level) => {
const res = {}
const _level = levels.find(item => item.key === level)
// 重置地雷数量
res.mines = restMines = restMinesShow = _level.mines
// 重置数组
mineArray = arrayReset(_level.cells)
showArray = arrayReset(_level.cells)
// 计算尺寸
res.size = calcStageSize(skin)
ctx.canvas.width = res.size.width
ctx.canvas.height = res.size.height
// 重绘
fullDraw(ctx, skin, res.size)
return res
}
在皮肤加载完毕后,Content.vue 会调用 initGames 函数进行游戏的初始化。initGames 会初始化相关数据和重绘界面。初始化的数据如代码所示。需要说明的应该是各函数参数部分。arrayReset 参数是一个表示 x 轴和 y 轴格子数量的数组——[x, y]。calcStageSize 函数的参数是皮肤的 key——default、handpainted。
界面重绘
/**
* canvas 整体重绘
* @param {object} ctx 绘图上下文
* @param {string} skin 皮肤
* @param {object} size 显示区宽高
*/
const fullDraw = (ctx, skin, size) => {
const _skin = skins.find(item => item.key === skin)
// 设置绘图样式
ctx.lineWidth = _skin.lineWidth
ctx.strokeStyle = _skin.lineColor
ctx.beginPath()
// 绘制线条
for (let y = 0; y <= showArray.length; y++) {
const tempY = _skin.lineWidth * (y + 1) + cellWidth * y - _skin.lineWidth / 2
ctx.moveTo(0, tempY)
ctx.lineTo(size.width, tempY)
}
for (let x = 0; x <= showArray[0].length; x++) {
const tempX = _skin.lineWidth * (x + 1) + cellWidth * x - _skin.lineWidth / 2
ctx.moveTo(tempX, 0)
ctx.lineTo(tempX, size.height)
}
ctx.stroke();
// 绘制方格
for (let y = 0; y < showArray.length; y++) {
for (let x = 0; x < showArray[0].length; x++) {
drawCell({
x,
y
}, ctx, _skin)
}
}
}
/**
* 绘制方格
* @param {object} cell {x, y}
* @param {object} ctx
* @param {object} _skin 皮肤
*/
const drawCell = (cell, ctx, _skin) => {
const {
x,
y
} = cell
const step = _skin.lineWidth + cellWidth
const xPx = x * step + _skin.lineWidth
const yPx = y * step + _skin.lineWidth
ctx.fillStyle = _skin.background;
ctx.font = `bold ${cellWidth * 0.625}px ${_skin.numberFontFamily}`
ctx.textBaseline = "middle";
ctx.textAlign = "center";
switch (showArray[y][x]) {
case 0:
// cover
ctx.drawImage(COVER, xPx, yPx, cellWidth, cellWidth);
break;
case 1:
// flag
ctx.drawImage(FLAG, xPx, yPx, cellWidth, cellWidth);
break;
case 2:
// mine
ctx.drawImage(MINE, xPx, yPx, cellWidth, cellWidth);
break;
case 3:
// boom
ctx.drawImage(BOOM, xPx, yPx, cellWidth, cellWidth);
break;
case 4:
// number
ctx.fillRect(xPx, yPx, cellWidth, cellWidth);
ctx.fillStyle = _skin.numberColor;
ctx.fillText(mineArray[y][x], xPx + cellWidth / 2, yPx + cellWidth / 2);
break;
case 5:
// background
ctx.fillRect(xPx, yPx, cellWidth, cellWidth);
break;
}
}
生成地雷
/**
* 左键点击
* @param {Event} e
* @param {object} ctx
* @param {string} skin
* @param {string} level
*/
export const onClick = (e, ctx, skin, level) => {
const cell = whichCell(e, skin)
if (cell.x === null || cell.y === null) return false
// 如果没有一个翻开的,就是第一次点击
if (showArray.every(row => row.every(cell => cell === 0))) {
mineArrayGenerate(level, cell)
}
return flip(cell, ctx, skin, level)
}
地雷的位置是用 Math.random() 生成的,应该有更科学的算法,这里就不探究了。生成雷之后需要计算各个方格显示的数字(0~8)。遍历 mineArray 这个二维数组,计算每个方格周围八个格子中类的数量。如果自身是雷,根据上面的定义,要填入数字 9。
如代码所示,生成雷的时机应该是第一次点击的时候。不能让用户第一次点击就触雷。所以 mineArrayGenerate 函数会排除点击的格子。
翻开格子
/**
* 翻开格子
* @param {object} cell
* @param {object} ctx
* @param {string} skin
* @param {string} level
* @returns {boolean|string} false: 不需要操作,number: 数字,background:空白,lose:失败,win:胜利
*/
const flip = (cell, ctx, skin, level) => {
const _skin = skins.find(item => item.key === skin)
const {
x,
y
} = cell
// 非 cover 状态
if (showArray[y][x]) {
return false;
}
// 下面是数字
if ((1 <= mineArray[y][x]) && (8 >= mineArray[y][x])) {
showArray[y][x] = 4;
drawCell(cell, ctx, _skin)
if (hasWin(level)) return 'win'
return 'number'
}
//下面是空白
if (0 == mineArray[y][x]) {
spaceCell(cell, ctx, skin, level)
if (hasWin(level)) return 'win'
return 'background'
}
//下面是地雷
if (9 == mineArray[y][x]) {
boom(cell, ctx, skin, level)
return 'lose'
}
}
翻开格子就三种状态:数字、空白、地雷。如果是数字,改变表现层数据(showArray),绘制一下翻开的格子。如果是空白,需要翻开周围的数字和连续的空白。所以 spaceCell 需要递归调用自身或 flip。如果是地雷,需要绘制所有的地雷。其中,数字和空白需要判断是否胜利,地雷直接失败。
标记格子
/**
* 右键点击
* @param {Event} e
* @param {object} ctx
* @param {string} skin
* @param {string} level
*/
export const onContentMenu = (e, ctx, skin, level) => {
const cell = whichCell(e, skin)
if (cell.x === null || cell.y === null) return false
return rightClick(cell, ctx, skin, level)
}
/**
* 右键标记
* @param {object} cell
* @param {object} ctx
* @param {string} skin
* @param {string} level
* @returns {boolean|number|object}
*/
const rightClick = (cell, ctx, skin, level) => {
const _skin = skins.find(item => item.key === skin)
// cover 状态,可以标记
if (0 == showArray[cell.y][cell.x]) {
// 如果没有可标记数量,不操作
if (restMinesShow === 0) return false
// 否则标记
showArray[cell.y][cell.x] = 1;
drawCell(cell, ctx, _skin);
restMinesShow--
// 如果是雷,restMines 减一
if (9 == mineArray[cell.y][cell.x]) {
restMines--
if (hasWin(level)) return {
win: true,
restMinesShow
}
}
return {
restMinesShow
}
} else if (1 == showArray[cell.y][cell.x]) { // 已经标记了,取消标记
showArray[cell.y][cell.x] = 0;
restMinesShow++
// 如果是雷,restMines 加一
if (9 === mineArray[cell.y][cell.x]) {
restMines++
}
drawCell(cell, ctx, _skin);
return {
restMinesShow
}
} else {
return false;
}
}
标记的时候,如果标记的是正确的雷,需要判断是否获胜。
结果判定
/**
* 是否赢了
* 如果 restMines 为 0,或者没有翻开和标记的格子数量等于此难度雷的数量,则为胜
* @param {string} level 难度
*/
export const hasWin = (level) => {
if (restMines === 0) return true
const _level = levels.find(item => item.key === level)
if (showArray.reduce((prev, cur) => prev + cur.reduce((subPrev, subCur) => subPrev + (subCur === 0 || subCur === 1 ? 1 : 0), 0), 0) === _level.mines) return true
return false
}
其他功能
PWA 支持
之前有写过一篇 PWA 支持的文章——网站添加 pwa 支持。那篇文章是不用相关插件的情况。而此次重构是用 vue-cli3 搭建的新项目,所以可以直接使用相关插件。
vue add @vue/pwa。执行这个命令后项目下会多出来一些文件。之后把 public/img/icons 里面的图片替换成项目 logo,尺寸名称不变。
pwa 的部分配置可以在 vue ui 命令打开的图形界面里操作,也可以在 vue.config.js 里配置。——@vue/cli-plugin-pwa
pwa: {
name: '扫雷',
themeColor: '#648CBA',
msTileColor: '#E0CCCC',
workboxOptions: {
skipWaiting: true
},
manifestOptions: {
background_color: '#E0CCCC'
}
},
为了让网站更新后即时生效,需要对 registerServiceWorker.js 做些改变:
updated () {
console.log('New content is available; please refresh.')
window.location.reload(true)
},
这样简单配置之后 pwa 的支持就算完成了。当然,这里只是初级的支持,更高级的功能并未使用。不过我只要能安装就可以了。——实际上还可以离线使用。
注意事项
- vue 组件销毁自身可以调用 this.$destroy() 实现,但是渲染的 DOM 不会消除。