资讯详情

资讯详情

数独游戏MFC完整源码:回溯生成、唯一解校验与界面绘制

简介数独游戏MFC完整实现源代码源自华为编程比赛决赛要求面向熟悉C、关注Windows桌面应用开发与数独算法设计的开发者完整解决从数独布局生成到自动求解的实际编程问题。代码基于Visual Studio 2008 SP1编写集成九宫格自由输入、数独原始布局数据读取、任意布局自动完成、难度适中布局生成、输入提示及游戏计时等功能覆盖完成算法与生成算法两大核心模块。压缩包共84个文件约870KB以头文件(h)、C源文件(cpp)、VS工程文件(sln/vcproj)、资源定义文件(rc)为主并附带可运行的exe、图标资源及调试信息文件工程结构完整便于直接打开研读与二次开发。已有564人学习下载适合想深入理解数独求解算法、MFC界面组织方式以及完整体验华为编程赛题要求的开发者作为实战参考。1. 数独游戏MFC实现完整源代码为什么这依然是 Windows 课设与练手的硬通货拿到“数独游戏MFC实现完整源代码”这套需求的人多半不是来玩游戏的而是想找一条能讲清原理的 Windows 桌面程序闭环生成盘面、挖洞出题、绘制棋盘、键盘输入、胜负判断全得自己写。现在还选 MFC不是因为时髦而是课设、毕设里它出现频率依旧很高另一个常见问题是“桌面软件开发用 MFC 还是 Qt”如果目标是快速吃透 Win32 消息机制MFC 反而更直白。这篇按核心算法、界面绘制、交互与状态栏、避坑、验证扩展五段展开全部代码按对话框工程骨架写。2. 数独核心算法用回溯法生成唯一解盘面的 3 个函数数独的难点根本不在界面而在“怎么保证题目只有一个答案”。网上不少源码生成的题目有多解玩家填完总和你参考答案对不上这是没做唯一解校验导致的。我习惯把核心算法和 MFC 彻底分开单独放一个 SudokuCore.cpp不包含任何 windows.h 依赖。这样既能控制台测试将来迁到 Qt 或网页端也能直接复用答辩时讲起来也更清爽。2.1 先写 isValid行、列、宫三个方向一次查完不管生成还是求解第一步都是校验“某个数字能不能放进来”。数独规则是三个方向同时不冲突同一行不能有重复、同一列不能有重复、所在的 3×3 小宫不能有重复。实现时注意宫起始坐标的计算用的是整数除法取整。#include cstring #include vector #include algorithm bool isValid(const int board[9][9], int row, int col, int num) { // 行与列检查一次循环扫完两条线 for (int i 0; i 9; i) { if (board[row][i] num) return false; // 行冲突 if (board[i][col] num) return false; // 列冲突 } // 宫检查先定位所在 3x3 宫的左上角 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 (board[i][j] num) return false; // 宫内冲突 } } return true; }这段代码有三个值得记住的细节行与列放在同一个循环里扫少写一遍循环宫的定位用(row / 3) * 3而不是row / 3后者只能得到 0、1、2 的区块编号不能直接当坐标用最后 return true 表示这个数字在这格合法。调用它之前要保证 board[row][col] 本身是 0否则校验会把已有数字误判成冲突。这个函数在生成、出题、玩家输入校验三处都要用必须单独抽出来。2.2 随机回溯生成完整盘面洗牌加递归下探生成一个完整 9×9 可行解我用的是“随机选数 深度优先回溯”。关键点是每个空格尝试的 1~9 顺序要随机否则每次运行生成出来的盘面都是一样的玩家两局就能背下答案。洗牌用最轻量的 Fisher-Yates不需要额外依赖。// 打乱数组保证每局盘面不一样 void shuffleNums(int* arr, int n) { for (int i n - 1; i 0; --i) { int j rand() % (i 1); std::swap(arr[i], arr[j]); } } bool generateFull(int board[9][9], int pos) { if (pos 81) return true; // 81 格都填满生成成功 int row pos / 9; int col pos % 9; int nums[9] {1, 2, 3, 4, 5, 6, 7, 8, 9}; shuffleNums(nums, 9); // 随机调整尝试顺序 for (int k 0; k 9; k) { if (isValid(board, row, col, nums[k])) { board[row][col] nums[k]; if (generateFull(board, pos 1)) { return true; } board[row][col] 0; // 回溯失败必须归零 } } return false; }调用前先在外部memset(board, 0, sizeof(board[0][0]) * 81)调用后 board 就是一张完整答案盘。注意递归失败时的归零操作如果不把当前格重置为 0后续分支尝试其他数字时 isValid 会读到残留值导致整条递归全部错乱。这个 bug 很难肉眼发现我在初版代码里栽过跟头。rand()记得在主程序入口或游戏开始时srand((unsigned)time(NULL))否则每次启动生成的盘面顺序固定。2.3 挖洞出题与唯一解校验solveCount 计数与回退生成完整盘面后要把它变成“题目盘”。做法是随机挖掉一些格子但每挖一个格都必须保证“挖完仍然只有唯一解”。这才是数独源码里最容易偷懒、也最容易翻车的地方。我先写一个递归求解计数函数它和普通解数独的区别是一旦解的数量超过 limit 就提前返回用于判断“是否唯一”时传 limit1 就够。// 统计解的数量超过 limit 立即退出 int solveCount(int board[9][9], int limit) { for (int i 0; i 81; i) { int r i / 9, c i % 9; if (board[r][c] 0) { int cnt 0; for (int num 1; num 9 cnt limit; num) { if (isValid(board, r, c, num)) { board[r][c] num; cnt solveCount(board, limit); if (cnt limit) { board[r][c] 0; return cnt; // 提前退出 } } } board[r][c] 0; return cnt; } } return 1; // 全填满且无冲突算一个解 } // 挖洞从完整盘中随机挖掉 targetHoles 个格子并保证唯一解 bool digHole(const int full[9][9], int puzzle[9][9], int targetHoles) { std::vectorstd::pairint, int cells; for (int i 0; i 81; i) { cells.push_back(std::make_pair(i / 9, i % 9)); } // 随机打乱格子顺序 for (int i 81; i 1; --i) { int j rand() % i; std::swap(cells[i - 1], cells[j]); } int dug 0; for (size_t idx 0; idx cells.size() dug targetHoles; idx) { int r cells[idx].first; int c cells[idx].second; int backup puzzle[r][c]; puzzle[r][c] 0; // 复制一份再检查不能直接在待解盘上跑 int test[9][9]; memcpy(test, puzzle, sizeof(test)); if (solveCount(test, 1) ! 1) { puzzle[r][c] backup; // 多解挖不动就回退 } else { dug; } } return dug targetHoles; }solveCount的返回值语义要理解清楚返回 1 表示唯一解返回 2 或更大就说明至少两个解。挖洞时把候选格子顺序打乱后逐个尝试当前格挖掉后如果解数不等于 1就恢复原值。digHole里的targetHoles就是难度参数简单约 36~40中等约 45~50困难约 55~60。但我要提醒一句目标挖洞数越高挖洞回退概率越大极端情况会接近卡死所以公允的做法是“挖到多少算多少”遇到连续回退就终止。这个我在第 6 章会给出可落地的参数。3. 对话框里的 9×9 棋盘从资源编辑器到双缓冲绘制算法独立出来后MFC 这部分就纯粹是“数据上屏”和“事件下发”。我的工程结构是对话框类持有一份题面盘m_puzzle、一份玩家盘m_user、一份完整解m_full绘制只读这两份数组。界面布局按“左侧棋盘占主区域右侧竖向按钮”来做。3.1 为什么选 CDialogEx 而不是 CView常见做法是用基于对话框的工程而不是单文档视图。数独的核心交互全在一张棋盘上用 CView 的文档/视图分离反而要把数据和窗口绕一大圈。对话框架构里棋盘直接画在客户区按钮用资源编辑器拖上去就行逻辑集中课程设计答辩时也好讲。新建工程时选“基于对话框”VS 会自动生成 App 类和 Dialog 类。我一般把默认生成的 Static 文本和 TODO 都删掉右侧放三个按钮新游戏、提示、检查。棋盘占剩余区域。需要注意一个老坑如果对话框上放了按钮按钮会拦截键盘焦点后面第 4 章会讲到用PreTranslateMessage解决。对话框的OnInitDialog里集中做初始化memset两个盘面数组、设置随机种子、生成第一局、启动计时器。3.2 绘制网格的坐标公式与高亮选中格棋盘绘制的核心是坐标公式。我习惯把常量定义在类里每格大小CELL 44像素、棋盘左上角偏移OX 24、OY 24。绘制和鼠标点击共用这套常量这是避免“点不准”的唯一可靠办法。先看头文件成员// SudokuDlg.h class CSudokuDlg : public CDialogEx { public: int m_puzzle[9][9]; // 题面盘0 表示空格 int m_user[9][9]; // 玩家填入盘 int m_full[9][9]; // 本局的完整解 int m_selRow -1, m_selCol -1; // 当前选中格-1 表示未选中 static const int CELL 44; static const int OX 24; static const int OY 24; };绘制放在OnPaint里。步骤分三步先填充背景和选中格高亮色再画网格线最后画数字。数字颜色要区分题面和玩家输入题面用深灰玩家填入用蓝色冲突时用红色。这样玩家一眼能看出哪些格是题目给的、哪些是自己写的。void CSudokuDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); dc.FillSolidRect(rc, RGB(248, 248, 250)); // 选中格高亮画在网格线下面避免盖住粗线 if (m_selRow 0 m_selCol 0) { CBrush selBrush(RGB(190, 220, 255)); CRect selRect( OX m_selCol * CELL - 1, OY m_selRow * CELL - 1, OX (m_selCol 1) * CELL 1, OY (m_selRow 1) * CELL 1); dc.FillRect(selRect, selBrush); selBrush.DeleteObject(); } // 网格线每 3 格用粗线区分宫其余用细线 for (int i 0; i 9; i) { int x OX i * CELL; int y OY i * CELL; if (i % 3 0) { dc.FillSolidRect(x - 1, OY, 3, 9 * CELL, RGB(40, 40, 40)); dc.FillSolidRect(OX, y - 1, 9 * CELL, 3, RGB(40, 40, 40)); } else { dc.FillSolidRect(x, OY, 1, 9 * CELL, RGB(170, 170, 170)); dc.FillSolidRect(OX, y, 9 * CELL, 1, RGB(170, 170, 170)); } } // 数字绘制 CFont font; font.CreateFont(30, 0, 0, 0, FW_BOLD, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, _T(Segoe UI)); CFont* oldFont dc.SelectObject(font); dc.SetBkMode(TRANSPARENT); for (int r 0; r 9; r) { for (int c 0; c 9; c) { int v m_puzzle[r][c]; if (v 0) v m_user[r][c]; if (v 0) continue; if (m_puzzle[r][c] ! 0) { dc.SetTextColor(RGB(30, 30, 30)); // 题面深色 } else if (!isValid(m_user, r, c, v)) { dc.SetTextColor(RGB(220, 40, 40)); // 填错标红 } else { dc.SetTextColor(RGB(30, 90, 200)); // 玩家输入蓝色 } CString str; str.Format(_T(%d), v); CRect txtRect( OX c * CELL, OY r * CELL, OX (c 1) * CELL, OY (r 1) * CELL); dc.DrawText(str, txtRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } dc.SelectObject(oldFont); font.DeleteObject(); }DrawText的DT_CENTER | DT_VCENTER让数字在格内水平垂直都居中DT_SINGLELINE防止换行。字体高度 30 对应 44 像素格大约八成宽视觉比较协调。冲突标红逻辑写在绘制里每次重绘都重新校验玩家一填错立即看到反馈不需要另写错误检测代码。注意 CFont 和 CBrush 都要在画完后DeleteObject否则每次OnPaint都泄漏一个 GDI 对象运行几十局后绘制会突然变卡。3.3 双缓冲重绘把闪烁和残影一次解决直接画在CPaintDC上每次Invalidate系统都会先擦背景再触发OnPaint网格一多画面就闪按键越快闪得越厉害。这个问题的标准解法是双缓冲先把所有内容画到一块内存位图上再一次性BitBlt贴到屏幕。void CSudokuDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); // 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* oldBmp memDC.SelectObject(bmp); // 所有绘制逻辑改到 memDC 上执行 // 三段代码同 3.2高亮、网格线、数字 // ... // 整块贴回屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); // 还原 GDI 对象 memDC.SelectObject(oldBmp); bmp.DeleteObject(); }双缓冲的关键是CreateCompatibleBitmap必须用dc创建这样内存位图的颜色格式与屏幕一致。贴回时SRCCOPY是整块覆盖不需要关心背景。配合Invalidate(FALSE)使用——第二个参数为 FALSE 表示不擦背景只重画内容能进一步减少闪烁。如果某些系统上还是闪可以再顺手重写OnEraseBkgnd直接 return TRUE屏蔽系统擦背景动作这一步基本能把闪烁清干净。4. 交互与规则判断鼠标点选、键盘输入、胜负校验一条链算法和绘制就位后剩下的是把“玩家操作”接入起来。交互链路是鼠标点选格子 → 键盘输入数字 → 实时校验冲突 → 完成后停止计时并弹窗。这里最容易踩的坑是键盘消息被控件吃掉所以处理键盘不能只靠ON_WM_KEYDOWN。先看消息映射。4.1 消息映射把鼠标和键盘事件接进来MFC 的消息映射在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间注册。对话框要处理的就四个ON_WM_PAINT、ON_WM_LBUTTONDOWN、ON_WM_TIMER键盘消息我建议不要用ON_WM_KEYDOWN直接接而是重写PreTranslateMessage统一拦截。头文件里对应的声明如下// SudokuDlg.h 中的成员函数声明 protected: afx_msg void OnPaint(); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnTimer(UINT_PTR nIDEvent); virtual BOOL PreTranslateMessage(MSG* pMsg); void OnDigit(int num);点选格子的实现非常直白先判断鼠标位置是否落在棋盘范围内再用“先减偏移再除格宽”的公式算出行列。注意边界判断不能省否则点在最左侧外围时col可能算出负值。void CSudokuDlg::OnLButtonDown(UINT nFlags, CPoint point) { // 先判断是否在棋盘内 if (point.x OX point.x OX 9 * CELL point.y OY point.y OY 9 * CELL) { m_selRow (point.y - OY) / CELL; m_selCol (point.x - OX) / CELL; } else { m_selRow m_selCol -1; // 点外面取消选中 } Invalidate(FALSE); CDialogEx::OnLButtonDown(nFlags, point); }坐标公式(point.y - OY) / CELL中必须先减偏移再整除。如果写成point.y / CELL第一行格子的 y24 会直接被当成第 1 行永远选不中第 0 行。这个细节我见过好几次翻车案例。4.2 键盘输入与新游戏流程PreTranslateMessage 与按钮响应PreTranslateMessage是 MFC 消息分发前最后的拦截点。对话框上如果有按钮焦点在按钮上时按数字键默认会被按钮处理掉你在OnKeyDown里怎么写都没用。统一在这里截获最可靠BOOL CSudokuDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg-message WM_KEYDOWN) { UINT key (UINT)pMsg-wParam; if (key 0 key 9) { OnDigit(key - 0); // 0 表示清除 return TRUE; // 消息已被处理不再分发 } if (key VK_DELETE || key VK_BACK) { OnDigit(0); // 删除键作为清除 return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); } void CSudokuDlg::OnDigit(int num) { // 没有选中格或选中格是题面格都不能改 if (m_selRow 0 || m_selCol 0) return; if (m_puzzle[m_selRow][m_selCol] ! 0) return; m_user[m_selRow][m_selCol] num; Invalidate(FALSE); if (checkFinished()) { KillTimer(TIMER_ID); MessageBox(_T(恭喜完成), _T(数独), MB_OK); } }注意OnDigit(0)同时承担“清空当前格”的功能等于给了玩家一颗后悔药填错了按 Backspace 就能清掉。还要处理小键盘VK_NUMPAD0 ~ VK_NUMPAD9的事件wParam是扩展键码需要单独映射不处理的后果是笔记本用户和小键盘用户数字输入没反应。新游戏按钮走常规消息响应逻辑要复位所有状态重新生成满解、挖洞、清空玩家盘和选中格、重置计时。按钮事件在资源编辑器里右键添加即可代码大致是void CSudokuDlg::OnBnClickedNewGame() { memset(m_full, 0, sizeof(m_full)); generateFull(m_full, 0); memcpy(m_puzzle, m_full, sizeof(m_puzzle)); digHole(m_full, m_puzzle, 45); // 中等难度 memset(m_user, 0, sizeof(m_user)); m_selRow m_selCol -1; KillTimer(TIMER_ID); SetTimer(TIMER_ID, 1000, NULL); Invalidate(FALSE); }这里生成和挖洞都在 UI 线程同步执行中等难度一般几十毫秒没问题但困难难度挖洞可能到几秒界面会像卡死一样。生产级做法是放到工作线程课程设计要求通常在主线程也能接受但要把这条限制写进代码注释里方便答辩时说明已知问题。4.3 候选数与状态栏mfc 状态栏怎么显示时间与错误计数候选数是数独玩家的辅助功能空格里用小字列出“当前还能填的数字”。MFC 里画九宫小字不算复杂在每个空格内划分 3×3 小矩形填入 1~9 候选。这里我给一个精简版绘制片段配合std::setint m_candidates[9][9]使用// 绘制候选数需要在画数字之前执行 for (int r 0; r 9; r) { for (int c 0; c 9; c) { if (m_user[r][c] ! 0 || m_puzzle[r][c] ! 0) continue; std::setint cand m_candidates[r][c]; if (cand.empty()) continue; dc.SetTextColor(RGB(130, 130, 130)); for (int k 1; k 9; k) { if (cand.find(k) cand.end()) continue; int smallRow (k - 1) / 3; int smallCol (k - 1) % 3; CRect rct( OX c * CELL smallCol * CELL / 3, OY r * CELL smallRow * CELL / 3, OX c * CELL (smallCol 1) * CELL / 3, OY r * CELL (smallRow 1) * CELL / 3); CString s; s.Format(_T(%d), k); dc.DrawText(s, rct, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } } }候选数更新逻辑是每次玩家填一个数就把它从同行同列同宫的候选集合里删掉。这个功能可做可不做但加上会让整套源码更像成品。如果时间紧可以先用状态栏显示信息替代候选项。状态栏是 MFC 对话框里绕不开的需求热搜里 “mfc 状态栏怎么显示”“我要将一些信息显示在状态栏” 问的人很多。对话框创建状态栏比框架窗口麻烦一点通常三步// 头文件里添加成员CStatusBar m_wndStatusBar; // OnInitDialog 中创建并设置两个分隔窗格 m_wndStatusBar.Create(this); UINT indicators[] { ID_SEPARATOR, ID_SEPARATOR }; m_wndStatusBar.SetIndicators(indicators, 2); // 第二格固定宽度放时间第一格自动拉伸 m_wndStatusBar.SetPaneInfo(0, ID_SEPARATOR, SBPS_STRETCH, 0); m_wndStatusBar.SetPaneInfo(1, ID_SEPARATOR, 0, 120);以后只需更新文本不用反复 SetPaneInfovoid CSudokuDlg::UpdateStatusBar() { CString timeStr; timeStr.Format(_T(用时 %02d:%02d), m_minutes, m_seconds); m_wndStatusBar.SetPaneText(0, timeStr); CString errStr; errStr.Format(_T(错误 %d 次), m_wrongCount); m_wndStatusBar.SetPaneText(1, errStr); }SetPaneText的索引要和SetIndicators里的个数对得上索引从 0 开始。错误计数的更新时机是每次OnDigit执行且填入值冲突时累加。还要记得在对话框OnSize里调用m_wndStatusBar.MoveWindow否则窗口拉伸后状态栏不会跟着贴底边。4.4 胜负校验临时置空再校验全局胜负判断有一个简洁写法先检查所有格是否都填满再把每个格子临时置 0用isValid校验该数值是否仍然合法。这个方法避免手写三套循环也避免二维数组越界问题。bool CSudokuDlg::checkFinished() { // 先查有没有空格 for (int r 0; r 9; r) for (int c 0; c 9; c) if (m_user[r][c] 0) return false; // 逐格验证全局合法性 for (int r 0; r 9; r) { for (int c 0; c 9; c) { int val m_user[r][c]; m_user[r][c] 0; bool ok isValid(m_user, r, c, val); m_user[r][c] val; if (!ok) return false; } } return true; }这套写法的巧妙之处在于isValid已经覆盖行、列、宫三个方向临时置空当前格后它能准确看到“其他位置是否已经占用这个数”。校验完成后代码路径会在OnDigit里继续走KillTimer停表弹窗提示完成。计时器本身在OnInitDialog里SetTimer(TIMER_ID, 1000, NULL)在OnClose或OnDestroy里KillTimer二者必须配对否则窗口销毁后定时器回调还会触发调试时会出现随机崩溃。5. 踩坑与排查MFC 数独最常见的 5 个坑这一章把我在实际调试中遇到的典型问题按“现象 → 原因 → 解决”写出来。网格绘制、坐标换算、消息焦点这些问题教科书里不会写但它们才是让一份 MFC 数独源码从“能跑”变成“能交”的关键。这里没有玄学每一条背后都有明确的消息机制或 GDI 生命周期原因。5.1 画面闪烁得像老式终端数字还有残影现象键盘按得快时棋盘先白一下再画出来网格线能看到明显闪动有时旧的数字残影还留在屏幕上。原因OnPaint直接在CPaintDC上绘制每次Invalidate(TRUE)都会先触发系统擦除背景再重画全部网格和数字。擦除和重画之间的空档被屏幕捕捉到了就是闪烁。解决按照 3.3 节改成双缓冲所有绘制到memDC最后BitBlt一次贴回。同时把Invalidate(TRUE)全部换成Invalidate(FALSE)。如果还闪重写OnEraseBkgnd直接return TRUE屏蔽背景擦除。MFC 的绘制闪烁 90% 都能被这三板斧解决。5.2 生成的题目有多个答案玩家填得和参考答案不一样现象自己出的题换一种填法也能把盘面填满游戏却判玩家错误。原因挖洞后没有做唯一解校验。有些代码只检查“挖掉这格后整体仍然能解”忽略了可能存在多个解。更隐蔽的原因是solveCount里递归返回条件写错遇到第一个解直接 return 0统计数量失真。解决用solveCount(limit1)做唯一性判断并确认它返回 1 才是唯一解。写完后建议用第 6 章的批量验证脚本跑 1000 次。我经历过一次深夜“玄学”查错最后发现是递归里临时格子没复位导致解的数量被多算了。任何修改解算器后都先跑批量验证不要用肉眼在界面上试。5.3 点击格子位置总偏差甚至点不到想点的格子现象点了第三行第三列高亮却出现在第二行第二列或者点边界没反应。原因坐标换算时忘了减棋盘起始偏移OX/OY或者OnPaint里用的CELL和鼠标事件里用的CELL不一致。绘制用 44点击用 40就会系统性偏移一格。解决把CELL、OX、OY定义为类静态常量绘制和鼠标事件共用一个数据源。如果你在OnSize里做过自适应缩放鼠标转换函数也要同步重算。更稳妥的做法是写一个辅助函数getCellFromPoint(CPoint pt, int row, int col)绘制、点击、合法性判断都走它。5.4 键盘数字按下去毫无反应现象鼠标点击有高亮但按 1~9 没有数字填入按 Delete 也没反应。原因MFC 对话框的消息默认派发给焦点控件。对话框上有按钮时焦点可能停在按钮上WM_KEYDOWN被按钮和默认对话框逻辑吃掉。另外只写了ON_WM_KEYDOWN而没有处理小键盘键码也会让部分键盘失效。解决用PreTranslateMessage在消息分发前截获数字键、小键盘数字键、Delete 和 Backspace处理完直接返回 TRUE。代码见 4.2 节。这里的关键是理解PreTranslateMessage的返回语义TRUE 表示消息已被消耗不会继续分发。5.5 Unicode 字符集导致中文乱码和格式化出错现象CString 里拼中文全部变成 ?????或者从 VS2008 老工程升级到新版后sprintf全报了 warning C4996。原因新版 MFC 工程默认使用 Unicode 字符集所有字符串字面量必须是宽字符。老的sprintf(board, %d, n)只认 char和CString的wchar_t混用就乱码或编译警告。解决统一用_T()包字符串字面量。_T(用时 %02d:%02d)在 Unicode 下自动扩展成宽字符串在 ANSI 下保持窄字符串。格式化用CString::Format而不是 sprintf。字体创建时字符集参数用DEFAULT_CHARSET避免中文界面字体变形。这个坑在老教材的示例代码里特别常见新写代码直接按 Unicode 规范写就能避免。6. 让代码走得更远难度参数、批量验证与 Qt 迁移思路算法一旦和界面解耦后面的路就豁然开朗。这一章我给出三个必做的落地动作难度参数表、批量验证、存档与迁移对照。它们不改变玩法但决定这份源码是“作业”还是“工具”。6.1 难度参数怎么调才平衡挖洞数不是越多越好唯一解校验决定了挖洞是“试错”过程。我给一组经过验证的参数范围难度目标挖洞数实际体验设计要点简单36 ~ 40新手 10 分钟内解完空格少唯一解校验轻松中等45 ~ 50需要候选数辅助挖洞开始有回退困难55 ~ 60可能耗时半小时以上接近极限允许挖不满digHole返回值是实际挖成的洞数。真实项目里我不会因为参数设 58 就死等挖满 58 个洞而是挖不上来时接受当前值保证“新游戏”按钮永远不卡死。时间紧就默认 45足够交差了。6.2 批量验证把唯一解和质量控制交给机器课程设计里最值钱的加分项是“证明自己的生成算法可靠”。写一个独立控制台函数循环生成并校验 1000 局void stressTest(int rounds) { int fail 0; int totalHoles 0; for (int i 0; i rounds; i) { int full[9][9] {0}; generateFull(full, 0); int puzzle[9][9]; memcpy(puzzle, full, sizeof(puzzle)); if (!digHole(full, puzzle, 45)) { --i; // 挖洞失败这一轮重来 continue; } int test[9][9]; memcpy(test, puzzle, sizeof(test)); if (solveCount(test, 1) ! 1) fail; int holes 0; for (int r 0; r 9; r) for (int c 0; c 9; c) if (puzzle[r][c] 0) holes; totalHoles holes; } // 输出 fail 数量与平均挖洞数 }运行 1000 轮后fail应该恒为 0。这个测试比任何代码 review 都更能说明问题。平均挖洞数如果比目标低说明你在“挖不动就返回”的逻辑里过早终止可以放宽连续回退的尝试次数。6.3 存档读档与 Qt 迁移的对照思路存档不需要序列化库按行把盘面写成文本就行。每行 81 个字符0 表示空格先写题面再写玩家盘再写难度和用时。读档时逐字符解析回数组。注意CString的字符读取要用GetAt(idx)减去_T(0)得到数值。如果将来要换 Qt把SudokuCore原样搬过去界面层按下面这张对照表重写MFC 概念Qt 对应物CPaintDC/OnPaintQPainter/paintEvent消息映射宏connect()信号槽Invalidate()QWidget::update()CStringQStringCStatusBar::SetPaneTextQStatusBar::showMessage这张表的价值在于它证明你把核心算法独立出来的决定是对的。答辩时如果老师追问“为什么不用 Qt”你可以直接说算法层已解耦界面替换成本很低。现在用 AI 生成 MFC 样板代码很快但递归逻辑、坐标换算、唯一解校验这些还是要自己盯一遍——希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →