shaku
Version:
A simple and effective JavaScript game development framework that knows its place!
536 lines (479 loc) • 18.6 kB
JavaScript
/**
* Implement the assets manager.
*
* |-- copyright and license --|
* @module Shaku
* @file shaku\src\assets\assets.js
* @author Ronen Ness (ronenness@gmail.com | http://ronenness.com)
* @copyright (c) 2021 Ronen Ness
* @license MIT
* |-- end copyright and license --|
*
*/
'use strict';
const SoundAsset = require('../assets/sound_asset.js');
const IManager = require('../manager.js');
const BinaryAsset = require('./binary_asset.js');
const JsonAsset = require('./json_asset.js');
const TextureAsset = require('./texture_asset.js');
const FontTextureAsset = require('./font_texture_asset');
const MsdfFontTextureAsset = require('./msdf_font_texture_asset.js');
const Asset = require('./asset.js');
const TextureAtlas = require('./texture_atlas_asset.js');
const TextureAtlasAsset = require('./texture_atlas_asset.js');
const Vector2 = require('../utils/vector2.js');
const _logger = require('../logger.js').getLogger('assets');
// add a 'isXXX' property to all util objects, for faster alternative to 'instanceof' checks.
// for example this will generate a 'isVector3' that will be true for all Vector3 instances.
SoundAsset.prototype.isSoundAsset = true;
BinaryAsset.prototype.isBinaryAsset = true;
JsonAsset.prototype.isJsonAsset = true;
TextureAsset.prototype.isTextureAsset = true;
FontTextureAsset.prototype.isFontTextureAsset = true;
MsdfFontTextureAsset.prototype.isMsdfFontTextureAsset = true;
TextureAtlasAsset.prototype.isTextureAtlasAsset = true;
/**
* Assets manager class.
* Used to create, load and cache game assets, which includes textures, audio files, JSON objects, etc.
* As a rule of thumb, all methods to load or create assets are async and return a promise.
*
* To access the Assets manager you use `Shaku.assets`.
*/
class Assets extends IManager
{
/**
* Create the manager.
*/
constructor()
{
super();
this._loaded = null;
this._waitingAssets = new Set();
this._failedAssets = new Set();
this._successfulLoadedAssetsCount = 0;
/**
* Optional URL root to prepend to all loaded assets URLs.
* For example, if all your assets are under '/static/assets/', you can set this url as root and omit it when loading assets later.
*/
this.root = '';
/**
* Optional suffix to add to all loaded assets URLs.
* You can use this for anti-cache mechanism if you want to reload all assets. For example, you can set this value to "'?dt=' + Date.now()".
*/
this.suffix = '';
}
/**
* Wrap a URL with 'root' and 'suffix'.
* @param {String} url Url to wrap.
* @returns {String} Wrapped URL.
*/
#_wrapUrl(url)
{
if (!url) { return url; }
return this.root + url + this.suffix;
}
/**
* Get list of assets waiting to be loaded.
* This list will be reset if you call clearCache().
* @returns {Array<string>} URLs of assets waiting to be loaded.
*/
get pendingAssets()
{
return Array.from(this._waitingAssets);
}
/**
* Get list of assets that failed to load.
* This list will be reset if you call clearCache().
* @returns {Array<string>} URLs of assets that had error loading.
*/
get failedAssets()
{
return Array.from(this._failedAssets);
}
/**
* Return a promise that will be resolved only when all pending assets are loaded.
* If an asset fails, will reject.
* @example
* await Shaku.assets.waitForAll();
* console.log("All assets are loaded!");
* @returns {Promise} Promise to resolve when all assets are loaded, or reject if there are failed assets.
*/
waitForAll()
{
return new Promise((resolve, reject) => {
_logger.debug("Waiting for all assets..");
// check if all assets are loaded or if there are errors
let checkAssets = () => {
// got errors?
if (this._failedAssets.size !== 0) {
_logger.warn("Done waiting for assets: had errors.");
return reject(this.failedAssets);
}
// all done?
if (this._waitingAssets.size === 0) {
_logger.debug("Done waiting for assets: everything loaded successfully.");
return resolve();
}
// try again in 1 ms
setTimeout(checkAssets, 1);
};
checkAssets();
});
}
/**
* @inheritdoc
* @private
*/
setup()
{
return new Promise((resolve, reject) => {
_logger.info("Setup assets manager..");
this._loaded = {};
resolve();
});
}
/**
* @inheritdoc
* @private
*/
startFrame()
{
}
/**
* @inheritdoc
* @private
*/
endFrame()
{
}
/**
* Get already-loaded asset from cache.
* @private
* @param {String} url Asset URL.
* @param {type} type If provided will make sure asset is of this type. If asset found but have wrong type, will throw exception.
* @returns Loaded asset or null if not found.
*/
#_getFromCache(url, type)
{
let cached = this._loaded[url] || null;
if (cached && type) {
if (!(cached instanceof type)) {
throw new Error(`Asset with URL '${url}' is already loaded, but has unexpected type (expecting ${type})!`);
}
}
return cached;
}
/**
* Load an asset of a given type and add to cache when done.
* @private
* @param {Asset} newAsset Asset instance to load.
* @param {*} params Optional loading params.
*/
async #_loadAndCacheAsset(newAsset, params)
{
// extract url and typename, and add to cache
let url = newAsset.url;
let typeName = newAsset.constructor.name;
this._loaded[url] = newAsset;
this._waitingAssets.add(url);
// initiate loading
return new Promise(async (resolve, reject) => {
// load asset
_logger.debug(`Load asset [${typeName}] from URL '${url}'.`);
try {
await newAsset.load(params);
}
catch (e) {
_logger.warn(`Failed to load asset [${typeName}] from URL '${url}'.`);
this._failedAssets.add(url);
return reject(e);
}
// update waiting assets count
this._waitingAssets.delete(url);
// make sure valid
if (!newAsset.valid) {
_logger.warn(`Failed to load asset [${typeName}] from URL '${url}'.`);
this._failedAssets.add(url);
return reject("Loaded asset is not valid!");
}
_logger.debug(`Successfully loaded asset [${typeName}] from URL '${url}'.`);
// resolve
this._successfulLoadedAssetsCount++;
resolve(newAsset);
});
}
/**
* Get asset directly from cache, synchronous and without a Promise.
* @param {String} url Asset URL or name.
* @returns {Asset} Asset or null if not loaded.
*/
getCached(url)
{
url = this.#_wrapUrl(url);
return this._loaded[url] || null;
}
/**
* Get / load asset of given type, and return a promise to be resolved when ready.
* @private
*/
#_loadAssetType(url, typeClass, params)
{
// normalize URL
url = this.#_wrapUrl(url);
// try to get from cache
let _asset = this.#_getFromCache(url, typeClass);
// check if need to create new and load
var needLoad = false;
if (!_asset) {
_asset = new typeClass(url);
needLoad = true;
}
// create promise to load asset
let promise = new Promise(async (resolve, reject) => {
if (needLoad) {
await this.#_loadAndCacheAsset(_asset, params);
}
_asset.onReady(() => {
resolve(_asset);
});
});
// return promise with asset attached to it
promise.asset = _asset;
return promise;
}
/**
* Create and init asset of given class type.
* @private
*/
#_createAsset(name, classType, initMethod, needWait)
{
// create asset
name = this.#_wrapUrl(name);
var _asset = new classType(name || generateRandomAssetName());
// if this asset need waiting
if (needWait) {
this._waitingAssets.add(name);
}
// generate render target in async
let promise = new Promise(async (resolve, reject) => {
// make sure not in cache
if (name && this._loaded[name]) { return reject(`Asset of type '${classType.name}' to create with URL '${name}' already exist in cache!`); }
// create and return
await initMethod(_asset);
if (name) {
this._loaded[name] = _asset;
}
resolve(_asset);
});
// attach asset to promise
promise.asset = _asset;
return promise;
}
/**
* Load a sound asset. If already loaded, will use cache.
* @example
* let sound = await Shaku.assets.loadSound("assets/my_sound.ogg");
* @param {String} url Asset URL.
* @returns {Promise<SoundAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadSound(url)
{
return this.#_loadAssetType(url, SoundAsset, undefined);
}
/**
* Load a texture asset. If already loaded, will use cache.
* @example
* let texture = await Shaku.assets.loadTexture("assets/my_texture.png", {generateMipMaps: false});
* @param {String} url Asset URL.
* @param {*=} params Optional params dictionary. See TextureAsset.load() for more details.
* @returns {Promise<TextureAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadTexture(url, params)
{
return this.#_loadAssetType(url, TextureAsset, params);
}
/**
* Create a render target texture asset. If already loaded, will use cache.
* @example
* let width = 512;
* let height = 512;
* let renderTarget = await Shaku.assets.createRenderTarget("optional_render_target_asset_id", width, height);
* @param {String | null} name Asset name (matched to URLs when using cache). If null, will not add to cache.
* @param {Number} width Texture width.
* @param {Number} height Texture height.
* @param {Number=} channels Texture channels count. Defaults to 4 (RGBA).
* @returns {Promise<TextureAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
createRenderTarget(name, width, height, channels)
{
// make sure we have valid size
if (!width || !height) {
throw new Error("Missing or invalid size!");
}
// create asset and return promise
return this.#_createAsset(name, TextureAsset, (asset) => {
asset.createRenderTarget(width, height, channels);
});
}
/**
* Create a texture atlas asset.
* @param {String | null} name Asset name (matched to URLs when using cache). If null, will not add to cache.
* @param {Array<String>} sources List of URLs to load textures from.
* @param {Number=} maxWidth Optional atlas textures max width.
* @param {Number=} maxHeight Optional atlas textures max height.
* @param {Vector2=} extraMargins Optional extra empty pixels to add between textures in atlas.
* @returns {Promise<TextureAtlas>} Promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
createTextureAtlas(name, sources, maxWidth, maxHeight, extraMargins)
{
// make sure we have valid size
if (!sources || !sources.length) {
throw new Error("Missing or invalid sources!");
}
// create asset and return promise
return this.#_createAsset(name, TextureAtlasAsset, async (asset) => {
try {
await asset._build(sources, maxWidth, maxHeight, extraMargins);
this._waitingAssets.delete(name);
this._successfulLoadedAssetsCount++;
}
catch (e) {
_logger.warn(`Failed to create texture atlas: '${e}'.`);
this._failedAssets.add(url);
}
}, true);
}
/**
* Load a font texture asset. If already loaded, will use cache.
* @example
* let fontTexture = await Shaku.assets.loadFontTexture('assets/DejaVuSansMono.ttf', {fontName: 'DejaVuSansMono'});
* @param {String} url Asset URL.
* @param {*} params Optional params dictionary. See FontTextureAsset.load() for more details.
* @returns {Promise<FontTextureAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadFontTexture(url, params)
{
return this.#_loadAssetType(url, FontTextureAsset, params);
}
/**
* Load a MSDF font texture asset. If already loaded, will use cache.
* @example
* let fontTexture = await Shaku.assets.loadMsdfFontTexture('DejaVuSansMono.font', {jsonUrl: 'assets/DejaVuSansMono.json', textureUrl: 'assets/DejaVuSansMono.png'});
* @param {String} url Asset URL.
* @param {*=} params Optional params dictionary. See MsdfFontTextureAsset.load() for more details.
* @returns {Promise<MsdfFontTextureAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadMsdfFontTexture(url, params)
{
return this.#_loadAssetType(url, MsdfFontTextureAsset, params);
}
/**
* Load a json asset. If already loaded, will use cache.
* @example
* let jsonData = await Shaku.assets.loadJson('assets/my_json_data.json');
* console.log(jsonData.data);
* @param {String} url Asset URL.
* @returns {Promise<JsonAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadJson(url)
{
return this.#_loadAssetType(url, JsonAsset);
}
/**
* Create a new json asset. If already exist, will reject promise.
* @example
* let jsonData = await Shaku.assets.createJson('optional_json_data_id', {"foo": "bar"});
* // you can now load this asset from anywhere in your code using 'optional_json_data_id' as url
* @param {String} name Asset name (matched to URLs when using cache). If null, will not add to cache.
* @param {Object|String} data Optional starting data.
* @returns {Promise<JsonAsset>} promise to resolve with asset instance, when ready. You can access the loading asset with `.asset` on the promise.
*/
createJson(name, data)
{
// make sure we have valid data
if (!data) {
return reject("Missing or invalid data!");
}
// create asset and return promise
return this.#_createAsset(name, JsonAsset, (asset) => {
asset.create(data);
});
}
/**
* Load a binary data asset. If already loaded, will use cache.
* @example
* let binData = await Shaku.assets.loadBinary('assets/my_bin_data.dat');
* console.log(binData.data);
* @param {String} url Asset URL.
* @returns {Promise<BinaryAsset>} promise to resolve with asset instance, when loaded. You can access the loading asset with `.asset` on the promise.
*/
loadBinary(url)
{
return this.#_loadAssetType(url, BinaryAsset);
}
/**
* Create a new binary asset. If already exist, will reject promise.
* @example
* let binData = await Shaku.assets.createBinary('optional_bin_data_id', [1,2,3,4]);
* // you can now load this asset from anywhere in your code using 'optional_bin_data_id' as url
* @param {String} name Asset name (matched to URLs when using cache). If null, will not add to cache.
* @param {Array<Number>|Uint8Array} data Binary data to set.
* @returns {Promise<BinaryAsset>} promise to resolve with asset instance, when ready. You can access the loading asset with `.asset` on the promise.
*/
createBinary(name, data)
{
// make sure we have valid data
if (!data) {
return reject("Missing or invalid data!");
}
// create asset and return promise
return this.#_createAsset(name, BinaryAsset, (asset) => {
asset.create(data);
});
}
/**
* Destroy and free asset from cache.
* @example
* Shaku.assets.free("my_asset_url");
* @param {String} url Asset URL to free.
*/
free(url)
{
url = this.#_wrapUrl(url);
let asset = this._loaded[url];
if (asset) {
asset.destroy();
delete this._loaded[url];
}
}
/**
* Free all loaded assets from cache.
* @example
* Shaku.assets.clearCache();
*/
clearCache()
{
for (let key in this._loaded) {
this._loaded[key].destroy();
}
this._loaded = {};
this._waitingAssets = new Set();
this._failedAssets = new Set();
}
/**
* @inheritdoc
* @private
*/
destroy()
{
this.clearCache();
}
}
// generate a random asset URL, for when creating assets that are outside of cache.
var _nextRandomAssetId = 0;
function generateRandomAssetName()
{
return "_runtime_asset_" + (_nextRandomAssetId++) + "_";
}
// export assets manager
module.exports = new Assets();