资讯详情

Qt C++ 中国象棋人机对战:从棋盘绘制到AI搜索的完整实现

📅 2026/11/2 5:01:41 | 华诺云谱 👁 阅读
Qt C++ 中国象棋人机对战:从棋盘绘制到AI搜索的完整实现
简介这份资源面向具备一定 C 基础、希望借助 Qt 框架练手桌面 GUI 与博弈算法的开发者提供一套可运行的中国象棋人机对战完整工程。项目以 Qt Creator 为开发环境围绕棋盘二维数组建模、各棋子走法规则校验、鼠标交互落子、基于搜索与评估函数的人机决策以及胜负判定与流程管理等环节展开适合作为课程设计、毕业设计或 Qt 入门后的进阶实战案例。压缩包共 47 个文件约 575KB以 14 个 .h 头文件与 14 个 .cpp 源文件构成核心逻辑配合 2 个 .ui 界面文件、1 个 .qrc 资源文件及若干 jpg、png 图片素材另有 .pro 工程配置与 README 说明目录组织清晰便于按模块阅读与二次开发。目前已有 1002 人学习下载。读者可从中获得一套结构完整的人机对战实现参考理解界面与逻辑分层、棋子规则封装及 AI 搜索评估的落地方式并在此基础上扩展难度等级、棋谱记录或网络对战等功能。1. 一个能跑起来的中国象棋人机对战从棋盘到AI落子的完整工程很多人第一次接触 Qt 项目都是从“能画个界面”开始的但真正能把界面、规则、搜索算法串成一条完整链路的练手项目并不多。这个基于 Qt Creator 和 C 实现的中国象棋人机对战恰好卡在一个很舒服的位置棋盘绘制、走子规则、局面评估、极小化极大搜索这几块都得自己写但又不至于像通用棋类引擎那样庞大到读不动。它适合两类人一类是刚学完 C 和 Qt 基础控件、想找一个“有真实逻辑”的项目练手的开发者另一类是做过五子棋、井字棋想看看象棋这种走法复杂、状态空间大的棋类AI 部分到底该怎么组织代码的人。整个项目围绕 Qt Widgets 的绘图事件和信号槽展开人机对战的核心不在界面而在“轮到电脑走”那一刻程序如何生成合法着法、如何评估局面、如何选出一个不犯傻的落子点。2. 工程结构与技术选型Qt Widgets 加纯 C 规则引擎2.1 为什么用 Qt Widgets 而不是 QML这个项目的界面部分没有走 QML 路线而是用 Qt Widgets 的QWidget加paintEvent手绘棋盘。原因很实际象棋棋盘是固定 9 列 10 行的网格棋子是圆形加汉字用QPainter画线、画圆、drawText写“车马炮”完全够用而且坐标计算直接对应棋盘数组下标调试时一眼就能对上。QML 虽然动画漂亮但要把点击坐标映射回棋盘行列、再和 C 规则层通信反而多一层胶水代码。项目里常见的做法是主窗口继承QWidget重写paintEvent负责绘制重写mousePressEvent负责把像素坐标换算成棋盘坐标规则判断和 AI 搜索放在独立的类里界面只负责“显示”和“转发点击”。技术栈上除了 Qt Core 和 Qt Gui基本不依赖第三方库。棋盘状态用一个二维数组或一维数组表示棋子用枚举区分红黑和兵种走法生成用循环加边界判断搜索部分自己实现。这种“零外部依赖”的结构对新手很友好克隆下来用 Qt Creator 打开.pro文件就能编译不需要配 CMake 包管理器或者额外装库。2.2 目录与类的职责划分一个能维护的 Qt 象棋项目类不会全堆在mainwindow.cpp里。常见的拆分方式是四层界面层、棋盘数据层、规则层、AI 层。下面是一个典型的文件组织具体文件名可能不同但职责边界值得参考。xiangqi/ ├── xiangqi.pro # qmake 工程文件 ├── main.cpp # 入口创建 QApplication 和主窗口 ├── mainwindow.h/.cpp # 主窗口绘图、鼠标事件、人机轮转控制 ├── board.h/.cpp # 棋盘数据棋子数组、初始化、走子/撤销 ├── piece.h # 棋子枚举与基础结构 ├── movegen.h/.cpp # 走法生成各兵种合法着法、将军判断 ├── evaluate.h/.cpp # 局面评估子力价值、位置分 └── engine.h/.cpp # AI 搜索极小化极大 Alpha-Beta 剪枝mainwindow只做三件事把board画出来、把鼠标点击翻译成“从哪到哪”、在玩家走完后触发engine计算电脑的回应。board管状态movegen管合法性evaluate管打分engine管搜索。这样拆的好处是调 AI 强弱时只动engine和evaluate调界面时完全不碰规则代码改错也不会互相牵连。2.3 qmake 工程文件的关键配置用 Qt Creator 新建项目时默认生成.pro文件这个项目不需要额外模块但有几个点要注意。下面是精简后的工程配置注释里标了每行的作用。QT core gui # 只需要核心和界面模块不用 network/sql greaterThan(QT_MAJOR_VERSION, 4): QT widgets # Qt5 及以上要显式加 widgets TARGET xiangqi # 生成的可执行文件名 TEMPLATE app # 应用程序模板 SOURCES \ main.cpp \ mainwindow.cpp \ board.cpp \ movegen.cpp \ evaluate.cpp \ engine.cpp HEADERS \ mainwindow.h \ board.h \ piece.h \ movegen.h \ evaluate.h \ engine.hQT core gui是必须的widgets在 Qt5 之后从 gui 里独立出来漏写会报找不到QWidget。TEMPLATE app表示生成可执行程序而不是库。如果编译时提示某个.cpp里的符号未定义先检查SOURCES里有没有漏加文件这是新手最常见的翻车点之一。3. 棋盘与走子规则把象棋规则翻译成数组操作3.1 棋盘数据结构与初始化棋盘用一维数组存 90 个格子索引row * 9 col行 0 到 9 从上到下列 0 到 8 从左到右。棋子用整数编码正数代表红方负数代表黑方绝对值对应兵种。这种编码的好处是判断敌我只需看符号取兵种只需取绝对值。// piece.h enum PieceType { EMPTY 0, KING 1, // 帅/将 ADVISOR 2, // 仕/士 BISHOP 3, // 相/象 KNIGHT 4, // 马 ROOK 5, // 车 CANNON 6, // 炮 PAWN 7 // 兵/卒 }; // board.h class Board { public: int cells[90]; // 正数红方负数黑方0 为空 void init(); // 摆初始局面 bool movePiece(int from, int to); // 执行走子返回是否成功 void undoMove(int from, int to, int captured); // 撤销用于搜索回溯 };init()里按固定位置填子红方在下行 7 到 9黑方在上行 0 到 2。这里容易出错的是“马腿”和“象眼”的坐标换算因为一维索引做行列出界判断时不能简单用index ± 1判断左右必须先把索引拆成row idx / 9、col idx % 9否则第 0 列往左会绕到上一行最后一列产生“穿墙”走法。我一般会在走法生成里统一用行列坐标计算最后再转回一维索引虽然多几行代码但边界判断清晰得多。3.2 各兵种走法生成的核心逻辑走法生成是规则层的核心每个兵种一个函数输入当前棋盘和起点输出所有合法终点。下面以马和炮为例这两个最能体现象棋规则的特殊性。// movegen.cpp 片段生成马的走法 void genKnightMoves(const Board b, int from, std::vectorint moves) { int row from / 9, col from % 9; int side (b.cells[from] 0) ? 1 : -1; // 红为正黑为负 // 八个方向先走直线一格马腿再走斜线一格 const int dr[8] {-2, -2, -1, 1, 2, 2, 1, -1}; const int dc[8] {-1, 1, 2, 2, 1, -1, -2, -2}; const int legR[8] {-1, -1, 0, 0, 1, 1, 0, 0}; // 马腿所在行偏移 const int legC[8] {0, 0, 1, 1, 0, 0, -1, -1}; // 马腿所在列偏移 for (int i 0; i 8; i) { int nr row dr[i], nc col dc[i]; int lr row legR[i], lc col legC[i]; if (nr 0 || nr 9 || nc 0 || nc 8) continue; // 出界 if (b.cells[lr * 9 lc] ! 0) continue; // 蹩马腿 int target b.cells[nr * 9 nc]; if (target * side 0) moves.push_back(nr * 9 nc); // 空位或敌子 } }这段代码里dr/dc是八个落点方向legR/legC是对应的马腿位置。判断target * side 0的意思是目标格为空0或者是敌方棋子符号相反乘积为负不能吃己方子。炮的走法不同炮吃子必须隔一个“炮架”所以生成时要沿四个方向扫描遇到第一个子记下位置继续扫到第二个子如果是敌子才能吃。// movegen.cpp 片段生成炮的走法 void genCannonMoves(const Board b, int from, std::vectorint moves) { int row from / 9, col from % 9; int side (b.cells[from] 0) ? 1 : -1; const int dr[4] {-1, 1, 0, 0}; const int dc[4] {0, 0, -1, 1}; for (int d 0; d 4; d) { int r row dr[d], c col dc[d]; bool hasScreen false; // 是否已经遇到炮架 while (r 0 r 9 c 0 c 8) { int idx r * 9 c; if (!hasScreen) { if (b.cells[idx] 0) { moves.push_back(idx); // 无炮架时只能走空位 } else { hasScreen true; // 遇到第一个子成为炮架 } } else { if (b.cells[idx] ! 0) { if (b.cells[idx] * side 0) moves.push_back(idx); // 隔山打敌子 break; // 无论敌我第二个子后停止 } } r dr[d]; c dc[d]; } } }参数说明from是起点一维索引moves收集合法终点。炮的逻辑关键是hasScreen状态位没遇到炮架前只能走空遇到后只找第一个非空子是敌方才加入结果。这里如果写成“遇到敌子就吃”而忘了break炮会穿过多个子连续吃属于典型规则错误测试时用炮在中路隔子打将很容易暴露。3.3 将军判断与合法着法过滤生成完伪合法着法后还要过滤掉“走完后自己被将军”的着法。常见做法是在Board上先执行走子调用isKingAttacked(side)检查己方帅/将是否被对方任一棋子攻击到如果被攻击就撤销这步。isKingAttacked可以复用走法生成遍历对方所有棋子看它们的伪合法着法是否包含己方帅/将的位置。这样写虽然慢一点但代码复用度高搜索层做深度剪枝后整体还能接受。注意“将帅照面”也是将军的一种两个帅/将在同一列且中间无子时走成照面的一方判负这个规则要在isKingAttacked里单独判断漏掉的话 AI 会走出送帅的着法。4. AI 搜索与评估让电脑走出“像样”的棋4.1 极小化极大框架与 Alpha-Beta 剪枝人机对战的核心在engine类。最基础的框架是极小化极大红方玩家走时选让局面分最高的着法黑方电脑走时选让局面分最低的着法递归到指定深度后调用评估函数返回分数。纯极小化极大在象棋上跑不深所以必须加 Alpha-Beta 剪枝把不可能影响结果的分支提前砍掉。// engine.cpp 片段带 Alpha-Beta 的搜索 int Engine::search(Board b, int depth, int alpha, int beta, bool redTurn) { if (depth 0) return evaluate(b); // 到达叶子返回局面分 std::vectorint moves; generateAllMoves(b, redTurn, moves); // 生成当前方所有合法着法 if (moves.empty()) return redTurn ? -MATE : MATE; // 无棋可走被将死或困毙 if (redTurn) { int best -INF; for (int mv : moves) { int from mv / 100, to mv % 100; // 编码起点终点 int captured b.cells[to]; b.movePiece(from, to); int score search(b, depth - 1, alpha, beta, false); b.undoMove(from, to, captured); best std::max(best, score); alpha std::max(alpha, best); if (alpha beta) break; // 剪枝 } return best; } else { int best INF; for (int mv : moves) { int from mv / 100, to mv % 100; int captured b.cells[to]; b.movePiece(from, to); int score search(b, depth - 1, alpha, beta, true); b.undoMove(from, to, captured); best std::min(best, score); beta std::min(beta, best); if (alpha beta) break; } return best; } }参数说明depth是剩余搜索层数alpha是当前最大下界beta是最小上界redTurn区分轮到哪方。着法用from * 100 to编码成一个整数省去结构体传参。MATE是一个很大的分数表示将死。剪枝条件alpha beta的意思是当前分支已经不可能比祖先节点已有的选择更好直接放弃。这里最容易翻车的是movePiece和undoMove必须严格配对搜索里任何一条提前return的路径如果漏了撤销棋盘状态就脏了后面所有评估都是错的而且这种 bug 不会崩溃只会让 AI 越走越怪属于典型的“黑匣子”问题。4.2 局面评估函数的设计评估函数决定 AI 的“棋感”。最基础的是子力价值车 900、马 400、炮 450、兵 100过河后加分、仕相 200帅/将给一个极大值。光有子力不够还要加位置分比如兵过河加 50、马在中心加 20、车占开放线加分。下面是一个简化版评估红方为正、黑方为负最后返回红方视角的总分。// evaluate.cpp 片段 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 side (p 0) ? 1 : -1; int value 0; switch (type) { case ROOK: value 900; break; case CANNON: value 450; break; case KNIGHT: value 400; break; case ADVISOR:value 200; break; case BISHOP: value 200; break; case PAWN: value 100; break; case KING: value 10000; break; } // 兵过河加分红兵行号小于 5 表示已过河 if (type PAWN) { int row i / 9; if ((side 0 row 5) || (side 0 row 4)) value 50; } score side * value; } return score; }这个评估很粗糙但足够让 AI 不白送子。想让它更强可以在evaluate里加机动性分合法着法数量、加“车马炮是否被牵制”的简单判断或者给不同兵种配位置权重表。注意评估函数必须对称红方车和黑方车在对称位置上的分值要一致否则 AI 会莫名其妙偏爱某一方调试时可以用空棋盘摆对称子力验证。4.3 搜索深度与性能的平衡纯 C 加 Alpha-Beta深度 4 到 5 层在普通机器上大概几百毫秒到一两秒再深就要加着法排序先搜吃子、将军等着法和置换表。项目里常见的做法是把深度做成可配置界面上给个“简单/普通/困难”下拉框简单对应深度 2普通深度 4困难深度 6 加简单排序。着法排序的一个低成本技巧是吃子着法按被吃子价值减走子价值排序先搜“车吃马”这种赚的剪枝效率会明显提升。如果发现电脑思考时间过长卡住界面说明搜索跑在主线程里需要挪到QThread或用QtConcurrent::run搜索期间禁用玩家点击算完再通过信号槽把落子结果发回界面。5. 避坑与常见问题编译、规则、搜索三层排查5.1 编译报错找不到 QWidget 或链接失败现象Qt Creator 里代码没红线但编译提示QWidget: No such file or directory或者链接阶段报一堆undefined reference。原因通常是.pro里漏了QT widgets或者新增的.cpp没加进SOURCES。解决先检查.pro的QT行Qt5 以上必须显式写widgets再检查报未定义的符号属于哪个类把对应源文件补进SOURCES然后执行“构建 → 执行 qmake”再重新编译只点构建有时不会重新读.pro。5.2 棋子能穿墙或走出棋盘现象马从第 0 列往左跳结果跳到了上一行第 8 列或者车沿着边线走一圈回到原地。原因是一维索引直接做±1加减没有拆行列判断边界。解决所有走法生成统一先算row idx / 9、col idx % 9在行列坐标上判断0 row 9、0 col 8最后再合成row * 9 col。改完后用“马在四个角、车贴边”的用例逐个验证。5.3 AI 思考后棋盘状态错乱现象电脑走完一步后某个棋子凭空消失或者玩家能吃自己的子。原因多半是搜索里movePiece后某条分支提前返回没有执行undoMove导致棋盘被改脏。解决把“走子—递归—撤销”写成固定三行中间不要插return或者用 RAII 思路包一个临时走子对象析构时自动撤销。调试时可以在每次搜索返回后打印棋盘哈希和搜索前对比不一致就说明有泄漏的走子。5.4 电脑一直送子或原地打转现象AI 明明有子可吃却不吃或者反复走同一个子。原因通常是评估函数符号写反红黑视角混了或者搜索里redTurn传参错误导致电脑在替玩家选“最差”着法。解决先写一个固定局面的单元测试手动摆一个“红车能吃黑马”的局面调用搜索看返回的着法是不是吃马再检查evaluate返回值的正负是否和redTurn对应。另一个常见原因是深度为 0 时直接返回评估分但评估分没有考虑“轮到谁走”导致静态局面分和搜索层视角不一致。5.5 点击落子位置偏移现象鼠标点在第 3 行第 4 列棋子却落在第 2 行第 5 列。原因是paintEvent里棋盘左上角有边距鼠标坐标换算时没减掉边距或者格子宽高用了浮点但取整方式不一致。解决把棋盘边距、格子宽高定义成常量绘制和点击换算共用同一组常量换算时用(x - marginX) / cellW并做四舍五入而不是直接整除。改完在棋盘四个角和中心各点一次确认落点准确。6. 二次开发从“能下”到“下得好”的改造路径拿到这个项目后最值得先动的不是界面皮肤而是搜索层的着法排序和置换表因为这两块对棋力提升最直接改动也相对独立。我的习惯是先在engine里加一个简单的着法排序生成着法后按“吃子收益”降序排吃子收益用被吃子价值减走子价值估算将军着法额外加权重。只加这一步同样深度下搜索节点数通常能降不少电脑落子会明显更快深度也能往上提一层。// 着法排序示例吃子优先 void sortMoves(Board b, std::vectorint moves) { std::sort(moves.begin(), moves.end(), [](int a, int c) { int toA a % 100, toC c % 100; int capA std::abs(b.cells[toA]); // 被吃子价值 int capC std::abs(b.cells[toC]); return capA capC; // 吃大子的着法排前面 }); }排序放在search生成着法之后、循环之前参数就是当前棋盘和着法列表。注意排序只影响着法搜索顺序不改变最终结果所以即使排序逻辑不完美也不会让 AI 下错棋属于低风险改动。验证方法是同一局面、同一深度对比加排序前后搜索的节点计数可以在search入口加个静态计数器节点数下降就说明剪枝生效。第二个改造点是评估函数的位置分。子力价值只能让 AI 不送子但走不出“占中路、控肋道”的感觉。可以给每个兵种配一张 90 格的位置权重表红方和黑方镜像使用评估时查表累加。这张表不用一次写完美先给兵过河、马占中心、车占开放线加几档分跑几盘看 AI 是否还愿意把马往边角跳再微调数值。这里有个血泪经验位置分不要加得太猛否则 AI 会为了“占好位置”白丢子子力分和位置分的量级要拉开通常位置分单项不超过对应子力价值的十分之一。第三个方向是把搜索挪到后台线程。界面卡顿是这类项目最常见的体验问题改法是新建一个QThread子类或QObject加moveToThread把Engine::search放进去跑算完后用信号把from/to发回主线程执行落子。注意搜索期间要禁用玩家点击用一个bool waiting标志在mousePressEvent里提前返回否则玩家在电脑思考时又点一步棋盘状态就乱了。线程间只传着法编码不传棋盘指针避免数据竞争。最后是验证方法每改一次 AI固定摆三个局面——开局、中局有子可吃、残局将死——分别让电脑执黑走一步看落子是否合理再跑一局完整对战观察有没有卡死、有没有非法着法。我一般会在movePiece里加一个断言检查起点非空、终点不是己方子一旦触发就打印棋盘这样规则层的错误在开发期就能暴露不用等到对局中途才发现。从那以后我每次改搜索或评估都强制先跑这三个固定局面再开新局省了很多回头查 bug 的时间。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