ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC斗地主源码解析:从工程骨架到牌型AI算法

MFC斗地主源码解析:从工程骨架到牌型AI算法 简介一套使用微软基础类库MFC开发的斗地主游戏完整工程适合有一定 C 基础的 Windows 桌面开发者学习借鉴尤其适合想掌握 MFC 消息机制、控件交互和游戏状态管理的读者。项目文件共 43 个压缩包体积仅 325KB内容覆盖 13 个头文件、12 个 C 源文件以及位图、图标、文本文档和资源定义文件。其中头文件和源文件实现了游戏核心逻辑与界面交互并通过手牌、规则判断、玩家、结果处理等类组织流程位图与图标用于牌面图案、背景和程序图标资源脚本定义了菜单、对话框和控件布局另附说明文本便于快速了解工程结构。源码完整实现了洗牌发牌、叫地主、比牌型、胜负判断等流程并通过消息映射和 DDX 机制处理用户操作研究此项目可学会用 MFC 搭建桌面游戏框架掌握将游戏规则与界面解耦的分层思路还能了解位图资源加载、随机数生成等实用技巧。当前已有 195 人学习下载适合作为课程设计参考或二次开发基础。1. 从ddz.rar到MFC斗地主源码这包代码到底能学什么一个以ddz.rar命名的压缩包多半是VC6.0时代的MFC斗地主源码解压后是一整套可以直接编译出exe的工程。相比网页端或Unity版的斗地主MFC版的价值并不在画面上而在于把扑克牌的数据结构、牌型识别、搜索决策和Windows消息机制揉进了一个不到几万行的经典C程序里。打开这个包的正确顺序是先看工程结构和资源再读牌型算法最后才碰界面。适合想快速接手老MFC项目的人也适合想用C重写纸牌逻辑的客户端开发。下面按这个顺序展开。2. 打开ddz.rar之前先搞懂MFC斗地主项目的骨架2.1 找对工程文件.dsw、.dsp和.sln之间的兼容关系解压ddz.rar后能看到一堆文件第一步不是看cpp而是找工程文件。MFC斗地主源码大概率保留了VC6.0的工作区文件.dsw和项目文件.dsp。VS2019以上版本可以直接打开.dsw触发迁移但迁移后生成的.sln和.vcxproj会改变原有的字符集和运行库设置。稳妥的做法是先复制一份目录留底再用“打开文件夹”的方式让VS自动识别或者手动把.dsp的编译选项抄到新工程里。老工程里常见的非标准写法比如#include stdafx.h、new和wnew混用在升级后往往要改。下表是不同工程文件的适用范围工程文件对应VS版本直接打开后的情况.dsw / .dspVC6.0VS2022提示一次性迁移自动生成.sln和.vcxproj.sln / .vcprojVS2002~VS2010可打开可能提示版本升级.sln / .vcxprojVS2012及以上原生支持仍需处理字符集问题迁移后最常出现的错误是fatal error C1189: #error : MFC does not support /clr这类后面第5章会给出统一解法。2.2 从CWinApp到CMainFrameMFC斗地主的启动顺序MFC程序的生命周期从App类的InitInstance开始。斗地主一般做成单文档SDI应用在InitInstance里用CSingleDocTemplate把Doc、Frame、View三者绑定。这段代码决定了窗口类型和菜单资源也是阅读MFC源码的第一个入口BOOL CLandLordApp::InitInstance() { // 标准MFC初始化 CWinApp::InitInstance(); // 单文档模板把文档类、主框架窗口、视图类绑在一起 CSingleDocTemplate* pDocTemplate NULL; pDocTemplate new CSingleDocTemplate( IDR_MAINFRAME, RUNTIME_CLASS(CLandLordDoc), RUNTIME_CLASS(CMainFrame), RUNTIME_CLASS(CLandLordView)); if (!pDocTemplate) return FALSE; AddDocTemplate(pDocTemplate); // 让框架根据命令行创建空文档并显示主窗口 CCommandLineInfo cmdInfo; ParseCommandLine(cmdInfo); if (!ProcessShellCommand(cmdInfo)) return FALSE; m_pMainWnd-ShowWindow(SW_SHOW); m_pMainWnd-UpdateWindow(); return TRUE; }参数说明IDR_MAINFRAME是资源ID菜单、图标、快捷键字符串都挂在它下面RUNTIME_CLASS是宏用于运行时创建指定类对象。这里有一个常见误用在老斗地主源码里主窗口经常是CMainFrame直接new出来再SetView并没有走文档/视图的标准流程。如果发现后面OnDraw不刷新就要检查是否漏调了m_pMainWnd-UpdateWindow()。2.3 消息映射表鼠标点击、定时器都挂在哪MFC不直接用虚函数处理Windows消息而是通过消息映射宏把消息接到成员函数。斗地主界面里的发牌动画、出牌点击、定时器全部依赖这张表BEGIN_MESSAGE_MAP(CLandLordView, CView) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_TIMER() ON_COMMAND(ID_GAME_DEAL, CLandLordView::OnGameDeal) END_MESSAGE_MAP()ON_WM_LBUTTONDOWN对应OnLButtonDown(UINT nFlags, CPoint point)在函数里要自己用CRect::PtInRect判断鼠标落在哪张牌上。ON_WM_TIMER对应OnTimer(UINT_PTR nIDEvent)发牌动画和电脑玩家的操作延时都靠它。理解这张表的意义在于当点击出牌没反应时优先查ID_GAME_DEAL是否被正确映射而不是去断点调试整个流程。2.4 位图资源与透明贴图扑克牌图片的加载入口老MFC斗地主很少用PNG多数是BMP位图资源配合CImage类或GDI的TransparentBlt来画。源码里通常能看到类似下面的代码BOOL CLandLordView::LoadCardImage(Card card, CImage img) { CString strPath; // 根据牌面数值拼出文件名例如 card_14_2.bmp strPath.Format(_T(res\\card_%d_%d.bmp), card.value, card.suit); return SUCCEEDED(img.Load(strPath)); }参数说明card.value用后面3.1节里的编码card.suit表示花色文件名规则直接决定资源管理是否方便。这里容易踩的坑是CImage::Load在Debug版默认使用当前工作目录如果把exe从Debug目录挪走而资源路径还是相对路径贴图就会全部消失。处理方法是把res目录放到工程根目录然后在代码里用GetModuleFileName拼接绝对路径。2.5 从文件名猜职责看源码里的类划分打开ddz.rar实际解压的文件夹会看到GameLogic.cpp、Card.h、AI.h这类命名很直白的文件。可以用一个快速方法判断代码质量把文件按大小排序逻辑文件通常不超过10KB界面文件会因为绘图代码而偏大。Card.h负责牌的数据结构GameLogic.cpp负责洗牌发牌和牌型判断AI.h里放电脑玩家决策。先读这三个文件再回到MFC的View里看调用点整个程序的调用链就清楚了。3. 斗地主源码里的牌型判定从Card结构体到牌型比较参数斗地主源码最核心的价值是牌型算法。这一部分与MFC无关纯C实现所以即使不喜欢老界面也值得把这块单独抠出来复用。3.1 牌的编码把A、2、小王、大王映射成数值扑克牌除了大小王之外每张牌有两个要素点数和花色。为了方便比较和排序常见做法是把点数直接映射成一个连续整数而不是用字符或字符串。这里用3到17表示整副牌3到14对应3、4、5、6、7、8、9、10、J、Q、K、A15对应216对应小王17对应大王。花色0~3分别表示黑桃、红心、梅花、方块。struct Card { BYTE value; // 3~17数值越大牌越大 BYTE suit; // 0~3仅用于显示不影响牌型比较 };这样设计的直接好处是判断单张大小只需比较value做顺子时只需要遍历连续区间统计牌数时可以用value作为数组下标。很多新手会把value直接从1开始编结果做顺子判断时还要额外写转换表反而麻烦。3.2 用counts数组把手牌压缩成“点数出现次数”牌型判断很少直接遍历vectorCard而是先统计每个点数出现了几次。这个counts数组长度开到18索引3到17刚好是编码区间void ToCounts(const std::vectorCard hand, int counts[18]) { memset(counts, 0, sizeof(int) * 18); for (size_t i 0; i hand.size(); i) { counts[hand[i].value]; } }counts[3]1表示手牌里有一张3counts[16]1且counts[17]1表示大小王各一张。之后识别牌型时只需要看哪几个下标大于0以及对应的数值分布不需要再关心花色。3.3 牌型枚举给每个出牌组合一个明确的类型ID斗地主规则里能打出的组合并不多先列一个枚举后面判断和比较都基于它enum HandType { HAND_ERROR 0, SINGLE, PAIR, TRIPLE, STRAIGHT, // 顺子 DOUBLE_STRAIGHT, // 连对 TRIPLE_ONE, // 三带一张 TRIPLE_TWO, // 三带一对 PLANE, // 飞机不带 PLANE_ONE, // 飞机带单 PLANE_TWO, // 飞机带对 FOUR_TWO, // 四带二 BOMB, // 炸弹 ROCKET // 王炸 };这里有几点需要注意四带二在比较大小时不按带牌算只按四个相同点数的大小王炸是独立最高牌型普通牌型之间不能互相比较只有炸弹和王炸能压一切普通牌型。很多源码在实现时会把ROCKET漏掉导致两个王打出后连炸弹都压不过它这是明显的规则错误。3.4 牌型识别函数先统计连续点数再套分支识别函数输入counts数组输出牌型和关键牌值。整体思路是先统计ones/twos/threes/fours的数量再根据数量分布进行分类。下面是一个可运行的骨架HandType Recognize(int counts[18], int keyValue) { int ones 0, twos 0, threes 0, fours 0; for (int v 3; v 17; v) { if (counts[v] 1) ones; else if (counts[v] 2) twos; else if (counts[v] 3) threes; else if (counts[v] 4) fours; } int total ones twos * 2 threes * 3 fours * 4; // 王炸大小王各一张共两张 if (counts[16] counts[17] total 2) return ROCKET; // 炸弹4张相同点数 if (fours 1 total 4) { for (int v 3; v 17; v) if (counts[v] 4) { keyValue v; break; } return BOMB; } // 顺子至少5张连续单牌且不包含2和王 int maxLen 0, startV 3; for (int v 3; v 14; v) { if (counts[v] 0) continue; int len 1; while (len 12 v len 14 counts[v len] 1) len; if (len maxLen) { maxLen len; startV v; } } if (maxLen 5 total maxLen) { keyValue startV maxLen - 1; // 顺子比较最大牌 return STRAIGHT; } // 单张、对子、三张 if (total 1) { for (int v 3; v 17; v) if (counts[v]) { keyValue v; break; } return SINGLE; } if (twos 1 total 2) { for (int v 3; v 17; v) if (counts[v] 2) { keyValue v; break; } return PAIR; } if (threes 1 total 3) { for (int v 3; v 17; v) if (counts[v] 3) { keyValue v; break; } return TRIPLE; } return HAND_ERROR; }逻辑说明函数先通过统计数量把最容易识别的王炸、炸弹、顺子筛掉再处理单张、对子和三张。while循环里的v len用于检查后面是否还有连续的点数内层循环结束后再继续外层遍历。keyValue统一取牌型中最大的点数这样后面比较大小就只需要比这个值。这个骨架没有覆盖三带一、连对和飞机原因是这些分支要额外处理带牌的组合代码会膨胀很多。实际源码里会在上述基础上增加三张点数相同的牌再搭配一个单张或一张其他牌连对要求连续点数的牌每样至少2张飞机要求连续3张点数的牌每样至少3张再按单牌或对子带出去。判断原则是先把“主体”点数区间找出来再验证剩余牌张数是否满足带牌要求。3.5 牌型比较炸弹最大王炸顶天同牌型看keyValue识别出牌型和keyValue后比较逻辑就非常清晰bool CanBeat(HandType curType, int curKey, HandType lastType, int lastKey) { if (curType ROCKET) return true; if (lastType ROCKET) return false; if (curType BOMB) return lastType ! BOMB || curKey lastKey; if (lastType BOMB) return false; if (curType ! lastType) return false; return curKey lastKey; }参数说明curType和curKey是我方要出的牌lastType和lastKey是上家打出的牌。王炸永远为true一旦对手打出王炸则任何牌都压不住。炸弹内部比较只看curKey所以四个A的炸弹能压四个K。这里有个容易出错的细节三带一、飞机带翅膀这类牌型keyValue必须取主体牌的最大值而不能取所带牌的最大值否则会出现三带一2压不动产以上的错误。下表汇总了常见牌型的比较基准牌型判断要点比较基准示例单张1张牌valueJ压10对子2张同点数value99压88顺子连续5~12张单牌无2和王最大牌点10-J-Q-K-A压3-4-5-6-7三带一3张相同带1张任意三张的点数AAA带5压999带K炸弹4张相同点数value9999压8888火箭大小王各一张最大压一切判断牌型时还需要注意总张数匹配上家出5张顺子我方也必须出5张顺子长度不同直接判负。这个检查放在CanBeat第一次调用前用selected.size()和last.count比较即可。4. 出牌合法性校验与斗地主AI让机器人能压能忍4.1 出牌前先过合法性闸门牌型合法且能压住上家MFC斗地主源码里玩家点击“出牌”按钮后先取手牌中被选中的牌然后调用识别和比较函数。这块代码几乎都是全局函数方便复用struct LastPlay { HandType type; int key; int count; bool valid; }; bool ValidatePlay(const std::vectorCard selected, const LastPlay last) { int counts[18] {0}; ToCounts(selected, counts); int key 0; HandType type Recognize(counts, key); if (type HAND_ERROR) return false; if (!last.valid) return true; // 自由出牌第一手不需要压 if (selected.size() ! last.count) return false; // 张数不同直接不合法 return CanBeat(type, key, last.type, last.key); }逻辑说明selected是当前候选牌last是上家最后出的牌。如果上家还没出牌或者刚被炸掉重新自由出牌last.valid置为false只检查牌型合法性。这里把张数一致性放在CanBeat前检查可以避免识别函数对长度不同的顺子产生误判。4.2 搜索最小可出牌把可压的牌分成几个桶找最小电脑玩家要做的第一件事是“压住上家”。朴素做法是将手牌按点数分成若干桶然后根据上家牌型从最小点数往上找。以压单张为例bool FindMinSingle(const int counts[18], int lastKey, Card result) { for (int v lastKey 1; v 17; v) { if (counts[v] 1) { result.value v; result.suit 0; return true; } } return false; }类似地压对子需要找counts[v] 2的牌压三带一需要先找三个相同的点再找最小的一个单张作为带牌。这些函数返回后AI可以统一调ValidatePlay再做一次确认防止递归搜索里出现逻辑漏洞。找不到常规牌时再降级去找一个炸弹炸弹也无解就返回“过”。4.3 地主先手出牌的策略从最小单张开始试探当场上重新进入自由出牌状态AI的策略一般是优先出数量最少但又不至于拆牌的组合。通常的优先级是单张、对子、三张、顺子炸弹最后。这个策略可以写成简单的打分逻辑void SuggestFreestyle(const int counts[18], std::vectorCard out) { // 先出最小的单张条件是不破坏对子/三张 for (int v 3; v 17; v) { if (counts[v] 1) { out.push_back({(BYTE)v, 0}); return; } } // 单张出完再出最小的对子 for (int v 3; v 17; v) { if (counts[v] 2) { out.push_back({(BYTE)v, 0}); out.push_back({(BYTE)v, 1}); return; } } }这里的out会继续传给ValidatePlay确认能合法打出。真正的完整AI会把顺子检测也加进来但基本原则是“避免拆大牌、保留炸弹”。如果你从ddz.rar里读到的AI版本只有随机出牌可以按这个骨架去替换它的决策函数。下表是AI出牌策略的一个简化优先级场景策略兜底行为自由出牌出最小单张或对子不拆炸弹压上家单张找最小大于上家的单张无则过压上家对子找最小大于上家的对子无则炸弹完全无牌可压出炸弹或过不轻易开炸4.4 状态机叫地主、加倍、出牌轮转游戏流程用状态机管理比散落的if else清晰得多enum GameState { STATE_DEAL, // 发牌 STATE_CALL, // 叫地主 STATE_DOUBLE, // 可选加倍 STATE_PLAY, // 出牌 STATE_END // 判胜 };在MFC的OnTimer里不断检测当前状态每次状态切换后调用Invalidate()触发重绘。特别要注意的是OnTimer本身在UI线程执行如果在这里写复杂的递归搜索会导致界面卡死常见做法是先把AI结果计算完再在下一帧应用。状态切换的典型写法是void CLandLordView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_AI gameState STATE_PLAY) { // 电脑玩家出牌先计算牌型再执行 std::vectorCard play; if (SuggestPlay(m_cardData[COMPUTER], lastPlay, play)) DoPlay(COMPUTER, play); else Pass(COMPUTER); } CView::OnTimer(nIDEvent); }逻辑说明TIMER_AI是自定义定时器IDSuggestPlay内部会调用4.2和4.3里的函数。这里把AI计算放到定时器里而不是放进按钮事件里是为了避免一次点击触发多帧动画后玩家的下一次操作被反复执行。4.5 记牌信息从出牌历史里剔除手牌简单AI容易追着上家的牌一直出却没意识到对手手里还剩什么。可以维护一个int remain[18]初始时是每个人手牌的点数统计每次有人出牌就裁剪void UpdateTrack(std::vectorCard played) { for (size_t i 0; i played.size(); i) --remain[played[i].value]; }remain数组还可以进一步换算成每家已出的“最大牌”和“剩牌张数”在AI判断“要不要炸”时很有用。很多开源MFC斗地主的AI只能赢新手就是因为没做这层统计导致炸弹局必输。关于AI的完整实现还可以把出牌决策做成递归搜索配合剪枝判断拆牌代价。常见做法是先用上述贪心找出一个可行解再用深度优先搜索找更优解对于MFC源码建议保留最小可出牌逻辑把复杂度控制在O(手牌数^2)内。5. 编译运行MFC斗地主源码静态编译、字符集和双缓冲3个动作5.1 统一字符集先把编译错误压到最低老源码大量使用char*和CString的隐式转换VS2015之后默认Unicode导致报错集中在字符串参数不匹配。解决办法是项目属性 - 高级 - 字符集 - 使用多字节字符集。如果源码已经用TCHAR宏不设置也能编译但谁也不能保证古老代码里没有裸char*。匹配设置后最常见的error C2664: int MessageBox(HWND, LPCWSTR, LPCWSTR, UINT)会消失。剩下的#error错误检查一下是不是_UNICODE和UNICODE宏冲突去预处理器里统一删掉即可。5.2 静态链接MFC运行时发布exe不用带一堆dll依赖mfc140u.dll的斗地主exe拷贝到没装VC运行库的机器上直接打不开。想发布单文件就在项目属性 - 常规 - MFC的使用里选择“在静态库中使用MFC”同时把“运行库”调整为“多线程(/MT)”。这样exe体积会从几百KB涨到几MB但不再依赖动态链接库。对于从ddz.rar解压的老工程还要检查是否引用了afxext.h以外的第三方库。如果有静态编译时记得把第三方库也切到对应字符集版本。5.3 双缓冲重绘解决出牌动画闪屏MFC的视类在OnDraw里直接绘图一旦牌数较多频繁刷新就会闪屏。最简单的改法是在OnPaint里用内存DC先把内容画到一张位图上再一次BitBlt贴到窗口void CLandLordView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 在这里绘制背景、牌桌、手牌和上家牌 DrawTable(memDC, rc); dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }CreateCompatibleDC创建的内存上下文和窗口DC格式一致BitBlt的SRCCOPY把内存位图整块复制到屏幕闪烁自然消失。注意CPaintDC构造时已经自动调用了BeginPaint不要在OnPaint里再写GetDC。这三步做完老工程在VS2022下通常能顺利跑出牌桌。本文还有配套的精品资源点击获取
返回列表