feat: drag boxes, transforms as values, Matrix() colours and offline renpy.fetch in the player

screens: `drag` with draggable True moves with the pointer (the place is kept while the screen is
shown); `at icon["at"]` picks a transform that Python code stored in a dict (transform names now
evaluate to a reference); PreviewSlowText and Move(...) exist as values. Python: Matrix([16 numbers])
for matrixcolor, renpy.has_live2d() is false, renpy.fetch fails like a missing network (games catch
it), cache.get_total_size and the texture helpers are no-ops.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:29:20 +03:00
1 parent dcddd42aca
commit 353cd5e49e
4 files changed
+63 -4

No files matched your search

+7
View File
@@ -70,6 +70,13 @@ export function parseColor(value: any): [number, number, number, number] {
export const colorMatrices = { export const colorMatrices = {
IdentityMatrix: () => ColorMatrix.identity(), IdentityMatrix: () => ColorMatrix.identity(),
/** `Matrix([16 numbers])`: Ren'Py's 4x4 acts on (r, g, b, a), as in its own BrightnessMatrix. */
Matrix: (values: any) => {
const flat = (Array.isArray(values) ? values : []).flat();
if (flat.length !== 16 || flat.some((v: any) => typeof v !== "number")) throw new Error("Matrix: нужны 16 чисел");
return new ColorMatrix([0, 1, 2, 3].map((i) => [flat[i * 4], flat[i * 4 + 1], flat[i * 4 + 2], flat[i * 4 + 3], 0] as Row)
.concat([[0, 0, 0, 0, 1] as Row]));
},
TintMatrix: (color: any) => { TintMatrix: (color: any) => {
const [r, g, b, a] = parseColor(color); const [r, g, b, a] = parseColor(color);
return diag(r, g, b, a); return diag(r, g, b, a);
+14 -3
View File
@@ -9,7 +9,7 @@ import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { Displayable, ScreenManager, type ScreenHost } from "./screens"; import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
import { musicRoomClass } from "./musicroom"; import { musicRoomClass } from "./musicroom";
import { pythonModules } from "./pymodules"; import { pythonModules } from "./pymodules";
import { parseExpression, parseStatements } from "./pyparse"; import { parseExpression, parseStatements } from "./pyparse";
@@ -125,8 +125,10 @@ export class Engine {
} }
this.persistent = this.makePersistent(); this.persistent = this.makePersistent();
const scope: Scope = { const scope: Scope = {
get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]), // a transform named in Python code (`{"at": iconlori}`) is a value the screens can put in `at`
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n), get: (n) => (Object.hasOwn(this.store, n) ? this.store[n]
: Object.hasOwn(this.builtins, n) ? this.builtins[n] : this.isTransform(n) ? new TransformRef(n) : this.builtins[n]),
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n) || this.isTransform(n),
set: (n, v) => void (this.store[n] = v), set: (n, v) => void (this.store[n] = v),
del: (n) => void delete this.store[n], del: (n) => void delete this.store[n],
}; };
@@ -181,6 +183,10 @@ export class Engine {
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)]; if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
}; };
private isTransform(name: string): boolean {
return Boolean(this.game.transforms?.[name] || this.game.atl?.[name]);
}
destroy() { destroy() {
window.removeEventListener("pointermove", this.onPointer); window.removeEventListener("pointermove", this.onPointer);
this.liveImages?.destroy(); this.liveImages?.destroy();
@@ -450,6 +456,10 @@ export class Engine {
has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]), has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]),
list_files: () => Object.keys(this.game.files ?? {}), list_files: () => Object.keys(this.game.files ?? {}),
get_mouse_pos: () => [...this.mousePos], get_mouse_pos: () => [...this.mousePos],
has_live2d: () => false,
// there is no network access from the player: games that fetch news handle the failure themselves
fetch: () => { throw new Error("IOError: сеть недоступна"); },
display: { draw: { kill_textures: () => null }, render: { free_memory: () => null } },
jump_out_of_context: (label: string) => { jump_out_of_context: (label: string) => {
throw new JumpSignal("jump", label); throw new JumpSignal("jump", label);
}, },
@@ -660,6 +670,7 @@ export class Engine {
tuple: (xs: any = []) => [...xs], tuple: (xs: any = []) => [...xs],
set: (xs: any = []) => [...new Set(xs)], set: (xs: any = []) => [...new Set(xs)],
frozenset: (xs: any = []) => [...new Set(xs)], frozenset: (xs: any = []) => [...new Set(xs)],
cache: { get_total_size: () => 0, cache_limit: 1 },
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})), dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})),
dict: (...a: any[]) => { dict: (...a: any[]) => {
const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []); const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []);
+40 -1
View File
@@ -66,6 +66,11 @@ type Props = Record<string, any>;
class ActionAbort extends Error {} class ActionAbort extends Error {}
/** 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. */ /** A screen action such as Jump("x"); run when the player activates a widget. */
export class ScreenAction { export class ScreenAction {
/** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */ /** Actions are callable in Ren'Py: `MainMenu(confirm=False)()` performs the action at once. */
@@ -571,6 +576,7 @@ export class ScreenManager {
private widget(node: any, ctx: Ctx): HTMLElement | null { private widget(node: any, ctx: Ctx): HTMLElement | null {
switch (node.w) { switch (node.w) {
case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true); case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true);
case "drag": return this.dragBox(node, ctx);
case "frame": case "frame":
case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]); case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]);
case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]); case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]);
@@ -594,6 +600,31 @@ export class ScreenManager {
return null; return null;
} }
/** 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 { private container(node: any, ctx: Ctx, tag: string, layout: Ctx["layout"], kinds: string[], fill = false): HTMLElement {
const el = document.createElement(tag); const el = document.createElement(tag);
const props = this.resolve(node, ctx, kinds); const props = this.resolve(node, ctx, kinds);
@@ -1345,7 +1376,12 @@ export class ScreenManager {
/** `at` transforms: static properties now, animated ones through the ATL runner. */ /** `at` transforms: static properties now, animated ones through the ATL runner. */
private transforms(el: HTMLElement, props: Record<string, Expr>, ctx: Ctx) { private transforms(el: HTMLElement, props: Record<string, Expr>, ctx: Ctx) {
const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else) : spec); const named = (value: any): AtSpec => {
const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value : "";
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)
: spec.dyn ? named(this.ev(spec.dyn)) : spec);
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : []; const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : [];
const live: Props = {}; const live: Props = {};
const apply = (p: Props) => { const apply = (p: Props) => {
@@ -1714,6 +1750,9 @@ export class ScreenManager {
out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn out.Flatten = (child: any) => child; // flattening only changes how the browser composites, not what is drawn
out.AlphaMask = shape("alphamask"); out.AlphaMask = shape("alphamask");
out.TrackCursor = shape("trackcursor"); 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; out.At = (child: any) => child;
Object.assign(out, colorMatrices); Object.assign(out, colorMatrices);
out.im = { out.im = {
+2
View File
@@ -56,6 +56,8 @@ export interface Layer {
export interface AtSpec { export interface AtSpec {
/** `at (a if cond else b)`: the entry is chosen each time the screen is drawn. */ /** `at (a if cond else b)`: the entry is chosen each time the screen is drawn. */
choose?: { c: Expr; then: AtSpec; else: AtSpec }; choose?: { c: Expr; then: AtSpec; else: AtSpec };
/** `at icon["at"]`: the transform is a value computed when the screen is drawn. */
dyn?: Expr;
pos?: string; pos?: string;
name?: string; name?: string;
props?: Record<string, any>; props?: Record<string, any>;