写个扫雷来玩玩

1,907 阅读4分钟

试玩地址

介绍

《扫雷》是一款大众类的益智小游戏,游戏目标是在最短的时间内根据点击格子出现的数字找出所有非雷格子,同时避免踩雷,踩到一个雷即全盘皆输

所以在这个游戏的基本规则之下,用原生 JavaScript 手撸了一个相似的游戏版本。

游戏设计

  • 采用 canvas 作为游戏设计基础
  • 绘制 20 x 30 个格子的区域
  • 对于地雷的随机生成,采用的是随机数的生成方式,生成几率为 2 成
  • 游戏开始的时候,随机选一个空白区域作为起始点
  • 鼠标事件处理:左键是打开单元格,右键是标记单元格,再次右键是取消标记。
  • 已经标记的单元格不能左键打开,必须要先取消标记
  • 当左键点到地雷时,游戏结束,然后进行结束处理:
    • 所有未标记的地雷全部显示出来
    • 所有标记错误的单元格,重新标记红色的 X
    • 对于正确的地雷标记不做处理
  • 当所有非地雷单元格都点开时,游戏结束

当前设计没有完成的点,就是计时、双击与地雷数的统计显示,当然这也不影响游戏设计。

思路讲解

先建立模板:确定必要常量
<div class="main">
  <h3>扫雷 <span class="restart" onclick="mineSweeper.restart()">重新开始</span></h3>
  <div class="container">
    <div class="mask"></div>
    <canvas id="canvas"></canvas>
  </div>
</div>

<style>
  .main {
    width: 1000px;
    margin: 0 auto;
  }
  .restart {
    cursor: pointer;
    font-size: 16px;
    margin-left: 20px;
  }
  .container {
    width: 752px;
    padding: 20px;
    background-color: #ccc;
    border-radius: 5px;
    position: relative;
  }
  .mask {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 100;
    display: none;
  }
  #canvas {
    background-color: #bbb;
  }
</style>
<script>
  class MineSweeper {
    constructor() {
      this.canvas = document.querySelector('#canvas')
      this.mask = document.querySelector('.mask')
      this.ctx = canvas.getContext('2d')

      this.row = 20 // 行数
      this.col = 30 // 列数
      this.cellWidth = 25  // 单元格宽度
      this.cellHeight = 25 // 单元格高度

      this.width = this.col * this.cellWidth
      this.height = this.row * this.cellHeight

      this.canvas.width = this.width
      this.canvas.height = this.height
      // 不同数字使用的不同颜色
      this.colors = [
        '#FF7F00',
        '#00FF00',
        '#FF0000',
        '#00FFFF',
        '#0000FF',
        '#8B00FF',
        '#297b83',
        '#0b0733'
      ]
      this.mineTotal = 0 // 地雷总数
      this.cellTotal = this.row * this.col // 单元格总数
      // 方向数组
      this.direction = [
        -this.col,      // 上
        this.col,       // 下
        -1,             // 左
        1,              // 右
        -this.col - 1,  // 左上
        this.col - 1,   // 左下
        -this.col + 1,  // 右上
        this.col + 1    // 右下
      ]
    }
  }
  new MineSweeper()
</script>
绘制网格线
drawLine() {
  const { ctx, row, col, width, height, cellWidth, cellHeight } = this
  for (let i = 0; i <= row; i++) {
    ctx.moveTo(0, i * cellWidth)
    ctx.lineTo(width, i * cellWidth)
  }
  for (let i = 0; i <= col; i++) {
    ctx.moveTo(i * cellHeight, 0)
    ctx.lineTo(i * cellHeight, height)
  }
  ctx.lineWidth = 3
  ctx.strokeStyle = '#ddd'
  ctx.stroke()
}

到这里,初始模板已经出来了,简单吧!下面就是生成数据了:

生成地雷数据

生成一个 row * col 长度包含地雷数据的数组,并计算每个非地雷单元格周围的地雷数。地雷生成的同事,需要一个状态数组(mark)来记录每个单元格的状态(已打开,未打开,已标记)

