Files
vim-sudoku/README.md
T

498 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @vimazing/vim-sudoku
[![npm version](https://img.shields.io/npm/v/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku)
[![npm downloads](https://img.shields.io/npm/dm/@vimazing/vim-sudoku.svg)](https://www.npmjs.com/package/@vimazing/vim-sudoku)
[![license](https://img.shields.io/npm/l/@vimazing/vim-sudoku.svg)](https://github.com/vimazing/vim-sudoku/blob/main/LICENSE)
![VIM Sudoku Demo](./vim-sudoku.gif)
Lightweight, typed **React hooks** for building interactive sudoku games with **VIM-style modal editing**.
Part of the [VIMazing](https://vimazing.com) project.
---
## Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API Reference](#api-reference)
- [Game States](#game-states)
- [VIM Controls](#vim-controls)
- [Scoring System](#scoring-system)
- [Game Over Conditions](#game-over-conditions)
- [Hint System](#hint-system)
- [Configuration](#configuration)
- [Example App](#example-app)
- [License](#license)
---
## Features
- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells with i/r/c commands
- ⌨️ **Full VIM motions** – hjkl, counts (5j), anchors (^/$), gg/G, repeat (.)
- 🎯 **Smart cell protection** – Pre-filled clues locked with visual feedback
- 💡 **Hint system** – Get hints for incorrect cells or empty cells (Shift+H)
- ⏱️ **Configurable limits** – Time limits and hint penalties trigger game-over
- 📊 **Comprehensive scoring** – Time + keystrokes + hints with difficulty multipliers
- 🎨 **Tokyo Night theme** – Beautiful dark theme with proper 3x3 box separators
- 📦 **Full TypeScript** – Complete type safety with generated declarations
- 🪝 **Composable architecture** – Clean separation: board, cursor, score, game status
- 🌐 **Platform hooks** – Optional integration for analytics and custom bindings
---
## Installation
```bash
npm install @vimazing/vim-sudoku
```
Or with bun:
```bash
bun add @vimazing/vim-sudoku
```
---
## Quick Start
```tsx
import { useGame } from "@vimazing/vim-sudoku";
import "@vimazing/vim-sudoku/game.css";
export function SudokuGame() {
const gameManager = useGame({ difficulty: 'easy' });
const { containerRef, gameStatus, scoreManager, startGame } = gameManager;
return (
<div>
<h1>VIMazing Sudoku</h1>
{gameStatus === 'waiting' && (
<button onClick={startGame}>Start Game</button>
)}
<div ref={containerRef} />
{gameStatus === 'game-won' && (
<div>
<h2>You Won!</h2>
<p>Score: {scoreManager.finalScore} / 1000</p>
<p>Time: {Math.floor(scoreManager.timeValue / 1000)}s</p>
</div>
)}
</div>
);
}
```
> **Note:** You must manually import `game.css` for styling.
---
## API Reference
### `useGame(options?, platformHook?)`
Main orchestrator hook that composes all game functionality.
#### Options
```typescript
type GameOptions = {
difficulty?: 'easy' | 'medium' | 'hard'; // Default: 'easy'
timeLimit?: number; // In seconds, default: 600 (10 min)
removedCells?: number; // Override difficulty default
};
```
**Difficulty Defaults:**
- `easy`: 25 cells removed (~56 givens)
- `medium`: 40 cells removed (~41 givens)
- `hard`: 50 cells removed (~31 givens)
**Examples:**
```typescript
// Default configuration
useGame()
// Hard difficulty with defaults
useGame({ difficulty: 'hard' })
// Custom puzzle size
useGame({ removedCells: 30 })
// Custom everything
useGame({
difficulty: 'hard',
removedCells: 45,
timeLimit: 480 // 8 minutes
})
```
#### Returns: GameManager
```typescript
type GameManager = {
// DOM Reference
containerRef: RefObject<HTMLDivElement | null>;
// Rendering
renderBoard: () => void;
// Game Status
gameStatus: GameStatus;
setGameStatus: (status: GameStatus) => void;
startGame: () => void;
togglePause: (pause?: boolean) => void;
quitGame: () => void;
// Cursor
cursor: CursorManager;
// Scoring
scoreManager: ScoreManager;
// Input Tracking
keyLog: KeyLogEntry[];
clearKeyLog: () => void;
getKeyLog: () => KeyLogEntry[];
};
```
#### CursorManager
```typescript
type CursorManager = {
position: () => Coord; // Current { row, col }
mode: () => CursorMode; // 'normal' | 'edit'
moveLeft: (count?: number) => void;
moveRight: (count?: number) => void;
moveUp: (count?: number) => void;
moveDown: (count?: number) => void;
moveToStart: () => void; // ^ or 0
moveToEnd: () => void; // $
moveToTop: () => void; // gg
moveToBottom: () => void; // G
repeatLastMotion: () => void; // .
};
```
#### ScoreManager
```typescript
type ScoreManager = {
timeValue: number; // Milliseconds elapsed
startTimer: () => void;
stopTimer: () => void;
resetTimer: () => void;
totalKeystrokes: number; // All keys pressed
hintsUsed: number; // Number of hints requested
finalScore: number | null; // 0-1000, null until game-won
gameOverReason: string | null; // "Time's up!" or "Too many hints!"
};
```
---
## Game States
The game follows a strict state machine:
```
waiting → started → game-won
↓
game-over
All states → [quit] → waiting
started ↔ paused
```
### State Descriptions
| State | Description | Triggers |
|-------|-------------|----------|
| `waiting` | Initial state, awaiting start | Default on load |
| `started` | Game in progress | Press Space or call `startGame()` |
| `paused` | Game temporarily paused | Press P or call `togglePause()` |
| `game-won` | Puzzle completed successfully | All cells filled correctly |
| `game-over` | Failed to complete in time/hints | Time limit or hint limit exceeded |
---
## VIM Controls
### Normal Mode (Default)
#### Movement
| Key | Action | Example |
|-----|--------|---------|
| `h` | Move left | `h` moves 1 left |
| `j` | Move down | `j` moves 1 down |
| `k` | Move up | `k` moves 1 up |
| `l` | Move right | `l` moves 1 right |
| `<count><motion>` | Move with count | `5j` moves 5 down, `3l` moves 3 right |
| `0` or `^` | Jump to row start | Move to column 0 |
| `$` | Jump to row end | Move to column 8 |
| `gg` | Jump to board top | Move to row 0 |
| `G` | Jump to board bottom | Move to row 8 |
| `.` | Repeat last motion | Repeats with same count |
#### Edit Commands
| Key | Action | Valid On | Mode |
|-----|--------|----------|------|
| `i` | Insert digit | ✅ Empty cells only | Multi-edit: type digits until Esc |
| `r` | Replace digit | ✅ User-entered cells only | Single-edit: auto-exit after 1 digit |
| `c` | Change digit | ✅ User-entered cells only | Single-edit: auto-exit after 1 digit |
| `x` | Delete digit | ✅ User-entered cells only | Instant |
| `d` | Delete digit | ✅ User-entered cells only | Instant |
| `Delete` | Delete digit | ✅ User-entered cells only | Instant |
| `Backspace` | Invalid move | ❌ All cells | Red flash (use in edit mode) |
#### Hints & Game Control
| Key | Action | Notes |
|-----|--------|-------|
| `Shift+H` | Request hint | Penalty: 25/50/100 pts based on difficulty |
| `q` | Quit game | Return to waiting state |
| `p` | Pause/unpause | Toggle pause state |
| `Space` | Start new game | Only in waiting/game-over state |
### Edit Mode (i/r/c to enter)
| Key | Action |
|-----|--------|
| `1-9` | Enter digit in current cell |
| `Backspace` | Clear current cell (stay in edit mode) |
| `Escape` | Exit to normal mode |
---
## Cell State Rules
Understanding cell states is crucial for VIM-style editing:
| Cell State | `i` Insert | `r`/`c` Replace | `x`/`d`/`Delete` | Visual Class |
|------------|------------|-----------------|------------------|--------------|
| **Empty** | ✅ Enter multi-edit | 🔴 Flash (use i) | 🔴 Flash (nothing to delete) | None |
| **User-entered** | 🔴 Flash (use r/c) | ✅ Enter single-edit | ✅ Delete instantly | `.user-entered` |
| **Given** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | `.given` |
### Visual Feedback
- 🔵 **Blue outline** – Normal mode cursor (`.active`)
- 🟡 **Yellow pulsing glow** – Edit mode cursor (`.editing`)
- 🔴 **Red flash** – Invalid move (`.invalid-move` - 500ms)
- 🔴 **Red flash + background** – Hint: incorrect cell (`.hint-flash-error` - 1000ms)
- 🟢 **Green flash + background** – Hint: correct digit shown (`.hint-flash-correct` - 1000ms)
---
## Scoring System
### Formula
```
Base Score = 1000 - (time penalty) - (keystroke penalty) - (hint penalty)
Final Score = min(1000, max(0, round(Base Score × difficulty multiplier)))
```
### Penalties
**Time Penalty:** `seconds / 10`
- 10 seconds = -1 point
- 60 seconds = -6 points
- 300 seconds = -30 points
**Keystroke Penalty:** `totalKeystrokes / 2`
- 2 keystrokes = -1 point
- 50 keystrokes = -25 points
- 200 keystrokes = -100 points
**Hint Penalty:** `hintsUsed × penalty`
- Easy: 25 points per hint
- Medium: 50 points per hint
- Hard: 100 points per hint
### Difficulty Multipliers
- Easy: **1.0x** (no bonus)
- Medium: **1.5x** (can exceed 1000 base, capped at 1000)
- Hard: **2.0x** (can exceed 1000 base, capped at 1000)
### Example Scores
**Easy Mode (25 cells, 1.0x):**
```
30 seconds, 50 keys, 0 hints:
= 1000 - 3 - 25 - 0 = 972 × 1.0 = 972 / 1000
```
**Hard Mode (50 cells, 2.0x):**
```
120 seconds, 150 keys, 2 hints:
= 1000 - 12 - 75 - 200 = 713 × 2.0 = 1000 / 1000 (capped)
```
---
## Game Over Conditions
### Time Limit
- **Default:** 600 seconds (10 minutes) for all difficulties
- **Configurable:** Set via `GameOptions.timeLimit`
- **Trigger:** When `timeValue >= timeLimit × 1000`
- **Message:** "Time's up!"
### Hint Limit
- **Threshold:** 500 points total hint penalty
- **Easy:** 20 hints max (20 × 25 = 500)
- **Medium:** 10 hints max (10 × 50 = 500)
- **Hard:** 5 hints max (5 × 100 = 500)
- **Trigger:** When `hintsUsed × HINT_PENALTY[difficulty] >= 500`
- **Message:** "Too many hints!"
Both conditions checked continuously during gameplay. Timer stops on game-over.
---
## Hint System
Press `Shift+H` in normal mode to request a hint.
### Hint Priority
**Priority 1: Show Incorrect Cell (Red Flash)**
- Finds all user-entered cells with wrong values
- Selects one randomly
- Flashes red with background for 1 second
- Does NOT reveal correct digit (player must figure it out)
**Priority 2: Show Correct Digit (Green Flash)**
- If no incorrect cells exist
- Finds all empty cells
- Selects one randomly
- Shows correct digit with green flash for 1 second
- Digit disappears after flash (player must remember and enter it)
### Hint Penalties
Hints subtract directly from base score before multiplier:
- Easy: -25 points per hint
- Medium: -50 points per hint
- Hard: -100 points per hint
After 500 points of penalties, game-over triggers.
---
## Configuration
### Recommended Configurations
**Beginner Practice:**
```typescript
useGame({
difficulty: 'easy',
removedCells: 15,
timeLimit: 900 // 15 minutes
})
```
**Standard Easy:**
```typescript
useGame({ difficulty: 'easy' })
// 25 cells, 10 minutes, 1.0x multiplier
```
**Standard Medium:**
```typescript
useGame({ difficulty: 'medium' })
// 40 cells, 10 minutes, 1.5x multiplier
```
**Standard Hard:**
```typescript
useGame({ difficulty: 'hard' })
// 50 cells, 10 minutes, 2.0x multiplier
```
**Speed Challenge:**
```typescript
useGame({
difficulty: 'medium',
timeLimit: 300 // 5 minutes
})
```
**Custom Difficulty:**
```typescript
useGame({
difficulty: 'easy', // Easy scoring (1.0x, 25pt hints)
removedCells: 60, // But very hard puzzle
timeLimit: 1200 // Generous time (20 min)
})
```
---
## Example App
A demo application lives under `example/` and consumes the package directly.
```bash
cd example
npm install
npm run dev
```
The example shows:
- Difficulty selection (Easy/Medium/Hard)
- Live scoreboard (Time, Keystrokes, Hints)
- Game status messages
- Final score display on win
- All vim controls working
---
## Platform Hook
Optional callback for platform integration:
```typescript
function myPlatformHook(gameManager: GameManager) {
// Track analytics
console.log('Game initialized');
// Add custom key handlers
window.addEventListener('keydown', (e) => {
if (e.key === 'F1') {
console.log('Help requested');
}
});
// Monitor game events
const interval = setInterval(() => {
if (gameManager.gameStatus === 'game-won') {
console.log('Victory!', gameManager.scoreManager.finalScore);
clearInterval(interval);
}
}, 100);
}
const gameManager = useGame({ difficulty: 'easy' }, myPlatformHook);
```
---
## License
MIT © [André Padez](https://github.com/andrepadez)