
写这个项目之前我在宿舍对着命令行黑框框敲了大半年的C语言作业链表、排序、学生管理系统翻来覆去总感觉少了点什么。后来看到一个学长用EasyX做了一个扫雷才意识到C语言完全能写出有界面的东西。于是决定搞一个国际象棋棋盘、棋子、规则、交互全都要自己动手这个项目做完我对C语言的理解完全上了一个台阶。这篇文章就完整记录一下整个项目的设计思路、核心代码结构和开发过程中踩过的坑。1. 为什么拿国际象棋练手这个项目的学习价值所在1.1 国际象棋比五子棋、贪吃蛇更适合练手很多人学完C语言基础之后第一个图形项目会选贪吃蛇或者五子棋。这两个项目确实能练到链表、数组和简单交互但深度不太够。国际象棋不一样它天然包含了一整套完整逻辑6种棋子的走法规则各不相同兵还有升变、吃过路兵这种特殊规则需要处理“将军”和“将杀”判断本质是一个搜索与判定问题棋盘本身是8x8的二维数组和C语言的数组下标能完美映射界面交互涉及鼠标点击、选中高亮、合法走法提示能充分练习EasyX的事件处理。以上每一条展开来都是一个独立的知识点。我在做这个项目时至少用到了结构体、枚举、二维数组、函数指针、坐标换算、事件循环、资源加载这些C语言核心内容不知不觉就把指针和结构体的用法练得滚瓜烂熟了。1.2 图形库让C语言“看得见”很多初学者觉得C语言只能做控制台程序这完全是误解。Windows平台上EasyX就是一个轻量级的图形库它直接封装了Windows GDI绘图接口用起来接近TC时代的图形函数风格。装了EasyX之后你仍然是用C语言在写代码但画面上是一个真正的图形窗口这让调试和验证逻辑变得直观得多。EasyX的部署非常简单去官网下载安装包它会自动识别你本机的Visual Studio版本一键安装。它支持的IDE主要是Visual Studio系列我用的是VS2019装完直接就能用。需要注意的是如果你用了Visual Studio Code配MinGWEasyX虽然在gcc环境下也有适配版本但安装和配置比VS麻烦不少小白建议老老实实用VS。在做这个项目之前先确认一下你对以下知识点的掌握情况结构体与枚举用来定义棋子类型和颜色二维数组8x8棋盘本身就是一张二维表函数拆分每种棋子的走法判断拆成独立函数基本的指针用法如果要做悔棋功能会涉及历史栈。如果上面这些都没问题这个项目就是恰到好处的进阶练习既不会简单到没有挑战也不会难到完全无从下手。2. 整体架构设计先定数据结构再谈界面2.1 棋盘的抽象一个8x8二维数组拿到这个项目的第一反应很多人会直接开始想界面怎么画这是错误的顺序。图形界面只是表层的皮棋局逻辑才是骨肉。我做的第一件事是定义棋盘的数据结构。typedef enum { EMPTY 0, PAWN, // 兵 KNIGHT, // 马 BISHOP, // 象 ROOK, // 车 QUEEN, // 后 KING // 王 } PieceType; typedef enum { WHITE 0, BLACK 1 } PieceColor; typedef struct { PieceType type; PieceColor color; int hasMoved; // 王车易位需要知道车和王是否移动过 } ChessPiece; ChessPiece board[8][8];这里最关键的设计是给每个棋子加了一个hasMoved字段这个字段是为了解决王车易位规则的判断。如果不加这个字段后面做王车易位判断时会非常被动你不知道这个车是否已经动过就没法确定易位条件是否满足。这种“现在看起来多写一个字段以后能救命”的设计思路在真实项目开发中非常重要。坐标的映射也是在这个阶段定下来的数组下标board[row][col]row表示行0到7col表示列0到7。屏幕坐标和数组下标之间的关系我放在后面“界面绘制”那一节详细说。2.2 程序模块怎么切分我一开始图省事把所有的代码都堆在了一个main.c文件里结果写到两千行之后自己都看不下去了。后来重构时按功能拆成了几个文件chess.h所有结构体、枚举、全局变量声明board.c棋盘初始化和重置逻辑rules.c六种棋子的走法判断game.c将军、将杀判断以及胜负逻辑ui.cEasyX绘图、鼠标交互、界面刷新。这种拆分方式在做大项目时是标配但很多C语言教程不会教你因为课程设计一般代码量不大。可一旦代码超过1500行不拆文件的话光是查找函数定义就能浪费你大量时间。拆完文件之后我才意识到头文件里该声明什么、源文件里该包含什么这些平时觉得抽象的概念一下子就具象了。2.3 初始化棋盘开局摆子国际象棋开局时的摆法是后排从左到右依次是车、马、象、后、王、象、马、车注意白后的初始位置在白格黑后的初始位置在黑格这是一个非常容易搞错的小细节。前排8个格子全是兵。void initBoard() { // 清空棋盘 for (int row 0; row 8; row) { for (int col 0; col 8; col) { board[row][col].type EMPTY; } } // 摆放黑方棋子第0行和第1行 placePiece(0, 0, ROOK, BLACK); placePiece(0, 1, KNIGHT, BLACK); placePiece(0, 2, BISHOP, BLACK); placePiece(0, 3, QUEEN, BLACK); placePiece(0, 4, KING, BLACK); placePiece(0, 5, BISHOP, BLACK); placePiece(0, 6, KNIGHT, BLACK); placePiece(0, 7, ROOK, BLACK); for (int col 0; col 8; col) { placePiece(1, col, PAWN, BLACK); } // 摆放白方棋子第6行和第7行 placePiece(7, 0, ROOK, WHITE); placePiece(7, 1, KNIGHT, WHITE); placePiece(7, 2, BISHOP, WHITE); placePiece(7, 3, QUEEN, WHITE); placePiece(7, 4, KING, WHITE); placePiece(7, 5, BISHOP, WHITE); placePiece(7, 6, KNIGHT, WHITE); placePiece(7, 7, ROOK, WHITE); for (int col 0; col 8; col) { placePiece(6, col, PAWN, WHITE); } }这里我在代码里直接用placePiece函数而不是直接给board[row][col]赋值是为了顺便初始化hasMoved 0避免漏初始化造成后续王车易位判断出错。这种“用函数封装赋值”的习惯对C语言初学者来说值得培养能省掉不少bug。3. EasyX图形库的关键用法与界面绘制3.1 从窗口创建到消息循环EasyX的使用模式非常直接初始化一个窗口然后进入一个死循环在循环里不断处理用户输入和刷新画面。这个模式和写一个控制台程序完全不同控制台程序是自上而下顺序执行完就退出图形程序是一个持续运行的事件驱动循环。#include graphics.h #include conio.h int main() { initgraph(640, 640); // 创建一个640x640的窗口 // 主循环 while (true) { drawBoard(); // 绘制棋盘 drawPieces(); // 绘制棋子 handleMouse(); // 处理鼠标点击 Sleep(10); // 避免CPU占用过高 } closegraph(); return 0; }initgraph的两个参数是窗口宽度和高度我选640是因为8个格子每个80像素整数运算不会产生浮点误差。closegraph负责释放资源程序结束前必须调用否则窗口可能残留。这个主循环是图形程序的基础所有的业务逻辑都在这个循环里递归执行。初学者第一次接触这个概念可能不习惯你就想象成电影放映机每一帧都重新画一次画面循环往复就成了动画。3.2 屏幕坐标与数组下标的换算8x8棋盘每个格子大小设为CELL_SIZE 80那么第row行第col列的格子它的左上角屏幕坐标就是(col * CELL_SIZE, row * CELL_SIZE)。注意这里row对应的是y方向col对应的是x方向初学者最容易在这里搞混。#define CELL_SIZE 80 // 棋子绘制根据数组下标计算屏幕位置 void drawPieceAt(int row, int col) { int x col * CELL_SIZE CELL_SIZE / 2; int y row * CELL_SIZE CELL_SIZE / 2; // x, y 就是棋子中心点的屏幕坐标 }反过来鼠标点击时收到的屏幕坐标(mouseX, mouseY)对应的数组下标就是mouseY / CELL_SIZE和mouseX / CELL_SIZE。这就是一套互逆的映射关系在一开始设计UI时就明确了后面的所有上下、左右判断都不会再混乱。3.3 双缓冲绘图解决画面闪烁EasyX在不做任何处理的情况下直接在窗口上画图会有明显的闪烁感特别是你每帧都要重画几十个棋子的时候。解决这个问题的标准做法是双缓冲先在一段不可见的内存缓冲区里画完整个画面再一次性提交到屏幕上。void draw() { BeginBatchDraw(); // 开始批绘制 drawBoard(); // 画棋盘底色 drawCoordinates(); // 画坐标标注 drawPieces(); // 画棋子 drawSelectedSquares(); // 画选中高亮 EndBatchDraw(); // 一次性刷新到屏幕 }用了BeginBatchDraw和EndBatchDraw之后闪烁问题基本消除。我一开始不明白为什么要这么麻烦以为直接画就行结果屏幕上一直在闪看得眼睛疼。后来查了一下原理没有双缓冲时每画一个图形系统就刷新一次而棋盘的绘制要画几十个图形相当于每帧都在进行几十次屏幕刷新不闪才怪。双缓冲把所有绘图操作全部累积到内存最后一次写入显示设备画面就稳定了。3.4 绘制棋盘的细节棋盘底色用fillrectangle画出来深色格和浅色格交替排列。国际象棋棋盘的右下角必须是白格这个细节在绘制时需要注意。void drawBoard() { for (int row 0; row 8; row) { for (int col 0; col 8; col) { int x col * CELL_SIZE; int y row * CELL_SIZE; if ((row col) % 2 0) { setfillcolor(RGB(235, 235, 235)); // 浅色格 } else { setfillcolor(RGB(78, 78, 78)); // 深色格 } fillrectangle(x, y, x CELL_SIZE, y CELL_SIZE); } } }(row col) % 2这个表达式的妙处在于它天然地让相邻格子颜色交替。如果你用row % 2去判断会发现每一行内所有格子颜色一样只有行与行之间有差异根本不对。这种小问题的排查经验也值得分享图形问题的bug通常不是图形API用错了而是数学映射关系搞错了。3.5 棋子的绘制方案选择棋子有两种画法一种是用EasyX的loadimage加载图片素材另一种是用圆弧、圆形、矩形等基本图形硬画。图片素材的美观度更高但需要处理透明背景否则棋子背景会是一个矩形色块很难看。我最终选择了用基本图形自绘原因有两个一是透明PNG的加载和Alpha通道处理在EasyX里需要额外几步对初学者不友好二是自绘棋子能把绘图API练得更熟。每个棋子其实就是由几个圆、矩形和线条组合而成。void drawPiece(int row, int col, ChessPiece piece) { int cx col * CELL_SIZE CELL_SIZE / 2; int cy row * CELL_SIZE CELL_SIZE / 2; int r CELL_SIZE * 3 / 8; // 根据棋子颜色设置绘制颜色 if (piece.color WHITE) { setfillcolor(WHITE); setlinecolor(BLACK); } else { setfillcolor(RGB(60, 60, 60)); setlinecolor(BLACK); } // 画一个圆形底座 fillcircle(cx, cy, r); // 根据棋子类型在圆形上加不同的标识 switch (piece.type) { case PAWN: // 画一个球 fillcircle(cx, cy - r / 2, r / 3); break; case ROOK: // 画一个矩形 fillrectangle(cx - r / 3, cy - r, cx r / 3, cy - r / 2); break; // ... 其它棋子类似 } }实际上我在最终的版本里每个棋子的形状比这个示例更精细一些用了多个图形组合。核心思路就是用不同颜色和形状区分黑白双方再用类型标志区分具体棋子。这里有一个小技巧绘制顺序很重要后画的图像会覆盖先画的图像所以先画大的底座再画小的装饰最终效果才有层次感。如果你直接套用这个方案我建议你花点时间调一调每个形状的偏移量视觉上会更像真的棋子。4. 核心走法规则与合法性校验4.1 走法框架从选中到落子的完整流程下棋的交互流程是点击自己的棋子选中它再点击一个目标位置程序判断这个走法是否合法合法则执行非法则提示并等待重新选择。整个流程用一个状态机来管理typedef enum { STATE_IDLE, // 未选中任何棋子 STATE_SELECTED // 已选中一枚棋子 } GameState;在STATE_IDLE状态下点击棋盘时判断被点击位置是否有当前轮到颜色的棋子有则进入STATE_SELECTED状态并记录选中位置。在STATE_SELECTED状态下点击棋盘时判断目标位置是否满足走法规则且不导致己方被将军满足则执行走子并切换行棋方不满足则提示错误或取消选中。这个状态机是整个游戏循环的骨架。没有这个设计时我一度把交互逻辑写得很乱一会儿判断选中一会儿判断落子改来改去老出bug。后来画了一张简单的状态转移图思路一下子清晰了。虽然做项目时说“画图”听起来不够程序员但它真的很管用。4.2 兵的走法最“反直觉”的一种兵是国际象棋里最特殊的棋子它的走法规则有一堆例外写起来比车的走法要麻烦得多。兵的规则总结如下往前直走一格从起始位置出发时可以直走两格吃子时只能斜前方一格不能后退到达对方底线时升变还有“吃过路兵”这个特殊规则。代码实现时必须区分当前棋子的颜色因为白方兵的移动方向和黑方兵的移动方向正好相反。我定义了一个direction变量来解决这个问题int isPawnMove(int fromRow, int fromCol, int toRow, int toCol, ChessPiece piece) { int direction (piece.color WHITE) ? -1 : 1; int rowDiff toRow - fromRow; int colDiff toCol - fromCol; // 第一次移动可以走两格 if (colDiff 0 rowDiff 2 * direction !piece.hasMoved) { // 中间格子必须为空 if (board[fromRow direction][fromCol].type EMPTY board[toRow][toCol].type EMPTY) { return 1; } } // 走一格 if (colDiff 0 rowDiff direction board[toRow][toCol].type EMPTY) { return 1; } // 吃子斜前方一格目标位置有对方棋子 if (abs(colDiff) 1 rowDiff direction board[toRow][toCol].type ! EMPTY board[toRow][toCol].color ! piece.color) { return 1; } return 0; }这个函数完美展现了C语言中“多种条件判断”的写法每一个if分支对应一条规则规则之间用返回隔离逻辑清晰。这里的abs函数来自math.h用来算绝对值在判断斜向步数时很好用。4.3 车、象、后直线与斜线的路径检测这三种棋子的走法都是“滑动型”的它们可以沿着某个方向走任意格但中间不能有棋子阻挡。实现思路完全一致先判断方向是否合法再逐个检查中间路径上的每一个格子。int isRookMove(int fromRow, int fromCol, int toRow, int toCol) { if (fromRow ! toRow fromCol ! toCol) { return 0; // 车只能横着或竖着走 } int rowStep (toRow fromRow) ? 1 : (toRow fromRow ? -1 : 0); int colStep (toCol fromCol) ? 1 : (toCol fromCol ? -1 : 0); int row fromRow rowStep; int col fromCol colStep; // 检查路径之间的所有格子是否为空 while (row ! toRow || col ! toCol) { if (board[row][col].type ! EMPTY) { return 0; // 有棋子阻挡 } row rowStep; col colStep; } // 目标位置不能是己方棋子 if (board[toRow][toCol].type ! EMPTY board[toRow][toCol].color board[fromRow][fromCol].color) { return 0; } return 1; }rowStep和colStep的值只能是-1、0、1三种它表示每次循环沿哪个方向推进一格。这个写法很经典把方向向量显式表达出来然后用while循环一步步模拟移动过程每走一步检查一次阻挡。象的走法完全同理只是限制条件是rowDiff colDiff或rowDiff -colDiff也就是斜向45度。后就是车和象的走法取并集直接用逻辑或把两个判断拼起来就行。实现到这里你会发现一个规律棋子的走法判断本质上就是“枚举所有可能的移动目标位置然后剔除那些不满足规则的位置”。这个思路也为后面做“合法走法生成器”打好了基础。4.4 马的走法跳格子不受阻挡马的走法是最简单的L形即横向走两格加纵向走一格或者横向走一格加纵向走两格。它最大的特点是“跳子”——马移动时不会被中间的棋子阻挡。这意味着判断马的走法不需要路径检查只需要算横纵坐标差的绝对值int isKnightMove(int fromRow, int fromCol, int toRow, int toCol) { int rowDiff abs(toRow - fromRow); int colDiff abs(toCol - fromCol); return (rowDiff 2 colDiff 1) || (rowDiff 1 colDiff 2); }这种简单直接的实现方式让我觉得国际象棋的走法规则其实很适合用来练习条件逻辑。你不需要堆砌复杂的算法只需要把每种棋子的模式描述清楚翻译成C语言的if语句就可以了。4.5 王车易位与兵升变两个特殊规则的实现王车易位是国际象棋中唯一一个同时移动两个棋子的着法实现时要同时满足好几个条件王和参与易位的车都未曾移动过王和车之间的格子必须全部为空王当前没有被将军王经过的格子不能处于被攻击状态王到达的格子也不能处于被攻击状态。我最初实现时漏掉了“王经过的格子不能被攻击”这个条件结果出现了一种奇怪的情况王可以跳过被攻击的格子完成易位这是违规的。后来看国际象棋规则文档才发现问题的根源。兵升变的实现则相对简单判断兵的row是否到达了0行白方或7行黑方如果是就弹出一个选择框让玩家选择升变成后、车、象或马中的任意一个。我在代码里偷了个懒直接默认升变为后因为后是威力最大的棋子实战里95%以上的升变都是升后。但为了让程序更完整我还是加了一个简单的选择界面。5. 将军与将杀判断让程序“懂规则”5.1 怎么判断一方被将军判断将军的核心是站在被将军方的视角看看对方的任意一枚棋子按照它的走法规则能否攻击到己方王所在的格子。实现这个判断的前提是我们已经实现了所有棋子的走法判断函数。int isSquareAttacked(int row, int col, PieceColor attackerColor) { // 遍历整个棋盘 for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (board[r][c].type EMPTY) continue; if (board[r][c].color ! attackerColor) continue; // 调用走法判断看是否能攻击到(row, col) if (canMove(r, c, row, col)) { return 1; } } } return 0; }这里canMove是一个统一入口内部根据棋子的类型分发到各自的走法判断函数。这个分发逻辑我用了一张函数指针表来实现这也是C语言函数指针很典型的应用场景。int (*moveFuncs[])(int, int, int, int) { isPawnMove, isKnightMove, isBishopMove, isRookMove, isQueenMove, isKingMove };然后canMove就变成了int canMove(int fromRow, int fromCol, int toRow, int toCol) { ChessPiece piece board[fromRow][fromCol]; if (piece.type EMPTY) return 0; // 目标位置不能是己方棋子 if (board[toRow][toCol].type ! EMPTY board[toRow][toCol].color piece.color) { return 0; } return moveFuncs[piece.type](fromRow, fromCol, toRow, toCol); }这个设计让我很满意新增一种棋子虽然国际象棋不会新增时只需要注册一个函数指针核心代码一行都不用改。函数指针这个概念在书本上看起来枯燥实际一用就懂了。5.2 判断将杀遍历所有合法走法判断一方是否被将杀需要一个更复杂的逻辑如果当前己方被将军则试着枚举己方所有棋子的所有合法走法看是否存在某一步走完之后己方王不再处于被将军状态。只要存在一步就不是将杀如果一步都没有那就是将杀。这个搜索过程用伪代码描述如下int isCheckmate(PieceColor color) { // 首先如果当前没被将军那肯定不是将杀 if (!isKingInCheck(color)) return 0; // 遍历己方所有棋子 for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (board[r][c].type EMPTY || board[r][c].color ! color) continue; // 遍历所有可能的目标位置 for (int tr 0; tr 8; tr) { for (int tc 0; tc 8; tc) { if (canMove(r, c, tr, tc)) { // 模拟走这一步然后检查是否解除将军 ChessPiece backupFrom board[r][c]; ChessPiece backupTo board[tr][tc]; board[tr][tc] board[r][c]; board[r][c].type EMPTY; int stillInCheck isKingInCheck(color); // 还原 board[r][c] backupFrom; board[tr][tc] backupTo; if (!stillInCheck) { return 0; // 还有一步可以走不是将杀 } } } } } } return 1; // 无论如何都被将军将杀 }这个遍历过程很直观会感觉到“程序的判断能力其实就是枚举所有可能性”。它的时间复杂度大约O(64648)对8x8棋盘来说完全可接受程序运行时的响应依然是瞬时的。这里模拟走法的做法也值得留个印象后面做AI搜索时会复用这个思路。5.3 逼和与无效对局除了将杀还有一种情况是逼和也就是轮到某一方行棋但该方没有任何合法走法同时王并没有被将军。这种情况在真实棋局里也是和棋。在代码里判断逼和只需要在“遍历所有走法”的基础上去掉“当前被将军”这个前提条件就行int isStalemate(PieceColor color) { if (isKingInCheck(color)) return 0; // 遍历所有棋子的所有合法走法 // 如果存在任意一个合法走法返回0 // 如果完全不存在合法走法返回1 }把isCheckmate和isStalemate合在一起就是一方无法走棋的完整判断条件。这一步逻辑看起来很朴素但它是让一个棋类游戏“真正能下完”的关键。6. 鼠标交互与对局流程的落地6.1 鼠标消息的获取与处理EasyX中获取鼠标消息的方式是调用MouseHit()判断是否有未处理的消息再用GetMouseMsg()取出一条消息。这两步是配套的缺一不可。void handleMouse() { if (MouseHit()) { MOUSEMSG msg GetMouseMsg(); if (msg.uMsg WM_LBUTTONDOWN) { int row msg.y / CELL_SIZE; int col msg.x / CELL_SIZE; if (row 0 row 8 col 0 col 8) { onCellClicked(row, col); } } } }这里有一个边界防御的小细节鼠标可能点在窗口非棋盘区域比如窗口边缘或者我预留的状态栏区域因此拿到row和col之后必须先判断是否越界。如果不去判断数组下标会越界直接导致程序崩溃或者产生随机bug。onCellClicked内部执行之前说的状态机逻辑如果当前是空闲状态点击处必须是己方棋子才能选中如果当前是选中状态再判断点击处是合法目标还是另一个己方棋子后者意味着重新选择。6.2 选中高亮与合法走法提示光有简单的选中还不够为了让玩家知道哪些位置可以走我实现了“合法走法提示”每次选中一枚棋子时遍历整个棋盘把所有满足canMove且不会导致己方被将军的目标位置标记出来。这些位置在界面上用特定的颜色圆点显示。void drawLegalMoves(int fromRow, int fromCol) { for (int tr 0; tr 8; tr) { for (int tc 0; tc 8; tc) { if (canMove(fromRow, fromCol, tr, tc)) { if (!wouldBeInCheckAfterMove(fromRow, fromCol, tr, tc)) { // 在目标位置画一个半透明指示点 drawHintCircle(tr, tc); } } } } }这个提示功能在做isCheckmate判断时顺带就能实现因为它们本质上都需要遍历所有合法走法。界面上的提示点让下棋体验好了很多也方便调试——如果你发现某个位置没有被正确标记那一定是走法判断函数或者将军判断函数出了问题。6.3 轮次切换与胜负判定国际象棋是白方先行所以我用一个全局变量currentTurn来记录当前轮到的颜色初始为WHITE。每次成功走子之后做一轮判定void switchTurn() { currentTurn (currentTurn WHITE) ? BLACK : WHITE; if (isCheckmate(currentTurn)) { // 对方已经被将杀 showMessage(Checkmate! ...); gameOver 1; } else if (isStalemate(currentTurn)) { // 逼和 showMessage(Stalemate! Draw.); gameOver 1; } else if (isKingInCheck(currentTurn)) { // 被将军提示玩家应对 showMessage(Check!); } }这里每次走子后都要重新判断一次当前玩家的状态消息用EasyX的outtextxy或者MessageBox弹出来。我一开始用的printf想输出到控制台但图形窗口模式下根本看不到控制台输出这才意识到图形程序的调试输出要走图形窗口的显示方式。这也是从控制台编程转向图形编程时需要适应的地方。6.4 悔棋功能用历史栈回退国际象棋对局中悔棋并不是标准功能但作为一个练习项目实现悔棋能很好地练习栈这种数据结构。我的实现方式是维护一个“历史走法数组”每次走子时把起点、终点、被吃掉的棋子、是否易位等信息压入栈中。悔棋时弹栈恢复棋局并回退轮次。typedef struct { int fromRow, fromCol; int toRow, toCol; ChessPiece capturedPiece; ChessPiece movedPiece; int wasCastling; } MoveRecord; MoveRecord history[256]; int historyCount 0;这样做的好处是无论后面要不要加AI或者复盘功能历史记录都是现成的基础数据。而且程序的模块越拆越清晰每个功能都是独立的一块不互相纠缠。6.5 AI对战最简极大值搜索很多同学做完双人对战之后会想加一个人机对战模式我也尝试做了一个最简版本的AI。核心思路是枚举当前玩家所有合法的走法对每一种走法计算一个评估分数选择分数最高的走法。评估分数怎么算呢最简单的办法是给每种棋子赋一个分值兵1分、马3分、象3分、车5分、后9分、王1000分。然后计算当前局面下双方棋子总分的差值。int evaluateBoard() { int score 0; for (int r 0; r 8; r) { for (int c 0; c 8; c) { if (board[r][c].type EMPTY) continue; int val pieceValue(board[r][c].type); if (board[r][c].color WHITE) score val; else score - val; } } return score; }AI走子就变成遍历所有合法走法模拟走一步计算局面分取分数最高的那一步。这个一步深度的AI其实很弱因为它看不到后续的变化但对于一个C语言项目的练习来说已经足够了。如果要做得更强可以引入MinMax搜索和Alpha-Beta剪枝这就超出本文范围了但也给后续进阶留下了空间。7. 开发中踩过的坑与排错思路7.1 数组越界静默崩溃的元凶数组越界是我这个项目里遇到的第一个大坑。马的走法判断里如果目标位置在棋盘边缘附近比如row7, col7的马它在尝试计算右移两格的坐标时会得到col9直接访问board[7][9]就溢出了。更麻烦的是C语言不会主动报错它会去读取一块未定义的内存结果可能是一个随机值给程序造成各种莫名其妙的诡异行为。解决方式是在每个走法函数的入口处加上范围判断int isValidSquare(int row, int col) { return row 0 row 8 col 0 col 8; }然后在所有走法函数里对目标位置先调用一次isValidSquare不合法就直接返回0。这个函数看起来简单但加不加它程序稳定性天差地别。7.2 棋子被吃后残留忘了更新棋盘状态在走子逻辑里把棋子从起点移动到终点时必须把起点的状态设置为EMPTY。我刚开始测试时发现棋子“吃掉”对方棋子后画面上的被吃棋子还在原地看起来像是叠了两个棋子。排查后发现是落子代码只写了终点赋值忘了把起点清空。这种小错误对于有经验的人来说一眼就能看出来但当时我围着代码转了好几圈才发现。void movePiece(int fromRow, int fromCol, int toRow, int toCol) { board[toRow][toCol] board[fromRow][fromCol]; board[toRow][toCol].hasMoved 1; board[fromRow][fromCol].type EMPTY; }这里勾起了我另一个经验写完走子后不要急着写后面的功能先手动走几步棋看看棋盘状态对不对。如果基础移动都有问题后面加再多规则都是白搭。7.3 将军判断中的死循环风险实现isKingInCheck时我的第一版代码在函数内部又调用了canMove而canMove里又调用isKingInCheck因为将军时不能走那一步造成递归调用导致程序陷入死循环运行起来卡死没反应。这个问题的根因是逻辑分层混乱。解决办法是把职责划分清楚canMove只负责“在不考虑将军的情况下这一步是否符合走法规则”是否会导致将军由上层函数wouldBeInCheckAfterMove决定。这个函数先模拟走子再调用isKingInCheck走完之后立即还原局面。这样各函数的关系就是单向的不会出现循环调用。int wouldBeInCheckAfterMove(int fromRow, int fromCol, int toRow, int toCol, PieceColor color) { ChessPiece backupFrom board[fromRow][fromCol]; ChessPiece backupTo board[toRow][toCol]; // 模拟移动 board[toRow][toCol] backupFrom; board[toRow][toCol].hasMoved 1; board[fromRow][fromCol].type EMPTY; int inCheck isKingInCheck(color); // 还原 board[fromRow][fromCol] backupFrom; board[toRow][toCol] backupTo; return inCheck; }这种“备份-修改-还原”的模式非常常用在AI搜索里几乎每一步都要用到。把它理解透后面学递归搜索会轻松很多。7.4 王车易位的条件遗漏王车易位的实现细节一开始被我低估了。第一次写完测试时发现王可以隔着棋子易位也可以在被将军之后易位这完全不符合规则。后来我一行一行对照国际象棋规则文档把缺少的条件逐个补上王和车都没移动过中间路径为空王当前不被将军王经过的格子不被攻击王到达的格子不被攻击。最容易被忽略的是第4条“王经过的格子不被攻击”。我补上之后又发现了另一个问题hasMoved标志在movePiece里没有对车也做更新。也就是说如果车先移动了但它的hasMoved还是0易位判断就会误判。这两个问题修完之后易位功能才算真正可用。提示hasMoved的更新时机要覆盖到所有可能移动棋子的路径上包括走子、吃子、易位时车的移动漏一个都会导致规则判断出错。7.5 提示文字的显示位置与实际棋盘重叠EasyX默认的文字绘制是从左上角开始的直接outtextxy提示信息会画在棋盘上方和棋盘内容重叠观感很差。我在窗口底部预留了一个固定高度80像素的状态栏区域绘制棋盘时只画到640 - 80的位置文字就统一画在这个区域里。这样界面看起来整洁多了也便于玩家分辨当前状态。7.6 编译错误链接不到graphics.h最后一个很常见的问题是同学下载了项目源码之后编译报错说找不到graphics.h。这通常是因为他们的工程配置里没有把EasyX的include目录加进去或者根本没有安装EasyX。还有一个很隐蔽的问题是EasyX只支持Windows环境下的Visual Studio如果用了MinGW的gcc可能会提示找不到某些Windows API。遇到这种问题先确认IDE是VS系列再确认EasyX安装成功最后检查工程属性里的包含目录。8. 优化方向与个人经验总结8.1 界面优化的进阶思路当前版本的自绘棋子虽然能看清棋子类型但离好看还有距离。如果想进一步提升可以加载PNG素材然后用putimage配合透明贴图函数绘制。EasyX较新的版本提供了putimage的透明通道支持用起来不算复杂。除此之外可以加入行棋动画让棋子平滑地从一个格子滑到另一个格子这需要每帧计算插值位置然后重绘整体体验会上一个档次。8.2 功能扩展的几个方向做完这个基础版之后我给自己列了几个扩展方向按难度从低到高排列加入棋子音效和背景音乐EasyX有mciSendString可以播放WAV或MP3加入对局记录与导出把走法按照代数记谱法输出到文本文件加入残局挑战模式随机生成若干个棋子摆成残局让玩家破解加入完整MinMax AI用Alpha-Beta剪枝提高搜索深度。每一个方向都会引入新的C语言知识点比如文件操作、递归搜索、更复杂的数据结构等。这个项目最大的好处就在于它有足够的复杂度能不断往外延伸。8.3 动手建议从最简版开始如果你准备动手做这个项目我的建议是不要想着一步到位。先把棋盘画出来能让一颗兵移动然后实现所有棋子的走法规则再做将军判断和回合切换最后完善特殊规则和交互细节。每完成一个阶段就编译运行一次把问题解决在最小范围内。这样整个开发过程会顺畅得多也更容易保持成就感。回看我做这个项目的整个过程最大的收获不是学会了一个图形库而是第一次体会到“一个稍微完整一点的软件是怎么被拆出来、做出来、调通的”。C语言教材上的每个知识点在这个项目里都变成了实实在在能用的工具。函数不再只是书上的概念结构体也不再是抽象定义它们组合在一起真的能构建出一个可以和人互动的国际象棋游戏。如果你也正在学C语言又恰好想找个有点挑战但又不至于让人放弃的项目练手我强烈推荐试一下这个。