C++实现三国杀:状态机、STL与多态技能的可扩展骨架

📅 发布时间:2026/10/7 5:32:44
C++实现三国杀:状态机、STL与多态技能的可扩展骨架
简介一份基于C实现的纸牌游戏三国杀课程设计资料包面向需要练习面向过程与面向对象编程、熟悉多元化数据结构应用的C学习者也适合作为课设或期末项目的参考。压缩包共2个文件含1个cpp源码和1个docx报告整体约1.21MB内容组织简洁。程序实现了随机发牌、比较大小、统计输赢、结果输出显示与文件等完整功能代码体积仅12KB可在DEV C等轻量环境下编译运行兼容性较好。界面采用黄色命令行窗口顶部实时显示当前时间避免全屏覆盖系统界面兼顾了使用体验。报告详细记录开发思路、规则设定与运行效果帮助读者对照源码理解游戏逻辑并了解如何在基础规则上自定义创新玩法。目前已有1201人学习浏览是C课程设计、综合实训或小型游戏入门的有益参考。1. 用 C 实现三国杀从 300 行简化规则到可扩展骨架三国杀这类回合制卡牌游戏是 C 项目里性价比很高的练手对象。它把状态机、STL 容器、随机数、多态和事件回调压缩进一个能真正玩起来的程序里比纯粹刷算法题更能逼着你做数据结构选型和代码组织。这个标题的真正难点不是“规则多”而是“回合状态怎么拆”摸牌、出牌、结算、弃牌只要串成一个不崩溃的循环后面加武将、加锦囊都是顺手的事。适合刚学完类、继承、STL想找一个五千行以内项目练手的人。我的建议是从简化规则开始——先砍掉身份、装备、距离只保留杀闪桃和几个锦囊把这个骨架跑通再去补完整规则。2. 数据模型怎么搭Card、Player 与牌堆的最小可编译版本2.1 从规则里抽出三个类Card、Player、Game 的职责边界做三国杀的第一步不是写游戏循环而是把“牌”和“人”的定义定下来。我见过不少翻车现场是直接在 main 里用两个 vector 硬写写到后期连“这张牌属于谁”都分不清。正确的做法是把数据模型拆成三层Card 是值对象Player 是运行时状态Game 负责持有牌堆和玩家并驱动流程。// card.h #pragma once #include string enum class CardType { Attack, // 杀 Dodge, // 闪 Peach, // 桃 ExNihilo, // 无中生有 Dismantle // 过河拆桥 }; struct Card { int id 0; CardType type CardType::Attack; std::string name; };这里有两个关键决定。第一Card 是值类型不持有指针因为一幅牌堆要频繁 shuffle、从牌堆移动到手牌、从手牌移动到弃牌堆值拷贝最省心避免到处管理生命周期。第二用 int id 做唯一标识不要拿 name 或 type 当身份。后面做日志回放、断点调试时一条“第 47 号牌从 A 移到 B”的记录比“一张叫杀的牌”精确得多。// player.h #pragma once #include vector #include string #include card.h struct Player { std::string name; int hp 4; int maxHp 4; int attackTimesUsed 0; bool isAI false; std::vectorCard hand; };Player 第一版可以只需要这些字段。hp、maxHp 是体力上下限attackTimesUsed 用来约束“每个回合只能出一次杀”。后续加武将技能就往这里塞技能列表但第一版先别加否则你会在“谁拥有技能、技能什么时候触发”里绕晕。我把技能设计放到第四章是因为技能依赖回合流程的钩子没有流程先谈技能就是空中楼阁。2.2 初始化一局游戏牌堆、shuffle 与“先发四张”牌堆是一张牌的线性序列第一版我不建议做成链表或者复杂结构。60 张牌初始化后洗匀然后挨个抽到玩家手牌里。注意洗牌不要用rand() % N这个坑在第五章细说这里直接用 C11 的random标准做法。// game_init.cpp #include algorithm #include random #include vector #include card.h std::vectorCard buildDeck() { std::vectorCard deck; int id 0; auto add [](CardType t, const std::string name, int count) { for (int i 0; i count; i) { deck.push_back({id, t, name}); } }; add(CardType::Attack, 杀, 30); add(CardType::Dodge, 闪, 15); add(CardType::Peach, 桃, 8); add(CardType::ExNihilo, 无中生有, 4); add(CardType::Dismantle, 过河拆桥, 3); return deck; } std::vectorCard deck buildDeck(); std::mt19937 rng(std::random_device{}()); std::shuffle(deck.begin(), deck.end(), rng); auto drawCard [](Player p) { if (deck.empty()) return; p.hand.push_back(deck.back()); deck.pop_back(); }; drawCard(players[0]); drawCard(players[0]); drawCard(players[1]); drawCard(players[1]);std::shuffle接收两个迭代器和一个均匀随机数生成器它内部会做均匀打乱不需要你自己处理“交换多少次”的问题。std::random_device{}()是种子来源多数实现下是系统熵游戏每次启动牌序不同。抽牌这里用deck.back()配合pop_back()因为洗好牌之后从尾部抽最自然。实际开局每人四张手牌写一个for (int i 0; i 4; i)给每个玩家各抽一张这里为了展示只写了前两张。2.3 编译环境VSCode 配置 C/C 环境时最容易漏的参数第一版最好用一个文件先跑通再拆卡牌、玩家、游戏三个.h/.cpp。我自己常用的是单文件快速验证功能稳定后再拆开。用 g 编译时注意标准版本C 的enum class、std::shuffle、智能指针都依赖 C11 之后的特性建议直接上 C17。g -stdc17 -Wall -Wextra main.cpp -o sanguosha如果你在 VSCode 配置 C/C 环境tasks.json 里的 args 不要只写main.cpp务必加上-stdc17。我见过很多人代码没问题但编译报enum class不支持就是因为默认用了老的 C 标准。Windows 上用 MinGW 同样走 g 命令如果用的是 MSVC对应参数是/std:c17。这一步不值得折腾定好标准后整组项目统一后面所有代码都按这个标准写。3. 回合主循环怎么驱动状态机、出牌与杀/闪结算3.1 回合流水线把一回合拆成五个可测试阶段三国杀的回合流程看起来复杂但拆开就五段回合开始、判定阶段可以先用空实现占位、摸牌阶段、出牌阶段、弃牌阶段。新手容易犯的错是把出牌的循环写在主循环里导致后续要插“回合开始触发技能”时无处下手。正确做法是把每个阶段定义成显式的状态用枚举驱动。enum class TurnStage { Start, Judge, Draw, Play, Discard, End }; void Game::runRound() { for (TurnStage stage : {TurnStage::Start, TurnStage::Judge, TurnStage::Draw, TurnStage::Play, TurnStage::Discard, TurnStage::End}) { switch (stage) { case TurnStage::Start: onStageStart(); break; case TurnStage::Judge: onStageJudge(); break; case TurnStage::Draw: onStageDraw(); break; case TurnStage::Play: onStagePlay(); break; case TurnStage::Discard:onStageDiscard(); break; case TurnStage::End: onStageEnd(); break; } if (gameOver()) return; } }这里我把Judge先写成空函数是因为第一版没有判定牌但状态机里要留这一个位置。后续加闪电、乐不思蜀你会发现当初留了这个坑是多么正确。主循环本身不要管阶段内部的细节它只负责“按顺序走一遍”这是回合制游戏最稳健的骨架。gameOver()放在每个阶段之后避免一个玩家被砍死之后还要继续出牌。3.2 主循环与出牌入口玩家输入和 AI 走同一条逻辑主循环只需要做两件事让当前玩家跑一个完整回合然后切换到下一个活着的玩家。切换用取模实现环形下标这是回合制游戏的标准做法手写if (current n-1) current 0容易在玩家数量变化时出错。void Game::nextPlayer() { do { current_ (current_ 1) % players_.size(); } while (players_[current_]-hp 0); }出牌阶段是人机交互的核心。我的建议是别为 AI 单独写一套出牌逻辑而是让 AI 也走“列出可用牌 → 选一张 → 执行效果”的公共流程只是选择策略不同。玩家从键盘输入AI 选第一张能用的牌。这样你调试 AI 时实际上在调试公共的出牌函数。void Game::onStagePlay() { Player* me players_[current_].get(); while (true) { std::vectorCard* usable; for (auto c : me-hand) { if (canUse(*me, c)) usable.push_back(c); } if (usable.empty()) break; Card* chosen me-isAI ? chooseAI(usable) : chooseHuman(usable); if (!chosen) break; useCard(*me, *chosen); if (gameOver()) return; } }canUse里至少要检查三件事牌类型是否合法、这个回合是不是已经出过杀、目标存不存在。拿到的usable是指向手牌元素的指针这里要小心后面删除元素会让指针失效所以useCard内部必须是拿到牌之后立刻完成“从手牌移除”再执行效果不要在函数调用之后再持有这个指针。第一版chooseHuman可以简化成输入数字选牌AI 则直接选第一张可用牌逻辑都是返回一个Card*。3.3 杀/闪结算伤害、濒死与桃的优先级结算函数是整个游戏最容易写乱的地方。我定一个原则结算函数只做一件事输赢判断交给主循环。杀对闪的结算流程是先查目标手牌有没有闪有闪就移除一张闪并结算结束没有闪就扣一点体力再检查是否濒死。bool Game::resolveAttack(Player* src, Player* dst) { logEvent(src-name 对 dst-name 使用【杀】); // 从目标手牌里找一张闪 auto dodgeIt std::find_if(dst-hand.begin(), dst-hand.end(), [](const Card c) { return c.type CardType::Dodge; }); if (dodgeIt ! dst-hand.end()) { discardCard(*dst, dodgeIt); logEvent(dst-name 打出【闪】); return false; } dst-hp--; logEvent(dst-name 受到 1 点伤害剩余体力 std::to_string(dst-hp)); if (dst-hp 0) { tryToSave(dst); } return true; }std::find_if配合 lambda 筛选手牌是最直观的写法。这里有个细节discardCard接收的是迭代器不是牌本身的值因为移除元素必须操作容器。tryToSave里面先让濒死玩家自己使用桃桃用完还没有救回来就判定死亡。第一版可以把“其他玩家出桃救人”砍掉濒死结算的逻辑清晰比规则完整更重要。4. 武将技能怎么做多态 Skill 基类与钩子函数的落点4.1 不用 switch 堆技能多态为“未知扩展”留出口武将技能是三国杀和普通卡牌游戏的分水岭。最容易翻车的写法是给每个角色编号然后在useCard、onDamage里堆switch (heroId)。前三个武将没问题第五个开始你会发现每个函数都膨胀而且技能之间无法复用。用多态的本质是游戏引擎不知道曹操和刘备是谁它只知道每个玩家身上挂着若干个Skill对象在固定时机调用它们的虚函数。这个设计的另一个好处是新增武将不用改引擎代码只需要写一个新的派生类。测试也更方便比如测试“刘备濒死时桃生效”直接构造一个带Rende技能的玩家跑结算函数不需要走完整局游戏。4.2 钩子函数的挂载点怎么设计钩子函数的名字要按“时机 事件”命名不要笼统写onEffect。我第一版吃过亏把所有触发都塞进onDoingSomething最后根本看不清哪个技能在哪个阶段生效。下面这组虚函数覆盖了三国杀最常见的触发时机// skill.h #pragma once #include string class Game; class Player; class Skill { public: explicit Skill(std::string skillName) : name_(std::move(skillName)) {} virtual ~Skill() default; virtual void onTurnBegin(Player* self, Game* g) {} virtual void onTurnEnd(Player* self, Game* g) {} virtual void onDraw(Player* self, Game* g) {} virtual void onPlayCard(Player* self, Game* g, Card* card) {} virtual void onDamageDealt(Player* self, Player* target, Game* g) {} virtual void onDamageTaken(Player* self, Player* source, Game* g) {} virtual bool onDying(Player* self, Game* g) { return false; } const std::string name() const { return name_; } private: std::string name_; };注意onDying返回 bool表示“这个技能是否能救回濒死玩家”。曹操和张飞的技能不需要返回值默认返回 false 就好。基类里所有虚函数都有空实现这样新增技能只需要重写关心的时机不用实现全部接口。Game和Player这里用了前置声明因为技能回调需要访问游戏状态但头文件互相包含会爆炸前置声明加在.cpp里包含完整定义是避免循环依赖的常规解法。4.3 两个技能示例曹操“奸雄”与刘备“仁德”光看基类不直观写两个具体技能你就懂了。曹操的奸雄是“受到伤害后获得造成伤害的牌”刘备的仁德是“出牌阶段把手牌交给其他角色每两张回复一点体力”。这里为了演示我把两个技能的规则都做了简化但钩子调用点在真实项目中就是这个思路。class JianXiong : public Skill { public: JianXiong() : Skill(奸雄) {} void onDamageTaken(Player* self, Player* source, Game* g) override { // 简化版受伤后从牌堆顶补一张牌 Card c g-drawCard(); self-hand.push_back(c); } }; class Rende : public Skill { public: Rende() : Skill(仁德) {} void onTurnBegin(Player* self, Game* g) override { givenThisTurn_ 0; } bool useFromHand(Player* self, Game* g, Card c) { g-moveToDiscard(c); givenThisTurn_; if (givenThisTurn_ % 2 0 self-hp self-maxHp) { self-hp; } return true; } private: int givenThisTurn_ 0; };奸雄的onDamageTaken由结算逻辑显式调用在resolveAttack扣血之后加一行for (auto sk : dst-skills) sk-onDamageTaken(...)即可。仁德的useFromHand不是虚函数覆盖它是在出牌阶段被额外检测到的“技能专用出牌方式”这意味着canUse里还要检查当前玩家是否有仁德技能、有没有可给牌的对象。实现到这里你该理解我为什么反复强调“引擎只调钩子不关心技能内部”——曹操补几张牌是技能自己的事引擎只管保证回调时机正确。5. 常见问题与避坑中文乱码、迭代器失效和洗牌的“玄学”5.1 中文乱码源码 UTF-8 与控制台 GBK 的冲突现象编译通过但运行时牌名输出成乱码或者第一个中文字符之后全是问号。原因你按 UTF-8 保存源码Windows 控制台默认代码页却是 GBK运行时把 UTF-8 字节流按 GBK 解码自然对不上。解决要么统一源码和运行环境要么输出英文日志。我自己的做法是游戏内提示先用英文日志文件用 UTF-8 写等所有逻辑稳定后再回来处理中文显示。如果一定想看到中文Windows 下在main开头调用system(chcp 65001 null)切换控制台代码页同时编译器参数要带上-finput-charsetUTF-8 -fexec-charsetUTF-8g或/utf-8MSVC。这一条不解决你会被乱码干扰得完全没法判断游戏逻辑对不对。5.2 手牌删除导致迭代器失效erase 的正确用法现象出牌阶段玩家明明有两张杀只用了一张就退出循环或者程序直接崩溃。原因在for (auto it hand.begin(); it ! hand.end(); it)里调用hand.erase(it)erase之后it已经失效it是未定义行为。解决优先用“迭代器循环 手动推进”模式erase返回下一个有效迭代器。auto it me-hand.begin(); while (it ! me-hand.end()) { if (it-type CardType::Attack canUseAttack) { it me-hand.erase(it); } else { it; } }这段代码能用是因为erase的返回值一定是下一个有效元素的位置。如果在useCard里先移除了手牌回到onStagePlay又去访问之前保存在usable里的指针照样崩溃。所以我在第三章特别强调了“移除手牌后不要继续持有指针”。5.3 rand() 洗牌的“玄学”为什么要用 mt19937现象几局游戏里“杀”总是扎堆出现或者每次洗牌结果都能猜个大概。原因rand()在多数实现下是线性同余生成器低位周期性明显直接rand() % deck.size()又引入了取模偏差牌的数量不是 2 的幂时每种牌被抽中的概率不均匀。这不是玄学是数学上确定的问题。解决用std::mt19937搭配std::shuffleshuffle 算法本身会尽量消除偏差。注意给mt19937一个真正随机的种子别写srand(time(NULL))那种老套路用std::random_device{}()。这个坑不影响单局体验但会严重影响你做 AI 胜率测试——统计出来的数据根本不可信。5.4 AI 在出牌阶段假死出口条件写错位置现象程序卡在某个玩家出牌阶段不往下走CPU 占用却很高。原因onStagePlay的循环出口条件写错了位置常见写法是先判断“有没有杀”再判断手牌清空而 AI 手里只剩闪和桃时usable列表为空但外层还在一遍遍扫描手牌。解决循环每次一开始就重新计算usable为空直接 break再加一个保险计数器连续空转超过 100 次强制退出。int idleTimes 0; while (true) { auto usable findUsable(*me); if (usable.empty()) break; if (idleTimes 100) { logEvent(AI 出牌异常强制结束); break; } // 正常出牌逻辑 }这个保险在真实卡牌游戏里很有用因为规则越加越多总会有某个技能组合导致无可出牌但循环条件仍然成立的死角。别迷信自己的逻辑“理论上不会死循环”日志和兜底都加上。5.5 从空牌堆抽牌摸牌阶段直接崩溃现象游戏进行到后期drawCard从deck里取出一个不存在的元素表现为内存报错或抽到垃圾牌。原因牌堆抽空了但弃牌堆的牌没有洗回牌堆。三国杀规则里牌堆空时要把弃牌堆洗成新牌堆很多简化实现把这步忘了。解决在drawCard入口处做牌堆检查空了就把弃牌堆搬回来并 shuffle。Card Game::drawCard() { if (deck_.empty()) { deck_ discard_; discard_.clear(); std::shuffle(deck_.begin(), deck_.end(), rng_); logEvent(牌堆耗尽弃牌堆重新洗入); } Card c deck_.back(); deck_.pop_back(); return c; }注意这里deck_ discard_是整段拷贝第一版完全可以接受后续要优化再改成移动语义。这个函数是全局唯一的发牌入口所有摸牌、技能的额外摸牌都必须走它才能保证永远不会空指针。6. 用日志和文本协议把调试变成可持续的活6.1 固定输出格式与固定随机种子让一局游戏可以复现三国杀最难受的调试场景是“刚才还能复现的 bug重启游戏就没了”因为每次 shuffle 的随机种子不同手牌顺序不同。我习惯给 Game 构造器加一个种子参数正常跑的时候用随机种子调试的时候手动指定固定值。Game game(42); // 固定种子保证每次启动手牌顺序一致配合固定输出格式把关键事件逐条写到 stdout 和日志文件。格式建议是“回合编号, 当前玩家, 事件描述”事件描述里包含牌名、来源、目标、体力变化。void logEvent(const std::string msg) { std::cout round_ , current_-name , msg \n; }6.2 状态转储与 diff把“血泪经验”变成回归用例日志只看事件流还不够我每次会在回合结束前打印所有玩家的手牌数和体力值这一整段输出叫“状态转储”。当你修改了技能逻辑把新旧版本的日志做一次diff立刻能看出哪个阶段开始行为分叉。具体做法是改代码前跑一局固定种子的游戏日志存成before.log改完跑同样种子输出after.log然后diff before.log after.log。分叉点就是新逻辑引入 bug 的地方。这比在gdb里单步跟五个小时有效率得多。我最早做这类项目时只会在代码里贴一堆cout看输出遇到问题就反复编译运行靠肉眼找逻辑错。后来我给自己定了规矩所有影响游戏走向的事件必须走logEvent调试时只看日志不猜。这个习惯让我在后续给游戏加技能时每改一个钩子都能在十分钟内确认没有破坏原有流程。这套日志驱动的开发方式比任何“高深技巧”都更能帮你把这个项目做完。希望帮到你。本文还有配套的精品资源点击获取