Spaces:
Paused
Paused
File size: 14,894 Bytes
a0040c1 8b94c2a a0040c1 8b94c2a a0040c1 3052df1 a0040c1 8b94c2a a0040c1 8b94c2a a0040c1 8b94c2a a0040c1 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 |
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);
}
}
|