1999 年发布的《魔法门之英雄无敌 III》是回合策略巅峰,但其 32 位二进制与硬编码数据把 “可扩展性” 锁死在 90 年代。VCMI 项目用现代 C++ 把整颗引擎重写一遍,目标只有一个:让 25 年后的玩家仍能像搭积木一样 “热插拔” 新城镇、新生物、新英雄,甚至把战斗逻辑换成 Lua 脚本。以下拆解它如何做到这一点。
一、从二进制 blob 到 JSON 描述:先解耦,再谈扩展
原版游戏把城镇、生物、法术全部写死在 EXE 与 LOD 压缩包里,任何新增内容都要 patch 二进制。VCMI 的第一步是把 “数据” 与 “代码” 彻底分离:
- 所有实体(faction、creature、artifact、spell、hero class)统一用 JSON 描述,字段与内存结构一一对应;
- 资源文件(图片、声音、动画)保持原格式兼容,但路径与命名规则开放给模组;
- 引擎启动时通过
Filesystem::loadModFilesystem()把每个 mod 的目录树挂到虚拟文件系统,后加载者覆盖前者,实现 “同名即覆盖” 的零冲突依赖。
这一步把 “新增一个城镇” 从反汇编变成写配置文件,为后续脚本化铺平道路。
二、热插拔架构:三行 JSON 就能把脚本装进运行时
VCMI 把 “脚本” 也当成一种实体,在 mod 的 content/config/scripts.json 里声明即可:
{
"mySpellEffect": {
"source": "scripts/lua/fireAura.lua",
"implements": "BATTLE_EFFECT"
}
}
引擎在 ScriptHandler::initialize() 阶段扫描所有 mod 的声明,按类型分发给客户端 / 服务器:
- 若
implements为BATTLE_EFFECT,脚本会在战斗结算时按需加载到 LuaJIT 虚拟机; - 若
implements为MAP_OBJECT,脚本成为地图事件的回调处理器; - 所有脚本文件以
std::filesystem::last_write_time做版本戳,开发模式下改动后自动重载,无需重启游戏。
通过 “描述 - 注册 - 延迟加载” 三级解耦,VCMI 实现了真正意义上的热插拔:玩家在大厅里勾选 / 取消 mod,下一把游戏立即生效,无需重新编译本体。
三、Lua 运行时:事件总线 + 全局 API,把战斗逻辑写成脚本
VCMI 把 LuaJIT 嵌在服务端与客户端两侧,暴露一组全局对象:
GAME——IGameInfoCallback接口,可查玩家、城镇、地图对象;BATTLE——IBattleInfoCallback接口,可迭代战场格子、部队堆;EVENT_BUS—— 事件总线句柄,可订阅PlayerGotTurn、BattleAttack、SpellCast等 30+ 事件;SERVICES—— 只读访问所有静态定义(creature stats、spell config),保证脚本只能 “读规则” 而不能 “改规则”;DATA—— 持久化表,回合间自动序列化到存档,解决脚本状态落地问题。
以自定义战斗法术为例,脚本只需订阅事件并返回特效参数:
local SpellCast = require("events.SpellCast")
SpellCast.subscribeAfter(EVENT_BUS, function(e)
if e.spell.id == "myFireAura" then
e.targets[1]:addBonus({type="FIRE_SHIELD", value=50})
end
end)
所有事件回调在服务器主循环中顺序执行,脚本异常被捕获并写进 script_errors.log,不会导致整局游戏崩溃。
四、实战:最小可运行 mod 的目录结构
MyFireAuraMod/
├─ mod.json // 版本、作者、依赖声明
├─ content/
│ ├─ config/
│ │ ├─ spells.json // 新增法术定义
│ │ └─ scripts.json // 脚本注册表
│ └─ scripts/
│ └─ lua/
│ └─ fireAura.lua // 上文示例脚本
└─ Data/
└─ spells/
└─ fireAura.def // 动画资源
关键字段只有三处:
mod.json里声明 `