starting new structure
This commit is contained in:
@@ -58,7 +58,7 @@ bun add @vimazing/vim-sudoku
|
||||
|
||||
```tsx
|
||||
import { useGame } from "@vimazing/vim-sudoku";
|
||||
import "@vimazing/vim-sudoku/sudoku.css";
|
||||
import "@vimazing/vim-sudoku/game.css";
|
||||
|
||||
export function SudokuGame() {
|
||||
const { containerRef, initGame, gameStatus, scoreManager } = useGame();
|
||||
@@ -80,6 +80,8 @@ export function SudokuGame() {
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** You must manually import the CSS file. The package exports styles but does not auto-import them, giving you control over when and how styles are loaded.
|
||||
|
||||
---
|
||||
|
||||
## VIM Modal Editing
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "@vimazing/vim-maze",
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
@@ -14,10 +15,14 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
|
||||
|
||||
+3
-58
@@ -1,70 +1,15 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGame } from "../../src/useGame/useGame";
|
||||
import { useGame } from "../../src/useGame";
|
||||
import { useKeyBindings } from './useKeyBindings';
|
||||
import { useMounted } from './useMounted';
|
||||
import { GameOverlay } from './GameOverlay';
|
||||
import "../../src/sudoku.css";
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
import "../../src/game.css";
|
||||
|
||||
export default function App() {
|
||||
const mounted = useMounted();
|
||||
const {
|
||||
containerRef,
|
||||
initGame,
|
||||
solveBoard,
|
||||
scoreManager,
|
||||
boardManager,
|
||||
keyLog,
|
||||
gameStatus,
|
||||
} = useGame(useKeyBindings);
|
||||
|
||||
const { timeValue, finalScore } = scoreManager;
|
||||
|
||||
const stringed = boardManager.boardToString(true);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(stringed);
|
||||
}, [stringed]);
|
||||
const { containerRef } = useGame({}, useKeyBindings);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h1>VIMazing Sudoku</h1>
|
||||
|
||||
{/* Scoreboard */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
marginBottom: 16,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
}}>
|
||||
<div>
|
||||
<strong>Time:</strong> {formatTime(timeValue)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Keystrokes:</strong> {keyLog.length}
|
||||
</div>
|
||||
{gameStatus === "game-won" && finalScore !== null && (
|
||||
<div style={{ color: '#82aaff', fontWeight: 'bold' }}>
|
||||
<strong>Score:</strong> {finalScore.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-fit mx-auto">
|
||||
<div ref={containerRef} />
|
||||
{mounted && <GameOverlay gameStatus={gameStatus} scoreManager={scoreManager} />}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 8 }}>
|
||||
<button onClick={initGame}>New Puzzle</button>
|
||||
<button onClick={solveBoard}>Solve</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
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] font-mono text-center select-none"
|
||||
style={{
|
||||
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
|
||||
fontSize: "calc(2rem * var(--sudoku-size-multiplier))"
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col" style={{ gap: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>
|
||||
<p style={{ fontSize: "calc(3rem * var(--sudoku-size-multiplier))" }}>Press <strong className="text-[#82aaff]">New Puzzle</strong> to start</p>
|
||||
<div style={{ fontSize: "calc(1.2rem * var(--sudoku-size-multiplier))" }}>
|
||||
<p style={{ marginBottom: "calc(1rem * var(--sudoku-size-multiplier))" }}><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 style={{ marginTop: "calc(0.5rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#82aaff]">R</strong> or <strong className="text-[#82aaff]">C</strong> to enter edit mode</p>
|
||||
|
||||
<p style={{ marginTop: "calc(1.5rem * var(--sudoku-size-multiplier))", marginBottom: "calc(1rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#ffc777]">Edit Mode</strong>:</p>
|
||||
<p>Type <strong className="text-[#82aaff]">1-9</strong> to fill cell</p>
|
||||
<p style={{ marginTop: "calc(0.5rem * var(--sudoku-size-multiplier))" }}><strong className="text-[#82aaff]">Esc</strong> or <strong className="text-[#82aaff]">Enter</strong> to exit edit mode</p>
|
||||
|
||||
<p className="text-[#828bb8]" style={{ marginTop: "calc(1.5rem * var(--sudoku-size-multiplier))", fontSize: "calc(0.8rem * var(--sudoku-size-multiplier))" }}>
|
||||
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] grid font-mono uppercase text-center select-none"
|
||||
style={{
|
||||
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
|
||||
fontSize: "calc(2rem * var(--sudoku-size-multiplier))",
|
||||
gap: "calc(1rem * var(--sudoku-size-multiplier))"
|
||||
}}
|
||||
>
|
||||
<p className="text-[#c3e88d]">🎉 Puzzle Solved!</p>
|
||||
<p>Final Score:</p>
|
||||
<p className="text-[#82aaff]" style={{ fontSize: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>{finalScore.toLocaleString()}</p>
|
||||
<p className="text-[#828bb8] normal-case" style={{ fontSize: "calc(0.65rem * var(--sudoku-size-multiplier))" }}>
|
||||
<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 style={{ fontSize: "calc(5rem * var(--sudoku-size-multiplier))" }}>Game Over</p>
|
||||
<p className="text-[#828bb8]" style={{ fontSize: "calc(2rem * var(--sudoku-size-multiplier))" }}>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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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] font-mono text-center select-none"
|
||||
style={{
|
||||
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
|
||||
fontSize: "calc(1rem * var(--sudoku-size-multiplier))"
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col" style={{ gap: "calc(0.75rem * var(--sudoku-size-multiplier))" }}>
|
||||
<p style={{ fontSize: "calc(1.5rem * var(--sudoku-size-multiplier))" }}>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] grid font-mono uppercase text-center select-none"
|
||||
style={{
|
||||
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
|
||||
fontSize: "calc(1rem * var(--sudoku-size-multiplier))",
|
||||
gap: "calc(1rem * var(--sudoku-size-multiplier))"
|
||||
}}
|
||||
>
|
||||
<p className="text-[#c3e88d]">🎉 Solved!</p>
|
||||
<p>Final Score:</p>
|
||||
<p className="text-[#82aaff]" style={{ fontSize: "calc(2.5rem * var(--sudoku-size-multiplier))" }}>{finalScore.toLocaleString()}</p>
|
||||
<p className="text-[#828bb8] normal-case" style={{ fontSize: "calc(0.65rem * var(--sudoku-size-multiplier))" }}>
|
||||
<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] font-mono uppercase text-center select-none"
|
||||
style={{
|
||||
textShadow: "2px 2px 4px rgba(27, 29, 43, 0.9)",
|
||||
fontSize: "calc(5rem * var(--sudoku-size-multiplier))"
|
||||
}}
|
||||
>
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import type { UseGameType } from "../../src/";
|
||||
import type { GameManager } from "../../src";
|
||||
|
||||
export const useKeyBindings = (gameManager: UseGameType) => {
|
||||
const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
|
||||
export const useKeyBindings = (gameManager: GameManager) => {
|
||||
const { gameStatus, renderBoard, startGame, quitGame, clearKeyLog } = gameManager;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
console.log(e.key);
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
// start game
|
||||
if (e.code === "Space") {
|
||||
if (ev.code === "Space") {
|
||||
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
||||
clearLog();
|
||||
resetCount();
|
||||
clearKeyLog();
|
||||
renderBoard();
|
||||
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
||||
stopGame();
|
||||
setTimeout(() => initGame(), 0);
|
||||
quitGame();
|
||||
setTimeout(() => startGame(), 0);
|
||||
} else {
|
||||
initGame();
|
||||
startGame();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -25,23 +24,15 @@ export const useKeyBindings = (gameManager: UseGameType) => {
|
||||
// ignore other keys while waiting
|
||||
if (gameStatus === "waiting") return;
|
||||
|
||||
// cancel count or stop game
|
||||
if (e.key === "Escape") {
|
||||
stopGame();
|
||||
resetCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// quit
|
||||
if (e.key === "q" || e.key === "Q") {
|
||||
stopGame();
|
||||
resetCount();
|
||||
if (ev.key === "q" || ev.key === "Q") {
|
||||
quitGame();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [gameStatus, initGame, stopGame, clearLog, resetCount]);
|
||||
}, [gameStatus]);
|
||||
};
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
/* === Path aliases (optional) === */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@vimazing/vim-maze": ["../dist/index.d.ts"],
|
||||
"@vimazing/vim-maze/*": ["../dist/*"]
|
||||
"@vimazing/vim-sudoku": ["../src/index.ts"],
|
||||
"@vimazing/vim-sudoku/*": ["../src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "global.d.ts"],
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@vimazing/vim-maze": libSource, // use source during dev
|
||||
"@vimazing/vim-sudoku": libSource, // use source during dev
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@vimazing/vim-maze",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@vimazing/vim-maze",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.3"
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vimazing/vim-sudoku",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.",
|
||||
"author": "André Padez <andre.padez@gmail.com>",
|
||||
"repository": {
|
||||
@@ -52,13 +52,14 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/sudoku.css', 'dist/sudoku.css');\"",
|
||||
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false && node -e \"const fs = require('node:fs'); fs.mkdirSync('dist', { recursive: true }); fs.copyFileSync('src/game.css', 'dist/game.css');\"",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.2.2",
|
||||
"typescript": "~5.9.3"
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
🔧 Controls (tweak these)
|
||||
========================================================= */
|
||||
:root {
|
||||
--sudoku-size-multiplier: 3;
|
||||
--cell-size: calc(32px * var(--sudoku-size-multiplier));
|
||||
--cell-font-size: calc(16px * var(--sudoku-size-multiplier));
|
||||
--grid-gap: calc(2px * var(--sudoku-size-multiplier));
|
||||
--box-separator: calc(3px * var(--sudoku-size-multiplier));
|
||||
--cell-size: 96px;
|
||||
--cell-font-size: 48px;
|
||||
--grid-gap: 6px;
|
||||
--box-separator: 9px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
+32
-3
@@ -1,3 +1,32 @@
|
||||
import './sudoku.css';
|
||||
export * from './types';
|
||||
export * from "./useGame";
|
||||
export { useGame } from './useGame';
|
||||
export type { GameManager, GameOptions } from './types';
|
||||
|
||||
export { useBoard } from './useBoard';
|
||||
export { useCursor } from './useCursor';
|
||||
export { useGameStatus } from './useGameStatus';
|
||||
export { useScore } from './useScore';
|
||||
|
||||
export type {
|
||||
Coord,
|
||||
SudokuBoard,
|
||||
SudokuCell,
|
||||
Difficulty,
|
||||
BoardManager
|
||||
} from './useBoard/types';
|
||||
|
||||
export type {
|
||||
CursorManager,
|
||||
CursorMode,
|
||||
GameKeyManager,
|
||||
KeyLogEntry
|
||||
} from './useCursor/types';
|
||||
|
||||
export type {
|
||||
GameStatus,
|
||||
GameStatusManager
|
||||
} from './useGameStatus/types';
|
||||
|
||||
export type {
|
||||
ScoreManager,
|
||||
TimerManager
|
||||
} from './useScore/types';
|
||||
+32
-17
@@ -1,20 +1,35 @@
|
||||
import { useGame } from "./useGame";
|
||||
import { useScore } from "./useScore";
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
export type UseGameType = ReturnType<typeof useGame>;
|
||||
export type UseScoreType = ReturnType<typeof useScore>;
|
||||
export type { Coord, SudokuBoard, SudokuCell, Difficulty } from './useBoard/types';
|
||||
export type { CursorManager, CursorMode, GameKeyManager, KeyLogEntry } from './useCursor/types';
|
||||
export type { GameStatus, GameStatusManager } from './useGameStatus/types';
|
||||
export type { ScoreManager, TimerManager } from './useScore/types';
|
||||
|
||||
export type {
|
||||
Difficulty,
|
||||
DifficultyName,
|
||||
} from "./lib/Sudoku";
|
||||
export type GameOptions = {
|
||||
difficulty?: Difficulty;
|
||||
};
|
||||
|
||||
export type GamePhase = "idle" | "playing";
|
||||
export type PlayStatus = "started" | "game-over" | "game-won";
|
||||
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
|
||||
|
||||
export const isPlaying = (status: GameStatus): status is PlayStatus =>
|
||||
status === "started" || status === "game-over" || status === "game-won";
|
||||
|
||||
export const getGamePhase = (status: GameStatus): GamePhase =>
|
||||
status === "waiting" ? "idle" : "playing";
|
||||
export type GameManager = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
renderBoard: () => void;
|
||||
gameStatus: GameStatus;
|
||||
setGameStatus: (status: GameStatus) => void;
|
||||
startGame: () => void;
|
||||
togglePause: (pause?: boolean) => void;
|
||||
quitGame: () => void;
|
||||
cursor: CursorManager;
|
||||
scoreManager: ScoreManager;
|
||||
keyLog: KeyLogEntry[];
|
||||
clearKeyLog: () => void;
|
||||
getKeyLog: () => KeyLogEntry[];
|
||||
board: {
|
||||
generateBoard: (difficulty: Difficulty) => void;
|
||||
solveBoard: () => boolean;
|
||||
resetBoard: () => void;
|
||||
getCell: (row: number, col: number) => any;
|
||||
setCell: (row: number, col: number, value: string) => boolean;
|
||||
canEditCell: (row: number, col: number) => boolean;
|
||||
isComplete: () => boolean;
|
||||
isFullyValid: () => boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
export type DifficultyName = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
|
||||
export type Difficulty = DifficultyName | number;
|
||||
|
||||
const DIGITS = '123456789';
|
||||
const ROWS = 'ABCDEFGHI';
|
||||
const COLS = DIGITS;
|
||||
let SQUARES: string[] = [];
|
||||
let UNITS: string[][] = [];
|
||||
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
|
||||
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
|
||||
|
||||
const MIN_GIVENS = 17;
|
||||
const NR_SQUARES = 81;
|
||||
|
||||
const DIFFICULTY: Record<DifficultyName, number> = {
|
||||
easy: 62,
|
||||
medium: 53,
|
||||
hard: 44,
|
||||
'very-hard': 35,
|
||||
insane: 26,
|
||||
inhuman: 17,
|
||||
};
|
||||
|
||||
const BLANK_CHAR = '.';
|
||||
const BLANK_BOARD = BLANK_CHAR.repeat(NR_SQUARES);
|
||||
|
||||
function initialize() {
|
||||
SQUARES = cross(ROWS, COLS);
|
||||
UNITS = getAllUnits(ROWS, COLS);
|
||||
SQUARE_UNITS_MAP = getSquareUnitsMap(SQUARES, UNITS);
|
||||
SQUARE_PEERS_MAP = getSquarePeersMap(SQUARES, SQUARE_UNITS_MAP);
|
||||
}
|
||||
|
||||
function cross(a: string, b: string) {
|
||||
const res: string[] = [];
|
||||
for (const ai of a) {
|
||||
for (const bi of b) {
|
||||
res.push(ai + bi);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
function inStr(v: string, seq: string) {
|
||||
return seq.indexOf(v) !== -1;
|
||||
}
|
||||
|
||||
function shuffle<T>(seq: T[]): T[] {
|
||||
const arr = [...seq];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function randRange(max: number, min = 0) {
|
||||
return Math.floor(Math.random() * (max - min)) + min;
|
||||
}
|
||||
|
||||
function stripDups<T>(seq: T[]): T[] {
|
||||
return Array.from(new Set(seq));
|
||||
}
|
||||
|
||||
function forceRange(nr: number, max: number, min = 0) {
|
||||
return Math.min(Math.max(nr || 0, min), max);
|
||||
}
|
||||
|
||||
function getAllUnits(rows: string, cols: string) {
|
||||
const units: string[][] = [];
|
||||
for (const r of rows) units.push(cross(r, cols));
|
||||
for (const c of cols) units.push(cross(rows, c));
|
||||
const row_blocks = ['ABC', 'DEF', 'GHI'];
|
||||
const col_blocks = ['123', '456', '789'];
|
||||
for (const rb of row_blocks) {
|
||||
for (const cb of col_blocks) {
|
||||
units.push(cross(rb, cb));
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
function getSquareUnitsMap(squares: string[], units: string[][]) {
|
||||
const map: Record<string, string[][]> = {};
|
||||
for (const s of squares) {
|
||||
const list: string[][] = [];
|
||||
for (const u of units) {
|
||||
if (u.includes(s)) list.push(u);
|
||||
}
|
||||
map[s] = list;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getSquarePeersMap(squares: string[], units_map: Record<string, string[][]>) {
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const s of squares) {
|
||||
const peers: string[] = [];
|
||||
for (const unit of units_map[s]) {
|
||||
for (const sq of unit) {
|
||||
if (sq !== s && !peers.includes(sq)) peers.push(sq);
|
||||
}
|
||||
}
|
||||
map[s] = peers;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getSquareValsMap(board: string) {
|
||||
const map: Record<string, string> = {};
|
||||
if (board.length !== SQUARES.length) throw 'Board length mismatch';
|
||||
for (let i = 0; i < SQUARES.length; i++) {
|
||||
map[SQUARES[i]] = board[i];
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function validateBoard(board: string): true | string {
|
||||
if (!board) return 'Empty board';
|
||||
if (board.length !== NR_SQUARES) return 'Invalid board size';
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
const c = board[i];
|
||||
if (!inStr(c, DIGITS) && c !== BLANK_CHAR) {
|
||||
return `Invalid char at ${i}: ${c}`;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getCandidatesMap(board: string): Record<string, string> | false {
|
||||
const report = validateBoard(board);
|
||||
if (report !== true) throw report;
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
const squares_values = getSquareValsMap(board);
|
||||
|
||||
for (const s of SQUARES) map[s] = DIGITS;
|
||||
|
||||
for (const s in squares_values) {
|
||||
const val = squares_values[s];
|
||||
if (inStr(val, DIGITS)) {
|
||||
const new_candidates = assign(map, s, val);
|
||||
if (!new_candidates) return false;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function assign(candidates: Record<string, string>, square: string, val: string) {
|
||||
const others = candidates[square].replace(val, '');
|
||||
for (const v of others) {
|
||||
const next = eliminate(candidates, square, v);
|
||||
if (!next) return false;
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function eliminate(candidates: Record<string, string>, square: string, val: string) {
|
||||
if (!inStr(val, candidates[square])) return candidates;
|
||||
|
||||
candidates[square] = candidates[square].replace(val, '');
|
||||
const n = candidates[square].length;
|
||||
|
||||
if (n === 1) {
|
||||
const target_val = candidates[square];
|
||||
for (const peer of SQUARE_PEERS_MAP[square]) {
|
||||
const next = eliminate(candidates, peer, target_val);
|
||||
if (!next) return false;
|
||||
}
|
||||
} else if (n === 0) return false;
|
||||
|
||||
for (const unit of SQUARE_UNITS_MAP[square]) {
|
||||
const places: string[] = [];
|
||||
for (const sq of unit) {
|
||||
if (inStr(val, candidates[sq])) places.push(sq);
|
||||
}
|
||||
if (places.length === 0) return false;
|
||||
else if (places.length === 1) {
|
||||
const next = assign(candidates, places[0], val);
|
||||
if (!next) return false;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function search(candidates: Record<string, string>, reverse: boolean): any {
|
||||
if (!candidates) return false;
|
||||
reverse = reverse || false;
|
||||
|
||||
let max_nr_candidates = 0;
|
||||
for (const s of SQUARES) {
|
||||
const n = candidates[s].length;
|
||||
if (n > max_nr_candidates) {
|
||||
max_nr_candidates = n;
|
||||
}
|
||||
}
|
||||
if (max_nr_candidates === 1) return candidates;
|
||||
|
||||
let min_nr_candidates = 10;
|
||||
let min_square = '';
|
||||
for (const s of SQUARES) {
|
||||
const n = candidates[s].length;
|
||||
if (n < min_nr_candidates && n > 1) {
|
||||
min_nr_candidates = n;
|
||||
min_square = s;
|
||||
}
|
||||
}
|
||||
|
||||
const min_candidates = candidates[min_square];
|
||||
if (!reverse) {
|
||||
for (const v of min_candidates) {
|
||||
const copy = JSON.parse(JSON.stringify(candidates));
|
||||
const next = search(assign(copy, min_square, v), reverse);
|
||||
if (next) return next;
|
||||
}
|
||||
} else {
|
||||
for (let i = min_candidates.length - 1; i >= 0; --i) {
|
||||
const v = min_candidates[i];
|
||||
const copy = JSON.parse(JSON.stringify(candidates));
|
||||
const next = search(assign(copy, min_square, v), reverse);
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class SudokuGenerator {
|
||||
static generate(difficulty: Difficulty, unique?: boolean): string {
|
||||
let numericDifficulty: number;
|
||||
if (typeof difficulty === 'string' || typeof difficulty === 'undefined') {
|
||||
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
|
||||
} else {
|
||||
numericDifficulty = difficulty;
|
||||
}
|
||||
|
||||
numericDifficulty = forceRange(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
|
||||
unique = unique ?? true;
|
||||
|
||||
const blank_board = BLANK_BOARD;
|
||||
let candidates = getCandidatesMap(blank_board);
|
||||
|
||||
if (!candidates) return SudokuGenerator.generate(numericDifficulty);
|
||||
|
||||
const shuffled_squares = shuffle(SQUARES);
|
||||
|
||||
for (const square of shuffled_squares) {
|
||||
const rand_candidate_idx = randRange(candidates[square].length);
|
||||
const rand_candidate = candidates[square][rand_candidate_idx];
|
||||
if (!assign(candidates, square, rand_candidate)) break;
|
||||
|
||||
const single_candidates: string[] = [];
|
||||
for (const s of SQUARES) {
|
||||
if (candidates[s].length === 1) single_candidates.push(candidates[s]);
|
||||
}
|
||||
|
||||
if (
|
||||
single_candidates.length >= numericDifficulty &&
|
||||
stripDups(single_candidates).length >= 8
|
||||
) {
|
||||
let board = '';
|
||||
let givens_idxs: number[] = [];
|
||||
|
||||
for (let i = 0; i < SQUARES.length; i++) {
|
||||
const s = SQUARES[i];
|
||||
if (candidates[s].length === 1) {
|
||||
board += candidates[s];
|
||||
givens_idxs.push(i);
|
||||
} else {
|
||||
board += BLANK_CHAR;
|
||||
}
|
||||
}
|
||||
|
||||
if (givens_idxs.length > numericDifficulty) {
|
||||
givens_idxs = shuffle(givens_idxs);
|
||||
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
|
||||
const target = givens_idxs[i];
|
||||
board =
|
||||
board.substring(0, target) +
|
||||
BLANK_CHAR +
|
||||
board.substring(target + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (SudokuGenerator.solve(board)) return board;
|
||||
}
|
||||
}
|
||||
|
||||
return SudokuGenerator.generate(numericDifficulty);
|
||||
}
|
||||
|
||||
static solve(board: string, reverse?: boolean): string | false {
|
||||
const report = validateBoard(board);
|
||||
if (report !== true) throw report;
|
||||
|
||||
let nr_givens = 0;
|
||||
for (const ch of board) {
|
||||
if (ch !== BLANK_CHAR && inStr(ch, DIGITS)) nr_givens++;
|
||||
}
|
||||
if (nr_givens < MIN_GIVENS) throw 'Too few givens';
|
||||
|
||||
reverse = reverse || false;
|
||||
const candidates = getCandidatesMap(board);
|
||||
if (!candidates) return false;
|
||||
|
||||
const result = search(candidates, reverse);
|
||||
|
||||
if (result) {
|
||||
let solution = '';
|
||||
for (const s of SQUARES) solution += result[s];
|
||||
return solution;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { SudokuBoard, SudokuCell } from './types';
|
||||
|
||||
export class SudokuRenderer {
|
||||
static renderBoard(container: HTMLElement, board: SudokuBoard): void {
|
||||
const sudokuDiv = document.createElement('div');
|
||||
sudokuDiv.id = 'sudoku';
|
||||
sudokuDiv.className = 'sudoku-board';
|
||||
|
||||
for (let row = 0; row < 9; row++) {
|
||||
const rowDiv = document.createElement('div');
|
||||
rowDiv.className = 'sudoku-row';
|
||||
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const cell = board[row][col];
|
||||
const cellDiv = document.createElement('div');
|
||||
cellDiv.className = 'sudoku-cell';
|
||||
cellDiv.dataset.row = row.toString();
|
||||
cellDiv.dataset.col = col.toString();
|
||||
cellDiv.textContent = cell.value || '';
|
||||
|
||||
if (cell.isGiven) {
|
||||
cellDiv.classList.add('given');
|
||||
}
|
||||
|
||||
if (cell.hasConflict) {
|
||||
cellDiv.classList.add('conflict');
|
||||
}
|
||||
|
||||
if (col === 2 || col === 5) {
|
||||
cellDiv.classList.add('border-right');
|
||||
}
|
||||
|
||||
if (row === 2 || row === 5) {
|
||||
cellDiv.classList.add('border-bottom');
|
||||
}
|
||||
|
||||
rowDiv.appendChild(cellDiv);
|
||||
}
|
||||
|
||||
sudokuDiv.appendChild(rowDiv);
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(sudokuDiv);
|
||||
}
|
||||
|
||||
static renderEmptyBoard(container: HTMLElement): void {
|
||||
const emptyBoard: SudokuBoard = Array(9).fill(null).map(() =>
|
||||
Array(9).fill(null).map(() => ({
|
||||
value: '',
|
||||
isGiven: false,
|
||||
hasConflict: false
|
||||
}))
|
||||
);
|
||||
|
||||
SudokuRenderer.renderBoard(container, emptyBoard);
|
||||
}
|
||||
|
||||
static updateCell(container: HTMLElement, row: number, col: number, cell: SudokuCell): void {
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (!cellEl) return;
|
||||
|
||||
cellEl.textContent = cell.value || '';
|
||||
cellEl.classList.toggle('given', cell.isGiven);
|
||||
cellEl.classList.toggle('conflict', cell.hasConflict);
|
||||
}
|
||||
|
||||
static highlightCursor(container: HTMLElement, row: number, col: number, editMode: boolean): void {
|
||||
const cells = container.querySelectorAll('.sudoku-cell');
|
||||
cells.forEach(cell => cell.classList.remove('cursor', 'edit-mode'));
|
||||
|
||||
const cursorCell = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (cursorCell) {
|
||||
cursorCell.classList.add('cursor');
|
||||
if (editMode) {
|
||||
cursorCell.classList.add('edit-mode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static flashCell(container: HTMLElement, row: number, col: number, className: string, duration: number = 300): void {
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (!cellEl) return;
|
||||
|
||||
cellEl.classList.add(className);
|
||||
setTimeout(() => cellEl.classList.remove(className), duration);
|
||||
}
|
||||
|
||||
static highlightConflicts(container: HTMLElement, conflictIndices: number[], duration: number = 2000): void {
|
||||
conflictIndices.forEach((idx) => {
|
||||
const row = Math.floor(idx / 9);
|
||||
const col = idx % 9;
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('conflict');
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
conflictIndices.forEach((idx) => {
|
||||
const row = Math.floor(idx / 9);
|
||||
const col = idx % 9;
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.remove('conflict');
|
||||
}
|
||||
});
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useBoard } from './useBoard';
|
||||
export type { BoardManager, SudokuBoard, SudokuCell, Coord, Difficulty } from './types';
|
||||
export { SudokuGenerator } from './SudokuGenerator';
|
||||
export { SudokuRenderer } from './SudokuRenderer';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
export type Coord = { row: number; col: number };
|
||||
|
||||
export type SudokuCell = {
|
||||
value: string;
|
||||
isGiven: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
|
||||
export type SudokuBoard = SudokuCell[][];
|
||||
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
|
||||
|
||||
export type BoardManager = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
boardRef: RefObject<SudokuBoard>;
|
||||
initialBoardRef: RefObject<string>;
|
||||
solutionRef: RefObject<string | null>;
|
||||
renderBoard: () => void;
|
||||
generateBoard: (difficulty: Difficulty) => void;
|
||||
getCell: (row: number, col: number) => SudokuCell | null;
|
||||
setCell: (row: number, col: number, value: string) => boolean;
|
||||
canEditCell: (row: number, col: number) => boolean;
|
||||
isValidPlacement: (row: number, col: number, digit: string) => boolean;
|
||||
getConflicts: (row: number, col: number) => number[];
|
||||
isComplete: () => boolean;
|
||||
isFullyValid: () => boolean;
|
||||
solveBoard: () => boolean;
|
||||
resetBoard: () => void;
|
||||
};
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { BoardManager, SudokuBoard, SudokuCell, Difficulty } from './types';
|
||||
import { SudokuGenerator } from './SudokuGenerator';
|
||||
import { SudokuRenderer } from './SudokuRenderer';
|
||||
|
||||
export function useBoard(): BoardManager {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const boardRef = useRef<SudokuBoard>([]);
|
||||
const initialBoardRef = useRef<string>('');
|
||||
const solutionRef = useRef<string | null>(null);
|
||||
|
||||
const initializeBoard = useCallback((boardString: string): SudokuBoard => {
|
||||
const board: SudokuBoard = [];
|
||||
for (let row = 0; row < 9; row++) {
|
||||
const boardRow: SudokuCell[] = [];
|
||||
for (let col = 0; col < 9; col++) {
|
||||
const index = row * 9 + col;
|
||||
const value = boardString[index];
|
||||
boardRow.push({
|
||||
value: value === '.' ? '' : value,
|
||||
isGiven: value !== '.',
|
||||
hasConflict: false
|
||||
});
|
||||
}
|
||||
board.push(boardRow);
|
||||
}
|
||||
return board;
|
||||
}, []);
|
||||
|
||||
const getCell = useCallback((row: number, col: number): SudokuCell | null => {
|
||||
const board = boardRef.current;
|
||||
if (row < 0 || row >= 9 || col < 0 || col >= 9) return null;
|
||||
return board[row]?.[col] || null;
|
||||
}, []);
|
||||
|
||||
const canEditCell = useCallback((row: number, col: number): boolean => {
|
||||
const cell = getCell(row, col);
|
||||
return cell ? !cell.isGiven : false;
|
||||
}, [getCell]);
|
||||
|
||||
const isValidPlacement = useCallback((boardString: string, row: number, col: number, digit: string): boolean => {
|
||||
if (digit === '.') return true;
|
||||
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const checkIdx = row * 9 + c;
|
||||
if (c !== col && boardString[checkIdx] === digit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const checkIdx = r * 9 + col;
|
||||
if (r !== row && boardString[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) && boardString[checkIdx] === digit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const getConflicts = useCallback((boardString: string, row: number, col: number): number[] => {
|
||||
const conflicts: number[] = [];
|
||||
const digit = boardString[row * 9 + col];
|
||||
|
||||
if (digit === '.') return conflicts;
|
||||
|
||||
for (let c = 0; c < 9; c++) {
|
||||
if (c !== col && boardString[row * 9 + c] === digit) {
|
||||
conflicts.push(row * 9 + c);
|
||||
}
|
||||
}
|
||||
|
||||
for (let r = 0; r < 9; r++) {
|
||||
if (r !== row && boardString[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++) {
|
||||
const idx = r * 9 + c;
|
||||
if ((r !== row || c !== col) && boardString[idx] === digit) {
|
||||
if (!conflicts.includes(idx)) {
|
||||
conflicts.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}, []);
|
||||
|
||||
const isFullyValid = useCallback((boardString: string): boolean => {
|
||||
for (let r = 0; r < 9; r++) {
|
||||
let rowSum = 0;
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const digit = boardString[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 = boardString[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 = boardString[r * 9 + c];
|
||||
boxSum += parseInt(digit, 10);
|
||||
}
|
||||
}
|
||||
if (boxSum !== 45) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const isComplete = useCallback((boardString: string): boolean => {
|
||||
return !boardString.includes('.');
|
||||
}, []);
|
||||
|
||||
const boardToString = useCallback((board: SudokuBoard): string => {
|
||||
return board.map(row =>
|
||||
row.map(cell => cell.value || '.').join('')
|
||||
).join('');
|
||||
}, []);
|
||||
|
||||
const setCell = useCallback((row: number, col: number, value: string): boolean => {
|
||||
if (!canEditCell(row, col)) return false;
|
||||
|
||||
const board = boardRef.current;
|
||||
const boardString = boardToString(board);
|
||||
const newBoardString = boardString.substring(0, row * 9 + col) + value + boardString.substring(row * 9 + col + 1);
|
||||
|
||||
if (!isValidPlacement(newBoardString, row, col, value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
board[row][col] = {
|
||||
value: value === '.' ? '' : value,
|
||||
isGiven: board[row][col].isGiven,
|
||||
hasConflict: false
|
||||
};
|
||||
|
||||
return true;
|
||||
}, [canEditCell, isValidPlacement, boardToString]);
|
||||
|
||||
const generateBoard = useCallback((difficulty: Difficulty): void => {
|
||||
const newBoardString = SudokuGenerator.generate(difficulty);
|
||||
const solution = SudokuGenerator.solve(newBoardString);
|
||||
|
||||
initialBoardRef.current = newBoardString;
|
||||
solutionRef.current = solution;
|
||||
boardRef.current = initializeBoard(newBoardString);
|
||||
}, [initializeBoard]);
|
||||
|
||||
const solveBoard = useCallback((): boolean => {
|
||||
const solution = solutionRef.current;
|
||||
if (!solution) return false;
|
||||
|
||||
boardRef.current = initializeBoard(solution);
|
||||
return true;
|
||||
}, [initializeBoard]);
|
||||
|
||||
const resetBoard = useCallback((): void => {
|
||||
const initialBoard = initialBoardRef.current;
|
||||
if (initialBoard) {
|
||||
boardRef.current = initializeBoard(initialBoard);
|
||||
}
|
||||
}, [initializeBoard]);
|
||||
|
||||
const renderBoard = useCallback((): void => {
|
||||
const container = containerRef.current;
|
||||
const board = boardRef.current;
|
||||
|
||||
if (!container || board.length === 0) {
|
||||
if (container) {
|
||||
SudokuRenderer.renderEmptyBoard(container);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
SudokuRenderer.renderBoard(container, board);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
boardRef,
|
||||
initialBoardRef,
|
||||
solutionRef,
|
||||
renderBoard,
|
||||
generateBoard,
|
||||
getCell,
|
||||
setCell,
|
||||
canEditCell,
|
||||
isValidPlacement: (row, col, digit) => {
|
||||
const boardString = boardToString(boardRef.current);
|
||||
return isValidPlacement(boardString, row, col, digit);
|
||||
},
|
||||
getConflicts: (row, col) => {
|
||||
const boardString = boardToString(boardRef.current);
|
||||
return getConflicts(boardString, row, col);
|
||||
},
|
||||
isComplete: () => {
|
||||
const boardString = boardToString(boardRef.current);
|
||||
return isComplete(boardString);
|
||||
},
|
||||
isFullyValid: () => {
|
||||
const boardString = boardToString(boardRef.current);
|
||||
return isFullyValid(boardString);
|
||||
},
|
||||
solveBoard,
|
||||
resetBoard
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export { useCursor } from './useCursor';
|
||||
export { useVimMotions } from './useVimMotions';
|
||||
export type {
|
||||
CursorManager,
|
||||
CursorMode,
|
||||
Coord,
|
||||
Motion,
|
||||
VimMotionSystem,
|
||||
GameKeyManager,
|
||||
KeyLogEntry,
|
||||
CursorRenderer
|
||||
} from './types';
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RefObject } from 'react';
|
||||
import type { BoardManager } from '../useBoard/types';
|
||||
|
||||
export type Coord = { row: number; col: number };
|
||||
|
||||
export type CursorMode = 'normal' | 'edit';
|
||||
|
||||
export type Motion = {
|
||||
dr: number;
|
||||
dc: number;
|
||||
steps: number;
|
||||
};
|
||||
|
||||
export type CursorManager = {
|
||||
position: () => Coord;
|
||||
mode: () => CursorMode;
|
||||
move: (dRows: number, dCols: number, count: number) => void;
|
||||
moveLeft: (count?: number) => void;
|
||||
moveRight: (count?: number) => void;
|
||||
moveUp: (count?: number) => void;
|
||||
moveDown: (count?: number) => void;
|
||||
moveToStart: () => void;
|
||||
moveToEnd: () => void;
|
||||
moveToTop: () => void;
|
||||
moveToBottom: () => void;
|
||||
repeatLastMotion: () => void;
|
||||
resetCount: () => void;
|
||||
getCount: () => string;
|
||||
hasCount: () => boolean;
|
||||
setCount: (digit: string) => void;
|
||||
setLastKey: (key: string) => void;
|
||||
getLastKey: () => string;
|
||||
setMode: (mode: CursorMode) => void;
|
||||
flashInvalid: () => void;
|
||||
flashValid: () => void;
|
||||
flashLocked: () => void;
|
||||
highlightConflicts: (conflictIndices: number[]) => void;
|
||||
};
|
||||
|
||||
export type VimMotionSystem = {
|
||||
processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count' | 'digit' | 'erase' | 'mode', data: any } | null;
|
||||
resetCount: () => void;
|
||||
getCount: () => number;
|
||||
hasCount: () => boolean;
|
||||
setLastMotion: (motion: Motion) => void;
|
||||
repeatLastMotion: () => Motion | null;
|
||||
};
|
||||
|
||||
export type GameKeyManager = {
|
||||
keyLog: KeyLogEntry[];
|
||||
clearKeyLog: () => void;
|
||||
getKeyLog: () => KeyLogEntry[];
|
||||
editMode: CursorMode;
|
||||
setEditMode: (mode: CursorMode) => void;
|
||||
};
|
||||
|
||||
export type KeyLogEntry = { key: string; timestamp: number };
|
||||
|
||||
export type CursorRenderer = {
|
||||
highlightCursor: (row: number, col: number, editMode: boolean) => void;
|
||||
flashCell: (row: number, col: number, className: string, duration?: number) => void;
|
||||
highlightConflicts: (conflictIndices: number[], duration?: number) => void;
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { BoardManager } from '../useBoard/types';
|
||||
import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
|
||||
import { useVimMotions } from './useVimMotions';
|
||||
|
||||
export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
|
||||
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
||||
const [mode, setMode] = useState<CursorMode>('normal');
|
||||
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
||||
|
||||
const vimMotions = useVimMotions();
|
||||
|
||||
const addKeyToLog = useCallback((key: string) => {
|
||||
setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
const move = useCallback((dRows: number, dCols: number, count: number) => {
|
||||
setPosition(prev => {
|
||||
const newRow = prev.row + (dRows * count);
|
||||
const newCol = prev.col + (dCols * count);
|
||||
|
||||
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
|
||||
flashInvalid();
|
||||
return prev;
|
||||
}
|
||||
|
||||
return { row: newRow, col: newCol };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const moveLeft = useCallback((count = 1) => {
|
||||
move(0, -1, count);
|
||||
}, [move]);
|
||||
|
||||
const moveRight = useCallback((count = 1) => {
|
||||
move(0, 1, count);
|
||||
}, [move]);
|
||||
|
||||
const moveUp = useCallback((count = 1) => {
|
||||
move(-1, 0, count);
|
||||
}, [move]);
|
||||
|
||||
const moveDown = useCallback((count = 1) => {
|
||||
move(1, 0, count);
|
||||
}, [move]);
|
||||
|
||||
const moveToStart = useCallback(() => {
|
||||
setPosition(prev => ({ ...prev, col: 0 }));
|
||||
}, []);
|
||||
|
||||
const moveToEnd = useCallback(() => {
|
||||
setPosition(prev => ({ ...prev, col: 8 }));
|
||||
}, []);
|
||||
|
||||
const moveToTop = useCallback(() => {
|
||||
setPosition({ row: 0, col: 0 });
|
||||
}, []);
|
||||
|
||||
const moveToBottom = useCallback(() => {
|
||||
setPosition({ row: 8, col: 0 });
|
||||
}, []);
|
||||
|
||||
const repeatLastMotion = useCallback(() => {
|
||||
const lastMotion = vimMotions.repeatLastMotion();
|
||||
if (lastMotion) {
|
||||
move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
|
||||
}
|
||||
}, [vimMotions, move]);
|
||||
|
||||
const resetCount = useCallback(() => {
|
||||
vimMotions.resetCount();
|
||||
}, [vimMotions]);
|
||||
|
||||
const getCount = useCallback(() => {
|
||||
return vimMotions.getCount().toString();
|
||||
}, [vimMotions]);
|
||||
|
||||
const hasCount = useCallback(() => {
|
||||
return vimMotions.hasCount();
|
||||
}, [vimMotions]);
|
||||
|
||||
const setCount = useCallback((digit: string) => {
|
||||
vimMotions.processKey(digit);
|
||||
}, [vimMotions]);
|
||||
|
||||
const setLastKey = useCallback((key: string) => {
|
||||
addKeyToLog(key);
|
||||
}, [addKeyToLog]);
|
||||
|
||||
const getLastKey = useCallback(() => {
|
||||
return keyLog[keyLog.length - 1]?.key || '';
|
||||
}, [keyLog]);
|
||||
|
||||
const flashInvalid = useCallback(() => {
|
||||
const container = board.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('invalid-move');
|
||||
setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
|
||||
}
|
||||
}, [board, position]);
|
||||
|
||||
const flashValid = useCallback(() => {
|
||||
const container = board.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('valid-move');
|
||||
setTimeout(() => cellEl.classList.remove('valid-move'), 300);
|
||||
}
|
||||
}, [board, position]);
|
||||
|
||||
const flashLocked = useCallback(() => {
|
||||
const container = board.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('locked-cell');
|
||||
setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
|
||||
}
|
||||
}, [board, position]);
|
||||
|
||||
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
||||
const container = board.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
conflictIndices.forEach((idx) => {
|
||||
const row = Math.floor(idx / 9);
|
||||
const col = idx % 9;
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('conflict');
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
conflictIndices.forEach((idx) => {
|
||||
const row = Math.floor(idx / 9);
|
||||
const col = idx % 9;
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.remove('conflict');
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
}, [board]);
|
||||
|
||||
const clearKeyLog = useCallback(() => {
|
||||
setKeyLog([]);
|
||||
}, []);
|
||||
|
||||
const getKeyLog = useCallback(() => {
|
||||
return [...keyLog];
|
||||
}, [keyLog]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const key = e.key;
|
||||
const result = vimMotions.processKey(key);
|
||||
|
||||
if (!result) return;
|
||||
|
||||
addKeyToLog(key);
|
||||
|
||||
switch (result.type) {
|
||||
case 'move':
|
||||
move(result.data.dr, result.data.dc, result.data.steps);
|
||||
break;
|
||||
|
||||
case 'anchor':
|
||||
switch (result.data) {
|
||||
case 'start':
|
||||
moveToStart();
|
||||
break;
|
||||
case 'end':
|
||||
moveToEnd();
|
||||
break;
|
||||
case 'top':
|
||||
moveToTop();
|
||||
break;
|
||||
case 'bottom':
|
||||
moveToBottom();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'repeat':
|
||||
if (result.data) {
|
||||
move(result.data.dr, result.data.dc, result.data.steps);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mode':
|
||||
setMode(result.data);
|
||||
break;
|
||||
|
||||
case 'digit':
|
||||
if (mode === 'edit') {
|
||||
const success = board.setCell(position.row, position.col, result.data);
|
||||
if (success) {
|
||||
flashValid();
|
||||
} else if (!board.canEditCell(position.row, position.col)) {
|
||||
flashLocked();
|
||||
} else {
|
||||
const conflicts = board.getConflicts(position.row, position.col);
|
||||
if (conflicts.length > 0) {
|
||||
highlightConflicts(conflicts);
|
||||
}
|
||||
flashInvalid();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'erase':
|
||||
if (mode === 'edit') {
|
||||
const success = board.setCell(position.row, position.col, '.');
|
||||
if (success) {
|
||||
flashValid();
|
||||
} else if (!board.canEditCell(position.row, position.col)) {
|
||||
flashLocked();
|
||||
} else {
|
||||
flashInvalid();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
|
||||
|
||||
const cursorManager: CursorManager = {
|
||||
position: () => position,
|
||||
mode: () => mode,
|
||||
move,
|
||||
moveLeft,
|
||||
moveRight,
|
||||
moveUp,
|
||||
moveDown,
|
||||
moveToStart,
|
||||
moveToEnd,
|
||||
moveToTop,
|
||||
moveToBottom,
|
||||
repeatLastMotion,
|
||||
resetCount,
|
||||
getCount,
|
||||
hasCount,
|
||||
setCount,
|
||||
setLastKey,
|
||||
getLastKey,
|
||||
setMode,
|
||||
flashInvalid,
|
||||
flashValid,
|
||||
flashLocked,
|
||||
highlightConflicts
|
||||
};
|
||||
|
||||
const keyManager: GameKeyManager = {
|
||||
keyLog,
|
||||
clearKeyLog,
|
||||
getKeyLog,
|
||||
editMode: mode,
|
||||
setEditMode: setMode
|
||||
};
|
||||
|
||||
return {
|
||||
...cursorManager,
|
||||
keyManager
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useRef } from 'react';
|
||||
import type { VimMotionSystem, Motion } from './types';
|
||||
|
||||
export function useVimMotions(): VimMotionSystem {
|
||||
const countRef = useRef<string>('');
|
||||
const lastMotionRef = useRef<Motion | null>(null);
|
||||
|
||||
const processKey = (key: string) => {
|
||||
if (key >= '0' && key <= '9') {
|
||||
if (key === '0' && countRef.current === '') {
|
||||
return { type: 'anchor', data: 'start' };
|
||||
}
|
||||
countRef.current += key;
|
||||
return { type: 'count', data: key };
|
||||
}
|
||||
|
||||
const count = countRef.current ? parseInt(countRef.current, 10) : 1;
|
||||
|
||||
switch (key) {
|
||||
case 'h':
|
||||
const motionH = { dr: 0, dc: -1, steps: count };
|
||||
lastMotionRef.current = motionH;
|
||||
countRef.current = '';
|
||||
return { type: 'move', data: motionH };
|
||||
|
||||
case 'j':
|
||||
const motionJ = { dr: 1, dc: 0, steps: count };
|
||||
lastMotionRef.current = motionJ;
|
||||
countRef.current = '';
|
||||
return { type: 'move', data: motionJ };
|
||||
|
||||
case 'k':
|
||||
const motionK = { dr: -1, dc: 0, steps: count };
|
||||
lastMotionRef.current = motionK;
|
||||
countRef.current = '';
|
||||
return { type: 'move', data: motionK };
|
||||
|
||||
case 'l':
|
||||
const motionL = { dr: 0, dc: 1, steps: count };
|
||||
lastMotionRef.current = motionL;
|
||||
countRef.current = '';
|
||||
return { type: 'move', data: motionL };
|
||||
|
||||
case '^':
|
||||
countRef.current = '';
|
||||
return { type: 'anchor', data: 'start' };
|
||||
|
||||
case '$':
|
||||
countRef.current = '';
|
||||
return { type: 'anchor', data: 'end' };
|
||||
|
||||
case 'g':
|
||||
countRef.current = '';
|
||||
return { type: 'anchor', data: 'top' };
|
||||
|
||||
case 'G':
|
||||
countRef.current = '';
|
||||
return { type: 'anchor', data: 'bottom' };
|
||||
|
||||
case '.':
|
||||
const lastMotion = lastMotionRef.current;
|
||||
if (lastMotion) {
|
||||
return { type: 'repeat', data: lastMotion };
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'i':
|
||||
countRef.current = '';
|
||||
return { type: 'mode', data: 'edit' };
|
||||
|
||||
case 'Escape':
|
||||
countRef.current = '';
|
||||
return { type: 'mode', data: 'normal' };
|
||||
|
||||
case 'x':
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
countRef.current = '';
|
||||
return { type: 'erase', data: null };
|
||||
|
||||
default:
|
||||
if (key >= '1' && key <= '9') {
|
||||
countRef.current = '';
|
||||
return { type: 'digit', data: key };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCount = () => {
|
||||
countRef.current = '';
|
||||
};
|
||||
|
||||
const getCount = () => {
|
||||
return countRef.current ? parseInt(countRef.current, 10) : 1;
|
||||
};
|
||||
|
||||
const hasCount = () => {
|
||||
return countRef.current !== '';
|
||||
};
|
||||
|
||||
const setLastMotion = (motion: Motion) => {
|
||||
lastMotionRef.current = motion;
|
||||
};
|
||||
|
||||
const repeatLastMotion = () => {
|
||||
return lastMotionRef.current;
|
||||
};
|
||||
|
||||
return {
|
||||
processKey,
|
||||
resetCount,
|
||||
getCount,
|
||||
hasCount,
|
||||
setLastMotion,
|
||||
repeatLastMotion
|
||||
};
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useBoard } from './useBoard';
|
||||
import { useCursor } from './useCursor';
|
||||
import { useGameStatus } from './useGameStatus';
|
||||
import { useScore } from './useScore';
|
||||
import type { GameManager, GameOptions } from './types';
|
||||
|
||||
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
|
||||
const board = useBoard();
|
||||
const gameStatusManager = useGameStatus();
|
||||
const { cursor, keyManager } = useCursor(board);
|
||||
const scoreManager = useScore({
|
||||
board,
|
||||
gameStatusManager,
|
||||
keystrokeCount: keyManager.keyLog.length
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
board.generateBoard('medium');
|
||||
}, [board]);
|
||||
|
||||
useEffect(() => {
|
||||
const { gameStatus } = gameStatusManager;
|
||||
|
||||
if (gameStatus === 'waiting') {
|
||||
board.renderBoard();
|
||||
} else {
|
||||
board.renderBoard();
|
||||
const container = board.containerRef.current;
|
||||
if (container) {
|
||||
const pos = cursor.position();
|
||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${pos.row}"][data-col="${pos.col}"]`) as HTMLElement;
|
||||
if (cellEl) {
|
||||
cellEl.classList.add('cursor');
|
||||
if (cursor.mode() === 'edit') {
|
||||
cellEl.classList.add('edit-mode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [gameStatusManager.gameStatus, board, cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = board.containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleCellClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const cellEl = target.closest('.sudoku-cell') as HTMLElement;
|
||||
if (!cellEl) return;
|
||||
|
||||
const row = parseInt(cellEl.dataset.row || '0');
|
||||
const col = parseInt(cellEl.dataset.col || '0');
|
||||
|
||||
const currentPos = cursor.position();
|
||||
if (currentPos.row !== row || currentPos.col !== col) {
|
||||
cursor.move(row - currentPos.row, col - currentPos.col, 1);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener('click', handleCellClick);
|
||||
return () => container.removeEventListener('click', handleCellClick);
|
||||
}, [board, cursor]);
|
||||
|
||||
const startGame = () => {
|
||||
board.generateBoard('medium');
|
||||
gameStatusManager.startGame();
|
||||
};
|
||||
|
||||
const togglePause = (pause?: boolean) => {
|
||||
gameStatusManager.togglePause(pause);
|
||||
};
|
||||
|
||||
const quitGame = () => {
|
||||
gameStatusManager.quitGame();
|
||||
};
|
||||
|
||||
const gameManager: GameManager = {
|
||||
containerRef: board.containerRef,
|
||||
renderBoard: board.renderBoard,
|
||||
gameStatus: gameStatusManager.gameStatus,
|
||||
setGameStatus: gameStatusManager.setGameStatus,
|
||||
startGame,
|
||||
togglePause,
|
||||
quitGame,
|
||||
cursor,
|
||||
scoreManager,
|
||||
keyLog: keyManager.keyLog,
|
||||
clearKeyLog: keyManager.clearKeyLog,
|
||||
getKeyLog: keyManager.getKeyLog,
|
||||
board,
|
||||
solveBoard: board.solveBoard,
|
||||
resetBoard: board.resetBoard
|
||||
};
|
||||
|
||||
if (typeof platformHook === 'function') {
|
||||
platformHook(gameManager);
|
||||
}
|
||||
|
||||
return gameManager;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useGameStatus } from './useGameStatus';
|
||||
export type { GameStatus, GameStatusManager } from './types';
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GameStatus = 'waiting' | 'started' | 'paused' | 'game-over' | 'game-won';
|
||||
|
||||
export type GameStatusManager = {
|
||||
gameStatus: GameStatus;
|
||||
setGameStatus: (status: GameStatus) => void;
|
||||
startGame: () => void;
|
||||
togglePause: (pause?: boolean) => void;
|
||||
quitGame: () => void;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { GameStatusManager, GameStatus } from './types';
|
||||
|
||||
export function useGameStatus(): GameStatusManager {
|
||||
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
setGameStatus('started');
|
||||
}, []);
|
||||
|
||||
const togglePause = useCallback((pause?: boolean) => {
|
||||
setGameStatus(current => {
|
||||
if (current === 'started' || current === 'paused') {
|
||||
return pause !== undefined ? (pause ? 'paused' : 'started') :
|
||||
(current === 'started' ? 'paused' : 'started');
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const quitGame = useCallback(() => {
|
||||
setGameStatus('waiting');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameStatus,
|
||||
setGameStatus,
|
||||
startGame,
|
||||
togglePause,
|
||||
quitGame
|
||||
};
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './useScore';
|
||||
export { useScore } from './useScore';
|
||||
export { useTimer } from './useTimer';
|
||||
export type { ScoreManager, TimerManager, UseScoreProps } from './types';
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { BoardManager } from '../useBoard/types';
|
||||
import type { GameStatusManager } from '../useGameStatus/types';
|
||||
|
||||
export type TimerManager = {
|
||||
timeValue: number;
|
||||
startTimer: () => void;
|
||||
stopTimer: () => void;
|
||||
resetTimer: () => void;
|
||||
};
|
||||
|
||||
export type ScoreManager = {
|
||||
timeValue: number;
|
||||
keystrokes: number;
|
||||
mistakes: number;
|
||||
hints: number;
|
||||
efficiency: number;
|
||||
finalScore: number | null;
|
||||
startTimer: () => void;
|
||||
stopTimer: () => void;
|
||||
resetTimer: () => void;
|
||||
addMistake: () => void;
|
||||
addHint: () => void;
|
||||
calculateEfficiency: () => number;
|
||||
calculateFinalScore: () => number | null;
|
||||
};
|
||||
|
||||
export type UseScoreProps = {
|
||||
board: BoardManager;
|
||||
gameStatusManager: GameStatusManager;
|
||||
keystrokeCount: number;
|
||||
};
|
||||
+77
-88
@@ -1,107 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTimer } from "./hooks/useTimer";
|
||||
import { useScoreTime } from "./useScoreTime";
|
||||
import type { GameStatus } from "../types";
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { BoardManager } from '../useBoard/types';
|
||||
import type { GameStatusManager } from '../useGameStatus/types';
|
||||
import type { ScoreManager, UseScoreProps } from './types';
|
||||
import { useTimer } from './useTimer';
|
||||
|
||||
const TIME_LIMIT_MS = Infinity; // No time limit by default
|
||||
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
|
||||
|
||||
type GameScoreContext = {
|
||||
gameStatus: GameStatus;
|
||||
setGameStatus: (status: GameStatus) => void;
|
||||
};
|
||||
|
||||
type UseScoreParams = {
|
||||
gameContext: GameScoreContext;
|
||||
keystrokeCount: number;
|
||||
board: string;
|
||||
isComplete: (board: string) => boolean;
|
||||
isFullyValid: (board: string) => boolean;
|
||||
initialBoard: string;
|
||||
};
|
||||
|
||||
export function useScore({
|
||||
gameContext,
|
||||
keystrokeCount,
|
||||
board,
|
||||
isComplete,
|
||||
isFullyValid,
|
||||
initialBoard
|
||||
}: UseScoreParams) {
|
||||
export function useScore({ board, gameStatusManager, keystrokeCount }: UseScoreProps): ScoreManager {
|
||||
const timer = useTimer();
|
||||
const { gameStatus, setGameStatus } = gameContext;
|
||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||
const [mistakes, setMistakes] = useState<number>(0);
|
||||
const [hints, setHints] = useState<number>(0);
|
||||
|
||||
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||
const addMistake = useCallback(() => {
|
||||
setMistakes(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
|
||||
const addHint = useCallback(() => {
|
||||
setHints(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
const calculateEfficiency = useCallback((): number => {
|
||||
const boardString = board.boardRef.current.map(row =>
|
||||
row.map(cell => cell.value || '.').join('')
|
||||
).join('');
|
||||
|
||||
const filledCells = boardString.replace(/\./g, '').length;
|
||||
const totalCells = 81;
|
||||
const givenCells = board.initialBoardRef.current.replace(/\./g, '').length;
|
||||
const userFilledCells = filledCells - givenCells;
|
||||
|
||||
if (userFilledCells === 0) return 100;
|
||||
|
||||
const optimalMoves = userFilledCells;
|
||||
const actualMoves = keystrokeCount;
|
||||
const efficiency = minMovesRequired > 0
|
||||
? Math.round((actualMoves / minMovesRequired) * 100)
|
||||
: 0;
|
||||
|
||||
useScoreTime({ gameStatus, timer });
|
||||
const efficiency = Math.round((optimalMoves / actualMoves) * 100);
|
||||
return Math.max(0, Math.min(100, efficiency));
|
||||
}, [board, keystrokeCount]);
|
||||
|
||||
const calculateFinalScore = useCallback((): number | null => {
|
||||
const { gameStatus } = gameStatusManager;
|
||||
|
||||
if (gameStatus !== 'game-won') return null;
|
||||
|
||||
const efficiency = calculateEfficiency();
|
||||
const timeInSeconds = timer.timeValue / 1000;
|
||||
const timeBonus = Math.max(0, 3600 - timeInSeconds) * 10;
|
||||
const mistakePenalty = mistakes * 100;
|
||||
const hintPenalty = hints * 200;
|
||||
|
||||
const baseScore = 10000;
|
||||
const efficiencyBonus = efficiency * 50;
|
||||
|
||||
const finalScore = baseScore + efficiencyBonus + timeBonus - mistakePenalty - hintPenalty;
|
||||
return Math.max(0, Math.round(finalScore));
|
||||
}, [gameStatusManager, calculateEfficiency, timer.timeValue, mistakes, hints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "started") return;
|
||||
const { gameStatus } = gameStatusManager;
|
||||
|
||||
if (isComplete(board) && isFullyValid(board)) {
|
||||
setGameStatus("game-won");
|
||||
return;
|
||||
if (gameStatus === 'started') {
|
||||
timer.startTimer();
|
||||
} else if (gameStatus === 'paused' || gameStatus === 'game-over' || gameStatus === 'game-won') {
|
||||
timer.stopTimer();
|
||||
}
|
||||
|
||||
if (timeValue >= TIME_LIMIT_MS) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
if (gameStatus === 'waiting') {
|
||||
timer.resetTimer();
|
||||
setMistakes(0);
|
||||
setHints(0);
|
||||
}
|
||||
|
||||
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Enable efficiency-based game-over in future
|
||||
// if (efficiency > 200) {
|
||||
// setGameStatus("game-over");
|
||||
// return;
|
||||
// }
|
||||
}, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
|
||||
}, [gameStatusManager.gameStatus, timer]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWin = () => {
|
||||
if (gameStatus === "started") {
|
||||
setGameStatus("game-won");
|
||||
const { gameStatus, setGameStatus } = gameStatusManager;
|
||||
|
||||
if (gameStatus === 'started' && board.isComplete() && board.isFullyValid()) {
|
||||
setGameStatus('game-won');
|
||||
}
|
||||
};
|
||||
window.addEventListener("sudoku-game-won", handleWin);
|
||||
return () => window.removeEventListener("sudoku-game-won", handleWin);
|
||||
}, [gameStatus, setGameStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "game-won") return;
|
||||
|
||||
const timeInSeconds = timeValue / 1000;
|
||||
const baseScore = 100000;
|
||||
const timePenalty = Math.floor(timeInSeconds * 10);
|
||||
const extraMoves = Math.max(0, actualMoves - minMovesRequired);
|
||||
const movePenalty = extraMoves * 50;
|
||||
const score = Math.max(0, baseScore - timePenalty - movePenalty);
|
||||
|
||||
setFinalScore(score);
|
||||
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
|
||||
}, [board, gameStatusManager]);
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
finalScore,
|
||||
timeLimit: TIME_LIMIT_MS,
|
||||
keystrokeLimit: KEYSTROKE_LIMIT,
|
||||
minMovesRequired,
|
||||
actualMoves,
|
||||
efficiency,
|
||||
timeValue: timer.timeValue,
|
||||
keystrokes: keystrokeCount,
|
||||
mistakes,
|
||||
hints,
|
||||
efficiency: calculateEfficiency(),
|
||||
finalScore: calculateFinalScore(),
|
||||
startTimer: timer.startTimer,
|
||||
stopTimer: timer.stopTimer,
|
||||
resetTimer: timer.resetTimer,
|
||||
addMistake,
|
||||
addHint,
|
||||
calculateEfficiency,
|
||||
calculateFinalScore
|
||||
};
|
||||
}
|
||||
|
||||
export type UseScoreType = ReturnType<typeof useScore>;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { TimerManager } from './types';
|
||||
|
||||
export function useTimer(): TimerManager {
|
||||
const [timeValue, setTimeValue] = useState<number>(0);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
const startTimer = () => {
|
||||
if (intervalRef.current) return;
|
||||
|
||||
startTimeRef.current = Date.now() - timeValue;
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTimeValue(Date.now() - startTimeRef.current);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
stopTimer();
|
||||
setTimeValue(0);
|
||||
startTimeRef.current = 0;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/* =========================================================
|
||||
🔧 Controls (tweak these)
|
||||
========================================================= */
|
||||
:root {
|
||||
--cell-size: 96px;
|
||||
--cell-font-size: 48px;
|
||||
--grid-gap: 6px;
|
||||
--box-separator: 9px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Container & Grid Base
|
||||
========================================================= */
|
||||
#sudoku_container {
|
||||
position: relative;
|
||||
margin: 1em auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#sudoku {
|
||||
position: relative;
|
||||
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;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Grid Layout
|
||||
========================================================= */
|
||||
#sudoku .sudoku-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
||||
grid-template-rows:
|
||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
||||
column-gap: var(--grid-gap);
|
||||
row-gap: var(--grid-gap);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Cell Base Styles
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell {
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
font-family: "Fira Code", monospace;
|
||||
font-size: var(--cell-font-size);
|
||||
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);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
3x3 Box Separators
|
||||
========================================================= */
|
||||
/* Right borders (end of each 3x3 box horizontally) */
|
||||
#sudoku .sudoku-cell[data-c="2"],
|
||||
#sudoku .sudoku-cell[data-c="5"],
|
||||
#sudoku .sudoku-cell[data-c="8"] {
|
||||
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 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 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 color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
User-Entered Cells
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.user-entered {
|
||||
color: var(--primary, #82aaff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Active Cell Highlight (Navigation Mode)
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.active {
|
||||
outline: 2px solid var(--primary, #82aaff);
|
||||
outline-offset: -2px;
|
||||
background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
|
||||
box-shadow:
|
||||
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 var(--accent, #ffc777);
|
||||
outline-offset: -2px;
|
||||
background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
|
||||
box-shadow:
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Visual Feedback Effects
|
||||
========================================================= */
|
||||
/* Invalid move effect */
|
||||
#sudoku .sudoku-cell.invalid-move {
|
||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
||||
box-shadow:
|
||||
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 color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
|
||||
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Locked cell effect (pre-filled clues) */
|
||||
#sudoku .sudoku-cell.locked-cell {
|
||||
animation: sudoku-locked-pulse 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes sudoku-locked-pulse {
|
||||
0% {
|
||||
transform: scale(1.08);
|
||||
background: rgba(255, 85, 85, 0.3);
|
||||
box-shadow:
|
||||
0 0 20px rgba(255, 85, 85, 0.9),
|
||||
0 0 32px rgba(255, 85, 85, 0.6),
|
||||
inset 0 0 16px rgba(255, 85, 85, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
background: rgba(255, 85, 85, 0.2);
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 85, 85, 0.8),
|
||||
0 0 28px rgba(255, 85, 85, 0.5),
|
||||
inset 0 0 12px rgba(255, 85, 85, 0.3);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Trail effect for moves */
|
||||
#sudoku .sudoku-cell.trail {
|
||||
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: 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from "./useGame";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './sudoku';
|
||||
export * from './validate-board';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Create a playable Sudoku puzzle by removing random digits
|
||||
* from a fully solved board string.
|
||||
*
|
||||
* Difficulty only determines how many cells are removed.
|
||||
* - "easy": removes ~40 cells
|
||||
* - "medium": removes ~50 cells
|
||||
* - "hard": removes ~60 cells
|
||||
*
|
||||
* The returned string uses '.' for blanks.
|
||||
*/
|
||||
export function makePuzzle(
|
||||
solution: string,
|
||||
difficulty: "easy" | "medium" | "hard" = "medium"
|
||||
): string {
|
||||
if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
|
||||
throw new Error("Invalid solved board: must be 81 digits 1–9");
|
||||
}
|
||||
|
||||
// --- removal count based on difficulty ---
|
||||
const removeCount = {
|
||||
easy: 40,
|
||||
medium: 50,
|
||||
hard: 60,
|
||||
}[difficulty];
|
||||
|
||||
// --- create a mutable copy ---
|
||||
const board = solution.split("");
|
||||
|
||||
// --- build a shuffled list of indices 0–80 ---
|
||||
const indices = Array.from({ length: 81 }, (_, i) => i);
|
||||
for (let i = indices.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
||||
}
|
||||
|
||||
// --- remove the desired number of cells ---
|
||||
for (let i = 0; i < removeCount; i++) {
|
||||
const idx = indices[i];
|
||||
board[idx] = ".";
|
||||
}
|
||||
|
||||
return board.join("");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { generateSolvedBoard } from "./sudoku";
|
||||
import { assertValidFullBoard } from "./validate-board";
|
||||
|
||||
|
||||
const valids = [
|
||||
"534678912672195348198342567859761423426853791713924856961537284287419635345286179",
|
||||
"456789123123456789789123456567891234234567891891234567678912345345678912912345678",
|
||||
"234567891567891234891234567345678912678912345912345678456789123789123456123456789",
|
||||
"987654321654321987321987654876543219543219876219876543765432198432198765198765432"
|
||||
];
|
||||
|
||||
const invalids = [
|
||||
"534678912672195348198342567859761423426853791713924856961537284287419635345286171",
|
||||
"534678911672195348198342567859761423426853791713924856961537284287419635345286179",
|
||||
"534678912572195348198342567859761423426853791713924856961537284287419635345286179",
|
||||
"534678912672195348198342567859761423426863791713924856961537284287419635345286179",
|
||||
];
|
||||
|
||||
describe("Testing the Asserter with static boards", () => {
|
||||
test("valid boards", () => {
|
||||
for (let board of valids) {
|
||||
expect(() => assertValidFullBoard(board)).not.toThrow();
|
||||
}
|
||||
})
|
||||
|
||||
test("invalid boards", () => {
|
||||
for (let board of invalids) {
|
||||
expect(() => assertValidFullBoard(board)).toThrow()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
describe("Testing the generateSolvedBoard", () => {
|
||||
test("generates 10 valid boards", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const generated = generateSolvedBoard();
|
||||
assertValidFullBoard(generated);
|
||||
};
|
||||
});
|
||||
test("generates 100 valid boards", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const generated = generateSolvedBoard();
|
||||
assertValidFullBoard(generated);
|
||||
};
|
||||
});
|
||||
|
||||
test("generates 1000 valid boards", () => {
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const generated = generateSolvedBoard();
|
||||
assertValidFullBoard(generated);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Generate a fully valid Sudoku solution using a base pattern and structural shuffles.
|
||||
* Returns a string of 81 digits (row-major order).
|
||||
*/
|
||||
export function generateSolvedBoard(): string {
|
||||
const size = 9;
|
||||
const box = 3;
|
||||
|
||||
// --- 1️⃣ Base pattern function ---
|
||||
const pattern = (r: number, c: number) =>
|
||||
(box * (r % box) + Math.floor(r / box) + c) % size;
|
||||
|
||||
// --- 2️⃣ Create base board ---
|
||||
const baseBoard: number[][] = Array.from({ length: size }, (_, r) =>
|
||||
Array.from({ length: size }, (_, c) => pattern(r, c) + 1)
|
||||
);
|
||||
|
||||
// --- 3️⃣ Helper to shuffle an array in place ---
|
||||
const shuffle = <T>(arr: T[]): T[] => {
|
||||
const copy = [...arr];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
};
|
||||
|
||||
// --- 4️⃣ Build shuffled row/col indices preserving 3×3 structure ---
|
||||
const rows = shuffle([0, 1, 2])
|
||||
.concat(shuffle([3, 4, 5]))
|
||||
.concat(shuffle([6, 7, 8]));
|
||||
const cols = shuffle([0, 1, 2])
|
||||
.concat(shuffle([3, 4, 5]))
|
||||
.concat(shuffle([6, 7, 8]));
|
||||
|
||||
// --- 5️⃣ Shuffle entire bands and stacks (groups of 3) ---
|
||||
const rowBands = shuffle([0, 1, 2]);
|
||||
const colStacks = shuffle([0, 1, 2]);
|
||||
|
||||
const shuffledRows = rowBands.flatMap(band => rows.slice(band * 3, band * 3 + 3));
|
||||
const shuffledCols = colStacks.flatMap(stack => cols.slice(stack * 3, stack * 3 + 3));
|
||||
|
||||
// --- 6️⃣ Digit permutation (1–9) ---
|
||||
const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
|
||||
// --- 7️⃣ Build the final shuffled board ---
|
||||
const board: number[][] = shuffledRows.map(r =>
|
||||
shuffledCols.map(c => digits[baseBoard[r][c] - 1])
|
||||
);
|
||||
|
||||
// --- 8️⃣ Flatten to 81-char string ---
|
||||
return board.flat().join('');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Validate a fully filled Sudoku board (81-char string)
|
||||
* Returns true if:
|
||||
* - 81 characters long
|
||||
* - all characters are digits 1–9
|
||||
* - all rows, columns, and 3×3 boxes sum to 45
|
||||
*/
|
||||
export function validateFullBoard(board: string): boolean {
|
||||
// --- Basic sanity ---
|
||||
if (board.length !== 81) return false;
|
||||
if (!/^[1-9]+$/.test(board)) return false;
|
||||
|
||||
const getCell = (r: number, c: number) => Number(board[r * 9 + c]);
|
||||
|
||||
// --- Validate rows ---
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const sum = Array.from({ length: 9 }, (_, c) => getCell(r, c))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
if (sum !== 45) return false;
|
||||
}
|
||||
|
||||
// --- Validate columns ---
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const sum = Array.from({ length: 9 }, (_, r) => getCell(r, c))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
if (sum !== 45) return false;
|
||||
}
|
||||
|
||||
// --- Validate 3×3 boxes ---
|
||||
for (let br = 0; br < 3; br++) {
|
||||
for (let bc = 0; bc < 3; bc++) {
|
||||
const sum = Array.from({ length: 9 }, (_, i) => {
|
||||
const r = br * 3 + Math.floor(i / 3);
|
||||
const c = bc * 3 + (i % 3);
|
||||
return getCell(r, c);
|
||||
}).reduce((a, b) => a + b, 0);
|
||||
if (sum !== 45) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* 🔍 Assert that a Sudoku board string represents a valid, fully filled grid.
|
||||
*
|
||||
* Format:
|
||||
* - 81 characters
|
||||
* - Only digits 1–9
|
||||
* - Each row, column, and 3×3 box contains unique digits that sum to 45
|
||||
*
|
||||
* Throws descriptive errors on failure.
|
||||
*/
|
||||
|
||||
export function assertValidFullBoard(board: string): void {
|
||||
// --- Basic integrity checks ---
|
||||
console.assert(board.length === 81, `❌ Board length must be 81, got ${board.length}`);
|
||||
if (board.length !== 81) throw new Error(`Invalid board length: ${board.length}`);
|
||||
|
||||
// All characters must be digits 1–9
|
||||
console.assert(/^[1-9]+$/.test(board), "❌ Board must contain only digits 1–9 (no 0s or dots)");
|
||||
if (!/^[1-9]+$/.test(board)) throw new Error("Board contains invalid characters");
|
||||
|
||||
const get = (r: number, c: number) => Number(board[r * 9 + c]);
|
||||
|
||||
// --- Helper to assert uniqueness and sum ---
|
||||
const assertGroup = (label: string, cells: number[]) => {
|
||||
const sum = cells.reduce((a, b) => a + b, 0);
|
||||
const unique = new Set(cells);
|
||||
console.assert(sum === 45, `❌ ${label} sum=${sum} (should be 45)`);
|
||||
console.assert(unique.size === 9, `❌ ${label} has duplicates: [${cells.join(",")}]`);
|
||||
|
||||
if (sum !== 45) throw new Error(`${label} does not sum to 45`);
|
||||
if (unique.size !== 9) throw new Error(`${label} contains duplicates`);
|
||||
};
|
||||
|
||||
// --- Validate rows ---
|
||||
for (let r = 0; r < 9; r++) {
|
||||
const row = Array.from({ length: 9 }, (_, c) => get(r, c));
|
||||
assertGroup(`Row ${r + 1}`, row);
|
||||
}
|
||||
|
||||
// --- Validate columns ---
|
||||
for (let c = 0; c < 9; c++) {
|
||||
const col = Array.from({ length: 9 }, (_, r) => get(r, c));
|
||||
assertGroup(`Column ${c + 1}`, col);
|
||||
}
|
||||
|
||||
// --- Validate 3×3 boxes ---
|
||||
for (let br = 0; br < 3; br++) {
|
||||
for (let bc = 0; bc < 3; bc++) {
|
||||
const box: number[] = [];
|
||||
for (let r = 0; r < 3; r++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
box.push(get(br * 3 + r, bc * 3 + c));
|
||||
}
|
||||
}
|
||||
const idx = br * 3 + bc + 1;
|
||||
assertGroup(`Box ${idx}`, box);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useGame } from "./useGame";
|
||||
import { useScore } from "./useScore";
|
||||
|
||||
export type UseGameType = ReturnType<typeof useGame>;
|
||||
export type UseScoreType = ReturnType<typeof useScore>;
|
||||
|
||||
export type {
|
||||
Difficulty,
|
||||
DifficultyName,
|
||||
} from "./lib/Sudoku";
|
||||
|
||||
export type GamePhase = "idle" | "playing";
|
||||
export type PlayStatus = "started" | "game-over" | "game-won";
|
||||
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
|
||||
|
||||
export const isPlaying = (status: GameStatus): status is PlayStatus =>
|
||||
status === "started" || status === "game-over" || status === "game-won";
|
||||
|
||||
export const getGamePhase = (status: GameStatus): GamePhase =>
|
||||
status === "waiting" ? "idle" : "playing";
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useScore';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTimer } from "./hooks/useTimer";
|
||||
import { useScoreTime } from "./useScoreTime";
|
||||
import type { GameStatus } from "../types";
|
||||
|
||||
const TIME_LIMIT_MS = Infinity; // No time limit by default
|
||||
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
|
||||
|
||||
type GameScoreContext = {
|
||||
gameStatus: GameStatus;
|
||||
setGameStatus: (status: GameStatus) => void;
|
||||
};
|
||||
|
||||
type UseScoreParams = {
|
||||
gameContext: GameScoreContext;
|
||||
keystrokeCount: number;
|
||||
board: string;
|
||||
isComplete: (board: string) => boolean;
|
||||
isFullyValid: (board: string) => boolean;
|
||||
initialBoard: string;
|
||||
};
|
||||
|
||||
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 });
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus !== "started") return;
|
||||
|
||||
if (isComplete(board) && isFullyValid(board)) {
|
||||
setGameStatus("game-won");
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeValue >= TIME_LIMIT_MS) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
|
||||
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
||||
setGameStatus("game-over");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
const timeInSeconds = timeValue / 1000;
|
||||
const baseScore = 100000;
|
||||
const timePenalty = Math.floor(timeInSeconds * 10);
|
||||
const extraMoves = Math.max(0, actualMoves - minMovesRequired);
|
||||
const movePenalty = extraMoves * 50;
|
||||
const score = Math.max(0, baseScore - timePenalty - movePenalty);
|
||||
|
||||
setFinalScore(score);
|
||||
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
|
||||
|
||||
return {
|
||||
timeValue,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
resetTimer,
|
||||
finalScore,
|
||||
timeLimit: TIME_LIMIT_MS,
|
||||
keystrokeLimit: KEYSTROKE_LIMIT,
|
||||
minMovesRequired,
|
||||
actualMoves,
|
||||
efficiency,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseScoreType = ReturnType<typeof useScore>;
|
||||
Reference in New Issue
Block a user