☰
Qt数独游戏开发实战:从回溯算法到界面交互完整解析
2026/10/12 3:20:35 网站建设 项目流程

简介:使用Qt框架编写的数独游戏完整工程,适合Qt初学者、C++开发者及对游戏开发感兴趣的学习者。项目覆盖了从界面布局到游戏核心逻辑的完整实现,包含数独盘面生成、输入校验、答案判定等模块,并支持背景更换等扩展功能。压缩包共141个文件,大小25.6MB,主要包含21个cpp源文件、15个h头文件、8个ui界面文件以及20个table数据文件,源码、界面与题面数据划分清晰,便于对照学习。已有2558人下载学习。从代码构成看,sudokuWidget、diverseFunction、explanatryDlg等模块分别负责主窗口、辅助功能与规则说明,集中演示了Qt的信号槽机制、事件处理、QWidgets布局及MVC分层思想,还涉及QFile文件读写、QPixmap图像处理等实用技术。读者可借此掌握Qt工程组织方式,并在此基础上扩展难度选择、计时计分、存档读档等常见功能。

1. 为什么用Qt写数独:一个让你从界面到算法都上手的练手项目

很多开发者学Qt到能写简单的窗口和按钮后,就卡在了一个不上不下的位置:界面能画出来,但一涉及稍微复杂的业务逻辑就不知道该怎么组织代码。数独游戏恰好是个分水岭级的练手项目。它看起来只是一个9x9的表格,背后却牵扯到回溯算法、唯一解判定、界面状态同步、撤销栈、存档读档这些实打实的工程问题。我最近拆解了一套《Qt编写数独游戏》的完整工程,从空盘生成到终局验证,再到界面绘制与交互,走完一遍之后才发现,一个小数独游戏几乎能把Qt开发的核心知识点全串起来。这套代码不需要你懂什么高深的数学,却在每个环节都留足了让你动手改的空间,特别适合刚学会信号槽、想再做一个小而完整的项目来巩固的开发者。这篇文章就按我实际的拆包顺序,把算法、界面、交互和避坑点一条一条拎出来讲。

2. 数独核心算法:生成终局与挖洞出题,别只顾着回溯

2.1 先有完整盘面再随机挖洞:出题逻辑的正确顺序

很多人第一次写数独,第一反应是直接用一个有几格的初始化数组,然后从固定题目开始。但真要做一个能随机出题的完整游戏,正确的顺序应该倒过来:先把一个9x9空盘填成合法的完整终局,再从终局里挖掉一定数量的数字,最后验证剩下的盘面是否只有一个解。先填后挖的好处很明显——你不需要一边生成题目一边检查约束,因为终局本身的合法性已经保证,接下来的问题只是“挖洞时别破坏唯一解”。

挖洞的数量决定难度。一般我们用“已知数个数”来描述难度,个数越少越难。常见的休闲级别是30个已知数,中等难度28个,困难可以到22个。如果少于20个,求解器的验证时间会成倍上升,盘面也容易多解。所以我的默认参数是26个已知数,挖掉55个,再通过求解器验证。

2.2 回溯生成终局:从空盘到合法完整盘

生成终局最直白的办法是回溯。从(0,0)开始依次尝试填入1~9,如果当前格某个数字在同行、同列、同宫没有冲突,就继续填下一个格,否则回溯换数字。

