C#推箱子游戏源码详解:WinForms+GDI+实现网格游戏核心逻辑

发布时间:2026/11/2 8:44:50
C#推箱子游戏源码详解:WinForms+GDI+实现网格游戏核心逻辑

简介:这是一份面向C#初学者与游戏编程爱好者的推箱子小游戏完整源码工程,适合用来练习WinForms界面开发、事件处理与游戏状态管理。压缩包共76个文件,约135KB,包含10个cs源文件、4个resx与resources资源文件、11个bmp位图素材、13个dat与15个way关卡及通关记录文件,另有sln解决方案、csproj工程文件与可执行程序,结构完整可直接编译运行。功能上支持撤销(Ctrl+Z)、重做(Ctrl+Y)、选关、存盘读盘,通关后自动将最佳步数保存为level.way文件,既能回放演示整个推箱过程,也便于向朋友分享战绩,玩家还可自行设计关卡。作者预留了自动寻路(深度优先、链表与栈)、鼠标操作与通关光荣榜等扩展方向,留待读者动手补全。目前已有964人学习下载,适合作为课程设计或自学练手项目。

1. 一个把“推箱子”拆开揉碎讲清楚的 C# 小游戏源码

很多人第一次接触游戏编程,都是从推箱子开始的——规则简单到一句话能说完,但真动手写才发现,地图怎么存、人物怎么动、箱子推到墙角怎么判断死局,每一步都是坑。这份 C# 推箱子小游戏源代码,就是把这些坑一个个填平之后的产物。它用最朴素的 WinForms 加 GDI+ 绘制,没有引入任何游戏引擎,整个项目结构清晰到新手能一行行读懂,熟手也能直接拿它当网格类游戏的骨架去改。如果你正在找一份能跑起来、能看懂、还能顺手改成自己版本的 C# 小游戏源码,这份推箱子代码值得你花一个下午拆一遍。

2. 项目定位与技术选型:为什么用 WinForms 而不是游戏引擎

2.1 这个项目到底解决什么问题

推箱子(Sokoban)的核心玩法是:玩家在网格地图上推动箱子,把所有箱子推到目标点就算过关。听起来简单,但它天然包含几个游戏编程的基础命题——网格坐标系统、碰撞检测、状态管理、关卡数据序列化、撤销与重开。这份源码把这些命题全部落在一个可运行的桌面程序里,没有多余的框架噪音。

它适合两类人:一类是刚学完 C# 基础语法、想找个完整项目练手的新手,另一类是想快速搭一个网格类小游戏原型、不想被 Unity 那套东西拖慢节奏的开发者。项目本身不依赖数据库、不依赖网络、不依赖任何第三方 NuGet 包,双击解决方案就能编译运行,这种“零外部依赖”的特性在练手项目里非常难得。

从功能覆盖上看,它实现了完整的推箱子闭环:多关卡切换、步数统计、撤销上一步、重新开始当前关卡、通关判定。这些功能看起来不起眼,但每一个背后都有对应的数据结构支撑,后面拆代码时会逐一展开。

2.2 技术栈拆解:WinForms + GDI+ 的轻量组合

项目用的是 .NET Framework 下的 WinForms,绘图走 GDI+。这个选型在今天的眼光看有点“老派”,但对推箱子这种 2D 网格游戏来说,反而是最省事的路子。原因很直接:推箱子的画面就是一张张固定大小的格子图,不需要 3D 渲染、不需要物理引擎、不需要粒子特效,GDI+ 的Graphics.DrawImage和Graphics.FillRectangle完全够用。

常见做法是把每个格子画成一个固定像素的方块,比如 32×32 或 48×48,地图有多大,画布就按格子数乘出来。这种“格子即像素”的映射方式,让坐标换算变得极其简单——鼠标点在哪、人物走到哪,都是一次整除运算的事。

技术栈可以归纳成这么几层:

