You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
AdLike2048/assets/script/ui/PopDailyShopLottery/UIPopDailyShopLottery.ts

640 lines
24 KiB

3 weeks ago
//*********************
// 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<number, string> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
return new Promise((resolve) => {
this.scheduleOnce(resolve, sec);
});
}
/** 呼吸 2 次(1→1.1→1 循环 2 次) */
private async _playWinBreathing(item: Node, token: number): Promise<void> {
// 先回到原始缩放(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<void> {
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 '抽奖失败';
}
}
}