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 { naniText } from "./naninovel";
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
import { textSettings } from "./texteffects";
import { containerClass, displayableClass, makeRender, pygameNamespace } from "./render";
import { musicRoomClass } from "./musicroom";
import { pythonModules } from "./pymodules";
@@ -1664,6 +1665,7 @@ export class Engine {
const waits = waitPoints(parsed).filter((w) => w.at > from);
let shown = from;
const cps = this.settings.cps;
textSettings.cps = cps; // {fi} letters fade in at the text speed
if (instant || cps <= 0 || this.skip) {
renderText(target, parsed, total, this.baseSize);
target.append(this.ctc);
-12
View File
@@ -372,19 +372,7 @@
/* 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; }
.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 */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.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-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 { movieDisplayable } from "./movie";
import { RenderHost, isDisplayableInstance } from "./render";
import { Matrix4, blendMode } from "./rendertransform";
import { strftime } from "./pymodules";
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, truthy, type Python } from "./python";
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))));
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
const box = document.createElement("div");
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 (xz !== 1 || yz !== 1) parts.push(`scale(${xz}, ${yz})`);
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.mixBlendMode = blendMode(p);
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)
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);
};
if (specs.some((spec) => spec.name === "Flatten")) el.style.isolation = "isolate"; // `at Flatten`: the widget is one picture
for (const spec of specs) {
Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
}
@@ -2027,7 +2043,10 @@ export class ScreenManager {
out.Fixed = shape("composite");
out.Composite = 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.TrackCursor = shape("trackcursor");
out.PreviewSlowText = shape("text");
+40 -10
View File
@@ -1,6 +1,7 @@
// Ren'Py text tags → styled segments, plus [variable] interpolation.
import { pyStr } from "./python";
import * as fx from "./texteffects";
export interface Style {
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). */
fx?: 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. */
tag?: string;
}
@@ -47,6 +50,8 @@ export function interpolate(text: string, lookup: (expr: string) => any,
});
}
let fxCounter = 0;
export function parseText(text: string): ParsedText {
const segments: Segment[] = [];
const stack: Style[] = [{}];
@@ -100,7 +105,7 @@ export function parseText(text: string): ParsedText {
stack.push({ ...top, size: value, tag: name });
break;
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;
case "w":
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`. */
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
el.textContent = "";
const counters = new Map<number, number>();
let left = visible;
for (const seg of parsed.segments) {
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("");
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.fx) {
fx.markStart(el);
const decorate = (piece: HTMLElement) => {
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);
};
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);
});
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;
}
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";
} });
}