ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC++ MFC迷宫游戏实战:从随机生成到BFS寻路

VC++ MFC迷宫游戏实战:从随机生成到BFS寻路 简介面向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::vectorstd::vectorCell maze; void GenerateMaze(int width, int height, unsigned seed) { maze.assign(height, std::vectorCell(width)); std::mt19937 rng(seed); std::vectorstd::pairint,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::vectorstd::pairint,int neighbors; if (cx 0 !maze[cy][cx-1].visited) neighbors.push_back({cx-1, cy}); if (cx width-1 !maze[cy][cx1].visited) neighbors.push_back({cx1, cy}); if (cy 0 !maze[cy-1][cx].visited) neighbors.push_back({cx, cy-1}); if (cy height-1 !maze[cy1][cx].visited) neighbors.push_back({cx, cy1}); if (neighbors.empty()) { stack.pop_back(); // 回溯 } else { std::uniform_int_distributionint 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 cx1) { 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 cy1) { 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 y0; yheight; y) { for (int x0; xwidth; 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, leftCELL_SIZE, topCELL_SIZE), wallBrush); } else { pDC-FillRect(CRect(left, top, leftCELL_SIZE, topCELL_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, leftCELL_SIZE-8, topCELL_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 _WinMain16现象编译通过但链接失败报找不到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::vectorstd::pairint,int path) { std::queuestd::pairint,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也是这样一路排掉编译坑后剩下的随机生成和寻路代码反而比预想的干净。如果你也想拿它练手建议先原封不动编译通过再开始改尺寸和加功能。希望帮到你。本文还有配套的精品资源点击获取
返回列表