UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,046 lines (838 loc) • 31.1 kB
import { assert } from "../../core/assert.js"; import { array_push_if_unique } from "../../core/collection/array/array_push_if_unique.js"; import { array_remove_first } from "../../core/collection/array/array_remove_first.js"; import BinaryHeap from "../../core/collection/heap/BinaryHeap.js"; import { HashMap } from "../../core/collection/map/HashMap.js"; import { ObservedMap } from "../../core/collection/map/ObservedMap.js"; import { Deque } from "../../core/collection/queue/Deque.js"; import { HashSet } from "../../core/collection/set/HashSet.js"; import { noop } from "../../core/function/noop.js"; import ConcurrentExecutor from "../../core/process/executor/ConcurrentExecutor.js"; import Task from "../../core/process/task/Task.js"; import { TaskSignal } from "../../core/process/task/TaskSignal.js"; import { AssetDescription } from "./AssetDescription.js"; import { AssetLoadState } from "./AssetLoadState.js"; import { AssetRequest, AssetRequestFlags } from "./AssetRequest.js"; import { AssetRequestScope } from "./AssetRequestScope.js"; import { CrossOriginConfig } from "./CORS/CrossOriginConfig.js"; import { AssetLoader } from "./loaders/AssetLoader.js"; import { PendingAsset } from "./PendingAsset.js"; import { extractAssetListFromManager } from "./preloader/extractAssetListFromManager.js"; class Response { /** * * @param {Asset} asset * @param {AssetRequest} request */ constructor(asset, request) { this.asset = asset; this.request = request; } } /** * Used by the priority queue, so the priority is inverted as the BinaryHeap returns elements in ascending score order (from lowest) * @param {PendingAsset} pending_asset * @returns {number} */ function get_pending_asset_priority_score(pending_asset) { return -pending_asset.priority; } // TODO handle 429 HTTP error gracefully /** * Handles loading/generating assets * @template CTX * @class */ export class AssetManager { /** * * @type {HashMap<AssetDescription, Asset>} * @private */ assets = new HashMap({ capacity: 1024 }); /** * * @type {ObservedMap<AssetDescription, PendingAsset>} */ request_map = new ObservedMap(new HashMap()); /** * Waiting queue for assets that haven't yet been scheduled (passed to relevant loader) * @type {BinaryHeap<PendingAsset>} * @private */ #pending_asset_wait_queue = new BinaryHeap(get_pending_asset_priority_score); /** * Assets currently being processed by #loaders * @type {Set<PendingAsset>} * @private */ #pending_asset_active_load_set = new Set(); /** * Maximum number of requests that can be handled at the same time * Since most of the requests are handled over the network, this can help you control network concurrency, * this can be useful to keep some network slots available for high-priority requests such as UI related ones * TODO currently nested asset requests are not recognized, so if nesting level exceeds concurrency - it will lead to a deadlock * @type {number} */ load_concurrency = Infinity; /** * Registered asset loaders * @private * @type {Object<AssetLoader>} */ #loaders = {}; /** * After an asset is loaded, a chain of transforms can be applied to it, these are registered here * Transformers are executed in the order in which they are added * Identified by asset "type" string * @type {Object<AssetTransformer[]>} * @private */ #transformers = {}; /** * Named links to specific assets. Useful to later re-mapping assets and having meaningful names for them * @type {Map<string, AssetDescription>} * @private * @see resolveAlias * @see assignAlias * @see promiseByAlias */ #aliases = new Map(); /** * This will be added to asset path for actual network resolution * @type {string} * @private */ rootPath = ''; /** * * @type {CrossOriginConfig} */ crossOriginConfig = new CrossOriginConfig(); /** * * @type {HashSet<AssetDescription>} * @private */ #failures = new HashSet(); /** * Queue of responses that are waiting to be dispatched * @type {Deque<Response>} * @private */ #response_queue = new Deque(); /** * * @type {Task} * @private */ #response_processor = new Task({ name: "Asset Manager Response processor", cycleFunction: () => { if (this.#response_queue.isEmpty()) { return TaskSignal.Yield; } const response = this.#response_queue.removeFirst(); this.#process_response(response); return TaskSignal.Continue; } }); /** * * @type {boolean} * @private */ #is_running = false; /** * * @type {CTX|null} * @private */ #context = null; /** * * @type {ConcurrentExecutor|null} */ #executor = null; /** * * @param {CTX} context * @param {ConcurrentExecutor} executor * @constructor */ constructor({ context, executor = new ConcurrentExecutor() } = {}) { this.#context = context; this.#executor = executor; } startup() { if (this.#is_running) { return; } this.#executor.run(this.#response_processor); this.#is_running = true; } /** * * @param {number} [immediate] should be let pending work finish, or abort it and shut down immediately? Defaults to false (wait) * @return {Promise<void>} */ async shutdown(immediate = false) { if (!this.#is_running) { return; } if (!immediate) { // wait until all responses have been processed await Promise.allSettled([Task.promise(this.#response_processor)]); } this.#executor.removeTask(this.#response_processor); this.#is_running = false; } /** * Remove asset if it is loaded, does nothing otherwise * @param {String} path * @param {String} type * @returns {boolean} true if asset was removed, else otherwise */ remove(path, type) { const assetDescription = new AssetDescription(path, type); return this.assets.delete(assetDescription); } /** * Clear out all loaded assets */ clear() { this.assets.clear(); } dumpLoadedAssetList() { return JSON.stringify(extractAssetListFromManager(this), 3, 3); } /** * @param {String} path * @param {String} type * @param {AssetRequestScope} [scope] * @param {boolean} [skip_queue] if true will skip the queue and dispatch request immediately * @returns {Promise<Asset>} */ promise(path, type, { scope, skip_queue = false } = {}) { return new Promise((resolve, reject) => { this.get({ path, type, callback: resolve, failure: reject, scope }); }); } /** * @template T * @param {String} path * @param {String} type * @param {function(asset:Asset<T>)} [callback] success callback * @param {function(reason:*)} [failure] * @param {function(loaded:number, total:number)} [progress] * @param {boolean} [skip_queue] * @param {AssetRequestScope} [scope] */ get({ path, type, callback = noop, failure = console.error, progress = noop, skip_queue = false, scope = null }) { if (typeof path !== "string") { throw new TypeError(`Path must be string. instead was '${typeof path}'`); } if (typeof type !== "string") { throw new TypeError(`type must be a string, instead was '${typeof type}'`); } assert.isFunction(callback, 'success'); assert.isFunction(failure, 'failure'); assert.isFunction(progress, 'progress'); const assetDescription = new AssetDescription(path, type); const asset = this.assets.get(assetDescription); if (asset !== undefined) { // already exists, dispatch callback immediately callback(asset); } else { //create request object const assetRequest = new AssetRequest(callback, failure, progress); assetRequest.scope = scope; assetRequest.writeFlag(AssetRequestFlags.SkipQueue, skip_queue); //submit request this.submitRequest(assetDescription, assetRequest); } } /** * Checks if an asset is currently being loaded. * Useful for avoiding redundant requests. * * @param {string} path * @param {string} type * @return {boolean} */ isPending(path, type) { const assetDescription = new AssetDescription(path, type); return this.request_map.has(assetDescription); } /** * * @param {string} path * @param {string} type * @returns {boolean} * @see isPending */ isFailed(path, type) { const ad = new AssetDescription(path, type); return this.#failures.has(ad); } /** * Checks if an asset is loaded. * If the asset is loaded, calls to {@link tryGet} are guaranteed to succeed. * * @param {string} path * @param {string} type * @return {boolean} */ isLoaded(path, type) { const ad = new AssetDescription(path, type); return this.assets.has(ad); } /** * Same as {@link insert}, but will register the asset as pending until loader function resolves. * * NOTE: {@link AssetTransformer}s are not applied to the asset * * NOTE: any subsequent normal requests such as {@link get} will be routed to the same loader * * @template T * @param {string} path * @param {string} type * @param {function(progress?:function(current:number, total:number)):Promise<Asset<T>>} loader * @returns {Promise<Asset<T>>} loaded asset * @see insert * @example * manager.insertAsync('path/to/asset', 'my-type', async (progress) => { * const asset = await loadAssetFromNetwork('path/to/asset', 'type', progress); * return asset; * }); * */ insertAsync(path, type, loader) { const asset_descriptor = new AssetDescription(path, type); if (this.request_map.get(asset_descriptor)) { throw new Error(`Asset with path '${path}' and type '${type}' is already pending`); } const pending = new PendingAsset(asset_descriptor); this.request_map.set(asset_descriptor, pending); /** * * @param {number} current * @param {number} total */ const progress = (current, total) => { pending.progress.setValue(current); pending.progress.setUpperLimit(total); }; const asset_promise = loader(progress); const cleanup = () => { const existing = this.request_map.get(asset_descriptor); if (existing !== pending) { // looks like another resolution is pending, this completely invalidates current insersion request // Likely reason is that something else was inserted later on return false; } this.request_map.delete(asset_descriptor); return true; } return asset_promise.then(asset => { const existing = this.request_map.get(asset_descriptor); if (existing !== pending) { // looks like another resolution is pending, this completely invalidates current insersion request // Likely reason is that something else was inserted later on throw new Error(`Race condition. Asset with path '${path}' and type '${type}' was already resolved somewhere else.`); } this.insert(path, type, asset); return asset; }, error => { cleanup(); //rethrow throw error; }); } /** * Manually add a fully resolved resource * NOTE: {@link AssetTransformer}s are not applied to the asset * * @template T * @param {string} path * @param {string} type * @param {Asset<T>} asset */ insert(path, type, asset) { assert.isString(path, 'path'); assert.isString(type, 'type'); assert.isObject(asset, 'asset'); const asset_descritptor = new AssetDescription(path, type); const existing_resource = this.assets.get(asset_descritptor); if (existing_resource !== undefined && existing_resource !== asset) { console.warn(`Another asset under ${asset_descritptor} already exists and will be replaced`); } // clear failures this.#failures.delete(asset_descritptor); // check for assets in-flight const pending = this.request_map.get(asset_descritptor); if (pending !== undefined) { // TODO check 'active' flight sets as well // console.warn(`Asset with path '${path}' and type '${type}' is already pending, this operation will squash the asset in-flight`); this.request_map.delete(asset_descritptor); const requests = pending.requests; const request_count = requests.length; for (let i = 0; i < request_count; i++) { const request = requests[i]; this.#schedule_response(asset, request); } } this.assets.set(asset_descritptor, asset); } /** * * @param {Response} response * @private */ #process_response(response) { try { response.request.successCallback(response.asset); } catch (e) { console.error("Failed to execute asset success callback", e); } } /** * * @param {Asset} asset * @param {AssetRequest} request * @private */ #schedule_response(asset, request) { // technically you can schedule responses just fine, but commonly the user forgets to call 'startup' and so nothing happens which is perceived as a bug. assert.equal(this.#is_running, true, 'AssetManager must be running to schedule responses'); this.#response_queue.add(new Response(asset, request)); } /** * * @param {PendingAsset} asset * @private */ #schedule_load(asset) { if (this.#pending_asset_active_load_set.size < this.load_concurrency) { this.#dispatch_pending_asset(asset); } else { this.#pending_asset_wait_queue.push(asset); } } /** * Force load of the asset * @param {PendingAsset} asset * @private */ #force_load(asset) { // check if the asset is already being loaded if (this.#pending_asset_active_load_set.has(asset)) { return; } // remove from queue this.#pending_asset_wait_queue.delete(asset); // dispatch this.#dispatch_pending_asset(asset); } /** * Asset has been loaded successfully, failed, or aborted * @param {PendingAsset} pending_asset * @private */ #handle_asset_resolved(pending_asset) { // console.log(`Asset resolved ${pending_asset.description}`); // DEBUG const active_set = this.#pending_asset_active_load_set; this.request_map.delete(pending_asset.description); active_set.delete(pending_asset); const queue = this.#pending_asset_wait_queue; // schedule more if possible while (!queue.isEmpty() && active_set.size < this.load_concurrency) { const load = queue.pop(); this.#dispatch_pending_asset(load); } } /** * Dispatch load request to relevant loader * @param {PendingAsset} pendingAsset * @private */ #dispatch_pending_asset(pendingAsset) { // console.log(`Asset load dispatched ${pendingAsset.description}`); // DEBUG const requests = pendingAsset.requests; const assetDescription = pendingAsset.description; const type = assetDescription.type; const path = assetDescription.path; const loader = this.#loaders[type]; if (loader === undefined) { let reported_error = false; // no loader for (let i = 0; i < requests.length; i++) { const assetRequest = requests[i]; if (typeof assetRequest.failureCallback === "function") { assetRequest.failureCallback(`no loader exists for asset type '${type}', valid types are: ${Object.keys(this.#loaders).join(', ')}`); } else { //uncaught if (!reported_error) { console.error("Uncaught asset load failure: No loader for asset type", type); reported_error = true; } } } this.#handle_asset_resolved(pendingAsset); return; } // mark as being loaded this.#pending_asset_active_load_set.add(pendingAsset); const assets = this.assets; const failures = this.#failures; /** * * @param {Asset} loaded_asset */ const success = async (loaded_asset) => { if (pendingAsset.state === AssetLoadState.Succeeded) { // incorrect state console.warn(`Asset already resolved, duplicate success invocation. Ignored. AD:${assetDescription}`); return; } if (pendingAsset.state === AssetLoadState.Failed) { // incorrect state console.error(`Asset already failed. Unexpected resolution signal. AD:${assetDescription}`); } pendingAsset.state = AssetLoadState.Succeeded; let asset = loaded_asset; // apply the transform chain const transformers = this.#transformers[type]; if (transformers !== undefined) { const transformer_count = transformers.length; for (let i = 0; i < transformer_count; i++) { const transformer = transformers[i]; const transformed_asset = await transformer.transform(asset, assetDescription); if (typeof transformed_asset !== "object") { console.error('Transformer produced invalid result. Ignoring result.', transformer, assetDescription); } else { asset = transformed_asset; } } } // link asset description asset.description = assetDescription; // clear possible failure this.#failures.delete(assetDescription); // register asset assets.set(assetDescription, asset); // clear callbacks etc. this.#handle_asset_resolved(pendingAsset); // process callbacks for (let i = 0; i < requests.length; i++) { const request = requests[i]; this.#schedule_response(asset, request); } } const failure = (error) => { if (pendingAsset.state === AssetLoadState.Failed) { console.warn(`Asset already failed, this is a redundant invocation. AD: ${assetDescription}`); return; } if (pendingAsset.state === AssetLoadState.Succeeded) { // incorrect state console.error(`Asset already resolved. Unexpected failure signal. AD:${assetDescription}`); } pendingAsset.state = AssetLoadState.Failed; for (let i = 0; i < requests.length; i++) { const request = requests[i]; try { request.failureCallback(error); } catch (e) { console.error("Failed to execute asset failure callback", e); } } //clear callbacks etc. this.#handle_asset_resolved(pendingAsset); // record failure failures.add(assetDescription); } function progress(current, total) { requests.forEach(function (request) { if (typeof request.pogressCallback !== "function") { //progress callback is not a function, ignore return; } try { request.pogressCallback(current, total); } catch (e) { console.error("Failed to execute asset progress callback", e); } }); pendingAsset.progress.setValue(current); pendingAsset.progress.setUpperLimit(total); } // collect scopes const scopes = []; for (let i = 0; i < requests.length; i++) { const request = requests[i]; const request_scope = request.scope; if (request_scope !== null) { array_push_if_unique(scopes, request_scope) } } let scope; if (scopes.length > 0) { scope = AssetRequestScope.from(scopes); } else { scope = AssetRequestScope.GLOBAL; } const full_path = this.rootPath + path; // console.log(`Request type: ${type}, path: ${path}, scope: ${scope}`); try { const result = loader.load(scope, full_path, success, failure, progress); if (result instanceof Promise) { // allow promise responses result.catch(failure); } } catch (e) { console.error(`Loader failed on invocation. path=${path}, type=${type}`, 'Loader exception: ', e); failure(e); } } /** * * @param {AssetDescription} assetDescription * @param {AssetRequest} request * @private */ submitRequest(assetDescription, request) { const requestMap = this.request_map; let shouldSchedule = false; let pendingAsset = requestMap.get(assetDescription); if (pendingAsset === undefined) { pendingAsset = new PendingAsset(assetDescription); requestMap.set(assetDescription, pendingAsset); shouldSchedule = true; } pendingAsset.requests.push(request); if (shouldSchedule) { // not loading yet, lets create a load container and schedule it this.#schedule_load(pendingAsset); } else { // update priority queue if necessary this.#pending_asset_wait_queue.updateElementScore(pendingAsset); } if (request.getFlag(AssetRequestFlags.SkipQueue)) { this.#force_load(pendingAsset); } } /** * * @param {string} type * @return {boolean} */ hasLoaderForType(type) { return this.getLoaderByType(type) !== undefined; } /** * * @param {string} type * @returns {AssetLoader|undefined} */ getLoaderByType(type) { assert.isString(type, 'type'); return this.#loaders[type]; } /** * * @param {string} type * @return {AssetDescription[]} * @private */ #getLoadedAssetDescriptorsByType(type) { const loaded_assets = Array.from(this.assets.keys()); return loaded_assets.filter(t => t.type === type); } /** * Transformer will be applied to all assets of the given type in order of registration. * * Does not apply retroactively, assets that were already loaded will not be transformed. * * @template T * @param {string} type * @param {AssetTransformer<T>} transformer * @returns {void} * @see removeTransformer */ registerTransformer(type, transformer) { let transformers = this.#transformers[type]; if (transformers === undefined) { transformers = []; this.#transformers[type] = transformers; } transformers.push(transformer); // check for loaded assets const matching_assets = this.#getLoadedAssetDescriptorsByType(type); if (matching_assets.length > 0) { console.warn(`Following assets of matching type '${type}' are already loaded and transform is not applied to them:\n\t${matching_assets.join('\n\t')}`); } } /** * @template T * @param {string} type * @param {AssetTransformer<T>} transformer * @returns {boolean} true if removed, false if not found * @see registerTransformer */ unregisterTransformer(type, transformer) { const transformers = this.#transformers[type]; if (transformers === undefined) { // not found return false; } if (!array_remove_first(transformers, transformer)) { // not found return false; } // check for loaded assets const matching_assets = this.#getLoadedAssetDescriptorsByType(type); if (matching_assets.length > 0) { console.warn(`Following assets of matching type '${type}' are already loaded and transform was probably already applied to them:\n\t${matching_assets.join('\n\t')}`); } return true; } /** * Will register loader only if none exists for this type * @template T * @param {string} type * @param {AssetLoader<T>} loader * @returns {Promise<boolean>} true if registered, false otherwise */ async tryRegisterLoader(type, loader) { if (this.hasLoaderForType(type)) { return false; } await this.registerLoader(type, loader); return true; } /** * @template T * @param {string} type * @param {AssetLoader<T>} loader * @throws {Error} if a loader is already registered for the given type */ async registerLoader(type, loader) { assert.isString(type, 'type'); const existing_loader = this.getLoaderByType(type); if (existing_loader !== undefined) { if (existing_loader === loader) { // all is well, already registered return existing_loader; } else if (Object.getPrototypeOf(existing_loader) === Object.getPrototypeOf(loader)) { console.warn(`Another instance of this loader is already registered for type '${type}'. Ignoring.`); return existing_loader; } else { throw new Error(`Loader for type '${type}' is already registered`); } } let _loader = loader; if (typeof _loader === "function") { _loader = new AssetLoader(); _loader.load = loader; console.warn(`function-based loaders are deprecated (${type})`); } else { await _loader.link(this, this.#context); } this.#loaders[type] = _loader; return _loader; } /** * * @param {string} type */ async unregisterLoader(type) { const loader = this.getLoaderByType(type); if (loader === undefined) { // asset loader is not registered, nothing to do return; } // first remove the loader from registry so no further asset requests can be made delete this.#loaders[type]; // TODO address all pending requests, possibly waiting for all of them to finalize // finally unlink the loader await loader.unlink(); } /** * Retrieve an asset if it is loaded, returns null if the asset is not loaded. * Does not trigger loading if the asset is not loaded. * @template T * @param {String} path * @param {String} type * @returns {Asset<T>|null} */ tryGet(path, type) { const assetDescription = new AssetDescription(path, type); const asset = this.assets.get(assetDescription); if (asset !== undefined) { return asset; } else { return null; } } /** * @template T * @param {string} alias * @return {Promise<Asset<T>>} */ promiseByAlias(alias) { assert.isString(alias, 'alias'); // resolve alias const assetDescription = this.#aliases.get(alias); if (assetDescription === undefined) { return new Promise.reject(`Alias '${alias}' not found`); } return this.promise(assetDescription.path, assetDescription.path); } /** * * @param {string} alias * @return {AssetDescription|undefined} */ resolveAlias(alias) { // todo consider cloning result to protect against mutation return this.#aliases.get(alias); } /** * * @param {string} alias * @param {string} path * @param {string} type */ assignAlias(alias, path, type) { assert.isString(alias, 'alias'); assert.isString(path, 'path'); assert.isString(type, 'type'); const assetDescription = new AssetDescription(path, type); this.#aliases.set(alias, assetDescription); } } /** * @readonly * @type {boolean} */ AssetManager.prototype.isAssetManager = true;