boxmoe_header_banner_img

Hello! 欢迎来到悠悠畅享网!

文章导读

使用JavaScript构建控制台版扫雷游戏教程


avatar
作者 2025年8月23日 26

使用JavaScript构建控制台版扫雷游戏教程

本教程旨在指导开发者使用纯JavaScript在VS Code控制台中构建一个基础的扫雷游戏。文章将详细阐述游戏的数据结构设计、状态初始化、游戏板渲染、用户交互处理、胜负判断逻辑以及主游戏循环的构建。通过分步指导和代码示例,帮助读者理解如何将复杂的游戏逻辑分解为可管理的模块,并提供错误处理与性能优化的建议,从而系统地开发一个功能完备的控制台扫雷游戏。

1. 游戏数据结构设计

构建扫雷游戏的第一步是定义其核心数据结构。一个扫雷棋盘本质上是一个二维网格,每个单元格(cell)都拥有特定的状态和属性。为了清晰地表示这些信息,我们应将每个单元格设计为一个对象,包含以下关键属性:

  • isMine: 布尔值,表示该单元格是否藏有地雷。
  • state: 字符串,表示单元格的当前可见状态,可选值包括 “unopened”(未打开)、”opened”(已打开)或 “flagged”(已标记)。
  • adjacentMines: 数字,表示该单元格周围八个方向上地雷的数量(仅当单元格非地雷且已打开时显示)。

因此,游戏状态可以由一个包含这些单元格对象的二维数组来表示。

/**  * @typedef {Object} Cell  * @property {boolean} isMine - 单元格是否为地雷  * @property {"unopened" | "opened" | "flagged"} state - 单元格的当前状态  * @property {number} [adjacentMines] - 周围地雷数量 (可选, 仅在非地雷且打开时有意义)  */  /**  * 游戏网格,由Cell对象组成的二维数组  * @type {Cell[][]}  */ let gameGrid;

2. 游戏状态初始化

在设计好数据结构后,我们需要初始化游戏网格。这包括创建指定大小的二维数组,并为每个单元格赋予初始属性。

2.1 生成网格骨架

首先,实现一个函数来生成一个指定大小的空二维数组,作为网格的容器。

