Hotdry.
systems-engineering

VCMI 如何用 C++ 重写 Heroes III 引擎并暴露 Lua 模组 API,实现跨平台沙盒与热重载

拆解 VCMI 的 C++ 引擎重写、Lua 事件总线与热重载机制,给出可落地的跨平台沙盒参数与开发清单。

VCMI 是一个社区驱动的开源项目,目标只有一个:用现代 C++ 把 1999 年的《魔法门之英雄无敌 III》引擎彻底重写,再套上一层可热重载的 Lua 脚本沙盒。结果是把一款仅能在 Windows 98 上跑的老游戏,变成了横跨 Windows、Linux、macOS、iOS、Android 的 “活体平台”,玩家可以像给 VS Code 装插件一样,在游戏里 “一键下载 - 启用” 新城镇、新生物、新法术,甚至自定义战斗特效,而无需重启客户端。

一、为什么要重写:原版引擎的三座大山

  1. 单线程 Win32 代码,32 位地址空间写死,地图尺寸、对象数量、资源句柄都有硬顶。
  2. 无脚本层,所有逻辑散落在 200+ 个 DLL 消息回调里,Mod 只能做二进制补丁,冲突率极高。
  3. 分辨率写死 800×600,UI 坐标硬编码,移植到触屏等于重做。

VCMI 给出的方案是 “整体置换”:保留原始美术与数据文件(用户需自行提供),把可执行部分全部替换为 MIT 许可的新引擎,从而合法绕过 Ubisoft 的版权红线。

二、C++ 重写带来的架构红利

1. 实体 - 组件 - 系统(ECS)

  • Entity 仅是一个 32 位 ID,无业务逻辑。
  • Component 是 POD 结构,例如 CreatureStats, SpellEffect
  • System 按组件集合跑逻辑,战斗、冒险、AI 各跑各的,天然适合多线程。

2. 序列化与快照

所有 Component 实现 serialize(Archive &ar) 模板,存档时把内存快照直接 dump 到磁盘,版本号写在文件头;读档时若主版本号不一致直接拒绝,避免 “坏档” 崩溃。

3. 跨平台抽象层

  • 渲染:SDL2 + OpenGL ES 2.0,一码走桌面 + 移动。
  • 输入:Qt 事件总线封装触控 / 键鼠,支持 “单指长按 = 右键” 这类触控语义。
  • 文件系统:Boost.Filesystem + CMake 工具链,Android 通过 AAssetManager 只读挂载 APK 内资源,iOS 用 NSBundle 同构。

4. 构建参数速查

cmake -B build -G Ninja \
  -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  -DENABLE_LAUNCHER=ON \
  -DENABLE_LUA=ON \
  -DENABLE_ERM=ON \
  -DENABLE_MOBILE_TOUCH=ON

移动平台额外加 -DUSE_CONAN=ON,自动拉 SDL2、Boost、LuaJIT 的预编译包,10 分钟可出 APK/IPA。

三、Lua 模组 API:把 “写死” 变成 “事件”

VCMI 在服务端(游戏逻辑)和客户端(表现)各跑一份 LuaJIT 虚拟机,通过事件总线解耦。核心设计只有三张表:

全局表 作用域 生命周期
DATA 持久化 存档时序列化,可存任意 Lua 表
GAME 只读 提供 IGameInfoCallback,查地图、玩家、对象
BATTLE 只读 提供 IBattleInfoCallback,查战场格子、单位属性

1. 热重载流程

  1. 模组 ZIP 放在 Mods/<modId>/
  2. 启动器计算 SHA-256,与本地缓存比对,差异则解压。
  3. 主菜单点击 “重载脚本”→ 引擎向 Lua 虚拟机发 SIGUSR1 软信号→ 虚拟机把老 package.loaded 清掉,重新 require
  4. 若重载失败,Lua 异常被 pcall 捕获,回滚到老版本并弹红色 Toast,游戏不崩。

2. 事件订阅示例:自定义战斗特效

-- scripts/lightning_strike.lua
local E = require "events.BattleSpellCastBefore"
E.subscribeAfter(EVENT_BUS, function(ev)
    if ev.spell.id == "myLightning" then
        -- 在目标格子上播放自定义特效
        BATTLE:addParticle(ev.targetHex, "sparks.def", 30)
        -- 额外伤害 = 施法英雄法力 * 10
        local dmg = ev.caster.spellPower * 10
        BATTLE:dealDamage(ev.target, dmg, "SPELL")
    end
end)

脚本保存后 0.5 秒内生效,无需重启战斗。

3. 零冲突资源隔离

  • 所有文件路径自动加命名空间前缀,例如 myMod/icons/creature/unicorn.png
  • 同名 ID 冲突时,后加载模组在 UI 显示黄色警告,但引擎仍强制隔离,运行时互不影响。
  • 若模组 A 依赖模组 B,在 mod.json 显式写 `
查看归档