简介C#推箱子小游戏完整源代码面向C#入门与游戏编程爱好者演示了Windows窗体应用从界面搭建、键盘事件到游戏逻辑的完整写法适合作为课程设计或练习项目参考。程序支持推箱子、撤销CtrlZ、重复CtrlY、选关等核心操作通关后可将最佳步数保存为.way文件用于回放演示或分享通关过程同时支持自行设计关卡、当前状态存盘与读盘方便反复挑战和扩展玩法。资源包共76个文件以10个.cs源码为中心配以11张.bmp地图与角色素材、15个.way路径记录、13个.dat数据存档以及项目工程和可运行exe整体仅135KB结构紧凑、层次分明便于对照源码逐模块理解。目前已有960人学习下载适合想通过小游戏实战熟悉C#文件I/O、窗口控件与逻辑控制的读者源码中还预留了自动寻路深度优先搜索与栈实现、鼠标拖拽操作、通关光荣榜等未完成功能能引导进阶者进一步练习算法和交互设计。1. C# 推箱子小游戏源代码练手就该从这种能跑完的项目开始不少 C# 初学者手头不缺语法书缺的是一个「能跑起来、读得懂、改得动」的完整小游戏源码。扫雷涉及递归展开贪吃蛇要处理队列和定时刷新对刚摸到面向对象门槛的人都有点劝退推箱子刚好卡在那个舒服的位置上——二维数组管地图、方向键管输入、几个 if 管碰撞判定整个游戏逻辑一晚上能读完。C# 推箱子小游戏源代码这个标题背后你要找的其实就是一套地图建模、角色移动、箱子推进、胜负判定的完整实现外加一个能交互的界面。这套源码解决的不只是「交作业」这一件事。对想转上位机或 Unity 的开发者来说推箱子是把「状态机 坐标运算 UI 刷新」串起来的最小可运行样本对老师或培训讲师来说它是一个能让学生照着改出几十个变体的课程设计载体。本文按一条主线走先把地图和移动逻辑讲透再给出控制台与 WinForms 两种可运行版本最后拆类、讲坑、加悔棋。跟着走完你手上会有一套自己能二次开发的推箱子源码而不是一个「跑完就删」的黑匣子。2. 推箱子的核心逻辑地图建模与移动判定先写对这 3 个函数2.1 地图建模用字符数组把关卡变成一张可以计算的网格所有推箱子源码的第一步都是把「一张画着墙、箱子、人的图」变成程序能读的数据。常见做法是用一个二维字符数组char[rows, cols]来存地图每个格子是一个单字符字符的约定在推箱子圈子里基本是通用的# 表示墙$ 表示箱子. 表示目标点 表示玩家。下面的解析函数把一组字符串数组转换成一个二维数组同时记住玩家初始坐标。public class GameMap { public char[,] Grid; public int Rows { get; private set; } public int Cols { get; private set; } public int PlayerX { get; private set; } public int PlayerY { get; private set; } public static GameMap Parse(string[] lines) { int rows lines.Length; int cols 0; foreach (var line in lines) cols Math.Max(cols, line.Length); var map new GameMap { Rows rows, Cols cols, Grid new char[rows, cols] }; for (int y 0; y rows; y) { for (int x 0; x cols; x) { char c x lines[y].Length ? lines[y][x] : ; if (c ) { map.PlayerX x; map.PlayerY y; } map.Grid[y, x] c; } } return map; } }逻辑说明GameMap把地图数据和玩家位置打包在一起Parse先把所有行的最大长度取出来作为列数避免某个短行导致数组变成参差不齐的形状。代码里的x lines[y].Length是为了防止某一行比其他行短缺失的部分统一补成空格。这个健壮性处理很关键——你从文本文件读关卡时最后一行很可能因为换行符问题少一个字符。参数说明Rows 和 Cols 是地图边界TryMove里所有坐标判断都要先拿它们做越界检查否则数组越界异常会在你按下方向键的瞬间喷出来。玩家坐标的初始化放在解析阶段是因为后面每一步移动都要知道「人现在在哪」。2.2 移动逻辑与碰撞检测角色能不能走、箱子能不能推全在这一个函数里有了地图数组接下来的问题是按下方向键后会发生什么。这里有个新手容易写乱的判定顺序——必须先把「人和箱子能不能动」全部探明再一次性更新坐标先动人再判断箱子就会出现主角瞬移穿墙的灵异事件。四个方向的移动本质上是给坐标加一组偏移量上(0, -1)、下(0, 1)、左(-1, 0)、右(1, 0)判断逻辑对四个方向完全对称。private static readonly (int dx, int dy)[] Dirs { (0, -1), // 上 (0, 1), // 下 (-1, 0), // 左 (1, 0) // 右 }; public bool TryMove(int dirIndex) { var (dx, dy) Dirs[dirIndex]; int nx PlayerX dx; int ny PlayerY dy; // 第一步目标格越界或撞墙直接返回 if (nx 0 || nx Cols || ny 0 || ny Rows) return false; if (Grid[ny, nx] #) return false; // 第二步目标格是箱子检查箱子能不能再往前一步 if (Grid[ny, nx] $ || Grid[ny, nx] *) { int bx nx dx; int by ny dy; if (bx 0 || bx Cols || by 0 || by Rows) return false; if (Grid[by, bx] # || Grid[by, bx] $ || Grid[by, bx] *) return false; // 箱子移动目标格若是 . 则变成 *,否则是 $ Grid[by, bx] Grid[by, bx] . ? * : $; Grid[ny, nx] (Grid[ny, nx] *) ? . : ; } // 第三步角色移动原位置恢复成 . 或空格 Grid[PlayerY, PlayerX] Grid[PlayerY, PlayerX] ? . : ; PlayerX nx; PlayerY ny; Grid[ny, nx] (Grid[ny, nx] . || Grid[ny, nx] *) ? : ; return true; }逻辑说明Dirs数组把四个方向映射为坐标偏移避免在函数里写四段几乎相同的 if 分支。判断的先后顺序是重点——先检查角色目标格(nx, ny)能不能站如果站的是箱子再检查箱子前方(bx, by)能不能落。两步检查全部通过之后才去改地图数组这就是「先探测后更新」的防穿墙关键。参数说明这里用到了*和两个特殊状态*表示箱子站在目标点上表示玩家站在目标点上。很多人第一次写字面量判断漏掉这两个状态导致箱子推到目标点上后地图显示变成空格胜利条件永远判不中。把这个状态在注释里写清楚后面所有关卡显示和胜负判定都会轻松很多。2.3 胜负判定箱子到没到齐两种计数方案任选推箱子的胜负条件很直白所有目标点都被箱子占住。实现上有两种思路。第一种是每次移动后遍历整个地图数一下*的数量是否等于目标点总数第二种是提前记录目标点的坐标集合每步移动后逐个检查。小地图上两者性能差别可以忽略但我建议用第二种原因是它还能顺带告诉你「还差哪个点没归位」做关卡提示时不用再扫一遍地图。public class GameMap { private readonly List(int x, int y) _targets new(); public int TargetCount _targets.Count; public bool IsWin() { foreach (var (x, y) in _targets) { if (Grid[y, x] ! * Grid[y, x] ! ) return false; } return true; } }逻辑说明_targets在Parse阶段收集凡遇到.或都记录坐标。IsWin遍历目标点只要有一个点上不是*就说明箱子没压上去。判定时把也算通过是因为玩家可能恰好站在某个目标点上如果玩家的位置和目标点重叠了那个格子仍然算目标已占用。参数说明这个实现不去数「箱子总数」因为可能存在箱子不在任何目标点上的死局状态数箱子会误判。实际调用IsWin的时机放在每次TryMove成功返回true之后移动失败不用判。死局检测箱子全卡墙角属于求解算法范畴这个最小源码里不做后面做关卡编辑器时可以通过「可解性」过滤关卡。3. 把源码跑起来从控制台最小版本到 WinForms 界面3.1 控制台最小版本不碰 UI 线程先把游戏循环跑通逻辑写完后第一步永远先做控制台版本。它没有窗体焦点的干扰没有跨线程访问控件的异常能让你把注意力全放在游戏循环上读按键、调TryMove、判断胜负、重绘地图。static void Main() { string[] lines { ##### , # # , # $ # , ### . ###, # $ #, # . #, ### . ###, # # , ##### }; var map GameMap.Parse(lines); while (true) { Console.Clear(); Render(map); if (map.IsWin()) { Console.WriteLine(你赢了); break; } var key Console.ReadKey(true).Key; int dir key switch { ConsoleKey.UpArrow 0, ConsoleKey.DownArrow 1, ConsoleKey.LeftArrow 2, ConsoleKey.RightArrow 3, ConsoleKey.Escape -1, _ -2 }; if (dir -1) break; if (dir 0) map.TryMove(dir); } } static void Render(GameMap map) { for (int y 0; y map.Rows; y) { for (int x 0; x map.Cols; x) Console.Write(map.Grid[y, x]); Console.WriteLine(); } }逻辑说明Console.ReadKey(true)的参数true表示按键不回显否则你每按一次方向键控制台窗口里会多出一个箭头字符干扰地图显示。switch表达式把ConsoleKey枚举映射成 0 到 3 的方向索引正好对上前一节Dirs数组的顺序这是一处极容易踩的对不齐问题——方向枚举写错了不会报错只会表现为「按上键人往左跑」。参数说明Escape键单独映射成-1退出循环其他无关按键映射成-2忽略掉避免按了个字母键导致程序直接把一个未知索引传给TryMove。控制台版不用加延迟推箱子是回合制游戏天然等人输入加Thread.Sleep只会让按键体验变黏。3.2 WinForms 最小版本用 Label 数组当瓦片逻辑与 UI 分离控制台版能跑通后往 WinForms 迁移时最省事的方式是用Label[,]数组做地图瓦片。每个Label显示一个字符背景色区分墙和空地窗体的KeyDown事件接方向键。这样地图逻辑完全复用上一章的GameMapUI 层只做「把数组渲染成控件」这件事。public partial class MainForm : Form { private GameMap _map; private Label[,] _cells; private const int CellSize 24; public MainForm() { InitializeComponent(); KeyPreview true; DoubleBuffered true; FormBorderStyle FormBorderStyle.FixedSingle; MaximizeBox false; string[] lines { /* 与 Console 版相同的关卡字符串 */ }; _map GameMap.Parse(lines); BuildGrid(); Redraw(); } private void BuildGrid() { _cells new Label[_map.Rows, _map.Cols]; for (int y 0; y _map.Rows; y) { for (int x 0; x _map.Cols; x) { var cell new Label { Left x * CellSize, Top y * CellSize, Width CellSize, Height CellSize, TextAlign ContentAlignment.MiddleCenter, Font new Font(Consolas, 12F) }; _cells[y, x] cell; Controls.Add(cell); } } ClientSize new Size(_map.Cols * CellSize, _map.Rows * CellSize); } private void Redraw() { for (int y 0; y _map.Rows; y) { for (int x 0; x _map.Cols; x) { char c _map.Grid[y, x]; _cells[y, x].Text c.ToString(); _cells[y, x].BackColor c switch { # Color.Gray, $ or * Color.SandyBrown, . or Color.LightGreen, _ Color.White }; } } } }逻辑说明KeyPreview true让窗体先于子控件收到键盘事件否则焦点落在某个 Label 上时方向键根本不会传到窗体。BuildGrid按CellSize逐个摆控件ClientSize根据行列数算这样窗口大小能严格罩住地图不会出现地图画了一半被窗体边缘切掉的局面。参数说明字体用Consolas是等宽字体否则每个字符宽度不一致贴了砖块的墙画出来会歪歪扭扭。Redraw用 switch 表达式做字符到背景色的映射后续想换皮肤比如把墙改成深灰色、箱子改成木纹色只改这一处不需要碰逻辑层。这就是逻辑与 UI 分离的第一个好处调样式不碰判断代码。3.3 键盘输入与刷新参数KeyDown 别写成长按连发重绘用 InvalidateWinForms 里的方向键处理常见的翻车点是把逻辑写在KeyPress事件里——方向键根本不是字符键KeyPress不会触发还有人用Timer每秒轮询键盘状态产生连发和输入黏滞。正确做法是重写OnKeyDown或在KeyDown事件里处理。protected override void OnKeyDown(KeyEventArgs e) { int dir e.KeyCode switch { Keys.Up 0, Keys.Down 1, Keys.Left 2, Keys.Right 3, _ -1 }; if (dir 0 _map.TryMove(dir)) { Redraw(); if (_map.IsWin()) MessageBox.Show(过关); } base.OnKeyDown(e); }逻辑说明OnKeyDown是窗体的虚方法重写它比挂事件委托更直观也避免在InitializeComponent里额外登记一遍 handler。TryMove的返回值在这里还有一个额外价值只有这一步确实发生了移动时才重绘和判胜负按了方向键但撞墙时界面纹丝不动玩家从视觉上就知道「这条路走不通」。参数说明WinForms 的KeyDown在按键按住时会自动重复触发这是系统行为回合制游戏不需要屏蔽它但如果要做「按住方向键连续移动」就得在Shift状态的配合下自己加节流。Redraw内部用的是 Label 控件的属性修改本身会触发重绘不需要手动调用Invalidate只有改成自定义Paint绘制时才需要Invalidate()通知窗体刷新。3.4 三种界面方案怎么选控制台、WinForms 与 Unity 的取舍方案上手难度卡点适合场景控制台最低字符渲染丑中文输入法可能截获方向键验证游戏逻辑、教学演示WinForms中等控件数量多大数据量地图会卡课程设计、小型桌面游戏Unity较高引入了 Sprite、Input System 等新概念想继续做游戏方向的人参数说明如果你只是要「C# 推箱子小游戏源代码」满足课程要求WinForms 是性价比最高的终点如果目标是学 Unity 开发建议把这里的GameMap直接搬过去渲染层替换成 Sprite 渲染器逻辑层完全不用动。这也印证了拆类的价值——换界面不换脑子。4. 源码怎么组织拆类、关卡文件与扩展点决定这套代码能走多远4.1 类怎么拆地图数据、游戏引擎、UI 渲染三者各管一段很多初学者拿到别人的源码第一反应是把所有代码塞进Form1.cs结果一个窗体文件写到六百行最后连自己都找不到箱子判定在哪。拆类的原则是地图数据归地图、游戏规则归规则、界面归界面。下面是一个从实战里沉淀出来的三分结构按这个结构组织源码后面加悔棋、换皮肤都只需要动一个文件。类 / 文件职责关键成员GameMap地图数据、解析、坐标查询Grid、PlayerX/Y、IsWin()GameEngine移动规则、步数统计、操作历史TryMove()、StepCountMainForm渲染与输入Redraw()、OnKeyDown()GameEngine可以看作GameMap的上层封装GameMap知道地图长什么样但不知道「人能不能推箱子」GameEngine只调用GameMap暴露的Grid和玩家坐标决定是否修改数据。这样GameMap可以单独做单元测试比如直接构造一张地图断言TryMove的返回值。4.2 关卡文件格式把地图存成文本换关不用改代码地图硬编码在源码里的缺点是换个关卡就要改代码重新编译。常见做法是把关卡放进外部文本文件用纯 ASCII 字符保存每行代表地图的一行。标准格式约定#墙$箱子.目标点玩家空格是空地其他字符一律视为非法。public static GameMap LoadFromFile(string path) { string[] lines File.ReadAllLines(path, Encoding.UTF8); if (lines.Length 0) throw new InvalidDataException(关卡文件为空); var map GameMap.Parse(lines); if (map.TargetCount 0) throw new InvalidDataException(关卡中没有目标点); if (map.PlayerX 0 map.PlayerY 0) throw new InvalidDataException(关卡中没有玩家); return map; }逻辑说明File.ReadAllLines会自动按平台换行符拆分行省去手工处理\r\n的麻烦但如果你在 Linux 上写好关卡、拿到 Windows 上用要确认文件没有混入异常换行。校验放在Parse之后是因为Parse只负责构造数组不负责业务合法性目标点为零或者玩家缺失的关卡运行后会变成死图提前抛异常比运行到一半出错直观得多。参数说明编码用Encoding.UTF8是防止文件里有 BOM 或中文注释导致首字符解析异常。关卡文件的列数不必每行相等Parse里已经做了补齐处理但建议还是保持矩形因为很多关卡编辑器生成的文本都是严格矩形的非矩形文件在别人复用时容易阅读错位。4.3 扩展点步数统计、关卡切换、操作历史留好接口一个能交差的推箱子源码通常还要有两个数字步数和关卡号。步数统计最容易犯的错是「移动失败也算一步」。正确做法是在GameEngine里做增量计数只有TryMove返回true时才StepCount。这个细节直接决定成绩统计准不准。关卡切换有两种做法把所有关卡写进一个文本文件用空行分隔或者放在Levels目录下按Level01.txt、Level02.txt命名。我一般推荐后者因为单个游戏文件不会膨胀到几百行而且可以单独替换某一关而不影响其他关卡。切换时至少要做一次越界保护——当前关卡号超过文件数量时提示「没有更多关卡了」。至于悔棋第六章详细讲这里只需要知道GameEngine里留一个StackSnapshot类型的字段未来的操作历史全挂在GameEngineMainForm只负责把撤销按钮接到这个堆栈上。5. C# 推箱子小游戏开发避坑指南5 个最容易翻车的点与排查方法5.1 方向键按了没反应窗体焦点被控件抢走事件根本没到你手里现象程序跑起来用鼠标点了地图上的某个格子或空白区域后再按方向键角色纹丝不动。原因是窗体上添加了大量Label控件其中某些.TabStop属性默认为true的控件会抢占焦点KeyDown事件被派发到那个控件而不是窗体。解决方法是两行设置KeyPreview true让窗体优先于子控件处理键盘事件同时把地图瓦片的TabStop全部设为false让 Label 不参与 Tab 循环。如果问题依旧在OnKeyDown开头加一个Console.WriteLine(e.KeyCode)先确认事件到底有没有进来再往下排查。5.2 窗口疯狂闪烁重绘整个 ClientArea但没有开启双缓冲现象每次按键后整个窗体像在放幻灯片尤其地图超过 10×10 后闪烁肉眼可见。原因是刷新地图时把所有 Label 的Text和BackColor都改了一遍每个控件分别触发布局和重绘高频触发时 GDI 忙不过来。解决方法是给窗体开双缓冲DoubleBuffered true把绘制先写进内存位图再一次刷到屏幕如果用了自定义控件自绘地图同样在自定义控件的构造函数里设DoubleBuffered true。这是 WinForms 图形开发里最无脑也最有效的性能开关新手经常把它当成玄学忽略掉实际上它是微软文档明写的原生属性。5.3 跨线程访问控件报错别用 Timer 轮询键盘状态现象用Timer每隔 100ms 读一次按键状态来驱动移动运行几分钟后弹出InvalidOperationException说某控件只能从创建它的线程访问。原因是Timer事件的回调跑在 UI 线程之外直接修改 Label 属性触发了线程安全检查。推箱子是回合制游戏根本不需要定时器——按键本身就是事件KeyDown天然在 UI 线程上执行。如果你的确需要在特定时间点自动执行动作比如做步数超时惩罚就该用Control.BeginInvoke把 UI 更新封送回主线线程private void TimerTick(object sender, EventArgs e) { BeginInvoke(new Action(() { // 在这里更新 UI 控件 })); }5.4 地图数组越界换行符、全角空格、矩形不齐三个隐形凶手现象直接复制网上关卡文本贴到代码里运行时一进入某一行就抛IndexOutOfRangeException。原因有三个复制文本时每行末尾带了 Windows 的\rParse按字符处理时多出一个不可见字符字符串里混入了全角空格或制表符\t导致边长计算错误行长短不一程序取了最长行的长度作为列数但短行访问时越界。解决方法是统一让关卡文件走LoadFromFile再在Parse里做一次防御char c x lines[y].Length ? lines[y][x] : ;。这样即使某行短了一截也只会在视觉上多出一个空格不会产生异常。组装关卡时可以用Debug.Assert(map.Cols expectedCols)把边界约束写死在代码里。5.5 箱子穿墙瞬移状态更新顺序写反先动了人再动的箱子现象面对墙角的箱子按下朝向墙角的方向键箱子直接穿透墙跑出去了。原因是移动逻辑里先把玩家坐标更新了再判断箱子能不能推结果TryMove用的是「新玩家位置」去取箱子坐标全部偏移一格判定自然错位。解决方法是严格按上一章的顺序先取目标格再判断目标格是箱子时取箱子前方所有越界和撞墙检查通过后才统一改写玩家和箱子的位置。这里有个把每个地图存成char[,]快照放进栈里的前提——你的GameMap必须支持深拷贝否则栈里存的是引用所有快照会在下一次移动时一起改变。6. 更进一步给源码加上悔棋和关卡校验增量改造不超过 20 行6.1 悔棋三步走快照、入栈、回退推箱子最常见的刚需是悔棋。地图不大完整复制整个char[,]的开销可以忽略所以最可靠的做法是「整图快照」而不是记录单步操作再反向推演。每一次移动前把当前Grid、玩家坐标、步数压进栈撤销时弹出快照恢复原状public class GameEngine { private readonly Stack(char[,] grid, int px, int py, int steps) _history new(); public bool TryMoveWithUndo(int dir) { _history.Push((CloneGrid(), PlayerX, PlayerY, StepCount)); return TryMove(dir); } public void Undo() { if (_history.Count 0) return; var (grid, px, py, steps) _history.Pop(); Grid grid; PlayerX px; PlayerY py; StepCount steps; } private char[,] CloneGrid() (char[,])Grid.Clone(); }参数说明栈里同时存了步数撤销后步数回退统计数字不会虚高。Clone()对二维数组做的是浅拷贝但这里Grid的元素是char值类型浅拷贝恰好等效于深拷贝。这个方案的一个隐藏收益是撤销不受「箱子经过哪些路径」影响哪怕关卡状态再乱都能完整回退到上一步。6.2 关卡编辑器写一个校验函数手工编关也能跑拿到源码后想自己加关卡最省事的就是做文本编辑器。在Notepad或 VS Code 里按 ASCII 画地图保存成 txt再用LoadFromFile加载。校验函数至少查三件事玩家有且只有一个、至少存在一个目标点、地图四周闭合。四周闭合可以遍历第一行、最后一行、第一列、最后一列确认全是#public static void Validate(GameMap map) { int pCount 0, targetCount 0; for (int y 0; y map.Rows; y) for (int x 0; x map.Cols; x) { if (map.Grid[y, x] ) pCount; if (map.Grid[y, x] . || map.Grid[y, x] *) targetCount; if (y 0 || y map.Rows - 1 || x 0 || x map.Cols - 1) if (map.Grid[y, x] ! #) throw new InvalidDataException(地图边界未闭合); } if (pCount ! 1) throw new InvalidDataException(玩家数量必须为1); if (targetCount 0) throw new InvalidDataException(至少需要一个目标点); }我第一次写推箱子源码时把地图硬编码在Form1.cs里加了三个关卡后代码就乱成一团。后来把地图挪到外部文件、把逻辑拆成GameMap和GameEngine再回头看真正难的部分根本不是界面而是移动判定里的状态枚举——*和这俩状态一漏地图显示和胜负判定同时出错。这也是我给所有拿这份源码做二次开发的人唯一的建议遇到诡异 bug 先打印当前地图到控制台把数组真实值看清楚再改代码。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?