diff --git a/src/app/(frontend)/eod/actions.ts b/src/app/(frontend)/eod/actions.ts index 9dddc1e..adfd1ff 100644 --- a/src/app/(frontend)/eod/actions.ts +++ b/src/app/(frontend)/eod/actions.ts @@ -19,6 +19,9 @@ export interface EodStats { bestScore: number; eodXp: number; xpEarned: number; + bestTrainingTime: number; + bestPerimeterTime: number; + bestSupplyRoadTime: number; } const DIFFICULTIES = { @@ -224,7 +227,20 @@ export async function submitEodResult(input: { return { success: true, - data: { gamesPlayed, wins, losses, bestScore, eodXp, xpEarned: runXp }, + data: { + gamesPlayed, + wins, + losses, + bestScore, + eodXp, + xpEarned: runXp, + bestTrainingTime: + bestTimeField === "bestTrainingTime" ? bestTime : (current.bestTrainingTime ?? 0), + bestPerimeterTime: + bestTimeField === "bestPerimeterTime" ? bestTime : (current.bestPerimeterTime ?? 0), + bestSupplyRoadTime: + bestTimeField === "bestSupplyRoadTime" ? bestTime : (current.bestSupplyRoadTime ?? 0), + }, }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; diff --git a/src/app/(frontend)/eod/page.tsx b/src/app/(frontend)/eod/page.tsx index 1763915..aed3dbd 100644 --- a/src/app/(frontend)/eod/page.tsx +++ b/src/app/(frontend)/eod/page.tsx @@ -42,6 +42,9 @@ export default async function EodPage() { bestScore: eod.bestScore ?? 0, eodXp: eod.xpEarned ?? 0, xpEarned: 0, + bestTrainingTime: eod.bestTrainingTime ?? 0, + bestPerimeterTime: eod.bestPerimeterTime ?? 0, + bestSupplyRoadTime: eod.bestSupplyRoadTime ?? 0, }; return ( diff --git a/src/components/frontend/eod/MinesweeperBoard.tsx b/src/components/frontend/eod/MinesweeperBoard.tsx index 0f7bdad..d165cdf 100644 --- a/src/components/frontend/eod/MinesweeperBoard.tsx +++ b/src/components/frontend/eod/MinesweeperBoard.tsx @@ -6,6 +6,14 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "rea import { Bomb, Flag, RotateCcw, ShieldCheck, Timer, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; @@ -13,6 +21,22 @@ import { cn } from "@/lib/utils"; import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds"; type Phase = "ready" | "playing" | "over"; +type GameMode = EodDifficulty | "custom"; + +type GameConfig = { + label: string; + rows: number; + cols: number; + mines: number; + baseScore: number; + parSeconds: number; + maxSeconds: number; +}; + +const CUSTOM_MIN_SIZE = 9; +const CUSTOM_MAX_SIZE = 500; +const CUSTOM_MAX_SAFE_MINES_BUFFER = 9; +const CUSTOM_FULLSCREEN_CELL_THRESHOLD = 900; type Cell = { readonly mine: boolean; @@ -95,7 +119,7 @@ function neighbors(index: number, rows: number, cols: number): number[] { return nearby; } -function createBoard(config: (typeof DIFFICULTIES)[EodDifficulty], safeIndex: number): Board { +function createBoard(config: GameConfig, safeIndex: number): Board { const size = config.rows * config.cols; const safe = new Set([safeIndex, ...neighbors(safeIndex, config.rows, config.cols)]); const mines = new Set(); @@ -156,7 +180,16 @@ export function MinesweeperBoard({ onPhaseChange: (phase: Phase) => void; }) { const router = useRouter(); - const [difficulty, setDifficulty] = useState("training"); + const [difficulty, setDifficulty] = useState("training"); + const [customConfig, setCustomConfig] = useState({ + label: "Custom Minefield", + rows: 9, + cols: 9, + mines: 10, + baseScore: 0, + parSeconds: 0, + maxSeconds: 3600, + }); const [phase, setPhase] = useState("ready"); const [board, setBoard] = useState(() => emptyBoard(81)); const [seconds, setSeconds] = useState(0); @@ -169,7 +202,11 @@ export function MinesweeperBoard({ const statsRef = useRef(serverStats); const longPressRef = useRef(false); const longPressTimerRef = useRef(null); - const config = DIFFICULTIES[difficulty]; + const config: GameConfig = difficulty === "custom" ? customConfig : DIFFICULTIES[difficulty]; + const customUnlocked = + stats.bestTrainingTime > 0 && stats.bestPerimeterTime > 0 && stats.bestSupplyRoadTime > 0; + const isLargeCustomGame = + difficulty === "custom" && config.rows * config.cols > CUSTOM_FULLSCREEN_CELL_THRESHOLD; const flaggedCount = board.reduce((count, cell) => count + (cell.flagged ? 1 : 0), 0); const setPhaseSafe = useCallback( @@ -199,9 +236,9 @@ export function MinesweeperBoard({ }, []); const reset = useCallback( - (nextDifficulty: EodDifficulty = difficulty) => { + (nextDifficulty: GameMode = difficulty) => { if (submittingRef.current) return; - const nextConfig = DIFFICULTIES[nextDifficulty]; + const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty]; setDifficulty(nextDifficulty); setBoard(emptyBoard(nextConfig.rows * nextConfig.cols)); generatedRef.current = false; @@ -209,12 +246,51 @@ export function MinesweeperBoard({ setLastRun(null); setPhaseSafe("ready"); }, - [difficulty, setPhaseSafe], + [customConfig, difficulty, 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 (difficulty === "custom") { + setLastRun({ + won, + seconds: elapsedSeconds, + score: 0, + xpEarned: 0, + best: 0, + }); + return; + } submittingRef.current = true; setSubmitting(true); const resultSeconds = Math.max(1, Math.min(elapsedSeconds, config.maxSeconds)); @@ -344,9 +420,100 @@ export function MinesweeperBoard({ : "border-destructive/40 bg-destructive/10" : "border-border bg-card"; + const renderGrid = (modal = false) => ( +
+
+ {board.map((cell, index) => { + const row = Math.floor(index / config.cols); + const col = index % config.cols; + const revealedMine = cell.revealed && cell.mine; + return ( + + ); + })} +
+
+ ); + return (
-
+
@@ -387,29 +554,143 @@ export function MinesweeperBoard({ {phase !== "playing" && ( -
- {DIFFICULTY_KEYS.map((key) => { - const option = DIFFICULTIES[key]; - return ( - - ); - })} -
+ + ); + })} +
+ {customUnlocked ? ( +
+
+
+ + + setCustomConfig((current) => { + const rows = Math.max( + CUSTOM_MIN_SIZE, + Math.min( + CUSTOM_MAX_SIZE, + Number(event.target.value) || CUSTOM_MIN_SIZE, + ), + ); + return { + ...current, + rows, + mines: Math.min( + current.mines, + rows * current.cols - CUSTOM_MAX_SAFE_MINES_BUFFER, + ), + }; + }) + } + className="w-24" + /> +
+
+ + + setCustomConfig((current) => { + const cols = Math.max( + CUSTOM_MIN_SIZE, + Math.min( + CUSTOM_MAX_SIZE, + Number(event.target.value) || CUSTOM_MIN_SIZE, + ), + ); + return { + ...current, + cols, + mines: Math.min( + current.mines, + current.rows * cols - CUSTOM_MAX_SAFE_MINES_BUFFER, + ), + }; + }) + } + className="w-24" + /> +
+
+ + + setCustomConfig((current) => ({ + ...current, + mines: Math.max( + 1, + Math.min( + current.rows * current.cols - CUSTOM_MAX_SAFE_MINES_BUFFER, + Number(event.target.value) || 1, + ), + ), + })) + } + className="w-24" + /> +
+ +
+

+ Custom mode is unranked. Choose a {CUSTOM_MIN_SIZE}×{CUSTOM_MIN_SIZE} to{" "} + {CUSTOM_MAX_SIZE}×{CUSTOM_MAX_SIZE} field. +

+
+ ) : ( +

+ Clear all three standard locations to unlock custom minefields. +

+ )} + )}
-
-
- {board.map((cell, index) => { - const row = Math.floor(index / config.cols); - const col = index % config.cols; - const revealedMine = cell.revealed && cell.mine; - return ( - - ); - })} + }} + onKeyDown={(event) => { + if (event.key.toLowerCase() === "f") { + event.preventDefault(); + toggleFlag(index); + } + }} + onPointerDown={(event: PointerEvent) => { + 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 ? : null} + {cell.flagged && !cell.incorrectFlagged && !revealedMine ? ( + + ) : null} + {revealedMine ? : null} + {cell.revealed && !cell.mine && cell.adjacent > 0 ? cell.adjacent : null} + + ); + })} +
-
+ )}

Left click or tap to reveal · right click,{" "} - F, or long-press to flag + F, or long-press to flag · press{" "} + R to restart

+ {isLargeCustomGame && ( + + + + {config.label} + + Large custom field · {config.rows}×{config.cols} · press R to restart + + +
{renderGrid(true)}
+
+
+ )} +
Games