add undo/redo feather
This commit is contained in:
@@ -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,31 +589,34 @@ const SudokuGame: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 数独棋盘 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(9, 40px)',
|
||||
gridTemplateRows: 'repeat(9, 40px)',
|
||||
border: '2px solid #333',
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
{board.map((row, rowIndex) =>
|
||||
row.map((cell, colIndex) => {
|
||||
const displayValue = getCellValue(cell);
|
||||
const cellStyle = getCellStyle(rowIndex, colIndex);
|
||||
<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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
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' }}>
|
||||
@@ -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>
|
||||
|
||||
{/* 完成成功对话框 */}
|
||||
|
||||
Reference in New Issue
Block a user