☰
Linux下C语言+Qt开发中国象棋:从规则引擎到AI剪枝全解析
2026/10/7 5:26:42 网站建设 项目流程

在Linux下用C语言配合Qt把一个中国象棋程序从零写到能玩、能悔棋、能复盘、能存档,这个过程踩过的坑和想明白的事,比最终代码本身值钱得多。这篇文章就把整个项目的设计思路、关键实现和排坑过程完整拆开讲,覆盖棋盘绘制、走棋规则、AI搜索、悔棋复盘、存档加载和背景音乐这些模块。想自己动手写一个Linux桌面应用、对象棋AI或者Qt开发感兴趣的朋友,这篇可以直接当参考。

1. 项目拆解:一个象棋程序到底包含什么

拿到“Linux下C语言实现中国象棋”这个需求,第一反应是“不就是画个棋盘、处理点击、判断胜负嘛”。实际动手拆完才发现,一个能正常对局的象棋程序,至少包含五个互相独立的子系统:棋盘与棋子的渲染模块、走棋规则引擎、AI搜索模块、对局管理模块(悔棋、复盘、存取盘)、音频播放模块。任何一个模块单独拎出来,都有自己的深度。

当初选型时纠结过用纯C还是C++,后来定了C语言配合Qt框架。Qt在Linux下的生态足够成熟,QWidget负责界面绘制,QPainter画棋盘和棋子效率很高,QMediaPlayer能直接处理背景音乐,而且Qt的跨平台特性意味着这套代码以后想移植到Windows或macOS,改动成本非常低。更重要的是,Qt的QJsonDocument让存档解析这类工作变得极其省事,不用自己手写JSON解析器。

模块划分上我坚持了一个原则:界面逻辑和业务逻辑彻底分离。棋盘绘制模块只负责把二维数组中的局面状态画到屏幕上,规则引擎只负责生成走法和判定合法性,AI模块只接收局面状态并返回落子坐标,对局管理模块负责记录和恢复状态。这样做的好处在后来的调试中体现得淋漓尽致——AI走法不对时,我直接在终端里打印局面,跑一个命令行版本的走法搜索,根本不用打开图形界面就能定位问题。

整个程序的运行流程是这样的:用户点击棋盘坐标,界面模块把像素位置转换成行列坐标,规则引擎判断这个棋子是否属于当前走子方、目标位置是否合法,合法则更新棋盘数组并切换回合,随后触发AI思考,AI通过搜索树选出最优走法,再回到界面模块刷新。每一轮走子都会压入步数栈,供悔棋和复盘使用,属于典型的事件驱动架构。

2. 棋盘绘制与走棋交互:从坐标换算到点击拾取

2.1 棋盘坐标约定与绘制细节

棋盘是9列10行,但棋子实际落在交叉点上,不是格子里。我用二维数组board[10][9]存储局面,第一维是行(0到9),第二维是列(0到8),值为0表示空位,值为1到7表示红方棋子,值为-1到-7表示黑方棋子。红黑双方用正负号区分,判定归属时只需要检查符号,这个小设计在规则判断里省了不少事。

绘制时的坐标换算要特别注意。QPainter绘制是像素坐标,而棋盘逻辑坐标是行列,两者之间需要一个线性映射。我在Widget::paintEvent里定义棋盘左上角起点和格子边长,然后通过(x, y)到(col, row)的换算关系实现点击拾取。棋子的文本绘制比图片节省资源,我用QFont加载系统字体,红方棋子用红色、黑方棋子用黑色,文本内容分别是“帅仕相马车炮兵”和“将士象马车炮卒”。这里有个细节:相同角色的红黑双方在文本上刻意用了不同的字,比如“帅”和“将”,“兵”和“卒”,这是为了符合象棋规则里红黑双方在不同区域的名称习惯。

绘制边框时,我用drawRect画出棋盘外框,再用循环画出10条横线和9条竖线,“河界”位置的留空用一个简单的判断实现——当行的位置处于上下半场之间时,只画左右两侧的短线,形成传统棋盘的韵味。

2.2 鼠标拾取与回合控制

鼠标点击处理的逻辑看似简单,实际上有几个容易忽略的细节。第一次点击选中棋子时,需要判断这个位置确实有棋子,并且棋子的归属方是当前走子方。红方回合点击黑方棋子,程序应该直接忽略。第二次点击是落子位置,需要调用规则引擎判断这步是否合法,合法则执行移动,非法则保留选中状态继续等待。

