技能监听器
同一句「监听技能」在项目里是两套独立体系: 任务侧的 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 三个维度:
| 方案 | 任务配置写什么 | 改监听范围时 |
|---|---|---|
| 直配技能 ID | SkillIds: [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_DURATION | Buff 被驱散/偷取/死亡清除等非自然到期的移除 |
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。
任务侧 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。 这类问题的通用解法:事件粒度粗于业务语义时,回调里按枚举收窄, 而不是要求事件系统按业务拆事件。
所以原因门只放行 BUFF_REMOVE_REASON_DURATION。 这类问题的通用解法:事件粒度粗于业务语义时,回调里按枚举收窄, 而不是要求事件系统按业务拆事件。
事件订阅怎么保证不泄漏、不误触工程
三条纪律:
① 按需订阅——聚合 Set 为空就一个监听不挂,没配的通道不占用分发; ② 目标级订阅 + 成对反注册——AddWithTarget 挂实体句柄, UnInitCtLogic 全部摘除;处理器是 readonly 箭头函数实例字段, 注册和反注册引用同一个函数,杜绝匿名函数注册后无法摘除的泄漏; ③ 回调防御前置——状态门(只在 Casting 响应)+ 实体门(目标级订阅兜底) + 值门(Set.has 收窄范围)三层先行 return, 任何迟到广播、跨实体事件、范围外事件都进不了 CompleteSkillGroup。
① 按需订阅——聚合 Set 为空就一个监听不挂,没配的通道不占用分发; ② 目标级订阅 + 成对反注册——AddWithTarget 挂实体句柄, UnInitCtLogic 全部摘除;处理器是 readonly 箭头函数实例字段, 注册和反注册引用同一个函数,杜绝匿名函数注册后无法摘除的泄漏; ③ 回调防御前置——状态门(只在 Casting 响应)+ 实体门(目标级订阅兜底) + 值门(Set.has 收窄范围)三层先行 return, 任何迟到广播、跨实体事件、范围外事件都进不了 CompleteSkillGroup。