简介:基于C#的开心消消乐游戏设计源码是一份面向C#入门者与游戏开发学习者的完整工程,适合用于课程设计、毕业设计或趣味项目实践,旨在帮助读者理解图形界面交互、游戏消除算法与资源组织方式,并体验从需求分析到编码实现的完整流程。压缩包共44个文件,约2.54MB,其中包含17张png图片用于界面背景与方块元素,11个cs源文件覆盖主窗体、控件及核心玩法逻辑,另有resx资源文件、json配置、sqlite数据库和Visual Studio工程文件等,目录结构清晰,便于直接打开运行与二次开发。目前已有472人学习下载。借助该工程可系统查看游戏初始化、方块生成与消除判定等关键代码,同时掌握C# WinForms下的菜单栏设计、鼠标事件处理、绘图技术以及本地数据存储的常见做法;对希望快速上手WinForms游戏开发或完善C#项目经验的学习者,是一份高参考价值的实战源码。
1. 用 C# 写开心消消乐:一个不依赖引擎就能跑起来的三消源码
如果让刚啃完 C# 语法、正愁没项目练手的同学挑一个方向,开心消消乐绝对排在我推荐清单的前三。你可能觉得三消必须上 Unity,实际上纯 C# + WinForms + GDI+ 就能把棋盘逻辑、消除判定和鼠标交互完整做出来。这套「基于C#的开心消消乐游戏设计源码」最有价值的地方,是把二维数组、集合、事件、位图绘制和坐标换算这些 C# 基础知识点织进了同一个项目里,做一轮基本等于把 C# 入门到进阶的核心内容重新过了一遍。适合课程设计、毕业设计,也适合找工作前当作品集里的一个能演示的 demo。
2. 核心算法层:用二维数组搭出棋盘,消除检测才是最硬核的部分
一个三消游戏能玩的前提是棋盘逻辑正确。交换、匹配、消除、下落这四个动作要在数据层先闭环,界面只是把这套数据画出来。很多人第一次写的时候先画界面再补逻辑,结果越写越乱。我的习惯是反着来:先在控制台里把算法调通,再粘到 WinForms 里。这一章直接看核心数据结构和三个算法。
2.1 棋盘数据结构:int[,] 还是 List<List >
棋盘本质上是一张二维表,每个格子存一个表示“气泡颜色”的整数,0 表示空。最自然的映射是 C# 的二维数组 int[,],因为行列数固定,访问 grid[y,x] 是 O(1) 且语义清晰。有人会问 C# 里数组和集合分别是怎么定义的、使用上有什么区别:数组定长、内存连续,适合这种网格;List<List > 每一行都是独立对象,做行插入删除方便,但三消棋盘大小固定,用集合反而多一层间接访问,还要小心每行长度不一致带来的越界风险。
我一般把棋盘定义成字段:
private int rows = 8; private int cols = 8; private int cellSize = 80; private int[,] grid; private Random rand = new Random();rows 和 cols 控制棋盘规模,8×8 是常见配置,改成 6×6 节奏更快,10×10 组合更多但单局时间拉长。cellSize 是每个格子绘制的像素宽度,直接决定窗口大小,这里取 80 是为了在普通鼠标和触屏上都点得准。
初始化棋盘时最好不要让初始局面自带三连,否则玩家还没操作,游戏就开始自动消了。常见做法是逐个生成并避开左边两个和上边两个同色:
private void InitGrid() { for (int y = 0; y < rows; y++) { for (int x = 0; x < cols; x++) { int type; do { type = rand.Next(1, 5); } while ((x >= 2 && grid[y, x - 1] == type && grid[y, x - 2] == type) || (y >= 2 && grid[y - 1, x] == type && grid[y - 2, x] == type)); grid[y, x] = type; } } }rand.Next(1, 5) 生成 1 到 4 之间的整数,对应四种颜色。do…while 里的条件做的是“回看”:如果左边两个已经和当前候选同色,或者上边两个同色,就重新掷一次。这里除了数组访问,还用到了逻辑表达式的短路求值,两个条件用 || 连接需要各自加括号,写成 && 和 || 混用时优先级坑很多。
选二维数组的另一个原因是渲染层和逻辑层的坐标可以共用同一个 Point。System.Drawing.Point 的 X 和 Y 分别对应列和行,后面鼠标点击换算出 (col, row) 后,直接就能用 grid[row, col] 取值,不需要再做坐标旋转变换。
提示:不要用一个一维数组存棋盘再用 row * cols + col 去索引。虽然效率差不多,但可读性差,后面做横向扫描时写起来全是取模运算,排查也麻烦。
2.2 消除检测算法:横向纵向扫描与极长串优化
消除检测是三消的核心。我的实现是:遍历每一行,找连续相同颜色的区间,长度达到 3 就记下来;再对每一列做同样操作。返回值用 List ,Point.X 存列号,Point.Y 存行号。
private List<Point> FindMatches() { List<Point> matched = new List<Point>(); // 横向扫描 for (int y = 0; y < rows; y++) { for (int x = 0; x < cols - 2; x++) { int type = grid[y, x]; if (type == 0) continue; int end = x; while (end + 1 < cols && grid[y, end + 1] == type) end++; if (end - x + 1 >= 3) { for (int i = x; i <= end; i++) matched.Add(new Point(i, y)); } x = end; } } // 纵向扫描 for (int x = 0; x < cols; x++) { for (int y = 0; y < rows - 2; y++) { int type = grid[y, x]; if (type == 0) continue; int end = y; while (end + 1 < rows && grid[end + 1, x] == type) end++; if (end - y + 1 >= 3) { for (int i = y; i <= end; i++) matched.Add(new Point(x, i)); } y = end; } } return matched; }这里有两个容易理解偏的点。第一个是内层 while 把 end 推到连续段的末尾,而不是只检查 x、x+1、x+2 三个位置。如果不推到底,四连、五连会被拆成两段,记录重复不说,后面做特效时你根本拿不到完整段的长度。第二个是扫描完一个段后把外层循环变量直接赋成 end,因为 end 之前的格子都已确认过,继续 x++ 会重复扫描,虽然不影响正确性但浪费 CPU。纵向循环同理,y = end。
这段代码不是最优解,但它足够直白。在 8×8 棋盘上跑一次 FindMatches 的开销在几十微秒级别,连锁消除时多跑几十次也不到一毫秒,没必要为了这点性能去写复杂的并查集或位运算,优化留给真正 20×20 以上的棋盘。
2.3 下落与补充:倒序写入是最稳的收尾动作
匹配的格子要置 0,然后重力让上方棋子下落,顶部补充新棋子。我第一次写的时候用正序遍历,结果落下来的棋子错位,后来才明白正确的姿势是倒序写入。
private void ClearMatches(List<Point> matches) { HashSet<Point> set = new HashSet<Point>(matches); foreach (Point p in set) grid[p.Y, p.X] = 0; } private void ApplyGravity() { // 每一列从下往上处理,write 指向当前可写入的空位 for (int x = 0; x < cols; x++) { int write = rows - 1; for (int y = rows - 1; y >= 0; y--) { if (grid[y, x] != 0) { if (write != y) { grid[write, x] = grid[y, x]; grid[y, x] = 0; } write--; } } } } private void FillEmpty() { for (int y = rows - 1; y >= 0; y--) { for (int x = 0; x < cols; x++) { if (grid[y, x] == 0) { grid[y, x] = rand.Next(1, 5); } } } }ClearMatches 里用 HashSet 去重,因为同一个格子可能在横竖两个方向上同时被匹配,消除时重复置 0 无伤大雅,但后面做特效计分会出错。ApplyGravity 里 write 是当前列从下往上第一个空位,从底部往上扫,遇到非空格子就搬到 write 处,write 再往上移一位。倒序的原因:正序时底部的空格被填上后,下一次循环判断的位置已经变化,会覆盖还没处理的棋子。
FillEmpty 补出的新棋子可能立刻形成新的三连,这在游戏中是允许的,也是连锁反应的来源。所以完整处理流程是一个循环:FindMatches → ClearMatches → ApplyGravity → FillEmpty,直到某次 FindMatches 返回空,棋盘才稳定。
private void ProcessMatches() { // 64 次上限防止极端情况死循环,正常连锁远达不到这个次数 for (int step = 0; step < 64; step++) { List<Point> matches = FindMatches(); if (matches.Count == 0) break; ClearMatches(matches); ApplyGravity(); FillEmpty(); } Invalidate(); }这段流程直接决定游戏手感。要加计分就放在 ClearMatches 之前,按 matches.Count 乘上单颗分值累加,四连、五连还可以加权。如果你想要更真实的“逐个下落”动画,就把 ApplyGravity 拆成多帧执行,但这属于动画层的事,数据层不要为动画改变设计。
3. 渲染与交互层:用 GDI+ 画棋盘,再把鼠标点按变成交换
数据层跑通后,界面只是棋盘的可视化映射。这一章讲渲染方案选择、绘制代码和鼠标交互,做完后一个最小可玩版本就落地了。
3.1 渲染方案怎么选:GDI+ / WPF / Unity
项目标题限定“基于C#”,但没限定引擎,可选路径有三条:WinForms + GDI+、WPF 的 DrawingVisual、Unity 引擎。三消是强交互、轻特效的游戏,纯 C# 技术栈用 GDI+ 就够,所有绘图 API 收敛在 System.Drawing 里,学习成本最低,也最容易让逻辑脱离引擎独立测试。WPF 的渲染模型在分辨率和动画方面更平滑,但自定义动画要理解依赖属性和 DispatcherTimer 的调度,门槛高一些。Unity 适合后续加粒子特效和音效的团队,但 C# 在 Unity 里更像脚本,逻辑和引擎强耦合,答辩时容易被问到“哪段是你写的,哪段是引擎给的”。
所以这个源码用 GDI+ 并不寒酸,反而能讲清楚坐标换算、双缓冲、命中检测这些基本功。这种自绘方案和 C# 上位机界面常用的套路是相通的,一个会写棋盘自绘的人,改行写仪表盘、写 LED 模拟界面也很快。
3.2 绘制棋盘与棋子:OnPaint 与双缓冲
棋盘用一个 UserControl 子类承载,重写 OnPaint。所有绘制都放在 Paint 事件里完成,不在鼠标回调里直接画图,这是避免重绘残留的基本习惯。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; for (int y = 0; y < rows; y++) { for (int x = 0; x < cols; x++) { Rectangle rect = new Rectangle(x * cellSize + 1, y * cellSize + 1, cellSize - 2, cellSize - 2); using (SolidBrush brush = new SolidBrush(GetColor(grid[y, x]))) { g.FillEllipse(brush, rect); } } } // 当前选中的格子画一个白色边框 if (selected.X >= 0) { Rectangle sel = new Rectangle(selected.X * cellSize + 1, selected.Y * cellSize + 1, cellSize - 2, cellSize - 2); g.DrawRectangle(Pens.White, sel); } } private Color GetColor(int type) => type switch { 1 => Color.FromArgb(231, 76, 60), 2 => Color.FromArgb(46, 134, 193), 3 => Color.FromArgb(241, 196, 15), 4 => Color.FromArgb(46, 204, 113), _ => Color.Gray };OnPaint 里每个棋子画成圆形,内部填充颜色。rect 的 x * cellSize + 1 是给格子留 1 像素间隔,不然 8×8 个圆会拼成一大块色斑,看不出单个棋子边界。GetColor 用 C# 8 的 switch 表达式,四个色值选自 Flat UI 配色,红蓝黄绿对绝大多数人都能区分。
双缓冲是 WinForms 自绘的必备项,否则每次 Invalidate 屏幕都会闪成白板再重绘。最简单的方式是在控件构造函数里加 DoubleBuffered = true。如果你用的是派生 UserControl,直接配置这个属性就行;如果还想进一步优化,可以手动开启控件样式,把 AllPaintingInWmPaint、UserPaint、OptimizedDoubleBuffer 三个标志位加上,再加上深色背景,视觉上会干净很多。
3.3 鼠标坐标换算与交换判定
鼠标点到屏幕像素后,第一件事是换算成棋盘坐标:列 = e.X / cellSize,行 = e.Y / cellSize。我采用两段式交互:第一次点击选中棋子,第二次点击目标位置,如果目标不相邻就重新选中。
private Point selected = new Point(-1, -1); protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); int x = e.X / cellSize; int y = e.Y / cellSize; if (x < 0 || x >= cols || y < 0 || y >= rows) return; if (selected.X < 0) { selected = new Point(x, y); } else { TrySwap(selected, new Point(x, y)); selected = new Point(-1, -1); } Invalidate(); } private void TrySwap(Point a, Point b) { // 曼哈顿距离等于 1 才算是相邻格子 if (Math.Abs(a.X - b.X) + Math.Abs(a.Y - b.Y) != 1) return; Swap(a, b); if (FindMatches().Count > 0) { ProcessMatches(); } else { Swap(a, b); } } private void Swap(Point a, Point b) { int temp = grid[a.Y, a.X]; grid[a.Y, a.X] = grid[b.Y, b.X]; grid[b.Y, b.X] = temp; }坐标换算是整数除法,天然向下取整,所以不会出现“点在两个格子中间”的歧义。TrySwap 里用曼哈顿距离判断相邻,横向或纵向相差 1 才算合法,斜对角交换直接忽略。交换后调用 FindMatches 预判:如果存在匹配就正式走 ProcessMatches,否则立刻换回来,玩家看到的视觉结果就是“点一下没反应”,符合三消的标准反馈。
这里的事件处理用的就是 WinForms 鼠标事件,事件本质上是委托的封装,理解这一点后,后续想把操作改为触摸滑动也很容易,把 OnMouseDown、OnMouseMove、OnMouseUp 组合起来就能判断滑动方向。
4. 避坑清单:交换失效、下落错位与连锁死循环
这个项目做完第一版后,我对着棋盘点了一下午,遇到的问题按频率排序大概是:交换没反应、下落错位、连锁死循环、开局无解、屏幕闪烁。下面每一条都按现象、原因、解决来记录,基本可以直接照抄修复。
4.1 交换没反应,或交换后棋盘原地复原
现象:点击两个相邻棋子,有时候纹丝不动,有时候换过去又换回来,操作没有任何配合;更奇怪的是点两个距离很远的棋子,它们居然发生了交换。
原因:第一种是最典型的相邻判断漏了,TrySwap 里没写曼哈顿距离检查,任何两次点击都触发 Swap,导致远距离棋子被“隔空换位”。第二种是交换后确实产生了匹配,但 FindMatches 的结果没有及时反馈到流程里,玩家看到的是棋子被换走又被换回,误以为操作失效。
解决:交换前必须先判断两个格子是否相邻,横纵坐标差的绝对值之和必须等于 1;交换后立即缓冲,在 ProcessMatches 之前就拿到匹配结果决定去留。还要注意 Swap 之后马上刷新界面,否则 FindMatches 返回 0 触发的回滚交换不会及时呈现,玩家会以为自己的操作被吞了。
// 核心修复:先判相邻,再交换,再预判 if (Math.Abs(a.X - b.X) + Math.Abs(a.Y - b.Y) != 1) return; Swap(a, b); if (FindMatches().Count == 0) Swap(a, b);4.2 下落循环正序遍历导致棋子错位
现象:消除后棋盘出现空洞,上方棋子没有正确落到底,而是悬在半空,甚至棋盘中央凭空多出几个棋子,下一轮又消失。
原因:这是下落算法里翻车最惨的一次。正序遍历每一列的空位,找到空位就把下方棋子往上搬,一个列有多个空洞时,第一次搬运把下方填上了,第二次循环时行的位置已经变化,要么漏掉棋子,要么把已经落好的又换上去。
解决:用 write 指针倒序访问,从最后一行向上扫,每遇到一个非空格子就写入 write 位置,write 递减。这个写法天然保证同一列多个空洞一次处理完。调试时建议把棋盘打印到控制台,逐列人工核对下标。
int write = rows - 1; for (int y = rows - 1; y >= 0; y--) { if (grid[y, x] != 0) { if (write != y) { grid[write, x] = grid[y, x]; grid[y, x] = 0; } write--; } }4.3 连锁消除写成递归,触发几十次后栈溢出
现象:五消触发连锁后,界面卡死,随后程序异常退出,异常栈指向 ProcessMatches。
原因:给 ProcessMatches 用了递归自调,理论上连锁次数等于递归深度。8×8 棋盘极端情况能连锁二十多次,虽然一般不会爆栈,但 FillEmpty 引入随机数后偶尔出现超长连锁,递归栈积累多了就挂。
解决:改成迭代循环,加一个 64 次上限。为什么是 64?棋盘只有 64 个格子,理论上每次连锁至少消除 3 个,最多 64 个格子全部被清空一次并重新填充,再往上一定是死循环。实战里正常一盘也就触发十几次连锁,这个上限不会误伤正常游戏。
for (int step = 0; step < 64; step++) { var m = FindMatches(); if (m.Count == 0) break; ClearMatches(m); ApplyGravity(); FillEmpty(); }4.4 初始化棋盘无解,玩家一步都动不了
现象:程序启动后,玩家把所有相邻组合点了个遍,没有一组能产生消除,游戏直接进入死局。
原因:随机生成的棋盘有概率出现没有任何可交换步的局面,概率不高但存在。如果初始化只做“当前局面没有三连”的检测,并不能保证存在可行交换,两个约束是不同的事。
解决:初始化完成后调用 HasValidMove 检测,没有可行交换就重置棋盘或洗牌。HasValidMove 的实现是遍历所有相邻格子对,交换后调用 FindMatches 看是否非空。具体代码在第 5.1 节会给出,可以直接复用 TrySwap 里的预判逻辑。
4.5 GDI+ 重绘闪烁,棋盘闪成白板
现象:每次点击或消除,整个控件闪一下白色,连续消除时像在放电。
原因:WinForms 的 UserControl 默认会先调用 OnPaintBackground 把背景刷成白色,再调用 OnPaint 重绘,两步之间屏幕完成一次刷新,看起来就是闪烁。没有开双缓冲时,任何 Invalidate 区域都会先白后画。
解决:构造函数里设置 DoubleBuffered = true。如果控件的 DoubleBuffered 属性不让你改,可以手动开启控件样式:
public BoardControl() { DoubleBuffered = true; // 或者手动开启样式 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); BackColor = Color.FromArgb(44, 62, 80); }把背景色换成深色也很有用,深色背景下重绘时颜色反差小,闪烁感知会明显降低。
5. 进阶玩法:把死局洗牌、特效道具和存档接进来
数据层和交互层跑通,已经能玩。想让它更像成品,还差三样东西:死局兜底、特效道具、进度保存。
5.1 死局检测与自动洗牌
遍历所有相邻交换,只要有一步能产生匹配,就不算死局:
private bool HasValidMove() { for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) { if (x + 1 < cols && CanMatchAfterSwap(new Point(x, y), new Point(x + 1, y))) return true; if (y + 1 < rows && CanMatchAfterSwap(new Point(x, y), new Point(x, y + 1))) return true; } return false; }CanMatchAfterSwap 的逻辑和 3.3 里 TrySwap 的预判一样:先交换,FindMatches 非空就返回 true,再换回来。检测到死局后不要直接重开棋盘,会把玩家熟悉的布局清空。常见做法是把棋盘元素随机打乱,打乱后再跑一次 HasValidMove。8×8 棋盘遍历全部相邻交换只有 112 次,每次一个 FindMatches,总耗时几毫秒,放在每步操作结束后跑完全没问题。
5.2 特效道具:先识别四消、五消
要支持特效,第一步把 FindMatches 的返回值从点位列表升级成 MatchLine 对象,带上起点、方向、长度三个字段。4 连生成直线消,5 连生成全屏消,两个方向交叉的位置生成范围消除。最省力的实现是统计每个格子在一轮匹配里被命中的次数,交叉点所在的格子生成爆炸型道具。特效识别务必放在消除之前,作为独立的 ResolveSpecial 函数,不要把特殊逻辑塞进 Swap 和 FindMatches 主干,否则以后加一个新道具就得动核心算法。
5.3 JSON 存档:一维化再序列化
System.Text.Json 对 int[,] 二维数组支持不友好,我一般先把棋盘展平成一维数组再存:
int[] flat = new int[rows * cols]; for (int y = 0; y < rows; y++) for (int x = 0; x < cols; x++) flat[y * cols + x] = grid[y, x];存 JSON 时同时记录 Score、Rows、Cols 和 FlatGrid,加载时按下标规则还原。读档后别忘了跑一次 HasValidMove,防止读到一个历史死局。我现在的习惯是每改一次数据层算法,就在控制台打印棋盘快照,比对消除前、消除中、消除后三个状态,比反复点鼠标验证快得多。这个习惯帮我避开了不少低级错误,也让我后来写 C# 上位机工具时少走了弯路。希望帮到你。
本文还有配套的精品资源,点击获取