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

+107 -2
View File
@@ -2,6 +2,7 @@
// whenever game state may have changed. Expressions run in the same Python sandbox as the script.
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
import { PyDict, pyStr, truthy, type Python } from "./python";
import type { AtSpec, Expr } from "./types";
@@ -35,6 +36,8 @@ export interface ScreenHost {
mainMenu(): void;
openMenu(): void;
openUrl(url: string): void;
quickSave(): void;
quickLoad(): void;
setLanguage(code: string | null): void;
transform(name: string): Record<string, any> | null;
atl(name: string): AtlBlock | null;
@@ -660,6 +663,68 @@ export class ScreenManager {
const result = typeof a === "function" ? a(0, 0) : null;
return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1);
}
case "text": {
const box = document.createElement("div");
const size = num(value.kw.size) ?? this.host.baseSize;
this.host.text(box, pyStr(a), size);
if (typeof value.kw.color === "string") box.style.color = value.kw.color;
return box;
}
case "image": return this.displayable(a, depth + 1);
case "matrixcolor": {
const child = this.displayable(a, depth + 1);
if (child && b instanceof ColorMatrix) child.style.filter = matrixFilter(b);
return child;
}
case "scale": {
const child = this.displayable(a, depth + 1);
if (child && num(value.args[1]) !== null) {
child.style.width = `${value.args[1]}px`;
child.style.height = `${value.args[2]}px`;
}
return child;
}
case "factorscale": {
const child = this.displayable(a, depth + 1);
if (child && num(b) !== null) {
child.style.transformOrigin = "0 0";
child.style.transform = `scale(${b}, ${value.args[2] ?? b})`;
}
return child;
}
case "flip": {
const child = this.displayable(a, depth + 1);
if (child) child.style.transform = `scale(${truthy(value.kw.horizontal) ? -1 : 1}, ${truthy(value.kw.vertical) ? -1 : 1})`;
return child;
}
case "filter": {
const child = this.displayable(a, depth + 1);
if (child) {
const how = String(value.kw.how);
if (how === "alpha") child.style.opacity = String(b ?? 1);
else child.style.filter = how === "blur" ? `blur(${b ?? 0}px)` : `${how}(1)`;
}
return child;
}
case "composite": {
const box = document.createElement("div");
const size = a;
box.style.position = "relative";
if (Array.isArray(size)) {
box.style.width = `${size[0]}px`;
box.style.height = `${size[1]}px`;
}
for (let i = 1; i + 1 < value.args.length; i += 2) {
const child = this.displayable(value.args[i + 1], depth + 1);
const at = value.args[i];
if (!child || !Array.isArray(at)) continue;
child.style.position = "absolute";
child.style.left = `${at[0]}px`;
child.style.top = `${at[1]}px`;
box.append(child);
}
return box;
}
case "transform": {
const child = this.displayable(a, depth + 1);
if (!child) return null;
@@ -763,6 +828,10 @@ export class ScreenManager {
const paint = () => {
const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive")
: selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle";
if (el.dataset.state && el.dataset.state !== state) {
// `on hover:` / `on idle:` handlers of the button's transforms
for (const entry of (el as any).__atl ?? []) entry.runner.event(entry.block, state);
}
el.dataset.state = state;
if (images) {
const file = images[state];
@@ -867,6 +936,7 @@ export class ScreenManager {
const style = el.style;
const alpha = num(g("alpha"));
if (alpha !== null) style.opacity = String(alpha);
applyFilters(el, { blur: g("blur"), matrixcolor: g("matrixcolor") });
// size
let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")];
const both = g("xysize");
@@ -979,7 +1049,7 @@ export class ScreenManager {
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
el.style.transform = parts.join(" ");
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (typeof p.blur === "number") el.style.filter = p.blur > 0 ? `blur(${p.blur}px)` : "";
applyFilters(el, p);
};
for (const spec of specs) {
Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
@@ -1000,6 +1070,8 @@ export class ScreenManager {
apply: (p) => apply(p),
eval: (expr, frame) => this.evalWith(expr, frame),
}, live, bindArguments(block, spec, (e) => this.ev(e)));
runner.watch = el;
((el as any).__atl ??= []).push({ runner, block });
ctx.inst.runners.push(runner);
const inst = ctx.inst;
void runner.start(block).then((completed) => {
@@ -1095,7 +1167,10 @@ export class ScreenManager {
this.assign(a, truthy(now) && now !== off ? off : on, owner);
return;
}
case "SetLocalVariable":
case "SetScreenVariable": owner?.vars.set(a, b); return;
case "QuickSave": this.host.quickSave(); return;
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
case "ToggleScreenVariable": {
const now = owner?.vars.get(a);
const on = action.args.length > 1 ? b : true;
@@ -1148,7 +1223,7 @@ export class ScreenManager {
for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
"OpenURL"]) out[kind] = make(kind);
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) out[kind] = make(kind);
const shape = (kind: string) => (...args: any[]) => {
const last = args[args.length - 1];
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
@@ -1162,6 +1237,27 @@ export class ScreenManager {
out.Transform = shape("transform");
out.DynamicDisplayable = shape("dynamic");
out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value));
// Character(...) built by game code: kept as a record the say statement can read
out.Character = (...args: any[]) => {
const last = args[args.length - 1];
const kw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw") ? last.__kw : {};
return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw };
};
out.Text = shape("text");
out.Image = shape("image");
out.Fixed = shape("composite");
out.AlphaMask = (base: any) => base;
out.At = (child: any) => child;
Object.assign(out, colorMatrices);
out.im = {
MatrixColor: shape("matrixcolor"), Scale: shape("scale"), FactorScale: shape("factorscale"), Flip: shape("flip"),
Crop: shape("crop"), Composite: shape("composite"),
Grayscale: (child: any) => new Displayable("filter", [child], { how: "grayscale" }),
Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }),
Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }),
Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }),
matrix: legacyMatrices,
};
out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
out.GetTooltip = () => this.tooltip;
out.StaticValue = (value: number, range = 1) => new BarValue(() => value, () => range);
@@ -1191,6 +1287,15 @@ export class ScreenManager {
}
}
/** blur and matrixcolor of a widget or transform, combined into one CSS filter. */
function applyFilters(el: HTMLElement, p: Props) {
const filters: string[] = [];
if (num(p.blur) !== null && 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(" ");
else if (p.blur !== undefined || p.matrixcolor !== undefined) el.style.filter = "";
}
function lengthOf(v: any): string {
return typeof v === "number" ? PX_UNIT(v) : "0px";
}