playcanvas
Version:
PlayCanvas WebGL game engine
312 lines (310 loc) • 11.7 kB
JavaScript
/**
* @import { Asset } from '../asset/asset.js'
* @import { AssetRegistry } from '../asset/asset-registry.js'
*/ /**
* Keeps track of which assets are in bundles and loads files from bundles.
*
* @ignore
*/ class BundleRegistry {
/**
* Create a new BundleRegistry instance.
*
* @param {AssetRegistry} assets - The asset registry.
*/ constructor(assets){
/**
* Index of bundle assets.
* @type {Map<number, Asset>}
* @private
*/ this._idToBundle = new Map();
/**
* Index of asset id to set of bundle assets.
* @type {Map<number, Set<Asset>>}
* @private
*/ this._assetToBundles = new Map();
/**
* Index of file url to set of bundle assets.
* @type {Map<string, Set<Asset>>}
* @private
*/ this._urlsToBundles = new Map();
/**
* Index of file request to load callbacks.
* @type {Map<string, function[]>}
* @private
*/ this._fileRequests = new Map();
this._assets = assets;
this._assets.bundles = this;
this._assets.on('add', this._onAssetAdd, this);
this._assets.on('remove', this._onAssetRemove, this);
}
/**
* Called when asset is added to AssetRegistry.
*
* @param {Asset} asset - The asset that has been added.
* @private
*/ _onAssetAdd(asset) {
// if this is a bundle asset then add it and
// index its referenced assets
if (asset.type === 'bundle') {
this._idToBundle.set(asset.id, asset);
this._assets.on(`load:start:${asset.id}`, this._onBundleLoadStart, this);
this._assets.on(`load:${asset.id}`, this._onBundleLoad, this);
this._assets.on(`error:${asset.id}`, this._onBundleError, this);
const assetIds = asset.data.assets;
for(let i = 0; i < assetIds.length; i++){
this._indexAssetInBundle(assetIds[i], asset);
}
} else {
// if this is not a bundle then index its URLs
if (this._assetToBundles.has(asset.id)) {
this._indexAssetFileUrls(asset);
}
}
}
_unbindAssetEvents(id) {
this._assets.off(`load:start:${id}`, this._onBundleLoadStart, this);
this._assets.off(`load:${id}`, this._onBundleLoad, this);
this._assets.off(`error:${id}`, this._onBundleError, this);
}
// Index the specified asset id and its file URLs so that
// the registry knows that the asset is in that bundle
_indexAssetInBundle(id, bundle) {
let bundles = this._assetToBundles.get(id);
if (!bundles) {
bundles = new Set();
this._assetToBundles.set(id, bundles);
}
bundles.add(bundle);
const asset = this._assets.get(id);
if (asset) this._indexAssetFileUrls(asset);
}
// Index the file URLs of the specified asset
_indexAssetFileUrls(asset) {
const urls = this._getAssetFileUrls(asset);
if (!urls) return;
for(let i = 0; i < urls.length; i++){
const bundles = this._assetToBundles.get(asset.id);
if (!bundles) continue;
this._urlsToBundles.set(urls[i], bundles);
}
}
// Get all the possible URLs of an asset
_getAssetFileUrls(asset) {
let url = asset.getFileUrl();
if (!url) return null;
url = url.split('?')[0];
const urls = [
url
];
// a font might have additional files
// so add them in the list
if (asset.type === 'font') {
const numFiles = asset.data.info.maps.length;
for(let i = 1; i < numFiles; i++){
urls.push(url.replace('.png', `${i}.png`));
}
}
return urls;
}
// Remove asset from internal indexes
_onAssetRemove(asset) {
if (asset.type === 'bundle') {
// remove bundle from index
this._idToBundle.delete(asset.id);
// remove event listeners
this._unbindAssetEvents(asset.id);
// remove bundle from _assetToBundles and _urlInBundles indexes
const assetIds = asset.data.assets;
for(let i = 0; i < assetIds.length; i++){
const bundles = this._assetToBundles.get(assetIds[i]);
if (!bundles) continue;
bundles.delete(asset);
if (bundles.size === 0) {
this._assetToBundles.delete(assetIds[i]);
for (const [url, otherBundles] of this._urlsToBundles){
if (otherBundles !== bundles) {
continue;
}
this._urlsToBundles.delete(url);
}
}
}
// fail any pending requests for this bundle
this._onBundleError(`Bundle ${asset.id} was removed`);
} else {
const bundles = this._assetToBundles.get(asset.id);
if (!bundles) return;
this._assetToBundles.delete(asset.id);
// remove asset urls from _urlsToBundles
const urls = this._getAssetFileUrls(asset);
if (!urls) return;
for(let i = 0; i < urls.length; i++){
this._urlsToBundles.delete(urls[i]);
}
}
}
_onBundleLoadStart(asset) {
asset.resource.on('add', (url, data)=>{
const callbacks = this._fileRequests.get(url);
if (!callbacks) return;
for(let i = 0; i < callbacks.length; i++){
callbacks[i](null, data);
}
this._fileRequests.delete(url);
});
}
// If we have any pending file requests
// that can be satisfied by the specified bundle
// then resolve them
_onBundleLoad(asset) {
// this can happen if the asset failed
// to create its resource
if (!asset.resource) {
this._onBundleError(`Bundle ${asset.id} failed to load`);
return;
}
// make sure the registry hasn't been destroyed already
if (!this._fileRequests) {
return;
}
for (const [url, requests] of this._fileRequests){
const bundles = this._urlsToBundles.get(url);
if (!bundles || !bundles.has(asset)) continue;
const decodedUrl = decodeURIComponent(url);
let err, data;
if (asset.resource.has(decodedUrl)) {
data = asset.resource.get(decodedUrl);
} else if (asset.resource.loaded) {
err = `Bundle ${asset.id} does not contain URL ${url}`;
} else {
continue;
}
for(let i = 0; i < requests.length; i++){
requests[i](err, err || data);
}
this._fileRequests.delete(url);
}
}
// If we have outstanding file requests for any
// of the URLs in the specified bundle then search for
// other bundles that can satisfy these requests.
// If we do not find any other bundles then fail
// those pending file requests with the specified error.
_onBundleError(err) {
for (const [url, requests] of this._fileRequests){
const bundle = this._findLoadedOrLoadingBundleForUrl(url);
if (!bundle) {
for(let i = 0; i < requests.length; i++){
requests[i](err);
}
this._fileRequests.delete(url);
}
}
}
// Finds a bundle that contains the specified URL but
// only returns the bundle if it's either loaded or being loaded
_findLoadedOrLoadingBundleForUrl(url) {
const bundles = this._urlsToBundles.get(url);
if (!bundles) return null;
let candidate = null;
for (const bundle of bundles){
if (bundle.loaded && bundle.resource) {
return bundle;
} else if (bundle.loading) {
candidate = bundle;
}
}
return candidate;
}
/**
* Lists all of the available bundles that reference the specified asset.
*
* @param {Asset} asset - The asset to search by.
* @returns {Asset[]|null} An array of bundle assets or null if the
* asset is not in any bundle.
*/ listBundlesForAsset(asset) {
const bundles = this._assetToBundles.get(asset.id);
if (bundles) return Array.from(bundles);
return null;
}
/**
* Lists all bundle assets.
*
* @returns {Asset[]} An array of bundle assets.
*/ list() {
return Array.from(this._idToBundle.values());
}
/**
* Returns true if there is a bundle that contains the specified URL.
*
* @param {string} url - The url.
* @returns {boolean} True or false.
*/ hasUrl(url) {
return this._urlsToBundles.has(url);
}
/**
* Returns true if there is a bundle that contains the specified URL and that bundle is either
* loaded or currently being loaded.
*
* @param {string} url - The url.
* @returns {boolean} True or false.
*/ urlIsLoadedOrLoading(url) {
return !!this._findLoadedOrLoadingBundleForUrl(url);
}
/**
* Loads the specified file URL from a bundle that is either loaded or currently being loaded.
*
* @param {string} url - The URL. Make sure you are using a relative URL that does not contain
* any query parameters.
* @param {Function} callback - The callback is called when the file has been loaded or if an
* error occurs. The callback expects the first argument to be the error message (if any) and
* the second argument is the file blob URL.
* @example
* const url = asset.getFileUrl().split('?')[0]; // get normalized asset URL
* this.app.bundles.loadFile(url, function (err, data) {
* // do something with the data
* });
*/ loadUrl(url, callback) {
const bundle = this._findLoadedOrLoadingBundleForUrl(url);
if (!bundle) {
callback(`URL ${url} not found in any bundles`);
return;
}
// Only load files from bundles that're explicitly requested to be loaded.
if (bundle.loaded) {
const decodedUrl = decodeURIComponent(url);
if (bundle.resource.has(decodedUrl)) {
callback(null, bundle.resource.get(decodedUrl));
return;
} else if (bundle.resource.loaded) {
callback(`Bundle ${bundle.id} does not contain URL ${url}`);
return;
}
}
let callbacks = this._fileRequests.get(url);
if (!callbacks) {
callbacks = [];
this._fileRequests.set(url, callbacks);
}
callbacks.push(callback);
}
/**
* Destroys the registry, and releases its resources. Does not unload bundle assets as these
* should be unloaded by the {@link AssetRegistry}.
*/ destroy() {
this._assets.off('add', this._onAssetAdd, this);
this._assets.off('remove', this._onAssetRemove, this);
for (const id of this._idToBundle.keys()){
this._unbindAssetEvents(id);
}
this._assets = null;
this._idToBundle.clear();
this._idToBundle = null;
this._assetToBundles.clear();
this._assetToBundles = null;
this._urlsToBundles.clear();
this._urlsToBundles = null;
this._fileRequests.clear();
this._fileRequests = null;
}
}
export { BundleRegistry };