AzerothCore学习笔记·模组03:Hook 系统(下)——SpellScript 与 AuraScript 的延迟创建机制

你在游戏里放了一个寒冰箭,冰柱从你手中飞出打中目标,目标身上挂了一个减速的 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_ALLSPELL_EFFECT_ANY 来匹配所有。


AuraScript:光环系统的脚本化

AuraScriptSpellScript 走同一套 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 的两层过滤

OnEffectHitDoEffectCalcAmount 这些 Effect 类 Hook,内部有一个 EffectHook 基类做两层过滤:

  1. Effect 索引过滤:只处理第 0/1/2 个 Effect
  2. 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------比如"暴风雪法术命中后额外施加冰冻效果"。

SpellMgrInitialize() 里分别初始化两个注册表:

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 万条法术背后的生存法则。