diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 3786923..8f08d80 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -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); diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 2a849e3..bb725d4 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -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); } } diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 0fd7d52..633eef2 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -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"); diff --git a/frontend/src/player/text.ts b/frontend/src/player/text.ts index 10b684d..872e6d0 100644 --- a/frontend/src/player/text.ts +++ b/frontend/src/player/text.ts @@ -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(); 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) { diff --git a/frontend/src/player/texteffects.ts b/frontend/src/player/texteffects.ts new file mode 100644 index 0000000..b2822de --- /dev/null +++ b/frontend/src/player/texteffects.ts @@ -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(); +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("[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"; + } }); +}