首页 / 入职工作清单 / 技能监听器

技能监听器

同一句「监听技能」在项目里是两套独立体系: 任务侧的 SkillListen 条件(监听全队玩家技能的 释放/命中/完成三态,监听目标用「技能集合」间接描述,服务器权威判定); 战斗侧的 SkillGroupObject 结束监听 (技能组 Casting 状态靠 CastSkill/BuffEnd 条件收尾, 三个 Set 聚合 + 目标级订阅 + 回调四重防御)。

从 0 建 Cast / Hit / Finish 三态 技能集合间接引用 回调四重防御 AddWithTarget 目标级订阅

1. 工作清单

内容性质
技能监听器:任务条件 SkillListen(全队玩家技能 Cast/Hit/Finish 监听、监听目标由 DT_SkillCollectionForLevel 技能集合描述、命中态可指定目标实体)+ 战斗侧 SkillGroup 结束条件监听(CastSkill/BuffEnd/Tag 三通道)从 0 建

2. 理论:两层监听架构与防御性回调

2.1 两层监听:判定方不同,写法就不同

层次载体判定方用途
任务条件层ISkillListenCondition(任务子条件)服务器(ServerAchieveChildQuestNode)主线/支线/关卡任务:「用某类技能命中 N 次」
战斗表现层SkillGroupObject 的 CtLogic客户端连招组 Casting → 结束的展示驱动(播下一组技能 UI/状态)
为什么任务侧监听是服务器判定 任务进度是存档事实——断线重连、换设备、防作弊都要求「达成与否」由服务器说了算。 客户端如果自己听事件就凑任务进度,等于把存档判定的权威交给了表现层。 所以 SkillListen 在节点注册表里挂的是 ServerAchieveChildQuestNode: 客户端只负责把条件配置结构(技能集合行名 + 三态 + 可选目标实体)交给服务器。

2.2 技能集合:监听目标的间接引用

任务配置里不直接写技能 ID 数组,而是写一个行名, 指向 DT_SkillCollectionForLevel 的一行, 行内含技能 ID、技能类型、技能 Tag 三个维度:

方案任务配置写什么改监听范围时
直配技能 IDSkillIds: [12001, 12002, ...]每个用了这批技能的任务全要改
技能集合行名SkillCollection: "Fire_Heavy"改 DT 表里那一行,所有引用任务自动生效
间接引用 = 把「监听什么」和「谁在监听」解耦 策划调技能范围(比如把水系重击也纳入「重击」集合)只动集合表; 任务侧引用行名不变。 三态里只有 Hit(命中)可以再叠加 TargetEntityId—— 命中是「技能 → 目标」的方向性事件, 配置了目标实体时才判定「监听目标与技能目标一致」; Cast(释放)和 Finish(完成)没有方向性,配了也不参与判定。

2.3 战斗侧订阅纪律:聚合 → 按需注册 → 成对反注册

1
清空三个 Set
ListenSkillIds / ListenBuffIds / ListenTags 先 clear,防初始化重入残留
2
扫配置聚合
遍历 EndConditions,CastSkill → 收 SkillIds,BuffEnd → 收 BuffIds,全部进对应 Set
3
非空才注册
size > 0 才 AddWithTarget——没配的通道一个监听都不挂
4
目标级订阅
AddWithTarget(catcher, ...) 挂在实体句柄上,与实体生命周期对齐
5
UnInit 成对反注册
UnInitCtLogic 把三通道监听全部摘掉,实体销毁不泄漏
为什么聚合用 Set 不用数组 回调里的高频判定是 ListenSkillIds.has(skillId)—— Set 是 O(1),数组 includes 是 O(n); 且同一技能 ID 在多条结束条件里重复配置时 Set 自动去重, 「监听范围」语义就是集合语义。

2.4 回调四重防御:每层拦截不同来源的脏事件

防御层判定拦的是什么
状态门StateInternal !== Casting非施法期收到的事件(Ready/结束后的迟到广播)
实体门entityId / charId !== this.EntityId目标级订阅的兜底——事件源不是本实体
值门ListenSkillIds.has / ListenBuffIds.has本实体事件但在监听范围之外
原因门eReason !== BUFF_REMOVE_REASON_DURATIONBuff 被驱散/偷取/死亡清除等非自然到期的移除
BuffEnd 的语义是「自然走完」 RemoveBuff 是一个大杂烩事件:到期、被驱散、被偷取、角色死亡清除全走它, 区别只在 eReason 枚举。 策划配「技能组维持到 buff 消失」,直觉语义是时间走完—— 被驱散就提前结束连招组,表现会和技能逻辑打架。 所以原因门只放行 BUFF_REMOVE_REASON_DURATION。 这是「事件粒度粗于业务语义」时的标准处理:回调里按枚举收窄。

