☰
C++麻将源码解析:MFC工程编译、胡牌算法与AI出牌实战
2026/9/28 18:08:14 网站建设 项目流程

简介:这是一份面向C++初学者与游戏开发爱好者的麻将游戏项目源码,基于Visual C++与MFC库开发,适合用来学习Windows桌面游戏的整体实现流程。压缩包共135个文件,约3.86MB,包含9个cpp源文件与10个h头文件承载核心逻辑,89个wav音频与5个bmp位图、2个ico图标构成音效与界面素材,另有dsw、dsp、opt等工程配置文件和可执行程序,方便直接在Visual Studio中打开、编译与运行。项目围绕麻将主题实现了洗牌、出牌、胡牌判断等算法,并涉及对话框设计、人机交互与文本说明读取,读者可借此理解MFC界面搭建、游戏状态管理与资源打包分发的完整思路。目前已有293人学习下载,适合希望从源码层面掌握C++小游戏开发的中级学习者参考。

1. 从一份 VC6 麻将源码包说起:C++ 桌面游戏到底怎么跑起来

如果你手头正好有一份CPP-Mahjong-game.rar,解压后看到Mahjong.dsw、MahjongDlg.cpp、MajHu.cpp、CmpThink.cpp这些文件,第一反应大概率是:这是哪个年代的产物?没错,.dsw是 Visual C++ 6.0 的工作区文件,不是现在 VS2022 的.sln。这意味着这套麻将源码是典型的 MFC 单文档/对话框工程,用 GDI 贴图的方式画牌面,逻辑层和界面层耦合在一个工程里。它解决的不是“做一个商业麻将”的问题,而是让你能在一份完整可编译的 C++ 工程里,看到洗牌、发牌、吃碰杠胡判断、电脑 AI 出牌这一整套流程是怎么用面向对象的方式串起来的。适合谁?适合刚学完 C++ 语法、想找一个有真实业务逻辑的桌面项目练手的人,也适合想研究 MFC 消息映射和 GDI 双缓冲的老派 Windows 开发者。资源包里那几个.bmp就是牌面、骰子、背景和标题图,麻将介绍.txt是规则说明,Mahjong很可能是编译好的可执行文件。别急着双击 exe,先搞清楚工程结构,后面编译和改逻辑才不会翻车。

2. 拆开 Mahjong.dsw 工程:MFC 对话框结构、资源视图与编译链路

2.1 为什么是 MFC 对话框工程,而不是控制台或 Qt

拿到Mahjong.dsw第一件事是判断工程类型。从MahjongDlg.cpp这个文件名就能看出,主界面继承自CDialog,是一个对话框程序。MFC 对话框工程的好处是资源编辑器直接拖控件、绑变量,消息映射用BEGIN_MESSAGE_MAP宏展开,对当年写 Windows 程序的人来说是最省事的路径。坏处也很明显:界面和逻辑容易糊在一起,MahjongDlg.cpp里往往既有OnPaint画牌,又有按钮响应调Wanjia.cpp里的出牌函数。如果你用现在 VS2022 打开,会提示升级工程,升级后.dsw变成.sln,但 MFC 库需要单独在安装器里勾选“适用于最新 v143 生成工具的 C++ MFC”。不勾就会报afxwin.h找不到,这是第一个高频坑。

2.2 工程文件清单与各自职责

把压缩包里的文件按职责分一下,后面改代码心里有数:

文件类型作用
Mahjong.dsw工作区VC6 工程入口,记录项目配置
Mahjong.dsp项目源文件列表、编译链接选项
MahjongDlg.cpp界面逻辑对话框初始化、绘图、按钮响应
Wanjia.cpp玩家逻辑玩家手牌管理、出牌、吃碰杠
MajHu.cpp胡牌判断胡牌牌型检测算法
CmpThink.cpp电脑 AI电脑出牌决策
Num.bmp / Majpai.bmp资源数字牌面、麻将牌面贴图
sezi.bmp / 背景.bmp资源骰子、背景图
Title.bmp资源标题画面
麻将介绍.txt文档规则说明

.ncb和.opt是 IDE 自动生成的浏览数据库和选项文件,删掉不影响编译,VS 会重建。真正要保留的是.dsp、.dsw和所有.cpp/.h。

2.3 用 VS2022 打开并编译的完整步骤

常见做法是直接用 VS2022 打开.dsw,让它自动升级。但更稳的方式是新建一个空 MFC 对话框工程,把源码和资源手动加进去,避免升级脚本改坏消息映射。我一般会走手动路线:

# 1. 解压后先备份原始工程,升级失败还能回退 cp -r CPP-Mahjong-game CPP-Mahjong-game-bak # 2. 用 VS2022 新建 MFC 对话框工程,命名为 Mahjong # 安装器里确认勾选:使用 C++ 的桌面开发 + 适用于 v143 的 C++ MFC # 3. 把原工程的 .cpp/.h 复制进新工程目录 # 把 .bmp 复制进 res 目录,并在资源视图里重新导入位图资源 # 资源 ID 要和原 rc 文件里一致,否则 LoadBitmap 会返回 NULL

代码后说明:第一步备份是后悔药,VC6 工程升级到 VS2022 时,BEGIN_MESSAGE_MAP里的宏有时会因为字符集从 MBCS 变 Unicode 而报错。第二步新建工程是为了拿到正确的 v143 工具集和 Unicode 配置。第三步复制资源时,Num.bmp、Majpai.bmp这些位图在LoadBitmap里是按资源 ID 加载的,ID 对不上就画不出牌,表现为界面一片空白。参数上,项目属性里“字符集”建议先设成“使用多字节字符集”,因为老代码里大量CString和char*混用,直接上 Unicode 会冒出一堆const char*转LPCWSTR的错误。

2.4 编译链接常见报错与定位

链接阶段最容易出unresolved external symbol,通常是 MFC 库没链上。检查项目属性 → 链接器 → 输入 → 附加依赖项,确认有mfc140.lib或对应版本。另一个是LNK2005重复定义,多半是stdafx.h预编译头没配好,把pch.h设为“使用预编译头”即可。运行起来如果弹Debug Assertion Failed,看是哪个Afx函数,通常是对话框控件指针为空,说明DoDataExchange里绑的控件 ID 在资源里被改了。

3. 麻将核心逻辑怎么读:洗牌、发牌、胡牌判断与电脑 AI 出牌

3.1 牌型数据结构与洗牌算法

麻将逻辑的起点是牌怎么表示。这份源码里大概率用一个int数组或vector<int>存牌,每张牌用编号区分万、条、筒、风、箭。常见做法是定义一个enum,比如 0-8 是万子一到九,9-17 是条子,18-26 是筒子,27 开始是字牌。洗牌就是对这个数组做随机打乱:

// 洗牌:Fisher-Yates 洗牌,保证每种排列等概率 void Shuffle(std::vector<int>& tiles) { srand((unsigned)time(NULL)); // 用时间做随机种子 for (int i = tiles.size() - 1; i > 0; --i) { int j = rand() % (i + 1); // 随机选一个下标 std::swap(tiles[i], tiles[j]); // 交换 } }

逻辑说明:从后往前遍历,每次在当前未处理区间里随机选一张换到当前位置。参数说明:tiles是整副牌,麻将通常是 136 张(万条筒各 36 张加字牌 28 张)。rand()的随机性一般,如果要做更公平的洗牌,可以用std::mt19937配合std::shuffle。注意srand只调用一次,放在InitInstance里,别每次洗牌都调,否则同一秒内开局牌序会重复,这是玄学问题的根源之一。

3.2 发牌与玩家手牌管理

发牌逻辑在Wanjia.cpp里,通常是给四个玩家各发 13 张,庄家 14 张。手牌一般用std::vector<int>存,并且排序,方便后面判断顺子刻子。排序用std::sort即可。这里有个坑:如果手牌不排序,胡牌判断里的顺子检测会漏判,因为顺子要求连续三张,乱序时你按顺序扫是扫不出来的。所以发完牌立刻排序,是血泪经验。

3.3 胡牌判断算法:递归拆解与将牌检测

MajHu.cpp是整套源码里最值得读的部分。胡牌判断的标准思路是:从手牌里先选一对将牌,剩下的牌尝试拆成若干顺子或刻子,全部拆完就是胡。用递归实现:

