Overview
Expect this Unit to last about 2 hours. To demonstrate what you should have at the end of this Unit, check out the Demo for Unit 6.
Games need all kinds of content: images (textures), sound, data files for animation or object configuration or level data.
Many times, a single asset will be used multiple times. 10 enemies all using the same sprite. A single texture containing all the frames of a player's run animation. Typically we save memory by only have one version of the asset loaded and letting each object only have a reference to it.
This is especially important with online games. With HTML5, whenever we want an asset, we have to request it from the server. If we aren't smart about caching it, then each time we try to use an image or a data file then we will be unnecessarily requesting data from the server.
assets.js
The Asset Manager's job is to be a general purpose asset loader with the following key feataures:
- Knows how to load assets of different types without the user specifying what type it is
so it knows by file extension if it is an Image file, or an Audio file, or a data files
- Keeps loaded assets cached so they are easy to access at run-time
so files don't unnecessarily get re-download from the server
- Caches assets by either filename or a user-friendly key
so we can have a shorthand way to refer to an asset, which makes it easier to replace assets without changing game code
- Simple API that supports loading batches of assets together
so if we want to load all the assets needed for a certain level, it's easy to do it in a single call
- Provides notifications through a callback whenever assets are finished loading
so we can do something in gameplay like start a new level, create a sprite, etc
We'll be adding these features directly onto the Engine's definition through the addition of just a few functions!
Loading assets - API
Let's start with the API: We want it to be super easy to load an asset without the user caring about which asset type it is. Our public facing generic loading function, called load(), should take in a list of assets to load concurrently and dispatch each one to the correct loading fuction.
API Source Code:
//=========================================================================
// Load a list of assets, and call our callback when done
// Required:
// * assetList - a string or array of asset filenames (or key/filename pairs) to load
// Optional:
// * onFinishedCallback - triggered once all assets are finished loading
// * options (optional) - object that stores other optional parameters: { onFinishedCallback, onErrorCallback, onProgressCallback }
Engine.prototype.load = function(assetList, onFinishedCallback, options) {
// @TODO
}
The three types of assets we will be concerned with at this moment are Images, Audio and Data files, so we'll write three internal loading functions: loadAssetImage(), loadAssetAudio(), loadAssetOther(). All perform the same task with a different asset type. The function parameters are:
API Source Code:
//=========================================================================
// Loader for Audio files
// Required:
// * key - the user friendly resource name to associate the asset with, which is used to fetch the asset later
// * src - the actual filename that is requested from the server
// Optional:
// * onLoadCallback - gets linked to the load event. Recieves the key and Image object as parameters
// * onErrorCallback - gets linked to the error event in case something goes wrong. Recieves the filename of the file that failed to load
Engine.prototype.loadAssetImage = function(key, src, onLoadCallback, onErrorCallback) {
// @TODO
};
//=========================================================================
// Loader for Audio files
// Required:
// * key - the user friendly resource name used to fetch the asset later
// * src - the actually filename that is requested from the server
// Optional:
// * onLoadCallback - gets linked to the load event. Takes the key and Image object as parameters
// * onErrorCallback - gets linked to the error event. Takes the filename of the file that failed to load
Engine.prototype.loadAssetAudio = function(key, src, onLoadCallback, onErrorCallback) {
// @TODO
};
//=========================================================================
// Loader for Audio files
// Required:
// * key - the user friendly resource name used to fetch the asset later
// * src - the actually filename that is requested from the server
// Optional:
// * onLoadCallback - gets linked to the load event. Takes the key and Image object as parameters
// * onErrorCallback - gets linked to the error event. Takes the filename of the file that failed to load
Engine.prototype.loadAssetOther = function(key, src, onLoadCallback, onErrorCallback) {
// @TODO
};
Determining what type of asset to load
So, given that the user is only going to use the load() function, we'll need to figure out what type of asset it is based on the file extension.
Let's write a handy helper functions for determining asset types based on the file extension. We can also write a function that gives us the filename with the extension removed.
These functions are assigned to the Engine namespace, and not the .prototype, meaning they act as static functions (just like GetSingleton) and not as member functions for an Engine instance.
Source Code:
///////////////////////////////////////
// Augmentable list of asset types.
// This is to decide what type of asset
// a file is based on its file extension
//
var _assetTypes = {
// Image Assets
png: "Image",
jpg: "Image",
gif: "Image",
jpeg: "Image",
// Audio Assets
ogg: "Audio",
wav: "Audio",
m4a: "Audio",
mp3: "Audio"
};
///////////////////////////////////////
// Determine the type of an asset using
// the lookup table
//
Engine.determineAssetType = function(asset) {
// Determine the lowercase extension of the file
var fileExt = _(asset.split(".")).last().toLowerCase();
// Lookup the asset in the _assetTypes hash, or return other
return _assetTypes[fileExt] || 'Other';
};
///////////////////////////////////////
// Return a name without an extension
// This will be handy for Audio, where we may want
// to use an alternative extension based on what
// audio formats are supported by the browser
//
Engine._removeExtension = function(filename) {
return filename.replace(/\.(\w{3,4})$/,"");
};
Loading assets - Implementation
Since loading an asset is essentially just a request to the server to send a file to us, the process is asynchronous and we must use callbacks to be notified if and when the file loading has completed.
If you've worked in HTML to display an image or play a sound, then you're familiar with the Images and Audio objects, which will be the kinds of objects we use to both request the asset from the server and utilize it in game.
The loadAssetOther() function is what we'll use for our JSON data files. JSON is a text based data format used in games, similiar to how XML might be used, but is super lightweight and extremely convienent to utilize in JavaScript since it is syntactically identical to the code for creating JavaScript objects.
loadAssetImage()
Image assets are loaded by creating an HTML/JavaScript Image object. jQuery helps us here by assigning callbacks to two important events: 'load' if it succeeds or 'error' if it fails.
Once the Image has loaded, we call the success callback passing along the key parameter here so it can be cached.
Source Code:
Engine.prototype.loadAssetImage = function(key, src, onLoadCallback, onErrorCallback) {
var filename = src;
var img = new Image(); // Image is an HTML object
$(img).on('load', function() { onLoadCallback(key,img); });
$(img).on('error', function() { onErrorCallback(filename); } );
img.src = filename; // Once we set this property, the image is requested from the server
};
loadAssetAudio()
Audio files are loaded by creating an HTML/JavaScript Audio object. Creating one would normally be just as easy as an Image object, but the truth is that audio is a super tricky issue in HTML5 games. Not all browsers necessarily support the same audio file type - some support the Ogg format, other support MP3, some other AAC.
A great way to deal with this is to have the asset loader strip off the file extension and postfix the one that is actually supported. The way we check that is by calling the Audio.canPlayType with the mime type associated with the file extension. It also means that your server should have multiple formats of the audio available to be requested by the client.
We also add an extra feature to disable loading audio if we have marked sound as disabled (muted by the player).
Source Code:
var _audioMimeTypes = { mp3: "audio/mpeg",
ogg: "audio/ogg; codecs='vorbis'",
m4a: "audio/m4a",
wav: "audio/wav" };
Engine.prototype.loadAssetAudio = function(key, src, onLoadCallback, onErrorCallback) {
// Check for audio support. If non then return
if(!document.createElement("audio").play || !this.sound) {
onLoadCallback(key,null);
return;
}
var snd = new Audio(),
baseName = Engine._removeExtension(src),
extension = null,
filename = null;
// Find a supported type
extension =
_(this.audioSupported)
.detect(function(extension) {
return snd.canPlayType(_audioMimeTypes[extension]) ? extension : null;
});
// No supported audio = trigger ok callback anyway
if(!extension) {
onLoadCallback(key,null);
return;
}
filename = baseName + "." + extension;
// If sound is turned off, call the callback immediately
// Check if the load succeeds by using the 'canplaythrough' event
$(snd).on('error', function() { onErrorCallback(filename); } );
$(snd).on('canplaythrough',function() {
onLoadCallback(key,snd);
});
snd.src = filename;
snd.load();
};
loadAssetOther()
Any other kind of asset that doesn't get loaded through a specific HTML/JavaScript object needs to be loaded through an AJAX get request, which we can easily do using jQuery's get() function. Since we are focusing on game engine development and not web development, I won't go into the HTTP GET request more than just showing you how to use it. If you plan on continuing your HTML5 game and having any online feature's such as communicating with a server, you'll likely need to a bit deeper into the topic.
NOTE: When we request JSON data from the server, we get back a JavaScript object, not the JSON string.
Source Code:
Engine.prototype.loadAssetOther = function(key, src, onLoadCallback, onErrorCallback) {
var filename = src;
$.get(filename,function(data) {
onLoadCallback(key,data);
}).fail( function(jqXHR, textStatus, errorThrown) {
if( textStatus === "timeout" ) {
onErrorCallback( "The server is not responding" );
}
onErrorCallback(filename + "\n" + errorThrown.message);
} );
};
load()
Now that we've seen the implementation of our individual asset loaders, let's take a look at the general-purpose one that the user will actually use!
The basic structure of the code is to loop over asset hash and determine the asset's type to dispatch appropriate loader function. It also supports a progress callback for each item, so we can show the user something like a loading bar, and caching the assets by storing them in a hash.
Before we start loading any assets, we setup a callback for handling an error, and for tracking progress internally to this function. Each time an asset is successfully loaded, we store it in a hash array called .cachedAssets so it can be utilized later.
We cache loaded assets by an optional user-friendly key name, ie. "player_run_anims_128x128.png" could be cached unded the name "playerrun" so it's easier to reference in game code, especially since we could change the file type to a .bmp and the game could could care less. If no key is provided the system will just use the filename.
To support this we make the assetList super flexible. It can be of any of these types:
- a string - a filename of a single asset to load (the key will be the same as the filename)
- an object - can represent multiple assets, where each property is the asset key and the value is the filename
- an array of strings, or of objects, or a mix of both - so the user could concatenate multiple sets of assets before passing them all over to be loaded together.
This function uses Underscore extensively, which you should be comfortable enough with by now to look up and understand it's usage here.
Source Code:
Engine.prototype.load = function(assetList, onFinishedCallback, options) {
// There are a number of callbacks that will have their context
// and still need to refer to the original this
var engine = this;
// Asset hash storing any loaded assets
if( engine.cachedAssets === undefined ) {
engine.cachedAssets = {};
}
if( typeof(onFinishedCallback) == "object" ) {
options = onFinishedCallback;
onFinishedCallback = options.onFinishedCallback;
}
// Make sure we have an options hash to work with
if(!options) { options = {}; }
// Get our progressCallback if we have one
var progressCallback = options.onProgressCallback;
// Error handling - we stop loading assets once there is an error
// and also trigger the errorCallback or default to an alert
var errors = false,
errorCallback = function(itemName) {
errors = true;
(options.onErrorCallback ||
function(itemName) { console.error("Error Loading: " + itemName ); })(itemName);
};
// Copy over assetList into a local object.
// If the user passed in an array, convert it
// to a hash with lookups by filename
var assetObj = {};
if(_.isArray(assetList)) {
_.each(assetList,function(itemName) {
if(_.isObject(itemName)) {
_.extend(assetObj,itemName);
} else {
assetObj[itemName] = itemName;
}
});
} else if(_.isString(assetList)) {
// Turn assets into an object if it's a string
assetObj[assetList] = assetList;
} else {
// Otherwise just use the assets as is
assetObj = assetList;
}
// Find the # of assets we're loading
var assetsTotal = _(assetObj).keys().length,
assetsRemaining = assetsTotal;
// Closure'd callback gets called per-asset each time
// one is successfully loaded
var assetLoadedCallback = function(key,obj) {
if(errors) return;
// Add the object to our asset list
engine.cachedAssets[key] = obj;
// We've got one less asset to load
assetsRemaining--;
// Update our progress if we have it
if(progressCallback) {
progressCallback(assetsTotal - assetsRemaining, assetsTotal, key);
}
// If we're out of assets, call our full callback
// if there is one
if(assetsRemaining === 0 && onFinishedCallback) {
onFinishedCallback.apply(engine);
}
};
// Now actually load each asset
_.each(assetObj,function(itemName,key) {
// Determine the type of the asset
var assetType = Engine.determineAssetType(itemName);
// If we already have the asset loaded,
// don't load it again but still trigger the callback to update our progress
if(engine.cachedAssets[key]) {
assetLoadedCallback(key,engine.cachedAssets[key]);
} else {
// Call the appropriate loader function
// passing in our per-asset callback
// Dropping our asset by name into this.cachedAssets
var loadingFunctionName = "loadAsset" + assetType;
engine[loadingFunctionName](key,itemName, assetLoadedCallback, errorCallback ); //function() { errorCallback(itemName); });
}
});
};
Requesting cached assets - API and Implementation
Well, this one is so straight forward I won't even bother separating the API from the implementation.
We already know how we're storing our cached assets, so for retrieving them we need only to write a function that does some safety checking first.
getAsset()
Source Code:
//=========================================================================
// Getter for an asset by key
// Use this over accessing the assets array directly just in case there
// is no asset loaded.
//
Engine.prototype.getAsset = function(key) {
if( this.cachedAssets === undefined ) {
console.error( "No Assets loaded! Error looking for asset key", key);
return;
}
return this.cachedAssets[key];
};
Test code
Look at this test code and make sure you understand what's going on.
Question: If you remember we don't load assets that we already have cached, and the system just pretends it succeeds as normal. But assets are cached by the key, so technically you could have the same asset file cached multiple times under different key names. Can you tell how many times "title_music.mp3" gets loaded here?
Source Code:
new Engine();
// Single asset load request, the key will be the same as the filename
var assetList_demo1 = "title_music.mp3";
// An array of asset filenames. Again the key will be the same as the filename
var assetList_demo2 = [
"title_music.mp3",
"sprite.png",
"animation_data.json"
];
// Use an object to associate user-friendly key names to be mapped to actual filename
var assetList_demo3 = {
"titlesong": "title_music.mp3",
"playerSprite": "gamedata/Character Boy.png"
};
// A combination of strings and objects in an array.
var assetList_demo4 = [
{
"playerSprite": "playerSprite.png",
"playerAnims:": "player_anim_data.json"
},
"treeSprite.png",
"buildingSprite.png"
];
var onFinishedCallback = function() {
alert("All asset loads finished!");
};
var options = {
onErrorCallback: function(assetName) {
alert("Could not load asset: " + assetName );
},
onProgressCallback: function( assetIndex, assetsTotal ) {
console.log( "Asset loading Progress: " + (assetIndex / assetsTotal) );
}
};
Engine().GetSingleton().load( assetList_demo1, onFinishedCallback, options );
Engine().GetSingleton().load( assetList_demo2, onFinishedCallback, options );
Engine().GetSingleton().load( assetList_demo3, onFinishedCallback, options );
Engine().GetSingleton().load( assetList_demo4, onFinishedCallback, options );
Homework
Part 1. Create and add the assets.js file to your project in the /js_engine sub-directory.
Part 2. Fill out assets.js with the code from this Unit.
Part 3. Create a new subdirectory to store game assets in, such as /gamedata. Put a few sample assets in there (an image file, an audio file, a JSON file)
Part 4. Load an Image asset. If you are comfortable with manipulating the DOM directly or through jQuery, try displaying that image in the HTML page after it's finished loading.
Part 5. Load an Audio asset. Once it's loaded play it
Part 6. Load a JSON data file (If testing locally, this is where you'll need to be using a web hosting application like Mongoose)
Part 7. Try loading a batch of assets, and update a loading bar (one idea is that you could use HTML and CSS to display and update a loading bar).