VCMI 是一个社区驱动的开源项目,目标只有一个:用现代 C++ 把 1999 年的《魔法门之英雄无敌 III》引擎彻底重写,再套上一层可热重载的 Lua 脚本沙盒。结果是把一款仅能在 Windows 98 上跑的老游戏,变成了横跨 Windows、Linux、macOS、iOS、Android 的 “活体平台”,玩家可以像给 VS Code 装插件一样,在游戏里 “一键下载 - 启用” 新城镇、新生物、新法术,甚至自定义战斗特效,而无需重启客户端。
一、为什么要重写:原版引擎的三座大山
- 单线程 Win32 代码,32 位地址空间写死,地图尺寸、对象数量、资源句柄都有硬顶。
- 无脚本层,所有逻辑散落在 200+ 个 DLL 消息回调里,Mod 只能做二进制补丁,冲突率极高。
- 分辨率写死 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. 热重载流程
- 模组 ZIP 放在
Mods/<modId>/。 - 启动器计算 SHA-256,与本地缓存比对,差异则解压。
- 主菜单点击 “重载脚本”→ 引擎向 Lua 虚拟机发
SIGUSR1软信号→ 虚拟机把老package.loaded清掉,重新require。 - 若重载失败,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显式写 `