简介用Qt/C开发的中国象棋人机对战项目是一份面向Qt初、中级开发者及高校课程设计的完整代码示例。项目实现了完整对弈流程棋盘矩阵建模、各棋子走法与吃子规则校验、鼠标交互、胜负判断并采用alpha-beta剪枝搜索和局面评估函数驱动电脑走棋核心思路清晰便于学习经典棋类AI的落地方式。资源共47个文件包含14个C源文件和14个头文件覆盖棋盘、棋子、单机对战、登录注册及数据模型等模块另有2个ui界面文件、6张jpg和3张png图片用于界面美化qrc资源文件统一管理素材压缩包整体仅575KB目录结构规整。项目由个人开发者整理文件命名规范可直接导入Qt Creator编译运行或按需二次扩展。该资源已有1001人学习浏览对希望以象棋项目锻炼C/Qt编程和算法设计能力的读者具有不错的参考价值。1. 基于Qt写中国象棋人机对战最该先啃下的是“规则闭环”如果你也试过在 Qt Creator 里拿 C 写棋盘类小游戏大概率撞过这类墙棋盘画好了、棋子摆上去了鼠标事件却不知道怎么转成“走子逻辑”等走子逻辑通了AI 又只会瞎跳玩两把就索然无味。这个项目正好把这条链路完整闭环了——从棋盘建模、走法生成、将军判定到带 Alpha-Beta 剪枝的搜索评估再到悔棋、回合管理全部基于 Qt Widgets 加标准 C 落地不依赖任何第三方库。它不是什么工业级象棋引擎但作为“从能跑到敢赢”的实战样板价值很实在。适合想通过一个中等规模项目吃透“界面与逻辑分层”的从业者也适合给刚学完 C 的开发者当第二个项目源码参考。2. 棋盘建模与走法生成把规则钉死在两个类里2.1 棋盘数据结构用一维数组还是二维数组常见做法是一维数组下标代表格子位置。九宫格的棋位编号可以简单映射到一维下标走法遍历时只需要做下标运算省去二维坐标换算。项目里定义了一个Board类内部用一个长度为 90 的数组存储局面0 表示空位正数表示红方棋子负数表示黑方棋子棋子类型按绝对值区分。class Board { public: static constexpr int BOARD_SIZE 90; // 棋盘十行九列前 9 个是第 0 行 static constexpr int EMPTY 0; int pieces[BOARD_SIZE]; // 正数红方负数黑方 void reset() { std::fill(pieces, pieces BOARD_SIZE, EMPTY); // 摆放初始局面车马相仕帅仕相马车 const int backRank[9] {4, 3, 2, 1, 0, 1, 2, 3, 4}; for (int col 0; col 9; col) { pieces[col] backRank[col] 8; // 红方底排8车 9马 10相 11仕 12帅 pieces[col 80] -(backRank[col] 8); // 黑方底排取负 pieces[col 9] 6; // 红兵 pieces[col 70] -6; // 黑卒 } } };这里的关键是把棋子类型编码成整数时留出负数空间后续走法生成和局面评估都靠“正负号”快速判断敌我。backRank数组里的 0 到 4 只是相对编号实际棋子值从 8 开始把 0 保留给空位避免“空位和帅”混淆。我一般会顺手把row/col转换写成内联函数比如row idx / 9col idx % 9这样后面做兵种规则判断时逻辑更直白。2.2 走法生成兵种行棋规则与边界处理走法生成是全部逻辑里最琐碎的一块。项目里把它拆成generateMoves(bool isRed)遍历棋盘上所有属于当前行棋方的棋子按兵种分别生成候选着法列表。每个着法用一个简单的结构体存起点、终点和吃子信息。struct Move { int from; int to; int captured; // 吃掉的棋子类型0 表示没吃子 Move(int f, int t, int c 0) : from(f), to(t), captured(c) {} }; std::vectorMove Board::generateMoves(bool isRed) { std::vectorMove moves; for (int idx 0; idx BOARD_SIZE; idx) { if (isRed ? pieces[idx] 0 : pieces[idx] 0) { int type std::abs(pieces[idx]); switch (type) { case 0: break; // 帅走九宫 case 1: addAdvisorMoves(idx, isRed, moves); break; case 2: addElephantMoves(idx, isRed, moves); break; case 3: addHorseMoves(idx, isRed, moves); break; case 4: addChariotMoves(idx, isRed, moves); break; case 5: addCannonMoves(idx, isRed, moves); break; case 6: addPawnMoves(idx, isRed, moves); break; default: break; } } } return moves; }每个 add 函数里都有各自的边界条件。最容易写错的是马的“蹩马腿”和象的“塞象眼”马的移动范围是八个 L 形但只要相邻的上下左右方向上有子对应两个目标位就作废象的田字中心如果有子同样不能落。炮的走法又不一样它和车一样走直线但吃子时必须隔一个“炮架”不吃子时中间一个子都不能有。我在项目里把这类判断单独抽成isPathClear(from, to)和getCannonScreen(from, to)两个小函数测试时专门盯着它们。2.3 将军检测与“不送将”约束走法生成出来之后还不能直接给 UI 用因为“走完之后自己将军被吃”是非法着法。项目在validateMove里先做一步模拟走子然后调用isKingAttacked(isRed)检测己方将帅是否处于被攻击状态。这一步不做任何剪枝优化就是全盘扫描对方棋子能否吃到将帅。bool Board::isKingAttacked(bool isRed) { int kingPos findKing(isRed); if (kingPos 0) return true; // 王被吃了视为被攻击 // 全盘扫描对方棋子 for (int idx 0; idx BOARD_SIZE; idx) { if (isRed ? pieces[idx] 0 : pieces[idx] 0) { if (canPieceAttack(idx, kingPos)) return true; } } return false; } bool Board::validateMove(const Move m, bool isRed) { // 1. 保存局面 // 2. 模拟走子 // 3. 检测是否导致己方被将军 // 4. 恢复局面 return !isKingAttackedAfterMove(m, isRed); }这个“先走再验”的思路比直接通过目标位置判断更稳因为象步、马步的遮挡关系不好在走子前预判。代价是每次validateMove都要全盘扫描一遍但人机对弈中走子频次很低这点开销完全可以接受。真正需要避免的是在搜索引擎里每个节点都调用它——那就要换一种增量更新被攻击状态的写法。3. 评估函数与Alpha-Beta搜索让AI从“会走”到“会打”3.1 手工设计局面评估函数象棋 AI 的“棋感”全部来自评估函数。项目用的是经典的手工评估基础子力价值加位置价值再加少量灵活度奖励。子力价值表是照常规认知设的车 500马 300炮 285相/仕 120兵未过河 30、过河加 50帅给一个超大值但不会让它参与吃子。static const int PIECE_VALUE[7] {0, 10000, 120, 120, 300, 285, 30}; int Board::evaluate(bool isRed) const { int score 0; for (int idx 0; idx BOARD_SIZE; idx) { if (pieces[idx] 0) continue; int type std::abs(pieces[idx]); int val PIECE_VALUE[type]; if (type 6) { int row idx / 9; // 红兵过河后行号变小黑卒过河后行号变大 if (pieces[idx] 0) val (row 5) ? 50 : 0; else val (row 4) ? 50 : 0; } score (pieces[idx] 0) ? val : -val; } return isRed ? score : -score; }这个评估函数写得比较朴素但足够让 AI 学会“主动吃子”和“避免白送”。搜两层的时候 AI 会毫不犹豫地用仅仅值 300 的马去换 500 的车因为它能看到两层后的子力净收益。位置表的加入可以考虑但项目里没做理由是目标场景是单机休闲对战评估函数做到“不犯大错”就够了过度调优容易翻车。3.2 负极大值搜索与 Alpha-Beta 剪枝搜索引擎我选择了负极大值写法加 Alpha-Beta 剪枝。负极大值的核心是“每个节点把自己的收益取反传给父节点”对红黑双方的评估天然对称代码比极小极大更短。搜索深度从 2 开始逐步加深配合简单的时间控制——每步棋最多算半秒。int Board::negamax(int depth, int alpha, int beta, bool isRed) { if (depth 0) return evaluate(isRed); std::vectorMove moves generateMoves(isRed); // 一个小的叶子节点增强局面已分胜负 if (moves.empty()) { return isKingInCheck(isRed) ? -MATE_SCORE : 0; // 被将死或困毙 } int best -MATE_SCORE; for (const Move m : moves) { makeMove(m); int score -negamax(depth - 1, -beta, -alpha, !isRed); undoMove(); if (score best) best score; if (best alpha) alpha best; if (alpha beta) break; // 剪枝 } return best; } Move Board::searchBestMove(int maxDepth) { int alpha -MATE_SCORE, beta MATE_SCORE; Move bestMove(-1, -1); for (int depth 1; depth maxDepth; depth) { std::vectorMove moves generateMoves(true); for (const Move m : moves) { makeMove(m); int score -negamax(depth - 1, -MATE_SCORE, MATE_SCORE, false); undoMove(); if (score alpha) { alpha score; bestMove m; } } } return bestMove; }注意几个关键参数alpha初始值是父节点已搜索到的最佳值beta是父节点的父节点给的上界。每当alpha beta这个子节点剩下的着法理论上不可能让父节点获得更好局面所以可以安全剪掉。depth - 1必须保证每次递归都消耗深度否则会出现“无限沉浸”的 bug。让整棵树的收敛速度明显提升的是着法排序本项目的解法是先按吃子分数排序再递归这个细节在后面的避坑章专门展开。3.3 搜索深度设定与吃子延伸休闲人机对战里搜索深度设 4 层是比较稳妥的选择。深度 2 时 AI 只会“看一步吃一步”深度 4 时已经能做出“先弃子引离、再吃掉大子”的组合型着法单步计算时间在普通桌面上大约 0.2 到 0.8 秒。为了让深度有限时也能看到吃子链条我在叶子节点加了一条“吃子延伸”规则如果最后一步是吃子则在叶子处接续搜索下一步且只有当吃方吃到的子价值大于被吃子时才继续延伸。int Board::searchWithCaptureExtend(int depth, int alpha, int beta, bool isRed, int extendLeft) { if (depth 0) { if (extendLeft 0) { std::vectorMove captures generateCaptures(isRed); // 只生成吃子着法 if (captureWorth(moves)) { return -negamax(0, -beta, -alpha, !isRed); } } return evaluate(isRed); } // 正常搜索 }吃子延伸的目的是缓解“水平线效应”——搜索在刚好碰不到吃子链条时停下AI 会误以为局面是安全的。实际测试里加上这个机制后 AI 对“车被炮瞄着”这类局面明显更警觉。延伸层数一般控制在 1 层再多会成倍拖慢速度得不偿失。4. 人机交互闭环走子、校验、悔棋与回合管理4.1 点击选棋与合法落点提示界面层用一组按钮或自定义paintEvent绘制棋盘。项目里选了后者用一个BoardWidget继承QWidget把所有棋盘元素画在paintEvent里。鼠标点击坐标换算成棋盘下标后交给逻辑层处理。void BoardWidget::mousePressEvent(QMouseEvent *event) { int row event-pos().y() / CELL_SIZE; int col event-pos().x() / CELL_SIZE; if (row 0 || row 9 || col 0 || col 8) return; int idx row * 9 col; if (selectedIdx 0) { // 第一次点击选定己方棋子 if (isRedPiece(idx)) selectedIdx idx; } else { // 第二次点击尝试走子 Move m(selectedIdx, idx); if (board.validateMove(m, true)) { emit moveReady(m); } else { selectedIdx idx; // 目标点不是合法落点重新选定 } } update(); }选棋后我会把当前所有合法落点直接画成半透明圆点做法是走子前先调一次generateMoves再对每个目标点做校验过滤掉那些“走了会被吃帅”的非法的点。这一步面向体验很有价值玩家点棋子的时候能落子的地方高亮出来不用自己在脑中推演规则。圆点绘制在paintEvent里用二次循环实现位置换算和mousePressEvent保持同一套偏移量否则会出现“点棋子下面却选中旁边格子”的诡异问题。4.2 回合管理、胜负判定与“王不见王”回合管理相对简单项目里用一个bool isRedTurn标志位红方走完置为 falseAI 计算结束后置为 true。但有几个细节很容易被忽略其中最典型的是“将帅照面”规则红帅和黑将处于同一列且中间无子时视为被将军这属于隐性杀招走法生成和评估函数里都必须处理。项目在canPieceAttack里单独写了一节当双方将帅同列中间无子时直接返回真。胜负判定分三种被将死、困毙无合法着法但不被将军时判和、自然限招双方连续 60 回合无吃子算和。工程里只实现了前两种第三种因为要维护历史局面状态对休闲项目来说性价比太低。不过建议至少把“无合法着法”判断写全否则 AI 在绝境时会直接崩溃或者拿帅乱撞。4.3 悔棋、重开与状态复位悔棋功能是人机对弈最常被问到的“后悔药”。项目里用一个QStackMove记录历史着法红方悔棋时回退两步AI 的着法和玩家自己的着法一起撤销。回退操作最关键的是做完整局面恢复不是简单把棋子放回原位而是要把被吃掉的子还原、回合标志恢复、历史记录栈同步弹出。void ChessGame::undoLastMove() { if (history.size() 2) return; // 回退 AI 的一步 Move aiMove history.pop(); board.undoMove(aiMove); Move playerMove history.pop(); board.undoMove(playerMove); isRedTurn true; ui-statusLabel-setText(已悔棋一步轮到红方走子); boardWidget-update(); }注意这里 undoMove 必须和 makeMove 完全互逆。项目里我额外维护了一个captured字段undoMove里做pieces[to] m.captured而不是简单地置 0。否则吃子之后悔棋被吃的棋子会凭空消失。这一点看着基础但很多半成品项目就翻车在这里。重开一局则直接调board.reset()并清空历史栈另外记得把状态栏文案和选中高亮全部复位避免出现“新棋盘却残留上一局选中特效”的割裂感。5. 避坑与排查这些雷我替你踩过了5.1 深拷贝没做悔棋回头全是“鬼影”现象AI 走子后悔棋棋盘上出现两枚同款棋子重叠偶尔还残留半透明高亮。原因undoMove里只恢复了pieces[to]没恢复被吃子的原始类型。被吃的车马炮在captured里记错成 0悔棋后那个位置被清成空位但画棋盘时用的备份数组还保留着棋子索引视觉上残留残影。解决统一把“移动”抽象成Move结构体内部保存完整起点终点和被吃子类型。undoMove时先还原终点再还原起点顺序不能反——先放被吃子再移动原棋子。从那以后我每次写棋盘类游戏都强制要求自己先写一轮移动-撤销的单元测试再写界面。5.2 兵/卒过河判断写反AI 底卒冲到底线现象黑方卒在己方半场时被 AI 当成“过河卒”评价函数给了过高权重AI 频繁送卒换子。原因评估函数里用row 5判断红兵过河黑卒也沿用了这个逻辑但黑卒从第 7 行往下走越靠近底线行号越大过河条件应该是row 4。代码看起来只是符号之差在评估里会直接扭曲若干百点分数AI 的棋风也会变得异常激进。解决评估函数里按棋子正负号分别判断过河条件能用一组成对的三元表达式就不要公用一套变量。调试时可以在评估函数里打印每枚棋子的价值分解一旦发现 AI 对某个区域权重异常直接定位到对应分支。5.3 坐标换算不一致棋子在棋盘上“跳格”现象点击棋盘右下角时选中的却是棋盘上方的棋子走子动画里的终点和鼠标点击的终点总差一格。原因鼠标坐标换算时用的CELL_SIZE和绘制棋盘时的格宽不一致。绘制时棋盘边距留了 20 像素但换算时没减掉边距所有索引整体偏移了一格。解决把棋盘起点坐标MARGIN_X、MARGIN_Y定义成常量绘制和鼠标事件共用同一套。换算公式永远是(pos - MARGIN_X) / CELL_SIZE不要出现第二套“裸减”。这个坑在触摸屏或高分屏下尤其阴间因为 Qt 的缩放因子会让宽高虚标。5.4 搜索越来越慢没做“重复局面”检测现象AI 下到中局后单步计算时间从 0.5 秒涨到 3 秒以上甚至在残局阶段出现卡顿。原因Alpha-Beta 剪枝遇到重复走法时同一局面会在不同分支反复搜索。没有置换表的情况下搜索树呈指数膨胀残局子力少但走法分支极多反而比中局更卡。解决接入一个最简单的 Zobrist 哈希表作为置换表key 是局面哈希值value 存该深度的评估值。遇到相同局面直接返回缓存结果。手写实现也就几十行关键是哈希表在搜索期间只读不重写否则容易污染全局最优解。如果不想引入这个复杂度至少把“连续 60 步无吃子判和”这个限招逻辑做上避免 AI 在重复局面里无限循环。5.5 在搜索线程里直接改 UI跑两步就闪退现象AI 计算完成后界面偶尔闪退QPainter 报错“QWidget:paintEvent not called in the main thread”。原因把searchBestMove放进了QThread里但搜完直接在子线程里调boardWidget-update()导致 paintEvent 在非 GUI 线程执行。Qt 对 GUI 操作有严格线程限制跨线程改控件是崩得最勤的玩法。解决用信号-槽把 AI 的结果发回主线程在槽函数里更新 UI。搜索线程只持有逻辑层对象所有界面交互都通过emit aiMoveComputed(Move)触发。给新手的建议是看到QWidget相关调用就默认“只能在主线程里碰”别抱着试试看的心理跨线程访问。6. 让AI从“能走”到“敢杀”历史启发与自对弈验证6.1 走法排序剪枝率直接从 40% 飙到 85%Alpha-Beta 剪枝的效率完全取决于走法顺序如果搜索的第一种走法恰好就是当前最优走法剩下的剪枝空间会非常大。项目初始版本按生成顺序搜索剪枝率大概只有三成后来我把着法按“吃子价值 × 10 减去被吃子价值 2”排序优先搜吃大子的着法剪枝率稳定到了 80%。这个加分项还被放进了一个小技巧被将军时的着法里优先搜索“垫子”和“逃帅”而不是对攻可以进一步压低搜索时间。6.2 自对弈批量对局验证强度调完启发式排序后我用一个自动化脚本连续让 AI 自己跟自己下 200 局红黑方各执 100 次先手记录每局步数和胜负分布。结果是先手胜率约 54%后手约 46%和棋率约 8%基本符合预想。这个验证脚本很值得保留它绕开了“感觉 AI 变强了”的主观判断直接在数据上暴露问题。某一次调完位置表后先手胜率掉到 40%我回滚了改动才发现是“过河卒”权重给得太猛导致 AI 在中局疯狂送马保卒。从那以后我每次给评估函数加权重都强迫自己跑一轮 50 局自对弈对比用一手数据说话少点玄学调参。希望帮到你。本文还有配套的精品资源点击获取