用C++实现三国杀:回合状态机与事件驱动设计
简介C实现的《三国杀》纸牌游戏完整工程适合C初学者、课程设计或游戏开发入门的读者。资源包含可直接编译运行的源代码文件和配套设计报告文档共2个文件压缩包约1.21MB。代码覆盖随机发牌、牌面比较、输赢统计与结果输出屏幕显示与文件保存等完整流程并运用面向过程与面向对象混合编程思想结合多种数据结构组织游戏逻辑。配套报告对设计思路、规则创新和实现细节做了说明便于理解代码与二次开发。程序体积小、兼容性高只需安装轻量的DEV C环境即可编译运行同时基于Visual Studio开发界面采用黄色命令行窗口并显示当前时间兼顾易读性与实用体验。目前已有1203人学习下载适合需要快速获得完整可运行项目作为参考的开发者。1. C 实现三国杀先把“回合状态机”立住再谈技能扩展一个做过三四个 C 项目的人第一次动手写“三国杀”时最容易的翻车方式不是不会枚举类型而是拿到需求就开始写卡牌类杀是类、闪是类、桃是类结果技能一多继承树立刻变蜘蛛网。这个标题想解决的就是这件事用标准 C 写一个能跑的最小纸牌游戏引擎不依赖图形库把回合、出牌、响应、伤害这些逻辑练扎实。适合的人群很具体学过 STL 但缺一个综合项目、想找 c 小游戏源码练手、以及准备把“状态机 事件驱动”写进面试项目里的人。等跑完你会发现三国杀的难点不在牌面美术而在“什么时候能出牌、出牌后谁有权响应、死后座位怎么跳”这一整套流程控制。2. 从桌游规则到 C 数据模型卡牌、玩家、距离与阶段三国杀的规则文本很长但落到代码里可以压缩成三层卡牌是数据玩家是容器回合是时钟。最忌讳一上来就把“装备”“锦囊”“武将技”全部数据化到十几个类里。先让最小闭环跑起来后面再加一层事件系统去接收技能才是做得下去的顺序。2.1 Card 用结构体而不是类一张牌需要的字段就三个很多人喜欢为每一种牌建子类比如SlashCard : public Card。这个思路在牌面数量固定、规则固定的项目里能跑但三国杀的杀、闪、桃、决斗之间还有属性、花色、点数、无懈可击等交叉结算。只要某个锦囊触发时要求“弃置一张杀”你就会发现类型转换写到手软。我一般把卡牌定义成只带数据的结构体细化到三个核心字段#include vector #include string enum class CardType { Slash, // 杀 Dodge, // 闪 Peach, // 桃 Duel, // 决斗 Unknown }; struct Card { int id 0; // 全局唯一保留原始牌 ID CardType type CardType::Unknown; int rank 1; // 1~13对应 A~K不非要存字符串 };为什么要有id这是第一个容易踩的坑。三国杀里存在“同花色同点数的杀”如果两张牌只是类型相同丢失 id 会导致弃牌时无法区分“哪一张”被弃。id在洗牌前分配一次之后手牌、弃牌堆、牌堆之间搬运的都是Card的拷贝判断相等只用id不用type。rank单独存数字而不是字符串是因为后面做判定、比大小、拼点都需要数值。UI 层要把红桃 3 显示成“红桃 3”那是显示层的事核心逻辑不要碰字符串。把字符串数组初始化留给界面模型层只管数字。2.2 Player 状态血、手牌、座位和“马”的距离玩家类不负责出牌决策它只保存状态。血量和手牌是最直接的但距离算法藏得比较深。标准规则里攻击距离是“从你的座位出发沿顺时针或逆时针数经过的存活玩家最少的一段”然后受装备影响。struct Player { int seat 0; // 0 ~ N-1环形座位号 int hp 4; int maxHp 4; bool alive true; int plusHorse 0; // 1 马防御马 int minusHorse 0; // -1 马进攻马 std::vectorCard hand; std::vectorCard equipment; // 武器、防具、马最小版本只存牌 };玩家之间的真实距离不能直接用abs(seatA - seatB)因为在环形桌子上 0 号和 7 号只隔 1 个座位。而且死亡玩家占着座位但不构成距离。一个可复现的距离函数如下int distanceBetween(const std::vectorPlayer players, int from, int to) { int n static_castint(players.size()); auto aliveCount [](int start, int step) { int cnt 0; int cur (start step n) % n; while (cur ! to) { if (players[cur].alive) cnt; cur (cur step n) % n; } return cnt; }; int clockwise aliveCount(from, 1); int counter aliveCount(from, -1); int base clockwise counter ? clockwise : counter; // 目标 1 马让距离变远攻击者 -1 马让距离变近 return base players[to].plusHorse - players[from].minusHorse; }这个函数有两个参数值得说step1是顺时针step-1是逆时针(cur step n) % n是为了避免取模出现负数。如果未来加入坐骑装备把对应玩家的plusHorse/minusHorse改掉即可判定距离的所有地方会同时生效。这个写法牺牲了一点性能但逻辑非常接近桌游直觉。2.3 Phase 是核心状态机六个阶段为什么不能省玩家回合不是“出一个牌”这么简单。标准流程是回合开始时触发技能、判定区结算、摸牌、出牌、弃牌、回合结束这六个阶段必须存在即使最小版本只是空转也要把轮子立起来。enum class Phase { Start, // 回合开始 Judge, // 判定阶段 Draw, // 摸牌阶段 Play, // 出牌阶段 Discard, // 弃牌阶段 End // 回合结束 }; Phase nextPhase(Phase p) { switch (p) { case Phase::Start: return Phase::Judge; case Phase::Judge: return Phase::Draw; case Phase::Draw: return Phase::Play; case Phase::Play: return Phase::Discard; case Phase::Discard: return Phase::End; case Phase::End: return Phase::Start; } return Phase::Start; }把阶段写成枚举能让“每个阶段里能做什么”变成明确的边界条件。比如出牌阶段不能摸牌弃牌阶段不能主动出杀。后面加“乐不思蜀”这种跳阶段技能时只需要在nextPhase调用前加一个skipPlay标志而不是回调里到处改phase的值。这里有一个原则阶段切换只允许nextPhase和回合拥有者两个入口去改。谁乱改phase谁就会在联调时面对“杀出去了但响应阶段没了”这种玄学 bug。先用这个状态机把主循环跑通再谈技能。3. 最小可玩版落地回合循环、洗牌发牌、杀闪桃结算数据模型定义好了这一步把Game类拼起来。很多教程把Game写成一个人畜无害的容器实际代码里它承担的是裁判角色知道当前回合是谁、当前阶段是什么、一张杀打出去由谁响应。这个类应该保持“只有逻辑没有打印”方便后面做自动化测试。3.1 洗牌与真正的随机数mt19937 代替 rand洗牌用std::shuffle是标准做法但真正的随机数来自std::mt19937。有些老编译器环境下rand()的实现只有 15 位有效随机数对 108 张的牌堆取模后某些位置的牌出现概率肉眼可见地不均匀。这不是玄学是实测能复现的偏差。#include random #include algorithm void shuffleDeck(std::vectorCard deck, unsigned seed 0) { static std::mt19937 rng; if (seed ! 0) { rng.seed(seed); // 复现问题时用固定种子 } else { rng.seed(std::random_device{}()); } std::shuffle(deck.begin(), deck.end(), rng); }参数seed是调试命门随机崩溃或某次发牌必现 bug 时把现场造成的 seed 打印出来下一次直接传进去就能重放同一局。生产逻辑里 seed 传 0 表示由random_device给种子测试里传固定值。发牌函数要处理“牌堆空”的情况class Game { public: std::vectorPlayer players; std::vectorCard drawPile; std::vectorCard discardPile; Card drawCard() { if (drawPile.empty()) { // 弃牌堆洗回牌堆 drawPile.swap(discardPile); if (drawPile.empty()) { throw std::runtime_error(draw pile is empty); } shuffleDeck(drawPile); } Card c drawPile.back(); drawPile.pop_back(); return c; } };这里有个隐含约定弃牌堆的牌不允许立刻进入可摸状态必须等摸牌堆耗尽再洗回来。经典玩法是先指定的弃牌堆作为二次牌堆避免玩家通过记忆弃牌顺序获得信息优势。3.2 回合循环把状态机转起来一次完整回合的驱动不写在某个大 while 里而是顺着Phase枚举一个阶段一个阶段往下走Phase phase Phase::Start; size_t current 0; void nextTurn() { current (current 1) % players.size(); while (!players[current].alive) { current (current 1) % players.size(); } phase Phase::Start; } void advancePhase() { phase nextPhase(phase); if (phase Phase::Draw) { for (int i 0; i 2; i) { players[current].hand.push_back(drawCard()); } } if (phase Phase::Discard) { // 弃牌到血量上限最小版本里直接让 AI 从末尾弃 while (players[current].hand.size() players[current].hp) { players[current].hand.pop_back(); } } if (phase Phase::Start current ! 0) { // 实际由外部调用 nextTurn这里只做约束 } }这个阶段循环的核心是“谁执行完下一阶段必须由裁判通知”。如果你在一个无限循环里检测phase Phase::Play一旦某个技能把阶段跳过去循环就会卡死。用advancePhase作为唯一推进入口所有阶段动作都挂在进入阶段的那一刻执行。3.3 结算杀与闪先找响应牌再扣血杀是最基础的进攻牌它的流程是出牌方指定一个距离内的目标目标可以选择打出闪不闪则掉 1 血。实现时要注意“闪”是被打出去的牌而不是一次技能回调。bool resolveSlash(Game g, size_t from, size_t to) { if (distanceBetween(g.players, from, to) 1) { return false; // 距离不够不能出杀 } Player target g.players[to]; for (auto it target.hand.begin(); it ! target.hand.end(); it) { if (it-type CardType::Dodge) { g.discardPile.push_back(*it); target.hand.erase(it); return false; // 被闪避 } } target.hp - 1; if (target.hp 0) { target.alive false; // 掉血和濒死事件留给后续扩展 } return true; }为什么先遍历手牌而不是直接std::find_if后 erase因为这个循环里既要保留Card进弃牌堆又要从手牌删除。erase之后迭代器失效必须先push_back保存一份副本再 erase。这个顺序写反就会出现弃牌堆里的闪变成空数据。距离判断放在结算最前面比出牌阶段统一拦截要准确。原因是“装备 -1 马”和“目标 1 马”会影响距离而距离是动态值。3.4 桃与回复边界血满时不能使用的标准教训桃的代码如下规则本身简单但边界条件写不好会出“满血吃桃回血后直接暴毙”的奇葩 bug。bool usePeach(Game g, size_t from, size_t to) { Player target g.players[to]; if (target.hp 0) { // 濒死求桃 target.hp 1; return true; } if (target.hp target.maxHp) { return false; // 血满不能吃 } target.hp 1; return true; }注意第二个分支只有在hp maxHp时允许回血。很多实现漏掉这个检查结果打出一张桃后血量超过上限后续掉血判定就会出错。更严谨的写法是设计heal(int amount)函数内部做hp min(hp amount, maxHp)这样所有加血路径共用同一个边界逻辑。4. 技能扩展的正确姿势事件回调与责任链而不是继承最小版跑通之后你会收到“加一个武将技能”的需求。这时最容易走入的误区是给Card加虚函数然后为每个技能造子类。真实三国杀的技能会互相交织一次伤害可能触发卖血、伤害加成、伤害减免、摸牌、弃牌。如果用继承表达组合类数量会爆炸。4.1 先看一个容易翻车的继承写法有些人会写出这种结构class SlashCard : public Card { public: virtual void onUse(Game g, Player user) {} }; class FireSlash : public SlashCard { ... };问题出在哪当一个技能说“你的火杀不能被闪避”时FireSlash和另一个技能“你的杀目标数 1”叠加你就必须知道当前实际对象是FireSlash还是某个复合包装类。C 没有天然的 trait 多重继承解决方案最后都会写成 RTTI 判断又慢又脆。4.2 用 Event 类型 handler 队列解耦技能标准做法是把游戏动作发成事件技能是事件处理器。std::function是 C 里表达回调的正统工具下面是最小事件骨架#include functional struct GameEvent { enum Kind { CardUsed, DamageDealt, DamageTaken, PlayerDying, PhaseChanged } kind; size_t source 0; size_t target 0; Card card; int damage 0; }; using EventHandler std::functionvoid(Game, const GameEvent); class Game { std::vectorEventHandler handlers_; public: void addHandler(EventHandler h) { handlers_.push_back(std::move(h)); } void trigger(const GameEvent evt) { // 遍历 handlers 时技能可能继续触发新事件 // 这里用索引而不是迭代器避免容器被回调改动 for (size_t i 0; i handlers_.size(); i) { if (i handlers_.size()) break; handlers_[i](*this, evt); } } };在resolveSlash里扣血后调triggertarget.hp - 1; if (target.hp 0) { GameEvent evt; evt.kind GameEvent::PlayerDying; evt.source from; evt.target to; evt.card card; trigger(evt); }这样“一滴血”的判定就在扣血之后、死亡结算之前可以让每个技能决定是否修改伤害或插入救人的动作。trigger里用索引遍历是刻意的如果某个技能调用addHandler导致handlers_扩容迭代器会失效索引不会。技能例子给曹操加“奸雄”即受伤后获得造成伤害的牌Game g; g.addHandler([](Game game, const GameEvent evt) { if (evt.kind GameEvent::DamageTaken evt.target 0) { game.players[0].hand.push_back(evt.card); } });这是非常典型的 c 回调函数例子回调不直接操作界面只通过事件参数修改游戏状态。技能之间互不感知却可以叠加在同一局里。4.3 给 AI 的简单决策优先级表和贪心没有用户输入时AI 的决策逻辑也可以做成处理器。最简单的 AI 按优先级出牌先出桃救人再出杀打距离最近的敌人最后才出决斗。int chooseCardToPlay(Game g, size_t self) { Player p g.players[self]; // 1. 血不满时优先吃桃 for (size_t i 0; i p.hand.size(); i) { if (p.hand[i].type CardType::Peach p.hp p.maxHp) { return static_castint(i); } } // 2. 找距离最近的活敌 int bestTarget -1; int bestDist 100; for (size_t i 0; i g.players.size(); i) { if (i self || !g.players[i].alive) continue; int dist distanceBetween(g.players, self, i); if (dist bestDist) { bestDist dist; bestTarget static_castint(i); } } // 3. 出杀 for (size_t i 0; i p.hand.size(); i) { if (p.hand[i].type CardType::Slash bestDist 1) { return static_castint(i); } } return -1; // 跳过出牌 }这个 AI 没有任何启发式搜索胜在可读。要是想提升强度可以把“杀的数量”“闪的数量”“血量”做成打分项由高到低选动作。那是决策搜索问题已经超出核心引擎范围。5. 避坑写三国杀核心最容易翻车的五个地方这部分是实战中真正耽误时间的“血泪经验”。每一条都在我调试不同版本的对局程序里真实出现过按“现象 → 原因 → 解决”整理如下。5.1 洗牌随机数rand() 的随机性不够用现象同一局反复重开后前 10 张牌明显重复。换srand(time(0))后偶尔改善但多核同时发牌时依然有周期性。原因老版本rand()的周期短低 4 位权重很低直接rand() % 108会让某些桶概率偏高。解决用std::mt19937配合std::shuffle洗牌。测试时固定 seed发布时用random_device做种子。不要自己实现“交换随机两张牌”的洗牌那会保留循环置换痕迹。5.2 vector 遍历中删除元素导致迭代器失效现象出闪之后手牌数量正常减少但下次遍历手牌时崩溃或者弃牌堆里的闪显示为空。原因erase会让当前迭代器及之后的所有迭代器失效。很多人写完it继续跑本质已经是未定义行为。解决用it player.hand.erase(it);接收删除后的新迭代器删除后不要立刻。下面的写法是正确的for (auto it hand.begin(); it ! hand.end(); ) { if (it-type CardType::Slash) { it hand.erase(it); } else { it; } }5.3 回调里修改游戏状态先收集后结算现象某个技能触发后牌堆数量在下一次触发时对不上甚至同一张牌被两名角色同时获得。原因trigger遍历 handlers 时回调又调用usePeach、drawCard、addHandler外面的结算循环还握着旧的手牌索引。解决核心原则是“事件触发只记录意图不直接改状态”。施工上可以用队列把即时动作延后一帧std::vectorGameEvent pendingEvents; void deferEvent(const GameEvent evt) { pendingEvents.push_back(evt); }处理完当前阶段后再逐个执行pendingEvents。这个模式比在回调里层层递归调用resolveSlash安全得多。5.4 卡牌相等性不要用 type 判断“同一张牌”现象弃一张杀时如果把两张同为杀的牌当成同一张会导致手牌里的杀错位。原因牌堆允许出现多个同类型同点数的牌。只比type和rank不足以区分它们。解决所有卡牌操作都带id。比如“弃置一张杀”需要找到手牌里第一张type Slash的牌但记下它的id后再用id做删除int slashId -1; for (auto c : hand) { if (c.type CardType::Slash) { slashId c.id; break; } } auto it std::find_if(hand.begin(), hand.end(), [slashId](const Card c) { return c.id slashId; });5.5 环形座位的取模与越界现象程序在 3 人局最后一名玩家死亡时崩溃。原因用current移动到下一个座位索引会超出players.size()。解决统一用current (current 1) % players.size()每次取当前玩家后先判断alive。不能直接current 1因为这里是环形。void nextAlivePlayer(size_t current, const std::vectorPlayer players) { do { current (current 1) % players.size(); } while (!players[current].alive); }这段代码里的空循环要小心如果场上只剩一个人do-while会死循环。先把存活人数判断放在前面或者用固定循环次数来兜底。6. 验证与进阶用断言构造最小回归再做成自动 AI 对局最小版本完成后我不会立刻加复杂武将而是先写一组把自己钉死的测试。C 项目里最轻量的验证工具就是assert它不需要引入测试框架把核心场景固定下来即可。#include cassert void testSlashCanBeDodged() { Game g; g.players.resize(2); g.players[0].hp 4; g.players[1].hp 4; Card slash; slash.id 1; slash.type CardType::Slash; g.players[0].hand.push_back(slash); Card dodge; dodge.id 2; dodge.type CardType::Dodge; g.players[1].hand.push_back(dodge); bool hit resolveSlash(g, 0, 1); assert(!hit); assert(g.players[1].hp 4); assert(g.players[1].hand.empty()); }测试通过后就是验证引擎安全性的最快路径。每加一个新技能至少配一个“被杀后触发”“伤害后获得牌”“死亡后座位跳过”的回归用例。等这些用例攒到 20 个以上你改内部结构时就不会夜夜失眠。进阶方向我建议做自动 AI 对局写一个runSimulation(seed)让四名 AI 各按自己的优先级函数出牌跑 2000 局统计回合数、平均血量和结束原因。你会在数据里看到很多“规则死角”比如某角色锦囊牌永远打不出去、AI 到死都没用过桃。统计报表比肉眼盯着终端输出可靠得多这也是我把“验证”放在所有优化之前的原因。如果要让 AI 更像真人可以在chooseCardToPlay里加入“保存闪”的逻辑手牌少于两张时不出杀优先留闪。这个策略不值一提但它提醒你AI 的复杂度和核心引擎的复杂度是两回事。先把引擎做稳再谈算法是我在这个项目上最大的教训。希望帮到你。本文还有配套的精品资源点击获取