GameOverlays
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# @vimazing/vim-sudoku
|
# @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.
|
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)
|
- [Features](#features)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
|
- [VIM Modal Editing](#vim-modal-editing)
|
||||||
- [Core Hooks](#core-hooks)
|
- [Core Hooks](#core-hooks)
|
||||||
- [Utilities](#utilities)
|
- [Game Mechanics](#game-mechanics)
|
||||||
- [Example App](#example-app)
|
- [Example App](#example-app)
|
||||||
- [Build & Release](#build--release)
|
- [Build & Release](#build--release)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
@@ -19,11 +20,15 @@ Part of the [VIMazing](https://github.com/andrepadez/vimazing-vimaze) project.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Drop-in hooks** for sudoku game logic, player control, scoring, and key logging.
|
- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells in insert mode (R/C to enter)
|
||||||
- **Typed API** with generated declaration files for editor IntelliSense.
|
- ⌨️ **Full VIM motions** – `h`/`j`/`k`/`l`, counted moves, `^`/`$`/`0` for row navigation
|
||||||
- **VIM-style navigation** (`h`, `j`, `k`, `l`, counts, `i` to start, etc.).
|
- 🎯 **Smart cell locking** – Pre-filled clues are protected with visual feedback
|
||||||
- **Composable architecture** – bring your own rendering and platform-specific bindings.
|
- ⏱️ **Time & keystroke limits** – Configurable game-over conditions
|
||||||
- **Tree-shakeable exports** to keep bundles lean.
|
- 📊 **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
|
## Quick Start
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useGame } from "@vimazing/vim-sudoku";
|
import { useGame } from "@vimazing/vim-sudoku";
|
||||||
|
import "@vimazing/vim-sudoku/sudoku.css";
|
||||||
|
|
||||||
export function SudokuGame() {
|
export function SudokuGame() {
|
||||||
const { containerRef, gameStatus, startGame } = useGame();
|
const { containerRef, initGame, gameStatus, scoreManager } = useGame();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
startGame();
|
|
||||||
}, [startGame]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-fit space-y-4">
|
<div>
|
||||||
<h1 className="text-2xl font-bold">VIMazing Sudoku</h1>
|
<h1>VIMazing Sudoku</h1>
|
||||||
<div ref={containerRef} className="relative" />
|
<button onClick={initGame}>New Puzzle</button>
|
||||||
{gameStatus === "game-won" && <p>🎉 You solved it!</p>}
|
|
||||||
</section>
|
<div className="relative w-fit">
|
||||||
|
<div ref={containerRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gameStatus === "game-won" && (
|
||||||
|
<p>🎉 Score: {scoreManager.finalScore?.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Default controls:
|
---
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
| `<count>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 |
|
| Key | Action |
|
||||||
| ------------------- | --------------- |
|
| --- | ------ |
|
||||||
| `i` | Start game |
|
| `Q` | Quit game |
|
||||||
| `h` / `j` / `k` / `l` | Move cursor |
|
|
||||||
| `<number>` | Enter number |
|
### Edit Mode (I/R to enter)
|
||||||
| `x` / `d` | Clear cell |
|
|
||||||
| `q` | Quit game |
|
| Key | Action |
|
||||||
| `Esc` | Cancel / pause |
|
| --- | ------ |
|
||||||
|
| `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
|
## Core Hooks
|
||||||
|
|
||||||
| Hook | Description |
|
### `useGame(platformHook?)`
|
||||||
| ---- | ----------- |
|
|
||||||
| `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. |
|
|
||||||
|
|
||||||
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<HTMLDivElement>,
|
||||||
|
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
|
```ts
|
||||||
import { useScore } from "@vimazing/vim-sudoku/useScore";
|
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.
|
Time-based scoring formula:
|
||||||
- `SudokuRenderer` – canvas renderer tailored for sudoku grids.
|
```
|
||||||
- `types` – shared TypeScript types such as `GameStatus`, `SudokuCell`, and `KeyLogEntry`.
|
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
|
```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
|
||||||
|
// ...
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import type { UseGameType } from "../../src/useGame/useGame";
|
import type { UseGameType } from "../../src/";
|
||||||
|
|
||||||
export const useKeyBindings = (gameManager: UseGameType) => {
|
export const useKeyBindings = (gameManager: UseGameType) => {
|
||||||
const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
|
const { gameStatus, initGame, stopGame, clearLog, resetCount } = gameManager;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
console.log(e.key);
|
||||||
// start game
|
// start game
|
||||||
if (e.key === "i" || e.key === "I") {
|
if (e.code === "Space") {
|
||||||
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
if (["waiting", "game-over", "game-won"].includes(gameStatus)) {
|
||||||
clearLog();
|
clearLog();
|
||||||
resetCount();
|
resetCount();
|
||||||
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
if (gameStatus === "game-over" || gameStatus === "game-won") {
|
||||||
stopGame();
|
stopGame();
|
||||||
setTimeout(() => initGame(true), 0);
|
setTimeout(() => initGame(), 0);
|
||||||
} else {
|
} else {
|
||||||
initGame(true);
|
initGame();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-40
@@ -1,20 +1,27 @@
|
|||||||
/* =========================================================
|
/* =========================================================
|
||||||
🔧 Controls (tweak these)
|
🔧 Controls (tweak these)
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
/* :root { */
|
||||||
|
/* --cell-size: 32px; */
|
||||||
|
/* --cell-font-size: 16px; */
|
||||||
|
/* --grid-gap: 2px; */
|
||||||
|
/* --box-separator: 3px; */
|
||||||
|
/* } */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--cell-size: 32px;
|
--cell-size: 48px;
|
||||||
--cell-font-size: 16px;
|
--cell-font-size: 32px;
|
||||||
--grid-gap: 2px;
|
--grid-gap: 4px;
|
||||||
--box-separator: 3px;
|
--box-separator: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
:root {
|
/* :root { */
|
||||||
--cell-size: 96px;
|
/* --cell-size: 96px; */
|
||||||
--cell-font-size: 48px;
|
/* --cell-font-size: 48px; */
|
||||||
--grid-gap: 6px;
|
/* --grid-gap: 6px; */
|
||||||
--box-separator: 9px;
|
/* --box-separator: 9px; */
|
||||||
}
|
/* } */
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
Container & Grid Base
|
Container & Grid Base
|
||||||
@@ -27,7 +34,7 @@
|
|||||||
|
|
||||||
#sudoku {
|
#sudoku {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #1e2030; /* Tokyo Night card background */
|
background-color: var(--card, #1e2030);
|
||||||
border-radius: 0.5rem;
|
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);
|
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -64,8 +71,8 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: "Fira Code", monospace;
|
font-family: "Fira Code", monospace;
|
||||||
font-size: var(--cell-font-size);
|
font-size: var(--cell-font-size);
|
||||||
background: #2f334d; /* Tokyo Night muted */
|
background: var(--muted, #2f334d);
|
||||||
color: #c8d3f5; /* Tokyo Night foreground */
|
color: var(--foreground, #c8d3f5);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
|
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="2"],
|
||||||
#sudoku .sudoku-cell[data-c="5"],
|
#sudoku .sudoku-cell[data-c="5"],
|
||||||
#sudoku .sudoku-cell[data-c="8"] {
|
#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) */
|
/* Left borders (start of each 3x3 box horizontally) */
|
||||||
#sudoku .sudoku-cell[data-c="0"],
|
#sudoku .sudoku-cell[data-c="0"],
|
||||||
#sudoku .sudoku-cell[data-c="3"],
|
#sudoku .sudoku-cell[data-c="3"],
|
||||||
#sudoku .sudoku-cell[data-c="6"] {
|
#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) */
|
/* Bottom borders (end of each 3x3 box vertically) */
|
||||||
#sudoku .sudoku-cell[data-r="2"],
|
#sudoku .sudoku-cell[data-r="2"],
|
||||||
#sudoku .sudoku-cell[data-r="5"],
|
#sudoku .sudoku-cell[data-r="5"],
|
||||||
#sudoku .sudoku-cell[data-r="8"] {
|
#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) */
|
/* Top borders (start of each 3x3 box vertically) */
|
||||||
#sudoku .sudoku-cell[data-r="0"],
|
#sudoku .sudoku-cell[data-r="0"],
|
||||||
#sudoku .sudoku-cell[data-r="3"],
|
#sudoku .sudoku-cell[data-r="3"],
|
||||||
#sudoku .sudoku-cell[data-r="6"] {
|
#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
|
User-Entered Cells
|
||||||
========================================================= */
|
========================================================= */
|
||||||
#sudoku .sudoku-cell.user-entered {
|
#sudoku .sudoku-cell.user-entered {
|
||||||
color: #82aaff; /* Tokyo Night primary blue */
|
color: var(--primary, #82aaff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,41 +120,41 @@
|
|||||||
Active Cell Highlight (Navigation Mode)
|
Active Cell Highlight (Navigation Mode)
|
||||||
========================================================= */
|
========================================================= */
|
||||||
#sudoku .sudoku-cell.active {
|
#sudoku .sudoku-cell.active {
|
||||||
outline: 2px solid #82aaff; /* Tokyo Night blue */
|
outline: 2px solid var(--primary, #82aaff);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
background: #3d4269;
|
background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 8px rgba(130, 170, 255, 0.4),
|
0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
|
||||||
0 0 12px rgba(130, 170, 255, 0.2),
|
0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
|
||||||
inset 0 0 6px rgba(130, 170, 255, 0.15);
|
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
Editing Cell Highlight (Edit Mode)
|
Editing Cell Highlight (Edit Mode)
|
||||||
========================================================= */
|
========================================================= */
|
||||||
#sudoku .sudoku-cell.editing {
|
#sudoku .sudoku-cell.editing {
|
||||||
outline: 2px solid #ffc777; /* Tokyo Night yellow */
|
outline: 2px solid var(--accent, #ffc777);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
background: #3d3836;
|
background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 16px rgba(255, 199, 119, 0.7),
|
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
||||||
0 0 24px rgba(255, 199, 119, 0.4),
|
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
||||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
||||||
animation: editing-cell-glow 1.5s ease-in-out infinite;
|
animation: editing-cell-glow 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes editing-cell-glow {
|
@keyframes editing-cell-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 16px rgba(255, 199, 119, 0.7),
|
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
||||||
0 0 24px rgba(255, 199, 119, 0.4),
|
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
||||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(255, 199, 119, 0.9),
|
0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
|
||||||
0 0 32px rgba(255, 199, 119, 0.6),
|
0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
|
||||||
inset 0 0 16px rgba(255, 199, 119, 0.4);
|
inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,16 +165,16 @@
|
|||||||
#sudoku .sudoku-cell.invalid-move {
|
#sudoku .sudoku-cell.invalid-move {
|
||||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
animation: sudoku-invalid-pulse 180ms ease-out;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 12px rgba(255, 117, 127, 0.85),
|
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
||||||
0 0 24px rgba(255, 117, 127, 0.45);
|
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sudoku-invalid-pulse {
|
@keyframes sudoku-invalid-pulse {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 16px rgba(255, 117, 127, 0.95),
|
0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
|
||||||
0 0 28px rgba(255, 117, 127, 0.55);
|
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: none;
|
transform: none;
|
||||||
@@ -206,11 +213,59 @@
|
|||||||
|
|
||||||
/* Trail effect for moves */
|
/* Trail effect for moves */
|
||||||
#sudoku .sudoku-cell.trail {
|
#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;
|
animation: sudoku-trail-fade 220ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sudoku-trail-fade {
|
@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; }
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+172
-16
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { sudoku } from "../lib/Sudoku";
|
import { sudoku } from "../lib/Sudoku";
|
||||||
import type { UseCursorType } from "./useCursor";
|
import type { UseCursorType } from "./useCursor";
|
||||||
|
|
||||||
@@ -8,40 +8,190 @@ export function useBoardState(cursorManager: UseCursorType) {
|
|||||||
const [initialBoard, setInitialBoard] = useState<string>(board);
|
const [initialBoard, setInitialBoard] = useState<string>(board);
|
||||||
const [solution, setSolution] = useState<string | null>(null);
|
const [solution, setSolution] = useState<string | null>(null);
|
||||||
|
|
||||||
const setDigit = useCallback(
|
const givenCells = () => {
|
||||||
(d: string) => {
|
const cells = new Set<number>();
|
||||||
|
for (let i = 0; i < initialBoard.length; i++) {
|
||||||
|
if (initialBoard[i] !== ".") {
|
||||||
|
cells.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEditCell = (row: number, col: number): boolean => {
|
||||||
|
const index = row * 9 + col;
|
||||||
|
return !givenCells().has(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
|
||||||
|
if (digit === ".") return true;
|
||||||
|
|
||||||
|
const idx = row * 9 + col;
|
||||||
|
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
const checkIdx = row * 9 + c;
|
||||||
|
if (c !== col && testBoard[checkIdx] === digit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
const checkIdx = r * 9 + col;
|
||||||
|
if (r !== row && testBoard[checkIdx] === digit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxRow = Math.floor(row / 3) * 3;
|
||||||
|
const boxCol = Math.floor(col / 3) * 3;
|
||||||
|
for (let r = boxRow; r < boxRow + 3; r++) {
|
||||||
|
for (let c = boxCol; c < boxCol + 3; c++) {
|
||||||
|
const checkIdx = r * 9 + c;
|
||||||
|
if ((r !== row || c !== col) && testBoard[checkIdx] === digit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConflicts = (testBoard: string, row: number, col: number): number[] => {
|
||||||
|
const conflicts: number[] = [];
|
||||||
|
const digit = testBoard[row * 9 + col];
|
||||||
|
|
||||||
|
if (digit === ".") return conflicts;
|
||||||
|
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
if (c !== col && testBoard[row * 9 + c] === digit) {
|
||||||
|
conflicts.push(row * 9 + c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
if (r !== row && testBoard[r * 9 + col] === digit) {
|
||||||
|
conflicts.push(r * 9 + col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boxRow = Math.floor(row / 3) * 3;
|
||||||
|
const boxCol = Math.floor(col / 3) * 3;
|
||||||
|
for (let r = boxRow; r < boxRow + 3; r++) {
|
||||||
|
for (let c = boxCol; c < boxCol + 3; c++) {
|
||||||
|
if ((r !== row || c !== col) && testBoard[r * 9 + c] === digit) {
|
||||||
|
const idx = r * 9 + c;
|
||||||
|
if (!conflicts.includes(idx)) {
|
||||||
|
conflicts.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFullyValid = (testBoard: string): boolean => {
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
let rowSum = 0;
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
const digit = testBoard[r * 9 + c];
|
||||||
|
if (digit === ".") return false;
|
||||||
|
rowSum += parseInt(digit, 10);
|
||||||
|
}
|
||||||
|
if (rowSum !== 45) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < 9; c++) {
|
||||||
|
let colSum = 0;
|
||||||
|
for (let r = 0; r < 9; r++) {
|
||||||
|
const digit = testBoard[r * 9 + c];
|
||||||
|
colSum += parseInt(digit, 10);
|
||||||
|
}
|
||||||
|
if (colSum !== 45) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let boxRow = 0; boxRow < 3; boxRow++) {
|
||||||
|
for (let boxCol = 0; boxCol < 3; boxCol++) {
|
||||||
|
let boxSum = 0;
|
||||||
|
for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
|
||||||
|
for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
|
||||||
|
const digit = testBoard[r * 9 + c];
|
||||||
|
boxSum += parseInt(digit, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (boxSum !== 45) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isComplete = (testBoard: string): boolean => {
|
||||||
|
return !testBoard.includes(".");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDigit = (d: string) => {
|
||||||
const i = cursor.r * 9 + cursor.c;
|
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("");
|
const arr = board.split("");
|
||||||
arr[i] = d;
|
arr[i] = d;
|
||||||
setBoard(arr.join(""));
|
const newBoard = arr.join("");
|
||||||
},
|
|
||||||
[cursor, board]
|
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 i = cursor.r * 9 + cursor.c;
|
||||||
const arr = board.split("");
|
const arr = board.split("");
|
||||||
arr[i] = ".";
|
arr[i] = ".";
|
||||||
setBoard(arr.join(""));
|
setBoard(arr.join(""));
|
||||||
}, [cursor, board]);
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = () => {
|
||||||
const newBoard = sudoku.generate("medium");
|
const newBoard = sudoku.generate("easy");
|
||||||
setBoard(newBoard);
|
setBoard(newBoard);
|
||||||
setInitialBoard(newBoard);
|
setInitialBoard(newBoard);
|
||||||
setSolution(null);
|
setSolution(null);
|
||||||
setCursor({ r: 0, c: 0 });
|
setCursor({ r: 0, c: 0 });
|
||||||
}, [setCursor]);
|
};
|
||||||
|
|
||||||
const solveBoard = useCallback(() => {
|
const solveBoard = () => {
|
||||||
const solved = sudoku.solve(board);
|
const solved = sudoku.solve(initialBoard);
|
||||||
if (solved) {
|
if (solved) {
|
||||||
setSolution(solved);
|
setSolution(solved);
|
||||||
setBoard(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
|
// Computer-optimized: compact 81-character string
|
||||||
if (!human) {
|
if (!human) {
|
||||||
return board;
|
return board;
|
||||||
@@ -60,7 +210,7 @@ export function useBoardState(cursorManager: UseCursorType) {
|
|||||||
if (r === 2 || r === 5) result += "------+-------+------\n";
|
if (r === 2 || r === 5) result += "------+-------+------\n";
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [board]);
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
board,
|
board,
|
||||||
@@ -71,6 +221,12 @@ export function useBoardState(cursorManager: UseCursorType) {
|
|||||||
reset,
|
reset,
|
||||||
solveBoard,
|
solveBoard,
|
||||||
boardToString,
|
boardToString,
|
||||||
|
canEditCell,
|
||||||
|
isValidPlacement,
|
||||||
|
getConflicts,
|
||||||
|
isFullyValid,
|
||||||
|
isComplete,
|
||||||
|
givenCells,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,40 @@ export function useCursor(rendererManager: UseRenderer) {
|
|||||||
setTimeout(() => el.classList.remove("locked-cell"), 300);
|
setTimeout(() => el.classList.remove("locked-cell"), 300);
|
||||||
}, [cursor, getCellEl]);
|
}, [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(
|
const moveCursor = useCallback(
|
||||||
(dr: number, dc: number, steps = 1) => {
|
(dr: number, dc: number, steps = 1) => {
|
||||||
if (animatingRef.current) return;
|
if (animatingRef.current) return;
|
||||||
@@ -54,9 +88,25 @@ export function useCursor(rendererManager: UseRenderer) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onLockedCell = () => flashLocked();
|
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);
|
window.addEventListener("sudoku-locked-cell", onLockedCell);
|
||||||
return () => window.removeEventListener("sudoku-locked-cell", onLockedCell);
|
window.addEventListener("sudoku-valid-move", onValidMove);
|
||||||
}, [flashLocked]);
|
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 {
|
return {
|
||||||
cursor,
|
cursor,
|
||||||
@@ -64,6 +114,8 @@ export function useCursor(rendererManager: UseRenderer) {
|
|||||||
moveCursor,
|
moveCursor,
|
||||||
getCellEl,
|
getCellEl,
|
||||||
flashInvalid,
|
flashInvalid,
|
||||||
|
flashValid,
|
||||||
|
highlightConflicts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -14,7 +14,17 @@ export function useGame(platformHook?: unknown) {
|
|||||||
const gameManager = useGameStatus(rendererManager, cursorManager);
|
const gameManager = useGameStatus(rendererManager, cursorManager);
|
||||||
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
|
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
|
||||||
const boardManager = useBoardState(cursorManager);
|
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
|
// Keybindings
|
||||||
@@ -37,6 +47,10 @@ export function useGame(platformHook?: unknown) {
|
|||||||
const scoreManager = useScore({
|
const scoreManager = useScore({
|
||||||
gameContext: { gameStatus, setGameStatus },
|
gameContext: { gameStatus, setGameStatus },
|
||||||
keystrokeCount: keyLog.length,
|
keystrokeCount: keyLog.length,
|
||||||
|
board,
|
||||||
|
isComplete,
|
||||||
|
isFullyValid,
|
||||||
|
initialBoard,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
@@ -78,16 +78,22 @@ export function useKeyBindings(ctx: Ctx) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// R or C to enter edit mode
|
// R to replace (only works on user-filled cells)
|
||||||
if (k === "r" || k === "R" || k === "c" || k === "C") {
|
if (k === "r" || k === "R") {
|
||||||
recordKey(k);
|
recordKey(k);
|
||||||
|
|
||||||
// Check if current cell is a pre-filled clue (not editable)
|
|
||||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
||||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
||||||
|
const isEmpty = ctx.board[cellIndex] === ".";
|
||||||
|
|
||||||
if (isClue) {
|
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"));
|
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,47 @@ export function useKeyBindings(ctx: Ctx) {
|
|||||||
return;
|
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
|
// Escape cancels count prefix
|
||||||
if (k === "Escape") {
|
if (k === "Escape") {
|
||||||
recordKey(k);
|
recordKey(k);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useTimer } from "./hooks/useTimer";
|
|||||||
import { useScoreTime } from "./useScoreTime";
|
import { useScoreTime } from "./useScoreTime";
|
||||||
import type { GameStatus } from "../types";
|
import type { GameStatus } from "../types";
|
||||||
|
|
||||||
const TIME_LIMIT_MS = 60000; // 60 seconds
|
const TIME_LIMIT_MS = Infinity; // No time limit by default
|
||||||
const KEYSTROKE_LIMIT = 100;
|
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
|
||||||
|
|
||||||
type GameScoreContext = {
|
type GameScoreContext = {
|
||||||
gameStatus: GameStatus;
|
gameStatus: GameStatus;
|
||||||
@@ -14,33 +14,68 @@ type GameScoreContext = {
|
|||||||
type UseScoreParams = {
|
type UseScoreParams = {
|
||||||
gameContext: GameScoreContext;
|
gameContext: GameScoreContext;
|
||||||
keystrokeCount: number;
|
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 timer = useTimer();
|
||||||
const { gameStatus, setGameStatus } = gameContext;
|
const { gameStatus, setGameStatus } = gameContext;
|
||||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||||
|
|
||||||
const [finalScore, setFinalScore] = useState<number | null>(null);
|
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
|
||||||
|
const actualMoves = keystrokeCount;
|
||||||
|
const efficiency = minMovesRequired > 0
|
||||||
|
? Math.round((actualMoves / minMovesRequired) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
useScoreTime({ gameStatus, timer });
|
useScoreTime({ gameStatus, timer });
|
||||||
|
|
||||||
// Check for game-over conditions
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameStatus !== "started") return;
|
if (gameStatus !== "started") return;
|
||||||
|
|
||||||
// Time limit exceeded
|
if (isComplete(board) && isFullyValid(board)) {
|
||||||
|
setGameStatus("game-won");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (timeValue >= TIME_LIMIT_MS) {
|
if (timeValue >= TIME_LIMIT_MS) {
|
||||||
setGameStatus("game-over");
|
setGameStatus("game-over");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keystroke limit exceeded
|
|
||||||
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
||||||
setGameStatus("game-over");
|
setGameStatus("game-over");
|
||||||
return;
|
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(() => {
|
useEffect(() => {
|
||||||
if (gameStatus !== "game-won") return;
|
if (gameStatus !== "game-won") return;
|
||||||
@@ -48,10 +83,12 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
|
|||||||
const timeInSeconds = timeValue / 1000;
|
const timeInSeconds = timeValue / 1000;
|
||||||
const baseScore = 100000;
|
const baseScore = 100000;
|
||||||
const timePenalty = Math.floor(timeInSeconds * 10);
|
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);
|
setFinalScore(score);
|
||||||
}, [gameStatus, timeValue]);
|
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timeValue,
|
timeValue,
|
||||||
@@ -61,6 +98,9 @@ export function useScore({ gameContext, keystrokeCount }: UseScoreParams) {
|
|||||||
finalScore,
|
finalScore,
|
||||||
timeLimit: TIME_LIMIT_MS,
|
timeLimit: TIME_LIMIT_MS,
|
||||||
keystrokeLimit: KEYSTROKE_LIMIT,
|
keystrokeLimit: KEYSTROKE_LIMIT,
|
||||||
|
minMovesRequired,
|
||||||
|
actualMoves,
|
||||||
|
efficiency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user