简介:一份基于VC++6.0与MFC的迷宫小游戏完整工程,面向想通过实际项目掌握C++桌面程序开发的初学者,也适合需要回顾MFC消息映射与控件用法的开发者。压缩包共65个文件、153KB,其中17个bmp用于小地图/大地图及角色贴图,17个h与16个cpp构成游戏逻辑和界面代码,另有2个ico图标及工程配置文件,结构清晰,可直接打开编译。已有213人学习浏览。项目实现了随机迷宫生成(深度优先搜索/Prim算法)、小地图全局预览与大地图探索、方向键控制兔子移动并检测碰撞,覆盖C++类设计、MFC窗口框架、键盘事件处理、GDI绘图等关键技能。下载后对照源码与说明文件,可逐段理解迷宫建模、角色移动和界面刷新的实现思路,适合作为课程设计参考或MFC入门练手项目。
1. 用 VC6.0 编写迷宫小游戏:老编译器是入门游戏开发最近的那条路
一个标题为“migong.rar_vc++6.0编写游戏_vc6.0编小”的压缩包,在不少论坛和网盘里躺了很多年,打开后通常就是一套用 VC++6.0 写的迷宫游戏源码。它不是冷门收藏,而是无数课程设计、毕业设计和自学者的共同起点:用 1998 年的编译器,亲手完成迷宫生成、窗口绘制、键盘移动、胜利判定这一整条游戏开发闭环。VC6.0 虽然老,但正因为没有现代引擎帮你包办一切,窗口、画笔、坐标、消息循环都必须自己面对,反而是理解游戏底层逻辑最直接的工具。这篇笔记把从生成算法到 MFC 界面、从避坑到验证的完整路径写透,新手可以照着跑通,熟手可以拿避坑清单自查。
2. 迷宫生成算法选型:为什么递归回溯比 Prim 更好讲、更好改
2.1 两种主流算法的思路对比与选型理由
迷宫生成算法分两类:一类是“深度优先打通墙”,以递归回溯为代表;一类是“随机拆墙”,以随机 Prim 为代表。递归回溯的思路是:从起点出发,每到一个格子就随机选一个还没访问过的邻居,拆掉中间的墙,把新格子压入栈;如果当前格子四周都走过了,就弹栈回溯到上一个岔路口。这个过程生成的迷宫通道长、岔路少,天然只有一条从起点到任意点的唯一路径,视觉上像洞穴体系,很适合课程设计里“附带最短路径演示”的后续要求。
随机 Prim 的路线完全不同:维护一个候选墙的集合,每次随机抽一堵墙,如果墙的另一侧还没被访问,就拆墙并把新格子加入集合。它的生成结果分支多、分布均匀,更像树状森林。两种算法都能生成完美迷宫,但我做这个题目时一定会选递归回溯,理由有三个:代码量三十多行,逻辑闭环容易讲明白;栈这个数据结构有明确的“推进—回溯”过程,评委一听就懂;生成出来的迷宫路径相对细长,画在窗口里不会出现大块空场,观感更接近“地道迷宫”。
2.2 在 VC6.0 控制台程序里先跑通生成逻辑
先别急着开 MFC 工程。迷宫生成的本质是二维数组操作,和窗口、画笔没有任何关系。我一般先把算法写成控制台程序,在漆黑的命令行里用“##”和空格把迷宫打印出来,验证通过后再套界面。这一步能帮你省掉至少一个晚上查坐标换算的时间。下面这段代码在 VC6.0 里可以独立编译运行。
// maze_gen.cpp : 在 VC6.0 控制台下验证递归回溯迷宫生成 #define _CRT_SECURE_NO_WARNINGS #include <stdio.h> #include <stdlib.h> #include <windows.h> const int ROWS = 21; // 迷宫行数,必须为奇数,保证外墙完整 const int COLS = 21; // 迷宫列数,必须为奇数 const int WALL = 1; // 墙 const int PATH = 0; // 通路 int maze[ROWS][COLS]; typedef struct { int row, col; } Point; Point stack[ROWS * COLS]; // 用数组模拟栈,容量按格子总数估算 int top = -1; void Push(Point p) { stack[++top] = p; } Point Pop() { return stack[top--]; } // 判断一个格子能否被扩展为通路 int CanVisit(int row, int col) { return row >= 1 && row < ROWS - 1 && col >= 1 && col < COLS - 1 && maze[row][col] == WALL; } void InitMaze() { int r, c; for (r = 0; r < ROWS; r++) for (c = 0; c < COLS; c++) maze[r][c] = WALL; } void GenerateMaze() { int d, count, pick; InitMaze(); top = -1; // 种子用系统运行时间,避免每次都生成同一个迷宫 srand(GetTickCount()); Point start = { 1, 1 }; maze[start.row][start.col] = PATH; Push(start); while (top >= 0) { Point cur = stack[top]; // 方向:上、下、左、右 int dr[4] = { -1, 1, 0, 0 }; int dc[4] = { 0, 0, -1, 1 }; int candidate[4]; // 收集所有“隔一堵墙”的目标格子 count = 0; for (d = 0; d < 4; d++) { int nr = cur.row + dr[d] * 2; int nc = cur.col + dc[d] * 2; if (CanVisit(nr, nc)) candidate[count++] = d; } if (count > 0) { pick = candidate[rand() % count]; int midRow = cur.row + dr[pick]; int midCol = cur.col + dc[pick]; int nextRow = cur.row + dr[pick] * 2; int nextCol = cur.col + dc[pick] * 2; maze[midRow][midCol] = PATH; // 拆掉中间的墙 maze[nextRow][nextCol] = PATH; // 新格子设为通路 Point next = { nextRow, nextCol }; Push(next); } else { Pop(); // 四周走不通,回溯 } } } void PrintMaze() { int r, c; for (r = 0; r < ROWS; r++) { for (c = 0; c < COLS; c++) { if (r == 1 && c == 1) printf("S "); // 起点 S else if (r == ROWS - 2 && c == COLS - 2) printf("E "); // 终点 E else printf("%s", maze[r][c] == WALL ? "##" : " "); } printf("\n"); } } int main() { GenerateMaze(); PrintMaze(); return 0; }这段代码的逻辑核心是“隔二取一”的扩展方式。每个通路格子只检查row + 2*方向和col + 2*方向的位置,中间那一格天然是墙,打通后就成为两个通路之间的连接。这样生成的迷宫,墙体和通道各占一个单元格,宽高一致,画到窗口里非常整齐。ROWS和COLS必须取奇数,因为起点在坐标 (1,1),如果行数是偶数,终点(ROWS-2, COLS-2)会落到边界外,外墙就不完整了。
参数说明:stack数组长度取ROWS*COLS是上限估算,DFS 回溯栈深度不会超过格子总数,对 21×21 的迷宫绰绰有余。srand(GetTickCount())是让每次运行生成不同迷宫的关键,GetTickCount()返回系统开机以来的毫秒数,比time(NULL)的秒级精度更适合当随机种子。注意在 VC6.0 里编译时,所有 for 循环变量我都提到了函数开头声明,这是故意绕开 VC6.0 的 for 作用域坑,后面避坑章会专门说。
2.3 控制台版本的核心参数与调试输出
在 VC6.0 里新建一个“Win32 Console Application”工程,把代码粘贴进去,Ctrl+F5 就能跑。命令行里会出现一张用##表示墙、空格表示通路的迷宫,S在左上角附近,E在右下。这是最原始的“黑匣子输出”,但它能立刻暴露两类问题:迷宫没完全连通,存在大片封闭区域;或者路径笔直穿过整张地图,说明随机性不够。
如果生成结果是大片空白或者大片墙,先检查CanVisit的边界条件和midRow/midCol是否把墙打通到数组外。如果maze[midRow][midCol] = PATH把边界格子也当通路打通了,打印时就会看到外墙“漏风”。定位这种问题不需要调试器,直接把maze数组按行列输出到文本文件,用编辑器打开,检查四个边是否全为 1 就能确定外墙完整性。
从控制台一寸一寸验证过的算法,搬到 MFC 后只剩坐标换算一件事。这也是我坚持先做控制台验证的原因:把逻辑错误和界面错误分开处理,你会更快锁定真正的问题。
3. 把迷宫搬进 MFC 窗口:从 AppWizard 到键盘交互的完整改造
3.1 用 MFC AppWizard 创建单文档工程,先关掉多余零件
在 VC6.0 里通过 File → New → Projects → MFC AppWizard(exe) 新建工程,应用类型选“单文档”,这一步生成的是 SDI 框架。对迷宫这类单窗口小游戏,SDI 比对话框合适:CView 天然带重绘机制和消息循环,后续画迷宫、响应键盘只需覆盖 CView 的几个虚函数,不用自己造轮子。
工程生成后打开资源编辑器,你会看到一排工具栏按钮和状态栏。这些在迷宫游戏里用不到,直接处理掉:在 ResourceView 里展开 Toolbar 资源,把多余按钮拖走;状态栏可以保留,后面用来显示步数和用时。主窗口标题在 MainFrame 的PreCreateWindow里改m_strTitle,或者直接改资源文件字符串表里 IDR_MAINFRAME 的标题,改成“迷宫小游戏 - VC6.0”,让窗口栏和任务栏都显示项目名。这一步花不了两分钟,但对“作品感”的提升很明显。
资源编辑器还需要加一个“新迷宫”按钮。如果你不想动工具栏,更省事的做法是给菜单加一项“游戏 → 新迷宫”,把命令 ID 映射到视图类的NewGame()成员函数。菜单项在 VC6.0 的资源编辑器里是所见即所得,双击生成消息映射,具体代码放到 4.1 一起写。
3.2 在 CView 里绘制迷宫:坐标换算与墙体画法
把迷宫数据从二维数组变成窗口里的方块,核心是坐标换算。定义CELL_SIZE = 20,第r行第c列的格子,在窗口中的左上角坐标就是(c * CELL_SIZE, r * CELL_SIZE),右下角是((c+1) * CELL_SIZE, (r+1) * CELL_SIZE)。行对应 Y 轴,列对应 X 轴,写反了就是迷宫上下颠倒。这是新手最容易翻车的地方,我见过有人调了一晚上,最后发现是Row和Col在绘制函数里互换。
// CMazeView.cpp : 迷宫绘制逻辑 // 成员变量约定: // int m_maze[21][21]; // 0 通路,1 墙 // int m_playerRow; // 玩家所在行 // int m_playerCol; // 玩家所在列 void CMazeView::OnDraw(CDC* pDC) { int r, c; const int CELL_SIZE = 20; // 先画墙体:逐格填充,逻辑直观 for (r = 0; r < ROWS; r++) { for (c = 0; c < COLS; c++) { if (m_maze[r][c] == WALL) { CBrush wallBrush(RGB(70, 80, 100)); // 深蓝灰色墙体 CBrush* oldBrush = pDC->SelectObject(&wallBrush); pDC->Rectangle(c * CELL_SIZE, r * CELL_SIZE, (c + 1) * CELL_SIZE, (r + 1) * CELL_SIZE); pDC->SelectObject(oldBrush); } } } // 画起点和终点标记 CBrush startBrush(RGB(60, 180, 80)); // 绿色起点 CBrush* oldStart = pDC->SelectObject(&startBrush); pDC->Rectangle(1 * CELL_SIZE, 1 * CELL_SIZE, 2 * CELL_SIZE, 2 * CELL_SIZE); pDC->SelectObject(oldStart); CBrush endBrush(RGB(200, 60, 60)); // 红色终点 CBrush* oldEnd = pDC->SelectObject(&endBrush); pDC->Rectangle((ROWS - 2) * CELL_SIZE, (COLS - 2) * CELL_SIZE, (ROWS - 1) * CELL_SIZE, (COLS - 1) * CELL_SIZE); pDC->SelectObject(oldEnd); // 最后画玩家:橙色圆块,保证不被墙体盖住 CBrush playerBrush(RGB(220, 120, 40)); CBrush* oldPlayer = pDC->SelectObject(&playerBrush); pDC->Ellipse(m_playerCol * CELL_SIZE + 3, m_playerRow * CELL_SIZE + 3, (m_playerCol + 1) * CELL_SIZE - 3, (m_playerRow + 1) * CELL_SIZE - 3); pDC->SelectObject(oldPlayer); }这里的绘制顺序是“先墙体、再起点终点、再玩家”。玩家为什么放最后?因为玩家是一个动态元素,每移动一步都要重绘,如果先画玩家再画墙,玩家就会被新画出来的墙体盖住,看起来像“走进墙里消失了”。终点用红色标记,起点用绿色,这样迷宫生成后玩家一眼就能判断目标在哪。
SelectObject后必须保存旧对象并在用完立刻恢复,这是 GDI 编程的铁律。恢复后 MFC 的CBrush析构函数才能正确释放 GDI 句柄,否则任务管理器里的 GDI 对象数会一路涨,窗口越跑越卡,避坑章 5.5 还会再提。InvalidateRect(NULL, FALSE)的第二个参数传FALSE表示不要擦除背景,能显著减少重绘闪烁。
3.3 键盘控制玩家移动与碰撞检测
MFC 的 CView 默认把方向键当作“改变焦点”的用户界面消息,不会下发给OnKeyDown。想用方向键控制游戏,最稳妥的做法是重写PreTranslateMessage,在消息被翻译成WM_CHAR之前拦截WM_KEYDOWN。这样做的另一个好处是,Tab 键和 Enter 键不会意外抢走焦点。
BOOL CMazeView::PreTranslateMessage(MSG* pMsg) { int key, nr, nc; if (pMsg->message == WM_KEYDOWN && m_gameState == GAME_PLAYING) { key = (int)pMsg->wParam; nr = m_playerRow; nc = m_playerCol; switch (key) { case VK_UP: nr--; break; case VK_DOWN: nr++; break; case VK_LEFT: nc--; break; case VK_RIGHT: nc++; break; default: return CView::PreTranslateMessage(pMsg); } // 防御式边界检查:防止数组越界访问 if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS && m_maze[nr][nc] == PATH) { m_playerRow = nr; m_playerCol = nc; m_stepCount++; InvalidateRect(NULL, FALSE); } return TRUE; // 消息已被处理,不再往下传递 } return CView::PreTranslateMessage(pMsg); }先做边界检查,再判断目标格是否为通路。虽然生成算法保证最外圈是墙,玩家理论上来不到边界,但防御式检查成本几乎为零,还能防止未来改动算法后出现数组越界导致程序崩溃。m_stepCount只在成功移动时增加,撞墙不算步数,这是做计步器的基本原则。
return TRUE非常关键。MFC 的消息传递顺序是PreTranslateMessage → TranslateMessage → DispatchMessage,返回 FALSE 时这条WM_KEYDOWN会被当作普通消息继续分发,可能触发焦点跳转或系统默认行为。返回 TRUE 等于告诉 MFC“这条消息我已经消化掉了,你们别碰”。如果你发现方向键按下后迷宫没反应但窗口标题栏在闪,多半就是这里返回了 FALSE。
4. 让迷宫真正“可玩”:步数计时、胜利判定与状态机管理
4.1 起点终点初始化与胜利判定
迷宫生成完之后,起点固定在(1,1),终点固定在(ROWS-2, COLS-2)。因为递归回溯生成的是完美迷宫,这两个格子之间必然有且仅有一条路径。把NewGame函数写好,菜单和按钮都映射到它,一劳永逸:
void CMazeView::NewGame() { GenerateMaze(); // 复用第 2 章的生成函数 m_playerRow = 1; m_playerCol = 1; m_gameState = GAME_PLAYING; m_stepCount = 0; m_startTick = GetTickCount(); SetTimer(1, 1000, NULL); // 每秒刷新一次计时显示 InvalidateRect(NULL, FALSE); } void CMazeView::CheckWin() { CString msg; DWORD elapsed; if (m_playerRow == m_exitRow && m_playerCol == m_exitCol) { m_gameState = GAME_WIN; elapsed = (GetTickCount() - m_startTick) / 1000; msg.Format(_T("恭喜通关!\n步数:%d\n用时:%d 秒"), m_stepCount, elapsed); MessageBox(msg, _T("迷宫小游戏"), MB_OK | MB_ICONINFORMATION); } }m_exitRow和m_exitCol在GenerateMaze()结束后赋值为ROWS-2和COLS-2。CheckWin在 3.3 的移动成功分支里调用,玩家踩到终点格就弹窗。这里用_T()宏包住所有字符串常量,是为了绕开 VC6.0 的字符集坑,避坑章 5.3 会详细解释。
4.2 用一个枚举管理游戏状态,而不是用一堆 bool
移动逻辑写完后,你可能会顺手加m_bStart、m_bWin、m_bMoving三个布尔变量。三个 bool 之间会出现非法组合,比如“游戏还没开始但玩家已经赢了”,这种状态会让代码到处都是特判。我习惯用一个枚举把游戏生命周期钉死:
enum GameState { GAME_READY = 0, // 还没生成迷宫,界面上只有空白 GAME_PLAYING, // 迷宫已生成,玩家正在走 GAME_WIN // 已到达终点,键盘不再响应 };在这套状态下,3.3 的PreTranslateMessage里m_gameState == GAME_PLAYING的判断就变成了状态保证而不是规则判断。你不需要再想“赢的时候能不能按方向键”,枚举已经保证了只有 PLAYING 状态才处理移动。菜单“新迷宫”只需要把状态重新置为GAME_PLAYING,所有键盘响应、计时器逻辑自动恢复。
VC6.0 的枚举在 Debug 下类型检查比较弱,不要依赖枚举默认值做运算。比如不要写m_gameState++来切换状态,枚举的递增在现代 C++ 里合法,但在 VC6.0 的调试器里容易被当成整数处理,带来不可预测的边界行为。显式赋值GAME_READY = 0是双保险。
4.3 计时显示:SetTimer 与 GetTickCount 怎么选
迷宫游戏的耗时有两个展示方案。方案一是在玩家每次移动时用GetTickCount()算差值,只在移动瞬间刷新标题;方案二是挂一个SetTimer(1, 1000, NULL),每秒主动刷新窗口标题。前者零额外开销,但玩家站着不动时计时不会跳,少了“作品感”。我推荐方案二:
void CMazeView::OnTimer(UINT nIDEvent) { DWORD elapsed; CString title; if (nIDEvent == 1 && m_gameState == GAME_PLAYING) { elapsed = (GetTickCount() - m_startTick) / 1000; title.Format(_T("迷宫小游戏 - 步数:%d 用时:%d 秒"), m_stepCount, elapsed); GetParentFrame()->SetWindowText(title); } CView::OnTimer(nIDEvent); }窗口标题实时刷新步数和用时,玩家不用低头看状态栏。这里GetParentFrame()拿的是主框架窗口句柄,在 SDI 里就是顶层窗口;如果你用的是对话框框架,要改成SetDlgItemText或者SetWindowText加GetSafeHwnd()。两种框架的定时器逻辑完全一样,区别只在怎么找到那个“显示文字的窗口”。
一个必须处理的尾巴是OnDestroy。在CMazeView::OnDestroy里调用KillTimer(1),否则窗口关闭后 Timer 消息仍可能继续进入消息队列,访问已经销毁的视图对象,VC6.0 里这会造成“关闭程序时弹出一个无标题错误框”的经典翻车现场。
5. VC6.0 编译迷宫源码的避坑手册:5 条血泪经验
5.1 for(int i…) 诡异报错:C2065 与变量作用域陷阱
现象:代码里写for (int r = 0; r < ROWS; r++),VC6.0 报错error C2065: 'r' : undeclared identifier,而且报错位置经常不在 for 那一行,而是在循环结束后的第一次使用处。
原因:VC6.0 的编译器发布于 C++ 标准刚定稿的年代,它把 for 循环中声明的变量当作整个函数作用域的变量,而不是只属于循环体。如果你在同一函数里后面又写了int r,就会报重定义;反过来,循环结束后使用r在某些情况下又能“碰巧”通过编译,让你误以为变量仍然可用。行为介于标准 C++ 和旧 C 风格之间,非常别扭。
解决:把循环变量全部提到函数开头声明,整个函数复用同一个变量名。我在这篇文章的所有代码里已经这样做了,你复制到 VC6.0 可以直接编译。如果你接手的是别人的源码,看到for(int i...)报错,不要犹豫,直接改成前置声明。
注意:VC6.0 的 for 作用域坑不止导致编译错误,还会让同一份代码在 Debug 与 Release 下表现不一致。全用前置变量是最省心的规避方式。
5.2 每次运行迷宫长得一模一样:srand 种子粒度问题
现象:迷宫生成函数没问题,但不管怎么运行,打印出来的迷宫完全相同。重启电脑后也还是同一张。
原因:习惯性写srand(time(NULL))时,time(NULL)的精度是秒。你在 F5 调试后马上 Ctrl+F5 再跑一次,两次时间戳落在同一秒内,种子相同,随机序列完全一致。即便不同秒,VC6.0 的rand()是线性同余生成器,相邻种子产生的前几个伪随机数往往差异很小,第一个候选方向可能撞车。
解决:把种子改成毫秒级,并掺入迷宫尺寸信息:
srand(GetTickCount() ^ (ROWS * 31 + COLS));如果这样仍然稳定复现同形迷宫,在生成前多调几次rand()打散初始状态,比如写rand(); rand();。另外,VC6.0 的rand()是全局共享序列,如果你在迷宫生成前还调用了其他库函数触发随机数,序列位置会变化,这类“只要你一加功能迷宫就变”的玄学现象,多半也跟种子序列没控制好有关。
5.3 字符串常量编译不过:char* 与 wchar_t 的纠缠
现象:MessageBox("通关", "游戏", MB_OK)编译报错,提示类似cannot convert parameter 1 from 'const char [5]' to 'const unsigned short *'。
原因:MFC 工程的字符集设置被配置为 Unicode,窗口类 API 被宏映射到宽字符版本,比如MessageBoxW,而你传进去的是窄字符串常量char*。VC6.0 里这种映射是隐式的,但常量字符串不会自动转换,于是编译器在参数类型上直接报错。
解决:不要在“多字节字符集”和“Unicode 字符集”之间反复横跳,统一用_T()宏包裹所有字符串常量。_T在 ANSI 工程下展开为char[],在 Unicode 工程下展开为wchar_t[],一套代码两种工程都能编译。这是 VC6.0 时代开发 MFC 应用的基本功,也是你从网上抄源码时最先要扫描替换的地方。
5.4 F9 断点不生效:调试信息与优化冲突
现象:在GenerateMaze()里打了断点,运行后断点变成空心圆圈,永远不进中断,仿佛程序绕过了那行代码。
原因:VC6.0 的工程默认 Debug Info 配置不完整,同时如果编译设置带了优化(哪怕是 Debug 下的 O1),编译器会重排指令,源码行号和机器码对应不上,调试器无法把断点映射到实际指令。
解决:进入 Project → Settings → C/C++,在 Category 下拉框里选 General,把 Debug Info 设为 “Program Database for Edit and Continue (/ZI)”;再把 Optimization 选为 Disable。Link 页面勾选 Generate Debug Info。改完这些后执行一次 Rebuild All,断点就能正常命中。这套配置对迷宫这种几十行逻辑的小工程来说完全够用。
5.5 窗口越走越闪,GDI 对象狂涨
现象:迷宫正常显示,但每移动一步,窗口明显闪烁,运行几分钟后整个程序重绘变慢,任务管理器里进程的“GDI 对象”列数值涨到几百甚至上千。
原因:初学绘制时把CBrush、CPen直接声明在OnPaint里,用完不恢复旧对象。MFC 的CBrush析构时会调用DeleteObject,但前提是它当前没有被选进设备上下文。如果你连续SelectObject却没有保存并恢复旧对象,旧的画刷句柄一直留在 DC 里,析构时无法释放,GDI 句柄就这样一点点泄漏。
解决:每次SelectObject都保存返回的旧指针,用完立即SelectObject(oldBrush)恢复。迷宫规模不大,逐格Rectangle时用一个成员画刷、一个成员画笔,效果最好。如果窗口还是闪,再加双缓冲:在OnDraw里创建内存CDC和CBitmap,先画到内存,再一次性BitBlt到窗口。课程设计能把这个优化讲出来,至少能加两分。
6. 先验证再扩展:把迷宫从作业做成能演示的小作品
6.1 用文本矩阵做基准验证,别只盯着窗口
迷宫生成完,最容易被忽略的是验证。窗口里看到的只是重绘后的像素,数组搬运过程中坐标错位很难用肉眼发现。我习惯在生成后把maze数组逐行写到一个文本文件里:
FILE* fp = fopen("maze_dump.txt", "w"); int r, c; for (r = 0; r < ROWS; r++) { for (c = 0; c < COLS; c++) fprintf(fp, "%d", m_maze[r][c]); fprintf(fp, "\n"); } fclose(fp);打开这个文本矩阵,检查三点:外圈是否全为 1,起点到终点是否只有一条 0 通路,有没有出现 2×2 的 0 方块。出现 2×2 的 0 方块说明相邻格子被打通过度,迷宫失去“墙厚一致”的观感,生成算法里CanVisit没过关。这个文本矩阵是后续所有扩展的基准,改一次算法就跑一次对比,比在窗口里走迷宫判断快得多。
6.2 三个低成本的扩展方向:寻路、关卡、算法拆封装
第一个扩展是自动寻路演示。在GAME_PLAYING状态下按某个键触发 BFS,实时画出从玩家当前位置到终点的路径。BFS 用队列实现,三四十行代码,课程设计里这一招几乎必杀,而且能复用迷宫生成时打的PATH/WALL标记。第二个扩展是关卡系统,把ROWS、COLS从常量改成成员变量,按关卡递增为 21、31、41,同时调整CELL_SIZE让窗口保持合适大小,计时器继续复用。第三个扩展是把迷宫生成、寻路算法封装成独立的MazeCore类,完全隔离 MFC 依赖,以后换 DirectX 或者控制台渲染只改一个接口。做这三个扩展不需要你重写任何核心算法,只是在现有代码外面包一层。
我当年做这个题目时,直接拿网上源码改,结果花了整晚调一个“看起来正常但终点永远不可达”的 Bug,最后发现是导入别人代码时把行列尺寸换错了。后来我养成的习惯是:不管多简单的算法,先跑一个文本输出,再谈画界面。这次写迷宫,文本矩阵永远在窗口之前给出正确答案。希望这个顺序也能帮到正在和 VC6.0 搏斗的你。
本文还有配套的精品资源,点击获取