迷宫游戏课设拆解:用顺序栈与DFS实现自动寻路
2026/9/19 22:36:36 网站建设 项目流程

简介:《数据结构课程设计》走迷宫游戏.doc 是一份完整的课程设计报告,面向信息工程、计算机类专业学生,用于完成数据结构课程中“栈”与搜索算法的综合实践。报告以迷宫老鼠寻路为任务,要求键盘控制上下左右移动、不让老鼠穿墙、规定时间内到达粮仓并判断胜负,同时加入编辑迷宫、寻找全部路径、迷宫地图文件存盘与读取等功能,能够体现 MFC 界面设计、二维数组、栈的入栈出栈以及文件中序列化的综合运用。文档共 1 个 doc 文件,压缩包大小为 418KB,内容涵盖任务书、总体设计、详细设计、调试与测试、源程序清单、设计总结及参考文献等完整章节。报告中包含操作流程图、模块流程图、函数功能模块说明与调用关系,并针对调试过程中遇到的常见问题给出了分析与解决措施,可直接用于同类课程设计参考或答辩准备。已有 430 人浏览学习该资源,适合需要完成迷宫类数据结构课设、学习 MFC 迷宫程序框架或复习栈与搜索算法应用的读者下载使用。

1. 迷宫游戏背后的数据结构:为什么一个课设值得反复拆

一份2015年的《数据结构课程设计》报告,课题是走迷宫游戏:界面显示迷宫地图,老鼠在中央,右下角是粮仓,用方向键控制老鼠在规定时间内到达终点。表面看是MFC小游戏,实际上把二维数组建模、顺序栈、深度优先遍历、序列化、GUI消息机制全部串在了一起。很多人在课设阶段只求“能跑”,但迷宫游戏恰好是观察栈和回溯算法如何工作的最小完整案例。对做后端或嵌入式的人而言,这套设计的价值在于:它演示了如何用显式的栈替代递归,如何在事件驱动的GUI里维护一套游戏状态机。这篇博文会按报告里的实现路径逐步拆开,并补上参数说明和踩坑记录。

2. 迷宫建模与顺序栈:先把地图变成程序能理解的数据

2.1 用二维数组表达迷宫,为什么不用图结构

报告的迷宫是13行×17列,每格固定50像素见方,wall[13][17]数组是全局状态,0表示路、1表示墙、2表示终点粮仓、3表示老鼠出生位置。这样做的直接好处是:绘图函数OnEraseBkgnd可以按数组值循环贴图,碰撞检测就是一次下标访问,连鼠标编辑迷宫也只需要改一个int。如果引入邻接表或真正的图结构,虽然路径搜索概念上更“标准”,但代价是每次墙变路时都要重建邻接关系。对课设这个规模,数组就是最简单的可运行结构。

数组建模的另一个好处是坐标换算非常直接。鼠标点击处point.x / 50得到列下标j,point.y / 50得到行下标k,反转操作同理。报告中墙的边界检查没有显式写数组越界,实际开发时应该补上,后面第5章会给出带约束的完整版本。

2.2 顺序栈:保存“回头路”的关键数据结构

栈在这里不是用来做表达式求值,而是保存探索路径。每走一步就把当前坐标压入栈,遇到死路就出栈回退,这是深度优先搜索的经典形态。报告定义了两个结构:

