feat: Ren'Py's transform geometry ported exactly (crop, size/fit, zoom, rotate_pad, 3D, anchors, pan, tile, blend)
rendertransform.ts follows RenderTransform from accelerator.pyx: the same matrices in the same order, with transform_anchor measuring the anchor in the child. Scene images are laid out from it (left/top plus a matrix3d with origin 0 0), so rotation turns about the picture's centre and the box grows to its diagonal as in Ren'Py; xpan/ypan are a wrapped shift of a repeating background, xtile/ytile repeat it, blend and a fully additive picture become mix-blend-mode. Checked in a real browser page and by unit tests of the math. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91b1d808ce
commit
5ff43038ae
4 files changed
+374
-26
No files matched your search
@@ -29,6 +29,18 @@ export type AtlStep =
|
|||||||
| { op: "function"; f: any }
|
| { op: "function"; f: any }
|
||||||
| { op: "time"; t: AtlValue<number> };
|
| { op: "time"; t: AtlValue<number> };
|
||||||
|
|
||||||
|
/** Names of the properties a timeline sets or moves (descending into its sub-blocks). */
|
||||||
|
export function blockProperties(block: AtlBlock | null | undefined, into = new Set<string>(), depth = 0): Set<string> {
|
||||||
|
if (!block || depth > MAX_DEPTH) return into;
|
||||||
|
for (const step of block.steps) {
|
||||||
|
if (step.op === "set" || step.op === "move") for (const key of Object.keys(step.props)) into.add(key);
|
||||||
|
else if (step.op === "parallel") for (const b of step.blocks) blockProperties(b, into, depth + 1);
|
||||||
|
else if (step.op === "choice") for (const b of step.blocks) blockProperties(b.block, into, depth + 1);
|
||||||
|
else if (step.op === "block" || step.op === "on") blockProperties(step.block, into, depth + 1);
|
||||||
|
}
|
||||||
|
return into;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AtlHost {
|
export interface AtlHost {
|
||||||
/** Timeline of a named animated transform. */
|
/** Timeline of a named animated transform. */
|
||||||
block(name: string): AtlBlock | null;
|
block(name: string): AtlBlock | null;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
|
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
|
||||||
// The stage has the game's native resolution and is scaled with a CSS transform.
|
// The stage has the game's native resolution and is scaled with a CSS transform.
|
||||||
|
|
||||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
import { AtlRunner, bindArguments, blockProperties, type AtlBlock } from "./atl";
|
||||||
|
import { blendMode, layoutTransform } from "./rendertransform";
|
||||||
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
||||||
import { movieDisplayable } from "./movie";
|
import { movieDisplayable } from "./movie";
|
||||||
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
||||||
@@ -84,7 +85,9 @@ export class Renderer {
|
|||||||
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
|
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
|
||||||
const prev = this.shown.get(tag);
|
const prev = this.shown.get(tag);
|
||||||
const atSpecs = at.length ? at : prev?.at ?? [];
|
const atSpecs = at.length ? at : prev?.at ?? [];
|
||||||
|
this.panWanted = this.usesPan(img.def, atSpecs);
|
||||||
const el = this.build(img.def);
|
const el = this.build(img.def);
|
||||||
|
this.panWanted = false;
|
||||||
el.classList.add("pg-image");
|
el.classList.add("pg-image");
|
||||||
const base = this.place(el, img.def, atSpecs);
|
const base = this.place(el, img.def, atSpecs);
|
||||||
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el,
|
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el,
|
||||||
@@ -98,9 +101,45 @@ export class Renderer {
|
|||||||
this.master.insertBefore(el, behind ?? later ?? null);
|
this.master.insertBefore(el, behind ?? later ?? null);
|
||||||
}
|
}
|
||||||
this.shown.set(tag, entry);
|
this.shown.set(tag, entry);
|
||||||
|
this.style(el, { ...base }); // now that it is in the page it has a size
|
||||||
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
|
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private panWanted = false;
|
||||||
|
|
||||||
|
/** Whether any property of the image or its transforms is xpan, ypan, xtile or ytile (they need a tiling element). */
|
||||||
|
private usesPan(def: ImageDef, at: AtSpec[]): boolean {
|
||||||
|
const PAN = ["xpan", "ypan", "xtile", "ytile"];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const spec of [...(def.at ?? []), ...at]) {
|
||||||
|
for (const key of Object.keys({ ...(spec.props ?? {}), ...(spec.name ? this.host.transform(spec.name) ?? {} : {}) })) seen.add(key);
|
||||||
|
const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl;
|
||||||
|
blockProperties(block, seen);
|
||||||
|
}
|
||||||
|
blockProperties(def.timeline, seen);
|
||||||
|
for (const key of Object.keys(def.props ?? {})) seen.add(key);
|
||||||
|
return PAN.some((key) => seen.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A picture that tiles and pans: its own element, with the picture as a repeating background. */
|
||||||
|
private panElement(file: string): HTMLElement {
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.dataset.pan = "1";
|
||||||
|
const src = this.host.url(file);
|
||||||
|
if (!src) return box;
|
||||||
|
box.style.backgroundImage = `url("${src}")`;
|
||||||
|
box.style.backgroundRepeat = "repeat";
|
||||||
|
const probe = new Image();
|
||||||
|
probe.onload = () => {
|
||||||
|
const scale = this.host.assetScale?.(src) ?? 1;
|
||||||
|
box.dataset.nw = String(probe.naturalWidth / (scale > 0 ? scale : 1));
|
||||||
|
box.dataset.nh = String(probe.naturalHeight / (scale > 0 ? scale : 1));
|
||||||
|
if ((box as any).__style) this.style(box, (box as any).__style);
|
||||||
|
};
|
||||||
|
probe.src = src;
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
/** Start the ATL timelines of an image; each works on its own copy of the properties. */
|
/** Start the ATL timelines of an image; each works on its own copy of the properties. */
|
||||||
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
|
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
|
||||||
if (this.fast) return;
|
if (this.fast) return;
|
||||||
@@ -227,6 +266,7 @@ export class Renderer {
|
|||||||
if (def.timeline) return this.timelineBox(def, depth);
|
if (def.timeline) return this.timelineBox(def, depth);
|
||||||
if (def.frames?.length) return this.animated(def, depth);
|
if (def.frames?.length) return this.animated(def, depth);
|
||||||
if (def.layered) return this.layered(def, depth);
|
if (def.layered) return this.layered(def, depth);
|
||||||
|
if (def.file && this.panWanted && depth === 0) return this.panElement(def.file);
|
||||||
if (def.file) {
|
if (def.file) {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.draggable = false;
|
img.draggable = false;
|
||||||
@@ -404,39 +444,55 @@ export class Renderer {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The size of an element before any transform: what Ren'Py calls the child's render size. */
|
||||||
|
private childSize(el: HTMLElement): [number, number] | null {
|
||||||
|
const w = el.offsetWidth || (el as HTMLImageElement).naturalWidth || 0;
|
||||||
|
const h = el.offsetHeight || (el as HTMLImageElement).naturalHeight || 0;
|
||||||
|
if (w > 0 && h > 0) {
|
||||||
|
el.dataset.cw = String(w);
|
||||||
|
el.dataset.ch = String(h);
|
||||||
|
return [w, h];
|
||||||
|
}
|
||||||
|
return el.dataset.cw ? [Number(el.dataset.cw), Number(el.dataset.ch)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
private style(el: HTMLElement, p: Record<string, any>) {
|
private style(el: HTMLElement, p: Record<string, any>) {
|
||||||
normalize(p);
|
normalize(p);
|
||||||
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
|
(el as any).__style = p;
|
||||||
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
if (el.dataset.pan) { // tile, then pan (Ren'Py's tile_and_pan): copies of the picture shifted by a fraction of its width
|
||||||
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
const nw = Number(el.dataset.nw), nh = Number(el.dataset.nh);
|
||||||
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
if (!nw || !nh) return;
|
||||||
// `size`/`xsize`/`ysize` stretch the picture; a missing side keeps the picture's proportions
|
const xtile = Math.max(1, Math.round(p.xtile ?? 1)), ytile = Math.max(1, Math.round(p.ytile ?? 1));
|
||||||
const [sx, sy] = p.xysize ?? p.size ?? [p.xsize, p.ysize];
|
const wrap = (angle: number) => ((angle % 360) + 360) % 360 / 360;
|
||||||
if (typeof sx === "number") el.style.width = `${sx}px`;
|
const panX = p.xpan === undefined || p.xpan === null ? 0 : nw * wrap(p.xpan);
|
||||||
if (typeof sy === "number") el.style.height = `${sy}px`;
|
const panY = p.ypan === undefined || p.ypan === null ? 0 : nh * wrap(p.ypan);
|
||||||
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
el.style.width = `${p.xpan === undefined || p.xpan === null ? nw * xtile : nw}px`;
|
||||||
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
el.style.height = `${p.ypan === undefined || p.ypan === null ? nh * ytile : nh}px`;
|
||||||
const crop = Array.isArray(p.crop) && p.crop.length === 4 ? (p.crop as number[]).map(Number) : null;
|
el.style.backgroundSize = `${nw}px ${nh}px`;
|
||||||
let ax = anchor(p.xanchor);
|
el.style.backgroundPosition = `${-panX}px ${-panY}px`;
|
||||||
let ay = anchor(p.yanchor);
|
|
||||||
if (crop) {
|
|
||||||
// only the cropped rectangle is shown, and anchors/positions refer to it
|
|
||||||
const [cx, cy, cw, ch] = crop;
|
|
||||||
const frac = (v: number, size: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? v : v * size);
|
|
||||||
ax = `${cx + frac(p.xanchor, cw)}px`;
|
|
||||||
ay = `${cy + frac(p.yanchor, ch)}px`;
|
|
||||||
el.style.clipPath = `inset(${cy}px calc(100% - ${cx + cw}px) calc(100% - ${cy + ch}px) ${cx}px)`;
|
|
||||||
} else {
|
|
||||||
el.style.clipPath = "";
|
|
||||||
}
|
}
|
||||||
el.style.transformOrigin = `${ax} ${ay}`;
|
const size = this.childSize(el);
|
||||||
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
if (!size) { // not laid out yet (a picture still loading): place it once it has a size
|
||||||
|
el.style.visibility = "hidden";
|
||||||
|
const again = () => { el.style.visibility = ""; if ((el as any).__style) this.style(el, (el as any).__style); };
|
||||||
|
if (el instanceof HTMLImageElement && !el.complete) el.addEventListener("load", again, { once: true });
|
||||||
|
else requestAnimationFrame(() => { if (this.childSize(el)) again(); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.style.visibility = "";
|
||||||
|
const out = layoutTransform(p, size[0], size[1], this.width, this.height);
|
||||||
|
el.style.left = `${out.left}px`;
|
||||||
|
el.style.top = `${out.top}px`;
|
||||||
|
el.style.transformOrigin = "0 0";
|
||||||
|
el.style.transform = out.transform;
|
||||||
|
el.style.clipPath = out.clip;
|
||||||
|
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);
|
||||||
const filters: string[] = [];
|
const filters: string[] = [];
|
||||||
if (p.filter) filters.push(`${p.filter}(1)`);
|
if (p.filter) filters.push(`${p.filter}(1)`);
|
||||||
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`);
|
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`);
|
||||||
if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
|
if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
|
||||||
if (filters.length) el.style.filter = filters.join(" ");
|
el.style.filter = filters.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- transitions ---------------------------------------------------------------
|
// --- transitions ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Ren'Py's Transform geometry (renpy/display/accelerator.pyx, RenderTransform), as a function from a transform's
|
||||||
|
// properties and its child's size to a CSS matrix. The same steps in the same order: crop, size/fit, zoom,
|
||||||
|
// rotate (with rotate_pad), the 3D rotations about matrixanchor, matrixtransform, zzoom, and the placement of the
|
||||||
|
// result by position and anchor (with transform_anchor choosing where the anchor is measured).
|
||||||
|
//
|
||||||
|
// Ren'Py's z axis points away from the viewer; CSS's points at the viewer. The matrices below are Ren'Py's, and
|
||||||
|
// `toCss` mirrors z when it hands one over.
|
||||||
|
|
||||||
|
/** A 4x4 matrix in Ren'Py's convention: column vectors, `a.mul(b)` applies b first. Row-major, [row][column]. */
|
||||||
|
export class Matrix4 {
|
||||||
|
constructor(public m: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) {}
|
||||||
|
|
||||||
|
static identity() { return new Matrix4(); }
|
||||||
|
|
||||||
|
static offset(x: number, y: number, z = 0) {
|
||||||
|
return new Matrix4([1, 0, 0, x, 0, 1, 0, y, 0, 0, 1, z, 0, 0, 0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static scale(x: number, y: number, z = 1) {
|
||||||
|
return new Matrix4([x, 0, 0, 0, 0, y, 0, 0, 0, 0, z, 0, 0, 0, 0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Matrix.rotate(x, y, z)`: degrees about each axis, applied as Ren'Py's rotate_matrix does. */
|
||||||
|
static rotate(x: number, y: number, z: number) {
|
||||||
|
const d = Math.PI / 180;
|
||||||
|
const sx = Math.sin(x * d), cx = Math.cos(x * d), sy = Math.sin(y * d), cy = Math.cos(y * d);
|
||||||
|
const sz = Math.sin(z * d), cz = Math.cos(z * d);
|
||||||
|
return new Matrix4([
|
||||||
|
cy * cz, -cx * sz + cz * sx * sy, cx * cz * sy + sx * sz, 0,
|
||||||
|
cy * sz, cx * cz + sx * sy * sz, cx * sy * sz - cz * sx, 0,
|
||||||
|
-sy, cy * sx, cx * cy, 0,
|
||||||
|
0, 0, 0, 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `Matrix.perspective(w, h, n, p, f)` */
|
||||||
|
static perspective(w: number, h: number, n: number, p: number, f: number) {
|
||||||
|
return new Matrix4([
|
||||||
|
p, 0, -w / 2, 0,
|
||||||
|
0, p, -h / 2, 0,
|
||||||
|
0, 0, -(f + n) / (f - n), (-2 * f * n + p * (f + n)) / (f - n),
|
||||||
|
0, 0, -1, p,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
mul(other: Matrix4): Matrix4 {
|
||||||
|
const a = this.m, b = other.m, out = new Array<number>(16).fill(0);
|
||||||
|
for (let r = 0; r < 4; r++)
|
||||||
|
for (let c = 0; c < 4; c++)
|
||||||
|
for (let k = 0; k < 4; k++) out[r * 4 + c] += a[r * 4 + k] * b[k * 4 + c];
|
||||||
|
return new Matrix4(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `a * b` in Python code (matrixtransform): another matrix, or a number. */
|
||||||
|
__binop__(op: string, other: any) {
|
||||||
|
if (op === "*" && other instanceof Matrix4) return this.mul(other);
|
||||||
|
if (op === "*" && typeof other === "number") return new Matrix4(this.m.map((v) => v * other));
|
||||||
|
throw new TypeError(`Matrix ${op}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
transform(x: number, y: number, z = 0, w = 1): [number, number, number, number] {
|
||||||
|
const m = this.m;
|
||||||
|
return [m[0] * x + m[1] * y + m[2] * z + m[3] * w, m[4] * x + m[5] * y + m[6] * z + m[7] * w,
|
||||||
|
m[8] * x + m[9] * y + m[10] * z + m[11] * w, m[12] * x + m[13] * y + m[14] * z + m[15] * w];
|
||||||
|
}
|
||||||
|
|
||||||
|
isIdentity() { return this.m.every((v, i) => v === (i % 5 === 0 ? 1 : 0)); }
|
||||||
|
|
||||||
|
/** `matrix3d(...)` for CSS: Ren'Py's z is flipped (S * M * S with S = diag(1, 1, -1, 1)), and CSS wants columns. */
|
||||||
|
toCss(): string {
|
||||||
|
const m = this.m;
|
||||||
|
const at = (r: number, c: number) => m[r * 4 + c] * ((r === 2) !== (c === 2) ? -1 : 1);
|
||||||
|
const cols: number[] = [];
|
||||||
|
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) cols.push(+at(r, c).toFixed(6));
|
||||||
|
return `matrix3d(${cols.join(",")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformProps {
|
||||||
|
xpos?: number; ypos?: number; xanchor?: number; yanchor?: number; xoffset?: number; yoffset?: number;
|
||||||
|
zoom?: number; xzoom?: number; yzoom?: number;
|
||||||
|
rotate?: number | null; rotate_pad?: boolean;
|
||||||
|
xsize?: number; ysize?: number; size?: [number, number]; xysize?: [number, number];
|
||||||
|
fit?: string; maxsize?: [number, number];
|
||||||
|
crop?: number[] | null; crop_relative?: boolean;
|
||||||
|
xrotate?: number; yrotate?: number; zrotate?: number; zpos?: number; zzoom?: boolean;
|
||||||
|
matrixanchor?: [number, number] | null; matrixtransform?: Matrix4 | null;
|
||||||
|
transform_anchor?: boolean; subpixel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Placement {
|
||||||
|
/** Where the element's top-left corner goes (its layout box is the child's own size). */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
/** `matrix3d(...)` or "" when the element is only translated. Always applied with transform-origin 0 0. */
|
||||||
|
transform: string;
|
||||||
|
/** The visible part of the child before the transform (crop), as a CSS inset() or "". */
|
||||||
|
clip: string;
|
||||||
|
/** Size of the transformed box (what a parent measures). */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ren'Py's `blend` names (config.gl_blend_func) and fully additive pictures, as a CSS mix-blend-mode. */
|
||||||
|
export function blendMode(p: { blend?: string | null; additive?: number }): string {
|
||||||
|
if ((p.additive ?? 0) >= 1) return "plus-lighter";
|
||||||
|
switch (p.blend) {
|
||||||
|
case "add": return "plus-lighter";
|
||||||
|
case "multiply": return "multiply";
|
||||||
|
case "min": return "darken";
|
||||||
|
case "max": return "lighten";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ren'Py's rule: 1.0 and below is a fraction, a whole number is pixels; here floats are fractions and ints pixels. */
|
||||||
|
const compute = (value: number, whole: number) => (Number.isInteger(value) && Math.abs(value) > 1 ? value : value * whole);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay the child out. `cw`, `ch` are its size; `areaW`, `areaH` the room the parent gives (the screen, for a scene
|
||||||
|
* image); `z11` the distance of the 1:1 plane when the transform lives in a perspective (0 when it does not).
|
||||||
|
*/
|
||||||
|
export function layoutTransform(p: TransformProps, cw: number, ch: number, areaW: number, areaH: number, z11 = 0): Placement {
|
||||||
|
let width = cw, height = ch;
|
||||||
|
|
||||||
|
// cropping: the visible rectangle of the child
|
||||||
|
let vx = 0, vy = 0, vw = cw, vh = ch;
|
||||||
|
let clip = "";
|
||||||
|
if (p.crop && p.crop.length === 4) {
|
||||||
|
const rel = p.crop_relative === true;
|
||||||
|
const [x, y, w, h] = p.crop;
|
||||||
|
const part = (v: number, whole: number) => (rel || !Number.isInteger(v) ? v * whole : v);
|
||||||
|
vx = part(x, cw);
|
||||||
|
vy = part(y, ch);
|
||||||
|
vw = part(w, rel ? cw : cw - vx);
|
||||||
|
vh = part(h, rel ? ch : ch - vy);
|
||||||
|
clip = `inset(${vy}px ${cw - vx - vw}px ${ch - vy - vh}px ${vx}px)`;
|
||||||
|
width = vw;
|
||||||
|
height = vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// size and fit: one factor per axis, applied before zoom
|
||||||
|
let sx = 1, sy = 1;
|
||||||
|
const [xs, ys] = p.xysize ?? p.size ?? [p.xsize, p.ysize];
|
||||||
|
const wanted: number[] = [];
|
||||||
|
if (typeof xs === "number") wanted.push(xs / width);
|
||||||
|
if (typeof ys === "number") wanted.push(ys / height);
|
||||||
|
let mul: number | null = null;
|
||||||
|
if (p.maxsize) mul = Math.min(p.maxsize[0] / width, p.maxsize[1] / height);
|
||||||
|
let fit = p.fit ?? null;
|
||||||
|
if (fit && !wanted.length) wanted.push(areaW / width, areaH / height);
|
||||||
|
if (!fit) fit = "fill";
|
||||||
|
if (wanted.length && width && height) {
|
||||||
|
if (fit === "scale-up") mul = Math.max(1, ...wanted);
|
||||||
|
else if (fit === "scale-down") mul = Math.min(1, ...wanted);
|
||||||
|
else if (fit === "contain") mul = Math.min(...wanted);
|
||||||
|
else if (fit === "cover") mul = Math.max(...wanted);
|
||||||
|
else {
|
||||||
|
sx = typeof xs === "number" ? xs / width : 1;
|
||||||
|
sy = typeof ys === "number" ? ys / height : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mul !== null) sx = sy = mul;
|
||||||
|
|
||||||
|
// zoom
|
||||||
|
const zoom = p.zoom ?? 1;
|
||||||
|
const zx = sx * zoom * (p.xzoom ?? 1);
|
||||||
|
const zy = sy * zoom * (p.yzoom ?? 1);
|
||||||
|
let boxW = Math.abs(width * zx), boxH = Math.abs(height * zy);
|
||||||
|
|
||||||
|
// rotate about the child's centre; the box grows to hold it (or to the diagonal when rotate_pad)
|
||||||
|
const rotate = p.rotate;
|
||||||
|
let angle = 0;
|
||||||
|
if (rotate !== undefined && rotate !== null && !z11) {
|
||||||
|
angle = rotate;
|
||||||
|
const a = angle * Math.PI / 180, cos = Math.cos(a), sin = Math.sin(a);
|
||||||
|
if (p.rotate_pad !== false) {
|
||||||
|
boxW = boxH = Math.hypot(boxW, boxH);
|
||||||
|
} else {
|
||||||
|
const w = boxW, h = boxH;
|
||||||
|
boxW = Math.abs(w * cos) + Math.abs(h * sin);
|
||||||
|
boxH = Math.abs(w * sin) + Math.abs(h * cos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// child -> box, as Ren'Py's reverse matrix does: scale (flips included), rotate about the centre, then the
|
||||||
|
// visible rectangle's centre goes to the box centre
|
||||||
|
let m = Matrix4.offset(boxW / 2, boxH / 2)
|
||||||
|
.mul(Matrix4.rotate(0, 0, angle))
|
||||||
|
.mul(Matrix4.scale(zx, zy, 1))
|
||||||
|
.mul(Matrix4.offset(-(vx + vw / 2), -(vy + vh / 2)));
|
||||||
|
|
||||||
|
// 3D rotations and matrixtransform, about matrixanchor (the box centre by default)
|
||||||
|
const [mx, my] = p.matrixanchor ? [compute(p.matrixanchor[0], boxW), compute(p.matrixanchor[1], boxH)] : [boxW / 2, boxH / 2];
|
||||||
|
const about = (n: Matrix4) => Matrix4.offset(mx, my).mul(n).mul(Matrix4.offset(-mx, -my));
|
||||||
|
if (p.xrotate || p.yrotate || p.zrotate) m = about(Matrix4.rotate(p.xrotate ?? 0, p.yrotate ?? 0, p.zrotate ?? 0)).mul(m);
|
||||||
|
if (p.zpos) m = Matrix4.offset(0, 0, p.zpos).mul(m);
|
||||||
|
if (p.matrixtransform) m = about(p.matrixtransform).mul(m);
|
||||||
|
if (p.zzoom && z11) {
|
||||||
|
const k = (z11 - (p.zpos ?? 0)) / z11;
|
||||||
|
m = Matrix4.offset(boxW / 2, boxH / 2).mul(Matrix4.scale(k, k, 1)).mul(Matrix4.offset(-boxW / 2, -boxH / 2)).mul(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// placement: the anchor is a fraction/number of the box, or (transform_anchor) a point of the child that follows it
|
||||||
|
let ax: number, ay: number;
|
||||||
|
if (p.transform_anchor) {
|
||||||
|
const [px, py] = [compute(p.xanchor ?? 0, cw), compute(p.yanchor ?? 0, ch)];
|
||||||
|
const [tx, ty] = m.transform(px, py);
|
||||||
|
ax = tx;
|
||||||
|
ay = ty;
|
||||||
|
} else {
|
||||||
|
ax = compute(p.xanchor ?? 0, boxW);
|
||||||
|
ay = compute(p.yanchor ?? 0, boxH);
|
||||||
|
}
|
||||||
|
const x = compute(p.xpos ?? 0, areaW) + (p.xoffset ?? 0) - ax;
|
||||||
|
const y = compute(p.ypos ?? 0, areaH) + (p.yoffset ?? 0) - ay;
|
||||||
|
|
||||||
|
const plain = m.isIdentity();
|
||||||
|
return { left: x, top: y, transform: plain ? "" : m.toCss(), clip, width: boxW, height: boxH };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/rendertransform.ts", import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||||
|
const { Matrix4, layoutTransform } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||||
|
const close = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) < eps, `${a} != ${b}`);
|
||||||
|
|
||||||
|
test("zoom grows the box and scales the child about its centre; the anchor is a fraction of the grown box", () => {
|
||||||
|
const out = layoutTransform({ zoom: 2, xpos: 0.5, ypos: 1, xanchor: 0.5, yanchor: 1 }, 100, 50, 1920, 1080);
|
||||||
|
close(out.width, 200); close(out.height, 100);
|
||||||
|
close(out.left, 960 - 100); close(out.top, 1080 - 100);
|
||||||
|
assert.equal(out.transform, "matrix3d(2,0,0,0,0,2,0,0,0,0,1,0,0,0,0,1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rotate pads the box to its diagonal (rotate_pad) and rotates about the centre", () => {
|
||||||
|
const out = layoutTransform({ rotate: 90, xpos: 0, ypos: 0 }, 60, 80, 1000, 1000);
|
||||||
|
close(out.width, 100); close(out.height, 100);
|
||||||
|
// the child's centre (30, 40) must land on the box centre (50, 50); rotating 90 degrees clockwise
|
||||||
|
const css = out.transform.match(/matrix3d\((.*)\)/)[1].split(",").map(Number);
|
||||||
|
const x = css[0] * 30 + css[4] * 40 + css[12], y = css[1] * 30 + css[5] * 40 + css[13];
|
||||||
|
close(x, 50, 1e-4); close(y, 50, 1e-4);
|
||||||
|
// the child's top-left corner (0,0) goes to (50+40, 50-30)
|
||||||
|
close(css[12], 90, 1e-4); close(css[13], 20, 1e-4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("without rotate_pad the box is the bounding box of the rotated child", () => {
|
||||||
|
const out = layoutTransform({ rotate: 90, rotate_pad: false }, 60, 80, 1000, 1000);
|
||||||
|
close(out.width, 80); close(out.height, 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("transform_anchor measures the anchor in the child and carries it through the transform", () => {
|
||||||
|
const out = layoutTransform({ zoom: 0.5, transform_anchor: true, xanchor: 985, yanchor: 1535, xpos: 0, ypos: 0 }, 2000, 3000, 1920, 1080);
|
||||||
|
close(out.left, -985 * 0.5); close(out.top, -1535 * 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a negative xzoom flips the child inside the same box", () => {
|
||||||
|
const out = layoutTransform({ xzoom: -1 }, 100, 50, 1000, 1000);
|
||||||
|
close(out.width, 100);
|
||||||
|
assert.equal(out.transform, "matrix3d(-1,0,0,0,0,1,0,0,0,0,1,0,100,0,0,1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("crop shows the rectangle and the box takes its size; size and fit scale first", () => {
|
||||||
|
const out = layoutTransform({ crop: [10, 20, 30, 40] }, 100, 100, 1000, 1000);
|
||||||
|
close(out.width, 30); close(out.height, 40);
|
||||||
|
assert.equal(out.clip, "inset(20px 60px 40px 10px)");
|
||||||
|
const fit = layoutTransform({ xysize: [200, 200], fit: "contain" }, 100, 50, 1000, 1000);
|
||||||
|
close(fit.width, 200); close(fit.height, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matrix products follow Ren'Py's order: a.mul(b) applies b first, and 3D rotation about the centre", () => {
|
||||||
|
const m = Matrix4.offset(10, 0).mul(Matrix4.scale(2, 2, 1));
|
||||||
|
assert.deepEqual(m.transform(1, 1).slice(0, 2), [12, 2]);
|
||||||
|
const out = layoutTransform({ yrotate: 180 }, 100, 50, 1000, 1000);
|
||||||
|
const css = out.transform.match(/matrix3d\((.*)\)/)[1].split(",").map(Number);
|
||||||
|
close(css[0], -1, 1e-5); // the picture is seen from behind: mirrored
|
||||||
|
});
|
||||||
Reference in new issue
Block a user