feat: Kinetic Text Tags {bt} {sc} {fi} {rotat} move letters; closing tags close their own tag

Letters of these custom tags animate one after another (CSS). A closing tag now closes the style that
it opened (with anything left open inside) instead of popping whatever was on top, and an unknown tag
that has a closing tag later shows its text plainly and stays balanced.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:29:20 +03:00
1 parent 8251fca9bf
commit dcddd42aca
3 files changed
+60 -6

No files matched your search

+11
View File
@@ -369,3 +369,14 @@
/* checkbox row of the settings screen */ /* 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 { 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); } .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); } }
+36 -6
View File
@@ -9,6 +9,11 @@ export interface Style {
s?: boolean; s?: boolean;
color?: string; color?: string;
size?: 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 = export type Segment =
@@ -74,7 +79,11 @@ export function parseText(text: string): ParsedText {
const [name, value] = splitTag(tag); const [name, value] = splitTag(tag);
const top = stack[stack.length - 1]; const top = stack[stack.length - 1];
if (name.startsWith("/")) { 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; continue;
} }
switch (name) { switch (name) {
@@ -82,13 +91,16 @@ export function parseText(text: string): ParsedText {
case "i": case "i":
case "u": case "u":
case "s": case "s":
stack.push({ ...top, [name]: true }); stack.push({ ...top, [name]: true, tag: name });
break; break;
case "color": case "color":
stack.push({ ...top, color: value }); stack.push({ ...top, color: value, tag: name });
break; break;
case "size": 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; break;
case "w": case "w":
segments.push({ kind: "wait", seconds: value ? Number(value) : null }); segments.push({ kind: "wait", seconds: value ? Number(value) : null });
@@ -111,10 +123,12 @@ export function parseText(text: string): ParsedText {
break; break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain": case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader": 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; break;
default: 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; break;
} }
continue; 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(""); const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
left -= chars.length; left -= chars.length;
const st = seg.style; 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) { if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
el.append(shown); el.append(shown);
continue; continue;
+13
View File
@@ -40,3 +40,16 @@ test("highlight wraps glossary terms case-insensitively and escapes regex charac
assert.match(html, /<mark title="x">a\+b<\/mark>/); assert.match(html, /<mark title="x">a\+b<\/mark>/);
assert.equal(highlight("plain", []), "plain"); 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");
});