ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++面向对象编程实战:手把手教你实现控制台狼人杀游戏

C++面向对象编程实战:手把手教你实现控制台狼人杀游戏 1. 项目概述从一份源代码到可运行的C狼人杀最近在整理硬盘时翻出了一个几年前写的C狼人杀控制台程序。这算不上什么复杂的项目但麻雀虽小五脏俱全它完整地模拟了狼人杀游戏的核心逻辑角色分配、夜间行动、白天发言投票、胜负判定。对于刚学完C基础语法想找个综合小项目练手的朋友来说这种规模的代码再合适不过了。它不依赖任何图形库纯粹用标准输入输出和标准库完成编译运行的门槛极低。今天我就把这套源代码分享出来并带你一起拆解它的设计思路和关键实现你可以直接复制代码去运行更可以以此为骨架添加你想要的任何新角色或新规则。这份代码的价值不在于它有多“炫技”而在于它清晰地展示了一个小型游戏项目如何从需求分析到代码落地的全过程。你会看到如何用面向对象的思想来设计游戏角色如何用有限状态机来管理游戏流程以及如何处理玩家输入这种充满不确定性的环节。对于初学者这是一个绝佳的“脚手架”你能看到每个类、每个函数为什么这样设计对于有经验的朋友或许其中的一些设计取舍和边界情况处理能带来一些启发。接下来我们就进入正题看看这套代码里到底藏着哪些门道。2. 核心设计思路与架构拆解2.1 需求分析与核心循环设计做任何项目第一步永远是搞清楚要做什么。狼人杀游戏虽然规则大家耳熟能详但用程序实现必须将其抽象为明确的、可执行的状态和事件。我们的核心需求是模拟一个简化版的狼人杀至少包含以下角色狼人夜间杀人、预言家夜间验人、女巫夜间救人/毒人、平民无夜间技能。游戏流程需遵循“夜晚-白天-投票-判决”的循环直到某一阵营达成胜利条件。基于此我采用了经典的“游戏循环Game Loop”配合“状态模式State Pattern”的简化版思想。整个游戏被抽象成一个Game类它内部维护一个游戏状态枚举如GameState::NIGHT,GameState::DAY_DISCUSSION,GameState::DAY_VOTING,GameState::GAME_OVER。主循环根据当前状态调用不同的处理函数。这种设计将复杂的流程分解为离散的、易于管理的步骤逻辑清晰也便于调试和扩展。比如想增加一个“警长竞选”环节只需要新增一个游戏状态并在主循环中相应位置插入处理逻辑即可。2.2 面向对象的角色系统设计角色是游戏的核心。我采用继承体系来实现多态。定义一个基类Role包含所有角色共有的属性如玩家ID、是否存活、所属阵营和行为接口如夜间行动performNightAction。然后派生出Werewolf狼人、Seer预言家、Witch女巫、Villager平民等子类各自重写夜间行动方法。这里有一个关键设计点如何让不同角色在夜间有序行动且互不知晓我的实现是在游戏夜晚阶段Game类遍历所有存活玩家但只对具有夜间技能的角色狼人、预言家、女巫调用其performNightAction方法。调用时程序会清屏或输出大量换行来模拟“其他玩家闭眼”然后提示当前角色玩家进行操作。操作目标通过输入其他玩家的编号来实现。这样虽然程序是顺序执行的但通过输出隔离给玩家营造了“同时秘密行动”的错觉。这种设计简单有效避免了复杂的多线程或事件总线对于控制台小游戏来说非常合适。2.3 数据管理与游戏状态同步游戏中有几个核心数据需要精心管理玩家列表、当前夜晚的“刀型”狼人击杀目标、“验型”预言家查验结果、女巫的“救药”和“毒药”使用状态。这些数据分散在不同的角色对象和Game类中如何保证它们的一致性和时序性至关重要。我采用了一种“集中收集统一结算”的策略。所有夜间行动产生的效果如狼人选择了击杀谁女巫选择救谁或毒谁并不立即生效而是将“意图”记录在Game类的几个临时成员变量里。当所有夜间行动结束后Game类再根据规则结算这些意图。例如结算顺序是先判断女巫是否救人若救则狼刀失效再结算女巫的毒药最后结算未被解救的狼刀。这个结算逻辑集中在一个函数里避免了状态分散导致的逻辑混乱和BUG。白天投票的计票逻辑也类似所有票数收集完毕后再找出得票最多的玩家进行处决。3. 关键代码模块深度解析3.1 玩家Player与角色Role的绑定一个常见的困惑是Player和Role是什么关系在我的设计里Player是一个相对静态的实体主要保存玩家的固定信息比如一个唯一的id和name。而Role是动态的、赋予玩家的能力和身份。我采用了组合模式Player类内部包含一个Role*指针。游戏开始时根据随机分配的结果为每个Player对象实例化一个具体的Role子类对象如new Werewolf()并将其地址赋给Player的role_指针。class Player { public: Player(int id, const std::string name) : id_(id), name_(name), role_(nullptr), alive_(true) {} // ... 其他方法 void assignRole(Role* role) { role_ role; } Role* getRole() const { return role_; } private: int id_; std::string name_; Role* role_; // 关键组合一个角色对象 bool alive_; };这样做的好处是分离了“玩家实体”和“角色能力”。当玩家被“狼人”或“女巫”杀死时我们只需将Player的alive_设为false而其role_指针依然保留方便查询其死亡前的身份进行复盘。游戏结束时也便于统一清理role_指针所指向的动态内存。3.2 夜间行动的逻辑实现以狼人行动为例我们看Werewolf::performNightAction的实现。这个方法需要接收当前游戏上下文如存活玩家列表以选择目标。class Werewolf : public Role { public: Werewolf() : Role(Camp::WEREWOLF, Werewolf) {} void performNightAction(GameContext context) override { // 1. 获取所有存活且非狼人的玩家 auto candidates context.getAlivePlayersNotOfCamp(Camp::WEREWOLF); if (candidates.empty()) { std::cout All non-werewolves are dead! Werewolves skip action.\n; return; } // 2. 提示狼人玩家选择这里简化实际应由对应的Player输入 std::cout Wolf, please choose a player to kill (enter ID):\n; for (const auto p : candidates) { std::cout ID: p-getId() , Name: p-getName() \n; } // 3. 获取输入并验证 int targetId; std::cin targetId; auto target std::find_if(candidates.begin(), candidates.end(), [targetId](Player* p) { return p-getId() targetId; }); if (target ! candidates.end()) { // 4. 将击杀意图提交给游戏上下文而非直接生效 context.submitWolfKillIntent(*target); std::cout Wolf has chosen to kill player (*target)-getName() .\n; } else { std::cout Invalid choice. Werewolves fail to kill tonight.\n; } // 5. 模拟其他玩家等待清屏或输出空行 std::this_thread::sleep_for(std::chrono::seconds(2)); // 实际代码中应有更完善的清屏操作 } };注意这里为了清晰将输入输出直接写在了角色类里。在更解耦的设计中输入输出应通过GameContext抽象接口进行使角色逻辑与UI分离便于未来移植到图形界面。本例为求简洁直观采用了混合写法。3.3 游戏主循环与状态迁移游戏主循环是驱动一切的核心。它看起来可能像一个大的switch-case语句但每个case里的逻辑都值得推敲。void Game::run() { currentState_ GameState::NIGHT; while (currentState_ ! GameState::GAME_OVER) { switch (currentState_) { case GameState::NIGHT: handleNightPhase(); if (checkGameOver()) { currentState_ GameState::GAME_OVER; } else { currentState_ GameState::DAY_DISCUSSION; } break; case GameState::DAY_DISCUSSION: handleDayDiscussion(); currentState_ GameState::DAY_VOTING; break; case GameState::DAY_VOTING: if (handleDayVoting()) { // 投票并处理处决 if (checkGameOver()) { currentState_ GameState::GAME_OVER; } else { currentState_ GameState::NIGHT; // 进入下一夜 } } else { // 平票等情况可能进入第二次讨论或直接进入黑夜 currentState_ GameState::NIGHT; } break; // ... 其他状态 } } announceResult(); }handleNightPhase函数会依次调用狼人、预言家、女巫的夜间行动并最终结算。checkGameOver会在每个可能改变存活玩家数量的环节后被调用实时判断胜负条件是否达成如所有狼人死亡或所有平民死亡。这种“状态驱动”的循环使得程序流非常清晰调试时只需关注当前状态下的逻辑即可。4. 源代码核心部分展示与说明4.1 游戏初始化与角色分配游戏开始的第一步是初始化玩家并随机分配角色。这部分代码的关键在于“无重复随机分配”。void Game::initializePlayers(int playerCount) { players_.clear(); // 创建玩家 for (int i 0; i playerCount; i) { players_.emplace_back(i 1, Player std::to_string(i 1)); } // 定义角色配置例如12人局4狼、4民、1预、1女巫、2个额外角色如猎人、守卫 std::vectorRoleType rolePool { RoleType::WEREWOLF, RoleType::WEREWOLF, RoleType::WEREWOLF, RoleType::WEREWOLF, RoleType::VILLAGER, RoleType::VILLAGER, RoleType::VILLAGER, RoleType::VILLAGER, RoleType::SEER, RoleType::WITCH, // RoleType::HUNTER, // 可扩展角色 // RoleType::GUARDIAN }; // 随机打乱角色池 std::random_device rd; std::mt19937 g(rd()); std::shuffle(rolePool.begin(), rolePool.end(), g); // 为每个玩家分配角色 for (size_t i 0; i players_.size() i rolePool.size(); i) { Role* role createRoleByType(rolePool[i]); // 工厂函数根据类型创建具体角色对象 players_[i].assignRole(role); // 将角色指针也保存到game的列表中方便统一管理内存 roles_.push_back(role); } }实操心得std::shuffle是C11后随机打乱容器的推荐方法比传统的rand() % n更均匀、更现代。记得包含random和algorithm头文件。内存管理方面roles_向量持有所有Role对象的所有权在Game析构时统一释放避免了内存泄漏。4.2 白天投票与票型统计白天投票是游戏互动性最强的环节之一。实现难点在于处理同票、弃票等情况。bool Game::handleDayVoting() { std::cout \n Daytime Voting \n; std::unordered_mapPlayer*, int voteCount; // 票箱 std::vectorPlayer* alivePlayers getAlivePlayers(); for (Player* voter : alivePlayers) { std::cout voter-getName() , please vote (enter player ID, or 0 to abstain):\n; // 显示可投票的存活玩家不能投自己根据规则可调整 for (Player* target : alivePlayers) { if (target ! voter) { // 通常不能投自己 std::cout ID: target-getId() - target-getName() \n; } } int choice; std::cin choice; if (choice 0) { std::cout voter-getName() abstains.\n; continue; } Player* target findPlayerById(choice); // 合法性校验目标必须存在、存活、且不是自己可选 if (target target-isAlive() target ! voter) { voteCount[target]; std::cout voter-getName() votes for target-getName() .\n; } else { std::cout Invalid vote. It is considered as abstention.\n; } } // 找出得票最多的玩家 if (voteCount.empty()) { std::cout No valid votes. No one is executed today.\n; return false; } auto maxEntry std::max_element(voteCount.begin(), voteCount.end(), [](const auto a, const auto b) { return a.second b.second; }); int maxVotes maxEntry-second; // 检查是否有人得票最高且唯一 int countMax std::count_if(voteCount.begin(), voteCount.end(), [maxVotes](const auto entry) { return entry.second maxVotes; }); if (countMax 1) { std::cout Tie! Players with maxVotes votes: ; for (const auto entry : voteCount) { if (entry.second maxVotes) { std::cout entry.first-getName() ; } } std::cout \nNo one is executed due to tie.\n; return false; } else { Player* executed maxEntry-first; std::cout executed-getName() is executed with maxVotes votes.\n; executed-setAlive(false); // 触发被处决玩家的技能如猎人 triggerOnDeathAbility(executed); return true; // 表示成功处决一人 } }这段代码完整展示了投票、计票、平票处理的逻辑。使用std::unordered_map作为票箱非常合适键是玩家指针值是票数。std::max_element和std::count_if算法使得查找最高票和统计平票变得简洁。4.3 胜负判定逻辑胜负判定需要不断检查因此我将其抽象成一个独立的函数checkGameOver它在游戏循环的多个节点被调用。Camp Game::checkGameOver() { int werewolfCount 0; int villagerCount 0; // 平民 神职预言家、女巫等 int godCount 0; // 单独统计神职某些规则下神职全亡也算狼赢 for (const auto player : players_) { if (player.isAlive()) { Role* role player.getRole(); if (role-getCamp() Camp::WEREWOLF) { werewolfCount; } else if (role-getCamp() Camp::VILLAGER) { villagerCount; // 简单区分神职和平民根据角色类型 if (dynamic_castSeer*(role) || dynamic_castWitch*(role)) { godCount; villagerCount--; // 从平民计数中剔除或单独统计 } } } } // 判定条件1狼人全部出局村民阵营胜利 if (werewolfCount 0) { return Camp::VILLAGER; } // 判定条件2平民全部出局狼人阵营胜利常见规则 if (villagerCount 0) { return Camp::WEREWOLF; } // 判定条件3神职全部出局狼人阵营胜利某些规则 // if (godCount 0) { return Camp::WEREWOLF; } // 判定条件4狼人数量 村民数量狼人阵营胜利屠边规则 if (werewolfCount (villagerCount godCount)) { return Camp::WEREWOLF; } // 游戏继续 return Camp::NONE; }注意事项胜负规则是狼人杀游戏最灵活的部分。上述代码展示了常见的“屠边”狼人杀死所有平民或所有神职即赢和“屠城”狼人杀死所有非狼人即赢规则的混合判断。在实际项目中最好将胜负规则抽象成一个可配置的策略类方便切换不同玩法。5. 编译、运行与自定义扩展指南5.1 环境准备与编译这个项目是纯C标准库项目不依赖任何第三方库因此编译非常简单。你只需要一个支持C11或以上标准的编译器。Windows (使用 MinGW 或 Visual Studio)MinGW (推荐轻量级):安装MinGW-w64将g添加到系统PATH。在源代码目录打开命令行执行g -stdc11 main.cpp game.cpp player.cpp role.cpp -o werewolf.exeVisual Studio:创建一个空的控制台项目将所有.cpp和.h文件添加到项目中直接编译运行即可。Linux/macOSg -stdc11 main.cpp game.cpp player.cpp role.cpp -o werewolf -pthread如果使用了std::this_thread::sleep_for需要-pthread链接选项现代GCC通常不需要但加上更安全编译成功后运行生成的可执行文件Windows下是werewolf.exeLinux/macOS下是./werewolf即可开始游戏。5.2 如何运行游戏程序启动后通常会提示输入玩家人数然后自动分配角色。游戏进程完全由控制台文字引导夜晚阶段屏幕会依次提示“狼人请睁眼”、“预言家请睁眼”等操作玩家根据提示输入目标玩家ID。白天阶段公布夜间信息谁死了是否被救然后进入轮流发言本例简化了发言可能直接进入投票和投票环节。重复1-2步直到某一阵营胜利。给新手的提示第一次运行时建议用4-6人小局测试角色配置简单如2狼1预1民这样可以快速走完整个流程理解程序逻辑。5.3 自定义角色与规则扩展这是本项目最大的乐趣所在。假设你想添加一个“猎人”角色死亡时可以开枪带走一人。创建新角色类继承Role类。// hunter.h #pragma once #include role.h class Hunter : public Role { public: Hunter(); void performNightAction(GameContext context) override; // 猎人特有的技能死亡时触发 void triggerDeathSkill(GameContext context, Player* hunter); private: bool hasGun_; };实现技能逻辑在hunter.cpp中performNightAction可能为空猎人夜间无行动重点实现triggerDeathSkill在函数内让猎人选择一名玩家将其状态设置为死亡。集成到游戏流程在Game类的handleDayVoting或结算死亡的函数中当有玩家被处决或毒杀时检查其角色是否是Hunter如果是则调用triggerDeathSkill。更新角色创建工厂修改Game::createRoleByType函数增加RoleType::HUNTER的分支返回new Hunter()。更新胜负判定根据新角色的阵营调整checkGameOver中的计数逻辑。按照这个模式你可以添加“守卫”、“丘比特”、“白狼王”等任何你喜欢的角色甚至可以修改游戏流程增加“警徽”、“警长”等机制。代码的结构设计就是为了这种扩展而准备的。6. 常见问题排查与调试技巧6.1 编译错误与解决‘sleep_for’ is not a member of ‘std::this_thread’原因编译器未启用C11线程支持或者缺少头文件。解决确保编译命令中有-stdc11或更高标准。并包含头文件thread和chrono。undefined reference to ‘WinMain16’(MinGW)原因编译器试图编译成GUI程序但入口点是main。解决检查是否误将.cpp文件命名成了.win或其他。确保主文件中有int main()函数。vector、unordered_map等模板类报错原因通常是因为没有包含对应的头文件vector,unordered_map或者使用了不存在的成员函数。解决仔细检查代码中的容器操作查阅C参考手册确认用法。6.2 运行时逻辑错误问题夜间行动时玩家可以选自己或已死亡的玩家。排查检查performNightAction函数中生成候选列表的逻辑。context.getAlivePlayersNotOfCamp的实现必须正确过滤掉死人和同阵营玩家。在获取玩家输入后必须验证目标ID是否在候选列表中。技巧在调试阶段可以在验证失败时打印出候选列表和输入值一目了然。问题女巫的解药和毒药可以重复使用。排查检查Witch类中是否用布尔值antidoteUsed_和poisonUsed_正确记录了药瓶状态。在performNightAction中提供选项前要先判断药瓶是否可用。结算后如果使用了药瓶要立即更新状态。心得对于这类“一次性”或“有限次”技能最好的方法是在角色类内部封装状态并提供明确的查询和修改接口避免状态被意外修改。问题游戏在第一天投票后就莫名结束了或者永不结束。排查这是胜负判定逻辑checkGameOver的BUG。首先在游戏开始时和每个玩家状态改变后死亡、被救打印出所有存活玩家的阵营信息确认计数是否正确。其次仔细检查判定条件。例如“狼人数量 村民数量”这个条件你的“村民”是否包含了神职计数是否正确调试方法在checkGameOver函数开头和每个return语句前打印出当前的werewolfCount,villagerCount等值。这是最直接的调试手段。6.3 内存管理问题项目使用了原始指针Role*并通过roles_向量统一管理。这是清晰的但需要注意确保所有权唯一Role对象只由Game::roles_创建和持有。Player类中的role_指针只是观察者非拥有。在Game的析构函数中务必遍历roles_并delete每一个指针。避免悬空指针游戏过程中Player对象可能被移除虽然本例中只是标记死亡。但只要Game对象还存在roles_中的Role对象就不会被释放因此Player::role_指针始终有效。更现代的做法如果使用C11或以上强烈建议使用std::unique_ptrRole来管理角色对象这样可以完全避免手动delete和内存泄漏的风险。将roles_的类型改为std::vectorstd::unique_ptrRolePlayer::role_可以改为Role*观察指针或std::weak_ptrRole如果改用shared_ptr。6.4 输入处理与鲁棒性控制台程序最脆弱的一环就是用户输入。上面的示例代码对输入错误做了基本处理如无效投票视为弃权但还可以加强防御非数字输入使用std::cin读取整数时如果用户输入了字母会导致流进入错误状态后续所有输入都会失败。解决方法是先读取字符串再尝试转换为数字或者使用std::cin.clear()和std::cin.ignore()清除错误状态。int getNumberInput() { int value; while (!(std::cin value)) { std::cout Invalid input. Please enter a number: ; std::cin.clear(); // 清除错误标志 std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); // 丢弃错误输入 } return value; }输入边界检查对于玩家ID的选择不仅要检查是否存在、是否存活还要检查是否在本次操作允许的范围内如女巫不能救自己预言家可以验自己吗。这些规则细节都需要在输入验证逻辑中体现。把这个项目跑起来然后试着去修改它、打破它、再修复它是学习C面向对象设计和游戏逻辑建模最快的方式。代码本身只是一个起点更多的可能性在于你的想象力。
返回列表