C++实现麻将游戏:从算法到AI的完整开发实践

发布时间:2026/7/26 19:50:12
C++实现麻将游戏:从算法到AI的完整开发实践 1. 项目概述从零构建一个可玩的C麻将游戏做一个小游戏是很多C学习者进阶路上的一个标志性项目。它不像“Hello World”那样简单也不像大型引擎那样遥不可及正好卡在能综合运用基础语法、数据结构、面向对象设计但又不会让人望而却步的甜点区。而“中国麻将人机模式”这个选题更是将这个甜点的风味提升了一个层次。它不仅仅是实现规则更涉及到状态机管理、简单的AI决策逻辑、以及一个相对复杂的交互界面可以说是一个微缩版的软件工程实践。我选择用C来实现一方面是出于对这门语言性能和控制力的信任另一方面也是想挑战一下自己看看如何用相对底层的工具来构建一个规则繁复的游戏逻辑。整个过程下来感触最深的有两点一是“麻将规则”这个看似约定俗成的东西用代码精确描述出来有多么繁琐二是为一个看似简单的“人机”对手设计行为逻辑其复杂度远超预期。这个项目绝不仅仅是“写个游戏玩玩”它更像是一次对系统设计能力、边界情况处理能力和耐心的大考。如果你正想找一个项目来巩固C、学习游戏逻辑架构或者单纯想拥有一个自己写的、能跟电脑“搓两把”的麻将程序那么跟着这篇记录走一遍应该会很有收获。2. 核心设计思路与架构拆解在动手写第一行代码之前我们必须把麻将这个游戏“拆解”成计算机能理解的一系列模块。直接面向136张牌和复杂的胡牌规则编程肯定会陷入混乱。我的设计核心是“高内聚、低耦合”将不同职责划分到独立的类中。2.1 核心类设计与职责划分整个项目我规划了五个核心类它们构成了游戏的基础骨架Card单张牌类这是最基本的单元。它需要封装一张牌的所有信息花色万、条、筒、字牌和点数1-9 东、南、西、北、中、发、白。此外为了后续比较和排序方便我为其赋予了一个唯一的整数ID。例如一万的ID是1九万的ID是9一条是10以此类推。这样判断两张牌是否相同、是否构成顺子就变成了简单的整数比较和运算。Player玩家基类无论是真人玩家还是电脑AI他们都是玩家拥有共同的行为和状态。这个基类需要管理一个玩家的手牌std::vectorCard、已碰/杠的牌组、以及游戏状态是否听牌、是否胡牌等。它提供一些基础接口比如“摸牌”、“打牌”、“检查是否可以碰/杠/胡”。HumanPlayer人类玩家类继承自Player。它的核心是重写“决策”方法。对于人类玩家决策意味着通过控制台或图形界面接收输入。我需要设计一套清晰的指令系统比如输入“打 5万”或“碰”让程序能正确解析玩家的意图并执行。AIPlayer电脑玩家类同样继承自Player这是“人机模式”的灵魂所在。它的决策方法内部是一套AI算法。我采用的是一种基于“向听数”的简单策略。AI会评估当前手牌距离胡牌还有多少步向听数然后尝试打出那张最不影响胡牌进度的牌或者在有碰、杠机会时根据简单的权重来决定是否行动。这部分逻辑可以做得非常复杂但作为初版一个“有点智能但又不至于不可战胜”的AI就足够了。Game游戏控制类这是整个游戏的大脑和调度中心。它负责初始化一副牌并洗牌、管理四个玩家实例可以是人类和AI的任意组合、控制游戏流程摸牌、出牌、判断吃碰杠胡、流局等、记录牌墙和骰子状态。Game类像一个导演按照麻将的规则剧本协调所有“演员”玩家和牌的行动。2.2 数据流与游戏状态机定义了类之后就要规划它们如何协作。麻将是一个典型的状态机驱动游戏。我将一局游戏简化为以下几个核心状态由Game类来维护和切换初始化洗牌、码牌、掷骰子、确定庄家、发牌。进行中这是一个循环直到有人胡牌或流局。子状态1当前玩家行动。摸牌然后进入“决策”状态。子状态2决策与响应。这是最复杂的部分。当前玩家打出一张牌后游戏进入一个短暂的“响应窗口”。其他三家按顺序逆时针检查自己是否可以“胡”、“杠”、“碰”这张牌。这里必须遵循麻将的优先级胡 杠 碰。一旦有人响应状态立即跳转并执行对应动作然后轮到响应者的下家继续游戏。如果无人响应则轮到下一位玩家行动。结束有人胡牌或牌被摸完流局。进行分数结算并准备下一局。这个状态机的清晰定义是避免代码逻辑混乱的关键。在实现时我使用一个枚举类enum class来定义这些状态并在Game的主循环里用switch-case或if-else进行分发处理。注意响应逻辑抢杠胡、杠上开花等是麻将中最易出错的边界情况。在设计状态机时必须为这些特殊情况预留处理路径。我的建议是初期可以简化比如不支持抢杠胡先让核心流程跑通之后再迭代增加这些复杂规则。3. 麻将规则的核心算法实现将设计落地为代码最大的挑战在于如何用算法精确描述麻将那套复杂的胡牌、听牌规则。这是项目的技术核心也是性能可能遇到的瓶颈点。3.1 胡牌判定算法递归与回溯中国麻将最常见的胡牌牌型是“4组面子 1对将牌”。面子可以是顺子如三四五万或刻子三个相同的牌。如何从14张手牌中找出这种组合暴力枚举所有分组方式显然不可行。我采用的是经典的回溯算法。算法的基本思路是尝试先抽出“将牌”一对相同的牌然后在剩下的12张牌中递归地尝试组成“顺子”或“刻子”。具体步骤如下我写了一个bool canWin(const std::vectorCard hand)函数基础检查如果手牌数量不是14张自摸胡或13张点炮胡前状态直接返回false。这里我们先讨论14张的情况。排序与去重将手牌按ID排序。统计每种牌的数量存储在一个大小为34对应34种不同的牌的数组count中。寻找“将牌”遍历count数组对于数量大于等于2的牌假设它作为“将牌”。将其数量减2然后对剩下的牌进行“组成面子”的测试。组成面子递归核心从最小的牌型ID开始遍历count。如果count[i] 0跳过。先尝试组成刻子如果count[i] 3说明可以组成一个刻子。将count[i]减3然后递归调用“组成面子”函数检查剩下的牌。如果递归成功返回true否则恢复count[i]回溯。再尝试组成顺子如果i对应的牌是万、条、筒即不是字牌且i 7保证有连续三张并且count[i] 0, count[i1] 0, count[i2] 0说明可以组成一个顺子。将这三个数量各减1然后递归检查。同样成功则返回true否则回溯。递归终止条件如果遍历完所有牌型IDcount数组全部为0说明所有牌都成功分组返回true。如果所有“将牌”的选择和面子组合尝试都失败则返回false。这个算法是胡牌判定的基石。它的时间复杂度在最坏情况下较高但由于麻将牌种类有限34种且实际手牌中牌型很集中性能在可接受范围内。// 伪代码示意 bool canFormMeld(int count[34]) { for (int i 0; i 34; i) { if (count[i] 0) continue; // 尝试刻子 if (count[i] 3) { count[i] - 3; if (canFormMeld(count)) return true; count[i] 3; // 回溯 } // 尝试顺子 (非字牌且不越界) if (i 27 i % 9 6) { // 万条筒且不是8、9 if (count[i] 0 count[i1] 0 count[i2] 0) { --count[i]; --count[i1]; --count[i2]; if (canFormMeld(count)) return true; count[i]; count[i1]; count[i2]; // 回溯 } } // 如果当前牌无法被消耗说明此路径失败 if (count[i] 0) return false; } return true; // 所有牌都消耗完毕 } bool canWin(const std::vectorCard hand) { if (hand.size() ! 14) return false; int count[34] {0}; for (const auto card : hand) count[card.id]; // 尝试每一种牌作为将牌 for (int i 0; i 34; i) { if (count[i] 2) { count[i] - 2; if (canFormMeld(count)) return true; count[i] 2; } } return false; }3.2 听牌检测与AI策略基础有了胡牌判定听牌检测就相对容易了。所谓听牌就是再摸进一张特定的牌就能胡牌的状态。算法很直接遍历所有34种牌依次虚拟添加到手牌中使手牌数变为14张然后调用canWin函数。如果胡牌那么这张被虚拟添加的牌就是所“听”的牌之一。这个功能对于AI决策至关重要。AI的核心目标就是减少“向听数”。向听数是指当前手牌至少还需要多少张有效牌才能听牌。计算向听数是一个更复杂的搜索问题一种简化的启发式方法是对于每一张可能打出的手牌计算打出去后手牌与所有可能胡牌牌型之间的“距离”选择使距离最小的那张打出。在我的实现中AI优先打出手牌中孤张的字牌或边张如一九牌然后根据听牌检测的结果保留那些能更快形成搭子的牌。3.3 碰、杠、吃的判定逻辑相比胡牌碰、杠、吃的判定在算法上简单很多主要是条件检查碰其他玩家打出一张牌X检查自己手牌中是否有至少两张相同的牌X。杠明杠其他玩家打出一张牌X自己手牌中有三张相同的牌X。暗杠自己摸到一张牌使得手牌中有四张相同的牌。加杠自己已经碰了一组牌又摸到相同的第四张。吃上家打出一张牌X检查自己手牌中是否能与X组成一个顺子仅限万、条、筒。例如上家打出发财则不能吃。这些判定在Player基类中实现为bool canPong(const Card),bool canKong(...),bool canChow(...)等方法。在游戏状态机的“响应窗口”Game类会依次调用其他玩家的这些方法来判断是否触发动作。实操心得在实现“吃”的逻辑时要特别注意牌的花色必须相同。一个常见的错误是只检查数字连续性忽略了“一万”和“二条”不能组成顺子。同时“吃”只能吃上家的牌这个顺序限制必须在Game类的逻辑里严格把控。4. 游戏流程的详细实现与代码组织有了核心算法接下来就是用Game类这根线把所有珠子串起来。我采用控制台交互的方式虽然简陋但能清晰地展现所有逻辑。4.1 初始化与主循环在Game::start()函数中初始化牌墙创建136张牌的数组每种牌4张使用std::shuffle进行随机洗牌。创建玩家根据设定实例化HumanPlayer和AIPlayer对象放入一个players数组中。确定庄家位置通常为0号玩家。发牌从牌墙按顺序给每位玩家发13张牌庄家多发一张先打牌共14张。进入主循环while (!gameOver) { Player currentPlayer players[currentPlayerIndex]; // 1. 当前玩家摸牌如果牌墙有牌 Card drawnCard drawFromWall(); currentPlayer.draw(drawnCard); // 2. 当前玩家决策AI自动人类等待输入 Action decision currentPlayer.makeDecision(); // 处理决策打牌、暗杠、自摸胡等 Card discardedCard processDecision(decision, currentPlayer); // 3. 其他玩家响应检查胡、杠、碰 bool actionTaken false; for (按顺序检查其他玩家) { if (otherPlayer.canWin(discardedCard)) { // 点炮胡 // 处理胡牌结束游戏 gameOver true; break; } if (!actionTaken otherPlayer.canKong(discardedCard)) { // 处理明杠 actionTaken true; break; // 杠牌后由杠牌者继续摸牌 } if (!actionTaken otherPlayer.canPong(discardedCard)) { // 处理碰牌 actionTaken true; break; // 碰牌后由碰牌者继续出牌 } // 吃只检查上家 if (!actionTaken otherPlayer.isPrevPlayer(currentPlayer) otherPlayer.canChow(discardedCard)) { // 处理吃牌 actionTaken true; break; } } // 4. 根据响应结果更新当前玩家索引和游戏状态 if (!gameOver) { updateCurrentPlayer(actionTaken, ...); } }结算游戏结束后根据胡牌方式自摸、点炮、番种计算分数更新玩家积分。4.2 人类玩家交互设计为了让控制台操作不那么反人类我设计了一套简单的命令语法d [牌]或discard [牌]打牌。例如d 5w表示打五万d zf表示打发财。p或pong碰牌。g或kong杠牌。c或chow吃牌需要后续输入组合如c 3w 4w表示用三四万吃二万或五万。h或win胡牌。pass过不进行任何操作。HumanPlayer::makeDecision()函数会阻塞等待用户输入解析这些命令并返回一个Action结构体给Game类处理。同时需要实时在控制台上绘制玩家的手牌、已碰杠的牌、以及牌墙剩余数量等信息。4.3 AI玩家决策逻辑实现AIPlayer::makeDecision()是体现“智能”的地方。我的AI策略分为几个优先级自摸胡摸牌后立即检查是否胡牌是则胡牌。暗杠/加杠检查手牌是否有暗杠机会或是否能加杠。杠牌可以增加番数且能多摸一张牌优先级较高。响应他人打出的牌在Game类调用canPong/Kong/Win时AI需要做出是否行动的决策。我设置了一些简单规则胡牌100%执行。杠牌如果杠后不破坏听牌或重要搭子且不是危险牌可能点炮则执行。碰牌如果碰后能减少向听数或能组成刻子增加番数则执行。吃牌通常AI会少吃牌因为吃牌会暴露信息且固定牌型。我只在吃牌能组成顺子且明显利于听牌时才执行。主动出牌当没有上述动作时AI需要打出一张牌。策略是计算当前手牌的“有效牌”和“孤张”。孤张且无用的字牌、边张一九优先打出。使用简单的“向听数估算”模拟打出每一张手牌然后用听牌检测算法计算剩余手牌的听牌数量听几张牌选择打出后听牌数量最多即胡牌可能性最广的那张牌。引入简单的“危险度”评估记录牌河中已经打出的牌如果某张牌的同线牌如四万则二三五六万一张未出则打出它的危险性可能较高AI会倾向于保留或稍后打出。这个AI策略虽然远不及职业水平但已经能做到基本的防守和进攻让游戏具备可玩性。5. 开发环境搭建、调试与性能优化5.1 环境配置与工具选择我使用的是Visual Studio 2022社区版进行开发。选择它的原因很简单对C标准支持好调试器强大项目管理方便。当然你也可以用VSCode CMake MinGW的组合这更轻量灵活。项目配置的关键点C语言标准在项目属性中设置为C17或更高。这能让我们使用std::optional,std::variant等现代特性来更好地处理可能为空的值或多种类型的动作。编码与调试麻将游戏涉及大量状态调试时查看std::vectorCard的内容很麻烦。我重写了Card类的操作符使其能输出如“1万”、“东风”这样的字符串并在VS的监视窗口中使用natvis自定义可视化工具让调试时能直接看到手牌的牌面而不是一堆数字ID效率提升巨大。// Card类的简单输出重载 std::ostream operator(std::ostream os, const Card card) { static const std::string suits 万条筒; static const std::string honors 东南西北中发白; if (card.id 27) { // 万条筒 os (card.id % 9 1) suits[card.id / 9]; } else { // 字牌 os honors[card.id - 27]; } return os; }5.2 常见问题与调试实录在开发过程中我踩过不少坑这里记录几个典型的问题胡牌判定偶尔错误特别是涉及多种胡牌可能时。排查在递归函数canFormMeld中加入详细的日志打印每次尝试的count数组状态。发现问题是“回溯”不彻底。当尝试用某张牌组成顺子失败后代码没有正确回溯到尝试刻子的分支直接返回了false。解决确保在每一条递归路径尝试刻子、尝试顺子返回后都立即恢复count数组的状态。上面的伪代码已经体现了正确的回溯逻辑。问题游戏流程混乱有时该AI响应时没响应有时又连续响应两次。排查问题出在游戏状态机的“响应窗口”循环。我的初始逻辑是每当一个玩家打牌后就遍历所有其他玩家检查响应。但我忽略了“一旦有人响应碰、杠就该立即中断循环并由响应者继续游戏”的规则。解决在响应循环中一旦有玩家执行了“碰”或“杠”动作立即设置一个标志actionTaken true并跳出循环。然后根据这个标志和响应者信息更新currentPlayerIndex。对于“胡牌”则直接结束游戏循环。问题AI打牌很“蠢”经常打出生张导致点炮。排查最初的AI只考虑优化自己的手牌向听数完全忽略了防守。我称之为“愣头青AI”。解决引入“牌河”记录。AI在决定打某张牌前会检查这张牌的同花色相邻牌在牌河中出现的数量。如果一张牌如四万打出后牌河中二三五六万一张未见那么这张四万就是“生张”点炮风险高。AI会给这张牌的打出的意愿度乘以一个小于1的危险系数从而倾向于先打熟张牌河中已经出现过的牌或绝对安全张牌河已见三张的牌。问题控制台刷新导致画面闪烁体验差。解决这是控制台程序的通病。我使用了Windows API的SetConsoleCursorPosition和GetStdHandle函数来控制光标位置实现局部刷新而不是每次都清屏重绘。将游戏区域划分为几个固定的“窗口”如手牌区、牌河区、信息区只更新需要变化的部分大大改善了视觉体验。5.3 性能考量与扩展思考对于136张牌的麻将目前的算法性能完全足够。但如果未来想支持更复杂的规则如国标麻将的81番种或者实现网络对战就需要考虑优化算法优化胡牌判定是性能热点。可以考虑使用查表法。预先计算出所有可能的胡牌牌型组合这是一个有限的、虽然很大的集合并将其哈希值存储在一个集合中。判定时只需计算当前手牌的哈希值检查是否在集合中。这需要精心设计哈希函数确保牌的顺序不影响结果。内存与对象管理大量创建和销毁Card对象可能带来开销。可以使用对象池Flyweight模式因为牌的种类只有34种每种牌有4张相同的实例完全可以复用。AI强化目前的AI基于简单规则。可以引入蒙特卡洛树搜索MCTS。AI可以模拟未来若干步的随机摸牌和打牌根据大量模拟的结果最终胡牌率、得分期望来选择当前最优的动作。这需要大量的计算但可以显著提升AI强度。架构扩展当前的Game类耦合了规则、流程和显示。为了支持图形界面如Qt、SFML或网络模块应该将核心游戏逻辑规则判定、状态管理抽象成一个独立的GameEngine库。界面层只负责输入输出和调用引擎接口。这样换用不同的UI框架会非常容易。6. 项目总结与未来可能的迭代方向回顾整个项目从设计到实现最大的挑战不是某一行代码而是如何将非形式化的、充满例外和约定的麻将规则转化为严谨、无二义性的逻辑代码。这个过程强迫我不断思考边界情况杠牌后从哪里摸牌抢杠胡是否成立流局怎么算每一次调试和修复这些边界情况都让我对麻将规则和状态机设计的理解更深一层。这个项目的价值远不止于一个可运行的游戏。它是一个完整的练习涵盖了面向对象设计类的职责划分、继承与多态Player基类。算法设计回溯算法在胡牌判定中的应用。状态机建模如何用代码描述复杂的游戏流程。基础数据结构std::vector,std::array的熟练使用。控制台交互简单的UI和输入处理。调试技巧如何利用工具深入复杂逻辑的内部。如果你也打算实现它我的建议是分阶段推进持续测试。先实现洗牌、发牌、打牌这个最小循环。然后加入碰、杠、吃的判定。最后再攻克胡牌算法和AI。每完成一个阶段就写一些简单的测试用例验证比如手动构造一个能胡的牌型看程序能否正确判定。这样能避免错误累积到最后无法收拾。这个项目还有很多可以打磨和扩展的地方图形界面用Qt或SFML替换控制台实现鼠标操作和更美观的牌面显示。规则完善加入更多番种计算平和、断幺九、清一色等甚至实现国标或日本麻将规则。网络对战将Game类改造成服务器玩家客户端通过网络连接实现真人联机。AI强化如前所述引入MCTS或简单的机器学习模型让AI更加拟人。复盘与回放记录每一局的出牌序列实现复盘功能用于分析牌局。最后分享一个在调试AI时发现的小技巧为了让AI的行为更易于观察我给它加了一个“日志模式”。在启动时设置一个标志AI每做一个决定打什么牌、碰不碰都会输出一行日志说明它考虑了哪些因素、最终为什么做出这个选择。这不仅是调试的利器也让我这个设计者能直观地理解AI的“思考过程”非常有趣。编程的乐趣往往就藏在这些能让抽象逻辑变得可见的细节之中。