import { SevenDayActivityInfo, SevenDayReward, SevenDayRewardItem } from "./SevenDayGiftApi"; import SevenDayGiftRuntime from "./SevenDayGiftRuntime"; import ActivityPopupAnimator, { ActivityPopupAnimationType } from "../Script/ActivityPopupAnimator"; import RewardClaimEffect from "../reward_claim_effect/RewardClaimEffect"; import SevenDayHitTest from "./SevenDayHitTest"; const { ccclass, property } = cc._decorator; const TOTAL_DAYS = 7; type ClaimHandler = (callback: (success: boolean) => void) => void; type CloseHandler = () => void; /** 七日活动视图:卡片展示、点击判定和领取效果。 */ @ccclass export default class SevenDayGift extends cc.Component { @property(cc.SpriteFrame) cardBlue: cc.SpriteFrame = null; @property(cc.SpriteFrame) cardYellow: cc.SpriteFrame = null; @property(cc.SpriteFrame) cardSpecial: cc.SpriteFrame = null; @property(cc.SpriteFrame) claimedOverlay: cc.SpriteFrame = null; @property(cc.SpriteFrame) claimedBadge: cc.SpriteFrame = null; @property(cc.SpriteFrame) catArt: cc.SpriteFrame = null; @property(cc.SpriteFrame) catLocked: cc.SpriteFrame = null; @property(cc.SpriteFrame) coinIcon: cc.SpriteFrame = null; @property(cc.SpriteFrame) hammerIcon: cc.SpriteFrame = null; @property(cc.SpriteFrame) freezeIcon: cc.SpriteFrame = null; @property(cc.SpriteFrame) magicWandIcon: cc.SpriteFrame = null; @property(cc.SpriteFrame) infiniteHealthIcon: cc.SpriteFrame = null; @property({ type: [cc.SpriteFrame], tooltip: "数量图片:依次为 0~9、加号、乘号、分钟" }) rewardNumberFrames: cc.SpriteFrame[] = []; @property({ type: cc.Enum(ActivityPopupAnimationType), displayName: "弹窗动画", tooltip: "活动页面打开和关闭时使用的动画" }) popupAnimation: ActivityPopupAnimationType = ActivityPopupAnimationType.Q弹弹出; private info: SevenDayActivityInfo = null; private cards: cc.Node[] = []; private claimHandler: ClaimHandler = null; private claiming = false; // 等待领取回调,阻止重复提交。 private effectPlaying = false; // 也覆盖测试面板单独预览效果的情况。 private panel: cc.Node = null; private mask: cc.Node = null; private closeEnabled = true; private closeHandler: CloseHandler = null; private runtime: SevenDayGiftRuntime = null; // 缓存节点、绑定点击,再创建正式领取流程。 onLoad() { if (!this.cacheView()) { this.node.active = false; return; } this.bindEvents(); this.runtime = new SevenDayGiftRuntime(this); } // 结束业务流程,忽略后续异步返回。 onDestroy() { if (this.runtime) this.runtime.destroy(); this.runtime = null; } // 主页加载分包后,从这里传入正式活动数据。 public initializeRuntime(info: SevenDayActivityInfo, closeHandler: CloseHandler): boolean { // Bundle 节点先以 inactive 挂入场景;激活后 onLoad 才会创建 Runtime。 this.node.active = true; if (!this.runtime) { cc.error("[SevenDayGift] Runtime 未初始化,请检查节点激活和 Prefab 结构"); return false; } this.runtime.initialize(info, closeHandler); return true; } // 把新的活动查询结果交给业务层。 public updateRuntimeInfo(info: SevenDayActivityInfo) { if (this.runtime) this.runtime.updateInfo(info); } // 共用视图入口,正式流程和本地模拟都可提供领取回调。 public setData(info: SevenDayActivityInfo, claimHandler: ClaimHandler) { this.info = info; this.claimHandler = claimHandler; this.refreshView(); } // 登记弹窗关闭后的处理,由主页负责释放资源。 public setCloseHandler(closeHandler: CloseHandler) { this.closeHandler = closeHandler; } // 打开时只展示奖励,等待玩家点击领取。 public show(_autoClaim: boolean = false): boolean { if (!this.info || !this.panel) return false; this.node.active = true; if (!this.node.activeInHierarchy) return false; this.setCloseEnabled(true); this.node.active = true; this.refreshView(); this.node.opacity = 255; this.node.scale = 1; ActivityPopupAnimator.open(this.panel, this.mask, this.popupAnimation); return true; } // 有奖励时本次只领取;无奖励时才关闭,忙碌期间忽略点击。 public close(event?: cc.Event) { if (event) event.stopPropagation(); if (!this.closeEnabled) return; if (this.claiming || this.effectPlaying) return; if (this.info && this.info.canClaim && !this.info.todayClaimed) { this.requestCurrentReward(); return; } this.setCloseEnabled(false); ActivityPopupAnimator.close(this.panel, this.mask, this.popupAnimation, () => { this.node.active = false; if (this.closeHandler) this.closeHandler(); }); } // 按 Prefab 约定找到卡片。 private cacheView(): boolean { this.panel = this.node.getChildByName("panel"); this.mask = this.node.getChildByName("mask"); const cards = this.panel && this.panel.getChildByName("cards"); if (!this.panel || !this.mask || !cards) { cc.error("[SevenDayGift] prefab structure is incomplete"); return false; } for (let day = 1; day <= TOTAL_DAYS; day++) { const card = cards.getChildByName("giftDay" + day); if (!card) { cc.error("[SevenDayGift] missing card for day " + day); return false; } this.cards.push(card); } return true; } // 区分可见 UI 与外部点击,奖励卡片单独处理领取。 private bindEvents() { // 所有点击共用一次可见性判断,透明处不会被矩形事件范围挡住。 const blockVisibleUI = (node: cc.Node) => { if (node.getComponent(cc.Sprite) || node.getComponent(cc.Label)) { node.on(cc.Node.EventType.TOUCH_END, this.onPanelTouch, this); } node.children.forEach(blockVisibleUI); }; blockVisibleUI(this.panel); this.mask.on(cc.Node.EventType.TOUCH_END, this.onPanelTouch, this); } // 透明处仅执行本次外部点击;命中奖励子图时沿父节点找到所属卡片。 private onPanelTouch(event: cc.Event.EventTouch) { event.stopPropagation(); let hit = SevenDayHitTest.findVisible(this.panel, event.getLocation()); if (!hit) { this.close(); return; } while (hit && hit !== this.panel) { const index = this.cards.indexOf(hit); if (index >= 0) { this.onCardClicked(index + 1); return; } hit = hit.parent; } } // 按最新活动快照刷新七张卡片。 private refreshView() { if (!this.info || this.cards.length !== TOTAL_DAYS) return; for (let day = 1; day <= TOTAL_DAYS; day++) { this.refreshCard(this.cards[day - 1], day, this.findReward(day)); } } // 设置当天高亮、已领印章和奖励图标。 private refreshCard(card: cc.Node, day: number, reward: SevenDayReward) { const claimed = !!(reward && reward.claimed); const available = !!this.info.canClaim && day === Number(this.info.nextRewardDay); const special = day === TOTAL_DAYS; const cardFrame = special ? this.cardSpecial : available ? this.cardYellow : this.cardBlue; this.setSprite(card, cardFrame); // 白色不改变贴图颜色,保留美术资源的原色。 card.color = cc.Color.WHITE; card.opacity = 255; const dayLabel = card.getChildByName("dayLabel").getComponent(cc.Label); const dayOutline = dayLabel.node.getComponent(cc.LabelOutline); if (dayOutline) { dayOutline.color = special ? cc.color(238, 91, 126) : available ? cc.color(242, 151, 0) : cc.color(0, 170, 182); } const items = reward && Array.isArray(reward.items) ? reward.items : []; this.refreshRewardItems(card, items, available || claimed); if (special) { const glow = card.getChildByName("catGlow"); glow.active = !claimed && items.some(item => item.type === "cat_skin"); const animation = glow.getComponent(cc.Animation); if (glow.active) { const state = animation.getAnimationState("CatRewardGlow"); if (!state || !state.isPlaying) animation.play("CatRewardGlow"); } else { animation.stop(); } } // 保留原卡片,遮罩压暗日期和奖励,印章绘制在遮罩上方。 const overlay = card.getChildByName("claimedOverlay"); overlay.active = claimed; const badge = card.getChildByName("claimedBadge"); badge.active = claimed; card.stopAllActions(); card.scale = 1; if (available) { cc.tween(card).repeatForever( cc.tween().to(0.65, { scale: 1.04 }).to(0.65, { scale: 1 }) ).start(); } } // 只允许领取当前可领天数,其他卡片点击无效。 private onCardClicked(day: number) { const reward = this.findReward(day); if (reward && reward.claimed) { return; } if (!this.info.canClaim || day !== Number(this.info.nextRewardDay)) { return; } if (this.claiming || !this.claimHandler) return; this.requestCurrentReward(); } // 统一卡片和外部点击的领取入口,直到回调结束才解锁。 private requestCurrentReward() { if (this.claiming || this.effectPlaying || !this.claimHandler || !this.info || !this.info.canClaim) return; this.claiming = true; this.claimHandler((success: boolean) => { this.claiming = false; this.setCloseEnabled(true); if (!success) cc.warn("[SevenDayGift] 领取未完成,可点击当天奖励重试"); }); } // 控制是否允许关闭,不再显示关闭按钮。 private setCloseEnabled(enabled: boolean) { this.closeEnabled = enabled; } // 从对应道具位置生成图标和数量,播放期间锁住交互。 public playRewardEffect(items: SevenDayRewardItem[], day: number, done: () => void) { this.effectPlaying = true; const card = this.cards[day - 1]; const container = card && card.getChildByName("rewardItems"); RewardClaimEffect.play(this.node, items.map((item, index) => { const source = container && container.children[index] || card || this.panel; return { icon: this.getRewardIcon(item, true), count: Math.max(0, Number(item.count !== undefined ? item.count : item.amount) || 0), prefix: item.type === "infinite_health" || item.type === "coin" ? "" as const : "×" as const, isTime: item.type === "infinite_health", worldPosition: source.convertToWorldSpaceAR(cc.v2()), worldPopPosition: source.convertToWorldSpaceAR(cc.v2(0, source.height)), }; }), () => { this.effectPlaying = false; done(); }, this.rewardNumberFrames); } // 按天数寻找奖励,不依赖服务端数组顺序。 private findReward(day: number): SevenDayReward { const rewards = Array.isArray(this.info.rewards) ? this.info.rewards : []; for (let i = 0; i < rewards.length; i++) { if (Number(rewards[i].day) === day) return rewards[i]; } return null; } // 更新固定奖励槽位,不创建、销毁或重排节点。 private refreshRewardItems(card: cc.Node, items: SevenDayRewardItem[], unlocked: boolean) { const container = card.getChildByName("rewardItems"); if (items.length > container.childrenCount) cc.warn("[SevenDayGift] 奖励数量超出预制体槽位,请更新预制体"); container.children.forEach((icon, index) => { const item = items[index]; icon.active = !!item; if (!item) return; this.setSprite(icon, this.getRewardIcon(item, unlocked)); this.refreshAmount(icon, item); }); } // 只更新预制体中已有的图片字位,无文字兜底。 private refreshAmount(icon: cc.Node, item: SevenDayRewardItem) { const amount = icon.getChildByName("amount"); const count = Math.max(0, Number(item.count !== undefined ? item.count : item.amount) || 0); const isTime = item.type === "infinite_health"; const prefix = isTime || item.type === "coin" ? "" : "×"; const value = isTime ? Number((count / 60).toFixed(2)) : count; const text = prefix + value; const glyphs = text.split("").map(char => this.rewardNumberFrames[char === "×" ? 11 : Number(char)]); if (isTime) glyphs.push(this.rewardNumberFrames[12]); const useImages = /^×?\d+$/.test(text) && glyphs.length <= amount.childrenCount && glyphs.every(Boolean); amount.children.forEach((digit, index) => digit.active = useImages && index < glyphs.length); if (!useImages) { cc.warn("[SevenDayGift] 数量图片或字位不足,请更新预制体:" + text); return; } const numberFrames = isTime ? glyphs.slice(0, -1) : glyphs; const scale = amount.height / Math.max(...numberFrames.map(frame => frame.getOriginalSize().height)); // 分钟贴图原始高度较小,单独按数字的可见高度等比放大。 const numberHeight = Math.max(...numberFrames.map(frame => frame.getRect().height)) * scale; const scales = glyphs.map((frame, index) => isTime && index === glyphs.length - 1 ? numberHeight / frame.getRect().height : scale); const widths = glyphs.map((frame, index) => frame.getOriginalSize().width * scales[index]); const total = widths.reduce((sum, width) => sum + width, 0) + glyphs.length - 1; const fit = Math.min(1, amount.width / total); let x = -total / 2; glyphs.forEach((frame, index) => { const digit = amount.getChildByName("digit" + index); this.setSprite(digit, frame); digit.setContentSize(widths[index] * fit, frame.getOriginalSize().height * scales[index] * fit); digit.setPosition((x + widths[index] / 2) * fit, 0); x += widths[index] + 1; }); } // 将奖励类型映射为活动图标,猫皮肤区分锁定状态。 private getRewardIcon(item: SevenDayRewardItem, unlocked: boolean): cc.SpriteFrame { switch (item.type) { case "coin": return this.coinIcon; case "hammer": return this.hammerIcon; case "freeze": return this.freezeIcon; case "magic_wand": return this.magicWandIcon; case "infinite_health": return this.infiniteHealthIcon; case "cat_skin": return unlocked ? this.catArt : this.catLocked; default: return this.coinIcon; } } // 有可用图标时才替换节点图片。 private setSprite(node: cc.Node, frame: cc.SpriteFrame) { if (!node || !frame) return; const sprite = node.getComponent(cc.Sprite); if (sprite) sprite.spriteFrame = frame; } }