restart() {
  this.mineTotal = 0
  this.cellTotal = this.row * this.col
  this.mask.style.display = 'none'
  // -1 为地雷
  this.datas = new Array(this.cellTotal).fill(0).map(v => {
    if (Math.random() > 0.8) {
      this.mineTotal++
      return -1
    }
    return 0
  })
  this.mark = new Array(this.cellTotal).fill(0) // 0 为未点开, 1 为点开, 2 为已标记
  this.calcRound()
}
// 计算地雷附近的提示数字
calcRound() {
  const { datas, direction } = this
  
  for (let i = 0; i < datas.length; i++) {
    if (datas[i] !== -1) {
      for (let d of direction) {
        const newIndex = i + d
        // 边界判断
        if (this.isCross(i, newIndex, d, this.col, datas.length)) continue

        if (datas[newIndex] === -1) {
          datas[i]++
        }
      }
    }
  }
}
计算鼠标位置

我们需要在 canvas 元素内的点击时,得到当前位置是处于数组的索引下标

// 在 constructor 方法内定义鼠标的点击和右键
constructor() {
  // ...
  this.restart()

  // 监听点击事件
  this.canvas.addEventListener('click', (e) => {
    const cellIndex = this.calcMouseCell(e)
    const cellValue = this.datas[cellIndex]
    // 已标记和已标记的单元格都不能点击
    if (this.mark[cellIndex] === 0) {
      if (cellValue === -1) {
        this.gameOver(cellIndex)
      } else {
        // 如果当前单元格的值为 0,则将周围的8个方向未点开的单元格都自定点开,并且递归
        if (cellValue === 0) {
          this.openCell(cellIndex)
        } else {
          this.mark[cellIndex] = 1
          this.cellTotal--
        }
        this.draw()
      }
    }
  })

  // 自定义canvas右键,用来标记和解除标记
  this.canvas.oncontextmenu = (e) => {
    if (e.button === 2) {
      const cellIndex = this.calcMouseCell(e)
      // 判断是否已经点开,没点开就标记
      if (this.mark[cellIndex] === 1) {
        return false
      }
      if (this.mark[cellIndex] === 0) {
        this.mark[cellIndex] = 2
      } else {
        this.mark[cellIndex] = 0
      }
      this.draw()
    }
    return false
  }
}
// 根据鼠标事件得到点击位置
calcMouseCell(e) {
  const row = e.offsetY / this.cellHeight | 0
  const col = e.offsetX / this.cellWidth | 0
  return row * this.col + col
}

当点到单元格为 0 的时候,周围的单元格一定没有地雷,做自动打开,且进行重新绘制

// 递归打开数字为 0 的单元格的相邻单元格
openCell(cellIndex) {
  const { datas, mark, direction } = this
  mark[cellIndex] = 1
  this.cellTotal--

  for (let d of direction) {
    const newIndex = cellIndex + d
    // 判断是否越界
    if (
      this.isCross(cellIndex, newIndex, d, this.col, datas.length) ||
      mark[newIndex] !== 0
    ) continue

    if (datas[newIndex] === 0) {
      this.openCell(newIndex)
    } else {
      mark[newIndex] = 1
      this.cellTotal--
    }
  }
}
// 单元格的边界判断
isCross(oriIndex, newIndex, d, col, maxLength) {
  if (
    newIndex < 0 || // 上边界
    newIndex >= maxLength || // 下边界
    (oriIndex % col === 0 && (d === -1 || d === -31 || d === 29)) || // 左边界
    (oriIndex % col === col - 1 && (d === 1 || d === -29 || d === 31)) // 右边界
  ) {
    return true
  }
  return false
}

在点击为 0 的单元格周围自动展开的方法已经完成,所以我们可以在开始的方法内加上开始时自动随机选择一个空白区域自动展开:

restart() {
  this.mineTotal = 0
  this.cellTotal = this.row * this.col
  this.mask.style.display = 'none'
  // -1 为地雷
  this.datas = new Array(this.cellTotal).fill(0).map(v => {
    if (Math.random() > 0.8) {
      this.mineTotal++
      return -1
    }
    return 0
  })
  this.mark = new Array(this.cellTotal).fill(0) // 0 为未点开, 1 为点开, 2 为已标记
  this.calcRound()

  // 随机选择一个 0 的位置进行自动点开,作为起始位置
  let randomIndex = Math.random() * this.datas.length | 0
  while(this.datas[randomIndex] !== 0) {
    randomIndex = Math.random() * this.datas.length | 0
  }
  this.openCell(randomIndex)
  
  this.draw()
}

