ARTICLE DETAIL

资讯详情

深耕网站SEO优化与搜索引擎排名提升的一线实战洞察。

C++实现复杂卡牌游戏逻辑:面向对象设计与事件驱动架构实战

C++实现复杂卡牌游戏逻辑:面向对象设计与事件驱动架构实战 1. 项目概述与核心挑战用C实现一个像《三国杀》这样复杂的卡牌游戏逻辑听起来像是一个“从入门到放弃”的经典项目。但如果你真的想深入理解面向对象设计、状态机管理、事件驱动和复杂规则引擎这绝对是一个绝佳的练手机会。我花了几个月时间从零开始用C撸了一个命令行版本的三国杀核心逻辑踩遍了所有的坑也收获了一套非常扎实的工程实践。这个项目远不止是“写个游戏”那么简单它本质上是在构建一个高内聚、低耦合、易于扩展的规则系统。你需要处理上百张功能各异的卡牌、几十个拥有独特技能的武将、以及一个环环相扣的回合与响应流程。用C来做意味着你要在追求性能和控制力的同时与内存管理、多态设计和清晰的架构搏斗。本文将带你深入这个“战场”从最核心的卡牌与武将设计模式到最烧脑的回合与响应流程状态机分享一套经过实战检验的C实现方案。2. 整体架构设计面向对象与组件化思维在动手写第一行代码之前架构设计决定了你的项目是优雅地演进还是一团乱麻。三国杀的逻辑非常适合用面向对象来建模但简单的继承层次很快就会变得臃肿不堪。我的核心思路是“实体-组件-系统”ECS的轻量级变体结合经典的责任链模式。2.1 核心类关系与职责划分我并没有采用严格的ECS而是吸收了其“组合优于继承”的思想。整个游戏世界由几个核心管理器驱动GameEngine (游戏引擎)这是单例是整个游戏的心脏。它不关心具体的卡牌效果只负责驱动游戏流程初始化、回合切换、阶段推进、事件广播和游戏结束判定。它持有一个全局的EventDispatcher事件分发器。Player (玩家)这是一个相对轻量的类主要保存状态信息体力值、手牌列表、装备区、判定区、身份、是否已死亡等。它不处理复杂的逻辑只是一个数据容器。Card (卡牌基类)所有卡牌的抽象基类。它包含卡牌的基本属性花色、点数、名称、类型以及一个纯虚函数bool use(GameContext context)。关键在于Card对象本身是无状态的同一张“杀”的实例可以被多个玩家使用效果由具体的派生类决定。Skill (技能接口)这是一个关键抽象。我将武将技能也对象化定义为ISkill接口包含方法如bool canTrigger(const GameEvent event)和void trigger(GameContext context)。每个武将对象持有一个技能列表。这比通过庞大的switch-case或在武将子类中重写无数方法要清晰得多。GameContext (游戏上下文)这是一个贯穿整个游戏流程的结构体或轻量类包含了当前游戏的所有动态信息当前回合玩家、当前阶段、当前正在处理的卡牌/技能、当前目标列表等。它被传递给卡牌的use方法和技能的trigger方法作为它们影响世界的唯一入口。EventDispatcher (事件分发器)这是实现复杂响应链的核心。游戏中的任何动作如“使用一张牌”、“造成一点伤害”、“回合开始”都抽象为一个GameEvent对象。EventDispatcher负责将这个事件通知给所有监听者如玩家的技能、装备牌的效果、全局游戏状态监听者根据canTrigger决定是否响应并可能插入新的GameEvent形成链式反应。// 事件类型枚举示例 enum class EventType { PhaseStart, // 阶段开始 PhaseEnd, // 阶段结束 BeforeUseCard, // 使用牌前 OnUseCard, // 使用牌时 AfterUseCard, // 使用牌后 BeforeDamage, // 造成伤害前 OnDamage, // 造成伤害时 AfterDamage, // 造成伤害后 BeforeHeal, // 回复体力前 OnDying, // 濒死时 OnDeath, // 死亡时 // ... 更多事件 }; struct GameEvent { EventType type; std::shared_ptrPlayer source; // 事件源如出牌者 std::vectorstd::shared_ptrPlayer targets; // 事件目标 std::shared_ptrCard card; // 关联的卡牌 std::shared_ptrISkill skill; // 关联的技能 int value; // 伤害值、回复值等 // ... 其他上下文信息 };设计心得很多初学者会试图在Player或Card的类方法里直接修改其他玩家的状态这会导致代码高度耦合调试地狱。通过EventDispatcher你将逻辑解耦。例如“关羽”的“武圣”技能只需要监听BeforeUseCard事件当检测到使用的卡牌是红色且可当【杀】时将其替换为一张虚拟的【杀】卡牌即可完全不用修改卡牌使用的主流程。2.2 管理器的协同工作GameEngine像导演按照剧本回合流程喊“开始”。EventDispatcher像现场的场记和副导演负责协调所有演员技能、卡牌在正确的时间点做出反应。GameContext记录了当前拍摄的场次、镜头和演员状态。例如在出牌阶段玩家选择一张【过河拆桥】指定一个目标GameEngine设置GameContext的当前阶段为PlayPhase。玩家操作触发GameEngine创建一个EventType::BeforeUseCard事件。EventDispatcher广播该事件。此时目标玩家装备的【仁王盾】技能如果存在可以监听此事件若卡牌是黑色【过河拆桥】则通过返回false等方式声明“此牌对我无效”。如果无人阻止GameEngine调用卡牌Dismantlement::use(GameContext)方法。在use方法内部它可能会触发EventType::OnUseCard和EventType::AfterUseCard事件进而触发其他技能如【无懈可击】。3. 卡牌系统的设计与实现多态与数据驱动卡牌是游戏的血液。三国杀的卡牌种类繁多效果各异但可以归纳为几个大类基本牌、锦囊牌、装备牌。用C实现核心在于利用多态和工厂模式。3.1 卡牌类的继承体系我设计了一个清晰的继承层次Card (抽象基类) ├── BasicCard (基本牌) │ ├── KillCard (杀) │ ├── DodgeCard (闪) │ └── PeachCard (桃) ├── TrickCard (锦囊牌) │ ├── SingleTargetTrick (单目标锦囊) │ │ ├── DuelCard (决斗) │ │ └── DismantlementCard (过河拆桥) │ └── MultiTargetTrick (多目标锦囊) │ ├── ArcheryAttackCard (万箭齐发) │ └── HarvestCard (五谷丰登) └── EquipmentCard (装备牌) ├── WeaponCard (武器) ├── ArmorCard (防具) └── HorseCard (坐骑)Card基类主要提供ID、名称、花色点数、卡牌类型等属性和一个use接口。派生类实现具体的use逻辑。关键技巧使用“模板方法”模式处理通用流程。例如所有锦囊牌的使用都可能涉及“指定目标”-“生效前无懈可击”-“生效”-“生效后”的流程。可以在TrickCard::use中定义这个骨架让子类实现具体的生效逻辑void applyEffect(GameContext)。bool TrickCard::use(GameContext context) override { // 1. 选择目标 (由子类或游戏引擎根据卡牌规则提供) if (!chooseTargets(context)) return false; // 2. 触发“使用牌前”事件检测无懈可击等 GameEvent beforeEvent(EventType::BeforeUseCard, context.currentPlayer, context.targets, shared_from_this()); if (!context.dispatcher.dispatch(beforeEvent)) { // 被无懈可击或其他技能抵消 return false; } // 3. 触发“使用牌时”事件 GameEvent onEvent(EventType::OnUseCard, context.currentPlayer, context.targets, shared_from_this()); context.dispatcher.dispatch(onEvent); // 通常不阻断 // 4. 执行卡牌具体效果子类实现 applyEffect(context); // 5. 触发“使用牌后”事件 GameEvent afterEvent(EventType::AfterUseCard, context.currentPlayer, context.targets, shared_from_this()); context.dispatcher.dispatch(afterEvent); // 6. 将使用的牌移入弃牌堆 moveToDiscardPile(context); return true; }3.2 卡牌效果的实现策略模式注入有些卡牌效果非常复杂比如【乐不思蜀】的判定、【闪电】的传递。如果把这些逻辑硬编码在卡牌类里类会爆炸。我的做法是引入“效果组件”EffectComponent。为每一类独立的效果如“判定效果”、“伤害效果”、“抽牌效果”、“移动装备效果”定义一个EffectComponent类。卡牌对象在构造时可以组合一个或多个效果组件。class JudgementEffect : public EffectComponent { public: JudgementEffect(const Card* judgementCard) : m_judgementCard(judgementCard) {} void execute(GameContext context) override { // 执行判定流程从牌堆顶翻一张判定牌与m_judgementCard比较花色点数... // 根据结果对context.targets[0]进行后续操作跳过出牌/受到伤害等 } private: const Card* m_judgementCard; }; // 乐不思蜀卡牌 class IndulgenceCard : public TrickCard { public: IndulgenceCard() { // 组合一个“判定效果”组件判定牌是红桃 m_effects.push_back(std::make_uniqueJudgementEffect(new Card(Suit::Heart, Number::ANY))); // 假设ANY代表任意点数 // 判定失败的效果跳过出牌阶段 m_effects.push_back(std::make_uniqueSkipPhaseEffect(Phase::Play)); } bool chooseTargets(GameContext context) override { /* 选择一名其他角色 */ } private: std::vectorstd::unique_ptrEffectComponent m_effects; };这种方式将卡牌数据的定义花色、点数、名称和其游戏行为效果部分解耦非常利于通过配置文件如JSON来定义新卡牌实现数据驱动。避坑指南小心循环引用和智能指针。Card对象可能持有EffectComponent而EffectComponent可能又需要引用特定的Card如判定牌。使用std::shared_ptr和std::weak_ptr管理生命周期避免内存泄漏。对于像基础牌这样无复杂状态、可大量复用的对象可以考虑使用享元模式Flyweight只保存一份实例使用时复制上下文信息。4. 武将技能系统接口、监听与动态绑定武将技能是三国杀的灵魂也是最复杂的部分。技能千奇百怪有主动技、被动技、锁定技、限定技。我的实现核心是将每个技能建模为一个实现了ISkill接口的独立对象并动态绑定到玩家身上。4.1 技能接口与事件监听class ISkill { public: virtual ~ISkill() default; // 技能名称、所属武将等 virtual std::string getName() const 0; // 判断此技能是否可以在当前事件下触发 virtual bool canTrigger(const GameEvent event, std::shared_ptrPlayer owner) const 0; // 触发技能效果 virtual void trigger(GameContext context, std::shared_ptrPlayer owner) 0; // 技能类型主动、被动、锁定、限定 virtual SkillType getType() const 0; // 对于主动技检查发动条件如距离、手牌数 virtual bool isActivatable(const GameContext context, std::shared_ptrPlayer owner) const { return true; } };一个玩家对象持有一个std::vectorstd::shared_ptrISkill技能列表。在游戏初始化时根据玩家选择的武将向该列表中添加相应的技能对象。技能如何生效通过向EventDispatcher注册监听器。在Player类中有一个方法registerSkills()它会遍历自己的所有技能将技能对象注册到全局事件分发器中。当事件发生时分发器会回调技能的canTrigger和trigger方法。// 示例关羽的“武圣”技能锁定技 class WushengSkill : public ISkill { public: SkillType getType() const override { return SkillType::LOCKED; } bool canTrigger(const GameEvent event, std::shared_ptrPlayer owner) const override { // 锁定技只要事件是“使用牌前”且所有者是事件源就考虑触发 return event.type EventType::BeforeUseCard event.source owner; } void trigger(GameContext context, std::shared_ptrPlayer owner) override { auto card event.card; // 检查使用的牌是否是红色 if (card-isRed() card-canBeConvertedTo(Kill)) { // 将这张牌临时替换为一张【杀】 context.convertedCard std::make_sharedKillCard(); // 注意这里并不消耗原牌只是改变了其“类型” } } };4.2 复杂技能的实现状态机与标记有些技能需要维护内部状态或标记例如张飞的“咆哮”出牌阶段可出无限张【杀】、孙尚香的“枭姬”失去装备牌时摸牌。对于这类技能技能对象本身需要有一些成员变量来保存状态。同时这些状态可能需要跨回合持久化。我的做法是在Player类中增加一个std::mapstd::string, int或std::mapstd::string, std::any来存储“玩家标记”技能可以读写这些标记。// 示例张飞“咆哮”技能 class PaoxiaoSkill : public ISkill { public: bool canTrigger(const GameEvent event, std::shared_ptrPlayer owner) const override { // 在出牌阶段每次使用【杀】时检查是否受到出【杀】次数限制 return event.type EventType::OnUseCard event.card-getName() Kill event.source owner context.currentPhase Phase::Play; } void trigger(GameContext context, std::shared_ptrPlayer owner) override { // 关键清除“本回合已使用杀次数”的标记或者直接设置一个“无视杀次数限制”的标记 owner-setMark(IgnoreKillLimit, 1); // 或者更精确地在每次出杀后不增加计数 } }; // 在游戏引擎的出牌阶段逻辑中 void GameEngine::playPhase(Player player) { int killUsed player.getMark(KillUsedTimes); int killLimit (player.hasMark(IgnoreKillLimit)) ? INT_MAX : 1; // 默认限制为1 // ... 处理出牌逻辑 }实操心得技能优先级和冲突解决是难点。例如当一名角色同时拥有“武圣”红色牌当杀和“青龙偃月刀”的技能出杀被闪后可再出杀事件触发顺序如何我引入了一个“技能优先级”字段在EventDispatcher中对同一事件的监听器按优先级排序。通常装备技能优先级高于武将技能锁定技优先级高于非锁定技。同时在技能trigger方法中可以通过修改GameContext或设置标记来影响后续技能的触发判断。5. 回合流程与状态机驱动游戏的核心引擎回合流程是游戏的骨架它必须严谨地处理每个阶段并正确触发所有时机。我将其实现为一个显式的状态机State Machine。5.1 回合阶段枚举与状态转换首先明确定义所有阶段和子阶段enum class Phase { RoundStart, // 回合开始前用于“回合开始阶段”之前的时机如神诸葛的七星 Start, // 回合开始阶段 Judge, // 判定阶段 Draw, // 摸牌阶段 Play, // 出牌阶段 Discard, // 弃牌阶段 RoundEnd, // 回合结束阶段 GameOver // 游戏结束特殊状态 }; // 每个阶段内可能还有子状态特别是出牌阶段 enum class PlaySubPhase { Start, // 出牌阶段开始 BeforePlay, // 出牌前例如发动“直言” Playing, // 正在出牌主循环 AfterPlay, // 出牌后例如发动“制衡” End // 出牌阶段结束 };GameEngine中有一个主循环void run()它持续运行直到游戏结束。每一轮循环代表一个玩家的一个阶段或子阶段的推进。5.2 阶段处理函数与事件触发每个阶段由一个专门的函数处理该函数负责更新GameContext广播阶段事件并执行该阶段的固有逻辑。void GameEngine::executePhase(Phase phase, Player currentPlayer) { GameContext ctx; ctx.currentPlayer ¤tPlayer; ctx.currentPhase phase; // 广播“阶段开始前”事件 (例如神曹操的“归心”) GameEvent beforePhaseStart(EventType::BeforePhaseStart, ctx.currentPlayer, {}, nullptr); beforePhaseStart.phase phase; m_eventDispatcher.dispatch(beforePhaseStart); // 广播“阶段开始”事件 GameEvent phaseStart(EventType::PhaseStart, ctx.currentPlayer, {}, nullptr); phaseStart.phase phase; m_eventDispatcher.dispatch(phaseStart); // 执行阶段核心逻辑 switch (phase) { case Phase::Start: handleStartPhase(ctx); break; case Phase::Judge: handleJudgePhase(ctx); break; case Phase::Draw: handleDrawPhase(ctx); break; case Phase::Play: handlePlayPhase(ctx); // 这里内部包含子状态循环 break; case Phase::Discard: handleDiscardPhase(ctx); break; case Phase::RoundEnd: handleRoundEndPhase(ctx); break; default: break; } // 广播“阶段结束”事件 GameEvent phaseEnd(EventType::PhaseEnd, ctx.currentPlayer, {}, nullptr); phaseEnd.phase phase; m_eventDispatcher.dispatch(phaseEnd); // 检查游戏是否结束有人达成胜利条件 if (checkGameOver()) { m_currentPhase Phase::GameOver; } }以判定阶段handleJudgePhase为例它需要处理玩家面前所有的延时类锦囊牌乐不思蜀、兵粮寸断、闪电void GameEngine::handleJudgePhase(GameContext ctx) { auto player *ctx.currentPlayer; auto judgementCards player.getJudgementAreaCards(); // 获取判定区牌 for (auto it judgementCards.begin(); it ! judgementCards.end(); ) { auto card *it; // 1. 从牌堆顶摸一张判定牌 auto judgeCard m_cardPile.draw(); // 2. 广播“判定前”事件 GameEvent beforeJudge(EventType::BeforeJudgement, player, {player}, card); m_eventDispatcher.dispatch(beforeJudge); // 3. 执行判定牌与延时锦囊牌的比对逻辑由卡牌效果组件决定 bool isValid card-judge(judgeCard, ctx); // 4. 广播“判定后”事件 GameEvent afterJudge(EventType::AfterJudgement, player, {player}, card); afterJudge.judgeResult isValid; m_eventDispatcher.dispatch(afterJudge); // 5. 将判定牌置入弃牌堆移除延时锦囊牌 m_cardPile.discard(judgeCard); m_cardPile.discard(card); it judgementCards.erase(it); // 从玩家判定区移除 // 6. 根据判定结果触发后续效果如跳过出牌、受到伤害等 if (!isValid) { card-applyFailedEffect(ctx); } } }5.3 出牌阶段的子状态循环出牌阶段是最复杂的因为它是一个允许玩家自由操作、并随时可能被其他玩家响应的循环。我将其实现为一个子状态机void GameEngine::handlePlayPhase(GameContext ctx) { ctx.playSubPhase PlaySubPhase::Start; broadcastPlaySubPhaseEvent(ctx); // 触发“出牌阶段开始” ctx.playSubPhase PlaySubPhase::BeforePlay; broadcastPlaySubPhaseEvent(ctx); // 触发“出牌阶段出牌前” ctx.playSubPhase PlaySubPhase::Playing; bool phaseEnded false; while (!phaseEnded ctx.currentPlayer-isAlive()) { // 1. 获取玩家输入可以是AI决策或真实玩家输入 PlayerAction action ctx.currentPlayer-getAction(ctx); // 2. 处理不同动作类型 switch (action.type) { case ActionType::UseCard: { // 使用卡牌 auto card action.card; // 检查合法性距离、次数、阶段限制等 if (isActionLegal(card, ctx)) { // 创建使用卡牌事件链如前文卡牌use方法所示 bool success card-use(ctx); // 如果使用成功可能需要重置一些状态如“出杀次数” if (success card-getName() Kill) { int killUsed ctx.currentPlayer-getMark(KillUsedTimes); ctx.currentPlayer-setMark(KillUsedTimes, killUsed 1); } } break; } case ActionType::UseSkill: { // 发动主动技能 auto skill action.skill; if (skill-isActivatable(ctx, ctx.currentPlayer)) { skill-trigger(ctx, ctx.currentPlayer); } break; } case ActionType::EndPhase: { // 玩家主动结束出牌阶段 phaseEnded true; break; } // ... 其他动作如取消、查看信息等 } // 3. 每次操作后检查是否有玩家死亡处理濒死结算 checkDeathAndDying(ctx); // 4. 检查是否因技能或效果强制结束阶段例如被“乐不思蜀” if (ctx.currentPlayer-hasMark(SkipPlayPhase)) { phaseEnded true; ctx.currentPlayer-removeMark(SkipPlayPhase); } } ctx.playSubPhase PlaySubPhase::AfterPlay; broadcastPlaySubPhaseEvent(ctx); // 触发“出牌阶段出牌后” ctx.playSubPhase PlaySubPhase::End; broadcastPlaySubPhaseEvent(ctx); // 触发“出牌阶段结束” }核心要点这个循环是非阻塞且事件驱动的。getAction可能等待用户输入但在网络版或AI对战中这里应该是立即返回一个决策。整个循环的每一次迭代世界状态玩家体力、手牌、装备等都可能改变所有监听器都必须基于最新状态做出反应。6. 响应机制与结算队列处理“打出一张闪”和“无懈可击”三国杀最精妙也最让人头疼的就是响应链。当一名玩家使用【南蛮入侵】时所有其他玩家需要按顺序决定是否打出【杀】。这涉及到动态的、按座次顺序的响应队列。6.1 响应请求与响应队列我设计了一个ResponseRequest类和ResponseQueue类。当需要响应时例如使用了一张【南蛮入侵】游戏引擎会创建一个ResponseRequest对象其中包含响应类型如ResponseToCard ResponseToDamage需要响应的卡牌或技能源当前响应的目标玩家可用的响应牌类型列表如对于【南蛮入侵】可响应牌为【杀】超时时间用于网络超时然后将这个请求放入一个ResponseQueue。ResponseQueue会按照游戏规则通常是从当前回合玩家下家开始逆时针逐个询问玩家。class ResponseQueue { public: // 发起一个响应请求 void askForResponse(const ResponseRequest req); // 处理一个玩家的响应 void onPlayerResponse(std::shared_ptrPlayer player, const PlayerResponse resp); // 获取当前正在询问的玩家 std::shared_ptrPlayer getCurrentResponder() const; // 检查响应是否完成或超时 bool isFinished() const; private: std::queueResponseRequest m_requestQueue; std::vectorstd::shared_ptrPlayer m_responderOrder; size_t m_currentResponderIndex; // ... };在卡牌的use方法中遇到需要响应的牌时流程如下bool ArcheryAttackCard::applyEffect(GameContext context) override { // 1. 确定所有目标除使用者外的所有角色 auto allPlayers context.engine-getAllAlivePlayers(); std::vectorstd::shared_ptrPlayer targets; std::copy_if(allPlayers.begin(), allPlayers.end(), std::back_inserter(targets), [](auto p) { return p ! context.currentPlayer; }); // 2. 为每个目标创建“响应请求”需要一张【闪】 for (auto target : targets) { ResponseRequest req; req.type ResponseType::CardEffect; req.sourceCard shared_from_this(); req.target target; req.allowedResponses {Dodge}; // 允许响应的牌 req.priority ResponsePriority::Normal; context.responseQueue-askForResponse(req); } // 3. 启动响应队列处理 while (!context.responseQueue-isFinished()) { auto responder context.responseQueue-getCurrentResponder(); // ... 等待并处理该玩家的响应打出闪/不打出 // 处理逻辑会更新targets列表例如移出成功响应的玩家 } // 4. 对所有未响应的目标造成伤害 for (auto target : targets) { if (!target-hasRespondedSuccessfully()) { // 造成1点伤害 DamageEffect damage(1, DamageNature::Normal, context.currentPlayer, target); damage.apply(context); } } return true; }6.2 结算顺序与嵌套结算更复杂的情况是嵌套结算例如A对B使用【决斗】B打出【无懈可击】C又对【无懈可击】打出【无懈可击】。这形成了一个栈式Stack的结算模型很像函数调用栈。我的处理方式是引入一个SettlementStack。每当一个新的卡牌或技能效果需要结算时就将其压栈。当前栈顶的结算完成后才继续结算下一个。class SettlementStack { public: struct SettlementItem { std::functionvoid() settlementAction; // 需要执行的结算函数 std::string description; }; void push(SettlementItem item) { m_stack.push_back(item); } void resolve() { while (!m_stack.empty()) { auto item m_stack.back(); m_stack.pop_back(); item.settlementAction(); // 执行结算 // 结算过程中可能又push了新的item } } private: std::vectorSettlementItem m_stack; };在【决斗】的例子中A使用【决斗】目标B。一个SettlementItem被压栈其settlementAction是“B需打出一张【杀】否则受到伤害”。在结算这个Item前B使用【无懈可击】。一个新的SettlementItem被压栈其动作是“抵消【决斗】对B的效果”。C对B的【无懈可击】使用【无懈可击】。又一个SettlementItem被压栈其动作是“抵消B的【无懈可击】”。结算栈从顶到底执行先结算C的【无懈可击】抵消了B的。栈顶移除。现在栈顶是B的【无懈可击】但已被抵消所以它无效移除。最后栈顶是A的【决斗】原始结算开始执行B需要出【杀】。这个模型完美处理了任意深度的嵌套响应。避坑指南响应和结算栈是BUG高发区。务必为每个ResponseRequest和SettlementItem记录详细的日志包括发起者、目标、当前栈深度。在调试时可以打印整个栈的状态这对于理清“谁在响应谁”、“哪个效果先结算”至关重要。同时要小心处理结算过程中的玩家死亡事件死亡可能立即中断当前结算链。7. 游戏初始化、持久化与AI集成7.1 游戏初始化与配置加载一个健壮的游戏引擎应该能从配置文件加载卡牌、武将、游戏规则而不是把数据硬编码在C里。我使用JSON作为配置文件格式。cards.json:[ { id: kill, name: 杀, type: basic, suit: [spade, club, heart, diamond], number: [7, 8, 9, 10, 11, 12, 13, 1], effect: damage, damage: 1 }, { id: dismantlement, name: 过河拆桥, type: trick, subtype: single_target, effect: discard_one_card_from_target } ]skills.json:{ guanyu: [ { name: 武圣, type: locked, trigger: before_use_card, condition: card.is_red card.can_be_kill, action: convert_card_to_kill } ] }在游戏启动时一个GameLoader类会读取这些JSON文件通过工厂模式创建对应的Card和ISkill对象并注册到全局管理器中。7.2 游戏状态持久化与回放为了方便调试和实现观战、回放功能游戏状态需要能序列化和反序列化。我为所有核心类Player,CardPile,GameEngine等实现了toJson()和fromJson()方法。在每一个游戏状态改变的关键点回合开始、阶段开始、使用牌、造成伤害等引擎可以自动将当前完整的游戏状态GameSnapshot保存下来。这构成了一个回放序列。struct GameSnapshot { int round; Phase currentPhase; std::shared_ptrPlayer currentPlayer; std::vectorPlayerSnapshot players; CardPileSnapshot drawPile; CardPileSnapshot discardPile; // ... 其他全局状态 std::string toJson() const; static GameSnapshot fromJson(const std::string json); };7.3 简单的AI实现对于单人测试或AI对战一个简单的基于规则的AI就很有用。我为Player类抽象了一个IAIStrategy接口。class IAIStrategy { public: virtual PlayerAction decideAction(const GameContext context) 0; virtual std::shared_ptrCard chooseCardToUse(const GameContext context) 0; virtual std::shared_ptrPlayer chooseTarget(const GameContext context, const Card* card) 0; virtual Response decideResponse(const ResponseRequest req) 0; };最简单的AI可以实现为decideAction: 如果手牌有【杀】且范围内有敌人则使用【杀】否则结束出牌阶段。decideResponse: 如果被【杀】且手牌有【闪】则打出【闪】如果被【南蛮入侵】且手牌有【杀】则打出【杀】否则不响应。更复杂的AI可以使用蒙特卡洛树搜索MCTS或深度学习但那就是另一个庞大的课题了。对于核心逻辑验证规则AI足够。8. 常见问题、调试技巧与性能优化8.1 常见问题与解决方案问题现象可能原因排查与解决思路游戏逻辑死循环技能触发条件形成闭环如A技能触发BB又触发A或回合阶段切换逻辑错误。1. 为每个事件和技能触发添加深度计数器超过阈值则中断并报警。2. 详细日志输出每个阶段、事件的进入和退出。3. 使用调试器检查调用栈。内存泄漏大量使用new/delete或智能指针循环引用。1. 全面使用std::shared_ptr和std::weak_ptr。2. 使用 Valgrind 或 AddressSanitizer 进行内存检查。3. 确保EventDispatcher中的监听器在玩家死亡时被正确移除。技能效果不符合预期技能监听的事件类型错误或canTrigger条件判断有误。1. 在canTrigger和trigger方法开头打印详细日志。2. 检查GameEvent对象中的source,targets,card等信息是否正确。3. 对比官方规则确认技能发动时机例如“当你使用一张牌时” vs “当你使用一张牌指定目标后”。距离计算错误距离计算没有考虑坐骑、技能如-1马、1马的影响。1. 将距离计算抽象为一个独立的DistanceCalculator类。2. 计算时先获取基础座位距离然后遍历所有玩家的装备和技能应用距离修正。3. 为测试用例创建特定的座位和装备配置验证距离。响应顺序混乱ResponseQueue的座次排序逻辑错误或嵌套结算栈处理不当。1. 在响应开始时打印完整的响应者顺序列表。2. 实现游戏状态的序列化保存出错瞬间的快照便于复盘。3. 为结算栈的每个push和pop操作添加日志。8.2 调试技巧详尽的日志系统这是最重要的调试工具。为游戏引擎、事件分发器、卡牌使用、技能触发等关键节点添加不同级别的日志DEBUG, INFO, WARN, ERROR。日志应包含玩家ID、卡牌名、技能名、事件类型等上下文信息。可以轻松地通过日志回溯整个游戏流程。状态快照与断言在关键逻辑点如回合开始、伤害结算后保存游戏快照。使用assert宏检查不变量是否被破坏例如玩家体力值是否在合理范围牌堆总数是否恒定。单元测试为底层工具类如距离计算、卡牌匹配和核心机制如摸牌、弃牌、伤害结算编写单元测试。使用像 Google Test 这样的框架。这能极大提高重构时的信心。可视化调试工具如果条件允许可以开发一个简单的图形界面实时显示所有玩家的状态、手牌、装备、判定区以及事件流。这对于理解复杂交互至关重要。8.3 性能优化考虑对于回合制游戏性能通常不是瓶颈但良好的设计能保证流畅性事件分发优化不要每次事件都广播给所有监听器。可以为事件类型建立索引让技能只注册到它关心的事件类型上。EventDispatcher内部使用std::unordered_mapEventType, std::vectorListener来存储监听器。对象池频繁创建和销毁的临时对象如GameEvent、ResponseRequest可以使用对象池复用减少内存分配开销。缓存计算结果例如玩家之间的距离、攻击范围在装备和座位未改变时是固定的可以缓存起来避免每回合重复计算。使用现代C特性如std::move语义避免不必要的复制使用constexpr编译期计算常量。实现一个完整的三国杀逻辑引擎是一个庞大的工程但通过分而治之采用事件驱动、组件化的架构可以有效地管理复杂度。从最核心的卡牌、技能、回合状态机开始逐步实现响应链、结算栈最后补全AI和持久化。这个过程不仅是对C面向对象和设计模式的深度实践更是对复杂系统建模能力的绝佳锻炼。当你看到自己编写的AI在命令行里打出精彩的配合时那种成就感是无与伦比的。最重要的是这套架构模式具有很强的通用性稍加修改就可以用来实现其他复杂的桌游或卡牌游戏逻辑。
返回列表