/**  * 生成一个指定大小的空二维数组作为网格骨架。  * @param {number} gridSize - 网格的边长(例如,9表示9x9的网格)。  * @returns {Array<Array<any>>} - 初始化的二维数组。  */ const generateEmptyGrid = (gridSize) => {     let grid = [];     for (let i = 0; i < gridSize; i++) {         grid.push([]);         for (let j = 0; j < gridSize; j++) {             grid[i][j] = null; // 暂时用null占位         }     }     return grid; };

2.2 随机布雷函数

接下来,我们需要一个函数来随机决定一个单元格是否为地雷。使用 math.random() 可以生成一个介于0(包含)和1(不包含)之间的浮点数。通过将其与一个阈值(例如0.2,表示20%的概率是地雷)进行比较,我们可以得到一个布尔值。

立即学习Java免费学习笔记(深入)”;

/**  * 随机决定一个单元格是否为地雷。  * @param {number} mineProbability - 单元格是地雷的概率 (0到1之间)。  * @returns {boolean} - 如果为true则为地雷,否则不是。  */ const isMine = (mineProbability = 0.2) => Math.random() < mineProbability;

2.3 初始化单元格属性

现在,结合上述函数,我们可以初始化整个游戏网格。在遍历网格时,为每个单元格创建一个 Cell 对象,并设置其初始状态。

/**  * 初始化游戏网格,包括布雷和设置初始状态。  * @param {number} gridSize - 网格的边长。  * @param {number} mineProbability - 单元格是地雷的概率。  * @returns {Cell[][]} - 初始化的游戏网格。  */ const initializeGrid = (gridSize, mineProbability = 0.2) => {     let grid = generateEmptyGrid(gridSize);     for (let r = 0; r < gridSize; r++) {         for (let c = 0; c < gridSize; c++) {             grid[r][c] = {                 isMine: isMine(mineProbability),                 state: "unopened",                 adjacentMines: 0 // 初始设置为0,后续计算             };         }     }     // 在所有地雷位置确定后,计算每个非地雷单元格的相邻地雷数     calculateAdjacentMines(grid);     return grid; };  /**  * 计算并设置每个非地雷单元格的相邻地雷数量。  * @param {Cell[][]} grid - 游戏网格。  */ const calculateAdjacentMines = (grid) => {     const gridSize = grid.length;     for (let r = 0; r < gridSize; r++) {         for (let c = 0; c < gridSize; c++) {             if (!grid[r][c].isMine) {                 let count = 0;                 // 检查周围8个方向                 for (let dr = -1; dr <= 1; dr++) {                     for (let dc = -1; dc <= 1; dc++) {                         if (dr === 0 && dc === 0) continue; // 跳过自身                         const nr = r + dr;                         const nc = c + dc;                         // 检查边界                         if (nr >= 0 && nr < gridSize && nc >= 0 && nc < gridSize) {                             if (grid[nr][nc].isMine) {                                 count++;                             }                         }                     }                 }                 grid[r][c].adjacentMines = count;             }         }     } };

3. 渲染游戏板

为了在控制台显示游戏状态,我们需要一个 render 函数,它将游戏网格转换为可读的字符串。不同的单元格状态应该用不同的字符表示。

  • “unopened”:未打开的单元格,例如用 . 或 # 表示。
  • “flagged”:已标记的单元格,例如用 F 表示。
  • “opened”:
    • 如果 isMine 为 true,表示踩到地雷,例如用 X 表示。
    • 如果 adjacentMines 为 0,表示空单元格,例如用空格 ` ` 表示。
    • 如果 adjacentMines > 0,则显示 adjacentMines 的数字。
/**  * 将游戏网格渲染为控制台可打印的字符串。  * @param {Cell[][]} grid - 游戏网格。  * @param {boolean} [revealMines=false] - 是否显示所有地雷(例如游戏结束时)。  * @returns {string} - 渲染后的游戏板字符串。  */ const renderGrid = (grid, revealMines = false) => {     let output = "";     const gridSize = grid.length;      // 打印列索引     output += "  ";     for (let c = 0; c < gridSize; c++) {         output += ` ${c}`;     }     output += "n";     output += "  " + "-".repeat(gridSize * 2) + "n";      for (let r = 0; r < gridSize; r++) {         output += `${r}|`; // 打印行索引         for (let c = 0; c < gridSize; c++) {             const cell = grid[r][c];             let char = " "; // 默认字符              if (revealMines && cell.isMine) {                 char = "X"; // 游戏结束时显示所有地雷             } else if (cell.state === "unopened") {                 char = "#"; // 未打开             } else if (cell.state === "flagged") {                 char = "F"; // 已标记             } else if (cell.state === "opened") {                 if (cell.isMine) {                     char = "X"; // 踩到地雷                 } else if (cell.adjacentMines === 0) {                     char = " "; // 空白区域                 } else {                     char = cell.adjacentMines.toString(); // 显示数字                 }             }             output += ` ${char}`;         }         output += "n";     }     return output; };

4. 用户交互与游戏动作

扫雷游戏主要有两种用户操作:打开单元格和标记/取消标记单元格。我们需要实现对应的函数来处理这些操作,并更新游戏状态。

4.1 打开单元格 (openCell)

打开单元格的逻辑相对复杂,特别是当打开一个周围地雷数为0的单元格时,需要递归地打开其周围的空单元格,直到遇到有数字的单元格。

/**  * 打开指定坐标的单元格。  * @param {Cell[][]} grid - 游戏网格。  * @param {number} r - 行索引。  * @param {number} c - 列索引。  * @returns {boolean} - 如果打开的是地雷,返回true(游戏失败),否则返回false。  */ const openCell = (grid, r, c) => {     const gridSize = grid.length;      // 边界检查和状态检查     if (r < 0 || r >= gridSize || c < 0 || c >= gridSize) return false;     const cell = grid[r][c];     if (cell.state === "opened" || cell.state === "flagged") return false;      cell.state = "opened";      if (cell.isMine) {         return true; // 踩到地雷,游戏失败     }      // 如果打开的是空单元格(adjacentMines为0),则递归打开周围的单元格     if (cell.adjacentMines === 0) {         for (let dr = -1; dr <= 1; dr++) {             for (let dc = -1; dc <= 1; dc++) {                 if (dr === 0 && dc === 0) continue;                 openCell(grid, r + dr, c + dc); // 递归调用             }         }     }     return false; // 未踩到地雷 };

4.2 标记/取消标记单元格 (flagCell)

标记单元格用于玩家怀疑某个位置有地雷,防止误触。再次标记则取消标记。

/**  * 标记或取消标记指定坐标的单元格。  * @param {Cell[][]} grid - 游戏网格。  * @param {number} r - 行索引。  * @param {number} c - 列索引。  */ const flagCell = (grid, r, c) => {     const gridSize = grid.length;     if (r < 0 || r >= gridSize || c < 0 || c >= gridSize) return; // 边界检查      const cell = grid[r][c];     if (cell.state === "opened") return; // 已打开的单元格不能标记      if (cell.state === "unopened") {         cell.state = "flagged";     } else if (cell.state === "flagged") {         cell.state = "unopened";     } };

5. 游戏结束条件判断

游戏需要判断何时结束,以及是胜利还是失败。

  • 失败条件:玩家打开了一个地雷单元格。
  • 胜利条件:所有非地雷单元格都被打开,且所有地雷单元格要么被标记,要么保持未打开(但未被触碰)。更简单的判断是所有非地雷单元格都被打开。
/**  * 检查游戏是否结束,并返回游戏状态。  * @param {Cell[][]} grid - 游戏网格。  * @returns {"win" | "lose" | false} - 游戏状态,如果未结束则返回false。  */ const checkEndCondition = (grid) => {     const gridSize = grid.length;     let unopenedNonMines = 0;     let totalMines = 0;      for (let r = 0; r < gridSize; r++) {         for (let c = 0; c < gridSize; c++) {             const cell = grid[r][c];             if (cell.isMine) {                 totalMines++;                 // 如果踩到地雷,直接判定为失败                 if (cell.state === "opened") {                     return "lose";                 }             } else {                 if (cell.state === "unopened" || cell.state === "flagged") {                     unopenedNonMines++;                 }             }         }     }      // 如果所有非地雷单元格都被打开,则游戏胜利     if (unopenedNonMines === 0) {         return "win";     }      return false; // 游戏尚未结束 };

6. 构建主游戏循环

主游戏循环是连接所有组件的核心。它负责初始化游戏、渲染棋盘、接收用户输入、处理动作、检查游戏状态并循环直到游戏结束。我们将使用Node.JS的 readline 模块来获取控制台输入。

const readline = require('readline');  const rl = readline.createInterface({     input: process.stdin,     output: process.stdout });  /**  * 异步提问函数,封装rl.question。  * @param {string} query - 提示用户的问题。  * @returns {Promise<string>} - 用户输入的答案。  */ const askQuestion = (query) => new Promise(resolve => rl.question(query, resolve));  /**  * 游戏主函数。  */ const main = async () => {     console.log("欢迎来到控制台扫雷游戏!");      let gridSize = 0;     while (gridSize < 3 || gridSize > 20 || isNaN(gridSize)) {         const sizeInput = await askQuestion("请输入网格大小 (例如: 9 表示 9x9): ");         gridSize = parseInt(sizeInput);         if (gridSize < 3 || gridSize > 20 || isNaN(gridSize)) {             console.log("无效的网格大小,请输入一个3到20之间的数字。");         }     }      let gameGrid = initializeGrid(gridSize, 0.15); // 15% 的地雷概率     let endState = false;      while (!endState) {         console.clear(); // 清空控制台         console.log(renderGrid(gameGrid));          let actionInput = await askQuestion("请输入操作 (例如: 'o 1 2' 打开(1,2) 或 'f 0 0' 标记(0,0)): ");         const parts = actionInput.trim().split(' ');         const action = parts[0].toLowerCase();         const row = parseInt(parts[1]);         const col = parseInt(parts[2]);          // 输入验证         if (isNaN(row) || isNaN(col) || row < 0 || row >= gridSize || col < 0 || col >= gridSize) {             console.log("无效的坐标或输入格式,请重试。");             await new Promise(resolve => setTimeout(resolve, 1500)); // 暂停1.5秒             continue;         }          if (action === "o") {             const isMineHit = openCell(gameGrid, row, col);             if (isMineHit) {                 endState = "lose";             }         } else if (action === "f") {             flagCell(gameGrid, row, col);         } else {             console.log("无效的操作,请使用 'o' (打开) 或 'f' (标记)。");             await new Promise(resolve => setTimeout(resolve, 1500));         }          if (!endState) { // 如果还没有因为踩雷而结束,则检查其他结束条件             endState = checkEndCondition(gameGrid);         }     }      console.clear();     console.log(renderGrid(gameGrid, true)); // 游戏结束时显示所有地雷      if (endState === "win") {         console.log("n恭喜你,你赢了!?");     } else if (endState === "lose") {         console.log("n很遗憾,你踩到地雷了!游戏结束。?");     }      rl.close(); };  main();

7. 错误处理与健壮性

在实际开发中,考虑用户可能进行的各种无效操作至关重要。上述代码已包含一些基本的输入验证,但仍可进一步增强:

  • 重复操作:用户尝试打开已打开或已标记的单元格,或标记已打开的单元格。这些情况在 openCell 和 flagCell 函数中已经处理。
  • 坐标越界:用户输入超出网格范围的坐标。这在输入解析和 openCell/flagCell 函数中都有边界检查。
  • 非法输入:用户输入非数字或非预期格式的指令。主循环中的 isNaN 检查和 parseInt 失败处理可以捕获这些。
  • 清晰的提示:当用户输入无效时,提供明确的错误消息和操作指导。

8. 优化与进阶思考

当前实现是一个功能完备的基础扫雷游戏,但仍有许多优化空间:

  • 性能优化:checkEndCondition 函数每次都会遍历整个网格。对于大型网格,这可能效率不高。可以通过维护额外的变量来优化:
    • openedCellsCount: 记录已打开的非地雷单元格数量。
    • totalNonMineCells: 游戏初始化时计算非地雷单元格总数。
    • 当 openedCellsCount === totalNonMineCells 时,游戏胜利。
    • 这样,每次 openCell 后只需更新 openedCellsCount 即可快速判断胜利。
  • 游戏体验
    • 增加计时器功能。
    • 允许玩家选择难度(地雷密度)。
    • 更友好的控制台界面,例如使用颜色。
  • 代码结构:可以将游戏逻辑封装在一个类中,使其更具模块化和可维护性。
  • 作弊模式:添加一个调试选项,在游戏开始时显示所有地雷位置。

通过以上步骤和建议,您应该能够构建一个功能完善的控制台扫雷游戏,并为进一步的优化和功能扩展打下坚实的基础。



评论(已关闭)

评论已关闭