Files
pgames-manager/frontend/src/player/texteffects.ts
T
mrleo1nidandClaude Sonnet 5.5 ea351e44b3
CI / frontend (push) Failing after 19s
CI / backend (push) Failing after 39s
feat: Kinetic Text Tags letters ({bt} {fi} {sc} {rotat} {glitch}) use the library's own formulas; Flatten; screen 3D
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>
2026-10-27 01:45:02 +03:00

128 lines
5.0 KiB
TypeScript

// 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";
} });
}