1. 从零构建消除逻辑为什么核心算法决定了整个游戏的成败消灭星星这个游戏看起来规则简单到一句话就能说清——点两个以上相邻的同色方块它们就消失上面的方块掉下来旁边的方块补过去。但真正动手写的时候你会发现消除逻辑是整个项目里最容易写崩的部分。我见过不少人用二维数组硬写结果连锁消除的时候索引越界、死循环、方块悬空各种问题层出不穷。这一篇作为系列的第三部分重点就是解决这个核心问题。前两部分我们搞定了EasyX的绘图环境和基础界面搭建现在要把游戏真正跑起来。适合已经有一定C语言基础、了解数组和指针操作、并且已经配置好EasyX环境的读者。如果你还没看过前两篇建议先回去补一下因为这里会直接用到前面的图形接口和数据结构。先说说整体思路。消灭星星的消除逻辑本质上是一个图搜索问题。每个方块是图中的一个节点相邻的同色方块之间有边相连。点击一个方块后需要找到所有与它连通且颜色相同的方块这就是一个典型的连通分量搜索。用BFS广度优先搜索或者DFS深度优先搜索都可以我选BFS原因是它天然适合用队列实现而且不会像递归DFS那样在方块数量多的时候有栈溢出的风险。为什么不用简单的双重循环遍历因为双重循环只能处理规则形状的连通区域而消灭星星的连通区域是不规则的可能拐弯、可能分叉。比如一个L形或者蛇形的同色区域双重循环根本没法正确识别。所以必须用图搜索的方式来做。整个游戏的数据结构我设计得很简单一个二维整型数组grid[ROW][COL]每个元素存储颜色编号0表示空。行数ROW设为10列数COL设为10这是经典消灭星星的标准尺寸。为什么选10×10因为太小了游戏很快就结束太大了屏幕上的方块会很小影响点击精度。10×10在800×600的窗口下每个方块大约50像素手指点击或者鼠标操作都很舒服。#define ROW 10 #define COL 10 #define BLOCK_SIZE 50 int grid[ROW][COL];这个数组就是整个游戏的状态核心。所有的消除、下落、左移操作都是对这个数组的变换。绘图的时候遍历这个数组根据每个位置的值画对应颜色的方块。逻辑和渲染分离这是保证代码可维护性的关键。2. 连通区域搜索BFS的实现细节与避坑指南2.1 为什么选择BFS而不是DFSBFS和DFS都能找到连通区域但在消灭星星这个场景下BFS有几个实际优势。第一BFS可以用一个固定大小的数组模拟队列不需要递归调用内存可控。第二BFS的搜索顺序是从中心向外扩散这在调试的时候更直观你能清楚地看到搜索的层次。第三当连通区域特别大的时候DFS的递归深度可能达到上百层虽然现代编译器一般能处理但万一栈空间不够就会崩溃BFS完全没这个问题。我用的队列实现方式是用一个结构体数组加两个指针typedef struct { int x; int y; } Point; Point queue[ROW * COL]; int head, tail;队列的最大长度就是ROW*COL因为每个方块最多入队一次。head指向队首tail指向队尾。入队就是queue[tail] p出队就是p queue[head]。简单粗暴但绝对高效。2.2 搜索算法的完整实现搜索函数的输入是点击位置的坐标输出是所有连通方块的坐标列表以及数量。先检查点击位置是否合法然后从该位置开始BFS。int visited[ROW][COL]; int bfs(int startX, int startY, Point result[]) { int color grid[startX][startY]; if (color 0) return 0; memset(visited, 0, sizeof(visited)); head tail 0; queue[tail] (Point){startX, startY}; visited[startX][startY] 1; int count 0; int dirs[4][2] {{-1,0},{1,0},{0,-1},{0,1}}; while (head tail) { Point cur queue[head]; result[count] cur; for (int i 0; i 4; i) { int nx cur.x dirs[i][0]; int ny cur.y dirs[i][1]; if (nx 0 || nx ROW || ny 0 || ny COL) continue; if (visited[nx][ny]) continue; if (grid[nx][ny] ! color) continue; visited[nx][ny] 1; queue[tail] (Point){nx, ny}; } } return count; }这段代码有几个关键点需要说明。visited数组每次搜索前都要清零否则上一次的搜索结果会污染这一次。方向数组dirs定义了上下左右四个方向注意这里只考虑四连通不考虑对角线。为什么因为消灭星星的规则就是上下左右相邻对角线不算连通。如果你把对角线也算上游戏难度会大幅降低而且不符合原版规则。注意visited数组如果开在函数内部每次调用都会在栈上分配内存。10×10的int数组是400字节问题不大。但如果你的网格更大建议开成全局变量或者静态变量避免栈空间浪费。2.3 搜索结果的边界情况处理有几个边界情况必须处理。第一点击的位置是空的grid值为0直接返回0不做任何操作。第二连通区域只有一个方块按照消灭星星的规则单个方块不能消除所以返回1的时候也不执行消除。第三点击位置越界这个在鼠标消息处理的时候就要过滤掉。我在实际开发中就踩过一个坑忘记检查点击位置是否为空。结果玩家点击空白区域的时候程序会以0为颜色值去搜索把所有空区域都连成一片然后试图消除它们。虽然不会崩溃但逻辑完全错了。后来加了一行if (color 0) return 0;就解决了。还有一个容易忽略的点搜索结果的存储。我传入了一个result数组来接收坐标这个数组的大小必须是ROW*COL因为最坏情况下所有方块都是连通的。如果你只开了一个小数组就会越界写入导致内存损坏。这种bug特别难查因为症状可能在其他地方才表现出来。3. 消除与下落数组变换的完整流程3.1 消除操作的实现搜索到连通区域后如果数量大于等于2就执行消除。消除很简单把result中所有坐标对应的grid值设为0。void eliminate(Point result[], int count) { for (int i 0; i count; i) { grid[result[i].x][result[i].y] 0; } }但消除之后不能就这么算了必须处理下落和左移否则方块会悬空游戏状态就乱了。3.2 下落逻辑逐列处理下落的原则是每一列中所有非空方块向下沉空位留在上方。实现方式是从下往上遍历每一列用一个写指针记录当前可以放置的位置。void applyGravity() { for (int j 0; j COL; j) { int writePos ROW - 1; for (int i ROW - 1; i 0; i--) { if (grid[i][j] ! 0) { grid[writePos][j] grid[i][j]; if (writePos ! i) { grid[i][j] 0; } writePos--; } } // 剩余位置清零 for (int i writePos; i 0; i--) { grid[i][j] 0; } } }这段代码的逻辑是从底部开始遇到非空方块就把它写到writePos位置然后writePos上移。如果writePos和i不同说明方块移动了原来的位置要清零。最后把writePos以上的所有位置都清零。为什么从下往上遍历因为方块是向下落的从下往上处理可以保证每个方块只移动一次而且不会覆盖还没处理的数据。如果从上往下遍历上面的方块下落时会覆盖下面的方块导致数据丢失。3.3 左移逻辑逐行处理左移的原则是每一行中所有非空方块向左靠拢空位列留在右侧。实现方式和下落类似只是方向变了。void applyShift() { for (int i 0; i ROW; i) { int writePos 0; for (int j 0; j COL; j) { if (grid[i][j] ! 0) { grid[i][writePos] grid[i][j]; if (writePos ! j) { grid[i][j] 0; } writePos; } } for (int j writePos; j COL; j) { grid[i][j] 0; } } }左移和下落是对称的操作理解了其中一个另一个就很好懂。注意左移是在下落之后执行的顺序不能反。如果先左移再下落方块的位置会不对。实操心得下落和左移的顺序必须是先下落再左移。因为下落之后可能产生新的空列左移才能正确地把这些空列消除。如果反过来先左移再下落某些方块的下落路径会被改变最终结果和预期不一致。3.4 连锁消除的处理消灭星星有一个很重要的机制消除后如果形成新的可消除区域不会自动消除需要玩家再次点击。这一点和很多三消游戏不同。所以我们的代码不需要处理连锁消除每次玩家点击只执行一次消除、下落、左移。这个设计简化了很多逻辑也符合原版游戏的体验。但有一个细节需要注意消除后如果整个网格都空了游戏应该结束并结算分数。这个判断放在每次操作之后执行。int isGameOver() { for (int i 0; i ROW; i) { for (int j 0; j COL; j) { if (grid[i][j] ! 0) return 0; } } return 1; }4. 分数计算与游戏状态管理4.1 分数公式的设计消灭星星的分数计算有一个经典公式n个方块消除得分为n的平方。比如消除3个得9分消除5个得25分消除10个得100分。这个公式的设计意图是鼓励玩家攒大块一起消而不是零敲碎打。因为5个单独消除两次23只能得4913分而攒到5个一起消得25分差距很明显。int calcScore(int count) { return count * count; }为什么用平方而不是其他函数平方的增长速度适中既不会像指数那样让大块消除的收益过于夸张也不会像线性那样让攒大块没有意义。这是经过验证的经典设计我们直接沿用。4.2 游戏状态的定义与切换游戏状态我用一个枚举来表示typedef enum { STATE_PLAYING, STATE_GAMEOVER } GameState;游戏进行中鼠标点击才有效。游戏结束后点击无效需要按特定键重新开始。这个状态机虽然简单但能避免很多逻辑错误。比如游戏结束后还能继续点击消除那就乱套了。状态切换的时机每次消除操作完成后检查isGameOver()如果为真就切换到STATE_GAMEOVER。重新开始的时候重置grid数组、分数、状态。4.3 剩余方块数的统计游戏结束时需要显示剩余方块数如果剩余方块数少于10个通常有额外奖励。这个统计很简单遍历整个数组数非零元素。int countRemaining() { int cnt 0; for (int i 0; i ROW; i) for (int j 0; j COL; j) if (grid[i][j] ! 0) cnt; return cnt; }剩余方块数的奖励规则我设定为剩余0个奖励2000分剩余1-9个奖励10-剩余数×100分剩余10个及以上无奖励。这个规则可以根据自己的喜好调整不影响核心逻辑。5. 鼠标交互与点击判定5.1 坐标转换从屏幕坐标到网格坐标EasyX的鼠标消息返回的是屏幕坐标需要转换成网格的行列索引。转换公式很简单int col mouseX / BLOCK_SIZE; int row mouseY / BLOCK_SIZE;但这里有一个坑如果鼠标点在窗口边缘或者方块之间的间隙除法结果可能越界。所以必须加边界检查if (row 0 || row ROW || col 0 || col COL) return;我在实际测试中发现如果窗口有边框或者标题栏鼠标坐标的原点可能不在客户区左上角。EasyX的getmouse或者MOUSEMSG返回的坐标是相对于绘图窗口的一般没问题。但如果你用了initgraph的偏移参数就要把这个偏移减掉。5.2 点击反馈与动画效果为了提升手感点击方块后应该有一个短暂的视觉反馈。最简单的做法是点击后方块闪烁一下或者改变边框颜色。我用的是选中高亮鼠标移动时悬停的方块画一个白色边框点击时消除的方块先变成白色再消失。这个效果实现起来不难但要注意性能。如果每帧都重绘整个屏幕10×10的方块加上边框在EasyX下完全没问题。但如果加了复杂的动画就要考虑双缓冲了。EasyX默认的绘图是直接写显存的可能会有闪烁。用BeginBatchDraw()和FlushBatchDraw()可以解决。BeginBatchDraw(); // 所有绘图操作 FlushBatchDraw();注意BeginBatchDraw和FlushBatchDraw必须成对使用而且中间不要有Sleep时间过长的操作否则界面会卡住不刷新。5.3 无效点击的处理无效点击包括点击空白区域、点击单个方块连通数为1、游戏结束后点击。这些情况都应该被忽略不产生任何效果。但为了用户体验可以给一个轻微的提示比如点击单个方块时让它抖动一下或者播放一个短促的音效。音效需要额外的库这里就不展开了重点是把逻辑判断做对。6. 常见问题与排查技巧实录6.1 消除后方块位置错乱这是最常见的问题症状是消除后有些方块消失了但位置不对或者方块重叠。根本原因通常是下落和左移的顺序搞反了或者写指针的逻辑有bug。排查方法在每次操作后打印整个grid数组对比预期结果。比如消除前是1 1 2 3 4 2 5 6 7消除两个1之后预期应该是0 0 2 3 4 2 5 6 7然后下落0 0 0 3 4 2 5 6 7再左移0 0 0 3 4 2 5 6 7如果打印出来的结果和预期不符就逐步检查下落和左移的代码。我建议把下落和左移写成独立的函数单独测试确认无误后再组合使用。6.2 搜索时数组越界BFS搜索时方向数组的四个方向都要检查边界。我见过有人只检查了上下边界忘了左右边界结果搜索到最右边一列时越界访问。这种bug在Debug模式下可能触发断言在Release模式下可能静默读取到垃圾数据导致搜索结果莫名其妙。排查方法在访问grid[nx][ny]之前确保nx在0到ROW-1之间ny在0到COL-1之间。这个检查必须放在所有其他检查之前因为越界访问的后果最严重。6.3 游戏结束后无法重新开始这个问题的原因通常是状态没有正确重置。重新开始时需要做以下几件事grid数组全部清零并重新填充随机颜色、分数归零、状态设为PLAYING、清除所有临时数据visited数组、队列等。我建议写一个resetGame()函数把所有重置逻辑集中在一起。这样每次重新开始只需要调用这一个函数不会遗漏。void resetGame() { memset(grid, 0, sizeof(grid)); memset(visited, 0, sizeof(visited)); head tail 0; score 0; state STATE_PLAYING; initGrid(); }6.4 颜色随机分布不均匀初始化网格时如果直接用rand() % COLOR_COUNT理论上每种颜色的概率相同。但实际上rand()的低位随机性较差可能导致颜色分布不均匀。更好的做法是用rand() % COLOR_COUNT之前先调用一次rand()或者用高位来取模。int color (rand() 4) % COLOR_COUNT 1;右移4位是取rand()的高位随机性更好。这个问题在方块数量少的时候不明显但10×10的网格有100个方块分布不均匀会直接影响游戏体验。6.5 常见问题速查表问题现象可能原因解决方法消除后方块悬空下落逻辑未执行或顺序错误确保先下落再左移点击无反应坐标转换错误或边界检查过严打印鼠标坐标和网格坐标对比搜索到不该连通的方块方向数组包含了对角线只保留上下左右四个方向游戏卡死BFS死循环visited未标记确保入队前标记visited分数计算错误用了线性公式而非平方检查calcScore函数重新开始后状态残留resetGame不完整检查所有全局变量是否重置7. 性能优化与代码组织建议7.1 绘图性能的优化10×10的网格每帧最多画100个方块对EasyX来说毫无压力。但如果你把方块画得很复杂比如带渐变、阴影、圆角性能就会下降。我的建议是方块用纯色填充加边框最多再加一个高光效果。这样既好看又高效。如果确实需要复杂效果可以用双缓冲先在内存DC上绘制再一次性拷贝到屏幕。EasyX的BeginBatchDraw已经做了类似的事情但它是针对整个绘图窗口的。对于局部更新可以自己创建IMAGE对象作为缓冲。7.2 代码文件的组织我习惯把代码分成三个文件main.c放主循环和消息处理game.c放游戏逻辑搜索、消除、下落、左移、分数game.h放数据结构和函数声明。这样逻辑清晰修改的时候不容易出错。如果你用的是Visual Studio记得把game.c添加到项目里否则链接的时候会报未定义符号。这个坑我踩过找了半天才发现是文件没加进项目。7.3 可扩展性的考虑现在的代码是硬编码的10×10和固定颜色数。如果想做成可配置的可以把这些参数放到一个配置结构体里初始化的时候传入。这样以后想改成8×8或者12×12只需要改一个数字。颜色数也是一样现在用的是5种颜色如果改成6种难度会上升。把这些参数抽出来游戏的可玩性调整就方便多了。8. 从逻辑到体验一些实战中的细节打磨8.1 消除动画的简单实现虽然前面说不需要复杂的动画但一个简单的消除效果能大幅提升手感。我的做法是点击后被消除的方块先变成白色延迟100毫秒再执行下落和左移。这个延迟用Sleep(100)实现简单有效。但要注意Sleep会阻塞消息循环如果延迟太长界面会卡住。100毫秒是经过测试的既能看清效果又不会觉得卡顿。如果你想要更流畅的动画可以用定时器或者多线程但那就复杂了没必要。8.2 音效的替代方案EasyX本身不支持音效需要额外的库。如果不想引入新库可以用Windows API的PlaySound函数播放wav文件。但这样会增加代码的复杂度而且音效文件需要额外管理。我的建议是先不做音效把核心玩法做扎实。音效是锦上添花不是必需品。8.3 难度曲线的调整消灭星星的难度主要取决于颜色数和网格大小。颜色越多连通区域越小难度越高。10×10的网格配5种颜色是我测试下来比较平衡的配置。如果你觉得太简单可以加到6种颜色觉得太难减到4种。另外初始网格的随机分布也会影响难度。如果随机出来的网格有很多大块连通区域游戏就简单反之就难。可以在初始化的时候加一个检查如果最大连通区域小于某个阈值就重新随机。但这个逻辑会增加初始化时间需要权衡。8.4 分数显示的细节分数显示在窗口顶部用outtextxy输出。注意要把数字转换成字符串用sprintf或者_itoa。我习惯用sprintf因为跨平台兼容性好。char scoreText[32]; sprintf(scoreText, Score: %d, score); outtextxy(10, 10, scoreText);字体大小和颜色也要调一下默认的字体太小看不清。用settextstyle设置高度为24字体用黑体或者宋体颜色用白色或者亮黄色在深色背景上比较醒目。8.5 窗口标题和图标窗口标题用initgraph之后调用SetWindowText设置比如消灭星星 - C语言EasyX版。图标需要额外的资源文件比较麻烦可以跳过。这些细节不影响玩法但能让程序看起来更完整。9. 完整代码框架与关键函数索引9.1 主循环的结构主循环负责消息处理、状态更新、绘图三件事。结构如下int main() { initgraph(WIDTH, HEIGHT); resetGame(); while (1) { // 消息处理 if (peekmessage(msg, EX_MOUSE)) { handleMouse(msg); } // 状态更新 if (state STATE_PLAYING) { // 游戏进行中的逻辑 } // 绘图 BeginBatchDraw(); drawGrid(); drawUI(); FlushBatchDraw(); Sleep(16); // 约60帧 } closegraph(); return 0; }Sleep(16)是为了控制帧率避免CPU占用过高。16毫秒大约对应60帧人眼看起来足够流畅。9.2 关键函数一览函数名功能所在文件initGrid初始化网格随机填充颜色game.cbfs搜索连通区域game.celiminate消除指定方块game.capplyGravity下落game.capplyShift左移game.ccalcScore计算分数game.cisGameOver判断游戏结束game.cresetGame重置游戏状态game.cdrawGrid绘制网格main.chandleMouse处理鼠标点击main.c这个函数划分是我经过多次重构后确定的每个函数的职责单一方便测试和修改。如果你要扩展功能比如加道具或者特殊方块只需要在对应的函数里加逻辑不会影响其他部分。9.3 编译与运行注意事项EasyX需要Visual Studio环境不支持MinGW或者GCC。如果你用的是VS Code需要配置MSVC编译器。这个配置过程比较繁琐建议直接用Visual Studio Community版免费而且配置简单。创建项目的时候选择空项目然后添加.c文件。EasyX的头文件是graphics.h需要在项目属性里配置包含目录和库目录。具体步骤可以参考EasyX官网的教程这里不展开。编译的时候如果报错无法打开graphics.h说明EasyX没装好或者路径没配对。重新安装EasyX注意选择和你VS版本匹配的安装包。10. 后续扩展方向与个人体会这个游戏的核心逻辑到这里就完整了。如果你想继续打磨有几个方向可以尝试。第一加撤销功能记录每一步的操作允许玩家回退。这个需要保存游戏状态的历史实现起来不难但比较繁琐。第二加排行榜把最高分保存到文件下次启动时读取。第三加关卡模式每关设定不同的目标分数和步数限制。我个人在实际开发中的体会是消除类游戏的核心难点不在绘图而在状态管理。数组的变换、状态的切换、边界条件的处理这些才是真正花时间的地方。绘图部分EasyX已经封装得很好了只要逻辑正确画出来就是对的。另外调试的时候一定要多用打印输出。把grid数组打印成矩阵形式每一步操作后都打印一次对比预期结果。这个方法虽然原始但极其有效。我大概有80%的bug都是靠打印数组发现的。最后分享一个小技巧如果你觉得BFS的代码太长可以用递归DFS简化代码量会少一半。但要注意递归深度10×10的网格最坏情况递归100层一般没问题。如果你用的是嵌入式环境或者栈空间受限还是老老实实用BFS。这个系列到这里就告一段落了。从环境搭建到核心逻辑三篇文章覆盖了用C语言和EasyX开发消灭星星的完整流程。代码不算复杂但细节很多希望这些经验能帮你少走弯路。