☰
C#推箱子源码拆解:WinForms分层结构、BFS寻路与关卡加载实战
2026/10/17 2:53:56 网站建设 项目流程

简介:这份用C#编写的推箱子小游戏完整工程源码,适合C#初学者以及希望借助经典小游戏练习Windows窗体开发的读者。项目实现了地图渲染、人物与箱子移动判定、选关、撤销与重做、最佳步骤记录等核心功能;通关后会把最优步骤保存为way文件,既能回放整个推箱子过程,也能分享给朋友参考。代码中还带有当前状态存盘与读盘功能,并且地图信息存放于文本或数据文件,玩家可以自行设计新关卡。包内共七十六个文件,压缩包只有一百三十五KB,主体是C#源码、窗体布局资源和位图素材,另有关卡数据、记录文件以及可执行程序,结构简洁,方便直接编译运行。目前已有九百六十一人学习下载这套源码,口碑较适合入门练习。通过学习这份代码,可以掌握键盘事件处理、界面控件交互、文件读写和状态恢复等桌面程序常见技巧;作者还预留了自动寻路、鼠标推箱子、通关光荣榜等拓展方向,适合在此基础上继续完善,也可作为课程设计或自学项目的参考。

1. 经典题材不简单:一套结构清晰的 C# 推箱子小游戏源码

如果你拿 C# 推箱子小游戏源代码当入门材料,多数版本会让你翻车:逻辑全堆在按钮点击事件里,改一个功能就要动全局变量;关卡写死在二维数组里,想加一关得改代码重新编译。我拆的这套不一样——移动判定、胜负检查、自动求解提示都拆成了独立类,关卡从文本文件读取,WinForms 界面层只负责画格子、接键盘事件。游戏虽小,但“界面、逻辑、数据”三层是分开的。它适合正在学 C# 课程设计、想快速交一个小游戏作业、或者想练手经典寻路算法的开发者。你能从里面看到完整的地图数据结构、推箱规则判定和 BFS 自动寻路实现,改造成关卡编辑器或个人作品集项目都不费劲。

2. 工程结构初探:WinForms 界面与游戏核心的职责分离

2.1 文件清单:每个类到底在管什么事

这套源码的工程结构不复杂,但分层清楚。我拆的时候第一件事就是把每个文件的责任列出来,这样后面改代码不会迷路。

文件职责关键成员
Program.cs应用程序入口,启动主窗体Main()
MainForm.csWinForms 主窗体,处理键盘事件、触发渲染OnPaint()、KeyDownhandler
GameMap.cs地图数据模型,封装二维格子数组与字符常量char[,] cells、Rows/Cols
GameEngine.cs游戏规则核心:移动判定、推箱、胜负检查、撤销TryMove()、IsCompleted()
LevelLoader.cs读取 .xsb 关卡文本文件,转成 GameMap 对象LoadFromFile()、Validate()
UndoStack 或历史栈记录每步地图快照,实现后悔药功能Stack<char[,]>

我拆的时候特意确认了 GameEngine 是否引用 WinForms 命名空间——没有。它只操作GameMap和方向枚举,这样以后想把界面换成 WPF、Unity 或者控制台版本,核心逻辑不用动。这是这套小项目里最值得抄的设计决策。

2.2 方向枚举与地图坐标:先定好规矩再写逻辑

推箱子所有规则都绕不开“格子坐标 + 方向”。源码里用了一个字节枚举表示方向,而不是直接用字符串“up/down”,好处是 switch 判断快、不容易拼错,而且后面做 BFS 寻路时给每个方向编号能直接按下标访问数组。

public enum Direction : byte { Up = 0, Down = 1, Left = 2, Right = 3 }

方向上对应的行偏移和列偏移,源码里用一个静态数组统一管理,移动时只要查表就行,不用在逻辑里写四个 if:

private static readonly int[] DR = { -1, 1, 0, 0 }; private static readonly int[] DC = { 0, 0, -1, 1 }; public static Point Offset(Direction dir) { int idx = (int)dir; return new Point(DR[idx], DC[idx]); }

偏移表配合枚举序号,让坐标变化变成纯算术操作。后面 BFS 自动求解时,遍历四个方向用一层 for 循环走Offset就够了,不需要每个方向写一段重复代码。这种小技巧在游戏类源码里很常见,但是初学者写的推箱子往往忽略掉,最后代码冗余到没法加新功能。

2.3 地图字符集:一个格子就是一个人物状态机

