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

+45 -5
View File
@@ -1,6 +1,7 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform.
import { AtlRunner, type AtlBlock } from "./atl";
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
@@ -27,11 +28,13 @@ export interface RendererHost {
resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null;
atl(name: string): AtlBlock | null;
text(e: any): string;
}
interface Entry extends ShownImage {
el: HTMLElement;
runners: { runner: AtlRunner; block: AtlBlock }[];
}
export class Renderer {
@@ -64,7 +67,10 @@ export class Renderer {
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove();
for (const e of this.shown.values()) {
e.runners.forEach((r) => r.runner.stop());
e.el.remove();
}
this.shown.clear();
}
@@ -73,8 +79,10 @@ export class Renderer {
const atSpecs = at.length ? at : prev?.at ?? [];
const el = this.build(img.def);
el.classList.add("pg-image");
this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
const base = this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el,
runners: [] };
prev?.runners.forEach((r) => r.runner.stop());
if (prev) {
prev.el.replaceWith(el);
} else {
@@ -83,13 +91,39 @@ export class Renderer {
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
this.animate(entry, base, atSpecs);
}
/** 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[]) {
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 }),
}, live);
entry.runners.push({ runner, block });
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
}
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
e.el.remove();
this.shown.delete(tag);
const exit = e.runners.find((r) => r.runner.hasHideHandler(r.block));
if (!exit || this.fast) {
e.runners.forEach((r) => r.runner.stop());
e.el.remove();
return;
}
// `on hide` animation: the picture leaves the scene state at once but stays visible until it ends
e.runners.forEach((r) => r.runner !== exit.runner && r.runner.stop());
void exit.runner.hide(exit.block).finally(() => e.el.remove());
}
isShown(tag: string) {
@@ -243,7 +277,8 @@ export class Renderer {
cameraAt: AtSpec[] = [];
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
/** Sets the element's position and look; returns the properties it was built from. */
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]): Record<string, any> {
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of [...(def.at ?? []), ...at]) {
if (spec.effect === "shake") {
@@ -256,6 +291,11 @@ export class Renderer {
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
if (spec.props) Object.assign(p, spec.props);
}
this.style(el, { ...p });
return p;
}
private style(el: HTMLElement, p: Record<string, any>) {
normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);