1. 为什么选择 EasyX 来做五子棋
1.1 从“能跑起来”到“看得见”的图形化跃迁
很多刚接触 C/C++ 的朋友都有类似的经历:跟着教程写了一个控制台版五子棋,黑底白字,用坐标输入落子,棋盘靠printf一行行拼出来。逻辑跑通了,但总觉得差点意思——没有棋盘线、没有棋子、没有鼠标点击,玩起来像在跟一台老式终端对话。EasyX 就是在这个节点上进入视野的。它本质上是一套面向 C/C++ 的图形库,封装了 Windows 平台下的绘图接口,把line、circle、fillcircle、setfillcolor这些函数直接暴露给你,不需要你理解窗口消息循环、GDI 对象、设备上下文这些偏底层的东西,就能在屏幕上画出像模像样的图形界面。
我最初选它做五子棋,理由很直接:学习曲线平缓,反馈即时。你写一行circle(x, y, r),屏幕上立刻出现一个圆;你写fillcircle,它就被填满。这种“所见即所得”的体验,对建立图形编程的直觉特别有帮助。相比之下,如果一上来就用 Qt 或者 DirectX,光是环境配置和框架理解就能劝退一大半人。EasyX 的定位很清晰——它不追求跨平台,也不追求工业级渲染性能,它就是把 Windows 图形接口做了一层轻量封装,让你能把精力放在游戏逻辑本身。
1.2 五子棋作为图形编程练手项目的天然优势
五子棋这个题材,放在图形编程入门的位置上,几乎是量身定做的。棋盘是规整的网格,棋子是简单的圆形,交互只有鼠标点击,胜负判定是纯粹的数组遍历。它没有复杂的物理引擎,没有资源加载,没有动画状态机,但又能完整覆盖图形编程的几个核心环节:坐标映射、事件响应、状态绘制、界面刷新。
具体来说,你需要把鼠标点击的屏幕坐标转换成棋盘数组的下标,这个“像素到逻辑”的映射是图形交互的基础;你需要响应鼠标消息,判断点击是否落在有效交叉点上;你需要在每次落子后重绘棋盘和棋子,理解“双缓冲”为什么能消除闪烁;你还需要在游戏结束时弹出提示,处理窗口关闭和重新开始。这些环节串起来,就是一个完整的图形应用程序骨架。把五子棋做透了,再去做推箱子、贪吃蛇、扫雷,你会发现套路是相通的。
1.3 适合哪些人参考这个项目
这个项目最适合三类人:第一类是刚学完 C 语言基础语法,想找个东西练手,但不想碰复杂框架的初学者;第二类是学过一点 C++,但对图形界面有畏难情绪,想通过一个看得见摸得着的项目建立信心的人;第三类是需要交课程设计或实训作业,希望有一个结构清晰、代码量适中、能讲清楚原理的参考项目的人。
如果你属于这三类中的任何一类,下面的内容会从设计思路到代码实现,再到踩坑经验,完整地拆一遍。我不会只贴代码,而是把每个关键决策背后的“为什么”讲清楚,这样你改起来、扩展起来才有方向。
2. 整体设计思路与核心模块拆解
2.1 棋盘数据结构:为什么用二维数组而不是链表
五子棋的棋盘是一个 15×15 的网格,总共 225 个交叉点。每个点有三种状态:空、黑子、白子。最自然的存储方式就是二维数组int board[15][15],用 0 表示空,1 表示黑,2 表示白。有人可能会想,用链表或者哈希表是不是更“高级”?我的经验是,在这个场景下,数组就是最优解。
原因有三:第一,棋盘大小固定,不需要动态扩容,数组的静态分配完全够用;第二,胜负判定需要频繁访问相邻位置,数组的随机访问是 O(1),链表还得遍历;第三,数组的下标天然对应棋盘的坐标,board[row][col]这种写法在调试时一目了然,换成哈希表还得自己维护键值映射。我试过用std::vector<std::vector<int>>,功能上没问题,但多了一层间接,代码读起来反而累。所以最终定下来,就用最朴素的int board[15][15],简单直接。
2.2 坐标映射:屏幕像素与棋盘下标的换算逻辑
这是图形化五子棋最核心的一步。棋盘在屏幕上画出来,每个交叉点都有对应的像素坐标。假设棋盘左上角第一个交叉点的屏幕坐标是(startX, startY),相邻两个交叉点的间距是gridSize,那么第row行第col列的交叉点坐标就是:
x = startX + col * gridSize y = startY + row * gridSize反过来,当你收到鼠标点击的像素坐标(mx, my)时,需要判断它落在哪个交叉点附近。最直接的做法是四舍五入:
col = round((mx - startX) / gridSize) row = round((my - startY) / gridSize)但这里有个细节:如果用户点在了两个交叉点正中间,四舍五入会把它归到某一边,这其实是可以接受的。更严谨的做法是设置一个“有效半径”,比如gridSize / 3,只有点击位置距离某个交叉点小于这个半径,才认为落子有效。我实测下来,用四舍五入加上边界检查,体验已经足够好,没必要过度设计。
注意:
round函数在 C 语言里需要包含<math.h>,而且返回的是浮点数,记得强制转成int。另外,计算出来的row和col必须做范围检查,防止数组越界。
2.3 双缓冲绘图:消除闪烁的关键手段
如果你直接在窗口上画棋盘和棋子,每次落子后重绘,会发现屏幕有明显的闪烁。原因是绘图操作是逐条执行的,先清屏、再画线、再画棋子,这个过程中屏幕会短暂显示“画了一半”的状态。双缓冲的思路是:先在内存里准备一张完整的图像,画好之后一次性贴到屏幕上。
EasyX 提供了BeginBatchDraw()和EndBatchDraw()这对函数,配合FlushBatchDraw()使用。你可以在BeginBatchDraw()之后正常调用绘图函数,这些操作会先写入后台缓冲区,调用FlushBatchDraw()时才真正刷新到屏幕。这样用户看到的永远是完整的一帧,闪烁就消失了。这个机制在游戏开发里叫“批量绘制”,是 EasyX 里最实用的技巧之一。
2.4 游戏状态管理:用枚举还是用整数
游戏有几种状态:进行中、黑方胜利、白方胜利、平局。我见过有人直接用int变量,0 表示进行中,1 表示黑胜,2 表示白胜。能跑,但代码可读性差,过两天自己都忘了 1 代表什么。更好的做法是用枚举:
enum GameState { PLAYING, BLACK_WIN, WHITE_WIN, DRAW };枚举的好处是自解释,if (state == BLACK_WIN)比if (state == 1)清晰得多。而且编译器会帮你检查类型,减少低级错误。这个习惯我强烈建议从第一个项目就养成,后面代码量大了会感谢自己。
3. 核心细节解析与实操要点
3.1 棋盘绘制的参数计算与视觉优化
棋盘绘制看起来简单,但有几个参数需要仔细调。首先是棋盘线的颜色,纯黑色RGB(0, 0, 0)太刺眼,用深灰色RGB(80, 80, 80)更柔和。其次是线条宽度,默认的 1 像素在 1080P 屏幕上偏细,可以设成 2 像素,用setlinestyle(PS_SOLID, 2)来设置。
棋盘的外边框通常比内部网格线粗一些,这样视觉上更有层次。我的做法是:内部网格线用 1 像素,外边框用 3 像素,颜色稍微深一点。另外,棋盘背景不要用纯白,用浅木色RGB(238, 203, 140)或者浅灰RGB(240, 240, 240),看起来更舒服。
还有一个容易被忽略的细节:棋盘线的端点处理。如果你直接用line画 15 条横线和 15 条竖线,交叉点处会有轻微的锯齿感。解决办法是让线条稍微超出交叉点一点,或者用rectangle画外框,内部线条精确对齐。我试过几种方案,最终发现最简单有效的是:所有线条都从第一个交叉点画到最后一个交叉点,不做额外延伸,配合 2 像素宽度,视觉效果就很干净。
3.2 棋子绘制的半径选择与颜色搭配
棋子的半径不能太大,否则会盖住棋盘线;也不能太小,否则点击时不好判断。经验值是gridSize * 0.4左右。假设gridSize是 40 像素,棋子半径就是 16 像素,这样棋子之间会留出 8 像素的间隙,看起来不拥挤。
颜色方面,黑子不要用纯黑RGB(0, 0, 0),用RGB(30, 30, 30)更有质感;白子不要用纯白RGB(255, 255, 255),用RGB(245, 245, 245),并且加一圈浅灰色边框RGB(180, 180, 180),这样白子在浅色背景上也能看清轮廓。这个细节很多教程不会讲,但实际做出来,加了边框的白子明显更精致。
绘制棋子用fillcircle(x, y, radius)填充,再用circle(x, y, radius)画边框。注意顺序:先填充后画边框,否则边框会被填充覆盖。
3.3 鼠标事件处理:消息循环与坐标获取
EasyX 的鼠标事件处理有两种方式:一种是使用getmessage()配合MOUSEMSG结构体,另一种是使用peekmessage()做非阻塞检测。对于五子棋这种需要持续响应鼠标点击的场景,我推荐用peekmessage()放在主循环里,这样不会阻塞其他逻辑。
MOUSEMSG msg; while (peekmessage(&msg)) { if (msg.uMsg == WM_LBUTTONDOWN) { // 处理左键点击 int mx = msg.x; int my = msg.y; // 坐标转换和落子逻辑 } }MOUSEMSG结构体里的x和y是相对于窗口客户区的坐标,不需要额外转换。注意peekmessage会一次性取出所有待处理的消息,所以要用while循环,而不是if。这个细节如果搞错了,快速点击时可能会丢事件。
3.4 胜负判定的方向遍历与边界处理
五子棋的胜负判定,本质上是检查当前落子位置在四个方向上是否形成了连续五个同色棋子。四个方向分别是:水平、垂直、主对角线(左上到右下)、副对角线(右上到左下)。每个方向都要往正反两个方向数,加起来等于或超过 5 就获胜。
以水平方向为例,假设当前落子在(row, col),颜色是color:
int count = 1; // 往左数 for (int i = col - 1; i >= 0 && board[row][i] == color; i--) count++; // 往右数 for (int i = col + 1; i < 15 && board[row][i] == color; i++) count++; if (count >= 5) return true;这里的关键是边界检查:往左数时i >= 0,往右数时i < 15,否则数组越界。另外,计数从 1 开始,因为当前落子本身就算一个。四个方向都检查一遍,任意一个方向满足条件就返回胜利。
提示:不要忘了在落子之后立即判定,而不是等到下一回合。否则玩家可能会看到对方已经五连了,自己还能再落一子,体验很怪。
4. 完整实操流程与关键代码实现
4.1 环境准备与项目搭建
EasyX 的安装很简单,去官网下载对应版本的安装包,运行后它会自动检测你本地的 Visual Studio 版本,选择对应的库文件安装即可。注意 EasyX 只支持 Visual Studio 系列编译器,如果你用的是 Dev-C++ 或者 Code::Blocks,需要额外配置,比较麻烦。我建议直接用 Visual Studio Community 版,免费且功能完整。
新建项目时选择“空项目”,然后添加一个.cpp文件。虽然 EasyX 也支持 C 语言,但用.cpp后缀可以使用 C++ 的一些便利特性,比如引用传参、默认参数等,写起来更顺手。在文件开头包含头文件:
#include <graphics.h> #include <conio.h> #include <math.h>graphics.h是 EasyX 的核心头文件,conio.h用于_getch()暂停程序,math.h用于round()函数。
4.2 全局常量与变量定义
把棋盘大小、格子间距、起始坐标这些参数定义成常量,方便统一调整:
const int BOARD_SIZE = 15; const int GRID_SIZE = 40; const int START_X = 60; const int START_Y = 60; const int PIECE_RADIUS = 16; int board[BOARD_SIZE][BOARD_SIZE] = {0}; int currentPlayer = 1; // 1 黑,2 白 GameState state = PLAYING;START_X和START_Y是第一个交叉点的坐标,留出 60 像素的边距,这样棋盘不会贴着窗口边缘。窗口大小可以算出来:START_X * 2 + GRID_SIZE * (BOARD_SIZE - 1),代入数值就是120 + 40 * 14 = 680,所以窗口设成 680×680 比较合适,再加一点底部空间放提示文字,可以设成 680×720。
4.3 棋盘绘制函数的实现
void drawBoard() { setbkcolor(RGB(238, 203, 140)); cleardevice(); setlinecolor(RGB(80, 80, 80)); setlinestyle(PS_SOLID, 2); // 画横线 for (int i = 0; i < BOARD_SIZE; i++) { int y = START_Y + i * GRID_SIZE; line(START_X, y, START_X + (BOARD_SIZE - 1) * GRID_SIZE, y); } // 画竖线 for (int i = 0; i < BOARD_SIZE; i++) { int x = START_X + i * GRID_SIZE; line(x, START_Y, x, START_Y + (BOARD_SIZE - 1) * GRID_SIZE); } // 画外边框,加粗 setlinestyle(PS_SOLID, 3); rectangle(START_X - 5, START_Y - 5, START_X + (BOARD_SIZE - 1) * GRID_SIZE + 5, START_Y + (BOARD_SIZE - 1) * GRID_SIZE + 5); }这里先设置背景色并清屏,然后画内部网格线,最后画外边框。注意rectangle的参数是左上角和右下角坐标,我往外扩了 5 像素,让边框和网格线之间有一点间隙,视觉上更舒服。
4.4 棋子绘制与落子逻辑
void drawPiece(int row, int col, int color) { int x = START_X + col * GRID_SIZE; int y = START_Y + row * GRID_SIZE; if (color == 1) { setfillcolor(RGB(30, 30, 30)); setlinecolor(RGB(30, 30, 30)); } else { setfillcolor(RGB(245, 245, 245)); setlinecolor(RGB(180, 180, 180)); } fillcircle(x, y, PIECE_RADIUS); circle(x, y, PIECE_RADIUS); }落子逻辑需要先判断该位置是否为空,然后更新数组、绘制棋子、判定胜负、切换玩家:
bool placePiece(int row, int col) { if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return false; if (board[row][col] != 0) return false; board[row][col] = currentPlayer; drawPiece(row, col, currentPlayer); if (checkWin(row, col, currentPlayer)) { state = (currentPlayer == 1) ? BLACK_WIN : WHITE_WIN; return true; } currentPlayer = (currentPlayer == 1) ? 2 : 1; return true; }4.5 主循环与消息处理
int main() { initgraph(680, 720); BeginBatchDraw(); drawBoard(); FlushBatchDraw(); while (true) { MOUSEMSG msg; while (peekmessage(&msg)) { if (msg.uMsg == WM_LBUTTONDOWN && state == PLAYING) { int col = round((msg.x - START_X) / (double)GRID_SIZE); int row = round((msg.y - START_Y) / (double)GRID_SIZE); if (placePiece(row, col)) { FlushBatchDraw(); } } } if (state != PLAYING) { // 显示胜利提示 settextcolor(RGB(200, 0, 0)); settextstyle(40, 0, _T("微软雅黑")); const TCHAR* text = (state == BLACK_WIN) ? _T("黑方胜利") : (state == WHITE_WIN) ? _T("白方胜利") : _T("平局"); outtextxy(240, 660, text); FlushBatchDraw(); _getch(); break; } Sleep(10); } EndBatchDraw(); closegraph(); return 0; }主循环里用peekmessage处理鼠标消息,每次落子后调用FlushBatchDraw刷新屏幕。游戏结束后显示提示文字,按任意键退出。Sleep(10)是为了降低 CPU 占用,不加的话循环会跑满一个核心。
5. 常见问题与排查技巧实录
5.1 棋盘画出来是歪的或者不居中
这个问题通常出在坐标计算上。检查START_X和START_Y是否相等,如果不相等,棋盘会偏向一边。另外,窗口宽度应该是START_X * 2 + GRID_SIZE * (BOARD_SIZE - 1),如果窗口宽度设小了,棋盘右边会被截断。我建议把窗口宽度和高度也用常量表示,和START_X、GRID_SIZE联动计算,这样改一个参数,其他自动跟着变。
5.2 点击落子位置偏移
最常见的原因是round函数的使用。(msg.x - START_X) / GRID_SIZE如果两个整数相除,会做整数除法,直接截断小数部分,导致偏移。解决办法是强制转成浮点数:(msg.x - START_X) / (double)GRID_SIZE,然后再round。这个坑我踩过,当时调了半天才发现是整数除法的问题。
另一个可能的原因是窗口有边框,msg.x和msg.y是客户区坐标,但如果你用了initgraph的默认样式,客户区左上角就是(0, 0),不需要额外偏移。如果你改了窗口样式,可能需要用GetClientRect获取实际客户区大小。
5.3 落子后屏幕闪烁
如果没用双缓冲,每次cleardevice之后重绘,闪烁是必然的。解决办法就是前面说的BeginBatchDraw+FlushBatchDraw+EndBatchDraw三件套。注意BeginBatchDraw要在第一次绘图之前调用,EndBatchDraw在程序结束前调用。另外,不要在FlushBatchDraw之前调用cleardevice,否则会把后台缓冲区也清掉,导致画面空白。
5.4 胜负判定漏判或误判
漏判通常是因为边界检查写错了。比如往左数的时候忘了i >= 0,数组越界访问到了其他内存,结果不可预测。误判可能是计数逻辑有问题,比如把当前落子算了两次。建议把四个方向的检查写成一个函数,传入方向向量(dx, dy),这样代码复用性好,也不容易出错。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 棋盘偏移 | START_X 与 START_Y 不等 | 检查常量定义 |
| 点击偏移 | 整数除法截断 | 强制转 double |
| 屏幕闪烁 | 未使用双缓冲 | 加 BeginBatchDraw |
| 胜负误判 | 边界检查遗漏 | 检查循环条件 |
| 程序卡死 | 消息循环阻塞 | 改用 peekmessage |
5.5 程序退出后窗口残留
有时候closegraph()之后窗口没有立即消失,这是因为EndBatchDraw()还没执行完。确保EndBatchDraw()在closegraph()之前调用,并且不要在中途return跳过清理。另外,如果用了_getch()暂停,确保在暂停之后才执行清理代码。
实操心得:调试图形程序时,善用
outtextxy在屏幕上打印中间变量,比打断点更直观。比如把鼠标点击的原始坐标和转换后的行列号打印出来,一眼就能看出映射对不对。
6. 可扩展方向与个人经验补充
6.1 加入悔棋功能
悔棋的思路是用一个栈记录每一步的落子位置和玩家。悔棋时弹出栈顶,把对应位置置空,重绘棋盘。注意要重绘整个棋盘,而不是只擦掉一个棋子,否则会留下痕迹。栈可以用数组模拟,也可以用std::stack。我建议用数组加一个top指针,简单可控。
6.2 加入简单 AI 对手
AI 不需要很复杂,一个基于评分表的贪心算法就能有不错的效果。对每个空位,计算如果下在这里,自己能形成多少连子、能堵住对方多少连子,加权求和,选分数最高的位置。评分表可以这样设计:活三 100 分,冲四 1000 分,活四 10000 分,五连 100000 分。这个 AI 虽然不完美,但对付新手足够了。
6.3 界面美化的小技巧
棋子可以用渐变色填充,虽然 EasyX 没有直接的渐变函数,但可以用多个同心圆模拟。棋盘背景可以加载一张木纹图片,用loadimage加载,putimage绘制。落子时加一个简单的动画,比如棋子从大到小缩放,用Sleep控制帧间隔。这些细节能让项目看起来更精致,答辩或展示时加分不少。
6.4 我踩过的一个典型坑
最开始写的时候,我把FlushBatchDraw放在了while(peekmessage)循环里面,结果每次鼠标移动都会触发重绘,CPU 直接飙到 50%。后来改成只在落子后刷新,CPU 降到 2% 以下。这个教训是:批量绘制的刷新时机要精确控制,不要放在高频触发的地方。另外,Sleep(10)虽然看起来不起眼,但没有它,主循环会空转,风扇会狂转。
还有一个坑是round函数的返回值类型。在 C++ 里round返回double,直接赋值给int会有警告。我后来改成(int)round(...)显式转换,警告才消失。虽然不影响运行,但代码干净一点总是好的。
6.5 关于代码组织的一点建议
不要把所有的代码都堆在main函数里。我习惯把棋盘绘制、棋子绘制、胜负判定、鼠标处理分别写成独立的函数,main只负责初始化和主循环。这样代码结构清晰,调试时也容易定位问题。如果项目再大一点,可以拆成多个文件,用头文件声明接口。虽然五子棋规模不大,但养成模块化的习惯,对后面做更大的项目有好处。
最后分享一个调试技巧:在placePiece函数里加一行printf输出当前落子的行列和玩家,重定向到文件,这样即使图形界面卡住了,也能从日志里看到逻辑执行到哪一步。这个方法在我排查胜负判定 bug 时帮了大忙。