223 lines
11 KiB
TypeScript
223 lines
11 KiB
TypeScript
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;
|
||
}
|
||
}
|