starting new structure

This commit is contained in:
2025-10-19 17:59:12 +01:00
parent 4c6bd0d4d4
commit d0a51d7079
50 changed files with 2219 additions and 432 deletions
+256
View File
@@ -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);
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './types';
export * from "./useGame";
+372
View File
@@ -0,0 +1,372 @@
// src/lib/Sudoku.ts
// Direct TypeScript + ES module version of Robatron's sudoku.js
// https://github.com/robatron/sudoku.js
// No algorithmic changes – only syntax, structure, and typing updates.
export type DifficultyName =
| "easy"
| "medium"
| "hard"
| "very-hard"
| "insane"
| "inhuman";
export type Difficulty = DifficultyName | number;
const sudoku: any = {};
sudoku.DIGITS = "123456789";
const ROWS = "ABCDEFGHI";
const COLS = sudoku.DIGITS;
let SQUARES: string[] = [];
let UNITS: string[][] = [];
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
const MIN_GIVENS = 17;
const NR_SQUARES = 81;
const DIFFICULTY: Record<DifficultyName, number> = {
easy: 62,
medium: 53,
hard: 44,
"very-hard": 35,
insane: 26,
inhuman: 17,
};
sudoku.BLANK_CHAR = ".";
sudoku.BLANK_BOARD = sudoku.BLANK_CHAR.repeat(NR_SQUARES);
function initialize() {
SQUARES = sudoku._cross(ROWS, COLS);
UNITS = sudoku._get_all_units(ROWS, COLS);
SQUARE_UNITS_MAP = sudoku._get_square_units_map(SQUARES, UNITS);
SQUARE_PEERS_MAP = sudoku._get_square_peers_map(SQUARES, SQUARE_UNITS_MAP);
}
// ---------------------------------------------------------------------------
// Generate
// ---------------------------------------------------------------------------
sudoku.generate = function (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 = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
unique = unique ?? true;
const blank_board = sudoku.BLANK_BOARD;
let candidates = sudoku._get_candidates_map(blank_board);
const shuffled_squares = sudoku._shuffle(SQUARES);
for (const square of shuffled_squares) {
const rand_candidate_idx = sudoku._rand_range(candidates[square].length);
const rand_candidate = candidates[square][rand_candidate_idx];
if (!sudoku._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 &&
sudoku._strip_dups(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 += sudoku.BLANK_CHAR;
}
}
if (givens_idxs.length > numericDifficulty) {
givens_idxs = sudoku._shuffle(givens_idxs);
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
const target = givens_idxs[i];
board =
board.substring(0, target) +
sudoku.BLANK_CHAR +
board.substring(target + 1);
}
}
if (sudoku.solve(board)) return board;
}
}
return sudoku.generate(numericDifficulty);
};
// ---------------------------------------------------------------------------
// Solve
// ---------------------------------------------------------------------------
sudoku.solve = function (board: string, reverse?: boolean): string | false {
const report = sudoku.validate_board(board);
if (report !== true) throw report;
let nr_givens = 0;
for (const ch of board) {
if (ch !== sudoku.BLANK_CHAR && sudoku._in(ch, sudoku.DIGITS)) nr_givens++;
}
if (nr_givens < MIN_GIVENS) throw "Too few givens";
reverse = reverse || false;
const candidates = sudoku._get_candidates_map(board);
const result = sudoku._search(candidates, reverse);
if (result) {
let solution = "";
for (const s of SQUARES) solution += result[s];
return solution;
}
return false;
};
// ---------------------------------------------------------------------------
// Get candidates
// ---------------------------------------------------------------------------
sudoku.get_candidates = function (board: string): string[][] | false {
const report = sudoku.validate_board(board);
if (report !== true) throw report;
const map = sudoku._get_candidates_map(board);
if (!map) return false;
const rows: string[][] = [];
let cur_row: string[] = [];
let i = 0;
for (const square in map) {
cur_row.push(map[square]);
if (i % 9 === 8) {
rows.push(cur_row);
cur_row = [];
}
++i;
}
return rows;
};
sudoku._get_candidates_map = function (board: string): Record<string, string> | false {
const report = sudoku.validate_board(board);
if (report !== true) throw report;
const map: Record<string, string> = {};
const squares_values = sudoku._get_square_vals_map(board);
for (const s of SQUARES) map[s] = sudoku.DIGITS;
for (const s in squares_values) {
const val = squares_values[s];
if (sudoku._in(val, sudoku.DIGITS)) {
const new_candidates = sudoku._assign(map, s, val);
if (!new_candidates) return false;
}
}
return map;
};
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
sudoku._search = function (candidates: Record<string, string>, reverse: boolean): any {
if (!candidates) return false;
reverse = reverse || false;
let max_nr_candidates = 0;
for (const s of SQUARES) {
const n = candidates[s].length;
if (n > max_nr_candidates) {
max_nr_candidates = n;
}
}
if (max_nr_candidates === 1) return candidates;
let min_nr_candidates = 10;
let min_square = "";
for (const s of SQUARES) {
const n = candidates[s].length;
if (n < min_nr_candidates && n > 1) {
min_nr_candidates = n;
min_square = s;
}
}
const min_candidates = candidates[min_square];
if (!reverse) {
for (const v of min_candidates) {
const copy = JSON.parse(JSON.stringify(candidates));
const next = sudoku._search(sudoku._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 = sudoku._search(sudoku._assign(copy, min_square, v), reverse);
if (next) return next;
}
}
return false;
};
// ---------------------------------------------------------------------------
// Assignment / elimination logic
// ---------------------------------------------------------------------------
sudoku._assign = function (candidates: Record<string, string>, square: string, val: string) {
const others = candidates[square].replace(val, "");
for (const v of others) {
const next = sudoku._eliminate(candidates, square, v);
if (!next) return false;
}
return candidates;
};
sudoku._eliminate = function (candidates: Record<string, string>, square: string, val: string) {
if (!sudoku._in(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 = sudoku._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 (sudoku._in(val, candidates[sq])) places.push(sq);
}
if (places.length === 0) return false;
else if (places.length === 1) {
const next = sudoku._assign(candidates, places[0], val);
if (!next) return false;
}
}
return candidates;
};
// ---------------------------------------------------------------------------
// Data structures
// ---------------------------------------------------------------------------
sudoku._get_square_vals_map = function (board: string) {
const map: Record<string, string> = {};
if (board.length !== SQUARES.length) throw "Board length mismatch";
for (let i = 0; i < SQUARES.length; i++) map[SQUARES[i]] = board[i];
return map;
};
sudoku._get_square_units_map = function (squares: string[], units: string[][]) {
const map: Record<string, string[][]> = {};
for (const s of squares) {
const list: string[][] = [];
for (const u of units) if (u.includes(s)) list.push(u);
map[s] = list;
}
return map;
};
sudoku._get_square_peers_map = function (
squares: string[],
units_map: Record<string, string[][]>
) {
const map: Record<string, string[]> = {};
for (const s of squares) {
const peers: string[] = [];
for (const unit of units_map[s]) {
for (const sq of unit) {
if (sq !== s && !peers.includes(sq)) peers.push(sq);
}
}
map[s] = peers;
}
return map;
};
sudoku._get_all_units = function (rows: string, cols: string) {
const units: string[][] = [];
for (const r of rows) units.push(sudoku._cross(r, cols));
for (const c of cols) units.push(sudoku._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(sudoku._cross(rb, cb));
return units;
};
// ---------------------------------------------------------------------------
// Utils
// ---------------------------------------------------------------------------
sudoku.validate_board = function (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 (!sudoku._in(c, sudoku.DIGITS) && c !== sudoku.BLANK_CHAR)
return `Invalid char at ${i}: ${c}`;
}
return true;
};
sudoku._cross = function (a: string, b: string) {
const res: string[] = [];
for (const ai of a) for (const bi of b) res.push(ai + bi);
return res;
};
sudoku._in = function (v: string, seq: string) {
return seq.indexOf(v) !== -1;
};
sudoku._shuffle = function <T>(seq: T[]): T[] {
const arr = [...seq];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
};
sudoku._rand_range = function (max: number, min = 0) {
return Math.floor(Math.random() * (max - min)) + min;
};
sudoku._strip_dups = function <T>(seq: T[]): T[] {
return Array.from(new Set(seq));
};
sudoku._force_range = function (nr: number, max: number, min = 0) {
return Math.min(Math.max(nr || 0, min), max);
};
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
initialize();
export { sudoku };
+2
View File
@@ -0,0 +1,2 @@
export * from './sudoku';
export * from './validate-board';
+45
View File
@@ -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("");
}
+57
View File
@@ -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);
};
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Generate a fully valid Sudoku solution using a base pattern and structural shuffles.
* Returns a string of 81 digits (row-major order).
*/
export function generateSolvedBoard(): string {
const size = 9;
const box = 3;
// --- 1️⃣ Base pattern function ---
const pattern = (r: number, c: number) =>
(box * (r % box) + Math.floor(r / box) + c) % size;
// --- 2️⃣ Create base board ---
const baseBoard: number[][] = Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => pattern(r, c) + 1)
);
// --- 3️⃣ Helper to shuffle an array in place ---
const shuffle = <T>(arr: T[]): T[] => {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
};
// --- 4️⃣ Build shuffled row/col indices preserving 3×3 structure ---
const rows = shuffle([0, 1, 2])
.concat(shuffle([3, 4, 5]))
.concat(shuffle([6, 7, 8]));
const cols = shuffle([0, 1, 2])
.concat(shuffle([3, 4, 5]))
.concat(shuffle([6, 7, 8]));
// --- 5️⃣ Shuffle entire bands and stacks (groups of 3) ---
const rowBands = shuffle([0, 1, 2]);
const colStacks = shuffle([0, 1, 2]);
const shuffledRows = rowBands.flatMap(band => rows.slice(band * 3, band * 3 + 3));
const shuffledCols = colStacks.flatMap(stack => cols.slice(stack * 3, stack * 3 + 3));
// --- 6️⃣ Digit permutation (1–9) ---
const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
// --- 7️⃣ Build the final shuffled board ---
const board: number[][] = shuffledRows.map(r =>
shuffledCols.map(c => digits[baseBoard[r][c] - 1])
);
// --- 8️⃣ Flatten to 81-char string ---
return board.flat().join('');
}
+104
View File
@@ -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);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
import { useGame } from "./useGame";
import { useScore } from "./useScore";
export type UseGameType = ReturnType<typeof useGame>;
export type UseScoreType = ReturnType<typeof useScore>;
export type {
Difficulty,
DifficultyName,
} from "./lib/Sudoku";
export type GamePhase = "idle" | "playing";
export type PlayStatus = "started" | "game-over" | "game-won";
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
export const isPlaying = (status: GameStatus): status is PlayStatus =>
status === "started" || status === "game-over" || status === "game-won";
export const getGamePhase = (status: GameStatus): GamePhase =>
status === "waiting" ? "idle" : "playing";
+1
View File
@@ -0,0 +1 @@
export * from './useGame';
+238
View File
@@ -0,0 +1,238 @@
import { useState } from "react";
import { sudoku } from "../lib/Sudoku";
import type { UseCursorType } from "./useCursor";
type UseBoardStateOptions = {
validateMoves?: boolean;
};
export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) {
const { cursor, setCursor } = cursorManager;
const { validateMoves = false } = options || {};
const [board, setBoard] = useState<string>(sudoku.generate("medium"));
const [initialBoard, setInitialBoard] = useState<string>(board);
const [solution, setSolution] = useState<string | null>(null);
const givenCells = () => {
const cells = new Set<number>();
for (let i = 0; i < initialBoard.length; i++) {
if (initialBoard[i] !== ".") {
cells.add(i);
}
}
return cells;
};
const canEditCell = (row: number, col: number): boolean => {
const index = row * 9 + col;
return !givenCells().has(index);
};
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
if (digit === ".") return true;
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 cellIndex = 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[cellIndex] = d;
const newBoard = arr.join("");
if (validateMoves && !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);
if (validateMoves) {
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 i = cursor.r * 9 + cursor.c;
const arr = board.split("");
arr[i] = ".";
setBoard(arr.join(""));
return true;
};
const reset = () => {
const newBoard = sudoku.generate("easy");
setBoard(newBoard);
setInitialBoard(newBoard);
setSolution(null);
setCursor({ r: 0, c: 0 });
};
const solveBoard = () => {
const solved = sudoku.solve(initialBoard);
if (solved) {
setSolution(solved);
setBoard(solved);
window.dispatchEvent(new CustomEvent("sudoku-game-won"));
}
};
const boardToString = (human: boolean = false) => {
// Computer-optimized: compact 81-character string
if (!human) {
return board;
}
// Human-readable: formatted grid with 3x3 box separators
let result = "";
for (let r = 0; r < 9; r++) {
for (let c = 0; c < 9; c++) {
const idx = r * 9 + c;
result += board[idx] === "." ? "." : board[idx];
if (c === 2 || c === 5) result += " | ";
else if (c < 8) result += " ";
}
result += "\n";
if (r === 2 || r === 5) result += "------+-------+------\n";
}
return result;
};
return {
board,
initialBoard,
solution,
setDigit,
erase,
reset,
solveBoard,
boardToString,
canEditCell,
isValidPlacement,
getConflicts,
isFullyValid,
isComplete,
givenCells,
};
}
export type UseBoardStateType = ReturnType<typeof useBoardState>;
+122
View File
@@ -0,0 +1,122 @@
import { useRef, useState, useCallback, useEffect } from "react";
import type { UseRenderer } from "./useSudokuRenderer";
export function useCursor(rendererManager: UseRenderer) {
const { containerRef } = rendererManager;
const [cursor, setCursor] = useState<{ r: number; c: number }>({ r: 0, c: 0 });
const animatingRef = useRef(false);
const getCellEl = useCallback((r: number, c: number): HTMLElement | null => {
const container = containerRef.current;
if (!container) return null;
const sudokuDiv = container.querySelector("#sudoku");
if (!sudokuDiv) return null;
const selector = `.sudoku-cell[data-r="${r}"][data-c="${c}"]`;
return sudokuDiv.querySelector(selector) as HTMLElement | null;
}, [containerRef]);
const flashInvalid = useCallback(() => {
const el = getCellEl(cursor.r, cursor.c);
if (!el) return;
el.classList.add("invalid-move");
setTimeout(() => el.classList.remove("invalid-move"), 180);
}, [cursor, getCellEl]);
const flashLocked = useCallback(() => {
const el = getCellEl(cursor.r, cursor.c);
if (!el) return;
el.classList.add("locked-cell");
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;
setCursor((prev) => {
const targetR = prev.r + (dr * steps);
const targetC = prev.c + (dc * steps);
// Check if the full counted move would go out of bounds
if (targetR < 0 || targetR > 8 || targetC < 0 || targetC > 8) {
// Flash invalid and don't move
setTimeout(() => flashInvalid(), 0);
return prev;
}
// Valid move - apply it
return { r: targetR, c: targetC };
});
},
[flashInvalid]
);
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);
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,
setCursor,
moveCursor,
getCellEl,
flashInvalid,
flashValid,
highlightConflicts,
};
}
export type UseCursorType = ReturnType<typeof useCursor>;
+125
View File
@@ -0,0 +1,125 @@
import { useEffect } from "react";
import { useRenderer } from "./useSudokuRenderer";
import { useCursor } from "./useCursor";
import { useGameStatus } from "./useGameStatus";
import { useBoardState } from "./useBoardState";
import { useKeyBindings } from "./useKeyBindings";
import { useScore } from "../useScore";
export type UseGameOptions = {
validateMoves?: boolean;
};
export function useGame(platformHook?: unknown, options?: UseGameOptions) {
const { validateMoves = false } = options || {};
const rendererManager = useRenderer();
const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager;
const cursorManager = useCursor(rendererManager);
const { cursor, moveCursor } = cursorManager;
const gameManager = useGameStatus(rendererManager, cursorManager);
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
const boardManager = useBoardState(cursorManager, { validateMoves });
const {
board,
initialBoard,
solution,
setDigit,
erase,
reset,
solveBoard,
isComplete,
isFullyValid,
} = boardManager;
// ---------------------------
// Keybindings
// ---------------------------
const keyBindings = useKeyBindings({
moveCursor,
setDigit,
erase,
stopGame,
cursor,
board,
initialBoard,
});
const { editMode, keyLog } = keyBindings;
// ---------------------------
// Scoring (needs keyLog from keyBindings)
// ---------------------------
const scoreManager = useScore({
gameContext: { gameStatus, setGameStatus },
keystrokeCount: keyLog.length,
board,
isComplete,
isFullyValid,
initialBoard,
});
// ---------------------------
// Rendering
// ---------------------------
useEffect(() => {
renderEmptyBoard();
}, [renderEmptyBoard]);
useEffect(() => {
if (gameStatus === "waiting") {
renderEmptyBoard();
} else {
renderBoard(board, initialBoard);
highlightCursor(cursor.r, cursor.c, editMode === "edit");
}
}, [gameStatus, board, initialBoard, cursor, editMode, renderEmptyBoard, renderBoard, highlightCursor]);
// ---------------------------
// Game Control
// ---------------------------
const initGame = () => {
reset();
startGame();
};
// ---------------------------
// Full Game Manager
// ---------------------------
const fullGameManager = {
// core
containerRef,
gameManager,
gameStatus,
setGameStatus,
startGame,
stopGame,
initGame,
// cursor
cursorManager,
cursor,
moveCursor,
// board state
boardManager,
board,
solution,
setDigit,
erase,
reset,
solveBoard,
// scoring
scoreManager,
// keybindings (vim-maze compatible)
...keyBindings,
};
// allow platform-level injection
if (typeof platformHook === "function") {
platformHook(fullGameManager);
}
return fullGameManager;
}
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useState, useMemo } from "react";
import type { UseRenderer } from "./useSudokuRenderer";
import type { UseCursorType } from "./useCursor";
import type { GameStatus } from "../types";
import { getGamePhase, isPlaying } from "../types";
export type { GameStatus } from "../types";
export function useGameStatus(
rendererManager: UseRenderer,
cursorManager: UseCursorType
) {
const { containerRef } = rendererManager;
const { setCursor } = cursorManager;
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
const gamePhase = useMemo(() => getGamePhase(gameStatus), [gameStatus]);
const playStatus = useMemo(
() => (isPlaying(gameStatus) ? gameStatus : null),
[gameStatus]
);
const startGame = useCallback(() => {
setGameStatus("started");
}, []);
const stopGame = useCallback(() => {
setCursor({ r: 0, c: 0 });
setGameStatus("waiting");
const container = containerRef.current;
container?.querySelectorAll(".active").forEach((el) => el.classList.remove("active"));
}, [setCursor, containerRef]);
return {
gameStatus,
gamePhase,
playStatus,
setGameStatus,
startGame,
stopGame,
};
}
export type UseGameStatusType = ReturnType<typeof useGameStatus>;
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
export type KeyLogEntry = { key: string; timestamp: number };
export type EditMode = "navigation" | "edit";
type Ctx = {
moveCursor: (dr: number, dc: number, steps?: number) => void;
setDigit: (d: string) => void;
erase: () => void;
stopGame: () => void;
cursor: { r: number; c: number };
board: string;
initialBoard: string;
};
export function useKeyBindings(ctx: Ctx) {
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const [editMode, setEditMode] = useState<EditMode>("navigation");
const logRef = useRef<KeyLogEntry[]>([]);
const countRef = useRef<string>(""); // Vim-style numeric prefix
const lastMotionRef = useRef<{ dr: number; dc: number; steps: number } | null>(null);
// Record keypresses for score/time analytics
const recordKey = (key: string) => {
const entry = { key, timestamp: performance.now() };
logRef.current.push(entry);
setKeyLog([...logRef.current]);
};
const clearLog = () => {
logRef.current = [];
setKeyLog([]);
};
const getLog = () => logRef.current;
const resetCount = () => {
countRef.current = "";
};
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const k = e.key;
// ========== EDIT MODE ==========
if (editMode === "edit") {
// Exit edit mode with Escape or Enter
if (k === "Escape" || k === "Enter") {
recordKey(k);
setEditMode("navigation");
return;
}
// Digits 1-9 set the cell value
if (/^[1-9]$/.test(k)) {
recordKey(k);
ctx.setDigit(k);
return;
}
// Erase with x or Backspace
if (k === "x" || k === "Backspace") {
recordKey(k);
ctx.erase();
return;
}
// Ignore all other keys in edit mode
return;
}
// ========== NAVIGATION MODE ==========
// Q to quit game
if (k === "q" || k === "Q") {
recordKey(k);
ctx.stopGame();
return;
}
// R to replace (only works on user-filled cells)
if (k === "r" || k === "R") {
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 is empty (use I to insert)
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
return;
}
setEditMode("edit");
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);
resetCount();
return;
}
// Numeric prefixes (for counted motions)
if (/^[0-9]$/.test(k) && k !== "0") {
recordKey(k);
countRef.current += k;
return;
}
// Vim-style navigation
let dr = 0;
let dc = 0;
if (k === "h") dc = -1;
else if (k === "l") dc = 1;
else if (k === "j") dr = 1;
else if (k === "k") dr = -1;
else if (k === "0" || k === "^") dc = -999; // go to col 0
else if (k === "$") dc = 999; // go to col 8
else if (k === ".") {
recordKey(".");
const last = lastMotionRef.current;
if (last) ctx.moveCursor(last.dr, last.dc, last.steps);
return;
} else return;
recordKey(k);
const steps = Math.max(1, parseInt(countRef.current || "1", 10));
resetCount();
if (dc === -999) {
ctx.moveCursor(0, -1, 99); // move far left
lastMotionRef.current = { dr: 0, dc: -1, steps: 99 };
return;
}
if (dc === 999) {
ctx.moveCursor(0, 1, 99); // move far right
lastMotionRef.current = { dr: 0, dc: 1, steps: 99 };
return;
}
ctx.moveCursor(dr, dc, steps);
lastMotionRef.current = { dr, dc, steps };
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [ctx, editMode]);
// ✅ same interface as vim-maze useKeyBindings
return {
keyLog,
getLog,
clearLog,
resetCount,
countRef,
lastMotionRef,
editMode,
};
}
export type UseKeyBindingsType = ReturnType<typeof useKeyBindings>;
+87
View File
@@ -0,0 +1,87 @@
import { useRef, useCallback } from "react";
export function useRenderer() {
const containerRef = useRef<HTMLDivElement | null>(null);
const renderEmptyBoard = useCallback(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = "";
const sudokuDiv = document.createElement("div");
sudokuDiv.id = "sudoku";
const grid = document.createElement("div");
grid.className = "sudoku-grid";
for (let i = 0; i < 81; i++) {
const r = Math.floor(i / 9);
const c = i % 9;
const cell = document.createElement("div");
cell.className = "sudoku-cell";
cell.dataset.r = String(r);
cell.dataset.c = String(c);
cell.textContent = "";
grid.appendChild(cell);
}
sudokuDiv.appendChild(grid);
container.appendChild(sudokuDiv);
}, []);
const renderBoard = useCallback((board: string, initialBoard?: string) => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = "";
const sudokuDiv = document.createElement("div");
sudokuDiv.id = "sudoku";
const grid = document.createElement("div");
grid.className = "sudoku-grid";
for (let i = 0; i < 81; i++) {
const r = Math.floor(i / 9);
const c = i % 9;
const cell = document.createElement("div");
cell.className = "sudoku-cell";
cell.dataset.r = String(r);
cell.dataset.c = String(c);
cell.textContent = board[i] === "." ? "" : board[i];
if (initialBoard && board[i] !== "." && board[i] !== initialBoard[i]) {
cell.classList.add("user-entered");
}
grid.appendChild(cell);
}
sudokuDiv.appendChild(grid);
container.appendChild(sudokuDiv);
}, []);
const highlightCursor = useCallback((r: number, c: number, isEditing = false) => {
const container = containerRef.current;
if (!container) return;
const sudokuDiv = container.querySelector("#sudoku");
if (!sudokuDiv) return;
sudokuDiv.querySelectorAll(".active").forEach((el) => el.classList.remove("active"));
sudokuDiv.querySelectorAll(".editing").forEach((el) => el.classList.remove("editing"));
const selector = `.sudoku-cell[data-r="${r}"][data-c="${c}"]`;
const cell = sudokuDiv.querySelector(selector);
if (cell) {
cell.classList.add("active");
if (isEditing) {
cell.classList.add("editing");
}
}
}, []);
return { containerRef, renderEmptyBoard, renderBoard, highlightCursor };
}
export type UseRenderer = ReturnType<typeof useRenderer>;
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type UseTimerReturn = {
timeValue: number;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
};
export type UseTimerOptions = {
interval?: number;
};
export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
const { interval = 100 } = options;
const [timeValue, setTimeValue] = useState(0);
const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
const offsetRef = useRef(0);
const startTimeRef = useRef<number | null>(null);
const update = useCallback(() => {
if (startTimeRef.current === null) {
return;
}
const now = performance.now();
setTimeValue(offsetRef.current + (now - startTimeRef.current));
}, []);
const startTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
return;
}
if (startTimeRef.current === null) {
startTimeRef.current = performance.now();
}
update();
intervalIdRef.current = setInterval(update, Math.max(16, interval));
}, [interval, update]);
const stopTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
if (startTimeRef.current !== null) {
update();
offsetRef.current += performance.now() - startTimeRef.current;
startTimeRef.current = null;
}
}, [update]);
const resetTimer = useCallback(() => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
intervalIdRef.current = null;
}
startTimeRef.current = null;
offsetRef.current = 0;
setTimeValue(0);
}, []);
useEffect(() => {
return () => {
if (intervalIdRef.current !== null) {
clearInterval(intervalIdRef.current);
}
};
}, []);
return {
timeValue,
startTimer,
stopTimer,
resetTimer,
};
}
+1
View File
@@ -0,0 +1 @@
export * from './useScore';
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { useTimer } from "./hooks/useTimer";
import { useScoreTime } from "./useScoreTime";
import type { GameStatus } from "../types";
const TIME_LIMIT_MS = Infinity; // No time limit by default
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
type GameScoreContext = {
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
};
type UseScoreParams = {
gameContext: GameScoreContext;
keystrokeCount: number;
board: string;
isComplete: (board: string) => boolean;
isFullyValid: (board: string) => boolean;
initialBoard: string;
};
export function useScore({
gameContext,
keystrokeCount,
board,
isComplete,
isFullyValid,
initialBoard
}: UseScoreParams) {
const timer = useTimer();
const { gameStatus, setGameStatus } = gameContext;
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
const [finalScore, setFinalScore] = useState<number | null>(null);
const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
const actualMoves = keystrokeCount;
const efficiency = minMovesRequired > 0
? Math.round((actualMoves / minMovesRequired) * 100)
: 0;
useScoreTime({ gameStatus, timer });
useEffect(() => {
if (gameStatus !== "started") return;
if (isComplete(board) && isFullyValid(board)) {
setGameStatus("game-won");
return;
}
if (timeValue >= TIME_LIMIT_MS) {
setGameStatus("game-over");
return;
}
if (keystrokeCount >= KEYSTROKE_LIMIT) {
setGameStatus("game-over");
return;
}
// TODO: Enable efficiency-based game-over in future
// if (efficiency > 200) {
// setGameStatus("game-over");
// return;
// }
}, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
useEffect(() => {
const handleWin = () => {
if (gameStatus === "started") {
setGameStatus("game-won");
}
};
window.addEventListener("sudoku-game-won", handleWin);
return () => window.removeEventListener("sudoku-game-won", handleWin);
}, [gameStatus, setGameStatus]);
useEffect(() => {
if (gameStatus !== "game-won") return;
const timeInSeconds = timeValue / 1000;
const baseScore = 100000;
const timePenalty = Math.floor(timeInSeconds * 10);
const extraMoves = Math.max(0, actualMoves - minMovesRequired);
const movePenalty = extraMoves * 50;
const score = Math.max(0, baseScore - timePenalty - movePenalty);
setFinalScore(score);
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
return {
timeValue,
startTimer,
stopTimer,
resetTimer,
finalScore,
timeLimit: TIME_LIMIT_MS,
keystrokeLimit: KEYSTROKE_LIMIT,
minMovesRequired,
actualMoves,
efficiency,
};
}
export type UseScoreType = ReturnType<typeof useScore>;
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useRef } from "react";
import type { UseTimerReturn } from "./hooks/useTimer";
import type { GameStatus } from "../types";
type UseScoreTimeParams = {
gameStatus: GameStatus;
timer: UseTimerReturn;
};
export function useScoreTime({ gameStatus, timer }: UseScoreTimeParams) {
const prevStatusRef = useRef<GameStatus | null>(null);
const { startTimer, stopTimer, resetTimer } = timer;
useEffect(() => {
prevStatusRef.current = gameStatus;
switch (gameStatus) {
case "started":
resetTimer();
startTimer();
break;
case "game-over":
case "game-won":
case "waiting":
stopTimer();
break;
}
}, [gameStatus, startTimer, stopTimer, resetTimer]);
}