简介:这份资源是面向高校学生与C#初学者的WinForm迷宫游戏期末大作业完整项目,围绕桌面应用开发、迷宫自动生成、角色移动、暂停控制与路径提示等核心功能展开,适合作为课程设计参考或自学练手案例。压缩包共165个文件,约3.24MB,以dll动态库、json配置、cache缓存、cs源码、pdb调试符号及exe可执行文件为主,另含sln解决方案、csproj工程文件与少量资源文件,完整保留了Visual Studio项目的目录结构与依赖关系。项目涵盖递归分割、深度优先搜索等迷宫生成算法,键盘事件驱动的上下左右移动逻辑,以及基于A*或Dijkstra的路线提示实现,代码结构清晰、注释较全,可直接运行并二次修改。目前已有162人学习下载,适合需要快速搭建可运行迷宫游戏、理解WinForm事件机制与路径搜索算法的读者参考借鉴。
1. 从一份“跑不起来”的 WinForm 迷宫大作业说起
很多同学拿到 C# 期末大作业——WinForm 迷宫游戏,第一反应是去搜“自动生成迷宫算法”,结果代码抄回来,窗体一跑,要么迷宫不连通,要么角色穿墙,要么暂停键按下去整个界面卡死。这份资源包里的Maze.UI.csproj、Maze.Library.csproj以及一堆mazegenerateinator.*.v9.bin缓存文件,其实暴露了一个典型工程结构:UI 层和算法库分离,迷宫生成器独立成库。它解决的不是“怎么画一个迷宫”,而是“怎么让迷宫每次随机、角色移动不越界、暂停不假死、提示路线能画出来”。适合正在做 WinForm 课程设计、需要交一个能演示、能讲清原理的 C# 桌面应用的人。下面我按自己拆包复现的顺序,把生成、移动、暂停、寻路四个点串一遍。
2. 迷宫生成算法选型:DFS 递归回溯为什么比 Prim 更适合大作业
2.1 三种常见生成算法的实际差异
自动生成迷宫的本质是在一个 M×N 的网格里,打通若干墙壁,形成从起点到终点连通、且没有环或环很少的路径。常见做法有递归回溯(DFS)、Prim 随机 Prim、Kruskal 并查集。我一般会先看作业要求里有没有“路径多样”“死胡同多”这类描述。DFS 递归回溯生成的迷宫有一条明显的主路径,死胡同分支多,视觉上更像传统迷宫;Prim 生成的迷宫分支短而密,看起来比较“碎”;Kruskal 生成的迷宫均匀但实现要写并查集,代码量偏大。
这份资源里Maze.Library承担生成逻辑,UI 只负责画格子和接收键盘。选 DFS 的理由很直接:递归写法短,用栈或递归都行,随机方向打乱后连通性天然保证,不会出现孤岛。对于期末大作业,老师看的是你能不能讲清“为什么这样打通不会形成环”,DFS 的 visited 标记正好能解释这一点。
2.2 用二维数组表示墙壁和通路的代码实现
我一般用int[,] maze,每个格子存墙壁状态,或者用两个布尔数组分别表示右墙和下墙。下面这段是 DFS 递归回溯的核心,放在Maze.Library里,UI 层只调用Generate(width, height)。
// MazeGenerator.cs public class MazeGenerator { private int[,] grid; // 0 表示墙,1 表示通路 private Random rand = new Random(); private int[] dx = { 0, 0, -1, 1 }; // 上下左右 private int[] dy = { -1, 1, 0, 0 }; public int[,] Generate(int width, int height) { // 宽高必须是奇数,保证墙和通路交替 if (width % 2 == 0) width++; if (height % 2 == 0) height++; grid = new int[height, width]; for (int i = 0; i < height; i++) for (int j = 0; j < width; j++) grid[i, j] = 0; // 先全部置墙 // 从 (1,1) 开始挖路 Carve(1, 1); return grid; } private void Carve(int x, int y) { grid[y, x] = 1; int[] dirs = { 0, 1, 2, 3 }; // 洗牌方向,保证随机性 for (int i = 0; i < 4; i++) { int r = rand.Next(i, 4); int tmp = dirs[i]; dirs[i] = dirs[r]; dirs[r] = tmp; } foreach (int d in dirs) { int nx = x + dx[d] * 2; int ny = y + dy[d] * 2; if (nx > 0 && nx < grid.GetLength(1) - 1 && ny > 0 && ny < grid.GetLength(0) - 1 && grid[ny, nx] == 0) { // 打通中间那面墙 grid[y + dy[d], x + dx[d]] = 1; Carve(nx, ny); } } } }逻辑说明:grid初始全 0 代表墙,Carve每次把当前格置 1,然后向四个方向隔一格探测。如果目标格还在边界内且是墙,就把中间那面墙也置 1,再递归过去。参数width、height建议传奇数,否则边界处理会多一层判断。递归深度等于通路格数,一般 21×21 的迷宫递归几百层,不会栈溢出;如果做到 101×101,建议改成显式栈。
2.3 生成结果怎么验证连通性
生成完不能直接交给 UI 画,我习惯加一个 BFS 验证:从起点 (1,1) 出发,看能不能走到终点 (width-2, height-2)。如果走不到,说明生成逻辑有边界 bug。验证代码放在Maze.Library里单独一个IsConnected方法,UI 层在Generate后调用一次,不通过就重新生成。这一步能挡掉大部分“迷宫看起来对但角色卡死”的问题。
3. 角色移动与键盘事件:别让 KeyDown 变成穿墙术
3.1 WinForm 键盘事件绑定的正确位置
WinForm 里捕获上下左右,常见翻车点是把KeyDown绑在某个 Panel 或 PictureBox 上,结果焦点不在那个控件,按键没反应。正确做法是把窗体的KeyPreview设为true,然后在窗体的KeyDown事件里统一处理。这样无论焦点在哪个子控件,方向键都能被窗体先拿到。
// MainForm.cs public MainForm() { InitializeComponent(); this.KeyPreview = true; // 关键:让窗体先接收按键 this.KeyDown += MainForm_KeyDown; } private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (isPaused) return; // 暂停时忽略移动 int nx = playerX, ny = playerY; switch (e.KeyCode) { case Keys.Up: ny--; break; case Keys.Down: ny++; break; case Keys.Left: nx--; break; case Keys.Right: nx++; break; default: return; } // 边界与墙壁检查 if (nx >= 0 && nx < maze.GetLength(1) && ny >= 0 && ny < maze.GetLength(0) && maze[ny, nx] == 1) { playerX = nx; playerY = ny; this.Invalidate(); // 触发重绘 } }逻辑说明:KeyPreview = true是让窗体在子控件之前处理按键。isPaused标志位控制暂停时是否响应移动。maze[ny, nx] == 1保证只能走到通路格,墙壁格直接忽略。Invalidate()通知窗体重绘,实际绘制放在OnPaint里。参数playerX、playerY是角色当前坐标,初始设为 (1,1)。
3.2 重绘时怎么画角色和迷宫
OnPaint里遍历maze数组,墙画黑色矩形,通路画白色,角色画一个圆形或图片。注意e.Graphics的绘制顺序:先画背景墙,再画路径提示(如果有),最后画角色,否则角色会被覆盖。格子大小建议用cellSize = 20或25,窗体大小按width * cellSize动态设置,避免迷宫画到窗体外面。
3.3 移动时的边界条件与常见误用
边界检查里nx >= 0 && nx < maze.GetLength(1)和ny >= 0 && ny < maze.GetLength(0)的顺序不能反,GetLength(0)是行数(高度),GetLength(1)是列数(宽度)。我见过有人把这两个写反,结果角色在左右移动时越界,上下移动反而正常。另外,如果迷宫生成时宽高是偶数,边界格可能全是墙,角色出生点 (1,1) 要确认是通路,否则第一步就卡住。
4. 暂停与路径提示:标志位和 A* 怎么配合才不卡界面
4.1 暂停功能的实现与假死排查
暂停不是Thread.Sleep,也不是Application.DoEvents循环。正确做法是设一个bool isPaused,在KeyDown和计时器 Tick 里判断。如果游戏有自动移动的怪物或计时,暂停时把Timer.Enabled设为false,恢复时再设true。这样界面消息循环正常,不会出现“点暂停整个窗口白屏”的情况。
private void btnPause_Click(object sender, EventArgs e) { isPaused = !isPaused; gameTimer.Enabled = !isPaused; // 有计时器就同步停 btnPause.Text = isPaused ? "继续" : "暂停"; this.Invalidate(); }逻辑说明:isPaused控制键盘移动是否生效,gameTimer.Enabled控制自动逻辑是否运行。两者要同步,否则会出现“角色不能动但计时还在走”的玄学现象。btnPause.Text给用户反馈当前状态。
4.2 A* 寻路提示的简化实现
路径提示不需要完整 A*,用 BFS 找最短路径就够,因为迷宫格子权值都是 1。从起点开始 BFS,记录每个格子的前驱,到达终点后回溯出路径,存一个List<Point>。绘制时把路径格子用半透明颜色覆盖。
// MazeSolver.cs public List<Point> FindPath(int[,] maze, Point start, Point end) { int rows = maze.GetLength(0), cols = maze.GetLength(1); var prev = new Point[rows, cols]; var visited = new bool[rows, cols]; var queue = new Queue<Point>(); queue.Enqueue(start); visited[start.Y, start.X] = true; int[] dx = { 0, 0, -1, 1 }; int[] dy = { -1, 1, 0, 0 }; while (queue.Count > 0) { var cur = queue.Dequeue(); if (cur == end) break; for (int d = 0; d < 4; d++) { int nx = cur.X + dx[d], ny = cur.Y + dy[d]; if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && !visited[ny, nx] && maze[ny, nx] == 1) { visited[ny, nx] = true; prev[ny, nx] = cur; queue.Enqueue(new Point(nx, ny)); } } } // 回溯路径 var path = new List<Point>(); if (!visited[end.Y, end.X]) return path; // 不可达 for (var p = end; p != start; p = prev[p.Y, p.X]) path.Add(p); path.Add(start); path.Reverse(); return path; }逻辑说明:prev记录每个格子从哪个格子走来,visited防止重复入队。BFS 保证第一次到达终点时路径最短。回溯时从终点沿prev走回起点,再反转。参数start和end用Point类型,注意Point的X是列,Y是行,和数组索引maze[y, x]对应。
4.3 提示路线的绘制与清除
提示按钮点一次显示路径,再点一次清除。用一个List<Point> hintPath保存当前路径,OnPaint里如果hintPath != null就画半透明蓝色方块。清除时把hintPath置 null 并Invalidate()。注意路径提示不要和角色位置冲突,角色所在格最后画,保证角色可见。
5. 避坑与排查:缓存文件、焦点丢失和递归深度
5.1 现象:删掉 bin 和 obj 后项目编译报错找不到 Maze.Library
原因:资源包里那些mazegenerateinator.*.v9.bin和*.assets.cache是 Visual Studio 的生成缓存,不是源码。直接删bin、obj后,如果Maze.UI.csproj里对Maze.Library的项目引用路径是相对路径,而两个项目不在同一解决方案目录下,就会找不到引用。解决:用 VS 打开.sln文件,在解决方案资源管理器里右键Maze.UI的“引用”,重新添加对Maze.Library的项目引用,然后重新生成。
5.2 现象:方向键按了没反应,但鼠标点按钮正常
原因:窗体KeyPreview没设true,或者焦点在某个TextBox、Button上,方向键被子控件截获。解决:构造函数里加this.KeyPreview = true;,并确保没有其他控件把KeyDown事件标记为已处理。如果用了Panel承载绘制,把Panel的TabStop设为false,避免它抢焦点。
5.3 现象:迷宫生成后角色出生在墙里,第一步就卡死
原因:DFS 从 (1,1) 开始挖,但如果宽高传了偶数,Generate里虽然做了width++,但 UI 层画格子时用的还是原始宽高,导致坐标错位。解决:Generate返回迷宫数组后,UI 层用maze.GetLength(1)和maze.GetLength(0)重新计算窗体大小和格子数,不要用传入的width、height。
5.4 现象:暂停后恢复,角色连续移动好几格
原因:暂停期间按键事件被系统缓存,恢复后一次性触发。解决:在KeyDown里判断e.KeyCode的同时,加一个if (isPaused) return;放在最前面。另外可以在暂停时把this.KeyPreview临时设为false,恢复时再设true,双保险。
5.5 现象:A* 或 BFS 提示路径画出来是斜线或错位
原因:Point的X、Y和数组maze[row, col]的索引搞反。Point(x, y)里x是列,y是行,绘制时e.Graphics.FillRectangle(brush, p.X * cellSize, p.Y * cellSize, cellSize, cellSize)。如果写成p.Y * cellSize作为横坐标,路径就会转 90 度。解决:统一约定maze[y, x],所有Point构造用new Point(col, row)。
6. 进阶技巧:把生成器做成可替换接口,顺便验证路径提示
6.1 用接口隔离生成算法,方便换 Prim 或 Kruskal
如果作业要求“至少实现两种生成算法”,不要在MazeGenerator里写if (type == "DFS")。定义一个IMazeGenerator接口,Generate(int width, int height)返回int[,]。DFS 和 Prim 各写一个类实现接口,UI 层通过下拉框选择。这样代码结构清晰,答辩时也能讲“面向接口编程”。
public interface IMazeGenerator { int[,] Generate(int width, int height); } public class DfsMazeGenerator : IMazeGenerator { /* 上面的 DFS 实现 */ } public class PrimMazeGenerator : IMazeGenerator { /* Prim 实现 */ }逻辑说明:接口只暴露生成方法,UI 层持有IMazeGenerator引用,运行时根据用户选择实例化具体类。参数width、height由 UI 传入,返回的数组由 UI 负责绘制。
6.2 路径提示的验证:用 BFS 距离场检查提示是否最短
提示路径画出来后,怎么确认它真的是最短?我一般会再跑一次 BFS,计算从起点到终点的步数,和提示路径的Count - 1比较。如果相等,说明提示正确;如果提示路径更长,说明 BFS 回溯写错了。这个验证不需要显示给用户,只在 Debug 模式下Debug.Assert一下。
int shortest = BfsDistance(maze, start, end); Debug.Assert(hintPath.Count - 1 == shortest, "提示路径不是最短");参数说明:BfsDistance返回步数,hintPath是提示路径列表。Debug.Assert只在 Debug 编译下生效,Release 不会影响性能。
6.3 我踩过的一个坑:缓存文件导致 UI 设计器打不开
资源包里那些Maze.UI.assets.cache和Maze.UI.csproj.AssemblyReference.cache是 VS 的资源缓存。有一次我直接复制项目文件夹到另一台机器,打开.sln后窗体设计器报“无法加载设计器”,删掉obj和bin重新生成也没用。后来发现是.vs隐藏文件夹里的缓存和*.assets.cache冲突。解决办法:关闭 VS,删除项目根目录下的.vs文件夹和所有obj、bin,再重新打开解决方案。从那以后我每次迁移 WinForm 项目,都强制走一遍“关 VS → 删 .vs/obj/bin → 重开 → 重新生成”,再也没遇到过设计器黑匣子。
希望帮到你。
本文还有配套的精品资源,点击获取