1
0
Fork 0
polaris-task-force/src/components/frontend/eod/CustomMinesweeperBoard.tsx
2026-09-08 15:57:01 -04:00

519 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Bomb, CircleHelp, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { submitCustomMinefieldScore } from "@/app/(frontend)/eod/custom/actions";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { edgeMineIndices } from "@/lib/custom-minigames/eod";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal } from "./sounds";
type Phase = "ready" | "playing" | "over";
type Cell = {
readonly mine: boolean;
readonly adjacent: number;
readonly revealed: boolean;
readonly flagged: boolean;
readonly exploded: boolean;
readonly incorrectFlagged: boolean;
};
type Board = readonly Cell[];
const NUMBER_CLASSES: Readonly<Record<number, string>> = {
1: "text-blue-400",
2: "text-green-400",
3: "text-red-400",
4: "text-purple-400",
5: "text-rose-400",
6: "text-teal-400",
7: "text-slate-200",
8: "text-gray-400",
};
export type CustomMinefieldData = {
id: number;
name: string;
rows: number;
cols: number;
mines: { row: number; col: number }[];
exposeEdgeMines: boolean;
};
function emptyBoard(size: number): Board {
return Array.from({ length: size }, () => ({
mine: false,
adjacent: 0,
revealed: false,
flagged: false,
exploded: false,
incorrectFlagged: false,
}));
}
function neighbors(index: number, rows: number, cols: number): number[] {
const row = Math.floor(index / cols);
const col = index % cols;
const nearby: number[] = [];
for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
for (let colOffset = -1; colOffset <= 1; colOffset++) {
if (rowOffset === 0 && colOffset === 0) continue;
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
if (nextRow >= 0 && nextRow < rows && nextCol >= 0 && nextCol >= 0 && nextCol < cols) {
nearby.push(nextRow * cols + nextCol);
}
}
}
return nearby;
}
function createBoardFromMines(
rows: number,
cols: number,
mines: { row: number; col: number }[],
): Board {
const size = rows * cols;
const mineSet = new Set<number>();
for (const { row, col } of mines) {
if (row >= 0 && row < rows && col >= 0 && col < cols) {
mineSet.add(row * cols + col);
}
}
const mined = emptyBoard(size).map((cell, index) => ({ ...cell, mine: mineSet.has(index) }));
return mined.map((cell, index) => ({
...cell,
adjacent: neighbors(index, rows, cols).filter((nearby) => mined[nearby]?.mine).length,
}));
}
function revealFrom(start: number, board: Board, rows: number, cols: number): Board {
const next = [...board];
const queue = [start];
const visited = new Set<number>();
while (queue.length > 0) {
const index = queue.shift();
if (index === undefined || visited.has(index)) continue;
visited.add(index);
const cell = next[index];
if (!cell || cell.flagged || cell.revealed || cell.mine) continue;
next[index] = { ...cell, revealed: true };
if (cell.adjacent === 0) queue.push(...neighbors(index, rows, cols));
}
return next;
}
function cellLabel(cell: Cell, row: number, col: number): string {
const position = `row ${row + 1} col ${col + 1}`;
if (cell.incorrectFlagged) return `${position} incorrect flag`;
if (cell.flagged) return `${position} flagged`;
if (!cell.revealed) return `${position} hidden`;
if (cell.mine) return `${position} mine`;
if (cell.adjacent === 0) return `${position} revealed empty`;
return `${position} revealed number ${cell.adjacent}`;
}
type LastRun = {
readonly won: boolean;
readonly seconds: number;
readonly xp?: number;
readonly error?: string;
};
export function CustomMinesweeperBoard({
minefield,
onGameRecorded,
onPhaseChange,
}: {
minefield: CustomMinefieldData;
onGameRecorded: () => void;
onPhaseChange: (phase: Phase) => void;
}) {
const { expired, expiredRef } = useSessionExpired();
const [phase, setPhase] = useState<Phase>("ready");
const [board, setBoard] = useState<Board>(() =>
createBoardFromMines(minefield.rows, minefield.cols, minefield.mines),
);
const [seconds, setSeconds] = useState(0);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [lost, setLost] = useState(false);
const [submitting, setSubmitting] = useState(false);
const submittingRef = useRef(false);
const setPhaseSafe = useCallback(
(nextPhase: Phase) => {
setPhase(nextPhase);
onPhaseChange(nextPhase);
},
[onPhaseChange],
);
useEffect(() => {
onPhaseChange("ready");
}, [onPhaseChange]);
useEffect(() => {
if (phase !== "playing") return;
const timer = window.setInterval(() => setSeconds((current) => current + 1), 1000);
return () => window.clearInterval(timer);
}, [phase]);
const reset = useCallback(() => {
if (submittingRef.current) return;
setBoard(createBoardFromMines(minefield.rows, minefield.cols, minefield.mines));
setSeconds(0);
setLastRun(null);
setLost(false);
setPhaseSafe("ready");
}, [minefield, setPhaseSafe]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key.toLowerCase() !== "r" ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
) {
return;
}
const target = event.target;
if (
target instanceof HTMLElement &&
(target.matches('input, textarea, select, [contenteditable="true"]') ||
target.closest('[role="combobox"]'))
) {
return;
}
event.preventDefault();
reset();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [reset]);
const recordResult = useCallback(
async (won: boolean, elapsedSeconds: number, flags: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
won,
seconds: elapsedSeconds,
error: "Your session expired. Result not recorded.",
});
return;
}
submittingRef.current = true;
setSubmitting(true);
const result = await submitCustomMinefieldScore({
minefieldId: minefield.id,
seconds: Math.max(1, elapsedSeconds),
flags,
won,
});
submittingRef.current = false;
setSubmitting(false);
if (result.success) {
onGameRecorded();
setLastRun({ won, seconds: elapsedSeconds, xp: result.data?.xpEarned });
} else {
setLastRun({
won,
seconds: elapsedSeconds,
error: result.error ?? "Could not record result.",
});
}
},
[expiredRef, minefield.id, onGameRecorded],
);
const finishGame = useCallback(
(won: boolean, finalBoard: Board) => {
if (submittingRef.current || phase === "over") return;
setPhaseSafe("over");
setLost(!won);
if (won) {
setBoard(finalBoard.map((cell) => (cell.mine ? { ...cell, flagged: true } : cell)));
} else {
// Reveal only the mines touching the exposed area (plus the one that
// went off) when the author kept the exposure rule on. The field is
// static, so a full reveal would leak the solution to retries and
// other players.
const revealed: number[] = [];
const mines: number[] = [];
finalBoard.forEach((cell, index) => {
if (cell.mine) mines.push(index);
else if (cell.revealed) revealed.push(index);
});
const edgeMines = minefield.exposeEdgeMines
? new Set(
edgeMineIndices({
rows: minefield.rows,
cols: minefield.cols,
mines,
revealed,
}),
)
: new Set<number>();
setBoard(
finalBoard.map((cell, index) =>
cell.mine
? edgeMines.has(index) || cell.exploded
? { ...cell, revealed: true }
: cell
: { ...cell, incorrectFlagged: cell.flagged },
),
);
}
if (won) playClear();
else playExplosion();
const flags = finalBoard.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
void recordResult(won, seconds, flags);
},
[minefield, phase, recordResult, seconds, setPhaseSafe],
);
const reveal = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.flagged || cell.revealed) return;
if (phase === "ready") setPhaseSafe("playing");
if (cell.mine) {
finishGame(
false,
board.map((item, cellIndex) =>
cellIndex === index ? { ...item, exploded: true } : item,
),
);
return;
}
playReveal();
const nextBoard = revealFrom(index, board, minefield.rows, minefield.cols);
setBoard(nextBoard);
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
},
[board, expiredRef, finishGame, minefield.cols, minefield.rows, phase, setPhaseSafe],
);
const toggleFlag = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.revealed) return;
playFlag();
setBoard((current) =>
current.map((item, cellIndex) =>
cellIndex === index ? { ...item, flagged: !item.flagged } : item,
),
);
},
[board, expiredRef, phase],
);
const longPressRef = useRef(false);
const longPressTimerRef = useRef<number | null>(null);
const clearLongPress = useCallback(() => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
useEffect(() => {
return () => {
if (longPressTimerRef.current !== null) window.clearTimeout(longPressTimerRef.current);
};
}, []);
const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0);
const incorrectFlagCount = board.reduce(
(count, cell) => count + (cell.incorrectFlagged ? 1 : 0),
0,
);
const statusClass =
phase === "over"
? lastRun?.won
? "border-emerald-500/40 bg-emerald-500/10"
: "border-destructive/40 bg-destructive/10"
: "border-border bg-card";
return (
<div
className="flex w-full max-w-5xl select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow"
onContextMenu={(event) => event.preventDefault()}
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="size-4 text-emerald-500" />
{minefield.name}
</div>
<p className="text-xs text-muted-foreground">
{minefield.rows}×{minefield.cols} · {minefield.mines.length} charges · custom wins
award reduced XP (10% of standard payouts)
</p>
</div>
<div className="flex items-center gap-4 font-mono text-sm tabular-nums">
<span
className="flex items-center gap-1.5"
aria-label={`${minefield.mines.length - flaggedCount} mines remaining`}
>
<Flag className="size-4 text-amber-400" />
{minefield.mines.length - flaggedCount}
</span>
<span className="flex items-center gap-1.5" aria-label={`${seconds} seconds elapsed`}>
<Timer className="size-4 text-muted-foreground" />
{seconds}s
</span>
</div>
</div>
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm",
statusClass,
)}
aria-live="polite"
>
<div className="flex flex-col gap-0.5">
{phase === "ready" && <span>Awaiting deployment. Any first click can be a mine.</span>}
{phase === "playing" && <span>Live sweep: mark every charge before you reveal.</span>}
{phase === "over" && lastRun?.won && (
<span>Sector clear. Defused in {lastRun.seconds}s.</span>
)}
{phase === "over" && !lastRun?.won && lastRun && (
<span>
The charge went off. MIA.
{incorrectFlagCount > 0 &&
` ${incorrectFlagCount} incorrect flag${incorrectFlagCount === 1 ? "" : "s"} marked with an X.`}{" "}
{minefield.exposeEdgeMines
? "Only charges bordering exposed ground are revealed; the rest of the layout stays hidden."
: "No remaining charges are revealed."}
</span>
)}
{phase === "over" && !lastRun && <span>Recording result…</span>}
{lastRun?.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
</div>
{phase === "over" && (
<Button
type="button"
variant="outline"
size="sm"
disabled={submitting || expired}
onClick={reset}
>
<RotateCcw /> Retry sector
</Button>
)}
</div>
<div
className="flex min-w-0 max-w-full items-center justify-center overflow-auto rounded-lg border border-border bg-background p-2"
style={{ containerType: "inline-size" }}
>
<div
className="grid min-w-max grid-cols-[repeat(var(--board-cols),var(--cell-size))] gap-1"
style={
{
"--board-cols": minefield.cols,
"--cell-size": `min(1.75rem, calc((100cqw - ${(minefield.cols - 1) * 0.25}rem) / ${minefield.cols}))`,
} as CSSProperties
}
role="grid"
aria-label={`${minefield.name} minefield`}
>
{board.map((cell, index) => {
const row = Math.floor(index / minefield.cols);
const col = index % minefield.cols;
const revealedMine = cell.revealed && cell.mine;
return (
<button
key={index}
type="button"
role="gridcell"
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-[var(--cell-size)] items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
!cell.mine &&
"border-yellow-900/40 bg-yellow-950/15 text-amber-50 shadow-inner",
!cell.revealed &&
"border-emerald-500 bg-emerald-800/90 text-emerald-50 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] hover:border-emerald-400 hover:bg-emerald-700",
lost &&
!cell.revealed &&
!cell.flagged &&
!cell.incorrectFlagged &&
"border-neutral-600 bg-neutral-800/80 text-neutral-400 hover:border-neutral-600 hover:bg-neutral-800/80",
cell.incorrectFlagged &&
"border-destructive bg-destructive/25 text-red-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.12),0_1px_2px_rgb(0_0_0_/_0.35)] animate-pulse",
cell.flagged &&
!cell.revealed &&
!cell.incorrectFlagged &&
"border-amber-300 bg-amber-500/25 text-amber-200 shadow-[inset_0_1px_0_rgb(255_255_255_/_0.14),0_1px_2px_rgb(0_0_0_/_0.35)]",
revealedMine && "border-destructive bg-destructive/30 text-red-300",
cell.exploded && "border-destructive bg-destructive text-destructive-foreground",
cell.revealed && cell.adjacent > 0 && NUMBER_CLASSES[cell.adjacent],
)}
onClick={() => {
if (longPressRef.current) {
longPressRef.current = false;
return;
}
reveal(index);
}}
onContextMenu={(event) => {
event.preventDefault();
toggleFlag(index);
}}
onKeyDown={(event) => {
if (event.key.toLowerCase() === "f") {
event.preventDefault();
toggleFlag(index);
}
}}
onPointerDown={(event: PointerEvent<HTMLButtonElement>) => {
if (event.pointerType !== "touch" || phase === "over") return;
longPressRef.current = false;
clearLongPress();
longPressTimerRef.current = window.setTimeout(() => {
longPressRef.current = true;
toggleFlag(index);
}, 350);
}}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
>
{cell.incorrectFlagged ? <X className="size-3.5" /> : null}
{cell.flagged && !cell.incorrectFlagged && !revealedMine ? (
<Flag className="size-3.5" />
) : null}
{revealedMine ? <Bomb className="size-3.5" /> : null}
{lost && !cell.revealed && !cell.flagged && !cell.incorrectFlagged ? (
<CircleHelp className="size-3.5" />
) : null}
{cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null}
</button>
);
})}
</div>
</div>
<p className="text-center text-xs text-muted-foreground">
Left click or tap to reveal · right click,{" "}
<kbd className="rounded border border-border px-1">F</kbd>, or long-press to flag · press{" "}
<kbd className="rounded border border-border px-1">R</kbd> to restart ·{" "}
{minefield.exposeEdgeMines
? "failed runs reveal only the charges bordering exposed ground"
: "failed runs reveal no remaining charges"}
</p>
</div>
);
}