
简介这是一份面向Qt与C初学者及课程设计者的中国象棋人机对战完整工程源码基于Qt Creator开发适合用来练习GUI编程、算法设计与软件工程组织。项目围绕棋盘二维数组建模、各棋子走法规则校验、鼠标交互反馈、alpha-beta剪枝搜索与评估函数等核心环节展开并涵盖登录注册、单机对战、信息管理等模块可作为课程设计或毕业设计的参考蓝本。压缩包共47个文件约575KB以14个h头文件与14个cpp源文件为主体另有2个ui界面文件、1个qrc资源文件、1个pro工程文件及若干jpg、png图片素材结构清晰便于按模块阅读与二次开发。目前已有1001人学习下载读者可从中获取完整的项目目录组织方式、Qt信号槽与界面布局写法、象棋规则与AI搜索的实现思路以及可扩展为网络对战、难度分级、棋谱学习的基础框架。1. 从零写一个 Qt 中国象棋人机对战为什么“能走子”和“会下棋”是两码事很多人第一次用 Qt Creator 做中国象棋卡住的地方不是界面而是“人机对战”这四个字。棋盘画出来了棋子能拖动了规则也判了可一到电脑走棋就懵了——它要么乱走要么慢得像死机。这个标题真正要解决的是在 QtC里把棋盘表示、走法生成、局面评估和搜索算法串成一条能跑通的链路让电脑走出一步“像人”的棋。适合已经会一点 Qt 控件和 C 语法、想做一个完整桌面棋类项目的开发者。我见过太多 Demo 停在“双人对战”人机部分直接写个随机走子糊弄过去结果一运行就露馅。下面按我实际做过的顺序把这条链路拆开讲清楚。2. 棋盘与走法生成先把“规则引擎”和“界面”彻底分开2.1 为什么建议用一维数组而不是二维数组存棋盘中国象棋棋盘是 9 列 × 10 行很多人第一反应是int board[10][9]。二维数组直观但在搜索里频繁做“走一步、撤一步”时二维的坐标转换和边界判断会拖慢速度。我一般用一维数组int board[90]索引idx row * 9 col。这样走法生成、局面复制、哈希计算都变成线性操作缓存友好。棋子编码用正负区分红黑比如红方车是2黑方车是-2空位是0。这样判断敌我只需要看符号不用额外查表。// board.h #pragma once #include array #include vector enum PieceType { EMPTY 0, KING 1, // 帅/将 ADVISOR 2, // 仕/士 BISHOP 3, // 相/象 KNIGHT 4, // 马 ROOK 5, // 车 CANNON 6, // 炮 PAWN 7 // 兵/卒 }; struct Move { int from; int to; }; class Board { public: std::arrayint, 90 cells{}; // 正数红方负数黑方0 为空 bool redTurn true; // 把二维坐标转成一维索引 static int index(int row, int col) { return row * 9 col; } // 生成当前走棋方的所有合法走法 std::vectorMove generateMoves() const; };这段代码的关键是cells用std::array固定长度避免动态分配。generateMoves返回std::vectorMove每个Move只存起点和终点索引。参数上红方为正、黑方为负这个约定要贯穿整个项目后面评估函数直接按符号累加分值不用再判断颜色。2.2 马腿、象眼、炮架三个最容易写错的规则走法生成里车和炮的直线扫描好写马和象的“蹩腿”才是翻车重灾区。马走日字但如果在前进方向紧挨着的位置有棋子这个方向就不能走。象走田字田字中心有棋子就不能走。炮吃子必须隔一个棋子移动时不能隔。我建议把每个棋子的走法写成独立函数用查表加偏移量别在一个大 switch 里堆几百行。// 以马为例偏移量是 (行偏移, 列偏移, 马腿行偏移, 马腿列偏移) static const int KNIGHT_DELTA[8][4] { {-2, -1, -1, 0}, {-2, 1, -1, 0}, {-1, -2, 0, -1}, {-1, 2, 0, 1}, { 1, -2, 0, -1}, { 1, 2, 0, 1}, { 2, -1, 1, 0}, { 2, 1, 1, 0} }; void genKnightMoves(const Board b, int from, std::vectorMove out) { int r from / 9, c from % 9; int self b.cells[from]; for (auto d : KNIGHT_DELTA) { int nr r d[0], nc c d[1]; if (nr 0 || nr 9 || nc 0 || nc 8) continue; int legR r d[2], legC c d[3]; if (b.cells[Board::index(legR, legC)] ! 0) continue; // 马腿被蹩 int target b.cells[Board::index(nr, nc)]; if (target 0 || (target 0) ! (self 0)) { out.push_back({from, Board::index(nr, nc)}); } } }KNIGHT_DELTA里前两个是目标偏移后两个是马腿偏移。判断(target 0) ! (self 0)就是“目标为空或敌方”。这个写法比一堆 if-else 清晰也方便后面加“将帅不能照面”的过滤。注意将帅照面规则要在生成所有走法后统一过滤不要塞进单个棋子逻辑里。2.3 把规则引擎做成无界面依赖的纯逻辑层这一步决定了后面搜索能不能复用。我一般把Board、Move、走法生成、胜负判断全部放在一个不包含任何 Qt 头文件的模块里只依赖标准库。Qt 界面层通过信号槽调用这个模块。好处是你可以单独写一个控制台程序跑几千局自对弈来调评估函数不用启动图形界面。很多新手把规则写在QWidget的鼠标事件里结果想加搜索时发现根本没法在后台线程调用只能重写血泪经验。3. 局面评估与搜索让电脑从“随机走”变成“有想法”3.1 子力价值表不是拍脑袋要按位置给分评估函数决定电脑的“棋风”。最基础的是子力价值车 900、马 400、炮 450、兵 100过河后 200、仕相 200。但只按子力算电脑会用马换炮还觉得自己赚了。我一般再加一张位置价值表比如兵过河后每前进一行加 20 分马在中心比在边线多 30 分。这些数字不需要多精确但要有否则搜索深度再高也是“近视眼”。// 简化的位置加分兵过河后按行加分 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 base PIECE_VALUE[type]; if (type PAWN) { int row i / 9; // 红兵向上走行号减小黑卒向下走行号增大 if (p 0 row 4) base (5 - row) * 20; if (p 0 row 5) base (row - 4) * 20; } score (p 0 ? base : -base); } return b.redTurn ? score : -score; // 返回当前走棋方视角的分数 }PIECE_VALUE是个常量数组索引对应棋子类型。最后一行很关键搜索算法通常用“负极大值”形式评估函数要返回当前走棋方的相对分数红方正分、黑方负分轮到黑方走时取反。这个符号搞错电脑会主动送子别问我是怎么知道的。3.2 Alpha-Beta 剪枝不加它深度 4 就卡死极小极大搜索遍历所有走法节点数是指数级。Alpha-Beta 剪枝在理想情况下能把有效分支减半同样时间能多搜两层。实现上就是在递归里传两个参数alpha和beta发现alpha beta就返回。配合走法排序——先搜吃子、再搜其他——剪枝效果更明显。int alphaBeta(Board b, int depth, int alpha, int beta) { if (depth 0) return evaluate(b); auto moves b.generateMoves(); // 简单排序吃子的走法排前面提高剪枝率 std::sort(moves.begin(), moves.end(), [](const Move a, const Move c) { return std::abs(b.cells[a.to]) std::abs(b.cells[c.to]); }); for (auto m : moves) { int captured b.cells[m.to]; b.cells[m.to] b.cells[m.from]; b.cells[m.from] 0; b.redTurn !b.redTurn; int val -alphaBeta(b, depth - 1, -beta, -alpha); b.cells[m.from] b.cells[m.to]; b.cells[m.to] captured; b.redTurn !b.redTurn; if (val beta) return beta; if (val alpha) alpha val; } return alpha; }这段是负极大值形式注意递归调用里-beta, -alpha的取反。depth每层减一到 0 调评估。走法排序用std::sort按被吃子力降序虽然每次排序有开销但剪枝省下的节点远大于排序成本。参数上深度 4 在普通笔记本上大约几十毫秒深度 5 可能到几百毫秒深度 6 就要上秒级了所以纯 Alpha-Beta 到深度 5 左右是桌面应用的合理范围。3.3 用迭代加深配合时间控制避免界面卡死固定深度搜索有个问题开局分支多深度 5 可能算很久残局分支少深度 5 又太快。我一般用迭代加深从深度 1 开始搜逐层加深每层记录最佳走法同时检查已用时间超过设定上限比如 1 秒就停止返回上一层的结果。这样既能在简单局面搜得更深又能保证响应时间。Move searchBestMove(Board b, int maxTimeMs) { Move best{0, 0}; auto start std::chrono::steady_clock::now(); for (int depth 1; depth 6; depth) { int alpha -INF, beta INF; Move currentBest{0, 0}; auto moves b.generateMoves(); for (auto m : moves) { // 执行走法、递归、撤销逻辑同上 // 记录 currentBest } best currentBest; auto elapsed std::chrono::duration_caststd::chrono::milliseconds( std::chrono::steady_clock::now() - start).count(); if (elapsed maxTimeMs) break; } return best; }maxTimeMs设 1000 到 2000 比较合适再长用户会觉得卡。注意搜索要放在 Qt 的QThread或QtConcurrent::run里别在主线程跑否则界面直接无响应。搜索结果通过信号发回主线程更新棋盘。4. Qt 界面与搜索线程信号槽怎么接才不卡4.1 棋盘绘制用 QPainter 还是 QGraphicsScene两种都行但我更推荐QGraphicsSceneQGraphicsItem。每个棋子是一个QGraphicsPixmapItem棋盘线是QGraphicsLineItem。好处是点击检测、拖动、动画都有现成支持不用自己算坐标。棋子移动用QPropertyAnimation做平滑过渡体验比瞬间跳过去好很多。如果只是简单 DemoQPainter重写paintEvent也够但后面加动画和选中高亮会越来越麻烦。// 棋子项继承 QGraphicsPixmapItem class PieceItem : public QGraphicsPixmapItem { public: int boardIndex; PieceItem(const QPixmap pix, int idx) : QGraphicsPixmapItem(pix), boardIndex(idx) {} };boardIndex记录棋子在逻辑棋盘上的索引点击时通过scene()-itemAt拿到PieceItem再读boardIndex就知道点了哪个位置。这样界面和逻辑的映射只靠一个整数不会乱。4.2 人机对战的线程模型主线程只做界面搜索必须放后台线程。我一般用一个SearchWorker类继承QObject提供startSearch(Board)槽函数内部跑迭代加深算完发bestMoveReady(Move)信号。主线程收到信号后执行走法、更新界面、切换回合。注意Board要按值传进线程避免共享状态被同时读写。class SearchWorker : public QObject { Q_OBJECT public slots: void startSearch(Board board) { Move m searchBestMove(board, 1500); emit bestMoveReady(m); } signals: void bestMoveReady(Move m); };连接方式用Qt::QueuedConnection确保信号跨线程安全。Board里只有std::array和bool拷贝成本很低不用担心性能。4.3 走法合法性校验界面层不能信鼠标用户拖动棋子时界面层要先用规则引擎校验这步是否合法不合法就弹回原位。常见错误是只在搜索里判合法界面直接改棋盘结果用户能走出“马蹩腿”的棋。正确做法是鼠标释放时构造Move调Board::isLegal(m)返回 true 才更新逻辑棋盘和界面。isLegal内部可以复用generateMoves的结果做查找虽然每次生成全部走法有点浪费但人操作频率低完全可接受。5. 避坑与排查这 5 个问题我几乎每次都会遇到5.1 电脑走棋慢到界面假死现象点击“电脑走棋”后窗口无响应几秒后才恢复。原因搜索跑在主线程阻塞了 Qt 事件循环。解决把搜索移到QThread用信号槽回传结果。如果不想引入线程至少用QApplication::processEvents()在搜索循环里定期处理事件但这只是权宜之计不推荐。5.2 电脑反复走同一步形成循环现象人和电脑来回几步后电脑总是重复之前的走法。原因评估函数没有区分“重复局面”搜索认为分数一样就随便选。解决在Board里加一个简单的局面历史哈希搜索时如果发现当前局面在历史里出现多次评估分扣掉一个惩罚值。或者用置换表记录已搜索局面避免重复计算。5.3 将帅照面没判电脑走出非法棋现象电脑把帅走到和将同一条直线上中间无棋子。原因走法生成时只判了单个棋子规则没做全局过滤。解决在generateMoves返回前加一个过滤如果走完这步后双方将帅在同一列且中间无子则丢弃该走法。这个判断要模拟走子后再检查不能只看当前局面。5.4 搜索深度一加就崩溃现象深度调到 6 以上程序直接闪退。原因递归太深导致栈溢出或者节点数爆炸内存不够。解决限制最大深度配合迭代加深和时间控制把递归改成显式栈结构检查是否有走法生成死循环比如某个棋子能无限吃子。我一般把深度上限设在 6再高收益不明显体验反而差。5.5 评估函数符号搞反电脑主动送车现象电脑明明有优势却把车送到对方兵口里。原因评估函数返回的是红方视角分数但搜索里没按当前走棋方取反。解决统一用“当前走棋方视角”在evaluate最后一行根据redTurn决定是否取反并在递归里用负极大值形式。调试时可以先让电脑只搜一层看它选的走法是不是吃子价值最高的快速定位符号问题。6. 进阶技巧用置换表和静态搜索把棋力再提一档走到这里一个能正常对战的 Qt 中国象棋已经跑通了。但如果想让电脑“更聪明”有两个投入产出比很高的方向置换表和静态搜索。置换表用哈希表缓存已搜索局面的结果同样局面不用重复算深度 5 能省掉三成以上节点。实现上用 Zobrist 哈希给每个棋子位置生成随机数异或得到局面键搜索前查表搜索后存表。注意要存深度和分数类型精确值、上界、下界否则会出错。// Zobrist 哈希示例 static U64 zobrist[90][14]; // 90 个位置14 种棋子状态含空 U64 boardHash(const Board b) { U64 h 0; for (int i 0; i 90; i) { int p b.cells[i]; int idx (p 0) ? 0 : (p 0 ? p : 7 - p); // 映射到 0-13 h ^ zobrist[i][idx]; } return h; }静态搜索解决的是“水平线效应”搜索到深度 0 时如果还有吃子没算完评估会失真。做法是在深度 0 时不直接返回评估而是继续只搜吃子走法直到没有吃子为止。这样电脑不会因为“下一步会被吃”而误判。参数上静态搜索深度一般限制在 4 层以内否则也会慢。我自己的习惯是先把基础版本跑通能完整下完一局再逐步加置换表和静态搜索每加一个就用固定局面测试搜索节点数和耗时确认有提升再保留。不要一上来就堆高级特性否则出了问题根本不知道是哪块的锅。希望帮到你。本文还有配套的精品资源点击获取