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,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;
|
||||
}
|
||||
Reference in new issue
Block a user