恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++实现小猫钓鱼纸牌游戏:队列与STL容器实战解析
首页
资讯中心
/
C++实现小猫钓鱼纸牌游戏:队列与STL容器实战解析
C++实现小猫钓鱼纸牌游戏:队列与STL容器实战解析
发布时间:2026/9/15 19:46:27
小猫钓鱼这个纸牌游戏估计不少人在小时候都玩过两个人各拿一摞牌轮流往桌上翻牌翻出的牌如果和桌面上已有的某张牌点数相同就能把这两张牌连同中间夹着的所有牌一起收走。规则简单气氛却热闹特别适合哄孩子。而用C把整个流程写成程序是很多初学者第一次接触“队列”这个概念时会选的练手项目——手牌天然就是先进先出桌面牌堆要频繁查找和删除整套逻辑刚好能把队列、查找、随机洗牌这些基础知识点串起来。这篇文章就完整拆解这个小游戏的C实现从规则梳理、数据结构选型到核心代码逐段讲解、踩坑排查全部一次讲透。课程设计、自学练手、甚至想给代码加个AI对手都能在这里找到参考。我尽量用做项目时的真实思路来讲不贴那种只有结论没有过程的“标准答案”。1. 规则梳理与需求分析1.1 两条核心规则先把玩法吃透小猫钓鱼的规则一句话就能概括轮流翻牌翻出的牌能“钓”走桌面上同点数牌之间的所有牌。展开说就是两件事。第一件事每个玩家手头有一叠牌出牌时只能从顶部翻出一张放到桌面牌堆的顶部这个过程完全不能用“选出某一张某张”的策略翻到哪张就是哪张。第二件事如果新翻出的这张牌在桌面已有的牌里能找到同点数那么从桌面上那张同点数牌开始一直到刚翻出的这张牌中间这一整段全部归翻牌人所有收进他的手牌底部如果找不到这张牌就孤零零留在桌面顶部等下一轮。有个容易忽略的细节判断“有没有同点数”的时候新翻出的牌已经放在桌面上了所以查找范围必须排除掉最后这一张否则任何一次翻牌都会匹配到自己规则直接失效。这个点写代码时特别容易中招后面第5章我会专门展开。胜负判定也简单某位玩家翻完手牌后手里一张牌都不剩就判负对方获胜。表面上看起来手牌只会越来越少但因为钓牌会把牌收回去双方手牌数量都在动态变化所以理论上可能出现两边来回收牌、谁也赢不了的局面。程序里必须加一个最大回合数兜底到点直接判平局否则程序会死循环挂掉。1.2 规则变体与程序需求清单不同地方玩的小猫钓鱼规则细节其实不太一样。有的玩法里A可以当“万能牌”钓走桌面任意一张有的玩法里允许“压牌”——翻出的牌如果和桌面最上面那张相同就只收这一张还有的玩法干脆把52张牌全部发完一人26张那游戏时间会非常漫长。做程序之前最好先把规则钉死否则写代码的时候三天两头改逻辑人会很崩溃。我这次实现采用课程设计里最常见的版本需求明确列成清单52张牌无大小王只比较点数花色忽略不计两名玩家各发10张可配置剩余牌闲置不用每回合两人各翻一张先玩家1后玩家2翻出的牌若在桌面已有牌中排除刚翻出的自己存在同点数整段收走某玩家手牌为空即判负另一方获胜超过最大回合数默认500回合判平局。这张清单其实就是程序的“需求规格说明书”。写代码前先把它确认好后面所有函数都是这条清单的翻译。1.3 这个游戏适合练什么这个项目虽小但覆盖的C知识点相当全STL容器queue和vector、泛型算法find、shuffle、函数封装、状态循环、边界条件处理。对刚学完C语法、想找个小项目练手的同学来说这个难度曲线非常合适。它比贪吃蛇简单不需要处理键盘事件和动画刷新也比“打印九九乘法表”这类纯语法练习有意义得多。最核心的价值在于它天然有“数据结构对应现实物体”的直观感——手牌就是先进先出的队列桌面则是一个需要随机查找和区间删除的序列抽象概念瞬间变成了看得见摸得着的东西。2. 数据结构选型与程序架构2.1 手牌为什么用 queue手牌的进出规则只有一个出牌从顶部出收牌从底部进。这标准就是队列的先进先出语义C STL 的 queue 天生适配。出牌就是pop()钓到的牌就是push()都是 O(1) 操作代码写起来非常直白完全不用自己维护数组下标。不过 queue 有个天生的缺陷它不提供遍历接口。你要是想在界面上把玩家手牌一张张列出来queue 做不到。很多初学者会在这里卡住“我明明能看到 queue 里有牌为什么不能打印”答案就是 STL 的 queue 为了设计简洁屏蔽了容器底层细节只暴露 push、pop、front、back、size 这些固定操作。想显示手牌内容的同学我建议直接用dequeint替代 queue。deque 支持push_back、pop_front、size()和operator[]既保留了队列操作又能随机访问打印手牌毫无压力。课程设计如果界面要显示手牌直接用 deque如果只需要统计手牌数量queue 完全够用。我的示例代码为了贴合教学里的“队列”主题先用 queue 展示最核心逻辑后面第4章扩展部分再讲怎么换成 deque。2.2 桌面牌堆为什么用 vector桌面牌堆需要频繁做三件事尾部加入新牌、查找同点数、把中间连续一段整体删除。这三个操作 vector 都方便得不得了。尾部加入用push_back摊还复杂度 O(1)查找用 STL 的find线性扫描对桌面几十张牌的规模来说毫无压力删除一段用erase一次就能把迭代器区间的元素全部干掉不用自己写循环搬运。有人可能会问桌面牌堆不是也能用 stack 吗翻牌就是压栈。问题是钓牌时要收取的往往是中间一段stack 只能访问栈顶中间和底部完全没法操作总不能把整摞牌全倒出来再重新放回去那也太蠢了。list 倒是可以删除中间节点但查找只能 O(n) 遍历而且链表节点在内存里不连续遍历起来缓存不友好综合体验还不如 vector 的连续内存块。在这个场景里vector 就是最顺手的选择没有之一。2.3 程序主流程整个程序其实是一个简单的状态机主循环长这样初始化牌组和手牌 while 游戏未结束 and 回合数 上限: 打印当前桌面和双方手牌数 玩家1翻牌处理钓牌 如果玩家1手牌为空: 玩家2获胜结束 玩家2翻牌处理钓牌 如果玩家2手牌为空: 玩家1获胜结束 回合数有两点值得注意。第一除了按回车翻牌玩家在整个游戏过程中没有任何策略选择翻哪张牌完全由手牌队列顺序决定游戏本质是个“看运气”的回合制过程。第二胜负判断必须放在翻牌动作之后因为翻牌可能触发钓牌钓牌后手牌数量会从0变成好几张这时候就还没输。如果要把游戏做成“有策略”的版本思路在第4章扩展里讲核心是允许玩家在出牌前重新排列手牌顺序把基于队列的模型升级为基于可排序序列的模型。2.4 全局变量还是封装成类很多教程的写法是全局变量加一堆自由函数代码最短也最容易读懂。对于课程设计来说这种写法完全没问题。如果老师要求“面向对象”再把全局变量挪进 Game 类函数变成成员函数main 里创建一个对象调run()就行改造难度很小。我给出的示例是面向过程的清晰版本方便初学者一条路读到底。坦白说很多刚学完类的同学会把对象封装写成“为了类而类”反而把逻辑弄乱。先把过程式版本跑通再谈重构这是更稳的学习路径。3. 关键代码一步步实现3.1 数据定义、洗牌与发牌先把头文件、全局变量和初始化函数贴出来后面的代码直接往下接就能编译。#include iostream #include queue #include vector #include algorithm #include random using namespace std; const int MAX_ROUNDS 500; vectorint desk; // 桌面牌堆 queueint hand[2]; // 两个玩家的手牌0 表示玩家11 表示玩家2 void dealCards(int n) { vectorint cards; for (int value 1; value 13; value) { for (int suit 0; suit 4; suit) { cards.push_back(value); } } random_device rd; mt19937 g(rd()); shuffle(cards.begin(), cards.end(), g); for (int i 0; i n; i) { hand[0].push(cards[i]); hand[1].push(cards[i n]); } }代码里有三个细节想重点说一下。第一构造牌组时往cards里循环塞了4轮1到13这是在模拟4种花色的A到K但因为规则只比较点数花色直接被丢弃了。这样处理的好处是牌面值正好就是数组里的整数比较和打印都方便缺点是控制台看起来只有数字不够像扑克牌。想加花色的话要用结构体Card这个放到第4章。第二底层洗牌用shuffle而不是老教材里的random_shuffle。random_shuffle在C14被标记为弃用C17标准直接移除了旧代码拿到新编译器上会报random_shuffle is not a member of std。shuffle需要第三个参数——随机数引擎这里用random_device配合mt19937是当前最稳妥的随机数组合。第三默认每人发10张牌。初始手牌越少单局结束越快适合演示和调试。如果每人发到20张以上游戏会拖得非常久而且平局概率大幅上升因为钓牌机制会让手牌数量反复横跳。3.2 翻牌与钓牌playTurn 逐行拆解下面这个函数是整个游戏的核心我把每行代码的意图都标出来。bool playTurn(int id) { if (hand[id].empty()) return false; int card hand[id].front(); hand[id].pop(); desk.push_back(card); auto it find(desk.begin(), desk.end() - 1, card); if (it ! desk.end() - 1) { for (auto pos it; pos ! desk.end(); pos) { hand[id].push(*pos); } desk.erase(it, desk.end()); } return true; }第一步防御性判断。hand[id].empty()时直接返回 false表示“翻牌失败”调用方据此判断玩家是不是已经输了。第二步翻牌。front()拿到队头牌pop()把它从手牌队列移除然后push_back放到桌面末尾。注意这里“放到桌面”的动作一定要在查找之前完成因为钓牌规则里的“钓到新翻出的牌”指的就是这张牌已经出现在桌面上了。第三步是核心中的核心find(desk.begin(), desk.end() - 1, card)。搜索范围故意排除了桌面最后一张也就是刚翻出来的那张牌。如果不排除find 百分之百会匹配到末尾那张新牌等于每次翻牌必定触发钓牌游戏逻辑直接崩坏。找到之后从it到desk.end()之间的所有牌依次收进手牌队列。这个顺序也有讲究先被钓到的牌先入队排在队列前面符合真实游戏里“整段收走、按桌面顺序叠在手牌底部”的体感。最后desk.erase(it, desk.end())一次性清空这一段桌面牌。erase 之后it和它后面的迭代器全部失效所以收牌循环必须放在 erase 之前这个顺序不能换。用一个小例子演示桌面当前是3 7 9玩家翻出7放上桌面后序列变成3 7 9 7。find 从开头扫到倒数第二张找到了位置1的7于是把7 9 7三张牌全部收走桌面只剩3。如果翻出的牌是5桌面变成3 7 9 5find 找不到同点数5就留在桌面等待下一次。3.3 状态打印与胜负收尾为了让玩家看清游戏进程需要每回合打印一次状态包括双方手牌数和桌面牌堆。void printStatus(int round) { cout 第 round 回合 endl; cout 玩家1手牌数: hand[0].size() 玩家2手牌数: hand[1].size() endl; cout 桌面牌堆: ; if (desk.empty()) { cout (空) endl; } else { for (int c : desk) cout c ; cout endl; } }桌面为空的情况一定要单独处理否则 range-based for 循环会打印一个空行看起来很怪。循环打印时牌与牌之间用空格分隔读起来更舒服。main 函数把整个流程串起来int main() { dealCards(10); int round 0; bool gameOver false; while (round MAX_ROUNDS !gameOver) { round; printStatus(round); cout 玩家1按回车翻牌...; cin.get(); playTurn(0); if (hand[0].empty()) { cout 玩家1手牌出完玩家2获胜 endl; gameOver true; break; } cout 玩家2按回车翻牌...; cin.get(); playTurn(1); if (hand[1].empty()) { cout 玩家2手牌出完玩家1获胜 endl; gameOver true; break; } } if (!gameOver) { cout 达到最大回合数( MAX_ROUNDS )游戏平局 endl; cout 玩家1剩余手牌: hand[0].size() 玩家2剩余手牌: hand[1].size() endl; } return 0; }胜负判断放在每个玩家的翻牌动作后面。如果翻完牌手牌为空说明刚才那一下已经把最后一张牌出掉了而且没有钓上新牌判负没跑。如果翻牌的同时钓到了牌手牌数量会立即变成钓上来的张数empty() 的判断自然不成立。round MAX_ROUNDS放在 while 条件里是最后一道保命栓。就算双方疯狂钓牌、你来我往地拖程序也会在500回合后干净利落地结束不会真的死循环。3.4 编译运行与开发环境建议把上面三段代码按顺序拼到一个 cpp 文件里就能直接编译运行。编译命令g main.cpp -o catfish -stdc11如果还在用 Dev-C 5.11需要到“工具 - 编译选项”里把编译标准设成 C11 或更高否则shuffle这种依赖新标准的函数会报错。新版 Dev-C、VS Code、CLion 默认标准都够新基本不用改。VS Code 配 C 环境的时候最常见的麻烦是编译时找不到标准库。装好 C/C 扩展并配置 MinGW-w64 之后tasks.json里的编译参数记得加上-stdc17不然有些新特性会报 warning 甚至 error。还有一个让很多人崩溃的问题是中文乱码。Dev-C 老版本源码默认 GBK 编码控制台也是 GBK输出中文没问题但 VS Code 里源码保存成 UTF-8Windows 控制台默认还是 GBKcout输出中文就会变成一堆乱码。解决办法是在 main 开头加一句仅 Windows 下生效#ifdef _WIN32 system(chcp 65001nul); #endif注意前提是源文件保存为 UTF-8 编码如果源文件本身就是 GBK这句反而会把输出搞乱。VS Code 里看右下角可以确认当前文件编码。4. 体验优化与玩法扩展4.1 结构体 Card让牌面有花色有样子基础版本的牌只是数字1到13玩起来和数字卡片没区别。想把程序做得更像真实扑克牌可以把 int 替换成结构体struct Card { int value; // 1-13代表 A 到 K int suit; // 0-3代表黑桃、红桃、梅花、方块 };改完之后所有vectorint、queueint都要同步换成Card查找时比较value字段输出时根据suit显示花色符号。这个改动有个隐藏的坑如果直接find一个Card默认的比较逻辑是逐成员比较也就是 value 和 suit 都相等才算相等这不符合“只看点数”的规则。所以要么给 Card 重载operator要么改用find_if自定义比较函数auto it find_if(desk.begin(), desk.end() - 1, [card](const Card c) { return c.value card.value; });这也是初学者在结构体版本里最容易踩的坑之一不要踩。4.2 人机对战让电脑自动翻牌想让第二个玩家变成电脑方式很简单轮到玩家2时如果是 AI就自动执行playTurn(1)中间用this_thread::sleep_for(chrono::seconds(1))模拟一下思考时间游戏体验就好很多。但如果想让 AI 显得“聪明”就必须突破队列模型的手牌顺序限制。我们可以把queueint换成dequeint允许 AI 在每轮开始前重新整理手牌顺序把点数相同的牌尽量往队头附近放提高翻牌时钓到牌的概率。排序可以用 STL 的 sort一行代码的事sort(hand[aiId].begin(), hand[aiId].end());很多初学者喜欢在这个场景手写冒泡排序其实没有必要。练习排序算法归练习写项目的时候直接调标准库把精力放在游戏逻辑上就好。一旦允许排序游戏就从“纯运气”变成了“记忆加策略”的对抗玩家需要记住桌面上有哪些牌决定要不要把自己的好牌压到队头或者故意把已经出现的点数留到后面翻。4.3 界面升级从控制台数字到 ASCII 卡片如果你想在课程设计里拿更高的分可以考虑给牌加一个简单的 ASCII 卡片样式打印效果类似┌───┐ │ 7 │ └───┘实现思路是给 Card 写一个printCard()方法分三行输出。桌面牌堆显示就变成一行牌背、一行点数、一行牌背比单纯列数字有辨识度得多评委看到的第一印象会完全不一样。界面这块我不建议过度投入把牌面显示清楚、回合信息完整就够了。控制台游戏做得再花哨复杂度也有限真正影响评分的是代码结构和逻辑严谨性。4.4 玩法变体自定义规则开关不同地区的小猫钓鱼规则细节不一样最常见的变化是特殊牌的处理。有的玩法里 A 可以钓走桌面任意位置一张牌有的玩法里 K 要通吃桌面所有牌。这些如果直接写死在 playTurn 里以后每改一个规则就要动核心函数很容易改出新 bug。更稳妥的做法是定义一个规则结构体struct Rule { int initCards; // 每人初始牌数 int maxRounds; // 平局兜底回合数 bool aceCanCatchAny; // A是否可以钓任意牌 bool kingCatchAll; // K是否通吃桌面 };在 playTurn 里根据规则开关走不同分支核心逻辑就稳定了。这个设计习惯对初学者来说可能觉得多余但等你写过几个项目就会发现可配置的规则比到处打补丁的代码好维护十倍。5. 常见问题与排查心得5.1 游戏永远不结束回合上限与死循环新手第一次跑通程序后最容易遇到的现象是游戏进行了几百回合还没有胜负。这不算 bug是规则本身可能导致双方收牌收来收去、手牌永远打不完。解决办法就是加最大回合数我的代码里默认500回合。实际测试下来每人10张初始牌的平局概率不低因为钓牌机制太容易触发了。想减少平局可以把初始手牌加大到13张甚至20张让前期博弈更充分或者直接把 MAX_ROUNDS 调到2000给足耐心。调试时可以故意把回合上限设小比如50快速验证逻辑对不对。等确认没问题再调回去。5.2 find 边界与迭代器失效这个项目里最常见的逻辑错误就是find的范围没有排除自己。如果没有把查找范围写成desk.end() - 1而写成desk.end()那么新放上去的牌自己就能匹配到自己每一轮都会触发钓牌游戏行为会完全乱套。正确写法是auto it find(desk.begin(), desk.end() - 1, card); if (it ! desk.end() - 1) { // 找到同点数收牌 }这里desk.end() - 1必须是合法操作要求 vector 非空。好在执行这段代码前刚push_back了一张牌vector 一定非空不会出现空指针或越界。还有一个相关的坑是erase后的迭代器失效。desk.erase(it, desk.end())执行完之后it及其后面的所有迭代器都不能再用了。所以收牌循环必须在 erase 之前完成顺序绝对不能反过来。5.3 随机种子与洗牌陷阱老代码里典型的做法是srand(time(NULL))配合rand()洗牌程序单次运行没问题但如果种子没有及时更新每次启动的发牌顺序完全一样体验很差。C11 之后的思路是用random_device生成一个随机种子再用mt19937作为随机数引擎配合shuffle完成洗牌random_device rd; mt19937 g(rd()); shuffle(cards.begin(), cards.end(), g);random_device在极少数编译器实现中可能退化成伪随机但在课程设计场景里完全够用。另外分享一个调试小技巧复现问题的时候可以把随机种子固定成一个固定值比如mt19937 g(42)这样每次运行的发牌顺序都一样bug 很容易重现。逻辑稳定后再换回随机种子。5.4 编译报错与运行环境问题汇总我把这个项目里能从“网络热词”里看到的典型环境问题整理成了一张表遇到直接对照报错或现象常见原因解决方案error: random_shuffle is not a member of std编译器用了 C17 及以上标准改用shufflemt19937error: sleep was not declared跨平台的头文件问题用thread的this_thread::sleep_for中文输出变成乱码源文件 UTF-8 与控制台 GBK 编码不一致_WIN32下执行system(chcp 65001nul)源文件存成 UTF-8一按回车程序直接退出cin 把换行符留在缓冲区cin.get()读到了旧换行在cin.get()前调用cin.ignore()清空缓冲区编译通过但运行卡死游戏没有回合数上限陷入长循环加上MAX_ROUNDS判断缓冲区那个问题尤其常见。如果某个版本的程序在翻牌前还用了cin 读取菜单选项残留的换行符会让下一次cin.get()立刻返回看起来就像“按了一下回车程序就跳过去了”。解决办法是每次读取完数字后紧跟一条cin.ignore()。还有个小提示queue::size()返回的是size_t类型无符号数直接和 int 比较在某些编译选项下会有 signed/unsigned 警告。稳妥做法是强转成 int或者定义一个int count (int)hand[id].size()再打印。5.5 完整代码如何组织如果你不想拼上面那几段代码我可以直接把完整版的组织逻辑再说一遍头文件、全局常量、全局变量、dealCards、playTurn、printStatus、main从上到下排列。所有函数互相独立通过全局变量通信这是最简单也最容易读懂的结构。将来要改成类就把全局变量变成私有成员把自由函数变成成员函数main 里创建对象调用接口。改造过程不超过半小时但你会在动手过程中理解“为什么需要封装”这个问题——因为全局变量一旦多起来函数之间的隐式依赖会让代码越来越难维护。我个人在实际操作中的体会是小猫钓鱼这个项目最大的价值不是“写完了”而是“写完之后敢不敢重构”。同一套逻辑先面向过程再面向对象再做模板版本每一步的重构都能让你对C的理解往上走一个台阶。如果你正打算拿它当课程设计建议至少把基础版和 Card 花色版都写一遍再决定要不要加 AI。这样交上去的作业无论从代码量还是完成度上看都会明显高出平均水平。