MatchMaster/assets/GameplayIntro/script/GameplayIntro.ts

223 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.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;
}
}