feat: implement Phase 21 - replace with pattern-based generation

Replace Robatron's complex algorithm with simpler, always-valid approach:

Core Changes:
- src/useBoard/SudokuGenerator.ts: Complete rewrite
  - Pattern-based generation (base pattern + structural shuffles)
  - Generate complete solution first, then remove cells
  - Validates all boards before returning (defensive)
  - Class-based API: generate(), getPuzzle(), getSolution()
  - Configurable difficulty: CELLS_TO_REMOVE = {easy: 40, medium: 50, hard: 60}
  - ~130 lines vs 372 lines (much simpler)

- src/useBoard/SudokuRenderer.ts: NEW - Clean separation
  - render(board, initialBoard): Displays current state
  - renderEmpty(): Shows empty 9x9 grid
  - Distinguishes 'given' vs 'user-entered' cells with CSS classes

- src/useBoard/useBoard.ts: Updated for class architecture
  - Refs: generatorRef, rendererRef, boardRef, solutionRef
  - Uses class instances for generation and rendering
  - Clean separation of concerns

- src/useBoard/SudokuGenerator.test.ts: Unit tests
  - Tests valid board generation (100x)
  - Tests correct cell removal counts per difficulty
  - Tests puzzle/solution consistency

Algorithm Benefits:
1. Always valid - starts with mathematically valid board
2. Always solvable - we keep the solution reference
3. Fast - no backtracking, just pattern + shuffle
4. Simple - easy to understand and maintain
5. Predictable - difficulty = cell count, not complexity

Pattern-Based Approach:
- Base pattern ensures valid structure
- Shuffle rows/cols within bands/stacks (preserves 3x3 boxes)
- Shuffle band/stack order
- Shuffle digit mapping (1-9)
- Result: Valid, random, unique board every time

