C#推箱子游戏源码解析:WinForms实现与核心逻辑
简介:这是一份面向C#初学者与游戏编程爱好者的推箱子小游戏完整源码工程,基于WinForms实现,可用于课程设计参考、C#桌面开发练手或小游戏二次开发。压缩包共76个文件,约135KB,包含10个cs源文件、4个resx与resources资源文件、11个bmp位图素材、13个dat与15个way关卡及通关记录文件,另有sln解决方案、csproj工程文件与可执行程序,结构完整可直接编译运行。已有964人学习下载。代码实现了方向键推箱、Ctrl+Z撤销与Ctrl+Y重做、选关、当前状态存盘读盘,通关后自动将最佳步数保存为level.way文件,可回放演示整局推箱过程或分享给朋友,同时支持自行设计关卡。自动寻路、鼠标操作与通关光荣榜等功能留有扩展空间,适合读者在此基础上练习深度优先搜索与栈的应用,理解游戏状态管理与文件持久化的实现思路。
1. 从一份能跑起来的 C# 推箱子源码说起
很多人学 C# 卡在“语法都会、项目不会”这一步:窗体拖控件能跑,真给一个完整小游戏却不知道从哪读起。这份 C# 推箱子小游戏源代码就是为这个场景准备的——它把地图数据、角色移动、箱子推动判定、关卡切换、胜负判断这些游戏里最基础也最容易写乱的部分,压缩在一个 WinForms 工程里。适合刚学完 C# 基础、想找一个能完整跑通并动手改的练手项目的开发者,也适合带课的老师拿它当课堂案例。下面按“先看清结构、再跑起来、最后动手改”的顺序拆。
2. 工程结构与技术选型:一个 WinForms 推箱子是怎么搭起来的
2.1 技术栈与运行环境
这个项目是典型的 .NET Framework WinForms 桌面程序,没有引入任何第三方游戏引擎或 UI 库。选型逻辑很直接:推箱子是网格回合制游戏,不需要实时渲染循环,用 GDI+ 画格子完全够用,窗体控件负责菜单和状态栏,省掉引擎学习成本。
| 项目 | 说明 |
|---|---|
| 语言 | C# |
| UI 框架 | WinForms(System.Windows.Forms) |
| 图形绘制 | GDI+(Graphics、Bitmap、Pen、Brush) |
| 目标框架 | .NET Framework 4.x(常见做法是 4.7.2 或 4.8) |
| 开发工具 | Visual Studio 2019/2022,装“.NET 桌面开发”工作负载 |
| 数据存储 | 关卡以文本/字符串数组形式内置,或读取外部 txt |
如果你机器上只有 .NET SDK 而没有 Visual Studio,用dotnet命令行跑 WinForms 会麻烦一些,因为老式 WinForms 工程文件是.csproj的旧格式。我一般建议直接装 Visual Studio 社区版,打开.sln就能编译,省去折腾。
2.2 目录与文件职责
一个干净的推箱子工程通常长这样,你拿到源码后先对照这个结构认文件:
Sokoban/ ├── Sokoban.sln ├── Sokoban/ │ ├── Program.cs // 程序入口,Application.Run │ ├── MainForm.cs // 主窗体:绘制、键盘事件、关卡切换 │ ├── MainForm.Designer.cs// 设计器生成的控件代码 │ ├── GameMap.cs // 地图数据与格子类型定义 │ ├── GameLogic.cs // 移动、推箱、胜负判定 │ ├── Levels.cs // 关卡数据(字符串数组或读取文件) │ └── Resources/ // 图标、背景图等职责划分的关键在于把“数据”和“规则”从窗体里拆出来。窗体只负责把地图画出来、把按键转成方向,真正的移动和判定放在GameLogic里。这样你改规则时不用碰 UI 代码,测试逻辑也方便。
2.3 地图数据模型
推箱子的地图本质是一个二维字符网格,每个格子有固定类型。常见做法是用字符表示,读入后转成枚举:
// GameMap.cs public enum TileType { Floor, // 空地 Wall, // 墙 Goal, // 目标点 Box, // 箱子 BoxOnGoal, // 箱子已在目标点上 Player // 玩家 } public class GameMap { public int Rows { get; private set; } public int Cols { get; private set; } public TileType[,] Tiles { get; private set; } // 用字符数组初始化地图 public void Load(string[] lines) { Rows = lines.Length; Cols = lines[0].Length; Tiles = new TileType[Rows, Cols]; for (int r = 0; r < Rows; r++) { for (int c = 0; c < Cols; c++) { char ch = lines[r][c]; switch (ch) { case '#': Tiles[r, c] = TileType.Wall; break; case '.': Tiles[r, c] = TileType.Goal; break; case '$': Tiles[r, c] = TileType.Box; break; case '*': Tiles[r, c] = TileType.BoxOnGoal; break; case '@': Tiles[r, c] = TileType.Player; break; default: Tiles[r, c] = TileType.Floor; break; } } } } }这里#是墙、.是目标点、$是箱子、*是已在目标点上的箱子、@是玩家,这是推箱子地图的通用字符约定。Load方法把字符逐格翻译成枚举,后续所有逻辑都基于Tiles[r, c]判断,不再碰原始字符。注意Cols取第一行长度,要求所有行等长,关卡数据里如果有短行要先用空格补齐,否则会数组越界。
2.4 窗体绘制:把二维数组画到屏幕上
WinForms 里画格子最稳的方式是重写OnPaint,用Graphics.FillRectangle逐格填充。不要用一堆 Label 控件拼地图,格子一多控件刷新会卡,而且坐标计算更乱。
// MainForm.cs private const int TileSize = 40; // 每格像素 protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.None; for (int r = 0; r < map.Rows; r++) { for (int c = 0; c < map.Cols; c++) { Rectangle rect = new Rectangle(c * TileSize, r * TileSize, TileSize, TileSize); switch (map.Tiles[r, c]) { case TileType.Wall: g.FillRectangle(Brushes.DimGray, rect); break; case TileType.Floor: g.FillRectangle(Brushes.WhiteSmoke, rect); break; case TileType.Goal: g.FillRectangle(Brushes.WhiteSmoke, rect); g.DrawEllipse(Pens.Red, rect.X + 12, rect.Y + 12, 16, 16); break; case TileType.Box: g.FillRectangle(Brushes.SaddleBrown, rect); break; case TileType.BoxOnGoal: g.FillRectangle(Brushes.Green, rect); break; case TileType.Player: g.FillEllipse(Brushes.SteelBlue, rect); break; } } } }TileSize决定格子像素大小,改大改小只影响显示,不影响逻辑。每种格子用不同画法区分:墙用深灰实心块、目标点画红圈、箱子用棕色方块、箱子在目标点上变绿、玩家画蓝圆。OnPaint每次重绘都从map.Tiles重新读,所以逻辑层改完数据后只要调Invalidate()就能刷新画面,这是 WinForms 里最省心的刷新方式。
2.5 键盘输入与移动入口
推箱子的操作只有上下左右,用KeyDown事件最直接。注意窗体要能接收键盘事件,得保证KeyPreview = true,否则焦点在子控件上时按键收不到。
// MainForm.cs private void MainForm_KeyDown(object sender, KeyEventArgs e) { int dr = 0, dc = 0; switch (e.KeyCode) { case Keys.Up: dr = -1; break; case Keys.Down: dr = 1; break; case Keys.Left: dc = -1; break; case Keys.Right: dc = 1; break; default: return; // 其他键不处理 } if (logic.TryMove(dr, dc)) { Invalidate(); // 重绘 if (logic.IsWin()) MessageBox.Show("过关!"); } }dr、dc是行、列方向增量,上移是行减一、左移是列减一,这是屏幕坐标系(原点在左上角)的约定。TryMove返回是否真的移动了,没移动就不重绘,减少无谓刷新。胜负判断放在移动之后,IsWin检查是否所有目标点都被箱子覆盖。
3. 核心逻辑拆解:一次按键从进入到返回的完整链路
3.1 移动与推箱判定
推箱子的规则只有三条:目标格是墙不能走;目标格是空地或目标点直接走;目标格是箱子时,要看箱子后面那格能不能放箱子。把这三条写成一个方法,是整个项目最核心的代码。
// GameLogic.cs public bool TryMove(int dr, int dc) { Point player = FindPlayer(); int nr = player.Row + dr; int nc = player.Col + dc; if (!InBounds(nr, nc)) return false; TileType target = map.Tiles[nr, nc]; // 情况一:撞墙 if (target == TileType.Wall) return false; // 情况二:空地或目标点,直接走 if (target == TileType.Floor || target == TileType.Goal) { MovePlayer(player, new Point(nr, nc)); return true; } // 情况三:前面是箱子,检查箱子后面 if (target == TileType.Box || target == TileType.BoxOnGoal) { int br = nr + dr; int bc = nc + dc; if (!InBounds(br, bc)) return false; TileType behind = map.Tiles[br, bc]; // 箱子后面必须是空地或目标点 if (behind != TileType.Floor && behind != TileType.Goal) return false; // 先推箱子,再移动玩家 map.Tiles[br, bc] = (behind == TileType.Goal) ? TileType.BoxOnGoal : TileType.Box; map.Tiles[nr, nc] = TileType.Floor; // 箱子原位置变空地 MovePlayer(player, new Point(nr, nc)); return true; } return false; }逐段看:FindPlayer遍历找Player格,返回坐标;InBounds防越界;target是玩家要去的格子。撞墙直接返回 false。空地/目标点直接调用MovePlayer。关键是箱子分支:先算箱子后面一格behind,只有空地或目标点才能推,否则返回 false。推成功时先把箱子写到新位置(如果新位置是目标点就记BoxOnGoal),再把箱子原位置改成Floor,最后移动玩家。顺序不能反,否则玩家会覆盖箱子数据。
MovePlayer负责把旧位置还原成Floor或Goal,这里有个容易翻车的点:玩家站过的格子如果原本是目标点,离开后要还原成Goal而不是Floor,否则目标点会“消失”。常见做法是单独维护一份“原始地形”数组,或者移动前记录旧格类型。
3.2 胜负判定与关卡切换
胜负判定很简单:遍历所有格子,只要还存在Box(没在目标点上的箱子)就没赢;全是BoxOnGoal或没有箱子就过关。
// GameLogic.cs public bool IsWin() { for (int r = 0; r < map.Rows; r++) for (int c = 0; c < map.Cols; c++) if (map.Tiles[r, c] == TileType.Box) return false; // 还有箱子没归位 return true; }注意这里只检查Box,不检查BoxOnGoal,因为BoxOnGoal表示已归位。如果地图里箱子数比目标点多,这个判定会误判,所以关卡数据要保证箱子数等于目标点数,这是设计关卡时的硬约束。
关卡切换一般用Levels.cs里的字符串数组,CurrentLevel索引加一后重新Load并Invalidate。我一般会在切换前把当前关卡状态存一份,方便做“重玩本关”。
3.3 撤销功能的数据结构
推箱子没有撤销会很难受,尤其是复杂关卡。撤销的本质是记录每一步的状态快照。最省事的做法是每次移动前把整个Tiles数组深拷贝一份压栈。
// GameLogic.cs private Stack<TileType[,]> history = new Stack<TileType[,]>(); public void SaveState() { int rows = map.Rows, cols = map.Cols; var snapshot = new TileType[rows, cols]; Array.Copy(map.Tiles, snapshot, map.Tiles.Length); history.Push(snapshot); } public bool Undo() { if (history.Count == 0) return false; map.Tiles = history.Pop(); return true; }SaveState在每次TryMove成功前调用,Array.Copy做浅拷贝对值类型枚举数组来说等价于深拷贝,够用。Undo弹栈恢复。这种方案内存占用随步数增长,但推箱子单关步数有限,完全可接受。如果关卡特别大,可以改成只记录“玩家位置 + 被推动的箱子位置”的增量日志,但那是优化,不是必需。
3.4 关卡数据的组织方式
关卡数据建议单独放,不要硬编码在窗体里。两种常见做法:一是Levels.cs里用string[][]内置;二是放levels.txt,用空行分隔关卡,运行时读文件。
// Levels.cs public static class Levels { public static readonly string[][] All = new string[][] { new string[] { "#####", "#@$.#", "#####" }, new string[] { "#######", "# . #", "# $@$ #", "# . #", "#######" } }; }第一关是最小可玩示例:玩家@、箱子$、目标点.在同一行,按一次右键就过关,用来验证逻辑通不通。第二关有四个目标点和两个箱子,用来测多箱子场景。注意字符串里的空格代表Floor,Load的default分支会处理成空地,所以关卡里可以用空格排版,可读性好。
4. 跑起来与调试:从打开解决方案到第一关通关
4.1 编译运行步骤
拿到源码后按这个顺序走,基本不会卡:
- 用 Visual Studio 打开
Sokoban.sln,不要单独打开.cs文件。 - 确认解决方案资源管理器里项目图标没有黄色感叹号,有的话右键“还原 NuGet 包”(虽然这个项目通常没有外部包)。
- 顶部工具栏把“启动项目”设为
Sokoban,解决方案平台选Any CPU。 - 按
F5调试运行,或Ctrl+F5不调试运行。 - 窗体出来后用方向键移动,撞墙不动、推箱有反馈、全部归位弹提示,说明跑通了。
如果按 F5 报“无法启动程序”,多半是输出类型不是 Windows 应用程序。右键项目 → 属性 → 应用程序 → 输出类型选“Windows 应用程序”,再检查“启动对象”是不是Sokoban.Program。
4.2 常见编译错误对照
| 现象 | 原因 | 解决 |
|---|---|---|
找不到Main方法 | 启动对象没设或Program.cs被排除 | 项目属性里设启动对象,检查文件是否“从项目中排除” |
MainForm设计器打不开 | Designer.cs里控件初始化顺序被改乱 | 用“查看代码”手改,或从备份恢复 Designer 文件 |
| 按键没反应 | 窗体KeyPreview为 false 或焦点在按钮上 | 设KeyPreview = true,或在KeyDown里处理 |
| 画面不刷新 | 改了数据没调Invalidate() | 每次逻辑变更后调Invalidate() |
| 数组越界 | 关卡行长度不一致 | 补齐短行,或Load里按最大行宽补空格 |
4.3 用调试器看一次移动
想真正理解逻辑,建议在TryMove里下断点,按一次方向键,单步看player、nr/nc、target、behind四个变量的变化。特别是推箱分支,观察map.Tiles[br, bc]和map.Tiles[nr, nc]的赋值顺序。这一步比读十遍代码都管用,也是我带人看这个项目时必做的一步。
5. 避坑与排查:推箱子源码里最容易翻车的几处
5.1 玩家离开目标点后目标点消失
现象:玩家从目标点上走开,那个格子变成普通空地,红圈没了,导致关卡永远无法通关。原因:MovePlayer把旧位置一律写成Floor,没判断旧格原本是不是Goal。解决:移动前记录旧格类型,离开时若是Goal就还原成Goal;或者维护一份独立的Terrain数组只存墙/空地/目标点,Tiles只叠加动态物体。
5.2 箱子被推到墙角后关卡死锁
现象:箱子被推进角落,再也推不出来,玩家只能重开。原因:这是推箱子的固有规则,不是 bug,但新手常以为是程序问题。解决:在TryMove推箱前加一个可选的“死锁检测”——箱子被推到两面墙夹角且不在目标点上时给提示或禁止该步。简单版只检测角落,复杂版要检测整行整列,按需做。
5.3 连续按键导致移动错乱
现象:快速连按方向键,偶尔出现玩家“穿墙”或箱子重叠。原因:KeyDown事件在 UI 线程排队,如果TryMove里有耗时操作或重绘没完成,状态可能不一致。解决:保证TryMove是纯同步、无阻塞的;重绘用Invalidate()而不是Refresh(),让系统合并刷新;必要时在移动入口加一个简单的重入保护标志。
5.4 关卡文件读取时中文路径或编码问题
现象:从levels.txt读关卡,某些机器上读出来是乱码或空。原因:File.ReadAllLines默认按 UTF-8 读,文件若是 GBK 保存就会乱。解决:统一用 UTF-8 保存关卡文件,或显式传Encoding.Default;路径用Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "levels.txt"),不要写死绝对路径。
5.5 窗体缩放后格子错位
现象:拉大窗口后地图还挤在左上角,或者格子被拉伸变形。原因:OnPaint里格子坐标按固定TileSize算,没考虑窗体实际尺寸。解决:要么禁止调整窗体大小(FormBorderStyle = FixedSingle、MaximizeBox = false),要么在OnPaint里根据ClientSize动态算TileSize = Math.Min(width / Cols, height / Rows),并居中偏移。
6. 二次开发:把练手项目改成你自己的版本
这个项目最大的价值不是“能玩”,而是结构足够小,改起来没有心理负担。我一般会从三个方向动手,按难度递增。
第一个方向是换皮和加关卡。把OnPaint里的FillRectangle换成DrawImage,加载几张 png 当墙、箱子、目标点贴图,TileSize调到 48 或 64,观感立刻不一样。关卡数据加到Levels.All里,或者改成读外部文件,配合一个简单的关卡选择列表(ListBox绑定关卡名)。这一步不改逻辑,适合先熟悉工程。
第二个方向是加功能。撤销已经讲了,再往下可以加步数统计和计时:在TryMove成功时stepCount++,窗体上放个Label显示;计时用System.Windows.Forms.Timer,每秒Tick一次更新。还可以加“重玩本关”,本质是重新Load当前关卡并清空history。这些功能都是围绕现有数据结构加外围,不会动核心判定。
第三个方向是重构逻辑层,让它可测试。把GameLogic从窗体依赖里彻底剥离,构造函数只接收GameMap,所有方法不碰Graphics和MessageBox。然后新建一个控制台测试项目,直接new GameLogic(map)调TryMove,断言map.Tiles的变化。这样你改推箱规则时能立刻验证有没有破坏原有行为,比每次开窗体手点靠谱得多。
// 重构后的逻辑层签名示例 public class GameLogic { private readonly GameMap map; public GameLogic(GameMap map) { this.map = map; } public bool TryMove(int dr, int dc) { /* 纯逻辑,无 UI 依赖 */ } public bool IsWin() { /* ... */ } public void SaveState() { /* ... */ } public bool Undo() { /* ... */ } }改造时有个习惯我一直保持:每加一个功能,先在逻辑层写一个最小验证(哪怕只是Debug.Assert),再接到 UI 上。推箱子这种规则密集的小游戏,逻辑错一步整个关卡就废,靠肉眼点很容易漏。从那以后我每次改移动或推箱相关代码,都强制先跑一遍“最小关卡 + 单步断言”,再开窗体手玩确认。希望这份拆解能帮你把这个小工程真正跑起来、改下去。
本文还有配套的精品资源,点击获取