简介这份资源是一套基于 Qt 框架实现的数独桌面游戏完整工程面向具备 C 基础、希望借助小项目熟悉 Qt 开发流程的初学者与进阶者。项目覆盖 Qt Designer 界面布局、信号与槽通信、QWidgets 与 QLayout 布局管理、MVC 分层设计以及数独盘面生成、答案校验、存档读取、自定义背景与多语言切换等模块可帮助读者理解如何把游戏逻辑融入简洁直观的图形界面。压缩包共 141 个文件约 25.6MB以 21 个 cpp 源文件、15 个 h 头文件、8 个 ui 界面文件为核心辅以 png 图片素材、qrc 资源文件、pro 工程配置及编译中间产物结构完整、便于直接构建运行。目前已有 2562 人学习下载适合作为 Qt 入门练手与课程设计参考读者可从中获取可运行的工程骨架、界面与逻辑分离的组织方式以及调试排错思路。1. 从“能跑就行”到“能交作业”Qt 数独游戏到底要解决什么很多同学接到“用 Qt 写个数独游戏”这个题目第一反应是打开 Designer 拖 81 个按钮然后卡在“怎么判断这一格能不能填”上。真正做过一遍的人都知道数独项目的难点从来不是界面而是题目生成、唯一解校验、状态管理这三件事界面只是把它们串起来的壳。这套方案面向两类人一是要交课程设计、毕设开题报告的同学需要一套结构完整、能讲清楚设计取舍的工程骨架二是想拿 Qt 练手桌面开发的从业者想要一个不依赖第三方库、纯 Qt Widgets 就能跑起来的完整小项目。下面按“需求怎么立、架构怎么分、模块怎么写、坑怎么避、最后怎么验收答辩”的顺序展开代码和参数都可以直接照着改。2. 需求与技术选型为什么是 Qt Widgets 而不是 QML2.1 功能需求拆成四层别一上来就画界面数独游戏看着简单需求拆不干净写到一半就会返工。我一般按四层来拆数据层负责 9×9 棋盘的存储、合法性校验、求解与生成逻辑层负责游戏流程包括新开一局、填数、擦除、提示、撤销、计时、判胜表现层负责棋盘绘制、数字键盘、状态栏、难度选择持久层负责把当前进度和最佳成绩存到本地关掉再打开能接着玩。这四层对应到代码里就是SudokuBoard纯数据不碰 UI、GameController流程调度、MainWindow与自定义BoardWidget界面、SettingsStoreQSettings 封装。把数据层做成不依赖 Qt 界面模块的纯 C 类是这套方案最关键的一个决定。好处很直接求解器和生成器可以单独写单元测试不用起窗口后面想换界面比如换成 QML 或者命令行版逻辑一行不用动。很多同学把校验逻辑写在按钮的槽函数里结果“提示”功能和“判胜”功能要复制一遍同样的代码改一处漏一处这就是需求没分层带来的血泪经验。2.2 技术栈与版本约束只用 Qt 自带的东西整套方案只依赖 Qt 5.15 或 Qt 6.x 的 Core、Gui、Widgets 三个模块构建系统用 CMake不引入任何第三方数独库或随机数库。选 Widgets 而不是 QML 的理由很实际数独棋盘是规整的 9×9 网格用QGridLayout摆 81 个控件或者干脆自绘都行Widgets 的信号槽模型对“点击格子→选中→按键填数”这种事件流表达得最直白QML 虽然动画好看但对交课程设计的同学来说多学一套声明式语法和 C 注册类型的成本不划算。随机数用QRandomGeneratorQt 5.10 之后自带比rand()的可控性好种子可以固定方便复现问题。模块依赖选型理由界面Qt Widgets网格布局直接事件模型简单构建CMake ≥ 3.16Qt 官方推荐跨平台一致随机QRandomGenerator可设种子便于调试复现存储QSettings免建表注册表/ini 自动适配测试Qt Test随 Qt 安装不额外引依赖提示如果课程要求必须用 qmake把 CMakeLists 换成 .pro 即可源文件列表和模块依赖QT core gui widgets是一一对应的逻辑代码不用改。2.3 难度不是“挖的格子多”而是“解题需要的推理步数”这是最容易被误解的一点。新手做难度分级通常按“挖掉多少个数字”来分挖 40 个叫简单、挖 55 个叫困难。问题是挖得多不等于难有些盘面挖掉 55 个仍然一路唯一候选数推到底有些只挖 45 个却必须用区块摒除甚至 X-Wing 才能推进。合格的做法是生成完整解之后用人类解题技巧模拟器去解按用到的最高级技巧给盘面打难度标签——只用唯一候选数能解完的算简单需要隐性唯一/区块摒除的算中等需要更高级技巧或者回溯次数超过阈值的算困难。实现上不必做全套技巧退一步用“求解器回溯节点数”当难度代理指标也能交差节点数少于某阈值算简单超过算困难中间算中等。这个指标不完美但比数空格数靠谱得多答辩时也讲得清楚。3. 总体架构设计四个类撑起整个项目3.1 类职责划分与信号槽走向整个项目控制在四个核心类加一个入口规模刚好既不会写成一个大文件也不会为了分层而分层。SudokuBoard持有int grid[9][9]0 表示空和bool fixed[9][9]题目给定格提供isValid、solve、generate、countSolutions四个纯函数式接口。GameController持有棋盘、计时器、撤销栈对外暴露newGame(difficulty)、inputNumber(n)、erase()、hint()、undo()等槽对内把棋盘变化翻译成信号boardChanged、cellConflict、gameWon。BoardWidget继承QWidget重写paintEvent自绘棋盘把鼠标点击换算成行列后发cellClicked(row, col)。MainWindow负责组装菜单栏、难度下拉、数字键盘、状态栏把各信号槽连起来。信号槽的走向要单向避免循环触发。我的习惯是界面只发“用户意图”点了哪格、按了哪个数字控制器只发“状态结果”棋盘变了、某格冲突了、赢了界面收到结果再重绘。绝不让BoardWidget直接改棋盘数据也不让控制器去调update()。这条规矩能省掉大量“界面和数据不同步”的玄学 bug。3.2 棋盘数据结构为什么用两个二维数组而不是一个结构体数组有人喜欢定义struct Cell { int value; bool fixed; bool conflict; }然后开Cell cells[9][9]。这样写看着面向对象实际用起来别扭求解器做回溯时要频繁复制整个棋盘结构体数组的拷贝开销和缓存局部性都不如两个紧凑的int/bool数组而且conflict是界面态不是数据态冲突与否完全可以从当前 grid 推导出来存进数据里反而要维护一致性。所以我的做法是数据层只存grid和fixed冲突格在渲染时现算某格非空且与同行/同列/同宫的其他格重复就标红。这样撤销、提示、重开都不会留下脏状态。// sudokuboard.h —— 纯数据层不 include 任何 Qt Widgets 头文件 class SudokuBoard { public: static constexpr int N 9; void clear(); bool setValue(int r, int c, int v); // 只允许改非 fixed 格 int value(int r, int c) const; bool isFixed(int r, int c) const; bool isValid(int r, int c, int v) const; // 行/列/宫三重校验 bool solve(); // 回溯求解就地填充 int countSolutions(int limit 2); // 数解个数用于唯一性校验 bool generate(int difficulty); // 生成完整解后按难度挖空 private: int grid[N][N]{}; bool fixed[N][N]{}; bool solveRec(int pos); };setValue里要挡住对fixed格的写入这是防止“题目给的数字被玩家改掉”的第一道闸。countSolutions带limit参数是个实用技巧校验唯一解时不需要知道到底有几个解数到 2 就可以提前返回生成一局的时间能从几百毫秒降到几十毫秒。generate的流程是先随机填满对角线三个宫这三个宫互不冲突可以随便填再整体回溯求出完整解最后随机顺序尝试挖空每挖一个就用countSolutions验一次唯一性不唯一就填回去。挖空数量按难度给个区间比如简单 3540、中等 4550、困难 5256具体数值可以调。3.3 撤销栈与计时状态快照比命令模式更省事撤销功能有两种实现命令模式记录每一步操作反向执行和快照模式每次操作前存一份棋盘副本。数独棋盘只有 81 个 int一份快照 324 字节存 100 步也才 30 多 KB完全没必要上命令模式。我一般用QVectorSnapshot当栈Snapshot里放grid副本和当前选中格inputNumber、erase、hint之前先压栈undo时弹栈恢复。注意hint也要进栈否则玩家用提示填了数再撤销会撤到提示之前的状态逻辑上说不通。计时用QTimer每秒发一次timeout控制器里存elapsedSeconds暂停时停表。这里有个小坑QTimer的精度受事件循环影响长时间运行会累积误差所以不要用“定时器触发次数”当秒数而是记录开始时间戳每次timeout用QDateTime::currentMSecsSinceEpoch()减去开始时间算真实秒数暂停时把已用时长累加进accumulated。这样切到后台、系统休眠再回来时间也是对的。4. 核心模块实现生成器、求解器与自绘棋盘4.1 回溯求解器位运算加速合法性判断求解器是整个项目的心脏写得好不好直接决定生成一局要等 0.1 秒还是 3 秒。朴素写法是每次填数都扫一遍行、列、宫81 格每格试 9 个数最坏情况开销不小。常规优化是用三个位掩码数组rowMask[9]、colMask[9]、boxMask[9]第 i 位为 1 表示数字 i1 已在该行/列/宫出现判断合法性就是三次按位与填数就是三次按位或回溯时再异或回去。这套写法在数独这种小规模问题上提升明显代码也不复杂。// 位掩码版回溯mask 的第 b 位表示数字 b1 是否已使用 bool SudokuBoard::solveRec(int pos) { if (pos 81) return true; int r pos / 9, c pos % 9; if (grid[r][c] ! 0) return solveRec(pos 1); // 已填格直接跳过 int b (r / 3) * 3 c / 3; // 宫编号 0~8 for (int v 1; v 9; v) { int bit 1 (v - 1); if ((rowMask[r] | colMask[c] | boxMask[b]) bit) continue; grid[r][c] v; rowMask[r] | bit; colMask[c] | bit; boxMask[b] | bit; if (solveRec(pos 1)) return true; grid[r][c] 0; // 回溯撤销三处掩码 rowMask[r] ~bit; colMask[c] ~bit; boxMask[b] ~bit; } return false; }三个掩码要在solve()入口根据当前grid初始化countSolutions复用同一套递归但改成计数并在达到limit时剪枝。参数上唯一要调的是递归顺序按pos从 0 到 80 顺序填遇到已填格跳过简单够用想再快可以改成“每次选候选数最少的空格”但那属于锦上添花课程设计层面顺序填已经足够。失败时看什么如果生成一局卡住超过两秒八成是countSolutions的limit没设或者挖空循环没做“不唯一就回填”的处理导致在无解盘面上反复重试。4.2 题目生成先造终盘再挖空唯一性是底线生成分两步。第一步造完整终盘把 19 按(r*3 r/3 c) % 9 1的公式填出一个合法基盘然后对数字做随机置换、对行做宫内随机交换、对列做宫内随机交换、对三个行带做整体交换、对三个列带做整体交换这五类变换都能保持数独合法性组合起来能产出足够多样的终盘而且比纯回溯快得多。第二步挖空把 81 个位置打乱顺序依次尝试置 0每置一个就调countSolutions(2)返回值小于 2 才保留否则恢复。挖到目标数量或者遍历完就停。bool SudokuBoard::generate(int difficulty) { // 1) 造终盘基盘 随机置换 行/列/带交换 buildBaseSolution(); shuffleDigits(); shuffleRowsWithinBand(); shuffleColsWithinStack(); shuffleBands(); shuffleStacks(); // 2) 挖空目标数量按难度取区间中值 int target (difficulty 0) ? 38 : (difficulty 1) ? 48 : 54; QVectorint order(81); std::iota(order.begin(), order.end(), 0); std::shuffle(order.begin(), order.end(), QRandomGenerator::global()-generate()); int removed 0; for (int idx : order) { if (removed target) break; int r idx / 9, c idx % 9, bak grid[r][c]; grid[r][c] 0; if (countSolutions(2) ! 1) grid[r][c] bak; // 破坏唯一性就回填 else removed; } rebuildFixedFlags(); // 剩下的非空格标记为 fixed return removed target - 2; // 允许差两格避免极端盘面挖不满 }target那三个数字是经验值简单档挖 38 个左右玩家用唯一候选数就能推完困难档挖 54 个基本要动点脑子。removed target - 2这个容差是必要的某些随机终盘在挖到目标前就挖不动了硬卡精确值会让生成函数偶尔返回失败界面上表现为“点了新游戏没反应”这是很典型的翻车点。rebuildFixedFlags在挖空结束后统一把非空格标成fixed保证玩家只能改空格。4.3 自绘棋盘paintEvent 里画格、画数、画冲突81 个QPushButton的方案能跑但样式难统一、焦点管理麻烦、重绘开销大。自绘一个BoardWidget更干净paintEvent里按width()/9算格宽先画 9×9 细线再画 3×3 粗线用QPen设 2px然后遍历棋盘画数字fixed格用深色粗体、玩家填的用蓝色、冲突格用红色背景。鼠标事件里(event-pos().x()) / cellW换算行列发cellClicked信号。键盘事件重写keyPressEvent数字键 19 发填数、Backspace/Delete 发擦除、方向键移动选中格。void BoardWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); int cell qMin(width(), height()) / 9; // 冲突格先铺红底冲突判定现算不存进数据层 for (int r 0; r 9; r) for (int c 0; c 9; c) if (m_board-value(r, c) hasConflict(r, c)) p.fillRect(c * cell, r * cell, cell, cell, QColor(255, 220, 220)); // 选中格高亮 if (m_selRow 0) p.fillRect(m_selCol * cell, m_selRow * cell, cell, cell, QColor(200, 225, 255)); // 网格线细线 1px宫界线 2px for (int i 0; i 9; i) { p.setPen(QPen(Qt::black, (i % 3 0) ? 2 : 1)); p.drawLine(i * cell, 0, i * cell, 9 * cell); p.drawLine(0, i * cell, 9 * cell, i * cell); } // 数字fixed 深色粗体玩家输入蓝色 QFont f p.font(); f.setPixelSize(cell * 0.6); p.setFont(f); for (int r 0; r 9; r) for (int c 0; c 9; c) { int v m_board-value(r, c); if (!v) continue; p.setPen(m_board-isFixed(r, c) ? Qt::black : QColor(30, 90, 200)); p.drawText(QRect(c * cell, r * cell, cell, cell), Qt::AlignCenter, QString::number(v)); } }hasConflict是BoardWidget的私有辅助函数扫同行、同列、同宫找重复值只在绘制时调用不缓存。cell用qMin(width(), height()) / 9保证窗口拉伸时棋盘始终是正方形多余空间留白比强行铺满好看。参数上唯一要注意的是f.setPixelSize(cell * 0.6)比例太大数字会顶格太小看不清0.550.65 之间都行。4.4 控制器与持久化把流程和存储收口到一处GameController是唯一能改棋盘的地方所有用户操作都走它的槽。inputNumber(int n)的逻辑是有选中格、该格非 fixed、当前不在暂停态才压快照、写值、发boardChanged然后检查是否填满且无冲突是则停表发gameWon。hint()从求解结果里取当前选中格没选中就取第一个空格的正确值填进去同样压栈。持久化用QSettings键名设计成game/grid81 个字符的字符串0 表示空、game/fixed、game/elapsed、stats/bestTime_easy这种结构closeEvent里存、构造时读读到合法数据就恢复否则开新局。void GameController::saveToSettings() { QString g, f; for (int r 0; r 9; r) for (int c 0; c 9; c) { g QChar(0 m_board-value(r, c)); f m_board-isFixed(r, c) ? 1 : 0; } QSettings s; // 组织名/应用名在 main 里 set 过 s.setValue(game/grid, g); s.setValue(game/fixed, f); s.setValue(game/elapsed, m_elapsed); s.setValue(game/difficulty, m_difficulty); }把 81 格压成一个 81 字符的字符串存比存 JSON 数组省事读回来QChar::digitValue还原即可。fixed单独存一份而不是从 grid 反推是因为“玩家填的数和题目给的数”在数据上必须区分反推不出来。QSettings在 Windows 写注册表、在 Linux 写~/.config下的 ini跨平台不用改代码这是它比手写文件 IO 省心的地方。5. 避坑与排查五个最容易翻车的点5.1 生成一局要等好几秒界面卡死现象点“新游戏”后窗口无响应进度条转圈简单档也要两三秒。原因generate在 UI 线程同步执行且countSolutions没设limit每个挖空位置都把整棵搜索树跑完。解决countSolutions一律传limit2数到两个解立刻返回挖空循环里一旦removed达标就break如果还慢把生成放到QtConcurrent::run里完成后用QFutureWatcher回主线程更新界面生成期间禁用“新游戏”按钮防止重入。5.2 撤销之后冲突标红不消失或者标错格现象填了个冲突数撤销回去红底还在或者两个格冲突只标了其中一个。原因把conflict状态存进了数据层撤销只恢复了grid没恢复conflict或者冲突判定只查了“新填入的格”而没查它的同行同列邻居。解决冲突状态一律不存渲染时对每个非空格现算hasConflict同行、同列、同宫任一有重复就标红这样撤销、提示、重开都不会留脏状态。这条改完那类“界面和数据不同步”的玄学问题基本绝迹。5.3 玩家能改题目给定的数字现象点一个题目自带的数字按键盘居然能改掉改完整局判定就乱了。原因setValue没检查fixed或者BoardWidget的点击处理没把fixed格过滤掉。解决双保险——数据层setValue里if (fixed[r][c]) return false;界面层mousePressEvent里选中fixed格只高亮不允许输入。别只做一层血泪经验是总有一条路径会漏。5.4 计时器在暂停/恢复后时间对不上现象暂停十分钟再继续计时器只走了几秒或者切到别的窗口再回来时间跳变。原因用QTimer触发次数当秒数暂停时停表但没把已用时长累加恢复后从零开始数。解决存accumulated已累计秒数和startStamp本段开始的时间戳暂停时accumulated (now - startStamp)恢复时重置startStamp显示值永远是accumulated (now - startStamp)QTimer只负责触发重绘不负责计数。5.5 换台机器打开存档读不出来或者盘面错乱现象在自己电脑上存了档换台机器或者换个 Qt 版本读回来棋盘是乱的。原因QSettings没设组织名和应用名键散落在默认位置或者存档字符串长度不是 81读的时候没校验直接按 9×9 切。解决main里第一行就QCoreApplication::setOrganizationName和setApplicationName读取时先校验g.size() 81 f.size() 81不满足就当没有存档开新局绝不硬解析。存档格式一旦定下就别改要改就加版本号字段做迁移。6. 验收与答辩怎么证明这套数独真的能交验收这件事别等到答辩前一天才想。我一般把验收拆成“功能清单跑一遍、边界条件怼一遍、现场演示留一手”三步。功能清单按第 2 章的四层需求逐条过新开三档难度各生成 5 局每局都能玩到判胜填数、擦除、提示、撤销各操作 20 次不崩关掉重开能恢复进度最佳成绩能正确刷新。边界条件专挑恶心的情况在 fixed 格上狂点、在暂停态按数字键、撤销到开局再撤销、连续快速点新游戏、把窗口拉到极小再拉大。这些跑完基本能挡住答辩现场 80% 的意外。现场演示有个技巧提前准备一个“必现冲突”的盘面和一个“一步获胜”的盘面存成两个固定的种子或者直接写死两份存档。演示时先展示正常流程再切到冲突盘面讲冲突检测怎么实现最后切到一步获胜盘面点一下展示判胜动画和成绩记录。这样评委看到的不是“能跑”而是“你想过各种状态”。答辩提问高频集中在三处生成算法怎么保证唯一解答countSolutions挖空回填、难度怎么定义答按解题所需技巧或回溯节点数不是空格数、为什么自绘不用按钮答样式统一、焦点简单、重绘可控。这三个问题提前把答案写成三句话背熟比临场组织语言稳得多。验收项通过标准检查方式生成正确性每局有且仅有一个解对生成结果跑countSolutions(2) 1难度区分三档平均求解节点数递增各生成 20 局统计均值状态一致性撤销 50 步后棋盘与快照一致对比grid与栈底快照存档健壮性损坏存档不崩溃、自动开新局手动改坏 ini 后启动交互边界fixed 格不可改、暂停态不响应输入手工点测从那以后我每次交这类带生成算法的课程设计都强制自己先写一个“生成 100 局、每局验唯一解、统计耗时”的小测试跑通再动界面因为生成器一旦有隐蔽 bug界面做得再花哨也是空中楼阁。希望这套结构能帮你把 Qt 数独从“能跑”推到“能交、能讲、能改”。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?