diff --git a/src/components/SudokuGame.tsx b/src/components/SudokuGame.tsx index d739e80..c76a64f 100644 --- a/src/components/SudokuGame.tsx +++ b/src/components/SudokuGame.tsx @@ -27,6 +27,7 @@ const CELL_SIZE = 80; const CELL_FONT_SIZE = 36; const DRAFT_SIZE = CELL_SIZE / 2; const DRAFT_FONT_SIZE = CELL_FONT_SIZE / 2; +const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => { const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode); @@ -39,6 +40,50 @@ const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]); const cloneDraftCells = (draftCells: DraftCells): DraftCells => draftCells.map(row => [...row]); const createEmptyDraftCells = (): DraftCells => Array.from({ length: 9 }, () => Array(9).fill(false)); +const getCellKey = (row: number, col: number): string => `${row}-${col}`; + +const isFilledDigit = (value: number): boolean => value >= 1 && value <= 9; + +const findConflictCells = (boardToCheck: GameBoard, regionMapToCheck: RegionMap): Set => { + const conflictCells = new Set(); + + const addGroupConflicts = (cells: Array<[number, number]>) => { + const cellsByValue = new Map(); + + cells.forEach(([row, col]) => { + const value = boardToCheck[row][col]; + if (!isFilledDigit(value)) return; + + const keys = cellsByValue.get(value) ?? []; + keys.push(getCellKey(row, col)); + cellsByValue.set(value, keys); + }); + + cellsByValue.forEach((keys) => { + if (keys.length <= 1) return; + keys.forEach((key) => conflictCells.add(key)); + }); + }; + + for (let row = 0; row < 9; row++) { + addGroupConflicts(Array.from({ length: 9 }, (_, col) => [row, col])); + } + + for (let col = 0; col < 9; col++) { + addGroupConflicts(Array.from({ length: 9 }, (_, row) => [row, col])); + } + + const cellsByRegion = Array.from({ length: 9 }, () => [] as Array<[number, number]>); + for (let row = 0; row < 9; row++) { + for (let col = 0; col < 9; col++) { + cellsByRegion[regionMapToCheck[row][col]].push([row, col]); + } + } + + cellsByRegion.forEach(addGroupConflicts); + + return conflictCells; +}; const SudokuGame: React.FC = () => { // 游戏难度 @@ -429,13 +474,25 @@ const SudokuGame: React.FC = () => { for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if (getCellHighlightType(r, c) !== 'none') { - highlighted.add(`${r}-${c}`); + highlighted.add(getCellKey(r, c)); } } } return highlighted; }, [board, selectedCell, config, regionMap]); + const conflictCells = useMemo(() => findConflictCells(board, regionMap), [board, regionMap]); + + const availableNumbers = useMemo(() => { + return board.map((row, rowIndex) => + row.map((cell, colIndex) => ( + cell === 0 + ? DIGITS.filter((num) => isValidInBoard(rowIndex, colIndex, num, board)) + : [] + )) + ); + }, [board, regionMap]); + // 计算剩余未填写的数字数量 const remainingCount = useMemo(() => { let count = 0; @@ -453,11 +510,13 @@ const SudokuGame: React.FC = () => { const isFixed = fixedCells[row][col]; const highlightType = getCellHighlightType(row, col); const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col; - const displayValue = getCellValue(cell); + const isConflicting = conflictCells.has(getCellKey(row, col)); // 基础背景色 - 根据高亮类型设置不同颜色 let backgroundColor = 'white'; - if (isSelected) { + if (isConflicting) { + backgroundColor = '#fff1f0'; // 冲突数字 - 警告红色 + } else if (isSelected) { backgroundColor = '#69b1ff'; // 选中时的深蓝色 } else if (highlightType === 'box') { backgroundColor = '#d9f7be'; // 宫 - 浅绿色 @@ -495,9 +554,12 @@ const SudokuGame: React.FC = () => { justifyContent: 'center', fontSize: `${CELL_FONT_SIZE}px`, cursor: 'pointer', - color: isFixed ? '#333' : '#1890ff', + color: isConflicting ? '#cf1322' : isFixed ? '#333' : '#1890ff', position: 'relative' as const, boxSizing: 'border-box', + boxShadow: isConflicting ? 'inset 0 0 0 3px #ff4d4f' : undefined, + outline: isSelected ? '3px solid #1677ff' : undefined, + outlineOffset: '-6px', }; }; @@ -513,9 +575,25 @@ const SudokuGame: React.FC = () => { justifyContent: 'center', fontSize: `${isDraft ? DRAFT_FONT_SIZE : CELL_FONT_SIZE}px`, lineHeight: 1, + position: 'relative' as const, + zIndex: 1, }; }; + const getAvailableNumbersStyle = (): React.CSSProperties => ({ + position: 'absolute' as const, + top: '4px', + right: '5px', + maxWidth: '46px', + fontSize: '11px', + lineHeight: 1, + color: '#8c8c8c', + textAlign: 'right' as const, + pointerEvents: 'none' as const, + zIndex: 2, + fontVariantNumeric: 'tabular-nums', + }); + return (
{/* 顶部控制和配置区 */} @@ -666,13 +744,19 @@ const SudokuGame: React.FC = () => { row.map((cell, colIndex) => { const displayValue = getCellValue(cell); const cellStyle = getCellStyle(rowIndex, colIndex); + const candidates = availableNumbers[rowIndex][colIndex]; + const isConflicting = conflictCells.has(getCellKey(rowIndex, colIndex)); return (
handleCellClick(rowIndex, colIndex)} style={cellStyle} + title={isConflicting ? '该数字与同一行、列或区域中的数字冲突' : undefined} > + {displayValue === null && candidates.length > 0 && ( + {candidates.join('')} + )} {displayValue !== null ? ( {displayValue} ) : ''}