working on useGame
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import './sudoku.css';
|
||||
export * from './types';
|
||||
export * from "./useGame";
|
||||
@@ -0,0 +1,371 @@
|
||||
// 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 {
|
||||
if (typeof difficulty === "string" || typeof difficulty === "undefined") {
|
||||
difficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
|
||||
}
|
||||
|
||||
difficulty = sudoku._force_range(difficulty, 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 >= difficulty &&
|
||||
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 > difficulty) {
|
||||
givens_idxs = sudoku._shuffle(givens_idxs);
|
||||
for (let i = 0; i < givens_idxs.length - difficulty; ++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(difficulty);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
let max_square = "";
|
||||
for (const s of SQUARES) {
|
||||
const n = candidates[s].length;
|
||||
if (n > max_nr_candidates) {
|
||||
max_nr_candidates = n;
|
||||
max_square = s;
|
||||
}
|
||||
}
|
||||
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 };
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/* =========================================================
|
||||
🔧 Controls (tweak these)
|
||||
========================================================= */
|
||||
:root {
|
||||
--cell-size: 32px;
|
||||
--cell-font-size: 16px;
|
||||
--grid-gap: 2px;
|
||||
--box-separator: 3px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Container & Grid Base
|
||||
========================================================= */
|
||||
#sudoku_container {
|
||||
position: relative;
|
||||
margin: 1em auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
#sudoku {
|
||||
position: relative;
|
||||
background-color: #1e2030; /* Tokyo Night card background */
|
||||
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: repeat(9, var(--cell-size));
|
||||
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: #2f334d; /* Tokyo Night muted */
|
||||
color: #c8d3f5; /* Tokyo Night foreground */
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
3x3 Box Separators
|
||||
========================================================= */
|
||||
/* Add thicker border to right of columns 2 and 5 (0-indexed) */
|
||||
#sudoku .sudoku-cell[data-c="2"],
|
||||
#sudoku .sudoku-cell[data-c="5"] {
|
||||
border-right: var(--box-separator) solid #1e2030;
|
||||
margin-right: 1px;
|
||||
}
|
||||
|
||||
/* Add thicker border to bottom of rows 2 and 5 (0-indexed) */
|
||||
#sudoku .sudoku-cell[data-r="2"],
|
||||
#sudoku .sudoku-cell[data-r="5"] {
|
||||
border-bottom: var(--box-separator) solid #1e2030;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
User-Entered Cells
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.user-entered {
|
||||
color: #82aaff; /* Tokyo Night primary blue */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Active Cell Highlight
|
||||
========================================================= */
|
||||
#sudoku .sudoku-cell.active {
|
||||
outline: 2px solid #ffc777; /* Tokyo Night yellow/warning */
|
||||
outline-offset: -2px;
|
||||
background: #3d3836;
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 199, 119, 0.7),
|
||||
0 0 24px rgba(255, 199, 119, 0.4),
|
||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
||||
animation: active-cell-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes active-cell-glow {
|
||||
0%, 100% {
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 199, 119, 0.7),
|
||||
0 0 24px rgba(255, 199, 119, 0.4),
|
||||
inset 0 0 12px rgba(255, 199, 119, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 20px rgba(255, 199, 119, 0.9),
|
||||
0 0 32px rgba(255, 199, 119, 0.6),
|
||||
inset 0 0 16px rgba(255, 199, 119, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Visual Feedback Effects
|
||||
========================================================= */
|
||||
/* Invalid move effect */
|
||||
#sudoku .sudoku-cell.invalid-move {
|
||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
||||
box-shadow:
|
||||
0 0 12px rgba(255, 117, 127, 0.85),
|
||||
0 0 24px rgba(255, 117, 127, 0.45);
|
||||
}
|
||||
|
||||
@keyframes sudoku-invalid-pulse {
|
||||
0% {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 0 16px rgba(255, 117, 127, 0.95),
|
||||
0 0 28px rgba(255, 117, 127, 0.55);
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Trail effect for moves */
|
||||
#sudoku .sudoku-cell.trail {
|
||||
box-shadow: inset 0 0 6px rgba(130, 170, 255, 0.6), 0 0 6px rgba(130, 170, 255, 0.35);
|
||||
animation: sudoku-trail-fade 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes sudoku-trail-fade {
|
||||
from { background-color: rgba(130, 170, 255, 0.25); }
|
||||
to { background-color: transparent; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useGame } from "./useGame";
|
||||
export type UseGameType = ReturnType<typeof useGame>;
|
||||
|
||||
export type {
|
||||
Difficulty,
|
||||
DifficultyName,
|
||||
BoardString,
|
||||
} from "./lib/Sudoku";
|
||||
|
||||
export type GamePhase = "idle" | "playing";
|
||||
export type PlayStatus = "started" | "game-over" | "game-won";
|
||||
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
|
||||
|
||||
export const isPlaying = (status: GameStatus): status is PlayStatus =>
|
||||
status === "started" || status === "game-over" || status === "game-won";
|
||||
|
||||
export const getGamePhase = (status: GameStatus): GamePhase =>
|
||||
status === "waiting" ? "idle" : "playing";
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useGame';
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { sudoku } from "../lib/Sudoku";
|
||||
import type { UseCursorType } from "./useCursor";
|
||||
|
||||
export function useBoardState(cursorManager: UseCursorType) {
|
||||
const { cursor, setCursor } = cursorManager;
|
||||
const [board, setBoard] = useState<string>(sudoku.generate("medium"));
|
||||
const [initialBoard, setInitialBoard] = useState<string>(board);
|
||||
const [solution, setSolution] = useState<string | null>(null);
|
||||
|
||||
const setDigit = useCallback(
|
||||
(d: string) => {
|
||||
const i = cursor.r * 9 + cursor.c;
|
||||
const arr = board.split("");
|
||||
arr[i] = d;
|
||||
setBoard(arr.join(""));
|
||||
},
|
||||
[cursor, board]
|
||||
);
|
||||
|
||||
const erase = useCallback(() => {
|
||||
const i = cursor.r * 9 + cursor.c;
|
||||
const arr = board.split("");
|
||||
arr[i] = ".";
|
||||
setBoard(arr.join(""));
|
||||
}, [cursor, board]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const newBoard = sudoku.generate("medium");
|
||||
setBoard(newBoard);
|
||||
setInitialBoard(newBoard);
|
||||
setSolution(null);
|
||||
setCursor({ r: 0, c: 0 });
|
||||
}, [setCursor]);
|
||||
|
||||
const solveBoard = useCallback(() => {
|
||||
const solved = sudoku.solve(board);
|
||||
if (solved) {
|
||||
setSolution(solved);
|
||||
setBoard(solved);
|
||||
}
|
||||
}, [board]);
|
||||
|
||||
return {
|
||||
board,
|
||||
initialBoard,
|
||||
solution,
|
||||
setDigit,
|
||||
erase,
|
||||
reset,
|
||||
solveBoard,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseBoardStateType = ReturnType<typeof useBoardState>;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef, useState, useCallback } 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 moveCursor = useCallback(
|
||||
(dr: number, dc: number, steps = 1) => {
|
||||
if (animatingRef.current) return;
|
||||
|
||||
setCursor((prev) => {
|
||||
let r = prev.r;
|
||||
let c = prev.c;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
r = Math.max(0, Math.min(8, r + dr));
|
||||
c = Math.max(0, Math.min(8, c + dc));
|
||||
}
|
||||
return { r, c };
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
cursor,
|
||||
setCursor,
|
||||
moveCursor,
|
||||
getCellEl,
|
||||
flashInvalid,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseCursorType = ReturnType<typeof useCursor>;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRenderer } from "./useSudokuRenderer";
|
||||
import { useCursor } from "./useCursor";
|
||||
import { useGameStatus } from "./useGameStatus";
|
||||
import { useBoardState } from "./useBoardState";
|
||||
import { useKeyBindings } from "./useKeyBindings";
|
||||
|
||||
export function useGame(platformHook?: unknown) {
|
||||
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);
|
||||
const { board, initialBoard, solution, setDigit, erase, reset, solveBoard } = boardManager;
|
||||
|
||||
// ---------------------------
|
||||
// Rendering
|
||||
// ---------------------------
|
||||
useEffect(() => {
|
||||
renderEmptyBoard();
|
||||
}, [renderEmptyBoard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus === "waiting") {
|
||||
renderEmptyBoard();
|
||||
} else {
|
||||
renderBoard(board, initialBoard);
|
||||
highlightCursor(cursor.r, cursor.c);
|
||||
}
|
||||
}, [gameStatus, board, initialBoard, cursor, renderEmptyBoard, renderBoard, highlightCursor]);
|
||||
|
||||
// ---------------------------
|
||||
// Keybindings
|
||||
// ---------------------------
|
||||
const keyBindings = useKeyBindings({
|
||||
moveCursor,
|
||||
setDigit,
|
||||
erase,
|
||||
});
|
||||
|
||||
// ---------------------------
|
||||
// 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,
|
||||
|
||||
// keybindings (vim-maze compatible)
|
||||
...keyBindings,
|
||||
};
|
||||
|
||||
// allow platform-level injection
|
||||
if (typeof platformHook === "function") {
|
||||
platformHook(fullGameManager);
|
||||
}
|
||||
|
||||
return fullGameManager;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type KeyLogEntry = { key: string; timestamp: number };
|
||||
|
||||
type Ctx = {
|
||||
moveCursor: (dr: number, dc: number, steps?: number) => void;
|
||||
setDigit: (d: string) => void;
|
||||
erase: () => void;
|
||||
};
|
||||
|
||||
export function useKeyBindings(ctx: Ctx) {
|
||||
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
||||
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;
|
||||
|
||||
// --- Digits 1–9 fill a cell ---
|
||||
if (/^[1-9]$/.test(k)) {
|
||||
recordKey(k);
|
||||
ctx.setDigit(k);
|
||||
resetCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Erase ---
|
||||
if (k === "x" || k === "Backspace") {
|
||||
recordKey(k);
|
||||
ctx.erase();
|
||||
resetCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Numeric prefixes ---
|
||||
if (/^[0-9]$/.test(k)) {
|
||||
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]);
|
||||
|
||||
// ✅ same interface as vim-maze useKeyBindings
|
||||
return {
|
||||
keyLog,
|
||||
getLog,
|
||||
clearLog,
|
||||
resetCount,
|
||||
countRef,
|
||||
lastMotionRef,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseKeyBindingsType = ReturnType<typeof useKeyBindings>;
|
||||
@@ -0,0 +1,83 @@
|
||||
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) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const sudokuDiv = container.querySelector("#sudoku");
|
||||
if (!sudokuDiv) return;
|
||||
|
||||
sudokuDiv.querySelectorAll(".active").forEach((el) => el.classList.remove("active"));
|
||||
|
||||
const selector = `.sudoku-cell[data-r="${r}"][data-c="${c}"]`;
|
||||
const cell = sudokuDiv.querySelector(selector);
|
||||
if (cell) {
|
||||
cell.classList.add("active");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { containerRef, renderEmptyBoard, renderBoard, highlightCursor };
|
||||
}
|
||||
|
||||
export type UseRenderer = ReturnType<typeof useRenderer>;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useScore';
|
||||
Reference in New Issue
Block a user