C语言实战:基于EasyX从零实现连连看游戏
1. 为什么选EasyX来做连连看1.1 从控制台到图形界面C语言学习者绕不开的一道坎学完C语言基础语法之后很多人会陷入一段迷茫期——循环、数组、指针、结构体都过了一遍但除了在黑色控制台里打印几个字符画好像什么也做不出来。想做个带界面的东西吧一查资料Qt太重、MFC太老、Win32 API太底层光是创建一个窗口就要写上百行代码还没开始做游戏逻辑热情已经被消磨掉一半了。EasyX这个库恰好卡在一个非常舒服的位置上。它本质上是对Windows GDI绘图接口的轻量级封装安装包不到几MB头文件只有一个graphics.h函数命名风格和当年Turbo C的BGI图形库几乎一脉相承。你不需要理解消息循环、窗口过程、设备上下文这些概念只要会initgraph创建窗口、circle画圆、getmessage处理消息就能把精力全部放在游戏逻辑本身。连连看这个项目特别适合作为EasyX的练手作品。它的规则简单到一句话就能说清楚——点击两个相同图案的方块如果它们之间能用不超过三条直线连通就消除掉。但真正动手写起来你会发现它涉及的知识点相当全面二维数组管理棋盘、鼠标交互、图形绘制、路径搜索算法、游戏状态机、计时与计分。做完这一遍你对C语言综合运用的理解会上一个明显的台阶。1.2 连连看的核心玩法与程序需要解决的关键问题先把游戏规则拆解清楚。一个标准的连连看棋盘通常是10列×8行共80个格子每个格子放一张图案。图案成对出现也就是说80个格子对应40对图案。玩家每次点击两个格子如果满足以下条件就消除两个格子里的图案相同两个格子之间存在一条通路这条通路最多拐两个弯即由不超过三条直线段组成通路不能穿过任何未被消除的格子这里最核心的技术难点就是路径搜索。很多新手第一反应是用BFS或者DFS去搜但那样代码量大、效率也不一定好。实际上连连看的连通判定有更简洁的思路——因为拐弯次数被限制在两次以内我们可以枚举所有可能的拐点位置逐一验证直线连通性。这个思路后面会详细展开。另一个容易被忽视的问题是棋盘初始化。图案必须成对出现而且打乱之后要保证有解。如果随机生成后出现死局剩下的图案两两之间都无法连通玩家就卡住了。所以还需要一个死局检测与重排的机制。1.3 开发环境与项目结构概览我用的环境是Visual Studio 2022社区版EasyX版本是2023年的稳定版。安装过程很简单去官网下载对应VS版本的安装包双击运行它会自动把graphics.h和对应的库文件放到VS的包含目录和库目录里。装完之后新建一个空项目把源文件后缀改成.cppEasyX的库是C编译的用.c会链接报错然后就可以直接#include graphics.h了。项目文件结构我建议这样组织lianliankan/ ├── main.cpp // 入口游戏主循环 ├── game.h // 常量定义、结构体声明、函数原型 ├── game.cpp // 游戏逻辑实现 ├── board.h // 棋盘相关操作 ├── board.cpp // 棋盘初始化、重排、死局检测 ├── path.h // 路径搜索 ├── path.cpp // 连通判定算法 └── render.h/.cpp // 绘制相关当然如果你习惯把所有代码写在一个文件里也完全没问题初期这样反而更方便调试。但当你发现main.cpp超过800行的时候拆文件就是迟早的事。提示EasyX的initgraph创建的窗口默认有控制台窗口在后面如果不想看到黑框可以在项目属性里把子系统设为Windows (/SUBSYSTEM:WINDOWS)入口函数改成WinMain。不过调试阶段保留控制台反而方便打印调试信息。2. 棋盘数据结构与初始化策略2.1 用二维数组还是动态分配棋盘数据最自然的表示就是一个二维整型数组。假设棋盘是ROWS行COLS列那么int board[ROWS][COLS]就够了。每个元素存一个整数0表示空格已消除正整数表示图案编号。但这里有个细节需要注意连连看的路径搜索允许路径走到棋盘外面去。也就是说一个在边缘的方块它的连通路径可以绕到棋盘外围再拐回来。为了统一处理这种情况通常的做法是在棋盘四周各加一圈“虚拟边界”实际数组大小变成(ROWS2) × (COLS2)最外圈的值固定为0表示可通行真正的游戏区域从下标[1][1]到[ROWS][COLS]。#define ROWS 8 #define COLS 10 #define MAX_ROWS (ROWS 2) #define MAX_COLS (COLS 2) int board[MAX_ROWS][MAX_COLS];这样做的好处是路径搜索时不需要额外判断坐标是否越界只要检查目标位置的值是否为0即可。代价是多用了少量内存对于这种规模的棋盘来说完全可以忽略。2.2 图案成对生成与随机打乱初始化棋盘分两步先生成成对的图案再打乱。图案的种类数取决于棋盘总格子数。80个格子需要40对如果准备10种图案每种图案出现8次4对。我一般用PAIR_COUNT ROWS * COLS / 2算出总对数然后按顺序填充void initBoard() { int total ROWS * COLS; int pairs total / 2; int* temp (int*)malloc(total * sizeof(int)); for (int i 0; i pairs; i) { int type i % PATTERN_TYPES 1; // 图案编号从1开始 temp[i * 2] type; temp[i * 2 1] type; } // Fisher-Yates 洗牌 for (int i total - 1; i 0; i--) { int j rand() % (i 1); int t temp[i]; temp[i] temp[j]; temp[j] t; } // 填入棋盘跳过外圈边界 int idx 0; for (int r 1; r ROWS; r) for (int c 1; c COLS; c) board[r][c] temp[idx]; free(temp); }Fisher-Yates洗牌是标准做法从后往前遍历每次和前面随机一个位置交换。这样每种排列出现的概率是均等的不会像“每次随机交换两个位置”那样产生分布偏差。2.3 死局检测与自动重排打乱之后有可能出现死局——剩下的图案中没有任何一对是连通的。玩家面对一个无解的棋盘只能干瞪眼体验极差。所以每次消除之后都要检测是否还有可行的配对如果没有就自动重排。死局检测的逻辑很直接遍历所有非空格子对每一对图案相同的格子调用连通判定函数只要找到一对连通的就说明还有解。如果遍历完都没找到就是死局。bool hasValidMove() { for (int r1 1; r1 ROWS; r1) { for (int c1 1; c1 COLS; c1) { if (board[r1][c1] 0) continue; for (int r2 r1; r2 ROWS; r2) { for (int c2 1; c2 COLS; c2) { if (r1 r2 c2 c1) continue; if (board[r2][c2] ! board[r1][c1]) continue; if (canConnect(r1, c1, r2, c2)) return true; } } } } return false; }重排的做法是把所有非零元素收集到一个数组里重新洗牌再填回原来的位置。注意重排后要再次检测因为有可能洗完之后还是死局概率很低但存在。我一般设置一个循环上限比如重排10次还没有解就重新生成整个棋盘。实操心得重排的时候不要改变空格的位置只把非零元素重新分配。否则棋盘上会出现“空洞漂移”的视觉效果玩家会觉得莫名其妙。3. 连通判定算法两次拐弯以内的路径搜索3.1 为什么不用BFS而用枚举拐点前面提到过连连看的连通判定可以用BFS做但BFS需要记录每个节点的“已拐弯次数”和“进入方向”状态空间是(行, 列, 方向, 拐弯数)实现起来比较繁琐。而且BFS找到的路径不一定是最短路径对于连连看来说我们只关心“能不能连”不关心“怎么连”。枚举拐点的思路是这样的两个格子A和B之间最多拐两个弯意味着路径由最多三条直线段组成。我们可以分三种情况讨论零拐弯A和B在同一行或同一列且中间没有障碍。一个拐弯存在一个拐点P使得A到P直线连通且P到B直线连通。P的位置是(A.row, B.col)或(B.row, A.col)。两个拐弯存在两个拐点P1和P2路径为A→P1→P2→B。P1和P2分别在A所在的行/列和B所在的行/列上。对于两个拐弯的情况我们可以枚举P1在A所在行或列上的所有可通行位置然后检查P1到B是否一个拐弯可达。这样就把问题递归地简化了。3.2 直线连通与单拐点判定先写最基础的直线连通函数// 检查从(r1,c1)到(r2,c2)的直线路径是否畅通 // 要求两点在同一行或同一列且中间所有格子为空 bool isLineClear(int r1, int c1, int r2, int c2) { if (r1 r2) { int minC (c1 c2) ? c1 : c2; int maxC (c1 c2) ? c1 : c2; for (int c minC 1; c maxC; c) if (board[r1][c] ! 0) return false; return true; } if (c1 c2) { int minR (r1 r2) ? r1 : r2; int maxR (r1 r2) ? r1 : r2; for (int r minR 1; r maxR; r) if (board[r][c1] ! 0) return false; return true; } return false; // 不在同一行也不在同一列 }注意这里检查的是“中间”的格子不包含起点和终点本身。因为起点和终点是有图案的它们本身不为0但路径允许从它们出发和到达。单拐点判定就是检查两个候选拐点bool canConnectOneTurn(int r1, int c1, int r2, int c2) { // 拐点1: (r1, c2) if (board[r1][c2] 0) { if (isLineClear(r1, c1, r1, c2) isLineClear(r1, c2, r2, c2)) return true; } // 拐点2: (r2, c1) if (board[r2][c1] 0) { if (isLineClear(r1, c1, r2, c1) isLineClear(r2, c1, r2, c2)) return true; } return false; }3.3 双拐点的枚举技巧与完整判定流程双拐点的处理稍微绕一点。假设路径是A→P1→P2→B其中P1和A在同一行或同一列P2和B在同一行或同一列且P1和P2之间直线连通。我的做法是沿着A所在的行和列向四个方向扫描每遇到一个空格就作为候选P1然后检查P1到B是否单拐点可达。由于P1到B的单拐点判定已经包含了“P1和B在同一行/列”的情况所以这样枚举是完备的。bool canConnectTwoTurn(int r1, int c1, int r2, int c2) { // 沿A的行向左扫描 for (int c c1 - 1; c 0; c--) { if (board[r1][c] ! 0) break; if (canConnectOneTurn(r1, c, r2, c2)) return true; } // 沿A的行向右扫描 for (int c c1 1; c MAX_COLS; c) { if (board[r1][c] ! 0) break; if (canConnectOneTurn(r1, c, r2, c2)) return true; } // 沿A的列向上扫描 for (int r r1 - 1; r 0; r--) { if (board[r][c1] ! 0) break; if (canConnectOneTurn(r, c1, r2, c2)) return true; } // 沿A的列向下扫描 for (int r r1 1; r MAX_ROWS; r) { if (board[r][c1] ! 0) break; if (canConnectOneTurn(r, c1, r2, c2)) return true; } return false; }完整的连通判定就是把三种情况串起来bool canConnect(int r1, int c1, int r2, int c2) { if (r1 r2 c1 c2) return false; if (board[r1][c1] ! board[r2][c2]) return false; if (board[r1][c1] 0) return false; // 零拐弯 if ((r1 r2 || c1 c2) isLineClear(r1, c1, r2, c2)) return true; // 一个拐弯 if (canConnectOneTurn(r1, c1, r2, c2)) return true; // 两个拐弯 if (canConnectTwoTurn(r1, c1, r2, c2)) return true; return false; }注意canConnectTwoTurn里扫描时遇到非空格子就break因为再往后的位置被挡住了不可能作为拐点。这个剪枝很重要否则效率会下降很多。3.4 路径记录与连线动画如果只是判定能不能连上面的代码就够了。但玩家点击两个方块之后如果能连最好把路径画出来给个视觉反馈。这就需要在判定过程中记录路径经过的拐点坐标。我定义一个简单的结构体来存路径typedef struct { int points[4][2]; // 最多4个关键点起点、拐点1、拐点2、终点 int count; } Path;然后在canConnect的各个分支里找到通路时把关键点填进去。绘制的时候用setlinecolor设置颜色line函数逐段画线配合一个短暂的延时比如200毫秒再清除玩家就能清楚地看到连线轨迹。这个动画不需要太复杂线条用亮色、稍微粗一点setlinestyle设置线宽效果就很不错了。4. 鼠标交互与界面绘制4.1 坐标映射从像素到格子EasyX的鼠标消息通过MOUSEMSG结构体获取里面包含x和y两个像素坐标。我们需要把它们转换成棋盘的行列下标。假设每个格子是CELL_SIZE像素比如60×60棋盘左上角在屏幕上的起始位置是(OFFSET_X, OFFSET_Y)那么int col (msg.x - OFFSET_X) / CELL_SIZE 1; int row (msg.y - OFFSET_Y) / CELL_SIZE 1;注意加1是因为棋盘数组的外圈边界占了[0]行和[0]列。转换完之后要检查row和col是否在有效范围内防止玩家点击棋盘外面的区域导致数组越界。if (row 1 || row ROWS || col 1 || col COLS) return;4.2 选中高亮与消除反馈玩家第一次点击某个格子时记录下它的位置并在该格子周围画一个高亮边框。第二次点击另一个格子时先判断是否满足消除条件如果满足消除两个格子把board对应位置设为0播放连线动画更新分数。如果不满足取消第一个格子的选中状态把第二个格子作为新的选中。高亮边框可以用rectangle画一个稍微大一点的矩形颜色用亮黄色或青色和棋盘背景形成对比。选中状态用一个全局变量记录int selectedRow -1; int selectedCol -1;每次点击时根据这个变量的值来判断是“第一次点击”还是“第二次点击”。消除的时候可以加一个简单的缩放动画——先画一个逐渐缩小的方块再清除。不过EasyX没有内置的动画系统需要自己用循环加延时来实现。如果嫌麻烦直接清除加一个短暂的闪烁效果也够用了。4.3 棋盘与图案的绘制细节绘制棋盘分两层背景层和图案层。背景层先铺一个深色底然后逐格画浅色的方格线。我习惯用setfillcolor配合fillrectangle来画填充方块比单纯画线好看很多。图案层就是根据board[r][c]的值来绘制对应的图案。图案可以用EasyX的基本图形组合出来比如圆形fillcircle矩形fillrectangle三角形fillpolygon菱形fillpolygon四个点每种图案用不同的颜色区分。如果嫌手画太麻烦也可以用loadimage加载外部图片但那样就需要准备素材文件项目分发的时候不太方便。纯代码绘制的好处是单文件就能跑复制粘贴就能编译。void drawPattern(int row, int col, int type) { int x OFFSET_X (col - 1) * CELL_SIZE; int y OFFSET_Y (row - 1) * CELL_SIZE; int cx x CELL_SIZE / 2; int cy y CELL_SIZE / 2; int r CELL_SIZE / 3; setfillcolor(patternColors[type]); switch (type) { case 1: fillcircle(cx, cy, r); break; case 2: fillrectangle(cx-r, cy-r, cxr, cyr); break; case 3: { POINT pts[3] {{cx, cy-r}, {cx-r, cyr}, {cxr, cyr}}; fillpolygon(pts, 3); break; } // ... 更多图案 } }4.4 双缓冲绘图消除闪烁EasyX默认的绘图方式是直接画到窗口上频繁刷新的时候会闪得很厉害。解决办法是使用双缓冲先在内存里创建一个IMAGE对象把所有绘制操作画到这个内存图像上最后一次性putimage到窗口。IMAGE buffer(SCREEN_W, SCREEN_H); void render() { SetWorkingImage(buffer); cleardevice(); drawBoard(); drawPatterns(); drawSelection(); drawUI(); SetWorkingImage(NULL); putimage(0, 0, buffer); }这样每次刷新都是整屏替换视觉上非常流畅。BeginBatchDraw和FlushBatchDraw也可以达到类似效果但双缓冲更可控一些。实操心得IMAGE对象不要在每帧循环里反复创建和销毁那样反而会拖慢速度。在游戏初始化时创建一次退出时销毁即可。5. 游戏状态管理与完整流程5.1 主循环的结构设计EasyX游戏的主循环通常长这样int main() { initgraph(SCREEN_W, SCREEN_H); initGame(); while (running) { handleInput(); update(); render(); Sleep(16); // 约60帧 } closegraph(); return 0; }handleInput里用peekmessage非阻塞地获取鼠标消息update里处理计时、死局检测等逻辑render负责绘制。Sleep(16)是为了控制帧率避免CPU占用率飙到100%。5.2 计时、计分与关卡推进计时可以用GetTickCount()获取系统启动以来的毫秒数记录游戏开始的时间戳每帧计算差值。也可以设置一个倒计时时间到了就游戏结束。计分规则我一般这样设计基础分10分如果连续快速消除比如两次消除间隔小于2秒给一个连击加成。分数显示在窗口顶部的UI区域。关卡推进就是当棋盘上所有格子都清空时进入下一关。下一关可以增加图案种类、缩短倒计时、或者增大棋盘尺寸。不过棋盘尺寸变了之后窗口大小和坐标映射都要跟着调整所以更简单的做法是保持棋盘尺寸不变只调整时间限制。5.3 游戏结束与重新开始游戏结束有两种情况倒计时归零或者死局且重排次数用尽。结束时在屏幕中央显示一个半透明的遮罩层上面写“游戏结束”和最终得分再提示“按R键重新开始”。重新开始就是把所有状态变量重置一遍棋盘重新初始化、分数归零、计时器重置、选中状态清空。void restartGame() { initBoard(); score 0; selectedRow selectedCol -1; startTime GetTickCount(); gameOver false; }6. 常见问题与排查技巧实录6.1 编译链接报错与库配置问题问题LNK2019: 无法解析的外部符号 _initgraph这是最常见的问题原因通常是项目被编译成了C文件而不是C文件。EasyX的库是用C编译的函数名经过了名称修饰name manglingC编译器生成的符号名不匹配。解决办法很简单把源文件后缀从.c改成.cpp。问题fatal error C1083: 无法打开包括文件: graphics.h说明EasyX没有正确安装到VS的包含目录。重新运行EasyX安装程序确保选择了正确的VS版本。如果还是不行手动把EasyX安装目录下的include文件夹路径添加到项目的附加包含目录里。6.2 路径判定中的典型Bug问题边缘方块明明可以绕出去连通但程序判定为不可连检查棋盘数组是否留了外圈边界。如果数组大小是[ROWS][COLS]而没有加2那么路径搜索时坐标-1或ROWS就会越界导致未定义行为。一定要用[ROWS2][COLS2]并在初始化时把外圈全部置0。问题两个相邻的相同方块无法消除检查isLineClear函数。当两个方块相邻时中间没有格子循环体一次都不执行应该直接返回true。如果代码里写了for (int c minC; c maxC; c)而不是minC1到maxC-1就会把起点和终点本身也检查进去而它们是非零的导致误判为不通。问题消除后出现死局但程序没有自动重排检查hasValidMove的调用时机。应该在每次消除之后立即调用而不是等到玩家点击了才发现无路可走。另外注意hasValidMove里遍历配对时内层循环的起始条件要正确避免重复检查同一对或者漏掉某些组合。6.3 绘图闪烁与性能问题问题画面闪烁严重原因是没有使用双缓冲。按照前面说的创建一个IMAGE对象作为离屏画布所有绘制先画到它上面最后统一putimage。另外检查是否在每帧都调用了cleardevice这个函数会清空整个屏幕如果直接画到窗口上就会闪。问题鼠标点击响应迟钝可能是Sleep时间设得太长或者handleInput里用了阻塞的getmessage而不是非阻塞的peekmessage。getmessage会一直等待消息到来导致游戏循环卡住。改成peekmessage并配合while循环处理所有待处理消息。6.4 常见问题速查表问题现象可能原因排查方向链接报错LNK2019源文件是.c后缀改成.cpp重新编译找不到graphics.hEasyX未安装或路径不对重装EasyX或手动添加包含目录边缘方块无法连通棋盘数组没有外圈边界数组大小改为[ROWS2][COLS2]相邻方块无法消除直线检测包含了起点终点循环范围改为min1到max-1画面闪烁未使用双缓冲创建IMAGE离屏画布鼠标点击无响应使用了阻塞式getmessage改用peekmessage死局未自动重排hasValidMove调用时机不对每次消除后立即检测重排后仍然死局随机性导致设置重排次数上限超过则重新生成避坑技巧调试路径搜索的时候可以在canConnect返回true之前用printf把路径的关键点坐标打印到控制台。这样当玩家报告“明明能连却消不掉”的时候你可以对照坐标检查是哪一步判定出了问题。控制台在调试阶段不要关等游戏稳定了再隐藏。7. 从能跑到好用几个值得加的细节7.1 提示功能与洗牌道具玩家卡住的时候一个“提示”按钮能大幅提升体验。实现方式就是调用hasValidMove里类似的遍历逻辑找到第一对可连通的方块然后用高亮边框闪烁几下提示玩家。洗牌道具则是让玩家主动触发重排而不是等系统检测到死局才自动重排。可以限制使用次数比如每关3次增加一点策略性。7.2 音效与视觉反馈的简单实现EasyX本身不支持音频播放但可以用Windows的PlaySound函数播放WAV文件。消除的时候播放一个短促的“叮”声连击的时候音调升高游戏结束播放一个低沉的音效。注意音频文件要放在可执行文件同级目录下或者用绝对路径。视觉反馈方面除了连线动画还可以在消除位置画一个扩散的圆圈或者粒子效果。用fillcircle配合逐渐减小的半径和逐渐变淡的颜色就能模拟出简单的粒子消散效果。7.3 代码模块化与后续扩展方向当基本功能都跑通之后建议把代码按职责拆分成多个文件。board.cpp只管棋盘数据path.cpp只管连通判定render.cpp只管绘制main.cpp只负责游戏循环和状态调度。这样后续想加新功能的时候改动范围可控不会牵一发而动全身。扩展方向的话可以尝试加入排行榜把最高分写到本地文件、关卡编辑器让玩家自己设计棋盘布局、或者网络对战这个就需要引入socket编程了复杂度会高不少。不过那是另一个话题了先把单机版做扎实再说。我个人在实际操作中的体会是连连看这个项目看起来简单但真正写起来会遇到不少细节问题——路径搜索的边界条件、鼠标坐标的映射、双缓冲的切换时机、死局检测的效率。每一个问题单独拎出来都不难但凑在一起就需要你对C语言的数组、指针、函数封装有比较扎实的掌握。写完一遍之后回头再看会发现之前很多模糊的概念都变得清晰了。