绘制方法: 循环含有地雷数据的数组,因为这个数组是一个一维数组,二绘制的区域是一个二维数组格式,所以我们需要用到 cellWidthcellHeight 的值来进行坐标转换。而绘制单元格需要结合状态数组来进行不同情况的绘制:

  • 当 mark[i] === 0 时,不做处理
  • 当 mark[i] === 1 时,表示当前单元格已经打开,需要改变当前单元格的背景颜色,并绘制不为 0 的数字
  • 当 mark[i] === 2 时,表示当前单元格被标记,这里为了方便我只加上了一个 ? 来代替
// 绘制地雷数据
drawMine() {
  const { datas, ctx, mark, cellWidth, cellHeight, col } = this
  ctx.font = 'bold 18px "微软雅黑"'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'top'
  for (let i = 0; i < datas.length; i++) {
    if (mark[i] === 0) continue

    const rowIndex = (i / col | 0) * cellWidth
    const colIndex = i % col * cellHeight
    if (mark[i] === 1) {
      ctx.fillStyle = '#eee'
      ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
      // 不为 0 才显示数字
      if (datas[i]) {
        ctx.fillStyle = this.colors[datas[i] - 1]
        ctx.fillText(datas[i], colIndex + 12, rowIndex + 6)
      }
    } else { // 标记
      ctx.fillStyle = '#000'
      ctx.fillText('?', colIndex + 12, rowIndex + 6)
    }
  }
}

因为该游戏的绘制,数据和网格线要一起重绘,所以定一个统一的方法进行执行

// 绘制
draw() {
  this.canvas.height = this.height // 清空画布
  this.drawLine()
  this.drawMine()
}
游戏结束

1、当点到地雷时,游戏结束

/**
 * 游戏结束将所有的地雷显示
 * 1、正确的标记不动
 * 2、将错误的标记打为红色的 X
 * 3、将未标记的地雷显示 *
 * 4、最后点击的地雷cell标记为红色背景
 */
gameOver(lastCell) {
  this.canvas.height = this.height
  this.drawLine()
  const { datas, ctx, mark, cellWidth, cellHeight, col } = this

  ctx.font = 'bold 18px "微软雅黑"'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'top'
  
  for (let i = 0; i < datas.length; i++) {
    const rowIndex = (i / col | 0) * cellWidth
    const colIndex = i % col * cellHeight

    if (mark[i] === 1) {
      ctx.fillStyle = '#eee'
      ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
      if (datas[i]) {
        ctx.fillStyle = this.colors[datas[i] - 1]
        ctx.fillText(datas[i], colIndex + 12, rowIndex + 6)
      }
    } else {
      if (datas[i] === -1) {
        ctx.fillStyle = '#000'
        if (mark[i] === 2) {
          ctx.fillText('?', colIndex + 12, rowIndex + 6)
        } else {
          if (i === lastCell) {
            ctx.fillStyle = 'red'
            ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
            ctx.fillStyle = '#000'
            ctx.fillText('*', colIndex + 13, rowIndex + 9)
          } else {
            ctx.fillText('*', colIndex + 13, rowIndex + 9)
          }
        }
      } else if (mark[i] === 2) {
        ctx.fillStyle = 'red'
        ctx.fillText('x', colIndex + 12, rowIndex + 4)
      }
    }
  }
  this.mask.style.display = 'block'
}

2、当所有非地雷格子全部打开,游戏完成

// 是否完成
isComplete() {
  // 剩余未点开的单元格等于地雷总数时
  return this.cellTotal === this.mineTotal
}

// 将该判断加载绘制完成方法最后
draw() {
  this.canvas.height = this.height
  this.drawLine()
  this.drawMine()

  if (this.isComplete()) {
    // 加setTimeout是因为要等canvas渲染完成
    setTimeout(() => {
      this.mask.style.display = 'block'
      alert('游戏完成')
    })
  }
}

到这里扫雷的主要功能呢完成了,代码量也不是很多,全部代码也就 300 多一点点。

github.com/554246839/t…

完整代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>扫雷</title>
  <style>
    .main {
      width: 1000px;
      margin: 0 auto;
    }
    .restart {
      cursor: pointer;
      font-size: 16px;
      margin-left: 20px;
    }
    .container {
      width: 752px;
      padding: 20px;
      background-color: #ccc;
      border-radius: 5px;
      position: relative;
    }
    .mask {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      z-index: 100;
      display: none;
    }
    #canvas {
      background-color: #bbb;
    }
  </style>
