简介:压缩包内是一份用 C++ 编写的数独游戏 GUI 工程,适合想通过完整小项目掌握 C++ 基础、回溯算法与界面编程的初学者,也适合作为课程设计或毕业设计的参考。代码以 9×9 二维数组保存盘面,围绕回溯法填数、行/列/宫格约束检查、用户输入校验与实时盘面更新展开,同时体现事件驱动编程、异常处理和文件保存功能;从控制台程序过渡到可视化界面的组织方式清晰,便于按模块逐步拆解学习。包体共 9 个文件,主要是 VC6 工程文件(dsw/dsp)、C++ 源文件(cpp)、预编译头 pch,以及 pdb、opt、ncb、plg 等编译调试辅助文件,整体约 24KB,结构精简,适合直接打开工程阅读或二次修改。已有 360 人浏览学习。通过分析该实例,可以同时熟悉数独求解算法、GUI 交互流程、调试方法与代码组织,是一份麻雀虽小但覆盖算法、界面、调试多个环节的入门学习资料。
1. 为什么要用C++写一个数独GUI:从算法到界面的完整一笔账
你在网上找的那种“数独游戏GUI源码”,多半是个压缩包,解压出来一堆cpp和h,编译却报错。别问我怎么知道的,我大学课设就下过这种shudu.rar。核心问题不是代码不对,而是缺了从算法到界面的完整工程思维。这篇文章我直接讲:用C++把数独的生成、求解、判定、GUI交互都自己做一遍,顺便把那些编译不过、运行时崩溃、难度不合理的地方全拆开讲。适合刚学完C++基础、想做第一个完整项目的人,也适合要交课程设计但不想复制粘贴跑不通代码的选手。
数独游戏编程放在C++里,好处是算法部分足够纯粹:一个二维数组、一个递归函数,就能跑通求解和生成;坏处是你很快会碰见“算法跑得好好的,一上界面就乱套”的经典问题。我建议的做法是先把算法当成独立模块写,不碰任何GUI头文件,等它在控制台里能生成、能求解、能判唯一解,再往上挂界面。这样即使后面换Qt、SDL甚至Win32,算法部分一行都不用动。
这篇文章不教你怎么“拿一个现成包改改”,而是把一条我认为最可靠的落地路径给你:先做算法,再做界面,最后调难度和交互。你会看到回溯求解怎么写、终盘怎么生成、挖洞怎么保证唯一解、Qt里怎么画9×9宫格、输入冲突怎么标红,以及那些你不亲手踩一遍绝对想不明白的坑。整个过程下来,你对“C++项目怎么分层”“GUI和逻辑怎么解耦”会有一个比课本清晰得多的手感。
2. 数独生成与求解:先让算法跑通再谈界面
数独游戏的核心不在界面,而在“怎么生成一个只有唯一解的题目”和“怎么快速判断玩家填的是不是对的”。这两件事都要靠算法。很多新手一上来就做界面,结果游戏逻辑全是硬编码,换个题目就崩溃。所以我坚持先把算法编译成一个能独立跑的控制台程序,验证通了再做GUI。
2.1 两种求解器选型:回溯法还是DLX精确覆盖
求解数独的常见做法有两种:回溯法和精准覆盖(Dancing Links,简称DLX)。DLX在处理大量谜题时性能很强,但代码复杂,实现一个小型完美覆盖矩阵需要额外学习精确覆盖算法本身。对9×9数独来说,回溯法配合行列宫冲突检查,最坏情况下也只需要几十毫秒到几百毫秒——这还是在Debug模式下。对课设或一个给人玩的游戏来说,完全够用。
我一般选回溯法,理由是它的逻辑直白:找一个空格,尝试1到9,每次填入前检查冲突,冲突就换下一个数字,否则递归下去。回溯法还有一个额外好处:判断一个题目是否只有唯一解,只需在找到第一个解后继续找第二个,找到就停。DLX在这种情况下要改很多东西,回溯只需要加一个解计数器。
如果你的目标是处理16×16甚至25×25的大数独,或者做数独生成器批量生成海量题目,再去研究DLX。但对标题里的这个场景——C++数独游戏GUI,回溯法是性价比最高的起点。下面的代码都在9×9棋盘上实现。
2.2 回溯求解的最小实现:从判定到唯一解
先写最核心的合法性判断函数,它检查三个维度:当前行、当前列、当前3×3宫。特别注意宫的下标计算,我见过不少人在这里写错。
#include <cstring> bool isValid(int board[9][9], int row, int col, int num) { // 检查行 for (int c = 0; c < 9; ++c) { if (board[row][c] == num) return false; } // 检查列 for (int r = 0; r < 9; ++r) { if (board[r][col] == num) return false; } // 检查3x3宫 int startRow = (row / 3) * 3; int startCol = (col / 3) * 3; for (int r = startRow; r < startRow + 3; ++r) { for (int c = startCol; c < startCol + 3; ++c) { if (board[r][c] == num) return false; } } return true; }逻辑说明:board用0表示空格。传入row和col是要试着填入的位置,num是候选数字。先扫一行,再扫一列,最后扫宫。这里(row / 3) * 3算出当前格子所在宫的顶部行号,col / 3 * 3算出宫的左列号。这个写法对任意9×9棋盘都成立,不需要额外记宫坐标。
然后写求解函数。最基本的版本是“只求第一个解”,但为了后续判断唯一解,我直接把它改写成“统计解的数量,超过limit就提前返回”。
int countSolutions(int board[9][9], int row = 0, int col = 0, int limit = 2) { // 找到下一个空格 bool hasEmpty = false; int nextR = row, nextC = col; for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { if (board[r][c] == 0) { hasEmpty = true; nextR = r; nextC = c; break; } } if (hasEmpty) break; } // 没有空格说明棋盘已填满,算1个解 if (!hasEmpty) return 1; int total = 0; for (int num = 1; num <= 9; ++num) { if (isValid(board, nextR, nextC, num)) { board[nextR][nextC] = num; total += countSolutions(board, nextR, nextC, limit); board[nextR][nextC] = 0; if (total >= limit) break; // 提前终止 } } return total; }逻辑说明:这个函数会把整个棋盘填完,然后通过返回值累计解的数量。每尝试一个数字,递归到下一层;像刚才说的,它在填完所有空格时返回1。limit默认值是2,表示只要找到两个解就停,用来判断“是否唯一”。你可能会问为什么不用bool:因为在你只想生成题目时,要“额外验证至少有两个解才判定为无唯一解”,返回整数更方便。
参数说明:row和col在递归时其实没起到“从哪个位置继续”的作用,因为我在每次递归里都从头找第一个空格。这种写法在多解统计时没问题,但性能略差。如果你想优化,可以传入起始坐标来减少扫描量。不过对9×9来说,从头扫描的消耗可以接受,代码更简单。真正的性能瓶颈是数字尝试顺序,后面挖洞那节会说到。
2.3 生成终盘:种子填数与随机打乱
生成数独题目的第一步是生成一个完整的、满足数独规则的终盘。有很多种做法,比如直接从空白棋盘用随机回溯填充。更高效、也更不容易卡死的方法是“对角线宫填数 + 随机洗行/洗列”。我先说简单可靠的一种:用随机回溯生成一个终盘。
#include <vector> #include <algorithm> #include <random> bool generateFull(int board[9][9]) { // 找到第一个空格 int r = -1, c = -1; for (int i = 0; i < 9; ++i) { for (int j = 0; j < 9; ++j) { if (board[i][j] == 0) { r = i; c = j; break; } } if (r != -1) break; } // 没有空格,说明已经是一个终盘 if (r == -1) return true; // 每次尝试数字时都打乱顺序 std::vector<int> nums = {1,2,3,4,5,6,7,8,9}; std::shuffle(nums.begin(), nums.end(), std::random_device{}()); for (int num : nums) { if (isValid(board, r, c, num)) { board[r][c] = num; if (generateFull(board)) return true; board[r][c] = 0; } } return false; }逻辑说明:这个函数从全0的board开始,每次随机选择数字顺序,填入空格,继续递归。核心是std::shuffle,让每次运行生成的终盘不完全一样。数独终盘数量大约是6.67×10^21,随机回溯能在很短时间内找到其中一个。random_device用来产生随机种子,如果你担心某些环境下不稳定,可以改用std::chrono::system_clock::now().time_since_epoch().count()做种子。
一个优化细节:直接在9×9全空棋盘上跑generateFull,有可能在某个空位尝试了所有数字都不行,然后回溯到上层。这个“试错”过程在多空位时会慢。常见做法是先手动填三个对角线宫,每个宫就是一个1~9的随机排列,然后再用上面的回溯把剩余的空位填完。因为三个对角宫互不相关,后面的回溯会快很多。
2.4 挖洞控难度:提示数数量与对称挖洞
有终盘后,需要挖掉一些数字,让玩家有地方填。怎么挖才能保证题目有唯一解?这是数独编程里最容易翻车的部分。正确做法是:每挖一个洞,就调用countSolutions检查当前题目是否仍然只有一个解。如果解的数量变成2或更多,回退这一步。
bool digHoles(int board[9][9], int holesToDig) { // 随机打乱所有格子坐标 std::vector<std::pair<int,int>> cells; for (int r = 0; r < 9; ++r) for (int c = 0; c < 9; ++c) cells.push_back({r, c}); std::shuffle(cells.begin(), cells.end(), std::random_device{}()); int dug = 0; for (auto [r, c] : cells) { if (dug >= holesToDig) break; int backup = board[r][c]; if (backup == 0) continue; board[r][c] = 0; // 判断剩余提示数是否还能保证唯一解 int serLinkVal = countSolutions(board, 0, 0, 2); if (serLinkVal != 1) { board[r][c] = backup; // 不能挖,回退 } else { ++dug; } } return dug == holesToDig; }逻辑说明:digHoles把81个坐标随机打乱,逐个尝试挖掉。每挖掉一个数字,立刻调用countSolutions(board, 0, 0, 2),如果返回值不是1,说明题目变成了多解,立即回退。注意,countSolutions会修改棋盘(填入数字再清零),但因为它每次都会恢复board状态,所以调用后棋盘和调用前一样。这个函数的参数顺序里,limit=2是关键,它让求解器只找2个解就退出,比求全部解快得多。
参数说明:holesToDig是希望挖掉的空格数。数独提示数越少题目越难。常见难度对应的空格数大约:简单40个空格,中等50个,困难60个。但注意,单纯看空格数并不严格对应难度,还要看挖洞位置是否对称、是否涉及唯一解。如果挖完后dug != holesToDig,说明当前终盘挖不到那么多洞,常见解决办法是重新生成终盘,或者稍微减少目标空格数。
你可以试着手动限制挖洞位置成对称结构,比如以中心格为对称点,同时挖两个对应位置的数字。这样生成的题目在棋盘上呈旋转对称或镜像对称,视觉上更“标准”。实现也很简单:把cells里的坐标加上一个对称坐标即可,但要注意避免重复挖同一个位置。
3. GUI框架选择:Qt、Win32还是SDL?界面与算法怎么分家
算法模块独立之后,下一步是给游戏穿上GUI的外壳。选什么框架,直接决定你后面要看多少文档、踩多少雷。C++的GUI方案听起来很多,但适合数独项目的其实也就几个。我先把我的推荐结论放前面:如果你不打算把程序做成华丽的3D效果,用Qt Widgets是最省心的选择。
3.1 不同GUI方案对比
我列一张表,把常见方案的关键差异说清楚。这是个经验对比,不是官方文档,具体适用性还看你手头的编译器和目标平台。
| 方案 | 开发效率 | 跨平台 | 控件与布局 | 二进制大小 | 学习成本 |
|---|---|---|---|---|---|
| Qt Widgets | 高,信号槽适合按钮和表格 | Win/Linux/macOS都可以 | 自带QTableWidget、QGridLayout | 几MB到十几MB(可裁剪) | 需要理解事件循环和信号槽概念 |
| Win32 API + GDI | 低,全靠手写窗口过程 | 只支持Windows | 需要自己画格子、处理重绘 | 很小 | 高,适合嵌入式或老项目 |
| SDL2 | 中,像素绘图 | 跨平台 | 没有标准控件,全手绘 | 较小 | 高,适合游戏渲染 |
| SFML | 中,类似SDL | 跨平台 | 也没控件,手绘 | 较小 | 中等 |
| wxWidgets | 中,原生外观 | 跨平台 | 有控件,但文档较古旧 | 较大 | 中偏高 |
对于“C++数独游戏GUI”这种以按钮、输入、表格为核心交互的程序,Qt Widgets的QTableWidget可以省掉90%的绘图代码。它自带单元格选择、框选、键盘输入事件,你只需要把数独逻辑挂上去。Win32能画出来,但你要处理WM_PAINT、WM_LBUTTONDOWN、键盘输入的消息分发,代码量直接翻倍。SDL2适合做动画和特效,但数独不需要这些,反而被坐标计算拖累。
3.2 界面与计算逻辑分离:MVC思路
选完GUI后,最需要坚持的一点是:算法模块不能依赖GUI。我的做法是给数独算法单独建一个类,命名为Sudoku,它只包含棋盘数据和解题/生成逻辑。界面类只负责把用户的动作翻译成算法调用,再把算法的结果反映到界面上。
// sudoku.h #pragma once #include <array> #include <vector> class Sudoku { public: enum class Difficulty { Easy, Medium, Hard }; Sudoku(); void newGame(Difficulty difficulty); bool setValue(int row, int col, int value); int getValue(int row, int col) const; bool checkConflict(int row, int col) const; bool isSolved() const; void getHint(int &row, int &col, int &value) const; private: void generateSolution(); bool digHoles(int holes); bool countSolutions(int board[9][9], int limit) const; int gameBoard[9][9]; // 玩家当前的棋盘 int solution[9][9]; // 终盘参考答案 bool locked[9][9]; // 是否为题目给的提示数 };逻辑说明:这个头文件把外部关心的接口全部暴露出来,但隐藏了内部实现细节。gameBoard是当前显示的棋盘,locked标记哪些格子在开局时就被填好、玩家不能修改。solution是正确答案,用于“提示”和“检查”。界面代码只需要调用newGame、setValue、getHint这些方法,完全不需要知道回撒到底怎么运行。这就是教科书上说的MVC,但实际工程里你把它当“逻辑和界面分离”就好。
参数说明:Difficulty参数控制newGame内部挖洞的数量。比如简单挖40个、中等挖50个、困难挖60个,但具体数字还要和digHoles配合。setValue返回false时表示该格子是locked的,玩家不能改;返回true表示写入成功。checkConflict返回当前格子是否有冲突,用于界面标红。
3.3 用Qt Widgets搭建棋盘与交互的骨架
既然选了Qt Widgets,我先给你看一个最小可运行的棋盘骨架。这个骨架不实现完整逻辑,只把9×9格子画出来并响应点击,目的是让你看到GUI和前面的Sudoku类怎么接上。
// mainwindow.h #pragma once #include <QMainWindow> #include <QGridLayout> #include "sudoku.h" class QPushButton; class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget *parent = nullptr); private slots: void onCellClicked(int row, int col); private: Sudoku game; QGridLayout *gridLayout; QPushButton *cells[9][9]; };// mainwindow.cpp #include "mainwindow.h" #include <QPushButton> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *central = new QWidget(this); gridLayout = new QGridLayout(central); for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { cells[r][c] = new QPushButton(central); cells[r][c]->setFixedSize(45, 45); connect(cells[r][c], &QPushButton::clicked, this, [r, c]() { onCellClicked(r, c); }); gridLayout->addWidget(cells[r][c], r, c); } } setCentralWidget(central); } void MainWindow::onCellClicked(int row, int col) { // 暂时只测试点击反馈 setWindowTitle(QString("点击了第 %1 行第 %2 列").arg(row + 1).arg(col + 1)); }逻辑说明:这段代码创建了一个9×9的按钮矩阵,每个按钮固定45×45像素。通过connect把按钮的clicked信号连接到一个lambda表达式,lambda捕获r、c两个坐标,然后调用onCellClicked。这是一个最简陋的GUI骨架,但已经包含“界面→逻辑”的事件流。
参数说明:setFixedSize(45, 45)是按钮尺寸,你可以根据窗口大小调整。如果想更整齐,可以设置gridLayout->setSpacing(0),并用加粗边框来区分3×3宫。下一章会把按钮换成更适合编辑的QTableWidget,因为它原生支持单元格编辑和选中态高亮,做数独输入比QPushButton省事得多。
4. 把数独游戏跑起来:从新建项目到可玩状态的完整步骤
算法和界面骨架都就位后,真正把项目填完整还需要处理项目文件、输入、冲突提示、新游戏、提示和检查这些具体功能。这一章我按“从空项目到可玩”的顺序,一步一步来。每一步你都能直接抄,但我会告诉你哪些参数必须改,哪些逻辑容易错。
4.1 项目结构与编译环境配置
先建一个干净的目录结构。我的习惯是头文件和源文件平行放,不搞子目录,因为小项目没必要。
shudu_game/ ├── CMakeLists.txt ├── main.cpp ├── sudoku.h ├── sudoku.cpp ├── mainwindow.h └── mainwindow.cppCMakeLists.txt是Qt项目的关键。下面的配置能在Qt5和Qt6之间都比较通用,至少不会让你一编译就被“找不到Qt模块”卡死。
cmake_minimum_required(VERSION 3.10) project(shudu_game) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 COMPONENTS Widgets REQUIRED) # 如果你装的是Qt6,把上面改成: # find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(shudu_game main.cpp sudoku.cpp mainwindow.cpp ) target_include_directories(shudu_game PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(shudu_game PRIVATE Qt5::Widgets)参数说明:CMAKE_CXX_STANDARD 11是数独算法需要的C++11标准,因为代码里用了std::shuffle和lambda。如果你想用更新的标准,写14或17也行,不影响现有代码。find_package后面的COMPONENTS Widgets表示只找你用到的Widgets模块,不需要Qt5::Core单独写,因为它会被Widgets带过来。
主函数main.cpp很简单:
#include <QApplication> #include "mainwindow.h" int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }这段代码除了初始化Qt事件循环外没有别的逻辑。注意QApplication必须放在创建窗口之前,如果漏掉,你会在运行时报“QWidget: Must construct a QApplication before a QWidget”。
4.2 棋盘绘制与选中高亮
上一章用的QPushButton不适合做数独输入界面,因为你要监听键盘输入,还得控制每个格子是否可编辑。QTableWidget天生就适合:它模拟了一个表格,可以设置每个单元格的text和背景色,还能通过currentCellChanged信号跟踪当前选中的格子。
#include <QTableWidget> #include <QTableWidgetItem> // 在MainWindow构造函数里创建表格 auto *table = new QTableWidget(9, 9, central); for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { QTableWidgetItem *item = new QTableWidgetItem(); item->setTextAlignment(Qt::AlignCenter); item->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); // 注意:不给ItemIsEditable,玩家不能直接双击改内容 table->setItem(r, c, item); if ((r / 3 + c / 3) % 2 == 0) { item->setBackground(QColor("#F0F0F0")); } } }逻辑说明:这段代码把9×9表格的每个格子都放了一个QTableWidgetItem。关键在setFlags里去掉Qt::ItemIsEditable,这样玩家不能双击进入编辑状态,必须由我们在键盘事件里统一处理。背景色用if ((r / 3 + c / 3) % 2 == 0)来交替染色,让三个一组的宫有明显分块效果。
选中高亮使用表格自带的行为:QTableWidget默认会画出当前高亮的单元格。你只需要设置选择模式为单格选择:
table->setSelectionMode(QAbstractItemView::SingleSelection); table->setSelectionBehavior(QAbstractItemView::SelectItems);参数说明:SingleSelection决定了用户一次只能选中一个格子,而SelectItems让选中的是一个单元格而不是整行整列。如果你想进一步美化高亮颜色,可以继承QStyledItemDelegate重写paint,但对第一版来说默认高亮够用了。
4.3 输入合法性判断与冲突提示
玩家输入数字后,要做的检查和提示分两层:第一层是“这个数字是否在1~9范围内”,第二层是“这个格子是否与当前棋盘其他数字冲突”。界面上冲突的地方用红色标出来。
void MainWindow::keyPressEvent(QKeyEvent *event) { QTableWidget *table = ...; // 你保存的table指针 int row = table->currentRow(); int col = table->currentColumn(); if (row < 0 || col < 0) return; int key = event->key(); if (key >= Qt::Key_1 && key <= Qt::Key_9) { int value = key - Qt::Key_0; if (game.setValue(row, col, value)) { table->item(row, col)->setText(QString::number(value)); } else { // 该格是提示数,不能修改,给个闪烁或提示 } } else if (key == Qt::Key_Delete || key == Qt::Key_Backspace) { if (game.setValue(row, col, 0)) { table->item(row, col)->setText(QString()); } } updateConflictDisplay(); }逻辑说明:keyPressEvent里先取当前选中格子的坐标,再判断按键。把Qt::Key_1到Qt::Key_9转换为数字1~9,采用key - Qt::Key_0的方式在Qt里是惯例。调用Sudoku::setValue,如果该格是玩家可以填的空格,则更新表格文本;如果返回false,多数情况是因为格子是题目原本的提示数,不能改。Delete/Backspace用来清空格子,方便玩家修正。
更新冲突显示的函数如下:
void MainWindow::updateConflictDisplay() { for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { QTableWidgetItem *item = table->item(r, c); item->setForeground(QColor("#000000")); // 恢复默认色 if (game.getValue(r, c) != 0 && game.checkConflict(r, c)) { item->setForeground(QColor("#FF0000")); } } } }这里的checkConflict需要在Sudoku类里实现:遍历对应行、列、宫,看是否有一个与当前格子数字相同的非零值。注意这个函数不要只看当前格子是不是和别的格子撞了,还要排除自己。你也可以写成“计算该格子对应行、列、宫里已有相同数字的个数”,排除自己后如果个数大于0,就是冲突。这个逻辑在算法章节已经有了雏形,只是需要再包一层成员函数。
4.4 新游戏、提示与检查功能
到这一步,游戏已经能填数字和清空了。但还差三个按钮:新游戏、提示、检查。我建议把它们放在窗口右侧的垂直布局里,用QVBoxLayout和棋盘表格并排。
新游戏按钮的槽函数:
void MainWindow::onNewGamePressed() { Sudoku::Difficulty diff = currentDifficulty; // 从下拉框读取 game.newGame(diff); for (int r = 0; r < 9; ++r) { for (int c = 0; c < 9; ++c) { int val = game.getValue(r, c); if (val != 0) { table->item(r, c)->setText(QString::number(val)); // 题目提示数设置为灰色,让玩家知道这些格子不可改 table->item(r, c)->setForeground(QColor("#666666")); } else { table->item(r, c)->setText(QString()); table->item(r, c)->setForeground(QColor("#000000")); } } } updateConflictDisplay(); }逻辑说明:调用Sudoku::newGame重新生成棋盘,然后把game里的数据同步到表格。注意题目提示数(即非零值的格子)我们要固定它的颜色为灰色,这样玩家一眼就能区分题目和玩家输入。setValue内部已经限制了不能改locked格子,所以即使误操作也没有副作用。
提示按钮的槽函数:
void MainWindow::onHintPressed() { int hintRow = -1, hintCol = -1, hintVal = 0; game.getHint(hintRow, hintCol, hintVal); if (hintRow >= 0 && hintCol >= 0) { table->setCurrentCell(hintRow, hintCol); table->item(hintRow, hintCol)->setText(QString::number(hintVal)); table->item(hintRow, hintCol)->setForeground(QColor("#00AA00")); } }getHint内部逻辑很简单:从solution数组里找一个和玩家当前填入值不同的格子,返回它的坐标和正确答案。优先找空格子,而不是改错格子,这样的提示体验更好。
检查按钮也不复杂:
void MainWindow::onCheckPressed() { bool solved = true; for (int r = 0; r < 9 && solved; ++r) { for (int c = 0; c < 9 && solved; ++c) { if (game.getValue(r, c) != game.getSolutionValue(r, c)) { solved = false; break; } } } if (solved) { QMessageBox::information(this, "完成", "恭喜你,解完了!"); } else { QMessageBox::information(this, "提醒", "还有一些格子和参考答案不同,继续加油。"); } }这只是一个最简单的检查:逐格比对玩家棋盘和solution。更完善的检查是只检查“玩家已经填了的空格”,因为有些格子还是空的,空的不算错。如果你想做得严格一点,先判断是否所有空格都已经填满,再判断是否有冲突。课设做成我这样的点击检查也足够交差。
到这里,一个可玩的数独GUI就成型了:能生成唯一解的题目,能输入数字,能高亮冲突,能提示,能检查答案。剩下的就是打磨细节和处理一些容易翻车的地方。
5. 避坑与常见问题:C++数独GUI最容易翻车的5个地方
能跑通和跑得好之间,隔着不少坑。我把自己在做这个项目时踩过的或者看别人踩过的5个常见问题写出来。每一条都按“现象→原因→解决”的顺序,方便你排查时对照。
5.1 生成终盘时程序长时间卡死或死循环
现象:在Debug模式下启动程序,窗口半天没弹出来,CPU占用100%。这多半是数独生成器在递归里找不到解,一直回溯。
原因:最常见的是随机探索太盲目。如果你从全空棋盘直接用固定数字顺序1~9回溯,而不打乱顺序,那么每次生成的路径都几乎一样,遇到一个错误分支就会一直递归到很深的层。另一种原因是随机数种子没换,导致每次运行都在同一个诅咒分支里打转。
解决:使用std::shuffle打乱数字顺序,并且在前一节的基础上先用三个随机对角宫填充。另一个实用技巧是generateFull加一个递归深度上限,超过上限直接返回false,重新初始化棋盘再试。你不用真的去追踪深度,在函数外层用一个for循环尝试不同种子多次即可。
5.2 挖洞后题目多解,玩家一填就发现存在两种答案
现象:你挖了50个洞,但解谜时发现某个空格填1和填2都能完成游戏。更糟的是,程序检查时也通过了,因为你只检查了和solution一致,没有验证唯一性。
原因:挖洞时只关注“当前空格能否填一个有效数字”,而不是“整个题目是否唯一解”。如果挖掉一个数字后,可以通过行互换、列互换、宫互换得到另一个完全不同的终盘,那这个题目就是多解的。
解决:如第2.4节所示,每挖一个洞就调用countSolutions(board, 0, 0, 2),只有返回值为1才接受这个洞。注意在挖洞循环里,每次调用countSolutions前要复制一份棋盘,因为虽然函数会恢复现场,但万一你中途加了提前退出逻辑,棋盘状态可能被破坏。我习惯直接传一个临时副本,更安全。
5.3 Qt编译时报“找不到Qt5Widgets”或链接错误
现象:CMake出现Could not find a package configuration file provided by "Qt5Widgets",或者编译时疯狂报“未定义引用”错误,指向vtable for MainWindow。
原因:CMake没找到Qt安装路径,或者Qt版本不匹配。有些发行版默认只有Qt5Core,没有Widgets模块;另外有人把Qt6::Widgets写成Qt5::Widgets,导致链接失败。
解决:在CMakeLists里加上set(CMAKE_PREFIX_PATH "你的Qt路径"),或者在Qt Creator里重新设置构建套件的Qt版本。链接错误还有一个常见原因是MainWindow里有Q_OBJECT宏,但没有运行moc。只要你在CMake里把所有Qt源文件都放进了add_executable,moc会自动处理。如果你手工写Makefile而忘了moc,就会出现“undefined reference to vtable”。检查mainwindow.cpp是否被编译进目标,是最快的排查法。
5.4 输入中文数字或键盘上方的数字键无效
现象:玩家按小键盘数字键输入,程序没反应,或者按下大键盘数字键时出现的是中文输入法的干扰。
原因:Qt的keyPressEvent里,小键盘数字键的key()值和主键盘不同,它们可能是Qt::Key_0加Qt::KeypadModifier,而你用event->key()取到的还是数字本身,但如果你没调用event->accept(),可能被其他事件处理吃掉。中文输入法在按住数字键时会把事件转为IME短信,导致keyPressEvent收不到数字。
解决:统一用event->text()获取输入字符,先判断是不是"1"~"9",再转换为数字。对中文输入法,可以在窗口初始化时设置setAttribute(Qt::WA_InputMethodEnabled, false),强制禁用输入法。还可以用QShortcut处理删除键,避免和焦点混乱冲突。
5.5 棋盘的宫线不明显,玩家看错区域
现象:9×9的表格没有粗边框,玩家很难分辨3×3宫,经常把行、列和宫搞混,检查冲突时也眼瞎。
原因:QTableWidget默认边框很细,且没有按宫分组。很多初学者只简单设置setShowGrid(true),9宫格完全看不清边界。
解决:用样式表为每个单元格设置边框。在表格所在的Widget上设置:
table->setStyleSheet( "QTableWidget::item { border: 1px solid #CCC; }" );然后对属于3×3宫边界上的单元格单独设置粗边框,可以用item(row, col)->setData(...)或重写paint。最靠谱的做法是自定义一个SudokuTableView,在paintEvent里自己画分隔宫格的粗线。这样虽然代码多一点,但显示效果更专业,也让玩家能靠视觉分宫,玩法体验显著提升。
6. 进阶:把这套代码的难度曲线和用户体验做到位
最后一章,我想讲点更“软”但实际很影响游戏质量的东西:难度分级和操作反馈。这部分不是必须的,但做了以后,你的作品才像正经游戏,而不是一个能运行的实验品。
首先要修正一个常见误解:难度不是靠挖洞数量堆出来的。两个题目都只有30个提示数,有可能一个简单到不用猜,一个困难到必须试错。更合理的难度模型是“根据可用的逻辑技巧数量来评估”,比如是否用到唯一候选数、区块摒除、唯一矩形等。但对课设来说,最简单的实现是用空格数量分级,加上“挖洞时优先挖掉容易形成多解的位置”这个约束,基本可以做出比较平滑的体验。
我在Sudoku::newGame里通常这样设计难度:
void Sudoku::newGame(Difficulty difficulty) { generateSolution(); int holes = 0; switch (difficulty) { case Difficulty::Easy: holes = 38; break; case Difficulty::Medium: holes = 48; break; case Difficulty::Hard: holes = 58; break; } // 把solution复制到gameBoard,然后挖洞 memcpy(gameBoard, solution, sizeof(gameBoard)); memset(locked, 0, sizeof(locked)); if (!digHoles(holes)) { newGame(difficulty); // 挖失败就重新生成 } }注意这里的memcpy和memset是标准库的,但newGame重入可能导致无限递归,最好把重试次数限制在3次以内。我一般写一个int retryCount,超过5次就抛异常或直接按“少挖几个洞”处理。
用户体验上,我强烈建议加三个小功能:计时器、撤回和错误次数显示。计时器给玩家紧迫感,撤回让你做错时不至于重来。撤回可以用一个操作栈,记录每次setValue之前的坐标和值,Ctrl+Z时出栈恢复。这里有个细节:如果玩家输入错了,撤回应该让他回到这个人自己的输入状态,而不是直接回到题目状态,所以栈里也要存“清空”操作。错误次数可以在updateConflictDisplay里统计冲突数,但注意同一个格子连续冲突不要重复计数。
可视化主题也是容易被低估的点。你不需要做多炫的皮肤,只要把“提示数、玩家输入、冲突、选中”四种状态的配色统一成一套颜色常量,并且放在一个Style命名空间里,以后想换主题只需要改几个颜色值,而不是在全项目里搜索#FF0000。我曾经为了省事在代码里硬写十几个颜色色值,最后改配色的时候简直想哭。把颜色收敛成常量,维护成本会大幅下降。
验证你的生成器是否可靠,可以写一个小测试:连续生成50个Hard难度的题目,每个都检查countSolutions == 1,同时统计生成时间。如果平均生成时间超过1秒,说明算法还有优化空间。我常用的优化是挖洞时优先尝试位置更“中心”的格子——这种位置对唯一解的破坏力更大,能更快挖出目标洞数,减少重试次数。
最后我想起一个印象很深的教训:不要试图在checkConflict里顺便做“高亮冲突”的逻辑。把“检测冲突”和“界面显示冲突”彻底分开,否则你一旦想加“只排除错一个数字”的新玩法,就得把界面代码挖个底朝天。宁可让逻辑层多往外暴露几个查询接口,也别让UI直接读取内部棋盘数组。这个原则保住了我后面很多次改动。希望帮到你。
本文还有配套的精品资源,点击获取