简介:基于QT5.9开发中国象棋暗棋游戏的完整学习工程包,面向有一定C++基础并希望掌握QT图形界面编程的开发者,帮助理解如何从零搭建规则完整的暗棋小游戏。压缩包共30个文件,体积仅320KB,包含6个cpp源文件、5个h头文件、15个png棋子与背景图、1个jpg背景,以及qrc资源映射和pro工程文件,覆盖代码、资源与工程配置;已有435人学习参考。工程实现了棋盘状态管理、不同棋子移动规则、点击交互的信号槽处理、QPainter自定义绘制棋盘棋子,并提供文件保存/读取进度等功能。通过阅读这份笔记,不仅能理解QT常用组件与事件机制,还能学会动画效果和文件序列化等实用技巧,适合边敲边学。 QT5.9学习笔记写到中国象棋暗棋游戏系列第六篇,前面五篇已经把界面绘制、棋子翻转动画、走子吃子流程、胜负判定这些基础模块全部跑通了。游戏能玩,但一个人打开程序只能本地坐两个人轮流操作,想自己练练手都找不到对手,这个项目总觉得还差临门一脚。所以这一篇我集中精力把暗棋AI写了出来,思路和传统象棋AI完全不同,期间踩了几个坑,也总结出一些很实用的调参经验。文章会从评估函数讲到搜索决策,再到难度档位设计,最后把调试过程里最有价值的几个问题整理出来,给同样在做QT棋类项目的朋友一个参考。
简单回顾一下暗棋玩法:棋盘上32个格子,红黑双方各16枚棋子,全部背面朝上随机布盘,轮流操作,要么翻开一枚暗子,要么移动己方明子去吃对方棋子,吃子按固定的大小链判定,炮则有隔子吃的特殊规则。几乎每翻一张牌都会改变整个局面的认知,所以AI的设计逻辑和明棋是两个完全不同的路子。
1. 为什么这个AI必须单独成模块:前五篇留下的收尾缺口
前五篇做的是标准的本地双人对战流程:两个玩家在同一台机器上轮流点击棋盘,程序负责响应交互、校验规则、切换回合。这个架构本身没有错,但要加AI对手,最大的问题不在于"怎么写一个会下棋的函数",而在于AI怎么不被UI层和逻辑层耦合拖死。
我一开始想过最省事的方案:在鼠标点击事件里加一个分支,如果当前是AI回合,就调用一个aiThink()函数,算完直接把返回的走法塞给现有的doMove()流程。这个方案看起来改动量最小,但实际写起来会发现一个问题:AI思考过程中需要频繁查询棋盘状态、尝试模拟走子,如果它直接操作的是界面线程里的棋盘对象,一旦模拟到一半界面刷新,状态就乱了。
所以我把AI做成了一个独立的DarkChessAI模块,它只接收一份"感知棋盘"的数据拷贝,内部怎么折腾都不影响主界面逻辑。这里有个关键设计:AI拿到的棋盘数据里,未翻开的棋子一律不记录真实类型,统一标记为未知状态。这个约束不是性能考虑,而是从数据结构层面上保证AI不会作弊。很多初学者写暗棋AI时直接复用了服务端的完整棋盘信息,AI表面上看挺聪明,翻牌一翻一个准,玩家玩起来就觉得假。把感知数据和真实数据分开之后,AI只能基于"已经公开的信息"做决策,这才符合暗棋的本质。
模块化还有一个好处:调试的时候我可以单独写一个自战测试入口,不启动界面,让两个AI实例在一个测试棋盘上自动对弈几百局,专门用来验证AI强度和难度区分度。如果AI逻辑是和界面代码揉在一起的,这个测试根本没法写。
2. 评估函数设计:给看不见的棋子算一笔明白账
暗棋AI的第一步不是搜索,而是回答一个问题:当前局面到底是对红方有利还是对黑方有利?这个量化过程就是评估函数。
我设计了一张基础分值表,按暗棋的吃子链关系倒推出来的:
| 棋子 | 等级rank | 基础分值 | 说明 |
|---|---|---|---|
| 帅/将 | 7 | 500 | 被吃即输,不参与普通交换 |
| 士 | 6 | 180 | 仅低于帅 |
| 相/象 | 5 | 150 | |
| 车 | 4 | 120 | |
| 马 | 3 | 90 | |
| 炮 | 2 | 70 | 吃法特殊,按独立逻辑处理 |
| 兵/卒 | 1 | 40 | 特殊规则可吃帅 |
这组数值不是随便拍的。暗棋里吃子看的是rank大小关系,rank高吃rank低,理论上分值可以设计成严格递增的序列,比如40、70、90、120、150、180、500。但实战中我发现,车马炮这些中坚子力对局面的控制力差距比数值体现的要大,所以调整成阶梯状,保证AI在评估交换得失时不会做出一车换一马的蠢事。
评估函数的核心逻辑是这样:
double evaluate(const AIBoard& board, int mySide, double noise) { double score = 0.0; for (auto& cell : board.grid) { if (cell.empty()) continue; double sign = (cell.side == mySide) ? 1.0 : -1.0; if (cell.isDark) { // 未翻开的子统一按暗子期望值估算 score += sign * darkExpectedValue; } else { // 明子直接用基础分值 score += sign * pieceValue[cell.type]; // 如果这枚棋子能威胁到对方明子,额外给一点权重 if (cell.side == mySide) { score += threatBonus(cell, board) * 0.2; } } } // 噪声:让AI不是绝对理性,后续难度分级要用 score += noise * QRandomGenerator::global()->generateDouble(); return score; }这里有两个细节值得展开说。
第一个是暗子期望值的设定。开局阶段场上全是暗子,AI对每个格子的认知就是"一枚未知棋子"。这个未知棋子的平均期望值可以通过所有剩余暗子的分值总和除以剩余暗子数量算出来,随着对局推进,每翻一张牌,这个均值都会变化。我在程序里维护了一个remainingPieces列表,每翻一张牌就从中移除对应棋子,然后动态重算期望值。这个处理比写死一个固定值要准确得多,因为到了中后期,棋盘上剩下的暗子越来越弱,期望值自然下降,AI对翻牌的兴趣也会跟着变化,这个行为逻辑非常接近真人玩家。
第二个是threatBonus。这个函数检查当前这枚明子能吃到哪些对方的明子,能吃到的目标分值总和越高,这枚棋子的实际价值就越大。比如一枚兵翻开了,如果它旁边正好有一枚对方的帅,这枚兵的实际价值瞬间飙升,因为吃掉帅直接赢棋。如果不用威胁加权,AI会忽略这类一锤定音的机会,下起来特别傻。
有一点必须强调:评估函数里对暗子用的是期望值而不是枚举所有可能值。原因很简单,场上16枚暗子的排列组合数量爆炸性增长,枚举根本不可行。更关键的是,AI本来就不需要精确知道暗子是什么,它只需要一个合理的平均估计,当棋子被翻开后这个估计会被真实值自动替代,决策自然更新。
3. 三步搞定动作生成:翻子、走子和吃子分开处理
评估函数负责打分,接下来要解决的是"在这个局面上AI能做什么"。我按动作类型把生成逻辑拆成了三类,分别处理,最后合并成一个候选动作列表。
第一步是翻牌动作。遍历棋盘中所有未翻开的格子,这些格子都是合法翻牌点。这里有个容易忽略的点:翻棋阶段每个空格子翻牌的预期收益并不一样。如果某个暗子旁边全是对方的强子,翻出来大概率是给对方送菜,这种位置翻牌的风险就高。评估函数里已经隐含了这个风险,因为翻开的棋子如果是对方阵营,sign会变成负号,AI自然会避开高风险翻牌点。但如果AI初始几步倾向闭眼乱翻,这是早期版本噪声太大导致的,后面讲难度调节时会细说。
第二步是普通走子动作。遍历当前AI阵营的所有明子,逐枚判断它能否走到相邻的空格。走子动作的价值主要在于把棋子移到更有利的进攻位置,但如果评估函数只看当前局面分值,AI可能走出一些意义不大的平移。所以我在生成走子动作时加了一个过滤:如果这枚棋子当前已经能攻击到对方明子,优先保留吃子动作和能扩大攻击范围的走子动作,纯无效游走直接压到列表尾部。
第三步是吃子动作,这个最复杂。普通棋子吃子只需要判断目标格子的rank比自己小,直接返回结果即可。炮是例外,它走直线,中间必须恰好隔着一枚棋子才能吃,而且按我采用的规则,炮不能隔子吃帅。代码实现并不难,但要小心边界:目标棋子不一定是暗子,暗子不能作为吃子目标,因为AI不能对看不见的东西发动物理攻击,这个判定容易被漏掉。
QList<AIMove> generateMoves(const AIBoard& board, int side) { QList<AIMove> list; // 翻牌 for (int i = 0; i < 32; ++i) { if (board.grid[i].isDark) list.append(AIMove::flip(i)); } // 走子和吃子 for (int i = 0; i < 32; ++i) { if (board.grid[i].side != side || board.grid[i].isDark) continue; auto targets = reachableTargets(i, board); for (auto& t : targets) { if (board.grid[t].isDark) continue; // 暗子不能作为吃子目标 if (board.grid[t].empty()) list.append(AIMove::walk(i, t)); else list.append(AIMove::capture(i, t)); } } return list; }动作生成这个环节最需要注意的是性能。暗棋棋盘只有32格,单步生成动作数量撑死也就几十个,完全没有优化压力,拷贝棋盘做模拟也是轻量操作。但我见过有人在这层用递归扫描全棋盘,写得极其复杂,最后性能也没差多少,反而bug难查。动作生成越直白越好,逻辑清晰比炫技重要。
4. 决策搜索:浅层前瞻加噪声,反而比深搜更像人
评估函数有了,动作生成有了,决策部分其实已经完成一大半。暗棋要不要做深搜?我试过把传统象棋的极小极大搜索搬过来,但很快发现方向不对。
根本原因在于暗棋是"不完全信息博弈"。传统象棋所有棋子都摆在明面上,搜索树可以准确预测几步之后的变化,深搜价值很高。暗棋则不同,AI面前有大量未翻开的暗子,这些暗子的真实身份是搜索树无法准确模拟的。如果强行用递归展开暗子的所有可能性,分叉因子会迅速爆炸,而且评估函数的噪声会把深搜带来的精度优势完全吃掉,算到最后结果和浅层搜索差不了多少。
我实测下来的结论是:一到两层的"前瞻+模拟回应"已经足够支撑一个能打的AI。具体的决策流程是这样的:
AIMove chooseMove(const AIBoard& board, int mySide, int depth, double noise) { auto moves = generateMoves(board, mySide); AIMove best; double bestScore = -1e9; for (auto& m : moves) { AIBoard tmp = board; applyMove(tmp, m); double s = evaluate(tmp, mySide, 0.0); // 对于吃子动作,多考虑一层:对方可能的回应 if (depth >= 2 && m.isCapture()) { auto replies = generateMoves(tmp, 1 - mySide); double worstReply = 1e9; for (auto& r : replies) { AIBoard tmp2 = tmp; applyMove(tmp2, r); double s2 = evaluate(tmp2, mySide, 0.0); worstReply = qMin(worstReply, s2); } s = (s + worstReply) / 2.0; } s += noise * QRandomGenerator::global()->generateDouble(); if (s > bestScore) { bestScore = s; best = m; } } return best; }核心思路是:普通走子和翻牌直接按当前评估值排序,吃子动作则需要模拟对方回应,然后取一个保守的中间值。这样做的好处是AI不会为了吃一个小卒子而把自己的车送进对方嘴里,对吃子后续风险的判断比纯贪心可靠很多。实测下来,这个深度就已经能稳定压制没有全局观的乱走玩家。
有人可能会问,为什么不对所有动作都做两层模拟?我实测过,对每个动作都做完整两层模拟之后,AI每步的思考时间明显变长,但胜率几乎没有提升。原因很简单:翻牌动作的模拟结果高度依赖随机性,第二层搜索对这种不确定性束手无策;而普通走子动作对局面的改变很小,多做一层搜索并不会改变排序结果。只有吃子动作因为直接改变双方子力差,才真正需要去看对方的反应。把计算资源集中在收益最大的动作类型上,这才是合理的分配策略。
5. 三种难度档位:用噪声和翻牌倾向调出"人味"
游戏做完之后我自己试玩了两天,发现一个很致命的问题:这个AI的棋风"太稳定"了。每一手都是固定套路,翻牌永远优先翻期望收益最高的格子,走子永远优先攻击价值最大的目标。玩多了就会发现规律,毫无乐趣可言。
要让AI有"人味",最直接的手段是给评估值加噪声。有了噪声,AI就有一定概率不选最高分动作,转而选一个次优动作,表现在玩家眼里就是"AI偶尔会犯错,也会走出一些让人意外的棋"。这个思路实现成本极低,效果却非常好。
我最终在界面的开局设置里加了三个难度档位,用一组参数控制:
| 难度 | 搜索深度 | 噪声幅度 | 翻牌激进系数 | 实际表现 |
|---|---|---|---|---|
| 简单 | 1 | 1.8 | 1.3 | 经常翻出对方的好子送菜,走子偶有废棋 |
| 普通 | 2 | 0.8 | 1.0 | 整体稳妥,不会主动送子,偶尔错过连吃机会 |
| 困难 | 2 | 0.2 | 0.7 | 吃子机会不丢,翻牌谨慎,劣势时会主动搏命翻牌 |
这里的"翻牌激进系数"是另一个调节维度,实现上很简单,就是把这个系数乘到翻牌动作的评估值上。系数越高,AI越愿意翻牌,这模拟的是真人玩家"手痒想开牌"的心理;系数越低,AI越倾向于走稳妥的明子。简单档位把这个系数调高,AI看上去就像个不怎么思考的新手,爱翻牌又容易翻出对方的人来,玩家玩起来有成就感。困难档位则压制翻牌欲望,只在翻牌期望收益明显大于走子时才动手,下法显得老辣许多。
噪声的幅度也要根据搜索深度联动。深度浅的时候,评估本身就不稳定,噪声调高一点影响不大。深度到两层时,AI已经能看到吃子后的回应,这时候如果噪声还很大,AI可能因为一个随机正数跳过必吃的好棋,就显得很蠢。我的办法是让简单档位用1层搜索加高噪声,困难档位用2层搜索加低噪声,这样两个档位在棋力上的差异来自"计算深度"和"决策稳定性"两个方面,而不是简单调一个倍数。
难度参数我放在一个结构体里管理:
struct AIDifficulty { int searchDepth = 2; double noise = 0.8; double flipBias = 1.0; };界面上的QComboBox切换难度时,把这个结构体传给AI构造函数就行了。以后想加第四个难度,比如"大师",直接调参数即可,完全不用改AI逻辑代码。
6. 调试暗棋AI时踩过的几个坑
从粗胚到能稳定下棋,这个AI我调了整整一个周末,踩过的坑比预想的多,选几个最典型的记录下来,想自己动手实现暗棋AI的朋友应该用得上。
坑一:AI翻牌一翻一个准,玩家体验极差。这是最初版本的通病,根因是我在生成动作和评估时直接用了内部完整棋盘,AI能"看穿"所有暗子。虽然逻辑上AI并没有故意作弊,但它的决策已经被暗子的真实身份污染了。修复方法就是把AI收到的棋盘独立出来,未翻开的子一律标记为未知类型,这一步做完AI立刻"盲"了,行为正常了许多。
坑二:开局固定,每次重开游戏翻到的子位置一样。这是洗牌随机数种子的问题。QT里如果用qsrand()没有正确设置种子,或者洗牌用的随机源没有接入QRandomGenerator::global(),就会出现同样的局面反复出现。我最后统一改用std::shuffle配合QRandomGenerator::global()做洗牌,问题彻底消失。
坑三:AI出现原地打转的死循环。有一版AI在评估函数里对走子动作只加了很小的正向激励,导致它觉得"走到哪里都一样",每次都在两个相邻格子之间来回平移,既不吃子也不翻牌,看起来像卡死了。我加了检测逻辑:如果连续多步AI只做无意义的往复平移,强制在候选动作中过滤掉走回上一步所在格子的动作,问题解决。
坑四:自战测试时偶发抛异常崩溃。排查后发现是模拟走子时把棋盘外的格子下标传了进去。暗棋棋盘虽然是4x8的矩形,但炮需要隔子吃,判断路径时要遍历中间格,边界下标没有校验到位就会越界。这个属于基本功问题,建议把棋盘的越界检查统一封装成一个isValidCell()函数,所有访问路径都走这个函数,比到处写散装判断可靠得多。
调试方法上我想重点推荐"自战模式"。我在AI模块里预留了一个独立入口,启动后不加载界面,直接创建两个AI实例,让它们在一张测试棋盘上连续自动对弈。每一手都打印出棋盘快照、当前评估值、候选动作数、实际选择的动作,这样既能看胜率走势,也能单独分析某一手棋的决策依据。连续跑几百局之后,AI的整体水平大致能摸清楚。比如简单档位的自战胜率显著低于普通和困难,困难档位100局里能赢简单档位85局以上,这个区分度就说明难度设计是有效的。
另外还有一个信号槽方面的经验:AI决策如果放在UI线程里同步执行,虽然暗棋的动作生成和评估很快,但在低配置机器或极端局面下还是有卡顿风险。我最后用QtConcurrent::run把决策过程放到后台线程跑,决策完成后再通过信号把走法发回主线程,配合现有的走子动画播放逻辑,整个游戏流程就顺畅多了。
7. 一点实际开发体会
这个暗棋AI模块写完之后,整个项目才算真正"完整"了。我最大的感受是:暗棋AI和明棋AI是两套完全不同的思维方式,明棋AI的核心是"我看到了什么,如何根据已知信息选择最优路径",暗棋AI的核心则是"我不知道的东西太多,怎么在这种不确定性里做出期望值最高的选择"。这种"在信息不对称条件下做决策"的思路,放在工作中也很有用,很多业务场景里的决策本质上都是暗棋,你手里的数据永远是不完整的,关键是要设计出一套在不确定性下依然有效的评价机制,而不是等所有信息齐了才行动。
我也给读者一个建议:如果你打算照这个思路自己写一版,先不要纠结算法的高级程度。把评估函数、动作生成、难度参数这三块做好,AI的下棋水平已经足够让普通人觉得"有点东西"了。它不深奥,但每一步都脚踏实地,比我一开始设想的高大上方案实用得多。
本文还有配套的精品资源,点击获取