C语言五子棋+EasyX实战:从二维数组到AI博弈
2026/9/9 22:43:18 网站建设 项目流程

简介:一份基于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或者SDL,EasyX就是奔着教学来的,它把窗口创建、鼠标事件、绘图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)),但你要落子,需要知道它对应棋盘的哪一行哪一列(比如(row=7, col=10))。

转换公式其实很简单:假设棋盘左上角的起始像素坐标是start_xstart_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.libEasyX.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 调试技巧:可视化每一步判定过程

checkWinevaluatePoint这类核心函数里,我强烈建议新手加一些临时的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++游戏引擎,这段基础都会成为你最厚实的那块垫脚石。

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

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

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

立即咨询