简介这是一份基于C#开发的推箱子小游戏完整源码工程面向C#初学者与游戏编程入门者帮助理解Windows窗体应用、事件驱动逻辑、文件I/O及简单游戏状态管理。资源包含76个文件涵盖10个核心C#源码文件如Form1.cs、Form3.cs等、15个关卡定义文件.way、13个数据配置文件.dat、11个位图资源.bmp及可执行程序.exe、解决方案文件.sln和项目配置文件.csproj整体压缩包仅135KB轻量易读。已有963人学习下载适合边学边练——读者可直接运行体验撤销CtrlZ、重做CtrlY、选关、存档/读档、通关步骤记录level.way及自定义关卡设计等功能代码结构清晰UI与逻辑分离资源集中于Resources目录便于理解WinForms资源嵌入机制与游戏状态持久化实现方式。1. 一个能跑通、能改透、能当教学案例的推箱子C# WinForms 实现不依赖 Unity不套壳源码即工程你可能见过几十个“推箱子源码”压缩包点开全是空文件夹、缺资源、报错 MissingAssembly 或找不到 Program.cs 入口——这次不是。这是一个在 Visual Studio 2022含 .NET 6 SDK下双击 .sln 就能编译运行的完整 WinForms 工程包含全部地图文件.txt 格式、音效资源WAV、图标和可配置关卡管理器。它没用任何第三方游戏引擎纯 C# GDI 绘图逻辑层与 UI 层解耦清晰GameBoard负责状态演算GamePanel专注渲染与输入响应。适合三类人刚学完 WinForms 想做点“有反馈”的项目的新手需要快速交付课堂 Demo 的某高校编程课导师或是想拿它当骨架加路径搜索 AI、存档系统或网络对战的进阶开发者。它不炫技但每行代码都经得起打断点调试——我上周帮 A 同学把第 12 关改成动态生成时只改了LevelLoader.LoadFromText()和GameBoard.IsSolved()两处30 分钟就跑通了。2. 环境准备与工程结构从 .sln 到 .exe 的真实路径2.1 开发环境最低要求与验证步骤这个项目基于 .NET 6 Desktop Runtime 构建不兼容 .NET Framework 4.x这是新手最容易翻车的第一步。别急着双击 .sln——先确认你的机器已安装dotnet --list-runtimes | findstr Microsoft.NETCore.App应输出类似Microsoft.NETCore.App 6.0.28 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]若无 6.x 版本请去 dotnet.microsoft.com/download/dotnet/6.0 下载.NET 6 Desktop Runtime (x64)安装包非 SDK非 ASP.NET Core。安装后重启 VS再打开解决方案。提示VS 2022 17.4 自带 .NET 6 SDK但运行时需单独安装。很多同学装了 SDK 却漏掉 Runtime导致 F5 启动时报 “The specified framework Microsoft.NETCore.App version 6.0.0 was not found”。2.2 工程目录结构5 个核心文件夹讲清职责边界整个解决方案名为BoxPusher.sln含单个项目BoxPusher.csproj。目录结构精简到 5 个关键文件夹没有冗余BoxPusher/ ├── Assets/ # 静态资源maps/关卡文本、sounds/WAV、icons/ICO ├── Core/ # 核心逻辑BoardState.cs, MoveDirection.cs, TileType.cs ├── Game/ # 游戏主干GameBoard.cs状态机、LevelLoader.cs关卡解析 ├── UI/ # WinForms 层GamePanel.cs自绘控件、MainForm.cs窗体容器 └── Properties/ # AssemblyInfo.cs launchSettings.json端口无关忽略注意Assets/maps/下的level_01.txt是标准关卡格式每行代表一行地图字符含义固定#→ 墙Wall空格→ 空地Floor→ 玩家Player$→ 箱子Box.→ 目标点Target*→ 箱子在目标点上BoxOnTarget→ 玩家在目标点上PlayerOnTarget这种设计让关卡编辑变成纯文本操作无需图形编辑器。2.3 项目文件关键配置3 行决定能否启动打开BoxPusher.csproj重点看这三行TargetFrameworknet6.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms OutputTypeWinExe/OutputTypenet6.0-windows明确指定 Windows 桌面平台启用 WinForms APIUseWindowsForms开启 WinForms 支持.NET 5 必须显式声明OutputType设为WinExe而非Exe避免控制台黑窗口闪现。若你用 VS Code 手动编译命令是dotnet build -c Release -r win-x64 dotnet publish -c Release -r win-x64 --self-contained false生成的.exe在bin/Release/net6.0-windows/publish/下可直接分发。2.4 首次运行必做的 2 项检查资源路径校验MainForm.cs中InitializeComponent()后有一段硬编码路径private void LoadInitialLevel() { var levelPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Assets, maps, level_01.txt); if (!File.Exists(levelPath)) throw new FileNotFoundException($关卡文件缺失{levelPath}); // ... }运行前请确认Assets/maps/level_01.txt真实存在。若你移动了文件夹必须同步更新此路径或改为相对路径../../Assets/maps/...。DPI 缩放适配WinForms 在高 DPI 屏幕如 2K/4K 笔记本下可能文字模糊。在MainForm.Designer.cs的InitializeComponent()末尾添加this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true); this.AutoScaleDimensions new SizeF(96F, 96F); // 强制 100% 缩放3. 核心模块拆解GameBoard 状态机与 GamePanel 渲染链路3.1 GameBoard纯逻辑层不碰 UI只管“能不能推”GameBoard.cs是整个游戏的“大脑”它不继承任何控件只是一个class职责极其单一接收玩家移动指令返回新状态。其核心是MovePlayer(MoveDirection direction)方法public bool MovePlayer(MoveDirection direction) { var playerPos FindPlayerPosition(); var nextPos playerPos direction.ToVector2(); // ToVector2() 返回 (0,-1) 等 // 边界检查不能走出地图 if (!IsInBounds(nextPos)) return false; var nextTile GetTileAt(nextPos); // 情况1前方是空地或目标点 → 直接移动 if (nextTile TileType.Floor || nextTile TileType.Target) { SetTile(playerPos, TileType.Floor); // 清除原位置 SetTile(nextPos, TileType.Player); // 设置新位置 return true; } // 情况2前方是箱子 → 检查箱子后方是否可推 if (nextTile TileType.Box || nextTile TileType.BoxOnTarget) { var boxNextPos nextPos direction.ToVector2(); if (!IsInBounds(boxNextPos)) return false; var boxNextTile GetTileAt(boxNextPos); if (boxNextTile ! TileType.Floor boxNextTile ! TileType.Target) return false; // 箱子后方是墙或另一箱子推不动 // 推箱子先移箱子再移玩家 SetTile(nextPos, nextTile TileType.BoxOnTarget ? TileType.Target : TileType.Floor); SetTile(boxNextPos, nextTile TileType.BoxOnTarget ? TileType.BoxOnTarget : TileType.Box); SetTile(playerPos, TileType.Floor); SetTile(nextPos, TileType.Player); return true; } return false; // 墙、越界等无效移动 }关键设计点说明FindPlayerPosition()每次遍历二维数组不GameBoard内部缓存了playerX,playerY字段O(1) 获取SetTile()不直接操作数组而是调用UpdateTile(x, y, newType)该方法会触发TileChanged事件供 UI 订阅刷新所有坐标运算用Vector2封装MoveDirection.Up.ToVector2()返回(0,-1)避免魔法数字。3.2 GamePanel自绘控件GDI 绘制每一帧GamePanel.cs继承Panel重写OnPaint()实现逐像素绘制。它不处理任何游戏逻辑只做三件事监听GameBoard.TileChanged事件、维护局部脏矩形、调用Graphics.DrawImage()渲染贴图。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_board null) return; var g e.Graphics; g.Clear(Color.FromArgb(240, 240, 240)); // 浅灰背景 // 遍历地图按 tile 类型绘制对应图片 for (int y 0; y _board.Height; y) { for (int x 0; x _board.Width; x) { var tile _board.GetTileAt(x, y); var rect new Rectangle(x * TileSize, y * TileSize, TileSize, TileSize); switch (tile) { case TileType.Wall: g.FillRectangle(Brushes.DarkGray, rect); break; case TileType.Player: g.DrawImage(_playerImg, rect); break; case TileType.Box: g.DrawImage(_boxImg, rect); break; case TileType.Target: g.DrawImage(_targetImg, rect); break; // ... 其他类型 } } } }性能优化细节_playerImg,_boxImg等位图在GamePanel构造函数中一次性Image.FromFile()加载避免 OnPaint 中重复 IOTileSize 48是硬编码值但所有绘制基于此缩放修改此处即可全局调整分辨率若地图很大如 50x50可改用Bitmap双缓冲先画到内存 Bitmap再g.DrawImage()一次刷出避免闪烁。3.3 输入响应键盘事件如何精准映射到 MoveDirectionMainForm.cs中注册KeyDown事件但不直接调用GameBoard.MovePlayer()而是通过委托解耦private void MainForm_KeyDown(object sender, KeyEventArgs e) { MoveDirection? dir e.KeyCode switch { Keys.Up or Keys.W MoveDirection.Up, Keys.Down or Keys.S MoveDirection.Down, Keys.Left or Keys.A MoveDirection.Left, Keys.Right or Keys.D MoveDirection.Right, _ null }; if (dir.HasValue _gameBoard ! null) { // 发布事件由 GamePanel 订阅并触发 MovePlayer MoveRequested?.Invoke(this, new MoveEventArgs(dir.Value)); } }GamePanel在构造函数中订阅该事件public GamePanel() { InitializeComponent(); ((MainForm)this.FindForm()).MoveRequested OnMoveRequested; } private void OnMoveRequested(object sender, MoveEventArgs e) { if (_gameBoard.MovePlayer(e.Direction)) { // 移动成功播放音效、刷新面板 SystemSounds.Asterisk.Play(); // 系统音效免资源依赖 this.Invalidate(); // 触发重绘 } }这种事件驱动模式让输入逻辑与游戏逻辑完全分离测试GameBoard时可直接传入MoveDirection无需模拟键盘。3.4 关卡加载器 LevelLoader从 .txt 到 BoardState 的转换规则LevelLoader.LoadFromText(string path)是文本解析的核心。它不使用 JSON/XML坚持纯文本因为推箱子关卡本质就是字符矩阵。解析逻辑分三步读取并校验格式var lines File.ReadAllLines(path).Where(l !string.IsNullOrWhiteSpace(l)).ToArray(); if (lines.Length 0) throw new ArgumentException(关卡文件为空); int width lines.Max(l l.Length); int height lines.Length;构建初始 BoardStatevar board new BoardState(width, height); for (int y 0; y height; y) { for (int x 0; x lines[y].Length; x) { char c lines[y][x]; TileType type c switch { # TileType.Wall, TileType.Floor, { board.PlayerX x; board.PlayerY y; TileType.Player; }, $ { board.AddBox(x, y); TileType.Box; }, . { board.AddTarget(x, y); TileType.Target; }, * { board.AddBox(x, y); board.AddTarget(x, y); TileType.BoxOnTarget; }, { board.PlayerX x; board.PlayerY y; board.AddTarget(x, y); TileType.PlayerOnTarget; }, _ throw new InvalidDataException($未知字符 {c} 在 ({x},{y})) }; board.SetTile(x, y, type); } }终局校验确保箱子数 目标点数否则抛异常if (board.BoxCount ! board.TargetCount) throw new InvalidOperationException($关卡无效箱子数({board.BoxCount}) ≠ 目标点数({board.TargetCount}));这种强校验让关卡作者在保存.txt时就能发现逻辑错误比运行时崩溃友好得多。4. 常见问题排查5 条血泪经验总结4.1 现象程序启动后黑屏或只显示灰色背景无地图原因GamePanel未正确加载Assets/maps/level_01.txt导致GameBoard初始化失败_board为 nullOnPaint()中跳过绘制。解决在MainForm_Load中加断点检查LoadInitialLevel()是否抛出FileNotFoundException确认Assets/文件夹与.exe同级不是子目录嵌套右键Assets文件夹 → 属性 → “复制到输出目录” 设为“始终复制”。4.2 现象玩家能移动但箱子推不动或推一次后卡死原因MovePlayer()中箱子后方检测逻辑有误。常见错误是boxNextPos坐标计算用了playerPos direction而非nextPos direction导致判断位置偏移一格。解决在MovePlayer()开头加日志Debug.WriteLine($玩家在({playerPos.X},{playerPos.Y}), 方向{direction}, 下一格({nextPos.X},{nextPos.Y}), 箱子后方({boxNextPos.X},{boxNextPos.Y}));对比实际地图坐标修正boxNextPos计算。4.3 现象按下方向键无反应但 AltF4 能退出原因MainForm的KeyPreview属性为false默认值导致KeyDown事件无法捕获到按键。解决在MainForm.Designer.cs的InitializeComponent()中找到this.KeyPreview false;改为this.KeyPreview true;或在MainForm_Load中写this.KeyPreview true;。4.4 现象地图显示错位墙和箱子重叠或部分区域空白原因GamePanel.TileSize与关卡文本的字符宽度不匹配。例如TileSize48但level_01.txt每行长度不一致混入 Tab 或中文空格导致x * TileSize计算偏移。解决用记事本打开关卡文件另存为 UTF-8 无 BOM 格式删除所有 Tab用空格对齐在LevelLoader解析时强制补空格for (int i 0; i lines.Length; i) lines[i] lines[i].PadRight(width, );4.5 现象切换关卡后旧关卡残留如上一关的箱子还在原因GameBoard实例未重建LevelLoader.LoadFromText()返回新实例但GamePanel仍引用旧实例且未重置_board字段。解决在MainForm中维护_currentBoard字段切换关卡时_currentBoard LevelLoader.LoadFromText(newPath); _gamePanel.Board _currentBoard; // GamePanel 需暴露 Board 属性 _gamePanel.Invalidate();并在GamePanel中添加public GameBoard Board { get _board; set { _board value; this.Invalidate(); // 强制重绘 } }5. 二次开发实战给推箱子加“撤销步数”功能含完整代码5.1 功能定位与设计约束推箱子是典型的“状态不可逆”游戏但玩家常因一步失误重开整关。加撤销Undo不是简单存上一帧画面而是要记录可逆的操作序列。我们采用“命令模式Command Pattern”每个移动操作封装为MoveCommand支持Execute()和Undo()。约束条件最多存 50 步防内存爆炸不存储完整BoardState太重只存玩家位置、被推箱子位置、推的方向撤销后GameBoard必须能精确还原到上一状态。5.2 核心类实现MoveCommand 与 CommandStack在Core/下新建Commands/文件夹添加MoveCommand.cspublic class MoveCommand { public MoveDirection Direction { get; } public int PlayerXBefore { get; } public int PlayerYBefore { get; } public int PlayerXAfter { get; } public int PlayerYAfter { get; } public int? BoxXBefore { get; } // null 表示未推箱子 public int? BoxYBefore { get; } public int? BoxXAfter { get; } public int? BoxYAfter { get; } public MoveCommand(MoveDirection direction, int pxBefore, int pyBefore, int pxAfter, int pyAfter, int? bxBefore null, int? byBefore null, int? bxAfter null, int? byAfter null) { Direction direction; PlayerXBefore pxBefore; PlayerYBefore pyBefore; PlayerXAfter pxAfter; PlayerYAfter pyAfter; BoxXBefore bxBefore; BoxYBefore byBefore; BoxXAfter bxAfter; BoxYAfter byAfter; } public void Execute(GameBoard board) { // 撤销时调用此方法恢复状态所以这里不做操作 } public void Undo(GameBoard board) { // 1. 恢复玩家位置 board.SetTile(PlayerXAfter, PlayerYAfter, TileType.Floor); board.SetTile(PlayerXBefore, PlayerYBefore, board.GetTileAt(PlayerXBefore, PlayerYBefore) TileType.Target ? TileType.PlayerOnTarget : TileType.Player); board.PlayerX PlayerXBefore; board.PlayerY PlayerYBefore; // 2. 若推了箱子恢复箱子位置 if (BoxXBefore.HasValue) { // 清除箱子在 After 位置的状态 var afterTile board.GetTileAt(BoxXAfter.Value, BoxYAfter.Value); if (afterTile TileType.BoxOnTarget) board.SetTile(BoxXAfter.Value, BoxYAfter.Value, TileType.Target); else board.SetTile(BoxXAfter.Value, BoxYAfter.Value, TileType.Floor); // 设置箱子在 Before 位置 var beforeTile board.GetTileAt(BoxXBefore.Value, BoxYBefore.Value); if (beforeTile TileType.Target) board.SetTile(BoxXBefore.Value, BoxYBefore.Value, TileType.BoxOnTarget); else board.SetTile(BoxXBefore.Value, BoxYBefore.Value, TileType.Box); // 更新箱子列表GameBoard 内部维护 List(int,int) Boxes board.UpdateBoxPosition(BoxXAfter.Value, BoxYAfter.Value, BoxXBefore.Value, BoxYBefore.Value); } } }再建CommandStack.cs管理栈public class CommandStack { private readonly StackMoveCommand _undoStack new(); private readonly int _maxSteps 50; public void Push(MoveCommand cmd) { _undoStack.Push(cmd); if (_undoStack.Count _maxSteps) _undoStack.Pop(); // 丢弃最老的 } public bool CanUndo() _undoStack.Count 0; public void Undo(GameBoard board) { if (_undoStack.Count 0) return; var cmd _undoStack.Pop(); cmd.Undo(board); } }5.3 集成到 GameBoard拦截移动并记录命令修改GameBoard.MovePlayer()在成功移动后生成命令并推入栈// GameBoard.cs 新增字段 private readonly CommandStack _commandStack new(); // 修改 MovePlayer 方法末尾 if (movedSuccessfully) { // 构建命令若推了箱子记录箱子位置变化 var cmd new MoveCommand(direction, playerPos.X, playerPos.Y, nextPos.X, nextPos.Y, boxMoved ? (int?)nextPos.X : null, boxMoved ? (int?)nextPos.Y : null, boxMoved ? (int?)boxNextPos.X : null, boxMoved ? (int?)boxNextPos.Y : null); _commandStack.Push(cmd); return true; }5.4 UI 层绑定CtrlZ 触发撤销在MainForm_KeyDown中追加if (e.Control e.KeyCode Keys.Z) { e.SuppressKeyPress true; // 阻止系统响声 if (_gameBoard.CommandStack.CanUndo()) { _gameBoard.CommandStack.Undo(_gameBoard); _gamePanel.Invalidate(); // 可选播放撤销音效 SystemSounds.Beep.Play(); } }5.5 验证与边界测试测试 1基础撤销推一个箱子 → CtrlZ → 箱子回原位玩家回原位测试 2连推多箱推箱A → 推箱B → CtrlZ → 箱B回位再 CtrlZ → 箱A回位测试 3边界连续移动 51 步 → 第 1 步应被自动丢弃第 51 步可撤销测试 4状态一致性撤销后调用_gameBoard.IsSolved()结果应与撤销前一致即未达成胜利。从那以后我每次加 Undo 功能都强制走一遍“推-撤-推-撤”循环再用Debug.WriteLine打印BoardState.ToString()对比内存快照。这招帮我揪出过三次BoxXBefore传错参数的 bug。希望帮到你。本文还有配套的精品资源点击获取