@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
1,046 lines (838 loc) • 31.1 kB
JavaScript
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;