Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4b6a8c62 | ||
|
|
27b10e7328 | ||
|
|
e11157e933 | ||
|
|
f6c8bee00f | ||
|
|
66f591c6df | ||
|
|
774fa5ecf3 | ||
|
|
3e79d33af2 | ||
|
|
ff497bc393 | ||
|
|
4236f53ef8 | ||
|
|
f68098657b |
+314
-2
@@ -1544,6 +1544,318 @@ Hard mode (100 points/hint):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 15: [Pending]
|
## Iteration 15: Game Over Logic
|
||||||
|
|
||||||
*To be filled as implementation progresses...*
|
**Date**: 2025-01-22
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Implement time limit for gameplay
|
||||||
|
- Implement hint limit based on penalty points
|
||||||
|
- Provide clear feedback on why game ended
|
||||||
|
- Make time limit configurable via game options
|
||||||
|
|
||||||
|
### Tasks Completed
|
||||||
|
|
||||||
|
#### 1. Added Time Limit Configuration
|
||||||
|
**File: `src/types.ts`**
|
||||||
|
|
||||||
|
**GameOptions Extended:**
|
||||||
|
```typescript
|
||||||
|
export type GameOptions = {
|
||||||
|
difficulty?: Difficulty;
|
||||||
|
timeLimit?: number; // In seconds, default 600 (10 minutes)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Default:** 10 minutes (600 seconds) for all difficulty levels
|
||||||
|
**Rationale:** Harder puzzles naturally take longer, creating appropriate challenge
|
||||||
|
|
||||||
|
#### 2. Implemented Game Over Triggers
|
||||||
|
**File: `src/useScore/useScore.ts`**
|
||||||
|
|
||||||
|
**Two conditions trigger game-over:**
|
||||||
|
|
||||||
|
**Condition 1: Time Limit Exceeded**
|
||||||
|
```typescript
|
||||||
|
const timeLimitMs = timeLimit * 1000;
|
||||||
|
if (timeValue >= timeLimitMs) {
|
||||||
|
setGameOverReason("Time's up!");
|
||||||
|
setGameStatus('game-over');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Condition 2: Hint Penalty Limit**
|
||||||
|
```typescript
|
||||||
|
const hintPenalty = hintsUsed * HINT_PENALTY[difficulty];
|
||||||
|
if (hintPenalty >= 500) {
|
||||||
|
setGameOverReason('Too many hints!');
|
||||||
|
setGameStatus('game-over');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hint Limits by Difficulty:**
|
||||||
|
- Easy (25 pts/hint): 20 hints max → 500 points
|
||||||
|
- Medium (50 pts/hint): 10 hints max → 500 points
|
||||||
|
- Hard (100 pts/hint): 5 hints max → 500 points
|
||||||
|
|
||||||
|
#### 3. Added Game Over Reason Tracking
|
||||||
|
**ScoreManager Extended:**
|
||||||
|
```typescript
|
||||||
|
export type ScoreManager = {
|
||||||
|
// ... existing fields
|
||||||
|
gameOverReason: string | null; // "Time's up!" or "Too many hints!"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reason Management:**
|
||||||
|
- Set when game-over triggered
|
||||||
|
- Reset on new game start
|
||||||
|
- Displayed to player in UI
|
||||||
|
|
||||||
|
#### 4. Updated Game Lifecycle
|
||||||
|
**Status Flow:**
|
||||||
|
```
|
||||||
|
waiting → started → [time/hints exceeded] → game-over
|
||||||
|
→ [puzzle complete] → game-won
|
||||||
|
|
||||||
|
game-over → [press space] → waiting → started (new game)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timer Behavior:**
|
||||||
|
- Stops on game-over
|
||||||
|
- Stops on game-won
|
||||||
|
- Resets on waiting
|
||||||
|
|
||||||
|
#### 5. Updated Example App Display
|
||||||
|
**Game Over Message:**
|
||||||
|
```tsx
|
||||||
|
{gameStatus === "game-over" && (
|
||||||
|
<p>{gameOverReason || "Game Over!"} Press <kbd>space</kbd> to restart</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows specific reason:
|
||||||
|
- "Time's up! Press space to restart"
|
||||||
|
- "Too many hints! Press space to restart"
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
|
||||||
|
**Check Frequency:**
|
||||||
|
- Both checks run on every render during 'started' state
|
||||||
|
- useEffect dependencies: `[gameStatus, timeValue, hintsUsed, difficulty, timeLimit]`
|
||||||
|
- Checks happen before win condition check
|
||||||
|
|
||||||
|
**Priority:**
|
||||||
|
1. Game-over checks (time, hints)
|
||||||
|
2. Win condition check (board complete)
|
||||||
|
|
||||||
|
**Time Limit Examples:**
|
||||||
|
```
|
||||||
|
Default (10 minutes):
|
||||||
|
- Player has 600 seconds to complete puzzle
|
||||||
|
- At 600s: game-over triggered
|
||||||
|
|
||||||
|
Custom (5 minutes):
|
||||||
|
- GameOptions: { timeLimit: 300 }
|
||||||
|
- Player has 300 seconds to complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hint Limit Examples:**
|
||||||
|
```
|
||||||
|
Easy mode (25 pts/hint, 500 limit):
|
||||||
|
- Hint 1-19: OK
|
||||||
|
- Hint 20: 20 × 25 = 500 → game-over
|
||||||
|
|
||||||
|
Hard mode (100 pts/hint, 500 limit):
|
||||||
|
- Hint 1-4: OK
|
||||||
|
- Hint 5: 5 × 100 = 500 → game-over
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Rationale
|
||||||
|
|
||||||
|
**Why Same Time Limit for All Difficulties?**
|
||||||
|
- Harder puzzles naturally take longer to solve
|
||||||
|
- Creates appropriate pressure at each level
|
||||||
|
- Simple and consistent rule
|
||||||
|
|
||||||
|
**Why 500 Point Hint Limit?**
|
||||||
|
- Half of max score (1000)
|
||||||
|
- Prevents hint abuse
|
||||||
|
- Scales appropriately with difficulty:
|
||||||
|
- Easy: More hints allowed (puzzle easier)
|
||||||
|
- Hard: Fewer hints allowed (puzzle harder)
|
||||||
|
|
||||||
|
**Why Two Separate Conditions?**
|
||||||
|
- Time limit: Encourages speed
|
||||||
|
- Hint limit: Encourages skill
|
||||||
|
- Both contribute to challenge
|
||||||
|
|
||||||
|
### Build Status
|
||||||
|
- ✅ TypeScript compiles cleanly
|
||||||
|
- ✅ Both game-over conditions working
|
||||||
|
- ✅ Reasons displayed correctly
|
||||||
|
- ✅ Timer stops on game-over
|
||||||
|
- ✅ Configurable time limit working
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
- Update README.md with complete API documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iteration 16: Configurable Game Options
|
||||||
|
|
||||||
|
**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!
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Lightweight, typed **React hooks** and utilities for building interactive sudoku games with **VIM-style modal editing**.
|
Lightweight, typed **React hooks** 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 +16,40 @@ Part of the [VIMazing](https://vimazing.com) project - [GitHub](https://github.c
|
|||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [VIM Modal Editing](#vim-modal-editing)
|
- [API Reference](#api-reference)
|
||||||
- [Core Hooks](#core-hooks)
|
- [Game States](#game-states)
|
||||||
- [Game Mechanics](#game-mechanics)
|
- [VIM Controls](#vim-controls)
|
||||||
|
- [Scoring System](#scoring-system)
|
||||||
|
- [Game Over Conditions](#game-over-conditions)
|
||||||
|
- [Hint System](#hint-system)
|
||||||
|
- [Configuration](#configuration)
|
||||||
- [Example App](#example-app)
|
- [Example App](#example-app)
|
||||||
- [Build & Release](#build--release)
|
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 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
|
- 🎮 **VIM modal editing** – Navigate in normal mode, edit cells with i/r/c commands
|
||||||
- 🎯 **Smart cell locking** – Pre-filled clues are protected with visual feedback
|
- ⌨️ **Full VIM motions** – hjkl, counts (5j), anchors (^/$), gg/G, repeat (.)
|
||||||
- ⏱️ **Time & keystroke limits** – Configurable game-over conditions
|
- 🎯 **Smart cell protection** – Pre-filled clues locked with visual feedback
|
||||||
- 📊 **Built-in scoring** – Time-based scoring with keystroke tracking
|
- 💡 **Hint system** – Get hints for incorrect cells or empty cells (Shift+H)
|
||||||
- 🎨 **Tokyo Night theme** – Beautiful dark theme with 3x3 box separators
|
- ⏱️ **Configurable limits** – Time limits and hint penalties trigger game-over
|
||||||
- 📦 **Typed API** – Full TypeScript support with generated declarations
|
- 📊 **Comprehensive scoring** – Time + keystrokes + hints with difficulty multipliers
|
||||||
- 🪝 **Composable hooks** – Separate concerns for game state, rendering, input, and scoring
|
- 🎨 **Tokyo Night theme** – Beautiful dark theme with proper 3x3 box separators
|
||||||
- 🌲 **Tree-shakeable exports** – Import only what you need
|
- 📦 **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
|
## Installation
|
||||||
|
|
||||||
Using **npm**:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @vimazing/vim-sudoku
|
npm install @vimazing/vim-sudoku
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with **bun**:
|
Or with bun:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun add @vimazing/vim-sudoku
|
bun add @vimazing/vim-sudoku
|
||||||
@@ -61,66 +64,212 @@ import { useGame } from "@vimazing/vim-sudoku";
|
|||||||
import "@vimazing/vim-sudoku/game.css";
|
import "@vimazing/vim-sudoku/game.css";
|
||||||
|
|
||||||
export function SudokuGame() {
|
export function SudokuGame() {
|
||||||
const { containerRef, initGame, gameStatus, scoreManager } = useGame();
|
const gameManager = useGame({ difficulty: 'easy' });
|
||||||
|
const { containerRef, gameStatus, scoreManager, startGame } = gameManager;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>VIMazing Sudoku</h1>
|
<h1>VIMazing Sudoku</h1>
|
||||||
<button onClick={initGame}>New Puzzle</button>
|
|
||||||
|
|
||||||
<div className="relative w-fit">
|
{gameStatus === 'waiting' && (
|
||||||
<div ref={containerRef} />
|
<button onClick={startGame}>Start Game</button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{gameStatus === "game-won" && (
|
<div ref={containerRef} />
|
||||||
<p>🎉 Score: {scoreManager.finalScore?.toLocaleString()}</p>
|
|
||||||
|
{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>
|
</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
|
#### 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 |
|
| Key | Action | Notes |
|
||||||
| --- | ------ | ----- |
|
|-----|--------|-------|
|
||||||
| `h` / `j` / `k` / `l` | Move cursor left/down/up/right | Core VIM navigation |
|
| `Shift+H` | Request hint | Penalty: 25/50/100 pts based on difficulty |
|
||||||
| `<count>h/j/k/l` | Move with count | e.g., `3j` = down 3, `5l` = right 5 |
|
| `q` | Quit game | Return to waiting state |
|
||||||
| `0` / `^` | Jump to start of row | Move to column 0 |
|
| `p` | Pause/unpause | Toggle pause state |
|
||||||
| `$` | Jump to end of row | Move to column 8 |
|
| `Space` | Start new game | Only in waiting/game-over state |
|
||||||
| `.` | Repeat last motion | Repeat with optional new count |
|
|
||||||
|
|
||||||
#### Editing
|
### Edit Mode (i/r/c to enter)
|
||||||
|
|
||||||
| 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
|
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| --- | ------ |
|
|-----|--------|
|
||||||
| `Q` | Quit game |
|
| `1-9` | Enter digit in current cell |
|
||||||
|
| `Backspace` | Clear current cell (stay in edit mode) |
|
||||||
### Edit Mode (I/R to enter)
|
| `Escape` | Exit to normal mode |
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
| --- | ------ |
|
|
||||||
| `1-9` | Fill cell with number |
|
|
||||||
| `x` / `Backspace` | Clear cell |
|
|
||||||
| `Esc` / `Enter` | Exit edit mode back to navigation |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,136 +277,197 @@ VIMazing Sudoku follows VIM's modal editing philosophy with strict insert/replac
|
|||||||
|
|
||||||
Understanding cell states is crucial for VIM-style editing:
|
Understanding cell states is crucial for VIM-style editing:
|
||||||
|
|
||||||
| Cell State | `I` (Insert) | `R` (Replace) | `D`/`X` (Delete) | Visual |
|
| Cell State | `i` Insert | `r`/`c` Replace | `x`/`d`/`Delete` | Visual Class |
|
||||||
| ---------- | ------------ | ------------- | ---------------- | ------ |
|
|------------|------------|-----------------|------------------|--------------|
|
||||||
| **Empty** (`.`) | ✅ Enter edit | 🔴 Flash (use I) | ➖ No effect | Normal |
|
| **Empty** | ✅ Enter multi-edit | 🔴 Flash (use i) | 🔴 Flash (nothing to delete) | None |
|
||||||
| **User-filled** | 🔴 Flash (use R) | ✅ Enter edit | ✅ Delete | Normal |
|
| **User-entered** | 🔴 Flash (use r/c) | ✅ Enter single-edit | ✅ Delete instantly | `.user-entered` |
|
||||||
| **Pre-filled clue** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | Darker text |
|
| **Given** | 🔴 Flash (locked) | 🔴 Flash (locked) | 🔴 Flash (locked) | `.given` |
|
||||||
|
|
||||||
### Visual Feedback
|
### Visual Feedback
|
||||||
|
|
||||||
- 🔵 **Blue glow** – Navigation mode (current cell)
|
- 🔵 **Blue outline** – Normal mode cursor (`.active`)
|
||||||
- 🟡 **Yellow pulsing glow** – Edit mode (typing active)
|
- 🟡 **Yellow pulsing glow** – Edit mode cursor (`.editing`)
|
||||||
- 🔴 **Red flash** – Invalid action (wrong mode, locked cell, or out of bounds)
|
- 🔴 **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
|
Base Score = 1000 - (time penalty) - (keystroke penalty) - (hint penalty)
|
||||||
Replace value: Press R → type 7 → Esc
|
Final Score = min(1000, max(0, round(Base Score × difficulty multiplier)))
|
||||||
Delete value: Press D (instant)
|
```
|
||||||
Move 3 down: Press 3j
|
|
||||||
Jump to end: Press $
|
### 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:**
|
### Hint Limit
|
||||||
- `validateMoves?: boolean` (default: `false`) - Enable real-time move validation with conflict detection. When `false`, allows harder gameplay without immediate feedback.
|
|
||||||
|
|
||||||
**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
|
Both conditions checked continuously during gameplay. Timer stops on game-over.
|
||||||
{
|
|
||||||
// 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
|
## Hint System
|
||||||
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
|
Press `Shift+H` in normal mode to request a hint.
|
||||||
scoreManager: {
|
|
||||||
timeValue: number, // Time in milliseconds
|
|
||||||
finalScore: number | null,
|
|
||||||
timeLimit: number, // 60000ms (60 seconds)
|
|
||||||
keystrokeLimit: number, // 100 keystrokes
|
|
||||||
},
|
|
||||||
|
|
||||||
// Key logging
|
### Hint Priority
|
||||||
keyLog: KeyLogEntry[],
|
|
||||||
editMode: "navigation" | "edit",
|
**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
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Individual Hooks
|
**Standard Easy:**
|
||||||
|
```typescript
|
||||||
|
useGame({ difficulty: 'easy' })
|
||||||
|
// 25 cells, 10 minutes, 1.0x multiplier
|
||||||
|
```
|
||||||
|
|
||||||
Export individual hooks for granular control:
|
**Standard Medium:**
|
||||||
|
```typescript
|
||||||
|
useGame({ difficulty: 'medium' })
|
||||||
|
// 40 cells, 10 minutes, 1.5x multiplier
|
||||||
|
```
|
||||||
|
|
||||||
```ts
|
**Standard Hard:**
|
||||||
import { useScore } from "@vimazing/vim-sudoku/useScore";
|
```typescript
|
||||||
import { useBoardState } from "@vimazing/vim-sudoku/useGame/useBoardState";
|
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)
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Game Mechanics
|
## Game Instructions Export
|
||||||
|
|
||||||
### Scoring
|
The package exports a structured `gameInfo` object containing complete game documentation:
|
||||||
|
|
||||||
Scoring formula with time and move penalties:
|
```typescript
|
||||||
```
|
import { gameInfo } from '@vimazing/vim-sudoku';
|
||||||
score = max(0, 100000 - timeInSeconds × 10 - extraMoves × 50)
|
|
||||||
|
// Access structured instructions
|
||||||
|
console.log(gameInfo.name); // "VIM Sudoku"
|
||||||
|
console.log(gameInfo.controls); // Navigation, editing, deletion, hints, game
|
||||||
|
console.log(gameInfo.rules); // Cell types, modes, visual feedback
|
||||||
|
console.log(gameInfo.scoring); // Formula, penalties, multipliers, examples
|
||||||
|
console.log(gameInfo.gameOver); // Time and hint limit conditions
|
||||||
|
console.log(gameInfo.hints); // How the hint system works
|
||||||
|
console.log(gameInfo.objective); // Win condition
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
**Use cases:**
|
||||||
- `baseScore = 100000`
|
- Render in-game help screens
|
||||||
- `timePenalty = timeInSeconds × 10`
|
- Generate tutorials
|
||||||
- `extraMoves = actualKeystrokes - minMovesRequired`
|
- Display control reference
|
||||||
- `movePenalty = extraMoves × 50`
|
- Show scoring breakdown
|
||||||
|
- Explain game mechanics
|
||||||
|
|
||||||
### Game Over Conditions
|
All data is fully typed with the `GameInfo` type for type safety.
|
||||||
|
|
||||||
- ⏱️ **Time limit**: 60 seconds
|
|
||||||
- ⌨️ **Keystroke limit**: 100 keystrokes
|
|
||||||
|
|
||||||
Customize in `src/useScore/useScore.ts`:
|
|
||||||
```ts
|
|
||||||
const TIME_LIMIT_MS = 60000;
|
|
||||||
const KEYSTROKE_LIMIT = 100;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Win Detection
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
Manual win trigger available via `solveBoard()` for testing.
|
|
||||||
|
|
||||||
### 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
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -267,24 +477,47 @@ A demo application lives under `example/` and consumes the package directly.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd example
|
cd example
|
||||||
bun install
|
npm install
|
||||||
bun run dev
|
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
|
```typescript
|
||||||
bun run build
|
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
|
## License
|
||||||
|
|||||||
+873
@@ -0,0 +1,873 @@
|
|||||||
|
# Unified API Specification
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Last Updated:** 2025-01-22
|
||||||
|
|
||||||
|
Complete specification for building VIMazing games. This document defines the architecture, requirements, and patterns that all games must follow to maintain consistency while allowing game-specific customization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Philosophy](#philosophy)
|
||||||
|
2. [Architecture Overview](#architecture-overview)
|
||||||
|
3. [Core Requirements](#core-requirements)
|
||||||
|
4. [Game-Specific Flexibility](#game-specific-flexibility)
|
||||||
|
5. [Hook Specifications](#hook-specifications)
|
||||||
|
6. [Type System](#type-system)
|
||||||
|
7. [Game Instructions Export](#game-instructions-export)
|
||||||
|
8. [Creating a New Game](#creating-a-new-game)
|
||||||
|
9. [Integration Guide](#integration-guide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
The Unified API is designed around three core principles:
|
||||||
|
|
||||||
|
### 1. Consistent Entry Point
|
||||||
|
Every game uses `useGame(options?, platformHook?)` as its single entry point. This ensures:
|
||||||
|
- Predictable initialization across all games
|
||||||
|
- Easy platform integration
|
||||||
|
- Consistent developer experience
|
||||||
|
|
||||||
|
### 2. Composable Architecture
|
||||||
|
Games are built from independent, composable hooks that manage distinct concerns:
|
||||||
|
- **useBoard** - Level/puzzle generation and rendering
|
||||||
|
- **useCursor** - Player input and interaction
|
||||||
|
- **useScore** - Metrics and performance tracking
|
||||||
|
- **useGameStatus** - Game lifecycle state machine
|
||||||
|
|
||||||
|
### 3. Freedom Within Constraints
|
||||||
|
The API defines WHAT must be exposed, not HOW it works internally. Games have complete freedom in:
|
||||||
|
- Scoring algorithms
|
||||||
|
- Rendering strategies
|
||||||
|
- Internal state management
|
||||||
|
- Additional game-specific features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### The Unified Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
useGame(options, platformHook?)
|
||||||
|
├─ useBoard() → Game-specific board/level management
|
||||||
|
├─ useCursor() → Player input and interaction
|
||||||
|
├─ useGameStatus() → Lifecycle state machine
|
||||||
|
└─ useScore() → Metrics and scoring (game-specific)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Input
|
||||||
|
↓
|
||||||
|
useCursor (validates, processes)
|
||||||
|
↓
|
||||||
|
useBoard (updates state)
|
||||||
|
↓
|
||||||
|
useScore (tracks metrics)
|
||||||
|
↓
|
||||||
|
useGameStatus (manages lifecycle)
|
||||||
|
↓
|
||||||
|
GameManager (unified interface)
|
||||||
|
↓
|
||||||
|
Platform/UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Requirements
|
||||||
|
|
||||||
|
These elements MUST be present in every VIMazing game:
|
||||||
|
|
||||||
|
### 1. Entry Point
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useGame(
|
||||||
|
options: GameOptions,
|
||||||
|
platformHook?: unknown
|
||||||
|
): GameManager
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- Accept options object (game-specific structure)
|
||||||
|
- Support optional platform hook function
|
||||||
|
- Return GameManager interface
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// vim-sudoku
|
||||||
|
useGame({ difficulty: 'easy', timeLimit: 600 })
|
||||||
|
|
||||||
|
// vim-maze
|
||||||
|
useGame({ rows: 24, cols: 32, timeLimit: 600 })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GameManager Interface
|
||||||
|
|
||||||
|
Every game must return a GameManager with these minimum fields:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type GameManager = {
|
||||||
|
// Required Core
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>; // DOM mounting point
|
||||||
|
renderBoard: () => void; // Force re-render
|
||||||
|
|
||||||
|
// Required State Management
|
||||||
|
gameStatus: GameStatus;
|
||||||
|
setGameStatus: (status: GameStatus) => void;
|
||||||
|
startGame: () => void;
|
||||||
|
quitGame: () => void;
|
||||||
|
|
||||||
|
// Required Managers
|
||||||
|
cursor: CursorManager; // Player interaction
|
||||||
|
scoreManager: ScoreManager; // Metrics and scoring
|
||||||
|
|
||||||
|
// Required Input Tracking
|
||||||
|
keyLog: KeyLogEntry[];
|
||||||
|
clearKeyLog: () => void;
|
||||||
|
getKeyLog: () => KeyLogEntry[];
|
||||||
|
|
||||||
|
// Optional: Game-specific additions
|
||||||
|
// [additional fields as needed]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Game Status State Machine
|
||||||
|
|
||||||
|
All games must implement this lifecycle:
|
||||||
|
|
||||||
|
```
|
||||||
|
waiting → started → game-won
|
||||||
|
↓
|
||||||
|
game-over
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required States:**
|
||||||
|
- `waiting` - Initial state, game not started
|
||||||
|
- `started` - Game in progress
|
||||||
|
- `game-won` - Successfully completed
|
||||||
|
- `game-over` - Failed to complete
|
||||||
|
|
||||||
|
**Optional States:**
|
||||||
|
- `paused` - Temporarily stopped
|
||||||
|
- Game-specific states (e.g., vim-maze's `has-key`)
|
||||||
|
|
||||||
|
### 4. Cursor/Input Manager
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type CursorManager = {
|
||||||
|
position: () => Coord; // Current position
|
||||||
|
mode: () => CursorMode; // Current mode
|
||||||
|
|
||||||
|
// Movement (VIM-style)
|
||||||
|
moveLeft: (count?: number) => void;
|
||||||
|
moveRight: (count?: number) => void;
|
||||||
|
moveUp: (count?: number) => void;
|
||||||
|
moveDown: (count?: number) => void;
|
||||||
|
|
||||||
|
// Anchors (VIM-style)
|
||||||
|
moveToStart: () => void; // ^ or 0
|
||||||
|
moveToEnd: () => void; // $
|
||||||
|
moveToTop: () => void; // gg
|
||||||
|
moveToBottom: () => void; // G
|
||||||
|
|
||||||
|
// Repeat (VIM-style)
|
||||||
|
repeatLastMotion: () => void; // .
|
||||||
|
|
||||||
|
// Optional: Game-specific methods
|
||||||
|
// [additional methods as needed]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Score Manager (Minimal Interface)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ScoreManager = {
|
||||||
|
// Required Timing
|
||||||
|
timeValue: number; // Current time in milliseconds
|
||||||
|
|
||||||
|
// Required Metrics
|
||||||
|
totalKeystrokes: number; // Total keys pressed
|
||||||
|
|
||||||
|
// Required Result
|
||||||
|
finalScore: number | null; // 0-1000 (null until game-won)
|
||||||
|
|
||||||
|
// Optional: Game-specific metrics
|
||||||
|
// [additional fields as needed]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scoring Requirements:**
|
||||||
|
- Score range: 0-1000 (capped)
|
||||||
|
- Score calculated on game-won
|
||||||
|
- Based on time and keystrokes (minimum)
|
||||||
|
- Can include game-specific penalties/bonuses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game-Specific Flexibility
|
||||||
|
|
||||||
|
These elements CAN vary between games:
|
||||||
|
|
||||||
|
### 1. GameOptions Structure
|
||||||
|
|
||||||
|
Each game defines its own options:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// vim-sudoku
|
||||||
|
type GameOptions = {
|
||||||
|
difficulty?: 'easy' | 'medium' | 'hard';
|
||||||
|
timeLimit?: number;
|
||||||
|
removedCells?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// vim-maze
|
||||||
|
type GameOptions = {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
timeLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// vim-snake (hypothetical)
|
||||||
|
type GameOptions = {
|
||||||
|
boardSize?: number;
|
||||||
|
speed?: 'slow' | 'medium' | 'fast';
|
||||||
|
obstacles?: boolean;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended Defaults:**
|
||||||
|
- Always provide sensible defaults
|
||||||
|
- Make most options optional
|
||||||
|
- Use consistent naming (e.g., `timeLimit` always in seconds)
|
||||||
|
|
||||||
|
### 2. Scoring Implementation
|
||||||
|
|
||||||
|
Complete freedom in how scoring works internally:
|
||||||
|
|
||||||
|
**vim-sudoku approach:**
|
||||||
|
```typescript
|
||||||
|
// Simple formula: time + keystrokes + hints
|
||||||
|
const baseScore = 1000 - timePenalty - keystrokePenalty - hintPenalty;
|
||||||
|
const finalScore = min(1000, baseScore * difficultyMultiplier);
|
||||||
|
```
|
||||||
|
|
||||||
|
**vim-maze approach:**
|
||||||
|
```typescript
|
||||||
|
// Size-based: time + keystrokes × maze size
|
||||||
|
const baseScore = 1000 - timePenalty - keystrokePenalty;
|
||||||
|
const sizeMultiplier = max(1.0, (rows × cols) / 500);
|
||||||
|
const finalScore = min(1000, baseScore × sizeMultiplier);
|
||||||
|
```
|
||||||
|
|
||||||
|
**vim-snake approach (hypothetical):**
|
||||||
|
```typescript
|
||||||
|
// Performance-based: length achieved vs time
|
||||||
|
const lengthScore = (snakeLength / maxLength) × 500;
|
||||||
|
const timeBonus = (timeRemaining / timeLimit) × 500;
|
||||||
|
const finalScore = min(1000, lengthScore + timeBonus);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Rendering Strategy
|
||||||
|
|
||||||
|
Games choose their own rendering approach:
|
||||||
|
|
||||||
|
**DOM Rendering (vim-maze, vim-sudoku):**
|
||||||
|
```typescript
|
||||||
|
class GameRenderer {
|
||||||
|
constructor(container: HTMLElement) { }
|
||||||
|
render(data: GameData): void {
|
||||||
|
// Direct DOM manipulation
|
||||||
|
container.innerHTML = generateHTML(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**React Rendering (hypothetical):**
|
||||||
|
```typescript
|
||||||
|
function GameBoard({ boardState }: Props) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{boardState.map(cell => <Cell key={cell.id} {...cell} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Canvas Rendering (hypothetical):**
|
||||||
|
```typescript
|
||||||
|
class CanvasRenderer {
|
||||||
|
render(ctx: CanvasRenderingContext2D, gameState: State): void {
|
||||||
|
// Direct canvas drawing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Internal State Management
|
||||||
|
|
||||||
|
Games manage state however they need:
|
||||||
|
|
||||||
|
**vim-sudoku:**
|
||||||
|
- Board as 81-character string
|
||||||
|
- Initial board (immutable)
|
||||||
|
- Solution board (for validation)
|
||||||
|
- No complex animation state
|
||||||
|
|
||||||
|
**vim-maze:**
|
||||||
|
- 2D array of maze cells
|
||||||
|
- Hero position tracking
|
||||||
|
- Animation state for multi-step movements
|
||||||
|
- Distance calculations for pathfinding
|
||||||
|
|
||||||
|
**vim-snake (hypothetical):**
|
||||||
|
- Snake segments array
|
||||||
|
- Food position
|
||||||
|
- Direction queue
|
||||||
|
- Collision detection
|
||||||
|
|
||||||
|
### 5. Additional Features
|
||||||
|
|
||||||
|
Games can add unique features:
|
||||||
|
|
||||||
|
**vim-sudoku specific:**
|
||||||
|
- Hint system (Shift+H)
|
||||||
|
- Edit modes (i/r/c for different entry styles)
|
||||||
|
- Cell validation (given vs user-entered)
|
||||||
|
|
||||||
|
**vim-maze specific:**
|
||||||
|
- Key pickup mechanic
|
||||||
|
- Wall collision detection
|
||||||
|
- Entrance/exit system
|
||||||
|
- Distance tracking
|
||||||
|
|
||||||
|
**vim-snake specific (hypothetical):**
|
||||||
|
- Power-ups
|
||||||
|
- Speed changes
|
||||||
|
- Obstacle generation
|
||||||
|
- Multiplayer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hook Specifications
|
||||||
|
|
||||||
|
### useBoard
|
||||||
|
|
||||||
|
**Purpose:** Manage game board/level state and rendering.
|
||||||
|
|
||||||
|
**Flexibility:** Complete freedom in implementation.
|
||||||
|
|
||||||
|
**Minimum Interface:**
|
||||||
|
```typescript
|
||||||
|
type BoardManager = {
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
renderBoard: () => void;
|
||||||
|
|
||||||
|
// Game-specific additions allowed
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Common Patterns:**
|
||||||
|
```typescript
|
||||||
|
// Pattern 1: Generator + Renderer classes
|
||||||
|
const generator = new GameGenerator(options);
|
||||||
|
const renderer = new GameRenderer(container);
|
||||||
|
generator.generate();
|
||||||
|
renderer.render(generator.getData());
|
||||||
|
|
||||||
|
// Pattern 2: Direct state management
|
||||||
|
const [board, setBoard] = useState(generateInitial());
|
||||||
|
renderBoard(board);
|
||||||
|
|
||||||
|
// Pattern 3: Canvas-based
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
drawBoard(canvasRef.current, boardState);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useCursor
|
||||||
|
|
||||||
|
**Purpose:** Handle player input and interaction.
|
||||||
|
|
||||||
|
**Required:** VIM-style navigation (hjkl, counts, anchors, repeat).
|
||||||
|
|
||||||
|
**Minimum Interface:**
|
||||||
|
```typescript
|
||||||
|
type CursorManager = {
|
||||||
|
position: () => Coord;
|
||||||
|
mode: () => CursorMode;
|
||||||
|
|
||||||
|
// VIM motions (required)
|
||||||
|
moveLeft: (count?: number) => void;
|
||||||
|
moveRight: (count?: number) => void;
|
||||||
|
moveUp: (count?: number) => void;
|
||||||
|
moveDown: (count?: number) => void;
|
||||||
|
moveToStart: () => void;
|
||||||
|
moveToEnd: () => void;
|
||||||
|
moveToTop: () => void;
|
||||||
|
moveToBottom: () => void;
|
||||||
|
repeatLastMotion: () => void;
|
||||||
|
|
||||||
|
// Game-specific additions allowed
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mode System:**
|
||||||
|
All games use at minimum:
|
||||||
|
- `normal` - Navigation mode
|
||||||
|
- `edit` - Interaction mode (if applicable)
|
||||||
|
|
||||||
|
Games can add modes as needed (e.g., sudoku's distinction between i/r/c entry modes).
|
||||||
|
|
||||||
|
### useScore
|
||||||
|
|
||||||
|
**Purpose:** Track metrics and calculate final score.
|
||||||
|
|
||||||
|
**Flexibility:** Almost complete freedom.
|
||||||
|
|
||||||
|
**Minimum Interface:**
|
||||||
|
```typescript
|
||||||
|
type ScoreManager = {
|
||||||
|
timeValue: number;
|
||||||
|
totalKeystrokes: number;
|
||||||
|
finalScore: number | null;
|
||||||
|
|
||||||
|
// Game-specific metrics allowed
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- Track time in milliseconds
|
||||||
|
- Track total keystrokes
|
||||||
|
- Calculate score 0-1000 (capped)
|
||||||
|
- Set finalScore only on game-won
|
||||||
|
|
||||||
|
**Optional but Recommended:**
|
||||||
|
- Game-over conditions based on time/performance
|
||||||
|
- Additional metrics for display
|
||||||
|
- Difficulty multipliers
|
||||||
|
|
||||||
|
### useGameStatus
|
||||||
|
|
||||||
|
**Purpose:** Manage game lifecycle state machine.
|
||||||
|
|
||||||
|
**Required States:**
|
||||||
|
```typescript
|
||||||
|
type GameStatus =
|
||||||
|
| 'waiting'
|
||||||
|
| 'started'
|
||||||
|
| 'game-won'
|
||||||
|
| 'game-over'
|
||||||
|
| ... // Optional game-specific states
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimum Interface:**
|
||||||
|
```typescript
|
||||||
|
type GameStatusManager = {
|
||||||
|
gameStatus: GameStatus;
|
||||||
|
setGameStatus: (status: GameStatus) => void;
|
||||||
|
startGame: () => void;
|
||||||
|
quitGame: () => void;
|
||||||
|
|
||||||
|
// Optional: togglePause, reset, etc.
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type System
|
||||||
|
|
||||||
|
### Required Core Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Coordinates (all games use this)
|
||||||
|
type Coord = { row: number; col: number };
|
||||||
|
|
||||||
|
// Key logging (all games track this)
|
||||||
|
type KeyLogEntry = { key: string; timestamp: number };
|
||||||
|
|
||||||
|
// Game status (required states)
|
||||||
|
type GameStatus = 'waiting' | 'started' | 'game-won' | 'game-over' | ...;
|
||||||
|
|
||||||
|
// Cursor mode (at minimum)
|
||||||
|
type CursorMode = 'normal' | 'edit' | ...;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Organization
|
||||||
|
|
||||||
|
Recommended structure:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types.ts # Game-specific exports + core types
|
||||||
|
├── useBoard/
|
||||||
|
│ └── types.ts # Board-specific types
|
||||||
|
├── useCursor/
|
||||||
|
│ └── types.ts # Cursor-specific types
|
||||||
|
├── useGameStatus/
|
||||||
|
│ └── types.ts # Status-specific types
|
||||||
|
└── useScore/
|
||||||
|
└── types.ts # Score-specific types
|
||||||
|
```
|
||||||
|
|
||||||
|
**Main types.ts pattern:**
|
||||||
|
```typescript
|
||||||
|
// Re-export all module types
|
||||||
|
export * from './useBoard/types';
|
||||||
|
export * from './useCursor/types';
|
||||||
|
export * from './useGameStatus/types';
|
||||||
|
export * from './useScore/types';
|
||||||
|
|
||||||
|
// Game-specific types
|
||||||
|
export type GameOptions = { /* game-specific */ };
|
||||||
|
export type GameManager = { /* unified interface */ };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Game Instructions Export
|
||||||
|
|
||||||
|
### Required Export
|
||||||
|
|
||||||
|
All games must export a `gameInfo` object containing structured documentation for platform consumption.
|
||||||
|
|
||||||
|
**File:** `src/gameInfo.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const gameInfo = {
|
||||||
|
name: string; // Game name
|
||||||
|
description: string; // Brief description
|
||||||
|
|
||||||
|
controls: {
|
||||||
|
navigation: Array<{ keys: string; description: string }>;
|
||||||
|
// Additional control sections as needed
|
||||||
|
game: Array<{ keys: string; description: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
// Game-specific rule sections
|
||||||
|
};
|
||||||
|
|
||||||
|
scoring: {
|
||||||
|
formula: string;
|
||||||
|
range: string;
|
||||||
|
penalties: Array<{ factor: string; formula: string; example: string }>;
|
||||||
|
examples: Array<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
gameOver: {
|
||||||
|
conditions: Array<{ type: string; trigger: string; message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
objective: string; // How to win (one sentence)
|
||||||
|
winCondition: string; // Specific win trigger
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type GameInfo = typeof gameInfo;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Export from main index:**
|
||||||
|
```typescript
|
||||||
|
// src/index.ts
|
||||||
|
export { gameInfo } from './gameInfo';
|
||||||
|
export type { GameInfo } from './gameInfo';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
The `gameInfo` export allows platforms to:
|
||||||
|
- Render in-game help screens
|
||||||
|
- Generate tutorials
|
||||||
|
- Display control references
|
||||||
|
- Show scoring breakdowns
|
||||||
|
- Explain game mechanics
|
||||||
|
|
||||||
|
Without needing to parse documentation or README files.
|
||||||
|
|
||||||
|
### Usage by Platforms
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { gameInfo } from '@vimazing/your-game';
|
||||||
|
|
||||||
|
// Render controls
|
||||||
|
gameInfo.controls.navigation.forEach(control => {
|
||||||
|
renderControl(control.keys, control.description);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show scoring formula
|
||||||
|
displayScoringInfo(gameInfo.scoring.formula, gameInfo.scoring.penalties);
|
||||||
|
|
||||||
|
// Display game-over conditions
|
||||||
|
gameInfo.gameOver.conditions.forEach(condition => {
|
||||||
|
showCondition(condition.type, condition.trigger);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required Sections
|
||||||
|
|
||||||
|
**Minimum structure:**
|
||||||
|
- `name` - Game name
|
||||||
|
- `description` - Brief description
|
||||||
|
- `controls.navigation` - VIM motion controls (required)
|
||||||
|
- `controls.game` - Game lifecycle controls (space, q, p)
|
||||||
|
- `rules` - At least one rule section
|
||||||
|
- `scoring.formula` - Score calculation
|
||||||
|
- `scoring.range` - Score bounds (always "0 - 1000 points")
|
||||||
|
- `gameOver.conditions` - At least one condition
|
||||||
|
- `objective` - Win goal
|
||||||
|
- `winCondition` - Specific trigger
|
||||||
|
|
||||||
|
**Optional sections:**
|
||||||
|
- Additional control groups (editing, actions, etc.)
|
||||||
|
- Visual feedback explanations
|
||||||
|
- Hints/power-ups/special mechanics
|
||||||
|
- Examples and breakdowns
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
See implementations:
|
||||||
|
- **vim-sudoku/src/gameInfo.ts** - Complex puzzle game with modes, hints
|
||||||
|
- **vim-maze/src/gameInfo.ts** - Navigation game with maze mechanics
|
||||||
|
- **vimazing-game-skeleton/src/gameInfo.ts** - Template with TODOs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a New Game
|
||||||
|
|
||||||
|
### Step 1: Define Your Game
|
||||||
|
|
||||||
|
Answer these questions:
|
||||||
|
- What is the game mechanic?
|
||||||
|
- What does the board/level look like?
|
||||||
|
- How does the player interact?
|
||||||
|
- What constitutes winning/losing?
|
||||||
|
- How should scoring work?
|
||||||
|
|
||||||
|
### Step 2: Design GameOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type GameOptions = {
|
||||||
|
// Required configuration
|
||||||
|
[key: string]: any;
|
||||||
|
|
||||||
|
// Recommended: timeLimit
|
||||||
|
timeLimit?: number; // Seconds
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Implement Core Hooks
|
||||||
|
|
||||||
|
**useBoard:**
|
||||||
|
```typescript
|
||||||
|
export function useBoard(/* game-specific params */) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
// Your rendering logic
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerRef,
|
||||||
|
renderBoard,
|
||||||
|
// Additional methods
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**useCursor:**
|
||||||
|
```typescript
|
||||||
|
export function useCursor(/* dependencies */) {
|
||||||
|
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
||||||
|
const [mode, setMode] = useState<CursorMode>('normal');
|
||||||
|
|
||||||
|
// Implement VIM motions
|
||||||
|
function moveLeft(count = 1) { /* ... */ }
|
||||||
|
function moveRight(count = 1) { /* ... */ }
|
||||||
|
// ... etc
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: () => position,
|
||||||
|
mode: () => mode,
|
||||||
|
moveLeft,
|
||||||
|
moveRight,
|
||||||
|
// ... etc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**useScore:**
|
||||||
|
```typescript
|
||||||
|
export function useScore(/* dependencies */) {
|
||||||
|
const [timeValue, setTimeValue] = useState(0);
|
||||||
|
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||||
|
const totalKeystrokes = keyLog.length;
|
||||||
|
|
||||||
|
// Calculate score on game-won
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus === 'game-won') {
|
||||||
|
const score = calculateScore(timeValue, totalKeystrokes);
|
||||||
|
setFinalScore(Math.min(1000, Math.max(0, score)));
|
||||||
|
}
|
||||||
|
}, [gameStatus]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeValue,
|
||||||
|
totalKeystrokes,
|
||||||
|
finalScore,
|
||||||
|
// Additional metrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**useGameStatus:**
|
||||||
|
```typescript
|
||||||
|
export function useGameStatus() {
|
||||||
|
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
setGameStatus('started');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitGame() {
|
||||||
|
setGameStatus('waiting');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameStatus,
|
||||||
|
setGameStatus,
|
||||||
|
startGame,
|
||||||
|
quitGame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Compose in useGame
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function useGame(
|
||||||
|
options: GameOptions,
|
||||||
|
platformHook?: unknown
|
||||||
|
): GameManager {
|
||||||
|
const board = useBoard(/* options */);
|
||||||
|
const gameStatusManager = useGameStatus();
|
||||||
|
const cursor = useCursor(/* dependencies */);
|
||||||
|
const scoreManager = useScore(/* dependencies */);
|
||||||
|
|
||||||
|
const gameManager: GameManager = {
|
||||||
|
containerRef: board.containerRef,
|
||||||
|
renderBoard: board.renderBoard,
|
||||||
|
gameStatus: gameStatusManager.gameStatus,
|
||||||
|
setGameStatus: gameStatusManager.setGameStatus,
|
||||||
|
startGame: gameStatusManager.startGame,
|
||||||
|
quitGame: gameStatusManager.quitGame,
|
||||||
|
cursor,
|
||||||
|
scoreManager,
|
||||||
|
keyLog: [], // Implement key tracking
|
||||||
|
clearKeyLog: () => {},
|
||||||
|
getKeyLog: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof platformHook === 'function') {
|
||||||
|
platformHook(gameManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameManager;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Export Public API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/index.ts
|
||||||
|
export { useGame } from './useGame';
|
||||||
|
export type { GameManager, GameOptions } from './types';
|
||||||
|
export type { CursorManager } from './useCursor/types';
|
||||||
|
export type { ScoreManager } from './useScore/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Guide
|
||||||
|
|
||||||
|
### For Game Consumers
|
||||||
|
|
||||||
|
**Basic Usage:**
|
||||||
|
```typescript
|
||||||
|
import { useGame } from '@vimazing/your-game';
|
||||||
|
import '@vimazing/your-game/game.css';
|
||||||
|
|
||||||
|
function GameComponent() {
|
||||||
|
const game = useGame(options);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div ref={game.containerRef} />
|
||||||
|
<button onClick={game.startGame}>Start</button>
|
||||||
|
<p>Status: {game.gameStatus}</p>
|
||||||
|
<p>Score: {game.scoreManager.finalScore || 0} / 1000</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**With Platform Hook:**
|
||||||
|
```typescript
|
||||||
|
function platformHook(gameManager: GameManager) {
|
||||||
|
// Analytics
|
||||||
|
trackGameStart(gameManager.gameStatus);
|
||||||
|
|
||||||
|
// Custom keybindings
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'h') trackHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor completion
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (gameManager.gameStatus === 'game-won') {
|
||||||
|
trackVictory(gameManager.scoreManager.finalScore);
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const game = useGame(options, platformHook);
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Platform Developers
|
||||||
|
|
||||||
|
All games expose the same core interface:
|
||||||
|
```typescript
|
||||||
|
interface UnifiedGame {
|
||||||
|
useGame(options: any, platformHook?: Function): GameManager;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration checklist:**
|
||||||
|
- ✅ Game exposes `useGame` entry point
|
||||||
|
- ✅ Returns `GameManager` with required fields
|
||||||
|
- ✅ Accepts optional platform hook
|
||||||
|
- ✅ Implements core game status states
|
||||||
|
- ✅ Provides VIM-style cursor navigation
|
||||||
|
- ✅ Scores on 0-1000 scale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
**v2.0** (2025-01-22)
|
||||||
|
- Rewritten for game-agnostic specification
|
||||||
|
- Clarified requirements vs flexibility
|
||||||
|
- Added comprehensive creation guide
|
||||||
|
- Removed game-specific implementation details
|
||||||
|
- Simplified scoring requirements
|
||||||
|
|
||||||
|
**v1.0** (2024)
|
||||||
|
- Initial unified API documentation
|
||||||
|
- Based on vim-maze implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT © VIMazing Project
|
||||||
+2
-2
@@ -8,7 +8,7 @@ function App() {
|
|||||||
const [difficulty, setDifficulty] = useState<Difficulty>('easy');
|
const [difficulty, setDifficulty] = useState<Difficulty>('easy');
|
||||||
const gameManager = useGame({ difficulty }, useKeyBindings);
|
const gameManager = useGame({ difficulty }, useKeyBindings);
|
||||||
const { containerRef, gameStatus, scoreManager } = gameManager;
|
const { containerRef, gameStatus, scoreManager } = gameManager;
|
||||||
const { timeValue, totalKeystrokes, hintsUsed, finalScore } = scoreManager;
|
const { timeValue, totalKeystrokes, hintsUsed, finalScore, gameOverReason } = scoreManager;
|
||||||
|
|
||||||
const formatTime = (ms: number) => {
|
const formatTime = (ms: number) => {
|
||||||
const seconds = Math.floor(ms / 1000);
|
const seconds = Math.floor(ms / 1000);
|
||||||
@@ -99,7 +99,7 @@ function App() {
|
|||||||
<div className="text-center text-2xl text-muted-foreground">
|
<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 === "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">Shift+H</kbd> for hint • 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-over" && <p>{gameOverReason || "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>}
|
{gameStatus === "game-won" && <p>You Won! Press <kbd className="px-2 py-1 bg-muted rounded">space</kbd> to play again</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@vimazing/vim-maze",
|
"name": "@vimazing/vim-maze",
|
||||||
"version": "0.11.0",
|
"version": "2.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@vimazing/vim-maze",
|
"name": "@vimazing/vim-maze",
|
||||||
"version": "0.11.0",
|
"version": "2.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@vimazing/vim-sudoku",
|
"name": "@vimazing/vim-sudoku",
|
||||||
"version": "0.11.0",
|
"version": "2.0.1",
|
||||||
"description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.",
|
"description": "VIMazing VIM Sudoku engine — a lightweight, typed React hook set for sudoku games.",
|
||||||
"author": "André Padez <andre.padez@gmail.com>",
|
"author": "André Padez <andre.padez@gmail.com>",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
export const gameInfo = {
|
||||||
|
name: 'VIM Sudoku',
|
||||||
|
description: 'Classic Sudoku puzzle with VIM-style modal editing',
|
||||||
|
|
||||||
|
controls: {
|
||||||
|
navigation: [
|
||||||
|
{ keys: 'hjkl', description: 'Move cursor left/down/up/right' },
|
||||||
|
{ keys: '5j, 10l', description: 'Move with count (5 down, 10 right)' },
|
||||||
|
{ keys: '0 or ^', description: 'Jump to start of row' },
|
||||||
|
{ keys: '$', description: 'Jump to end of row' },
|
||||||
|
{ keys: 'gg', description: 'Jump to top of board' },
|
||||||
|
{ keys: 'G', description: 'Jump to bottom of board' },
|
||||||
|
{ keys: '.', description: 'Repeat last motion' },
|
||||||
|
],
|
||||||
|
|
||||||
|
editing: [
|
||||||
|
{ keys: 'i', description: 'Insert digit (works on empty cells only)' },
|
||||||
|
{ keys: 'r', description: 'Replace digit (works on user-entered cells only)' },
|
||||||
|
{ keys: 'c', description: 'Change digit (works on user-entered cells only)' },
|
||||||
|
{ keys: '1-9', description: 'Enter digit (while in edit mode)' },
|
||||||
|
{ keys: 'Escape', description: 'Exit edit mode to normal mode' },
|
||||||
|
],
|
||||||
|
|
||||||
|
deletion: [
|
||||||
|
{ keys: 'x', description: 'Delete digit from user-entered cell' },
|
||||||
|
{ keys: 'd', description: 'Delete digit from user-entered cell' },
|
||||||
|
{ keys: 'Delete', description: 'Delete digit from user-entered cell' },
|
||||||
|
{ keys: 'Backspace', description: 'Delete digit (only in edit mode)' },
|
||||||
|
],
|
||||||
|
|
||||||
|
hints: [
|
||||||
|
{ keys: 'Shift+H', description: 'Request hint (costs 25/50/100 points by difficulty)' },
|
||||||
|
],
|
||||||
|
|
||||||
|
game: [
|
||||||
|
{ keys: 'Space', description: 'Start new game (when waiting)' },
|
||||||
|
{ keys: 'q', description: 'Quit game' },
|
||||||
|
{ keys: 'p', description: 'Pause/unpause game' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
cellTypes: [
|
||||||
|
{ type: 'Empty', canEdit: 'i only', canReplace: 'no', canDelete: 'no', description: 'Empty cell ready for input' },
|
||||||
|
{ type: 'User-entered', canEdit: 'r or c only', canReplace: 'yes', canDelete: 'yes', description: 'Digit you entered' },
|
||||||
|
{ type: 'Given', canEdit: 'no', canReplace: 'no', canDelete: 'no', description: 'Original puzzle digit (locked)' },
|
||||||
|
],
|
||||||
|
|
||||||
|
modes: [
|
||||||
|
{
|
||||||
|
name: 'Normal Mode',
|
||||||
|
indicator: 'Blue outline on cursor',
|
||||||
|
description: 'Navigate with hjkl, access all commands',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit Mode (i)',
|
||||||
|
indicator: 'Yellow pulsing glow on cursor',
|
||||||
|
description: 'Type multiple digits, press Escape to exit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Edit Mode (r/c)',
|
||||||
|
indicator: 'Yellow pulsing glow on cursor',
|
||||||
|
description: 'Type one digit, auto-exits to normal mode',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
visualFeedback: [
|
||||||
|
{ indicator: 'Blue outline', meaning: 'Current cell in normal mode' },
|
||||||
|
{ indicator: 'Yellow pulsing glow', meaning: 'Current cell in edit mode' },
|
||||||
|
{ indicator: 'Red flash (500ms)', meaning: 'Invalid move or action' },
|
||||||
|
{ indicator: 'Red flash + background (1s)', meaning: 'Hint: this cell is incorrect' },
|
||||||
|
{ indicator: 'Green flash + background (1s)', meaning: 'Hint: showing correct digit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
scoring: {
|
||||||
|
formula: 'Base Score = 1000 - (time penalty) - (keystroke penalty) - (hint penalty)',
|
||||||
|
multiplier: 'Final Score = min(1000, Base Score × difficulty multiplier)',
|
||||||
|
range: '0 - 1000 points',
|
||||||
|
|
||||||
|
penalties: [
|
||||||
|
{ factor: 'Time', formula: 'seconds / 10', example: '60 seconds = -6 points' },
|
||||||
|
{ factor: 'Keystrokes', formula: 'total keys / 2', example: '100 keys = -50 points' },
|
||||||
|
{ factor: 'Hints (Easy)', formula: 'hints × 25', example: '3 hints = -75 points' },
|
||||||
|
{ factor: 'Hints (Medium)', formula: 'hints × 50', example: '3 hints = -150 points' },
|
||||||
|
{ factor: 'Hints (Hard)', formula: 'hints × 100', example: '3 hints = -300 points' },
|
||||||
|
],
|
||||||
|
|
||||||
|
multipliers: [
|
||||||
|
{ difficulty: 'Easy', multiplier: '1.0x', description: 'No bonus' },
|
||||||
|
{ difficulty: 'Medium', multiplier: '1.5x', description: 'Can exceed base 1000, capped at 1000' },
|
||||||
|
{ difficulty: 'Hard', multiplier: '2.0x', description: 'Can exceed base 1000, capped at 1000' },
|
||||||
|
],
|
||||||
|
|
||||||
|
examples: [
|
||||||
|
{
|
||||||
|
difficulty: 'Easy',
|
||||||
|
time: '30 seconds',
|
||||||
|
keystrokes: 50,
|
||||||
|
hints: 0,
|
||||||
|
calculation: '1000 - 3 - 25 - 0 = 972 × 1.0',
|
||||||
|
score: '972 / 1000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
difficulty: 'Hard',
|
||||||
|
time: '120 seconds',
|
||||||
|
keystrokes: 150,
|
||||||
|
hints: 2,
|
||||||
|
calculation: '1000 - 12 - 75 - 200 = 713 × 2.0',
|
||||||
|
score: '1000 / 1000 (capped)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
gameOver: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
type: 'Time Limit',
|
||||||
|
trigger: '10 minutes (600 seconds) by default',
|
||||||
|
configurable: true,
|
||||||
|
message: "Time's up!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Hint Limit',
|
||||||
|
trigger: '500 points total hint penalty',
|
||||||
|
breakdown: 'Easy: 20 hints | Medium: 10 hints | Hard: 5 hints',
|
||||||
|
configurable: false,
|
||||||
|
message: 'Too many hints!',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
hints: {
|
||||||
|
description: 'Press Shift+H to get help when stuck',
|
||||||
|
priority: [
|
||||||
|
{
|
||||||
|
level: 1,
|
||||||
|
behavior: 'Show Incorrect Cell',
|
||||||
|
description: 'Randomly select a user-entered cell with wrong value and flash it red for 1 second',
|
||||||
|
reveals: 'Location of error (not the correct digit)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 2,
|
||||||
|
behavior: 'Show Correct Digit',
|
||||||
|
description: 'If no errors exist, show correct digit for a random empty cell with green flash for 1 second',
|
||||||
|
reveals: 'Correct digit temporarily (you must remember it)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cost: 'Deducted from final score based on difficulty',
|
||||||
|
limit: 'Game-over after 500 points of penalties',
|
||||||
|
},
|
||||||
|
|
||||||
|
objective: 'Fill all empty cells with digits 1-9 such that each row, column, and 3×3 box contains each digit exactly once.',
|
||||||
|
|
||||||
|
winCondition: 'All cells filled correctly (no validation enforced - trust yourself!)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type GameInfo = typeof gameInfo;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
export { useGame } from './useGame';
|
export { useGame } from './useGame';
|
||||||
export { useGameStatus } from './useGameStatus';
|
export { useGameStatus } from './useGameStatus';
|
||||||
export type { GameManager, GameOptions, GameStatus, GameStatusManager, Difficulty } from './types';
|
export type { GameManager, GameOptions, GameStatus, GameStatusManager, Difficulty } from './types';
|
||||||
|
export { gameInfo } from './gameInfo';
|
||||||
|
export type { GameInfo } from './gameInfo';
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type { ScoreManager } from './useScore/types';
|
|||||||
|
|
||||||
export type GameOptions = {
|
export type GameOptions = {
|
||||||
difficulty?: Difficulty;
|
difficulty?: Difficulty;
|
||||||
|
timeLimit?: number;
|
||||||
|
removedCells?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type KeyLogEntry = { key: string; timestamp: number };
|
export type KeyLogEntry = { key: string; timestamp: number };
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ export class SudokuGenerator {
|
|||||||
this.generate('medium');
|
this.generate('medium');
|
||||||
}
|
}
|
||||||
|
|
||||||
generate(difficulty: Difficulty = 'medium'): void {
|
generate(difficulty: Difficulty = 'medium', customRemovedCells?: number): void {
|
||||||
this.solution = this.generateSolvedBoard();
|
this.solution = this.generateSolvedBoard();
|
||||||
|
|
||||||
if (!this.validateFullBoard(this.solution)) {
|
if (!this.validateFullBoard(this.solution)) {
|
||||||
throw new Error('Generated invalid solved board');
|
throw new Error('Generated invalid solved board');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.puzzle = this.makePuzzle(this.solution, difficulty);
|
this.puzzle = this.makePuzzle(this.solution, difficulty, customRemovedCells);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSolution(): string {
|
getSolution(): string {
|
||||||
@@ -74,12 +74,12 @@ export class SudokuGenerator {
|
|||||||
return board.flat().join('');
|
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)) {
|
if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
|
||||||
throw new Error('Invalid solved board: must be 81 digits 1–9');
|
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 board = solution.split('');
|
||||||
|
|
||||||
const indices = Array.from({ length: 81 }, (_, i) => i);
|
const indices = Array.from({ length: 81 }, (_, i) => i);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type BoardManager = {
|
|||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
boardRef: RefObject<string>;
|
boardRef: RefObject<string>;
|
||||||
boardRendered: number;
|
boardRendered: number;
|
||||||
generateBoard: (difficulty: Difficulty) => void;
|
generateBoard: (difficulty: Difficulty, removedCells?: number) => void;
|
||||||
renderBoard: () => void;
|
renderBoard: () => void;
|
||||||
setCell: (row: number, col: number, value: string) => void;
|
setCell: (row: number, col: number, value: string) => void;
|
||||||
getHint: () => HintResult;
|
getHint: () => HintResult;
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ export function useBoard() {
|
|||||||
const initialBoardRef = useRef<string>('');
|
const initialBoardRef = useRef<string>('');
|
||||||
const [boardRendered, setBoardRendered] = useState(0);
|
const [boardRendered, setBoardRendered] = useState(0);
|
||||||
|
|
||||||
function generateBoard(difficulty: Difficulty = 'easy') {
|
function generateBoard(difficulty: Difficulty = 'easy', removedCells?: number) {
|
||||||
if (!generatorRef.current) {
|
if (!generatorRef.current) {
|
||||||
generatorRef.current = new SudokuGenerator();
|
generatorRef.current = new SudokuGenerator();
|
||||||
}
|
}
|
||||||
|
|
||||||
generatorRef.current.generate(difficulty);
|
generatorRef.current.generate(difficulty, removedCells);
|
||||||
const puzzle = generatorRef.current.getPuzzle();
|
const puzzle = generatorRef.current.getPuzzle();
|
||||||
const solution = generatorRef.current.getSolution();
|
const solution = generatorRef.current.getSolution();
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -7,7 +7,7 @@ import { useCursorRender } from './useCursor/useCursorRender';
|
|||||||
import { useScore } from './useScore';
|
import { useScore } from './useScore';
|
||||||
|
|
||||||
export function useGame(options: GameOptions = {}, platformHook?: unknown): GameManager {
|
export function useGame(options: GameOptions = {}, platformHook?: unknown): GameManager {
|
||||||
const { difficulty = 'easy' } = options;
|
const { difficulty = 'easy', timeLimit = 600, removedCells } = options;
|
||||||
const board = useBoard();
|
const board = useBoard();
|
||||||
const { containerRef, renderBoard, generateBoard, boardRendered } = board;
|
const { containerRef, renderBoard, generateBoard, boardRendered } = board;
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
|
|||||||
const cursorPosition = cursor.position();
|
const cursorPosition = cursor.position();
|
||||||
const cursorMode = cursor.mode();
|
const cursorMode = cursor.mode();
|
||||||
|
|
||||||
const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedRef.current });
|
const scoreManager = useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedRef.current, timeLimit });
|
||||||
|
|
||||||
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
|
useCursorRender(containerRef, cursorPosition, cursorMode, gameStatus, boardRendered);
|
||||||
|
|
||||||
@@ -51,12 +51,12 @@ export function useGame(options: GameOptions = {}, platformHook?: unknown): Game
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameStatus === 'started') {
|
if (gameStatus === 'started') {
|
||||||
generateBoard(difficulty);
|
generateBoard(difficulty, removedCells);
|
||||||
renderBoard();
|
renderBoard();
|
||||||
} else if (gameStatus === 'waiting') {
|
} else if (gameStatus === 'waiting') {
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
}, [gameStatus, difficulty]);
|
}, [gameStatus, difficulty, removedCells]);
|
||||||
|
|
||||||
const gameManager: GameManager = {
|
const gameManager: GameManager = {
|
||||||
containerRef,
|
containerRef,
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export type ScoreManager = {
|
|||||||
totalKeystrokes: number;
|
totalKeystrokes: number;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
finalScore: number | null;
|
finalScore: number | null;
|
||||||
|
gameOverReason: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type UseScoreParams = {
|
|||||||
cursorMode: 'normal' | 'edit';
|
cursorMode: 'normal' | 'edit';
|
||||||
difficulty: Difficulty;
|
difficulty: Difficulty;
|
||||||
hintsUsed: number;
|
hintsUsed: number;
|
||||||
|
timeLimit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIFFICULTY_MULTIPLIER = {
|
const DIFFICULTY_MULTIPLIER = {
|
||||||
@@ -26,26 +27,49 @@ const HINT_PENALTY = {
|
|||||||
hard: 100,
|
hard: 100,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedProp }: UseScoreParams): ScoreManager {
|
export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficulty, hintsUsed: hintsUsedProp, timeLimit }: UseScoreParams): ScoreManager {
|
||||||
const timer = useTimer();
|
const timer = useTimer();
|
||||||
const { gameStatus, setGameStatus } = gameStatusManager;
|
const { gameStatus, setGameStatus } = gameStatusManager;
|
||||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
||||||
|
|
||||||
const [finalScore, setFinalScore] = useState<number | null>(null);
|
const [finalScore, setFinalScore] = useState<number | null>(null);
|
||||||
|
const [gameOverReason, setGameOverReason] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameStatus === 'started') {
|
if (gameStatus === 'started') {
|
||||||
startTimer();
|
startTimer();
|
||||||
|
setGameOverReason(null);
|
||||||
} else if (gameStatus === 'waiting') {
|
} else if (gameStatus === 'waiting') {
|
||||||
resetTimer();
|
resetTimer();
|
||||||
setFinalScore(null);
|
setFinalScore(null);
|
||||||
|
setGameOverReason(null);
|
||||||
} else if (gameStatus === 'game-won') {
|
} else if (gameStatus === 'game-won') {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
|
} else if (gameStatus === 'game-over') {
|
||||||
|
stopTimer();
|
||||||
}
|
}
|
||||||
}, [gameStatus]);
|
}, [gameStatus]);
|
||||||
|
|
||||||
const totalKeystrokes = keyLog.length;
|
const totalKeystrokes = keyLog.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gameStatus !== 'started') return;
|
||||||
|
|
||||||
|
const timeLimitMs = timeLimit * 1000;
|
||||||
|
if (timeValue >= timeLimitMs) {
|
||||||
|
setGameOverReason("Time's up!");
|
||||||
|
setGameStatus('game-over');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintPenalty = hintsUsedProp * HINT_PENALTY[difficulty];
|
||||||
|
if (hintPenalty >= 500) {
|
||||||
|
setGameOverReason('Too many hints!');
|
||||||
|
setGameStatus('game-over');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [gameStatus, timeValue, hintsUsedProp, difficulty, timeLimit, setGameStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameStatus !== 'started') return;
|
if (gameStatus !== 'started') return;
|
||||||
if (cursorMode !== 'normal') return;
|
if (cursorMode !== 'normal') return;
|
||||||
@@ -82,5 +106,6 @@ export function useScore({ board, gameStatusManager, keyLog, cursorMode, difficu
|
|||||||
totalKeystrokes,
|
totalKeystrokes,
|
||||||
hintsUsed: hintsUsedProp,
|
hintsUsed: hintsUsedProp,
|
||||||
finalScore,
|
finalScore,
|
||||||
|
gameOverReason,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
/* =========================================================
|
|
||||||
🔧 Controls (tweak these)
|
|
||||||
========================================================= */
|
|
||||||
:root {
|
|
||||||
--cell-size: 96px;
|
|
||||||
--cell-font-size: 48px;
|
|
||||||
--grid-gap: 6px;
|
|
||||||
--box-separator: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Container & Grid Base
|
|
||||||
========================================================= */
|
|
||||||
#sudoku_container {
|
|
||||||
position: relative;
|
|
||||||
margin: 1em auto;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sudoku {
|
|
||||||
position: relative;
|
|
||||||
background-color: var(--card, #1e2030);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
|
|
||||||
padding: 0.5rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Grid Layout
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
|
||||||
grid-template-rows:
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
|
||||||
column-gap: var(--grid-gap);
|
|
||||||
row-gap: var(--grid-gap);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Cell Base Styles
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell {
|
|
||||||
width: var(--cell-size);
|
|
||||||
height: var(--cell-size);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: "Fira Code", monospace;
|
|
||||||
font-size: var(--cell-font-size);
|
|
||||||
background: var(--muted, #2f334d);
|
|
||||||
color: var(--foreground, #c8d3f5);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
3x3 Box Separators
|
|
||||||
========================================================= */
|
|
||||||
/* Right borders (end of each 3x3 box horizontally) */
|
|
||||||
#sudoku .sudoku-cell[data-c="2"],
|
|
||||||
#sudoku .sudoku-cell[data-c="5"],
|
|
||||||
#sudoku .sudoku-cell[data-c="8"] {
|
|
||||||
border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left borders (start of each 3x3 box horizontally) */
|
|
||||||
#sudoku .sudoku-cell[data-c="0"],
|
|
||||||
#sudoku .sudoku-cell[data-c="3"],
|
|
||||||
#sudoku .sudoku-cell[data-c="6"] {
|
|
||||||
border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom borders (end of each 3x3 box vertically) */
|
|
||||||
#sudoku .sudoku-cell[data-r="2"],
|
|
||||||
#sudoku .sudoku-cell[data-r="5"],
|
|
||||||
#sudoku .sudoku-cell[data-r="8"] {
|
|
||||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top borders (start of each 3x3 box vertically) */
|
|
||||||
#sudoku .sudoku-cell[data-r="0"],
|
|
||||||
#sudoku .sudoku-cell[data-r="3"],
|
|
||||||
#sudoku .sudoku-cell[data-r="6"] {
|
|
||||||
border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
User-Entered Cells
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.user-entered {
|
|
||||||
color: var(--primary, #82aaff);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Active Cell Highlight (Navigation Mode)
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.active {
|
|
||||||
outline: 2px solid var(--primary, #82aaff);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
|
|
||||||
0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
|
|
||||||
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Editing Cell Highlight (Edit Mode)
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.editing {
|
|
||||||
outline: 2px solid var(--accent, #ffc777);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
|
||||||
animation: editing-cell-glow 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes editing-cell-glow {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
|
|
||||||
0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
|
|
||||||
inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Visual Feedback Effects
|
|
||||||
========================================================= */
|
|
||||||
/* Invalid move effect */
|
|
||||||
#sudoku .sudoku-cell.invalid-move {
|
|
||||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
|
||||||
box-shadow:
|
|
||||||
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-invalid-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
|
|
||||||
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
|
|
||||||
0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
|
|
||||||
animation: sudoku-trail-fade 220ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-trail-fade {
|
|
||||||
from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
|
|
||||||
to { background-color: transparent; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Valid move flash */
|
|
||||||
#sudoku .sudoku-cell.valid-move {
|
|
||||||
animation: sudoku-valid-flash 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-valid-flash {
|
|
||||||
0% {
|
|
||||||
background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent);
|
|
||||||
box-shadow:
|
|
||||||
0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent),
|
|
||||||
0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Conflict highlighting */
|
|
||||||
#sudoku .sudoku-cell.conflict {
|
|
||||||
outline: 2px solid var(--destructive, #c53b53);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
|
||||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
|
||||||
animation: sudoku-conflict-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-conflict-pulse {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
|
||||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export { useGame } from './useGame';
|
|
||||||
export type { GameManager, GameOptions } from './types';
|
|
||||||
|
|
||||||
export { useBoard } from './useBoard';
|
|
||||||
export { useCursor } from './useCursor';
|
|
||||||
export { useGameStatus } from './useGameStatus';
|
|
||||||
export { useScore } from './useScore';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
Coord,
|
|
||||||
SudokuBoard,
|
|
||||||
SudokuCell,
|
|
||||||
Difficulty,
|
|
||||||
BoardManager
|
|
||||||
} from './useBoard/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
CursorManager,
|
|
||||||
CursorMode,
|
|
||||||
GameKeyManager,
|
|
||||||
KeyLogEntry
|
|
||||||
} from './useCursor/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
GameStatus,
|
|
||||||
GameStatusManager
|
|
||||||
} from './useGameStatus/types';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
ScoreManager,
|
|
||||||
TimerManager
|
|
||||||
} from './useScore/types';
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import type { RefObject } from 'react';
|
|
||||||
|
|
||||||
export type { Coord, SudokuBoard, SudokuCell, Difficulty } from './useBoard/types';
|
|
||||||
export type { CursorManager, CursorMode, GameKeyManager, KeyLogEntry } from './useCursor/types';
|
|
||||||
export type { GameStatus, GameStatusManager } from './useGameStatus/types';
|
|
||||||
export type { ScoreManager, TimerManager } from './useScore/types';
|
|
||||||
|
|
||||||
export type GameOptions = {
|
|
||||||
difficulty?: Difficulty;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameManager = {
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
renderBoard: () => void;
|
|
||||||
gameStatus: GameStatus;
|
|
||||||
setGameStatus: (status: GameStatus) => void;
|
|
||||||
startGame: () => void;
|
|
||||||
togglePause: (pause?: boolean) => void;
|
|
||||||
quitGame: () => void;
|
|
||||||
cursor: CursorManager;
|
|
||||||
scoreManager: ScoreManager;
|
|
||||||
keyLog: KeyLogEntry[];
|
|
||||||
clearKeyLog: () => void;
|
|
||||||
getKeyLog: () => KeyLogEntry[];
|
|
||||||
board: {
|
|
||||||
generateBoard: (difficulty: Difficulty) => void;
|
|
||||||
solveBoard: () => boolean;
|
|
||||||
resetBoard: () => void;
|
|
||||||
getCell: (row: number, col: number) => any;
|
|
||||||
setCell: (row: number, col: number, value: string) => boolean;
|
|
||||||
canEditCell: (row: number, col: number) => boolean;
|
|
||||||
isComplete: () => boolean;
|
|
||||||
isFullyValid: () => boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
export type DifficultyName = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
|
|
||||||
export type Difficulty = DifficultyName | number;
|
|
||||||
|
|
||||||
const DIGITS = '123456789';
|
|
||||||
const ROWS = 'ABCDEFGHI';
|
|
||||||
const COLS = DIGITS;
|
|
||||||
let SQUARES: string[] = [];
|
|
||||||
let UNITS: string[][] = [];
|
|
||||||
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
|
|
||||||
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
const MIN_GIVENS = 17;
|
|
||||||
const NR_SQUARES = 81;
|
|
||||||
|
|
||||||
const DIFFICULTY: Record<DifficultyName, number> = {
|
|
||||||
easy: 62,
|
|
||||||
medium: 53,
|
|
||||||
hard: 44,
|
|
||||||
'very-hard': 35,
|
|
||||||
insane: 26,
|
|
||||||
inhuman: 17,
|
|
||||||
};
|
|
||||||
|
|
||||||
const BLANK_CHAR = '.';
|
|
||||||
const BLANK_BOARD = BLANK_CHAR.repeat(NR_SQUARES);
|
|
||||||
|
|
||||||
function initialize() {
|
|
||||||
SQUARES = cross(ROWS, COLS);
|
|
||||||
UNITS = getAllUnits(ROWS, COLS);
|
|
||||||
SQUARE_UNITS_MAP = getSquareUnitsMap(SQUARES, UNITS);
|
|
||||||
SQUARE_PEERS_MAP = getSquarePeersMap(SQUARES, SQUARE_UNITS_MAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cross(a: string, b: string) {
|
|
||||||
const res: string[] = [];
|
|
||||||
for (const ai of a) {
|
|
||||||
for (const bi of b) {
|
|
||||||
res.push(ai + bi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inStr(v: string, seq: string) {
|
|
||||||
return seq.indexOf(v) !== -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffle<T>(seq: T[]): T[] {
|
|
||||||
const arr = [...seq];
|
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randRange(max: number, min = 0) {
|
|
||||||
return Math.floor(Math.random() * (max - min)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripDups<T>(seq: T[]): T[] {
|
|
||||||
return Array.from(new Set(seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceRange(nr: number, max: number, min = 0) {
|
|
||||||
return Math.min(Math.max(nr || 0, min), max);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAllUnits(rows: string, cols: string) {
|
|
||||||
const units: string[][] = [];
|
|
||||||
for (const r of rows) units.push(cross(r, cols));
|
|
||||||
for (const c of cols) units.push(cross(rows, c));
|
|
||||||
const row_blocks = ['ABC', 'DEF', 'GHI'];
|
|
||||||
const col_blocks = ['123', '456', '789'];
|
|
||||||
for (const rb of row_blocks) {
|
|
||||||
for (const cb of col_blocks) {
|
|
||||||
units.push(cross(rb, cb));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return units;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSquareUnitsMap(squares: string[], units: string[][]) {
|
|
||||||
const map: Record<string, string[][]> = {};
|
|
||||||
for (const s of squares) {
|
|
||||||
const list: string[][] = [];
|
|
||||||
for (const u of units) {
|
|
||||||
if (u.includes(s)) list.push(u);
|
|
||||||
}
|
|
||||||
map[s] = list;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSquarePeersMap(squares: string[], units_map: Record<string, string[][]>) {
|
|
||||||
const map: Record<string, string[]> = {};
|
|
||||||
for (const s of squares) {
|
|
||||||
const peers: string[] = [];
|
|
||||||
for (const unit of units_map[s]) {
|
|
||||||
for (const sq of unit) {
|
|
||||||
if (sq !== s && !peers.includes(sq)) peers.push(sq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map[s] = peers;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSquareValsMap(board: string) {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
if (board.length !== SQUARES.length) throw 'Board length mismatch';
|
|
||||||
for (let i = 0; i < SQUARES.length; i++) {
|
|
||||||
map[SQUARES[i]] = board[i];
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateBoard(board: string): true | string {
|
|
||||||
if (!board) return 'Empty board';
|
|
||||||
if (board.length !== NR_SQUARES) return 'Invalid board size';
|
|
||||||
for (let i = 0; i < board.length; i++) {
|
|
||||||
const c = board[i];
|
|
||||||
if (!inStr(c, DIGITS) && c !== BLANK_CHAR) {
|
|
||||||
return `Invalid char at ${i}: ${c}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCandidatesMap(board: string): Record<string, string> | false {
|
|
||||||
const report = validateBoard(board);
|
|
||||||
if (report !== true) throw report;
|
|
||||||
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
const squares_values = getSquareValsMap(board);
|
|
||||||
|
|
||||||
for (const s of SQUARES) map[s] = DIGITS;
|
|
||||||
|
|
||||||
for (const s in squares_values) {
|
|
||||||
const val = squares_values[s];
|
|
||||||
if (inStr(val, DIGITS)) {
|
|
||||||
const new_candidates = assign(map, s, val);
|
|
||||||
if (!new_candidates) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assign(candidates: Record<string, string>, square: string, val: string) {
|
|
||||||
const others = candidates[square].replace(val, '');
|
|
||||||
for (const v of others) {
|
|
||||||
const next = eliminate(candidates, square, v);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function eliminate(candidates: Record<string, string>, square: string, val: string) {
|
|
||||||
if (!inStr(val, candidates[square])) return candidates;
|
|
||||||
|
|
||||||
candidates[square] = candidates[square].replace(val, '');
|
|
||||||
const n = candidates[square].length;
|
|
||||||
|
|
||||||
if (n === 1) {
|
|
||||||
const target_val = candidates[square];
|
|
||||||
for (const peer of SQUARE_PEERS_MAP[square]) {
|
|
||||||
const next = eliminate(candidates, peer, target_val);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
} else if (n === 0) return false;
|
|
||||||
|
|
||||||
for (const unit of SQUARE_UNITS_MAP[square]) {
|
|
||||||
const places: string[] = [];
|
|
||||||
for (const sq of unit) {
|
|
||||||
if (inStr(val, candidates[sq])) places.push(sq);
|
|
||||||
}
|
|
||||||
if (places.length === 0) return false;
|
|
||||||
else if (places.length === 1) {
|
|
||||||
const next = assign(candidates, places[0], val);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
function search(candidates: Record<string, string>, reverse: boolean): any {
|
|
||||||
if (!candidates) return false;
|
|
||||||
reverse = reverse || false;
|
|
||||||
|
|
||||||
let max_nr_candidates = 0;
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
const n = candidates[s].length;
|
|
||||||
if (n > max_nr_candidates) {
|
|
||||||
max_nr_candidates = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (max_nr_candidates === 1) return candidates;
|
|
||||||
|
|
||||||
let min_nr_candidates = 10;
|
|
||||||
let min_square = '';
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
const n = candidates[s].length;
|
|
||||||
if (n < min_nr_candidates && n > 1) {
|
|
||||||
min_nr_candidates = n;
|
|
||||||
min_square = s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const min_candidates = candidates[min_square];
|
|
||||||
if (!reverse) {
|
|
||||||
for (const v of min_candidates) {
|
|
||||||
const copy = JSON.parse(JSON.stringify(candidates));
|
|
||||||
const next = search(assign(copy, min_square, v), reverse);
|
|
||||||
if (next) return next;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = min_candidates.length - 1; i >= 0; --i) {
|
|
||||||
const v = min_candidates[i];
|
|
||||||
const copy = JSON.parse(JSON.stringify(candidates));
|
|
||||||
const next = search(assign(copy, min_square, v), reverse);
|
|
||||||
if (next) return next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SudokuGenerator {
|
|
||||||
static generate(difficulty: Difficulty, unique?: boolean): string {
|
|
||||||
let numericDifficulty: number;
|
|
||||||
if (typeof difficulty === 'string' || typeof difficulty === 'undefined') {
|
|
||||||
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
|
|
||||||
} else {
|
|
||||||
numericDifficulty = difficulty;
|
|
||||||
}
|
|
||||||
|
|
||||||
numericDifficulty = forceRange(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
|
|
||||||
unique = unique ?? true;
|
|
||||||
|
|
||||||
const blank_board = BLANK_BOARD;
|
|
||||||
let candidates = getCandidatesMap(blank_board);
|
|
||||||
|
|
||||||
if (!candidates) return SudokuGenerator.generate(numericDifficulty);
|
|
||||||
|
|
||||||
const shuffled_squares = shuffle(SQUARES);
|
|
||||||
|
|
||||||
for (const square of shuffled_squares) {
|
|
||||||
const rand_candidate_idx = randRange(candidates[square].length);
|
|
||||||
const rand_candidate = candidates[square][rand_candidate_idx];
|
|
||||||
if (!assign(candidates, square, rand_candidate)) break;
|
|
||||||
|
|
||||||
const single_candidates: string[] = [];
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
if (candidates[s].length === 1) single_candidates.push(candidates[s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
single_candidates.length >= numericDifficulty &&
|
|
||||||
stripDups(single_candidates).length >= 8
|
|
||||||
) {
|
|
||||||
let board = '';
|
|
||||||
let givens_idxs: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < SQUARES.length; i++) {
|
|
||||||
const s = SQUARES[i];
|
|
||||||
if (candidates[s].length === 1) {
|
|
||||||
board += candidates[s];
|
|
||||||
givens_idxs.push(i);
|
|
||||||
} else {
|
|
||||||
board += BLANK_CHAR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (givens_idxs.length > numericDifficulty) {
|
|
||||||
givens_idxs = shuffle(givens_idxs);
|
|
||||||
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
|
|
||||||
const target = givens_idxs[i];
|
|
||||||
board =
|
|
||||||
board.substring(0, target) +
|
|
||||||
BLANK_CHAR +
|
|
||||||
board.substring(target + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SudokuGenerator.solve(board)) return board;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SudokuGenerator.generate(numericDifficulty);
|
|
||||||
}
|
|
||||||
|
|
||||||
static solve(board: string, reverse?: boolean): string | false {
|
|
||||||
const report = validateBoard(board);
|
|
||||||
if (report !== true) throw report;
|
|
||||||
|
|
||||||
let nr_givens = 0;
|
|
||||||
for (const ch of board) {
|
|
||||||
if (ch !== BLANK_CHAR && inStr(ch, DIGITS)) nr_givens++;
|
|
||||||
}
|
|
||||||
if (nr_givens < MIN_GIVENS) throw 'Too few givens';
|
|
||||||
|
|
||||||
reverse = reverse || false;
|
|
||||||
const candidates = getCandidatesMap(board);
|
|
||||||
if (!candidates) return false;
|
|
||||||
|
|
||||||
const result = search(candidates, reverse);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
let solution = '';
|
|
||||||
for (const s of SQUARES) solution += result[s];
|
|
||||||
return solution;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize();
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import type { SudokuBoard, SudokuCell } from './types';
|
|
||||||
|
|
||||||
export class SudokuRenderer {
|
|
||||||
static renderBoard(container: HTMLElement, board: SudokuBoard): void {
|
|
||||||
const sudokuDiv = document.createElement('div');
|
|
||||||
sudokuDiv.id = 'sudoku';
|
|
||||||
sudokuDiv.className = 'sudoku-board';
|
|
||||||
|
|
||||||
for (let row = 0; row < 9; row++) {
|
|
||||||
const rowDiv = document.createElement('div');
|
|
||||||
rowDiv.className = 'sudoku-row';
|
|
||||||
|
|
||||||
for (let col = 0; col < 9; col++) {
|
|
||||||
const cell = board[row][col];
|
|
||||||
const cellDiv = document.createElement('div');
|
|
||||||
cellDiv.className = 'sudoku-cell';
|
|
||||||
cellDiv.dataset.row = row.toString();
|
|
||||||
cellDiv.dataset.col = col.toString();
|
|
||||||
cellDiv.textContent = cell.value || '';
|
|
||||||
|
|
||||||
if (cell.isGiven) {
|
|
||||||
cellDiv.classList.add('given');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cell.hasConflict) {
|
|
||||||
cellDiv.classList.add('conflict');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col === 2 || col === 5) {
|
|
||||||
cellDiv.classList.add('border-right');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row === 2 || row === 5) {
|
|
||||||
cellDiv.classList.add('border-bottom');
|
|
||||||
}
|
|
||||||
|
|
||||||
rowDiv.appendChild(cellDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
sudokuDiv.appendChild(rowDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = '';
|
|
||||||
container.appendChild(sudokuDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
static renderEmptyBoard(container: HTMLElement): void {
|
|
||||||
const emptyBoard: SudokuBoard = Array(9).fill(null).map(() =>
|
|
||||||
Array(9).fill(null).map(() => ({
|
|
||||||
value: '',
|
|
||||||
isGiven: false,
|
|
||||||
hasConflict: false
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
SudokuRenderer.renderBoard(container, emptyBoard);
|
|
||||||
}
|
|
||||||
|
|
||||||
static updateCell(container: HTMLElement, row: number, col: number, cell: SudokuCell): void {
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (!cellEl) return;
|
|
||||||
|
|
||||||
cellEl.textContent = cell.value || '';
|
|
||||||
cellEl.classList.toggle('given', cell.isGiven);
|
|
||||||
cellEl.classList.toggle('conflict', cell.hasConflict);
|
|
||||||
}
|
|
||||||
|
|
||||||
static highlightCursor(container: HTMLElement, row: number, col: number, editMode: boolean): void {
|
|
||||||
const cells = container.querySelectorAll('.sudoku-cell');
|
|
||||||
cells.forEach(cell => cell.classList.remove('cursor', 'edit-mode'));
|
|
||||||
|
|
||||||
const cursorCell = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (cursorCell) {
|
|
||||||
cursorCell.classList.add('cursor');
|
|
||||||
if (editMode) {
|
|
||||||
cursorCell.classList.add('edit-mode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static flashCell(container: HTMLElement, row: number, col: number, className: string, duration: number = 300): void {
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (!cellEl) return;
|
|
||||||
|
|
||||||
cellEl.classList.add(className);
|
|
||||||
setTimeout(() => cellEl.classList.remove(className), duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
static highlightConflicts(container: HTMLElement, conflictIndices: number[], duration: number = 2000): void {
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const row = Math.floor(idx / 9);
|
|
||||||
const col = idx % 9;
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('conflict');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const row = Math.floor(idx / 9);
|
|
||||||
const col = idx % 9;
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.remove('conflict');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { useBoard } from './useBoard';
|
|
||||||
export type { BoardManager, SudokuBoard, SudokuCell, Coord, Difficulty } from './types';
|
|
||||||
export { SudokuGenerator } from './SudokuGenerator';
|
|
||||||
export { SudokuRenderer } from './SudokuRenderer';
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { RefObject } from 'react';
|
|
||||||
|
|
||||||
export type Coord = { row: number; col: number };
|
|
||||||
|
|
||||||
export type SudokuCell = {
|
|
||||||
value: string;
|
|
||||||
isGiven: boolean;
|
|
||||||
hasConflict: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SudokuBoard = SudokuCell[][];
|
|
||||||
|
|
||||||
export type Difficulty = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
|
|
||||||
|
|
||||||
export type BoardManager = {
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
boardRef: RefObject<SudokuBoard>;
|
|
||||||
initialBoardRef: RefObject<string>;
|
|
||||||
solutionRef: RefObject<string | null>;
|
|
||||||
renderBoard: () => void;
|
|
||||||
generateBoard: (difficulty: Difficulty) => void;
|
|
||||||
getCell: (row: number, col: number) => SudokuCell | null;
|
|
||||||
setCell: (row: number, col: number, value: string) => boolean;
|
|
||||||
canEditCell: (row: number, col: number) => boolean;
|
|
||||||
isValidPlacement: (row: number, col: number, digit: string) => boolean;
|
|
||||||
getConflicts: (row: number, col: number) => number[];
|
|
||||||
isComplete: () => boolean;
|
|
||||||
isFullyValid: () => boolean;
|
|
||||||
solveBoard: () => boolean;
|
|
||||||
resetBoard: () => void;
|
|
||||||
};
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import { useRef, useState, useCallback } from 'react';
|
|
||||||
import type { RefObject } from 'react';
|
|
||||||
import type { BoardManager, SudokuBoard, SudokuCell, Difficulty } from './types';
|
|
||||||
import { SudokuGenerator } from './SudokuGenerator';
|
|
||||||
import { SudokuRenderer } from './SudokuRenderer';
|
|
||||||
|
|
||||||
export function useBoard(): BoardManager {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const boardRef = useRef<SudokuBoard>([]);
|
|
||||||
const initialBoardRef = useRef<string>('');
|
|
||||||
const solutionRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const initializeBoard = useCallback((boardString: string): SudokuBoard => {
|
|
||||||
const board: SudokuBoard = [];
|
|
||||||
for (let row = 0; row < 9; row++) {
|
|
||||||
const boardRow: SudokuCell[] = [];
|
|
||||||
for (let col = 0; col < 9; col++) {
|
|
||||||
const index = row * 9 + col;
|
|
||||||
const value = boardString[index];
|
|
||||||
boardRow.push({
|
|
||||||
value: value === '.' ? '' : value,
|
|
||||||
isGiven: value !== '.',
|
|
||||||
hasConflict: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
board.push(boardRow);
|
|
||||||
}
|
|
||||||
return board;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getCell = useCallback((row: number, col: number): SudokuCell | null => {
|
|
||||||
const board = boardRef.current;
|
|
||||||
if (row < 0 || row >= 9 || col < 0 || col >= 9) return null;
|
|
||||||
return board[row]?.[col] || null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const canEditCell = useCallback((row: number, col: number): boolean => {
|
|
||||||
const cell = getCell(row, col);
|
|
||||||
return cell ? !cell.isGiven : false;
|
|
||||||
}, [getCell]);
|
|
||||||
|
|
||||||
const isValidPlacement = useCallback((boardString: string, row: number, col: number, digit: string): boolean => {
|
|
||||||
if (digit === '.') return true;
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const checkIdx = row * 9 + c;
|
|
||||||
if (c !== col && boardString[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const checkIdx = r * 9 + col;
|
|
||||||
if (r !== row && boardString[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxRow = Math.floor(row / 3) * 3;
|
|
||||||
const boxCol = Math.floor(col / 3) * 3;
|
|
||||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
|
||||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
|
||||||
const checkIdx = r * 9 + c;
|
|
||||||
if ((r !== row || c !== col) && boardString[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getConflicts = useCallback((boardString: string, row: number, col: number): number[] => {
|
|
||||||
const conflicts: number[] = [];
|
|
||||||
const digit = boardString[row * 9 + col];
|
|
||||||
|
|
||||||
if (digit === '.') return conflicts;
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
if (c !== col && boardString[row * 9 + c] === digit) {
|
|
||||||
conflicts.push(row * 9 + c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
if (r !== row && boardString[r * 9 + col] === digit) {
|
|
||||||
conflicts.push(r * 9 + col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxRow = Math.floor(row / 3) * 3;
|
|
||||||
const boxCol = Math.floor(col / 3) * 3;
|
|
||||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
|
||||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
|
||||||
const idx = r * 9 + c;
|
|
||||||
if ((r !== row || c !== col) && boardString[idx] === digit) {
|
|
||||||
if (!conflicts.includes(idx)) {
|
|
||||||
conflicts.push(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isFullyValid = useCallback((boardString: string): boolean => {
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
let rowSum = 0;
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const digit = boardString[r * 9 + c];
|
|
||||||
if (digit === '.') return false;
|
|
||||||
rowSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
if (rowSum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
let colSum = 0;
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const digit = boardString[r * 9 + c];
|
|
||||||
colSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
if (colSum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let boxRow = 0; boxRow < 3; boxRow++) {
|
|
||||||
for (let boxCol = 0; boxCol < 3; boxCol++) {
|
|
||||||
let boxSum = 0;
|
|
||||||
for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
|
|
||||||
for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
|
|
||||||
const digit = boardString[r * 9 + c];
|
|
||||||
boxSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (boxSum !== 45) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isComplete = useCallback((boardString: string): boolean => {
|
|
||||||
return !boardString.includes('.');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const boardToString = useCallback((board: SudokuBoard): string => {
|
|
||||||
return board.map(row =>
|
|
||||||
row.map(cell => cell.value || '.').join('')
|
|
||||||
).join('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setCell = useCallback((row: number, col: number, value: string): boolean => {
|
|
||||||
if (!canEditCell(row, col)) return false;
|
|
||||||
|
|
||||||
const board = boardRef.current;
|
|
||||||
const boardString = boardToString(board);
|
|
||||||
const newBoardString = boardString.substring(0, row * 9 + col) + value + boardString.substring(row * 9 + col + 1);
|
|
||||||
|
|
||||||
if (!isValidPlacement(newBoardString, row, col, value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
board[row][col] = {
|
|
||||||
value: value === '.' ? '' : value,
|
|
||||||
isGiven: board[row][col].isGiven,
|
|
||||||
hasConflict: false
|
|
||||||
};
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, [canEditCell, isValidPlacement, boardToString]);
|
|
||||||
|
|
||||||
const generateBoard = useCallback((difficulty: Difficulty): void => {
|
|
||||||
const newBoardString = SudokuGenerator.generate(difficulty);
|
|
||||||
const solution = SudokuGenerator.solve(newBoardString);
|
|
||||||
|
|
||||||
initialBoardRef.current = newBoardString;
|
|
||||||
solutionRef.current = solution;
|
|
||||||
boardRef.current = initializeBoard(newBoardString);
|
|
||||||
}, [initializeBoard]);
|
|
||||||
|
|
||||||
const solveBoard = useCallback((): boolean => {
|
|
||||||
const solution = solutionRef.current;
|
|
||||||
if (!solution) return false;
|
|
||||||
|
|
||||||
boardRef.current = initializeBoard(solution);
|
|
||||||
return true;
|
|
||||||
}, [initializeBoard]);
|
|
||||||
|
|
||||||
const resetBoard = useCallback((): void => {
|
|
||||||
const initialBoard = initialBoardRef.current;
|
|
||||||
if (initialBoard) {
|
|
||||||
boardRef.current = initializeBoard(initialBoard);
|
|
||||||
}
|
|
||||||
}, [initializeBoard]);
|
|
||||||
|
|
||||||
const renderBoard = useCallback((): void => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
const board = boardRef.current;
|
|
||||||
|
|
||||||
if (!container || board.length === 0) {
|
|
||||||
if (container) {
|
|
||||||
SudokuRenderer.renderEmptyBoard(container);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
SudokuRenderer.renderBoard(container, board);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerRef,
|
|
||||||
boardRef,
|
|
||||||
initialBoardRef,
|
|
||||||
solutionRef,
|
|
||||||
renderBoard,
|
|
||||||
generateBoard,
|
|
||||||
getCell,
|
|
||||||
setCell,
|
|
||||||
canEditCell,
|
|
||||||
isValidPlacement: (row, col, digit) => {
|
|
||||||
const boardString = boardToString(boardRef.current);
|
|
||||||
return isValidPlacement(boardString, row, col, digit);
|
|
||||||
},
|
|
||||||
getConflicts: (row, col) => {
|
|
||||||
const boardString = boardToString(boardRef.current);
|
|
||||||
return getConflicts(boardString, row, col);
|
|
||||||
},
|
|
||||||
isComplete: () => {
|
|
||||||
const boardString = boardToString(boardRef.current);
|
|
||||||
return isComplete(boardString);
|
|
||||||
},
|
|
||||||
isFullyValid: () => {
|
|
||||||
const boardString = boardToString(boardRef.current);
|
|
||||||
return isFullyValid(boardString);
|
|
||||||
},
|
|
||||||
solveBoard,
|
|
||||||
resetBoard
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export { useCursor } from './useCursor';
|
|
||||||
export { useVimMotions } from './useVimMotions';
|
|
||||||
export type {
|
|
||||||
CursorManager,
|
|
||||||
CursorMode,
|
|
||||||
Coord,
|
|
||||||
Motion,
|
|
||||||
VimMotionSystem,
|
|
||||||
GameKeyManager,
|
|
||||||
KeyLogEntry,
|
|
||||||
CursorRenderer
|
|
||||||
} from './types';
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { RefObject } from 'react';
|
|
||||||
import type { BoardManager } from '../useBoard/types';
|
|
||||||
|
|
||||||
export type Coord = { row: number; col: number };
|
|
||||||
|
|
||||||
export type CursorMode = 'normal' | 'edit';
|
|
||||||
|
|
||||||
export type Motion = {
|
|
||||||
dr: number;
|
|
||||||
dc: number;
|
|
||||||
steps: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CursorManager = {
|
|
||||||
position: () => Coord;
|
|
||||||
mode: () => CursorMode;
|
|
||||||
move: (dRows: number, dCols: number, count: number) => void;
|
|
||||||
moveLeft: (count?: number) => void;
|
|
||||||
moveRight: (count?: number) => void;
|
|
||||||
moveUp: (count?: number) => void;
|
|
||||||
moveDown: (count?: number) => void;
|
|
||||||
moveToStart: () => void;
|
|
||||||
moveToEnd: () => void;
|
|
||||||
moveToTop: () => void;
|
|
||||||
moveToBottom: () => void;
|
|
||||||
repeatLastMotion: () => void;
|
|
||||||
resetCount: () => void;
|
|
||||||
getCount: () => string;
|
|
||||||
hasCount: () => boolean;
|
|
||||||
setCount: (digit: string) => void;
|
|
||||||
setLastKey: (key: string) => void;
|
|
||||||
getLastKey: () => string;
|
|
||||||
setMode: (mode: CursorMode) => void;
|
|
||||||
flashInvalid: () => void;
|
|
||||||
flashValid: () => void;
|
|
||||||
flashLocked: () => void;
|
|
||||||
highlightConflicts: (conflictIndices: number[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VimMotionSystem = {
|
|
||||||
processKey: (key: string) => { type: 'move' | 'anchor' | 'repeat' | 'count' | 'digit' | 'erase' | 'mode', data: any } | null;
|
|
||||||
resetCount: () => void;
|
|
||||||
getCount: () => number;
|
|
||||||
hasCount: () => boolean;
|
|
||||||
setLastMotion: (motion: Motion) => void;
|
|
||||||
repeatLastMotion: () => Motion | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GameKeyManager = {
|
|
||||||
keyLog: KeyLogEntry[];
|
|
||||||
clearKeyLog: () => void;
|
|
||||||
getKeyLog: () => KeyLogEntry[];
|
|
||||||
editMode: CursorMode;
|
|
||||||
setEditMode: (mode: CursorMode) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type KeyLogEntry = { key: string; timestamp: number };
|
|
||||||
|
|
||||||
export type CursorRenderer = {
|
|
||||||
highlightCursor: (row: number, col: number, editMode: boolean) => void;
|
|
||||||
flashCell: (row: number, col: number, className: string, duration?: number) => void;
|
|
||||||
highlightConflicts: (conflictIndices: number[], duration?: number) => void;
|
|
||||||
};
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import type { BoardManager } from '../useBoard/types';
|
|
||||||
import type { CursorManager, CursorMode, Coord, VimMotionSystem, GameKeyManager, KeyLogEntry } from './types';
|
|
||||||
import { useVimMotions } from './useVimMotions';
|
|
||||||
|
|
||||||
export function useCursor(board: BoardManager): CursorManager & { keyManager: GameKeyManager } {
|
|
||||||
const [position, setPosition] = useState<Coord>({ row: 0, col: 0 });
|
|
||||||
const [mode, setMode] = useState<CursorMode>('normal');
|
|
||||||
const [keyLog, setKeyLog] = useState<KeyLogEntry[]>([]);
|
|
||||||
|
|
||||||
const vimMotions = useVimMotions();
|
|
||||||
|
|
||||||
const addKeyToLog = useCallback((key: string) => {
|
|
||||||
setKeyLog(prev => [...prev, { key, timestamp: Date.now() }]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const move = useCallback((dRows: number, dCols: number, count: number) => {
|
|
||||||
setPosition(prev => {
|
|
||||||
const newRow = prev.row + (dRows * count);
|
|
||||||
const newCol = prev.col + (dCols * count);
|
|
||||||
|
|
||||||
if (newRow < 0 || newRow > 8 || newCol < 0 || newCol > 8) {
|
|
||||||
flashInvalid();
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { row: newRow, col: newCol };
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const moveLeft = useCallback((count = 1) => {
|
|
||||||
move(0, -1, count);
|
|
||||||
}, [move]);
|
|
||||||
|
|
||||||
const moveRight = useCallback((count = 1) => {
|
|
||||||
move(0, 1, count);
|
|
||||||
}, [move]);
|
|
||||||
|
|
||||||
const moveUp = useCallback((count = 1) => {
|
|
||||||
move(-1, 0, count);
|
|
||||||
}, [move]);
|
|
||||||
|
|
||||||
const moveDown = useCallback((count = 1) => {
|
|
||||||
move(1, 0, count);
|
|
||||||
}, [move]);
|
|
||||||
|
|
||||||
const moveToStart = useCallback(() => {
|
|
||||||
setPosition(prev => ({ ...prev, col: 0 }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const moveToEnd = useCallback(() => {
|
|
||||||
setPosition(prev => ({ ...prev, col: 8 }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const moveToTop = useCallback(() => {
|
|
||||||
setPosition({ row: 0, col: 0 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const moveToBottom = useCallback(() => {
|
|
||||||
setPosition({ row: 8, col: 0 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const repeatLastMotion = useCallback(() => {
|
|
||||||
const lastMotion = vimMotions.repeatLastMotion();
|
|
||||||
if (lastMotion) {
|
|
||||||
move(lastMotion.dr, lastMotion.dc, lastMotion.steps);
|
|
||||||
}
|
|
||||||
}, [vimMotions, move]);
|
|
||||||
|
|
||||||
const resetCount = useCallback(() => {
|
|
||||||
vimMotions.resetCount();
|
|
||||||
}, [vimMotions]);
|
|
||||||
|
|
||||||
const getCount = useCallback(() => {
|
|
||||||
return vimMotions.getCount().toString();
|
|
||||||
}, [vimMotions]);
|
|
||||||
|
|
||||||
const hasCount = useCallback(() => {
|
|
||||||
return vimMotions.hasCount();
|
|
||||||
}, [vimMotions]);
|
|
||||||
|
|
||||||
const setCount = useCallback((digit: string) => {
|
|
||||||
vimMotions.processKey(digit);
|
|
||||||
}, [vimMotions]);
|
|
||||||
|
|
||||||
const setLastKey = useCallback((key: string) => {
|
|
||||||
addKeyToLog(key);
|
|
||||||
}, [addKeyToLog]);
|
|
||||||
|
|
||||||
const getLastKey = useCallback(() => {
|
|
||||||
return keyLog[keyLog.length - 1]?.key || '';
|
|
||||||
}, [keyLog]);
|
|
||||||
|
|
||||||
const flashInvalid = useCallback(() => {
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('invalid-move');
|
|
||||||
setTimeout(() => cellEl.classList.remove('invalid-move'), 180);
|
|
||||||
}
|
|
||||||
}, [board, position]);
|
|
||||||
|
|
||||||
const flashValid = useCallback(() => {
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('valid-move');
|
|
||||||
setTimeout(() => cellEl.classList.remove('valid-move'), 300);
|
|
||||||
}
|
|
||||||
}, [board, position]);
|
|
||||||
|
|
||||||
const flashLocked = useCallback(() => {
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${position.row}"][data-col="${position.col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('locked-cell');
|
|
||||||
setTimeout(() => cellEl.classList.remove('locked-cell'), 300);
|
|
||||||
}
|
|
||||||
}, [board, position]);
|
|
||||||
|
|
||||||
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const row = Math.floor(idx / 9);
|
|
||||||
const col = idx % 9;
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('conflict');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const row = Math.floor(idx / 9);
|
|
||||||
const col = idx % 9;
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${row}"][data-col="${col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.remove('conflict');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
}, [board]);
|
|
||||||
|
|
||||||
const clearKeyLog = useCallback(() => {
|
|
||||||
setKeyLog([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getKeyLog = useCallback(() => {
|
|
||||||
return [...keyLog];
|
|
||||||
}, [keyLog]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const key = e.key;
|
|
||||||
const result = vimMotions.processKey(key);
|
|
||||||
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
addKeyToLog(key);
|
|
||||||
|
|
||||||
switch (result.type) {
|
|
||||||
case 'move':
|
|
||||||
move(result.data.dr, result.data.dc, result.data.steps);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'anchor':
|
|
||||||
switch (result.data) {
|
|
||||||
case 'start':
|
|
||||||
moveToStart();
|
|
||||||
break;
|
|
||||||
case 'end':
|
|
||||||
moveToEnd();
|
|
||||||
break;
|
|
||||||
case 'top':
|
|
||||||
moveToTop();
|
|
||||||
break;
|
|
||||||
case 'bottom':
|
|
||||||
moveToBottom();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'repeat':
|
|
||||||
if (result.data) {
|
|
||||||
move(result.data.dr, result.data.dc, result.data.steps);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'mode':
|
|
||||||
setMode(result.data);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'digit':
|
|
||||||
if (mode === 'edit') {
|
|
||||||
const success = board.setCell(position.row, position.col, result.data);
|
|
||||||
if (success) {
|
|
||||||
flashValid();
|
|
||||||
} else if (!board.canEditCell(position.row, position.col)) {
|
|
||||||
flashLocked();
|
|
||||||
} else {
|
|
||||||
const conflicts = board.getConflicts(position.row, position.col);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
highlightConflicts(conflicts);
|
|
||||||
}
|
|
||||||
flashInvalid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'erase':
|
|
||||||
if (mode === 'edit') {
|
|
||||||
const success = board.setCell(position.row, position.col, '.');
|
|
||||||
if (success) {
|
|
||||||
flashValid();
|
|
||||||
} else if (!board.canEditCell(position.row, position.col)) {
|
|
||||||
flashLocked();
|
|
||||||
} else {
|
|
||||||
flashInvalid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [vimMotions, addKeyToLog, move, moveToStart, moveToEnd, moveToTop, moveToBottom, mode, board, position, flashValid, flashLocked, flashInvalid, highlightConflicts]);
|
|
||||||
|
|
||||||
const cursorManager: CursorManager = {
|
|
||||||
position: () => position,
|
|
||||||
mode: () => mode,
|
|
||||||
move,
|
|
||||||
moveLeft,
|
|
||||||
moveRight,
|
|
||||||
moveUp,
|
|
||||||
moveDown,
|
|
||||||
moveToStart,
|
|
||||||
moveToEnd,
|
|
||||||
moveToTop,
|
|
||||||
moveToBottom,
|
|
||||||
repeatLastMotion,
|
|
||||||
resetCount,
|
|
||||||
getCount,
|
|
||||||
hasCount,
|
|
||||||
setCount,
|
|
||||||
setLastKey,
|
|
||||||
getLastKey,
|
|
||||||
setMode,
|
|
||||||
flashInvalid,
|
|
||||||
flashValid,
|
|
||||||
flashLocked,
|
|
||||||
highlightConflicts
|
|
||||||
};
|
|
||||||
|
|
||||||
const keyManager: GameKeyManager = {
|
|
||||||
keyLog,
|
|
||||||
clearKeyLog,
|
|
||||||
getKeyLog,
|
|
||||||
editMode: mode,
|
|
||||||
setEditMode: setMode
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...cursorManager,
|
|
||||||
keyManager
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { useRef } from 'react';
|
|
||||||
import type { VimMotionSystem, Motion } from './types';
|
|
||||||
|
|
||||||
export function useVimMotions(): VimMotionSystem {
|
|
||||||
const countRef = useRef<string>('');
|
|
||||||
const lastMotionRef = useRef<Motion | null>(null);
|
|
||||||
|
|
||||||
const processKey = (key: string) => {
|
|
||||||
if (key >= '0' && key <= '9') {
|
|
||||||
if (key === '0' && countRef.current === '') {
|
|
||||||
return { type: 'anchor', data: 'start' };
|
|
||||||
}
|
|
||||||
countRef.current += key;
|
|
||||||
return { type: 'count', data: key };
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = countRef.current ? parseInt(countRef.current, 10) : 1;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'h':
|
|
||||||
const motionH = { dr: 0, dc: -1, steps: count };
|
|
||||||
lastMotionRef.current = motionH;
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'move', data: motionH };
|
|
||||||
|
|
||||||
case 'j':
|
|
||||||
const motionJ = { dr: 1, dc: 0, steps: count };
|
|
||||||
lastMotionRef.current = motionJ;
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'move', data: motionJ };
|
|
||||||
|
|
||||||
case 'k':
|
|
||||||
const motionK = { dr: -1, dc: 0, steps: count };
|
|
||||||
lastMotionRef.current = motionK;
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'move', data: motionK };
|
|
||||||
|
|
||||||
case 'l':
|
|
||||||
const motionL = { dr: 0, dc: 1, steps: count };
|
|
||||||
lastMotionRef.current = motionL;
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'move', data: motionL };
|
|
||||||
|
|
||||||
case '^':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'anchor', data: 'start' };
|
|
||||||
|
|
||||||
case '$':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'anchor', data: 'end' };
|
|
||||||
|
|
||||||
case 'g':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'anchor', data: 'top' };
|
|
||||||
|
|
||||||
case 'G':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'anchor', data: 'bottom' };
|
|
||||||
|
|
||||||
case '.':
|
|
||||||
const lastMotion = lastMotionRef.current;
|
|
||||||
if (lastMotion) {
|
|
||||||
return { type: 'repeat', data: lastMotion };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
|
|
||||||
case 'i':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'mode', data: 'edit' };
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'mode', data: 'normal' };
|
|
||||||
|
|
||||||
case 'x':
|
|
||||||
case 'Delete':
|
|
||||||
case 'Backspace':
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'erase', data: null };
|
|
||||||
|
|
||||||
default:
|
|
||||||
if (key >= '1' && key <= '9') {
|
|
||||||
countRef.current = '';
|
|
||||||
return { type: 'digit', data: key };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetCount = () => {
|
|
||||||
countRef.current = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCount = () => {
|
|
||||||
return countRef.current ? parseInt(countRef.current, 10) : 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasCount = () => {
|
|
||||||
return countRef.current !== '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLastMotion = (motion: Motion) => {
|
|
||||||
lastMotionRef.current = motion;
|
|
||||||
};
|
|
||||||
|
|
||||||
const repeatLastMotion = () => {
|
|
||||||
return lastMotionRef.current;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
processKey,
|
|
||||||
resetCount,
|
|
||||||
getCount,
|
|
||||||
hasCount,
|
|
||||||
setLastMotion,
|
|
||||||
repeatLastMotion
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useBoard } from './useBoard';
|
|
||||||
import { useCursor } from './useCursor';
|
|
||||||
import { useGameStatus } from './useGameStatus';
|
|
||||||
import { useScore } from './useScore';
|
|
||||||
import type { GameManager, GameOptions } from './types';
|
|
||||||
|
|
||||||
export function useGame(options: GameOptions, platformHook?: unknown): GameManager {
|
|
||||||
const board = useBoard();
|
|
||||||
const gameStatusManager = useGameStatus();
|
|
||||||
const { cursor, keyManager } = useCursor(board);
|
|
||||||
const scoreManager = useScore({
|
|
||||||
board,
|
|
||||||
gameStatusManager,
|
|
||||||
keystrokeCount: keyManager.keyLog.length
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
board.generateBoard('medium');
|
|
||||||
}, [board]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { gameStatus } = gameStatusManager;
|
|
||||||
|
|
||||||
if (gameStatus === 'waiting') {
|
|
||||||
board.renderBoard();
|
|
||||||
} else {
|
|
||||||
board.renderBoard();
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (container) {
|
|
||||||
const pos = cursor.position();
|
|
||||||
const cellEl = container.querySelector(`.sudoku-cell[data-row="${pos.row}"][data-col="${pos.col}"]`) as HTMLElement;
|
|
||||||
if (cellEl) {
|
|
||||||
cellEl.classList.add('cursor');
|
|
||||||
if (cursor.mode() === 'edit') {
|
|
||||||
cellEl.classList.add('edit-mode');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [gameStatusManager.gameStatus, board, cursor]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const container = board.containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const handleCellClick = (e: MouseEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const cellEl = target.closest('.sudoku-cell') as HTMLElement;
|
|
||||||
if (!cellEl) return;
|
|
||||||
|
|
||||||
const row = parseInt(cellEl.dataset.row || '0');
|
|
||||||
const col = parseInt(cellEl.dataset.col || '0');
|
|
||||||
|
|
||||||
const currentPos = cursor.position();
|
|
||||||
if (currentPos.row !== row || currentPos.col !== col) {
|
|
||||||
cursor.move(row - currentPos.row, col - currentPos.col, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener('click', handleCellClick);
|
|
||||||
return () => container.removeEventListener('click', handleCellClick);
|
|
||||||
}, [board, cursor]);
|
|
||||||
|
|
||||||
const startGame = () => {
|
|
||||||
board.generateBoard('medium');
|
|
||||||
gameStatusManager.startGame();
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePause = (pause?: boolean) => {
|
|
||||||
gameStatusManager.togglePause(pause);
|
|
||||||
};
|
|
||||||
|
|
||||||
const quitGame = () => {
|
|
||||||
gameStatusManager.quitGame();
|
|
||||||
};
|
|
||||||
|
|
||||||
const gameManager: GameManager = {
|
|
||||||
containerRef: board.containerRef,
|
|
||||||
renderBoard: board.renderBoard,
|
|
||||||
gameStatus: gameStatusManager.gameStatus,
|
|
||||||
setGameStatus: gameStatusManager.setGameStatus,
|
|
||||||
startGame,
|
|
||||||
togglePause,
|
|
||||||
quitGame,
|
|
||||||
cursor,
|
|
||||||
scoreManager,
|
|
||||||
keyLog: keyManager.keyLog,
|
|
||||||
clearKeyLog: keyManager.clearKeyLog,
|
|
||||||
getKeyLog: keyManager.getKeyLog,
|
|
||||||
board,
|
|
||||||
solveBoard: board.solveBoard,
|
|
||||||
resetBoard: board.resetBoard
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof platformHook === 'function') {
|
|
||||||
platformHook(gameManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
return gameManager;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { useGameStatus } from './useGameStatus';
|
|
||||||
export type { GameStatus, GameStatusManager } from './types';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type GameStatus = 'waiting' | 'started' | 'paused' | 'game-over' | 'game-won';
|
|
||||||
|
|
||||||
export type GameStatusManager = {
|
|
||||||
gameStatus: GameStatus;
|
|
||||||
setGameStatus: (status: GameStatus) => void;
|
|
||||||
startGame: () => void;
|
|
||||||
togglePause: (pause?: boolean) => void;
|
|
||||||
quitGame: () => void;
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type { GameStatusManager, GameStatus } from './types';
|
|
||||||
|
|
||||||
export function useGameStatus(): GameStatusManager {
|
|
||||||
const [gameStatus, setGameStatus] = useState<GameStatus>('waiting');
|
|
||||||
|
|
||||||
const startGame = useCallback(() => {
|
|
||||||
setGameStatus('started');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePause = useCallback((pause?: boolean) => {
|
|
||||||
setGameStatus(current => {
|
|
||||||
if (current === 'started' || current === 'paused') {
|
|
||||||
return pause !== undefined ? (pause ? 'paused' : 'started') :
|
|
||||||
(current === 'started' ? 'paused' : 'started');
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const quitGame = useCallback(() => {
|
|
||||||
setGameStatus('waiting');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
gameStatus,
|
|
||||||
setGameStatus,
|
|
||||||
startGame,
|
|
||||||
togglePause,
|
|
||||||
quitGame
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { useScore } from './useScore';
|
|
||||||
export { useTimer } from './useTimer';
|
|
||||||
export type { ScoreManager, TimerManager, UseScoreProps } from './types';
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { BoardManager } from '../useBoard/types';
|
|
||||||
import type { GameStatusManager } from '../useGameStatus/types';
|
|
||||||
|
|
||||||
export type TimerManager = {
|
|
||||||
timeValue: number;
|
|
||||||
startTimer: () => void;
|
|
||||||
stopTimer: () => void;
|
|
||||||
resetTimer: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ScoreManager = {
|
|
||||||
timeValue: number;
|
|
||||||
keystrokes: number;
|
|
||||||
mistakes: number;
|
|
||||||
hints: number;
|
|
||||||
efficiency: number;
|
|
||||||
finalScore: number | null;
|
|
||||||
startTimer: () => void;
|
|
||||||
stopTimer: () => void;
|
|
||||||
resetTimer: () => void;
|
|
||||||
addMistake: () => void;
|
|
||||||
addHint: () => void;
|
|
||||||
calculateEfficiency: () => number;
|
|
||||||
calculateFinalScore: () => number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseScoreProps = {
|
|
||||||
board: BoardManager;
|
|
||||||
gameStatusManager: GameStatusManager;
|
|
||||||
keystrokeCount: number;
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import type { BoardManager } from '../useBoard/types';
|
|
||||||
import type { GameStatusManager } from '../useGameStatus/types';
|
|
||||||
import type { ScoreManager, UseScoreProps } from './types';
|
|
||||||
import { useTimer } from './useTimer';
|
|
||||||
|
|
||||||
export function useScore({ board, gameStatusManager, keystrokeCount }: UseScoreProps): ScoreManager {
|
|
||||||
const timer = useTimer();
|
|
||||||
const [mistakes, setMistakes] = useState<number>(0);
|
|
||||||
const [hints, setHints] = useState<number>(0);
|
|
||||||
|
|
||||||
const addMistake = useCallback(() => {
|
|
||||||
setMistakes(prev => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addHint = useCallback(() => {
|
|
||||||
setHints(prev => prev + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const calculateEfficiency = useCallback((): number => {
|
|
||||||
const boardString = board.boardRef.current.map(row =>
|
|
||||||
row.map(cell => cell.value || '.').join('')
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
const filledCells = boardString.replace(/\./g, '').length;
|
|
||||||
const totalCells = 81;
|
|
||||||
const givenCells = board.initialBoardRef.current.replace(/\./g, '').length;
|
|
||||||
const userFilledCells = filledCells - givenCells;
|
|
||||||
|
|
||||||
if (userFilledCells === 0) return 100;
|
|
||||||
|
|
||||||
const optimalMoves = userFilledCells;
|
|
||||||
const actualMoves = keystrokeCount;
|
|
||||||
|
|
||||||
const efficiency = Math.round((optimalMoves / actualMoves) * 100);
|
|
||||||
return Math.max(0, Math.min(100, efficiency));
|
|
||||||
}, [board, keystrokeCount]);
|
|
||||||
|
|
||||||
const calculateFinalScore = useCallback((): number | null => {
|
|
||||||
const { gameStatus } = gameStatusManager;
|
|
||||||
|
|
||||||
if (gameStatus !== 'game-won') return null;
|
|
||||||
|
|
||||||
const efficiency = calculateEfficiency();
|
|
||||||
const timeInSeconds = timer.timeValue / 1000;
|
|
||||||
const timeBonus = Math.max(0, 3600 - timeInSeconds) * 10;
|
|
||||||
const mistakePenalty = mistakes * 100;
|
|
||||||
const hintPenalty = hints * 200;
|
|
||||||
|
|
||||||
const baseScore = 10000;
|
|
||||||
const efficiencyBonus = efficiency * 50;
|
|
||||||
|
|
||||||
const finalScore = baseScore + efficiencyBonus + timeBonus - mistakePenalty - hintPenalty;
|
|
||||||
return Math.max(0, Math.round(finalScore));
|
|
||||||
}, [gameStatusManager, calculateEfficiency, timer.timeValue, mistakes, hints]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { gameStatus } = gameStatusManager;
|
|
||||||
|
|
||||||
if (gameStatus === 'started') {
|
|
||||||
timer.startTimer();
|
|
||||||
} else if (gameStatus === 'paused' || gameStatus === 'game-over' || gameStatus === 'game-won') {
|
|
||||||
timer.stopTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameStatus === 'waiting') {
|
|
||||||
timer.resetTimer();
|
|
||||||
setMistakes(0);
|
|
||||||
setHints(0);
|
|
||||||
}
|
|
||||||
}, [gameStatusManager.gameStatus, timer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { gameStatus, setGameStatus } = gameStatusManager;
|
|
||||||
|
|
||||||
if (gameStatus === 'started' && board.isComplete() && board.isFullyValid()) {
|
|
||||||
setGameStatus('game-won');
|
|
||||||
}
|
|
||||||
}, [board, gameStatusManager]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeValue: timer.timeValue,
|
|
||||||
keystrokes: keystrokeCount,
|
|
||||||
mistakes,
|
|
||||||
hints,
|
|
||||||
efficiency: calculateEfficiency(),
|
|
||||||
finalScore: calculateFinalScore(),
|
|
||||||
startTimer: timer.startTimer,
|
|
||||||
stopTimer: timer.stopTimer,
|
|
||||||
resetTimer: timer.resetTimer,
|
|
||||||
addMistake,
|
|
||||||
addHint,
|
|
||||||
calculateEfficiency,
|
|
||||||
calculateFinalScore
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import type { TimerManager } from './types';
|
|
||||||
|
|
||||||
export function useTimer(): TimerManager {
|
|
||||||
const [timeValue, setTimeValue] = useState<number>(0);
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const startTimeRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const startTimer = () => {
|
|
||||||
if (intervalRef.current) return;
|
|
||||||
|
|
||||||
startTimeRef.current = Date.now() - timeValue;
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setTimeValue(Date.now() - startTimeRef.current);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopTimer = () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTimer = () => {
|
|
||||||
stopTimer();
|
|
||||||
setTimeValue(0);
|
|
||||||
startTimeRef.current = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeValue,
|
|
||||||
startTimer,
|
|
||||||
stopTimer,
|
|
||||||
resetTimer
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
/* =========================================================
|
|
||||||
🔧 Controls (tweak these)
|
|
||||||
========================================================= */
|
|
||||||
:root {
|
|
||||||
--cell-size: 96px;
|
|
||||||
--cell-font-size: 48px;
|
|
||||||
--grid-gap: 6px;
|
|
||||||
--box-separator: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Container & Grid Base
|
|
||||||
========================================================= */
|
|
||||||
#sudoku_container {
|
|
||||||
position: relative;
|
|
||||||
margin: 1em auto;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sudoku {
|
|
||||||
position: relative;
|
|
||||||
background-color: var(--card, #1e2030);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 0 10px rgba(27, 29, 43, 0.6), inset 0 0 6px rgba(130, 170, 255, 0.05);
|
|
||||||
padding: 0.5rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Grid Layout
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
|
||||||
grid-template-rows:
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size)
|
|
||||||
var(--cell-size) var(--cell-size) var(--cell-size);
|
|
||||||
column-gap: var(--grid-gap);
|
|
||||||
row-gap: var(--grid-gap);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Cell Base Styles
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell {
|
|
||||||
width: var(--cell-size);
|
|
||||||
height: var(--cell-size);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: "Fira Code", monospace;
|
|
||||||
font-size: var(--cell-font-size);
|
|
||||||
background: var(--muted, #2f334d);
|
|
||||||
color: var(--foreground, #c8d3f5);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
box-shadow: inset 0 0 3px rgba(27, 29, 43, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
3x3 Box Separators
|
|
||||||
========================================================= */
|
|
||||||
/* Right borders (end of each 3x3 box horizontally) */
|
|
||||||
#sudoku .sudoku-cell[data-c="2"],
|
|
||||||
#sudoku .sudoku-cell[data-c="5"],
|
|
||||||
#sudoku .sudoku-cell[data-c="8"] {
|
|
||||||
border-right: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left borders (start of each 3x3 box horizontally) */
|
|
||||||
#sudoku .sudoku-cell[data-c="0"],
|
|
||||||
#sudoku .sudoku-cell[data-c="3"],
|
|
||||||
#sudoku .sudoku-cell[data-c="6"] {
|
|
||||||
border-left: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bottom borders (end of each 3x3 box vertically) */
|
|
||||||
#sudoku .sudoku-cell[data-r="2"],
|
|
||||||
#sudoku .sudoku-cell[data-r="5"],
|
|
||||||
#sudoku .sudoku-cell[data-r="8"] {
|
|
||||||
border-bottom: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Top borders (start of each 3x3 box vertically) */
|
|
||||||
#sudoku .sudoku-cell[data-r="0"],
|
|
||||||
#sudoku .sudoku-cell[data-r="3"],
|
|
||||||
#sudoku .sudoku-cell[data-r="6"] {
|
|
||||||
border-top: 2px solid color-mix(in srgb, var(--primary, #82aaff) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
User-Entered Cells
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.user-entered {
|
|
||||||
color: var(--primary, #82aaff);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Active Cell Highlight (Navigation Mode)
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.active {
|
|
||||||
outline: 2px solid var(--primary, #82aaff);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--primary, #82aaff) 20%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 8px color-mix(in srgb, var(--primary, #82aaff) 40%, transparent),
|
|
||||||
0 0 12px color-mix(in srgb, var(--primary, #82aaff) 20%, transparent),
|
|
||||||
inset 0 0 6px color-mix(in srgb, var(--primary, #82aaff) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Editing Cell Highlight (Edit Mode)
|
|
||||||
========================================================= */
|
|
||||||
#sudoku .sudoku-cell.editing {
|
|
||||||
outline: 2px solid var(--accent, #ffc777);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--accent, #ffc777) 15%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
|
||||||
animation: editing-cell-glow 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes editing-cell-glow {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--accent, #ffc777) 70%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--accent, #ffc777) 30%, transparent);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 20px color-mix(in srgb, var(--accent, #ffc777) 90%, transparent),
|
|
||||||
0 0 32px color-mix(in srgb, var(--accent, #ffc777) 60%, transparent),
|
|
||||||
inset 0 0 16px color-mix(in srgb, var(--accent, #ffc777) 40%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
Visual Feedback Effects
|
|
||||||
========================================================= */
|
|
||||||
/* Invalid move effect */
|
|
||||||
#sudoku .sudoku-cell.invalid-move {
|
|
||||||
animation: sudoku-invalid-pulse 180ms ease-out;
|
|
||||||
box-shadow:
|
|
||||||
0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 45%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-invalid-pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow:
|
|
||||||
0 0 16px color-mix(in srgb, var(--destructive, #c53b53) 95%, transparent),
|
|
||||||
0 0 28px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 color-mix(in srgb, var(--primary, #82aaff) 60%, transparent),
|
|
||||||
0 0 6px color-mix(in srgb, var(--primary, #82aaff) 35%, transparent);
|
|
||||||
animation: sudoku-trail-fade 220ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-trail-fade {
|
|
||||||
from { background-color: color-mix(in srgb, var(--primary, #82aaff) 25%, transparent); }
|
|
||||||
to { background-color: transparent; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Valid move flash */
|
|
||||||
#sudoku .sudoku-cell.valid-move {
|
|
||||||
animation: sudoku-valid-flash 300ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-valid-flash {
|
|
||||||
0% {
|
|
||||||
background: color-mix(in srgb, var(--success, #4fd6be) 40%, transparent);
|
|
||||||
box-shadow:
|
|
||||||
0 0 12px color-mix(in srgb, var(--success, #4fd6be) 80%, transparent),
|
|
||||||
0 0 20px color-mix(in srgb, var(--success, #4fd6be) 50%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--success, #4fd6be) 35%, transparent);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Conflict highlighting */
|
|
||||||
#sudoku .sudoku-cell.conflict {
|
|
||||||
outline: 2px solid var(--destructive, #c53b53);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background: color-mix(in srgb, var(--destructive, #c53b53) 25%, var(--muted, #2f334d));
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
|
||||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
|
||||||
animation: sudoku-conflict-pulse 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes sudoku-conflict-pulse {
|
|
||||||
0%, 100% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 10px color-mix(in srgb, var(--destructive, #c53b53) 70%, transparent),
|
|
||||||
0 0 18px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent),
|
|
||||||
inset 0 0 8px color-mix(in srgb, var(--destructive, #c53b53) 30%, transparent);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow:
|
|
||||||
0 0 14px color-mix(in srgb, var(--destructive, #c53b53) 85%, transparent),
|
|
||||||
0 0 24px color-mix(in srgb, var(--destructive, #c53b53) 55%, transparent),
|
|
||||||
inset 0 0 12px color-mix(in srgb, var(--destructive, #c53b53) 40%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './types';
|
|
||||||
export * from "./useGame";
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
// src/lib/Sudoku.ts
|
|
||||||
// Direct TypeScript + ES module version of Robatron's sudoku.js
|
|
||||||
// https://github.com/robatron/sudoku.js
|
|
||||||
// No algorithmic changes – only syntax, structure, and typing updates.
|
|
||||||
|
|
||||||
export type DifficultyName =
|
|
||||||
| "easy"
|
|
||||||
| "medium"
|
|
||||||
| "hard"
|
|
||||||
| "very-hard"
|
|
||||||
| "insane"
|
|
||||||
| "inhuman";
|
|
||||||
|
|
||||||
export type Difficulty = DifficultyName | number;
|
|
||||||
|
|
||||||
const sudoku: any = {};
|
|
||||||
|
|
||||||
sudoku.DIGITS = "123456789";
|
|
||||||
const ROWS = "ABCDEFGHI";
|
|
||||||
const COLS = sudoku.DIGITS;
|
|
||||||
let SQUARES: string[] = [];
|
|
||||||
|
|
||||||
let UNITS: string[][] = [];
|
|
||||||
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
|
|
||||||
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
const MIN_GIVENS = 17;
|
|
||||||
const NR_SQUARES = 81;
|
|
||||||
|
|
||||||
const DIFFICULTY: Record<DifficultyName, number> = {
|
|
||||||
easy: 62,
|
|
||||||
medium: 53,
|
|
||||||
hard: 44,
|
|
||||||
"very-hard": 35,
|
|
||||||
insane: 26,
|
|
||||||
inhuman: 17,
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku.BLANK_CHAR = ".";
|
|
||||||
sudoku.BLANK_BOARD = sudoku.BLANK_CHAR.repeat(NR_SQUARES);
|
|
||||||
|
|
||||||
function initialize() {
|
|
||||||
SQUARES = sudoku._cross(ROWS, COLS);
|
|
||||||
UNITS = sudoku._get_all_units(ROWS, COLS);
|
|
||||||
SQUARE_UNITS_MAP = sudoku._get_square_units_map(SQUARES, UNITS);
|
|
||||||
SQUARE_PEERS_MAP = sudoku._get_square_peers_map(SQUARES, SQUARE_UNITS_MAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Generate
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku.generate = function (difficulty: Difficulty, unique?: boolean): string {
|
|
||||||
let numericDifficulty: number;
|
|
||||||
if (typeof difficulty === "string" || typeof difficulty === "undefined") {
|
|
||||||
numericDifficulty = DIFFICULTY[difficulty as DifficultyName] || DIFFICULTY.easy;
|
|
||||||
} else {
|
|
||||||
numericDifficulty = difficulty;
|
|
||||||
}
|
|
||||||
|
|
||||||
numericDifficulty = sudoku._force_range(numericDifficulty, NR_SQUARES + 1, MIN_GIVENS);
|
|
||||||
unique = unique ?? true;
|
|
||||||
|
|
||||||
const blank_board = sudoku.BLANK_BOARD;
|
|
||||||
let candidates = sudoku._get_candidates_map(blank_board);
|
|
||||||
|
|
||||||
const shuffled_squares = sudoku._shuffle(SQUARES);
|
|
||||||
|
|
||||||
for (const square of shuffled_squares) {
|
|
||||||
const rand_candidate_idx = sudoku._rand_range(candidates[square].length);
|
|
||||||
const rand_candidate = candidates[square][rand_candidate_idx];
|
|
||||||
if (!sudoku._assign(candidates, square, rand_candidate)) break;
|
|
||||||
|
|
||||||
const single_candidates: string[] = [];
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
if (candidates[s].length === 1) single_candidates.push(candidates[s]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
single_candidates.length >= numericDifficulty &&
|
|
||||||
sudoku._strip_dups(single_candidates).length >= 8
|
|
||||||
) {
|
|
||||||
let board = "";
|
|
||||||
let givens_idxs: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < SQUARES.length; i++) {
|
|
||||||
const s = SQUARES[i];
|
|
||||||
if (candidates[s].length === 1) {
|
|
||||||
board += candidates[s];
|
|
||||||
givens_idxs.push(i);
|
|
||||||
} else {
|
|
||||||
board += sudoku.BLANK_CHAR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (givens_idxs.length > numericDifficulty) {
|
|
||||||
givens_idxs = sudoku._shuffle(givens_idxs);
|
|
||||||
for (let i = 0; i < givens_idxs.length - numericDifficulty; ++i) {
|
|
||||||
const target = givens_idxs[i];
|
|
||||||
board =
|
|
||||||
board.substring(0, target) +
|
|
||||||
sudoku.BLANK_CHAR +
|
|
||||||
board.substring(target + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sudoku.solve(board)) return board;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sudoku.generate(numericDifficulty);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Solve
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku.solve = function (board: string, reverse?: boolean): string | false {
|
|
||||||
const report = sudoku.validate_board(board);
|
|
||||||
if (report !== true) throw report;
|
|
||||||
|
|
||||||
let nr_givens = 0;
|
|
||||||
for (const ch of board) {
|
|
||||||
if (ch !== sudoku.BLANK_CHAR && sudoku._in(ch, sudoku.DIGITS)) nr_givens++;
|
|
||||||
}
|
|
||||||
if (nr_givens < MIN_GIVENS) throw "Too few givens";
|
|
||||||
|
|
||||||
reverse = reverse || false;
|
|
||||||
const candidates = sudoku._get_candidates_map(board);
|
|
||||||
const result = sudoku._search(candidates, reverse);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
let solution = "";
|
|
||||||
for (const s of SQUARES) solution += result[s];
|
|
||||||
return solution;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Get candidates
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku.get_candidates = function (board: string): string[][] | false {
|
|
||||||
const report = sudoku.validate_board(board);
|
|
||||||
if (report !== true) throw report;
|
|
||||||
|
|
||||||
const map = sudoku._get_candidates_map(board);
|
|
||||||
if (!map) return false;
|
|
||||||
|
|
||||||
const rows: string[][] = [];
|
|
||||||
let cur_row: string[] = [];
|
|
||||||
let i = 0;
|
|
||||||
for (const square in map) {
|
|
||||||
cur_row.push(map[square]);
|
|
||||||
if (i % 9 === 8) {
|
|
||||||
rows.push(cur_row);
|
|
||||||
cur_row = [];
|
|
||||||
}
|
|
||||||
++i;
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._get_candidates_map = function (board: string): Record<string, string> | false {
|
|
||||||
const report = sudoku.validate_board(board);
|
|
||||||
if (report !== true) throw report;
|
|
||||||
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
const squares_values = sudoku._get_square_vals_map(board);
|
|
||||||
|
|
||||||
for (const s of SQUARES) map[s] = sudoku.DIGITS;
|
|
||||||
|
|
||||||
for (const s in squares_values) {
|
|
||||||
const val = squares_values[s];
|
|
||||||
if (sudoku._in(val, sudoku.DIGITS)) {
|
|
||||||
const new_candidates = sudoku._assign(map, s, val);
|
|
||||||
if (!new_candidates) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Search
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku._search = function (candidates: Record<string, string>, reverse: boolean): any {
|
|
||||||
if (!candidates) return false;
|
|
||||||
reverse = reverse || false;
|
|
||||||
|
|
||||||
let max_nr_candidates = 0;
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
const n = candidates[s].length;
|
|
||||||
if (n > max_nr_candidates) {
|
|
||||||
max_nr_candidates = n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (max_nr_candidates === 1) return candidates;
|
|
||||||
|
|
||||||
let min_nr_candidates = 10;
|
|
||||||
let min_square = "";
|
|
||||||
for (const s of SQUARES) {
|
|
||||||
const n = candidates[s].length;
|
|
||||||
if (n < min_nr_candidates && n > 1) {
|
|
||||||
min_nr_candidates = n;
|
|
||||||
min_square = s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const min_candidates = candidates[min_square];
|
|
||||||
if (!reverse) {
|
|
||||||
for (const v of min_candidates) {
|
|
||||||
const copy = JSON.parse(JSON.stringify(candidates));
|
|
||||||
const next = sudoku._search(sudoku._assign(copy, min_square, v), reverse);
|
|
||||||
if (next) return next;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = min_candidates.length - 1; i >= 0; --i) {
|
|
||||||
const v = min_candidates[i];
|
|
||||||
const copy = JSON.parse(JSON.stringify(candidates));
|
|
||||||
const next = sudoku._search(sudoku._assign(copy, min_square, v), reverse);
|
|
||||||
if (next) return next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Assignment / elimination logic
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku._assign = function (candidates: Record<string, string>, square: string, val: string) {
|
|
||||||
const others = candidates[square].replace(val, "");
|
|
||||||
for (const v of others) {
|
|
||||||
const next = sudoku._eliminate(candidates, square, v);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._eliminate = function (candidates: Record<string, string>, square: string, val: string) {
|
|
||||||
if (!sudoku._in(val, candidates[square])) return candidates;
|
|
||||||
|
|
||||||
candidates[square] = candidates[square].replace(val, "");
|
|
||||||
const n = candidates[square].length;
|
|
||||||
|
|
||||||
if (n === 1) {
|
|
||||||
const target_val = candidates[square];
|
|
||||||
for (const peer of SQUARE_PEERS_MAP[square]) {
|
|
||||||
const next = sudoku._eliminate(candidates, peer, target_val);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
} else if (n === 0) return false;
|
|
||||||
|
|
||||||
for (const unit of SQUARE_UNITS_MAP[square]) {
|
|
||||||
const places: string[] = [];
|
|
||||||
for (const sq of unit) {
|
|
||||||
if (sudoku._in(val, candidates[sq])) places.push(sq);
|
|
||||||
}
|
|
||||||
if (places.length === 0) return false;
|
|
||||||
else if (places.length === 1) {
|
|
||||||
const next = sudoku._assign(candidates, places[0], val);
|
|
||||||
if (!next) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Data structures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku._get_square_vals_map = function (board: string) {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
if (board.length !== SQUARES.length) throw "Board length mismatch";
|
|
||||||
for (let i = 0; i < SQUARES.length; i++) map[SQUARES[i]] = board[i];
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._get_square_units_map = function (squares: string[], units: string[][]) {
|
|
||||||
const map: Record<string, string[][]> = {};
|
|
||||||
for (const s of squares) {
|
|
||||||
const list: string[][] = [];
|
|
||||||
for (const u of units) if (u.includes(s)) list.push(u);
|
|
||||||
map[s] = list;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._get_square_peers_map = function (
|
|
||||||
squares: string[],
|
|
||||||
units_map: Record<string, string[][]>
|
|
||||||
) {
|
|
||||||
const map: Record<string, string[]> = {};
|
|
||||||
for (const s of squares) {
|
|
||||||
const peers: string[] = [];
|
|
||||||
for (const unit of units_map[s]) {
|
|
||||||
for (const sq of unit) {
|
|
||||||
if (sq !== s && !peers.includes(sq)) peers.push(sq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map[s] = peers;
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._get_all_units = function (rows: string, cols: string) {
|
|
||||||
const units: string[][] = [];
|
|
||||||
for (const r of rows) units.push(sudoku._cross(r, cols));
|
|
||||||
for (const c of cols) units.push(sudoku._cross(rows, c));
|
|
||||||
const row_blocks = ["ABC", "DEF", "GHI"];
|
|
||||||
const col_blocks = ["123", "456", "789"];
|
|
||||||
for (const rb of row_blocks)
|
|
||||||
for (const cb of col_blocks)
|
|
||||||
units.push(sudoku._cross(rb, cb));
|
|
||||||
return units;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Utils
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
sudoku.validate_board = function (board: string): true | string {
|
|
||||||
if (!board) return "Empty board";
|
|
||||||
if (board.length !== NR_SQUARES) return "Invalid board size";
|
|
||||||
for (let i = 0; i < board.length; i++) {
|
|
||||||
const c = board[i];
|
|
||||||
if (!sudoku._in(c, sudoku.DIGITS) && c !== sudoku.BLANK_CHAR)
|
|
||||||
return `Invalid char at ${i}: ${c}`;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._cross = function (a: string, b: string) {
|
|
||||||
const res: string[] = [];
|
|
||||||
for (const ai of a) for (const bi of b) res.push(ai + bi);
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._in = function (v: string, seq: string) {
|
|
||||||
return seq.indexOf(v) !== -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._shuffle = function <T>(seq: T[]): T[] {
|
|
||||||
const arr = [...seq];
|
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._rand_range = function (max: number, min = 0) {
|
|
||||||
return Math.floor(Math.random() * (max - min)) + min;
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._strip_dups = function <T>(seq: T[]): T[] {
|
|
||||||
return Array.from(new Set(seq));
|
|
||||||
};
|
|
||||||
|
|
||||||
sudoku._force_range = function (nr: number, max: number, min = 0) {
|
|
||||||
return Math.min(Math.max(nr || 0, min), max);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Init
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
initialize();
|
|
||||||
|
|
||||||
export { sudoku };
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './sudoku';
|
|
||||||
export * from './validate-board';
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Create a playable Sudoku puzzle by removing random digits
|
|
||||||
* from a fully solved board string.
|
|
||||||
*
|
|
||||||
* Difficulty only determines how many cells are removed.
|
|
||||||
* - "easy": removes ~40 cells
|
|
||||||
* - "medium": removes ~50 cells
|
|
||||||
* - "hard": removes ~60 cells
|
|
||||||
*
|
|
||||||
* The returned string uses '.' for blanks.
|
|
||||||
*/
|
|
||||||
export function makePuzzle(
|
|
||||||
solution: string,
|
|
||||||
difficulty: "easy" | "medium" | "hard" = "medium"
|
|
||||||
): string {
|
|
||||||
if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
|
|
||||||
throw new Error("Invalid solved board: must be 81 digits 1–9");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- removal count based on difficulty ---
|
|
||||||
const removeCount = {
|
|
||||||
easy: 40,
|
|
||||||
medium: 50,
|
|
||||||
hard: 60,
|
|
||||||
}[difficulty];
|
|
||||||
|
|
||||||
// --- create a mutable copy ---
|
|
||||||
const board = solution.split("");
|
|
||||||
|
|
||||||
// --- build a shuffled list of indices 0–80 ---
|
|
||||||
const indices = Array.from({ length: 81 }, (_, i) => i);
|
|
||||||
for (let i = indices.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[indices[i], indices[j]] = [indices[j], indices[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- remove the desired number of cells ---
|
|
||||||
for (let i = 0; i < removeCount; i++) {
|
|
||||||
const idx = indices[i];
|
|
||||||
board[idx] = ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
return board.join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { generateSolvedBoard } from "./sudoku";
|
|
||||||
import { assertValidFullBoard } from "./validate-board";
|
|
||||||
|
|
||||||
|
|
||||||
const valids = [
|
|
||||||
"534678912672195348198342567859761423426853791713924856961537284287419635345286179",
|
|
||||||
"456789123123456789789123456567891234234567891891234567678912345345678912912345678",
|
|
||||||
"234567891567891234891234567345678912678912345912345678456789123789123456123456789",
|
|
||||||
"987654321654321987321987654876543219543219876219876543765432198432198765198765432"
|
|
||||||
];
|
|
||||||
|
|
||||||
const invalids = [
|
|
||||||
"534678912672195348198342567859761423426853791713924856961537284287419635345286171",
|
|
||||||
"534678911672195348198342567859761423426853791713924856961537284287419635345286179",
|
|
||||||
"534678912572195348198342567859761423426853791713924856961537284287419635345286179",
|
|
||||||
"534678912672195348198342567859761423426863791713924856961537284287419635345286179",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("Testing the Asserter with static boards", () => {
|
|
||||||
test("valid boards", () => {
|
|
||||||
for (let board of valids) {
|
|
||||||
expect(() => assertValidFullBoard(board)).not.toThrow();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("invalid boards", () => {
|
|
||||||
for (let board of invalids) {
|
|
||||||
expect(() => assertValidFullBoard(board)).toThrow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Testing the generateSolvedBoard", () => {
|
|
||||||
test("generates 10 valid boards", () => {
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const generated = generateSolvedBoard();
|
|
||||||
assertValidFullBoard(generated);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
test("generates 100 valid boards", () => {
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
const generated = generateSolvedBoard();
|
|
||||||
assertValidFullBoard(generated);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test("generates 1000 valid boards", () => {
|
|
||||||
for (let i = 0; i < 1000; i++) {
|
|
||||||
const generated = generateSolvedBoard();
|
|
||||||
assertValidFullBoard(generated);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Generate a fully valid Sudoku solution using a base pattern and structural shuffles.
|
|
||||||
* Returns a string of 81 digits (row-major order).
|
|
||||||
*/
|
|
||||||
export function generateSolvedBoard(): string {
|
|
||||||
const size = 9;
|
|
||||||
const box = 3;
|
|
||||||
|
|
||||||
// --- 1️⃣ Base pattern function ---
|
|
||||||
const pattern = (r: number, c: number) =>
|
|
||||||
(box * (r % box) + Math.floor(r / box) + c) % size;
|
|
||||||
|
|
||||||
// --- 2️⃣ Create base board ---
|
|
||||||
const baseBoard: number[][] = Array.from({ length: size }, (_, r) =>
|
|
||||||
Array.from({ length: size }, (_, c) => pattern(r, c) + 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- 3️⃣ Helper to shuffle an array in place ---
|
|
||||||
const shuffle = <T>(arr: T[]): T[] => {
|
|
||||||
const copy = [...arr];
|
|
||||||
for (let i = copy.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- 4️⃣ Build shuffled row/col indices preserving 3×3 structure ---
|
|
||||||
const rows = shuffle([0, 1, 2])
|
|
||||||
.concat(shuffle([3, 4, 5]))
|
|
||||||
.concat(shuffle([6, 7, 8]));
|
|
||||||
const cols = shuffle([0, 1, 2])
|
|
||||||
.concat(shuffle([3, 4, 5]))
|
|
||||||
.concat(shuffle([6, 7, 8]));
|
|
||||||
|
|
||||||
// --- 5️⃣ Shuffle entire bands and stacks (groups of 3) ---
|
|
||||||
const rowBands = shuffle([0, 1, 2]);
|
|
||||||
const colStacks = shuffle([0, 1, 2]);
|
|
||||||
|
|
||||||
const shuffledRows = rowBands.flatMap(band => rows.slice(band * 3, band * 3 + 3));
|
|
||||||
const shuffledCols = colStacks.flatMap(stack => cols.slice(stack * 3, stack * 3 + 3));
|
|
||||||
|
|
||||||
// --- 6️⃣ Digit permutation (1–9) ---
|
|
||||||
const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|
||||||
|
|
||||||
// --- 7️⃣ Build the final shuffled board ---
|
|
||||||
const board: number[][] = shuffledRows.map(r =>
|
|
||||||
shuffledCols.map(c => digits[baseBoard[r][c] - 1])
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- 8️⃣ Flatten to 81-char string ---
|
|
||||||
return board.flat().join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validate a fully filled Sudoku board (81-char string)
|
|
||||||
* Returns true if:
|
|
||||||
* - 81 characters long
|
|
||||||
* - all characters are digits 1–9
|
|
||||||
* - all rows, columns, and 3×3 boxes sum to 45
|
|
||||||
*/
|
|
||||||
export function validateFullBoard(board: string): boolean {
|
|
||||||
// --- Basic sanity ---
|
|
||||||
if (board.length !== 81) return false;
|
|
||||||
if (!/^[1-9]+$/.test(board)) return false;
|
|
||||||
|
|
||||||
const getCell = (r: number, c: number) => Number(board[r * 9 + c]);
|
|
||||||
|
|
||||||
// --- Validate rows ---
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const sum = Array.from({ length: 9 }, (_, c) => getCell(r, c))
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
if (sum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validate columns ---
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const sum = Array.from({ length: 9 }, (_, r) => getCell(r, c))
|
|
||||||
.reduce((a, b) => a + b, 0);
|
|
||||||
if (sum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validate 3×3 boxes ---
|
|
||||||
for (let br = 0; br < 3; br++) {
|
|
||||||
for (let bc = 0; bc < 3; bc++) {
|
|
||||||
const sum = Array.from({ length: 9 }, (_, i) => {
|
|
||||||
const r = br * 3 + Math.floor(i / 3);
|
|
||||||
const c = bc * 3 + (i % 3);
|
|
||||||
return getCell(r, c);
|
|
||||||
}).reduce((a, b) => a + b, 0);
|
|
||||||
if (sum !== 45) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 🔍 Assert that a Sudoku board string represents a valid, fully filled grid.
|
|
||||||
*
|
|
||||||
* Format:
|
|
||||||
* - 81 characters
|
|
||||||
* - Only digits 1–9
|
|
||||||
* - Each row, column, and 3×3 box contains unique digits that sum to 45
|
|
||||||
*
|
|
||||||
* Throws descriptive errors on failure.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function assertValidFullBoard(board: string): void {
|
|
||||||
// --- Basic integrity checks ---
|
|
||||||
console.assert(board.length === 81, `❌ Board length must be 81, got ${board.length}`);
|
|
||||||
if (board.length !== 81) throw new Error(`Invalid board length: ${board.length}`);
|
|
||||||
|
|
||||||
// All characters must be digits 1–9
|
|
||||||
console.assert(/^[1-9]+$/.test(board), "❌ Board must contain only digits 1–9 (no 0s or dots)");
|
|
||||||
if (!/^[1-9]+$/.test(board)) throw new Error("Board contains invalid characters");
|
|
||||||
|
|
||||||
const get = (r: number, c: number) => Number(board[r * 9 + c]);
|
|
||||||
|
|
||||||
// --- Helper to assert uniqueness and sum ---
|
|
||||||
const assertGroup = (label: string, cells: number[]) => {
|
|
||||||
const sum = cells.reduce((a, b) => a + b, 0);
|
|
||||||
const unique = new Set(cells);
|
|
||||||
console.assert(sum === 45, `❌ ${label} sum=${sum} (should be 45)`);
|
|
||||||
console.assert(unique.size === 9, `❌ ${label} has duplicates: [${cells.join(",")}]`);
|
|
||||||
|
|
||||||
if (sum !== 45) throw new Error(`${label} does not sum to 45`);
|
|
||||||
if (unique.size !== 9) throw new Error(`${label} contains duplicates`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Validate rows ---
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const row = Array.from({ length: 9 }, (_, c) => get(r, c));
|
|
||||||
assertGroup(`Row ${r + 1}`, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validate columns ---
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const col = Array.from({ length: 9 }, (_, r) => get(r, c));
|
|
||||||
assertGroup(`Column ${c + 1}`, col);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validate 3×3 boxes ---
|
|
||||||
for (let br = 0; br < 3; br++) {
|
|
||||||
for (let bc = 0; bc < 3; bc++) {
|
|
||||||
const box: number[] = [];
|
|
||||||
for (let r = 0; r < 3; r++) {
|
|
||||||
for (let c = 0; c < 3; c++) {
|
|
||||||
box.push(get(br * 3 + r, bc * 3 + c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const idx = br * 3 + bc + 1;
|
|
||||||
assertGroup(`Box ${idx}`, box);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useGame } from "./useGame";
|
|
||||||
import { useScore } from "./useScore";
|
|
||||||
|
|
||||||
export type UseGameType = ReturnType<typeof useGame>;
|
|
||||||
export type UseScoreType = ReturnType<typeof useScore>;
|
|
||||||
|
|
||||||
export type {
|
|
||||||
Difficulty,
|
|
||||||
DifficultyName,
|
|
||||||
} from "./lib/Sudoku";
|
|
||||||
|
|
||||||
export type GamePhase = "idle" | "playing";
|
|
||||||
export type PlayStatus = "started" | "game-over" | "game-won";
|
|
||||||
export type GameStatus = "waiting" | "started" | "game-over" | "game-won";
|
|
||||||
|
|
||||||
export const isPlaying = (status: GameStatus): status is PlayStatus =>
|
|
||||||
status === "started" || status === "game-over" || status === "game-won";
|
|
||||||
|
|
||||||
export const getGamePhase = (status: GameStatus): GamePhase =>
|
|
||||||
status === "waiting" ? "idle" : "playing";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './useGame';
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { sudoku } from "../lib/Sudoku";
|
|
||||||
import type { UseCursorType } from "./useCursor";
|
|
||||||
|
|
||||||
type UseBoardStateOptions = {
|
|
||||||
validateMoves?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useBoardState(cursorManager: UseCursorType, options?: UseBoardStateOptions) {
|
|
||||||
const { cursor, setCursor } = cursorManager;
|
|
||||||
const { validateMoves = false } = options || {};
|
|
||||||
const [board, setBoard] = useState<string>(sudoku.generate("medium"));
|
|
||||||
const [initialBoard, setInitialBoard] = useState<string>(board);
|
|
||||||
const [solution, setSolution] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const givenCells = () => {
|
|
||||||
const cells = new Set<number>();
|
|
||||||
for (let i = 0; i < initialBoard.length; i++) {
|
|
||||||
if (initialBoard[i] !== ".") {
|
|
||||||
cells.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canEditCell = (row: number, col: number): boolean => {
|
|
||||||
const index = row * 9 + col;
|
|
||||||
return !givenCells().has(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidPlacement = (testBoard: string, row: number, col: number, digit: string): boolean => {
|
|
||||||
if (digit === ".") return true;
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const checkIdx = row * 9 + c;
|
|
||||||
if (c !== col && testBoard[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const checkIdx = r * 9 + col;
|
|
||||||
if (r !== row && testBoard[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxRow = Math.floor(row / 3) * 3;
|
|
||||||
const boxCol = Math.floor(col / 3) * 3;
|
|
||||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
|
||||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
|
||||||
const checkIdx = r * 9 + c;
|
|
||||||
if ((r !== row || c !== col) && testBoard[checkIdx] === digit) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConflicts = (testBoard: string, row: number, col: number): number[] => {
|
|
||||||
const conflicts: number[] = [];
|
|
||||||
const digit = testBoard[row * 9 + col];
|
|
||||||
|
|
||||||
if (digit === ".") return conflicts;
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
if (c !== col && testBoard[row * 9 + c] === digit) {
|
|
||||||
conflicts.push(row * 9 + c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
if (r !== row && testBoard[r * 9 + col] === digit) {
|
|
||||||
conflicts.push(r * 9 + col);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const boxRow = Math.floor(row / 3) * 3;
|
|
||||||
const boxCol = Math.floor(col / 3) * 3;
|
|
||||||
for (let r = boxRow; r < boxRow + 3; r++) {
|
|
||||||
for (let c = boxCol; c < boxCol + 3; c++) {
|
|
||||||
if ((r !== row || c !== col) && testBoard[r * 9 + c] === digit) {
|
|
||||||
const idx = r * 9 + c;
|
|
||||||
if (!conflicts.includes(idx)) {
|
|
||||||
conflicts.push(idx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return conflicts;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFullyValid = (testBoard: string): boolean => {
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
let rowSum = 0;
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const digit = testBoard[r * 9 + c];
|
|
||||||
if (digit === ".") return false;
|
|
||||||
rowSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
if (rowSum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
let colSum = 0;
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
const digit = testBoard[r * 9 + c];
|
|
||||||
colSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
if (colSum !== 45) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let boxRow = 0; boxRow < 3; boxRow++) {
|
|
||||||
for (let boxCol = 0; boxCol < 3; boxCol++) {
|
|
||||||
let boxSum = 0;
|
|
||||||
for (let r = boxRow * 3; r < boxRow * 3 + 3; r++) {
|
|
||||||
for (let c = boxCol * 3; c < boxCol * 3 + 3; c++) {
|
|
||||||
const digit = testBoard[r * 9 + c];
|
|
||||||
boxSum += parseInt(digit, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (boxSum !== 45) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isComplete = (testBoard: string): boolean => {
|
|
||||||
return !testBoard.includes(".");
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDigit = (d: string) => {
|
|
||||||
const cellIndex = cursor.r * 9 + cursor.c;
|
|
||||||
|
|
||||||
if (!canEditCell(cursor.r, cursor.c)) {
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arr = board.split("");
|
|
||||||
arr[cellIndex] = d;
|
|
||||||
const newBoard = arr.join("");
|
|
||||||
|
|
||||||
if (validateMoves && !isValidPlacement(newBoard, cursor.r, cursor.c, d)) {
|
|
||||||
const conflicts = getConflicts(newBoard, cursor.r, cursor.c);
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("sudoku-invalid-move", {
|
|
||||||
detail: { row: cursor.r, col: cursor.c, digit: d, conflicts },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBoard(newBoard);
|
|
||||||
if (validateMoves) {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("sudoku-valid-move", {
|
|
||||||
detail: { row: cursor.r, col: cursor.c, digit: d },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const erase = () => {
|
|
||||||
if (!canEditCell(cursor.r, cursor.c)) {
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const i = cursor.r * 9 + cursor.c;
|
|
||||||
const arr = board.split("");
|
|
||||||
arr[i] = ".";
|
|
||||||
setBoard(arr.join(""));
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
const newBoard = sudoku.generate("easy");
|
|
||||||
setBoard(newBoard);
|
|
||||||
setInitialBoard(newBoard);
|
|
||||||
setSolution(null);
|
|
||||||
setCursor({ r: 0, c: 0 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const solveBoard = () => {
|
|
||||||
const solved = sudoku.solve(initialBoard);
|
|
||||||
if (solved) {
|
|
||||||
setSolution(solved);
|
|
||||||
setBoard(solved);
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-game-won"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const boardToString = (human: boolean = false) => {
|
|
||||||
// Computer-optimized: compact 81-character string
|
|
||||||
if (!human) {
|
|
||||||
return board;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Human-readable: formatted grid with 3x3 box separators
|
|
||||||
let result = "";
|
|
||||||
for (let r = 0; r < 9; r++) {
|
|
||||||
for (let c = 0; c < 9; c++) {
|
|
||||||
const idx = r * 9 + c;
|
|
||||||
result += board[idx] === "." ? "." : board[idx];
|
|
||||||
if (c === 2 || c === 5) result += " | ";
|
|
||||||
else if (c < 8) result += " ";
|
|
||||||
}
|
|
||||||
result += "\n";
|
|
||||||
if (r === 2 || r === 5) result += "------+-------+------\n";
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
board,
|
|
||||||
initialBoard,
|
|
||||||
solution,
|
|
||||||
setDigit,
|
|
||||||
erase,
|
|
||||||
reset,
|
|
||||||
solveBoard,
|
|
||||||
boardToString,
|
|
||||||
canEditCell,
|
|
||||||
isValidPlacement,
|
|
||||||
getConflicts,
|
|
||||||
isFullyValid,
|
|
||||||
isComplete,
|
|
||||||
givenCells,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseBoardStateType = ReturnType<typeof useBoardState>;
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useRef, useState, useCallback, useEffect } from "react";
|
|
||||||
import type { UseRenderer } from "./useSudokuRenderer";
|
|
||||||
|
|
||||||
export function useCursor(rendererManager: UseRenderer) {
|
|
||||||
const { containerRef } = rendererManager;
|
|
||||||
const [cursor, setCursor] = useState<{ r: number; c: number }>({ r: 0, c: 0 });
|
|
||||||
|
|
||||||
const animatingRef = useRef(false);
|
|
||||||
|
|
||||||
const getCellEl = useCallback((r: number, c: number): HTMLElement | null => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return null;
|
|
||||||
const sudokuDiv = container.querySelector("#sudoku");
|
|
||||||
if (!sudokuDiv) return null;
|
|
||||||
const selector = `.sudoku-cell[data-r="${r}"][data-c="${c}"]`;
|
|
||||||
return sudokuDiv.querySelector(selector) as HTMLElement | null;
|
|
||||||
}, [containerRef]);
|
|
||||||
|
|
||||||
const flashInvalid = useCallback(() => {
|
|
||||||
const el = getCellEl(cursor.r, cursor.c);
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.add("invalid-move");
|
|
||||||
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 flashValid = useCallback(() => {
|
|
||||||
const el = getCellEl(cursor.r, cursor.c);
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.add("valid-move");
|
|
||||||
setTimeout(() => el.classList.remove("valid-move"), 300);
|
|
||||||
}, [cursor, getCellEl]);
|
|
||||||
|
|
||||||
const highlightConflicts = useCallback((conflictIndices: number[]) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
const sudokuDiv = container.querySelector("#sudoku");
|
|
||||||
if (!sudokuDiv) return;
|
|
||||||
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const r = Math.floor(idx / 9);
|
|
||||||
const c = idx % 9;
|
|
||||||
const el = getCellEl(r, c);
|
|
||||||
if (el) {
|
|
||||||
el.classList.add("conflict");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
conflictIndices.forEach((idx) => {
|
|
||||||
const r = Math.floor(idx / 9);
|
|
||||||
const c = idx % 9;
|
|
||||||
const el = getCellEl(r, c);
|
|
||||||
if (el) {
|
|
||||||
el.classList.remove("conflict");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
}, [containerRef, getCellEl]);
|
|
||||||
|
|
||||||
const moveCursor = useCallback(
|
|
||||||
(dr: number, dc: number, steps = 1) => {
|
|
||||||
if (animatingRef.current) return;
|
|
||||||
|
|
||||||
setCursor((prev) => {
|
|
||||||
const targetR = prev.r + (dr * steps);
|
|
||||||
const targetC = prev.c + (dc * steps);
|
|
||||||
|
|
||||||
// Check if the full counted move would go out of bounds
|
|
||||||
if (targetR < 0 || targetR > 8 || targetC < 0 || targetC > 8) {
|
|
||||||
// Flash invalid and don't move
|
|
||||||
setTimeout(() => flashInvalid(), 0);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid move - apply it
|
|
||||||
return { r: targetR, c: targetC };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[flashInvalid]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onLockedCell = () => flashLocked();
|
|
||||||
const onValidMove = () => flashValid();
|
|
||||||
const onInvalidMove = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail;
|
|
||||||
if (detail?.conflicts) {
|
|
||||||
highlightConflicts(detail.conflicts);
|
|
||||||
}
|
|
||||||
flashInvalid();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("sudoku-locked-cell", onLockedCell);
|
|
||||||
window.addEventListener("sudoku-valid-move", onValidMove);
|
|
||||||
window.addEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("sudoku-locked-cell", onLockedCell);
|
|
||||||
window.removeEventListener("sudoku-valid-move", onValidMove);
|
|
||||||
window.removeEventListener("sudoku-invalid-move", onInvalidMove as EventListener);
|
|
||||||
};
|
|
||||||
}, [flashLocked, flashValid, flashInvalid, highlightConflicts]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
cursor,
|
|
||||||
setCursor,
|
|
||||||
moveCursor,
|
|
||||||
getCellEl,
|
|
||||||
flashInvalid,
|
|
||||||
flashValid,
|
|
||||||
highlightConflicts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseCursorType = ReturnType<typeof useCursor>;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useRenderer } from "./useSudokuRenderer";
|
|
||||||
import { useCursor } from "./useCursor";
|
|
||||||
import { useGameStatus } from "./useGameStatus";
|
|
||||||
import { useBoardState } from "./useBoardState";
|
|
||||||
import { useKeyBindings } from "./useKeyBindings";
|
|
||||||
import { useScore } from "../useScore";
|
|
||||||
|
|
||||||
export type UseGameOptions = {
|
|
||||||
validateMoves?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useGame(platformHook?: unknown, options?: UseGameOptions) {
|
|
||||||
const { validateMoves = false } = options || {};
|
|
||||||
const rendererManager = useRenderer();
|
|
||||||
const { containerRef, renderEmptyBoard, renderBoard, highlightCursor } = rendererManager;
|
|
||||||
const cursorManager = useCursor(rendererManager);
|
|
||||||
const { cursor, moveCursor } = cursorManager;
|
|
||||||
const gameManager = useGameStatus(rendererManager, cursorManager);
|
|
||||||
const { gameStatus, setGameStatus, startGame, stopGame } = gameManager;
|
|
||||||
const boardManager = useBoardState(cursorManager, { validateMoves });
|
|
||||||
const {
|
|
||||||
board,
|
|
||||||
initialBoard,
|
|
||||||
solution,
|
|
||||||
setDigit,
|
|
||||||
erase,
|
|
||||||
reset,
|
|
||||||
solveBoard,
|
|
||||||
isComplete,
|
|
||||||
isFullyValid,
|
|
||||||
} = boardManager;
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Keybindings
|
|
||||||
// ---------------------------
|
|
||||||
const keyBindings = useKeyBindings({
|
|
||||||
moveCursor,
|
|
||||||
setDigit,
|
|
||||||
erase,
|
|
||||||
stopGame,
|
|
||||||
cursor,
|
|
||||||
board,
|
|
||||||
initialBoard,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { editMode, keyLog } = keyBindings;
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Scoring (needs keyLog from keyBindings)
|
|
||||||
// ---------------------------
|
|
||||||
const scoreManager = useScore({
|
|
||||||
gameContext: { gameStatus, setGameStatus },
|
|
||||||
keystrokeCount: keyLog.length,
|
|
||||||
board,
|
|
||||||
isComplete,
|
|
||||||
isFullyValid,
|
|
||||||
initialBoard,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Rendering
|
|
||||||
// ---------------------------
|
|
||||||
useEffect(() => {
|
|
||||||
renderEmptyBoard();
|
|
||||||
}, [renderEmptyBoard]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameStatus === "waiting") {
|
|
||||||
renderEmptyBoard();
|
|
||||||
} else {
|
|
||||||
renderBoard(board, initialBoard);
|
|
||||||
highlightCursor(cursor.r, cursor.c, editMode === "edit");
|
|
||||||
}
|
|
||||||
}, [gameStatus, board, initialBoard, cursor, editMode, renderEmptyBoard, renderBoard, highlightCursor]);
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Game Control
|
|
||||||
// ---------------------------
|
|
||||||
const initGame = () => {
|
|
||||||
reset();
|
|
||||||
startGame();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Full Game Manager
|
|
||||||
// ---------------------------
|
|
||||||
const fullGameManager = {
|
|
||||||
// core
|
|
||||||
containerRef,
|
|
||||||
gameManager,
|
|
||||||
gameStatus,
|
|
||||||
setGameStatus,
|
|
||||||
startGame,
|
|
||||||
stopGame,
|
|
||||||
initGame,
|
|
||||||
|
|
||||||
// cursor
|
|
||||||
cursorManager,
|
|
||||||
cursor,
|
|
||||||
moveCursor,
|
|
||||||
|
|
||||||
// board state
|
|
||||||
boardManager,
|
|
||||||
board,
|
|
||||||
solution,
|
|
||||||
setDigit,
|
|
||||||
erase,
|
|
||||||
reset,
|
|
||||||
solveBoard,
|
|
||||||
|
|
||||||
// scoring
|
|
||||||
scoreManager,
|
|
||||||
|
|
||||||
// keybindings (vim-maze compatible)
|
|
||||||
...keyBindings,
|
|
||||||
};
|
|
||||||
|
|
||||||
// allow platform-level injection
|
|
||||||
if (typeof platformHook === "function") {
|
|
||||||
platformHook(fullGameManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullGameManager;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useCallback, useState, useMemo } from "react";
|
|
||||||
import type { UseRenderer } from "./useSudokuRenderer";
|
|
||||||
import type { UseCursorType } from "./useCursor";
|
|
||||||
import type { GameStatus } from "../types";
|
|
||||||
import { getGamePhase, isPlaying } from "../types";
|
|
||||||
|
|
||||||
export type { GameStatus } from "../types";
|
|
||||||
|
|
||||||
export function useGameStatus(
|
|
||||||
rendererManager: UseRenderer,
|
|
||||||
cursorManager: UseCursorType
|
|
||||||
) {
|
|
||||||
const { containerRef } = rendererManager;
|
|
||||||
const { setCursor } = cursorManager;
|
|
||||||
const [gameStatus, setGameStatus] = useState<GameStatus>("waiting");
|
|
||||||
|
|
||||||
const gamePhase = useMemo(() => getGamePhase(gameStatus), [gameStatus]);
|
|
||||||
const playStatus = useMemo(
|
|
||||||
() => (isPlaying(gameStatus) ? gameStatus : null),
|
|
||||||
[gameStatus]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startGame = useCallback(() => {
|
|
||||||
setGameStatus("started");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopGame = useCallback(() => {
|
|
||||||
setCursor({ r: 0, c: 0 });
|
|
||||||
setGameStatus("waiting");
|
|
||||||
const container = containerRef.current;
|
|
||||||
container?.querySelectorAll(".active").forEach((el) => el.classList.remove("active"));
|
|
||||||
}, [setCursor, containerRef]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
gameStatus,
|
|
||||||
gamePhase,
|
|
||||||
playStatus,
|
|
||||||
setGameStatus,
|
|
||||||
startGame,
|
|
||||||
stopGame,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseGameStatusType = ReturnType<typeof useGameStatus>;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
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<KeyLogEntry[]>([]);
|
|
||||||
const [editMode, setEditMode] = useState<EditMode>("navigation");
|
|
||||||
const logRef = useRef<KeyLogEntry[]>([]);
|
|
||||||
const countRef = useRef<string>(""); // Vim-style numeric prefix
|
|
||||||
const lastMotionRef = useRef<{ dr: number; dc: number; steps: number } | null>(null);
|
|
||||||
|
|
||||||
// Record keypresses for score/time analytics
|
|
||||||
const recordKey = (key: string) => {
|
|
||||||
const entry = { key, timestamp: performance.now() };
|
|
||||||
logRef.current.push(entry);
|
|
||||||
setKeyLog([...logRef.current]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLog = () => {
|
|
||||||
logRef.current = [];
|
|
||||||
setKeyLog([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLog = () => logRef.current;
|
|
||||||
|
|
||||||
const resetCount = () => {
|
|
||||||
countRef.current = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
const k = e.key;
|
|
||||||
|
|
||||||
// ========== 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 to replace (only works on user-filled cells)
|
|
||||||
if (k === "r" || k === "R") {
|
|
||||||
recordKey(k);
|
|
||||||
|
|
||||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
|
||||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
|
||||||
const isEmpty = ctx.board[cellIndex] === ".";
|
|
||||||
|
|
||||||
if (isClue) {
|
|
||||||
// Flash red - cell is locked (pre-filled clue)
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
// Flash red - cell is empty (use I to insert)
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditMode("edit");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// I to insert (only works on empty cells)
|
|
||||||
if (k === "i" || k === "I") {
|
|
||||||
recordKey(k);
|
|
||||||
|
|
||||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
|
||||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
|
||||||
const isEmpty = ctx.board[cellIndex] === ".";
|
|
||||||
|
|
||||||
if (isClue) {
|
|
||||||
// Flash red - cell is locked (pre-filled clue)
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEmpty) {
|
|
||||||
// Flash red - cell already has a value (use R to replace)
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditMode("edit");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// D or X to delete cell value in navigation mode
|
|
||||||
if (k === "d" || k === "D" || k === "x" || k === "X") {
|
|
||||||
recordKey(k);
|
|
||||||
|
|
||||||
const cellIndex = ctx.cursor.r * 9 + ctx.cursor.c;
|
|
||||||
const isClue = ctx.initialBoard[cellIndex] !== ".";
|
|
||||||
|
|
||||||
if (isClue) {
|
|
||||||
// Flash red - can't delete clues
|
|
||||||
window.dispatchEvent(new CustomEvent("sudoku-locked-cell"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.erase();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Escape cancels count prefix
|
|
||||||
if (k === "Escape") {
|
|
||||||
recordKey(k);
|
|
||||||
resetCount();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Numeric prefixes (for counted motions)
|
|
||||||
if (/^[0-9]$/.test(k) && k !== "0") {
|
|
||||||
recordKey(k);
|
|
||||||
countRef.current += k;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vim-style navigation
|
|
||||||
let dr = 0;
|
|
||||||
let dc = 0;
|
|
||||||
if (k === "h") dc = -1;
|
|
||||||
else if (k === "l") dc = 1;
|
|
||||||
else if (k === "j") dr = 1;
|
|
||||||
else if (k === "k") dr = -1;
|
|
||||||
else if (k === "0" || k === "^") dc = -999; // go to col 0
|
|
||||||
else if (k === "$") dc = 999; // go to col 8
|
|
||||||
else if (k === ".") {
|
|
||||||
recordKey(".");
|
|
||||||
const last = lastMotionRef.current;
|
|
||||||
if (last) ctx.moveCursor(last.dr, last.dc, last.steps);
|
|
||||||
return;
|
|
||||||
} else return;
|
|
||||||
|
|
||||||
recordKey(k);
|
|
||||||
|
|
||||||
const steps = Math.max(1, parseInt(countRef.current || "1", 10));
|
|
||||||
resetCount();
|
|
||||||
|
|
||||||
if (dc === -999) {
|
|
||||||
ctx.moveCursor(0, -1, 99); // move far left
|
|
||||||
lastMotionRef.current = { dr: 0, dc: -1, steps: 99 };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dc === 999) {
|
|
||||||
ctx.moveCursor(0, 1, 99); // move far right
|
|
||||||
lastMotionRef.current = { dr: 0, dc: 1, steps: 99 };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.moveCursor(dr, dc, steps);
|
|
||||||
lastMotionRef.current = { dr, dc, steps };
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, [ctx, editMode]);
|
|
||||||
|
|
||||||
// ✅ same interface as vim-maze useKeyBindings
|
|
||||||
return {
|
|
||||||
keyLog,
|
|
||||||
getLog,
|
|
||||||
clearLog,
|
|
||||||
resetCount,
|
|
||||||
countRef,
|
|
||||||
lastMotionRef,
|
|
||||||
editMode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseKeyBindingsType = ReturnType<typeof useKeyBindings>;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useRef, useCallback } from "react";
|
|
||||||
|
|
||||||
export function useRenderer() {
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const renderEmptyBoard = useCallback(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.innerHTML = "";
|
|
||||||
const sudokuDiv = document.createElement("div");
|
|
||||||
sudokuDiv.id = "sudoku";
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
|
||||||
grid.className = "sudoku-grid";
|
|
||||||
|
|
||||||
for (let i = 0; i < 81; i++) {
|
|
||||||
const r = Math.floor(i / 9);
|
|
||||||
const c = i % 9;
|
|
||||||
const cell = document.createElement("div");
|
|
||||||
cell.className = "sudoku-cell";
|
|
||||||
cell.dataset.r = String(r);
|
|
||||||
cell.dataset.c = String(c);
|
|
||||||
cell.textContent = "";
|
|
||||||
grid.appendChild(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
sudokuDiv.appendChild(grid);
|
|
||||||
container.appendChild(sudokuDiv);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderBoard = useCallback((board: string, initialBoard?: string) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.innerHTML = "";
|
|
||||||
const sudokuDiv = document.createElement("div");
|
|
||||||
sudokuDiv.id = "sudoku";
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
|
||||||
grid.className = "sudoku-grid";
|
|
||||||
|
|
||||||
for (let i = 0; i < 81; i++) {
|
|
||||||
const r = Math.floor(i / 9);
|
|
||||||
const c = i % 9;
|
|
||||||
const cell = document.createElement("div");
|
|
||||||
cell.className = "sudoku-cell";
|
|
||||||
cell.dataset.r = String(r);
|
|
||||||
cell.dataset.c = String(c);
|
|
||||||
cell.textContent = board[i] === "." ? "" : board[i];
|
|
||||||
|
|
||||||
if (initialBoard && board[i] !== "." && board[i] !== initialBoard[i]) {
|
|
||||||
cell.classList.add("user-entered");
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.appendChild(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
sudokuDiv.appendChild(grid);
|
|
||||||
container.appendChild(sudokuDiv);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const highlightCursor = useCallback((r: number, c: number, isEditing = false) => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const sudokuDiv = container.querySelector("#sudoku");
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { containerRef, renderEmptyBoard, renderBoard, highlightCursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseRenderer = ReturnType<typeof useRenderer>;
|
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
export type UseTimerReturn = {
|
|
||||||
timeValue: number;
|
|
||||||
startTimer: () => void;
|
|
||||||
stopTimer: () => void;
|
|
||||||
resetTimer: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UseTimerOptions = {
|
|
||||||
interval?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useTimer(options: UseTimerOptions = {}): UseTimerReturn {
|
|
||||||
const { interval = 100 } = options;
|
|
||||||
const [timeValue, setTimeValue] = useState(0);
|
|
||||||
|
|
||||||
const intervalIdRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const offsetRef = useRef(0);
|
|
||||||
const startTimeRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const update = useCallback(() => {
|
|
||||||
if (startTimeRef.current === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
setTimeValue(offsetRef.current + (now - startTimeRef.current));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startTimer = useCallback(() => {
|
|
||||||
if (intervalIdRef.current !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startTimeRef.current === null) {
|
|
||||||
startTimeRef.current = performance.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
update();
|
|
||||||
intervalIdRef.current = setInterval(update, Math.max(16, interval));
|
|
||||||
}, [interval, update]);
|
|
||||||
|
|
||||||
const stopTimer = useCallback(() => {
|
|
||||||
if (intervalIdRef.current !== null) {
|
|
||||||
clearInterval(intervalIdRef.current);
|
|
||||||
intervalIdRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startTimeRef.current !== null) {
|
|
||||||
update();
|
|
||||||
offsetRef.current += performance.now() - startTimeRef.current;
|
|
||||||
startTimeRef.current = null;
|
|
||||||
}
|
|
||||||
}, [update]);
|
|
||||||
|
|
||||||
const resetTimer = useCallback(() => {
|
|
||||||
if (intervalIdRef.current !== null) {
|
|
||||||
clearInterval(intervalIdRef.current);
|
|
||||||
intervalIdRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
startTimeRef.current = null;
|
|
||||||
offsetRef.current = 0;
|
|
||||||
setTimeValue(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (intervalIdRef.current !== null) {
|
|
||||||
clearInterval(intervalIdRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeValue,
|
|
||||||
startTimer,
|
|
||||||
stopTimer,
|
|
||||||
resetTimer,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './useScore';
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTimer } from "./hooks/useTimer";
|
|
||||||
import { useScoreTime } from "./useScoreTime";
|
|
||||||
import type { GameStatus } from "../types";
|
|
||||||
|
|
||||||
const TIME_LIMIT_MS = Infinity; // No time limit by default
|
|
||||||
const KEYSTROKE_LIMIT = Infinity; // No keystroke limit by default
|
|
||||||
|
|
||||||
type GameScoreContext = {
|
|
||||||
gameStatus: GameStatus;
|
|
||||||
setGameStatus: (status: GameStatus) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UseScoreParams = {
|
|
||||||
gameContext: GameScoreContext;
|
|
||||||
keystrokeCount: number;
|
|
||||||
board: string;
|
|
||||||
isComplete: (board: string) => boolean;
|
|
||||||
isFullyValid: (board: string) => boolean;
|
|
||||||
initialBoard: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useScore({
|
|
||||||
gameContext,
|
|
||||||
keystrokeCount,
|
|
||||||
board,
|
|
||||||
isComplete,
|
|
||||||
isFullyValid,
|
|
||||||
initialBoard
|
|
||||||
}: UseScoreParams) {
|
|
||||||
const timer = useTimer();
|
|
||||||
const { gameStatus, setGameStatus } = gameContext;
|
|
||||||
const { timeValue, startTimer, stopTimer, resetTimer } = timer;
|
|
||||||
|
|
||||||
const [finalScore, setFinalScore] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const minMovesRequired = initialBoard.split("").filter(c => c === ".").length;
|
|
||||||
const actualMoves = keystrokeCount;
|
|
||||||
const efficiency = minMovesRequired > 0
|
|
||||||
? Math.round((actualMoves / minMovesRequired) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
useScoreTime({ gameStatus, timer });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameStatus !== "started") return;
|
|
||||||
|
|
||||||
if (isComplete(board) && isFullyValid(board)) {
|
|
||||||
setGameStatus("game-won");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeValue >= TIME_LIMIT_MS) {
|
|
||||||
setGameStatus("game-over");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keystrokeCount >= KEYSTROKE_LIMIT) {
|
|
||||||
setGameStatus("game-over");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Enable efficiency-based game-over in future
|
|
||||||
// if (efficiency > 200) {
|
|
||||||
// setGameStatus("game-over");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
}, [gameStatus, timeValue, keystrokeCount, board, setGameStatus, isComplete, isFullyValid, efficiency]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleWin = () => {
|
|
||||||
if (gameStatus === "started") {
|
|
||||||
setGameStatus("game-won");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("sudoku-game-won", handleWin);
|
|
||||||
return () => window.removeEventListener("sudoku-game-won", handleWin);
|
|
||||||
}, [gameStatus, setGameStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (gameStatus !== "game-won") return;
|
|
||||||
|
|
||||||
const timeInSeconds = timeValue / 1000;
|
|
||||||
const baseScore = 100000;
|
|
||||||
const timePenalty = Math.floor(timeInSeconds * 10);
|
|
||||||
const extraMoves = Math.max(0, actualMoves - minMovesRequired);
|
|
||||||
const movePenalty = extraMoves * 50;
|
|
||||||
const score = Math.max(0, baseScore - timePenalty - movePenalty);
|
|
||||||
|
|
||||||
setFinalScore(score);
|
|
||||||
}, [gameStatus, timeValue, actualMoves, minMovesRequired]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timeValue,
|
|
||||||
startTimer,
|
|
||||||
stopTimer,
|
|
||||||
resetTimer,
|
|
||||||
finalScore,
|
|
||||||
timeLimit: TIME_LIMIT_MS,
|
|
||||||
keystrokeLimit: KEYSTROKE_LIMIT,
|
|
||||||
minMovesRequired,
|
|
||||||
actualMoves,
|
|
||||||
efficiency,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UseScoreType = ReturnType<typeof useScore>;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { UseTimerReturn } from "./hooks/useTimer";
|
|
||||||
import type { GameStatus } from "../types";
|
|
||||||
|
|
||||||
type UseScoreTimeParams = {
|
|
||||||
gameStatus: GameStatus;
|
|
||||||
timer: UseTimerReturn;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useScoreTime({ gameStatus, timer }: UseScoreTimeParams) {
|
|
||||||
const prevStatusRef = useRef<GameStatus | null>(null);
|
|
||||||
const { startTimer, stopTimer, resetTimer } = timer;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
prevStatusRef.current = gameStatus;
|
|
||||||
|
|
||||||
switch (gameStatus) {
|
|
||||||
case "started":
|
|
||||||
resetTimer();
|
|
||||||
startTimer();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "game-over":
|
|
||||||
case "game-won":
|
|
||||||
case "waiting":
|
|
||||||
stopTimer();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, [gameStatus, startTimer, stopTimer, resetTimer]);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user