import { warn } from '../../utils/logging/warn';
import { path } from '../../utils/path';
import { convertToList } from '../utils/convertToList';
import { createStringVariations } from '../utils/createStringVariations';
import { isSingleItem } from '../utils/isSingleItem';
import type {
ArrayOr,
AssetsBundle,
AssetsManifest,
AssetSrc,
ResolvedAsset,
ResolvedSrc,
UnresolvedAsset,
} from '../types';
import type { PreferOrder, ResolveURLParser } from './types';
/**
* Options for how the resolver deals with generating bundle ids
* @memberof assets
*/
export interface BundleIdentifierOptions
{
/** The character that is used to connect the bundleId and the assetId when generating a bundle asset id key */
connector?: string;
/**
* A function that generates a bundle asset id key from a bundleId and an assetId
* @param bundleId - the bundleId
* @param assetId - the assetId
* @returns the bundle asset id key
*/
createBundleAssetId?: (bundleId: string, assetId: string) => string;
/**
* A function that generates an assetId from a bundle asset id key. This is the reverse of generateBundleAssetId
* @param bundleId - the bundleId
* @param assetBundleId - the bundle asset id key
* @returns the assetId
*/
extractAssetIdFromBundle?: (bundleId: string, assetBundleId: string) => string;
}
/**
* A class that is responsible for resolving mapping asset URLs to keys.
* At its most basic it can be used for Aliases:
*
* ```js
* resolver.add('foo', 'bar');
* resolver.resolveUrl('foo') // => 'bar'
* ```
*
* It can also be used to resolve the most appropriate asset for a given URL:
*
* ```js
* resolver.prefer({
* params: {
* format: 'webp',
* resolution: 2,
* }
* });
*
* resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']);
*
* resolver.resolveUrl('foo') // => 'bar@2x.webp'
* ```
* Other features include:
* - Ability to process a manifest file to get the correct understanding of how to resolve all assets
* - Ability to add custom parsers for specific file types
* - Ability to add custom prefer rules
*
* This class only cares about the URL, not the loading of the asset itself.
*
* It is not intended that this class is created by developers - its part of the Asset class
* This is the third major system of PixiJS' main Assets class
* @memberof assets
*/
export class Resolver
{
/**
* The prefix that denotes a URL is for a retina asset.
* @static
* @name RETINA_PREFIX
* @type {RegExp}
* @default /@([0-9\.]+)x/
* @example `@2x`
*/
public static RETINA_PREFIX = /@([0-9\.]+)x/;
private readonly _defaultBundleIdentifierOptions: Required<BundleIdentifierOptions> = {
connector: '-',
createBundleAssetId: (bundleId, assetId) =>
`${bundleId}${this._bundleIdConnector}${assetId}`,
extractAssetIdFromBundle: (bundleId, assetBundleId) =>
assetBundleId.replace(`${bundleId}${this._bundleIdConnector}`, ''),
};
/** The character that is used to connect the bundleId and the assetId when generating a bundle asset id key */
private _bundleIdConnector = this._defaultBundleIdentifierOptions.connector;
/**
* A function that generates a bundle asset id key from a bundleId and an assetId
* @param bundleId - the bundleId
* @param assetId - the assetId
* @returns the bundle asset id key
*/
private _createBundleAssetId: (
bundleId: string,
assetId: string
) => string = this._defaultBundleIdentifierOptions.createBundleAssetId;
/**
* A function that generates an assetId from a bundle asset id key. This is the reverse of generateBundleAssetId
* @param bundleId - the bundleId
* @param assetBundleId - the bundle asset id key
* @returns the assetId
*/
private _extractAssetIdFromBundle: (
bundleId: string,
assetBundleId: string
) => string = this._defaultBundleIdentifierOptions.extractAssetIdFromBundle;
private _assetMap: Record<string, ResolvedAsset[]> = {};
private _preferredOrder: PreferOrder[] = [];
private readonly _parsers: ResolveURLParser[] = [];
private _resolverHash: Record<string, ResolvedAsset> = {};
private _rootPath: string;
private _basePath: string;
private _manifest: AssetsManifest;
private _bundles: Record<string, string[]> = {};
private _defaultSearchParams: string;
/**
* Override how the resolver deals with generating bundle ids.
* must be called before any bundles are added
* @param bundleIdentifier - the bundle identifier options
*/
public setBundleIdentifier(bundleIdentifier: BundleIdentifierOptions): void
{
this._bundleIdConnector = bundleIdentifier.connector ?? this._bundleIdConnector;
this._createBundleAssetId = bundleIdentifier.createBundleAssetId ?? this._createBundleAssetId;
this._extractAssetIdFromBundle = bundleIdentifier.extractAssetIdFromBundle ?? this._extractAssetIdFromBundle;
if (this._extractAssetIdFromBundle('foo', this._createBundleAssetId('foo', 'bar')) !== 'bar')
{
throw new Error('[Resolver] GenerateBundleAssetId are not working correctly');
}
}
/**
* Let the resolver know which assets you prefer to use when resolving assets.
* Multiple prefer user defined rules can be added.
* @example
* resolver.prefer({
* // first look for something with the correct format, and then then correct resolution
* priority: ['format', 'resolution'],
* params:{
* format:'webp', // prefer webp images
* resolution: 2, // prefer a resolution of 2
* }
* })
* resolver.add('foo', ['bar@2x.webp', 'bar@2x.png', 'bar.webp', 'bar.png']);
* resolver.resolveUrl('foo') // => 'bar@2x.webp'
* @param preferOrders - the prefer options
*/
public prefer(...preferOrders: PreferOrder[]): void
{
preferOrders.forEach((prefer) =>
{
this._preferredOrder.push(prefer);
if (!prefer.priority)
{
// generate the priority based on the order of the object
prefer.priority = Object.keys(prefer.params);
}
});
this._resolverHash = {};
}
/**
* Set the base path to prepend to all urls when resolving
* @example
* resolver.basePath = 'https://home.com/';
* resolver.add('foo', 'bar.ong');
* resolver.resolveUrl('foo', 'bar.png'); // => 'https://home.com/bar.png'
* @param basePath - the base path to use
*/
public set basePath(basePath: string)
{
this._basePath = basePath;
}
public get basePath(): string
{
return this._basePath;
}
/**
* Set the root path for root-relative URLs. By default the `basePath`'s root is used. If no `basePath` is set, then the
* default value for browsers is `window.location.origin`
* @example
* // Application hosted on https://home.com/some-path/index.html
* resolver.basePath = 'https://home.com/some-path/';
* resolver.rootPath = 'https://home.com/';
* resolver.add('foo', '/bar.png');
* resolver.resolveUrl('foo', '/bar.png'); // => 'https://home.com/bar.png'
* @param rootPath - the root path to use
*/
public set rootPath(rootPath: string)
{
this._rootPath = rootPath;
}
public get rootPath(): string
{
return this._rootPath;
}
/**
* All the active URL parsers that help the parser to extract information and create
* an asset object-based on parsing the URL itself.
*
* Can be added using the extensions API
* @example
* resolver.add('foo', [
* {
* resolution: 2,
* format: 'png',
* src: 'image@2x.png',
* },
* {
* resolution:1,
* format:'png',
* src: 'image.png',
* },
* ]);
*
* // With a url parser the information such as resolution and file format could extracted from the url itself:
* extensions.add({
* extension: ExtensionType.ResolveParser,
* test: loadTextures.test, // test if url ends in an image
* parse: (value: string) =>
* ({
* resolution: parseFloat(Resolver.RETINA_PREFIX.exec(value)?.[1] ?? '1'),
* format: value.split('.').pop(),
* src: value,
* }),
* });
*
* // Now resolution and format can be extracted from the url
* resolver.add('foo', [
* 'image@2x.png',
* 'image.png',
* ]);
*/
public get parsers(): ResolveURLParser[]
{
return this._parsers;
}
/** Used for testing, this resets the resolver to its initial state */
public reset(): void
{
this.setBundleIdentifier(this._defaultBundleIdentifierOptions);
this._assetMap = {};
this._preferredOrder = [];
// Do not reset this._parsers
this._resolverHash = {};
this._rootPath = null;
this._basePath = null;
this._manifest = null;
this._bundles = {};
this._defaultSearchParams = null;
}
/**
* Sets the default URL search parameters for the URL resolver. The urls can be specified as a string or an object.
* @param searchParams - the default url parameters to append when resolving urls
*/
public setDefaultSearchParams(searchParams: string | Record<string, unknown>): void
{
if (typeof searchParams === 'string')
{
this._defaultSearchParams = searchParams;
}
else
{
const queryValues = searchParams as Record<string, any>;
this._defaultSearchParams = Object.keys(queryValues)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(queryValues[key])}`)
.join('&');
}
}
/**
* Returns the aliases for a given asset
* @param asset - the asset to get the aliases for
*/
public getAlias(asset: UnresolvedAsset): string[]
{
const { alias, src } = asset;
const aliasesToUse = convertToList<ArrayOr<string | AssetSrc>>(
alias || src, (value: string | AssetSrc) =>
{
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map((v) => (v as ResolvedSrc)?.src ?? v);
if (value?.src) return value.src;
return value;
}, true) as string[];
return aliasesToUse;
}
/**
* Add a manifest to the asset resolver. This is a nice way to add all the asset information in one go.
* generally a manifest would be built using a tool.
* @param manifest - the manifest to add to the resolver
*/
public addManifest(manifest: AssetsManifest): void
{
if (this._manifest)
{
// #if _DEBUG
warn('[Resolver] Manifest already exists, this will be overwritten');
// #endif
}
this._manifest = manifest;
manifest.bundles.forEach((bundle) =>
{
this.addBundle(bundle.name, bundle.assets);
});
}
/**
* This adds a bundle of assets in one go so that you can resolve them as a group.
* For example you could add a bundle for each screen in you pixi app
* @example
* resolver.addBundle('animals', [
* { alias: 'bunny', src: 'bunny.png' },
* { alias: 'chicken', src: 'chicken.png' },
* { alias: 'thumper', src: 'thumper.png' },
* ]);
* // or
* resolver.addBundle('animals', {
* bunny: 'bunny.png',
* chicken: 'chicken.png',
* thumper: 'thumper.png',
* });
*
* const resolvedAssets = await resolver.resolveBundle('animals');
* @param bundleId - The id of the bundle to add
* @param assets - A record of the asset or assets that will be chosen from when loading via the specified key
*/
public addBundle(bundleId: string, assets: AssetsBundle['assets']): void
{
const assetNames: string[] = [];
let convertedAssets: UnresolvedAsset[] = assets as UnresolvedAsset[];
if (!Array.isArray(assets))
{
// convert to array...
convertedAssets = Object.entries(assets).map(([alias, src]) =>
{
if (typeof src === 'string' || Array.isArray(src))
{
return { alias, src };
}
return { alias, ...src };
});
}
// when storing keys against a bundle we prepend the bundleId to each asset key
// and pass it through as an additional alias for the asset
// this keeps clashing ids separate on a per-bundle basis
// you can also resolve a file using the bundleId-assetId syntax
convertedAssets.forEach((asset) =>
{
const srcs = asset.src;
const aliases = asset.alias;
let ids: string[];
if (typeof aliases === 'string')
{
const bundleAssetId = this._createBundleAssetId(bundleId, aliases);
assetNames.push(bundleAssetId);
ids = [aliases, bundleAssetId];
}
else
{
const bundleIds = aliases.map((name) => this._createBundleAssetId(bundleId, name));
assetNames.push(...bundleIds);
ids = [...aliases, ...bundleIds];
}
this.add({
...asset,
...{
alias: ids,
src: srcs,
}
});
});
this._bundles[bundleId] = assetNames;
}
/**
* Tells the resolver what keys are associated with witch asset.
* The most important thing the resolver does
* @example
* // Single key, single asset:
* resolver.add({alias: 'foo', src: 'bar.png');
* resolver.resolveUrl('foo') // => 'bar.png'
*
* // Multiple keys, single asset:
* resolver.add({alias: ['foo', 'boo'], src: 'bar.png'});
* resolver.resolveUrl('foo') // => 'bar.png'
* resolver.resolveUrl('boo') // => 'bar.png'
*
* // Multiple keys, multiple assets:
* resolver.add({alias: ['foo', 'boo'], src: ['bar.png', 'bar.webp']});
* resolver.resolveUrl('foo') // => 'bar.png'
*
* // Add custom data attached to the resolver
* Resolver.add({
* alias: 'bunnyBooBooSmooth',
* src: 'bunny{png,webp}',
* data: { scaleMode:SCALE_MODES.NEAREST }, // Base texture options
* });
*
* resolver.resolve('bunnyBooBooSmooth') // => { src: 'bunny.png', data: { scaleMode: SCALE_MODES.NEAREST } }
* @param aliases - the UnresolvedAsset or array of UnresolvedAssets to add to the resolver
*/
public add(
aliases: ArrayOr<UnresolvedAsset>,
): void
{
const assets: UnresolvedAsset[] = [];
if (Array.isArray(aliases))
{
assets.push(...(aliases as UnresolvedAsset[]));
}
else
{
assets.push(aliases as UnresolvedAsset);
}
let keyCheck: (key: string) => void;
// #if _DEBUG
// eslint-disable-next-line prefer-const
keyCheck = (key: string) =>
{
if (this.hasKey(key))
{
// #if _DEBUG
warn(`[Resolver] already has key: ${key} overwriting`);
// #endif
}
};
// #endif
const assetArray = convertToList(assets);
// loop through all the assets and generate a resolve asset for each src
assetArray.forEach((asset) =>
{
const { src } = asset;
let { data, format, loadParser } = asset;
// src can contain an unresolved asset itself
// so we need to merge that data with the current asset
// we dont need to create string variations for the src if it is a ResolvedAsset
const srcsToUse: (string | ResolvedSrc)[][] = convertToList<AssetSrc>(src).map((src) =>
{
if (typeof src === 'string')
{ return createStringVariations(src); }
return Array.isArray(src) ? src : [src];
});
const aliasesToUse = this.getAlias(asset);
// #if _DEBUG
Array.isArray(aliasesToUse) ? aliasesToUse.forEach(keyCheck) : keyCheck(aliasesToUse);
// #endif
// loop through all the srcs and generate a resolve asset for each src
const resolvedAssets: ResolvedAsset[] = [];
srcsToUse.forEach((srcs) =>
{
srcs.forEach((src) =>
{
let formattedAsset = {} as ResolvedAsset;
if (typeof src !== 'object')
{
formattedAsset.src = src;
// first see if it contains any {} tags...
for (let i = 0; i < this._parsers.length; i++)
{
const parser = this._parsers[i];
if (parser.test(src))
{
formattedAsset = parser.parse(src);
break;
}
}
}
else
{
data = src.data ?? data;
format = src.format ?? format;
loadParser = src.loadParser ?? loadParser;
formattedAsset = {
...formattedAsset,
...src,
};
}
// check if aliases is undefined
if (!aliasesToUse)
{
throw new Error(`[Resolver] alias is undefined for this asset: ${formattedAsset.src}`);
}
formattedAsset = this._buildResolvedAsset(formattedAsset, {
aliases: aliasesToUse,
data,
format,
loadParser,
});
resolvedAssets.push(formattedAsset);
});
});
aliasesToUse.forEach((alias) =>
{
this._assetMap[alias] = resolvedAssets;
});
});
}
// TODO: this needs an overload like load did in Assets
/**
* If the resolver has had a manifest set via setManifest, this will return the assets urls for
* a given bundleId or bundleIds.
* @example
* // Manifest Example
* const manifest = {
* bundles: [
* {
* name: 'load-screen',
* assets: [
* {
* alias: 'background',
* src: 'sunset.png',
* },
* {
* alias: 'bar',
* src: 'load-bar.{png,webp}',
* },
* ],
* },
* {
* name: 'game-screen',
* assets: [
* {
* alias: 'character',
* src: 'robot.png',
* },
* {
* alias: 'enemy',
* src: 'bad-guy.png',
* },
* ],
* },
* ]
* };
*
* resolver.setManifest(manifest);
* const resolved = resolver.resolveBundle('load-screen');
* @param bundleIds - The bundle ids to resolve
* @returns All the bundles assets or a hash of assets for each bundle specified
*/
public resolveBundle(bundleIds: ArrayOr<string>):
Record<string, ResolvedAsset> | Record<string, Record<string, ResolvedAsset>>
{
const singleAsset = isSingleItem(bundleIds);
bundleIds = convertToList<string>(bundleIds);
const out: Record<string, Record<string, ResolvedAsset>> = {};
bundleIds.forEach((bundleId) =>
{
const assetNames = this._bundles[bundleId];
if (assetNames)
{
const results = this.resolve(assetNames) as Record<string, ResolvedAsset>;
const assets: Record<string, ResolvedAsset> = {};
for (const key in results)
{
const asset = results[key];
assets[this._extractAssetIdFromBundle(bundleId, key)] = asset;
}
out[bundleId] = assets;
}
});
return singleAsset ? out[bundleIds[0]] : out;
}
/**
* Does exactly what resolve does, but returns just the URL rather than the whole asset object
* @param key - The key or keys to resolve
* @returns - The URLs associated with the key(s)
*/
public resolveUrl(key: ArrayOr<string>): string | Record<string, string>
{
const result = this.resolve(key as string) as ResolvedAsset | Record<string, ResolvedAsset>;
if (typeof key !== 'string')
{
const out: Record<string, string> = {};
for (const i in result)
{
out[i] = (result as Record<string, ResolvedAsset>)[i].src;
}
return out;
}
return (result as ResolvedAsset).src;
}
/**
* Resolves each key in the list to an asset object.
* Another key function of the resolver! After adding all the various key/asset pairs. this will run the logic
* of finding which asset to return based on any preferences set using the `prefer` function
* by default the same key passed in will be returned if nothing is matched by the resolver.
* @example
* resolver.add('boo', 'bunny.png');
*
* resolver.resolve('boo') // => { src: 'bunny.png' }
*
* // Will return the same string as no key was added for this value..
* resolver.resolve('another-thing.png') // => { src: 'another-thing.png' }
* @param keys - key or keys to resolve
* @returns - the resolve asset or a hash of resolve assets for each key specified
*/
public resolve(keys: string): ResolvedAsset;
public resolve(keys: string[]): Record<string, ResolvedAsset>;
public resolve(keys: ArrayOr<string>): ResolvedAsset | Record<string, ResolvedAsset>
{
const singleAsset = isSingleItem(keys);
keys = convertToList<string>(keys);
const result: Record<string, ResolvedAsset> = {};
keys.forEach((key) =>
{
if (!this._resolverHash[key])
{
if (this._assetMap[key])
{
let assets = this._assetMap[key];
const preferredOrder = this._getPreferredOrder(assets);
preferredOrder?.priority.forEach((priorityKey) =>
{
preferredOrder.params[priorityKey].forEach((value: unknown) =>
{
const filteredAssets = assets.filter((asset) =>
{
if (asset[priorityKey as keyof ResolvedAsset])
{
return asset[priorityKey as keyof ResolvedAsset] === value;
}
return false;
});
if (filteredAssets.length)
{
assets = filteredAssets;
}
});
});
this._resolverHash[key] = assets[0];
}
else
{
this._resolverHash[key] = this._buildResolvedAsset({
alias: [key],
src: key,
}, {});
}
}
result[key] = this._resolverHash[key];
});
return singleAsset ? result[keys[0]] : result;
}
/**
* Checks if an asset with a given key exists in the resolver
* @param key - The key of the asset
*/
public hasKey(key: string): boolean
{
return !!this._assetMap[key];
}
/**
* Checks if a bundle with the given key exists in the resolver
* @param key - The key of the bundle
*/
public hasBundle(key: string): boolean
{
return !!this._bundles[key];
}
/**
* Internal function for figuring out what prefer criteria an asset should use.
* @param assets
*/
private _getPreferredOrder(assets: ResolvedAsset[]): PreferOrder
{
for (let i = 0; i < assets.length; i++)
{
const asset = assets[0];
const preferred = this._preferredOrder.find((preference: PreferOrder) =>
preference.params.format.includes(asset.format));
if (preferred)
{
return preferred;
}
}
return this._preferredOrder[0];
}
/**
* Appends the default url parameters to the url
* @param url - The url to append the default parameters to
* @returns - The url with the default parameters appended
*/
private _appendDefaultSearchParams(url: string): string
{
if (!this._defaultSearchParams) return url;
const paramConnector = (/\?/).test(url) ? '&' : '?';
return `${url}${paramConnector}${this._defaultSearchParams}`;
}
private _buildResolvedAsset(formattedAsset: ResolvedAsset, data?: {
aliases?: string[],
data?: Record<string, unknown>
loadParser?: string,
format?: string,
}): ResolvedAsset
{
const { aliases, data: assetData, loadParser, format } = data;
if (this._basePath || this._rootPath)
{
formattedAsset.src = path.toAbsolute(formattedAsset.src, this._basePath, this._rootPath);
}
formattedAsset.alias = aliases ?? formattedAsset.alias ?? [formattedAsset.src];
formattedAsset.src = this._appendDefaultSearchParams(formattedAsset.src);
formattedAsset.data = { ...assetData || {}, ...formattedAsset.data };
formattedAsset.loadParser = loadParser ?? formattedAsset.loadParser;
formattedAsset.format = format ?? formattedAsset.format ?? getUrlExtension(formattedAsset.src);
return formattedAsset;
}
}
export function getUrlExtension(url: string)
{
return url.split('.').pop().split('?').shift()
.split('#')
.shift();
}