feat: add configurable removedCells option and comprehensive documentation
- Add removedCells to GameOptions for custom puzzle sizes - removedCells overrides difficulty default when provided - Maintain backward compatibility with all defaults - Complete IMPLEMENTATION.md with Iteration 16 and summary - Completely rewrite README.md with comprehensive documentation: - Complete API reference (GameOptions, GameManager, CursorManager, ScoreManager) - Game states and state machine flow - Full VIM controls reference (movement, editing, hints, game control) - Cell state rules and visual feedback - Detailed scoring formula with examples - Game-over conditions (time and hint limits) - Hint system priority and penalties - Configuration examples for different play styles - Platform hook integration guide - Mark implementation as COMPLETE and PRODUCTION READY
This commit is contained in:
+155
-4
@@ -1700,11 +1700,162 @@ Hard mode (100 pts/hint, 500 limit):
|
||||
- ✅ Configurable time limit working
|
||||
|
||||
### Next Steps
|
||||
- Final polish and testing
|
||||
- Update UNIFIED_API.md
|
||||
- Update README.md with complete API documentation
|
||||
|
||||
---
|
||||
|
||||
## Iteration 16: [Pending]
|
||||
## Iteration 16: Configurable Game Options
|
||||
|
||||
*To be filled as implementation progresses...*
|
||||
**Date**: 2025-01-22
|
||||
|
||||
### Goals
|
||||
- Make game configuration more flexible
|
||||
- Allow custom removedCells independent of difficulty
|
||||
- Maintain backward compatibility with defaults
|
||||
|
||||
### Tasks Completed
|
||||
|
||||
#### 1. Added removedCells to GameOptions
|
||||
**File: `src/types.ts`**
|
||||
|
||||
**Extended GameOptions:**
|
||||
```typescript
|
||||
export type GameOptions = {
|
||||
difficulty?: Difficulty; // 'easy' | 'medium' | 'hard'
|
||||
timeLimit?: number; // In seconds, default 600
|
||||
removedCells?: number; // Override difficulty default
|
||||
};
|
||||
```
|
||||
|
||||
#### 2. Updated Board Generation
|
||||
**Files: `SudokuGenerator.ts`, `useBoard.ts`**
|
||||
|
||||
**Generator signature updated:**
|
||||
```typescript
|
||||
generate(difficulty: Difficulty, customRemovedCells?: number): void
|
||||
```
|
||||
|
||||
**Logic:**
|
||||
```typescript
|
||||
const removeCount = customRemovedCells ?? CELLS_TO_REMOVE[difficulty];
|
||||
```
|
||||
|
||||
**Priority:** Custom removedCells takes precedence over difficulty default
|
||||
|
||||
#### 3. Configuration Flexibility
|
||||
|
||||
**Option 1: Full defaults**
|
||||
```typescript
|
||||
useGame({})
|
||||
// difficulty: 'easy', removedCells: 25, timeLimit: 600
|
||||
```
|
||||
|
||||
**Option 2: Just difficulty**
|
||||
```typescript
|
||||
useGame({ difficulty: 'hard' })
|
||||
// difficulty: 'hard', removedCells: 50, timeLimit: 600
|
||||
```
|
||||
|
||||
**Option 3: Just removedCells**
|
||||
```typescript
|
||||
useGame({ removedCells: 30 })
|
||||
// difficulty: 'easy', removedCells: 30 (custom), timeLimit: 600
|
||||
```
|
||||
|
||||
**Option 4: Both (removedCells overrides)**
|
||||
```typescript
|
||||
useGame({ difficulty: 'hard', removedCells: 35 })
|
||||
// difficulty: 'hard', removedCells: 35 (custom), timeLimit: 600
|
||||
// Note: Difficulty still used for score multipliers and hint penalties
|
||||
```
|
||||
|
||||
**Option 5: All custom**
|
||||
```typescript
|
||||
useGame({ difficulty: 'medium', removedCells: 45, timeLimit: 480 })
|
||||
// All custom values
|
||||
```
|
||||
|
||||
### Technical Details
|
||||
|
||||
**Agnostic Design:**
|
||||
- `removedCells` completely independent of difficulty level
|
||||
- Difficulty still used for:
|
||||
- Score multipliers (1.0x, 1.5x, 2.0x)
|
||||
- Hint penalties (25, 50, 100 points)
|
||||
- Allows creative configurations (e.g., easy difficulty scoring with hard puzzle size)
|
||||
|
||||
**Default Values:**
|
||||
```typescript
|
||||
difficulty = 'easy'
|
||||
timeLimit = 600 // 10 minutes
|
||||
removedCells = CELLS_TO_REMOVE[difficulty] // 25, 40, or 50
|
||||
```
|
||||
|
||||
**Backward Compatibility:**
|
||||
- All existing code works without changes
|
||||
- Options object can be empty
|
||||
- Each option independent and optional
|
||||
|
||||
### Use Cases
|
||||
|
||||
**Testing:**
|
||||
```typescript
|
||||
useGame({ removedCells: 5, timeLimit: 60 })
|
||||
// Quick 5-cell puzzle with 1-minute limit
|
||||
```
|
||||
|
||||
**Custom Challenges:**
|
||||
```typescript
|
||||
useGame({ difficulty: 'easy', removedCells: 60, timeLimit: 1200 })
|
||||
// Large puzzle with easy scoring, generous time
|
||||
```
|
||||
|
||||
**Tournament Mode:**
|
||||
```typescript
|
||||
useGame({ difficulty: 'hard', removedCells: 50, timeLimit: 300 })
|
||||
// Standard hard puzzle with strict 5-minute limit
|
||||
```
|
||||
|
||||
### Build Status
|
||||
- ✅ TypeScript compiles cleanly
|
||||
- ✅ All options working correctly
|
||||
- ✅ Defaults maintained
|
||||
- ✅ Full backward compatibility
|
||||
|
||||
### Implementation Complete
|
||||
|
||||
All core features implemented:
|
||||
- ✅ Board generation and rendering
|
||||
- ✅ Complete vim motion system
|
||||
- ✅ Edit mode (i/r/c)
|
||||
- ✅ Erase functionality (x/d/Delete/Backspace)
|
||||
- ✅ Cursor rendering with visual feedback
|
||||
- ✅ Game status state machine
|
||||
- ✅ Scoring system (time + keystrokes)
|
||||
- ✅ Hint system with penalties
|
||||
- ✅ Game-over conditions (time/hints)
|
||||
- ✅ Win condition detection
|
||||
- ✅ Configurable game options
|
||||
- ✅ Platform hook support
|
||||
- ✅ Full example app
|
||||
|
||||
### Next Steps
|
||||
- Update README.md with comprehensive API documentation
|
||||
- Update UNIFIED_API.md with vim-sudoku specifics
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The vim-sudoku implementation is **COMPLETE** and **PRODUCTION READY**.
|
||||
|
||||
All planned features have been implemented following the unified API architecture. The game is fully playable with:
|
||||
- Complete vim keybindings
|
||||
- Multi-mode editing
|
||||
- Comprehensive scoring
|
||||
- Hint system
|
||||
- Game-over conditions
|
||||
- Flexible configuration
|
||||
- Platform integration support
|
||||
|
||||
Ready for deployment and player testing!
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
[](https://www.npmjs.com/package/@vimazing/vim-sudoku)
|
||||
[](https://github.com/vimazing/vim-sudoku/blob/main/LICENSE)
|
||||
|
||||

|
||||
Lightweight, typed **React hooks** for building interactive sudoku games with **VIM-style modal editing**.
|
||||
|
||||
Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**.
|
||||
|
||||
Part of the [VIMazing](https://vimazing.com) project - [GitHub](https://github.com/andrepadez/vimazing-vimaze).
|
||||
Part of the [VIMazing](https://vimazing.com) project.
|
||||
|
||||
---
|
||||
|
||||
@@ -16,37 +14,40 @@ Part of the [VIMazing](https://vimazing.com) project - [GitHub](https://github.c
|
||||
- [Features](#features)
|
||||
- [Installation](#installation)
|
||||
- [Quick Start](#quick-start)
|
||||
- [VIM Modal Editing](#vim-modal-editing)
|
||||
- [Core Hooks](#core-hooks)
|
||||
- [Game Mechanics](#game-mechanics)
|
||||
- [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)
|
||||
- [Build & Release](#build--release)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells in insert mode (R/C to enter)
|
||||
- ⌨️ **Full VIM motions** – `h`/`j`/`k`/`l`, counted moves, `^`/`$`/`0` for row navigation
|
||||
- 🎯 **Smart cell locking** – Pre-filled clues are protected with visual feedback
|
||||
- ⏱️ **Time & keystroke limits** – Configurable game-over conditions
|
||||
- 📊 **Built-in scoring** – Time-based scoring with keystroke tracking
|
||||
- 🎨 **Tokyo Night theme** – Beautiful dark theme with 3x3 box separators
|
||||
- 📦 **Typed API** – Full TypeScript support with generated declarations
|
||||
- 🪝 **Composable hooks** – Separate concerns for game state, rendering, input, and scoring
|
||||
- 🌲 **Tree-shakeable exports** – Import only what you need
|
||||
|
||||
- 🎮 **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
|
||||
|
||||
Using **npm**:
|
||||
|
||||
```bash
|
||||
npm install @vimazing/vim-sudoku
|
||||
```
|
||||
|
||||
Or with **bun**:
|
||||
Or with bun:
|
||||
|
||||
```bash
|
||||
bun add @vimazing/vim-sudoku
|
||||
@@ -61,66 +62,212 @@ import { useGame } from "@vimazing/vim-sudoku";
|
||||
import "@vimazing/vim-sudoku/game.css";
|
||||
|
||||
export function SudokuGame() {
|
||||
const { containerRef, initGame, gameStatus, scoreManager } = useGame();
|
||||
const gameManager = useGame({ difficulty: 'easy' });
|
||||
const { containerRef, gameStatus, scoreManager, startGame } = gameManager;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>VIMazing Sudoku</h1>
|
||||
<button onClick={initGame}>New Puzzle</button>
|
||||
|
||||
<div className="relative w-fit">
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
{gameStatus === 'waiting' && (
|
||||
<button onClick={startGame}>Start Game</button>
|
||||
)}
|
||||
|
||||
{gameStatus === "game-won" && (
|
||||
<p>🎉 Score: {scoreManager.finalScore?.toLocaleString()}</p>
|
||||
<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 the CSS file. The package exports styles but does not auto-import them, giving you control over when and how styles are loaded.
|
||||
> **Note:** You must manually import `game.css` for styling.
|
||||
|
||||
---
|
||||
|
||||
## VIM Modal Editing
|
||||
## API Reference
|
||||
|
||||
VIMazing Sudoku follows VIM's modal editing philosophy with strict insert/replace semantics:
|
||||
### `useGame(options?, platformHook?)`
|
||||
|
||||
### Navigation Mode (default)
|
||||
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 |
|
||||
| --- | ------ | ----- |
|
||||
| `h` / `j` / `k` / `l` | Move cursor left/down/up/right | Core VIM navigation |
|
||||
| `<count>h/j/k/l` | Move with count | e.g., `3j` = down 3, `5l` = right 5 |
|
||||
| `0` / `^` | Jump to start of row | Move to column 0 |
|
||||
| `$` | Jump to end of row | Move to column 8 |
|
||||
| `.` | Repeat last motion | Repeat with optional new count |
|
||||
|-----|--------|-------|
|
||||
| `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 |
|
||||
|
||||
#### Editing
|
||||
|
||||
| Key | Action | Valid On |
|
||||
| --- | ------ | -------- |
|
||||
| `I` | Enter insert mode | ✅ Empty cells only |
|
||||
| `R` | Enter replace mode | ✅ User-filled cells only |
|
||||
| `D` / `X` | Delete cell value | ✅ User-filled cells only |
|
||||
|
||||
#### Game Control
|
||||
### Edit Mode (i/r/c to enter)
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `Q` | Quit game |
|
||||
|
||||
### Edit Mode (I/R to enter)
|
||||
|
||||
| Key | Action |
|
||||
| --- | ------ |
|
||||
| `1-9` | Fill cell with number |
|
||||
| `x` / `Backspace` | Clear cell |
|
||||
| `Esc` / `Enter` | Exit edit mode back to navigation |
|
||||
|-----|--------|
|
||||
| `1-9` | Enter digit in current cell |
|
||||
| `Backspace` | Clear current cell (stay in edit mode) |
|
||||
| `Escape` | Exit to normal mode |
|
||||
|
||||
---
|
||||
|
||||
@@ -128,135 +275,168 @@ VIMazing Sudoku follows VIM's modal editing philosophy with strict insert/replac
|
||||
|
||||
Understanding cell states is crucial for VIM-style editing:
|
||||
|
||||
| Cell State | `I` (Insert) | `R` (Replace) | `D`/`X` (Delete) | Visual |
|
||||
| ---------- | ------------ | ------------- | ---------------- | ------ |
|
||||
| **Empty** (`.`) | ✅ Enter edit | 🔴 Flash (use I) | ➖ No effect | Normal |
|
||||
| **User-filled** | 🔴 Flash (use R) | ✅ Enter edit | ✅ Delete | Normal |
|
||||
| **Pre-filled clue** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | Darker text |
|
||||
| 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 glow** – Navigation mode (current cell)
|
||||
- 🟡 **Yellow pulsing glow** – Edit mode (typing active)
|
||||
- 🔴 **Red flash** – Invalid action (wrong mode, locked cell, or out of bounds)
|
||||
- 🔵 **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)
|
||||
|
||||
### Examples
|
||||
---
|
||||
|
||||
## Scoring System
|
||||
|
||||
### Formula
|
||||
|
||||
```
|
||||
Empty cell: Press I → type 5 → Esc
|
||||
Replace value: Press R → type 7 → Esc
|
||||
Delete value: Press D (instant)
|
||||
Move 3 down: Press 3j
|
||||
Jump to end: Press $
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Hooks
|
||||
## Game Over Conditions
|
||||
|
||||
### `useGame(platformHook?, options?)`
|
||||
### Time Limit
|
||||
|
||||
Main hook that orchestrates the entire sudoku game.
|
||||
- **Default:** 600 seconds (10 minutes) for all difficulties
|
||||
- **Configurable:** Set via `GameOptions.timeLimit`
|
||||
- **Trigger:** When `timeValue >= timeLimit × 1000`
|
||||
- **Message:** "Time's up!"
|
||||
|
||||
**Options:**
|
||||
- `validateMoves?: boolean` (default: `false`) - Enable real-time move validation with conflict detection. When `false`, allows harder gameplay without immediate feedback.
|
||||
### Hint Limit
|
||||
|
||||
**Returns:**
|
||||
- **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!"
|
||||
|
||||
```ts
|
||||
{
|
||||
// Core
|
||||
containerRef: RefObject<HTMLDivElement>,
|
||||
gameStatus: GameStatus, // "waiting" | "started" | "game-won" | "game-over"
|
||||
startGame: () => void,
|
||||
stopGame: () => void,
|
||||
initGame: () => void, // Reset board + start
|
||||
|
||||
// Cursor
|
||||
cursor: { r: number; c: number },
|
||||
moveCursor: (dr, dc, steps?) => void,
|
||||
|
||||
// Board
|
||||
board: string, // 81-character string
|
||||
initialBoard: string, // Original clues
|
||||
setDigit: (digit) => void,
|
||||
erase: () => void,
|
||||
reset: () => void,
|
||||
solveBoard: () => void,
|
||||
boardToString: (human?) => string, // Format board for display
|
||||
|
||||
// Scoring
|
||||
scoreManager: {
|
||||
timeValue: number, // Time in milliseconds
|
||||
finalScore: number | null,
|
||||
timeLimit: number, // 60000ms (60 seconds)
|
||||
keystrokeLimit: number, // 100 keystrokes
|
||||
},
|
||||
|
||||
// Key logging
|
||||
keyLog: KeyLogEntry[],
|
||||
editMode: "navigation" | "edit",
|
||||
}
|
||||
```
|
||||
|
||||
### Individual Hooks
|
||||
|
||||
Export individual hooks for granular control:
|
||||
|
||||
```ts
|
||||
import { useScore } from "@vimazing/vim-sudoku/useScore";
|
||||
import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState";
|
||||
```
|
||||
Both conditions checked continuously during gameplay. Timer stops on game-over.
|
||||
|
||||
---
|
||||
|
||||
## Game Mechanics
|
||||
## Hint System
|
||||
|
||||
### Scoring
|
||||
Press `Shift+H` in normal mode to request a hint.
|
||||
|
||||
Scoring formula with time and move penalties:
|
||||
```
|
||||
score = max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)
|
||||
### 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
|
||||
})
|
||||
```
|
||||
|
||||
Where:
|
||||
- `baseScore = 100000`
|
||||
- `timePenalty = timeInSeconds × 10`
|
||||
- `extraMoves = actualKeystrokes - minMovesRequired`
|
||||
- `movePenalty = extraMoves × 50`
|
||||
|
||||
### Game Over Conditions
|
||||
|
||||
- ⏱️ **Time limit**: 60 seconds
|
||||
- ⌨️ **Keystroke limit**: 100 keystrokes
|
||||
|
||||
Customize in `src/useScore/useScore.ts`:
|
||||
```ts
|
||||
const TIME_LIMIT_MS = 60000;
|
||||
const KEYSTROKE_LIMIT = 100;
|
||||
**Standard Easy:**
|
||||
```typescript
|
||||
useGame({ difficulty: 'easy' })
|
||||
// 25 cells, 10 minutes, 1.0x multiplier
|
||||
```
|
||||
|
||||
### Win Detection
|
||||
**Standard Medium:**
|
||||
```typescript
|
||||
useGame({ difficulty: 'medium' })
|
||||
// 40 cells, 10 minutes, 1.5x multiplier
|
||||
```
|
||||
|
||||
Automatic win detection is fully implemented using an efficient sum-to-45 algorithm:
|
||||
- Validates when board is complete (no empty cells)
|
||||
- Checks all rows sum to 45 (1+2+...+9)
|
||||
- Checks all columns sum to 45
|
||||
- Checks all 3x3 boxes sum to 45
|
||||
**Standard Hard:**
|
||||
```typescript
|
||||
useGame({ difficulty: 'hard' })
|
||||
// 50 cells, 10 minutes, 2.0x multiplier
|
||||
```
|
||||
|
||||
Manual win trigger available via `solveBoard()` for testing.
|
||||
**Speed Challenge:**
|
||||
```typescript
|
||||
useGame({
|
||||
difficulty: 'medium',
|
||||
timeLimit: 300 // 5 minutes
|
||||
})
|
||||
```
|
||||
|
||||
### Board Format
|
||||
|
||||
```ts
|
||||
// Computer format (compact)
|
||||
boardToString() // "628.1...535928671..."
|
||||
|
||||
// Human format (readable)
|
||||
boardToString(true)
|
||||
// 6 2 8 | 1 . . | . 5 .
|
||||
// 5 3 9 | 2 4 8 | 6 7 1
|
||||
// ...
|
||||
**Custom Difficulty:**
|
||||
```typescript
|
||||
useGame({
|
||||
difficulty: 'easy', // Easy scoring (1.0x, 25pt hints)
|
||||
removedCells: 60, // But very hard puzzle
|
||||
timeLimit: 1200 // Generous time (20 min)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
@@ -267,24 +447,47 @@ A demo application lives under `example/` and consumes the package directly.
|
||||
|
||||
```bash
|
||||
cd example
|
||||
bun install
|
||||
bun run dev
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
During local development the Vite config aliases `@vimazing/vim-sudoku` to the source folder so you can iterate without rebuilding. When publishing, run the build first (see below) so editors consume the generated declarations in `dist/`.
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
## Build & Release
|
||||
## Platform Hook
|
||||
|
||||
Build the distributable bundle and type declarations:
|
||||
Optional callback for platform integration:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
```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);
|
||||
```
|
||||
|
||||
This writes JavaScript, type definitions, and styles to `dist/`. The `prepublishOnly` hook reuses the same command to guarantee fresh artifacts before publishing.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
@@ -12,6 +12,7 @@ export type { ScoreManager } from './useScore/types';
|
||||
export type GameOptions = {
|
||||
difficulty?: Difficulty;
|
||||
timeLimit?: number;
|
||||
removedCells?: number;
|
||||
};
|
||||
|
||||
export type KeyLogEntry = { key: string; timestamp: number };
|
||||
|
||||
@@ -14,14 +14,14 @@ export class SudokuGenerator {
|
||||
this.generate('medium');
|
||||
}
|
||||
|
||||
generate(difficulty: Difficulty = 'medium'): void {
|
||||
generate(difficulty: Difficulty = 'medium', customRemovedCells?: number): void {
|
||||
this.solution = this.generateSolvedBoard();
|
||||
|
||||
if (!this.validateFullBoard(this.solution)) {
|
||||
throw new Error('Generated invalid solved board');
|
||||
}
|
||||
|
||||
this.puzzle = this.makePuzzle(this.solution, difficulty);
|
||||
this.puzzle = this.makePuzzle(this.solution, difficulty, customRemovedCells);
|
||||
}
|
||||
|
||||
getSolution(): string {
|
||||
@@ -74,12 +74,12 @@ export class SudokuGenerator {
|
||||
return board.flat().join('');
|
||||
}
|
||||
|
||||
private makePuzzle(solution: string, difficulty: Difficulty): string {
|
||||
private makePuzzle(solution: string, difficulty: Difficulty, customRemovedCells?: number): string {
|
||||
if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
|
||||
throw new Error('Invalid solved board: must be 81 digits 1–9');
|
||||
}
|
||||
|
||||
const removeCount = CELLS_TO_REMOVE[difficulty];
|
||||
const removeCount = customRemovedCells ?? CELLS_TO_REMOVE[difficulty];
|
||||
const board = solution.split('');
|
||||
|
||||
const indices = Array.from({ length: 81 }, (_, i) => i);
|
||||
|
||||
@@ -12,7 +12,7 @@ export type BoardManager = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
boardRef: RefObject<string>;
|
||||
boardRendered: number;
|
||||
generateBoard: (difficulty: Difficulty) => void;
|
||||
generateBoard: (difficulty: Difficulty, removedCells?: number) => void;
|
||||
renderBoard: () => void;
|
||||
setCell: (row: number, col: number, value: string) => void;
|
||||
getHint: () => HintResult;
|
||||
|
||||
@@ -13,12 +13,12 @@ export function useBoard() {
|
||||
const initialBoardRef = useRef<string>('');
|
||||
const [boardRendered, setBoardRendered] = useState(0);
|
||||
|
||||
function generateBoard(difficulty: Difficulty = 'easy') {
|
||||
function generateBoard(difficulty: Difficulty = 'easy', removedCells?: number) {
|
||||
if (!generatorRef.current) {
|
||||
generatorRef.current = new SudokuGenerator();
|
||||
}
|
||||
|
||||
generatorRef.current.generate(difficulty);
|
||||
generatorRef.current.generate(difficulty, removedCells);
|
||||
const puzzle = generatorRef.current.getPuzzle();
|
||||
const solution = generatorRef.current.getSolution();
|
||||
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ import { useCursorRender } from './useCursor/useCursorRender';
|
||||
import { useScore } from './useScore';
|
||||
|
||||
export function useGame(options: GameOptions = {}, platformHook?: unknown): GameManager {
|
||||
const { difficulty = 'easy', timeLimit = 600 } = options;
|
||||
const { difficulty = 'easy', timeLimit = 600, removedCells } = options;
|
||||
const board = useBoard();
|
||||
const { containerRef, renderBoard, generateBoard, boardRendered } = board;
|
||||
|
||||
@@ -51,12 +51,12 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
|
||||
|
||||
useEffect(() => {
|
||||
if (gameStatus === 'started') {
|
||||
generateBoard(difficulty);
|
||||
generateBoard(difficulty, removedCells);
|
||||
renderBoard();
|
||||
} else if (gameStatus === 'waiting') {
|
||||
renderBoard();
|
||||
}
|
||||
}, [gameStatus, difficulty]);
|
||||
}, [gameStatus, difficulty, removedCells]);
|
||||
|
||||
const gameManager: GameManager = {
|
||||
containerRef,
|
||||
|
||||
Reference in New Issue
Block a user