☰
VC++ MFC迷宫游戏实战:从随机生成到BFS寻路
2026/10/5 5:02:10 网站建设 项目流程

简介:面向C++初学者和游戏开发爱好者,这份VC++迷宫游戏源码演示了如何基于MFC或Win32实现迷宫生成与键盘交互。项目核心包括随机地图生成、红块人物控制、出口判定与胜负逻辑,代码结构清晰,适合学习路径规划、碰撞检测和消息处理。压缩包共11个文件,以5个头文件、1个cpp源文件为核心,辅以工程配置、资源脚本与辅助模块,整体仅14KB,内容紧凑且便于快速查阅。目前已有488人学习下载,可用于个人练手或课程设计参考。资源既可以直接编译运行体验,也可对照源码拆解随机迷宫算法与键盘响应流程,帮助理解游戏开发基础流程。

1. VC++迷宫游戏:为什么这个MFC小项目还值得下

如果你是刚啃完C++语法、想找个不涉及复杂图形库也能练手MFC的项目,这个VC++迷宫游戏挺合适。它不是一个网页小游戏,也不是Unity工程,而是一个典型的老式MFC单文档程序:启动后窗口里出现随机生成的迷宫地图,你通过键盘方向键控制红色方块,从左上角走到右下角就算赢。整个项目从地图生成、绘制到寻路提示都塞在几个带注释的头文件和cpp里,Maze32.dsw/dsp直接双击就能在Visual Studio里打开。适合用来理解MFC消息映射、二维数组建模和经典迷宫算法。我拆包后发现,它不像很多课程设计那样只给结果,而是把“随机生成迷宫”这个核心逻辑独立成了createmaze.h,把寻路独立成findway.h,模块边界干净,这点倒比不少应付毕业设计的代码强。

2. 工程骨架与消息循环:从Maze32.dsp到Maze32.cpp

很多人拿到这个压缩包后第一反应是找“主函数”。MFC程序里没有传统意义上的main入口,真正的启动逻辑藏在Maze32.cpp的CWinApp派生类里。这个项目里的Maze32.dsw是Visual C++ 6.0的工作区文件,Maze32.dsp是工程文件,双击dsw会自动关联到dsp。新版VS打开时若提示“不支持格式”,选择“不转换”通常也能打开,或是用VS2008以上的版本导入后自动升级工程。

2.1 从dsw到cpp:工程文件怎么串起来

先梳理文件关系,不然你会在解决方案资源管理器里迷路:

文件作用
Maze32.dsw / Maze32.dsp工作区与工程配置,决定编译哪些文件
Maze32.cpp程序主文件,包含应用类、窗口类、消息循环入口
Maze32.rc / resource.h资源脚本与资源ID定义,窗口菜单和图标在这里
createmaze.h / tidymaze.h迷宫生成与整理算法
drawmaze.h迷宫和红块的绘制函数
findway.h自动寻路算法声明

Maze32.cpp里典型的MFC入口是这样的:

// Maze32.cpp : 定义应用程序类 #include "stdafx.h" #include "Maze32.h" #include "Maze32Dlg.h" // 如果是对话框程序则包含对话框头文件 BEGIN_MESSAGE_MAP(CMaze32App, CWinApp) // 处理“关于”菜单等命令 END_MESSAGE_MAP() CMaze32App theApp; BOOL CMaze32App::InitInstance() { CMaze32Dlg dlg; m_pMainWnd = &dlg; dlg.DoModal(); return FALSE; }

这个工程更接近单文档视图或多线程控制台混合结构,但消息映射的写法完全一致。逻辑说明:InitInstance是整个MFC程序的实质入口,theApp作为全局对象在进程启动时构造,随后进入消息循环。参数上,DoModal()表示这是一个模态对话框;如果要用单文档框架,则改为CMainFrame和文档模板的串联。从resource.hm这个文件来看,工程保留了资源提示信息,说明它原本是完整可编译的。

