尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C#三消游戏开发实战:从算法到UI的完整实现指南

C#三消游戏开发实战:从算法到UI的完整实现指南 1. 项目概述从零构建一个C#宝石消除游戏最近在整理过去的项目时翻到了一个自己早期用C#写的宝石消除游戏Demo。这类游戏逻辑看似简单但要把点击交换、匹配检测、消除下落、连锁反应这一整套流程用代码优雅地实现并保证性能和体验里面有不少值得琢磨的细节。今天我就结合这个项目把从核心算法到界面交互的完整开发过程拆解一遍。无论你是刚接触C#游戏开发的新手还是想了解具体实现细节的同行这篇文章都能提供一个可直接参考的“脚手架”。这个项目本质上是一个典型的网格化益智游戏核心是管理一个二维网格比如8x8每个格子放置一种颜色的“宝石”。玩家交换相邻的两个宝石如果交换后能在横或竖方向上形成三个或更多同色宝石的连续序列这些宝石就会被消除上方的宝石会下落填充空位并在顶部生成新的宝石同时可能触发新的连锁消除。我们将使用C#和Windows Forms为了直观和快速原型开发来实现但核心逻辑是通用的可以轻松迁移到WPF、Unity或其他框架。2. 游戏核心设计与数据结构建模2.1 游戏状态的核心二维网格与宝石枚举一切的基础是游戏地图。我们用一个二维数组Grid[,]来表示整个游戏面板。每个网格单元Cell需要存储几种关键信息当前宝石的类型、是否为空、是否正在被标记为待消除等。为了清晰我们首先定义一个宝石类型的枚举。public enum GemType { Empty 0, // 空位用于消除后的状态 Red 1, Blue 2, Green 3, Yellow 4, Purple 5 // 可以根据需要扩展更多颜色 }接下来我们设计一个GridCell类来封装每个格子的状态。虽然对于简单Demo直接用GemType的二维数组也可以但使用类可以更方便地扩展动画状态、坐标等属性。public class GridCell { public GemType Type { get; set; } public int Row { get; set; } public int Col { get; set; } // 用于动画或状态标记 public bool IsMatched { get; set; } public bool IsEmpty Type GemType.Empty; }游戏主逻辑类GemMatchGame将持有一个GridCell[,]的实例并定义网格的行数(Rows)和列数(Cols)。初始化时我们需要用随机宝石填充这个网格但要避免一开始就出现可匹配的情况这需要一个简单的初始化算法。注意纯粹的随机填充几乎必然导致初始局面就有可消除的匹配。一个常见的处理方法是生成随机宝石后立即检查并消除所有初始匹配然后用新宝石填充重复这个过程直到没有初始匹配。或者更简单一点在玩家开始游戏前不进行检查但这样会影响初始体验。我们采用后一种并在游戏开始时给玩家一个“重新排布”的按钮或自动处理一次。2.2 游戏循环与状态管理一个消除游戏的核心循环是“等待输入 - 处理交换 - 检查匹配 - 执行消除与填充 - 检查连锁反应”。我们需要一个清晰的状态机来管理这个过程防止在动画播放期间接受输入。public enum GameState { WaitingForInput, // 等待玩家点击或拖拽 Swapping, // 宝石正在交换动画中 Matching, // 正在检查并标记匹配 Removing, // 消除匹配的宝石播放消失动画 Falling, // 宝石下落填充空位 Refilling, // 在顶部生成新宝石 GameOver }在WaitingForInput状态我们监听玩家的鼠标操作第一次点击选中一个宝石第二次点击相邻的另一个宝石则尝试交换。交换前需要先进行“预检查”即模拟交换后是否会形成至少一个匹配。如果没有则不允许交换或者交换后立即换回这是经典玩法。3. 核心算法实现详解3.1 匹配检测算法二维数组的扫描策略这是游戏逻辑中最关键的部分。给定一个GridCell[,]网格我们需要找出所有在水平或垂直方向上连续三个或以上同类型宝石的序列。一个直观但低效的方法是遍历每个格子然后向右、向下探索。更高效且清晰的方法是分别进行水平扫描和垂直扫描。水平匹配检测我们遍历每一行用一个变量currentType记录当前连续序列的宝石类型count记录连续数量。从左到右遍历该行的每一列。如果当前格子类型与currentType相同且不是Empty则count。如果不同则检查之前的count是否 3。如果是则将之前连续的那些格子标记为IsMatched true。然后重置currentType和count为新的格子类型和1。 遍历完一行后别忘了检查行末尾可能存在的匹配序列。垂直检测逻辑完全类似只是改为遍历每一列从上到下扫描。public ListListGridCell FindAllMatches() { var allMatches new ListListGridCell(); // 水平检测 for (int r 0; r Rows; r) { GemType currentType GemType.Empty; int count 0; ListGridCell currentRun new ListGridCell(); for (int c 0; c Cols; c) { var cell grid[r, c]; if (cell.Type currentType cell.Type ! GemType.Empty) { count; currentRun.Add(cell); } else { if (count 3) { // 避免重复添加一个格子可能被多个匹配序列包含但这里我们先简单处理 allMatches.Add(new ListGridCell(currentRun)); } // 重置开始新的序列 currentType cell.Type; count (cell.Type ! GemType.Empty) ? 1 : 0; currentRun.Clear(); if (count 0) currentRun.Add(cell); } } // 行末尾检查 if (count 3) { allMatches.Add(new ListGridCell(currentRun)); } } // 垂直检测逻辑类似行列循环互换 // ... return allMatches; }实操心得在标记匹配时一个宝石可能同时属于一个水平匹配和一个垂直匹配形成十字或T形。上面的简单算法可能会将这个宝石添加到两个不同的ListGridCell中。在后续消除时这可能导致重复处理。更好的做法是在GridCell上直接设置一个IsMatched布尔标记。在扫描过程中一旦发现某个格子属于一个有效匹配序列就将其IsMatched设为true。最后再遍历所有格子将所有IsMatched为true的格子收集起来进行消除。这样可以天然去重。3.2 消除与下落填充算法当所有匹配的宝石被标记后我们需要执行消除。消除不仅仅是移除宝石更重要的是处理因消除产生的空位。消除阶段遍历网格将所有IsMatched为true的格子的Type设置为GemType.Empty并清除IsMatched标记。同时可以在这里触发消除动画如渐隐、缩放。下落填充阶段这是算法的一个小难点。我们需要模拟重力效果让每个空位上方的宝石依次下落。最可靠的方法是从下往上、从左到右对每一列进行处理。 对于每一列创建一个“写指针”初始指向该列最底部Rows-1。从该列最底部向上遍历for (int r Rows-1; r 0; r--)。如果当前格子不是空的!grid[r, col].IsEmpty就将这个格子的宝石“移动”到写指针指向的位置实际上是交换两个格子的Type和状态然后将写指针向上移动一位。遍历完该列后写指针以上的所有位置包括写指针当前位置都应该被设置为空位因为它们上方的非空宝石已经下落了。public void ApplyGravity() { for (int c 0; c Cols; c) { int writeRow Rows - 1; // 从最底部开始填充 for (int r Rows - 1; r 0; r--) { if (!grid[r, c].IsEmpty) { // 如果当前位置不是写指针位置则进行“下落” if (r ! writeRow) { // 交换 writeRow 和 r 位置的宝石类型 SwapCells(grid[writeRow, c], grid[r, c]); } writeRow--; } } // 循环结束后writeRow 以上的位置全部置为空 for (int r writeRow; r 0; r--) { grid[r, c].Type GemType.Empty; } } }这里的SwapCells方法只交换GridCell的Type等核心属性而不是交换对象引用以保持行列坐标信息的正确性。顶部填充新宝石在下落完成后所有列顶部的空位需要生成新的随机宝石。只需遍历网格对所有IsEmpty的格子随机分配一个非Empty的GemType即可。3.3 交换的有效性预检查在玩家尝试交换两个相邻宝石时不能直接执行交换必须先判断这次交换是否能产生至少一个新的匹配。这需要用到“模拟交换-检查-还原”的策略。public bool IsValidSwap(GridCell cellA, GridCell cellB) { // 1. 检查是否相邻曼哈顿距离为1 if (Math.Abs(cellA.Row - cellB.Row) Math.Abs(cellA.Col - cellB.Col) ! 1) return false; // 2. 模拟交换 GemType temp cellA.Type; cellA.Type cellB.Type; cellB.Type temp; // 3. 检查交换后这两个宝石所在的行列是否有新的匹配产生 // 只需检查与cellA和cellB相关的行和列即可无需全盘扫描这是性能优化点 bool createsMatch CheckMatchAroundCell(cellA) || CheckMatchAroundCell(cellB); // 4. 还原交换 cellB.Type cellA.Type; cellA.Type temp; return createsMatch; }CheckMatchAroundCell函数需要检查该宝石所在行和列看是否能以它为中心或包含它形成至少长度为3的连续序列。实现时需要注意边界条件。踩坑记录早期版本我进行了全盘匹配检测来判断有效性在8x8的网格上虽然没问题但不够高效。后来改为只检查受交换影响的两个宝石周围性能提升明显。特别是在实现“提示”功能寻找所有可交换对时这个优化至关重要。4. 使用Windows Forms实现可视化与交互4.1 界面绘制自定义控件的OnPaint我们创建一个从Panel或Control继承的自定义控件GameBoardControl来负责绘制游戏网格。在OnPaint方法中我们需要计算每个宝石的绘制矩形Rectangle。根据GridCell.Type选择对应的颜色或图片进行填充。绘制网格线。如果宝石被选中、正在匹配或下落可以绘制不同的状态效果如边框、半透明遮罩。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 抗锯齿 int cellSize Math.Min(ClientSize.Width / Cols, ClientSize.Height / Rows); for (int r 0; r Rows; r) { for (int c 0; c Cols; c) { Rectangle rect new Rectangle(c * cellSize, r * cellSize, cellSize, cellSize); var cell gameLogic.Grid[r, c]; // 绘制背景 g.FillRectangle(Brushes.White, rect); g.DrawRectangle(Pens.LightGray, rect); if (!cell.IsEmpty) { // 根据GemType选择画刷 Brush gemBrush GetBrushByGemType(cell.Type); // 绘制宝石例如圆形 int padding 4; Rectangle gemRect Rectangle.Inflate(rect, -padding, -padding); g.FillEllipse(gemBrush, gemRect); g.DrawEllipse(Pens.Black, gemRect); // 如果被选中绘制高亮边框 if (cell selectedCell) { g.DrawRectangle(new Pen(Color.Orange, 3), rect); } // 如果被匹配绘制特殊效果 if (cell.IsMatched) { g.FillRectangle(new SolidBrush(Color.FromArgb(100, Color.Yellow)), rect); } } } } }GetBrushByGemType方法可以返回预定义的颜色画刷例如GemType.Red对应Brushes.Red。4.2 鼠标交互与游戏状态联动在自定义控件中我们需要处理鼠标事件来捕获玩家的点击。OnMouseDown: 获取点击位置换算成网格坐标(row, col)。如果这是第一次点击selectedCell为null则记录选中的格子并重绘高亮。如果已经有一个选中的格子则判断点击的另一个格子是否与其相邻且交换有效。如果有效则触发游戏逻辑的交换并开始一系列的状态更新匹配、消除、下落、填充。游戏逻辑 (GemMatchGame) 的状态变化如Grid更新需要通知到界面控件。这可以通过事件 (event) 来实现。例如当GemMatchGame完成消除、下落、填充后触发一个BoardUpdated事件。GameBoardControl订阅这个事件在事件处理程序中调用Invalidate()来触发重绘。为了有更好的视觉效果我们还需要为交换、消除、下落加入简单的动画。例如交换动画可以通过一个计时器 (Timer) 来实现在几帧内逐渐改变两个宝石的绘制位置。下落动画则可以计算每个宝石的目标位置然后在多帧内平滑移动。对于Demo我们可以简化使用Thread.Sleep配合Invalidate来实现分步重绘但这会阻塞UI线程。更好的方式是使用async/await配合Task.Delay。public async Task PerformSwapAnimationAsync(GridCell cellA, GridCell cellB) { // 计算实际像素坐标 Point posA GetCellPixelPosition(cellA); Point posB GetCellPixelPosition(cellB); int frames 10; for (int i 0; i frames; i) { float ratio (float)i / frames; // 线性插值计算中间位置 currentAnimPosA Interpolate(posA, posB, ratio); currentAnimPosB Interpolate(posB, posA, ratio); this.Invalidate(); // 请求重绘绘制时会用animPos而非网格坐标 await Task.Delay(30); // 每帧30毫秒 } // 动画结束清除动画状态逻辑上的交换早已完成 currentAnimPosA null; currentAnimPosB null; }4.3 分数计算与游戏进程控制游戏需要有一个计分系统。常见的规则是消除3个宝石得基础分如100分每多一个宝石加分如50分连锁消除有倍数奖励。我们可以在FindAllMatches方法中不仅标记匹配还计算本次消除的总宝石数然后根据规则计算得分。游戏结束的条件通常是“在规定时间内达到目标分数”或“在有限的步数内完成特定任务”。对于经典的无尽模式可以设计为“棋盘上不再存在任何可能的有效交换时游戏结束”。这就需要实现一个HasPossibleMoves函数遍历所有相邻的宝石对用IsValidSwap进行判断。如果所有相邻对都无法产生新的匹配则游戏结束。5. 性能优化与高级功能探讨5.1 匹配检测的优化技巧前面提到的匹配检测算法在每次交换后都需要全盘扫描O(Rows*Cols)对于小棋盘没问题。但如果棋盘变大如12x12并且需要频繁检查如预检查、寻找提示可以进行优化局部更新交换只影响两个宝石所在的行和列。因此在CheckMatchAroundCell中我们只需要检查这两行两列最多2*(RowsCols)个格子而不是全盘扫描。在消除下落填充后新宝石的填入也只影响各列的顶部匹配可能出现在任何地方此时才需要全盘扫描。缓存与增量检查对于“寻找所有可能交换”的提示功能可以缓存上一次的计算结果只在棋盘发生改变时重新计算。5.2 实现特殊宝石与连锁反应基础的三消玩熟了就可以加入特殊宝石来增加趣味性这也是商业游戏的常见做法。爆炸宝石Bomb消除时消除周围3x3或十字形区域的所有宝石。条纹宝石Line Clear分为横向和纵向消除时能消除整行或整列。彩虹宝石Color Bomb可以与任何颜色的宝石交换消除棋盘上所有同色的宝石。实现特殊宝石的关键在于扩展GemType枚举并在匹配检测和消除逻辑中加入特殊处理。例如当四个相同颜色的宝石连成一线时生成一个条纹宝石类型为GemType.HorizontalLine或GemType.VerticalLine。在消除阶段如果检测到特殊宝石则触发其特有的消除效果函数。连锁反应Combo是指一次交换引发的多次连续的消除、下落、填充循环。我们的游戏循环已经支持了这一点在一次消除填充后立即再次调用FindAllMatches如果还有匹配则继续消除并增加连锁计数器同时分数奖励倍增。5.3 资源管理与代码结构建议图片与音效使用图片精灵Sprite代替纯色绘制会让游戏更精美。可以将所有宝石图片放在一张大图雪碧图中通过Graphics.DrawImage和源矩形、目标矩形来绘制。音效点击、交换、匹配、消除可以使用System.Media.SoundPlayer播放简单的WAV文件或使用更高级的库如NAudio。状态管理确保游戏状态机 (GameState) 的转换是严谨的。例如在Falling状态时不能接受新的输入。使用事件来解耦逻辑层和表现层。模型-视图-控制器MVC模式虽然对于小项目有点重但清晰地区分GemMatchGame模型负责数据与规则、GameBoardControl视图负责显示、以及连接两者的控制器可以是主窗体会让代码更易于维护和测试。模型完全独立于UI便于进行单元测试。6. 常见问题与调试技巧实录6.1 宝石交换后动画与逻辑不同步问题描述点击交换两个宝石屏幕上动画播放了但有时发现逻辑上它们并没有真正交换或者交换错了位置。排查与解决检查坐标映射首先确认GetCellPixelPosition和将鼠标坐标转换为网格索引(row, col)的函数是否正确。一个常见的错误是整除时忽略了偏移量。确保公式是row mouseY / cellSize并且cellSize计算准确。动画与逻辑的时序确保动画开始前游戏逻辑已经完成了宝石数据的交换。我的推荐顺序是用户点击 - 验证有效性 - 更新模型数据grid交换- 开始播放交换动画 - 动画结束后触发匹配检查。不要在动画播放中途去修改模型数据。线程安全问题如果使用了多线程或异步任务来播放动画要确保对共享数据如grid数组的访问是线程安全的或者在UI线程上执行数据更新。WinForms的控件方法通常需要在创建它的线程上调用。6.2 消除与下落填充后出现“悬空”宝石问题描述消除一行宝石后上方的宝石没有完全下落到底部或者下落位置错乱导致视觉上有宝石浮在空中。排查与解决仔细检查ApplyGravity算法最常见的原因是下落算法的指针逻辑错误。按照前面所述的从下往上遍历使用写指针的方法是可靠的。可以针对单列在纸上画图模拟或者用调试器逐行跟踪writeRow的变化。检查“空位”的定义确保IsEmpty属性或判断GemType.Empty的逻辑在整个下落过程中是一致的。有时在消除阶段只设置了IsMatchedtrue但忘了将Type设为Empty。绘制顺序问题在OnPaint中确保是先绘制背景再按正确的Z-order绘制宝石。如果下落动画是实时计算位置绘制要确保动画状态能正确反映逻辑上的最终位置。6.3 游戏卡顿或响应缓慢问题描述当棋盘变大或连续发生多次连锁消除时游戏界面出现明显的卡顿。排查与解决避免在UI线程进行密集计算匹配检测、寻找可行交换等算法操作如果棋盘很大可能会耗时。可以考虑使用Task.Run将这些计算移到后台线程但注意计算结果需要回到UI线程来更新控件。优化绘制在OnPaint中只绘制需要更新的区域脏矩形可以使用Invalidate(Rectangle)代替整个控件的Invalidate()。对于每个宝石的绘制避免创建大量的Brush和Pen对象应该复用。简化动画如果动画很复杂考虑减少动画帧数或者使用更高效的动画技术。对于下落动画可以不用为每个宝石单独做缓动而是整体步进式下落。6.4 无法检测到“T”型或“L”型匹配问题描述算法只检测到了横向和纵向的匹配但四个宝石组成的“田”字形或五个宝石组成的“T”字形有时会被拆分成两个单独的匹配而不是作为一个组合处理。问题根源我们之前实现的FindAllMatches是分别进行行扫描和列扫描。一个“田”字形2x2方块在行扫描中会被识别为两个长度为2的匹配不满足3在列扫描中同样如此因此会被忽略。解决方案对于需要支持特殊形状匹配的规则需要更通用的匹配检测算法。一种方法是使用连通分量分析。将棋盘视为图每个宝石是一个节点与上下左右同色的宝石有边相连。使用深度优先搜索DFS或广度优先搜索BFS找出所有连通区域然后筛选出大小3的区域作为匹配。这样任何形状的连续同色区域都能被检测到。public ListListGridCell FindMatchesByFloodFill() { var allMatches new ListListGridCell(); bool[,] visited new bool[Rows, Cols]; for (int r 0; r Rows; r) { for (int c 0; c Cols; c) { if (!visited[r, c] !grid[r, c].IsEmpty) { var connectedGems FloodFill(r, c, grid[r, c].Type, visited); if (connectedGems.Count 3) { allMatches.Add(connectedGems); } } } } return allMatches; } private ListGridCell FloodFill(int startRow, int startCol, GemType targetType, bool[,] visited) { ListGridCell result new ListGridCell(); StackPoint stack new StackPoint(); stack.Push(new Point(startCol, startRow)); while (stack.Count 0) { var p stack.Pop(); int r p.Y; int c p.X; if (r 0 || r Rows || c 0 || c Cols) continue; if (visited[r, c]) continue; if (grid[r, c].Type ! targetType) continue; visited[r, c] true; result.Add(grid[r, c]); // 检查四邻域 stack.Push(new Point(c, r - 1)); // 上 stack.Push(new Point(c, r 1)); // 下 stack.Push(new Point(c - 1, r)); // 左 stack.Push(new Point(c 1, r)); // 右 } return result; }这个算法比简单的行列扫描稍慢但功能更强大能适应更复杂的匹配规则。在实际项目中可以根据游戏规则选择最合适的检测方法。
返回列表