接手这个题目的时候,我一开始以为就是随手写个发牌器,把52张牌洗一洗、分一分,然后比较一下大小就完事。真正动笔之后才发现,一个能“自动跑完整局”的打牌游戏模拟器,最大的工作量根本不在牌型判断,而在怎么把人的牌感翻译成代码逻辑,尤其是在AI决策这一层。
这篇内容我以“斗地主”为蓝本做一个对战模拟器,完整走一遍从数据结构设计、洗牌发牌、AI出牌策略,到整局跑通的实现路径。里面会贴关键代码,也会把我在实测过程中踩过的几个逻辑坑拿出来说清楚。项目使用C++控制台实现,不依赖任何第三方库,环境只需要一个支持C++11的编译器即可。
1. 只写规则判定是不够的:第一版设计走到了死胡同
先说个我自己的教训。刚拿到这个题目时,我想得特别简单:先定义牌型,写一个比较器,然后轮流摸牌、出牌,谁先出完谁赢。于是第一版代码里面全是isSingle()、isPair()、isStraight()这种判断函数,打出的牌型也能正确识别,程序也能跑,看起来像模像样。
但问题出现在“谁来出牌”这一环。模拟器里没有真人玩家,需要让电脑自己决定出什么。我当时写了一个最基础的逻辑:从手牌里找出所有能打赢上家的牌型,然后选最小的一个打出去。听起来没问题吧?实际跑起来满盘皆输。
为什么?因为这种贪心策略只考虑了“当前能不能压住”,完全没考虑“出完之后剩下的牌还能不能成结构”。举个例子,手牌里有3 4 5 6 7和一对8,上家出了单张4,按贪心逻辑会选择出5,这没错。但如果你手牌里有一个能直接走完的顺子,你把5拆出去之后,整个顺子就废了,后面几手就变得非常被动。
更要命的是,这种写法根本不知道怎么处理“要不要出炸弹”这个经典问题。炸弹是唯一的无脑压制手段,但它也只能用一次。是留到对方只剩一手牌时绝杀,还是在自己走投无路时提前引爆?这两个选择的胜率天差地别。单纯靠“能压就压”的规则,永远给不出合理的回答。
跑完第一版之后我彻底明白了:模拟打牌游戏的核心不是模拟牌的物理规则,而是模拟人的决策过程。决策逻辑没想清楚之前,所有关于发牌和牌型的代码都只是在造零件,拼不成一台能开的车。
2. 卡牌数据结构与发牌逻辑:从编码方式说起
2.1 用整数编码替代字符串
打牌游戏里最基础的问题,是怎么表示一张牌。直接存"红桃5"这种字符串当然可以,但后续各类牌型判断要频繁比较大小、计算连续关系,字符串做起来非常别扭。我最终选择了整数编码方案:
- 点数编码:
3到2依次映射为0到12,小王编码为13,大王编码为14。 - 花色单独用一个枚举表示,不影响点数比较。
这样设计之后,判断“顺子”就等价于检查点数序列是否连续递增,而且天然忽略了花色限制(斗地主中顺子不要求同花色),两行代码的事。
在C++里我这样定义基础结构:
enum Suit { HEART, DIAMOND, CLUB, SPADE, JOKER }; struct Card { int point; // 0~12 对应 3~2,13=小王,14=大王 Suit suit; bool operator<(const Card& other) const { if (point != other.point) return point < other.point; return suit < other.suit; } bool operator==(const Card& other) const { return point == other.point && suit == other.suit; } };没有直接编码成单个数,是为了在需要知道具体花色时(比如以后扩展“拖拉机”“同花顺”这类玩法)不会丢失信息。
2.2 Fisher-Yates洗牌与切牌逻辑
洗牌算法没悬念,直接用标准的Fisher-Yates,从后往前遍历,随机选一个位置交换。标准库里有shuffle可以直接用,但我在项目里自己写了一个,方便控制随机种子,做可复现的联调测试:
void shuffleDeck(std::vector<Card>& deck) { std::mt19937 rng(std::random_device{}()); for (int i = deck.size() - 1; i > 0; --i) { std::uniform_int_distribution<int> dist(0, i); std::swap(deck[i], deck[dist(rng)]); } }发牌的时候需要注意一个细节:斗地主里除了54张牌平均分给三个人之外,还有3张底牌要到最后才亮出来。我的做法是先分配前51张,每人17张,剩下3张留作底牌。这样发牌阶段的代码逻辑和玩家真实摸牌过程就能保持一致。
2.3 手牌的容器选择与排序策略
手牌用std::vector<Card>保存,每次摸牌和出牌后重新排序,始终保证手牌有序。排序会让后续的牌型识别轻松很多,尤其是检查顺子和连对,只需要邻位比较。
这里有一个常见的坑:有些人习惯每次出牌后只删掉指定的牌,不重新排序。如果手牌数量小可能没感觉,一旦进入残局阶段,手牌只剩五六张,有序和无序的处理逻辑完全是两个负责量级。我的建议是sortHand()函数在每次出牌后必须调用,不要省这一步。
3. 核心难点:AI出牌搜索与决策策略的分层设计
AI是整个模拟器的灵魂,也是消耗了我最多调试时间的模块。我最终把出牌决策拆成了三层:牌型识别层、候选生成层、策略打分层。三层各管各的,互不越界。
3.1 第一层:从手牌中拆解“天然结构”
AI每次行动前,先把手中牌拆解成一组“天然结构”:单牌、对子、三张、顺子、连对、飞机、炸弹。我采用的基本方法是“按点数分组计数”,把相同点数的牌归类,再根据数量决定它们可能组成的结构。
举个例子,手牌里有3 4 5 6 7 8、三张9、一对10。按点数分组后:
- 点数
3~8连续出现且各一张,构成顺子345678。 - 点数
9有三张,天然是三张。 - 点数
10有两张,天然是对子。
但这里有个微妙的地方:拆解结果不唯一。同一组牌可以拆成三张9带一对10(三带二),也可以拆成三张9和一对10两个独立结构留作单出。这两种拆法在后续决策上差异很大。我的处理方式是:把所有可行的拆解全部枚举出来,放入候选集合,交给策略层去选,而不是让识别层武断地决定唯一拆法。
3.2 第二层:候选生成,先管“要不要”再管“出什么”
队友已经出完牌、轮到我们跟牌时,优先找能直接压住上家牌型的最小组合;手里有东风可以带头出牌时,则从所有候选里挑一个“牌型价值最低”的打出去。
候选生成时我会先进行分类:
struct MoveCandidate { MoveType type; // kSingle / kPair / kTriple / kStraight / kBomb... int weight; // 牌型强度,比如炸弹的权重远高于单牌 std::vector<Card> cards; int remainingScore; // 打出之后手牌的评估分数(越小越好) };这里最花心思的是remainingScore的计算。我的评分规则是:打完这手牌之后,把剩余手牌重新做一次结构拆解,计算剩余结构中“单牌、散牌”的累计权重。单牌数量越少,后续越容易脱手,分数越低。这个评分并不要求绝对精确,它只是为了在多个可行出法中挑一个“看起来后劲更足”的方案。
坦白讲,这个评分函数我调了很久。最开始直接按“剩余牌数”打分,结果AI倾向把所有大结构一次性拆光,导致残局阶段全是散牌,越打越被动。后来调整为“先拆结构再算散牌权重”后,局势明显改善了,因为AI会倾向于保留完整顺子和炸弹,把单张小牌在前期甩掉。
3.3 第三层:炸弹与压牌时机
炸弹的处理单独拎出来说,因为它不属于常规牌型决策。我给AI的规则是:
- 上家出牌时,如果自己手牌处于绝对劣势(剩余牌数小于等于4张)且炸弹能直接终结比赛,果断炸。
- 只要手牌结构拆解完还有超过6张“难处理牌”(单牌、对子、三张的散组),且对方行牌张数已经很少(3张及以下),可以用炸弹抢回出牌权。
- 开局和中局阶段,除非对方已经打出“顺子”或“飞机”这种大题,否则轻易不出炸弹。
这个规则让AI的胜率从最初的不到两成,提升到了大约五成。当然,五成胜率在三人对战中实际上已经是很合理的水平了,毕竟再聪明的规则也打不赢完美的记牌和概率推断。
4. 牌型识别的那些边界:顺子、连对、飞机与四带二
如果说AI策略决定了模拟器“聪明不聪明”,那牌型识别就决定了模拟器“跑得动跑不动”。整个牌型识别模块必须处理所有可能出现的组合,否则一旦玩家打出冷门牌型,程序直接崩溃或者给出错误判定,整个项目就废了。
4.1 牌型识别的主流程
我实现了一个统一的识别函数,输入一组牌,输出它属于什么类型以及权重值:
MoveType identifyMove(const std::vector<Card>& cards) { if (cards.size() == 1) return MoveType::kSingle; if (cards.size() == 2) { return (cards[0].point == cards[1].point) ? MoveType::kPair : MoveType::kInvalid; } // 继续判断三张、三带一、三带二、顺子、连对、飞机、炸弹... return MoveType::kInvalid; }关键思路是先用size()排除大量不可能的情况,再根据点数计数结果进一步细分。比如,5张牌不可能是对子或连对,但可能是顺子、三带一、三带二或四带一。这个预过滤能帮你少写很多无用的判断分支。
4.2 顺子判定的重要前提:不能包含2和大小王
有一件事我在初版代码里写漏了,导致AI连续几局都在非法出牌。斗地主里顺子最小长度为5,最大长度为12(从3到A),但2和大小王绝对不能进入顺子。这不是规则手感问题,而是斗地主的基本牌理:2和大小王只能作为单牌或对子出现,它们不具备“顺子连续性”的资格。
所以判断顺子时,我会先做一次点数范围筛查,确保所有牌的点数都在[0, 11]区间内(对应3到A)。否则即便点数连续,也要判为非法。
4.3 飞机带翅膀的判定顺序
飞机(连三张)是所有牌型里最容易写错的。判定逻辑分为几个小步骤,每步都不能省:
- 按点数分组,找出所有数量为3的点数序列。
- 检查这些三张对应的点数是否连续。
- 如果是“飞机不带”,直接返回。
- 如果是“飞机带单”,检查剩余牌数是否等于三张组数。
- 如果是“飞机带对”,检查剩余牌是否都能组成对子。
这里最容易踩的坑,是把“连三张带两对”误判成“连三张带两张单牌”。举个例子,333 444 55 66是典型的飞机带对,但如果你在分组计数时没有把“对子”作为整体参与判断,很可能把55 66拆成四张单牌,导致判定失败。
4.4 四带二的处理细节
四带二分为“四带两张单牌”和“四带两对”,它们的张数不同(6张 vs 8张),识别时必须分开处理。我只在牌数正好为6或8时才会进入四带二的判断分支,再进一步检查是否有四张同点牌和两张剩余牌。这里的边界比较隐蔽,但逻辑本身不复杂,慢慢列条件即可。
5. 跑通整局之后,怎么设计胜负判定与残局模拟
全流程跑通之后,还有一个绕不开的问题:一局游戏怎么算结束?斗地主的结束条件有两个——地主先出完牌或者农民先出完牌。模拟器里的处理方式是每轮出牌后都检查三方的手牌,如果任意一家手牌清空,立即结束本局并结算胜负。
5.1 明牌与记牌信息的简化
真实玩家会记牌,电脑模拟器要不要?我的第一版没有引入记牌逻辑,AI只在自家手牌范围内决策。但跑了几百局之后发现,如果玩家打出剩余牌,AI连最基本的“这张牌还有几张在外面”都不清楚,很容易在结尾做出愚蠢决策。后来我加了一个remainingCount数组,维护每个点数在场上未出现的次数:
int remainingCount[15]; // 初始:3~10各4张,J/Q/K/A各4张,2有4张,小王1张,大王1张每次出牌时,从remainingCount中扣除对应牌数。AI决策前,先检查某个点数还剩多少张,再决定是否赌对子或顺子能顺出去。这不算高级AI,但确实让模拟器的胜负走势更贴近真实对局。
5.2 单局模拟器的主循环
整局模拟器主循环大概长这样:
while (true) { playerMove = aiDecide(currentPlayer, lastMove); applyMove(currentPlayer, playerMove); updateRemainingCount(playerMove.cards); if (isHandEmpty(currentPlayer)) { declareWinner(currentPlayer); break; } // 切换到下一家 if (playerMove.type != MoveType::kBomb) { currentPlayer = nextPlayer(currentPlayer); } }需要注意的细节是:如果上家出的是炸弹,出牌权会直接跨越下家吗?并不是。斗地主里炸弹只是“压制牌型”,不会跳过回合,它只是把你从“必须跟牌”的状态中解放出来。也就是说,炸弹打出后,原下家仍然有权选择是否再炸。主循环里不需要为炸弹写任何特殊的“跳过下一家”逻辑,只要将它视作“当前玩家的行动结束,轮到下一家跟牌”即可。
5.3 残局阶段的特殊处理
残局(三方各剩不超过6张牌)与中局逻辑完全不同。AI在残局阶段需要优先寻找“一次清空”的选项。我加了一个canFinish检查:当剩余手牌本身就是1~2手牌型时,优先打出整手牌,而不是沿用中局策略拆解出牌。这一步是提高AI残局胜率的核心优化,效果非常明显。实测中,大量对局是在残局阶段分出胜负的,如果AI没有这个特殊分支,它在中局拆散的结构会在最后几手卡死自己。
6. 实测过程中踩过的三个坑:从死循环到错误压牌
6.1 坑一:死循环,场上两人反复出同一手牌
这是最让我崩溃的一个bug。某局模拟中,AI-A和AI-B互相跟牌,但双方永远只出最小的单牌,谁也没有能力改变牌型,导致循环了3000多次都结束不了。
问题根源在“无牌可压时跳过操作”的处理。我当时设计的是:如果一个玩家跟上家牌型完全相同(比如同样是单张5),也会被算作“跟牌成功”。但我忘了斗地主里的一个铁律:跟牌必须严格大于上家牌。单张5作为上家,你出单张5是无效的,必须出单张6以上。
修复方式很简单,在canBeat函数里加一行判断:当前牌型必须严格大于上家的牌型权重,等于也不行。
6.2 坑二:拆炸弹让AI进入了无法脱手的局面
AI手上有炸弹,中局阶段为了压制对方的一个三带一,直接把炸弹拆成了三张带一张打出去。结果后面对方打出同花顺时,自己完全没有还手之力。
这个问题的本质是“拆炸弹的机会成本”没被评分函数纳进去。后来我在打分时给炸弹设置了极高的“保留权重”,只有当剩余手牌数低于等于4张时,才允许拆炸弹。代码里加了这个条件之后,AI拆炸的次数大幅减少,胜率也上去了。
6.3 坑三:地主与农民的配合逻辑缺失
最初我把三个AI都写成各自为战,结果造成了一个很讽刺的现象:两个农民AI经常互相压牌,地主持观望态度,反而坐收渔利。后来我引入了一个简单的“阵营策略”:
- 如果当前是农民的回合,而且上家也是农民阵营,那么尽量不出牌(除非手牌能一次走光)。
- 如果当前是地主的回合,则所有农民都必须尽量压制地主。
这套规则非常粗糙,但它直观地反映了一个基本牌理:农民的目标是“斗地主”,不是“斗队友”。加上这个规则后,农民阵营的胜率从30%左右提升到了40%以上。
7. 后续可以继续扩展的方向
模拟器目前跑通了三人AI完整对局,发牌、跟牌、胜负结算都能自动完成。如果后续想继续增强,我个人认为有三个方向最有价值:
- 引入更精细的记牌与概率推演,例如根据剩余牌张数推算对手手牌上限,从而决定是否冒险拆牌。
- 支持人为干预——提供“下一步模拟”和“手动出牌”模式,这样你能盯着AI的每一步决策,观察它的打分方向是否符合预期。
- 做一个简易的复盘日志,把每局的关键决策点输出成文本,方便做策略调试。
从我自己的经验看,这个项目最大的收获不是“把斗地主跑通了”,而是彻底理解了规则系统和搜索决策系统的分工边界。规则系统负责严谨,决策系统负责有弹性,两者配合得好,模拟器才算真正有了“牌感”。
最后分享一个实操中的小技巧:调试AI出牌逻辑时,不要一上来就跑完整局,那样出问题很难定位。我习惯先写一个“牌局残局测试器”,把已知手牌和场上局面作为输入,输出AI的每一步选择。这样排查逻辑问题,比看完整日志快得多。