Compare commits
4 Commits
0e87d5546d
...
5567bf572f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5567bf572f | |||
| f1f906be55 | |||
| 8af733a274 | |||
| e54b9a0b7a |
@@ -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'] }] },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 完成成功对话框 */}
|
{/* 完成成功对话框 */}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user