3. 代码片段

3.1 任务侧配置结构:三态 + 技能集合 + 可选目标

Src\UniverseEditor\Interface\IQuest.ts:1286-1307

/** 技能监听的技能状态 */
export const enum ESkillListenState {
    /** 释放 */
    Cast = 'Cast',
    /** 命中 */
    Hit = 'Hit',
    /** 完成,技能取消视为完成 */
    Finish = 'Finish',
}

/**
 * 技能监听:监听全队玩家技能的释放/命中/完成事件。
 * 监听目标由「技能集合」描述,即 DT_SkillCollectionForLevel 的一行,行内含技能Id、技能类型、技能Tag。
 */
export interface ISkillListenCondition extends IQuestCondition<EChildQuest.SkillListen> {
    /** 技能集合行名,对应 DT_SkillCollectionForLevel */
    SkillCollection: string;
    /** 技能状态 */
    SkillState: ESkillListenState;
    /** 目标实体,可置空;仅「命中」时判定监听目标与技能目标是否一致 */
    TargetEntityId?: int;
}
注释即需求 「技能取消视为完成」写进枚举注释——Finish 的边界(取消算不算完成) 是和策划对齐过的语义决定,留在类型定义旁边,后来者不会猜错。 TargetEntityId 注释写明只对 Hit 生效, 可选字段的行为边界在声明处收敛。

3.2 运行时归属:SkillListen 挂服务器达成型节点

Src\Game\Module\GeneralLogicTree\Define\NodeTypeDefine.ts:104-108(节选)

[EChildQuest.CheckGeneralMoveMoving]: new NodeTypeData(ServerAchieveChildQuestNode),
[EChildQuest.CheckGeneralMoveArrive]: new NodeTypeData(ServerAchieveChildQuestNode),
[EChildQuest.CheckGeneralMoveSectionFinished]: new NodeTypeData(ServerAchieveChildQuestNode),
[EChildQuest.SkillListen]: new NodeTypeData(ServerAchieveChildQuestNode),
一行注册,权威归位 任务条件树里每个子条件类型都要在节点注册表登记运行时节点。 SkillListen 与「到达检查点」「击杀 N 次」同列 ServerAchieve—— 客户端配置结构,服务器判定达成, 存档一致性和防作弊天然成立,客户端零进度状态可维护。

3.3 战斗侧聚合与按需注册

Src\Game\World\Entity\Skill\SkillGroup\SkillGroupObject.ts:144-193(节选)

private InitCtStateListeners(): void {
    this.StateInternal = ESkillGroupState.Ready;
    this.ListenSkillIds.clear();
    this.ListenBuffIds.clear();
    this.ListenTags.clear();

    const catcher = EntityInterfaceHelper.GetEntityHandleByLocalEntityId(this.EntityId);
    if (!catcher) {
        Log.Error(ELogModule.Battle, ELogAuthor.CZL,
            'SkillGroupObject::InitCtStateListeners Entity不存在,无法注册监听',
            ['EntityId', this.EntityId],
        );
        return;
    }

    this.ConfigInternal!.StateLogic.EndConditions.forEach((endCondition) => {
        switch (endCondition.ConditionType) {
            case UE.ESkillGroupEndStateConditionType.CastSkill:
                for (const skillId of endCondition.CastSkill.SkillIds) {
                    this.ListenSkillIds.add(skillId);
                }
                break;
            case UE.ESkillGroupEndStateConditionType.BuffEnd:
                for (const buffId of endCondition.BuffEnd.BuffIds) {
                    this.ListenBuffIds.add(buffId);
                }
                break;
        }
    });

    if (this.ListenSkillIds.size > 0) {
        EventSystem.AddWithTarget(catcher, EEventName.CharUseSkill, this.OnCatcherUseSkill);
    }
    if (this.ListenBuffIds.size > 0) {
        EventSystem.AddWithTarget(catcher, EEventName.RemoveBuff, this.OnRemoveBuff);
    }
}
三段式订阅初始化 先 clear(重入安全)→ 句柄缺失立刻 Error 并放弃注册 (监听不注册不会误判完成,注册了才可能出错)→ 扫配置聚合进三个 Set → 非空通道才订阅。 注释里原代码保留了一个被注掉的 TagEnd 分支—— 条件类型演进中下线了 Tag 通道但 Set 聚合结构保留, 扩展通道时结构零改动。

