
简介一份基于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 和 playerColmap 里只留地面类型。这样做的好处是移动时只需要改两个坐标变量不用反复改地图数组避免「玩家走过后原地板类型被覆盖」这种经典 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用 03 的整数而不是直接传坐标增量目的是让键盘事件和后续的 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 是深拷贝但如果你把地图改成ListGameObject这类引用类型数组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全部是纯函数输入输出只有数组和坐标。这套约束在推箱子这种小项目里看起来小题大做可一旦你决定加新玩法——比如双人模式、限时模式、关卡编辑器——纯逻辑层的价值立刻显出来。第二个人接手代码时不需要打开窗体设计器就能把规则改明白。从那以后我每次写网格类小游戏都强制自己把逻辑层和渲染层拆干净先跑断言再碰界面。希望帮到你。本文还有配套的精品资源点击获取