diff --git a/README.md b/README.md index 9528d7a..dd055f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @vimazing/vim-sudoku -Lightweight, typed **React hooks** and utilities for building interactive sudoku games with VIM-inspired controls. +Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**. Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project. @@ -10,8 +10,9 @@ Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project. - [Features](#features) - [Installation](#installation) - [Quick Start](#quick-start) +- [VIM Modal Editing](#vim-modal-editing) - [Core Hooks](#core-hooks) -- [Utilities](#utilities) +- [Game Mechanics](#game-mechanics) - [Example App](#example-app) - [Build & Release](#build--release) - [License](#license) @@ -19,11 +20,15 @@ Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project. --- ## Features -- **Drop-in hooks** for sudoku game logic, player control, scoring, and key logging. -- **Typed API** with generated declaration files for editor IntelliSense. -- **VIM-style navigation** (`h`, `j`, `k`, `l`, counts, `i` to start, etc.). -- **Composable architecture** – bring your own rendering and platform-specific bindings. -- **Tree-shakeable exports** to keep bundles lean. +- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells in insert mode (R/C to enter) +- ⌨️ **Full VIM motions** – `h`/`j`/`k`/`l`, counted moves, `^`/`$`/`0` for row navigation +- 🎯 **Smart cell locking** – Pre-filled clues are protected with visual feedback +- ⏱️ **Time & keystroke limits** – Configurable game-over conditions +- 📊 **Built-in scoring** – Time-based scoring with keystroke tracking +- 🎨 **Tokyo Night theme** – Beautiful dark theme with 3x3 box separators +- 📦 **Typed API** – Full TypeScript support with generated declarations +- 🪝 **Composable hooks** – Separate concerns for game state, rendering, input, and scoring +- 🌲 **Tree-shakeable exports** – Import only what you need --- @@ -46,65 +51,187 @@ bun add @vimazing/vim-sudoku ## Quick Start ```tsx -import { useEffect } from "react"; import { useGame } from "@vimazing/vim-sudoku"; +import "@vimazing/vim-sudoku/sudoku.css"; export function SudokuGame() { - const { containerRef, gameStatus, startGame } = useGame(); - - useEffect(() => { - startGame(); - }, [startGame]); + const { containerRef, initGame, gameStatus, scoreManager } = useGame(); return ( -
-

VIMazing Sudoku

-
- {gameStatus === "game-won" &&

🎉 You solved it!

} -
+
+

VIMazing Sudoku

+ + +
+
+
+ + {gameStatus === "game-won" && ( +

🎉 Score: {scoreManager.finalScore?.toLocaleString()}

+ )} +
); } ``` -Default controls: +--- -| Key | Action | -| ------------------- | --------------- | -| `i` | Start game | -| `h` / `j` / `k` / `l` | Move cursor | -| `` | Enter number | -| `x` / `d` | Clear cell | -| `q` | Quit game | -| `Esc` | Cancel / pause | +## VIM Modal Editing + +VIMazing Sudoku follows VIM's modal editing philosophy with strict insert/replace semantics: + +### Navigation Mode (default) + +#### Movement + +| Key | Action | Notes | +| --- | ------ | ----- | +| `h` / `j` / `k` / `l` | Move cursor left/down/up/right | Core VIM navigation | +| `h/j/k/l` | Move with count | e.g., `3j` = down 3, `5l` = right 5 | +| `0` / `^` | Jump to start of row | Move to column 0 | +| `$` | Jump to end of row | Move to column 8 | +| `.` | Repeat last motion | Repeat with optional new count | + +#### Editing + +| Key | Action | Valid On | +| --- | ------ | -------- | +| `I` | Enter insert mode | ✅ Empty cells only | +| `R` | Enter replace mode | ✅ User-filled cells only | +| `D` / `X` | Delete cell value | ✅ User-filled cells only | + +#### Game Control + +| Key | Action | +| --- | ------ | +| `Q` | Quit game | + +### Edit Mode (I/R to enter) + +| Key | Action | +| --- | ------ | +| `1-9` | Fill cell with number | +| `x` / `Backspace` | Clear cell | +| `Esc` / `Enter` | Exit edit mode back to navigation | + +--- + +## Cell State Rules + +Understanding cell states is crucial for VIM-style editing: + +| Cell State | `I` (Insert) | `R` (Replace) | `D`/`X` (Delete) | Visual | +| ---------- | ------------ | ------------- | ---------------- | ------ | +| **Empty** (`.`) | ✅ Enter edit | 🔴 Flash (use I) | ➖ No effect | Normal | +| **User-filled** | 🔴 Flash (use R) | ✅ Enter edit | ✅ Delete | Normal | +| **Pre-filled clue** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | Darker text | + +### Visual Feedback + +- 🔵 **Blue glow** – Navigation mode (current cell) +- 🟡 **Yellow pulsing glow** – Edit mode (typing active) +- 🔴 **Red flash** – Invalid action (wrong mode, locked cell, or out of bounds) + +### Examples + +``` +Empty cell: Press I → type 5 → Esc +Replace value: Press R → type 7 → Esc +Delete value: Press D (instant) +Move 3 down: Press 3j +Jump to end: Press $ +``` --- ## Core Hooks -| Hook | Description | -| ---- | ----------- | -| `useGame(platformHook?)` | One-stop hook that wires sudoku generation, rendering, player state, scoring, and keyboard bindings. Returns refs, status helpers, and managers you can compose with your UI. | -| `useKeyBindings()` | Provides VIM-style key bindings, including repeat counts and movement helpers. | -| `useScore()` | Tracks timers, moves, and final score calculation. | +### `useGame(platformHook?)` -Each hook is exported individually, so you can cherry-pick only what you need: +Main hook that orchestrates the entire sudoku game. Returns: + +```ts +{ + // Core + containerRef: RefObject, + gameStatus: GameStatus, // "waiting" | "started" | "game-won" | "game-over" + startGame: () => void, + stopGame: () => void, + initGame: () => void, // Reset board + start + + // Cursor + cursor: { r: number; c: number }, + moveCursor: (dr, dc, steps?) => void, + + // Board + board: string, // 81-character string + initialBoard: string, // Original clues + setDigit: (digit) => void, + erase: () => void, + reset: () => void, + solveBoard: () => void, + boardToString: (human?) => string, // Format board for display + + // Scoring + scoreManager: { + timeValue: number, // Time in milliseconds + finalScore: number | null, + timeLimit: number, // 60000ms (60 seconds) + keystrokeLimit: number, // 100 keystrokes + }, + + // Key logging + keyLog: KeyLogEntry[], + editMode: "navigation" | "edit", +} +``` + +### Individual Hooks + +Export individual hooks for granular control: ```ts import { useScore } from "@vimazing/vim-sudoku/useScore"; +import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState"; ``` --- -## Utilities +## Game Mechanics -Besides the hooks, the library exports: +### Scoring -- `SudokuGenerator` – procedural sudoku puzzle generation class. -- `SudokuRenderer` – canvas renderer tailored for sudoku grids. -- `types` – shared TypeScript types such as `GameStatus`, `SudokuCell`, and `KeyLogEntry`. +Time-based scoring formula: +``` +score = max(0, 100000 - timeInSeconds × 10) +``` + +### Game Over Conditions + +- ⏱️ **Time limit**: 60 seconds +- ⌨️ **Keystroke limit**: 100 keystrokes + +Customize in `src/useScore/useScore.ts`: +```ts +const TIME_LIMIT_MS = 60000; +const KEYSTROKE_LIMIT = 100; +``` + +### Win Detection + +Currently manual (solving via UI or `solveBoard()`). Automatic win detection coming soon! + +### Board Format ```ts -import { SudokuGenerator, GameStatus } from "@vimazing/vim-sudoku"; +// Computer format (compact) +boardToString() // "628.1...535928671..." + +// Human format (readable) +boardToString(true) +// 6 2 8 | 1 . . | . 5 . +// 5 3 9 | 2 4 8 | 6 7 1 +// ... ``` --- diff --git a/example/src/useKeyBindings.ts b/example/src/useKeyBindings.ts index 4ec3a28..9290af4 100644 --- a/example/src/useKeyBindings.ts +++ b/example/src/useKeyBindings.ts @@ -1,21 +1,22 @@ import { useEffect } from "react"; -import type { UseGameType } from "../../src/useGame/useGame"; +import type { UseGameType } from "../../src/"; export const useKeyBindings = (gameManager: UseGameType) => { const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager; useEffect(() => { const handler = (e: KeyboardEvent) => { + console.log(e.key); // start game - if (e.key === "i" || e.key === "I") { + if (e.code === "Space") { if (["waiting", "game-over", "game-won"].includes(gameStatus)) { clearLog(); resetCount(); if (gameStatus === "game-over" || gameStatus === "game-won") { stopGame(); - setTimeout(() => initGame(true), 0); + setTimeout(() => initGame(), 0); } else { - initGame(true); + initGame(); } return; } diff --git a/example/src/GameOverlay.tsx b/src/GameOverlay.tsx similarity index 100% rename from example/src/GameOverlay.tsx rename to src/GameOverlay.tsx diff --git a/example/src/MobileGameOverlay.tsx b/src/MobileGameOverlay.tsx similarity index 100% rename from example/src/MobileGameOverlay.tsx rename to src/MobileGameOverlay.tsx diff --git a/src/sudoku.css b/src/sudoku.css index 11131c0..50469ff 100644 --- a/src/sudoku.css +++ b/src/sudoku.css @@ -1,20 +1,27 @@ /* ========================================================= 🔧 Controls (tweak these) ========================================================= */ +/* :root { */ +/* --cell-size: 32px; */ +/* --cell-font-size: 16px; */ +/* --grid-gap: 2px; */ +/* --box-separator: 3px; */ +/* } */ + :root { - --cell-size: 32px; - --cell-font-size: 16px; - --grid-gap: 2px; - --box-separator: 3px; + --cell-size: 48px; + --cell-font-size: 32px; + --grid-gap: 4px; + --box-separator: 6px; } -:root { - --cell-size: 96px; - --cell-font-size: 48px; - --grid-gap: 6px; - --box-separator: 9px; -} +/* :root { */ +/* --cell-size: 96px; */ +/* --cell-font-size: 48px; */ +/* --grid-gap: 6px; */ +/* --box-separator: 9px; */ +/* } */ /* ========================================================= Container & Grid Base @@ -27,7 +34,7 @@ #sudoku { position: relative; - background-color: #1e2030; /* Tokyo Night card background */ + background-color: var(--card, #1e2030); border-radius: 0.5rem; box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05); padding: 0.5rem; @@ -64,8 +71,8 @@ border-radius: 4px; font-family: "Fira Code", monospace; font-size: var(--cell-font-size); - background: #2f334d; /* Tokyo Night muted */ - color: #c8d3f5; /* Tokyo Night foreground */ + background: var(--muted, #2f334d); + color: var(--foreground, #c8d3f5); transition: all 0.15s ease; box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4); } @@ -77,35 +84,35 @@ #sudoku .sudoku-cell[data-c="2"], #sudoku .sudoku-cell[data-c="5"], #sudoku .sudoku-cell[data-c="8"] { - border-right: 2px solid rgba(130, 170, 255, 0.3); + border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent); } /* Left borders (start of each 3x3 box horizontally) */ #sudoku .sudoku-cell[data-c="0"], #sudoku .sudoku-cell[data-c="3"], #sudoku .sudoku-cell[data-c="6"] { - border-left: 2px solid rgba(130, 170, 255, 0.3); + border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent); } /* Bottom borders (end of each 3x3 box vertically) */ #sudoku .sudoku-cell[data-r="2"], #sudoku .sudoku-cell[data-r="5"], #sudoku .sudoku-cell[data-r="8"] { - border-bottom: 2px solid rgba(130, 170, 255, 0.3); + border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent); } /* Top borders (start of each 3x3 box vertically) */ #sudoku .sudoku-cell[data-r="0"], #sudoku .sudoku-cell[data-r="3"], #sudoku .sudoku-cell[data-r="6"] { - border-top: 2px solid rgba(130, 170, 255, 0.3); + border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent); } /* ========================================================= User-Entered Cells ========================================================= */ #sudoku .sudoku-cell.user-entered { - color: #82aaff; /* Tokyo Night primary blue */ + color: var(--primary, #82aaff); font-weight: 600; } @@ -113,41 +120,41 @@ Active Cell Highlight (Navigation Mode) ========================================================= */ #sudoku .sudoku-cell.active { - outline: 2px solid #82aaff; /* Tokyo Night blue */ + outline: 2px solid var(--primary, #82aaff); outline-offset: -2px; - background: #3d4269; + background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d)); box-shadow: - 0 0 8px rgba(130, 170, 255, 0.4), - 0 0 12px rgba(130, 170, 255, 0.2), - inset 0 0 6px rgba(130, 170, 255, 0.15); + 0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent), + 0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent), + inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent); } /* ========================================================= Editing Cell Highlight (Edit Mode) ========================================================= */ #sudoku .sudoku-cell.editing { - outline: 2px solid #ffc777; /* Tokyo Night yellow */ + outline: 2px solid var(--accent, #ffc777); outline-offset: -2px; - background: #3d3836; + background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d)); box-shadow: - 0 0 16px rgba(255, 199, 119, 0.7), - 0 0 24px rgba(255, 199, 119, 0.4), - inset 0 0 12px rgba(255, 199, 119, 0.3); + 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent), + 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent), + inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent); animation: editing-cell-glow 1.5s ease-in-out infinite; } @keyframes editing-cell-glow { 0%, 100% { box-shadow: - 0 0 16px rgba(255, 199, 119, 0.7), - 0 0 24px rgba(255, 199, 119, 0.4), - inset 0 0 12px rgba(255, 199, 119, 0.3); + 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent), + 0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent), + inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent); } 50% { box-shadow: - 0 0 20px rgba(255, 199, 119, 0.9), - 0 0 32px rgba(255, 199, 119, 0.6), - inset 0 0 16px rgba(255, 199, 119, 0.4); + 0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent), + 0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent), + inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent); } } @@ -158,16 +165,16 @@ #sudoku .sudoku-cell.invalid-move { animation: sudoku-invalid-pulse 180ms ease-out; box-shadow: - 0 0 12px rgba(255, 117, 127, 0.85), - 0 0 24px rgba(255, 117, 127, 0.45); + 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent), + 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent); } @keyframes sudoku-invalid-pulse { 0% { transform: scale(1.05); box-shadow: - 0 0 16px rgba(255, 117, 127, 0.95), - 0 0 28px rgba(255, 117, 127, 0.55); + 0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent), + 0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent); } 100% { transform: none; @@ -206,11 +213,59 @@ /* Trail effect for moves */ #sudoku .sudoku-cell.trail { - box-shadow: inset 0 0 6px rgba(130, 170, 255, 0.6), 0 0 6px rgba(130, 170, 255, 0.35); + box-shadow: + inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 60%, transparent), + 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent); animation: sudoku-trail-fade 220ms ease-out; } @keyframes sudoku-trail-fade { - from { background-color: rgba(130, 170, 255, 0.25); } + from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); } to { background-color: transparent; } } + +/* Valid move flash */ +#sudoku .sudoku-cell.valid-move { + animation: sudoku-valid-flash 300ms ease-out; +} + +@keyframes sudoku-valid-flash { + 0% { + background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent); + box-shadow: + 0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent), + 0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent), + inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent); + } + 100% { + background: transparent; + box-shadow: none; + } +} + +/* Conflict highlighting */ +#sudoku .sudoku-cell.conflict { + outline: 2px solid var(--destructive, #c53b53); + outline-offset: -2px; + background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d)); + box-shadow: + 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent), + 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent), + inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent); + animation: sudoku-conflict-pulse 1s ease-in-out infinite; +} + +@keyframes sudoku-conflict-pulse { + 0%, 100% { + box-shadow: + 0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent), + 0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent), + inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent); + } + 50% { + box-shadow: + 0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent), + 0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent), + inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent); + } +} diff --git a/src/useGame/useBoardState.ts b/src/useGame/useBoardState.ts index c414e0c..8bf2e02 100644 --- a/src/useGame/useBoardState.ts +++ b/src/useGame/useBoardState.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { sudoku } from "../lib/Sudoku"; import type { UseCursorType } from "./useCursor"; @@ -8,40 +8,190 @@ export function useBoardState(cursorManager: UseCursorType) { const [initialBoard, setInitialBoard] = useState(board); const [solution, setSolution] = useState(null); - const setDigit = useCallback( - (d: string) => { - const i = cursor.r * 9 + cursor.c; - const arr = board.split(""); - arr[i] = d; - setBoard(arr.join("")); - }, - [cursor, board] - ); + const givenCells = () => { + const cells = new Set(); + for (let i = 0; i < initialBoard.length; i++) { + if (initialBoard[i] !== ".") { + cells.add(i); + } + } + return cells; + }; + + const canEditCell = (row: number, col: number): boolean => { + const index = row * 9 + col; + return !givenCells().has(index); + }; + + const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => { + if (digit === ".") return true; + + const idx = row * 9 + col; + + for (let c = 0; c < 9; c++) { + const checkIdx = row * 9 + c; + if (c !== col && testBoard[checkIdx] === digit) { + return false; + } + } + + for (let r = 0; r < 9; r++) { + const checkIdx = r * 9 + col; + if (r !== row && testBoard[checkIdx] === digit) { + return false; + } + } + + const boxRow = Math.floor(row / 3) * 3; + const boxCol = Math.floor(col / 3) * 3; + for (let r = boxRow; r < boxRow + 3; r++) { + for (let c = boxCol; c < boxCol + 3; c++) { + const checkIdx = r * 9 + c; + if ((r !== row || c !== col) && testBoard[checkIdx] === digit) { + return false; + } + } + } + + return true; + }; + + const getConflicts = (testBoard: string, row: number, col: number): number[] => { + const conflicts: number[] = []; + const digit = testBoard[row * 9 + col]; + + if (digit === ".") return conflicts; + + for (let c = 0; c < 9; c++) { + if (c !== col && testBoard[row * 9 + c] === digit) { + conflicts.push(row * 9 + c); + } + } + + for (let r = 0; r < 9; r++) { + if (r !== row && testBoard[r * 9 + col] === digit) { + conflicts.push(r * 9 + col); + } + } + + const boxRow = Math.floor(row / 3) * 3; + const boxCol = Math.floor(col / 3) * 3; + for (let r = boxRow; r < boxRow + 3; r++) { + for (let c = boxCol; c < boxCol + 3; c++) { + if ((r !== row || c !== col) && testBoard[r * 9 + c] === digit) { + const idx = r * 9 + c; + if (!conflicts.includes(idx)) { + conflicts.push(idx); + } + } + } + } + + return conflicts; + }; + + const isFullyValid = (testBoard: string): boolean => { + for (let r = 0; r < 9; r++) { + let rowSum = 0; + for (let c = 0; c < 9; c++) { + const digit = testBoard[r * 9 + c]; + if (digit === ".") return false; + rowSum += parseInt(digit, 10); + } + if (rowSum !== 45) return false; + } + + for (let c = 0; c < 9; c++) { + let colSum = 0; + for (let r = 0; r < 9; r++) { + const digit = testBoard[r * 9 + c]; + colSum += parseInt(digit, 10); + } + if (colSum !== 45) return false; + } + + for (let boxRow = 0; boxRow < 3; boxRow++) { + for (let boxCol = 0; boxCol < 3; boxCol++) { + let boxSum = 0; + for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) { + for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) { + const digit = testBoard[r * 9 + c]; + boxSum += parseInt(digit, 10); + } + } + if (boxSum !== 45) return false; + } + } + + return true; + }; + + const isComplete = (testBoard: string): boolean => { + return !testBoard.includes("."); + }; + + const setDigit = (d: string) => { + const i = cursor.r * 9 + cursor.c; + + if (!canEditCell(cursor.r, cursor.c)) { + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return false; + } + + const arr = board.split(""); + arr[i] = d; + const newBoard = arr.join(""); + + if (!isValidPlacement(newBoard, cursor.r, cursor.c, d)) { + const conflicts = getConflicts(newBoard, cursor.r, cursor.c); + window.dispatchEvent( + new CustomEvent("sudoku-invalid-move", { + detail: { row: cursor.r, col: cursor.c, digit: d, conflicts }, + }) + ); + return false; + } + + setBoard(newBoard); + window.dispatchEvent( + new CustomEvent("sudoku-valid-move", { + detail: { row: cursor.r, col: cursor.c, digit: d }, + }) + ); + return true; + }; + + const erase = () => { + if (!canEditCell(cursor.r, cursor.c)) { + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return false; + } - const erase = useCallback(() => { const i = cursor.r * 9 + cursor.c; const arr = board.split(""); arr[i] = "."; setBoard(arr.join("")); - }, [cursor, board]); + return true; + }; - const reset = useCallback(() => { - const newBoard = sudoku.generate("medium"); + const reset = () => { + const newBoard = sudoku.generate("easy"); setBoard(newBoard); setInitialBoard(newBoard); setSolution(null); setCursor({ r: 0, c: 0 }); - }, [setCursor]); + }; - const solveBoard = useCallback(() => { - const solved = sudoku.solve(board); + const solveBoard = () => { + const solved = sudoku.solve(initialBoard); if (solved) { setSolution(solved); setBoard(solved); + window.dispatchEvent(new CustomEvent("sudoku-game-won")); } - }, [board]); + }; - const boardToString = useCallback((human: boolean = false) => { + const boardToString = (human: boolean = false) => { // Computer-optimized: compact 81-character string if (!human) { return board; @@ -60,7 +210,7 @@ export function useBoardState(cursorManager: UseCursorType) { if (r === 2 || r === 5) result += "------+-------+------\n"; } return result; - }, [board]); + }; return { board, @@ -71,6 +221,12 @@ export function useBoardState(cursorManager: UseCursorType) { reset, solveBoard, boardToString, + canEditCell, + isValidPlacement, + getConflicts, + isFullyValid, + isComplete, + givenCells, }; } diff --git a/src/useGame/useCursor.ts b/src/useGame/useCursor.ts index fa24be5..230e508 100644 --- a/src/useGame/useCursor.ts +++ b/src/useGame/useCursor.ts @@ -30,6 +30,40 @@ export function useCursor(rendererManager: UseRenderer) { setTimeout(() => el.classList.remove("locked-cell"), 300); }, [cursor, getCellEl]); + const flashValid = useCallback(() => { + const el = getCellEl(cursor.r, cursor.c); + if (!el) return; + el.classList.add("valid-move"); + setTimeout(() => el.classList.remove("valid-move"), 300); + }, [cursor, getCellEl]); + + const highlightConflicts = useCallback((conflictIndices: number[]) => { + const container = containerRef.current; + if (!container) return; + const sudokuDiv = container.querySelector("#sudoku"); + if (!sudokuDiv) return; + + conflictIndices.forEach((idx) => { + const r = Math.floor(idx / 9); + const c = idx % 9; + const el = getCellEl(r, c); + if (el) { + el.classList.add("conflict"); + } + }); + + setTimeout(() => { + conflictIndices.forEach((idx) => { + const r = Math.floor(idx / 9); + const c = idx % 9; + const el = getCellEl(r, c); + if (el) { + el.classList.remove("conflict"); + } + }); + }, 2000); + }, [containerRef, getCellEl]); + const moveCursor = useCallback( (dr: number, dc: number, steps = 1) => { if (animatingRef.current) return; @@ -54,9 +88,25 @@ export function useCursor(rendererManager: UseRenderer) { useEffect(() => { const onLockedCell = () => flashLocked(); + const onValidMove = () => flashValid(); + const onInvalidMove = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.conflicts) { + highlightConflicts(detail.conflicts); + } + flashInvalid(); + }; + window.addEventListener("sudoku-locked-cell", onLockedCell); - return () => window.removeEventListener("sudoku-locked-cell", onLockedCell); - }, [flashLocked]); + window.addEventListener("sudoku-valid-move", onValidMove); + window.addEventListener("sudoku-invalid-move", onInvalidMove as EventListener); + + return () => { + window.removeEventListener("sudoku-locked-cell", onLockedCell); + window.removeEventListener("sudoku-valid-move", onValidMove); + window.removeEventListener("sudoku-invalid-move", onInvalidMove as EventListener); + }; + }, [flashLocked, flashValid, flashInvalid, highlightConflicts]); return { cursor, @@ -64,6 +114,8 @@ export function useCursor(rendererManager: UseRenderer) { moveCursor, getCellEl, flashInvalid, + flashValid, + highlightConflicts, }; } diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 914be9e..6a72ef9 100644 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -14,7 +14,17 @@ export function useGame(platformHook?: unknown) { const gameManager = useGameStatus(rendererManager, cursorManager); const { gameStatus, setGameStatus, startGame, stopGame } = gameManager; const boardManager = useBoardState(cursorManager); - const { board, initialBoard, solution, setDigit, erase, reset, solveBoard } = boardManager; + const { + board, + initialBoard, + solution, + setDigit, + erase, + reset, + solveBoard, + isComplete, + isFullyValid, + } = boardManager; // --------------------------- // Keybindings @@ -37,6 +47,10 @@ export function useGame(platformHook?: unknown) { const scoreManager = useScore({ gameContext: { gameStatus, setGameStatus }, keystrokeCount: keyLog.length, + board, + isComplete, + isFullyValid, + initialBoard, }); // --------------------------- diff --git a/src/useGame/useKeyBindings.ts b/src/useGame/useKeyBindings.ts index d1670a0..09bdba4 100644 --- a/src/useGame/useKeyBindings.ts +++ b/src/useGame/useKeyBindings.ts @@ -78,16 +78,22 @@ export function useKeyBindings(ctx: Ctx) { return; } - // R or C to enter edit mode - if (k === "r" || k === "R" || k === "c" || k === "C") { + // R to replace (only works on user-filled cells) + if (k === "r" || k === "R") { recordKey(k); - // Check if current cell is a pre-filled clue (not editable) const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c; const isClue = ctx.initialBoard[cellIndex] !== "."; + const isEmpty = ctx.board[cellIndex] === "."; if (isClue) { - // Flash red - cell is locked + // Flash red - cell is locked (pre-filled clue) + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return; + } + + if (isEmpty) { + // Flash red - cell is empty (use I to insert) window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); return; } @@ -96,6 +102,47 @@ export function useKeyBindings(ctx: Ctx) { return; } + // I to insert (only works on empty cells) + if (k === "i" || k === "I") { + recordKey(k); + + const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c; + const isClue = ctx.initialBoard[cellIndex] !== "."; + const isEmpty = ctx.board[cellIndex] === "."; + + if (isClue) { + // Flash red - cell is locked (pre-filled clue) + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return; + } + + if (!isEmpty) { + // Flash red - cell already has a value (use R to replace) + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return; + } + + setEditMode("edit"); + return; + } + + // D or X to delete cell value in navigation mode + if (k === "d" || k === "D" || k === "x" || k === "X") { + recordKey(k); + + const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c; + const isClue = ctx.initialBoard[cellIndex] !== "."; + + if (isClue) { + // Flash red - can't delete clues + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return; + } + + ctx.erase(); + return; + } + // Escape cancels count prefix if (k === "Escape") { recordKey(k); diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts index 2365d40..a85eae0 100644 --- a/src/useScore/useScore.ts +++ b/src/useScore/useScore.ts @@ -3,8 +3,8 @@ import { useTimer } from "./hooks/useTimer"; import { useScoreTime } from "./useScoreTime"; import type { GameStatus } from "../types"; -const TIME_LIMIT_MS = 60000; // 60 seconds -const KEYSTROKE_LIMIT = 100; +const TIME_LIMIT_MS = Infinity; // No time limit by default +const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default type GameScoreContext = { gameStatus: GameStatus; @@ -14,33 +14,68 @@ type GameScoreContext = { type UseScoreParams = { gameContext: GameScoreContext; keystrokeCount: number; + board: string; + isComplete: (board: string) => boolean; + isFullyValid: (board: string) => boolean; + initialBoard: string; }; -export function useScore({ gameContext, keystrokeCount }: UseScoreParams) { +export function useScore({ + gameContext, + keystrokeCount, + board, + isComplete, + isFullyValid, + initialBoard +}: UseScoreParams) { const timer = useTimer(); const { gameStatus, setGameStatus } = gameContext; const { timeValue, startTimer, stopTimer, resetTimer } = timer; const [finalScore, setFinalScore] = useState(null); + const minMovesRequired = initialBoard.split("").filter(c => c === ".").length; + const actualMoves = keystrokeCount; + const efficiency = minMovesRequired > 0 + ? Math.round((actualMoves / minMovesRequired) * 100) + : 0; + useScoreTime({ gameStatus, timer }); - // Check for game-over conditions useEffect(() => { if (gameStatus !== "started") return; - // Time limit exceeded + if (isComplete(board) && isFullyValid(board)) { + setGameStatus("game-won"); + return; + } + if (timeValue >= TIME_LIMIT_MS) { setGameStatus("game-over"); return; } - // Keystroke limit exceeded if (keystrokeCount >= KEYSTROKE_LIMIT) { setGameStatus("game-over"); return; } - }, [gameStatus, timeValue, keystrokeCount, setGameStatus]); + + // TODO: Enable efficiency-based game-over in future + // if (efficiency > 200) { + // setGameStatus("game-over"); + // return; + // } + }, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]); + + useEffect(() => { + const handleWin = () => { + if (gameStatus === "started") { + setGameStatus("game-won"); + } + }; + window.addEventListener("sudoku-game-won", handleWin); + return () => window.removeEventListener("sudoku-game-won", handleWin); + }, [gameStatus, setGameStatus]); useEffect(() => { if (gameStatus !== "game-won") return; @@ -48,10 +83,12 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) { const timeInSeconds = timeValue / 1000; const baseScore = 100000; const timePenalty = Math.floor(timeInSeconds * 10); - const score = Math.max(0, baseScore - timePenalty); + const extraMoves = Math.max(0, actualMoves - minMovesRequired); + const movePenalty = extraMoves * 50; + const score = Math.max(0, baseScore - timePenalty - movePenalty); setFinalScore(score); - }, [gameStatus, timeValue]); + }, [gameStatus, timeValue, actualMoves, minMovesRequired]); return { timeValue, @@ -61,6 +98,9 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) { finalScore, timeLimit: TIME_LIMIT_MS, keystrokeLimit: KEYSTROKE_LIMIT, + minMovesRequired, + actualMoves, + efficiency, }; }