
1. 项目概述一个可复用的C狼人杀游戏框架最近在整理旧项目时翻出了一个几年前用C写的狼人杀游戏命令行版本。这个项目最初是为了练习面向对象设计和多线程编程而写的后来断断续续加了不少功能像角色技能、游戏日志、网络对战雏形等等。代码量不大核心逻辑大概两千行左右但麻雀虽小五脏俱全把狼人杀的核心轮次、角色互动、状态判断都实现了。我觉得它的价值不在于多炫酷的界面而在于提供了一个清晰、可扩展的架构。无论是想学习C面向对象设计、理解状态机模型还是想自己动手添加新角色、改成图形界面甚至网络服务这份代码都是一个不错的起点。它完全开源你可以直接复制、修改不用担心任何版权问题。接下来我就把这个项目的核心设计思路、关键代码实现以及我踩过的一些坑详细拆解一遍。2. 核心设计思路与架构拆解写一个狼人杀游戏最忌讳的就是把所有逻辑都塞进main函数里。那会变成一场维护灾难。我的核心思路是**“高内聚、低耦合”**用面向对象的方式将游戏中的各种概念抽象成独立的类让它们各司其职。2.1 核心类设计角色、游戏与状态整个游戏围绕几个核心类展开Player玩家类这是所有角色的基类。它包含玩家的基本属性ID、姓名、是否存活、所属阵营好人、狼人、第三方。更重要的是它定义了一个虚函数NightAction()和DayAction()所有具体角色如狼人、预言家、女巫都会重写这些函数来实现自己的技能。Role角色类继承自Player。这里我设计了一个Role工厂类用于根据角色名称字符串如werewolf,seer创建对应的具体角色对象。像Werewolf、Seer、Witch、Hunter这些类都继承自Role并实现了自己独特的行动逻辑。Game游戏控制类这是游戏的大脑一个单例类。它负责管理游戏的生命周期初始化玩家和角色、主持昼夜交替、处理玩家的行动请求、判断游戏状态进行中、好人胜利、狼人胜利。它持有一个Player的列表并维护当前游戏阶段夜晚、白天、投票、遗言等。GameState游戏状态类我将游戏流程建模为一个状态机。GameState是一个基类有EnterState(),Execute(),ExitState()等方法。具体的状态如NightState夜晚行动、DiscussState白天讨论、VoteState投票、LastWordsState遗言都继承自它。Game类持有当前状态指针通过调用currentState-Execute()来驱动游戏流程并在适当时候转换到下一个状态。这种方式让游戏流程的控制变得非常清晰添加新阶段比如“警长竞选”也很容易。2.2 通信与交互基于命令模式的解耦玩家如何向游戏核心发出指令比如狼人在夜晚选择要杀的玩家平民在白天投票。我采用了命令模式Command Pattern。我定义了一个Action基类包含Execute(Game game)和IsValid()等方法。然后派生出具体的命令KillAction狼人杀人、CheckAction预言家查验、SaveAction女巫救人、VoteAction玩家投票等。每个Player对象在需要行动时不是直接修改Game的数据而是生成一个对应的Action对象提交给Game。Game在一个队列中收集所有玩家的行动然后在合适的时机如夜晚结束、投票截止批量验证并执行这些行动。这样做的好处是解耦Player不知道Game内部如何执行只负责产生意图。集中验证Game可以统一检查行动合法性比如女巫不能救同一个人两次。支持撤销/重做理论上可以保存行动序列用于复盘或实现“悔棋”功能。易于扩展网络功能网络客户端发送过来的就是一个序列化的Action命令服务器端解析后执行即可。2.3 数据存储与日志可追溯的游戏历程为了方便调试和复盘一个详尽的游戏日志必不可少。我实现了一个简单的Logger单例类支持不同级别INFO, WARN, DEBUG的日志输出。关键事件都会被记录[INFO] 游戏开始。玩家列表1-张三(狼人), 2-李四(预言家)... [DEBUG] 进入夜晚阶段。 [INFO] 狼人团队张三选择击杀目标李四。 [INFO] 女巫王五使用解药拯救了李四。 [INFO] 进入白天阶段。昨晚是平安夜。日志不仅输出到控制台也会写入一个文件。这对于分析复杂对局、排查BUG比如为什么某个技能没生效有巨大帮助。在架构上Logger被设计为全局可访问但通过宏定义在发布版本中可以关闭DEBUG日志以减少开销。3. 关键代码模块深度解析光讲设计太抽象我们直接看几个最核心的代码模块理解它们是如何协作的。3.1 角色工厂与多态行动这是实现角色多样性的关键。首先看角色工厂// RoleFactory.h class RoleFactory { public: static std::shared_ptrPlayer CreateRole(const std::string roleName, int id, const std::string playerName) { if (roleName werewolf) { return std::make_sharedWerewolf(id, playerName); } else if (roleName seer) { return std::make_sharedSeer(id, playerName); } else if (roleName witch) { return std::make_sharedWitch(id, playerName); } else if (roleName hunter) { return std::make_sharedHunter(id, playerName); } else if (roleName villager) { return std::make_sharedVillager(id, playerName); } // 默认返回平民 return std::make_sharedVillager(id, playerName); } };在游戏初始化时Game类读取配置或随机分配一个角色名列表然后循环调用RoleFactory::CreateRole来创建玩家对象并存入m_players列表。接下来看具体角色的行动实现。以Werewolf为例// Werewolf.cpp std::shared_ptrAction Werewolf::NightAction() { if (!IsAlive()) return nullptr; // 死亡角色不行动 // 这里简化处理在实际中这里应该触发UI或网络请求让狼人玩家选择目标。 // 本例中我们模拟一个简单的AI随机选择一个存活的好人玩家。 int targetId SelectRandomAlivePlayerFromFaction(Faction::Good); if (targetId ! -1) { // 创建一个“杀人”命令 return std::make_sharedKillAction(GetId(), targetId); } return nullptr; } void Werewolf::OnGameStart() { // 游戏开始时狼人需要知道自己的队友是谁。 // Game类会调用每个角色的这个函数并传递全体玩家列表。 // 狼人在这里可以筛选出队友用于夜间讨论如果实现讨论功能的话。 auto teammates Game::GetInstance()-GetPlayersByRole(werewolf); m_teammates teammates; // 存储队友信息 }Seer预言家和Witch女巫的实现类似但生成的是CheckAction和SaveAction/PoisonAction。Game在夜晚阶段会遍历所有存活玩家调用其NightAction()方法收集返回的Action对象存入一个待处理行动列表。注意这里有一个重要的设计取舍。我将行动选择逻辑AI放在了角色类内部。这有利于封装但不利于实现复杂的人机交互。对于需要玩家输入的情况更好的做法是让NightAction()返回一个“等待输入”的状态由Game或一个专门的InputHandler去获取输入再回调角色类的方法来生成具体的Action。我的代码中保留了这种扩展的可能性。3.2 游戏状态机的流转状态机是控制游戏节奏的核心。我们看看Game类的主循环和状态转换// Game.cpp void Game::Start() { m_logger-Log(INFO, 游戏开始。); ChangeState(std::make_uniqueNightState()); // 初始状态为夜晚 while (m_gameStatus GameStatus::RUNNING) { m_currentState-Execute(*this); // 执行当前状态逻辑 // 状态内部可能会调用Game::ChangeState来切换状态 } AnnounceResult(); } void Game::ChangeState(std::unique_ptrGameState newState) { if (m_currentState) { m_currentState-ExitState(*this); } m_logger-Log(DEBUG, 游戏状态切换: m_currentState-GetName() - newState-GetName()); m_currentState std::move(newState); m_currentState-EnterState(*this); }以NightState为例// NightState.cpp void NightState::Execute(Game game) { m_logger-Log(INFO, 夜晚降临 ); // 1. 唤醒有夜间技能的角色狼人、预言家、女巫... auto players game.GetPlayers(); std::vectorstd::shared_ptrAction nightActions; for (auto player : players) { if (player-IsAlive() player-HasNightAction()) { auto action player-NightAction(); if (action) { nightActions.push_back(action); } } } // 2. 处理行动顺序和冲突例如女巫的救人与狼人的杀人 // 通常顺序是狼人杀人 - 女巫救人/毒人 - 预言家查验 // 这里需要对nightActions按角色优先级排序和处理 ProcessActionSequence(game, nightActions); // 3. 执行所有有效的夜间行动 for (auto action : nightActions) { if (action-IsValid(game)) { action-Execute(game); m_logger-Log(INFO, action-GetDescription()); } } // 4. 检查是否有玩家在夜晚死亡触发技能如猎人 game.ResolveNightDeaths(); // 5. 夜晚结束切换到白天讨论阶段 game.ChangeState(std::make_uniqueDiscussState()); }DiscussState和VoteState的实现模式类似但处理的是白天讨论发言和投票逻辑。投票环节尤其复杂需要处理平票、PK、弃票等情况。3.3 行动命令的执行与验证命令模式的核心在于Action的执行。我们深入看一下KillAction和VoteAction。// KillAction.cpp bool KillAction::IsValid(const Game game) const { // 验证1行动发起者是否存活且是狼人 auto sourcePlayer game.FindPlayer(m_sourceId); if (!sourcePlayer || !sourcePlayer-IsAlive() || sourcePlayer-GetFaction() ! Faction::Werewolf) { return false; } // 验证2目标是否存活 auto targetPlayer game.FindPlayer(m_targetId); if (!targetPlayer || !targetPlayer-IsAlive()) { return false; } // 验证3狼人不能自杀除非特殊规则 if (m_sourceId m_targetId) { return false; } // 验证4今夜是否已被守护或其他技能保护这里需要查询Game中的夜间保护状态 if (game.IsPlayerProtected(m_targetId)) { m_logger-Log(INFO, 目标玩家被守护击杀无效。); return false; // 或者执行但无效取决于规则 } return true; } void KillAction::Execute(Game game) { if (!IsValid(game)) { return; } auto targetPlayer game.FindPlayer(m_targetId); targetPlayer-SetAlive(false); targetPlayer-SetCauseOfDeath(CauseOfDeath::WEREWOLF_KILL); game.MarkPlayerForDeath(m_targetId); // 标记为待处理死亡 // 注意这里不立即移出游戏因为女巫可能救人死亡结算在阶段最后统一进行。 }VoteAction的IsValid需要检查投票者是否存活、是否已经投过票、投票对象是否合法不能投自己除非规则允许等。Execute则会更新游戏的投票计数器。实操心得行动验证IsValid的逻辑一定要和规则说明书保持一致并且要放在Execute之前。我最初把部分验证逻辑散落在Execute里导致一些非法行动执行了一半才失败留下了不一致的游戏状态很难调试。后来我严格遵循“先验证后执行”的原则并且IsValid尽量做成无副作用的纯查询函数。4. 游戏流程的完整实现与扩展有了上面的基础一个完整的游戏流程就可以串联起来了。我以一场最简单的预女猎守局为例走一遍代码流程。4.1 初始化与游戏设置游戏启动后首先进入初始化阶段。这部分代码通常在一个配置文件中如game_config.json或者通过命令行参数指定。// 模拟初始化代码 Game game Game::GetInstance(); // 添加玩家和角色 game.AddPlayer(RoleFactory::CreateRole(werewolf, 1, 玩家A)); game.AddPlayer(RoleFactory::CreateRole(werewolf, 2, 玩家B)); game.AddPlayer(RoleFactory::CreateRole(seer, 3, 玩家C)); game.AddPlayer(RoleFactory::CreateRole(witch, 4, 玩家D)); game.AddPlayer(RoleFactory::CreateRole(hunter, 5, 玩家E)); game.AddPlayer(RoleFactory::CreateRole(villager, 6, 玩家F)); game.AddPlayer(RoleFactory::CreateRole(villager, 7, 玩家G)); game.AddPlayer(RoleFactory::CreateRole(villager, 8, 玩家H)); game.SetGameRule(GameRule::STANDARD); // 设置标准规则 game.Start(); // 开始游戏进入主循环Game::Start()会触发OnGameStart事件所有角色特别是狼人会收到通知可以进行内部初始化如狼人认识队友。4.2 昼夜交替与行动处理游戏进入主循环状态机开始工作。第一夜NightState::Execute被调用。遍历玩家狼人玩家A、B的NightAction()被调用他们通过AI或未来的人机界面选择击杀目标比如玩家F平民。生成KillAction(1, 6)和KillAction(2, 6)。实际上狼人团队通常只有一个统一的杀人指令这里需要设计一个狼人团队的协调机制。在我的实现中我让第一个狼人产生指令其他狼人跳过或表示同意。女巫玩家D的NightAction()被调用她得知玩家F被杀可以选择使用解药拯救生成SaveAction(4, 6)或者使用毒药毒杀另一人。预言家玩家C的NightAction()被调用他选择查验一人比如玩家A生成CheckAction(3, 1)。ProcessActionSequence会按照规则顺序处理这些行动先结算狼人杀人玩家F标记为濒死再结算女巫行动使用解药清除玩家F的濒死标记最后结算预言家查验游戏日志记录“玩家A的身份是狼人”。夜晚结束进入DiscussState。第一天白天DiscussState::Execute模拟发言环节。在我的命令行版本中这可能只是简单的日志输出和AI生成发言内容。更复杂的实现会在这里等待玩家输入。发言结束后进入VoteState。每个存活玩家通过AI决定投票给谁生成VoteAction。VoteState统计票数。假设玩家A狼人被高票出局。玩家A死亡触发其OnDeath事件。因为他是狼人没有立即技能。进入LastWordsState遗言。遗言后检查游戏是否结束狼人全部出局好人神职全部出局。如果没有再次进入NightState。第二夜及以后流程类似但女巫可能已用完解药预言家可能已死局势不断变化。Game类持续检查胜利条件void Game::CheckWinCondition() { int aliveWerewolves CountAlivePlayersByFaction(Faction::Werewolf); int aliveGoods CountAlivePlayersByFaction(Faction::Good); // 包括神和平民 int aliveGods CountAlivePlayersByRoleType(RoleType::God); // 仅神职 if (aliveWerewolves 0) { m_gameStatus GameStatus::GOOD_WINS; } else if (aliveGoods aliveWerewolves) { // 狼人屠边规则好人数量神民小于等于狼人数量 m_gameStatus GameStatus::WEREWOLF_WINS; } else if (aliveGods 0) { // 或者神职全部死亡 m_gameStatus GameStatus::WEREWOLF_WINS; } // 还可以加入第三方阵营的胜利条件 }4.3 如何扩展新角色与规则这套架构的优势在于扩展性。假设我们要加入一个“守卫”角色每晚可以守护一个人不能连续两晚守护同一人防止其被狼人杀害。创建新角色类新建Guardian类继承自Role。实现特有逻辑重写NightAction()返回一个GuardAction。在Guardian类内部需要记录上一晚守护的是谁以实现“不能连续守同一人”的规则。创建新行动类新建GuardAction类继承自Action。它的IsValid需要检查上述连续守护规则。它的Execute会调用game.ProtectPlayer(targetId)设置一个全局保护状态。修改工厂和行动处理在RoleFactory中添加guardian的创建分支。在NightState::ProcessActionSequence中需要确定GuardAction的执行顺序通常在最前面因为守护先于狼人行动生效。修改规则判断在KillAction::IsValid中我们已经有了game.IsPlayerProtected(m_targetId)的检查现在这个函数会返回GuardAction设置的保护状态。通过这种方式添加新角色主要工作是实现独立的类对原有核心逻辑的侵入性很小。5. 常见问题、调试技巧与性能考量在开发和测试这个项目过程中我遇到了不少典型问题这里总结一下方便你避坑。5.1 内存管理与智能指针C项目最怕内存泄漏。在这个项目中玩家对象、行动对象、状态对象生命周期清晰非常适合使用std::shared_ptr进行自动内存管理。std::vectorstd::shared_ptrPlayer m_players; std::unique_ptrGameState m_currentState;m_players使用shared_ptr因为一个玩家对象可能被多个地方引用比如狼人队友列表、投票关系等。m_currentState使用unique_ptr因为游戏状态在Game中具有明确的独占所有权状态切换时直接std::move转移所有权即可。关键点要小心循环引用。比如如果Player对象内部持有指向Game的shared_ptr而Game又持有该Player的shared_ptr就会形成循环导致内存泄漏。在这种情况下Player持有Game的引用通常使用原始指针或weak_ptr。在我的设计中Player通过Game::GetInstance()单例访问全局状态或者由Game在调用Player方法时将自己作为参数传入避免了循环引用。5.2 多线程与资源竞争最初的版本我尝试为每个玩家模拟一个“思考线程”结果迅速陷入了死锁和竞态条件的泥潭。对于回合制游戏单线程事件循环是更简单可靠的选择。所有游戏状态更新都必须在主线程游戏循环线程中进行。玩家的“输入”无论是AI决策还是网络消息都先放入一个线程安全的队列如std::queue 互斥锁。在主循环的每个状态Execute中从队列中取出并处理这些输入将其转换为Action。这样可以避免在遍历玩家列表时列表被其他线程修改也避免了行动执行顺序的不确定性。// 简化的线程安全输入队列 class ActionQueue { public: void Push(std::shared_ptrAction action) { std::lock_guardstd::mutex lock(m_mutex); m_queue.push(action); } std::shared_ptrAction Pop() { std::lock_guardstd::mutex lock(m_mutex); if (m_queue.empty()) return nullptr; auto action m_queue.front(); m_queue.pop(); return action; } private: std::queuestd::shared_ptrAction m_queue; std::mutex m_mutex; };5.3 游戏状态的一致性与BUG排查狼人杀逻辑复杂一个细微的BUG可能导致整个游戏逻辑崩盘。我的调试经验是详尽的日志这是最重要的工具。为每个关键步骤、每次状态转换、每个行动的执行和验证都打上日志。日志级别要分级在开发时打开DEBUG发布时关闭。状态快照与复盘在游戏每个阶段结束时将关键游戏状态存活玩家、角色、已使用技能等序列化保存下来。当出现异常时可以回放复盘精准定位问题发生的时间点。单元测试为核心类编写单元测试。特别是Action::IsValid和Game::CheckWinCondition这类纯逻辑函数很容易用测试用例覆盖各种边界情况如平票、最后一神一狼一民、女巫自救等。使用断言在代码中合理使用assert。例如在Game::FindPlayer中如果传入的ID找不到对应玩家在调试版本中可以用assert立即崩溃并给出调用栈这比在Release版本中产生难以追踪的运行时错误要好得多。5.4 性能优化点对于一个小型命令行游戏性能通常不是瓶颈。但如果要支持大量AI对局模拟用于训练AI或平衡测试则需考虑避免频繁的动态内存分配在游戏循环中频繁创建/销毁Action对象和状态对象可能带来开销。可以考虑使用对象池Object Pool来复用这些对象。使用更高效的数据结构根据玩家ID快速查找玩家使用std::unordered_mapint, std::shared_ptrPlayer比遍历std::vector更高效。简化AI决策逻辑如果AI逻辑非常复杂比如基于概率树搜索会成为性能热点。需要优化算法或者设置决策时间限制。这个C狼人杀项目从设计到实现是一个典型的面向对象和软件设计模式的练习场。它涉及了工厂模式、状态模式、命令模式、单例模式等的实际应用。代码本身可能不完美但结构清晰易于理解和扩展。你可以直接用它来学习C也可以以此为骨架添加上图形界面用Qt或SFML、网络功能用Boost.Asio或简单的WebSocket或者实现更复杂的AI。希望这份详细的拆解能给你带来一些启发。如果你在复现或扩展过程中遇到问题欢迎随时交流很多细节都是在不断调试和重构中打磨出来的。