// 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> = { 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 | null; atl(name: string): AtlBlock | null; /** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */ evalExpr(expr: any, frame?: Map): 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(); 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, 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, vars: Map, 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 = {}; 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 { const p: Record = { ...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) { 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 { 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) { 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((r) => setTimeout(r, seconds * 1000)); } function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise { return new Promise((resolve) => { const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" }); a.onfinish = () => resolve(); a.oncancel = () => resolve(); }); }