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

📅 2026/7/4 20:14:00 👁️ 阅读次数 📝 编程学习
自己写一个《英雄无敌3》战斗AI

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

  • 目的与背景
  • VCMI 是什么?它和正版游戏是什么关系?
  • 战斗 AI 是如何接入游戏的?
    • 代码层面
  • 一个示例 AI:醉汉
  • 另一个示例 AI: 末日审判!
  • 打造一个自己的战斗 AI:完整步骤
    • 0. 开发环境
    • 1. 下载 VCMI 代码并编译
      • 安装 Visual Studio
      • 下载 VCMI 源代码
      • 下载预编译的依赖包(推荐)
      • 生成 Conan Toolchain
      • 编译
    • 2. 运行 VCMI 并导入游戏数据
    • 3. 编写自己的战斗 AI
    • 4. 设置我(友)方的 AI
    • 5. 启动游戏
  • 后续

目的与背景

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:boolhuman=false;PlayerColor playerID;std::string dllName;virtual~CBattleGameInterface(){};virtualvoidinitBattleInterface(std::shared_ptr<Environment>ENV,std::shared_ptr<CBattleCallback>CB){};virtualvoidinitBattleInterface(std::shared_ptr<Environment>ENV,std::shared_ptr<CBattleCallback>CB,AutocombatPreferences autocombatPreferences){};//battle call-insvirtualvoidactiveStack(constBattleID&battleID,constCStack*stack)=0;//called when it's turn of that stackvirtualvoidyourTacticPhase(constBattleID&battleID,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(constBattleID&battleID,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 availableHexes=cb->getBattle(battleID)->battleGetAvailableHexes(stack,false);// Drop hexes the stack already occupies so a pick always results in a real move.BattleHexArray moveTargets;for(constBattleHex&hex:availableHexes)if(!stack->coversPos(hex))moveTargets.insert(hex);if(moveTargets.empty()){// Stall-guard: a fully blocked unit (or a siege weapon, which can't 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));}

逻辑相当直白:

  1. 先问游戏接口:“我这队兵现在能走到哪些格子?”
  2. 把里面没被占的格子挑出来,塞进一个候选列表。
  3. 如果列表是空的——那就啥也不干,原地发呆。
  4. 否则,随机抽一个格子,让生物移动过去。

至于这些接口具体怎么调、参数怎么传……说实话我也不会。代码完全是 Vibe Coding 直接生成的,我只负责描述意图。上面这段是我凭理解还原的核心逻辑,细节上可能不精确,但意思大概就是这么个意思。

另一个示例 AI: 末日审判!

这里展示的是我做的第二个 AI,名字叫 “末日审判”。

这个更简单,所有单位都不行动,英雄不停地释放末日审判。

我们看一下代码:

voidCArmageddonAI::activeStack(constBattleID&battleID,constCStack*stack){print("activeStack called for "+stack->nodeName());// "The pyromaniac": the hero's 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(constBattleID&battleID)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.constautobattle=cb->getBattle(battleID);constCGHeroInstance*hero=battle->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, hasn't// 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*armageddon=SpellID(SpellID::ARMAGEDDON).toSpell();if(!armageddon||!armageddon->canBeCast(battle.get(),spells::Mode::HERO,hero))returnfalse;// Armageddon has no destination (AimType::NOTHING) -> leave the action's target empty.BattleAction spellcast;spellcast.actionType=EActionType::HERO_SPELL;spellcast.spell=SpellID::ARMAGEDDON;spellcast.side=side;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-folder=conan-msvc ^ --build=never ^ --profile=dependencies\conan_profiles\msvc-x64 ^ -s "&:compiler.version=192" ^ -s "&:build_type=RelWithDebugInfo" ^ -o "&:target_pre_windows10=False"
  • compiler.version=192对应前面说的 v142 Toolset,两者要匹配。
  • build_type推荐用RelWithDebugInfo,编译和运行都快一些,又有足够的调试日志。

编译

生成 VS 项目后在 IDE 里编译也行,直接用 CMake 命令编译也行。我用的命令行(Windows Terminal 现在挺好用的):

cmake --build build --config RelWithDebugInfo --target vcmiclient

2. 运行 VCMI 并导入游戏数据

前面说过,VCMI 只是个引擎,游戏素材还得从你正版 HoMM3 里拿。按照官方安装文档导入数据即可。这一步不算难,照着做就行。

3. 编写自己的战斗 AI

核心工作就是定义一个新类,继承CBattleGameInterface(主要是实现activeStack方法)。但需要额外做一些“注册”操作:

  1. 修改 CMake:添加新的源文件,并链接我们新增的 AI。

  2. 修改lib/callback/AIFactory.cpp:在这里实例化我们的 AI。

  3. 修改config/schemas/settings.json:加入新 AI 的名字,要和上一步硬编码的名字一致。

"combatAlliedAI":{"type":"string","enum":["EmptyAI","StupidAI","BattleAI","MMAI","<!!OurNewAI!!>"],"default":"BattleAI"},

⚠️ 注意:VCMI 在创建战斗 AI 时会查这个 JSON,如果找不到你写的名字,创建就会失败,然后回退到默认 AI。所以这里一定要加上

改完之后,再次编译整个 VCMI:

cmake--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,我特意自己做了一个极简的小地图,开局就能撞上敌人,省去运营环节,直奔主题。

后续

这只是一个起点。接下来我打算继续做这几件事:

  1. 研究 StupidAI 和 BattleAI 的实现——学习官方的决策逻辑,借鉴一些好的写法。

  2. 做一个“壳” AI——只负责和 VCMI 通信,实际的决策数据通过某种方式传给 Python 程序处理。这样一来,修改策略就不需要重新编译整个 VCMI 了,也为接入强化学习铺平道路。

  3. 研究 MMAI(vcmi-gym)——学习它是怎么用强化学习训练战斗 AI 的,最终实现自己的 RL 战斗 AI。