feat: add ATL animation timelines and try/raise to the Ren'Py player
Python sandbox - try/except/else/finally, raise (incl. re-raise), assert, for/while ... else - Multi-generator and dict comprehensions - Builtin exception hierarchy; user exceptions subclass it - Inside try, missing keys/indexes, division by zero, attribute access on None and unknown names raise Python exceptions; lookups stay lenient elsewhere - int()/float() raise ValueError on non-numeric strings ATL - converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat, parallel, choice, block, on show/hide and named transforms become a timeline shipped as game.json "atl"; Move() becomes a linear slide - player/atl.ts: timeline runner wired into the renderer; `on hide` plays before the picture is removed - function, time, contains, matrixcolor and expressions still block the import Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README and ARCHITECTURE updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
962595c925
commit
ff82d882c0
18 files changed
+1050
-53
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4);
|
||||
const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/atl.ts", import.meta.url))],
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { AtlRunner } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const block = (steps, repeat = null) => ({ steps, repeat });
|
||||
const set = (props) => ({ op: "set", props });
|
||||
const move = (t, props, warp = "linear") => ({ op: "move", warp, t, props });
|
||||
|
||||
function runner(named = {}) {
|
||||
const seen = [];
|
||||
const host = {
|
||||
block: (n) => named[n] ?? null,
|
||||
props: () => null,
|
||||
apply: (p) => seen.push({ ...p }),
|
||||
};
|
||||
const props = { xpos: 0.5, xanchor: 0.5 };
|
||||
return { run: new AtlRunner(host, props), props, seen };
|
||||
}
|
||||
|
||||
test("a linear move interpolates from the current value to the target", async () => {
|
||||
const { run, props, seen } = runner();
|
||||
await run.start(block([set({ xalign: 0 }), move(0.12, { xalign: 1 })]));
|
||||
assert.equal(props.xalign, 1);
|
||||
const values = seen.map((p) => p.xalign);
|
||||
assert.ok(values.some((v) => v > 0 && v < 1), "has intermediate frames");
|
||||
assert.deepEqual(values, [...values].sort((a, b) => a - b), "monotonic");
|
||||
});
|
||||
|
||||
test("a move starts from the value implied by xpos when xalign was never set", async () => {
|
||||
const { run, seen } = runner();
|
||||
await run.start(block([move(0.05, { xalign: 1 })]));
|
||||
assert.ok(seen[0].xalign > 0.4, `first frame ${seen[0].xalign} starts near xpos 0.5`);
|
||||
});
|
||||
|
||||
test("tuples move component-wise and parallel blocks run together", async () => {
|
||||
const { run, props } = runner();
|
||||
const started = performance.now();
|
||||
await run.start(block([{ op: "parallel", blocks: [
|
||||
block([move(0.1, { pos: [1, 1] })]), block([move(0.1, { alpha: 0 })]),
|
||||
] }]));
|
||||
assert.deepEqual(props.pos, [1, 1]);
|
||||
assert.equal(props.alpha, 0);
|
||||
assert.ok(performance.now() - started < 250, "blocks overlapped instead of running one after another");
|
||||
});
|
||||
|
||||
test("repeat loops until stopped, even when the body takes no time", async () => {
|
||||
const { run, seen } = runner();
|
||||
const done = run.start(block([set({ alpha: 0.5 })], true)); // would freeze the page without the frame yield
|
||||
await wait(60);
|
||||
run.stop();
|
||||
await done;
|
||||
assert.ok(seen.length > 1 && seen.length < 200);
|
||||
const counted = runner();
|
||||
const finished = counted.run.start(block([{ op: "pause", t: 0.01 }, set({ zoom: 2 })], 3));
|
||||
await finished;
|
||||
assert.equal(counted.seen.length, 3);
|
||||
});
|
||||
|
||||
test("named transforms, choice and block steps are followed", async () => {
|
||||
const named = { grow: block([move(0.03, { zoom: 2 })]) };
|
||||
const { run, props } = runner(named);
|
||||
await run.start(block([{ op: "use", name: "grow" }, { op: "choice", blocks: [{ w: 1, block: block([set({ alpha: 0.25 })]) }] },
|
||||
{ op: "block", block: block([set({ yoffset: 5 })]) }]));
|
||||
assert.equal(props.zoom, 2);
|
||||
assert.equal(props.alpha, 0.25);
|
||||
assert.equal(props.yoffset, 5);
|
||||
});
|
||||
|
||||
test("on show runs at once and `on hide` runs only when the image leaves", async () => {
|
||||
const { run, props } = runner();
|
||||
const timeline = block([
|
||||
{ op: "on", events: ["show"], block: block([set({ alpha: 1 })]) },
|
||||
{ op: "on", events: ["hide"], block: block([move(0.05, { alpha: 0 })]) },
|
||||
]);
|
||||
void run.start(timeline);
|
||||
await wait(30);
|
||||
assert.equal(props.alpha, 1);
|
||||
assert.equal(run.hasHideHandler(timeline), true);
|
||||
await run.hide(timeline);
|
||||
assert.equal(props.alpha, 0);
|
||||
});
|
||||
|
||||
test("excessive nesting is refused instead of overflowing the stack", async () => {
|
||||
const { run } = runner({ loop: { steps: [{ op: "use", name: "loop" }], repeat: null } });
|
||||
await assert.rejects(run.start(block([{ op: "use", name: "loop" }])), /вложенность/);
|
||||
});
|
||||
Reference in new issue
Block a user