☰
C语言EasyX五子棋实战:从棋盘绘制到人机对战详解
2026/10/21 14:44:43 网站建设 项目流程

简介:C语言五子棋EasyX项目是一份面向C语言初学者与图形编程爱好者的完整工程实例,基于EasyX图形库实现棋盘绘制、落子交互与胜负判断,覆盖人机对战与人人对战两种模式。人机部分涉及Minimax或Alpha-Beta剪枝等基础搜索算法,适合用来理解游戏逻辑、状态管理与图形界面开发的结合。压缩包共145个文件,约57.36MB,以cpp/h源代码、exe可执行程序、jpg图片素材以及Visual Studio工程文件(sln/vcxproj)为主体,另含mp3/wav音频和编译过程中的obj/tlog等中间文件,可直接打开工程编译运行,或按需提取图片与代码模块。已有1687人学习下载,可作为课程设计或入门图形编程的参考范例。通过阅读源码可掌握EasyX常用绘图函数、键盘鼠标事件处理、棋盘数据结构的组织方式,并了解简单AI决策的落子思路;若自行扩展网络对战或音效功能,也能在现有工程基础上快速迭代。整个项目目录结构清晰,划分了菜单、棋盘、主流程等模块,便于按模块拆解学习。

1. 用 EasyX 写五子棋:星空视觉不是加分项,而是整体架构的一部分

如果你搜"c语言五子棋easyx",大概率是课程设计或期末项目迫在眉睫。直接说结论:用 C 语言配合 EasyX 图形库做五子棋,最快能在一周内完成核心功能,但"星空系列"这个视觉方向,表面上只是换了个背景,实际会影响坐标换算、落子反馈和双缓冲绘制顺序。很多人在终端里用数组下棋没问题,一上 EasyX 就懵——其实不是不会写逻辑,而是没分清"绘图坐标系"和"棋盘逻辑坐标系"之间那层换算。这篇文章会把棋盘绘制、落子判定、胜负扫描、悔棋、人机对战估值函数一路拆到底,先立住原理再贴代码,最后把我在调试中踩过的几个硬坑交代清楚,帮你少走弯路。

2. 先搭可视化底座:棋盘坐标体系与星空背景的绘制顺序

2.1 从 Win32 窗口到 EasyX:为什么说 EasyX 不是"库"而是"包装层"

很多人刚开始用 EasyX 时,会误以为它是一个类似 OpenGL 的独立图形库。实际上 EasyX 只是对 Windows GDI 的 C 语言封装,它把 CreateWindow、RegisterClass、消息循环这些 Win32 样板代码藏在了内,所以你在写五子棋时可以完全不管窗口句柄,直接调用 initgraph 就能画出窗口。

理解这个对后续布局很重要。因为 EasyX 的绘图命令最终落到 GDI 上,意味着:

  • 画线、画圆、填充区域都是同步操作,性能足够五子棋这种 15x15 的棋盘场景;
  • BeginBatchDraw 和 FlushBatchDraw 是双缓冲的关键,如果不调用,棋盘闪烁到怀疑人生;
  • 图形坐标原点在窗口左上角,x 向右为正,y 向下为正,和数学坐标系不同,写坐标换算时要习惯这种"屏幕坐标"。

具体到星空系列的视觉实现,通常做法是先用深蓝色或渐变色铺满背景,再随机撒几百个白色小点作为星星。但这里有一个容易被忽略的顺序问题:必须先画背景,再画棋盘,最后才画棋子。如果你先画了棋盘再画背景,棋谱线会被星空背景覆盖,结果就是棋盘只剩边框和棋子,中间的线全被"星空"吃掉了。

2.2 棋盘绘制:核心函数与坐标换算公式

我一般用宏定义固定棋盘参数,这样后续改棋盘路数、格子大小都只动一处:

