Files
d1kt/src/components/SudokuGame.tsx
2026-05-15 22:00:36 +08:00

724 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Difficulty>('hard');
// 游戏模式:标准或不规则
const [gameMode, setGameMode] = useState<GameMode>('standard');
const [round, setRound] = useState<SudokuRound>(() => createSudokuRound('hard', 'standard'));
const { board, regionMap } = round;
const [undoStack, setUndoStack] = useState<GameBoard[]>([]);
const [redoStack, setRedoStack] = useState<GameBoard[]>([]);
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<boolean[][]>(() =>
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<string>();
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 (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '20px', padding: '20px' }}>
{/* 顶部控制和配置区 */}
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={handleNewGame}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
<button
onClick={handleClear}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#ff4d4f',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
<button
onClick={handleUndo}
disabled={!canUndo}
aria-label="撤销"
title="撤销 (Ctrl/Cmd+Z)"
style={getHistoryButtonStyle(canUndo)}
>
<UndoIcon fontSize="small" />
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
aria-label="重做"
title="重做 (Ctrl/Cmd+Y)"
style={getHistoryButtonStyle(canRedo)}
>
<RedoIcon fontSize="small" />
</button>
</div>
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
<span>:</span>
{(['easy', 'medium', 'hard'] as const).map((d) => (
<button
key={d}
onClick={() => handleDifficultyChange(d)}
style={{
padding: '5px 10px',
fontSize: '14px',
cursor: 'pointer',
backgroundColor: difficulty === d ? '#1890ff' : '#f0f0f0',
color: difficulty === d ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
}}
>
{d === 'easy' ? '简单' : d === 'medium' ? '中等' : '困难'}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
<span>:</span>
{(['standard', 'irregular'] as const).map((m) => (
<button
key={m}
onClick={() => handleModeChange(m)}
style={{
padding: '5px 10px',
fontSize: '14px',
cursor: 'pointer',
backgroundColor: gameMode === m ? '#52c41a' : '#f0f0f0',
color: gameMode === m ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
}}
>
{m === 'standard' ? '标准' : '不规则'}
</button>
))}
</div>
</div>
{/* 配置选项区 */}
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', justifyContent: 'center', padding: '10px', backgroundColor: '#fafafa', borderRadius: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.highlightRegion}
onChange={(e) => setConfig({ ...config, highlightRegion: e.target.checked })}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.highlightSameNumbers}
onChange={(e) => setConfig({ ...config, highlightSameNumbers: e.target.checked })}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={config.showRemainingCount}
onChange={(e) => setConfig({ ...config, showRemainingCount: e.target.checked })}
/>
</label>
</div>
{/* 数独棋盘 */}
<div style={{ width: '100%', overflowX: 'auto', display: 'flex', justifyContent: 'center', paddingBottom: '4px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(9, ${CELL_SIZE}px)`,
gridTemplateRows: `repeat(9, ${CELL_SIZE}px)`,
border: '4px solid #333',
backgroundColor: '#fff',
flex: '0 0 auto',
}}
>
{board.map((row, rowIndex) =>
row.map((cell, colIndex) => {
const displayValue = getCellValue(cell);
const cellStyle = getCellStyle(rowIndex, colIndex);
return (
<div
key={`${rowIndex}-${colIndex}`}
onClick={() => handleCellClick(rowIndex, colIndex)}
style={cellStyle}
>
{displayValue !== null ? displayValue : ''}
</div>
);
})
)}
</div>
</div>
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ padding: '15px 25px', backgroundColor: '#f5f5f5', borderRadius: '8px', textAlign: 'center' }}>
<span style={{ fontSize: '14px', color: '#666', display: 'block', marginBottom: '8px' }}>
{selectedCell ? '按键盘 1-9 输入数字' : '选择一个格子后输入数字'}
</span>
<span style={{ fontSize: '16px', color: '#666', display: 'block', marginBottom: '8px' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{formatTime(timeElapsed)}</strong>
</span>
{config.showRemainingCount && (
<span style={{ fontSize: '16px', color: '#666' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{remainingCount}</strong>
</span>
)}
</div>
</div>
{/* 快捷键提示 */}
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
<strong>:</strong> 1-9 | /Backspace | Ctrl/Cmd+Z | Ctrl/Cmd+Y |
</div>
{/* 完成成功对话框 */}
{showCompleteDialog && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}}>
<div style={{
fontSize: '48px',
marginBottom: '20px',
}}>
🎉
</div>
<h2 style={{
margin: '0 0 20px 0',
color: '#1890ff',
fontSize: '24px',
}}>
</h2>
<p style={{
margin: '0 0 30px 0',
color: '#666',
fontSize: '16px',
lineHeight: '1.6',
}}>
</p>
<button
onClick={() => setShowCompleteDialog(false)}
style={{
padding: '12px 32px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#1890ff',
color: 'white',
border: 'none',
borderRadius: '4px',
marginRight: '10px',
}}
>
</button>
<button
onClick={handleNewGame}
style={{
padding: '12px 32px',
fontSize: '16px',
cursor: 'pointer',
backgroundColor: '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
</button>
</div>
</div>
)}
</div>
);
};
export default SudokuGame;