1. 项目概述:从零构建一个带核心算法的麻将游戏
最近几年,身边不少朋友和同事都开始琢磨自己动手做点小游戏,一来是兴趣使然,二来也是想验证一下自己的技术栈。其中,麻将游戏因为规则复杂、逻辑性强,成了很多开发者跃跃欲试的“硬骨头”。我自己也花了几个月时间,从零开始完整地实现了一套麻将游戏软件,不仅包含了完整的客户端界面和网络对战功能,更重要的是,我把整个游戏最核心、最复杂的算法部分——包括胡牌判定、听牌计算、AI出牌逻辑——都独立封装成了一个可复用的算法库。
这个项目远不止是画个界面、发发牌那么简单。它真正的挑战在于,如何用代码精准地模拟人类打麻将时的复杂决策过程,尤其是那个让无数新手头疼的“胡牌算法”。市面上很多开源或简单的麻将游戏,其胡牌判定可能只支持最基本的牌型,或者逻辑有漏洞。而一个健壮的核心算法,需要能处理所有通用规则下的特殊情况,比如七对、十三幺、清一色等,还要能高效计算当前手牌的听牌状态(听哪几张牌能胡)。这背后涉及大量的状态枚举、优化剪枝和逻辑判断。
如果你也对游戏开发、算法设计,或者单纯对麻将的逻辑实现感兴趣,那么这篇文章会非常对你胃口。我会抛开那些花哨的UI框架,聚焦于最本质的“算法内核”,带你一步步拆解我是如何设计并实现这套麻将核心算法的。无论你是想自己写一个麻将游戏,还是单纯想学习如何处理这类复杂的规则型游戏逻辑,相信都能从中获得可以直接“抄作业”的干货。
2. 核心算法模块设计与思路拆解
一个麻将游戏的核心算法,可以看作是一个独立的“规则引擎”。它的输入是牌局状态(包括玩家手牌、已出牌、杠碰状态等),输出则是各种判定结果(能否胡牌、听什么牌、推荐出哪张牌等)。在设计之初,我就明确要将这个引擎与具体的界面、网络通信彻底解耦,使其成为一个纯逻辑的、无状态的算法库。
2.1 核心算法模块的职责划分
我将核心算法划分为四个相对独立又相互关联的模块:
牌型与状态表示模块:这是所有算法的基础。如何用代码高效地表示一张麻将牌、一手牌以及整个牌局?我放弃了直接用字符串如“一万”、“东风”来表示,而是采用了“枚举值+位运算”的方案。每张牌被赋予一个唯一的整数ID,这个ID同时编码了它的花色(万、条、筒、字)和点数。一手牌则用一个长度为牌型总数(如34种)的整数数组来表示,数组下标对应牌ID,值表示该牌拥有的张数。这种表示法在后续进行顺子、刻子查找以及计算牌型组合时,效率远高于操作字符串列表。
胡牌判定模块:这是算法的“心脏”。它的任务是,给定一手牌(通常14张),判断其是否符合胡牌牌型。经典的判定算法是“回溯法”:尝试将手牌分解为若干个“面子”(顺子或刻子)加上一对“将”(对子)。我在此基础上进行了大量优化,比如优先处理字牌(因为它们不能组成顺子),以及利用牌的张数信息进行快速剪枝,避免无谓的回溯,将判定时间复杂度控制在极低的水平,即使是在AI需要每秒计算成千上万次胡牌可能性的场景下也游刃有余。
听牌计算模块:这是胡牌判定的“逆向工程”。给定一手牌(通常13张),计算出所有打出一张牌后,能够等待哪些牌来达成胡牌状态。实现上,我会遍历所有可能打出的牌,然后遍历所有可能摸进的牌(共34种),对每一种“打出-摸进”的组合调用胡牌判定。这里的关键优化是“对称性剪枝”和“记忆化搜索”,避免重复计算完全相同的牌型状态,使得听牌计算能在毫秒级内完成。
AI决策模块:基于以上模块构建的上层应用。一个简单的AI可以随机出牌,但一个有点水平的AI需要模拟简单的决策树。我的AI核心是一个“风险-收益”评估模型。对于每一张可打出的手牌,AI会评估:打出这张牌后,自己手牌的“向听数”(距离听牌还差几步)减少了多少(收益)?同时,这张牌是否为“危险牌”(即很可能点炮)?评估危险牌时,AI会结合已打出的牌和推测的他人手牌(非常简化的模型)来估算点炮概率。最终,AI会选择“收益/风险比”最高的那张牌打出。
2.2 为什么选择自研算法而非使用现有库?
在项目初期,我确实调研过一些开源的麻将算法库。但发现它们要么耦合了特定的游戏框架,难以剥离;要么只实现了基础规则,对国标、日本麻将等变种的扩展性不好;要么代码晦涩,难以调试和定制。自研算法虽然前期投入大,但带来了几个决定性的优势:
- 绝对的控制力:任何规则调整、性能优化、Bug修复,我都能第一时间完成,不依赖第三方。
- 深度理解:通过亲手实现,我对麻将算法的每一个细节都了如指掌,这在调试和设计AI时是无价之宝。
- 可定制性:我可以轻松地为算法库添加新的牌型(如地方麻将的“飘胡”)、新的判定规则,或者集成更复杂的AI策略。
3. 核心细节解析与实操要点
3.1 胡牌判定算法的深度优化
基础的“回溯+面子分解”算法伪代码如下,但直接实现效率很低:
function 是否可以胡牌(手牌数组) { if (手牌总数 != 14) return false; return 回溯尝试分解(手牌数组, 是否需要将牌); }优化点一:字牌优先处理。字牌(东南西北中发白)只能组成刻子,不能组成顺子。因此,在开始复杂的回溯前,先单独检查字牌。如果某种字牌只有1张或2张,且无法通过后续的“杠”或“补花”解决,那么这手牌绝对不可能胡,可以立即返回false。这能提前过滤掉大量无效情况。
优化点二:利用“向听数”快速剪枝。“向听数”是一个衡量手牌距离听牌还有多远的指标。在回溯分解过程中,我们可以实时计算当前部分分解状态下的最小可能向听数。如果发现即使做最理想的后续分解,向听数也大于0(即无法胡牌),就可以立即终止当前分支的回溯。这需要预先设计一个高效的向听数估算函数。
优化点三:状态记忆化(Memoization)。在回溯过程中,不同的分解路径可能会遇到相同的“手牌状态”(指忽略牌顺序的牌型组合)。我们可以用一个哈希表(或字典)来记录已经计算过的状态及其结果(能否胡牌)。当再次遇到相同状态时,直接查表返回结果,避免重复计算。这对于听牌计算和AI模拟中大量重复的判定调用,性能提升是数量级的。
注意:实现记忆化时,关键是如何生成状态的唯一键。我采用的方法是将手牌数组(34个整数)转换成一个长字符串或一个64位整数(通过每位表示某种牌的张数),确保相同牌型必然产生相同的键。
3.2 听牌计算中的效率陷阱与解决
听牌计算最朴素的实现是双重循环:外层循环尝试打出手牌中的每一张牌(假设有13张),内层循环尝试摸进剩余的每一种牌(最多34种),然后调用胡牌判定。13 * 34 = 442次判定。看起来不多,但在AI实时决策或需要同时计算多个玩家听牌状态时,这个计算量会成倍增长。
我的解决方案是“增量计算”。核心思想是:很多不同的“打出一张牌A”的状态,其实共享大量相同的子状态。具体做法是:
- 首先,计算当前13张手牌的“基础牌型”。
- 当计算打出某张牌A后的听牌时,我不再从头开始双重循环,而是基于“基础牌型”快速生成“打出A后的牌型”,然后在这个新牌型上,只遍历可能摸进的牌。
- 更进一步的优化是,我发现如果两张牌是“同花色且相邻点数”,那么打出它们后的牌型,在计算听牌时有很多重叠部分。我可以预先计算并缓存这些关联关系。
通过这套组合优化,我将单次听牌计算的平均耗时降低了70%以上。
3.3 AI决策模型的设计心得
让AI像人一样打牌是终极目标,但作为第一阶段,我设计了一个实用且有效的“基于牌效的防守型AI”。它的决策流程如下:
- 牌效评估:对于每一张手牌,计算其“孤立度”和“有效进张数”。例如,一张孤立的“五万”可能不如一张与“四万”、“六万”组成搭子的“五万”有价值,因为后者更容易形成顺子。我会给每张牌一个基础分数。
- 危险度评估:这是一个简化的模型。AI会记录所有玩家打出的牌。如果某张牌(特别是字牌和幺九牌)从未被打出过,且已到牌局中后期,则其危险度增加。同时,如果某张牌是“现物”(即同一张牌其他玩家刚打过),则其危险度极低(根据规则,通常不能胡“现物”)。
- 综合决策:AI有一个目标函数:
得分 = 牌效提升分数 - 危险度系数 * 点炮预估损失。它会选择使这个得分最高的牌打出。其中,“点炮预估损失”是一个根据牌局阶段动态调整的权重,早期权重低(鼓励进攻),后期权重高(鼓励防守)。
这个模型虽然远不及职业选手,但已经能打出有模有样的牌局,不会犯“随便打生张点炮”的低级错误,并且会尝试向听牌方向改进手牌。
4. 实操过程与核心环节实现
4.1 数据结构定义与牌型初始化
一切从定义一张牌开始。我选择用8位字节(byte)来编码一张牌,高4位表示花色,低4位表示点数。
class MahjongTile: SUIT_WAN = 1 # 万 SUIT_TIAO = 2 # 条 SUIT_TONG = 3 # 筒 SUIT_ZI = 4 # 字 def __init__(self, suit: int, value: int): self.suit = suit self.value = value # 对于字牌,1-7分别代表东南西北中发白 self.id = (suit << 4) | value # 生成唯一ID def __str__(self): # 转换为“一万”、“东风”这样的字符串,方便调试 suits = {1: '万', 2: '条', 3: '筒', 4: '字'} values = ['', '一','二','三','四','五','六','七','八','九'] zi_values = ['', '东','南','西','北','中','发','白'] if self.suit == self.SUIT_ZI: return zi_values[self.value] else: return values[self.value] + suits[self.suit]一手牌,我用一个TileCounter类来管理,它内部就是一个长度为34的数组。
class TileCounter: TOTAL_TILE_TYPES = 34 # 万条筒各9种,字牌7种 def __init__(self): self.counts = [0] * self.TOTAL_TILE_TYPES def add_tile(self, tile_id: int): self.counts[tile_id] += 1 def remove_tile(self, tile_id: int): if self.counts[tile_id] > 0: self.counts[tile_id] -= 1 return True return False def to_vector(self): """转换为可用于哈希和比较的元组""" return tuple(self.counts)4.2 胡牌判定算法的代码实现
以下是经过优化的胡牌判定核心函数。它先处理特殊牌型(如七对、十三幺),再处理通用牌型。
class MahjongChecker: def __init__(self): self.memo = {} # 用于记忆化搜索 def is_winning_hand(self, counter: TileCounter) -> bool: """判断牌组是否构成和牌形""" total_tiles = sum(counter.counts) if total_tiles != 14: return False # 1. 检查特殊牌型(如七对、国士无双) if self._is_seven_pairs(counter): return True if self._is_thirteen_orphans(counter): return True # 2. 通用牌型检查:4个面子+1个将 return self._check_standard_win(counter) def _check_standard_win(self, counter: TileCounter, has_pair=False) -> bool: """回溯检查标准牌型(递归实现)""" state_key = counter.to_vector() if state_key in self.memo: return self.memo[state_key] # 递归基:所有牌用完,且已有将牌 if sum(counter.counts) == 0: result = has_pair self.memo[state_key] = result return result # 优化:优先尝试抽取刻子(三张相同) for tile_id in range(counter.TOTAL_TILE_TYPES): if counter.counts[tile_id] >= 3: # 尝试移除一个刻子 counter.counts[tile_id] -= 3 if self._check_standard_win(counter, has_pair): counter.counts[tile_id] += 3 # 回溯 self.memo[state_key] = True return True counter.counts[tile_id] += 3 # 回溯 # 尝试抽取顺子(仅限万、条、筒,且点数连续) for suit in [MahjongTile.SUIT_WAN, MahjongTile.SUIT_TIAO, MahjongTile.SUIT_TONG]: for start_value in range(1, 8): # 顺子起始点数最大为7 tile_id1 = MahjongTile(suit, start_value).id tile_id2 = MahjongTile(suit, start_value + 1).id tile_id3 = MahjongTile(suit, start_value + 2).id if (counter.counts[tile_id1] > 0 and counter.counts[tile_id2] > 0 and counter.counts[tile_id3] > 0): # 尝试移除一个顺子 counter.counts[tile_id1] -= 1 counter.counts[tile_id2] -= 1 counter.counts[tile_id3] -= 1 if self._check_standard_win(counter, has_pair): # 回溯 counter.counts[tile_id1] += 1 counter.counts[tile_id2] += 1 counter.counts[tile_id3] += 1 self.memo[state_key] = True return True # 回溯 counter.counts[tile_id1] += 1 counter.counts[tile_id2] += 1 counter.counts[tile_id3] += 1 # 尝试抽取将牌(对子),但只能抽一次 if not has_pair: for tile_id in range(counter.TOTAL_TILE_TYPES): if counter.counts[tile_id] >= 2: counter.counts[tile_id] -= 2 if self._check_standard_win(counter, has_pair=True): counter.counts[tile_id] += 2 self.memo[state_key] = True return True counter.counts[tile_id] += 2 self.memo[state_key] = False return False def _is_seven_pairs(self, counter: TileCounter) -> bool: """判断是否为七对""" pair_count = 0 for count in counter.counts: if count == 2: pair_count += 1 elif count != 0: return False # 有不是0或2的张数,肯定不是七对 return pair_count == 7 def _is_thirteen_orphans(self, counter: TileCounter) -> bool: """判断是否为十三幺(国士无双)""" # 定义13种幺九牌和字牌的ID orphan_ids = [ ... ] # 具体ID列表省略 has_pair = False for tile_id in orphan_ids: c = counter.counts[tile_id] if c == 0: return False # 缺任何一种都不行 elif c == 2: if has_pair: return False # 只能有一对 has_pair = True elif c > 2: return False # 检查总牌数:13种幺九牌各一张,其中一种有两张,共14张 return has_pair and sum(counter.counts) == 144.3 听牌计算功能的实现
听牌计算函数会返回一个列表,列出所有“打出一张牌后,能胡哪些牌”的组合。
def calculate_ready_hand(self, counter: TileCounter): """计算当前手牌(通常13张)的听牌状态。 返回一个列表,每个元素为 (打出的牌ID, [能胡的牌ID列表])""" ready_hand_info = [] total_tiles = sum(counter.counts) if total_tiles != 13: # 也可能是杠后或者别的状态,这里简化处理为13张标准听牌 return ready_hand_info # 遍历每一张可能打出的牌 for discard_id in range(self.TOTAL_TILE_TYPES): if counter.counts[discard_id] == 0: continue # 没有这张牌,无法打出 # 模拟打出这张牌 counter.counts[discard_id] -= 1 winning_tiles = [] # 遍历所有可能摸进的牌 for draw_id in range(self.TOTAL_TILE_TYPES): # 如果牌池里这种牌已经没了(假设4张为上限),则不能摸进 # 这里需要接入游戏的总牌墙状态,为简化,我们假设任何牌都至少还能摸一张 if self._is_tile_available(draw_id): # 需要实现这个方法 counter.counts[draw_id] += 1 if self.is_winning_hand(counter): winning_tiles.append(draw_id) counter.counts[draw_id] -= 1 # 回溯 if winning_tiles: # 如果打出这张牌后,能听牌 ready_hand_info.append((discard_id, winning_tiles)) # 恢复状态,准备计算下一张打出牌 counter.counts[discard_id] += 1 return ready_hand_info5. 常见问题与排查技巧实录
在开发和调试这套算法的过程中,我踩过不少坑,也总结出一些排查问题的有效方法。
5.1 胡牌判定结果异常
问题现象:明明应该是胡牌的牌型,算法返回false;或者不应该胡的牌,算法却判定为胡牌。
排查思路:
- 单元测试是生命线:必须建立庞大的测试用例库。包括所有基本胡牌牌型(平胡、碰碰胡、清一色等)、所有特殊牌型(七对、十三幺、全不靠等),以及大量边缘案例(如含有杠的牌型、多种解法的牌型)。我最初就是靠一个包含上百个测试用例的脚本,才揪出了回溯算法中一个关于字牌处理的逻辑错误。
- 可视化调试:不要只盯着数组看。写一个辅助函数,将
TileCounter对象以人类可读的方式(如“一万2, 二万1, 三万1, 东风3...”)打印出来。对比输入的手牌和算法中间步骤分解出的“面子”和“将”,能直观地发现逻辑错误。 - 检查特殊牌型优先级:我的算法中,先检查特殊牌型(七对、十三幺),再检查通用牌型。要确保这个顺序是合理的,并且特殊牌型的判定条件绝对准确。例如,七对必须恰好是7个对子,不能有碰或杠。
5.2 听牌计算效率低下或结果不全
问题现象:计算听牌时程序卡顿,或者计算出的听牌选项明显遗漏。
排查技巧:
- 性能分析:使用性能分析工具(如Python的
cProfile)找到耗时最长的函数。往往是is_winning_hand被调用了太多次。这时就要检查记忆化缓存memo是否正常工作,命中率如何。我遇到过因为状态键生成函数有Bug,导致缓存几乎永不命中,性能直接回到解放前。 - 验证听牌结果:对于一手牌,手动推理出听牌结果,然后与程序计算结果对比。如果遗漏,问题可能出在:
_is_tile_available函数逻辑错误,错误地排除了某些可摸的牌。- 听牌计算循环中,
discard_id和draw_id的遍历范围不正确。 - 手牌初始状态不对,比如传入的牌不是13张。
- 边界条件检查:特别注意“九莲宝灯”这种特殊听牌(听同花色的所有九张牌)。用这种极端牌型测试,能很好地检验算法完备性。
5.3 AI表现愚蠢,常打危险牌或错过听牌
问题现象:AI在牌局后期打出明显的生张,导致点炮;或者手牌明明已经听牌,AI却还在换牌。
解决与优化:
- 强化危险度模型:最初的危险度模型只考虑了“是否现物”和“是否字牌”。后来我加入了“牌河分析”,即统计每种牌被打出的总张数。如果一种牌一张都没出现过(生张),且已进入中后期,其危险度会呈指数级上升。还可以引入“筋牌理论”(例如,如果4万和6万都被打光了,那么5万是相对安全的),但这需要更复杂的模型。
- 引入“听牌感知”:在AI决策循环的最开始,先调用
calculate_ready_hand。如果发现当前手牌已经听牌,则立刻进入“听牌后模式”,此时出牌策略应转变为:优先打绝对安全牌(现物),没有安全牌则打危险度最低的牌,坚决不拆听口。我最初的AI就是因为缺少这个检查,在听牌后还会去优化牌型,结果打了不该打的牌。 - 调整牌效评估参数:牌效评估中的“有效进张数”计算需要精细调整。例如,边张(如一万、九万)和中张(如五万)的价值是不同的;孤立的字牌和成对的字牌价值也天差地别。我通过让AI自我对弈数千局,观察不同参数下的胜率,来微调这些评估权重。
5.4 算法与游戏状态同步问题
问题现象:算法库计算出的结果,与游戏实际状态不符。例如,算法认为可以胡牌,但游戏规则里因为“没下叫”或“过水”等原因不能胡。
核心原则:算法库只提供纯粹的、与规则无关的逻辑计算能力。胡牌判定算法只回答“这组牌是否符合麻将牌型组合规则”。至于“此刻能否胡这张牌”,这是一个游戏规则问题,它依赖于更多上下文:是否报听过?是否过水?是否满足起胡番数?这些规则判断应该放在游戏逻辑层(服务端),算法库只作为一个工具被调用。
我的做法是,算法库提供一系列原子API:can_win(hand_tiles, new_tile)、get_ready_tiles(hand_tiles)等。游戏逻辑层负责收集所有状态(玩家手牌、刚摸的牌、杠碰状态、规则配置),然后调用合适的API,并结合规则进行最终裁决。这样保持了算法库的纯净和可复用性。