#define BOARD_SIZE 15 // 15x15 标准五子棋盘 #define CELL_SIZE 40 // 每个格子边长(像素) #define MARGIN 30 // 棋盘边缘到窗口边缘的留白 #define WINDOW_W (MARGIN * 2 + CELL_SIZE * (BOARD_SIZE - 1)) #define WINDOW_H (MARGIN * 2 + CELL_SIZE * (BOARD_SIZE - 1))

窗口尺寸的计算公式里有个细节:15 条线之间只有 14 个格子,所以总宽度是CELL_SIZE * 14而不是CELL_SIZE * 15,再加大两边留白。这个细节错了,画面会不对称,右侧和底部明显比左侧和顶部挤。

坐标换算可以封装成两个函数:

// 逻辑下标 (row, col) 转屏幕坐标 int getScreenX(int col) { return MARGIN + col * CELL_SIZE; } int getScreenY(int row) { return MARGIN + row * CELL_SIZE; } // 屏幕坐标转逻辑下标,四舍五入到最近的交叉点 int getColFromMouse(int x) { double col = (x - MARGIN) / (double)CELL_SIZE; return (int)(col + 0.5); // 超过一半就进位 } int getRowFromMouse(int y) { double row = (y - MARGIN) / (double)CELL_SIZE; return (int)(row + 0.5); }

注意getColFromMouse里我用了(x - MARGIN) / (double)CELL_SIZE而不是整数除法。C 语言里两个 int 相除会直接舍去小数,假设鼠标在格子的 0.8 位置,整数除法直接得到 0,你有 80% 的区间会落错格。转成 double 再 +0.5 取整,才能实现"点到哪个格子就算哪个格子"。

画星空的代码我一般放在画棋盘之前,用一个随机数生成器撒点:

