feat: implement hint system (Shift+H) with scoring penalties

- 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
This commit is contained in:
2025-10-22 21:24:06 +01:00
parent cfdf8100fb
commit 97c31ab6cc
9 changed files with 345 additions and 9 deletions
+196 -1
View File
@@ -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...*
+7 -2
View File
@@ -8,7 +8,7 @@ function App() {
const [difficulty, setDifficulty] = useState<Difficulty>('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() {
<div className="text-muted-foreground">Keystrokes</div>
<div className="text-lg font-bold">{totalKeystrokes}</div>
</div>
<div className="w-px h-12 bg-muted-foreground/20" />
<div>
<div className="text-muted-foreground">Hints</div>
<div className="text-lg font-bold">{hintsUsed}</div>
</div>
{gameStatus === "game-won" && finalScore !== null && (
<>
<div className="w-px h-12 bg-muted-foreground/20" />
@@ -93,7 +98,7 @@ function App() {
</div>
<div className="text-center text-2xl text-muted-foreground">
{gameStatus === "waiting" && <p>Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to start</p>}
{gameStatus === "started" && <p>Use <kbd className="px-2 py-1 bg-muted rounded">hjkl</kbd> to navigate • Press <kbd className="px-2 py-1 bg-muted rounded">i</kbd> to edit • Press <kbd className="px-2 py-1 bg-muted rounded">q</kbd> to quit</p>}
{gameStatus === "started" && <p>Use <kbd className="px-2 py-1 bg-muted rounded">hjkl</kbd> to navigate • Press <kbd className="px-2 py-1 bg-muted rounded">i</kbd> to edit • Press <kbd className="px-2 py-1 bg-muted rounded">Shift+H</kbd> for hint • Press <kbd className="px-2 py-1 bg-muted rounded">q</kbd> to quit</p>}
{gameStatus === "game-over" && <p>Game Over! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to restart</p>}
{gameStatus === "game-won" && <p>You Won! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to play again</p>}
</div>
+38
View File
@@ -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);
}
}
+8
View File
@@ -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<HTMLDivElement | null>;
boardRef: RefObject<string>;
@@ -8,4 +15,5 @@ export type BoardManager = {
generateBoard: (difficulty: Difficulty) => void;
renderBoard: () => void;
setCell: (row: number, col: number, value: string) => void;
getHint: () => HintResult;
};
+43
View File
@@ -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<HTMLDivElement | null>(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,
};
}
+34 -1
View File
@@ -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<HTMLDivElement | null>, boardManager: BoardManager) {
export function useCursor(gameStatusManager: GameStatusManager, containerRef: React.RefObject<HTMLDivElement | null>, boardManager: BoardManager, onHintUsed?: () => void) {
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
const [mode, setMode] = useState<CursorMode>('normal');
const [singleEdit, setSingleEdit] = useState<boolean>(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;
+6 -2
View File
@@ -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);
+1
View File
@@ -11,5 +11,6 @@ export type ScoreManager = {
stopTimer: () => void;
resetTimer: () => void;
totalKeystrokes: number;
hintsUsed: number;
finalScore: number | null;
};
+12 -3
View File
@@ -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,
};
}