3.4 回调四重防御:三个处理器一个模子

Src\Game\World\Entity\Skill\SkillGroup\SkillGroupObject.ts:435-484

private readonly OnCatcherUseSkill = (
    entityId: number,
    skillId: number,
    skillGroupId: number,
    isAutonomousProxy: boolean,
): void => {
    if (this.StateInternal !== ESkillGroupState.Casting) { return; }
    if (entityId !== this.EntityId) { return; }
    if (!this.ListenSkillIds.has(skillId)) { return; }

    this.CompleteSkillGroup();
};

private readonly OnRemoveBuff = (
    charId: number | undefined,
    buffId: TBuffIdType,
    buffUid: bigint | undefined,
    eReason: Game.Protocol.BuffRemoveReason | undefined,
): void => {
    if (this.StateInternal !== ESkillGroupState.Casting) { return; }
    if (charId !== this.EntityId) { return; }
    if (eReason !== Game.Protocol.BuffRemoveReason.BUFF_REMOVE_REASON_DURATION) { return; }
    if (!buffId || !this.ListenBuffIds.has(buffId)) { return; }

    this.CompleteSkillGroup();
};

private readonly OnTagChange = (tagId: number, tagExist: boolean): void => {
    if (this.StateInternal !== ESkillGroupState.Casting) { return; }

    if (!tagExist) {
        this.CompleteSkillGroup();
    }
};
读防御顺序,就是读作者的踩坑史 三个处理器入口结构完全一致:状态门 → 实体门 →(值门/原因门)→ CompleteSkillGroup。 箭头函数用 readonly 修饰——实例字段持有引用, 注册和反注册是同一个函数引用,不会出现匿名函数注册后摘不掉的泄漏。 OnRemoveBuff 的参数签名里 charId/buffUid/eReason 都是 | undefined——事件协议字段的空态在类型层面就承认, 防御代码不需要猜「会不会为空」。

4. 面试怎么讲

介绍一下你做的技能监听器设计
两套体系,判定方不同所以写法不同。
任务侧 SkillListen 条件:监听全队玩家技能的 Cast/Hit/Finish 三态, 监听目标不直配技能 ID,用 DT_SkillCollectionForLevel 技能集合行名间接引用—— 策划改技能集合表,所有引用任务自动生效; 只有命中态可以配 TargetEntityId 做「监听目标与技能目标一致」的方向性判定。 运行时挂 ServerAchieveChildQuestNode,服务器权威判定任务达成, 客户端零进度状态。
战斗侧 SkillGroupObject 结束监听:CastSkill/BuffEnd/Tag 三通道聚合进 Set, 非空才 AddWithTarget 目标级订阅,回调四重防御后 CompleteSkillGroup。
OnRemoveBuff 为什么要过滤移除原因难点
RemoveBuff 是个大杂烩事件:自然到期、驱散、偷取、死亡清除全走它,区别只在 eReason。 策划配「技能组维持到 buff 消失」的直觉语义是时间走完—— 如果不按原因过滤,玩家被驱散 buff 时连招组表现会提前收尾, 和技能逻辑打架。
所以原因门只放行 BUFF_REMOVE_REASON_DURATION。 这类问题的通用解法:事件粒度粗于业务语义时,回调里按枚举收窄, 而不是要求事件系统按业务拆事件。
事件订阅怎么保证不泄漏、不误触工程
三条纪律:
① 按需订阅——聚合 Set 为空就一个监听不挂,没配的通道不占用分发; ② 目标级订阅 + 成对反注册——AddWithTarget 挂实体句柄, UnInitCtLogic 全部摘除;处理器是 readonly 箭头函数实例字段, 注册和反注册引用同一个函数,杜绝匿名函数注册后无法摘除的泄漏; ③ 回调防御前置——状态门(只在 Casting 响应)+ 实体门(目标级订阅兜底) + 值门(Set.has 收窄范围)三层先行 return, 任何迟到广播、跨实体事件、范围外事件都进不了 CompleteSkillGroup。