你在游戏里放了一个寒冰箭,冰柱从你手中飞出打中目标,目标身上挂了一个减速的 Debuff。过程中,服务器先后创建了一个 Spell 对象和一个 Aura 对象。
如果这条寒冰箭绑定了自定义脚本------比如命中后额外触发冰冻效果------那脚本对象也是在这时按需创建的。不是启动时,是施法时。
这和上一篇说到的「模块 new 脚本 → 常驻注册表」机制完全不同。SpellScript 和 AuraScript 走的是延迟创建 + 绑定到具体法术 ID 的路线 ,这是 AzerothCore Hook 系统中最复杂的机制之一。

问题:法术太多了
WoW 3.3.5(WotLK)有 8 万多条法术记录 。如果像 PlayerScript 那样,每个脚本对象在启动时就注册到全局注册表,然后运行时按需调用------那当服务器施放任意一个法术时,ScriptMgr 要遍历所有 SpellScript 实例,检查"你是不是管这个法术的"?
8 万次匹配,每次施法来一遍。不可接受。
所以 AzerothCore 把法术脚本从"常驻注册"改成了"按需创建"------只有当某个法术实际被施放时,才为它创建对应的脚本对象,用完即弃。
SpellScriptLoader:法术脚本的工厂
桥梁是一个叫 SpellScriptLoader 的类。它本身是一个普通的脚本对象(继承自 ScriptObject),注册方式和上一篇说的完全一样:
cpp
class SpellScriptLoader : public ScriptObject
{
bool IsDatabaseBound() const override { return true; }
virtual SpellScript* GetSpellScript() const { return nullptr; }
virtual AuraScript* GetAuraScript() const { return nullptr; }
};
IsDatabaseBound() 返回 true,说明它必须在 spell_script_names 表中有记录才能生效。这张表的 script_name 字段对应 Loader 的构造函数参数:
sql
-- spell_script_names 表结构
spell_id | ScriptName
---------+---------------------------
339 | spell_mage_blizzard
42223 | spell_hun_crabby
启动时,AddALScripts() 把数据库中配置的 Loader 全部注册到 ScriptPointerList。但 Loader 注册的不是脚本逻辑,而是一个工厂 ------告诉核心:" spell_id 339 被施放时,调用我的 GetSpellScript() 来创建真正的脚本对象。"
两层宏的注册套路
实际写法很简单。AzerothCore 提供了宏来省掉手写 Loader:
cpp
// 最常见的用法
RegisterSpellScript(spell_mage_blizzard)
展开后就是 new GenericSpellAndAuraScriptLoader<spell_mage_blizzard, ...>("spell_mage_blizzard", ...),其中 GenericSpellAndAuraScriptLoader 继承自 SpellScriptLoader,重写了 GetSpellScript() 返回 new spell_mage_blizzard()。
如果需要注册 SpellScript + AuraScript 对(同一条法术既有施法逻辑又有光环逻辑):
cpp
RegisterSpellAndAuraScriptPair(spell_pal_seal_of_command, aura_pal_seal_of_command)
如果脚本构造函数需要额外参数:
cpp
RegisterSpellScriptWithArgs(spell_example, "spell_example", arg1, arg2)
所有这些宏最终都指向 GenericSpellAndAuraScriptLoader,它在编译时通过模板元编程(Acore::find_type_if_t)区分传入的参数是 SpellScript 还是 AuraScript,然后实现对应的工厂方法。
SpellScript:按需创建的生命周期
真正的法术逻辑在 SpellScript 类中。它不是 ScriptObject 的子类------所以不参与常驻注册表。生命周期由核心代码控制:
法术被施放
│
▼
sSpellMgr->CreateSpellScript(spellId, spell)
│ ← 查 ScriptRegistry<SpellScriptLoader>
│ ← 找到对应 Loader,调用 GetSpellScript()
▼
new spell_mage_blizzard() ← 此时才创建
│
▼
script->_Init(&name, spellId)
script->Load() ← 初始化(从构造函数到 Load)
script->Register() ← 绑定 Hook 处理函数
│
▼
法术施放流程中,核心在各个阶段调用脚本
│ _PrepareScriptCall(hookType)
│ 执行 Hook 处理函数
│ _FinishScriptCall()
▼
施法结束 → script 被销毁
从创建到销毁,跨越一个完整的法术施放过程。下一个法术再来,再创建一个新的。
Register() 模式:声明式 Hook 绑定
SpellScript 的写法和 PlayerScript 完全不同。PlayerScript 是继承重写虚函数,SpellScript 是在 Register() 里用 += 绑定处理函数:
cpp
class spell_mage_blizzard : public SpellScript
{
PrepareSpellScript(spell_mage_blizzard);
void HandleOnHit(SpellMissInfo /*missInfo*/)
{
// 寒冰箭命中后的自定义逻辑
if (Unit* target = GetHitUnit())
target->CastSpell(target, 12484, true); // 冰冻效果
}
void Register() override
{
OnHit += SpellHitFn(spell_mage_blizzard::HandleOnHit);
}
};
PrepareSpellScript 是一个宏,展开后是一堆类型定义(把成员函数指针类型别名化),让后面 SpellHitFn 等转换宏能正常工作。
Register() 里用 += 把处理函数绑定到 Hook 列表上。可用的 Hook 有:
| Hook | 触发时机 |
|---|---|
OnCheckCast |
施法前检查(可阻止施法) |
BeforeCast |
施法开始前 |
OnCast |
施法开始时 |
AfterCast |
施法完成后 |
OnEffectLaunch |
效果发射前(每个 Effect) |
OnEffectLaunchTarget |
效果发射到目标时 |
OnEffectHit |
效果命中时 |
OnEffectHitTarget |
效果命中到目标时 |
BeforeHit |
命中判定后、实际效果前 |
OnHit |
命中时 |
AfterHit |
命中后 |
OnObjectAreaTargetSelect |
区域目标选择后(可过滤目标) |
OnObjectTargetSelect |
单体目标选择后 |
OnDestinationTargetSelect |
目标点选择后 |
每个 OnEffect* 钩子都支持按 Effect 索引和 Effect 类型过滤:
cpp
OnEffectHitTarget += SpellEffectFn(
HandleOnEffectHitTarget,
EffectIndex0, // 第 0 个 Effect
SPELL_EFFECT_SCHOOL_DAMAGE // 类型为法术伤害
);
可以传入 EFFECT_ALL 和 SPELL_EFFECT_ANY 来匹配所有。
AuraScript:光环系统的脚本化
AuraScript 和 SpellScript 走同一套 SpellScriptLoader 注册机制,但 Hook 体系不同------它绑定的是**光环(Aura/Buff)**的生命周期:
cpp
class aura_pal_sanctity_aura : public AuraScript
{
PrepareAuraScript(aura_pal_sanctity_aura);
void CalculateAmount(AuraEffect const* /*aurEff*/, int32& amount, bool& /*canBeRecalculated*/)
{
amount = 10; // 固定 10% 伤害加成
}
void Register() override
{
DoEffectCalcAmount += AuraEffectCalcAmountFn(
aura_pal_sanctity_aura::CalculateAmount,
EFFECT_0,
SPELL_AURA_MOD_DAMAGE_DONE
);
}
};
AuraScript 的 Hook 围绕四个维度:计算(Calc)→ 应用(Apply)→ 周期(PeriodicTick)→ 移除(Remove)。
常用的包括:
DoEffectCalcAmount--- 修改光环数值OnApply/OnRemove--- 光环施加/消失时OnPeriodicTick--- 每 Tick 触发(DOT/HOT 类)CheckAreaTarget--- 持续光环的范围内目标判定
EffectHook 的两层过滤
OnEffectHit、DoEffectCalcAmount 这些 Effect 类 Hook,内部有一个 EffectHook 基类做两层过滤:
- Effect 索引过滤:只处理第 0/1/2 个 Effect
- Effect 名称过滤 :只处理
SPELL_EFFECT_SCHOOL_DAMAGE类型
两层都用位掩码实现。GetAffectedEffectsMask() 返回一个 uint8,最多支持 8 个 Effect(WoW 法术最多 3 个,预留了扩展空间)。检查时 spellInfo->GetEffect(i) 按 Effect 索引逐一匹配。
这样做的好处:一个法术有 3 个 Effect,不同 Effect 类型不同(比如一个是伤害、一个是减速),一个 SpellScript 可以用不同的处理函数分别拦截它们,而不需要在一个函数里写 if-else。
与 AllSpellScript 的区别
还有一类 AllSpellScript(旧名 SpellSC),它不是 按法术 ID 创建的------它继承 ScriptObject,走常驻注册表,每个 Hook 在所有法术施放时触发。
cpp
class AllSpellScript : public ScriptObject
{
virtual void OnSpellCast(Spell* spell, Unit* caster,
SpellInfo const* spellInfo, bool skipCheck) { }
virtual bool CanScalingEverything(Spell* spell) { return false; }
virtual void OnCalcMaxDuration(Aura const* aura,
int32& maxDuration) { }
// ...
};
典型的全局逻辑用 AllSpellScript------比如"所有法术的伤害系数根据 PvP/PvE 场景缩放"。特定法术的定制逻辑用 SpellScript------比如"暴风雪法术命中后额外施加冰冻效果"。
SpellMgr 的 Initialize() 里分别初始化两个注册表:
cpp
ScriptRegistry<SpellSC>::InitEnabledHooksIfNeeded(ALLSPELLHOOK_END);
// SpellScriptLoader 在 ALScripts 阶段加载,SpellScript 不走注册表
一张图对比三种法术脚本
┌─────────────────────────────────────────────────────────┐
│ AllSpellScript │
│ 常驻注册,所有法术都触发 │
│ Register: new MyAllSpellScript() │
│ Hook: OnSpellCast / OnCalcMaxDuration / CanScaling... │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ SpellScript │
│ 按需创建,绑定到具体 spell_id │
│ Register: RegisterSpellScript(spell_xxx) │
│ Loader → spell_script_names 表 │
│ Hook: OnHit / OnEffectHit / OnCheckCast / BeforeCast... │
│ 生命周期: 施法开始 → 施法结束 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ AuraScript │
│ 按需创建,绑定到具体 spell_id(光环) │
│ Register: RegisterSpellAndAuraScriptPair(...) │
│ Hook: DoEffectCalcAmount / OnApply / OnRemove / Tick... │
│ 生命周期: 光环施加 → 光环消失 │
└─────────────────────────────────────────────────────────┘
为什么不用统一的 ScriptObject
这样做的代价是法术脚本体系更复杂------需要额外的 Loader 类、spell_script_names 表、Register() 模式、生命周期管理。但换来的是真正的按需创建:
8 万条法术,实际运行的服务器同时在线可能就几百人,一场团本同时施放的法术不过几十个。常驻注册一个 AllSpellScript 全局脚本 + 几十个 SpellScriptLoader 工厂,内存占用极低。只有法术真正被施放时,才为那一条法术分配脚本对象。
这是 MMO 服务端一个经典的取舍:牺牲启动时的类型统一性,换取运行时的大规模对象池节约。
一台服务器同时在线的几百人,一场团本同时施放的法术不过几十个。常驻注册一个全局 AllSpellScript + 几十个工厂 Loader,内存占用极低。只有法术真正被施放时,才为那一条法术分配脚本对象------这就是 8 万条法术背后的生存法则。