
1. 为什么用 EasyX 做五子棋是个被低估的练手项目很多人一听五子棋小游戏第一反应是太简单了没什么技术含量。我一开始也这么想直到自己真正动手用 EasyX 从零写了一遍才发现这个项目里藏着的东西远比想象中多。EasyX 是一个面向 C/C 的图形库它的定位很明确让初学者能在 Windows 环境下用最少的代码画出图形、响应鼠标和键盘。用它来做五子棋你不需要学 Qt、不需要配 OpenGL、不需要理解窗口消息循环的底层细节几百行代码就能跑出一个能玩的东西。但能跑和写得好之间差距很大。我见过太多人写五子棋棋盘画出来了棋子也能下但代码全堆在 main 函数里判断胜负用了一堆 if-else 硬编码悔棋功能不知道怎么实现AI 对手更是无从下手。这个项目的真正价值不在于做出一个五子棋而在于它是一个麻雀虽小五脏俱全的工程练习——图形渲染、鼠标交互、数据结构设计、算法逻辑、状态管理这些在大型项目里才会同时出现的东西五子棋一个不落全给你了。这篇文章适合谁看如果你已经学过 C 或 C 的基本语法知道数组、循环、函数是怎么回事但还没做过一个看得见摸得着的完整项目那五子棋配 EasyX 就是最好的起点。如果你已经写过一些控制台程序想跨入图形界面的门槛这个项目也能帮你建立对事件驱动编程的直觉。我会从环境搭建一路讲到 AI 实现把每个设计决策背后的理由都说清楚让你不只是抄代码而是真正理解为什么这么写。2. 环境搭建与 EasyX 的安装细节2.1 开发环境的选择与版本匹配EasyX 官方支持 Visual Studio 系列编译器这是最省心的组合。我实测下来VS2019 和 VS2022 都能正常使用但要注意 EasyX 的版本要和 VS 版本对应。EasyX 官网提供了针对不同 VS 版本的安装包下载时看清楚。如果你用的是 VS2022就下支持 VS2022 的那个版本装错了会出现链接错误或者头文件找不到的问题。安装过程本身很简单下载下来是一个可执行文件它会自动检测你机器上安装的 VS 版本然后让你选择要装到哪个版本里。点一下确认头文件和库文件就自动复制到对应目录了。装完之后新建一个空项目在源文件里写#include graphics.h和#include conio.h如果能编译通过说明环境没问题。注意EasyX 只支持 Windows 平台如果你用的是 Mac 或者 Linux需要换方案。另外EasyX 的图形窗口是基于 Win32 API 封装的所以你的项目必须是控制台程序或者 Windows 桌面程序不能是空项目里没有入口函数的那种。2.2 第一个测试程序确认图形窗口能正常弹出在正式写五子棋之前先跑一个最小测试程序确认图形环境没问题#include graphics.h #include conio.h int main() { initgraph(640, 480); circle(320, 240, 100); _getch(); closegraph(); return 0; }这段代码做了三件事创建一个 640x480 的窗口在中心画一个半径 100 的圆等待按键后关闭。如果你能看到窗口弹出并且有一个圆说明 EasyX 工作正常。这里有个细节值得说initgraph创建窗口后坐标系原点在左上角x 轴向右y 轴向下这和数学课本上的坐标系是反的。画五子棋棋盘的时候要时刻记住这一点否则算坐标会绕晕。_getch()是 conio.h 里的函数作用是等待用户按任意键。如果不加这一句程序画完圆就立刻退出窗口一闪而过你什么都看不到。这个坑我踩过当时还以为 EasyX 装坏了折腾了半天才发现是程序执行太快。2.3 项目文件结构规划虽然五子棋代码量不大但我建议从一开始就养成分文件的好习惯。我的做法是main.cpp程序入口负责初始化和主循环board.h/board.cpp棋盘数据结构、落子逻辑、胜负判断render.h/render.cpp所有绘制相关的函数ai.h/ai.cppAI 决策逻辑这样分的好处是当你调试 AI 的时候不需要在一堆绘图代码里找逻辑当你调整界面的时候也不会不小心改坏核心算法。对于初学者来说分文件还能帮你理解接口和实现分离的概念——头文件里声明函数源文件里写具体实现其他文件只需要 include 头文件就能调用。3. 棋盘的数据结构设计与坐标换算3.1 用二维数组表示棋盘状态五子棋棋盘是 15x15 的网格最自然的表示方式就是一个二维数组const int BOARD_SIZE 15; int board[BOARD_SIZE][BOARD_SIZE]; // 0空, 1黑子, 2白子用 0、1、2 三个值分别表示空位、黑子和白子简单直观。每次落子就是把对应位置的值改成 1 或 2判断胜负就是检查某个值是否在横、竖、斜四个方向上连续出现五次。这里有个设计决策值得讨论为什么不用枚举类型用enum { EMPTY, BLACK, WHITE }当然更规范但在五子棋这个规模的项目里用 int 反而更方便——数组初始化直接memset(board, 0, sizeof(board))就搞定了用枚举还得写循环。当然如果你追求代码规范用枚举也没问题只是初始化的时候要注意。3.2 像素坐标与逻辑坐标的换算这是五子棋项目里最容易出错的地方。棋盘逻辑上是 15x15 个格子但屏幕上要画成有间距的网格线。假设每个格子边长 40 像素棋盘边距 30 像素那么第 (row, col) 个交叉点的屏幕坐标是x 30 col * 40y 30 row * 40反过来鼠标点击的像素坐标 (mx, my) 要换算成逻辑坐标col (mx - 30 20) / 40row (my - 30 20) / 40这里加 20 是半个格子的宽度目的是让点击落在交叉点附近就能被识别而不是要求精确点到交叉点上。这个容差处理非常关键否则玩家会觉得我明明点中了怎么没反应。我建议把这两个换算写成独立的函数void logicToPixel(int row, int col, int x, int y) { x MARGIN col * CELL_SIZE; y MARGIN row * CELL_SIZE; } bool pixelToLogic(int mx, int my, int row, int col) { col (mx - MARGIN CELL_SIZE / 2) / CELL_SIZE; row (my - MARGIN CELL_SIZE / 2) / CELL_SIZE; if (row 0 || row BOARD_SIZE || col 0 || col BOARD_SIZE) return false; return true; }pixelToLogic返回 bool 值表示点击是否在棋盘范围内这样调用方就不用自己判断边界了。3.3 棋盘绘制的细节处理画棋盘看起来简单但有几个细节直接影响观感。第一网格线要用黑色细线但不要用纯黑用 RGB(0, 0, 0) 就行线宽默认 1 像素。第二棋盘背景建议用浅木色 RGB(238, 203, 140) 之类的颜色比纯白更接近真实棋盘。第三五个星位天元加四个角星要画出来位置分别是 (3,3)、(3,11)、(7,7)、(11,3)、(11,11)画成实心小圆点。void drawBoard() { setbkcolor(RGB(238, 203, 140)); cleardevice(); setlinecolor(BLACK); for (int i 0; i BOARD_SIZE; i) { int x1 MARGIN, y1 MARGIN i * CELL_SIZE; int x2 MARGIN (BOARD_SIZE - 1) * CELL_SIZE, y2 y1; line(x1, y1, x2, y2); line(y1, x1, y2, x2); } // 画星位 int stars[5][2] {{3,3},{3,11},{7,7},{11,3},{11,11}}; setfillcolor(BLACK); for (int i 0; i 5; i) { int x, y; logicToPixel(stars[i][0], stars[i][1], x, y); solidcircle(x, y, 4); } }注意line函数的参数顺序是 (x1, y1, x2, y2)画横线时 y 不变画竖线时 x 不变。我上面用了一个小技巧横线和竖线的坐标计算共用变量写起来更简洁。4. 鼠标交互与落子逻辑的完整实现4.1 鼠标消息的获取与处理EasyX 提供了MOUSEMSG结构体来获取鼠标消息用法如下MOUSEMSG msg; while (true) { if (MouseHit()) { msg GetMouseMsg(); if (msg.uMsg WM_LBUTTONDOWN) { // 处理左键点击 int row, col; if (pixelToLogic(msg.x, msg.y, row, col)) { // 落子 } } } // 其他逻辑 }MouseHit()判断是否有鼠标消息GetMouseMsg()取出消息。uMsg字段表示消息类型WM_LBUTTONDOWN是左键按下。这里要注意GetMouseMsg()是阻塞的如果没有消息它会一直等所以必须先用MouseHit()判断。4.2 落子合法性检查与状态更新落子之前要检查三件事位置是否在棋盘内pixelToLogic已经做了、该位置是否为空、当前是否轮到该玩家。检查通过后更新数组、绘制棋子、切换玩家。bool placeStone(int row, int col, int player) { if (board[row][col] ! 0) return false; board[row][col] player; int x, y; logicToPixel(row, col, x, y); setfillcolor(player 1 ? BLACK : WHITE); solidcircle(x, y, CELL_SIZE / 2 - 2); return true; }棋子的半径设为CELL_SIZE / 2 - 2留 2 像素的间隙这样棋子之间不会挤在一起视觉上更舒服。黑子用黑色填充白子用白色填充但白子最好加一个黑色边框否则在浅色背景上看不清边界。4.3 悔棋功能的实现思路悔棋是五子棋的标配功能实现起来比想象中简单。核心思路是用一个栈记录每一步的落子信息struct Move { int row, col, player; }; std::vectorMove history;每次落子时history.push_back({row, col, player})悔棋时取出最后一个把board[row][col]置回 0然后重绘整个棋盘。重绘的时候不需要擦除单个棋子直接cleardevice()然后重画棋盘和所有棋子就行。对于 15x15 的棋盘全量重绘的性能开销完全可以忽略。提示如果要做人机对战的悔棋通常需要一次撤销两步玩家一步、AI 一步否则悔棋后 AI 立刻又下在同一步等于没悔。这个细节很多教程不会提但实际做的时候一定会遇到。5. 胜负判断的算法优化与边界处理5.1 从落子点出发的四方向扫描判断胜负最高效的方式不是遍历整个棋盘而是只检查刚落下那颗棋子的四个方向水平、垂直、主对角线、副对角线。每个方向上从落子点向两边延伸统计连续同色棋子的数量如果达到或超过 5 个就判定胜利。bool checkWin(int row, int col, int player) { int dirs[4][2] {{0,1},{1,0},{1,1},{1,-1}}; for (int d 0; d 4; d) { int count 1; for (int sign -1; sign 1; sign 2) { int r row dirs[d][0] * sign; int c col dirs[d][1] * sign; while (r 0 r BOARD_SIZE c 0 c BOARD_SIZE board[r][c] player) { count; r dirs[d][0] * sign; c dirs[d][1] * sign; } } if (count 5) return true; } return false; }这段代码的精妙之处在于sign循环sign -1时向一个方向扫描sign 1时向反方向扫描两个方向的计数加上落子本身就是这条线上的连续棋子总数。四个方向都扫一遍只要有一个方向达到 5 就返回 true。5.2 边界条件的处理边界处理是胜负判断里最容易出 bug 的地方。上面的while循环里r 0 r BOARD_SIZE c 0 c BOARD_SIZE这个条件必须放在最前面否则数组越界访问会导致程序崩溃或者读到垃圾数据。我建议把边界检查单独写成一个函数bool inBoard(int r, int c) { return r 0 r BOARD_SIZE c 0 c BOARD_SIZE; }这样代码更清晰也不容易漏掉某个方向的边界检查。5.3 平局判断当棋盘上所有位置都被占满且没有一方获胜时就是平局。判断方法很简单落子后检查棋盘上是否还有空位。但更高效的做法是维护一个计数器记录已经落子的步数当步数达到BOARD_SIZE * BOARD_SIZE时就是平局。int moveCount 0; // 每次落子后 moveCount if (moveCount BOARD_SIZE * BOARD_SIZE) { // 平局 }6. 让程序会思考五子棋 AI 的实现路径6.1 从随机落子到启发式评估最简单的 AI 就是随机选一个空位落子但这毫无体验可言。稍微好一点的做法是给每个空位打分选分数最高的位置。打分规则基于这个位置对进攻和防守的价值如果某位置落子后能形成五连分数极高直接获胜如果能形成活四两端开放的四连分数很高如果能形成冲四一端被封的四连分数较高活三、眠三、活二依次递减同时要考虑对手的威胁对手能形成五连的位置必须堵这种评估方式不需要搜索只需要遍历所有空位对每个位置计算我方落子的价值 对手落子的价值取最大值。实现起来大概一百行代码效果已经能打败大部分新手。6.2 评分函数的设计细节评分函数的核心是模式匹配。对于每个空位假设我方落子然后检查四个方向上形成的连续棋子数和两端是否被封堵。我用一个简化的评分表来说明棋型说明进攻分防守分五连已经连成五个100000100000活四两端开放的四连1000010000冲四一端被封的四连10001000活三两端开放的三连1000800眠三一端被封的三连100100活二两端开放的二连10080眠二一端被封的二连1010进攻分和防守分略有不同是因为进攻的优先级通常略高于防守。但对手的活四和冲四必须优先防守所以防守分也不能太低。实际实现时我会对每个空位计算我方落子后的得分和对手落子后的得分然后取max(我方得分, 对手得分 * 0.9)作为最终评分这样既积极进攻又不忽视防守。6.3 搜索深度的取舍纯启发式评估的 AI 有一个致命弱点它只看一步容易被对手的双三或四三组合击败。要提升棋力就需要引入搜索。最常用的是极小化极大搜索Minimax配合 Alpha-Beta 剪枝。但对于五子棋来说搜索深度不是越深越好。15x15 的棋盘每一步有超过 200 个可选位置搜索深度 4 就意味着 200^4 16 亿个节点即使剪枝也扛不住。我的做法是先用启发式评估筛选出前 10 个候选位置再对这 10 个位置做深度 4 的搜索。这样搜索空间降到 10^4 10000 个节点现代 CPU 毫秒级就能算完。// 伪代码筛选候选位置 std::vectorMove getCandidates() { std::vectorstd::pairint, Move scored; for (每个空位) { int score evaluatePosition(pos); scored.push_back({score, pos}); } sort(scored.rbegin(), scored.rend()); // 取前 10 个 return top10; }注意候选位置的筛选半径也很重要。如果只考虑已有棋子周围 2 格范围内的空位候选数量会大幅减少而且这些位置才是真正有战略价值的。远离棋子的空位几乎没有意义可以忽略。7. 界面美化与交互体验的打磨7.1 棋子绘制与动画效果基础的棋子就是实心圆但加一点细节就能让观感提升一个档次。我的做法是黑子用径向渐变效果EasyX 不直接支持渐变但可以用多个同心圆模拟白子加灰色边框。落子时加一个简单的缩放动画——棋子从半径 0 扩大到目标半径持续 100 毫秒左右。void drawStoneAnimated(int x, int y, int player) { int targetR CELL_SIZE / 2 - 2; for (int r 2; r targetR; r 2) { setfillcolor(player 1 ? BLACK : WHITE); solidcircle(x, y, r); Sleep(5); } }这个动画虽然简单但能让落子有啪一下的感觉体验好很多。不过要注意动画期间要屏蔽鼠标输入否则玩家快速点击会导致状态混乱。7.2 状态提示与胜负展示游戏需要明确告诉玩家当前轮到谁、谁赢了。我的做法是在棋盘右侧留出一块区域显示当前回合和提示信息。胜利时用一条高亮线标出获胜的五颗棋子并在屏幕中央弹出提示。void showWinner(int player, int r1, int c1, int r2, int c2) { setlinecolor(RED); setlinestyle(PS_SOLID, 3); int x1, y1, x2, y2; logicToPixel(r1, c1, x1, y1); logicToPixel(r2, c2, x2, y2); line(x1, y1, x2, y2); // 显示文字 settextcolor(RED); settextstyle(40, 0, _T(微软雅黑)); outtextxy(200, 300, player 1 ? _T(黑方获胜) : _T(白方获胜)); }setlinestyle可以设置线宽settextstyle设置字体大小和名称。注意 EasyX 的文本函数需要宽字符字符串前要加_T()宏。7.3 重新开始与功能按钮一个完整的游戏应该有重新开始按钮。实现方式是在窗口底部画一个矩形按钮鼠标点击时判断坐标是否在按钮范围内。点击后重置棋盘数组、清空历史记录、重绘界面。bool isInButton(int mx, int my, int bx, int by, int bw, int bh) { return mx bx mx bx bw my by my by bh; }按钮的视觉反馈也很重要——鼠标悬停时改变颜色点击时有一个短暂的下压效果。这些细节虽然小但能让程序看起来更专业。8. 调试五子棋时踩过的坑与排查思路8.1 棋子画偏了坐标换算的典型错误我第一次写五子棋时棋子总是画在网格线的交叉处偏右下的位置。排查了半天才发现logicToPixel里我写的是x MARGIN col * CELL_SIZE但画棋盘时横线的起点是MARGIN终点是MARGIN (BOARD_SIZE - 1) * CELL_SIZE。这意味着第 0 列对应 x MARGIN第 14 列对应 x MARGIN 14 * CELL_SIZE逻辑是对的。问题出在pixelToLogic里我忘了加半个格子的偏移导致点击换算出来的行列号偏了。这个坑的教训是坐标换算函数一定要成对测试。写一个测试程序在棋盘上随机点几个位置打印出换算后的行列号再手动验证是否正确。不要等到整个游戏写完才发现坐标不对那时候排查起来更麻烦。8.2 胜负判断漏判方向数组写错了有一次测试时发现斜向的五连有时候判不出来。检查后发现是方向数组写错了我把{1, -1}写成了{1, 1}导致副对角线方向根本没被检查到。方向数组的四个方向必须覆盖水平、垂直、主对角线、副对角线int dirs[4][2] {{0,1}, {1,0}, {1,1}, {1,-1}};建议在代码里加注释标明每个方向避免以后自己看糊涂。8.3 AI 反应慢搜索没有剪枝最初版本的 AI 用了深度 6 的 Minimax 搜索没有 Alpha-Beta 剪枝结果每走一步要等五六秒。后来加上了剪枝和候选位置筛选思考时间降到 100 毫秒以内。这里的关键是搜索深度不是越深越好候选位置的质量比数量更重要。与其在 200 个位置里搜 4 层不如在 10 个精选位置里搜 6 层。8.4 鼠标点击无响应消息循环被阻塞有一次我加了一个Sleep(1000)来做落子动画结果动画期间鼠标完全没反应。原因是 EasyX 的鼠标消息是在消息循环里处理的如果主线程被Sleep阻塞消息就得不到处理。解决办法是把动画拆成多帧每帧Sleep一小段时间在帧之间处理鼠标消息。或者干脆用非阻塞的方式做动画在每一帧的循环里检查时间是否到了。9. 从五子棋延伸出去还能练什么五子棋写完之后这个框架其实可以复用到很多类似的棋类游戏上。把棋盘从 15x15 改成 8x8棋子换成不同形状胜负判断改成检查斜向就变成了黑白棋或者四子棋。把落子规则改成三连成线就变成了井字棋的加强版。这些变体的核心逻辑——二维数组、坐标换算、回合制、胜负判断——都是通用的。如果你想继续深入可以尝试这几个方向一是把 AI 换成基于蒙特卡洛树搜索的版本体验一下不同算法的棋力差异二是加入网络对战功能学习 Socket 编程三是把界面从 EasyX 迁移到 Qt 或 SFML理解不同图形库的设计哲学。每一个方向都能让你对游戏开发这件事有更深的理解。我个人在实际操作中的体会是五子棋这个项目最大的价值不在于最终做出来的东西有多复杂而在于它逼着你去思考数据怎么组织状态怎么管理交互怎么设计这些在教科书里不会细讲、但在实际开发中天天遇到的问题。把这些想清楚了再去做更大的项目心里就有底了。