规则引擎在执行有吃子的走法时,要在走法列表里记录被吃掉的棋子,这样才能在悔棋时精确还原。整个交互流程我用一个枚举状态机来管理:STATE_SELECT表示等待选中棋子,STATE_TARGET表示已选中棋子等待目标位置。选中的棋子绘制时我用一个半透明红色矩形框高亮,这个视觉反馈在实战对局中非常重要,否则用户根本不知道当前选中的是哪颗棋子。

除此之外,每走完一步要检查胜负。我用一个checkGameOver函数统一判断,将死、困毙、长将判负都汇总到这里。界面上通过弹窗提示结束信息,并在状态栏显示当前轮到哪一方走棋。

3. 规则引擎:走法生成与胜负判定

3.1 各棋子的走法生成

规则引擎是整个程序的基石,AI搜索、合法性检查、胜负判定全部依赖它。生成走法时,每种棋子的移动逻辑各不相同,但代码组织上可以用统一的模式——给定棋子坐标,返回一个包含所有合法目标位置的列表。

车的走法最简单,对四个方向分别做直线扫描,遇到友军停止,遇到敌军记录该点然后停止。炮稍复杂,移动时同样走直线,但吃子条件完全不同——必须隔一个棋子(炮架)才能吃掉对方棋子,所以走法生成要拆成两段:一段是常规移动扫描,遇到第一个棋子就停止;另一段是跳跃吃子扫描,找到一个炮架后继续向后找目标,遇到棋子就尝试吃子。这两个逻辑要分开写,否则会出现炮能直接跨过自己的帅的Bug。

马的“绊马腿”判断也是新手常踩的坑。马走日字,但当马紧邻的直方向有棋子时不能移动。判断方式是:目标位置与马的位置横纵坐标差为(1,2)或(2,1),检查马前进方向的临位是否有棋子阻挡。比如马要从(3,3)走到(4,5),需要检查(4,3)位置是否为空,不为空则不能走,这就是“蹩马腿”。象的“塞象眼”与此类似,只是检查的是田字中心点。士和将只能活动在九宫格内,我直接在代码里写死九宫范围:列3到5、行0到2为黑方九宫,列3到5、行7到9为红方九宫。兵的走法要注意过河前后的差异:未过河只能向前,过河后可以向前和左右,但永远不能后退。

3.2 将军判断与将死判定

将军判断的朴素思路是:走完一步后,生成对方所有棋子的吃子走法,扫描这些走法里是否包含我方将帅的位置。这个思路简单可靠,只是需要遍历全部棋子,在单个局面判断场景下性能完全够用,AI搜索里频繁调用时也能通过走法排序和裁剪弥补。

这里有一个关键点必须处理好:将帅不能照面。中国象棋规则中,将和帅之间不能有其他棋子时,双方将帅不能直接面对面出现在同一条竖线上。这个规则容易被忽略,导致AI走出离谱的送将走法。我的处理方式是:在生成将帅走法时,把“对脸”情况视为非法移动——如果两步之间没有任何棋子,则这条竖线被封锁,将帅不能往那个方向走。

将死判定是胜负判断的核心。走完一步后,如果轮到对方,对方所有合法走法都会让自己仍处于被将军状态,则对方被将死。在递归搜索中,判定逻辑要特别注意不能改变原始棋盘状态,必须采用“临时走子-判断-撤销”的模式,否则棋盘会被搜索过程污染,这是后面排查到的重大Bug来源。

3.3 局面评估与胜负边界

除了将死之外,困毙(无子可动)在中国象棋中同样算输,这一点和围棋类似,但很多初学者会忽略。我在规则引擎里实现了一个hasAnyLegalMove函数,用于检查当前方是否存在合法走法,如果没有且已被将军,则判负。这个函数在AI搜索中也是叶节点判定的重要部分。

4. 象棋AI:minimax搜索与α-β剪枝优化

4.1 为什么选择minimax而不是其他方案

象棋AI的常用方案有基于棋谱的机器学习、基于蒙特卡洛树搜索、基于经典minimax搜索这几类。考虑到这是Linux下的C项目,不依赖Python生态和重型框架,minimax配合评估函数是最务实的路线。它的核心思想是:假设双方都走最优棋,红方走一步时挑对自己最有利的走法,黑方走一步时挑对红方最不利的走法,两人轮流决策,最后在叶节点用评估函数打分。

