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

+182
View File
@@ -0,0 +1,182 @@
// The small parts of the Python standard library games reach for: re, copy, time, datetime,
// collections.defaultdict and functools.partial. Everything is pure computation.
import { PyDict, PyInstance, pyStr } from "./python";
/** dict that creates missing entries from a factory (collections.defaultdict). */
export class PyDefaultDict extends PyDict {
constructor(public factory: (() => any) | null, entries?: Iterable<[any, any]>) {
super(entries);
}
missing(k: any) {
const value = this.factory ? this.factory() : null;
this.set(k, value);
return value;
}
}
class ReMatch {
constructor(private m: RegExpExecArray) {}
group(...which: (number | string)[]) {
const pick = (w: number | string) => {
const v = typeof w === "string" ? this.m.groups?.[w] : this.m[w];
return v ?? null;
};
if (!which.length) return this.m[0];
return which.length === 1 ? pick(which[0]) : which.map(pick);
}
groups() {
return this.m.slice(1).map((v) => v ?? null);
}
groupdict() {
return new PyDict(Object.entries(this.m.groups ?? {}));
}
start() {
return this.m.index;
}
end() {
return this.m.index + this.m[0].length;
}
}
const FLAGS: Record<number, string> = { 2: "i", 8: "m", 16: "s" };
function compile(pattern: string, flags = 0, global = false): RegExp {
const js = pattern.replace(/\(\?P<(\w+)>/g, "(?<$1>").replace(/\(\?P=(\w+)\)/g, "\\k<$1>").replace(/\\Z/g, "$");
const letters = Object.entries(FLAGS).filter(([bit]) => flags & Number(bit)).map(([, f]) => f).join("");
return new RegExp(js, letters + (global ? "g" : "") + "u");
}
function expand(replacement: string): string {
// \1 and \g<name> are the group references of Python templates
return replacement.replace(/\\(\d+)/g, "$$$1").replace(/\\g<(\w+)>/g, "$$<$1>");
}
function deepcopy(v: any, seen = new Map<any, any>()): any {
if (v === null || typeof v !== "object") return v;
if (seen.has(v)) return seen.get(v);
if (Array.isArray(v)) {
const out: any[] = [];
seen.set(v, out);
v.forEach((x) => out.push(deepcopy(x, seen)));
return out;
}
if (v instanceof PyDefaultDict) {
const out = new PyDefaultDict(v.factory);
seen.set(v, out);
v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen)));
return out;
}
if (v instanceof PyDict) {
const out = new PyDict();
seen.set(v, out);
v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen)));
return out;
}
if (v instanceof PyInstance) {
const attrs: Record<string, any> = Object.create(null);
const out = new PyInstance(v.cls, attrs);
seen.set(v, out);
for (const [k, x] of Object.entries(v.attrs)) attrs[k] = deepcopy(x, seen);
return out;
}
return v;
}
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
class DateTime {
constructor(public d: Date) {}
get year() { return this.d.getFullYear(); }
get month() { return this.d.getMonth() + 1; }
get day() { return this.d.getDate(); }
get hour() { return this.d.getHours(); }
get minute() { return this.d.getMinutes(); }
get second() { return this.d.getSeconds(); }
weekday() { return (this.d.getDay() + 6) % 7; }
strftime(format: string) { return strftime(format, this.d); }
isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; }
}
function strftime(format: string, d: Date): string {
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => {
switch (c) {
case "Y": return String(d.getFullYear());
case "y": return pad(d.getFullYear() % 100);
case "m": return pad(d.getMonth() + 1);
case "d": return pad(d.getDate());
case "H": return pad(d.getHours());
case "I": return pad(d.getHours() % 12 || 12);
case "M": return pad(d.getMinutes());
case "S": return pad(d.getSeconds());
case "p": return d.getHours() < 12 ? "AM" : "PM";
case "A": return days[d.getDay()];
case "a": return days[d.getDay()].slice(0, 3);
case "B": return months[d.getMonth()];
case "b": return months[d.getMonth()].slice(0, 3);
case "%": return "%";
default: return "";
}
});
}
const now = () => new DateTime(new Date());
const datetimeClass = { now, today: now, utcnow: now };
/** Namespaces added to the sandbox's builtins. */
export function pythonModules(): Record<string, any> {
const re = {
I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16,
escape: (s: string) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
compile: (pattern: string, flags = 0) => ({
search: (s: string) => re.search(pattern, s, flags),
match: (s: string) => re.match(pattern, s, flags),
sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags),
findall: (s: string) => re.findall(pattern, s, flags),
split: (s: string) => re.split(pattern, s, 0, flags),
}),
search: (pattern: string, s: string, flags = 0) => {
const m = compile(pattern, flags).exec(String(s));
return m ? new ReMatch(m) : null;
},
match: (pattern: string, s: string, flags = 0) => {
const m = compile(`^(?:${pattern})`, flags).exec(String(s));
return m ? new ReMatch(m) : null;
},
findall: (pattern: string, s: string, flags = 0) =>
[...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])),
sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => {
let left = count || Infinity;
const regex = compile(pattern, flags, true);
return String(s).replace(regex, (...args: any[]) => {
if (left-- <= 0) return args[0];
if (typeof replacement === "function") return pyStr(replacement(new ReMatch(Object.assign(args.slice(0, -2).filter((x) => typeof x !== "object"), { index: args[args.length - 2], groups: undefined, input: s }) as any)));
return String(replacement).replace(/\\(\d+)/g, (_m: string, n: string) => args[Number(n)] ?? "");
});
},
split: (pattern: string, s: string, maxsplit = 0, flags = 0) => {
const parts = String(s).split(compile(pattern, flags, true));
return maxsplit ? [...parts.slice(0, maxsplit), parts.slice(maxsplit).join("")] : parts;
},
};
void expand;
const partial = (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args);
const functionModules = { partial };
return {
re,
copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) },
time: {
time: () => Date.now() / 1000,
sleep: () => null,
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
},
datetime: Object.assign({ datetime: datetimeClass, date: datetimeClass }, datetimeClass),
collections: {
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
OrderedDict: () => new PyDict(),
},
functools: functionModules,
};
}