bool solveGrid(int grid[9][9]) { int row = -1, col = -1; bool found = false; for (int i = 0; i < 9 && !found; ++i) { for (int j = 0; j < 9 && !found; ++j) { if (grid[i][j] == 0) { row = i; col = j; found = true; } } } if (!found) return true; // 没有空位,盘面已填满 // 这里刻意随机打乱填数顺序,否则每次生成的终局都一样 int nums[9] = {1,2,3,4,5,6,7,8,9}; for (int i = 8; i > 0; --i) { int j = rand() % (i + 1); qSwap(nums[i], nums[j]); } for (int k = 0; k < 9; ++k) { int num = nums[k]; if (isSafe(grid, row, col, num)) { grid[row][col] = num; if (solveGrid(grid)) return true; grid[row][col] = 0; // 回溯 } } return false; }

isSafe函数检查指定位置放入num后,对应行、列、3x3宫是否已经有重复数字。逻辑很简单,但有一个关键点:填数顺序如果不随机,你调用一百次得到的都是同一个终局。所以我在外层先打乱nums数组,再逐个尝试,这样能保证每次生成不同的终局。这里的rand()需要srand播种,我一般用QTime::currentTime().msec()做种子。

回溯本身虽然直观,但要注意递归深度最多81层,不用担心栈溢出。性能上,普通桌面CPU生成一个终局在1毫秒左右,完全够用。

2.3 挖洞之后必须验证唯一解:用求解器验证而非肉眼判断

挖洞听起来简单:随机找个格子,把数字去掉,然后看看有没有解。但真正的问题是“去掉这个数字后,盘面是否仍然只有唯一解”。如果你随意挖,挖出来的题目很可能有几个解,玩家填到后面会发现自己填的也合法,但和你的标准答案不一致,这是数独游戏最忌讳的体验。

我一般会这样做:先把终局复制到puzzle数组中,计算需要挖掉的格数,然后循环随机选格挖掉,每挖一个就调用一次求解器统计解的数量。如果解的数量不是1,就把这个格子恢复。

void digHoles(int puzzle[9][9], int clues) { int cells = 81 - clues; while (cells > 0) { int idx = rand() % 81; int r = idx / 9; int c = idx % 9; if (puzzle[r][c] == 0) continue; // 已经挖过 int backup = puzzle[r][c]; puzzle[r][c] = 0; if (countSolutions(puzzle, 0, 2) != 1) { puzzle[r][c] = backup; // 挖掉会导致多解或零解,恢复 } else { --cells; } } }

countSolutions是求解器的扩展版本,它利用回溯统计所有可行解,但我们可以传入一个上限值2,一旦发现第二个解就立即返回。这样能大幅节省时间,不必把所有解数完。

int countSolutions(int grid[9][9], int pos, int limit) { if (pos == 81) return 1; // 填满,找到一个解 int r = pos / 9; int c = pos % 9; if (grid[r][c] != 0) return countSolutions(grid, pos + 1, limit); int count = 0; for (int num = 1; num <= 9; ++num) { if (isSafe(grid, r, c, num)) { grid[r][c] = num; count += countSolutions(grid, pos + 1, limit - count); grid[r][c] = 0; if (count >= limit) break; } } return count; }

注意countSolutions不能直接复用solveGrid,因为solveGrid找到一个解就返回true,而我们需要统计个数。另外,limit参数的处理要小心:我传入limit - count,如果当前已经有1个解,剩余限制为1,意味着再多找到一个就超过。count >= limit时直接break。

这个挖洞循环的随机策略有个隐患:如果单纯用rand()%81,可能刷到很后面才挖满55个,因为越挖越难遇到合适的格子。我一般会先打乱所有81个格子的索引数组,然后按这个顺序去挖,这样效率更高,也更均匀。你可以用Fisher-Yates洗牌来实现,这个在Qt里没有现成函数,自己写也就几行。

3. Qt界面搭建:QWidget还是QML?按你的目标选

3.1 用QTableView还是自绘QGrid:交互设计的取舍

到了界面层,第一个分叉点就是选择技术方案。很多人会下意识用QTableView加QStandardItemModel,因为表格模型天然支持编辑和定位。但我更推荐用QWidget自己paintEvent绘制9x9网格,原因有三:第一,数独格子大小必须严格相等,QTableView的默认行高列宽虽然可以设置,但总有几个像素的不确定性,碰到高分屏还需要额外处理缩放;第二,每个格子的状态有多种:固定题目的数字、用户填入的数字、冲突高亮、当前选中格,这些在QTableView里都要用Delegate或者背景画刷去实现,逻辑非常绕;第三,自绘QWidget后,鼠标点击和键盘输入的坐标换算可以直接在paintEvent里保持一致,不用关心模型索引和视图坐标的转换。

当然,如果你以后想把这个游戏扩展成在线对战版或需要大量列表展示,QTableView会更合适。但从“快速做一个能玩的数独”来看,自绘是性价比最高的。

3.2 用样式表做皮肤:高亮当前行、宫格和错误格

既然自绘,绘制逻辑就要自己写得清楚。我通常在SudokuBoard类的paintEvent里完成所有绘制,先用QPainter画网格线,再遍历每个格子画数字和背景色。网格线的粗细要区分外框和内部宫格:3x3宫的分界线用2像素,普通格线用1像素。这里的像素数不是固定值,而是根据当前控件边长动态计算的,这样才能保证窗口拉伸时界面不变形。

void SudokuBoard::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); int side = qMin(width(), height()); int cell = side / 9; int offsetX = (width() - side) / 2; int offsetY = (height() - side) / 2; // 绘制背景 painter.fillRect(offsetX, offsetY, side, side, QColor("#f8f9fa")); // 绘制网格线 for (int i = 0; i <= 9; ++i) { int lineSize = (i % 3 == 0) ? 2 : 1; painter.setPen(QPen(QColor("#4050b5"), lineSize)); int x = offsetX + i * cell; int y = offsetY + i * cell; painter.drawLine(x, offsetY, x, offsetY + side); painter.drawLine(offsetX, y, offsetX + side, y); } // 绘制选中格高亮 if (m_selectedRow >= 0) { painter.fillRect(offsetX + m_selectedCol * cell + 1, offsetY + m_selectedRow * cell + 1, cell - 2, cell - 2, QColor("#d0d8ff")); } // 绘制数字 painter.setFont(QFont("Arial", cell * 0.5)); for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { int value = m_board[r][c]; if (value == 0) continue; QRect rect(offsetX + c * cell, offsetY + r * cell, cell, cell); painter.setPen(m_fixed[r][c] ? QColor("#202124") : QColor("#1769e0")); painter.drawText(rect, Qt::AlignCenter, QString::number(value)); } } }

这里的偏移量OffsetX和OffsetY是为了让9x9网格在窗口内居中,因为窗口不一定恰好是正方形。segmentSize用来画网格线和数字字号,单元格大小和总边长有关系,拉伸时自动缩放。

高亮规则我做了三层:选中单元格用浅蓝色填充,如果该数字与同行或同列已有数字冲突,则填充红色;固定题目数字用深色,用户输入数字用蓝色,一眼就能区分。这些颜色可以放进一个QMap里,方便后期做主题皮肤。

3.3 信号槽连接:把算法层和界面层彻底解耦

界面绘制只是第一步,最关键的是让界面层和算法层不直接互相调用。我一般在SudokuBoard内不保存任何算法逻辑,只维护一个当前显示状态m_board和m_fixed,然后对外发射信号:

signals: void cellEntered(int row, int col, int value);

在keyPressEvent里,用户按数字键1-9时,先判断当前是否有选中格,然后发射这个信号。外部逻辑层(比如MainWindow)收到信号后,校验该数字是否合法,合法则调用board->setCellValue(row, col, value, false)来更新界面,不合法则弹出状态栏提示或标记冲突。这样做的好处是,之后想改成游戏菜单、网络对战甚至机器自动解题,都只需要替换对信号的响应逻辑,而不需要动SudokuBoard的绘制代码。

4. 把算法接到界面上:从点格子到产生答案的完整流程

4.1 从信号到逻辑层:数字校验、冲突标记与撤销栈

用户在键盘上按下数字,board发射cellEntered,接着逻辑层需要做的事是:先检查这个坐标是不是固定的题目格,如果是就忽略;再检查这个数字和当前盘面是否冲突,冲突时不能直接写入,而应该把冲突的位置反馈给界面层。

void MainWindow::onCellEntered(int row, int col, int value) { if (m_game.isFixed(row, col)) return; if (!m_game.isValidMove(row, col, value)) { m_statusBar->showMessage(tr("该数字已存在,无法填入"), 1500); m_board->highlightConflict(row, col, value); return; } // 记录撤销前状态 m_undoStack.push({row, col, m_game.getCell(row, col)}); m_game.setCell(row, col, value); m_board->setCellValue(row, col, value, false); }

这里m_game是数独逻辑层对象,它持有当前盘面数据、固定数组、以及求解器接口。撤销栈我用的是QStack,压入的是一个结构体,包含行列和旧值。用户点击撤销时,从栈顶弹出并恢复,同时刷新界面。如果要支持多步撤销,也可以把撤销栈的上限设为1000步,内存开销很小。

这里的重点是isValidMove不能只查当前数字在行、列、是否重复,还需要额外检查这个值填入后是否会导致其他空格无解——但这个检查比较耗时,一般游戏只做即时冲突检查就够了。如果要做到“提示某个位置填什么”,那就要用求解器来计算。

4.2 计时、提示与一键填充:三个常用功能的实现

计时器用QTimer,间隔1秒,在每次启动新游戏时清零,每触发一次timeout就更新主窗口标题或状态栏。

m_timer = new QTimer(this); connect(m_timer, &QTimer::timeout, this, [=]() { ++m_elapsedSeconds; int m = m_elapsedSeconds / 60; int s = m_elapsedSeconds % 60; ui->labelTime->setText(QString("%1:%2").arg(m, 2, 10, QChar('0')) .arg(s, 2, 10, QChar('0'))); }); m_timer->start(1000);

提示功能并不是简单调用求解器得到一个完整答案,然后填进去,那样游戏就没意义了。我做的提示是只提示当前选中格,做法是:复制一份当前盘面,用求解器求解,拿到这个格子的正确值,然后仅把该格填上,但不标记为固定。注意,如果当前盘面有误填的数字,求解器可能会得到零解,所以提示前要先检查当前盘面的合法性。

一键填充则更简单:直接用求解器算出完整解,然后遍历所有空格,用交互式填充并标记为用户输入值。这样玩家可以自己看答案,但会失去游戏乐趣。我一般会在菜单里把这两个功能分开,提示作为“锦囊”,一键填充作为“作弊”。

4.3 存档与读档:用QDataStream还是配置文件

数独的存档不需要保存整个界面状态,只需要保存当前盘面、固定标记、已用时间、撤销栈这几项。我习惯用QDataStream写入一个二进制文件,因为它支持直接写入QVector和int,读写都很快。

void MainWindow::saveGame(const QString &filename) { QFile file(filename); if (!file.open(QIODevice::WriteOnly)) return; QDataStream out(&file); out.setVersion(QDataStream::Qt_5_15); // 写入盘面和固定标记 QVector<int> board = m_game.getBoard(); QVector<int> fixed = m_game.getFixedFlag(); out << board << fixed << m_elapsedSeconds; } void MainWindow::loadGame(const QString &filename) { QFile file(filename); if (!file.open(QIODevice::ReadOnly)) return; QDataStream in(&file); QVector<int> board, fixed; in >> board >> fixed >> m_elapsedSeconds; m_game.setBoard(board, fixed); m_board->updateBoard(board, fixed); }

这里有个容易踩的坑:QDataStream读入的是QVector ,但如果你写成std::vector,就要自己手工序列化每一个元素。另外,存档版本号一定要设置,否则Qt新版本读写格式一变,老存档就废了。我一般用QDataStream::Qt_5_9以上版本,兼容性最好。

存档的时机也很讲究,不能每个数字落下都写一次磁盘,那样太伤性能。我采用两种触发:一是关闭窗口时自动保存到临时目录,二是在菜单里手动另存为。自动保存的路径用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)拼一个固定文件名,保证下次打开自动恢复上次进度。

5. 避坑与常见问题:数独Qt开发中我踩过的五个坑

5.1 生成的题目有时无解或多解

现象:明明从合法终局挖洞,有时生成的题面玩家怎么填都填不出来,或者填完发现还有另一个答案。 原因:挖洞时没有做唯一解验证;另外固定位置挖得太多,导致剩下空格之间的约束不足,出现多解。 解决:每次挖掉一个格子后,必须用countSolutions限定count>=2就提前返回,确认只有唯一解才保留。后来我把挖洞索引先洗牌,挖格顺序固定,能有效减少重复随机采样带来的死循环。

5.2 窗口拉伸时格子严重变形

现象:窗体变大后,9x9网格被拉成矩形,格子不是正方形,数字居中错位。 原因:绘制时直接用width()和height()分别除以9得到每个格子的宽高,没有做约束。 解决:在paintEvent里统一用int side = qMin(width(), height());计算可绘制区域的边长,再除以9得到cell大小,绘制时通过offsetX和offsetY把网格居中。从那以后,任何尺寸下界面都能保持正方形。

5.3 点击格子没反应,但键盘输入有效

现象:鼠标点选单元格有高亮,但按数字键没反应,焦点仍停在主窗口上。 原因:SudokuBoard的焦点策略默认为NoFocus,键盘事件根本不会发给它。 解决:在构造函数里设置setFocusPolicy(Qt::StrongFocus);,并且鼠标点击时调用setFocus()强制把焦点抢过来。我还在keyPressEvent里忽略了非数字按键,避免用户按方向键时误触发。

5.4 读档后盘面数字错乱或直接崩溃

现象:保存后重启程序读档,数字变成乱码,甚至程序崩溃。 原因:QDataStream写入时没有指定版本,默认版本随Qt库更新变化;或者读写时成员顺序不一致。 解决:写和读都必须设置相同的out.setVersion(QDataStream::Qt_5_15);,并且保持写入顺序为board、fixed、time。我还封装了saveGame/loadGame两个对称函数,任何一边改动都要同步另一边的字段顺序。

5.5 高亮冲突闪烁严重

现象:填一个冲突数字时,相关格子颜色变红,但鼠标移动一下颜色就乱跳,视觉上像在闪烁。 原因:paintEvent里每帧都重新计算高亮区域,并且直接调用update()重绘整个界面,没有做局部更新。 解决:只在选中格变动或数字变动时调用update(),并且用update(rect)更新具体区域,而不是update()全画布。另外,把所有颜色值集中在一个静态常量表里,避免每次绘制时重复构造QColor导致性能浪费。

6. 最终验证与一个实用技巧:用命令行使每一次开发调试都在同一盘面

数独这个项目最烦人的是,每次启动程序如果都随机生成一个新题目,你在调试某个具体bug时就会很痛苦——你自己都记不住刚才那盘的数字布局,代码里也断不住位置。我最后养成的一个习惯,是在程序启动时通过QCommandLineParser加一个可选参数,指定随机种子和挖洞数量。

QCommandLineParser parser; parser.setApplicationDescription("数独游戏"); parser.addHelpOption(); QCommandLineOption seedOption("seed", "指定随机种子,方便复现盘面", "seed"); QCommandLineOption cluesOption("clues", "指定已知数个数,默认26", "clues"); parser.addOption(seedOption); parser.addOption(cluesOption); parser.process(argc, argv); int seed = parser.value(seedOption).toInt(); int clues = parser.value(cluesOption).isEmpty() ? 26 : parser.value(cluesOption).toInt(); qDebug() << "当前固定种子:" << seed << "已知数:" << clues; srand(seed);

这样启动时,比如./sudoku --seed 42 --clues 22,就能固定复现同一盘面。每次报bug,我都在这个固定种子上操作,然后在paintEvent或按键处理里下断点,问题定位速度快了好几倍。

验证一个数独游戏是否正确,不只是看它能不能玩。我会写一个自动测试脚本,用命令行模式加载一个已保存的存档,先把所有空格用求解器填充,再比对每一行、每一列、每一宫是否都是1~9的全排列。这些自动化测试不需要界面,直接把数独逻辑层封装成独立类就行。我从这以后,每次改动算法都会先跑一遍这个测试,再打开界面做手工回归,省去了很多无谓的翻车。

至于玩家侧,验证最终胜负也不难。每次用户输入完,我检查盘面是否已满,再调用一次isComplete接口,它遍历每一行、列、宫,如果全合法就弹胜利提示。这个检查在每次落子后执行,不会卡顿。

你要是也打算拆这个“Qt编写数独游戏”的源码包,我建议你重点看这三块:solveGrid的随机打乱方式、digHoles里的唯一解判定、以及paintEvent里qMin(width(), height())的正方形处理。把这三个点吃透,再去看别的代码会顺手很多。我后来每一次重构,都强制自己走一遍“固定种子启动——自动验证——手动交互”的流程,这个小习惯帮我少熬了不少夜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询