C语言+EasyX实战:消灭星星游戏核心算法与架构拆解
1. 消灭星星核心玩法与整体架构拆解1.1 为什么选择C语言加EasyX这套组合做消灭星星这类消除类小游戏技术选型其实比想象中要讲究。市面上的方案大致分三档Unity、Godot这类引擎方案Python加Pygame的脚本方案以及C/C配图形库的原生方案。我当初选C语言加EasyX核心原因有三个。第一是学习性价比。消灭星星的玩法逻辑——网格扫描、连通块判定、重力下落、列位移——本质上是二维数组操作和搜索算法的练习场。用引擎做这些逻辑会被引擎的组件系统封装掉你写的是“配置”而不是“算法”。用C语言手写每一步都得自己想清楚对理解游戏循环、状态管理、内存布局的帮助是实打实的。第二是EasyX的轻量。EasyX是Windows平台下的一个图形库封装了GDI的绘图接口提供initgraph、BeginBatchDraw、putimage这类函数。它没有场景树、没有物理引擎、没有资源管理器就是一个画布加一堆绘图API。这意味着整个游戏的架构完全由你掌控不会出现“引擎帮你做了但你不懂”的黑盒。第三是性能可控。消灭星星的棋盘通常是10乘10最多100个格子。这个规模下C语言的数组遍历速度远超脚本语言即使每帧全量扫描连通块帧率也稳得很。而且C语言没有GC不会出现脚本语言那种“消除瞬间卡一下”的掉帧问题。当然这套组合也有代价。EasyX只支持Windows跨平台就别想了图形API偏底层做动画、粒子、缓动都得自己写没有内置的UI系统按钮、文字、弹窗全得手搓。但对于一个消灭星星来说这些代价完全可接受反而逼着你把基础功练扎实。1.2 游戏状态机的设计思路消灭星星看起来简单但状态流转其实不少。我把它拆成六个状态初始化、等待输入、选中判定、消除结算、下落填充、游戏结束。每个状态负责一件事状态之间通过事件驱动切换。为什么用状态机而不是一堆if-else堆在主循环里因为消除类游戏的状态切换有明确的先后依赖。比如你不能在“下落填充”还没结束时就去响应新的点击否则会出现“方块还在掉玩家又选了一组”的混乱。状态机强制你按顺序走流程逻辑清晰调试也方便。具体实现上我用一个枚举GameState表示当前状态主循环里用switch分发。每个状态处理完自己的逻辑后通过修改GameState变量触发切换。比如“选中判定”确认玩家选了一组可消除的星星后把状态改成“消除结算”“消除结算”播完消除动画把状态改成“下落填充”“下落填充”完成后检查是否还有可消除的组合有就回到“等待输入”没有就进入“游戏结束”。这里有个细节动画和逻辑要分离。消除动画、下落动画都是表现层的东西不应该阻塞逻辑层。我的做法是逻辑层瞬间完成数据变更把要消除的格子标记为空格把上方的星星下移表现层用一个独立的动画队列去播放。这样即使动画还在播逻辑层已经准备好了下一帧的数据不会出现“等动画”的空转。1.3 棋盘数据结构的选择与理由棋盘数据我用的是一维数组模拟二维而不是int board[10][10]这种二维数组。原因有两个。一是内存连续性。一维数组在内存里是连续排列的遍历时缓存命中率高。二维数组虽然逻辑上是二维的但物理内存也是连续的只是索引计算多一层。对于10乘10这种小规模性能差异可以忽略但一维数组在写连通块搜索时索引计算更统一。二是便于扩展。如果以后想把棋盘改成非矩形或者做棋盘旋转一维数组配合宽高变量更容易改。二维数组的board[i][j]写法在旋转时容易搞混行列。具体定义是这样的#define BOARD_W 10 #define BOARD_H 10 #define EMPTY 0 int board[BOARD_W * BOARD_H]; // 索引转换 int idx(int x, int y) { return y * BOARD_W x; }每个格子的值表示星星的颜色编号0表示空格。颜色编号从1开始方便和EMPTY区分。这个设计看起来简单但后面所有的连通块搜索、重力下落、列位移都建立在这个基础上所以一开始就要定清楚。提示不要用0表示有效颜色否则空格和有效格子无法区分。我见过有人用0表示红色结果消除后判断空格时把红色也当成空了排查了半天。2. 连通块搜索与消除判定的核心实现2.1 连通块搜索为什么用BFS而不是DFS消灭星星的核心判定是玩家选中的两个及以上同色相邻星星构成一个连通块可以消除。这里的“相邻”指上下左右四方向不含对角线。搜索连通块的标准做法是BFS或DFS我选BFS。原因在于消除类游戏的连通块通常不大但棋盘上可能有多个连通块。BFS用队列实现天然适合“一层一层往外扩”的搜索模式而且不会像DFS那样在极端情况下递归过深导致栈溢出。虽然10乘10的棋盘DFS也不会溢出但BFS的代码结构更直观调试时打印队列内容就能看到搜索过程。BFS的实现需要一个队列和一个访问标记数组。访问标记我用一个和棋盘同大小的bool visited[]每次搜索前清零。队列用数组模拟因为最大元素数就是棋盘格子数不会超过100用循环队列或者简单的头尾指针都行。int queue[BOARD_W * BOARD_H]; bool visited[BOARD_W * BOARD_H]; int bfs(int startX, int startY, int color, int* outCells) { int head 0, tail 0; int count 0; memset(visited, 0, sizeof(visited)); queue[tail] idx(startX, startY); visited[idx(startX, startY)] true; while (head tail) { int cur queue[head]; outCells[count] cur; int cx cur % BOARD_W; int cy cur / BOARD_W; // 四方向扩展 int dx[] {0, 0, -1, 1}; int dy[] {-1, 1, 0, 0}; for (int d 0; d 4; d) { int nx cx dx[d]; int ny cy dy[d]; if (nx 0 || nx BOARD_W || ny 0 || ny BOARD_H) continue; int ni idx(nx, ny); if (visited[ni]) continue; if (board[ni] ! color) continue; visited[ni] true; queue[tail] ni; } } return count; }这段代码里有个容易踩的坑入队时就要标记visited不能等出队再标记。如果出队才标记同一个格子可能被多次入队队列膨胀严重时死循环。我第一版就犯了这个错测试时发现消除一组星星后程序卡死查了半天才发现是重复入队。2.2 消除判定与得分计算玩家点击两个星星后先判断这两个星星颜色是否相同、是否相邻。如果满足就以其中一个为起点做BFS得到整个连通块。如果连通块大小大于等于2就可以消除。得分计算我参考了常见消灭星星的规则n个星星消除得n的平方分。比如消除3个得9分消除5个得25分。这个规则鼓励玩家攒大块再消而不是见两个就消增加了策略性。int score count * count; totalScore score;为什么用平方而不是线性线性的话消两个得2分消四个得4分玩家没有攒块的动力。平方的话消两个得4分消四个得16分差距拉大玩家会主动寻找大连通块。这是消除类游戏设计的经典手法用得分曲线引导玩家行为。消除时把连通块内所有格子的board值设为EMPTY同时记录消除的格子坐标供动画层使用。这里要注意先收集再清除不要边遍历边清除否则BFS的visited数组和board状态会不一致。2.3 消除动画的实现细节消除动画我做了个简单的缩放效果星星先放大到1.2倍再缩小到0同时透明度从1降到0。EasyX本身不支持透明度混合所以透明度效果我用的是颜色插值——把星星颜色逐渐向背景色靠拢视觉上就像淡出。动画用队列管理每个动画元素记录格子坐标、当前帧、总帧数、起始缩放、结束缩放。每帧更新所有动画元素的状态画完后从队列移除已完成的。typedef struct { int x, y; int color; int frame; int totalFrames; } AnimCell; AnimCell animQueue[100]; int animCount 0;动画帧数我设的是15帧按60帧每秒算就是0.25秒。太快看不清太慢拖节奏。实测下来15帧是个比较舒服的值。注意动画期间要屏蔽玩家输入否则玩家在星星还没消完时又点了一组逻辑会乱。我的做法是在状态机里只有“等待输入”状态才响应鼠标事件其他状态一律忽略。3. 重力下落与列位移的算法实现3.1 单列重力下落的实现消除完成后每一列上方的星星要落到下方空位。这个操作的逻辑是从下往上扫描每一列用一个写指针记录当前应该填入的位置遇到非空格就写到写指针位置然后写指针上移。void applyGravity() { for (int x 0; x BOARD_W; x) { int writeY BOARD_H - 1; for (int y BOARD_H - 1; y 0; y--) { int i idx(x, y); if (board[i] ! EMPTY) { if (writeY ! y) { board[idx(x, writeY)] board[i]; board[i] EMPTY; } writeY--; } } } }这段代码的关键是从下往上扫描。如果从上往下扫遇到空格后把下面的星星移上来会覆盖掉还没处理的星星。从下往上扫写指针始终在扫描指针下方或重合不会覆盖未处理的数据。为什么不用“冒泡”方式一个个交换因为冒泡是O(n²)的虽然10行也就100次操作但写指针法一次遍历就搞定代码也更简洁。这是消除类游戏里非常经典的技巧值得记住。3.2 列位移与棋盘收缩有些版本的消灭星星在消除后会把空列整体左移或右移让棋盘收缩。我这一版没有做列位移原因是列位移会让玩家的空间记忆失效——玩家刚记住某列有个大块结果一消除整列移走了体验反而不好。而且列位移后棋盘宽度变化后续的连通块搜索和绘制都要跟着改复杂度上升。但如果你确实想做列位移思路是这样的先标记哪些列是空列然后用一个写指针从左到右扫描把非空列整体移到写指针位置。和重力下落是同一个套路只是方向从垂直变成水平。void shiftColumns() { int writeX 0; for (int x 0; x BOARD_W; x) { bool isEmpty true; for (int y 0; y BOARD_H; y) { if (board[idx(x, y)] ! EMPTY) { isEmpty false; break; } } if (!isEmpty) { if (writeX ! x) { for (int y 0; y BOARD_H; y) { board[idx(writeX, y)] board[idx(x, y)]; board[idx(x, y)] EMPTY; } } writeX; } } }我个人的建议是第一版先不做列位移把核心消除和下落跑通再加这个功能。否则一旦出bug你分不清是消除逻辑错了还是位移逻辑错了。3.3 下落动画与逻辑的同步下落动画比消除动画麻烦一点因为星星要移动位置。我的做法是逻辑层瞬间完成下落board数据已经更新表现层记录每个星星的“起始位置”和“目标位置”然后插值绘制。具体来说在下落前先保存一份下落前的board快照下落完成后对比快照和当前board找出每个移动了的星星生成动画元素起始坐标是快照里的位置目标坐标是当前位置。动画播放时星星从起始坐标线性移动到目标坐标。typedef struct { int fromX, fromY; int toX, toY; int color; int frame; int totalFrames; } FallAnim;下落动画帧数我设的是10帧比消除动画短因为下落是“物理运动”太快显得突兀太慢显得拖沓。10帧约0.17秒实测比较自然。这里有个细节多个星星同时下落时动画要并行播放不能一个播完再播下一个。所以动画队列要支持同时更新多个元素每帧遍历整个队列更新状态。实操心得下落动画的缓动函数我用的是ease-out即速度先快后慢模拟重力加速然后落地减速的效果。线性移动看起来像“飘”ease-out看起来像“掉”差别很明显。4. 常见问题排查与性能优化实录4.1 消除后残留星星不消失的排查这是我最开始遇到的一个bug消除一组星星后大部分消失了但总有一两个残留。排查后发现是BFS的visited数组没有正确重置。我在每次BFS前用memset清零visited但有一次在循环里调用BFS时忘了清零导致上一轮的visited状态影响了下一轮。排查这类问题的思路是先确认数据层是否正确。我在消除后打印整个board发现残留的星星在数据层已经是EMPTY了说明消除逻辑没问题问题出在绘制层。再查绘制层发现绘制时用的是缓存的一份旧board数据没有及时同步。所以教训是数据变更后要立即同步到绘制层不要用缓存。4.2 点击响应错位的坐标转换问题EasyX的鼠标事件返回的是屏幕坐标而棋盘绘制在窗口的某个偏移位置。如果直接用屏幕坐标去算格子索引会错位。我的做法是定义一个棋盘绘制区域的原点boardOriginX、boardOriginY和格子大小cellSize点击时先减去原点再除以格子大小。int gx (mouseX - boardOriginX) / cellSize; int gy (mouseY - boardOriginY) / cellSize; if (gx 0 || gx BOARD_W || gy 0 || gy BOARD_H) return;这里有个坑整数除法会向下取整如果鼠标点在格子边界左侧算出来的索引是对的但如果点在边界右侧一点点可能算到下一个格子。解决办法是用浮点除法然后取整或者在接受点击时加一个容差判断。我实测下来格子大小40像素时边界误差在2像素以内玩家基本感知不到所以直接用整数除法也能接受。4.3 帧率不稳与双缓冲EasyX默认的绘图是直接画到屏幕上如果每帧绘制内容多会出现闪烁。解决办法是用双缓冲先用BeginBatchDraw开启批量绘制所有绘图操作先在内存里完成最后用FlushBatchDraw一次性刷到屏幕。BeginBatchDraw(); // 所有绘制操作 FlushBatchDraw();另外主循环里要控制帧率不能让它全速跑。我用的是Sleep加时间差的方式目标60帧每秒每帧约16毫秒。如果这一帧的逻辑加绘制只用了5毫秒就Sleep(11)补足。DWORD frameStart GetTickCount(); // 逻辑和绘制 DWORD frameTime GetTickCount() - frameStart; if (frameTime 16) Sleep(16 - frameTime);注意Sleep的精度是毫秒级实际可能会有1到2毫秒的误差对于消灭星星这种休闲游戏完全够用。如果要做动作游戏得用更高精度的计时方式。4.4 常见问题速查表问题现象可能原因排查方法解决方案消除后残留星星绘制层数据未同步打印board对比数据变更后立即重绘点击错位坐标未减原点打印鼠标坐标和格子索引减去棋盘原点再除格子大小画面闪烁未用双缓冲观察闪烁频率BeginBatchDraw加FlushBatchDraw帧率过高主循环无延时打印每帧耗时Sleep补足到16毫秒BFS死循环visited入队未标记打印队列长度入队时立即标记visited下落覆盖数据扫描方向错误打印下落前后board从下往上扫描动画期间逻辑乱未屏蔽输入观察动画时点击状态机限制输入状态得分不更新得分变量未累加打印totalScore消除时累加count乘count这张表是我在实际开发中踩过的坑的总结基本上覆盖了消灭星星开发中80%的常见问题。建议在开发时对照检查能省不少调试时间。4.5 性能优化的几个实用技巧消灭星星的性能瓶颈通常不在逻辑层而在绘制层。10乘10的棋盘每帧要画100个星星如果每个星星都用fillcircle画圆100次绘图调用在低端机上可能掉帧。我的优化方案是预渲染星星贴图。具体做法是游戏初始化时把每种颜色的星星预先画到一个IMAGE对象里尺寸是格子大小。绘制时直接用putimage贴图比每次fillcircle快得多。EasyX的putimage是位块传输底层是内存拷贝速度远超逐像素绘制。IMAGE starImages[COLOR_COUNT]; // 初始化时预渲染 for (int c 1; c COLOR_COUNT; c) { SetWorkingImage(starImages[c]); cleardevice(); setfillcolor(colors[c]); fillcircle(cellSize/2, cellSize/2, cellSize/2 - 2); SetWorkingImage(NULL); }另一个技巧是脏矩形重绘。如果这一帧只有几个格子变化不需要全屏重绘只重绘变化的区域。EasyX没有直接的脏矩形API但你可以用putimage的裁剪版本只贴变化区域。不过对于消灭星星这种小棋盘全屏重绘也就100次贴图实测在普通电脑上稳60帧所以脏矩形优化属于“锦上添花”不是必须的。实操心得预渲染贴图时记得把贴图的背景设为透明色。EasyX的putimage支持透明贴图但需要先用SetWorkingImage和putimage的掩码版本。如果背景不透明星星会带着一个方块背景看起来像贴了膏药。5. 从能跑到好玩的体验打磨5.1 选中高亮与预览反馈玩家点击一个星星后如果只是选中没有反馈体验会很差。我加了两个反馈选中高亮和连通块预览。选中高亮是给选中的星星加一个白色描边让玩家知道点到了哪个。连通块预览是当玩家选中一个星星后自动高亮所有与它同色相邻的星星让玩家在点击第二个星星前就能看到这一组有多大。预览的实现是在“选中判定”状态里对选中的星星做一次BFS把连通块内的格子用半透明高亮画出来。这样玩家可以直观地判断“这一组值不值得消”。// 预览高亮 for (int i 0; i previewCount; i) { int x previewCells[i] % BOARD_W; int y previewCells[i] / BOARD_W; setlinecolor(WHITE); setlinestyle(PS_SOLID, 2); rectangle(boardOriginX x*cellSize, boardOriginY y*cellSize, boardOriginX (x1)*cellSize, boardOriginY (y1)*cellSize); }这个功能看起来小但对体验提升很大。玩家不用“盲选”可以规划消除顺序策略性上来了。5.2 得分飘字与连击反馈消除时如果只是分数默默变化玩家感知不到。我加了得分飘字消除的星星中心位置弹出一个“25”的文字向上飘一段距离后淡出。飘字用和动画队列类似的结构管理记录起始位置、当前帧、总帧数、文字内容。每帧更新位置和透明度画完移除。typedef struct { int x, y; int score; int frame; int totalFrames; } ScorePopup;连击反馈是如果玩家连续多次消除都大于等于4个屏幕边缘闪一下光效给玩家“打得好”的正反馈。这个不是必须的但加上后游戏节奏感更强。5.3 游戏结束判定与重开游戏结束的条件是棋盘上不存在任何大小大于等于2的同色连通块。判定方法是遍历所有格子对每个非空格子做BFS如果找到任何一个连通块大小大于等于2游戏继续否则游戏结束。bool hasMove() { for (int y 0; y BOARD_H; y) { for (int x 0; x BOARD_W; x) { int i idx(x, y); if (board[i] EMPTY) continue; int cells[100]; int count bfs(x, y, board[i], cells); if (count 2) return true; } } return false; }这个判定每轮消除后都要做一次复杂度是O(n²)乘BFS的O(n)对于100个格子来说就是10000次操作完全可以接受。游戏结束后弹出一个结算界面显示总分和“再来一局”按钮。重开就是把board重新随机填充分数清零状态机回到“等待输入”。注意重开时要把动画队列、飘字队列、选中状态全部清空否则上一局的残留动画会干扰新一局。我第一版重开时忘了清动画队列结果新一局开始后上一局的消除动画还在播画面很混乱。5.4 难度曲线与随机填充策略随机填充看起来简单但填充策略直接影响游戏难度。如果完全随机可能出现“开局就没有可消除组合”的死局或者“满屏都是同色”的简单局。我的做法是填充时保证至少存在一个大小大于等于2的连通块。具体实现是填充完后检查hasMove()如果没有就重新填充最多重试10次。这样保证开局一定有解。另外颜色数量也影响难度。颜色越多同色相邻的概率越低连通块越小难度越高。我默认用5种颜色实测下来难度适中既有大块可消又不会太简单。如果想调难度改成4种颜色会简单很多6种颜色会难不少。#define COLOR_COUNT 5这个参数建议做成可配置的方便调试和调整。5.5 音效与背景音乐的接入EasyX本身不支持音频播放但可以用Windows的PlaySound函数播放WAV文件。消除时播一个“啵”的音效游戏结束时播一个结算音效背景音乐循环播放。#include mmsystem.h #pragma comment(lib, winmm.lib) PlaySound(TEXT(pop.wav), NULL, SND_FILENAME | SND_ASYNC);SND_ASYNC表示异步播放不会阻塞主循环。如果同步播放每次消除都会卡一下体验很差。音效文件建议用短小的WAV采样率不用太高22050赫兹足够。背景音乐可以用循环播放的WAV但WAV文件体积大如果在意体积可以转成MP3但PlaySound不支持MP3需要用其他库。对于消灭星星这种小游戏WAV完全够用。实操心得音效播放频率要控制。如果玩家快速连续消除音效叠加会刺耳。我的做法是加一个音效冷却时间两次音效之间至少间隔100毫秒。6. 代码组织与后续扩展方向6.1 文件拆分与模块职责单文件写消灭星星也能跑但代码超过500行后维护就很痛苦。我把它拆成四个文件main.c负责主循环和状态机board.c负责棋盘数据和连通块搜索render.c负责绘制和动画game.c负责游戏逻辑和得分。每个文件配一个头文件暴露必要的接口隐藏内部实现。比如board.h只暴露board_init、board_bfs、board_gravity这几个函数visited数组和queue队列都是board.c内部的静态变量外部不需要知道。这样拆的好处是改绘制不影响逻辑改逻辑不影响数据。调试时也可以单独测试某个模块比如写个测试函数只调board_bfs验证连通块搜索是否正确。6.2 可扩展的玩法方向消灭星星的核心玩法跑通后可以扩展的方向不少。我列几个我试过或考虑过的限时模式给每局加一个倒计时时间到就结算。这个改动主要在状态机里加一个计时器每帧递减归零时切到游戏结束状态。道具系统比如“炸弹”可以消除任意一个星星“刷新”可以重新排列棋盘。道具用次数限制增加策略性。关卡模式每关设定目标分数达到就进下一关难度递增颜色增多、目标分数提高。排行榜把最高分存到本地文件启动时读取显示。用fopen和fprintf就能实现不需要数据库。这些扩展的共同点是核心逻辑不变只加外围系统。这也是为什么我建议先把核心玩法做扎实再考虑扩展。核心不稳扩展越多bug越多。6.3 从EasyX迁移到其他平台的思路如果以后想把这个游戏搬到其他平台核心逻辑棋盘数据、BFS、重力下落是纯C语言可以直接复用。需要重写的只有绘制层和输入层。比如搬到Web端可以用Emscripten把C代码编译成WebAssembly绘制层用Canvas API重写。搬到移动端可以用SDL2替换EasyXSDL2跨平台支持好API和EasyX类似迁移成本不高。关键是把逻辑层和表现层彻底分离。我的代码里board.c和game.c不包含任何EasyX的调用只操作数据。render.c才依赖EasyX。这样迁移时只需要重写render.c逻辑层一行不用改。这个分离原则在写第一版时可能觉得麻烦但后期收益很大。我见过有人把绘制和逻辑混在一起想加个新皮肤都得改核心代码那才是真的痛苦。6.4 我个人的开发节奏建议最后分享下我做这个项目的节奏。第一版我只做了最核心的功能棋盘显示、点击选中、连通块消除、重力下落。没有动画、没有音效、没有得分飘字。跑通后确认逻辑没问题再加动画和音效。最后加得分、连击、结算这些外围系统。这个顺序的好处是每一步都有可运行的版本。如果一上来就做完整版一旦出bug你面对的是几百行代码排查成本高。分步做的话每一步只改一小块出问题容易定位。另外每加一个功能就提交一次代码如果用Git的话这样即使改坏了也能回滚。我吃过亏有一次加动画时把消除逻辑改乱了又没有备份只能从头重写。从那以后我养成了每完成一个小功能就提交的习惯。实操心得调试消除类游戏时把棋盘数据打印成文本格式非常有用。我写了个printBoard函数用字符表示颜色比如R红色、G绿色、.空格。每次消除前后各打印一次对比就能看出逻辑对不对。这个习惯帮我省了大量时间。