简介:这份麻将游戏AI源代码是一套基于C/C++与Windows API开发的Windows桌面游戏项目,能够在Windows环境下编译运行,完整包含电脑AI对战模块,面向希望从实际项目中学习游戏逻辑、搜索算法、数据结构和窗口程序设计的开发者。RAR压缩包共87个文件、586KB:11个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[i+1] > 0 and hand[i+2] > 0: tmp = list(hand) tmp[i] -= 1; tmp[i+1] -= 1; tmp[i+2] -= 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, n=200): 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 | 纯牌效AI | 23% | 0.42 |
| v2 | 牌效+基础防守 | 26% | 0.31 |
| v3 | 牌效+防守+蒙特卡洛辅助 | 27% | 0.28 |
v1的问题非常典型:它只知道做自己的牌,对手一旦立直,它照样往枪口上撞。加进防守逻辑后,放铳率立刻下来了,胜率也上去了。这说明防守带来的收益比盲目进攻高得多。
6.2 容易踩的坑
第一个坑是向听数计算里的递归层数。Python默认递归深度是1000,复杂局面加上七对子、国士无双分支后,递归深度很容易飙升。解决办法是改成迭代式栈,或者直接提高递归上限,但提高上限后要注意栈溢出风险。
第二个坑是副露后的手牌数量。我早期直接在原来的向听数函数上套,导致副露后向听数永远算不对。后来副露后的手牌用单独的类表示,长度是10或7,而不是13。
第三个坑是防守逻辑太激进。v2版本我一度把安全度权重调到1.5,AI经常在牌效极好的局面选择打安全牌,把自己拖崩。后来我加入了“自己和牌收益”的权衡逻辑,当自己已经听牌且手牌足够大时,对危险牌的容忍度提升。
6.3 调参经验
调参与其说是技术,不如说是在打麻将。我每次改完参数,都会跑500局对局看统计,不凭感觉拍板。有一个经验可以分享:进攻还是防守的阈值,跟规则有直接关系。在日麻规则下,防守权重可以给高一点,因为放铳的惩罚很大;但在有些地方的麻将规则里,放铳惩罚相对小,那就应该更偏向进攻。所以做AI之前一定要先想明白目标规则下的得分结构。
我自己现在跑这套代码,日常训练已经可以让AI稳定打赢普通玩家。下一步的打算是尝试把向听数和有效牌相关的网络结构接进来,用自对弈生成的数据训练一个价值网络,替换掉现在手工调的权重。
最后分享一个我已经踩过多次的坑:不要在还没有对局模拟器的时候就开始调AI参数。没有模拟器,你根本没法在短时间内验证一次改动到底是变强了还是变弱了。先把模拟器写好,让AI自己和自己打,后续所有优化才谈得上有效率。
本文还有配套的精品资源,点击获取