简介:这是一份面向C#初学者与游戏开发爱好者的开心消消乐游戏源码,适合用于课程设计、毕业设计参考或WinForms桌面开发练手。项目以C#语言实现,围绕图案消除的核心玩法构建了完整的游戏逻辑,涵盖方块生成、匹配判定、界面交互与分数统计等模块,可帮助读者理解桌面小游戏从界面到逻辑的组织方式。压缩包共44个文件,约2.54MB,其中11个cs源文件承载游戏主逻辑与自定义控件,17个png图片提供方块、封面与提示等美术素材,另有resx资源文件、json配置、sqlite数据库及sln、csproj工程文件,整体结构清晰,可直接用Visual Studio打开运行。目前已有472人学习下载,读者可从中获取完整的工程目录组织、WinForms控件封装思路与游戏状态管理写法,并在此基础上替换素材、调整关卡难度或扩展新玩法,作为二次开发的起点。
1. 从一份 C# 开心消消乐源码说起:三消游戏到底难在哪
很多人第一次拿到「基于C#的开心消消乐游戏设计源码」这类工程时,第一反应是打开主窗体按 F5,看着方块能交换、能消除,就觉得这项目不过如此。真到自己动手改需求——加一种特殊方块、把固定棋盘换成不规则地图、让消除判定支持连锁反应——才发现代码里到处是耦合:界面刷新和逻辑判定写在一个方法里,消除算法靠遍历控件数组,连「三个同色连成一线」这种基础规则都散落在四五个事件处理器中。三消游戏看着简单,本质是一个状态机 + 匹配算法 + 动画调度的组合体,任何一环没拆干净,后期加功能就是血泪史。
这份源码面向的人群很明确:想用 C# 做完整桌面小游戏、需要一套能跑通「交换—匹配—消除—下落—补充—连锁」全流程的参考实现,或者课程设计、上位机练手项目里需要一个有交互、有算法、有界面的综合案例。它解决的不是「怎么画一个方块」,而是「怎么把三消规则抽象成可维护的代码结构」。下面我按实际拆解这类工程的顺序,把棋盘数据结构、匹配算法、消除与下落、动画与状态管理、以及最容易翻车的几个点讲清楚,让你拿到源码后知道每一块该动哪里、参数怎么调、坑在哪。
2. 棋盘与方块的数据结构:别再用二维控件数组硬扛
2.1 为什么推荐用一维数组加坐标换算
新手写三消,最容易上手的方式是Button[,] board = new Button[8,8],每个按钮直接当方块用。这样写前三天很爽,第四天就翻车:你想做「下落动画」时发现按钮位置和逻辑坐标绑死了,想做「预判可消除组合」时发现要遍历控件树,想做「撤销」时发现状态全在 UI 里没法快照。常见做法是逻辑层和表现层分离:逻辑层用一维数组存方块类型,表现层用控件或绘制对象渲染。
// 逻辑棋盘:一维数组,索引 = row * cols + col public class Board { public int Rows { get; } public int Cols { get; } private int[] cells; // 存方块类型 ID,0 表示空 private Random rng = new Random(); public Board(int rows, int cols) { Rows = rows; Cols = cols; cells = new int[rows * cols]; FillInitial(); } // 行列转一维索引,所有逻辑操作统一走这里 public int Index(int row, int col) => row * Cols + col; public int Get(int row, int col) => cells[Index(row, col)]; public void Set(int row, int col, int type) => cells[Index(row, col)] = type; // 初始化时避免直接生成三连,否则开局就自动消除 private void FillInitial() { for (int r = 0; r < Rows; r++) for (int c = 0; c < Cols; c++) { int t; do { t = rng.Next(1, 6); } // 假设 5 种颜色,ID 1~5 while (CreatesImmediateMatch(r, c, t)); Set(r, c, t); } } private bool CreatesImmediateMatch(int row, int col, int type) { // 检查左边两个和上边两个是否同色 if (col >= 2 && Get(row, col - 1) == type && Get(row, col - 2) == type) return true; if (row >= 2 && Get(row - 1, col) == type && Get(row - 2, col) == type) return true; return false; } }这段代码的关键点有三个。第一,Index方法把二维坐标压成一维,后续匹配、下落、快照都只操作cells数组,和 UI 完全解耦。第二,FillInitial里用do-while反复随机直到不产生初始三连,这是三消开局的基本要求,否则玩家一进来就看到自动消除,体验直接崩。第三,方块类型用int而不是枚举或字符串,是为了后续做「类型扩展」时方便——比如加一种彩虹方块,只需要约定一个特殊 ID,匹配算法里单独判断即可。
2.2 方块类型与特殊块的设计预留
如果你只做基础三消,5 种颜色足够。但「开心消消乐」这类游戏的核心乐趣在于特殊块:四连生成直线消除块、五连生成彩虹块、L 型生成爆炸块。这些必须在数据结构层面预留,不能等做到一半再改。
// 方块类型定义:普通块 1~5,特殊块用高位标记 public static class TileType { public const int Empty = 0; public const int NormalMin = 1; public const int NormalMax = 5; // 特殊块:普通类型 + 偏移量,保留低 4 位存颜色 public const int LineH = 100; // 横向消除 public const int LineV = 200; // 纵向消除 public const int Bomb = 300; // 爆炸 public const int Rainbow = 400; // 彩虹 public static bool IsSpecial(int t) => t >= 100; public static int BaseColor(int t) => t >= 100 ? t % 100 : t; }这样设计的好处是:匹配算法只需要判断BaseColor是否相同,特殊块的触发逻辑单独写一个TriggerSpecial方法。参数上,NormalMax决定颜色数量,直接影响到游戏难度——5 色是经典配置,6 色难度明显上升,4 色则连锁太频繁。我一般会把这个值做成配置项,方便调试时快速改难度。
3. 匹配算法:从暴力遍历到可复用的匹配器
3.1 横向纵向扫描的最小实现
匹配算法的输入是棋盘状态,输出是所有满足三连及以上的坐标组。最直观的做法是分别扫描每一行和每一列,记录连续同色长度,达到 3 就收集。
public class MatchFinder { private Board board; public MatchFinder(Board b) { board = b; } // 返回所有匹配组,每组是一个坐标列表 public List<List<(int r, int c)>> FindAllMatches() { var results = new List<List<(int r, int c)>>(); ScanLines(results, horizontal: true); ScanLines(results, horizontal: false); return results; } private void ScanLines(List<List<(int, int)>> results, bool horizontal) { int outer = horizontal ? board.Rows : board.Cols; int inner = horizontal ? board.Cols : board.Rows; for (int i = 0; i < outer; i++) { int runStart = 0; int runColor = -1; for (int j = 0; j <= inner; j++) { int color = j < inner ? TileType.BaseColor(horizontal ? board.Get(i, j) : board.Get(j, i)) : -1; // 边界哨兵,强制结算最后一段 if (color != runColor) { if (runColor > 0 && j - runStart >= 3) results.Add(BuildGroup(i, runStart, j - 1, horizontal)); runStart = j; runColor = color; } } } } private List<(int, int)> BuildGroup(int line, int from, int to, bool horizontal) { var group = new List<(int, int)>(); for (int k = from; k <= to; k++) group.Add(horizontal ? (line, k) : (k, line)); return group; } }这段代码的核心技巧是哨兵值:在循环末尾多跑一次j == inner,用一个不存在的颜色-1强制结算最后一段连续块。没有这个哨兵,最后一段匹配会被漏掉,这是新手最常见的翻车点之一。参数上,>= 3就是三消阈值,改成 4 就变成四消玩法,但要注意特殊块生成规则也要同步改。
3.2 匹配结果去重与特殊块判定
上面的扫描会把「L 型」和「T 型」拆成横向和纵向两组,但实际游戏里 L 型五连应该生成爆炸块,而不是两个独立匹配。所以拿到原始匹配组后,需要做一次合并判定。
// 合并共享坐标的匹配组,识别 L/T 型 public List<MatchGroup> MergeGroups(List<List<(int, int)>> raw) { var merged = new List<MatchGroup>(); var used = new bool[raw.Count]; for (int i = 0; i < raw.Count; i++) { if (used[i]) continue; var group = new MatchGroup(raw[i]); used[i] = true; for (int j = i + 1; j < raw.Count; j++) { if (used[j]) continue; if (raw[j].Any(p => group.Cells.Contains(p))) { group.Cells.UnionWith(raw[j]); used[j] = true; } } merged.Add(group); } return merged; }MatchGroup里再根据Cells.Count和形状判断生成什么特殊块:4 个同色直线生成直线块,5 个直线生成彩虹,L/T 型生成爆炸。这里有个参数边界要注意:如果一次消除同时触发多个匹配组,特殊块的生成位置通常选在玩家交换的那个方块位置,而不是匹配组的中心,否则玩家会觉得「我明明换的是这里,怎么特殊块跑到那边去了」。这个细节在源码里如果没处理,体验会差很多。
4. 消除、下落与补充:连锁反应的状态推进
4.1 消除后下落的标准流程
匹配到一组方块后,流程是:标记消除 → 上方方块下落 → 顶部补充新方块 → 再次检测匹配 → 循环直到无匹配。这个循环就是连锁反应,也是三消最核心的状态推进逻辑。
public IEnumerator ResolveBoard(Action<int> onChain) { int chain = 0; while (true) { var matches = matchFinder.FindAllMatches(); if (matches.Count == 0) break; chain++; onChain?.Invoke(chain); // 通知外部更新连锁计数 // 1. 标记消除 foreach (var g in matches) foreach (var (r, c) in g) board.Set(r, c, TileType.Empty); // 2. 下落:每列从下往上压实 for (int c = 0; c < board.Cols; c++) { int writeRow = board.Rows - 1; for (int r = board.Rows - 1; r >= 0; r--) { if (board.Get(r, c) != TileType.Empty) { if (r != writeRow) { board.Set(writeRow, c, board.Get(r, c)); board.Set(r, c, TileType.Empty); } writeRow--; } } // 3. 顶部补充 for (int r = writeRow; r >= 0; r--) board.Set(r, c, rng.Next(1, 6)); } yield return null; // 让出一帧,给动画留时间 } }用协程(IEnumerator)而不是普通循环,是为了在每轮连锁之间插入动画播放时间。如果你用while同步跑完,玩家看到的是方块瞬间全部消失又瞬间补满,完全没有消除的爽感。yield return null让出一帧,配合 Unity 或 WinForm 的定时器刷新,就能做出逐轮消除的节奏。参数上,chain计数可以用来做连击加分,一般第 1 轮 1 倍、第 2 轮 1.5 倍、第 3 轮以上 2 倍,具体倍率看数值策划。
4.2 补充新块时避免立即三连
顶部补充新方块时,如果完全随机,有可能补进来就直接形成三连,导致连锁停不下来。常见做法是补充时做一次检查,如果新块和左右或上下形成三连,就换一个颜色。
private int GetSafeColor(int row, int col) { int color; int guard = 0; do { color = rng.Next(1, 6); guard++; } while (guard < 20 && CreatesMatchAt(row, col, color)); return color; } private bool CreatesMatchAt(int row, int col, int color) { // 检查左二、右二、上二、下二 if (col >= 2 && board.Get(row, col-1) == color && board.Get(row, col-2) == color) return true; if (col + 2 < board.Cols && board.Get(row, col+1) == color && board.Get(row, col+2) == color) return true; if (row >= 2 && board.Get(row-1, col) == color && board.Get(row-2, col) == color) return true; return false; }guard是防止死循环的后悔药:极端情况下可能连续随机都撞上匹配,设一个上限,超过就直接用当前颜色,避免卡死。这个细节在源码里如果没写,调试时偶尔会遇到「游戏卡住不动」的玄学问题,其实就是这里死循环了。
5. 避坑与排查:三消源码里最容易翻车的 4 个点
5.1 现象:交换后没有消除,但两个方块确实互换了
原因通常是匹配检测只在交换后跑了一次,而且检测的是「交换后的棋盘」,但交换本身可能让原本不匹配的两个方块各自形成了新匹配,也可能让原本匹配的被打断。正确做法是:交换后先检测是否有匹配,如果没有,就把交换撤销回去。这个「试探—检测—回滚」的逻辑如果写反了,就会出现「能换但消不掉」或者「不能换但硬换」的 bug。
解决方式是在交换方法里做原子操作:记录两个位置的原值,交换,跑FindAllMatches,如果结果为空则换回来并播放一个抖动动画提示玩家。注意回滚时也要更新 UI,否则逻辑回滚了但界面没回滚,看起来就是「方块自己弹回去了」。
5.2 现象:连锁反应偶尔漏掉一组匹配
原因是下落和补充之后没有重新扫描整个棋盘,而是只扫描了发生变化的列。三消的匹配是全局的,下落可能导致原本不相邻的同色方块变成相邻,如果只扫描局部就会漏。我一般会在每轮下落补充后直接跑一次全盘FindAllMatches,棋盘不大(8x8 或 10x10)时性能完全够用,没必要做局部优化。
5.3 现象:特殊块触发后消除范围不对
常见于直线块和爆炸块的触发逻辑。直线块应该消除整行或整列,但有些实现里只消除了同色的整行,遇到不同色的方块就跳过了。正确逻辑是:直线块触发时,整行(或整列)所有方块无条件消除,不管颜色。爆炸块则是以自身为中心 3x3 范围全部消除。这个规则如果和匹配算法混在一起写,很容易写成「只消除同色」,导致玩家觉得特殊块「威力不够」。
5.4 现象:游戏运行一段时间后越来越卡
排查方向通常是两个:一是每次匹配都新建大量List对象,GC 压力大;二是 UI 刷新没有做脏标记,每帧都在重绘所有方块。解决方式是把匹配结果列表做成可复用的缓冲区,每次清空而不是新建;UI 层只在棋盘状态变化时刷新受影响的格子,而不是全量刷新。对于 8x8 的棋盘,优化前后帧率差距可能从 30 帧拉到稳定 60 帧。
6. 进阶技巧:用状态快照做撤销与关卡回放
三消游戏做到后期,两个需求一定会出现:撤销上一步、以及关卡回放/分享。这两个功能底层是同一件事——状态快照。因为棋盘逻辑已经全部收敛到int[] cells数组,快照就变得非常简单:每次玩家操作前,把cells复制一份存起来即可。
public class BoardSnapshot { public int[] Cells; public int Score; public int Steps; public static BoardSnapshot Capture(Board board, int score, int steps) { return new BoardSnapshot { Cells = (int[])board.Cells.Clone(), // 深拷贝,关键 Score = score, Steps = steps }; } public void Restore(Board board) { Array.Copy(Cells, board.Cells, Cells.Length); } }这里唯一的坑是Clone()必须做深拷贝。如果直接赋值Cells = board.Cells,快照和当前棋盘共享同一个数组,后续任何修改都会同时改掉快照,撤销功能直接失效。这个 bug 非常隐蔽,因为不报错,只是撤销时「撤了个寂寞」。
用快照做撤销时,一般只保留最近 10~20 步,用一个Queue<BoardSnapshot>或List做环形缓冲。步数上限根据内存和玩家习惯定,20 步足够覆盖绝大多数误操作场景。回放功能则是把每一步的操作(交换坐标)和初始棋盘存下来,回放时按顺序重放操作即可,不需要存每一步的完整棋盘,数据量小很多。
验证快照是否正确,有一个很实用的方法:连续做 5 步操作,每步都存快照,然后逐步撤销,检查每一步撤销后的棋盘是否和对应快照完全一致。如果中间某一步对不上,说明快照捕获的时机有问题——通常是在动画播放前捕获还是播放后捕获没统一。我自己的习惯是在玩家输入确认后、任何逻辑修改前捕获快照,这样撤销时能精确回到操作前的状态。
最后说一个参数层面的经验:棋盘大小和颜色数量这两个值,直接决定了游戏的手感和难度。8x8 配 5 色是经典配置,10x10 配 6 色适合做长线关卡,6x6 配 4 色则适合做快节奏限时模式。拿到源码后先别急着加功能,把这两个参数各改三档跑一跑,你对三消节奏的理解会比看十篇文章都深。希望帮到你。
本文还有配套的精品资源,点击获取