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; 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 { 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); } }