diff --git a/src/css/toolbox-layers-list.css b/src/css/toolbox-layers-list.css index 07f9f9689..4de131fad 100644 --- a/src/css/toolbox-layers-list.css +++ b/src/css/toolbox-layers-list.css @@ -46,6 +46,11 @@ background : #222; } +.layer-item-opacity { + position: absolute; + right: 8px; +} + .current-layer-item, .current-layer-item:hover { background : #333; diff --git a/src/js/app.js b/src/js/app.js index a410009f2..d39499e59 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -203,9 +203,14 @@ }, getFirstFrameAsPng : function () { - var firstFrame = this.piskelController.getFrameAt(0); - var firstFrameCanvas = pskl.utils.FrameUtils.toImage(firstFrame); - return firstFrameCanvas.toDataURL('image/png'); + var frame = pskl.utils.LayerUtils.mergeFrameAt(this.piskelController.getLayers(), 0); + var canvas; + if (frame instanceof pskl.model.frame.RenderedFrame) { + canvas = pskl.utils.CanvasUtils.createFromImage(frame.getRenderedFrame()); + } else { + canvas = pskl.utils.FrameUtils.toImage(frame); + } + return canvas.toDataURL('image/png'); }, getFramesheetAsPng : function () { diff --git a/src/js/controller/LayersListController.js b/src/js/controller/LayersListController.js index 590e911a1..5d62d1a7d 100644 --- a/src/js/controller/LayersListController.js +++ b/src/js/controller/LayersListController.js @@ -88,7 +88,8 @@ var layerItemHtml = pskl.utils.Template.replace(this.layerItemTemplate_, { 'layername' : layer.getName(), 'layerindex' : index, - 'isselected:current-layer-item' : isSelected + 'isselected:current-layer-item' : isSelected, + 'opacity': layer.getOpacity() }); var layerItem = pskl.utils.Template.createFromHTML(layerItemHtml); this.layersListEl.insertBefore(layerItem, this.layersListEl.firstChild); @@ -102,6 +103,11 @@ } else if (el.classList.contains('layer-item')) { index = el.dataset.layerIndex; this.piskelController.setCurrentLayerIndex(parseInt(index, 10)); + } else if (el.classList.contains('layer-item-opacity')) { + index = pskl.utils.Dom.getData(el, 'layerIndex'); + var layer = this.piskelController.getLayerAt(parseInt(index, 10)); + var opacity = window.prompt('Set layer opacity (value between 0 and 1)', layer.getOpacity()); + this.piskelController.setLayerOpacityAt(index, opacity); } }; diff --git a/src/js/controller/piskel/PiskelController.js b/src/js/controller/piskel/PiskelController.js index ed3d73200..6d3230e78 100644 --- a/src/js/controller/piskel/PiskelController.js +++ b/src/js/controller/piskel/PiskelController.js @@ -90,17 +90,14 @@ return this.piskel; }; - ns.PiskelController.prototype.getFrameAt = function (index) { - var hash = []; - var frames = this.getLayers().map(function (l) { - var frame = l.getFrameAt(index); - hash.push(frame.getHash()); - return frame; + ns.PiskelController.prototype.isTransparent = function () { + return this.getLayers().some(function (l) { + return l.isTransparent(); }); - var mergedFrame = pskl.utils.FrameUtils.merge(frames); - mergedFrame.id = hash.join('-'); - mergedFrame.version = 0; - return mergedFrame; + }; + + ns.PiskelController.prototype.renderFrameAt = function (index, preserveOpacity) { + return pskl.utils.LayerUtils.flattenFrameAt(this.getLayers(), index, preserveOpacity); }; ns.PiskelController.prototype.hasFrameAt = function (index) { @@ -205,6 +202,13 @@ } }; + ns.PiskelController.prototype.setLayerOpacityAt = function (index, opacity) { + var layer = this.getLayerByIndex(index); + if (layer) { + layer.setOpacity(opacity); + } + }; + ns.PiskelController.prototype.mergeDownLayerAt = function (index) { var layer = this.getLayerByIndex(index); var downLayer = this.getLayerByIndex(index - 1); diff --git a/src/js/controller/piskel/PublicPiskelController.js b/src/js/controller/piskel/PublicPiskelController.js index 033d578ab..e8d82b9b4 100644 --- a/src/js/controller/piskel/PublicPiskelController.js +++ b/src/js/controller/piskel/PublicPiskelController.js @@ -29,6 +29,7 @@ this.saveWrap_('moveLayerUp', true); this.saveWrap_('moveLayerDown', true); this.saveWrap_('removeCurrentLayer', true); + this.saveWrap_('setLayerOpacityAt', true); var shortcuts = pskl.service.keyboard.Shortcuts; pskl.app.shortcutService.registerShortcut(shortcuts.MISC.PREVIOUS_FRAME, this.selectPreviousFrame.bind(this)); diff --git a/src/js/controller/preview/PreviewController.js b/src/js/controller/preview/PreviewController.js index 33278b722..6efcaeec5 100644 --- a/src/js/controller/preview/PreviewController.js +++ b/src/js/controller/preview/PreviewController.js @@ -168,9 +168,9 @@ ns.PreviewController.prototype.render = function (delta) { this.elapsedTime += delta; var index = this.getNextIndex_(delta); - if (this.shoudlRender_() || this.currentIndex != index) { + if (this.shouldRender_() || this.currentIndex != index) { this.currentIndex = index; - var frame = this.piskelController.getFrameAt(this.currentIndex); + var frame = pskl.utils.LayerUtils.mergeFrameAt(this.piskelController.getLayers(), index); this.renderer.render(frame); this.renderFlag = false; @@ -240,7 +240,7 @@ this.renderFlag = bool; }; - ns.PreviewController.prototype.shoudlRender_ = function () { + ns.PreviewController.prototype.shouldRender_ = function () { return this.renderFlag || this.popupPreviewController.renderFlag; }; diff --git a/src/js/controller/settings/exportimage/GifExportController.js b/src/js/controller/settings/exportimage/GifExportController.js index 9ca27fed3..f74a5148e 100644 --- a/src/js/controller/settings/exportimage/GifExportController.js +++ b/src/js/controller/settings/exportimage/GifExportController.js @@ -105,7 +105,9 @@ ns.GifExportController.prototype.renderAsImageDataAnimatedGIF = function(zoom, fps, cb) { var currentColors = pskl.app.currentColorsService.getCurrentColors(); - var preserveColors = currentColors.length < MAX_GIF_COLORS; + var layers = this.piskelController.getLayers(); + var isTransparent = layers.some(function (l) {return l.isTransparent();}); + var preserveColors = !isTransparent && currentColors.length < MAX_GIF_COLORS; var transparentColor, transparent; // transparency only supported if preserveColors is true, see Issue #357 @@ -117,23 +119,30 @@ transparent = null; } + var width = this.piskelController.getWidth(); + var height = this.piskelController.getHeight(); + var gif = new window.GIF({ workers: 5, quality: 1, - width: this.piskelController.getWidth() * zoom, - height: this.piskelController.getHeight() * zoom, + width: width * zoom, + height: height * zoom, preserveColors : preserveColors, transparent : transparent }); + // Create a background canvas that will be filled with the transparent color before each render. + var background = pskl.utils.CanvasUtils.createCanvas(width, height); + var context = background.getContext('2d'); + context.fillStyle = transparentColor; + for (var i = 0 ; i < this.piskelController.getFrameCount() ; i++) { - var frame = this.piskelController.getFrameAt(i); - var canvasRenderer = new pskl.rendering.CanvasRenderer(frame, zoom); - if (preserveColors) { + var render = this.piskelController.renderFrameAt(i, true); + context.clearRect(0, 0, width, height); + context.fillRect(0, 0, width, height); + context.drawImage(render, 0, 0, width, height); - } - canvasRenderer.drawTransparentAs(transparentColor); - var canvas = canvasRenderer.render(); + var canvas = pskl.utils.ImageResizer.scale(background, zoom); gif.addFrame(canvas.getContext('2d'), { delay: 1000 / fps }); diff --git a/src/js/controller/settings/exportimage/PngExportController.js b/src/js/controller/settings/exportimage/PngExportController.js index e9ba4c277..882e54388 100644 --- a/src/js/controller/settings/exportimage/PngExportController.js +++ b/src/js/controller/settings/exportimage/PngExportController.js @@ -60,8 +60,8 @@ ns.PngExportController.prototype.mergedExport_ = function (zip) { var paddingLength = ('' + this.piskelController.getFrameCount()).length; for (var i = 0; i < this.piskelController.getFrameCount(); i++) { - var frame = this.piskelController.getFrameAt(i); - var canvas = this.getFrameAsCanvas_(frame); + var render = this.piskelController.renderFrameAt(i, true); + var canvas = pskl.utils.CanvasUtils.createFromImage(render); var basename = this.pngFilePrefixInput.value; var id = pskl.utils.StringUtils.leftPad(i, paddingLength, '0'); var filename = basename + id + '.png'; @@ -77,8 +77,8 @@ var layer = this.piskelController.getLayerAt(j); var layerid = pskl.utils.StringUtils.leftPad(j, layerPaddingLength, '0'); for (var i = 0; i < this.piskelController.getFrameCount(); i++) { - var frame = layer.getFrameAt(i); - var canvas = this.getFrameAsCanvas_(frame); + var render = pskl.utils.LayerUtils.renderFrameAt(layer, i, true); + var canvas = pskl.utils.CanvasUtils.createFromImage(render); var basename = this.pngFilePrefixInput.value; var frameid = pskl.utils.StringUtils.leftPad(i + 1, framePaddingLength, '0'); var filename = 'l' + layerid + '_' + basename + frameid + '.png'; @@ -87,12 +87,6 @@ } }; - ns.PngExportController.prototype.getFrameAsCanvas_ = function (frame) { - var canvasRenderer = new pskl.rendering.CanvasRenderer(frame, 1); - canvasRenderer.drawTransparentAs(Constants.TRANSPARENT_COLOR); - return canvasRenderer.render(); - }; - ns.PngExportController.prototype.getPiskelName_ = function () { return this.piskelController.getPiskel().getDescriptor().name; }; diff --git a/src/js/controller/settings/resize/ResizeController.js b/src/js/controller/settings/resize/ResizeController.js index d054fcb37..62e00048f 100644 --- a/src/js/controller/settings/resize/ResizeController.js +++ b/src/js/controller/settings/resize/ResizeController.js @@ -71,8 +71,11 @@ }; ns.ResizeController.prototype.resizeLayer_ = function (layer) { + var opacity = layer.getOpacity(); var resizedFrames = layer.getFrames().map(this.resizeFrame_.bind(this)); - return pskl.model.Layer.fromFrames(layer.getName(), resizedFrames); + var resizedLayer = pskl.model.Layer.fromFrames(layer.getName(), resizedFrames); + resizedLayer.setOpacity(opacity); + return resizedLayer; }; ns.ResizeController.prototype.onResizeContentChange_ = function (evt) { diff --git a/src/js/model/Layer.js b/src/js/model/Layer.js index 1daa1fd35..98019ada7 100644 --- a/src/js/model/Layer.js +++ b/src/js/model/Layer.js @@ -7,6 +7,7 @@ } else { this.name = name; this.frames = []; + this.opacity = 1; } }; @@ -31,6 +32,21 @@ this.name = name; }; + ns.Layer.prototype.getOpacity = function () { + return this.opacity; + }; + + ns.Layer.prototype.setOpacity = function (opacity) { + if (opacity === null || isNaN(opacity) || opacity < 0 || opacity > 1) { + return; + } + this.opacity = opacity; + }; + + ns.Layer.prototype.isTransparent = function () { + return this.opacity > 0 && this.opacity < 1; + }; + ns.Layer.prototype.getFrames = function () { return this.frames; }; diff --git a/src/js/model/frame/AsyncCachedFrameProcessor.js b/src/js/model/frame/AsyncCachedFrameProcessor.js index 14c92520a..f1854cecb 100644 --- a/src/js/model/frame/AsyncCachedFrameProcessor.js +++ b/src/js/model/frame/AsyncCachedFrameProcessor.js @@ -30,6 +30,10 @@ var key1 = frame.getHash(); if (cache[key1]) { processedFrame = cache[key1]; + } else if (frame instanceof pskl.model.frame.RenderedFrame) { + // Cannot use 2nd level cache with rendered frames + var callbackFirstLvlCacheOnly = this.onProcessorComplete_.bind(this, deferred, cache, key1, key1); + this.frameProcessor(frame, callbackFirstLvlCacheOnly); } else { var framePixels = JSON.stringify(frame.getPixels()); var key2 = pskl.utils.hashCode(framePixels); diff --git a/src/js/model/frame/CachedFrameProcessor.js b/src/js/model/frame/CachedFrameProcessor.js index 27df5ff67..5a1ff57c0 100644 --- a/src/js/model/frame/CachedFrameProcessor.js +++ b/src/js/model/frame/CachedFrameProcessor.js @@ -66,6 +66,10 @@ var cacheKey = frame.getHash(); if (cache[cacheKey]) { processedFrame = cache[cacheKey]; + } else if (frame instanceof pskl.model.frame.RenderedFrame) { + // Cannot use 2nd level cache with rendered frames + processedFrame = this.frameProcessor(frame); + cache[cacheKey] = processedFrame; } else { var framePixels = JSON.stringify(frame.getPixels()); var frameAsString = pskl.utils.hashCode(framePixels); diff --git a/src/js/model/frame/RenderedFrame.js b/src/js/model/frame/RenderedFrame.js new file mode 100644 index 000000000..47a75ca4c --- /dev/null +++ b/src/js/model/frame/RenderedFrame.js @@ -0,0 +1,49 @@ +(function () { + var ns = $.namespace('pskl.model.frame'); + + /** + * Create a frame instance that provides an image getter. Can be faster + * to use after merging using transparency. Transparent frames are merged to + * an image and this allows to reuse the image rather than retransform into + * a frame before calling the renderers. + * + * This rendered frame should only be used with renderers that support it. + * + * @param {Function} imageFn getter that will create the image + * @param {Number} width image width in pixels + * @param {Number} height image height in pixels + * @param {String} id will be used as hash, so should be as unique as possible + */ + ns.RenderedFrame = function (renderFn, width, height, id) { + this.width = width; + this.height = height; + this.id = id; + this.renderFn = renderFn; + }; + + ns.RenderedFrame.prototype.getRenderedFrame = function () { + return this.renderFn(); + }; + + ns.RenderedFrame.prototype.getHash = function () { + return this.id; + }; + + ns.RenderedFrame.prototype.getWidth = function () { + return this.width; + }; + + ns.RenderedFrame.prototype.getHeight = function () { + return this.height; + }; + + ns.RenderedFrame.prototype.getPixels = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.containsPixel = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.isSameSize = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.clone = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.setPixels = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.clear = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.setPixel = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.getPixel = Constants.ABSTRACT_FUNCTION; + ns.RenderedFrame.prototype.forEachPixel = Constants.ABSTRACT_FUNCTION; +})(); diff --git a/src/js/rendering/CanvasRenderer.js b/src/js/rendering/CanvasRenderer.js index 9dfe9e07c..27841115b 100644 --- a/src/js/rendering/CanvasRenderer.js +++ b/src/js/rendering/CanvasRenderer.js @@ -4,6 +4,7 @@ ns.CanvasRenderer = function (frame, zoom) { this.frame = frame; this.zoom = zoom; + this.opacity_ = 1; this.transparentColor_ = 'white'; }; @@ -16,11 +17,15 @@ this.transparentColor_ = color; }; + ns.CanvasRenderer.prototype.setOpacity = function (opacity) { + this.opacity_ = opacity; + }; + ns.CanvasRenderer.prototype.render = function () { var canvas = this.createCanvas_(); // Draw in canvas - pskl.utils.FrameUtils.drawToCanvas(this.frame, canvas, this.transparentColor_); + pskl.utils.FrameUtils.drawToCanvas(this.frame, canvas, this.transparentColor_, this.opacity_); var scaledCanvas = this.createCanvas_(this.zoom); var scaledContext = scaledCanvas.getContext('2d'); diff --git a/src/js/rendering/PiskelRenderer.js b/src/js/rendering/PiskelRenderer.js index a53788b3b..1cbfbe3c5 100644 --- a/src/js/rendering/PiskelRenderer.js +++ b/src/js/rendering/PiskelRenderer.js @@ -5,10 +5,30 @@ ns.PiskelRenderer = function (piskelController) { var frames = []; for (var i = 0 ; i < piskelController.getFrameCount() ; i++) { - frames.push(piskelController.getFrameAt(i)); + frames.push(piskelController.renderFrameAt(i, true)); } - ns.FramesheetRenderer.call(this, frames); + this.piskelController = piskelController; + this.frames = frames; }; - pskl.utils.inherit(ns.PiskelRenderer, ns.FramesheetRenderer); + ns.PiskelRenderer.prototype.renderAsCanvas = function () { + var canvas = this.createCanvas_(); + for (var i = 0 ; i < this.frames.length ; i++) { + var frame = this.frames[i]; + this.drawFrameInCanvas_(frame, canvas, i * this.piskelController.getWidth(), 0); + } + return canvas; + }; + + ns.PiskelRenderer.prototype.drawFrameInCanvas_ = function (frame, canvas, offsetWidth, offsetHeight) { + var context = canvas.getContext('2d'); + context.drawImage(frame, offsetWidth, offsetHeight, frame.width, frame.height); + }; + + ns.PiskelRenderer.prototype.createCanvas_ = function () { + var count = this.frames.length; + var width = count * this.piskelController.getWidth(); + var height = this.piskelController.getHeight(); + return pskl.utils.CanvasUtils.createCanvas(width, height); + }; })(); diff --git a/src/js/rendering/frame/BackgroundImageFrameRenderer.js b/src/js/rendering/frame/BackgroundImageFrameRenderer.js index f03e18275..96f6ff74a 100644 --- a/src/js/rendering/frame/BackgroundImageFrameRenderer.js +++ b/src/js/rendering/frame/BackgroundImageFrameRenderer.js @@ -16,7 +16,12 @@ }; ns.BackgroundImageFrameRenderer.prototype.frameToDataUrl_ = function (frame) { - var canvas = new pskl.utils.FrameUtils.toImage(frame, this.zoom); + var canvas; + if (frame instanceof pskl.model.frame.RenderedFrame) { + canvas = pskl.utils.ImageResizer.scale(frame.getRenderedFrame(), this.zoom); + } else { + canvas = pskl.utils.FrameUtils.toImage(frame, this.zoom); + } return canvas.toDataURL('image/png'); }; diff --git a/src/js/rendering/layer/LayersRenderer.js b/src/js/rendering/layer/LayersRenderer.js index d7a5688d9..1bfe8fbdb 100644 --- a/src/js/rendering/layer/LayersRenderer.js +++ b/src/js/rendering/layer/LayersRenderer.js @@ -32,11 +32,11 @@ var offset = this.getOffset(); var size = this.getDisplaySize(); var layers = this.piskelController.getLayers(); - var currentFrameIndex = this.piskelController.getCurrentFrameIndex(); - var currentLayerIndex = this.piskelController.getCurrentLayerIndex(); + var frameIndex = this.piskelController.getCurrentFrameIndex(); + var layerIndex = this.piskelController.getCurrentLayerIndex(); - var belowLayers = layers.slice(0, currentLayerIndex); - var aboveLayers = layers.slice(currentLayerIndex + 1, layers.length); + var belowLayers = layers.slice(0, layerIndex); + var aboveLayers = layers.slice(layerIndex + 1, layers.length); var serializedRendering = [ this.getZoom(), @@ -45,8 +45,8 @@ offset.y, size.width, size.height, - this.getHashForLayersAt_(currentFrameIndex, belowLayers), - this.getHashForLayersAt_(currentFrameIndex, aboveLayers), + pskl.utils.LayerUtils.getFrameHashAt(belowLayers, frameIndex), + pskl.utils.LayerUtils.getFrameHashAt(aboveLayers, frameIndex), layers.length ].join('-'); @@ -56,12 +56,12 @@ this.clear(); if (belowLayers.length > 0) { - var belowFrame = this.getFrameForLayersAt_(currentFrameIndex, belowLayers); + var belowFrame = pskl.utils.LayerUtils.mergeFrameAt(belowLayers, frameIndex); this.belowRenderer.render(belowFrame); } if (aboveLayers.length > 0) { - var aboveFrame = this.getFrameForLayersAt_(currentFrameIndex, aboveLayers); + var aboveFrame = pskl.utils.LayerUtils.mergeFrameAt(aboveLayers, frameIndex); this.aboveRenderer.render(aboveFrame); } } @@ -80,20 +80,6 @@ } }; - ns.LayersRenderer.prototype.getFrameForLayersAt_ = function (frameIndex, layers) { - var frames = layers.map(function (l) { - return l.getFrameAt(frameIndex); - }); - return pskl.utils.FrameUtils.merge(frames); - }; - - ns.LayersRenderer.prototype.getHashForLayersAt_ = function (frameIndex, layers) { - var hash = layers.map(function (l) { - return l.getFrameAt(frameIndex).getHash(); - }); - return hash.join('-'); - }; - ns.LayersRenderer.prototype.onUserSettingsChange_ = function (evt, settingsName, settingsValue) { if (settingsName == pskl.UserSettings.LAYER_OPACITY) { this.updateLayersCanvasOpacity_(settingsValue); diff --git a/src/js/utils/FrameUtils.js b/src/js/utils/FrameUtils.js index 346abd454..bc5d97b2e 100644 --- a/src/js/utils/FrameUtils.js +++ b/src/js/utils/FrameUtils.js @@ -9,42 +9,55 @@ * @param zoom {Number} zoom * @return {Image} */ - toImage : function (frame, zoom) { + toImage : function (frame, zoom, opacity) { zoom = zoom || 1; + opacity = isNaN(opacity) ? 1 : opacity; + var canvasRenderer = new pskl.rendering.CanvasRenderer(frame, zoom); canvasRenderer.drawTransparentAs(Constants.TRANSPARENT_COLOR); + canvasRenderer.setOpacity(opacity); return canvasRenderer.render(); }, /** * Draw the provided frame in a 2d canvas * - * @param frame {pskl.model.Frame} frame the frame to draw - * @param canvas {Canvas} canvas the canvas target - * @param transparentColor {String} transparentColor (optional) color to use to represent transparent pixels. + * @param {Frame|RenderedFrame} frame the frame to draw + * @param {Canvas} canvas the canvas target + * @param {String} transparentColor (optional) color to use to represent transparent pixels. + * @param {String} globalAlpha (optional) global frame opacity */ - drawToCanvas : function (frame, canvas, transparentColor) { + drawToCanvas : function (frame, canvas, transparentColor, globalAlpha) { var context = canvas.getContext('2d'); - + globalAlpha = isNaN(globalAlpha) ? 1 : globalAlpha; + context.globalAlpha = globalAlpha; transparentColor = transparentColor || Constants.TRANSPARENT_COLOR; - for (var x = 0, width = frame.getWidth() ; x < width ; x++) { - for (var y = 0, height = frame.getHeight() ; y < height ; y++) { - var color = frame.getPixel(x, y); - - // accumulate all the pixels of the same color to speed up rendering - // by reducting fillRect calls - var w = 1; - while (color === frame.getPixel(x, y + w) && (y + w) < height) { - w++; - } - if (color == Constants.TRANSPARENT_COLOR) { - color = transparentColor; + if (frame instanceof pskl.model.frame.RenderedFrame) { + context.fillRect(transparentColor, 0, 0, frame.getWidth(), frame.getHeight()); + context.drawImage(frame.getRenderedFrame(), 0, 0); + } else { + for (var x = 0, width = frame.getWidth() ; x < width ; x++) { + for (var y = 0, height = frame.getHeight() ; y < height ; y++) { + var color = frame.getPixel(x, y); + + // accumulate all the pixels of the same color to speed up rendering + // by reducting fillRect calls + var w = 1; + while (color === frame.getPixel(x, y + w) && (y + w) < height) { + w++; + } + + if (color == Constants.TRANSPARENT_COLOR) { + color = transparentColor; + } + + pskl.utils.FrameUtils.renderLine_(color, x, y, w, context); + y = y + w - 1; } - - pskl.utils.FrameUtils.renderLine_(color, x, y, w, context); - y = y + w - 1; } + + context.globalAlpha = 1; } }, @@ -91,12 +104,16 @@ }, /* - * Create a pskl.model.Frame from an Image object. - * Transparent pixels will either be converted to completely opaque or completely transparent pixels. + * Create a pskl.model.Frame from an Image object. By default transparent + * pixels will be converted to completely opaque or completely transparent + * pixels. If preserveOpacity is true the actual opacity of the pixel will + * be used and the generated frame will contain rgba pixels. + * * @param {Image} image source image + * @param {boolean} preserveOpacity set to true to preserve the opacity * @return {pskl.model.Frame} corresponding frame */ - createFromImage : function (image) { + createFromImage : function (image, preserveOpacity) { var w = image.width; var h = image.height; var canvas = pskl.utils.CanvasUtils.createCanvas(w, h); @@ -104,10 +121,10 @@ context.drawImage(image, 0, 0, w, h, 0, 0, w, h); var imgData = context.getImageData(0, 0, w, h).data; - return pskl.utils.FrameUtils.createFromImageData_(imgData, w, h); + return pskl.utils.FrameUtils.createFromImageData_(imgData, w, h, preserveOpacity); }, - createFromImageData_ : function (imageData, width, height) { + createFromImageData_ : function (imageData, width, height, preserveOpacity) { // Draw the zoomed-up pixels to a different canvas context var grid = []; for (var x = 0 ; x < width ; x++) { @@ -119,10 +136,18 @@ var g = imageData[i + 1]; var b = imageData[i + 2]; var a = imageData[i + 3]; - if (a < 125) { - grid[x][y] = Constants.TRANSPARENT_COLOR; + if (preserveOpacity) { + if (a === 0) { + grid[x][y] = Constants.TRANSPARENT_COLOR; + } else { + grid[x][y] = 'rgba(' + [r, g, b, a / 255].join(',') + ')'; + } } else { - grid[x][y] = pskl.utils.rgbToHex(r, g, b); + if (a < 125) { + grid[x][y] = Constants.TRANSPARENT_COLOR; + } else { + grid[x][y] = pskl.utils.rgbToHex(r, g, b); + } } } } diff --git a/src/js/utils/ImageResizer.js b/src/js/utils/ImageResizer.js index f93435141..6f0564f82 100644 --- a/src/js/utils/ImageResizer.js +++ b/src/js/utils/ImageResizer.js @@ -2,6 +2,10 @@ var ns = $.namespace('pskl.utils'); ns.ImageResizer = { + scale : function (image, factor, smoothingEnabled) { + return ns.ImageResizer.resize(image, image.width * factor, image.height * factor, smoothingEnabled); + }, + resize : function (image, targetWidth, targetHeight, smoothingEnabled) { var canvas = pskl.utils.CanvasUtils.createCanvas(targetWidth, targetHeight); var context = canvas.getContext('2d'); diff --git a/src/js/utils/LayerUtils.js b/src/js/utils/LayerUtils.js index 5a042e6b4..bb9673724 100644 --- a/src/js/utils/LayerUtils.js +++ b/src/js/utils/LayerUtils.js @@ -3,12 +3,15 @@ ns.LayerUtils = { /** - * Create a pskl.model.Layer from an Image object. + * Create a Frame array from an Image object. * Transparent pixels will either be converted to completely opaque or completely transparent pixels. + * TODO : move to FrameUtils + * * @param {Image} image source image - * @return {pskl.model.Frame} corresponding frame + * @param {Number} frameCount number of frames in the spritesheet + * @return {Array} */ - createLayerFromSpritesheet : function (image, frameCount) { + createFramesFromSpritesheet : function (image, frameCount) { var width = image.width; var height = image.height; var frameWidth = width / frameCount; @@ -37,6 +40,72 @@ }); var mergedLayer = pskl.model.Layer.fromFrames(layerA.getName(), mergedFrames); return mergedLayer; + }, + + getFrameHashAt : function (layers, index) { + var hashBuffer = []; + layers.forEach(function (l) { + var frame = l.getFrameAt(index); + hashBuffer.push(frame.getHash()); + hashBuffer.push(l.getOpacity()); + return frame; + }); + return hashBuffer.join('-'); + }, + + /** + * Create a frame instance merging all the frames from the layers array at + * the provided index. + * + * @param {Array} layers array of layers to use + * @param {Number} index frame index to merge + * @return {Frame} Frame instance (can be a fake frame when using + * transparency) + */ + mergeFrameAt : function (layers, index) { + var isTransparent = layers.some(function (l) {return l.isTransparent();}); + if (isTransparent) { + return pskl.utils.LayerUtils.mergeTransparentFrameAt_(layers, index); + } else { + return pskl.utils.LayerUtils.mergeOpaqueFrameAt_(layers, index); + } + }, + + mergeTransparentFrameAt_ : function (layers, index) { + var hash = pskl.utils.LayerUtils.getFrameHashAt(layers, index); + var width = layers[0].frames[0].getWidth(); + var height = layers[0].frames[0].getHeight(); + var renderFn = function () {return pskl.utils.LayerUtils.flattenFrameAt(layers, index, true);}; + return new pskl.model.frame.RenderedFrame(renderFn, width, height, hash); + }, + + mergeOpaqueFrameAt_ : function (layers, index) { + var hash = pskl.utils.LayerUtils.getFrameHashAt(layers, index); + var frames = layers.map(function(l) {return l.getFrameAt(index);}); + var mergedFrame = pskl.utils.FrameUtils.merge(frames); + mergedFrame.id = hash; + mergedFrame.version = 0; + return mergedFrame; + }, + + renderFrameAt : function (layer, index, preserveOpacity) { + var opacity = preserveOpacity ? layer.getOpacity() : 1; + var frame = layer.getFrameAt(index); + return pskl.utils.FrameUtils.toImage(frame, 1, opacity); + }, + + flattenFrameAt : function (layers, index, preserveOpacity) { + var width = layers[0].getFrameAt(index).getWidth(); + var height = layers[0].getFrameAt(index).getHeight(); + var canvas = pskl.utils.CanvasUtils.createCanvas(width, height); + + var context = canvas.getContext('2d'); + layers.forEach(function (l) { + var render = ns.LayerUtils.renderFrameAt(l, index, preserveOpacity); + context.drawImage(render, 0, 0, width, height, 0, 0, width, height); + }); + + return canvas; } }; diff --git a/src/js/utils/serialization/Deserializer.js b/src/js/utils/serialization/Deserializer.js index 60f1dca7b..01db4abe0 100644 --- a/src/js/utils/serialization/Deserializer.js +++ b/src/js/utils/serialization/Deserializer.js @@ -6,6 +6,7 @@ this.data_ = data; this.callback_ = callback; this.piskel_ = null; + this.layers_ = []; }; ns.Deserializer.deserialize = function (data, callback) { @@ -36,9 +37,10 @@ } }; - ns.Deserializer.prototype.deserializeLayer = function (layerString) { + ns.Deserializer.prototype.deserializeLayer = function (layerString, index) { var layerData = JSON.parse(layerString); var layer = new pskl.model.Layer(layerData.name); + layer.setOpacity(layerData.opacity); // 1 - create an image to load the base64PNG representing the layer var base64PNG = layerData.base64PNG; @@ -47,39 +49,39 @@ // 2 - attach the onload callback that will be triggered asynchronously image.onload = function () { // 5 - extract the frames from the loaded image - var frames = pskl.utils.LayerUtils.createLayerFromSpritesheet(image, layerData.frameCount); + var frames = pskl.utils.LayerUtils.createFramesFromSpritesheet(image, layerData.frameCount); // 6 - add each image to the layer - this.addFramesToLayer(frames, layer); + this.addFramesToLayer(frames, layer, index); }.bind(this); // 3 - set the source of the image image.src = base64PNG; - - // 4 - return a pointer to the new layer instance return layer; }; - ns.Deserializer.prototype.loadExpandedLayer = function (layerData) { + ns.Deserializer.prototype.loadExpandedLayer = function (layerData, index) { var layer = new pskl.model.Layer(layerData.name); + layer.setOpacity(layerData.opacity); var frames = layerData.grids.map(function (grid) { return pskl.model.Frame.fromPixelGrid(grid); }); - this.addFramesToLayer(frames, layer); - - // 4 - return a pointer to the new layer instance + this.addFramesToLayer(frames, layer, index); return layer; }; - ns.Deserializer.prototype.addFramesToLayer = function (frames, layer) { + ns.Deserializer.prototype.addFramesToLayer = function (frames, layer, index) { frames.forEach(layer.addFrame.bind(layer)); - this.piskel_.addLayer(layer); + this.layers_[index] = layer; this.onLayerLoaded_(); }; ns.Deserializer.prototype.onLayerLoaded_ = function () { this.layersToLoad_ = this.layersToLoad_ - 1; if (this.layersToLoad_ === 0) { + this.layers_.forEach(function (layer) { + this.piskel_.addLayer(layer); + }.bind(this)); this.callback_(this.piskel_); } }; diff --git a/src/js/utils/serialization/Serializer.js b/src/js/utils/serialization/Serializer.js index c9ab9f3d0..b946ad8e8 100644 --- a/src/js/utils/serialization/Serializer.js +++ b/src/js/utils/serialization/Serializer.js @@ -22,15 +22,16 @@ serializeLayer : function (layer, expanded) { var frames = layer.getFrames(); - var renderer = new pskl.rendering.FramesheetRenderer(frames); var layerToSerialize = { name : layer.getName(), + opacity : layer.getOpacity(), frameCount : frames.length }; if (expanded) { layerToSerialize.grids = frames.map(function (f) {return f.pixels;}); return layerToSerialize; } else { + var renderer = new pskl.rendering.FramesheetRenderer(frames); layerToSerialize.base64PNG = renderer.renderAsCanvas().toDataURL(); return JSON.stringify(layerToSerialize); } diff --git a/src/piskel-script-list.js b/src/piskel-script-list.js index c9929859a..144895b7e 100644 --- a/src/piskel-script-list.js +++ b/src/piskel-script-list.js @@ -58,7 +58,7 @@ // Promises "js/lib/q.js", - // Application libraries--> + // Application libraries "js/rendering/DrawingLoop.js", // Models @@ -67,6 +67,7 @@ "js/model/piskel/Descriptor.js", "js/model/frame/CachedFrameProcessor.js", "js/model/frame/AsyncCachedFrameProcessor.js", + "js/model/frame/RenderedFrame.js", "js/model/Palette.js", "js/model/Piskel.js", diff --git a/src/templates/layers-list.html b/src/templates/layers-list.html index 36b838566..1f43eaffc 100644 --- a/src/templates/layers-list.html +++ b/src/templates/layers-list.html @@ -34,6 +34,13 @@

