feat: player runs the game's own text tags, layers and camera, real preferences and Color

Layers (config.layers, onlayer, add_layer, show_layer_at, camera with perspective), the
game's config.custom_text_tags called like Ren'Py does (the built-in {bt}/{fi}/... copies
are gone), built-in {font} {alpha} {k} {cps} {a} {rb}/{rt} {space} {alt} {image}, FontGroup,
mouse displayable, renpy.fetch, preferences backed by the player's settings, Color class,
Render.subsurface/zoom, `default obj.attr` on provided objects, and a repaint loop that
survives text being laid out again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-27 04:39:48 +03:00
1 parent 5da7ab1a90
commit a7034b3563
13 files changed
+1563 -710

No files matched your search

+58 -17
View File
@@ -2,6 +2,7 @@
// 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";
@@ -43,6 +44,8 @@ export interface ScreenHost {
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;
@@ -107,7 +110,18 @@ const legacyMatrix = (...given: any[]) => {
};
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 {
@@ -118,6 +132,8 @@ export class BarValue {
interface Instance {
name: string;
/** The layer it is shown on: "screens" unless the script said otherwise. */
layer: string;
def: ScreenDef;
args: any[];
kw: Props;
@@ -250,25 +266,33 @@ export class ScreenManager {
const previous = this.suspended;
this.suspended = true;
this.layer.style.visibility = "hidden";
return () => { this.suspended = previous; this.layer.style.visibility = previous ? "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 } = {}) {
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, def, args, kw, frame: new Map(), vars: previous?.vars ?? new Map(), order: previous?.order ?? ++this.counter,
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.layer.append(inst.el);
(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));
@@ -311,14 +335,14 @@ export class ScreenManager {
this.layer.remove();
}
snapshot(): { name: string; args: any[]; kw: Props }[] {
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 }));
.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 }[]) {
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);
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) {
@@ -634,7 +658,10 @@ export class ScreenManager {
state === "selected_hover" ? ["selected_hover", "selected", "hover", ""] : ["selected_insensitive", "insensitive", ""];
for (const prefix of order) {
const key = prefix ? `${prefix}_${name}` : name;
if (key in props) return this.ev(props[key]);
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;
}
@@ -955,6 +982,7 @@ export class ScreenManager {
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" });
@@ -1056,6 +1084,11 @@ export class ScreenManager {
}
/** 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);
@@ -1097,7 +1130,8 @@ export class ScreenManager {
const box = document.createElement("div");
const size = num(value.kw.size) ?? this.host.baseSize;
this.host.text(box, pyStr(a), size);
if (typeof value.kw.color === "string") box.style.color = value.kw.color;
const textColor = cssColor(value.kw.color);
if (textColor) box.style.color = textColor;
return box;
}
case "image": return this.displayable(a, depth + 1);
@@ -1233,7 +1267,7 @@ export class ScreenManager {
return box;
}
case "transform": {
const child = this.displayable(a, depth + 1);
const child = this.displayable(a ?? value.kw.child, depth + 1);
if (!child) return null;
const p = value.kw;
const parts: string[] = [];
@@ -1455,8 +1489,9 @@ export class ScreenManager {
const size = num(this.pick(props, "size", state));
if (size !== null) el.style.fontSize = `${size}px`;
const font = this.pick(props, "font", state);
if (typeof font === "string") {
const family = this.host.fontFamily(font);
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";
@@ -2012,7 +2047,7 @@ export class ScreenManager {
out.Null = shape("null");
out.Transform = shape("transform");
out.DynamicDisplayable = shape("dynamic");
out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value));
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];
@@ -2028,7 +2063,7 @@ export class ScreenManager {
out.RoundedCorners = rounded;
out.CurriedRoundedCorners = rounded;
out.ExplodeText = (child: any) => child; // the letter-explosion effect is shown as the plain text
out.MouseDisplayable = () => null; // a custom mouse picture: the browser keeps its own cursor
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");
@@ -2105,11 +2140,17 @@ export class ScreenManager {
const last = a[a.length - 1];
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
const args = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? a.slice(1, -1) : a.slice(1);
this.show(String(a[0]), args, kw);
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) => (this.shown.has(name) ? true : 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; },
};
}