层次技术作用
界面层WinForms Form + Panel承载画布、接收键盘事件
绘制层GDI+ Graphics逐格绘制墙壁、地板、箱子、目标点、人物
逻辑层纯 C# 类地图解析、移动判定、胜利检测
数据层文本关卡文件用字符矩阵描述每一关的布局

这个分层不是硬性架构,而是代码自然形成的边界。逻辑层完全不碰 UI,意味着你可以把同一套判定逻辑搬到控制台程序甚至 Web 后端里跑,这也是后面二次开发时最值得利用的一点。

2.3 关卡数据的字符矩阵表示法

推箱子的关卡数据怎么存,是这个项目第一个值得学的设计。源码里用的是最经典的字符矩阵方案:每个关卡就是一个字符串数组,每个字符代表一种地形或物件。

// 关卡数据:每个字符串是一行,字符含义见下方注释 private readonly string[] Level1 = new string[] { "########", "# #", "# .$ #", "# @ #", "# $ #", "# . #", "########" }; // # 墙壁 空格 地板 . 目标点 $ 箱子 @ 玩家 * 箱子已在目标点 + 玩家站在目标点

这段数据里,#是墙,.是目标点,$是箱子,@是玩家。解析的时候遍历每个字符,把坐标和类型塞进对应的集合里。这种表示法的好处是关卡可以直接手写、肉眼可读,改关卡不用打开任何编辑器,记事本就能干。

参数说明:字符矩阵的行数决定地图高度,每行字符串长度决定宽度,解析时要保证所有行等长,否则后面按width * y + x算索引会错位。我一般会在解析入口加一个长度校验,行长度不一致直接抛异常,比运行到一半画面错乱要好排查得多。

3. 核心模块拆解:从地图解析到移动判定的完整链路

3.1 地图解析:把字符矩阵变成可操作的数据结构

解析关卡是整个游戏的起点。源码里的做法是定义几个集合来存不同物件的位置,玩家位置单独用两个 int 存行列坐标。

