
简介该资源是一套面向C语言初学者与图形编程入门者的五子棋实战项目源码基于EasyX轻量级图形库实现重点解决学了语法却不会做带界面的完整小游戏这一常见问题。项目围绕初始化绘图窗口、绘制棋盘网格、落子判定与胜负检测等环节组织代码同时保留菜单模块与人机、双人对战等模式的实现思路可作为课程设计、期末大作业或自学练手的参考蓝本。压缩包共145个文件、约57.36MB除6个cpp源文件与5个头文件外还包含obj、pdb、ilk等编译中间产物3个可执行程序与sln、vcxproj工程配置便于直接用Visual Studio打开还原另有jpg界面截图与mp3、wav音频素材可直观对照运行效果。目前已有1689人学习下载适合希望通过完整工程理解C语言模块划分、EasyX绘图函数调用与基础博弈逻辑落子评估的读者参考借鉴。1. 从命令行到图形窗口一套 C 语言五子棋 easyx 项目能落地成什么样很多人的第一个 C 语言大作业是控制台五子棋黑底白字落子靠输入3 5这样的坐标走一步刷一屏玩两把就腻了。这套星空系列 C语言 五子棋 easyx 项目解决的就是这个尴尬棋盘画在真实窗口里鼠标点哪落哪配一张星空背景图再叠一层闪烁星点交作业和给同学演示都不掉价。它适合三类人——刚学完数组和函数、想找个完整小项目练手的初学者要交 C 语言课程设计、需要图形界面撑场面的同学以及想把双人对战改成带简单 AI 的人机对战、拿它当二次开发底座的人。代码量不大但棋盘、消息循环、胜负判定、悔棋、重绘这几件事一个不少麻雀虽小五脏俱全。2. 工程骨架先把窗口、棋盘和坐标这三件事定下来这套项目的代码量如果摊开来看主线其实很短短到很多人第一次看完会有点意外——真正撑起像个游戏的不是逻辑有多复杂而是工程结构有没有提前切干净。我见过太多版本的五子棋把main函数写成五百行的面条画棋盘、判断输赢、处理鼠标、弹提示框全糊在一个while里等到想加个悔棋按钮或者换个棋盘尺寸牵一发动全身最后只能推倒重写。所以第二章先把地基打牢文件怎么分、环境怎么配、坐标系怎么定这三件事定死了后面写业务逻辑才不会被反复返工。2.1 目录结构与文件职责划分这个项目规模不大硬拆成十几个文件反而增加跳转成本我一般按一个职责一个文件的方式切成五块编译时用一条命令或者一个 Makefile 打通即可。具体分布大致是这样文件职责关键内容main.c程序入口与主循环initgraph、消息循环、流程调度board.c / board.h棋盘数据与规则二维数组、落子、悔棋、胜负判定render.c / render.h全部绘制逻辑棋盘线、棋子、星空背景、提示文字input.c / input.h输入处理鼠标消息解析、坐标换算res/资源目录背景图、落子音效、字体文件拆分的核心原则是规则层不碰任何 EasyX 的 API。board.c里只允许出现int、数组和for循环一旦你发现自己在规则文件里写了setfillcolor说明分层已经漏了。这条约束听起来教条实际收益很大——规则层可以脱离图形环境单独测试你可以写一个几十行的控制台测试程序把十万局随机对局灌进去验证胜负判定有没有漏判不用每次都开窗口点鼠标。头文件里用extern声明共享状态或者更规范一点把棋盘封装进一个结构体通过指针传递/* board.h —— 规则层对外只暴露这些接口 */ #ifndef BOARD_H #define BOARD_H #define SIZE 15 /* 15 路棋盘五子棋标准尺寸 */ typedef struct { int cell[SIZE][SIZE]; /* 0空位 1黑子 2白子 */ int top; /* 悔棋栈指针指向下一个可写位置 */ int cur; /* 当前该谁走1 黑 2 白 */ int moves[SIZE * SIZE][3]; /* 保存每一步的 行、列、执子方 */ } GameState; void InitGame(GameState *g); int PlaceStone(GameState *g, int r, int c); /* 成功返回 1非法返回 0 */ int UndoOne(GameState *g); /* 成功返回 1 */ int CheckWin(const GameState *g, int r, int c); #endif这个结构体是整个项目的单一事实来源。棋盘数组、当前执子方、历史记录全在里面主循环里只维护一个GameState gs;实例传指针进去改。好处是后面想加重开一局功能直接InitGame(gs)一把清零不需要到处找全局变量挨个重置——那种漏重置一个变量导致的新开一局第一手就判赢的玄学 bug我踩过不止一次。moves数组用二维int而不是结构体数组是为了让存盘和读档更省事fwrite一次就能把整局棋谱写到文件里。如果你更喜欢可读性换成typedef struct { int r, c, who; } Move;也完全没问题内存占用差不了几十字节。2.2 EasyX 环境配置与最小可运行窗口EasyX 是给 Visual C 用的一套轻量图形库本质上是对 GDI 的一层封装头文件graphics.h编译时链接EasyXa.lib新版通常是自动链接装完就带。它的安装方式很省事去官网下对应 VS 版本的安装包双击运行它会自动把graphics.h、easyx.h和库文件塞进 VS 的 include 与 lib 目录重启 IDE 就能用。注意一定要选和自己编译器版本匹配的安装包版本选错会报一堆无法解析的外部符号这是新手最容易卡住的第一关。工程类型建议选空项目而不是控制台应用然后把字符集设为使用多字节字符集——因为项目里会用到中文字符串和outtextxy输出提示语Unicode 字符集下char*直接传会编译报错。最小可运行骨架长这样/* main.c —— 先不写游戏逻辑把窗口点亮再说 */ #include graphics.h #include conio.h #include board.h #include render.h int main(void) { /* 680 40 * 15 40 * 240 是格子间距两边各留 40 像素边距 */ initgraph(680, 720); setbkcolor(RGB(238, 220, 180)); /* 棋盘底色木黄色 */ cleardevice(); GameState gs; InitGame(gs); DrawStarfield(); /* 星空背景先画绘之后在这里补 */ DrawBoard(); /* 画 15 条横线 15 条竖线 */ /* 卡住窗口避免程序跑完立刻退出 */ _getch(); closegraph(); return 0; }几个参数必须解释清楚否则改起来容易翻车initgraph的前两个参数是窗口客户区宽高不含标题栏和边框。想让棋盘居中宽高就得按MARGIN * 2 GRID * (SIZE - 1)算别拍脑袋写 800×600画出来棋盘会偏到左下角。setbkcolor只改背景色不会自动重画。改完必须跟一句cleardevice()这是新手第一个高频坑只改不刷新等于没改。EW_SHOWCONSOLE这个标志按需加。调试期加上方便printf打日志发布时去掉不然玩家会看到一个黑框跟着游戏窗口。DrawBoard本身就是两层循环没什么技术含量但坐标公式要写对void DrawBoard(void) { setlinecolor(RGB(90, 60, 30)); setlinestyle(PS_SOLID, 1); for (int i 0; i SIZE; i) { /* 横线y 固定x 从 MARGIN 到 MARGIN (SIZE-1)*GRID */ line(MARGIN, MARGIN i * GRID, MARGIN (SIZE - 1) * GRID, MARGIN i * GRID); /* 竖线x 固定y 走一遍 */ line(MARGIN i * GRID, MARGIN, MARGIN i * GRID, MARGIN (SIZE - 1) * GRID); } /* 五个星位小圆点右上、右下、左下、左上、天元 */ setfillcolor(RGB(90, 60, 30)); int star[5][2] {{3,3},{3,11},{11,3},{11,11},{7,7}}; for (int i 0; i 5; i) solidcircle(MARGIN star[i][1] * GRID, MARGIN star[i][0] * GRID, 4); }星位点的坐标是{行, 列}顺序画的时候要换成{列, 行}喂给solidcircle这个不小心写反星位会变成中心对称的错误分布一眼能看出来但排查时容易懵。半径 4 像素在 40 像素格子间距下视觉刚好格子改小到 30 就改成 3别死守一个值。2.3 像素坐标到格点坐标的映射鼠标点击拿到的是像素坐标(x, y)棋盘上要的是行列下标(r, c)中间这层换算写错了就会出现点了半天不知道落哪去了的现象。标准公式是四舍五入到最近的格点/* 像素坐标 - 格点下标越界返回 -1 */ int PixelToCell(int px, int py, int *row, int *col) { int c (px - MARGIN GRID / 2) / GRID; int r (py - MARGIN GRID / 2) / GRID; /* 双重校验既要在棋盘范围内又要离交点足够近 */ if (r 0 || r SIZE || c 0 || c SIZE) return -1; int dx px - (MARGIN c * GRID); int dy py - (MARGIN r * GRID); if (dx * dx dy * dy (GRID / 2) * (GRID / 2)) return -1; *row r; *col c; return 0; } GRID / 2就是整数除法下的四舍五入技巧像素落在两个交点中间靠右加半个格子后自然归到右边那一格。第二段距离校验是给宽容度兜底的——没有它玩家点到格子中央偏下的空白处也会落子手感很怪加上之后离最近交点超过半个格子距离的点击会被丢弃落子精度明显提升。有人说这算过度设计反正棋盘就那么大。但血泪经验是一旦你后面加了鼠标悬停高亮或者预测落点的半透明预览这层宽容度判断会直接决定手感好坏早写早省事。3. 核心链路拆解一次落子从鼠标点击到胜负判定要走完哪几步第二章把静态的东西铺完了这一章动起来。一次完整的落子在代码里其实是一条四段的流水线消息循环捕获鼠标左键 → 坐标换算得到行列 → 规则层校验并写入棋盘 → 绘制层画子并判定胜负。这条链路里任何一段顺序错了都会出问题比如先画子后校验就会出现点了已有棋子的位置画面闪了一下又弹回去的闪烁先判胜负后存历史悔棋时就会把已经结束的那一手也退掉。下面按顺序拆。3.1 消息循环与落子流水线EasyX 有两套消息接口老式的MOUSEMSGmousemsg()和新式的ExMessagepeekmessage()。这个项目建议直接用新的因为它能一次处理鼠标、键盘、窗口消息且支持EX_MOUSE | EX_KEY这样的标志过滤写起来干净。主循环的正确姿势是非阻塞轮询而不是getmessage那种阻塞式等待#include graphics.h int main(void) { initgraph(680, 720); BeginBatchDraw(); /* 开启双缓冲防闪烁 */ GameState gs; InitGame(gs); int over 0; ExMessage msg; while (!over) { while (peekmessage(msg, EX_MOUSE | EX_KEY)) { if (msg.message WM_LBUTTONDOWN) { int r, c; if (PixelToCell(msg.x, msg.y, r, c) 0) { if (PlaceStone(gs, r, c)) { /* 规则层校验落子 */ DrawStone(r, c, gs.cur 1 ? 2 : 1); if (CheckWin(gs, r, c)) { ShowWinner(gs.cur 1 ? 2 : 1); over 1; } } } } else if (msg.message WM_KEYDOWN msg.vkcode Z) { if (UndoOne(gs)) RedrawAll(gs); /* 悔棋整盘重画 */ } else if (msg.message WM_KEYDOWN msg.vkcode VK_ESCAPE) { over 1; } } FlushBatchDraw(); /* 把这一帧真正刷到屏幕上 */ } EndBatchDraw(); closegraph(); return 0; }这里有几个关键点值得展开讲。第一peekmessage返回非零表示拿到了一条消息所以要用while循环把队列里积压的消息全部消费掉只写if的话一次按键可能漏掉中间几条快速连点时会丢落子。第二BeginBatchDraw/FlushBatchDraw/EndBatchDraw这三件套是防闪烁的核心原理是把绘制先画到内存位图FlushBatchDraw时整体贴到屏幕一帧只贴一次人眼看着就是稳的。第三msg.x和msg.y是窗口客户区坐标原点在左上角跟鼠标的屏幕绝对坐标不是一回事EasyX 已经帮你换好了不要再去调GetCursorPos。那个gs.cur 1 ? 2 : 1的写法是因为PlaceStone成功后会切换当前执子方画子时要用落子前的那一方所以这里反着取一次。嫌绕的话可以在PlaceStone里返回实际落子的颜色代码更直白。3.2 棋盘状态表示与落子校验棋盘状态直接用一个int cell[15][15]表示0 空、1 黑、2 白。为什么不用char省内存15×15 一共 225 个格子int也就 900 字节省这点内存没有任何意义而int在调试器里看得清楚打印出来也整齐。落子函数的职责边界要卡死它只负责判断这一步合不合法 写入状态 记录历史 切换执子方绝不负责画图。画图交给主循环或者渲染层int PlaceStone(GameState *g, int r, int c) { /* 三重校验越界、占位、游戏已结束 */ if (r 0 || r SIZE || c 0 || c SIZE) return 0; if (g-cell[r][c] ! 0) return 0; g-cell[r][c] g-cur; /* 压入历史栈供悔棋使用 */ g-moves[g-top][0] r; g-moves[g-top][1] c; g-moves[g-top][2] g-cur; g-top; g-cur (g-cur 1) ? 2 : 1; /* 换手 */ return 1; }返回值的语义要统一1 表示这一步真的落下了0 表示没落。主循环只在返回 1 时才去画子、判胜负、播音效这样就不会出现点了已有棋子却触发音效和胜负判定的错乱。参数g用指针传避免每次调用都拷贝 900 字节的结构体虽然这点开销在桌面程序里可以忽略但养成习惯没坏处。3.3 四方向扫描的胜负判定胜负判定是五子棋的灵魂也是最容易写出 bug 的地方。常见错误有两种一是只扫四个方向里的两个导致斜向五连判不出来二是从棋子出发单向累加某个方向上连成五个但被扫描方向漏掉。正确做法是以落子点为中心四个轴向各向两侧延伸计数/* 从 (r,c) 沿 (dr,dc) 方向数同色连子不含起点自身 */ static int CountDir(const GameState *g, int r, int c, int dr, int dc, int who) { int n 0, rr r dr, cc c dc; while (rr 0 rr SIZE cc 0 cc SIZE g-cell[rr][cc] who) { n; rr dr; cc dc; } return n; } int CheckWin(const GameState *g, int r, int c) { /* 横、竖、主对角、副对角四个轴一次到位 */ static const int D[4][2] {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; int who g-cell[r][c]; if (who 0) return 0; for (int i 0; i 4; i) { int cnt 1 CountDir(g, r, c, D[i][0], D[i][1], who) CountDir(g, r, c, -D[i][0], -D[i][1], who); if (cnt 5) return 1; } return 0; }参数说明一下D[4][2]里第 0 行{0,1}是横向行不变、列加一第 1 行{1,0}是竖向第 2 行{1,1}是右下斜第 3 行{1,-1}是右上斜。每个方向正反各数一遍加上落子本身凑成总数。用 5而不是 5是因为长连六子七子在实际对局中同样算赢写死等于 5 会漏判。CountDir里的边界检查必须放在循环条件里且先判边界再判颜色顺序反了就是数组越界——g-cell[-1][3]这种访问在 Debug 下可能碰巧不崩Release 下就是随机崩溃是最折磨人的一类 bug。3.4 悔棋栈与整盘重绘悔棋的实现有两派一派是删掉最后一个子另一派是记录全部历史回退后整盘重画。这个项目用第二种因为操作简单且不会出错——按一次 Z 退一步历史栈指针减一、棋盘对应格清零、换手然后整盘重绘一遍int UndoOne(GameState *g) { if (g-top 0) return 0; /* 没有可悔的棋 */ g-top--; int r g-moves[g-top][0]; int c g-moves[g-top][1]; g-cell[r][c] 0; /* 清空该格 */ g-cur g-moves[g-top][2]; /* 执子方回退到落这一手的人 */ return 1; }注意换手的写法不是简单地切换1↔2而是直接读历史里记录的那一手是谁这样即使中途有重开跳步之类的非常规操作也不会把执子方搞乱。top指针同时充当历史长度和下一个写入位置省掉一个变量。整盘重绘函数RedrawAll就是cleardevice()之后依次调DrawStarfield、DrawBoard、再遍历cell数组把所有棋子重新画一遍。225 个格子遍历一遍在现代 CPU 上不到一毫秒配合双缓冲完全感觉不到卡顿。硬要抠性能的话可以只重画被清掉的那一格用棋盘底色矩形盖住再补画格线——但说实话这种微型项目上做局部重绘纯属自找麻烦画错一个像素的格线都能让你盯着屏幕找半小时。4. 避坑与排查五子棋在 EasyX 上最容易翻车的五个点这一章单独拎出来是因为这几个问题几乎每个用 EasyX 写小游戏的人都撞过撞完之后还未必知道根因。下面五条按现象 → 原因 → 解决组织都是我实打实踩出来的。4.1 窗口闪一下就退出现象编译通过运行后黑窗口一闪而过或者图形窗口刚出现就消失屏幕上什么都没留下。原因main函数执行到return就结束了没有阻塞点。图形窗口的生命周期必须由你的代码维持不是系统自动挂着的。还有一种可能是initgraph之后没有任何绘制调用窗口确实是空白的看起来像没启动。解决在主循环外面套一个while (!over)的循环靠 ESC 或者关闭按钮来打破临时调试时用_getch()卡住。另外检查项目属性里的子系统是不是设成了WINDOWS那种配置下main不会被调用得改成WinMain或者干脆切回CONSOLE子系统——这一点在从网上抄工程配置时特别容易中招。4.2 落子总差半格鼠标点哪都不对现象棋子不画在交点上而是偏到格子中间越往右下偏得越厉害。原因坐标映射和绘制用的基准不一致。比如画棋盘时线从MARGIN开始但换算时忘了减MARGIN或者直接用x / GRID取整没做四舍五入导致点击位置稍偏就落到上一格。解决把MARGIN、GRID定义成宏绘制和换算共用同一份绝不各写各的常量。换算时老老实实写(px - MARGIN GRID / 2) / GRID。排查手段很简单在点击处画一个红色小圆点再在你算出来的格点位置画一个绿色小圆点两个点重合说明映射没问题不重合直接看偏移方向就知道是漏减还是取整错。4.3 中文提示乱码或干脆不显示现象outtextxy(100, 100, 黑棋获胜)出来一堆问号或者方块。原因工程字符集是 Unicodechar*字面量传进去类型不匹配或者用了settextstyle但字体名写了个系统里没有的例如随手写楷体某些精简版系统上就是找不到。解决项目属性 → 高级 → 字符集改成使用多字节字符集这是最省事的路子。然后统一用settextstyle(24, 0, 微软雅黑)这类系统必带字体字号写实际像素值第二个参数 0 表示自动适配宽度。如果还是乱码检查源文件保存编码是不是 UTF-8 无 BOM某些 VS 版本对无 BOM 的中文注释和字符串会误判。4.4 五连判定的边界越界崩溃现象对局前期一切正常一接近棋盘边缘落子就程序崩溃Debug 下弹写入位置 0xXXXX 时发生访问冲突。原因CountDir里没有边界检查或者检查写在了数组访问之后。g-cell[rr][cc]里rr或cc跑到 -1 或者 15就越界了。五子棋的棋盘小、落子密集这个 bug 往往在测试的第五分钟才冒出来前面测得好好的很容易误以为程序没问题。解决把边界判断写在while条件的第一位用短路求值挡住后面的数组访问。写完以后专门测四个角和四条边(0,0)、(0,14)、(14,0)、(14,14)各连成五子能全部判赢才算过关。再狠一点写个测试函数把 225 个格子分别假设落子跑一遍CheckWin循环不崩就说明边界守住了。4.5 每画一步整屏狂闪现象每落一子整个棋盘抖一下背景图有明显撕裂感。原因没有开双缓冲所有绘制直接怼到屏幕 DC 上cleardevice清屏那一瞬间屏幕是空的人眼能看到这个空窗期。解决BeginBatchDraw()放在初始化之后、主循环之前FlushBatchDraw()放在每帧绘制的最后EndBatchDraw()在退出前调用。注意FlushBatchDraw的位置要在所有DrawXXX之后放早了等于没效果。另外别在循环里反复cleardevice加loadimage背景图那张图应该只在初始化时加载一次存到IMAGE变量里反复从磁盘读图是另一个性能杀手。5. 二次开发从双人对战到人机对战与星空主题的改造路线把项目跑起来只是起点真正有意思的是往上加东西。得益于第二章的分层改造点非常集中基本不用碰主循环。第一件事是把双人换成带 AI 的人机。规则层加一个新接口int BestMove(const GameState *g, int who, int *r, int *c)落子流程里判完合法性后调用它再把结果交给PlaceStone。AI 不需要一上来就上极小化极大先用评分表 贪心就能打得像模像样对 225 个空位分别假设自己落一子按四个方向统计连子形态给每种形态打分五连 100000、活四 10000、冲四 1000、活三 1000、活二 100最后取最高分的点。这套评分表写在一个static const int Score[6]数组里改数值就能调强度加两轮对手落这我也得多少分的双向评估防守意识立刻就出来了。/* 简化版估值单点单方向实际要和四个方向加起来 */ static int EvalPoint(const GameState *g, int r, int c, int who) { if (g-cell[r][c] ! 0) return -1; /* 已占位直接淘汰 */ int total 0; static const int D[4][2] {{0,1},{1,0},{1,1},{1,-1}}; for (int i 0; i 4; i) { int cnt 1 CountDir(g, r, c, D[i][0], D[i][1], who) CountDir(g, r, c, -D[i][0], -D[i][1], who); if (cnt 6) cnt 5; /* 长连按五连封顶计分 */ total Score[cnt]; } return total; }这段代码的关键在Score表和封顶处理。数组定义成六元素{0, 10, 100, 1000, 10000, 100000}下标就是连子数cnt超过 5 会被压回 5避免数组越界——这是上一章边界坑的延伸评分函数里同样要防。真正落地时EvalPoint要算两遍一遍按 AI 自己算进攻分一遍按对手算防守分然后attack * 1.0 defend * 0.8之类的权重叠加那个 0.8 就是进攻优先还是防守优先的调节旋钮改它比改评分表直观。第二件事是星空主题的强化。现在的做法大概是在背景上撒随机白点再加几颗带呼吸效果的亮星。撒点用for循环画几百个putpixel或小solidcircle颜色从RGB(200,200,255)到RGB(60,60,120)随机取能做出远近层次感。呼吸效果靠一个全局计数器每帧让亮度在sin曲线上滑一下注意亮度必须转成整数再传进RGB浮点数直接塞进去会被截断可能出现所有星星一样亮的假象。加个缓慢移动的星云背景图用loadimage加载一次putimage时靠透明度参数ALPHA混合整体质感立刻不一样。第三件事是工程层的收尾。把棋盘尺寸从宏改成变量让InitGame支持 15 路和 19 路切换窗口尺寸跟着重算把棋谱写进文件实现断电续玩给落子加个PlaySound的音效素材放res/目录用相对路径引用。这里说个我自己的教训早期我改这些功能的时候图快直接在main.c里加判断改到第四版发现主循环已经三百多行鼠标、键盘、AI、存盘逻辑绞在一起加个功能要读半天才敢下手。从那以后我每次动主循环之前都强制先把新逻辑抽成一个独立函数、头文件里声明好接口再回到main里只加一行调用——哪怕这个函数只被调用一次也照抽不误。多花五分钟后面能省两小时。希望帮到你。本文还有配套的精品资源点击获取