原生js实现 扫雷

在线查看

介绍

一个用原生js实现的扫雷游戏

软件架构

  • initMapArray: 用于初始化地图数组
  • initUI: 用于初始化UI
  • initEventLogic: 用于初始化交互逻辑
  • checkgStatus: 用于检查是否获胜
  • setVisBlock: 用于求出连通块
  • gameOver: 判断游戏结束
  • setMessage: 设置dom消息
  • initButton: 添加开始游戏事件

1. 考虑需求

首先我们看看传统的扫雷有哪些功能

  • 点击开始游戏,显示游戏地图
  • 点击小方块,要么显示数字,要么没有数字,要么就是地雷, 同时,能够标记小方块。

2. 实现

2.1 用户故事——点击开始游戏,显示游戏地图

分析

  • 首先我们需要一个按钮,并且监听点击事件。

    • <body>
      	<div class="container">
      		<div class="name">AcWink 扫雷</div>
              <div class="control">
                  <!-- 添加一个按钮-->
                  <button id="begin">开始游戏</button>
              </div>
              <div class="message-box">
              </div>
              <div class="game">
                  
              </div>
      	</div>
      </body>
      
      <script>
      	// 设置一个函数然后为这个按钮添加点击事件
          // 绑定按钮逻辑控制游戏
          function initButtonEvent() {
            const beginBtnEl = document.querySelector("#begin");
            beginBtnEl.addEventListener(
              "click",
              () => {
                // 监听点击事件, 然后执行对应的初始化操作
              },
              false
            );
          }
          initButtonEvent();
      
      </script>
      
  • 需要初始化地图,并且控制 ui 的渲染。

    • 首先,我们需要在js中记录,地图的信息,以便我们判断游戏的操作。

    • 创建 Node 类来记录,记录单个小方格的信息

      • class Node {
          constructor() {
            this.mineNumberByAround = 0;		// 当前点周围的地雷数
            this.opened = false;				// 这个点是否已经被点开了
            this.isFlag = false;				// 这个点是否被右键标记了
            this.isMine = false;				// 这个点是否是一个地雷
          }
        }
        
    • 接下来我们就需要,初始化游戏需要的数据

      • const GAME_STATUS = {
          START: "start",
          END: "end",
          WIN: "win",
        }; // 用于表示游戏状态
        
        let g = null; // 储存每个小格子的状态信息
        let gameStatus = null; // 存储游戏当前的状态
        
        // 开始初始化数据
        // n: 行,m:列,mineCount: 地雷数
        function initMapData(n, m, mineCount) {
          // 初始化 n x m 的地图
          g = Array.from({ length: n }, () =>
            new Array(m).fill(0).map(() => new Node())
          );
          // 设置游戏状态
          gameStatus = GAME_STATUS.START;
        	
          // 随机生成地雷下标
          const randomMine = (n, m) => {
            const x = Math.floor(Math.random() * (n - 1));
            const y = Math.floor(Math.random() * (m - 1));
            return [x, y];
          };
          // 生成自定地雷的函数
          while (mineCount > 0) {
            let [x, y] = randomMine(n, m);
            // 如果生成的下标已经是地雷,就在重新生成
            while (g[x][y].isMine) {
              [x, y] = randomMine(n, m);
            }
            g[x][y].isMine = true;
            mineCount--;
          }
        
          // 计算每个不是地雷的小方格周围的地雷个数, 2 x 2 周围
          // 也就是查 上下左右 四个方向的地雷数
          const d = [
            [0, 1],
            [1, 0],
            [0, -1],
            [-1, 0],
          ];
          const computerAroundMine = (x, y) => {
            let count = 0;
        	
            for (let i = 0; i < 4; ++i) {
              let nx = x + d[i][0];
              let ny = y + d[i][1];
              // 判断nx,ny是否越界
              if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
        
              if (g[nx][ny].isMine) count++;
            }
        
            return count;
          };
        
          // 循环计算每个非地雷结点,周围的地雷数
          for (let i = 0; i < n; ++i)
            for (let j = 0; j < m; ++j)
              if (!g[i][j].isMine) {
                g[i][j].mineNumberByAround = computerAroundMine(i, j);
              }
        }
        
    • 然后我们就需要根据数据渲染UI啦。

      • // 初始化地图UI
        function initUI() {
          // 获取容器结点
          const gameEl = document.querySelector(".game");
          // 创建一个虚拟容器
          const vContainer = document.createDocumentFragment();
        
          // 获取长宽
          const n = g.length;
          const m = g[0].length;
        
          // 求出容器的宽高, 这里设置 每个小方格 20 X 20
          const gameElWidth = n * 20;
          const gameElHeight = m * 20;
        
          // 创建子结点,并初始化
          for (let i = 0; i < n; ++i)
            for (let j = 0; j < m; ++j) {
              const item = document.createElement("div");
              item.setAttribute("x", i);
              item.setAttribute("y", j);
              item.classList.add("game-item", `game-item-${i}-${j}`);
         
              if (g[i][j].isMine) item.classList.add("mine");
              item.style.width = "20px";
              item.style.height = "20px";
              vContainer.appendChild(item);
            }
        
          // 设置父容器高度和宽度,并挂载子结点
          gameEl.style.width = `${gameElHeight}px`;
          gameEl.style.height = `${gameElWidth}px`;
          if (gameEl.hasChildNodes()) gameEl.innerHTML = "";
          gameEl.appendChild(vContainer);
        }
        
      • 这里的虚拟容器,DocumentFragment 并不会被渲染,在它被添加到其他DOM结点时,它只会把内部子结点添加进去。

      • 这里我们虽然添加了类名,但是没有设置布局样式,此时是没有效果的,添加样式如下。

      • .container {
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
        }
        
        .name {
            margin: 20px 0;
            width: 500px;
            padding: 10px 0;
            text-align: center;
            border: 2px solid skyblue;
            border-radius: 4px;
        }
        
        .control {
            display: flex;
            flex-direction: column;
            align-items: center;
            margin-bottom: 20px;
        }
        
        .message-box {
            margin-bottom: 20px;
            width: 200px;
            height: 20px;
            padding: 10px 0;
            text-align: center;
            color: red;
            border: 1px solid;
            border-radius: 4px;
        }
        #begin {
            display: inline-block;
            margin-top: 20px;
        }
        .game {
            display: flex;
            flex-wrap: wrap;
            background-color: #ccc;
        }
        
        .game-item {
            color: #fff;
            text-align: center;
            box-sizing: border-box;
            background-color: bisque;
            border: 1px solid #fff;
        }
        
    • 写完这些逻辑后,我们把这两个函数 在开始按钮的 点击事件回调函数中执行。

      • beginBtnEl.addEventListener(
            "click",
            () => {
            	// 监听点击事件, 然后执行对应的初始化操作
            	initMapData(8, 8, 8);
                initUI();
            },
            false
        );
        
  • 点击开始游戏的时候,我们就能得到如下面效果图

    • image-20230104213520381
