C++数独游戏GUI开发实战:从算法到Qt界面完整指南
简介压缩包内含一个基于C的数独游戏GUI完整工程面向初学C、希望结合算法与界面编程的开发者也适合作为课程设计或毕业设计的参考源码。该rar包共9个文件包括cpp源文件、dsw/dsp工程文件以及ncb/opt/pch等编译辅助文件整体仅24KB代码量不大适合逐行阅读。已有360人学习浏览。源码使用9x9二维数组表示盘面将空格设为0借助回溯法逐格尝试数字并通过行、列、宫格的约束传播提前排除非法选项核心逻辑清晰。图形界面采用事件驱动模式可处理鼠标点击和数字输入涵盖新游戏生成、填数合法性检查、完成判断等交互。此外还涉及棋盘数据的文件读写和异常输入提示能够帮助读者把C语法、经典搜索算法与GUI程序设计综合起来是一份紧凑而完整的练习项目。1. 从 shudu.rar 到能玩的小游戏C 数独游戏 GUI 值得自己写一遍如果你在网盘里下载过标题类似 shudu.rar 的压缩包解压后多半是几个 .cpp、.h 文件和一张截图赶上手气差时连运行都跑不起来。数独游戏 GUI 写起来并不神秘核心就两件事让程序自己能生成唯一解的题目、能校验玩家填的数再用图形界面把九宫格画出来、把交互接起来。它能把你学过的 C 数组、递归、信号槽、线程串成一条完整主线也是课设和简历项目里最容易讲清楚的小东西。这篇文章按我实际开发的顺序来写先用终端把数独算法跑通再选 GUI 框架最后接界面、排坑。适合 C 刚入门、第一次碰 GUI 编程的读者也适合已经写过终端版、正卡在界面层的开发者。2. 数独核心逻辑是地基先让程序学会出题和解题2.1 为什么先写终端版算法和界面不该同时调试很多教程上来就铺界面结果回溯几十毫秒还是几秒都看不出来因为所有耗时都被 GUI 的黑匣子遮住了。我一般会把数独核心单独写成一个不依赖任何图形框架的类在终端里用 main 函数直接验证。这样既能确认算法正确后面接 Qt 时也只需要关注界面这一层。写终端验证代码不需要任何额外依赖先建一个sudoku_core.h然后在 main 里跑一遍完整流程// main.cpp 终端验证生成终盘 - 挖洞 - 求解 #include sudoku_core.h #include cstdio int main() { SudokuCore core; std::vectorstd::vectorint full, puzzle; if (!core.generateFinished(full)) { printf(generate failed\n); return 1; } core.digPuzzle(full, puzzle, 40); printf(puzzle:\n); for (int i 0; i 9; i) { for (int j 0; j 9; j) printf(%2d, puzzle[i][j]); printf(\n); } return 0; }这里的generateFinished、digPuzzle在下面两节实现。先跑通这个文件确认题面能打印出来再继续。很多人跳过这步直接写界面最后报错时根本分不清是回溯逻辑错了还是信号槽连接写错了。2.2 用回溯生成完整终盘让随机性来自候选数字乱序生成终盘常见两种思路一种是先写一行模板再通过行列交换生成基盘速度快但随机性偏弱另一种是从空盘开始按随机候选数做深度优先回溯。我推荐后者因为它和后面求解器的结构几乎一样写一次能复用两处。核心代码放在SudokuCore类里// sudoku_core.h #include vector #include random #include algorithm class SudokuCore { public: SudokuCore() : rng(std::random_device{}()) {} bool generateFinished(std::vectorstd::vectorint board) { board.assign(9, std::vectorint(9, 0)); return fill(board); } private: std::mt19937 rng; bool isValidMove(const std::vectorstd::vectorint board, int row, int col, int num) { for (int i 0; i 9; i) { if (i ! col board[row][i] num) return false; if (i ! row board[i][col] num) return false; } int br (row / 3) * 3; int bc (col / 3) * 3; for (int i br; i br 3; i) for (int j bc; j bc 3; j) if ((i ! row || j ! col) board[i][j] num) return false; return true; } bool fill(std::vectorstd::vectorint board) { int row -1, col -1; for (int i 0; i 9 row -1; i) for (int j 0; j 9 row -1; j) if (board[i][j] 0) { row i; col j; } if (row -1) return true; std::vectorint digits {1,2,3,4,5,6,7,8,9}; std::shuffle(digits.begin(), digits.end(), rng); for (int d : digits) { if (isValidMove(board, row, col, d)) { board[row][col] d; if (fill(board)) return true; board[row][col] 0; } } return false; } };逻辑很简单fill每次找第一个空格没空格说明整盘填满返回 true候选数字先随机打乱再逐个试。isValidMove同时检查行、列、宫格其中br (row / 3) * 3算的是所在宫的左上角行号bc同理。注意循环里用i ! col和i ! row跳过当前位置这样即使board[row][col]暂存着旧值也不会把自己误判成冲突。参数说明generateFinished的入参是二维 vector 引用函数内部会先assign清空所以调用方不需要预填 0。rng用std::random_device初始化正常情况下每次运行会拿到不同种子生成不同终盘random_device 失效的坑放到避坑章节讲。2.3 挖洞生成题面难度不只看空格数量终盘有了下一步就是挖洞摘掉部分数字让盘面变成玩家看到的题面。挖洞最关键的前提是题目必须唯一解否则玩家填到最后可能发现两种填法都合法整个游戏失去意义。挖洞的常规做法是把 81 个格子随机排序逐个尝试置为 0每挖一个洞就调用求解器检查解的数量解不唯一就填回去。这样做能保证唯一解但耗时跟后面的求解器性能强相关。// 声明统计解数量limit 是为了提前退出 int countSolutions(std::vectorstd::vectorint board, int limit); // 成员函数需要放在 SudokuCore 类里以便访问 rng void digPuzzle(const std::vectorstd::vectorint fullBoard, std::vectorstd::vectorint puzzle, int cellsToRemove) { puzzle fullBoard; std::vectorstd::pairint,int pos; for (int i 0; i 9; i) for (int j 0; j 9; j) pos.push_back({i, j}); std::shuffle(pos.begin(), pos.end(), rng); int removed 0; for (auto [r, c] : pos) { if (removed cellsToRemove) break; int backup puzzle[r][c]; puzzle[r][c] 0; if (countSolutions(puzzle, 2) ! 1) { puzzle[r][c] backup; } else { removed; } } }countSolutions的返回值约定0 表示无解1 表示唯一解2 表示至少两个解。这里传limit2意思是只要发现第二个解就立刻返回 2不用把棋盘翻个底朝天挖洞性能能快不少。关于难度新手最容易犯的错是把难度等同于空格数。去掉 40 个洞的题目不一定比去掉 35 个更难因为有些题面留给玩家的推理路径很直白有些则需要高级技巧。入门阶段可以按挖洞数量粗分简单约 35 个中等约 45 个困难约 55 个。更严谨的分级需要统计解题过程中用到的技巧类型这个放到最后一章展开。2.4 求解器与局面校验一个裁判三种用法挖洞阶段和后面 GUI 的“检查答案”“提示一格”功能都需要一个相同的裁判求解器。我写求解器时用的是和生成终盘几乎一致的回溯结构只是fill变成solveOne一旦找到一组解就返回。bool solveOne(std::vectorstd::vectorint board) { int row -1, col -1; for (int i 0; i 9 row -1; i) for (int j 0; j 9 row -1; j) if (board[i][j] 0) { row i; col j; } if (row -1) return true; for (int num 1; num 9; num) { if (isValidMove(board, row, col, num)) { board[row][col] num; if (solveOne(board)) return true; board[row][col] 0; } } return false; } int countSolutions(std::vectorstd::vectorint board, int limit) { int row -1, col -1; for (int i 0; i 9 row -1; i) for (int j 0; j 9 row -1; j) if (board[i][j] 0) { row i; col j; } if (row -1) return 1; int total 0; for (int num 1; num 9; num) { if (isValidMove(board, row, col, num)) { board[row][col] num; total countSolutions(board, limit); board[row][col] 0; if (total limit) return total; } } return total; }两个函数长得像但用途不同solveOne修改传入的 board并把第一个找到的解留在里面适合“提示”功能countSolutions不改动最终局面试填后立刻恢复 0只返回解得数量适合唯一解检查。在 GUI 里校验玩家当前填的数其实不需要完整求解只需要检查该数字在当前行、列、宫格里有没有重复。直接用前面isValidMove即可传入玩家刚填的数字。注意挖洞和生成时调用顺序不同别把这几个函数搞混。3. 选对 GUI 框架为什么我推荐 Qt 而不是 Win32 或 Web3.1 三个主流选择对比配置成本、跨平台、学习曲线数独的界面很简单但选错框架会让整个项目变成一场环境噩梦。我先做过 Win32 原生窗口消息循环、控件句柄、重绘逻辑一套下来九宫格还没画完就写了三百行而且一换电脑就编译不过。后来换 Qt界面代码量少了三分之二遇到问题也能在社区找到大量案例。我用过一个对比表直接决定选型框架学习曲线跨平台打包体积适合场景Qt (Widgets)中等Windows / Linux / macOS较大需带运行库桌面小游戏、工具软件wxWidgets较陡原生控件跨平台较大追求原生外观的桌面应用Win32 API陡控件全手写仅 Windows小练习底层机制或极简工具Web 前端套壳取决于前端功底跨平台可大可小想顺便练 HTML/CSS 的情况我的看法是如果目标是快速做出一个能玩、能展示的数独小游戏Qt Widgets 是最稳妥的路径。它自带布局系统、信号槽和成熟的调试工具不用手算控件坐标。Qt 5 和 Qt 6 在 Widgets 上差异不大本项目的代码两个版本都能跑。3.2 搭建 Qt 环境新建项目、选编译器、跑通第一个窗口环境搭建是整个项目里翻车率最高的环节。常见问题是装了 Qt 但没有选对编译器套件或者下载了不支持当前系统的组件。我的建议是走官方工具链少碰第三方 “xx 汉化版” 的打包环境。操作步骤大致是先从 Qt 官网下载在线安装器安装时勾选 Qt Creator 和你需要的一个套件套件要么是 MinGW 64-bit要么是 MSVC 64-bit选一个就行。如果你本机装了 Visual StudioMSVC 套件会和它匹配如果你希望免装 VS就选 MinGW。接着新建 Qt Widgets Application项目名随便取类名默认MainWindow即可直接运行。默认生成的项目里有一个空的main.cpp#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow w; w.show(); return app.exec(); }QApplication管理界面资源MainWindow是我们后面放棋盘的地方。跑通空窗口后再回到第 2 章把sudoku_core.h加进工程这样核心算法和界面分开编译排查问题时才不用两头猜。3.3 棋盘界面拆解9x9 单元格、候选数与状态栏一个数独界面拆开就三块81 个格子构成的棋盘、几个操作按钮加一条显示当前状态的状态栏。格子用什么控件我推荐QLineEdit而不是QSpinBox因为 QSpinBox 不允许空置玩家想清空一个格子时会很难受。QLineEdit 用 validator 限制输入 1-9又允许空白是最合适的单元格选择。先不看完整代码单看初始化棋盘的骨架// MainWindow 构造函数片段 SudokuWindow::SudokuWindow(QWidget *parent) : QMainWindow(parent) { QWidget *central new QWidget(this); QGridLayout *outer new QGridLayout(central); cells.resize(9, std::vectorQLineEdit*(9, nullptr)); for (int br 0; br 3; br) { for (int bc 0; bc 3; bc) { QFrame *box new QFrame(central); box-setFrameShape(QFrame::StyledPanel); QGridLayout *inner new QGridLayout(box); inner-setSpacing(2); for (int r 0; r 3; r) for (int c 0; c 3; c) { int row br * 3 r; int col bc * 3 c; QLineEdit *cell new QLineEdit(box); cell-setFixedSize(48, 48); cell-setAlignment(Qt::AlignCenter); cell-setMaxLength(1); cells[row][col] cell; inner-addWidget(cell, r, c); } outer-addWidget(box, br, bc); } } setCentralWidget(central); }这段代码用两层网格实现宫格分组外层 3x3 放QFrame每个 QFrame 内再用 3x3 网格放 9 个 QLineEdit。好处是宫格线天然清晰样式表的边框也更好控制。cells[row][col]这个二维数组后续访问任意格子都非常方便。提示这里先不接信号和输入限制只把骨架立起来。确认棋盘能显示再继续否则一次性写太多代码界面一黑根本不知道是哪行的问题。4. 把算法接进界面数独游戏 GUI 的最小可玩版本4.1 用 QGridLayout 渲染棋盘固定大小、输入限制与宫格分组3.3 的骨架已经能画出 81 个格子但玩家现在还能输入字母。要做的就是给每个格子加输入校验和键盘监听。Qt 里的 validator 是首选它能在输入层面直接挡住非法字符。Qt5 下用QRegExpValidatorQt6 则改成QRegularExpressionValidator代码差别不大#include QRegularExpressionValidator // 初始化单个格子时调用 QRegularExpressionValidator *validator new QRegularExpressionValidator(QRegularExpression([1-9]), cell); cell-setValidator(validator);正则[1-9]表示只允许单个 1 到 9 的字符空字符串也放行因为校验器逻辑是“输入后仍能匹配整个正则才接受”空串符合起始状态玩家可以用退格删除数字。注意不要把正则写成[0-9]那样 0 会被接受而数独里根本没有 0。宫格分组已经在 3.3 用 QFrame 实现了。现在补上样式让每个格子边框清晰已固定的数字和玩家输入的数字用不同颜色区分// 固定数字用深色粗体玩家输入用蓝色 if (isFixed[row][col]) { cell-setStyleSheet(font-weight: bold; color: #333; background: #f0f0f0;); } else { cell-setStyleSheet(color: #0078d4; background: white;); }这些细节能让界面一眼看出哪些格子是题目自带、哪些是玩家填的对后续按钮逻辑也有帮助。4.2 信号与槽输入校验、错误高亮、按钮联动棋盘只是展示层真正的交互是输入。我给每个 QLineEdit 接textChanged信号一旦内容变化就读取这个格子当前的数字再拿着它去检查行、列、宫格是否有重复。冲突的格子立刻标红这是新手最有成就感的一步。// 在初始化循环里为每个格子连接信号 #include functional for (int i 0; i 9; i) { for (int j 0; j 9; j) { int fixed isFixed[i][j]; connect(cells[i][j], QLineEdit::textChanged, this, [this, i, j, fixed](const QString text) { if (m_updating) return; onCellChanged(i, j, text, fixed); }); } }m_updating是一个 bool 成员变量它的作用在避坑章节细讲简单说就是防止程序自己 setText 时再次触发校验造成死循环。onCellChanged内部会取出当前棋盘值调用isValidMove检查冲突void SudokuWindow::onCellChanged(int row, int col, const QString text, bool fixed) { if (fixed) return; // 题目自带数字不参与校验 if (text.isEmpty()) { cells[row][col]-setStyleSheet(color: #0078d4; background: white;); return; } int num text.toInt(); std::vectorstd::vectorint board currentBoard(); if (!isValidMove(board, row, col, num)) { cells[row][col]-setStyleSheet(color: red; background: #ffe0e0;); } else { cells[row][col]-setStyleSheet(color: #0078d4; background: white;); } }注意这里每次都复制整块棋盘再校验九宫格才 81 个数开销完全可以忽略。高手当然可以用计数器做增量冲突检测但那要把行、列、宫的三个计数器都维护起来对初学者来说过于绕。按钮区域通常放三个新游戏、检查、提示。检查按钮可以直接复用solveOne把当前盘面复制一份求解成功就把结果和当前输入逐格比较不一样说明填错了弹 QMessageBox 提示。4.3 别让界面卡死把生成和求解放进后台线程挖洞和唯一解检测虽然通常只有几十毫秒但在较弱的 CPU 上、或者挖洞数量接近 55 时耗时可能冲到几百毫秒甚至一闪而过的卡顿。更关键的是如果玩家点“提示”时求解器回溯了很深界面就会冻结看起来像程序崩溃。常见的做法是用 QtConcurrent 把耗时任务丢到线程池再用QFutureWatcher接收结果。这样做不需要自己管理线程生命周期回调会回到主线程界面可以放心更新。#include QtConcurrent #include QFutureWatcher struct PuzzleResult { std::vectorstd::vectorint full; std::vectorstd::vectorint puzzle; }; void SudokuWindow::newGame() { int removeCount ui-difficultyCombo-currentIndex() 0 ? 35 : ui-difficultyCombo-currentIndex() 1 ? 45 : 55; QFutureWatcherPuzzleResult *watcher new QFutureWatcherPuzzleResult(this); QFuturePuzzleResult future QtConcurrent::run([removeCount]() { SudokuCore core; PuzzleResult res; core.generateFinished(res.full); core.digPuzzle(res.full, res.puzzle, removeCount); return res; }); watcher-setFuture(future); connect(watcher, QFutureWatcherPuzzleResult::finished, this, [this, watcher]() { applyPuzzle(watcher-result()); watcher-deleteLater(); }); }提示QtConcurrent 属于 Qt Concurrent 模块需要在.pro文件里加一行QT concurrent。这里removeCount由难度下拉框决定实现了难度分级的基础版。后台线程会带来一个坑如果玩家在生成过程中直接关窗口回调可能访问已经销毁的控件。稳妥做法是在窗口关闭时把 watcher 取消或者让 watcher 以 this 为父对象这样窗口销毁时 watcher 也会被销毁但后台任务仍在跑它返回后 lambda 里applyPuzzle不会再执行因为 watcher 已删finished 信号不会发出去所以这个写法是安全的。5. 避坑与排查写数独 GUI 最容易翻车的五个细节5.1 现象每次启动游戏题目几乎一模一样原因std::random_device不是在所有平台都能给出真正随机的种子某些老版本 MinGW 环境下它会退化成固定值。第一次跑没事第二次跑还是同一套题浪费了大半天。解决给 rng 加一个混合种子用当前时间兜底。构造函数改成这样SudokuCore() { std::random_device rd; auto now std::chrono::high_resolution_clock::now(); auto nanos std::chrono::duration_caststd::chrono::nanoseconds( now.time_since_epoch()).count(); rng.seed(rd() ^ static_castunsigned(nanos)); }原理是拿随机设备和纳秒时间戳做异或即使 random_device 退化时间戳也能让每次运行的种子不同。这个改动放第 2.2 节可能不明显但到 GUI 阶段你会特别感谢它。5.2 现象点“新游戏”或“提示”后窗口直接灰掉标题栏写着“无响应”原因耗时任务跑在了主线程。挖洞需要反复调用countSolutions一次回溯到底可能要遍历上百万个节点几十毫秒的卡顿在用户眼里就是“程序死了”。这不是死循环是界面线程被占住了。解决回到 4.3 的做法把生成、求解逻辑全部放进QtConcurrent::run界面线程只负责接收结果。判断标准很简单任何调用了solveOne或digPuzzle的按钮事件都不该直接在主线程里执行。5.3 现象明明是对的题面程序却提示“该格子有冲突”或者求解器返回无解原因宫格坐标算错。最常见的是把br (row / 3) * 3写成br row % 3 * 3或者宫格循环里没有跳过(row, col)自身。这类错误在终端版里可能被巧合掩盖一到 GUI 的多盘面切换就暴露。解决先把isValidMove独立出来针对行冲突、列冲突、宫格冲突各写一个最小测试。比如把(0,0)填 5又把(0,3)填 5isValidMove必须返回 false。用调试器看一次宫格坐标(br,bc) ( (row/3)*3, (col/3)*3 )这是 3x3 宫格左上角的起始坐标后续循环里i br 3才能固定走 3 行。5.4 现象程序自动填入题目时所有格子都被标红或者文本被吃掉一部分原因程序内setText也会触发textChanged信号然后onCellChanged又反过来修改样式表甚至再次setText形成循环。更隐蔽的是你加载题面时逐格填数字填到一半时下一格判断的是“上一格的临时状态”校验就会误报。解决用一个m_updating布尔标志包裹所有程序内部的批量更新void SudokuWindow::applyPuzzle(const std::vectorstd::vectorint puzzle) { m_updating true; for (int i 0; i 9; i) for (int j 0; j 9; j) { cells[i][j]-setText(puzzle[i][j] ? QString::number(puzzle[i][j]) : QString()); } m_updating false; }textChanged回调里看到m_updating为 true 就立刻 return中断这次连锁反应。这个标志是解决 Qt 信号循环的通用套路不只在数独里用。5.5 现象程序在自己电脑能跑拷给朋友就报“缺少 VCRUNTIME140.dll”或“找不到 Qt5Core.dll”原因Qt 程序动态链接了一堆运行库直接把 exe 拷走当然不行。Qt 官方的解决思路是用部署工具把依赖的 DLL 收集到同一个目录但很多人不知道 MSVC 套件编译出的程序还需要目标机器安装 Microsoft Visual C Redistributable。MinGW 套件相对少这个依赖但也得带头文件和动态库。解决Release 模式编译后打开 Qt 自带的命令行工具进入 exe 所在目录执行windeployqt 你的程序名.exe。它会自动复制 Qt 相关 DLL、插件和样式表。如果程序在中文路径下跑部署时可能会漏掉目录这也是经常被忽视的细节。完成后整个文件夹一起打包再交给别人就很少出问题。6. 从能玩到好用难度分级、存档和提示的进阶改造前面 4.3 已经用removeCount按 35 / 45 / 55 分了三个难度。更真实的分级会复杂些两个都是 45 个空格的题面可能一个只用唯一候选就能解出另一个却要用区块排除玩家体感差距很大。如果你想做得更像商业产品可以给digPuzzle加一个“最大回溯深度”统计挖洞时每尝试一个数字如果求解时第三层以内就出现候选唯一路径算简单如果回溯到七层以上才算困难。这个统计不需要新算法在countSolutions里加个递归深度参数就能顺带记录。存档功能我用的是QSettings把棋盘和计时器状态写成一组键值对配置键存储内容sudoku/board当前完整的 9x9 数字数组sudoku/timer剩余时间或已用秒数sudoku/difficulty当前难度等级读取时QSettings::value换成QVariant::toInt再逐格填回界面。要注意存盘前把isFixed也存下来否则重开后分不清哪些是题目数字哪些是玩家填的。提示功能是个很容易做顺手的小技巧复制当前盘面调用solveOne得到完整解然后取玩家光标所在格子的正确数字填进去。这看起来完全自动但别忘了把m_updating置为 true否则又触发一轮校验标红。我自己做这类小游戏时习惯把SudokuCore保持成纯 C不包含任何 Qt 头文件。这样做的好处是可以在不启动界面的情况下快速跑算法回归测试后期想换界面框架也不用动核心代码。这个习惯帮我少走了不少弯路。如果你也在整理自己的 C 课设建议从第一行开始就把算法和界面分开等到接 GUI 那天你会发现一切都顺理成章。希望这些细节能帮到你。本文还有配套的精品资源点击获取