发布于 2026-01-06 1 阅读
0

让我们尽情享受游戏的乐趣吧!如何使用 NodeJS 构建一个命令行版扫雷游戏 💣🔍 扫雷 JS

让我们尽情享受乐趣吧!如何使用 NodeJS 构建一个命令行版扫雷游戏💣🔍

扫雷游戏 JS

(封面照片由 Flickr 用户FolsomNatural提供)

举手!谁曾经连续几个小时玩过这款绝对的经典游戏?🙋 我记得小时候玩过。它有无数种不同的版本,甚至还有3D版本。说真的,我现在偶尔还会玩玩。那么,为什么不用Node在命令行界面(CLI)上构建我们自己的版本呢?

我还要特别感谢@krystofex,她通过buymeacoffee支持了我!☕ 非常感谢你的支持,我感激不尽!前几天回家路上,我已经享用了一杯美味的咖啡!☺️

铺垫

这将是一个尽可能精简的命令行应用程序,不依赖任何外部组件。考虑到命令行界面中大多数功能(例如参数解析和结构化显示)都能开箱即用,因此这个应用程序应该能够很好地运行。

不过,我们先来看看游戏规则。

扫雷游戏通常在正方形场地进行,比如 10x10 或 60x60,你懂的。一定数量的“地雷”会随机放置在场地上。玩家需要标记出所有地雷,而且只能标记这些地雷。为此,玩家可以在他们认为有地雷的场地上插旗。为了确定地雷的位置,玩家可以翻开场地。这样,玩家就能看到有多少相邻的场地有地雷。翻开一个没有相邻地雷的场地,也会同时翻开所有相邻的没有地雷的场地。但这究竟意味着什么呢?

我们来看一个 5x5 的场地,里面有 3 个地雷:

+----------+
|0 0 0 0 0 |
|2 2 1 1 1 |
|m m 1 1 m |
|2 2 1 1 1 |
|0 0 0 0 0 |
+----------+
Enter fullscreen mode Exit fullscreen mode

地雷用 标记m,数字表示有多少个相邻的单元格有地雷。所有 8 个相邻单元格都算作相邻单元格。游戏开始时,这些地雷都不可见。玩家选择揭开左上角的单元格。他们会看到这样的景象:

+----------+
|0 0 0 0 0 |
|2 2 1 1 1 |
|          |
|          |
|          |
+----------+
Enter fullscreen mode Exit fullscreen mode

通过发现一个没有相邻地雷的区域,所有非地雷的相邻区域都会被发现,直到某个单元格与地雷相邻为止。

如果玩家不小心挖到地雷,游戏就输了。如果玩家正确标记了所有地雷,游戏就赢了。正是这种简单性让它如此令人上瘾。“上次我差点就赢了,这次我一定能赢!”——对吧?不过,这款游戏有时确实让人感觉有点不公平。玩家随机挖到地雷的概率是number of mines / width * height……在一个标准的10x10的小型地图上,放置8个地雷,那么挖到地雷的概率是8%。很低吧?但是,当你连续三次在第一步就挖到地雷时,你会忍不住想:我的天哪,为什么偏偏要这样对我?!

好吧,我可能玩得有点太频繁了。我需要冷静下来,我们来这里是为了建设,而不是一定要

解析参数

好了,心率已经下降了。

为了确定场地应该有多大以及应该放置多少地雷,我们将使用控制台参数。

该应用程序应该可以像这样调用:

node minesweeper.js --width=10 --height=10 --mines=20
Enter fullscreen mode Exit fullscreen mode

这样就形成了一个 10x10 的游戏场地,上面随机放置了 10 个地雷。

我们将使用一些正则表达式来解析这些参数:

const getArg = (args, name) => {
  const match = args.match(new RegExp('--' + name + '=(\\d+)'))

  if (match === null) {
    throw new Error('Missing argument ' + name)
  }

  return parseInt(match[1])
}

let width = 0
let height = 0
let mines = 0

