diff --git a/src/sudoku.css b/src/sudoku.css index dc00012..9269640 100644 --- a/src/sudoku.css +++ b/src/sudoku.css @@ -57,18 +57,16 @@ /* ========================================================= 3x3 Box Separators ========================================================= */ -/* Add thicker border to right of columns 2 and 5 (0-indexed) */ +/* Add extra margin 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; + margin-right: var(--box-separator); } -/* Add thicker border to bottom of rows 2 and 5 (0-indexed) */ +/* Add extra margin 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; + margin-bottom: var(--box-separator); } /* ========================================================= @@ -80,20 +78,33 @@ } /* ========================================================= - Active Cell Highlight + Active Cell Highlight (Navigation Mode) ========================================================= */ #sudoku .sudoku-cell.active { - outline: 2px solid #ffc777; /* Tokyo Night yellow/warning */ + outline: 2px solid #82aaff; /* Tokyo Night blue */ + outline-offset: -2px; + background: #3d4269; + box-shadow: + 0 0 8px rgba(130, 170, 255, 0.4), + 0 0 12px rgba(130, 170, 255, 0.2), + inset 0 0 6px rgba(130, 170, 255, 0.15); +} + +/* ========================================================= + Editing Cell Highlight (Edit Mode) + ========================================================= */ +#sudoku .sudoku-cell.editing { + outline: 2px solid #ffc777; /* Tokyo Night yellow */ 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; + animation: editing-cell-glow 1.5s ease-in-out infinite; } -@keyframes active-cell-glow { +@keyframes editing-cell-glow { 0%, 100% { box-shadow: 0 0 16px rgba(255, 199, 119, 0.7), @@ -132,6 +143,35 @@ } } +/* 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 rgba(130, 170, 255, 0.6), 0 0 6px rgba(130, 170, 255, 0.35); diff --git a/src/useGame/useCursor.ts b/src/useGame/useCursor.ts index 3e65d30..7a53cb1 100644 --- a/src/useGame/useCursor.ts +++ b/src/useGame/useCursor.ts @@ -1,4 +1,4 @@ -import { useRef, useState, useCallback } from "react"; +import { useRef, useState, useCallback, useEffect } from "react"; import type { UseRenderer } from "./useSudokuRenderer"; export function useCursor(rendererManager: UseRenderer) { @@ -23,6 +23,13 @@ export function useCursor(rendererManager: UseRenderer) { 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 moveCursor = useCallback( (dr: number, dc: number, steps = 1) => { if (animatingRef.current) return; @@ -40,6 +47,12 @@ export function useCursor(rendererManager: UseRenderer) { [] ); + useEffect(() => { + const onLockedCell = () => flashLocked(); + window.addEventListener("sudoku-locked-cell", onLockedCell); + return () => window.removeEventListener("sudoku-locked-cell", onLockedCell); + }, [flashLocked]); + return { cursor, setCursor, diff --git a/src/useGame/useGame.ts b/src/useGame/useGame.ts index 2001d20..9fb64a3 100644 --- a/src/useGame/useGame.ts +++ b/src/useGame/useGame.ts @@ -15,6 +15,21 @@ export function useGame(platformHook?: unknown) { const boardManager = useBoardState(cursorManager); const { board, initialBoard, solution, setDigit, erase, reset, solveBoard } = boardManager; + // --------------------------- + // Keybindings + // --------------------------- + const keyBindings = useKeyBindings({ + moveCursor, + setDigit, + erase, + stopGame, + cursor, + board, + initialBoard, + }); + + const { editMode } = keyBindings; + // --------------------------- // Rendering // --------------------------- @@ -27,18 +42,9 @@ export function useGame(platformHook?: unknown) { renderEmptyBoard(); } else { renderBoard(board, initialBoard); - highlightCursor(cursor.r, cursor.c); + highlightCursor(cursor.r, cursor.c, editMode === "edit"); } - }, [gameStatus, board, initialBoard, cursor, renderEmptyBoard, renderBoard, highlightCursor]); - - // --------------------------- - // Keybindings - // --------------------------- - const keyBindings = useKeyBindings({ - moveCursor, - setDigit, - erase, - }); + }, [gameStatus, board, initialBoard, cursor, editMode, renderEmptyBoard, renderBoard, highlightCursor]); // --------------------------- // Game Control diff --git a/src/useGame/useKeyBindings.ts b/src/useGame/useKeyBindings.ts index 3122925..d1670a0 100644 --- a/src/useGame/useKeyBindings.ts +++ b/src/useGame/useKeyBindings.ts @@ -1,15 +1,21 @@ 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([]); + const [editMode, setEditMode] = useState("navigation"); const logRef = useRef([]); const countRef = useRef(""); // Vim-style numeric prefix const lastMotionRef = useRef<{ dr: number; dc: number; steps: number } | null>(null); @@ -36,30 +42,75 @@ export function useKeyBindings(ctx: Ctx) { const onKeyDown = (e: KeyboardEvent) => { const k = e.key; - // --- Digits 1–9 fill a cell --- - if (/^[1-9]$/.test(k)) { + // ========== 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 or C to enter edit mode + if (k === "r" || k === "R" || k === "c" || k === "C") { + recordKey(k); + + // Check if current cell is a pre-filled clue (not editable) + const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c; + const isClue = ctx.initialBoard[cellIndex] !== "."; + + if (isClue) { + // Flash red - cell is locked + window.dispatchEvent(new CustomEvent("sudoku-locked-cell")); + return; + } + + setEditMode("edit"); + return; + } + + // Escape cancels count prefix + if (k === "Escape") { 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)) { + // Numeric prefixes (for counted motions) + if (/^[0-9]$/.test(k) && k !== "0") { recordKey(k); countRef.current += k; return; } - // --- Vim-style navigation --- + // Vim-style navigation let dr = 0; let dc = 0; if (k === "h") dc = -1; @@ -98,7 +149,7 @@ export function useKeyBindings(ctx: Ctx) { window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [ctx]); + }, [ctx, editMode]); // ✅ same interface as vim-maze useKeyBindings return { @@ -108,6 +159,7 @@ export function useKeyBindings(ctx: Ctx) { resetCount, countRef, lastMotionRef, + editMode, }; } diff --git a/src/useGame/useSudokuRenderer.ts b/src/useGame/useSudokuRenderer.ts index 95cd980..d0c312d 100644 --- a/src/useGame/useSudokuRenderer.ts +++ b/src/useGame/useSudokuRenderer.ts @@ -60,7 +60,7 @@ export function useRenderer() { container.appendChild(sudokuDiv); }, []); - const highlightCursor = useCallback((r: number, c: number) => { + const highlightCursor = useCallback((r: number, c: number, isEditing = false) => { const container = containerRef.current; if (!container) return; @@ -68,11 +68,15 @@ export function useRenderer() { 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"); + } } }, []);