2.2 消息映射与定时器入口

迷宫游戏需要持续监听键盘,而不是像按钮程序那样等待单击。MFC里键盘事件的消息映射名是WM_KEYDOWN,你需要在窗口类的消息映射宏里加一行:

BEGIN_MESSAGE_MAP(CMazeDlg, CDialogEx) ON_WM_KEYDOWN() ON_WM_PAINT() END_MESSAGE_MAP() void CMazeDlg::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { // nChar 代表虚拟键码,例如VK_LEFT/VK_RIGHT/VK_UP/VK_DOWN if (nChar == VK_LEFT) MoveSquare(-1, 0); if (nChar == VK_RIGHT) MoveSquare(1, 0); if (nChar == VK_UP) MoveSquare(0, -1); if (nChar == VK_DOWN) MoveSquare(0, 1); CDialogEx::OnKeyDown(nChar, nRepCnt, nFlags); }

注意nRepCnt是重复次数,按住方向键时会连续触发。我一般会在移动函数里加一个最小时间间隔,或者用GetTickCount()限制移动频率,否则迷宫里的红块会“飞”出去。这个项目里没有做帧率控制,而是直接依赖键盘事件触发重绘,所以在旧机器上飞快,新机器上可能快得没法玩。想让它稳一点,可以在OnKeyDown里判断当前时间与上次移动时间差大于120ms才响应。

3. 随机迷宫生成:createmaze.h里的递归回溯与种子控制

迷宫游戏的核心不是绘制,而是“随机生成”。createmaze.h干的事就是生成一个满足“任意两点之间只有一条通路”的完美迷宫。这种迷宫也叫单连通迷宫,不会存在环,从入口到出口有且仅有一条路径。资源里同时出现了createmaze.h和tidymaze.h,我拆解后认为前者负责骨架,后者负责把地图整理成适合绘制的二维数组。

3.1 递归回溯法原理与实现

生成迷宫最简单的算法是递归回溯(Recursive Backtracker)。思路是:把迷宫想象成网格,每个格子四周有墙。从起点开始,随机选一个未访问过的邻居,拆掉之间的墙,然后递归进入邻居,直到所有格子都被访问。

// createmaze.h 示例逻辑 #include <vector> #include <random> struct Cell { bool visited = false; bool wallLeft = true; bool wallTop = true; bool wallRight = true; bool wallBottom = true; }; std::vector<std::vector<Cell>> maze; void GenerateMaze(int width, int height, unsigned seed) { maze.assign(height, std::vector<Cell>(width)); std::mt19937 rng(seed); std::vector<std::pair<int,int>> stack; // 手动模拟递归 int x = 0, y = 0; maze[y][x].visited = true; stack.push_back({x, y}); while (!stack.empty()) { auto [cx, cy] = stack.back(); // 收集未访问的邻居 std::vector<std::pair<int,int>> neighbors; if (cx > 0 && !maze[cy][cx-1].visited) neighbors.push_back({cx-1, cy}); if (cx < width-1 && !maze[cy][cx+1].visited) neighbors.push_back({cx+1, cy}); if (cy > 0 && !maze[cy-1][cx].visited) neighbors.push_back({cx, cy-1}); if (cy < height-1 && !maze[cy+1][cx].visited) neighbors.push_back({cx, cy+1}); if (neighbors.empty()) { stack.pop_back(); // 回溯 } else { std::uniform_int_distribution<int> dist(0, neighbors.size()-1); auto [nx, ny] = neighbors[dist(rng)]; // 拆墙:判断邻居在哪个方向 if (nx == cx-1) { maze[cy][cx].wallLeft = false; maze[ny][nx].wallRight = false; } if (nx == cx+1) { maze[cy][cx].wallRight = false; maze[ny][nx].wallLeft = false; } if (ny == cy-1) { maze[cy][cx].wallTop = false; maze[ny][nx].wallBottom = false; } if (ny == cy+1) { maze[cy][cx].wallBottom = false; maze[ny][nx].wallTop = false; } maze[ny][nx].visited = true; stack.push_back({nx, ny}); } } }

逻辑说明:visited标记保证每个格子只被访问一次,拆墙时必须同步修改两个相邻格子的对应方向,否则画出来墙是错的。参数里最关键是seed,同一个种子生成的迷宫完全相同,改一个数字整个迷宫布局就变了。项目里Maze32这个名字很可能默认生成32x32的网格,也就是1024个单元格,对一局游戏来说规模适中。

3.2 随机种子与墙体数组的落地细节

这个项目的tidymaze.h负责把Cell结构转成更简单的二维数组,比如int maze[32][32],其中1表示墙,0表示路。为什么要多此一举?因为Cell结构有4个bool描述四面墙,而绘制时通常只需要判断“右边和下边”有没有墙,就能画完整个地图。转换逻辑大致是:

for (int y=0; y<height; y++) { for (int x=0; x<width; x++) { // 用位标记来记录墙 int wallCode = 0; if (maze[y][x].wallTop) wallCode |= 0x01; if (maze[y][x].wallRight) wallCode |= 0x02; if (maze[y][x].wallBottom) wallCode |= 0x04; if (maze[y][x].wallLeft) wallCode |= 0x08; grid[y][x] = wallCode; } }

我再啰嗦一句:seed最好用time(NULL),否则每次启动都生成同一个迷宫,玩两次就腻了。但做成课程设计时要留一个“固定种子”的调试开关,这样能稳定复现你向老师演示的那张图,不然你演示时随机生成一张超级难的地图,自己都走不出去,就尴尬了。

4. 绘制与键盘控制:drawmaze.h的红块移动与碰撞检测

迷宫生成了,接下来的事是把它画到屏幕上,并让红块跟着方向键走。drawmaze.h里包含绘制函数,Maze32.cpp里则包含对绘图函数的调用和逻辑更新。这里有个容易搞错的概念:MFC里窗口不是主动调用你的画图代码,而是等系统发出WM_PAINT消息后才通过OnPaint回调绘图。所以移动红块后,不能直接“让画面马上变”,而是要让窗口区域失效并强制重绘。

4.1 坐标系与红块绘制

格子尺寸不是硬编码就是宏定义。常见定义是#define CELL_SIZE 20,这样每个逻辑格子映射到屏幕像素就是20x20。迷宫本身从(0,0)开始绘制,红块的坐标用“逻辑坐标”,也就是第几列第几行,绘图时再乘以CELL_SIZE换算成像素。

// drawmaze.h 示例 #define CELL_SIZE 20 void DrawMaze(CDC* pDC, int grid[32][32], int width, int height) { CBrush wallBrush(RGB(0, 0, 0)); // 黑色墙 CBrush pathBrush(RGB(255, 255, 255)); // 白色路 for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int left = x * CELL_SIZE; int top = y * CELL_SIZE; if (grid[y][x] == 1) { pDC->FillRect(CRect(left, top, left+CELL_SIZE, top+CELL_SIZE), &wallBrush); } else { pDC->FillRect(CRect(left, top, left+CELL_SIZE, top+CELL_SIZE), &pathBrush); } } } } void DrawPlayer(CDC* pDC, int playerX, int playerY) { CBrush redBrush(RGB(255, 0, 0)); int left = playerX * CELL_SIZE + 4; int top = playerY * CELL_SIZE + 4; pDC->FillRect(CRect(left, top, left+CELL_SIZE-8, top+CELL_SIZE-8), &redBrush); }

逻辑说明:FillRect接受像素坐标,所以要乘上CELL_SIZE。我画红块时留了4像素内边距,视觉上更精致,不会把整个格子填满。参数上,grid[y][x]里的y在前x在后,因为二维数组的行是第一个维度。

4.2 方向键移动与碰撞检测

移动的核心不是“让红块位置+1”,而是“判断目标位置能不能走”。走不通的情况有两种:越界和墙遮挡。这里最容易被新手忽略的是墙的判定——只判断目标格子是不是墙是不够的。如果你的墙画在两个格子之间,那么从当前格子到左边格子,需要看的是当前格子是否有左墙,而不是目标格子是否有右墙。虽然生成时同时设置了双方的墙,但严谨起见,移动判断建议写成:

bool CanMove(int x, int y, int dir) { if (dir == VK_LEFT && x > 0) { return !grid[y][x].wallLeft; // 当前格子的左墙 } if (dir == VK_RIGHT && x < width-1) { return !grid[y][x].wallRight; } if (dir == VK_UP && y > 0) { return !grid[y][x].wallTop; } if (dir == VK_DOWN && y < height-1) { return !grid[y][x].wallBottom; } return false; } void MoveSquare(int dx, int dy) { int nx = playerX + dx; int ny = playerY + dy; if (CanMove(playerX, playerY, dx == 1 ? VK_RIGHT : dx == -1 ? VK_LEFT : dy == 1 ? VK_DOWN : VK_UP)) { playerX = nx; playerY = ny; InvalidateRect(NULL, TRUE); // 让窗口重绘 } }

注意InvalidateRect是异步的,它只是通知系统“这个窗口需要重绘”,系统会在空闲时发WM_PAINT。你如果追求实时反馈,可以在移动后调用UpdateWindow()强制立即重绘。我实际测试时发现,按方向键后红块偶尔“卡顿”是因为在OnPaint里做了太多耗时计算,比如每次重绘都重新扫描整张地图。解决方法是把地图背景缓存成位图,或者用BitBlt局部刷新。这个项目比较简单,全量重绘也够用。

5. 避坑指南:老VC项目在VS2008/VS2022下编译的五个坑

Maze32.dsp是VC++ 6.0时代的东西,拿到今天编译,要么是VS2008,要么是VS2022,中间隔了十几年的编译器规则更改。我在Windows 10上折腾过好几个类似老工程,下面这些坑基本每条都踩过。

5.1 运行时报错:LIBCD.lib(wincrt0.obj) : error LNK2001: unresolved external symbol _WinMain@16

现象:编译通过但链接失败,报找不到WinMain。原因:项目被当成控制台程序或Windows程序入口模型配置错了。在MFC工程里,入口函数被MFC框架隐藏了,链接器需要/ENTRY:wWinMainCRTStartup,而老VC6工程默认的入口是WinMain。解决:打开项目属性 -> 链接器 -> 高级 -> 入口点,改填wWinMainCRTStartup。如果是VS2008以上,右键项目“属性”->“常规”->“MFC的使用”选“在共享DLL中使用MFC”,同时“字符集”选“使用多字节字符集”。

5.2 编译错误:cannot convert parameter 1 from 'const char [8]' to 'LPCTSTR'

现象:像AfxMessageBox("Hello")这样的代码突然编译失败。原因:新工程默认Unicode字符集,老代码里用的双引号字符串是ANSI的,不能隐式转换。解决:把字符串前加_T()宏,或者直接在项目属性里把“字符集”改成“使用多字节字符集”。我建议改字符集更省事,但要是代码里用了TCHAR系列函数,就得注意类型匹配。

5.3 资源编译错误:RC2175: resource.hm: file not found

现象:编译.rc资源文件时找不到resource.hm。原因:resource.hm是Visual Studio生成的头文件,根本不是源码自己写的,老工程忘了把它包含进资源脚本。解决:在资源视图里打开Maze32.rc,检查#include部分,如果引用了resource.hm就删除该行,或者从工程目录里找同名文件放回去。我一般干脆删掉resource.hm相关引用,因为它的作用只是维护资源ID历史记录,对编译结果没有实质影响。

5.4 运行库缺失:MSVCP80D.dll not found

现象:程序在本机能跑,拷到别的电脑上双击报缺DLL。原因:老工程编译时动态链接了VC运行库,目标机器没有对应版本。解决:项目属性 -> C/C++ -> 代码生成 -> 运行时库,改成“多线程(/MT)”。这样运行库静态链接进exe,单文件就能跑。副作用是exe体积变大,但对这种小工具无所谓。

5.5 光标或键盘消息不响应

现象:程序窗口出来了,但按方向键红块不动,鼠标点击也没反应。原因:对话框程序默认焦点不在子控件上,或者OnKeyDown没有被正确映射。解决:在OnInitDialog里调用SetFocus()把焦点设到主窗口,同时确认消息映射里写的是ON_WM_KEYDOWN()而不是ON_WM_CHAR()。另外,如果窗口上有按钮控件,按钮会抢走键盘焦点,可以在按钮属性里把“Tab stop”勾掉,或者干脆用无控件的纯窗口。

6. 进阶扩展:把32x32迷宫改成可变尺寸与自动寻路

玩通几遍后你会发现核心瓶颈在“迷宫尺寸写死”和“没有寻路提示”。我把这两个地方改成可配置,整个项目就能变成更通用的迷宫演示工具。

6.1 增加行列参数与墙密度

在Maze32.cpp顶部定义两个宏,然后让所有使用32的地方改成变量:

#define DEFAULT_ROWS 25 #define DEFAULT_COLS 35 int g_rows = DEFAULT_ROWS; int g_cols = DEFAULT_COLS; // 在OnInitDialog里读取对话框输入框的值 CString strRows; GetDlgItemText(IDC_EDIT_ROWS, strRows); g_rows = _ttoi(strRows);

然后createmaze.h里所有用到width/height的地方都从g_cols/g_rows取值。注意Cell嵌套vector的初始化也要跟着改,否则越界访问数组会导致随机崩溃。我一般还会加一个g_seed,支持手动填种子号,这样想复现某个迷宫只需记下数字。

6.2 集成findway.h的BFS自动寻路

资源里的findway.h已经理好了寻路接口,你可以把它和移动逻辑绑定:按F2键调用一次,然后让红块沿着路径一步步走。核心是用BFS求出从当前位置到出口的最短路径,因为迷宫是无权图,BFS保证路径最短。

// findway.h 示例思路 bool FindPath(int grid[][MAX_COLS], int sx, int sy, int ex, int ey, std::vector<std::pair<int,int>>& path) { std::queue<std::pair<int,int>> q; int prev[81][81]; // 记录前驱节点索引,用1D编号 memset(prev, -1, sizeof(prev)); q.push({sx, sy}); prev[sy][sx] = sy * MAX_COLS + sx; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); if (x == ex && y == ey) break; // 四个方向探索 int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}}; for (auto [dx, dy] : dirs) { int nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue; if (grid[ny][nx] == 1) continue; // 墙 if (prev[ny][nx] != -1) continue; // 已经访问过 prev[ny][nx] = y * MAX_COLS + x; q.push({nx, ny}); } } if (prev[ey][ex] == -1) return false; // 回溯路径 int cur = ex, cy = ey; while (cur != sx || cy != sy) { path.push_back({cur, cy}); int p = prev[cy][cur]; cur = p % MAX_COLS; cy = p / MAX_COLS; } std::reverse(path.begin(), path.end()); return true; }

注意prev用一维编号记录前驱,回溯时用取模和除法拆回坐标。这个项目里MAX_COLS可能定义为81,但我更推荐改成动态vector,避免尺寸超过上限。自动寻路演示完,速度不要太快,每50ms走一格,配合Sleep或定时器,效果比瞬间跳到终点好。

把那以后我每次拿到陌生MFC老工程,都会先检查字符集、入口点和资源文件这三处,再谈业务逻辑。这次拆Maze32也是这样,一路排掉编译坑后,剩下的随机生成和寻路代码反而比预想的干净。如果你也想拿它练手,建议先原封不动编译通过,再开始改尺寸和加功能。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询