//********************* // create by 流云 // time: Wed Aug 26 2026 // desc: 每日商店抽奖弹窗(Battlebuff 8 格,与 PopDailyShopBuy 分离) //********************* import { _decorator, Color, EventTouch, instantiate, Label, Layout, Node, Sprite, tween, v3 } from 'cc'; import { Auto_PopDailyShopLottery } from './Auto_PopDailyShopLottery'; import { DailyShopLotteryIcon, DailyShopOwnedDesc } from './DailyShopLotteryIcon'; import { DailyShopBuySkillFlyPresenter } from '../PopDailyShopBuy/DailyShopBuySkillFlyPresenter'; import { GEvent } from '../../../mx/module/event/GEvent'; import { GAudioPath, UIID } from '../../game/ConfigRes'; import { DailyShopDataManager } from '../../manager/DailyShopDataManager'; import { PopReplaceSkillParam } from '../PopReplaceSkill/UIPopReplaceSkill'; import { PopToastType } from '../PopToast/UIPopToast'; import { DailyShopKind, DailyShopParsed, DailyShopPurchaseFail, DailyShopPurchaseResult, dailyShopFailReason, isDailyShopNeedReplace, } from '../../manager/DailyShopTypes'; import { RankReport } from '../../manager/RankReport'; const { ccclass } = _decorator; const BUY_USED_TIP = '商品已售罄,欢迎下次光临!'; const LOTTERY_SLOT_COUNT = 8; /** 顺时针:lottery2 → … → lottery8 → lottery1 */ const LOTTERY_CW_INDEX = [1, 2, 3, 4, 5, 6, 7, 0]; const LOTTERY_SPIN_LAPS = 2; const LOTTERY_SPIN_STEP_MS = 0.08; /** 抽奖格名称描边色(按品质) */ const LOTTERY_NAME_OUTLINE_HEX: Record = { 1: '#386c42', 2: '#285c6d', 3: '#623073', 4: '#734930', }; @ccclass('UIPopDailyShopLottery') export class UIPopDailyShopLottery extends Auto_PopDailyShopLottery { private _busy = false; private _btnLotteryDraw: Node | null = null; private _lotteryItems: Node[] = []; private _lotteryPoolIds: number[] = []; private _lotterySpinToken = 0; private _lotteryWinSlotIndex = -1; private _lotteryPendingWinId = 0; private _ownedDesc: DailyShopOwnedDesc | null = null; private _skillFly: DailyShopBuySkillFlyPresenter | null = null; onLoad(): void { super.onLoad(); this._skillFly = new DailyShopBuySkillFlyPresenter(); this._skillFly.bind(this.ui_center); if (this.ui_lotteryItem?.isValid) this.ui_lotteryItem.active = false; if (this.ui_skillMainItem?.isValid) this.ui_skillMainItem.active = false; if (this.ui_skillPassiveItem?.isValid) this.ui_skillPassiveItem.active = false; if (this.ui_emptySkillMainItem?.isValid) this.ui_emptySkillMainItem.active = false; if (this.ui_emptySkillPassiveItem?.isValid) this.ui_emptySkillPassiveItem.active = false; this._btnLotteryDraw = this.ui_lottery?.getChildByName('btnLottery') ?? null; if (this.ui_descLeft?.isValid || this.ui_descRight?.isValid) { this._ownedDesc = new DailyShopOwnedDesc(this.ui_descLeft, this.ui_descRight); this._ownedDesc.init(); } } onEnable(): void { super.onEnable(); this._ownedDesc?.bindDismiss(); GEvent.Ins.on(GEvent.DailyShopOffersRefresh, this._onOffersRefresh, this); if (this._btnLotteryDraw?.isValid) { this._btnLotteryDraw.on(Node.EventType.TOUCH_END, this._onClickLotteryDraw, this); } void this._refreshView(); } onDisable(): void { GEvent.Ins.off(GEvent.DailyShopOffersRefresh, this._onOffersRefresh, this); if (this._btnLotteryDraw?.isValid) { this._btnLotteryDraw.off(Node.EventType.TOUCH_END, this._onClickLotteryDraw, this); } this._ownedDesc?.unbindDismiss(); this._skillFly?.unbind(); this._interruptLotteryDraw(); this._lotterySpinToken++; this._lotteryWinSlotIndex = -1; this._lotteryPendingWinId = 0; super.onDisable(); } onInit(): void { this._busy = false; this.showOpenCenterEff(); this.showOpenMaskEff(2); void this._resetAndShow(); } click_btnClose(): void { this._interruptLotteryDraw(); this.close(2); } private async _resetAndShow(): Promise { await DailyShopDataManager.ensureTable(); if (!this.isValid) return; DailyShopDataManager.ensureToday(); await this._refreshView(); const pendingId = DailyShopDataManager.getPendingReplaceGoodsId(); if (pendingId > 0) this._askReplace(pendingId); } private _onOffersRefresh(): void { if (!this.isValid) return; if (this._busy) return; this._renderOwnedSkills(); if (!DailyShopDataManager.hasUsedLotteryPurchase()) { this._renderLottery(); } } private async _refreshView(): Promise { await DailyShopDataManager.ensureTable(); if (!this.isValid) return; this._renderOwnedSkills(); this._renderLottery(); this._refreshLotteryDrawBtn(); } private _renderOwnedSkills(hideSlot?: { kind: DailyShopKind; slotIndex: number }): void { this._ownedDesc?.hide(); const { active, passive } = DailyShopDataManager.getSlotCaps(); this._fillSkillSlots( this.ui_skillMain, this.ui_skillMainItem, this.ui_emptySkillMainItem, DailyShopDataManager.getActiveSlotIds(), active, DailyShopKind.Active, hideSlot?.kind === DailyShopKind.Active ? hideSlot.slotIndex : -1, ); this._fillSkillSlots( this.ui_skillPassive, this.ui_skillPassiveItem, this.ui_emptySkillPassiveItem, DailyShopDataManager.getPassiveSlotIds(), passive, DailyShopKind.Passive, hideSlot?.kind === DailyShopKind.Passive ? hideSlot.slotIndex : -1, ); } private _fillSkillSlots( container: Node | null | undefined, template: Node | null | undefined, emptyTemplate: Node | null | undefined, slotIds: number[], slotCount: number, kind: DailyShopKind, hideSlotIndex = -1, ): void { if (!container?.isValid || !template?.isValid || slotCount <= 0) { container?.removeAllChildren(); return; } container.removeAllChildren(); for (let i = 0; i < slotCount; i++) { // 始终显示空模板作为底 const base = emptyTemplate?.isValid ? instantiate(emptyTemplate) : null; if (base) { base.active = true; base.setPosition(v3(0, 0, 0)); base.parent = container; } // 有技能则叠在空模板上面(hideSlotIndex 指定的槽位不显示技能) const id = Number(slotIds[i]) || 0; const parsed = id > 0 ? DailyShopDataManager.getParsed(id) : null; if (parsed && i !== hideSlotIndex) { const item = instantiate(template); item.active = true; item.setPosition(v3(0, 0, 0)); item.parent = base ?? container; DailyShopLotteryIcon.bindSlotItem(item, parsed, kind); this._ownedDesc?.bindSlotItem(item, parsed, kind); this._bindDeleteBtn(item, kind, i); } } container.getComponent(Layout)?.updateLayout(); } private _bindDeleteBtn(item: Node, kind: DailyShopKind, slotIndex: number): void { const deleteBtn = item.getChildByName('deleteBtn'); if (!deleteBtn?.isValid) return; deleteBtn.active = true; deleteBtn.setPosition(v3(32, 32, 0)); deleteBtn.setSiblingIndex(item.children.length - 1); const deleteIcon = deleteBtn.getChildByName('icon'); // 预制体已内置删除图标 spriteFrame,勿再动态加载不存在的 popCommon 路径 if (deleteIcon?.isValid) { deleteIcon.active = true; } deleteBtn['dailyShopSlotKind'] = kind; deleteBtn['dailyShopSlotIndex'] = slotIndex; deleteBtn.targetOff(Node.EventType.TOUCH_END); deleteBtn.on(Node.EventType.TOUCH_END, this._onClickDeleteSlot, this); } private _onClickDeleteSlot(event: EventTouch): void { if (this._busy) return; const btn = event.currentTarget as Node; const kind = Number(btn?.['dailyShopSlotKind']) as DailyShopKind; const slotIndex = Number(btn?.['dailyShopSlotIndex']) || 0; const name = this._getSlotGoodsName(kind, slotIndex); gg.game.showPopToast( `你确定要删除${name}道具吗?`, PopToastType.DeleteItem, () => { if (!DailyShopDataManager.removeSlotAt(kind, slotIndex)) { gg.ui.showToast('删除失败', false); return; } void this._refreshView(); }, ); } private _getSlotGoodsName(kind: DailyShopKind, slotIndex: number): string { const ids = kind === DailyShopKind.Passive ? DailyShopDataManager.getPassiveSlotIds() : DailyShopDataManager.getActiveSlotIds(); const id = Number(ids[slotIndex]) || 0; if (id <= 0) return ''; return DailyShopDataManager.getParsed(id)?.name ?? ''; } private _renderLottery(): void { if (!this.ui_lottery?.isValid || !this.ui_lotteryItem?.isValid) return; this._lotteryItems = []; this._lotteryPoolIds = []; const pool = DailyShopDataManager.getLotteryPoolSlots(); for (let i = 0; i < LOTTERY_SLOT_COUNT; i++) { const slot = this.ui_lottery.getChildByName(`lottery${i + 1}`); if (!slot?.isValid) continue; slot.removeAllChildren(); const parsed = pool[i]; if (!parsed) continue; const item = instantiate(this.ui_lotteryItem); item.active = true; item.setPosition(v3(0, 0, 0)); item.parent = slot; this._bindLotteryItem(item, parsed); this._lotteryItems[i] = item; this._lotteryPoolIds[i] = parsed.id; } this._lotteryWinSlotIndex = -1; this._clearLotterySelect(); this._selectDefaultLotteryItem(); this._refreshLotteryDrawBtn(); } private _selectDefaultLotteryItem(): void { if (this._lotteryItems.length <= 0 || !this._lotteryItems[0]?.isValid) return; this._selectLotterySlot(0); } private _selectLotterySlot(slotIndex: number): void { this._clearLotterySelect(); const item = this._lotteryItems[slotIndex]; if (!item?.isValid) return; const parsed = this._getLotteryItemParsed(item, slotIndex); this._applyLotteryItemSelect(item, parsed, true); } private _getLotteryItemParsed(item: Node, slotIndex: number): DailyShopParsed | null { const cached = item['dailyShopLotteryParsed'] as DailyShopParsed | undefined; if (cached) return cached; const rawId = Number(item['dailyShopLotteryId']) || Number(this._lotteryPoolIds[slotIndex]) || 0; if (rawId <= 0) return null; return DailyShopDataManager.getParsed(rawId); } private _applyLotteryItemSelect(item: Node, parsed: DailyShopParsed | null, selected: boolean): void { const select = item.getChildByName('select'); if (select?.isValid) select.active = selected; const main = item.getChildByName('main'); if (main?.isValid) { main.active = parsed?.kind === DailyShopKind.Active; } } private _bindLotteryItem(item: Node, parsed: DailyShopParsed): void { DailyShopLotteryIcon.bindLotteryItem(item, parsed); // 设置名称与品质描边 const nameNode = item.getChildByName('name'); if (nameNode?.isValid) { const lb = nameNode.getComponent(Label); if (lb) { lb.string = parsed.name; const hex = LOTTERY_NAME_OUTLINE_HEX[Math.max(1, Math.floor(parsed.quality) || 1)]; if (hex) { lb.enableOutline = true; lb.outlineColor = Color.fromHEX(new Color(), hex); } } } item['dailyShopLotteryId'] = parsed.id; item['dailyShopLotteryParsed'] = parsed; this._ownedDesc?.bindLotteryItem(item, parsed); this._applyLotteryItemSelect(item, parsed, false); } private _clearLotterySelect(): void { for (let i = 0; i < this._lotteryItems.length; i++) { const item = this._lotteryItems[i]; if (!item?.isValid) continue; const parsed = this._getLotteryItemParsed(item, i); this._applyLotteryItemSelect(item, parsed, false); } } private _showLotterySelect(slotIndex: number): void { this._selectLotterySlot(slotIndex); } private _refreshLotteryDrawBtn(): void { if (!this._btnLotteryDraw?.isValid) return; this._setBuyBtnGray(this._btnLotteryDraw, this._isBuyLocked()); } private _isBuyLocked(): boolean { return DailyShopDataManager.hasUsedLotteryPurchase(); } private _onClickLotteryDraw(): void { if (this._busy) return; if (this._isBuyLocked()) { gg.ui.showToast(BUY_USED_TIP, false); return; } this._busy = true; gg.sdk.showVideoAd((res: boolean) => { if (!res) { this._busy = false; gg.ui.showToast('观看视频失败', false); return; } RankReport.adLottery(); void this._runLotteryDraw(); }); } private async _runLotteryDraw(): Promise { const winId = DailyShopDataManager.rollLotteryWinnerId(); const winSlot = this._lotteryPoolIds.indexOf(winId); if (winId <= 0 || winSlot < 0) { this._busy = false; gg.ui.showToast('抽奖失败', false); return; } this._lotteryPendingWinId = winId; const spinToken = ++this._lotterySpinToken; // 1. 转盘(含缩放跳动) await this._playLotterySpin(winSlot, spinToken); if (this._lotteryPendingWinId !== winId) return; if (spinToken !== this._lotterySpinToken || !this.isValid) { this._settlePendingLottery(true); return; } // 2. 呼吸 2 次 const winItem = this._lotteryItems[winSlot]; if (winItem?.isValid) { await this._playWinBreathing(winItem, spinToken); if (this._lotteryPendingWinId !== winId) return; if (spinToken !== this._lotterySpinToken || !this.isValid) { this._settlePendingLottery(true); return; } } // 3. 购买(数据入槽) this._lotteryPendingWinId = 0; const result = DailyShopDataManager.purchaseLottery(winId); if (result.ok || isDailyShopNeedReplace(result)) { this._lotteryWinSlotIndex = winSlot; this._showLotterySelect(winSlot); } else { this._clearLotterySelect(); } // 4. 飞行到道具栏(成功且非即时,与购买逻辑一致) if (result.ok && !result.instant && result.slotIndex != null && result.slotIndex >= 0 && result.parsed && result.slotKind != null) { const slotKind = result.slotKind; const slotIndex = result.slotIndex; const sourceIcon = winItem?.isValid ? (DailyShopLotteryIcon.resolveShopIcon(winItem) ?? winItem) : null; const slotBase = this._getSlotBase(slotKind, slotIndex); const fly = this._skillFly; // 音效 + 提示 gg.ui.showToast('抽奖成功', false); gg.dot?.updateRedDot(); if (sourceIcon?.isValid && slotBase?.isValid && fly) { // 先起飞(终点取当前槽位世界坐标),再隐藏目标槽技能 fly.playIconFly(sourceIcon, slotBase, () => { if (!this.isValid) return; this._busy = false; this._refreshAfterLotteryUse(); // 落点脉冲(与购买一致) const base = this._getSlotBase(slotKind, slotIndex); const skillItem = base?.isValid ? this._findSlotSkillItem(base) : null; if (skillItem?.isValid) fly.playSlotPulse(skillItem); }); this._renderOwnedSkills({ kind: slotKind, slotIndex }); return; } this._busy = false; this._refreshAfterLotteryUse(); return; } // 其它情况(即时/替换/失败) this._busy = false; this._handleLotteryResult(result); } private _interruptLotteryDraw(): void { if (this._lotteryPendingWinId <= 0) return; this._lotterySpinToken++; this._settlePendingLottery(true); } private _settlePendingLottery(interrupted: boolean): void { const winId = this._lotteryPendingWinId; if (winId <= 0) return; this._lotteryPendingWinId = 0; this._busy = false; const result = DailyShopDataManager.purchaseLottery(winId); if (!interrupted) return; this._clearLotterySelect(); if (result.ok || isDailyShopNeedReplace(result)) { gg.dot?.updateRedDot(); if (this.isValid) { this._refreshAfterLotteryUse(); if (isDailyShopNeedReplace(result)) { this._askReplace(DailyShopDataManager.getPendingReplaceGoodsId()); } } } } /** 顺时针转盘高亮,每进一格播一次音效 */ private async _playLotterySpin(winSlotIndex: number, token: number): Promise { this._clearLotterySelect(); const cw = LOTTERY_CW_INDEX; let winCwIdx = cw.indexOf(winSlotIndex); if (winCwIdx < 0) winCwIdx = 0; const totalSteps = LOTTERY_SPIN_LAPS * cw.length + winCwIdx + 1; let prevItem: Node | null = null; for (let step = 0; step < totalSteps; step++) { if (token !== this._lotterySpinToken || !this.isValid) return; this._clearLotterySelect(); const slotIdx = cw[step % cw.length]; const item = this._lotteryItems[slotIdx]; // 前一格回到原始缩放 if (prevItem?.isValid) { prevItem.setScale(1, 1, 1); } // 当前进格放大 1.1 if (item?.isValid) { item.setScale(1.1, 1.1, 1); const select = item.getChildByName('select'); if (select?.isValid) select.active = true; prevItem = item; } this._playLotterySpinTick(); const tail = totalSteps - step; const delay = tail <= 6 ? LOTTERY_SPIN_STEP_MS + (6 - tail) * 0.04 : LOTTERY_SPIN_STEP_MS; await this._waitSec(delay); } } /** 转盘进一格时播一次音效 */ private _playLotterySpinTick(): void { gg.audio.playEffect({ name: '抽奖转盘', path: GAudioPath.SfxUi }); } private _waitSec(sec: number): Promise { return new Promise((resolve) => { this.scheduleOnce(resolve, sec); }); } /** 呼吸 2 次(1→1.1→1 循环 2 次) */ private async _playWinBreathing(item: Node, token: number): Promise { // 先回到原始缩放(spinning 可能留下 1.1) item.setScale(1, 1, 1); for (let i = 0; i < 2; i++) { if (token !== this._lotterySpinToken || !this.isValid) return; await this._tweenScale(item, 1.1, 0.15); if (token !== this._lotterySpinToken || !this.isValid) return; await this._tweenScale(item, 1, 0.15); } } /** 缩放 tween → Promise */ private _tweenScale(node: Node, scale: number, dur: number): Promise { return new Promise((resolve) => { if (!node?.isValid) { resolve(); return; } tween(node) .to(dur, { scale: v3(scale, scale, 1) }) .call(resolve) .start(); }); } /** 槽位空模板节点 */ private _getSlotBase(kind: DailyShopKind, slotIndex: number): Node | null { const container = kind === DailyShopKind.Active ? this.ui_skillMain : this.ui_skillPassive; if (!container?.isValid || slotIndex < 0 || slotIndex >= container.children.length) return null; return container.children[slotIndex]; } /** 槽位上的技能节点(排除 deleteBtn) */ private _findSlotSkillItem(base: Node): Node | null { const kids = base.children; for (let i = 0; i < kids.length; i++) { const child = kids[i]; if (child.name === 'deleteBtn') continue; return child; } return null; } private _handleLotteryResult(result: DailyShopPurchaseResult): void { if (result.ok) { if (result.instant) { gg.ui.showToast('抽奖成功', false); } else if (result.replacedId) { const oldName = DailyShopDataManager.getParsed(result.replacedId)?.name ?? ''; gg.ui.showToast(oldName ? `已替换「${oldName}」` : '抽奖成功', false); } else { gg.ui.showToast('抽奖成功', false); } this._refreshAfterLotteryUse(); gg.dot?.updateRedDot(); return; } if (isDailyShopNeedReplace(result)) { this._refreshAfterLotteryUse(); this._askReplace(DailyShopDataManager.getPendingReplaceGoodsId()); return; } gg.ui.showToast(this._failTip(dailyShopFailReason(result)), false); this._clearLotterySelect(); } private _refreshAfterLotteryUse(): void { this._renderOwnedSkills(); this._refreshLotteryDrawBtn(); } private _setBuyBtnGray(btn: Node, gray: boolean): void { const walk = (n: Node) => { const sp = n.getComponent(Sprite); if (sp) sp.grayscale = gray; const kids = n.children; for (let i = 0; i < kids.length; i++) walk(kids[i]); }; walk(btn); } private _askReplace(goodsId: number): void { const parsed = DailyShopDataManager.getParsed(goodsId); if (!parsed) return; const entries = DailyShopDataManager.getOccupiedSlotEntries(parsed.kind); if (entries.length <= 0) { gg.ui.showToast('没有可替换的道具', false); return; } gg.ui.openPanel(UIID.PopReplaceSkill, { data: { goodsId, onConfirm: (replaceOwnedId: number) => { this._completeReplace(replaceOwnedId); }, onCancel: () => { this._refreshAfterLotteryUse(); }, } satisfies PopReplaceSkillParam, }); } private _completeReplace(replaceOwnedId: number): void { if (this._busy) return; this._busy = true; const result = DailyShopDataManager.completeReplace(replaceOwnedId); this._busy = false; if (result.ok) { RankReport.shopBuy(result.parsed?.name ?? ''); const oldName = result.replacedId ? DailyShopDataManager.getParsed(result.replacedId)?.name ?? '' : ''; gg.ui.showToast(oldName ? `已替换「${oldName}」` : '抽奖成功', false); this._refreshAfterLotteryUse(); gg.dot?.updateRedDot(); return; } gg.ui.showToast(this._failTip(dailyShopFailReason(result)), false); } private _failTip(reason: DailyShopPurchaseFail): string { switch (reason) { case DailyShopPurchaseFail.AlreadySold: return '已售罄'; case DailyShopPurchaseFail.NotInOffer: return '不在今日奖池'; case DailyShopPurchaseFail.InvalidReplace: return '替换目标无效'; case DailyShopPurchaseFail.PendingReplace: return '请先完成替换'; default: return '抽奖失败'; } } }