import { allBlockTexturePaths, BLOCK_TEXTURE_TRIMS, collectBlockTexturePaths } from "./BlockTexturePaths"; declare const wx: any; declare const tt: any; type ImageSource = "内存复用" | "本地缓存" | "临时缓存" | "远程下载"; interface BlockWindowOptions { onRemoteRequired?: () => void; currentLevel?: number; nextLevel?: number; stage?: string; } 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(); private frames = new Map(); 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 foregroundLoads = 0; private backgroundRunning = false; private backgroundTimer: any = null; private backgroundStarted = false; private initialize(): Promise { if (!this.ready) { 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) cc.warn("Block 版本检查失败,本次使用已知版本:", fallback, error); // 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[], next: any[], options: BlockWindowOptions = {}): Promise { const currentPaths = collectBlockTexturePaths(current); const nextPaths = collectBlockTexturePaths(next); const paths = new Set(currentPaths.concat(nextPaths)); const id = ++this.requestId; const stage = options.stage || "进关"; const currentLabel = "当前关" + (options.currentLevel || ""); const nextLabel = "下一关" + (options.nextLevel || ""); // 同时存在多个进关请求时,只通知最新窗口;共享加载失败后的远程重试也走这里。 this.reportSource = (path, source) => { if (id !== this.requestId || !paths.has(path)) return; const levels = [currentPaths.indexOf(path) >= 0 ? currentLabel : "", nextPaths.indexOf(path) >= 0 ? nextLabel : ""].filter(Boolean).join(" / "); // console.log(`[Block图片][${stage}来源] ${levels} ${path}.png -> ${source},版本=${this.version}`); if (source === "远程下载" && options.onRemoteRequired) options.onRemoteRequired(); }; this.windowPaths = paths; this.foregroundLoads++; this.releaseUnused(); try { await this.initialize(); if (id !== this.requestId) return false; this.ensureUserCacheFolder(); // console.log(`[Block图片][${stage}检查] ${currentLabel} / ${nextLabel},版本=${this.version},共${paths.size}张`); const names = Array.from(paths); // 限制前台并发,后台队列在此期间让出下载机会。 for (let i = 0; i < names.length; i += 4) { if (id !== this.requestId) return false; const results = await Promise.all(names.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; return true; } catch (error) { if (id !== this.requestId) return false; this.windowPaths = this.committedPaths; throw error; } finally { if (id === this.requestId) this.reportSource = null; this.foregroundLoads--; this.pumpDownloadQueue(); this.releaseUnused(); } } activateScene(blocks: any[]): void { const paths = collectBlockTexturePaths(blocks); paths.forEach(path => this.getFrame(path)); this.activePaths = new Set(paths); this.releaseUnused(); } releaseScene(): void { this.activePaths.clear(); this.releaseUnused(); } getFrame(path: string): cc.SpriteFrame { const frame = this.frames.get(path); if (!frame || !cc.isValid(frame)) throw new Error("方块图片尚未就绪: " + path); return frame; } /** 调试时可核对是否只有当前关、下一关,以及尚未销毁的旧场景资源。 */ getLoadedPaths(): string[] { return Array.from(this.frames.keys()).sort(); } private releaseUnused(): void { this.frames.forEach((frame, path) => { if (this.windowPaths.has(path) || this.committedPaths.has(path) || this.activePaths.has(path)) return; this.frames.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 = this.loadTextureWithRetry(path).then(texture => { // 在任何 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; 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 loadTextureWithRetry(path: string): 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, 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 texture = await new Promise((resolve, reject) => { const direct = loadUrl !== url; cc.assetManager.loadRemote(loadUrl, { ext: ".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: cc.Texture2D) => err || !result ? reject(err || new Error("Block 图片为空: " + path)) : resolve(result)); }); // if (remote) console.log(`[Block图片][远程加载] 成功 ${path}.png,已直存并加载,${url}`); return texture; } 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 + ".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 + ".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 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 () => { while (index < paths.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 >= paths.length) return; const path = paths[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); } }; await Promise.all(Array.from({ length: concurrency }, () => worker())); 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)); } }