Files
MatchMaster/assets/GameplayIntro/script/GameplayIntro.ts
T

220 lines
11 KiB
TypeScript

import PopupAnimator, { ActivityPopupAnimationType as PopupType } from "../../Script/ActivityPopupAnimator";
import GuideCarousel from "./GuideCarousel";
// 一页图片的资源记录:node 是图片容器,frames 是本页持有引用的资源,sprites 是显示图片的组件。
// live=false 表示本页已移出保留范围;晚到的加载结果必须丢弃,不能再显示。
type IntroPage = { node: cc.Node; frames: cc.SpriteFrame[]; sprites: cc.Sprite[]; live: boolean };
const { ccclass } = cc._decorator;
// daoju26 是变色跳跃门、daoju27 是障碍杆;公共素材的这两个编号相反。
const PROP_ART_IDS = Array.from({ length: 34 }, (_, index) => index === 26 ? 27 : index === 27 ? 26 : index);
/**
* 本关玩法面板控制器,挂在 GameplayIntro 预制体根节点。
* 阅读顺序:open 接收本关列表 → syncPages 确定需要的三页 → picture 排队加载图片。
* 关闭时 releasePages 释放玩法图;背景、标题等固定布局留在预制体中供下次复用。
*/
@ccclass
export default class GameplayIntro extends cc.Component {
private imageLayouts = new Map<cc.Node, { width: number; height: number; scale: number }>();
// 调用方已按本关配置筛选并去重;按解锁关卡倒序排列,每次打开最左端的最新玩法。
private introductions: any[] = [];
private carousel: GuideCarousel = null;
private board: cc.Node = null;
private status: cc.Label = null;
// 每次打开/关闭递增的请求编号,用来识别已过期的异步加载回调。
private request = 0;
private panelTween: cc.Tween = null;
private closing = false;
// 只记录当前页及相邻页的图片,不为所有历史页面常驻图片资源。
private loadedPages: { [index: number]: IntroPage } = {};
// 图片逐张加载:队列存“加载任务”,imageLoading 防止同时启动多个任务。
private imageQueue: Array<() => void> = [];
private imageLoading = false;
/** entries 是本关实际用到的玩法介绍,空列表直接关闭。 */
open(entries: any[]) {
this.stopPanelAnimation();
this.releasePages();
this.introductions = entries.slice().sort((a, b) => b.level - a.level);
if (!this.introductions.length) {
if (this.carousel) this.carousel.clear();
this.finishClose();
return;
}
this.node.active = true;
// 固定节点只查找和绑定一次,避免重复打开时叠加关闭/翻页监听。
if (!this.board) {
this.board = this.node.getChildByName("IntroPanel");
this.status = this.board.getChildByName("Status").getComponent(cc.Label);
// Backdrop 是面板外的遮罩,点击它关闭;面板自身通过 BlockInputEvents 阻止穿透。
const backdrop = this.node.getChildByName("Backdrop");
const graphics = backdrop.getComponent(cc.Graphics);
graphics.fillColor = new cc.Color(0, 0, 0, 170);
graphics.rect(-backdrop.width / 2, -backdrop.height / 2, backdrop.width, backdrop.height);
graphics.fill();
backdrop.on("click", this.close, this);
this.board.getChildByName("CloseButton").on("click", this.close, this);
const viewport = this.board.getChildByName("IntroViewport");
this.carousel = viewport.getComponent(GuideCarousel);
viewport.on("page-changed", () => {
// GuideCarousel 通知目标页已变化,此时及时加载新邻页、释放远处页。
this.syncPages();
}, this);
}
// 重置分页状态和滑动监听,保留预制体中的三个固定页面。
this.carousel.clear();
this.status.node.targetOff(this);
this.status.node.active = true;
this.status.string = "加载介绍中…";
this.status.node.active = false;
this.carousel.initialize(this.introductions.length, 0, this.board.getChildByName("Pagination"));
this.closing = false;
PopupAnimator.open(this.board);
}
/** 性能优化入口:翻页后只保留 current-1、current、current+1 这三页的图片。 */
private syncPages() {
const current = this.carousel.index;
Object.keys(this.loadedPages).forEach(key => {
const index = Number(key);
// 例如当前为第 5 项,只保留索引 4、5、6;其它已加载页立即释放。
if (Math.abs(index - current) > 1) this.releasePage(index);
});
// 优先加载当前页,再加载前一页和后一页;越界或已加载的页面直接跳过。
// 在真实列表的两端,通常只需保留两页。
[current, current - 1, current + 1].forEach(index => {
if (index < 0 || index >= this.introductions.length || this.loadedPages[index]) return;
const node = this.carousel.pages[index];
node.active = true;
const page: IntroPage = { node, frames: [], sprites: [], live: true };
this.loadedPages[index] = page;
const entry = this.introductions[index];
const art = PROP_ART_IDS[Number(entry.name.replace("daoju", ""))];
// 公共玩法图:上方标题、中间道具大图、下方说明。
if (art !== undefined) {
this.picture(page, "TitleImage", "title" + art);
this.picture(page, "IconImage", "icon" + art);
this.picture(page, "DescriptionImage", "Introduction" + art);
} else this.showMessage(entry.tips);
});
}
/** 替换预制体中的图片;道具图和说明图由组件控制大小;标题图按预制体展示区域等比适配。 */
private picture(page: IntroPage, nodeName: string, path: string) {
const node = page.node.getChildByName(nodeName);
const fitImage = nodeName === "TitleImage";
if (fitImage && !this.imageLayouts.has(node)) {
this.imageLayouts.set(node, { width: node.width, height: node.height, scale: node.scale });
}
const sprite = node.getComponent(cc.Sprite);
page.sprites.push(sprite);
this.imageQueue.push(() => {
// 排队期间用户可能已翻走或关闭面板,失效任务无需再发起资源加载。
if (!page.live) { this.nextImage(); return; }
this.imageLoading = true;
cc.resources.load("Window_Prop/" + path, cc.SpriteFrame, (error, frame: cc.SpriteFrame) => {
// addRef 表示本页持有这份资源;释放页时配对 decRef。
// decRef 减少引用,资源没有其它引用时由引擎回收。
if (!error && frame) frame.addRef();
// 已经发出的请求不能靠清空队列取消;因此结果回来时还要再检查页面是否有效。
if (!page.live || !cc.isValid(node, true)) {
if (!error && frame) frame.decRef();
} else if (error) {
this.showMessage("图片加载失败");
cc.error("Gameplay intro image", path, error);
} else {
page.frames.push(frame);
// 禁止加入动态合图,避免图片被复制到长期存在的图集中,影响关闭后的回收。
frame.getTexture().packable = false;
sprite.spriteFrame = frame;
if (fitImage) {
const layout = this.imageLayouts.get(node);
const size = frame.getOriginalSize();
node.setContentSize(size.width, size.height);
node.scale = Math.min(layout.width / size.width, layout.height / size.height) * layout.scale;
}
}
this.imageLoading = false;
this.nextImage();
});
});
this.nextImage();
}
/** 一次只启动一张图,当前加载回调结束后才取下一项,减少集中加载造成的卡顿。 */
private nextImage() {
if (this.imageLoading || !this.imageQueue.length) return;
this.imageQueue.shift()();
}
/** 先断开显示引用,再归还资源引用,最后隐藏固定页面,供后续复用。 */
private releasePage(index: number) {
const page = this.loadedPages[index];
page.live = false;
page.sprites.forEach(sprite => {
if (cc.isValid(sprite, true)) sprite.spriteFrame = null;
});
page.frames.forEach(frame => frame.decRef());
if (cc.isValid(page.node, true)) {
page.node.active = false;
// 固定页面节点保留,下一次仅替换图片。
}
delete this.loadedPages[index];
}
/** 清空待加载任务和已加载图片;在途请求返回后由 picture 中的 live 检查处理。 */
private releasePages() {
this.imageQueue = [];
Object.keys(this.loadedPages).forEach(key => this.releasePage(Number(key)));
}
// 即使外部直接隐藏面板而没有调用 close,也要释放图片并让旧请求失效。
onDisable() {
this.stopPanelAnimation();
++this.request;
this.releasePages();
}
// 场景退出或节点被销毁时的兜底清理。
onDestroy() {
this.stopPanelAnimation();
this.releasePages();
}
/** 隐藏面板并通知打开它的场景;是否恢复游戏由 SceneManager 判断。 */
close(button?: cc.Button) {
if (this.closing || !this.node.active) return;
if (button) cc.fx.AudioManager._instance.playEffect("anniu_little", null);
if (!this.board) { this.finishClose(); return; }
if (this.panelTween) this.panelTween.stop();
this.closing = true;
PopupAnimator.close(this.board, PopupType.Bounce, () => this.finishClose());
}
// 退场完成后再释放图片和通知场景恢复游戏,避免动画期间内容消失。
private finishClose() {
this.stopPanelAnimation();
++this.request;
this.releasePages();
this.node.active = false;
this.node.emit("intro-closed");
}
private stopPanelAnimation() {
PopupAnimator.cancel(this.board);
if (this.panelTween) this.panelTween.stop();
this.panelTween = null;
this.closing = false;
// 销毁回调中子节点可能已释放;这里只停止动画,不再写入节点属性。
// 每次打开都会重新设置起始缩放。
}
// 提示也复用主预制体的 Status 节点,避免向固定玩法页追加节点。
private showMessage(value: string) {
this.status.string = value;
this.status.node.active = true;
}
}