// Per-letter text effects of the Kinetic Text Tags library and its relatives ({bt} {fi} {sc} {rotat} {glitch}), with // the formulas of the library's own displayables (BounceText, FadeInText, ScareText, GlitchText). Each letter is an // element that a single animation loop moves for as long as the element stays on the page. /** Text speed in characters per second (0 = instant), as the player's settings have it. */ export const textSettings = { cps: 0 }; interface Motion { el: HTMLElement; update(t: number): void; } const live = new Set(); let ticking = false; function tick() { const now = performance.now(); for (const motion of [...live]) { if (!motion.el.isConnected) { live.delete(motion); continue; } const start = Number(motion.el.closest("[data-fx-start]")?.dataset.fxStart ?? now); motion.update((now - start) / 1000); } if (live.size) requestAnimationFrame(tick); else ticking = false; } function add(motion: Motion) { live.add(motion); motion.update(0); if (!ticking) { ticking = true; requestAnimationFrame(tick); } } /** The time zero of the effects inside `root`: set once, so a text that is typed out letter by letter keeps its phase. */ export function markStart(root: HTMLElement) { if (!root.dataset.fxStart) root.dataset.fxStart = String(performance.now()); } export const split = (arg: string) => arg.split("-").filter((p) => p !== ""); /** `{bt=a20-p2.0-s0.5}` or `{bt=12}`: amplitude, period and speed of the wave. */ export function bounce(el: HTMLElement, arg: string, offset: number) { let amp = 20, period = 4.0, speed = 1.0; if (arg !== "") { const parts = split(arg); if (parts.length === 1 && /^\d/.test(parts[0])) amp = parseInt(parts[0], 10); else for (const part of parts) { if (part[0] === "a") amp = parseInt(part.slice(1), 10); else if (part[0] === "p") period = parseFloat(part.slice(1)); else if (part[0] === "s") speed = parseFloat(part.slice(1)); } } add({ el, update: (t) => { el.style.transform = `translateY(${Math.sin(period * (t * speed + offset * -0.1)) * amp}px)`; } }); } /** `{fi=index-time-distance}`: each letter fades in and slides, one after another at the text speed. */ export function fadeIn(el: HTMLElement, arg: string, number: number) { let fade = 5.0, slide = 100; const parts = split(arg); if (parts.length > 1) fade = parseFloat(parts[1]); if (parts.length > 2) slide = parseInt(parts[2], 10); const delay = textSettings.cps !== 0 ? number / textSettings.cps : 0; add({ el, update: (t) => { let alpha = 0, x = 5.0; if (t > delay) { const progress = (t - delay) / fade; alpha = Math.min(1, progress); x = Math.max(slide - progress * slide, 0); } el.style.opacity = String(alpha); el.style.transform = `translateX(${x}px)`; } }); } /** `{sc=5}`: every letter jitters inside a square of that size, on every frame. */ export function scare(el: HTMLElement, arg: string) { const square = arg === "" ? 5 : parseFloat(arg); add({ el, update: () => { el.style.transform = `translate(${(Math.random() - 0.5) * square}px, ${(Math.random() - 0.5) * square}px)`; } }); } /** `{rotat=300}`: the letter turns about its own centre at that many degrees a second. */ export function rotation(el: HTMLElement, arg: string) { const speed = arg === "" ? 300 : parseFloat(arg); add({ el, update: (t) => { el.style.transform = `rotate(${t * speed}deg)`; } }); } /** `{glitch=10}`: the text is cut into strips of random height and, now and then, a strip jumps sideways. */ export function glitch(el: HTMLElement, arg: string) { const amount = arg === "" ? 10 : parseFloat(arg); const pool: HTMLElement[] = []; el.style.position = "relative"; let color = ""; let text = ""; add({ el, update: () => { const h = el.offsetHeight, w = el.offsetWidth; if (!h || !w) return; if (!color) { // the text itself is hidden; the strips, which carry its colour, are what shows text = el.textContent ?? ""; color = getComputedStyle(el).color; el.style.color = "transparent"; } let used = 0; let y = 0; let guard = 0; while (y < h && guard++ < 200) { const occurs = Math.random() * 100 < amount; const height = occurs ? Math.round(Math.random() * 20 - 10) : Math.round(Math.random() * 10); const offset = Math.round(Math.random() * 20 - 10); if (height > 0) { let strip = pool[used]; if (!strip) { strip = document.createElement("span"); strip.className = "pg-glitch-strip"; strip.style.cssText = `position:absolute;left:0;top:0;white-space:pre;pointer-events:none;color:${color}`; strip.textContent = text; el.append(strip); pool.push(strip); } used++; strip.style.visibility = "visible"; strip.style.clipPath = `inset(${y}px 0 ${Math.max(0, h - y - height)}px 0)`; strip.style.transform = `translateX(${occurs ? offset : 0}px)`; } y += Math.abs(height) || 1; } for (let i = used; i < pool.length; i++) pool[i].style.visibility = "hidden"; } }); }