#!/usr/bin/env node const { createCanvas, loadImage } = require("canvas"); const { GIFEncoder, quantize, applyPalette } = require("gifenc"); const fs = require("fs"); const MAX_FRAME = 4; const OUT_SIZE = 112; const CACHE_SIZE = 256; const g = { squish: 1.25, scale: 0.875, delay: 60, spriteX: 14, spriteY: 20, spriteWidth: 112, spriteHeight: 112, flip: false, }; const CANVAS_OPTIONS = Object.freeze({ antialias: false, powerPreference: "low-power", }); /// ANIMATION /** Frame offset values */ const frameOffsets = [ { x: 0, y: 0, w: 0, h: 0 }, { x: -4, y: 12, w: 4, h: -12 }, { x: -12, y: 18, w: 12, h: -18 }, { x: -8, y: 12, w: 4, h: -12 }, { x: -4, y: 0, w: 0, h: 0 }, ]; /** * Get the sprite's positioning for a frame * @param {number} frame */ const getSpriteFrame = (frame) => { const offset = frameOffsets[frame]; return { dx: ~~(g.spriteX + offset.x * (g.squish * 0.4)), dy: ~~(g.spriteY + offset.y * (g.squish * 0.9)), dw: ~~((g.spriteWidth + offset.w * g.squish) * g.scale), dh: ~~((g.spriteHeight + offset.h * g.squish) * g.scale), }; }; /** Render animation frame */ const renderFrame = (img, _frame, _ctx, _adjust) => { const cf = getSpriteFrame(_frame); // reset canvas if (_ctx.globalAlpha !== 1) _ctx.globalAlpha = 1; _ctx.clearRect(0, 0, OUT_SIZE, OUT_SIZE); // flipping the sprite is super annoying. first we translate canvas to where the sprite will // be which allows us to draw the hand sprite (and the outline for adjust mode) at (0,0). then // we flip the whole canvas and draw what ever we need to draw flipped, and then finally reset // the scale/translation and draw the hand _ctx.save(); _ctx.translate(cf.dx, cf.dy); if (g.flip) { _ctx.scale(-1, 1); cf.dw *= -1; // invert the width or the sprite gets drawn off canvas } // draw sprite and outline _ctx.drawImage(img.sprite, 0, 0, cf.dw, cf.dh); if (_adjust) _ctx.strokeRect(0, 0, cf.dw, cf.dh); _ctx.restore(); // draw hand if (_adjust) _ctx.globalAlpha = 0.75; _ctx.drawImage( img.hand, _frame * OUT_SIZE, //sx 0, //sy OUT_SIZE, //sw OUT_SIZE, //sh 0, //dx // don't ask where these numbers are from they just work.... Math.max(0, ~~(cf.dy * 0.75 - Math.max(0, g.spriteY) - 0.5)), //dy OUT_SIZE, //dw OUT_SIZE //dh ); }; /** * Replace transparent pixels with green since gif.js doesn't dither transparency * @param {Uint8ClampedArray} data */ const optimizeFrameColors = (data) => { for (let i = 0; i < data.length; i += 4) { // clamp greens to avoid pure greens in the image from turning transparent // basically a hack and it's not really noticeable and it works data[i + 1] = data[i + 1] > 250 ? 250 : data[i + 1]; // Set transparent pixels to green if (data[i + 3] < 120) { data[i + 0] = 0; data[i + 1] = 255; data[i + 2] = 0; } // No more transparent pixels data[i + 3] = 255; } }; /** Render gif */ const renderGif = (img, output) => { const renderCanvas = createCanvas(OUT_SIZE, OUT_SIZE); const renderCtx = renderCanvas.getContext("2d", CANVAS_OPTIONS); const encoder = GIFEncoder(); for (let i = 0; i <= MAX_FRAME; i++) { // render frame renderFrame(img, i, renderCtx, false); const imgData = renderCtx.getImageData(0, 0, OUT_SIZE, OUT_SIZE); // fix transparency optimizeFrameColors(imgData.data); const palette = quantize(imgData.data, 255); palette.push([0, 255, 0]); const index = applyPalette(imgData.data, palette); encoder.writeFrame(index, OUT_SIZE, OUT_SIZE, { palette, delay: g.delay, transparent: true, transparentIndex: palette.length-1 }); } encoder.finish(); output.write(encoder.bytes()); }; Promise.all([ loadImage(__dirname + "/hand.png"), loadImage(process.argv[2]), ]).then(([hand, sprite]) => renderGif({ hand, sprite }, fs.createWriteStream(process.argv[3])));