简介:这份资源面向具备一定 C++ 基础、希望借助 Qt 框架练手桌面 GUI 与博弈算法的开发者,提供一套可运行的中国象棋人机对战完整工程。项目以 Qt Creator 为开发环境,围绕棋盘二维数组建模、各棋子走法规则校验、鼠标交互落子、基于搜索与评估函数的人机决策以及胜负判定与流程管理等环节展开,适合作为课程设计、毕业设计或 Qt 入门后的进阶实战案例。压缩包共 47 个文件,约 575KB,以 14 个 .h 头文件与 14 个 .cpp 源文件构成核心逻辑,配合 2 个 .ui 界面文件、1 个 .qrc 资源文件及若干 jpg、png 图片素材,另有 .pro 工程配置与 README 说明,目录组织清晰,便于按模块阅读与二次开发。目前已有 1002 人学习下载。读者可从中获得一套结构完整的人机对战实现参考,理解界面与逻辑分层、棋子规则封装及 AI 搜索评估的落地方式,并在此基础上扩展难度等级、棋谱记录或网络对战等功能。
1. 一个能跑起来的中国象棋人机对战:从棋盘到AI落子的完整工程
很多人第一次接触 Qt 项目,都是从“能画个界面”开始的,但真正能把界面、规则、搜索算法串成一条完整链路的练手项目并不多。这个基于 Qt Creator 和 C++ 实现的中国象棋人机对战,恰好卡在一个很舒服的位置:棋盘绘制、走子规则、局面评估、极小化极大搜索这几块都得自己写,但又不至于像通用棋类引擎那样庞大到读不动。它适合两类人:一类是刚学完 C++ 和 Qt 基础控件、想找一个“有真实逻辑”的项目练手的开发者;另一类是做过五子棋、井字棋,想看看象棋这种走法复杂、状态空间大的棋类,AI 部分到底该怎么组织代码的人。整个项目围绕 Qt Widgets 的绘图事件和信号槽展开,人机对战的核心不在界面,而在“轮到电脑走”那一刻,程序如何生成合法着法、如何评估局面、如何选出一个不犯傻的落子点。
2. 工程结构与技术选型:Qt Widgets 加纯 C++ 规则引擎
2.1 为什么用 Qt Widgets 而不是 QML
这个项目的界面部分没有走 QML 路线,而是用 Qt Widgets 的QWidget加paintEvent手绘棋盘。原因很实际:象棋棋盘是固定 9 列 10 行的网格,棋子是圆形加汉字,用QPainter画线、画圆、drawText写“车马炮”完全够用,而且坐标计算直接对应棋盘数组下标,调试时一眼就能对上。QML 虽然动画漂亮,但要把点击坐标映射回棋盘行列、再和 C++ 规则层通信,反而多一层胶水代码。项目里常见的做法是主窗口继承QWidget,重写paintEvent负责绘制,重写mousePressEvent负责把像素坐标换算成棋盘坐标,规则判断和 AI 搜索放在独立的类里,界面只负责“显示”和“转发点击”。
技术栈上,除了 Qt Core 和 Qt Gui,基本不依赖第三方库。棋盘状态用一个二维数组或一维数组表示,棋子用枚举区分红黑和兵种,走法生成用循环加边界判断,搜索部分自己实现。这种“零外部依赖”的结构对新手很友好,克隆下来用 Qt Creator 打开.pro文件就能编译,不需要配 CMake 包管理器或者额外装库。
2.2 目录与类的职责划分
一个能维护的 Qt 象棋项目,类不会全堆在mainwindow.cpp里。常见的拆分方式是四层:界面层、棋盘数据层、规则层、AI 层。下面是一个典型的文件组织,具体文件名可能不同,但职责边界值得参考。
xiangqi/ ├── xiangqi.pro # qmake 工程文件 ├── main.cpp # 入口,创建 QApplication 和主窗口 ├── mainwindow.h/.cpp # 主窗口:绘图、鼠标事件、人机轮转控制 ├── board.h/.cpp # 棋盘数据:棋子数组、初始化、走子/撤销 ├── piece.h # 棋子枚举与基础结构 ├── movegen.h/.cpp # 走法生成:各兵种合法着法、将军判断 ├── evaluate.h/.cpp # 局面评估:子力价值、位置分 └── engine.h/.cpp # AI 搜索:极小化极大 + Alpha-Beta 剪枝mainwindow只做三件事:把board画出来、把鼠标点击翻译成“从哪到哪”、在玩家走完后触发engine计算电脑的回应。board管状态,movegen管合法性,evaluate管打分,engine管搜索。这样拆的好处是,调 AI 强弱时只动engine和evaluate,调界面时完全不碰规则代码,改错也不会互相牵连。
2.3 qmake 工程文件的关键配置
用 Qt Creator 新建项目时默认生成.pro文件,这个项目不需要额外模块,但有几个点要注意。下面是精简后的工程配置,注释里标了每行的作用。
QT += core gui # 只需要核心和界面模块,不用 network/sql greaterThan(QT_MAJOR_VERSION, 4): QT += widgets # Qt5 及以上要显式加 widgets TARGET = xiangqi # 生成的可执行文件名 TEMPLATE = app # 应用程序模板 SOURCES += \ main.cpp \ mainwindow.cpp \ board.cpp \ movegen.cpp \ evaluate.cpp \ engine.cpp HEADERS += \ mainwindow.h \ board.h \ piece.h \ movegen.h \ evaluate.h \ engine.hQT += core gui是必须的,widgets在 Qt5 之后从 gui 里独立出来,漏写会报找不到QWidget。TEMPLATE = app表示生成可执行程序而不是库。如果编译时提示某个.cpp里的符号未定义,先检查SOURCES里有没有漏加文件,这是新手最常见的翻车点之一。
3. 棋盘与走子规则:把象棋规则翻译成数组操作
3.1 棋盘数据结构与初始化
棋盘用一维数组存 90 个格子,索引row * 9 + col,行 0 到 9 从上到下,列 0 到 8 从左到右。棋子用整数编码,正数代表红方,负数代表黑方,绝对值对应兵种。这种编码的好处是判断敌我只需看符号,取兵种只需取绝对值。
// piece.h enum PieceType { EMPTY = 0, KING = 1, // 帅/将 ADVISOR = 2, // 仕/士 BISHOP = 3, // 相/象 KNIGHT = 4, // 马 ROOK = 5, // 车 CANNON = 6, // 炮 PAWN = 7 // 兵/卒 }; // board.h class Board { public: int cells[90]; // 正数红方,负数黑方,0 为空 void init(); // 摆初始局面 bool movePiece(int from, int to); // 执行走子,返回是否成功 void undoMove(int from, int to, int captured); // 撤销,用于搜索回溯 };init()里按固定位置填子,红方在下(行 7 到 9),黑方在上(行 0 到 2)。这里容易出错的是“马腿”和“象眼”的坐标换算,因为一维索引做行列出界判断时,不能简单用index ± 1判断左右,必须先把索引拆成row = idx / 9、col = idx % 9,否则第 0 列往左会绕到上一行最后一列,产生“穿墙”走法。我一般会在走法生成里统一用行列坐标计算,最后再转回一维索引,虽然多几行代码,但边界判断清晰得多。
3.2 各兵种走法生成的核心逻辑
走法生成是规则层的核心,每个兵种一个函数,输入当前棋盘和起点,输出所有合法终点。下面以马和炮为例,这两个最能体现象棋规则的特殊性。
// movegen.cpp 片段:生成马的走法 void genKnightMoves(const Board& b, int from, std::vector<int>& moves) { int row = from / 9, col = from % 9; int side = (b.cells[from] > 0) ? 1 : -1; // 红为正,黑为负 // 八个方向:先走直线一格(马腿),再走斜线一格 const int dr[8] = {-2, -2, -1, 1, 2, 2, 1, -1}; const int dc[8] = {-1, 1, 2, 2, 1, -1, -2, -2}; const int legR[8] = {-1, -1, 0, 0, 1, 1, 0, 0}; // 马腿所在行偏移 const int legC[8] = {0, 0, 1, 1, 0, 0, -1, -1}; // 马腿所在列偏移 for (int i = 0; i < 8; ++i) { int nr = row + dr[i], nc = col + dc[i]; int lr = row + legR[i], lc = col + legC[i]; if (nr < 0 || nr > 9 || nc < 0 || nc > 8) continue; // 出界 if (b.cells[lr * 9 + lc] != 0) continue; // 蹩马腿 int target = b.cells[nr * 9 + nc]; if (target * side <= 0) moves.push_back(nr * 9 + nc); // 空位或敌子 } }这段代码里dr/dc是八个落点方向,legR/legC是对应的马腿位置。判断target * side <= 0的意思是:目标格为空(0)或者是敌方棋子(符号相反,乘积为负),不能吃己方子。炮的走法不同,炮吃子必须隔一个“炮架”,所以生成时要沿四个方向扫描,遇到第一个子记下位置,继续扫到第二个子,如果是敌子才能吃。
// movegen.cpp 片段:生成炮的走法 void genCannonMoves(const Board& b, int from, std::vector<int>& moves) { int row = from / 9, col = from % 9; int side = (b.cells[from] > 0) ? 1 : -1; const int dr[4] = {-1, 1, 0, 0}; const int dc[4] = {0, 0, -1, 1}; for (int d = 0; d < 4; ++d) { int r = row + dr[d], c = col + dc[d]; bool hasScreen = false; // 是否已经遇到炮架 while (r >= 0 && r <= 9 && c >= 0 && c <= 8) { int idx = r * 9 + c; if (!hasScreen) { if (b.cells[idx] == 0) { moves.push_back(idx); // 无炮架时只能走空位 } else { hasScreen = true; // 遇到第一个子,成为炮架 } } else { if (b.cells[idx] != 0) { if (b.cells[idx] * side < 0) moves.push_back(idx); // 隔山打敌子 break; // 无论敌我,第二个子后停止 } } r += dr[d]; c += dc[d]; } } }参数说明:from是起点一维索引,moves收集合法终点。炮的逻辑关键是hasScreen状态位,没遇到炮架前只能走空,遇到后只找第一个非空子,是敌方才加入结果。这里如果写成“遇到敌子就吃”而忘了break,炮会穿过多个子连续吃,属于典型规则错误,测试时用炮在中路隔子打将很容易暴露。
3.3 将军判断与合法着法过滤
生成完伪合法着法后,还要过滤掉“走完后自己被将军”的着法。常见做法是在Board上先执行走子,调用isKingAttacked(side)检查己方帅/将是否被对方任一棋子攻击到,如果被攻击就撤销这步。isKingAttacked可以复用走法生成:遍历对方所有棋子,看它们的伪合法着法是否包含己方帅/将的位置。这样写虽然慢一点,但代码复用度高,搜索层做深度剪枝后整体还能接受。注意“将帅照面”也是将军的一种,两个帅/将在同一列且中间无子时,走成照面的一方判负,这个规则要在isKingAttacked里单独判断,漏掉的话 AI 会走出送帅的着法。
4. AI 搜索与评估:让电脑走出“像样”的棋
4.1 极小化极大框架与 Alpha-Beta 剪枝
人机对战的核心在engine类。最基础的框架是极小化极大:红方(玩家)走时选让局面分最高的着法,黑方(电脑)走时选让局面分最低的着法,递归到指定深度后调用评估函数返回分数。纯极小化极大在象棋上跑不深,所以必须加 Alpha-Beta 剪枝,把不可能影响结果的分支提前砍掉。
// engine.cpp 片段:带 Alpha-Beta 的搜索 int Engine::search(Board& b, int depth, int alpha, int beta, bool redTurn) { if (depth == 0) return evaluate(b); // 到达叶子,返回局面分 std::vector<int> moves; generateAllMoves(b, redTurn, moves); // 生成当前方所有合法着法 if (moves.empty()) return redTurn ? -MATE : MATE; // 无棋可走,被将死或困毙 if (redTurn) { int best = -INF; for (int mv : moves) { int from = mv / 100, to = mv % 100; // 编码起点终点 int captured = b.cells[to]; b.movePiece(from, to); int score = search(b, depth - 1, alpha, beta, false); b.undoMove(from, to, captured); best = std::max(best, score); alpha = std::max(alpha, best); if (alpha >= beta) break; // 剪枝 } return best; } else { int best = INF; for (int mv : moves) { int from = mv / 100, to = mv % 100; int captured = b.cells[to]; b.movePiece(from, to); int score = search(b, depth - 1, alpha, beta, true); b.undoMove(from, to, captured); best = std::min(best, score); beta = std::min(beta, best); if (alpha >= beta) break; } return best; } }参数说明:depth是剩余搜索层数,alpha是当前最大下界,beta是最小上界,redTurn区分轮到哪方。着法用from * 100 + to编码成一个整数,省去结构体传参。MATE是一个很大的分数,表示将死。剪枝条件alpha >= beta的意思是:当前分支已经不可能比祖先节点已有的选择更好,直接放弃。这里最容易翻车的是movePiece和undoMove必须严格配对,搜索里任何一条提前return的路径如果漏了撤销,棋盘状态就脏了,后面所有评估都是错的,而且这种 bug 不会崩溃,只会让 AI 越走越怪,属于典型的“黑匣子”问题。
4.2 局面评估函数的设计
评估函数决定 AI 的“棋感”。最基础的是子力价值:车 900、马 400、炮 450、兵 100(过河后加分)、仕相 200,帅/将给一个极大值。光有子力不够,还要加位置分,比如兵过河加 50、马在中心加 20、车占开放线加分。下面是一个简化版评估,红方为正、黑方为负,最后返回红方视角的总分。
// evaluate.cpp 片段 int evaluate(const Board& b) { int score = 0; for (int i = 0; i < 90; ++i) { int p = b.cells[i]; if (p == 0) continue; int type = std::abs(p); int side = (p > 0) ? 1 : -1; int value = 0; switch (type) { case ROOK: value = 900; break; case CANNON: value = 450; break; case KNIGHT: value = 400; break; case ADVISOR:value = 200; break; case BISHOP: value = 200; break; case PAWN: value = 100; break; case KING: value = 10000; break; } // 兵过河加分:红兵行号小于 5 表示已过河 if (type == PAWN) { int row = i / 9; if ((side > 0 && row < 5) || (side < 0 && row > 4)) value += 50; } score += side * value; } return score; }这个评估很粗糙,但足够让 AI 不白送子。想让它更强,可以在evaluate里加机动性分(合法着法数量)、加“车马炮是否被牵制”的简单判断,或者给不同兵种配位置权重表。注意评估函数必须对称:红方车和黑方车在对称位置上的分值要一致,否则 AI 会莫名其妙偏爱某一方,调试时可以用空棋盘摆对称子力验证。
4.3 搜索深度与性能的平衡
纯 C++ 加 Alpha-Beta,深度 4 到 5 层在普通机器上大概几百毫秒到一两秒,再深就要加着法排序(先搜吃子、将军等着法)和置换表。项目里常见的做法是把深度做成可配置,界面上给个“简单/普通/困难”下拉框,简单对应深度 2,普通深度 4,困难深度 6 加简单排序。着法排序的一个低成本技巧是:吃子着法按被吃子价值减走子价值排序,先搜“车吃马”这种赚的,剪枝效率会明显提升。如果发现电脑思考时间过长卡住界面,说明搜索跑在主线程里,需要挪到QThread或用QtConcurrent::run,搜索期间禁用玩家点击,算完再通过信号槽把落子结果发回界面。
5. 避坑与常见问题:编译、规则、搜索三层排查
5.1 编译报错找不到 QWidget 或链接失败
现象:Qt Creator 里代码没红线,但编译提示QWidget: No such file or directory,或者链接阶段报一堆undefined reference。原因通常是.pro里漏了QT += widgets,或者新增的.cpp没加进SOURCES。解决:先检查.pro的QT行,Qt5 以上必须显式写widgets;再检查报未定义的符号属于哪个类,把对应源文件补进SOURCES,然后执行“构建 → 执行 qmake”再重新编译,只点构建有时不会重新读.pro。
5.2 棋子能穿墙或走出棋盘
现象:马从第 0 列往左跳,结果跳到了上一行第 8 列;或者车沿着边线走一圈回到原地。原因是一维索引直接做±1加减,没有拆行列判断边界。解决:所有走法生成统一先算row = idx / 9、col = idx % 9,在行列坐标上判断0 <= row <= 9、0 <= col <= 8,最后再合成row * 9 + col。改完后用“马在四个角、车贴边”的用例逐个验证。
5.3 AI 思考后棋盘状态错乱
现象:电脑走完一步后,某个棋子凭空消失,或者玩家能吃自己的子。原因多半是搜索里movePiece后某条分支提前返回,没有执行undoMove,导致棋盘被改脏。解决:把“走子—递归—撤销”写成固定三行,中间不要插return;或者用 RAII 思路包一个临时走子对象,析构时自动撤销。调试时可以在每次搜索返回后打印棋盘哈希,和搜索前对比,不一致就说明有泄漏的走子。
5.4 电脑一直送子或原地打转
现象:AI 明明有子可吃却不吃,或者反复走同一个子。原因通常是评估函数符号写反(红黑视角混了),或者搜索里redTurn传参错误,导致电脑在替玩家选“最差”着法。解决:先写一个固定局面的单元测试,手动摆一个“红车能吃黑马”的局面,调用搜索看返回的着法是不是吃马;再检查evaluate返回值的正负是否和redTurn对应。另一个常见原因是深度为 0 时直接返回评估分,但评估分没有考虑“轮到谁走”,导致静态局面分和搜索层视角不一致。
5.5 点击落子位置偏移
现象:鼠标点在第 3 行第 4 列,棋子却落在第 2 行第 5 列。原因是paintEvent里棋盘左上角有边距,鼠标坐标换算时没减掉边距,或者格子宽高用了浮点但取整方式不一致。解决:把棋盘边距、格子宽高定义成常量,绘制和点击换算共用同一组常量;换算时用(x - marginX) / cellW并做四舍五入,而不是直接整除。改完在棋盘四个角和中心各点一次,确认落点准确。
6. 二次开发:从“能下”到“下得好”的改造路径
拿到这个项目后,最值得先动的不是界面皮肤,而是搜索层的着法排序和置换表,因为这两块对棋力提升最直接,改动也相对独立。我的习惯是先在engine里加一个简单的着法排序:生成着法后按“吃子收益”降序排,吃子收益用被吃子价值减走子价值估算,将军着法额外加权重。只加这一步,同样深度下搜索节点数通常能降不少,电脑落子会明显更快,深度也能往上提一层。
// 着法排序示例:吃子优先 void sortMoves(Board& b, std::vector<int>& moves) { std::sort(moves.begin(), moves.end(), [&](int a, int c) { int toA = a % 100, toC = c % 100; int capA = std::abs(b.cells[toA]); // 被吃子价值 int capC = std::abs(b.cells[toC]); return capA > capC; // 吃大子的着法排前面 }); }排序放在search生成着法之后、循环之前,参数就是当前棋盘和着法列表。注意排序只影响着法搜索顺序,不改变最终结果,所以即使排序逻辑不完美也不会让 AI 下错棋,属于低风险改动。验证方法是:同一局面、同一深度,对比加排序前后搜索的节点计数(可以在search入口加个静态计数器),节点数下降就说明剪枝生效。
第二个改造点是评估函数的位置分。子力价值只能让 AI 不送子,但走不出“占中路、控肋道”的感觉。可以给每个兵种配一张 90 格的位置权重表,红方和黑方镜像使用,评估时查表累加。这张表不用一次写完美,先给兵过河、马占中心、车占开放线加几档分,跑几盘看 AI 是否还愿意把马往边角跳,再微调数值。这里有个血泪经验:位置分不要加得太猛,否则 AI 会为了“占好位置”白丢子,子力分和位置分的量级要拉开,通常位置分单项不超过对应子力价值的十分之一。
第三个方向是把搜索挪到后台线程。界面卡顿是这类项目最常见的体验问题,改法是新建一个QThread子类或QObject加moveToThread,把Engine::search放进去跑,算完后用信号把from/to发回主线程执行落子。注意搜索期间要禁用玩家点击,用一个bool waiting标志在mousePressEvent里提前返回,否则玩家在电脑思考时又点一步,棋盘状态就乱了。线程间只传着法编码,不传棋盘指针,避免数据竞争。
最后是验证方法:每改一次 AI,固定摆三个局面——开局、中局有子可吃、残局将死——分别让电脑执黑走一步,看落子是否合理;再跑一局完整对战,观察有没有卡死、有没有非法着法。我一般会在movePiece里加一个断言,检查起点非空、终点不是己方子,一旦触发就打印棋盘,这样规则层的错误在开发期就能暴露,不用等到对局中途才发现。从那以后我每次改搜索或评估,都强制先跑这三个固定局面再开新局,省了很多回头查 bug 的时间。希望帮到你。
本文还有配套的精品资源,点击获取