try {
  const args = process.argv.slice(2).join(' ')
  width = getArg(args, 'width')
  height = getArg(args, 'height')
  mines = getArg(args, 'mines')

  if (width < 1 || height < 1) {
    throw new Error('Field size must be positive')
  }
} catch (e) {
  console.error(e)
  process.exit(1)
}
Enter fullscreen mode Exit fullscreen mode

由于我们所有的参数都是数值,我们可以完美地使用\d+参数名作为正则表达式,解析出数字并使用它。我们唯一需要注意的是,宽度和高度都不能为 0——反正那样也没什么意义,对吧?不过,我们允许地雷为 0。简单模式。只是为了稍微安抚一下神经。

建设领域

我们刚才说到哪儿了?对了。

现在我们创建一个小实用函数:

const getNeighbouringCoords = (x, y) => [
  [y - 1, x - 1],
  [y - 1, x],
  [y - 1, x + 1],
  [y, x + 1],
  [y, x - 1],
  [y + 1, x - 1],
  [y + 1, x],
  [y + 1, x + 1],
].filter(([y, x]) => (
  y >= 0 && x >= 0 && x < width && y < height
))
Enter fullscreen mode Exit fullscreen mode

这将为给定的 X 和 Y 坐标提供最多 8 个坐标对的数组。这在后面会很有用。我们可以用它来确定要显示哪些字段,以及将之前看到的那些数字设置在哪里。

接下来我们需要某种方法来保存数据。我们主要需要三种类型的矩阵:

  • 一个用来记录那些讨厌的地雷在哪里(以及它们周围的编号)的工具
  • 一个用来记录玩家目前为止已经探索过的领域。
  • 最后,还有一个功能用来记录玩家标记为“包含地雷”的地块。
const createMatrix = v => Array(width).fill([]).map(
  () => Array(height).fill(v)
)

const field = createMatrix(0)
// We'll overwrite this matrix later, hence `let`
let uncoveredField = createMatrix(false)
const flaggedField = createMatrix(false)
Enter fullscreen mode Exit fullscreen mode

接下来,我们将放置地雷。为此,我们会生成一些随机的 X/Y 坐标。如果该位置已经存在地雷,我们会跳过该位置,以确保玩家获得完整的游戏体验。

地雷布设完毕后,我们将所有相邻单元格的值加1。这将生成特征数字模式:

while (mines > 0) {
  const mineX = Math.round(Math.random() * (width - 1))
  const mineY = Math.round(Math.random() * (height - 1))

  if (field[mineY][mineX] !== 'm') {
    field[mineY][mineX] = 'm'

    getNeighbouringCoords(mineX, mineY)
      .filter(([y, x]) => field[y][x] !== 'm')
      .forEach(([y, x]) => {
        field[y][x]++
      })

    mines--
  }
}
Enter fullscreen mode Exit fullscreen mode

我们来验证一下:

+----------+
|0 1 2 2 1 |
|0 1 m m 1 |
|0 1 2 3 2 |
|0 0 0 1 m |
|0 0 0 1 1 |
+----------+
Enter fullscreen mode Exit fullscreen mode

效果极佳!

检查玩家是否获胜

要判断玩家是否获胜,我们需要将玩家设置的旗帜位置与地雷位置进行比较。这意味着,如果旗帜插在没有地雷的位置,则玩家未获胜。我们可以使用every以下方法:

const checkIfWon = () => {
  return flaggedField.every(
    (row, y) => row.every(
      (cell, x) => {
        return (cell && field[y][x] === 'm')
          || (!cell && field[y][x] !== 'm')
      })
  )
}
Enter fullscreen mode Exit fullscreen mode

这样做会将每一行简化为“是”或“否”,具体取决于每个字段true是否都false符合条件。然后,通过简单地询问“所有行是否都为真”,将所有行简化为一个布尔值。

渲染字段

这有点复杂。单元格可以有三种状态:已覆盖、未覆盖和已标记。未覆盖的单元格可以是 0、1 到 8 之间的任意数字,或者是一个地雷。单元格也可以是光标当前所在的位置。

我们将使用表情符号来显示字段。首先,让我们定义一下要用于未覆盖单元格的表情符号:

