简介:这份基于 MFC 的简易迷宫游戏源码包,面向刚接触 Windows 程序设计与 C++ 的初学者,也适合希望理解文档/视图架构、事件驱动和基础路径搜索的开发者。压缩包共 45 个文件、约 2.13MB,包含 7 个头文件、6 个源文件,以及工程配置、资源脚本、图标位图、编译中间文件和可执行程序等;源代码与附带说明可用于梳理工程结构,图片材料则呈现迷宫创建和实现思路。目前已有 380 人学习下载。读者可从中拆解 MFC 的窗口管理、文档与视图分离、菜单及鼠标键盘交互、GDI 图形绘制等用法,并结合二维数组、DFS/BFS 思路理解迷宫地图存储、路径查找与状态更新。工程保留了 VC6 项目文件,适合对照编译调试;通过阅读和运行示例,还能观察消息映射、资源加载、迷宫边界判断与重置操作的组织方式,可作为课程设计或 MFC 入门练习的参考样例。
1. MFC 迷宫游戏拆解:一个能编译出 exe 的桌面小项目到底长什么样
学完 C++ 语法之后最尴尬的阶段,是知道怎么写循环、怎么用 vector,却做不出一个看得见的东西——控制台黑框太没成就感。这个 MFC 迷宫游戏正好卡在这个位置上:启动后随机生成一张迷宫,方向键控制一个小方块在里面走,撞墙就停在原地,走到右下角出口弹出通关提示,顺带记着步数和用时。它不联网、不接第三方库,解压后用较新的 Visual Studio 打开 .sln 按 F5 就能出窗口。整个 mfc 游戏的技术栈很朴素:MFC 负责窗口和消息,GDI 负责画线画方块,迷宫本身用标准 C++ 的 vector 和 stack 搞定。适合两类人:一是想拿一个完整可编译的 Windows 桌面程序练手的同学,二是需要一份能跑、结构清楚、方便往上加玩法的 MFC 骨架代码的开发者。
2. 工程骨架:从 .sln 到 CMazeApp 的启动链路和消息映射表
2.1 目录结构与每个文件的实际职责
拿到压缩包先别急着 F5,先认清哪些文件是项目自带的、哪些是 IDE 生成的。这类项目的文件构成高度固定,下面这张表是我拆过几个版本后整理出的通用清单,你手上的包大概率八九不离十:
| 文件 | 作用 | 改动频率 |
|---|---|---|
| MazeGame.sln | 解决方案入口,双击打开 | 不动 |
| MazeGame.vcxproj / .filters | 编译配置、源文件分组 | 加文件时才动 |
| framework.h / pch.h | 预编译头,塞 Windows.h、afxdialogex.h | 偶尔 |
| MazeGame.h / .cpp | CWinApp 派生类,程序入口 InitInstance | 少动 |
| MainFrm.h / .cpp | 主框架窗口,管菜单、工具栏、状态栏 | 少动 |
| MazeGameDoc.h / .cpp | 文档类,存迷宫数据(对话框版没有) | 常动 |
| MazeGameView.h / .cpp | 视图类,负责绘制和键盘响应 | 改动最多 |
| MazeCore.h / .cpp | 纯 C++ 迷宫生成与查询逻辑 | 常动 |
| MazeGame.rc / resource.h | 资源脚本、控件和菜单 ID | 加菜单时动 |
关键点是 MazeCore 这一层。很多版本会把生成算法直接塞进 View 里,能跑但没法复用,也没法单测。我一般坚持把「迷宫是什么」和「迷宫怎么画」拆开:MazeCore 只管二维数组和墙壁位,不 include 任何 afx 头文件,这样以后想换成 Qt 或者控制台版本,这层代码原样搬走就能用。
如果是对话框版(CDialogEx 派生类),结构更扁平,没有 Doc/View,主对话框类同时承担数据和绘制,上手快但后期加功能会越来越臃肿。两者在生成算法上没有区别,后面第 3 章的代码两边都能用。
2.2 启动链路:theApp 全局对象是怎么把窗口拉起来的
MFC 的入口不在 main,而在一个全局对象上。工程里有一行看着不起眼的定义,它就是整个程序的起点:
// MazeGame.cpp CMazeGameApp theApp; // 全局对象,程序加载时先于 main 构造 BOOL CMazeGameApp::InitInstance() { // 老版本 MFC 里这行必须有,否则控件和窗口可能初始化异常 INITCOMMONCONTROLSEX icc; icc.dwSize = sizeof(icc); icc.dwICC = ICC_WIN95_CLASSES; InitCommonControlsEx(&icc); CWinApp::InitInstance(); // 单文档模板:文档 + 框架 + 视图三者绑定 CSingleDocTemplate* pDocTemplate = new CSingleDocTemplate( IDR_MAINFRAME, RUNTIME_CLASS(CMazeGameDoc), RUNTIME_CLASS(CMainFrame), RUNTIME_CLASS(CMazeGameView)); if (!pDocTemplate) return FALSE; AddDocTemplate(pDocTemplate); // 解析命令行并创建主窗口,这里才会真正调用 Create CCommandLineInfo cmdInfo; ParseCommandLine(cmdInfo); if (!ProcessShellCommand(cmdInfo)) return FALSE; m_pMainWnd->ShowWindow(SW_SHOW); m_pMainWnd->UpdateWindow(); return TRUE; }逻辑上分四步:全局对象构造时把「程序类」注册进 MFC 内部链表;WinMain 由 MFC 库提供,它调用 InitInstance;InitInstance 里注册文档模板,把 Doc、Frame、View 三类捆在一起;ProcessShellCommand 根据命令行创建新文档,进而创建框架窗口和视图窗口。参数上,IDR_MAINFRAME 是资源里定义的字符串资源 ID,用来指定窗口标题、文档类型名和默认扩展名;RUNTIME_CLASS 宏依赖 DECLARE_DYNCREATE / IMPLEMENT_DYNCREATE,缺了这两个宏会在运行时断言失败,而不是编译报错——这是新手最容易懵的一类问题。
2.3 消息映射表:一行宏背后展开成什么
MFC 不用虚函数处理窗口消息,用的是一张静态表加查表分发。视图类里通常长这样:
// MazeGameView.h class CMazeGameView : public CView { DECLARE_DYNCREATE(CMazeGameView) protected: CMazeGameView(); virtual ~CMazeGameView(); public: virtual void OnDraw(CDC* pDC); virtual void OnInitialUpdate(); protected: afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); afx_msg void OnTimer(UINT_PTR nIDEvent); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() }; // MazeGameView.cpp BEGIN_MESSAGE_MAP(CMazeGameView, CView) ON_WM_KEYDOWN() // 对应 WM_KEYDOWN -> OnKeyDown ON_WM_TIMER() // 对应 WM_TIMER -> OnTimer ON_WM_ERASEBKGND() // 拦截背景擦除,抗闪烁 END_MESSAGE_MAP()BEGIN_MESSAGE_MAP 展开后是一个 static 的 AFX_MSGMAP_ENTRY 数组,每项包含消息 ID、处理函数签名和函数指针;DECLARE_MESSAGE_MAP 在类里埋一个虚函数 GetMessageMap 重写。窗口过程拿到 WM_KEYDOWN 后,沿继承链从子类往父类查表,命中就调对应函数。理解这一点很实用:如果你的 OnKeyDown 死活不进断点,先确认三件事——函数签名和宏是否严格匹配、afx_msg 是否写了、消息映射表是否写在了 .cpp 里而不是头文件里。ON_WM_KEYDOWN 的参数签名是固定的,自己写成 void OnKeyDown(UINT nChar) 只有一个参数,就会在编译期报「无法将成员函数转换为 AFX_PMSG」,这个报错信息很难直接看出根因。
另外补一句,ON_WM_ERASEBKGND 在这里不是可选项。如果不拦截它,系统每次重绘前先用背景刷填一遍客户区,再被你的双缓冲覆盖,画面就会有肉眼可见的闪烁,后面第 4 章还会回到这个点。
3. 迷宫生成:递归回溯挖墙法在一维数组上的完整实现
3.1 数据结构选型:为什么用「墙壁位」而不是黑白位图
最直觉的做法是开一个 (2R+1) × (2C+1) 的 char 数组,1 表示墙、0 表示路,随机撒点再判连通。这样写第一版很快,但马上会遇到两个问题:一是连通性没保证,可能生成一片走不到的死区;二是墙占了一半的内存和绘制量,缩放时格子对不齐。
我一般用另一种表示:迷宫有 R 行 C 列「单元格」,每个单元格用 4 个 bit 记录四面墙是否存在。
// MazeCore.h #pragma once #include <vector> // 四个方向的墙壁位,用位或组合,方便一次判断多面墙 const int DIR_N = 1; // 0001 北 const int DIR_E = 2; // 0010 东 const int DIR_S = 4; // 0100 南 const int DIR_W = 8; // 1000 西 // 方向索引 0..3 对应 北 东 南 西,三个数组必须保持同序 const int DX[4] = { 0, 1, 0, -1 }; const int DY[4] = { -1, 0, 1, 0 }; const int WALL[4] = { DIR_N, DIR_E, DIR_S, DIR_W }; const int OPPO[4] = { DIR_S, DIR_W, DIR_N, DIR_E }; class CMazeCore { public: void Generate(int rows, int cols, unsigned seed); int Cell(int r, int c) const { return m_cells[r * m_cols + c]; } int Rows() const { return m_rows; } int Cols() const { return m_cols; } bool CanMove(int r, int c, int dir) const; private: int m_rows = 0, m_cols = 0; std::vector<int> m_cells; // 一维数组模拟二维,行优先 };一维数组配 r * m_cols + c 的寻址,比 vector<vector > 少一层指针跳转,缓存也更友好,对于 40×40 这个量级其实无所谓,但习惯养成了以后写大点的地图不会突然卡。DX/DY 表示行列偏移,注意 DY 里向北是 -1,因为屏幕坐标 y 轴向下增长;WALL 和 OPPO 是成对的,打通 A 的东墙时必须同时打通 B 的西墙,少写一半就会画出「单向门」——看着有路,走过去撞墙。
3.2 递归回溯的核心代码:用显式栈代替递归
算法本身是标准的 DFS 挖墙:从起点开始,随机选一个未访问的邻居,打通之间的墙,走过去,继续;走不动了就回退。教科书版本用递归写,代码短,但迷宫一大(比如 60×60)递归深度能到几千层,默认 1MB 栈空间直接溢出,表现是程序无声无息地退出,没有异常、没有报错,特别像玄学。用显式 stack 改写是必须的:
// MazeCore.cpp #include "pch.h" #include "MazeCore.h" #include <stack> #include <random> void CMazeCore::Generate(int rows, int cols, unsigned seed) { m_rows = rows; m_cols = cols; // 初始状态:每个格子四面都是墙 m_cells.assign(rows * cols, DIR_N | DIR_E | DIR_S | DIR_W); std::mt19937 rng(seed); // 固定种子 = 可复现的同一张图 std::vector<char> visited(rows * cols, 0); std::stack<int> st; // 存一维下标,避免 CPoint 依赖 MFC st.push(0); visited[0] = 1; while (!st.empty()) { int cur = st.top(); int cr = cur / m_cols; // 反解出行 int cc = cur % m_cols; // 反解出列 // 收集当前格所有「合法且未访问」的方向 int cand[4], n = 0; for (int d = 0; d < 4; ++d) { int nr = cr + DY[d]; int nc = cc + DX[d]; if (nr < 0 || nr >= m_rows || nc < 0 || nc >= m_cols) continue; if (visited[nr * m_cols + nc]) continue; cand[n++] = d; } if (n == 0) { st.pop(); continue; } // 死路,回退一步 int d = cand[rng() % n]; // 关键:先收集再随机,不能边遍历边随机 int nr = cr + DY[d]; int nc = cc + DX[d]; m_cells[cur] &= ~WALL[d]; // 拆掉当前格的这面墙 m_cells[nr * m_cols + nc] &= ~OPPO[d]; // 拆掉邻居的对侧墙 visited[nr * m_cols + nc] = 1; st.push(nr * m_cols + nc); } }几个参数和写法上的细节值得停一下。seed 用 unsigned 传入,主程序里通常用 GetTickCount() 或 std::random_device 取,想复现某张图就固定成常量,调试的时候非常省事。cand 数组那一句是核心:先把所有候选方向收齐,再对 n 取模随机取一个。如果写成在 for 循环里边判断边 rng() 边 break,取到的方向分布是不均匀的,生成的迷宫会明显偏向某个角落,这种「看着随机其实有偏」的问题肉眼很难发现,只有在统计大量样本后才能看出来。
顺便说,递归回溯生成的迷宫有个特点:路径长、岔路少,走起来像一条蜿蜒的长蛇,从入口到出口的解几乎是唯一的一条。想要多解、多环路的效果,得换成 Prim 或 Kruskal,随机拆墙不保证树形结构,会有环,可玩性更高但写法完全不一样。
3.3 入口出口与几个配套查询
生成完只解决了「墙在哪」,还需要两个边界约定:入口固定左上 (0,0),出口固定右下 (rows-1, cols-1)。为了保证出口可达,可以在生成后额外做一次检查,或者干脆相信算法——递归回溯保证所有格子都在同一棵生成树上,任意两点必然连通,所以出口一定可达,不用额外处理。这点在换算法的时候要重新评估,Prim 和 Kruskal 也保证连通,但如果你手写了个「随机撒点」的野路子,就必须补一次 BFS 连通性校验。
移动判定建议单独抽一个函数,让视图层只做「问一句能不能走」:
bool CMazeCore::CanMove(int r, int c, int dir) const { if (r < 0 || r >= m_rows || c < 0 || c >= m_cols) return false; if (dir < 0 || dir > 3) return false; // 该方向上没有墙,且目标格在界内,才允许移动 if (m_cells[r * m_cols + c] & WALL[dir]) return false; int nr = r + DY[dir], nc = c + DX[dir]; return nr >= 0 && nr < m_rows && nc >= 0 && nc < m_cols; }这里多写了一次边界判断,看着冗余,但如果以后加上「传送门」「边界穿越」这类玩法,这层判断就是唯一的改动点。参数 dir 用 0..3 的索引而不是墙壁位,是因为调用方(键盘处理)天然就是按方向索引组织的,转换只在 WALL[dir] 这一处发生,边界清晰。
4. 绘制与交互:OnDraw 双缓冲加 OnKeyDown 的整套走位逻辑
4.1 双缓冲绘制:为什么直接在 CPaintDC 上画会闪
MFC 视图的绘制入口是 OnDraw(CDC* pDC),框架已经替你处理了 WM_PAINT 的 BeginPaint/EndPaint。很多人第一版直接在 pDC 上一条条 LineTo,小迷宫还凑合,一旦加了定时器每秒刷新,或者窗口被拖动缩放,整个画面就开始抖动闪烁。原因不复杂:每画一条线,屏幕就真的刷一次,成千上万次半成品状态被眼睛看到了。
标准解法是内存 DC:先在兼容位图上画完整帧,再一次性 BitBlt 到屏幕。
// MazeGameView.cpp void CMazeGameView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; // 最小化时宽高为 0,必须提前返回 // 1. 建内存 DC 和兼容位图,尺寸必须和客户区一致 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2. 整帧画在内存 DC 上 memDC.FillSolidRect(&rc, RGB(248, 248, 244)); DrawMaze(&memDC); DrawPlayer(&memDC); // 3. 一次性贴到屏幕,注意最后要恢复选入的 GDI 对象 pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }顺序不能乱:CreateCompatibleBitmap 必须在 SelectObject 之前,否则选进去的是 1×1 单色位图,贴出来一片黑;SelectObject 返回的旧对象必须还回去,否则 GDI 对象泄漏,跑久了会看到「句柄无效」的错误。另外记得同时处理 OnEraseBkgnd,直接 return TRUE 告诉系统「背景我自己负责」,这样才彻底断掉闪烁的来源。
迷宫本体绘制就是逐格判断四个墙壁位:
void CMazeGameView::DrawMaze(CDC* pDC) { const int cs = m_cellSize; // 单格像素边长,随窗口大小动态算 CPen pen(PS_SOLID, 2, RGB(40, 44, 52)); CPen* pOldPen = pDC->SelectObject(&pen); pDC->SelectStockObject(NULL_BRUSH); // 只画线,不填充 for (int r = 0; r < m_core.Rows(); ++r) { for (int c = 0; c < m_core.Cols(); ++c) { const int x = c * cs, y = r * cs; const int v = m_core.Cell(r, c); if (v & DIR_N) { pDC->MoveTo(x, y); pDC->LineTo(x + cs, y); } if (v & DIR_W) { pDC->MoveTo(x, y); pDC->LineTo(x, y + cs); } if (v & DIR_E) { pDC->MoveTo(x + cs, y); pDC->LineTo(x + cs, y + cs); } if (v & DIR_S) { pDC->MoveTo(x, y + cs); pDC->LineTo(x + cs, y + cs); } } } pDC->SelectObject(pOldPen); }重复画相邻两格共用的墙是故意的,因为做了 &= ~WALL 拆墙,共用墙在两侧都不存在,重复判断只是多几次比较,换来的是绘制逻辑不用考虑邻居,出错概率低。真正影响性能的是画笔对象的创建——如果每格都 new 一支 CPen,几万次创建销毁会明显拖慢重绘,所以画笔提到循环外创建一次。这也是新手代码里很常见的一处性能翻车点。
4.2 键盘响应与移动判定:焦点这件事必须处理
视图类要收到 WM_KEYDOWN,前提是它持有焦点。单文档程序默认框架窗口是主窗口,键盘消息可能先被 CMainFrame 吃掉。稳妥做法是在 OnInitialUpdate 里主动抢焦点:
void CMazeGameView::OnInitialUpdate() { CView::OnInitialUpdate(); SetFocus(); // 让视图成为焦点窗口,否则按键无响应 m_core.Generate(16, 16, (unsigned)GetTickCount()); m_playerR = 0; m_playerC = 0; m_steps = 0; m_elapsed = 0; SetTimer(1, 1000, nullptr); // ID=1,间隔 1000ms 的计时器 } void CMazeGameView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { int dir = -1; switch (nChar) { case VK_UP: dir = 0; break; case VK_RIGHT: dir = 1; break; case VK_DOWN: dir = 2; break; case VK_LEFT: dir = 3; break; default: CView::OnKeyDown(nChar, nRepCnt, nFlags); return; } if (m_core.CanMove(m_playerR, m_playerC, dir)) { m_playerR += DY[dir]; m_playerC += DX[dir]; ++m_steps; // 只刷新变化区域,BCalc 传 FALSE 避免先擦背景再画,进一步抗闪 CRect rc = GetCellRect(m_playerR, m_playerC); InvalidateRect(&rc, FALSE); InvalidateRect(&m_lastPlayerRect, FALSE); m_lastPlayerRect = rc; if (m_playerR == m_core.Rows() - 1 && m_playerC == m_core.Cols() - 1) { KillTimer(1); CString msg; msg.Format(_T("通关!步数 %d,用时 %d 秒"), m_steps, m_elapsed); AfxMessageBox(msg); } } CView::OnKeyDown(nChar, nRepCnt, nFlags); }参数说明几个容易忽略的:nChar 是虚拟键码,方向键对应 VK_UP 到 VK_LEFT,不是字符 'W'/'A'/'S'/'D',想加 WASD 直接在 switch 里加 4 个 case 映射到同样的 dir 即可;nRepCnt 是重复计数,按住方向键不放系统会连发,这里不处理也能用,但如果你做了加速或连击就很关键;nFlags 含扫描码和扩展位,一般用不上。
InvalidateRect 传 FALSE 表示「不要擦背景」,因为我们全帧重绘,擦背景纯属多余,还会引入一次闪烁。这是从每秒刷新升级到高频按键响应时最有效的一处优化,改完之后走位的手感会立刻不一样。
4.3 计时、步数与状态收敛
计时用 SetTimer 最简单,WM_TIMER 精度大约 15ms 级别,做秒级显示完全够用。OnTimer 里判一下 ID:
void CMazeGameView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { ++m_elapsed; InvalidateRect(nullptr, FALSE); // 全量刷新,状态栏数字变了 } CView::OnTimer(nIDEvent); }要注意的是资源释放:视图销毁时要 KillTimer,否则定时器继续向已销毁的窗口发消息,在调试版下会触发断言。可以在 OnDestroy 里补一行,或者用 CWnd::SetTimer 的返回值存下来统一管理。另外点击菜单重置迷宫时,一定要先 KillTimer 再重新 SetTimer,并把 m_elapsed 清零,我见过不止一个版本重置后计时器叠了两个,秒数跳得飞快。
5. 避坑排查:MFC 新手在这类项目上最容易翻车的 5 个点
现象一:编译报「无法打开包括文件: pch.h」。原因通常是项目从旧版本迁过来,预编译头文件名是 stdafx.h,而新加的 cpp 里写的是 pch.h;或者项目的 C/C++ → 预编译头设置成了「使用」,但文件开头没写 #include "pch.h"。解决方式是统一改文件名,或者在文件属性里把该 cpp 的预编译头单独设成「不使用预编译头」。批量处理时按文件名全局搜索一次最省事。
现象二:程序启动后窗口一闪就没了,或者一片白。先分清是哪种。一闪而过通常是 InitInstance 里某步返回了 FALSE,比如 ProcessShellCommand 失败,常见根因是 IDR_MAINFRAME 字符串资源被别人误删,或者文档模板的 RUNTIME_CLASS 少了 IMPLEMENT_DYNCREATE。一片白则多半是 OnDraw 里提前 return 了,比如 GetClientRect 拿到 0 尺寸时直接退出,而窗口还没真正显示出来。加一句断点看 rc 的宽高,能立刻定位。
现象三:方向键完全没反应,但鼠标点击有反应。键盘消息走的是焦点链,鼠标走的是命中测试,两者路径不同,所以会出现「鼠标能用键盘不能」。检查 OnInitialUpdate 有没有 SetFocus,以及 CMainFrame 是不是把 WM_KEYDOWN 拦截了。还有一个隐蔽情况:视图里放了个 CEdit 或按钮控件做调试面板,焦点被控件拿走,必须在点击视图时把焦点抢回来。实在查不出,就在 PreTranslateMessage 里打日志,看消息最后落在哪个窗口。
现象四:迷宫生成时程序直接退出,没有任何报错。十有八九是递归爆栈。教科书版本的递归回溯在 50×50 以上的地图上深度能到两三千层,默认栈空间顶不住,崩溃方式极其安静。改成第 3 章那样的显式栈就没事了。如果已经用了栈还是偶发异常,检查 visited 数组的下标有没有越界,r * m_cols + c 里 m_cols 用成了 m_rows 是这类 bug 的经典写法。
现象五:拖动窗口改大小时画面严重抖动,或者缩到最小再恢复就全黑。抖动是没做双缓冲或没拦 OnEraseBkgnd;全黑是 CreateCompatibleBitmap 拿到了 0 尺寸,因为最小化时客户区宽高为 0,此时创建位图会失败,后面的 BitBlt 自然什么也没贴。解决就是在 OnDraw 开头加 IsRectEmpty 判断直接返回,等窗口恢复后系统会自动重发一次 WM_PAINT。这条踩过一次就会记一辈子。
6. 二次开发:把 16×16 的演示改成带寻路提示和关卡的小游戏
骨架跑通之后,往上加东西的顺序我一般是这样排的:先参数化,再加玩法,最后做持久化。
第一步把硬编码抠出来。行数、列数、格子边长、背景色这些散落在各处的魔数,统一收进一个配置结构,迷宫尺寸改成在菜单里可选 8×8 / 16×16 / 32×32:
struct MazeConfig { int rows = 16; int cols = 16; int cellSize = 24; // 像素,窗口尺寸 = cols * cellSize COLORREF wallColor = RGB(40, 44, 52); unsigned seed = 0; // 0 表示每次随机 };改尺寸时最容易漏的是玩家坐标和「上一帧矩形」的重置,换图后如果不把 m_lastPlayerRect 清空,会看到屏幕上残留一个旧位置的小方块。
第二步加提示功能,也就是从当前位置到出口的一条路径高亮。用 BFS 求最短路,因为迷宫本身没有权重,BFS 出来的就是最短解,几十行代码:
// 返回从 (sr,sc) 到出口的一条最短路;空 vector 表示不可达 std::vector<int> CMazeCore::Solve(int sr, int sc) const { std::vector<int> prev(m_rows * m_cols, -1); std::vector<char> vis(m_rows * m_cols, 0); std::queue<int> q; int start = sr * m_cols + sc; q.push(start); vis[start] = 1; while (!q.empty()) { int cur = q.front(); q.pop(); int cr = cur / m_cols, cc = cur % m_cols; for (int d = 0; d < 4; ++d) { if (!CanMove(cr, cc, d)) continue; int nr = cr + DY[d], nc = cc + DX[d]; int ni = nr * m_cols + nc; if (vis[ni]) continue; vis[ni] = 1; prev[ni] = cur; q.push(ni); } } // 从出口回溯路径 std::vector<int> path; int goal = (m_rows - 1) * m_cols + (m_cols - 1); if (!vis[goal]) return path; for (int p = goal; p != -1; p = prev[p]) path.push_back(p); std::reverse(path.begin(), path.end()); return path; }拿到路径后在 DrawMaze 之后叠一层半透明高亮或者虚线,提示就用一次扣一次分,玩法闭环就有了。
第三步做关卡与存档。最省事的方案是把每关的 seed 和尺寸写进一个 ini 或 JSON,通关后解锁下一关;存档只存「当前关卡号 + 最好成绩」,用 CWinApp::WriteProfileInt 写注册表或写本地 ini 都行,十几行的事。别一上来就设计复杂的关卡编辑器,先跑通数值驱动,编辑器的需求会自然浮现。
验证方法上我有个固定套路:每次改完,先用固定 seed 生成一次,走一遍完整通关流程,看步数是否和手动数的一致;再把窗口拖到最大最小各一次,确认无残留无黑屏;最后连续按方向键快速撞墙二十次,确认玩家不会穿墙。这三步花不了三分钟,能挡住八成的回归问题。
踩过的坑是改完地图尺寸忘了同步重算 cellSize,窗口没跟着变大,迷宫被裁掉一半,玩家走到边界就「凭空消失」,查了半天以为是 CanMove 的边界判断写错了。从那以后我每次动尺寸参数,都强制把「生成 → 绘制 → 一次完整通关」这条链路从头走一遍,不敢再偷懒。这份代码结构不复杂,但正是这种小项目值得亲手拆一遍,希望帮到你。
本文还有配套的精品资源,点击获取