Minesweeper / minesweeper.ts
T1ckbase
add counter and timer
8b94c2a
raw
history blame
14.9 kB
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);
}
}