C++算法模拟器:用贪心、BFS、DP实现游戏角色AI对抗

发布时间:2026/8/6 7:22:08
C++算法模拟器:用贪心、BFS、DP实现游戏角色AI对抗 1. 项目概述当算法竞赛遇上游戏设计几年前我还在算法竞赛的圈子里打转每天琢磨着怎么用更优的时间复杂度去解决那些经典的图论、动态规划问题。后来机缘巧合我开始接触游戏开发突然发现那些曾经让我绞尽脑汁的算法在游戏世界里找到了绝佳的用武之地。比如一个简单的BFS广度优先搜索可以用来实现游戏中的寻路系统状态压缩DP动态规划能优雅地处理复杂的游戏状态而贪心算法则可能是设计一个公平又有趣的AI对手的关键。“超能力者大赛”这个项目正是这种跨界思维的产物。它不是一个传统的、画面华丽的商业游戏而是一个基于控制台的、以算法逻辑为核心的模拟器。它的核心目标是让你亲手用C将一群拥有不同“超能力”本质上是不同的算法行为模式的角色放在一个竞技场中观察他们如何博弈、对抗并最终决出胜负。这听起来有点像“吃鸡”或“大逃杀”的简化版但内核完全不同——我们关注的是算法策略的对抗性而非图形渲染或用户操作。这个项目非常适合两类朋友一是有一定C基础想通过一个综合性项目提升工程能力的开发者二是对算法感兴趣想看看抽象的逻辑如何转化为具体、可交互的模拟的爱好者。你不需要掌握复杂的图形库如OpenGL或Unity只需要一个能跑C的编译器比如VS Code MinGW或者Visual Studio和一颗好奇心。我们将从零开始构建角色、定义能力、设计规则、实现核心循环并最终得到一个可以运行、可以观察、甚至可以由你定制规则的完整程序。2. 核心设计思路从抽象规则到具体代码在动手写代码之前我们必须把“超能力者大赛”这个模糊的想法拆解成清晰、可实现的模块。一个好的设计能让你在编码时思路清晰避免后期陷入混乱的重构。2.1 游戏世界与核心规则定义首先我们需要一个“舞台”。为了简化我们设计一个二维网格世界。假设它是一个 20x20 的方格地图每个格子可以容纳一个角色。角色们在这个地图上移动、使用能力、相互对抗。游戏的核心规则采用回合制。这非常关键因为回合制给了我们充足的时间去计算每个角色的复杂决策算法而不用担心实时渲染带来的性能压力。每一回合所有存活的角色按一定顺序比如随机顺序或根据某个属性依次执行他们的“行动”。一个完整的行动可能包括感知环境收集信息、决策选择移动方向或使用能力、执行移动或发动攻击/效果。胜利条件很简单最后一个存活在场地上的角色获胜。这模拟了“大逃杀”的最终生存者模式。2.2 “超能力”的算法化诠释“超能力”是这个项目的灵魂。我们不能把它做成简单的属性增减而是要赋予每个能力独特的算法逻辑。这里我们设计四个初始角色分别对应四种经典的算法思想“闪现者” - 贪心算法的化身它的能力是“闪现”。每回合它会计算移动到哪个格子能最大化其“收益”。这个收益函数可以是“距离最近敌人的距离的负值”即离敌人越近收益越高也可以是“移动到资源点”。它总是选择当前看来最优的一步目光短浅但反应迅速。这完美体现了贪心算法“局部最优”的特点。“预言家” - 动态规划的窥探者它的能力是“预判”。它不会只看眼前而是会尝试向前推演未来2-3个回合可能发生的情况包括对手的可能行动。基于这个推演它选择一个从长远看多个回合累计收益最高的行动。这需要维护一个“状态”的概念并计算状态转移的代价是动态规划思想的简化体现。“追踪者” - 图论搜索的执行者它的能力是“精准追踪”。它每回合会以自己为中心使用广度优先搜索BFS或Dijkstra算法扫描整个地图找到所有敌人的位置并规划出一条避开障碍如果有的话到达最近敌人的最短路径。然后沿着这条路径移动一步。这展示了图论算法在路径规划和全局感知中的应用。“混沌法师” - 随机化与概率的代言人它的能力是“混沌波动”。它的行动完全随机或者按照某种概率分布进行。比如有50%概率向随机方向移动30%概率对随机敌人造成伤害20%概率给自己加一个随机buff。它代表了算法中的随机化策略在复杂环境中有时能产生意想不到的效果也是对确定性算法的一种补充和干扰。设计心得为每个能力设计一个清晰、独立的决策函数接口至关重要。例如每个角色类都有一个makeDecision(const GameState state)的纯虚函数。这样新增角色只需要继承并实现这个接口极大地提高了扩展性。2.3 系统架构与类设计基于以上思路我们可以规划出几个核心的类Game游戏主控类。负责初始化、运行主循环、判断游戏结束、管理回合。GameState游戏状态类。这是一个只读的、快照式的数据结构包含当前所有角色的位置、血量、状态以及地图信息。它被传递给每个角色用于决策确保角色不能直接修改游戏世界只能通过返回“行动指令”来影响世界。这是保持逻辑清晰和数据安全的关键。Character基类角色基类。包含通用属性ID、位置、血量、状态和虚函数接口决策函数makeDecision 执行函数act。Blinker, Prophet, Tracker, ChaosMage继承自Character的具体角色类。它们各自实现独特的makeDecision逻辑。Action行动指令类。这是角色决策的产出可能是一个移动指令MOVE_TO [x, y]一个攻击指令ATTACK [target_id]或者一个使用技能的指令USE_ABILITY。Game类收集所有角色的Action然后按顺序安全地执行它们解决可能的冲突比如两个角色想移动到同一格。这种“状态快照 - 角色决策 - 行动收集 - 统一执行”的流水线是回合制策略模拟的经典架构逻辑清晰且易于调试。3. 核心模块实现详解有了设计图我们开始砌砖。这里我会重点讲解几个核心模块的实现细节和背后的考量。3.1 游戏状态GameState的设计与实现GameState是整个模拟的“事实来源”。它必须高效、不可变对角色而言并且包含决策所需的所有信息。// GameState.h #pragma once #include vector #include memory #include Character.h // 前向声明可能更好这里简单包含 struct GameState { int currentRound; int mapWidth, mapHeight; // 使用智能指针管理角色避免内存泄漏 std::vectorstd::shared_ptrCharacter characters; // 可以扩展地形信息、资源点等 // std::vectorstd::vectorTile mapGrid; // 关键方法提供一个只读的角色视图和地图视图 const std::vectorstd::shared_ptrCharacter getCharacters() const { return characters; } // 辅助方法查找特定角色计算距离等 std::shared_ptrCharacter findCharacterById(int id) const; double calculateDistance(int id1, int id2) const; bool isPositionValid(int x, int y) const; // 检查位置是否在地图内且未被阻挡 };为什么使用std::shared_ptr在模拟中角色对象会被频繁地传递和访问例如在GameState中存储被各个决策函数读取。使用原始指针容易导致所有权混乱和内存泄漏。std::shared_ptr实现了引用计数当最后一个持有该角色指针的对象如GameState 或某个正在计算的角色释放它时角色对象会被自动销毁安全省心。“只读”的重要性GameState提供给角色决策函数时必须是const引用。这强制角色只能读取当前状态而不能修改其他角色或地图。所有对世界的修改都必须通过返回Action 由Game主循环在下一阶段统一、顺序地处理。这避免了并发修改带来的数据竞争和不可预测的bug尤其在模拟复杂交互时。3.2 角色基类与能力接口角色基类定义了所有角色的共同框架。// Character.h #pragma once #include Action.h #include memory class GameState; // 前向声明 class Character { protected: int id; std::string name; int health; int positionX, positionY; // 可能还有能量、冷却时间等属性 public: Character(int id, std::string name, int x, int y, int health); virtual ~Character() default; // 核心基于当前游戏状态做出决策返回一个行动 virtual Action makeDecision(const GameState state) 0; // 执行行动的效果由Game类调用修改角色自身状态 virtual void applyAction(const Action action); // Getter 和 Setter int getId() const { return id; } // ... bool isAlive() const { return health 0; } };纯虚函数makeDecision这是多态的关键。Game主循环遍历所有角色时只需要调用character-makeDecision(state) 无需关心具体是哪种角色。具体的决策逻辑完全由子类实现。接下来我们以实现“追踪者”Tracker的BFS寻路为例看看如何实现一个具体的决策逻辑。// Tracker.cpp (部分) #include Tracker.h #include queue #include unordered_map #include algorithm Action Tracker::makeDecision(const GameState state) { if (!isAlive()) return Action(ActionType::IDLE); // 死亡角色不行动 // 1. 使用BFS找到最近的敌人 std::queuestd::pairint, int q; // 队列存储 (x, y) std::unordered_mapint, std::unordered_mapint, std::pairint, int cameFrom; // 记录路径 (x, y) - (parentX, parentY) std::unordered_mapint, std::unordered_mapint, bool visited; // 访问标记 q.push({positionX, positionY}); visited[positionX][positionY] true; cameFrom[positionX][positionY] {-1, -1}; // 起点的父节点设为无效 int targetX -1, targetY -1; std::vectorstd::pairint, int directions {{1,0},{-1,0},{0,1},{0,-1}}; // 四方向移动 while (!q.empty()) { auto [x, y] q.front(); q.pop(); // 检查这个位置是否有敌人非自己且存活 bool isEnemyHere false; for (const auto ch : state.getCharacters()) { if (ch-getId() ! id ch-isAlive() ch-getPositionX() x ch-getPositionY() y) { targetX x; targetY y; isEnemyHere true; break; } } if (isEnemyHere) break; // 向四个方向扩展 for (auto [dx, dy] : directions) { int nx x dx, ny y dy; if (state.isPositionValid(nx, ny) !visited[nx][ny]) { visited[nx][ny] true; cameFrom[nx][ny] {x, y}; q.push({nx, ny}); } } } // 2. 如果找到了敌人回溯路径得到下一步该走的位置 if (targetX ! -1) { // 回溯到起点找到第一步 int backX targetX, backY targetY; while (cameFrom[backX][backY].first ! positionX || cameFrom[backX][backY].second ! positionY) { if (cameFrom[backX][backY].first -1) break; // 安全保护 auto [px, py] cameFrom[backX][backY]; backX px; backY py; } // 如果下一步就是敌人位置说明相邻可以攻击 if (backX targetX backY targetY) { // 查找该位置敌人的ID for (const auto ch : state.getCharacters()) { if (ch-getId() ! id ch-isAlive() ch-getPositionX() targetX ch-getPositionY() targetY) { return Action(ActionType::ATTACK, ch-getId()); } } } // 否则移动到路径上的下一个格子 return Action(ActionType::MOVE_TO, backX, backY); } // 3. 没找到敌人随机移动或待机 return generateRandomMove(state); }BFS实现要点队列 (queue)用于实现广度优先的遍历顺序保证找到的路径是最短步数在网格权重一致的情况下。路径记录 (cameFrom)一个二维映射记录每个格子是从哪个格子访问过来的。这是回溯重建路径的关键。我们使用unordered_map套unordered_map来实现稀疏的二维访问比使用一个大大的二维vector更节省空间尤其当地图很大但角色很少时。边界与有效性检查state.isPositionValid()函数封装了对地图边界和障碍物的检查使BFS逻辑更清晰。性能考虑每次决策都做一次全图BFS在角色很多、地图很大时可能成为性能瓶颈。在实际优化中可以考虑缓存结果、使用更高效的搜索算法如A*或者限制搜索深度。但对于我们这个中等规模的模拟完全可接受。3.3 游戏主循环与行动裁决这是驱动整个模拟的引擎。主循环必须清晰地区分“决策阶段”和“执行阶段”。// Game.cpp (主循环部分) void Game::runSimulation() { int round 1; while (!isGameOver()) { std::cout Round round std::endl; // 阶段1: 决策阶段 - 所有存活角色基于当前状态做出决策 std::vectorAction actions; for (auto character : gameState.characters) { if (character-isAlive()) { Action act character-makeDecision(gameState); act.setSourceId(character-getId()); // 记录行动发起者 actions.push_back(std::move(act)); // 使用移动语义提高效率 } } // 阶段2: 行动裁决与执行阶段 - 按顺序或特定规则处理行动 // 这里采用简单的随机顺序执行增加不确定性 std::shuffle(actions.begin(), actions.end(), randomEngine); for (auto action : actions) { if (!action.isValid()) continue; executeAction(action); // 执行单个行动并更新gameState } // 阶段3: 回合结束处理 - 更新状态打印日志 resolveRoundEnd(); printGameState(); round; // 可以加一个延时方便观察 // std::this_thread::sleep_for(std::chrono::milliseconds(500)); } announceWinner(); }executeAction函数是另一个核心它负责解释Action指令并实际修改游戏世界。例如处理MOVE_TO指令时需要检查目标位置是否合法是否出界、是否已被占据如果合法则更新角色的坐标。处理ATTACK指令时需要找到目标角色减少其血量并判断是否被击败。避坑指南行动冲突处理这是一个常见的难点。比如角色A和角色B在同一回合都决定移动到同一个空位上或者角色A攻击B的同时B也攻击了A。我们的处理策略是顺序执行如上代码所示我们按某种顺序这里随机打乱执行行动。后执行的角色会“看到”先执行角色行动后的世界状态。这更符合“同时发生但总有细微先后”的现实感。先判后执在执行移动前检查目标位置在当前时刻是否被占据。如果已被本回合先执行的角色占据则移动失败。攻击同理判断目标是否还存活。日志记录务必详细记录每个行动的执行结果成功、失败及原因这对调试和复盘至关重要。可以在executeAction中加入日志输出。4. 代码整合、运行与观察将上述所有模块组合起来我们还需要一个main.cpp来设置初始场景。// main.cpp #include Game.h #include Blinker.h #include Prophet.h #include Tracker.h #include ChaosMage.h #include memory int main() { // 1. 创建游戏实例设置地图大小 Game game(20, 20); // 2. 创建并添加角色到游戏中 game.addCharacter(std::make_sharedBlinker(1, Blinker_Alpha, 5, 5, 100)); game.addCharacter(std::make_sharedProphet(2, Prophet_Beta, 15, 5, 100)); game.addCharacter(std::make_sharedTracker(3, Tracker_Gamma, 5, 15, 100)); game.addCharacter(std::make_sharedChaosMage(4, Chaos_Delta, 15, 15, 100)); // 3. 可以设置随机种子使每次运行结果可复现调试用或不可预测模拟用 // game.setRandomSeed(12345); // 4. 运行模拟 std::cout 超能力者大赛模拟开始 std::endl; game.runSimulation(); return 0; }编译并运行这个程序例如使用g -stdc17 *.cpp -o super_arena你将在控制台看到一场安静的、由算法驱动的生死斗。每一回合程序会打印出所有角色的位置和状态以及他们执行的关键行动。如何从输出中获取洞见不要只看最后谁赢了。仔细观察过程“追踪者”是否总能直线冲向敌人是的BFS保证了最短路径。“预言家”的行为是否显得更有“远见”它可能会为了躲避未来的围攻而提前走位即使当前离敌人更远了。“贪心者”是否容易落入陷阱比如为了追击一个残血敌人冲进了另外两个敌人的攻击范围。“混沌法师”的战绩如何它的胜率可能不稳定但偶尔能乱拳打死老师傅这说明了随机策略在复杂系统中的作用。你可以通过修改角色的初始位置、血量、甚至微调他们的决策函数比如改变“预言家”的推演深度“追踪者”的搜索范围来观察对战结果的变化。这本身就是一种简单的参数调优和平衡性测试。5. 扩展方向与深度优化建议一个基础版本跑起来后你可以从多个维度扩展这个项目让它变得更丰富、更专业。5.1 游戏性扩展更复杂的地形在GameState的mapGrid中加入不同的地形格子如“森林”移动消耗增加、“山地”无法通行、“治疗泉”每回合回复。角色决策时需要将这些因素纳入收益计算。更多样的能力设计基于其他算法思想的能力。“分身者”使用分治思想。当生命值低于一定阈值时可以分裂成两个属性减半的角色共同作战。“时间窃贼”使用回溯算法。可以花费大量能量将游戏状态回退到1-2回合前需要保存历史状态快照尝试不同的选择。“均衡使徒”使用模拟退火算法。它的决策不是完全贪心或完全随机而是以一个较高的“能量”开始随着回合进行“温度”下降逐渐从随机探索转向确定性策略。资源与成长系统在地图上随机生成“能量晶体”角色拾取后可以永久增强其某项属性如攻击力、视野范围或解锁二级技能。这引入了长期规划的维度。5.2 算法与性能优化空间换时间对于“追踪者”的BFS如果地图固定且无障碍可以预先计算所有格子之间的最短路径距离Floyd-Warshall算法存储在一个二维数组中。决策时直接查表将O(n)的搜索变为O(1)的查找。这适用于角色众多、需要频繁寻路的场景。决策缓存“预言家”的推演计算量最大。可以为其实现一个记忆化Memoization缓存。将游戏状态的哈希值作为键将计算出的最优行动作为值存储起来。当遇到相同的状态时在推演中很常见直接返回缓存结果避免重复计算。并行化决策在决策阶段每个角色的makeDecision是相互独立的。可以利用C的thread或future库将不同角色的决策过程放到多个线程中并行执行充分利用多核CPU显著提升大规模模拟如100个角色的速度。状态哈希为了高效比较和缓存游戏状态需要为GameState实现一个快速的哈希函数。可以将所有角色的位置、血量等信息编码成一个字符串或一个大的整数比如使用Zobrist Hashing一种在棋类AI中常用的技术作为状态的唯一标识。5.3 工程化与可视化数据驱动配置将角色的属性血量、速度、能力的参数贪心算法的收益权重、预言家的推演深度从代码中剥离放到JSON或YAML配置文件中。这样调整平衡性无需重新编译也方便进行自动化测试用脚本批量跑不同配置的模拟。日志与复盘系统将完整的对战过程每一回合的所有状态和行动以结构化的格式如JSON Lines记录到文件中。之后可以编写一个单独的“复盘查看器”程序加载日志文件像看录像一样回放整个比赛甚至可以暂停、分析关键时刻的决策。简单可视化虽然我们强调算法核心但一个简单的可视化能极大提升观察体验。你可以集成一个轻量级的图形库如EasyXWindows或SFML跨平台。不需要复杂的动画只需用不同颜色的方块代表不同角色每回合刷新一次画面就能直观地看到角色的移动轨迹和交战情况。这会将你的项目从“黑盒模拟”升级为“可观察实验”。6. 常见问题与调试技巧在开发过程中你肯定会遇到各种问题。这里记录一些我踩过的坑和解决方法。问题1程序运行后很快结束或者角色行为异常比如原地不动、穿墙。排查思路检查决策函数返回值确保每个角色的makeDecision在所有分支条件下都返回了一个有效的Action对象。最容易出错的地方是条件分支的遗漏导致函数没有返回值未定义行为。验证GameState的传递在makeDecision函数内部打印或调试查看传入的state参数。确认它包含了你期望的角色信息和地图信息。检查getCharacters()返回的列表是否正确。单步调试决策逻辑针对一个行为异常的角色在它的makeDecision函数开始处设置断点。一步一步跟踪看它的感知找到敌人了吗、计算BFS路径对吗、决策选择的行动合理吗每一步是否符合预期。检查行动执行 (executeAction)在executeAction中为每种行动类型添加详细的日志。例如“角色[ID]尝试移动到(X,Y)该位置当前状态为[空/被占]移动[成功/失败]”。这能清晰地暴露行动冲突或条件判断错误。问题2内存使用量不断增长内存泄漏。根本原因虽然我们用了shared_ptr但如果在容器间复制或循环引用仍可能导致内存无法释放。解决方案使用weak_ptr打破循环如果两个角色对象需要相互引用比如记录“仇恨目标”不要使用shared_ptrCharacter 而应使用weak_ptrCharacter。weak_ptr不会增加引用计数需要使用时通过lock()方法尝试获取shared_ptr。注意容器清理当角色死亡时将其从GameState::characters中移除。如果只是标记为“死亡”但指针还在向量里对象就不会被销毁。正确的做法是在回合结束后将characters中所有isAlive() false的元素移除使用std::erase_if。工具辅助在Linux/macOS下可以使用valgrind 在Windows下可以使用Visual Studio的内存诊断工具来检测内存泄漏。问题3模拟结果不可复现每次运行胜者都不同。这是特性也可能是bug如果是因为“混沌法师”的随机行为或行动顺序的随机打乱导致的那是设计好的特性。但如果所有角色都是确定性的比如去掉混沌法师固定行动顺序结果仍不同那就是bug。排查方法固定随机种子在main函数或Game初始化时调用std::srand(固定值)或传入固定的随机数引擎种子。确保所有“随机”行为如混沌法师的决策、初始打乱顺序在每次运行时完全一致。检查未初始化变量确保所有类的成员变量、局部变量都被正确初始化。未初始化的变量值是不确定的会导致程序行为不可预测。养成在构造函数中使用初始化列表的习惯。检查容器遍历的稳定性如果你在决策或执行逻辑中遍历characters向量并且这个遍历顺序会影响结果例如先处理哪个角色的攻击那么你需要对向量进行排序例如按ID以确保遍历顺序稳定。问题4随着角色增多程序运行越来越慢。性能瓶颈分析最可能的热点每个角色的决策函数尤其是那些包含完整地图搜索如BFS的函数。复杂度可能是 O(N * M)其中N是角色数M是地图格子数。使用性能分析工具如gprof(Linux) 或 Visual Studio Profiler。找出耗时最长的函数。优化策略降低搜索频率不是每回合都进行全图搜索。可以每2-3回合搜索一次中间回合沿用之前的路径或进行小范围调整。缩小搜索范围为角色增加“视野”属性只搜索视野范围内的区域。使用更高效的数据结构在判断“某个位置是否有角色”时如果每次都线性扫描characters向量是O(N)。可以维护一个std::unordered_mapstd::pairint, int, Character*来建立位置到角色的快速映射实现O(1)的查找。如前所述考虑并行化决策。这个项目就像一座桥梁一端是严谨、抽象的算法世界另一端是生动、具象的模拟体验。当你看到自己编写的几行BFS代码驱动着一个角色在网格间执着地追逐对手时那种感觉非常奇妙。它让你深刻理解算法不是枯燥的公式而是构建智能行为的基石。你可以不断往里面添加新的“超能力”算法调整参数观察整个系统涌现出的复杂行为。这不仅是编程练习更是一场关于策略、涌现和复杂系统的微型实验。

相关新闻