// 判断剩余牌能否全部拆成顺子或刻子 bool CanFormMelds(std::vector<int>& tiles) { if (tiles.empty()) return true; // 拆完即成功 int first = tiles[0]; // 尝试刻子:三张相同 if (count(tiles.begin(), tiles.end(), first) >= 3) { removeThree(tiles, first); // 移除三张 if (CanFormMelds(tiles)) return true; addThree(tiles, first); // 回溯 } // 尝试顺子:first, first+1, first+2 且同花色 if (isSameSuit(first, first + 1) && isSameSuit(first, first + 2) && hasTile(tiles, first + 1) && hasTile(tiles, first + 2)) { removeTile(tiles, first); removeTile(tiles, first + 1); removeTile(tiles, first + 2); if (CanFormMelds(tiles)) return true; addBack(tiles, first, first + 1, first + 2); // 回溯 } return false; // 两种都试过,失败 } // 胡牌入口:枚举将牌 bool IsHu(std::vector<int>& hand) { for (int i = 0; i < hand.size(); ++i) { if (i > 0 && hand[i] == hand[i-1]) continue; // 跳过重复将牌 if (count(hand.begin(), hand.end(), hand[i]) >= 2) { std::vector<int> rest = hand; removeTwo(rest, hand[i]); // 移除将牌 if (CanFormMelds(rest)) return true; } } return false; }

逻辑说明:IsHu先枚举所有可能的将牌对子,对每种情况调用CanFormMelds递归拆解。CanFormMelds每次取最小的牌,优先试刻子再试顺子,失败就回溯。参数说明:hand是排序后的手牌,isSameSuit要判断两张牌是否属于同一花色且数字连续,字牌不能组顺子。这个算法的时间复杂度在 14 张牌规模下完全够用,不需要上动态规划。注意递归里的remove和addBack必须成对,漏一个就会把手牌改乱,导致后续判断全错。

3.4 电脑 AI 出牌策略:CmpThink.cpp 的取舍逻辑

CmpThink.cpp里的电脑 AI 一般不会做太深的搜索,常见做法是“打孤张、留搭子”。具体就是给每张牌算一个价值分:能组成顺子或刻子的牌加分,孤张减分,然后打掉分最低的。更简单的是直接打最边上、最不靠张的牌。读这段代码时重点看它怎么评估“搭子”,比如手里有 3 万和 5 万,那 4 万就是有效进张,价值高。如果 AI 总是打错牌,先检查它的手牌有没有排序,没排序的 AI 等于闭着眼睛打。

4. 资源与界面:BMP 贴图、GDI 绘图和 MFC 消息映射的避坑清单

4.1 位图资源加载与透明贴图

牌面用Majpai.bmp一张大图切,还是每张牌一个小图,取决于源码实现。常见做法是一张大图里按网格排列,用BitBlt或StretchBlt截取对应区域画到对话框上。透明背景要用TransparentBlt,但它在老 MFC 里需要链接msimg32.lib。如果牌面画出来带白底,就是没用透明贴图。资源 ID 在resource.h里定义,LoadBitmap(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDB_MAJPAI))加载,返回 NULL 就说明 ID 或路径不对。

4.2 双缓冲绘图消除闪烁

MFC 对话框直接OnPaint里画图,刷新时会闪。标准解法是双缓冲:先在内存 DC 里画好整帧,再一次性BitBlt到屏幕 DC。

