简介:这是一份基于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::stack<Move> history; // 悔棋时从栈顶弹出棋盘数组建议定义为全局变量。你可能觉得全局变量不优雅,但在这个项目里它带来的便利远大于坏处:绘图函数、判定函数、AI函数都要读它,如果每个函数都传int board[15][15]参数,函数签名会非常啰嗦。等以后把项目扩展成类封装时,再把它收进成员变量也不迟。
还有一个细节:如果把棋盘数组声明为int board[17][17],把有效下棋区域放在下标1到15之间,下标0和16那两圈留空,那么胜负判定时就不需要检查数组越界了。这是很多游戏源码处理边界问题的经典技巧,我在下一章的判定代码里会再提到。
3.2 绘制函数拆解:画网格、画棋子与坐标换算
绘制棋盘之前先确定三个常量:棋盘路数BOARD_SIZE=15、每格像素宽GRID_SIZE=40、边距MARGIN=40。窗口宽度等于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后没有保持消息循环就执行到了return,closegraph随即释放了窗口资源。窗口假死则是因为主循环里做了耗时操作,或者循环里没有调用任何系统消息相关的函数,导致窗口消息无法处理。
解决:在主函数里用一个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时直接加50000,AI就会更积极地往成五的方向冲。反之如果AI总被对手连成四子才反应,说明防守权重算低了,把3连的防守得分再放大一些。
新手调参最容易犯的错,是盯着某一局棋反复试,直到AI恰好赢了这一局。正确做法是固定一组参数,连下五局,分别测试AI先手、AI后手、对手斜线进攻、对手冲四威胁这几种场景,统计输赢再调。我最早写的AI只会进攻不会防守,被室友用一个斜线活三直接杀穿,后来把防守权值拉平才恢复正常。从那以后我写策略类代码都会先问自己一句:如果对手用最狠的一招打过来,我这里扛不扛得住。这句自问在很多项目里都救过我,希望帮到你。
本文还有配套的精品资源,点击获取