public class LevelData { public int Width; // 地图宽度(列数) public int Height; // 地图高度(行数) public List<Point> Walls = new List<Point>(); // 墙壁坐标集合 public List<Point> Targets = new List<Point>(); // 目标点坐标集合 public List<Point> Boxes = new List<Point>(); // 箱子坐标集合 public Point Player; // 玩家当前坐标 public void Parse(string[] rows) { Height = rows.Length; Width = rows[0].Length; for (int y = 0; y < Height; y++) { for (int x = 0; x < Width; x++) { char c = rows[y][x]; switch (c) { case '#': Walls.Add(new Point(x, y)); break; case '.': Targets.Add(new Point(x, y)); break; case '$': Boxes.Add(new Point(x, y)); break; case '@': Player = new Point(x, y); break; case '*': Boxes.Add(new Point(x, y)); Targets.Add(new Point(x, y)); break; case '+': Player = new Point(x, y); Targets.Add(new Point(x, y)); break; } } } } }

逻辑说明:外层循环走行(y),内层走列(x),这样遍历顺序和字符串数组的存储顺序一致,不会出现行列颠倒的经典错误。*和+这两个复合字符要同时往两个集合里塞数据,这是新手最容易漏的地方——箱子已经在目标点上,它既是箱子也是目标点,判定胜利时两个集合都得算对。

参数说明:Point用的是System.Drawing.Point,x 是列、y 是行,和屏幕坐标一致。后面所有移动判定都基于这个坐标系,千万别在某个地方把 x、y 写反,否则会出现“按上键人物往左走”这种玄学 bug。

3.2 移动判定:一次按键背后的四步检查

玩家按方向键之后,程序要做的事情比想象中多。源码里的移动逻辑可以拆成四步:算出目标格、判断是不是墙、判断有没有箱子、箱子能不能推。

public bool TryMove(int dx, int dy) { Point next = new Point(Player.X + dx, Player.Y + dy); // 第一步:撞墙直接返回 if (Walls.Contains(next)) return false; // 第二步:目标格有箱子,尝试推箱子 if (Boxes.Contains(next)) { Point boxNext = new Point(next.X + dx, next.Y + dy); // 箱子前方是墙或另一个箱子,推不动 if (Walls.Contains(boxNext) || Boxes.Contains(boxNext)) return false; // 执行推箱子:更新箱子位置 Boxes.Remove(next); Boxes.Add(boxNext); } // 第三步:更新玩家位置 Player = next; return true; }

逻辑说明:这段代码的顺序很关键。先判墙、再判箱子、最后才移动玩家,任何一步失败都直接返回 false,保证状态不会被改到一半。推箱子那一段先Remove旧位置再Add新位置,用的是List<Point>的引用相等判断,Point是值类型,Contains比较的是坐标值,所以能正确命中。

参数说明:dx和dy是方向增量,上是(0,-1)、下是(0,1)、左是(-1,0)、右是(1,0)。这个约定要和键盘事件里的映射保持一致,源码里是在KeyDown事件里做 switch 分发,四个方向各调一次TryMove。

这里有个容易翻车的点:Boxes.Contains(next)用的是线性查找,箱子数量少的时候无所谓,但如果关卡里箱子特别多,每帧都线性扫一遍会有性能损耗。常见做法是额外维护一个二维数组或字典做 O(1) 查询,不过对推箱子这种规模,线性查找完全够用,没必要提前优化。

3.3 胜利判定:所有箱子是否都在目标点上

胜利判定的逻辑一句话能说完:每个箱子所在的位置,是否都在目标点集合里。

public bool IsWin() { foreach (Point box in Boxes) { if (!Targets.Contains(box)) return false; } return true; }

逻辑说明:遍历箱子集合,只要有一个箱子不在目标点上就返回 false,全部命中才返回 true。这个判定在每次成功移动之后调用一次,返回 true 就弹出通关提示并加载下一关。

参数说明:这里依赖的是Targets集合里存了所有目标点坐标,包括*和+解析出来的那些。如果解析阶段漏了复合字符,这里就会误判——箱子明明在目标点上,程序却说没赢,这种 bug 排查起来很折磨人,所以解析和判定这两块要对照着看。

3.4 撤销功能:用栈保存每一步的状态快照

撤销是推箱子体验里很重要的一环,源码里用栈来实现。每成功移动一步,就把当前玩家位置和箱子位置集合压栈;撤销时弹栈恢复。

private Stack<GameState> history = new Stack<GameState>(); private void SaveState() { history.Push(new GameState { Player = this.Player, Boxes = new List<Point>(this.Boxes) // 深拷贝,避免引用共享 }); } public void Undo() { if (history.Count == 0) return; GameState state = history.Pop(); Player = state.Player; Boxes = state.Boxes; }

逻辑说明:GameState是一个简单的快照类,存玩家坐标和箱子集合的副本。关键在new List<Point>(this.Boxes)这一句——必须新建一个列表,不能直接赋引用,否则后续移动修改的是同一个列表,撤销回去的状态也跟着变了,这是引用类型最经典的坑。

参数说明:栈的容量没有上限,一关走几百步也就几百个快照,内存压力可以忽略。如果关卡特别大、步数特别多,可以考虑只存增量(移动方向和是否推了箱子),撤销时反向执行,但那是优化项,初版用全量快照最直观。

4. 绘制与交互:GDI+ 逐格渲染和键盘事件绑定

4.1 双缓冲绘制:消除闪烁的关键一行

WinForms 里直接往 Panel 上画,快速重绘时会闪。源码里开了双缓冲,这是桌面小游戏必做的一步。

// 在窗体或绘制控件的构造函数里开启双缓冲 this.DoubleBuffered = true; this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true);

逻辑说明:DoubleBuffered = true让绘制先发生在后台缓冲区,画完再整体贴到屏幕上,避免逐格绘制过程中被用户看到中间状态。AllPaintingInWmPaint和UserPaint配合使用,把绘制统一收到OnPaint里处理。

参数说明:这三个样式标志是组合使用的,少一个效果就打折扣。如果绘制控件是自定义的 Panel 子类,在子类构造函数里设置;如果是直接在 Form 上画,就在 Form 构造函数里设置。这一步不做,画面每走一步闪一下,体验直接崩掉。

4.2 逐格渲染:一个 for 循环画完整个地图

绘制逻辑的核心是一个双层循环,按行列遍历,每个格子根据类型选不同的画法。

protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; int cell = 32; // 每格 32 像素 for (int y = 0; y < level.Height; y++) { for (int x = 0; x < level.Width; x++) { Rectangle rect = new Rectangle(x * cell, y * cell, cell, cell); Point p = new Point(x, y); if (level.Walls.Contains(p)) g.FillRectangle(Brushes.DimGray, rect); // 墙 else g.FillRectangle(Brushes.WhiteSmoke, rect); // 地板 if (level.Targets.Contains(p)) g.FillEllipse(Brushes.OrangeRed, rect); // 目标点 if (level.Boxes.Contains(p)) g.FillRectangle(Brushes.SaddleBrown, rect); // 箱子 if (level.Player == p) g.FillEllipse(Brushes.SteelBlue, rect); // 玩家 } } }

逻辑说明:每个格子先铺底(墙或地板),再叠目标点、箱子、玩家。绘制顺序决定了图层关系,玩家画在最后,保证站在目标点上时人物可见。用纯色图形代替图片资源,是为了让源码不依赖外部素材文件,下载下来就能跑。

参数说明:cell是格子像素尺寸,改大改小只影响显示,不影响逻辑坐标。Rectangle的构造参数是(x, y, width, height),这里的 x、y 是像素坐标,等于格子坐标乘 cell。如果想让画面更精致,可以把FillRectangle换成DrawImage加载贴图,逻辑层完全不用动。

4.3 键盘事件:方向键映射与重绘触发

交互部分绑定在窗体的KeyDown事件上,方向键映射到四个方向增量,移动成功后调Invalidate()触发重绘。

private void Form_KeyDown(object sender, KeyEventArgs e) { int dx = 0, dy = 0; switch (e.KeyCode) { case Keys.Up: dy = -1; break; case Keys.Down: dy = 1; break; case Keys.Left: dx = -1; break; case Keys.Right: dx = 1; break; case Keys.Z: Undo(); Invalidate(); return; // Z 键撤销 case Keys.R: RestartLevel(); Invalidate(); return; default: return; } SaveState(); // 先存快照 if (level.TryMove(dx, dy)) { stepCount++; if (level.IsWin()) ShowWinAndNextLevel(); } else { history.Pop(); // 移动失败,撤销刚存的快照 } Invalidate(); // 请求重绘 }

逻辑说明:先存快照再尝试移动,移动失败就把快照弹掉,保证历史栈里只有有效步骤。Invalidate()只是标记区域需要重绘,真正的绘制发生在下一次OnPaint,这是 WinForms 的标准刷新机制。

参数说明:e.KeyCode是物理按键枚举,方向键对应Keys.Up/Down/Left/Right。如果窗体上有其他控件抢焦点,KeyDown可能收不到,常见做法是把窗体的KeyPreview设为 true,让窗体优先接收按键。

5. 避坑与排查:跑起来之后最容易撞上的五个问题

5.1 画面闪烁严重,走一步闪一下

现象:每次按方向键,整个画面明显闪一下,快速连按更明显。

原因:没有开启双缓冲,GDI+ 逐格绘制的过程被用户直接看到了。

解决:在窗体或绘制控件的构造函数里设置DoubleBuffered = true和SetStyle的三个标志,具体代码见 4.1 节。如果绘制在 Panel 子类里,设置要写在子类构造函数,写在 Form 上对子控件不生效。

5.2 撤销之后箱子位置错乱

现象:按 Z 撤销,人物回去了,但箱子位置和撤销前一样,或者多个箱子叠在一起。

原因:保存快照时直接赋了Boxes的引用,没有深拷贝,后续移动修改的是同一个列表。

解决:快照里用new List<Point>(this.Boxes)新建副本,恢复时整体替换。这个坑我在别的项目里也踩过,引用类型做快照,十有八九会翻车。

5.3 箱子推到目标点上,胜利判定却不触发

现象:肉眼看着所有箱子都在目标点上了,程序没反应。

原因:关卡数据里用了*(箱子在目标点)或+(玩家在目标点)这类复合字符,解析时只处理了单一字符,目标点集合里缺了这些坐标。

解决:解析的 switch 里补上*和+分支,同时往箱子集合和目标点集合里加数据。改完拿一个含复合字符的关卡跑一遍,确认IsWin返回 true。

5.4 方向键没反应,或者人物往反方向走

现象:按上键人物不动,或者按左键人物往右走。

原因:两种可能——一是KeyDown事件没绑上或被其他控件抢了焦点;二是dx、dy的映射写反了,把行列和屏幕坐标搞混。

解决:先确认窗体KeyPreview = true且事件已绑定;再对照 3.2 节的增量约定检查 switch 分支,上(0,-1)、下(0,1)、左(-1,0)、右(1,0),一个都不能错。

5.5 换关卡之后地图错位或越界

现象:加载第二关时画面只画了一半,或者抛数组越界异常。

原因:不同关卡的字符串数组行长度不一致,解析时按第一行长度算宽度,后面按width * y + x取字符就越界了。

解决:解析入口加校验,遍历所有行确认长度等于rows[0].Length,不一致直接抛带行号的异常。关卡数据是手写的,这种低级错误很常见,早校验早省心。

6. 二次开发:把推箱子改成你自己的网格游戏

拿到这份源码之后,最值钱的改造方向不是加皮肤,而是把逻辑层和表现层彻底解耦,让它变成一个通用的网格游戏骨架。具体做法是:把LevelData里的TryMove、IsWin抽到一个独立的GameLogic类里,这个类只依赖坐标和集合,不引用任何System.Windows.Forms或System.Drawing的绘制 API。抽完之后,同一套判定逻辑可以套到控制台版本、甚至一个简单的 Web 前端上,UI 只负责把状态画出来。

第二个改造点是关卡数据的加载方式。现在关卡硬编码在源码里,改成从外部文本文件读取,每行一个关卡文件,用空行分隔。这样改关卡不用重新编译,策划或者你自己用记事本就能调。

// 从文件加载多关卡:每个关卡之间用空行分隔 public static List<string[]> LoadLevels(string path) { var levels = new List<string[]>(); var current = new List<string>(); foreach (string line in File.ReadAllLines(path)) { if (string.IsNullOrWhiteSpace(line)) { if (current.Count > 0) { levels.Add(current.ToArray()); current.Clear(); } } else current.Add(line); } if (current.Count > 0) levels.Add(current.ToArray()); return levels; }

这段代码的关键在于空行作为关卡分隔符,读完之后每个string[]就是一个完整关卡,直接喂给Parse方法。参数上要注意文件编码统一用 UTF-8,避免中文路径或特殊字符出问题。

第三个方向是加“死局检测”。推箱子里箱子被推到墙角且不在目标点上,这关就废了,但玩家可能还在傻推。常见做法是在每次推箱子之后检查:箱子是否贴墙、贴墙的两侧是否也是墙或边界,满足条件就提示“当前局面无解,建议撤销”。这个检测不需要搜索算法,纯几何判断就能覆盖大部分死局,实现成本低、体验提升明显。

验证改造是否成功,我一般用三个关卡做回归:一个普通关卡验证基本移动和胜利判定,一个含*、+复合字符的关卡验证解析,一个箱子多到需要撤销的关卡验证历史栈。三个都过,基本就稳了。

说个自己的教训:早些年改这类网格游戏,我总想着先把画面做好看,结果逻辑层和绘制层缠在一起,后来想加个新玩法,改一处崩三处。从那以后我每次动这类项目,都强制先把逻辑抽干净、跑通控制台版本,再回头接 UI。希望这份拆解能帮你少走点弯路,源码拿去跑一遍、改一版,比看十篇文章都管用。

本文还有配套的精品资源,点击获取