Spaces:
Paused
Paused
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 |
+
}
|