feat: Kinetic Text Tags letters ({bt} {fi} {sc} {rotat} {glitch}) use the library's own formulas; Flatten; screen 3D
CI / frontend (push) Failing after 19s
CI / backend (push) Failing after 39s

Each letter is moved by one animation loop with the formulas of BounceText, FadeInText, ScareText and
GlitchText (fi follows the text speed, bt keeps its phase while a line is typed out, glitch cuts the text
into strips that jump sideways). Flatten and `at Flatten` isolate their children as one picture, screens
get the 3D turns, matrixtransform and blend of their widgets, and RotateMatrix/OffsetMatrix/ScaleMatrix
exist in Python.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-27 01:45:02 +03:00
1 parent 5ff43038ae
commit ea351e44b3
5 files changed
+189 -23

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
// 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<Motion>();
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<HTMLElement>("[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";
} });
}