自己写一个《英雄无敌3》战斗AI

发布时间:2026/7/4 20:10:51
自己写一个《英雄无敌3》战斗AI 自己写一个《英雄无敌3》战斗AI目的与背景VCMI 是什么它和正版游戏是什么关系战斗 AI 是如何接入游戏的代码层面一个示例 AI醉汉另一个示例 AI: 末日审判打造一个自己的战斗 AI完整步骤0. 开发环境1. 下载 VCMI 代码并编译安装 Visual Studio下载 VCMI 源代码下载预编译的依赖包推荐生成 Conan Toolchain编译2. 运行 VCMI 并导入游戏数据3. 编写自己的战斗 AI4. 设置我友方的 AI5. 启动游戏后续目的与背景2026 年年初我随手刷到一个《英雄无敌3》的游戏视频。说实话我小时候碰过这游戏但那会儿基本属于“瞎玩”——属性技能不了解魔法看不懂对英雄特长也没概念。可这次再看居然觉得好有意思于是就下载下来玩。一开始玩战役带着珍妮打僵尸墓园。实在是太上头了。春节那几天更是离谱拜完年冲回家澡都没洗一直鏖战到半夜两三点连吃饭脑子里都在盘算这队射手该站哪、这波魔法该怎么甩、开局走路还能不能再省一步……不过《英3》的战斗真是让人又爱又恨。游戏里大部分时间其实都在“运营”——跑图、攒兵、抢资源但折腾半天最终还是要落到一场硬碰硬的战斗上。而作为一枚真·新手战役里对上困难电脑的主力部队我总是感到有很大压力。于是S/L 大法成了我的日常同一场战斗反复读档换阵型、换魔法顺序、换攻击目标直到打出理想结果。要是实在打不过……那就只能认怂读更早的存档重来。但正是这种“死磕然后翻盘”的瞬间特别爽。我本来就是个战斗狂人越打越上头最后脑子里冒出一个大胆的想法能不能自己搞一个最强的战斗 AI让它替我打战役我本职是个程序员这两年 Vibe Coding 的浪潮铺天盖地虽然偶尔也会心虚——怕哪天饭碗被 AI 端走但更多时候我真心觉得我们应该拥抱这种新的思维和范式。所以我决定用 AI 来帮我做这件事。以前要是想从头研究这件事我会觉得完全无从下手因为我没有任何相关的背景知识。但现在不一样了有 LLM 帮忙很多门槛被踏平了。我的终极目标是用强化学习训练出一个顶级的战斗 AI但那是个大工程。在这篇文章里我们先从最基础的一步开始——怎么让自己的战斗 AI真正“接”进游戏里。VCMI 是什么它和正版游戏是什么关系一开始我完全不知道该怎么把自己的 AI 塞进游戏里。跟 Claude 聊了好几轮之后它抛出了一个我没听过的名字——VCMI。带着好奇心研究了一番我很快意识到这就是我需要的东西。简单来说VCMI 是一个开源的《英雄无敌3》游戏引擎。这里有个关键区别值得先搞清楚“引擎”不等于“游戏本身”。引擎是那套负责“跑规则”的程序——伤害怎么算、移动怎么判、回合怎么走全归它管。VCMI 从头重写了这套引擎但它并不是一个完整的游戏。要想真正玩起来你还得提供自己合法拥有的 HoMM3 素材文件——图片、音乐、地图、兵种数据等等。VCMI 替换的只是那个“可执行程序”的部分游戏的内容资产依然来自你的正版副本。而这一点正是我们能够“动手脚”的根本原因原版游戏是闭源的你只能玩它没法看它但 VCMI 是开源的——我们能读它的代码能改它的逻辑甚至能往里面插进一个我们自己写的 AI。战斗 AI 是如何接入游戏的战斗时引擎和战斗 AI 之间是这样一问一答的循环引擎「轮到这个单位了这是当前战场情况你想怎么做」 │ ▼ 大脑AI「我决定——移动 / 近战 / 射击 / 等待 / 防御。」 │ ▼ 引擎执行这个动作更新战场然后问下一个单位……每当你的一个单位该行动时引擎就把战场状态交给大脑大脑给出一个动作引擎去执行。如此往复直到战斗结束。VCMI 本身就内置了好几个这样的大脑比如最简单的StupidAI和默认的、更聪明的BattleAI以及通过机器学习训练出来的MMAI游戏里可以选用哪一个。代码层面这几个战斗 AI 都实现了这个 CBattleGameInterface 的接口classDLL_LINKAGECBattleGameInterface:publicIBattleEventsReceiver{public:boolhumanfalse;PlayerColor playerID;std::string dllName;virtual~CBattleGameInterface(){};virtualvoidinitBattleInterface(std::shared_ptrEnvironmentENV,std::shared_ptrCBattleCallbackCB){};virtualvoidinitBattleInterface(std::shared_ptrEnvironmentENV,std::shared_ptrCBattleCallbackCB,AutocombatPreferences autocombatPreferences){};//battle call-insvirtualvoidactiveStack(constBattleIDbattleID,constCStack*stack)0;//called when its turn of that stackvirtualvoidyourTacticPhase(constBattleIDbattleID,intdistance)0;//called when interface has opportunity to use Tactics skill - use cb-battleMakeTacticAction from this function};说实话这个接口里最关键的方法其实就一个——activeStack。游戏运行战斗时每到玩家的回合activeStack就会被调用。我们要做的就是往这个函数里塞进自己的决策逻辑下一步该动谁、往哪走、打哪个单位、放什么魔法。这个函数有两个参数battleID你可以通过它拿到当前战场上的全部信息——双方兵力、站位、状态、魔法值……几乎一切你能想到的数据。stack则是当前轮到的那一队生物。这个名字挺形象的因为《英3》里同一格堆叠着多个单位就像一叠“生物牌”摞在一起所以叫stack。至于具体怎么从battleID里挖信息、怎么指挥stack行动……说实话我也没深究。最后那部分代码是直接让 AI 帮我生成的。我们只要搞清楚整体是怎么运作的就好。后面我也会给两个简单的例子直观地展示这个流程。所以我们的做法是定义一个继承CBattleGameInterface的新类然后把它的实例注册到 AI 工厂里再通过配置文件选中我们自己写的 AI。这样一来游戏里发生战斗时调用的就是我们自己的逻辑了。看到这里你可能已经冒出一个疑问了这不就是写死的 C 规则代码吗 每次改策略都得重新编译整个项目也太不灵活了吧也没办法接入机器学习模型了——神经网络、强化学习这些。没错。作为第一步我选择先“将就”一下——至少先把流程跑通让自定义 AI 能在游戏里动起来。但长期来看我不会止步于此。我打算把这个 AI 做成一个“壳”它本身只负责和 VCMI 通信真正的决策逻辑交给另一个独立的程序去跑。那个程序可以用 Python 写可以加载训练好的模型可以随时热更新——怎么灵活怎么来。实际上之前提到的 MMAI就是别人用机器学习训练出来的战斗 AI我猜它应该也是用了类似的思路虽然我还没仔细研究过源码但这个方向是明确的。一个示例 AI醉汉这里展示的是我做的第一个 AI名字叫 “醉汉”。为什么叫这个名呢因为这个 AI 的决策逻辑非常简单——它会让当前行动的生物随机挑一个能去的格子然后走过去仅此而已。至于攻击、施法、等待不存在的。就像股市里常说的“醉汉漫步”那样每一步都充满了不确定性放在这里简直绝配添加这个 AI 的完整代码都在我的这个 commit 里。我们简单看一下最主要的代码voidCMyRuleBasedAI::activeStack(constBattleIDbattleID,constCStack*stack){print(activeStack called for stack-nodeName());// Random Mover v0 (the drunkard): ignore all enemies, just wander to a// random reachable hex. No attacking, no enemy-seeking - on purpose.BattleHexArray availableHexescb-getBattle(battleID)-battleGetAvailableHexes(stack,false);// Drop hexes the stack already occupies so a pick always results in a real move.BattleHexArray moveTargets;for(constBattleHexhex:availableHexes)if(!stack-coversPos(hex))moveTargets.insert(hex);if(moveTargets.empty()){// Stall-guard: a fully blocked unit (or a siege weapon, which cant walk)// still MUST submit some valid action, or the battle hangs waiting on it.// This is the only fallback allowed in v0 - it is a safety net, not a decision.print(stack-nodeName(): no move available, defending);cb-battleMakeUnitAction(battleID,BattleAction::makeDefend(stack));return;}constBattleHex destination*RandomGeneratorUtil::nextItem(moveTargets,CRandomGenerator::getDefault());print(stack-nodeName(): random-moving to hex std::to_string(destination.toInt()));cb-battleMakeUnitAction(battleID,BattleAction::makeMove(stack,destination));}逻辑相当直白先问游戏接口“我这队兵现在能走到哪些格子”把里面没被占的格子挑出来塞进一个候选列表。如果列表是空的——那就啥也不干原地发呆。否则随机抽一个格子让生物移动过去。至于这些接口具体怎么调、参数怎么传……说实话我也不会。代码完全是 Vibe Coding 直接生成的我只负责描述意图。上面这段是我凭理解还原的核心逻辑细节上可能不精确但意思大概就是这么个意思。另一个示例 AI: 末日审判这里展示的是我做的第二个 AI名字叫 “末日审判”。这个更简单所有单位都不行动英雄不停地释放末日审判。我们看一下代码voidCArmageddonAI::activeStack(constBattleIDbattleID,constCStack*stack){print(activeStack called for stack-nodeName());// The pyromaniac: the heros Armageddon is the only thing this AI does.// Fires whenever the hero is allowed to cast.if(tryCastArmageddon(battleID))return;// Otherwise the stack does nothing. A unit must still submit *some* valid action// every turn or the battle hangs waiting on it, so do nothing defend in place.print(stack-nodeName(): nothing to do, defending);cb-battleMakeUnitAction(battleID,BattleAction::makeDefend(stack));}boolCArmageddonAI::tryCastArmageddon(constBattleIDbattleID)const{// If our hero can cast Armageddon right now, do it - no evaluation, no mercy.// Armageddon is a battlefield-wide fire nuke that also hits our own// (non-fire-immune) troops. That indiscriminate boom is the whole point.constautobattlecb-getBattle(battleID);constCGHeroInstance*herobattle-battleGetMyHero();if(!hero)returnfalse;// no hero on our side - nobody to cast// General gate: is the hero allowed to cast at all this turn? (has mana, hasnt// already cast this round, not silenced, ...)if(battle-battleCanCastSpell(hero,spells::Mode::HERO)!ESpellCastProblem::OK)returnfalse;// Spell-specific gate: does the hero actually know Armageddon and can it be cast now?constCSpell*armageddonSpellID(SpellID::ARMAGEDDON).toSpell();if(!armageddon||!armageddon-canBeCast(battle.get(),spells::Mode::HERO,hero))returnfalse;// Armageddon has no destination (AimType::NOTHING) - leave the actions target empty.BattleAction spellcast;spellcast.actionTypeEActionType::HERO_SPELL;spellcast.spellSpellID::ARMAGEDDON;spellcast.sideside;spellcast.stackNumber-1;// the hero, not a stackprint(hero: casting Armageddon);cb-battleMakeSpellAction(battleID,spellcast);returntrue;}这段代码看起来就更加直观了看一下我们的英雄能不能放末日审判查了下正版的英文名字就叫Armageddon它源自《圣经》启示录指的是世界末日善恶双方进行最后决战的地点。就不多做赘述了。打造一个自己的战斗 AI完整步骤接下来我带大家走一遍打造自定义战斗 AI 的完整流程。我不会事无巨细地罗列所有细节——毕竟自己动手时总会遇到各种意想不到的问题而现在 LLM 这么给力我相信你一定能搞定。不过我会把一些关键 Tips 和我踩过的坑都标记出来帮你少走弯路。0. 开发环境我用的是 Windows 11——纯粹因为自家电脑就是它。如果你用的是 Linux那我默认你是个资深程序员肯定比我玩得溜而且说实话在 Linux 上开发应该会更顺畅。1. 下载 VCMI 代码并编译按照官方编译指南来操作就行。说起来轻巧但当初我可没少折腾。下面是我总结的几个要点安装 Visual Studio直接装最新的 VS2026 即可。但千万注意Toolset 一定要选v142。VS2026 默认是v145我一开始没留意用v145走到后面各种报错当然也可能是别的原因但强烈建议直接用v142避险。下载 VCMI 源代码git clone程序员基本功不多说。我把我当时用的具体版本贴出来方便你用完全一致的配置。下载预编译的依赖包推荐这一步是可选的但我强烈建议做。我的电脑配置比较老旧用预编译包能省下大量编译时间——VCMI 全量编译真的非常慢。我当时没用“从头编译依赖”的方式所以没法对比。我当时选的预编译包版本是 2026-06-08大家可去 GitHub Releases 找。如果你们用的是 VCMI 主分支最新代码推荐选一个最新的 pre-release 包但别选太新的可能有不匹配问题。另外注意架构选择——大部分人应该是 x64如果不确定可以查一下自己的系统。生成 Conan Toolchain这一步最坑务必把 Conan profile 里的配置改对conan install . ^ --output-folderconan-msvc ^ --buildnever ^ --profiledependencies\conan_profiles\msvc-x64 ^ -s :compiler.version192 ^ -s :build_typeRelWithDebugInfo ^ -o :target_pre_windows10Falsecompiler.version192对应前面说的 v142 Toolset两者要匹配。build_type推荐用RelWithDebugInfo编译和运行都快一些又有足够的调试日志。编译生成 VS 项目后在 IDE 里编译也行直接用 CMake 命令编译也行。我用的命令行Windows Terminal 现在挺好用的cmake --build build --config RelWithDebugInfo --target vcmiclient2. 运行 VCMI 并导入游戏数据前面说过VCMI 只是个引擎游戏素材还得从你正版 HoMM3 里拿。按照官方安装文档导入数据即可。这一步不算难照着做就行。3. 编写自己的战斗 AI核心工作就是定义一个新类继承CBattleGameInterface主要是实现activeStack方法。但需要额外做一些“注册”操作修改 CMake添加新的源文件并链接我们新增的 AI。修改lib/callback/AIFactory.cpp在这里实例化我们的 AI。修改config/schemas/settings.json加入新 AI 的名字要和上一步硬编码的名字一致。combatAlliedAI:{type:string,enum:[EmptyAI,StupidAI,BattleAI,MMAI,!!OurNewAI!!],default:BattleAI},⚠️ 注意VCMI 在创建战斗 AI 时会查这个 JSON如果找不到你写的名字创建就会失败然后回退到默认 AI。所以这里一定要加上改完之后再次编译整个 VCMIcmake--buildbuild--configRelWithDebInfo--targetvcmiclient小贴士config/schemas/settings.json 只是配置模式文件可以在编译之后再修改不影响编译过程。4. 设置我友方的 AI文章前面那张 VCMI 界面截图里可以设置游戏 AI但那需要改 VCMI 源码我没走那条路。更简单的方式是直接改游戏配置文件。在 Windows 上默认路径是C:\Users\Administrator\Documents\My Games\vcmi\config\settings.json打开后修改ai字段ai:{combatAlliedAI:MyRuleBasedAI,combatEnemyAI:MMAI,combatNeutralAI:MMAI},把combatAlliedAI改成你新写的 AI 名字就好。注意区分第三步和这一步的区别第三步是在 VCMI 源码里注册 AI告诉系统“有这个 AI 类型存在”。第四步是在用户配置里指定“我方使用哪个 AI”。在开发过程中LLM 还告诉我可以用游戏内命令动态切换 AI——在游戏中打开聊天框输入SetBattleAI AI_Name。不过我试下来发现这个命令改的是中立方的 AI我方的 AI 没法通过它切换。所以目前还是老老实实改配置文件吧。5. 启动游戏大功告成启动 VCMI 即可。为了快速测试战斗 AI我特意自己做了一个极简的小地图开局就能撞上敌人省去运营环节直奔主题。后续这只是一个起点。接下来我打算继续做这几件事研究 StupidAI 和 BattleAI 的实现——学习官方的决策逻辑借鉴一些好的写法。做一个“壳” AI——只负责和 VCMI 通信实际的决策数据通过某种方式传给 Python 程序处理。这样一来修改策略就不需要重新编译整个 VCMI 了也为接入强化学习铺平道路。研究 MMAIvcmi-gym——学习它是怎么用强化学习训练战斗 AI 的最终实现自己的 RL 战斗 AI。