// yy-rendersheet
// by David Figatner
// (c) YOPEY YOPEY LLC 2019
// MIT License
// https://github.com/davidfig/rendersheet
const PIXI = require('pixi.js')
const Events = require('eventemitter3')
const GrowingPacker = require('./growingpacker')
const SimplePacker = require('./simplepacker')
// types
const CANVAS = 0 // default
const IMAGE = 1 // image url
const DATA = 2 // data src (e.g., result of .toDataURL())
// default ms to wait to check if an image has finished loading
const WAIT = 250
class RenderSheet extends Events
{
/**
* @param {object} options
* @param {number} [options.maxSize=2048]
* @param {number} [options.buffer=5] around each texture
* @param {number} [options.scale=1] of texture
* @param {number} [options.resolution=1] of rendersheet
* @param {number} [options.extrude] the edges--useful for removing gaps in sprites when tiling
* @param {number} [options.wait=250] number of milliseconds to wait between checks for onload of addImage images before rendering
* @param {boolean} [options.testBoxes] draw a different colored boxes behind each rendering (useful for debugging)
* @param {number|boolean} [options.scaleMode] PIXI.settings.SCALE_MODE to set for rendersheet (use =true for PIXI.SCALE_MODES.NEAREST for pixel art)
* @param {boolean} [options.useSimplePacker] use a stupidly simple packer instead of growing packer algorithm
* @param {boolean|object} [options.show] set to true or a CSS object (e.g., {zIndex: 10, background: 'blue'}) to attach the final canvas to document.body--useful for debugging
* @fire render
*/
constructor(options)
{
super()
options = options || {}
this.wait = options.wait || WAIT
this.testBoxes = options.testBoxes || false
this.maxSize = options.maxSize || 2048
this.buffer = options.buffer || 5
this.scale = options.scale || 1
this.scaleMode = options.scaleMode === true ? PIXI.SCALE_MODES.NEAREST : options.scaleMode
this.resolution = options.resolution || 1
this.show = options.show
this.extrude = options.extrude
if (this.extrude && this.buffer < 2)
{
this.buffer = 2
}
this.packer = options.useSimplePacker ? SimplePacker : GrowingPacker
this.clear()
}
/**
* removes all textures from rendersheets
*/
clear()
{
this.canvases = []
this.baseTextures = []
this.textures = {}
}
/**
* adds a canvas rendering
* @param {string} name of rendering
* @param {Function} draw function(context) - use the context to draw within the bounds of the measure function
* @param {Function} measure function(context) - needs to return {width: width, height: height} for the rendering
* @param {object} params - object to pass the draw() and measure() functions
* @return {object} rendersheet object for texture
*/
add(name, draw, measure, param)
{
const object = this.textures[name] = { name: name, draw: draw, measure: measure, param: param, type: CANVAS, texture: new PIXI.Texture(PIXI.Texture.EMPTY) }
return object
}
/**
* adds an image rendering
* @param {string} name of rendering
* @param {string} src for image
* @return {object} rendersheet object for texture
*/
addImage(name, src)
{
const object = this.textures[name] = { name, file: src, type: IMAGE, texture: new PIXI.Texture(PIXI.Texture.EMPTY) }
object.image = new Image()
object.image.onload = () => object.loaded = true
object.image.src = src
return object
}
/**
* adds a data source (e.g., a PNG file in data format)
* @param {object} data of rendering (not filename)
* @param {string} [header=data:image/png;base64,] for data
* @return {object} rendersheet object for texture
*/
addData(name, data, header)
{
header = typeof header !== 'undefined' ? header : 'data:image/png;base64,'
const object = this.textures[name] = { name, type: DATA, texture: new PIXI.Texture(PIXI.Texture.EMPTY) }
object.image = new Image()
object.image.src = header + data
if (object.image.complete)
{
object.loaded = true
}
else
{
object.image.onload = () => object.loaded = true
}
return object
}
/**
* attaches RenderSheet to DOM for testing
* @param {object} styles - CSS styles to use for rendersheet
* @private
*/
showCanvases()
{
if (!this.divCanvases)
{
this.divCanvases = document.createElement('div')
document.body.appendChild(this.divCanvases)
}
else
{
while (this.divCanvases.hasChildNodes())
{
this.divCanvases.removeChild(this.divCanvases.lastChild)
}
}
const percent = 1 / this.canvases.length
for (let i = 0; i < this.canvases.length; i++)
{
const canvas = this.canvases[i]
const style = canvas.style
style.position = 'fixed'
style.left = '0px'
style.top = i * Math.round(percent * 100) + '%'
style.width = 'auto'
style.height = Math.round(percent * 100) + '%'
style.zIndex = 1000
if (this.scaleMode === PIXI.SCALE_MODES.NEAREST)
{
style.imageRendering = 'pixelated'
}
style.background = this.randomColor()
if (typeof this.show === 'object')
{
for (let key in this.show)
{
style[key] = this.show[key]
}
}
this.divCanvases.appendChild(canvas)
}
}
/**
* tests whether a texture exists
* @param {string} name of texture
* @return {boolean}
*/
exists(name)
{
return this.textures[name] ? true : false
}
/**
* @param {string} name of texture
* @return {(PIXI.Texture|null)}
*/
getTexture(name)
{
const texture = this.textures[name]
if (texture)
{
return texture.texture
}
else
{
console.warn('yy-rendersheet: texture ' + name + ' not found in spritesheet.')
return null
}
}
/**
* returns a PIXI.Sprite (with anchor set to 0.5, because that's where it should be)
* @param {string} name of texture
* @return {PIXI.Sprite}
*/
getSprite(name)
{
const texture = this.getTexture(name)
if (texture)
{
const sprite = new PIXI.Sprite(texture)
sprite.anchor.set(0.5)
return sprite
}
else
{
return null
}
}
/**
* alias for getSprite()
* @param {string} name of texture
* @return {PIXI.Sprite}
*/
get(name)
{
return this.getSprite(name)
}
/**
* @return {number} amount of textures in this rendersheet
*/
entries()
{
return Object.keys(this.textures).length
}
/**
* prints statistics of canvases to console.log
*/
debug()
{
for (let i = 0; i < this.canvases.length; i++)
{
const canvas = this.canvases[i]
console.log('yy-rendersheet: Sheet #' + (i + 1) + ' | size: ' + canvas.width + 'x' + canvas.height + ' | resolution: ' + this.resolution)
}
}
/**
* find the index of the texture based on the texture object
* @param {number} find this indexed texture
* @returns {PIXI.Texture}
*/
getIndex(find)
{
let i = 0
for (let key in this.textures)
{
if (i === find)
{
return this.textures[key].texture
}
i++
}
return null
}
/**
* checks if all textures are loaded
* @return {boolean}
*/
checkLoaded()
{
for (let key in this.textures)
{
const current = this.textures[key]
if ((current.type === IMAGE || current.type === DATA) && !current.loaded)
{
return false
}
}
return true
}
/**
* create (or refresh) the rendersheet (supports async instead of callback)
* @param {boolean} skipTextures - don't create PIXI.BaseTextures and PIXI.Textures (useful for generating external spritesheets)
*/
asyncRender(skipTextures)
{
return new Promise(resolve =>
{
this.render(resolve, skipTextures)
})
}
/**
* create (or refresh) the rendersheet
* @param {boolean} skipTextures - don't create PIXI.BaseTextures and PIXI.Textures (useful for generating external spritesheets)
* @param {function} callback - convenience function that calls RenderSheet.once('render', callback)
*/
render(callback, skipTextures)
{
if (callback)
{
this.once('render', callback)
}
if (!Object.keys(this.textures).length)
{
this.emit('render')
return
}
if (!this.checkLoaded())
{
setTimeout(() => this.render(), this.wait)
return
}
this.canvases = []
this.sorted = []
this.measure()
this.sort()
this.pack()
this.draw()
if (!skipTextures)
{
this.createBaseTextures()
for (let key in this.textures)
{
const current = this.textures[key]
current.texture.baseTexture = this.baseTextures[current.canvas]
current.texture.frame = new PIXI.Rectangle(current.x, current.y, current.width, current.height)
current.texture.update()
}
}
if (this.show)
{
this.showCanvases()
}
this.emit('render')
}
/**
* measures canvas renderings
* @private
*/
measure()
{
const c = document.createElement('canvas')
c.width = this.maxSize
c.height = this.maxSize
const context = c.getContext('2d')
const multiplier = Math.ceil(this.scale * this.resolution)
for (let key in this.textures)
{
const texture = this.textures[key]
switch (texture.type)
{
case CANVAS:
const size = texture.measure(context, texture.param, c)
texture.width = Math.ceil(size.width * multiplier)
texture.height = Math.ceil(size.height * multiplier)
break
case IMAGE: case DATA:
texture.width = Math.ceil(texture.image.width * multiplier)
texture.height = Math.ceil(texture.image.height * multiplier)
break
}
this.sorted.push(texture)
}
}
/**
* sort textures by largest dimension
* @private
*/
sort()
{
this.sorted.sort(
function(a, b)
{
let aSize = Math.max(a.height, a.width)
let bSize = Math.max(b.height, b.width)
if (aSize === bSize)
{
aSize = Math.min(a.height, a.width)
bSize = Math.max(b.height, b.width)
}
return bSize - aSize
}
)
}
/**
* create square canvas
* @param {number} [size=this.maxSize]
* @private
*/
createCanvas(size)
{
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size || this.maxSize
this.canvases.push(canvas)
}
/**
* returns a random rgb color
* @private
*/
randomColor()
{
function r()
{
return Math.floor(Math.random() * 255)
}
return 'rgba(' + r() + ',' + r() + ',' + r() + ', 0.2)'
}
/**
* draw renderings to rendertexture
* @private
*/
draw()
{
let current, context
const multiplier = Math.ceil(this.scale * this.resolution)
for (let key in this.textures)
{
const texture = this.textures[key]
if (texture.canvas !== current)
{
if (typeof current !== 'undefined')
{
context.restore()
}
current = texture.canvas
context = this.canvases[current].getContext('2d')
context.save()
context.scale(multiplier, multiplier)
}
context.save()
context.translate(Math.ceil(texture.x / multiplier), Math.ceil(texture.y / multiplier))
if (this.testBoxes)
{
context.fillStyle = this.randomColor()
context.fillRect(0, 0, Math.ceil(texture.width / multiplier), Math.ceil(texture.height / multiplier))
}
switch (texture.type)
{
case CANVAS:
texture.draw(context, texture.param, this.canvases[current])
break
case IMAGE: case DATA:
context.drawImage(texture.image, 0, 0)
break
}
if (this.extrude)
{
this.extrudeEntry(texture, context, current)
}
context.restore()
}
context.restore()
}
/**
* extrude pixels for entry
* @param {object} texture
* @param {CanvasRenderingContext2D} context
* @private
*/
extrudeEntry(texture, context, current)
{
function get(x, y)
{
const entry = (x + y * texture.width) * 4
const d = data.data
return 'rgba(' + d[entry] + ',' + d[entry + 1] + ',' + d[entry + 2] + ',' + (d[entry + 3] / 0xff) + ')'
}
const canvas = this.canvases[current]
const data = context.getImageData(texture.x, texture.y, texture.width, texture.height)
if (texture.x !== 0)
{
for (let y = 0; y < texture.height; y++)
{
context.fillStyle = get(0, y)
context.fillRect(-1, y, 1, 1)
}
if (texture.y !== 0)
{
context.fillStyle = get(0, 0)
context.fillRect(-1, -1, 1, 1)
}
}
if (texture.x + texture.width !== canvas.width - 1)
{
for (let y = 0; y < texture.height; y++)
{
context.fillStyle = get(texture.width - 1, y)
context.fillRect(texture.width, y, 1, 1)
}
if (texture.y + texture.height !== canvas.height - 1)
{
context.fillStyle = get(texture.width - 1, texture.height - 1)
context.fillRect(texture.width, texture.height, 1, 1)
}
}
if (texture.y !== 0)
{
for (let x = 0; x < texture.width; x++)
{
context.fillStyle = get(x, 0)
context.fillRect(x, -1, 1, 1)
}
}
if (texture.y + texture.height !== canvas.height - 1)
{
for (let x = 0; x < texture.width; x++)
{
context.fillStyle = get(x, texture.height - 1)
context.fillRect(x, texture.height, 1, 1)
}
}
}
/**
* @private
*/
createBaseTextures()
{
while (this.baseTextures.length)
{
this.baseTextures.pop().destroy()
}
for (let i = 0; i < this.canvases.length; i++)
{
const from = PIXI.BaseTexture.fromCanvas || PIXI.BaseTexture.from
const base = from(this.canvases[i])
base.scaleMode = this.scaleMode
this.baseTextures.push(base)
}
}
/**
* pack textures after measurement
* @private
*/
pack()
{
const packers = [new this.packer(this.maxSize, this.sorted[0], this.buffer)]
for (let i = 0; i < this.sorted.length; i++)
{
const block = this.sorted[i]
let packed = false
for (var j = 0; j < packers.length; j++)
{
if (packers[j].add(block, j))
{
block.canvas = j
packed = true
break
}
}
if (!packed)
{
packers.push(new this.packer(this.maxSize, block, this.buffer))
if (!packers[j].add(block, j))
{
console.warn('yy-rendersheet: ' + block.name + ' is too big for the spritesheet.')
return
}
else
{
block.canvas = j
}
}
}
for (let i = 0; i < packers.length; i++)
{
const size = packers[i].finish(this.maxSize)
this.createCanvas(size)
}
}
/**
* Changes the drawing function of a texture
* NOTE: this only works if the texture remains the same size; use Sheet.render() to resize the texture
* @param {string} name
* @param {function} draw
*/
changeDraw(name, draw)
{
const texture = this.textures[name]
if (texture.type !== CANVAS)
{
console.warn('yy-sheet.changeTextureDraw only works with type: CANVAS.')
return
}
texture.draw = draw
const context = this.canvases[texture.canvas].getContext('2d')
const multiplier = this.scale * this.resolution
context.save()
context.scale(multiplier, multiplier)
context.translate(texture.x / multiplier, texture.y / multiplier)
texture.draw(context, texture.param)
context.restore()
texture.texture.update()
}
}
module.exports = RenderSheet
/**
* fires when render completes
* @event RenderSheet#render
*/