import React, { useState, useMemo, useEffect } from 'react'; import UndoIcon from '@mui/icons-material/Undo'; import RedoIcon from '@mui/icons-material/Redo'; import { API_BASE_URL } from '../config'; import { GameBoard, Difficulty, GameMode, RegionMap, generatePuzzleForMode, getRegionId, getRegionNeighbors, } from './sudokuEngine'; type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber'; type SudokuRound = { board: GameBoard; regionMap: RegionMap; }; const CELL_SIZE = 80; const CELL_FONT_SIZE = 36; const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => { const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode); return { board: puzzle, regionMap, }; }; const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]); const SudokuGame: React.FC = () => { // 游戏难度 const [difficulty, setDifficulty] = useState('hard'); // 游戏模式:标准或不规则 const [gameMode, setGameMode] = useState('standard'); const [round, setRound] = useState(() => createSudokuRound('hard', 'standard')); const { board, regionMap } = round; const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const setBoard = (nextBoard: GameBoard) => { setRound((currentRound) => ({ ...currentRound, board: nextBoard, })); }; const applyBoardChange = (nextBoard: GameBoard) => { setUndoStack((previousStack) => [...previousStack, cloneGameBoard(board)]); setRedoStack([]); setBoard(nextBoard); }; const [fixedCells, setFixedCells] = useState(() => board.map(row => row.map(cell => cell !== 0)) ); // 选中的格子 [row, col] const [selectedCell, setSelectedCell] = useState<[number, number] | null>(null); // 游戏完成状态 const [isGameComplete, setIsGameComplete] = useState(false); // 完成对话框显示状态 const [showCompleteDialog, setShowCompleteDialog] = useState(false); const [timeElapsed, setTimeElapsed] = useState(0); const [isTimerRunning, setIsTimerRunning] = useState(true); // 配置选项 const [config, setConfig] = useState({ highlightRegion: true, // 突出显示区域(行、列、宫) highlightSameNumbers: true, // 突出显示相同数字 showRemainingCount: true, // 显示剩余数字数量 }); // 检查在位置 (row, col) 放置数字 num 是否有效 const isValid = (row: number, col: number, num: number): boolean => { for (let c = 0; c < 9; c++) { const cell = board[row][c]; if (c !== col && typeof cell === 'number' && cell === num) return false; } for (let r = 0; r < 9; r++) { const cell = board[r][col]; if (r !== row && typeof cell === 'number' && cell === num) return false; } const targetRegion = getRegionId(regionMap, row, col); for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { const cell = board[r][c]; if ((r !== row || c !== col) && getRegionId(regionMap, r, c) === targetRegion && typeof cell === 'number' && cell === num) return false; } } return true; }; // 检查数独是否完成 const isComplete = (): boolean => { for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { const cell = board[r][c]; if (typeof cell !== 'number') return false; if (!isValid(r, c, cell)) return false; } } return true; }; const getCellValue = (cell: number): number | null => { return cell === 0 ? null : cell; }; const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; const saveGameRecord = async (won: boolean, timeSeconds: number) => { try { const token = window.localStorage.getItem('token'); if (!token) { console.log('未登录,不保存数独记录'); return; } const response = await fetch(`${API_BASE_URL}/api/sudoku/record`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ difficulty, timeSeconds, won }) }); if (response.ok) { console.log('数独游戏记录保存成功'); } else { console.warn('保存数独记录失败', response.statusText); } } catch (error) { console.error('保存数独记录失败:', error); } }; const handleNumberInput = (num: number) => { if (!selectedCell || isGameComplete) return; const [row, col] = selectedCell; if (fixedCells[row][col]) return; if (board[row][col] === num) return; const newBoard = board.map(row => [...row]); newBoard[row][col] = num; applyBoardChange(newBoard); // 检查是否完成 setTimeout(() => { if (checkIsComplete(newBoard)) { setIsGameComplete(true); setIsTimerRunning(false); setShowCompleteDialog(true); saveGameRecord(true, timeElapsed); } }, 0); }; const checkIsComplete = (boardToCheck: GameBoard): boolean => { for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { const cell = boardToCheck[r][c]; if (cell === 0) return false; if (!isValidInBoard(r, c, cell, boardToCheck)) return false; } } return true; }; const isValidInBoard = (row: number, col: number, num: number, boardToCheck: GameBoard): boolean => { for (let c = 0; c < 9; c++) { if (c !== col && boardToCheck[row][c] === num) return false; } for (let r = 0; r < 9; r++) { if (r !== row && boardToCheck[r][col] === num) return false; } // 使用 regionMap 检查不规则区域 const targetRegion = getRegionId(regionMap, row, col); for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if ((r !== row || c !== col) && getRegionId(regionMap, r, c) === targetRegion && boardToCheck[r][c] === num) return false; } } return true; }; const handleClear = () => { if (!selectedCell || isGameComplete) return; const [row, col] = selectedCell; if (fixedCells[row][col] || board[row][col] === 0) return; const newBoard = board.map(row => [...row]); newBoard[row][col] = 0; applyBoardChange(newBoard); }; const handleUndo = () => { if (isGameComplete || undoStack.length === 0) return; const previousBoard = undoStack[undoStack.length - 1]; setUndoStack(undoStack.slice(0, -1)); setRedoStack([cloneGameBoard(board), ...redoStack]); setBoard(cloneGameBoard(previousBoard)); }; const handleRedo = () => { if (isGameComplete || redoStack.length === 0) return; const nextBoard = redoStack[0]; setRedoStack(redoStack.slice(1)); setUndoStack([...undoStack, cloneGameBoard(board)]); setBoard(cloneGameBoard(nextBoard)); }; const canUndo = !isGameComplete && undoStack.length > 0; const canRedo = !isGameComplete && redoStack.length > 0; const getHistoryButtonStyle = (enabled: boolean): React.CSSProperties => ({ padding: '10px 16px', fontSize: '16px', cursor: enabled ? 'pointer' : 'not-allowed', backgroundColor: enabled ? '#f0f0f0' : '#f7f7f7', color: enabled ? '#333' : '#aaa', border: 'none', borderRadius: '4px', display: 'inline-flex', alignItems: 'center', gap: '6px', opacity: enabled ? 1 : 0.65, }); const createNewGame = (level: Difficulty, mode: GameMode = gameMode) => { const nextRound = createSudokuRound(level, mode); setRound(nextRound); setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0))); setUndoStack([]); setRedoStack([]); setSelectedCell(null); setIsGameComplete(false); setShowCompleteDialog(false); setTimeElapsed(0); setIsTimerRunning(true); }; const handleNewGame = () => { createNewGame(difficulty); }; const handleDifficultyChange = (level: Difficulty) => { setDifficulty(level); createNewGame(level); }; const handleModeChange = (mode: GameMode) => { setGameMode(mode); createNewGame(difficulty, mode); }; const handleCellClick = (row: number, col: number) => { setSelectedCell([row, col]); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); const isModifierKey = e.ctrlKey || e.metaKey; if (isModifierKey && key === 'z') { e.preventDefault(); if (e.shiftKey) { handleRedo(); } else { handleUndo(); } return; } if (isModifierKey && key === 'y') { e.preventDefault(); handleRedo(); return; } if (!selectedCell) return; const [row, col] = selectedCell; switch (e.key) { case 'ArrowUp': e.preventDefault(); setSelectedCell([Math.max(0, row - 1), col]); break; case 'ArrowDown': e.preventDefault(); setSelectedCell([Math.min(8, row + 1), col]); break; case 'ArrowLeft': e.preventDefault(); setSelectedCell([row, Math.max(0, col - 1)]); break; case 'ArrowRight': e.preventDefault(); setSelectedCell([row, Math.min(8, col + 1)]); break; case 'Backspace': case 'Delete': case ' ': e.preventDefault(); handleClear(); break; case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': e.preventDefault(); handleNumberInput(Number(e.key)); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedCell, board, undoStack, redoStack, isGameComplete, fixedCells, timeElapsed, regionMap, difficulty]); useEffect(() => { if (!isTimerRunning) return; const intervalId = window.setInterval(() => { setTimeElapsed((prev) => prev + 1); }, 1000); return () => window.clearInterval(intervalId); }, [isTimerRunning]); const getCellHighlightType = (row: number, col: number): HighlightType => { if (!selectedCell) return 'none'; const [selRow, selCol] = selectedCell; const cell = board[row][col]; const selectedValue = board[selRow][selCol]; if (config.highlightRegion) { const inSameRow = row === selRow; const inSameCol = col === selCol; const inSameBox = getRegionId(regionMap, row, col) === getRegionId(regionMap, selRow, selCol); if (inSameBox) return 'box'; if (inSameRow) return 'row'; if (inSameCol) return 'col'; } if (config.highlightSameNumbers && selectedValue !== 0) { if (cell === selectedValue) { return 'sameNumber'; } } return 'none'; }; const highlightedCells = useMemo(() => { const highlighted = new Set(); if (!selectedCell) return highlighted; for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if (getCellHighlightType(r, c) !== 'none') { highlighted.add(`${r}-${c}`); } } } return highlighted; }, [board, selectedCell, config, regionMap]); // 计算剩余未填写的数字数量 const remainingCount = useMemo(() => { let count = 0; for (let r = 0; r < 9; r++) { for (let c = 0; c < 9; c++) { if (board[r][c] === 0) count++; } } return count; }, [board]); // 获取单元格样式 - 支持不规则区域的边框显示 const getCellStyle = (row: number, col: number): React.CSSProperties => { const cell = board[row][col]; const isFixed = fixedCells[row][col]; const highlightType = getCellHighlightType(row, col); const isSelected = selectedCell && selectedCell[0] === row && selectedCell[1] === col; const displayValue = getCellValue(cell); // 基础背景色 - 根据高亮类型设置不同颜色 let backgroundColor = 'white'; if (isSelected) { backgroundColor = '#69b1ff'; // 选中时的深蓝色 } else if (highlightType === 'box') { backgroundColor = '#d9f7be'; // 宫 - 浅绿色 } else if (highlightType === 'row') { backgroundColor = '#bae7ff'; // 行 - 浅蓝色 } else if (highlightType === 'col') { backgroundColor = '#ffe58f'; // 列 - 浅黄色 } else if (highlightType === 'sameNumber') { backgroundColor = '#ff4444'; // 相同数字 - 更醒目的红色 } else if (isFixed) { backgroundColor = '#f0f0f0'; // 初始固定格子 } const thickBorder = '4px solid #666'; const thinBorder = '2px solid #bbb'; // 使用 getRegionNeighbors 判断边框粗细,支持不规则区域 const neighbors = getRegionNeighbors(regionMap, row, col); const borderRight = col < 8 ? (neighbors.right ? thickBorder : thinBorder) : thickBorder; const borderBottom = row < 8 ? (neighbors.bottom ? thickBorder : thinBorder) : thickBorder; const borderLeft = col > 0 ? (neighbors.left ? thickBorder : thinBorder) : thickBorder; const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder; return { width: `${CELL_SIZE}px`, height: `${CELL_SIZE}px`, backgroundColor, borderRight, borderBottom, borderLeft, borderTop, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: `${CELL_FONT_SIZE}px`, cursor: 'pointer', color: isFixed ? '#333' : '#1890ff', position: 'relative' as const, boxSizing: 'border-box', }; }; return (
{/* 顶部控制和配置区 */}
难度: {(['easy', 'medium', 'hard'] as const).map((d) => ( ))}
模式: {(['standard', 'irregular'] as const).map((m) => ( ))}
{/* 配置选项区 */}
{/* 数独棋盘 */}
{board.map((row, rowIndex) => row.map((cell, colIndex) => { const displayValue = getCellValue(cell); const cellStyle = getCellStyle(rowIndex, colIndex); return (
handleCellClick(rowIndex, colIndex)} style={cellStyle} > {displayValue !== null ? displayValue : ''}
); }) )}
{selectedCell ? '按键盘 1-9 输入数字' : '选择一个格子后输入数字'} 用时:{formatTime(timeElapsed)} {config.showRemainingCount && ( 剩余:{remainingCount} )}
{/* 快捷键提示 */}
快捷键: 1-9 输入数字 | 空格/Backspace 清除 | Ctrl/Cmd+Z 撤销 | Ctrl/Cmd+Y 重做 | ↑↓←→ 移动选择
{/* 完成成功对话框 */} {showCompleteDialog && (
🎉

恭喜!完成成功

你已经正确完成了这个数独谜题! 所有格子已锁定,无法再修改。

)}
); }; export default SudokuGame;