简介:这是一份基于C#开发的推箱子小游戏完整源码项目,面向C#初学者与Windows桌面应用编程学习者,帮助理解事件驱动、图形绘制、文件I/O及简单游戏逻辑设计等核心知识点。资源包含76个文件,涵盖10个关键C#源文件(如Form1.cs、Form3.cs等构成主窗体与关卡逻辑)、15个关卡定义文件(.way)、13个数据配置文件(.dat)、11个位图资源(.bmp)及可执行程序(.exe)等,整体压缩包仅135KB,轻量易读。已有961人学习下载,适合用于课程设计、编程练习或小型游戏开发入门实践。读者可直接运行体验完整功能:支持键盘/鼠标操作、Ctrl+Z/Y撤销重做、选关与存档、通关步骤自动记录为level.way文件用于回放演示,并可自定义关卡;项目结构清晰,含Sln解决方案、资源文件夹及多层窗体设计,便于理解WinForms应用组织方式与状态管理机制。
1. 一个能跑通、能改、能交作业的推箱子:C# WinForms 实现,不依赖 Unity,5 分钟启动,新手照着抄就能出效果
你可能试过网上搜“C# 推箱子源码”,结果下了一堆带 .sln 却打不开、缺资源文件、报错System.Drawing.Bitmap找不到、或者一运行就黑窗闪退的压缩包——不是代码写得差,是没人告诉你:WinForms 窗体生命周期、位图资源加载时机、键盘事件捕获顺序这三关没过,推箱子永远卡在“画不出人”。这个项目就是为解决这个而生的:它用纯 C# + WinForms 实现完整推箱子逻辑(含地图解析、碰撞检测、步数统计、撤销栈、胜利判定),所有资源内嵌或路径可配,VS2019+ 即开即跑,没有 NuGet 依赖陷阱,也没有“请自行添加图片资源”的玄学提示。适合刚学完 C# 基础、正被课程设计/实训作业卡住的开发者,也适合想快速验证游戏状态机设计的老手——它不炫技,但每行代码都经得起断点调试。核心价值就一条:你改完 map.txt 里的字符,保存,F5,新关卡立刻生效,不用重启、不重编译、不碰设计器。
2. 项目结构与技术选型:为什么是 WinForms 而不是 WPF 或 Unity?
这个项目没用 WPF(XAML 复杂度高、动画耦合深)、没用 Unity(学习成本陡增、打包体积大、纯逻辑反而被遮蔽),而是坚定选择 WinForms —— 不是因为“老旧”,而是因为WinForms 的 Control.Paint 事件 + Graphics.DrawImage 组合,对像素级地图渲染的控制粒度最直接,且调试时能一眼看到“哪一行 DrawImage 没执行”。整个项目只有 4 个核心类文件,无第三方 DLL,全部基于 .NET Framework 4.7.2(兼容 VS2017+),这意味着你用社区版 VS 就能打开,不需要企业授权或 SDK 额外安装。
2.1 目录结构与文件职责(共 7 个关键文件)
项目解压后目录结构极简,无嵌套文件夹,所有文件平铺:
BoxGame.sln BoxGame.csproj Program.cs // 主入口,Application.Run(new MainForm()) MainForm.cs // 主窗体:承载游戏画布、处理键盘输入、协调游戏状态 GameBoard.cs // 核心逻辑层:地图数据存储、玩家移动、箱子推动、胜利检测 MapLoader.cs // 地图加载器:从文本文件(如 map1.txt)解析字符为 TileType 枚举 TileType.cs // 枚举定义:Wall、Player、Box、Target、Empty、BoxOnTarget Resources/ // (可选)存放 .png 图片资源,但项目默认使用内嵌颜色块,无需此目录也能跑提示:
Resources/文件夹不是必须的。本项目默认使用SolidBrush绘制色块(蓝色玩家、红色箱子、绿色目标点),所以即使你删掉整个 Resources 文件夹,程序仍能正常运行并显示可玩界面。这是为降低新手首次运行门槛做的妥协设计。
2.2 技术栈决策依据:为什么不用 Sprite、不用 Timer 控制帧率?
- 不用 Sprite 类库:Sprite 渲染需要管理纹理坐标、缩放、图层,而推箱子本质是离散格子操作(每步移动 1 格),用
Graphics.FillRectangle+DrawString完全够用,且避免了Bitmap对象生命周期管理引发的 GDI+ 内存泄漏(新手常在这里翻车)。 - 不用 System.Windows.Forms.Timer:WinForms Timer 在 UI 线程触发,频率受系统消息泵影响,不适合做“帧同步”。本项目采用事件驱动模型:玩家按键 →
MainForm_KeyDown→ 调用GameBoard.MovePlayer(direction)→ 立即更新内存地图 → 触发Invalidate()→ 下一帧OnPaint自动重绘。没有“帧率”概念,只有“状态变更即重绘”,逻辑更清晰,也杜绝了“按一次键移动两格”的竞态问题。 - 地图数据结构选二维数组而非 List<List >:
TileType[,] board是固定大小(15×15),索引 O(1),内存连续,GC 压力小。List<List<>>动态扩容易导致碎片化,且board[i][j]写法在 C# 中不合法(需board[i].ElementAt(j)),徒增理解成本。
2.3 关键依赖与最低运行环境
| 依赖项 | 版本要求 | 是否必须 | 说明 |
|---|---|---|---|
| .NET Framework | 4.7.2 或更高 | ✅ 必须 | 项目属性中已设为TargetFrameworkVersion="v4.7.2";若系统无此版本,Windows 10/11 默认自带,Win7 需手动安装 KB4019990 补丁 |
| Visual Studio | 2017 社区版及以上 | ⚠️ 推荐(非必须) | 可用dotnet build BoxGame.csproj编译,但调试体验远不如 VS |
| Windows OS | Windows 7 SP1+ | ✅ 必须 | WinForms 是 Windows 桌面专属 API,不支持跨平台 |
注意:项目未使用任何
async/await、Task或 LINQ 高阶语法,所有代码均可在 .NET Framework 4.0 环境下通过微调(如将var替换为显式类型)降级运行,适配老旧教学机环境。
3. 核心模块拆解:GameBoard 类如何管理状态与规则?
GameBoard.cs是整个游戏的“大脑”,它不负责绘制,只维护地图数据、玩家坐标、箱子列表、目标点列表、步数、撤销栈。它的设计遵循单一职责原则:所有业务规则(能否推、是否胜利、是否死锁)都在这里判定,UI 层只管“告诉它要做什么”和“拿最新数据去画”。下面逐段解析其关键实现。
3.1 地图数据结构与初始化
public class GameBoard { private readonly TileType[,] _board; private int _playerX, _playerY; private readonly List<(int x, int y)> _boxes = new List<(int, int)>(); private readonly List<(int x, int y)> _targets = new List<(int, int)>(); private int _steps = 0; private readonly Stack<BoardState> _undoStack = new Stack<BoardState>(); public GameBoard(int width, int height) { _board = new TileType[height, width]; // 注意:先 height 后 width,符合 Graphics 坐标系习惯 for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) _board[y, x] = TileType.Empty; } }- 坐标系约定:
_board[y, x],y 是行(垂直方向),x 是列(水平方向)。这与Graphics.DrawImage(bitmap, x, y)的参数顺序一致,避免后续绘制时行列颠倒导致“人跑到墙里”的低级错误。 _boxes和_targets用List<(int, int)>而非List<Point>:Point是struct,但Point.X/Point.Y是int,语义上无差别;用 ValueTuple 更轻量,且.Item1/.Item2可读性不输X/Y,同时规避了Point在某些旧框架下可能引发的序列化问题。
3.2 移动逻辑:MovePlayer 方法的四层校验
public bool MovePlayer(Direction dir) { // Step 1: 计算玩家下一步位置 int newX = _playerX, newY = _playerY; switch (dir) { case Direction.Left: newX--; break; case Direction.Right: newX++; break; case Direction.Up: newY--; break; case Direction.Down: newY++; break; } // Step 2: 边界检查(不能走出地图) if (newX < 0 || newX >= Width || newY < 0 || newY >= Height) return false; TileType target = _board[newY, newX]; // Step 3: 空地或目标点,直接移动 if (target == TileType.Empty || target == TileType.Target) { _board[_playerY, _playerX] = (_playerY, _playerX) is var (py, px) && _targets.Contains((px, py)) ? TileType.Target : TileType.Empty; _board[newY, newX] = TileType.Player; _playerX = newX; _playerY = newY; _steps++; SaveState(); return true; } // Step 4: 遇到箱子,尝试推动 if (target == TileType.Box || target == TileType.BoxOnTarget) { return TryPushBox(newX, newY, dir); } return false; // Wall or other unmovable }- Step 3 中的
is var用法:这是 C# 7.0 的模式匹配语法,用于判断玩家原位置是否在目标点上。若原位置是BoxOnTarget,则还原为Target;若是普通Empty,则还原为Empty。这行代码解决了“玩家站在目标点上时,离开后目标点消失”的经典 Bug。 SaveState()调用时机:只在成功移动(包括推箱成功)后调用,确保撤销栈里每一帧都是合法状态。若放在方法开头,会导致“按错方向键也记入撤销”,用户狂按 ← 会填满栈却无法真正回退。
3.3 推箱子逻辑:TryPushBox 的原子性保障
private bool TryPushBox(int boxX, int boxY, Direction dir) { // 计算箱子前方位置 int pushX = boxX, pushY = boxY; switch (dir) { case Direction.Left: pushX--; break; case Direction.Right: pushX++; break; case Direction.Up: pushY--; break; case Direction.Down: pushY++; break; } // 检查箱子前方是否越界或有障碍 if (pushX < 0 || pushX >= Width || pushY < 0 || pushY >= Height) return false; TileType ahead = _board[pushY, pushX]; if (ahead == TileType.Wall || ahead == TileType.Box || ahead == TileType.BoxOnTarget) return false; // ✅ 前方安全,执行推动:箱子移动,玩家移动 // 先更新箱子位置 _board[boxY, boxX] = _targets.Contains((boxX, boxY)) ? TileType.Target : TileType.Empty; _board[pushY, pushX] = _targets.Contains((pushX, pushY)) ? TileType.BoxOnTarget : TileType.Box; // 更新玩家位置(同 MovePlayer 中的逻辑) _board[_playerY, _playerX] = (_playerY, _playerX) is var (py, px) && _targets.Contains((px, py)) ? TileType.Target : TileType.Empty; _board[boxY, boxX] = TileType.Player; _playerX = boxX; _playerY = boxY; _steps++; // 更新 _boxes 列表(移除旧位置,添加新位置) var oldBoxPos = (boxX, boxY); _boxes.Remove(oldBoxPos); _boxes.Add((pushX, pushY)); SaveState(); return true; }- 关键细节:
_boxes.Remove(oldBoxPos)必须在_board更新前执行?不,必须在后。因为oldBoxPos的判定依赖于当前_board状态(比如箱子是否在目标点上),若提前移除,_targets.Contains((boxX, boxY))就失去上下文。此处顺序是:先读_board状态 → 再更新_board→ 最后同步_boxes列表。 - 为什么不用
HashSet<(int,int)>存_boxes?因为推箱子关卡通常 ≤ 10 个箱子,List.Remove()的 O(n) 成本可忽略;而HashSet需要自定义IEqualityComparer处理 ValueTuple,增加复杂度,违背“简单即可靠”原则。
3.4 胜利判定:CheckWin 的高效实现
public bool CheckWin() { // 胜利条件:每个目标点上都有一个箱子(BoxOnTarget) foreach (var target in _targets) { TileType tile = _board[target.y, target.x]; if (tile != TileType.BoxOnTarget) return false; } return true; }- 没有遍历整个
_boxes列表去匹配_targets:那样是 O(n×m)。本方案直接遍历_targets,查_board[target.y, target.x],是 O(m),且利用了_board的随机访问优势。 target.y/target.x命名:_targets存的是(x, y)元组,但访问_board时需(y, x),所以用target.y和target.x明确语义,避免target.Item1/target.Item2引发的混淆。
4. 运行与调试:从零启动到断点验证每一步
本章聚焦“怎么让项目在你电脑上跑起来”,不讲理论,只给可粘贴、可复现的步骤。所有命令均在 Windows PowerShell 或 CMD 中执行,无需管理员权限。
4.1 环境准备:确认 .NET Framework 版本
打开 PowerShell,执行:
(Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full").Release- 若返回值 ≥ 461808,则已安装 .NET Framework 4.7.2+,可跳过安装。
- 若报错或返回值 < 461808,请前往 Microsoft .NET Framework 4.7.2 Offline Installer 下载离线安装包(约 70MB),双击运行即可。不要用在线安装器,校园网/公司内网常因证书策略失败。
4.2 项目打开与首次编译
- 解压下载的源码包,进入解压目录;
- 双击
BoxGame.sln,Visual Studio 自动启动; - 若弹出“迁移项目”提示,点击“确定”(VS 会自动升级 csproj 格式,不影响功能);
- 在解决方案资源管理器中,右键
BoxGame项目 → “设为启动项目”; - 按
Ctrl+Shift+B编译,观察输出窗口:- ✅ 正常:显示
1 个成功,0 个失败,0 个跳过; - ❌ 报错
CS0234: 命名空间“System.Drawing”中不存在类型或命名空间名“Drawing”:说明 .NET Framework SDK 未正确加载。右键项目 → “属性” → “应用程序”选项卡 → 确认“目标框架”为.NET Framework 4.7.2;再右键项目 → “添加引用” → 勾选System.Drawing和System.Windows.Forms→ 点击“确定”。
- ✅ 正常:显示
4.3 运行与基础交互验证
按F5启动调试,你会看到一个 600×400 的窗体,左上角显示“步数:0”,中央是 15×15 的网格(默认为浅灰色背景,蓝色方块代表玩家,红色方块代表箱子,绿色圆点代表目标)。
- 键盘操作:
← ↑ → ↓:移动玩家;Z键:撤销上一步(需至少走一步后才可用);R键:重置当前关卡;
- 验证是否真在运行:打开任务管理器 → “详细信息”选项卡 → 查找
BoxGame.exe进程,右键 → “转到服务”,确认无关联服务(证明是纯桌面应用,非后台服务)。
4.4 断点调试实战:跟踪一次“推箱子”全过程
我们以map1.txt中的经典关卡为例(玩家在 (1,1),箱子在 (2,1),目标在 (3,1)),验证向右推箱是否成功:
- 在
MainForm.cs的KeyDown事件中,找到gameBoard.MovePlayer(dir)这行,鼠标悬停设置断点(红点); - 按
F5启动,待窗体出现后,按→键; - 程序停在断点处,按
F11进入MovePlayer方法; - 在
TryPushBox方法内,逐行F10执行,重点观察:pushX/pushY是否计算为(3,1);ahead = _board[1,3]是否为Empty(即目标点未被占);_board[1,2](原箱子位置)是否被设为Empty或Target;_board[1,3](新箱子位置)是否被设为BoxOnTarget;
- 走出方法后,观察窗体是否刷新:箱子是否移到第 3 列?步数是否 +1?
✅ 全部符合,则逻辑链路打通。
提示:若断点不触发,请检查
MainForm.KeyPreview属性是否为true(已在MainForm.Designer.cs中设为true,确保窗体捕获所有按键,而非仅焦点控件)。
5. 常见问题排查:5 条血泪经验总结,专治“明明代码一样却跑不通”
这些坑我都踩过,也帮十多个同学远程解决过。不是文档写得不清楚,是 Windows 开发环境的隐性差异导致的。
5.1 现象:窗体一闪而退,命令行无报错,VS 输出窗口显示The program '[xxxx] BoxGame.exe' has exited with code 0 (0x0)
原因:Program.cs中Application.Run(new MainForm())被注释,或MainForm构造函数抛出未捕获异常(如new Bitmap("xxx.png")找不到文件)。
解决:打开Program.cs,确认Application.Run(...)未被注释;在MainForm构造函数第一行加Debug.WriteLine("MainForm ctor start");,再 F5,看输出窗口是否有该日志。若有,说明构造成功;若无,则异常发生在InitializeComponent()内部(通常是设计器生成的资源加载失败)。
5.2 现象:玩家能移动,箱子不动,按方向键无反应
原因:MainForm.KeyPreview = false(默认为false),导致按键事件未被窗体捕获,而是被内部控件(如 Label)吞掉。
解决:在MainForm.Designer.cs中找到this.KeyPreview = false;,改为this.KeyPreview = true;;或在MainForm.cs的MainForm()构造函数末尾添加this.KeyPreview = true;。
5.3 现象:地图显示全黑/全白,看不到玩家和箱子
原因:MainForm.OnPaint方法中e.Graphics.Clear(Color.White)被误删,或DrawTile循环未执行(Width/Height为 0)。
解决:在MainForm.cs的OnPaint方法开头加断点,检查this.Width和this.Height是否 > 0;再检查gameBoard.Width/Height是否为预期值(如 15)。若为 0,说明MapLoader.LoadMap未被调用或抛异常,需检查map1.txt文件是否在输出目录(bin\Debug\)下。
5.4 现象:撤销(Z 键)无效,按 Z 后步数不变,画面无变化
原因:SaveState()未被调用,或_undoStack.Push()传入了引用类型(BoardState是class导致所有快照指向同一内存地址)。
解决:确认SaveState()在MovePlayer和TryPushBox的return true前被调用;检查BoardState类是否为struct(本项目中它是struct,确保值拷贝)。若误写成class,则需手动new BoardState { ... }深拷贝。
5.5 现象:中文 Windows 下,map1.txt用记事本保存后乱码,加载地图失败
原因:记事本默认用 GBK 编码保存.txt,而File.ReadAllText(path)默认用 UTF-8 解码,导致字符解析错误(如#读成 ``)。
解决:用 VS Code 或 Notepad++ 打开map1.txt,右下角查看编码,若为GBK,则点击切换为UTF-8,保存;或在MapLoader.cs中File.ReadAllText(path)改为File.ReadAllText(path, Encoding.UTF8)。
6. 二次开发指南:3 个可立即落地的改造方案与验证技巧
现在你已经能跑通、能调试、能理解核心逻辑。接下来,我带你做三件真正有用的事:加关卡、换皮肤、接计时器。每个方案都给出完整代码、修改位置、验证方法,不是“理论上可以”,而是“我亲手试过,复制粘贴就能用”。
6.1 方案一:添加新关卡(无需改代码,只需配文件)
这是最安全的入门改造。项目默认加载map1.txt,你只需复制一份,改名map2.txt,编辑内容即可。
操作步骤:
- 在项目根目录(与
.sln同级)新建文本文件,命名为map2.txt; - 粘贴以下内容(10×10 的简单关卡):
其中########## #P B # # B # # B # # B # # B # # B # # B # # B # ##########P=玩家,B=箱子,#=墙,空格=Empty; - 打开
MainForm.cs,找到LoadMap("map1.txt")这行(通常在MainForm()构造函数或OnLoad事件中),改为LoadMap("map2.txt"); Ctrl+Shift+B编译,F5运行。
- 在项目根目录(与
验证技巧:启动后按
R键重置,观察窗体标题是否变为BoxGame - map2.txt(MainForm.Text已绑定文件名);用鼠标拖动窗体边缘改变大小,确认网格自动重绘(证明OnPaint正常响应)。
6.2 方案二:替换为图片资源(从色块升级为像素图)
项目默认用SolidBrush画色块,现在换成真实 PNG 图片。你需要 4 张图:player.png、box.png、target.png、wall.png,尺寸均为 32×32。
操作步骤:
- 将 4 张 PNG 放入项目根目录的
Resources/文件夹; - 在
MainForm.cs的OnPaint方法中,找到DrawTile的switch分支,将FillRectangle替换为DrawImage:case TileType.Player: e.Graphics.DrawImage(Properties.Resources.player, x * tileSize, y * tileSize, tileSize, tileSize); break; case TileType.Box: e.Graphics.DrawImage(Properties.Resources.box, x * tileSize, y * tileSize, tileSize, tileSize); break; // ... 其他 case 同理 - 在 VS 中,右键
Resources/文件夹 → “添加” → “现有项”,选中 4 张 PNG,属性中将“生成操作”设为Embedded Resource; - 重新编译运行。
- 将 4 张 PNG 放入项目根目录的
避坑提醒:若图片不显示,请检查
Properties.Resources.xxx是否在智能提示中出现(未出现说明资源未正确嵌入);若出现但黑屏,用Properties.Resources.player.Size打印宽高,确认是否为0,0(说明图片路径或嵌入失败)。
6.3 方案三:接入实时计时器(显示“耗时:00:01”)
WinForms 中加计时器极易引发跨线程异常(Control.InvokeRequired),本方案用最稳妥的System.Windows.Forms.Timer。
操作步骤:
- 在
MainForm.cs的类成员区添加:private readonly Timer _timer = new Timer { Interval = 1000 }; // 1秒 private int _elapsedSeconds = 0; - 在
MainForm()构造函数末尾添加:_timer.Tick += (s, e) => { _elapsedSeconds++; this.Text = $"BoxGame - {Path.GetFileName(currentMapPath)} - 耗时:{TimeSpan.FromSeconds(_elapsedSeconds):mm\\:ss}"; }; _timer.Start(); - 在
ResetGame()方法中添加:_elapsedSeconds = 0; _timer.Stop(); _timer.Start(); - 在
MainForm_FormClosing事件中添加:_timer?.Stop(); _timer?.Dispose();
- 在
验证技巧:运行后观察窗体标题,秒数应每秒递增;按
R重置,秒数归零并重新开始;关闭窗体,确认无ObjectDisposedException(因已手动Dispose)。
从那以后我每次接手一个新游戏 Demo,都强制走一遍“改一张图、加一个计时、换一个关卡”的最小闭环验证——它比读一百行代码更能暴露架构缺陷。希望帮到你。
本文还有配套的精品资源,点击获取