add undo/redo feather

This commit is contained in:
2026-05-15 22:00:36 +08:00
parent e54b9a0b7a
commit 8af733a274

View File

@@ -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>
{/* 完成成功对话框 */} {/* 完成成功对话框 */}