MatchMaster/assets/Script/module/Tool/BlockAssetManager.ts

873 lines
40 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 { allBlockTexturePaths, BLOCK_TEXTURE_TRIMS, collectBlockTexturePaths } from "./BlockTexturePaths";
import { GAMEPLAY_ATLAS_PATHS, GameplayAtlasLayout, isGameplayAtlas, parseGameplayAtlas } from "./GameplayAtlasFrames";
declare const wx: any;
declare const tt: any;
type ImageSource = "内存复用" | "本地缓存" | "临时缓存" | "远程下载";
interface BlockWindowOptions {
extraPaths?: string[];
onRemoteRequired?: () => void;
currentLevel?: number;
stage?: string;
homeWarmup?: boolean;
}
interface BlockCacheCheck {
cached: number;
temporary: number;
missing: number;
complete: boolean;
}
interface BlockDownloadJob {
path: string;
priority: number;
background: boolean;
started: boolean;
resolve: () => void;
reject: (error: any) => void;
}
/** Block 原始 PNG:版本化磁盘缓存与 SpriteFrame/纹理内存分开管理。 */
export default class BlockAssetManager {
static readonly instance = new BlockAssetManager();
static readonly CACHE_KEY = "block_image_cache_v1";
private static readonly BACKGROUND_CONCURRENCY = 8;
// Block 自己调度直存下载:后台最多 8 路,进关可使用总计 10 路。
private static readonly FOREGROUND_DOWNLOAD_LIMIT = 10;
private static readonly BACKGROUND_RETRY_MS = 10000;
private ready: Promise<void> = null;
private version: string = null;
private imageBase = "";
private paths = allBlockTexturePaths().concat(GAMEPLAY_ATLAS_PATHS.map(name => name + ".plist"));
private frames = new Map<string, cc.SpriteFrame>();
private atlasFrames = new Map<string, Map<string, cc.SpriteFrame>>();
private atlasLayouts = new Map<string, GameplayAtlasLayout>();
private retainGameplayAtlases = false;
private loading = new Map<string, Promise<void>>();
private fileDownloads = new Map<string, { promise: Promise<void>, job: BlockDownloadJob }>();
private downloadQueue: BlockDownloadJob[] = [];
private activeFileDownloads = 0;
private activeBackgroundDownloads = 0;
private reportSource: (path: string, source: ImageSource) => void = null;
private backgroundDownloads = new Set<string>();
private windowPaths = new Set<string>();
private committedPaths = new Set<string>();
private activePaths = new Set<string>();
private requestId = 0;
private windowRequestId = 0;
private committedRequestId = 0;
private activeRequestId = 0;
private homeWarmupRequestId = 0;
private foregroundLoads = 0;
private entryDownloadHolds = 0;
/** 包括直存下载、排队任务和整批后台缓存,供最低优先级分包让路。 */
hasPendingLoads(): boolean {
return this.foregroundLoads > 0 || this.entryDownloadHolds > 0 || this.backgroundRunning
|| this.loading.size > 0 || this.fileDownloads.size > 0 || this.downloadQueue.length > 0;
}
/** 只停止后台派发;已在途下载继续完成,进关所需文件仍可提优先级。 */
holdBackgroundDownloads(): () => void {
this.entryDownloadHolds++;
let released = false;
return () => {
if (released) return;
released = true;
this.entryDownloadHolds--;
this.pumpDownloadQueue();
};
}
private backgroundRunning = false;
private backgroundTimer: any = null;
private backgroundStarted = false;
private initialize(): Promise<void> {
if (!this.ready) {
cc.director.off(cc.Director.EVENT_AFTER_SCENE_LAUNCH, this.onSceneLaunched, this);
cc.director.on(cc.Director.EVENT_AFTER_SCENE_LAUNCH, this.onSceneLaunched, this);
const gameTool = cc.fx.GameTool;
if (gameTool && gameTool.MEMORY_WARNING_EVENT) {
cc.game.off(gameTool.MEMORY_WARNING_EVENT, this.onMemoryWarning, this);
cc.game.on(gameTool.MEMORY_WARNING_EVENT, this.onMemoryWarning, this);
}
this.ready = this.selectVersion().then(() => {
const previous = this.readCacheState();
if (this.getFileSystem() && (previous.version !== this.version || previous.baseUrl !== this.imageBase)) {
this.writeCacheState(this.paths.length, 0, false);
}
}).catch(error => {
this.ready = null;
throw error;
});
}
return this.ready;
}
/** 一次启动只选择一个版本,切场景/回到前台不更换正在使用的缓存命名空间。 */
private async selectVersion(): Promise<string> {
if (this.version !== null) return this.version;
const config = cc.fx.GameConfig;
const base = config.BLOCK_IMAGE_BASE_URL;
const defaultVersion = config.BLOCK_IMAGE_VERSION;
if (!/^https?:\/\//.test(base) || !this.isVersion(defaultVersion)) {
throw new Error("Block 图片地址或默认版本配置无效");
}
this.imageBase = base.replace(/\/+$/, "") + "/";
const sdk = typeof wx !== "undefined" && wx && wx.request ? wx
: typeof tt !== "undefined" && tt && tt.request ? tt : null;
const previous = this.readCacheState();
const fallback = previous.baseUrl === this.imageBase && this.isVersion(previous.version)
? previous.version : defaultVersion;
const url = this.imageBase + "version.json?_t=" + Date.now();
console.log(`[Block图片][版本检查] 开始,${url}`);
this.version = await new Promise<string>(resolve => {
let finished = false;
let request: any = null;
const finish = (error: any, version?: string) => {
if (finished) return;
finished = true;
clearTimeout(timer);
if (error) console.log(`[Block图片][版本检查] 失败,回退到版本=${fallback}`, error);
else console.log(`[Block图片][版本检查] 成功,版本=${version}`);
resolve(version || fallback);
};
const timer = setTimeout(() => {
finish(new Error("Block 版本检查超时"));
if (request && request.abort) request.abort();
}, 5000);
const accept = (error: any, data: any) => {
try {
if (error) throw error;
if (typeof data === "string") data = JSON.parse(data);
if (!data || !this.isVersion(data.version)) throw new Error("Block 版本清单无效");
finish(null, data.version);
} catch (error) { finish(error); }
};
try {
// 版本 JSON 不写 Cocos 文件缓存;CDN 也应设置不缓存。
if (sdk) {
request = sdk.request({
url, method: "GET", timeout: 5000, header: { "Cache-Control": "no-cache" },
success: (response: any) => accept(response.statusCode === 200 ? null
: new Error("Block 版本 HTTP " + response.statusCode), response.data),
fail: (error: any) => finish(error)
});
} else {
// 2.4.15 运行时提供此 XHR 接口,项目旧版类型声明未包含。
request = (cc.assetManager.downloader as any).downloadFile(url,
{ responseType: "json", timeout: 5000, header: { "Cache-Control": "no-cache" } }, null, accept);
}
} catch (error) { finish(error); }
});
return this.version;
}
private isVersion(value: any): boolean {
return typeof value === "string" && value.length <= 32 && /^\d+\.\d+\.\d+$/.test(value);
}
private readCacheState(): any {
try {
return JSON.parse(cc.sys.localStorage.getItem(BlockAssetManager.CACHE_KEY)) || {};
} catch (error) { return {}; }
}
/** 只准备当前关;当前场景仍在显示时保留它的图片。 */
async prepareWindow(current: any[], options: BlockWindowOptions = {}): Promise<boolean> {
// 正式进关窗口在图片就绪、等待切场景时仍有效,首页预热不能抢占。
if (options.homeWarmup && this.windowRequestId
&& this.windowRequestId !== this.homeWarmupRequestId) return false;
const currentPaths = collectBlockTexturePaths(current);
const paths = new Set(currentPaths.concat(options.extraPaths || []));
if (Array.from(paths).some(isGameplayAtlas)) this.retainGameplayAtlases = true;
const id = ++this.requestId;
this.homeWarmupRequestId = options.homeWarmup ? id : 0;
const stage = options.stage || "进关";
const currentLabel = "当前关" + (options.currentLevel || "");
// 同时存在多个进关请求时,只通知最新窗口;共享加载失败后的远程重试也走这里。
this.reportSource = (path, source) => {
if (id !== this.requestId || !paths.has(path)) return;
// console.log(`[Block图片][${stage}来源] ${currentLabel} ${path}.png -> ${source},版本=${this.version}`);
if (source === "远程下载" && options.onRemoteRequired) options.onRemoteRequired();
};
this.windowPaths = paths;
this.windowRequestId = id;
this.foregroundLoads++;
this.releaseUnused();
try {
const versionStartedAt = Date.now();
await this.initialize();
// console.log(`[进关耗时][${stage}/${currentLabel}/Block版本准备] 独立耗时=${Date.now() - versionStartedAt}ms`);
if (id !== this.requestId) return false;
this.ensureUserCacheFolder();
// console.log(`[Block图片][${stage}检查] ${currentLabel},版本=${this.version},共${paths.size}张`);
const names = Array.from(paths);
// 公共图集全部进内存后才加载方块,不能混在同一批并发请求中。
const groups = [names.filter(isGameplayAtlas), names.filter(name => !isGameplayAtlas(name))];
for (const group of groups) {
const groupStartedAt = Date.now();
const label = group === groups[0] ? "公共图集" : "方块图片";
// console.log(`[进关耗时][${stage}/${currentLabel}/${label}] 开始,共${group.length}张,最多4个加载任务`);
let nextIndex = 0;
let failure: Error = null;
const worker = async () => {
while (id === this.requestId && !failure && nextIndex < group.length) {
const path = group[nextIndex++];
try { await this.loadFrame(path); }
catch (error) { failure = error; }
}
};
// 完成一张立即补一张;失败后停止领取,并等已在途任务结束再回滚窗口。
await Promise.all(Array.from({ length: Math.min(4, group.length) }, () => worker()));
// console.log(`[进关耗时][${stage}/${currentLabel}/${label}] ${failure ? "失败" : id !== this.requestId ? "取消" : "完成"},独立耗时=${Date.now() - groupStartedAt}ms`);
if (failure) throw failure;
if (id !== this.requestId) return false;
}
if (id !== this.requestId) return false;
this.committedPaths = paths;
this.committedRequestId = id;
return true;
} catch (error) {
if (id !== this.requestId) return false;
this.windowPaths = this.committedPaths;
this.windowRequestId = this.committedRequestId;
throw error;
} finally {
if (id === this.requestId) this.reportSource = null;
this.foregroundLoads--;
this.pumpDownloadQueue();
this.releaseUnused();
}
}
activateScene(blocks: any[], extraPaths: string[] = []): void {
const paths = collectBlockTexturePaths(blocks).concat(extraPaths);
paths.forEach(path => this.getFrame(path));
this.activePaths = new Set(paths);
this.activeRequestId = this.committedRequestId;
this.releaseUnused();
}
releaseScene(): void {
const releasedRequestId = this.activeRequestId;
this.activePaths.clear();
this.activeRequestId = 0;
// 若下一关或重开关卡已准备完成,即使图片集合相同,也不能被旧场景销毁流程释放。
if (releasedRequestId && this.windowRequestId === releasedRequestId) {
this.windowPaths = new Set<string>();
this.windowRequestId = 0;
}
if (releasedRequestId && this.committedRequestId === releasedRequestId) {
this.committedPaths = new Set<string>();
this.committedRequestId = 0;
}
this.releaseUnused();
}
/** HomeScene 只把当前关文件提权写入 USER_DATA_PATH,不创建 Texture/SpriteFrame。 */
async cacheWindowFiles(current: any[], extraPaths: string[] = []): Promise<boolean> {
const paths = Array.from(new Set(collectBlockTexturePaths(current).concat(extraPaths)));
await this.initialize();
if (!this.canUseUserCache()) return false;
this.foregroundLoads++;
try {
const results = await Promise.all(paths.map(path => this.cacheFileToUserPath(path, 10)
.then(() => null, error => error)));
const error = results.find(result => result != null);
if (error) throw error;
console.log(`[Block图片][首页磁盘预热] 完成,共${paths.length}张,版本=${this.version}`);
return true;
} finally {
this.foregroundLoads--;
this.pumpDownloadQueue();
}
}
/** 当前关重开可复用;返回首页后根据设备内存及告警状态决定是否继续驻留。 */
private onSceneLaunched(): void {
const scene = cc.director.getScene();
if (!scene || scene.name !== "HomeScene") return;
// 使返首页前未完成的进关请求失效,迟到的加载结果也会被 releaseUnused 回收。
++this.requestId;
this.reportSource = null;
const gameTool = cc.fx.GameTool;
this.retainGameplayAtlases = this.retainGameplayAtlases && !!(gameTool
&& gameTool.shouldRetainGameplayAtlasesOnHome && gameTool.shouldRetainGameplayAtlasesOnHome());
this.windowPaths.clear();
this.committedPaths.clear();
this.activePaths.clear();
this.homeWarmupRequestId = 0;
this.windowRequestId = this.committedRequestId = this.activeRequestId = 0;
this.releaseUnused();
}
private onMemoryWarning(): void {
const scene = cc.director.getScene();
if (!scene || scene.name !== "HomeScene") return;
if (this.homeWarmupRequestId) {
++this.requestId;
this.homeWarmupRequestId = 0;
this.windowPaths.clear();
this.committedPaths.clear();
this.windowRequestId = this.committedRequestId = 0;
}
const gameTool = cc.fx.GameTool;
if (gameTool && gameTool.shouldRetainGameplayAtlasesOnHome
&& gameTool.shouldRetainGameplayAtlasesOnHome()) return;
this.retainGameplayAtlases = false;
// 已在准备进关的图片仍受窗口保护,不释放即将使用的纹理。
this.releaseUnused();
}
getFrame(path: string): cc.SpriteFrame {
const frame = this.frames.get(path);
if (!frame || !cc.isValid(frame)) throw new Error("方块图片尚未就绪: " + path);
return frame;
}
/** 图集 PNG 共用原有下载缓存,小图按需创建,随整张纹理一起释放。 */
getAtlasFrame(atlas: string, name: string): cc.SpriteFrame {
const texture = this.getFrame(atlas).getTexture();
let frames = this.atlasFrames.get(atlas);
if (!frames) this.atlasFrames.set(atlas, frames = new Map());
const cached = frames.get(name);
if (cached) return cached;
const layout = this.atlasLayouts.get(atlas);
const data = layout && layout.frames[name];
// 与 SpriteAtlas.getSpriteFrame 一致,缺少对应状态的小图时返回空。
if (!data) return null;
const frame = new cc.SpriteFrame(texture, cc.rect(data[0], data[1], data[2], data[3]),
!!data[4], cc.v2(data[5], data[6]), cc.size(data[7], data[8]));
frame.name = name;
// 此九宫格边距是编辑器中的 UI 设置,不包含在 TexturePacker 的 plist 中。
if (atlas === "door" && name === "turn_bg") frame.insetLeft = frame.insetRight = 30;
frames.set(name, frame);
return frame;
}
/** 当前关、转场中的旧关图片,以及按设备内存策略驻留的公共图集。 */
getLoadedPaths(): string[] {
return Array.from(this.frames.keys()).sort();
}
private releaseUnused(): void {
this.frames.forEach((frame, path) => {
if (this.retainGameplayAtlases && isGameplayAtlas(path)) return;
if (this.windowPaths.has(path) || this.committedPaths.has(path) || this.activePaths.has(path)) return;
this.frames.delete(path);
this.atlasLayouts.delete(path);
const children = this.atlasFrames.get(path);
if (children) {
children.forEach(child => child.destroy());
this.atlasFrames.delete(path);
}
// 动态 SpriteFrame 没有导入依赖表,必须显式释放持有的纹理引用。
const texture = frame.getTexture();
frame.destroy();
texture.decRef();
this.clearImageCaches(path);
});
}
private loadFrame(path: string): Promise<void> {
if (this.frames.has(path) || this.loading.has(path)) {
if (this.reportSource) this.reportSource(path, this.getImageSource(path));
return this.loading.get(path) || Promise.resolve();
}
const promise = (async () => {
let layout: GameplayAtlasLayout = null;
if (isGameplayAtlas(path)) {
const plistPath = path + ".plist";
const asset = await this.loadAssetWithRetry<cc.Asset>(plistPath, asset => {
layout = parseGameplayAtlas((asset as any)._nativeAsset, path);
});
// 只保留解析后的坐标表,不让 plist 的引擎 Asset 和 XML 常驻内存。
cc.assetManager.releaseAsset(asset);
this.clearImageCaches(plistPath);
}
const texture = await this.loadAssetWithRetry<cc.Texture2D>(path, texture => {
if (layout && (texture.width !== layout.width || texture.height !== layout.height)) {
throw new Error("PNG 尺寸与同版本 plist 不一致: " + path);
}
});
// 在任何 Sprite 使用前禁用合图,确保独立小图可以独立释放。
texture.packable = false;
texture.addRef();
const trim = BLOCK_TEXTURE_TRIMS[path];
// 保留原图的裁剪布局;换图时需保持画布尺寸和透明边距兼容。
const frame = trim && texture.width === trim[6] && texture.height === trim[7]
? new cc.SpriteFrame(texture, cc.rect(trim[0], trim[1], trim[2], trim[3]), false,
cc.v2(trim[4], trim[5]), cc.size(trim[6], trim[7]))
: new cc.SpriteFrame(texture);
frame.name = path;
if (layout) this.atlasLayouts.set(path, layout);
this.frames.set(path, frame);
this.releaseUnused();
})().then(() => {
this.loading.delete(path);
}, error => {
this.loading.delete(path);
throw error;
});
this.loading.set(path, promise);
return promise;
}
private async loadAssetWithRetry<T extends cc.Asset>(path: string, validate: (asset: T) => void): Promise<T> {
const url = this.imageUrl(path);
const load = async (reload: boolean): Promise<T> => {
const source: ImageSource = reload ? "远程下载" : this.getImageSource(path);
if (this.reportSource) this.reportSource(path.replace(/\.plist$/, ""), source);
const remote = source === "远程下载";
// if (remote) console.log(`[Block图片][远程加载] ${this.backgroundDownloads.has(path) ? "等待后台下载" : "开始"} `
// + `${path}.png,进关${reload ? "重试" : ""},${url}`);
try {
let loadUrl = source === "本地缓存" ? this.getPersistentFile(path)
: source === "临时缓存" ? this.getTempFile(path) : url;
if (remote && this.canUseUserCache()) {
await this.downloadToUserCache(path, 10);
loadUrl = this.userFilePath(path);
}
const asset = await new Promise<T>((resolve, reject) => {
const direct = loadUrl !== url;
cc.assetManager.loadRemote(loadUrl,
{
ext: path.endsWith(".plist") ? ".plist" : ".png", priority: 10,
cacheEnabled: !direct, reload: !direct && reload,
__cacheBundleRoot__: direct ? undefined : "Block",
maxConcurrency: BlockAssetManager.FOREGROUND_DOWNLOAD_LIMIT,
// 本地读取失败由外层显式重试;直存下载失败也只立即重试一次。
maxRetryCount: direct ? 0 : remote ? cc.assetManager.downloader.maxRetryCount : 0
},
(err, result: T) => err || !result
? reject(err || new Error("Block 图片为空: " + path)) : resolve(result));
});
// if (remote) console.log(`[Block图片][远程加载] 成功 ${path}.png,已直存并加载,${url}`);
try {
validate(asset);
} catch (error) {
cc.assetManager.releaseAsset(asset);
throw error;
}
return asset;
} catch (error) {
// if (remote) console.log(`[Block图片][远程加载] 失败 ${path}.png,${url}`, error);
throw error;
}
};
try {
return await load(false);
} catch (error) {
// 缓存损坏也要重下,不能仅凭文件存在/完成标记一直命中坏文件。
// console.log(`[Block图片][加载重试] ${path}.png,清理无效缓存后从远程重试`, error);
this.invalidateFile(path);
return load(true);
}
}
private getFileSystem(): any {
if (typeof wx !== "undefined" && wx && wx.getFileSystemManager) return wx.getFileSystemManager();
if (typeof tt !== "undefined" && tt && tt.getFileSystemManager) return tt.getFileSystemManager();
return null;
}
private getDownloadSdk(): any {
if (typeof wx !== "undefined" && wx && wx.downloadFile) return wx;
if (typeof tt !== "undefined" && tt && tt.downloadFile) return tt;
return null;
}
private userCacheRoot(): string {
const sdk = this.getDownloadSdk();
const root = sdk && sdk.env && sdk.env.USER_DATA_PATH;
return typeof root === "string" && root ? root.replace(/\/$/, "") + "/Block/" + this.version : "";
}
private userFilePath(path: string): string {
const root = this.userCacheRoot();
return root ? root + "/" + (path.endsWith(".plist") ? path : path + ".png") : "";
}
private canUseUserCache(): boolean {
const fs = this.getFileSystem();
return !!(this.version && this.userCacheRoot() && fs && fs.mkdirSync && fs.renameSync && fs.unlinkSync);
}
private ensureUserCacheFolder(): void {
const fs = this.getFileSystem();
const folder = this.userCacheRoot();
if (!folder || !fs) return;
try {
if (fs.statSync(folder).isDirectory()) return;
} catch (error) { /* missing */ }
try {
fs.mkdirSync(folder, true);
} catch (error) {
// 并发创建时允许另一个调用先成功;权限/同名文件错误必须继续抛出。
try { if (fs.statSync(folder).isDirectory()) return; } catch (ignored) { /* keep original */ }
// console.log(`[Block图片][用户缓存] 创建目录失败 ${folder}`, error);
throw error;
}
}
private fileExists(path: string): boolean {
if (typeof path !== "string" || !path) return false;
try {
const stat = this.getFileSystem().statSync(path);
return stat.isFile() && stat.size > 0;
} catch (error) {
return false;
}
}
private imageUrl(path: string): string {
if (this.paths.indexOf(path) < 0) throw new Error("未知的 Block 图片: " + path);
return this.imageBase + (path.endsWith(".plist") ? path : path + ".png") + "?v=" + this.version;
}
private getPersistentFile(path: string): string {
const direct = this.userFilePath(path);
if (this.fileExists(direct)) return direct;
const cache = cc.assetManager.cacheManager as any;
if (!cache || !this.getFileSystem()) return "";
const url = this.imageUrl(path);
const local = cache.getCache(url);
if (this.fileExists(local)) return local;
if (local) {
cache.removeCache(url);
this.clearDownloadedFile(url);
}
return "";
}
private hasLocalFile(path: string): boolean {
const cache = cc.assetManager.cacheManager as any;
if (!this.getFileSystem()) return false;
if (this.getPersistentFile(path)) return true;
if (!cache) return false;
const url = this.imageUrl(path);
const temp = cache.getTemp(url);
if (temp && !this.fileExists(temp)) {
cache.tempFiles.remove(url);
this.clearDownloadedFile(url);
}
return false;
}
private getTempFile(path: string): string {
if (!this.getFileSystem()) return "";
const cache = cc.assetManager.cacheManager as any;
const url = this.imageUrl(path);
const temp = cache && cache.getTemp(url);
if (this.fileExists(temp)) return temp;
const native = (cc.assetManager as any)._files.get(url + "@native");
if (this.fileExists(native)) return native;
if (native) this.clearDownloadedFile(url);
return "";
}
private getImageSource(path: string): ImageSource {
if (this.frames.has(path)) return "内存复用";
if (this.hasLocalFile(path)) return "本地缓存";
if (this.getTempFile(path)) return "临时缓存";
return "远程下载";
}
private invalidateFile(path: string): void {
const url = this.imageUrl(path);
this.deleteFile(this.userFilePath(path));
this.deleteFile(this.userFilePath(path) + ".tmp");
const cache = cc.assetManager.cacheManager as any;
if (cache) {
cache.removeCache(url);
cache.tempFiles && cache.tempFiles.remove(url);
}
this.clearImageCaches(path);
}
private deleteFile(path: string): void {
if (!path) return;
try { this.getFileSystem().unlinkSync(path); } catch (error) { /* already absent */ }
}
private clearImageCaches(path: string): void {
const url = this.imageUrl(path);
this.clearDownloadedFile(url);
this.clearDownloadedFile(this.userFilePath(path));
}
private clearDownloadedFile(url: string): void {
// 2.4.15 运行时提供这两个缓存,项目的 creator.d.ts 未声明它们。
const manager = cc.assetManager as any;
if (!url) return;
manager._files && manager._files.remove(url + "@native");
manager._parsed && manager._parsed.remove(url + "@native");
}
/** 同一路径共享任务;排队中的后台任务被进关请求命中时立即提升优先级。 */
private downloadToUserCache(path: string, priority: number): Promise<void> {
if (this.fileExists(this.userFilePath(path))) return Promise.resolve();
const existing = this.fileDownloads.get(path);
if (existing) {
if (priority > existing.job.priority) existing.job.priority = priority;
if (priority > 0 && !existing.job.started) existing.job.background = false;
this.pumpDownloadQueue();
return existing.promise;
}
this.ensureUserCacheFolder();
let resolve: () => void;
let reject: (error: any) => void;
const promise = new Promise<void>((ok, fail) => { resolve = ok; reject = fail; });
const job: BlockDownloadJob = {
path, priority, background: priority < 0, started: false,
resolve: resolve!, reject: reject!
};
this.fileDownloads.set(path, { promise, job });
this.downloadQueue.push(job);
this.pumpDownloadQueue();
return promise;
}
private cacheFileToUserPath(path: string, priority: number): Promise<void> {
const target = this.userFilePath(path);
if (this.fileExists(target)) return Promise.resolve();
if (this.fileDownloads.has(path)) return this.downloadToUserCache(path, priority);
const cached = this.getPersistentFile(path) || this.getTempFile(path);
const fs = this.getFileSystem();
if (cached && cached !== target && fs && fs.copyFile) {
return this.copyToUserCache(path, cached);
}
return this.downloadToUserCache(path, priority);
}
private pumpDownloadQueue(): void {
this.downloadQueue.sort((a, b) => b.priority - a.priority);
while (this.activeFileDownloads < BlockAssetManager.FOREGROUND_DOWNLOAD_LIMIT) {
const index = this.downloadQueue.findIndex(job => !job.background
|| (this.foregroundLoads === 0 && this.entryDownloadHolds === 0
&& this.activeBackgroundDownloads < BlockAssetManager.BACKGROUND_CONCURRENCY));
if (index < 0) return;
const job = this.downloadQueue.splice(index, 1)[0];
job.started = true;
this.activeFileDownloads++;
if (job.background) this.activeBackgroundDownloads++;
this.startUserDownload(job);
}
}
private startUserDownload(job: BlockDownloadJob): void {
const sdk = this.getDownloadSdk();
const fs = this.getFileSystem();
const url = this.imageUrl(job.path);
const target = this.userFilePath(job.path);
const temporary = target + ".tmp";
this.deleteFile(temporary);
let finished = false;
const finish = (error?: any) => {
if (finished) return;
finished = true;
this.activeFileDownloads--;
if (job.background) this.activeBackgroundDownloads--;
this.fileDownloads.delete(job.path);
if (error) {
this.deleteFile(temporary);
job.reject(error);
} else {
job.resolve();
}
this.pumpDownloadQueue();
};
try {
sdk.downloadFile({
url, filePath: temporary,
success: (response: any) => {
try {
if (!response || response.statusCode !== 200 || !this.fileExists(temporary)) {
throw new Error("Block 图片 HTTP " + (response && response.statusCode));
}
this.deleteFile(target);
fs.renameSync(temporary, target);
if (!this.fileExists(target)) throw new Error("Block 图片直存失败: " + target);
finish();
} catch (error) { finish(error); }
},
fail: (error: any) => finish(error || new Error("Block 图片下载失败: " + job.path))
});
} catch (error) { finish(error); }
}
/** 兼容升级前已经下载到 Cocos 临时目录的文件,直接复制到用户版本目录。 */
private copyToUserCache(path: string, source: string): Promise<void> {
if (this.fileExists(this.userFilePath(path))) return Promise.resolve();
this.ensureUserCacheFolder();
const fs = this.getFileSystem();
const target = this.userFilePath(path);
const temporary = target + ".tmp";
this.deleteFile(temporary);
return new Promise<void>((resolve, reject) => fs.copyFile({
srcPath: source, destPath: temporary,
success: () => {
try {
this.deleteFile(target);
fs.renameSync(temporary, target);
if (!this.fileExists(target)) throw new Error("Block 临时图片迁移失败: " + target);
resolve();
} catch (error) { this.deleteFile(temporary); reject(error); }
},
fail: (error: any) => { this.deleteFile(temporary); reject(error); }
}));
}
startBackgroundCache(): void {
this.runBackgroundCache();
}
private runBackgroundCache(): void {
// 浏览器不做全量预缓存;小游戏必须支持用户目录直存。
const hasFileSystem = !!this.getFileSystem();
const hasUserCache = !!this.getDownloadSdk();
if (!hasFileSystem || !hasUserCache) {
// console.log(`[Block图片][后台缓存] 跳过:用户缓存环境不可用,下载接口=${hasUserCache},文件系统=${hasFileSystem}`);
return;
}
if (!this.backgroundStarted) {
this.backgroundStarted = true;
cc.game.on(cc.game.EVENT_SHOW, this.startBackgroundCache, this);
}
if (this.backgroundRunning) {
// console.log("[Block图片][后台缓存] 任务正在运行,不重复启动");
return;
}
if (this.backgroundTimer) clearTimeout(this.backgroundTimer);
this.backgroundTimer = null;
this.backgroundRunning = true;
// console.log(`[Block图片][后台缓存] 启动,资源清单共${this.paths.length}张,补齐缺失图片`);
this.cacheAllFiles().catch(error => {
// console.log("[Block图片][后台缓存] 任务失败,稍后重试", error);
// cc.warn("Block 后台缓存未完成,稍后重试:", error);
return null;
}).then(result => {
this.backgroundRunning = false;
if (result && result.complete) {
// console.log("[Block图片][后台缓存] 本轮结束,全部已持久缓存");
return;
}
const reason = !result ? "任务异常" : result.missing ? `仍缺少${result.missing}张图片`
: `${result.temporary}张旧临时文件待迁移`;
// console.log(`[Block图片][后台缓存] 本轮结束,${reason},10秒后重试`);
this.backgroundTimer = setTimeout(() => this.runBackgroundCache(), BlockAssetManager.BACKGROUND_RETRY_MS);
});
}
private async cacheAllFiles(): Promise<BlockCacheCheck> {
await this.initialize();
if (!this.canUseUserCache()) throw new Error("Block 用户缓存环境不可用");
this.ensureUserCacheFolder();
const paths = this.paths;
const checked = await this.saveCacheState(paths);
if (checked.complete) {
// console.log("[Block图片][后台缓存] 全部本地缓存有效,无需下载");
return checked;
}
const concurrency = BlockAssetManager.BACKGROUND_CONCURRENCY;
// console.log(`[Block图片][后台缓存] 滚动下载队列,最多${concurrency}张并发,进关期间停止派发`);
let index = 0;
let paused = false;
const worker = async (batch: string[]) => {
while (index < batch.length) {
while (this.foregroundLoads > 0 || this.entryDownloadHolds > 0) {
// if (!paused) console.log("[Block图片][后台缓存] 暂停派发,优先等待进关图片,已发出的请求继续完成");
paused = true;
await this.delay(100);
}
if (paused) {
// console.log("[Block图片][后台缓存] 进关图片处理结束,继续后台缓存");
paused = false;
}
// 每次只领取一张;完成一张就补一张,等待前台期间其他 worker 可能已取完队列。
if (index >= batch.length) return;
const path = batch[index++];
if (this.hasLocalFile(path)) continue;
const url = this.imageUrl(path);
const temp = this.getTempFile(path);
const fs = this.getFileSystem();
if (temp && fs && fs.copyFile) {
try {
await this.copyToUserCache(path, temp);
// console.log(`[Block图片][用户缓存] 已迁移临时文件 ${path}.png -> ${this.userFilePath(path)}`);
} catch (error) {
// console.log(`[Block图片][用户缓存] 临时文件迁移失败 ${path}.png`, error);
// cc.warn("Block 临时图片迁移失败:", path, error);
}
continue;
}
// 极旧环境没有 copyFile 时不依赖 Cocos 临时缓存,直接补一份用户目录文件。
this.backgroundDownloads.add(path);
// console.log(`[Block图片][远程下载] 开始 ${path}.png,后台缓存,在途=${this.backgroundDownloads.size}/${concurrency},${url}`);
try {
await this.downloadToUserCache(path, -10);
// console.log(`[Block图片][远程下载] 成功 ${path}.png,已直存=${this.userFilePath(path)},${url}`);
} catch (error) {
// console.log(`[Block图片][远程下载] 失败 ${path}.png,${url}`, error);
// cc.warn("Block 图片缓存失败,保留未完成状态:", path, error);
} finally {
this.backgroundDownloads.delete(path);
}
await this.delay(0);
}
};
const isAtlasFile = (path: string) => isGameplayAtlas(path.replace(/\.plist$/, ""));
const groups = [paths.filter(isAtlasFile), paths.filter(path => !isAtlasFile(path))];
for (const batch of groups) {
index = 0;
await Promise.all(Array.from({ length: concurrency }, () => worker(batch)));
// 图集尚未落盘时先按原有机制重试,不抢先下载普通方块。
if (batch.some(path => isAtlasFile(path) && !this.hasLocalFile(path))) break;
}
return this.saveCacheState(paths);
}
private async saveCacheState(paths: string[]): Promise<BlockCacheCheck> {
let cached = 0;
let temporary = 0;
let missing = 0;
for (let i = 0; i < paths.length; i++) {
if (this.hasLocalFile(paths[i])) cached++;
else if (this.getTempFile(paths[i])) temporary++;
else missing++;
// 分批检查,避免一次同步扫描全部文件阻塞游戏帧。
if (i % 8 === 7) await this.delay(0);
}
const complete = paths.length > 0 && cached === paths.length;
this.writeCacheState(paths.length, cached, complete);
console.log(`[Block图片][缓存校验] 版本=${this.version},用户目录=${this.userCacheRoot()},有效持久文件=${cached}/${paths.length},完整落盘=${complete},临时文件=${temporary},缺失=${missing}`);
return { cached, temporary, missing, complete };
}
private writeCacheState(total: number, cached: number, complete: boolean): void {
const state = JSON.stringify({
version: this.version, baseUrl: this.imageBase,
total, cached, complete
});
try {
if (cc.sys.localStorage.getItem(BlockAssetManager.CACHE_KEY) !== state) {
cc.sys.localStorage.setItem(BlockAssetManager.CACHE_KEY, state);
}
} catch (error) {
// cc.warn("Block 缓存状态保存失败:", error);
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}