简介这份麻将游戏AI源代码是一套基于C/C与Windows API开发的Windows桌面游戏项目能够在Windows环境下编译运行完整包含电脑AI对战模块面向希望从实际项目中学习游戏逻辑、搜索算法、数据结构和窗口程序设计的开发者。RAR压缩包共87个文件、586KB11个cpp与12个h构成核心游戏规则与AI实现17个bmp提供牌面及界面图像33个mp3用于音效另含sln/vcproj工程文件便于直接编译构建。目前已有3813人学习下载。通过阅读代码可以理解麻将牌型判断、胡牌条件、碰杠操作等规则建模过程也能看到AI如何结合深度优先、蒙特卡洛树搜索或启发式评估函数做出出牌决策并掌握Win32消息循环、GDI绘图、资源加载、事件处理和常见数据结构的具体用法。这套源码规模紧凑、结构清晰是学习经典棋牌类AI落地实践和C/C游戏工程组织的合适样本。 写麻将AI之前我先说个实话很多人以为麻将AI就是“给每张牌打一个分选最高的打出去”真动手做才发现完全不是这么回事。麻将不是一个纯计算游戏它是一个隐藏信息游戏——你看不到别人的手牌只能根据牌河、副露和舍牌去反推。这也是为什么很多人照着教程写出来的“AI”一上场就被新手吊打它只会顺着自己的手牌走完全不管桌上发生了什么。这篇文章想分享的是我从零实现一套麻将游戏AI源代码的完整思路。包括手牌编码、向听数计算、舍牌评估、副露判断、防守决策以及如何用蒙特卡洛模拟做辅助验证。代码都以Python为例偏向日麻规则有宝牌、副露但算法思路拿去做广东麻将、四川麻将、国标麻将都可以只需要调整役种和牌型判断部分。整个项目做完我的感受是麻将AI的核心价值不在某个算法多惊艳而在于状态评估的完整程度。下面按我自己搭建时的顺序来讲先地基后上层讲清楚每一步为什么这么做。1. 为什么麻将AI比棋类AI更麻烦——先理解问题本质1.1 不完全信息博弈的特殊性拿象棋、围棋举例这类游戏属于完全信息博弈棋盘上所有信息都是公开的AI只需要在给定的局面下做最优搜索。麻将完全不是这个模型每一家的手牌互相不可见牌山里的剩余牌也不可知每个人看到的世界都不一样。所以麻将AI在底层逻辑上更接近德州扑克你要根据有限的可观测信息去推断对手的牌型倾向然后做概率意义上的最优决策。但这同时也意味着没有一个“绝对正确”的AI——同样一手牌在对手已经立直的情况下和对手还在乱打的情况下最优舍牌完全不同。这也是我在设计代码时强制自己遵守的第一条原则决策函数必须接收完整的游戏状态而不是只看着自己手牌做决定。1.2 麻将AI需要覆盖的决策点麻将的一局游戏AI至少要处理四类决策摸牌后选择打哪一张舍牌别家舍牌后判断要不要吃、碰、杠还是直接过副露后继续选择舍牌听牌时判断要不要换听、要不要改听更安全的牌其中舍牌决策频率最高对手牌走向影响最大是整套系统里最先要攻克的部分。而吃碰杠决策虽然触发频率低但做错一次可能直接决定这局是赢还是放铳。1.3 自底向上的搭建路线我真正动手开始写的时候发现网上能找到的麻将AI代码要么是纯规则硬编码要么是直接上强化学习中间层几乎没有。后来我确定了一条自底向上的路线先实现手牌编码和向听数计算评估手牌的基础再做牌效优先的舍牌“最强AI”的基线加入安全度评估防守最后做副露判断和蒙特卡洛模拟辅助这条路线的好处是每一层都可以单独验证。比如向听数算错了后面所有的评估全是废的。所以每一步我都会写一个简单的测试脚本来验证正确性。2. 向听数与手牌评估整个AI的地基2.1 手牌的编码方式麻将牌一共34种万子1-9、筒子1-9、条子1-9、风牌4种、三元牌3种。我用一个长度为34的整数数组表示手牌数组下标对应牌种值对应该牌的张数。# 牌编码约定 # 0-8: 万子 1万-9万 # 9-17: 筒子 1筒-9筒 # 18-26: 条子 1条-9条 # 27-30: 东南西北 # 31-33: 白发中 hand [0] * 34这种编码方式比字符串表示更高效做牌型拆解、遍历都方便。后面做副露判断时副露的牌用单独的结构存储手牌数组只维护手上的牌。2.2 向听数的计算思路向听数的定义是还差几张有效牌就能听牌。它是所有麻将AI决策的基石。0向听就是已经听牌1向听是摸一张有效牌就能听牌。计算向听数最直观的方法是递归拆解。手牌的标准形是“4组面子1组雀头”其中面子是顺子或刻子。所以问题转化为从手牌里能拆出多少个面子、多少个搭子、以及是否有一个雀头。def count_shanten(hand): 计算标准形的向听数简化版未考虑七对子和国士无双 返回0表示听牌返回-1表示已经和牌 min_shanten 8 # 标准形最大向听数是8 for pair_idx in range(34): if hand[pair_idx] 2: tmp list(hand) tmp[pair_idx] - 2 shanten _dfs(tmp, 4, 0) # 需要凑4组面子 if shanten min_shanten: min_shanten shanten return min_shanten def _dfs(hand, need_melds, shanten): if need_melds 0: # 剩下的牌全是孤张 return shanten sum(hand) # 找到第一个有牌的索引 i 0 while i 34 and hand[i] 0: i 1 if i 34: return shanten need_melds * 2 best 8 # 尝试拆刻子 if hand[i] 3: tmp list(hand) tmp[i] - 3 best min(best, _dfs(tmp, need_melds - 1, shanten)) # 尝试拆顺子 if i 27 and i % 9 7 and hand[i1] 0 and hand[i2] 0: tmp list(hand) tmp[i] - 1; tmp[i1] - 1; tmp[i2] - 1 best min(best, _dfs(tmp, need_melds - 1, shanten)) # 或者把这张牌当孤张后续再优化 tmp list(hand) tmp[i] - 1 best min(best, _dfs(tmp, need_melds, shanten 1)) return best这段代码是我早期验证用的简化版递归深度不大逻辑也好理解。但在真实项目中我建议再加上七对子、国士无双的单独计算它们在牌型特殊时向听数会明显低于标准形不开这个分支会漏掉最优解。另外我踩过一个坑如果手牌是13张正常摸牌后副露过一次后手牌变成10张再副露变成7张。副露后的向听数公式和标准形不同因为手牌数量减少了。我的处理方式是单独写一个函数处理副露后的手牌不再复用同一段代码。2.3 有效牌数量与牌效评估有向听数之后就可以算“有效牌”了。有效牌的定义是摸到哪几张牌能让手牌向听数下降。枚举手牌里还没摸到的34种牌每种模拟摸一张重算向听数如果变低了就计入有效牌集合。def effective_tiles(hand, visible_tiles): 计算当前手牌的有效牌及数量 visible_tiles: 已经出现的牌用于排除 base count_shanten(hand) result [] for t in range(34): if hand[t] 4 - visible_tiles[t]: continue # 这种牌已经不可能再摸到 tmp list(hand) tmp[t] 1 if count_shanten(tmp) base: result.append(t) return result有效牌数量就是这个手牌的“牌效分数”。什么时候换听、什么时候拆搭子本质上都是在比较不同方案的有效牌数量。这个指标虽然简单却是后面所有复杂策略的原始输入。3. 舍牌决策从“牌效优先”到“攻守平衡”3.1 牌效评分打出某张后的进张数基础舍牌逻辑是枚举手牌里的每一张候选牌模拟打出后计算新手牌的有效牌数量取有效牌数量最多的打法。def choose_tile_naive(hand, visible_tiles): best_tile None best_score -1 for t in range(34): if hand[t] 0: continue new_hand list(hand) new_hand[t] - 1 score len(effective_tiles(new_hand, visible_tiles)) if score best_score: best_score score best_tile t return best_tile这套逻辑跑起来之后AI就已经能在不考虑防守的情况下打出比较好的牌效了比初级玩家强不少。但它有一个致命问题完全不看别家打出来的牌也不知道自己在什么风险局面下。3.2 安全度评估牌河的信号怎么用这里引入安全度评估。核心思想是牌河里出现过的牌相对安全一张都没出现的生张很危险。我把舍牌的危险程度分成几档危险等级典型状态说明0 - 安全现物牌河里已有同类牌对方不可能用这张牌和牌除非单骑听牌1 - 较安全早巡外字牌字牌很少被做成顺子早巡打出的字牌安全度极高2 - 中等筋牌数字牌57、5和8之类日麻里筋牌是相对安全的3 - 危险无筋生张数牌最容易放铳的一类4 - 极危险对手副露后的切牌附近的生张需要重点防守在日麻里防守时还需要考虑振听规则立直者打过哪些牌那些牌的筋、同巡都可以纳入安全度评估。具体逻辑我简化成了一个函数def tile_danger(tile, game_state): score 0 # 现物绝对安全 if tile in game_state.discards: return 0 # 字牌看场上出现张数 if tile 27: score max(0, 3 - game_state.tile_count_on_table[tile]) else: # 数牌无筋加2分生张加1分 if not game_state.is_suji(tile): score 2 if game_state.tile_count_on_table[tile] 0: score 1 # 对手副露附近的牌加1分 if game_state.near_meld_tiles(tile): score 1 # 巡目越晚危险分数越高 score game_state.turn / 12 return score3.3 综合评分公式最终我的舍牌评分公式长这样final_score tile_efficiency_score - danger_weight * danger_scoredanger_weight 是一个可调参数我一开始设成0.5后面在测试对局里反复调。防守权重太高AI变得太怂明明该进攻的时候打保守牌牌效直线下降权重太低又变成无脑进攻放铳率感人。最终在我的测试环境下调到0.8左右比较平衡。这里有一个非常关键的判断**要不要防守取决于“我离和牌有多近”和“对手离和牌有多近”的对比。**听牌了但手牌很小对手已经立直果断弃和才是正解自己已经听牌且牌很大那即使有危险牌也可以搏一搏。我会先判断自己当前手牌的向听数再结合场上的立直、副露情况决定要不要切换防守模式。4. 吃碰杠的时机判断当“动”则动当“静”则静4.1 副露后手牌结构的变化吃碰杠副露对牌型影响很大手牌减少一张可调节空间变小牌河信息暴露给三家别人知道你在做什么牌。但副露的好处也很直接减少一张手牌相当于少一张要打出去的牌通常能加快听牌速度。我的判断逻辑不搞什么玄学先计算收益def should_chi_or_pon(game_state, action): # action 是吃/碰的具体牌型 # 模拟副露后的手牌 new_hand simulate_meld(game_state.hand, action) new_shanten count_shanten_after_meld(new_hand) old_shanten count_shanten(game_state.hand) return new_shanten old_shanten如果吃碰后的向听数显著下降比如从2向听变成1向听AI倾向于副露如果向听数没有变化则考虑副露带来的额外收益断幺、役牌、染手方向否则不副露。4.2 快速听牌 vs 手牌价值这里有一个我调试中发现的坑副露判断不能只看向听数还要看剩余牌数。一局进行到后半段快速听牌的价值远大于手牌的理论价值但如果牌山里还有大量有效牌保留门前清反而可能做出更大的牌。所以最终的判断逻辑分成三档副露后直接听牌多数情况下副露除非手牌有明确的役种方向冲突副露后向听数下降一位评估手牌价值和剩余巡数场况紧迫就副露副露后向听数不变一般不副露除非能凑出役种比如吃出断幺4.3 风险控制副露后防守能力大幅下降副露还有一个隐性成本手牌张数变少之后防守时能选择的切牌范围变小了。所以你经常能看到日麻高手的对局里明明可以早巡碰牌却故意不碰就是不想放弃后续的防守弹性。这个观点在我自己的AI里落地成了一条规则当场上有两家以上明显在做牌或者立直时副露的收益要乘以一个折扣系数。5. 代码架构与蒙特卡洛辅助让AI可测试、可扩展5.1 模块划分整套代码我分成几个独立模块方便单测和替换策略mahjong/ ├── core/ │ ├── tiles.py # 牌编码、牌组判断 │ ├── hand.py # 手牌类、副露结构 │ └── shanten.py # 向听数计算 ├── agent/ │ ├── base.py # AI接口定义 │ ├── efficiency.py # 牌效优先AI │ ├── defensive.py # 带防守的AI │ └── mcts.py # 蒙特卡洛模拟辅助 ├── game/ │ ├── table.py # 牌桌状态、回合流转 │ └── simulator.py # 对局模拟器 └── tests/ ├── test_shanten.py └── test_effective.py这种结构的最大好处是每个AI策略都可以单独跑对局对比胜率换策略不需要动游戏框架。5.2 对局模拟器的作用对局模拟器是整个项目中最被低估的部分。没有它你只能靠真人打牌去测试AI效率极低。模拟器要模拟从洗牌、发牌到打牌的全过程并且支持两个AI互相竞技。我一开始只写了随机AI作为baseline后来加入牌效AI、防守AI每次改完代码跑几千局对局看统计结果。# 对局模拟核心循环 def play_game(ai1, ai2, ai3, ai4): table Table() while not table.game_over(): current table.current_player() tile current.agent.choose_action(table.public_state()) # ...5.3 蒙特卡洛模拟辅助决策单纯靠公式评分的AI有一个问题公式是拍脑袋定的不同局面下最优权重不一样。我后来给AI加了一层蒙特卡洛模拟对候选的舍牌方案各自随机模拟N次后续摸牌过程统计最终的和牌率和期望得分用这个统计结果辅助决策。def simulate_discard(game_state, tile, n200): wins 0 for _ in range(n): sim_state copy.deepcopy(game_state) sim_state.discard(tile) while not sim_state.game_over(): sim_state.step_random() if sim_state.winner() 0: wins 1 return wins / n这个方法虽然有效但我提醒一句它很吃算力。每手牌做200次模拟每次模拟几十步步数多了之后对局速度肉眼可见地变慢。我在项目里只对“牌效分数很接近”的候选牌做模拟而不是对每张候选牌都跑。6. 实测数据与调参心得AI真实对局的那些坑6.1 用胜率和放铳率说话我先后实现了三个版本版本描述胜率4人局平均放铳数/局v1纯牌效AI23%0.42v2牌效基础防守26%0.31v3牌效防守蒙特卡洛辅助27%0.28v1的问题非常典型它只知道做自己的牌对手一旦立直它照样往枪口上撞。加进防守逻辑后放铳率立刻下来了胜率也上去了。这说明防守带来的收益比盲目进攻高得多。6.2 容易踩的坑第一个坑是向听数计算里的递归层数。Python默认递归深度是1000复杂局面加上七对子、国士无双分支后递归深度很容易飙升。解决办法是改成迭代式栈或者直接提高递归上限但提高上限后要注意栈溢出风险。第二个坑是副露后的手牌数量。我早期直接在原来的向听数函数上套导致副露后向听数永远算不对。后来副露后的手牌用单独的类表示长度是10或7而不是13。第三个坑是防守逻辑太激进。v2版本我一度把安全度权重调到1.5AI经常在牌效极好的局面选择打安全牌把自己拖崩。后来我加入了“自己和牌收益”的权衡逻辑当自己已经听牌且手牌足够大时对危险牌的容忍度提升。6.3 调参经验调参与其说是技术不如说是在打麻将。我每次改完参数都会跑500局对局看统计不凭感觉拍板。有一个经验可以分享进攻还是防守的阈值跟规则有直接关系。在日麻规则下防守权重可以给高一点因为放铳的惩罚很大但在有些地方的麻将规则里放铳惩罚相对小那就应该更偏向进攻。所以做AI之前一定要先想明白目标规则下的得分结构。我自己现在跑这套代码日常训练已经可以让AI稳定打赢普通玩家。下一步的打算是尝试把向听数和有效牌相关的网络结构接进来用自对弈生成的数据训练一个价值网络替换掉现在手工调的权重。最后分享一个我已经踩过多次的坑不要在还没有对局模拟器的时候就开始调AI参数。没有模拟器你根本没法在短时间内验证一次改动到底是变强了还是变弱了。先把模拟器写好让AI自己和自己打后续所有优化才谈得上有效率。本文还有配套的精品资源点击获取