简介:本资源是一份基于C#语言实现的经典推箱子游戏完整源码,面向C#初学者、游戏开发入门者及希望掌握Godot引擎与桌面益智游戏设计逻辑的开发者。项目采用面向对象设计,涵盖关卡管理、角色控制、碰撞判定与状态渲染等核心模块,可作为游戏逻辑架构与跨文件协作的优质学习范例。压缩包共36个文件(132KB),含13个C#脚本(如Player.cs、Crate.cs、Level.cs)、10个TSCN场景文件(定义关卡布局与节点结构)、1个Godot项目文件(project.godot)及配套资源(PNG图集、SVG图标、JSON配置、CSProj与SLN工程文件等),体现典型Godot+C#混合开发项目组织方式。已有93人学习下载,代码结构清晰、注释规范,附带license.md许可说明与readme.txt使用指引,便于快速编译运行、调试修改并迁移至同类益智游戏开发实践。
1. 为什么一个“过时”的推箱子游戏,至今仍是C#初学者绕不开的实战入口?
你可能在B站看到过这样的视频标题:“300行C#代码写完推箱子,面试官当场要走源码”;也可能在GitHub上刷到过star破千的仓库,README第一行写着“纯WinForms实现,零第三方依赖”。这不是怀旧——而是因为基于C#语言的经典推箱子游戏设计源码,是少数几个能同时锤炼UI事件流控制、状态机建模、地图序列化、键盘响应优化、撤销/重做机制落地这五项硬核能力的轻量级项目。它不依赖Unity或WPF,用原生WinForms就能跑通完整逻辑闭环;它不靠AI生成关卡,但要求你亲手解析TXT地图文件、校验可解性、管理玩家步数与历史栈;它甚至会逼你直面GDI+绘图性能瓶颈——当玩家连按方向键时,窗体闪烁、响应延迟、按键吞没,这些不是Bug,是Windows消息泵与双缓冲没配对的真实反馈。如果你正准备C#上位机开发岗、想补足桌面端工程手感、或需要一份能放进简历里“可现场演示+可解释每行逻辑”的作品,这个项目不是练手玩具,而是你技术肌肉的校准器。
2. 从零搭建:用WinForms构建可运行的推箱子骨架
推箱子不是画布上摆几个方块那么简单。它的核心在于状态驱动渲染:玩家位置、箱子坐标、目标点集合、墙壁布局,全部必须以结构化数据承载,并在每次按键后原子性更新、校验、重绘。WinForms虽老,但恰恰适合这种确定性强、交互路径清晰的场景——没有MVVM绑定开销,没有XAML解析延迟,所有逻辑都在Form类里可控流转。
2.1 地图数据结构设计:用二维字符数组还是自定义类?
常见做法是读取.txt地图文件(如level1.txt),每行代表一行格子,字符含义约定为:
#:墙- (空格):空地
@:玩家初始位置$:箱子.:目标点+:玩家在目标点上*:箱子在目标点上
但直接用char[,]二维数组会带来三个隐患:
- 无法携带元信息(如该格是否可通行、是否为终点);
- 修改时易越界(
map[x][y] = 'X'前需反复检查x,y合法性); - 无法扩展(后续加“冰面”“传送门”等新地形时需改遍所有判断逻辑)。
我一般会封装为Tile类:
public enum TileType { Wall, Floor, Player, Box, Target, PlayerOnTarget, BoxOnTarget } public class Tile { public TileType Type { get; set; } public bool IsTarget { get; set; } // 是否为关卡目标点 public bool IsOccupied { get; set; } // 是否被玩家/箱子占据 }再用List<List<Tile>>替代char[,]——虽然内存稍增,但IsTarget字段让“是否完成关卡”的判定从遍历所有$和.变成boxes.All(b => b.IsOnTarget),逻辑更直白,后期加特效(如目标点亮动画)也只需监听IsTarget变化。
提示:不要用
Dictionary<Point, Tile>存地图。Point作为Key在频繁移动时会产生大量GC压力,且无法按行列索引,遍历时失去空间局部性。
2.2 玩家移动逻辑:事件驱动下的状态迁移
WinForms中,KeyDown事件是唯一可靠入口(KeyPress不捕获方向键,PreviewKeyDown又太底层)。关键不是“怎么移动”,而是如何保证移动原子性——即:一次按键,要么完整执行(玩家移位+箱子推移+状态更新),要么完全不执行(撞墙/推不动)。
private void GameForm_KeyDown(object sender, KeyEventArgs e) { if (_isMoving || _isSolving) return; // 防止连按导致状态错乱 var direction = GetDirectionFromKey(e.KeyCode); if (direction == Direction.None) return; var nextPlayerPos = _playerPosition + direction; var nextBoxPos = nextPlayerPos + direction; // 1. 检查玩家下一步是否可通行 if (!IsWalkable(nextPlayerPos)) return; // 2. 检查前方是否有箱子,且箱子能否被推动 if (GetTileAt(nextPlayerPos).Type == TileType.Box) { if (!IsWalkable(nextBoxPos)) return; // 箱子前方是墙或另一箱子,推不动 } // 3. 执行移动(此处开始修改状态) _isMoving = true; MovePlayer(direction); if (GetTileAt(_playerPosition - direction).Type == TileType.Box) { MoveBox(direction); } UpdateGameState(); _isMoving = false; }注意三点:
_isMoving开关防止按键抖动导致多次触发;IsWalkable()需同时检查Wall和Box(箱子本身不可通行);MovePlayer()和MoveBox()必须先计算新坐标,再批量更新Tile对象的Type和IsOccupied,避免中间态渲染出错。
2.3 双缓冲绘图:解决WinForms经典闪烁问题
默认Paint事件直接调用Graphics.DrawImage会导致严重闪烁。必须启用双缓冲并接管绘制流程:
public GameForm() { InitializeComponent(); SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint, true); UpdateStyles(); } protected override void OnPaint(PaintEventArgs e) { // 创建离屏Bitmap if (_offscreenBitmap == null || _offscreenBitmap.Width != ClientSize.Width || _offscreenBitmap.Height != ClientSize.Height) { _offscreenBitmap?.Dispose(); _offscreenBitmap = new Bitmap(ClientSize.Width, ClientSize.Height); } using (var g = Graphics.FromImage(_offscreenBitmap)) { DrawBackground(g); DrawTiles(g); DrawPlayer(g); DrawBoxes(g); } e.Graphics.DrawImage(_offscreenBitmap, Point.Empty); }关键参数说明:
OptimizedDoubleBuffer:启用系统级双缓冲,比手动CreateGraphics()更稳定;ResizeRedraw:窗体缩放时自动重绘,避免拉伸撕裂;AllPaintingInWmPaint:禁止WM_ERASEBKGND消息,防止背景擦除与绘图竞争;- 离屏Bitmap复用:避免每帧都
new Bitmap(),否则GC压力暴增。
3. 关卡系统:从TXT解析到可解性验证的完整链路
推箱子的趣味性70%来自关卡设计。但若只加载TXT就开玩,玩家可能卡在无解关卡里——这不是挑战,是挫败。必须在加载阶段完成静态可解性预检,这是专业级源码与玩具代码的分水岭。
3.1 TXT地图解析:支持多关卡打包与注释语法
标准格式应兼容主流推箱子关卡库(如Sokoban YASC),允许注释与空行:
; Level 1: Simple start ##### # @ # # $ # # . # #####解析器需跳过;开头行和纯空行,并记录关卡标题、作者、步数限制(如有):
public class LevelLoader { public static List<Level> LoadFromText(string text) { var levels = new List<Level>(); var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); var currentLevelLines = new List<string>(); foreach (var line in lines) { var trimmed = line.Trim(); if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith(";")) continue; if (trimmed.StartsWith("Level:")) // 扩展头信息 { if (currentLevelLines.Count > 0) { levels.Add(ParseLevel(currentLevelLines)); currentLevelLines.Clear(); } continue; } currentLevelLines.Add(trimmed); } if (currentLevelLines.Count > 0) levels.Add(ParseLevel(currentLevelLines)); return levels; } }注意:
ParseLevel()中需校验@和$数量是否匹配.数量(否则必然无解),并提取所有Target坐标存入Level.Targets集合,供后续验证使用。
3.2 可解性验证:用BFS穷举所有箱子组合态
真正可靠的验证不是“玩家能不能走到箱子后”,而是是否存在一串操作,使所有箱子最终落在目标点上。由于箱子数量通常≤10,可用BFS遍历所有箱子位置组合(状态空间≈width×height的boxCount次方):
public bool IsSolvable() { var initialBoxes = _tiles.Where(t => t.Type == TileType.Box).Select(t => t.Position).ToList(); var targets = _targets.ToList(); var visited = new HashSet<string>(); var queue = new Queue<(List<Point> boxes, int steps)>(); queue.Enqueue((initialBoxes, 0)); while (queue.Count > 0) { var (boxes, steps) = queue.Dequeue(); var stateKey = string.Join(",", boxes.OrderBy(p => p.X).ThenBy(p => p.Y)); if (visited.Contains(stateKey)) continue; visited.Add(stateKey); // 检查是否全部归位 if (boxes.Count == targets.Count && boxes.All(b => targets.Contains(b))) return true; // 尝试推动每个箱子(需玩家能到达其后方) foreach (var box in boxes) { foreach (var dir in Directions) { var behindBox = box - dir; // 玩家需在此位置 var newBoxPos = box + dir; if (!IsWalkable(behindBox) || !IsWalkable(newBoxPos) || boxes.Contains(newBoxPos)) // 新位置已被其他箱子占据 continue; var newBoxes = boxes.Select(b => b == box ? newBoxPos : b).ToList(); queue.Enqueue((newBoxes, steps + 1)); } } } return false; }参数说明:
IsWalkable():仅检查Wall和Box,不检查玩家位置(因BFS中玩家位置隐含在“能到达箱子后方”这一条件里);stateKey用排序后坐标拼接,避免(1,2),(3,4)与(3,4),(1,2)被当作不同状态;- 步数限制
steps < 1000可加防死循环(实际关卡极少超500步)。
3.3 撤销/重做栈:用Command模式管理操作历史
Ctrl+Z不是简单存playerPos,而是保存操作指令本身。Command模式让撤销/重做与业务逻辑解耦:
public abstract class GameCommand { public abstract void Execute(); public abstract void Undo(); } public class MovePlayerCommand : GameCommand { private readonly Point _oldPos; private readonly Point _newPos; private readonly GameContext _context; public MovePlayerCommand(GameContext context, Point oldPos, Point newPos) { _context = context; _oldPos = oldPos; _newPos = newPos; } public override void Execute() => _context.PlayerPosition = _newPos; public override void Undo() => _context.PlayerPosition = _oldPos; } // 使用时 _commandHistory.Push(new MovePlayerCommand(_context, oldPos, newPos));优势:
- 后期加“重力推箱”“磁力吸附”等新规则时,只需新增
Command子类,不改主逻辑; Undo()可精确回退到任意历史点,而非仅上一步;- 栈大小可控(如只存最近50步),避免内存爆炸。
4. 避坑指南:C#推箱子开发中90%新手踩过的5个深坑
这些不是教科书里的“注意事项”,而是我在带实习生、Code Review上百份作业后,总结出的血泪经验。每一个都曾导致整晚调试无果,甚至推翻重写。
4.1 现象:玩家按键后画面卡住,但后台逻辑仍在运行
原因:KeyDown事件中执行了耗时操作(如未优化的BFS验证、大地图重绘),阻塞UI线程,导致Paint消息无法派发。
解决:将可解性验证、地图序列化等操作移到BackgroundWorker或Task.Run(),UI线程只做状态变更与Invalidate()触发重绘。切记:Invalidate()后立即Application.DoEvents()是饮鸩止渴,会引发重入问题。
4.2 现象:箱子推到目标点后,再次推动时“消失”或“穿透”
原因:Tile对象复用错误。例如将BoxOnTarget误设为Box,导致IsWalkable()返回true(因Box类型未被判定为障碍),箱子被推到非目标点却仍显示为BoxOnTarget。
解决:为TileType添加IsSolid属性(Wall/Box/BoxOnTarget为true),所有通行判断统一调用tile.IsSolid,杜绝类型枚举值误判。
4.3 现象:窗体最小化后再恢复,地图错位或部分不显示
原因:未处理WM_PAINT消息丢失。WinForms在最小化时会丢弃无效区域,若OnPaint中未覆盖整个ClientRectangle,恢复后只重绘上次Invalidate()区域。
解决:OnPaint开头强制e.Graphics.FillRectangle(Brushes.White, ClientRectangle)清屏;或重写WndProc捕获WM_NCPAINT确保非客户区重绘。
4.4 现象:多关卡切换时,内存占用持续上涨,最终OOM
原因:Bitmap、Font、Brush等GDI+资源未释放。尤其DrawString()创建的Font若未Dispose(),会累积GDI句柄泄漏。
解决:所有IDisposable对象用using包裹;全局资源(如字体)在FormClosed事件中统一释放;禁用GC.Collect()强行回收——它治标不治本。
4.5 现象:键盘连按方向键,玩家移动“跳跃式”前进(如一次按→,玩家瞬移3格)
原因:Windows默认键盘重复延迟(KeyboardDelay)与重复速率(KeyboardSpeed)未适配游戏节奏。KeyDown事件在长按后会高频触发,而_isMoving标志未覆盖此场景。
解决:在KeyDown中启动Timer(Interval=100ms),首次触发执行移动,后续触发忽略;松开KeyUp时停止Timer。或改用GetAsyncKeyState()轮询(需P/Invoke),但WinForms中更推荐Timer方案。
5. 进阶技巧:让推箱子不止于“能玩”,而成为可交付的工程样板
做到能通关只是起点。真正的价值在于把推箱子变成可测试、可配置、可扩展的桌面应用范式。以下三个技巧,是我给团队新人的“后悔药”清单——早用早省三天debug时间。
5.1 用JSON配置关卡元数据,解耦逻辑与内容
硬编码关卡路径(LoadLevel("level1.txt"))会让测试和迭代痛苦。改为JSON描述关卡包:
{ "package": "classic", "levels": [ { "id": 1, "name": "入门练习", "file": "levels/classic/001.txt", "author": "Sokoban Original", "maxSteps": 25, "difficulty": 1 } ] }加载时用System.Text.Json反序列化:
public class LevelPackage { public string Package { get; set; } public List<LevelMeta> Levels { get; set; } } public class LevelMeta { public int Id { get; set; } public string Name { get; set; } public string File { get; set; } public int MaxSteps { get; set; } public int Difficulty { get; set; } }好处:
- 测试时可快速切换关卡集(如
test.json只含3个边界case); - 发布时打包
levels/目录即可,无需编译进exe; - 后期加成就靠
Difficulty字段驱动,不用改代码。
5.2 实现自动化测试:用NUnit验证核心算法
别信“肉眼测通关”。为IsSolvable()、MoveBox()等关键方法写单元测试:
[Test] public void IsSolvable_ReturnsTrue_ForTrivialLevel() { // Arrange var level = LevelLoader.LoadFromText("#\n#@$\n#.#\n#"); // Act var result = level.IsSolvable(); // Assert Assert.IsTrue(result); } [Test] public void MoveBox_PushesBoxToTarget_WhenValid() { // Arrange var level = CreateTestLevelWithBoxAndTarget(); var originalBoxPos = level.GetBoxPositions().First(); // Act level.MovePlayer(Direction.Right); // 推向目标 // Assert var newBoxPos = level.GetBoxPositions().First(); Assert.AreEqual(new Point(originalBoxPos.X + 1, originalBoxPos.Y), newBoxPos); Assert.IsTrue(level.IsBoxOnTarget(newBoxPos)); }提示:测试用例优先覆盖“箱子推到墙角卡死”“玩家被箱子围困”“多箱子连锁推动”三类边界场景,比测正常流程更重要。
5.3 性能监控面板:实时显示帧率与GC压力
WinForms应用常被质疑“性能差”。加个右下角小面板,用Stopwatch测OnPaint耗时,用GC.CollectionCount(0)看Gen0回收频次:
private void UpdatePerformancePanel() { var paintTime = _paintStopwatch.ElapsedMilliseconds; var gc0 = GC.CollectionCount(0); _perfLabel.Text = $"FPS:{(int)(1000f / Math.Max(paintTime, 1))} | " + $"GC0:{gc0} | " + $"Mem:{Process.GetCurrentProcess().WorkingSet64 / 1024 / 1024}MB"; }当FPS < 30或GC0突增时,立刻定位到DrawTiles()中未缓存的Bitmap创建,或Command栈未限制长度——数据比感觉更可信。
我带过的实习生,凡是在推箱子项目里加了这三招的,后续做上位机串口通信、PLC数据采集时,架构意识和问题定位速度明显高出一截。因为推箱子逼你直面状态一致性、资源生命周期、人机交互时序这三大桌面开发本质命题。它不炫技,但每行代码都在回答:“用户此刻看到的,是不是我代码里定义的那个世界?”
希望帮到你。
本文还有配套的精品资源,点击获取