GameOverlays
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import type { GameStatus, UseScoreType } from "../../src/types";
|
||||
import { useIsMobile } from "./useIsMobile";
|
||||
import { MobileGameOverlay } from "./MobileGameOverlay";
|
||||
|
||||
type GameOverlayProps = {
|
||||
gameStatus: GameStatus;
|
||||
scoreManager: UseScoreType;
|
||||
};
|
||||
|
||||
export const GameOverlay = ({ gameStatus, scoreManager }: GameOverlayProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
if (isMobile) return <MobileGameOverlay gameStatus={gameStatus} scoreManager={scoreManager} />
|
||||
|
||||
if (gameStatus === "started") return null;
|
||||
|
||||
return (
|
||||
<GameOverlayContainer>
|
||||
{gameStatus === "waiting" && <GameWaitingContent />}
|
||||
{gameStatus === "game-over" && <GameOverContent />}
|
||||
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
|
||||
</GameOverlayContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameWaitingContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="text-[#c8d3f5] text-[2em] font-mono text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
<p className="text-[3rem]">Press <strong className="text-[#82aaff]">New Puzzle</strong> to start</p>
|
||||
<div className="text-[1.2em]">
|
||||
<p className="mb-4"><strong className="text-[#ffc777]">Navigation Mode</strong> (default):</p>
|
||||
<p><strong className="text-[#82aaff]">h</strong>,<strong className="text-[#82aaff]">j</strong>,<strong className="text-[#82aaff]">k</strong>,<strong className="text-[#82aaff]">l</strong> to move around</p>
|
||||
<p className="mt-2"><strong className="text-[#82aaff]">R</strong> or <strong className="text-[#82aaff]">C</strong> to enter edit mode</p>
|
||||
|
||||
<p className="mt-6 mb-4"><strong className="text-[#ffc777]">Edit Mode</strong>:</p>
|
||||
<p>Type <strong className="text-[#82aaff]">1-9</strong> to fill cell</p>
|
||||
<p className="mt-2"><strong className="text-[#82aaff]">Esc</strong> or <strong className="text-[#82aaff]">Enter</strong> to exit edit mode</p>
|
||||
|
||||
<p className="mt-6 text-[0.8em] text-[#828bb8]">
|
||||
Try vim motions: <strong className="text-[#82aaff]">$</strong>, <strong className="text-[#82aaff]">^</strong>, <strong className="text-[#82aaff]">0</strong>, counted moves
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
|
||||
const finalScore = scoreManager.finalScore ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-[#c8d3f5] text-[2em] grid gap-4 font-mono uppercase text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
<p className="text-[#c3e88d]">🎉 Puzzle Solved!</p>
|
||||
<p>Final Score:</p>
|
||||
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
|
||||
<p className="text-[#828bb8] text-[0.65em] normal-case">
|
||||
<span className="text-[#c099ff]">max</span>
|
||||
<span className="text-[#c8d3f5]">(</span>
|
||||
<span className="text-[#ff966c]">0</span>
|
||||
<span className="text-[#c8d3f5]">, </span>
|
||||
<span className="text-[#ff966c]">100000</span>
|
||||
<span className="text-[#c8d3f5]"> - </span>
|
||||
<span className="text-[#82aaff]">timeInSeconds</span>
|
||||
<span className="text-[#c8d3f5]"> * </span>
|
||||
<span className="text-[#ff966c]">10</span>
|
||||
<span className="text-[#c8d3f5]">)</span>
|
||||
</p>
|
||||
<p className="text-[#828bb8]">Click <strong className="text-[#ffc777] normal-case">New Puzzle</strong> to play again</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameOverContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="text-[#ff757f] font-mono uppercase text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
<p className="text-[5em]">Game Over</p>
|
||||
<p className="text-[2em] text-[#828bb8]">Press <strong className="text-[#ffc777] normal-case">i</strong> to play again</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameOverlayContainer = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
flex justify-center items-center
|
||||
z-40
|
||||
pointer-events-none
|
||||
"
|
||||
style={{
|
||||
backgroundColor: 'rgba(27, 29, 43, 0.75)',
|
||||
backdropFilter: 'blur(1px)'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { GameStatus, UseScoreType } from "../../src/types";
|
||||
|
||||
type MobileGameOverlayProps = {
|
||||
gameStatus: GameStatus;
|
||||
scoreManager: UseScoreType;
|
||||
};
|
||||
|
||||
export const MobileGameOverlay = ({ gameStatus, scoreManager }: MobileGameOverlayProps) => {
|
||||
if (gameStatus === "started") return null;
|
||||
|
||||
return (
|
||||
<GameOverlayContainer>
|
||||
{gameStatus === "waiting" && <GameWaitingContent />}
|
||||
{gameStatus === "game-over" && <GameOverContent />}
|
||||
{gameStatus === "game-won" && <GameWonContent scoreManager={scoreManager} />}
|
||||
</GameOverlayContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameWaitingContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="text-[#c8d3f5] text-[1em] font-mono text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-[1.5rem]">Tap New Puzzle to start</p>
|
||||
<p>VIM motions available <br /> via on-screen keyboard</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => {
|
||||
const finalScore = scoreManager.finalScore ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="text-[#c8d3f5] text-[1em] grid gap-4 font-mono uppercase text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
<p className="text-[#c3e88d]">🎉 Solved!</p>
|
||||
<p>Final Score:</p>
|
||||
<p className="text-[#82aaff] text-[2.5em]">{finalScore.toLocaleString()}</p>
|
||||
<p className="text-[#828bb8] text-[0.65em] normal-case">
|
||||
<span className="text-[#c099ff]">max</span>
|
||||
<span className="text-[#c8d3f5]">(</span>
|
||||
<span className="text-[#ff966c]">0</span>
|
||||
<span className="text-[#c8d3f5]">, </span>
|
||||
<span className="text-[#ff966c]">100000</span>
|
||||
<span className="text-[#c8d3f5]"> - </span>
|
||||
<span className="text-[#82aaff]">time</span>
|
||||
<span className="text-[#c8d3f5]"> * </span>
|
||||
<span className="text-[#ff966c]">10</span>
|
||||
<span className="text-[#c8d3f5]">)</span>
|
||||
</p>
|
||||
<p className="text-[#828bb8]">Tap to play again</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameOverContent = () => {
|
||||
return (
|
||||
<div
|
||||
className="text-[#ff757f] text-[5em] font-mono uppercase text-center select-none"
|
||||
style={{ textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)" }}
|
||||
>
|
||||
Game Over
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const GameOverlayContainer = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
flex justify-center items-center
|
||||
z-40
|
||||
pointer-events-none
|
||||
"
|
||||
style={{
|
||||
backgroundColor: 'rgba(27, 29, 43, 0.75)',
|
||||
backdropFilter: 'blur(1px)'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+95
-40
@@ -1,20 +1,27 @@
|
||||
/* =========================================================
|
||||
🔧 Controls (tweak these)
|
||||
========================================================= */
|
||||
/* :root { */
|
||||
/* --cell-size: 32px; */
|
||||
/* --cell-font-size: 16px; */
|
||||
/* --grid-gap: 2px; */
|
||||
/* --box-separator: 3px; */
|
||||
/* } */
|
||||
|
||||
:root {
|
||||
--cell-size: 32px;
|
||||
--cell-font-size: 16px;
|
||||
--grid-gap: 2px;
|
||||
--box-separator: 3px;
|
||||
--cell-size: 48px;
|
||||
--cell-font-size: 32px;
|
||||
--grid-gap: 4px;
|
||||
--box-separator: 6px;
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
--cell-size: 96px;
|
||||
--cell-font-size: 48px;
|
||||
--grid-gap: 6px;
|
||||
--box-separator: 9px;
|
||||
}
|
||||
/* :root { */
|
||||
/* --cell-size: 96px; */
|
||||
/* --cell-font-size: 48px; */
|
||||
/* --grid-gap: 6px; */
|
||||
/* --box-separator: 9px; */
|
||||
/* } */
|
||||
|
||||
/* =========================================================
|
||||
Container & Grid Base
|
||||
@@ -27,7 +34,7 @@
|
||||
|
||||
#sudoku {
|
||||
position: relative;
|
||||
background-color: #1e2030; /* Tokyo Night card background */
|
||||
background-color: var(--card, #1e2030);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
|
||||
padding: 0.5rem;
|
||||
@@ -64,8 +71,8 @@
|
||||
border-radius: 4px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: var(--cell-font-size);
|
||||
background: #2f334d; /* Tokyo Night muted */
|
||||
color: #c8d3f5; /* Tokyo Night foreground */
|
||||
background: var(--muted, #2f334d);
|
||||
color: var(--foreground, #c8d3f5);
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
|
||||
}
|
||||
@@ -77,35 +84,35 @@
|
||||
#sudoku .sudoku-cell[data-c="2"],
|
||||
#sudoku .sudoku-cell[data-c="5"],
|
||||
#sudoku .sudoku-cell[data-c="8"] {
|
||||
border-right: 2px solid rgba(130, 170, 255, 0.3);
|
||||
border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Left borders (start of each 3x3 box horizontally) */
|
||||
#sudoku .sudoku-cell[data-c="0"],
|
||||
#sudoku .sudoku-cell[data-c="3"],
|
||||
#sudoku .sudoku-cell[data-c="6"] {
|
||||
border-left: 2px solid rgba(130, 170, 255, 0.3);
|
||||
border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Bottom borders (end of each 3x3 box vertically) */
|
||||
#sudoku .sudoku-cell[data-r="2"],
|
||||
#sudoku .sudoku-cell[data-r="5"],
|
||||
#sudoku .sudoku-cell[data-r="8"] {
|
||||
border-bottom: 2px solid rgba(130, 170, 255, 0.3);
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Top borders (start of each 3x3 box vertically) */
|
||||
#sudoku .sudoku-cell[data-r="0"],
|
||||
#sudoku .sudoku-cell[data-r="3"],
|
||||
#sudoku .sudoku-cell[data-r="6"] {
|
||||
border-top: 2px solid rgba(130, 170, 255, 0.3);
|
||||
border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
User-Entered Cells
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.user-entered {
|
||||
color: #82aaff; /* Tokyo Night primary blue */
|
||||
color: var(--primary, #82aaff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -113,41 +120,41 @@
|
||||
Active Cell Highlight (Navigation Mode)
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.active {
|
||||
outline: 2px solid #82aaff; /* Tokyo Night blue */
|
||||
outline: 2px solid var(--primary, #82aaff);
|
||||
outline-offset: -2px;
|
||||
background: #3d4269;
|
||||
background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
|
||||
box-shadow:
|
||||
0 0 8px rgba(130, 170, 255, 0.4),
|
||||
0 0 12px rgba(130, 170, 255, 0.2),
|
||||
inset 0 0 6px rgba(130, 170, 255, 0.15);
|
||||
0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
|
||||
0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
|
||||
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Editing Cell Highlight (Edit Mode)
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.editing {
|
||||
outline: 2px solid #ffc777; /* Tokyo Night yellow */
|
||||
outline: 2px solid var(--accent, #ffc777);
|
||||
outline-offset: -2px;
|
||||
background: #3d3836;
|
||||
background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 199, 119, 0.7),
|
||||
0 0 24px rgba(255, 199, 119, 0.4),
|
||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
||||
animation: editing-cell-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes editing-cell-glow {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 199, 119, 0.7),
|
||||
0 0 24px rgba(255, 199, 119, 0.4),
|
||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(255, 199, 119, 0.9),
|
||||
0 0 32px rgba(255, 199, 119, 0.6),
|
||||
inset 0 0 16px rgba(255, 199, 119, 0.4);
|
||||
0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
|
||||
0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
|
||||
inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,16 +165,16 @@
|
||||
#sudoku .sudoku-cell.invalid-move {
|
||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
||||
box-shadow:
|
||||
0 0 12px rgba(255, 117, 127, 0.85),
|
||||
0 0 24px rgba(255, 117, 127, 0.45);
|
||||
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
|
||||
}
|
||||
|
||||
@keyframes sudoku-invalid-pulse {
|
||||
0% {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 117, 127, 0.95),
|
||||
0 0 28px rgba(255, 117, 127, 0.55);
|
||||
0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
|
||||
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
@@ -206,11 +213,59 @@
|
||||
|
||||
/* Trail effect for moves */
|
||||
#sudoku .sudoku-cell.trail {
|
||||
box-shadow: inset 0 0 6px rgba(130, 170, 255, 0.6), 0 0 6px rgba(130, 170, 255, 0.35);
|
||||
box-shadow:
|
||||
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
|
||||
0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
|
||||
animation: sudoku-trail-fade 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes sudoku-trail-fade {
|
||||
from { background-color: rgba(130, 170, 255, 0.25); }
|
||||
from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
|
||||
to { background-color: transparent; }
|
||||
}
|
||||
|
||||
/* Valid move flash */
|
||||
#sudoku .sudoku-cell.valid-move {
|
||||
animation: sudoku-valid-flash 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes sudoku-valid-flash {
|
||||
0% {
|
||||
background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent);
|
||||
box-shadow:
|
||||
0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent),
|
||||
0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Conflict highlighting */
|
||||
#sudoku .sudoku-cell.conflict {
|
||||
outline: 2px solid var(--destructive, #c53b53);
|
||||
outline-offset: -2px;
|
||||
background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d));
|
||||
box-shadow:
|
||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
||||
animation: sudoku-conflict-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sudoku-conflict-pulse {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent),
|
||||
inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
+176
-20
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { sudoku } from "../lib/Sudoku";
|
||||
import type { UseCursorType } from "./useCursor";
|
||||
|
||||
@@ -8,40 +8,190 @@ export function useBoardState(cursorManager: UseCursorType) {
|
||||
const [initialBoard, setInitialBoard] = useState<string>(board);
|
||||
const [solution, setSolution] = useState<string | null>(null);
|
||||
|
||||
const setDigit = useCallback(
|
||||
(d: string) => {
|
||||
const i = cursor.r * 9 + cursor.c;
|
||||
const arr = board.split("");
|
||||
arr[i] = d;
|
||||
setBoard(arr.join(""));
|
||||
},
|
||||
[cursor, board]
|
||||
);
|
||||
const givenCells = () => {
|
||||
const cells = new Set<number>();
|
||||
for (let i = 0; i < initialBoard.length; i++) {
|
||||
if (initialBoard[i] !== ".") {
|
||||
cells.add(i);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
};
|
||||
|
||||
const canEditCell = (row: number, col: number): boolean => {
|
||||
const index = row * 9 + col;
|
||||
return !givenCells().has(index);
|
||||
};
|
||||
|
||||
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
|
||||
if (digit === ".") return true;
|
||||
|
||||
const idx = row * 9 + col;
|
||||
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const checkIdx = row * 9 + c;
|
||||
if (c !== col && testBoard[checkIdx] === digit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const checkIdx = r * 9 + col;
|
||||
if (r !== row && testBoard[checkIdx] === digit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const boxRow = Math.floor(row / 3) * 3;
|
||||
const boxCol = Math.floor(col / 3) * 3;
|
||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
||||
const checkIdx = r * 9 + c;
|
||||
if ((r !== row || c !== col) && testBoard[checkIdx] === digit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getConflicts = (testBoard: string, row: number, col: number): number[] => {
|
||||
const conflicts: number[] = [];
|
||||
const digit = testBoard[row * 9 + col];
|
||||
|
||||
if (digit === ".") return conflicts;
|
||||
|
||||
for (let c = 0; c < 9; c++) {
|
||||
if (c !== col && testBoard[row * 9 + c] === digit) {
|
||||
conflicts.push(row * 9 + c);
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; r < 9; r++) {
|
||||
if (r !== row && testBoard[r * 9 + col] === digit) {
|
||||
conflicts.push(r * 9 + col);
|
||||
}
|
||||
}
|
||||
|
||||
const boxRow = Math.floor(row / 3) * 3;
|
||||
const boxCol = Math.floor(col / 3) * 3;
|
||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
||||
if ((r !== row || c !== col) && testBoard[r * 9 + c] === digit) {
|
||||
const idx = r * 9 + c;
|
||||
if (!conflicts.includes(idx)) {
|
||||
conflicts.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
|
||||
const isFullyValid = (testBoard: string): boolean => {
|
||||
for (let r = 0; r < 9; r++) {
|
||||
let rowSum = 0;
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const digit = testBoard[r * 9 + c];
|
||||
if (digit === ".") return false;
|
||||
rowSum += parseInt(digit, 10);
|
||||
}
|
||||
if (rowSum !== 45) return false;
|
||||
}
|
||||
|
||||
for (let c = 0; c < 9; c++) {
|
||||
let colSum = 0;
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const digit = testBoard[r * 9 + c];
|
||||
colSum += parseInt(digit, 10);
|
||||
}
|
||||
if (colSum !== 45) return false;
|
||||
}
|
||||
|
||||
for (let boxRow = 0; boxRow < 3; boxRow++) {
|
||||
for (let boxCol = 0; boxCol < 3; boxCol++) {
|
||||
let boxSum = 0;
|
||||
for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
|
||||
for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
|
||||
const digit = testBoard[r * 9 + c];
|
||||
boxSum += parseInt(digit, 10);
|
||||
}
|
||||
}
|
||||
if (boxSum !== 45) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isComplete = (testBoard: string): boolean => {
|
||||
return !testBoard.includes(".");
|
||||
};
|
||||
|
||||
const setDigit = (d: string) => {
|
||||
const i = cursor.r * 9 + cursor.c;
|
||||
|
||||
if (!canEditCell(cursor.r, cursor.c)) {
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const arr = board.split("");
|
||||
arr[i] = d;
|
||||
const newBoard = arr.join("");
|
||||
|
||||
if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
|
||||
const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sudoku-invalid-move", {
|
||||
detail: { row: cursor.r, col: cursor.c, digit: d, conflicts },
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setBoard(newBoard);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("sudoku-valid-move", {
|
||||
detail: { row: cursor.r, col: cursor.c, digit: d },
|
||||
})
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const erase = () => {
|
||||
if (!canEditCell(cursor.r, cursor.c)) {
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const erase = useCallback(() => {
|
||||
const i = cursor.r * 9 + cursor.c;
|
||||
const arr = board.split("");
|
||||
arr[i] = ".";
|
||||
setBoard(arr.join(""));
|
||||
}, [cursor, board]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const newBoard = sudoku.generate("medium");
|
||||
const reset = () => {
|
||||
const newBoard = sudoku.generate("easy");
|
||||
setBoard(newBoard);
|
||||
setInitialBoard(newBoard);
|
||||
setSolution(null);
|
||||
setCursor({ r: 0, c: 0 });
|
||||
}, [setCursor]);
|
||||
};
|
||||
|
||||
const solveBoard = useCallback(() => {
|
||||
const solved = sudoku.solve(board);
|
||||
const solveBoard = () => {
|
||||
const solved = sudoku.solve(initialBoard);
|
||||
if (solved) {
|
||||
setSolution(solved);
|
||||
setBoard(solved);
|
||||
window.dispatchEvent(new CustomEvent("sudoku-game-won"));
|
||||
}
|
||||
}, [board]);
|
||||
};
|
||||
|
||||
const boardToString = useCallback((human: boolean = false) => {
|
||||
const boardToString = (human: boolean = false) => {
|
||||
// Computer-optimized: compact 81-character string
|
||||
if (!human) {
|
||||
return board;
|
||||
@@ -60,7 +210,7 @@ export function useBoardState(cursorManager: UseCursorType) {
|
||||
if (r === 2 || r === 5) result += "------+-------+------\n";
|
||||
}
|
||||
return result;
|
||||
}, [board]);
|
||||
};
|
||||
|
||||
return {
|
||||
board,
|
||||
@@ -71,6 +221,12 @@ export function useBoardState(cursorManager: UseCursorType) {
|
||||
reset,
|
||||
solveBoard,
|
||||
boardToString,
|
||||
canEditCell,
|
||||
isValidPlacement,
|
||||
getConflicts,
|
||||
isFullyValid,
|
||||
isComplete,
|
||||
givenCells,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,40 @@ export function useCursor(rendererManager: UseRenderer) {
|
||||
setTimeout(() => el.classList.remove("locked-cell"), 300);
|
||||
}, [cursor, getCellEl]);
|
||||
|
||||
const flashValid = useCallback(() => {
|
||||
const el = getCellEl(cursor.r, cursor.c);
|
||||
if (!el) return;
|
||||
el.classList.add("valid-move");
|
||||
setTimeout(() => el.classList.remove("valid-move"), 300);
|
||||
}, [cursor, getCellEl]);
|
||||
|
||||
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const sudokuDiv = container.querySelector("#sudoku");
|
||||
if (!sudokuDiv) return;
|
||||
|
||||
conflictIndices.forEach((idx) => {
|
||||
const r = Math.floor(idx / 9);
|
||||
const c = idx % 9;
|
||||
const el = getCellEl(r, c);
|
||||
if (el) {
|
||||
el.classList.add("conflict");
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
conflictIndices.forEach((idx) => {
|
||||
const r = Math.floor(idx / 9);
|
||||
const c = idx % 9;
|
||||
const el = getCellEl(r, c);
|
||||
if (el) {
|
||||
el.classList.remove("conflict");
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
}, [containerRef, getCellEl]);
|
||||
|
||||
const moveCursor = useCallback(
|
||||
(dr: number, dc: number, steps = 1) => {
|
||||
if (animatingRef.current) return;
|
||||
@@ -54,9 +88,25 @@ export function useCursor(rendererManager: UseRenderer) {
|
||||
|
||||
useEffect(() => {
|
||||
const onLockedCell = () => flashLocked();
|
||||
const onValidMove = () => flashValid();
|
||||
const onInvalidMove = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.conflicts) {
|
||||
highlightConflicts(detail.conflicts);
|
||||
}
|
||||
flashInvalid();
|
||||
};
|
||||
|
||||
window.addEventListener("sudoku-locked-cell", onLockedCell);
|
||||
return () => window.removeEventListener("sudoku-locked-cell", onLockedCell);
|
||||
}, [flashLocked]);
|
||||
window.addEventListener("sudoku-valid-move", onValidMove);
|
||||
window.addEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("sudoku-locked-cell", onLockedCell);
|
||||
window.removeEventListener("sudoku-valid-move", onValidMove);
|
||||
window.removeEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
||||
};
|
||||
}, [flashLocked, flashValid, flashInvalid, highlightConflicts]);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
@@ -64,6 +114,8 @@ export function useCursor(rendererManager: UseRenderer) {
|
||||
moveCursor,
|
||||
getCellEl,
|
||||
flashInvalid,
|
||||
flashValid,
|
||||
highlightConflicts,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -14,7 +14,17 @@ export function useGame(platformHook?: unknown) {
|
||||
const gameManager = useGameStatus(rendererManager, cursorManager);
|
||||
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
|
||||
const boardManager = useBoardState(cursorManager);
|
||||
const { board, initialBoard, solution, setDigit, erase, reset, solveBoard } = boardManager;
|
||||
const {
|
||||
board,
|
||||
initialBoard,
|
||||
solution,
|
||||
setDigit,
|
||||
erase,
|
||||
reset,
|
||||
solveBoard,
|
||||
isComplete,
|
||||
isFullyValid,
|
||||
} = boardManager;
|
||||
|
||||
// ---------------------------
|
||||
// Keybindings
|
||||
@@ -37,6 +47,10 @@ export function useGame(platformHook?: unknown) {
|
||||
const scoreManager = useScore({
|
||||
gameContext: { gameStatus, setGameStatus },
|
||||
keystrokeCount: keyLog.length,
|
||||
board,
|
||||
isComplete,
|
||||
isFullyValid,
|
||||
initialBoard,
|
||||
});
|
||||
|
||||
// ---------------------------
|
||||
|
||||
@@ -78,16 +78,22 @@ export function useKeyBindings(ctx: Ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// R or C to enter edit mode
|
||||
if (k === "r" || k === "R" || k === "c" || k === "C") {
|
||||
// R to replace (only works on user-filled cells)
|
||||
if (k === "r" || k === "R") {
|
||||
recordKey(k);
|
||||
|
||||
// Check if current cell is a pre-filled clue (not editable)
|
||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
||||
const isEmpty = ctx.board[cellIndex] === ".";
|
||||
|
||||
if (isClue) {
|
||||
// Flash red - cell is locked
|
||||
// Flash red - cell is locked (pre-filled clue)
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
// Flash red - cell is empty (use I to insert)
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return;
|
||||
}
|
||||
@@ -96,6 +102,47 @@ export function useKeyBindings(ctx: Ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// I to insert (only works on empty cells)
|
||||
if (k === "i" || k === "I") {
|
||||
recordKey(k);
|
||||
|
||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
||||
const isEmpty = ctx.board[cellIndex] === ".";
|
||||
|
||||
if (isClue) {
|
||||
// Flash red - cell is locked (pre-filled clue)
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEmpty) {
|
||||
// Flash red - cell already has a value (use R to replace)
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return;
|
||||
}
|
||||
|
||||
setEditMode("edit");
|
||||
return;
|
||||
}
|
||||
|
||||
// D or X to delete cell value in navigation mode
|
||||
if (k === "d" || k === "D" || k === "x" || k === "X") {
|
||||
recordKey(k);
|
||||
|
||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
||||
|
||||
if (isClue) {
|
||||
// Flash red - can't delete clues
|
||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.erase();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape cancels count prefix
|
||||
if (k === "Escape") {
|
||||
recordKey(k);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useTimer } from "./hooks/useTimer";
|
||||
import { useScoreTime } from "./useScoreTime";
|
||||
import type { GameStatus } from "../types";
|
||||
|
||||
const TIME_LIMIT_MS = 60000; // 60 seconds
|
||||
const KEYSTROKE_LIMIT = 100;
|
||||
const TIME_LIMIT_MS = Infinity; // No time limit by default
|
||||
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
|
||||
|
||||
type GameScoreContext = {
|
||||
gameStatus: GameStatus;
|
||||
@@ -14,33 +14,68 @@ type GameScoreContext = {
|
||||
type UseScoreParams = {
|
||||
gameContext: GameScoreContext;
|
||||
keystrokeCount: number;
|
||||
board: string;
|
||||
isComplete: (board: string) => boolean;
|
||||
isFullyValid: (board: string) => boolean;
|
||||
initialBoard: string;
|
||||
};
|
||||
|
||||
export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
|
||||
export function useScore({
|
||||
gameContext,
|
||||
keystrokeCount,
|
||||
board,
|
||||
isComplete,
|
||||
isFullyValid,
|
||||
initialBoard
|
||||
}: UseScoreParams) {
|
||||
const timer = useTimer();
|
||||
const { gameStatus, setGameStatus } = gameContext;
|
||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||
|
||||
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||
|
||||
const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
|
||||
const actualMoves = keystrokeCount;
|
||||
const efficiency = minMovesRequired > 0
|
||||
? Math.round((actualMoves / minMovesRequired) * 100)
|
||||
: 0;
|
||||
|
||||
useScoreTime({ gameStatus, timer });
|
||||
|
||||
// Check for game-over conditions
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "started") return;
|
||||
|
||||
// Time limit exceeded
|
||||
if (isComplete(board) && isFullyValid(board)) {
|
||||
setGameStatus("game-won");
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeValue >= TIME_LIMIT_MS) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
|
||||
// Keystroke limit exceeded
|
||||
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
}, [gameStatus, timeValue, keystrokeCount, setGameStatus]);
|
||||
|
||||
// TODO: Enable efficiency-based game-over in future
|
||||
// if (efficiency > 200) {
|
||||
// setGameStatus("game-over");
|
||||
// return;
|
||||
// }
|
||||
}, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWin = () => {
|
||||
if (gameStatus === "started") {
|
||||
setGameStatus("game-won");
|
||||
}
|
||||
};
|
||||
window.addEventListener("sudoku-game-won", handleWin);
|
||||
return () => window.removeEventListener("sudoku-game-won", handleWin);
|
||||
}, [gameStatus, setGameStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "game-won") return;
|
||||
@@ -48,10 +83,12 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
|
||||
const timeInSeconds = timeValue / 1000;
|
||||
const baseScore = 100000;
|
||||
const timePenalty = Math.floor(timeInSeconds * 10);
|
||||
const score = Math.max(0, baseScore - timePenalty);
|
||||
const extraMoves = Math.max(0, actualMoves - minMovesRequired);
|
||||
const movePenalty = extraMoves * 50;
|
||||
const score = Math.max(0, baseScore - timePenalty - movePenalty);
|
||||
|
||||
setFinalScore(score);
|
||||
}, [gameStatus, timeValue]);
|
||||
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
@@ -61,6 +98,9 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
|
||||
finalScore,
|
||||
timeLimit: TIME_LIMIT_MS,
|
||||
keystrokeLimit: KEYSTROKE_LIMIT,
|
||||
minMovesRequired,
|
||||
actualMoves,
|
||||
efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user