/** * @file ItemListComp.ts * @description 商店列表项组件(UI 视图层 + 购买逻辑) * * 职责: * 1. 渲染单个商品/装备卡的显示信息(名称、等级、属性词条、图标)。 * 2. 处理购买点击 → 扣费 → 根据卡牌类型派发对应事件触发购买效果。 * * 支持类型: * - 装备卡(trigger_type=Field)→ 派发 UseEquipCard,由 MissEquipComp 处理。 * - 商品/药品卡(kind=Potion)→ 派发 UseItemCard,由 MissSkillsComp 处理(一次性 buff)。 * * 设计说明: * 本组件同时服务于装备商店和商品商店,通过 cardData 的 trigger_type / kind 自动判断类型。 */ import { mLogger } from "../common/Logger"; import { _decorator, Node, Label, RichText, NodeEventType, Vec3, UIOpacity, tween, Tween } 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, CardTriggerType, CKind } from "../common/config/CardSet"; import { EquipPoolList } from "../common/config/EquipSet"; import { buildCardDescRich, buildEquipRich } from "../common/config/HeroSkillDesc"; import { oops } from "db://oops-framework/core/Oops"; import { getCardIconId, setSpriteFrame } from "../common/CardIconHelper"; import { GameEvent } from "../common/config/GameEvent"; import { MissionEconomy } from "./MissionEconomy"; const { ccclass, property } = _decorator; /** * ItemListComp —— 商店列表项(装备/商品通用) * * 由 MissionCardComp.populateEquipments() / populateShopItems() 实例化。 * 以图标 + 名称 + 描述/属性词条 + 购买按钮的形式呈现。 */ @ccclass('ItemListComp') @ecs.register('ItemListComp', false) export class ItemListComp extends CCComp { private debugMode: boolean = false; // ======================== 编辑器绑定节点 ======================== /** 商品图标节点 */ @property({ type: Node }) private icon_node: Node = null; /** 背景节点(含 green/blue/purple/red/yellow 颜色子节点,按 card_lv 激活) */ @property({ type: Node }) private bg_node: Node = null; /** 商品名称 */ @property(Label) private name_label: Label = null; /** 描述/属性词条富文本(多行,数值绿色高亮、技能名蓝色) */ @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; // ======================== 运行时状态 ======================== /** 当前商品的卡牌配置 */ private cardData: CardConfig | null = null; /** 购买/逝去动画播放中标记(仅用于跳过新卡的普通逝去动画,不影响再次购买) */ private isPurchased: boolean = false; /** 透明度组件(用于淡入淡出动画) */ private opacityComp: UIOpacity | null = null; /** 入场动画时长(秒) */ private readonly enterDuration: number = 0.25; /** 购买/逝去动画时长(秒) */ private readonly exitDuration: number = 0.2; // ======================== 生命周期 ======================== onLoad() { 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); } 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 * * 若槽位已有旧卡且显示中,先播普通逝去动画(缩小淡出),结束后切新卡; * 刚购买的槽位跳过逝去动画直接应用新卡(购买动画已播放大淡出)。 * * @param data 商品/装备卡配置 */ applyCardData(data: CardConfig): void { if (!data) return; // 刚购买(已播放大逝去动画)→ 不播普通逝去动画,直接应用新卡 if (this.isPurchased) { this.cardData = data; this.isPurchased = false; this.updateUI(); return; } // 旧卡存在且显示中 → 播普通逝去动画,结束后切新卡 if (this.cardData && this.node.active) { this.playRefreshExitAnim(() => { this.cardData = data; this.isPurchased = false; this.updateUI(); }); return; } // 首次赋值 → 直接渲染 this.cardData = data; this.isPurchased = false; this.updateUI(); } // ======================== UI 渲染 ======================== updateUI() { if (!this.cardData) return; const isEquip = this.cardData.trigger_type === CardTriggerType.Field; const isPotion = this.cardData.kind === CKind.Potion; // 名称:装备从 EquipPoolList 查找,商品直接用 cardData.name if (this.name_label) { if (isEquip) { const equipConfig = EquipPoolList.find(c => c.uuid === this.cardData!.uuid); this.name_label.string = equipConfig?.name || this.cardData.name || ""; } else { this.name_label.string = this.cardData.name || ""; } } // 等级标签(★ 表示卡牌等级) if (this.level_label) { 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(); // 描述/属性词条富文本(装备逐词条多行;药品/技能卡数值随 overrides 档位自动同步) if (this.info_rich) { let desc = ""; if (isEquip) { desc = buildEquipRich(this.cardData.field || []); } else if (isPotion) { desc = buildCardDescRich(this.cardData); } else { desc = this.cardData.info || ""; } this.info_rich.string = desc; this.info_rich.node.active = !!desc; } // 图标(共享工具解析:cardData.icon → FieldSkillSet) if (this.icon_node) { const iconId = getCardIconId(this.cardData); if (iconId) { setSpriteFrame(this.icon_node, iconId); } } // 购买按钮费用显示 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); }); } // ======================== 动画 ======================== /** 播放入场动画:卡牌由小变大(scale 0→1)+ 淡入 */ private playEnterAnim() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); } this.node.setScale(0, 0, 1); if (this.opacityComp) { this.opacityComp.opacity = 0; } 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)+ 淡出 */ private playPurchaseAnim() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); } tween(this.node) .to(this.exitDuration, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadIn' }) .start(); if (this.opacityComp) { tween(this.opacityComp) .to(this.exitDuration, { 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.exitDuration, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' }) .start(); if (this.opacityComp) { tween(this.opacityComp) .to(this.exitDuration, { opacity: 0 }) .call(onComplete) .start(); } else { this.scheduleOnce(onComplete, this.exitDuration); } } // ======================== 按钮动画 ======================== /** 按钮正常缩放 */ 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() { if (!this.buy_node || !this.buy_node.isValid) 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) }) .start(); } // ======================== 购买逻辑 ======================== /** * 购买点击处理: * 1. 扣除金币(失败则提示并中止)。 * 2. 根据卡牌类型派发对应事件: * - 装备卡(trigger_type=Field)→ UseEquipCard → MissEquipComp * - 商品/药品卡(kind=Potion)→ UseItemCard → MissSkillsComp(一次性 buff 技能) * 3. 播放购买动画,购买后可继续购买(购买按钮保持可见)。 */ private onBuyClick() { if (!this.cardData) return; // 播放按钮点击回弹动画 this.playBtnClickAnim(); oops.audio.playEffect("music/button"); // 扣费 const cost = this.cardData.cost ?? 0; const success = MissionEconomy.spendCoin(cost); if (!success) { oops.message.dispatchEvent(GameEvent.ShowSmallTip, "buy_coin"); mLogger.log(this.debugMode, "ItemListComp", "purchase failed: not enough coin", { uuid: this.cardData.uuid, cost }); return; } // 根据类型派发对应事件 const isEquip = this.cardData.trigger_type === CardTriggerType.Field; const isPotion = this.cardData.kind === CKind.Potion; if (isEquip) { // 装备:被动驻场,由 MissEquipComp 处理 oops.message.dispatchEvent(GameEvent.UseEquipCard, this.cardData); mLogger.log(this.debugMode, "ItemListComp", "equipment purchased", { uuid: this.cardData.uuid, cost }); } else if (isPotion) { // 商品/药品:一次性 buff 技能,由 MissSkillsComp 处理 oops.message.dispatchEvent(GameEvent.UseItemCard, this.cardData); mLogger.log(this.debugMode, "ItemListComp", "item purchased", { uuid: this.cardData.uuid, skill: this.cardData.skill, cost }); } else { // 默认走技能卡流程 oops.message.dispatchEvent(GameEvent.UseSkillCard, this.cardData); mLogger.log(this.debugMode, "ItemListComp", "card purchased", { uuid: this.cardData.uuid, cost }); } // 播放购买动画(卡牌放大 + 淡出) this.isPurchased = true; this.playPurchaseAnim(); // 派发 CardUsed 事件,触发整池刷新 oops.message.dispatchEvent(GameEvent.CardUsed, this); } /** 标记购买动画播放中(供外部在刷新前保持动画状态,不再用于限购) */ setPurchased(): void { this.isPurchased = true; } /** ECS 组件移除时销毁节点 */ reset() { if (this.node && this.node.isValid) { this.node.destroy(); } } }