1
0
Fork 0
lasertank-js/lib/TileMasker.js

412 lines
14 KiB
JavaScript

/**
* Handles the masking of tile sets
*
* Copyright (C) 2012 Mike Gerwitz
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
* This handles the masking and slicing of tiles found in LTG files (see
* LtgLoader for more information).
*
* We must think back to the good ol' days - before transparency was represented
* in the image format itself and before alphatransparency even existed. Bitmaps
* have no alpha channel like PNG, nor can they designate any color as
* transparent like GIFs. That is what the mask bitmap is for. In the case of
* the LT mask, black is used to denote opacity whereas white denotes
* transparency. Furthermore, not all tiles have masks associated with them
* (e.g. blocks and walls). Rather than those mask tiles being represented as
* solid black boxes, some tileset masks are solid *white*. This, as we will
* see, complicates our masking algorithm.
*
* When rendering the tiles, we obviously need to support transparency. In the
* browser, this must be done either with a GIF or an alpha channel. In other
* words --- we need to apply the mask to the tiles to result in a tile set with
* an alpha channel which can be directly drawn to the screen. Applying this
* mask when the LTG file is initially loaded will also improve performance by
* eliminating the need to re-apply the mask each and every time a particular
* tile is drawn.
*
* To apply this mask, since CSS masking is (at this time) in its infancy, we
* must use the canvas element. Canvas XOR masks, however, do not help us ---
* they apply the mask using the alpha channel, whereas we want to apply based
* on the brightness of a particular pixel. Therefore, our solution will be to
* grab the image data and manipulate each pixel individually, adjusting the
* alpha channel to either 255 for opaque or 0 for transparent. Since the mask
* is either black or white, we needn't calculate the brightness --- we can
* simply check the color value of a single channel (e.g. R) and make the pixel
* entirely transparent if the value is !== 0.
*
* Remember that certain tiles contain no mask. Since they are not filled with
* black, we must be able to recognize when we should *not* apply a mask;
* otherwise, the tile will be entire transparent! Coupling this unfortunate
* detail with the fact that putImageData() does not support slicing like
* drawImage() does, it makes more sense to store each tile individually in
* memory rather than as a single image. Otherwise, we would be forced to use
* drawImage() and re-apply the mask each time a tile is drawn, which is not
* worth the little extra memory that will be consumed by separate tile images.
* Given this implementation, we may then let the LtgLoader know specifically
* what tiles should have masks applied.
*
* With that, we should have an easy-to-use set of tile graphics ready for
* rendering.
*/
/**
* Slices tiles and applies masks
*/
ltjs.TileMasker = Class( 'TileMasker',
{
/**
* Canvas 2D context (used for masking and tile slicing)
* @type {CanvasRenderingContext2d}
*/
'private _context': null,
/**
* Tile definition to use for all operations
* @type {Array.<Array.<string,number>>}
*/
'private _tileDfn': null,
/**
* Width of each individual tile
* @type {number}
*/
'private _tileWidth': 0,
/**
* Height of each individual tile
* @type {number}
*/
'private _tileHeight': 0,
/**
* Number of tiles per row
* @type {number}
*/
'private _tilesPerRow': 0,
/**
* Calculated width of tile set provided a tile definition
* @type {number}
*/
'private _setWidth': 0,
/**
* Calculated height of tile set provided a tile definition
* @type {number}
*/
'private _setHeight': 0,
/**
* Initialize loader with a tile definition
*
* The tile definition defines how a tile set should be interpreted. This
* allows us to support *any* type of tile set -- not just those that are
* defined by the original game.
*
* @param {ltjs.TileDfn} tile_dfn tile definition object
*/
__construct: function( tile_dfn )
{
if ( !( Class.isA( ltjs.TileDfn, tile_dfn ) ) )
{
throw TypeError( "Invalid tile definition provided." );
}
// pre-calculate our tile information
this._tileDfn = tile_dfn.getTileDefinition();
this._calcSetDimensions( tile_dfn );
// rather than accepting a context, we will create our own canvas in
// memory to perform our operations (it will not be added to the DOM, so
// these operations will not be visible to the user)
var context = document.createElement( 'canvas' ).getContext( '2d' );
// size the canvas so that it can fit the entire tileset
context.canvas.width = this._setWidth;
context.canvas.height = this._setHeight;
this._context = context;
},
/**
* Calculate tile set dimensions from the given tile definition object
*
* These dimensions are cached, as these are frequently used and it is
* unwise to continuously invoke methods unnecessarily (who knows what the
* developer of the given tile definition did!).
*
* @param {ltjs.TileDfn} tile_dfn tile definition object
*
* @return {undefined}
*/
'private _calcSetDimensions': function( tile_dfn )
{
// these vars are for clarity
var sizes = tile_dfn.getTileDimensions(),
n = this._tileDfn.length;
// store values so that we do not have to make additional calls to our
// TileDfn instance
this._tileWidth = sizes[ 0 ];
this._tileHeight = sizes[ 1 ];
this._tilesPerRow = sizes[ 2 ];
// calculate full width and height of tile set
this._setWidth = ( this._tileWidth * this._tilesPerRow );
this._setHeight = (
Math.ceil( n / this._tilesPerRow ) * this._tileHeight
);
},
/**
* Retrieve image data for each individual tile (pre-masked)
*
* Each tile will have the mask applied before being returned. This allows
* the tile to be rendered without any additional processing, but at the
* cost of additional overhead for the tile loading (which is well worth it,
* since we will be spending the majority of our time rendering tiles, not
* loading them).
*
* This operation is asynchronous, but the masking algorithm is not. If
* performance is a concern during the masking process (for example, if one
* were to create an extension to support very large tilesets), one can
* extend this class to make the operation asynchronous.
*
* @param {string} bmp_game game tileset bitmap (URL or data URL)
* @param {string} bmp_mask game tileset mask bitmap (URL or data URL)
*
* @param {function(Object)} callback function to call with tiles
*
* @return {ltjs.TileMasker} self
*/
'public getMaskedTiles': function( bmp_game, bmp_mask, callback )
{
var _self = this;
this._getImageData( bmp_mask, function( data_mask )
{
// we will render the game image after the mask so that it does not
// need to be re-rendered in order to pull out the image data
_self._renderImage( bmp_game, function()
{
_self.getMaskedTileSet( data_mask, callback );
} );
} );
return this;
},
/**
* For use by subtypes that may need access to the otherwise private data
*
* See getMaskedTileSet().
*
* @return {Array.<number>} tile width, height and number per row
*/
'protected getTileDimensions': function()
{
return [ this._tileWidth, this._tileHeight, this._tilesPerRow ];
},
/**
* Apply mask to each tile and return individual tiles
*
* This method requires that the tileset has already been rendered to the
* canvas.
*
* Note that, although this method accepts a callback, it is not
* asynchronous. It does, however, allow subtypes to make this algorithm
* asynchronous should the need arise. See getMaskedTiles() for more
* information.
*
* @param {Object} data_mask image data for mask bitmap
* @param {function(Object)} callback function to call with tiles
*
* @return {undefined}
*/
'virtual protected getMaskedTileSet': function( data_mask, callback )
{
var tdata = this._tileDfn,
tiles = {},
i = -1,
len = tdata.length,
// shorten the names
tw = this._tileWidth,
th = this._tileHeight,
xn = this._tilesPerRow;
// create each tile (preserving order, thus no decrementing)
while ( ++i < len )
{
var name = tdata[ i ][ 0 ],
mask = tdata[ i ][ 1 ],
// calculate the X and Y position of this tile based on the tile
// and bitmap dimensions
x = ( ( i % xn ) * th ),
y = ( ( Math.floor( i / xn ) ) * tw );
// the third index indicates whether or not a mask should be applied
// to the tile
tiles[ name ] = ( mask === 1 )
? this.getMaskedTileData( data_mask, x, y )
: this.getTileData( x, y );
}
callback( tiles );
},
/**
* Retrieve a tile with the mask applied
*
* This algorithm uses the image rendered to the canvas along with the given
* mask image data to alter the alpha channel of the tile, producing a tile
* with the appropriate transparency.
*
* The LaserTank mask considered black to be opaque and white to be
* transparent. Since those are the only two colors permitted, we can
* improve performance by checking only a single channel rather than
* calculating brightness. If not black, the respective pixel in the tile
* will be considered transparent.
*
* Only the image data for the requested tile will be returned. That is, the
* image data will represent a single tile and it can be rendered directly
* to the canvas.
*
* WARNING: Not all tiles have masks. This method should not be used unless
* the tile has a mask. The result is otherwise LTG-dependent, since some
* LTG files do not have fully opaque masks for those tiles.
*
* @param {Object} data_mask image data for the mask bitmap
* @param {number} x tile X position in game/mask bitmap
* @param {number} y tile Y position in game/mask bitmap
*
* @return {Object} image data for the requested tile
*/
'virtual protected getMaskedTileData': function( data_mask, x, y )
{
var raw = this.getTileData( x, y ),
w = raw.width,
h = raw.height,
mw = data_mask.width,
yi = h;
// apply the mask to the raw tile data (simple and easy-to-understand
// algorithm; we can refine it later if need be), looping through each
// pixel
while ( yi-- )
{
xi = w;
while ( xi-- )
{
// get the R value for the associated pixel in the mask bitmap
// (remember that, although we are dealing with applying the
// mask to a single tile, the mask image contains all tiles, so
// we must calculate its position accordingly)
var mi = ( ( ( yi + y ) * ( mw * 4 ) ) + ( ( xi + x ) * 4 ) ),
mr = data_mask.data[ mi ];
// manipulate the alpha channel of our tile; if the R value for
// the mask is not 0, then this pixel in our tile should be
// transparent (we need only check the R pixel since the mask
// consists of only black and white, so there is no need to
// calculate brightness)
raw.data[ ( ( yi * w * 4 ) + ( xi * 4 ) ) + 3 ] =
( mr === 0 ) ? 255 : 0;
}
}
return raw;
},
/**
* Retrieve image data for the tile at the given position
*
* @param {number} x tile X position in bitmap
* @param {number} y tile Y position in bitmap
*
* @return {Object} image data for tile
**/
'protected getTileData': function( x, y )
{
return this._context.getImageData(
x, y, this._tileWidth, this._tileHeight
);
},
/**
* Render an image to the canvas
*
* This operation is asynchronous and supports loading of external
* resources. Note that an external resource that violates the browser's
* cross-site security policies will taint the canvas, preventing the
* masking operation. Using data URLs will avoid this issue entirely.
*
* @param {string} bmp image URL or data URL
* @param {function(Image)} callback function to call when complete
*
* @return {undefined}
*/
'private _renderImage': function( bmp, callback )
{
var _self = this,
img = new Image();
img.onload = function()
{
_self._context.drawImage( img, 0, 0 );
callback( img );
};
img.src = bmp;
},
/**
* Retrieve the canvas image data of the given bitmap
*
* @param {string} bmp image URL or data URL
* @param {function(Image)} callback function to call when complete
*
* @return {undefined}
*/
'private _getImageData': function( bmp, callback )
{
var _self = this;
this._renderImage( bmp, function()
{
callback(
_self._context.getImageData(
0, 0, _self._setWidth, _self._setHeight
)
);
} );
}
} );