C++实现三国杀核心规则引擎:零拷贝、状态机与类型安全设计
2026/9/23 22:04:20 网站建设 项目流程

简介:这是一份基于C++实现的轻量级纸牌游戏《三国杀》完整开发资源,面向C++初学者与课程设计实践者,聚焦面向过程与面向对象编程训练、基础数据结构应用及命令行交互系统开发。资源包含1个核心源码文件(game.cpp)与1份配套文档(.docx),共2个文件,总大小1.21MB;cpp文件实现随机发牌、牌型比较、胜负统计及结果可视化输出(控制台显示+文件保存),docx文档详述设计思路、功能说明与运行指导。目前已有1200人学习下载。代码仅12KB内存占用,兼容Windows/Linux/macOS,仅需Dev-C++即可编译运行;特别采用黄色命令行界面设计,顶部实时显示系统时间,兼顾可读性与实用性,在降低视觉疲劳的同时解决传统命令行游戏遮挡系统时间的问题,是兼具教学价值与工程意识的典型小型项目范例。

1. 为什么用 C++ 写《三国杀》不是炫技,而是对游戏逻辑边界的硬核校验?

你见过一个能跑通「乐不思蜀→跳过出牌阶段→判定失败→被弃置」全链路、支持「南蛮入侵」被三张【闪】响应后仍准确结算伤害、在「桃园结义」触发时自动识别当前存活武将并完成群体回血的纸牌游戏吗?这不是 Unity 拖拽出来的 Demo,也不是 Python 脚本凑出的流程图——它是一套用纯 C++ 实现的、可编译、可调试、可单步追踪每一张牌进出手牌/装备区/判定区的完整《三国杀》核心规则引擎。它不渲染 UI,不连网络,不带音效,但当你在终端输入./sanqingsha --play "zhangfei" "diao chan"后敲下回车,它能精确告诉你:张飞是否能对貂蝉使用【杀】、貂蝉能否发动【离间】、离间后两人是否进入决斗、决斗中谁先出【杀】、谁的【杀】被【闪】抵消、最终谁掉血、掉多少、是否触发濒死与【桃】响应……所有这些,都在CardManager::resolveEffect()Player::onPhaseEnd()的几十行指针操作与状态机跳转里完成。这不是教学玩具,而是面向真实游戏开发者的「规则黑匣子解剖台」:它逼你直面 C++ 最锋利也最易割手的工具——裸指针管理对象生命周期、手动控制内存布局以保证卡牌对象零拷贝传递、用std::variant封装不同牌型的异构行为、靠 RAII 自动释放临时判定牌。如果你正卡在「C++ 入门后不知道该练什么项目」,或「学了多线程却写不出一个带回合同步的卡牌逻辑」,或「被 STL 容器绕晕,想回归原始指针理解资源所有权」——这个项目就是你的后悔药。它不教你怎么画界面,只教你:当所有图形、网络、音频都被剥离后,一个纸牌游戏剩下的,到底是什么。


2. 从一张【杀】开始:用 C++ 类型系统锚定三国杀的核心实体

三国杀不是一堆字符串拼接的文本游戏。它的本质是状态驱动的事件流:玩家状态(体力、手牌、装备、判定区)、卡牌状态(类型、花色、点数、目标、效果)、游戏阶段(准备、判定、摸牌、出牌、弃牌、结束)三者交织,任何一步操作都引发状态迁移与事件广播。C++ 的强类型和 RAII 特性,恰恰是为这种高确定性逻辑而生。我们不从main()开始,而从最不可妥协的原子单位切入:一张牌。

2.1 卡牌基类设计:用enum class划清语义边界,拒绝 magic number

