案例——《minesweeper(扫雷)》解析

当年刚学完 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 不会消除。