纯Canvas实现扫雷小游戏

403 阅读1分钟

看代码注释

完整代码:

<!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>mineSweeper</title>
  <style>
    * {
      padding: 0;
      margin: 0;
    }

    body {
      display: flex;
    }

    #main {
      margin: 20px auto;
    }
  </style>
</head>

<body>
  <div id="main"></div>
  <script>

    class Cell {
      constructor(id, position, x, y, size, background, textColor, text, type) {
        this.id = id;
        this.position = position;
        this.x = x;
        this.y = y;
        this.size = size; // 尺寸
        this.background = background; // 背景色
        this.textColor = textColor;// 文本颜色
        this.text = text; // 文本: 0附近没有雷,不显示
        this.type = type; // 0: 雷  1: 非雷
        this.status = 0; // 0: 未打开  1: 打开  2: 标记  3: 疑问
      }
    }

    class MineSweeper {
      el; // 容器
      canvas = document.createElement('canvas'); // 画布
      ctx = this.canvas.getContext('2d'); // 画笔

      canvasSize = [200, 280]; // 画布尺寸
      header = 80; // 头部尺寸
      size = [10, 10]; // 游戏格子个数
      cellSize = 20; // 格子尺寸 -- 这里会在运行时重新计算
      mineNumber = 10; // 雷数量

      // 不同数字使用的不同颜色
      textColors = [
        '#FF7F00',
        '#00aa00',
        '#aa0000',
        '#00dddd',
        '#0000FF',
        '#8B00FF',
        '#307b80',
        '#0b0733'
      ];

      gamePadding = 2; // 游戏内边距
      cellMargin = 2; // 每个格子的外边距

      background = 'rgb(99,99,99)'; // 背景色
      cellBackground = 'rgb(170,180,210)'; // 未打开的格子背景
      openBackground = 'rgb(215,225,255)'; // 打开的格子的背景
      mineBackground = 'rgb(100,110,140)'; // 雷的背景
      mineTextColor = 'rgb(255,255,255)'; // 标识雷的文本的颜色

      headerBackground = 'rgb(230, 230, 230)'; // 头部控制区背景
      headerPadding = 10; // 头部控制区内边距
      titleColor = 'rgb(0,0,0)'; // 头部文本颜色
      timerColor = 'rgb(200,0,0)'; // 计时器颜色
      playTimeText = '00:00:00'; // 计时器显示文本
      playTime = 0; // 本局游戏游玩时间
      startTime = 0; // 本局游戏开始时间
      cells = []; // 格子数组
      mines = []; // 雷数组
      openSum = 0; // 打开的格子个数
      gameStatus = 0; // 0: 未开始  1: 开始  2: 结束  3: 成功
      markNum = 0; // 标记为雷的个数
      startButtonSize = { // 开始按钮定位
        id: 'start',
        x: 94,
        y: 23,
        width: 45,
        height: 20
      }
      resetButtonSize = { // 重置按钮定位
        id: 'reset',
        x: 143,
        y: 23,
        width: 45,
        height: 20
      }
      rowAddButtonSize = { // 行增加按钮定位
        id: 'rowAdd',
        x: 43,
        y: 8,
        width: 12,
        height: 12
      }
      rowSubtractButtonSize = { // 行减少按钮定位
        id: 'rowSubtract',
        x: 73,
        y: 8,
        width: 12,
        height: 12
      }
      colAddButtonSize = { // 列增加按钮定位
        id: 'colAdd',
        x: 43,
        y: 28,
        width: 12,
        height: 12
      }
      colSubtractButtonSize = { // 列减少按钮定位
        id: 'colSubtract',
        x: 73,
        y: 28,
        width: 12,
        height: 12
      }
      mineAddButtonSize = { // 雷增加按钮定位
        id: 'mineAdd',
        x: 133,
        y: 8,
        width: 12,
        height: 12
      }
      mineSubtractButtonSize = { // 雷减少按钮定位
        id: 'mineSubtract',
        x: 185,
        y: 8,
        width: 12,
        height: 12
      }

      startButtonColor = 'rgb(0,200,0)'; // 开始按钮文本颜色
      resetButtonColor = 'rgb(200,0,0)'; // 重置按钮文本颜色

      buttons = [ // 全部按钮
        this.startButtonSize,
        this.resetButtonSize,
        this.rowAddButtonSize,
        this.rowSubtractButtonSize,
        this.colAddButtonSize,
        this.colSubtractButtonSize,
        this.mineAddButtonSize,
        this.mineSubtractButtonSize
      ];
      hover; // 高亮的按钮
      hoverColor = 'rgba(0,0,0, 0.2)'; // 按钮hover背景

      constructor(el) {
        this.el = el;
        this.init();
      }

      init() {
        this.initCanvas();
        this.el.appendChild(this.canvas);
        this.initData();
        this.initEvent();
        this.ani();
      }

      // 初始化canvas
      initCanvas() {
        this.canvas.width = this.canvasSize[0];
        this.canvas.height = this.canvasSize[1];
        this.canvas.style.background = this.background;
        this.cellSize = (this.canvas.width - this.gamePadding * 2 - (this.size[0] - 1) * this.cellMargin) / this.size[0];
      }

      // 初始化格子
      initData() {
        for (let i = 0; i < this.size[0]; i++) {
          this.cells[i] = [];
          for (let j = 0; j < this.size[1]; j++) {
            this.cells[i][j] = new Cell(
              i + j,
              [i, j],
              this.gamePadding + i * (this.cellSize + this.cellMargin),
              this.gamePadding + j * (this.cellSize + this.cellMargin) + this.header,
              this.cellSize,
              this.cellBackground,
              '#000',
              0, // ✳
              1
            );
          }
        }
        this.randomMine(); // 布置雷位置
      }

      randomMine() {
        let num = 0;
        this.mines = [];
        while (num < this.mineNumber) { // 根据雷个数布置雷
          const i = this.random(this.size[0] - 1);
          const j = this.random(this.size[1] - 1);
          const cell = this.cells[i][j];
          if (cell.type) {
            cell.type = 0;
            cell.text = '✳';
            cell.textColor = this.mineTextColor;
            this.mines.push(cell);
            num++;
          }
        }

        this.mines.forEach(item => {
          this.calcRound(item); // 设置雷旁边格子的文本
        });
      }

      calcRound(mine) {
        const [i, j] = mine.position; // 雷坐标
        this.cross(i, j, (i, j) => { // 雷一圈的格子坐标
          if (this.cells[i][j].type !== 0) {
            let num = 0;
            this.cross(i, j, (i, j) => { // 判断周围雷个数
              this.cells[i][j].type === 0 && num++;
            });
            this.cells[i][j].text = num; // 设置文本
            this.cells[i][j].textColor = this.textColors[num - 1]; // 设置文本颜色
          }
        });
      }

      random(max, min = 0) {
        return ~~(Math.random() * (max - min + 1) + min);
      }

      initEvent() {
        this.canvas.addEventListener('click', (e) => {
          if (this.gameStatus === 1) {
            const cell = this.getClickCell(e); // 获取点中的格子
            if (cell && cell.status === 0) {
              this.openCell(cell); // 点中了格子并且格子状态为未打开
            }
          }
        });
        this.canvas.addEventListener('contextmenu', (e) => {
          e.preventDefault();
          if (this.gameStatus === 1) {
            const cell = this.getClickCell(e);// 获取点中的格子
            if (cell) {
              if (cell.status === 0) { // 未打开设置成标记
                cell.status = 2;
                this.markNum++;
              } else if (cell.status === 2) { // 标记状态设置成疑问
                cell.status = 3;
                this.markNum--;
              } else if (cell.status === 3) { // 疑问状态设置为未打开
                cell.status = 0;
              }
            }
          }
        });

        this.canvas.addEventListener('click', (e) => {
          if (this.hover) { // 根据点击的按钮进行相关交互
            switch (this.hover.id) {
              case 'start':
                this.start();
                break;
              case 'reset':
                this.reset();
                break;
              case 'colAdd':
                if (this.size[0] < 50) {
                  this.size[0] = this.size[0] + 10;
                }
                this.changeSize(...this.size);
                break;
              case 'colSubtract':
                if (this.size[0] > 10) {
                  this.size[0] = this.size[0] - 10;
                }
                this.changeSize(...this.size);
                break;

              case 'rowAdd':
                if (this.size[1] < 50) {
                  this.size[1] = this.size[1] + 10;
                }
                this.changeSize(...this.size);
                break;
              case 'rowSubtract':
                if (this.size[1] > 10) {
                  this.size[1] = this.size[1] - 10;
                }
                this.changeSize(...this.size);
                break;
              case 'mineAdd':
                if (this.mineNumber < this.size[0] * this.size[1] * 0.6) {
                  this.mineNumber += 10;
                }
                this.changeSize(...this.size);
                break;
              case 'mineSubtract':
                if (this.mineNumber > 10) {
                  this.mineNumber -= 10;
                }
                this.changeSize(...this.size);
                break;
              default:
                break;
            }
          }
        });

        // 判断鼠标是否移动到按钮上
        this.canvas.addEventListener('mousemove', (e) => {
          const { offsetX, offsetY } = e;
          let i = this.buttons.length;
          this.hover = null;
          while (i--) {
            const button = this.buttons[i];
            if (
              offsetX > button.x
              && offsetX < button.x + button.width
              && offsetY > button.y
              && offsetY < button.y + button.height
            ) {
              this.hover = button;
              break;
            }
          }
        });
      }

      // 获取鼠标当前位置是否在格子上
      getClickCell(e) {
        const { offsetX, offsetY } = e;
        const i = ~~((offsetX - this.gamePadding + this.cellMargin) / (this.cellSize + this.cellMargin));
        const lastI = this.gamePadding + i * (this.cellSize + this.cellMargin);
        const j = ~~((offsetY - this.gamePadding + this.cellMargin - this.header) / (this.cellSize + this.cellMargin));
        const lastJ = this.gamePadding + j * (this.cellSize + this.cellMargin) + this.header;
        if (offsetX > lastI && offsetY > lastJ) {
          return this.cells[i][j];
        }
      }

      // 打开格子
      openCell(cell) {
        if (cell.status !== 0) { // 不是未打开状态无法打开
          return;
        }
        cell.status = 1;
        cell.background = this.openBackground;
        this.openSum++;
        if (cell.text) {
          if (cell.type === 0) { // 雷结束 -- 点中了
            this.gameOver(cell);
          }
        } else {
          // 开8个方向格子 -- 待优化
          const [i, j] = cell.position;
          this.cross(i, j, (i, j) => this.openCell(this.cells[i][j]));
        }
      }

      gameOver(cell) {
        this.gameStatus = 2;
        console.log('Game Over');
        this.mines.forEach(item => { // 游戏结束显示全部的雷
          item.status = 1;
          item.background = this.mineBackground;
        });
        if (cell) { // 被点中的雷背景变红
          cell.background = '#a00';
          cell.textColor = '#fff';
        } else {
          console.log('Timeout');
        }
      }

      // 边界判断--格子周围的八个格子--有格子则执行回调
      cross(i, j, callback) {
        j - 1 >= 0 && callback(i, j - 1); // 上
        j + 1 < this.size[1] && callback(i, j + 1); // 下
        if (i - 1 >= 0) {
          callback(i - 1, j); // 左
          j - 1 >= 0 && callback(i - 1, j - 1); // 左上
          j + 1 < this.size[1] && callback(i - 1, j + 1); // 左下
        }
        if (i + 1 < this.size[0]) {
          callback(i + 1, j); // 右
          j - 1 >= 0 && callback(i + 1, j - 1); // 右上
          j + 1 < this.size[1] && callback(i + 1, j + 1); // 右下
        }
      }

      draw() {
        this.drawHeader();
        for (let i = 0; i < this.size[0]; i++) {
          for (let j = 0; j < this.size[1]; j++) {
            const cell = this.cells[i][j];
            this.drawCell(cell);
            switch (cell.status) { // 根据格子状态绘制格子
              case 1:
                if (cell.type) {
                  if (cell.text) {
                    this.drawText(cell);
                  }
                } else {
                  this.drawCell(cell);
                  this.drawText(cell);
                }
                break;
              case 2:
                if (cell.type === 1 && this.gameStatus === 2) {
                  this.drawError(cell); // 标记错误的格子
                } else {
                  this.drawMark(cell); // 标记的格子
                }
                break;
              case 3:
                this.drawQuestionMark(cell); // 疑问状态的格子
                break;
              default:
                break;
            }
          }
        }
        if (this.gameStatus === 3) { // 扫雷成功
          this.drawSuccess();
        }
      }

      drawHeader() {

        // 背景色
        this.ctx.beginPath();
        this.ctx.fillStyle = this.headerBackground;
        this.ctx.fillRect(0, 0, this.canvasSize[0], this.header);
        this.ctx.closePath();


        if (this.hover) {
          const { x, y, width, height } = this.hover;
          this.ctx.fillStyle = this.hoverColor;
          this.ctx.fillRect(x, y, width, height);
          this.canvas.style.cursor = 'pointer';
        } else {
          this.canvas.style.cursor = 'default';
        }

        // row title
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'right';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('row:', this.headerPadding + 30, 15);
        this.ctx.closePath();

        // row ++
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('+', this.headerPadding + 35, 15);
        this.ctx.closePath();

        // row
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(this.size[1], this.headerPadding + 45, 15);
        this.ctx.closePath();

        // row --
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('-', this.headerPadding + 65, 15);
        this.ctx.closePath();

        // col title
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'right';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('col:', this.headerPadding + 30, 35);
        this.ctx.closePath();

        // col ++
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('+', this.headerPadding + 35, 35);
        this.ctx.closePath();

        // col
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(this.size[0], this.headerPadding + 45, 35);
        this.ctx.closePath();

        // col --
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('-', this.headerPadding + 65, 35);
        this.ctx.closePath();

        // mine title
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('mine:', this.headerPadding + 80, 15);
        this.ctx.closePath();

        // mine ++
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('+', this.headerPadding + 125, 15);
        this.ctx.closePath();

        // mine
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'center';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(this.mineNumber, this.headerPadding + 155, 15);
        this.ctx.closePath();

        // mine --
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.titleColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('-', this.headerPadding + 177, 15);
        this.ctx.closePath();

        // start
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.startButtonColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('Start', this.headerPadding + 85, 35);
        this.ctx.closePath();

        // reset
        this.ctx.beginPath();
        this.ctx.font = `16px cursive`;
        this.ctx.fillStyle = this.resetButtonColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('Reset', this.headerPadding + 135, 35);
        this.ctx.closePath();

        // timer
        this.ctx.beginPath();
        this.ctx.font = `20px cursive`;
        this.ctx.fillStyle = this.timerColor;
        this.ctx.textAlign = 'left';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(this.playTimeText, this.headerPadding + 45, 60);
        this.ctx.closePath();
      }

      drawSuccess() {
        // 画成功图层
        const x = this.canvasSize[0] / 2;
        const y = this.canvasSize[1] / 2 + 0.5 * this.header;

        this.ctx.beginPath();
        this.ctx.fillStyle = 'rgba(0,0,0,0.1)';
        this.ctx.fillRect(0, this.header, this.canvasSize[0], this.canvasSize[1] - this.header);
        this.ctx.closePath();

        this.ctx.beginPath();
        this.ctx.arc(x, y, 80, 0, Math.PI * 2);
        this.ctx.fillStyle = 'rgba(0,0,0,0.2)';
        this.ctx.fill();
        this.ctx.closePath();

        this.ctx.beginPath();
        this.ctx.fillStyle = 'rgba(255,255,255,1)';
        this.ctx.textAlign = 'center';
        this.ctx.textBaseline = 'middle';
        this.ctx.font = '30px Verdana';
        this.ctx.fillText('Success', x, y);
        this.ctx.closePath();
      }

      // 画格子
      drawCell(cell) {
        const { x, y, size, background } = cell;
        this.ctx.beginPath();
        this.ctx.fillStyle = background;
        this.ctx.fillRect(x, y, size, size);
        this.ctx.closePath();
      }

      // 画文字
      drawText(cell) {
        const { text, x, y, size, textColor } = cell;
        this.ctx.beginPath();
        this.ctx.font = `${size * 0.7}px Verdana`;
        this.ctx.fillStyle = textColor;
        this.ctx.textAlign = 'center';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText(text, x + size / 2, y + size / 2);
        this.ctx.closePath();
      }

      // 画问号
      drawQuestionMark(cell) {
        const { x, y, size, textColor } = cell;
        this.ctx.beginPath();
        this.ctx.font = `${size * 0.7}px Verdana`;
        this.ctx.fillStyle = textColor;
        this.ctx.textAlign = 'center';
        this.ctx.textBaseline = 'middle';
        this.ctx.fillText('?', x + size / 2, y + size / 2);
        this.ctx.closePath();
      }

      // 画标记
      drawMark(cell) {
        const { x, y, size } = cell;
        this.ctx.beginPath();
        this.ctx.fillStyle = '#dd0000';
        this.ctx.moveTo(x + size * 0.3, y + size * 0.2);
        this.ctx.lineTo(x + size * 0.7, y + size * 0.35);
        this.ctx.lineTo(x + size * 0.35, y + size * 0.5);
        this.ctx.lineTo(x + size * 0.35, y + size * 0.8);
        this.ctx.lineTo(x + size * 0.3, y + size * 0.8);
        this.ctx.fill();
        this.ctx.closePath();
      }

      // 画错误 -- 点中雷后标记错误的格子
      drawError(cell) {
        const { x, y, size } = cell;
        this.ctx.beginPath();
        this.ctx.fillStyle = '#aa0000';
        this.ctx.fillRect(x, y, size, size);
        this.ctx.closePath();

        this.ctx.beginPath();
        this.ctx.strokeStyle = '#ffffff';
        this.ctx.lineWidth = this.cellSize / 7;
        this.ctx.moveTo(x + 2, y + 2);
        this.ctx.lineTo(x + size - 2, y + size - 2);
        this.ctx.stroke();
        this.ctx.closePath();

        this.ctx.beginPath();
        this.ctx.strokeStyle = '#ffffff';
        this.ctx.lineWidth = this.cellSize / 7;
        this.ctx.moveTo(x + size - 2, y + 2);
        this.ctx.lineTo(x + 2, y + size - 2);
        this.ctx.stroke();
        this.ctx.closePath();
      }

      // 格式化计时器
      formatTime(time) {
        let mm = Math.floor(time / (1000 * 60));
        const ss = Math.floor((time - mm * 60 * 1000) / 1000).toString().padStart(2, '0');
        const ms = Math.floor((time - mm * 60 * 1000 - ss * 1000) / 10).toString().padStart(2, '0');
        mm < 10 && (mm = mm.toString().padStart(2, '0'));
        return `${mm}:${ss}:${ms}`;
      }

      // 计算时间
      calcTimer() {
        this.playTimeText = this.formatTime(this.playTime);
      }

      clear() {
        this.ctx.clearRect(0, 0, ...this.canvasSize);
      }

      reset() {
        this.mineNumber = this.mineNumber > this.size[0] * this.size[1] * 0.6 ? ~~(this.size[0] * this.size[1] * 0.6 / 10) * 10 : this.mineNumber;
        this.initData();
        this.gameStatus = 0;
        this.markNum = 0;
        this.openSum = 0;
        this.playTimeText = '00:00:00';
      }

      start() {
        if (this.gameStatus === 0) {
          this.gameStatus = 1;
          this.startTime = Date.now();
        }
      }

      changeSize(x, y) {
        this.size = [x, y];
        this.canvasSize = [x * 20, y * 20 + this.header];
        this.initCanvas();
        this.reset();
      }

      changeMineNumber(num) {
        this.mineNumber = num;
      }

      ani = () => {
        this.clear();
        this.draw();
        if (this.gameStatus === 1) {
          // 计时器工作
          this.playTime = Date.now() - this.startTime;
          if (this.playTime > 5940000) { // 超时结束
            this.gameOver();
          }
          this.calcTimer();
          if (this.openSum === this.size[0] * this.size[1] - this.mineNumber && this.markNum === this.mineNumber) {
            this.gameStatus = 3;
            console.log('Success');
          }
        }
        requestAnimationFrame(this.ani);
      }
    }

    const mineSweeper = new MineSweeper(document.getElementById('main'));
  </script>
</body>

</html>