@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
416 lines (327 loc) • 10.7 kB
JavaScript
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;