资讯动态

C#推箱子游戏开发实战:从WinForms到核心算法完整解析

发布时间:2026/10/17 6:58:34 来源:尧图企业网站定制
简介这是一份C#编写的推箱子小游戏完整源代码包适合C#初学者、课程设计或对经典游戏实现感兴趣的开发者学习与二次开发。游戏功能较为全面支持方向键移动、CtrlZ撤销、CtrlY重做、选关、存盘/读盘并可通过level.way文件保存通关步骤用于回放演示或分享给朋友同时提供关卡自定义入口方便玩家自行设计新地图。包内共76个文件压缩包约135KB主要包括10个CS源码文件、13个DAT地图/存档数据、15个WAY通关记录、11个BMP素材图片、可执行程序以及sln/csproj工程文件CS文件覆盖窗体与游戏逻辑BMP和DAT支撑界面与地图数据目录清晰便于直接打开工程调试运行。已有961人下载学习。对自动寻路、鼠标操作等未完成扩展方向可结合深度优先搜索、链表与栈等知识进阶尝试是理解经典益智游戏实现流程的好素材。1. 谁是C#推箱子一个迷宫、十个关卡与回溯后悔药有一类项目是C#开发者绕不开的入门试炼C#推箱子小游戏就是其中最具代表性的一个——不用任何游戏引擎仅靠WinForms和标准库把经典推箱子做成一个能跑、能玩、能自己加关卡的桌面程序。这个项目通常在1500行左右却能把二维数组、状态机、键盘事件、双缓冲渲染、撤销栈这些基本功全部串起来。如果你正想找一个能放进简历、能拿给同事评审的练手项目或者需要一个给新人练手又不至于太简单的游戏源码它都合适。本文从数据模型拆到渲染链路再从踩坑记录讲到二次开发看完你能完整复现也能按自己的需求改出花来。2. 数据模型与关卡系统从txt字符到地图数组动手写UI之前先把推箱子抽象成一个数据问题。整个游戏在内存里的核心状态只有三样一张二维地图、一组目标点坐标、一个玩家坐标。地图上每个格子是什么类型用枚举来表示这是后续所有逻辑的地基。2.1 目录结构与文件职责项目拿到手第一件事是把结构看明白。一个简洁的推箱子项目通常长这样Sokoban/ ├── Program.cs // 入口Application.Run 启动主窗体 ├── Game/ │ ├── GameMap.cs // 地图数据 移动判定 胜负判定 │ ├── MoveRecord.cs // 撤销用的历史记录结构 │ └── LevelParser.cs // 把 txt 关卡文件解析成 GameMap ├── UI/ │ └── MainForm.cs // 主窗体负责键盘事件与画面渲染 └── Levels/ ├── level01.txt ├── level02.txt └── ...这个分层的关键在于GameMap.cs不引用任何 WinForms 类型。它只处理数组、枚举和坐标所有 UI 相关的逻辑全部收敛在MainForm里。这样设计的直接好处是如果哪一天你想把界面从 WinForms 换成 WPF 或者做一个 Unity 版本Game目录下的代码原封不动就能搬过去。我见过不少新手把地图数组直接写在窗体类里按键处理和移动逻辑揉在一起最后想加个关卡选择功能时发现连Map的构造函数都没法独立调用只能推倒重来。所以从第一个版本起就坚持逻辑层和表现层分离。2.2 关卡存储从txt文件到Tile地图关卡文件是纯文本的方格布局用不同的字符代表不同的物体这是推箱子社区约定俗成的格式。一个小关卡长这样####### # # # . # # $ # # # #######字符到游戏元素的对应关系# 墙 (Wall) 地板 (Floor空格) $ 箱子 (Box) . 目标点 (Target) 玩家 (Player)解析的代码不复杂但要处理好一个字符对应一个格子的对齐问题。关卡每一行长度可能不一致读取时需要对短行补空格public static GameMap LoadFromFile(string filePath) { // 读取所有行注意用 UTF-8防止 Windows 默认编码带来的中文处理问题 string[] lines File.ReadAllLines(filePath, Encoding.UTF8); // 找最长的行作为地图列数行数就是数组的行数 int rows lines.Length; int cols lines.Max(l l.Length); var tiles new Tile[rows, cols]; var targets new List(int Row, int Col)(); for (int r 0; r rows; r) { string line lines[r]; for (int c 0; c cols; c) { // 短行后面的空格统一当成 Floor这就是补全逻辑 char ch c line.Length ? line[c] : ; switch (ch) { case #: tiles[r, c] Tile.Wall; break; case $: tiles[r, c] Tile.Box; break; case .: tiles[r, c] Tile.Target; targets.Add((r, c)); break; case : tiles[r, c] Tile.Player; break; default: tiles[r, c] Tile.Floor; break; } } } return new GameMap(tiles, targets); }解析时有两个容易翻车的点。一是关卡文件里如果混入了\r\n换行某些字符读取时会带出多余的\r导致每个格子解析错位。二是 short line 补空格的逻辑不能省略——我见过一个关卡文件某一行少写了一个空格结果玩家可以穿墙走到不存在的格子上。解析完成后GameMap内部持有Tile[,]二维数组和targets列表这两个字段就是后续所有逻辑的输入。Tile枚举本身也值得设计得稍微超前一点。我用byte作为底层类型public enum Tile : byte { Wall 0, Floor 1, Box 2, Target 3, Player 4 }byte底层类型在存储上更紧凑虽然对这个小游戏影响可以忽略但从一开始养成好习惯后面如果做关卡批量加载内存优势就体现出来了。2.3 移动判定与胜负判定推箱子的核心规则推箱子的移动规则只有三条玩家可以走到空地和目标点玩家可以推动箱子但箱子前方必须是空地或目标点玩家不能穿墙箱子也不能被推进墙里。这三条规则用代码写出来比用文字描述还要直观。我一般把移动和一个贪心式的边界检查写在一起所有判定都放在GameMap.TryMove里完成public bool TryMove(int dr, int dc) { int pr playerRow, pc playerCol; int nr pr dr, nc pc dc; // 出界地图边界是最可靠的墙先挡住 if (nr 0 || nr rows || nc 0 || nc cols) return false; // 前方就是墙直接失败 if (tiles[nr, nc] Tile.Wall) return false; // 前方是箱子继续判断箱子的下一个格子 if (tiles[nr, nc] Tile.Box) { int br nr dr, bc nc dc; // 箱子前方出界、是墙、或者是另一个箱子都推不动 if (br 0 || br rows || bc 0 || bc cols) return false; if (tiles[br, bc] Tile.Wall || tiles[br, bc] Tile.Box) return false; // 箱子可以被推动先更新箱子的位置再更新玩家位置 tiles[br, bc] Tile.Box; tiles[nr, nc] Tile.Floor; } // 更新玩家位置原位置变成地板新位置变成玩家 tiles[pr, pc] Tile.Floor; tiles[nr, nc] Tile.Player; (playerRow, playerCol) (nr, nc); return true; }参数dr和dc是从按键映射出来的方向向量比如按 Up 时dr -1, dc 0按 Right 时dr 0, dc 1。这种用方向向量驱动移动的方式让移动逻辑完全和具体按键解耦后面如果要做触屏虚拟方向键或手柄输入直接生成方向向量就能复用整套移动代码。胜负判定更简单只需要检查所有目标点是否都被箱子覆盖public bool IsWin() { foreach (var (r, c) in targets) { if (tiles[r, c] ! Tile.Box) return false; } return true; }这里有个容易忽略的性能点IsWin每次移动后都可以调用但不用每帧调用。因为一步移动最多改变两个格子理论上可以增量判断但推箱子关卡很小通常不超过 20×20全量遍历的开销微乎其微完全不需要优化。3. 核心模块拆解一次推箱子的完整生命周期规则层写完接下来看一次玩家操作从按下按键到画面更新到底经过了哪些环节。这个链路在推箱子项目里非常清晰理解了它WinForms 游戏的大体框架也就理解了。3.1 事件入口键盘按下到移动开始WinForms 的键盘响应有两种方式给控件挂KeyDown事件或者重写窗体的ProcessCmdKey方法。对于方向键直接用KeyDown有一个坑——方向键在某些情况下会被焦点控件拦截导致事件不触发。保险的做法是重写ProcessCmdKeyprotected override bool ProcessCmdKey(ref Message msg, Keys keyData) { int dr 0, dc 0; switch (keyData) { case Keys.Up: dr -1; break; case Keys.Down: dr 1; break; case Keys.Left: dc -1; break; case Keys.Right: dc 1; break; case Keys.Z: Undo(); return true; // 追加的撤销快捷键 case Keys.R: RestartLevel(); return true; default: return base.ProcessCmdKey(ref msg, keyData); } if (dr ! 0 || dc ! 0) { Game.TryMove(dr, dc); if (Game.IsWin()) ShowWinMessage(); Invalidate(); // 通知窗体重新绘制 UpdateStepCounter(); return true; } return base.ProcessCmdKey(ref msg, keyData); }ProcessCmdKey在按键命令行输入前执行优先级比KeyDown高方向键在这里一定能被捕获到。Invalidate()是 WinForms 里触发重绘的经典做法——它只是把窗口区域标记为脏系统会在下一个空闲时刻自动调用OnPaint。如果你在按键处理里直接调Refresh()或者Update()会造成不必要的同步重绘画面反而容易闪烁。3.2 移动与撤销状态变更与后悔药TryMove成功之后地图状态已经改变但还没记录历史。如果玩家推错了箱子得允许回退。这一步我用一个栈来存每一步的变更记录每次移动时压栈撤销时弹栈回滚public readonly struct MoveRecord { public readonly int PlayerFromRow, PlayerFromCol; public readonly int PlayerToRow, PlayerToCol; public readonly int BoxFromRow, BoxFromCol; public readonly int BoxToRow, BoxToCol; public readonly bool PushedBox; public MoveRecord(int pfr, int pfc, int ptr, int ptc, bool pushed, int bfr, int bfc, int btr, int btc) { PlayerFromRow pfr; PlayerFromCol pfc; PlayerToRow ptr; PlayerToCol ptc; PushedBox pushed; BoxFromRow bfr; BoxFromCol bfc; BoxToRow btr; BoxToCol btc; } }在GameMap.TryMove里把记录历史单独摘出来做会更加规整。我一般这样做如果移动成功调用方再调用一次GetLastMoveRecord()来拿记录或者直接在TryMove内部压栈。内部压栈的好处是调用方不用关心什么时候记录坏处是GameMap多了状态。二选一看你习惯我倾向于在TryMove里直接压栈private readonly StackMoveRecord history new StackMoveRecord(); // 在 TryMove 里箱子被推动时记录箱子位置变化 // if (pushed) // history.Push(new MoveRecord(pr, pc, nr, nc, true, nr, nc, br, bc));撤销逻辑把记录倒过来执行一遍public bool Undo() { if (history.Count 0) return false; var rec history.Pop(); // 玩家回到原来的位置 tiles[rec.PlayerFromRow, rec.PlayerFromCol] Tile.Player; tiles[rec.PlayerToRow, rec.PlayerToCol] Tile.Floor; // 如果这一步推了箱子把箱子也拉回来 if (rec.PushedBox) { tiles[rec.BoxFromRow, rec.BoxFromCol] Tile.Box; tiles[rec.BoxToRow, rec.BoxToCol] Tile.Floor; } (playerRow, playerCol) (rec.PlayerFromRow, rec.PlayerFromCol); return true; }这里有一个细节值得注意撤销时重置玩家位置必须放在最后因为playerRow和playerCol是我们用来定位所有操作的基准坐标。先改tiles再改坐标顺序反了会导致格子赋值错乱。3.3 渲染与双缓冲从内存地图到窗口像素渲染层是让玩家感知到游戏存在的唯一途径。推箱子不需要复杂贴图用Graphics.FillRectangle画色块就足够但务必开启双缓冲否则画面会有明显闪烁。WinForms 开启双缓冲有两种方式。第一种是在构造函数里设置控件样式SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);第二种是把整个窗体的DoubleBuffered属性设为true。我推荐第一种因为OptimizedDoubleBuffer会把绘制内容先画到内存位图再一次性拷贝到屏幕闪烁问题从根源上消失。OnPaint里的绘制逻辑很直接遍历二维数组按格子类型填色protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; int cellSize 40; // 每个格子的像素大小可根据窗体尺寸调整 for (int r 0; r Game.Rows; r) { for (int c 0; c Game.Cols; c) { Rectangle rect new Rectangle(c * cellSize, r * cellSize, cellSize, cellSize); Brush brush Game.Tiles[r, c] switch { Tile.Wall Brushes.Gray, Tile.Floor Brushes.White, Tile.Target Brushes.LightYellow, Tile.Box Brushes.SaddleBrown, Tile.Player Brushes.Blue, _ Brushes.White }; g.FillRectangle(brush, rect); // 目标点上画一个空心圆提示玩家这里需要放箱子 if (Game.Tiles[r, c] Tile.Target) { g.DrawEllipse(Pens.DimGray, rect.X 8, rect.Y 8, 24, 24); } } } }代码里的switch表达式是 C# 8 之后引入的语法比传统switch-case简洁很多。cellSize决定棋盘在窗口里占多大面积关卡行数多时可以调小行数少时可以调大。绘制性能这块完全不用焦虑一个 20×20 的关卡最多 400 个格子每帧全部重绘耗时远小于 1 毫秒。这里也顺带给了后续扩展一个入口如果你想给箱子和玩家加图片只需要把FillRectangle换成g.DrawImage(image, rect)其他逻辑不需要改动。4. 避坑排查地图越界、死局陷阱与渲染闪烁写推箱子踩过的坑比想象中多有些问题在代码评审时一眼就能看出来有些则要实际运行十五分钟才暴露。这一节整理几个我反复踩过的雷每条都按现象→原因→解决来讲。4.1 数组越界推到地图外直接抛异常现象操作方向键时游戏突然抛IndexOutOfRangeException程序崩溃。如果关卡边缘正好没有墙这几乎是必然发生的事情。原因关卡文件的四周不一定是完整的墙。作者为了省字符可能只在有物体的地方画墙地图边界本身是开放的。而TryMove里如果先检查tiles[nr, nc]再检查边界就会访问到数组不存在的下标。解决在访问数组之前先做边界检查顺序不能反if (nr 0 || nr rows || nc 0 || nc cols) return false;这条规则对所有游戏通用——只要是二维数组做地图边界检查永远先于内容读取。我还额外在每个关卡文件的四周都补了完整的一圈墙一来符合推箱子的艺术风格二来让边界检查变成永不触发的保险丝。4.2 箱子死局合法移动但永远无解现象游戏没有崩溃玩家也一直在按规则移动但玩到某个状态后没有任何操作序列能把所有箱子推到目标点。只能重启关卡。原因推箱子的一个隐藏规则是箱子一旦被推进角落或者贴墙的死角比如墙和墙组成的L形区域就再也拉不出来了。因为箱子只能被推不能拉。这是规则层面的死局不是代码 bug。解决粗粒度的方案是在界面上提供 Restart 功能用一个重置按钮或 R 键恢复初始状态。细粒度的方案是写一个死局检测函数在每次移动后判断是否有箱子卡死提示玩家后悔药只剩几步。我一般会做一层简单的静态死局检测遍历所有箱子如果箱子紧贴墙角且不在目标点上就判定为死局并弹提示。public bool IsDeadLocked() { for (int r 0; r rows; r) for (int c 0; c cols; c) { if (tiles[r, c] ! Tile.Box) continue; // 检查箱子是否在一个没法推出来的墙角 bool topBlocked IsWallOrBorder(r - 1, c); bool leftBlocked IsWallOrBorder(r, c - 1); bool rightBlocked IsWallOrBorder(r, c 1); bool bottomBlocked IsWallOrBorder(r 1, c); // 两个相邻方向被堵住且箱子不在目标点上就是死局 if ((topBlocked leftBlocked) || (topBlocked rightBlocked) || (bottomBlocked leftBlocked) || (bottomBlocked rightBlocked)) { // 还需要再判断箱子本身不是目标点否则可能是过关状态 if (tiles[r, c] ! Tile.Box || !targets.Contains((r, c))) return true; } } return false; }注意这里的IsWallOrBorder把地图边界也当成墙处理——出界的位置本来就无法放置箱子这种位置被墙壁或者边界挡住了箱子确实推不出去。走向角检测算法不算完备有一些复杂的死局它识别不了但作为一款入门游戏的辅助提示已经足够。4.3 渲染闪烁画面跳动让人根本玩不下去现象每按一次方向键画面都像闪白一样跳一下玩久了眼睛酸。尤其当墙体颜色和窗口背景颜色差异大时更明显。原因WinForms 默认的Paint是先擦除背景再重绘这个过程如果不做双缓冲用户会看到背景色和绘制内容来回切换的中间帧。频繁按键时闪烁尤其严重。解决三种手段叠加使用。第一按本章 3.3 节的SetStyle开启双缓冲第二不要在KeyDown或ProcessCmdKey里调用Refresh()而是用Invalidate()让消息泵统一驱动重绘第三如果还闪可以在OnPaintBackground里不做事彻底取消背景擦除protected override void OnPaintBackground(PaintEventArgs e) { // 不调用 base.OnPaintBackground等于跳过背景擦除 // 所有内容统一由 OnPaint 绘制 }这样处理之后整个窗体只有OnPaint一个绘制入口彻底消除了闪烁。这个坑看起来小但体验影响极大属于不跑不知道跑了回不去的典型问题。4.4 关卡文件编码与换行符现象在项目里编写好英文关卡文件后一切正常但某个关卡文件被某次编辑保存成带 BOM 的 UTF-8 格式第一行第一个字符解析成乱码然后出现墙壁消失的诡异现象。原因File.ReadAllLines在默认情况下能处理 BOM但如果你用了File.ReadAllText再按行分割BOM 字符会残留在第一行开头干扰解析。解决统一用File.ReadAllLines(path, Encoding.UTF8)并且关卡文件保存为无 BOM 的 UTF-8 或系统默认 ANSI。我自己的做法是在LevelParser里加一层防护把所有非地图符号的字符自动当成 Floor宁可乱画也不崩溃default: tiles[r, c] Tile.Floor; break;5. 二次开发从自定义关卡到扩展功能项目跑通之后真正的乐趣才刚开始。做推箱子最有成就感的二次开发方向有三个扩展关卡格式、加计步和计时、做关卡编辑器。5.1 关卡格式扩展现在的格式是纯字符对字符扩展方向是支持一个关卡文件包含多个关卡用;或空行分隔。解析时按空行切分然后在MainForm里加一个关卡索引通过左右方向键切换关卡即可。这个扩展的规模大约 30 行代码但对整个项目的实用价值提升是巨大的——你再也不用在Levels目录里放几十个零散文件。5.2 步数统计与计时器把步数计数器挂在TryMove成功之后的位置需要先让GameMap暴露一个StepCount属性。计时器则用System.Windows.Forms.Timer每分钟触发一次把Stopwatch的读数显示到窗体的StatusStrip上或者直接改Text标题。5.3 一个具体技巧把移动判定做成独立服务类在你动手扩展之前有一个重构值得先做。把GameMap里的TryMove和Undo抽到一个独立的SokobanEngine类中让GameMap只负责数据存储。这样做最大的好处是可以用单元测试直接验证核心逻辑——不需要启动窗体只要写几行测试代码就能覆盖正常推动、撞墙、推动两个箱子、撤销恢复四条关键路径。从那以后我每次写完一个小游戏 Demo都强制把逻辑层与 UI 层剥离开先跑通测试再连窗体。事实证明这个习惯帮我省掉了大半手动点按调试的时间。如果你只是想在已有源码的基础上做点看得见的改动建议从画风入手把色块换成从网上找的 32×32 图标或者给玩家加一个连按方向的平滑移动动画。推箱子这个项目的好处是天花板高、地板低它不会限制你的发挥希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