diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 5ca6bbe..1b300fa 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -369,3 +369,14 @@ /* checkbox row of the settings screen */ .pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; } .pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); } + +/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */ +.pg-fx { display: inline-block; white-space: pre; } +.pg-fx-bt { animation: pg-fx-bounce 0.9s ease-in-out infinite; } +.pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; } +.pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; } +.pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; } +@keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } } +@keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } } +@keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } } +@keyframes pg-fx-spin { to { transform: rotate(360deg); } } diff --git a/frontend/src/player/text.ts b/frontend/src/player/text.ts index 6abce36..957f175 100644 --- a/frontend/src/player/text.ts +++ b/frontend/src/player/text.ts @@ -9,6 +9,11 @@ export interface Style { s?: boolean; color?: string; size?: string; + /** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */ + fx?: string; + fxArg?: string; + /** The tag that opened this style, so its closing tag finds it again. */ + tag?: string; } export type Segment = @@ -74,7 +79,11 @@ export function parseText(text: string): ParsedText { const [name, value] = splitTag(tag); const top = stack[stack.length - 1]; if (name.startsWith("/")) { - if (stack.length > 1) stack.pop(); + const opened = name.slice(1); + let at = stack.length - 1; + if (opened) while (at > 0 && stack[at].tag !== opened) at--; + if (opened && at > 0) stack.length = at; // closes what it opened, and anything left open inside + else if (!opened && stack.length > 1) stack.pop(); // `{/}` closes the innermost style continue; } switch (name) { @@ -82,13 +91,16 @@ export function parseText(text: string): ParsedText { case "i": case "u": case "s": - stack.push({ ...top, [name]: true }); + stack.push({ ...top, [name]: true, tag: name }); break; case "color": - stack.push({ ...top, color: value }); + stack.push({ ...top, color: value, tag: name }); break; case "size": - stack.push({ ...top, size: value }); + stack.push({ ...top, size: value, tag: name }); + break; + case "bt": case "sc": case "fi": case "rotat": // Kinetic Text Tags + stack.push({ ...top, fx: name, fxArg: value, tag: name }); break; case "w": segments.push({ kind: "wait", seconds: value ? Number(value) : null }); @@ -111,10 +123,12 @@ export function parseText(text: string): ParsedText { break; case "a": case "font": case "cps": case "alpha": case "k": case "plain": case "outlinecolor": case "rt": case "rb": case "art": case "shader": - stack.push({ ...top }); // styling we ignore but must still balance + stack.push({ ...top, tag: name }); // styling we ignore but must still balance break; default: - // {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags + // a tag with its closing tag later ({chaos}...{/chaos}) shows its text plainly; anything else + // ({image=...}, {#comment}, {done}, {clear}) is self-closing + if (name && text.includes(`{/${name}}`, end)) stack.push({ ...top, tag: name }); break; } continue; @@ -142,6 +156,22 @@ export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, const shown = chars.length <= left ? seg.text : chars.slice(0, left).join(""); left -= chars.length; const st = seg.style; + if (st.fx) { // every letter moves on its own, one after another + const amount = Number.parseFloat(st.fxArg ?? ""); + [...shown].forEach((letter, index) => { + const piece = document.createElement("span"); + piece.textContent = letter; + piece.className = `pg-fx pg-fx-${st.fx}`; + piece.style.animationDelay = `${(index * 0.07).toFixed(2)}s`; + if (Number.isFinite(amount)) piece.style.setProperty("--fx", String(amount)); + if (st.b) piece.style.fontWeight = "bold"; + if (st.i) piece.style.fontStyle = "italic"; + if (st.color) piece.style.color = st.color; + if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize); + el.append(piece); + }); + continue; + } if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) { el.append(shown); continue; diff --git a/frontend/tests/ui.test.mjs b/frontend/tests/ui.test.mjs index 4380b28..f23d77f 100644 --- a/frontend/tests/ui.test.mjs +++ b/frontend/tests/ui.test.mjs @@ -40,3 +40,16 @@ test("highlight wraps glossary terms case-insensitively and escapes regex charac assert.match(html, /a\+b<\/mark>/); assert.equal(highlight("plain", []), "plain"); }); + +test("custom tags: letters of {bt} and {sc} move, a closing tag closes only its own tag, unknown pairs stay plain", async () => { + const { build } = await import("esbuild"); + const { fileURLToPath } = await import("node:url"); + const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/text.ts", import.meta.url))], + bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" }); + const { parseText, plainText } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); + const parsed = parseText("a{b}{bt=8}jump{/bt} still bold{/b} plain {chaos}odd{/chaos} end"); + const styles = parsed.segments.filter((s) => s.kind === "text").map((s) => [s.text, !!s.style.b, s.style.fx ?? null]); + assert.deepEqual(styles, [["a", false, null], ["jump", true, "bt"], [" still bold", true, null], [" plain ", false, null], + ["odd", false, null], [" end", false, null]]); + assert.equal(plainText(parsed), "ajump still bold plain odd end"); +});