ARTICLE DETAIL

资讯详情

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

C++实战:面向对象与STL实现小猫钓鱼纸牌游戏

C++实战:面向对象与STL实现小猫钓鱼纸牌游戏 我这两年带学生练C最头疼的不是语法讲不透而是很多人学完了类、容器、指针合上书依然做不出一个完整的程序。培训班里最常见的作业是图书管理系统、学生成绩管理写得人想吐看的人更想吐。后来我换了个思路用“小猫钓鱼”这个纸牌游戏当练手项目效果出奇地好——游戏规则简单到小学生都懂但真要拿C写出来对象建模、STL容器、随机算法、交互输入输出、状态流转这些核心硬功夫全都能过一遍。这篇就完整记录一下我用C实现小猫钓鱼纸牌游戏的思路、代码和踩坑过程给想拿C练手写小游戏的朋友做一个参考。1. 从规则到选型我为什么挑了这副牌1.1 规则先定好程序才不会乱写写游戏之前第一件事不是开IDE而是把规则用大白话写清楚。小猫钓鱼这个游戏有很多变体——有按“接龙”玩的有按“钓鱼”猜牌玩的。我实现的是经典“Go Fish”玩法规则一句话两个人每人先拿7张牌轮到自己时喊一个点数问对方手里有没有这个点数的牌对方有就得全交出来没有就得说“去钓鱼”然后自己从牌堆摸一张只要手里凑齐4张相同点数就当完成一组放旁边得分。牌堆空了、俩人手里也没牌了谁完成的小组多谁赢。52张牌、无大小王、3到5组就算一局决胜这些参数其实都可以做成常量。设计时我先画了个简陋的列表初始化牌堆 - 洗牌 - 发牌 - 循环回合问牌/给牌/摸牌/凑组结算- 判定结束。整个程序的主心骨就出来了。别小看这一步多数初学者写这种游戏翻车不是不会写语法是一上来就写main函数写到一半忘了下一步该干嘛。1.2 环境与语言版本选择这次实现我用的是C17编译器用的MinGW-w64IDE用的VSCode。许多初学者会纠结VSCode怎么配置C/C环境其实核心就三件事装好MinGW-w64并配好PATH环境变量、安装C/C扩展、配置好tasks.json和launch.json。如果怕麻烦直接用Visual Studio新建控制台项目也行而且VS对断点调试的支持更顺手适合在排查逻辑bug时使用。需要说明的是代码里基本只用了C11之后就稳定的特性唯一用到C17的地方是std::optional和std::string_view如果用老编译器改回bool加引用参数也完全可行。游戏本身不挑版本关键是思路。2. 对象怎么切Card、Deck、Player、Game的职责边界2.1 Card点数用2到14还是1到13新手写卡牌程序第一个容易纠结的问题A到底是1还是14我建议统一用2到14表示2最小14是A。为什么不用1到13因为后面打印牌面时要做映射表用2到14更符合“2、3、4……10、J、Q、K、A”的自然顺序排序显示时直接用点数比较不用额外处理A的特殊位置。单张牌我用了一个结构体加两个枚举#include string #include vector enum class Suit { HEARTS, SPADES, CLUBS, DIAMONDS }; enum class Rank { TWO 2, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING, ACE }; struct Card { Rank rank; Suit suit; int getRankValue() const { return static_castint(rank); } };花色用enum class点数用enum class的显式底层值这样后面洗牌、比较点数时都直接用整数不会出现“把花色当点数”的低级错误。getRankValue()看起来多余但把static_cast封装在语义明确的函数里调用点会干净很多。2.2 Deck一个容器加上洗牌和抽牌牌堆的本质是一个包含52张牌的容器对外暴露“洗牌”和“抽牌”两个操作就够了。我用std::vector而不是deque来存牌因为游戏里只会从牌堆顶部取牌不需要双端操作vector内存连续、随机访问快配合swap实现洗牌再合适不过。#include algorithm #include random class Deck { private: std::vectorCard cards; int topIndex; public: Deck() : topIndex(0) { for (int s 0; s 4; s) { for (int r 2; r 14; r) { cards.push_back({static_castRank(r), static_castSuit(s)}); } } } void shuffle() { std::mt19937 rng(std::random_device{}()); for (int i static_castint(cards.size()) - 1; i 0; --i) { std::uniform_int_distributionint dist(0, i); std::swap(cards[i], cards[dist(rng)]); } topIndex 0; } bool isEmpty() const { return topIndex static_castint(cards.size()); } Card drawCard() { if (isEmpty()) { throw std::runtime_error(Cannot draw from an empty deck); } return cards[topIndex]; } };有一个细节洗牌时不能重置cards本身而是重置topIndex。因为洗牌动的是底层数组的顺序而“牌发到哪了”是另一个状态。把两者分开后续想实现“把已完成的组重新洗回牌堆”之类的变体规则就会非常轻松。2.3 Player手牌、得分和“我有没有这张牌”玩家类我单独封装了手牌管理逻辑手里的牌用std::vectorCard保存因为玩家会频繁增删牌拿到或交出相同点数的牌但数量始终不大顺序无所谓线性访问很划算。class Player { protected: std::string name; std::vectorCard hand; int groupsScore; public: Player(const std::string playerName) : name(playerName), groupsScore(0) {} const std::string getName() const { return name; } int getScore() const { return groupsScore; } bool hasEmptyHand() const { return hand.empty(); } void addCard(const Card card) { hand.push_back(card); } bool hasRank(int rankValue) const { for (const auto card : hand) { if (card.getRankValue() rankValue) { return true; } } return false; } std::vectorCard giveAllRank(int rankValue) { std::vectorCard result; auto it hand.begin(); while (it ! hand.end()) { if (it-getRankValue() rankValue) { result.push_back(*it); it hand.erase(it); } else { it; } } return result; } bool completeGroupIfPossible() { for (int rankVal 2; rankVal 14; rankVal) { int count 0; for (const auto card : hand) { if (card.getRankValue() rankVal) { count; } } if (count 4) { auto it hand.begin(); while (it ! hand.end()) { if (it-getRankValue() rankVal) { it hand.erase(it); } else { it; } } groupsScore; return true; } } return false; } };giveAllRank这里有个经验删除vector元素时要避免在遍历时用下标自增因为erase之后后面的元素会移动迭代器会失效。用while加erase返回的新迭代器是最稳的写法。关于“有没有这张牌”我直接线性遍历。也可以把手牌排序后用二分查找但这个场景里手牌最多十来张hasRank每回合也就调用几次线性遍历的常数开销完全可以忽略没必要为了炫技引入复杂性——这是很多八股文喜欢考的点实际工程里要先分析数据规模再决定算法。2.4 Game回合流程的总导演Game类负责把上面三个对象串起来里面最核心的是一个回合的流程方法。先看Game类的骨架class Game { private: Deck deck; Player human; Player ai; bool humanTurn; public: Game() : human(你), ai(电脑), humanTurn(true) {} void setup() { deck.shuffle(); for (int i 0; i 7; i) { human.addCard(deck.drawCard()); ai.addCard(deck.drawCard()); } humanTurn true; } void play() { setup(); while (!isGameOver()) { if (humanTurn) { playHumanTurn(); } else { playAITurn(); } humanTurn !humanTurn; } announceWinner(); } private: bool isGameOver() const; void playHumanTurn(); void playAITurn(); void askAndSettle(Player asker, Player answerer, int rankValue); bool drawAndCheck(Player player); void announceWinner() const; };这里我刻意把“询问结算”抽成了一个独立方法askAndSettle因为人和AI的回合流程几乎一样区别只是“选什么点数来问”。人与AI流程完全共用同一套结算代码bug面一下就小了很多。如果想让代码更工程化还可以把人类玩家和AI玩家抽成Player的派生类各自实现chooseRankToAsk()。但这两个类在行为上的差异太小强行上继承反而会制造出“隐藏覆盖”问题后面我细说。所以这版先保持聚合关系等AI策略变得足够复杂时再拆不迟。3. 洗牌是随机的艺术从random_shuffle换到shufflemt199373.1 手写一遍Fisher-Yates就永远忘不掉了洗牌在C里看似一个函数调用的事——std::shuffle两行搞定。但我建议每个初学者至少手写一遍算法因为这是理解“随机排列”的最佳训练。Fisher-Yates的思路非常朴素从最后一个元素开始每次都从当前位置及之前的所有元素里随机挑一个跟当前位置交换然后当前位置往前移一位。void FisherYatesShuffle(std::vectorCard deck) { std::mt19937 rng(std::random_device{}()); for (int i static_castint(deck.size()) - 1; i 0; --i) { std::uniform_int_distributionint dist(0, i); std::swap(deck[i], deck[dist(rng)]); } }为什么是“当前位置及之前”因为位置i之后已经完成洗牌是“已经确定的随机部分”如果再把后面的牌拉进来参与交换反而会破坏均匀性。每一步做n次总共做n轮每一种排列出现的概率理论上均等。这也是它取代老式“每次随机选一张插入新数组、再从原数组删除”那种O(n²)写法的根本原因。3.2 为什么不直接用rand()C的老接口std::random_shuffle因为内部依赖rand()已经沦为反面教材。rand()的致命问题有两个第一它的随机质量对很多实现来说很差——低位的周期短容易出现可预测的规律第二如果你不手动seed每次运行洗出来的初始状态都一样玩家会觉得你在作弊。C11后在random引入的mt19937配合std::uniform_int_distribution才是现代稳定做法。很多教程上来就写srand(time(0))试十次有九次都能跑但你能保证它在不同平台、不同编译器下都生成均匀分布吗rand()的算法和RAND_MAX在不同标准库中都不一样写出来的代码不能跨平台复现。mt19937则给了确定的周期和分布特性配合均匀分布对象这才是干净的可移植写法。3.3 洗得够不够乱用统计说话写完洗牌后我顺手写了个测试洗一万次牌统计每一张牌的初始位置分布。如果洗牌算法是均匀的那么任意一张牌出现在位置0~51中任意位置理论概率都接近1/52。#include array #include iostream void testShuffleUniformity(int trials) { std::arraystd::arraylong long, 52, 52 posCount{}; for (int t 0; t trials; t) { std::vectorCard deck createDeck(); FisherYatesShuffle(deck); for (int pos 0; pos 52; pos) { int cardId (static_castint(deck[pos].rank) - 2) * 4 static_castint(deck[pos].suit); posCount[cardId][pos]; } } double expected static_castdouble(trials) / 52.0; for (int card 0; card 52; card) { for (int pos 0; pos 52; pos) { double ratio static_castdouble(posCount[card][pos]) / expected; if (ratio 0.95 || ratio 1.05) { std::cout Card card at pos pos ratio: ratio \n; } } } std::cout Uniformity test done.\n; }实测下来一万次样本里几乎所有位置的出现次数都在理论值正负5%以内偶有越界也正常——样本量不够大时总会有波动。这个测试本身并不复杂但它把“觉得随机”变成了“计算上的依据”也帮我在后面排查问题时确认了洗牌不是bug源头。4. 控制台上的你来我往交互和回合循环怎么写4.1 手牌显示排序和点数字符串映射控制台游戏让人玩得舒服的第一步就是手牌显示清楚。我不光打印牌面还会按点数从大到小排序这样玩家扫一眼就能判断该问哪个点数。void displayHand(const Player player) { std::vectorCard sorted player.getHand(); std::sort(sorted.begin(), sorted.end(), [](const Card a, const Card b) { return a.getRankValue() b.getRankValue(); }); std::cout player.getName() 的手牌: ; for (const auto card : sorted) { std::cout rankToString(card.getRankValue()) ; } std::cout | 已完成 player.getScore() 组\n; }这里的std::sort需要#include algorithm这个头文件C程序员一天要include八百遍不引入直接报错初学者经常卡在让人摸不着头脑的“模板实例化错误”上——其实就少了一行头文件。rankToString是“C字符串数组初始化”的典型场景std::string rankToString(int rankValue) { static const char* rankNames[] {, , 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A}; if (rankValue 2 rankValue 14) { return rankNames[rankValue]; } return ?; }数组下标直接对应该点数值这比写一长串if-else优雅多了。静态数组只初始化一次也不会在每次调用时重新构造字符串。用std::arraystd::string, 15也可但下标从2开始才用得上浪费前两个位置这个代价无所谓换来的是代码可读性。4.2 输入校验非法输入和重复询问的防守控制台程序输入是最大的不稳定因素。玩家敲了个“猫”字怎么办敲了15怎么办按了回车又敲了一遍全都要处理。我封装了一个读取点数的辅助函数int getValidRankFromUser(int handSize) { int input; while (true) { std::cout 你想问哪个点数 (2-14输入数字即可): ; if (!(std::cin input)) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 输入无效请重新输入。\n; continue; } if (input 2 input 14) { return input; } std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 点数范围是2到14。\n; } }这里强烈建议在读到合法输入后再ignore一次把缓冲区内可能残留的换行清掉。std::cin input读取整数时会在输入流里留下一个换行符如果后面还有std::getline那个换行符就会被读走——这是C控制台程序最经典的坑我在第六节里会再详细展开。有一点不要只防数字非法还要防“玩家问了自己手里没有的点数”。规则上没有禁止这么问但游戏策略上这么玩很蠢。我选择不强制拦截这行为只在你问之前显示一次手牌提示让玩家自己判断。拦截逻辑写起来很简单但会让交互显得太“管家婆”游戏首要目的是好玩。4.3 一个完整回合的流程代码回合处理的核心代码其实很短void Game::askAndSettle(Player asker, Player answerer, int rankValue) { std::cout asker.getName() 询问点数: rankToString(rankValue) \n; if (answerer.hasRank(rankValue)) { std::vectorCard gained answerer.giveAllRank(rankValue); for (const auto card : gained) { asker.addCard(card); } std::cout answerer.getName() 交出了 gained.size() 张 rankToString(rankValue) !\n; } else { std::cout answerer.getName() 说: 没有钓鱼\n; if (!deck.isEmpty()) { Card drawn deck.drawCard(); asker.addCard(drawn); std::cout asker.getName() 摸到了 rankToString(drawn.getRankValue()) \n; } else { std::cout 牌堆已经空了跳过摸牌。\n; } } while (asker.completeGroupIfPossible()) { std::cout asker.getName() 集齐一组当前完成 asker.getScore() 组。\n; } }注意我用了while (asker.completeGroupIfPossible())而不是if。这考虑了一个边界情况玩家一次问牌可能同时拿到多个点数的牌而这次询问前可能手里已经有两个点数的牌各3张问来1张凑齐第一组放下手里剩下的另一个点数可能刚好也是4张。用while循环把这些连锁反应全部结算干净不会漏分。4.4 AI的简单记忆策略电脑对手不能太脑残——最简单的策略是每次只问自己手头有的点数因为这种问题至少对方有一半的牌在自己这边不那么容易白嫖。更进一步我给AI加了一个“记忆表”AI会记住你曾经拿出来的所有牌。当你交出某点数的牌AI就把它记下来并且只在自己也有的情况下才问。class AIPlayer : public Player { private: std::arrayint, 15 rememberedCount; public: AIPlayer() : Player(电脑), rememberedCount{} {} void rememberCardsGiven(int rankValue, int count) { rememberedCount[rankValue] count; } int chooseRankToAsk() { std::vectorint ownedRanks; for (int r 2; r 14; r) { if (hasRank(r)) { ownedRanks.push_back(r); } } auto it std::max_element(ownedRanks.begin(), ownedRanks.end(), [this](int a, int b) { return rememberedCount[a] rememberedCount[b]; }); return *it; } };AI已经知道“对记忆里出现过的点数感兴趣”因为它判断出你更可能还有这个点数。当然这个策略挺粗糙的——只统计历史发放牌没有统计已经完成的组也没有隐藏信息的加权模型。但对命令行小游戏来说体验已经比瞎问强一大截而且代码只有十几行。5. 把“能跑”升级成“像工程”这块代码里值得较真的细节5.1 值语义还是智能指针写Game类时我直接用了Player human; Player ai;而不是std::unique_ptrPlayer。为什么因为这两个Player对象在Game整个生命周期里都存在没有所有权转移值语义完全够用。现代C的默认选择是值类型只有面对多态对象或者体积大、复制昂贵的场景才考虑智能指针。不过AI类已经继承自Player想体现多态就得改成指针。这时候优先用std::unique_ptrPlayer而不是裸指针或shared_ptr。unique_ptr表达“独占所有权”的意图复制它会被编译器拦截从根上避免双重释放的隐患。等Game被销毁智能指针自动释放堆上的Player无需手动delete。这是C写游戏远比“new/delete满天飞”舒服的原因之一。5.2 覆盖与隐藏给AI和人类玩家做继承时的经典陷阱一旦动手写继承就会踩到C里“覆盖override”和“隐藏hide”这个高频面试坑。假设我有一个基类class Player { public: virtual void chooseStrategy() { /* 基类默认实现 */ } }; class AIPlayer : public Player { public: void chooseStrategy() { /* 特殊实现 */ } };此时AIPlayer::chooseStrategy是覆盖了基类虚函数。但如果基类里没有virtual关键字或者派生类函数签名跟基类不完全相同就成了“隐藏”——不管基类函数是不是虚函数只要派生类里有同名函数就会把基类的同名版本遮蔽掉。class Player { public: void chooseStrategy(); // 非虚 }; class AIPlayer : public Player { public: void chooseStrategy(); // 这个是隐藏不是覆盖 };这种错误最坑的是它不报错。如果你通过Player*指针调用chooseStrategy调到的永远是Player版本而不是AIPlayer版本运行结果跟你想象得完全不一样。所以在派生类重写虚函数时务必加上override关键字class AIPlayer : public Player { public: void chooseStrategy() override; };只要签名不匹配编译器直接报错而不是留到运行时给你一个莫名其妙的bug。这是“宁可编译失败不要debug三天”的典型案例。5.3 灵活扩展存档、多人和换玩法这个小游戏后续能扩的地方很多。存档功能其实不难游戏状态无非是牌堆顺序、两个玩家手牌、还剩多少牌、轮到谁这些都是能用文件流序列化的数据。#include fstream void saveGame(const Game game, const std::string filename) { std::ofstream ofs(filename); // 按固定顺序写入洗好的牌堆、手牌、分数、回合标志 // 加载时反向读回即可 }能让C的operator与operator配合自定义类型用friend重载写出人类可读的文本存档这样玩家不玩游戏时甚至能看懂存档内容调试也更友好。如果以后加入网络联机对战核心逻辑已经和UI分离只要把askAndSettle结果通过socket发出去就行不需要重写业务逻辑。5.4 可测试性把核心逻辑和UI分开写这个项目过程中我最大的经验就是游戏逻辑和输入输出尽量解耦。askAndSettle里不要出现std::cin读取——把“选点数”这件事彻底做成调用方传入的参数。这样就可以写自动化测试脚本自动模拟一万局验证AI和玩家的胜率验证结束条件是否一定成立。否则每测一次都要坐在终端前敲敲打打再怎么手动测也覆盖不了所有边界。后来我把整局游戏抽成了一个playRound(Strategy humanStrategy, Strategy aiStrategy)函数测试就变成了循环调用跑一万局游戏不超过五秒钟比手测高效太多。6. 调试实录我实际踩进去的坑6.1 固定随机种子是复现bug的最快路径随机牌局最难处理的就是bug不复现。今天还能稳定触发的“集齐两组分没给”的bug明天洗完牌就再也碰不上了。解决方案给随机数生成器换一种可配置的种子方式。我调试时先写死种子std::mt19937 rng(42); // 固定种子可复现然后用固定种子跑一万次命中一次bug记下这个种子的整局流程再开一个逐行断点对着它排查。找到问题改完代码后再用std::random_device当种子正常跑确认平时游玩体验没有退化。固定种子只用于开发正式版本必须保证每次玩都不重样。6.2 cin吃掉了换行混合输入的老大难这是C控制台程序经久不衰的坑。我引入std::getline读取玩家名字之后发现第一次询问点数时输入什么都会被跳过。原因非常经典std::cin name只读取了字符串把末尾的换行符留在缓冲区紧接着的std::getline立刻读到一个空行直接返回。解决方式是在两种读取方式切换时清掉残留std::cin name; std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n);这个ignore就是个安全网。你也可以规定整个项目只用std::getline然后用std::stoi转整数一劳永逸不会混用出问题。对新人我更推荐统一用getlinestoi虽然多写几行但再也不用猜测缓冲区里还有什么。6.3 摸到牌就凑齐4张结算顺序必须想清楚最初版本里我的回合逻辑是“先处理询问结果再处理玩家的已有手牌分组”。结果出现了这么个情况玩家手里本来有3张点数7问了电脑拿到了第4张7但因为这轮先走了“摸牌”分支而不是“得分”分支导致这张7一直放在手牌里所以下一轮玩家又可以拿它继续和其他人换牌。这在一对一游戏里问题不大但手牌一直不清理会干扰hasRank的判断也让规则变得奇奇怪怪。修正方式就是前面代码里写的那样无论“对方交出牌”还是“自己摸牌”之后都要立即执行completeGroupIfPossible()。一次询问可能触发的连锁得分必须马上结算完然后才能进入下一回合。6.4 牌堆空、手牌空结束条件的优先级结束条件我用的是两级判断先看牌堆是否为空再看双方手牌是否都为空二者同时满足才结束。bool Game::isGameOver() const { bool noCardsInHands human.hasEmptyHand() ai.hasEmptyHand(); bool noCardsInDeck deck.isEmpty(); if (noCardsInDeck noCardsInHands) { return true; } if (human.getScore() WINNING_GROUPS || ai.getScore() WINNING_GROUPS) { return true; } return false; }这里特别要注意玩家手上没牌不代表游戏结束因为牌堆里还有牌的话轮到你时还要摸牌。只有牌堆空了且双方手牌都无法再提供新的牌源游戏才真正结束。另外一个容易被忽略的是“牌堆空了但某玩家手上有牌”的情况这时游戏继续但所有询问结果都是“没有就空过”直到有人把剩余手牌全部换成组分数。如果双方都没有牌了直接根据groupsScore判胜负。判负场景先判胜利避免出现“AI完成7组但双方手里还有牌”时游戏仍继续的弱智局面。最后说点我的个人体会。写这个小游戏代码量不过几百行但我每次带人做这个项目都会看到同样的收获理解了类与类之间的真实协作明白了为什么需要STL容器学会了用调试器而不是println满天飞也体会了用户输入有多不靠谱。如果你正在C的瓶颈期徘徊与其背一百个八股题不如抽一个晚上把小猫钓鱼从头写一遍。再把AI策略改改学学动态规划和概率决策这个小游戏就能陪你从入门一路走到进阶。
返回列表