简介:这是基于C++编写的麻将游戏项目,开发环境为Visual C++,采用MFC构建图形界面,代码与素材一起封装在RAR包中,适合C++初学者、游戏开发爱好者以及想研究经典棋牌逻辑的开发者。压缩包共135个文件,主体源代码为9个cpp与10个头文件,涵盖玩家操作、胡牌判断、电脑AI等核心模块;89个wav音频和bmp、ico、cur等图片资源提供背景、牌面、骰子、窗口图标与音效支持;dsw、dsp等工程文件便于在Visual Studio中恢复项目配置,另有麻将介绍.txt辅助说明玩法,同时包含rc/rc2资源脚本和plg日志等辅助文件,结构清晰。包体仅3.86MB,轻巧完整。通过学习这份代码,可以掌握MFC窗口程序的搭建方法、麻将洗牌与胡牌算法、多模块协作开发,以及将资源文件与程序一同打包发布的思路;对于学习C++桌面应用与Visual Studio项目管理也有参考价值。目前已有293人浏览学习,运行exe即可直观体验,适合对照源码逐模块拆解。
1. 这个 C++ 麻将游戏包是什么:解压前先看清门道
在源码站搜“mahjong”或“麻将”,大概率会看到一个叫 CPP-Mahjong-game.rar 的包,文件名后面还跟着mahjong和 _麻将 两个标签。这类包流传很广,解压出来通常是十几个 .cpp 和 .h 文件,在命令行里就能跑起一局完整的麻将:发牌、理牌、出牌、吃碰杠、判胡都有。对多数人来说,这个包最值钱的是牌型数据结构和判胡算法那几十行代码,可以拆出来用到自己的项目里。它适合三类人:学过 C++ 但没独立做过项目的新手、需要课程设计源码底子的人,以及想研究判胡和听牌逻辑的工程师。动手之前先确认一件事:这个 rar 能不能正常解开、代码能不能编译。这篇笔记就按这个顺序往下走。
2. rar 解压与文件校验:拿到源码包先做的 3 个动作
源码站上被反复转载的 rar 包,质量参差不齐。命名里带_mahjong_、_麻将这种标签,说明它是被某个目录收录后重新打包的——经过二手转存,压缩包本身损坏、被塞进无关文件甚至加密,都是家常便饭。所以拿到手的第一件事不是解压,而是先做三件看起来有点多余、实际上省时间的事:看元信息、找密码、管编码。
2.1 解压前先看元信息:文件清单、加密标记与顶层目录
我一般先用unrar或7z列一下压缩包内容,不解压,只读清单。这个动作能暴露大多数问题:里面到底是源码还是编译好的 exe、是不是分卷包、有没有加密。
# 列出压缩包内文件清单(不解压) unrar l CPP-Mahjong-game.rar # 用 7z 同样能做到,输出格式更规整 7z l CPP-Mahjong-game.rarl参数是 list 的意思,只读取压缩包的中央目录,不会往磁盘写任何文件,速度很快。看输出时重点盯三点。第一,文件名后缀:有没有.cpp、.h、.c、.hpp,如果列表里全是.jpg和.exe,那这个包大概率不是源码包,是别人打包好的现成程序,跟你的学习目标对不上。第二,有没有顶层目录:正常源码包会有一个根文件夹,比如CPP-Mahjong-game/,所有文件在它下面;如果一堆.cpp直接散落在压缩包根目录,后面编译时文件管理会麻烦一点。第三,加密标记:unrar l输出里文件名右侧会出现星号或[encrypted]字样,7z l会在类型列显示加密属性,这个信息决定你要不要接着找密码。
看完清单再决定是否解压。列表里出现分卷编号.r00、.r01,说明这只是一个分卷的其中一段,单独解压必然报错,得把同目录下所有分卷下载齐全放在一起再解。
2.2 “rar密码移除”工具别碰:先找来源与注释
不少资源站为了引流,打包时会设置密码。下载页面通常会写“解压密码:xxx”,有时候也会把密码写进压缩包注释。如果你第一反应是去搜“rar密码移除”,这里先泼盆冷水:rar 加密用的是 AES-128 或 AES-256,正经密码移除工具是不存在的,市面上打着这个旗号的东西大部分是广告捆绑和假破解,装完不仅解不开包,还会给电脑塞进一堆推广件,属于典型的翻车路径。
正确的顺序是:先回下载页找说明文字,再解压工具里看注释。Windows 下用 WinRAR 打开压缩包,右侧信息面板有“注释”区域,很多站会把密码放在那里。命令行下可以这样试:
# 详细模式列出压缩包信息,部分版本会输出注释块 unrar v CPP-Mahjong-game.rar如果注释里明确写了密码,就直接用。还有一种常见做法:某些包的密码被伪装成一个网址,解压时直接输入那个网址,这种也算约定俗成。实在找不到密码,最稳的办法是换一个下载源,而不是和压缩包死磕。一个需要密码的源码包,即使你破解出来了,里面代码也未必值回时间成本。
2.3 编码与换行:乱码要从解压阶段就开始管
中国开发者发布的 C++ 源码包,源文件编码大部分是 GBK,因为 Windows 简体中文版默认就是这个。但在 Linux 或 macOS 上解压后,用文本编辑器打开全是乱码。这不是文件坏了,是编码不匹配。另一个隐藏问题是换行符:Windows 用的是\r\n,Linux 用的是\n,老版本编译器在混用换行符时会给警告,虽然通常不影响编译,但配合编码问题会让人误判故障点。
解压后用file先看文件类型和编码,这个命令比任何编辑器都直观:
# 查看文件编码与换行符格式 file main.cpp # 如果显示 Non-ISO extended-ASCII 或 ISO-8859,基本可断定是 GBK # 转成 UTF-8,-f 指原编码,-t 指目标编码 iconv -f GBK -t UTF-8 main.cpp -o main_utf8.cpp mv main_utf8.cpp main.cpp # 顺手把 CRLF 换成 LF,避免编译器报警告 dos2unix main.cppiconv的-f和-t参数指定了转换路径,这里是从 GBK 转到 UTF-8。做这个转换之前先file确认一下原始编码,有些包可能已经是 UTF-8,再转一次反而会把中文注释转坏。dos2unix只处理换行符,不改编码,两个命令独立使用。Windows 用户如果不想装这些工具,用记事本打开另存为 UTF-8 也能达到同样效果,但记得一次处理全部.cpp和.h,否则混着编译反而更乱。
编码问题建议在解压阶段统一解决,不要拖到编译阶段。原因很简单:编译器报的错如果是英文的,你还能顺着行号找;如果错误信息里飘着一堆乱码字符,你根本分不清是语法问题还是编码问题,排查成本直接翻倍。
3. 编译运行 CPP 麻将项目:从 dev cpp 到命令行 g++ 的最小路径
源码包解压出来了,接下来是编译。这一步卡住最多人的不是语法错误,而是不知道这个项目该用什么方式构建。麻将项目不像那种“简单的单例类”练手代码只有一个 main.cpp,它往往拆成好几个源文件,还可能有自己的头文件依赖关系。先看清楚工程结构,再决定编译方式,能省掉一半的报错。
3.1 先读工程结构:Makefile、CMakeLists 还是 dev 文件
解压完先看根目录有没有这几个文件,它们决定了项目的主构建方式:
- 有
Makefile:说明作者用 make 构建,进目录直接make通常就行,没装 make 的 Windows 用户可以用 mingw32-make。 - 有
CMakeLists.txt:这是用 CMake 构建的项目,需要先cmake ..生成工程,再make或cmake --build .。 - 有
.dev后缀的文件:Dev-C++ 的工程文件,用 Dev-C++ 打开后点编译运行即可。 - 什么都没有:很常见,尤其是某些网课作业流传出来的版本。这种就没有“标准”构建流程,需要自己组编译命令。
我见过最多的情况是第三种:作者用 Dev-C++ 写完了整个麻将游戏,打包源码时只把.cpp和.h打了进去,工程文件.dev反而被遗漏了。这种包编译时往往要用回老的 Dev-C++(或者它的现代替代品),因为代码里可能用了旧版编译器才支持的写法。如果你机器上装了新版的 Visual Studio 或 clang,直接编译老项目经常报一堆 C++17 标准之前的兼容错误,这时候不是代码错了,是编译器标准太新。
3.2 最小编译命令:从单文件到全量 *.cpp
大多数这种麻将项目的源文件数量在 3 到 10 个之间。编译的最小命令其实很短,核心是把所有.cpp都交给编译器,而不是只编译 main.cpp。
# 单文件版本:整个项目只有一个 main.cpp 时用 g++ -std=c++11 -Wall main.cpp -o mahjong # 多文件版本:把当前目录所有 cpp 一起编译,通配符搞定 g++ -std=c++17 -O2 -Wall *.cpp -o mahjong # 如果源码在 src/ 子目录里,加上路径 g++ -std=c++17 -O2 -Wall src/*.cpp -o mahjong参数说明:-std=c++17指定 C++ 标准,老项目建议先降到c++11试一次,如果报错再根据提示上调;-O2开启优化,判胡是递归算法,不优化的话出牌会有肉眼可见的卡顿;-Wall把警告打开,虽然警告不等于错误,但 C++ 的警告往往暗示着隐性问题,比如未初始化的变量、类型转换丢精度,这些在处理牌型数字时很容易踩雷。
*.cpp通配符是这条命令的关键。很多新手只编译 main.cpp,结果链接阶段报undefined reference to xxx,原因就是函数定义在另一个.cpp里,而编译器压根没见过那个文件。用通配符把目录下所有.cpp一次性交给 g++,能规避绝大多数链接错误。如果项目文件多,或者你打算长期改它,建议写一个简单的 Makefile,而不是每次敲这一长串命令。
3.3 运行控制台麻将:输入、输出与代码页
编译成功后得到一个可执行文件。这种游戏包基本是控制台程序,没有图形界面,运行方式很简单,但有两个坑很容易让新手以为程序坏了。
# Linux / macOS ./mahjong # Windows:先切到 exe 所在目录,再运行 cd D:\mahjong mahjong.exe第一个坑是中文乱码。程序里的提示语如果是 UTF-8 编码,在 Windows 默认的 GBK 控制台下显示就是乱码;反过来,源码是 GBK,在 Linux 终端下运行也会乱。解决办法是让“源文件编码”和“运行终端编码”保持一致,Windows 的 cmd 里可以用chcp 65001切到 UTF-8 代码页,或者chcp 936切回 GBK。Dev-C++ 自带的控制台一般是 GBK,所以源码保持 GBK 反而省事。
第二个坑是双击运行一闪而过。在 Windows 上双击 exe,如果程序跑完就退出,黑框会直接关闭,什么都看不到。这在开发阶段很容易造成“我编译失败了吧”的误判。处理办法是在 cmd 里手动运行,这样窗口会保留输出信息;如果源程序确实没有暂停点,可以用管道把输出重定向到文件再查看:
mahjong.exe > output.txt 2>&12>&1把标准错误也并入标准输出,这样编译警告和运行时错误都能落盘。
4. 麻将判胡逻辑拆解:牌型编码与回溯算法的 C++ 实现
编译跑通只是开始。这类项目里真正值得反复读的是判胡模块——它决定了游戏能不能正确识别“胡了”。新手往往觉得判胡很难,因为要考虑的牌型太多,但实际上主流玩法只有两套逻辑:普通牌型(3n+2 结构)和特殊牌型(七对、十三幺)。这一章把它们的实现逻辑拆开讲,代码可以直接抄走。
4.1 用 34 个整数表示整副手牌
麻将牌一句话概括:万、条、筒各 9 种,风牌 4 种,箭牌 3 种,总计 34 种。最常见的数据结构就是int hand[34],数组下标表示牌型,值表示剩余张数。
| 编号区间 | 牌面 | 类型 |
|---|---|---|
| 0-8 | 一万到九万 | 万子 |
| 9-17 | 一条到九条 | 条子 |
| 18-26 | 一筒到九筒 | 筒子 |
| 27-30 | 东南西北 | 风牌 |
| 31-33 | 中发白 | 箭牌 |
用整数而非字符串的好处是判断顺子非常直观:顺子就是编号连续的三张牌,比如hand[i]、hand[i+1]、hand[i+2]都大于 0。判断花色边界也很简单,i % 9就是当前牌在所属花色里的位置,只要i % 9 <= 6就说明后面还够两张组成顺子。
const int kHandSize = 34; // hand[i] 表示第 i 种牌还剩几张,初始化为 0 int hand[kHandSize] = {0};这段代码定义了核心数据结构。注意kHandSize是常量,后面所有遍历手牌的循环都用它,不要硬编码 34,改起来容易漏。这样的结构足够支撑吃碰杠、听牌、出牌 AI 的所有操作,工程上这是最主流的方案。
4.2 判胡的核心:先找将,再递归拆 3 张组
普通牌型的规则是:手牌总数满足3n + 2,其中 2 张是一对将牌,其余每 3 张组成一个刻子或顺子。判胡的思路是:先假设某一种牌做将牌,剩下 12 张递归判断能不能全部拆成刻子或顺子。
// 递归判断剩余 rest 张牌能否全部拆成刻子或顺子 bool canSplit(int cnt[], int rest) { if (rest == 0) return true; // 找到编号最小的非空牌 int mi = -1; for (int i = 0; i < kHandSize; ++i) { if (cnt[i] > 0) { mi = i; break; } } // 优先尝试刻子:三张相同牌 if (cnt[mi] >= 3) { cnt[mi] -= 3; if (canSplit(cnt, rest - 3)) { cnt[mi] += 3; return true; } cnt[mi] += 3; } // 再尝试顺子:必须是同花色且后两张都存在 if (mi < 27 && mi % 9 <= 6 && cnt[mi + 1] > 0 && cnt[mi + 2] > 0) { cnt[mi]--; cnt[mi + 1]--; cnt[mi + 2]--; if (canSplit(cnt, rest - 3)) { cnt[mi]++; cnt[mi + 1]++; cnt[mi + 2]++; return true; } cnt[mi]++; cnt[mi + 1]++; cnt[mi + 2]++; } return false; } // 外层:枚举哪一张牌做将牌 bool isStandardHu(int cnt[]) { for (int j = 0; j < kHandSize; ++j) { if (cnt[j] >= 2) { cnt[j] -= 2; // 14 张去掉 2 张将牌,剩 12 张 if (canSplit(cnt, 12)) { cnt[j] += 2; return true; } cnt[j] += 2; } } return false; }逻辑说明:canSplit每次从最小编号的牌开始拆,是因为最小的那张牌如果不能组成刻子或顺子,这个局面就是死局,直接返回 false。这比随机挑牌拆要快得多,也避免了重复递归。mi < 27限定只有万、条、筒才能组成顺子,风牌和箭牌没有顺子的概念,如果这里不加边界,9 万后面的 1 条会被误判成连续牌。isStandardHu里枚举每张牌做将牌,减去 2 张后把剩余的 12 张交给递归处理。
这段代码的匹配逻辑有一个隐含前提:数组cnt会被修改,所以调用方要在递归前后保证数组状态恢复。每次尝试失败后都把减掉的牌加回来,这是回溯算法的标准操作,漏掉任何一行恢复代码都会让后面的判断全乱。
特殊牌型是两个独立分支,很多流传的判胡代码压根没处理它们,这直接导致七对胡不了。把它们单独实现并在总入口优先判断:
// 七对:七个对子,每种恰好 2 张 bool isSevenPairs(int cnt[]) { int pairs = 0; for (int i = 0; i < kHandSize; ++i) { if (cnt[i] == 2) ++pairs; } return pairs == 7; } // 十三幺:十三种幺九牌各 1 张,另加任意 1 张作将 bool isThirteenOrphans(int cnt[]) { int yao[kHandSize] = {0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33}; for (int i = 0; i < 13; ++i) { if (cnt[yao[i]] != 1) return false; } int pairs = 0; for (int i = 0; i < kHandSize; ++i) { if (cnt[i] == 2) ++pairs; } return pairs == 1; } // 总入口:三种胡法按顺序判断 bool isHu(int cnt[]) { return isSevenPairs(cnt) || isThirteenOrphans(cnt) || isStandardHu(cnt); }这里要特别说明:isSevenPairs判断的是“恰好七个对子”,如果手牌同时满足普通牌型和七对(比如碰碰胡的某些形态),在多数麻将规则里按玩家选择的胡法算,代码里用||连接意味着任意一种成立即胡,这是最宽容的判断。isThirteenOrphans中的yao数组罗列了所有幺九牌和字牌的编号,必须是每种 1 张再加上任意 1 张做将,注意这种情况下额外那张将牌也必须是幺九牌之一,所以最后查的是“某一张有 2 张”,而不是“有一张任意牌”。
4.3 出牌与听牌检测:拿判胡当引擎
判胡函数最实用的衍生功能是听牌检测——判断当前手牌打出哪张后,摸到哪些牌能胡。实现思路很暴力但有效:遍历每一张手牌,假设打掉它,再遍历 34 种牌假设摸进来,调用isHu验证。
// 判断打出手牌 i 后,是否至少存在一张牌让手牌组成胡牌 bool isNearWin(int cnt[], int i) { cnt[i]--; // 模拟打出第 i 种牌 for (int j = 0; j < kHandSize; ++j) { cnt[j]++; // 模拟摸入第 j 种牌 if (isHu(cnt)) { cnt[j]--; // 恢复摸入的牌 cnt[i]++; // 恢复打出的牌 return true; } cnt[j]--; // 撤销这次摸入尝试 } cnt[i]++; // 撤销打出操作 return false; }复杂度看着高,但实际每轮判断只有 34 次isHu调用,而判胡递归的剪枝非常充分,在普通桌面上耗时可忽略不计。这是很多“简单 AI”的地基:AI 出牌前先对每张手牌调用isNearWin,如果打掉某张后能听牌,就优先打它;如果所有选择都听不了,再退回随机出牌。cnt数组的恢复逻辑是这段代码最容易出错的地方,我在注释里标了每一步对应哪个操作的逆操作,抄代码时务必保留这几行。
5. 避坑与排查:编译和运行 C++ 麻将时的 5 个高频问题
无论你是自己写判胡逻辑,还是从网上下载这种项目编译运行,下面的问题几乎一定会遇到至少一两个。每一条都按“现象、原因、解决”的顺序写,可以直接对照排查。
5.1 链接时报 undefined reference,源码看着全都在
现象:编译命令执行后报错,提示undefined reference to xxx,但打开头文件,明明声明了这个函数。
原因:最常见的情况是只编译了 main.cpp,函数定义在 game.cpp 里,编译器链接时找不到实现。另一种情况是某个.cpp文件压根没被加入工程,Dev-C++ 里表现为“添加了文件但没勾选编译”。
解决:用通配符一次编译所有.cpp,如果项目在子目录就带上路径:
g++ -std=c++17 -O2 -Wall *.cpp -o mahjong这招能解决 90% 以上的链接报错。剩下 10% 是声明了但没写实现,或者实现函数名拼写和声明不一致——查一下.cpp里的函数签名,重点看参数列表是否带了const或引用符号。
5.2 中文提示全是乱码
现象:程序启动后,控制台里中文变成娌℃湁杩欎釜鐗或????????,英文字符正常。
原因:源文件是 GBK 编码,在 UTF-8 终端下运行,或者反过来。命令行程序里所有中文字符串在编译时就被固化成二进制字节序列,运行时直接输出,不会自动做编码转换。
解决:两个思路。改文件编码,让源码和终端一致;或者告诉编译器源文件的编码。第二招更省事:
# Linux 下源文件是 GBK,但想让程序输出 UTF-8 g++ -finput-charset=GBK -fexec-charset=UTF-8 *.cpp -o mahjong-finput-charset告诉编译器源文件用什么编码存储,-fexec-charset指定编译后的字符串常量用什么编码。Windows 下如果终端是 GBK,就把-fexec-charset设成GBK,或者更简单:chcp 65001把终端切到 UTF-8。
5.3 判胡结果不对:七对和十三幺永远胡不了
现象:手里是七个对子,运行游戏提示“不能胡”;手里是十三幺,同样不能胡。但普通的顺子刻子牌型判断正常。
原因:判胡函数只实现了isStandardHu,没有处理特殊牌型。网上流传的很多麻将源码都这样,作者只写了最常见的 3n+2 判法。
解决:把第 4 章的isSevenPairs和isThirteenOrphans补进你的判胡入口。注意判断顺序:先判断特殊牌型,再判断普通牌型,因为有些牌型同时满足多个条件,优先按番数高的算。
5.4 解压到一半报错,包不完整
现象:unrar解压过程中提示Unexpected end of archive或CRC failed,程序中断,文件不全。
原因:rar 包在下载或转存过程中损坏,也可能是分卷包只下载了其中一段。资源站经过多次转载后这种问题很常见。
解决:解压前先用unrar t测试包完整性,这个命令不写入文件,只校验每文件块的 CRC:
unrar t CPP-Mahjong-game.rar如果提示有分卷缺失,去下载页把.part1.rar、.part2.rar等全部下载,放到同一目录再解压。包损坏的话没有修的必要,重新换一个下载源更实际。
5.5 双击运行一闪而过,看不到任何输出
现象:Windows 下双击 exe,黑框闪一下就消失,无法判断程序是否正常、有没有报错。
原因:程序主流程执行完毕就退出了,或者异常崩溃。这种麻将游戏如果是单局制,打完一局直接 return,控制台自然会关闭。
解决:先用 cmd 手动运行,保留输出。把窗口留下再排查:
// 在 main 函数 return 0 之前加一段,仅用于调试 std::cout << "按回车键退出..." << std::endl; std::cin.get();std::cin.get()会阻塞等待用户按回车。注意这只能是调试手段,正式发布不要留——游戏本身的循环如果设计合理,玩家主动退出前窗口本就应该保持打开,加这行说明主循环结构有问题。
6. 验证与改造:判胡测试与玩法扩展
6.1 最小用例验证判胡
在动手改代码之前,先给判胡函数喂几个已知结果的手牌。这套最小用例能保证你的算法不是黑匣子,改完不翻车。
void testHu() { int hand[34] = {0}; // 用例 1:标准胡 111万 + 234万 + 55筒(将) hand[0] = 3; // 一万 x3 hand[1] = 1; // 二万 hand[2] = 1; // 三万 hand[3] = 1; // 四万 hand[21] = 2; // 五筒 x2 std::cout << "标准胡: " << isHu(hand) << std::endl; // 期望 1 // 用例 2:七对 memset(hand, 0, sizeof(hand)); int seven[7] = {0, 1, 2, 3, 4, 5, 6}; for (int i = 0; i < 7; ++i) hand[seven[i]] = 2; std::cout << "七对: " << isHu(hand) << std::endl; // 期望 1 // 用例 3:13 张不完整牌,不应胡 memset(hand, 0, sizeof(hand)); hand[0] = 1; hand[1] = 1; hand[2] = 1; hand[3] = 1; std::cout << "不完整手牌: " << isHu(hand) << std::endl; // 期望 0 }这个测试函数写进 main 里跑一遍,确认三个用例的输出分别是 1、1、0,再进入玩法开发。以后每次改动判胡逻辑,先跑这个函数,能挡住八成回归问题。
6.2 改造方向:从能跑到好玩
跑通整个项目后,值得做的改造有三个方向。第一,把 AI 出牌从随机改成听牌优先,用第 4 章的isNearWin替换原有的随机逻辑,这一步让游戏从“能玩”变成“能打”。第二,加计番功能,番型判断同样基于 34 编码的手牌数组,比如清一色检查所有非零牌是否落在同一个花色区间,碰碰胡检查是否全是刻子加将牌。第三,把控制台输入换成图形界面,常见做法是 Qt,核心逻辑不变,只替换输入输出层。无论选哪个方向,都记得保留原始的 rar 包不动,改坏了随时能退回最初状态——这是我从各种源码包练习里攒下的习惯。希望帮到你。
本文还有配套的精品资源,点击获取