void CMahjongDlg::OnPaint() { CPaintDC dc(this); CRect rect; GetClientRect(&rect); CDC memDC; memDC.CreateCompatibleDC(&dc); // 创建内存 DC CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rect.Width(), rect.Height()); memDC.SelectObject(&bmp); // 选入内存位图 // 在这里把所有牌、背景画到 memDC DrawAllTiles(&memDC); dc.BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }

逻辑说明:所有绘制操作先落在内存 DC,最后一步拷贝到屏幕,避免逐张绘制时的闪烁。参数说明:CreateCompatibleBitmap的宽高用客户区大小,BitBlt的SRCCOPY表示直接覆盖。注意内存 DC 和位图要随用随建,或者做成成员变量复用,频繁创建销毁会拖慢帧率。

4.3 消息映射与按钮响应

MFC 的消息映射靠BEGIN_MESSAGE_MAP和ON_BN_CLICKED这类宏。如果按钮点了没反应,先检查三处:资源里按钮 ID 是否和宏里一致、DoDataExchange里有没有绑变量、消息映射宏有没有写在BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间。漏写END_MESSAGE_MAP会编译报错,写错 ID 则静默失效,这是最常见的翻车点。

4.4 避坑与排查清单

现象一:编译报afxwin.h找不到。原因:VS2022 没装 MFC 组件。解决:打开 Visual Studio Installer,修改“使用 C++ 的桌面开发”,勾选“适用于最新 v143 生成工具的 C++ MFC”。

现象二:程序运行后界面空白,牌画不出来。原因:位图资源 ID 不匹配或LoadBitmap返回 NULL。解决:在LoadBitmap后加ASSERT(bmp != NULL),断下来看资源视图里的 ID 和代码里是否一致。

现象三:胡牌判断偶尔漏胡。原因:手牌没排序,或递归回溯时remove/addBack不成对。解决:发牌后立即std::sort,并在递归函数里用 RAII 或成对调用保证状态还原。

现象四:电脑 AI 出牌卡死或死循环。原因:AI 选牌时没有排除已打出的牌,或者循环条件写错。解决:在CmpThink里加打印,看它每次选的牌是否在合法手牌范围内。

现象五:升级到 Unicode 后大量CString报错。原因:老代码用char*和sprintf。解决:项目属性字符集先设“使用多字节字符集”,或者把sprintf换成_stprintf_s,char*换成CString。

5. 从能跑到能改:加一个听牌提示与出牌记录的小技巧

把工程跑起来只是第一步,真正让这份源码变成自己东西的办法是加功能。我一般会先加一个“听牌提示”,因为它的实现路径清晰,又能逼你把胡牌判断复用起来。思路是:遍历当前手牌里每一张,假设打掉它,然后对剩下的 13 张枚举所有可能的进张(34 种牌),用IsHu判断是否胡。如果某张进张能让手牌胡,就说明当前处于听牌状态,把这张进张记下来显示。

// 听牌提示:返回所有能胡的进张 std::vector<int> GetTingTiles(std::vector<int>& hand) { std::vector<int> result; for (int discard = 0; discard < hand.size(); ++discard) { std::vector<int> after = hand; after.erase(after.begin() + discard); // 假设打掉这张 for (int t = 0; t < 34; ++t) { // 枚举 34 种牌 if (count(after.begin(), after.end(), t) >= 4) continue; // 超过四张不可能 std::vector<int> test = after; test.push_back(t); std::sort(test.begin(), test.end()); if (IsHu(test)) { result.push_back(t); // 记录进张 } } } std::sort(result.begin(), result.end()); result.erase(std::unique(result.begin(), result.end()), result.end()); return result; }

逻辑说明:对每张可打的牌,枚举 34 种可能的进张,补进去后调IsHu。参数说明:hand是当前 14 张手牌,t从 0 到 33 对应万条筒字。注意count判断不能超过四张,因为麻将每种牌最多四张。这个函数在 14 张规模下跑一次大概几毫秒,放在出牌后调用完全不影响体验。显示的时候把t转成中文牌名,比如 0 是“一万”,27 是“东”,贴到对话框的静态文本上。

出牌记录更简单,在玩家和电脑每次出牌时往一个CListBox或CListCtrl里插一条字符串,格式“玩家 打出 五万”。关键是记录要放在真正执行出牌的那一步,别放在按钮响应开头,否则玩家取消出牌也会记进去。我习惯在Wanjia.cpp的DiscardTile函数末尾统一记录,这样玩家和 AI 走的是同一条路径,不会漏。

这两个小功能加起来不到一百行,但能让你把MajHu.cpp、Wanjia.cpp、CmpThink.cpp之间的调用关系彻底摸清。从那以后我每次拿到一份陌生源码,都强制先找一个能复用核心算法的功能加上去,跑通了再谈改架构。希望这份拆解帮到你,少走点升级工程和资源 ID 对不上的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询