MFC实战:数独游戏完整实现,从回溯算法到自绘界面

发布时间:2026/9/9 12:09:15
MFC实战:数独游戏完整实现,从回溯算法到自绘界面 简介一套基于MFC的数独游戏完整源码包源自作者参加华为编程比赛决赛时的实际需求适合学习MFC界面开发、数独求解与生成算法的C开发者参考。项目在Visual Studio 2008 SP1环境下编写实现了九宫格自由输入、读取外部布局数据、自动完成任意数独布局、自动生成难度适中布局、输入提示与游戏计时等完整功能覆盖从界面交互到核心算法的常见实现路径同时代码中针对VS2008非SP1环境的兼容性调整也有必要说明方便不同版本用户直接使用。压缩包共84个文件以h头文件、cpp源文件、sln/vcproj工程文件为主同时包含可直接运行的exe、图标资源与少量文本说明整体仅870KB目录结构直观便于快速查看和移植。资源已有563人学习对希望了解赛题级完整项目组织、算法实现或进行二次开发的人而言能直接获得可编译的工程结构与代码实现也可作为课程设计、毕业设计或数独算法练习的参考素材。 我从大二开始用MFC写Windows桌面工具数独这个项目算是比较“完整”的一个不但有从空盘生成终盘、到挖洞出题、再进行唯一解校验的完整算法链路还做了难度选择、计时、提示、错误高亮这些交互体验。前两天把整套数独游戏MFC实现完整源代码翻出来整理了一下正好借这篇博文把设计思路、核心算法、界面实现以及踩过的坑一次性讲清楚。这个项目适合两类人看一类是正在学MFC、想做课程设计或毕设的C学生另一类是想搞懂“数独生成与唯一解判定”到底怎么实现的算法爱好者。前者能从里面学会对话框程序怎么搭建、消息映射怎么走、自绘控件怎么做后者可以直接把核心算法类抽出来换到Qt、Win32甚至命令行里跑。1. 整体设计思路与方案选型1.1 为什么用MFC实现数独游戏现在写桌面小工具很多人的第一反应是Qt、C# WinForm或者Electron。但MFC在今天依然有它不可替代的场景对Win32 API的封装足够薄程序启动快、体积小、无运行时依赖静态编译前提下对于“单窗口简单交互”的工具类软件非常合适。拿数独游戏来说它的核心界面就是9x9的网格、几个按钮、一个计时器完全没有复杂控件需求。用MFC的对话框模板可以几分钟搭好主窗口剩下的工作集中在自绘棋盘的OnPaint和鼠标消息的处理上比Qt的信号槽机制更直接。而且MFC程序里可以随时调用Win32 API做个MessageBox、监听键盘、操作剪贴板都来得很顺手——这些都是学生党做课程设计时最容易被加分的点。另外学MFC本身也是一个“承上启下”的过程。你会接触到消息循环、句柄、设备上下文DC这些Windows底层概念对理解现代GUI框架的封装方式很有帮助。1.2 整体功能拆解这份源代码实现的功能比“能玩”多了一步不只是用户填数字、程序判断对错而是从空盘开始生成一个有唯一解的题目这个过程的逻辑层次是清晰的。核心逻辑层与界面无关生成完整终盘、按难度挖洞、求解校验唯一解、检测用户填入是否正确界面交互层绘制9x9棋盘、响应鼠标点击与键盘数字输入、高亮当前格与关联格、错误数字标红辅助功能层计时器、难度选择简单/中等/困难对应挖洞数量和求解剪枝不同、新游戏、提示、检查错误扩展预留存档读档接口这里用文件保存当前盘面与用时方便继续游戏我在项目里把核心逻辑单独封装为一个CSudokuCore类内部不包含任何MFC头文件。这样将来想把它换成命令行版或者移植到Linux都容易也算是一个“领域逻辑与界面分离”的典型示范。1.3 技术选型关键点自绘控件还是按钮矩阵刚开始做数独界面时很多人第一反应是放81个按钮每个按钮显示一个数字。这个方案不是不行但有几个明显问题81个按钮的创建和销毁很浪费资源消息映射要写81份或动态反射控件的边框和字体不统一会导致界面很难看而且选中高亮、同区高亮这些效果要靠按钮自绘才能实现工作量极大。我最终选择的方案是主窗口不放置任何数字按钮而是用OnPaint统一绘制整个棋盘区域把鼠标点击坐标换算成行列号第几行第几列键盘数值键直接输入到当前选中格。这样既避开了控件爆炸问题又可以让格子选中、高亮、错误标红都通过重绘来实现效果完全是按钮矩阵方案比不了的。提示如果你确实想练习“MFC自定义按钮”可以在界面下方放几个数字选择按钮1~9用BS_OWNERDRAW自绘按钮处理高亮和选中状态。这和棋盘绘制是两套逻辑能共存。2. 数独核心算法与实现细节2.1 从空盘生成完整终盘的随机回溯一个完整合法的数独终盘是指每行、每列、每个3x3宫都恰好包含1~9且不重复的矩阵。生成终盘最常用的方式是回溯法从左上角开始依次填入随机打乱的1~9数字如果发现当前数字和同行、同列、同宫已有数字冲突就换下一个数字如果1~9都不行则回溯到上一个格子重新选数。为了让每次生成的终盘都不一样关键是“随机打乱候选数字”。我在代码里没有直接用rand()%9而是把1~9放入一个数组用std::shuffle或手写的Fisher-Yates随机洗牌算法打乱顺序然后按这个顺序尝试填入。这样生成的终盘随机性更强也不会每次都出现第一行是123456789的情况。写回溯法时要注意回溯恢复现场bool SudokuCore::fillGrid(int row, int col) { if (row 9) return true; // 全部填满 int nextRow (col 8) ? row 1 : row; int nextCol (col 8) ? 0 : col 1; vectorint nums {1,2,3,4,5,6,7,8,9}; shuffle(nums.begin(), nums.end(), rng); for (int num : nums) { if (isSafe(row, col, num, board)) { board[row][col] num; if (fillGrid(nextRow, nextCol)) return true; board[row][col] 0; // 回溯恢复 } } return false; }这里isSafe的实现可以用查重数组优化不必每次循环再去扫描三行三列9次。实际代码里我维护了rowUsed[9][10]、colUsed[9][10]、boxUsed[9][10]三个布尔数组每次填数和回溯时同步更新速度能快一个数量级。不过对于9x9的固定规模纯暴力的isSafe也完全能在毫秒级跑完不必过度优化。2.2 挖洞出题与唯一解判定有了完整终盘之后题目生成就是“挖洞”把终盘里某些格子置为0变成空格让玩家来填。这个环节最核心的问题是——挖完之后必须保证只剩一个解否则玩家可能填出和标准答案不同的合法数独那游戏就没法判定了。挖洞策略有很多种常见的有随机挖洞法每次随机挑一个非空格挖掉后判断唯一解若唯一则保留否则回溯恢复和对称挖洞法挖一个格子的同时挖掉其对称位置的格子让题目更有美感。我采用的是“难度分级随机挖洞法”简单挖 40 格左右中等挖 50 格左右困难挖 56 格以上每挖一格就用求解器验证一次唯一解。如果当前盘面已经有多解就回溯恢复这一格然后尝试下一格。唯一解判定的本质是“求解并计数”。我的做法是DFS递归求解一旦发现第2个解就立即返回不再继续搜索。这样做能大幅缩短时间因为多解往往会在较浅的深度就暴露出来。2.3 用户输入校验与提示功能的设计用户输入校验是游戏体验的一部分。我做了两层即时校验用户填入数字后检查该数字在当前行、列、宫是否重复如果重复就标红并给出轻微提示不弹窗打断爽快感完成判定每次填数后调用isComplete()检查是否盘面已满且全合法满足则弹出胜利框并记录用时提示功能实现相对粗暴但有效用一次“单格求解”把当前选中格子的正确数字算出来显示到状态栏但会扣减提示次数作为代价我做的是每局3次提示用完后按钮变灰。这个设计让游戏有挑战性也避免了提示按钮变成摆设。注意求解当前格正确数字时要基于“当前盘面已有数字”求解而不是基于原始题目求解否则在玩家已经填错的情况下会得到错误提示。3. MFC界面交互与核心功能实现3.1 创建工程与界面布局对话框程序还是单文档MFC里可以做单文档SDI程序也可以做基于对话框Dialog的程序。对数独这种单窗口游戏我强烈建议用基于对话框的程序——创建工程时选“基于对话框”自动生成主对话框类和资源文件少掉一大堆文档/视图结构相关的文件代码量能少三分之一。对话框模板里我只放了少量控件左上角的“新游戏”按钮、难度组合框、计时静态文本、左下角的“提示”和“检查错误”按钮以及中间的棋盘绘制区域其实是一块空白客户区。棋盘本身不用控件直接用OnPaint在这个区域画9x9网格和数字。布局时要注意不同的屏幕分辨率下对话框内部控件位置可能错乱。我的处理效率很高——在OnInitDialog里拿到棋盘区域相对对话框的坐标然后所有棋盘绘制都基于这个坐标矩形进行不存死数值。这样窗口拉伸时只需重算一次矩形即可自适应。3.2 棋盘绘制与鼠标交互的细节实现棋盘绘制是界面部分最关键的一环。整个绘制逻辑在CSudokuDlg::OnPaint()中完成绘制大网格9x9的外框和内部粗线区分3x3宫绘制小网格每格之间的细线绘制数字已经固定的题目数字用黑色用户填入的数字用蓝色错误数字用红色绘制高亮效果选中格用浅蓝色填充同行同列同宫用浅灰色填充数字冲突的格用浅红色底鼠标交互要解决的核心问题是坐标换算。我在OnLButtonDown中拿到鼠标在窗口客户区的坐标point然后通过判断point.x和point.y是否落在棋盘矩形内计算出对应的行列void CSudokuDlg::OnLButtonDown(UINT nFlags, CPoint point) { // boardRect 为棋盘在客户区的矩形范围 if (boardRect.PtInRect(point)) { int col (point.x - boardRect.left) * 9 / boardRect.Width(); int row (point.y - boardRect.top) * 9 / boardRect.Height(); SetSelectedCell(row, col); } else { SetSelectedCell(-1, -1); // 点击棋盘外取消选中 } CDialogEx::OnLButtonDown(nFlags, point); }这里有个细节值得注意(point.x - boardRect.left) * 9 / boardRect.Width()不是先除以每格宽度而是先乘以9再除以整个棋盘宽。为什么这样写因为整数除法会丢失精度如果先除以格宽再取整边缘像素处理不好会导致“点在最右边一格却选到第8列”的偏差。乘9再除总宽相当于按比例换算避免了累计误差。键盘输入的处理是重写OnKeyDown把VK_1到VK_9的数字键映射到当前选中格的数字输入。这里要注意对话框程序里默认焦点可能在一个Button上键盘消息不会直接发到对话框。所以我设置对话框本身PreTranslateMessage来拦截数字键消息或者将按钮设为WS_TABSTOP之外不抢占焦点。3.3 自绘按钮与状态栏扩展热搜词里出现了“mfc自定义按钮”这正好是这个项目扩展的一个方向。我的代码里输时间限制的“提示”按钮做了简单自绘重写DrawItem将按钮背景改为淡黄色使能/禁用状态用不同深浅颜色区分。MFC自绘按钮的核心是给按钮设置BS_OWNERDRAW样式然后在你自己的类里重写DrawItem(LPDRAWITEMSTRUCT). 如果界面整体风格想做得更精致一些可以把所有按钮都换成自绘按钮统一加圆角和渐变背景。不过要提醒自绘按钮的消息响应逻辑和普通按钮没区别OnBnClicked照样使用别为了自绘把消息映射弄丢了。状态栏部分我用了对话框底部的静态文本控件来显示提示信息如“当前填入了错误数字”比真的在对话框里嵌一个CStatusBar轻量得多。4. 工程结构与源码组织方式4.1 类的划分与文件清单一个MFC数独工程我建议分成几个清晰的文件文件文件职责SudokuCore.h / SudokuCore.cpp核心算法生成终盘、挖洞、求解、校验、提示不依赖MFCCSudokuDlg.h / CSudokuDlg.cpp主对话框界面布局、消息处理、绘制棋盘、计时器CMyButton.h / CMyButton.cpp自绘按钮如果需要负责“提示/新游戏”等按钮的美化Sudoku.h / Sudoku.cpp应用类CWinApp派生负责程序入口Sudoku.rc资源文件对话框模板、图标、版本信息pch.h / pch.cpp预编译头文件MFC项目的标准组成部分这种分的的好处是核心算法可以被单元测试调用也可以轻松移植到其他框架。我在SudokuCore里还加了几个重载接口比如GeneratePuzzle(int difficulty)返回题目盘面GetAnswer()返回完整终盘SetPlayerValue(int row, int col, int value)供界面层调用。4.2 消息映射与界面刷新流程MFC程序运行起来就是消息循环。这个项目里用到的核心消息有WM_PAINT绘制棋盘、数字、高亮WM_LBUTTONDOWN选中格子WM_KEYDOWN键盘输入数字WM_TIMER计时器每秒刷新一次时间显示BN_CLICKED按钮点击事件新游戏、检查错误、提示绘制过程中有一个非常影响体验的点如果每次SetSelectedCell之后都调用Invalidate()全窗口重绘棋盘外的按钮和文字也会跟着闪烁。更好的做法是只刷新棋盘所在的矩形InvalidateRect(boardRect, FALSE);第二个参数FALSE表示不擦除背景重绘效率更高也避免白色闪烁。第一版代码就是因为没注意这个细节点击格子时整个窗口闪个不停后来改成局部刷新才解决。4.3 从“能玩”到“完整”计时、存档与难度选择单纯能填数字的程序只能算Demo加上计时、难度、存档才算完整游戏。我在项目里的实现方式都是比较轻量的难度选择下框框CComboBox绑定三档难度OnCbnSelchange触发时重新生成题目计时SetTimer(1, 1000, NULL)每秒触发WM_TIMER计算出时分秒显示到静态控件存档将当前盘面、题目盘面、难度、用时、提示剩余次数放心成一个文本文件用自记录简单格式下次启动时检测到存档文件就提示是否继续提示存档文件我用了UTF-8编码避免中文字符串在部分Windows系统上被乱码。MFC的CStdioFile默认按ANSI读取如果想兼容跨区域系统建议用CFile读写字节流自己处理编码转换。5. 典型问题与排查技巧实录5.1 挖洞死循环与题目生成过慢这个坑几乎所有写数独的人都会遇到。当你挖到50多个空格时格子越多棋盘约束越少随机挖一个格试一次唯一解失败的几率越大。如果简单采用“每次洗完开始从头遍历找可以挖的格”的方式到后期会发现几乎所有格子都不能挖程序看起来就像卡死了。我的对策很简单设置挖洞次数上限如连续尝试50次挖不了就放弃重新生成终盘以及遍历顺序改为随机打乱索引避免总是从(0,0)到(8,8)顺序挖导致的“前半部分太稀疏、后半部分挖不动”问题。再配合唯一解的快速剪枝一旦找到两个解立刻返回单局生成时间能控制在几十毫秒内。5.2 键盘输入法弹窗与数字输入冲突在MFC对话框里重写OnKeyDown后有一个很麻烦的问题如果焦点落在某个按钮上回车、空格会触发按钮事件数字键又会被按钮吃掉完全不经过对话框。更糟的是如果焦点在一个CEdit控件上用户按下数字键会弹出输入法软键盘或者直接输入到编辑框里游戏玩不起来。我的处理方式是在PreTranslateMessage中拦截所有键盘消息如果消息是数字键并且当前选中格合法就自己处理掉不让消息继续分发如果消息是VK_ESCAPE就取消当前选中格。这样数字键永远优先进入游戏逻辑按钮也不会抢键盘。5.3 控件自适应屏幕分辨率与高分屏MFC程序在1366x768的笔记本上做拿到4K高分屏上打开经常发现对话框小得可怜、文字模糊。主要原因是没有做DPI自适应。这里有两个层面的处理一是工程属性里把“DPI感知”设为“系统DPI感知”或者调用SetProcessDPIAware()否则Windows会把整个画面拉伸字就糊了二是在OnSize或者OnGetMinMaxInfo里根据窗口大小重新计算棋盘矩形让棋盘随窗口等比例缩放。我的代码里用了一个简单的方法在OnPaint里根据当前对话框客户区的宽度高度重新计算boardRect所以不管窗口被拉伸到多大棋盘都能紧贴着左边距和上边距放置。5.4 MFC项目打包发布时的两个坑最后说一下发布的问题。在Debug模式下编译出的exe依赖一堆MFC调试DLL拷到别的机器根本跑不起来。我发布时用的是Release x86版本并做了两个关键设置“常规 MFC的使用”选“在静态库中使用MFC”“C/C 代码生成 运行库”选“多线程(/MT)”这样生成的是一个不依赖MFC DLL、不依赖C运行库的绿色exe单文件直接双击就能运行体积大概在1MB~2MB级别。需要注意的是在VS2013及之前版本里如果静态编译程序里用到的某些Unicode字符集相关的库也需要一并静态链接千万别漏了。6. 一点个人心得这套代码写完后我对数独的算法和MFC的开发模式都有更深的理解。如果你打算自己动手复刻我建议第一步先做核心算法生成一个终盘挖洞出题并能判定唯一解把它做成一个控制台程序跑通再去碰MFC界面。界面永远是最容易的部分算法才是这个项目的灵魂。最后再分享一个小技巧所有判断行列宫的代码我都建议写成清晰的函数名并加上注释例如isSafe里三个判断条件分别注释“行检查”“列检查”“宫检查”。因为这种嵌套调用特别容易让人看晕等过两个月你再回头看代码时这几个清晰注释能帮你节省半小时的回忆时间。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询