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(); // 调用方已按本关配置筛选并去重;按解锁关卡倒序排列,每次打开最左端的最新玩法。 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.board.scale = 0.3; this.panelTween = cc.tween(this.board) .to(0.2, { scale: 1.05 }, { easing: "backOut" }) .to(0.15, { scale: 1 }, { easing: "sineOut" }); this.panelTween.start(); } /** 性能优化入口:翻页后只保留 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() { if (this.closing || !this.node.active) return; if (!this.board) { this.finishClose(); return; } if (this.panelTween) this.panelTween.stop(); this.closing = true; this.panelTween = cc.tween(this.board) .to(0.15, { scale: 0.3 }, { easing: "sineIn" }) .call(() => this.finishClose()); this.panelTween.start(); } // 退场完成后再释放图片和通知场景恢复游戏,避免动画期间内容消失。 private finishClose() { this.stopPanelAnimation(); ++this.request; this.releasePages(); this.node.active = false; this.node.emit("intro-closed"); } private stopPanelAnimation() { 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; } }