/* * SpriteSheetBuilder * Visit http://createjs.com/ for documentation, updates and examples. * * Copyright (c) 2010 gskinner.com, inc. * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ /** * @module EaselJS */ // namespace: this.createjs = this.createjs||{}; (function() { "use strict"; // constructor: /** * The SpriteSheetBuilder allows you to generate {{#crossLink "SpriteSheet"}}{{/crossLink}} instances at run time * from any display object. This can allow you to maintain your assets as vector graphics (for low file size), and * render them at run time as SpriteSheets for better performance. * * SpriteSheets can be built either synchronously, or asynchronously, so that large SpriteSheets can be generated * without locking the UI. * * Note that the "images" used in the generated SpriteSheet are actually canvas elements, and that they will be * sized to the nearest power of 2 up to the value of {{#crossLink "SpriteSheetBuilder/maxWidth:property"}}{{/crossLink}} * or {{#crossLink "SpriteSheetBuilder/maxHeight:property"}}{{/crossLink}}. * @class SpriteSheetBuilder * @param {Number} [framerate=0] The {{#crossLink "SpriteSheet/framerate:property"}}{{/crossLink}} of * {{#crossLink "SpriteSheet"}}{{/crossLink}} instances that are created. * @extends EventDispatcher * @constructor **/ function SpriteSheetBuilder(framerate) { this.EventDispatcher_constructor(); // public properties: /** * The maximum width for the images (not individual frames) in the generated SpriteSheet. It is recommended to * use a power of 2 for this value (ex. 1024, 2048, 4096). If the frames cannot all fit within the max * dimensions, then additional images will be created as needed. * @property maxWidth * @type Number * @default 2048 */ this.maxWidth = 2048; /** * The maximum height for the images (not individual frames) in the generated SpriteSheet. It is recommended to * use a power of 2 for this value (ex. 1024, 2048, 4096). If the frames cannot all fit within the max * dimensions, then additional images will be created as needed. * @property maxHeight * @type Number * @default 2048 **/ this.maxHeight = 2048; /** * The SpriteSheet that was generated. This will be null before a build is completed successfully. * @property spriteSheet * @type SpriteSheet **/ this.spriteSheet = null; /** * The scale to apply when drawing all frames to the SpriteSheet. This is multiplied against any scale specified * in the addFrame call. This can be used, for example, to generate a SpriteSheet at run time that is tailored * to the a specific device resolution (ex. tablet vs mobile). * @property scale * @type Number * @default 1 **/ this.scale = 1; /** * The padding to use between frames. This is helpful to preserve antialiasing on drawn vector content. * @property padding * @type Number * @default 1 **/ this.padding = 1; /** * A number from 0.01 to 0.99 that indicates what percentage of time the builder can use. This can be * thought of as the number of seconds per second the builder will use. For example, with a timeSlice value of 0.3, * the builder will run 20 times per second, using approximately 15ms per build (30% of available time, or 0.3s per second). * Defaults to 0.3. * @property timeSlice * @type Number * @default 0.3 **/ this.timeSlice = 0.3; /** * A value between 0 and 1 that indicates the progress of a build, or -1 if a build has not * been initiated. * @property progress * @type Number * @default -1 * @readonly */ this.progress = -1; /** * A {{#crossLink "SpriteSheet/framerate:property"}}{{/crossLink}} value that will be passed to new {{#crossLink "SpriteSheet"}}{{/crossLink}} instances that are * created. If no framerate is specified (or it is 0), then SpriteSheets will use the {{#crossLink "Ticker"}}{{/crossLink}} * framerate. * @property framerate * @type Number * @default 0 */ this.framerate = framerate || 0; // private properties: /** * @property _frames * @protected * @type Array **/ this._frames = []; /** * @property _animations * @protected * @type Array **/ this._animations = {}; /** * @property _data * @protected * @type Array **/ this._data = null; /** * @property _nextFrameIndex * @protected * @type Number **/ this._nextFrameIndex = 0; /** * @property _index * @protected * @type Number **/ this._index = 0; /** * @property _timerID * @protected * @type Number **/ this._timerID = null; /** * @property _scale * @protected * @type Number **/ this._scale = 1; } var p = createjs.extend(SpriteSheetBuilder, createjs.EventDispatcher); // constants: SpriteSheetBuilder.ERR_DIMENSIONS = "frame dimensions exceed max spritesheet dimensions"; SpriteSheetBuilder.ERR_RUNNING = "a build is already running"; // events: /** * Dispatched when a build completes. * @event complete * @param {Object} target The object that dispatched the event. * @param {String} type The event type. * @since 0.6.0 */ /** * Dispatched when an asynchronous build has progress. * @event progress * @param {Object} target The object that dispatched the event. * @param {String} type The event type. * @param {Number} progress The current progress value (0-1). * @since 0.6.0 */ // public methods: /** * Adds a frame to the {{#crossLink "SpriteSheet"}}{{/crossLink}}. Note that the frame will not be drawn until you * call {{#crossLink "SpriteSheetBuilder/build"}}{{/crossLink}} method. The optional setup params allow you to have * a function run immediately before the draw occurs. For example, this allows you to add a single source multiple * times, but manipulate it or its children to change it to generate different frames. * * Note that the source's transformations (x, y, scale, rotate, alpha) will be ignored, except for regX/Y. To apply * transforms to a source object and have them captured in the SpriteSheet, simply place it into a {{#crossLink "Container"}}{{/crossLink}} * and pass in the Container as the source. * @method addFrame * @param {DisplayObject} source The source {{#crossLink "DisplayObject"}}{{/crossLink}} to draw as the frame. * @param {Rectangle} [sourceRect] A {{#crossLink "Rectangle"}}{{/crossLink}} defining the portion of the * source to draw to the frame. If not specified, it will look for a `getBounds` method, bounds property, or * `nominalBounds` property on the source to use. If one is not found, the frame will be skipped. * @param {Number} [scale=1] Optional. The scale to draw this frame at. Default is 1. * @param {Function} [setupFunction] A function to call immediately before drawing this frame. It will be called with two parameters: the source, and setupData. * @param {Object} [setupData] Arbitrary setup data to pass to setupFunction as the second parameter. * @return {Number} The index of the frame that was just added, or null if a sourceRect could not be determined. **/ p.addFrame = function(source, sourceRect, scale, setupFunction, setupData) { if (this._data) { throw SpriteSheetBuilder.ERR_RUNNING; } var rect = sourceRect||source.bounds||source.nominalBounds; if (!rect&&source.getBounds) { rect = source.getBounds(); } if (!rect) { return null; } scale = scale||1; return this._frames.push({source:source, sourceRect:rect, scale:scale, funct:setupFunction, data:setupData, index:this._frames.length, height:rect.height*scale})-1; }; /** * Adds an animation that will be included in the created {{#crossLink "SpriteSheet"}}{{/crossLink}}. * @method addAnimation * @param {String} name The name for the animation. * @param {Array} frames An array of frame indexes that comprise the animation. Ex. [3,6,5] would describe an animation * that played frame indexes 3, 6, and 5 in that order. * @param {String} [next] Specifies the name of the animation to continue to after this animation ends. You can * also pass false to have the animation stop when it ends. By default it will loop to the start of the same animation. * @param {Number} [speed] Specifies a frame advance speed for this animation. For example, a value of 0.5 would * cause the animation to advance every second tick. Note that earlier versions used `frequency` instead, which had * the opposite effect. **/ p.addAnimation = function(name, frames, next, speed) { if (this._data) { throw SpriteSheetBuilder.ERR_RUNNING; } this._animations[name] = {frames:frames, next:next, speed:speed}; }; /** * This will take a {{#crossLink "MovieClip"}}{{/crossLink}} instance, and add its frames and labels to this * builder. Labels will be added as an animation running from the label index to the next label. For example, if * there is a label named "foo" at frame 0 and a label named "bar" at frame 10, in a MovieClip with 15 frames, it * will add an animation named "foo" that runs from frame index 0 to 9, and an animation named "bar" that runs from * frame index 10 to 14. * * Note that this will iterate through the full MovieClip with {{#crossLink "MovieClip/actionsEnabled:property"}}{{/crossLink}} * set to `false`, ending on the last frame. * @method addMovieClip * @param {MovieClip} source The source MovieClip instance to add to the SpriteSheet. * @param {Rectangle} [sourceRect] A {{#crossLink "Rectangle"}}{{/crossLink}} defining the portion of the source to * draw to the frame. If not specified, it will look for a {{#crossLink "DisplayObject/getBounds"}}{{/crossLink}} * method, `frameBounds` Array, `bounds` property, or `nominalBounds` property on the source to use. If one is not * found, the MovieClip will be skipped. * @param {Number} [scale=1] The scale to draw the movie clip at. * @param {Function} [setupFunction] A function to call immediately before drawing each frame. It will be called * with three parameters: the source, setupData, and the frame index. * @param {Object} [setupData] Arbitrary setup data to pass to setupFunction as the second parameter. * @param {Function} [labelFunction] This method will be called for each MovieClip label that is added with four * parameters: the label name, the source MovieClip instance, the starting frame index (in the movieclip timeline) * and the end index. It must return a new name for the label/animation, or `false` to exclude the label. **/ p.addMovieClip = function(source, sourceRect, scale, setupFunction, setupData, labelFunction) { if (this._data) { throw SpriteSheetBuilder.ERR_RUNNING; } var rects = source.frameBounds; var rect = sourceRect||source.bounds||source.nominalBounds; if (!rect&&source.getBounds) { rect = source.getBounds(); } if (!rect && !rects) { return; } var i, l, baseFrameIndex = this._frames.length; var duration = source.timeline.duration; for (i=0; i<duration; i++) { var r = (rects&&rects[i]) ? rects[i] : rect; this.addFrame(source, r, scale, this._setupMovieClipFrame, {i:i, f:setupFunction, d:setupData}); } var labels = source.timeline._labels; var lbls = []; for (var n in labels) { lbls.push({index:labels[n], label:n}); } if (lbls.length) { lbls.sort(function(a,b){ return a.index-b.index; }); for (i=0,l=lbls.length; i<l; i++) { var label = lbls[i].label; var start = baseFrameIndex+lbls[i].index; var end = baseFrameIndex+((i == l-1) ? duration : lbls[i+1].index); var frames = []; for (var j=start; j<end; j++) { frames.push(j); } if (labelFunction) { label = labelFunction(label, source, start, end); if (!label) { continue; } } this.addAnimation(label, frames, true); // for now, this loops all animations. } } }; /** * Builds a {{#crossLink "SpriteSheet"}}{{/crossLink}} instance based on the current frames. * @method build * @return {SpriteSheet} The created SpriteSheet instance, or null if a build is already running or an error * occurred. **/ p.build = function() { if (this._data) { throw SpriteSheetBuilder.ERR_RUNNING; } this._startBuild(); while (this._drawNext()) {} this._endBuild(); return this.spriteSheet; }; /** * Asynchronously builds a {{#crossLink "SpriteSheet"}}{{/crossLink}} instance based on the current frames. It will * run 20 times per second, using an amount of time defined by `timeSlice`. When it is complete it will call the * specified callback. * @method buildAsync * @param {Number} [timeSlice] Sets the timeSlice property on this instance. **/ p.buildAsync = function(timeSlice) { if (this._data) { throw SpriteSheetBuilder.ERR_RUNNING; } this.timeSlice = timeSlice; this._startBuild(); var _this = this; this._timerID = setTimeout(function() { _this._run(); }, 50-Math.max(0.01, Math.min(0.99, this.timeSlice||0.3))*50); }; /** * Stops the current asynchronous build. * @method stopAsync **/ p.stopAsync = function() { clearTimeout(this._timerID); this._data = null; }; /** * SpriteSheetBuilder instances cannot be cloned. * @method clone **/ p.clone = function() { throw("SpriteSheetBuilder cannot be cloned."); }; /** * Returns a string representation of this object. * @method toString * @return {String} a string representation of the instance. **/ p.toString = function() { return "[SpriteSheetBuilder]"; }; // private methods: /** * @method _startBuild * @protected **/ p._startBuild = function() { var pad = this.padding||0; this.progress = 0; this.spriteSheet = null; this._index = 0; this._scale = this.scale; var dataFrames = []; this._data = { images: [], frames: dataFrames, framerate: this.framerate, animations: this._animations // TODO: should we "clone" _animations in case someone adds more animations after a build? }; var frames = this._frames.slice(); frames.sort(function(a,b) { return (a.height<=b.height) ? -1 : 1; }); if (frames[frames.length-1].height+pad*2 > this.maxHeight) { throw SpriteSheetBuilder.ERR_DIMENSIONS; } var y=0, x=0; var img = 0; while (frames.length) { var o = this._fillRow(frames, y, img, dataFrames, pad); if (o.w > x) { x = o.w; } y += o.h; if (!o.h || !frames.length) { var canvas = createjs.createCanvas?createjs.createCanvas():document.createElement("canvas"); canvas.width = this._getSize(x,this.maxWidth); canvas.height = this._getSize(y,this.maxHeight); this._data.images[img] = canvas; if (!o.h) { x=y=0; img++; } } } }; /** * @method _setupMovieClipFrame * @protected * @return {Number} The width & height of the row. **/ p._setupMovieClipFrame = function(source, data) { var ae = source.actionsEnabled; source.actionsEnabled = false; source.gotoAndStop(data.i); source.actionsEnabled = ae; data.f&&data.f(source, data.d, data.i); }; /** * @method _getSize * @protected * @return {Number} The width & height of the row. **/ p._getSize = function(size,max) { var pow = 4; while (Math.pow(2,++pow) < size){} return Math.min(max,Math.pow(2,pow)); }; /** * @method _fillRow * @param {Array} frames * @param {Number} y * @param {HTMLImageElement} img * @param {Object} dataFrames * @param {Number} pad * @protected * @return {Number} The width & height of the row. **/ p._fillRow = function(frames, y, img, dataFrames, pad) { var w = this.maxWidth; var maxH = this.maxHeight; y += pad; var h = maxH-y; var x = pad; var height = 0; for (var i=frames.length-1; i>=0; i--) { var frame = frames[i]; var sc = this._scale*frame.scale; var rect = frame.sourceRect; var source = frame.source; var rx = Math.floor(sc*rect.x-pad); var ry = Math.floor(sc*rect.y-pad); var rh = Math.ceil(sc*rect.height+pad*2); var rw = Math.ceil(sc*rect.width+pad*2); if (rw > w) { throw SpriteSheetBuilder.ERR_DIMENSIONS; } if (rh > h || x+rw > w) { continue; } frame.img = img; frame.rect = new createjs.Rectangle(x,y,rw,rh); height = height || rh; frames.splice(i,1); dataFrames[frame.index] = [x,y,rw,rh,img,Math.round(-rx+sc*source.regX-pad),Math.round(-ry+sc*source.regY-pad)]; x += rw; } return {w:x, h:height}; }; /** * @method _endBuild * @protected **/ p._endBuild = function() { this.spriteSheet = new createjs.SpriteSheet(this._data); this._data = null; this.progress = 1; this.dispatchEvent("complete"); }; /** * @method _run * @protected **/ p._run = function() { var ts = Math.max(0.01, Math.min(0.99, this.timeSlice||0.3))*50; var t = (new Date()).getTime()+ts; var complete = false; while (t > (new Date()).getTime()) { if (!this._drawNext()) { complete = true; break; } } if (complete) { this._endBuild(); } else { var _this = this; this._timerID = setTimeout(function() { _this._run(); }, 50-ts); } var p = this.progress = this._index/this._frames.length; if (this.hasEventListener("progress")) { var evt = new createjs.Event("progress"); evt.progress = p; this.dispatchEvent(evt); } }; /** * @method _drawNext * @protected * @return Boolean Returns false if this is the last draw. **/ p._drawNext = function() { var frame = this._frames[this._index]; var sc = frame.scale*this._scale; var rect = frame.rect; var sourceRect = frame.sourceRect; var canvas = this._data.images[frame.img]; var ctx = canvas.getContext("2d"); frame.funct&&frame.funct(frame.source, frame.data); ctx.save(); ctx.beginPath(); ctx.rect(rect.x, rect.y, rect.width, rect.height); ctx.clip(); ctx.translate(Math.ceil(rect.x-sourceRect.x*sc), Math.ceil(rect.y-sourceRect.y*sc)); ctx.scale(sc,sc); frame.source.draw(ctx); // display object will draw itself. ctx.restore(); return (++this._index) < this._frames.length; }; createjs.SpriteSheetBuilder = createjs.promote(SpriteSheetBuilder, "EventDispatcher"); }());