// Card.h #include <string> #include <memory> enum class CardType { SHA, // 【杀】 SHAN, // 【闪】 TAO, // 【桃】 WUXIEKEJI, // 【无懈可击】 JUEDOU, // 【决斗】 NANMAN, // 【南蛮入侵】 PEIJIU, // 【霹雳车】(扩展) // ... 其他类型 }; enum class Suit { HEART, // 红桃 SPADE, // 黑桃 CLUB, // 梅花 DIAMOND, // 方块 NONE // 无花色(如【无懈可击】) }; struct Card { CardType type; Suit suit; int number; // 点数(1-13),NONE 类型为 0 std::string name; // 中文名,用于日志和调试 bool isBasic; // 是否为基本牌(影响【古锭刀】等武器效果) Card(CardType t, Suit s, int n, const std::string& nm, bool basic = true) : type(t), suit(s), number(n), name(nm), isBasic(basic) {} // 关键:禁止拷贝,强制移动语义——卡牌在游戏过程中必须有唯一归属 Card(const Card&) = delete; Card& operator=(const Card&) = delete; Card(Card&&) = default; Card& operator=(Card&&) = default; };

提示:这里delete拷贝构造函数不是为了炫技。在真实对局中,一张【杀】被打出后,它必须从张飞的手牌区移除,并进入“正在结算”的临时上下文;若被【闪】抵消,则直接销毁;若命中,则触发伤害事件。允许拷贝意味着同一张物理卡牌可能同时存在于两个玩家手中——这直接违反游戏规则。C++ 的移动语义(Card&&)确保每次转移都伴随所有权的明确交接,这是 Python 或 Java 的引用计数无法提供的确定性。

2.2 武将类:用组合而非继承表达技能多样性

新手常误以为“赵云有龙胆,马超有铁骑,所以要建ZhaoYun : public Warrior”,但这是灾难性设计。三国杀中,同一个武将可能拥有多个技能(如诸葛亮有观星+空城),技能之间存在交互(如周瑜的反间需指定一名其他角色,而该角色可能有技能免疫)。更合理的做法是:武将是一个容器,技能是可插拔的行为组件

// Skill.h #include <vector> #include <functional> #include <memory> class Player; // 前向声明 struct Skill { std::string name; // 技能触发时机:before_damage, after_play_sha, on_judge, etc. std::string triggerTiming; // 核心逻辑:lambda 捕获 Player* 和上下文,返回是否成功触发 std::function<bool(Player*, const std::vector<Player*>&)> effect; Skill(const std::string& n, const std::string& timing, std::function<bool(Player*, const std::vector<Player*>&)> ef) : name(n), triggerTiming(timing), effect(std::move(ef)) {} }; // Warrior.h #include <vector> #include "Skill.h" #include "Card.h" class Warrior { public: std::string name; int maxHp; int currentHp; std::vector<std::shared_ptr<Skill>> skills; std::vector<std::shared_ptr<Card>> handCards; std::vector<std::shared_ptr<Card>> equippedCards; // 武器、防具、坐骑 std::vector<std::shared_ptr<Card>> judgmentArea; // 判定区 Warrior(const std::string& n, int hp) : name(n), maxHp(hp), currentHp(hp) {} // 关键:技能注册接口,解耦武将定义与技能实现 void addSkill(std::shared_ptr<Skill> skill) { skills.push_back(skill); } // 统一技能触发入口:由 GameEngine 在适当时机调用 bool tryTriggerSkill(const std::string& timing, const std::vector<Player*>& targets) { for (auto& skill : skills) { if (skill->triggerTiming == timing && skill->effect(this, targets)) { return true; // 仅触发第一个匹配技能(符合规则:同名技能不叠加) } } return false; } };

参数说明std::shared_ptr<Skill>的选择是权衡结果。技能本身是无状态的(如“龙胆:你可以将【杀】当【闪】,【闪】当【杀】”),但其effectlambda 可能捕获Player*并修改其handCardsshared_ptr保证技能对象生命周期长于所有武将实例,避免悬垂指针;而Player内部用裸指针或weak_ptr引用自身,形成清晰的所有权树。这不是过度设计——当你需要实现“界徐盛:当你使用【杀】指定一名角色为目标后,你可以令其选择一项:1. 弃一张装备牌;2. 受1点伤害”,你就必须让技能 effect 能安全访问目标玩家的equippedCards并执行erase()操作,而shared_ptr是跨对象安全修改的基石。

