UNPKG

animation-composition

Version:
444 lines (375 loc) 11.3 kB
class Animation { constructor({ canvas, layers, loop = true, ticksPerFrame = 0, debug = false, debugOffset = 0, onerror = () => {}, paused = false, }) { this.canvas = canvas; this.loop = loop; this.layers = layers; this.debug = debug; this.debugOffset = debugOffset; this.onerror = onerror; if (this.debug) { this.canvases = [...this.layers] .map( (_l, i) => { if (i === 0) { return canvas; } const _canvas = canvas.cloneNode(true); _canvas.style = this._getCanvasStyle(i); canvas.parentNode.appendChild(_canvas); return _canvas; } ); } this.maxNumOfFrames = this.layers.reduce((acc, cur, i) => { return Math.max(acc, cur.getFrames()); }, 0); this.isPaused = paused; this.frameIndex = 0, this.tickCount = 0, this.ticksPerFrame = this.isPaused ? 0 : ticksPerFrame; // Start Animation this._preloadFrame(0) .then(this._RAFLoop()); } _getCanvasStyle(i) { const canvasOffset = this.canvas.getBoundingClientRect() const shift = [ 'top', 'left' ]; const keys = [ 'width', 'height']; const style = [...keys, ...shift] .reduce((acc, key) => ` ${acc} ${key}: ${shift.indexOf(key) >= 0 ? canvasOffset[key] + (this.debugOffset * i) : canvasOffset[key]}px; `, 'position: absolute;'); return style; } updateDebugOffset(debugOffset) { this.debugOffset = debugOffset; } destroy() { this._clearCtx(); if (this.debug) { this.canvases.forEach((canvas, i) => i !== 0 && canvas.parentNode.removeChild(canvas)); } this.isDestroyed = true; } next() { if (this.isDestroyed || !this.isPaused) return; this.render(); this._RAFLoop(this.frameIndex)(); } _update() { if (this.isDestroyed) return; this.tickCount += 1; if (this.tickCount > this.ticksPerFrame) { this.tickCount = 0; // If the current frame index is in range if (this.frameIndex + 1 < this.maxNumOfFrames) { // Go to the next frame this.frameIndex += 1; } else if (this.loop) { this.frameIndex = 0; } } } _RAFLoop(frameIndex = 0) { if (this.isDestroyed) return; const preloadFramePromise = this._preloadFrame(frameIndex + 1); this._update(); return () => { preloadFramePromise .then(() => { if (!this.isPaused) { window.requestAnimationFrame(() => { if (this.isDestroyed) return; this._RAFLoop(this.frameIndex)(); }); } if (this.maxNumOfFrames !== 1 && this.frameIndex === frameIndex) return; if (!this.isPaused) this.render(); }); }; } _preloadFrame(index) { const promises = this.layers.reduce((acc, layer) => { acc.push(layer.preload(index)); return acc; }, []); return Promise.all(promises) .catch((err ={}) => { if (this.debug) { console.warn(`Error preloading image: ${JSON.stringify(err)}`); } this.onerror('Image not available', err); }); } _clearCtx() { if (this.debug) { this.canvases.forEach(canvas => { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); }); return; } const ctx = this.canvas.getContext('2d'); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } _setCtxComposite(operation, canvas) { const ctx = canvas.getContext('2d'); ctx.globalCompositeOperation = operation; } render() { this._clearCtx(); if (this.debug) { [...this.layers] .forEach( (_l, i) => { if (i === 0) return; this.canvases[i].style = this._getCanvasStyle(i); } ); } if (!this._layerRendering) { this._layerRendering = [...this.layers].reduce((acc, layer, i) => { layer._originalIndex = i; if (!!layer.mask) { acc.masks.push(layer); } else { if (acc.masks.length) { acc.postmaskLayers.push(layer); } else { acc.premaskLayers.push(layer); } } return acc; }, { masks: [], premaskLayers: [], postmaskLayers: [] }); } [...this._layerRendering.masks].reverse().forEach((layer, i) => { const canvas = this.debug ? this.canvases[layer._originalIndex] : this.canvas; this._setCtxComposite('destination-over', canvas); layer.render(canvas, this.frameIndex); }); [...this._layerRendering.premaskLayers].reverse().forEach((layer, i) => { const canvas = this.debug ? this.canvases[layer._originalIndex] : this.canvas; this._setCtxComposite('destination-over', canvas); layer.render(canvas, this.frameIndex); }); [...this._layerRendering.postmaskLayers].forEach((layer, i) => { const canvas = this.debug ? this.canvases[layer._originalIndex] : this.canvas; this._setCtxComposite('source-over', canvas); layer.render(canvas, this.frameIndex); }); } } class BaseLayer { constructor({ name = 'UNKOWN' } = {}) { this.name = name; } getFrames() { return 0; } preload() { return Promise.resolve(); } } class ColorLayer extends BaseLayer { constructor(opts) { super(opts); const { color } = opts; this.color = color; } render(canvas) { const ctx = canvas.getContext('2d'); ctx.fillStyle = this.color; ctx.fillRect(0, 0, canvas.width, canvas.height); } } class Layer extends BaseLayer { constructor(opts) { super(opts); const { images = [], sprite = {}, sizeRef, size } = opts; this.images = images; this.sprite = sprite; this._spriteSize = sprite.size; this.sizeRef = sizeRef; this.size = size; this.imageCache = []; this.imagesError = []; } getFrames() { return this._isSprite() ? this.sprite.frames : this.images.length; } getSize(index) { if (this._isSprite()) return { width: this._spriteSize, height: this._spriteSize }; if (index >= this.images.length) index = this.images.length - 1; if (this.imageCache[index]) { const { width, height } = this.imageCache[index]; return { width, height }; } return null; } preload(index) { if (this._isSprite()) { if (this.spriteImageCache) return Promise.resolve(); return new Promise((resolve, reject) => this._getSpriteImg(resolve, reject)); } if (index >= this.images.length) return Promise.resolve(); if (this.imageCache[index]) return Promise.resolve(); return new Promise((resolve, reject) => this._getImg(index, resolve, reject)); } _getImg(index, cb = () => {}, reject = () => {}) { if (index >= this.images.length) index = this.images.length - 1; if (this.imagesError[index]) return null; if (!!this.imageCache[index]) return this.imageCache[index]; const img = new Image(); img.crossOrigin = "Anonymous"; img.onload = cb; img.onerror = err => { this.imagesError[index] = true; reject(Object.assign({}, { src: this.images[index] }, err)); }; img.src = this.images[index]; this.imageCache[index] = img; return img; } _getSpriteImg(cb = () => {}, reject = () => {}) { if (this.spriteImageError) return null; if (!!this.spriteImageCache) return this.spriteImageCache; const img = new Image(); img.crossOrigin = "Anonymous"; img.onload = (...args) => { this._spriteCol = img.width/this._spriteSize; this._spriteRow = img.height/this._spriteSize; cb(...args); } img.onerror = err => { this.spriteImageError = true; reject(Object.assign({}, { src: this.sprite.sheet }, err)); }; img.src = this.sprite.sheet; this.spriteImageCache = img; return img; } _getSpriteOrigin(frameIndex) { let col = frameIndex % this._spriteCol; let row = Math.floor(frameIndex / this._spriteCol); // Trying to get origin outside of image if (row >= this._spriteRow) { const maxFrameCol = this.sprite.frames % this._spriteCol; const maxFrameRow = Math.floor(this.sprite.frames / this._spriteCol); // Get last frame col = Math.min(maxFrameCol, this._spriteCol - 1); row = Math.min(maxFrameRow, this._spriteRow - 1); } const sx = col * this._spriteSize; const sy = row * this._spriteSize; return { sx, sy, }; } _isSprite() { return this.images.length === 0 && !!this.sprite.sheet; } render(canvas, frameIndex) { const ctx = canvas.getContext('2d'); if (!this._isSprite()) { const img = this._getImg(frameIndex); if (!img) return; if (this.sizeRef || this.size) { // Warning: When trying to abstract width and height out and only having a single call // to drawImage it breaks........WTFBBQ let { width, height } = this._getImg(0); let x = 0; let y = 0; if (this.size) { width = this.size.width || width; height = this.size.height || height; x = this.size.x || x; y = this.size.y || y; } else { const size = this.sizeRef.getSize(frameIndex); if (size) { width = size.width; height = size.height; } } return ctx.drawImage(img, x, y, width, height); } return ctx.drawImage(img, 0, 0); } // if _isSprite const { sx, sy } = this._getSpriteOrigin(Math.min(frameIndex, this.sprite.frames)); const img = this._getSpriteImg(); if (!img) return; ctx.drawImage( img, // image, sx, sy, this._spriteSize, // sWidth, this._spriteSize, // sHeight, 0, // dx, 0, // dy, this._spriteSize, // dWidth, this._spriteSize // dHeight ); } } class MaskLayer extends BaseLayer { constructor(opts) { super(opts); const { mask, layers } = opts; this.mask = mask; this.layers = layers; this.maxNumOfFrames = [this.mask, ...this.layers].reduce((acc, cur, i) => { return Math.max(acc, cur.getFrames()); }, 0); } getFrames() { return this.maxNumOfFrames; } preload(index) { const promises = [this.mask, ...this.layers].reduce((acc, layer) => { acc.push(layer.preload(index)); return acc; }, []); return Promise.all(promises); } render(canvas, frameIndex) { const ctx = canvas.getContext('2d'); this.layers.forEach((layer, i) => { layer.render(canvas, frameIndex) }); ctx.globalCompositeOperation = 'destination-in'; this.mask.render(canvas, frameIndex); } } const imageStringIterator = (stringWithSymbol, start, end, padWithZeros = true) => { const images = []; for (let i = start; i <= end; i++) { const iAsString = `${end}`.length > `${i}`.length ? `0${i}` : `${i}`; images.push(stringWithSymbol.replace('XX', iAsString)); } return images; }; export default { Animation, BaseLayer, Layer, MaskLayer, ColorLayer, Utils: { imageStringIterator, }, };