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:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:11:07 +03:00
1 parent 962595c925
commit ff82d882c0
18 files changed
+1050 -53

No files matched your search

+93
View File
@@ -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" }])), /вложенность/);
});
+1
View File
@@ -0,0 +1 @@
[{"op":"class","name":"AppError","bases":[{"k":"name","id":"Exception"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"msg","d":null,"kind":"p"},{"n":"code","d":{"k":"const","v":0},"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[],"kw":{}},"a":"__init__"},"a":[{"k":"name","id":"msg"}],"kw":{}}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"code"},"v":{"k":"name","id":"code"}}],"decos":[]}]},{"op":"def","name":"risky","params":[{"n":"n","d":null,"kind":"p"}],"body":[{"op":"if","c":{"k":"cmp","l":{"k":"name","id":"n"},"ops":["=="],"r":[{"k":"const","v":0}]},"then":[{"op":"raise","v":{"k":"call","f":{"k":"name","id":"AppError"},"a":[{"k":"const","v":"zero"}],"kw":{"code":{"k":"const","v":7}}}}],"else":[]},{"op":"return","v":{"k":"bin","op":"//","l":{"k":"const","v":10},"r":{"k":"name","id":"n"}}}],"decos":[]},{"op":"set","t":{"k":"name","id":"log"},"v":{"k":"list","v":[]}},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"risky"},"a":[{"k":"const","v":0}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"AppError"},"name":"e","body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"call","f":{"k":"name","id":"str"},"a":[{"k":"name","id":"e"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_code"},"v":{"k":"attr","o":{"k":"name","id":"e"},"a":"code"}}]}],"else":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"const","v":"no"}],"kw":{}}}],"final":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"log"},"a":"append"},"a":[{"k":"const","v":"fin"}],"kw":{}}}]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"d"},"v":{"k":"dict","v":[[{"k":"const","v":"a"},{"k":"const","v":1}]]}},{"op":"set","t":{"k":"name","id":"v"},"v":{"k":"sub","o":{"k":"name","id":"d"},"i":{"k":"const","v":"b"}}}],"handlers":[{"types":{"k":"name","id":"KeyError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_key"},"v":{"k":"const","v":"keyerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"xs"},"v":{"k":"list","v":[{"k":"const","v":1}]}},{"op":"eval","e":{"k":"sub","o":{"k":"name","id":"xs"},"i":{"k":"const","v":5}}}],"handlers":[{"types":{"k":"tuple","v":[{"k":"name","id":"IndexError"},{"k":"name","id":"ValueError"}]},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_index"},"v":{"k":"const","v":"indexerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"int"},"a":[{"k":"const","v":"abc"}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"ValueError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"const","v":"valueerror"}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"eval","e":{"k":"call","f":{"k":"name","id":"risky"},"a":[{"k":"const","v":0}],"kw":{}}}],"handlers":[{"types":{"k":"name","id":"Exception"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_generic"},"v":{"k":"const","v":true}}]}],"else":[],"final":[]},{"op":"try","body":[{"op":"set","t":{"k":"name","id":"result_zero"},"v":{"k":"bin","op":"/","l":{"k":"const","v":1},"r":{"k":"const","v":0}}}],"handlers":[{"types":{"k":"name","id":"ZeroDivisionError"},"name":null,"body":[{"op":"set","t":{"k":"name","id":"result_zero"},"v":{"k":"const","v":"zerodiv"}}]}],"else":[],"final":[]},{"op":"def","name":"retry","params":[],"body":[{"op":"for","t":{"k":"name","id":"i"},"it":{"k":"call","f":{"k":"name","id":"range"},"a":[{"k":"const","v":3}],"kw":{}},"body":[{"op":"try","body":[{"op":"if","c":{"k":"cmp","l":{"k":"name","id":"i"},"ops":["<"],"r":[{"k":"const","v":2}]},"then":[{"op":"raise","v":{"k":"call","f":{"k":"name","id":"ValueError"},"a":[{"k":"const","v":"again"}],"kw":{}}}],"else":[]},{"op":"return","v":{"k":"name","id":"i"}}],"handlers":[{"types":{"k":"name","id":"ValueError"},"name":null,"body":[{"op":"continue"}]}],"else":[],"final":[]}],"else":[]},{"op":"return","v":{"k":"unary","op":"-","v":{"k":"const","v":1}}}],"decos":[]},{"op":"set","t":{"k":"name","id":"result_retry"},"v":{"k":"call","f":{"k":"name","id":"retry"},"a":[],"kw":{}}},{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"const","v":null}},{"op":"for","t":{"k":"name","id":"n"},"it":{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":3},{"k":"const","v":5}]},"body":[{"op":"if","c":{"k":"cmp","l":{"k":"bin","op":"%","l":{"k":"name","id":"n"},"r":{"k":"const","v":2}},"ops":["=="],"r":[{"k":"const","v":0}]},"then":[{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"name","id":"n"}},{"op":"break"}],"else":[]}],"else":[{"op":"set","t":{"k":"name","id":"found"},"v":{"k":"const","v":"none"}}]},{"op":"set","t":{"k":"name","id":"result_forelse"},"v":{"k":"name","id":"found"}},{"op":"set","t":{"k":"name","id":"k"},"v":{"k":"const","v":0}},{"op":"while","c":{"k":"cmp","l":{"k":"name","id":"Line truncated
+99
View File
@@ -0,0 +1,99 @@
# Source of exceptions.ir.json. Regenerate from backend/ with:
# python -m tests.make_ir ../frontend/tests/fixtures/exceptions.py ../frontend/tests/fixtures/exceptions.ir.json
class AppError(Exception):
def __init__(self, msg, code=0):
super().__init__(msg)
self.code = code
def risky(n):
if n == 0:
raise AppError("zero", code=7)
return 10 // n
log = []
try:
risky(0)
except AppError as e:
log.append(str(e))
result_code = e.code
else:
log.append("no")
finally:
log.append("fin")
try:
d = {"a": 1}
v = d["b"]
except KeyError:
result_key = "keyerror"
try:
xs = [1]
xs[5]
except (IndexError, ValueError):
result_index = "indexerror"
try:
int("abc")
except ValueError:
result_value = "valueerror"
try:
risky(0)
except Exception:
result_generic = True
try:
result_zero = 1 / 0
except ZeroDivisionError:
result_zero = "zerodiv"
def retry():
for i in range(3):
try:
if i < 2:
raise ValueError("again")
return i
except ValueError:
continue
return -1
result_retry = retry()
found = None
for n in [1, 3, 5]:
if n % 2 == 0:
found = n
break
else:
found = "none"
result_forelse = found
k = 0
while k < 3:
k += 1
else:
k = k * 10
result_whileelse = k
try:
try:
raise RuntimeError("inner")
except RuntimeError:
raise
except RuntimeError as e3:
result_reraise = str(e3)
result_pairs = [(x, y) for x in [1, 2] for y in [3, 4]]
result_dictcomp = {a: b for a in [1, 2] for b in [a * 10] if b > 10}
try:
assert 1 == 2, "boom"
except AssertionError as e4:
result_assert = str(e4)
+29 -1
View File
@@ -14,7 +14,7 @@ const ir = (name) => JSON.parse(readFileSync(new URL(`./fixtures/${name}.ir.json
function runtime() {
const store = Object.create(null);
const builtins = {
any: (xs) => xs.some(Boolean), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
any: (xs) => xs.some(Boolean), range: (n) => Array.from({ length: n }, (_, i) => i), max: Math.max, int: Number, str: pyStr, list: (xs = []) => [...xs],
};
const scope = {
get: (n) => (n in store ? store[n] : builtins[n]),
@@ -82,3 +82,31 @@ test("unbounded recursion and bad calls fail with explicit errors", () => {
assert.throws(() => py.eval({ k: "call", f: { k: "name", id: "one" },
a: [{ k: "const", v: 1 }, { k: "const", v: 2 }], kw: {} }), /слишком много/);
});
test("try/except/else/finally, raise, loop else, assert and multi-generator comprehensions", () => {
const { py, store } = runtime();
py.exec(ir("exceptions"));
assert.deepEqual(store.log, ["zero", "fin"]);
assert.equal(store.result_code, 7);
assert.equal(store.result_key, "keyerror"); // dict[...] is strict inside try
assert.equal(store.result_index, "indexerror");
assert.equal(store.result_value, "valueerror"); // int("abc")
assert.equal(store.result_generic, true);
assert.equal(store.result_zero, "zerodiv");
assert.equal(store.result_retry, 2); // return/continue pass through try
assert.equal(store.result_forelse, "none");
assert.equal(store.result_whileelse, 30);
assert.equal(store.result_reraise, "inner");
assert.deepEqual(store.result_pairs, [[1, 3], [1, 4], [2, 3], [2, 4]]);
assert.deepEqual([...store.result_dictcomp.map.entries()], [[2, 20]]);
assert.equal(store.result_assert, "boom");
});
test("lookups stay lenient outside try, uncaught exceptions are explicit errors", () => {
const { py, store } = runtime();
py.exec([{ op: "set", t: { k: "name", id: "d" }, v: { k: "dict", v: [] } }]);
assert.equal(py.eval({ k: "sub", o: { k: "name", id: "d" }, i: { k: "const", v: "missing" } }), null);
assert.throws(() => py.exec([{ op: "raise", v: { k: "name", id: "ValueError" } }]), /ValueError/);
assert.equal(py.strict, 0);
void store;
});