starting new structure
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './useGame';
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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>;
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user