ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
finite repeat, time, function f(trans, st, at), calls of other transforms with
arguments, on hover/idle events for buttons. Animations stop when their element
leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped
Colour
- matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG
filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values
Python
- match statement, re/copy/time/datetime/defaultdict/functools.partial, more
str/dict methods, Character(...) created by code
- narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in
Python blocks become script nodes
- Audit follows reachability: functions and classes nothing refers to (custom
statement lexers, unused tools) no longer block an import
Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and
QSP planned). Tests for the new runtime pieces.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
489 lines
19 KiB
TypeScript
489 lines
19 KiB
TypeScript
// 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, bindArguments, type AtlBlock } from "./atl";
|
|
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
|
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
|
|
|
const POSITIONS: Record<string, Record<string, number>> = {
|
|
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
|
|
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
|
|
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
|
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
|
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
|
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
|
|
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
|
|
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
|
|
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
|
|
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
|
|
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
|
|
};
|
|
|
|
export interface ResolvedImage {
|
|
def: ImageDef;
|
|
name: string[];
|
|
}
|
|
|
|
export interface RendererHost {
|
|
url(file: string): string | null;
|
|
resolveRef(name: string): ImageDef | null;
|
|
evalCond(e: any): boolean;
|
|
transform(name: string): Record<string, any> | null;
|
|
atl(name: string): AtlBlock | null;
|
|
/** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */
|
|
evalExpr(expr: any, frame?: Map<string, any>): any;
|
|
text(e: any): string;
|
|
}
|
|
|
|
interface Entry extends ShownImage {
|
|
el: HTMLElement;
|
|
runners: { runner: AtlRunner; block: AtlBlock }[];
|
|
}
|
|
|
|
export class Renderer {
|
|
readonly stage: HTMLDivElement;
|
|
readonly master: HTMLDivElement;
|
|
private overlay: HTMLDivElement;
|
|
private shown = new Map<string, Entry>();
|
|
private committed: HTMLElement | null = null;
|
|
fast = false;
|
|
|
|
constructor(parent: HTMLElement, public width: number, public height: number,
|
|
private host: RendererHost) {
|
|
this.stage = document.createElement("div");
|
|
this.stage.className = "pg-stage";
|
|
this.stage.style.width = `${width}px`;
|
|
this.stage.style.height = `${height}px`;
|
|
this.master = document.createElement("div");
|
|
this.master.className = "pg-layer";
|
|
this.overlay = document.createElement("div");
|
|
this.overlay.className = "pg-layer pg-transition";
|
|
this.stage.append(this.master, this.overlay);
|
|
parent.append(this.stage);
|
|
}
|
|
|
|
fit(container: HTMLElement) {
|
|
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
|
|
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
|
|
}
|
|
|
|
// --- scene state ---------------------------------------------------------
|
|
|
|
scene() {
|
|
for (const e of this.shown.values()) {
|
|
e.runners.forEach((r) => r.runner.stop());
|
|
e.el.remove();
|
|
}
|
|
this.shown.clear();
|
|
}
|
|
|
|
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
|
|
const prev = this.shown.get(tag);
|
|
const atSpecs = at.length ? at : prev?.at ?? [];
|
|
const el = this.build(img.def);
|
|
el.classList.add("pg-image");
|
|
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 {
|
|
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
|
|
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
|
|
this.master.insertBefore(el, behind ?? later ?? null);
|
|
}
|
|
this.shown.set(tag, entry);
|
|
this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline);
|
|
}
|
|
|
|
/** 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[], timeline?: AtlBlock) {
|
|
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) this.startTimeline(entry.el, block, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)), entry.runners);
|
|
}
|
|
if (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners);
|
|
}
|
|
|
|
hide(tag: string) {
|
|
const e = this.shown.get(tag);
|
|
if (!e) return;
|
|
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) {
|
|
return this.shown.has(tag);
|
|
}
|
|
|
|
attrsOf(tag: string): string[] | null {
|
|
return this.shown.get(tag)?.name ?? null;
|
|
}
|
|
|
|
snapshot(): ShownImage[] {
|
|
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
|
|
}
|
|
|
|
// --- displayables ------------------------------------------------------------
|
|
|
|
private build(def: ImageDef, depth = 0): HTMLElement {
|
|
if (depth > 8) return document.createElement("div");
|
|
if (def.ref) {
|
|
const ref = this.host.resolveRef(def.ref);
|
|
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
|
|
}
|
|
if (def.expr) {
|
|
// an image defined by a Python expression: its value names a picture, evaluated when shown
|
|
const value = this.host.evalExpr(def.expr);
|
|
if (typeof value === "string") {
|
|
const ref = this.host.resolveRef(value);
|
|
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
|
return this.build(ref ?? color ?? { file: value }, depth + 1);
|
|
}
|
|
return document.createElement("div");
|
|
}
|
|
if (def.switch) {
|
|
const hit = def.switch.find(([c]) => this.host.evalCond(c));
|
|
if (hit) return this.build(hit[1], depth + 1);
|
|
}
|
|
if (def.color) {
|
|
const d = document.createElement("div");
|
|
d.style.background = def.color;
|
|
d.style.width = `${this.width}px`;
|
|
d.style.height = `${this.height}px`;
|
|
return d;
|
|
}
|
|
if (def.composite) {
|
|
const d = document.createElement("div");
|
|
d.style.position = "relative";
|
|
if (def.size) {
|
|
d.style.width = `${def.size[0]}px`;
|
|
d.style.height = `${def.size[1]}px`;
|
|
}
|
|
for (const layer of def.composite) {
|
|
const child = this.build(layer.img, depth + 1);
|
|
child.style.position = "absolute";
|
|
child.style.left = `${layer.pos[0]}px`;
|
|
child.style.top = `${layer.pos[1]}px`;
|
|
d.append(child);
|
|
}
|
|
return d;
|
|
}
|
|
if (def.movie) {
|
|
const v = document.createElement("video");
|
|
const src = this.host.url(def.movie);
|
|
if (src) v.src = src;
|
|
v.autoplay = true;
|
|
v.loop = true;
|
|
v.muted = true;
|
|
v.playsInline = true;
|
|
return v;
|
|
}
|
|
if (def.text !== undefined || def.textExpr) {
|
|
const d = document.createElement("div");
|
|
d.className = "pg-show-text";
|
|
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
|
|
return d;
|
|
}
|
|
if (def.timeline) return this.timelineBox(def, depth);
|
|
if (def.frames?.length) return this.animated(def, depth);
|
|
if (def.layered) return this.layered(def, depth);
|
|
if (def.file) {
|
|
const img = document.createElement("img");
|
|
img.draggable = false;
|
|
img.decoding = "async";
|
|
const src = this.host.url(def.file);
|
|
if (src) img.src = src;
|
|
else console.warn("[pgvn] нет файла", def.file);
|
|
if (def.props?.size) {
|
|
img.style.width = `${def.props.size[0]}px`;
|
|
img.style.height = `${def.props.size[1]}px`;
|
|
}
|
|
return img;
|
|
}
|
|
return document.createElement("div");
|
|
}
|
|
|
|
/** Container of an ATL timeline that switches pictures; the first one is shown at once. */
|
|
private timelineBox(def: ImageDef, depth: number): HTMLElement {
|
|
const box = document.createElement("div");
|
|
box.style.display = "grid";
|
|
box.dataset.timeline = "1";
|
|
const first = firstPicture(def.timeline!);
|
|
if (first) {
|
|
const child = this.build(first, depth + 1);
|
|
child.style.gridArea = "1 / 1";
|
|
box.append(child);
|
|
}
|
|
return box;
|
|
}
|
|
|
|
/** Replace the picture shown in an animated element, optionally with a cross-fade. */
|
|
private swap(box: HTMLElement, def: ImageDef, transition: Transition | null | undefined) {
|
|
const child = this.build(def);
|
|
child.style.gridArea = "1 / 1";
|
|
const old = [...box.children];
|
|
if (!transition || this.fast) {
|
|
box.replaceChildren(child);
|
|
return;
|
|
}
|
|
child.style.opacity = "0";
|
|
box.append(child);
|
|
child.animate([{ opacity: 0 }, { opacity: 1 }], { duration: (transition.d ?? 0.5) * 1000, fill: "forwards" })
|
|
.onfinish = () => {
|
|
child.style.opacity = "";
|
|
old.forEach((e) => e.remove());
|
|
};
|
|
}
|
|
|
|
/** Run a timeline on `el`; its properties are applied with the same styling as any shown image. */
|
|
private startTimeline(el: HTMLElement, block: AtlBlock, base: Record<string, any>,
|
|
vars: Map<string, any>, runners: Entry["runners"]) {
|
|
const live = base;
|
|
const runner = new AtlRunner({
|
|
block: (name) => this.host.atl(name),
|
|
props: (name) => this.host.transform(name),
|
|
apply: (p) => this.style(el, { ...p }),
|
|
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
|
image: (img, transition) => this.swap(el, img, transition),
|
|
contains: (img) => {
|
|
const child = this.build(img);
|
|
child.style.position = "absolute";
|
|
child.style.left = child.style.top = "0";
|
|
el.append(child);
|
|
if (img.timeline) this.startTimeline(child, img.timeline, {}, new Map(), runners);
|
|
},
|
|
}, live, vars);
|
|
runner.watch = el;
|
|
runners.push({ runner, block });
|
|
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
|
|
}
|
|
|
|
/** An element for an image definition, for UI built outside the scene (screens). */
|
|
displayable(def: ImageDef): HTMLElement {
|
|
return this.build(def);
|
|
}
|
|
|
|
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
|
|
private animated(def: ImageDef, depth: number): HTMLElement {
|
|
const box = document.createElement("div");
|
|
box.style.position = "relative";
|
|
const frames = def.frames!;
|
|
let i = 0;
|
|
let started = false;
|
|
box.append(this.build(frames[0].img, depth + 1));
|
|
const tick = () => {
|
|
if (box.isConnected) started = true;
|
|
else if (started) return;
|
|
const d = frames[i].d ?? 0;
|
|
const last = i === frames.length - 1;
|
|
if (!d || (last && !def.repeat)) return;
|
|
window.setTimeout(() => {
|
|
if (started && !box.isConnected) return;
|
|
i = last ? 0 : i + 1;
|
|
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
|
tick();
|
|
}, d * 1000);
|
|
};
|
|
tick();
|
|
return box;
|
|
}
|
|
|
|
/** layeredimage: stack the layers selected by the image's attributes. */
|
|
private layered(def: ImageDef, depth: number): HTMLElement {
|
|
const box = document.createElement("div");
|
|
box.style.position = "relative";
|
|
const sel = new Set(def.selected ?? []);
|
|
const groupsSelected = new Set(
|
|
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
|
|
const imgs: ImageDef[] = [];
|
|
for (const layer of def.layered!) {
|
|
if (layer.always) imgs.push(layer.always);
|
|
else if (layer.cond) {
|
|
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
|
|
if (hit) imgs.push(hit[1]);
|
|
} else if (layer.attr !== undefined) {
|
|
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
|
|
if (!on || !layer.img || layer.null) continue;
|
|
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
|
|
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
|
|
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
|
|
imgs.push(layer.img);
|
|
}
|
|
}
|
|
imgs.forEach((img, n) => {
|
|
const child = this.build(img, depth + 1);
|
|
if (n > 0) {
|
|
child.style.position = "absolute";
|
|
child.style.left = "0";
|
|
child.style.top = "0";
|
|
}
|
|
box.append(child);
|
|
});
|
|
return box;
|
|
}
|
|
|
|
camera(at: AtSpec[]) {
|
|
const p: Record<string, any> = {};
|
|
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
|
|
const zoom = p.zoom ?? 1;
|
|
this.master.style.transformOrigin = "50% 50%";
|
|
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
|
|
this.cameraAt = at;
|
|
}
|
|
|
|
cameraAt: 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") {
|
|
const dist = spec.dist ?? 20;
|
|
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
|
|
{ duration: (spec.d ?? 0.5) * 1000 });
|
|
continue;
|
|
}
|
|
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
|
|
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}%`);
|
|
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
|
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
|
const ax = anchor(p.xanchor);
|
|
const ay = anchor(p.yanchor);
|
|
el.style.transformOrigin = `${ax} ${ay}`;
|
|
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
|
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
|
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
|
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
|
|
if (ax.endsWith("px") || ay.endsWith("px"))
|
|
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
|
|
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
|
const filters: string[] = [];
|
|
if (p.filter) filters.push(`${p.filter}(1)`);
|
|
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`);
|
|
if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor));
|
|
if (filters.length) el.style.filter = filters.join(" ");
|
|
}
|
|
|
|
// --- transitions ---------------------------------------------------------------
|
|
|
|
/** Remember the current picture as the starting point of the next transition. */
|
|
commit() {
|
|
this.committed = this.master.cloneNode(true) as HTMLElement;
|
|
}
|
|
|
|
async transition(t: Transition | null | undefined): Promise<void> {
|
|
const before = this.committed;
|
|
this.commit();
|
|
if (!t || !before || this.fast) return;
|
|
if (t.t === "punch") return this.punch(t);
|
|
const old = before;
|
|
old.classList.add("pg-old");
|
|
this.overlay.append(old);
|
|
try {
|
|
if (t.t === "fade") {
|
|
const color = document.createElement("div");
|
|
color.className = "pg-fill";
|
|
color.style.background = t.color ?? "#000";
|
|
color.style.opacity = "0";
|
|
this.overlay.append(color);
|
|
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
|
|
old.remove();
|
|
if (t.hold) await sleep(t.hold);
|
|
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
|
|
color.remove();
|
|
} else if (t.t === "wipe") {
|
|
const dir = t.dir ?? "right";
|
|
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
|
|
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
|
|
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
|
|
} else if (t.t === "slide") {
|
|
const dir = t.dir ?? "left";
|
|
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
|
|
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
|
|
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
|
|
} else {
|
|
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
|
|
}
|
|
} finally {
|
|
old.remove();
|
|
}
|
|
}
|
|
|
|
private async punch(t: Transition) {
|
|
const axis = t.dir === "h" ? "X" : "Y";
|
|
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
|
|
t.d ?? 0.3);
|
|
}
|
|
|
|
preload(def: ImageDef | null) {
|
|
if (def?.file) {
|
|
const src = this.host.url(def.file);
|
|
if (src) new Image().src = src;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The first picture an ATL timeline shows (what is on screen before it starts). */
|
|
function firstPicture(block: AtlBlock): ImageDef | null {
|
|
for (const step of block.steps) {
|
|
if (step.op === "image") return step.img;
|
|
if (step.op === "parallel") for (const b of step.blocks) { const f = firstPicture(b); if (f) return f; }
|
|
if (step.op === "block") { const f = firstPicture(step.block); if (f) return f; }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalize(p: Record<string, any>) {
|
|
if (p.align) [p.xalign, p.yalign] = p.align;
|
|
if (p.pos) [p.xpos, p.ypos] = p.pos;
|
|
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
|
|
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
|
|
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
|
|
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
|
|
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
|
|
p.xanchor = asFloat(p.xanchor);
|
|
p.yanchor = asFloat(p.yanchor);
|
|
p.xpos = asFloat(p.xpos);
|
|
p.ypos = asFloat(p.ypos);
|
|
}
|
|
|
|
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
|
|
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
|
|
function asFloat(v: any): number {
|
|
return typeof v === "number" ? v : 0;
|
|
}
|
|
|
|
export function sleep(seconds: number) {
|
|
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
|
|
}
|
|
|
|
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
|
|
a.onfinish = () => resolve();
|
|
a.oncancel = () => resolve();
|
|
});
|
|
}
|