add undo/redo feather
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useMemo, useEffect } from 'react';
|
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 { API_BASE_URL } from '../config';
|
||||||
import {
|
import {
|
||||||
GameBoard,
|
GameBoard,
|
||||||
@@ -16,6 +18,9 @@ type SudokuRound = {
|
|||||||
regionMap: RegionMap;
|
regionMap: RegionMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CELL_SIZE = 80;
|
||||||
|
const CELL_FONT_SIZE = 36;
|
||||||
|
|
||||||
const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => {
|
const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound => {
|
||||||
const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode);
|
const { puzzle, regionMap } = generatePuzzleForMode(difficulty, mode);
|
||||||
return {
|
return {
|
||||||
@@ -24,6 +29,8 @@ const createSudokuRound = (difficulty: Difficulty, mode: GameMode): SudokuRound
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cloneGameBoard = (board: GameBoard): GameBoard => board.map(row => [...row]);
|
||||||
|
|
||||||
const SudokuGame: React.FC = () => {
|
const SudokuGame: React.FC = () => {
|
||||||
// 游戏难度
|
// 游戏难度
|
||||||
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
|
const [difficulty, setDifficulty] = useState<Difficulty>('hard');
|
||||||
@@ -33,6 +40,8 @@ const SudokuGame: React.FC = () => {
|
|||||||
|
|
||||||
const [round, setRound] = useState<SudokuRound>(() => createSudokuRound('hard', 'standard'));
|
const [round, setRound] = useState<SudokuRound>(() => createSudokuRound('hard', 'standard'));
|
||||||
const { board, regionMap } = round;
|
const { board, regionMap } = round;
|
||||||
|
const [undoStack, setUndoStack] = useState<GameBoard[]>([]);
|
||||||
|
const [redoStack, setRedoStack] = useState<GameBoard[]>([]);
|
||||||
|
|
||||||
const setBoard = (nextBoard: GameBoard) => {
|
const setBoard = (nextBoard: GameBoard) => {
|
||||||
setRound((currentRound) => ({
|
setRound((currentRound) => ({
|
||||||
@@ -41,6 +50,12 @@ const SudokuGame: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyBoardChange = (nextBoard: GameBoard) => {
|
||||||
|
setUndoStack((previousStack) => [...previousStack, cloneGameBoard(board)]);
|
||||||
|
setRedoStack([]);
|
||||||
|
setBoard(nextBoard);
|
||||||
|
};
|
||||||
|
|
||||||
const [fixedCells, setFixedCells] = useState<boolean[][]>(() =>
|
const [fixedCells, setFixedCells] = useState<boolean[][]>(() =>
|
||||||
board.map(row => row.map(cell => cell !== 0))
|
board.map(row => row.map(cell => cell !== 0))
|
||||||
);
|
);
|
||||||
@@ -144,10 +159,11 @@ const SudokuGame: React.FC = () => {
|
|||||||
if (!selectedCell || isGameComplete) return;
|
if (!selectedCell || isGameComplete) return;
|
||||||
const [row, col] = selectedCell;
|
const [row, col] = selectedCell;
|
||||||
if (fixedCells[row][col]) return;
|
if (fixedCells[row][col]) return;
|
||||||
|
if (board[row][col] === num) return;
|
||||||
|
|
||||||
const newBoard = board.map(row => [...row]);
|
const newBoard = board.map(row => [...row]);
|
||||||
newBoard[row][col] = num;
|
newBoard[row][col] = num;
|
||||||
setBoard(newBoard);
|
applyBoardChange(newBoard);
|
||||||
|
|
||||||
// 检查是否完成
|
// 检查是否完成
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -191,15 +207,54 @@ const SudokuGame: React.FC = () => {
|
|||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
if (!selectedCell || isGameComplete) return;
|
if (!selectedCell || isGameComplete) return;
|
||||||
const [row, col] = selectedCell;
|
const [row, col] = selectedCell;
|
||||||
|
if (fixedCells[row][col] || board[row][col] === 0) return;
|
||||||
|
|
||||||
const newBoard = board.map(row => [...row]);
|
const newBoard = board.map(row => [...row]);
|
||||||
newBoard[row][col] = 0;
|
newBoard[row][col] = 0;
|
||||||
setBoard(newBoard);
|
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 createNewGame = (level: Difficulty, mode: GameMode = gameMode) => {
|
||||||
const nextRound = createSudokuRound(level, mode);
|
const nextRound = createSudokuRound(level, mode);
|
||||||
setRound(nextRound);
|
setRound(nextRound);
|
||||||
setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0)));
|
setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0)));
|
||||||
|
setUndoStack([]);
|
||||||
|
setRedoStack([]);
|
||||||
setSelectedCell(null);
|
setSelectedCell(null);
|
||||||
setIsGameComplete(false);
|
setIsGameComplete(false);
|
||||||
setShowCompleteDialog(false);
|
setShowCompleteDialog(false);
|
||||||
@@ -227,6 +282,25 @@ const SudokuGame: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
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;
|
if (!selectedCell) return;
|
||||||
|
|
||||||
const [row, col] = selectedCell;
|
const [row, col] = selectedCell;
|
||||||
@@ -271,7 +345,7 @@ const SudokuGame: React.FC = () => {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [selectedCell, board]);
|
}, [selectedCell, board, undoStack, redoStack, isGameComplete, fixedCells, timeElapsed, regionMap, difficulty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTimerRunning) return;
|
if (!isTimerRunning) return;
|
||||||
@@ -358,8 +432,8 @@ const SudokuGame: React.FC = () => {
|
|||||||
backgroundColor = '#f0f0f0'; // 初始固定格子
|
backgroundColor = '#f0f0f0'; // 初始固定格子
|
||||||
}
|
}
|
||||||
|
|
||||||
const thickBorder = '2px solid #666';
|
const thickBorder = '4px solid #666';
|
||||||
const thinBorder = '1px solid #bbb';
|
const thinBorder = '2px solid #bbb';
|
||||||
|
|
||||||
// 使用 getRegionNeighbors 判断边框粗细,支持不规则区域
|
// 使用 getRegionNeighbors 判断边框粗细,支持不规则区域
|
||||||
const neighbors = getRegionNeighbors(regionMap, row, col);
|
const neighbors = getRegionNeighbors(regionMap, row, col);
|
||||||
@@ -370,8 +444,8 @@ const SudokuGame: React.FC = () => {
|
|||||||
const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder;
|
const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: '40px',
|
width: `${CELL_SIZE}px`,
|
||||||
height: '40px',
|
height: `${CELL_SIZE}px`,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
borderRight,
|
borderRight,
|
||||||
borderBottom,
|
borderBottom,
|
||||||
@@ -380,10 +454,11 @@ const SudokuGame: React.FC = () => {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '18px',
|
fontSize: `${CELL_FONT_SIZE}px`,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: isFixed ? '#333' : '#1890ff',
|
color: isFixed ? '#333' : '#1890ff',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -420,6 +495,26 @@ const SudokuGame: React.FC = () => {
|
|||||||
>
|
>
|
||||||
清除
|
清除
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '5px', alignItems: 'center' }}>
|
||||||
@@ -494,31 +589,34 @@ const SudokuGame: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 数独棋盘 */}
|
{/* 数独棋盘 */}
|
||||||
<div
|
<div style={{ width: '100%', overflowX: 'auto', display: 'flex', justifyContent: 'center', paddingBottom: '4px' }}>
|
||||||
style={{
|
<div
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(9, 40px)',
|
display: 'grid',
|
||||||
gridTemplateRows: 'repeat(9, 40px)',
|
gridTemplateColumns: `repeat(9, ${CELL_SIZE}px)`,
|
||||||
border: '2px solid #333',
|
gridTemplateRows: `repeat(9, ${CELL_SIZE}px)`,
|
||||||
backgroundColor: '#fff',
|
border: '4px solid #333',
|
||||||
}}
|
backgroundColor: '#fff',
|
||||||
>
|
flex: '0 0 auto',
|
||||||
{board.map((row, rowIndex) =>
|
}}
|
||||||
row.map((cell, colIndex) => {
|
>
|
||||||
const displayValue = getCellValue(cell);
|
{board.map((row, rowIndex) =>
|
||||||
const cellStyle = getCellStyle(rowIndex, colIndex);
|
row.map((cell, colIndex) => {
|
||||||
|
const displayValue = getCellValue(cell);
|
||||||
|
const cellStyle = getCellStyle(rowIndex, colIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${rowIndex}-${colIndex}`}
|
key={`${rowIndex}-${colIndex}`}
|
||||||
onClick={() => handleCellClick(rowIndex, colIndex)}
|
onClick={() => handleCellClick(rowIndex, colIndex)}
|
||||||
style={cellStyle}
|
style={cellStyle}
|
||||||
>
|
>
|
||||||
{displayValue !== null ? displayValue : ''}
|
{displayValue !== null ? displayValue : ''}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '30px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
@@ -539,7 +637,7 @@ const SudokuGame: React.FC = () => {
|
|||||||
|
|
||||||
{/* 快捷键提示 */}
|
{/* 快捷键提示 */}
|
||||||
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
|
<div style={{ marginTop: '20px', padding: '10px', backgroundColor: '#f9f9f9', borderRadius: '4px', fontSize: '12px', color: '#666' }}>
|
||||||
<strong>快捷键:</strong> 1-9 输入数字 | 空格/Backspace 清除 | ↑↓←→ 移动选择
|
<strong>快捷键:</strong> 1-9 输入数字 | 空格/Backspace 清除 | Ctrl/Cmd+Z 撤销 | Ctrl/Cmd+Y 重做 | ↑↓←→ 移动选择
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 完成成功对话框 */}
|
{/* 完成成功对话框 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user