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" }])), /вложенность/);
|
||||
});
|
||||
+1
@@ -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
@@ -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)
|
||||
@@ -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;
|
||||
});
|
||||
Reference in new issue
Block a user