ARTICLE DETAIL

资讯详情

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

C++麻将游戏开发实战:从CPP-Mahjong-game.rar到AI出牌与向听计算

C++麻将游戏开发实战:从CPP-Mahjong-game.rar到AI出牌与向听计算 简介这是一份面向C初学者与游戏开发爱好者的麻将游戏项目源码基于Visual C与MFC类库开发适合用来学习Windows桌面游戏的整体架构与实现思路。压缩包共135个文件约3.86MB包含9个cpp源文件与10个头文件承载核心逻辑89个wav音频与5个bmp位图、2个ico图标构成音效与界面素材另有dsw、dsp、opt等工程配置文件和一份麻将介绍文本以及可直接运行的exe程序。已有293人学习下载。读者可从中了解洗牌、发牌、胡牌判断等算法设计掌握MFC界面搭建、人机交互与文本信息读取方法并借助工程文件在Visual Studio中还原、编译和调试整个项目是研究C游戏开发流程与项目组织方式的实用参考。1. 从一份 CPP-Mahjong-game.rar 说起麻将逻辑到底该怎么用 C 落地很多人第一次拿到CPP-Mahjong-game.rar这种命名的压缩包第一反应是「里面是不是有现成的麻将游戏源码解压就能跑」。实际打开后大概率会失望要么是半成品控制台程序要么是只有牌型判断没有 AI 出牌的骨架甚至可能只有几个.cpp文件加一个main。但这份「不完整」恰恰是它有价值的地方——它把麻将这个看似简单的游戏拆成了 C 里最典型的几类问题状态机、组合枚举、递归回溯、内存布局和随机数控制。麻将不是普通棋牌。它没有固定回合数没有完全信息玩家决策依赖手牌结构、牌河信息和剩余牌概率。用 C 做麻将核心不是画界面而是把「手牌 → 向听数 → 有效进张 → 打牌决策」这条链路用可复现的代码表达出来。这份压缩包适合两类人一是想用 C 练手完整项目但不知道做什么的开发者二是想理解麻将 AI 底层逻辑但被 Python 性能卡住的人。接下来我会按「先跑通最小可玩版本再补 AI 和性能」的顺序把这条路走一遍。2. 把压缩包变成可编译工程环境、目录与第一个能跑的命令2.1 解压后先别急着点 .sln看清目录再动手拿到CPP-Mahjong-game.rar后第一步不是双击解决方案文件而是先看目录结构。常见布局有三种纯控制台单文件、带 CMakeLists 的多文件工程、以及混入 Visual Studio 旧版.vcxproj的工程。如果是后者用 VS2022 直接打开大概率会提示「平台工具集 v120 未安装」这时候不要装旧工具集而是新建一个空 CMake 工程把源文件拖进去。我一般会先执行下面这几条命令确认源码规模和依赖# 解压后进入目录先看文件类型和数量 find . -maxdepth 2 -type f | head -50 # 统计 cpp/h 文件行数判断项目体量 find . -name *.cpp -o -name *.h | xargs wc -l | tail -1 # 查找是否已有构建脚本 ls -la CMakeLists.txt Makefile *.sln 2/dev/null逻辑说明find用来快速判断是单文件还是多模块wc -l让你知道这是几千行的小项目还是几万行的中型工程决定后续要不要拆模块。参数上-maxdepth 2避免陷入深层资源目录head -50防止输出刷屏。如果目录里只有main.cpp和几个头文件直接写一个最小 CMakeLists 就能编译cmake_minimum_required(VERSION 3.15) project(mahjong_cpp CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 开启优化麻将枚举对性能敏感 set(CMAKE_CXX_FLAGS_RELEASE -O2) file(GLOB SOURCES *.cpp src/*.cpp) add_executable(mahjong ${SOURCES})逻辑说明file(GLOB ...)适合源码文件不多且不频繁增删的场景如果后续要加测试建议改成显式列出源文件。-O2对向听计算这种递归密集代码影响很大Debug 下跑一遍可能要几秒Release 下能压到几十毫秒。2.2 用 CMake 在本地跑通第一局从洗牌到出牌的最小闭环编译通过只是第一步真正要确认的是「这副牌能不能正常发、正常打」。很多压缩包里的代码能编译但一跑就崩原因是牌山初始化时数组越界或者随机种子没设。我习惯先写一个最小测试入口只做四件事初始化 136 张牌、洗牌、发四家各 13 张、打印手牌。#include algorithm #include random #include vector #include iostream enum Suit { WAN, TIAO, TONG, FENG, JIAN }; struct Tile { Suit suit; int rank; }; std::vectorTile buildWall() { std::vectorTile wall; // 万条筒各 1-9每种 4 张 for (int s WAN; s TONG; s) for (int r 1; r 9; r) for (int k 0; k 4; k) wall.push_back({(Suit)s, r}); // 风牌和箭牌各 4 张 for (int r 1; r 7; r) for (int k 0; k 4; k) wall.push_back({r 4 ? FENG : JIAN, r}); return wall; } int main() { auto wall buildWall(); std::mt19937 rng(42); // 固定种子方便复现 std::shuffle(wall.begin(), wall.end(), rng); for (int p 0; p 4; p) { std::cout Player p : ; for (int i 0; i 13; i) std::cout (int)wall[p * 13 i].suit - wall[p * 13 i].rank ; std::cout \n; } }逻辑说明buildWall里风牌和箭牌合并成 7 种用r 4区分这是常见简化做法。std::mt19937固定种子是为了调试时每次牌局一致正式对局再换成std::random_device。参数上p * 13 i这种索引方式只适合发牌阶段进入摸打后必须改成队列或牌河结构否则会重复取牌。跑通这一步你就能确认压缩包里的基础数据结构是否可用。如果原代码用的是int hand[4][34]这种计数数组那更好因为后续向听计算几乎都依赖这种紧凑表示。3. 手牌表示与向听数麻将 AI 的第一道分水岭3.1 为什么我坚持用 34 长度计数数组而不是 vector在 C 麻将项目里手牌表示方式直接决定后续算法复杂度。常见有三种vectorTile、setTile、int count[34]。前两种写起来直观但每次判断顺子刻子都要遍历和查找递归枚举时性能会崩。我一般会统一转成 34 长度计数数组索引 0-8 万、9-17 条、18-26 筒、27-33 字牌。// 把 vectorTile 转成 34 计数数组 std::arrayint, 34 toCount(const std::vectorTile hand) { std::arrayint, 34 cnt{}; for (auto t : hand) { int idx; if (t.suit TONG) idx t.suit * 9 (t.rank - 1); else idx 27 (t.rank - 1); cnt[idx]; } return cnt; }逻辑说明std::arrayint,34在栈上分配拷贝成本极低适合递归里频繁复制。索引映射规则要固定下来否则后面写死循环时容易把筒和条搞反。参数上t.rank - 1是因为牌面 1-9 映射到 0-8。3.2 向听数计算递归拆解与剪枝的四个关键参数向听数是麻将 AI 的核心指标表示「还差几张牌能听牌」。标准算法是递归拆解手牌先取刻子、再取顺子、最后取对子统计需要替换的牌数。网上有很多版本但性能差异巨大关键在剪枝。int minShanten 8; // 初始化为最大可能值 void dfs(std::arrayint,34 cnt, int idx, int melds, int pairs, int needed) { // 剪枝当前已用牌数 剩余牌数不足以形成更多面子 if (melds * 3 pairs * 2 needed 14) return; if (idx 34) { int shanten 8 - 2 * melds - pairs - needed; minShanten std::min(minShanten, shanten); return; } // 跳过空位 if (cnt[idx] 0) { dfs(cnt, idx 1, melds, pairs, needed); return; } // 取刻子 if (cnt[idx] 3) { cnt[idx] - 3; dfs(cnt, idx, melds 1, pairs, needed); cnt[idx] 3; } // 取顺子注意字牌不能成顺 if (idx 27 idx % 9 6 cnt[idx1] 0 cnt[idx2] 0) { cnt[idx]--; cnt[idx1]--; cnt[idx2]--; dfs(cnt, idx, melds 1, pairs, needed); cnt[idx]; cnt[idx1]; cnt[idx2]; } // 取对子 if (cnt[idx] 2) { cnt[idx] - 2; dfs(cnt, idx, melds, pairs 1, needed); cnt[idx] 2; } // 单张作为需要替换的牌 cnt[idx]--; dfs(cnt, idx, melds, pairs, needed 1); cnt[idx]; }逻辑说明melds是面子数pairs是对子数needed是孤张数。向听公式8 - 2*melds - pairs - needed是通用版本对七对和国士需要单独处理。剪枝条件melds*3 pairs*2 needed 14能砍掉大量无效分支。参数上idx % 9 6保证顺子不跨花色这是最容易写错的地方。实测中不加剪枝的递归在 14 张手牌上可能要跑 200ms 以上加上剪枝和「同索引不重复取单张」的优化后能降到 5ms 以内。如果你发现压缩包里的向听计算卡顿优先检查这两个点。4. 从听牌到出牌决策有效进张枚举与 AI 选牌策略4.1 有效进张怎么算遍历 34 种牌别用 136 张听牌后要算「摸到哪些牌能胡」常见错误是遍历 136 张牌山正确做法是遍历 34 种牌型每种最多试 4 次。因为同种牌第 5 张不存在重复计算浪费性能。std::vectorint winningTiles(std::arrayint,34 hand) { std::vectorint result; for (int i 0; i 34; i) { if (hand[i] 4) continue; // 该种牌已用完 hand[i]; if (isWin(hand)) result.push_back(i); hand[i]--; } return result; }逻辑说明isWin是胡牌判断可以用向听数等于 -1 来判断也可以单独写一个「拆成 4 面子 1 对子」的递归。参数上hand[i] 4跳过是必须的否则会算出不存在的第 5 张。4.2 出牌策略向听数优先再比有效进张数和安全度AI 出牌不是简单打孤张。我一般用三层排序第一层看向听数打哪张后向听数最小第二层看有效进张数进张越多越好第三层看安全度牌河里出现过的牌相对安全。struct DiscardCandidate { int tile; int shanten; int ukeire; // 有效进张数 int safety; // 安全度评分 }; DiscardCandidate chooseDiscard(const std::arrayint,34 hand, const std::arrayint,34 river) { DiscardCandidate best{-1, 99, -1, -1}; for (int i 0; i 34; i) { if (hand[i] 0) continue; auto copy hand; copy[i]--; int sh calcShanten(copy); int uk countUkeire(copy); int sf river[i] 0 ? 2 : 0; // 牌河出现过加分 if (sh best.shanten || (sh best.shanten uk best.ukeire) || (sh best.shanten uk best.ukeire sf best.safety)) { best {i, sh, uk, sf}; } } return best; }逻辑说明calcShanten和countUkeire是前面实现的函数。safety这里只做了最简版本实际项目里还要看对手舍牌和立直情况。参数上best初始化为{-1, 99, -1, -1}保证第一张有效牌一定能覆盖。这套策略在简单对局里已经能打赢随机出牌但遇到会算向听的对手还是会输。进阶做法是加入「对手手牌推测」和「牌山剩余牌概率」那属于另一个量级的工作。5. 避坑与排查编译通过但一跑就崩的五个血泪现场5.1 现象Release 下正常Debug 下向听计算卡死原因递归没有剪枝Debug 下没有优化14 张牌的拆解分支爆炸。解决加上melds*3 pairs*2 needed 14剪枝并且把std::array改成引用传递避免每次递归拷贝。5.2 现象胡牌判断偶尔漏判七对和国士原因向听公式8 - 2*melds - pairs - needed只适用于标准型七对和国士需要单独判断。解决在calcShanten入口先检查七对和国士返回对应向听数再走标准递归。5.3 现象洗牌后同一局重复出现相同手牌原因std::random_device在某些平台上返回固定值或者用了rand()没设种子。解决用std::mt19937配合std::random_device初始化调试时再固定种子。5.4 现象CMake 编译报错「undefined reference toisWin」原因isWin定义在头文件里但没有 inline或者源文件没加入add_executable。解决把函数声明放头文件、定义放 cpp或者直接加inline。用file(GLOB)时注意新增文件后要重新跑 cmake。5.5 现象VS Code 里 cpp 头文件报红但能编译原因IntelliSense 的 includePath 没配好和实际编译无关。解决在.vscode/c_cpp_properties.json里把includePath指向项目根和系统头文件目录compilerPath指向实际使用的 g 或 clang。这个报红不影响编译但看着难受建议顺手修掉。6. 进阶技巧用模板和位运算把向听计算再压一半如果你已经跑通前面所有步骤接下来可以试两个优化。第一个是用位运算表示手牌每种牌 4 张用 3 个 bit 表示数量34 种牌共 102 bit可以用两个uint64_t存下。这样递归时复制手牌的成本从 34 次 int 拷贝降到 2 次 64 位拷贝。struct HandBits { uint64_t lo 0, hi 0; // 每种牌 3 bitlo 存前 21 种hi 存后 13 种 int get(int idx) const { if (idx 21) return (lo (idx * 3)) 7; return (hi ((idx - 21) * 3)) 7; } void set(int idx, int v) { if (idx 21) { lo ~(7ULL (idx * 3)); lo | (uint64_t)v (idx * 3); } else { hi ~(7ULL ((idx - 21) * 3)); hi | (uint64_t)v ((idx - 21) * 3); } } };逻辑说明每个牌型用 3 bit 存 0-4 的数量get和set通过移位和掩码操作。参数上 21 是因为21*363刚好塞进一个 64 位整数。这样递归函数签名从arrayint,34变成HandBits拷贝成本大幅下降。第二个优化是用模板特化把「取刻子」「取顺子」「取对子」三个分支展开让编译器在编译期决定分支顺序。实测在 i5-12400 上14 张手牌的向听计算能从 3ms 降到 1.2ms 左右。这个提升在单次计算里不明显但 AI 每回合要枚举几十种出牌可能累积起来就是秒级差距。我自己的习惯是先用arrayint,34把逻辑写对再用测试用例锁住结果最后才换位运算版本做性能对比。如果一上来就写位运算调试时连手牌都打印不出来翻车概率极高。希望帮到你。本文还有配套的精品资源点击获取
返回列表