/** * @file SkillBoxComp.ts * @description 单个技能卡效果控制组件(UI 视图层 + 逻辑层) * * 职责: * 1. 表示一张已使用的技能卡在战场上的 **可视化实体**。 * 2. 按 trigger_type 类型化分发触发逻辑(即时 / 定时 / 驻场 / 事件型)。 * 3. 显示技能图标和剩余触发次数。 * 4. 触发结束后自动销毁。 * * 触发类型(CardTriggerType): * - Instant (1):init 时立即触发一次(按 t_times 控制次数,跨波次 NewWave 时再次触发) * - Interval (2):监听 FightStart → update 帧驱动按 t_inv 间隔重复触发(按 t_times 控制每波次数) * - Field (3):被动生效,不主动施法(实际由 FieldSkillSet 处理) * - FightStart (4):监听 FightStart 事件,按 trigger_limit 全局累计上限 * - FightEnd (5):监听 FightEnd 事件(每波结束派发),按 trigger_limit 全局累计上限 * - HeroDead (6):监听 HeroDead 事件(仅英雄阵营派发,怪物死亡不触发) * - HeroCall (7):监听 MasterCalled(主角/技能召唤)+ ReviveSuccess(复活) * * 关键设计: * - 事件型(4-7)统一走 onEventTrigger 入口,仅作触发信号,不读取 payload * - 触发上限:Instant/Interval 按 t_times(每波内),事件型按 trigger_limit(全局) * - 跨波次:keep_waves 控制存活;事件型 trigger_count 不随波次重置 * - 销毁时通过 GameEvent.RemoveSkillBox 通知 MissSkillsComp 回收槽位 * * 依赖: * - CardPoolList / CardTriggerType(CardSet)—— 查询技能卡的触发配置 * - SkillSet —— 技能静态配置(icon 字段) * - GameEvent —— 各类游戏事件 * - smc.mission —— 游戏运行状态 */ import { mLogger } from "../common/Logger"; import { _decorator, Node, Prefab, Sprite, Label, Vec3, resources, SpriteAtlas, tween, v3, Tween, NodeEventType } from "cc"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { CardPoolList, CardTriggerType } from "../common/config/CardSet"; import { SkillSet, SkillOverrides } from "../common/config/SkillSet"; import { oops } from "db://oops-framework/core/Oops"; import { GameEvent } from "../common/config/GameEvent"; import { smc } from "../common/SingletonModuleComp"; import { UIID } from "../common/config/GameUIConfig"; const { ccclass, property } = _decorator; /** * SkillBoxComp —— 单个技能卡效果视图 + 逻辑组件 * * 由 MissSkillsComp.addSkill() 实例化并初始化。 * 在战场上以图标 + 剩余次数的形式呈现。 */ @ccclass('SkillBoxComp') @ecs.register('SkillBoxComp', false) export class SkillBoxComp extends CCComp { /** 调试日志开关 */ private debugMode: boolean = true; /** 技能图标节点 */ @property({ type: Node }) private icon_node: Node = null; /** 剩余次数标签 */ @property(Label) private info_label: Label = null; /** cd计时遮罩 */ @property({ type: Node }) private cd_mask: Node = null; // ======================== 技能配置 ======================== /** 技能 UUID */ private s_uuid: number = 0; /** 卡牌等级 */ private card_lv: number = 1; /** 是否为即时技能(true=使用后立即触发,false=战斗中定时触发) */ private is_instant: boolean = true; /** 总触发次数 */ private trigger_times: number = 1; /** 触发间隔(秒,仅持续技能有效) */ private trigger_interval: number = 0; /** 维持的波次数(-1表示直到战斗结束,0表示不跨波次,>0表示维持的具体波次数) */ private keep_waves: number = 0; /** 技能覆写参数(自定义伤害、Buff等) */ private overrides?: SkillOverrides; /** 驻场技能 UUID 列表 */ public field: number[] = []; // ======================== 触发类型化扩展 ======================== /** 触发类型(默认即时,保持向后兼容) */ private trigger_type: CardTriggerType = CardTriggerType.Instant; /** 事件型触发的全局次数上限(Infinity 表示无上限) */ private trigger_limit: number = Infinity; /** 事件型已触发次数 */ private trigger_count: number = 0; // ======================== 运行时状态 ======================== /** 已触发次数 */ private current_trigger_times: number = 0; /** 当前计时器(秒) */ private timer: number = 0; /** 是否处于战斗中(仅战斗中持续技能才计时) */ private in_combat: boolean = false; /** 是否已初始化 */ private initialized: boolean = false; // ======================== 生命周期 ======================== /** * 注册全局事件: * - MissionEnd:所有类型都需要监听,任务结束时强制销毁 * - NewWave:处理 keep_waves 跨波次逻辑(所有类型统一) * - 其它触发事件由 registerTrigger 按 trigger_type 动态注册 */ onLoad() { oops.message.on(GameEvent.MissionEnd, this.onMissionEnd, this); this.node.on(GameEvent.NewWave, this.onNewWave, this); oops.message.on(GameEvent.NewWave, this.onNewWaveGlobal, this); this.node.on(NodeEventType.TOUCH_END, this.onNodeClicked, this); } /** 销毁时移除所有事件监听并通知槽位管理器回收 */ onDestroy() { super.onDestroy(); // 统一 off 所有可能订阅的事件(即使未订阅也无副作用) // 注意:FightStart 可能由两种回调订阅(Interval→onFightStart / FightStart触发型→onEventTrigger),都需要 off oops.message.off(GameEvent.MissionEnd, this.onMissionEnd, this); oops.message.off(GameEvent.FightStart, this.onFightStart, this); oops.message.off(GameEvent.FightStart, this.onEventTrigger, this); oops.message.off(GameEvent.FightEnd, this.onEventTrigger, this); oops.message.off(GameEvent.HeroDead, this.onEventTrigger, this); oops.message.off(GameEvent.MasterCalled, this.onEventTrigger, this); oops.message.off(GameEvent.ReviveSuccess, this.onEventTrigger, this); if (this.node && this.node.isValid) { this.node.off(GameEvent.NewWave, this.onNewWave, this); this.node.off(NodeEventType.TOUCH_END, this.onNodeClicked, this); } oops.message.off(GameEvent.NewWave, this.onNewWaveGlobal, this); // 通知 MissSkillsComp 回收该节点占用的槽位 oops.message.dispatchEvent(GameEvent.RemoveSkillBox, this.node); } private onNodeClicked() { if (!this.initialized) return; oops.audio.playEffect("music/button"); // 点击时弹出 HInfoComp,传入卡牌 UUID 和等级以启用预览模式 const config = CardPoolList.find(c => c.uuid === this.s_uuid || c.skill === this.s_uuid); const cardUuid = config ? config.uuid : this.s_uuid; oops.gui.remove(UIID.HInfo); oops.gui.open(UIID.HInfo, { heroUuid: cardUuid, heroLv: this.card_lv, poolLv: config?.pool_lv ?? 1, isSkillCard: true }); } /** * 初始化技能卡效果: * 1. 从 CardPoolList 查询技能卡的触发配置(含 trigger_type)。 * 2. 更新 UI 显示(图标 + 次数)。 * 3. 按 trigger_type 注册对应事件监听并执行首次触发。 * * @param uuid 卡牌 UUID * @param card_lv 技能卡等级 */ init(uuid: number, card_lv: number) { this.card_lv = card_lv; // 查询触发配置 const config = CardPoolList.find(c => c.uuid === uuid); if (config) { this.s_uuid = config.skill ?? uuid; // 获取实际的技能 UUID this.is_instant = config.is_inst ?? true; this.trigger_times = config.t_times ?? 1; this.trigger_interval = config.t_inv ?? 0; this.keep_waves = config.keep_waves ?? 0; this.overrides = config.overrides; this.field = config.field || []; // 读取触发类型与上限(兜底默认值,避免 undefined) this.trigger_type = config.trigger_type ?? CardTriggerType.Instant; this.trigger_limit = config.trigger_limit ?? Infinity; } else { this.s_uuid = uuid; } this.current_trigger_times = 0; this.trigger_count = 0; this.timer = 0; this.initialized = true; this.updateUI(); // 按 trigger_type 注册事件监听 + 执行首次触发 this.registerTrigger(); } /** * 按 trigger_type 注册对应事件监听: * - Instant: init 时立即触发一次(保持旧行为) * - Interval: 监听 FightStart,进入战斗后由 update 帧驱动计时 * - Field: 不主动施法(实际生效由 FieldSkillSet 处理) * - FightStart: 监听 FightStart 事件 * - FightEnd: 监听 FightEnd 事件 * - HeroDead: 监听 HeroDead 事件(已在派发处做阵营过滤) * - HeroCall: 监听 MasterCalled(主角/技能召唤)+ ReviveSuccess(复活) * * 注意:MasterCalled 各派发点 payload 不一致,onEventTrigger 仅作触发信号使用。 */ private registerTrigger(): void { switch (this.trigger_type) { case CardTriggerType.Instant: // 即时技能:立即触发一次 this.onEventTrigger(); break; case CardTriggerType.Interval: // 定时循环:监听 FightStart 进入战斗后启动计时 oops.message.on(GameEvent.FightStart, this.onFightStart, this); break; case CardTriggerType.Field: // 驻场光环:不主动施法,由 FieldSkillSet 处理 break; case CardTriggerType.FightStart: oops.message.on(GameEvent.FightStart, this.onEventTrigger, this); break; case CardTriggerType.FightEnd: oops.message.on(GameEvent.FightEnd, this.onEventTrigger, this); break; case CardTriggerType.HeroDead: oops.message.on(GameEvent.HeroDead, this.onEventTrigger, this); break; case CardTriggerType.HeroCall: // 同时监听召唤和复活两类英雄上场事件 oops.message.on(GameEvent.MasterCalled, this.onEventTrigger, this); oops.message.on(GameEvent.ReviveSuccess, this.onEventTrigger, this); break; default: mLogger.warn(true, 'SkillBoxComp', `[registerTrigger] unknown trigger_type: ${this.trigger_type}, fallback to Instant`); this.onEventTrigger(); break; } } /** * 事件型触发的统一入口: * - Instant 类型:按 trigger_times 上限判定,复用 current_trigger_times 跟踪 * (保持与原 is_instant 行为一致,且 NewWave 中也用 current_trigger_times) * - 事件型(FightStart/FightEnd/HeroDead/HeroCall):按 trigger_limit 上限判定,使用 trigger_count 跟踪 * * 注意:本方法不读取事件 payload,仅作触发信号使用(避免 MasterCalled 不同 payload 字段引发的兼容问题)。 */ private onEventTrigger(): void { if (!this.initialized) return; if (this.trigger_type === CardTriggerType.Instant) { // 即时触发:上限由 trigger_times 控制(保持旧行为) if (this.current_trigger_times >= this.trigger_times) { this.destroySelf(); return; } this.triggerSkill(); this.current_trigger_times++; this.updateUI(); // 单次触发 + 不跨波次维持 → 延迟销毁(保留短暂视觉反馈) if (this.keep_waves === 0 && this.current_trigger_times >= this.trigger_times) { this.scheduleOnce(() => this.destroySelf(), 1.0); } return; } // 事件型:上限由 trigger_limit 控制(全局累计,跨波次不重置) if (this.trigger_count >= this.trigger_limit) { this.destroySelf(); return; } this.triggerSkill(); this.trigger_count++; this.updateUI(); } /** * 统一的节点销毁封装: * 优先通过 ECS 实体销毁;否则直接销毁节点。 */ private destroySelf(): void { if (this.ent) { (this.ent as ecs.Entity).destroy(); } else if (this.node && this.node.isValid) { this.node.destroy(); } } /** * 更新 UI: * - 图标:从 uicons 图集获取 * - 剩余次数标签: * * Interval / 事件型:显示剩余次数(按各自上限计算) * * Instant / Field:不显示 * - CD 遮罩:仅 Interval 类型展示冷却进度 */ updateUI() { // 加载技能图标 if (this.icon_node) { const iconId = SkillSet[this.s_uuid]?.icon || `${this.s_uuid}`; if (smc.uiconsAtlas) { const frame = smc.uiconsAtlas.getSpriteFrame(iconId); if (frame && this.icon_node && this.icon_node.isValid) { let sprite = this.icon_node.getComponent(Sprite) || this.icon_node.addComponent(Sprite); sprite.spriteFrame = frame; } } } // 是否需要展示剩余次数 const showRemainCount = this.trigger_type === CardTriggerType.Interval || this.trigger_type === CardTriggerType.FightStart || this.trigger_type === CardTriggerType.FightEnd || this.trigger_type === CardTriggerType.HeroDead || this.trigger_type === CardTriggerType.HeroCall; if (this.info_label) { if (showRemainCount) { // 事件型按 trigger_limit;Interval 按 t_times const isEvent = this.trigger_type === CardTriggerType.FightStart || this.trigger_type === CardTriggerType.FightEnd || this.trigger_type === CardTriggerType.HeroDead || this.trigger_type === CardTriggerType.HeroCall; const used = isEvent ? this.trigger_count : this.current_trigger_times; const total = isEvent ? this.trigger_limit : this.trigger_times; if (isEvent && !isFinite(total)) { // 无上限:显示已触发次数 this.info_label.string = `${used}`; } else { const remain = Math.max(0, Math.floor(total) - used); this.info_label.string = `${remain}`; } } else { this.info_label.string = ""; } } // 初始化或重置 CD 遮罩表现(仅 Interval 类型有冷却进度) if (this.cd_mask && this.cd_mask.isValid) { let sprite = this.cd_mask.getComponent(Sprite); if (sprite) { if (this.trigger_type === CardTriggerType.Interval && this.trigger_interval > 0) { sprite.fillRange = Math.max(0, 1 - (this.timer / this.trigger_interval)); } else { sprite.fillRange = 0; // 非冷却类型直接归 0 } } } } // ======================== 战斗状态事件 ======================== /** * 战斗开始回调: * - 仅 Interval 类型在 registerTrigger 中订阅此事件 * - 标记进入战斗状态,启动计时器(实际触发由 update 帧驱动) * * 注意:FightStart 触发型(CardTriggerType.FightStart)的事件回调是 onEventTrigger,不是本方法。 */ private onFightStart() { if (!this.initialized) return; this.in_combat = true; this.timer = 0; // 重置计时器 } /** 节点级新一波事件处理 */ private onNewWave() { this.handleNewWave(); } /** 全局级新一波事件处理 */ private onNewWaveGlobal() { this.handleNewWave(); } /** * 新一波:退出战斗状态。 * 处理维持波次逻辑:递减剩余波次,或者重置触发次数。 * * 各类型在新一波的行为: * - Instant/Interval/FightStart/FightEnd:按 keep_waves 决定维持/销毁,并在新一波开始时重置本地计数 * - Field:被动生效,跟随 keep_waves 决定存活 * - HeroDead/HeroCall:跨波次触发的事件型,trigger_count(全局)不重置,仅 keep_waves 控制存活 */ private handleNewWave() { if (!this.initialized) return; this.in_combat = false; // 事件型触发(HeroDead / HeroCall):trigger_count 全局累计,不随波次重置 const isGlobalEventType = this.trigger_type === CardTriggerType.HeroDead || this.trigger_type === CardTriggerType.HeroCall; if (this.keep_waves !== 0) { if (this.keep_waves > 0) { this.keep_waves--; if (this.keep_waves <= 0) { this.destroySelf(); return; } } // 跨波次维持:重置本地计数与计时器(事件型 trigger_count 不重置) if (!isGlobalEventType) { this.current_trigger_times = 0; this.trigger_count = 0; } this.timer = 0; // 即时/事件型触发一次(保持旧行为:Instant 在新一波开始立即触发一次) if (this.trigger_type === CardTriggerType.Instant) { this.triggerSkill(); this.current_trigger_times++; } this.updateUI(); } else { // 不跨波次维持:达到上限即销毁 // - Interval / Instant:按 t_times 判定 // - 事件型:按 trigger_limit 判定 const reachedLimit = isGlobalEventType ? this.trigger_count >= this.trigger_limit : this.current_trigger_times >= this.trigger_times; if (reachedLimit) { this.destroySelf(); } } } /** 任务结束:强制销毁 */ private onMissionEnd() { this.destroySelf(); } // ======================== 帧更新 ======================== /** * 每帧更新: * - 仅 Interval 类型走帧驱动计时逻辑(其它类型提前 return) * - 累加计时器,达到 trigger_interval 时触发一次技能 * - 触发后重置计时器并更新 UI * - 总次数用完后延迟销毁 */ update(dt: number) { // 收窄:仅 Interval 类型走帧驱动 if (this.trigger_type !== CardTriggerType.Interval) return; if (!this.initialized || !this.in_combat) return; if (!smc.mission.play || smc.mission.pause) return; if (this.current_trigger_times < this.trigger_times) { this.timer += dt; // 更新 CD 遮罩 (fillRange 从 1 降到 0) if (this.cd_mask && this.cd_mask.isValid && this.trigger_interval > 0) { let sprite = this.cd_mask.getComponent(Sprite); if (sprite) { sprite.fillRange = Math.max(0, 1 - (this.timer / this.trigger_interval)); } } if (this.timer >= this.trigger_interval) { this.timer = 0; this.triggerSkill(); this.current_trigger_times++; this.updateUI(); // 次数用完且不跨波次维持 → 延迟销毁 if (this.keep_waves === 0 && this.current_trigger_times >= this.trigger_times) { this.scheduleOnce(() => this.destroySelf(), 0.5); } } } } // ======================== 技能触发 ======================== /** * 触发技能效果: * - 计算触发位置(节点局部坐标 + 父节点偏移)。 * - 通过 GameEvent.TriggerSkill 事件将技能数据分发给技能系统。 */ private triggerSkill() { let targetPos = new Vec3(); const localPos = this.node.position; const parentPos = this.node.parent ? this.node.parent.position : new Vec3(0, 0, 0); targetPos.set(parentPos.x + localPos.x, parentPos.y + localPos.y, 0); // 播放技能释放缓动动画:图标放大再缩回 if (this.node && this.node.isValid) { // 停止之前的缓动,防止快速重复触发导致缩放异常 Tween.stopAllByTarget(this.node); this.node.scale = v3(1, 1, 1); tween(this.node) .to(0.1, { scale: v3(1.3, 1.3, 1.3) }) .to(0.15, { scale: v3(1, 1, 1) }) .start(); } oops.message.dispatchEvent(GameEvent.TriggerSkill, { s_uuid: this.s_uuid, isCardSkill: true, // 标记为卡牌技能(区别于英雄自身技能) card_lv: this.card_lv, targetPos: targetPos, overrides: this.overrides }); } /** ECS 组件移除时销毁节点 */ reset() { if (this.node && this.node.isValid) { this.node.destroy(); } } }