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

+2
View File
@@ -10,6 +10,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens"; import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
import { textSettings } from "./texteffects";
import { containerClass, displayableClass, makeRender, pygameNamespace } from "./render"; import { containerClass, displayableClass, makeRender, pygameNamespace } from "./render";
import { musicRoomClass } from "./musicroom"; import { musicRoomClass } from "./musicroom";
import { pythonModules } from "./pymodules"; import { pythonModules } from "./pymodules";
@@ -1664,6 +1665,7 @@ export class Engine {
const waits = waitPoints(parsed).filter((w) => w.at > from); const waits = waitPoints(parsed).filter((w) => w.at > from);
let shown = from; let shown = from;
const cps = this.settings.cps; const cps = this.settings.cps;
textSettings.cps = cps; // {fi} letters fade in at the text speed
if (instant || cps <= 0 || this.skip) { if (instant || cps <= 0 || this.skip) {
renderText(target, parsed, total, this.baseSize); renderText(target, parsed, total, this.baseSize);
target.append(this.ctc); target.append(this.ctc);
-12
View File
@@ -372,19 +372,7 @@
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */ /* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */
.pg-fx { display: inline-block; white-space: pre; } .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; }
.pg-fx-glitch { animation: pg-fx-glitch 0.35s steps(2, end) infinite; text-shadow: 1px 0 #f0f, -1px 0 #0ff; }
.pg-fx-wave { animation: pg-fx-wave 1.2s ease-in-out infinite; }
@keyframes pg-fx-glitch { 20% { transform: translate(-1px, 1px); } 40% { transform: translate(1px, -1px); } 60% { transform: translate(-2px, 0); } 80% { transform: translate(2px, 1px); } }
@keyframes pg-fx-wave { 50% { transform: translateY(-4px); } }
/* SnowBlossom: pictures that fall and drift while the scene is shown */ /* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } .pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; } .pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } } @keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
@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); } }
+20 -1
View File
@@ -9,6 +9,7 @@ import { galleryClass } from "./gallery";
import { parseExpression } from "./pyparse"; import { parseExpression } from "./pyparse";
import { movieDisplayable } from "./movie"; import { movieDisplayable } from "./movie";
import { RenderHost, isDisplayableInstance } from "./render"; import { RenderHost, isDisplayableInstance } from "./render";
import { Matrix4, blendMode } from "./rendertransform";
import { strftime } from "./pymodules"; import { strftime } from "./pymodules";
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, truthy, type Python } from "./python"; import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, truthy, type Python } from "./python";
import type { AtSpec, Expr } from "./types"; import type { AtSpec, Expr } from "./types";
@@ -1184,6 +1185,13 @@ export class ScreenManager {
const name = pyStr(a).replace(/\[([A-Za-z_][\w.]*)\]/g, (_m, source: string) => pyStr(this.host.py.eval(parseExpression(source)))); const name = pyStr(a).replace(/\[([A-Za-z_][\w.]*)\]/g, (_m, source: string) => pyStr(this.host.py.eval(parseExpression(source))));
return this.displayable(name, depth + 1); return this.displayable(name, depth + 1);
} }
case "flatten": { // the children are drawn as one picture: opacity and filters then act on the whole
const group = document.createElement("div");
group.style.isolation = "isolate";
const child = this.displayable(a, depth + 1);
if (child) group.append(child);
return group;
}
case "animation": { // pictures that take turns; the last one stays when it has no time case "animation": { // pictures that take turns; the last one stays when it has no time
const box = document.createElement("div"); const box = document.createElement("div");
const pictures: HTMLElement[] = []; const pictures: HTMLElement[] = [];
@@ -1591,7 +1599,14 @@ export class ScreenManager {
if (p.xoffset || p.yoffset) parts.push(`translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px)`); if (p.xoffset || p.yoffset) parts.push(`translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px)`);
if (xz !== 1 || yz !== 1) parts.push(`scale(${xz}, ${yz})`); if (xz !== 1 || yz !== 1) parts.push(`scale(${xz}, ${yz})`);
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
// 3D turns about the widget's centre (the default matrixanchor) are Ren'Py's own matrices
if (p.xrotate || p.yrotate || p.zrotate || p.zpos) {
const turn = Matrix4.offset(0, 0, p.zpos ?? 0).mul(Matrix4.rotate(p.xrotate ?? 0, p.yrotate ?? 0, p.zrotate ?? 0));
parts.unshift(turn.toCss());
}
if (p.matrixtransform instanceof Matrix4) parts.unshift(p.matrixtransform.toCss());
el.style.transform = parts.join(" "); el.style.transform = parts.join(" ");
el.style.mixBlendMode = blendMode(p);
if (p.alpha !== undefined) el.style.opacity = String(p.alpha); if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader) if (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader)
const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`; const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`;
@@ -1600,6 +1615,7 @@ export class ScreenManager {
} }
applyFilters(el, p); applyFilters(el, p);
}; };
if (specs.some((spec) => spec.name === "Flatten")) el.style.isolation = "isolate"; // `at Flatten`: the widget is one picture
for (const spec of specs) { for (const spec of specs) {
Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {}); Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
} }
@@ -2027,7 +2043,10 @@ export class ScreenManager {
out.Fixed = shape("composite"); out.Fixed = shape("composite");
out.Composite = shape("composite"); out.Composite = shape("composite");
out.LiveComposite = shape("composite"); out.LiveComposite = shape("composite");
out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn out.Flatten = shape("flatten");
out.RotateMatrix = (x: number, y: number, z: number) => Matrix4.rotate(x, y, z);
out.OffsetMatrix = (x: number, y: number, z: number) => Matrix4.offset(x, y, z);
out.ScaleMatrix = (x: number, y: number, z: number) => Matrix4.scale(x, y, z);
out.AlphaMask = shape("alphamask"); out.AlphaMask = shape("alphamask");
out.TrackCursor = shape("trackcursor"); out.TrackCursor = shape("trackcursor");
out.PreviewSlowText = shape("text"); out.PreviewSlowText = shape("text");
+40 -10
View File
@@ -1,6 +1,7 @@
// Ren'Py text tags → styled segments, plus [variable] interpolation. // Ren'Py text tags → styled segments, plus [variable] interpolation.
import { pyStr } from "./python"; import { pyStr } from "./python";
import * as fx from "./texteffects";
export interface Style { export interface Style {
b?: boolean; b?: boolean;
@@ -12,6 +13,8 @@ export interface Style {
/** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */ /** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */
fx?: string; fx?: string;
fxArg?: string; fxArg?: string;
/** One id per opening tag, so the letters of one tag are counted together. */
fxId?: number;
/** The tag that opened this style, so its closing tag finds it again. */ /** The tag that opened this style, so its closing tag finds it again. */
tag?: string; tag?: string;
} }
@@ -47,6 +50,8 @@ export function interpolate(text: string, lookup: (expr: string) => any,
}); });
} }
let fxCounter = 0;
export function parseText(text: string): ParsedText { export function parseText(text: string): ParsedText {
const segments: Segment[] = []; const segments: Segment[] = [];
const stack: Style[] = [{}]; const stack: Style[] = [{}];
@@ -100,7 +105,7 @@ export function parseText(text: string): ParsedText {
stack.push({ ...top, size: value, tag: name }); stack.push({ ...top, size: value, tag: name });
break; break;
case "bt": case "sc": case "fi": case "rotat": case "glitch": case "wave": // letters that move (text tags of the games) case "bt": case "sc": case "fi": case "rotat": case "glitch": case "wave": // letters that move (text tags of the games)
stack.push({ ...top, fx: name, fxArg: value, tag: name }); stack.push({ ...top, fx: name, fxArg: value, fxId: ++fxCounter, 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 });
@@ -152,6 +157,7 @@ function splitTag(tag: string): [string, string] {
/** Render the first `visible` characters of parsed text into `el`. */ /** Render the first `visible` characters of parsed text into `el`. */
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) { export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
el.textContent = ""; el.textContent = "";
const counters = new Map<number, number>();
let left = visible; let left = visible;
for (const seg of parsed.segments) { for (const seg of parsed.segments) {
if (seg.kind !== "text") continue; if (seg.kind !== "text") continue;
@@ -160,20 +166,44 @@ 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 if (st.fx) {
const amount = Number.parseFloat(st.fxArg ?? ""); fx.markStart(el);
[...shown].forEach((letter, index) => { const decorate = (piece: HTMLElement) => {
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.b) piece.style.fontWeight = "bold";
if (st.i) piece.style.fontStyle = "italic"; if (st.i) piece.style.fontStyle = "italic";
if (st.color) piece.style.color = st.color; if (st.color) piece.style.color = st.color;
if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize); if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize);
};
if (st.fx === "glitch") { // the effect acts on the whole run of text
const run = document.createElement("span");
run.className = "pg-fx";
run.textContent = shown;
decorate(run);
el.append(run);
fx.glitch(run, st.fxArg ?? "");
continue;
}
for (const letter of [...shown]) {
const piece = document.createElement("span");
piece.textContent = letter;
piece.className = "pg-fx";
decorate(piece);
el.append(piece); el.append(piece);
}); const id = st.fxId ?? 0;
const arg = st.fxArg ?? "";
if (st.fx === "bt") {
const n = counters.get(id) ?? 0;
counters.set(id, n + 1);
fx.bounce(piece, arg, n);
} else if (st.fx === "fi") {
if (letter === " ") continue; // blanks are not counted, nor faded
const first = fx.split(arg)[0];
const n = counters.get(id) ?? (first ? parseInt(first, 10) : 0);
counters.set(id, n + 1);
fx.fadeIn(piece, arg, n);
} else if (st.fx === "sc") fx.scare(piece, arg);
else if (st.fx === "rotat") fx.rotation(piece, arg);
}
continue; 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) {
+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";
} });
}