Skip to content

Instantly share code, notes, and snippets.

@Piterden
Created January 11, 2023 00:35
Show Gist options
  • Save Piterden/ec96e07fb5cc9248cce69871a7fbdc02 to your computer and use it in GitHub Desktop.
Save Piterden/ec96e07fb5cc9248cce69871a7fbdc02 to your computer and use it in GitHub Desktop.
NodeJS Minesweeper for terminal. No deps. One file.
const { randomInt } = require('crypto')
const readlinePromises = require('readline/promises')
class Square {
open = false
flag = false
bomb = false
value = 0
constructor (col, row) {
this.col = col
this.row = row
}
toString () {
if (this.flag) return '\x1b[42;1;30mF\x1b[0m'
if (this.open) {
if (this.bomb) return `\x1b[1;41m${this.bomb}\x1b[0m`
if (this.value) return `\x1b[1;3${this.value}m${this.value}\x1b[0m`
return ' '
}
return '■'
}
}
class Grid {
constructor (game) {
this.game = game
}
get firstLine () {
return ` ${this.widthArray()
.map((_, idx) => `${this.padIdx(idx)}`)
.join(' ')}
┌${this.widthArray('───').join('┬')}┐`
}
get dividerLine () {
return ` ├${this.widthArray('───').join('┼')}┤`
}
get lastLine () {
return ` └${this.widthArray('───').join('┴')}┘`
}
widthArray (fill = null) {
return new Array(this.game.width).fill(fill)
}
padIdx (idx) {
return String(idx).padStart(2, ' ').padEnd(3, ' ')
}
render () {
return `${this.firstLine}
${this.game.field
.map((row, idx) => ` ${this.padIdx(idx)}│ ${row.join(' │ ')} │`)
.join(`\n${this.dividerLine}\n`)}
${this.lastLine}${this.game.over ? `\n ${this.game.over}` : ''}`
}
}
class Game {
over = false
firstTurn = true
constructor (width, height, minesNumber) {
this.width = Number(width)
this.height = Number(height)
this.minesNumber = Number(minesNumber)
this.field = new Array(this.height).fill(new Array(this.width).fill(0))
.map(
(col, colIdx) => col.map(
(_, rowIdx) => new Square(colIdx, rowIdx)
)
)
}
get squares () {
return this.field.flat()
}
mine (exceptSquare) {
const minedSquares = this.squares
.sort((a, b) => {
if (a === exceptSquare) return 1
if (b === exceptSquare) return -1
return randomInt(2) || -1
})
.slice(0, this.minesNumber)
minedSquares.forEach((square) => {
square.bomb = '¤'
})
for (let i = 0; i < minedSquares.length; i += 1) {
this.neighbours(minedSquares[i]).forEach((square) => {
if (square.bomb) return
square.value += 1
})
}
}
neighbours (square) {
const { col, row } = square
const prevCol = col - 1
const nextCol = col + 1
const prevRow = row - 1
const nextRow = row + 1
return [
this.field[prevCol]?.[row],
this.field[nextCol]?.[row],
this.field[prevCol]?.[prevRow],
this.field[prevCol]?.[nextRow],
this.field[nextCol]?.[prevRow],
this.field[nextCol]?.[nextRow],
this.field[col]?.[prevRow],
this.field[col]?.[nextRow],
].filter(Boolean)
}
openSquare (col, row, skipChecks) {
const square = this.field[col]?.[row]
if (!skipChecks) {
if (!square) throw new Error(
`Wrong square [${col} ${row}]. The maximum values are [${this.width - 1} ${this.height - 1}].`
)
if (square.open) throw new Error(`The square ${square.col} ${square.row} is already opened.`)
if (square.flag) throw new Error('The square is flagged. Take off the flag first.')
}
if (this.firstTurn) {
this.mine(square)
this.firstTurn = false
}
square.open = true
if (square.bomb) {
square.bomb = '\x1b[0m\x1b[30;5;41mØ\x1b[0m'
this.openAll()
this.over = '\x1b[41m You loose! \x1b[0m'
return
}
if (this.squares.filter(({ bomb }) => !bomb).every(({ open }) => open)) {
this.openAll()
this.over = '\x1b[42;30m You win! \x1b[0m'
return
}
const neighbours = this.neighbours(square)
if (neighbours.every(({ bomb }) => !bomb)) {
neighbours
.filter(({ open, flag }) => !flag && !open)
.forEach(({ col, row }) => {
this.openSquare(col, row, true)
})
}
}
flagSquare (col, row) {
const square = this.field[col]?.[row]
if (!square) throw new Error(
`Wrong square [${col} ${row}]. The maximum values are [${this.width - 1} ${this.height - 1}].`
)
if (square.open) throw new Error('The square is already opened.')
square.flag = !square.flag
}
openAll () {
this.squares
.filter(({ open }) => !open)
.forEach((sq) => sq.open = true)
}
toString () {
return new Grid(this).render()
}
}
const isNumeric = (str) => !isNaN(Number(str))
const isRange = (str) => /^\d+-\d+$/.test(str)
const expandRange = (args) => {
const result = []
while (args.length > 1) {
const [row, col] = args.splice(0, 2)
if (isNumeric(col) && isNumeric(row)) {
result.push([row, col])
}
if (isRange(col) && isNumeric(row)) {
const [fr, to] = col.split('-')
for (let i = Number(fr); i <= Number(to); i += 1) {
result.push([row, i])
}
}
if (isRange(row) && isNumeric(col)) {
const [fr, to] = row.split('-')
for (let i = Number(fr); i <= Number(to); i += 1) {
result.push([i, col])
}
}
}
return result
}
const app = readlinePromises.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
})
app.prompt()
let game
app.on('line', (line) => {
const [command, ...args] = line.split(/\s+/).filter(Boolean)
switch(command) {
case 'log':
console.dir(game)
break
case 'n':
case 'new':
if (args.length === 3 && args.every(isNumeric)) {
const [width, height, minesNumber] = args
try {
game = new Game(width, height, minesNumber)
}
catch (error) {
process.stdout.write(`\n \x1b[41m${error.message}\x1b[0m\n`)
}
}
break
case 'o':
case 'open':
if (args.length === 2 && args.every(isNumeric)) {
const [row, col] = args
try {
game.openSquare(col, row)
}
catch (error) {
process.stdout.write(`\n \x1b[41m${error.message}\x1b[0m\n`)
}
}
break
case 'f':
case 'flag':
if (args.length > 0 && args.length % 2 === 0) {
try {
expandRange(args).forEach((square) => {
const [row, col] = square
game.flagSquare(col, row)
})
}
catch (error) {
process.stdout.write(`\n \x1b[41m${error.message}\x1b[0m\n`)
}
}
break
default:
if (isNumeric(command) && args.length === 1 && isNumeric(args[0])) {
try {
game.openSquare(args[0], command)
}
catch (error) {
process.stdout.write(`\n \x1b[41m${error.message}\x1b[0m\n`)
}
}
break
}
process.stdout.write(`\n${game}\n\n`)
app.prompt()
})
/* Colors */
// Reset = "\x1b[0m"
// Bright = "\x1b[1m"
// Dim = "\x1b[2m"
// Italic = "\x1b[3m"
// Underscore = "\x1b[4m"
// Blink = "\x1b[5m"
// Reverse = "\x1b[7m"
// Hidden = "\x1b[8m"
// FgBlack = "\x1b[30m"
// FgRed = "\x1b[31m"
// FgGreen = "\x1b[32m"
// FgYellow = "\x1b[33m"
// FgBlue = "\x1b[34m"
// FgMagenta = "\x1b[35m"
// FgCyan = "\x1b[36m"
// FgWhite = "\x1b[37m"
// BgBlack = "\x1b[40m"
// BgRed = "\x1b[41m"
// BgGreen = "\x1b[42m"
// BgYellow = "\x1b[43m"
// BgBlue = "\x1b[44m"
// BgMagenta = "\x1b[45m"
// BgCyan = "\x1b[46m"
// BgWhite = "\x1b[47m"
/* Modes */
// Beginner: 81 tiles, 10 mines
// Intermediate: 256 tiles, 40 mines
// Expert: 480 tiles, 99 mines
@Piterden
Copy link
Author

Launch:
node onefile.js

New game:
{n|new} width height mines
Example:
n 15 15 60

Open square
[o|open] x y
Example:
12 6

Rise flag
{f|flag} x y
Example:
f 11 8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment