UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

416 lines (327 loc) • 10.7 kB
import { assert } from "../../../core/assert.js"; import { array_push_if_unique } from "../../../core/collection/array/array_push_if_unique.js"; import Signal from "../../../core/events/signal/Signal.js"; import { IllegalStateException } from "../../../core/fsm/exceptions/IllegalStateException.js"; import { clamp01 } from "../../../core/math/clamp01.js"; import { objectKeyByValue } from "../../../core/model/object/objectKeyByValue.js"; import AssetLevel from "./AssetLevel.js"; import { AssetLoadSpec } from "./AssetLoadSpec.js"; /** * @enum {number} */ const StateKind = { INITIAL: 0, LOADING: 1, RESOLVED: 2, } /** * Utility for loading multiple assets together. * * NOTE: A pre-loader is not intended to be re-used. You have one collection of assets to be loaded, use one {@link AssetPreloader} instance for it. For another batch - use a separate instance. * * @example * const loader = new AssetPreloader(); * * loader.add("/images/cat.jpg", "image", AssetLevel.CRITICAL ); * loader.add("/images/meow.mp3", "sound", AssetLevel.NORMAL ); * * const assetManager:AssetManager = ...; // Obtain your asset manager * * loader.on.progress.add(({global}) => console.log(`loaded ${global.ratio*100}%`)); * loader.on.succeeded.add(()=> console.log("preload complete")); * * loader.load(assetManager); */ export class AssetPreloader { /** * * @return {number} */ get totalAssetCount() { return this.#asset_count++; } /** * * @type {number} */ #asset_count = 0; /** * * @type {number} */ #asset_count_loaded = 0; /** * * @type {number} */ #asset_count_failed = 0; /** * * @type {AssetManager} */ #asset_manager; /** * * @type {StateKind} */ #state = StateKind.INITIAL; /** * * @return {boolean} */ get isResolved() { return this.#state === StateKind.RESOLVED; } /** * @readonly * @type {AssetLoadSpec[][]} */ assets = []; /** * @readonly */ on = { /** * @type {Signal<AssetLoadSpec,number>} */ added: new Signal(), /** * Progress event. * Note that the numbers are arbitrary and just signify overall progress and not specific quantifies such as bytes or asset counts. * @type {Signal<{global:{current:number, total:number, progress: number}}>} */ progress: new Signal(), /** * Single priority has finished loading * @type {Signal} */ levelFinished: new Signal(), /** * An asset has failed to load */ error: new Signal(), /** * @type {Signal<{level:number, count:number}[]>} */ started: new Signal(), /** * Dispatched when all scheduled assets have been successfully loaded (no errors). * @type {Signal<number>} */ succeeded: new Signal(), /** * All assets are processed, even if some have errored out. * This is a more permissive version of {@link succeeded} signal. * @type {Signal<number,number>} */ resolved: new Signal(), }; constructor() { //build batch containers for each level for (let l in AssetLevel) { if (AssetLevel.hasOwnProperty(l)) { const level = AssetLevel[l]; this.assets[level] = []; } } } /** * * @param {string} uri * @param {string} type * @param {number} [level] defines priority group * @param {number} [priority] defines priority within the group * @return {AssetPreloader} */ add( uri, type, level = AssetLevel.OPTIONAL, priority = 0 ) { assert.isString(uri, 'uri'); assert.isString(type, 'type'); assert.isNumber(level, 'level'); assert.isNumber(priority, 'priority'); if (this.#state !== StateKind.INITIAL) { throw new IllegalStateException(`Can only .add in initial state. Actual state = ${objectKeyByValue(StateKind, this.#state)}`) } const assets = this.assets; //asset definition const def = AssetLoadSpec.fromJSON({ uri, type, priority }); let _level = level; if (!assets.hasOwnProperty(_level)) { //unsupported level was requested, rewrite to optional _level = AssetLevel.OPTIONAL; console.warn(`Unsupported level(=${level}) was requested for ${JSON.stringify(def)}, defaulting to optional`); } assets[_level].push(def); this.#asset_count++; this.on.added.send2(def, _level); return this; } /** * * @param {{uri:string, type:string, level?:number}[]} list */ addAll(list) { assert.isArray(list, 'list'); for (const el of list) { this.add(el.uri, el.type, el.level); } } #try_finish() { if (this.#state === StateKind.RESOLVED) { // nothing to do return; } const loaded = this.#asset_count_loaded; const failed = this.#asset_count_failed; const total_processed = loaded + failed; if (total_processed < this.#asset_count) { // not done yet return; } this.#state = StateKind.RESOLVED; // discard reference to asset manager this.#asset_manager = undefined; if (failed === 0) { this.on.succeeded.send1(loaded); } this.on.resolved.send2(loaded, failed); } /** * submit requests in batches in order of importance * @param {AssetLevel|number} level */ #load_level(level) { assert.enum(level, AssetLevel, "level"); // filter out assets of the specified level const batch = this.assets[level]; const batch_size = batch.length; if (batch_size === 0) { // a batch of 0 elements. // dispatch completion event this.on.levelFinished.dispatch(level); //early exit return; } let batchElementLoadedCount = 0; let batchElementFailedCount = 0; /** * * @param {Asset} asset */ const assetLoadSuccess = (asset) => { batchElementLoadedCount++; this.#asset_count_loaded++; let total_ratio = this.#asset_count > 0 ? clamp01(this.#asset_count_loaded / this.#asset_count) : 1; const level_ratio = batch_size > 0 ? clamp01(batchElementLoadedCount / batch_size) : 1; //dispatch progress this.on.progress.send1({ level: { id: level, current: batchElementLoadedCount, total: batch_size, progress: level_ratio, }, global: { current: this.#asset_count_loaded, total: this.#asset_count, progress: total_ratio } }); //monitor completion if ((batchElementLoadedCount + batchElementFailedCount) >= batch_size) { this.on.levelFinished.dispatch(level); } this.#try_finish(); } const assetLoadFailed = (e) => { this.#asset_count_failed++; batchElementFailedCount++; this.on.error.dispatch(e); this.#try_finish(); } //sort batch by priority batch.sort((a, b) => { return b.priority - a.priority; }); const am = this.#asset_manager; for (const def of batch) { try { am.get({ path: def.uri, type: def.type, callback: assetLoadSuccess, failure: assetLoadFailed }); } catch (e) { assetLoadFailed(e); } } } /** * * @param {AssetManager} assetManager * @return {this} */ load(assetManager) { assert.defined(assetManager, 'assetManager'); assert.equal(assetManager.isAssetManager, true, 'assetManager.isAssetManager !== true'); if (this.#state !== StateKind.INITIAL) { throw new IllegalStateException(`Expected initial state. Actual state = ${objectKeyByValue(StateKind, this.#state)}`); } this.#asset_manager = assetManager; this.#state = StateKind.LOADING; const on = this.on; //current level being processed const assets = this.assets; //dispatch init event const initEvent = assets.map((batch, level) => { return { level: level, count: batch.length }; }); on.started.send1(initEvent); /** * * @type {number[]} */ const levels = []; for (let level in assets) { const batch = assets[level]; if (batch === undefined || batch.length === 0) { // batch is empty, skip continue; } let level_i = parseInt(level); array_push_if_unique(levels, level_i); } let last_level_cursor = 0; const prod = () => { if (last_level_cursor < levels.length) { // load next const level_to_load = levels[last_level_cursor]; last_level_cursor++; this.on.levelFinished.addOne(prod); this.#load_level(level_to_load); } else { // we're done this.#try_finish(); } } prod(); return this; } } /** * @deprecated use {@link AssetPreloader} import instead. Renamed in v2.131.7 * @type {AssetPreloader} */ export const Preloader = AssetPreloader;