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 UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import { API_BASE_URL } from '../config';
import {
GameBoard,
@@ -16,6 +18,9 @@ type SudokuRound = {
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 {
@@ -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 [difficulty, setDifficulty] = useState<Difficulty>('hard');
@@ -33,6 +40,8 @@ const SudokuGame: React.FC = () => {
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) => ({
@@ -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[][]>(() =>
board.map(row => row.map(cell => cell !== 0))
);
@@ -144,10 +159,11 @@ const SudokuGame: React.FC = () => {
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;
setBoard(newBoard);
applyBoardChange(newBoard);
// 检查是否完成
setTimeout(() => {
@@ -191,15 +207,54 @@ const SudokuGame: React.FC = () => {
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;
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 nextRound = createSudokuRound(level, mode);
setRound(nextRound);
setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0)));
setUndoStack([]);
setRedoStack([]);
setSelectedCell(null);
setIsGameComplete(false);
setShowCompleteDialog(false);
@@ -227,6 +282,25 @@ const SudokuGame: React.FC = () => {
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;
@@ -271,7 +345,7 @@ const SudokuGame: React.FC = () => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedCell, board]);
}, [selectedCell, board, undoStack, redoStack, isGameComplete, fixedCells, timeElapsed, regionMap, difficulty]);
useEffect(() => {
if (!isTimerRunning) return;
@@ -358,8 +432,8 @@ const SudokuGame: React.FC = () => {
backgroundColor = '#f0f0f0'; // 初始固定格子
}
const thickBorder = '2px solid #666';
const thinBorder = '1px solid #bbb';
const thickBorder = '4px solid #666';
const thinBorder = '2px solid #bbb';
// 使用 getRegionNeighbors 判断边框粗细,支持不规则区域
const neighbors = getRegionNeighbors(regionMap, row, col);
@@ -370,8 +444,8 @@ const SudokuGame: React.FC = () => {
const borderTop = row > 0 ? (neighbors.top ? thickBorder : thinBorder) : thickBorder;
return {
width: '40px',
height: '40px',
width: `${CELL_SIZE}px`,
height: `${CELL_SIZE}px`,
backgroundColor,
borderRight,
borderBottom,
@@ -380,10 +454,11 @@ const SudokuGame: React.FC = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
fontSize: `${CELL_FONT_SIZE}px`,
cursor: 'pointer',
color: isFixed ? '#333' : '#1890ff',
position: 'relative' as const,
boxSizing: 'border-box',
};
};
@@ -420,6 +495,26 @@ const SudokuGame: React.FC = () => {
>
</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' }}>
@@ -494,13 +589,15 @@ const SudokuGame: React.FC = () => {
</div>
{/* 数独棋盘 */}
<div style={{ width: '100%', overflowX: 'auto', display: 'flex', justifyContent: 'center', paddingBottom: '4px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(9, 40px)',
gridTemplateRows: 'repeat(9, 40px)',
border: '2px solid #333',
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) =>
@@ -520,6 +617,7 @@ const SudokuGame: React.FC = () => {
})
)}
</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' }}>
@@ -539,7 +637,7 @@ const SudokuGame: React.FC = () => {
{/* 快捷键提示 */}
<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>
{/* 完成成功对话框 */}