
简介一份基于VC与MFC的迷宫小游戏完整工程面向初学C或正在准备课程设计的开发者解决如何随机生成迷宫地图、并通过键盘方向键控制红色方块从起点走到出口的问题。压缩包仅14KB共11个文件包含5个头文件、1个cpp主程序以及aps、dsp、rc、hm、dsw等MFC工程常用配套文件头文件按职责拆分为迷宫创建、地图绘制、路径查找等模块cpp中实现窗口与游戏流程源码和工程配置齐全可直接编译运行。通过这个小巧实例可学习随机迷宫生成思路、MFC消息映射、键盘响应及简单游戏循环设计无论是初学者模仿练习还是想快速做出迷宫原型都很有参考价值。目前已有488人学习浏览搭配博文中的说明能帮助初学者更快理解代码结构与运行原理。1. VC迷宫游戏随机地图的关键是生成算法不是绘图VC迷宫游戏这个题目乍看像是课程设计里最普通的练手项目实际做下来你会发现一个反直觉的事实让地图随机生成的算法代码不到一百行真正把项目拖垮的往往是环境配置、重绘闪烁和随机数种子这些不起眼的细节。随机生成迷宫地图核心是把“生成算法”和“地图数据模型”分开设计——算法决定路和墙怎么分布数据模型让绘图和寻路都能高效访问同一个数组。适合刚学完C、想找个带图形界面的Windows项目练手的人也适合正在准备课程设计或面试项目的同学。跟着这篇做你能把一个每次地图都不一样、可玩性完整的迷宫游戏跑起来而且能说清楚每一步为什么这么写。2. 随机生成的地图怎么来三种迷宫算法选型与VC环境取舍2.1 三种生成算法深度优先回溯、随机Prim、Kruskal的效果差异做迷宫随机生成第一步不是写代码而是选算法。方格迷宫上主流做法就三种深度优先回溯DFS、随机Prim、Kruskal。三种算法跑出来的迷宫长相完全不同选错方向后面调参很痛苦。算法核心思路迷宫特征实现难度深度优先回溯栈 随机方向走不通就回退长走廊多主线清晰有探索感最易随机Prim维护候选墙集合随机打通岔路均匀路径短没有特别长的直道中等Kruskal把墙当边乱序合并并查集维护连通性与Prim接近但实现更绕中等偏上深度优先回溯的判据最直观从当前格子向某个方向跳两格如果目标格还没被访问过就打通两格中间那面墙走过去把新格子压栈四个方向都走不了就弹栈回退。这个“跳两格”是整格墙模型的关键后面第3章会细讲。效果上它生成的迷宫有很明显的“树状”结构从起点到终点通常只有一条通路路径长、岔路少玩家走起来有“深入洞穴”的感觉——这也是多数商业迷宫游戏选它的原因。随机Prim的思路不同它把所有“墙”塞进一个候选集合随机抽一面墙如果墙两侧恰好一个格子已经连通、另一个还没连通就打通它。因为挑选是全局随机的生成结果不会出现长走廊而是四处开花路线分布均匀。缺点是实现要维护墙集合代码比DFS长三分之一而且生成的迷宫岔路太多玩家容易迷路。Kruskal本质和Prim等价只是换成了对墙做随机乱序、用并查集合并。方格迷宫上用它的收益不大除非你已经有并查集工具类否则我不推荐在课程设计里选这条。我的建议是迷宫游戏首选深度优先回溯代码量少、效果有探索感等游戏能跑了再把Prim加进去做难度切换让同一次运行能生成风格不同的两张图。2.2 VC环境取舍VC 2022 还是 Visual Studio 2008Win32 还是 MFC环境选型直接决定你后面要踩多少坑。老教材里迷宫游戏多数配的是Visual Studio 2008VC 9.0但那是十多年前的编译器了。现在很多人在Win10、Win11上装完VS 2008编译倒是能过一运行就报错或者安装其它软件时提示识别不了VC运行时——这类问题这几年特别多根因基本都是老版本运行时库和新系统不兼容。我的建议很明确新机器一律用Visual Studio 2022编译器对应VC 2022平台工具集v143不要为了“教材用的是2008”去折腾老环境。但代码要按标准C来写——不碰C/CLI、不碰托管扩展、不用MFC的强制依赖。这样写出来的代码以后想切回VS 2008或者换到别的工具集改动只是项目属性里的一个下拉框源码一行不用动。界面框架选Win32 API还是MFC迷宫游戏只有一个主窗口、一组键盘消息、一块GDI绘图区Win32 API完全够用而且消息循环和WM_PAINT的机制是Windows编程的底子面试时能讲清楚是加分项。MFC封装得深遇到问题你在框架里绕圈子不如直接用Win32把窗口过程、消息分发、绘图刷新这几条链路看明白。不过如果你所在学校的课程设计大纲强制要求MFC那就在Win32代码外面套一个MFC的View类壳子核心的迷宫生成和绘制逻辑原样搬进去。2.3 地图的数据模型整格墙模型为什么最适合迷宫游戏选好算法接下来定数据模型。方格迷宫在代码里有两种主流表示法一种是“每个格子记录四面墙有没有”的墙面模型另一种是“整张地图就是一个二维数组、奇数位是路、偶数位是墙”的整格墙模型。墙面模型更贴近人的直觉每个单元格存N、E、S、W四个bool表示四面墙是否立着。但它绘图时要额外换算——画墙得遍历每个格子的四边画路又要重新查一遍两套坐标来回切新手很容易绕晕。整格墙模型就简单粗暴maze[r][c]等于1表示这是一个可走的路格等于0表示这是墙格数组下标直接映射到画布坐标一个格子画一个方块碰撞检测也只需要查目标格子的值是0还是1。迷宫游戏选整格墙模型几乎是共识原因有三第一渲染逻辑极度简单像素格和数组元素一一对应第二碰撞检测就是一次数组取值不需要算几何第三它天然支持BFS寻路和存档序列化因为这些操作本质上都在同一个二维数组上。代价是内存多占一半墙格和路格数量接近1比1但一个100×100的迷宫也就一万个int完全可以忽略。注意整格墙模型有两个硬性约束迷宫的行列数必须是奇数否则路和墙无法交替排布地图最外圈必须是墙否则生成出来的迷宫四面漏风玩家一步就能走出去。这两条在初始化时就要保证后面所有代码都建立在它们之上。3. 把算法落成代码地图数据结构和深度优先生成实现3.1 地图初始化与随机数引擎为什么用mt19937而不是rand代码从数据结构开始。地图用一个二维vector不用C数组——因为迷宫尺寸要在运行时决定方便后续做难度切换比如11×11、21×21、51×51随意改。#include vector #include random // 迷宫地图0墙1路 std::vectorstd::vectorint maze; int rows, cols; // 初始化全部置为墙起点和终点在后续生成中打通 void InitMaze(int r, int c) { rows r; cols c; maze.assign(rows, std::vectorint(cols, 0)); } // 随机数引擎mt19937 random_device 真随机种子 std::mt19937 rng; void InitRandom() { std::random_device rd; rng.seed(rd()); }InitMaze里maze.assign把整张地图填成0也就是初始状态全是墙一个路都没有。注意这里不要“提前把奇数格铺成路”——很多教程会先铺路再打墙那套做法配合DFS会出问题第5章会讲它错在哪。随机数引擎用mt19937替代老式的rand()原因有两个rand()的状态空间小生成大迷宫时模式容易重复而用srand(time(NULL))播种子同一秒内连续启动两次程序生成的地图一模一样这在演示时很尴尬。3.2 深度优先随机生成核心代码与参数说明生成函数是整篇代码的核心不到四十行。它做的事情概括成一句话维护一个栈每次从栈顶格子尝试向随机方向挖两步挖不动就回溯。#include stack void GenerateMaze(int startR, int startC) { // 栈保存已访问的路径格子 std::stackstd::pairint, int st; maze[startR][startC] 1; // 起点打通为路 st.push({startR, startC}); // 四个方向每次跳两格跨过中间墙 int dirs[4][2] {{-2, 0}, {2, 0}, {0, -2}, {0, 2}}; while (!st.empty()) { int r st.top().first; int c st.top().second; // 收集当前格子所有能去的方向 std::vectorint valid; for (int i 0; i 4; i) { int nr r dirs[i][0]; int nc c dirs[i][1]; // 目标格必须在边界内且还是墙未访问 if (nr 0 nr rows - 1 nc 0 nc cols - 1 maze[nr][nc] 0) { valid.push_back(i); } } if (valid.empty()) { st.pop(); // 四个方向都走不了回溯 } else { // 随机挑一个可行方向 int dir valid[rng() % valid.size()]; int nr r dirs[dir][0]; int nc c dirs[dir][1]; // 打通当前格子和目标格子中间的墙 maze[r dirs[dir][0] / 2][c dirs[dir][1] / 2] 1; maze[nr][nc] 1; // 打通目标格 st.push({nr, nc}); } } }几个参数值得细看。dirs里每个方向跳两格比如向上是(-2, 0)它中间的墙在(r-1, c)所以打通时用r dirs[dir][0] / 2——除以2是因为跳两格中间墙只偏移一格。这个映射写错会导致迷宫出现“双格厚”的墙或者路和墙错位。valid的边界判断用nr 0 nr rows - 1是因为地图最外圈必须保留为墙不能让生成器在边缘上开洞。maze[nr][nc] 0这个条件有两个含义目标格是墙同时“还没被访问过”——因为所有被访问过的格子都已经被置成1了所以这个判断同时做了访问标记和墙检测两件事这是整格墙模型的一个小技巧。调用方式起点固定取(1, 1)终点固定取(rows - 2, cols - 2)这两个角在奇数尺寸下必然是路格生成完自然连通。建议在WinMain里先InitMaze(41, 41)再GenerateMaze(1, 1)这样默认就是一张41×41、实际可走区域约20×20的迷宫。3.3 生成结果检查先打印再谈绘图图形界面还没接之前先用控制台打印迷宫的全貌。这一步能帮你分清“算法错了”和“显示错了”#include iostream void PrintMaze() { for (int r 0; r rows; r) { for (int c 0; c cols; c) { std::cout (maze[r][c] 1 ? : #); } std::cout \n; } }调试时可以跑一个只做控制台输出的版本眼睛扫一遍结构理想效果是一条不规则的白色通路贯穿整张图没有孤立的黑色区域。更严格的做法是写一个BFS连通性检查确认起点能走到终点#include queue bool IsReachable(int sr, int sc, int er, int ec) { std::queuestd::pairint, int q; std::vectorstd::vectorbool visited(rows, std::vectorbool(cols, false)); q.push({sr, sc}); visited[sr][sc] true; int dirs[4][2] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r er c ec) return true; for (auto d : dirs) { int nr r d[0], nc c d[1]; if (nr 0 nr rows nc 0 nc cols !visited[nr][nc] maze[nr][nc] 1) { visited[nr][nc] true; q.push({nr, nc}); } } } return false; }DFS生成的迷宫理论上必定连通这个函数更多是给后期扩展留保险——如果你把Prim算法接进来做第二种地图随时可以用它做自动化回归验证。每一次重新生成迷宫后调用IsReachable(1, 1, rows - 2, cols - 2)返回false就重新生成这比肉眼检查可靠得多。4. GDI绘图与键盘控制让迷宫跑起来的交互实现4.1 GDI双缓冲绘制流程消除闪烁的画法迷宫要显示在窗口上最直接的GDI画法是用FillRect逐格填充墙画深色路画浅色。但如果每次WM_PAINT都直接在窗口DC上画窗口拖动、刷新时会闪得眼睛疼——因为背景擦除和前景绘制之间有间隙整个画面会先变白再画出来。双缓冲是标准解先在内存里画好一整张位图再一次BitBlt拷到窗口。代码如下#include windows.h void DrawMaze(HWND hwnd, HDC hdc, int cellSize) { RECT rect; GetClientRect(hwnd, rect); int w rect.right - rect.left; int h rect.bottom - rect.top; // 创建内存DC和兼容位图 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, w, h); HGDIOBJ oldBmp SelectObject(memDC, memBmp); // 背景浅灰色路格 HBRUSH roadBrush CreateSolidBrush(RGB(240, 240, 240)); HBRUSH wallBrush CreateSolidBrush(RGB(40, 40, 40)); FillRect(memDC, rect, roadBrush); for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (maze[r][c] 0) { RECT cell {c * cellSize, r * cellSize, (c 1) * cellSize, (r 1) * cellSize}; FillRect(memDC, cell, wallBrush); } } } // 一次拷到窗口 BitBlt(hdc, 0, 0, w, h, memDC, 0, 0, SRCCOPY); // 按相反顺序释放资源 SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); DeleteObject(roadBrush); DeleteObject(wallBrush); }细看几个点。CreateCompatibleBitmap创建的内存位图和窗口DC的颜色格式一致这是后续BitBlt不出花屏的前提。FillRect先用浅色铺满整张图再专门把墙格盖成深色这样路格自然就是背景色不用逐格画路。资源释放的顺序有讲究先把oldBmp选回memDC再删除位图和DC——如果直接删除memBmp而它还被选在memDC里GDI会报错或产生资源泄漏这个坑在连续切换迷宫尺寸时尤其容易出来。最后DeleteObject清理画刷顺序别乱。cellSize建议取8到12像素太小路格看不清太大整张图超窗口。WM_PAINT里调用时套标准写法case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); DrawMaze(hwnd, hdc, cellSize); EndPaint(hwnd, ps); return 0; }4.2 玩家移动与碰撞判定键盘消息与边界检查玩家移动在WM_KEYDOWN里处理。核心是把“要去的格子”查一遍数组值是1才允许移动值是0就原地不动。同时要处理胜利判定——玩家到达地图右下角(rows-2, cols-2)游戏结束。int playerR, playerC; // 玩家当前所在路格坐标 void TryMove(int dr, int dc) { int nr playerR dr; int nc playerC dc; // 边界检查 目标格必须是路 if (nr 0 nr rows nc 0 nc cols maze[nr][nc] 1) { playerR nr; playerC nc; if (playerR rows - 2 playerC cols - 2) { MessageBox(NULL, L通关了, L迷宫, MB_OK); } } } case WM_KEYDOWN: switch (wParam) { 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; } InvalidateRect(hwnd, NULL, TRUE); // 请求重绘 return 0;移动步长是1而不是生成时的2因为玩家是逐格走的。碰撞判定只查目标格是不是路不需要额外判断“玩家当前格子一定在路上”——只要初始化时把playerR和playerC设在(1, 1)且移动永远只进路格这个不变量就能一直保持。这里有个容易翻车的点如果你在绘制时用别的方式标记玩家比如画个红点记得红点只影响渲染不影响maze数组玩家位置永远用playerR和playerC这两个独立变量不要直接改maze数组里的值否则碰撞数据会被污染。4.3 游戏主循环消息泵与重绘触发时机Win32程序的“主循环”不是while(true)里Sleep而是消息循环。玩家按键后通过InvalidateRect触发WM_PAINT重绘请求会合并——连续按十次方向键Windows可能只触发一次重绘这是好事省CPU。// 让窗口以固定频率响应键盘避免按键连发导致角色“瞬移” SetTimer(hwnd, TIMER_MOVE, 100, NULL); case WM_TIMER: if (wParam TIMER_MOVE) { // 如果希望按住方向键持续移动在这里查键盘状态 if (GetAsyncKeyState(VK_UP) 0x8000) TryMove(-1, 0); if (GetAsyncKeyState(VK_DOWN) 0x8000) TryMove(1, 0); if (GetAsyncKeyState(VK_LEFT) 0x8000) TryMove(0, -1); if (GetAsyncKeyState(VK_RIGHT) 0x8000) TryMove(0, 1); InvalidateRect(hwnd, NULL, TRUE); } return 0;两种交互风格二选一单次按键走一格用WM_KEYDOWN就够了按住方向键连续移动用WM_TIMER轮询键盘状态更稳。为什么不在WM_KEYDOWN里连续移动因为键盘重复消息的触发速度由系统参数控制不同电脑手感完全不同——游戏里角色移动速度却应该由代码统一决定这就是帧率控制的雏形第6章再展开。5. 常见问题与避坑随机种子、运行时库与重绘的排查手记5.1 编译通过运行报错VC运行时库缺失现象代码在VS 2022里编译零错误生成的exe拷到另一台电脑双击直接提示“缺少VCRUNTIME140.dll”或“无法定位程序输入点”或者安装某些软件时安装器弹出“识别不了VC”的提示尤其是Visual Studio 2008VC 9.0的runtime在Win10、Win11上装不上或装上失效。原因VS默认用动态链接的方式连运行时库exe运行时需要目标机器存在对应版本的VC Redistributable。老版本如VS 2008生成的exe依赖的VC 2008 runtime在新系统上兼容性差经常出现“runtime libraries are not installed”这类误报。解决优先改项目属性——配置属性→常规→平台工具集把v143或更高版本选上再重新编译新系统用新运行时最省心。但如果你确实要分发到没有网的老机器上可以把运行库改成静态链接配置属性→C/C→代码生成→运行库把“多线程DLL(/MD)”改成“多线程(/MT)”。这样编译器会把运行时代码直接塞进exe体积增大一两百KB但exe不再依赖外部DLL一个人拷走就能跑。注意改/MT后Debug和Release各需要重新编译一次且Debug版静态库自带调试断言别拿Debug版当正式分发版。5.2 每次生成的地图都一样随机数种子的坑现象程序每次启动迷宫一模一样或者迷宫连着生成两次第二张图只是第一张的左右翻转明显有规律。原因生成函数里用的是rand()但没调用srand()或者用srand(time(NULL))做种子——同一秒内启动两次time(NULL)返回同一个值种子相同随机序列自然相同。更隐蔽的坑是有人先写了“把奇数格全部铺成路然后用DFS撞墙”的初始化方案导致无论随机种子怎么换迷宫总有大量双通路看起来一点都不随机。解决用std::mt19937 std::random_device代码已经写在3.1节。random_device在Windows上读取系统真随机源两次启动的种子几乎不可能重复。至于“奇数格铺路”的错误模型正确初始化是——地图全置0全墙从(1,1)开始走DFS走过的路才改成1。如果发现迷宫里存在两个相邻的路格之间没有墙、白白宽出一块走廊基本就是初始化铺路铺多了。5.3 画面闪烁和玩家穿墙现象窗口拖动时画面闪白、刷新时像在闪灯另一个现象是玩家在迷宫最边缘按方向键程序直接崩溃或者明明前面是墙但角色还是走出去了。原因闪烁是因为没有双缓冲同时WM_ERASEBKGND默认会擦背景——擦一下画一下就闪了。穿墙或崩溃几乎都是移动判断里漏了边界检查比如数组下标越界访问到maze[-1][0]或者把结束条件写成玩家坐标等于终点坐标但终点周围还没生成路。解决闪烁分两步——窗口过程里加一条case WM_ERASEBKGND: return TRUE;禁止系统擦背景再配合第4章的双缓冲绘制闪动基本消失。穿墙问题则是TryMove函数里的边界判断不能省nr和nc必须先检查在0到rows-1、0到cols-1范围内再去访问maze[nr][nc]顺序不能反——C的短路求值虽然能保护你但写成两行独立if更容易读。另外终点坐标(rows-2, cols-2)在奇数尺寸下必定是路格但如果有人把迷宫尺寸改成偶数终点恰好落在墙里游戏一开始就通关这样的“幸运bug”调试起来极其迷惑——务必在InitMaze里强制检查奇偶性行列数不是奇数就直接报错。6. 进阶用BFS验证迷宫可解再给难度和帧率做调优先给迷宫加一个最实用的功能显示通关路径。把3.3节的BFS改成带路径回溯的版本BFS不光记录“是否访问”还要记录每个格子是从哪个格子走过来的。到达终点后沿着parent链一路回推把路径格子标成2绘图时画成另一种颜色玩家就能看到从起点到终点的最短步数。std::vectorstd::pairint,int FindPath(int sr, int sc, int er, int ec) { std::vectorstd::vectorstd::pairint,int parent( rows, std::vectorstd::pairint,int(cols, {-1, -1})); std::queuestd::pairint,int q; q.push({sr, sc}); parent[sr][sc] {sr, sc}; int dirs[4][2] {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; while (!q.empty()) { auto [r, c] q.front(); q.pop(); if (r er c ec) { std::vectorstd::pairint,int path; int pr er, pc ec; while (!(pr sr pc sc)) { path.push_back({pr, pc}); auto [fr, fc] parent[pr][pc]; pr fr; pc fc; } path.push_back({sr, sc}); std::reverse(path.begin(), path.end()); return path; } for (auto d : dirs) { int nr r d[0], nc c d[1]; if (nr 0 nr rows nc 0 nc cols parent[nr][nc].first -1 maze[nr][nc] 1) { parent[nr][nc] {r, c}; q.push({nr, nc}); } } } return {}; // 理论上看不到防御性返回 }找路径只是热身。难度控制才是这个项目真正能展示深度的部分迷宫尺寸决定宏观难度生成算法决定风格帧率和移动速度决定手感。下面这个参数组合表是我调过的经验值Dimension指迷宫行数边长cellSize指每个格子的像素大小。难度DimensioncellSize算法风格体验描述演示11×1124DFS一眼看穿适合验证功能默认41×4112DFS正常游戏体验几分钟通关偏难71×718混合DFSPrim岔路多容易迷路挑战101×1016Prim需要路径高亮辅助配合尺寸帧率用SetTimer的周期值控制单步移动时代码把周期设在100ms按住方向键连续走时周期缩到50ms手感明显不同。周期太短低于30msWM_TIMER消息会积压角色移动反而卡顿这是Windows消息循环的固有限制不是你的逻辑写错了。做这个项目我有个从翻车里得来的习惯每改一次生成算法就顺手跑一遍IsReachable做回归测试。有一次我把Prim集成进去自测时肉眼觉得地图“看起来都对”结果玩家在某片区域绕了十圈也到不了终点——就是没做连通性验证。所以我把BFS验证写成了生成流程的固定环节生成→验证→不可达就重新生成最多重试三次再报错。这个习惯帮我挡掉了很多次改动引入的隐性bug。迷宫这个项目看起来简单但把随机生成、数据模型、GDI绘制、碰撞检测、路径寻优串起来之后它几乎覆盖了Windows客户端游戏的最小完整闭环。你把这套代码吃透往后接图形引擎、接存档系统都有了清晰的参照物——技术上的通用能力往往就是从这种不起眼的练手项目开始的。希望这些经验帮到你。本篇收在路径验证这个点上的原因很简单一个不能保证“有解”的迷宫游戏功能写得再花哨也立不住。先用BFS守住底线再去折腾花样是我做这个方向最值得分享的套路。本文还有配套的精品资源点击获取