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

+98 -3
View File
@@ -1,5 +1,7 @@
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
import { ColorMatrix } from "./colormatrix";
export type AtlProps = Record<string, any>;
/** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */
@@ -20,7 +22,11 @@ export type AtlStep =
| { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] }
| { op: "block"; block: AtlBlock }
| { op: "on"; events: string[]; block: AtlBlock }
| { op: "use"; name: string };
| { op: "use"; name: string; args?: any[]; kw?: Record<string, any> }
| { op: "image"; img: any; with: any }
| { op: "contains"; img: any }
| { op: "function"; f: any }
| { op: "time"; t: AtlValue<number> };
export interface AtlHost {
/** Timeline of a named animated transform. */
@@ -31,6 +37,10 @@ export interface AtlHost {
apply(props: AtlProps): void;
/** Evaluate a run-time value with the transform's parameters in scope. */
eval?(expr: any, vars: Map<string, any>): any;
/** `"other.png" with dissolve`: show another picture in the animated element. */
image?(img: any, transition: any): void;
/** `contains:`: add a child picture that runs its own timeline. */
contains?(img: any): void;
}
/** Bind `name(args)` of an `at` clause to the transform's parameters. `ev` evaluates in the caller's scope. */
@@ -69,8 +79,13 @@ function nextFrame() {
export class AtlRunner {
private token = 0;
/** When set, the timeline stops by itself once this element has left the document. */
watch: HTMLElement | null = null;
private attached = false;
constructor(private host: AtlHost, private props: AtlProps, private vars: Map<string, any> = new Map()) {}
/** Resolve `{ e: expr }` values (also inside tuples). */
private val<T = any>(v: AtlValue<T> | undefined): any {
if (Array.isArray(v)) return v.map((x) => this.val(x));
@@ -138,17 +153,41 @@ export class AtlRunner {
private alive(token: number) {
if (token !== this.token) throw new Stopped();
if (this.watch) {
if (this.watch.isConnected) this.attached = true;
else if (this.attached) {
this.token++;
throw new Stopped();
}
}
}
/** Deliver a button/interaction event (`on hover:` ...) to the handlers of `block`. */
event(block: AtlBlock, name: string) {
const handlers = this.handlers(block, new Set([name]));
if (!handlers.length) return;
const token = ++this.token;
void (async () => {
try {
for (const handler of handlers) await this.run(handler, token, 0, name);
} catch (err) {
if (!(err instanceof Stopped)) throw err;
}
})();
}
private apply() {
this.host.apply(this.props);
}
private blockStart = 0;
private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> {
if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность");
let loops = 0;
for (;;) {
const startedAt = performance.now();
this.blockStart = startedAt;
let waited = false;
for (let i = 0; i < block.steps.length; i++) {
this.alive(token);
@@ -203,8 +242,18 @@ export class AtlRunner {
return true;
case "use": {
const named = this.host.block(step.name);
if (named) await this.run(named, token, depth + 1, event);
else {
if (named) {
// `other(args)`: the callee runs with its own parameters, on the same properties
const callerVars = this.vars;
if (step.args || step.kw || named.params) {
this.vars = bindArguments(named, step, (e) => (this.host.eval ? this.host.eval(e, callerVars) : null));
}
try {
await this.run(named, token, depth + 1, event);
} finally {
this.vars = callerVars;
}
} else {
const props = this.host.props(step.name);
if (props) {
Object.assign(this.props, props);
@@ -213,11 +262,53 @@ export class AtlRunner {
}
return true;
}
case "image":
this.host.image?.(step.img, step.with);
return false;
case "contains":
this.host.contains?.(step.img);
return false;
case "time": {
const target = this.blockStart + this.seconds(step.t) * 1000;
while (performance.now() < target) {
await nextFrame();
this.alive(token);
}
return true;
}
case "function":
await this.callFunction(step.f, token);
return true;
case "on":
return false;
}
}
/** `function f`: Python moves the element. f(trans, st, at) returns the delay until the next call, or None. */
private async callFunction(expr: any, token: number) {
const f = this.host.eval ? this.host.eval(expr, this.vars) : null;
if (typeof f !== "function") return;
const props = this.props;
const trans = new Proxy({}, {
get: (_t, key: string) => props[key] ?? this.current(key),
set: (_t, key: string, value) => {
props[key] = value;
return true;
},
});
const started = performance.now();
for (;;) {
this.alive(token);
const elapsed = (performance.now() - started) / 1000;
const next = f(trans, elapsed, elapsed);
this.apply();
if (next === null || next === undefined) return;
const delay = Number(next);
if (!Number.isFinite(delay)) return;
await this.sleep(Math.max(delay, 0.016), token);
}
}
private async sleep(seconds: number, token: number) {
const end = performance.now() + seconds * 1000;
while (performance.now() < end) {
@@ -265,6 +356,10 @@ export class AtlRunner {
function mix(a: any, b: any, k: number): any {
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k;
if (a instanceof ColorMatrix && b instanceof ColorMatrix) {
return new ColorMatrix(a.rows.map((row, i) => row.map((v, j) => v + (b.rows[i][j] - v) * k) as any));
}
if (b instanceof ColorMatrix && (a === undefined || a === null || a === 0)) return mix(ColorMatrix.identity(), b, k);
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k));
return k >= 1 ? b : a;
}
+163
View File
@@ -0,0 +1,163 @@
// Colour matrices (Ren'Py `matrixcolor` and the older `im.matrix`) as 5x5 matrices over (r, g, b, a, 1),
// applied to pictures through an SVG feColorMatrix filter.
export type Row = [number, number, number, number, number];
const LUMA: [number, number, number] = [0.2126, 0.7152, 0.0722];
export class ColorMatrix {
/** Rows r, g, b, a and the constant row [0, 0, 0, 0, 1]. */
constructor(public rows: Row[]) {}
static identity() {
return new ColorMatrix([[1, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 1]]);
}
/** `a * b` applies b first, then a. */
times(other: ColorMatrix): ColorMatrix {
const out: Row[] = [];
for (let i = 0; i < 5; i++) {
const row = [0, 0, 0, 0, 0] as Row;
for (let j = 0; j < 5; j++) {
let sum = 0;
for (let k = 0; k < 5; k++) sum += this.rows[i][k] * other.rows[k][j];
row[j] = sum;
}
out.push(row);
}
return new ColorMatrix(out);
}
/** Lets the Python sandbox multiply matrices with `*`. */
__binop__(op: string, other: any) {
if (op === "*" && other instanceof ColorMatrix) return this.times(other);
throw new TypeError("Матрицы цвета можно только перемножать");
}
/** The 20 numbers of an feColorMatrix. */
values(): string {
return this.rows.slice(0, 4).map((row) => row.map((v) => +v.toFixed(5)).join(" ")).join(" ");
}
toJSON() {
return undefined; // never part of a save
}
}
function diag(r: number, g: number, b: number, a = 1) {
const m = ColorMatrix.identity();
m.rows[0][0] = r;
m.rows[1][1] = g;
m.rows[2][2] = b;
m.rows[3][3] = a;
return m;
}
/** "#rgb", "#rrggbb", "#rrggbbaa" or a (r, g, b[, a]) tuple of 0-255 / 0-1 numbers -> [r, g, b, a] in 0..1. */
export function parseColor(value: any): [number, number, number, number] {
if (typeof value === "string") {
let hex = value.replace("#", "");
if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join("");
const n = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255;
return [n(0) || 0, n(2) || 0, n(4) || 0, hex.length >= 8 ? n(6) : 1];
}
if (Array.isArray(value)) {
const scale = value.some((v) => v > 1) ? 255 : 1;
return [value[0] / scale, value[1] / scale, value[2] / scale, value.length > 3 ? value[3] / scale : 1];
}
return [1, 1, 1, 1];
}
export const colorMatrices = {
IdentityMatrix: () => ColorMatrix.identity(),
TintMatrix: (color: any) => {
const [r, g, b, a] = parseColor(color);
return diag(r, g, b, a);
},
OpacityMatrix: (value = 1) => diag(1, 1, 1, value),
BrightnessMatrix: (value = 0) => {
const m = ColorMatrix.identity();
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value;
return m;
},
ContrastMatrix: (value = 1) => {
const m = diag(value, value, value);
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = 0.5 * (1 - value);
return m;
},
SaturationMatrix: (value = 1, desat: [number, number, number] = LUMA) => {
const m = ColorMatrix.identity();
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) m.rows[i][j] = (1 - value) * desat[j] + (i === j ? value : 0);
}
return m;
},
InvertMatrix: (value = 1) => {
const m = diag(1 - 2 * value, 1 - 2 * value, 1 - 2 * value);
m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value;
return m;
},
HueMatrix: (degrees = 0) => {
const a = (degrees * Math.PI) / 180;
const c = Math.cos(a);
const s = Math.sin(a);
const m = ColorMatrix.identity();
m.rows[0] = [0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928, 0, 0];
m.rows[1] = [0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.14, 0.072 - c * 0.072 - s * 0.283, 0, 0];
m.rows[2] = [0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072, 0, 0];
return m;
},
ColorizeMatrix: (black: any, white: any) => {
const [br, bg, bb] = parseColor(black);
const [wr, wg, wb] = parseColor(white);
const m = ColorMatrix.identity();
const channels: [number, number][] = [[br, wr], [bg, wg], [bb, wb]];
channels.forEach(([lo, hi], i) => {
m.rows[i] = [(hi - lo) * LUMA[0], (hi - lo) * LUMA[1], (hi - lo) * LUMA[2], 0, lo];
});
return m;
},
};
/** The older `im.matrix.*` names. */
export const legacyMatrices = {
identity: colorMatrices.IdentityMatrix,
brightness: colorMatrices.BrightnessMatrix,
contrast: colorMatrices.ContrastMatrix,
saturation: colorMatrices.SaturationMatrix,
invert: () => colorMatrices.InvertMatrix(1),
opacity: colorMatrices.OpacityMatrix,
hue_rotate: colorMatrices.HueMatrix,
colorize: colorMatrices.ColorizeMatrix,
tint: (r: number, g: number, b: number) => diag(r, g, b),
};
let host: SVGSVGElement | null = null;
const known = new Map<string, string>();
/** CSS `filter` value that applies the matrix (an SVG filter is created once per distinct matrix). */
export function matrixFilter(matrix: ColorMatrix): string {
const values = matrix.values();
let id = known.get(values);
if (!id) {
id = `pgm-${known.size}`;
known.set(values, id);
if (!host) {
host = document.createElementNS("http://www.w3.org/2000/svg", "svg");
host.setAttribute("width", "0");
host.setAttribute("height", "0");
host.style.position = "absolute";
host.append(document.createElementNS("http://www.w3.org/2000/svg", "defs"));
document.body.append(host);
}
const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter");
filter.id = id;
filter.setAttribute("color-interpolation-filters", "sRGB");
const fe = document.createElementNS("http://www.w3.org/2000/svg", "feColorMatrix");
fe.setAttribute("type", "matrix");
fe.setAttribute("values", values);
filter.append(fe);
host.querySelector("defs")!.append(filter);
}
return `url(#${id})`;
}
+33 -3
View File
@@ -7,6 +7,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel";
import { ScreenManager, type ScreenHost } from "./screens";
import { pythonModules } from "./pymodules";
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
@@ -321,6 +322,20 @@ export class Engine {
...this.screens.renpyApi(),
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
end_replay: () => null,
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
loadable: (path: any) => this.fileUrl(String(path)) !== null,
is_init_phase: () => false,
call_stack_depth: () => this.stack.length,
const: (value: any) => value,
screenshot: () => null,
Keymap: () => null,
register_shader: () => null,
register_statement: () => null,
redraw: () => null,
stop_predict: () => null,
map_event: () => null,
count_displayables_in_layer: () => 0,
displayable: (value: any) => value,
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
seen_image: (name: any) => this.seenImages.has(String(name)),
@@ -351,6 +366,10 @@ export class Engine {
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
// a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)),
print: () => null,
...pythonModules(),
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
_window_show: () => { this.windowShown = true; return null; },
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
str: (x: any) => pyStr(x),
@@ -429,6 +448,8 @@ export class Engine {
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
},
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
quickSave: () => void this.save("quick"),
quickLoad: () => void this.load("quick"),
transform: (name) => this.game.transforms[name] ?? null,
atl: (name) => this.game.atl?.[name] ?? null,
};
@@ -729,7 +750,7 @@ export class Engine {
this.renderer.scene();
this.clearNvl();
}
if (node.op === "hide") this.renderer.hide(node.tag);
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag);
else if (node.img) this.showNode(node);
if (node.with) await this.renderer.transition(this.transition(node.with));
this.advance();
@@ -878,7 +899,11 @@ export class Engine {
}
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
const who: string | undefined = node.who;
let who: string | undefined = node.who;
if (node.whoExpr) {
const value = this.safeEval(node.whoExpr);
who = typeof value === "string" ? value : undefined;
}
if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0];
return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" };
@@ -891,6 +916,9 @@ export class Engine {
const char = this.game.characters[who];
if (!char) {
const v = this.store[who];
if (v && typeof v === "object" && v.__character) {
return { char: { color: v.color ?? undefined }, name: this.interp(this.tr.str(v.name ?? who)), mode: "adv" };
}
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
}
let name = this.tr.str(char.name ?? "");
@@ -927,7 +955,9 @@ export class Engine {
if (!inMenu) this.interactionStart();
if (node.with) await this.renderer.transition(this.transition(node.with));
let raw = this.interp(this.tr.line(node.tid, node.what));
let raw = node.whatExpr
? this.interp(pyStr(this.safeEval(node.whatExpr)))
: this.interp(this.tr.line(node.tid, node.what));
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0;
if (mode === "extend" && this.lastSay) {
+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,
};
}
+46 -1
View File
@@ -192,6 +192,8 @@ function percentFormat(fmt: string, args: any): string {
}
export function binop(op: string, a: any, b: any): any {
// objects (colour matrices) may define their own arithmetic
if (a && typeof a === "object" && typeof a.__binop__ === "function") return a.__binop__(op, b);
switch (op) {
case "+":
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
@@ -289,7 +291,13 @@ function methodOf(obj: any, name: string): any {
index: (x) => obj.findIndex((y) => pyEq(y, x)),
count: (x) => obj.filter((y) => pyEq(y, x)).length,
reverse: () => void obj.reverse(),
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
sort: (...a: any[]) => {
const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {};
const key = typeof kw.key === "function" ? kw.key : (x: any) => x;
const sorted = obj.map((x) => [key(x), x]).sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0)).map((p) => p[1]);
if (truthy(kw.reverse)) sorted.reverse();
obj.splice(0, obj.length, ...sorted);
},
clear: () => void obj.splice(0),
copy: () => [...obj],
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
@@ -316,6 +324,10 @@ function methodOf(obj: any, name: string): any {
return obj.get(k);
},
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
has_key: (k) => obj.has(k),
iteritems: () => [...obj.map.entries()],
iterkeys: () => [...obj.map.keys()],
itervalues: () => [...obj.map.values()],
clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()),
};
@@ -341,6 +353,35 @@ function methodOf(obj: any, name: string): any {
},
count: (s) => obj.split(s).length - 1,
find: (s) => obj.indexOf(s),
zfill: (n: number) => obj.padStart(n, "0"),
ljust: (n: number, ch = " ") => obj.padEnd(n, ch),
rjust: (n: number, ch = " ") => obj.padStart(n, ch),
center: (n: number, ch = " ") => {
const total = Math.max(0, n - obj.length);
const left = Math.floor(total / 2);
return ch.repeat(left) + obj + ch.repeat(total - left);
},
partition: (sep: string) => {
const i = obj.indexOf(sep);
return i < 0 ? [obj, "", ""] : [obj.slice(0, i), sep, obj.slice(i + sep.length)];
},
rpartition: (sep: string) => {
const i = obj.lastIndexOf(sep);
return i < 0 ? ["", "", obj] : [obj.slice(0, i), sep, obj.slice(i + sep.length)];
},
rsplit: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
splitlines: () => obj.split(/\r?\n/),
isupper: () => obj !== obj.toLowerCase() && obj === obj.toUpperCase(),
islower: () => obj !== obj.toUpperCase() && obj === obj.toLowerCase(),
isspace: () => obj.length > 0 && /^\s+$/.test(obj),
isalnum: () => /^[\p{L}\p{N}]+$/u.test(obj),
swapcase: () => [...obj].map((c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())).join(""),
index: (x: string) => {
const i = obj.indexOf(x);
if (i < 0) throw new Error("substring not found");
return i;
},
rfind: (x: string) => obj.lastIndexOf(x),
isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj),
};
@@ -412,12 +453,16 @@ export class Python {
const m = methodOf(o, e.a);
if (m) return m;
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
// methods of host objects (datetime, regex matches) need their object
if (typeof v === "function" && !(o instanceof PyDict) && !v.__py && Object.getPrototypeOf(o) !== Object.prototype)
return v.bind(o);
return v === undefined ? null : v;
}
case "sub": {
const o = this.eval(e.o);
const i = this.eval(e.i);
if (o instanceof PyDict) {
if (!o.has(i) && typeof (o as any).missing === "function") return (o as any).missing(i); // defaultdict
if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i));
return o.has(i) ? o.get(i) : null;
}
+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;
+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";
}
+2
View File
@@ -28,6 +28,8 @@ export interface ImageDef {
props?: Record<string, any>;
frames?: { img: ImageDef; d?: number }[];
repeat?: boolean;
/** ATL timeline that switches pictures and moves them (image blocks with parallel, contains ...). */
timeline?: import("./atl").AtlBlock;
layered?: Layer[];
at?: AtSpec[];
selected?: string[]; // attributes chosen for a layered image (set by the engine)
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
Object.assign(globalThis, { window, document });
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
});
const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const name = (id) => ({ k: "name", id });
const attr = (o, a) => ({ k: "attr", o, a });
const call = (f, ...a) => ({ k: "call", f, a: a.map((v) => (v && v.k ? v : { k: "const", v })), kw: {} });
function python(extra = {}) {
const store = {};
const builtins = { ...pythonModules(), ...colorMatrices, ...extra };
const py = new Python({
get: (n) => (n in store ? store[n] : builtins[n]),
has: (n) => n in store || n in builtins,
set: (n, v) => { store[n] = v; },
del: (n) => { delete store[n]; },
});
return { py, store };
}
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
const brighter = colorMatrices.BrightnessMatrix(0.2);
const half = colorMatrices.TintMatrix("#808080");
const both = half.times(brighter); // brighten, then tint
assert.ok(Math.abs(both.rows[0][4] - 0.2 * (128 / 255)) < 1e-6);
assert.deepEqual(ColorMatrix.identity().times(brighter).rows, brighter.rows);
const gray = colorMatrices.SaturationMatrix(0);
assert.ok(Math.abs(gray.rows[0][0] - 0.2126) < 1e-9 && Math.abs(gray.rows[1][2] - 0.0722) < 1e-9);
assert.equal(legacyMatrices.tint(1, 0.5, 0).rows[1][1], 0.5);
assert.match(matrixFilter(both), /^url\(#pgm-\d+\)$/);
assert.equal(matrixFilter(both), matrixFilter(both), "equal matrices share one SVG filter");
assert.equal(document.querySelectorAll("feColorMatrix").length, 1);
});
test("the sandbox multiplies matrices with `*` and refuses other arithmetic on them", () => {
const { py, store } = python();
py.exec([{ op: "set", t: name("m"), v: { k: "bin", op: "*", l: call(name("TintMatrix"), "#ff0000"), r: call(name("BrightnessMatrix"), 0.1) } }]);
assert.ok(store.m instanceof ColorMatrix);
assert.throws(() => py.eval({ k: "bin", op: "+", l: name("m"), r: name("m") }), /перемножать/);
});
test("re, copy, defaultdict, datetime and string helpers behave like their Python versions", () => {
const { py, store } = python();
const re = (fn, ...args) => py.eval(call(attr(name("re"), fn), ...args));
assert.equal(re("sub", "(\\d+)", "<\\1>", "a12b3"), "a<12>b<3>");
assert.equal(re("match", "^ab", "abc").group(), "ab");
assert.equal(re("search", "(?P<n>\\d+)", "x42").group("n"), "42");
assert.deepEqual(re("findall", "\\d", "a1b2"), ["1", "2"]);
const original = new PyDict([["k", [1, 2]]]);
store.original = original;
const copied = py.eval(call(attr(name("copy"), "deepcopy"), name("original")));
copied.get("k").push(3);
assert.deepEqual(original.get("k"), [1, 2]);
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), { k: "name", id: "list" }));
py.scope.set("list", () => []);
store.counts = py.eval(call(attr(name("collections"), "defaultdict"), name("list")));
py.eval(call(attr({ k: "sub", o: name("counts"), i: { k: "const", v: "a" } }, "append"), 1));
assert.deepEqual(store.counts.get("a"), [1]);
assert.equal(py.eval(call(attr({ k: "const", v: "7" }, "zfill"), 3)), "007");
assert.deepEqual(py.eval(call(attr({ k: "const", v: "a=b" }, "partition"), "=")), ["a", "=", "b"]);
const hour = py.eval(attr(call(attr(name("datetime"), "now")), "hour"));
assert.ok(hour >= 0 && hour < 24);
});
test("ATL image steps, finite repeat, time and function work in a timeline", async () => {
const shown = [];
const props = {};
const { py } = python();
py.scope.set("drift", (trans) => { trans.xpos = 0.75; return null; });
const host = {
block: () => null, props: () => null, apply: () => {},
eval: (expr) => py.eval(expr),
image: (img, transition) => shown.push([img.file, transition?.d]),
};
const runner = new AtlRunner(host, props);
const started = performance.now();
const completed = await runner.start({ repeat: 2, steps: [
{ op: "image", img: { file: "a.webp" }, with: { t: "dissolve", d: 0.2 } },
{ op: "pause", t: 0.02 },
{ op: "image", img: { file: "b.webp" }, with: null },
{ op: "time", t: 0.05 },
] });
assert.equal(completed, true);
assert.deepEqual(shown, [["a.webp", 0.2], ["b.webp", undefined], ["a.webp", 0.2], ["b.webp", undefined]]);
assert.ok(performance.now() - started >= 95, "`time 0.05` waits for the block clock, twice");
await new AtlRunner(host, props).start({ repeat: null, steps: [{ op: "function", f: { k: "name", id: "drift" } }] });
assert.equal(props.xpos, 0.75, "function f mutates the transform properties");
});
test("an animation stops by itself when its element leaves the page", async () => {
const box = document.createElement("div");
document.body.append(box);
let frames = 0;
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => { frames++; } }, {});
runner.watch = box;
const running = runner.start({ repeat: true, steps: [{ op: "move", warp: "linear", t: 0.02, props: { alpha: 0 } }] });
await wait(40);
box.remove();
assert.equal(await running, false, "stopped, not finished");
const seen = frames;
await wait(40);
assert.equal(frames, seen);
});
test("on hover handlers run when the button reports the event", async () => {
const props = { alpha: 1 };
const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => {} }, props);
const block = { repeat: null, steps: [
{ op: "on", events: ["hover"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 0.5 } }] } },
{ op: "on", events: ["idle"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 1 } }] } },
] };
runner.event(block, "hover");
await wait(20);
assert.equal(props.alpha, 0.5);
runner.event(block, "idle");
await wait(20);
assert.equal(props.alpha, 1);
});