diff --git a/src/components/SudokuGame.tsx b/src/components/SudokuGame.tsx index dc25926..fcd839b 100644 --- a/src/components/SudokuGame.tsx +++ b/src/components/SudokuGame.tsx @@ -4,14 +4,25 @@ import { GameBoard, Difficulty, GameMode, - STANDARD_REGION_MAP, - generatePuzzle, + RegionMap, + generatePuzzleForMode, getRegionId, getRegionNeighbors, - getRegionMapByMode, } from './sudokuEngine'; type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber'; +type SudokuRound = { + board: GameBoard; + regionMap: RegionMap; +}; + +const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => { + const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode); + return { + board: puzzle, + regionMap, + }; +}; const SudokuGame: React.FC = () => { // 游戏难度 @@ -20,10 +31,15 @@ const SudokuGame: React.FC = () => { // 游戏模式:标准或不规则 const [gameMode, setGameMode] = useState('standard'); - // 使用 useMemo 确保 regionMap 随 gameMode 变化 - const regionMap = useMemo(() => getRegionMapByMode(gameMode), [gameMode]); - - const [board, setBoard] = useState(() => generatePuzzle('hard', STANDARD_REGION_MAP)); + const [round, setRound] = useState(() => createSudokuRound('hard', 'standard')); + const { board, regionMap } = round; + + const setBoard = (nextBoard: GameBoard) => { + setRound((currentRound) => ({ + ...currentRound, + board: nextBoard, + })); + }; const [fixedCells, setFixedCells] = useState(() => board.map(row => row.map(cell => cell !== 0)) @@ -181,10 +197,9 @@ const SudokuGame: React.FC = () => { }; const createNewGame = (level: Difficulty, mode: GameMode = gameMode) => { - const nextRegionMap = getRegionMapByMode(mode); - const newPuzzle = generatePuzzle(level, nextRegionMap); - setBoard(newPuzzle); - setFixedCells(newPuzzle.map(row => row.map(cell => cell !== 0))); + const nextRound = createSudokuRound(level, mode); + setRound(nextRound); + setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0))); setSelectedCell(null); setIsGameComplete(false); setShowCompleteDialog(false); @@ -306,7 +321,7 @@ const SudokuGame: React.FC = () => { } } return highlighted; - }, [board, selectedCell, config]); + }, [board, selectedCell, config, regionMap]); // 计算剩余未填写的数字数量 const remainingCount = useMemo(() => { diff --git a/src/components/sudokuEngine.test.ts b/src/components/sudokuEngine.test.ts index f61fc27..c08de18 100644 --- a/src/components/sudokuEngine.test.ts +++ b/src/components/sudokuEngine.test.ts @@ -1,7 +1,9 @@ import { createEmptyBoard, generateFullBoard, + generateIrregularRegionMap, generatePuzzle, + generatePuzzleForMode, getRegionId, getRegionNeighbors, IRREGULAR_REGION_MAP, @@ -35,6 +37,17 @@ describe('sudokuEngine', () => { expect(isValidRegionMap(IRREGULAR_REGION_MAP)).toBe(true); }); + test('generateIrregularRegionMap creates a non-standard connected region map', () => { + const regionMap = generateIrregularRegionMap(); + const differentCells = regionMap.reduce( + (count, row, rowIndex) => count + row.filter((regionId, colIndex) => regionId !== STANDARD_REGION_MAP[rowIndex][colIndex]).length, + 0 + ); + + expect(isValidRegionMap(regionMap)).toBe(true); + expect(differentCells).toBeGreaterThan(0); + }); + test('isPlacementValid uses irregular regions instead of 3x3 boxes', () => { const board = createEmptyBoard(); board[0][0] = 5; @@ -56,6 +69,17 @@ describe('sudokuEngine', () => { expect(puzzle.flat().some((cell) => cell === 0)).toBe(true); }); + test('generatePuzzleForMode pairs irregular puzzles with their generated region map', () => { + const { puzzle, regionMap } = generatePuzzleForMode('medium', 'irregular'); + const fullBoard = generateFullBoard(regionMap); + + expect(isValidRegionMap(regionMap)).toBe(true); + expect(puzzle).toHaveLength(9); + expect(puzzle.every((row) => row.length === 9)).toBe(true); + expect(puzzle.flat().some((cell) => cell === 0)).toBe(true); + expectSolvedBoard(fullBoard, regionMap); + }); + test('generateFullBoard creates a solved irregular board', () => { const board = generateFullBoard(IRREGULAR_REGION_MAP); diff --git a/src/components/sudokuEngine.ts b/src/components/sudokuEngine.ts index aa03b05..99595d9 100644 --- a/src/components/sudokuEngine.ts +++ b/src/components/sudokuEngine.ts @@ -2,10 +2,35 @@ export type GameBoard = number[][]; export type Difficulty = 'easy' | 'medium' | 'hard'; export type GameMode = 'standard' | 'irregular'; export type RegionMap = number[][]; +export type GeneratedPuzzle = { + puzzle: GameBoard; + regionMap: RegionMap; +}; + +type SolvedSudoku = { + fullBoard: GameBoard; + regionMap: RegionMap; +}; + +type CellPosition = [number, number]; + +const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9]; +const DIRECTIONS: CellPosition[] = [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], +]; + +const MIN_IRREGULAR_CELL_DIFFERENCE = 16; +const MAX_IRREGULAR_RESTARTS = 24; +const MAX_IRREGULAR_GREEDY_STEPS = 28; + +const generatedFullBoards = new WeakMap(); export const createEmptyBoard = (): GameBoard => Array.from({ length: 9 }, () => Array(9).fill(0)); -const shuffleArray = (arr: number[]) => { +const shuffleArray = (arr: T[]): T[] => { const result = [...arr]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); @@ -14,6 +39,10 @@ const shuffleArray = (arr: number[]) => { return result; }; +const cloneBoard = (board: GameBoard): GameBoard => board.map((row) => [...row]); + +const cloneRegionMap = (regionMap: RegionMap): RegionMap => regionMap.map((row) => [...row]); + export const STANDARD_REGION_MAP: RegionMap = Array.from({ length: 9 }, (_, row) => Array.from({ length: 9 }, (_, col) => Math.floor(row / 3) * 3 + Math.floor(col / 3)) ); @@ -44,6 +73,8 @@ const IRREGULAR_FULL_BOARD: GameBoard = [ export const getRegionId = (regionMap: RegionMap, row: number, col: number): number => regionMap[row][col]; +const isInBounds = (row: number, col: number): boolean => row >= 0 && row < 9 && col >= 0 && col < 9; + export const isValidRegionMap = (regionMap: RegionMap): boolean => { if (regionMap.length !== 9 || regionMap.some((row) => row.length !== 9)) { return false; @@ -69,19 +100,14 @@ const isConnectedRegion = (cells: Array<[number, number]>): boolean => { const cellSet = new Set(cells.map(([row, col]) => `${row}-${col}`)); const seen = new Set(); - const queue: Array<[number, number]> = [cells[0]]; + const queue: CellPosition[] = [cells[0]]; seen.add(`${cells[0][0]}-${cells[0][1]}`); for (let index = 0; index < queue.length; index++) { const [row, col] = queue[index]; - const neighbors: Array<[number, number]> = [ - [row - 1, col], - [row + 1, col], - [row, col - 1], - [row, col + 1], - ]; - - for (const [nextRow, nextCol] of neighbors) { + for (const [rowOffset, colOffset] of DIRECTIONS) { + const nextRow = row + rowOffset; + const nextCol = col + colOffset; const key = `${nextRow}-${nextCol}`; if (cellSet.has(key) && !seen.has(key)) { seen.add(key); @@ -98,12 +124,203 @@ const isSameRegionMap = (left: RegionMap, right: RegionMap): boolean => left.every((row, rowIndex) => row.length === right[rowIndex].length && row.every((cell, colIndex) => cell === right[rowIndex][colIndex])); const randomizeDigits = (board: GameBoard): GameBoard => { - const shuffledDigits = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]); + const shuffledDigits = shuffleArray(DIGITS); const digitMap = new Map(shuffledDigits.map((digit, index) => [index + 1, digit])); return board.map((row) => row.map((cell) => digitMap.get(cell) ?? cell)); }; +const generateRandomStandardFullBoard = (): GameBoard => { + const pattern = (row: number, col: number) => ((row * 3 + Math.floor(row / 3) + col) % 9) + 1; + const rows = shuffleArray([0, 1, 2]).flatMap((band) => shuffleArray([0, 1, 2]).map((row) => band * 3 + row)); + const cols = shuffleArray([0, 1, 2]).flatMap((stack) => shuffleArray([0, 1, 2]).map((col) => stack * 3 + col)); + const shuffledDigits = shuffleArray(DIGITS); + + return rows.map((row) => cols.map((col) => shuffledDigits[pattern(row, col) - 1])); +}; + +const countDifferentCells = (left: RegionMap, right: RegionMap): number => { + let count = 0; + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + if (left[row][col] !== right[row][col]) { + count++; + } + } + } + return count; +}; + +const getTouchingRegionPairs = (regionMap: RegionMap): Array<[number, number]> => { + const pairs = new Set(); + + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + for (const [rowOffset, colOffset] of DIRECTIONS) { + const nextRow = row + rowOffset; + const nextCol = col + colOffset; + if (!isInBounds(nextRow, nextCol)) continue; + + const currentRegion = regionMap[row][col]; + const nextRegion = regionMap[nextRow][nextCol]; + if (currentRegion === nextRegion) continue; + + const [low, high] = currentRegion < nextRegion ? [currentRegion, nextRegion] : [nextRegion, currentRegion]; + pairs.add(`${low}-${high}`); + } + } + } + + return Array.from(pairs, (pair) => pair.split('-').map(Number) as [number, number]); +}; + +const getBoundaryCells = (regionMap: RegionMap, regionId: number, neighborRegionId: number): CellPosition[] => { + const cells: CellPosition[] = []; + + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + if (regionMap[row][col] !== regionId) continue; + + const touchesNeighbor = DIRECTIONS.some(([rowOffset, colOffset]) => { + const nextRow = row + rowOffset; + const nextCol = col + colOffset; + return isInBounds(nextRow, nextCol) && regionMap[nextRow][nextCol] === neighborRegionId; + }); + + if (touchesNeighbor) { + cells.push([row, col]); + } + } + } + + return cells; +}; + +const swapRegionCells = ( + regionMap: RegionMap, + firstCell: CellPosition, + secondCell: CellPosition, + firstRegionId: number, + secondRegionId: number +): RegionMap => { + const nextRegionMap = cloneRegionMap(regionMap); + const [firstRow, firstCol] = firstCell; + const [secondRow, secondCol] = secondCell; + + nextRegionMap[firstRow][firstCol] = secondRegionId; + nextRegionMap[secondRow][secondCol] = firstRegionId; + + return nextRegionMap; +}; + +const findBetterCompatibleRegionMap = ( + regionMap: RegionMap, + fullBoard: GameBoard, + currentDifference: number +): RegionMap | null => { + let bestDifference = currentDifference; + let bestRegionMaps: RegionMap[] = []; + + for (const [firstRegionId, secondRegionId] of shuffleArray(getTouchingRegionPairs(regionMap))) { + const firstBoundaryCells = shuffleArray(getBoundaryCells(regionMap, firstRegionId, secondRegionId)); + const secondBoundaryCells = getBoundaryCells(regionMap, secondRegionId, firstRegionId); + + for (const firstCell of firstBoundaryCells) { + const [firstRow, firstCol] = firstCell; + const matchingSecondCells = secondBoundaryCells.filter(([secondRow, secondCol]) => fullBoard[secondRow][secondCol] === fullBoard[firstRow][firstCol]); + + for (const secondCell of shuffleArray(matchingSecondCells)) { + const candidateRegionMap = swapRegionCells(regionMap, firstCell, secondCell, firstRegionId, secondRegionId); + const candidateDifference = countDifferentCells(candidateRegionMap, STANDARD_REGION_MAP); + + if (candidateDifference <= bestDifference || !isValidRegionMap(candidateRegionMap)) { + continue; + } + + if (candidateDifference > bestDifference) { + bestDifference = candidateDifference; + bestRegionMaps = []; + } + + bestRegionMaps.push(candidateRegionMap); + } + } + } + + if (bestRegionMaps.length === 0) { + return null; + } + + return shuffleArray(bestRegionMaps)[0]; +}; + +const isSolvedBoardForRegionMap = (board: GameBoard, regionMap: RegionMap): boolean => { + const expected = DIGITS.join(','); + const toKey = (values: number[]) => [...values].sort((a, b) => a - b).join(','); + + for (let index = 0; index < 9; index++) { + if (toKey(board[index]) !== expected) return false; + if (toKey(board.map((row) => row[index])) !== expected) return false; + + const regionValues: number[] = []; + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + if (regionMap[row][col] === index) { + regionValues.push(board[row][col]); + } + } + } + + if (toKey(regionValues) !== expected) return false; + } + + return true; +}; + +const rememberSolvedSudoku = (solvedSudoku: SolvedSudoku): SolvedSudoku => { + generatedFullBoards.set(solvedSudoku.regionMap, cloneBoard(solvedSudoku.fullBoard)); + return solvedSudoku; +}; + +const generateSolvedIrregularSudoku = (): SolvedSudoku => { + let bestSolvedSudoku: SolvedSudoku | null = null; + let bestDifference = -1; + + for (let restart = 0; restart < MAX_IRREGULAR_RESTARTS; restart++) { + const fullBoard = generateRandomStandardFullBoard(); + let regionMap = cloneRegionMap(STANDARD_REGION_MAP); + let difference = 0; + + for (let step = 0; step < MAX_IRREGULAR_GREEDY_STEPS; step++) { + const nextRegionMap = findBetterCompatibleRegionMap(regionMap, fullBoard, difference); + if (!nextRegionMap) break; + + regionMap = nextRegionMap; + difference = countDifferentCells(regionMap, STANDARD_REGION_MAP); + } + + if (isValidRegionMap(regionMap) && isSolvedBoardForRegionMap(fullBoard, regionMap) && difference > bestDifference) { + bestSolvedSudoku = { fullBoard, regionMap }; + bestDifference = difference; + } + + if (bestSolvedSudoku && bestDifference >= MIN_IRREGULAR_CELL_DIFFERENCE) { + return rememberSolvedSudoku(bestSolvedSudoku); + } + } + + if (bestSolvedSudoku && bestDifference > 0) { + return rememberSolvedSudoku(bestSolvedSudoku); + } + + return rememberSolvedSudoku({ + fullBoard: randomizeDigits(IRREGULAR_FULL_BOARD), + regionMap: cloneRegionMap(IRREGULAR_REGION_MAP), + }); +}; + +export const generateIrregularRegionMap = (): RegionMap => generateSolvedIrregularSudoku().regionMap; + export const isPlacementValid = ( board: GameBoard, row: number, @@ -132,26 +349,40 @@ export const isPlacementValid = ( }; const fillBoard = (board: GameBoard, regionMap: RegionMap): boolean => { + let bestCell: { row: number; col: number; candidates: number[] } | null = null; + for (let row = 0; row < 9; row++) { for (let col = 0; col < 9; col++) { if (board[row][col] !== 0) continue; - const numbers = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]); - for (const num of numbers) { - if (isPlacementValid(board, row, col, num, regionMap)) { - board[row][col] = num; - if (fillBoard(board, regionMap)) { - return true; - } - board[row][col] = 0; - } + const candidates = DIGITS.filter((num) => isPlacementValid(board, row, col, num, regionMap)); + if (!bestCell || candidates.length < bestCell.candidates.length) { + bestCell = { row, col, candidates }; } - return false; + if (candidates.length <= 1) { + break; + } + } + + if (bestCell && bestCell.candidates.length <= 1) { + break; } } - return true; + if (!bestCell) { + return true; + } + + for (const num of shuffleArray(bestCell.candidates)) { + board[bestCell.row][bestCell.col] = num; + if (fillBoard(board, regionMap)) { + return true; + } + board[bestCell.row][bestCell.col] = 0; + } + + return false; }; export const generateFullBoard = (regionMap: RegionMap): GameBoard => { @@ -159,6 +390,11 @@ export const generateFullBoard = (regionMap: RegionMap): GameBoard => { throw new Error('Invalid sudoku region map'); } + const generatedFullBoard = generatedFullBoards.get(regionMap); + if (generatedFullBoard) { + return cloneBoard(generatedFullBoard); + } + if (isSameRegionMap(regionMap, IRREGULAR_REGION_MAP)) { return randomizeDigits(IRREGULAR_FULL_BOARD); } @@ -183,8 +419,7 @@ const getRemovalCount = (difficulty: Difficulty) => { } }; -export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard => { - const fullBoard = generateFullBoard(regionMap); +const createPuzzleFromFullBoard = (difficulty: Difficulty, fullBoard: GameBoard): GameBoard => { const puzzle = fullBoard.map((row) => [...row]); const positions = Array.from({ length: 81 }, (_, idx) => idx); const toRemove = shuffleArray(positions).slice(0, getRemovalCount(difficulty)); @@ -198,6 +433,25 @@ export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): Ga return puzzle; }; +export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard => + createPuzzleFromFullBoard(difficulty, generateFullBoard(regionMap)); + +export const generatePuzzleForMode = (difficulty: Difficulty, mode: GameMode): GeneratedPuzzle => { + if (mode === 'standard') { + const regionMap = cloneRegionMap(STANDARD_REGION_MAP); + return { + puzzle: createPuzzleFromFullBoard(difficulty, generateRandomStandardFullBoard()), + regionMap, + }; + } + + const { fullBoard, regionMap } = generateSolvedIrregularSudoku(); + return { + puzzle: createPuzzleFromFullBoard(difficulty, fullBoard), + regionMap, + }; +}; + export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: number) => { const regionId = getRegionId(regionMap, row, col); @@ -210,4 +464,4 @@ export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: numbe }; export const getRegionMapByMode = (mode: GameMode): RegionMap => - mode === 'irregular' ? IRREGULAR_REGION_MAP : STANDARD_REGION_MAP; + mode === 'irregular' ? generateIrregularRegionMap() : cloneRegionMap(STANDARD_REGION_MAP);