feat: prepare vim-sudoku for unified API implementation

- Archive previous implementation attempt (src -> src_failed)
- Create comprehensive IMPLEMENTATION_PLAN.md with phased approach
- Create IMPLEMENTATION.md to track implementation progress
- Update example app to match vim-maze patterns:
  - Simplify UI to desktop-only (no mobile support)
  - Add score metrics display (time, keystrokes, final score)
  - Update instructions for sudoku-specific keys (hjkl, i for edit)
  - Remove unnecessary mobile hooks

Phase 5 complete. Ready to begin Phase 10 (Setup & Core Types).
This commit is contained in:
2025-10-22 18:08:33 +01:00
parent d0a51d7079
commit 9292b83dd0
25 changed files with 368 additions and 85 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);
}
}
+32
View File
@@ -0,0 +1,32 @@
export { useGame } from './useGame';
export type { GameManager, GameOptions } from './types';
export { useBoard } from './useBoard';
export { useCursor } from './useCursor';
export { useGameStatus } from './useGameStatus';
export { useScore } from './useScore';
export type {
Coord,
SudokuBoard,
SudokuCell,
Difficulty,
BoardManager
} from './useBoard/types';
export type {
CursorManager,
CursorMode,
GameKeyManager,
KeyLogEntry
} from './useCursor/types';
export type {
GameStatus,
GameStatusManager
} from './useGameStatus/types';
export type {
ScoreManager,
TimerManager
} from './useScore/types';
+35
View File
@@ -0,0 +1,35 @@
import type { RefObject } from 'react';
export type { Coord, SudokuBoard, SudokuCell, Difficulty } from './useBoard/types';
export type { CursorManager, CursorMode, GameKeyManager, KeyLogEntry } from './useCursor/types';
export type { GameStatus, GameStatusManager } from './useGameStatus/types';
export type { ScoreManager, TimerManager } from './useScore/types';
export type GameOptions = {
difficulty?: Difficulty;
};
export type GameManager = {
containerRef: RefObject<HTMLDivElement | null>;
renderBoard: () => void;
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
cursor: CursorManager;
scoreManager: ScoreManager;
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
board: {
generateBoard: (difficulty: Difficulty) => void;
solveBoard: () => boolean;
resetBoard: () => void;
getCell: (row: number, col: number) => any;
setCell: (row: number, col: number, value: string) => boolean;
canEditCell: (row: number, col: number) => boolean;
isComplete: () => boolean;
isFullyValid: () => boolean;
};
};
+316
View File
@@ -0,0 +1,316 @@
export type DifficultyName = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
export type Difficulty = DifficultyName | number;
const DIGITS = '123456789';
const ROWS = 'ABCDEFGHI';
const COLS = DIGITS;
let SQUARES: string[] = [];
let UNITS: string[][] = [];
let SQUARE_UNITS_MAP: Record<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,
};
const BLANK_CHAR = '.';
const BLANK_BOARD = BLANK_CHAR.repeat(NR_SQUARES);
function initialize() {
SQUARES = cross(ROWS, COLS);
UNITS = getAllUnits(ROWS, COLS);
SQUARE_UNITS_MAP = getSquareUnitsMap(SQUARES, UNITS);
SQUARE_PEERS_MAP = getSquarePeersMap(SQUARES, SQUARE_UNITS_MAP);
}
function cross(a: string, b: string) {
const res: string[] = [];
for (const ai of a) {
for (const bi of b) {
res.push(ai + bi);
}
}
return res;
}
function inStr(v: string, seq: string) {
return seq.indexOf(v) !== -1;
}
function shuffle<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;
}
function randRange(max: number, min = 0) {
return Math.floor(Math.random() * (max - min)) + min;
}
function stripDups<T>(seq: T[]): T[] {
return Array.from(new Set(seq));
}
function forceRange(nr: number, max: number, min = 0) {
return Math.min(Math.max(nr || 0, min), max);
}
function getAllUnits(rows: string, cols: string) {
const units: string[][] = [];
for (const r of rows) units.push(cross(r, cols));
for (const c of cols) units.push(cross(rows, c));
const row_blocks = ['ABC', 'DEF', 'GHI'];
const col_blocks = ['123', '456', '789'];
for (const rb of row_blocks) {
for (const cb of col_blocks) {
units.push(cross(rb, cb));
}
}
return units;
}
function getSquareUnitsMap(squares: string[], units: string[][]) {
const map: Record<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;
}
function getSquarePeersMap(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;
}
function getSquareValsMap(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;
}
function validateBoard(board: string): true | string {
if (!board) return 'Empty board';
if (board.length !== NR_SQUARES) return 'Invalid board size';
for (let i = 0; i < board.length; i++) {
const c = board[i];
if (!inStr(c, DIGITS) && c !== BLANK_CHAR) {
return `Invalid char at ${i}: ${c}`;
}
}
return true;
}
function getCandidatesMap(board: string): Record<string, string> | false {
const report = validateBoard(board);
if (report !== true) throw report;
const map: Record<string, string> = {};
const squares_values = getSquareValsMap(board);
for (const s of SQUARES) map[s] = DIGITS;
for (const s in squares_values) {
const val = squares_values[s];
if (inStr(val, DIGITS)) {
const new_candidates = assign(map, s, val);
if (!new_candidates) return false;
}
}
return map;
}
function assign(candidates: Record<string, string>, square: string, val: string) {
const others = candidates[square].replace(val, '');
for (const v of others) {
const next = eliminate(candidates, square, v);
if (!next) return false;
}
return candidates;
}
function eliminate(candidates: Record<string, string>, square: string, val: string) {
if (!inStr(val, candidates[square])) return candidates;
candidates[square] = candidates[square].replace(val, '');
const n = candidates[square].length;
if (n === 1) {
const target_val = candidates[square];
for (const peer of SQUARE_PEERS_MAP[square]) {
const next = eliminate(candidates, peer, target_val);
if (!next) return false;
}
} else if (n === 0) return false;
for (const unit of SQUARE_UNITS_MAP[square]) {
const places: string[] = [];
for (const sq of unit) {
if (inStr(val, candidates[sq])) places.push(sq);
}
if (places.length === 0) return false;
else if (places.length === 1) {
const next = assign(candidates, places[0], val);
if (!next) return false;
}
}
return candidates;
}
function search(candidates: Record<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 = search(assign(copy, min_square, v), reverse);
if (next) return next;
}
} else {
for (let i = min_candidates.length - 1; i >= 0; --i) {
const v = min_candidates[i];
const copy = JSON.parse(JSON.stringify(candidates));
const next = search(assign(copy, min_square, v), reverse);
if (next) return next;
}
}
return false;
}
export class SudokuGenerator {
static generate(difficulty: Difficulty, unique?: boolean): string {
let numericDifficulty: number;
if (typeof difficulty === 'string' || typeof difficulty === 'undefined') {
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
} else {
numericDifficulty = difficulty;
}
numericDifficulty = forceRange(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
unique = unique ?? true;
const blank_board = BLANK_BOARD;
let candidates = getCandidatesMap(blank_board);
if (!candidates) return SudokuGenerator.generate(numericDifficulty);
const shuffled_squares = shuffle(SQUARES);
for (const square of shuffled_squares) {
const rand_candidate_idx = randRange(candidates[square].length);
const rand_candidate = candidates[square][rand_candidate_idx];
if (!assign(candidates, square, rand_candidate)) break;
const single_candidates: string[] = [];
for (const s of SQUARES) {
if (candidates[s].length === 1) single_candidates.push(candidates[s]);
}
if (
single_candidates.length >= numericDifficulty &&
stripDups(single_candidates).length >= 8
) {
let board = '';
let givens_idxs: number[] = [];
for (let i = 0; i < SQUARES.length; i++) {
const s = SQUARES[i];
if (candidates[s].length === 1) {
board += candidates[s];
givens_idxs.push(i);
} else {
board += BLANK_CHAR;
}
}
if (givens_idxs.length > numericDifficulty) {
givens_idxs = shuffle(givens_idxs);
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
const target = givens_idxs[i];
board =
board.substring(0, target) +
BLANK_CHAR +
board.substring(target + 1);
}
}
if (SudokuGenerator.solve(board)) return board;
}
}
return SudokuGenerator.generate(numericDifficulty);
}
static solve(board: string, reverse?: boolean): string | false {
const report = validateBoard(board);
if (report !== true) throw report;
let nr_givens = 0;
for (const ch of board) {
if (ch !== BLANK_CHAR && inStr(ch, DIGITS)) nr_givens++;
}
if (nr_givens < MIN_GIVENS) throw 'Too few givens';
reverse = reverse || false;
const candidates = getCandidatesMap(board);
if (!candidates) return false;
const result = search(candidates, reverse);
if (result) {
let solution = '';
for (const s of SQUARES) solution += result[s];
return solution;
}
return false;
}
}
initialize();
+110
View File
@@ -0,0 +1,110 @@
import type { SudokuBoard, SudokuCell } from './types';
export class SudokuRenderer {
static renderBoard(container: HTMLElement, board: SudokuBoard): void {
const sudokuDiv = document.createElement('div');
sudokuDiv.id = 'sudoku';
sudokuDiv.className = 'sudoku-board';
for (let row = 0; row < 9; row++) {
const rowDiv = document.createElement('div');
rowDiv.className = 'sudoku-row';
for (let col = 0; col < 9; col++) {
const cell = board[row][col];
const cellDiv = document.createElement('div');
cellDiv.className = 'sudoku-cell';
cellDiv.dataset.row = row.toString();
cellDiv.dataset.col = col.toString();
cellDiv.textContent = cell.value || '';
if (cell.isGiven) {
cellDiv.classList.add('given');
}
if (cell.hasConflict) {
cellDiv.classList.add('conflict');
}
if (col === 2 || col === 5) {
cellDiv.classList.add('border-right');
}
if (row === 2 || row === 5) {
cellDiv.classList.add('border-bottom');
}
rowDiv.appendChild(cellDiv);
}
sudokuDiv.appendChild(rowDiv);
}
container.innerHTML = '';
container.appendChild(sudokuDiv);
}
static renderEmptyBoard(container: HTMLElement): void {
const emptyBoard: SudokuBoard = Array(9).fill(null).map(() =>
Array(9).fill(null).map(() => ({
value: '',
isGiven: false,
hasConflict: false
}))
);
SudokuRenderer.renderBoard(container, emptyBoard);
}
static updateCell(container: HTMLElement, row: number, col: number, cell: SudokuCell): void {
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (!cellEl) return;
cellEl.textContent = cell.value || '';
cellEl.classList.toggle('given', cell.isGiven);
cellEl.classList.toggle('conflict', cell.hasConflict);
}
static highlightCursor(container: HTMLElement, row: number, col: number, editMode: boolean): void {
const cells = container.querySelectorAll('.sudoku-cell');
cells.forEach(cell => cell.classList.remove('cursor', 'edit-mode'));
const cursorCell = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cursorCell) {
cursorCell.classList.add('cursor');
if (editMode) {
cursorCell.classList.add('edit-mode');
}
}
}
static flashCell(container: HTMLElement, row: number, col: number, className: string, duration: number = 300): void {
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (!cellEl) return;
cellEl.classList.add(className);
setTimeout(() => cellEl.classList.remove(className), duration);
}
static highlightConflicts(container: HTMLElement, conflictIndices: number[], duration: number = 2000): void {
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('conflict');
}
});
setTimeout(() => {
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.remove('conflict');
}
});
}, duration);
}
}
+4
View File
@@ -0,0 +1,4 @@
export { useBoard } from './useBoard';
export type { BoardManager, SudokuBoard, SudokuCell, Coord, Difficulty } from './types';
export { SudokuGenerator } from './SudokuGenerator';
export { SudokuRenderer } from './SudokuRenderer';
+31
View File
@@ -0,0 +1,31 @@
import type { RefObject } from 'react';
export type Coord = { row: number; col: number };
export type SudokuCell = {
value: string;
isGiven: boolean;
hasConflict: boolean;
};
export type SudokuBoard = SudokuCell[][];
export type Difficulty = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
export type BoardManager = {
containerRef: RefObject<HTMLDivElement | null>;
boardRef: RefObject<SudokuBoard>;
initialBoardRef: RefObject<string>;
solutionRef: RefObject<string | null>;
renderBoard: () => void;
generateBoard: (difficulty: Difficulty) => void;
getCell: (row: number, col: number) => SudokuCell | null;
setCell: (row: number, col: number, value: string) => boolean;
canEditCell: (row: number, col: number) => boolean;
isValidPlacement: (row: number, col: number, digit: string) => boolean;
getConflicts: (row: number, col: number) => number[];
isComplete: () => boolean;
isFullyValid: () => boolean;
solveBoard: () => boolean;
resetBoard: () => void;
};
+240
View File
@@ -0,0 +1,240 @@
import { useRef, useState, useCallback } from 'react';
import type { RefObject } from 'react';
import type { BoardManager, SudokuBoard, SudokuCell, Difficulty } from './types';
import { SudokuGenerator } from './SudokuGenerator';
import { SudokuRenderer } from './SudokuRenderer';
export function useBoard(): BoardManager {
const containerRef = useRef<HTMLDivElement | null>(null);
const boardRef = useRef<SudokuBoard>([]);
const initialBoardRef = useRef<string>('');
const solutionRef = useRef<string | null>(null);
const initializeBoard = useCallback((boardString: string): SudokuBoard => {
const board: SudokuBoard = [];
for (let row = 0; row < 9; row++) {
const boardRow: SudokuCell[] = [];
for (let col = 0; col < 9; col++) {
const index = row * 9 + col;
const value = boardString[index];
boardRow.push({
value: value === '.' ? '' : value,
isGiven: value !== '.',
hasConflict: false
});
}
board.push(boardRow);
}
return board;
}, []);
const getCell = useCallback((row: number, col: number): SudokuCell | null => {
const board = boardRef.current;
if (row < 0 || row >= 9 || col < 0 || col >= 9) return null;
return board[row]?.[col] || null;
}, []);
const canEditCell = useCallback((row: number, col: number): boolean => {
const cell = getCell(row, col);
return cell ? !cell.isGiven : false;
}, [getCell]);
const isValidPlacement = useCallback((boardString: string, row: number, col: number, digit: string): boolean => {
if (digit === '.') return true;
for (let c = 0; c < 9; c++) {
const checkIdx = row * 9 + c;
if (c !== col && boardString[checkIdx] === digit) {
return false;
}
}
for (let r = 0; r < 9; r++) {
const checkIdx = r * 9 + col;
if (r !== row && boardString[checkIdx] === digit) {
return false;
}
}
const boxRow = Math.floor(row / 3) * 3;
const boxCol = Math.floor(col / 3) * 3;
for (let r = boxRow; r < boxRow + 3; r++) {
for (let c = boxCol; c < boxCol + 3; c++) {
const checkIdx = r * 9 + c;
if ((r !== row || c !== col) && boardString[checkIdx] === digit) {
return false;
}
}
}
return true;
}, []);
const getConflicts = useCallback((boardString: string, row: number, col: number): number[] => {
const conflicts: number[] = [];
const digit = boardString[row * 9 + col];
if (digit === '.') return conflicts;
for (let c = 0; c < 9; c++) {
if (c !== col && boardString[row * 9 + c] === digit) {
conflicts.push(row * 9 + c);
}
}
for (let r = 0; r < 9; r++) {
if (r !== row && boardString[r * 9 + col] === digit) {
conflicts.push(r * 9 + col);
}
}
const boxRow = Math.floor(row / 3) * 3;
const boxCol = Math.floor(col / 3) * 3;
for (let r = boxRow; r < boxRow + 3; r++) {
for (let c = boxCol; c < boxCol + 3; c++) {
const idx = r * 9 + c;
if ((r !== row || c !== col) && boardString[idx] === digit) {
if (!conflicts.includes(idx)) {
conflicts.push(idx);
}
}
}
}
return conflicts;
}, []);
const isFullyValid = useCallback((boardString: string): boolean => {
for (let r = 0; r < 9; r++) {
let rowSum = 0;
for (let c = 0; c < 9; c++) {
const digit = boardString[r * 9 + c];
if (digit === '.') return false;
rowSum += parseInt(digit, 10);
}
if (rowSum !== 45) return false;
}
for (let c = 0; c < 9; c++) {
let colSum = 0;
for (let r = 0; r < 9; r++) {
const digit = boardString[r * 9 + c];
colSum += parseInt(digit, 10);
}
if (colSum !== 45) return false;
}
for (let boxRow = 0; boxRow < 3; boxRow++) {
for (let boxCol = 0; boxCol < 3; boxCol++) {
let boxSum = 0;
for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
const digit = boardString[r * 9 + c];
boxSum += parseInt(digit, 10);
}
}
if (boxSum !== 45) return false;
}
}
return true;
}, []);
const isComplete = useCallback((boardString: string): boolean => {
return !boardString.includes('.');
}, []);
const boardToString = useCallback((board: SudokuBoard): string => {
return board.map(row =>
row.map(cell => cell.value || '.').join('')
).join('');
}, []);
const setCell = useCallback((row: number, col: number, value: string): boolean => {
if (!canEditCell(row, col)) return false;
const board = boardRef.current;
const boardString = boardToString(board);
const newBoardString = boardString.substring(0, row * 9 + col) + value + boardString.substring(row * 9 + col + 1);
if (!isValidPlacement(newBoardString, row, col, value)) {
return false;
}
board[row][col] = {
value: value === '.' ? '' : value,
isGiven: board[row][col].isGiven,
hasConflict: false
};
return true;
}, [canEditCell, isValidPlacement, boardToString]);
const generateBoard = useCallback((difficulty: Difficulty): void => {
const newBoardString = SudokuGenerator.generate(difficulty);
const solution = SudokuGenerator.solve(newBoardString);
initialBoardRef.current = newBoardString;
solutionRef.current = solution;
boardRef.current = initializeBoard(newBoardString);
}, [initializeBoard]);
const solveBoard = useCallback((): boolean => {
const solution = solutionRef.current;
if (!solution) return false;
boardRef.current = initializeBoard(solution);
return true;
}, [initializeBoard]);
const resetBoard = useCallback((): void => {
const initialBoard = initialBoardRef.current;
if (initialBoard) {
boardRef.current = initializeBoard(initialBoard);
}
}, [initializeBoard]);
const renderBoard = useCallback((): void => {
const container = containerRef.current;
const board = boardRef.current;
if (!container || board.length === 0) {
if (container) {
SudokuRenderer.renderEmptyBoard(container);
}
return;
}
SudokuRenderer.renderBoard(container, board);
}, []);
return {
containerRef,
boardRef,
initialBoardRef,
solutionRef,
renderBoard,
generateBoard,
getCell,
setCell,
canEditCell,
isValidPlacement: (row, col, digit) => {
const boardString = boardToString(boardRef.current);
return isValidPlacement(boardString, row, col, digit);
},
getConflicts: (row, col) => {
const boardString = boardToString(boardRef.current);
return getConflicts(boardString, row, col);
},
isComplete: () => {
const boardString = boardToString(boardRef.current);
return isComplete(boardString);
},
isFullyValid: () => {
const boardString = boardToString(boardRef.current);
return isFullyValid(boardString);
},
solveBoard,
resetBoard
};
}
+12
View File
@@ -0,0 +1,12 @@
export { useCursor } from './useCursor';
export { useVimMotions } from './useVimMotions';
export type {
CursorManager,
CursorMode,
Coord,
Motion,
VimMotionSystem,
GameKeyManager,
KeyLogEntry,
CursorRenderer
} from './types';
+63
View File
@@ -0,0 +1,63 @@
import type { RefObject } from 'react';
import type { BoardManager } from '../useBoard/types';
export type Coord = { row: number; col: number };
export type CursorMode = 'normal' | 'edit';
export type Motion = {
dr: number;
dc: number;
steps: number;
};
export type CursorManager = {
position: () => Coord;
mode: () => CursorMode;
move: (dRows: number, dCols: number, count: number) => void;
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
moveToStart: () => void;
moveToEnd: () => void;
moveToTop: () => void;
moveToBottom: () => void;
repeatLastMotion: () => void;
resetCount: () => void;
getCount: () => string;
hasCount: () => boolean;
setCount: (digit: string) => void;
setLastKey: (key: string) => void;
getLastKey: () => string;
setMode: (mode: CursorMode) => void;
flashInvalid: () => void;
flashValid: () => void;
flashLocked: () => void;
highlightConflicts: (conflictIndices: number[]) => void;
};
export type VimMotionSystem = {
processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count' | 'digit' | 'erase' | 'mode', data: any } | null;
resetCount: () => void;
getCount: () => number;
hasCount: () => boolean;
setLastMotion: (motion: Motion) => void;
repeatLastMotion: () => Motion | null;
};
export type GameKeyManager = {
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
editMode: CursorMode;
setEditMode: (mode: CursorMode) => void;
};
export type KeyLogEntry = { key: string; timestamp: number };
export type CursorRenderer = {
highlightCursor: (row: number, col: number, editMode: boolean) => void;
flashCell: (row: number, col: number, className: string, duration?: number) => void;
highlightConflicts: (conflictIndices: number[], duration?: number) => void;
};
+275
View File
@@ -0,0 +1,275 @@
import { useState, useCallback, useEffect } from 'react';
import type { BoardManager } from '../useBoard/types';
import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
import { useVimMotions } from './useVimMotions';
export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
const [mode, setMode] = useState<CursorMode>('normal');
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
const vimMotions = useVimMotions();
const addKeyToLog = useCallback((key: string) => {
setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
}, []);
const move = useCallback((dRows: number, dCols: number, count: number) => {
setPosition(prev => {
const newRow = prev.row + (dRows * count);
const newCol = prev.col + (dCols * count);
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
flashInvalid();
return prev;
}
return { row: newRow, col: newCol };
});
}, []);
const moveLeft = useCallback((count = 1) => {
move(0, -1, count);
}, [move]);
const moveRight = useCallback((count = 1) => {
move(0, 1, count);
}, [move]);
const moveUp = useCallback((count = 1) => {
move(-1, 0, count);
}, [move]);
const moveDown = useCallback((count = 1) => {
move(1, 0, count);
}, [move]);
const moveToStart = useCallback(() => {
setPosition(prev => ({ ...prev, col: 0 }));
}, []);
const moveToEnd = useCallback(() => {
setPosition(prev => ({ ...prev, col: 8 }));
}, []);
const moveToTop = useCallback(() => {
setPosition({ row: 0, col: 0 });
}, []);
const moveToBottom = useCallback(() => {
setPosition({ row: 8, col: 0 });
}, []);
const repeatLastMotion = useCallback(() => {
const lastMotion = vimMotions.repeatLastMotion();
if (lastMotion) {
move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
}
}, [vimMotions, move]);
const resetCount = useCallback(() => {
vimMotions.resetCount();
}, [vimMotions]);
const getCount = useCallback(() => {
return vimMotions.getCount().toString();
}, [vimMotions]);
const hasCount = useCallback(() => {
return vimMotions.hasCount();
}, [vimMotions]);
const setCount = useCallback((digit: string) => {
vimMotions.processKey(digit);
}, [vimMotions]);
const setLastKey = useCallback((key: string) => {
addKeyToLog(key);
}, [addKeyToLog]);
const getLastKey = useCallback(() => {
return keyLog[keyLog.length - 1]?.key || '';
}, [keyLog]);
const flashInvalid = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('invalid-move');
setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
}
}, [board, position]);
const flashValid = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('valid-move');
setTimeout(() => cellEl.classList.remove('valid-move'), 300);
}
}, [board, position]);
const flashLocked = useCallback(() => {
const container = board.containerRef.current;
if (!container) return;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('locked-cell');
setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
}
}, [board, position]);
const highlightConflicts = useCallback((conflictIndices: number[]) => {
const container = board.containerRef.current;
if (!container) return;
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('conflict');
}
});
setTimeout(() => {
conflictIndices.forEach((idx) => {
const row = Math.floor(idx / 9);
const col = idx % 9;
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.remove('conflict');
}
});
}, 2000);
}, [board]);
const clearKeyLog = useCallback(() => {
setKeyLog([]);
}, []);
const getKeyLog = useCallback(() => {
return [...keyLog];
}, [keyLog]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key;
const result = vimMotions.processKey(key);
if (!result) return;
addKeyToLog(key);
switch (result.type) {
case 'move':
move(result.data.dr, result.data.dc, result.data.steps);
break;
case 'anchor':
switch (result.data) {
case 'start':
moveToStart();
break;
case 'end':
moveToEnd();
break;
case 'top':
moveToTop();
break;
case 'bottom':
moveToBottom();
break;
}
break;
case 'repeat':
if (result.data) {
move(result.data.dr, result.data.dc, result.data.steps);
}
break;
case 'mode':
setMode(result.data);
break;
case 'digit':
if (mode === 'edit') {
const success = board.setCell(position.row, position.col, result.data);
if (success) {
flashValid();
} else if (!board.canEditCell(position.row, position.col)) {
flashLocked();
} else {
const conflicts = board.getConflicts(position.row, position.col);
if (conflicts.length > 0) {
highlightConflicts(conflicts);
}
flashInvalid();
}
}
break;
case 'erase':
if (mode === 'edit') {
const success = board.setCell(position.row, position.col, '.');
if (success) {
flashValid();
} else if (!board.canEditCell(position.row, position.col)) {
flashLocked();
} else {
flashInvalid();
}
}
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
const cursorManager: CursorManager = {
position: () => position,
mode: () => mode,
move,
moveLeft,
moveRight,
moveUp,
moveDown,
moveToStart,
moveToEnd,
moveToTop,
moveToBottom,
repeatLastMotion,
resetCount,
getCount,
hasCount,
setCount,
setLastKey,
getLastKey,
setMode,
flashInvalid,
flashValid,
flashLocked,
highlightConflicts
};
const keyManager: GameKeyManager = {
keyLog,
clearKeyLog,
getKeyLog,
editMode: mode,
setEditMode: setMode
};
return {
...cursorManager,
keyManager
};
}
+118
View File
@@ -0,0 +1,118 @@
import { useRef } from 'react';
import type { VimMotionSystem, Motion } from './types';
export function useVimMotions(): VimMotionSystem {
const countRef = useRef<string>('');
const lastMotionRef = useRef<Motion | null>(null);
const processKey = (key: string) => {
if (key >= '0' && key <= '9') {
if (key === '0' && countRef.current === '') {
return { type: 'anchor', data: 'start' };
}
countRef.current += key;
return { type: 'count', data: key };
}
const count = countRef.current ? parseInt(countRef.current, 10) : 1;
switch (key) {
case 'h':
const motionH = { dr: 0, dc: -1, steps: count };
lastMotionRef.current = motionH;
countRef.current = '';
return { type: 'move', data: motionH };
case 'j':
const motionJ = { dr: 1, dc: 0, steps: count };
lastMotionRef.current = motionJ;
countRef.current = '';
return { type: 'move', data: motionJ };
case 'k':
const motionK = { dr: -1, dc: 0, steps: count };
lastMotionRef.current = motionK;
countRef.current = '';
return { type: 'move', data: motionK };
case 'l':
const motionL = { dr: 0, dc: 1, steps: count };
lastMotionRef.current = motionL;
countRef.current = '';
return { type: 'move', data: motionL };
case '^':
countRef.current = '';
return { type: 'anchor', data: 'start' };
case '$':
countRef.current = '';
return { type: 'anchor', data: 'end' };
case 'g':
countRef.current = '';
return { type: 'anchor', data: 'top' };
case 'G':
countRef.current = '';
return { type: 'anchor', data: 'bottom' };
case '.':
const lastMotion = lastMotionRef.current;
if (lastMotion) {
return { type: 'repeat', data: lastMotion };
}
return null;
case 'i':
countRef.current = '';
return { type: 'mode', data: 'edit' };
case 'Escape':
countRef.current = '';
return { type: 'mode', data: 'normal' };
case 'x':
case 'Delete':
case 'Backspace':
countRef.current = '';
return { type: 'erase', data: null };
default:
if (key >= '1' && key <= '9') {
countRef.current = '';
return { type: 'digit', data: key };
}
return null;
}
};
const resetCount = () => {
countRef.current = '';
};
const getCount = () => {
return countRef.current ? parseInt(countRef.current, 10) : 1;
};
const hasCount = () => {
return countRef.current !== '';
};
const setLastMotion = (motion: Motion) => {
lastMotionRef.current = motion;
};
const repeatLastMotion = () => {
return lastMotionRef.current;
};
return {
processKey,
resetCount,
getCount,
hasCount,
setLastMotion,
repeatLastMotion
};
}
+101
View File
@@ -0,0 +1,101 @@
import { useEffect } from 'react';
import { useBoard } from './useBoard';
import { useCursor } from './useCursor';
import { useGameStatus } from './useGameStatus';
import { useScore } from './useScore';
import type { GameManager, GameOptions } from './types';
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
const board = useBoard();
const gameStatusManager = useGameStatus();
const { cursor, keyManager } = useCursor(board);
const scoreManager = useScore({
board,
gameStatusManager,
keystrokeCount: keyManager.keyLog.length
});
useEffect(() => {
board.generateBoard('medium');
}, [board]);
useEffect(() => {
const { gameStatus } = gameStatusManager;
if (gameStatus === 'waiting') {
board.renderBoard();
} else {
board.renderBoard();
const container = board.containerRef.current;
if (container) {
const pos = cursor.position();
const cellEl = container.querySelector(`.sudoku-cell[data-row="${pos.row}"][data-col="${pos.col}"]`) as HTMLElement;
if (cellEl) {
cellEl.classList.add('cursor');
if (cursor.mode() === 'edit') {
cellEl.classList.add('edit-mode');
}
}
}
}
}, [gameStatusManager.gameStatus, board, cursor]);
useEffect(() => {
const container = board.containerRef.current;
if (!container) return;
const handleCellClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const cellEl = target.closest('.sudoku-cell') as HTMLElement;
if (!cellEl) return;
const row = parseInt(cellEl.dataset.row || '0');
const col = parseInt(cellEl.dataset.col || '0');
const currentPos = cursor.position();
if (currentPos.row !== row || currentPos.col !== col) {
cursor.move(row - currentPos.row, col - currentPos.col, 1);
}
};
container.addEventListener('click', handleCellClick);
return () => container.removeEventListener('click', handleCellClick);
}, [board, cursor]);
const startGame = () => {
board.generateBoard('medium');
gameStatusManager.startGame();
};
const togglePause = (pause?: boolean) => {
gameStatusManager.togglePause(pause);
};
const quitGame = () => {
gameStatusManager.quitGame();
};
const gameManager: GameManager = {
containerRef: board.containerRef,
renderBoard: board.renderBoard,
gameStatus: gameStatusManager.gameStatus,
setGameStatus: gameStatusManager.setGameStatus,
startGame,
togglePause,
quitGame,
cursor,
scoreManager,
keyLog: keyManager.keyLog,
clearKeyLog: keyManager.clearKeyLog,
getKeyLog: keyManager.getKeyLog,
board,
solveBoard: board.solveBoard,
resetBoard: board.resetBoard
};
if (typeof platformHook === 'function') {
platformHook(gameManager);
}
return gameManager;
}
+2
View File
@@ -0,0 +1,2 @@
export { useGameStatus } from './useGameStatus';
export type { GameStatus, GameStatusManager } from './types';
+9
View File
@@ -0,0 +1,9 @@
export type GameStatus = 'waiting' | 'started' | 'paused' | 'game-over' | 'game-won';
export type GameStatusManager = {
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
};
+32
View File
@@ -0,0 +1,32 @@
import { useState, useCallback } from 'react';
import type { GameStatusManager, GameStatus } from './types';
export function useGameStatus(): GameStatusManager {
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
const startGame = useCallback(() => {
setGameStatus('started');
}, []);
const togglePause = useCallback((pause?: boolean) => {
setGameStatus(current => {
if (current === 'started' || current === 'paused') {
return pause !== undefined ? (pause ? 'paused' : 'started') :
(current === 'started' ? 'paused' : 'started');
}
return current;
});
}, []);
const quitGame = useCallback(() => {
setGameStatus('waiting');
}, []);
return {
gameStatus,
setGameStatus,
startGame,
togglePause,
quitGame
};
}
+3
View File
@@ -0,0 +1,3 @@
export { useScore } from './useScore';
export { useTimer } from './useTimer';
export type { ScoreManager, TimerManager, UseScoreProps } from './types';
+31
View File
@@ -0,0 +1,31 @@
import type { BoardManager } from '../useBoard/types';
import type { GameStatusManager } from '../useGameStatus/types';
export type TimerManager = {
timeValue: number;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
};
export type ScoreManager = {
timeValue: number;
keystrokes: number;
mistakes: number;
hints: number;
efficiency: number;
finalScore: number | null;
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
addMistake: () => void;
addHint: () => void;
calculateEfficiency: () => number;
calculateFinalScore: () => number | null;
};
export type UseScoreProps = {
board: BoardManager;
gameStatusManager: GameStatusManager;
keystrokeCount: number;
};
+96
View File
@@ -0,0 +1,96 @@
import { useState, useEffect, useCallback } from 'react';
import type { BoardManager } from '../useBoard/types';
import type { GameStatusManager } from '../useGameStatus/types';
import type { ScoreManager, UseScoreProps } from './types';
import { useTimer } from './useTimer';
export function useScore({ board, gameStatusManager, keystrokeCount }: UseScoreProps): ScoreManager {
const timer = useTimer();
const [mistakes, setMistakes] = useState<number>(0);
const [hints, setHints] = useState<number>(0);
const addMistake = useCallback(() => {
setMistakes(prev => prev + 1);
}, []);
const addHint = useCallback(() => {
setHints(prev => prev + 1);
}, []);
const calculateEfficiency = useCallback((): number => {
const boardString = board.boardRef.current.map(row =>
row.map(cell => cell.value || '.').join('')
).join('');
const filledCells = boardString.replace(/\./g, '').length;
const totalCells = 81;
const givenCells = board.initialBoardRef.current.replace(/\./g, '').length;
const userFilledCells = filledCells - givenCells;
if (userFilledCells === 0) return 100;
const optimalMoves = userFilledCells;
const actualMoves = keystrokeCount;
const efficiency = Math.round((optimalMoves / actualMoves) * 100);
return Math.max(0, Math.min(100, efficiency));
}, [board, keystrokeCount]);
const calculateFinalScore = useCallback((): number | null => {
const { gameStatus } = gameStatusManager;
if (gameStatus !== 'game-won') return null;
const efficiency = calculateEfficiency();
const timeInSeconds = timer.timeValue / 1000;
const timeBonus = Math.max(0, 3600 - timeInSeconds) * 10;
const mistakePenalty = mistakes * 100;
const hintPenalty = hints * 200;
const baseScore = 10000;
const efficiencyBonus = efficiency * 50;
const finalScore = baseScore + efficiencyBonus + timeBonus - mistakePenalty - hintPenalty;
return Math.max(0, Math.round(finalScore));
}, [gameStatusManager, calculateEfficiency, timer.timeValue, mistakes, hints]);
useEffect(() => {
const { gameStatus } = gameStatusManager;
if (gameStatus === 'started') {
timer.startTimer();
} else if (gameStatus === 'paused' || gameStatus === 'game-over' || gameStatus === 'game-won') {
timer.stopTimer();
}
if (gameStatus === 'waiting') {
timer.resetTimer();
setMistakes(0);
setHints(0);
}
}, [gameStatusManager.gameStatus, timer]);
useEffect(() => {
const { gameStatus, setGameStatus } = gameStatusManager;
if (gameStatus === 'started' && board.isComplete() && board.isFullyValid()) {
setGameStatus('game-won');
}
}, [board, gameStatusManager]);
return {
timeValue: timer.timeValue,
keystrokes: keystrokeCount,
mistakes,
hints,
efficiency: calculateEfficiency(),
finalScore: calculateFinalScore(),
startTimer: timer.startTimer,
stopTimer: timer.stopTimer,
resetTimer: timer.resetTimer,
addMistake,
addHint,
calculateEfficiency,
calculateFinalScore
};
}
+45
View File
@@ -0,0 +1,45 @@
import { useState, useEffect, useRef } from 'react';
import type { TimerManager } from './types';
export function useTimer(): TimerManager {
const [timeValue, setTimeValue] = useState<number>(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number>(0);
const startTimer = () => {
if (intervalRef.current) return;
startTimeRef.current = Date.now() - timeValue;
intervalRef.current = setInterval(() => {
setTimeValue(Date.now() - startTimeRef.current);
}, 100);
};
const stopTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
const resetTimer = () => {
stopTimer();
setTimeValue(0);
startTimeRef.current = 0;
};
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return {
timeValue,
startTimer,
stopTimer,
resetTimer
};
}