简介:一套基于Python实现的图形化麻将游戏项目,同时集成了蒙特卡洛树搜索AI,适合Python游戏开发或人工智能方向的学习者,可作为课程设计、毕业设计或工程实训的参考。项目提供两种运行模式:通过controller.py启动Pygame图形界面,或通过pyenv.py在终端运行纯文本版本,便于对照理解完整交互流程与AI决策过程。资源压缩包共52个文件,大小约198KB,包含35个PNG麻将牌面与界面素材、8个Python主程序与模块、3个C++源文件及2个头文件(用于AI加速计算),以及Java索引文件、pickle数据和说明文档等,文件类型多样,可对应图形渲染、牌局逻辑、AI算法和工具脚本等不同模块。自发布以来已有470人学习浏览,适合需要模块化游戏项目范例或AI算法落地参考的进阶学习者。通过研读源码与AI实现,可掌握Pygame图形交互、蒙特卡洛树搜索思路、C++与Python混合调用等实践技巧。
1. 从“能胡牌”到“会猜牌”:为什么麻将 AI 要用蒙特卡洛
很多人第一次听说“麻将 AI”时,第一反应是“这不就是个查表胡牌器吗”。确实,如果只做“听牌判断”和“胡牌检测”,麻将的规则复杂度根本不需要 AI——几张表加几个递归函数就能搞定。但麻将真正难的地方在于:你永远不知道对手手里有什么牌。你只知道他打了什么、碰了什么、杠了什么,剩下的全靠推测。这是典型的不完全信息博弈,而蒙特卡洛方法恰好就是处理这类不确定性的标准武器。
这篇文章要讲的,是一个基于 Python 的图形化麻将游戏,AI 用蒙特卡洛模拟来做“概率型决策”。简单说,AI 在每次出牌前,会基于当前已知信息“脑补”出成千上万种对手手牌分布,然后在每种分布里模拟打某张牌的后续走向,统计哪张牌导致输牌的概率最低、和牌的概率最高。这种思路和 AlphaGo 的蒙特卡洛树搜索同源,但实现成本低得多,纯 Python 就能在一秒内完成一次决策。适合想理解博弈 AI 原理、又不想碰深度学习框架的开发者。
文章会从手牌表示和胡牌判定讲起,再给蒙特卡洛模拟器做渐进式实现,最后落到一个 Pygame 桌面上能跑的完整原型。你不需要有强化学习背景,只需要熟悉 Python 基础语法和一点概率直觉。
2. 手牌表示与胡牌判定:先把规则的“地基”写得足够快
蒙特卡洛 AI 的核心是“大量模拟”,每次模拟都要做胡牌检测。如果胡牌判定函数慢一倍,AI 的模拟次数就少一半,决策质量直接打折。所以第一步不是写界面,而是把手牌数据结构和胡牌算法写对。
2.1 用整数编码麻将牌,避免字符串比较开销
常见做法是用 0 到 33 的整数表示 34 种牌:0-8 表示万子(1万到9万),9-17 表示条子,18-26 表示饼子,27-33 表示字牌(东南西北白发中)。这样做的好处是:比较大小只需要整数运算,构造顺子时只需要检查数值是否连续,而且可以用长度为 34 的 list 或 array 来统计每张牌的数量。
# 牌面编码:0-8万,9-17条,18-26饼,27-33字 def encode_tile(name: str) -> int: """将'3万'、'7条'这类字符串转为整数编码""" suit = name[-1] num = int(name[:-1]) - 1 if suit == '万': return num elif suit == '条': return 9 + num elif suit == '饼': return 18 + num else: return 27 + num # 字牌按 东南西北白发中 顺序映射 # 手牌统计:tiles[i] 表示编码为 i 的牌有几张 # 例:hand = [0]*34; hand[3] = 2 表示有两张4万这里用列表长度 34 而不是直接用集合,是因为胡牌判定需要频繁查询“某张牌是否还有剩余”,数组索引比字典或集合都快。对于蒙特卡洛模拟来说,这种微性能差异会在百万次调用后被放大得很明显。
2.2 胡牌判定:拆分为“将牌 + 面子”的递归搜索
麻将胡牌的标准结构是:一对将牌(对子)加上若干组面子(顺子或刻子)。普通牌型是 3n+2 张,七对子、十三幺等特殊牌型可以单独判断。递归判断的核心思路是:从数值最小的牌开始,优先尝试把它作为刻子移除,再尝试作为顺子移除。因为最小的牌如果不能组成顺子或刻子,它就只能做将牌。
def can_win(tiles: list) -> bool: """判断手牌是否胡牌,tiles 为长度 34 的计数数组""" # 七对子:7 个不同对子,不含刻子 if sum(tiles) == 14 and tiles.count(2) == 7: return True # 普通牌型:遍历每一种牌作为将牌 for i in range(34): if tiles[i] >= 2: tiles[i] -= 2 if _is_regular_hand(tiles): tiles[i] += 2 return True tiles[i] += 2 return False def _is_regular_hand(tiles: list) -> bool: """判断去掉将牌后,剩余牌是否能全部组成顺子/刻子""" # 找到第一张数量不为 0 的牌 for i in range(34): if tiles[i] == 0: continue if tiles[i] >= 3: tiles[i] -= 3 if _is_regular_hand(tiles): tiles[i] += 3 return True tiles[i] += 3 if i < 27 and i % 9 <= 6 and tiles[i+1] > 0 and tiles[i+2] > 0: # 尝试组成顺子:i, i+1, i+2 必须同花色且不跨区 tiles[i] -= 1 tiles[i+1] -= 1 tiles[i+2] -= 1 if _is_regular_hand(tiles): tiles[i] += 1 tiles[i+1] += 1 tiles[i+2] += 1 return True tiles[i] += 1 tiles[i+1] += 1 tiles[i+2] += 1 return False # 最小的牌既不能组刻子也不能组顺子,说明无解 return True这段代码的逻辑顺序很关键:优先尝试刻子,因为刻子的约束条件更严格;i % 9 <= 6这个判断是为了防止“8万9万1条”被错误地当成顺子,因为 8万编码是 7,9万编码是 8,而 1条编码是 9,不在同一花色区间内。_is_regular_hand的递归深度最多 4 层(14 张牌最多拆 4 组面子),栈开销可以忽略。
判断听牌时,只需要遍历 34 种牌,逐一尝试加入手牌并调用can_win。考察的是没有性能压力,但蒙特卡洛模拟中每次摸牌都要判断,所以建议在 AI 决策循环里缓存已经验证过的牌型结果。实际测试中,纯 Python 的can_win单次调用约 0.05ms,百万次模拟需要 50 秒左右,这个量级需要通过剪枝和向量化来压缩,后面章节会讲具体做法。
3. 蒙特卡洛模拟器的设计与采样策略:让 AI 学会“猜别人的手牌”
蒙特卡洛在这里解决的核心问题是:AI 只知道自己的手牌和所有玩家打出的牌,不知道对手手里有什么。但决策需要评估“打哪张牌更好”的期望收益,于是我们用大量随机采样来逼近这个期望。
3.1 采样流程:从剩余牌堆中随机补全对手手牌
假设开局每人 13 张牌,你当前手牌 13 张,场上已经打出了若干张牌。AI 能确定的信息有:自己的手牌、所有玩家打出的牌、碰杠后明示的牌。那么对手手牌唯一能确定的数量就是——总牌数减去所有已知牌后剩余的部分。
import random class MonteCarloSimulator: def __init__(self, known_tiles: list, hand_size: int = 13): """ known_tiles: 长度为 34 的数组,标记所有 AI 能看到的牌 hand_size: 需要补全的单家手牌数量 """ self.remaining = [] for i in range(34): count = 4 - known_tiles[i] # 每种牌原本4张,减去已见的 self.remaining.extend([i] * count) self.hand_size = hand_size def sample_opponent_hand(self) -> list: """从剩余牌堆中随机抽取一组对手手牌""" deck = self.remaining[:] random.shuffle(deck) return deck[:self.hand_size]这个采样有个隐含假设:剩余牌堆里每张牌被抽中的概率均等。实际麻将中对手的打牌行为会透露信息——比如对手一直打条子,说明他很可能没有条子顺子需求。但第一版 AI 可以先做均匀采样,后续再引入“对手策略模型”做非均匀采样。这个递进思路在蒙特卡洛 AI 里很常见。
提示:采样时不要把已打出的牌放回牌堆。known_tiles里包含了所有玩家打出的牌和亮明的牌,这样remaining才是真正的未知牌堆。
3.2 评估函数:以“能否快速和牌”为奖励标准
一次完整模拟包含三步:随机给每个对手补手牌、模拟牌堆剩余牌的摸牌顺序、判断 AI 在当前牌型下获胜的概率。设计评估函数时,我一般用“AI 和牌所需的最少摸牌次数”作为指标,而不是简单地用“能否胡牌”做二值判断。因为蒙特卡洛需要区分“听牌”“一向听”“两向听”之间的差距。
def evaluate_hand(hand: list, simulator: MonteCarloSimulator, trials: int = 1000) -> float: """ 评估当前手牌在随机牌堆上的期望向听数 返回数值越小代表离胡牌越近 """ total_distance = 0 for _ in range(trials): # 模拟剩余牌堆的摸牌顺序 draw_order = simulator.remaining[:] random.shuffle(draw_order) temp_hand = hand[:] distance = 0 for tile in draw_order: temp_hand.append(tile) if can_win(temp_hand): break temp_hand.pop() # 没胡就继续摸下一张 distance += 1 if distance > 6: # 超过6向听直接截断,节省计算 break total_distance += distance return total_distance / trials这里有个容易踩的坑:temp_hand.append(tile)后调用can_win,如果没胡必须pop()恢复原状,否则手牌会越来越大。另外截断条件设成 6 是因为麻将的向听数上限一般是 6(13 张牌全部不挨边时),超过这个数目的模拟结果对决策没有区分度。实际项目中,trials设为 300 到 500 之间的效果和 1000 差别很小,但耗时能省一半。
3.3 出牌决策:对每一张候选牌做蒙特卡洛评估
AI 的决策逻辑是:遍历手牌中所有不重复的牌,假设打出这张牌,然后用蒙特卡洛评估剩余手牌的“期望向听数”,选择期望值最小的那张牌打出。
def choose_discard(hand: list, known_tiles: list, trials: int = 500) -> int: """ 返回要打出的牌的编码 hand: AI 当前手牌(13张) known_tiles: 所有已出现的牌(含自己的手牌和所有玩家打出的牌) """ simulator = MonteCarloSimulator(known_tiles) unique_tiles = set(hand) best_tile = None best_score = float('inf') for tile in unique_tiles: # 模拟打出这张牌 new_hand = hand[:] new_hand.remove(tile) score = evaluate_hand(new_hand, simulator, trials) if score < best_score: best_score = score best_tile = tile return best_tileunique_tiles的使用需要注意:如果手牌里有三张相同的牌,你只需要评估一次打出它的效果,因为结果完全一样。这个优化能把评估次数从 13 降到平均 8 次左右。对于每轮决策需要 8×500=4000 次胡牌判定的场景,纯 Python 大约耗时 0.5 到 1 秒,在图形化游戏里完全可以接受。
| 参数 | 建议值 | 影响 |
|---|---|---|
| trials | 300-500 | 低于 100 时随机波动大,高于 1000 时提升不明显 |
| 截断向听数 | 6 | 控制单次模拟的最大步数,值过大会拖慢速度 |
| 候选牌去重 | 开启 | 手牌重复越多,节省的计算量越大 |
4. 图形化界面的实现:Pygame 桌面版的牌桌布局与交互
图形化部分我选用 Pygame 而不是 Tkinter 或 PyQt,原因有两点:一是 Pygame 的绘制模型更接近游戏场景,方便做牌桌动画和鼠标交互;二是它的安装成本低,pip install pygame即装即用。整个界面按“玩家手牌在底部、AI 手牌在顶部、牌河在中间”的布局设计。
4.1 牌桌布局与坐标系计算
先用固定尺寸的画布(1200×800)设计牌桌。每张牌用 40×56 像素的矩形表示。玩家的手牌放在底部居中,AI 的手牌顶部朝下显示(不需要让玩家看到具体牌面,用牌背代替)。牌河区域按 6 列 × 2 行的网格堆放每家打出的牌。
import pygame # 牌面矩形区域计算 TILE_W, TILE_H = 40, 56 PLAYER_HAND_Y = 700 # 玩家手牌基线 AI_HAND_Y = 40 # AI 手牌区域 DISCARD_AREA = (300, 200, 600, 400) # 牌河区域 (x, y, w, h) def draw_tile(screen, font, tile_id: int, x: int, y: int, face_up: bool = True): """在指定位置绘制一张牌""" rect = pygame.Rect(x, y, TILE_W, TILE_H) pygame.draw.rect(screen, (245, 245, 245), rect) # 牌面背景 pygame.draw.rect(screen, (80, 80, 80), rect, 2) # 边框 if face_up: # 根据 tile_id 解析花色和数字 suit_names = ['万', '条', '饼'] if tile_id < 27: num = tile_id % 9 + 1 suit = suit_names[tile_id // 9] text = f'{num}{suit}' else: text = ['东', '南', '西', '北', '白', '发', '中'][tile_id - 27] surface = font.render(text, True, (20, 20, 20)) screen.blit(surface, (x + 6, y + 18))这里的坐标是固定值,后续适配不同屏幕尺寸时可以把 1200×800 抽成常量,所有子坐标按比例缩放。字牌的渲染分支用了单独列表,因为字牌没有数字只有文字。
4.2 游戏主循环与事件处理
游戏主循环采用标准 Pygame 模式:处理事件、更新逻辑、绘制画面、控制帧率。玩家通过点击手牌出牌,AI 的回合在短延迟后自动执行 3.3 节的决策函数。
def main(): pygame.init() screen = pygame.display.set_mode((1200, 800)) clock = pygame.time.Clock() font = pygame.font.Font(None, 32) player_hand = [3, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] # 示例手牌 known_tiles = [0] * 34 for t in player_hand: known_tiles[t] += 1 current_turn = 'player' running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN and current_turn == 'player': # 检测点击了哪张手牌 x, y = event.pos if PLAYER_HAND_Y <= y <= PLAYER_HAND_Y + TILE_H: idx = (x - 20) // (TILE_W + 10) if 0 <= idx < len(player_hand): pass # 后续在这里加入出牌逻辑 if current_turn == 'ai': pygame.time.wait(800) # 简单延迟,模拟“思考” discard = choose_discard(player_hand, known_tiles, trials=300) player_hand.remove(discard) known_tiles[discard] += 1 current_turn = 'player' # 绘制代码省略:底图、手牌、牌河、按钮 pygame.display.flip() clock.tick(60) pygame.quit()玩家手牌的点击热区计算用的是(x - 20) // (TILE_W + 10),20 是左边距、10 是牌间距。这段代码把点击位置映射到手牌列表的下标。AI 决策之间加 800ms 延迟是为了让玩家看清 AI 打出了哪张牌,实际项目中这个延迟应该做成可配置项。
4.3 牌的动画反馈与状态提示
为了让图形化游戏有“活着”的感觉,需要加两个最小动画:选牌高亮和出牌滑动。选牌高亮的做法是在玩家点击某张牌后,给该牌画一个黄色边框;出牌滑动则是用插值计算牌的当前位置,让它从手牌位置移动到牌河位置。
class DiscardAnimation: """出牌动画:记录起始位置、目标位置和时间进度""" def __init__(self, tile_id, start_pos, end_pos, duration=300): self.tile_id = tile_id self.start = start_pos self.end = end_pos self.progress = 0.0 self.duration = duration self.start_time = pygame.time.get_ticks() def update(self): elapsed = pygame.time.get_ticks() - self.start_time self.progress = min(1.0, elapsed / self.duration) def current_pos(self): # 简单的线性插值;想更顺滑可以改用 ease-out 曲线 x = self.start[0] + (self.end[0] - self.start[0]) * self.progress y = self.start[1] + (self.end[1] - self.start[1]) * self.progress return (int(x), int(y))DiscardAnimation类把动画状态和绘制逻辑分离,主循环里每帧调用update()更新进度,然后通过current_pos()获取当前绘制位置。动画结束时从活动列表里移除对象。线性插值在视觉上略显生硬,ease-out 只需要把progress做一次1 - (1 - t)^2变换,成本忽略不计。
5. 蒙特卡洛 AI 的 3 个必调参数与实战调优
把界面和 AI 拼起来后,你会发现“能跑”和“会打”之间有明显的差距。差距主要来自三个参数:模拟次数、采样分布的偏差、评估函数的截断深度。
5.1 模拟次数:别盲目堆高,看决策稳定性曲线
模拟次数从 100 提升到 500,决策稳定性显著提升;从 500 到 2000,稳定性曲线趋于平缓。验证方法是固定一个手牌场景,重复运行choose_discard20 次,统计每次选择的牌是否一致。
def test_decision_stability(hand, known_tiles, trial_list=[100, 300, 500, 1000]): """对比不同模拟次数下的决策一致性""" for trials in trial_list: results = [] for _ in range(20): disc = choose_discard(hand, known_tiles, trials) results.append(disc) # 统计出现次数最多的牌及其占比 from collections import Counter counter = Counter(results) top_tile, top_count = counter.most_common(1)[0] stability = top_count / len(results) print(f'trials={trials}, 最常打出的牌={top_tile}, 占比={stability:.2%}')运行这个函数会看到 trials=100 时稳定性可能只有 60%,而 trials=500 时通常能达到 90% 以上。在决定全局 trials 值前,先把当前手牌场景跑一遍这个测试,找到拐点即可。实际游戏里,AI 决策时间应该控制在 1 秒内,如果 trials=1000 耗时超过 1 秒,优先用 5.2 的采样优化,而不是硬砍 trials。
5.2 采样分布:把“均匀随机”改成“扣牌加权”
均匀采样会让 AI 觉得所有未知牌的出现概率相同,但现实中,对手打出的牌会暴露他的牌型偏好。一个廉价的改进是:统计对手打过某个花色的次数,降低该花色的采样权重。
def sample_opponent_hand_weighted(remaining: list, discard_history: dict) -> list: """ 根据对手弃牌历史调整采样权重 discard_history: {'wan': 5, 'tiao': 2, 'bing': 3, 'zi': 1} """ weights = [] for tile in remaining: if tile < 9: suit = 'wan' elif tile < 18: suit = 'tiao' elif tile < 27: suit = 'bing' else: suit = 'zi' # 打过越多某花色,说明越不需要该花色,我们从剩余牌堆里抽给对手时降低权重 base = 1.0 penalty = 0.15 * discard_history.get(suit, 0) weights.append(max(0.1, base - penalty)) # 用 random.choices 按权重采样 chosen = [] indices = list(range(len(remaining))) pool = list(zip(remaining, weights)) # 简化:不放回采样可以用 random.choices 反复抽取后去重 for _ in range(13): selected = random.choices(pool, k=1)[0] chosen.append(selected[0]) pool.remove(selected) return chosen注意:不放回采样在这里不能直接用random.choices,因为它默认允许重复。上面的实现通过每次选完从pool里移除来模拟不放回。实际项目中还可以把惩罚系数 0.15 改成可配置项,不同风格的对手用不同系数,这就是“对手建模”的最简形式。
5.3 评估函数的向听截断:6 向听不是永远合适
evaluate_hand里截断距离设成 6,适用于开场阶段的决策。但进入牌局中期,AI 可能已经是 2 向听或 3 向听,这时候应该把截断值调小——因为后续摸牌的期望步数本身就很短,截断太大会浪费模拟次数在无意义的路径上。
def adaptive_distance_limit(hand: list) -> int: """根据手牌当前向听数动态调整模拟深度""" # 粗估向听数:计算手牌中孤张的数量(简化版) isolated = 0 for i in range(34): if hand.count(i) == 1: # 检查左右邻牌是否存在 left = i - 1 if i % 9 > 0 else None right = i + 1 if i % 9 < 8 else None if not ((left is not None and left in hand) or (right is not None and right in hand)): isolated += 1 distance = isolated return max(2, distance)这个简化估算基于“孤张越多离胡牌越远”的经验,精确计算向听数需要做广度优先搜索,但作为模拟截断够用了。max(2, distance)保证截断值至少为 2,避免距离设成 0 或 1 时评估结果过于敏感。把截断和模拟次数联动起来后,AI 在牌局前中期的决策速度差异会很明显:开头慢但判断准,后期快且果断。
6. 在图形化界面里观察 AI 的思考过程:调试面板与决策日志
把蒙特卡洛内部状态可视化,是判断 AI“有没有学歪”的最快方式。很多麻将 AI 项目跑起来后,你只看到 AI 打了一张牌,根本不知道它为什么打这张。给 Pygame 界面加一个侧边调试面板,实时显示每张候选牌的评估分数和为最优牌贡献的模拟次数。
class DebugPanel: """在画面右侧渲染 AI 决策过程中的统计数据""" def __init__(self, x=1000, y=100, width=180, height=600): self.rect = pygame.Rect(x, y, width, height) self.scores = [] # [(tile_name, score)] def update(self, scores): self.scores = scores def draw(self, screen, font): pygame.draw.rect(screen, (40, 40, 40), self.rect) y_offset = self.rect.y + 20 title = font.render('候选牌评分', True, (220, 220, 220)) screen.blit(title, (self.rect.x + 10, y_offset)) y_offset += 40 for tile_name, score in self.scores: # score 越小越好,按从低到高排序输出 text = font.render(f'{tile_name}: {score:.2f}', True, (200, 200, 200)) screen.blit(text, (self.rect.x + 15, y_offset)) y_offset += 25在choose_discard的循环里收集各候选牌的评估值,用一个回调函数或直接返回带分数的结果,然后把数据传给DebugPanel.update()。这样可以直观看到,AI 在某个局面下是否在“打 3 万”和“打 7 万”之间犹豫——犹豫越久,说明候选牌的价值越接近,这一步对调整采样权重很有启发。最后把每局的关键决策(手牌、放弃牌、评估分、模拟次数)写入 CSV 文件,用 pandas 做离线的参数对比分析,远比盯着屏幕猜效果要好。
本文还有配套的精品资源,点击获取