From 97c31ab6ccdb89ed4962e316ba658bff189bcc15 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 22 Oct 2025 21:24:06 +0100 Subject: [PATCH] feat: implement hint system (Shift+H) with scoring penalties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getHint() method to BoardManager with two-tier priority: 1. Find incorrect user-entered cells → flash red 2. Find empty cells → show correct digit green temporarily - Add Shift+H key handler in useCursor (normal mode only) - Visual feedback: - Red flash (hint-flash-error): incorrect cells, 1s duration, red bg + border - Green flash (hint-flash-correct): correct digit shown, 1s duration, green bg + border - Distinct from invalid-move (no background, 500ms) - Score penalties based on difficulty: - Easy: 25 points per hint - Medium: 50 points per hint - Hard: 100 points per hint - Track hints used in scoreboard display - Correct digit appears temporarily (1s) then disappears (player still does the work) - Update example app to show hints counter and Shift+H instruction --- IMPLEMENTATION.md | 197 ++++++++++++++++++++++++++++++++++++- example/src/App.tsx | 9 +- src/game.css | 38 +++++++ src/useBoard/types.ts | 8 ++ src/useBoard/useBoard.ts | 43 ++++++++ src/useCursor/useCursor.ts | 35 ++++++- src/useGame.ts | 8 +- src/useScore/types.ts | 1 + src/useScore/useScore.ts | 15 ++- 9 files changed, 345 insertions(+), 9 deletions(-) diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 3da9229..c813594 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -1349,6 +1349,201 @@ Hard (50 cells): --- -## Iteration 14: [Pending] +## Iteration 14: Hint System + +**Date**: 2025-01-22 + +### Goals +- Implement hint system to help stuck players +- Two-tier hint priority (errors first, then correct digits) +- Apply scoring penalties for hint usage +- Visual feedback with distinct flash animations + +### Tasks Completed + +#### 1. Added getHint Method to BoardManager +**File: `src/useBoard/useBoard.ts`** + +**Logic:** +1. Check all user-entered cells against solution +2. Find incorrect cells (user value ≠ solution value) +3. If incorrect cells exist → return random incorrect cell +4. If no incorrect cells → return random empty cell with correct digit + +**Return Type:** +```typescript +type HintResult = { + row: number; + col: number; + type: 'error' | 'correct'; + value?: string; // Correct digit (for 'correct' type) +} | null; +``` + +**Implementation:** +```typescript +function getHint(): HintResult { + const incorrectCells = []; // User-entered cells with wrong values + const emptyCells = []; // Empty cells to fill + + // Priority 1: Find incorrect user entries + if (incorrectCells.length > 0) { + return { row, col, type: 'error' }; + } + + // Priority 2: Show correct digit for empty cell + if (emptyCells.length > 0) { + return { row, col, type: 'correct', value: correctDigit }; + } + + return null; // Puzzle complete +} +``` + +#### 2. Added Shift+H Key Handler +**File: `src/useCursor/useCursor.ts`** + +**Key binding:** `Shift+H` (capital H) +**Mode:** Only works in normal mode +**Behavior:** +```typescript +if (key === 'H') { + const hint = boardManager.getHint(); + if (!hint) return; // No hints available + + if (onHintUsed) onHintUsed(); // Increment hint counter + + if (hint.type === 'error') { + // Flash red for 1 second + targetCell.classList.add('hint-flash-error'); + setTimeout(() => targetCell.classList.remove('hint-flash-error'), 1000); + } else if (hint.type === 'correct') { + // Show digit temporarily for 1 second + targetCell.textContent = hint.value; + targetCell.classList.add('hint-flash-correct'); + setTimeout(() => { + targetCell.classList.remove('hint-flash-correct'); + targetCell.textContent = ''; // Remove digit + }, 1000); + } +} +``` + +#### 3. Added Hint Tracking to Score +**File: `src/useScore/useScore.ts`** + +**Penalty Structure:** +```typescript +const HINT_PENALTY = { + easy: 25, // 25 points per hint + medium: 50, // 50 points per hint + hard: 100, // 100 points per hint +}; +``` + +**Score Calculation:** +```typescript +const timePenalty = seconds / 10; +const keystrokePenalty = totalKeystrokes / 2; +const hintPenalty = hintsUsed * HINT_PENALTY[difficulty]; + +const baseScore = 1000 - timePenalty - keystrokePenalty - hintPenalty; +const finalScore = min(1000, max(0, round(baseScore * multiplier))); +``` + +**Tracking:** +- Hints counter passed from useGame to useScore +- Displayed in scoreboard during gameplay +- Applied to final score calculation + +#### 4. Added CSS Animations +**File: `src/game.css`** + +**Error Hint Flash (Red):** +```css +.sudoku-cell.hint-flash-error { + background-color: rgba(239, 68, 68, 0.5); + outline: 3px solid rgb(239, 68, 68); + outline-offset: -3px; + animation: hint-error-flash 1s ease-out; +} +``` +- Red border AND background (more obnoxious than invalid-move) +- 1 second animation duration +- Fades from bright to transparent + +**Correct Hint Flash (Green):** +```css +.sudoku-cell.hint-flash-correct { + background-color: rgba(34, 197, 94, 0.5); + outline: 3px solid rgb(34, 197, 94); + outline-offset: -3px; + animation: hint-correct-flash 1s ease-out; +} +``` +- Green border AND background +- 1 second animation duration +- Shows correct digit temporarily + +**Comparison:** +- `.invalid-move` - Red border only, 500ms (boundary violations) +- `.hint-flash-error` - Red border + background, 1000ms (incorrect cells) +- `.hint-flash-correct` - Green border + background, 1000ms (correct hints) + +#### 5. Updated Example App +**Scoreboard Display:** +- Added "Hints: X" counter +- Shows hint usage during gameplay +- Included in score calculation + +**Instructions Updated:** +- Added "Press Shift+H for hint" to gameplay instructions + +### Technical Details + +**Hint Priority Logic:** +``` +Press Shift+H + ↓ +Check for incorrect user-entered cells + ↓ +Found? → Flash random incorrect cell RED (1s) + ↓ +Not found? → Check for empty cells + ↓ +Found? → Flash random empty cell GREEN with correct digit (1s) + ↓ +Not found? → Do nothing (puzzle complete) +``` + +**Score Impact Examples:** +``` +Easy mode (25 points/hint): +- 3 hints used = -75 points from base score +- 10 hints used = -250 points from base score + +Hard mode (100 points/hint): +- 3 hints used = -300 points from base score +- 5 hints used = -500 points from base score +``` + +**Architecture Decision:** +- Hints counter managed in useGame (not useScore) +- Passed to useScore as prop to avoid circular dependency +- onHintUsed callback passed to useCursor for increment + +### Build Status +- ✅ TypeScript compiles cleanly +- ✅ Hint system working perfectly +- ✅ Visual feedback distinct and clear +- ✅ Score penalties applied correctly + +### Next Steps +- Final polish and testing +- Documentation updates + +--- + +## Iteration 15: [Pending] *To be filled as implementation progresses...* diff --git a/example/src/App.tsx b/example/src/App.tsx index 1e58f64..37b3ed5 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -8,7 +8,7 @@ function App() { const [difficulty, setDifficulty] = useState('easy'); const gameManager = useGame({ difficulty }, useKeyBindings); const { containerRef, gameStatus, scoreManager } = gameManager; - const { timeValue, totalKeystrokes, finalScore } = scoreManager; + const { timeValue, totalKeystrokes, hintsUsed, finalScore } = scoreManager; const formatTime = (ms: number) => { const seconds = Math.floor(ms / 1000); @@ -76,6 +76,11 @@ function App() {
Keystrokes
{totalKeystrokes}
+
+
+
Hints
+
{hintsUsed}
+
{gameStatus === "game-won" && finalScore !== null && ( <>
@@ -93,7 +98,7 @@ function App() {
{gameStatus === "waiting" &&

Press space to start

} - {gameStatus === "started" &&

Use hjkl to navigate • Press i to edit • Press q to quit

} + {gameStatus === "started" &&

Use hjkl to navigate • Press i to edit • Press Shift+H for hint • Press q to quit

} {gameStatus === "game-over" &&

Game Over! Press space to restart

} {gameStatus === "game-won" &&

You Won! Press space to play again

}
diff --git a/src/game.css b/src/game.css index 69e3fc4..f989200 100644 --- a/src/game.css +++ b/src/game.css @@ -273,3 +273,41 @@ box-shadow: 0 0 0 rgba(239, 68, 68, 0); } } + +/* Hint flash - incorrect cell */ +.sudoku-cell.hint-flash-error { + background-color: rgba(239, 68, 68, 0.5); + outline: 3px solid rgb(239, 68, 68); + outline-offset: -3px; + animation: hint-error-flash 1s ease-out; +} + +@keyframes hint-error-flash { + 0% { + background-color: rgba(239, 68, 68, 0.7); + box-shadow: 0 0 30px rgba(239, 68, 68, 0.9); + } + 100% { + background-color: rgba(239, 68, 68, 0); + box-shadow: 0 0 0 rgba(239, 68, 68, 0); + } +} + +/* Hint flash - correct digit shown */ +.sudoku-cell.hint-flash-correct { + background-color: rgba(34, 197, 94, 0.5); + outline: 3px solid rgb(34, 197, 94); + outline-offset: -3px; + animation: hint-correct-flash 1s ease-out; +} + +@keyframes hint-correct-flash { + 0% { + background-color: rgba(34, 197, 94, 0.7); + box-shadow: 0 0 30px rgba(34, 197, 94, 0.9); + } + 100% { + background-color: rgba(34, 197, 94, 0); + box-shadow: 0 0 0 rgba(34, 197, 94, 0); + } +} diff --git a/src/useBoard/types.ts b/src/useBoard/types.ts index e9f7b14..3ea8908 100644 --- a/src/useBoard/types.ts +++ b/src/useBoard/types.ts @@ -1,6 +1,13 @@ import type { RefObject } from 'react'; import type { Difficulty } from '../types'; +export type HintResult = { + row: number; + col: number; + type: 'error' | 'correct'; + value?: string; +} | null; + export type BoardManager = { containerRef: RefObject; boardRef: RefObject; @@ -8,4 +15,5 @@ export type BoardManager = { generateBoard: (difficulty: Difficulty) => void; renderBoard: () => void; setCell: (row: number, col: number, value: string) => void; + getHint: () => HintResult; }; diff --git a/src/useBoard/useBoard.ts b/src/useBoard/useBoard.ts index 5930c69..0d7ddf2 100644 --- a/src/useBoard/useBoard.ts +++ b/src/useBoard/useBoard.ts @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { SudokuGenerator } from './SudokuGenerator'; import { SudokuRenderer } from './SudokuRenderer'; import type { Difficulty } from './SudokuGenerator'; +import type { HintResult } from './types'; export function useBoard() { const containerRef = useRef(null); @@ -54,6 +55,47 @@ export function useBoard() { renderBoard(); } + function getHint(): HintResult { + const board = boardRef.current; + const solution = solutionRef.current; + const initialBoard = initialBoardRef.current; + + if (!board || !solution) return null; + + const incorrectCells: Array<{ row: number; col: number }> = []; + const emptyCells: Array<{ row: number; col: number }> = []; + + for (let i = 0; i < 81; i++) { + const row = Math.floor(i / 9); + const col = i % 9; + const currentValue = board[i]; + const correctValue = solution[i]; + const initialValue = initialBoard[i]; + + if (currentValue !== '.' && initialValue === '.' && currentValue !== correctValue) { + incorrectCells.push({ row, col }); + } + + if (currentValue === '.') { + emptyCells.push({ row, col }); + } + } + + if (incorrectCells.length > 0) { + const randomCell = incorrectCells[Math.floor(Math.random() * incorrectCells.length)]; + return { ...randomCell, type: 'error' }; + } + + if (emptyCells.length > 0) { + const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; + const index = randomCell.row * 9 + randomCell.col; + const correctValue = solution[index]; + return { ...randomCell, type: 'correct', value: correctValue }; + } + + return null; + } + return { containerRef, boardRef, @@ -61,5 +103,6 @@ export function useBoard() { generateBoard, renderBoard, setCell, + getHint, }; } diff --git a/src/useCursor/useCursor.ts b/src/useCursor/useCursor.ts index ae8f585..2b28e87 100644 --- a/src/useCursor/useCursor.ts +++ b/src/useCursor/useCursor.ts @@ -4,7 +4,7 @@ import type { GameStatusManager } from '../useGameStatus/types'; import type { BoardManager } from '../useBoard/types'; import { useVimMotions } from './useVimMotions'; -export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject, boardManager: BoardManager) { +export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject, boardManager: BoardManager, onHintUsed?: () => void) { const [position, setPosition] = useState({ row: 0, col: 0 }); const [mode, setMode] = useState('normal'); const [singleEdit, setSingleEdit] = useState(false); @@ -159,6 +159,39 @@ export function useCursor(gameStatusManager: GameStatusManager, containerRef: Re return; } + if (key === 'H') { + const hint = boardManager.getHint(); + if (!hint) return; + + if (onHintUsed) onHintUsed(); + + const container = containerRef.current; + if (!container) return; + + const cells = container.querySelectorAll('.sudoku-cell'); + const index = hint.row * 9 + hint.col; + const targetCell = cells[index] as HTMLElement; + + if (!targetCell) return; + + if (hint.type === 'error') { + targetCell.classList.add('hint-flash-error'); + setTimeout(() => { + targetCell.classList.remove('hint-flash-error'); + }, 1000); + } else if (hint.type === 'correct' && hint.value) { + const originalText = targetCell.textContent; + targetCell.textContent = hint.value; + targetCell.classList.add('hint-flash-correct'); + setTimeout(() => { + targetCell.classList.remove('hint-flash-correct'); + targetCell.textContent = originalText; + }, 1000); + } + + return; + } + if (key === 'i' || key === 'r' || key === 'c') { const container = containerRef.current; if (!container) return; diff --git a/src/useGame.ts b/src/useGame.ts index 2fa86cd..1cc2cce 100644 --- a/src/useGame.ts +++ b/src/useGame.ts @@ -16,11 +16,15 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game const [keyLog, setKeyLog] = useState<{ key: string; timestamp: number }[]>([]); - const cursor = useCursor(gameStatusManager, containerRef, board); + const [hintsUsedRef] = useState({ current: 0 }); + + const cursor = useCursor(gameStatusManager, containerRef, board, () => { + hintsUsedRef.current++; + }); const cursorPosition = cursor.position(); const cursorMode = cursor.mode(); - const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty }); + const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedRef.current }); useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered); diff --git a/src/useScore/types.ts b/src/useScore/types.ts index ca7e0ec..f5abec4 100644 --- a/src/useScore/types.ts +++ b/src/useScore/types.ts @@ -11,5 +11,6 @@ export type ScoreManager = { stopTimer: () => void; resetTimer: () => void; totalKeystrokes: number; + hintsUsed: number; finalScore: number | null; }; diff --git a/src/useScore/useScore.ts b/src/useScore/useScore.ts index b23fdb9..54c0f61 100644 --- a/src/useScore/useScore.ts +++ b/src/useScore/useScore.ts @@ -11,6 +11,7 @@ type UseScoreParams = { keyLog: { key: string; timestamp: number }[]; cursorMode: 'normal' | 'edit'; difficulty: Difficulty; + hintsUsed: number; }; const DIFFICULTY_MULTIPLIER = { @@ -19,7 +20,13 @@ const DIFFICULTY_MULTIPLIER = { hard: 2.0, } as const; -export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty }: UseScoreParams): ScoreManager { +const HINT_PENALTY = { + easy: 25, + medium: 50, + hard: 100, +} as const; + +export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedProp }: UseScoreParams): ScoreManager { const timer = useTimer(); const { gameStatus, setGameStatus } = gameStatusManager; const { timeValue, startTimer, stopTimer, resetTimer } = timer; @@ -58,13 +65,14 @@ export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficu const seconds = timeValue / 1000; const timePenalty = seconds / 10; const keystrokePenalty = totalKeystrokes / 2; + const hintPenalty = hintsUsedProp * HINT_PENALTY[difficulty]; const multiplier = DIFFICULTY_MULTIPLIER[difficulty]; - const baseScore = 1000 - timePenalty - keystrokePenalty; + const baseScore = 1000 - timePenalty - keystrokePenalty - hintPenalty; const score = Math.min(1000, Math.max(0, Math.round(baseScore * multiplier))); setFinalScore(score); - }, [gameStatus, timeValue, totalKeystrokes, difficulty]); + }, [gameStatus, timeValue, totalKeystrokes, hintsUsedProp, difficulty]); return { timeValue, @@ -72,6 +80,7 @@ export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficu stopTimer, resetTimer, totalKeystrokes, + hintsUsed: hintsUsedProp, finalScore, }; }