ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于EasyX的C++五子棋游戏设计与实现:从绘图到AI判定

基于EasyX的C++五子棋游戏设计与实现:从绘图到AI判定 简介一份基于easyx图形库的C五子棋游戏完整源码适合C初学者、游戏开发爱好者以及图形界面编程入门者学习与实践。项目实现了棋盘绘制、落子交互、胜负判断等核心玩法并通过easyx完成窗口渲染和鼠标操作整体逻辑清晰便于拆解学习。资源包共36个文件大小22.92MB包含3个cpp源文件、3个头文件以及14个jpg、3个png等图像素材和4个mp3、2个wav、1个ogg等音频素材此外图像与音频素材较丰富视觉和听觉体验完整还附带Visual Studio解决方案文件与Git配置既可直接在VS中编译运行也能了解工程配置与版本管理方式。作为综合练习项目它覆盖了C语法、面向对象设计、easyx绘图、游戏循环、鼠标事件响应以及简单AI对战算法适合课程设计、毕业设计或自主练手已有203人浏览学习是一份值得参考的C游戏开发源码。1. 一个「能交作业、能学 C」的窗口程序为什么值得自己写一遍如果你正在找一套能跑、能看懂、能改的 C 练手项目基于 easyx 库的 C 五子棋游戏设计源码正好卡在这个位置它不需要引入 Qt、SDL、raylib 这类重框架只用一个轻量图形库把背景、棋盘、鼠标、棋子全部画出来核心逻辑全部落在纯 C 的数据结构和算法上。做完这套东西你会同时摸清三件事C 程序是怎么从 main 函数跑到图形界面的、二维数组在真实游戏里怎么当棋盘用、以及胜负判定这种看似简单的问题里藏着哪些边界条件。适合的人群很具体正在做课程设计的学生、想从控制台程序往图形界面挪一步的初学者、以及想给 C 语法找个实际场景的开发者。它不能帮你做网络对战或 3D 特效但能把「面向对象 消息处理 算法」这三个 C 入门必过的坎一次性踩完下面按我实际写这套源码的顺序展开。2. 把源码跑通之前先想清楚 easyx 解决的是哪一层问题2.1 为什么选 easyx 而不是 SFML、SDL、raylib五子棋这种程序九成工作量在「棋盘绘制、鼠标交互、胜负判断」三件事上真正需要图形库提供的其实只有窗口创建、画线、画圆、接收鼠标这些基础能力。easyx 恰好把 Windows API 封装成了一组类似 Turbo C 风格的绘图函数几十行代码就能出一个窗口SFML 和 raylib 虽然更现代但需要你额外处理事件循环、渲染纹理和坐标系对还不熟悉指针和类的大多数初学者来说学习曲线直接翻了一倍。更关键的是编译链路的差别。easyx 走的是 VC 编译器路线在 Visual Studio 里装完就能用#include easyx.h 再加一条链接配置就完事SDL 和 raylib 要走 CMake、装依赖库、配 include 和 lib 路径任何一个环节版本对不上就报一大堆 LNK2019。如果你只是想让五子棋源码尽快跑起来而不是研究图形引擎easyx 是最稳的选择。2.2 从源码下载到第一帧画面三个前置步骤拿到源码包后不要急着用记事本打开就编译先按工序来。第一步装 Visual Studio常见做法是装 VS2019 或 VS2022社区版足够安装时勾选「使用 C 的桌面开发」。第二步去 easyx 官网下载与 VS 版本匹配的安装包双击安装安装程序会自动把头文件和静态库写到 VS 的 VC 目录里不需要手工配置 include 路径。第三步在 VS 里新建一个空项目把源码文件拖进去然后在项目属性的「链接器 → 输入 → 附加依赖项」里确认包含easyx.h对应的库。下面这段是这套五子棋源码最常见的入口写法#include easyx.h #include conio.h int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); // 创建绘图窗口 setbkcolor(WHITE); // 设置背景色 cleardevice(); // 用背景色清空屏幕 // 游戏主循环放在这里 // ... getch(); // 按任意键退出 closegraph(); // 关闭窗口 return 0; }这段代码里initgraph是 easyx 的窗口初始化函数两个参数是窗口宽和高单位是像素setbkcolor接受一个颜色常量cleardevice让背景色立即生效。注意getch()在这里的作用是让窗口停留如果没有它程序执行完closegraph窗口会一闪而过你甚至看不到棋盘画出来。主循环里要处理鼠标消息和绘制逻辑后面两章展开。2.3 源码文件和组织结构先认清这四块再动手改一套完整的五子棋源码包通常拆成四个角色。文件里一般有一个入口文件main.cpp、一个棋盘与绘制相关的模块、一个逻辑判定模块、一个公共头文件。我第一次拿到这类源码时习惯先画一张静态关系表理清哪些函数会被谁调用。文件/职责内容范围关键函数示例main.cpp窗口生命周期、游戏主循环main()、gameLoop()board.h / board.cpp棋盘初始化、绘制、棋子落盘initBoard()、drawBoard()judge.h / judge.cpp落子合法性、五子连珠判定isValidMove()、checkWin()common.h常量、全局数据结构、公共类型BOARD_SIZE、StoneTypeBOARD_SIZE通常定义成 15棋盘数组用二维数组int board[15][15]表示0 代表空1 代表黑子2 代表白子。这样设计的好处是判定函数不需要关心绘图它只读数组里的值逻辑和界面彻底解耦。改代码时你只需要记住棋盘数组是唯一的数据真相画出来的棋子只是它的投影。3. 从空白窗口到能落子棋盘绘制和坐标换算是这套源码的第一道坎3.1 棋盘参数设计一个 40 像素格子和一条留白公式画棋盘之前先定参数。标准五子棋是 15 乘 15 条线交叉点落子不是格子中心落子所以窗口尺寸要刚好容纳 14 个间隔加两侧留白。我习惯用下面这组数值窗口 640 乘 640格子尺寸CELL_SIZE 40棋盘外侧留白BOARD_MARGIN (640 - 14 * 40) / 2 40。这样整个 15 路棋盘在水平和垂直方向完全居中。#define BOARD_SIZE 15 #define CELL_SIZE 40 #define WINDOW_WIDTH 640 #define WINDOW_HEIGHT 640 #define BOARD_MARGIN ((WINDOW_WIDTH - (BOARD_SIZE - 1) * CELL_SIZE) / 2)BOARD_MARGIN这个宏不用手算成 40让编译器算更安全因为一旦你改大窗口尺寸或格子尺寸留白会自动重新居中。这套参数是整个源码的骨架鼠标坐标换算、棋盘绘制、AI 落子都要引用它改任何一处之前先想想其他函数会不会跟着变。3.2 绘制棋盘直线、交叉点、天元和星位多数源码把棋盘绘制拆成两个函数画线画交叉点再画星位。画线部分循环 15 次每次画一条横线和一条竖线line函数接收起点和终点坐标使用 easyx 的黑色画笔。落在交叉点上的天元和五个星位按固定坐标画实心小圆。void drawBoard() { setlinecolor(BLACK); // 设置线颜色为黑色 for (int i 0; i BOARD_SIZE; i) { // 横线y 固定x 从左边距到右边距 line(BOARD_MARGIN, BOARD_MARGIN i * CELL_SIZE, BOARD_MARGIN (BOARD_SIZE - 1) * CELL_SIZE, BOARD_MARGIN i * CELL_SIZE); // 竖线x 固定y 从上边距到下边距 line(BOARD_MARGIN i * CELL_SIZE, BOARD_MARGIN, BOARD_MARGIN i * CELL_SIZE, BOARD_MARGIN (BOARD_SIZE - 1) * CELL_SIZE); } // 星位坐标天元在正中央四隅星位对称分布 fillcircle(BOARD_MARGIN 7 * CELL_SIZE, BOARD_MARGIN 7 * CELL_SIZE, 4); // 其余星位第 3、11 行与第 3、11 列的四个交点 }这里fillcircle的前两个参数是圆心坐标第三个是半径。星位坐标如果记不住直接记住数字在 15 路棋盘上星位位于第 3、7、11 三条线与第 3、7、11 三条线的交点上7 路交点就是天元。初学者最容易在这里漏画四个角的星位视觉效果上棋盘会显得「缺角」。3.3 把鼠标点转换成棋盘交叉点半个格子是关键棋盘的逻辑坐标和窗口像素坐标必须建立换算关系。鼠标点击位置大概率不会精准落在某条线的交点上所以要按最近邻规则吸附。这个换算函数必须在每个下棋源码里单独抽出来因为判定、AI、落子绘制都要复用它。int mouseToGrid(int mousePos) { // 先判断是否落在棋盘留白区域外 if (mousePos BOARD_MARGIN) return -1; // 距离留白最近的交叉点索引加半个格子实现四舍五入 int grid (mousePos - BOARD_MARGIN CELL_SIZE / 2) / CELL_SIZE; if (grid BOARD_SIZE) return -1; return grid; }参数说明mousePos是消息结构体里带出的鼠标 x 或 y 像素坐标。加上 CELL_SIZE / 2这一步作用很关键——如果鼠标点在两个交叉点之间但更靠近右边那个整数除法会把余数舍掉导致吸附到左边的点加上半个格子相当于先偏移再截断自动完成四舍五入。越界判断返回 -1调用方必须对它做拦截否则数组下标负值会直接让程序翻车。3.4 落子后的重绘双缓冲是体验分水岭落子不只是往数组写数字还要把棋子画到窗口上。很多初学者在这里直接调fillcircle画一个带边框的圆结果鼠标每点一次屏幕就闪一下。原因是窗口没有缓冲机制绘制和擦除交错进行。bool placeStone(int row, int col, int player) { if (board[row][col] ! 0) return false; // 已有棋子拒绝落子 board[row][col] player; beginBatchDraw(); // 启动批量绘制 setfillcolor(player 1 ? BLACK : WHITE); setlinecolor(player 1 ? BLACK : WHITE); int x BOARD_MARGIN col * CELL_SIZE; int y BOARD_MARGIN row * CELL_SIZE; fillcircle(x, y, CELL_SIZE / 2 - 2); // 半径留 2 像素空隙更美观 // 白子加边线否则白棋与浅色背景会融为一体 if (player 2) setlinecolor(BLACK); circle(x, y, CELL_SIZE / 2 - 2); endBatchDraw(); // 统一刷新到屏幕 return true; }beginBatchDraw和endBatchDraw是 easyx 提供的双缓冲函数对所有绘制先进入内存画布再一次性输出到窗口。注意棋子半径不是CELL_SIZE / 2而是要减 2否则相邻棋子会挤在一起。白子绘制时要把描边颜色再设回黑色这一步很容易漏漏了以后白子和白色背景几乎分不开。4. 胜负判定与人机 AI让这套源码从「能下棋」升级到「能对战」4.1 五子连珠判定4 个方向共用同一个方向向量表胜负判定是一个典型的「方向扫描」问题初学者最容易写成的劣质代码是一段五层 if 嵌套分别数上下、左右、两条斜线每个方向还单独写循环。这类代码跑起来没问题但一旦要加禁手、加 AI、换棋盘尺寸改动量会让人崩溃。我在这类源码里一般不用整体棋盘扫描而是从最后落子的位置出发沿四个方向向两端扩散数同色棋子数到大于等于 5 就判胜。四个方向用一张静态方向表描述。const int dir[4][2] { {1, 0}, // 水平方向列递增 {0, 1}, // 垂直方向行递增 {1, 1}, // 主对角线行列同增 {1, -1} // 副对角线行增列减 }; bool checkWin(int row, int col, int player) { for (int k 0; k 4; k) { int count 1; // 当前棋子自身算一颗 // sign 为 -1 时向负方向数为 1 时向正方向数 for (int sign -1; sign 1; sign 2) { int nr row dir[k][0] * sign; int nc col dir[k][1] * sign; while (nr 0 nr BOARD_SIZE nc 0 nc BOARD_SIZE board[nr][nc] player) { count; nr dir[k][0] * sign; nc dir[k][1] * sign; } } if (count 5) return true; } return false; }逻辑说明sign变量在 -1 和 1 之间跳跃每次只沿一个方向延伸while条件里先做边界检查再访问数组元素这行顺序写反就可能在遍历到棋盘边缘时把内存读穿。四条方向逐一对齐后count累加的就是包含当前落子在内的连续同色子数量。这个函数只依赖board数组和落子坐标所以判定时机永远是在placeStone落子完成后立即调用一次。4.2 判定的时机选择为什么不在每轮全盘扫描另一个常见分歧点是落子后扫全盘找五连还是只从落子点往外扩散。功能上两者都能判对但性能差距在棋盘越大时越明显。15 路棋盘全盘扫描一次是 225 个位置每个位置扫四个方向单轮也要近千次数组访问而落子点扩散法只遍历与当前步相关的连通区域实际访问量一般只有几十次而且不依赖当前是第几手、棋盘上有无残局。实践上还有个更隐蔽的好处从落子点扩散天然支持「悔棋后重新判定」因为每次只检测最近一手棋不用重建整个盘面状态。如果你的源码里还有观战演示、棋谱回放这类功能这个特性省很多事。4.3 人机对战接缝先让 AI 学会堵活四再谈策略源码里最常被问到的就是 AI 部分我要说的直白一点课程设计级别的源码不需要上弗洛伊德和蒙特卡洛树搜索一个「赢棋优先、堵活四优先、随机保底」的分层判断就足够打交换手五五开。最简实现是轮到 AI 时扫描全部空点对每个空点临时落子调用checkWin看自己能不能五连能就直接下这里。若自己无胜点再模拟玩家在同样位置落子若玩家能五连则必须堵。两轮都没有就选一个优先位离天元近的位置下。这样写出来大约 40 行关键代码也只有一张模拟表和一个随机兜底。int findBestMove(int aiPlayer) { int opponent (aiPlayer 1) ? 2 : 1; for (int i 0; i BOARD_SIZE; i) { for (int j 0; j BOARD_SIZE; j) { if (board[i][j] ! 0) continue; // 先看自己下这手能否直接获胜 board[i][j] aiPlayer; if (checkWin(i, j, aiPlayer)) { board[i][j] 0; // 试探后立即还原 return i * BOARD_SIZE j; } // 再看玩家下这手是否会赢赢了必须堵 board[i][j] opponent; if (checkWin(i, j, opponent)) { board[i][j] 0; return i * BOARD_SIZE j; } board[i][j] 0; } } return -1; // 无攻防点走保守落子 }参数说明棋盘数组在这里被当作临时状态使用模拟落子后必须立刻归零否则后续判定会被脏数据污染。返回值用row * 15 col把两个坐标打包成一个整数调用方再用除法和取模解包。这个写法在源码里很常见也是一道经典的 C 编程小考题为什么不用pair或结构体答案是结构体更直白但整数打包省去每次构造对象的开销面试时能说出这点差异会显得底子更扎实。4.4 AI 强度参数搜索深度不是越大越好有人拿到这类源码后会立刻改搜索深度想做一个按不了的五子棋 AI。这里要控制预期当前盘面模拟一层只能看到「下一手谁赢」遇到冲活三、双活三这种需要两步以上推演的局面必然漏招。想提高强度至少要加一层模拟我方下完后把对手可能的应对也试一遍选出自己的得分最高的分支。不过深度每加一层时间复杂度就按空位数指数膨胀。15 路棋盘中盘阶段空位有两百多个模拟三层就是千万量级每秒只处理鼠标消息的单线程程序根本扛不住。工程做法是给搜索加一个层数上限同时给空点排序——优先搜索靠近已有棋子的位置离战场太远的点直接跳过这样即便只搜两层棋力也能明显超过只会堵活四的新手版。5. 编译、链接、运行阶段的高频踩坑从 LNK 报错到白子消失5.1 编译报错 lnk2019 / lnk2001main 函数和窗口子系统对不上现象代码看起来完全没问题编译器报链接错误说无法解析的外部符号_main或者WinMain入口缺失。原因easyx 程序默认使用 Windows 窗口子系统但项目属性里配置的入口还是控制台程序的main反过来控制台项目里的人又会出现找不到 WinMain 的报错。解决在项目属性「链接器 → 系统 → 子系统」里把「控制台」改成「窗口」或者保持控制台不动但确保源码里main函数明确存在。我在多个版本的 VS 里都遇到过新项目向导自动生成_tmain的情况统一把入口函数改成标准main()就能避开一堆隐藏宏带来的问题。5.2 中文字符串变成乱码源文件编码和编译器默认不一致现象棋盘标题、菜单提示等中文文本运行时全部显示成一片乱码英文和数字正常。原因Windows 下 VS 的源文件保存编码和编译器读取编码不一致最常见的组合是源文件用 UTF-8 保存而编译器按本地代码页读取。解决有二选一的方案。简单粗暴的是把所有中文提示改成英文或拼音另一种是把源文件另存为 GBK/ANSI 编码或者在main最开头调用setlocale(LC_ALL, zh-CN)。我在这类课程设计源码里一般推荐 GBK 另存因为要求对方改编译器选项不现实而 GBK 是 Windows 下维护最省心的中文编码。5.3 鼠标点击有延迟或完全无响应消息保存在事件队列里没被取走现象窗口能画棋盘但怎么点都没反应或者反应明显慢一拍。原因没进消息循环或消息分支写成了阻塞等待鼠标消息堆积在队列里没被peekmessage及时取走。解决游戏主循环里必须用非阻塞方式取消息并且每帧都调用一次取消息函数。常见错误写法是在循环里用getmessage本次取不到就一直停在那里等导致棋盘根本来不及重绘。正确姿势是peekmessage取出一条处理一条然后马上继续画帧。主循环结构大致是while (running) { while (peekmessage(msg)) handle(msg); draw(); }。5.4 棋子落下一个变成两个重复绘制且没有清屏现象先落的白子点击另一处后旧位置又出现一颗相同棋子整个棋盘越来越乱。原因绘制循环里用cleardevice清掉了背景但没有调用drawBoard重绘棋盘底图于是老棋子的绘图残留叠加到了新画面里。解决每帧统一流程应该是先清屏、再画棋盘、再根据board数组把所有已存在的棋子全部画一遍。注意绘制顺序不能反棋盘底图要在最底层棋子和星位最后覆盖。顺手做个优化只有棋盘数组变化时才重绘鼠标悬停但不按键时不要每次都全量刷新能避免 CPU 占用时刻居高不下。5.5 easyx.h 找不到安装路径和 VS 实例没对上现象#include easyx.h直接报 fatal error C1083无法打开包括文件。原因装 easyx 时选错 VS 版本或者安装时 VS 没关安装包把文件写进了旧 VS 的目录而当前导入的是新实例。解决关闭全部 VS 再重装 easyx安装界面里会列出检测到的 VC 版本确认和当前使用的 VS 对应。如果仍找不到去 easyx 安装目录的 include 文件夹确认是否存在 easyx.h然后把该目录手动填进项目属性的「C/C → 常规 → 附加包含目录」。这里顺便提醒一句如果用的是 VSCode 配 C 环境easyx 在不是 MSVC 的编译器上兼容性通常不太理想常见做法还是回到 Visual Studio 一条龙。6. 设计一个能随时反悔的落子记录从数据栈到回放把悔棋做成一个独立小功能比做 AI 更能体现这套源码的数据结构功底。核心做法是用标准容器维护一个历史栈每落一子就记录「行、列、玩家」三元组悔棋时从栈顶弹出把对应格子清成 0再重绘整个棋盘。这种设计天然支持「悔到任意步骤」因为你随时可以重建盘面。struct StoneMove { int row, col, player; }; std::vectorStoneMove history; // 用 vector 模拟栈 bool undoLastMove() { if (history.empty()) return false; StoneMove last history.back(); history.pop_back(); board[last.row][last.col] 0; redrawAllStones(); // 清屏后按 history 里的全部记录重绘 return true; }逻辑说明history局变量在整盘游戏中只增不减只有当用户执行悔棋或重新开始时才 pop 或 clear。redrawAllStones里按记录顺序重新画棋子而不是只擦除栈顶那一颗原因是棋盘上后续棋子的位置没有变但旧棋子被擦除后必须靠重建才能恢复到刚才的视觉效果。这套思路再往前走一步就是复盘功能从空棋盘开始逐条执行 history每执行一条暂停几十毫秒就变成棋谱回放。给这套源码再加东西时我的习惯是永远先看数据结构能不能表达新需求。加计时器就维护一个开局时间戳加和棋判定就维护落子计数加音效就在placeStone成功后调用播放函数。这些改动听起来零碎但你会发现它们全部围绕同一个数组和一个记录栈转这正是这一类课程设计源码最好的学习价值所在——它不会教你复杂设计模式但它逼着你把每个新增需求翻译成最朴素的 C 数据操作。这个习惯我保留到现在做图形程序时仍然觉得够用希望帮到你。本文还有配套的精品资源点击获取
返回列表