2.2 用户故事——点击小方块,要么显示数字,要么没有数字,要么就是地雷, 同时,能够标记小方块

分析

  • 监听用户对小方块的点击

    • 因为小方块太多了,我们如果直接给每个小方块监听点击事件,所以我们可以利用事件的传递性,做事件代理

    • 鼠标左击小方块,判断是否 雷、周围有地雷、周围没有地雷。

    • 鼠标右击小方块,首先我们得静止默认事件 mousedown, 然后设置自己的事件逻辑。

    • function initEventLogic() {
        const gameEl = document.querySelector(".game");
        const isGameItemEl = (target) =>
          Array.from(target.classList).includes("game-item");
        gameEl.addEventListener(
          "click",
          (e) => {
            // 判断游戏是否结束
            if (gameOver()) {
              setMessage("游戏已经结束,点击开始游戏。");
              return;
            }
      
            const target = e.target;
      
            if (!isGameItemEl(target)) return;
      
            const x = Number.parseInt(target.getAttribute("x"));
            const y = Number.parseInt(target.getAttribute("y"));
      
            // 如果结点已经打开过,直接返回
            if (g[x][y].opened) return;
            // 1. 判断是否是地雷,如果点击了地雷,就直接显示所有地雷。
            if (g[x][y].isMine) {
              // 找到所有是地雷的Dom,把它们颜色修改成红色
              gameStatus = GAME_STATUS.END;
      		
                
              // 将UI修改相关逻辑加入到微任务队列中
              queueMicrotask(() => {
                const mineEls = document.querySelectorAll(".mine");
                mineEls.forEach((item) => {
                  item.style.background = "red";
                });
              });
      
              setMessage("你完了,你踩到地雷了");
      
              return;
            }
            // 2. 判断是否不是地雷,不是地雷的如果当前有标记附近的地雷数不等于 0
            else if (g[x][y].mineNumberByAround !== 0) {
              // 修改点击的结点
              g[x][y].opened = true;
      
              queueMicrotask(() => {
                // 插入文本提示
                target.innerHTML = "" + g[x][y].mineNumberByAround;
      
                // 修改背景颜色
                target.style.background = "blue";
              });
            } else if (g[x][y].mineNumberByAround === 0) {
              // 如果等于 0 我们需要扩散当前范围,直到没有 空结点可扩散,也就是找连通块
              setVisBlock(x, y);
            }
      
            // 判断是否排除了所有非雷的元素, 也就是判断是否胜利
            const isWon = checkgStatus();
            if (isWon) {
              gameStatus = GAME_STATUS.WIN;
              setMessage("你赢了!");
            }
          },
          false
        );
          
          
        // 监听鼠标右键功能
        // 1. 取消默认事件
        window.oncontextmenu = (e) => e.preventDefault();
        // 2. 监听鼠标右键事件
        window.addEventListener(
          "mousedown",
          (e) => {
            if (e.button !== 2) return;
            const target = e.target;
      
            if (!isGameItemEl(target)) return;
      
            const x = Number.parseInt(target.getAttribute("x"));
            const y = Number.parseInt(target.getAttribute("y"));
      
            // 已经被打开过
            if (g[x][y].opened) return;
      
            // 查看是否已经被标记过
            if (g[x][y].isFlag) {
              target.style.background = "bisque";
              g[x][y].isFlag = false;
            } else {
              target.style.background = "pink";
              g[x][y].isFlag = true;
            }
          },
          false
        );
      }
      
  • 最后在开始按钮中调用始化事件监听逻辑函数

    • beginBtnEl.addEventListener(
          "click",
          () => {
          	// 监听点击事件, 然后执行对应的初始化操作
          	initMapData(8, 8, 8);
              initUI();
          },
          false
      );
      
    • 注意, 当点击到空按钮的时候,我们需要扩散。什么时候结束扩散呢,遇到边界或遇到有数字的方块,形成一个包围块(连通块)后结束扩散。具体实现在 setVisBlock