麻将游戏AI源代码实战:牌型编码、胡牌检测与出牌决策系统解析
简介:基于C/C++实现的麻将游戏AI源代码,面向Windows平台可编译运行,核心在于电脑AI的智能对战算法。适合对游戏开发与人工智能应用感兴趣、具备一定C++基础的程序员,作为学习完整游戏架构和AI决策逻辑的实践案例。压缩包共87个文件,约586KB,其中33个mp3音频、17个bmp图片、12个h头文件与11个cpp源文件构成主体,辅以工程配置、图标和编译描述,代码与资源分离清晰,便于按模块研读。已有3820人学习,适合通过源码理解麻将牌型建模、胡牌判断、碰杠操作等规则实现,以及与AI的搜索策略和评估函数结合的人机对战机制。项目还涵盖Win32窗口消息处理、GDI绘图、事件驱动、数据结构运用和调试优化等要点,能帮助读者将底层系统交互与上层游戏逻辑串联起来,无论牌型判定还是AI出牌决策,都可以结合代码逐步跟踪学习。
1. 麻将游戏AI源代码:不是玄学,是一套可拆解的牌桌决策系统
麻将游戏AI源代码,这个词听起来像某种「一键智能打牌」的玄学,实际拆开就是让程序完成四件事:认牌、判胡、算进张、选安全牌。我第一次接这类需求是在一个棋牌游戏项目里,甲方要求AI在1秒内完成出牌,还不能明显比新手菜。当时从开源社区扒了几份C++老代码,要么牌型编码看不懂,要么只顾进攻不管防守,改起来比重新写还累。这个方向真正值钱的部分不在算法多新潮,而在于牌型编码、胡牌检测、进张计算和出牌评分这四块怎么严密地咬合。适合谁?想做棋牌机器人、研究博弈AI入门、或者给自建游戏加陪玩NPC的开发者,照着下面这条路径走,能少踩至少一半的坑。
2. 麻将AI的核心决策链:从牌型编码到向听数计算,先把骨架立住
2.1 牌型编码:34计数数组为什么比136序列更适合AI做决策
写麻将AI的第一步不是写策略,而是定义牌在程序里长什么样。新手最容易犯的错误是把一手牌存成一个由136张牌ID组成的列表,比如[0, 1, 2, 3, 8, 8, 9, ...]这样。136序列的好处是直观,摸一张牌就是在列表里加一个元素,但它有两个致命问题:第一,判断「手里有没有某张牌」要线性扫描;第二,枚举所有可能的拆牌组合时,同一张牌的不同实例会被当成不同分支,产生大量重复计算。比如手里有2万三张,拆刻子时第一张、第二张、第三张分别被选中,逻辑上完全等价,但递归会多跑三倍。
我习惯用34长度的计数数组,hand[i]表示第i种牌的张数。0到8是万,9到17是条,18到26是筒,27到33是字牌。这个编码天然去重,同一种牌的张数用数值表示,枚举牌型时只关心「用了哪几种牌、各用几张」,不关心具体哪一张。网络检索里那些C++写的开源麻将源码,核心牌型表示也几乎全是这一套思路,区别只是接口封装好不好看。
# 34牌编码:0-8万,9-17条,18-26筒,27-33字牌 def init_hand(cards): hand = [0] * 34 for c in cards: hand[c] += 1 return hand # 判断第i张牌能否作为顺子的起点 def can_start_shunzi(i): # i % 9 <= 6 表示该花色中还有2个空位 # 字牌区间27-33不参与顺子 return i < 27 and i % 9 <= 6参数说明:init_hand接收一个从0到33的牌ID列表,返回计数数组。can_start_shunzi是后面胡牌检测的基础工具函数,它把「万条筒三种花色、每种9张」的边界条件锁死——一旦i落在字牌区间(27到33),或者i是该花色的第7、8、9张,它就不会被错误地当成顺子起点。这里藏着一个高频坑:如果把字牌当成花色的一部分参与顺子检测,胡牌判定会把「东南西」这种根本不存在的顺子拆出来,导致AI产生幻觉胡牌。
34编码还有另一个好处,就是牌河信息和手牌信息可以用同一个remain数组来表达。remain[i]表示第i种牌还剩多少张,初始为4,每出现一张已知牌就减1。后面所有概率计算都建立在remain之上,34编码让「已知牌扣减」这个操作变成一次数组下标访问,非常快。
2.2 胡牌检测与向听数:一手牌离胜利还有几步,这才是决策的标尺
胡牌检测是地基。标准麻将胡牌结构是4个面子加1对雀头,面子可以是顺子或刻子。实现上主流是回溯法:先尝试从手牌里拆出一副面子,递归拆剩下的,直到拆空。
def can_win(hand): if sum(1 for x in hand if x == 2) == 7: return True # 七对特判 for i in range(34): if hand[i] >= 2: hand[i] -= 2 # 枚举雀头 if check_regular(hand): hand[i] += 2 return True hand[i] += 2 return False def check_regular(hand): if all(x == 0 for x in hand): return True for i in range(34): if hand[i] > 0: # 优先拆刻子 if hand[i] >= 3: hand[i] -= 3 ok = check_regular(hand) hand[i] += 3 if ok: return True # 再尝试顺子 if can_start_shunzi(i) and hand[i] > 0 and hand[i+1] > 0 and hand[i+2] > 0: hand[i] -= 1; hand[i+1] -= 1; hand[i+2] -= 1 ok = check_regular(hand) hand[i] += 1; hand[i+1] += 1; hand[i+2] += 1 if ok: return True return False # 这张牌既不能拆刻也不能拆顺,直接失败 return False逻辑说明:can_win先特判七对,然后用两层循环枚举雀头的位置,把雀头从手牌里拿走,剩下的交给check_regular判断能否全拆成面子。check_regular的递归顺序很重要——我习惯先拆刻子再拆顺子,因为刻子只有一种拆法,能尽早确定唯一路径;顺子拆法多,留到后面尝试。return False放在循环末尾是剪枝关键:一旦发现某张非0牌既凑不成刻子也凑不成顺子,整条路径立刻报废,不用再往后试。
参数说明:hand是34计数数组,函数执行时会修改它,所以调用方要在递归前做好备份,或者在递归里保证每次修改后复原。代码里用加回操作来还原,这是回溯的标准写法。如果想提速,把hand转成tuple后用functools.lru_cache装饰check_regular,单局内大量重复子结构会被缓存命中,性能能提升好几倍。
向听数比胡牌判定更重要,它回答的问题是「一手牌还差几次有效换牌才能听」。向听数为0表示已经听牌,1表示一向听。计算向听数有三种级别:暴力BFS枚举所有换牌组合,结果最准但14张牌时可能跑出百万级节点;贪心估算把牌拆成面子、搭子、对子,速度极快但边界情况会算错;标准算法用回溯加剪枝,兼顾速度和准确度。我一般把暴力BFS当作对照基准,生产环境用DFS剪枝:
def calculate_shanten(hand): best = 8 def dfs(h, depth, last_i): nonlocal best if depth >= best: return if can_win(h): best = depth return for i in range(last_i, 34): if h[i] > 0: h[i] -= 1 dfs(h, depth + 1, i) h[i] += 1 dfs(hand[:], 0, 0) return best这个实现里有三个关键参数:depth是已经打出的牌数,last_i用来保证每一层只往牌号更大的方向打,避免「先打1万再打2万」和「先打2万再打1万」这种重复分支;best是全局最优值,一旦当前深度超过best就剪枝。它比标准算法慢不少,但作为正确性验证工具足够。标准向听数算法的核心公式是:向听数等于2乘以「还缺的面子数」再减去雀头修正,实现时用「面子数 + 搭子数」的贪心拆牌法,这个我建议单独写一个fast_shanten函数,和暴力版本互相校验。
为什么向听数这么重要?AI的进攻决策全部围绕它展开:打出某张牌后向听数降低,说明手牌在进步;向听数不变但牌更安全,说明在防守。向听数没算对,后面所有评分公式都是在错误的地基上盖楼。我见过好几个项目,AI表现忽好忽坏,最后定位到是向听数边界case算错,导致AI在即将听牌时打出一张完全无关的牌。
3. 用Python跑通最小麻将AI:三个函数搞定听牌判断与出牌决策
3.1 听牌判断与进张数:把「打哪张」变成可计算的数值
听牌判断就是「摸到哪张牌能胡」的枚举。在34编码下,做法是对34种牌逐一尝试加一张,然后调用can_win。能胡牌的那几张就是听牌列表。
def get_ting_list(hand): ting = [] for i in range(34): if hand[i] < 4: # 理论上一共只有4张 hand[i] += 1 if can_win(hand): ting.append(i) hand[i] -= 1 return ting def calc_remaining(hand, discards): remain = [4] * 34 for i in range(34): remain[i] -= hand[i] for d in discards: remain[d] -= 1 return remainget_ting_list的时间复杂度是34次胡牌检测,配合第2章的递归剪枝,在普通机器上跑一次大约几毫秒。calc_remaining负责统计「每张牌还剩多少张」,它从每样4张的初始值开始,先扣掉手牌,再扣掉discards里已经露面的牌。返回值是后面所有概率计算的基础——一般来说,remain值越小,说明这张牌已经被消耗得越多,后续摸到的可能性越低。
这里引入一个关键概念:进张数。听牌列表里有三张牌,不代表摸到其中任何一张的概率是3/34,因为每张牌在牌池里的剩余数量不同。我习惯把听牌列表对应的remain值累加起来,叫作剩余进张数:
def calc_advance(hand, remain): ting = get_ting_list(hand) return sum(remain[t] for t in ting)这个数字比「听几张」准确得多。举个例子,AI听2万和5万,2万已经被人打掉3张,5万一张未现,如果单纯数听牌数会认为两者等价,但实际进张数2万只剩1张、5万还有4张,差距是4倍。这两者的区别直接决定了出牌决策的质量。注意remain参数必须基于当前桌面的真实可见牌,如果自己手牌里已经有2张2万,那么remain[2万]还要再扣掉这两张,否则会把已经在自己手里的牌当成潜在摸牌目标——这种错误在刚写AI时特别容易犯。
3.2 出牌评分:不搜索,先让AI学会算概率
有了听牌判断和进张数,出牌决策就水到渠成:对每一张可以打出的牌,假设打出它,然后评估打完后手牌的三项指标——进张数、安全度、向听数变化。把三个指标加权求和,选分数最高的打出。这是大多数据说「够用」级别的规则AI的通用做法。
def evaluate_discard(hand, discards): remain = calc_remaining(hand, discards) candidates = [i for i in range(34) if hand[i] > 0] scores = {} for c in candidates: hand[c] -= 1 # 模拟打出这张牌 ting = get_ting_list(hand) attack_score = sum(remain[t] for t in ting) defense_score = (4 - remain[c]) * 0.5 shanten = calculate_shanten(hand) scores[c] = attack_score * 1.0 + defense_score * 1.0 - shanten * 2.0 hand[c] += 1 # 还原手牌 return sorted(scores.items(), key=lambda x: x[1], reverse=True)逻辑说明:attack_score是打出c之后手牌还能听多少张剩余牌,代表进攻潜力;defense_score用「c这张牌已经见了几张」来近似安全度,见得多说明别人手里也不太可能有,打出去被胡的风险低;shanten项是向听数惩罚,AI应该避免打出让手牌变散的牌。三个1.0/1.0/2.0的系数就是AI的性格开关。
参数说明:candidates用[i for i in range(34) if hand[i] > 0]生成,而不是遍历34张手牌ID列表,是为了避免同一手牌重复评估。defense_score的系数0.5意味着一个完全安全但进张数为0的牌,打分只有2.0封顶,可能输给一张进张数很高但稍带危险的牌。这个比例不是固定的,第4章会专门讲怎么调。
这里故意没有引入蒙特卡洛模拟或者深度搜索,原因有二:第一,麻将的决策空间虽然不小,但多数情况下「进张数+安全度+向听数」已经能覆盖八成以上的出牌质量,剩下的提升来自读牌和特殊牌型,这部分用规则补齐比引入重型搜索更划算;第二,蒙特卡洛在实时场景下时间开销难控制,一旦超时AI就会卡住不动,这在棋牌项目里是不可接受的。等你把规则引擎调到接近上限,再考虑用神经网络做进一步替代,那是第6章的事。
3.3 最小闭环:随机开局跑一局,看AI每一步怎么选
有了听牌判断和出牌评分,最小闭环就是:把一副洗好的牌堆发给AI,让它摸一张打一张,直到胡牌或流局。这个闭环能跑通,说明你的骨架没问题。
import random def run_episode(seed=42): random.seed(seed) tiles = [i for i in range(34) for _ in range(4)] random.shuffle(tiles) hand = init_hand(tiles[:14]) wall = tiles[14:] discards = [] while wall: draw = wall.pop() hand[draw] += 1 # 摸牌 if can_win(hand): return "win", len(wall) ranked = evaluate_discard(hand, discards) play = ranked[0][0] discards.append(play) hand[play] -= 1 # 出牌 return "draw", 0 print(run_episode(seed=42))run_episode的参数seed是随机种子,固定种子可以让每次实验得到完全相同的发牌序列,方便对比不同策略参数的表现。返回值的第二个元素是「胡牌时牌墙还剩几张」,也就是第几巡胡牌,数字越小说明胡得越早。discards列表会传给evaluate_discard来计算remain,但这里有一个简化——它只记录了AI自己打出的牌,没有记录虚拟对手的弃牌,所以remain会偏高。要获得更真实的概率,需要至少模拟一个对手的完整动作,但作为最小闭环,这个简化可以接受。
注意:这里没有任何碰杠吃的逻辑,AI只做摸牌和出牌两件事。如果你的目标是验证核心决策链,这个环境已经足够;如果要做完整的四人麻将,需要再加三方的状态模拟和副露处理,那属于架构扩展,核心函数不需要改动。这种步步为营的推进方式,能让你在写策略之前先确认地基牢固,而不是一口气堆出几百行代码然后调试到崩溃。
4. 参数调优与策略选择:进张、安全度、防守的三方博弈
4.1 进攻权重与防守权重的配比:三个数字决定AI性格
evaluate_discard里的三个系数,本质上是把「进攻」「防守」「牌型紧凑度」三个目标加权到一个分数里。我见过不少项目在这里翻车,原因都是把attack_score的权重设得太高,AI每张牌都往听牌最快的方向打,结果中盘开始疯狂打危险张,连续点炮。反过来防守权重太高也不行,AI会为了不点炮拆掉自己的听牌,整局打得像缩头乌龟——低级失误的频率是低了,但整场下来赢不了牌。
我的经验是权重不能是固定的三个常数,而应该随牌局阶段动态变化。开局阶段,大家手牌都是散的,打生张的惩罚很低,进攻权重可以高;到了终盘,牌池里危险牌堆积,对手随时可能听牌,防守权重必须压过进攻。
def dynamic_weights(draw_count, max_draws=18): # draw_count是摸牌数(巡数),max_draws是预计最长巡数 progress = min(draw_count / max_draws, 1.0) attack = 1.5 - progress * 0.5 defense = 0.5 + progress * 1.5 return attack, defense这个函数把progress当作0到1的阶段进度,attack从1.5线性降到1.0,defense从0.5线性升到2.0。调用时把第3章评分公式里的固定系数替换成这两个动态值即可。调参时不要凭感觉,我一般把progress分成5档(0.0/0.25/0.5/0.75/1.0),每档跑100局自对弈统计胜率,选胜率最高的那一组曲线。注意这两条曲线的交叉点,通常在progress等于0.6到0.7之间,意味着AI大部分时间还是偏进攻的,只在残局转入防守。这符合牌理:前期落后了还能追,后期点一炮可能直接出局。
4.2 危险度计算的颗粒度:从「剩余张数」到「对手牌河」
上一章的defense_score用的是静态安全度:这张牌在牌池里出现得越多越安全。这个指标在开局阶段够用,但到了中后盘,必须加入对手行为的动态信息。核心思路是读牌,从对手打过的牌反推他手里大概有什么、听什么。热词里反复出现的「AI Agent」「多AI协作」在这个场景的映射,就是让防守模块不再只是被动算概率,而是像老玩家一样对牌河做语义分析。
麻将读牌有几条经验规则,比如「对手刚打过5万,那他手里的3万和7万很可能留不住,2万和8万反而是点炮高发牌」,再比如「对手碰了9万,那他手里基本不会有8万」。这些规则用代码实现并不复杂,关键是要维护每名对手的弃牌历史:
def danger_level(card, opponent_discards): # 基础:对手打出过这张牌越多次,他越不要这张牌,打出去越安全 safety = opponent_discards.count(card) danger = 4 - safety # 筋牌修正:对手打过5,那么2和8的打牌风险上升 suit = card % 9 if suit == 1 and (card + 1) in opponent_discards: danger += 1 if suit == 7 and (card - 1) in opponent_discards: danger += 1 return danger这段代码的筋牌逻辑写得比较粗糙,真实读牌需要同时维护「对手打过的所有牌」和「对手碰杠吃过的牌」,并且用牌河安全表而不是简单的加减分。但方向是对的:danger_level返回的数值不再是一张牌的静态属性,而是随着对手动作动态变化的信号。我在项目里会把danger_level的结果乘到原先的defense_score上,也就是final_defense = defense_score * (1 - danger_level / 8),这样危险牌的防守分会迅速衰减,AI就不太可能去送炮。
这里要提醒一句:读牌规则本质是概率推断,不是定理。对手打过5万,2万和8万风险高,只是统计意义上的相关,不代表每次都会点炮。所以danger_level的修正幅度不要设太大,尤其是对付那些打法混乱的随机对手,读牌规则反而会帮倒忙。我踩过的坑是把筋牌修正权重设成2.0,结果AI在残局过度害怕,把把打安全牌,进攻节奏全丢了。
4.3 时间预算与搜索剪枝:普通电脑上1秒内出牌的优化手段
如果AI要上桌实时出牌,1秒是底线。evaluate_discard对每个候选牌都要做get_ting_list,get_ting_list内部又是34次胡牌检测,乘积效应下CPU会吃紧。我用的优化手段有三个。
第一个是缓存胡牌检测结果。把hand转成元组后用lru_cache装饰can_win,相同手牌结构只算一次。单局内大量出牌评估会碰到重复的子结构,命中率很高,实测能把单次出牌耗时从800毫秒压到200毫秒左右。
第二个是剪枝候选牌数量。并非每一张可打牌都值得评估,先按「手牌中张数多的优先」做一个快速排序,只评估前10张候选。这个启发式在大多数情况下不会漏掉最优解,因为最优出牌几乎总是来自手牌中孤立的、远离搭子的那些牌。张数多的牌通常已经在成型组合里,打出去的机会成本太高。
第三个是提前退出。如果某张候选牌打出后直接听牌(向听数为0),直接选中它,不再评估其他牌。听牌是阶段性目标,能听就听,不要为了贪图更高的进张数放弃听牌。
best_play = candidates[0] for c in candidates: hand[c] -= 1 if calculate_shanten(hand) == 0: best_play = c hand[c] += 1 break # 直接听牌,立即停止搜索 hand[c] += 1这段提前退出的代码要注意必须在还原手牌后再break,否则后续逻辑会拿到被修改的手牌。实际项目中我会把候选按花色分组,如果某个花色已经凑齐面子,那这个花色的牌可以先跳过,集中评估还有缺陷的花色。这类剪枝对性能的提升很直接,而且不容易引入策略错误。说到底,时间预算的本质是让AI在1秒内做出最不坏的决定,而不是理论上最优的决定——牌桌上是实时博弈,慢半拍的AI再聪明也没人愿意跟你玩。
5. 麻将AI的避坑指南:常见翻车现场与排查手段
5.1 现象一:七对子与字牌顺子让胡牌检测判错
现象:AI手里明明已经是七对子的听牌形态,程序却判定没听,打出一张字牌后退出听牌。日志里手牌是7个对子,can_win却返回False。
原因:can_win里虽然写了七对特判,但特判条件写错位置。比如「七对」要求7个对子,而手牌是14张;但如果碰过牌(有副露),手牌数量不是14张,特判会误伤。另一个高频错误是check_regular里字牌的顺子边界处理:字牌区间27到33如果不加阻断,递归时会把「东南西」当作顺子拆出来,导致胡牌结果完全错乱。
解决:副露状态要单独处理。我的做法是:胡牌检测函数接收一个extra参数,表示已经副露的面子数。副露了k组面子,普通牌型就只需要检测「剩余手牌能否凑出4-k组面子+1对雀头」,七对子则直接禁用(七对不能有副露)。字牌边界用第2章的can_start_shunzi函数统一判断,不要在每个递归里手写边界条件,否则改一处漏三处。排查这个问题时,最快的方法是打印debug信息:在can_win入口打印hand和副露状态,在check_regular出口打印拆出来的面子组合,一张一张对,错误基本是秒现。
5.2 现象二:AI已经听牌,下一手又自己退听
现象:AI打出某张牌后听牌,下一轮摸牌后,评分函数给出的最优出牌居然把听牌拆了,向听数从0变成1,AI在明明该进攻的时候选择了拆牌防守。
原因:评分函数里的shanten惩罚系数是2.0,如果某张牌打了之后进张数很高、防守分很高,总分压过「保持听牌」的收益,AI就会犯迷糊。这是典型的软约束问题——听牌状态应该是一个硬约束,而不是被其他分数盖过的加权项。
解决:在评分前先判断当前手牌是否听牌,如果听牌,任何会导致退听的出牌直接排除:
if calculate_shanten(hand) == 0: score = {} for c in candidates: hand[c] -= 1 if calculate_shanten(hand) > 0: hand[c] += 1 continue # 跳过这张会让AI退听的牌 ting = get_ting_list(hand) attack = sum(remain[t] for t in ting) defense = (4 - remain[c]) * 0.5 score[c] = attack + defense hand[c] += 1 return max(score, key=score.get)这个硬约束的优先级必须最高,因为「听牌后退听」在真实牌桌上是最容易被玩家看穿的破绽。我当初用自对弈日志发现AI频繁退听,加了这个约束之后,胜率立刻提升了大约8个百分点——听起来不多,但在麻将这种高随机性游戏里,差两个点就已经很可观了。
5.3 现象三:碰杠牌没算进剩余张数,AI以为安全实际点炮
现象:AI打出某张牌,自评防御分很高(认为剩余张数很少),结果下家正好碰牌或胡牌。检查日志发现,对手碰过的那张牌在remain里没有扣除。
原因:calc_remaining只减去了hand和discards,没有统计碰杠吃产生的明牌。碰牌后的三张牌属于已知牌,必须从remain里扣掉,否则AI会误以为牌池里还有这张牌,低估它的风险。
解决:维护一个完整的visible列表,把场上所有已经翻开的牌都放进去,包括所有对手的副露和弃牌。calc_remaining改为从4张初始值扣掉hand、discards、exposed_cards三部分的计数。我建议写一个统一的函数:
def calc_remaining(hand, discards, exposed): remain = [4] * 34 for i in range(34): remain[i] -= hand[i] for d in discards: remain[d] -= 1 for e in exposed: remain[e] -= 1 return remainexposed里每张牌出现的次数,就是它在牌桌上已经亮相的次数。这里要特别小心重复扣除:如果一张牌既在discards里,又在exposed里,说明记录员把它记了两遍。我的做法是exposed里只放副露的牌,discards只放打出的牌,两者物理上互斥。这类计数错误不会导致AI崩盘,但会让它的防守判断时好时坏,属于最阴险的隐性bug——表面上代码没问题,就是偶尔点炮。
5.4 现象四:带万能牌(宝牌/金牌)的规则,AI牌型计数直接爆炸
现象:在带万能牌规则的麻将里,比如武汉麻将的皮、日本麻将的宝牌指示,AI的胡牌判定会漏判,或者递归函数卡死,表现为出牌时CPU飙满、迟迟不落子。
原因:万能牌能当作任何一张牌用,如果把它硬编码成34种牌之一,can_win的递归分支数会指数膨胀。比如手里有一张万能牌,枚举它当1万、当2万、当3万……每个分支都要递归拆一次,手牌越多越卡。
解决:把万能牌从34计数数组里单独拿出来,用一个wild_count参数表示。check_regular递归时优先用普通牌凑面子,凑不齐再尝试用万能牌补位:
def check_regular_wild(hand, wild): if all(x == 0 for x in hand) and wild == 0: return True if wild < 0: return False for i in range(34): if hand[i] > 0: if hand[i] >= 3: hand[i] -= 3 ok = check_regular_wild(hand, wild) hand[i] += 3 if ok: return True if can_start_shunzi(i) and hand[i] > 0 and hand[i+1] > 0 and hand[i+2] > 0: hand[i] -= 1; hand[i+1] -= 1; hand[i+2] -= 1 ok = check_regular_wild(hand, wild) hand[i] += 1; hand[i+1] += 1; hand[i+2] += 1 if ok: return True return False return wild == 2 # 兜底:万能牌补雀头这个函数的性能问题可以用剪枝缓解:只有当前递归层没有任何普通牌可以组成面子时,才考虑动用wild牌。很多人觉得万能牌是加分项,其实它是坑——一旦处理不当,AI的听牌判断会把「根本不可能的胡牌牌型」也算进去,导致AI选出一张看似能胡实则永远胡不了的牌。判断万能牌AI强度时,我建议把带wild的规则和无wild的规则分开评测,否则两种规则下的参数会互相干扰。
6. 用自对弈验证AI强度:一套不上桌就能测出水平的评测方法
6.1 固定种子自对弈:胜率、听牌率、平均向听数三个指标
调参数不能靠感觉,我习惯把评测脚本当作项目的第一份源码来写。固定的做法是:选10个随机种子,每个种子跑100局,统计胜率、听牌率、放炮率和平均向听数。听牌率衡量AI有没有在流局前进入听牌状态,放炮率衡量防守水平,平均向听数衡量手牌推进效率,这四个数字比单看胜率靠谱得多——因为麻将发牌的随机性太大,几十局的胜率可能全是噪声。
def evaluate(games=100): win = 0 for seed in range(games): result = run_episode(seed=seed) if result[0] == "win": win += 1 return win / games这个函数演示了胜率的统计方式。要统计听牌率和放炮率,需要把run_episode的返回值扩展成一个dict,在每巡记录AI是否听牌、是否出过危险张、最终是否点炮。口播一下我的经验:听牌率稳定在80%以上,说明进攻决策合格;放炮率如果超过15%,防守权重需要上调。平均向听数是更细的指标,它可以告诉你AI在每一巡的手牌质量是在变好还是停滞。
评测脚本一定要跟着源代码一起做版本管理。我见过太多人改了一版出牌评分,感觉好像变强了,实际上没有数据支撑。把评测脚本写进CI流程里,每次提交代码都自动跑一轮,出牌评分的回归问题在合入分支前就能发现,而不是上线后被打爆了才回头查。这也是「源代码管理」这个词在AI项目里最实在的落地场景——管理的不只是代码,还有代码带来的行为变化。
6.2 进阶:从规则引擎到神经网络的接棒点
规则引擎的极限出现在读牌逻辑变厚的时候。当你有二三十条读牌规则,AI的行为开始变得僵硬,继续堆if-else的收益很低,这时候适合引入神经网络做打牌决策。我的建议是走「模仿学习+自对弈强化」的路线:先用当前规则AI跑出大量对局,把状态(手牌编码、牌河编码、弃牌历史)和AI打出的牌存成样本,训练一个简单的全连接网络做行为克隆,再用自对弈让网络通过策略梯度继续进化。
# 数据采集:跑1000局规则AI,保存状态和决策 state = encode_state(hand, discards, opponents) record.append((state, play))encode_state的编码方式要跟训练网络的输入对齐,常见做法是手牌34维、牌河34维、副露情况再拼接几维,总共一百维以内。这个规模不用上大模型,一个三层的MLP就能完成从「模仿老师」到「超越老师」的过程。不过训练之前一定要确认规则AI本身没有系统性缺陷,否则网络只会学着把错误发扬光大。
说到底,我自己的教训是:先有扎实的规则引擎和评测体系,再谈AI进阶,顺序反了会陷入无穷无尽的调参循环。早期我把大量时间花在调花哨的读牌规则上,结果没有一个可靠的评测标准,改一版翻一版。后来老老实实把自对弈脚本写在项目第一行,每次改动先跑一轮,再上桌验证,翻车的概率才真正压下来。希望这套方法也能帮你把时间花在真正能提升AI强度的地方,而不是在玄学调参里打转。
本文还有配套的精品资源,点击获取