394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
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 closeNode: 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");
|
||
this.closeNode = this.panel && this.panel.getChildByName("close");
|
||
const cards = this.panel && this.panel.getChildByName("cards");
|
||
const message = this.panel && this.panel.getChildByName("message");
|
||
if (!this.panel || !this.mask || !cards) {
|
||
cc.error("[SevenDayGift] prefab structure is incomplete");
|
||
return false;
|
||
}
|
||
if (message) message.active = false;
|
||
if (this.closeNode) this.closeNode.active = 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);
|
||
dayLabel.string = day === TOTAL_DAYS ? "第7天" : "第" + day + "天";
|
||
dayLabel.fontSize = 32;
|
||
dayLabel.lineHeight = 38;
|
||
dayLabel.node.color = cc.Color.WHITE;
|
||
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, special, available || claimed);
|
||
|
||
// 保留原卡片,遮罩压暗日期和奖励,印章绘制在遮罩上方。
|
||
let overlay = card.getChildByName("claimedOverlay");
|
||
if (!overlay) {
|
||
overlay = new cc.Node("claimedOverlay");
|
||
const sprite = overlay.addComponent(cc.Sprite);
|
||
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM;
|
||
card.addChild(overlay);
|
||
}
|
||
this.setSprite(overlay, this.claimedOverlay);
|
||
overlay.setContentSize(card.width, card.height);
|
||
overlay.setPosition(0, 0);
|
||
overlay.zIndex = 9;
|
||
overlay.active = claimed;
|
||
|
||
let badge = card.getChildByName("claimedBadge");
|
||
if (!badge) {
|
||
badge = new cc.Node("claimedBadge");
|
||
badge.addComponent(cc.Sprite);
|
||
card.addChild(badge);
|
||
badge.zIndex = 10;
|
||
}
|
||
badge.setContentSize(118, 99);
|
||
badge.setPosition(0, 0);
|
||
badge.active = claimed;
|
||
this.setSprite(badge, this.claimedBadge);
|
||
|
||
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;
|
||
if (this.closeNode) this.closeNode.active = false;
|
||
}
|
||
|
||
// 从对应道具位置生成图标和数量,播放期间锁住交互。
|
||
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[], special: boolean, unlocked: boolean) {
|
||
const originalIcon = card.getChildByName("rewardIcon");
|
||
if (originalIcon) originalIcon.active = false;
|
||
|
||
let container = card.getChildByName("rewardItems");
|
||
if (!container) {
|
||
container = new cc.Node("rewardItems");
|
||
container.zIndex = 2;
|
||
card.addChild(container);
|
||
}
|
||
|
||
container.children.slice().forEach(child => {
|
||
child.removeFromParent();
|
||
child.destroy();
|
||
});
|
||
container.active = items.length > 0;
|
||
if (!items.length) return;
|
||
|
||
const count = items.length;
|
||
const availableWidth = special ? 220 : 184;
|
||
// 图标略微缩小,为下方图片数量留出空间。
|
||
const iconSize = special ? 116 : 92;
|
||
const step = count === 1 ? 0 : Math.min(iconSize + 8, (availableWidth - iconSize) / (count - 1));
|
||
const totalWidth = iconSize + step * (count - 1);
|
||
const startX = -totalWidth / 2 + iconSize / 2;
|
||
const centerY = -4; // 道具和数量整体下移 12px。
|
||
|
||
container.setContentSize(availableWidth, special ? 210 : 130);
|
||
container.setPosition(0, special ? -8 : -4);
|
||
|
||
items.forEach((item, index) => {
|
||
const itemNode = new cc.Node("rewardItem" + index);
|
||
// 第七天突出小猫,爱心缩小;按实际尺寸限制位置,避免超出卡片。
|
||
const baseSize = special && item.type === "cat_skin" ? 140
|
||
: special && item.type === "infinite_health" ? 78 : iconSize;
|
||
const size = count === 1 ? Math.round(baseSize * 1.15) : baseSize;
|
||
const maxX = (availableWidth - size) / 2;
|
||
const x = Math.max(-maxX, Math.min(maxX, startX + step * index));
|
||
itemNode.setContentSize(size, size);
|
||
// 爱心略微下移,形成高低层次,让小猫成为视觉重点。
|
||
const y = special && item.type === "infinite_health" ? centerY - 24 : centerY;
|
||
itemNode.setPosition(special && item.type === "infinite_health" ? x - 12 : x, y);
|
||
itemNode.zIndex = index;
|
||
container.addChild(itemNode);
|
||
|
||
const sprite = itemNode.addComponent(cc.Sprite);
|
||
sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM;
|
||
sprite.spriteFrame = this.getRewardIcon(item, unlocked);
|
||
// 按原图比例收进目标尺寸,避免非正方形道具被拉伸。
|
||
sprite.trim = false;
|
||
if (sprite.spriteFrame) {
|
||
const original = sprite.spriteFrame.getOriginalSize();
|
||
const scale = size / Math.max(original.width, original.height, 1);
|
||
itemNode.setContentSize(original.width * scale, original.height * scale);
|
||
}
|
||
// 数量紧跟各自图标;双奖励各占自己的宽度,避免文字相互挤压。
|
||
RewardClaimEffect.createAmount(itemNode, {
|
||
count: Math.max(0, Number(item.count !== undefined ? item.count : item.amount) || 0),
|
||
prefix: item.type === "coin" || item.type === "infinite_health" ? "" : "×",
|
||
isTime: item.type === "infinite_health",
|
||
}, this.rewardNumberFrames, count > 1 ? Math.min(88, step) : 100, 26, itemNode);
|
||
});
|
||
}
|
||
|
||
// 将奖励类型映射为活动图标,猫皮肤区分锁定状态。
|
||
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;
|
||
}
|
||
}
|