简介这是一份基于C与easyx图形库开发的五子棋游戏完整源码面向正在学习C编程、图形界面设计及基础游戏逻辑的开发者。项目包含完整的对战流程、棋盘数据表示、胜负判断算法及鼠标交互可直接在Visual Studio环境中编译运行既可作为课设参考也适合入门级游戏开发练习。压缩包共36个文件大小约22.92MB核心代码由3个cpp源文件和3个头文件组成封装了主体逻辑与easyx绘图调用另含多张jpg/png图片及mp3/wav/ogg音效用于搭建菜单界面、棋盘背景与配乐sln/vcxproj等配置文件则便于直接打开工程。目前已有203人学习下载资源结构清晰、注释与模块划分完整能帮助读者理解图形库调用、状态管理及音频资源加载等关键思路是一份可直接上手研读的实战样例。1. 为什么五子棋是最值得手写的C入门项目网上搜“C小游戏”出现频率最高的永远是五子棋、贪吃蛇、俄罗斯方块这三样。但五子棋有个其他两个比不了的优势它同时覆盖了图形界面、鼠标交互、状态管理和胜负判定整个源码规模又控制在一两千行以内特别适合作为第一个“有窗口、能点击、会判定”的C完整体验项目。真正动手做过的人会告诉你一个反直觉的结论——这个项目最难的部分不是AI而是把棋盘建模、坐标换算和边界判断这三件事写稳。本文会从EasyX库的选型与配置开始一路拆到落子、判胜、悔棋和人机对战把能踩的坑先替你踩一遍。2. 用EasyX把窗口搭起来环境配置与最小例程2.1 为什么是EasyX在Qt和纯控制台之间的最优解做C小游戏常见的选择有三条路纯控制台、Qt、EasyX。控制台只能靠ASCII字符拼棋盘输入靠敲坐标谈不上交互Qt功能强但要先理解项目、信号槽、CMake这一整套概念对刚学完语法的人来说门槛偏高。EasyX库正好卡在中间它把Windows绘图API封装成简单函数画线、画圆、监听鼠标都是一行调用源码读起来也接近“白纸黑字”不需要额外抽象。我一般建议课程设计或者练手项目直接选EasyX原因很实际它不需要你在编译环境上折腾太久。在Visual Studio里装好库建个空项目就能跑从零到看见窗口不会超过十分钟。这对“先把游戏跑起来”的入门阶段尤其重要。下面这张对比表可以看得更清楚方案安装成本绘图API鼠标交互适合场景纯控制台无需安装字符绘制键盘输入坐标练习语法不练交互EasyX一键安装封装的绘图函数原生鼠标消息入门图形小游戏Qt需安装Qt库与IDE需学QPainter体系信号槽机制跨平台正式项目EasyX还有一个对新手友好的特点它不强制你做架构设计但也没让你绕过核心逻辑。棋盘数据、绘制、交互、判定这些模块仍然得自己写只是省掉了渲染层面的复杂度。这样当你后面去看Qt、SDL或者更复杂的游戏源码时已经具备“数据模型与表现层分离”的意识了。2.2 环境配置与最小例程装完库先跑通这个窗口先说最顺的路线Visual Studio。安装VS2022社区版时工作负载勾选“使用C的桌面开发”然后去EasyX官网下载安装包双击后它会自动识别你机器上的VS版本选中对应版本一键安装。装完新建一个空项目源文件里直接粘贴代码就能编译。注意不要选“控制台应用”模板之外的复杂模板空项目最干净。很多同学习惯用VSCode配置C/C环境配好g后一编译发现#include graphics.h报错。这不是包含路径的问题而是EasyX库底层依赖MSVC编译器VSCode默认用的MinGW编译器跑不了它。虽然可以通过切换到MSVC工具链的方式在VSCode里跑通但配置成本对新手不友好。我一般建议这个项目直接用VS等以后做跨平台项目再回VSCode省下的时间够你写完整个棋盘绘制。装好后先别急着写五子棋跑通一个最小例程确认库和环境没问题#include graphics.h #include conio.h int main() { // 创建 800x600 的绘图窗口 initgraph(800, 600); // 设置填充色为绿色画一个圆心在(400,300)、半径为100的实心圆 setfillcolor(GREEN); solidcircle(400, 300, 100); // 按任意键关闭窗口防止程序一运行就一闪而过 _getch(); closegraph(); return 0; }这段代码里initgraph负责创建窗口参数分别是宽和高setfillcolor设置填充颜色solidcircle接收圆心坐标和半径画实心圆closegraph释放绘图资源。_getch()的作用是让程序停留等用户按键后再退出。如果这个例程能跑出一个绿色圆说明EasyX库安装成功后面就可以放心写棋盘了。3. 棋盘数据结构与绘制二维数组如何变成15×15的棋局3.1 棋盘数据模型15×15数组为什么够用五子棋的棋盘是15×15的交点阵列最直观的建模方式就是二维数组。我习惯用int board[15][15]值为0表示空位1表示黑子2表示白子。有人会问为什么不直接用bool因为bool只有两种状态放不下“空位”这个第三种状态除非再用一个辅助数组记录谁下了哪步那样反而绕。int数组虽然每个格子占4字节但15×15总共才900个元素内存开销可以忽略。除了棋盘数组还需要两个配套状态当前轮到谁下棋以及历史落子记录。当前玩家用一个int变量currentPlayer来切换每次落子后把它从1变成2或从2变回1。历史落子用栈来存这样悔棋的时候可以从栈顶弹出一颗棋子恢复棋盘现场。下面这个结构体在后面的悔棋功能里会用到#include stack // 用结构体记录一次落子的位置 struct Move { int row; int col; }; // 全局变量棋盘数组、当前玩家、历史栈 int board[15][15] {0}; int currentPlayer 1; // 1 黑棋先手2 白棋 std::stackMove history; // 悔棋时从栈顶弹出棋盘数组建议定义为全局变量。你可能觉得全局变量不优雅但在这个项目里它带来的便利远大于坏处绘图函数、判定函数、AI函数都要读它如果每个函数都传int board[15][15]参数函数签名会非常啰嗦。等以后把项目扩展成类封装时再把它收进成员变量也不迟。还有一个细节如果把棋盘数组声明为int board[17][17]把有效下棋区域放在下标1到15之间下标0和16那两圈留空那么胜负判定时就不需要检查数组越界了。这是很多游戏源码处理边界问题的经典技巧我在下一章的判定代码里会再提到。3.2 绘制函数拆解画网格、画棋子与坐标换算绘制棋盘之前先确定三个常量棋盘路数BOARD_SIZE15、每格像素宽GRID_SIZE40、边距MARGIN40。窗口宽度等于MARGIN * 2 GRID_SIZE * (BOARD_SIZE - 1)。注意是乘14而不是乘15因为15路棋盘只有14个间隔。很多自己写的源码在这里算错导致棋盘画出来偏向一边窗口尺寸对不上。#include graphics.h const int BOARD_SIZE 15; const int GRID_SIZE 40; const int MARGIN 40; const int WIN_WIDTH MARGIN * 2 GRID_SIZE * (BOARD_SIZE - 1); const int WIN_HEIGHT WIN_WIDTH; // 绘制棋盘背景和网格线 void drawBoard() { // 木纹色背景比默认黑色更接近真实棋盘 setbkcolor(RGB(220, 180, 130)); cleardevice(); // 深棕色网格线2像素宽度 setlinecolor(RGB(60, 40, 20)); setlinestyle(PS_SOLID, 2); for (int i 0; i BOARD_SIZE; i) { // 画竖线从第 i 列的第一个交点到最后一个交点 line(MARGIN i * GRID_SIZE, MARGIN, MARGIN i * GRID_SIZE, MARGIN (BOARD_SIZE - 1) * GRID_SIZE); // 画横线从第 i 行的第一个交点到最后一个交点 line(MARGIN, MARGIN i * GRID_SIZE, MARGIN (BOARD_SIZE - 1) * GRID_SIZE, MARGIN i * GRID_SIZE); } }这段代码里setbkcolor设置背景色cleardevice用背景色清空整个窗口setlinecolor和setlinestyle分别控制线的颜色和宽度。画线时横竖两个方向都从MARGIN画到MARGIN (BOARD_SIZE - 1) * GRID_SIZE保证所有交点的坐标是对称的。接下来是坐标换算。鼠标点击得到的是窗口像素坐标比如(320, 180)但棋盘逻辑需要的是第几行第几列。换算公式是col (x - MARGIN GRID_SIZE / 2) / GRID_SIZE。加GRID_SIZE/2是为了四舍五入当点击位置更接近某个交点时整除得到的就是那个交点的列号。如果不加这个偏移点击点落到两格交界处时会取到靠左靠上的格子棋子画出来是歪的。// 像素坐标转换为棋盘行列成功返回 true bool screenToBoard(int x, int y, int row, int col) { // 超出棋盘范围直接拒绝范围外包含一半格子宽度的容差 if (x MARGIN - GRID_SIZE / 2 || x MARGIN (BOARD_SIZE - 1) * GRID_SIZE GRID_SIZE / 2) return false; if (y MARGIN - GRID_SIZE / 2 || y MARGIN (BOARD_SIZE - 1) * GRID_SIZE GRID_SIZE / 2) return false; // 加半格实现四舍五入确保点击落在格点附近时选中最接近的交点 col (x - MARGIN GRID_SIZE / 2) / GRID_SIZE; row (y - MARGIN GRID_SIZE / 2) / GRID_SIZE; // 防止极端情况下换算结果越界 if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return false; return true; }画棋子的逻辑同理根据行列反算屏幕坐标然后以交点为圆心画实心圆。棋子半径建议取GRID_SIZE / 2 - 2减掉的这2像素是为了让相邻棋子之间留一条细缝。如果你把半径直接设成GRID_SIZE / 2连成一片时棋子之间会互相粘连视觉上像一串糖葫芦。这也是我后来改源码时对比出来的细节光看图片根本意识不到。4. 落子与胜负判定核心源码从哪下笔4.1 鼠标消息循环输入响应的正确写法EasyX库处理鼠标消息有两代接口。老代码里常见MOUSEMSG配合flushmouse的写法现在的新版EasyX推荐用ExMessage结构体加peekmessage函数。区别在于flushmouse会把鼠标消息一次性清空而peekmessage按需取出一条消息更适合放在游戏主循环里逐帧处理。我建议直接用新写法网上搜到的老教材代码如果编译报错提示找不到ExMessage多半是EasyX版本太旧升级到新版即可。#include graphics.h // 游戏主循环不断读取鼠标消息并响应左键点击 void handleMouse() { ExMessage msg; while (true) { // peekmessage 非阻塞地取出一条鼠标消息没有消息时返回 false if (peekmessage(msg, EM_MOUSE)) { // 只处理鼠标左键按下事件 if (msg.message WM_LBUTTONDOWN) { int row, col; // 坐标换算成功且该位置为空才允许落子 if (screenToBoard(msg.x, msg.y, row, col) board[row][col] 0) { // 落子、画棋、判定胜负的代码在这里接上 } } } // 降低CPU占用率避免死循环空转 Sleep(10); } }peekmessage的第二个参数EM_MOUSE表示只关注鼠标消息msg.message和WM_LBUTTONDOWN是Windows消息机制里的标准常量分别表示消息类型和左键按下事件。Sleep(10)每10毫秒让出一次CPU不加的话这个空循环会占满一个核心风扇立刻转起来。这里有个设计决定值得说明把窗口关闭和游戏结束的条件也放在同一个循环里。五子棋的对局中每次落子后先判断胜负胜则弹窗提示并break退出循环。如果写成“点击一下处理一下”的事件驱动模式反而要额外维护游戏状态机的退出逻辑对入门项目不划算。4.2 落子与悔棋数据结构要能“后悔”落子的完整流程是鼠标左键按下屏幕坐标换算成行列检查该位置是否为空然后写入数组、绘制棋子、压入历史栈、切换当前玩家。不要因为代码短就省略合法性检查我见过不少人下到后面的棋子上还能继续落就是因为漏了board[row][col] 0这个判断。// 在 (row, col) 位置落下一颗棋子 void placePiece(int row, int col) { // 写入棋盘数组 board[row][col] currentPlayer; // 画棋子黑子白子通过当前玩家值区分 drawPiece(row, col, currentPlayer); // 记录历史供后续悔棋使用 history.push({row, col}); // 判定胜负五子连珠则弹出提示 if (checkWin(board, row, col)) { TCHAR buf[128]; _stprintf_s(buf, _T(玩家 %d 获胜), currentPlayer); MessageBox(GetHWnd(), buf, _T(游戏结束), MB_OK); } // 切换玩家 currentPlayer (currentPlayer 1) ? 2 : 1; }悔棋的实现依赖历史栈。弹出栈顶的Move记录后把对应棋盘格子清空然后重绘整个棋盘。注意这里的关键点不能只把棋子的圆形区域用背景色盖掉因为棋子覆盖住的网格线也一并消失了。要么补画那两条交叉线更省事的做法是调用一次drawBoard()再遍历棋盘数组把所有遗留的棋子重画一遍。15×15的棋盘重绘一次耗时几乎为零重绘反而比“精确擦除”代码更短、bug更少。// 悔棋弹出历史栈顶清空棋盘后重绘 void undoMove() { if (history.empty()) return; Move last history.top(); history.pop(); // 清空该位置的棋子 board[last.row][last.col] 0; // 先重绘棋盘背景和网格线再重绘所有剩余棋子 drawBoard(); for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) drawPiece(r, c, board[r][c]); } } // 悔棋后当前玩家切换回上一手 currentPlayer (currentPlayer 1) ? 2 : 1; }这个“先清空再全量重绘”的做法是我写了很多版源码后最推荐的方式。初学时会觉得全量重绘是浪费但在这个规模下它才是最不容易出错的。真正需要在渲染层面做增量更新的场景是后来做动态游戏时的事了。4.3 胜负判定四方向扫描与边界检查胜负判定的思路是以刚落下的棋子为中心沿着水平、垂直、主对角线、副对角线四个方向统计连续同色棋子的数量。数量达到5就赢。实现时把四个方向的增量预先写在数组里循环遍历代码可以写得很紧凑。// 检测 (row, col) 处的棋子是否形成五连 bool checkWin(const int board[][BOARD_SIZE], int row, int col) { int player board[row][col]; if (player 0) return false; // 四个方向的增量水平、垂直、主对角、副对角 int dx[4] {1, 0, 1, 1}; int dy[4] {0, 1, 1, -1}; for (int d 0; d 4; d) { int count 1; // 当前棋子本身算一个 // 正方向延伸统计 for (int step 1; step 5; step) { int nr row dx[d] * step; int nc col dy[d] * 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 5; step) { int nr row - dx[d] * step; int nc col - dy[d] * step; // 同样需要边界判断 if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] ! player) break; count; } // 只要有一个方向达到5连立即判定胜利 if (count 5) return true; } return false; }边界判断是这段代码里最容易被忽略的部分。五子棋的胜负往往发生在棋盘边角如果你不检查nr和nc是否越界数组访问会越界轻则读到一个错误数据导致误判重则直接崩溃。很多人下到棋盘边上的第五颗子时程序卡死原因就在这。注意判定条件用的是count 5而不是count 5。如果出现六连或更长连珠 5会漏判。标准五子棋规则里长连也算赢但如果你要参加正规比赛长连在专业规则里会被判定为禁手。这个项目是双人娱乐版 5是正确选择同时也为你以后扩展禁手规则留出了位置。5. 常见问题与避坑五子棋项目最容易翻车的5个地方提示以下问题是我翻过车、也帮别人改过无数次的真实踩坑记录按编译期和运行期分两类每一条都按现象、原因、解决的顺序拆分。5.1 编译期问题中文乱码、字符集与库版本现象代码里写了MessageBox(GetHWnd(), 黑棋获胜, 提示, MB_OK)编译时直接报错cannot convert from const char [9] to LPCWSTR或者编译通过但弹窗里显示一堆乱码。原因Visual Studio的项目字符集默认是Unicode字符串被解释成宽字符类型LPCWSTR而字面量“黑棋获胜”是窄字符const char*两者不匹配。这是中文Windows下C编程特有的字符集问题。解决统一使用_T()宏包裹字符串它会根据项目字符集自动选择窄字符或宽字符版本。代码里写_T(黑棋获胜)配合TCHAR和_stprintf_s在任何字符集下都不会报错。这也是为什么我在前面的落子代码里用的是_T而不是直接写中文。另一个办法是把TCHAR buf[128]换成wchar_t buf[128]把所有字符串都加L前缀但这样代码一旦换回多字节项目又要改不如_T省心。现象用Dev-C或者VSCode的MinGW编译器编译报错说找不到graphics.h这个头文件。原因EasyX库从底层依赖微软的MSVC编译器和Windows SDK它不是一个跨平台的图形库。Dev-C默认自带的是MinGW编译器两者的头文件搜索路径不兼容就算你手动把EasyX的头文件拷过去库的二进制文件也无法与MinGW链接。解决换用Visual Studio社区版作为主力环境。如果因为某种原因必须用Dev-C可以学EGE库它是另一个基于MinGW的图形库API风格和EasyX接近但很多函数细节需要重新熟悉。从实际就业和课程设计角度看早点切换到VS才是正路。5.2 运行期问题坐标偏移、数组越界与窗口假死现象鼠标点击后棋子没有落在交点上而是画在两格之间的网格线上。尤其是点击棋盘角落位置时偏移特别明显有时甚至落在棋盘外。原因坐标换算公式写错了。常见错误是忘记减去MARGIN或者整除前没有加GRID_SIZE / 2。比如直接用col x / GRID_SIZE那点击(160, 200)时换算出的行列与实际棋盘位置至少偏了一格。另一个隐蔽错误是换算公式和绘制公式用了不同的基准换算时用MARGIN col * GRID_SIZE绘制时却用了MARGIN (col 1) * GRID_SIZE两套逻辑自然对不上。解决把所有坐标换算集中到两个函数里屏幕坐标转棋盘坐标用screenToBoard棋盘坐标转屏幕坐标统一用MARGIN index * GRID_SIZE计算。不要在多个地方各自写一遍公式只要改一个地方别处跟着错。我在源码里把“棋盘坐标转屏幕坐标”封装成一行内联计算就是为了避免这种低级但难查的错位。现象游戏运行一段时间后在某次落子时程序直接崩溃或者弹出“数组下标越界”的断言窗口而且崩的位置每次都靠近棋盘边界。原因胜负判定函数里的方向扫描没有做边界检查。比如当前棋子在第0行往正上方扫到第-1行时数组越界读取的是垃圾数据行为不可预测。这是五子棋源码里最常见的崩溃原因没有之一。解决在判定函数里对nr和nc做越界判断越界即停止该方向的扫描。如果你用的是int board[17][17]的外扩写法有效区域从下标1开始那么越界检查可以省去因为下标0的那一圈天然当作了“墙”。两条路都可以新手建议先写显式的边界判断看得清楚出问题也好排查。现象程序启动后窗口刷一下就消失或者在下棋过程中窗口变成“未响应”状态鼠标怎么点都没反应。原因窗口闪退通常是主函数在initgraph后没有保持消息循环就执行到了returnclosegraph随即释放了窗口资源。窗口假死则是因为主循环里做了耗时操作或者循环里没有调用任何系统消息相关的函数导致窗口消息无法处理。解决在主函数里用一个while(true)循环保持窗口存活循环内调用peekmessage处理鼠标消息。每次循环末尾加Sleep(10)既降低CPU占用又给窗口消息处理留出时间。判胜后的MessageBox弹窗要放在循环内部弹窗关闭后再break出来避免弹窗期间窗口状态异常。6. 移植到人机对战给棋盘加一个会堵路的AI评分器6.1 权值打分AI的最小实现双人版跑通后下一步就是让人机对战。最基本的AI不需要搜索树用权值打分就够了遍历所有空位分别假设黑棋和白棋落在这里统计四个方向上连续棋子的数量乘以对应权重进攻分和防守分相加总分最高的位置就是AI的落子点。// 评估在 (row, col) 放一颗 player 棋子的得分 int evaluatePoint(int board[][BOARD_SIZE], int row, int col, int player) { if (board[row][col] ! 0) return -100000; board[row][col] player; int score 0; int dx[4] {1, 0, 1, 1}; int dy[4] {0, 1, 1, -1}; // 四个方向分别统计连续棋子数按长度累加得分 for (int d 0; d 4; d) { int cnt 1; for (int step 1; step 5; step) { int nr row dx[d] * step; int nc col dy[d] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) cnt; else break; } for (int step 1; step 5; step) { int nr row - dx[d] * step; int nc col - dy[d] * step; if (nr 0 || nr BOARD_SIZE || nc 0 || nc BOARD_SIZE) break; if (board[nr][nc] player) cnt; else break; } if (cnt 5) score 100000; else score cnt * cnt * 1000; // 长度越长权重增长越快 } board[row][col] 0; // 恢复空位 return score; }选点时把进攻和防守都算一遍。进攻是评估AI自己落子的价值防守是评估对手落子的价值。两者相加的好处是AI既会主动成五也会在对手快赢时及时堵截。如果你只算进攻AI会变成瞎眼棋手对对手的活三视而不见。// AI 遍历全盘找进攻防守总得分最高的位置 Move aiMove(int board[][BOARD_SIZE], int aiPlayer) { int bestScore -1; Move best {7, 7}; for (int r 0; r BOARD_SIZE; r) { for (int c 0; c BOARD_SIZE; c) { if (board[r][c] ! 0) continue; int attack evaluatePoint(board, r, c, aiPlayer); int defend evaluatePoint(board, r, c, 3 - aiPlayer); int score attack defend; if (score bestScore) { bestScore score; best {r, c}; } } } return best; }6.2 调参经验与下一步优化权值表不是死的。cnt * cnt * 1000这种增长方式对4连已经很敏感对3连的作用稍弱。如果你觉得AI太保守、下得很“怂”把4连的权重单独提出来给一个更大的值比如cnt 4时直接加50000AI就会更积极地往成五的方向冲。反之如果AI总被对手连成四子才反应说明防守权重算低了把3连的防守得分再放大一些。新手调参最容易犯的错是盯着某一局棋反复试直到AI恰好赢了这一局。正确做法是固定一组参数连下五局分别测试AI先手、AI后手、对手斜线进攻、对手冲四威胁这几种场景统计输赢再调。我最早写的AI只会进攻不会防守被室友用一个斜线活三直接杀穿后来把防守权值拉平才恢复正常。从那以后我写策略类代码都会先问自己一句如果对手用最狠的一招打过来我这里扛不扛得住。这句自问在很多项目里都救过我希望帮到你。本文还有配套的精品资源点击获取