import { _decorator, Asset, AssetManager, assetManager, Component, director, instantiate, Node, ParticleSystem2D, Prefab, sp, SpriteFrame } from 'cc'; import { DEV } from 'cc/env'; import { Singleton } from '../../tools/Singleton'; import { ResPkgName } from './ResPkgName'; import { BundleLifecycle } from './BundleLifecycle'; import { formatLoadKey, LoadRecoveryConfig, type LoadResult } from './LoadRecovery'; import { createMembershipSet, membershipAdd, membershipToArray, mapKeysToArray, type MembershipSet, } from '../../tools/MembershipSet'; const { ccclass, property } = _decorator; /**资源信息 */ export interface ResInfo { /**资源名称 */ name: string, /**资源所在bundle */ bundle: string, /**资源路径 */ path: string, /**资源类型 */ type: typeof Asset /**资源实例 */ res?: Asset } /** 目录批量加载(bundle.loadDir,避免数十条并行单资源 load 在预览/弱机失败) */ export interface ResDirInfo { /** bundle 名 */ bundle: string; /** 目录路径(如 prefab/hero) */ path: string; /** 资源类型 */ type: typeof Asset; } /**资源包 */ export interface ResPakage { /**资源包名称 */ name: string, /**是否可释放 */ isReleaseable: boolean, /**资源列表 */ ress: ResInfo[], /** 可选:整目录加载条目(与 ress 并列计入进度) */ dirs?: ResDirInfo[], } @ccclass('ResourcesManager') export class ResourcesManager extends Singleton { /**节点池 */ private _nodePool: Map = new Map(); /**资源缓存 */ private _resCache: Map = new Map(); /**预加载登记(preLoad 命中后 ensure 时晋升) */ private _preloadedKeys: MembershipSet = createMembershipSet(); /** 同路径 bundle.load 合并,超时不拆原生请求 */ private _assetLoadWait: Record | undefined> = {}; /** 同目录 bundle.loadDir 合并 */ private _dirLoadWait: Record | undefined> = {}; /** 同名资源包加载代数:clearResPkg/重复 load 时递增,旧链 finishOne 不得再写 cache */ private _pkgLoadGen: Record = {}; assetKey(name: string, path: string, bundle: string, type: typeof Asset): string { const n = type === SpriteFrame ? name + "/spriteframe" : name; return `${bundle}|${path}|${n}|${type.name}`; } isPreloaded(name: string, path: string, bundle: string, type: typeof Asset): boolean { return this._preloadedKeys.has(this.assetKey(name, path, bundle, type)); } /**获取资源 * @param path 资源路径 * @param bundle 资源所在bundle * @returns 资源 */ getRes(name: string, path: string, bundle: string, type: typeof Asset): T extends Asset ? T : null { if (type == SpriteFrame) { name = name + "/spriteframe"; } for (let resArr of this._resCache.values()) { for (let i = 0; i < resArr.ress.length; i++) { let resInfo = resArr.ress[i]; if (resInfo.name == name && resInfo.path == path && resInfo.bundle == bundle && resInfo.type == type) { if (resInfo.res?.isValid) return resInfo.res as T extends Asset ? T : null; resInfo.res = null; } } } let b = assetManager.getBundle(bundle); if (b) { const assetPath = path + name; const res = this._validBundleAsset(b, assetPath, type); if (res) { return res as T extends Asset ? T : null; } } return null; } /**获取节点(从节点池获取,没有就从预制件缓存实例化) */ getNode(path: string, name: string, bundle: string) { let key = bundle + path + name; try { let pool = this._nodePool.get(key); if (!pool) { pool = []; this._nodePool.set(key, pool); } for (let attempt = 0; attempt < 8; attempt++) { while (pool.length > 0 && !pool[0]?.isValid) { pool.shift(); } if (pool.length <= 0) { let p = this.getRes(name, path, bundle, Prefab) as Prefab; if (p?.isValid) { let n = instantiate(p); n["resKey"] = key; this._markPoolNodeBaseline(n); pool.push(n); } } if (pool.length <= 0) return null; const node = pool.shift(); if (!node?.isValid) continue; // 仍挂在场景上的池条目:曾 reparent 回场景却未出池,出池会把在用字块摘走 if (node.parent?.isValid) { continue; } node.active = true; this._restorePoolNodeBaseline(node, name, path, bundle); node.setPosition(0, 0, 0); node.angle = 0; for (const sk of node.getComponentsInChildren(sp.Skeleton)) { if (!sk?.isValid) continue; sk.enabled = true; this._resetPooledSpine(sk); } return node; } return null; } catch (error) { console.error("UIBase getNode error", key, String(error)); return null; } } /** * 节点已重新挂回场景时,从池数组摘掉,避免下次 getNode 偷走在用节点。 * @param node 可能仍留在池里的场景节点 */ detachFromPool(node: Node | null | undefined): void { if (!node?.isValid) return; const key = node['resKey'] as string | undefined; if (!key) return; const pool = this._nodePool.get(key); if (!pool?.length) return; for (let i = pool.length - 1; i >= 0; i--) { if (pool[i] === node) { pool.splice(i, 1); } } } /** * 出池/回池重置 Spine。缓存模式不能 clearTracks,须先退回 REALTIME。 * @param sk Spine 组件 */ private _resetPooledSpine(sk: sp.Skeleton): void { try { if (typeof sk.isAnimationCached === 'function' && sk.isAnimationCached()) { sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); } sk.clearTracks(); sk.setCompleteListener(null); sk.setToSetupPose?.(); sk.setSlotsToSetupPose?.(); } catch (_) { /* bundle 已卸载 */ } } /** 记录预制体根节点设计 scale(如睡觉=0.5);出池时恢复,禁止统一洗成 1 */ private _markPoolNodeBaseline(node: Node): void { if (node["poolBaseScaleX"] == null) { node["poolBaseScaleX"] = node.scale.x; node["poolBaseScaleY"] = node.scale.y; } } private _readPrefabRootScale(name: string, path: string, bundle: string): { x: number; y: number } | null { const p = this.getRes(name, path, bundle, Prefab) as Prefab | null; if (!p?.isValid || !p.data?.isValid) return null; const x = p.data.scale.x; const y = p.data.scale.y; if (Math.abs(x - 1) < 0.01 && Math.abs(y - 1) < 0.01) return null; return { x, y }; } /** 出池恢复预制体根 scale;优先读 Prefab 设计值,纠正旧池污染为 1 的节点 */ private _restorePoolNodeBaseline(node: Node, prefabName: string, path: string, bundle: string): void { const design = this._readPrefabRootScale(prefabName, path, bundle); const storedX = node["poolBaseScaleX"] as number | undefined; if (design) { if ( storedX == null || (Math.abs(storedX - 1) < 0.01 && Math.abs(design.x - 1) > 0.01) ) { node["poolBaseScaleX"] = design.x; node["poolBaseScaleY"] = design.y; } } else if (storedX == null) { this._markPoolNodeBaseline(node); } const sx = node["poolBaseScaleX"] ?? 1; const sy = node["poolBaseScaleY"] ?? 1; node.setScale(sx, sy, 1); } /**回收节点 */ putNode(node: Node) { let psss = node.getComponentsInChildren(ParticleSystem2D); for (let ps of psss) { ps.stopSystem(); } const sks = node.getComponentsInChildren(sp.Skeleton); for (const sk of sks) { if (!sk?.isValid) continue; sk.enabled = false; this._resetPooledSpine(sk); } let pool = this._nodePool.get(node["resKey"]); if (!pool) { node.destroy(); } else { // 回池前必须失活:否则挂回场景时会以脏 Sprite 状态触发 onEnable / 合批刷新 node.active = false; node.removeFromParent(); // 避免同一节点重复入池(曾挂回场景却未 detachFromPool) for (let i = pool.length - 1; i >= 0; i--) { if (pool[i] === node) pool.splice(i, 1); } pool.push(node); } } /**加载资源包 * @param resPkg 资源数组 * @param onProgress 进度回调 * @param onComplete 完成回调 * @param fresh true 时跳过 bundle 内已缓存资源,强制 bundle.load(退战斗/预载 invalidate 后进战斗须用) */ loadResPakg( resPkg: ResPakage, onProgress: Function = null, onComplete: ((result: LoadResult) => void) | null = null, fresh = false, ) { // 同名资源包重复加载前先释放旧引用,避免 _resCache 覆盖导致引用丢失。 this.clearResPkg(resPkg.name); if (fresh) { this.evictPackageFromBundles(resPkg); } const gen = ((this._pkgLoadGen[resPkg.name] ?? 0) + 1); this._pkgLoadGen[resPkg.name] = gen; this._resCache.set(resPkg.name, resPkg); const dirList = resPkg.dirs ?? []; const totalCount = resPkg.ress.length + dirList.length; let loadCount = 0; const failed: string[] = []; let staleCompleted = false; const isStale = (): boolean => this._pkgLoadGen[resPkg.name] !== gen; /** 旧包被 clear/重载:必须立刻 settle,否则调用方 Promise 永挂 */ const completeIfStale = (): void => { if (!isStale() || staleCompleted) return; staleCompleted = true; if (onComplete) onComplete({ ok: false, failed: ['pkg_replaced'] }); }; if (totalCount <= 0) { if (onComplete) onComplete({ ok: true, failed: [] }); return; } const finishOne = () => { completeIfStale(); if (staleCompleted) return; loadCount++; if (onProgress) onProgress(loadCount, totalCount); if (loadCount >= totalCount) { const result: LoadResult = { ok: failed.length === 0, failed }; if (!result.ok) { console.error('[ResourcesManager] loadResPakg partial fail', resPkg.name, failed); } if (onComplete) onComplete(result); } }; for (let i = 0; i < resPkg.ress.length; i++) { const conf = resPkg.ress[i]; if (!conf) { finishOne(); continue; } if (typeof conf.name !== 'string' || !conf.name) { console.error('[ResourcesManager] skip invalid res name', conf.bundle, conf.path, conf.name); failed.push(formatLoadKey(conf.bundle ?? '', conf.path ?? '', String(conf.name))); finishOne(); continue; } const path = conf.path + conf.name; let res = this.getRes(conf.name, conf.path, conf.bundle, conf.type) as Asset | null; if (res && !res.isValid) res = null; if (res) { const cachedRes = res as Asset; cachedRes.addRef(); conf.res = cachedRes; finishOne(); continue; } this.loadBundle(conf.bundle, (b) => { completeIfStale(); if (staleCompleted) return; if (!b) { failed.push(formatLoadKey(conf.bundle, conf.path, conf.name)); finishOne(); return; } this.loadRes(b, path, (loaded) => { completeIfStale(); if (staleCompleted) { // 旧包已被 clear:丢弃本条,避免 decRef 后仍挂在 conf.res if (loaded) loaded.decRef(); return; } if (loaded) { loaded.addRef(); conf.res = loaded; } else { failed.push(formatLoadKey(conf.bundle, conf.path, conf.name)); } finishOne(); }, conf.type, fresh); }); } for (const dir of dirList) { this.loadBundle(dir.bundle, (b) => { completeIfStale(); if (staleCompleted) return; if (!b) { failed.push(formatLoadKey(dir.bundle, dir.path, '*')); finishOne(); return; } this._loadDirAssets(b, dir.path, dir.type, fresh, (ok) => { completeIfStale(); if (staleCompleted) return; if (!ok) { failed.push(formatLoadKey(dir.bundle, dir.path, '*')); } finishOne(); }); }); } } /** * 从 bundle 缓存剔除资源包内条目(clearResPkg / invalidate 后、fresh load 前)。 * 避免 loadResPakg 命中 bundle.get 返回已 release 的 SpriteFrame → getGFXTexture 空引用。 * 局外常驻包(isReleaseable=false,如 HerosRes)持有的资源不释放,否则 getRes 仍返回脏 Prefab,instantiate 报 nil。 */ evictPackageFromBundles(resPkg: ResPakage): void { const residentKeys = this._residentAssetKeys(); const released = createMembershipSet(); for (const conf of resPkg.ress) { if (typeof conf.name !== 'string' || !conf.name) continue; const key = this.assetKey(conf.name, conf.path, conf.bundle, conf.type); if (residentKeys.has(key)) continue; const bundle = assetManager.getBundle(conf.bundle); if (!bundle) continue; let assetPath = conf.path + conf.name; if (conf.type === SpriteFrame) { assetPath = conf.path + conf.name + '/spriteframe'; } const asset = bundle.get(assetPath, conf.type) as Asset | null; if (asset && !released.has(asset)) { membershipAdd(released, asset); assetManager.releaseAsset(asset); } try { bundle.release(assetPath, conf.type); } catch (e) { if (DEV) console.log('[ResourcesManager] bundle.release failed', conf.bundle, assetPath, String(e)); } this._dropCachedAsset(conf.name, conf.path, conf.bundle, conf.type); this._preloadedKeys.delete(key); } for (const dir of resPkg.dirs ?? []) { const bundle = assetManager.getBundle(dir.bundle); if (!bundle) continue; try { bundle.release(dir.path, dir.type); } catch (e) { if (DEV) console.log('[ResourcesManager] bundle.release dir failed', dir.bundle, dir.path, String(e)); } } } /** 不可释放资源包里已登记的资源键(大厅 heros 字形等) */ private _residentAssetKeys(): MembershipSet { const keys = createMembershipSet(); for (const pkg of this._resCache.values()) { if (pkg.isReleaseable !== false) continue; for (const conf of pkg.ress) { if (typeof conf.name !== 'string' || !conf.name) continue; membershipAdd(keys, this.assetKey(conf.name, conf.path, conf.bundle, conf.type)); } } return keys; } /** 释放后清掉其它包里指向同一资源的缓存,避免 getRes 拿到已 release 的 Prefab */ private _dropCachedAsset(name: string, path: string, bundle: string, type: typeof Asset): void { for (const pkg of this._resCache.values()) { if (pkg.isReleaseable === false) continue; for (const conf of pkg.ress) { if (conf.name === name && conf.path === path && conf.bundle === bundle && conf.type === type) { conf.res = null; } } } } /** 单条资源预载(供战斗后台分帧队列使用,每次只发起一项 IO) */ preLoadOne(conf: ResInfo): Promise { return new Promise((resolve) => { if (typeof conf.name !== 'string' || !conf.name) { console.error('[ResourcesManager] skip invalid preload res name', conf.bundle, conf.path, conf.name); resolve(); return; } const path = conf.path + conf.name; const res = this.getRes(conf.name, conf.path, conf.bundle, conf.type); if (res) { resolve(); return; } this.loadBundle(conf.bundle, (b) => { if (!b) { resolve(); return; } this.preLoadRes(b, path, conf.type, (loaded) => { if (loaded) { loaded.addRef(); membershipAdd(this._preloadedKeys, this.assetKey(conf.name, conf.path, conf.bundle, conf.type)); } resolve(); }); }); }); } /**预加载资源包 */ preLoadResPakg(resPkg: ResPakage, onProgress: Function = null, onComplete: ((result: LoadResult) => void) | null = null) { const dirList = resPkg.dirs ?? []; let totalCount = resPkg.ress.length + dirList.length; let loadCount = 0; let allCount = totalCount; const failed: string[] = []; if (totalCount <= 0) { if (onComplete) onComplete({ ok: true, failed: [] }); return; } let fun = () => { loadCount++; if (onProgress) onProgress(loadCount, allCount); if (loadCount >= allCount) { if (onComplete) onComplete({ ok: failed.length === 0, failed }); } } for (let i = 0; i < resPkg.ress.length; i++) { let conf = resPkg.ress[i]; if (!conf) { fun(); continue; } if (typeof conf.name !== 'string' || !conf.name) { console.error('[ResourcesManager] skip invalid preload res name', conf.bundle, conf.path, conf.name); failed.push(formatLoadKey(conf.bundle ?? '', conf.path ?? '', String(conf.name))); fun(); continue; } let path = conf.path + conf.name; let res = this.getRes(conf.name, conf.path, conf.bundle, conf.type); if (res) { fun(); continue; } this.loadBundle(conf.bundle, (b) => { if (!b) { failed.push(formatLoadKey(conf.bundle, conf.path, conf.name)); fun(); return; } this.preLoadRes(b, path, conf.type, (loaded) => { if (loaded) { loaded.addRef(); membershipAdd(this._preloadedKeys, this.assetKey(conf.name, conf.path, conf.bundle, conf.type)); } else { failed.push(formatLoadKey(conf.bundle, conf.path, conf.name)); } fun(); }); }); } for (const dir of dirList) { this.loadBundle(dir.bundle, (b) => { if (!b) { failed.push(formatLoadKey(dir.bundle, dir.path, '*')); fun(); return; } this._loadDirAssets(b, dir.path, dir.type, false, (ok) => { if (!ok) { failed.push(formatLoadKey(dir.bundle, dir.path, '*')); } fun(); }); }); } } /**清理资源 */ clearResPkg(respkgName: ResPkgName | string) { let respkg = this._resCache.get(respkgName); if (!respkg) return; if (!respkg.isReleaseable) { return; } const bundleNames = createMembershipSet(); for (const res of respkg.ress) { if (typeof res.bundle === 'string' && res.bundle) { membershipAdd(bundleNames, res.bundle); } } for (const dir of respkg.dirs ?? []) { if (typeof dir.bundle === 'string' && dir.bundle) { membershipAdd(bundleNames, dir.bundle); } } // bundle 即将 decRef/unload:先销毁池中对应实例,避免复用已失效 SpriteFrame this.purgeNodePoolForBundles(membershipToArray(bundleNames)); for (let res of respkg.ress) { res.res?.decRef(); if (res.res && res.res.refCount <= 0) { res.res = null; } } const names = membershipToArray(bundleNames); for (let i = 0; i < names.length; i++) { BundleLifecycle.getInstance().releaseBundleOwner(names[i], 'res'); } this._resCache.delete(respkg.name); } /** 销毁指定 bundle 下节点池中的实例(bundle unload 前调用) */ purgeNodePoolForBundles(bundleNames: readonly string[]): void { const prefixes: string[] = []; for (let i = 0; i < bundleNames.length; i++) { if (bundleNames[i]) prefixes.push(bundleNames[i]); } prefixes.sort((a, b) => b.length - a.length); if (prefixes.length === 0) return; const poolKeys = mapKeysToArray(this._nodePool); for (let i = 0; i < poolKeys.length; i++) { const key = poolKeys[i]; const pool = this._nodePool.get(key); if (!pool) continue; const owner = prefixes.find((prefix) => key.startsWith(prefix)); if (!owner) continue; for (let n = 0; n < pool.length; n++) { const node = pool[n]; if (node?.isValid) node.destroy(); } this._nodePool.delete(key); } } /** * 释放资源包预热占用:丢掉 preload addRef,无人持有则剔除 bundle 缓存。 * 打开中的面板仍持有引用时不释放。 */ invalidatePreloadForPackage(resPkg: ResPakage): void { for (const conf of resPkg.ress) { if (typeof conf.name !== 'string' || !conf.name) continue; const key = this.assetKey(conf.name, conf.path, conf.bundle, conf.type); const hadPreload = this._preloadedKeys.delete(key); const bundle = assetManager.getBundle(conf.bundle); if (!bundle) continue; const assetPath = conf.path + conf.name; const asset = bundle.get(assetPath, conf.type) as Asset | null; if (!asset) continue; if (!asset.isValid) { this._evictInvalidAsset(bundle, assetPath, conf.type, asset); continue; } if (hadPreload) { asset.decRef(); if (asset.refCount > 0) asset.decRef(); } if (asset.refCount > 0) continue; try { bundle.release(assetPath, conf.type); } catch (e) { if (DEV) console.log('[ResourcesManager] bundle.release after invalidate', conf.bundle, assetPath, String(e)); } } } clearNodePool() { for (let pool of this._nodePool.values()) { for (let node of pool) { node.destroy(); } pool = []; } this._nodePool.clear(); } /** 内存诊断:已登记资源包与仍持有引用的资源数量 */ getMemoryDiagStats(): { pkgCount: number; assetCount: number; preloadedCount: number; nodePoolKeys: number; nodePoolNodes: number; } { let assetCount = 0; for (const pkg of this._resCache.values()) { for (const info of pkg.ress) { if (info.res) assetCount++; } } let nodePoolNodes = 0; for (const pool of this._nodePool.values()) { nodePoolNodes += pool.length; } return { pkgCount: this._resCache.size, assetCount, preloadedCount: this._preloadedKeys.size, nodePoolKeys: this._nodePool.size, nodePoolNodes, }; } loadBundle(bundleName, onComplete: Function = null, owner = 'res') { void BundleLifecycle.getInstance().ensureLoaded(bundleName).then((bundle) => { if (bundle) { BundleLifecycle.getInstance().acquire(bundleName, owner); } if (onComplete) onComplete(bundle); }); } /** 加载单个资源并 addRef(供 AssetService 晋升预加载资源) */ loadAsset( bundleName: string, path: string, name: string, type: typeof Asset, onComplete: (asset: Asset | null) => void, ): void { const cached = this.getRes(name, path, bundleName, type) as Asset | null; if (cached) { cached.addRef(); onComplete(cached); return; } this.loadBundle(bundleName, (b) => { if (!b) { onComplete(null); return; } const assetPath = path + name; this.loadRes(b, assetPath, (res: Asset | null) => { if (res) res.addRef(); onComplete(res); }, type); }); } /** * 加载资源。第二参 fromCache=true 表示命中缓存,调用方须自行 addRef。 * @param bundle 分包 * @param assetPath 资源路径 * @param onComplete 完成回调 * @param type 资源类型 */ loadResPublic(bundle: AssetManager.Bundle, assetPath: string, onComplete: ((asset: Asset | null, fromCache?: boolean) => void) | null = null, type = Asset) { this.loadRes(bundle, assetPath, onComplete, type); } /** * bundle 缓存中仍有效的资源;失效则剔除,避免后续打开永久失败。 * @param bundle 分包 * @param assetPath 资源路径 * @param type 资源类型 */ private _validBundleAsset(bundle: AssetManager.Bundle, assetPath: string, type: typeof Asset): Asset | null { const cached = bundle.get(assetPath, type) as Asset | null; if (!cached) return null; if (cached.isValid) return cached; this._evictInvalidAsset(bundle, assetPath, type, cached); return null; } /** * 剔除 bundle.get 仍返回的已销毁资源。 * @param bundle 分包 * @param assetPath 资源路径 * @param type 资源类型 * @param asset 失效资源 */ private _evictInvalidAsset(bundle: AssetManager.Bundle, assetPath: string, type: typeof Asset, asset: Asset): void { console.error('[ResourcesManager] cached asset invalid, evict', bundle.name, assetPath); try { assetManager.releaseAsset(asset); } catch (e) { if (DEV) console.log('[ResourcesManager] releaseAsset invalid', bundle.name, assetPath, String(e)); } try { bundle.release(assetPath, type); } catch (e) { if (DEV) console.log('[ResourcesManager] bundle.release invalid', bundle.name, assetPath, String(e)); } } private loadRes(bundle: AssetManager.Bundle, assetPath, onComplete: Function = null, type = Asset, forceLoad = false) { if (!forceLoad) { const cached = this._validBundleAsset(bundle, assetPath, type); if (cached) { if (onComplete) onComplete(cached, true); return; } } const waitKey = `${bundle.name}|${assetPath}|${type.name}`; const inflight = this._assetLoadWait[waitKey]; if (inflight && !forceLoad) { void inflight.then((asset) => { const valid = asset?.isValid ? asset : this._validBundleAsset(bundle, assetPath, type); if (onComplete) onComplete(valid, false); }); return; } const native = new Promise((resolve) => { bundle.load(assetPath, type, (err, asset) => { if (this._assetLoadWait[waitKey] === native) { this._assetLoadWait[waitKey] = undefined; } if (err) { console.error('[ResourcesManager] bundle.load fail', bundle.name, assetPath, err.message ?? err); resolve(null); return; } resolve(asset); }); }); this._assetLoadWait[waitKey] = native; let done = false; const finish = (asset: Asset | null, fromCache: boolean): void => { if (done) return; done = true; if (onComplete) onComplete(asset, fromCache); }; const timer = setTimeout(() => { const late = this._validBundleAsset(bundle, assetPath, type); if (!late) { console.error('[ResourcesManager] bundle.load timeout', bundle.name, assetPath); } finish(late, !!late); }, LoadRecoveryConfig.prefabTimeoutMs); void native.then((asset) => { clearTimeout(timer); if (asset?.isValid) { finish(asset, false); return; } const late = this._validBundleAsset(bundle, assetPath, type); if (!late && asset && !asset.isValid) { console.error('[ResourcesManager] bundle.load 预制无效', bundle.name, assetPath); } finish(late, !!late); }); } /** bundle.loadDir:目录内直接子资源(非递归) */ private _loadDirAssets( bundle: AssetManager.Bundle, dirPath: string, type: typeof Asset, forceLoad: boolean, onComplete: (ok: boolean, assets?: Asset[]) => void, ): void { if (forceLoad) { try { bundle.release(dirPath, type); } catch (e) { if (DEV) console.log('[ResourcesManager] bundle.release dir before loadDir', bundle.name, dirPath, String(e)); } } const waitKey = `${bundle.name}|${dirPath}|${type.name}`; const inflight = this._dirLoadWait[waitKey]; if (inflight && !forceLoad) { void inflight.then((r) => onComplete(r.ok, r.assets)); return; } const native = new Promise<{ ok: boolean; assets?: Asset[] }>((resolve) => { bundle.loadDir(dirPath, type, (err, assets) => { if (this._dirLoadWait[waitKey] === native) { this._dirLoadWait[waitKey] = undefined; } if (err || !assets || assets.length <= 0) { console.error('[ResourcesManager] bundle.loadDir fail', bundle.name, dirPath, err?.message ?? err); resolve({ ok: false }); return; } for (const asset of assets) { asset.addRef(); } resolve({ ok: true, assets }); }); }); this._dirLoadWait[waitKey] = native; let done = false; const finish = (ok: boolean, assets?: Asset[]): void => { if (done) return; done = true; onComplete(ok, assets); }; const timer = setTimeout(() => { console.error('[ResourcesManager] bundle.loadDir timeout', bundle.name, dirPath); finish(false); }, LoadRecoveryConfig.dirTimeoutMs); void native.then((r) => { clearTimeout(timer); finish(r.ok, r.assets); }); } /** 预加载资源(走 loadRes 同路径合并,避免与 openPanel 双飞丢回调) */ preLoadRes( bundle: AssetManager.Bundle, assetPath: string, type: typeof Asset, onComplete: ((asset: Asset | null) => void) | null = null, ) { this.loadRes(bundle, assetPath, (asset: Asset | null) => { if (onComplete) onComplete(asset); }, type); } }