拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Qt实战:从零构建中国象棋人机对战完整项目

简介这是一份面向Qt初学者与中级开发者的游戏开发实践资源聚焦中国象棋这一经典逻辑场景系统呈现基于Qt框架的桌面端游戏完整实现路径。资源以C/Qt为核心技术栈覆盖UI构建、规则引擎、事件驱动、多线程AI及可选网络对战等关键模块助力开发者打通图形界面、业务逻辑与系统交互的全链路能力。压缩包含21个文件10个cpp源文件、9个h头文件、1个pro工程配置及1个user用户配置总大小仅12KB结构精炼Board/Stone等类封装棋盘与棋子渲染Step/SingleGame/MultiGame等模块分层实现走法校验、单机对弈与联网逻辑CtrlPanel与ChooseDlg支撑交互控制完整体现Qt项目典型的MVC分层思想与QGraphicsView场景化绘图范式。已有711人学习下载代码注释清晰、模块职责明确是理解Qt信号槽机制、自定义QGraphicsItem及游戏状态机设计的优质入门范例。 有的项目看起来很传统但能从零到一完整做出来收获比想象中大得多。中国象棋就是这类项目的典型代表——它不像聊天机器人那样追热点也不像管理系统那样堆业务但一个完整的象棋程序把界面绘制、鼠标交互、业务规则、算法决策、事件通信全部串起来了。用Qt实现它刚好能把Qt最核心的机制——信号槽、事件系统、QPainter绘图、模型与视图分离——全部过一遍。这次我把整个实现过程整理出来包含棋盘坐标建模、走棋规则边界处理、人机对战AI、打包发布适合已经跑过Qt基础示例、想拿完整项目练手的中级开发者参考。1. 为什么把中国象棋当成Qt练手项目1.1 从“会写控件”到“能写程序”的跨越很多人在Qt入门阶段做过计算器、记事本、图片浏览器这类小项目。这类项目有个共同特点界面是静态的业务逻辑基本挂在控件信号上实现完也就完了。但中国象棋不一样它天然是一个“状态机事件驱动”的程序棋盘状态会随每一步落子变化界面需要根据状态刷新业务规则走法合法性、将军、胜负独立于界面存在AI还需要在后台计算走法。这种结构刚好契合Qt的信号槽机制和事件循环模型。我做这个项目之前的几个Qt练习都有一个共同问题——写完就忘因为没有逻辑复杂度逼着你思考代码组织。开始写象棋之后才发现如果一开始不把“棋盘状态”和“界面显示”分开后面每一步改动都是灾难。这也是我建议拿象棋练手的最大理由它足够复杂但又不至于复杂到让初学者放弃。1.2 这个项目覆盖了哪些Qt核心技术点从Qt技能维度看中国象棋项目至少覆盖了下面这些内容QPainter绘制棋盘和棋子涉及坐标变换、抗锯齿、字体绘制Qt鼠标事件派发包含mousePressEvent、mouseMoveEvent、mouseReleaseEvent信号槽通信机制处理界面层与逻辑层的数据交互QTimer和动画机制可以实现走子动画和倒计时功能自定义数据结构设计比如棋子枚举、棋盘二维数组、走法记录栈模块化设计把规则引擎、AI引擎、界面层拆成相互独立的类说实话这些能力在官方文档里都有但零散地学效率很低。通过一个具体项目把它们串在一起用印象会深得多。我当时做完之后最大的感受是Qt不是从文档里学会的是从“改BUG”里学会的。1.3 项目整体结构与代码量我这个象棋项目的最终结构是四个核心类类名职责关键内容BoardWidget棋盘绘制与交互绘制棋盘、绘制棋子、处理鼠标事件GameEngine规则逻辑走法生成、合法性判定、将军与胜负判断ChessAI人机对战AI评估函数、搜索算法、走法排序MainWindow主窗口菜单栏、工具栏、状态栏、界面组装总的代码量在两千行上下。这个量级很合适——不会像工业级项目那样动辄上万行劝退人但又足够让你体会到“代码组织不好就没法继续写下去”的痛苦逼着你重视类和类之间的关系。接下来我会从最基础的棋盘建模开始讲。2. 棋盘建模与绘制坐标体系决定后续所有逻辑2.1 逻辑坐标与像素坐标分离写象棋程序第一个想清楚的问题不是怎么画棋盘而是“棋盘在程序里怎么表示”。中国象棋棋盘是10行横线乘以9列竖线交叉点就是棋子可以放的位置。我用的方案是用二维数组表示棋盘状态数组下标直接当作逻辑坐标。// 棋子颜色 enum PieceColor { RED 1, BLACK 2 }; // 棋子类型 enum PieceType { KING 1, // 将/帅 ADVISOR 2, // 士/仕 ELEPHANT 3, // 象/相 HORSE 4, // 马 ROOK 5, // 车 CANNON 6, // 炮 PAWN 7 // 兵/卒 }; // 棋盘格子逻辑坐标从0开始 // board[y][x]0 y 90 x 8 // 值为0表示空正数表示红方棋子负数表示黑方棋子 // 例如board[2][0] 1 表示红方帅在(0, 2)位置 int board[10][9];这里之所以把红方用正数、黑方用负数是为了后续判断盟友还是敌人时直接相乘判断符号。棋子类型可以用绝对值来表示。这样只用一张二维表就能同时保存“有什么棋子”和“属于哪一方”两个信息省去冗余的结构体。像素坐标和逻辑坐标必须严格分离。棋盘绘制的时候通过一个原点偏移和格子间距把逻辑坐标换算成屏幕上像素坐标。这样在实现走棋规则时永远不用关心“鼠标点在了哪几个像素”只需要处理“点击对应哪个格子”。2.2 棋盘绘制的核心代码绘制棋盘的逻辑不算复杂但有两个需要注意的点一是线条位置要与交叉点对齐二是“楚河汉界”和九宫斜线要单独处理。void BoardWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); int margin 30; // 棋盘外边距 int cellSize 60; // 格子边长 int boardWidth cellSize * 8; int boardHeight cellSize * 9; // 棋盘原点 m_originX margin; m_originY margin; // 绘制横线和竖线 painter.setPen(QPen(QColor(90, 60, 30), 2)); for (int row 0; row 9; row) { int y m_originY row * cellSize; if (row 0 || row 9) { // 最上和最下边线加粗 painter.setPen(QPen(QColor(90, 60, 30), 3)); } else { painter.setPen(QPen(QColor(90, 60, 30), 2)); } painter.drawLine(m_originX, y, m_originX boardWidth, y); } // 竖线分三段绘制跳过中间的河界 for (int col 0; col 8; col) { int x m_originX col * cellSize; painter.setPen(QPen(QColor(90, 60, 30), 2)); if (col 0 || col 8) { painter.setPen(QPen(QColor(90, 60, 30), 3)); } painter.drawLine(x, m_originY, x, m_originY 4 * cellSize); painter.drawLine(x, m_originY 5 * cellSize, x, m_originY boardHeight); } // 绘制九宫斜线 painter.setPen(QPen(QColor(90, 60, 30), 2)); // 上方九宫 painter.drawLine(m_originX 3 * cellSize, m_originY, m_originX 5 * cellSize, m_originY 2 * cellSize); painter.drawLine(m_originX 5 * cellSize, m_originY, m_originX 3 * cellSize, m_originY 2 * cellSize); // 下方九宫 painter.drawLine(m_originX 3 * cellSize, m_originY 7 * cellSize, m_originX 5 * cellSize, m_originY 9 * cellSize); painter.drawLine(m_originX 5 * cellSize, m_originY 7 * cellSize, m_originX 3 * cellSize, m_originY 9 * cellSize); // 绘制棋子 drawPieces(painter); }棋子绘制的核心是“从棋盘状态数组读数据在对应交叉点画一个带背景色的圆形和汉字”。汉字我用的是系统自带字体红方用红色黑体黑方用黑色黑体。这里有个小坑在Linux下有些字体不支持生僻字“卒”“仕”等需要手动指定一个中文字体比如文泉驿正黑否则会出现方块字。2.3 棋子布局初始化初始化棋子的位置是固定的但红方和黑方的坐标要注意对称关系。我习惯用红方视角定义常量数组然后通过“行号翻转”来生成黑方布局。void GameEngine::initBoard() { memset(board, 0, sizeof(board)); // 黑方上方 board[0][0] -ROOK; board[0][1] -HORSE; board[0][2] -ELEPHANT; board[0][3] -ADVISOR; board[0][4] -KING; board[0][5] -ADVISOR; board[0][6] -ELEPHANT; board[0][7] -HORSE; board[0][8] -ROOK; board[2][1] -CANNON; board[2][7] -CANNON; board[3][0] -PAWN; board[3][2] -PAWN; board[3][4] -PAWN; board[3][6] -PAWN; board[3][8] -PAWN; // 红方下方行号翻转 board[9][0] ROOK; board[9][1] HORSE; board[9][2] ELEPHANT; board[9][3] ADVISOR; board[9][4] KING; board[9][5] ADVISOR; board[9][6] ELEPHANT; board[9][7] HORSE; board[9][8] ROOK; board[7][1] CANNON; board[7][7] CANNON; board[6][0] PAWN; board[6][2] PAWN; board[6][4] PAWN; board[6][6] PAWN; board[6][8] PAWN; currentTurn RED; }这样做的好处是初始化逻辑一目了然你对照真实棋局看一眼就能确认没写错。后面做人机对战时AI也完全基于这张board表做计算不需要额外的数据结构。3. 走棋规则让代码理解“马走日象走田”3.1 走法生成器的基本设计象棋规则是整个项目的灵魂。一个棋子能不能移动到目标位置核心是“走法生成器”——给定棋盘状态、起点坐标返回所有合法终点坐标。简单的实现就是对每个棋子类型写一个独立函数传入起点、返回可达的终点列表。这里我把“生成走法”和“校验走法”拆成了两层生成走法时只做“基本规则判断”不判断是否会导致被将军真正走棋之前再做“最终合法性检查”。QListQPoint GameEngine::generateMoves(int fromX, int fromY) { QListQPoint moves; int piece board[fromY][fromX]; if (piece 0) return moves; int type qAbs(piece); int color (piece 0) ? RED : BLACK; switch (type) { case KING: generateKingMoves(fromX, fromY, color, moves); break; case ADVISOR: generateAdvisorMoves(fromX, fromY, color, moves); break; case ELEPHANT: generateElephantMoves(fromX, fromY, color, moves); break; case HORSE: generateHorseMoves(fromX, fromY, color, moves); break; case ROOK: generateRookMoves(fromX, fromY, color, moves); break; case CANNON: generateCannonMoves(fromX, fromY, color, moves); break; case PAWN: generatePawnMoves(fromX, fromY, color, moves); break; } return moves; }走法生成器的设计直接影响后续的AI搜索效率。如果生成器不够高效搜索深度每加一层耗时都是指数级增长。我实际实现的时候对生成器做了不少优化具体细节放在AI那部分讲。3.2 各兵种走法的实现坑点这里逐个说一下各兵种的实现思路和容易踩的坑。车ROOK规则最简单横着走或竖着走中间不能有棋子挡路。实现时沿四个方向逐格扫描遇到空格加入走法列表遇到己方棋子终止遇到敌方棋子吃子后终止。炮CANNON最容易写错的棋子。炮的走法分两种不吃子时和车一样走直线但不能跨越棋子吃子时必须在炮和目标棋子之间恰好有一个炮架这个炮架可以是任何一方的棋子。这里要注意炮可以跳过一个棋子吃另一个棋子但不能跳过两个及以上棋子。void GameEngine::generateCannonMoves(int x, int y, int color, QListQPoint moves) { // 四个方向扫描 int dx[4] {0, 0, -1, 1}; int dy[4] {-1, 1, 0, 0}; for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; bool hasJumped false; while (nx 0 nx 8 ny 0 ny 9) { int target board[ny][nx]; if (!hasJumped) { // 未跳炮架时只能走空位 if (target 0) { moves.append(QPoint(nx, ny)); } else { hasJumped true; // 遇到第一个棋子作为炮架 } } else { // 已跳炮架遇到第一个非空格可以吃 if (target ! 0) { if ((color RED target 0) || (color BLACK target 0)) { moves.append(QPoint(nx, ny)); } break; // 无论吃不吃都停止扫描 } } nx dx[i]; ny dy[i]; } } }马HORSE关键点是蹩马腿的判断。马在走“日”字时其“日”字的长边中间点不能有棋子。比如马从(3,3)走到(4,5)对应的马腿是(3,4)这个位置如果这个位置有棋子马就不能走。这里把马腿方向的向量单独定义出来代码会更清晰。象ELEPHANT关键点是塞象眼和不能过河。象走“田”字田字中心不能有棋子。同时象不能越过河界红象只能在y 5的区域黑象只能在y 4的区域。士ADVISOR只能走九宫内的斜对角而且每步必须走一格斜线。将/帅KING在九宫内上下左右走一格。还有一个特殊规则是“将帅不能照面”——如果两个将帅在同一条竖线上且中间没有别的棋子则当前方算输。这个规则在判断局面合法性时单独检查。兵/卒PAWN红方兵过河前只能向上走y减1过河后可以向上或左右走但不能后退。黑方逻辑相反。3.3 将军、将死与困毙的判定走法生成后需要判断一个局面是不是“将军”状态。我的做法是遍历当前回合方的所有棋子生成所有走法看是否有某个走法能走到对方将帅所在的位置。如果存在说明当前局面正在将军对方。判断“将死”更麻烦一点。一个局面将死意味着当前被将军的一方无论走任何一步合法棋都无法摆脱被将军的状态。实现方法是穷举当前方所有合法走法对每个走法模拟落到新棋盘判断该新棋盘上自己是否仍然被将军。如果所有走法都会导致自己被将军那就是将死胜负已分。这里面藏着一个经典坑模拟走棋之后需要先判断“走棋后自己的将帅是否暴露在对方的攻击下”再继续后续判断。很多人第一次实现时会漏掉这个环节——比如红方走一步棋结果把黑方将死了但红方自己的将也被黑方的车隔着马吃掉了——这个走法就是非法的。bool GameEngine::isMoveLegal(int fromX, int fromY, int toX, int toY) { // 保存现场 int tempFrom board[fromY][fromX]; int tempTo board[toY][toX]; // 模拟走子 board[toY][toX] tempFrom; board[fromY][fromX] 0; // 判断走完这步后自己是否被将军 bool isSelfChecked isKingInCheck(tempFrom 0 ? RED : BLACK); // 还原现场 board[fromY][fromX] tempFrom; board[toY][toX] tempTo; return !isSelfChecked; }这个“模拟-判断-还原”三步法是整个规则模块最通用的套路后面AI搜索的每个节点都会用到。它简单、可靠虽然效率不是最优但对象棋这个量级的项目完全够用。4. 界面交互细节从一版能跑到体验不错的距离4.1 技术选型QWidget QPainter 还是 QGraphicsView写界面的时候我认真比较过两条路经典QWidget QPainter自定义绘制以及QGraphicsView框架。两者的区别通俗说就是前者需要自己管一切绘制和事件后者把图元、场景、视图分层管理。一开始想用QGraphicsView因为看起来“更高级”。但实际调研后发现QGraphicsView封装度太高在棋子拖拽、坐标转换、层级管理上反而多了一层概念调试起来不够直观。象棋棋盘本身是一个规整的二维网格QWidget QPainter完全可以胜任代码还更直接。所以最终选了QWidget在paintEvent里统一绘制。这个选择也符合我后来项目里的一贯思路能用简单方案解决的需求绝不为“技术厉害”而加复杂度。如果你后面打算做双人联机对战可以在网络层单独加东西界面上再用同样的绘制方式。4.2 鼠标点击、拖拽与信号通信界面交互的核心逻辑是三个鼠标事件按压、移动、释放。按压时判断是否选中了己方棋子。如果是记录选中棋子的逻辑坐标。移动时如果有选中的棋子显示拖拽跟随效果。释放时判断释放位置是否在棋盘交叉点范围内如果在把“从哪到哪”发送给规则引擎校验。这里的坐标转换要小心。鼠标的全局坐标需要先用mapFromGlobal()转换成相对于棋盘控件的局部坐标再根据原点和格子大小推算出逻辑坐标。void BoardWidget::mousePressEvent(QMouseEvent *event) { QPoint pos event-pos(); int x qRound((pos.x() - m_originX) / (double)m_cellSize); int y qRound((pos.y() - m_originY) / (double)m_cellSize); if (x 0 || x 8 || y 0 || y 9) return; // 利用hitTest判断是否选中己方棋子 if (m_engine-isOwnPiece(x, y, m_currentTurn)) { m_selectedPoint QPoint(x, y); update(); } }信号槽在这里发挥了重要作用。界面控件只负责“用户点了哪个格子”它发出信号规则引擎收到信号后执行校验、修改状态再发信号让界面刷新。// BoardWidget 发出点击信号 emit pieceClicked(fromX, fromY, toX, toY); // MainWindow 连接信号与槽 connect(m_boardWidget, BoardWidget::pieceClicked, m_engine, GameEngine::onPlayerMove); connect(m_engine, GameEngine::moveFinished, m_boardWidget, BoardWidget::onMoveFinished);这种设计保证了界面和逻辑不会互相污染。我后期加“悔棋”功能和“局面记录”时几乎没改界面代码只往引擎里加了个走法栈。4.3 选中提示、可走位置标记与动画第一版做出来后能玩了但体验很差——完全不显示哪些棋子能走也不知道点到棋子后能走到哪。后来加了“可走位置提示”选中一个棋子后调用走法生成器对生成的每个合法走法在目标交叉点画一个半透明的小圆点。这样玩家能直观看到棋子能走到哪些位置。更细节的优化是“吃到对方棋子时画一个红圈”以及增加走子动画。走子动画的实现思路是记录起点和终点的像素坐标用QTimer驱动每次tick把棋子当前位置插值到中间位置然后刷新。由于棋盘组件的坐标转换统一封装了动画只需要插值逻辑坐标即可简单又干净。我用的插值方式很简单——线性插值double progress elapsed / (double)duration; int currentX fromX (toX - fromX) * progress; int currentY fromY (toY - fromY) * progress;过河兵、飞象、跳马这些动作在动画上都是一条直线。虽然细腻程度不如游戏引擎但作为Qt项目已经足够自然。4.4 悔棋、重新开局与死局处理悔棋用“走法栈”保存每一步的历史格式包括起点、终点、被吃的棋子类型。悔棋时从栈顶弹出一条记录恢复棋子位置和被吃棋子状态。这个功能对调试AI特别有用——AI走出一步离谱棋我可以悔一步回去看局面。重新开局更简单重新初始化棋盘数组清空历史栈刷新界面即可。和棋判断我没做太复杂的规则只做了最简单的“全盘棋子数量小于双方各2个时就提示和棋”以及“连续60回合无吃子时提示和棋”。这个和正式象棋规则有差距但对一个练习项目来说足够了。5. 人机对战不加AI的象棋项目是不完整的5.1 AI的整体组成象棋程序加到“能玩”很容易但“有点AI的样子”很难。我给自己定的目标不是打败多厉害的程序而是让AI至少能走出合理棋——开局懂得动炮、跳马中局知道兑子不吃亏残局能把将帅走回安全位置。人机对战的AI由三个部分组成走法生成器给定局面生成所有合法走法评估函数给一个局面打分红方有利为正黑方有利为负博弈搜索算法在走法树上搜索对最理想结果走法生成器在规则模块已经实现了AI部分直接复用。5.2 评估函数从棋子价值开始评估函数的目标是把一个棋盘局面量化成一个分数。最经典的方案是“棋子价值位置价值”。棋子价值表我用的值是业界常用的经典值棋子价值将/帅10000车900马400炮450象/相200士/仕200兵/卒过河前100兵/卒过河后200注意炮的价值我设得比马高一点点450 vs 400因为在实际对局中炮的作用和中局的灵活性往往比马好一些。这个调参全凭个人手感你也可以按自己的理解改。位置价值表则是给每个棋子加上“站在不同位置的附加分”。比如红方马站在河边第四、五行附近比较灵活加分兵过河后位置越深入对方腹地价值越高。位置表用二维数组表示每个棋子类型一张表总共7张。int ChessAI::evaluateBoard(GameEngine *engine) { int score 0; for (int y 0; y 9; y) { for (int x 0; x 8; x) { int piece engine-board[y][x]; if (piece 0) continue; int type qAbs(piece); int value BASE_VALUE[type]; // 加上位置价值红方从下方视角黑方从上方视角 if (piece 0) { score value; score POSITION_VALUE[type][y][x]; } else { score - value; score - POSITION_VALUE[type][9 - y][x]; } } } return score; }这里黑方位置表计算时翻转了y坐标这样一份位置表就能同时服务红黑双方。评估函数是这个AI最重要的一块它决定了AI的落子风格。初学者经常低估评估函数的重要性觉得搜索深度大就一定厉害。实际上一个精心调校的评估函数配合2层搜索能打败一个粗暴评估配4层搜索的AI。5.3 负极大值搜索与Alpha-Beta剪枝搜索算法我用了“负极大值搜索”——本质上和极大极小值搜索等价但代码更简洁。基本原理假设AI执红那对AI来说红方分数越高越好轮到黑方走时黑方想要的正好是红方分数越低越好。负极大值的做法是在递归时把分数取负数统一用“当前走棋方要最大化负分”来表达。单纯的遍历所有走法树会导致组合爆炸象棋平均每局面约有40种合法走法3层就是64000个节点4层就是2560000个节点必须配合Alpha-Beta剪枝。剪枝的核心逻辑是如果在某个节点上已经发现某一分支的结果对当前走棋方足够差那这个节点下面的其他分支就不用再搜了因为对手不会让你有机会走到这个分支。int ChessAI::negamax(GameEngine *engine, int depth, int alpha, int beta, int turn) { if (depth 0) { int score evaluateBoard(engine); return (turn RED) ? score : -score; } // 生成所有走法 QListMove moves generateAllMoves(engine, turn); if (moves.isEmpty()) { // 无棋可走判负 return -INF; } // 走法排序优化剪枝效率 std::sort(moves.begin(), moves.end(), [](const Move a, const Move b){ return a.score b.score; // 历史启发/吃子优先排列 }); int best -INF; for (const Move move : moves) { engine-makeMove(move); // 模拟走子 int val -negamax(engine, depth - 1, -beta, -alpha, (turn RED) ? BLACK : RED); engine-unmakeMove(move); // 还原局面 if (val best) best val; if (best alpha) alpha best; if (alpha beta) break; // 剪枝 } return best; }这段代码是AI模块最核心的骨架。注意到在递归调用时alpha和beta都取了负并交换位置这是负极大值写法的标准形式写错了会导致AI剪掉不应该剪的分支从而走出各种“瞎走”的棋。5.4 搜索深度与时间性能实测我在主频3GHz左右的机器上用Release模式编译实测不同搜索深度下的耗时搜索深度平均耗时棋力表现2层小于10ms类似初学者能吃子就算赢3层30-80ms具备基本战术意识4层500ms-2s能算到一些组合杀5层5s受叶子节点数量影响很大数据说明第三层到第四层搜索耗时增长非常明显但对普通练习项目3层搜索已经够用了——起码能吊打完全没学过象棋的纯新手。我把默认搜索深度设为3层并在UI上留了个深度选择器让用户自己权衡速度与棋力。如果想要更快的速度最简单的优化是“走法排序”和“历史启发”在所有走法中优先搜索吃子走法和历史表现好的走法。这样alpha-beta剪枝能更早触发大幅减少搜索节点数。我在代码里加了简单的吃子优先排序4层搜索耗时从3秒降到了1秒左右。5.5 对AI棋力的优化一个踩了比较久的坑AI上线后出现过一个诡异现象AI在中局偶尔会“送子”——明明车可以安全逃走的局面它偏偏走到对方炮的炮口上。排查了很久才发现问题出在评估函数没做“安全度”评估单纯统计棋子价值会认为“现在我的车值900分敌方炮值450分一换二不亏”但忽略了换子之后自己的帅可能直接暴露在危险中。解决方式是加入“将军优先级”如果当前走法能让对方将帅无路可逃即将军且下一步能将死这个走法得分极高如果当前走法会让自己的将帅处于被将军状态则直接丢弃。这个优先级判断需要和规则引擎的isKingInCheck配合在搜索评估时额外判断。int ChessAI::evaluateMove(GameEngine *engine, int fromX, int fromY, int toX, int toY) { // 先检查走完这步后自己是否被将军 bool selfChecked engine-isKingInCheckAfterMove(fromX, fromY, toX, toY, RED); if (selfChecked) return -100000; // 非法优先级极高 int baseScore evaluateBoard(engine); // 如果这一步将军了对方要加大分 if (engine-isKingInCheckAfterMove(fromX, fromY, toX, toY, BLACK)) { baseScore 500; } return baseScore; }加了这部分之后AI终于不再主动送子了。这让我明白一个道理评估函数不只要看棋盘上有什么更要看局面是否安全。这和中国象棋的战术思想其实是相通的。6. 编译与打包给朋友发一个能跑的程序6.1 构建方式选择qmake还是CMake搭建项目构建系统时我在qmake和CMake之间犹豫过。Qt官方对CMake的支持越来越好新项目用CMake是主流方向。但我自己当时选的是qmake原因很朴素qmake配置简单一个.pro文件就能搞定而且Qt自带的工具链对qmake调试更顺手。QT core gui widgets TARGET ChineseChess TEMPLATE app SOURCES \ main.cpp \ mainwindow.cpp \ boardwidget.cpp \ gameengine.cpp \ chessai.cpp HEADERS \ mainwindow.h \ boardwidget.h \ gameengine.h \ chessai.h # 支持不同分辨率下的高DPI显示 QT core5compat如果你是从零开始的新项目我会推荐直接上CMake。CMake对跨平台构建的控制力更强而且后续如果要集成第三方库比如加个音效库CMake生态更成熟。qmake的优势是简单适合小项目和快速原型。6.2 Windows下打包发布流程开发时在Qt Creator里点运行很舒服但要把程序发给朋友需要处理动态库依赖问题。Qt程序在Windows下依赖一堆DLL包括Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll还有平台插件platforms/qwindows.dll。最简单的打包方式是用Qt自带的windeployqt工具# 假设构建输出目录为 build/Release cd build/Release windeployqt ChineseChess.exe这个工具会自动扫描exe依赖的Qt模块把需要的DLL复制到exe同一目录下。跑完之后把这个目录整体压缩发给对方就能运行不需要对方安装Qt。需要注意两个坑编译器类型要匹配如果你的程序是用MinGW编译的windeployqt会自动带MinGW运行时DLL如果是MSVC编译的需要把对应版本的VC运行库一起带上。混用编译器会导致程序启动就报找不到DLL。中文路径问题我以前把一个项目放在中文路径下编译能过但是打包后运行各种奇怪报错。后来统一把项目路径改成纯英文问题就消失了。建议不管用什么编译器项目路径尽量只用英文和数字。6.3 跨平台编译的配置细节这个象棋项目我在Windows上用MinGW编译器跑通过也在LinuxUbuntu上用GCC编译过。两边的代码基本不用改main里唯一的区别是字体设置。Linux下如果中文显示成方块需要在代码里指定字体QFont font painter.font(); font.setFamily(WenQuanYi Micro Hei); font.setPixelSize(26); painter.setFont(font);Windows下用“Microsoft YaHei”或者系统默认字体就行。实际处理中我先用QFontDatabase::families()检测系统有哪些中文字体再从中挑一个可用的这样跨平台更稳。6.4 从“开发环境能跑”到“别人也能跑”的差距打包这件事看着不起眼但特别影响体验。我第一次打包完发给朋友对方双击后一点反应都没有。查了半天才想起来我的程序用了Q_OBJECT宏运行期需要对应编译器的C运行库。后来用windeployqt自动带上运行库后问题解决。我还踩过一个更细的坑Qt的HDPI缩放。有的电脑屏幕是2K或4K分辨率程序默认启动后在低缩放屏幕上正常在150%缩放下模糊。解决办法是在main函数前面加上QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling)或者在.pro文件里加一行配置QMAKE_LFLAGS /MANIFESTUAC:\levelasInvoker uiAccessfalse\更彻底的做法是写一个qt.conf文件放在exe同目录告诉Qt插件在哪里。这些细节虽然啰嗦但能避免很多“为什么别人电脑上跑不起来”的尴尬。7. 一些真实使用中的补充建议7.1 模块之间的边界别被打破项目开发到后期我最庆幸的是从一开始就坚持“界面不碰规则、规则不碰AI”的边界。虽然平时写的时候觉得有时候直接调用很方便但项目大了之后这个边界帮你省下的调试时间不可估量。如果你想给这个项目加网络对战只需要在GameEngine和BoardWidget之间加一层网络协议把“走棋/悔棋/认输”映射成消息发出去就行不必重构现有代码。7.2 写测试比想象中重要象棋规则是典型的“边界条件多”的逻辑只有边界测试才能保证不会改一处坏另一处。我给GameEngine写了一个简单的QTest单元测试套件专门验证蹩马腿、塞象眼、炮隔子吃子、兵过河转向这些特殊规则。测试用例长得像这样void TestGameEngine::testHorseBlocked() { GameEngine engine; engine.initBoard(); // 布置一个蹩马腿的局面 engine.board[3][4] 0; engine.board[2][4] -ROOK; // 阻塞马腿 // 马在(2,3)目标是(3,5) QListQPoint moves engine.generateMoves(2, 3); // 验证(3,5)不在返回列表中 QVERIFY(!moves.contains(QPoint(3, 5))); }这套测试花了两小时写但后面加AI、加悔棋功能时保证我再也没把规则改坏过。如果你不想做完整测试框架至少可以写一个命令行入口手动测试每种棋子的边界情况。7.3 从“能用”到“好用”的几个小优化项目推进到最后阶段我做了几个提升体验的小优化音效用QSoundEffect播放走子音频需要提前准备wav文件。音效虽小但让游戏感提升不少。倒计时在MainWindow状态栏加一个QLabel配合QTimer显示当前回合剩余时间。代码也就二十行但对对局体验很有帮助。残局布局加载从配置文件中读入自定义初始局面这样就能研究“车马炮对单车”等残局。实现方法很粗暴就是把board数组的初始值序列化成一个文本文件。每个优化都不难但合在一起项目就从一个“课程设计”变成了一个“像样的应用”。7.4 下一步可以怎么扩展如果你照着这篇做完了基础版我建议可以继续往这几个方向扩展一是加一个“着法记录/打谱”功能把每一步走法记录成文本类似象棋棋谱格式支持打开棋谱回放二是加入“基于历史棋谱的开局库”让AI在开局阶段直接从库中选棋三是做成局域网双人对战用Qt的QTcpSocket或QUdpSocket相互发送走棋数据。我个人更推荐做第三个方向。联网对战本身的技术栈很清晰——你将棋步序列化成JSON或自定义协议用socket发给对方对方解析后更新棋盘。这一套做完你就把Qt的另一个核心领域网络编程也练到了而且还能做成一个“跨设备对战”的完整项目。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门