Files
pixelheros/assets/script/game/map/SkillBoxComp.ts
panFD dc8391847b refactor(cardSkill): 完成卡牌技能触发机制类型化改造
本次提交为全量的卡牌技能触发系统重构,主要变更包括:
1.  新增CardTriggerType枚举,统一卡牌触发类型定义
2.  补全依赖事件派发:每波战斗结束FightEnd、英雄死亡HeroDead(带阵营过滤)、复活成功ReviveSuccess
3.  重构SkillBoxComp,按触发类型动态注册事件监听,拆分即时/定时/驻场/事件型逻辑
4.  批量迁移所有卡牌配置,为旧技能补充显式触发类型
5.  新增全局触发次数上限机制,区分每波/全局触发计数规则
6.  新增配套设计文档,记录改造背景与方案细节

本次重构彻底解决了原有隐式配置难以维护、无法支持事件型触发的痛点,实现了技能触发逻辑的标准化与可扩展性。
2026-06-19 23:01:24 +08:00

524 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 / CardTriggerTypeCardSet—— 查询技能卡的触发配置
* - 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_limitInterval 按 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 / HeroCalltrigger_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();
}
}
}