</head>
<body>
  <div class="main">
    <h3>扫雷 <span class="restart" onclick="mineSweeper.restart()">重新开始</span></h3>
    <div class="container">
      <div class="mask"></div>
      <canvas id="canvas"></canvas>
    </div>
  </div>

  <script>
    class MineSweeper {
      constructor() {
        this.canvas = document.querySelector('#canvas')
        this.mask = document.querySelector('.mask')
        this.ctx = canvas.getContext('2d')

        this.row = 20
        this.col = 30
        this.cellWidth = 25
        this.cellHeight = 25

        this.width = this.col * this.cellWidth
        this.height = this.row * this.cellHeight

        this.canvas.width = this.width
        this.canvas.height = this.height
        // 不同数字使用的不同颜色
        this.colors = [
          '#FF7F00',
          '#00FF00',
          '#FF0000',
          '#00FFFF',
          '#0000FF',
          '#8B00FF',
          '#297b83',
          '#0b0733'
        ]
        this.mineTotal = 0 // 地雷总数
        this.cellTotal = this.row * this.col // 单元格总数
        // 方向数组
        this.direction = [
          -this.col,      // 上
          this.col,       // 下
          -1,             // 左
          1,              // 右
          -this.col - 1,  // 左上
          this.col - 1,   // 左下
          -this.col + 1,  // 右上
          this.col + 1    // 右下
        ]

        this.restart()

        // 监听点击事件
        this.canvas.addEventListener('click', (e) => {
          const cellIndex = this.calcMouseCell(e)
          const cellValue = this.datas[cellIndex]
          // 已标记和已标记的单元格都不能点击
          if (this.mark[cellIndex] === 0) {
            if (cellValue === -1) {
              this.gameOver(cellIndex)
            } else {
              // 如果当前单元格的值为 0,则将周围的8个方向未点开的单元格都自定点开,并且递归
              if (cellValue === 0) {
                this.openCell(cellIndex)
              } else {
                this.mark[cellIndex] = 1
                this.cellTotal--
              }
              this.draw()
            }
          }
        })

        // 自定义canvas右键,用来标记和解除标记
        this.canvas.oncontextmenu = (e) => {
          if (e.button === 2) {
            const cellIndex = this.calcMouseCell(e)
            // 判断是否已经点开,没点开就标记
            if (this.mark[cellIndex] === 1) {
              return false
            }
            if (this.mark[cellIndex] === 0) {
              this.mark[cellIndex] = 2
            } else {
              this.mark[cellIndex] = 0
            }
            this.draw()
          }
          return false
        }
      }

      restart() {
        this.mineTotal = 0
        this.cellTotal = this.row * this.col
        this.mask.style.display = 'none'
        // -1 为地雷
        this.datas = new Array(this.cellTotal).fill(0).map(v => {
          if (Math.random() > 0.8) {
            this.mineTotal++
            return -1
          }
          return 0
        })
        this.mark = new Array(this.cellTotal).fill(0) // 0 为未点开, 1 为点开, 2 为已标记
        this.calcRound()

        // 随机选择一个 0 的位置进行自动点开,作为起始位置
        let randomIndex = Math.random() * this.datas.length | 0
        while(this.datas[randomIndex] !== 0) {
          randomIndex = Math.random() * this.datas.length | 0
        }
        this.openCell(randomIndex)
        
        this.draw()
      }

      // 递归打开数字为 0 的单元格的相邻单元格
      openCell(cellIndex) {
        const { datas, mark, direction } = this
        mark[cellIndex] = 1
        this.cellTotal--

        for (let d of direction) {
          const newIndex = cellIndex + d
          if (this.isCross(cellIndex, newIndex, d, this.col, datas.length) || mark[newIndex] !== 0) continue

          if (datas[newIndex] === 0) {
            this.openCell(newIndex)
          } else {
            mark[newIndex] = 1
            this.cellTotal--
          }
        }
      }

      // 根据鼠标事件得到点击位置
      calcMouseCell(e) {
        const row = e.offsetY / this.cellHeight | 0
        const col = e.offsetX / this.cellWidth | 0
        return row * this.col + col
      }

      // 计算地雷附近的提示数字
      calcRound() {
        const { datas, direction } = this
        
        for (let i = 0; i < datas.length; i++) {
          if (datas[i] !== -1) {
            for (let d of direction) {
              const newIndex = i + d
              // 边界判断
              if (this.isCross(i, newIndex, d, this.col, datas.length)) continue

              if (datas[newIndex] === -1) {
                datas[i]++
              }
            }
          }
        }
      }

      // 单元格的边界判断
      isCross(oriIndex, newIndex, d, col, maxLength) {
        if (
          newIndex < 0 || // 上边界
          newIndex >= maxLength || // 下边界
          (oriIndex % col === 0 && (d === -1 || d === -31 || d === 29)) || // 左边界
          (oriIndex % col === col - 1 && (d === 1 || d === -29 || d === 31)) // 右边界
        ) {
          return true
        }
        return false
      }

      // 绘制
      draw() {
        this.canvas.height = this.height
        this.drawLine()
        this.drawMine()

        if (this.isComplete()) {
          setTimeout(() => {
            this.mask.style.display = 'block'
            alert('游戏完成')
          })
        }
      }

      // 绘制地雷数据
      drawMine() {
        const { datas, ctx, mark, cellWidth, cellHeight, col } = this
        ctx.font = 'bold 18px "微软雅黑"'
        ctx.textAlign = 'center'
        ctx.textBaseline = 'top'
        for (let i = 0; i < datas.length; i++) {
          if (mark[i] === 0) continue

          const rowIndex = (i / col | 0) * cellWidth
          const colIndex = i % col * cellHeight

          if (mark[i] === 1) {
            ctx.fillStyle = '#eee'
            ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
            // 不为 0 才显示数字
            if (datas[i]) {
              ctx.fillStyle = this.colors[datas[i] - 1]
              ctx.fillText(datas[i], colIndex + 12, rowIndex + 6)
            }
          } else {
            ctx.fillStyle = '#000'
            ctx.fillText('?', colIndex + 12, rowIndex + 6)
          }
        }
      }

      // 绘制网格线
      drawLine() {
        const { ctx, row, col, width, height, cellWidth, cellHeight } = this
        for (let i = 0; i <= row; i++) {
          ctx.moveTo(0, i * cellWidth)
          ctx.lineTo(width, i * cellWidth)
        }
        for (let i = 0; i <= col; i++) {
          ctx.moveTo(i * cellHeight, 0)
          ctx.lineTo(i * cellHeight, height)
        }
        ctx.lineWidth = 3
        ctx.strokeStyle = '#ddd'
        ctx.stroke()
      }
      
      // 是否完成
      isComplete() {
        return this.cellTotal === this.mineTotal
      }

      /**
       * 游戏结束将所有的地雷显示
       * 1、正确的标记不动
       * 2、将错误的标记打为红色的 X
       * 3、将未标记的地雷显示 *
       * 4、最后点击的地雷cell标记为红色背景
       */
      gameOver(lastCell) {
        this.canvas.height = this.height
        this.drawLine()
        const { datas, ctx, mark, cellWidth, cellHeight, col } = this

        ctx.font = 'bold 18px "微软雅黑"'
        ctx.textAlign = 'center'
        ctx.textBaseline = 'top'
        
        for (let i = 0; i < datas.length; i++) {
          const rowIndex = (i / col | 0) * cellWidth
          const colIndex = i % col * cellHeight

          if (mark[i] === 1) {
            ctx.fillStyle = '#eee'
            ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
            if (datas[i]) {
              ctx.fillStyle = this.colors[datas[i] - 1]
              ctx.fillText(datas[i], colIndex + 12, rowIndex + 6)
            }
          } else {
            if (datas[i] === -1) {
              ctx.fillStyle = '#000'
              if (mark[i] === 2) {
                ctx.fillText('?', colIndex + 12, rowIndex + 6)
              } else {
                if (i === lastCell) {
                  ctx.fillStyle = 'red'
                  ctx.fillRect(colIndex + 2, rowIndex + 2, cellWidth - 4, cellHeight - 4)
                  ctx.fillStyle = '#000'
                  ctx.fillText('*', colIndex + 13, rowIndex + 9)
                } else {
                  ctx.fillText('*', colIndex + 13, rowIndex + 9)
                }
              }
            } else if (mark[i] === 2) {
              ctx.fillStyle = 'red'
              ctx.fillText('x', colIndex + 12, rowIndex + 4)
            }
          }
        }
        this.mask.style.display = 'block'
      }
    }
    var mineSweeper = new MineSweeper()
  </script>
</body>
</html>