简介:一份基于C#的推箱子小游戏完整源代码,适合初学C#窗体编程、想了解经典小游戏如何实现的读者,覆盖了绘制地图、人物交互、键盘响应、关卡管理等功能。压缩包共76个文件,其中10个cs为游戏核心逻辑、bmp提供人物/箱子/墙体等贴图、dat与way存储关卡与通关进度,附带sln工程与资源文件,整体仅135KB,可直接用Visual Studio加载运行。功能上实现了箱子推拉、撤销重做、选关、当前状态存盘与读盘,并将每关最佳步数保存为way文件,可回放整个通关过程,也支持自行设计关卡并在朋友间分享,对理解栈式撤销、关卡文件序列化等知识点很有帮助。项目工程结构清晰,便于按窗体、绘图、逻辑模块研读,适合自学、课程设计或作为C#游戏开发的练习项目。当前已有960人学习。
1. 推箱子这份 C# 小游戏源码:从数组逻辑到界面闭环的完整样本
推箱子这游戏,规则一句话就能说清,可真的用 C# 把它从零写到能跑、能玩、能换关卡,链路比多数人预想的长:地图建模、角色移动、碰撞判定、界面响应、状态回滚,每一环都有细节在等着翻车。网上 C++ 的小游戏 100 例流传很广,C# 版本反而散,这套推箱子源代码的价值就在于把「地图文件 → 内存数组 → 键盘输入 → 画面重绘」整条链路串齐了,拿来能编译、能直接玩,也能当课程设计或 C# 入门练手项目。适合三类人:正在学 C# 语法想找个完整项目的在校生,需要交课程设计又不想从空白窗体开始搭的开发者,以及想搞懂小游戏底层逻辑的编程爱好者。我拆这套代码时最深的感受是:推箱子根本不是「逻辑难」,而是「状态多」,一次移动要同时维护玩家坐标、箱子坐标、目标点状态三份信息,这份源码怎么处理这些状态,才是真正值得看的地方。
2. 地图建模与文件解析:把二维字符变成可运算的 int 数组
2.1 关卡文本的字符映射:一套社区通用的推箱子标记法
推箱子关卡在社区里有一套流传很广的文本格式,一行的每个字符代表地图上的一个物体。这套标记法不是某家公司定的标准,但几乎所有推箱子工具和关卡库都认它,来源关卡素材时基本不用改格式。字符含义如下:
| 字符 | 含义 | 对应 int 值 |
|---|---|---|
# | 墙壁 | 1 |
| ; | 空格或- | 空地 |
. | 目标点 | 2 |
$ | 箱子(普通状态) | 3 |
* | 箱子已在目标点上 | 5 |
@ | 玩家(普通状态) | 玩家坐标单独存 |
+ | 玩家站在目标点上 | 玩家坐标单独存 |
| (空格) | 空地,也用于补齐行宽 | 0 |
用 int 而不用 char 直接操作,是因为移动判定里频繁要比较「这块地是不是墙」「箱子能不能推进去」,int 的 switch 或 if 比字符比较更直观,而且将来扩展要素(比如加冰面、加传送门)只需要新分配一个数字,不用动解析逻辑。这份资源里的关卡文件就是按这个思路组织的,读取后先按行拆开,再逐字符映射成二维数组。
2.2 解析代码:按最长行补齐宽度
关卡文本最常见的坑是行尾空格被编辑器删掉,导致地图右侧的墙对不齐、数组越界。解析时不能直接取第一行的长度当列数,要按所有行的最大长度补空格。下面这段是资源里核心解析逻辑的简化版,逐字符映射时顺手记录玩家出生点:
private const int SPACE = 0, WALL = 1, TARGET = 2, BOX = 3, BOX_ON_TARGET = 5; private static int[,] ParseLevel(string[] lines, out int playerRow, out int playerCol) { int rows = lines.Length; int cols = 0; foreach (string line in lines) if (line.Length > cols) cols = line.Length; // 取最长行,防止行尾空格被 Trim 后错位 int[,] map = new int[rows, cols]; playerRow = -1; playerCol = -1; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { char ch = c < lines[r].Length ? lines[r][c] : ' '; switch (ch) { case '#': map[r, c] = WALL; break; case '.': map[r, c] = TARGET; break; case '$': map[r, c] = BOX; break; case '*': map[r, c] = BOX_ON_TARGET; break; case '@': playerRow = r; playerCol = c; map[r, c] = SPACE; break; case '+': playerRow = r; playerCol = c; map[r, c] = TARGET; break; default: map[r, c] = SPACE; break; } } } return map; }逻辑说明:玩家不在 map 数组里占一个格子,而是解析时单独记录 playerRow 和 playerCol,map 里只留地面类型。这样做的好处是移动时只需要改两个坐标变量,不用反复改地图数组,避免「玩家走过后原地板类型被覆盖」这种经典 bug。@落在空地就记坐标、原地设为 SPACE,+落在目标点就记坐标、原地保留 TARGET,这样后续判断胜利时*(箱子在目标上)和+(玩家在目标上)的处理完全一致。
参数说明:cols必须在第一次循环里先算出来,不能拿lines[0].Length凑合;内层循环里c < lines[r].Length这个判断用来兜底短行,否则短行访问到不存在的字符会直接抛越界异常。这段代码跑完后,map[r, c]就是一个干净的、只含 0/1/2/3/5 的地图矩阵,渲染和移动判定都只认这张表。
2.3 建模选择的边界:什么时候需要叠加态
这套建模里有一个刻意为之的设计:箱子叠在目标点上时存 5,而不是维护「箱子列表 + 目标点列表」两套数据。五子棋那种黑白子分离的模型适合双方对弈,推箱子则相反——目标点是地图固有属性,箱子是动态物体,两者合并成单个枚举值能让「胜利判定」退化成一次全图扫描:只要地图里不存在值为 3 的格子,说明所有箱子都被推到了目标点上。
代价是移动逻辑里多一层恢复逻辑:箱子从目标点上被推走时,原位置必须还原成 TARGET 而不是 SPACE。这个还原逻辑是新手最容易漏的,后面避坑章会专门展开。如果你打算在源码基础上加新玩法(比如箱子可以变色、目标点分颜色),再把「箱子对象」和「目标点」拆成两个数组也不迟,但初始版本用合并枚举是性价比最高的写法。
3. 移动算法与碰撞判定:一次按键背后的三步逻辑
3.1 方向向量与先算后走原则
键盘上下左右按键映射到二维坐标上的位移,常见做法是用一个方向数组:
private static readonly int[] DR = { -1, 1, 0, 0 }; // 上、下、左、右 private static readonly int[] DC = { 0, 0, -1, 1 }; private static bool TryMove(int[,] map, ref int playerRow, ref int playerCol, int dirIndex, out int pushCount) { pushCount = 0; int nextR = playerRow + DR[dirIndex]; // 玩家下一步位置 int nextC = playerCol + DC[dirIndex]; if (nextR < 0 || nextR >= map.GetLength(0) || nextC < 0 || nextC >= map.GetLength(1)) return false; // 出界直接拒绝 int cell = map[nextR, nextC]; if (cell == WALL) return false; // 撞墙拒绝 if (cell == BOX || cell == BOX_ON_TARGET) { int beyondR = nextR + DR[dirIndex]; // 箱子再往前一格 int beyondC = nextC + DC[dirIndex]; if (beyondR < 0 || beyondR >= map.GetLength(0) || beyondC < 0 || beyondC >= map.GetLength(1)) return false; int beyondCell = map[beyondR, beyondC]; if (beyondCell == WALL || beyondCell == BOX || beyondCell == BOX_ON_TARGET) return false; // 箱子推不动:前方是墙或另一个箱子 // 箱子移动 map[nextR, nextC] = (cell == BOX_ON_TARGET) ? TARGET : SPACE; map[beyondR, beyondC] = (beyondCell == TARGET) ? BOX_ON_TARGET : BOX; pushCount = 1; } // 玩家移动 playerRow = nextR; playerCol = nextC; return true; }逻辑说明:「先算后走」是这段代码的核心习惯——先算出 next 和 beyond 两个位置,全部判定通过后才真正修改 map 和玩家坐标。反过来如果先移动再判断,撞墙后还得回滚,逻辑容易乱。箱子推走时要区分两种情况:箱子原来在普通地面还是目标点上,前者原位置恢复 SPACE,后者恢复 TARGET;箱子推到的位置如果是目标点,箱子要标记为 BOX_ON_TARGET。
参数说明:dirIndex用 0~3 的整数而不是直接传坐标增量,目的是让键盘事件和后续的 AI 自动求解共用同一套移动接口,接机器人玩家时只要把方向算出来调TryMove就行。pushCount是输出参数,主界面拿它累加「推数」,用于 UI 上区分「步数」和「推数」两个统计维度。
3.2 胜利判定:一次全图扫描
每次移动完成后检查是否过关,不需要维护任何计数器,直接扫一遍地图:
private static bool CheckWin(int[,] map) { for (int r = 0; r < map.GetLength(0); r++) for (int c = 0; c < map.GetLength(1); c++) if (map[r, c] == BOX) return false; // 还有普通箱子,说明有箱子没到目标点 return true; }逻辑说明:只要地图里还存在值为 3 的格子,就说明至少有一个箱子没有被推到目标点上。玩家站在目标点上不影响判断,因为玩家的位置没有写进 map。这个函数在每次TryMove返回 true 之后调用,速度上是 O(rows × cols),对推箱子这种小地图完全可以接受。
3.3 死局预判:把不可解局面拦在发生前
推箱子最劝退的体验是箱子被推进墙角,整局直接报废。简单实现可以不做死局判断,靠玩家悔棋兜底;但实战里至少可以把最典型的「箱子进角落」拦下来:
private static bool IsCornerDead(int[,] map, int boxR, int boxC) { bool wallUp = map[boxR - 1, boxC] == WALL; bool wallDown = map[boxR + 1, boxC] == WALL; bool wallLeft = map[boxR, boxC - 1] == WALL; bool wallRight = map[boxR, boxC + 1] == WALL; // 箱子被夹在两面墙之间,且该位置不是目标点 if ((wallUp && wallLeft) || (wallUp && wallRight) || (wallDown && wallLeft) || (wallDown && wallRight)) return map[boxR, boxC] != TARGET && map[boxR, boxC] != BOX_ON_TARGET; return false; }逻辑说明:当箱子相邻四个方向里有相邻的两面墙,且箱子本身不落在目标点上,这个箱子理论上不可能再被推出来,属于不可逆死局。注意这个判断不能覆盖所有死局——两个箱子并排顶死在墙边、箱子被卡在长通道里都属于更复杂的情况,完整解法需要做 BFS 搜索。源码里提供这个方法是让你在推箱子时能立刻弹提示,而不是让玩家玩到一半才发现无解。
参数说明:调用时机是在箱子被推动之后,对新箱子的位置做一次检查;TARGET和BOX_ON_TARGET都放行,是因为箱子在目标点上即使被墙夹住也算完成,不算死局。
4. 渲染与交互:WinForms 与控制台两套落地方案
4.1 WinForms 双缓冲绘制:解决画面闪烁
窗体版的核心是把地图画到 Panel 上,这里最容易翻车的是闪烁。WinForms 的控件默认不是双缓冲的,每次Invalidate()触发重绘时,背景先被擦成灰色再画格子,肉眼看到的就是闪。解决办法是在构造函数里打开双缓冲:
public sealed class GameBoard : Panel { private int[,] _map; private int _playerRow, _playerCol; private const int CELL = 32; // 每格 32 像素 public GameBoard() { DoubleBuffered = true; // 关键:关闭擦除闪烁 BackColor = Color.Black; } protected override void OnPaint(PaintEventArgs e) { if (_map == null) return; Graphics g = e.Graphics; for (int r = 0; r < _map.GetLength(0); r++) { for (int c = 0; c < _map.GetLength(1); c++) { Rectangle rect = new Rectangle(c * CELL, r * CELL, CELL, CELL); switch (_map[r, c]) { case WALL: g.FillRectangle(Brushes.Gray, rect); break; case TARGET: g.FillEllipse(Brushes.Gold, rect); break; case BOX: g.FillRectangle(Brushes.Orange, rect); break; case BOX_ON_TARGET: g.FillRectangle(Brushes.LimeGreen, rect); break; default: break; // 空地不画 } } } // 玩家单独画一层,不占用地图数据 Rectangle playerRect = new Rectangle(_playerCol * CELL, _playerRow * CELL, CELL, CELL); g.FillEllipse(Brushes.White, playerRect); } }逻辑说明:玩家不写进_map,绘制时在最后一步叠加画一个白色圆,这样地图数据和渲染层彻底分离。DoubleBuffered = true是 WinForms 里最省事的抗闪烁方案,比手动创建BufferedGraphics简洁得多。
参数说明:CELL是像素格大小,32 是入门值,笔记本屏幕上刚好能看清;如果关卡地图很大(比如超过 20×20),建议降到 24 或 20,否则窗口会超出屏幕。OnPaint里每次全量重绘整个地图,对推箱子这种小地图性能没问题,不需要做脏矩形局部刷新。
4.2 键盘事件:KeyDown 与方向映射
窗体或 Panel 上挂键盘事件,注意焦点问题——Panel 默认拿不到键盘焦点,需要在构造函数里调用TabStop = true并Focus()。核心映射代码如下:
private void GameBoard_KeyDown(object? sender, KeyEventArgs e) { int dir = -1; switch (e.KeyCode) { case Keys.Up: dir = 0; break; case Keys.Down: dir = 1; break; case Keys.Left: dir = 2; break; case Keys.Right: dir = 3; break; case Keys.Z: Undo(); return; // Z 键悔棋 } if (dir >= 0 && TryMove(_map, ref _playerRow, ref _playerCol, dir, out int push)) { _steps++; _pushCount += push; Invalidate(); // 重绘 if (CheckWin(_map)) ShowWin(); } }逻辑说明:Keys.Z单独处理走悔棋分支,不参与移动计算。TryMove返回 false 时什么都不做,不扣步数,这个细节很重要——玩家撞墙不应该算步数。Invalidate()只是给系统发重绘请求,真正的绘制在OnPaint里执行,中间由消息循环调度。
4.3 控制台版:Console.ReadKey 与重绘策略
控制台版拿到的是无 GUI 环境下的可运行版本,适合命令行交作业或者快速验证逻辑。控制台的渲染没有「重绘」概念,常见做法是每次移动后Console.Clear()再全量输出,但清屏会导致明显的闪烁。改进方案是关掉光标、定位到固定位置重新输出:
Console.OutputEncoding = Encoding.UTF8; // 防止中文/特殊字符乱码 Console.CursorVisible = false; while (true) { DrawMap(); // 内部用 Console.SetCursorPosition(0, 0) 定位 ConsoleKey key = Console.ReadKey(true).Key; int dir = key switch { ConsoleKey.UpArrow => 0, ConsoleKey.DownArrow => 1, ConsoleKey.LeftArrow => 2, ConsoleKey.RightArrow => 3, ConsoleKey.Z => -1, // 悔棋 _ => -2 // 无效按键 }; if (dir == -1) { Undo(); continue; } if (dir == -2) continue; if (TryMove(_map, ref _playerRow, ref _playerCol, dir, out _)) { _steps++; if (CheckWin(_map)) break; } }逻辑说明:Console.ReadKey(true)的true表示不把按下的字符回显到屏幕上,否则画面会被按键字母污染。ThrowKey映射到方向数组的下标,和 WinForms 版共用同一个TryMove,核心逻辑完全复用。Console.SetCursorPosition(0, 0)每次把光标挪回左上角再输出,比Clear()闪烁小得多,这是命令行游戏渲染的常见优化。
4.4 悔棋与步数统计:历史栈快照
悔棋的本质是状态快照回滚。这份源码里悔棋的实现是把「地图 + 玩家坐标」打包压栈,每次移动前存一份:
private readonly Stack<(int[,] map, int row, int col)> _history = new(); private void SaveState() { int[,] snapshot = (int[,])_map.Clone(); // 值类型二维数组的深拷贝 _history.Push((snapshot, _playerRow, _playerCol)); } private void Undo() { if (_history.Count == 0) return; (_map, _playerRow, _playerCol) = _history.Pop(); _steps = Math.Max(0, _steps - 1); Invalidate(); }逻辑说明:(int[,])_map.Clone()对 int 二维数组是真正的深拷贝,因为 int 是值类型,不涉及引用共享。步数回退用Math.Max(0, ...)防止负数,因为开局第一次移动前没有历史可退。Undo时步数减 1 是一种简化的倒推逻辑,如果一局里推了箱子,严格来说应该同时回退推数,实际项目里可以给(int[,], int, int, int, int)打包时把步数、推数一起存进去。
5. 避坑记录:推箱子源码复现时最常踩的五个问题
5.1 关卡字符串宽度不一致,运行到一半数组越界
现象:关卡能加载,但玩家走到某些行的右侧时直接抛IndexOutOfRangeException,或者右侧墙壁少一截。
原因:关卡文本里短行的行尾空格被编辑器或 Git 自动删掉,lines[r].Length比最长行短,取lines[r][c]时越界。
解决:解析前先遍历一遍所有行算最大长度,内层访问字符前加c < lines[r].Length判断,缺的字符当空格处理。这个兜底逻辑必须写进解析函数,不能指望关卡文件格式永远规范。
5.2 箱子从目标点推走后,原地块类型丢了
现象:箱子原本在目标点上显示为绿色,推走之后目标点消失,变成普通地面,关卡永远无法完成。
原因:移动逻辑里把箱子的旧位置一律写成SPACE,没判断它原来是不是BOX_ON_TARGET。如果原来是BOX_ON_TARGET,旧位置应该恢复成TARGET而不是空地。
解决:恢复旧格子时用三元表达式判断cell == BOX_ON_TARGET ? TARGET : SPACE。检查方法:把地图上所有BOX_ON_TARGET的箱子推走一步,目标点的金色圆点应该还在。
5.3 悔棋栈存了引用,退几步后整个地图全乱
现象:按一次 Z 能回退一步,连按几次 Z 之后地图变成完全错乱的状态,墙的位置都对不上。
原因:保存快照时写了_history.Push(_map),没做Clone()。二维数组变量存的是引用,压栈后后续移动修改的是同一块内存,栈里所有「历史」其实都指向同一份当前地图。
解决:压栈前必须(int[,])_map.Clone()。顺手在备注里写清楚:int 值类型的多维数组 Clone 是深拷贝,但如果你把地图改成List<GameObject>这类引用类型数组,Clone 只拷贝外壳,内部对象还是共享的,届时要自己做逐对象拷贝。
5.4 键盘长按不松手,玩家一口气冲进墙里
现象:按住方向键不放,玩家连续快速移动,松开时发现已经越过了本该停下的位置,或者步数统计异常偏高。
原因:WinForms 的KeyDown在长按时会触发系统级键盘重复,重复速率由操作系统控制面板决定,不是代码能完全屏蔽的。
解决:在KeyDown里加一个「当前是否在处理」的互斥标记,或者改用KeyUp触发移动。更实用的方案是接受重复输入但依赖TryMove的越界和撞墙判定兜底——移动逻辑本身要保证任何非法方向都被拒绝,这样就算重复触发,玩家也只是撞墙后原地踏步,不会穿墙。另外,移动成功后立即Invalidate(),重复触发的多余移动会让步数虚高,介意的话可以在KeyDown里判断e.Repeat是否为 true 并跳过。
5.5 控制台输出特殊字符变问号或方块
现象:控制台版运行时,墙和目标点显示成?或空心方块,完全看不出地图结构。
原因:Windows 控制台默认代码页是 GBK 或 ASCII,对 UTF-8 的多字节字符不兼容。
解决:程序入口最前面加Console.OutputEncoding = Encoding.UTF8;。如果还乱,把控制台字体改成「新宋体」或「Consolas」并勾选 Unicode 选项。要彻底省事,也可以用纯 ASCII 字符代替:#当墙、.当目标点、$当箱子、@当玩家,虽然不好看但永远不会乱码。
6. 扩展与验证:把这份源码改造成能拿得出手的作品
先把资源里自带的地图文件拆开看,你会发现每个关卡就是 6 到 10 行字符串,这种纯文本关卡的好处是扩展关卡时不用改代码。顺手做一件事:写一个批量校验脚本,遍历关卡目录下所有.txt文件,逐个调用ParseLevel,解析失败或玩家坐标没找到的关卡直接报错列出文件名。这是我在接手别人关卡资源时的习惯,一次能拦下一大半格式问题,比让玩家玩到一半崩了再反馈强得多。
移动逻辑抽出来后,建议补一组最少断言验证。新建一个控制台测试项目,引用游戏逻辑类库,逐条验证:箱子遇到墙不能推动、箱子推上目标点后胜利、玩家走回离开的地方地图状态不残留。这类回归测试在改代码时作用极大——比如你想把移动算法从「先判箱子后动玩家」改成「先走玩家再推箱子」,改完跑一遍断言就心里有底,不用每次手动玩到第三关才能确认没改崩。
我个人的习惯是这样:任何小游戏项目的逻辑层都不允许直接访问 UI 控件,TryMove、CheckWin、ParseLevel全部是纯函数,输入输出只有数组和坐标。这套约束在推箱子这种小项目里看起来小题大做,可一旦你决定加新玩法——比如双人模式、限时模式、关卡编辑器——纯逻辑层的价值立刻显出来。第二个人接手代码时不需要打开窗体设计器,就能把规则改明白。从那以后我每次写网格类小游戏,都强制自己把逻辑层和渲染层拆干净,先跑断言再碰界面。希望帮到你。
本文还有配套的精品资源,点击获取