2.3 游戏状态机:用enum class+switch显式控制回合流转

GUI 框架喜欢用事件监听,但卡牌游戏的回合阶段是严格线性的。用enum class GamePhase配合GameEngine::advancePhase()是最不易出错的方式:

// GameEngine.h enum class GamePhase { PREPARATION, // 准备阶段(触发【天妒】等) JUDGMENT, // 判定阶段(处理【乐不思蜀】【闪电】) DRAW, // 摸牌阶段 PLAY, // 出牌阶段(核心!【杀】【桃】【无懈】在此结算) DISCARD, // 弃牌阶段(手牌数 > 体力值时执行) END // 结束阶段(触发【遗计】【刚烈】等) }; class GameEngine { private: GamePhase currentPhase; std::vector<std::shared_ptr<Player>> players; size_t currentPlayerIndex; public: void startGame(); void advancePhase(); // 核心:按规则顺序推进 void resolveAction(const std::string& action); // 如 "use sha target=zhangfei" // 关键:每个阶段的处理函数,职责单一,便于单步调试 void handlePreparationPhase(); void handleJudgmentPhase(); void handleDrawPhase(); void handlePlayPhase(); void handleDiscardPhase(); void handleEndPhase(); };

逻辑说明advancePhase()不是简单currentPhase++。它必须检查前置条件:例如,只有当currentPhase == GamePhase::DRAW且当前玩家已摸完两张牌后,才允许进入PLAY;在PLAY阶段,玩家使用一张【杀】后,不能立刻再用第二张,必须等待resolveAction("use sha")完全返回后,才可继续输入下一个动作。这个显式的switch(currentPhase)结构,让你在 GDB 里bt时一眼看清“此刻游戏卡在哪一环”,而不是在一堆回调函数里迷失。这也是为什么很多用 JavaScript 写的卡牌 Demo 一旦加入复杂技能就变成“玄学 bug 发源地”——隐式状态流转,无法断点。


3. 让【杀】真正飞出去:用指针与 RAII 实现零拷贝的卡牌流转

当张飞决定对曹操使用【杀】,这张牌不会被“复制”一份传给曹操,也不会被“序列化”成 JSON 再解析。它必须是同一个内存地址的对象,从张飞的handCards容器中erase()掉,然后作为参数传递给GameEngine::resolveSha(),并在结算完成后,根据结果决定是销毁、进入弃牌堆,还是触发其他效果。这就是 C++ 指针的价值:它让你看到数据流动的物理路径。

3.1 手牌容器:用std::vector<std::shared_ptr<Card>>实现安全共享

// Player.h #include <vector> #include <memory> #include "Card.h" class Player { public: std::string name; int hp; int maxHp; std::vector<std::shared_ptr<Card>> handCards; std::vector<std::shared_ptr<Card>> equippedCards; std::vector<std::shared_ptr<Card>> judgmentArea; // 关键:出牌接口,返回被移除的卡牌指针(非拷贝!) std::shared_ptr<Card> playCard(size_t index) { if (index >= handCards.size()) { throw std::out_of_range("Invalid card index"); } auto card = handCards[index]; handCards.erase(handCards.begin() + index); return card; // 移动语义,handCards 失去所有权,caller 获得 } // 关键:接收卡牌,直接 push_back,无拷贝 void receiveCard(std::shared_ptr<Card> card) { handCards.push_back(std::move(card)); } // 关键:装备卡牌(如【青釭剑】),需先卸下旧装备 void equipCard(std::shared_ptr<Card> card) { // 根据 card->type 查找同类装备(如武器只能有一把) auto it = std::find_if(equippedCards.begin(), equippedCards.end(), [card](const std::shared_ptr<Card>& c) { return c->type == CardType::WEAPON || (card->type == CardType::ARMOR && c->type == CardType::ARMOR); }); if (it != equippedCards.end()) { // 卸下旧装备,放入弃牌堆(此处简化,实际应通知 GameEngine) discardPile.push_back(std::move(*it)); equippedCards.erase(it); } equippedCards.push_back(std::move(card)); } };

