Compare commits

..

4 Commits

Author SHA1 Message Date
5567bf572f sudoku leader board fixed 2026-05-15 23:21:31 +08:00
f1f906be55 added draft mode 2026-05-15 22:21:23 +08:00
8af733a274 add undo/redo feather 2026-05-15 22:00:36 +08:00
e54b9a0b7a radomirregular region 2026-05-15 21:53:08 +08:00
6 changed files with 634 additions and 88 deletions

View File

@@ -7,6 +7,7 @@ export interface ISudokuRecord extends Document {
username: string; username: string;
fullname: string; fullname: string;
difficulty: SudokuDifficulty; difficulty: SudokuDifficulty;
gameMode: 'standard' | 'irregular';
timeSeconds: number; timeSeconds: number;
won: boolean; won: boolean;
createdAt: Date; createdAt: Date;
@@ -17,6 +18,7 @@ export interface ISudokuLeaderboardRecord {
userId: mongoose.Types.ObjectId; userId: mongoose.Types.ObjectId;
username: string; username: string;
fullname: string; fullname: string;
gameMode: 'standard' | 'irregular';
bestTime: number; bestTime: number;
totalGames: number; totalGames: number;
wonGames: number; wonGames: number;
@@ -43,6 +45,12 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
enum: ['easy', 'medium', 'hard'], enum: ['easy', 'medium', 'hard'],
required: true required: true
}, },
gameMode: {
type: String,
enum: ['standard', 'irregular'],
required: true,
default: 'standard'
},
timeSeconds: { timeSeconds: {
type: Number, type: Number,
required: true, required: true,
@@ -56,25 +64,41 @@ const sudokuRecordSchema = new Schema<ISudokuRecord>({
timestamps: true timestamps: true
}); });
sudokuRecordSchema.index({ userId: 1, difficulty: 1 }); sudokuRecordSchema.index({ userId: 1, difficulty: 1, gameMode: 1 });
sudokuRecordSchema.index({ difficulty: 1, won: 1, timeSeconds: 1 }); sudokuRecordSchema.index({ difficulty: 1, won: 1, gameMode: 1, timeSeconds: 1 });
sudokuRecordSchema.statics.getLeaderboard = function( sudokuRecordSchema.statics.getLeaderboard = function(
difficulty: SudokuDifficulty, difficulty: SudokuDifficulty,
gameMode: 'standard' | 'irregular' | 'all',
skipCount: number, skipCount: number,
pageSize: number pageSize: number
): mongoose.Aggregate<ISudokuLeaderboardRecord[]> { ): mongoose.Aggregate<ISudokuLeaderboardRecord[]> {
const match: any = {
difficulty
};
if (gameMode === 'standard') {
match.$or = [
{ gameMode: 'standard' },
{ gameMode: { $exists: false } }
];
} else if (gameMode === 'irregular') {
match.gameMode = 'irregular';
}
return this.aggregate([ return this.aggregate([
{ {
$match: { $match: match
difficulty
}
}, },
{ {
$group: { $group: {
_id: '$userId', _id: {
userId: '$userId',
gameMode: { $ifNull: ['$gameMode', 'standard'] }
},
username: { $first: '$username' }, username: { $first: '$username' },
fullname: { $first: '$fullname' }, fullname: { $first: '$fullname' },
gameMode: { $first: { $ifNull: ['$gameMode', 'standard'] } },
bestTime: { bestTime: {
$min: { $min: {
$cond: [{ $eq: ['$won', true] }, '$timeSeconds', null] $cond: [{ $eq: ['$won', true] }, '$timeSeconds', null]
@@ -92,7 +116,7 @@ sudokuRecordSchema.statics.getLeaderboard = function(
}, },
{ {
$addFields: { $addFields: {
userId: '$_id', userId: '$_id.userId',
winRate: { winRate: {
$multiply: [ $multiply: [
{ $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] }, { $cond: [{ $eq: ['$totalGames', 0] }, 0, { $divide: ['$wonGames', '$totalGames'] }] },

View File

@@ -51,34 +51,71 @@ router.post('/record', auth, async (req: Request, res: Response) => {
router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => { router.get('/leaderboard/:difficulty', async (req: Request, res: Response) => {
try { try {
const { difficulty } = req.params; const { difficulty } = req.params;
const { page = '1', limit = '10' } = req.query; const { page = '1', limit = '10', mode = 'all' } = req.query;
const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard']; const validDifficulties: SudokuDifficulty[] = ['easy', 'medium', 'hard'];
const validModes = ['all', 'standard', 'irregular'];
if (!validDifficulties.includes(difficulty as SudokuDifficulty)) { if (!validDifficulties.includes(difficulty as SudokuDifficulty)) {
return res.status(400).json({ error: '无效的难度级别' }); return res.status(400).json({ error: '无效的难度级别' });
} }
if (!validModes.includes(mode as string)) {
return res.status(400).json({ error: '无效的游戏模式' });
}
const pageNum = parseInt(page as string, 10); const pageNum = parseInt(page as string, 10);
const pageSize = parseInt(limit as string, 10); const pageSize = parseInt(limit as string, 10);
const skipCount = (pageNum - 1) * pageSize; const skipCount = (pageNum - 1) * pageSize;
const gameMode = mode as 'standard' | 'irregular' | 'all';
const records = await SudokuRecord.getLeaderboard( const records = await SudokuRecord.getLeaderboard(
difficulty as SudokuDifficulty, difficulty as SudokuDifficulty,
gameMode,
skipCount, skipCount,
pageSize pageSize
); );
const totalUsers = await SudokuRecord.distinct('userId', { const totalMatch: any = {
difficulty, difficulty,
won: true won: true
}); };
if (gameMode === 'standard') {
totalMatch.$or = [
{ gameMode: 'standard' },
{ gameMode: { $exists: false } }
];
} else if (gameMode === 'irregular') {
totalMatch.gameMode = 'irregular';
}
const totalCountResult = await SudokuRecord.aggregate([
{
$match: totalMatch
},
{
$group: {
_id: {
userId: '$userId',
gameMode: { $ifNull: ['$gameMode', 'standard'] }
}
}
},
{
$count: 'count'
}
]);
const total = totalCountResult[0]?.count || 0;
res.json({ res.json({
records, records,
total: totalUsers.length, total,
currentPage: pageNum, currentPage: pageNum,
totalPages: Math.ceil(totalUsers.length / pageSize), totalPages: Math.ceil(total / pageSize),
difficulty difficulty,
mode: gameMode
}); });
} catch (error) { } catch (error) {
console.error('获取数独排行榜失败:', error); console.error('获取数独排行榜失败:', error);

View File

@@ -1,17 +1,44 @@
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,
Difficulty, Difficulty,
GameMode, GameMode,
STANDARD_REGION_MAP, RegionMap,
generatePuzzle, generatePuzzleForMode,
getRegionId, getRegionId,
getRegionNeighbors, getRegionNeighbors,
getRegionMapByMode,
} from './sudokuEngine'; } from './sudokuEngine';
type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber'; type HighlightType = 'none' | 'row' | 'col' | 'box' | 'sameNumber';
type SudokuRound = {
board: GameBoard;
regionMap: RegionMap;
};
type DraftCells = boolean[][];
type BoardSnapshot = {
board: GameBoard;
draftCells: DraftCells;
};
const CELL_SIZE = 80;
const CELL_FONT_SIZE = 36;
const DRAFT_SIZE = CELL_SIZE / 2;
const DRAFT_FONT_SIZE = CELL_FONT_SIZE / 2;
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 cloneDraftCells = (draftCells: DraftCells): DraftCells => draftCells.map(row => [...row]);
const createEmptyDraftCells = (): DraftCells => Array.from({ length: 9 }, () => Array(9).fill(false));
const SudokuGame: React.FC = () => { const SudokuGame: React.FC = () => {
// 游戏难度 // 游戏难度
@@ -20,10 +47,32 @@ const SudokuGame: React.FC = () => {
// 游戏模式:标准或不规则 // 游戏模式:标准或不规则
const [gameMode, setGameMode] = useState<GameMode>('standard'); const [gameMode, setGameMode] = useState<GameMode>('standard');
// 使用 useMemo 确保 regionMap 随 gameMode 变化 const [round, setRound] = useState<SudokuRound>(() => createSudokuRound('hard', 'standard'));
const regionMap = useMemo(() => getRegionMapByMode(gameMode), [gameMode]); const { board, regionMap } = round;
const [draftCells, setDraftCells] = useState<DraftCells>(() => createEmptyDraftCells());
const [draftMode, setDraftMode] = useState(false);
const [undoStack, setUndoStack] = useState<BoardSnapshot[]>([]);
const [redoStack, setRedoStack] = useState<BoardSnapshot[]>([]);
const [board, setBoard] = useState<GameBoard>(() => generatePuzzle('hard', STANDARD_REGION_MAP)); const setBoard = (nextBoard: GameBoard) => {
setRound((currentRound) => ({
...currentRound,
board: nextBoard,
}));
};
const applyBoardChange = (nextBoard: GameBoard, nextDraftCells: DraftCells) => {
setUndoStack((previousStack) => [
...previousStack,
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
]);
setRedoStack([]);
setBoard(nextBoard);
setDraftCells(nextDraftCells);
};
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))
@@ -109,6 +158,7 @@ const SudokuGame: React.FC = () => {
}, },
body: JSON.stringify({ body: JSON.stringify({
difficulty, difficulty,
gameMode,
timeSeconds, timeSeconds,
won won
}) })
@@ -128,14 +178,18 @@ 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 && draftCells[row][col] === draftMode) return;
const newBoard = board.map(row => [...row]); const newBoard = board.map(row => [...row]);
const newDraftCells = cloneDraftCells(draftCells);
newBoard[row][col] = num; newBoard[row][col] = num;
setBoard(newBoard); newDraftCells[row][col] = draftMode;
applyBoardChange(newBoard, newDraftCells);
// 检查是否完成 // 检查是否完成
setTimeout(() => { setTimeout(() => {
if (checkIsComplete(newBoard)) { if (checkIsComplete(newBoard)) {
setDraftCells(createEmptyDraftCells());
setIsGameComplete(true); setIsGameComplete(true);
setIsTimerRunning(false); setIsTimerRunning(false);
setShowCompleteDialog(true); setShowCompleteDialog(true);
@@ -175,16 +229,71 @@ 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]);
const newDraftCells = cloneDraftCells(draftCells);
newBoard[row][col] = 0; newBoard[row][col] = 0;
setBoard(newBoard); newDraftCells[row][col] = false;
applyBoardChange(newBoard, newDraftCells);
}; };
const handleUndo = () => {
if (isGameComplete || undoStack.length === 0) return;
const previousSnapshot = undoStack[undoStack.length - 1];
setUndoStack(undoStack.slice(0, -1));
setRedoStack([
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
...redoStack,
]);
setBoard(cloneGameBoard(previousSnapshot.board));
setDraftCells(cloneDraftCells(previousSnapshot.draftCells));
};
const handleRedo = () => {
if (isGameComplete || redoStack.length === 0) return;
const nextSnapshot = redoStack[0];
setRedoStack(redoStack.slice(1));
setUndoStack([
...undoStack,
{
board: cloneGameBoard(board),
draftCells: cloneDraftCells(draftCells),
},
]);
setBoard(cloneGameBoard(nextSnapshot.board));
setDraftCells(cloneDraftCells(nextSnapshot.draftCells));
};
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 nextRegionMap = getRegionMapByMode(mode); const nextRound = createSudokuRound(level, mode);
const newPuzzle = generatePuzzle(level, nextRegionMap); setRound(nextRound);
setBoard(newPuzzle); setFixedCells(nextRound.board.map(row => row.map(cell => cell !== 0)));
setFixedCells(newPuzzle.map(row => row.map(cell => cell !== 0))); setDraftCells(createEmptyDraftCells());
setUndoStack([]);
setRedoStack([]);
setSelectedCell(null); setSelectedCell(null);
setIsGameComplete(false); setIsGameComplete(false);
setShowCompleteDialog(false); setShowCompleteDialog(false);
@@ -212,6 +321,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;
@@ -256,7 +384,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, draftCells, draftMode, undoStack, redoStack, isGameComplete, fixedCells, timeElapsed, regionMap, difficulty]);
useEffect(() => { useEffect(() => {
if (!isTimerRunning) return; if (!isTimerRunning) return;
@@ -306,7 +434,7 @@ const SudokuGame: React.FC = () => {
} }
} }
return highlighted; return highlighted;
}, [board, selectedCell, config]); }, [board, selectedCell, config, regionMap]);
// 计算剩余未填写的数字数量 // 计算剩余未填写的数字数量
const remainingCount = useMemo(() => { const remainingCount = useMemo(() => {
@@ -343,8 +471,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);
@@ -355,8 +483,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,
@@ -365,10 +493,26 @@ 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',
};
};
const getCellValueStyle = (row: number, col: number): React.CSSProperties => {
const isDraft = draftCells[row][col] && !fixedCells[row][col];
const size = isDraft ? DRAFT_SIZE : CELL_SIZE;
return {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: `${isDraft ? DRAFT_FONT_SIZE : CELL_FONT_SIZE}px`,
lineHeight: 1,
}; };
}; };
@@ -405,6 +549,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' }}>
@@ -476,40 +640,53 @@ const SudokuGame: React.FC = () => {
/> />
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<input
type="checkbox"
checked={draftMode}
onChange={(e) => setDraftMode(e.target.checked)}
/>
稿
</label>
</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 ? (
</div> <span style={getCellValueStyle(rowIndex, colIndex)}>{displayValue}</span>
); ) : ''}
}) </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' }}>
<div style={{ padding: '15px 25px', backgroundColor: '#f5f5f5', borderRadius: '8px', textAlign: 'center' }}> <div style={{ padding: '15px 25px', backgroundColor: '#f5f5f5', borderRadius: '8px', textAlign: 'center' }}>
<span style={{ fontSize: '14px', color: '#666', display: 'block', marginBottom: '8px' }}> <span style={{ fontSize: '14px', color: '#666', display: 'block', marginBottom: '8px' }}>
{selectedCell ? '按键盘 1-9 输入数字' : '选择一个格子后输入数字'} {selectedCell ? (draftMode ? '草稿模式:按键盘 1-9 试填数字' : '按键盘 1-9 输入数字') : '选择一个格子后输入数字'}
</span> </span>
<span style={{ fontSize: '16px', color: '#666', display: 'block', marginBottom: '8px' }}> <span style={{ fontSize: '16px', color: '#666', display: 'block', marginBottom: '8px' }}>
<strong style={{ color: '#1890ff', fontSize: '20px' }}>{formatTime(timeElapsed)}</strong> <strong style={{ color: '#1890ff', fontSize: '20px' }}>{formatTime(timeElapsed)}</strong>
@@ -524,7 +701,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>
{/* 完成成功对话框 */} {/* 完成成功对话框 */}

View File

@@ -19,11 +19,13 @@ import EmojiEventsIcon from '@mui/icons-material/EmojiEvents';
import { API_BASE_URL } from '../config'; import { API_BASE_URL } from '../config';
type Difficulty = 'easy' | 'medium' | 'hard'; type Difficulty = 'easy' | 'medium' | 'hard';
type GameMode = 'all' | 'standard' | 'irregular';
interface LeaderboardRecord { interface LeaderboardRecord {
userId: string; userId: string;
username: string; username: string;
fullname: string; fullname: string;
gameMode: 'standard' | 'irregular';
bestTime: number; bestTime: number;
totalGames: number; totalGames: number;
wonGames: number; wonGames: number;
@@ -47,6 +49,7 @@ const DIFFICULTY_LABELS: Record<Difficulty, string> = {
const SudokuLeaderboard: React.FC = () => { const SudokuLeaderboard: React.FC = () => {
const [difficulty, setDifficulty] = useState<Difficulty>('hard'); const [difficulty, setDifficulty] = useState<Difficulty>('hard');
const [gameModeFilter, setGameModeFilter] = useState<GameMode>('all');
const [records, setRecords] = useState<LeaderboardRecord[]>([]); const [records, setRecords] = useState<LeaderboardRecord[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
@@ -55,14 +58,14 @@ const SudokuLeaderboard: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchLeaderboard(); fetchLeaderboard();
}, [difficulty, page]); }, [difficulty, page, gameModeFilter]);
const fetchLeaderboard = async () => { const fetchLeaderboard = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE_URL}/api/sudoku/leaderboard/${difficulty}?page=${page}&limit=10` `${API_BASE_URL}/api/sudoku/leaderboard/${difficulty}?page=${page}&limit=10&mode=${gameModeFilter}`
); );
if (!response.ok) { if (!response.ok) {
@@ -85,6 +88,11 @@ const SudokuLeaderboard: React.FC = () => {
setPage(1); setPage(1);
}; };
const handleGameModeChange = (_event: React.SyntheticEvent, newValue: GameMode) => {
setGameModeFilter(newValue);
setPage(1);
};
const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => { const handlePageChange = (_event: React.ChangeEvent<unknown>, value: number) => {
setPage(value); setPage(value);
}; };
@@ -139,12 +147,27 @@ const SudokuLeaderboard: React.FC = () => {
</Tabs> </Tabs>
</Box> </Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs
value={gameModeFilter}
onChange={handleGameModeChange}
centered
textColor="primary"
indicatorColor="primary"
>
<Tab value="all" label="全部" />
<Tab value="standard" label="标准" />
<Tab value="irregular" label="不规则" />
</Tabs>
</Box>
<TableContainer component={Paper} sx={{ mb: 3 }}> <TableContainer component={Paper} sx={{ mb: 3 }}>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell align="center" width="80px"></TableCell> <TableCell align="center" width="80px"></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell align="center"></TableCell>
<TableCell align="center"></TableCell> <TableCell align="center"></TableCell>
<TableCell align="center"></TableCell> <TableCell align="center"></TableCell>
<TableCell align="center"></TableCell> <TableCell align="center"></TableCell>
@@ -155,7 +178,7 @@ const SudokuLeaderboard: React.FC = () => {
<TableBody> <TableBody>
{records.length === 0 ? ( {records.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} align="center"> <TableCell colSpan={8} align="center">
<Typography variant="body2" color="textSecondary" py={3}> <Typography variant="body2" color="textSecondary" py={3}>
</Typography> </Typography>
@@ -167,7 +190,7 @@ const SudokuLeaderboard: React.FC = () => {
const badge = page === 1 ? getRankBadge(rank) : null; const badge = page === 1 ? getRankBadge(rank) : null;
return ( return (
<TableRow <TableRow
key={record.userId} key={`${record.userId}_${record.gameMode}`}
sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}} sx={badge ? { backgroundColor: 'rgba(255, 215, 0, 0.1)' } : {}}
> >
<TableCell align="center"> <TableCell align="center">
@@ -181,6 +204,13 @@ const SudokuLeaderboard: React.FC = () => {
{record.fullname || record.username} {record.fullname || record.username}
</Typography> </Typography>
</TableCell> </TableCell>
<TableCell align="center">
<Chip
label={record.gameMode === 'standard' ? '标准' : '不规则'}
color={record.gameMode === 'standard' ? 'primary' : 'secondary'}
size="small"
/>
</TableCell>
<TableCell align="center"> <TableCell align="center">
<Chip <Chip
label={formatTime(record.bestTime)} label={formatTime(record.bestTime)}

View File

@@ -1,7 +1,9 @@
import { import {
createEmptyBoard, createEmptyBoard,
generateFullBoard, generateFullBoard,
generateIrregularRegionMap,
generatePuzzle, generatePuzzle,
generatePuzzleForMode,
getRegionId, getRegionId,
getRegionNeighbors, getRegionNeighbors,
IRREGULAR_REGION_MAP, IRREGULAR_REGION_MAP,
@@ -35,6 +37,17 @@ describe('sudokuEngine', () => {
expect(isValidRegionMap(IRREGULAR_REGION_MAP)).toBe(true); expect(isValidRegionMap(IRREGULAR_REGION_MAP)).toBe(true);
}); });
test('generateIrregularRegionMap creates a non-standard connected region map', () => {
const regionMap = generateIrregularRegionMap();
const differentCells = regionMap.reduce(
(count, row, rowIndex) => count + row.filter((regionId, colIndex) => regionId !== STANDARD_REGION_MAP[rowIndex][colIndex]).length,
0
);
expect(isValidRegionMap(regionMap)).toBe(true);
expect(differentCells).toBeGreaterThan(0);
});
test('isPlacementValid uses irregular regions instead of 3x3 boxes', () => { test('isPlacementValid uses irregular regions instead of 3x3 boxes', () => {
const board = createEmptyBoard(); const board = createEmptyBoard();
board[0][0] = 5; board[0][0] = 5;
@@ -56,6 +69,17 @@ describe('sudokuEngine', () => {
expect(puzzle.flat().some((cell) => cell === 0)).toBe(true); expect(puzzle.flat().some((cell) => cell === 0)).toBe(true);
}); });
test('generatePuzzleForMode pairs irregular puzzles with their generated region map', () => {
const { puzzle, regionMap } = generatePuzzleForMode('medium', 'irregular');
const fullBoard = generateFullBoard(regionMap);
expect(isValidRegionMap(regionMap)).toBe(true);
expect(puzzle).toHaveLength(9);
expect(puzzle.every((row) => row.length === 9)).toBe(true);
expect(puzzle.flat().some((cell) => cell === 0)).toBe(true);
expectSolvedBoard(fullBoard, regionMap);
});
test('generateFullBoard creates a solved irregular board', () => { test('generateFullBoard creates a solved irregular board', () => {
const board = generateFullBoard(IRREGULAR_REGION_MAP); const board = generateFullBoard(IRREGULAR_REGION_MAP);

View File

@@ -2,10 +2,35 @@ export type GameBoard = number[][];
export type Difficulty = 'easy' | 'medium' | 'hard'; export type Difficulty = 'easy' | 'medium' | 'hard';
export type GameMode = 'standard' | 'irregular'; export type GameMode = 'standard' | 'irregular';
export type RegionMap = number[][]; export type RegionMap = number[][];
export type GeneratedPuzzle = {
puzzle: GameBoard;
regionMap: RegionMap;
};
type SolvedSudoku = {
fullBoard: GameBoard;
regionMap: RegionMap;
};
type CellPosition = [number, number];
const DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const DIRECTIONS: CellPosition[] = [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
];
const MIN_IRREGULAR_CELL_DIFFERENCE = 16;
const MAX_IRREGULAR_RESTARTS = 24;
const MAX_IRREGULAR_GREEDY_STEPS = 28;
const generatedFullBoards = new WeakMap<RegionMap, GameBoard>();
export const createEmptyBoard = (): GameBoard => Array.from({ length: 9 }, () => Array(9).fill(0)); export const createEmptyBoard = (): GameBoard => Array.from({ length: 9 }, () => Array(9).fill(0));
const shuffleArray = (arr: number[]) => { const shuffleArray = <T>(arr: T[]): T[] => {
const result = [...arr]; const result = [...arr];
for (let i = result.length - 1; i > 0; i--) { for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
@@ -14,6 +39,10 @@ const shuffleArray = (arr: number[]) => {
return result; return result;
}; };
const cloneBoard = (board: GameBoard): GameBoard => board.map((row) => [...row]);
const cloneRegionMap = (regionMap: RegionMap): RegionMap => regionMap.map((row) => [...row]);
export const STANDARD_REGION_MAP: RegionMap = Array.from({ length: 9 }, (_, row) => export const STANDARD_REGION_MAP: RegionMap = Array.from({ length: 9 }, (_, row) =>
Array.from({ length: 9 }, (_, col) => Math.floor(row / 3) * 3 + Math.floor(col / 3)) Array.from({ length: 9 }, (_, col) => Math.floor(row / 3) * 3 + Math.floor(col / 3))
); );
@@ -44,6 +73,8 @@ const IRREGULAR_FULL_BOARD: GameBoard = [
export const getRegionId = (regionMap: RegionMap, row: number, col: number): number => regionMap[row][col]; export const getRegionId = (regionMap: RegionMap, row: number, col: number): number => regionMap[row][col];
const isInBounds = (row: number, col: number): boolean => row >= 0 && row < 9 && col >= 0 && col < 9;
export const isValidRegionMap = (regionMap: RegionMap): boolean => { export const isValidRegionMap = (regionMap: RegionMap): boolean => {
if (regionMap.length !== 9 || regionMap.some((row) => row.length !== 9)) { if (regionMap.length !== 9 || regionMap.some((row) => row.length !== 9)) {
return false; return false;
@@ -69,19 +100,14 @@ const isConnectedRegion = (cells: Array<[number, number]>): boolean => {
const cellSet = new Set(cells.map(([row, col]) => `${row}-${col}`)); const cellSet = new Set(cells.map(([row, col]) => `${row}-${col}`));
const seen = new Set<string>(); const seen = new Set<string>();
const queue: Array<[number, number]> = [cells[0]]; const queue: CellPosition[] = [cells[0]];
seen.add(`${cells[0][0]}-${cells[0][1]}`); seen.add(`${cells[0][0]}-${cells[0][1]}`);
for (let index = 0; index < queue.length; index++) { for (let index = 0; index < queue.length; index++) {
const [row, col] = queue[index]; const [row, col] = queue[index];
const neighbors: Array<[number, number]> = [ for (const [rowOffset, colOffset] of DIRECTIONS) {
[row - 1, col], const nextRow = row + rowOffset;
[row + 1, col], const nextCol = col + colOffset;
[row, col - 1],
[row, col + 1],
];
for (const [nextRow, nextCol] of neighbors) {
const key = `${nextRow}-${nextCol}`; const key = `${nextRow}-${nextCol}`;
if (cellSet.has(key) && !seen.has(key)) { if (cellSet.has(key) && !seen.has(key)) {
seen.add(key); seen.add(key);
@@ -98,12 +124,203 @@ const isSameRegionMap = (left: RegionMap, right: RegionMap): boolean =>
left.every((row, rowIndex) => row.length === right[rowIndex].length && row.every((cell, colIndex) => cell === right[rowIndex][colIndex])); left.every((row, rowIndex) => row.length === right[rowIndex].length && row.every((cell, colIndex) => cell === right[rowIndex][colIndex]));
const randomizeDigits = (board: GameBoard): GameBoard => { const randomizeDigits = (board: GameBoard): GameBoard => {
const shuffledDigits = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]); const shuffledDigits = shuffleArray(DIGITS);
const digitMap = new Map(shuffledDigits.map((digit, index) => [index + 1, digit])); const digitMap = new Map(shuffledDigits.map((digit, index) => [index + 1, digit]));
return board.map((row) => row.map((cell) => digitMap.get(cell) ?? cell)); return board.map((row) => row.map((cell) => digitMap.get(cell) ?? cell));
}; };
const generateRandomStandardFullBoard = (): GameBoard => {
const pattern = (row: number, col: number) => ((row * 3 + Math.floor(row / 3) + col) % 9) + 1;
const rows = shuffleArray([0, 1, 2]).flatMap((band) => shuffleArray([0, 1, 2]).map((row) => band * 3 + row));
const cols = shuffleArray([0, 1, 2]).flatMap((stack) => shuffleArray([0, 1, 2]).map((col) => stack * 3 + col));
const shuffledDigits = shuffleArray(DIGITS);
return rows.map((row) => cols.map((col) => shuffledDigits[pattern(row, col) - 1]));
};
const countDifferentCells = (left: RegionMap, right: RegionMap): number => {
let count = 0;
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (left[row][col] !== right[row][col]) {
count++;
}
}
}
return count;
};
const getTouchingRegionPairs = (regionMap: RegionMap): Array<[number, number]> => {
const pairs = new Set<string>();
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
for (const [rowOffset, colOffset] of DIRECTIONS) {
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
if (!isInBounds(nextRow, nextCol)) continue;
const currentRegion = regionMap[row][col];
const nextRegion = regionMap[nextRow][nextCol];
if (currentRegion === nextRegion) continue;
const [low, high] = currentRegion < nextRegion ? [currentRegion, nextRegion] : [nextRegion, currentRegion];
pairs.add(`${low}-${high}`);
}
}
}
return Array.from(pairs, (pair) => pair.split('-').map(Number) as [number, number]);
};
const getBoundaryCells = (regionMap: RegionMap, regionId: number, neighborRegionId: number): CellPosition[] => {
const cells: CellPosition[] = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (regionMap[row][col] !== regionId) continue;
const touchesNeighbor = DIRECTIONS.some(([rowOffset, colOffset]) => {
const nextRow = row + rowOffset;
const nextCol = col + colOffset;
return isInBounds(nextRow, nextCol) && regionMap[nextRow][nextCol] === neighborRegionId;
});
if (touchesNeighbor) {
cells.push([row, col]);
}
}
}
return cells;
};
const swapRegionCells = (
regionMap: RegionMap,
firstCell: CellPosition,
secondCell: CellPosition,
firstRegionId: number,
secondRegionId: number
): RegionMap => {
const nextRegionMap = cloneRegionMap(regionMap);
const [firstRow, firstCol] = firstCell;
const [secondRow, secondCol] = secondCell;
nextRegionMap[firstRow][firstCol] = secondRegionId;
nextRegionMap[secondRow][secondCol] = firstRegionId;
return nextRegionMap;
};
const findBetterCompatibleRegionMap = (
regionMap: RegionMap,
fullBoard: GameBoard,
currentDifference: number
): RegionMap | null => {
let bestDifference = currentDifference;
let bestRegionMaps: RegionMap[] = [];
for (const [firstRegionId, secondRegionId] of shuffleArray(getTouchingRegionPairs(regionMap))) {
const firstBoundaryCells = shuffleArray(getBoundaryCells(regionMap, firstRegionId, secondRegionId));
const secondBoundaryCells = getBoundaryCells(regionMap, secondRegionId, firstRegionId);
for (const firstCell of firstBoundaryCells) {
const [firstRow, firstCol] = firstCell;
const matchingSecondCells = secondBoundaryCells.filter(([secondRow, secondCol]) => fullBoard[secondRow][secondCol] === fullBoard[firstRow][firstCol]);
for (const secondCell of shuffleArray(matchingSecondCells)) {
const candidateRegionMap = swapRegionCells(regionMap, firstCell, secondCell, firstRegionId, secondRegionId);
const candidateDifference = countDifferentCells(candidateRegionMap, STANDARD_REGION_MAP);
if (candidateDifference <= bestDifference || !isValidRegionMap(candidateRegionMap)) {
continue;
}
if (candidateDifference > bestDifference) {
bestDifference = candidateDifference;
bestRegionMaps = [];
}
bestRegionMaps.push(candidateRegionMap);
}
}
}
if (bestRegionMaps.length === 0) {
return null;
}
return shuffleArray(bestRegionMaps)[0];
};
const isSolvedBoardForRegionMap = (board: GameBoard, regionMap: RegionMap): boolean => {
const expected = DIGITS.join(',');
const toKey = (values: number[]) => [...values].sort((a, b) => a - b).join(',');
for (let index = 0; index < 9; index++) {
if (toKey(board[index]) !== expected) return false;
if (toKey(board.map((row) => row[index])) !== expected) return false;
const regionValues: number[] = [];
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (regionMap[row][col] === index) {
regionValues.push(board[row][col]);
}
}
}
if (toKey(regionValues) !== expected) return false;
}
return true;
};
const rememberSolvedSudoku = (solvedSudoku: SolvedSudoku): SolvedSudoku => {
generatedFullBoards.set(solvedSudoku.regionMap, cloneBoard(solvedSudoku.fullBoard));
return solvedSudoku;
};
const generateSolvedIrregularSudoku = (): SolvedSudoku => {
let bestSolvedSudoku: SolvedSudoku | null = null;
let bestDifference = -1;
for (let restart = 0; restart < MAX_IRREGULAR_RESTARTS; restart++) {
const fullBoard = generateRandomStandardFullBoard();
let regionMap = cloneRegionMap(STANDARD_REGION_MAP);
let difference = 0;
for (let step = 0; step < MAX_IRREGULAR_GREEDY_STEPS; step++) {
const nextRegionMap = findBetterCompatibleRegionMap(regionMap, fullBoard, difference);
if (!nextRegionMap) break;
regionMap = nextRegionMap;
difference = countDifferentCells(regionMap, STANDARD_REGION_MAP);
}
if (isValidRegionMap(regionMap) && isSolvedBoardForRegionMap(fullBoard, regionMap) && difference > bestDifference) {
bestSolvedSudoku = { fullBoard, regionMap };
bestDifference = difference;
}
if (bestSolvedSudoku && bestDifference >= MIN_IRREGULAR_CELL_DIFFERENCE) {
return rememberSolvedSudoku(bestSolvedSudoku);
}
}
if (bestSolvedSudoku && bestDifference > 0) {
return rememberSolvedSudoku(bestSolvedSudoku);
}
return rememberSolvedSudoku({
fullBoard: randomizeDigits(IRREGULAR_FULL_BOARD),
regionMap: cloneRegionMap(IRREGULAR_REGION_MAP),
});
};
export const generateIrregularRegionMap = (): RegionMap => generateSolvedIrregularSudoku().regionMap;
export const isPlacementValid = ( export const isPlacementValid = (
board: GameBoard, board: GameBoard,
row: number, row: number,
@@ -132,26 +349,40 @@ export const isPlacementValid = (
}; };
const fillBoard = (board: GameBoard, regionMap: RegionMap): boolean => { const fillBoard = (board: GameBoard, regionMap: RegionMap): boolean => {
let bestCell: { row: number; col: number; candidates: number[] } | null = null;
for (let row = 0; row < 9; row++) { for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) { for (let col = 0; col < 9; col++) {
if (board[row][col] !== 0) continue; if (board[row][col] !== 0) continue;
const numbers = shuffleArray([1, 2, 3, 4, 5, 6, 7, 8, 9]); const candidates = DIGITS.filter((num) => isPlacementValid(board, row, col, num, regionMap));
for (const num of numbers) { if (!bestCell || candidates.length < bestCell.candidates.length) {
if (isPlacementValid(board, row, col, num, regionMap)) { bestCell = { row, col, candidates };
board[row][col] = num;
if (fillBoard(board, regionMap)) {
return true;
}
board[row][col] = 0;
}
} }
return false; if (candidates.length <= 1) {
break;
}
}
if (bestCell && bestCell.candidates.length <= 1) {
break;
} }
} }
return true; if (!bestCell) {
return true;
}
for (const num of shuffleArray(bestCell.candidates)) {
board[bestCell.row][bestCell.col] = num;
if (fillBoard(board, regionMap)) {
return true;
}
board[bestCell.row][bestCell.col] = 0;
}
return false;
}; };
export const generateFullBoard = (regionMap: RegionMap): GameBoard => { export const generateFullBoard = (regionMap: RegionMap): GameBoard => {
@@ -159,6 +390,11 @@ export const generateFullBoard = (regionMap: RegionMap): GameBoard => {
throw new Error('Invalid sudoku region map'); throw new Error('Invalid sudoku region map');
} }
const generatedFullBoard = generatedFullBoards.get(regionMap);
if (generatedFullBoard) {
return cloneBoard(generatedFullBoard);
}
if (isSameRegionMap(regionMap, IRREGULAR_REGION_MAP)) { if (isSameRegionMap(regionMap, IRREGULAR_REGION_MAP)) {
return randomizeDigits(IRREGULAR_FULL_BOARD); return randomizeDigits(IRREGULAR_FULL_BOARD);
} }
@@ -183,8 +419,7 @@ const getRemovalCount = (difficulty: Difficulty) => {
} }
}; };
export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard => { const createPuzzleFromFullBoard = (difficulty: Difficulty, fullBoard: GameBoard): GameBoard => {
const fullBoard = generateFullBoard(regionMap);
const puzzle = fullBoard.map((row) => [...row]); const puzzle = fullBoard.map((row) => [...row]);
const positions = Array.from({ length: 81 }, (_, idx) => idx); const positions = Array.from({ length: 81 }, (_, idx) => idx);
const toRemove = shuffleArray(positions).slice(0, getRemovalCount(difficulty)); const toRemove = shuffleArray(positions).slice(0, getRemovalCount(difficulty));
@@ -198,6 +433,25 @@ export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): Ga
return puzzle; return puzzle;
}; };
export const generatePuzzle = (difficulty: Difficulty, regionMap: RegionMap): GameBoard =>
createPuzzleFromFullBoard(difficulty, generateFullBoard(regionMap));
export const generatePuzzleForMode = (difficulty: Difficulty, mode: GameMode): GeneratedPuzzle => {
if (mode === 'standard') {
const regionMap = cloneRegionMap(STANDARD_REGION_MAP);
return {
puzzle: createPuzzleFromFullBoard(difficulty, generateRandomStandardFullBoard()),
regionMap,
};
}
const { fullBoard, regionMap } = generateSolvedIrregularSudoku();
return {
puzzle: createPuzzleFromFullBoard(difficulty, fullBoard),
regionMap,
};
};
export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: number) => { export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: number) => {
const regionId = getRegionId(regionMap, row, col); const regionId = getRegionId(regionMap, row, col);
@@ -210,4 +464,4 @@ export const getRegionNeighbors = (regionMap: RegionMap, row: number, col: numbe
}; };
export const getRegionMapByMode = (mode: GameMode): RegionMap => export const getRegionMapByMode = (mode: GameMode): RegionMap =>
mode === 'irregular' ? IRREGULAR_REGION_MAP : STANDARD_REGION_MAP; mode === 'irregular' ? generateIrregularRegionMap() : cloneRegionMap(STANDARD_REGION_MAP);