用Qt和QPainter从零实现中国象棋:规则引擎与AI设计
简介在桌面应用开发中图形界面与逻辑分离是构建复杂交互项目的关键。通过事件处理与自定义绘图开发者可以高效实现棋盘类游戏。本文从基础概念出发讲解如何利用Qt框架与QPainter绘制界面并设计一套可扩展的规则引擎。结合C的面向对象思想将数据、规则与显示分层解耦再引入极简极大极小搜索算法实现人机对弈。此类项目不仅适用于课程设计也能帮助开发者深入理解GUI事件循环、坐标变换与算法优化。文章以中国象棋为载体完整演示了从棋盘建模到AI落子的全过程。 做Qt开发的人多多少少都会想写点带界面的小游戏练手。中国象棋是一个特别合适的选择规则清晰、交互直观、逻辑复杂度适中而且做完之后拿给朋友玩也很有成就感。这个项目不依赖外部图片素材全部用QPainter绘制棋盘和棋子C代码量控制在合理范围非常适合学过Qt基础控件、想进阶理解事件处理和绘图体系的开发者。如果你正在找课程设计题目或者想给简历加一个完整项目这套源码的设计思路可以直接参考。整个项目我按照数据层、规则层、界面层三层来组织棋盘用一个二维数组表示走棋规则单独封装成静态方法界面部分只负责绘制和接收鼠标事件。下面我把核心思路、规则实现、交互流程以及AI设计一一拆开讲代码片段都能直接放进你的工程里跑通。1. 项目整体设计与思路拆解1.1 为什么选Qt做中国象棋中国象棋这类棋盘游戏本质上是数据 规则 呈现三个问题的组合。Qt恰好在这三块都有非常顺手的工具QPainter绘制棋盘棋子很灵活QWidget的鼠标事件天然适合落子交互而规则判断其实是纯C逻辑不依赖任何框架特性。用Qt来做等于把精力集中在游戏本身的实现上而不是被底层图形接口拖住。另一个现实原因是资料多、跨平台。无论你用的是Windows、Linux还是macOSQt的API几乎一致代码写完直接在各平台编译。这个项目我在Qt 5.12和5.15.2上都编译过Qt 6.x也基本兼容只有极少数枚举名有差异。建议新手直接用5.15.2的MinGW 32位版本配Qt Creator开箱即用省去VS环境配置的折腾。当然如果你习惯VS5.12.12配VS2015编译环境也完全没问题注意把Qt的MSVC版本和你本机的VS版本匹配上就行。Qt安装时如果觉得在线下载慢用国内镜像站是最舒服的方式。在维护Qt的镜像站点列表中挑一个离你近的把下载地址填进维护工具的临时仓库设置里速度会快几个数量级。Qt Creator里还要顺手装好Qt 5.15.2 MSVC2015 64bit或MinGW 8.1.0 64bit这套编译器套件工具链匹配是新手最常踩的第一个坑。提示如果之前装过其他版本或者想换编译器套件不必卸载重装Qt。在维护工具里直接添加组件即可卸载旧版本反而容易误删共享的Qt Creator配置。1.2 整体架构界面、规则、流程三层分离写中国象棋最忌讳的是把所有代码塞进一个QWidget里画棋盘、判规则、管轮次全部耦合在一起改一个功能牵一发动全身。我推荐的架构分三块棋盘数据模型BoardModel负责存储棋盘状态提供基本的读写下棋接口。核心就是一个10行9列的二维数组加上每个棋子的位置映射表。规则引擎RuleEngine职责单一只做一件事——判断某个走法是否合法。它不关心界面也不关心谁在操作只根据当前棋盘状态做纯逻辑判断。界面与控制器BoardWidget与GameControllerBoardWidget继承QWidget负责绘制棋盘和处理鼠标事件GameController负责串联流程比如玩家选中棋子 → 高亮可走位置 → 点击目标格 → 调用规则引擎校验 → 更新棋盘数据 → 刷新界面 → 轮到AI走棋。这套分层的好处很明显你可以单独给规则引擎写单元测试可以随时换AI算法界面做任何调整都不会影响走棋逻辑。我实际开发时规则引擎部分几乎没改过而界面重构了两版这种稳定性就是分层带来的。1.3 工程文件组织与关键类清单创建工程时我建议建一个空的QWidget项目然后手动添加以下文件结构清晰也方便理解依赖关系chess/ ├── ChessWidget.h / .cpp # 棋盘界面继承QWidget负责绘制和鼠标事件 ├── BoardModel.h / .cpp # 棋盘数据模型10x9数组和棋子状态 ├── RuleEngine.h / .cpp # 走棋规则判断静态方法为主 ├── GameController.h / .cpp # 游戏流程控制管理轮次、悔棋、胜负 ├── AIEngine.h / .cpp # 简易AI评估函数搜索 └── main.cpp # 程序入口各文件依赖方向是单向的ChessWidget依赖BoardModel、GameControllerGameController依赖RuleEngine和AIEngine。绝不让子模块反向依赖界面这样的依赖关系让每个类都能单独测试。2. 棋盘建模与坐标体系2.1 棋盘数据结构设计中国象棋棋盘是10行9列行编号0到9列编号0到8。我用的数据结构是直接开一个二维数组元素为枚举类型或结构体enum PieceType { NO_PIECE 0, RED_KING, RED_ADVISOR, RED_ELEPHANT, RED_HORSE, RED_CHARIOT, RED_CANNON, RED_SOLDIER, BLACK_KING, BLACK_ADVISOR, BLACK_ELEPHANT, BLACK_HORSE, BLACK_CHARIOT, BLACK_CANNON, BLACK_SOLDIER }; class BoardModel { public: static const int ROWS 10; static const int COLS 9; PieceType board[ROWS][COLS]; BoardModel() { reset(); } void reset() { memset(board, NO_PIECE, sizeof(board)); setupPieces(); } private: void setupPieces() { // 红方在下方行下标为9是红方底线黑方在上方行下标为0是黑方底线 board[0][0] BLACK_CHARIOT; board[0][1] BLACK_HORSE; board[0][2] BLACK_ELEPHANT; board[0][3] BLACK_ADVISOR; board[0][4] BLACK_KING; board[0][5] BLACK_ADVISOR; board[0][6] BLACK_ELEPHANT; board[0][7] BLACK_HORSE; board[0][8] BLACK_CHARIOT; board[2][1] BLACK_CANNON; board[2][7] BLACK_CANNON; board[3][0] BLACK_SOLDIER; board[3][2] BLACK_SOLDIER; board[3][4] BLACK_SOLDIER; board[3][6] BLACK_SOLDIER; board[3][8] BLACK_SOLDIER; board[9][0] RED_CHARIOT; board[9][1] RED_HORSE; board[9][2] RED_ELEPHANT; board[9][3] RED_ADVISOR; board[9][4] RED_KING; board[9][5] RED_ADVISOR; board[9][6] RED_ELEPHANT; board[9][7] RED_HORSE; board[9][8] RED_CHARIOT; board[7][1] RED_CANNON; board[7][7] RED_CANNON; board[6][0] RED_SOLDIER; board[6][2] RED_SOLDIER; board[6][4] RED_SOLDIER; board[6][6] RED_SOLDIER; board[6][8] RED_SOLDIER; } };这里有个细节值得注意红方在下方行下标比黑方大。判断哪一方时可以通过枚举行的范围或者单独存一个isRed标记。不建议通过比较行下标大小来判断因为棋子过河之后行关系会变化容易出错。如果用结构体存棋子可以考虑加一个id字段方便悔棋恢复但我实测发现二维数组加枚举已经足够悔棋时只要记录从哪拿到哪、吃了什么三个信息就能完整回退。2.2 三类坐标的转换与统一写这个项目最容易翻车的点就是坐标。我在开发中实际遇到三种坐标同时存在数组索引坐标board[row][col]row范围0-9col范围0-8这是逻辑层唯一使用的坐标。棋盘网格坐标标准的车一平五那种记谱坐标但编程中一般不用它。像素坐标鼠标点击时拿到的QPoint单位是像素。界面层必须在这三种坐标之间来回切换。核心转换函数是数组坐标 → 像素坐标和像素坐标 → 数组坐标。我用的网格边距是30像素格子大小是56像素换算公式如下// 数组坐标转像素坐标返回棋子中心点 QPoint BoardWidget::boardToPixel(int row, int col) const { int x MARGIN col * CELL_SIZE; int y MARGIN row * CELL_SIZE; return QPoint(x, y); } // 像素坐标转数组坐标返回是否转换成功 bool BoardWidget::pixelToBoard(const QPoint pos, int row, int col) const { double dx (pos.x() - MARGIN) / (double)CELL_SIZE; double dy (pos.y() - MARGIN) / (double)CELL_SIZE; row qRound(dy); col qRound(dx); return row 0 row 10 col 0 col 9; }qRound是Qt自带的四舍五入函数比直接用(int)强转稳定。pixelToBoard返回bool的含义是如果鼠标点在棋盘外比如点到窗口边缘的空白区就不应该触发任何走棋逻辑。注意窗口大小改变时如果我不重写resizeEvent去动态调整MARGIN和CELL_SIZE而是固定窗口尺寸则QWidget默认不会缩放内容。我建议直接用setFixedSize固定窗口省掉自适应这层麻烦。固定尺寸在逻辑上也让坐标映射简单可靠。2.3 棋子模型类型、归属与绘制棋子在数据层就是BoardModel里的枚举值但为了界面绘制方便我封装了几个小函数判断棋子属于红方还是黑方、获取棋子的中文名称、获取棋子对应的颜色。这些函数放在RuleEngine或单独的工具类里都可以我个人放在RuleEngine里因为判断归属本身就是规则的一部分。bool RuleEngine::isRed(PieceType p) { return p RED_KING p RED_SOLDIER; } bool RuleEngine::isBlack(PieceType p) { return p BLACK_KING p BLACK_SOLDIER; } QString RuleEngine::pieceName(PieceType p) { switch (p) { case RED_KING: return 帅; case BLACK_KING: return 将; case RED_ADVISOR: return 仕; case BLACK_ADVISOR: return 士; case RED_ELEPHANT: return 相; case BLACK_ELEPHANT: return 象; case RED_HORSE: return 马; case BLACK_HORSE: return 马; case RED_CHARIOT: return 车; case BLACK_CHARIOT: return 车; case RED_CANNON: return 炮; case BLACK_CANNON: return 炮; case RED_SOLDIER: return 兵; case BLACK_SOLDIER: return 卒; default: return ; } }绘制棋子时我不使用图片直接用QPainter画圆形再在中心写汉字。这样工程里不需要管理资源文件也省去了图片版权问题。给棋子画一个渐变色圆形背景红方用浅红色系、黑方用浅灰色系视觉上很清楚。汉字用系统默认字体即可如果要更好看可以设置QFont(SimHei)或QFont(Microsoft YaHei)。3. 棋子走法与规则判定的核心实现3.1 走法合法性判断的总出入口规则引擎是所有走棋逻辑的中枢对外只暴露一个接口bool RuleEngine::isLegalMove(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol);这个函数做了四件事检查起点是否有棋子检查终点是否为目标方自己的棋子检查棋子是否走出了规则允许的形态最后检查走完这一步后己方将/帅是否暴露在对方的攻击范围内也就是不能送将/帅。前三点是常规的走法校验最后一点特别重要叫作自将军检查。如果不做这一步你可能会走出让己方主帅直接被吃掉的招法这在象棋规则里是不允许走的。自将军检查的实现逻辑是先在数据模型上模拟走完这一步然后扫描整个棋盘看对方棋子是否能攻击到己方将/帅当前所在位置。因为走棋是吃子还是移动在数据层都统一成一个操作——目标格覆盖所以模拟起来很简单。bool RuleEngine::isLegalMove(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol) { if (fromRow 0 || fromRow 10 || fromCol 0 || fromCol 8) return false; if (toRow 0 || toRow 10 || toCol 0 || toCol 8) return false; PieceType piece board.board[fromRow][fromCol]; if (piece NO_PIECE) return false; PieceType target board.board[toRow][toCol]; if (target ! NO_PIECE RuleEngine::isRed(piece) RuleEngine::isRed(target)) { return false; // 不能吃己方棋子 } if (!isMoveShapeOk(board, piece, fromRow, fromCol, toRow, toCol)) { return false; } // 模拟走棋检查是否送将/帅 BoardModel temp board; temp.board[toRow][toCol] piece; temp.board[fromRow][fromCol] NO_PIECE; return !isKingInCheck(temp, RuleEngine::isRed(piece)); }这里用了一个小的设计技巧BoardModel temp board。BoardModel内部就是一个二维枚举数组所以拷贝代价极低做模拟判断非常划算。你不需要专门写撤销逻辑只要在临时副本上操作就行。3.2 车、马、象、士、将、炮的规则细节接下来是重头戏逐个棋子讲解规则实现。每个走法判断函数我都写成单独的static方法方便定位问题和扩展。车的走法车走直线中间不能有子。判断方法是检查起点和终点是否在同一行或同一列然后遍历中间的格子是否为空bool RuleEngine::isChariotMoveOk(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol) { if (fromRow ! toRow fromCol ! toCol) return false; int dr (toRow fromRow) ? 1 : (toRow fromRow ? -1 : 0); int dc (toCol fromCol) ? 1 : (toCol fromCol ? -1 : 0); int row fromRow dr, col fromCol dc; while (row ! toRow || col ! toCol) { if (board.board[row][col] ! NO_PIECE) return false; row dr; col dc; } return true; }这段代码的巧妙之处在于用一个通用的方向增量dr/dc同时处理四个方向不需要写四个分支。马的走法马的走法大家熟悉走日字但存在蹩马腿。关键是怎么判断马腿位置。马移动时横竖各走两格加一格如果横向移动两格那么马腿在横向移动方向上的中间格如果纵向移动两格则马腿在纵向移动方向上的中间格bool RuleEngine::isHorseMoveOk(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol) { int dr toRow - fromRow; int dc toCol - fromCol; if (qAbs(dr) 2 qAbs(dc) 1) { int legRow fromRow dr / 2; if (board.board[legRow][fromCol] ! NO_PIECE) return false; // 蹩马腿 } else if (qAbs(dr) 1 qAbs(dc) 2) { int legCol fromCol dc / 2; if (board.board[fromRow][legCol] ! NO_PIECE) return false; // 蹩马腿 } else { return false; } return true; }注意dr / 2在C里对正负值都正确因为整数除法向零取整而dr只可能是-2、2或-1、1所以这里不会有截断问题。象的走法走田字不能过河。田字中心是象眼中心有子则不能走。判断条件是横纵各走两格且不能越过河界bool RuleEngine::isElephantMoveOk(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol) { if (qAbs(toRow - fromRow) ! 2 || qAbs(toCol - fromCol) ! 2) return false; // 象不能过河红象只能在下方5行行5-9黑象只能在上方5行行0-4 PieceType p board.board[fromRow][fromCol]; if (RuleEngine::isRed(p) toRow 5) return false; if (RuleEngine::isBlack(p) toRow 4) return false; int eyeRow (fromRow toRow) / 2; int eyeCol (fromCol toCol) / 2; if (board.board[eyeRow][eyeCol] ! NO_PIECE) return false; // 塞象眼 return true; }河界判断用toRow 5还是toRow 4取决于行下标从哪边数起。红方在下行9是底线红象的活动范围是行5到行9黑方在上活动范围是行0到行4。写的时候一定要清楚自己的行方向。士和将士走九宫斜线将走九宫直线。九宫范围是列3到5红将行7到9黑将行0到2。这两个棋子的判断简单但要注意将帅对脸的特殊规则如果双方将帅在同一列且中间没有棋子那么任何一方都不能走成这样的局面。这个判断其实已经包含在3.1节的自将军检查里了因为对脸意味着对方的帅可以直接沿着直线吃掉你的将等价于你在送将。炮的走法炮是象棋里最特殊的棋子。移动时和车一样走直线中间不能有子吃子时中间必须恰好有一个棋子炮架。我把移动和吃子分开处理先数中间经过的棋子数量bool RuleEngine::isCannonMoveOk(const BoardModel board, int fromRow, int fromCol, int toRow, int toCol) { if (fromRow ! toRow fromCol ! toCol) return false; int dr (toRow fromRow) ? 1 : (toRow fromRow ? -1 : 0); int dc (toCol fromCol) ? 1 : (toCol fromCol ? -1 : 0); int row fromRow dr, col fromCol dc; int count 0; while (row ! toRow || col ! toCol) { if (board.board[row][col] ! NO_PIECE) count; row dr; col dc; } PieceType target board.board[toRow][toCol]; if (target NO_PIECE) return count 0; // 移动中间无子 return count 1; // 吃子中间恰有一个子 }这个实现比网上很多版本都简洁。网上常见错误是先把吃子和移动用if分开然后在吃子分支里数路径上的第一个子是不是目标位置那样非常容易数错。用count统一统计路径中间子的数量逻辑一目了然。3.3 将军与胜负判定将军判定是规则引擎里比较核心的部分给定一个棋盘状态和某一方判断该方的将/帅是否正被对方攻击。实现思路是反向攻击检测——遍历对方所有棋子对每个棋子调用对应的走法判断函数不考虑自将军检查它能不能走到己方将/帅的位置bool RuleEngine::isKingInCheck(const BoardModel board, bool redKing) { int kingRow, kingCol; // 找到指定方将/帅的位置 PieceType kingPiece redKing ? RED_KING : BLACK_KING; for (int r 0; r 10; r) { for (int c 0; c 9; c) { if (board.board[r][c] kingPiece) { kingRow r; kingCol c; } } } // 遍历对方所有棋子看能否攻击到将/帅 for (int r 0; r 10; r) { for (int c 0; c 9; c) { PieceType p board.board[r][c]; if (p NO_PIECE) continue; bool oppRed redKing ? false : true; if (RuleEngine::isRed(p) ! oppRed) continue; if (isMoveShapeOk(board, p, r, c, kingRow, kingCol)) { return true; } } } return false; }胜负判定有两种常见方案第一种最简单——只要将/帅被吃掉游戏立即结束第二种更贴近正规规则——将被将军的一方若无法通过走棋解除将军无论吃子、挡子还是躲将则判负。我的项目里采用了第二种方案因为AI对局时被将军但可以支士这类情况经常出现只靠吃将判断会提前结束体验不对。判断是否无解的做法是遍历被将军一方的所有合法走法只要存在一个走法走完后己方将/帅不再处于被将军状态就说明还有救。若所有走法都无效则判负。这个逻辑在AI搜索中也会复用因为AI需要知道自己当前局面有没有被将死的风险。4. 界面交互与游戏流程4.1 用QPainter绘制棋盘绘制棋盘是整个项目最直观的部分代码不复杂但细节不少。我在paintEvent里按以下顺序绘制背景、横线竖线、九宫斜线、河界文字、炮位和兵位标记、棋子。void ChessWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 棋盘背景 painter.fillRect(rect(), QColor(222, 184, 135)); // 画横线 for (int row 0; row 10; row) { int y MARGIN row * CELL_SIZE; painter.drawLine(QPoint(MARGIN, y), QPoint(MARGIN 8 * CELL_SIZE, y)); } // 画竖线中间两侧的竖线在河界处断开 for (int col 0; col 9; col) { int x MARGIN col * CELL_SIZE; painter.drawLine(QPoint(x, MARGIN), QPoint(x, MARGIN 4 * CELL_SIZE)); // 上半 painter.drawLine(QPoint(x, MARGIN 5 * CELL_SIZE), QPoint(x, MARGIN 9 * CELL_SIZE)); // 下半 } // 九宫斜线 painter.drawLine(QPoint(MARGIN 3 * CELL_SIZE, MARGIN), QPoint(MARGIN 5 * CELL_SIZE, MARGIN 2 * CELL_SIZE)); painter.drawLine(QPoint(MARGIN 5 * CELL_SIZE, MARGIN), QPoint(MARGIN 3 * CELL_SIZE, MARGIN 2 * CELL_SIZE)); painter.drawLine(QPoint(MARGIN 3 * CELL_SIZE, MARGIN 7 * CELL_SIZE), QPoint(MARGIN 5 * CELL_SIZE, MARGIN 9 * CELL_SIZE)); painter.drawLine(QPoint(MARGIN 5 * CELL_SIZE, MARGIN 7 * CELL_SIZE), QPoint(MARGIN 3 * CELL_SIZE, MARGIN 9 * CELL_SIZE)); // 河界文字 QFont font painter.font(); font.setPointSize(16); font.setBold(true); painter.setFont(font); painter.drawText(QRect(MARGIN 1 * CELL_SIZE, MARGIN 4 * CELL_SIZE, 3 * CELL_SIZE, CELL_SIZE), Qt::AlignCenter, 楚 河); painter.drawText(QRect(MARGIN 5 * CELL_SIZE, MARGIN 4 * CELL_SIZE, 3 * CELL_SIZE, CELL_SIZE), Qt::AlignCenter, 汉 界); drawPieces(painter); }竖线在河界处断开是象棋棋盘比较有辨识度的特征不要画成完整的贯穿线。九宫斜线的坐标计算要仔细红方九宫在下半部分黑方在上半部分。4.2 鼠标点击与走棋交互流程交互流程是整个游戏的用户故事玩家先点击己方棋子选中它再点击一个目标格完成走棋。期间要处理选中状态、高亮可走位置、非法点击提示等情况。我用的方案是mousePressEvent里维护两个状态变量——selectedRow/selectedCol表示当前选中的棋子位置。如果点击位置有己方棋子就把它选中并在界面上高亮如果已经选中了一个棋子再点击的位置是合法目标就走棋否则取消选中。void ChessWidget::mousePressEvent(QMouseEvent* event) { int row, col; if (!pixelToBoard(event-pos(), row, col)) return; if (selectedRow -1) { // 未选中棋子尝试选中己方棋子 PieceType p model.board[row][col]; if (p ! NO_PIECE isPlayerTurn(p)) { selectedRow row; selectedCol col; update(); } } else { // 已选中棋子尝试走棋 if (row selectedRow col selectedCol) { // 点击同一个位置取消选中 selectedRow -1; selectedCol -1; update(); return; } PieceType target model.board[row][col]; if (target ! NO_PIECE sameSide(target, model.board[selectedRow][selectedCol])) { // 点击己方另一个棋子切换选中 selectedRow row; selectedCol col; update(); return; } if (controller.tryMove(selectedRow, selectedCol, row, col)) { selectedRow -1; selectedCol -1; } else { // 非法走法可以播放错误提示音或闪烁提示简单起见不处理 QApplication::beep(); } update(); } }这里有个体验细节点击己方另一个棋子时应该切换选中而不是走棋失败。这比先取消再重新选更流畅。选中时我在棋子上画一个高亮圆环可走位置在目标格画半透明的圆圈这些视觉效果都在paintEvent里根据状态追加绘制。4.3 悔棋、新开局与轮次控制轮次控制放在GameController里统一管理。GameController维护一个枚举状态PLAYER_TURN、AI_THINKING、GAME_OVER。玩家走完一步后状态切到AI_THINKING用QTimer::singleShot延迟200毫秒再让AI走棋这样界面有时间刷新不会出现卡一下的感觉。悔棋功能需要数据结构支持。我用一个QVectorMoveRecord记录每一步起点、终点、被吃的棋子、走棋方。悔棋时从栈顶弹出一步把棋盘恢复目标位置设置回被吃的棋子如果有起点位置重新放回走棋方棋子。注意如果玩家悔棋一步AI上一步也要一并悔掉否则先后手会混乱void GameController::undo() { if (moveHistory.isEmpty()) return; // 如果AI刚走完需要撤回AI的一步和玩家的一步 if (!moveHistory.isEmpty() currentTurn PLAYER_TURN) { applyMoveReverse(moveHistory.pop()); } if (!moveHistory.isEmpty()) { applyMoveReverse(moveHistory.pop()); } currentTurn PLAYER_TURN; emit boardChanged(); }新开局功能简单调用BoardModel的reset清空历史记录重置轮次。这里要记得把选中状态也清掉否则重新开局后可能残留一个高亮的旧棋子。5. 简易AI让电脑陪你下棋5.1 评估函数怎么写没有AI的中国象棋项目只能双人对战有了AI才有人机对战的玩法。这个项目我实现了一个入门级AI强度中等偏下适合练手和学习搜索算法的原理。AI的核心是评估函数。评估函数对当前棋盘打分正数表示红方优势负数表示黑方优势。最简单的做法是计算双方棋子价值差棋子分值将/帅10000车900马400炮450象/相200士/仕200兵/卒100这只是基础分值还可以给每个棋子设置位置加分表。比如兵过河后价值应该提升马在边角位置价值下降。位置加成表是一个10行9列的二维数组直接把值加到基础分上。我实现时把位置表写得比较简单没做复杂的开局库但对于学习够了。int AIEngine::evaluate(const BoardModel board) { int score 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { PieceType p board.board[r][c]; if (p NO_PIECE) continue; int val pieceValue(p); if (RuleEngine::isRed(p)) score val; else score - val; } } return score; // 正数红优负数黑优 }注意视角问题AI如果执黑它要选的走法应该让评估值尽量小AI如果执红则让评估值尽量大。我的做法是让评估函数始终从红方视角计算AI搜索时根据自己执的子力颜色取最大或最小。5.2 极小极大搜索与α-β剪枝AI的决策过程是在当前局面下模拟我方走每一种合法走法然后在对方视角下递归评估后续局面。双方都假设对方会走对自己最有利的一步这个博弈过程就是极小极大搜索。搜索深度我默认设为3层即我方走一步、对方走一步、我方再走一步。3层深度配合剪枝在普通PC上思考时间大约几百毫秒体验适中。想增加难度可以调到4层但首次启动会有明显思考延迟。α-β剪枝是极小极大搜索的优化如果某个分支已经明显比之前的某个选择差就没必要继续搜索。剪枝能砍掉大约一半的分支让3层搜索几乎和2层一样快。核心代码如下int AIEngine::minimax(BoardModel board, int depth, int alpha, int beta, bool maximizing) { if (depth 0) return evaluate(board); // 生成所有合法走法 QVectorMove moves; generateMoves(board, moves, maximizing); if (maximizing) { int best -1000000; for (const Move m : moves) { applyMove(board, m); best qMax(best, minimax(board, depth - 1, alpha, beta, false)); undoMove(board, m); alpha qMax(alpha, best); if (beta alpha) break; // β剪枝 } return best; } else { int best 1000000; for (const Move m : moves) { applyMove(board, m); best qMin(best, minimax(board, depth - 1, alpha, beta, true)); undoMove(board, m); beta qMin(beta, best); if (beta alpha) break; // α剪枝 } return best; } }applyMove和undoMove要成对使用特别注意undoMove时要恢复被吃的棋子。我在实现undo时直接保存了被吃棋子的类型恢复非常可靠。5.3 AI强度调优的经验如果你想让AI更强一点不一定要无限加深搜索深度有几个性价比很高的优化提升评估函数的准确性是最关键的。在基础分值上增加位置加分表、给机动性加一点分比如能走的合法走法数效果比无脑加深深度更明显。我自己测试加了位置表之后AI在3层深度下已经能主动用马换炮、合理吃子。限制搜索范围也有帮助。很多走法明显不理智比如用帅去吃远处的卒可以在生成走法时先做一轮初级过滤剔除明显送死的走法但要注意不要误删合理的兑子走法。这个优化能让4层搜索达到接近5层原始搜索的效果。最后一个小技巧开局阶段可以在AI走棋时加入随机性让AI在前几步棋里从几个分值接近的走法里随机选一个。这样既避免玩家感觉电脑每盘棋都一模一样又不会明显降低AI实力。6. 常见问题与排查技巧实录6.1 坐标错乱、走子漂移现象这个项目里最典型的bug就是点棋盘一个位置棋子跑到旁边去了。我排查时发现基本都是像素坐标转换的边界没处理好。特别是用(int)强转而不是qRound时鼠标若点在大格子的边缘强转会直接落到相邻格子。另一个容易混淆的地方是行列坐标。棋盘数组第一个下标是行对应的是屏幕上的y方向第二个下标是列对应屏幕上的x方向。在写boardToPixel时如果你不小心把row和col传反画出来的棋盘就会旋转90度。我把排查这类问题的方法整理成了一个速查表问题现象可能原因解决方法点击后棋子跑到相邻格像素坐标用了(int)强转改用qRound四舍五入棋盘左右镜像行和列参数传反统一坐标命名row代表y方向col代表x方向棋子画在格子线上MARGIN和CELL_SIZE计算不准确手动画一个固定尺寸窗口不用自适应点击无反应pixelToBoard返回false被忽略先判断返回值再执行后续逻辑6.2 规则漏判的排查方法规则方面最常见的坑是马腿和象眼判断。这个没有别的捷径只能把每个棋子单独拎出来写测试用例。我习惯在main函数里临时加一段测试代码固定棋盘为初始局面然后逐个试马能不能走到某个位置比对预期结果。比如初始局面下红方马在(9,1)它应该能走到(7,2)和(8,3)但走不到(7,0)因为那个位置被自己车挡住马腿。手动验证几个关键用例之后再去测混合局面。还有一个经常被忽略的规则是将军时不能随便走棋。很多新手写的规则引擎只检查了走法本身的形状没做自将军检查。结果会出现明明主帅被将军了玩家还能走一步完全无关的棋。排查这个问题只需要记住一条原则——任何走棋合法性判断的最后一步都是模拟走棋后检查己方将/帅是否安全。这个检查一定要放在所有形状判断之后并且使用临时副本不要污染真实棋盘。6.3 界面刷新与事件冲突类问题界面刷新问题通常表现为走棋后图形没有更新。最常见原因是改了棋盘数据后调用update()的时机不对。update()是异步刷新请求不是立即重绘所以你在循环里连续改数据再调update()可能只重绘一次而且如果后续代码又改回了数据画出来的就是旧状态。正确做法是每次走棋完成后调用一次update()不要在一个事件处理函数里多次修改棋盘数据。事件冲突方面如果你在棋盘上加了右键菜单或者拖拽事件要小心它们和左键点击事件互相干扰。我只处理了mousePressEvent没有用mouseMoveEvent和mouseReleaseEvent避免引入状态机复杂度。如果你要加拖拽走子功能建议先把基础的点选走棋跑通再加拖拽作为增强。注意QTimer::singleShot延迟AI走棋时要防止玩家在AI思考期间连续点击棋盘。我的做法是在AI_THINKING状态下直接忽略鼠标事件并在状态切换时及时update()刷新界面提示。6.4 资源与编译相关的坑这个项目不需要任何外部资源文件所以基本不会遇到资源路径问题。但如果你用Qt Creator新建工程编译器套件选错会导致QPainter头文件找不到或者链接报错。我建议在首次编译前先确认Qt版本选5.12或5.15编译器选MinGW 64位或MSVC2017 64位并且保持它们的位数一致不要32位MinGW配64位MSVC。如果遇到unknown type name qint64这类报错多半是编译器工具链配置有问题重新设置一下Kit即可。编译通过但运行时汉字显示成方块大概率是字体问题。在Windows上设置QFont(SimHei)、Linux上设置QFont(Noto Sans CJK SC)可以解决。如果你想做跨平台可以用QFontDatabase动态挑一个支持中文的字体。写在最后的一些体会这个项目我从零开始写前后花了大概两天时间其中规则引擎和AI搜索占了大头。做完之后有一个很深的感受象棋看起来规则简单但完整实现合法走棋 将军检测 AI搜索这套闭环涉及的东西远比想象中多。特别是自将军检查和AI搜索的配合两者之间联动经常出现隐蔽的bug比如AI在搜索过程中生成了一个让自己送将的走法并当作合法走法处理导致评估值失真。解决这类问题最好的办法是给RuleEngine写单元测试测试数据准备好后续改AI都不会慌。如果你打算在这个基础上继续扩展我建议优先加两个功能一是走棋历史记录和棋谱导入导出这能把项目从能玩提升到有实用价值二是增加开局库让AI开局不那么容易落入被动。这两个方向网上资料多也都能很好地融入现有架构。做项目最怕的不是功能少而是结构乱到不敢改分层清晰之后功能扩展其实是水到渠成的事。本文还有配套的精品资源点击获取