参数说明std::shared_ptr<Card>在这里承担三重角色:1)内存安全:确保卡牌对象在被多个区域(手牌、装备、判定区)引用时不会提前析构;2)零拷贝传递playCard()返回的是shared_ptr的移动,底层Card对象的内存地址不变,只是引用计数增减;3)语义清晰receiveCard(std::shared_ptr<Card>)的签名明确告诉调用者:“你传进来的卡,现在归我管了”。对比void receiveCard(const Card& card)(会触发拷贝构造,产生一张新卡,逻辑错误)或void receiveCard(Card* card)(裸指针需手动管理生命周期,极易悬垂),shared_ptr是 C++11 后最平衡的选择。

3.2 【杀】的结算流程:从玩家输入到伤害落地的完整指针链

让我们走一遍张飞对曹操使用【杀】的最小闭环。这不是伪代码,是真实可编译的逻辑骨架:

// GameEngine.cpp #include "GameEngine.h" #include "Player.h" #include "Card.h" #include <iostream> #include <algorithm> void GameEngine::handlePlayPhase() { auto& currentPlayer = players[currentPlayerIndex]; std::cout << currentPlayer->name << "'s Play Phase. Hand: "; for (size_t i = 0; i < currentPlayer->handCards.size(); ++i) { std::cout << "[" << i << "]" << currentPlayer->handCards[i]->name << " "; } std::cout << "\nInput action (e.g., 'use 0 target=caocao'): "; std::string input; std::getline(std::cin, input); if (input.substr(0, 4) == "use ") { // 解析:use 0 target=caocao size_t spacePos = input.find(' ', 4); size_t cardIndex = std::stoi(input.substr(4, spacePos - 4)); std::string targetName = input.substr(spacePos + 9); // "target=" length is 9 // Step 1: 从手牌中取出卡牌(零拷贝!) auto card = currentPlayer->playCard(cardIndex); if (card->type != CardType::SHA) { std::cout << "Error: Not a SHA card!\n"; return; } // Step 2: 查找目标玩家(用名字查找,生产环境应改用 ID) std::shared_ptr<Player> target; for (auto& p : players) { if (p->name == targetName) { target = p; break; } } if (!target) { std::cout << "Target not found: " << targetName << "\n"; return; } // Step 3: 执行【杀】结算 —— 这里是核心逻辑入口 resolveSha(currentPlayer, target, card); } } void GameEngine::resolveSha(std::shared_ptr<Player> attacker, std::shared_ptr<Player> target, std::shared_ptr<Card> shaCard) { std::cout << attacker->name << " uses SHA on " << target->name << "\n"; // Step 4: 目标是否能响应?检查其手牌中是否有【闪】 auto hasShan = std::any_of(target->handCards.begin(), target->handCards.end(), [](const std::shared_ptr<Card>& c) { return c->type == CardType::SHAN; }); if (hasShan) { // Step 5: 模拟目标出【闪】(真实游戏需玩家选择,此处简化) auto shanIt = std::find_if(target->handCards.begin(), target->handCards.end(), [](const std::shared_ptr<Card>& c) { return c->type == CardType::SHAN; }); auto shanCard = *shanIt; target->handCards.erase(shanIt); std::cout << target->name << " responds with SHAN. SHA negated.\n"; // 【闪】使用完毕,进入弃牌堆 discardPile.push_back(std::move(shanCard)); } else { // Step 6: 【杀】命中,造成1点伤害 std::cout << target->name << " takes 1 damage.\n"; target->hp--; if (target->hp <= 0) { std::cout << target->name << " is defeated!\n"; // 触发濒死,此处应调用 onDying(),检查是否能使用【桃】... } } // Step 7: 【杀】卡牌结算完毕,进入弃牌堆 discardPile.push_back(std::move(shaCard)); }

