Spaces:
Paused
Paused
import * as path from '@std/path'; | |
export interface Cell { | |
isMine: boolean; | |
isRevealed: boolean; | |
adjacentMines: number; | |
} | |
export type GameState = 'playing' | 'won' | 'lost'; | |
export type ImageKey = | |
| 'cell_hidden' // gray-button.svg | |
| 'cell_revealed_0' // gray.svg | |
| 'cell_revealed_1' // 1.svg | |
| 'cell_revealed_2' // 2.svg | |
| 'cell_revealed_3' // 3.svg | |
| 'cell_revealed_4' // 4.svg | |
| 'cell_revealed_5' // 5.svg | |
| 'cell_revealed_6' // 6.svg | |
| 'cell_revealed_7' // 7.svg | |
| 'cell_revealed_8' // 8.svg | |
| 'mine_normal' // mine.svg (for unrevealed mines at game end, or revealed mines in won state) | |
| 'mine_hit' // mine-red.svg (for the mine that was clicked and caused loss) | |
| 'status_playing' // emoji-smile.svg | |
| 'status_won' // emoji-sunglasses.svg | |
| 'status_lost' // emoji-dead.svg | |
| 'counter' // counter.svg | |
| 'timer'; // timer.svg | |
export class Minesweeper { | |
// deno-fmt-ignore | |
// Static readonly array for neighbor directions | |
private static readonly DIRECTIONS: ReadonlyArray<[number, number]> = [ | |
[-1, -1], [-1, 0], [-1, 1], | |
[0, -1], [0, 1], | |
[1, -1], [1, 0], [1, 1], | |
]; | |
private board: Cell[][]; | |
private gameState: GameState; | |
private mineCount: number; | |
private rows: number; | |
private cols: number; | |
private remainingNonMineCells: number; // Number of non-mine cells that need to be revealed to win | |
private hitMineCoordinates: { row: number; col: number } | null = null; // To identify the exploded mine | |
private startTime: Date; // Time when the game was started | |
private endTime: Date | null = null; // Time when the game ended (won or lost) | |
// Store loaded images | |
private imageCache: Map<ImageKey, Uint8Array>; | |
private imageDirectory: string; | |
private decoder = new TextDecoder('utf-8'); | |
private encoder = new TextEncoder(); | |
constructor(rows: number, cols: number, mineCount: number, imageDirectory: string = './image') { | |
// Validate input parameters | |
if (rows <= 0 || cols <= 0) throw new Error('Board dimensions (rows, cols) must be positive integers.'); | |
if (mineCount < 0) throw new Error('Mine count cannot be negative.'); | |
// If mineCount > rows * cols, the placeMines method would loop indefinitely | |
// as it tries to place more mines than available cells. | |
if (mineCount > rows * cols) throw new Error('Mine count cannot exceed the total number of cells (rows * cols).'); | |
this.rows = rows; | |
this.cols = cols; | |
this.mineCount = mineCount; | |
this.gameState = 'playing'; | |
// This tracks the number of non-mine cells that still need to be revealed for the player to win. | |
this.remainingNonMineCells = rows * cols - mineCount; | |
this.board = this.initializeBoard(); | |
this.placeMines(); | |
this.calculateAdjacentMines(); | |
this.startTime = new Date(); // Record the start time of the game | |
this.imageDirectory = imageDirectory; | |
this.imageCache = new Map(); | |
this.loadImages(); // Load images upon initialization | |
} | |
/** | |
* Defines the mapping from logical image keys to their filenames. | |
*/ | |
private getImageFileMap(): Record<ImageKey, string> { | |
return { | |
cell_hidden: 'gray-button.svg', | |
cell_revealed_0: 'gray.svg', | |
cell_revealed_1: '1.svg', | |
cell_revealed_2: '2.svg', | |
cell_revealed_3: '3.svg', | |
cell_revealed_4: '4.svg', | |
cell_revealed_5: '5.svg', | |
cell_revealed_6: '6.svg', | |
cell_revealed_7: '7.svg', | |
cell_revealed_8: '8.svg', | |
mine_normal: 'mine.svg', | |
mine_hit: 'mine-red.svg', | |
status_playing: 'emoji-surprise-smile.svg', | |
status_won: 'emoji-sunglasses.svg', | |
status_lost: 'emoji-dead.svg', | |
counter: 'counter.svg', | |
timer: 'timer.svg', | |
}; | |
} | |
/** | |
* Loads all necessary images from the specified directory into the imageCache. | |
* This method assumes a Node.js environment for file system access. | |
*/ | |
private loadImages(): void { | |
const imageFileMap = this.getImageFileMap(); | |
for (const key in imageFileMap) { | |
if (Object.prototype.hasOwnProperty.call(imageFileMap, key)) { | |
const typedKey = key as ImageKey; | |
const fileName = imageFileMap[typedKey]; | |
const filePath = path.join(this.imageDirectory, fileName); // Use path.join for cross-platform compatibility | |
try { | |
const fileBuffer = Deno.readFileSync(filePath); | |
this.imageCache.set(typedKey, fileBuffer); // Deno.readFileSync returns a Buffer, which is a Uint8Array | |
// console.log(`Loaded image: ${filePath} for key: ${typedKey}`); | |
} catch (error) { | |
console.error(`Failed to load image ${filePath} for key ${typedKey}:`, error); | |
// You might want to throw an error here or have a default placeholder image | |
} | |
} | |
} | |
} | |
/** | |
* Initializes or resets the game to a new state with the current settings. | |
* This method is called by the constructor and resetGame. | |
*/ | |
private initializeNewGame(): void { | |
this.gameState = 'playing'; | |
this.remainingNonMineCells = this.rows * this.cols - this.mineCount; | |
this.board = this.initializeBoard(); | |
this.placeMines(); | |
this.calculateAdjacentMines(); | |
this.startTime = new Date(); // Record/Reset the start time | |
this.endTime = null; // Reset the end time | |
this.hitMineCoordinates = null; // Reset hit mine coordinates | |
} | |
/** | |
* Initializes the game board with all cells set to default state (not a mine, not revealed, 0 adjacent mines). | |
* @returns A 2D array of Cell objects representing the initialized board. | |
*/ | |
private initializeBoard(): Cell[][] { | |
return Array.from({ length: this.rows }, () => | |
Array.from({ length: this.cols }, () => ({ | |
isMine: false, | |
isRevealed: false, | |
adjacentMines: 0, | |
}))); | |
} | |
/** | |
* Randomly places the specified number of mines on the board. | |
* Ensures that mines are placed only on cells that do not already contain a mine. | |
*/ | |
private placeMines(): void { | |
let minesPlaced = 0; | |
// Ensure board is clean of mines if this is called during a reset | |
for (let r = 0; r < this.rows; r++) { | |
for (let c = 0; c < this.cols; c++) { | |
this.board[r][c].isMine = false; | |
} | |
} | |
while (minesPlaced < this.mineCount) { | |
const row = Math.floor(Math.random() * this.rows); | |
const col = Math.floor(Math.random() * this.cols); | |
if (!this.board[row][col].isMine) { | |
this.board[row][col].isMine = true; | |
minesPlaced++; | |
} | |
} | |
} | |
/** | |
* Calculates and stores the number of adjacent mines for each cell on the board that is not a mine itself. | |
*/ | |
private calculateAdjacentMines(): void { | |
for (let row = 0; row < this.rows; row++) { | |
for (let col = 0; col < this.cols; col++) { | |
this.board[row][col].adjacentMines = 0; // Reset for recalculation (e.g., on game reset) | |
if (this.board[row][col].isMine) { | |
continue; // Mines don't have an adjacent mine count in this context | |
} | |
let count = 0; | |
for (const [dr, dc] of Minesweeper.DIRECTIONS) { | |
const newRow = row + dr; | |
const newCol = col + dc; | |
if (this.isValidPosition(newRow, newCol) && this.board[newRow][newCol].isMine) { | |
count++; | |
} | |
} | |
this.board[row][col].adjacentMines = count; | |
} | |
} | |
} | |
/** | |
* Checks if a given row and column are within the valid boundaries of the game board. | |
* @param row The row index to check. | |
* @param col The column index to check. | |
* @returns True if the position is valid, false otherwise. | |
*/ | |
private isValidPosition(row: number, col: number): boolean { | |
return row >= 0 && row < this.rows && col >= 0 && col < this.cols; | |
} | |
/** | |
* Reveals all cells on the board. This is typically called when the game ends. | |
*/ | |
private revealAllCells(): void { | |
for (let r = 0; r < this.rows; r++) { | |
for (let c = 0; c < this.cols; c++) { | |
this.board[r][c].isRevealed = true; | |
} | |
} | |
} | |
/** | |
* Handles the logic for revealing a cell at the given row and column. | |
* If the cell is a mine, the game is lost. | |
* If the cell has 0 adjacent mines, it triggers a recursive reveal of neighboring cells (flood fill). | |
* Checks for win condition after a successful reveal. | |
* @param row The row index of the cell to reveal. | |
* @param col The column index of the cell to reveal. | |
*/ | |
public revealCell(row: number, col: number): void { | |
// Ignore if game is over, position is invalid, or cell is already revealed | |
if (this.gameState !== 'playing' || !this.isValidPosition(row, col) || this.board[row][col].isRevealed) { | |
return; | |
} | |
const cell = this.board[row][col]; | |
cell.isRevealed = true; | |
// Note: The time of the last move that *concludes* the game is captured by this.endTime. | |
if (cell.isMine) { | |
this.gameState = 'lost'; | |
this.endTime = new Date(); // Record game end time | |
this.hitMineCoordinates = { row, col }; // Record which mine was hit | |
this.revealAllCells(); // Reveal all cells as the game is lost | |
return; | |
} | |
// If it's a non-mine cell, decrement the count of remaining non-mine cells to be revealed. | |
this.remainingNonMineCells--; | |
// If the revealed cell has no adjacent mines, recursively reveal its neighbors (flood fill). | |
if (cell.adjacentMines === 0) { | |
for (const [dr, dc] of Minesweeper.DIRECTIONS) { | |
const newRow = row + dr; | |
const newCol = col + dc; | |
// The recursive call to revealCell itself handles isValidPosition and isRevealed checks. | |
this.revealCell(newRow, newCol); | |
} | |
} | |
// Check for win condition if all non-mine cells have been revealed. | |
if (this.checkWinCondition()) { | |
this.gameState = 'won'; | |
this.endTime = new Date(); // Record game end time | |
this.revealAllCells(); // Reveal all cells as the game is won | |
} | |
} | |
/** | |
* Checks if the win condition has been met (all non-mine cells are revealed). | |
* @returns True if the player has won, false otherwise. | |
*/ | |
private checkWinCondition(): boolean { | |
return this.remainingNonMineCells === 0; | |
} | |
/** | |
* Resets the game to its initial state with the same dimensions and mine count. | |
* This method can only be called if the game is currently in a 'won' or 'lost' state. | |
* @throws Error if the game is still 'playing'. | |
*/ | |
public resetGame(): void { | |
if (this.gameState === 'playing') { | |
throw new Error("Cannot reset the game while it is still in progress. Game must be 'won' or 'lost'."); | |
} | |
// Re-initialize the game using the original settings | |
this.initializeNewGame(); | |
} | |
/** | |
* @returns The current state of the game board (2D array of Cells). | |
*/ | |
public getBoard(): Cell[][] { | |
return this.board; | |
} | |
/** | |
* @returns The current game state ('playing', 'won', or 'lost'). | |
*/ | |
public getGameState(): GameState { | |
return this.gameState; | |
} | |
/** | |
* @returns The Date object representing when the game started. | |
*/ | |
public getStartTime(): Date { | |
return this.startTime; | |
} | |
/** | |
* @returns The Date object representing when the game ended, or null if the game is still in progress. | |
* This also serves as the time of the "last move" that concluded the game. | |
*/ | |
public getEndTime(): Date | null { | |
return this.endTime; | |
} | |
/** | |
* Gets the Uint8Array for the image corresponding to the cell's current state. | |
* @param row The row of the cell. | |
* @param col The column of the cell. | |
* @returns The Uint8Array of the image, or undefined if no image is found for the state. | |
*/ | |
public getCellImage(row: number, col: number): Uint8Array | undefined { | |
if (!this.isValidPosition(row, col)) { | |
console.warn(`getCellImage: Invalid position (${row}, ${col})`); | |
return undefined; | |
} | |
const cell = this.board[row][col]; | |
// If cell is not revealed (only possible if game is 'playing') | |
if (!cell.isRevealed) { | |
// During 'playing' state, all unrevealed cells are hidden buttons. | |
// If the game has ended (won/lost), `revealAllCells` makes all cells `isRevealed = true`, | |
// so this branch effectively only runs when gameState === 'playing'. | |
return this.imageCache.get('cell_hidden'); | |
} | |
// Cell is revealed (either by user action or by revealAllCells at game end) | |
if (cell.isMine) { | |
if (this.gameState === 'lost') { | |
// If this specific mine was the one clicked that ended the game | |
if (this.hitMineCoordinates && this.hitMineCoordinates.row === row && this.hitMineCoordinates.col === col) { | |
return this.imageCache.get('mine_hit'); // e.g., mine-red.svg | |
} | |
// Other mines revealed after losing | |
return this.imageCache.get('mine_normal'); // e.g., mine.svg | |
} | |
if (this.gameState === 'won') { | |
// All mines are revealed peacefully when the game is won | |
return this.imageCache.get('mine_normal'); // e.g., mine.svg | |
} | |
// Fallback: Should not happen if a mine is revealed while 'playing', as game would end. | |
// But if it did, treat it as a hit mine. | |
return this.imageCache.get('mine_hit'); | |
} else { | |
// Revealed and not a mine | |
const key = `cell_revealed_${cell.adjacentMines}` as ImageKey; // e.g., cell_revealed_0 for gray.svg, cell_revealed_1 for 1.svg | |
return this.imageCache.get(key); | |
} | |
} | |
/** | |
* Gets the Uint8Array for the image corresponding to the current game status. | |
* @returns The Uint8Array of the status image, or undefined if not found. | |
*/ | |
public getGameStatusImage(): Uint8Array | undefined { | |
switch (this.gameState) { | |
case 'playing': | |
return this.imageCache.get('status_playing'); | |
case 'won': | |
return this.imageCache.get('status_won'); | |
case 'lost': | |
return this.imageCache.get('status_lost'); | |
default: | |
return this.imageCache.get('status_playing'); // Fallback | |
} | |
} | |
public getCounterImage(count: number): Uint8Array | undefined { | |
const image = this.imageCache.get('counter'); | |
if (!image) return; | |
const decodedImage = this.decoder.decode(image); | |
const newImage = decodedImage.replace('--count: 0;', `--count: ${count};`); | |
return this.encoder.encode(newImage); | |
} | |
public getTimerImage(): Uint8Array | undefined { | |
const seconds = Math.floor(((this.gameState === 'playing' ? Date.now() : +this.endTime!) - +this.startTime) / 1000); | |
if (this.gameState !== 'playing') return this.getCounterImage(seconds); | |
const image = this.imageCache.get('timer'); | |
if (!image) return; | |
const decodedImage = this.decoder.decode(image); | |
const newImage = decodedImage.replace('animation: timer 1000s linear infinite;', `animation: timer 1000s linear infinite -${seconds}s;`); | |
return this.encoder.encode(newImage); | |
} | |
} | |