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:
1 parent
62ece25902
commit
ea03cebf6a
21 files changed
+1264
-93
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user