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 = null; private version: string = null; private imageBase = ""; private paths = allBlockTexturePaths().concat(GAMEPLAY_ATLAS_PATHS.map(name => name + ".plist")); private frames = new Map(); private atlasFrames = new Map>(); private atlasLayouts = new Map(); private retainGameplayAtlases = false; private loading = new Map>(); private fileDownloads = new Map, job: BlockDownloadJob }>(); private downloadQueue: BlockDownloadJob[] = []; private activeFileDownloads = 0; private activeBackgroundDownloads = 0; private reportSource: (path: string, source: ImageSource) => void = null; private backgroundDownloads = new Set(); private windowPaths = new Set(); private committedPaths = new Set(); private activePaths = new Set(); private requestId = 0; private windowRequestId = 0; private committedRequestId = 0; private activeRequestId = 0; private homeWarmupRequestId = 0; private foregroundLoads = 0; private backgroundRunning = false; private backgroundTimer: any = null; private backgroundStarted = false; private initialize(): Promise { 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 { 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(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 { // 正式进关窗口在图片就绪、等待切场景时仍有效,首页预热不能抢占。 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 { await this.initialize(); 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) { for (let i = 0; i < group.length; i += 4) { if (id !== this.requestId) return false; const results = await Promise.all(group.slice(i, i + 4).map(name => this.loadFrame(name).then(() => null, error => error))); const error = results.find(result => result != null); if (error) throw error; } } 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(); this.windowRequestId = 0; } if (releasedRequestId && this.committedRequestId === releasedRequestId) { this.committedPaths = new Set(); this.committedRequestId = 0; } this.releaseUnused(); } /** HomeScene 只把当前关文件提权写入 USER_DATA_PATH,不创建 Texture/SpriteFrame。 */ async cacheWindowFiles(current: any[], extraPaths: string[] = []): Promise { 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 { 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(plistPath, asset => { layout = parseGameplayAtlas((asset as any)._nativeAsset, path); }); // 只保留解析后的坐标表,不让 plist 的引擎 Asset 和 XML 常驻内存。 cc.assetManager.releaseAsset(asset); this.clearImageCaches(plistPath); } const texture = await this.loadAssetWithRetry(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(path: string, validate: (asset: T) => void): Promise { const url = this.imageUrl(path); const load = async (reload: boolean): Promise => { 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((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 { 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((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 { 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.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 { 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((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 { 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) { // 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 { 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 { return new Promise(resolve => setTimeout(resolve, ms)); } }