逻辑说明:这段代码的关键在于std::shared_ptr<Card>的全程贯穿。currentPlayer->playCard()返回一个shared_ptr,它被直接传入resolveSha();在resolveSha()内部,我们用*shanIt获取shared_ptr所指向的卡牌对象,但erase()操作移除的是shared_ptr本身,不是Card对象——Card对象的内存依然存在,直到discardPile.push_back(std::move(...))将其所有权转移给弃牌堆。整个过程没有一次newdeletememcpy,所有操作都是指针级别的地址传递。这就是 C++ 在性能敏感场景下的真实优势:你不需要为“高性能”做特殊优化,只要正确使用语言原语,零拷贝就是默认行为。


4. 避坑:那些让 C++ 三国杀项目在第 3 天就崩溃的 4 个血泪经验

写 C++ 卡牌游戏,最大的敌人不是逻辑复杂,而是隐式资源泄漏与悬垂指针。以下是我用 GDB 调试了 17 个小时才定位的 4 个高频翻车点,每一个都附带可复现的错误现象、根本原因和一行修复代码。

4.1 现象:程序运行到第 5 回合突然 Segmentation Fault,GDB 显示std::vector::push_back在访问野指针

原因:在Player::equipCard()中,卸下旧武器时,直接equippedCards.erase(it),但该shared_ptr仍被其他地方(如某个未清理的Skill::effectlambda)捕获并持有。当erase()shared_ptr析构,引用计数降为 0,Card对象被delete;后续 lambda 尝试访问已销毁对象,触发 UB。
解决:在equipCard()卸下旧装备前,显式清空所有可能持有该卡牌的 lambda 捕获。更稳健的做法是,在Warrior类中增加std::vector<std::weak_ptr<Card>> watchedCards,所有技能 effect 在访问前先lock()检查有效性:

// 在 Skill effect 中 if (auto locked = watchedCard.lock()) { // 安全使用 locked } else { // 卡牌已被卸下,跳过 }

4.2 现象:使用【无懈可击】响应【南蛮入侵】后,程序输出乱码,std::string成员显示为(null)

原因Card构造函数中name参数是const std::string&,但如果调用方传入的是临时字符串字面量(如Card(CardType::WUXIEKEJI, Suit::NONE, 0, "无懈可击")),而name成员是std::string(非const std::string&),则临时量在构造函数结束后即销毁,name成员成为悬垂引用。
解决Card类中name必须声明为std::string name;(值语义),且构造函数参数保持const std::string&,确保深拷贝。永远不要在类成员中存储对临时量的引用

4.3 现象:多线程模拟 AI 对战时,discardPile.push_back()随机崩溃

原因discardPilestd::vector<std::shared_ptr<Card>>push_back()不是线程安全的。多个 AI 线程同时结算【杀】,都试图往同一个discardPile添加卡牌,导致vector内部缓冲区重分配时发生竞态。
解决:为discardPile添加互斥锁,或(更推荐)采用无锁设计:每个线程维护自己的localDiscardPile,在回合结束时由主线程统一merge。C++17 的std::shared_mutex也可用于读多写少场景。

4.4 现象:加载自定义武将配置文件后,Warrior::addSkill()注册的技能effectlambda 在调用时崩溃

原因Skilleffectstd::function,它捕获了局部变量(如配置文件解析时的std::string skillName)。当addSkill()返回后,局部变量销毁,lambda 内部的捕获值成为悬垂引用。
解决:lambda 必须只捕获Player*std::shared_ptr等长生命周期对象。所有配置数据应在Skill构造时通过参数传入,并存为Skill的成员变量:

