JavaScript控制台扫雷游戏开发教程
时间:2025-08-24 18:15:38 114浏览 收藏
本教程将手把手教你使用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>} - 初始化的二维数组。 */ 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%的概率是地雷)进行比较,我们可以得到一个布尔值。
/** * 随机决定一个单元格是否为地雷。 * @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} - 用户输入的答案。 */ 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 即可快速判断胜利。
- 游戏体验:
- 增加计时器功能。
- 允许玩家选择难度(地雷密度)。
- 更友好的控制台界面,例如使用颜色。
- 代码结构:可以将游戏逻辑封装在一个类中,使其更具模块化和可维护性。
- 作弊模式:添加一个调试选项,在游戏开始时显示所有地雷位置。
通过以上步骤和建议,您应该能够构建一个功能完善的控制台扫雷游戏,并为进一步的优化和功能扩展打下坚实的基础。
文中关于的知识介绍,希望对你的学习有所帮助!若是受益匪浅,那就动动鼠标收藏这篇《JavaScript控制台扫雷游戏开发教程》文章吧,也可关注golang学习网公众号了解相关技术文章。
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
492 收藏
-
161 收藏
-
245 收藏
-
452 收藏
-
411 收藏
-
494 收藏
-
315 收藏
-
118 收藏
-
382 收藏
-
417 收藏
-
326 收藏
-
445 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 511次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 498次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习