void drawStarBackground() { setfillcolor(RGB(255, 255, 255)); for (int i = 0; i < 300; i++) { int x = rand() % WINDOW_W; int y = rand() % WINDOW_H; int r = rand() % 2 + 1; // 星星半径 1~2,避免全部一样大 solidcircle(x, y, r); } }

这里有一个"星空不自然"的常见问题:纯随机撒 300 个点会分布得非常均匀,看起来像噪点而不是星空。想做得好一点,可以把星星按亮度分两层,暗星半径 1、亮星半径 2,并且亮星数量控制在 20% 左右。视觉上会出现少数几颗明显的星,其它细碎,这样更有纵深感。

棋盘主体用line函数画横竖线,星位用实心小圆标记:

void drawBoard() { setlinecolor(RGB(180, 180, 190)); for (int i = 0; i < BOARD_SIZE; i++) { line(MARGIN, getScreenY(i), MARGIN + CELL_SIZE * (BOARD_SIZE - 1), getScreenY(i)); line(MARGIN, getScreenY(i), getScreenX(i), MARGIN + CELL_SIZE * (BOARD_SIZE - 1)); // 注意画竖线时 y 坐标要用 MARGIN + ... } // 画星位(天元和四个角星) int starPoints[5][2] = { {7,7}, {3,3}, {3,11}, {11,3}, {11,11} }; for (int i = 0; i < 5; i++) { int sx = getScreenX(starPoints[i][1]); // col -> x int sy = getScreenY(starPoints[i][0]); // row -> y fillcircle(sx, sy, 4); } }

上面画竖线时有一个我经常写错的点:横线循环里参数是line(MARGIN, y, endX, y),竖线循环里如果照着横线的写法,很容易把竖线坐标写成line(x, MARGIN, x, endY)时漏掉MARGIN + CELL_SIZE * (BOARD_SIZE - 1)这个终点计算,导致第二根竖线以后全部画歪。建议画完之后立刻用getpixel检查或截图对比,别只盯着一根线看。

2.3 游戏状态存储与窗口初始化

棋盘画得再好,如果用数组存落子状态,后续判定胜负才能高效。我常用一个二维数组board[15][15],0 表示空,1 表示黑棋,2 表示白棋。配合棋面绘制函数:

int board[BOARD_SIZE][BOARD_SIZE] = {0}; void drawPiece(int row, int col, int player) { int x = getScreenX(col); int y = getScreenY(row); setfillcolor(player == 1 ? RGB(30, 30, 30) : RGB(230, 230, 230)); solidcircle(x, y, CELL_SIZE / 2 - 4); // 黑棋加一个白边,白色棋子加一个浅灰边,提升视觉对比度 setlinecolor(player == 1 ? RGB(200, 200, 200) : RGB(120, 120, 120)); circle(x, y, CELL_SIZE / 2 - 4); } void drawAllPieces() { 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]); }

初始化窗口时,initgraph(WINDOW_W, WINDOW_H)会创建一个标题为"EasyX"的窗口。如果你想改窗口标题,用SetWindowText(GetHWnd(), "五子棋")。还有一点容易忽略:getpixel之类的函数在窗口最小化时会丢失绘制内容,所以drawAllPieces必须在每次消息循环重绘时调用一次,而不是只在落子时调用。否则你拖拽窗口再回来,棋盘可能就"消失"了半边。

3. 落子交互与胜负判定:从鼠标消息到五连扫描

3.1 鼠标消息处理:用 ExMessage 而不是老 API 的原因

早期 EasyX 处理鼠标用的是MOUSEMSG结构体加GetMouseMsg(),它的问题是每次只能取一条消息,如果用户快速点击多次,会导致事件丢失。更稳的做法是用ExMessage配合peekmessage循环取完缓冲区里所有消息。这在五子棋里不是玄学,你快速连点两下时,旧 API 可能只处理一次,落两子的目标直接落一子。

标准的事件循环写在while (true)里:

while (true) { ExMessage msg; while (peekmessage(&msg, EX_MOUSE)) { if (msg.message == WM_LBUTTONDOWN) { int row = getRowFromMouse(msg.y); int col = getColFromMouse(msg.x); handleClick(row, col); } } // 每帧重绘棋盘,保证最小化恢复后内容完整 cleardevice(); drawStarBackground(); drawBoard(); drawAllPieces(); FlushBatchDraw(); }

EX_MOUSE参数表示我只取鼠标消息,不理会键盘消息。如果你后面想加"按 R 重新开始"的快捷键,需要把参数改成EX_MOUSE | EX_KEY,然后在循环里加msg.message == WM_KEYDOWN的判断。

3.2 handleClick 与落子有效性判断

点击棋盘某一个交叉点时,有一个边界情况:鼠标点到棋盘范围之外。此时getRowFromMouse会算出负数或大于 14 的值,直接访问board[row][col]数组就越界了——这在 C 语言里是未定义行为,程序可能崩也可能不崩,但状态就乱套了。所以落子前必须校验:

void handleClick(int row, int col) { // 越界检查:必须在 0~14 范围内 if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; // 该位置已有棋子则忽略 if (board[row][col] != 0) return; board[row][col] = currentPlayer; // currentPlayer 是全局变量,1 或 2 if (checkWin(row, col, currentPlayer)) { setcolor(RGB(255, 215, 0)); setfont(32, 0, "微软雅黑"); outtextxy(WINDOW_W / 2 - 60, WINDOW_H / 2 - 16, "黑方胜!"); // 或者白方胜,再进入结束等待状态 gameOver = 1; return; } currentPlayer = (currentPlayer == 1) ? 2 : 1; // 切换玩家 }

这里的currentPlayer初始化为 1(黑棋先手)。切换玩家逻辑看着简单,但要注意:胜负判定必须发生在玩家切换之前。如果你先切换再判定,赢家就变成对方了。这个顺序错误在代码 review 时很难发现,因为大部分时候游戏不会刚好走到五连,但真走到时你会被"明明是黑棋赢了却显示白方胜"这种诡异现象坑一整晚。

3.3 五连判定:四方向扫描的边界处理

checkWin是五子棋的核心,思路是:从刚落下的棋子出发,分别扫描水平、垂直、左斜、右斜四个方向,计算每个方向上连续同色棋子的总数。总数达到 5 就赢。

int checkWin(int row, int col, int player) { // 方向数组:dx 表示列偏移,dy 表示行偏移 int dirs[4][2] = { {1, 0}, // 水平向右 {0, 1}, // 垂直向下 {1, 1}, // 右斜下 {1, -1} // 右斜上 }; for (int i = 0; i < 4; i++) { int count = 1; // 当前棋子已经算一个 // 正向延伸 for (int step = 1; step < 5; step++) { int nr = row + dirs[i][1] * step; int nc = col + dirs[i][0] * 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 - dirs[i][1] * step; int nc = col - dirs[i][0] * 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; }

方向数组是这类判定的关键。四个方向里我只存了四种"正向",反向通过减法实现,这样代码量少一半。但容易出错的地方在{1, -1}这个方向,它代表从左上到右下还是从右上到左下,完全取决于你dy的符号约定。我的建议是:写完四个方向后,手动在纸上画一个 5 连"左下到右上"的布局,然后用调试器逐步看每个方向延伸时nr、nc的变化是否符合预期。如果{1, -1}配反了,右下到左上的斜线永远检测不出来。

还有一个小优化:其实不需要真的扫描完整个棋盘去判断胜负,只判断刚刚落子的那个点附近即可。上面的代码最多循环 4 个方向乘以 8 次延伸,计算量极小,没必要做全局扫描。

3.4 悔棋功能:小心数组与棋子的双重回滚

悔棋是用户刚需,尤其在课程设计答辩演示时,手滑落错子可以直接救回来。实现思路是用栈记录每一步的落点:

typedef struct { int row; int col; int player; } MoveRecord; MoveRecord moveStack[225]; // 15x15 最大 225 步 int moveTop = -1; void undoLastMove() { if (moveTop < 0) return; // 没有可悔的棋 MoveRecord last = moveStack[moveTop--]; board[last.row][last.col] = 0; // 清掉数组 currentPlayer = last.player; // 回合还给上一步的玩家 }

落子时把记录压栈:

void recordMove(int row, int col) { moveTop++; moveStack[moveTop].row = row; moveStack[moveTop].col = col; moveStack[moveTop].player = currentPlayer; }

这里有两个容易踩的坑。第一:currentPlayer = last.player而不是currentPlayer = (last.player == 1) ? 2 : 1,因为悔棋后当前执棋的人应该是刚被撤掉那颗棋子的玩家。第二:游戏结束状态下不能允许悔棋,否则会出现"黑棋赢了,然后悔棋一步,黑棋又赢了"的作弊方法。我见过有人用悔棋功能来"测试"负胜负判定逻辑,结果把自己绕晕了。正确做法是在undoLastMove开头检查gameOver是否为 1,如果是则直接 return。

4. 调试避坑指南:EasyX 项目的五个典型"翻车"现场

4.1 乱码问题:字符集从 Multi-Byte 到 Unicode 的一步之遥

现象:outtextxy输出的中文"黑方胜"在界面上显示成问号,或者编译直接报错"LNK2001 unresolved external symbol _WinMain@16"。

原因:新版 Visual Studio 默认使用 Unicode 字符集,而 EasyX 提供的outtextxy对 UTF-8 字符串的支持取决于项目编码设置。当你在源文件里写"黑方胜",如果源文件是 UTF-8 编码而编译器按 GBK 解释,就会出现乱码。

解决:我一般把项目属性里的"字符集"改成"使用多字节字符集"。另外,文件保存时选择"简体中文(GB2312)编码"而不是 UTF-8。如果你非要用 Unicode,那就把outtextxy的第一个参数改成_T("黑方胜"),注意_T宏可以让字符串在两种字符集下都保持一致,但_T只对字符串字面量有效,不能用在char*变量上。

4.2 闪烁问题:双缓冲用了但没用到点子上

现象:棋子落下去时,整个棋盘闪一下。或者拖动窗口时棋盘变成一块块"马赛克"。

原因:cleardevice()清屏后直接画棋盘再画棋子,绘制过程中用户看到了中间状态。双缓冲的目的是把所有绘制先在内存里完成,再一次性拷贝到屏幕。

解决:确保画图前调用BeginBatchDraw(),画完所有元素(背景、棋盘、棋子)后调用FlushBatchDraw()。注意BeginBatchDraw只需调用一次,不是每帧一次。每帧调用的应该是cleardevice()搭配FlushBatchDraw()。我一直把这段代码放在 while 循环的顶部和底部,中间不管画多少个元素,都会在内存里叠好。

while (true) { BeginBatchDraw(); // 进入批量绘制模式 ... // 各种绘制 FlushBatchDraw(); // 一次性把内存画面输出到窗口 Sleep(10); // 控制帧率,避免 CPU 占用过高 }

这里Sleep(10)是我踩过的一个性能坑。如果不加,循环会疯狂刷新,一帧都不用等,CPU 直接顶满一个核心。加上 10ms 的休眠,画面丝滑且 CPU 占用可以忽略。

4.3 高清屏适配:窗口模糊还是坐标错位

现象:在高分屏上运行,窗口文字发虚,或者鼠标点击的位置和实际落子位置偏移了几个像素。

原因:Windows 对高分屏有 DPI 缩放机制,EasyX 默认创建的窗口不是 DPI-Aware,所以坐标会被缩放。

解决:在main函数开头调用SetProcessDPIAware(),这样 EasyX 创建的窗口就按物理像素工作,不再缩放。但代价是窗口在高分屏上会看起来比较小。另一种做法是接受缩放,然后将绘图的坐标也乘以缩放系数。对五子棋这种固定尺寸的窗口,我推荐前者:

#include <windows.h> int main() { SetProcessDPIAware(); // 放在 initgraph 之前调用 initgraph(WINDOW_W, WINDOW_H); ... }

如果你记忆里没这一步,换个电脑演示就会发现"在家正常,在答辩教室偏移"。给课程设计录视频时最稳的方式就是加这一行。

4.4 入口函数错误:控制台应用 vs Windows 应用

现象:新建项目后编译报错"无法解析的外部符号 main"或"无法解析的外部符号 _WinMain"。

原因:EasyX 默认生成的窗口程序需要 WinMain 入口。而 VS 默认的控制台应用需要 main 入口。如果你新建的是"控制台应用",代码里写的是int main(),链接时缺 WinMain;如果你新建的是"Windows 桌面应用",代码里写 main 也会报错。

解决:最省事的做法是新建"空项目",然后手动添加一个main.cpp,并在代码里写int main()。EasyX 的initgraph会在内部接管窗口消息循环,你不需要自己写 WinMain。还有一种方式是在项目属性里把"链接器 -> 系统 -> 子系统"改成"控制台",这样main就能被正确识别。我一般直接用空项目加main.cpp,不用改链接器,干净。

4.5 重绘丢失:最小化窗口后棋盘一片黑

现象:窗口最小化再恢复,棋盘变成黑色背景,棋子全部消失,只剩星空的星星还在。

原因:屏幕内容被系统回收了,EasyX 不会自动重绘。你以为画一次就永久保留,但窗口系统不是这样的。

解决:把"绘制"和"逻辑"彻底分开。棋盘数组是唯一数据源,每次重绘时按数组重新画棋子和棋盘。这就是我第 3 章里 while 循环每帧都调用drawStarBackground / drawBoard / drawAllPieces的原因。只要绘制函数完整,窗口恢复后重绘自然恢复正常。很多初学者把drawAllPieces放在handleClick里,只在落子时调用,最小化后就会翻车。

5. 进阶玩法:把"人人对战"改成"人机对战"的估值函数思路

课程设计从"人人对战"升级到"人机对战",难度会明显上一个档次,但分也不止高一点。EasyX 本身不提供 AI 功能,这完全靠你写。我建议你不要一上来就想着让电脑会"思考",先做一个最简单的"贪心估值",它足够以数据结构的方式展现你理解了博弈论。

核心思路是:对棋盘上的每个空点打分,分数越高表示这个位置越有价值。分数来源是两个方向:进攻分(我的棋子连子数)和防守分(对手的棋子连子数)。比如某个位置放上我的黑棋能形成"四连"(差一步就五连),进攻分很高;如果这个位置放上白棋能形成"四连",那我的防守分也很高。

int evaluatePosition(int row, int col, int player) { int score = 0; // 四个方向各评估一次 int dirs[4][2] = { {1,0}, {0,1}, {1,1}, {1,-1} }; for (int i = 0; i < 4; i++) { int count = 1; // 已有当前棋子 int empty = 0; // 空位计数,用于判断活路 // 正向延伸 for (int step = 1; step < 5; step++) { int nr = row + dirs[i][1] * step; int nc = col + dirs[i][0] * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] == player) count++; else if (board[nr][nc] == 0) { empty++; break; } else break; } // 反向延伸 for (int step = 1; step < 5; step++) { int nr = row - dirs[i][1] * step; int nc = col - dirs[i][0] * step; if (nr < 0 || nr >= BOARD_SIZE || nc < 0 || nc >= BOARD_SIZE) break; if (board[nr][nc] == player) count++; else if (board[nr][nc] == 0) { empty++; break; } else break; } if (count >= 5) return 100000; // 直接赢的位置,最高分 if (count == 4 && empty == 1) score += 800; // 冲四 else if (count == 3 && empty == 1) score += 300; // 活三 else if (count == 2 && empty == 2) score += 80; else score += count * 5; } return score; }

AI 走棋时,对每个空点分别计算出"我下在这里的得分"和"对手下在这里的得分",取两者中较高的值作为总分,选总分最高的位置落子:

void aiMove() { int bestScore = -1, bestR = 0, bestC = 0; for (int r = 0; r < BOARD_SIZE; r++) { for (int c = 0; c < BOARD_SIZE; c++) { if (board[r][c] != 0) continue; int attackScore = evaluatePosition(r, c, currentPlayer); int defendScore = evaluatePosition(r, c, 3 - currentPlayer); int total = attackScore * 0.6 + defendScore * 0.4; // 随机加一点偏移,避免每次同样局面走法完全一致 total += rand() % 5; if (total > bestScore) { bestScore = total; bestR = r; bestC = c; } } } board[bestR][bestC] = currentPlayer; // 到这里 AI 落子,记得也压入 moveStack,用户悔棋才能成功 recordMove(bestR, bestC); currentPlayer = 3 - currentPlayer; }

这里attackScore * 0.6 + defendScore * 0.4是我调了很久的比例。进攻权重高会让 AI 更积极地组织自己的棋形,防守权重高会让 AI 更擅长堵截对手。0.6/0.4 是我用模拟对局试出来的平衡点,如果你觉得 AI 太弱,就把进攻权重调高,比如 0.7/0.3,但要注意这样会减少 AI 防守,容易被对手连续做活三。我个人建议保留随机扰动rand() % 5,否则相同的开局每次走法都一样,"人机对战"很快会被摸透,演示时也显得呆板。

如果课设要求是"至少使用一种算法",那么用这种估值函数做基础,还可以用"极大极小值搜索"优化,但那属于进阶。我最后提示一个关键点:AI 落子之后一定要走recordMove压栈。我之前偷懒跳过这一行,结果用户连续悔棋时栈里存的都是人类落点,出现"悔棋一次,AI 的棋子消失了但轮到人类走"的荒诞局面。整个调试过程非常折磨,从那以后我每次写完 AI 落子逻辑,都强制自己检查一遍"是否有对应的悔棋记录"。

希望这篇笔记能帮你把 EasyX 五子棋从画棋盘一路写到人机对战,做课设时少一点玄学,多一点掌控感。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询