C++实现三国杀核心规则引擎:零拷贝、状态机与类型安全设计
简介这是一份基于C实现的轻量级纸牌游戏《三国杀》完整开发资源面向C初学者与课程设计实践者聚焦面向过程与面向对象编程训练、基础数据结构应用及命令行交互系统开发。资源包含1个核心源码文件game.cpp与1份配套文档.docx共2个文件总大小1.21MBcpp文件实现随机发牌、牌型比较、胜负统计及结果可视化输出控制台显示文件保存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-13NONE 类型为 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::functionbool(Player*, const std::vectorPlayer*) effect; Skill(const std::string n, const std::string timing, std::functionbool(Player*, const std::vectorPlayer*) 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::vectorstd::shared_ptrSkill skills; std::vectorstd::shared_ptrCard handCards; std::vectorstd::shared_ptrCard equippedCards; // 武器、防具、坐骑 std::vectorstd::shared_ptrCard judgmentArea; // 判定区 Warrior(const std::string n, int hp) : name(n), maxHp(hp), currentHp(hp) {} // 关键技能注册接口解耦武将定义与技能实现 void addSkill(std::shared_ptrSkill skill) { skills.push_back(skill); } // 统一技能触发入口由 GameEngine 在适当时机调用 bool tryTriggerSkill(const std::string timing, const std::vectorPlayer* targets) { for (auto skill : skills) { if (skill-triggerTiming timing skill-effect(this, targets)) { return true; // 仅触发第一个匹配技能符合规则同名技能不叠加 } } return false; } };参数说明std::shared_ptrSkill的选择是权衡结果。技能本身是无状态的如“龙胆你可以将【杀】当【闪】【闪】当【杀】”但其effectlambda 可能捕获Player*并修改其handCards。shared_ptr保证技能对象生命周期长于所有武将实例避免悬垂指针而Player内部用裸指针或weak_ptr引用自身形成清晰的所有权树。这不是过度设计——当你需要实现“界徐盛当你使用【杀】指定一名角色为目标后你可以令其选择一项1. 弃一张装备牌2. 受1点伤害”你就必须让技能 effect 能安全访问目标玩家的equippedCards并执行erase()操作而shared_ptr是跨对象安全修改的基石。2.3 游戏状态机用enum classswitch显式控制回合流转GUI 框架喜欢用事件监听但卡牌游戏的回合阶段是严格线性的。用enum class GamePhase配合GameEngine::advancePhase()是最不易出错的方式// GameEngine.h enum class GamePhase { PREPARATION, // 准备阶段触发【天妒】等 JUDGMENT, // 判定阶段处理【乐不思蜀】【闪电】 DRAW, // 摸牌阶段 PLAY, // 出牌阶段核心【杀】【桃】【无懈】在此结算 DISCARD, // 弃牌阶段手牌数 体力值时执行 END // 结束阶段触发【遗计】【刚烈】等 }; class GameEngine { private: GamePhase currentPhase; std::vectorstd::shared_ptrPlayer players; size_t currentPlayerIndex; public: void startGame(); void advancePhase(); // 核心按规则顺序推进 void resolveAction(const std::string action); // 如 use sha targetzhangfei // 关键每个阶段的处理函数职责单一便于单步调试 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::vectorstd::shared_ptrCard实现安全共享// Player.h #include vector #include memory #include Card.h class Player { public: std::string name; int hp; int maxHp; std::vectorstd::shared_ptrCard handCards; std::vectorstd::shared_ptrCard equippedCards; std::vectorstd::shared_ptrCard judgmentArea; // 关键出牌接口返回被移除的卡牌指针非拷贝 std::shared_ptrCard 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_ptrCard card) { handCards.push_back(std::move(card)); } // 关键装备卡牌如【青釭剑】需先卸下旧装备 void equipCard(std::shared_ptrCard card) { // 根据 card-type 查找同类装备如武器只能有一把 auto it std::find_if(equippedCards.begin(), equippedCards.end(), [card](const std::shared_ptrCard 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_ptrCard在这里承担三重角色1)内存安全确保卡牌对象在被多个区域手牌、装备、判定区引用时不会提前析构2)零拷贝传递playCard()返回的是shared_ptr的移动底层Card对象的内存地址不变只是引用计数增减3)语义清晰receiveCard(std::shared_ptrCard)的签名明确告诉调用者“你传进来的卡现在归我管了”。对比void receiveCard(const Card card)会触发拷贝构造产生一张新卡逻辑错误或void receiveCard(Card* card)裸指针需手动管理生命周期极易悬垂shared_ptr是 C11 后最平衡的选择。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 targetcaocao): ; std::string input; std::getline(std::cin, input); if (input.substr(0, 4) use ) { // 解析use 0 targetcaocao 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_ptrPlayer 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_ptrPlayer attacker, std::shared_ptrPlayer target, std::shared_ptrCard 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_ptrCard c) { return c-type CardType::SHAN; }); if (hasShan) { // Step 5: 模拟目标出【闪】真实游戏需玩家选择此处简化 auto shanIt std::find_if(target-handCards.begin(), target-handCards.end(), [](const std::shared_ptrCard 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_ptrCard的全程贯穿。currentPlayer-playCard()返回一个shared_ptr它被直接传入resolveSha()在resolveSha()内部我们用*shanIt获取shared_ptr所指向的卡牌对象但erase()操作移除的是shared_ptr本身不是Card对象——Card对象的内存依然存在直到discardPile.push_back(std::move(...))将其所有权转移给弃牌堆。整个过程没有一次new、delete或memcpy所有操作都是指针级别的地址传递。这就是 C 在性能敏感场景下的真实优势你不需要为“高性能”做特殊优化只要正确使用语言原语零拷贝就是默认行为。4. 避坑那些让 C 三国杀项目在第 3 天就崩溃的 4 个血泪经验写 C 卡牌游戏最大的敌人不是逻辑复杂而是隐式资源泄漏与悬垂指针。以下是我用 GDB 调试了 17 个小时才定位的 4 个高频翻车点每一个都附带可复现的错误现象、根本原因和一行修复代码。4.1 现象程序运行到第 5 回合突然 Segmentation FaultGDB 显示std::vector::push_back在访问野指针原因在Player::equipCard()中卸下旧武器时直接equippedCards.erase(it)但该shared_ptr仍被其他地方如某个未清理的Skill::effectlambda捕获并持有。当erase()后shared_ptr析构引用计数降为 0Card对象被delete后续 lambda 尝试访问已销毁对象触发 UB。解决在equipCard()卸下旧装备前显式清空所有可能持有该卡牌的 lambda 捕获。更稳健的做法是在Warrior类中增加std::vectorstd::weak_ptrCard 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()随机崩溃原因discardPile是std::vectorstd::shared_ptrCardpush_back()不是线程安全的。多个 AI 线程同时结算【杀】都试图往同一个discardPile添加卡牌导致vector内部缓冲区重分配时发生竞态。解决为discardPile添加互斥锁或更推荐采用无锁设计每个线程维护自己的localDiscardPile在回合结束时由主线程统一merge。C17 的std::shared_mutex也可用于读多写少场景。4.4 现象加载自定义武将配置文件后Warrior::addSkill()注册的技能effectlambda 在调用时崩溃原因Skill的effect是std::function它捕获了局部变量如配置文件解析时的std::string skillName。当addSkill()返回后局部变量销毁lambda 内部的捕获值成为悬垂引用。解决lambda 必须只捕获Player*或std::shared_ptr等长生命周期对象。所有配置数据应在Skill构造时通过参数传入并存为Skill的成员变量// 错误捕获局部变量 std::string configName 龙胆; skills.push_back(std::make_sharedSkill(龙胆, on_play_sha, [configName](Player* p, ...) { /* 使用 configName */ })); // configName 已销毁 // 正确将配置数据存为 Skill 成员 struct Skill { std::string name; std::string configData; // 存储解析后的配置 std::functionbool(Player*, ...) effect; };注意以上 4 条每一条都对应一个真实的 core dump 文件。它们不是理论风险而是你在make ./sanqingsha后必然撞上的墙。C 的强大从来都伴随着对资源所有权的绝对掌控要求——你不能假装它不存在只能把它写进每一行shared_ptr和move()里。5. 让技能真正“活”起来用std::variant和访问者模式实现技能效果的类型安全分发三国杀最棘手的部分不是【杀】打人而是“界黄盖当你受到1点伤害后你可以失去1点体力然后摸三张牌”。这句话里藏着三个异构操作1) 响应“受到伤害”事件2) 修改自身体力hp--3) 从牌堆摸三张牌drawCards(3)。如果用std::functionvoid()统一存储所有技能效果类型信息就丢失了——你无法在编译期知道这个 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::variantDamageReduction, 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是 C17 引入的类型安全联合体。它保证一个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].getstd::string(); if (type damage_reduction) { return DamageReduction{j[amount].getint()}; } else if (type draw_cards) { return DrawCards{j[count].getint()}; } else if (type discard_cards) { return DiscardCards{ j[count].getint(), j[from].getstd::string() }; } else if (type trigger_judgment) { return TriggerJudegment{j[card_name].getstd::string()}; } else if (type gain_hp) { return GainHp{j[amount].getint()}; } 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_sharedSkill( config[name].getstd::string(), config[trigger].getstd::string(), [effect, this](Player* p, const std::vectorPlayer* targets) - bool { // 当技能触发时执行 effect std::visit(EffectVisitor{p, gameEngine}, effect); return true; } ); skills.push_back(skill); }参数说明parseEffect()函数是类型安全的守门员。它接收一个json对象根据type字段返回一个具体的Effect变体。由于Effect是std::variant返回值类型在编译期就确定了std::visit调用时无需任何dynamic_cast或if-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 doesnt modify players hp directly, // but sets a flag in context that takeDamage() will read std::visit(EffectVisitor{player, nullptr}, effect); EXPECT_TRUE(player.hasDamageReduction()); // 检查减伤标记是否设置 }关键技巧这里的MockPlayer和MockGameEngine是轻量级测试替身只实现handCards和deck等被EffectVisitor访问的成员。你不需要模拟整个游戏世界就能验证“界黄盖摸三张牌”这个原子行为是否正确。这种测试粒度是 GUI 框架或脚本语言难以企及的——它让你能把“技能是否生效”这个业务逻辑从“UI 是否渲染”、“网络是否连通”等外部依赖中彻底剥离出来。我坚持给每个Effect写至少一个单元测试因为这是唯一能保证“当策划改了技能描述代码一定跟着改”的防线。上线前跑一遍make test看到 100% 的Effect测试通过比看十遍 UI 演示都让人安心。希望帮到你。本文还有配套的精品资源点击获取