typedef struct { int x, y; // 格子坐标:x为列,y为行 int di; // 进入该点时尝试的方向,0右、1下、2左、3上 } DataType; typedef struct { DataType data[MAXSIZE]; // 顺序存储的路径点数组 int top; // 栈顶下标,-1为空栈 } Seqstack;

DataType不仅记录坐标,还记录di方向值,这一点很关键。自动寻路回退时,如果不知道上一次是从哪个方向走进来的,就可能反复进出同一个路口形成死循环。di让回溯过程能接着上一次的方向继续试探,而不是从头开始。MAXSIZE在报告里是宏定义,按13×17=221格算,取256以上就足够。

2.3 四个基础接口:初始化、判空、压栈、出栈

Seqstack *CSkfction::init_Seqstack() { Seqstack *s = (Seqstack *)malloc(sizeof(Seqstack)); s->top = -1; // 空栈标记 return s; } int CSkfction::Empty_Seqstack(Seqstack *s) { return (s->top == -1) ? 1 : 0; } int CSkfction::Push_Seqstack(Seqstack *s, DataType x) { if (s->top == MAXSIZE - 1) return 0; // 栈满,压栈失败 s->data[++s->top] = x; return 1; } int CSkfction::Pop_Seqstack(Seqstack *s, DataType *x) { if (s->top == -1) return 0; // 空栈,出栈失败 *x = s->data[s->top--]; return 1; }

四个接口的语义与严蔚敏版《数据结构》里的定义基本一致。init_Seqstack只有成功路径,实际工程中malloc后需要判空;Push和Pop的返回值设计成0/1,是为了让调用方在寻路循环里快速判断边界条件,而不是用异常或全局错误码。注意Pop的出参是指针而非返回值,这样函数可以用return标识状态、用x带回坐标数据,这是C语言里常见的双通道返回模式,比直接返回结构体更节省拷贝。

2.4 接口与调用场景对照

接口入参出参触发时机
init_SeqstackSeqstack*点击“自动寻路”或重置路径时
Empty_SeqstackSeqstack*1/0回溯循环的终止条件
Push_SeqstackSeqstack*, DataType1/0试探到可通路时记录当前点
Pop_SeqstackSeqstack*, DataType*1/0四方向都走不通时回退一步

这四行对照是阅读源程序清单的捷径。报告在3.2节里列出了OnAuto调用顺序,实际顺序就是“初始化→循环判空→试探压栈→死路出栈”。写课设报告时,把这类调用关系画成表,比贴一大段代码更容易让老师看清楚设计意图。

3. 键盘控制与状态管理:从消息映射到贴图动画

3.1 OnKeyDown:方向键不是直接移动,而是先查墙

MFC的视图类通过OnKeyDown响应键盘消息,第一个参数nChar是虚拟键码。方向键在Windows中对应VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT,与键盘上的上下左右一一对应。报告里的处理逻辑可以整理成下面这段结构:

void CLabyrinthView::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { if (m_timestatus != 1) { // 未点“开始游戏”时按方向键无效,直接回到起点 x = start_x; y = start_y; return; } int nextX = x, nextY = y; if (nChar == VK_UP) nextY--; else if (nChar == VK_DOWN) nextY++; else if (nChar == VK_LEFT) nextX--; else if (nChar == VK_RIGHT) nextX++; else return; // 其他键忽略,老鼠原地不动 if (wall[nextY][nextX] == 1) return; // 墙,禁止穿墙 x = nextX; y = nextY; if (wall[y][x] == 2) { // 走到粮仓,弹窗提示成功 AfxMessageBox("恭喜你赢了!"); } }

注意这里先计算nextX/nextY,再查wall数组,而不是直接修改x、y。这是碰撞检测的正确姿势:移动意图先落在临时变量上,只有不撞墙才提交真正的坐标更新。报告里说“迷宫的墙足够结实,老鼠不能穿墙而过”,实现上就是这一行下标检查。wall数组中0和2都是可通行区域,2表示终点,所以只有等于1时才拦截。非方向键直接return,这也是报告测试记录里“按下其他键老鼠呆在原地不动”的来源。

提示:方向键对应的虚拟键码是VK_LEFT、VK_RIGHT、VK_UP、VK_DOWN,不是字符'a''d''w''s'。如果换成wasd控制,需要在nChar判断分支里增加对大小写的兼容。

3.2 游戏状态机:m_timestatus与OnTimer兜底

m_timestatus用1表示游戏进行中,0表示未开始或已结束。这个变量把整个界面分成两个状态:开始前按方向键无效,结束后必须重新开始。报告调试部分提到一个典型问题:走完迷宫后界面保持现状,重新开始要手动点按钮。解决方案是在OnTimer里判断剩余时间m_lasttime小于0时,调用OnOpen()重新加载地图,同时重置老鼠位置。这套逻辑本质上是一个超时自动复位机制。

void CMainFrame::OnTimer(UINT nIDEvent) { if (m_lasttime < 0) { MessageBox("你怎么让老鼠饿死啦!"); OnOpen(); // 重新载入地图并复位状态 } else if (m_timestatus == 1) { m_lasttime--; // 每秒减一,状态栏同步刷新 } CFrameWnd::OnTimer(nIDEvent); }

SetTimer(1, 1000, NULL)的第一个参数是定时器ID,第二个参数1000毫秒即1秒触发一次,第三个参数NULL表示把WM_TIMER消息投递到消息队列,而不是用回调函数。多个定时器共存时,OnTimer靠nIDEvent区分来源。状态栏时间用SetPaneInfo设置窗格宽度和凸起样式,再用SetPaneText写入格式化后的字符串,报告中IDS_LASTTIME和IDS_SETTIME两个资源ID分别对应剩余时间与规定时间两个窗格。

3.3 方向动画与“脚印”效果

报告里绘制的细节值得单独说:老鼠有4个方向、每个方向4帧,共16张位图,用bitmap[4][4]管理,方向由nChar决定,帧索引由移动步数累加。每走一步先用背景位图覆盖老鼠原位置的贴图,再在新位置贴下一帧,视觉上就形成了“留下脚印、向前走”的效果。OnEraseBkgnd里按wall数组的四个值分别贴路、墙、粮仓、出生点,也是同一套循环贴图思路。

// 伪代码:移动后的贴图刷新 for (int i = 0; i < 4; i++) for (int j = 0; j < 4; j++) mdc->SelectObject(bitmap[i][j]); // 根据方向i与帧号j取图

这段代码隐藏了一个性能细节:不要在OnKeyDown里直接调用贴图函数做复杂绘制,正确的做法是修改坐标后调用Invalidate触发WM_PAINT,让OnDraw统一重绘。课设规模看不出差别,但一旦迷宫扩大或位图尺寸变大,频繁的局部绘制会造成明显闪烁。报告中用的方式是贴背景图“涂抹”旧位置,本质上就是手动局部刷新,效果够用,但不是最干净的方案。

3.4 键盘无响应的焦点坑

报告在调试记录里提到“按键没有反应”,解决方法是点击窗口空白区域,让任何子控件都不持有焦点。这是MFC视图获取键盘消息的典型陷阱:当某个按钮、编辑框获得焦点时,WM_KEYDOWN会发给该子控件,而不是View。处理思路有两个:在子控件重写OnKeyDown转发消息,或者在View里响应PreTranslateMessage做全局拦截。课设里选择点击空白区域是最快的绕过方案,正式项目建议用后者。

4. 自动寻路的栈回溯:深度优先遍历在迷宫中的落地

4.1 从老鼠视角理解DFS

自动寻路的目标是按某种顺序试走每个方向,能走就走,走不通就原路退回,直到找到粮仓。这就是深度优先搜索的顺序栈实现。与递归DFS相比,显式栈的每个元素不仅记录了坐标,还保留了方向信息,方便在GUI里一步一帧地展示寻路过程。报告为这个功能封装了CSkfction类,它把2.3节四个栈操作函数收进同一模块,OnAuto只负责调用。

4.2 方向表:用增量数组统一四种试探

typedef struct { int x; int y; } item; void CLabyrinthView::OnAuto() { item move[4] = { {1,0}, {0,1}, {-1,0}, {0,-1} }; // 依次对应:向右、向下、向左、向上 // 注意屏幕坐标的y轴向下,所以“向下”的y增量为+1 CSkfction *csk = new CSkfction(); Seqstack *s = csk->init_Seqstack(); DataType temp; // 以老鼠当前位置为起点入栈 temp.x = x; temp.y = y; temp.di = 0; csk->Push_Seqstack(s, temp); wall[y][x] = -1; // 起点标记为已走过 while (!csk->Empty_Seqstack(s)) { int d = 0; while (d < 4) { int nx = x + move[d].x; int ny = y + move[d].y; if (wall[ny][nx] == 0 || wall[ny][nx] == 2) { // 可通行:0为路,2为粮仓终点 temp.x = nx; temp.y = ny; temp.di = d; csk->Push_Seqstack(s, temp); wall[y][x] = -1; // 标记旧位置已踩过 x = nx; y = ny; d = 0; // 新位置从0方向重新试探 break; } d++; } if (d >= 4) { // 当前位置四个方向都走不通,弹栈回退一步 csk->Pop_Seqstack(s, &temp); x = temp.x; y = temp.y; } } }

这段代码把报告里的逻辑整理成可读版本,核心思路不变。move数组用四组增量表示方向,省去四个if分支;wall[y][x]=-1是“已走过”标记,避免在两个相邻空格之间来回横跳;d=0让每个新位置都从头试探,属于深度优先的标准行为。遇到粮仓时wall值等于2也满足通行条件,所以最终会停在终点并触发成功逻辑,报告源码里用if(x==16&&y==10)判断到达唯一出口,这里的16和10是粮仓在13×17地图中的列下标与行下标。

判断条件wall[ny][nx] == 0 || wall[ny][nx] == 2意味着墙(1)被排除,但并没有排除越界访问。实际迷宫若四周都有墙,下标不会越界;如果编辑迷宫把边界改成路,则需要先判断ny与nx的合法范围,这是课设代码与工程代码的一个明显差异。

4.3 为什么栈而非递归:栈帧、栈溢出与可视化

在这个场景里完全可以用递归写DFS,但MFC视图类的成员函数运行在窗口线程栈上,默认栈空间约1MB,递归深度等于路径长度,迷宫13×17最大221步,其实不会溢出。真正的问题在于可视化:递归调用会一次性钻进最深分支,中间没有机会让GUI刷新贴图。顺序栈把每一步压栈、出栈都留在OnAuto的循环里,可以在每次状态变更后刷新屏幕,这是课设要求“动画式寻路”的必然选择。

如果需求从“找出一条路径”变成“找出所有路径”,顺序栈同样比递归更容易改:出栈后再换下一个方向继续试探即可。报告标题里写了“找出走出迷宫的所有路径”,实际实现的OnAuto是找到一条就结束,真正的全路径枚举需要去掉终点处的提前返回,并允许探索完栈内全部路径,感兴趣的读者可以在此基础上补充。自动寻路开始前建议先把m_timestatus置0,避免寻路过程中方向键还在干扰坐标;寻路结束再恢复,这是常见的防重入做法。

4.4 为什么不用BFS:最短路径与所有路径的取舍

广度优先搜索按层扩展,第一次到达终点时路径最短,但需要额外的前驱数组记录每个格子的来源,内存占用约为迷宫格数的两倍;DFS用栈天然回溯,只多一个-1标记。对课程设计而言,DFS代码更短、与栈章节的课程目标更贴合。若后续要把它升级为“最短路径演示”,再引入BFS也不迟,迷宫规模小,两种算法的时间差异肉眼不可见。

提示:如果用户在编辑迷宫时把起点和终点之间的所有通路都改成墙,OnAuto会在退栈到空栈后直接结束,界面上不提示原因。工程化做法是循环结束后检查栈是否为空,为空则弹窗“路径不存在”。

5. 地图编辑、序列化与扩展到通用迷宫工具

5.1 鼠标编辑:坐标换算与状态切换

编辑功能在m_selfmap==1时开启,通过OnLButtonDown把像素坐标折算成格子坐标:

void CLabyrinthView::OnLButtonDown(UINT nFlags, CPoint point) { if (m_selfmap != 1) return; int k = point.y / 50; // 行号 int j = point.x / 50; // 列号 if (k < 0 || k >= 13 || j < 0 || j >= 17) return; switch (wall[k][j]) { case 1: wall[k][j] = 0; break; // 墙变路 case 0: wall[k][j] = 1; break; // 路变墙 } Invalidate(); // 通知系统重绘 }

这里k、j的顺序别写反:point.y对应行、point.x对应列,与wall[k][j]的下标一致。编辑后调用Invalidate而不是手动贴图,让OnEraseBkgnd统一刷新,是避免画面残留的常用做法。出生点和粮仓的位置也可以做成可拖拽,那属于扩展功能,课设里只做了墙与路的互换。

5.2 地图存盘:为什么用ASCII码而不是二进制

保存地图时,报告把每个int加48转换成ASCII字符写入文件,一次fwrite写入13×17=222字节:

void CMainFrame::OnSave() { extern int wall[13][17]; char ch[13][17]; for (int i = 0; i < 13; i++) for (int j = 0; j < 17; j++) ch[i][j] = wall[i][j] + '0'; // 数字转ASCII FILE *pFile = fopen("Gamemap.txt", "w"); fwrite(ch, 1, 13 * 17, pFile); fclose(pFile); }

之所以不用二进制,是因为wall里可能有2(粮仓)和3(出生点),若按每格一字节直接写二进制,换行与格式难以统一,用ASCII记录天然可读可手工修改。载入时加一步ch[i][j] - '0'还原即可。报告里没有写文件头与版本号,实际升级迷宫尺寸时建议在文件开头补两字节宽高,否则换地图必须同步改代码。

5.3 从13×17固死到N×M动态迷宫

把wall数组改成vector<vector<int>>,所有硬编码的13、17都换成运行期的rows、cols,并把贴图尺寸也参数化,这个课设就能变成通用迷宫编辑工具。最常见的坑是坐标换算沿用50像素常量,导致窗口缩放后点击错位,正确做法是在OnSize里重新计算格宽=客户区宽度/cols,格高=客户区高度/rows,再用这个动态值替换所有除50的地方。这套思维同样适用于算法题:先固定尺寸跑通,再抽象成动态规模。

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

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

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

立即咨询