// 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 { cssColor, makeColor } from "./colors"; 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 { disposeMovies, movieDisplayable } from "./movie"; import { RenderHost, isDisplayableInstance } from "./render"; import { Matrix4, blendMode } from "./rendertransform"; import { strftime } from "./pymodules"; import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, 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 { galleryUnlocked?: boolean; 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; /** The container for screens shown on a layer of the game (config.layers); null for the interface layer. */ screensOn?(layer: string): HTMLElement | null; /** The Rollback action: back to the previous interaction. */ rollback?(): void; openUrl(url: string): void; quickSave(): void; quickLoad(): void; /** The Skip action: fast-forward through text already seen. */ setSkipping?(on: boolean): void; /** Ask the player to confirm (Confirm action). */ ask(text: string): Promise; /** The name of the current save page (`FilePageNameInputValue`). */ filePageName: string; /** 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; /** Natural size of a named picture (from the converter), or null. */ imageSize?(value: any): [number, number] | null; /** Size of the game picture, for displayables that ask how much room they have. */ resolution?: [number, number]; /** 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 {} /** `continue` and `break` in a screen's `for`. */ class LoopSignal { constructor(public kind: "continue" | "break") {} } /** A transform used as a value: `{"at": move_in}`; `at icon["at"]` then applies it. */ export class TransformRef { constructor(public name: string) {} } /** 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(...). */ /** `im.matrix(20 or 25 numbers)`: rows of [r, g, b, a, offset]. */ const legacyMatrix = (...given: any[]) => { const values: number[] = (given.length === 1 && Array.isArray(given[0]) ? given[0] : given).flat(); if (values.length !== 20 && values.length !== 25) throw new Error("im.matrix: нужны 20 или 25 чисел"); const rows = [0, 1, 2, 3].map((i) => values.slice(i * 5, i * 5 + 5) as [number, number, number, number, number]); return new ColorMatrix([...rows, [0, 0, 0, 0, 1]]); }; export class Displayable { /** Counts the changes made after it was built (`set_text`), so a picture drawn from it is drawn again. */ rev = 0; constructor(public kind: string, public args: any[], public kw: Props) {} /** `Text.set_text`: the displayable shows other text from now on. */ set_text(text: any) { if (this.kind === "text") { this.args[0] = text; this.rev++; } return null; } } 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; /** The layer it is shown on: "screens" unless the script said otherwise. */ layer: 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); } /** Game displayables written against the render API (renpy.Displayable subclasses). */ private renderHostValue: RenderHost | null = null; get renderHost(): RenderHost { return (this.renderHostValue ??= new RenderHost({ py: this.host.py, element: (value) => this.displayable(value), area: () => this.host.resolution ?? [1920, 1080], imageSize: (value) => this.host.imageSize?.(value) ?? null, finish: (value) => this.interactionReturn?.(value), })); } 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; } captureFocus(id: string) { this.focus = { id, rect: this.screenRect(this.hovered ?? this.activated) }; } clearFocus(id: string) { if (this.focus?.id === id) this.focus = null; } /** `renpy.get_focus_rect(id)`: where the widget that captured the focus `id` is. */ focusRect(id: string) { return this.focus?.id === id ? this.focus.rect : null; } /** `renpy.set_screen_variable`: set a variable of the screen being drawn (or the newest shown one). */ setScreenVariable(name: string, value: any) { const target = this.rendering ?? [...this.shown.values()].pop(); target?.vars.set(name, value); this.refresh(); } /** 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"; for (const inst of this.shown.values()) inst.el.style.visibility = "hidden"; return () => { this.suspended = previous; this.layer.style.visibility = previous ? "hidden" : ""; for (const inst of this.shown.values()) inst.el.style.visibility = previous ? "hidden" : ""; }; } // --- lifecycle ------------------------------------------------------------------- show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void; layer?: string } = {}) { 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 own = def.props.layer; const layer = opts.layer ?? previous?.layer ?? (own?.k === "const" ? pyStr(own.v) : "screens"); const inst: Instance = { name, layer, 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.host.screensOn?.(layer) ?? this.layer).append(inst.el); if (this.suspended) inst.el.style.visibility = "hidden"; 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; layer?: string }[] { 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, ...(i.layer === "screens" ? {} : { layer: i.layer }) })); } restore(list: { name: string; args: any[]; kw: Props; layer?: string }[]) { this.clear(); for (const item of list) if (this.screens[item.name]) this.show(item.name, item.args, item.kw, { layer: item.layer }); } 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, element] of [...this.movies]) if (id.startsWith(`${inst.name}|`)) { disposeMovies(element); 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; // A press may have started after this frame was queued by a timer or hover. if (this.pressed) { this.refreshAfterPress = true; 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); }); // Preserve cached players on redraw; explicitly stop those no screen shows. 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}|`)) { disposeMovies(element); 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); try { this.nodes(node.body, ctx, out); } catch (signal) { if (!(signal instanceof LoopSignal)) throw signal; if (signal.kind === "break") break; } } } finally { py.locals = py.locals.slice(0, -1); } } else if (node.c === "continue" || node.c === "break") { throw new LoopSignal(node.c); } 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) { const value = this.ev(props[key]); return value && value.__renpyColor === true ? value.hexcode : value; // a Color is a CSS colour once it is drawn } } 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 "drag": return this.dragBox(node, ctx); case "diamondbox": return this.diamondBox(node, ctx); 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 "input": return this.inputWidget(node, ctx); case "bar": case "vbar": return this.bar(node, ctx); } return null; } /** Children in a zigzag chain: every second one sits half a cell lower (the game's DiamondBox). */ private diamondBox(node: any, ctx: Ctx): HTMLElement { const el = this.container(node, ctx, "div", "abs", ["fixed"]); const props = this.resolve(node, ctx, ["fixed"]); const cell = Math.max(1, Math.round(num(this.pick(props, "cell_size")) ?? 180)); const step = (cell + (num(this.pick(props, "spacing")) ?? 0)) / 2; const fit = this.pick(props, "fit") ?? "fill"; const lower = truthy(this.pick(props, "start_lower")); const children = [...el.children] as HTMLElement[]; children.forEach((child, index) => { const down = children.length > 1 && (lower ? index % 2 === 0 : index % 2 === 1); child.style.position = "absolute"; child.style.left = `${Math.round(index * step)}px`; child.style.top = `${down ? Math.round(step) : 0}px`; child.style.width = child.style.height = `${cell}px`; child.style.objectFit = fit === "scale-up" ? "cover" : String(fit); }); el.style.width = `${Math.ceil(cell + Math.max(0, children.length - 1) * step)}px`; el.style.height = `${Math.ceil(cell + (children.length > 1 ? step : 0))}px`; return el; } /** A box the player can move with the pointer; where it was left is kept while the screen is shown. */ private dragBox(node: any, ctx: Ctx): HTMLElement { const el = this.container(node, ctx, "div", "abs", ["fixed"]); const props = this.resolve(node, ctx, ["fixed"]); if (!truthy(this.pick(props, "draggable"))) return el; const key = `__drag:${pyStr(this.pick(props, "drag_name") ?? "")}`; const place = (ctx.inst.vars.get(key) ?? [0, 0]) as [number, number]; el.style.translate = `${place[0]}px ${place[1]}px`; el.style.cursor = "grab"; el.onpointerdown = (down) => { if ((down.target as HTMLElement).closest("button, [role=button], .pg-sbutton")) return; down.stopPropagation(); el.setPointerCapture(down.pointerId); const start = [...place] as [number, number]; el.onpointermove = (move) => { place[0] = start[0] + (move.clientX - down.clientX); place[1] = start[1] + (move.clientY - down.clientY); el.style.translate = `${place[0]}px ${place[1]}px`; ctx.inst.vars.set(key, [...place]); }; el.onpointerup = () => { el.onpointermove = null; el.onpointerup = null; }; }; return el; } 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; } private hovered: HTMLElement | null = null; /** 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; } /** `input`: a text field bound to an InputValue (or standing alone, with `default`). */ private inputWidget(node: any, ctx: Ctx): HTMLElement { const props = this.resolve(node, ctx, ["input", "text"]); const value = props.value ? this.ev(props.value) : null; const multiline = truthy(this.pick(props, "multiline")); const el = document.createElement(multiline ? "textarea" : "input") as HTMLInputElement; el.className = "pg-sw pg-sinput"; this.layout(el, props, ctx, { fill: false }); this.textLook(el, props, "idle"); const own = ctx.inst.vars; const key = `__input:${node.props.id ? pyStr(this.ev(node.props.id)) : ""}`; const field = value instanceof PyInstance ? value : null; const read = (): string => pyStr(field ? this.host.py.attrOf(field, "get_text")() ?? "" : ""); const start = field ? read() : own.has(key) ? own.get(key) : pyStr(this.pick(props, "default") ?? ""); el.value = pyStr(start); const length = num(this.pick(props, "length")); if (length) el.maxLength = length; if (this.pick(props, "mask") != null) el.type = "password"; const allow = this.pick(props, "allow"), exclude = this.pick(props, "exclude"); const clean = (text: string) => [...text].filter((ch) => (allow ? String(allow).includes(ch) : true) && (exclude ? !String(exclude).includes(ch) : true)).join(""); el.oninput = () => { const text = clean(el.value); if (text !== el.value) el.value = text; if (field) { this.host.py.attrOf(field, "set_text")(text); this.refresh(); } else own.set(key, text); if (props.changed) this.run(this.callWith(this.ev(props.changed), text), ctx.inst); }; el.onkeydown = (ev) => { ev.stopPropagation(); if ((ev as KeyboardEvent).key !== "Enter" || multiline) return; const text = el.value; if (field) { const answer = this.host.py.attrOf(field, "enter")(); field.attrs.editing = false; if (answer !== null && answer !== undefined) this.run(answer, ctx.inst); return; } if (ctx.inst.onReturn) ctx.inst.onReturn(text); else this.interactionReturn?.(text); }; el.onclick = (ev) => ev.stopPropagation(); if (field?.attrs.editing) window.setTimeout(() => el.focus(), 0); return el; } /** Call a Python function with a text; its result may be an action list. */ private callWith(fn: any, text: string): any { const call = callableOf(fn); return call ? call(text) : null; } /** 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"; overlay.style.pointerEvents = "auto"; 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. */ /** The element a displayable value stands for (the player's mouse picture). */ element(value: any): HTMLElement | null { return this.displayable(value); } private displayable(value: any, depth = 0): HTMLElement | null { if (depth > 8 || value === null || value === undefined) return null; if (isDisplayableInstance(value)) return this.renderHost.host(value); 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); const textColor = cssColor(value.kw.color); if (textColor) box.style.color = textColor; 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 "blink": { // anim.Blink(picture): fades in and out const picture = this.displayable(a, depth + 1); if (picture) picture.style.animation = "pg-blink 1.2s ease-in-out infinite"; return picture; } case "snowblossom": { // falling pictures const field = document.createElement("div"); field.className = "pg-snow"; const count = Math.min(60, Number(value.kw.count ?? b ?? 10) || 10); for (let i = 0; i < count; i++) { const piece = this.displayable(a, depth + 1); if (!piece) break; piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`; piece.style.animationDuration = `${6 + (i * 7) % 9}s`; piece.style.animationDelay = `${-((i * 13) % 11)}s`; field.append(piece); } return field; } case "conditionswitch": { // ConditionSwitch("cond", picture, ...): the first condition that holds for (let i = 0; i + 1 < value.args.length; i += 2) { const holds = typeof value.args[i] === "string" ? truthy(this.host.py.eval(parseExpression(value.args[i]))) : truthy(value.args[i]); if (holds) return this.displayable(value.args[i + 1], depth + 1); } return document.createElement("div"); } case "dynamicimage": { // DynamicImage("path/[variable].png"): the name is filled in each time it is drawn const name = pyStr(a).replace(/\[([A-Za-z_][\w.]*)\]/g, (_m, source: string) => pyStr(this.host.py.eval(parseExpression(source)))); return this.displayable(name, depth + 1); } case "flatten": { // the children are drawn as one picture: opacity and filters then act on the whole const group = document.createElement("div"); group.style.isolation = "isolate"; const child = this.displayable(a, depth + 1); if (child) group.append(child); return group; } case "animation": { // pictures that take turns; the last one stays when it has no time const box = document.createElement("div"); const pictures: HTMLElement[] = []; const times: (number | null)[] = []; for (let i = 0; i < value.args.length; i += 2) { const picture = this.displayable(value.args[i], depth + 1); if (picture) { pictures.push(picture); times.push(typeof value.args[i + 1] === "number" ? value.args[i + 1] : null); } } let at = 0; const show = () => { box.replaceChildren(pictures[at]); const wait = times[at]; if (wait === null || !box.isConnected && at > 0) return; window.setTimeout(() => { at = (at + 1) % pictures.length; show(); }, wait * 1000); }; if (pictures.length) show(); return box; } 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 ?? value.kw.child, 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; // Keep every state in the same grid cell. Replacing the idle image with an // unloaded hover image collapses the hit area and causes enter/leave loops. const pictures = new Map(); if (images) for (const file of Object.values(images)) { if (!file || pictures.has(file)) continue; const picture = this.displayable(file); if (!picture) continue; picture.classList.add("pg-simg"); picture.style.gridArea = "1 / 1"; picture.style.pointerEvents = "none"; pictures.set(file, picture); el.append(picture); } 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) { for (const [file, picture] of pictures) picture.style.opacity = file === images[state] ? "1" : "0"; } 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; this.hovered = el; 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); const fontName = typeof font === "string" ? font : typeof font?.toString === "function" && String(font).startsWith("@group:") ? String(font) : null; if (fontName) { const family = this.host.fontFamily(fontName); 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 named = (value: any): AtSpec => { const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value : value && typeof value.__transform === "string" ? value.__transform : ""; if (value && value.__rounded !== undefined) return { props: { rounded: value.__rounded } }; return name ? { name, atl: this.host.atl(name) ? name : undefined } : {}; }; const pick = (spec: AtSpec): AtSpec[] => { if (spec.choose) return pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else); if (!spec.dyn) return [spec]; const value = this.ev(spec.dyn); return (Array.isArray(value) ? value : [value]).filter((v) => v !== null && v !== undefined).map(named); // a list of transforms }; const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.flatMap(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)`); // 3D turns about the widget's centre (the default matrixanchor) are Ren'Py's own matrices if (p.xrotate || p.yrotate || p.zrotate || p.zpos) { const turn = Matrix4.offset(0, 0, p.zpos ?? 0).mul(Matrix4.rotate(p.xrotate ?? 0, p.yrotate ?? 0, p.zrotate ?? 0)); parts.unshift(turn.toCss()); } if (p.matrixtransform instanceof Matrix4) parts.unshift(p.matrixtransform.toCss()); el.style.transform = parts.join(" "); el.style.mixBlendMode = blendMode(p); if (p.alpha !== undefined) el.style.opacity = String(p.alpha); if (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader) const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`; el.style.borderRadius = radius; el.style.overflow = "hidden"; } applyFilters(el, p); }; if (specs.some((spec) => spec.name === "Flatten")) el.style.isolation = "isolate"; // `at Flatten`: the widget is one picture 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" ? this.host.galleryUnlocked || !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 "InvertSelected": return !this.actionSelected(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) { // Absent hover actions must not rebuild the button under the pointer: // its replacement would receive mouseenter again and replay hover_sound. if (this.suspended || action === null || action === undefined || action === false) 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 "InputEdit": { const target = a as PyInstance; target.attrs.editing = b === "toggle" ? !target.attrs.editing : b === "on"; return; } case "CaptureFocus": this.focus = { id: pyStr(a ?? "default"), rect: this.screenRect(this.hovered ?? this.activated) }; return; case "ClearFocus": if (!a || this.focus?.id === pyStr(a)) this.focus = null; return; case "InvertSelected": this.perform(a, owner); return; case "UploadSync": case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return; case "Skip": this.host.setSkipping?.(true); return; case "Rollback": this.host.rollback?.(); return; case "ToggleLocalVariable": { const now = owner?.vars.get(a); owner?.vars.set(a, !truthy(now)); return; } case "Confirm": { const yes = b, no = c; void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); }); 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", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync", "DownloadSync", "Confirm", "Skip", "Rollback", "ToggleLocalVariable"]) out[kind] = make(kind); // what an `input` edits: classes games can extend (`class Typing(VariableInputValue)` overrides set_text) const inputBase = new PyClass("InputValue", [OBJECT]); const native = (cls: PyClass, name: string, fn: (self: PyInstance, ...a: any[]) => any) => cls.attrs.set(name, nativeMethod(fn)); native(inputBase, "get_text", () => ""); native(inputBase, "set_text", () => null); native(inputBase, "enter", (self) => (truthy(self.attrs.returnable) ? new ScreenAction("Return", [self.attrs.__text ?? this.host.py.attrOf(self, "get_text")()], {}, null) : null)); // Enable() and friends are called as `value.Enable()`: they must return the action itself for (const [method, mode] of [["Enable", "on"], ["Disable", "off"], ["Toggle", "toggle"]] as const) native(inputBase, method, (self) => new ScreenAction("InputEdit", [self, mode], {}, null)); registerNativeClass(inputBase); const kwOfCall = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {}; const inputClass = (name: string, init: (self: PyInstance, a: any[], kw: Record) => void, get: (self: PyInstance) => any, set: (self: PyInstance, text: string) => void) => { const cls = new PyClass(name, [inputBase]); native(cls, "__init__", (self, ...a: any[]) => { const kw = kwOfCall(a); init(self, a.filter((x) => !x?.__kw), kw); return null; }); native(cls, "get_text", (self) => pyStr(get(self) ?? "")); native(cls, "set_text", (self, text: string) => { set(self, text); return null; }); return registerNativeClass(cls); }; out.InputValue = inputBase; out.VariableInputValue = inputClass("VariableInputValue", (self, a, kw) => { self.attrs.variable = pyStr(a[0] ?? kw.variable); self.attrs.returnable = truthy(kw.returnable ?? a[2] ?? false); }, (self) => this.host.py.lookup(self.attrs.variable), (self, text) => { this.assign(self.attrs.variable, text, null); this.refresh(); }); out.ScreenVariableInputValue = inputClass("ScreenVariableInputValue", (self, a, kw) => { self.attrs.variable = pyStr(a[0] ?? kw.variable); self.attrs.returnable = truthy(kw.returnable ?? a[2] ?? false); self.attrs.owner = this.rendering; }, (self) => self.attrs.owner?.vars.get(self.attrs.variable), (self, text) => { self.attrs.owner?.vars.set(self.attrs.variable, text); this.refresh(); }); out.FieldInputValue = inputClass("FieldInputValue", (self, a, kw) => { self.attrs.object = a[0] ?? kw.object; self.attrs.field = pyStr(a[1] ?? kw.field); }, (self) => self.attrs.object?.[self.attrs.field], (self, text) => { if (self.attrs.object) self.attrs.object[self.attrs.field] = text; }); out.DictInputValue = inputClass("DictInputValue", (self, a, kw) => { self.attrs.dict = a[0] ?? kw.dict; self.attrs.key = a[1] ?? kw.key; }, (self) => (self.attrs.dict instanceof PyDict ? self.attrs.dict.get(self.attrs.key) : ""), (self, text) => { if (self.attrs.dict instanceof PyDict) self.attrs.dict.set(self.attrs.key, text); }); out.FilePageNameInputValue = inputClass("FilePageNameInputValue", () => {}, () => this.host.filePageName, (_self, text) => { this.host.filePageName = text; }); out.CurrentScreenName = () => this.rendering?.name ?? ""; // 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 = (...a: any[]) => makeColor(a); // 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.RenpyText = shape("text"); out.Animation = shape("animation"); out.SnowBlossom = shape("snowblossom"); out.anim = { Blink: shape("blink") }; const rounded = (...a: any[]) => ({ __rounded: (a[a.length - 1]?.__kw ?? {}).radius ?? a[1] ?? 0 }); out.RoundedCorners = rounded; out.CurriedRoundedCorners = rounded; out.ExplodeText = (child: any) => child; // the letter-explosion effect is shown as the plain text out.MouseDisplayable = (child: any, xoffset = 0, yoffset = 0) => ({ __mouse: child, xoffset, yoffset }); out.ConditionSwitch = shape("conditionswitch"); out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText` out.MatrixColor = shape("matrixcolor"); out.GLMovie = shape("movie"); out.Image = shape("image"); out.ImageReference = shape("image"); out.Gallery = galleryClass({ unlocked: () => !!this.host.galleryUnlocked, 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 = shape("flatten"); out.RotateMatrix = (x: number, y: number, z: number) => Matrix4.rotate(x, y, z); out.OffsetMatrix = (x: number, y: number, z: number) => Matrix4.offset(x, y, z); out.ScaleMatrix = (x: number, y: number, z: number) => Matrix4.scale(x, y, z); out.AlphaMask = shape("alphamask"); out.TrackCursor = shape("trackcursor"); out.PreviewSlowText = shape("text"); out.Move = (...a: any[]) => ({ __transition: "move", args: a }); // `default vpunch = Move(...)`: the effect itself is chosen by `with` out.absolute = (x: any) => Number(x); 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: Object.assign(legacyMatrix, { __statics: legacyMatrices }), Image: shape("image"), AlphaMask: shape("alphamask"), }; out.If = (...a: any[]) => { const kw = kwOf(a), args = a.filter((value) => !value?.__kw); const condition = args.length ? args[0] : kw.expression; return truthy(condition) ? (args.length > 1 ? args[1] : kw.true ?? null) : (args.length > 2 ? args[2] : kw.false ?? null); }; 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); const { _layer, ...rest } = kw; this.show(String(a[0]), args, rest, { layer: _layer ? pyStr(_layer) : undefined }); return null; }, hide_screen: (name: string) => { this.hide(String(name)); return null; }, get_screen: (name: string, ...more: any[]) => { const last = more[more.length - 1]; const layer = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw.layer : more[0]; const found = this.shown.get(name); return found && (!layer || found.layer === pyStr(layer)) ? 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); }