feat: player runtime for goodluke and SaintlyCorruption
Text fields (`input`) with InputValue classes games can extend, continue/break in screen loops, Confirm, CaptureFocus/ClearFocus/InvertSelected, generator functions and iter/next, starred tuple items, renpy.file and json over data files loaded before the game starts, renpy.Container and register_sl_displayable, DiamondBox layout, ConditionSwitch/DynamicImage/Animation, renpy.show(what=...), callable transforms (`store.move_in(0.3)`), call_in_new_context as a call, im.matrix/im.Image/ MatrixColor, colorsys, fnmatch, FontGroup, persistent._clear and small renpy helpers. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e66160cffa
commit
e48521bdfb
12 files changed
+440
-26
No files matched your search
@@ -10,7 +10,7 @@ import { parseExpression } from "./pyparse";
|
||||
import { movieDisplayable } from "./movie";
|
||||
import { RenderHost, isDisplayableInstance } from "./render";
|
||||
import { strftime } from "./pymodules";
|
||||
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
||||
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, truthy, type Python } from "./python";
|
||||
import type { AtSpec, Expr } from "./types";
|
||||
|
||||
export interface ScreenDef {
|
||||
@@ -45,6 +45,10 @@ export interface ScreenHost {
|
||||
openUrl(url: string): void;
|
||||
quickSave(): void;
|
||||
quickLoad(): 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;
|
||||
@@ -69,6 +73,11 @@ 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) {}
|
||||
@@ -82,6 +91,14 @@ export class ScreenAction {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
constructor(public kind: string, public args: any[], public kw: Props) {}
|
||||
}
|
||||
@@ -189,6 +206,26 @@ export class ScreenManager {
|
||||
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");
|
||||
@@ -425,11 +462,18 @@ export class ScreenManager {
|
||||
try {
|
||||
for (const item of items) {
|
||||
py.bind(node.t, item, frame);
|
||||
this.nodes(node.body, ctx, out);
|
||||
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") {
|
||||
@@ -591,6 +635,7 @@ export class ScreenManager {
|
||||
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"]);
|
||||
@@ -608,12 +653,35 @@ export class ScreenManager {
|
||||
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"]);
|
||||
@@ -720,6 +788,8 @@ export class ScreenManager {
|
||||
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;
|
||||
@@ -785,6 +855,58 @@ export class ScreenManager {
|
||||
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");
|
||||
@@ -1025,6 +1147,35 @@ export class ScreenManager {
|
||||
}
|
||||
return child;
|
||||
}
|
||||
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 "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");
|
||||
@@ -1204,6 +1355,7 @@ export class ScreenManager {
|
||||
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);
|
||||
@@ -1392,7 +1544,8 @@ export class ScreenManager {
|
||||
/** `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 : "";
|
||||
const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value
|
||||
: value && typeof value.__transform === "string" ? value.__transform : "";
|
||||
return name ? { name, atl: this.host.atl(name) ? name : undefined } : {};
|
||||
};
|
||||
const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else)
|
||||
@@ -1468,6 +1621,7 @@ export class ScreenManager {
|
||||
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));
|
||||
@@ -1580,6 +1734,21 @@ export class ScreenManager {
|
||||
}
|
||||
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 "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": {
|
||||
@@ -1674,7 +1843,47 @@ export class ScreenManager {
|
||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
||||
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
|
||||
"FilePage", "FilePageNext", "FilePagePrevious"]) out[kind] = make(kind);
|
||||
"FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync",
|
||||
"DownloadSync", "Confirm"]) 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);
|
||||
@@ -1750,6 +1959,12 @@ export class ScreenManager {
|
||||
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.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({
|
||||
@@ -1777,7 +1992,9 @@ export class ScreenManager {
|
||||
Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }),
|
||||
Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }),
|
||||
Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }),
|
||||
matrix: legacyMatrices,
|
||||
matrix: Object.assign(legacyMatrix, { __statics: legacyMatrices }),
|
||||
Image: shape("image"),
|
||||
AlphaMask: shape("alphamask"),
|
||||
};
|
||||
out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
|
||||
out.GetTooltip = () => this.tooltip;
|
||||
|
||||
Reference in new issue
Block a user