minimax的递归实现很直观:

int minimax(int depth, int side) { if (depth == 0 || gameOver()) { return evaluate(); } int best = (side == RED) ? -INF : INF; MoveList moves = generateMoves(side); for (each move in moves) { makeMove(move); int score = minimax(depth - 1, -side); undoMove(move); if (side == RED) { best = max(best, score); } else { best = min(best, score); } } return best; }

side参数在这里用正负号标记,红方最大化分数,黑方最小化分数。这种正负交替的设计让代码非常紧凑,不需要为双方写两套逻辑。

4.2 α-β剪枝到底剪掉了什么

朴素的minimax搜索树规模是爆炸级的,每一步平均按40种合法走法来算,搜索4层深就是40的四次方——256万次局面评估,在C语言里虽然能跑但明显卡顿。α-β剪枝的意义在于:当某一层的某个分支已经确定了比上层更差的结果时,就没必要继续搜索这个分支的剩余子节点了。

α是红方能保证的最大分数下界,β是黑方能保证的最小分数上界。当α >= β时,直接剪掉这个分支。剪枝后搜索节点数量通常能减少70%到80%,这就是搜索深度4时从卡顿变成流畅的根本原因。实现时我把α和β作为递归参数一路传下去,初始值分别是负无穷和正无穷,每层的搜索代码会不断更新这两个值。

4.3 走法排序:剪枝效率的秘密武器

α-β剪枝的效率严重依赖走法搜索顺序。如果先搜索最优走法,后续的剪枝面会非常大;如果先搜索最差走法,几乎剪不掉任何分支。我做的优化是按吃子优先排序:吃车、吃马、吃炮这类大价值吃子走法排在最前面,普通走法排在后面。排序本身有开销,但相比剪枝节省的计算量,这点开销微不足道。

实际测试数据很直观:深4层搜索时,不排序走法需要评估约两百多万个节点,耗时超过3秒;按吃子优先排序后,节点数降到约三十万,耗时缩短到700毫秒左右。被吃掉棋子的价值越高,走法越排在前面,剪枝越快。

4.4 评估函数:AI水平的真正分水岭

搜索深度决定AI能看到多远的未来,但评估函数决定它怎么评价看到的局面。我的评估函数由两块组成:子力价值评估和位置价值评估。

子力价值沿用经典权重:车500分、马350分、炮300分、象200分、士200分、兵100分,过河兵额外加50分。将帅本身不给分,因为将帅被吃意味着对局结束。

位置价值是调优的重点。兵卒过河后有位置奖励,越靠近对方九宫奖励越高;马在中原位置有微弱的灵活性加分,但要避开角落;窝心马(位于九宫中心正前方)会被重点扣分。我特别加入了“将帅安全”评估——将帅周围有己方士象保护时加分,暴露在开阔地时扣分。调参时我是拿固定残局反复测试,调整权重后AI的走法风格肉眼可见地聪明了,这个反馈比单纯加深搜索层数来得明显。

5. 悔棋、复盘与存档加载

5.1 命令模式与状态快照结合

悔棋和复盘本质上都是对历史状态的回溯,但两者需要的粒度不同。悔棋只退回一步,复盘要能一步一步前进、后退。当时在“命令模式”和“状态快照”之间权衡了一下,最后选了两者结合。命令模式负责记录每一步的走法细节,状态快照负责存储关键阶段的棋盘全景。

StepRecord结构体记录一次走子的完整信息:

typedef struct { int fromX, fromY; int toX, toY; int eatenPiece; /* 被吃棋子类型,0表示空 */ int side; /* 走棋方 */ } StepRecord;

执行悔棋时,从栈顶弹出记录,把fromX, fromY位置恢复为原棋子,把toX, toY位置恢复为eatenPiece。这种逆操作比重新演算整局棋的状态要简单得多。复盘时我维护一个走法数组和一个当前索引,前进就应用走法,后退就逆操作。

5.2 状态快照的存储设计

一盘棋最多几百步,每一步步数记录加上棋盘快照,内存开销完全可以忽略。我实际测试过,一盘150步的对局,全部历史记录占用的内存不到1MB。所以我在悔棋实现上选择了“每次走子后复制一份完整棋盘状态存入堆栈”的粗暴方案。这个决策写代码时省心,排查问题时省命,值得推荐。

5.3 JSON存档格式设计

存取盘功能我用了JSON格式,数据结构是这样组织的:

{ "side": 1, "steps": [ {"fromX": 0, "fromY": 7, "toX": 0, "toY": 8, "eatenPiece": 0, "side": 1} ], "board": [ [3, 1, 0, 0, 5, 0, 0, 1, 3], ... ] }

这里同时存了当前回合、走法列表和完整棋盘。恢复局面时直接读取board数组重建棋盘,再载入steps数据供复盘使用,这样无论对局到哪一步,保存和恢复都只涉及一次解析,不需要重放全部走法。Qt的QJsonDocument和QJsonObject解析这类结构非常顺手,代码量也就二三十行。

存盘文件命名我用了时间戳后缀,避免覆盖之前的存档。加载存档时弹出一个文件选择对话框,过滤.json后缀,这一步在Linux文件系统下对路径的处理要小心,Qt的QFileDialog已经封装好了,直接用就行。

6. 背景音乐与多媒体模块

6.1 QMediaPlayer在Linux下的正确打开方式

背景音乐模块用的是Qt多媒体框架。Qt5时代QMediaPlayer可以直接播放音频,Qt6改了API,必须搭配QAudioOutput才能出声。这个API变化在Linux下尤其折磨人,因为不同发行版默认装的Qt版本不同,写代码时最好做一次版本判断。

我的做法是写一个MusicPlayer类,内部初始化时先检测QT_VERSION宏,如果是Qt6就创建QAudioOutput并设置音量,挂到QMediaPlayer上,Qt5就走旧接口。说实话,Qt在Linux下的多媒体支持一直有点玄学,对gstreamer后端的依赖经常导致播放失败,这段代码花了我一整个晚上的时间调环境。

6.2 Linux下多媒体依赖的坑

程序在开发机上能播放音乐,打包到另一台机器上没声音,九成是目标机器缺gstreamer插件。我在部署文档里特别标注了需要检查的依赖项:gstreamer1.0-plugins-base、gstreamer1.0-plugins-good以及对应的pulseaudio桥接包。用ldd检查可执行文件依赖时,libgstreamer-1.0.so.0是否存在是个重要信号。

音乐文件格式上,我改用OGG格式而不是MP3。原因有二:一是版权风险低,二是gstreamer对OGG的解码支持比MP3更不容易受专利插件限制。这在Debian系系统上尤其明显,很多发行版默认不装MP3解码器。

7. 编译部署与工程组织

7.1 qmake工程文件与模块依赖

.pro文件是Qt工程的入口,我把它配置成了这样:

QT += core gui widgets multimedia greaterThan(QT_MAJOR_VERSION, 5): QT += multimedia TARGET = chinesechess TEMPLATE = app SOURCES += main.cpp Widget.cpp BoardWidget.cpp RuleEngine.cpp AIEngine.cpp MoveRecord.cpp HEADERS += Widget.h BoardWidget.h RuleEngine.h AIEngine.h MoveRecord.h

注意multimedia模块在Qt6里还需要额外确认,有时得单独加network模块才能让gstreamer正常加载。Linux下的编译依赖管理一直是痛点,我的建议是尽量用发行版自带的Qt包,从官方源码编译Qt容易遇到OpenGL、xcb库缺失之类的连环问题。

7.2 动态库依赖与分发策略

用ldd查看编译产物时,依赖的Qt库少说有十几个,这也暴露了动态编译分发的成本。如果想发布一个免安装版本,两个方案可选。一是静态编译Qt,在configure时加-static参数,生成的可执行文件体积会增大到二十多MB,但换来的是一台机器一个文件直接跑的便利。二是打deb包,把动态库和资源文件一起打包,通过dpkg -i安装。我实际测试过,静态编译的Qt程序在深色系统主题下偶尔会出现菜单样式异常,所以最终选择了动态库加打包脚本的方案。

7.3 内存管理与指针安全

C语言的内存管理在这个项目中是个不能回避的话题。棋盘数组、走法列表、历史记录这些数据,我尽量在栈上分配,通过显式传指针的方式传给函数。象棋程序的结构天然适合这种风格:棋盘就一百个格子,一步的走法列表最多几十项,栈空间完全够用。

真正需要注意的是递归搜索里的临时缓冲区。AI搜索会频繁创建和销毁走法列表,我在每个递归层级用宏定义了一个局部数组手工管理释放时机,配合malloc/free封装,确保所有失败路径上都释放内存,避免搜索中途出异常导致泄漏。这类问题用Valgrind跑一遍就能查出来,我养成了每次调完AI模块就测一遍内存的好习惯。

