import { Color, EventTouch, Label, Node, Sorting2D, UIRenderer, UITransform, Vec3 } from 'cc'; import { SpriteLoad } from 'db://assets/mx/components/SpriteLoad'; import { GBundle, GPath } from '../../game/ConfigRes'; import { DailyShopKind, DailyShopParsed } from '../../manager/DailyShopTypes'; /** 道具名称描边色(按品质) */ const NAME_OUTLINE_HEX: Record = { 1: '#386c42', 2: '#285c6d', 3: '#623073', 4: '#734930', }; /** 抽奖弹窗:shopSkill 品质框与图标 */ export class DailyShopLotteryIcon { /** 抽奖格品质框:skill_lottery{quality} */ static lotteryQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `skill_lottery${q}`; } /** 被动槽品质框:skill_square_small_{quality} */ static passiveQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `skill_square_small_${q}`; } /** 主动槽品质框:skill_circle_small{quality} */ static activeQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `skill_circle_small${q}`; } /** 槽位品质框 */ static slotQualityFrame(kind: DailyShopKind, quality: number): string { return kind === DailyShopKind.Active ? this.activeQualityFrame(quality) : this.passiveQualityFrame(quality); } /** 货架品质框:主动 skill_circle_big{q},被动 skill_square_big_{q} */ static shopQualityFrame(kind: DailyShopKind, quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return kind === DailyShopKind.Active ? `skill_circle_big${q}` : `skill_square_big_${q}`; } /** 货架名称品质底:nameQuality{q} */ static shopNameQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `nameQuality${q}`; } /** 替换弹窗新道具品质框:skill_square_big_{q} */ static replaceNewQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `skill_square_big_${q}`; } /** 替换弹窗列表项品质框:skill_square_small_{q} */ static replaceListQualityFrame(quality: number): string { const q = Math.max(1, Math.floor(Number(quality) || 0)); return `skill_square_small_${q}`; } /** Battlebuff 表描述 */ static battleBuffDesc(parsed: DailyShopParsed): string { const row = parsed.row as ITableBattlebuff; return String(row?.desc ?? ''); } /** 名称 Label 按品质设置描边色 */ static applyNameOutline(label: Label | null | undefined, quality: number): void { if (!label) return; const hex = NAME_OUTLINE_HEX[Math.max(1, Math.floor(Number(quality) || 0))]; if (!hex) return; label.enableOutline = true; label.outlineColor = Color.fromHEX(new Color(), hex); } /** 道具图标名(优先 parsed.icon,其次表 icon,最后 name) */ static itemIconName(parsed: DailyShopParsed): string { if (parsed.icon) return String(parsed.icon); const row = parsed.row as { icon?: string; name?: string } | null | undefined; const tableIcon = row?.icon != null ? String(row.icon).trim() : ''; if (tableIcon) return tableIcon; return String(parsed.name ?? row?.name ?? ''); } /** 对已挂载 SpriteLoad 的节点设图;缺失组件时 warn */ private static _setNodeSprite(node: Node | null | undefined, assetName: string): void { if (!node?.isValid) return; const sl = node.getComponent(SpriteLoad); if (!sl) { console.log('[DailyShop] 节点缺少 SpriteLoad', node.name); return; } sl.setSprite(GPath.ShopSkill(assetName), GBundle.Items); } /** * 绑定品质框 + 图标 * @param root 根节点(含 quality / icon 子节点) * @param frameName 品质框图名 * @param iconName 图标图名 */ static bindFrameAndIcon(root: Node, frameName: string, iconName: string): void { const qualityNode = root.getChildByName('quality') ?? root; this._setNodeSprite(qualityNode, frameName); const icon = qualityNode.getChildByName('icon') ?? root.getChildByName('icon'); this._setNodeSprite(icon, iconName); } /** 绑定携带槽道具;品质框按槽位类型(ui_skillMain 圆框 / ui_skillPassive 方框) */ static bindSlotItem(root: Node, parsed: DailyShopParsed, slotKind: DailyShopKind): void { const q = Math.max(1, Math.floor(Number(parsed.quality) || 0)); this.bindFrameAndIcon( root, this.slotQualityFrame(slotKind, q), this.itemIconName(parsed), ); } /** 绑定抽奖格道具 */ static bindLotteryItem(root: Node, parsed: DailyShopParsed): void { const q = Math.max(1, Math.floor(Number(parsed.quality) || 0)); this.bindFrameAndIcon( root, this.lotteryQualityFrame(q), this.itemIconName(parsed), ); } /** 货架 icon 节点(quality/icon) */ static resolveShopIcon(root: Node): Node | null { const qualityNode = root.getChildByName('quality'); const icon = qualityNode?.getChildByName('icon') ?? root.getChildByName('icon'); return icon?.isValid ? icon : null; } /** 绑定货架商品格(quality / quality.icon / nameQuality / main) */ static bindShopItem(root: Node, parsed: DailyShopParsed): void { const q = Math.max(1, Math.floor(Number(parsed.quality) || 0)); const iconName = this.itemIconName(parsed); const qualityNode = root.getChildByName('quality'); if (qualityNode?.isValid) { this._setNodeSprite(qualityNode, this.shopQualityFrame(parsed.kind, q)); this._setNodeSprite(qualityNode.getChildByName('icon'), iconName); } else { this.bindFrameAndIcon( root, this.shopQualityFrame(parsed.kind, q), iconName, ); } this._setNodeSprite(root.getChildByName('nameQuality'), this.shopNameQualityFrame(q)); const main = root.getChildByName('main'); if (main?.isValid) { main.active = parsed.kind === DailyShopKind.Active; } } /** 绑定替换弹窗新道具 ui_getQuality(品质框在根节点) */ static bindReplaceNewItem(root: Node, parsed: DailyShopParsed): void { const q = Math.max(1, Math.floor(Number(parsed.quality) || 0)); const iconName = this.itemIconName(parsed); this._setNodeSprite(root, this.replaceNewQualityFrame(q)); this._setNodeSprite(root.getChildByName('icon'), iconName); const main = root.getChildByName('main'); if (main?.isValid) { main.active = parsed.kind === DailyShopKind.Active; } } /** 绑定替换弹窗列表项 ui_item */ static bindReplaceListItem(root: Node, parsed: DailyShopParsed): void { const q = Math.max(1, Math.floor(Number(parsed.quality) || 0)); const iconName = this.itemIconName(parsed); const qualityNode = root.getChildByName('quality') ?? root; this._setNodeSprite(qualityNode, this.replaceListQualityFrame(q)); const icon = qualityNode.getChildByName('icon') ?? root.getChildByName('icon'); this._setNodeSprite(icon, iconName); } } /** 描述浮层 Y 偏移配置 */ export type DailyShopOwnedDescOpts = { /** 主动槽:相对技能格中心的世界 Y 偏移 */ activeYOffset?: number; /** 被动槽:相对技能格中心的世界 Y 偏移 */ passiveYOffset?: number; }; /** 携带槽描述浮层(PopDailyShopBuy / PopDailyShopLottery 共用) */ export class DailyShopOwnedDesc { /** 默认主动槽描述框 Y 偏移(我方 ui_desc) */ private static readonly DEFAULT_ACTIVE_Y_OFFSET = 40; /** 默认被动槽描述框 Y 偏移(我方 ui_desc) */ private static readonly DEFAULT_PASSIVE_Y_OFFSET = 30; /** 与字块 canUpEff 同 Sorting2D 层 */ private static readonly DESC_SORT_LAYER = 3; /** 高于 canUpEff(4) / buff 特效(5) / 营地血条(20) */ private static readonly DESC_SORT_ORDER = 30; private readonly _uiDescLeft: Node | null; private readonly _uiDescRight: Node | null; private readonly _activeYOffset: number; private readonly _passiveYOffset: number; private _peerDesc: DailyShopOwnedDesc | null = null; /** * @param uiDescLeft 点击 X<0 时显示的描述根节点 * @param uiDescRight 点击 X>=0 时显示的描述根节点 * @param opts Y 偏移;敌方 ui_otherDesc 使用 -66 / -35 */ constructor(uiDescLeft: Node | null, uiDescRight: Node | null, opts?: DailyShopOwnedDescOpts) { this._uiDescLeft = uiDescLeft; this._uiDescRight = uiDescRight; this._activeYOffset = opts?.activeYOffset ?? DailyShopOwnedDesc.DEFAULT_ACTIVE_Y_OFFSET; this._passiveYOffset = opts?.passiveYOffset ?? DailyShopOwnedDesc.DEFAULT_PASSIVE_Y_OFFSET; } /** 互斥显示:打开本浮层前先关闭对方(我方/敌方 skill 描述) */ setPeerDesc(peer: DailyShopOwnedDesc | null): void { this._peerDesc = peer; } /** 初始化:默认隐藏,并置于父节点最上层 */ init(): void { if (this._uiDescLeft?.isValid) this._uiDescLeft.active = false; if (this._uiDescRight?.isValid) this._uiDescRight.active = false; this._raiseDescLayer(this._uiDescLeft); this._raiseDescLayer(this._uiDescRight); } /** 注册点击描述层关闭 */ bindDismiss(): void { this._bindDismissOnRoot(this._uiDescLeft); this._bindDismissOnRoot(this._uiDescRight); } /** 注销关闭监听并隐藏 */ unbindDismiss(): void { this._unbindDismissOnRoot(this._uiDescLeft); this._unbindDismissOnRoot(this._uiDescRight); this.hide(); } /** 绑定携带槽点击显示描述 */ bindSlotItem(item: Node, parsed: DailyShopParsed, slotKind: DailyShopKind): void { if (!item?.isValid) return; item['dailyShopOwnedParsed'] = parsed; item['dailyShopOwnedSlotKind'] = slotKind; item.targetOff(Node.EventType.TOUCH_END); item.on(Node.EventType.TOUCH_END, this._onClickOwnedSlot, this); } /** * 绑定战斗 skill 格点击描述(仅移除本类监听,不影响拖拽等其它 TOUCH_END)。 * @param item skill 格根节点 * @param parsed buff * @param slotKind 主动/被动 */ bindBattleSkillItem(item: Node, parsed: DailyShopParsed, slotKind: DailyShopKind): void { if (!item?.isValid) return; item['dailyShopOwnedParsed'] = parsed; item['dailyShopOwnedSlotKind'] = slotKind; item.off(Node.EventType.TOUCH_END, this._onClickOwnedSlot, this); item.on(Node.EventType.TOUCH_END, this._onClickOwnedSlot, this); } /** 注销战斗 skill 格描述点击 */ unbindBattleSkillItem(item: Node): void { if (!item?.isValid) return; item.off(Node.EventType.TOUCH_END, this._onClickOwnedSlot, this); } /** 绑定抽奖格点击显示描述(锚点为格子中心) */ bindLotteryItem(item: Node, parsed: DailyShopParsed): void { if (!item?.isValid) return; item['dailyShopLotteryParsed'] = parsed; item.targetOff(Node.EventType.TOUCH_END); item.on(Node.EventType.TOUCH_END, this._onClickLotteryItem, this); } /** 绑定货架商品点击显示描述(锚点为商品格中心;含售罄) */ bindShopItem(item: Node, parsed: DailyShopParsed): void { if (!item?.isValid) return; item['dailyShopGoodsParsed'] = parsed; item.targetOff(Node.EventType.TOUCH_END); item.on(Node.EventType.TOUCH_END, this._onClickShopItem, this); } /** 隐藏描述浮层 */ hide(): void { if (this._uiDescLeft?.isValid) this._uiDescLeft.active = false; if (this._uiDescRight?.isValid) this._uiDescRight.active = false; } /** 点击描述层关闭 */ private _onClickUiDesc(): void { this.hide(); } /** 点击携带槽显示描述 */ private _onClickOwnedSlot(event: EventTouch): void { const target = event.target as Node; if (this._isDeleteBtnTouch(target)) return; const item = event.currentTarget as Node; const parsed = item?.['dailyShopOwnedParsed'] as DailyShopParsed | undefined; const slotKind = Number(item?.['dailyShopOwnedSlotKind']) as DailyShopKind | undefined; if (!parsed || !item?.isValid) return; this.show(item, parsed, slotKind ?? parsed.kind); } /** 点击抽奖格:描述框锚在格子中心 */ private _onClickLotteryItem(event: EventTouch): void { const item = event.currentTarget as Node; const parsed = item?.['dailyShopLotteryParsed'] as DailyShopParsed | undefined; if (!parsed || !item?.isValid) return; this.showAtWorld(this._resolveNodeCenterWorld(item), parsed); } /** 点击货架商品:描述框锚在商品格中心(跳过购买/售罄标记) */ private _onClickShopItem(event: EventTouch): void { const target = event.target as Node; if (this._isShopActionTouch(target)) return; const item = event.currentTarget as Node; const parsed = item?.['dailyShopGoodsParsed'] as DailyShopParsed | undefined; if (!parsed || !item?.isValid) return; this.showAtWorld(this._resolveNodeCenterWorld(item), parsed); } /** 在锚点上方显示描述浮层(按 X 正负选左/右模板,避免超出屏幕) */ show(anchor: Node, parsed: DailyShopParsed, slotKind?: DailyShopKind): void { if (!anchor?.isValid) return; const world = anchor.getWorldPosition(); const kind = slotKind ?? parsed.kind; const yOffset = kind === DailyShopKind.Active ? this._activeYOffset : this._passiveYOffset; this.showAtWorld(new Vec3(world.x, world.y + yOffset, world.z), parsed); } /** * 在指定世界坐标显示描述(按 X 正负选左/右模板)。 * @param world 锚点世界坐标(通常为点击中心) * @param parsed 商品 */ showAtWorld(world: Readonly, parsed: DailyShopParsed): void { this._peerDesc?.hide(); const refUt = this._resolveRefTransform(); if (!refUt) { console.log('[DailyShop] ui_descLeft/ui_descRight 缺少 UITransform'); return; } const anchorPos = refUt.convertToNodeSpaceAR(new Vec3(world.x, world.y, world.z)); const useLeft = anchorPos.x < 0; const descRoot = useLeft ? this._uiDescLeft : this._uiDescRight; if (!descRoot?.isValid) { console.log('[DailyShop] ui_desc 缺失', useLeft ? 'Left' : 'Right'); return; } const descBg = descRoot.getChildByName('descBg'); if (!descBg?.isValid) { console.log('[DailyShop] ui_desc 缺少 descBg', descRoot.name); return; } const descUt = descRoot.getComponent(UITransform); if (!descUt) { console.log('[DailyShop] ui_desc 缺少 UITransform', descRoot.name); return; } this.hide(); const bgPos = descUt.convertToNodeSpaceAR(new Vec3(world.x, world.y, world.z)); descBg.setPosition(bgPos); const nameNode = descBg.getChildByName('name'); this._setLabel(nameNode, parsed.name); DailyShopLotteryIcon.applyNameOutline(nameNode?.getComponent(Label), parsed.quality); this._setDescLabel(descBg, DailyShopLotteryIcon.battleBuffDesc(parsed)); this._raiseDescLayer(descRoot); descRoot.active = true; } /** 描述层 sibling + Sorting2D 置顶(战斗字块 canUpEff 走 Sorting2D,仅 sibling 不够) */ private _raiseDescLayer(descRoot: Node | null): void { if (!descRoot?.isValid) return; const parent = descRoot.parent; if (parent?.isValid) { descRoot.setSiblingIndex(parent.children.length - 1); } this._applyDescSortingTree(descRoot); } /** 为描述层所有 UIRenderer 节点设置更高 Sorting2D */ private _applyDescSortingTree(root: Node): void { if (!root?.isValid) return; if (root.getComponent(UIRenderer)) { let sorting = root.getComponent(Sorting2D); if (!sorting) sorting = root.addComponent(Sorting2D); sorting.sortingLayer = DailyShopOwnedDesc.DESC_SORT_LAYER; sorting.sortingOrder = DailyShopOwnedDesc.DESC_SORT_ORDER; } const kids = root.children; for (let i = 0; i < kids.length; i++) { this._applyDescSortingTree(kids[i]); } } /** 取左/右描述层共用的坐标参照 UITransform */ private _resolveRefTransform(): UITransform | null { if (this._uiDescLeft?.isValid) { const ut = this._uiDescLeft.getComponent(UITransform); if (ut) return ut; } if (this._uiDescRight?.isValid) { return this._uiDescRight.getComponent(UITransform) ?? null; } return null; } /** * 节点中心世界坐标。 * @param node 目标节点 */ private _resolveNodeCenterWorld(node: Node): Vec3 { const ut = node.getComponent(UITransform); if (ut) return ut.convertToWorldSpaceAR(new Vec3(0, 0, 0)); return node.getWorldPosition(); } /** 注册单个描述根节点关闭 */ private _bindDismissOnRoot(root: Node | null): void { if (!root?.isValid) return; root.on(Node.EventType.TOUCH_END, this._onClickUiDesc, this); } /** 注销单个描述根节点关闭 */ private _unbindDismissOnRoot(root: Node | null): void { if (!root?.isValid) return; root.off(Node.EventType.TOUCH_END, this._onClickUiDesc, this); } /** 是否点在 deleteBtn 上 */ private _isDeleteBtnTouch(node: Node | null): boolean { let cur = node; while (cur?.isValid) { if (cur.name === 'deleteBtn') return true; cur = cur.parent; } return false; } /** 是否点在货架购买/售罄控件上 */ private _isShopActionTouch(node: Node | null): boolean { let cur = node; while (cur?.isValid) { const name = cur.name; if (name === 'buyBtn' || name === 'buyBtnAd' || name === 'complete') return true; cur = cur.parent; } return false; } /** 设置 Label 文本 */ private _setLabel(node: Node | null | undefined, text: string): void { if (!node?.isValid) return; const lb = node.getComponent(Label); if (lb) lb.string = text; } /** * 描述文案:固定宽度、按行增高,避免预制体 CLAMP+矮框裁切多行。 * @param descBg 描述底板 * @param text 描述全文 */ private _setDescLabel(descBg: Node, text: string): void { const descNode = descBg.getChildByName('desc'); if (!descNode?.isValid) { console.log('[DailyShop] descBg 缺少 desc'); return; } const lb = descNode.getComponent(Label); const ut = descNode.getComponent(UITransform); if (!lb || !ut) { console.log('[DailyShop] desc 缺少 Label/UITransform'); return; } const width = ut.contentSize.width; if (!(width > 0)) { console.log('[DailyShop] desc 宽度无效', width); return; } lb.overflow = Label.Overflow.RESIZE_HEIGHT; lb.enableWrapText = true; if (!(lb.lineHeight > lb.fontSize)) { lb.lineHeight = lb.fontSize + 6; } ut.setContentSize(width, ut.contentSize.height); lb.string = text; lb.updateRenderData(true); } }