UNPKG

fumen-svg

Version:

Convert fumen data into animated SVG.

251 lines (231 loc) 6.71 kB
const {decoder} = require("tetris-fumen"); const escape = require("escape-html"); const tiles = { _: "#000", X: "#999", Xl: "#ccc", I: "hsl(180, 100%, 35%)", Il: "#0ff", L: "hsl(30, 95%, 47%)", Ll: "hsl(36, 100%, 55%)", O: "hsl(60, 70%, 40%)", Ol: "#ff0", Z: "hsl(0, 70%, 50%)", Zl: "hsl(0, 100%, 63%)", T: "hsl(295, 80%, 47%)", Tl: "hsl(300, 90%, 57%)", J: "hsl(240, 75%, 50%)", Jl: "hsl(240, 100%, 60%)", S: "hsl(120, 100%, 33%)", Sl: "#0f0" }; function createTile(name, size) { const fill = tiles[name]; const stroke = name === "_" ? ' stroke="rgba(255,255,255,0.05)"' : ""; return `<rect id="t${name}" fill="${fill}" width="${size}" height="${size}"${stroke}/>`; } function pageToFrame(page) { const field = page.field.copy(); const filledMino = page.operation && field.fill(page.operation); const output = Array(200); const touchedY = new Set; // extract field data for (let y = 0; y < 20; y++) { for (let x = 0; x < 10; x++) { output[y * 10 + x] = { type: field.at(x, y), light: false }; } } // draw active mino if (filledMino) { for (const {x, y} of filledMino.positions()) { touchedY.add(y); output[y * 10 + x].light = true; } } if (page.flags.lock) { // lighten lines which should be cleared for (const y of touchedY) { if (isLineFilled(field, y)) { for (let x = 0; x < 10; x++) { output[y * 10 + x].light = true; } } } } return output; } function isLineFilled(field, y) { for (let x = 0; x < 10; x++) { if (field.at(x, y) === "_") { return false; } } return true; } function createSVG({ data, pages = decoder.decode(data), index, delay = 500, size = 16, commentSize = 16, animateType = "css", comment = "auto" }) { if (index != null) { pages = pages.slice(index, index + 1); } const frames = pages.map(pageToFrame); const drawComment = comment === "always" ? true : comment === "none" ? false : pages.some(p => p.comment); const commentHeight = Math.round(commentSize * 1.6); const commentOffset = commentSize * 1.2; const commentY = size * 20; const width = size * 10; const height = size * 20 + (drawComment ? commentHeight : 0); // diff frames for (let i = frames.length - 1; i >= 1; i--) { for (let j = 0; j < frames[i].length; j++) { if (isTileEqual(frames[i - 1][j], frames[i][j])) { frames[i][j] = null; } } } // diff comment for (let i = pages.length - 1; i >= 1; i--) { if (pages[i].comment && pages[i].comment === pages[i - 1].comment) { pages[i].comment = null; } } const layers = []; const usedTiles = new Set; for (let i = 0; i < frames.length; i++) { const layer = createLayer(frames[i], size); for (const t of layer.used) { usedTiles.add(t); } const comment = drawComment ? createComment({ text: pages[i].comment, width, height: commentHeight, y: commentY, size: commentSize, offset: commentOffset, index: i }) : ""; layers.push(` <svg viewBox="0 0 ${width} ${height}" id="f${i}"> ${layer.tiles.join("")} ${comment} ${createAnimate(i, frames.length, delay, animateType)} </svg>`); } return ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" style="font-family: sans-serif"> <defs> ${createDefs(usedTiles, size)} </defs> ${layers.join("")} ${createProgress(commentY, frames.length, width, delay, animateType)} </svg>`.trim().replace(/>\s+</g, ">\n<"); } function createProgress(y, total, width, delay, type) { if (total === 1) { return ""; } const attr = `d="M 0,${y} H${width}" stroke="silver" stroke-width="${width / 50}" stroke-dasharray="${width}"`; if (type === "css") { return ` <path ${attr} id="p"/> <style> #p {animation: p ${delay * total}ms steps(${total}, start) infinite} @keyframes p { from { stroke-dashoffset: ${width}; } to { stroke-dashoffset: 0; } } </style>`; } return ` <path ${attr}> <animate attributeName="stroke-dashoffset" values="${createValues()}" calcMode="discrete" dur="${total * delay}ms" repeatCount="indefinite"/> </path>`; function createValues() { const values = []; for (let i = 0; i < total; i++) { values.push((total - i - 1) / total * width); } return values.join(";"); } } function createComment({text, width, height, y, size, offset, index}) { if (!text && index) { return ""; } const bg = index === 0 ? `<rect id="cbg" fill="#333" y="${y}" width="${width}" height="${height}"/>` : '<use href="#cbg"/>'; const textEl = text ? `<text x="${width / 2}" y="${y + offset}" text-anchor="middle" font-size="${size}" fill="white">${escape(text)}</text>` : ""; return bg + textEl; } function createAnimate(i, total, delay, type) { if (i === 0) { return ""; } if (type === "css") { return `<style> #f${i} { animation: a${i} ${total * delay}ms step-end infinite; } @keyframes a${i} { 0% {visibility: hidden} ${i * 100 / total}% {visibility: visible} } </style>`; } return `<animate attributeName="display" values="none;inline" calcMode="discrete" keyTimes="0;${i/total}" dur="${delay * total}ms" repeatCount="indefinite" />`; } function createDefs(include, size) { const els = []; for (const name of include) { els.push(createTile(name, size)); } return els.join(""); } function createLayer(buffer, size) { const bgTiles = []; const tiles = []; const used = new Set; for (let i = 0; i < buffer.length; i++) { if (!buffer[i]) { continue; } const name = `${buffer[i].type}${buffer[i].light ? "l" : ""}`; used.add(name); const y = Math.floor(i / 10); const x = i % 10; const data = `<use href="#t${name}" x="${x * size}" y="${(20 - y - 1) * size}"/>`; if (name === "_") { bgTiles.push(data); } else { tiles.push(data); } } return { used, tiles: bgTiles.concat(tiles) }; } function isTileEqual(a, b) { return a.type === b.type && a.light === b.light; } module.exports = {createSVG};