ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Qt数独游戏开发实战:从架构设计到唯一解生成与自绘棋盘

Qt数独游戏开发实战:从架构设计到唯一解生成与自绘棋盘

简介:这份资源是一套基于 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 而不是 QML

2.1 功能需求拆成四层,别一上来就画界面

数独游戏看着简单,需求拆不干净,写到一半就会返工。我一般按四层来拆:数据层负责 9×9 棋盘的存储、合法性校验、求解与生成;逻辑层负责游戏流程,包括新开一局、填数、擦除、提示、撤销、计时、判胜;表现层负责棋盘绘制、数字键盘、状态栏、难度选择;持久层负责把当前进度和最佳成绩存到本地,关掉再打开能接着玩。这四层对应到代码里就是SudokuBoard(纯数据,不碰 UI)、GameController(流程调度)、MainWindow与自定义BoardWidget(界面)、SettingsStore(QSettings 封装)。

把数据层做成不依赖 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++ 注册类型的成本不划算。随机数用QRandomGenerator,Qt 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验一次唯一性,不唯一就填回去。挖空数量按难度给个区间,比如简单 35~40、中等 45~50、困难 52~56,具体数值可以调。

3.3 撤销栈与计时:状态快照比命令模式更省事

撤销功能有两种实现:命令模式(记录每一步操作,反向执行)和快照模式(每次操作前存一份棋盘副本)。数独棋盘只有 81 个 int,一份快照 324 字节,存 100 步也才 30 多 KB,完全没必要上命令模式。我一般用QVector<Snapshot>当栈,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 表示数字 i+1 已在该行/列/宫出现,判断合法性就是三次按位与,填数就是三次按位或,回溯时再异或回去。这套写法在数独这种小规模问题上提升明显,代码也不复杂。

// 位掩码版回溯:mask 的第 b 位表示数字 b+1 是否已使用 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 题目生成:先造终盘再挖空,唯一性是底线

生成分两步。第一步造完整终盘:把 1~9 按(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; QVector<int> 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,数字键 1~9 发填数、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.55~0.65 之间都行。

4.4 控制器与持久化:把流程和存储收口到一处

GameController是唯一能改棋盘的地方,所有用户操作都走它的槽。inputNumber(int n)的逻辑是:有选中格、该格非 fixed、当前不在暂停态,才压快照、写值、发boardChanged,然后检查是否填满且无冲突,是则停表发gameWon。hint()从求解结果里取当前选中格(没选中就取第一个空格)的正确值填进去,同样压栈。持久化用QSettings,键名设计成game/grid(81 个字符的字符串,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一律传limit=2,数到两个解立刻返回;挖空循环里一旦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 数独从“能跑”推到“能交、能讲、能改”。

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

返回列表