From 353cd5e49e058fee98a996234b02a7ed89752269 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:29:20 +0300 Subject: [PATCH] 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 --- frontend/src/player/colormatrix.ts | 7 +++++ frontend/src/player/engine.ts | 17 ++++++++++--- frontend/src/player/screens.ts | 41 +++++++++++++++++++++++++++++- frontend/src/player/types.ts | 2 ++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/frontend/src/player/colormatrix.ts b/frontend/src/player/colormatrix.ts index 6b92d48..06fe2e2 100644 --- a/frontend/src/player/colormatrix.ts +++ b/frontend/src/player/colormatrix.ts @@ -70,6 +70,13 @@ export function parseColor(value: any): [number, number, number, number] { export const colorMatrices = { 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) => { const [r, g, b, a] = parseColor(color); return diag(r, g, b, a); diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index eadb228..9276541 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -9,7 +9,7 @@ import { Renderer, type ResolvedImage } from "./renderer"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { languageName, rememberLanguage, type Translator } from "./i18n"; import { naniText } from "./naninovel"; -import { Displayable, ScreenManager, type ScreenHost } from "./screens"; +import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens"; import { musicRoomClass } from "./musicroom"; import { pythonModules } from "./pymodules"; import { parseExpression, parseStatements } from "./pyparse"; @@ -125,8 +125,10 @@ export class Engine { } this.persistent = this.makePersistent(); const scope: Scope = { - get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]), - has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n), + // a transform named in Python code (`{"at": iconlori}`) is a value the screens can put in `at` + 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), 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)]; }; + private isTransform(name: string): boolean { + return Boolean(this.game.transforms?.[name] || this.game.atl?.[name]); + } + destroy() { window.removeEventListener("pointermove", this.onPointer); this.liveImages?.destroy(); @@ -450,6 +456,10 @@ export class Engine { has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]), list_files: () => Object.keys(this.game.files ?? {}), 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) => { throw new JumpSignal("jump", label); }, @@ -660,6 +670,7 @@ export class Engine { tuple: (xs: any = []) => [...xs], set: (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 ?? {})), dict: (...a: any[]) => { const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []); diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index b099185..a00998c 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -66,6 +66,11 @@ type Props = Record; 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. */ export class ScreenAction { /** 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 { switch (node.w) { case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true); + case "drag": return this.dragBox(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"]); @@ -594,6 +600,31 @@ export class ScreenManager { 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 { const el = document.createElement(tag); 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. */ private transforms(el: HTMLElement, props: Record, 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 live: 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.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 = { diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index ef49594..c45bead 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -56,6 +56,8 @@ export interface Layer { export interface AtSpec { /** `at (a if cond else b)`: the entry is chosen each time the screen is drawn. */ 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; name?: string; props?: Record;