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而不是DFS
BFS和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个单独消除两次(2+3)只能得4+9=13分,而攒到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.c |
| bfs | 搜索连通区域 | game.c |
| eliminate | 消除指定方块 | game.c |
| applyGravity | 下落 | game.c |
| applyShift | 左移 | game.c |
| calcScore | 计算分数 | game.c |
| isGameOver | 判断游戏结束 | game.c |
| resetGame | 重置游戏状态 | game.c |
| drawGrid | 绘制网格 | main.c |
| handleMouse | 处理鼠标点击 | 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开发消灭星星的完整流程。代码不算复杂,但细节很多,希望这些经验能帮你少走弯路。