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>
This commit is contained in:
1 parent
5ff43038ae
commit
ea351e44b3
5 files changed
+189
-23
No files matched your search
@@ -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";
|
||||
} });
|
||||
}
|
||||
Reference in new issue
Block a user