1. 项目概述:从零构建一个可玩的C++麻将游戏
做一个小游戏是很多C++学习者进阶路上的一个标志性项目。它不像“Hello World”那样简单,也不像大型引擎那样遥不可及,正好卡在能综合运用基础语法、数据结构、面向对象设计,但又不会让人望而却步的甜点区。而“中国麻将(人机模式)”这个选题,更是将这个甜点的风味提升了一个层次。它不仅仅是实现规则,更涉及到状态机管理、简单的AI决策逻辑、以及一个相对复杂的交互界面,可以说是一个微缩版的软件工程实践。
我选择用C++来实现,一方面是出于对这门语言性能和控制力的信任,另一方面也是想挑战一下自己,看看如何用相对底层的工具来构建一个规则繁复的游戏逻辑。整个过程下来,感触最深的有两点:一是“麻将规则”这个看似约定俗成的东西,用代码精确描述出来有多么繁琐;二是为一个看似简单的“人机”对手设计行为逻辑,其复杂度远超预期。这个项目绝不仅仅是“写个游戏玩玩”,它更像是一次对系统设计能力、边界情况处理能力和耐心的大考。如果你正想找一个项目来巩固C++、学习游戏逻辑架构,或者单纯想拥有一个自己写的、能跟电脑“搓两把”的麻将程序,那么跟着这篇记录走一遍,应该会很有收获。
2. 核心设计思路与架构拆解
在动手写第一行代码之前,我们必须把麻将这个游戏“拆解”成计算机能理解的一系列模块。直接面向136张牌和复杂的胡牌规则编程,肯定会陷入混乱。我的设计核心是“高内聚、低耦合”,将不同职责划分到独立的类中。
2.1 核心类设计与职责划分
整个项目我规划了五个核心类,它们构成了游戏的基础骨架:
Card(单张牌类):这是最基本的单元。它需要封装一张牌的所有信息:花色(万、条、筒、字牌)和点数(1-9, 东、南、西、北、中、发、白)。此外,为了后续比较和排序方便,我为其赋予了一个唯一的整数ID。例如,一万的ID是1,九万的ID是9,一条是10,以此类推。这样,判断两张牌是否相同、是否构成顺子,就变成了简单的整数比较和运算。Player(玩家基类):无论是真人玩家还是电脑AI,他们都是玩家,拥有共同的行为和状态。这个基类需要管理一个玩家的手牌(std::vector<Card>)、已碰/杠的牌组、以及游戏状态(是否听牌、是否胡牌等)。它提供一些基础接口,比如“摸牌”、“打牌”、“检查是否可以碰/杠/胡”。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::vector<Card>& 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[i+1] > 0, count[i+2] > 0,说明可以组成一个顺子。将这三个数量各减1,然后递归检查。同样,成功则返回true,否则回溯。
- 从最小的牌型ID开始遍历
- 递归终止条件:如果遍历完所有牌型ID,
count数组全部为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[i+1] > 0 && count[i+2] > 0) { --count[i]; --count[i+1]; --count[i+2]; if (canFormMeld(count)) return true; ++count[i]; ++count[i+1]; ++count[i+2]; // 回溯 } } // 如果当前牌无法被消耗,说明此路径失败 if (count[i] > 0) return false; } return true; // 所有牌都消耗完毕 } bool canWin(const std::vector<Card>& 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++语言标准:在项目属性中设置为
C++17或更高。这能让我们使用std::optional,std::variant等现代特性来更好地处理可能为空的值或多种类型的动作。 - 编码与调试:麻将游戏涉及大量状态,调试时查看
std::vector<Card>的内容很麻烦。我重写了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函数来控制光标位置,实现局部刷新,而不是每次都清屏重绘。将游戏区域划分为几个固定的“窗口”(如手牌区、牌河区、信息区),只更新需要变化的部分,大大改善了视觉体验。
- 解决:这是控制台程序的通病。我使用了Windows API的
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的“思考过程”,非常有趣。编程的乐趣,往往就藏在这些能让抽象逻辑变得可见的细节之中。