ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于C#的开心消消乐游戏设计源码:从棋盘建模到消除下落完整实现

基于C#的开心消消乐游戏设计源码:从棋盘建模到消除下落完整实现 简介这是一份面向C#初学者与游戏开发爱好者的开心消消乐游戏源码适合用于课程设计、毕业设计或自学练手帮助理解桌面端休闲游戏的完整实现思路。资源包共44个文件约2.54MB以17张png图片素材、11个C#源代码文件为主另含resx资源文件、json配置、sqlite数据库及sln解决方案等覆盖界面绘制、方块逻辑、资源管理与项目配置等模块结构清晰便于二次修改。目前已有472人学习下载具备一定参考热度。读者可从中获得可直接编译运行的完整工程学习WinForms窗体布局、消除算法与游戏状态管理并借助图片素材与配置文件快速替换主题、调整关卡难度是入门C#游戏开发与课程实践的一份实用参考。1. 从零拆解基于C#的开心消消乐游戏设计源码到底能跑出什么很多人第一次搜“基于C#的开心消消乐游戏设计源码”心里想的其实是两件事一是想找一个能直接跑起来的三消Demo二是想看看别人怎么用C#把消除逻辑、关卡配置、动画反馈串成一条线。我当初也是这么想的结果下载了几个版本后发现能跑和能改是两码事。开心消消乐这类三消游戏核心不在画面多炫而在棋盘数据结构、匹配检测、消除下落、补充新块这四步的闭环。C#配合Unity或者WinForms都能做但如果你要的是可维护、可扩展的源码结构那重点看的是它怎么把规则和表现分离。这篇内容适合两类人刚学完C#基础语法想找个完整项目练手的以及接到三消类外包需求需要快速搭原型的。接下来我会按实际落地顺序把棋盘建模、匹配算法、消除下落、关卡配置、性能避坑这几个环节拆开讲每一步都给可复现的代码和参数说明。2. 棋盘与元素建模用C#把8x8网格变成可计算的数据结构2.1 为什么不用二维数组直接存GameObject新手最容易踩的坑就是拿一个GameObject[,]或者Button[,]当棋盘。这样写匹配逻辑时每次都要去访问场景对象、读组件、比Tag代码又慢又乱。我一般会分成两层一层是纯数据的BoardModel用整型或枚举表示元素类型另一层是BoardView负责把数据映射成场景里的图标。这样匹配算法只跟整数打交道测试起来也方便。// 元素类型枚举0-5对应六种基础颜色 public enum TileType { Red 0, Blue 1, Green 2, Yellow 3, Purple 4, Orange 5, Empty -1 // 表示已被消除、等待下落填充 } // 棋盘数据模型 public class BoardModel { public int Rows { get; private set; } public int Cols { get; private set; } private TileType[,] _grid; public BoardModel(int rows, int cols) { Rows rows; Cols cols; _grid new TileType[rows, cols]; } public TileType Get(int r, int c) _grid[r, c]; public void Set(int r, int c, TileType t) _grid[r, c] t; // 判断坐标是否在棋盘内 public bool InBounds(int r, int c) r 0 r Rows c 0 c Cols; }这段代码的关键在于TileType.Empty这个状态。很多源码里用null或者直接销毁对象结果下落逻辑写起来到处判空。用Empty统一表示“这个格子暂时没东西”后续的填充算法只需要检查Empty就行。参数方面Rows和Cols建议做成可配置常见三消是8x8或9x9但有些关卡会做成不规则形状这时候可以在BoardModel外面再包一层bool[,] mask来表示哪些格子可用。2.2 初始化时如何避免开局就有三连如果初始化完全随机大概率一开局就自动消除玩家还没操作就掉分。我一般用“生成时检查左两个和上两个”的策略填每个格子时随机一个类型如果它和左边两个相同、或者和上边两个相同就重新随机直到不冲突。public void FillRandom(System.Random rand) { for (int r 0; r Rows; r) { for (int c 0; c Cols; c) { TileType t; do { t (TileType)rand.Next(0, 6); // 0到5 } while ( (c 2 Get(r, c - 1) t Get(r, c - 2) t) || (r 2 Get(r - 1, c) t Get(r - 2, c) t) ); Set(r, c, t); } } }这里rand.Next(0, 6)的上限6是开区间实际取0到5。如果你想要更多颜色改这个数字的同时也要同步改TileType枚举。注意do-while在极端情况下可能循环多次但8x8棋盘下概率很低实测平均每格重试不到1.2次。如果棋盘很大可以加一个最大重试次数超过就强制放一个和左边不同的类型避免死循环。3. 匹配检测与消除横向纵向扫描的两种写法与性能取舍3.1 全盘扫描法简单但每帧都跑会卡最直观的匹配检测就是遍历每个格子分别向右和向下延伸看有没有连续三个及以上相同。这种写法逻辑清晰适合刚上手时验证规则。// 返回所有需要消除的坐标集合 public HashSet(int r, int c) FindMatches() { var matches new HashSet(int, int)(); // 横向扫描 for (int r 0; r Rows; r) { int count 1; for (int c 1; c Cols; c) { bool same c Cols Get(r, c) ! TileType.Empty Get(r, c) Get(r, c - 1); if (same) { count; } else { if (count 3) { for (int k c - count; k c; k) matches.Add((r, k)); } count 1; } } } // 纵向扫描逻辑同上交换行列 for (int c 0; c Cols; c) { int count 1; for (int r 1; r Rows; r) { bool same r Rows Get(r, c) ! TileType.Empty Get(r, c) Get(r - 1, c); if (same) { count; } else { if (count 3) { for (int k r - count; k r; k) matches.Add((k, c)); } count 1; } } } return matches; }这段代码里count 3是消除阈值如果你要做四消、五消的特殊块就在这个分支里根据count的值生成不同道具。HashSet用来去重因为横向和纵向可能同时命中同一个格子。性能上8x8棋盘全盘扫描一次大概几十微秒但如果你在Update里每帧调用累积起来也会掉帧。我一般只在玩家交换后、下落完成后调用一次。3.2 增量检测只查交换位置周围适合实时反馈如果要做拖拽过程中实时高亮可消除区域全盘扫描就太重了。这时候可以只检查交换的两个格子所在的行和列。思路是交换后分别以这两个位置为中心向左右和上下延伸看能否形成三连。// 检查指定位置是否参与三连返回相关坐标 public List(int r, int c) CheckAt(int r, int c) { var result new List(int, int)(); var type Get(r, c); if (type TileType.Empty) return result; // 横向延伸 var line new List(int, int) { (r, c) }; for (int i c - 1; i 0 Get(r, i) type; i--) line.Add((r, i)); for (int i c 1; i Cols Get(r, i) type; i) line.Add((r, i)); if (line.Count 3) result.AddRange(line); // 纵向延伸 line new List(int, int) { (r, c) }; for (int i r - 1; i 0 Get(i, c) type; i--) line.Add((i, c)); for (int i r 1; i Rows Get(i, c) type; i) line.Add((i, c)); if (line.Count 3) result.AddRange(line); return result; }这种写法在交换动画播放时调用能立刻知道这次交换有没有产生消除从而决定是播放成功动画还是回退。注意result里可能有重复坐标用之前去重一下。参数上CheckAt只适合检测单次交换不适合全盘初始化检测因为初始化时没有“中心点”。4. 消除下落与补充用队列把空位填满的稳定写法4.1 下落逻辑从下往上扫描把非空元素往下压消除之后上面的元素要掉下来。我见过有人用物理引擎做下落结果元素弹来弹去完全不可控。正确做法是纯逻辑位移对每一列从最下面往上扫维护一个写入位置writeRow遇到非空元素就写到writeRow然后writeRow上移。public void ApplyGravity() { for (int c 0; c Cols; c) { int writeRow Rows - 1; for (int r Rows - 1; r 0; r--) { if (Get(r, c) ! TileType.Empty) { if (r ! writeRow) { Set(writeRow, c, Get(r, c)); Set(r, c, TileType.Empty); } writeRow--; } } } }这段代码执行完后每一列的非空元素都沉到底部上面全是Empty。writeRow从Rows-1开始每写入一个就减一。注意Set(writeRow, c, ...)之后要把原位置置空否则会复制出两个相同元素。这个算法的时间复杂度是O(Rows*Cols)8x8下几乎无感。4.2 补充新元素从顶部空位随机填充并再次检查匹配下落完成后顶部的Empty需要填新元素。填充时同样要避免立即形成三连但这里可以放宽允许偶尔出现可消除组合因为玩家下一步可能正好利用。不过如果连续自动消除太多体验会很差。我一般填充时只检查纵向因为横向在填充过程中不容易连续。public void Refill(System.Random rand) { for (int c 0; c Cols; c) { for (int r 0; r Rows; r) { if (Get(r, c) TileType.Empty) { TileType t; int guard 0; do { t (TileType)rand.Next(0, 6); guard; } while (r 2 Get(r - 1, c) t Get(r - 2, c) t guard 10); Set(r, c, t); } } } }guard是防止死循环的保险超过10次就强制放置。填充完后通常要再跑一次FindMatches如果还有匹配就继续消除形成连锁。连锁次数建议设上限比如5次超过就强制停止并重新洗牌避免无限循环。4.3 交换合法性判断相邻且能产生消除才允许玩家交换两个相邻元素时不是所有交换都合法。常见规则是交换后必须能形成至少一个三连否则回退。实现时先交换数据调用CheckAt检查两个位置如果都没有三连就换回来。public bool TrySwap(int r1, int c1, int r2, int c2) { // 必须相邻 if (Math.Abs(r1 - r2) Math.Abs(c1 - c2) ! 1) return false; // 交换 var tmp Get(r1, c1); Set(r1, c1, Get(r2, c2)); Set(r2, c2, tmp); // 检查是否产生消除 bool valid CheckAt(r1, c1).Count 3 || CheckAt(r2, c2).Count 3; if (!valid) { // 回退 tmp Get(r1, c1); Set(r1, c1, Get(r2, c2)); Set(r2, c2, tmp); } return valid; }Math.Abs(r1 - r2) Math.Abs(c1 - c2) ! 1用来判断上下左右相邻对角线不算。这个判断放在交换之前避免无效操作。如果游戏允许对角线交换改成 2并额外判断不相等即可。5. 避坑与排查三消源码里最容易翻车的五个地方5.1 消除后索引越界现象是IndexOutOfRangeException原因是在遍历中修改了集合很多人写消除时一边遍历matches一边从棋盘里移除元素结果集合被修改后迭代器失效。解决方法是先收集所有要消除的坐标遍历结束后再统一置为Empty。如果用的是List也可以用倒序遍历删除。5.2 下落动画与数据不同步现象是图标位置对不上原因是先播动画后改数据我见过一个版本先让图标飞下去再更新BoardModel结果玩家在动画期间快速点击数据就乱了。正确顺序是先改数据再根据数据变化计算动画目标位置。动画只是表现数据永远是权威。5.3 连锁消除死循环现象是游戏卡死原因是补充后立即匹配且没有上限如果补充的新元素恰好又形成三连就会触发下一轮消除理论上可能无限循环。虽然概率极低但必须有保护。我一般设chainCount超过5次就强制打乱棋盘或者直接结束本轮。5.4 特殊块生成条件写错现象是四消没有生成炸弹原因是判断顺序不对四消、五消的检测要在FindMatches之后单独做不能混在三消里。常见做法是先找出所有匹配然后检查每个匹配的长度如果长度是4就生成一个特殊块放在交换位置长度是5就生成另一个。注意特殊块本身也要参与后续匹配。5.5 棋盘初始化太慢现象是进入关卡卡顿原因是每次随机都做全盘冲突检测如果棋盘是20x20以上逐格随机加冲突检测会明显变慢。优化方法是按行生成每行只检查左边两个同时记录上一行的数据来检查上方两个。这样一次遍历就能完成不需要反复重试。6. 关卡配置与难度曲线用ScriptableObject把数值从代码里抽出来6.1 为什么要把关卡数据做成独立资产如果关卡目标、步数限制、棋盘大小都硬编码在MonoBehaviour里每加一关就要改代码、重新编译。我一般用ScriptableObject存关卡配置策划可以直接在Inspector里改不用碰代码。using UnityEngine; [CreateAssetMenu(fileName LevelConfig, menuName Match3/LevelConfig)] public class LevelConfig : ScriptableObject { public int rows 8; public int cols 8; public int moveLimit 20; public int targetScore 1000; public TileType[] allowedTypes; // 本关允许出现的元素类型 public float spawnWeightRed 1f; // 各类型权重用于控制难度 public float spawnWeightBlue 1f; // 其他权重省略 }allowedTypes用来限制前期关卡只出现4种颜色降低难度。spawnWeight可以做成数组但为了Inspector友好我一般写成独立字段。注意ScriptableObject在运行时修改不会持久化如果要做动态难度需要复制一份实例。6.2 难度曲线的三个可调参数根据我改过多个三消项目的经验难度主要受三个参数影响颜色数量、步数限制、目标分数。前期4种颜色、25步、目标800分玩家平均能剩5步以上中期5种颜色、20步、目标1500分开始需要规划后期6种颜色、18步、目标2500分基本每一步都要算。调整时不要三个一起动先加颜色再加目标分最后减步数。阶段颜色数步数目标分特殊块出现率前期425800低中期5201500中后期6182500高特殊块出现率可以通过调整四消、五消的生成概率来控制前期可以只生成炸弹后期再加入直线消除。6.3 用Editor脚本批量生成关卡资产如果关卡多手动创建ScriptableObject很累。写一个Editor脚本按规则批量生成。#if UNITY_EDITOR using UnityEditor; using UnityEngine; public class LevelGenerator : EditorWindow { [MenuItem(Match3/Generate Levels)] static void Generate() { for (int i 1; i 30; i) { var config ScriptableObject.CreateInstanceLevelConfig(); config.rows 8; config.cols 8; config.moveLimit Mathf.Max(15, 30 - i / 3); config.targetScore 500 i * 100; AssetDatabase.CreateAsset(config, $Assets/Levels/Level_{i}.asset); } AssetDatabase.SaveAssets(); } } #endifMathf.Max(15, 30 - i / 3)保证步数不低于15。AssetDatabase.CreateAsset的路径要提前建好文件夹。这个脚本只在编辑器下运行打包时会被排除。生成后可以手动微调个别关卡比从零创建快很多。6.4 运行时加载与切换关卡加载时用Resources.Load或者Addressables。如果关卡不多Resources够用如果要做热更新建议用Addressables。加载后把LevelConfig传给BoardModel和GameManager初始化棋盘和UI。public class GameManager : MonoBehaviour { public LevelConfig currentLevel; private BoardModel _model; void Start() { _model new BoardModel(currentLevel.rows, currentLevel.cols); _model.FillRandom(new System.Random()); // 后续绑定View } }currentLevel可以在Inspector里指定也可以通过关卡选择界面动态赋值。注意FillRandom之后要立即检查一次匹配如果有就消除并补充直到稳定否则玩家一进来就看到可消除组合会以为游戏出bug了。最后一章我想聊一个具体技巧怎么用C#的async/await把消除动画串起来而不是用协程嵌套。我早期项目里用Coroutine做连锁消除代码里全是yield return new WaitForSeconds嵌套三层以后根本看不懂。后来改成async方法每一步await一个动画完成的任务逻辑线性很多。比如消除动画、下落动画、补充动画各写一个返回Task的方法主流程就是await ClearAsync(); await FallAsync(); await RefillAsync();连锁用while循环包起来。唯一要注意的是Unity里async方法不能直接操作UI需要回到主线程可以用TaskCompletionSource或者UniTask插件。这个改动让我的连锁逻辑从200多行降到80行而且加新步骤时不用再算协程嵌套层级。如果你也在维护三消源码建议尽早把协程换成async后期改需求会轻松很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表