fumen-svg
Version:
Convert fumen data into animated SVG.
251 lines (231 loc) • 6.71 kB
JavaScript
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};