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 不会消除。