Layers diff --git a/test/js/model/LayerTest.js b/test/js/model/LayerTest.js new file mode 100644 index 000000000..2fedfc377 --- /dev/null +++ b/test/js/model/LayerTest.js @@ -0,0 +1,39 @@ +describe("Layer model test", function() { + + beforeEach(function() {}); + afterEach(function() {}); + + it("has proper defaults", function() { + var layer = new pskl.model.Layer('layerName'); + + expect(layer.getOpacity()).toBe(1); + expect(layer.getFrames().length).toBe(0); + expect(layer.getName()).toBe('layerName'); + }); + + it("can set opacity", function() { + var layer = new pskl.model.Layer('layerName'); + + layer.setOpacity(0.5); + expect(layer.getOpacity()).toBe(0.5); + }); + + it("ignores bad opacity", function() { + var layer = new pskl.model.Layer('layerName'); + + layer.setOpacity(0.3); + expect(layer.getOpacity()).toBe(0.3); + + layer.setOpacity('Yep I\'m an opacity, let me in !'); + expect(layer.getOpacity()).toBe(0.3); + + layer.setOpacity(9000); + expect(layer.getOpacity()).toBe(0.3); + + layer.setOpacity(-1); + expect(layer.getOpacity()).toBe(0.3); + + layer.setOpacity(null); + expect(layer.getOpacity()).toBe(0.3); + }); +}); \ No newline at end of file diff --git a/test/js/testutils/TestUtils.js b/test/js/testutils/TestUtils.js index 5153bec4d..156f2be6c 100644 --- a/test/js/testutils/TestUtils.js +++ b/test/js/testutils/TestUtils.js @@ -16,10 +16,10 @@ * we expect this to be a 3x2 image, one black line above a white line. * * However Frame.createFromGrid needs the following input to create such an image : - * + * * [[black, white], * [black, white], - * [black, white]] + * [black, white]] * * This helper will build the second array from the first array. */ @@ -41,4 +41,32 @@ expect(color).toBe(grid[row][col]); }); }; + + ns.imageEqualsGrid = function (image, grid) { + for (var x = 0 ; x < grid.length ; x++) { + for (var y = 0 ; y < grid[x].length ; y++) { + var expected = tinycolor(grid[x][y]).toRgbString(); + var color = tinycolor(ns.getRgbaAt(image, x, y)).toRgbString(); + expect(color).toBe(expected); + } + } + } + + ns.getRgbaAt = function (image, x, y) { + var w = image.width; + var h = image.height; + var canvas = pskl.utils.CanvasUtils.createCanvas(w, h); + var context = canvas.getContext('2d'); + + context.drawImage(image, 0, 0, w, h, 0, 0, w, h); + var imageData = context.getImageData(0, 0, w, h).data; + var i = (y * w + x) * 4; + + return { + r : imageData[i], + g : imageData[i + 1], + b : imageData[i + 2], + a : imageData[i + 3]/255 + }; + } })(); \ No newline at end of file diff --git a/test/js/utils/FrameUtilsTest.js b/test/js/utils/FrameUtilsTest.js index 403130126..8b4a32985 100644 --- a/test/js/utils/FrameUtilsTest.js +++ b/test/js/utils/FrameUtilsTest.js @@ -74,7 +74,7 @@ describe("FrameUtils suite", function() { ]); }); - it ("[LayerUtils] creates a layer from a simple spritesheet", function () { + it ("[LayerUtils] creates frames from a simple spritesheet", function () { var B = black, R = red; // original image in 4x2 @@ -86,7 +86,7 @@ describe("FrameUtils suite", function() { var spritesheet = pskl.utils.FrameUtils.toImage(frame); // split the spritesheet by 4 - var frames = pskl.utils.LayerUtils.createLayerFromSpritesheet(spritesheet, 4); + var frames = pskl.utils.LayerUtils.createFramesFromSpritesheet(spritesheet, 4); // expect 4 frames of 1x2 expect(frames.length).toBe(4); diff --git a/test/js/utils/LayerUtilsTest.js b/test/js/utils/LayerUtilsTest.js new file mode 100644 index 000000000..922c70035 --- /dev/null +++ b/test/js/utils/LayerUtilsTest.js @@ -0,0 +1,56 @@ +describe("LayerUtils test", function() { + + var B = '#000000'; + var R = '#ff0000'; + var T = Constants.TRANSPARENT_COLOR; + var frameEqualsGrid = test.testutils.frameEqualsGrid; + var imageEqualsGrid = test.testutils.imageEqualsGrid; + + var frame1 = pskl.model.Frame.fromPixelGrid([ + [B, T], + [T, B] + ]); + + var frame2 = pskl.model.Frame.fromPixelGrid([ + [T, R], + [R, T] + ]); + + beforeEach(function() {}); + afterEach(function() {}); + + it("flattens a frame", function() { + // when + var l1 = new pskl.model.Layer('l1'); + l1.addFrame(frame1); + var l2 = new pskl.model.Layer('l2'); + l2.addFrame(frame2); + + // then + var flattened = pskl.utils.LayerUtils.flattenFrameAt([l1, l2], 0); + + //verify + imageEqualsGrid(flattened, [ + [B, R], + [R, B] + ]); + }); + + it("flattens a frame with opacity", function() { + // when + var l1 = new pskl.model.Layer('l1'); + l1.addFrame(frame1); + var l2 = new pskl.model.Layer('l2'); + l2.setOpacity(0.5); + l2.addFrame(frame2); + + // then + var flattened = pskl.utils.LayerUtils.flattenFrameAt([l1, l2], 0, true); + + //verify + imageEqualsGrid(flattened, [ + [B, 'rgba(255,0,0,0.5)'], + ['rgba(255,0,0,0.5)', B] + ]); + }); +}); \ No newline at end of file diff --git a/test/js/utils/serialization/SerializerTest.js b/test/js/utils/serialization/SerializerTest.js new file mode 100644 index 000000000..cf136f3da --- /dev/null +++ b/test/js/utils/serialization/SerializerTest.js @@ -0,0 +1,41 @@ +describe("Serialization/Deserialization test", function() { + + beforeEach(function() { + pskl.app.piskelController = { + getFPS: function () { + return 1; + } + }; + }); + afterEach(function() { + delete pskl.app.piskelController; + }); + + it("serializes layer opacity", function(done) { + var descriptor = new pskl.model.piskel.Descriptor('piskelName', 'piskelDesc'); + var piskel = new pskl.model.Piskel(1, 1, descriptor); + + piskel.addLayer(new pskl.model.Layer('layer1')); + piskel.addLayer(new pskl.model.Layer('layer2')); + piskel.addLayer(new pskl.model.Layer('layer3')); + + piskel.getLayerAt(0).setOpacity(0); + piskel.getLayerAt(1).setOpacity(0.3); + piskel.getLayerAt(2).setOpacity(0.9); + + var frame = new pskl.model.Frame(1, 1); + piskel.getLayers().forEach(function (layer) { + layer.addFrame(frame); + }); + + var serializedPiskel = pskl.utils.Serializer.serializePiskel(piskel); + + var deserializer = pskl.utils.serialization.Deserializer; + deserializer.deserialize(JSON.parse(serializedPiskel), function (p) { + expect(p.getLayerAt(0).getOpacity()).toBe(0); + expect(p.getLayerAt(1).getOpacity()).toBe(0.3); + expect(p.getLayerAt(2).getOpacity()).toBe(0.9); + done(); + }) + }); +}); \ No newline at end of file