AzerothCore学习笔记·模组01:模块化设计——从 MaNGOS 到 AzerothCore 的演进

你在 GitHub 上搜 AzerothCore,点进去看到 modules/ 目录,第一反应可能是:怎么这么多独立仓库?每个有自己的 CI、README、CMakeLists.txt。这看起来不像一个项目------更像一群各自独立的程序,约好了住在同一个屋檐下。

这个设计不是一天建成的。把一段代码从「嵌在核心里」变成「独立在 modules 目录下」,花了三个大项目的迭代,背后是开源软件工程思路的持续演变。


Before:MaNGOS 时代,脚本和核心混在一起

MaNGOS(MaNGOS,全称 Massive Network Game Object Server)是 2005 年左右最早的 WoW 模拟器项目之一。它的架构逻辑很简单:

所有功能写在同一个项目里。

你想给某个 BOSS 加一个特殊技能?进 src/game/AI/ 找到对应文件,改!想加自定义 NPC?进 src/game/Globals/,改!

问题也很直接:

  • 编译耦合:改一行脚本,整个 worldserver 要重新编译。一个 500 行的 BOSS 脚本变更,导致全工程 30 分钟重编。
  • 冲突频繁:两个开发者各改各的功能,最终 merge 时发现改了同一个文件的不同行------冲突。
  • 升级困难:核心升级(修 Bug、优化性能)时,自定义修改的部分需要手动移植到新版代码里,也就是每次"打补丁"。

后来 TrinityCore 从 MaNGOS 分叉,做了第一步改进------把脚本从核心里抽出来,放到独立的 ScriptDev2 项目中。脚本代码和核心代码不再编译在一起,但本质上还是一个库,编译后链接到 worldserver 上。


AzerothCore:真正独立出去的模块

AzerothCore 从 TrinityCore 分叉后,走了最彻底的路:

每个模块是一个完全独立的目录,自动被构建系统发现和加载。

核心代码在 src/server/game/ 里,不依赖任何模块。模块在 modules/ 目录下,每个模块自己管自己的源码、配置、数据库脚本。

实际看一眼 CMake 的加载逻辑(CMakeLists.txt):

cmake 复制代码
# 扫描 modules/ 目录下的所有子目录
CU_SUBDIRLIST(sub_DIRS "${CMAKE_SOURCE_DIR}/modules" FALSE FALSE)
FOREACH(subdir ${sub_DIRS})
    get_filename_component(MODULENAME ${subdir} NAME)

    # 排除用户禁用列表中的模块
    if (";${DISABLED_AC_MODULES};" MATCHES ";${MODULENAME};")
        continue()
    endif()

    # 有 CMakeLists.txt 的模块才被加载
    if(EXISTS "${subdir}/CMakeLists.txt")
        add_subdirectory("${subdir_rel}")
    endif()
ENDFOREACH()

一句话概括:往 modules/ 里丢一个新目录,重启编译就有了。不想要了,从目录里删掉------零副作用。

目录的名字就是模块的名字,CMake 自动发现。开发者不需要修改任何核心 CMake 文件。


模块长什么样

拿实际例子看,比如 mod-ollama-chat,一个让游戏 NPC 对接大语言模型聊天的模块:

复制代码
modules/mod-ollama-chat/
├── conf/              # 模块配置文件(编译后复制到 etc/)
├── data/              # 模块数据文件
├── src/               # 模块源码
│   ├── mod-ollama-chat_main.cpp    # 模块入口
│   ├── mod-ollama-chat_events.cpp  # Hook 注册
│   ├── mod-ollama-chat_api.cpp     # API 调用实现
│   ├── mod-ollama-chat_config.cpp  # 配置解析
│   └── mod-ollama-chat_handler.cpp # 命令处理
└── README.md          # 模块说明文档

每个模块的源码结构和核心的 game/ 目录结构类似,但只包含自己需要的文件。因为模块和核心之间通过 Hook 系统(ScriptMgr)通信,模块可以注册它关心的回调,核心有事件发生时再通知模块。


模块化带来的四个变化

1. 解耦编译

模块的变更不会触发核心代码的重新编译。AzerothCore 核心一年可能更新几百次,如果你用的模块是纯数据库变更 + Hook 回调,就完全不需要重新编译------纯 SQL 模块甚至不需要编译。

2. 独立的依赖管理

模块可以有自己的第三方依赖。mod-ollama-chat 依赖 nlohmann/json 做 JSON 解析,这个依赖声明在模块自己的 CMakeLists.txt 里。即使核心不使用任何 JSON 库,也不影响。

3. 按需启用/禁用

conf/config.cmake 提供了 DISABLED_AC_MODULES 变量:

cmake 复制代码
set(DISABLED_AC_MODULES "mod-ollama-chat")

不想用哪个,写上名字编译时就跳过了。不用在代码里加 #ifdef,不会污染核心代码。

4. 替换和定制

想自己写一个 NPC 聊天功能替换 mod-ollama-chat?复制官方模块的目录,改个名字,按需实现自己的 Hook 回调。核心不需要知道你是 A 模块还是 B 模块,它只管发事件。


模块化不是银弹

AzerothCore 的模块化做得彻底,但也有代价:

  • Hook 点覆盖不全:有些核心逻辑没有暴露出 Hook 接口,如果模块想拦截这类操作,就得直接改核心源码,等于回到 MaNGOS 模式。
  • 模块间不能互相通信:两个模块需要协作时,必须走核心的 ScriptMgr 作为中间人。没有模块到模块的直接调用。
  • 性能开销:ScriptMgr 的 Hook 调用是虚函数 + 遍历注册表,高频率事件(比如每次玩家位置更新)会增加开销。不过对游戏服务器来说,这个量级可以忽略。

社区也在不断补充 Hook 接口,目标是让"不改核心一行代码"就能实现任何功能。


回顾:从硬编码到插件化

项目 定位 脚本与核心关系
MaNGOS 早期探索 脚本全在核心代码里,改功能 == 改核心
TrinityCore 第一次分离 脚本独立到 ScriptDev2,但仍需编译链接
AzerothCore 彻底模块化 每个模块独立,CMake 自动发现,Hook 回调驱动

这不是 AzerothCore 独有的创新------游戏引擎界(Unreal、Unity)早就有插件系统了。但在一款从 2005 年源码演化而来的项目中,把模块化做到这个程度,是对原始架构最大的改进之一。

如果再往前看,这种演化路径几乎是所有长生命周期项目的必然方向------MaNGOS 的硬编码、TrinityCore 的脚本分离、AzerothCore 的完全模块化。唯一的不同是,AzerothCore 把这件事做到了极致:modules/ 目录下的每一个文件夹,理论上都可以是一个独立工程。