简介:这是一份面向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 做中国象棋,卡住的地方不是界面,而是“人机对战”这四个字。棋盘画出来了,棋子能拖动了,规则也判了,可一到电脑走棋就懵了——它要么乱走,要么慢得像死机。这个标题真正要解决的是:在 Qt(C++)里把棋盘表示、走法生成、局面评估和搜索算法串成一条能跑通的链路,让电脑走出一步“像人”的棋。适合已经会一点 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::array<int, 90> cells{}; // 正数红方,负数黑方,0 为空 bool redTurn = true; // 把二维坐标转成一维索引 static int index(int row, int col) { return row * 9 + col; } // 生成当前走棋方的所有合法走法 std::vector<Move> generateMoves() const; };这段代码的关键是cells用std::array固定长度,避免动态分配。generateMoves返回std::vector<Move>,每个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::vector<Move>& 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_cast<std::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
两种都行,但我更推荐QGraphicsScene+QGraphicsItem。每个棋子是一个QGraphicsPixmapItem,棋盘线是QGraphicsLineItem。好处是点击检测、拖动、动画都有现成支持,不用自己算坐标。棋子移动用QPropertyAnimation做平滑过渡,体验比瞬间跳过去好很多。如果只是简单 Demo,QPainter重写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 层以内,否则也会慢。
我自己的习惯是:先把基础版本跑通,能完整下完一局,再逐步加置换表和静态搜索,每加一个就用固定局面测试搜索节点数和耗时,确认有提升再保留。不要一上来就堆高级特性,否则出了问题根本不知道是哪块的锅。希望帮到你。
本文还有配套的精品资源,点击获取