简介C# 推箱子小游戏全套源代码面向C#入门学习者与游戏开发爱好者覆盖窗体程序设计、键盘事件响应、关卡渲染与游戏状态管理等典型知识点。编译后可直接运行支持方向键推箱、CtrlZ撤销、CtrlY重做、选关与自定义关卡通关后可将最佳步骤保存为level.way文件用于回放演示或向朋友分享也支持存盘与读盘。资源共76个文件压缩包约135KB主要包含10个.cs源码、11个bmp图像素材、15个way关卡记录、13个dat数据文件、4个txt与resources资源文件以及可直接启动的exe程序目录结构清晰便于对照学习与二次开发。目前已有960人学习下载适合希望深入理解推箱子算法、存档读档机制和界面布局设计的读者。1. 用 C# 重写推箱子一份源码包带来的三个判断说实话推箱子这种经典小游戏在 GitHub 上一搜一大把但真正能拿来就跑、跑完还能改的 C# 版本并不多。多数要么是控制台字符画要么是 WinForms 里画几个方块逻辑和界面耦合得乱七八糟想换个关卡格式都得动半天手术。这份 C# 推箱子小游戏源码好就好在它把「玩法逻辑」和「界面渲染」拆开了关卡数据是纯文本二维数组移动判定是独立类界面只是负责把地图画出来并转发键盘事件。你拿到的不是一份只能演示的成品而是一套可以替换关卡、扩展玩法的骨架。适合谁用两类人。一类是刚学完 C# 语法、想找个完整项目练手的学生需要的是能读懂每行代码的体量另一类是做课程设计或毕业设计的从业者想在这份源码上二次开发加关卡、加计步、加音效而不是从零画窗体。全篇的逻辑放在 .NET Framework 4.x 或 .NET Core 3.1 以上都能编译不用装额外依赖。下面我从项目结构讲起把核心算法、边界条件和踩过的坑都过一遍。2. 源码包结构先分清「逻辑层」和「表现层」再动手改拿到源码包先别急着双击运行第一件事是看目录结构。这份资源里的代码量不大但组织方式值得琢磨它分清了「游戏该怎么做」和「界面该怎么画」两个问题。2.1 核心文件清单与职责划分假设你解压后看到的核心文件是这样一套组织Sokoban/ ├── Sokoban.sln ├── Sokoban/ │ ├── Program.cs // 程序入口启动窗体 │ ├── GameMap.cs // 地图数据解析、关卡存储、关卡切换 │ ├── GameLogic.cs // 移动判定、推箱判定、胜利判定 │ ├── MapRenderer.cs // 地图绘制方块、箱子、目标点、玩家 │ ├── MainForm.cs // 窗体交互键盘事件处理 │ ├── Maps/ │ │ ├── level01.txt │ │ ├── level02.txt │ │ └── level03.txt │ └── Resources/ │ └── tiles.png // 可选如果用了图片贴图我一般拿到任何源码包都会先画这样的职责图GameMap管数据GameLogic管规则MapRenderer管显示MainForm管输入。这份源码的设计符合这个思路所以改起来很舒服。如果你看到MainForm.cs里同时写了地图解析和移动判定那说明作者偷懒了扩展的时候你会很痛苦但这份包没有这个问题。2.2 关卡文件格式为什么用文本数组而不是 JSON打开关卡文件你看到的内容类似这样####### # # # . $ # # # # *$ # #######字符含义是固定的#表示墙空格表示空地表示玩家$表示箱子.表示目标点*表示已经在目标点上的箱子表示玩家站在目标点上。用文本数组做关卡格式好处是肉眼可读、容易改上一行和下一行直接对比就知道地图布局。GameMap.cs里的解析逻辑一般长这样public char[,] ParseLevel(string[] lines) { int rows lines.Length; int cols lines.Max(l l.Length); char[,] map new char[rows, cols]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (c lines[r].Length) map[r, c] lines[r][c]; else map[r, c] #; // 短行补墙防止越界 } } return map; }这段代码做了两件重要的事把字符串数组转成二维字符数组同时把长度不足的行补成墙。最后一步特别关键很多自制关卡在编辑器里看着没问题一加载就报数组越界就是因为某一行少了几个字符。补#是保守策略宁可多堵一堵墙也不能让玩家走出边界。2.3 地图数据与渲染数据的解耦这里要强调一个观点地图数据不能直接作为绘制数据用。因为在游戏过程中箱子的位置会变玩家的位置会变但原始关卡数据不能动否则「重新开始本关」就没法实现了。所以GameLogic在初始化时必须复制一份地图做「当前状态」而GameMap保留原始关卡作为「初始状态」。按钮点击重新开始时再从初始状态拷贝一次。这份源码里如果你看到类似InitializeLevel()的方法内部做的事情就是数组拷贝public void ResetLevel() { currentMap (char[,])originMap.Clone(); playerPos FindPlayer(currentMap); stepCount 0; }数组的Clone()是浅拷贝但因为是char型二维数组值类型直接复制效果等同于深拷贝可以放心用。如果你把地图换成了自定义类比如箱子是个对象浅拷贝就会出大问题同一个箱子被两个状态引用移动时会互相干扰。这个坑后面详细说。3. 核心算法拆解移动判定、推箱规则与胜利条件的实现这是整个源码的精华所在也是最容易出现隐蔽 bug 的地方。推箱子表面上是移动实际上是「玩家移动 → 是否撞墙 → 是否推箱 → 箱子是否撞墙 → 是否把箱子推上目标点」的层层条件判断。3.1 玩家移动与推箱子判定函数核心移动逻辑的核心函数大致是public bool MovePlayer(int dx, int dy) { int newX playerX dx; int newY playerY dy; // 第一步目标位置是否是墙 if (currentMap[newY, newX] #) return false; // 第二步目标位置是否有箱子 if (currentMap[newY, newX] $ || currentMap[newY, newX] *) { int boxX newX dx; int boxY newY dy; // 箱子再往前一步如果是墙或者又是箱子推不动 if (currentMap[boxY, boxX] # || currentMap[boxY, boxX] $ || currentMap[boxY, boxX] *) return false; // 执行推动移动箱子 currentMap[boxY, boxX] (currentMap[boxY, boxX] .) ? * : $; currentMap[newY, newX] (currentMap[newY, newX] *) ? : ; } else { // 普通移动玩家走到空地原位置恢复为初始状态 currentMap[newY, newX] (currentMap[newY, newX] .) ? : ; currentMap[playerY, playerX] (currentMap[playerY, playerX] ) ? . : ; } playerX newX; playerY newY; return true; }这段代码就是一个推箱子算法的完整逻辑我拆几行说清楚。先看参数dx和dy分别代表横向和纵向的偏移量取值只有-1、0、1三种。按下方向键时dy 1、dx 0按左方向键时dx -1、dy 0。这里的键位映射在MainForm里完成逻辑层不关心你按的是哪个键只关心偏移方向。再看推动箱子的那几行赋值语句。这里用到了字符的「组合状态」设计目标点上放箱子显示*玩家站到目标点显示。所以你在移动的时候不能简单地把旧位置清成空格——如果旧位置是说明玩家原本站在目标点上清掉后应该恢复成.。反之如果旧位置是普通地面清成空格 。这就是为什么赋值语句里要判断currentMap[playerY, playerX] 后决定写回.还是 。3.2 胜利判定的两种实现方式胜利判定的思路有两种源码里通常用其中一种但两种你都得掌握。第一种是「遍历目标点」也就是检查地图上所有.和位置。注意表示玩家站在目标点上这种状态下虽然目标点被占但还没有箱子在上面所以不能算完成*才代表箱子坐在目标点上。因此胜利条件是地图上不存在任何.或者。写出来很简洁public bool IsWin() { for (int r 0; r rows; r) { for (int c 0; c cols; c) { char ch currentMap[r, c]; if (ch . || ch ) // 还有未放箱子的目标点 return false; } } return true; }第二种是「统计装箱数量」也就是在解析关卡时先数出目标点总数然后在移动逻辑中维护一个boxOnTargetCount计数器箱子推上目标点就加一推离目标点就减一等于目标点总数时胜利。这种做法的优点是时间复杂度为 O(1)不用每次移动都全图扫描。对于单屏小游戏来说遍历全图也就几百个格子性能差异根本感知不到但后者的编码难度更高因为你必须在移动逻辑中同时跟踪箱子的前一个位置的状态。源码里如果用了前者恭喜你逻辑简单不容易出错如果用了后者注意检查*变成$时箱子被从目标点推走有没有递减计数器。3.3 方向键处理与连续的移动状态机MainForm里的按键处理不只是一个简单的switch。真实场景中玩家可能会快速连按多个方向键如果每次按键都立即响应那么在上一次移动还没处理完时状态就可能错乱。虽然 Windows 消息循环里按键是串行到达的但直接写在KeyDown事件里的处理逻辑会有一个问题按住方向键不放会导致系统自动重复触发 KeyDown如果不做节流玩家角色会像瞬移一样滑出去还会在推箱子时把箱子连推好几格这不是我们想要的行为。常见的做法是加一个「移动冷却」开关private bool isMoving false; protected override void OnKeyDown(KeyEventArgs e) { if (isMoving) return; isMoving true; int dx 0, dy 0; switch (e.KeyCode) { case Keys.Up: dy -1; break; case Keys.Down: dy 1; break; case Keys.Left: dx -1; break; case Keys.Right: dx 1; break; case Keys.R: gameLogic.ResetLevel(); return; default: isMoving false; return; } bool moved gameLogic.MovePlayer(dx, dy); if (moved) RenderMap(); isMoving false; }这里的关键是isMoving标志一次按键处理完成前后续的 KeyDown 事件直接忽略。如果你希望支持「按住方向键连续走」的操作手感可以在KeyDown里启动一个定时器每隔 150ms 触发一次移动这样比依赖系统按键重复可靠得多因为系统重复的延迟和频率是受 Windows 控制面板设置影响的不同机器手感完全不同。4. 渲染与交互细节从字符画到滑动动画逻辑层完成之后真正让玩家觉得「这是个游戏」的是画面表现。推箱子最朴素的做法是用Graphics.FillRectangle画色块进阶一点用图片贴图再进阶一点做成滑动动画。这份源码包的渲染方案决定了你二次开发的工作量。4.1 方块尺寸与坐标换算公式不论用哪种方式画你都要维护一个「逻辑坐标 → 像素坐标」的换算公式。假设你的窗体客户区是 640×480地图是 8 行 10 列那么每个格子的边长是这么算的int cellSize Math.Min(panelWidth / cols, panelHeight / rows);用Math.Min的原因是要保证地图完整显示如果高度受限就让格子小一点如果宽度受限也同理。然后把地图居中绘制避免长宽比不一致时地图偏到左上角int offsetX (panelWidth - cellSize * cols) / 2; int offsetY (panelHeight - cellSize * rows) / 2; for (int r 0; r rows; r) { for (int c 0; c cols; c) { Rectangle rect new Rectangle( offsetX c * cellSize, offsetY r * cellSize, cellSize, cellSize); DrawCell(graphics, currentMap[r, c], rect); } }DrawCell内部根据字符类型选择颜色墙用深灰箱子用橙色目标点用浅蓝玩家用亮绿。地图边界外留出的空白区域用窗体的BackColor填充即可。4.2 图片贴图模式下的资源管理与尺寸适配如果你不想用色块觉得太简陋可以改用图片。这时候牵扯到图片的缩放绘制直接用Graphics.DrawImage会导致图片模糊或者性能变差。我见过不少新手把每帧都new Bitmap内存直接爆炸。正确做法是在窗体加载时一次性载入图片然后每帧只做DrawImage操作private Dictionarychar, Bitmap tileCache; private void LoadTiles() { tileCache new Dictionarychar, Bitmap(); tileCache[#] new Bitmap(wall.png); tileCache[$] new Bitmap(box.png); tileCache[*] new Bitmap(box_on_target.png); tileCache[.] new Bitmap(target.png); tileCache[] new Bitmap(player.png); }这里有一个容易踩的坑图片尺寸不统一。如果 wall.png 是 64×64player.png 是 32×32画到同一个格子里会有一个大一个小。建议在加载时统一缩放到cellSize尺寸而且cellSize是在窗体尺寸确定之后才计算的所以LoadTiles只能放在OnResize或第一次渲染时触发不能放在构造函数里。统一缩放可以用这段代码private Bitmap ScaleTile(Bitmap original, int size) { Bitmap scaled new Bitmap(size, size); using (Graphics g Graphics.FromImage(scaled)) { g.InterpolationMode InterpolationMode.NearestNeighbor; g.DrawImage(original, 0, 0, size, size); } return scaled; }注意InterpolationMode.NearestNeighbor像素风的素材如果用默认的HighQualityBicubic会糊成一片。这是游戏素材缩放的一个经典误区很多人在屏幕上看到模糊的方块以为图片分辨率不够其实是插值算法选错了。4.3 步数与计时器的界面刷新策略推箱子玩家很在意步数——毕竟这是评价操作水平的核心指标。步数应该在MovePlayer返回true时增加但这里有个语义问题推着箱子走一步算一步那推箱子这一步和空走一步是不是同权常见做法是统一计算不区分是否推箱。界面上刷步数时不要让整个窗体Invalidate()因为重绘地图的代价远高于刷新一个数字。正确策略是只刷新对应的Labelprivate void UpdateStepLabel() { stepLabel.Text $步数: {gameLogic.StepCount}; stepLabel.Invalidate(); // 只重绘标签不是整个窗体 }如果你的界面里还有计时器注意计时器 Tick 事件里也只更新时间文本不要碰地图绘制。把「地图重绘」和「文本刷新」彻底分开是避免界面卡顿的关键。很多初学者把渲染代码全塞进 Tick 里结果帧率一高 CPU 占用就飙升。5. 常见问题与避坑四类让新手最头疼的报错和逻辑异常前面说的都是怎么把功能做出来下面说点实在的——我拆过的源码包里最容易翻车的几个地方。5.1 数组越界地图边缘的墙到底够不够厚现象玩家角色走到地图最右边再按右方向键程序直接抛IndexOutOfRangeException。原因地图最外层没有全部用#围起来。很多手工编辑的关卡长这样——中间是空地最右列直接是边界没有墙。玩家一直往右走newX就超过了cols的上限。解决在处理移动之前加边界检查或者在地图解析时确保外层有一圈墙。最省事的做法是在MovePlayer开头加条件if (newX 0 || newX cols || newY 0 || newY rows) return false;这属于防御式编程即使关卡文件有瑕疵游戏也不会崩溃。但更根本的解法是在解析关卡时自动补齐外墙。5.2 重开本关后箱子消失或位置错乱现象点击「重新开始」后某些箱子不见了玩家出生点也变了。原因ResetLevel里用了浅拷贝而地图是自定义类数组。如果你把箱子改成了Box类Clone()复制的是引用两个状态指向同一个对象。原始地图里的箱子被移动逻辑改了位置初始状态也跟着变了。解决如果地图元素是结构体如char或structClone()没问题如果是class必须手动逐行逐列重建数组。我建议用结构体或者简单的char类型存储地图这样天然避免深拷贝问题。5.3 键盘按住不动角色连滑现象按住右方向键角色一直向右走到撞墙才停而不是走一格停一下。原因Windows 的键盘自动重复触发KeyDown事件。系统一旦检测到按键按住超过约 500ms就开始以固定频率重复发送 KeyDown。解决用前面提到的isMoving标志位或者改用KeyUp事件配合定时器来控制移动频率。5.4 箱子推上目标点后无法再次推走现象箱子在目标点上显示为*此时玩家想把它从目标点上推开但怎么推都推不动。原因判断「位置是否有箱子」时只检查了$没检查*。*也是箱子只是它恰好坐在目标点上而已。移动逻辑里忘了把*当成箱子处理。解决所有判断箱子的地方都要同时考虑$和*不能只认一个字符。类似地判断目标点时也要同时考虑.和。还有一个容易忽略的游荡在外的箱子被推回目标点时位置字符要从$变成*如果推到普通地面则保持$。很多新手在这里漏掉了三态转换空地 → 箱子、目标点 → 箱子在目标点、玩家 → 玩家在目标点。6. 进阶玩法二连从「能玩」到「好玩」的最小改动如果你照着源码把基础功能跑通了恭喜你已经有了一个完整的推箱子游戏。但真正的价值在于二次开发。我个人觉得从「能玩」到「好玩」只需要做两个改动工作量不算大但界面观感和玩法深度会明显提升。第一个必改项是关卡编辑器。别急着直接写代码先想清楚编辑器本质上就是让玩家在文本数组上摆放各种元素然后导出成关卡文件。最简单的实现方式是复用MapRenderer的绘制逻辑在鼠标点击的格子处写入对应的字符。加一个「当前选中的元素」变量比如按W选墙、按B选箱子、按P选玩家然后鼠标左键点击就在该格写入选中元素。这种方式比拿记事本手工敲字符效率高得多。第二个改选项是动画过渡。目前移动是一个瞬移效果箱子「啪」地从一格跳到另一格。想做得更顺滑可以在MovePlayer成功之后启动一个 100ms 的定时器在定时器里逐步插值绘制玩家位置等动画结束后再更新逻辑层坐标。这里有个原则必须记住动画期间不允许玩家输入否则逻辑坐标和显示坐标会不同步走两步之后角色就漂移了。我个人的习惯是动画统一用Timer控制每次 tick 完成 10% 的位移10 个 tick 走完一格。Tick 频率是 10ms所以一格移动耗时 100ms体感刚好。另外如果你做的推箱子还要面向课程答辩或展示建议把关卡通过弹窗做成自定义绘制而不是默认的MessageBox毕竟MessageBox的样式太粗糙了。自定义一个半透明遮罩层加上「本关完成步数 XX是否进入下一关」的按钮观感会专业很多。技术上就是在MainForm上叠一个Panel并设置BackColor Color.FromArgb(128, 0, 0, 0)做出蒙版效果。说回这份源码包我判断它价值的地方不在代码量而在GameLogic.cs那个移动函数写得规范、边界条件齐全。从那以后我每次自己做小游戏或者给学生改代码都强制把逻辑层和表现层分开哪怕只是一个几百字的小游戏也强行拆文件。这样做短期内看起来多写了几个类但后面换界面方案时就知道有多省事了。如果你也打算把这份 C# 推箱子拿去改造成课程设计或者练手作品建议你从GameLogic.cs开始读读透那个移动函数后面所有功能都会顺理成章。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?