简介一份基于C语言与EasyX图形库实现的五子棋游戏工程面向初学C语言、想尝试图形界面编程的开发者可作为课程设计或入门练手项目。压缩包共145个文件、约57.36MB包含6个CPP和5个头文件源码、可直接运行的EXE、Visual Studio工程配置SLN/VCXPROJ以及大量JPG图片与少量音频MP3/WAV资源编译中间文件tlog/obj/pdb也一并保留便于直接打开调试。目前已有1683人浏览学习。项目涵盖人机对战与人人对战等模式源码中可观察棋盘绘制、落子判断、输赢检测等核心逻辑人机部分还涉及简单搜索算法思路同时联机模式相关的网络通信概念也有体现服务器端未包含。对希望把C语言语法落到实际小游戏、并想拓展图形库与工程组织能力的读者来说是一份结构完整、可直接运行的参考样本。1. 项目定位与整体思路拆解作为一个常年带C语言入门者的老学长我太清楚大家学到指针和结构体之后那种我到底学了啥的迷茫感了。C语言五子棋EasyX这个组合我愿称之为C语言入门阶段的毕业设计——它不复杂但足够你把数组、函数、指针、文件操作、算法这些核心语法全部串起来跑一遍。先说这个项目能解决什么问题第一把二维数组从纸上谈兵变成棋盘落子第二把函数封装从书本例题变成模块化设计第三把调试过程从一脸懵变成逻辑推理。我自己带过的学生里做完这个项目再回去看指针和数组理解深度完全是两个层次。EasyX图形库的选择逻辑很简单它是目前Windows环境下配置最省心的C语言图形库下载一个安装包在Dev-C里改两个配置项就能用不需要折腾CMake、VS Studio的复杂工程配置。相比Qt或者SDLEasyX就是奔着教学来的它把窗口创建、鼠标事件、绘图API封装得极其简单让你把核心精力放在C语言本身的逻辑上而不是跟图形框架较劲。整个项目我倾向于分成五个模块来做棋盘初始化与绘制模块鼠标交互与落子模块胜负判定模块人机对战AI模块进阶存档/复盘模块进阶这样的模块划分思路对应着真实项目开发里的单一职责原则——每个模块只管自己的事模块之间通过接口通信。这在五子棋这个体量下看起来很杀鸡用牛刀但这种习惯一旦养成后面写任何大项目都受益。2. 核心数据结构设计与原理解析2.1 二维数组就是棋盘的灵魂五子棋的棋盘本质是什么就是一个15×15的二维网格。我见过很多初学者上来就想用图形化的方式存储棋子画一个圆存一个圆这在逻辑上完全走偏了——游戏逻辑和数据存储必须跟界面渲染分离。正确做法是用一个二维数组来表示棋盘状态#define BOARD_SIZE 15 int board[BOARD_SIZE][BOARD_SIZE]; // 0表示空1表示黑棋2表示白棋这个数组就是整个游戏的核心数据模型。界面上的每一次绘制都从数组读取状态鼠标的每一次点击都要转换到数组下标去修改状态。这样做的好处是胜负判断、AI计算、存档功能全部可以基于这个纯逻辑的数组来实现根本不需要关心图形界面的细节。2.2 坐标系的两次转换这里有一个新手最容易搞晕的地方——像素坐标和格子坐标是两个不同的坐标系。你在窗口上点击鼠标拿到的坐标是像素值比如(340, 210)但你要落子需要知道它对应棋盘的哪一行哪一列比如(row7, col10)。转换公式其实很简单假设棋盘左上角的起始像素坐标是start_x、start_y每个格子的间距是cell_size那么int col (mouse_x - start_x cell_size / 2) / cell_size; int row (mouse_y - start_y cell_size / 2) / cell_size;加cell_size / 2这个操作是为了四舍五入——你的鼠标点在格子中间偏左一点它也会归到最近的格子上不然玩家的操作体验会非常拧巴。注意这里的边界检查必须做。如果鼠标点在棋盘外侧再除以cell_size得到的行列值可能是负数或者大于15直接用会数组越界。我把这个坐标转换单独拿出来讲是因为它是所有图形交互程序的基础逻辑。做过游戏开发的人都知道不管是什么引擎、什么框架屏幕坐标→逻辑坐标→数据索引这条链路永远绕不开。2.3 为什么要用全局数组而不是动态分配很多学了堆内存的同学会纠结要不要用malloc动态分配棋盘数组。我给的答案是15×15的int数组总共才900字节直接用全局变量或者栈上分配就好。动态内存分配在这个场景下带来的麻烦忘记free导致泄漏、指针操作增加心智负担远大于它带来的好处。全局数组在这个项目里还有个特殊意义所有函数都能直接访问它省去了参数传递。我知道这不太符合低耦合的软件工程原则但作为教学项目它的直观性更重要。等你把项目做顺了再自己尝试用指针传递来替代全局变量那就是进阶的练习了。3. 核心代码实现从绘制棋盘到胜负判定3.1 基于EasyX的界面初始化与棋盘绘制先交代一下EasyX的项目配置。我用的是Dev-C新版自带EasyX安装向导配上easyx.h头文件。安装EasyX时选择对应的编译器版本装完在工程设置里链接EasyXa.lib或EasyX.lib就行。如果你用Visual Studio直接通过NuGet搜索EasyX安装更方便。窗口初始化用initgraph(660, 660)创建一个660×660像素的窗口然后写一个绘制棋盘的函数void drawBoard() { setbkcolor(RGB(210, 160, 80)); // 木纹底色 cleardevice(); // 画15×15网格线 int start 30, gap 40; for (int i 0; i BOARD_SIZE; i) { // 水平线 line(start, start i * gap, start (BOARD_SIZE - 1) * gap, start i * gap); // 垂直线 line(start i * gap, start, start i * gap, start (BOARD_SIZE - 1) * gap); } // 画天元和星位小圆点方便定位 setfillcolor(BLACK); solidcircle(start 7 * gap, start 7 * gap, 3); solidcircle(start 3 * gap, start 3 * gap, 3); // ... 其他星位省略 // 根据数组状态画棋子 for (int row 0; row BOARD_SIZE; row) { for (int col 0; col BOARD_SIZE; col) { if (board[row][col] 1) { setfillcolor(BLACK); solidcircle(start col * gap, start row * gap, 15); } else if (board[row][col] 2) { setfillcolor(WHITE); solidcircle(start col * gap, start row * gap, 15); } } } }这段代码的核心思想是**先画底再画线最后根据数据画棋子**——绘制顺序在图形程序里非常关键画布就像一张纸后画的会覆盖先画的。有些同学画出来的棋子被网格线盖住就是顺序搞反了。3.2 鼠标交互与落子逻辑EasyX封装了MOUSEMSG结构体和MouseHit()函数用来检测鼠标动作。主循环长这样while (true) { if (MouseHit()) { MOUSEMSG msg GetMouseMsg(); if (msg.uMsg WM_LBUTTONDOWN !gameOver) { int col (msg.x - start gap / 2) / gap; int row (msg.y - start gap / 2) / gap; if (row 0 row BOARD_SIZE col 0 col BOARD_SIZE board[row][col] 0) { board[row][col] currentPlayer; drawBoard(); if (checkWin(row, col, currentPlayer)) { // 显示胜利信息 gameOver 1; } currentPlayer 3 - currentPlayer; // 切换玩家 } } } }currentPlayer 3 - currentPlayer这行代码是个小技巧——当currentPlayer是1时变成2是2时变成1比if判断简洁。当然这种写法可读性稍差我一般在教学现场会先写if版本再展示这个优化让同学们感受一下代码还能这么写的乐趣。落子前必须检查该位置是否已有棋子这个判断也是很多初学者的经典遗落。不加这个检查你就能体验下棋互相覆盖的诡异现象。3.3 五子连珠判定的四个方向胜负判定是整个项目里最讲逻辑的部分。很多人第一反应是遍历整个棋盘查所有五连子这个思路能做但效率极差而且容易出错。更优雅的做法是只检查最后一个落子的位置——因为五子连珠必然包含最后这个棋子。我从这个落点出发沿四个方向水平、垂直、主对角线、副对角线各数一遍连续同色棋子的数量加起来大于等于5就赢了。int checkWin(int row, int col, int player) { int dirs[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { int count 1; // 正方向数 for (int step 1; ; step) { int nr row dirs[d][0] * step; int nc col dirs[d][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! player) break; count; } // 反方向数 for (int step 1; ; step) { int nr row - dirs[d][0] * step; int nc col - dirs[d][1] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! player) break; count; } if (count 5) return 1; } return 0; }这里有个细节值得展开方向数组dirs[4][2]定义了四个方向向量正方向用反方向用-两步循环方向遍历正好覆盖了从落子点向左数、向右数的全部逻辑。边界判断的顺序必须先做再去访问数组否则一旦越界就会访问到非法内存这在C语言里是undefined behavior可能会导致莫名其妙的崩溃或者脏数据。提示左对角线(1, -1)的方向要小心col方向是递减的第一次写的时候很容易把正负号写反导致判断结果诡异。3.4 用文件操作实现存档与复盘加一个存档功能顺便就把C语言文件读写给练了。存档本质就是把当前棋盘数组和当前轮到谁写到文件里复盘就是反过来读出来void saveGame(const char* filename) { FILE* fp fopen(filename, wb); fwrite(board, sizeof(int), BOARD_SIZE * BOARD_SIZE, fp); fwrite(currentPlayer, sizeof(int), 1, fp); fclose(fp); } void loadGame(const char* filename) { FILE* fp fopen(filename, rb); if (fp NULL) return; fread(board, sizeof(int), BOARD_SIZE * BOARD_SIZE, fp); fread(currentPlayer, sizeof(int), 1, fp); fclose(fp); drawBoard(); }用二进制的fwrite/fread而不是文本格式好处是结构清晰、一次读写整个数组坏处是文件不可读用记事本打开是乱码。想要可读性就用fprintf逐行写各有利弊。我习惯把fopen的返回值判断写上——这个坏习惯一旦养成后面做嵌入式开发读写配置文件时就知道多重要了。4. 人机对战AI从零构建一个会下棋的对手4.1 基于权值评分的简单AI思路做成双人对战之后很多人会想加个人机模式。这里我没直接上复杂的MinMax搜索树对入门项目来说天花板太高而是用一个基于模式评分的权值AI——它虽然打不过专业五子棋AI但赢普通同学绰绰有余而且代码量可控、思想可扩展。核心思路遍历棋盘的每一个空位假设当前位置放上AI的棋子评估它对AI的贡献值再假设当前位置放上玩家的棋子评估对手可能的威胁值。两个值叠加就是该位置的最终评分AI落子在评分最高的位置。#define SCORE_FIVE 100000 // 成五 #define SCORE_LIVE4 50000 // 活四 #define SCORE_JUMP4 20000 // 冲四 #define SCORE_LIVE3 10000 // 活三 #define SCORE_SLEEP3 2000 // 眠三 #define SCORE_LIVE2 500 // 活二 #define SCORE_SLEEP2 100 // 眠二评分表的量级设置很关键相邻档位之间差距必须足够大否则AI会做出用一个活三去挡对方活四这种短视决策。上面这套数值我是调了好几轮才定下来的一开始差距拉得不够大AI的棋风特别怂。4.2 方向扫描与模式识别评估某个空位的分数时需要从该位置出发沿四个方向扫描统计每一方向上己方和对方的连子模式。简化版实现如下int evaluatePoint(int row, int col, int player) { int total 0; int dirs[4][2] {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; for (int d 0; d 4; d) { // 模拟放上己方棋子后向正反方向数连子 int count 1; int block 0; // 被堵住的方向数 // 正方向 for (int step 1; step 5; step) { int nr row dirs[d][0] * step; int nc col dirs[d][1] * step; if (outOfBounds(nr, nc)) { block; break; } if (board[nr][nc] player) count; else if (board[nr][nc] 0) break; else { block; break; } } // 反方向类似省略 // 根据count和block查表得分 total scoreTable[count][block]; } return total; }这个实现里block变量的作用至关重要——它区分了活三两端都没堵和眠三一端被堵这两种威胁等级完全不同的情况。很多初版AI棋力很弱就是没区分活棋和死棋把被堵死的三连跟活三看成同级别的威胁。对于入门版的AI遍历的深度限制在空位本身即可也就是贪心算法——只看这一步的最优解。它的复杂度是O(棋盘空位数×4方向×步长)15×15的棋盘一共225个格子完全跑得动。4.3 难易度调节与放水策略我见过很多人的五子棋AI做出来之后被同学抱怨太难了——因为AI总会堵你的棋导致新手完全赢不了。解决办法是加一个难度调节简单模式下AI以80%的概率在总评分前35%的位置里随机落子。if (difficulty EASY rand() % 100 80) { // 在前35%的高分位置中随机选一个放水 }这比AI走随机步高明得多因为它保留了AI的基本棋理只是不总是选择最优解给人一种对手会下但不强的感觉。我自己实测简单模式对完全新手刚刚好输了不会太沮丧赢了又有成就感。5. 常见问题与调试技巧实录5.1 问题速查表做这个项目这几年我在学生和读者身上收集到的高频问题基本如下面这张表问题现象根本原因解决方案编译报graphics.h找不到EasyX未安装或编译器未配置链接库重新安装对应编译器版本的EasyX检查工程设置里的链接器选项棋子画出来有锯齿/边缘发虚使用了抗锯齿设置或者窗口缩放在EasyX中关闭平滑处理或用solidcircle代替像素绘制鼠标点了没反应没在主循环中调用MouseHit()确认while循环里检查鼠标消息的代码在drawBoard()之前赢棋判定有时不触发边界判断逻辑不完整检查四个方向扫描的边界条件用断点打印count值棋子会落在棋盘线以外的位置缺少坐标范围检查加row/col的0~14范围校验AI一直堵不死对手评分表量级差距不够大把成五、活四的分数调大到十万级活三的分数保持在万级5.2 调试技巧可视化每一步判定过程在checkWin和evaluatePoint这类核心函数里我强烈建议新手加一些临时的printf输出——用EasyX的OutTextXY函数直接把关键变量绘制在窗口上。比如在判断胜利时把当前方向扫到的连续棋子数量打在屏幕上。这个技巧听起来很土但它能帮你直观地看到代码的执行逻辑。有一次我排查一个横着赢判定不出来的bug坐标转换后发现是dirs里水平方向向量的符号反了——如果不是在窗口上把坐标和方向一起打印出来这种bug靠肉眼纯瞪代码是很难快速发现的。另一个实用的调试技巧是录像功能把每一步的棋盘数组打印到一个文本文件里出错之后可以直接回溯整个对局过程。这个习惯在后面的嵌入式开发、服务端开发中都会被反复用到——日志是最基础也最有效的调试手段。5.3 内存越界的排查经验整个项目最容易产生灵异现象的地方就是数组越界。比如你在checkWin里漏了边界判断循环访问到board[-1][10]C语言不会立刻报错而是悄悄修改了内存里的其他数据——结果可能是棋子颜色错乱、窗口莫名关闭、或者AI评分全部变成负的。排查越界的最土但最有效的办法是在所有数组访问前加上边界打印printf(board[%d][%d], row%d, col%d, size%d\n, row, col, row, col, BOARD_SIZE);我见过有同学因为一个越界问题排查了三个晚上最后发现是for循环的结束条件写成了for(int i 0; i BOARD_SIZE; i)——多了一次越界访问。这种时候你只需要问自己一个问题我有没有哪个循环条件里出现等号这个怀疑方向能救你很多时间。6. 从五子棋到更大的世界做完这个项目之后有两条扩展路径我特别推荐。第一条是网络对战用socket编程把落子坐标通过网络发送给另一台电脑这就进入了C语言网络编程的领域。第二条是强化AI算法把权值AI升级为MinMax搜索Alpha-Beta剪枝这是所有棋类AI的经典算法——学完之后你再看那些下象棋、下围棋的开源AI思路就完全通了。我自己这几年带项目的体会是一个人从写完这个五子棋到真正能上手企业级项目中间缺的从来不是语法知识而是用代码解决实际问题的完整思维链条。五子棋刚好是这个链条的第一环——它清晰、可控、反馈即时且值得你在上面做深做透。把上面这些模块真正吃透你就已经掌握了C语言游戏开发的入门密码后面不管是走嵌入式、走网络编程还是转向C游戏引擎这段基础都会成为你最厚实的那块垫脚石。本文还有配套的精品资源点击获取