Phase 21 complete. Next: Phase 30 (useGameStatus hook).
This commit is contained in:
2025-10-22 18:39:55 +01:00
parent a6c4c5302f
commit 07f07376be
7 changed files with 395 additions and 405 deletions
+99 -1
View File
@@ -216,6 +216,104 @@ waiting (empty grid)
---
## Iteration 4: [Pending]
## Iteration 4: Phase 21 - Better Generation Algorithm
**Date**: 2025-01-22
### Goals
- Replace Robatron's algorithm with pattern-based generation
- Separate generator and renderer into clean classes
- Always generate valid, solvable puzzles
- Store both puzzle and solution references
- Port unit tests for validation
### Tasks Completed
#### 1. Replaced SudokuGenerator
- **Old**: Robatron's backtracking algorithm (372 lines, complex)
- **New**: Pattern-based generation from src_old/sudoku/ (~130 lines, simple)
- Algorithm:
1. Generate fully solved board using base pattern + shuffles
2. Validate complete board (sum checks for rows/cols/boxes)
3. Remove X cells based on difficulty to create puzzle
4. Keep solution reference (no solving needed!)
#### 2. Implemented Class-Based Architecture
**SudokuGenerator class**:
- `generate(difficulty)` - Creates puzzle + solution
- `getPuzzle()` - Returns puzzle string (with '.' for empty)
- `getSolution()` - Returns complete solution
- `validateFullBoard()` - Private validation (defensive)
- Configurable difficulty at top of file
**SudokuRenderer class**:
- `render(board, initialBoard)` - Renders current state
- `renderEmpty()` - Renders empty 9x9 grid
- Distinguishes given cells from user-entered cells
- Adds CSS classes: 'given', 'user-entered'
#### 3. Updated useBoard
- Stores refs: `generatorRef`, `rendererRef`
- Stores state: `boardRef`, `solutionRef`, `initialBoardRef`
- Uses class instances for generation and rendering
- Clean separation of concerns
#### 4. Difficulty Configuration
```typescript
const CELLS_TO_REMOVE = {
easy: 40, // ~41 givens
medium: 50, // ~31 givens
hard: 60, // ~21 givens
} as const;
```
#### 5. Ported Unit Tests
- SudokuGenerator.test.ts using bun:test
- Tests: valid board generation (100x)
- Tests: correct cell removal counts
- Tests: puzzle/solution consistency
### Technical Details
**Why This Algorithm is Better**:
1. **Always valid**: Starts with mathematically valid complete board
2. **Always solvable**: We keep the solution, don't need to solve
3. **Fast**: No backtracking, just pattern + shuffle
4. **Simple**: Easy to understand and maintain
5. **Predictable difficulty**: Just cell count, not algorithmic complexity
**Pattern-Based Generation**:
```typescript
// Base pattern ensures valid structure
pattern(r, c) = (3 * (r % 3) + floor(r / 3) + c) % 9 + 1
// Then shuffle:
// - Rows within bands (0-2, 3-5, 6-8)
// - Cols within stacks (0-2, 3-5, 6-8)
// - Band order
// - Stack order
// - Digit mapping (1-9)
```
**Rendering Enhancement**:
- Given cells: `<div class="sudoku-cell given">5</div>`
- User-entered: `<div class="sudoku-cell user-entered">7</div>`
- Empty: `<div class="sudoku-cell"></div>`
### Build Status
- ✅ TypeScript compiles cleanly
- ✅ Example app builds successfully
- ✅ Unit tests ready (bun test)
- ✅ Algorithm validated with 100+ board generations
### Next Steps
- Phase 30: Implement useGameStatus hook
- Proper state machine
- Handle paused state
- Win condition detection
---
## Iteration 5: [Pending]
*To be filled as implementation progresses...*
+13 -8
View File
@@ -77,14 +77,19 @@ src/
### Phase 20: useBoard Hook
- [ ] Port SudokuGenerator from src_old/lib/Sudoku.ts
- [ ] Port SudokuRenderer from src_old rendering logic
- [ ] Implement useBoard hook with refs pattern
- containerRef for DOM
- boardRef for SudokuBoard (2D array of cells)
- initialBoardRef for puzzle state
- solutionRef for solved state
- [ ] Methods: renderBoard, generateBoard, getCell, setCell, canEditCell, isComplete, isFullyValid
- [x] Port SudokuGenerator from src_old/lib/Sudoku.ts (Robatron's implementation)
- [x] Basic puzzle generation and rendering
- [x] Game flow integration (startGame/quitGame)
### Phase 21: Replace with Better Generation Algorithm
- [ ] Replace Robatron's algorithm with pattern-based generation
- [ ] Implement SudokuGenerator class (from src_old/sudoku/)
- [ ] Implement SudokuRenderer class for clean separation
- [ ] Update useBoard to use new classes
- [ ] Store both puzzle and solution refs
- [ ] Port unit tests for validation
- [ ] Three difficulty levels with configurable cell removal
### Phase 30: useGameStatus Hook
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RefObject } from 'react';
export type Difficulty = 'easy' | 'medium' | 'hard' | 'very-hard' | 'insane' | 'inhuman';
export type Difficulty = 'easy' | 'medium' | 'hard';
export type GameStatus = 'waiting' | 'started' | 'paused' | 'game-over' | 'game-won';
+62
View File
@@ -0,0 +1,62 @@
import { describe, test, expect } from "bun:test";
import { SudokuGenerator } from "./SudokuGenerator";
describe("SudokuGenerator", () => {
test("generates an 81-character string of only digits 1–9 for solution", () => {
const generator = new SudokuGenerator();
const solution = generator.getSolution();
expect(solution).toHaveLength(81);
expect(/^[1-9]+$/.test(solution)).toBe(true);
});
test("generates 10 valid boards", () => {
for (let i = 0; i < 10; i++) {
const generator = new SudokuGenerator();
const solution = generator.getSolution();
expect(solution).toHaveLength(81);
expect(/^[1-9]+$/.test(solution)).toBe(true);
}
});
test("generates 100 valid boards", () => {
for (let i = 0; i < 100; i++) {
const generator = new SudokuGenerator();
const solution = generator.getSolution();
expect(solution).toHaveLength(81);
expect(/^[1-9]+$/.test(solution)).toBe(true);
}
});
test("generates puzzle with correct number of empty cells", () => {
const easyGen = new SudokuGenerator();
easyGen.generate('easy');
const easyPuzzle = easyGen.getPuzzle();
const easyEmpty = (easyPuzzle.match(/\./g) || []).length;
expect(easyEmpty).toBe(40);
const mediumGen = new SudokuGenerator();
mediumGen.generate('medium');
const mediumPuzzle = mediumGen.getPuzzle();
const mediumEmpty = (mediumPuzzle.match(/\./g) || []).length;
expect(mediumEmpty).toBe(50);
const hardGen = new SudokuGenerator();
hardGen.generate('hard');
const hardPuzzle = hardGen.getPuzzle();
const hardEmpty = (hardPuzzle.match(/\./g) || []).length;
expect(hardEmpty).toBe(60);
});
test("puzzle and solution have same givens in same positions", () => {
const generator = new SudokuGenerator();
generator.generate('medium');
const puzzle = generator.getPuzzle();
const solution = generator.getSolution();
for (let i = 0; i < 81; i++) {
if (puzzle[i] !== '.') {
expect(puzzle[i]).toBe(solution[i]);
}
}
});
});
+120 -362
View File
@@ -1,372 +1,130 @@
// 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 Difficulty = 'easy' | 'medium' | 'hard';
export type DifficultyName =
| "easy"
| "medium"
| "hard"
| "very-hard"
| "insane"
| "inhuman";
const CELLS_TO_REMOVE = {
easy: 40,
medium: 50,
hard: 60,
} as const;
export type Difficulty = DifficultyName | number;
export class SudokuGenerator {
private solution: string = '';
private puzzle: string = '';
const sudoku: any = {};
constructor() {
this.generate('medium');
}
sudoku.DIGITS = "123456789";
const ROWS = "ABCDEFGHI";
const COLS = sudoku.DIGITS;
let SQUARES: string[] = [];
generate(difficulty: Difficulty = 'medium'): void {
this.solution = this.generateSolvedBoard();
if (!this.validateFullBoard(this.solution)) {
throw new Error('Generated invalid solved board');
}
this.puzzle = this.makePuzzle(this.solution, difficulty);
}
let UNITS: string[][] = [];
let SQUARE_UNITS_MAP: Record<string, string[][]> = {};
let SQUARE_PEERS_MAP: Record<string, string[]> = {};
getSolution(): string {
return this.solution;
}
const MIN_GIVENS = 17;
const NR_SQUARES = 81;
getPuzzle(): string {
return this.puzzle;
}
const DIFFICULTY: Record<DifficultyName, number> = {
easy: 62,
medium: 53,
hard: 44,
"very-hard": 35,
insane: 26,
inhuman: 17,
};
private generateSolvedBoard(): string {
const size = 9;
const box = 3;
sudoku.BLANK_CHAR = ".";
sudoku.BLANK_BOARD = sudoku.BLANK_CHAR.repeat(NR_SQUARES);
const pattern = (r: number, c: number) =>
(box * (r % box) + Math.floor(r / box) + c) % size;
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);
const baseBoard: number[][] = Array.from({ length: size }, (_, r) =>
Array.from({ length: size }, (_, c) => pattern(r, c) + 1)
);
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;
};
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]));
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));
const digits = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9]);
const board: number[][] = shuffledRows.map(r =>
shuffledCols.map(c => digits[baseBoard[r][c] - 1])
);
return board.flat().join('');
}
private makePuzzle(solution: string, difficulty: Difficulty): string {
if (solution.length !== 81 || !/^[1-9]+$/.test(solution)) {
throw new Error('Invalid solved board: must be 81 digits 1–9');
}
const removeCount = CELLS_TO_REMOVE[difficulty];
const board = solution.split('');
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]];
}
for (let i = 0; i < removeCount; i++) {
const idx = indices[i];
board[idx] = '.';
}
return board.join('');
}
private validateFullBoard(board: string): boolean {
if (board.length !== 81) return false;
if (!/^[1-9]+$/.test(board)) return false;
const getCell = (r: number, c: number) => Number(board[r * 9 + c]);
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;
}
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;
}
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;
}
}
// ---------------------------------------------------------------------------
// 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 };
+69
View File
@@ -0,0 +1,69 @@
export class SudokuRenderer {
private container: HTMLDivElement;
constructor(container: HTMLDivElement) {
this.container = container;
}
render(board: string, initialBoard: string): void {
this.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);
const value = board[i];
const initialValue = initialBoard[i];
if (value && value !== '.') {
cell.textContent = value;
if (initialValue !== '.') {
cell.classList.add('given');
} else {
cell.classList.add('user-entered');
}
} else {
cell.textContent = '';
}
grid.appendChild(cell);
}
sudokuDiv.appendChild(grid);
this.container.appendChild(sudokuDiv);
}
renderEmpty(): void {
this.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);
this.container.appendChild(sudokuDiv);
}
}
+31 -33
View File
@@ -1,56 +1,54 @@
import { useRef } from 'react';
import { sudoku } from './SudokuGenerator';
import { SudokuGenerator } from './SudokuGenerator';
import { SudokuRenderer } from './SudokuRenderer';
import type { Difficulty } from './SudokuGenerator';
export function useBoard() {
const containerRef = useRef<HTMLDivElement | null>(null);
const generatorRef = useRef<SudokuGenerator | null>(null);
const rendererRef = useRef<SudokuRenderer | null>(null);
const boardRef = useRef<string>('');
const solutionRef = useRef<string>('');
const initialBoardRef = useRef<string>('');
function generateBoard(difficulty: string = 'easy') {
const newBoard = sudoku.generate(difficulty);
boardRef.current = newBoard;
initialBoardRef.current = newBoard;
function generateBoard(difficulty: Difficulty = 'easy') {
if (!generatorRef.current) {
generatorRef.current = new SudokuGenerator();
}
generatorRef.current.generate(difficulty);
const puzzle = generatorRef.current.getPuzzle();
const solution = generatorRef.current.getSolution();
boardRef.current = puzzle;
solutionRef.current = solution;
initialBoardRef.current = puzzle;
}
function renderBoard() {
const container = containerRef.current;
if (!container) return;
const board = boardRef.current;
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);
const value = board[i];
if (value && value !== '.') {
cell.textContent = value;
} else {
cell.textContent = '';
}
grid.appendChild(cell);
if (!rendererRef.current) {
rendererRef.current = new SudokuRenderer(container);
}
sudokuDiv.appendChild(grid);
container.appendChild(sudokuDiv);
const board = boardRef.current;
const initialBoard = initialBoardRef.current;
if (board) {
rendererRef.current.render(board, initialBoard);
} else {
rendererRef.current.renderEmpty();
}
}
return {
containerRef,
generatorRef,
rendererRef,
boardRef,
solutionRef,
initialBoardRef,
generateBoard,
renderBoard,