地图数据用char[,]存,但字符的语义不是拍脑袋定的,而是沿用了推箱子社区通用的 XSB 标准字符集。这套字符集好在哪里?它把“人物站在目标点”和“箱子在目标点上”也编码成了独立字符,移动时只需要关心当前格子是什么,不需要额外记一套状态表。

字符含义备注
#墙不可进入、不可推
空地可走、可推
$箱子可被推动
.目标点人物和箱子都能站上去
@人物玩家控制
*箱子已在目标点推走后恢复为.,不是空格
+人物站在目标点移走后恢复为.

这套字符约定是整个游戏的“数据协议”。源码里在 GameMap 类中把它们定义成了常量,所有逻辑代码引用常量而不是裸字符,避免魔法数字满天飞。我后面自己写关卡编辑器时直接复用了这套字符集,整个工具链都不用重做。

3. 核心逻辑拆解:移动判定、胜负检查与 BFS 自动求解

3.1 移动与推箱判定:三步走,缺一步就穿墙

推箱子最核心的代码就是人物移动。很多新手版本翻车的原因是:只判断了“下一步是不是墙”,漏了“箱子前面是不是还有箱子或墙”。这套源码把移动拆成三个独立判定,逻辑顺序非常清楚。

public bool TryMove(Direction dir) { Point player = map.PlayerPosition(); Point next = player + DirectionOffset(dir); Point beyond = next + DirectionOffset(dir); // 第一步:人想去的地方是墙,直接失败 if (map[next.X, next.Y] == Tile.Wall) return false; // 第二步:人想去的地方是箱子 if (map[next.X, next.Y] == Tile.Box || map[next.X, next.Y] == Tile.BoxOnTarget) { // 箱子再往前是墙或另一个箱子,推不动 if (map[beyond.X, beyond.Y] == Tile.Wall || map[beyond.X, beyond.Y] == Tile.Box || map[beyond.X, beyond.Y] == Tile.BoxOnTarget) return false; PushBox(next, beyond, dir); } // 第三步:人物本身走过去,处理目标点状态 MovePlayer(player, next); return true; }

这里的参数其实只有Direction dir一个,但内部涉及三个坐标:人物当前位置、人物下一步位置、箱子被推后的位置。PushBox和MovePlayer是私有方法,它们负责处理“格子原来是目标点”的恢复逻辑。

逻辑说明:判定顺序是从近到远。先判人能不能走,再判要不要推箱子,最后才真正修改地图。如果把“推箱子”和“人移动”写反了,或者合并成一个判定,很容易出现人和箱子重叠、人把箱子挤进墙里的 Bug。

参数说明:Tile.BoxOnTarget代表箱子已经在目标点上的状态。这个判断不能漏,否则箱子一旦被推进目标点,再想推出来程序会把人挡住,关卡直接卡死。

3.2 胜负判定:箱子数量对不等于过关

胜负判定是另一个容易写错的地方。新手常见的错误是“数一数地图上还有几个箱子没有进目标点”,但漏了*(箱子在目标点上)这个状态,或者把人物站着的目标点也算错。这套源码的判定思路是所有箱子逐个比对位置,而不是数目标点。

public bool IsCompleted() { for (int r = 0; r < map.Rows; r++) { for (int c = 0; c < map.Cols; c++) { char cell = map[r, c]; if ((cell == Tile.Box || cell == Tile.BoxOnTarget) && map.IsTargetAt(r, c) == false) { return false; } } } return true; }

逻辑说明:只要发现一个“箱子身份”的格子但它所在位置不是目标点,就判定未过关。循环结束后没找到这样的格子,说明所有箱子都在目标点上,返回 true。

参数说明:IsTargetAt(r, c)是 GameMap 提供的一个查询方法,内部判断该坐标在初始关卡数据里是不是.点。注意这里不是判断当前格子字符是不是.,因为箱子占住目标点后字符变成了*,直接看字符会误判。源码把“箱子的当前位置”和“目标点的原始位置”分开维护,从根上避开了这个坑。

3.3 BFS 自动求解提示:给玩家一个“求助键”

这套源码里比较亮眼的是一键提示功能:玩家卡住时可以请求程序计算一个可行动作。实现方式是用广度优先搜索(BFS)在“人物可移动区域”内找一条到箱子旁边的路径。完整解整个关卡属于高级话题,源码做的是“找到最近的可推箱子并走位到它的推动位”,但 BFS 骨架是完整的,可以在此基础上扩展。

public List<Direction> BfsFindPath(Point start, Point goal) { if (start == goal) return new List<Direction>(); int rows = map.Rows, cols = map.Cols; bool[,] visited = new bool[rows, cols]; Point?[,] parent = new Point?[rows, cols]; Direction[,] dirUsed = new Direction[rows, cols]; Queue<Point> queue = new Queue<Point>(); visited[start.X, start.Y] = true; queue.Enqueue(start); while (queue.Count > 0) { Point cur = queue.Dequeue(); for (int d = 0; d < 4; d++) { Direction dir = (Direction)d; Point next = cur + DirectionOffset(dir); if (next.X < 0 || next.X >= rows || next.Y < 0 || next.Y >= cols) continue; if (visited[next.X, next.Y]) continue; if (map[next.X, next.Y] == Tile.Wall) continue; // 箱子格不算可通行区域 if (map[next.X, next.Y] == Tile.Box || map[next.X, next.Y] == Tile.BoxOnTarget) continue; visited[next.X, next.Y] = true; parent[next.X, next.Y] = cur; dirUsed[next.X, next.Y] = dir; queue.Enqueue(next); if (next == goal) return ReconstructPath(parent, dirUsed, start, next); } } return new List<Direction>(); // 返回空列表表示无路可走 }

逻辑说明:visited防止重复遍历,parent和dirUsed记录每个格子是从哪个格子、沿哪个方向来的,目标找到后从终点回溯到起点,还原出完整路径。队列保证了先到达终点的路径就是最短步数路径。

参数说明:start是人物当前位置,goal是想要到达的“箱子推动位”。实际调用时,源码会遍历所有箱子,计算每个箱子两侧的推动位,然后对最近的推动位做 BFS。这里有个细节点:推动位本身可能是目标点字符.,但 BFS 里要把它当成空地处理,不能被目标点字符卡住。

这套 BFS 代码虽然只有几十行,但已经足够演示“寻路算法在游戏里怎么落地”。如果你后面想做全关卡自动求解器,只需要把搜索状态从“人物位置”扩展成“人物位置 + 所有箱子位置”的复合状态,BFS 的思想完全一致,只是状态空间变大。

4. 关卡与地图系统:从文本文件到屏幕渲染

4.1 XSB 关卡文件:关卡不再是硬编码

这套源码支持从外部文件加载关卡,而不是把地图写死在代码里。关卡文件用 XSB 文本格式,我上面提过字符集,现在看看完整的关卡文件长什么样:

####### # # # . $ # # @ # # . $ # # # #######

每一行是一个字符串,行里的每个字符对应地图中的一个格子。这种格式的好处是任何文本编辑器都能打开编辑,而且网上的推箱子社区有海量现成关卡可以拿来直接用。源码里关卡文件放在单独的目录下,每个文件一个关卡,GameEngine 启动时指定要加载的关卡编号。

4.2 加载与校验:关卡文件不是随便就能吃进来

直接读文件到字符数组会导致很多潜在问题:行长度不一致、包含非法字符、地图四周没封闭(人物可以走出去)等。这套源码的 LevelLoader 做了基础校验。我看过不少简化版推箱子源代码,这一步常常缺失,但实际开发中它很重要——你自己写关卡编辑器时生成的文件很难保证格式总正确。

public static GameMap LoadFromFile(string path) { string[] lines = File.ReadAllLines(path, Encoding.UTF8); if (lines.Length == 0) throw new InvalidDataException("关卡文件为空"); int rows = lines.Length; int cols = lines.Max(line => line.Length); char[,] cells = new char[rows, cols]; // 先填满空格,避免短行的空白区域是未初始化值 for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) cells[r, c] = Tile.Space; for (int r = 0; r < rows; r++) { string line = lines[r]; for (int c = 0; c < line.Length; c++) { char ch = line[c]; if (Tile.IsValid(ch) == false) throw new InvalidDataException( $"第 {r + 1} 行第 {c + 1} 列存在非法字符 '{ch}'"); cells[r, c] = ch; } } return new GameMap(cells); }

逻辑说明:第一步取所有行里的最大长度作为列数,然后把整个二维数组预填成空格,再逐行写入真实字符。这样即使某行短了几个字符,也不会读到数组未初始化的脏数据。

参数说明:Tile.IsValid(ch)是一组白名单判断,只允许#、空格、.、$、@、*、+这几种字符进入地图。如果关卡文件里混入了不可见控制字符或中文符号,加载阶段就会直接报错并提示行列位置,而不是等到运行时出现“人物莫名其妙走不动”的玄学问题。

4.3 渲染刷新的两种做法:GDI+ 绘制与贴图方案

地图加载完成后,下一步就是把二维数组画到窗体上。这套源码用的是 GDI+ 绘图,在 MainForm 的OnPaint里统一绘制,而不是放一堆 Label 控件。这种做法在小游戏里更可控,因为窗体重绘、最小化恢复时只需要重新执行一次绘制逻辑。

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; for (int r = 0; r < map.Rows; r++) { for (int c = 0; c < map.Cols; c++) { Rectangle rect = new Rectangle( c * TileSize + Margin, r * TileSize + Margin, TileSize, TileSize); Brush brush = BrushForCell(map[r, c]); g.FillRectangle(brush, rect); if (map[r, c] == Tile.Wall) g.DrawRectangle(Pens.DarkGray, rect); } } }

逻辑说明:外层循环遍历行,内层循环遍历列,把每个格子换算成屏幕坐标画出来。Margin是窗体边距,TileSize是每个格子的像素边长,这两个常量决定了整个游戏的显示尺寸。

参数说明:BrushForCell是一个颜色映射函数,把字符映射回画刷。空地是浅灰色,墙是深色,目标点是红色小方块,箱子是橙色,人是蓝色。如果你把 TileSize 从 32 改成 64,画面整体放大一倍。想要更好看的视觉效果,可以把这个方法替换成Image.FromFile加载图片,用DrawImage代替FillRectangle,这就是整套源码的画风扩展点。

这里有一个我在拆代码时特别留意的细节:键盘事件触发移动后,刷新画面不是调用Invalidate()而是调用Refresh()。Invalidate是异步的,可能攒到消息循环空闲才重绘;而Refresh强制同步重绘。推箱子这种每按一次键变化一个格子的场景,同步重绘能避免画面闪烁和“按键了但画面没跟上”的割裂感。

5. 避坑记录:推箱子项目里最常见的几个翻车现场

这一章是我自己在拆这类源码、以及照着写一个推箱子时踩过的真实坑。每条都按“现象 → 原因 → 解决”整理,你二次开发时大概率也会撞上。

5.1 人物走出目标点后,那个格子神秘消失了

现象:人物本来站在.点上,角色状态是+。人物移开后,目标点从地图上消失了,变成普通空地,这一关永远无法完成。

原因:移动逻辑里直接map[oldX, oldY] = Tile.Space,没有判断旧格子原来是目标点还是普通空地。

解决:移动人物时,先记录当前位置的原始单元格类型。如果是+(人站在目标点),移动后这个格子要恢复成.;如果是普通@,移动后才置为空格。源码在 MovePlayer 方法里用一个局部变量保存原始状态,离开后根据它决定恢复目标点还是空地。这个坑非常隐蔽,因为测试时你很可能先让角色在空地上来回走,等第一次站上目标点再离开时才暴露。

5.2 箱子全推进目标点了却不通关

现象:画面上每个框子都在红色目标点上,但IsCompleted()返回 false,游戏不弹胜利提示。

原因:胜负判定遍历的是“目标点位置有没有被箱子占”,而不是“所有箱子是否都在目标点上”。如果地图上还有目标点被人物占着,或者目标点总数比箱子总数多,这个判定方式就会漏判。另一类原因是忘了把*(箱子在目标点)状态识别为箱子,判定位把*当场了不可通过的障碍。

解决:按上一章3.2节的写法,遍历全图找“箱子身份的格子”,对每个箱子检查它脚下是不是目标点。不要从目标点视角出发去数,要从箱子视角出发。判定语句里对Tile.Box和Tile.BoxOnTarget同等对待,两个都算箱子。

5.3 关卡文件第一行被吞掉,地图整体错位

现象:加载某个网上下载的关卡,地图第一行字符缺失、墙面少了半边,或者第一行第一个字符解析成非法字符直接抛异常。

原因:很多文本编辑器保存文件时带 UTF-8 BOM 头(EF BB BF),File.ReadAllLines如果没指定编码,读出来的第一行字符串会带着一个隐形字符\uFEFF,导致第一个格子校验失败。另外,网上下载的关卡文件可能是\r\n换行,如果按\n切分行也会出现诡异行为。

解决:读取时显式指定Encoding.UTF8,解析前对第一行做Trim()去掉不可见控制字符。我一般这样写:

string[] lines = File.ReadAllLines(path, Encoding.UTF8) .Select(l => l.Trim()) .ToArray();

这算是一个典型的“线上能跑,线下翻车”的编码问题,事后排查花费的时间远大于写这一行Trim()。

5.4 撤销功能把所有历史状态一起改了

现象:按“后悔键”回退到上一步,发现画面确实变了,但再按一次回退,画面纹丝不动,好像历史记录全都变成了同一个状态。

原因:撤销栈里存的是char[,]数组的引用,而不是副本。推箱子每一步都通过原地修改地图数组来更新状态,所以栈里的所谓“历史快照”其实都指向同一个数组对象,内容永远是“最新状态”。这是 C# 里典型的引用类型误用。

解决:入栈时显式拷贝一份数据。最简单的写法:

private char[,] CloneMap(char[,] source) { return (char[,])source.Clone(); }

每次入栈前调用一次CloneMap,同时给栈设一个上限(比如 100 步),防止玩家来回走太多次把内存吃光。这个坑很好地说明了为什么游戏状态管理通常推荐“不可变对象”或“命令模式”,哪怕是推箱子这种小项目,引用陷阱也会真实咬人。

5.5 方向键按下没反应,焦点被控件抢走了

现象:窗体上如果有 TextBox、Button 等控件,键盘方向键触发不了游戏移动;或者按方向键时焦点在窗体和控件之间跳来跳去。

原因:WinForms 的方向键默认用于控件间导航,KeyDown事件在某些控件上不会正常触发,需要告诉窗体“我要自己处理这些键”。

解决:在主窗体里重写IsInputKey方法,或者注册PreviewKeyDown事件。我常用的做法是重写IsInputKey,明确声明方向键不交给默认处理:

protected override bool IsInputKey(Keys keyData) { switch (keyData) { case Keys.Up: case Keys.Down: case Keys.Left: case Keys.Right: return true; default: return base.IsInputKey(keyData); } }

如果不想重写基类方法,也可以在PreviewKeyDown里把IsInputKey = true赋值,效果相同。这个问题不解决,写再多游戏逻辑都白搭——玩家按方向键根本进不了你的事件处理函数,是典型的“黑匣子”问题,看起来像代码没生效,实际上是事件根本没发到你手里。

5.6 调试技巧:跑不通时先打印地图快照

我在调这套源码时养成了一个习惯:写一个把GameMap转成字符串的方法,随时在控制台或日志里输出地图当前状态。

public string Dump() { var sb = new StringBuilder(); for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) sb.Append(this[r, c]); sb.AppendLine(); } return sb.ToString(); }

遇到“这步移动后箱子离奇消失”之类说不清的问题,就在移动前后各打一次Dump(),对比两个字符串,哪个字符变了、哪个字符没变一目了然。比单步断点更快,也比反复用 WinForms 的 MessageBox 弹窗试错高效得多。这套源码核心逻辑与界面分离的最大好处就是可以在纯控制台环境下用这种方法做单元验证,不需要把整个游戏跑起来才能测逻辑。

6. 二次开发方向:从基础版到能拿得出手的完整小游戏

这套源码的底子够扎实,接下来的改造空间很大。我认为性价比最高的三个方向是:关卡编辑器、存档与步数统计、贴图换肤。

想做关卡编辑器,可以直接复用 LevelLoader 和字符集。窗体上放一个地图预览区,鼠标点击格子循环切换字符类型,编辑完保存成 .xsb 文件。关键点在保存时统一用Encoding.UTF8且不要带 BOM,否则又会踩上面 5.3 节的坑。再配合实时校验——保存前跑一遍Tile.IsValid,可以保证你手工画的关卡一定能被游戏加载。

存档与步数统计需要动手的地方也不大。步数可以在 GameEngine 每次成功调用TryMove时加一;存档则可以直接序列化当前GameMap的字符数组和步数到一个 JSON 文件,下次启动时装回去。注意序列化也逃不开引用拷贝问题,如果你把char[,]直接塞进JsonConvert.SerializeObject,还原时最好重新走一遍 Loader 的解析流程,保证数据一致。

贴图换肤这块,把BrushForCell换成图片渲染即可:准备墙、地面、箱子、目标点、人物五张素材图,OnPaint里用DrawImage替代FillRectangle。素材可以用现成的免费像素画,也可以用代码生成简单色块。

从那以后我每拿到一个游戏类源码,都会强制先走一遍“读文件清单 → 找核心逻辑类 → 写一个 Dump 验证方法”这套流程,确认能跑通再开始改代码。C# 推箱子虽然是个经典小游戏,但它把数据、逻辑、渲染的边界划得很清楚,当你把这里面的设计习惯迁移到更大的项目时,会发现省下的是大量查 Bug 的时间。希望帮到你。

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

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

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

立即咨询