news 2026/10/10 8:43:10

用C++实现三国杀:回合状态机与事件驱动设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用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::vector<Card> hand; std::vector<Card> equipment; // 武器、防具、马,最小版本只存牌 };

玩家之间的真实距离不能直接用abs(seatA - seatB),因为在环形桌子上 0 号和 7 号只隔 1 个座位。而且死亡玩家占着座位但不构成距离。一个可复现的距离函数如下:

int distanceBetween(const std::vector<Player>& players, int from, int to) { int n = static_cast<int>(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; }

这个函数有两个参数值得说:step=1是顺时针,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::vector<Card>& 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::vector<Player> players; std::vector<Card> drawPile; std::vector<Card> 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::function<void(Game&, const GameEvent&)>; class Game { std::vector<EventHandler> 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里扣血后调trigger:

target.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_cast<int>(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_cast<int>(i); } } // 3. 出杀 for (size_t i = 0; i < p.hand.size(); ++i) { if (p.hand[i].type == CardType::Slash && bestDist <= 1) { return static_cast<int>(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::vector<GameEvent> 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::vector<Player>& 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 的复杂度和核心引擎的复杂度是两回事。先把引擎做稳,再谈算法,是我在这个项目上最大的教训。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 8:41:46

10 分钟给 Windows 11 减重提速:Win11Debloat 系统优化新手指南

10 分钟给 Windows 11 减重提速&#xff1a;Win11Debloat 系统优化新手指南 【免费下载链接】Win11Debloat A simple, lightweight PowerShell script that allows you to remove pre-installed apps, disable telemetry, as well as perform various other changes to declutt…

作者头像 李华
网站建设 2026/10/10 8:41:04

告别JSONP与XML测试噩梦:jQuery Mockjax多数据类型Mock完整指南

告别JSONP与XML测试噩梦&#xff1a;jQuery Mockjax多数据类型Mock完整指南 【免费下载链接】jquery-mockjax The jQuery Mockjax Plugin provides a simple and extremely flexible interface for mocking or simulating ajax requests and responses 项目地址: https://git…

作者头像 李华
网站建设 2026/10/10 8:38:33

西门子S7-1200恒压供水一拖三控制:从PID调节到接触器互锁实战

接手这套项目的时候&#xff0c;业主反复问过一句话&#xff1a;“三台泵为什么不能一起变频&#xff1f;既然有变频器&#xff0c;直接一台变频器拖三台电机&#xff0c;不是更省事&#xff1f;”——做过楼宇供水改造的朋友&#xff0c;大概率都听过类似的问题。答案其实不复…

作者头像 李华
网站建设 2026/10/10 8:38:31

使用双指针解决链表题

这是一篇初出茅庐的小白被链表题整疯后对双指针解决链表题的见解。双指针,即使用两个指针去解决问题。能用两个指针解决的问题通常用一个指针也能解决&#xff0c;但是双指针相比于单指针&#xff0c;在时间复杂度和空间复杂度方面都占优势。&#xff08;来源&#xff1a;LeetC…

作者头像 李华
网站建设 2026/10/10 8:37:13

基于微信小程序的校园运动搭子平台设计与实现-附源码

温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;本人主页置顶文章(点我)开头有 CSDN 平台…

作者头像 李华
网站建设 2026/10/10 8:35:36

字符串贪心到链表匹配:五大算法专题实战套路解析

前阵子集中刷算法专题&#xff0c;刷到“字符串处理、贪心思想、逆向思维、二叉排序树、链表模式匹配、图形打印”这几个关键词时&#xff0c;我发现它们其实不像表面看上去那么孤立。尤其是那道经典的“拼数(number)”题目&#xff0c;小 x 给了小 r 一个字符串 s&#xff0c;…

作者头像 李华