/** * @file CHeroComp.ts * @description 英雄卡池单个卡槽组件(UI 视图层 + 购买/召唤逻辑) * * 职责: * 1. 渲染单个英雄卡的显示信息(名称、等级、描述、图标、费用), * 展示方式与 SCardComp 一致(图集静态图标 + ★等级 + 描述词条)。 * 同时支持动态升级卡(SpecialUpgrade + target_hero_eid)按英雄卡样式渲染。 * 2. 处理购买点击 → UseHeroCard 上限校验(guard) → 扣费 → 派发 CallHero 召唤英雄。 * 特殊卡(升级/刷新)则派发 UseSpecialCard。 * 3. 使用成功后派发 CardUsed 事件,通知 MissionCardComp 刷新英雄卡池。 * * 与 SCardComp 的差异: * - 数据源为 HeroInfo(heroSet),而非 SkillCardList / SkillSet。 * - 购买前需通过 GameEvent.UseHeroCard 做英雄数量上限校验。 * - 英雄卡效果事件为 GameEvent.CallHero,而非 UseSkillCard。 */ import { mLogger } from "../common/Logger"; import { _decorator, Node, Sprite, Label, RichText, NodeEventType, Vec3, Tween, tween, UIOpacity, SpriteFrame, resources, AnimationClip } 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 { CardConfig, CardType } from "../common/config/CardSet"; import { HeroInfo } from "../common/config/heroSet"; import { oops } from "db://oops-framework/core/Oops"; import { GameEvent } from "../common/config/GameEvent"; import { outlineRich } from "../common/RichOutline"; import { smc } from "../common/SingletonModuleComp"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; const { ccclass, property } = _decorator; /** * CHeroComp —— 英雄卡池单个英雄卡项 * * 由 MissionCardComp.cacheCardComps() 实例化。 * 以图标 + 名称 + 描述词条 + 购买按钮的形式呈现。 */ @ccclass('CHeroComp') @ecs.register('CHeroComp', false) export class CHeroComp extends CCComp { private debugMode: boolean = true; // ======================== 编辑器绑定节点 ======================== /** 英雄图标节点 */ @property({ type: Node }) private icon_node: Node = null; /** 背景节点 */ @property({ type: Node }) private bg_node: Node = null; /** 英雄名称 */ @property(Label) private name_label: Label = null; /** 描述词条 1 */ @property(Label) private fied1: Label = null; /** 描述词条 2 */ @property(Label) private fied2: Label = null; /** 描述词条 3 */ @property(Label) private fied3: Label = null; /** 描述富文本(单条,直接展示 heroSet.info 手写文案) */ @property(RichText) private info_rich: RichText = null; /** 等级标签 */ @property(Label) private level_label: Label = null; /** 购买按钮节点 */ @property({ type: Node }) private buy_node: Node = null; /** 消费金币标签(未绑定时回退查找 buy_node 下的 num/Label 子节点) */ @property(Label) private cost_label: Label = null; // ======================== 运行时状态 ======================== /** 当前槽位承载的卡牌配置,null 表示空槽 */ private cardData: CardConfig | null = null; /** 是否已购买 */ private isPurchased: boolean = false; /** 是否已缓存基准 Y/Z 坐标(首次 setSlotPosition 时确定) */ private hasFixedBasePosition: boolean = false; /** 槽位基准 Y 坐标 */ private fixedBaseY: number = 0; /** 槽位基准 Z 坐标 */ private fixedBaseZ: number = 0; /** 静止位置(由 MissionCardComp 布局决定) */ private restPosition: Vec3 = new Vec3(); /** 透明度组件(用于淡入淡出) */ private opacityComp: UIOpacity | null = null; /** 图标视觉令牌(异步加载竞态保护) */ private iconVisualToken: number = 0; /** 当前显示的英雄 UUID(避免相同 UUID 重复加载动画) */ private iconHeroUuid: number = 0; // ======================== 生命周期 ======================== onLoad() { mLogger.log(this.debugMode, "CHeroComp", "onLoad bindings", { nodeName: this.node?.name, nodeUuid: this.node?.uuid, hasCardData: !!this.cardData, cardUuid: this.cardData?.uuid ?? null, icon_node: !!this.icon_node, bg_node: !!this.bg_node, name_label: !!this.name_label, level_label: !!this.level_label, fied1: !!this.fied1, fied2: !!this.fied2, fied3: !!this.fied3, buy_node: !!this.buy_node }); this.buy_node?.on(NodeEventType.TOUCH_START, this.onBuyTouchStart, this); this.buy_node?.on(NodeEventType.TOUCH_END, this.onBuyClick, this); this.buy_node?.on(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this); // 缓存透明度组件(用于淡入淡出动画) this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity); // 补偿首次渲染:addComponent 当帧 onLoad 尚未触发, // 若 MissionCardComp 在 onLoad 前已 applyCardData,此处绑定就绪后重刷一次 if (this.cardData) { this.updateUI(); } } onDestroy() { super.onDestroy(); if (this.buy_node && this.buy_node.isValid) { this.buy_node.off(NodeEventType.TOUCH_START, this.onBuyTouchStart, this); this.buy_node.off(NodeEventType.TOUCH_END, this.onBuyClick, this); this.buy_node.off(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this); } } // ======================== 数据初始化 ======================== /** * 初始化英雄卡数据并渲染 UI * * 若槽位已有旧卡且处于显示状态,先播放旧卡"逝去"动画(缩小淡出), * 动画结束后再切换为新卡数据并播放入场动画。 * 首次赋值(node 未激活)直接渲染,无逝去动画。 * * @param data 卡牌配置,传 null 时清空显示 */ applyCardData(data: CardConfig | null): void { mLogger.log(this.debugMode, "CHeroComp", "applyCardData", { nodeName: this.node?.name, nodeUuid: this.node?.uuid, uuid: data?.uuid ?? null, type: data?.type ?? null, name: data?.name ?? null, card_lv: data?.card_lv ?? null, hero_lv: data?.hero_lv ?? null, cost: data?.cost ?? null, icon: data?.icon ?? null, nodeActiveBefore: this.node?.active }); // 刚购买(已播放大逝去动画)→ 不播普通逝去动画,直接应用新卡 if (this.isPurchased) { this.cardData = data; this.isPurchased = false; if (data) { this.node.active = true; this.updateUI(); } else { this.applyEmptyUI(); } return; } // 旧卡存在且当前显示中 → 播逝去动画,结束后再应用新卡 if (this.cardData && this.node.active) { this.playRefreshExitAnim(() => { this.cardData = data; this.isPurchased = false; if (data) { this.node.active = true; this.updateUI(); } else { this.applyEmptyUI(); } }); return; } // 首次赋值或槽位为空 → 直接渲染 this.cardData = data; this.isPurchased = false; if (data) { this.node.active = true; this.updateUI(); } else { this.applyEmptyUI(); } } /** 清空 UI 显示 */ private applyEmptyUI() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); this.opacityComp.opacity = 255; } this.node.setScale(1, 1, 1); if (this.name_label) this.name_label.string = ""; if (this.level_label) this.level_label.string = ""; if (this.info_rich) { this.info_rich.string = ""; this.info_rich.node.active = false; } if (this.icon_node) { const sprite = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; } if (this.buy_node) { this.buy_node.active = false; } this.node.active = false; } // ======================== UI 渲染 ======================== updateUI() { if (!this.cardData) { mLogger.log(this.debugMode, "CHeroComp", "updateUI skip: no cardData", { nodeName: this.node?.name }); return; } // 动态升级卡(SpecialUpgrade + target_hero_eid)按英雄卡样式渲染: // 通过 eid 反查场上实体得到 hero_uuid;实体不存在时降级为本卡 uuid const isUpgradeCard = this.cardData.type === CardType.SpecialUpgrade && !!this.cardData.target_hero_eid; const renderUuid = isUpgradeCard ? (this.queryHeroUuidByEid(this.cardData.target_hero_eid as number) ?? this.cardData.uuid) : this.cardData.uuid; const hero = HeroInfo[renderUuid]; mLogger.log(this.debugMode, "CHeroComp", "updateUI resolve", { nodeName: this.node?.name, uuid: this.cardData.uuid, type: this.cardData.type, isUpgradeCard, renderUuid, heroFound: !!hero, heroName: hero?.name ?? null, heroPath: hero?.path ?? null, heroInfo: hero?.info ?? null }); // 名称 if (this.name_label) { const finalName = hero?.name || this.cardData.name || ""; this.name_label.string = finalName; mLogger.log(this.debugMode, "CHeroComp", "name_label assigned", { nodeName: this.node?.name, finalName, labelStringAfter: this.name_label.string, labelNodeActive: this.name_label.node?.active, labelNodeParentActive: this.name_label.node?.parent?.active, labelNodeParentName: this.name_label.node?.parent?.name }); } // 等级标签(★ 表示卡牌等级;升级卡显示英雄目标等级) if (this.level_label) { if (isUpgradeCard) { const heroLv = Math.max(1, this.cardData.hero_lv ?? 1); this.level_label.string = heroLv > 1 ? `Lv.${heroLv - 1} -> Lv.${heroLv}` : `Lv.${heroLv}`; } else { const lv = this.cardData.card_lv ?? 1; this.level_label.string = lv >= 2 ? "★".repeat(lv - 1) : ""; } } // 背景:按 card_lv 激活对应颜色子节点(1→green 2→blue 3→purple 4→red 5→yellow) this.updateBgNode(); // 描述富文本:直接取 heroSet 的 info 字段(单条手写文案) // outlineRich 为整段富文本包裹描边,提升深色面板可读性 if (this.info_rich) { const desc = hero?.info || this.cardData.info || ""; this.info_rich.string = outlineRich(desc); this.info_rich.node.active = !!desc; } // 图标 if (this.icon_node) { this.iconVisualToken += 1; this.updateHeroAnimation(this.icon_node, renderUuid, this.iconVisualToken); } // 购买按钮费用显示 if (this.buy_node) { this.buy_node.active = true; const costLabel = this.cost_label || this.buy_node.getChildByName("num")?.getComponent(Label) || this.buy_node.getComponentInChildren(Label); if (costLabel) { costLabel.string = `${this.cardData.cost ?? 0}`; } } // 入场动画:从静止位置下方升起 + 淡入 this.playEnterAnim(); } /** * 根据 card_lv 切换 bg_node 下的颜色子节点显示。 * 等级与颜色对应关系: * card_lv 1 → green * card_lv 2 → blue * card_lv 3 → purple * card_lv 4 → red * card_lv 5 → yellow */ private updateBgNode() { if (!this.bg_node || !this.cardData) return; const lvToColor: Record = { 1: "green", 2: "blue", 3: "purple", 4: "red", 5: "yellow", }; const targetColor = lvToColor[this.cardData.card_lv ?? 1]; this.bg_node.children.forEach(child => { child.active = (child.name === targetColor); }); } // ======================== 动画效果 ======================== /** 入场动画时长(秒) */ private readonly enterDuration: number = 0.25; /** 购买动画时长(秒) */ private readonly purchaseDuration: number = 0.2; /** * 播放入场动画:由小变大(scale 0→1)+ 淡入。 * 不依赖位置,与 Widget 布局完全解耦。 */ private playEnterAnim() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); this.opacityComp.opacity = 0; } this.node.setScale(0, 0, 1); tween(this.node) .to(this.enterDuration, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }) .start(); if (this.opacityComp) { tween(this.opacityComp) .to(this.enterDuration, { opacity: 255 }) .start(); } } /** * 播放购买动画:卡牌放大(scale 1→1.1)+ 淡出,结束后隐藏节点。 * 动画结束由 onBuyClick 的后续逻辑(CardUsed 刷新卡池)接管。 */ private playPurchaseAnim() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); } tween(this.node) .to(this.purchaseDuration, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadIn' }) .start(); if (this.opacityComp) { tween(this.opacityComp) .to(this.purchaseDuration, { opacity: 0 }) .start(); } } /** * 播放普通刷新"逝去"动画:旧卡缩小(scale 1→0)+ 淡出,结束后回调切换新卡。 * 与购买动画区分:购买是放大逝去,普通刷新是缩小逝去。 * @param onComplete 动画结束回调,用于应用新卡数据 */ private playRefreshExitAnim(onComplete: () => void) { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); } tween(this.node) .to(this.purchaseDuration, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' }) .start(); if (this.opacityComp) { tween(this.opacityComp) .to(this.purchaseDuration, { opacity: 0 }) .call(onComplete) .start(); } else { // 无透明度组件时直接延迟回调 this.scheduleOnce(onComplete, this.purchaseDuration); } } // ======================== 按钮动画 ======================== /** 按钮正常缩放 */ private readonly btnNormalScale: number = 1; /** 按钮按下缩放 */ private readonly btnPressScale: number = 0.92; /** 按钮点击回弹峰值缩放 */ private readonly btnClickScale: number = 1.08; /** 购买按钮按下:缩小反馈 */ private onBuyTouchStart() { this.playBtnScaleTo(this.btnPressScale, 0.06); } /** 购买按钮取消:恢复缩放 */ private onBuyTouchCancel() { this.playBtnScaleTo(this.btnNormalScale, 0.08); } /** 按钮缩放动画(通用) */ private playBtnScaleTo(scale: number, duration: number) { if (!this.buy_node || !this.buy_node.isValid) return; Tween.stopAllByTarget(this.buy_node); tween(this.buy_node) .to(duration, { scale: new Vec3(scale, scale, 1) }) .start(); } /** 按钮点击回弹动画:先弹到峰值再回到正常 */ private playBtnClickAnim(onComplete?: () => void) { if (!this.buy_node || !this.buy_node.isValid) { onComplete?.(); return; } Tween.stopAllByTarget(this.buy_node); tween(this.buy_node) .to(0.05, { scale: new Vec3(this.btnClickScale, this.btnClickScale, 1) }) .to(0.08, { scale: new Vec3(this.btnNormalScale, this.btnNormalScale, 1) }) .call(() => onComplete?.()) .start(); } /** * 通过 eid 反查场上英雄实体的 hero_uuid。 * 用于动态升级卡渲染:升级卡绑定的是 eid,但显示需要 HeroInfo[uuid]。 * @returns 找到则返回 hero_uuid,找不到(实体已销毁等)返回 null */ private queryHeroUuidByEid(eid: number): number | null { if (!eid) return null; const entity = ecs.getEntityByEid(eid); if (!entity) return null; const model = entity.get(HeroAttrsComp); if (!model) return null; return model.hero_uuid ?? null; } /** * 为英雄图标加载 idle 动画剪辑,提取首帧 SpriteFrame 静止展示。 * 与 HInfoComp 的 updateHeroAnimation 一致,零运行时开销。 * @param node 图标节点 * @param uuid 英雄 UUID * @param token 视觉令牌 */ private updateHeroAnimation(node: Node, uuid: number, token: number) { if (!node || !node.isValid) return; // 相同英雄 UUID 不重复加载,避免刷新卡池时闪烁 if (this.iconHeroUuid === uuid) return; this.iconHeroUuid = uuid; const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; const hero = HeroInfo[uuid]; if (!hero) { mLogger.log(this.debugMode, "CHeroComp", "updateHeroAnimation hero not found", { nodeName: this.node?.name, uuid }); return; } const path = `game/heros/hero/${hero.path}/idle`; mLogger.log(this.debugMode, "CHeroComp", "updateHeroAnimation load", { nodeName: this.node?.name, uuid, path, token }); resources.load(path, AnimationClip, (err, clip) => { if (err || !clip) { mLogger.log(this.debugMode, "CHeroComp", "updateHeroAnimation load failed", { nodeName: this.node?.name, uuid, path, message: err?.message ?? null }); return; } // 竞态保护:仅允许最新一次图标请求落地 if (token !== this.iconVisualToken) return; if (!node || !node.isValid) return; const target = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (!target || !target.isValid) return; // 帧动画数据存于 spriteFrame 轨道的 _channel._curve._values(裸 SpriteFrame 数组) let firstFrame: SpriteFrame | undefined; for (const track of clip.tracks) { const curve = (track as unknown as { channel?: { curve?: { _values?: unknown[] } } }).channel?.curve; const frame = curve?._values?.[0]; if (frame instanceof SpriteFrame) { firstFrame = frame; break; } } if (firstFrame) { target.spriteFrame = firstFrame; } }); } // ======================== 购买逻辑 ======================== /** * 购买点击处理: * 1. 播放按钮点击回弹动画。 * 2. 英雄卡先通过 UseHeroCard 事件做数量上限校验(guard 模式,可被取消)。 * 3. 按卡牌类型分发效果:英雄卡 → CallHero 召唤英雄;特殊卡 → UseSpecialCard。 * 4. 派发 CardUsed 通知 MissionCardComp 刷新英雄卡池。 * 5. 标记已购买,隐藏购买按钮。 * * 注:购买不花费金币(免费获取),原扣费逻辑已移除。 */ private onBuyClick() { if (!this.cardData || this.isPurchased) return; // 播放按钮点击回弹动画(视觉反馈,不阻塞后续逻辑) this.playBtnClickAnim(); oops.audio.playEffect("music/button"); // 英雄卡数量上限校验:guard 模式,MissionCardComp 可设 cancel=true 阻止使用 if (this.cardData.type === CardType.Hero) { const guard = { cancel: false, reason: "", uuid: this.cardData.uuid, hero_lv: this.cardData.hero_lv ?? 1, card_lv: this.cardData.base_card_lv ?? this.cardData.card_lv ?? 1 }; oops.message.dispatchEvent(GameEvent.UseHeroCard, guard); if (guard.cancel) return; } smc.vmdata.scores.refresh_hit_count++; const used = this.cardData; // 按卡牌类型分发效果事件 switch (used.type) { case CardType.Hero: oops.message.dispatchEvent(GameEvent.CallHero, used); break; case CardType.SpecialUpgrade: case CardType.SpecialRefresh: oops.message.dispatchEvent(GameEvent.UseSpecialCard, used); break; } // 播放购买动画(缩小淡出),刷新卡池由 CardUsed 事件触发 this.playPurchaseAnim(); // 标记已购买 this.isPurchased = true; if (this.buy_node) { this.buy_node.active = false; } // 通知 MissionCardComp:本槽位卡牌已使用,刷新英雄卡池 oops.message.dispatchEvent(GameEvent.CardUsed, this); mLogger.log(this.debugMode, "CHeroComp", "hero card purchased", { uuid: used.uuid, type: used.type }); } /** 标记为已购买状态 */ setPurchased(): void { this.isPurchased = true; if (this.buy_node) { this.buy_node.active = false; } } /** * 接收 MissionCardComp 分发的卡牌并刷新显示。 * @param data 卡牌配置,null 时清空显示 * @returns 是否成功接收(true = 接收并刷新 UI) */ applyDrawCard(data: CardConfig | null): boolean { this.applyCardData(data); return !!data; } /** * 设置槽位的水平位置(由 MissionCardComp 布局计算后调用)。 * 首次调用时缓存基准 Y/Z,后续仅更新 X。 * @param x 目标水平坐标 */ setSlotPosition(x: number) { const current = this.node.position; if (!this.hasFixedBasePosition) { this.fixedBaseY = current.y; this.fixedBaseZ = current.z; this.hasFixedBasePosition = true; } this.restPosition = new Vec3(x, this.fixedBaseY, this.fixedBaseZ); this.node.setPosition(this.restPosition); } /** * 系统清槽:任务开始/结束时强制重置。 * 清空数据与 UI 并隐藏节点。 * * 仅在 setSlotPosition 初始化过静止位置后才恢复位置, * 否则 restPosition 为默认 (0,0,0),会把所有槽位重置到中心。 */ clearBySystem() { this.cardData = null; this.isPurchased = false; if (this.hasFixedBasePosition) { this.node.setPosition(this.restPosition); } this.applyEmptyUI(); this.node.active = false; } /** ECS 组件移除时销毁节点 */ reset() { if (this.node && this.node.isValid) { this.node.destroy(); } } }