diff --git a/README.md b/README.md index 802b8cd..270d69b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index 935fccc..a7cbb10 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/example/src/App.tsx b/example/src/App.tsx index 22acd24..b02fc63 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -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 (

VIMazing Sudoku

- - {/* Scoreboard */} -
-
- Time: {formatTime(timeValue)} -
-
- Keystrokes: {keyLog.length} -
- {gameStatus === "game-won" && finalScore !== null && ( -
- Score: {finalScore.toLocaleString()} -
- )} -
-
- {mounted && } -
- -
- -
); diff --git a/example/src/GameOverlay.tsx b/example/src/GameOverlay.tsx deleted file mode 100644 index e0d6340..0000000 --- a/example/src/GameOverlay.tsx +++ /dev/null @@ -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 - - if (gameStatus === "started") return null; - - return ( - - {gameStatus === "waiting" && } - {gameStatus === "game-over" && } - {gameStatus === "game-won" && } - - ); -}; - -export const GameWaitingContent = () => { - return ( -
-
-

Press New Puzzle to start

-
-

Navigation Mode (default):

-

h,j,k,l to move around

-

R or C to enter edit mode

- -

Edit Mode:

-

Type 1-9 to fill cell

-

Esc or Enter to exit edit mode

- -

- Try vim motions: $, ^, 0, counted moves -

-
-
-
- ); -}; - -export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => { - const finalScore = scoreManager.finalScore ?? 0; - - return ( -
-

πŸŽ‰ Puzzle Solved!

-

Final Score:

-

{finalScore.toLocaleString()}

-

- max - ( - 0 - , - 100000 - - - timeInSeconds - * - 10 - ) -

-

Click New Puzzle to play again

-
- ); -}; - -export const GameOverContent = () => { - return ( -
-

Game Over

-

Press i to play again

-
- ); -}; - -export const GameOverlayContainer = ({ - children, -}: { - children: React.ReactNode; -}) => { - return ( -
- {children} -
- ); -}; - - - diff --git a/example/src/MobileGameOverlay.tsx b/example/src/MobileGameOverlay.tsx deleted file mode 100644 index 10f6632..0000000 --- a/example/src/MobileGameOverlay.tsx +++ /dev/null @@ -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 ( - - {gameStatus === "waiting" && } - {gameStatus === "game-over" && } - {gameStatus === "game-won" && } - - ); -}; - -export const GameWaitingContent = () => { - return ( -
-
-

Tap New Puzzle to start

-

VIM motions available
via on-screen keyboard

-
-
- ); -}; - -export const GameWonContent = ({ scoreManager }: { scoreManager: UseScoreType }) => { - const finalScore = scoreManager.finalScore ?? 0; - - return ( -
-

πŸŽ‰ Solved!

-

Final Score:

-

{finalScore.toLocaleString()}

-

- max - ( - 0 - , - 100000 - - - time - * - 10 - ) -

-

Tap to play again

-
- ); -}; - -export const GameOverContent = () => { - return ( -
- Game Over -
- ); -}; - -export const GameOverlayContainer = ({ - children, -}: { - children: React.ReactNode; -}) => { - return ( -
- {children} -
- ); -}; - - diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts index 9290af4..63f5690 100644 --- a/example/src/useKeyBindings.ts +++ b/example/src/useKeyBindings.ts @@ -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]); }; diff --git a/example/tsconfig.json b/example/tsconfig.json index 8538eed..0f68697 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -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"], diff --git a/example/vite.config.ts b/example/vite.config.ts index f2794ab..03bb162 100644 --- a/example/vite.config.ts +++ b/example/vite.config.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: { diff --git a/package-lock.json b/package-lock.json index 763f377..cc2fadc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index f0193fb..8a67b42 100644 --- a/package.json +++ b/package.json @@ -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 ", "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" diff --git a/src/sudoku.css b/src/game.css similarity index 96% rename from src/sudoku.css rename to src/game.css index 7aca8d4..09454f5 100644 --- a/src/sudoku.css +++ b/src/game.css @@ -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; } /* ========================================================= diff --git a/src/index.ts b/src/index.ts index 630a69f..5eb33b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index d09f67c..91f9be7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,35 @@ -import { useGame } from "./useGame"; -import { useScore } from "./useScore"; +import type { RefObject } from 'react'; -export type UseGameType = ReturnType; -export type UseScoreType = ReturnType; +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; + 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; + }; +}; \ No newline at end of file diff --git a/src/useBoard/SudokuGenerator.ts b/src/useBoard/SudokuGenerator.ts new file mode 100644 index 0000000..585150a --- /dev/null +++ b/src/useBoard/SudokuGenerator.ts @@ -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 = {}; +let SQUARE_PEERS_MAP: Record = {}; + +const MIN_GIVENS = 17; +const NR_SQUARES = 81; + +const DIFFICULTY: Record = { + 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(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(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 = {}; + 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) { + const map: Record = {}; + 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 = {}; + 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 | false { + const report = validateBoard(board); + if (report !== true) throw report; + + const map: Record = {}; + 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, 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, 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, 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(); \ No newline at end of file diff --git a/src/useBoard/SudokuRenderer.ts b/src/useBoard/SudokuRenderer.ts new file mode 100644 index 0000000..e99b483 --- /dev/null +++ b/src/useBoard/SudokuRenderer.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/useBoard/index.ts b/src/useBoard/index.ts new file mode 100644 index 0000000..89a59d7 --- /dev/null +++ b/src/useBoard/index.ts @@ -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'; \ No newline at end of file diff --git a/src/useBoard/types.ts b/src/useBoard/types.ts new file mode 100644 index 0000000..b6d369a --- /dev/null +++ b/src/useBoard/types.ts @@ -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; + boardRef: RefObject; + initialBoardRef: RefObject; + solutionRef: RefObject; + 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; +}; \ No newline at end of file diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts new file mode 100644 index 0000000..4f8006c --- /dev/null +++ b/src/useBoard/useBoard.ts @@ -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(null); + const boardRef = useRef([]); + const initialBoardRef = useRef(''); + const solutionRef = useRef(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 + }; +} \ No newline at end of file diff --git a/src/useCursor/index.ts b/src/useCursor/index.ts new file mode 100644 index 0000000..2a6a4c6 --- /dev/null +++ b/src/useCursor/index.ts @@ -0,0 +1,12 @@ +export { useCursor } from './useCursor'; +export { useVimMotions } from './useVimMotions'; +export type { + CursorManager, + CursorMode, + Coord, + Motion, + VimMotionSystem, + GameKeyManager, + KeyLogEntry, + CursorRenderer +} from './types'; \ No newline at end of file diff --git a/src/useCursor/types.ts b/src/useCursor/types.ts new file mode 100644 index 0000000..b4d3fcd --- /dev/null +++ b/src/useCursor/types.ts @@ -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; +}; \ No newline at end of file diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts new file mode 100644 index 0000000..451a72d --- /dev/null +++ b/src/useCursor/useCursor.ts @@ -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({ row: 0, col: 0 }); + const [mode, setMode] = useState('normal'); + const [keyLog, setKeyLog] = useState([]); + + 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 + }; +} \ No newline at end of file diff --git a/src/useCursor/useVimMotions.ts b/src/useCursor/useVimMotions.ts new file mode 100644 index 0000000..a79e26a --- /dev/null +++ b/src/useCursor/useVimMotions.ts @@ -0,0 +1,118 @@ +import { useRef } from 'react'; +import type { VimMotionSystem, Motion } from './types'; + +export function useVimMotions(): VimMotionSystem { + const countRef = useRef(''); + const lastMotionRef = useRef(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 + }; +} \ No newline at end of file diff --git a/src/useGame.ts b/src/useGame.ts new file mode 100644 index 0000000..a3a1709 --- /dev/null +++ b/src/useGame.ts @@ -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; +} \ No newline at end of file diff --git a/src/useGameStatus/index.ts b/src/useGameStatus/index.ts new file mode 100644 index 0000000..c72f736 --- /dev/null +++ b/src/useGameStatus/index.ts @@ -0,0 +1,2 @@ +export { useGameStatus } from './useGameStatus'; +export type { GameStatus, GameStatusManager } from './types'; \ No newline at end of file diff --git a/src/useGameStatus/types.ts b/src/useGameStatus/types.ts new file mode 100644 index 0000000..d764da1 --- /dev/null +++ b/src/useGameStatus/types.ts @@ -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; +}; \ No newline at end of file diff --git a/src/useGameStatus/useGameStatus.ts b/src/useGameStatus/useGameStatus.ts new file mode 100644 index 0000000..56ecde4 --- /dev/null +++ b/src/useGameStatus/useGameStatus.ts @@ -0,0 +1,32 @@ +import { useState, useCallback } from 'react'; +import type { GameStatusManager, GameStatus } from './types'; + +export function useGameStatus(): GameStatusManager { + const [gameStatus, setGameStatus] = useState('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 + }; +} \ No newline at end of file diff --git a/src/useScore/index.ts b/src/useScore/index.ts index 525ce75..1cd87d9 100644 --- a/src/useScore/index.ts +++ b/src/useScore/index.ts @@ -1 +1,3 @@ -export * from './useScore'; +export { useScore } from './useScore'; +export { useTimer } from './useTimer'; +export type { ScoreManager, TimerManager, UseScoreProps } from './types'; \ No newline at end of file diff --git a/src/useScore/types.ts b/src/useScore/types.ts new file mode 100644 index 0000000..128174b --- /dev/null +++ b/src/useScore/types.ts @@ -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; +}; \ No newline at end of file diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts index a85eae0..eb4179e 100644 --- a/src/useScore/useScore.ts +++ b/src/useScore/useScore.ts @@ -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(0); + const [hints, setHints] = useState(0); - const [finalScore, setFinalScore] = useState(null); + const addMistake = useCallback(() => { + setMistakes(prev => prev + 1); + }, []); - const minMovesRequired = initialBoard.split("").filter(c => c === ".").length; - const actualMoves = keystrokeCount; - const efficiency = minMovesRequired > 0 - ? Math.round((actualMoves / minMovesRequired) * 100) - : 0; + const addHint = useCallback(() => { + setHints(prev => prev + 1); + }, []); - useScoreTime({ gameStatus, timer }); + 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 = 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"); - } - }; - window.addEventListener("sudoku-game-won", handleWin); - return () => window.removeEventListener("sudoku-game-won", handleWin); - }, [gameStatus, setGameStatus]); + const { gameStatus, setGameStatus } = gameStatusManager; - 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]); + if (gameStatus === 'started' && board.isComplete() && board.isFullyValid()) { + setGameStatus('game-won'); + } + }, [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; +} \ No newline at end of file diff --git a/src/useScore/useTimer.ts b/src/useScore/useTimer.ts new file mode 100644 index 0000000..0412bbf --- /dev/null +++ b/src/useScore/useTimer.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useRef } from 'react'; +import type { TimerManager } from './types'; + +export function useTimer(): TimerManager { + const [timeValue, setTimeValue] = useState(0); + const intervalRef = useRef(null); + const startTimeRef = useRef(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 + }; +} \ No newline at end of file diff --git a/src_old/game.css b/src_old/game.css new file mode 100644 index 0000000..09454f5 --- /dev/null +++ b/src_old/game.css @@ -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); + } +} diff --git a/src_old/index.ts b/src_old/index.ts new file mode 100644 index 0000000..891c6f4 --- /dev/null +++ b/src_old/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from "./useGame"; diff --git a/src/lib/Sudoku.ts b/src_old/lib/Sudoku.ts similarity index 100% rename from src/lib/Sudoku.ts rename to src_old/lib/Sudoku.ts diff --git a/src_old/sudoku/index.ts b/src_old/sudoku/index.ts new file mode 100644 index 0000000..947c3b1 --- /dev/null +++ b/src_old/sudoku/index.ts @@ -0,0 +1,2 @@ +export * from './sudoku'; +export * from './validate-board'; diff --git a/src_old/sudoku/make-puzzle.ts b/src_old/sudoku/make-puzzle.ts new file mode 100644 index 0000000..63a4248 --- /dev/null +++ b/src_old/sudoku/make-puzzle.ts @@ -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(""); +} + diff --git a/src_old/sudoku/sudoku.test.ts b/src_old/sudoku/sudoku.test.ts new file mode 100644 index 0000000..4c86286 --- /dev/null +++ b/src_old/sudoku/sudoku.test.ts @@ -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); + }; + }); +}); + + + diff --git a/src_old/sudoku/sudoku.ts b/src_old/sudoku/sudoku.ts new file mode 100644 index 0000000..cf776b3 --- /dev/null +++ b/src_old/sudoku/sudoku.ts @@ -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 = (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(''); +} + diff --git a/src_old/sudoku/validate-board.ts b/src_old/sudoku/validate-board.ts new file mode 100644 index 0000000..3d638ce --- /dev/null +++ b/src_old/sudoku/validate-board.ts @@ -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); + } + } +} + + + diff --git a/src_old/types.ts b/src_old/types.ts new file mode 100644 index 0000000..d09f67c --- /dev/null +++ b/src_old/types.ts @@ -0,0 +1,20 @@ +import { useGame } from "./useGame"; +import { useScore } from "./useScore"; + +export type UseGameType = ReturnType; +export type UseScoreType = ReturnType; + +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"; diff --git a/src/useGame/index.ts b/src_old/useGame/index.ts similarity index 100% rename from src/useGame/index.ts rename to src_old/useGame/index.ts diff --git a/src/useGame/useBoardState.ts b/src_old/useGame/useBoardState.ts similarity index 100% rename from src/useGame/useBoardState.ts rename to src_old/useGame/useBoardState.ts diff --git a/src/useGame/useCursor.ts b/src_old/useGame/useCursor.ts similarity index 100% rename from src/useGame/useCursor.ts rename to src_old/useGame/useCursor.ts diff --git a/src/useGame/useGame.ts b/src_old/useGame/useGame.ts similarity index 100% rename from src/useGame/useGame.ts rename to src_old/useGame/useGame.ts diff --git a/src/useGame/useGameStatus.ts b/src_old/useGame/useGameStatus.ts similarity index 100% rename from src/useGame/useGameStatus.ts rename to src_old/useGame/useGameStatus.ts diff --git a/src/useGame/useKeyBindings.ts b/src_old/useGame/useKeyBindings.ts similarity index 100% rename from src/useGame/useKeyBindings.ts rename to src_old/useGame/useKeyBindings.ts diff --git a/src/useGame/useSudokuRenderer.ts b/src_old/useGame/useSudokuRenderer.ts similarity index 100% rename from src/useGame/useSudokuRenderer.ts rename to src_old/useGame/useSudokuRenderer.ts diff --git a/src/useScore/hooks/useTimer.ts b/src_old/useScore/hooks/useTimer.ts similarity index 100% rename from src/useScore/hooks/useTimer.ts rename to src_old/useScore/hooks/useTimer.ts diff --git a/src_old/useScore/index.ts b/src_old/useScore/index.ts new file mode 100644 index 0000000..525ce75 --- /dev/null +++ b/src_old/useScore/index.ts @@ -0,0 +1 @@ +export * from './useScore'; diff --git a/src_old/useScore/useScore.ts b/src_old/useScore/useScore.ts new file mode 100644 index 0000000..a85eae0 --- /dev/null +++ b/src_old/useScore/useScore.ts @@ -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(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; diff --git a/src/useScore/useScoreTime.ts b/src_old/useScore/useScoreTime.ts similarity index 100% rename from src/useScore/useScoreTime.ts rename to src_old/useScore/useScoreTime.ts