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

+226
View File
@@ -0,0 +1,226 @@
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
export type AtlProps = Record<string, any>;
export interface AtlBlock {
steps: AtlStep[];
repeat: null | true | number;
}
export type AtlStep =
| { op: "set"; props: AtlProps }
| { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: number; props: AtlProps }
| { op: "pause"; t: number }
| { op: "parallel"; blocks: AtlBlock[] }
| { op: "choice"; blocks: { w: number; block: AtlBlock }[] }
| { op: "block"; block: AtlBlock }
| { op: "on"; events: string[]; block: AtlBlock }
| { op: "use"; name: string };
export interface AtlHost {
/** Timeline of a named animated transform. */
block(name: string): AtlBlock | null;
/** Properties of a named static transform. */
props(name: string): AtlProps | null;
/** Called after every change of the working properties. */
apply(props: AtlProps): void;
}
const SHOW_EVENTS = new Set(["show", "appear", "default", "start", "update", "replace"]);
const HIDE_EVENTS = new Set(["hide", "replaced"]);
const NEUTRAL: AtlProps = {
alpha: 1, zoom: 1, xzoom: 1, yzoom: 1, rotate: 0, xoffset: 0, yoffset: 0,
xpos: 0, ypos: 0, xanchor: 0, yanchor: 0, xalign: 0, yalign: 0, xcenter: 0, ycenter: 0,
};
const MAX_DEPTH = 16;
class Stopped extends Error {}
const curves = {
linear: (t: number) => t,
ease: (t: number) => 0.5 - Math.cos(Math.PI * t) / 2,
easein: (t: number) => Math.cos((1 - t) * Math.PI / 2),
easeout: (t: number) => 1 - Math.cos(t * Math.PI / 2),
};
function nextFrame() {
return new Promise<number>((resolve) => requestAnimationFrame(resolve));
}
export class AtlRunner {
private token = 0;
constructor(private host: AtlHost, private props: AtlProps) {}
/** Start the timeline; resolves when it ends (never, for endless loops) or is stopped. */
async start(block: AtlBlock): Promise<void> {
const token = ++this.token;
try {
await this.run(block, token, 0, "show");
} catch (err) {
if (!(err instanceof Stopped)) throw err;
}
}
stop() {
this.token++;
}
/** Run the `on hide` handler of the block, if any; resolves once it has finished. */
async hide(block: AtlBlock): Promise<void> {
const handler = this.handlers(block, HIDE_EVENTS);
this.stop();
if (!handler.length) return;
const token = ++this.token;
try {
for (const b of handler) await this.run(b, token, 0, "hide");
} catch (err) {
if (!(err instanceof Stopped)) throw err;
}
}
hasHideHandler(block: AtlBlock): boolean {
return this.handlers(block, HIDE_EVENTS).length > 0;
}
private handlers(block: AtlBlock, events: Set<string>, depth = 0): AtlBlock[] {
if (depth > MAX_DEPTH) return [];
const out: AtlBlock[] = [];
for (const step of block.steps) {
if (step.op === "on" && step.events.some((e) => events.has(e))) out.push(step.block);
else if (step.op === "parallel") for (const b of step.blocks) out.push(...this.handlers(b, events, depth + 1));
else if (step.op === "block") out.push(...this.handlers(step.block, events, depth + 1));
else if (step.op === "use") {
const named = this.host.block(step.name);
if (named) out.push(...this.handlers(named, events, depth + 1));
}
}
return out;
}
private alive(token: number) {
if (token !== this.token) throw new Stopped();
}
private apply() {
this.host.apply(this.props);
}
private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> {
if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность");
let loops = 0;
for (;;) {
const startedAt = performance.now();
let waited = false;
for (let i = 0; i < block.steps.length; i++) {
this.alive(token);
const step = block.steps[i];
if (step.op === "on") {
// `on` statements form an event loop: run the show-time handler, then wait for hide
const matching = block.steps.filter((s) => s.op === "on" && s.events.some((e) => (event === "hide" ? HIDE_EVENTS : SHOW_EVENTS).has(e)));
for (const handler of matching) await this.run((handler as { block: AtlBlock }).block, token, depth + 1, event);
if (event === "hide") return;
await new Promise<void>(() => {}); // idle until stopped
}
if (await this.step(step, token, depth, event)) waited = true;
}
loops++;
if (block.repeat === null) return;
if (block.repeat !== true && loops >= block.repeat) return;
// a loop without any waiting step would freeze the page: yield one frame per turn
if (!waited && performance.now() - startedAt < 1) await nextFrame();
}
}
/** Returns true when the step took time. */
private async step(step: AtlStep, token: number, depth: number, event: string): Promise<boolean> {
switch (step.op) {
case "set":
Object.assign(this.props, step.props);
this.apply();
return false;
case "pause":
await this.sleep(step.t, token);
return step.t > 0;
case "move":
await this.move(step, token);
return step.t > 0;
case "parallel":
await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event)));
return true;
case "choice": {
const total = step.blocks.reduce((s, b) => s + b.w, 0);
let pick = Math.random() * total;
const chosen = step.blocks.find((b) => (pick -= b.w) < 0) ?? step.blocks[step.blocks.length - 1];
if (chosen) await this.run(chosen.block, token, depth + 1, event);
return true;
}
case "block":
await this.run(step.block, token, depth + 1, event);
return true;
case "use": {
const named = this.host.block(step.name);
if (named) await this.run(named, token, depth + 1, event);
else {
const props = this.host.props(step.name);
if (props) {
Object.assign(this.props, props);
this.apply();
}
}
return true;
}
case "on":
return false;
}
}
private async sleep(seconds: number, token: number) {
const end = performance.now() + seconds * 1000;
while (performance.now() < end) {
await nextFrame();
this.alive(token);
}
}
/** Value a property has right now, including values implied by its aliases (xalign <- xpos). */
private current(key: string): any {
const p = this.props;
const pair = (a: string, b: string, fa = 0, fb = 0) => [p[a] ?? fa, p[b] ?? fb];
switch (key) {
case "xalign": return p.xalign ?? p.xpos ?? 0;
case "yalign": return p.yalign ?? p.ypos ?? 0;
case "xcenter": return p.xcenter ?? p.xpos ?? 0;
case "ycenter": return p.ycenter ?? p.ypos ?? 0;
case "pos": return p.pos ?? pair("xpos", "ypos");
case "align": return p.align ?? [p.xalign ?? p.xpos ?? 0, p.yalign ?? p.ypos ?? 0];
case "anchor": return p.anchor ?? pair("xanchor", "yanchor");
default: return p[key] ?? NEUTRAL[key] ?? 0;
}
}
private async move(step: Extract<AtlStep, { op: "move" }>, token: number) {
const from: AtlProps = {};
for (const key of Object.keys(step.props)) from[key] = this.current(key);
const curve = curves[step.warp] ?? curves.linear;
const start = performance.now();
const duration = step.t * 1000;
while (duration > 0) {
await nextFrame();
this.alive(token);
const raw = Math.min(1, (performance.now() - start) / duration);
const k = curve(raw);
for (const [key, to] of Object.entries(step.props)) this.props[key] = mix(from[key], to, k);
this.apply();
if (raw >= 1) return;
}
Object.assign(this.props, step.props);
this.apply();
}
}
function mix(a: any, b: any, k: number): any {
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k;
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k));
return k >= 1 ? b : a;
}
+1
View File
@@ -112,6 +112,7 @@ export class Engine {
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null,
atl: (name) => game.atl?.[name] ?? null,
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
});
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
+178 -13
View File
@@ -88,6 +88,39 @@ class PyDescriptor {
export const OBJECT = new PyClass("object", []);
const classRegistry = new Map<string, PyClass>();
/** Builtin exception hierarchy; ``args`` holds the constructor arguments. */
export const BASE_EXCEPTION = new PyClass("BaseException", [OBJECT]);
BASE_EXCEPTION.attrs.set("__init__", (self: PyInstance, ...args: any[]) => {
const last = args[args.length - 1];
self.attrs.args = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw")
? args.slice(0, -1) : args;
return null;
});
BASE_EXCEPTION.attrs.set("__str__", (self: PyInstance) => {
const args = self.attrs.args;
return Array.isArray(args) && args.length ? (args.length === 1 ? pyStr(args[0]) : pyStr(args)) : "";
});
for (const method of BASE_EXCEPTION.attrs.values()) method.__method = true;
export const EXCEPTIONS = new Map<string, PyClass>([["BaseException", BASE_EXCEPTION]]);
for (const [name, parent] of [
["Exception", "BaseException"], ["ArithmeticError", "Exception"], ["ZeroDivisionError", "ArithmeticError"],
["OverflowError", "ArithmeticError"], ["AssertionError", "Exception"], ["AttributeError", "Exception"],
["ImportError", "Exception"], ["LookupError", "Exception"], ["IndexError", "LookupError"],
["KeyError", "LookupError"], ["NameError", "Exception"], ["OSError", "Exception"], ["IOError", "OSError"],
["EnvironmentError", "OSError"], ["RuntimeError", "Exception"], ["NotImplementedError", "RuntimeError"],
["RecursionError", "RuntimeError"], ["StopIteration", "Exception"], ["SyntaxError", "Exception"],
["TypeError", "Exception"], ["ValueError", "Exception"], ["UnicodeError", "ValueError"],
] as const) {
EXCEPTIONS.set(name, new PyClass(name, [EXCEPTIONS.get(parent)!]));
}
for (const cls of EXCEPTIONS.values()) classRegistry.set(cls.name, cls);
class PyException extends Error {
constructor(public instance: PyInstance) {
super(`${instance.cls.name}: ${instanceText(instance)}`);
}
}
type FnDef = { name: string; params: any[]; body?: PyStmt[]; expr?: Expr };
type FnCtx = {
vars: Map<string, any>;
@@ -328,6 +361,9 @@ export class Python {
warned = new Set<string>();
fn: FnCtx | null = null;
depth = 0;
/** > 0 while a try block runs: lookups that normally yield None raise Python exceptions. */
strict = 0;
handling: PyInstance[] = [];
constructor(public scope: Scope) {
activePython = this;
@@ -345,6 +381,7 @@ export class Python {
for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.scope.has(name)) return this.scope.get(name);
if (this.strict) this.raise("NameError", `имя ${name} не определено`);
this.warnOnce(`неизвестное имя ${name}`);
return null;
}
@@ -368,6 +405,7 @@ export class Python {
}
if (!safeProperty(e.a) || typeof o === "function") return null;
if (o === null || o === undefined) {
if (this.strict) this.raise("AttributeError", `у None нет атрибута ${e.a}`);
this.warnOnce(`атрибут ${e.a} у None`);
return null;
}
@@ -379,8 +417,15 @@ export class Python {
case "sub": {
const o = this.eval(e.o);
const i = this.eval(e.i);
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
if (o instanceof PyDict) {
if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i));
return o.has(i) ? o.get(i) : null;
}
if (!safeProperty(i) || typeof o === "function") return null;
if (this.strict && (Array.isArray(o) || typeof o === "string")) {
const at = index(o.length, i);
if (at < 0 || at >= o.length) this.raise("IndexError", "индекс вне диапазона");
}
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null;
}
@@ -390,8 +435,13 @@ export class Python {
const hi = e.hi ? this.eval(e.hi) : undefined;
return o.slice(lo, hi);
}
case "bin":
return binop(e.op, this.eval(e.l), this.eval(e.r));
case "bin": {
const left = this.eval(e.l);
const right = this.eval(e.r);
if (this.strict && right === 0 && (e.op === "/" || e.op === "//" || e.op === "%"))
this.raise("ZeroDivisionError", "деление на ноль");
return binop(e.op, left, right);
}
case "bool": {
let v: any = null;
for (const x of e.v) {
@@ -426,18 +476,30 @@ export class Python {
case "fmt":
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
case "comp": {
const gens = e.gens ?? [{ t: e.t, it: e.it, if: e.if }];
const isDict = e.v !== undefined && e.v !== null;
const out: any[] = [];
const dict = new PyDict();
const frame = new Map<string, any>();
this.locals.push(frame);
try {
for (const item of iterate(this.eval(e.it))) {
this.bind(e.t, item, frame);
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
const run = (level: number) => {
if (level === gens.length) {
if (isDict) dict.set(this.eval(e.e), this.eval(e.v));
else out.push(this.eval(e.e));
return;
}
const gen = gens[level];
for (const item of [...iterate(this.eval(gen.it))]) {
this.bind(gen.t, item, frame);
if (gen.if.every((c: Expr) => truthy(this.eval(c)))) run(level + 1);
}
};
try {
run(0);
} finally {
this.locals.pop();
}
return out;
return isDict ? dict : out;
}
case "lambda": {
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
@@ -530,17 +592,38 @@ export class Python {
case "if":
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
break;
case "while":
case "while": {
let broke = false;
while (truthy(this.eval(s.c))) {
if (++guard > 100000) throw new Error("бесконечный цикл в python");
if (this.loopBody(s.body)) break;
if (this.loopBody(s.body)) {
broke = true;
break;
}
}
if (!broke && s.else?.length) this.exec(s.else);
break;
case "for":
}
case "for": {
let broke = false;
for (const item of [...iterate(this.eval(s.it))]) {
this.bind(s.t, item);
if (this.loopBody(s.body)) break;
if (this.loopBody(s.body)) {
broke = true;
break;
}
}
if (!broke && s.else?.length) this.exec(s.else);
break;
}
case "try":
this.execTry(s);
break;
case "raise":
this.execRaise(s);
break;
case "assert":
if (!truthy(this.eval(s.c))) this.raise("AssertionError", s.m ? pyStr(this.eval(s.m)) : "");
break;
case "del":
if (s.t.k === "name") {
@@ -577,6 +660,69 @@ export class Python {
}
}
// --- exceptions ---------------------------------------------------------------------
/** Throw a builtin Python exception, e.g. ``raise("KeyError", "x")``. */
raise(name: string, message: string): never {
const cls = EXCEPTIONS.get(name) ?? EXCEPTIONS.get("Exception")!;
throw new PyException(this.instantiate(cls, message === "" ? [] : [message]));
}
private execRaise(s: PyStmt) {
if (!s.v) {
const current = this.handling[this.handling.length - 1];
if (!current) this.raise("RuntimeError", "нет исключения для повторного вызова");
throw new PyException(current);
}
const v = this.eval(s.v);
if (v instanceof PyInstance && v.cls.mro().includes(BASE_EXCEPTION)) throw new PyException(v);
if (v instanceof PyClass && v.mro().includes(BASE_EXCEPTION)) throw new PyException(this.instantiate(v, []));
this.raise("TypeError", "можно вызвать только исключение");
}
/** Python exception for anything thrown while running a try body, or null for control flow. */
private caught(err: unknown): PyInstance | null {
if (err instanceof PyException) return err.instance;
if (err instanceof BreakSignal || err instanceof ContinueSignal || err instanceof ReturnSignal
|| err instanceof JumpSignal) return null;
if (err instanceof TypeError) return this.instantiate(EXCEPTIONS.get("TypeError")!, [err.message]);
if (err instanceof Error) return this.instantiate(EXCEPTIONS.get("RuntimeError")!, [err.message]);
return null;
}
private execTry(s: PyStmt) {
try {
let handled = false;
this.strict++;
try {
this.exec(s.body);
} catch (err) {
this.strict--;
const exception = this.caught(err);
const handler = exception && s.handlers.find((h: any) => {
if (h.types === null) return true;
const kinds = this.eval(h.types);
return (Array.isArray(kinds) ? kinds : [kinds]).some((k: any) =>
k instanceof PyClass && exception.cls.mro().includes(k));
});
if (!exception || !handler) throw err;
handled = true;
if (handler.name) this.bindName(handler.name, exception);
this.handling.push(exception);
try {
this.exec(handler.body);
} finally {
this.handling.pop();
}
return;
}
this.strict--;
if (!handled && s.else?.length) this.exec(s.else);
} finally {
if (s.final?.length) this.exec(s.final);
}
}
// --- functions and classes ---------------------------------------------------------
/** Assign a name in the scope the running code sees: function local, global or enclosing. */
@@ -755,7 +901,7 @@ export class Python {
if (value.kind === "static") return value.fn;
return (...a: any[]) => value.fn(cls, ...a);
}
if (typeof value === "function" && value.__def) return (...a: any[]) => value(self, ...a);
if (typeof value === "function" && (value.__def || value.__method)) return (...a: any[]) => value(self, ...a);
return value;
}
@@ -828,6 +974,25 @@ export function userBuiltins(py: Python): Record<string, any> {
};
const less = (a: any, b: any) => (a < b ? -1 : a > b ? 1 : 0);
return {
...Object.fromEntries(EXCEPTIONS),
int: (x: any = 0, base?: number) => {
if (typeof x === "string") {
const text = x.trim();
const ok = base ? new RegExp(`^[+-]?[0-9a-z]+$`, "i").test(text) : /^[+-]?\d+$/.test(text);
const n = ok ? parseInt(text, base ?? 10) : NaN;
if (Number.isNaN(n)) py.raise("ValueError", `недопустимое значение для int(): '${x}'`);
return n;
}
const n = Math.trunc(Number(x));
if (!Number.isFinite(n)) py.raise("ValueError", "недопустимое значение для int()");
return n;
},
float: (x: any = 0) => {
const n = typeof x === "string" ? Number(x.trim()) : Number(x);
if (Number.isNaN(n) || (typeof x === "string" && x.trim() === ""))
py.raise("ValueError", `недопустимое значение для float(): '${x}'`);
return n;
},
object: OBJECT,
python_object: OBJECT,
NoRollback: OBJECT,
+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}%`);
+3
View File
@@ -51,6 +51,8 @@ export interface AtSpec {
pos?: string;
name?: string;
props?: Record<string, any>;
/** Animated transform: a name from GameData.atl or an inline timeline. */
atl?: string | import("./atl").AtlBlock;
effect?: "shake";
d?: number;
dist?: number;
@@ -106,6 +108,7 @@ export interface GameData {
characters: Record<string, CharacterDef>;
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
atl?: Record<string, import("./atl").AtlBlock>;
transitions: Record<string, Transition | null>;
audio: Record<string, string>;
channels?: Record<string, { mixer: string; loop: boolean }>;