直接打开 VS,新建一个空项目,敲上几百行 C 代码,屏幕上弹出带棋盘的交互界面,鼠标点一下就能落子,电脑还能跟你对弈。这件事听起来不算惊天动地,但对于刚学完 C 语言、整天在黑窗口里打 printf 的人来说,是一个特别有成就感的转折点。这篇博文要聊的正是这样一个项目:用 C 语言实现五子棋,配合 EasyX 图形库(也就是 graphics.h)做出带界面的版本。
C 语言本身不提供图形界面能力,终端只能呈现字符,所以给 C 语言“装上眼睛和手”的任务就交给了图形库。EasyX 是 Windows 平台下针对 C/C++ 的一套轻量级绘图库,封装了大量 Windows GDI 接口,调用方式接近经典 Turbo C 时代的 graphics.h,用起来非常直观。这个项目的核心价值在于:把数组、循环、函数、条件判断这些 C 语言基础知识落到一个完整可运行的小游戏里,同时接触窗口绘图、鼠标交互、游戏循环这些之前黑窗口编程完全碰不到的概念。如果你正在学 C 语言,学完指针和数组后想拿一个难度适中、能跑起来、能给别人炫耀的项目练手,五子棋几乎是最合适的选择。
整个项目我会从设计思路、核心原理、代码结构、完整实操和问题排查几个角度拆开讲,给出的源码也是我调试过的版本,你可以直接照着敲一遍,也可以基于它继续加功能,比如悔棋、联网对战、不同难度 AI。
1. 项目整体设计与思路拆解
1.1 为什么选 C 语言 + EasyX,而不是其他方案
在开始写之前,首先要回答一个“为什么”的问题:市面上游戏开发框架那么多,用 C 写五子棋还非得配 EasyX,图什么?
我的回答是:这套组合是 C 语言学习路径上衔接最平滑的“图形界面入门”方案。很多学习者学到指针、结构体之后会陷入一种迷茫——黑窗口里能做的演示无非是学生成绩管理系统、图书管理系统之类,界面全靠 printf 排版,交互全靠 scanf 输入数字,做完之后感觉 C 语言只能做“控制台程序”。其实 C 语言本身并不弱,它弱在标准库没有携带 GUI 组件。EasyX 的作用就是把这个短板补上,它不需要你学习额外的窗口消息机制(比如 Win32 API),不需要了解复杂的事件驱动模型,而是提供了一套类似“画点、画线、画圆、取鼠标坐标”的极简接口。你只需要像在纸上画棋盘一样,用几行代码把界面“画”出来即可。
对比其他方案:用 Qt、GTK 这类框架功能强大但上手曲线陡,学完 C 语法还得学信号槽、对象模型,容易劝退。用 Unity 这种游戏引擎则已经脱离了 C 语言的范畴。所以对于“想用 C 语言做一个带图形界面的小游戏”这个诉求来说,EasyX 几乎是量身定做的。
当然,EasyX 也有明显的边界:它只支持 Windows 平台,不是跨平台库。如果你有跨平台需求,可能需要考虑 SDL、raylib 这类库。但单纯说学习 C 语言、练习项目实战这件事,Windows + EasyX 的容错率是最高的。
1.2 棋盘数据结构设计:一个二维数组撑起整个游戏
五子棋的核心是棋盘,棋盘的本质是一个二维状态矩阵。这个认知是整个项目最关键的一步。
标准五子棋棋盘的交叉点是落子位置,通常是 15×15 的格子(即 15 行 15 列,共 225 个交叉点)。在程序里,我直接用二维数组int board[15][15]来存储每个交叉点的状态:
0表示这里没有棋子1表示这里是黑子2表示这里是白子
选择int而不是char或枚举类型,主要是为了后续扩展方便。比如以后想记录“上一步是哪一方下的”“这里是否被标记为最近落子点”,可以在数组里增加语义,或者用额外的数组去记录。对于初学阶段,int足够简单,也方便打印调试。
数据结构设计还隐含了一个重要决策:棋盘数组是全局变量还是局部变量?我的建议是定义为全局变量,虽然“全局变量有害”是很多教科书里的教条,但在这个规模的项目里,全局变量能显著减少函数参数传递的复杂度,让代码可读性更高。等你把功能写完,再回过头来思考如何封装成结构体也不迟。学习阶段优先保证“跑得起来”,再考虑“设计得好”。
这里要注意一个容易混淆的地方:棋盘的“格子”大小和棋盘数组的大小不是一回事。格子大小是像素单位,决定绘图时的间距,比如每个格子画成 40 像素宽;而棋盘数组的大小只决定逻辑上有多少个落点。绘图时要把逻辑坐标(第几行第几列)换算成像素坐标(屏幕上的 x、y),这部分我会在第 3 章详细展开。
1.3 胜负判断:五子棋规则的程序化表达
规则很简单:任意一方在水平、垂直、两条对角线方向上,先连成五个或以上同色棋子即获胜。程序化的核心就是“从某个落点出发,沿着四个方向数连续同色棋子的个数”。
很多人第一次写胜负判断时容易犯一个错误:每下一步棋就遍历整个棋盘,检查所有可能的五连子。其实没必要。因为新棋子的落点是已知的,只有包含这个新落点的方向才可能形成新五连,所以只需要从当前落点出发,向四个方向分别统计即可。这样做既高效又直观。
具体做法是定义四个方向向量:
- 水平方向:横向偏移 (1, 0) 和 (-1, 0)
- 垂直方向:纵向偏移 (0, 1) 和 (0, -1)
- 主对角线方向:偏移 (1, 1) 和 (-1, -1)
- 副对角线方向:偏移 (1, -1) 和 (-1, 1)
从当前落点出发,沿着某个方向的正向数同色棋子,再沿着反方向数,把两段数量加起来再加 1(当前棋子本身),如果总和大于等于 5,就判定获胜。这个逻辑用两个嵌套循环就能完成,代码量不大,但需要特别小心数组越界问题——计数器在往边界外走时必须停止,否则程序会崩溃。这里我建议提前写好一个“判断坐标是否在棋盘内”的辅助函数,别嫌它简单,它能省下很多麻烦。
1.4 人机对弈的 AI 设计:不写算法也能玩
如果你只想做双人对战(两个人在同一台电脑上轮流落子),那么 AI 部分可以完全跳过。但很多人的兴趣点在于“和电脑下一盘”,所以我在这里简单讲一种不用复杂算法、但效果还不算太弱的 AI 设计思路:分数评估法。
这种方法的原理非常朴素:遍历棋盘上每一个空白交叉点,按照几条启发式规则给它打分,规则包括:
- 如果黑子(玩家)在这个点落子,能形成四连或三连,那么这个点对玩家价值高,AI 需要给它打高分并优先“堵”住。
- 如果白子(AI)在这个点落子,能形成四连或三连,那么这个点对 AI 价值高,AI 应该优先“攻”。
- 根据连子数量、是否两端被封死等情况,给不同的加权分。
最后 AI 选择分数最高的空白点落子。这种方式的优点是代码量小、容易理解,足够应付普通玩家的棋力。缺点是它没有全局规划能力,不会做“多步推演”,所以棋力天花板不高。对初学者来说,先把这种“贪心打分”版本写出来,再去学极大极小搜索和 alpha-beta 剪枝,路径更清晰。
关于 AI 的难度,我在代码里预留了一个difficulty参数,通过调整进攻分数和防守分数的权重来控制 AI 的激进程度,这比单纯调随机落子更有意思。
2. EasyX 图形库核心知识点入门
2.1 graphics.h 到底是什么
很多人在网上搜“C语言图形界面”,会看到一堆关于 graphics.h 的内容,但容易搞混:老教材里的 graphics.h 来自 Borland C++ / Turbo C,是 DOS 时代的图形库,现在基本没法直接用;而我们用的 EasyX 是专门为 Windows 平台设计的 C/C++ 图形库,它把底层 GDI 绘图逻辑封装成了一组风格接近老 graphics.h 的函数,让你在 VS 里可以直接 include 并调用。
EasyX 不是一个独立安装的软件,准确说它是给 Visual Studio(或 VS Code 配合编译器)提供的一组库文件。装好之后,在代码里写:
#include <graphics.h>然后调用initgraph(宽度, 高度)初始化窗口,用circle画圆、line画线、outtextxy输出文字,用MOUSEMSG获取鼠标消息。这些函数看起来都非常直白,基本就是你高中时用几何画板画图的感觉。
需要注意:EasyX 只支持 Windows,不支持 Linux 和 macOS。如果你用的是 Mac,可以考虑用 raylib 或者 SDL 替代,但本篇文章的所有内容以 Windows 10/11 + Visual Studio + EasyX 为准。版本方面,建议直接使用 EasyX 官方最新版,它的安装步骤是自动配置到编译器里的,不需要手动复制.h和.lib文件。
2.2 环境配置实操:VS 里跑出第一个图形窗口
环境准备部分我简化为下面几步,每一步都有明确的检验标准:
- 安装 Visual Studio。社区版即可,安装时勾选“使用 C++ 的桌面开发”工作负载。
- 去 EasyX 官网下载安装包。安装程序会让你选择对应的编译器版本,我这里选 VS 版本,安装程序会自动把库文件放进编译器的 include/lib 目录。
- 创建一个空项目。文件 -> 新建 -> 项目,选择 C++ 空项目(虽然我们写的是 C 代码,但 VS 里很多时候代码文件的扩展名是 .cpp,因为 EasyX 的库按 C++ 方式链接,稍后我会解释这个细节)。
新建项目后右键源文件 -> 添加 -> 新建项,命名为main.cpp或game.c。这里有个常见问题:以.c结尾的源文件在 VS 中按 C 语言规则编译,如果直接调用 EasyX 可能会遇到链接错误,原因是 EasyX 库内部用 C++ 接口。解决方法有两种:一种是把源文件扩展名改为.cpp,代码主体用 C 语言的风格写;另一种是保持.c但用__cplusplus宏处理。我更推荐初学者直接建.cpp文件,既可以继续用 C 语法,又能避免很多链接层面的坑。
- 用下面的代码测试环境是否生效:
#include <graphics.h> #include <conio.h> int main() { initgraph(640, 480); circle(320, 240, 100); _getch(); closegraph(); return 0; }如果运行后弹出一个 640×480 的窗口,中间有个圆,按任意键窗口关闭,说明 EasyX 安装成功,环境准备到此为止。
2.3 常用绘图函数与鼠标交互
EasyX 的核心函数不长,我挑几个五子棋里最常用的列出来:
initgraph(int width, int height):初始化绘图窗口,设置窗口宽高(像素)。setbkcolor(COLORREF color):设置背景颜色。比如setbkcolor(WHITE)。cleardevice():用当前背景色清空屏幕。每次刷新画面之前通常要调用。setlinecolor(COLORREF color):设置线条颜色。line(int x1, int y1, int x2, int y2):画直线。setfillcolor(COLORREF color):设置填充颜色。solidcircle(int x, int y, int radius):画实心圆。outtextxy(int x, int y, LPCTSTR str):在指定位置输出字符串。MOUSEMSG结构体和MouseHit()、MouseGetMessage():读取鼠标消息。
鼠标操作是五子棋交互的关键,在 EasyX 中获取鼠标点击的代码模式固定是这样的:
MOUSEMSG msg; if (MouseHit()) { msg = GetMouseMsg(); if (msg.uMsg == WM_LBUTTONDOWN) { // msg.x, msg.y 是鼠标点击时的屏幕坐标 } }这里WM_LBUTTONDOWN是 Windows 消息常量,表示鼠标左键按下。整个读取流程本质上是在轮询消息队列里是否有点击事件——没有事件就继续循环,有事件就取出坐标,然后判断落在哪个交叉点上。
2.4 双缓冲绘图:避免窗口闪烁
初学图形编程时很容易遇到一个问题:每次清屏重画整个棋盘,窗口会明显闪烁。这是因为绘制过程直接写到了屏幕上,人眼能看到“清屏”和“重绘”之间的中间状态。解决方式是用双缓冲:先在内存里把画面画好,再一次性地拷贝到屏幕上。
EasyX 提供了一组函数来支持双缓冲:
BeginBatchDraw(); // 开始批量绘制 // ... 在这里进行所有绘图操作 EndBatchDraw(); // 将内存画面整体刷新到屏幕在游戏主循环里使用这两个函数包裹绘图代码,闪屏问题基本能消失。这个小技巧在后续开发任何图形程序时都会用到,值得记在心里。
3. 实操过程与核心模块实现
3.1 棋盘绘制的关键参数与坐标换算
这一节是源码实现的核心部分。我先把棋盘处理的参数直接列出来,方便你边看边对:
- 棋盘行数/列数:
ROWS = 15,COLS = 15 - 格子像素间距:
GRID_SIZE = 40 - 棋盘左上角起始坐标:
MARGIN_X = 30,MARGIN_Y = 30 - 棋子半径:
RADIUS = 15
为什么要单独引入起始坐标?因为如果不留边距,棋盘的边缘棋子会贴到窗口边界,很难看,而且靠边的棋子没有空间绘制坐标提示。加了边距之后,窗口的宽度可以计算为MARGIN_X + (COLS - 1) * GRID_SIZE + MARGIN_X,也就是30 + 14*40 + 30 = 620,同理高度也为 620。如果你还想在棋盘右侧留一块区域显示文字信息(比如“轮到谁下”“胜负提示”),可以把窗口宽度再加大,比如 800×640。
棋盘交叉点的像素坐标换算公式是:
像素x = MARGIN_X + col * GRID_SIZE 像素y = MARGIN_Y + row * GRID_SIZE反过来,鼠标点击时从像素坐标得到棋盘格坐标:
col = (msg.x - MARGIN_X + GRID_SIZE / 2) / GRID_SIZE row = (msg.y - MARGIN_Y + GRID_SIZE / 2) / GRID_SIZE这里的+ GRID_SIZE / 2是为了让落在格子中间区域内的点击都归到最近的那个交叉点上,简单说就是四舍五入。然后还要加上越界判断:如果row < 0 || row >= ROWS || col < 0 || col >= COLS,直接忽略这次点击。
棋盘绘制的代码大概是这样:
void DrawBoard() { setbkcolor(RGB(210, 180, 140)); // 木质棋盘底色 cleardevice(); setlinecolor(BLACK); // 画横线 for (int row = 0; row < ROWS; row++) { int y = MARGIN_Y + row * GRID_SIZE; line(MARGIN_X, y, MARGIN_X + (COLS - 1) * GRID_SIZE, y); } // 画竖线 for (int col = 0; col < COLS; col++) { int x = MARGIN_X + col * GRID_SIZE; line(x, MARGIN_Y, x, MARGIN_Y + (ROWS - 1) * GRID_SIZE); } // 画五个星位标记 int starPos[5][2] = { {3,3}, {3,11}, {7,7}, {11,3}, {11,11} }; for (int i = 0; i < 5; i++) { int x = MARGIN_X + starPos[i][1] * GRID_SIZE; int y = MARGIN_Y + starPos[i][0] * GRID_SIZE; solidcircle(x, y, 4); } }3.2 落子操作的判定逻辑与代码实现
落子模块要做的事有 4 件:读取鼠标点击;判断点击是否合法(在棋盘范围内、该位置为空);写入棋盘数组;绘制棋子。写棋子之前还要判断当前是哪一方轮到,我是用int currentPlayer变量来记录,1 表示黑、2 表示白,每次成功落子后切换。
落子部分的核心逻辑:
void HandleClick(int mx, int my) { // 像素坐标 -> 棋盘坐标 int col = (mx - MARGIN_X + GRID_SIZE / 2) / GRID_SIZE; int row = (my - MARGIN_Y + GRID_SIZE / 2) / GRID_SIZE; if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return; if (board[row][col] != 0) return; board[row][col] = currentPlayer; int cx = MARGIN_X + col * GRID_SIZE; int cy = MARGIN_Y + row * GRID_SIZE; DrawPiece(cx, cy, currentPlayer); // 判断胜负 if (CheckWin(row, col, currentPlayer)) { // 显示获胜信息,结束游戏循环 gameOver = 1; } else { currentPlayer = (currentPlayer == 1) ? 2 : 1; } }这里每一步都有对应的输出或状态变化,建议新手在落子后加一句printf打印当前棋盘状态,方便在没有图形界面的调试环境中排查逻辑问题。
DrawPiece就是把棋子画在棋盘交叉点上,黑子用深色填充、白子用浅色填充,同时为了好看可以加一个边框:
void DrawPiece(int x, int y, int player) { setfillcolor(player == 1 ? BLACK : WHITE); solidcircle(x, y, RADIUS); setlinecolor(player == 1 ? BLACK : GRAY); circle(x, y, RADIUS); }3.3 胜负判断模块:四个方向的连续棋子统计
这一节我直接贴出完整的CheckWin函数。因为这是整个游戏逻辑的核心,你必须理解它的每一步。
方向定义我放在一个二维数组里,用dirs[4][2]表示四个方向的主偏移量(从落点向某一侧延伸):
int dirs[4][2] = { {1, 0}, // 水平 {0, 1}, // 垂直 {1, 1}, // 主对角线 {1, -1} // 副对角线 };对每个方向,分别向正负两边延伸统计同色子数量。我把统计逻辑单独抽成一个函数:
int CountDirection(int row, int col, int dx, int dy, int player) { int count = 0; int r = row + dx; int c = col + dy; while (r >= 0 && r < ROWS && c >= 0 && c < COLS && board[r][c] == player) { count++; r += dx; c += dy; } return count; }然后CheckWin把正反两个方向加起来,再加上当前棋子本身:
int CheckWin(int row, int col, int player) { for (int i = 0; i < 4; i++) { int dx = dirs[i][0]; int dy = dirs[i][1]; int count = 1 + CountDirection(row, col, dx, dy, player) + CountDirection(row, col, -dx, -dy, player); if (count >= 5) return 1; } return 0; }这个写法的好处是逻辑对称,不容易漏方向。边界安全由CountDirection里的循环条件保证了。我实际调试时发现最容易出错的是副对角线方向的正负号写反,这里dx=1, dy=-1表示向右下、向左上为反方向,如果符号不对会统计漏棋子。
3.4 人机对弈的简单实现:分数评估法
如果你选择做双人对战,直接跳过这一节没有任何问题。但如果电脑要参与,我们就需要一个“决定下一步落哪儿”的函数。
我用一个scoreBoard[ROWS][COLS]数组保存每个空白位置的分数。打分逻辑如下:
- 遍历棋盘所有空白格。
- 对该格,模拟放一颗黑子(假设玩家会这么走),计算能形成的最大连子数,并给一个分数
attackScore。 - 模拟放一颗白子(AI自己走),计算能形成的最大连子数,给一个分数
defendScore。 - 综合分 =
attackScore * 1.0 + defendScore * 1.2(防守权重大一点,因为堵住玩家的活三更重要)。 - 选综合分最高的位置作为 AI 落子点。
计算最大连子数时,可以利用之前写好的CountDirection函数,对四个方向都数一遍,取最大值。但注意这里不需要真的把子放到棋盘上,只需要临时假设放上去。为了让这个逻辑清晰,我额外写了一个EvaluatePoint(row, col, player)函数:
int EvaluatePoint(int row, int col, int player) { int maxCount = 0; for (int i = 0; i < 4; i++) { int dx = dirs[i][0]; int dy = dirs[i][1]; int count = 1 + CountDirection(row, col, dx, dy, player) + CountDirection(row, col, -dx, -dy, player); if (count > maxCount) maxCount = count; } return maxCount; }然后 AI 落子:
void AIPlayerMove() { int bestScore = -1; int bestRow = -1, bestCol = -1; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (board[r][c] != 0) continue; int attack = EvaluatePoint(r, c, 1); // 假设玩家下这里 int defend = EvaluatePoint(r, c, 2); // 假设 AI 下这里 int score = attack + defend * 2; if (score > bestScore) { bestScore = score; bestRow = r; bestCol = c; } } } if (bestRow != -1) { board[bestRow][bestCol] = 2; // 绘制棋子、判胜、切换玩家 } }defend * 2这个值是按经验定的,数值越大 AI 越保守(偏防守),越小越激进(偏进攻)。你可以根据自己的感觉调整。这个方法已经能够应付不少新手了。
3.5 游戏主循环的完整骨架
把前面的模块串起来的游戏主循环大体长这样:
int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); setbkcolor(RGB(210, 180, 140)); cleardevice(); InitBoard(); DrawBoard(); gameOver = 0; currentPlayer = 1; // 黑方先手(玩家) while (!gameOver) { if (currentPlayer == 1) { // 玩家操作:等待鼠标点击 if (MouseHit()) { MOUSEMSG msg = GetMouseMsg(); if (msg.uMsg == WM_LBUTTONDOWN) HandleClick(msg.x, msg.y); } } else { // AI 操作:留一点延迟,避免“瞬间落子”让人反应不过来 Sleep(300); AIPlayerMove(); } } // 输出胜负信息,然后任意键关闭 outtextxy(...); _getch(); closegraph(); return 0; }这里面有两个细节容易出现小问题:一是Sleep(300)是为了让 AI 落子不是瞬发的,否则人眼还没来得及看清上一步棋,电脑就落子了,体验很差;二是在gameOver后如果还用_getch()阻塞,要确保之前的 EasyX 窗口没有被关闭,否则程序会闪退。顺序上应该先显示结果再_getch()再closegraph()。
4. 常见问题与排查技巧实录
4.1 编译链接相关:graphics.h 找不到、链接失败
这个是我被问得最多的一个问题。graphics.h找不到有两种原因:一是 EasyX 没有安装成功,库文件没有复制到编译器对应的 include 目录;二是你用 VS Code + MinGW 编译,但 MinGW 环境通常需要手动处理 include 路径和库依赖。如果你用的是 VS,安装包应该能自动完成配置。
链接失败的典型表现是 LNK2019 或者 LNK1120 错误,提示无法解析_initgraph等符号。我踩过的坑是:源文件扩展名是.c,但库是 C++ 接口。如果你坚持使用.c文件,可以考虑在文件开头加一个条件编译的处理;但如果你想少惹麻烦,最简单的做法是把源文件改成.cpp。C 语言写习惯了,改成 .cpp 之后只要不用到类、模板、重载这些 C++ 特性,代码基本可以原样运行。实际操作中,我后来在很多项目里都用这一招,省时省力。
4.2 中文乱码:字符集问题
使用 VS 新建项目时默认源文件可能是 UTF-8 编码,但 EasyX 的outtextxy输出中文时,常常因为字符集和字体渲染的问题显示成乱码。我的经验是:把源文件的编码改成“简体中文(GB2312)”或 GBK 编码,在 VS 里通过“文件 -> 高级保存选项 -> 编码保存”来修改。
如果不想折腾编码,另一个方案是直接在界面上显示英文文本。五子棋的提示信息无非是Black's turn、White wins这类,用英文显示反而更省心。对新手来说,建议先别在中文显示上花时间,把游戏逻辑调通了再美化也不迟。
4.3 闪屏问题
如果你没有用双缓冲,每次绘制整个棋盘都会闪屏。解决方案我在 2.4 节已经讲了:用BeginBatchDraw()和EndBatchDraw()。需要注意的是这两个函数必须成对使用,而且在主循环中不要在中间调用cleardevice()时把内存画面也清了,它们是互相配合的关系。我在第一次写贪吃蛇的时候,就是没搞明白cleardevice()和批量绘制的顺序,导致画面一直闪烁甚至花屏。
一般建议把绘制的流程统一写成:
BeginBatchDraw(); DrawBoard(); for (int r = 0; r < ROWS; r++) for (int c = 0; c < COLS; c++) if (board[r][c] != 0) DrawPiece(...); EndBatchDraw();即每次画面刷新时,把棋盘和所有棋子都完整地重画一遍。这样代码逻辑简单,也天然避免了残留图像问题。
4.4 点击坐标偏移、落子在奇怪的位置
这个问题的根源通常是坐标换算公式写错了,或者是忽略了MARGIN_X/MARGIN_Y。调试方法是在HandleClick函数里把msg.x/msg.y和换算出来的row/col用printf输出到控制台(VS 中按 Ctrl+F5 运行时会弹出一个控制台窗口,前提是项目设置里把子系统设置为控制台)。如果换算有偏差,调整+ GRID_SIZE / 2那个四舍五入的偏移量。
另外一个容易被忽略的点:EasyX 窗口左上角的坐标是 (0, 0),如果你把鼠标点击的坐标直接当作棋盘数组的下标使用,大概率会越界。必须先做像素坐标到棋盘坐标的换算。
4.5 棋子被重复覆盖、游戏无法判胜
这个问题的原因通常是落子后没有正确维护board数组,或者没有用board[row][col] != 0做合法性判断。我在 3.2 节里写了检查逻辑,顺序不能乱:先判越界,再判是否已有棋子。如果你把越界检查放在数组访问之后,一旦点击到棋盘边缘外,就会产生未定义行为,程序行为变得诡异。
还有一种情况是判胜函数写错方向导致漏判。建议在调试阶段写一个PrintBoard()函数,手动构造一个“横向四连 + 落下第五颗”的场景,跑一下CheckWin,然后在控制台看输出结果。简单而有效。
4.6 初学者常见问题速查表
| 现象 | 主要原因 | 建议的处理 |
|---|---|---|
| 编译报错找不到 graphics.h | EasyX 未安装或未配置 | 重新安装对应版本的 EasyX |
| 链接错误 LNK2019 | 源文件是 .c,EasyX 是 C++ 接口 | 改为 .cpp 文件 |
| 窗口闪屏 | 没有使用双缓冲 | 用 BeginBatchDraw / EndBatchDraw |
| 中文乱码 | 源文件编码与文本输出 API 不匹配 | 改为 GBK 编码或使用英文 |
| 点击后棋子不在鼠标位置 | 坐标换算公式错误 | 检查 MARGIN_X + col * GRID_SIZE 相关公式 |
| 可以重复在同一个位置落子 | 缺少 board 状态检查 | 判断 board[row][col] != 0 |
| 程序运行后窗口立即关闭 | 缺少 _getch() 等待 | 在 closegraph() 前加 _getch() |
| AI 落子过快 | 没有设置延迟 | 在 AI 操作前加 Sleep(300) |
4.7 一个容易被忽略的细节:资源释放与窗口关闭
虽然这是一个很小的项目,但养成好习惯很重要。程序结束前调用closegraph()可以释放图形资源;配合_getch()可以在窗口关闭前让玩家看到胜负提示。如果不希望弹出额外的控制台窗口,可以在 VS 项目属性里把子系统改为“Windows”,但那意味着 stdout 输出不可见,调试时反而不好用。
我个人的习惯是调试阶段保留控制台窗口,用 printf 输出关键状态;所有逻辑验证完毕、准备展示给别人看时,再把子系统改成“Windows”,顺手把printf清理掉。这样既有调试的便利,又有交付的干净。
5. 写在最后的实操心得与扩展建议
我最初写这个项目时,最大的感受是“原来 C 语言能做的远不止 scanf 和 printf”。当一个带棋盘的窗口在自己面前弹出,鼠标点下去能看到黑白子落位、听到逻辑判断在背后运行,那种输出的即时反馈是黑窗口里完全体会不到的。这也是我特别推荐 C 语言学习者早点接触图形库的原因——它能帮你把抽象的语法具象化。
如果你要把这个项目继续做下去,我个人建议从这几个方向扩展:第一,增加“悔棋”功能,用栈保存历史落子坐标,出栈即可撤销;第二,加入“重新开始”和“胜负计数”的界面按钮;第三,把 AI 的分数评估替换成极小极大搜索,你可以明显感受到 AI 棋力的提升;第四,尝试用网络通信做在线双人对战,这会对 socket 编程有更深入的理解。
关于源码,建议不要复制粘贴完就跑,至少把CheckWin的四个方向统计逻辑亲手写一遍,把主循环的鼠标轮询流程亲手敲一遍。踩一次坑,比读十遍代码都管用。如果你把上面的代码都消化了,这个项目已经足够成为你 C 语言学习路上的一小块里程碑。