2210 lines
100 KiB
TypeScript
2210 lines
100 KiB
TypeScript
// 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<string, Expr>;
|
|
defaults: [string, Expr][];
|
|
body: any[];
|
|
}
|
|
|
|
export interface StyleDef {
|
|
is: string | null;
|
|
props: Record<string, Expr>;
|
|
}
|
|
|
|
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<boolean>;
|
|
/** 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<string, any> | null): void;
|
|
endReplay(): void;
|
|
setLanguage(code: string | null): void;
|
|
transform(name: string): Record<string, any> | 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<string, any>;
|
|
|
|
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<string, any>;
|
|
vars: Map<string, any>;
|
|
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<any, { handle: number; finished: boolean }>;
|
|
clocksSeen: Set<any>;
|
|
/** Final properties of animations that already ran to the end (they must not replay). */
|
|
finished: Map<any, Props>;
|
|
/** `on "show"` handlers already run, and `on "hide"` handlers waiting for the screen to go. */
|
|
shownEvents: Set<any>;
|
|
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<string, Instance>();
|
|
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<string, ScreenDef>,
|
|
private styles: Record<string, StyleDef>) {
|
|
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<string, HTMLElement>();
|
|
private moviesSeen = new Set<string>();
|
|
private movieCount = new Map<string, number>();
|
|
|
|
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<T>(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<string, any>): 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<HTMLElement>(".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<HTMLElement>(".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<string, any>();
|
|
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<string, any>();
|
|
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<string, any>[]; 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<string>()): Record<string, Expr> {
|
|
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<string, Expr> {
|
|
const merged: Record<string, Expr> = {};
|
|
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<string, Expr> }[][]) {
|
|
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<string, Expr>, 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<string, string> = { 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<string, any>;
|
|
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<string, Expr>, ctx: Ctx, label: HTMLElement | null,
|
|
textProps: Props, images: Record<string, any> | 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<any, HTMLElement>();
|
|
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<string, Expr>, 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<string, Expr>, 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<string, Expr>, 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<string, Expr>) {
|
|
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<string, Expr>, 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<string, Expr>, 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<string, any> {
|
|
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<string, any> = {};
|
|
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<string, any>) => 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<HTMLElement>(`[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<string, any> {
|
|
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<any> {
|
|
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<string, string> = { 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);
|
|
}
|