
简介这是一份基于Qt Creator与C实现的中国象棋人机对战完整工程面向Qt初学者、课程设计或毕业设计人群能帮助理解桌面棋类项目的整体架构与实现思路。程序覆盖棋盘与棋子建模、兵/炮/车/马等各兵种合法走法判断、选棋落子交互、历史步骤管理并通过alpha-beta剪枝搜索结合局面评估函数实现电脑自动决策同时配有登录注册、控制面板、主窗口等界面模块包含中间层数据模型与代理项目结构贴近真实软件工程。压缩包共47个文件约575KB以14个h头文件和14个cpp源文件为主体辅以ui界面文件、qrc资源文件及jpg/png图片素材并附pro工程配置与README说明目录清晰可导入Qt Creator直接编译运行。目前已有1000余人浏览学习无论想掌握Qt控件布局、事件响应、算法博弈还是参考完整棋类项目的分层设计与二次扩展这份资源都有不错的参考价值。1. 为什么选Qt/C做中国象棋人机对战从需求到技术选型很多同学拿到这个题目时第一反应是Python加速不行、C界面又难写在选型上反复横跳。实际上用Qt Creator配合C做这个项目是性价比很高的组合Qt的信号槽天然适合处理棋盘点击、走子这类事件驱动逻辑C在Alpha-Beta剪枝和走法生成这类密集计算上不用刻意优化就能达到毫秒级响应。这套方案解决的不只是“机器能下棋”而是把棋盘表示、走法生成、搜索算法、界面交互收在一个工程里让课设/毕设既能展示算法深度又能演示完整交互。它适合要交完整设计的学生也适合想快速产出桌面棋类Demo的从业者。接下来的章节按需求、设计、实现、避坑、验收的顺序展开你可以直接对照着搭项目。2. 总体架构设计棋盘数据结构、走法生成与着法搜索的分层2.1 棋盘表示与棋子编码这一步决定后面所有代码好不好写。中国象棋的棋盘是10行9列通常不会直接用10x9二维数组因为马的走法、炮的跳子判断需要频繁计算坐标偏移。我习惯用一个长度为256的一维数组来模拟16x16的棋盘索引用16 * row col计算这样可以把“河界、九宫”这一类判断变成简单的坐标范围比较也为后面用查表法生成走法留出空间。棋子编码采用“兵种颜色”两层含义。空位记为0红方棋子和黑方棋子各占一段数值例如红帅6、黑将12这样只要判断数值区间就能区分敌我。实际项目里也见到过用位棋盘但课设和大部分毕设没有必要一维数组配合规则循环已经足够流畅。棋盘初始化时直接把开局布子填入数组这个逻辑放到Board::init()里后续每走一步都只修改两个格子悔棋时靠历史栈恢复。class Board { public: static const int BOARD_SIZE 256; static const int ROW_OFFSET 16; int squares[BOARD_SIZE]; void init() { for (int i 0; i BOARD_SIZE; i) squares[i] EMPTY; // 红方底线 place(9, 0, RED_ROOK); place(9, 1, RED_KNIGHT); place(9, 2, RED_BISHOP); place(9, 3, RED_ADVISOR); place(9, 4, RED_KING); place(9, 5, RED_ADVISOR); place(9, 6, RED_BISHOP); place(9, 7, RED_KNIGHT); place(9, 8, RED_ROOK); // 红方炮、兵 place(7, 1, RED_CANNON); place(7, 7, RED_CANNON); for (int c 0; c 9; c 2) place(6, c, RED_PAWN); // 黑方对称设置行号换成0、2、3 place(0, 0, BLACK_ROOK); place(0, 1, BLACK_KNIGHT); place(0, 2, BLACK_BISHOP); place(0, 3, BLACK_ADVISOR); place(0, 4, BLACK_KING); place(0, 5, BLACK_ADVISOR); place(0, 6, BLACK_BISHOP); place(0, 7, BLACK_KNIGHT); place(0, 8, BLACK_ROOK); place(2, 1, BLACK_CANNON); place(2, 7, BLACK_CANNON); for (int c 0; c 9; c 2) place(3, c, BLACK_PAWN); } void place(int row, int col, int piece) { squares[row * ROW_OFFSET col] piece; } };这里的核心是棋盘索引用row * 16 col而不是row * 9 col。多出来的行位置全当作空位走法生成时天然跳过。虽然多占了内存但换来了两个好处一是将帅所在的九宫坐标判断变成简单的边界比较二是马腿、象眼这类“卡位判断”只需要检查固定偏移位置是不是空位不用单独处理边界越界。如果你后续想改成国际象棋、将棋这个结构也能低成本迁移。2.2 走法生成从规则表到方向数组走法生成是搜索的发动机。最朴素的做法是每走一步遍历棋盘上所有棋子再对每个棋子遍历可走方向。这个方案在层数浅时勉强能用但配合Alpha-Beta剪枝后浅层生成的走法顺序直接决定剪枝效率所以生成函数必须够快。对车、炮、象、士这些棋子方向是固定的。通常我定义一组方向数组const int ROOK_DIR[4][2] { {0,1}, {0,-1}, {1,0}, {-1,0} }; const int CANNON_DIR[4][2] { {0,1}, {0,-1}, {1,0}, {-1,0} }; const int BISHOP_DIR[4][2] { {-1,-1}, {-1,1}, {1,-1}, {1,1} }; const int BOARD_DIR[4][2] { {-1,-1}, {-1,1}, {1,-1}, {1,1} }; const int HORSE_MOVE[8][2] { {-2,-1}, {-2,1}, {2,-1}, {2,1}, {-1,-2}, {-1,2}, {1,-2}, {1,2} };注意象和士的方向虽然共用对角线但约束完全不同士只能落在九宫内的固定几个点象不能过河且只能走“田”字对角线。马的走法需要额外检查“马腿”也就是马跳转前那个邻近格子是否被占。这些规则写起来不复杂但最容易翻车的是河界我方兵过了河才能左右移动象不能过河士不能离开九宫。把这些判断拆成isInPalace、isAcrossRiver、isHorseLegBlocked几个小函数比在每个方向循环里写一长串if要容易调试。走法生成的结果用一个结构体装起来保存起点、终点、吃子信息。引擎每层递归都会生成一份走法列表内存开销不小我一般用std::vectorMove并在递归入参里传入引用复用避免反复分配。struct Move { int fromRow, fromCol; int toRow, toCol; int captured; // 被吃的棋子EMPTY表示没有 };Move里的captured字段在makeMove时读取unmakeMove恢复棋子用。有的同学把被吃棋子丢在临时变量里悔棋或搜索回溯时忘了恢复这是后续很多奇怪bug的来源。2.3 搜索框架极大极小搜索与Alpha-Beta剪枝棋类AI的核心搜索框架是极大极小值搜索。假设我方是红方红方在偶数层取最大值黑方在奇数层取最小值最终根节点选一个评估分最高的走法。完整博弈树在象棋里是不可枚举的所以用Alpha-Beta剪枝砍掉无用分支。Alpha和Beta分别表示当前搜索路径上我方已知的最好分数、对方已知的最好分数。当某个节点的评估已经低于Alpha对方就不会再给更好机会可以直接剪掉。用负极大值形式写出来的搜索代码最简洁红黑双方共用同一段逻辑int search(int depth, int alpha, int beta, int color) { if (depth 0) return evaluate(color); MoveList moves; generateMoves(moves, color); for (Move m : moves) { makeMove(m); int val -search(depth - 1, -beta, -alpha, -color); unmakeMove(m); if (val beta) { alpha val; // 剪枝 break; } if (val alpha) alpha val; } return alpha; }这段代码里color代表当前走子方evaluate(color)返回当前走子方的优势分所以每次向下一层时把alpha、beta变成-beta, -alpha返回值再取负。新手最容易把剪枝条件val beta写成val alpha导致剪枝失效、搜索变得很慢。调试时可以在根节点打印评估分数用浅层搜索配合手工摆一个两步杀的棋局验证剪枝后结果和不剪枝一致。2.4 评估函数与搜索深度取舍评估函数决定AI的“棋品”。如果只做棋子价值求和AI会为了一个兵送出车。需要叠加位置价值兵逼近九宫时价值提升马在边角价值下降车的活动空间也要纳入考量。课设阶段不需要做到开局库和残局库把静态评估写清楚已经能打败大部分初学者。搜索深度取舍上普通桌面CPU在单线程情况下中国象棋的分支因子大约是30到504层大约要评估几百万个节点。Qt程序如果直接在UI线程里跑搜索界面会卡死所以一定要放到子线程或至少用QtConcurrent。我建议默认3层起步开局和残局可以开到5层再用“思考时间上限”作为兜底防止对方故意拖时间。位置价值表一般按10行9列静态初始化每种棋子配一张。下面这张是红兵位置价值表的一部分数字表示从红方视角看的相对价值int pawnPosValue[10][9] { {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0, 0}, {0, 0, 0, 0, 20, 0, 0, 0, 0}, {0, 0, 0, 30, 40, 30, 0, 0, 0}, // 底部兵接近九宫价值更高 {0, 0, 40, 60, 80, 60, 40, 0, 0}, };黑方位置表是红方的镜像写起来有点机械。我一般写一个mirrorTable函数在初始化时把红方表翻转行序生成黑方表这样只维护一份数据。3. 界面与交互实现Qt Widget的棋盘绘制、鼠标事件与对局状态机3.1 棋盘绘制与资源加载界面放在QWidget子类里重写paintEvent不用QGraphicsScene这种重型框架。棋盘网格直接用QPainter画线九宫用斜线河界写“楚河汉界”四个字。棋子用QPixmap加载也可以先用QFont画汉字减少设计素材依赖。整块绘制逻辑放在一张离屏QPixmap上走子后只更新变化区域避免整个棋盘重绘导致闪烁。资源加载路径是常见的坑Qt Creator默认的构建目录和源码目录不一致直接用相对路径“resource/images/rook.png”在运行时经常找不到。工程里用QDir::currentPath()和QCoreApplication::applicationDirPath()分别打出来对比或者干脆把图片通过.qrc文件编进二进制这样无论从哪个目录启动都不会丢资源。void ChessWidget::paintEvent(QPaintEvent *) { QPainter painter(this); // 画棋盘线10行9列 for (int i 0; i 10; i) { painter.drawLine(offsetX, offsetY i * cellSize, offsetX 8 * cellSize, offsetY i * cellSize); } for (int j 0; j 9; j) { painter.drawLine(offsetX j * cellSize, offsetY, offsetX j * cellSize, offsetY 8 * cellSize); } // 九宫斜线与河界文字省略 // 画棋子 for (int row 0; row 10; row) { for (int col 0; col 9; col) { int piece board-squares[row * Board::ROW_OFFSET col]; if (piece EMPTY) continue; QRect target(offsetX col * cellSize, offsetY row * cellSize, cellSize, cellSize); painter.drawPixmap(target, pieceImages[piece]); } } }这里pieceImages是一个长度为13的QPixmap数组按棋子编码直接索引。绘制时的坐标换算要统一棋盘数组的行row对应屏幕y方向列col对应x方向。鼠标点击时反过来从屏幕坐标算出row和col这两个映射在同一个类里写不要一遍在paint里用row*16col一遍在mousePress里用(y-offsetY)/cellSize去判断容易弄反。3.2 鼠标交互选子、走子与规则校验交互流程拆成三个状态空闲、已选中棋子、已走子等待AI。点击事件里先判断点击位置是否落在棋盘内如果当前是玩家回合再判断点击的格子有没有己方棋子有就切换选中状态并高亮。高亮不是重画整个界面而是记录selectedRow/selectedCol在paint里多画一个半透明矩形框。void ChessWidget::mousePressEvent(QMouseEvent *event) { if (gameState ! PLAYER_TURN) return; int row (event-pos().y() - offsetY) / cellSize; int col (event-pos().x() - offsetX) / cellSize; if (row 0 || row 10 || col 0 || col 9) return; if (selectedRow -1) { if (board-isMyPiece(row, col, currentPlayer)) { selectedRow row; selectedCol col; update(); } } else { // 先做引擎层合法校验再走子 if (engine.isLegalMove(selectedRow, selectedCol, row, col, currentPlayer)) { board-makeMove(selectedRow, selectedCol, row, col); selectedRow -1; gameState AI_THINKING; update(); startAISearch(); } else if (board-isMyPiece(row, col, currentPlayer)) { selectedRow row; selectedCol col; update(); } } }注意界面层只能做“点击区域有效”的粗校验真正的规则校验在引擎层走一遍完整走法生成避免出现“界面上允许、引擎不允许”的分裂。我一般让isLegalMove直接复用引擎的走法生成而不是在界面里再写一份规则。这样虽然多花一点时间但规则只维护一份后面改将军判负、困毙这类特殊规则时不会漏改。选中高亮的绘制放在棋子绘制之前painter.fillRect(target, QColor(255, 255, 0, 80))。半透明填充不会盖住棋子纹理玩家能很直观地看到当前选中格。如果发现高亮被棋子挡住把高亮绘制代码挪到棋子绘制之前或者反过来取决于你先画谁。3.3 对局流程与悔棋/新局对局状态机用枚举表示玩家回合、AI思考中、对局结束。状态转换如下玩家回合点击己方棋子进入选中点击目标格且合法走子后进入AI思考AI走完回到玩家回合将死或困毙时进入对局结束。这个状态机要放在界面类里引擎不管界面状态只管搜索。AI思考不能阻塞UI线程用QThread或QtConcurrent::run把engine.searchBestMove丢到后台搜索结束后通过信号把结果传回主线程主线程再执行走子并重绘。这里最容易被忽略的是“搜索过程中用户点击悔棋”后台线程还在跑主线程已经把棋盘回退了线程结束返回的走法坐标是旧棋盘上的坐标。所以悔棋前要发一个取消标志或者简单点AI思考中禁止悔棋直到对局状态回到玩家回合。悔棋实现用std::vectorMoveRecord记录每一步的起点、终点、被吃棋子。玩家悔棋弹回两步AI悔棋弹回一步这里有个常见的对称性问题有的同学只弹一步结果玩家和AI各自悔一步实际想表达的“悔棋”是回到玩家走之前的状态通常需要把上一轮AI走子和玩家走子一起撤销。新局就是清空历史栈、重新init()、重置状态机然后让AI决定先手还是后手。4. 核心算法落地C实现中国象棋引擎的完整流程4.1 走法合法性校验与送将过滤引擎从根上分两步第一步是“走法生成”枚举一个局面下所有合法走法第二步是“走法过滤”把导致己方老将被吃的走法删除。很多人只做第一步就投喂给搜索结果AI会经常走到送将的位置。过滤的标准做法是尝试走一步然后检查己方将帅是否处于被攻击状态如果是则回退并删除该走法。将帅被吃检查写成isUnderAttack(color, kingRow, kingCol)找到己方将的位置遍历对方所有棋子的走法看有没有能攻击到这个格的。中国象棋“将帅对脸”要单独处理如果两将之间没有棋子相当于将可以直接吃对方将这等价于当前局面已经处于被将军状态。在走法过滤时要把对脸判成一个攻击条件。bool ChessEngine::isLegalMove(int fromRow, int fromCol, int toRow, int toCol, int color) { if (!isMoveGenerated(fromRow, fromCol, toRow, toCol, color)) return false; Move mv{fromRow, fromCol, toRow, toCol}; board-makeMove(mv); bool ok !board-isKingUnderAttack(color); board-unmakeMove(mv); return ok; }这个函数的逻辑看起来简单但两个细节容易踩坑一是makeMove时要把被吃棋子记录到Move里否则unmakeMove恢复不了二是isKingUnderAttack内部如果再去调isLegalMove会无限递归必须在底层用“直接攻击判定”而不是“递归走法”来判断。所谓直接攻击判定就是单独实现车、炮、马、兵、将的攻击检测函数不调用走法生成器。4.2 Alpha-Beta剪枝的完整实现与走法排序搜索函数用负极大值形式写红黑双方共用一套代码。前面第2章给出了递归核心这里补上根节点处理和走法排序。Move ChessEngine::searchBestMove(int depth) { int alpha -INF, beta INF; Move bestMove; MoveList moves; generateMoves(moves, currentColor); // 走法排序吃子优先兵向前走次优先 sortMoves(moves); for (Move m : moves) { makeMove(m); int val -search(depth - 1, -beta, -alpha, -currentColor); unmakeMove(m); if (val alpha) { alpha val; bestMove m; } } return bestMove; }走法排序很关键Alpha-Beta剪枝的效率依赖走法顺序如果最好的走法排在最前面剪枝会非常早。最简单的排序是“吃子优先”用被吃棋子价值减去攻击棋子价值做代价值大的排在前面。如果你做到后面想提升棋力还可以加一个“历史启发表”记录哪些走法在搜索中产生过截断优先搜索这些走法。这个优化能让同样时间内的搜索深度提高半层到一层。4.3 评估函数细节子力价值、位置价值与将军附加分评估函数返回红方视角分数红优为正、黑优为负。基础子力价值参考将10000、车600、马300、炮300、士120、象120、兵30。位置价值表按棋子类型各配一张兵越靠近九宫价值越高马在中心价值高炮在原始位置价值低。黑方的位置表由红方镜像生成避免维护两套数据。还有一个容易忽略的点在搜索叶子节点如果当前局面是将军状态要加一个不大的附加分比如50让AI更愿意主动将军。如果搜索到某一方没有合法走法且被将军直接返回负无穷代表将死这样AI能识别出杀棋。困毙无路可走但未被将军在中国象棋里也是输棋同样返回负无穷。int ChessEngine::evaluate(int color) { int score 0; for (int row 0; row 10; row) { for (int col 0; col 9; col) { int piece board-squares[row * ROW_OFFSET col]; if (piece EMPTY) continue; int val baseValue[piece] positionValue[piece][row][col]; score (piece 7) ? val : -val; // 红正黑负 } } if (board-isAnyKingCheck(color)) score 50; return score; }这里piece 7是红方棋子的编码区间黑方编码从7开始所以直接判断区间就能正负号。完整工程里评估函数会被调用几十万次建议把子力价值总和维护成Board成员变量makeMove时增量更新评估时只遍历位置表差值能明显提速。你自己试的时候会发现深度从3层到4层的耗时突然翻了好几倍这部分优化就是为了给开4层留出余量。4.4 引擎与界面联调异步搜索与线程安全引擎和界面的联调是工程落地最容易被轻视的一环。设计上我把引擎封装成ChessEngine界面只通过searchBestMove拿结果不直接访问搜索内部状态。搜索放到QtConcurrent::run里结束后用信号把Move传回UI线程。线程安全要注意三点一是棋盘对象不能让两个线程同时读写最简单的方式是“搜索期间界面禁止走子”用状态机保证二是searchBestMove如果被取消要等它安全返回不能直接terminate线程否则棋盘可能停留在中间状态三是悔棋前如果后台搜索还在跑先置一个abortFlag搜索每层检查该标志看到后立即返回再执行悔棋。// 在search每层递归入口、generateMoves之后检查 if (abortSearch) { return alpha; }这个abort标志用std::atomicbool跨线程读写安全。搜索结束后不要再碰棋盘把走法结果通过信号发给主线程让主线程去执行makeMove这比在子线程里直接改界面状态安全得多。5. 常见问题与避坑指南搜索卡顿、误判吃子、界面刷新与悔棋错乱5.1 现象走一步棋要等好几秒原因搜索直接放在UI线程跑或者层数开到了5层以上且剪枝条件写错。解决先确认searchBestMove是否在子线程再打印搜索耗时如果耗时和深度不成比例回去看search函数里的剪枝条件检查是否写成了val alpha且没有break。我用QElapsedTimer打印每层耗时发现未剪枝4层要3.8秒剪枝修正后只要0.5秒血泪经验。如果你发现剪枝代码没问题但还是慢大概率是走法排序没做分支因子一直很高剪枝效率上不来。5.2 现象AI总是送吃原因评估函数只统计了子力价值没做位置价值或者根节点返回的不是“最佳走法”而是在递归里把最佳走法覆盖了。解决先在评估函数里把位置价值表加上再确认searchBestMove只在根节点更新bestMove不要在递归里更新。另外检查每一次makeMove和unmakeMove是否成对漏掉一次恢复会让评估值越来越偏。最快的排查办法是让AI自己和自己下每走一步都打印当前子力价值总和如果出现“分数越走越低但AI还选它”基本就是回溯没恢复。5.3 现象棋子拖拽或光标移动时界面闪烁原因在paintEvent外主动调用了repaint或直接在事件处理里画图或者没有把背景刷成棋盘底色导致残影。解决统一只在paintEvent里画需要刷新时调用update()让Qt自动合并绘制事件。如果做了离屏缓冲注意在resizeEvent里同步更新缓冲图大小。闪烁问题很多时候不是性能问题是绘制时机问题。还有一个基础坑是cellSize在窗口缩放后没同步更新棋子画到棋盘外面去了检查所有用到cellSize的地方是不是都从resizeEvent里统一读取。5.4 现象悔棋后引擎计算的走法还是旧局面原因后台搜索线程还没结束主线程已经回退了棋盘并又发起了新搜索两个线程同时读写Board。解决按4.4的联调方案悔棋前先置abortFlag等线程结束后再改棋盘。更稳妥的做法是“AI思考中直接禁用悔棋按钮”对课设来说完全够用演示时不会出错。如果一定要支持思考中悔棋就把棋盘访问加一个互斥锁但加锁会让搜索退化成单线程性能不建议在课设里做。5.5 现象AI搜索到一半程序崩溃原因递归里makeMove和unmakeMove没有配对或者MoveList在深递归时引用失效。解决检查每一个continue/return之前是否都执行了unmakeMove把MoveList改成局部变量而不是跨层共享引用如果全盘局走法很多用std::vectorMove按层传递避免悬垂引用。遇到崩溃先跑一遍“双方不走棋只让AI连续搜索同一局面”的稳定性用例通常能复现。再用调试模式编译崩溃时看调用栈如果卡在评估函数里大概率是棋盘数组越界检查256长度和一维索引计算。6. 验收与答辩从功能测试到性能评测的标准流程6.1 功能验收用例设计测试不能只测“能走子”。设计一份用例表开局红方第一步能走兵、马、相黑方自动应对士不能出九宫象不能过河炮吃子必须隔子马蹩腿不能走将帅对脸判定吃子吃子后棋子消失将军提示绝杀判定悔棋连续三次新局重置。每一项标注预期结果答辩时展示这份用例表比口头说“我测过了”更有说服力。6.2 性能验收步时、深度与分支因子性能验收用数据说话。固定一个中局分别测深度1到5的搜索耗时加上剪枝前后对比记录分支因子。用表格呈现深度无剪枝耗时Alpha-Beta耗时分支因子245ms8ms12.33480ms35ms10.143.8s320ms8.4519s1.6s6.9答辩时能解释清楚分支因子下降说明剪枝有效评委就知道你真正理解了算法。6.3 答辩讲法怎么把方案讲成自己的答辩最忌讳背代码。我习惯拿一个具体局面现场演示让AI走一步然后问“为什么不走车去吃马”点开调试日志展示搜索树里那一步的评估分数变化讲清楚“吃马后续会丢车”所以AI放弃。这样能避开“是不是网上抄的”追问。我在演示时还会把搜索深度切成2和4各跑一步对比落子变化说明深度对棋力的影响这比口头解释更直观。从那以后我每次做完棋类项目都会强制走一遍复盘先跑功能用例再录性能数据最后准备一个能讲五分钟的实战对局故事。希望帮到你。本文还有配套的精品资源点击获取