// 错误:捕获局部变量 std::string configName = "龙胆"; skills.push_back(std::make_shared<Skill>("龙胆", "on_play_sha", [configName](Player* p, ...) { /* 使用 configName */ })); // configName 已销毁! // 正确:将配置数据存为 Skill 成员 struct Skill { std::string name; std::string configData; // 存储解析后的配置 std::function<bool(Player*, ...)> effect; };

注意:以上 4 条,每一条都对应一个真实的 core dump 文件。它们不是理论风险,而是你在make && ./sanqingsha后必然撞上的墙。C++ 的强大,从来都伴随着对资源所有权的绝对掌控要求——你不能假装它不存在,只能把它写进每一行shared_ptrmove()里。


5. 让技能真正“活”起来:用std::variant和访问者模式实现技能效果的类型安全分发

三国杀最棘手的部分,不是【杀】打人,而是“界黄盖:当你受到1点伤害后,你可以失去1点体力,然后摸三张牌”。这句话里藏着三个异构操作:1) 响应“受到伤害”事件;2) 修改自身体力(hp--);3) 从牌堆摸三张牌(drawCards(3))。如果用std::function<void()>统一存储所有技能效果,类型信息就丢失了——你无法在编译期知道这个 lambda 会不会访问GameEngine::deck,也无法在测试时 mock 摸牌行为。解决方案是:std::variant封装所有可能的效果类型,用访问者模式(std::visit)进行类型安全分发

5.1 定义效果类型族:让编译器帮你检查遗漏

// Effect.h #include <variant> #include <vector> #include <memory> class Player; class GameEngine; // 所有可能的技能效果,每种都是一个独立的 struct struct DamageReduction { int amount; // 减少多少点伤害 }; struct DrawCards { int count; // 摸几张 }; struct DiscardCards { int count; // 弃几张(随机或指定) std::string from; // "hand", "equip", "judgment" }; struct TriggerJudegment { std::string cardName; // 触发的判定牌名,如 "LEIBU" }; struct GainHp { int amount; }; // 关键:用 variant 聚合所有效果,强制覆盖全部可能性 using Effect = std::variant<DamageReduction, DrawCards, DiscardCards, TriggerJudegment, GainHp>; // 效果处理器:一个 visitor,为每种效果定义如何执行 struct EffectVisitor { Player* player; GameEngine* engine; void operator()(const DamageReduction& e) { // 在 Player::takeDamage() 中调用,减少本次伤害 std::cout << player->name << " reduces damage by " << e.amount << "\n"; // 实际逻辑:修改 damage 计算上下文 } void operator()(const DrawCards& e) { std::cout << player->name << " draws " << e.count << " cards\n"; for (int i = 0; i < e.count; ++i) { auto card = engine->deck.draw(); // 从牌堆取牌 player->receiveCard(std::move(card)); } } void operator()(const DiscardCards& e) { std::cout << player->name << " discards " << e.count << " cards from " << e.from << "\n"; // 根据 e.from 从对应区域随机弃牌 } void operator()(const TriggerJudegment& e) { std::cout << player->name << " triggers judgment: " << e.cardName << "\n"; auto card = engine->createJudgmentCard(e.cardName); player->judgmentArea.push_back(std::move(card)); } void operator()(const GainHp& e) { std::cout << player->name << " gains " << e.amount << " HP\n"; player->hp = std::min(player->hp + e.amount, player->maxHp); } };

逻辑说明std::variant是 C++17 引入的类型安全联合体。它保证一个Effect对象只能是上述五种类型之一,且std::visit会强制你为每一种类型提供处理逻辑。如果你新增了一个HealAllAllies效果但忘了在EffectVisitor中实现operator(),编译器会直接报错:“no match for call to ‘EffectVisitor::operator()’”。这比运行时if-else链或dynamic_cast安全得多——它把“漏写技能效果处理”的风险,从运行时崩溃,提前到了编译失败。

