Created
January 11, 2023 00:35
-
-
Save Piterden/ec96e07fb5cc9248cce69871a7fbdc02 to your computer and use it in GitHub Desktop.
NodeJS Minesweeper for terminal. No deps. One file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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