第一次用C语言写一个带图形界面的国际象棋,是在某学期的课程设计上。当时班里多数人选了贪吃蛇、扫雷,我想搞个规则更复杂点的东西——国际象棋。理由很实在:棋子种类多,走法规则差异大,能逼着自己把数组、结构体、函数、逻辑判断这些C语言核心知识全用一遍。而“带图形界面”这一项,我用的是 EasyX 图形库,配合 VS 环境,C语言就能直接画窗口、接鼠标事件,不用去啃复杂框架。这个项目做完之后,我对C语言的理解进步是跳跃式的,所以今天把完整的思路、实现细节和踩过的坑都整理出来,给想做同类项目的同学一个参考。
这篇内容适合两类人:一是学完C语言基础、想找个实战项目巩固的人,二是学校课程设计、期末项目选了国际象棋方向的同学。我会从整体设计、数据建模、EasyX 交互逻辑、核心代码、常见问题排查这几个角度完整讲一遍,你照着做出来的东西能真正跑起来,而且是完整的双人对弈版本。
1. 项目定位与整体设计思路
1.1 这个项目到底在做什么
这个项目的目标很明确:用 C 语言配合 EasyX 图形库,做出一个可运行的、窗口化的国际象棋游戏。功能上至少要满足——8×8 棋盘正确显示,黑白各 16 个棋子摆放在正确位置,玩家用鼠标点击棋子完成选中和移动,程序能判断走法是否合法,并处理吃子、胜负判定。
听起来不复杂,但真做起来你会发现它比贪吃蛇那一类项目难在“规则校验”上。国际象棋有 6 种棋子,每种走法都不一样,而 C 语言里没有对象、没有现成的集合类,所有东西都得靠结构体、二维数组、函数封装去模拟。这个“从零建模”的过程,恰恰是 C 语言学习者最缺的实战训练。
我把功能拆成了三层:数据层负责存储棋盘状态和棋子类型,逻辑层负责走法合法性判断和胜负检测,表现层负责 EasyX 窗口绘制和鼠标事件处理。数据层和逻辑层完全独立于图形界面,也就是说,哪怕你将来不想要图形界面了,这套规则引擎照样可以在控制台里跑。这么设计有个实际好处——调试规则的时候不用反复启动窗口,直接在控制台打印棋盘状态就可以了。
1.2 为什么选 EasyX 而不是其他图形方案
C 语言做图形界面,可选方案其实没那么多,尤其是对初学者。用 Windows API 原生窗口编程,写一个空白窗口就要一屏代码,别说是做棋盘了,对 C 语法还不熟的人很容易劝退。用 SDL、SFML、Qt 这类框架,功能确实强大,但它们要么是 C++ 的“主场”,要么配置环境麻烦,学习曲线偏陡。而 EasyX 是一个简单到极致的 Windows 图形库,安装之后只需要#include <graphics.h>就能调用initgraph、circle、solidrectangle、getmessage这些函数,所有图形输出、鼠标键盘消息都给你封装好了,用它来做课程设计级别的图形界面是再合适不过的。
说白了,EasyX 就像是一个给你提前砌好的毛坯房,你不用自己画图纸盖墙,只需要在房间里布置家具、接好水电。当年的 Turbo C 里有个graphics.h,很多人应该听过,EasyX 就是 Windows 版的图形库平替,而且接口设计非常直白,C 语言基础学完就能上手。
1.3 哪些人适合拿它当练手项目
如果你数组、循环、函数已经学得差不多,那这个项目就能让你把知识串成线。它不会像纯算法题那样只考逻辑,也不会像业务开发那样牵扯大量框架,它的核心就是“用最基础的 C 语法做一件完整的事”。我甚至建议刚学完结构体的人直接开搞,因为你会逼着自己去思考“棋子应该用什么结构表示”这类问题。
如果基础还比较薄弱,也不用慌。你不需要提前掌握 C 语言文件读写、指针、链表这些进阶内容,在这个项目里,指针只需要接触“参数传递”层面的用法,文件读写属于可选扩展功能。先把逻辑跑通,再谈进阶。
2. 棋盘建模与走法规则解析
2.1 棋盘和棋子到底怎么存
这是整个项目最关键的设计决策。我见过不少人一上来就用二维数组存“棋子名字符”,比如用'R'、'N'、'B'表示车、马、象,看着直观,但逻辑判断时很别扭——你要判断棋子颜色,还得先判断字符大小写。更合理的方式是用整型数组加约定值。
我采用的是int board[8][8],0 表示空格,正数表示白方棋子,负数表示黑方棋子,再用枚举或者宏定义给每种棋子规定一个绝对值:
#define EMPTY 0 #define PAWN 1 #define KNIGHT 2 #define BISHOP 3 #define ROOK 4 #define QUEEN 5 #define KING 6 // 比如 board[row][col] = 5 表示白方皇后 // board[row][col] = -2 表示黑方马这样设计的好处非常多。第一,判断棋子归属只要看正负号,正负号一乘,敌我关系就出来了。第二,判断棋子类型只需要取绝对值,写走法函数时按abs(board[fromY][fromX])分类就行。第三,数组天然就是 8×8 网格的下标,坐标换算极其方便。
还有个容易被忽略的点:棋盘的行和列下标方向要和屏幕坐标保持一致。我统一用row表示行(0 到 7,从上到下),col表示列(0 到 7,从左到右)。屏幕坐标换算公式是screenX = col * CELL_SIZE,screenY = row * CELL_SIZE。如果你的棋盘是从边界留了偏移量,记得把偏移加上。
2.2 六种棋子的走法如何拆解
这是规则部分的重头戏,也是很多人第一次写会懵的地方。逐个拆开看,其实每个都不难。
兵走法的重点在于方向性。白兵从第 6 行出发,只能向行号减小的方向前进;黑兵从第 1 行出发,行号只能增大。直走时目标格必须为空,吃子时必须斜前方一格有对方棋子,并且只能斜一格。初始位置可以选择走两格,前提是途经的一格也为空。兵走到最后一排还要升变,初学者可以先把它强制变成皇后,后面再扩展选择菜单。
马的走法国人最容易搞错的一点:中国象棋有“蹩马腿”,国际象棋的马没有蹩腿限制。判断方法就是两个方向差的绝对值组合:(abs(dx)==2 && abs(dy)==1)或者(abs(dx)==1 && abs(dy)==2)。我用这句单行判断就能覆盖所有马位。
象、车、后的走法本质上是“直线扫描”。象走斜线,车走横竖直线,后两者都能走。实现核心是方向数组加循环,比如车的方向有四个,(-1,0)、(1,0)、(0,-1)、(0,1);象的方向有四个对角线。沿着方向逐步累加,遇到边界停,遇到敌方棋子可以吃掉并停止,遇到己方棋子不能走也停止。后的走法就是车的方向加上象的方向,一共 8 个方向。
王的走法最简单,周围 8 个格子范围内,目标格不能是己方棋子就行,但这里建议先不考虑“王不能走到被将军的格子”这个约束,等将军检测函数写完了,再统一加上这个限制,否则开发顺序会乱。
2.3 将军、将死和胜负判断怎么实现
很多人做到走子功能就停了,其实最大的挑战是胜负判断。第一种做法是简化版——谁先把对方的王吃掉,谁赢。这种实现最省事:移动后如果目标位置是对方王,直接弹出胜利提示。但严格来说这不完全符合国际象棋规则,因为合法走法中你是不能用王去送死的。不过对于课程设计而言,先把基础版做出来再升级才是正确路线,一步一步来嘛。
要做得像样一点,就必须实现“将军检测”。方法不复杂:给定一个棋盘状态和某一方王的位置(kingRow, kingCol),遍历全盘所有对方棋子,逐一检查是否存在一枚棋子能走到王所在的位置。这个检查完全可以复用 2.2 里写的走法合法性判断函数——判断逻辑就是“如果把对方那枚棋子移动到王的位置,是否合法”。
有了将军检测之后,判断“将死”就更深一层:王被将军后,要尝试所有可能的逃王方案——王移动到邻近格、用己方棋子吃掉攻击者、用己方棋子挡住攻击线——只要存在任何一种合法走法能解除将军,就不算将死。这个逻辑我可以告诉你,实现起来绝对不轻松,但它是项目的“王冠”,一旦打通,整个项目的成就感完全不一样。
3. EasyX 界面设计与鼠标交互实现
3.1 环境准备和窗口初始化
先装环境。你要用 Visual Studio 社区版,官方免费,安装时勾选“使用 C++ 的桌面开发”工作负载。然后去 EasyX 官网下载对应 VS 版本的安装包,安装程序会自动识别你的 VS 版本,一键装好。装完之后新建一个 C++ 空项目,因为 VS 对 .c 文件和 .cpp 文件都能编译,但你写 C 语言代码时可以直接存成 .cpp 文件,EasyX 的接口本身是按 C++ 风格设计的,这样省心。如果你非要用纯 C 的方式写,也完全可以,函数接口都是兼容的,但编译时注意别开 C++ 专属的语法。
窗口初始化的代码很简单:
#include <graphics.h> #include <conio.h> int main() { const int CELL_SIZE = 80; initgraph(CELL_SIZE * 8, CELL_SIZE * 8); // 640x640 的窗口 // ... 绘图和逻辑 closegraph(); return 0; }initgraph是 EasyX 的核心入口,双参版本直接指定窗口宽高。棋盘 8×8,每格 80 像素,总共 640 像素,窗口正好放下。别把窗口设太大,否则棋子和格子会被拉得特别粗犷,视觉效果并不好。
3.2 绘制棋盘和棋子:从方块到立体感
画棋盘最容易的方式是双层循环:外层遍历行,内层遍历列,用solidrectangle交替填充浅黄和浅棕色。注意一个细节:国际象棋的右下角必须是白格,也就是说(row + col)为偶数时用深色,奇数时用浅色——不同写法会导致棋盘左右颠倒。
画棋子我强烈建议用“圆形底色 + 字符标识”的组合,初学者不需要任何美术素材:
setfillcolor(isWhite ? WHITE : BLACK); solidcircle(screenX + CELL_SIZE / 2, screenY + CELL_SIZE / 2, 30); settextcolor(isWhite ? BLACK : WHITE); settextstyle(24, 0, _T("Arial")); outtextxy(screenX + 25, screenY + 22, _T("♞"));用 Unicode 棋字符号,白底黑字、黑底白字,视觉效果相当不错。如果想用图片,把棋子 PNG 素材放到工程文件同目录,用loadimage加载,再用putimage绘制。图片方式更精致,但要注意路径问题——EasyX 的loadimage相对路径是相对于程序运行目录的,VS 调试时运行目录通常是Debug文件夹,素材要拷贝过去,否则就果断用绝对路径。我自己做项目时,第一版用字符,第二版才换成图片,字符版调试速度快很多。
3.3 鼠标选子、走子的完整交互逻辑
鼠标交互是整个 EasyX 项目最核心、也最容易写乱的地方。我用的是循环 + 消息队列的方式,核心思路特别直白:每帧循环里,通过peekmessage或者getmessage拿到鼠标消息,判断是不是左键按下,再拿坐标去换算棋盘行列。
整个交互流程分成四段状态:无人选中、选中己方棋子、点击落子点成功移动、点击非法位置取消或忽略。我用两个全局变量selectedRow和selectedCol记录选中棋子位置,初始设为 -1 表示没有选中。鼠标点击后的处理方法如下:
int row = y / CELL_SIZE; int col = x / CELL_SIZE; if (selectedRow == -1) { // 没选中时,点击自己一方棋子则选中 if (board[row][col] != 0 && isMyPiece(board[row][col])) { selectedRow = row; selectedCol = col; // 画高亮框提示 } } else { // 已选中,判断目标格能否按规则移动 if (isValidMove(selectedRow, selectedCol, row, col)) { movePiece(selectedRow, selectedCol, row, col); } // 无论合法不合法,都取消选中 selectedRow = -1; selectedCol = -1; }这段逻辑看着简单,却解决了交互上的两个经典痛点:误点到空位不会崩、点到不能走的位置也不会误动棋子。实际测试下来,这种状态机式的处理方式非常稳定。还有个小细节,isValidMove里需要把目标格为己方棋子这条规则单独排除掉,否则会出现“点到己方棋子被当成移动目标”的诡异局面。
3.4 防闪烁与刷新机制:双缓冲必须做
EasyX 默认是单缓冲绘图,你每画一块屏幕就会实际更新一次。如果每帧都先画背景再画棋盘再画棋子,屏幕会明显闪烁,尤其鼠标一动就重绘,根本没法看。解决办法是 EasyX 自带的双缓冲接口:
BeginBatchDraw(); // 这里画所有内容 FlushBatchDraw(); EndBatchDraw();BeginBatchDraw先把所有绘图操作缓存在内存里,FlushBatchDraw一次性把内存内容全部显示到窗口上,EndBatchDraw结束批处理。实际项目里我在主循环里只在动画结束后调用一次FlushBatchDraw,这样就算棋子移动有细微动画,也不会闪。关于重绘,一定要记住:每次鼠标事件后手动调用一次绘制函数,而不是在无限循环里硬刷Sleep(10),否则 CPU 占用直接拉满。
4. 核心代码结构与关键函数实现
4.1 全局数据与函数模块划分
C 语言项目最怕的就是所有代码堆在main里。我的模块划分大概是这样的:
- 全局区:
board[8][8]、selectedRow、selectedCol、currentTurn(用 1 表示白方,-1 表示黑方,正好和棋盘数组正负号一致)。 - 绘图区:
drawBoard()、drawPiece(int type, int screenX, int screenY)、drawScene()。 - 逻辑区:
isValidMove(int fromRow, int fromCol, int toRow, int toCol)、isKingChecked(int color)、movePiece()。 - 工具区:
boardIndex(int row, int col)、inBoard(int row, int col)。
为什么要这样分?因为调试时你可以在isValidMove里加打印,单独测试某一种棋子的走法,不需要动绘制代码。还有个原则:逻辑区不允许出现任何 EasyX 相关函数,这样规则引擎拿到了命令行程序里也能用。
4.2 isValidMove:多层判断框架
isValidMove是所有规则的入口。它要做四层检查:起点是否有己方棋子、目标是否越界、目标格是否为己方棋子(不能吃己方子)、棋子走法是否符合规则。如果前三条都通过,再派发到不同的函数:movePawn、moveKnight、moveSlidingPiece等。
拿车的直线滑动判断举例:
int moveRook(int fromRow, int fromCol, int toRow, int toCol) { if (fromRow != toRow && fromCol != toCol) return 0; // 不在同一行或同一列,非法 int stepRow = (toRow > fromRow) ? 1 : (toRow < fromRow) ? -1 : 0; int stepCol = (toCol > fromCol) ? 1 : (toCol < fromCol) ? -1 : 0; int r = fromRow + stepRow; int c = fromCol + stepCol; while (r != toRow || c != toCol) { if (board[r][c] != 0) return 0; // 中途有子挡住 r += stepRow; c += stepCol; } return 1; }这段代码的关键是用方向增量来统一处理上下左右四个方向,而不是写四种分支判断。同理,象和后的移动逻辑里也复用了这套位移思想,moveSlidingPiece接收一个方向数组,8 个方位依次扫描。
4.3 将军检测与王的安全判断
有了isValidMove,将军检测就非常顺手:
int isSquareAttacked(int row, int col, int attackerColor) { for (int r = 0; r < 8; r++) { for (int c = 0; c < 8; c++) { if (board[r][c] != 0 && isColor(board[r][c], attackerColor)) { if (isValidMove(r, c, row, col)) return 1; } } } return 0; }这个函数承担两件事:第一,移动前检查自己王是否可能被将军,如果移动后王暴露在攻击下,就判定为非法移动;第二,移动后检查对方王是否被将。严格实现时还得区分“王本身不能走到被攻击格”,所以isValidMove里在处理王时额外调用一次isSquareAttacked,把递归关系处理好。写着写着你会发现规则之间是有连锁反应的,建议先把基础走法写完并测试稳定,再接入将军逻辑。
4.4 主循环与状态管理
main函数里我使用了一个while (1)结构来处理事件和渲染:
while (1) { while (peekmessage(&msg)) { if (msg.message == WM_LBUTTONDOWN) { handleClick(msg.x, msg.y); } } drawScene(); // 判断游戏结束状态 if (gameover) break; }peekmessage相比getmessage的好处是不会阻塞——它每帧检查是否有新消息,没有就继续渲染。这对动画、高亮效果都非常友好。handleClick里做完选子和移动逻辑后,drawScene重新绘制整个棋盘。在这里我踩过一个坑:如果移动后立刻断轮换,忘记更新currentTurn,就会导致同一方连续走两步。轮换逻辑一定要写在movePiece里,currentTurn = -currentTurn,非常优雅。
5. 常见问题与调试技巧实录
5.1 高频报错与界面问题的速查表
实际编码过程中有几类问题几乎每个新手都会碰到。我把它们整理成一张速查表,方便你对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 窗口出现但全黑 | initgraph后忘记先绘制就进入getmessage阻塞 | 先调用drawScene()再进入循环 |
| 文字乱码 | 项目字符集是 Unicode,但用了窄字符串常量 | 改项目属性为“多字节字符集”或统一用_T()宏 |
| 棋盘闪烁严重 | 没有开启双缓冲 | 在绘制前后添加BeginBatchDraw/FlushBatchDraw |
| 鼠标点到格子但子不走 | 坐标换算错误,窗口实际尺寸不是 640×640 | 在handleClick里先打印msg.x / CELL_SIZE和row对比 |
| 白棋和黑棋重叠 | 棋子位置计算没用+ CELL_SIZE / 2之类居中偏移 | 画出格子后以格子中心为圆心画棋子 |
| 棋子图片不显示 | loadimage相对路径定位错误 | 把图片路径改成绝对路径测试,确定运行目录在哪 |
另一个高频问题:VS 的调试模式默认工作目录是$(ProjectDir),但可执行文件输出在Debug文件夹下,图片资源如果放在项目根目录,运行时就会找不到。简单粗暴的解决办法是把需要加载的图片文件同时放到项目目录和 Debug 目录,发布时再整理。
5.2 逻辑 bug 的调试三板斧
最怕遇见的是那种“看起来没问题但规则不对”的 bug,比如王突然满盘乱走、黑兵能倒退、马能隔山吃子。这时候不要干瞪眼,用这三招:
第一招,在isValidMove函数开头加一行调试打印,输入起点终点和当前棋子类型,看看函数到底被谁调用、传了什么参。第二招,把棋盘实时状态输出到控制台或文件里,用字符拼一个 8×8 网格,方便肉眼直接看棋形。第三招,做一个“单元测试”模式:在程序启动时自动跑一组预置局面,逐个验证走法是否按预期放行。
我自己最深刻的教训是“王走棋忘了检查目标格是否被攻击”,结果我的王能直接走进对方车嘴里,而且程序还觉得合法。这种问题不用这三招,光靠鼠标测试要跑半天才能碰上,加一个自动测试用例,几秒钟就暴露了。
5.3 代码扩展的思路:从双人对战到简单 AI
如果基础版本做完了,能力足够,可以试试三个方向:第一是增加“王车易位”和“吃过路兵”特殊规则,这需要引入更多状态变量,比如记录车和王的移动历史。第二是文件读写,把棋盘状态存成文本文件,下次启动时加载,这是练fopen、fprintf、fscanf的好机会。第三是写一个非常简单的 AI——用深度为 2 的极小化极大搜索,配上棋子价值表(王 10000,后 900,车 500,象/马 300,兵 100),就能实现一个能跟人对弈的弱 AI。搜索实现时需要用临时数组复制棋盘,然后再走子、评估、回溯,整个流程就是 C 语言指针和结构体综合练习的巅峰。
我实际做完这三个扩展之后,C 语言水平上升了一个档次,尤其是对“数据状态变更”这件事的理解,比看十遍教程都深刻。
6. 关于这个项目的最后几点体会
项目做到最后,我最大的感受是:国际象棋这个选题选得值。它的复杂度恰好卡在“能啃下来”和“不白做”的临界点上。如果你只会在控制台打印棋盘,那收获有限;但当你通过鼠标完成一次吃子,看到对方王被标记成“被将军”状态时,那种成就感真的不一样。
最后分享两个小技巧。第一,开发时一定先把规则引擎写成纯控制台版本,所有函数在窗口界面之外独立测试,通了再套 EasyX 外壳,这样调试效率能翻好几倍。第二,代码提交前把所有魔法数字改成常量,比如把80提取成const int CELL_SIZE,把8提取成const int BOARD_SIZE,别嫌麻烦,一旦你想把棋盘尺寸改小或者适配高分屏,这些常量能救你命。
我从这个项目开始,才真正把 C 语言当成一门“能做出东西的语言”来用,而不是考试会写几道题就完事。如果你也在做类似项目,不用急于求成,先把走棋逻辑跑通,再一点点加图上界面和高级规则,一定会收获远超预期的成长。