简介这份资源面向具备一定 C 基础、希望借助 Qt 框架练手图形界面与算法设计的开发者提供一套完整的中国象棋人机对战项目源码。项目以 Qt Creator 为开发环境围绕棋盘二维数组建模、各棋子走法规则判定、鼠标交互反馈、基于搜索与评估函数的人工智能决策以及游戏流程管理等核心环节展开可作为课程设计、毕业设计或 Qt 入门实战的参考范例。压缩包共 47 个文件约 575KB以 14 个 .h 头文件与 14 个 .cpp 源文件为主体配合 2 个 .ui 界面文件、1 个 .qrc 资源文件及若干 jpg、png 图片素材另有 .pro 工程配置与 README 说明模块划分清晰便于按头文件与实现文件对照阅读。目前已有 1002 人学习下载读者可从中了解 Qt 信号槽与界面布局的落地方式、棋子规则与搜索算法的组织思路以及多文件工程的目录结构设计。1. 一个 Qt 象棋人机对战工程从能跑到能改的落地拆解前阵子帮一个做嵌入式的朋友看他的课程设计需求很朴素用 Qt Creator 加 C 做一个中国象棋人机对战界面能走子、电脑能应招、能判断输赢。他卡在“电脑走了一步之后界面不刷新”上折腾了两天。这类工程在网上一搜一大把但真正能编译、能改、能讲清楚搜索逻辑的并不多。这份资源就是围绕这个场景整理的Qt Widgets 画棋盘、C 写规则与搜索、人机对弈闭环。它适合三类人——课程设计要交差的学生、想练手 Qt 事件与绘图机制的初中级开发者、以及想给老项目补一个像样 AI 的维护者。下面按“它怎么组织、怎么跑起来、哪里会翻车、怎么改强”的顺序拆。2. 工程结构与棋盘绘制先把“看得见”的部分立住拿到一个 Qt 象棋工程别急着点运行。先看目录判断它是纯 Widgets 还是混了 QML是单文件堆逻辑还是分了模块。这决定了你后面改 AI 时会不会牵一发动全身。常见做法是main.cpp只负责拉起主窗口棋盘、棋子、规则、搜索各占一对.h/.cpp资源图走.qrc。如果所有东西都塞在mainwindow.cpp里超过两千行那基本可以判定是“能跑但不好改”的类型得先做拆分再谈优化。2.1 目录与类职责的读法一个结构清晰的工程类职责大致是这样切的BoardWidget继承QWidget只管画和收鼠标事件ChessBoard或Board存 10×9 的棋子数组和走子规则Engine或AI负责搜索与评估MainWindow做菜单、计时、悔棋这些外围。读代码时先找“谁持有棋盘状态”通常是一个二维数组int board[10][9]用正负号区分红黑、绝对值区分兵种。找到这个数组整个工程的数据流就清楚了鼠标点击 → 坐标换算 → 规则校验 → 改数组 → 触发重绘 → 轮到 AI → 搜索出一步 → 再改数组 → 再重绘。判断坐标体系是第一个容易懵的点。象棋棋盘是 9 列 10 行但屏幕像素是左上角原点。工程里一般会定义CELL_SIZE比如 60 像素和边距MARGIN把像素坐标(x, y)换算成棋盘坐标(col, row)。换算公式常见是col (x - MARGIN) / CELL_SIZErow (y - MARGIN) / CELL_SIZE再做一次范围裁剪。如果你发现点棋子总是偏半格八成是边距没算对或者用了round而不是整除。2.2 用 paintEvent 画棋盘与棋子Qt Widgets 里画棋盘的标准入口是重写paintEvent。下面这段是这类工程里最典型的结构我按可复现的方式写出来你可以对照自己手里的版本看差异void BoardWidget::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 1. 画木色底 painter.fillRect(rect(), QColor(238, 203, 140)); // 2. 画 9 竖 10 横的网格线 for (int i 0; i 10; i) { int y MARGIN i * CELL_SIZE; painter.drawLine(MARGIN, y, MARGIN 8 * CELL_SIZE, y); } for (int j 0; j 9; j) { int x MARGIN j * CELL_SIZE; // 楚河汉界处竖线断开 if (j 0 || j 8) { painter.drawLine(x, MARGIN, x, MARGIN 9 * CELL_SIZE); } else { painter.drawLine(x, MARGIN, x, MARGIN 4 * CELL_SIZE); painter.drawLine(x, MARGIN 5 * CELL_SIZE, x, MARGIN 9 * CELL_SIZE); } } // 3. 遍历棋盘数组画棋子 for (int r 0; r 10; r) { for (int c 0; c 9; c) { int piece board[r][c]; if (piece 0) continue; QPoint center cellToPixel(r, c); drawPiece(painter, center, piece); } } // 4. 高亮上一步或选中格 if (selectedRow 0) { painter.setPen(QPen(Qt::red, 2)); painter.drawRect(cellRect(selectedRow, selectedCol)); } }逻辑上分四层底色、网格、棋子、高亮。参数里MARGIN和CELL_SIZE必须和鼠标事件里的换算共用同一组常量否则点击和绘制会对不上这是新手最常见的“点了没反应”来源。drawPiece里一般用QPainter::drawPixmap贴图或者用drawEllipse加drawText画圆写字贴图方案要注意.qrc里的路径大小写Linux 下大小写敏感Windows 下不敏感跨平台时容易一边能跑一边白块。提示paintEvent里不要做搜索、不要读文件、不要sleep。AI 思考必须放到主线程之外或至少用定时器切片否则界面直接假死用户以为程序崩了。2.3 鼠标事件与走子校验的衔接mousePressEvent里做的事只有三件像素转棋盘坐标、判断点的是不是己方棋子、判断目标格是否合法。合法走子规则建议单独放一个bool canMove(int fromR, int fromC, int toR, int toC)内部按兵种分支车走直线、马走日且蹩马腿、象走田且塞象眼、炮吃子隔一个、将帅九宫且不能照面。把这些规则写成纯函数的好处是AI 搜索时可以直接复用同一套校验不用维护两份逻辑——两份逻辑一旦不同步就会出现“人走合法、AI 走非法”的玄学 bug。走完一步后要做的收尾动作容易被漏切换行棋方、清空选中态、记录历史用于悔棋、检查是否被将军或绝杀、最后update()触发重绘。如果 AI 是同步计算的还要在切换后立刻调用引擎并再走一步。很多“AI 不走棋”的问题根子就在切换行棋方之后没有重新进入 AI 分支。3. 人机对战的搜索内核从随机走子到 Alpha-Beta界面能走子只是壳真正决定这个工程“值不值得改”的是搜索部分。网上不少象棋 Demo 的 AI 其实是“随机挑一个合法走法”或者只做一层贪心吃子这种拿来交差可以但一上手就露馅。一个合格的人机对战至少要具备局面评估函数、极小化极大搜索、Alpha-Beta 剪枝最好再加置换表或走法排序。下面按这个顺序讲清楚每一层为什么需要、参数怎么调。3.1 局面评估子力价值加位置分评估函数决定 AI 的“性格”。最基础的是子力价值车 900、马 400、炮 450、兵 100、士 200、象 200、将 10000红正黑负全盘求和。只靠子力AI 会显得短视因为它不知道“马往中间走更好”。所以常见做法是加位置分表piece-square table给每个兵种在 10×9 的每个格子上预设一个加分比如兵过河加分、马在中心加分、车占开放线加分。int Engine::evaluate(const Board b) { int score 0; for (int r 0; r 10; r) { for (int c 0; c 9; c) { int p b.at(r, c); if (p 0) continue; int type qAbs(p); int sign (p 0) ? 1 : -1; // 红为正黑为负 score sign * (PIECE_VALUE[type] POS_BONUS[type][r][c]); } } return score; // 站在红方视角 }参数说明PIECE_VALUE用数组下标对应兵种编号POS_BONUS是三维表红黑可以共用一张再镜像行号省一半数据。评估函数必须保证“红方视角为正”搜索里做极大极小交替时符号才不会乱。这里有个血泪经验如果评估函数里混入了“当前轮到谁走”的因子搜索深度一加深就会出现 AI 主动送子的怪象因为它在不同层对同一局面给出了不同分数。3.2 极小化极大与 Alpha-Beta 剪枝极小化极大Minimax的思路是AI 假设对手永远走最狠的一步自己在所有走法里选让最终评估最高的那步。朴素实现要遍历整棵博弈树深度 4 就可能上百万节点纯 C 也扛不住交互体验。Alpha-Beta 剪枝的作用是在已知某分支不可能更优时提前放弃理想情况下把有效分支数从 b 降到 √b同样时间能多搜两三层。int Engine::alphaBeta(Board b, int depth, int alpha, int beta, bool redTurn) { if (depth 0 || b.isGameOver()) { return evaluate(b); } QVectorMove moves b.generateMoves(redTurn); orderMoves(moves, b); // 吃子走法优先提升剪枝率 if (redTurn) { int best -INF; for (const Move m : moves) { b.makeMove(m); best qMax(best, alphaBeta(b, depth - 1, alpha, beta, false)); b.unmakeMove(m); alpha qMax(alpha, best); if (alpha beta) break; // 剪枝 } return best; } else { int best INF; for (const Move m : moves) { b.makeMove(m); best qMin(best, alphaBeta(b, depth - 1, alpha, beta, true)); b.unmakeMove(m); beta qMin(beta, best); if (alpha beta) break; } return best; } }关键参数depth是剩余搜索层数交互场景一般 3 到 5 层配合走法排序能到 4 层秒回alpha是当前 MAX 方已知的下界beta是 MIN 方已知的上界初始传-INF/INF。makeMove/unmakeMove必须严格成对且unmakeMove要能完整还原被吃子、行棋方、特殊状态比如是否走过将帅照面判定。这里翻车最多的是“撤销不干净”搜索里改了棋盘但没恢复某个标志位导致后面评估全错表现出来就是 AI 偶尔走出完全看不懂的棋。3.3 走法排序与搜索深度取舍Alpha-Beta 的剪枝效率高度依赖走法顺序。先搜吃大子的走法、再搜历史表现好的走法能让剪枝更早发生。简单实现可以按“被吃子价值 - 走子价值”排序复杂一点用 killer move 或历史启发。对课程设计级别的工程前者足够。深度怎么定我的建议是先固定 4 层跑通再根据实测耗时调整。用QElapsedTimer在搜索前后打点如果单步超过 800 毫秒用户就会觉得卡。此时要么降深度要么加迭代加深先搜 2 层拿到一个可用走法再逐步加深超时就用上一层结果。迭代加深的好处是任何时刻都有一个合法走法兜底不会因为搜索没完成而“AI 不动”。注意搜索里不要直接操作界面控件。引擎只返回Move由主窗口在搜索结束后统一改棋盘并update()。跨线程时用信号槽把结果传回主线程别在子线程里碰QWidget。4. 避坑与排查这类工程最容易翻车的五处4.1 点击棋子没反应或偏移一格现象鼠标点在棋子上选中框出现在旁边一格或者干脆选不中。原因像素到棋盘坐标的换算与绘制用的MARGIN/CELL_SIZE不是同一组或者用了(x - MARGIN) / CELL_SIZE但x是相对父窗口而非控件本身。解决统一把常量放进一个头文件鼠标事件里先用event-pos()拿到控件内坐标再换算加一行qDebug()打印换算结果对照绘制位置校准边距。4.2 AI 思考时界面假死现象轮到电脑走窗口几秒无响应标题栏显示“未响应”。原因搜索在主线程同步执行事件循环被阻塞。解决把引擎放到QThread或QtConcurrent::run搜索完成后通过信号把Move发回主线程或者用QTimer::singleShot(0, ...)把搜索切片每搜一层让出一次事件循环。前者改动小后者更适合需要显示“思考中”动画的场景。4.3 悔棋之后 AI 走子错乱现象悔一步再让 AI 走AI 走了非法棋或重复走同一步。原因历史栈只存了棋步没存被吃子unmakeMove还原不完整或者悔棋只改了界面没改引擎内部状态。解决历史记录用结构体存{from, to, capturedPiece, sideToMove}撤销时按逆序完整还原悔棋一次撤两步人一步、AI 一步保证又回到人的回合。4.4 将帅照面与绝杀判定漏判现象双方将帅在同一列中间无子程序仍允许走别的棋或者被将死时没有提示。原因规则校验只做了单步合法性没做“走完之后己方是否被将军”的反向检查。解决在canMove通过后模拟走子再判断己方将帅是否处于被攻击状态被攻击则这步非法每步走完检查对方是否还有合法走法没有且被将军即为绝杀。4.5 跨平台资源加载失败现象Windows 下棋子图片正常换到另一台机器或另一平台全是空白。原因.qrc路径大小写不一致、图片格式插件缺失或者用了绝对路径。解决资源统一走:/images/xxx.png这种 qrc 路径路径全小写确认QImageReader::supportedImageFormats()包含所用格式发布时用windeployqt或对应平台工具补齐插件目录。5. 把 AI 调强的三个具体手法与验证习惯第一招是给评估函数加“机动性”和“将帅安全”两项。机动性统计己方合法走法数量乘以一个小系数比如 2 到 5能让 AI 更愿意占据开放位置将帅安全则在九宫周围被对方棋子攻击时扣分避免 AI 为了吃子把老将暴露。两项都别给太大权重否则 AI 会变得保守或乱冲建议先用 1:1 的子力分做基准再小幅加。第二招是引入简单的置换表。用 Zobrist 哈希给每个局面算一个 64 位键搜索前查表命中且深度足够就直接返回缓存分数。实现时注意“同局面不同搜索窗口”不能直接复用精确值可以只缓存下界/上界或者干脆只缓存“这个局面搜过、深度是多少”用来做走法排序。对 4 层搜索置换表带来的提升主要体现在中局重复局面多的场景。第三招是做一个“自对弈回归”。改完评估或搜索参数后别只靠人眼下一盘就下结论。写一个小循环让新旧两个引擎各执红黑对下 20 盘统计胜率和平均步数。下面是个最小验证脚本的骨架// 自对弈旧引擎 vs 新引擎各执先手若干盘 int oldWins 0, newWins 0, draws 0; for (int game 0; game 20; game) { Board b; Engine oldE(/*旧参数*/), newE(/*新参数*/); bool redTurn true; while (!b.isGameOver() b.moveCount() 300) { Engine cur (redTurn (game % 2 0)) ? newE : oldE; Move m cur.search(b, 4); b.makeMove(m); redTurn !redTurn; } // 根据 b 的结果累计胜负 } qDebug() new: newWins old: oldWins draw: draws;参数上盘数别太少10 盘以内噪声很大深度固定成一样否则比的是算力不是算法。跑完看结果如果新引擎胜率没有明显提升先怀疑评估函数的符号或位置表镜像有没有写反而不是急着加深度。我自己的习惯是每次动搜索或评估先跑一遍自对弈再手动下三盘感受“棋风”有没有变得离谱最后才去看单步耗时。从那以后我每次改完引擎参数都强制走一遍这个流程省下不少“改完更弱还以为变强”的后悔药。希望这套拆解帮到你拿到工程后先跑通、再按章节逐块替换比一上来就大改要稳得多。本文还有配套的精品资源点击获取