简介:一个基于Qt Creator与C++实现的中国象棋人机对战完整工程,面向有C++基础、希望系统掌握Qt界面开发与棋类AI算法的学习者。程序用二维数组构建9行10列棋盘,存储红黑双方32枚棋子的颜色与类型,完整实现车、马、炮、兵等各类棋子的走法与吃子判定,通过鼠标点击完成选子、走子和反馈。人机部分采用alpha-beta剪枝搜索结合评估函数,让电脑能够评估局面并选择较优着法,是理解博弈树搜索和GUI事件循环的典型项目。工程还包含登录注册、用户信息管理等模块,体现较完整的软件设计思路。压缩包为zip格式,共47个文件,包括14个C++源文件、14个头文件、2个Qt界面文件、qrc资源文件及多张图片素材,总大小约575KB,目录按Stone、Board、SingleGame、MainWnd等模块划分,导入Qt Creator即可编译运行。目前已有1001人学习下载,非常适合作为课程设计、毕业设计或入门Qt游戏开发的参考实现。
1. 中国象棋人机对战这个 Qt/C++ 项目:从画棋盘到剪枝搜索
中国象棋人机对战这个 Qt/C++ 工程,第一眼看是个不起眼的桌面小项目,拆完源码才发现它把「界面绘制、鼠标交互、走法生成、规则校验、局面评估、Alpha-Beta 搜索」整条链路全走通了,而且全程只依赖 Qt Widgets,不碰第三方库。很多从业者想找这类源码复现,结果要么只实现了人人对战,要么 AI 只是随机走子凑数,根本谈不上「对战」。这份资源的价值在于:它让「电脑下一步棋」不再是一个黑匣子,而是你可以在一个下午里读完、改完、跑起来的完整代码。适合正在学 Qt/C++ 的开发者,也适合想给毕业设计换一个能演示搜索算法的桌面应用的在校生。下面我从数据结构开始,一步步拆到搜索和交互,最后把最容易翻车的几个坑按「现象、原因、解决」列给你。
2. 棋盘与棋子的数据建模:二维数组、枚举与坐标换算
2.1 棋盘数据结构:10 行 9 列二维数组是最稳的底座
象棋棋盘是 10 行 9 列,很多初学者会想用 QGraphicsScene 摆图形项,或者用一个自定义类封装每个格子。等你真正写搜索算法时会发现,AI 每秒钟要生成、评估成千上万个局面,界面对象在这里完全派不上用场。这套项目里用的是最朴素也最好调试的做法:一个int board[10][9]二维数组,每个元素存一个整数,0 表示空,正数表示红方棋子,负数表示黑方棋子。真正的优势在于:搜索的时候直接操作数组,不需要跟任何 QGraphicsItem 打交道。
// BoardState.h #pragma once #include <QVector> enum PieceId { EMPTY = 0, RED_KING = 1, // 帅 RED_GUARD = 2, // 仕 RED_ELEPHANT= 3, // 相 RED_HORSE = 4, // 马 RED_CHARIOT = 5, // 车 RED_CANNON = 6, // 炮 RED_SOLDIER = 7, // 兵 BLACK_KING = -1, BLACK_GUARD = -2, BLACK_ELEPHANT = -3, BLACK_HORSE = -4, BLACK_CHARIOT = -5, BLACK_CANNON = -6, BLACK_SOLDIER = -7 }; struct BoardState { int board[10][9]; // 行 0~9,列 0~8 bool redTurn; // true = 红方走 };这里用正负号区分红黑,判红黑只需要一个if (val > 0),评估函数里也能直接用符号决定加减分。redTurn这个标志位很关键,它不只在界面层用,搜索递归时也要跟着翻转。我不建议把它省略掉再靠轮次去推断,因为悔棋、初始化、新局都需要显式恢复这一栏状态。
2.2 界面逻辑:QPainter 绘制与鼠标坐标换算
棋盘绘制和棋子绘制是两套逻辑,但共用同一套坐标换算。常见做法是在 QWidget 的paintEvent里用 QPainter 画 10 条横线、9 条竖线、炮位十字线、九宫斜线,然后把棋子画成带颜色的圆圈加汉字。因为「汉字 + 圆形」是标准显示方案,drawText的字体大小要随格子间距缩放,否则窗口拉伸后文字和圆圈比例失调。
// BoardWidget::paintEvent 核心片段 void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // boardOriginX / boardOriginY / gridSize 是成员变量,构造时计算 int ox = boardOriginX, oy = boardOriginY, gs = gridSize; // 画横线: 10 行 -> 9 条横线 for (int row = 0; row < 10; row++) { painter.drawLine(ox, oy + row * gs, ox + 8 * gs, oy + row * gs); } // 画竖线: 9 列 for (int col = 0; col < 9; col++) { painter.drawLine(ox + col * gs, oy, ox + col * gs, oy + 9 * gs); } // 画九宫斜线、河界文字等略... }坐标换算是新手最容易写错的地方。项目里统一封装两个函数,所有鼠标事件、绘制、动画都走这两个函数,不要在任何地方手写像素偏移:
QPoint boardToScreen(const BoardState &s, int row, int col) { int x = boardOriginX + col * gridSize; int y = boardOriginY + row * gridSize; return QPoint(x, y); } bool screenToBoard(const QPoint &pt, int &row, int &col) { int c = (pt.x() - boardOriginX + gridSize / 2) / gridSize; int r = (pt.y() - boardOriginY + gridSize / 2) / gridSize; if (r < 0 || r > 9 || c < 0 || c > 8) return false; row = r; col = c; return true; }这里gridSize一般取 60 像素,boardOriginX/Y在窗口尺寸变化时按(width() - 8 * gridSize) / 2重新计算,保证棋盘居中。注意screenToBoard加了gridSize / 2的容差,这是为了让用户点到格子边缘时也能正确落到最近的交叉点。少了这半步,靠近边线的棋子会很难点中,玩起来非常别扭。
3. 走法生成与规则校验:先列候选走法再统一过滤
3.1 走法数据结构与方向数组
搜索和 UI 共用一套走法结构。项目里定义Move { fromRow, fromCol, toRow, toCol },外加一个score字段用于排序。走法生成的核心思路是:不直接判断某个目标点是否能走,而是先按棋子的移动模式枚举出候选目标点,再统一交给合法性过滤器。这样的好处是规则校验集中在一处,不会出现「车能斜走」这种低级 bug。
struct Move { int fromRow, fromCol; int toRow, toCol; int score; // 简单吃子分值,用于排序 }; QVector<Move> generateMoves(const BoardState &s, bool forRed) { QVector<Move> moves; for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { int piece = s.board[r][c]; if (piece == EMPTY) continue; if (forRed && piece < 0) continue; // 只产红方走法 if (!forRed && piece > 0) continue; // 只产黑方走法 // 按棋子类型分发到不同生成函数 switch (abs(piece)) { case RED_CHARIOT: addChariotMoves(s, r, c, moves); break; case RED_CANNON: addCannonMoves(s, r, c, moves); break; case RED_HORSE: addHorseMoves(s, r, c, moves); break; case RED_ELEPHANT: addElephantMoves(s, r, c, moves); break; case RED_GUARD: addGuardMoves(s, r, c, moves); break; case RED_KING: addKingMoves(s, r, c, moves); break; case RED_SOLDIER: addSoldierMoves(s, r, c, moves); break; } } } return moves; }参数说明:forRed决定生成哪一方的走法,搜索时黑白双方交替传入。abs(piece)让红黑双方共用同一套方向数组,大大减少重复代码。比如马的 8 个跳跃点定义成{-2,-1},{-2,1},{-1,-2},{-1,2},{1,-2},{1,2},{2,-1},{2,1},对应的马腿位置就是{-1,0},{-1,0},{-0,-1},{-0,1},{0,-1},{0,1},{1,0},{1,0},两组数组按下标一一对应,不需要为红黑各写一套。
3.2 规则校验:马腿、象眼、九宫与将帅对脸
候选走法生成之后,统一过一个isLegalMove过滤器。这个过滤器要做两件事:第一,目标格不能是己方棋子;第二,走完一步之后己方将/帅不能被将军。第二点往往被忽略,但它可以顺带解决「将帅对脸」「送将」等所有非法局面,不用为每种棋子单独写特殊判断。
bool isLegalMove(BoardState &s, const Move &m, bool forRed) { int target = s.board[m.toRow][m.toCol]; if (forRed && target > 0) return false; // 目标格是红方棋子 if (!forRed && target < 0) return false; // 先备份,再模拟走子 BoardState backup = s; s.board[m.toRow][m.toCol] = s.board[m.fromRow][m.fromCol]; s.board[m.fromRow][m.fromCol] = EMPTY; bool legal = !isInCheck(s, forRed); s = backup; // 无脑恢复,简单可靠 return legal; }这里我特意强调「无脑恢复」而不是「再走回去」,因为走子过程中可能吃子、可能移动将帅,恢复现场比反向移动容易出错。isInCheck的实现用了一个「反推」技巧:找到己方将/帅的位置,然后检查是否有对方的车、炮、马能一步吃到它。判断炮时要数中间隔子的数量,判断马时要检查马腿,这两个地方是 bug 高发区。将帅对脸的局面在isInCheck里也要处理:如果在同一列且中间无棋子,直接判被将军。
提示:当走法生成和合法性校验分开后,调试会非常舒服。遇到「AI 走出送将的臭棋」这类问题,先在generateMoves返回列表里打断点,再进isLegalMove逐条过滤,基本十分钟能定位。
4. Alpha-Beta 搜索与评估函数:深度 4 层控制在 1 秒内的关键配置
4.1 为什么不能穷举:分支因子与搜索层数
象棋平均每个局面有 40~50 个合法走法,如果做全深度穷举,6 层就要评估约45^6 ≈ 83 亿个叶子节点,单线程 C++ 也跑不动。这也是很多人写了评估函数却卡死的原因——没剪枝。Alpha-Beta 剪枝能在不改变搜索结果的前提下,把有效节点数降到约45^(6*0.75)的量级,配合走法排序可以再快 2~3 倍。
搜索层数也要定义清楚:项目里的「深度 1 层」指的是「AI 走一步 + 玩家应一步」,也就是说depth = 4表示 AI 往后看 4 个半回合。很多人写搜索时把首步单独拿出来,导致深度计算混乱。最省事的写法是让搜索函数统一处理所有层,包括首步之外的每一层。
4.2 Alpha-Beta 剪枝的落地写法
项目里的主搜索函数写成这样,核心是「先走子、递归、再恢复」的三段式:
int alphaBeta(BoardState &s, int depth, int alpha, int beta, bool maximizing) { if (depth == 0) { return evaluate(s, maximizing ? RED : BLACK); } QVector<Move> moves = generateMoves(s, maximizing); if (moves.isEmpty()) { // 无子可走: 被将死或困毙 return maximizing ? -KING_VALUE : KING_VALUE; } // 走法排序: 吃子大的优先,剪枝效率更高 std::sort(moves.begin(), moves.end(), [](const Move &a, const Move &b) { return a.score > b.score; }); int best = maximizing ? -KING_VALUE : KING_VALUE; for (const Move &m : moves) { BoardState backup = s; s.board[m.toRow][m.toCol] = s.board[m.fromRow][m.fromCol]; s.board[m.fromRow][m.fromCol] = EMPTY; s.redTurn = !s.redTurn; int score = alphaBeta(s, depth - 1, alpha, beta, !maximizing); s = backup; // 恢复局面 if (maximizing) { best = qMax(best, score); alpha = qMax(alpha, score); } else { best = qMin(best, score); beta = qMin(beta, score); } if (beta <= alpha) break; // 剪枝点 } return best; }这段代码有几个隐含参数值得注意:KING_VALUE设为 1000000,确保将死局面的分数远高于任何子力交换;alpha初始为负无穷,beta为正无穷。递归里BoardState backup = s用的是整个结构体的拷贝构造,本身开销不大,真正影响速度的是走法排序——如果每次都重新生成所有走法再全遍历,剪枝率会明显下降。项目里把吃车、吃马这类大子走法排到前面,能让剪枝触发得更早。
4.3 评估函数:子力价值表与位置加分项
评估函数决定 AI 的棋风。项目里的做法是「子力价值 + 位置价值」,红黑对称。子力价值表如下:
| 棋子 | 基础分值 | 备注 |
|---|---|---|
| 车 | 1000 | 全盘最强,尽量避免兑掉 |
| 马 | 450 | 残局价值高于开局 |
| 炮 | 400 | 开局价值高,残局反而弱 |
| 兵 | 150 | 过河后按位置加成到 200 |
| 相/象 | 200 | 基本固定 |
| 仕/士 | 200 | 基本固定 |
| 将/帅 | 1000000 | 仅用于搜索判胜负 |
位置价值表我一般用 10x9 的二维常量数组,红方使用原始表,黑方用镜像表。兵过河后每前进一行加 15 分,车占二路和肋道各加 10 分,马在河沿附近的灵活度加分。不用做得太复杂,一个小函数即可:
int evaluate(const BoardState &s, int perspective) { int score = 0; for (int r = 0; r < 10; r++) { for (int c = 0; c < 9; c++) { int p = s.board[r][c]; if (p == EMPTY) continue; int val = pieceBaseValue(abs(p)); if (abs(p) == RED_SOLDIER && (p > 0 ? r <= 4 : r >= 5)) { val += 50; // 过河兵加成 } score += (p > 0 ? 1 : -1) * val; } } return (perspective == RED) ? score : -score; }这里perspective决定返回正分还是负分,搜索树里双方调用的评估永远是从当前走子方视角看。如果你发现 AI 红黑表现不一致,别去怀疑评估函数偏心,优先检查这里符号是不是写反了。
5. 交互实现与常见问题:鼠标拾子、悔棋栈与六个避坑记录
5.1 鼠标拾子与走子流程
红色方走子由鼠标事件驱动,流程分为两个状态:selectedRow/Col为 -1 表示未选子,点击后选中;再点一个目标格,先判断目标格是否为同色棋子(是则改选),否则生成这一步交给合法性校验。
void BoardWidget::mousePressEvent(QMouseEvent *e) { int r, c; if (!screenToBoard(e->pos(), r, c)) return; if (selectedRow == -1) { // 第一步: 选己方棋子 if (boardState.redTurn && boardState.board[r][c] > 0) { selectedRow = r; selectedCol = c; update(); } } else { Move m { selectedRow, selectedCol, r, c }; if (isLegalMove(boardState, m, true)) { applyMove(boardState, m); selectedRow = -1; selectedCol = -1; update(); // 触发电脑走子 QTimer::singleShot(200, this, &BoardWidget::onComputerMove); } else { selectedRow = -1; selectedCol = -1; update(); } } }这段代码里的QTimer::singleShot(200, ...)是刻意加的人工延时。没有这个延时,AI 会在鼠标点击瞬间立刻回应,玩家根本看不清刚才那步怎么走的,体验很差。延时 200 毫秒配合走子动画,观感会自然很多。注意applyMove里要先historyStack.push(boardState)再落子,这是悔棋能恢复局面前提。
5.2 悔棋与新局:局面快照栈
悔棋实现用QStack<BoardState>存完整局面快照,每次走子前 push。悔棋按钮点一次,红方悔一步、AI 马上跟上自动悔一步:
void BoardWidget::onUndo() { if (historyStack.size() < 2) return; // AI 悔一步 historyStack.pop(); // 丢弃 AI 走后的局面 boardState = historyStack.pop(); // 恢复成玩家走之前 redTurn = true; selectedRow = -1; update(); }这里的细节是:局面快照不含「选中的棋子」和「动画状态」,所以悔棋后要手动重置这两个界面状态。很多人在悔棋之后发现残影、选子卡死,都是忘了重置selectedRow。用完整快照而非记录走法列表的好处是,不需要为每个棋子维护「是否过河」等隐式状态,简单直接。
5.3 常见问题与排查:六条踩坑记录
以下是这个项目里最常出现的坑,按「现象 → 原因 → 解决」写,你遇到同名问题时直接照做即可。
坑一:马走日偶尔越界或跳错现象:AI 在某几个位置时马会走到棋盘外面,或者隔着一个子也跳过去了。 原因:马腿数组和跳跃数组下标没对齐,越界目标没有提前过滤。 解决:生成马的候选点时先检查边界,再检查马腿位置是否为空。顺序不能反,先边界后马腿。
坑二:AI 会自己送将现象:电脑走完之后,己方将帅正对着对方的车。 原因:合法性过滤只做了「目标格不能是己方」,没做「走后不被将军」检查。 解决:所有走法统一走一遍isLegalMove,里面必须包含模拟走子后的isInCheck。不要偷懒省略这一步。
坑三:搜索很慢,深度 4 层就要 10 秒现象:点完棋子后电脑半天没反应。 原因:走法没有排序,Alpha-Beta 剪枝形同虚设。 解决:按吃子价值降序排序,吃车的排最前。加了排序后同样的机器通常能快 3 倍左右。
坑四:黑方走子时评估符号反了现象:AI 执红棋正常,执黑棋的时候专门往坏处走。 原因:evaluate返回的分数没有按照当前走棋方转换视角。 解决:返回前统一转成perspective视角,红方为正、黑方为负。
坑五:窗口拉伸后棋盘模糊错位现象:窗口大小改变后,棋子画到了格子外面。 原因:坐标换算函数里用了写死的偏移量。 解决:所有像素坐标都从boardOriginX/Y + gridSize推导,gridSize根据窗口宽度动态计算,不要硬编码。
坑六:编译时中文棋子文字乱码现象:界面上棋子文字变成乱码或问号。 原因:Qt 5 默认源文件编码跟随系统 locale,Windows 下 GBK 和项目里保存的 UTF-8 冲突。 解决:在 .pro 文件里加QMAKE_CXXFLAGS += /utf-8,统一源码编码。这是 Qt 中文项目最常见的坑,没处理好会浪费很多时间。
6. 进阶玩法:迭代加深、开局库和固定对局验证
6.1 迭代加深:给 AI 一个时间上限
固定深度搜索有个尴尬场景:深度 3 太弱,深度 5 在复杂局面可能要卡 10 秒。给 AI 加一个时间上限,替代固定深度,是体验提升最大的一步。实现方式是迭代加深:从深度 1 开始逐层加深,每次都复用上一层的走法结果,一旦超时就返回当前已有结果。
Move iterativeDeepening(const BoardState &s, int maxMillis) { QElapsedTimer timer; timer.start(); Move bestMove; for (int depth = 1; depth <= 6; depth++) { Move depthBest = searchRoot(s, depth); bestMove = depthBest; if (timer.elapsed() > maxMillis) break; // 如果某层直接找到将死,也可以提前退出 } return bestMove; }实际项目中我通常把maxMillis设为 1000~1500ms,这样玩家基本感知不到等待,又不会让电脑走太慢。迭代加深的额外收益是:如果某层搜索直接就赢了,可以立刻终止;如果时间紧张,至少保证返回的是上一层完整搜索过的最佳招法,而不是从半截搜索里随便抓一个。
6.2 固定对局验证:改完评估函数不要直接上强度
验证 AI 变强还是变弱,最忌讳单独跑几局人机对练,手感和偶然因素太大。我一般会让 AI 左右互搏:加载两个 AI 实例,一个执红一个执黑,各自使用不同的评估参数,自动跑完一局并记录胜方和步数。改一次评估函数就让它自对弈 5 局,对比胜负分布。这个方法比人测靠谱得多,因为玩家水平不稳定,会掩盖评估函数的真实性能。
void runSelfPlay(int games) { int redWins = 0, blackWins = 0, draws = 0; for (int i = 0; i < games; i++) { BoardState s = initialBoard(); int result = playOneGame(s); // 1 红胜, 2 黑胜, 0 和棋 if (result == 1) redWins++; else if (result == 2) blackWins++; else draws++; } qDebug() << "红胜" << redWins << "黑胜" << blackWins << "和" << draws; }6.3 一个调试技巧:把深度变化成日志
最后分享一个我调试搜索时用的习惯:把每个深度搜索到的招法和评估值打印出来。这个输出能让你直观看到 AI 在第 3 层认为车占中炮好,到第 5 层却改走马踏连环,从而快速判断是评估函数漏了项,还是剪枝写错了。从那以后,我每次改完评估函数都强制让 AI 自对弈 5 局、把三个深度的主变招法打出来核对一遍,再放回去给用户玩。这套流程能挡住绝大多数带病上线的补丁,希望帮到你。
本文还有配套的精品资源,点击获取