Spaces:
Running
Running
T1ckbase
commited on
Commit
·
a0040c1
1
Parent(s):
aa1e918
update
Browse files- .gitignore +1 -0
- deno.json +2 -1
- generate-table.ts +23 -0
- main.ts +80 -5
- minesweeper.ts +377 -0
- utils.ts +22 -0
.gitignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
.env
|
deno.json
CHANGED
@@ -1,9 +1,10 @@
|
|
1 |
{
|
2 |
"tasks": {
|
3 |
-
"start": "deno --allow-net --watch main.ts"
|
4 |
},
|
5 |
"imports": {
|
6 |
"@std/async": "jsr:@std/async@^1.0.12",
|
|
|
7 |
"hono": "jsr:@hono/hono@^4.7.8"
|
8 |
},
|
9 |
"fmt": {
|
|
|
1 |
{
|
2 |
"tasks": {
|
3 |
+
"start": "deno --allow-net --allow-read --allow-env --env-file=.env --watch main.ts"
|
4 |
},
|
5 |
"imports": {
|
6 |
"@std/async": "jsr:@std/async@^1.0.12",
|
7 |
+
"@std/path": "jsr:@std/path@^1.0.9",
|
8 |
"hono": "jsr:@hono/hono@^4.7.8"
|
9 |
},
|
10 |
"fmt": {
|
generate-table.ts
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const rows = 8;
|
2 |
+
const cols = 8;
|
3 |
+
const baseUrl = 'https://t1ckbase-minesweeper.hf.space';
|
4 |
+
|
5 |
+
const html = '<table id="toc">\n' +
|
6 |
+
' <tr>\n' +
|
7 |
+
' <td align="center">\n' +
|
8 |
+
` <a href="${baseUrl}/game/reset"><img src="${baseUrl}/game/status" width="48px" height="48px" /></a>\n` +
|
9 |
+
' </td>\n' +
|
10 |
+
' </tr>\n' +
|
11 |
+
Array.from({ length: rows }, (_, r) =>
|
12 |
+
' <tr>\n' +
|
13 |
+
' <td align="center">\n' +
|
14 |
+
Array.from({ length: cols }, (_, c) => {
|
15 |
+
const imageUrl = `${baseUrl}/cell/${r}/${c}/image`;
|
16 |
+
const clickUrl = `${baseUrl}/cell/${r}/${c}/click`;
|
17 |
+
return ` <a href="${clickUrl}"><img src="${imageUrl}" width="32px" height="32px" /></a>`;
|
18 |
+
}).join('\n') + '\n' +
|
19 |
+
' </td>\n' +
|
20 |
+
' </tr>').join('\n') +
|
21 |
+
'\n</table>';
|
22 |
+
|
23 |
+
console.log(html);
|
main.ts
CHANGED
@@ -1,19 +1,94 @@
|
|
1 |
import { Hono } from 'hono';
|
2 |
import { logger } from 'hono/logger';
|
3 |
-
import {
|
|
|
|
|
|
|
4 |
|
5 |
// https://t1ckbase-minesweeper.hf.space
|
6 |
|
|
|
|
|
|
|
|
|
7 |
const app = new Hono();
|
8 |
|
9 |
app.use(logger());
|
10 |
|
11 |
-
app.get('/', (c) => c.text(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
-
// app.get('*', serveStatic({ path: './gray.svg' }));
|
14 |
-
app.get('*', (c) => {
|
15 |
c.header('Content-Type', 'image/svg+xml');
|
16 |
-
return c.body(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
});
|
18 |
|
19 |
Deno.serve(app.fetch);
|
|
|
1 |
import { Hono } from 'hono';
|
2 |
import { logger } from 'hono/logger';
|
3 |
+
import { Minesweeper } from './minesweeper.ts';
|
4 |
+
import { isGithubUserPath } from './utils.ts';
|
5 |
+
|
6 |
+
// TODO: check header referer
|
7 |
|
8 |
// https://t1ckbase-minesweeper.hf.space
|
9 |
|
10 |
+
const USER = 'T1ckbase';
|
11 |
+
|
12 |
+
const minesweeper = new Minesweeper(8, 8, 10, './images');
|
13 |
+
|
14 |
const app = new Hono();
|
15 |
|
16 |
app.use(logger());
|
17 |
|
18 |
+
app.get('/', (c) => c.text(`Play minesweeper:\nhttps://github.com/${USER}`));
|
19 |
+
|
20 |
+
app.get('/headers', (c) => c.text(Array.from(c.req.raw.headers).join('\n')));
|
21 |
+
|
22 |
+
if (Deno.env.get('DENO_ENV') === 'development') {
|
23 |
+
// app.get('/board', (c) => c.text(JSON.stringify(minesweeper.getBoard(), null, 2)));
|
24 |
+
app.get('/board', (c) => c.text(minesweeper.getBoard().map((row) => row.map((cell) => cell.isMine ? 'b' : cell.adjacentMines).join('')).join('\n')));
|
25 |
+
}
|
26 |
+
|
27 |
+
app.get('/cell/:row/:col/image', (c) => {
|
28 |
+
const row = Number(c.req.param('row'));
|
29 |
+
const col = Number(c.req.param('col'));
|
30 |
+
if (Number.isNaN(row) || Number.isNaN(col)) return c.text('Invalid coordinates', 400);
|
31 |
+
|
32 |
+
const cellImage = minesweeper.getCellImage(row, col);
|
33 |
+
if (!cellImage) return c.text(`Not Found: Image for cell (${row}, ${col}) could not be found. Coordinates may be invalid or no image is defined for this cell state.`, 404);
|
34 |
|
|
|
|
|
35 |
c.header('Content-Type', 'image/svg+xml');
|
36 |
+
return c.body(cellImage);
|
37 |
+
});
|
38 |
+
|
39 |
+
app.get('/cell/:row/:col/click', (c) => {
|
40 |
+
const row = Number(c.req.param('row'));
|
41 |
+
const col = Number(c.req.param('col'));
|
42 |
+
if (Number.isNaN(row) || Number.isNaN(col)) return c.text('Invalid coordinates', 400);
|
43 |
+
|
44 |
+
const referer = c.req.header('Referer');
|
45 |
+
let redirectUrl = `https://github.com/${USER}`;
|
46 |
+
if (referer) {
|
47 |
+
if (isGithubUserPath(referer, USER)) {
|
48 |
+
redirectUrl = referer;
|
49 |
+
} else {
|
50 |
+
console.warn(`Invalid or non-GitHub referer: ${referer}`);
|
51 |
+
// return c.text('?', 403);
|
52 |
+
}
|
53 |
+
} else {
|
54 |
+
console.warn('Referer header is missing.');
|
55 |
+
// return c.text('?', 403);
|
56 |
+
}
|
57 |
+
|
58 |
+
minesweeper.revealCell(row, col);
|
59 |
+
|
60 |
+
return c.redirect(redirectUrl);
|
61 |
+
});
|
62 |
+
|
63 |
+
app.get('/game/status', (c) => {
|
64 |
+
c.header('Content-Type', 'image/svg+xml');
|
65 |
+
const image = minesweeper.getGameStatusImage();
|
66 |
+
if (!image) return c.text('Status image is not available.', 404);
|
67 |
+
return c.body(image);
|
68 |
+
});
|
69 |
+
|
70 |
+
app.get('/game/reset', (c) => {
|
71 |
+
const referer = c.req.header('Referer');
|
72 |
+
let redirectUrl = `https://github.com/${USER}`;
|
73 |
+
if (referer) {
|
74 |
+
if (isGithubUserPath(referer, USER)) {
|
75 |
+
redirectUrl = referer;
|
76 |
+
} else {
|
77 |
+
console.warn(`Invalid or non-GitHub referer: ${referer}`);
|
78 |
+
// return c.text('?', 403);
|
79 |
+
}
|
80 |
+
} else {
|
81 |
+
console.warn('Referer header is missing.');
|
82 |
+
// return c.text('?', 403);
|
83 |
+
}
|
84 |
+
|
85 |
+
try {
|
86 |
+
minesweeper.resetGame();
|
87 |
+
} catch (e) {
|
88 |
+
console.warn(e instanceof Error ? e.message : e);
|
89 |
+
}
|
90 |
+
|
91 |
+
return c.redirect(redirectUrl);
|
92 |
});
|
93 |
|
94 |
Deno.serve(app.fetch);
|
minesweeper.ts
ADDED
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as path from '@std/path';
|
2 |
+
|
3 |
+
export interface Cell {
|
4 |
+
isMine: boolean;
|
5 |
+
isRevealed: boolean;
|
6 |
+
adjacentMines: number;
|
7 |
+
}
|
8 |
+
|
9 |
+
export type GameState = 'playing' | 'won' | 'lost';
|
10 |
+
|
11 |
+
export type ImageKey =
|
12 |
+
| 'cell_hidden' // gray-button.svg
|
13 |
+
| 'cell_revealed_0' // gray.svg
|
14 |
+
| 'cell_revealed_1' // 1.svg
|
15 |
+
| 'cell_revealed_2' // 2.svg
|
16 |
+
| 'cell_revealed_3' // 3.svg
|
17 |
+
| 'cell_revealed_4' // 4.svg
|
18 |
+
| 'cell_revealed_5' // 5.svg
|
19 |
+
| 'cell_revealed_6' // 6.svg
|
20 |
+
| 'cell_revealed_7' // 7.svg
|
21 |
+
| 'cell_revealed_8' // 8.svg
|
22 |
+
| 'mine_normal' // mine.svg (for unrevealed mines at game end, or revealed mines in won state)
|
23 |
+
| 'mine_hit' // mine-red.svg (for the mine that was clicked and caused loss)
|
24 |
+
| 'status_playing' // emoji-smile.svg
|
25 |
+
| 'status_won' // emoji-sunglasses.svg
|
26 |
+
| 'status_lost'; // emoji-dead.svg
|
27 |
+
|
28 |
+
export class Minesweeper {
|
29 |
+
// deno-fmt-ignore
|
30 |
+
// Static readonly array for neighbor directions
|
31 |
+
private static readonly DIRECTIONS: ReadonlyArray<[number, number]> = [
|
32 |
+
[-1, -1], [-1, 0], [-1, 1],
|
33 |
+
[0, -1], [0, 1],
|
34 |
+
[1, -1], [1, 0], [1, 1],
|
35 |
+
];
|
36 |
+
|
37 |
+
private board: Cell[][];
|
38 |
+
private gameState: GameState;
|
39 |
+
private mineCount: number;
|
40 |
+
private rows: number;
|
41 |
+
private cols: number;
|
42 |
+
private remainingNonMineCells: number; // Number of non-mine cells that need to be revealed to win
|
43 |
+
private hitMineCoordinates: { row: number; col: number } | null = null; // To identify the exploded mine
|
44 |
+
|
45 |
+
private startTime: Date; // Time when the game was started
|
46 |
+
private endTime: Date | null = null; // Time when the game ended (won or lost)
|
47 |
+
|
48 |
+
// Store loaded images
|
49 |
+
private imageCache: Map<ImageKey, Uint8Array>;
|
50 |
+
private imageDirectory: string;
|
51 |
+
|
52 |
+
constructor(rows: number, cols: number, mineCount: number, imageDirectory: string = './image') {
|
53 |
+
// Validate input parameters
|
54 |
+
if (rows <= 0 || cols <= 0) throw new Error('Board dimensions (rows, cols) must be positive integers.');
|
55 |
+
if (mineCount < 0) throw new Error('Mine count cannot be negative.');
|
56 |
+
// If mineCount > rows * cols, the placeMines method would loop indefinitely
|
57 |
+
// as it tries to place more mines than available cells.
|
58 |
+
if (mineCount > rows * cols) throw new Error('Mine count cannot exceed the total number of cells (rows * cols).');
|
59 |
+
|
60 |
+
this.rows = rows;
|
61 |
+
this.cols = cols;
|
62 |
+
this.mineCount = mineCount;
|
63 |
+
|
64 |
+
this.gameState = 'playing';
|
65 |
+
// This tracks the number of non-mine cells that still need to be revealed for the player to win.
|
66 |
+
this.remainingNonMineCells = rows * cols - mineCount;
|
67 |
+
this.board = this.initializeBoard();
|
68 |
+
this.placeMines();
|
69 |
+
this.calculateAdjacentMines();
|
70 |
+
this.startTime = new Date(); // Record the start time of the game
|
71 |
+
|
72 |
+
this.imageDirectory = imageDirectory;
|
73 |
+
this.imageCache = new Map();
|
74 |
+
this.loadImages(); // Load images upon initialization
|
75 |
+
}
|
76 |
+
|
77 |
+
/**
|
78 |
+
* Defines the mapping from logical image keys to their filenames.
|
79 |
+
*/
|
80 |
+
private getImageFileMap(): Record<ImageKey, string> {
|
81 |
+
return {
|
82 |
+
cell_hidden: 'gray-button.svg',
|
83 |
+
cell_revealed_0: 'gray.svg',
|
84 |
+
cell_revealed_1: '1.svg',
|
85 |
+
cell_revealed_2: '2.svg',
|
86 |
+
cell_revealed_3: '3.svg',
|
87 |
+
cell_revealed_4: '4.svg',
|
88 |
+
cell_revealed_5: '5.svg',
|
89 |
+
cell_revealed_6: '6.svg',
|
90 |
+
cell_revealed_7: '7.svg',
|
91 |
+
cell_revealed_8: '8.svg',
|
92 |
+
mine_normal: 'mine.svg',
|
93 |
+
mine_hit: 'mine-red.svg',
|
94 |
+
status_playing: 'emoji-smile.svg',
|
95 |
+
status_won: 'emoji-sunglasses.svg',
|
96 |
+
status_lost: 'emoji-dead.svg',
|
97 |
+
};
|
98 |
+
}
|
99 |
+
|
100 |
+
/**
|
101 |
+
* Loads all necessary images from the specified directory into the imageCache.
|
102 |
+
* This method assumes a Node.js environment for file system access.
|
103 |
+
*/
|
104 |
+
private loadImages(): void {
|
105 |
+
const imageFileMap = this.getImageFileMap();
|
106 |
+
for (const key in imageFileMap) {
|
107 |
+
if (Object.prototype.hasOwnProperty.call(imageFileMap, key)) {
|
108 |
+
const typedKey = key as ImageKey;
|
109 |
+
const fileName = imageFileMap[typedKey];
|
110 |
+
const filePath = path.join(this.imageDirectory, fileName); // Use path.join for cross-platform compatibility
|
111 |
+
try {
|
112 |
+
const fileBuffer = Deno.readFileSync(filePath);
|
113 |
+
this.imageCache.set(typedKey, new Uint8Array(fileBuffer)); // Deno.readFileSync returns a Buffer, which is a Uint8Array
|
114 |
+
// console.log(`Loaded image: ${filePath} for key: ${typedKey}`);
|
115 |
+
} catch (error) {
|
116 |
+
console.error(`Failed to load image ${filePath} for key ${typedKey}:`, error);
|
117 |
+
// You might want to throw an error here or have a default placeholder image
|
118 |
+
}
|
119 |
+
}
|
120 |
+
}
|
121 |
+
}
|
122 |
+
|
123 |
+
/**
|
124 |
+
* Initializes or resets the game to a new state with the current settings.
|
125 |
+
* This method is called by the constructor and resetGame.
|
126 |
+
*/
|
127 |
+
private initializeNewGame(): void {
|
128 |
+
this.gameState = 'playing';
|
129 |
+
this.remainingNonMineCells = this.rows * this.cols - this.mineCount;
|
130 |
+
this.board = this.initializeBoard();
|
131 |
+
this.placeMines();
|
132 |
+
this.calculateAdjacentMines();
|
133 |
+
this.startTime = new Date(); // Record/Reset the start time
|
134 |
+
this.endTime = null; // Reset the end time
|
135 |
+
this.hitMineCoordinates = null; // Reset hit mine coordinates
|
136 |
+
}
|
137 |
+
|
138 |
+
/**
|
139 |
+
* Initializes the game board with all cells set to default state (not a mine, not revealed, 0 adjacent mines).
|
140 |
+
* @returns A 2D array of Cell objects representing the initialized board.
|
141 |
+
*/
|
142 |
+
private initializeBoard(): Cell[][] {
|
143 |
+
return Array.from({ length: this.rows }, () =>
|
144 |
+
Array.from({ length: this.cols }, () => ({
|
145 |
+
isMine: false,
|
146 |
+
isRevealed: false,
|
147 |
+
adjacentMines: 0,
|
148 |
+
})));
|
149 |
+
}
|
150 |
+
|
151 |
+
/**
|
152 |
+
* Randomly places the specified number of mines on the board.
|
153 |
+
* Ensures that mines are placed only on cells that do not already contain a mine.
|
154 |
+
*/
|
155 |
+
private placeMines(): void {
|
156 |
+
let minesPlaced = 0;
|
157 |
+
// Ensure board is clean of mines if this is called during a reset
|
158 |
+
for (let r = 0; r < this.rows; r++) {
|
159 |
+
for (let c = 0; c < this.cols; c++) {
|
160 |
+
this.board[r][c].isMine = false;
|
161 |
+
}
|
162 |
+
}
|
163 |
+
|
164 |
+
while (minesPlaced < this.mineCount) {
|
165 |
+
const row = Math.floor(Math.random() * this.rows);
|
166 |
+
const col = Math.floor(Math.random() * this.cols);
|
167 |
+
if (!this.board[row][col].isMine) {
|
168 |
+
this.board[row][col].isMine = true;
|
169 |
+
minesPlaced++;
|
170 |
+
}
|
171 |
+
}
|
172 |
+
}
|
173 |
+
|
174 |
+
/**
|
175 |
+
* Calculates and stores the number of adjacent mines for each cell on the board that is not a mine itself.
|
176 |
+
*/
|
177 |
+
private calculateAdjacentMines(): void {
|
178 |
+
for (let row = 0; row < this.rows; row++) {
|
179 |
+
for (let col = 0; col < this.cols; col++) {
|
180 |
+
this.board[row][col].adjacentMines = 0; // Reset for recalculation (e.g., on game reset)
|
181 |
+
if (this.board[row][col].isMine) {
|
182 |
+
continue; // Mines don't have an adjacent mine count in this context
|
183 |
+
}
|
184 |
+
|
185 |
+
let count = 0;
|
186 |
+
for (const [dr, dc] of Minesweeper.DIRECTIONS) {
|
187 |
+
const newRow = row + dr;
|
188 |
+
const newCol = col + dc;
|
189 |
+
if (this.isValidPosition(newRow, newCol) && this.board[newRow][newCol].isMine) {
|
190 |
+
count++;
|
191 |
+
}
|
192 |
+
}
|
193 |
+
this.board[row][col].adjacentMines = count;
|
194 |
+
}
|
195 |
+
}
|
196 |
+
}
|
197 |
+
|
198 |
+
/**
|
199 |
+
* Checks if a given row and column are within the valid boundaries of the game board.
|
200 |
+
* @param row The row index to check.
|
201 |
+
* @param col The column index to check.
|
202 |
+
* @returns True if the position is valid, false otherwise.
|
203 |
+
*/
|
204 |
+
private isValidPosition(row: number, col: number): boolean {
|
205 |
+
return row >= 0 && row < this.rows && col >= 0 && col < this.cols;
|
206 |
+
}
|
207 |
+
|
208 |
+
/**
|
209 |
+
* Reveals all cells on the board. This is typically called when the game ends.
|
210 |
+
*/
|
211 |
+
private revealAllCells(): void {
|
212 |
+
for (let r = 0; r < this.rows; r++) {
|
213 |
+
for (let c = 0; c < this.cols; c++) {
|
214 |
+
this.board[r][c].isRevealed = true;
|
215 |
+
}
|
216 |
+
}
|
217 |
+
}
|
218 |
+
|
219 |
+
/**
|
220 |
+
* Handles the logic for revealing a cell at the given row and column.
|
221 |
+
* If the cell is a mine, the game is lost.
|
222 |
+
* If the cell has 0 adjacent mines, it triggers a recursive reveal of neighboring cells (flood fill).
|
223 |
+
* Checks for win condition after a successful reveal.
|
224 |
+
* @param row The row index of the cell to reveal.
|
225 |
+
* @param col The column index of the cell to reveal.
|
226 |
+
*/
|
227 |
+
public revealCell(row: number, col: number): void {
|
228 |
+
// Ignore if game is over, position is invalid, or cell is already revealed
|
229 |
+
if (this.gameState !== 'playing' || !this.isValidPosition(row, col) || this.board[row][col].isRevealed) {
|
230 |
+
return;
|
231 |
+
}
|
232 |
+
|
233 |
+
const cell = this.board[row][col];
|
234 |
+
cell.isRevealed = true;
|
235 |
+
// Note: The time of the last move that *concludes* the game is captured by this.endTime.
|
236 |
+
|
237 |
+
if (cell.isMine) {
|
238 |
+
this.gameState = 'lost';
|
239 |
+
this.endTime = new Date(); // Record game end time
|
240 |
+
this.hitMineCoordinates = { row, col }; // Record which mine was hit
|
241 |
+
this.revealAllCells(); // Reveal all cells as the game is lost
|
242 |
+
return;
|
243 |
+
}
|
244 |
+
|
245 |
+
// If it's a non-mine cell, decrement the count of remaining non-mine cells to be revealed.
|
246 |
+
this.remainingNonMineCells--;
|
247 |
+
|
248 |
+
// If the revealed cell has no adjacent mines, recursively reveal its neighbors (flood fill).
|
249 |
+
if (cell.adjacentMines === 0) {
|
250 |
+
for (const [dr, dc] of Minesweeper.DIRECTIONS) {
|
251 |
+
const newRow = row + dr;
|
252 |
+
const newCol = col + dc;
|
253 |
+
// The recursive call to revealCell itself handles isValidPosition and isRevealed checks.
|
254 |
+
this.revealCell(newRow, newCol);
|
255 |
+
}
|
256 |
+
}
|
257 |
+
|
258 |
+
// Check for win condition if all non-mine cells have been revealed.
|
259 |
+
if (this.checkWinCondition()) {
|
260 |
+
this.gameState = 'won';
|
261 |
+
this.endTime = new Date(); // Record game end time
|
262 |
+
this.revealAllCells(); // Reveal all cells as the game is won
|
263 |
+
}
|
264 |
+
}
|
265 |
+
|
266 |
+
/**
|
267 |
+
* Checks if the win condition has been met (all non-mine cells are revealed).
|
268 |
+
* @returns True if the player has won, false otherwise.
|
269 |
+
*/
|
270 |
+
private checkWinCondition(): boolean {
|
271 |
+
return this.remainingNonMineCells === 0;
|
272 |
+
}
|
273 |
+
|
274 |
+
/**
|
275 |
+
* Resets the game to its initial state with the same dimensions and mine count.
|
276 |
+
* This method can only be called if the game is currently in a 'won' or 'lost' state.
|
277 |
+
* @throws Error if the game is still 'playing'.
|
278 |
+
*/
|
279 |
+
public resetGame(): void {
|
280 |
+
if (this.gameState === 'playing') {
|
281 |
+
throw new Error("Cannot reset the game while it is still in progress. Game must be 'won' or 'lost'.");
|
282 |
+
}
|
283 |
+
// Re-initialize the game using the original settings
|
284 |
+
this.initializeNewGame();
|
285 |
+
}
|
286 |
+
|
287 |
+
/**
|
288 |
+
* @returns The current state of the game board (2D array of Cells).
|
289 |
+
*/
|
290 |
+
public getBoard(): Cell[][] {
|
291 |
+
return this.board;
|
292 |
+
}
|
293 |
+
|
294 |
+
/**
|
295 |
+
* @returns The current game state ('playing', 'won', or 'lost').
|
296 |
+
*/
|
297 |
+
public getGameState(): GameState {
|
298 |
+
return this.gameState;
|
299 |
+
}
|
300 |
+
|
301 |
+
/**
|
302 |
+
* @returns The Date object representing when the game started.
|
303 |
+
*/
|
304 |
+
public getStartTime(): Date {
|
305 |
+
return this.startTime;
|
306 |
+
}
|
307 |
+
|
308 |
+
/**
|
309 |
+
* @returns The Date object representing when the game ended, or null if the game is still in progress.
|
310 |
+
* This also serves as the time of the "last move" that concluded the game.
|
311 |
+
*/
|
312 |
+
public getEndTime(): Date | null {
|
313 |
+
return this.endTime;
|
314 |
+
}
|
315 |
+
|
316 |
+
/**
|
317 |
+
* Gets the Uint8Array for the image corresponding to the cell's current state.
|
318 |
+
* @param row The row of the cell.
|
319 |
+
* @param col The column of the cell.
|
320 |
+
* @returns The Uint8Array of the image, or undefined if no image is found for the state.
|
321 |
+
*/
|
322 |
+
public getCellImage(row: number, col: number): Uint8Array | undefined {
|
323 |
+
if (!this.isValidPosition(row, col)) {
|
324 |
+
console.warn(`getCellImage: Invalid position (${row}, ${col})`);
|
325 |
+
return undefined;
|
326 |
+
}
|
327 |
+
const cell = this.board[row][col];
|
328 |
+
|
329 |
+
// If cell is not revealed (only possible if game is 'playing')
|
330 |
+
if (!cell.isRevealed) {
|
331 |
+
// During 'playing' state, all unrevealed cells are hidden buttons.
|
332 |
+
// If the game has ended (won/lost), `revealAllCells` makes all cells `isRevealed = true`,
|
333 |
+
// so this branch effectively only runs when gameState === 'playing'.
|
334 |
+
return this.imageCache.get('cell_hidden');
|
335 |
+
}
|
336 |
+
|
337 |
+
// Cell is revealed (either by user action or by revealAllCells at game end)
|
338 |
+
if (cell.isMine) {
|
339 |
+
if (this.gameState === 'lost') {
|
340 |
+
// If this specific mine was the one clicked that ended the game
|
341 |
+
if (this.hitMineCoordinates && this.hitMineCoordinates.row === row && this.hitMineCoordinates.col === col) {
|
342 |
+
return this.imageCache.get('mine_hit'); // e.g., mine-red.svg
|
343 |
+
}
|
344 |
+
// Other mines revealed after losing
|
345 |
+
return this.imageCache.get('mine_normal'); // e.g., mine.svg
|
346 |
+
}
|
347 |
+
if (this.gameState === 'won') {
|
348 |
+
// All mines are revealed peacefully when the game is won
|
349 |
+
return this.imageCache.get('mine_normal'); // e.g., mine.svg
|
350 |
+
}
|
351 |
+
// Fallback: Should not happen if a mine is revealed while 'playing', as game would end.
|
352 |
+
// But if it did, treat it as a hit mine.
|
353 |
+
return this.imageCache.get('mine_hit');
|
354 |
+
} else {
|
355 |
+
// Revealed and not a mine
|
356 |
+
const key = `cell_revealed_${cell.adjacentMines}` as ImageKey; // e.g., cell_revealed_0 for gray.svg, cell_revealed_1 for 1.svg
|
357 |
+
return this.imageCache.get(key);
|
358 |
+
}
|
359 |
+
}
|
360 |
+
|
361 |
+
/**
|
362 |
+
* Gets the Uint8Array for the image corresponding to the current game status.
|
363 |
+
* @returns The Uint8Array of the status image, or undefined if not found.
|
364 |
+
*/
|
365 |
+
public getGameStatusImage(): Uint8Array | undefined {
|
366 |
+
switch (this.gameState) {
|
367 |
+
case 'playing':
|
368 |
+
return this.imageCache.get('status_playing');
|
369 |
+
case 'won':
|
370 |
+
return this.imageCache.get('status_won');
|
371 |
+
case 'lost':
|
372 |
+
return this.imageCache.get('status_lost');
|
373 |
+
default:
|
374 |
+
return this.imageCache.get('status_playing'); // Fallback
|
375 |
+
}
|
376 |
+
}
|
377 |
+
}
|
utils.ts
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export function isGitHubUrl(urlString: string): boolean {
|
2 |
+
try {
|
3 |
+
const url = new URL(urlString);
|
4 |
+
const githubDomains = ['github.com', 'www.github.com'];
|
5 |
+
return githubDomains.includes(url.hostname) && url.protocol === 'https:';
|
6 |
+
} catch {
|
7 |
+
return false;
|
8 |
+
}
|
9 |
+
}
|
10 |
+
|
11 |
+
export function isGithubUserPath(url: string, username: string): boolean {
|
12 |
+
if (!isGitHubUrl(url)) {
|
13 |
+
return false;
|
14 |
+
}
|
15 |
+
const urlObject = new URL(url);
|
16 |
+
const pathSegments = urlObject.pathname.split('/').filter((segment) => segment !== '');
|
17 |
+
if (pathSegments.length === 0) {
|
18 |
+
return false;
|
19 |
+
}
|
20 |
+
const pathUsername = pathSegments[0];
|
21 |
+
return pathUsername === username;
|
22 |
+
}
|