简介:面向魔兽世界巫妖王之怒版本服务器搭建者与MMORPG开发者的开源模块化方案,基于AzerothCore提供完整游戏服务器框架,可用于自定义游戏环境、快速验证玩法创意,无需从零编写底层代码。项目强调模块化组件设计,便于增删改任务、脚本与玩法功能,并与TrinityCore、MaNGOS、SunwellCore及ScriptDev2等生态工具形成参照。压缩包约138.19MB,文件总数标记为0、类型明细上游暂无数据,实际以master主分支核心代码与配套资源为主,适合自行编译配置后搭建服务端。已有1403人学习或下载,读者可据此理解MMO服务端目录组织、模块扩展方式、脚本开发接口与开源协作流程,对业余爱好者和专业开发者均具参考价值。
1. 先看清 azerothcore-wotlk 的边界:能启动和能被改造是两回事
一个 3.3.5a 的 MMO 游戏服务器能在单机上跑起来,本身并不稀奇;稀奇的是它被拆成了可插拔的模块,让 azerothcore-wotlk 从一个“能跑的服务端”变成一套“能改的工程”。很多人接触它的方式是拿到一个已经编译好的压缩包,启动、创建角色、进游戏,十分钟后就不知道下一步该干什么了。真正的分水岭出现在你想加一个自定义功能的时候:是去src/server/scripts里翻二十万行代码、直接改核心,还是新建一个目录、写三十行脚本、重新编译一次就能挂上去。这份开源服务端解决的就是后者:一套带脚本钩子、带模块自动发现、带数据库版本管理的 mmorpg server 底座。适合已经把服务端跑起来、准备改玩法的人;只想进游戏看一眼的,用现成的整包更省事。
2. 选型对比:AzerothCore、TrinityCore、MaNGOS、SunwellCore 四条路线怎么选
这四条路线的名字经常一起出现,但它们并不是并列关系。搞清楚血缘,能省掉大量“我照着某篇教程做,结果宏找不到”的时间。
2.1 四条路线的血缘差别
MaNGOS 是最早的一批开源服务端实现,代码结构偏老,很多宏和类名被后面的项目继承了下来。TrinityCore 从它分出去,重写了不少网络层和地图层,把重心放在协议还原和运行性能上,脚本接口相对收敛。
AzerothCore 又从 TrinityCore 的 3.3.5a 分支派生,做的主要动作是把“工程化”往前推了一格:模块目录会被构建系统自动扫描,脚本钩子通过构造函数的掩码显式声明,数据库更新走独立的版本表,还自带一套可复现的编译与集成流程。SunwellCore 是另一条常见分支,社区规模小一些,自定义内容的改动更多,适合拿来做单机向的玩法魔改。
ScriptDev2 这个名字在这里也常被提到,它是最早那套把 AI 与脚本从核心中剥离出来的项目,现在几条路线各自有对应实现,概念是相通的:脚本代码不该长在核心类里。
选型的判断标准其实只有三条:你要不要改玩法、你愿不愿意碰 C++、你能不能接受升级核心时重新合并改动。三条答案都是“要”,AzerothCore 的模块机制会让后面每一次改动都轻很多。
2.2 一份可对照的选型参数表
| 维度 | AzerothCore 形态 | 你需要亲自确认的事 |
|---|---|---|
| 上游血缘 | TrinityCore 的 3.3.5a 分支 | 你拉的分支是否还在跟进主线合并 |
| 模块机制 | modules/目录自动扫描,CMake 自动挂载 | 模块目录名是否以mod-开头 |
| 脚本注册 | 构造函数传入 hook 掩码,编译期校验 | 钩子名拼错时是编译报错还是静默失效 |
| 数据库 | 三库分离,带updates增量表 | 每升级一次代码,增量 SQL 是否跑过 |
| 构建产物 | worldserver、authserver加一组数据提取工具 | 提取工具是否和核心同一版本编译出来 |
| 语言门槛 | C++17 加 SQL | 团队里有没有人能读 CMake 报错 |
这张表里最容易被低估的是最后两行。数据提取工具和核心必须是同一次编译的产物,混用不同版本的mapextractor和worldserver,症状是进图之后地面全是空的、NPC 悬空,日志里一行红字都没有,纯靠猜。
2.3 什么时候不该选它
第一种情况,你要的资料片不是它支持的那个。数据库结构、协议版本、地图格式是绑死的,换版本等于换项目。
第二种情况,你只需要一个能跑的单机环境,不打算写任何代码。那模块机制对你没有价值,反而多了一层编译依赖和配置合并的麻烦。
第三种情况,团队里没人愿意维护一套 C++ 构建链。每次上游更新都要重新编译,Boost、OpenSSL、MySQL 客户端库的版本一错就是几百行报错,这个成本是长期的、真实存在的。
3. 从零编译一套可启动服务端:依赖、CMake 参数与三个库的导入顺序
这一章是从空目录到能登录的完整路径。中间任何一步跳过去,后面都会以“启动即退出”或“卡在角色列表”的形式找回来。
3.1 依赖清单与版本约束
| 依赖 | 常见下限 | 说明 |
|---|---|---|
| CMake | 3.16 | 低于这个版本,模块的AC_ADD_SCRIPT宏行为不一致 |
| GCC / Clang | GCC 8 / Clang 7 | 需要 C++17;MSVC 用 2019 以上 |
| Boost | 1.70 | 主要用到system、filesystem、program_options、thread、regex |
| MySQL 客户端库 | 8.0 或 MariaDB 10.5 | 服务端本身不跑数据库,只连外部实例 |
| OpenSSL | 1.1 或 3.x | 认证握手用,版本跨度大时要确认头文件路径 |
| zlib / bz2 | 系统自带即可 | 压缩包与地图数据处理 |
Debian 系一把装齐大概是这样:
sudo apt update sudo apt install -y build-essential cmake git \ libboost-all-dev libssl-dev libmysqlclient-dev \ libreadline-dev zlib1g-dev libbz2-dev mysql-server这里最容易翻车的是libmysqlclient-dev和libmariadb-dev同时装,头文件互相覆盖,编译到数据库层才报“找不到mysql.h里某个符号”。先dpkg -l | grep -i mysql看一眼有没有残留,再决定装哪个。
3.2 CMake 配置项逐条说明
git clone --depth 1 <仓库地址> azerothcore-wotlk cd azerothcore-wotlk mkdir -p build && cd build cmake .. \ -DCMAKE_INSTALL_PREFIX=/opt/acore \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DSCRIPTS=static \ -DMODULES=static \ -DTOOLS_BUILD=all \ -DBUILD_TESTING=OFF \ -DWITH_COREDEBUG=OFF逐条说清楚每个参数在干什么:
CMAKE_INSTALL_PREFIX:make install之后worldserver、authserver和配置模板的落地位置。装到/opt下的好处是源码目录可以随时删掉重拉,不影响运行中的服务端。CMAKE_BUILD_TYPE=RelWithDebInfo:带符号的优化构建。纯Release省内存但崩了拿不到堆栈,纯Debug跑起来慢到没法测,这个折中是长期开服场景的常规选择。SCRIPTS=static:把官方脚本编译进worldserver本体。可选none或dynamic,dynamic会生成独立的动态库,适合只改脚本不想重编核心的场景,代价是多一层加载路径问题。MODULES=static:同上,作用于modules/下的第三方模块。第一次编译建议先用static,加载失败会在启动阶段直接暴露。TOOLS_BUILD=all:把mapextractor、vmap4extractor、vmap4assembler、mmaps_generator一起编出来。不开这个,后面提取地图数据就没工具可用。WITH_COREDEBUG=OFF:关掉核心调试代码,减少日志噪音。排查崩溃时再打开重编。
配置阶段如果停在Could NOT find Boost,先确认BOOST_ROOT,再确认装的 Boost 是不是被 CMake 找到的那个版本;两个 Boost 共存时 CMake 会挑错,显式传-DBOOST_ROOT=/usr通常能解决。
make -j"$(nproc)" make install-j开满核。内存小于 8G 的机器把并发降到 4,否则会在链接阶段被 OOM Killer 干掉,报错信息是c++: fatal error: Killed signal terminated program cc1plus,看着像编译器问题,其实是内存。
3.3 三个库的导入顺序与配置落地
数据库的导入顺序不能反。acore_auth存账号和 realm 列表,acore_characters存角色,acore_world存量最大的静态数据,三库之间有外键依赖但导入本身互不影响,真正要注意的是先建库再灌数据。
# 1) 建库建账号,脚本在源码树的 data/sql/create 下 mysql -uroot -p < data/sql/create/create_mysql.sql # 2) 依次导入三个库的基础结构,目录名以你拉到的分支为准,先 ls 确认 for f in data/sql/base/db_auth/*.sql; do mysql -uacore -pacore acore_auth < "$f"; done for f in data/sql/base/db_characters/*.sql; do mysql -uacore -pacore acore_characters < "$f"; done for f in data/sql/base/db_world/*.sql; do mysql -uacore -pacore acore_world < "$f"; done # 3) 跑增量更新,这一步在每次 git pull 之后都要重做 for f in data/sql/updates/db_world/*.sql; do mysql -uacore -pacore acore_world < "$f"; donefor循环里的顺序不用改,但目录名要先ls一遍确认。不同分支下db_world可能叫db_world也可能被拆成db_world/base加db_world/updates,照抄命令导致No such file or directory是最常见的第一道坎。
配置文件的落地方式是先复制模板再改:
cp /opt/acore/etc/worldserver.conf.dist /opt/acore/etc/worldserver.conf cp /opt/acore/etc/authserver.conf.dist /opt/acore/etc/authserver.conf要改的就四行:
# /opt/acore/etc/worldserver.conf LoginDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_auth" WorldDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_world" CharacterDatabaseInfo = "127.0.0.1;3306;acore;acore;acore_characters" DataDir = "/opt/acore/data"DataDir指向的是提取出来的dbc、maps、vmaps、mmaps四个目录的父目录,不是客户端根目录。这一点写错的表现是启动时报Correct *.map files not found,然后进程直接退出。
地图数据用同一批编出来的工具生成:
cd /opt/acore/bin ./mapextractor # 产出 dbc 与 maps ./vmap4extractor # 产出 vmaps 原始数据 ./vmap4assembler # 合并成可用的 vmaps ./mmaps_generator # 生成寻路网格,耗时最长不带参数运行会打印各自的用法;不同版本的参数名有差异,硬背命令不如先跑一次看帮助。mmaps_generator在大机器上也要跑十几分钟到半小时,中途别中断,生成到一半的目录会让服务端启动时校验失败。
3.4 账号、GM 权限与最小验证闭环
authserver和worldserver分别在两个终端启动,顺序是先 auth 后 world。
cd /opt/acore/bin ./authserver # 监听认证端口,日志出现 "WorldSocket listening" 即可 ./worldserver # 启动较慢,等日志出现 "World initialized" 再登录控制台里建账号:
account create tester tester123 account set gmlevel tester 3 -1-1表示全 realm 生效,3是 GM 等级。改完权限要让角色重新登录才生效,热改只对新建会话起作用。
进游戏后用两条命令确认闭环通了:
.server info # 看在线人数、运行时长、核心版本 .gm on # 打开 GM 模式,能飞能传送.server info能返回数据,说明数据库连接、脚本加载、网络层三块都正常。这一条验证过了再往下做模块,否则后面模块加载失败的锅会算错地方。
提示:
worldserver启动卡在Loading Player Create Info很久是正常的,这一步在一次性加载种族职业初始数据,机械硬盘上能到几十秒。
4. 模块化落地:写一个 mod-greeter 并让它被构建树自动发现
模块机制是这个项目最值得看的部分。它不复杂,但有几个必须守住的约定,破坏任何一条都会导致“编译过了、运行时不生效”这种最难查的问题。
4.1 模块目录结构与自动发现机制
构建系统在配置阶段会扫modules/下的每一个子目录,目录名必须带mod-前缀,且目录内必须有CMakeLists.txt。缺 CMakeLists 的目录会被静默跳过,不会报错。
modules/ └── mod-greeter/ ├── CMakeLists.txt ├── conf/ │ └── mod-greeter.conf.dist └── src/ ├── mod_greeter.cpp └── mod_greeter_loader.cppCMakeLists.txt只需要两行声明:
# modules/mod-greeter/CMakeLists.txt AC_ADD_SCRIPT_LOADER("Greeter" "${CMAKE_CURRENT_LIST_DIR}/src/mod_greeter_loader.cpp") AC_ADD_SCRIPT("${CMAKE_CURRENT_LIST_DIR}/src/mod_greeter.cpp")第一行的"Greeter"不是随便起的字符串。构建系统会用这个名字推导出注册函数AddGreeterScripts,并在生成的入口代码里调用它。名字和 loader 里定义的函数名对不上,链接阶段就会报undefined reference,这是模块开发里最高频的第一次失败。
loader 文件本身很短,职责就是把脚本对象 new 出来:
// modules/mod-greeter/src/mod_greeter_loader.cpp void AddGreeterScripts(); // 前置声明,签名必须与构建系统推导的一致 void AddGreeterScripts() { // 脚本对象的生命周期由核心管理,这里只管注册 new GreeterPlayerScript(); }4.2 脚本钩子:注册方式与执行时机
真正的逻辑写在mod_greeter.cpp里。以玩家登录和升级两个时机为例:
// modules/mod-greeter/src/mod_greeter.cpp #include "ScriptMgr.h" #include "Player.h" #include "Config.h" #include "Chat.h" #include "Log.h" class GreeterPlayerScript : public PlayerScript { public: // 第二个参数是钩子掩码:显式声明订阅哪些事件 GreeterPlayerScript() : PlayerScript("GreeterPlayerScript", { PLAYERHOOK_ON_LOGIN, PLAYERHOOK_ON_LEVEL_CHANGED }) { } void OnPlayerLogin(Player* player) override { if (!sConfigMgr->GetOption<bool>("Greeter.Enable", true)) return; // GM 不打扰,避免自己测试时被刷屏 if (player->GetSession()->GetSecurity() > SEC_PLAYER) return; ChatHandler(player->GetSession()).SendSysMessage( sConfigMgr->GetOption<std::string>("Greeter.WelcomeText", "欢迎回来。")); } void OnLevelChanged(Player* player, uint8 oldLevel) override { if (!sConfigMgr->GetOption<bool>("Greeter.Enable", true)) return; LOG_INFO("module.greeter", "player {} level {} -> {}", player->GetName(), static_cast<uint32>(oldLevel), static_cast<uint32>(player->getLevel())); } };钩子掩码这一行是关键。早期写法是不声明掩码,核心在每个事件上都调一遍虚函数,二十个模块挂上去之后每 tick 都在做无谓的虚调用。现在必须在构造函数里列出来,没列的事件不会被派发。
OnPlayerLogin的执行时机在角色对象完全构造之后、进入世界之前,这里能拿到Player*但还不能安全地做传送、发邮件这类操作。OnLevelChanged的第二个参数是升级前的等级,用来算“升了几级”比事后自己缓存靠谱。
4.3 配置项读取与生效边界
配置项的写法是模块名加点号,和主配置共用同一个ConfigMgr:
# modules/mod-greeter/conf/mod-greeter.conf.dist Greeter.Enable = 1 Greeter.WelcomeText = "欢迎回来,今天的任务刷新了吗?"GetOption<T>的第二个参数是默认值,配置文件里没写这一项时用它。这个默认值不要写业务关键值,一旦配置加载失败,服务端会带着默认值继续跑,看起来“都正常”但行为不对。
模块配置文件的落点按官方模板的约定放进安装目录的etc/modules/下,启动时会被统一合并。也可以直接把这几行写进worldserver.conf,效果一样,因为底层是同一个配置实例。区别在于升级核心时,写进主配置的改动容易被模板覆盖掉,而etc/modules/下的文件是独立的。
注意:配置项在启动阶段一次性读入。运行中改文件不会热生效,改完必须重启
worldserver。少数带重载命令的配置项是例外,模块自己加的项不属于这一类。
4.4 编译、加载与日志验证
改完模块重新编译,只需要增量构建:
cd /opt/acore/build cmake . # 新增了模块目录,必须重跑一次配置让它被扫描到 make -j"$(nproc)" make install只在已有模块里改代码,跳过cmake .也能编译;新增目录不重跑配置,构建系统压根不知道它的存在,表现是“编译成功但没变化”。
验证模块是否真的挂上了,看启动日志里的加载行,或者在代码里加一条LOG_INFO然后登录触发。日志级别在worldserver.conf的Logger.module.greeter段里单独控制,默认继承全局级别,INFO一般能打出来。
更稳的验证方式是加一条自定义 GM 命令,让模块自己报告状态:
class GreeterCommandScript : public CommandScript { public: GreeterCommandScript() : CommandScript("GreeterCommandScript") { } std::vector<ChatCommandTable> GetCommands() const override { static ChatCommandTable table = { { "greeter", HandleGreeterCommand, SEC_PLAYER, Console::No } }; return table; } static bool HandleGreeterCommand(ChatHandler* handler) { handler->PSendSysMessage("Greeter.Enable = {}", sConfigMgr->GetOption<bool>("Greeter.Enable", true)); return true; } };然后在 loader 里多 new 一行。命令能返回正确的配置值,说明注册、配置读取、命令分发三条链路全通,比看日志可靠得多。
5. 避坑与排查:五次翻车记录,现象、原因和解决路径
下面五条都是我自己撞过的,按撞到的概率从高到低排。
5.1 启动即退出,日志最后一行是表不存在
现象:worldserver启动两秒后退出,日志末尾是Table 'acore_world.xxx' doesn't exist。
原因:基础 SQL 导入不完整。常见于for循环里某个文件因为字符集或外键顺序报错中断,但被for忽略过去了,脚本继续往下跑,你只看到最后一行成功的输出。
解决:重新导一遍,这次不要用循环,逐个文件执行并检查返回码。或者导入完成后直接查表数量:三个库的表数量应该在一个稳定区间,和刚拉下来的空库对比就知道少了哪些。
5.2 编译通过,模块钩子完全不触发
现象:模块目录在构建日志里出现过,make没有报错,但登录时什么反应都没有。
原因有三类:CMakeLists.txt里的注册名和 loader 函数名不一致但恰好没被链接到;hook 掩码里漏了对应事件;模块目录名没带mod-前缀被跳过。
解决:先看配置阶段的输出里有没有这个模块的目录名。没有就是目录命名问题;有但运行无反应,就在 loader 里加一条无条件日志,确认AddXxxScripts真的被调用了。
5.3 改配置不生效,重启也没用
现象:改了配置文件里的值,重启服务端行为不变。
原因:改的是.conf.dist模板而不是.conf。模板只在首次复制时用一次,之后所有改动都在.conf上;另一种情况是模块配置写进了worldserver.conf,而模块自己的配置文件在etc/modules/下,加载顺序上后者覆盖前者。
解决:grep配置项名字,把所有出现的位置列出来,确认最终生效的那一个是哪份文件。同时检查有没有重复的键,同一个键出现两次时通常最后一次生效,行为看起来像“改错了”。
5.4 怪物穿墙、卡在台阶上,服务端没有报错
现象:NPC 追人时直接穿过地形,或者卡在斜坡上原地抖动。
原因:mmaps没生成或者DataDir指错了。mapextractor只产出地形高度和贴图数据,寻路网格是另外一组工具单独产出的,缺了它 AI 只能走直线。
解决:确认DataDir指向的目录下四个子目录齐全,特别看mmaps的大小,正常是几个 G。缺失就重新跑mmaps_generator,注意它需要客户端原始数据,跑完之前不要启动服务端。
5.5 长时间运行后内存持续上涨
现象:跑一天之后 RSS 从 1G 涨到 4G,重启就好了。
原因:模块里在OnPlayerLogin这类高频钩子里往静态容器塞数据,角色下线时没有清理。另一个常见来源是给每一个玩家对象new了自定义结构体,却挂在核心不管生命周期的地方。
解决:加一个OnPlayerLogout钩子做对称清理;静态容器定期打印size()到日志,观察是否单调增长。缓存本身没问题,问题是无上限的缓存。
提示:内存问题优先怀疑自己写的模块,而不是核心。官方脚本跑一周不涨,加了模块就涨,基本可以定位。
6. 进阶验证:用三条命令和两个参数把服务端跑稳
模块能跑通之后,真正决定体验的是稳定性,而不是功能数量。下面这套验证流程我每次改完模块都会走一遍,成本不到十分钟。
6.1 一条最小回归路径
# 1) 冷启动,等 World initialized ./worldserver # 2) 进游戏后确认三条链路 .server info .gm on .greeter # 自定义命令,验证模块状态 # 3) 主动触发目标钩子 .character level tester 79 # 掉一级 .character level tester 80 # 升回 80,看 OnLevelChanged 是否打日志.server info验证核心与数据库,.greeter验证模块注册与配置读取,.character level验证钩子派发。三条都过,说明模块从注册到执行是通的;有任何一条不过,回头查对应那一层,不要从头重装。
6.2 两个值得提前调的参数
worldserver.conf里有两个参数对稳定性影响很大,默认值偏保守:
| 参数 | 默认倾向 | 调整建议 |
|---|---|---|
MapUpdate.Threads | 1 | 按物理核数的一半起步,超过核数会互相抢锁,性能反而下降 |
WorldDatabase.WorkerThreads | 较少 | 模块里有大量查询热点时调到 4 到 8,同时确认 MySQL 的max_connections够 |
地图线程调大后要观察一件事:多个线程同时更新同一张地图的实体时,如果模块在钩子里改共享状态,会出现偶发的不一致,症状是“偶尔”某个玩家的数据不对。这类问题在单线程下永远不会出现,调完参数才暴露,属于典型的调优引发的血案。
我的习惯是每次改完MapUpdate.Threads之后,专门跑一轮多人在线测试,让两个角色同时进同一张图、同时触发同一个钩子,看日志里的顺序有没有乱。单人在线测不出并发问题,这一点没什么捷径。
从那以后,我每次新增模块都会先把 hook 掩码和 loader 函数名对着抄一遍,再编译,宁可慢五分钟也不要花两小时查“为什么没生效”。希望帮到你。
本文还有配套的精品资源,点击获取