8. 常见问题与排查实录

8.1 递归回溯后棋盘没还原,出现“幽灵棋子”

这是整个开发过程里最隐蔽的Bug。AI搜索时,临时走子后应该调用undoMove还原棋盘,但我在吃子走法里漏了一个细节——吃子时需要暂存被吃掉的棋子,还原时也要恢复它。少做一步的结果是搜索过程中棋盘上会凭空多出几颗棋子,AI的评估分数完全失真,走出来的棋像抽风一样。

排查这问题时用了最原始的办法:在makeMove和undoMove两个函数里加入断言,检查走子前后棋盘对方棋子数量是否一致。断言暴力打印之后,问题立刻现身。所以做象棋AI项目,临时走子-还原这一对的对称性一定要检查好,这是搜索正确性的基石。

8.2 将帅照面规则缺失,AI直接把帅送到对面脸上

这个Bug的触发场景是:红帅在中路,黑将也在中路,中间没有遮挡,AI仍然能走帅或将横移。我检查走法生成时发现,将帅的移动范围判断只检查了九宫,没有检查“对脸”状态。修起来不复杂,在将帅走法生成后加一个中间棋子扫描,确认两点之间没有其他棋子即可。但这类隐性规则最容易漏,建议在规则引擎单元测试里专门把所有特殊情况都覆盖一遍。

8.3 AI思考卡顿:评估函数频繁调用导致性能恶化

深度4搜索在剪枝优化后已经能跑到700毫秒左右,但当我加入位置评估后,耗时不降反升,一度跑到1.5秒以上。定位发现是评估函数中重复计算了将帅安全分,而这段逻辑涉及多次棋盘遍历,在几万次调用中累计开销被放大。优化方式是预处理位置价值表,把静态的位置打分提前算好存成二维数组,评估时直接查表,动态的部分只保留棋子位置带来的增量更新。优化后耗时重新回到800毫秒以内。

8.4 音频模块初始化失败,程序无声音且不报错

背景音乐无声这个坑折磨了我很久。代码逻辑没有问题,编译也没有报错,但播放时就是没声。最后发现是目标环境缺少gstreamer的PulseAudio桥接库,而Qt媒体框架的初始化是异步的,失败了也不会弹错误框。排查手段是安装gstreamer1.0-pulseaudio后立刻正常,验证了猜测。之后我写了一个音频自检函数,在启动时尝试播放一段静音并检查QMediaPlayer::mediaStatus状态,如果不正常就在界面上提示,至少不会让用户面对一个哑巴程序干瞪眼。

8.5 Qt5和Qt6 API差异导致的编译兼容问题

由于这台测试机装的是Qt5,另一台装的是Qt6,代码里到处是#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)的宏判断。最集中的差异就是QMediaPlayer的用法,其次是QRegExp到QRegularExpression的迁移。说实话,用宏维护双版本兼容是比较累的,我建议新项目直接锁定Qt6,老版本兼容只在生产环境有硬性需求时才做。

8.6 常见问题速查表

现象可能原因排查要点
棋盘画错位坐标映射写反检查行列与x/y轴对应关系
棋子点击无响应命中检测区域没算对打印点击行列坐标验证
AI走出非法走法规则引擎有漏洞用单步打印走法列表逐一核对
AI卡顿明显剪枝不生效加计数器统计搜索节点数
悔棋后局面错乱被吃子信息丢失检查undoMove还原逻辑
存档读不出来JSON字段名不匹配对比保存和加载的键名大小写
背景音乐没声音gstreamer插件缺失用ldd检查动态库依赖

回想整个开发过程,最深的体会是:象棋程序的核心难点不在界面,也不在AI,而在规则引擎的严谨性和递归调试的耐心。规则引擎一旦正确,AI搜索和胜负判定都是水到渠成的事。如果你也想写一个类似的项目,我建议按“棋盘绘制-规则引擎-AI搜索-悔棋复盘-存取盘-音乐”这个顺序推进,每一步都单独验证通过再进入下一步。另外,推荐用Git做版本管理,每完成一个模块就提交一次,这样出现致命Bug时可以用二分法快速定位引入问题的提交。最后再分享一个小技巧:AI的走法日志输出到终端,和人机对战的界面路走法对照着看,能发现很多视觉上根本看不出来的逻辑问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询