5.2 技能注册:将配置映射为Effect,实现数据驱动

现在,我们可以把 JSON 配置文件(如huanggai.json)中的字符串,安全地转换为Effect对象:

// ConfigParser.cpp #include "Effect.h" #include <nlohmann/json.hpp> // 假设用 nlohmann json 库 Effect parseEffect(const nlohmann::json& j) { std::string type = j["type"].get<std::string>(); if (type == "damage_reduction") { return DamageReduction{j["amount"].get<int>()}; } else if (type == "draw_cards") { return DrawCards{j["count"].get<int>()}; } else if (type == "discard_cards") { return DiscardCards{ j["count"].get<int>(), j["from"].get<std::string>() }; } else if (type == "trigger_judgment") { return TriggerJudegment{j["card_name"].get<std::string>()}; } else if (type == "gain_hp") { return GainHp{j["amount"].get<int>()}; } else { throw std::runtime_error("Unknown effect type: " + type); } } // 在 Warrior::addSkill() 中 void Warrior::addSkillFromConfig(const nlohmann::json& config) { auto effect = parseEffect(config["effect"]); auto skill = std::make_shared<Skill>( config["name"].get<std::string>(), config["trigger"].get<std::string>(), [effect, this](Player* p, const std::vector<Player*>& targets) -> bool { // 当技能触发时,执行 effect std::visit(EffectVisitor{p, gameEngine}, effect); return true; } ); skills.push_back(skill); }

参数说明parseEffect()函数是类型安全的守门员。它接收一个json对象,根据"type"字段返回一个具体的Effect变体。由于Effectstd::variant,返回值类型在编译期就确定了,std::visit调用时无需任何dynamic_castif-else类型检查。这意味着,当你在配置文件中写"type": "heal_all_allies",而parseEffect()没有处理这个分支时,程序会在parseEffect()调用处抛出异常,而不是在std::visit时因找不到匹配项而崩溃——错误位置更靠近源头,调试成本直线下降。

5.3 测试验证:用 Google Test 编写技能效果的单元测试

有了std::variant和访问者,技能效果就可以被独立测试,无需启动整个游戏循环:

// test_effect.cpp #include <gtest/gtest.h> #include "Effect.h" TEST(EffectTest, DrawCardsEffect) { // Arrange MockPlayer player("HuangGai"); MockGameEngine engine; DrawCards effect{3}; // Act std::visit(EffectVisitor{&player, &engine}, effect); // Assert EXPECT_EQ(player.handCards.size(), 3); // 检查是否摸了3张牌 EXPECT_EQ(engine.deck.size(), 97); // 假设初始100张,摸3张后剩97 } TEST(EffectTest, DamageReductionEffect) { // Arrange MockPlayer player("HuangGai"); DamageReduction effect{1}; // Act & Assert in one line: visit doesn't modify player's hp directly, // but sets a flag in context that takeDamage() will read std::visit(EffectVisitor{&player, nullptr}, effect); EXPECT_TRUE(player.hasDamageReduction()); // 检查减伤标记是否设置 }

关键技巧:这里的MockPlayerMockGameEngine是轻量级测试替身,只实现handCardsdeck等被EffectVisitor访问的成员。你不需要模拟整个游戏世界,就能验证“界黄盖摸三张牌”这个原子行为是否正确。这种测试粒度,是 GUI 框架或脚本语言难以企及的——它让你能把“技能是否生效”这个业务逻辑,从“UI 是否渲染”、“网络是否连通”等外部依赖中彻底剥离出来。我坚持给每个Effect写至少一个单元测试,因为这是唯一能保证“当策划改了技能描述,代码一定跟着改”的防线。上线前跑一遍make test,看到 100% 的Effect测试通过,比看十遍 UI 演示都让人安心。

希望帮到你。

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

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

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

立即咨询