working on useGame

This commit is contained in:
2025-10-15 17:29:53 +01:00
commit 8acc16fc1c
37 changed files with 3320 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export * from './useGame';
+55
View File
@@ -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>;
+52
View File
@@ -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>;
+88
View File
@@ -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;
}
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useState, useMemo } from "react";
import type { UseRenderer } from "./useSudokuRenderer";
import type { UseCursorType } from "./useCursor";
import type { GameStatus } from "../types";
import { getGamePhase, isPlaying } from "../types";
export type { GameStatus } from "../types";
export function useGameStatus(
rendererManager: UseRenderer,
cursorManager: UseCursorType
) {
const { containerRef } = rendererManager;
const { setCursor } = cursorManager;
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
const gamePhase = useMemo(() => getGamePhase(gameStatus), [gameStatus]);
const playStatus = useMemo(
() => (isPlaying(gameStatus) ? gameStatus : null),
[gameStatus]
);
const startGame = useCallback(() => {
setGameStatus("started");
}, []);
const stopGame = useCallback(() => {
setCursor({ r: 0, c: 0 });
setGameStatus("waiting");
const container = containerRef.current;
container?.querySelectorAll(".active").forEach((el) => el.classList.remove("active"));
}, [setCursor, containerRef]);
return {
gameStatus,
gamePhase,
playStatus,
setGameStatus,
startGame,
stopGame,
};
}
export type UseGameStatusType = ReturnType<typeof useGameStatus>;
+114
View File
@@ -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>;
+83
View File
@@ -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>;