// Ren'Py screens (see backend/app/converter/screens.py): built as DOM from the IR, re-built // whenever game state may have changed. Expressions run in the same Python sandbox as the script. import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix"; import type { AudioSystem } from "./audio"; import { isNativeAction } from "./musicroom"; import { galleryClass } from "./gallery"; import { parseExpression } from "./pyparse"; import { movieDisplayable } from "./movie"; import { strftime } from "./pymodules"; import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python"; import type { AtSpec, Expr } from "./types"; export interface ScreenDef { params: { n: string; d: Expr | null; kind: string }[]; props: Record; defaults: [string, Expr][]; body: any[]; } export interface StyleDef { is: string | null; props: Record; } export interface ScreenHost { py: Python; fileUrl(path: string): string | null; /** Element for an image name, file path or colour. */ image(value: string): HTMLElement | null; /** Fill `el` with rich text (markup, interpolation, translation). */ text(el: HTMLElement, value: string, size: number): void; fontFamily(file: string): string | null; baseSize: number; play(channel: string, file: string, loop: boolean): void; stop(channel: string): void; notify(message: string): void; jump(label: string): void; call(label: string): void; quit(): void; mainMenu(): void; openMenu(): void; openUrl(url: string): void; quickSave(): void; quickLoad(): void; /** Slot metadata for File* actions; null when empty or not yet read. */ slotInfo(slot: string): { title: string; time: string } | null; saveSlot(slot: string, confirm: boolean): void; loadSlot(slot: string): void; deleteSlot(slot: string, confirm: boolean): void; /** The page of the save/load screen (`persistent._file_page`). */ filePage: string; startReplay(label: string, scope: Record | null): void; endReplay(): void; setLanguage(code: string | null): void; transform(name: string): Record | null; atl(name: string): AtlBlock | null; audio: AudioSystem; /** Set the volume (0..1) of a mixer: "music", "sfx" or "voice". */ setMixer(name: string, value: number): void; error?(message: string): void; } type Props = Record; class ActionAbort extends Error {} /** A screen action such as Jump("x"); run when the player activates a widget. */ export class ScreenAction { /** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */ __call__: () => null = () => null; constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {} } /** A displayable built by game code: Crop(...), Solid(...), Frame(...), Transform(...). */ export class Displayable { constructor(public kind: string, public args: any[], public kw: Props) {} } export class BarValue { /** Changes by itself (a playing position): the screen redraws it while it is shown. */ live = false; constructor(public value: () => number, public range: () => number, public set?: (v: number) => void) {} } interface Instance { name: string; def: ScreenDef; args: any[]; kw: Props; frame: Map; vars: Map; order: number; onReturn?: (value: any) => void; call: boolean; el: HTMLElement; timers: number[]; keys: ((ev: KeyboardEvent) => void)[]; runners: AtlRunner[]; /** Timers survive re-evaluation; they are keyed by their node in the screen definition. */ clocks: Map; clocksSeen: Set; /** Final properties of animations that already ran to the end (they must not replay). */ finished: Map; /** `on "show"` handlers already run, and `on "hide"` handlers waiting for the screen to go. */ shownEvents: Set; hideActions: any[]; } interface Ctx { /** How the parent places this child. */ layout: "abs" | "stack" | "col" | "row" | "grid" | "side"; prefix: string; inst: Instance; } const PX_UNIT = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`); function num(v: any): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } export class ScreenManager { readonly layer: HTMLDivElement; private shown = new Map(); private counter = 0; private pending = 0; private rendering: Instance | null = null; private interactionReturn: ((value: any) => void) | null = null; private suspended = false; private galleryClose: (() => void) | null = null; tooltip: any = ""; constructor(private host: ScreenHost, private screens: Record, private styles: Record) { this.layer = document.createElement("div"); this.layer.className = "pg-usr"; // A click needs its button to stay in the page between press and release; a redraw in between would lose it window.addEventListener("pointerdown", this.onPress, true); window.addEventListener("pointerup", this.onRelease, true); window.addEventListener("pointercancel", this.onRelease, true); } private pressed = false; private refreshAfterPress = false; private onPress = () => { this.pressed = true; }; private onRelease = () => { this.pressed = false; // after the click handlers of this release have run window.setTimeout(() => { if (this.refreshAfterPress && !this.pressed) { this.refreshAfterPress = false; this.refresh(); } }, 0); }; /** Videos survive a redraw of their screen: a new element would restart the picture. */ private movies = new Map(); private moviesSeen = new Set(); private movieCount = new Map(); private cachedMovie(key: string, make: () => HTMLElement): HTMLElement { const n = (this.movieCount.get(key) ?? 0) + 1; this.movieCount.set(key, n); const id = `${this.rendering?.name ?? ""}|${key}|${n}`; let element = this.movies.get(id); if (!element) { element = make(); this.movies.set(id, element); } this.moviesSeen.add(id); return element; } has(name: string) { return name in this.screens; } /** ui.interact waits on Return from an already shown screen, without hiding it. */ interact(done: (value: any) => void): () => void { if (this.interactionReturn) throw new Error("Уже выполняется ui.interact"); const answer = (value: any) => { if (this.interactionReturn !== answer) return; this.interactionReturn = null; done(value); }; this.interactionReturn = answer; return () => { if (this.interactionReturn === answer) this.interactionReturn = null; }; } suspend(): () => void { const previous = this.suspended; this.suspended = true; this.layer.style.visibility = "hidden"; return () => { this.suspended = previous; this.layer.style.visibility = previous ? "hidden" : ""; }; } // --- lifecycle ------------------------------------------------------------------- show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) { const def = this.screens[name]; if (!def) throw new Error(`Не найден screen ${name}`); const previous = this.shown.get(name); if (previous) this.dispose(previous, false); const inst: Instance = { name, def, args, kw, frame: new Map(), vars: previous?.vars ?? new Map(), order: previous?.order ?? ++this.counter, onReturn: opts.onReturn, call: !!opts.call, el: document.createElement("div"), timers: [], keys: [], runners: [], clocks: new Map(), clocksSeen: new Set(), finished: previous?.finished ?? new Map(), shownEvents: previous?.shownEvents ?? new Set(), hideActions: [], }; inst.el.className = "pg-sinst"; this.shown.set(name, inst); this.layer.append(inst.el); this.bindParameters(inst); if (!previous) this.runInside(inst, () => { for (const [variable, expr] of def.defaults) inst.vars.set(variable, this.host.py.eval(expr)); }); this.render(inst); // `tag`: showing a screen replaces the other one with the same tag const tag = this.tagOf(inst); if (tag) for (const other of [...this.shown.values()]) if (other !== inst && this.tagOf(other) === tag) this.hide(other.name); } hide(name: string) { const inst = this.shown.get(name); if (inst) this.dispose(inst, true); } hideCall(name: string) { const inst = this.shown.get(name); if (inst?.call) this.dispose(inst, true); } /** Called when the script restarts: screens waiting for a return value have no one to answer. */ hideCalls() { for (const inst of [...this.shown.values()]) if (inst.call) this.dispose(inst, true); } clear() { this.suspended = false; this.layer.style.visibility = ""; this.interactionReturn = null; this.galleryClose?.(); for (const inst of [...this.shown.values()]) this.dispose(inst, true); this.tooltip = ""; } destroy() { window.removeEventListener("pointerdown", this.onPress, true); window.removeEventListener("pointerup", this.onRelease, true); window.removeEventListener("pointercancel", this.onRelease, true); this.clear(); this.layer.remove(); } snapshot(): { name: string; args: any[]; kw: Props }[] { return [...this.shown.values()].filter((i) => !i.call).sort((a, b) => a.order - b.order) .map((i) => ({ name: i.name, args: i.args, kw: i.kw })); } restore(list: { name: string; args: any[]; kw: Props }[]) { this.clear(); for (const item of list) if (this.screens[item.name]) this.show(item.name, item.args, item.kw); } private dispose(inst: Instance, forget: boolean) { if (forget) for (const action of inst.hideActions) this.run(action, inst); inst.hideActions = []; inst.timers.forEach((t) => window.clearTimeout(t)); inst.timers = []; inst.clocks.forEach((clock) => window.clearTimeout(clock.handle)); inst.clocks.clear(); inst.keys.forEach((k) => window.removeEventListener("keydown", k)); inst.keys = []; inst.runners.forEach((r) => r.stop()); inst.runners = []; inst.el.remove(); for (const id of [...this.movies.keys()]) if (id.startsWith(`${inst.name}|`)) this.movies.delete(id); if (forget) this.shown.delete(inst.name); } /** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */ refresh() { if (this.suspended || this.pending || !this.shown.size) return; if (this.pressed) { this.refreshAfterPress = true; return; } this.pending = requestAnimationFrame(() => { this.pending = 0; if (this.suspended) return; for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst); }); } // --- evaluation -------------------------------------------------------------------- private bindParameters(inst: Instance) { const { def, args, kw } = inst; inst.frame.clear(); def.params.forEach((p, i) => { if (p.kind === "p" || p.kind === "k") { if (Object.hasOwn(kw, p.n)) inst.frame.set(p.n, kw[p.n]); else if (p.kind === "p" && i < args.length) inst.frame.set(p.n, args[i]); else inst.frame.set(p.n, p.d ? this.runInside(inst, () => this.host.py.eval(p.d)) : null); } else if (p.kind === "*") inst.frame.set(p.n, args.slice(def.params.filter((q) => q.kind === "p").length)); else if (p.kind === "**") inst.frame.set(p.n, new PyDict(Object.entries(kw))); }); } /** Run `fn` with the screen's variables visible to Python expressions. */ private runInside(inst: Instance, fn: () => T): T { const py = this.host.py; const savedLocals = py.locals; const savedFn = py.fn; const savedRendering = this.rendering; py.locals = [...savedLocals, inst.frame, inst.vars]; py.fn = { vars: inst.vars, globals: new Set(), nonlocals: new Set(), isClassBody: false, index: 0 }; this.rendering = inst; try { return fn(); } finally { py.locals = savedLocals; py.fn = savedFn; this.rendering = savedRendering; } } private ev(expr: Expr | null | undefined): any { if (!expr) return undefined; try { return this.host.py.eval(expr); } catch (err) { this.host.py.warnOnce(`screen: ${(err as Error).message}`); return undefined; } } /** Evaluate with extra variables (an ATL transform's parameters) visible. */ private evalWith(expr: Expr, frame: Map): any { const { py } = this.host; const saved = py.locals; py.locals = [...saved, frame]; try { return this.ev(expr); } finally { py.locals = saved; } } private tagOf(inst: Instance): string { return this.runInside(inst, () => { const t = inst.def.props.tag; return t ? (t.k === "name" ? t.id : pyStr(this.ev(t))) : ""; }); } // --- rendering ----------------------------------------------------------------------- private render(inst: Instance) { this.moviesSeen.clear(); this.movieCount.clear(); const previousScroll = [...inst.el.querySelectorAll(".pg-sviewport")].map((e) => e.scrollTop); inst.timers.forEach((t) => window.clearTimeout(t)); inst.timers = []; inst.keys.forEach((k) => window.removeEventListener("keydown", k)); inst.keys = []; inst.runners.forEach((r) => r.stop()); inst.runners = []; inst.clocksSeen.clear(); inst.hideActions = []; this.runInside(inst, () => { const props = inst.def.props; const zorder = num(this.ev(props.zorder)) ?? 0; inst.el.style.zIndex = String(zorder * 100 + inst.order); inst.el.classList.toggle("pg-smodal", truthy(this.ev(props.modal))); const prefix = props.style_prefix ? pyStr(this.ev(props.style_prefix)) : ""; const ctx: Ctx = { layout: "abs", prefix, inst }; const children: HTMLElement[] = []; this.nodes(inst.def.body, ctx, children); inst.el.replaceChildren(...children); }); // a video taken out of the page pauses by itself; carry on, and forget the ones no screen shows now for (const [id, element] of [...this.movies]) { if (this.moviesSeen.has(id)) { const video = element instanceof HTMLVideoElement ? element : element.querySelector("video"); if (video && video.paused && !video.ended) void video.play().catch(() => {}); } else if (id.startsWith(`${inst.name}|`)) { if (element instanceof HTMLVideoElement) element.pause(); this.movies.delete(id); } } for (const [node, clock] of [...inst.clocks]) { if (!inst.clocksSeen.has(node)) { window.clearTimeout(clock.handle); inst.clocks.delete(node); } } [...inst.el.querySelectorAll(".pg-sviewport")].forEach((e, i) => { e.scrollTop = previousScroll[i] ?? 0; }); } private nodes(list: any[], ctx: Ctx, out: HTMLElement[]) { for (const node of list) { if (node.w) { const built = this.widget(node, ctx); if (built) out.push(built); } else if (node.c === "if") { const branch = node.branches.find((b: any) => b.c === null || truthy(this.ev(b.c))); if (branch) this.nodes(branch.body, ctx, out); } else if (node.c === "for") { const items = [...iter(this.ev(node.it))]; const frame = new Map(); const { py } = this.host; py.locals = [...py.locals, frame]; try { for (const item of items) { py.bind(node.t, item, frame); this.nodes(node.body, ctx, out); } } finally { py.locals = py.locals.slice(0, -1); } } else if (node.c === "use") { this.use(node, ctx, out); } else if (node.c === "python") { // `renpy.ui.imagebutton(...)` called from game code adds its widget to this screen const outer = this.uiTarget; this.uiTarget = { out, ctx }; try { this.host.py.exec(node.code); } catch (err) { this.host.py.warnOnce(`screen python: ${(err as Error).message}`); } finally { this.uiTarget = outer; } } else if (node.c === "key") { this.keyBinding(node, ctx); } else if (node.c === "timer") { this.timer(node, ctx); } else if (node.c === "transclude") { this.transclude(ctx, out); } else if (node.c === "on") { this.event(node, ctx); } } } private use(node: any, ctx: Ctx, out: HTMLElement[]) { const name = node.nameExpr ? pyStr(this.ev(node.nameExpr)) : node.name; const def = this.screens[name]; if (!def) return; const args = node.args.map((a: Expr) => this.ev(a)); const kw = Object.fromEntries(Object.entries(node.kw).map(([k, v]) => [k, this.ev(v as Expr)])); for (const spread of node.ks ?? []) { const values = this.ev(spread); if (values && typeof values === "object") Object.assign(kw, values instanceof Map ? Object.fromEntries(values) : values); } const frame = new Map(); def.params.forEach((p, i) => { if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]); else if (i < args.length) frame.set(p.n, args[i]); else frame.set(p.n, p.d ? this.ev(p.d) : null); }); const { py } = this.host; const saved = py.locals; if (node.body?.length) this.transclusions.push({ nodes: node.body, locals: [...saved], ctx }); py.locals = [...saved, frame]; try { const prefix = def.props.style_prefix ? pyStr(this.ev(def.props.style_prefix)) : ctx.prefix; for (const [variable, expr] of def.defaults) if (!frame.has(variable)) frame.set(variable, this.ev(expr)); this.nodes(def.body, { ...ctx, prefix }, out); } finally { py.locals = saved; if (node.body?.length) this.transclusions.pop(); } } private transclusions: { nodes: any[]; locals: Map[]; ctx: Ctx }[] = []; /** `transclude`: the children given to `use name:` are drawn here, in the caller's scope. */ private transclude(ctx: Ctx, out: HTMLElement[]) { const entry = this.transclusions[this.transclusions.length - 1]; if (!entry) return; const { py } = this.host; const saved = py.locals; const pending = this.transclusions.pop(); py.locals = entry.locals; try { this.nodes(entry.nodes, { ...ctx, prefix: entry.ctx.prefix }, out); } finally { py.locals = saved; if (pending) this.transclusions.push(pending); } } private event(node: any, ctx: Ctx) { const inst = ctx.inst; if (node.event === "show" || node.event === "replace") { if (inst.shownEvents.has(node)) return; inst.shownEvents.add(node); const action = this.ev(node.action); queueMicrotask(() => this.run(action, inst)); } else { inst.hideActions.push(this.ev(node.action)); } } private keyBinding(node: any, ctx: Ctx) { const wanted = pyStr(this.ev(node.key)); const handler = (ev: KeyboardEvent) => { if (ev.target instanceof HTMLInputElement) return; if (!matchesKey(wanted, ev)) return; ev.preventDefault(); this.run(this.ev(node.action), ctx.inst); }; window.addEventListener("keydown", handler); ctx.inst.keys.push(handler); } private timer(node: any, ctx: Ctx) { const inst = ctx.inst; inst.clocksSeen.add(node); if (inst.clocks.has(node)) return; // already running (or finished): re-rendering must not restart it const seconds = num(this.ev(node.delay)); if (seconds === null) return; const repeat = truthy(this.ev(node.repeat)); const clock = { handle: 0, finished: false }; const fire = () => { if (this.shown.get(inst.name) !== inst) return; // the action is evaluated now, with the state the screen has at this moment this.runInside(inst, () => this.run(this.ev(node.action), inst)); if (repeat && this.shown.get(inst.name) === inst) clock.handle = window.setTimeout(fire, seconds * 1000); else clock.finished = true; }; clock.handle = window.setTimeout(fire, seconds * 1000); inst.clocks.set(node, clock); } // --- styles -------------------------------------------------------------------------- /** Properties of a named style, parents first; values are still expressions. */ private styleProps(name: string, seen = new Set()): Record { const style = this.styles[name]; if (!style || seen.has(name)) return {}; seen.add(name); return { ...(style.is ? this.styleProps(style.is, seen) : {}), ...style.props }; } /** Node properties on top of the styles that apply to this kind of widget. */ private resolve(node: any, ctx: Ctx, styleKinds: string[], own?: Props): Record { const merged: Record = {}; for (const kind of styleKinds) Object.assign(merged, this.styleProps(kind)); if (ctx.prefix) for (const kind of styleKinds) Object.assign(merged, this.styleProps(`${ctx.prefix}_${kind}`)); const explicit = node.props.style ? pyStr(this.ev(node.props.style)) : ""; if (explicit) Object.assign(merged, this.styleProps(explicit)); Object.assign(merged, node.props); // `if` inside the widget that only sets properties for (const group of (node.propIf ?? []) as { c: Expr | null; props: Record }[][]) { const branch = group.find((b) => b.c === null || truthy(this.ev(b.c))); if (branch) Object.assign(merged, branch.props); } return own ? { ...merged, ...own } : merged; } private pick(props: Record, name: string, state = "idle"): any { const order = state === "idle" ? ["idle", ""] : state === "hover" ? ["hover", ""] : state === "insensitive" ? ["insensitive", ""] : state === "selected_idle" ? ["selected_idle", "selected", "idle", ""] : state === "selected_hover" ? ["selected_hover", "selected", "hover", ""] : ["selected_insensitive", "insensitive", ""]; for (const prefix of order) { const key = prefix ? `${prefix}_${name}` : name; if (key in props) return this.ev(props[key]); } return undefined; } // --- widgets ------------------------------------------------------------------------- private widget(node: any, ctx: Ctx): HTMLElement | null { switch (node.w) { case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true); case "frame": case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]); case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]); case "hbox": return this.container(node, ctx, "div", "row", ["hbox"]); case "grid": return this.grid(node, ctx); case "viewport": return this.viewport(node, ctx); case "vpgrid": return this.vpgrid(node, ctx); case "side": return this.container(node, ctx, "div", "side", ["side", "fixed"]); case "nearrect": return this.nearRect(node, ctx); case "button": return this.button(node, ctx, "div", ["button"], null); case "textbutton": return this.button(node, ctx, "div", ["button"], this.ev(node.args[0])); case "imagebutton": return this.imageButton(node, ctx); case "text": return this.text(node, ctx, ["text"], this.ev(node.args[0])); case "label": return this.text(node, ctx, ["text", "label_text"], this.ev(node.args[0]), "pg-slabel"); case "add": return this.add(node, ctx); case "null": return this.nullWidget(node, ctx); case "mousearea": return this.mouseArea(node, ctx); case "bar": case "vbar": return this.bar(node, ctx); } return null; } private container(node: any, ctx: Ctx, tag: string, layout: Ctx["layout"], kinds: string[], fill = false): HTMLElement { const el = document.createElement(tag); const props = this.resolve(node, ctx, kinds); el.className = `pg-sw pg-s${node.w}`; const style = el.style; if (layout === "abs") style.position = "relative"; if (layout === "col" || layout === "row") { style.display = "flex"; style.flexDirection = layout === "col" ? "column" : "row"; const gap = num(this.pick(props, "spacing")); if (gap !== null) style.gap = `${gap}px`; if (truthy(this.pick(props, "box_reverse"))) style.flexDirection += "-reverse"; if (truthy(this.pick(props, "box_wrap"))) { style.flexWrap = "wrap"; // between the lines of a wrapped box (`spacing` is between the items of one line) const wrapGap = num(this.pick(props, "box_wrap_spacing")); if (wrapGap !== null) style[layout === "col" ? "columnGap" : "rowGap"] = `${wrapGap}px`; } } else if (layout === "stack") { style.display = "grid"; } else if (layout === "side") { // `side "c tl r"`: the children take these places; the centre gets what the edges leave style.display = "grid"; style.gridTemplateColumns = "auto 1fr auto"; style.gridTemplateRows = "auto 1fr auto"; } else if (layout === "grid") { style.display = "grid"; const cols = num(this.pick(props, "cols")); const rows = num(this.pick(props, "rows")); if (cols) style.gridTemplateColumns = `repeat(${cols}, auto)`; else if (rows) style.gridAutoFlow = "column"; if (rows) style.gridTemplateRows = `repeat(${rows}, auto)`; const gap = num(this.pick(props, "spacing")); if (gap !== null) style.gap = `${gap}px`; } this.layout(el, props, ctx, { fill: fill && !("xsize" in props || "xysize" in props) }); this.look(el, props, "idle"); const inner: Ctx = { ...ctx, layout }; const children: HTMLElement[] = []; this.nodes(node.body, inner, children); const places = layout === "side" ? pyStr(this.ev(node.args[0]) ?? "").split(/\s+/).filter(Boolean) : []; const SIDE_CELLS: Record = { tl: "1 / 1", t: "1 / 2", tr: "1 / 3", l: "2 / 1", c: "2 / 2", r: "2 / 3", bl: "3 / 1", b: "3 / 2", br: "3 / 3" }; children.forEach((child, i) => { if (layout === "stack") child.style.gridArea = "1 / 1"; if (layout === "side" && SIDE_CELLS[places[i]]) child.style.gridArea = SIDE_CELLS[places[i]]; el.append(child); }); this.transforms(el, props, ctx); return el; } private grid(node: any, ctx: Ctx): HTMLElement { const el = document.createElement("div"); const props = this.resolve(node, ctx, ["grid"]); const [cols, rows] = [num(this.ev(node.args[0])) ?? 1, num(this.ev(node.args[1])) ?? 1]; el.className = "pg-sw pg-sgrid"; el.style.display = "grid"; el.style.gridTemplateColumns = `repeat(${cols}, auto)`; el.style.gridTemplateRows = `repeat(${rows}, auto)`; const gap = num(this.pick(props, "spacing")); const xgap = num(this.pick(props, "xspacing")) ?? gap; const ygap = num(this.pick(props, "yspacing")) ?? gap; if (xgap !== null) el.style.columnGap = `${xgap}px`; if (ygap !== null) el.style.rowGap = `${ygap}px`; this.layout(el, props, ctx, { fill: false }); this.look(el, props, "idle"); const children: HTMLElement[] = []; this.nodes(node.body, { ...ctx, layout: "grid" }, children); el.append(...children); this.transforms(el, props, ctx); return el; } private vpgrid(node: any, ctx: Ctx): HTMLElement { const el = this.container(node, ctx, "div", "grid", ["vpgrid", "viewport"]); el.classList.add("pg-sviewport"); el.style.overflowY = "auto"; return el; } /** The widget whose `ToggleFocus` opened a popup, and where it is (in screen coordinates). */ private focus: { id: string; rect: { x: number; y: number; w: number; h: number } } | null = null; private activated: HTMLElement | null = null; /** A box that appears beside the focused widget: below it, above (`prefer_top`) or on `preferred_side`. */ private nearRect(node: any, ctx: Ctx): HTMLElement { const el = this.container(node, ctx, "div", "abs", ["fixed"]); const props = this.resolve(node, ctx, ["fixed"]); const rect = this.focus && this.focus.id === pyStr(this.ev(props.focus)) ? this.focus.rect : null; el.style.position = "absolute"; if (!rect) return el; const dx = num(this.pick(props, "xpos")) ?? 0; const dy = num(this.pick(props, "ypos")) ?? 0; const side = pyStr(this.pick(props, "preferred_side") ?? ""); let [x, y, shift] = [rect.x + rect.w / 2, rect.y + rect.h, "-50%, 0"]; if (truthy(this.pick(props, "prefer_top"))) [x, y, shift] = [rect.x + rect.w / 2, rect.y, "-50%, -100%"]; else if (side === "left") [x, y, shift] = [rect.x, rect.y + rect.h / 2, "-100%, -50%"]; else if (side === "right") [x, y, shift] = [rect.x + rect.w, rect.y + rect.h / 2, "0, -50%"]; el.style.left = `${x + dx}px`; el.style.top = `${y + dy}px`; el.style.transform = `translate(${shift})`; return el; } private screenRect(el: HTMLElement | null): { x: number; y: number; w: number; h: number } { const root = el?.closest(".pg-root") as HTMLElement | null; if (!el || !root) return { x: 0, y: 0, w: 0, h: 0 }; const box = el.getBoundingClientRect(); const area = root.getBoundingClientRect(); const scale = root.offsetWidth ? area.width / root.offsetWidth : 1; return { x: (box.left - area.left) / scale, y: (box.top - area.top) / scale, w: box.width / scale, h: box.height / scale }; } private viewport(node: any, ctx: Ctx): HTMLElement { const el = this.container(node, ctx, "div", "col", ["viewport"]); el.classList.add("pg-sviewport"); const props = this.resolve(node, ctx, ["viewport"]); const viewportId = this.pick(props, "id"); if (viewportId) el.dataset.viewportId = pyStr(viewportId); const mode = pyStr(this.pick(props, "scrollbars") ?? ""); el.style.overflowY = mode.includes("vertical") || this.pick(props, "mousewheel") ? "auto" : "hidden"; el.style.overflowX = mode.includes("horizontal") ? "auto" : "hidden"; return el; } private text(node: any, ctx: Ctx, kinds: string[], value: any, cls = "", forwarded?: Props): HTMLElement { const el = document.createElement("div"); el.className = `pg-sw pg-stext ${cls}`.trim(); const props = this.resolve(node, ctx, kinds, forwarded); this.layout(el, props, ctx, { fill: false }); this.textLook(el, props, "idle"); const size = num(this.pick(props, "size")) ?? this.host.baseSize; this.host.text(el, value === null || value === undefined ? "" : pyStr(value), size); this.transforms(el, props, ctx); return el; } private nullWidget(node: any, ctx: Ctx): HTMLElement { const el = document.createElement("div"); const props = this.resolve(node, ctx, []); el.className = "pg-sw pg-snull"; this.layout(el, props, ctx, { fill: false }); return el; } /** An invisible area that reports the pointer entering and leaving it, without taking clicks from what lies below. */ private mouseArea(node: any, ctx: Ctx): HTMLElement { const el = document.createElement("div"); const props = this.resolve(node, ctx, []); el.className = "pg-sw pg-snull"; el.style.pointerEvents = "none"; this.layout(el, props, ctx, { fill: false }); let inside = false; const track = (ev: MouseEvent) => { if (!el.isConnected) { document.removeEventListener("mousemove", track); return; } const box = el.getBoundingClientRect(); const now = ev.clientX >= box.left && ev.clientX < box.right && ev.clientY >= box.top && ev.clientY < box.bottom; if (now === inside) return; inside = now; this.run(this.ev(now ? props.hovered : props.unhovered), ctx.inst); }; document.addEventListener("mousemove", track); return el; } private add(node: any, ctx: Ctx): HTMLElement | null { const value = this.ev(node.args[0]); if (value === null || value === undefined) return null; const props = this.resolve(node, ctx, []); const picture = this.displayable(value); if (!picture) return null; const el = this.cropped(picture, this.pick(props, "crop")); el.classList.add("pg-sw", "pg-sadd"); this.layout(el, props, ctx, { fill: false }); this.transforms(el, props, ctx); return el; } private showGallery(pictures: any[][], transition: any) { this.galleryClose?.(); if (!pictures.length) return; const overlay = document.createElement("div"); overlay.className = "pg-gallery"; Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "9999", background: "black", display: "grid" }); const image = document.createElement("div"); Object.assign(image.style, { display: "grid", gridArea: "1 / 1", placeItems: "center" }); const controls = document.createElement("div"); Object.assign(controls.style, { gridArea: "1 / 1", alignSelf: "end", justifySelf: "center", zIndex: "1" }); let index = 0; const close = () => { overlay.remove(); if (this.galleryClose === close) this.galleryClose = null; }; const paint = () => { image.replaceChildren(); for (const value of pictures[index]) { const element = this.displayable(value); if (element) { element.style.gridArea = "1 / 1"; image.append(element); } } if (transition?.d && image.animate) image.animate([{ opacity: 0 }, { opacity: 1 }], { duration: transition.d * 1000 }); }; const button = (label: string, run: () => void) => { const element = document.createElement("button"); element.textContent = label; element.onclick = (event) => { event.stopPropagation(); run(); }; controls.append(element); }; button("Назад", () => { index = (index + pictures.length - 1) % pictures.length; paint(); }); button("Закрыть", close); const next = () => { if (++index >= pictures.length) close(); else paint(); }; button("Далее", next); overlay.onclick = (event) => { event.stopPropagation(); next(); }; overlay.onkeydown = (event) => { event.stopPropagation(); if (event.key === "Escape") close(); else if (["Enter", " ", "ArrowRight"].includes(event.key)) { event.preventDefault(); next(); } else if (event.key === "ArrowLeft") { event.preventDefault(); index = (index + pictures.length - 1) % pictures.length; paint(); } }; overlay.tabIndex = 0; overlay.append(image, controls); this.layer.append(overlay); this.galleryClose = close; paint(); overlay.focus(); } private bar(node: any, ctx: Ctx): HTMLElement { const el = document.createElement("div"); const props = this.resolve(node, ctx, ["bar"]); el.className = "pg-sw pg-sbar"; const vertical = truthy(this.ev(node.props.vertical)); this.layout(el, props, ctx, { fill: false }); this.look(el, props, "idle"); const fill = document.createElement("div"); fill.className = "pg-sbar-fill"; let value = num(this.pick(props, "value")) ?? 0; let range = num(this.pick(props, "range")) ?? 1; const source = this.pick(props, "value"); if (source instanceof BarValue) { value = source.value(); range = source.range(); } if (source instanceof BarValue && source.live) { window.setTimeout(() => { if (el.isConnected) this.refresh(); }, 250); } const ratio = range > 0 ? Math.max(0, Math.min(1, value / range)) : 0; fill.style[vertical ? "height" : "width"] = `${ratio * 100}%`; el.append(fill); if (source instanceof BarValue && source.set) { const set = source.set; const apply = (ev: PointerEvent) => { const box = el.getBoundingClientRect(); const t = vertical ? 1 - (ev.clientY - box.top) / box.height : (ev.clientX - box.left) / box.width; set(Math.max(0, Math.min(1, t)) * source.range()); this.refresh(); }; el.onpointerdown = (ev) => { ev.stopPropagation(); el.setPointerCapture(ev.pointerId); apply(ev); }; el.onpointermove = (ev) => { if (el.hasPointerCapture(ev.pointerId)) apply(ev); }; } return el; } // --- displayables -------------------------------------------------------------------- /** The picture of the channel that plays video; one element per shown screen (see cachedMovie). */ private channelMovie(value: Displayable): HTMLElement { const box = document.createElement("div"); const size = value.kw.size; box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%"; box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%"; const channel = String(value.kw.channel ?? "movie"); const sync = () => { const video = this.host.audio.mediaOf(channel); if (video instanceof HTMLVideoElement && video.parentElement !== box) { video.style.width = video.style.height = "100%"; box.replaceChildren(video); } else if (!video && box.firstChild) box.replaceChildren(); if (box.isConnected || !box.dataset.seen) { if (box.isConnected) box.dataset.seen = "1"; window.setTimeout(sync, 200); } }; sync(); return box; } /** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */ private displayable(value: any, depth = 0): HTMLElement | null { if (depth > 8 || value === null || value === undefined) return null; if (!(value instanceof Displayable)) return this.host.image(pyStr(value)); const [a, b] = value.args; switch (value.kind) { case "gallerybutton": { if (!this.rendering) throw new Error("Кнопка Gallery должна находиться в screen"); const props = Object.fromEntries(Object.entries(value.kw).map(([key, v]) => [key, { k: "const", v }])); return this.imageButton({ props, args: [], body: [] }, { inst: this.rendering, layout: "abs", prefix: "" }); } case "crop": { const child = this.displayable(b, depth + 1); return child && Array.isArray(a) ? this.cropped(child, a) : child; } case "solid": { const box = document.createElement("div"); box.style.background = pyStr(a); box.style.width = box.style.height = "100%"; return box; } case "frame": { const box = document.createElement("div"); this.paintBackground(box, a); box.style.width = box.style.height = "100%"; return box; } case "null": { const box = document.createElement("div"); if (num(value.kw.width ?? a) !== null) box.style.width = `${value.kw.width ?? a}px`; if (num(value.kw.height ?? b) !== null) box.style.height = `${value.kw.height ?? b}px`; return box; } case "dynamic": { const result = typeof a === "function" ? a(0, 0) : null; return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1); } case "text": { const box = document.createElement("div"); const size = num(value.kw.size) ?? this.host.baseSize; this.host.text(box, pyStr(a), size); if (typeof value.kw.color === "string") box.style.color = value.kw.color; return box; } case "image": return this.displayable(a, depth + 1); case "matrixcolor": { const child = this.displayable(a, depth + 1); if (child && b instanceof ColorMatrix) child.style.filter = matrixFilter(b); return child; } case "scale": { const child = this.displayable(a, depth + 1); if (child && num(value.args[1]) !== null) { child.style.width = `${value.args[1]}px`; child.style.height = `${value.args[2]}px`; } return child; } case "factorscale": { const child = this.displayable(a, depth + 1); if (child && num(b) !== null) { child.style.transformOrigin = "0 0"; child.style.transform = `scale(${b}, ${value.args[2] ?? b})`; } return child; } case "flip": { const child = this.displayable(a, depth + 1); if (child) child.style.transform = `scale(${truthy(value.kw.horizontal) ? -1 : 1}, ${truthy(value.kw.vertical) ? -1 : 1})`; return child; } case "filter": { const child = this.displayable(a, depth + 1); if (child) { const how = String(value.kw.how); if (how === "alpha") child.style.opacity = String(b ?? 1); else child.style.filter = how === "blur" ? `blur(${b ?? 0}px)` : `${how}(1)`; } return child; } case "movie": { if (value.kw.play !== undefined || typeof a === "string") { const src = this.host.fileUrl(pyStr(value.kw.play ?? a)); const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null; if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie"); return this.cachedMovie(`${src}|${mask}|${value.kw.loop !== false}`, () => movieDisplayable(src, mask, value.kw.loop !== false)); } // the picture of the channel that plays video (renpy.music.play(..., channel="movie")) return this.cachedMovie(`channel|${String(value.kw.channel ?? "movie")}|${JSON.stringify(value.kw.size ?? null)}`, () => this.channelMovie(value)); } case "alphamask": { const child = this.displayable(a, depth + 1); const mask = this.displayable(b, depth + 1); const url = mask instanceof HTMLImageElement ? mask.src : ""; if (child && url) { child.style.maskImage = `url("${url}")`; child.style.maskSize = "100% 100%"; child.style.maskRepeat = "no-repeat"; } return child; } case "trackcursor": return this.trackCursor(this.displayable(a, depth + 1), Number(b) || 1); case "composite": { const box = document.createElement("div"); const size = a; box.style.position = "relative"; if (Array.isArray(size)) { box.style.width = `${size[0]}px`; box.style.height = `${size[1]}px`; } for (let i = 1; i + 1 < value.args.length; i += 2) { const child = this.displayable(value.args[i + 1], depth + 1); const at = value.args[i]; if (!child || !Array.isArray(at)) continue; child.style.position = "absolute"; child.style.left = `${at[0]}px`; child.style.top = `${at[1]}px`; box.append(child); } return box; } case "transform": { const child = this.displayable(a, depth + 1); if (!child) return null; const p = value.kw; const parts: string[] = []; const zoom = (p.zoom ?? 1); if (zoom !== 1 || p.xzoom || p.yzoom) parts.push(`scale(${zoom * (p.xzoom ?? 1)}, ${zoom * (p.yzoom ?? 1)})`); if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); child.style.transform = parts.join(" "); if (p.alpha !== undefined) child.style.opacity = String(p.alpha); return child; } } return null; } /** Menu parallax: the picture drifts opposite to the mouse, `damping` times less than the pointer moves. */ private trackCursor(child: HTMLElement | null, damping: number): HTMLElement | null { if (!child) return null; const box = document.createElement("div"); box.style.position = "absolute"; box.style.left = box.style.top = "0"; box.append(child); let target = [0, 0]; let shown = [0, 0]; let last = performance.now(); const move = (ev: PointerEvent) => { const area = box.parentElement; if (!area) return; const rect = area.getBoundingClientRect(); const scale = area.offsetWidth ? rect.width / area.offsetWidth : 1; target = [-(ev.clientX - rect.left) / scale / damping, -(ev.clientY - rect.top) / scale / damping]; }; window.addEventListener("pointermove", move); const frame = (now: number) => { if (!box.isConnected && now - last > 1000) { window.removeEventListener("pointermove", move); return; } const k = Math.min(1, 1.5 * damping * (now - last) / 1000); shown = [shown[0] + (target[0] - shown[0]) * k, shown[1] + (target[1] - shown[1]) * k]; box.style.transform = `translate(${Math.floor(shown[0])}px, ${Math.floor(shown[1])}px)`; last = now; requestAnimationFrame(frame); }; requestAnimationFrame(frame); return box; } /** `crop (x, y, w, h)`: show only that part of the picture. */ private cropped(el: HTMLElement, rect: any): HTMLElement { if (!Array.isArray(rect) || rect.length !== 4 || rect.some((n) => num(n) === null)) return el; const [x, y, w, h] = rect as number[]; const box = document.createElement("div"); box.style.overflow = "hidden"; box.style.width = `${w}px`; box.style.height = `${h}px`; box.style.position = "relative"; el.style.position = "absolute"; el.style.left = `${-x}px`; el.style.top = `${-y}px`; el.style.maxWidth = "none"; box.append(el); return box; } // --- buttons ------------------------------------------------------------------------- /** `text` is null for a plain `button`, whose children are laid out inside. */ private button(node: any, ctx: Ctx, tag: string, kinds: string[], text: any): HTMLElement { const el = document.createElement(tag); const props = this.resolve(node, ctx, kinds); el.className = "pg-sw pg-sbtn"; el.tabIndex = 0; el.setAttribute("role", "button"); el.style.display = "grid"; this.layout(el, props, ctx, { fill: false }); const textNode: Props = {}; for (const [key, value] of Object.entries(props)) { if (key.startsWith("text_")) textNode[key.slice(5)] = value; else if (/^(idle|hover|insensitive|selected|selected_idle|selected_hover)_(color|size|font|bold|italic|outlines)$/.test(key)) textNode[key] = value; } let label: HTMLElement | null = null; if (text !== null) { label = this.text({ props: {} }, { ...ctx, layout: "stack" }, ["text", "button_text"], text, "", textNode); label.style.gridArea = "1 / 1"; el.append(label); } else { const children: HTMLElement[] = []; this.nodes(node.body, { ...ctx, layout: "stack" }, children); for (const child of children) { child.style.gridArea = "1 / 1"; el.append(child); } } this.wireButton(el, props, ctx, label, textNode, null); this.transforms(el, props, ctx); return el; } private imageButton(node: any, ctx: Ctx): HTMLElement { const el = document.createElement("div"); const props = this.resolve(node, ctx, ["image_button", "button"]); el.className = "pg-sw pg-sbtn pg-simgbtn"; el.tabIndex = 0; el.setAttribute("role", "button"); el.style.display = "grid"; this.layout(el, props, ctx, { fill: false }); const auto = this.pick(props, "auto"); const image = (state: string): any => { const direct = this.pick(props, state === "idle" ? "idle" : state); if (typeof direct === "string" || direct instanceof Displayable) return direct; if (typeof auto === "string") return auto.replace("%s", state); return null; }; const images = { idle: image("idle"), hover: image("hover") ?? image("idle"), insensitive: image("insensitive") ?? image("idle"), selected_idle: image("selected_idle") ?? image("idle"), selected_hover: image("selected_hover") ?? image("hover") ?? image("idle"), selected_insensitive: image("selected_insensitive") ?? image("insensitive") ?? image("idle"), } as Record; this.wireButton(el, props, ctx, null, {}, images); this.transforms(el, props, ctx); return el; } /** Behaviour shared by all buttons: states, actions, sounds, tooltips. */ private wireButton(el: HTMLElement, props: Record, ctx: Ctx, label: HTMLElement | null, textProps: Props, images: Record | null) { const action = this.ev(props.action); const sensitive = props.sensitive ? truthy(this.ev(props.sensitive)) && this.actionSensitive(action) : this.actionSensitive(action); const selected = props.selected ? truthy(this.ev(props.selected)) : this.actionSelected(action); let hover = false; const paint = () => { const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive") : selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle"; if (el.dataset.state && el.dataset.state !== state) { // `on hover:` / `on idle:` handlers of the button's transforms for (const entry of (el as any).__atl ?? []) entry.runner.event(entry.block, state); } el.dataset.state = state; if (images) { const file = images[state]; const picture = file ? this.displayable(file) : null; el.replaceChildren(...(picture ? [picture] : [])); picture?.classList.add("pg-simg"); } else { this.look(el, props, state); if (label) this.textLook(label, { ...props, ...Object.fromEntries(Object.entries(textProps).map(([k, v]) => [k, v])) }, state); } }; paint(); el.classList.toggle("pg-sdisabled", !sensitive); el.classList.toggle("pg-sselected", selected); const hoverSound = props.hover_sound ? this.ev(props.hover_sound) : null; const clickSound = props.activate_sound ? this.ev(props.activate_sound) : null; const tooltip = props.tooltip ? this.ev(props.tooltip) : undefined; el.onmouseenter = () => { if (!sensitive) return; hover = true; paint(); if (typeof hoverSound === "string") this.host.play("sound", hoverSound, false); if (tooltip !== undefined && this.tooltip !== tooltip) { this.tooltip = tooltip; this.refresh(); } this.run(this.ev(props.hovered), ctx.inst); }; el.onmouseleave = () => { if (!hover) return; hover = false; paint(); if (tooltip !== undefined && this.tooltip === tooltip) { this.tooltip = ""; this.refresh(); } this.run(this.ev(props.unhovered), ctx.inst); }; const activate = (ev: Event) => { ev.stopPropagation(); if (!sensitive) return; this.activated = el; if (typeof clickSound === "string") this.host.play("sound", clickSound, false); this.run(action, ctx.inst); }; el.onclick = activate; el.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); activate(ev); } }; } // --- look & layout ------------------------------------------------------------------- private look(el: HTMLElement, props: Record, state: string) { const background = this.pick(props, "background", state); el.style.background = ""; el.style.backgroundImage = ""; if (typeof background === "string") this.paintBackground(el, background); else if (background instanceof Displayable) { if (background.kind === "solid") el.style.background = pyStr(background.args[0]); else if (background.kind === "frame") this.paintBackground(el, pyStr(background.args[0])); } const alpha = num(this.pick(props, "alpha")); if (alpha !== null) el.style.opacity = String(alpha); } private paintBackground(el: HTMLElement, value: string) { if (/^#[0-9a-f]{3,8}$/i.test(value)) el.style.background = value; else { const url = this.host.fileUrl(value) ?? this.imageUrl(value); if (url) { el.style.backgroundImage = `url("${url}")`; el.style.backgroundSize = "100% 100%"; el.style.backgroundRepeat = "no-repeat"; } } } private imageUrl(name: string): string | null { const probe = this.host.image(name); if (probe instanceof HTMLImageElement) return probe.src || null; return null; } private textLook(el: HTMLElement, props: Record, state: string) { const color = this.pick(props, "color", state); if (typeof color === "string") el.style.color = color; const size = num(this.pick(props, "size", state)); if (size !== null) el.style.fontSize = `${size}px`; const font = this.pick(props, "font", state); if (typeof font === "string") { const family = this.host.fontFamily(font); if (family) el.style.fontFamily = `"${family}"`; } if (truthy(this.pick(props, "bold", state))) el.style.fontWeight = "bold"; if (truthy(this.pick(props, "italic", state))) el.style.fontStyle = "italic"; if (truthy(this.pick(props, "underline", state))) el.style.textDecoration = "underline"; const align = this.pick(props, "text_align") ?? this.pick(props, "textalign"); if (num(align) !== null) el.style.textAlign = align <= 0 ? "left" : align >= 1 ? "right" : "center"; const spacing = num(this.pick(props, "line_spacing")); if (spacing !== null) el.style.lineHeight = `calc(1em + ${spacing}px)`; const outlines = this.pick(props, "outlines"); if (Array.isArray(outlines) && outlines.length) { const [width, colour] = outlines[0] as [number, string]; if (typeof colour === "string" && width > 0) el.style.textShadow = `0 0 ${width}px ${colour}, 0 0 ${width}px ${colour}`; } } private layout(el: HTMLElement, props: Record, ctx: Ctx, opts: { fill: boolean }) { const g = (name: string) => this.pick(props, name); const style = el.style; const alpha = num(g("alpha")); if (alpha !== null) style.opacity = String(alpha); applyFilters(el, { blur: g("blur"), matrixcolor: g("matrixcolor") }); // size let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")]; const both = g("xysize"); if (Array.isArray(both)) [width, height] = both; const area = g("area"); if (Array.isArray(area) && area.length === 4) { style.position = "absolute"; style.left = lengthOf(area[0]); style.top = lengthOf(area[1]); width = area[2]; height = area[3]; } if (num(width) !== null) style.width = lengthOf(width); else if (truthy(g("xfill")) || opts.fill) style.width = "100%"; if (num(height) !== null) style.height = lengthOf(height); else if (truthy(g("yfill")) || opts.fill) style.height = "100%"; if (num(g("xminimum")) !== null) style.minWidth = lengthOf(g("xminimum")); if (num(g("yminimum")) !== null) style.minHeight = lengthOf(g("yminimum")); if (num(g("xmaximum")) !== null) style.maxWidth = lengthOf(g("xmaximum")); if (num(g("ymaximum")) !== null) style.maxHeight = lengthOf(g("ymaximum")); style.boxSizing = "border-box"; // padding and margin this.box(style, "padding", props); this.box(style, "margin", props); // position if (Array.isArray(area)) return; this.position(el, props, ctx); } private box(style: CSSStyleDeclaration, kind: "padding" | "margin", props: Record) { const g = (name: string) => this.pick(props, name); const all = g(kind); let [left, top, right, bottom] = [0, 0, 0, 0]; if (num(all) !== null) [left, top, right, bottom] = [all, all, all, all]; else if (Array.isArray(all) && all.length === 2) [left, right, top, bottom] = [all[0], all[0], all[1], all[1]]; else if (Array.isArray(all) && all.length === 4) [left, top, right, bottom] = all; const x = num(g(`x${kind}`)); const y = num(g(`y${kind}`)); if (x !== null) [left, right] = [x, x]; if (y !== null) [top, bottom] = [y, y]; left = num(g(`left_${kind}`)) ?? left; right = num(g(`right_${kind}`)) ?? right; top = num(g(`top_${kind}`)) ?? top; bottom = num(g(`bottom_${kind}`)) ?? bottom; if (left || top || right || bottom) style[kind] = `${top}px ${right}px ${bottom}px ${left}px`; } private position(el: HTMLElement, props: Record, ctx: Ctx) { const g = (name: string) => this.pick(props, name); const pair = (name: string): [any, any] => { const v = g(name); return Array.isArray(v) ? [v[0], v[1]] : [undefined, undefined]; }; const [apx, apy] = pair("align"); const [ppx, ppy] = pair("pos"); const [anx, any] = pair("anchor"); let xalign = g("xalign") ?? apx; let yalign = g("yalign") ?? apy; const xcenter = g("xcenter"); const ycenter = g("ycenter"); let xpos = g("xpos") ?? ppx; let ypos = g("ypos") ?? ppy; let xanchor = g("xanchor") ?? anx; let yanchor = g("yanchor") ?? any; if (num(xalign) !== null) { xpos = xalign; xanchor = xalign; } if (num(yalign) !== null) { ypos = yalign; yanchor = yalign; } if (num(xcenter) !== null) { xpos = xcenter; xanchor = 0.5; } if (num(ycenter) !== null) { ypos = ycenter; yanchor = 0.5; } const xoffset = num(g("xoffset")) ?? 0; const yoffset = num(g("yoffset")) ?? 0; const style = el.style; const hasX = num(xpos) !== null; const hasY = num(ypos) !== null; if (ctx.layout === "abs") { if (!hasX && !hasY && !xoffset && !yoffset && num(xanchor) === null) return; style.position = "absolute"; style.left = `calc(${lengthOf(xpos ?? 0)} + ${xoffset}px)`; style.top = `calc(${lengthOf(ypos ?? 0)} + ${yoffset}px)`; const ax = anchorOf(xanchor); const ay = anchorOf(yanchor); if (ax !== "0%" || ay !== "0%") style.translate = `-${ax} -${ay}`; return; } // flowing layouts: alignment inside the cell, offsets relative to the flow position const horizontal = num(xalign) !== null ? alignName(xalign) : null; const vertical = num(yalign) !== null ? alignName(yalign) : null; if (ctx.layout === "col" && horizontal) style.alignSelf = horizontal; else if (ctx.layout === "row" && vertical) style.alignSelf = vertical; else if (ctx.layout === "stack" || ctx.layout === "grid") { if (horizontal) style.justifySelf = horizontal; if (vertical) style.alignSelf = vertical; } if (xoffset || yoffset || (hasX && num(xalign) === null) || (hasY && num(yalign) === null)) { style.position = "relative"; if (hasX && num(xalign) === null) style.left = lengthOf(xpos); if (hasY && num(yalign) === null) style.top = lengthOf(ypos); if (xoffset) style.marginLeft = `${xoffset}px`; if (yoffset) style.marginTop = `${yoffset}px`; } } /** `at` transforms: static properties now, animated ones through the ATL runner. */ private transforms(el: HTMLElement, props: Record, ctx: Ctx) { const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else) : spec); const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : []; const live: Props = {}; const apply = (p: Props) => { const zoom = (p.zoom ?? 1); const xz = zoom * (p.xzoom ?? 1); const yz = zoom * (p.yzoom ?? 1); const parts: string[] = []; if (p.xoffset || p.yoffset) parts.push(`translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px)`); if (xz !== 1 || yz !== 1) parts.push(`scale(${xz}, ${yz})`); if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); el.style.transform = parts.join(" "); if (p.alpha !== undefined) el.style.opacity = String(p.alpha); applyFilters(el, p); }; for (const spec of specs) { Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {}); } if (specs.length) apply(live); for (const spec of specs) { const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl; if (!block) continue; const done = ctx.inst.finished.get(spec); if (done) { Object.assign(live, done); apply(live); continue; } const runner = new AtlRunner({ block: (name) => this.host.atl(name), props: (name) => this.host.transform(name), apply: (p) => apply(p), eval: (expr, frame) => this.evalWith(expr, frame), }, live, bindArguments(block, spec, (e) => this.ev(e))); runner.watch = el; ((el as any).__atl ??= []).push({ runner, block }); ctx.inst.runners.push(runner); const inst = ctx.inst; void runner.start(block).then((completed) => { if (completed && block.repeat === null) inst.finished.set(spec, { ...live }); }).catch(() => {}); } } // --- actions ------------------------------------------------------------------------- private actionSensitive(action: any): boolean { const list = Array.isArray(action) ? action : [action]; return list.every((a) => { if (isNativeAction(a)) return a.get_sensitive(); if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true); return !(a instanceof ScreenAction) || (a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true); }); } /** `get_selected` / `get_sensitive` of a game's Action class, when it defines them. */ private askAction(action: PyInstance, method: string, fallback: boolean): boolean { const found = action.cls.find(method)?.value; return typeof found === "function" ? truthy(found(action)) : fallback; } private actionSelected(action: any): boolean { const list = Array.isArray(action) ? action : [action]; return list.some((a) => { if (isNativeAction(a)) return a.get_selected(); if (a instanceof PyInstance) return this.askAction(a, "get_selected", false); if (!(a instanceof ScreenAction)) return false; switch (a.kind) { case "ToggleSetMembership": return Array.isArray(a.args[0]) && a.args[0].some((x: any) => pyEq(x, a.args[1])); case "ToggleFocus": return this.focus?.id === pyStr(a.args[0]); case "SelectedIf": return truthy(a.args[0]); case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1]; case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1]; case "ToggleVariable": return truthy(this.valueOf(a.args[0], a.screen)); case "ToggleScreenVariable": return truthy(a.screen?.vars.get(a.args[0])); default: return false; } }); } private valueOf(path: string, screen: Instance | null): any { const [root, ...rest] = String(path).split("."); let v = screen?.vars.has(root) ? screen.vars.get(root) : this.host.py.lookup(root); for (const part of rest) v = v instanceof PyDict ? v.get(part) : v?.[part]; return v ?? null; } private assign(path: string, value: any, screen: Instance | null) { const parts = String(path).split("."); if (parts.length === 1 && screen?.vars.has(parts[0])) { screen.vars.set(parts[0], value); return; } let target: Expr = { k: "name", id: parts[0] }; for (const part of parts.slice(1)) target = { k: "attr", o: target, a: part }; this.host.py.bind(target, value); } /** Run what a widget's `action` evaluated to. */ run(action: any, screen: Instance | null) { if (this.suspended) return; // actions act on the game, not on the screen's own variable scope const { py } = this.host; const savedFn = py.fn; py.fn = null; try { this.perform(action, screen); } catch (err) { if (!(err instanceof ActionAbort)) { if (this.host.error) this.host.error((err as Error).message); else console.warn("[pgvn] действие screen", err); } } finally { py.fn = savedFn; } this.refresh(); } /** The save slot a File* action means: its name on the current (or the given) page. */ private slotId(name: any, page?: any): string { return `file:${page ?? this.host.filePage}-${pyStr(name)}`; } private perform(action: any, screen: Instance | null) { if (action === null || action === undefined || action === false) return; if (Array.isArray(action)) { for (const item of action) this.perform(item, screen); return; } if (isNativeAction(action)) { action.__call__(); return; } const call = callableOf(action); if (call) { // a game's own Action class (or any function) call(); return; } if (!(action instanceof ScreenAction)) return; const [a, b, c] = action.args; const owner = action.screen ?? screen; const { py } = this.host; switch (action.kind) { case "Jump": this.host.jump(String(a)); throw new ActionAbort(); case "Call": this.host.call(String(a)); throw new ActionAbort(); case "Return": if (owner?.onReturn) { owner.onReturn(a ?? null); throw new ActionAbort(); } if (this.interactionReturn) { this.interactionReturn(a ?? null); throw new ActionAbort(); } return; case "SetVariable": this.assign(a, b, owner); return; case "ToggleVariable": { const now = this.valueOf(a, owner); const on = action.args.length > 1 ? b : true; const off = action.args.length > 2 ? c : false; this.assign(a, truthy(now) && now !== off ? off : on, owner); return; } case "SetLocalVariable": case "SetScreenVariable": owner?.vars.set(a, b); return; case "Replay": { const scope = action.kw.scope ?? b; this.host.startReplay(String(a), scope instanceof PyDict ? Object.fromEntries([...scope.map.entries()].map(([k, v]) => [String(k), v])) : null); throw new ActionAbort(); } case "EndReplay": this.host.endReplay(); throw new ActionAbort(); case "FileAction": case "FileSave": case "FileLoad": { const slot = this.slotId(a, action.kw.page); const loading = action.kind === "FileLoad" || (action.kind === "FileAction" && /load/i.test(owner?.name ?? "")); const sure = truthy(action.kw.confirm ?? (action.kind === "FileLoad" ? b : false) ?? false); if (loading) { this.host.loadSlot(slot); throw new ActionAbort(); } this.host.saveSlot(slot, sure); return; } case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return; case "FileTakeScreenshot": return; case "FilePage": this.host.filePage = String(a); return; case "FilePageNext": case "FilePagePrevious": { const kw = action.kw; const max = typeof kw.max === "number" ? kw.max : typeof a === "number" ? a : null; const order = [...(kw.auto ?? true ? ["auto"] : []), ...(kw.quick ?? true ? ["quick"] : [])]; const numbered = max === null ? 0 : max; const pages = [...order, ...Array.from({ length: numbered || 99 }, (_, i) => String(i + 1))]; const here = pages.indexOf(this.host.filePage); const step = action.kind === "FilePageNext" ? 1 : -1; let next = (here < 0 ? pages.indexOf("1") : here) + step; if (next < 0 || next >= pages.length) next = truthy(kw.wrap) ? (next + pages.length) % pages.length : Math.max(0, Math.min(pages.length - 1, next)); this.host.filePage = pages[next]; return; } case "QuickSave": this.host.quickSave(); return; case "QuickLoad": this.host.quickLoad(); throw new ActionAbort(); case "ToggleScreenVariable": { const now = owner?.vars.get(a); const on = action.args.length > 1 ? b : true; const off = action.args.length > 2 ? c : false; owner?.vars.set(a, truthy(now) && now !== off ? off : on); return; } case "SetDict": if (a instanceof PyDict) a.set(b, c); else if (a) a[b] = c; return; case "SetField": if (a) a[b] = c; return; case "ToggleField": if (a) a[b] = !truthy(a[b]); return; case "Show": case "ShowTransient": this.show(String(a), action.args.slice(1).filter((x) => typeof x !== "object" || x === null || Array.isArray(x) || x instanceof PyDict), action.kw); return; case "Hide": this.hide(String(a)); return; case "Function": { const rest = action.args.slice(1); if (typeof a === "function") a(...rest, ...(Object.keys(action.kw).length ? [{ __kw: action.kw }] : [])); return; } case "ToggleSetMembership": { const set = a; if (!Array.isArray(set)) return; const at = set.findIndex((x: any) => pyEq(x, b)); if (at >= 0) set.splice(at, 1); else set.push(b); return; } case "ToggleFocus": this.focus = this.focus?.id === pyStr(a) ? null : { id: pyStr(a), rect: this.screenRect(this.activated) }; return; case "PauseAudio": { const channel = String(a ?? "music"); const value = action.args.length > 1 || action.kw.value !== undefined ? (b ?? action.kw.value) : "toggle"; this.host.audio.setPaused(channel, value === "toggle" ? !this.host.audio.paused(channel) : truthy(value)); return; } case "Play": this.host.play(String(a), String(b), false); return; case "SetMixer": this.host.setMixer(String(a), Number(b)); return; case "Queue": this.host.play(String(a), String(b), false); return; case "Stop": this.host.stop(String(a)); return; case "Notify": this.host.notify(pyStr(a)); return; case "ToggleScreen": if (this.shown.has(String(a))) this.hide(String(a)); else this.show(String(a), action.args.slice(1), action.kw); return; case "Language": this.host.setLanguage(a === null || a === undefined ? null : String(a)); return; case "ShowMenu": this.host.openMenu(); throw new ActionAbort(); case "OpenURL": this.host.openUrl(String(a)); return; case "Quit": this.host.quit(); throw new ActionAbort(); case "MainMenu": this.host.mainMenu(); throw new ActionAbort(); case "NullAction": case "SensitiveIf": case "SelectedIf": return; } void py; } /** Python-visible constructors of actions and bar values. */ builtins(): Record { const make = (kind: string) => (...args: any[]) => { const last = args[args.length - 1]; const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); const action = new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering); action.__call__ = () => { this.run(action, action.screen); return null; }; return action; }; const out: Record = {}; for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField", "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", "Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership", "ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot", "FilePage", "FilePageNext", "FilePagePrevious"]) out[kind] = make(kind); // values of the save/load screens; screenshots are not stored, so a slot has none const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {}; const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page); out.FileLoadable = (...a: any[]) => this.host.slotInfo(slotOf(a)) !== null; out.FileUsedSlot = out.FileLoadable; out.FileNewest = () => false; out.FileSaveName = (...a: any[]) => this.host.slotInfo(slotOf(a))?.title ?? ""; out.FileTime = (...a: any[]) => { const kw = kwOf(a), info = this.host.slotInfo(slotOf(a)); if (!info) return kw.empty ?? a[2] ?? ""; return strftime(String(kw.format ?? (typeof a[1] === "string" ? a[1] : "%b %d, %H:%M")), new Date(info.time)); }; out.FileJson = (...a: any[]) => { const kw = kwOf(a), info = this.host.slotInfo(slotOf(a)); if (!info) return kw.empty ?? null; return kw.key !== undefined ? kw.missing ?? null : new PyDict(); }; out.FileScreenshot = (...a: any[]) => kwOf(a).empty ?? null; out.FileCurrentScreenshot = (...a: any[]) => kwOf(a).empty ?? null; out.FileCurrentPage = () => this.host.filePage; out.FilePageName = (...a: any[]) => { const kw = kwOf(a), page = this.host.filePage; return page === "auto" ? kw.auto ?? a[0] ?? "Auto " : page === "quick" ? kw.quick ?? a[1] ?? "Quick " : page; }; out.FileSlotName = (slot: any, perPage: any) => { const n = Number(slot) - 1, size = Number(perPage) || 1; const page = Math.floor(n / size) + 1; return `${page}-${(n % size) + 1}`; }; out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null); out.MixerValue = (mixer: any) => { return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v)); }; out.AudioPositionValue = (...a: any[]) => { const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {}; const channel = String(kw.channel ?? (typeof a[0] === "string" ? a[0] : "music")); const value = new BarValue(() => this.host.audio.position(channel) ?? 0, () => this.host.audio.duration(channel) ?? 1, (v) => this.host.audio.seek(channel, v)); value.live = true; return value; }; const shape = (kind: string) => (...args: any[]) => { const last = args[args.length - 1]; const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); return new Displayable(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}); }; out.Movie = shape("movie"); // a bar that scrolls the viewport with this id const scrollValue = (axis: "x" | "y") => (id: any) => { const target = () => document.querySelector(`[data-viewport-id="${CSS.escape(pyStr(id))}"]`); const top = axis === "y" ? "scrollTop" : "scrollLeft"; const full = (el: HTMLElement) => (axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth); const value = new BarValue(() => target()?.[top] ?? 0, () => Math.max(1, (target() ? full(target()!) : 1)), (v) => { const el = target(); if (el) el[top] = v; }); value.live = true; return value; }; out.XScrollValue = scrollValue("x"); out.YScrollValue = scrollValue("y"); out.Crop = shape("crop"); out.LiveCrop = shape("crop"); out.Solid = shape("solid"); out.Frame = shape("frame"); out.Null = shape("null"); out.Transform = shape("transform"); out.DynamicDisplayable = shape("dynamic"); out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value)); // Character(...) built by game code: kept as a record the say statement can read out.Character = (...args: any[]) => { const last = args[args.length - 1]; const kw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw") ? last.__kw : {}; return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw }; }; out.Text = shape("text"); out.Image = shape("image"); out.ImageReference = shape("image"); out.Gallery = galleryClass({ seen: (name) => truthy(this.host.py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "renpy" }, a: "seen_image" }, a: [{ k: "const", v: name }], kw: {} })), evaluate: (source) => this.host.py.eval(parseExpression(source)), show: (pictures, transition) => this.showGallery(pictures, transition), button: (image, enabled, action, properties) => new Displayable("gallerybutton", [], { ...properties, idle: image, insensitive: image, sensitive: enabled, action }), }); out.Fixed = shape("composite"); out.Composite = shape("composite"); out.LiveComposite = shape("composite"); out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn out.AlphaMask = shape("alphamask"); out.TrackCursor = shape("trackcursor"); out.At = (child: any) => child; Object.assign(out, colorMatrices); out.im = { MatrixColor: shape("matrixcolor"), Scale: shape("scale"), FactorScale: shape("factorscale"), Flip: shape("flip"), Crop: shape("crop"), Composite: shape("composite"), Grayscale: (child: any) => new Displayable("filter", [child], { how: "grayscale" }), Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }), Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }), Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }), matrix: legacyMatrices, }; out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no); out.GetTooltip = () => this.tooltip; out.StaticValue = (value: number, range = 1) => new BarValue(() => value, () => range); out.AnimatedValue = out.StaticValue; const screen = () => this.rendering; out.VariableValue = (variable: string, range = 1) => new BarValue(() => Number(this.valueOf(variable, screen())) || 0, () => range, (v) => this.assign(variable, v, screen())); out.FieldValue = (object: any, field: string, range = 1) => new BarValue(() => Number(object?.[field]) || 0, () => range, (v) => { if (object) object[field] = v; }); return out; } /** Where the imperative `renpy.ui` API puts widgets: the screen code that is running. */ private uiTarget: { out: HTMLElement[]; ctx: Ctx } | null = null; /** `renpy.show_screen` and friends. */ renpyApi(): Record { const widget = (kind: string) => (...a: any[]) => { const last = a[a.length - 1]; const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {}; const target = this.uiTarget; if (!target) throw new Error(`renpy.ui.${kind} вызван вне screen`); const props = Object.fromEntries(Object.entries(kw).map(([k, v]) => [k, { k: "const", v }])); const built = this.widget({ w: kind, args: [], props }, target.ctx); if (built) target.out.push(built); return null; }; return { ui: { imagebutton: widget("imagebutton") }, show_screen: (...a: any[]) => { const last = a[a.length - 1]; const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {}; const args = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? a.slice(1, -1) : a.slice(1); this.show(String(a[0]), args, kw); return null; }, hide_screen: (name: string) => { this.hide(String(name)); return null; }, get_screen: (name: string) => (this.shown.has(name) ? true : null), restart_interaction: () => { this.refresh(); return null; }, }; } } /** blur and matrixcolor of a widget or transform, combined into one CSS filter. */ function applyFilters(el: HTMLElement, p: Props) { const filters: string[] = []; if (num(p.blur) !== null && 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(" "); else if (p.blur !== undefined || p.matrixcolor !== undefined) el.style.filter = ""; } function lengthOf(v: any): string { return typeof v === "number" ? PX_UNIT(v) : "0px"; } function anchorOf(v: any): string { return typeof v === "number" ? PX_UNIT(v) : "0%"; } function alignName(v: number): string { return v <= 0 ? "start" : v >= 1 ? "end" : "center"; } function* iter(v: any): Iterable { if (Array.isArray(v) || typeof v === "string") yield* v; else if (v instanceof PyDict) yield* v.map.keys(); else if (v && typeof v[Symbol.iterator] === "function") yield* v; } function matchesKey(wanted: string, ev: KeyboardEvent): boolean { const name = wanted.replace(/^K_/, "").toLowerCase(); const map: Record = { escape: "escape", return: "enter", space: "space", left: "arrowleft", right: "arrowright", up: "arrowup", down: "arrowdown", tab: "tab", backspace: "backspace", delete: "delete" }; const key = ev.key.toLowerCase() === " " ? "space" : ev.key.toLowerCase(); return key === (map[name] ?? name); }