简介这是一份基于 MFC 开发的简单迷宫游戏工程源码包面向具备 C 基础、希望了解 Windows 桌面程序与小型游戏实现的初学者和课程实践者。压缩包共 45 个文件约 2.13MB包含 h/cpp 源文件与 dsp/dsw 工程配置便于用 VC6 打开编译rc/res、ico、bmp、png 等承担界面资源与步骤图示obj、pdb、exe 等为编译产物可直接运行参考。已有 379 人学习说明其在 MFC 入门与游戏逻辑练习中具有一定参考价值。读者可观察文档/视图架构、菜单控件事件与 GDI 绘图方式结合二维数组或链表组织迷宫、以 DFS/BFS 完成路径搜索配套思路说明与多张步骤截图有助于理解工程组织适合对照源码梳理 MFC 游戏开发流程。1. 从一张能走通的迷宫开始这个 MFC 小游戏适合拿来练什么第一次拿到这个 MFC 迷宫游戏源码我是在一台只装了基础 C 工作负载的机器上双击.sln的结果afxwin.h直接报缺失。这个 MFC 小游戏本身不复杂对话框窗口、二维数组迷宫、方向键移动、双缓冲重绘刚好把消息映射、GDI 绘图和资源路径这几件事串在一起。它解决的是“想找一个能编译、能改、能讲清 MFC 消息流的小项目”的问题适合刚接触 Windows 桌面开发、需要把 C 语法落到窗口程序里的读者也适合拿它当 GDI 双缓冲和键盘消息的练手底稿。别急着改玩法先让它在你机器上跑起来后面才有讨论参数的意义。2. MFC 迷宫工程的骨架对话框、视图和消息映射怎么接起来2.1 工程类型选择对话框程序还是单文档视图这类简单迷宫游戏常见两种骨架基于对话框的CDialogEx工程或者基于单文档的CView工程。两者都能画迷宫但落地成本不一样。对话框工程资源少主窗口就是一个对话框模板按钮、静态文本、绘图区都塞在同一个客户区里改起来直接单文档工程自带文档/视图/框架三层适合后面要加菜单、工具栏、多文档切换的场景但一开始就要理解CDocument和CView的分工对只想跑通迷宫的人不划算。我一般会先选对话框工程把迷宫数组、玩家坐标、步数、定时器 ID 都放在对话框类里等逻辑稳定了再拆出CMazeModel之类的数据类。这样第 2 章到第 3 章的代码不用来回换文件排错时只看一个.cpp也能定位大半问题。资源脚本里保留IDD_MAZEGAME_DIALOG对话框模板客户区留白不要放太多控件方向键消息一旦被按钮抢走后面就要用PreTranslateMessage兜底反而多一层。如果你拿到的源码是单文档工程也不用硬改成对话框。先看CView派生类的OnDraw是不是负责绘制OnKeyDown是不是挂在视图类上OnTimer是不是挂在框架或视图上。MFC 的消息路由是按窗口层级走的视图收不到键盘消息时先查焦点在谁身上而不是先怀疑迷宫数组写错。选型的判断标准很简单只做一张迷宫、一个玩家、一个出口对话框够用要做关卡编辑、菜单存档、多窗口预览单文档更顺。2.2 消息映射OnPaint、OnTimer、OnKeyDown 三根线MFC 不像控制台程序那样有一个显式的while游戏循环它靠 Windows 消息驱动。迷宫游戏真正要盯住的消息只有三类WM_PAINT负责把当前迷宫状态画到屏幕WM_TIMER负责计时、动画或限时关卡WM_KEYDOWN负责方向键和重开键。把这三根线接好游戏就能转起来接不好表现出来就是白屏、方向键没反应、计时器不跳。消息映射的写法很固定头文件里声明afx_msg成员函数源文件里用BEGIN_MESSAGE_MAP和END_MESSAGE_MAP把消息和函数绑起来。下面这段是对话框类的骨架OnKeyDown和PreTranslateMessage都保留前者处理正常键盘消息后者处理控件抢焦点的情况。// MazeGameDlg.h对话框类声明只保留迷宫游戏需要的消息处理 class CMazeGameDlg : public CDialogEx { DECLARE_DYNAMIC(CMazeGameDlg) public: CMazeGameDlg(CWnd* pParent nullptr); enum { IDD IDD_MAZEGAME_DIALOG }; protected: virtual void DoDataExchange(CDataExchange* pDX); virtual BOOL PreTranslateMessage(MSG* pMsg); // 抢在控件前拦截方向键 afx_msg void OnPaint(); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); DECLARE_MESSAGE_MAP() }; // MazeGameDlg.cpp消息映射表 BEGIN_MESSAGE_MAP(CMazeGameDlg, CDialogEx) ON_WM_PAINT() ON_WM_TIMER() ON_WM_KEYDOWN() END_MESSAGE_MAP()这段代码的逻辑是把 Windows 消息翻译成类成员调用ON_WM_PAINT()对应OnPaintON_WM_TIMER()对应OnTimerON_WM_KEYDOWN()对应OnKeyDown。参数上要注意OnTimer的nIDEvent是定时器 ID不是时间值如果你在SetTimer(1, 100, nullptr)里用了 1回调里就要判断nIDEvent 1否则别的定时器也会走进来。OnKeyDown的nChar是虚拟键码方向键是VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT不是字符W、A、S、D要做 WASD 就再加一层映射不要混着写。PreTranslateMessage不是必须的但对话框里只要放了按钮、编辑框方向键就可能被控件先吃掉。血泪经验是先不加PreTranslateMessage用键盘试一遍如果方向键没反应再在PreTranslateMessage里判断pMsg-message WM_KEYDOWN把方向键转给OnKeyDown或直接调用移动函数。不要一上来就把所有按键都拦截否则编辑框里连退格都打不出来。2.3 类职责拆分把迷宫数组从窗口类里挪出去简单项目最容易写成“窗口类什么都干”生成迷宫、画格子、读键盘、算步数、弹通关框全在CMazeGameDlg里。这样不是不能跑但后面改迷宫尺寸、换生成算法、加关卡文件时会在一堆界面代码里找数组下标。更稳的做法是把迷宫数据抽成一个轻量模型类窗口类只负责消息和绘制。模型类不需要继承CObject也不用搞复杂几个公有成员加方法就够。我通常把模型拆成三块m_maze二维数组存 0/10 是通路、1 是墙m_playerRow、m_playerCol存玩家格子坐标m_steps、m_seed存步数和随机种子。生成函数Generate(seed)只改数组移动函数TryMove(dr, dc)只改玩家坐标并返回是否成功窗口类拿到返回值后决定是否Invalidate(FALSE)。这样第 6 章做固定种子回归时不用启动窗口就能验证迷宫连通性。拆分时注意一个边界模型类不要直接调用MessageBox或Invalidate否则又和界面绑死。通关判断可以返回一个枚举比如MoveResult::ReachedExit由对话框决定是弹提示、停定时器还是切下一关。这个边界划清后迷宫生成算法换 DFS、Prim 或递归分割都只动模型文件窗口类几乎不用改。工程文件清单可以先对照下面这张表看拿到源码后不至于在一堆文件里迷路。不同工程模板会有差异但核心角色差不多。文件/配置作用先看什么.sln解决方案入口双击后能不能正常加载项目.vcxproj编译配置平台工具集、字符集、MFC 使用方式对话框类.h/.cpp消息映射和界面逻辑OnPaint、OnKeyDown、OnTimer是否挂上模型类.h/.cpp迷宫数组和移动规则数组尺寸、起点终点、墙和通路的值.rc资源脚本对话框模板、图标IDD_对话框 ID 是否和代码一致res目录图片、地图、文本资源运行时路径是不是按 exe 目录拼提示先编译一次再读代码。编译错误会直接告诉你缺的是 MFC 组件、字符集还是入口点配置比盲猜快得多。3. 迷宫生成与渲染DFS 回溯、双缓冲和键盘碰撞的落地参数3.1 用 DFS 回溯生成奇数网格迷宫迷宫生成算法里DFS 回溯最适合课程设计级项目逻辑短、结果直观、每条通路都能走通。核心规则是“隔一格打通中间墙”。把二维数组先全部填成墙从(1,1)开始每次向上下左右跨两格找未访问格子找到就把当前格、中间格、目标格都置为通路然后压栈四个方向都走不通就弹栈回溯。因为每次跨两格行数和列数必须取奇数边界留一圈墙否则玩家会从边上走出去。下面这段是迭代栈版本没有用递归。递归写法更短但迷宫尺寸一大递归深度可能把线程栈压穿迭代栈至少能把“栈溢出”这个翻车点先排除。随机数用std::mt19937加std::shuffle不用已经移除的std::random_shuffle这样在新标准下也能编译。#include random #include vector const int kRows 21; // 必须是奇数且不小于 5 const int kCols 21; int m_maze[kRows][kCols]; // 0 通路1 墙 void CMazeGameDlg::GenerateMaze(unsigned int seed) { std::mt19937 rng(seed); // 固定种子可用于复现同一张迷宫 for (int r 0; r kRows; r) for (int c 0; c kCols; c) m_maze[r][c] 1; // 先全部当墙 struct Cell { int r; int c; }; std::vectorCell stack; m_maze[1][1] 0; stack.push_back({1, 1}); const int dr[4] {-2, 2, 0, 0}; // 跨两格中间那格就是要打通的墙 const int dc[4] {0, 0, -2, 2}; while (!stack.empty()) { Cell cur stack.back(); int order[4] {0, 1, 2, 3}; std::shuffle(order, order 4, rng); // 四个方向随机顺序 bool advanced false; for (int i 0; i 4; i) { int d order[i]; int nr cur.r dr[d]; int nc cur.c dc[d]; // 边界保留一圈墙目标格必须是未访问的墙 if (nr 0 || nr kRows - 1 || nc 0 || nc kCols - 1) continue; if (m_maze[nr][nc] ! 1) continue; m_maze[(cur.r nr) / 2][(cur.c nc) / 2] 0; // 打通中间墙 m_maze[nr][nc] 0; stack.push_back({nr, nc}); advanced true; break; } if (!advanced) stack.pop_back(); // 死路回溯 } m_maze[1][1] 0; // 起点 m_maze[kRows - 2][kCols - 2] 0; // 出口 }这段代码的关键参数是kRows和kCols必须奇数建议 21×21 起步太小迷宫没意思太大在普通窗口里格子会小到看不清。seed是随机种子传固定值就能每次生成同一张迷宫方便验证传GetTickCount()或std::random_device{}()就能每局不同。数组里1表示墙、0表示通路绘制、碰撞检测、连通性检查都按这个约定走不要中途改语义。起点(1,1)和出口(kRows-2,kCols-2)在生成后强制置 0避免随机过程把出口留成墙。一个容易忽略的边界是“跨两格”的合法性nr和nc必须落在1到kRows-2、1到kCols-2之间不能只判断nr 0。如果只判断大于等于 0生成过程可能把最外圈墙打通玩家看起来能走出去实际数组越界或出口失效。把边界条件写成nr 0 || nr kRows - 1这种闭区间判断后面查穿墙问题时能省很多时间。3.2 双缓冲绘制格子尺寸、偏移和重绘范围MFC 里直接拿CPaintDC一个格子一个格子画窗口一拖动就闪方向键连续按时更明显。双缓冲的做法是先建一个兼容内存 DC把整张迷宫画到内存位图里最后一次BitBlt贴到屏幕。这样屏幕只看到一次完整贴图不会看到“先擦背景再画墙”的中间过程。实现上要注意三点内存 DC 要CreateCompatibleDC位图要CreateCompatibleBitmap选入位图后记得SelectObject还原旧对象否则 GDI 对象泄漏跑久了会变卡。格子尺寸不能写死。客户区大小随窗口变化固定 20 像素会导致小窗口画不下、大窗口留白。常用做法是用min(客户区宽 / 列数, 客户区高 / 行数)算格子边长再算居中偏移。格子最小给 4 像素防止窗口缩得太小时出现 0 或负数尺寸。下面这段DrawMaze把墙、通路、玩家、出口一次画完坐标换算统一用ox c * cell不要墙用一种算法、玩家用另一种算法否则玩家会“看起来站在墙里”。void CMazeGameDlg::DrawMaze(CDC dc, const CRect rc) { const int cell max(4, min(rc.Width() / kCols, rc.Height() / kRows)); const int ox (rc.Width() - cell * kCols) / 2; // 水平居中偏移 const int oy (rc.Height() - cell * kRows) / 2; // 垂直居中偏移 CBrush wallBrush(RGB(40, 44, 52)); CBrush pathBrush(RGB(250, 250, 250)); CBrush playerBrush(RGB(0, 122, 204)); CBrush exitBrush(RGB(46, 160, 67)); for (int r 0; r kRows; r) { for (int c 0; c kCols; c) { CRect cellRect(ox c * cell, oy r * cell, ox (c 1) * cell, oy (r 1) * cell); CBrush* brush (m_maze[r][c] 1) ? wallBrush : pathBrush; dc.FillRect(cellRect, brush); } } // 玩家和出口复用同一套坐标换算避免视觉错位 CRect pr(ox m_playerCol * cell, oy m_playerRow * cell, ox (m_playerCol 1) * cell, oy (m_playerRow 1) * cell); dc.FillRect(pr, playerBrush); CRect er(ox (kCols - 2) * cell, oy (kRows - 2) * cell, ox (kCols - 1) * cell, oy (kRows - 1) * cell); dc.FillRect(er, exitBrush); } void CMazeGameDlg::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); memDC.FillSolidRect(rc, RGB(245, 245, 245)); DrawMaze(memDC, rc); dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); // 还原避免 GDI 对象泄漏 }DrawMaze的参数dc是目标设备上下文rc是客户区矩形cell是每个格子的像素边长ox、oy是整张迷宫左上角的偏移。OnPaint里先GetClientRect拿客户区再建内存 DC 和兼容位图BitBlt的SRCCOPY表示直接复制像素。注意CreateCompatibleBitmap用的是dc而不是memDC因为要匹配屏幕格式如果先CreateCompatibleBitmap(memDC, ...)在部分环境下会得到单色位图画出来颜色不对。重绘范围也要控制。每次移动都Invalidate(TRUE)会擦背景虽然双缓冲能压住闪烁但没必要整窗重画。用Invalidate(FALSE)标记无效区域让OnPaint里统一双缓冲绘制如果迷宫很大可以进一步用InvalidateRect只重绘玩家旧位置和新位置两个格子但简单项目整窗重画已经够用。不要在没有双缓冲的情况下用UpdateWindow强行刷新那只是把闪烁提前不是解决。3.3 键盘移动与碰撞检测TryMove 的四个边界键盘移动的逻辑不复杂坑在“什么时候算一次有效移动”。正确顺序是先算目标格(nr, nc)再判断行、列是否越界再判断目标格是不是墙最后才更新玩家坐标、累加步数、检查出口。顺序反了就会出现“撞墙也加步数”或者“越界后数组访问崩溃”。方向键的dr、dc是行增量、列增量不是像素增量上移是dr -1, dc 0右移是dr 0, dc 1不要把屏幕坐标的 y 轴方向和数组行方向搞混。void CMazeGameDlg::TryMove(int dr, int dc) { int nr m_playerRow dr; int nc m_playerCol dc; // 先边界再墙最后才改状态 if (nr 0 || nr kRows || nc 0 || nc kCols) return; if (m_maze[nr][nc] 1) return; m_playerRow nr; m_playerCol nc; m_steps; // 只有成功移动才计步 if (m_playerRow kRows - 2 m_playerCol kCols - 2) OnReachExit(); // 停定时器、弹提示或切下一关 Invalidate(FALSE); // 交给 OnPaint 双缓冲重绘 } void CMazeGameDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { switch (nChar) { case VK_UP: TryMove(-1, 0); break; case VK_DOWN: TryMove(1, 0); break; case VK_LEFT: TryMove(0, -1); break; case VK_RIGHT: TryMove(0, 1); break; case VK_SPACE: RestartGame(); break; // 空格重开 default: break; } CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); }TryMove的dr、dc只接受-1、0、1三种值调用方不要传像素距离。OnKeyDown里nChar是虚拟键码nRepCnt是重复次数nFlags是扫描码等标志简单游戏可以不用后两个参数但调用基类CDialogEx::OnKeyDown是个好习惯避免吞掉对话框默认处理。VK_SPACE重开前最好先确认当前没有弹模态框否则空格会把提示框关掉玩家以为游戏卡住。出口判断放在坐标更新之后、Invalidate之前。如果先Invalidate再判断屏幕上会先画玩家踩到出口再弹提示视觉上闪一下。OnReachExit里可以KillTimer(1)停计时器弹一个MessageBox再把m_steps清零或保留。简单项目不要在这里直接EndDialog否则窗口一关后面想加“下一关”按钮又要重写。4. 从 .sln 到可执行文件MFC 环境、字符集和资源路径的排查顺序4.1 打开解决方案前先核对 MFC 组件与平台工具集MFC 工程报afxwin.h找不到十有八九不是代码问题是开发环境没装 MFC 组件。用 Visual Studio 安装器打开“修改”在“单个组件”里找“MFC 和 ATL 支持”勾上再装。只装“使用 C 的桌面开发”工作负载不一定带 MFC这一点很多人第一次会翻车。装完后重新打开解决方案先别急着编译右键项目看“属性”里的“使用 MFC”是不是“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”空着就会在链接阶段报一堆_afx相关符号缺失。平台工具集也要和本机安装对得上。项目文件里记录的“平台工具集”如果本机没有加载时会提示重定目标。重定目标可以点但重定完要重新编译一遍不要拿旧中间文件继续链接。Debug 和 Release 配置也要分开看Debug 下能跑不代表 Release 下能跑Release 可能开了优化未初始化变量、数组越界这类问题会以不同形式暴露。简单迷宫项目建议先在 Debug 下跑通再切 Release 验证。链接阶段常见的unresolved external symbol _WinMain16或入口点错误通常不是迷宫代码写错而是项目配置的子系统不对。MFC 对话框程序应该走 Windows 子系统入口点是wWinMain或 MFC 提供的入口如果误改成控制台子系统链接器会找不到main。碰到这类错误先看“链接器 → 系统 → 子系统”再看“高级 → 入口点”有没有被手工改过。不要在不理解配置的情况下乱加入口点越加越乱。4.2 字符集与预编译头Unicode 和 MBCS 的迁移边界MFC 项目的字符集选项会直接影响字符串类型。用“使用 Unicode 字符集”时CString底层是wchar_t_T(...)展开成宽字符用“使用多字节字符集”时CString底层是char_T(...)展开成窄字符。老源码里如果混用了strcpy、sprintf、char*切到 Unicode 就会报“不能将const char*转换为LPCTSTR”。这不是迷宫逻辑错是字符串类型不匹配。处理办法有两个一是保持项目原字符集源码里用_T()、_tcscpy、_stprintf这类 TCHAR 宏让代码跟着配置走二是统一改成 Unicode把char换成TCHAR把strcpy换成_tcscpy_s把sprintf换成_stprintf_s。不要一半 Unicode 一半 MBCS尤其不要在.rc资源里混用。资源脚本里的对话框标题、按钮文本如果编码不对运行时会显示乱码但编译不一定报错排查时容易误判成字体问题。配置项常见选择异常表现先看哪里字符集使用 Unicode 字符集char*转LPCTSTR报错项目属性 → 高级 → 字符集字符集使用多字节字符集宽字符 API 调用不匹配源码里的CString和_T()使用 MFC在共享 DLL 中使用 MFC缺少 MFC DLL 或链接符号项目属性 → 高级 → 使用 MFC预编译头使用预编译头pch.h找不到或宏冲突项目属性 → C/C → 预编译头运行库/MD 或 /MT 与 MFC 一致链接冲突、重复定义项目属性 → C/C → 代码生成预编译头是另一个容易踩的点。MFC 工程通常有pch.h或stdafx.h每个.cpp第一行要包含它项目属性里“预编译头”要选“使用”。如果你新建了一个.cpp忘了包含编译会报PCH相关错误如果你把“预编译头”改成“不使用”但源文件里还留着#include pch.h又可能报找不到。简单项目最稳的做法是跟随模板不轻易改预编译头设置要加新文件就复制现有.cpp的头几行保持包含顺序一致。4.3 资源路径与调试工作目录别让图片和地图文件靠运气迷宫游戏如果只画纯色格子不需要外部资源一旦加了背景图、音效、关卡文本就要处理路径。最常见的翻车方式是代码里写fopen(res\\map.txt, r)或CImage.Load(_T(res\\bg.png))在 Visual Studio 里调试能跑双击 exe 就找不到文件。原因是调试时“工作目录”默认可能是项目目录直接运行 exe 时工作目录是 exe 所在目录两者不是同一个地方。相对路径靠的是当前工作目录不是 exe 路径。稳的做法是用GetModuleFileName拿 exe 全路径截掉文件名拼资源目录。这样无论从 IDE 启动还是双击启动路径都一致。下面这个函数返回带结尾反斜杠的 exe 目录拼资源时直接GetExeDir() _T(res\\map.txt)。CString GetExeDir() { TCHAR path[MAX_PATH] {0}; GetModuleFileName(nullptr, path, MAX_PATH); // 当前进程 exe 全路径 CString s(path); int pos s.ReverseFind(_T(\\)); return (pos 0) ? s.Left(pos 1) : s; // 保留结尾反斜杠 }GetModuleFileName的第一个参数传nullptr表示当前进程path缓冲区大小用MAX_PATH返回值是实际写入长度。ReverseFind找最后一个反斜杠Left(pos 1)把文件名截掉但保留分隔符。这个写法对普通路径够用超长路径要换动态缓冲区和\\?\前缀简单项目不用展开。资源加载失败时先MessageBox打出拼好的完整路径看它到底指向哪里比在代码里猜快得多。调试工作目录也可以在项目属性里改“调试 → 工作目录”设成$(OutDir)让 IDE 调试和双击运行的行为一致。但我不建议只靠改工作目录因为用户机器上不会带着你的项目配置。代码里按 exe 目录拼路径才是能带走的方案。资源文件复制到输出目录也要配置“生成事件”或“内容”项否则编译完 exe 旁边没有res文件夹照样加载失败。5. 避坑与常见问题五个让迷宫走不动的现场记录5.1 编译与链接类afxwin.h 缺失和入口点错误现象双击.sln后编译第一行就报“无法打开包括文件: afxwin.h”或者链接阶段报一堆_afx开头的未解析外部符号。原因开发环境没有安装 MFC 组件或者项目“使用 MFC”配置为空。只装 C 桌面工作负载不等于带 MFCMFC 是单独的可选组件项目配置里如果选了“标准 Windows 库”而不是 MFC也会出现头文件和库都对不上的情况。解决打开 Visual Studio 安装器修改安装在单个组件里勾选“MFC 和 ATL 支持”装完重启 IDE。然后右键项目 → 属性 → 高级 → 使用 MFC选“在共享 DLL 中使用 MFC”先跑通要发给别人且不想带 MFC DLL再改“在静态库中使用 MFC”重新编译。改完清理解决方案删掉旧中间文件再重编不要直接增量链接。现象链接报unresolved external symbol _WinMain16或“无法解析的外部符号 WinMain”。原因链接器子系统被改成控制台或者入口点被手工改过。MFC 对话框程序需要 Windows 子系统入口点由 MFC 库提供不是普通控制台main。解决项目属性 → 链接器 → 系统 → 子系统选“Windows (/SUBSYSTEM:WINDOWS)”高级 → 入口点留空不要手填WinMain。如果之前复制过别的项目配置检查.vcxproj里有没有残留的EntryPointSymbol有就删掉。改完重新生成不要只按 F5。5.2 运行与输入类方向键没反应和玩家穿墙现象程序能启动迷宫也画出来了但按方向键玩家不动鼠标点一下窗口或按钮后偶尔又能动。原因键盘焦点不在接收消息的窗口上。对话框里放了按钮、编辑框焦点默认可能在某个控件上方向键被控件拿去做焦点切换或者OnKeyDown消息映射没挂上WM_KEYDOWN根本没进你的函数。解决先确认消息映射里有ON_WM_KEYDOWN()函数签名是afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)。再在OnInitDialog里把焦点设到主窗口或绘图控件GetDlgItem(IDC_MAZE_AREA)-SetFocus()如果绘图区就是对话框客户区可以SetFocus()。仍然不灵就在PreTranslateMessage里拦截WM_KEYDOWN判断方向键后直接调用TryMove返回TRUE阻止控件继续处理。现象玩家能移动但偶尔会站到墙里或者从迷宫最外圈走出去数组访问还报越界。原因碰撞检测顺序不对或者迷宫生成时边界没保留。常见写法是先改m_playerRow、m_playerCol再判断新位置是不是墙这样即使撞墙坐标也已经变了另一种是生成时只判断nr 0把最外圈墙打通了。解决按第 3 章TryMove的顺序来先算nr、nc再判nr 0 || nr kRows || nc 0 || nc kCols再判m_maze[nr][nc] 1最后才更新坐标。生成函数里边界条件用nr 0 || nr kRows - 1保证最外圈是墙。改完用固定种子跑几张迷宫手动沿边线走一圈确认走不出去。5.3 绘制与资源类白屏、闪烁和图片加载失败现象窗口打开后一片白迷宫格子一个都不显示但程序没崩方向键还能改变步数。原因OnPaint里没有真正绘制或者绘制到了错误的 DC也可能是双缓冲的BitBlt没执行内存位图没选进memDC。窗口尺寸为 0 时CreateCompatibleBitmap也会失败后面画什么都无效。解决在OnPaint里先GetClientRect判断rc.Width() 0 rc.Height() 0再建位图。确认memDC.SelectObject(bmp)的返回值保存了旧位图最后BitBlt到CPaintDC。如果只画了内存 DC 忘了贴图屏幕就是白的。临时可以在OnPaint里直接画一个红色矩形做断点确认OnPaint有没有进来没进来就查Invalidate和窗口样式。现象拖动窗口或连续按方向键时画面闪烁墙和通路像在跳。原因没有双缓冲或者每次Invalidate(TRUE)擦背景再重画屏幕看到擦和画两个过程也可能在OnPaint里创建了画笔、画刷没释放GDI 对象越积越多后面绘制变慢。解决用第 3 章的双缓冲结构Invalidate(FALSE)代替Invalidate(TRUE)所有绘制在内存 DC 完成后一次BitBlt。CBrush、CPen用局部对象选入 DC 后记得SelectObject还原旧对象不要new CBrush之后不delete。如果还闪检查窗口类有没有CS_HREDRAW | CS_VREDRAW导致整窗重画简单项目可以保留但双缓冲必须到位。现象背景图或关卡文本加载失败调试时正常双击 exe 就找不到文件。原因用了相对路径依赖当前工作目录。IDE 调试的工作目录和 exe 所在目录不一致换台机器更不一致。解决按第 4 章的GetExeDir()拼绝对路径资源文件随 exe 一起复制到输出目录。加载失败时把完整路径打到OutputDebugString或MessageBox确认路径拼错还是文件没复制。不要用..\\..\\res这种靠目录层级的写法项目一换位置就断。6. 进阶验证把随机迷宫做成可回归的关卡生成器6.1 固定种子和连通性检查随机迷宫最容易出现的“玄学”问题是大部分局能走通偶尔一局出口被墙封死。生成算法本身是 DFS 回溯理论上连通但边界条件一改、出口坐标一写错就可能出问题。我的习惯是给生成函数加一个固定种子入口再用 BFS 从起点搜一遍确认出口可达。下面这段检查函数不依赖 MFC 窗口可以单独放在控制台测试或单元测试里跑。bool IsExitReachable() { bool vis[kRows][kCols] {false}; std::queuestd::pairint, int q; q.push({1, 1}); vis[1][1] true; const int dr[4] {-1, 1, 0, 0}; const int dc[4] {0, 0, -1, 1}; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r kRows - 2 c kCols - 2) return true; for (int i 0; i 4; i) { int nr r dr[i]; int nc c dc[i]; if (nr 0 || nr kRows || nc 0 || nc kCols) continue; if (vis[nr][nc] || m_maze[nr][nc] 1) continue; vis[nr][nc] true; q.push({nr, nc}); } } return false; }这段 BFS 的参数和移动逻辑与TryMove保持一致四方向、墙为 1、边界判断用0到kRows-1。跑 100 个连续种子只要有一个返回false就说明生成或出口设置有问题。固定种子还能做回归改完绘制代码后用同一个种子重新生成迷宫布局应该完全一致方便对比是不是改坏了。6.2 难度参数化和地图导出把kRows、kCols写死适合入门但不适合做关卡。更实用的做法是把行列数、种子、限时、步数上限放进一个LevelConfig结构从文本或ini读入生成后把m_maze按行导出成 0/1 文本方便人工检查或换机器复现。导出时每行末尾加换行不要加空格读回时按字符解析。难度不要只靠加大尺寸格子太小看不清可以保持 21×21缩短限时或增加必须收集的钥匙点。参数建议范围作用调过头会怎样行/列1531 奇数迷宫规模太大格子小玩家看不清种子固定值或随机复现布局固定太多局会腻限时30120 秒增加压力太短变成背地图步数上限路径长度 1.52 倍防无脑乱走太紧会误判失败血泪经验是先把固定种子和连通性检查跑通再谈难度参数。从那以后我每次拿到这类 MFC 小游戏源码都先按第 4 章的顺序过一遍环境、字符本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?