const characterMap = {
  m: '💣', // I kinda developed an aversion to that emoji.
  0: '',
  1: '1️⃣ ',
  2: '2️⃣ ',
  3: '3️⃣ ',
  4: '4️⃣ ',
  5: '5️⃣ ',
  6: '6️⃣ ',
  7: '7️⃣ ',
  8: '8️⃣ ',
}
Enter fullscreen mode Exit fullscreen mode

接下来,我们定义一个用于渲染场地的函数。它应该首先清除 CLI 输出,并预先渲染顶部和底部墙壁:

const renderField = (playerX, playerY) => {
  console.clear()
  console.log('🧱'.repeat(width + 2))

  // ...

  console.log('🧱'.repeat(width + 2))
  console.log('Press ENTER to uncover a field, SPACE to place a flag')
}
Enter fullscreen mode Exit fullscreen mode

接下来我们需要遍历整个场地。我们可以先给每一行添加左右两侧的墙壁。

// ...
for (let y = 0; y < height; y++) {
  let row = '🧱'
  for (let x = 0; x < width; x++) {
    // ...
  }
  row += '🧱'
  console.log(row)
}
// ...
Enter fullscreen mode Exit fullscreen mode

为了完成渲染,我们现在只需要为每个 x 和 y 坐标添加不同的状态:

for (let y = 0; y < height; y++) {
  let row = '🧱'
  for (let x = 0; x < width; x++) {
    if (x === playerX && y === playerY) {
      row += '\x1b[47m\x1b[30m'
    }

    if (flaggedField[y][x]) {
      row += '🚩'
    } else if (uncoveredField[y][x]) {
      row += characterMap[field[y][x]]
    } else {
      row += '  '
    }

    if (x === playerX && y === playerY) {
      row += '\x1b[0m'
    }
  }

  row += '🧱'
  console.log(row)
}
Enter fullscreen mode Exit fullscreen mode

您可能已经注意到两条if包含特殊字符的语句。`-- white-background \x1b[47m-text` 会将命令行界面 (CLI) 中后续文本的背景设置为白色,`--black-background- \x1b[30mtext` 会将后续文本的背景设置为黑色。对于大多数 CLI 来说,这实际上意味着反转标准颜色。此语句用于指示玩家光标的当前位置。`-- \x1b[0mreset-color` 用于重置这些设置,确保只有当前单元格的颜色会发生变化。

揭开这片领域的面纱

这一关会更棘手。游戏规则规定,所有没有相邻地雷的空地都必须被挖开。这实际上可以形成任何形状,例如圆形。因此,我们需要找到绕过圆形地雷的方法。

理想情况下,这种揭示过程会像“扩散”一样进行。一个区域会先揭示自身,然后询问其相邻区域是否也能揭示。这听起来像是递归,对吧?

确实如此!这个小函数通过递归地请求相邻元素揭示,完美地实现了我们想要的功能:

