feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py

ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
  finite repeat, time, function f(trans, st, at), calls of other transforms with
  arguments, on hover/idle events for buttons. Animations stop when their element
  leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped

Colour
- matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG
  filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values

Python
- match statement, re/copy/time/datetime/defaultdict/functools.partial, more
  str/dict methods, Character(...) created by code
- narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in
  Python blocks become script nodes
- Audit follows reachability: functions and classes nothing refers to (custom
  statement lexers, unused tools) no longer block an import

Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and
QSP planned). Tests for the new runtime pieces.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:05:14 +03:00
1 parent 62ece25902
commit ea03cebf6a
21 files changed
+1264 -93

No files matched your search

+82 -11
View File
@@ -2,6 +2,7 @@
// The stage has the game's native resolution and is scaled with a CSS transform.
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import { ColorMatrix, matrixFilter } from "./colormatrix";
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
@@ -93,25 +94,18 @@ export class Renderer {
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs]);
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
}
/** 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[]) {
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
if (this.fast) return;
const live = { ...base };
for (const spec of specs) {
const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl;
if (!block) continue;
const runner = new AtlRunner({
block: (name) => this.host.atl(name),
props: (name) => this.host.transform(name),
apply: (p) => this.style(entry.el, { ...p }),
eval: (expr, frame) => this.host.evalExpr(expr, frame),
}, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)));
entry.runners.push({ runner, block });
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
if (block) this.startTimeline(entry.el, block, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)), entry.runners);
}
if (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners);
}
hide(tag: string) {
@@ -149,6 +143,16 @@ export class Renderer {
const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
}
if (def.expr) {
// an image defined by a Python expression: its value names a picture, evaluated when shown
const value = this.host.evalExpr(def.expr);
if (typeof value === "string") {
const ref = this.host.resolveRef(value);
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
return this.build(ref ?? color ?? { file: value }, depth + 1);
}
return document.createElement("div");
}
if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1);
@@ -192,6 +196,7 @@ export class Renderer {
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
return d;
}
if (def.timeline) return this.timelineBox(def, depth);
if (def.frames?.length) return this.animated(def, depth);
if (def.layered) return this.layered(def, depth);
if (def.file) {
@@ -210,6 +215,61 @@ export class Renderer {
return document.createElement("div");
}
/** Container of an ATL timeline that switches pictures; the first one is shown at once. */
private timelineBox(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div");
box.style.display = "grid";
box.dataset.timeline = "1";
const first = firstPicture(def.timeline!);
if (first) {
const child = this.build(first, depth + 1);
child.style.gridArea = "1 / 1";
box.append(child);
}
return box;
}
/** Replace the picture shown in an animated element, optionally with a cross-fade. */
private swap(box: HTMLElement, def: ImageDef, transition: Transition | null | undefined) {
const child = this.build(def);
child.style.gridArea = "1 / 1";
const old = [...box.children];
if (!transition || this.fast) {
box.replaceChildren(child);
return;
}
child.style.opacity = "0";
box.append(child);
child.animate([{ opacity: 0 }, { opacity: 1 }], { duration: (transition.d ?? 0.5) * 1000, fill: "forwards" })
.onfinish = () => {
child.style.opacity = "";
old.forEach((e) => e.remove());
};
}
/** Run a timeline on `el`; its properties are applied with the same styling as any shown image. */
private startTimeline(el: HTMLElement, block: AtlBlock, base: Record<string, any>,
vars: Map<string, any>, runners: Entry["runners"]) {
const live = base;
const runner = new AtlRunner({
block: (name) => this.host.atl(name),
props: (name) => this.host.transform(name),
apply: (p) => this.style(el, { ...p }),
eval: (expr, frame) => this.host.evalExpr(expr, frame),
image: (img, transition) => this.swap(el, img, transition),
contains: (img) => {
const child = this.build(img);
child.style.position = "absolute";
child.style.left = child.style.top = "0";
el.append(child);
if (img.timeline) this.startTimeline(child, img.timeline, {}, new Map(), runners);
},
}, live, vars);
runner.watch = el;
runners.push({ runner, block });
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
}
/** An element for an image definition, for UI built outside the scene (screens). */
displayable(def: ImageDef): HTMLElement {
return this.build(def);
@@ -322,6 +382,7 @@ export class Renderer {
const filters: string[] = [];
if (p.filter) filters.push(`${p.filter}(1)`);
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 (filters.length) el.style.filter = filters.join(" ");
}
@@ -384,6 +445,16 @@ export class Renderer {
}
}
/** The first picture an ATL timeline shows (what is on screen before it starts). */
function firstPicture(block: AtlBlock): ImageDef | null {
for (const step of block.steps) {
if (step.op === "image") return step.img;
if (step.op === "parallel") for (const b of step.blocks) { const f = firstPicture(b); if (f) return f; }
if (step.op === "block") { const f = firstPicture(step.block); if (f) return f; }
}
return null;
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;