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