简介一个基于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; // 简单吃子分值用于排序 }; QVectorMove generateMoves(const BoardState s, bool forRed) { QVectorMove 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); } QVectorMove 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 悔棋与新局局面快照栈悔棋实现用QStackBoardState存完整局面快照每次走子前 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 默认源文件编码跟随系统 localeWindows 下 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 局、把三个深度的主变招法打出来核对一遍再放回去给用户玩。这套流程能挡住绝大多数带病上线的补丁希望帮到你。本文还有配套的精品资源点击获取