const uncoverCoords = (x, y) => {
  // Uncover the field by default
  uncoveredField[y][x] = true

  const neighbours = getNeighbouringCoords(x, y)

  // Only if the field is a 0, so if it has no adjacent mines,
  // ask its neighbours to uncover.
  if (field[y][x] === 0) {
    neighbours.forEach(([y, x]) => {
      // Only uncover fields that have not yet been uncovered.
      // Otherwise we would end up with an infinite loop.
      if (uncoveredField[y][x] !== true) {
        // Recursive call.
        uncoverCoords(x, y)
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

最后,我们需要……

用户输入

最后冲刺!就快到了。很快我们就能再次看到那个小炸弹表情符号,它告诉我们连续第十三次倒霉了,为什么我这么倒霉??

我们先来定义一下控制方式:光标可以通过键盘移动。按下 键enter会触发揭开标记的操作,再按下 键space则会放置或移除标记。

为了判断是否仍然接受键盘输入,我们需要记录用户是赢了还是输了游戏。此外,我们还需要记录光标坐标:

let playerX = 0
let playerY = 0
let hasLost = false
let hasWon = false
Enter fullscreen mode Exit fullscreen mode

然后我们首先渲染该字段:

renderField(playerX, playerY)
Enter fullscreen mode Exit fullscreen mode

为了获取用户的键盘输入,我们可以使用 Node 的内置readline模块。该模块readline允许我们将按键事件“转换”为标准输入事件process.stdin。然后,我们监听标准输入的按键事件(通常在使用“原始模式”时完成),并对这些事件做出响应:

const readlineModule = require('readline')
readlineModule.emitKeypressEvents(process.stdin)
process.stdin.setRawMode(true)

process.stdin.on('keypress', (character, key) => {
  // Do stuff
})
Enter fullscreen mode Exit fullscreen mode

然而,由于标准输入处于原始模式,使用 Ctrl+C 终止当前脚本不起作用。按住 Ctrl 键再按 C 键也被视为一次按键操作。因此,我们需要自己实现这个功能:

// ...
process.stdin.on('keypress', (character, key) => {
  // More stuff

  if (key.name === 'c' && key.ctrl) {
    process.exit(0)
  }
})
Enter fullscreen mode Exit fullscreen mode

key对象以小写字母告诉我们所按下的键的名称,并带有指示是否按下了 Ctrl 或 Shift 的标志。

现在,让我们添加所有方向键、空格键和回车键:

process.stdin.on('keypress', (character, key) => {
  if (!hasLost && !hasWon) {
    // Do not move past right wall
    if (key.name === 'right' && playerX < width - 1) {
      playerX++
    }

    // Do not move past left wall
    if (key.name === 'left' && playerX > 0) {
      playerX--
    }

    // Do not move past down wall
    if (key.name === 'down' && playerY < height - 1) {
      playerY++
    }

    // Do not move past up wall
    if (key.name === 'up' && playerY > 0) {
      playerY--
    }

    // Uncovering fields
    if (key.name === 'return') {
      uncoverCoords(playerX, playerY)

      // The player seems to have found a mine
      if (field[playerY][playerX] === 'm') {
        hasLost = true

        // Uncover all fields in case the player has lost
        uncoveredField = Array(height).fill([]).map(() => Array(width).fill(true))
      }
    }

    // Placing a flag
    if (key.name === 'space') {
      flaggedField[playerY][playerX] = !flaggedField[playerY][playerX]

      hasWon = checkIfWon()
    }
  }

  // Show the player what just happened on the field
  renderField(playerX, playerY)

  if (hasLost) {
    console.log('Lost :(')
  }

  if (hasWon) {
    console.log('Won :)')
  }

  if (key.name === 'c' && key.ctrl) {
    process.exit(0)
  }
})
Enter fullscreen mode Exit fullscreen mode

好啦,我们完成了!

我也想玩!

真的可以!我已经把它开源了:

GitHub 标志 thormeier / minesweeper.js

扫雷游戏,但是是在命令行界面上玩!

扫雷游戏 JS

一款基于表情符号的简单扫雷游戏克隆版,可在命令行界面 (CLI) 上运行!

Minesweeper.js CLI 的屏幕截图

用法

通过克隆此存储库进行下载,然后运行node minesweeper.js或执行以下命令启动它npx minesweeper-cli.js

论点

  • --width=number- 字段宽度,默认值为8
  • --height=number- 字段高度,默认值为8
  • --mines=number- 放置在棋盘上的地雷数量,默认值为10

详细解释

请查看我在那边的帖子dev.to/thormeier

执照

麻省理工学院






你也可以通过执行来玩。npx minesweeper-cli.js

享受!


希望您喜欢这篇文章,就像我喜欢写这篇文章一样!如果喜欢,请点个赞❤️🦄 !我会在空闲时间写一些科技文章,偶尔也喜欢喝杯咖啡。

如果你想支持我的工作, 可以请我喝杯咖啡 或者 在推特上关注我🐦 你也可以直接通过PayPal支持我!

给我买杯咖啡按钮

文章来源:https://dev.to/thormeier/lets-have-a-blast-of-fun-build-a-minesweeper-clone-for-the-cli-1o5