From 4dc9836a8b05dee38b612177b27ed3bf00399437 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:02:13 +0300 Subject: [PATCH] feat: mousearea, datetime dates, dir(), renpy.image_size and style.rebuild for Ren'Py games Screens get an invisible mousearea that reports the pointer entering and leaving without taking clicks. datetime.date/datetime/timedelta support construction and date arithmetic. dir() lists the game's variables, renpy.style.rebuild() is a no-op, and renpy.image_size answers only for pictures the browser already holds (otherwise an IOError the game can catch). Co-Authored-By: Claude Sonnet 5.5 --- backend/app/converter/compatibility.py | 2 + backend/app/converter/screens.py | 2 +- backend/tests/test_screens.py | 13 +++++- frontend/src/player/engine.ts | 12 ++++++ frontend/src/player/pymodules.ts | 53 ++++++++++++++++++++++++- frontend/src/player/screens.ts | 21 ++++++++++ frontend/tests/engine-behavior.test.mjs | 20 ++++++++++ 7 files changed, 119 insertions(+), 4 deletions(-) diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 1b6aa5a..f6eb5bc 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -120,6 +120,8 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre "renpy.ui.imagebutton", "renpy.return_statement"} from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402 CALLS |= set(NATIVE_CLASSES) +CALLS |= {"dir", "datetime.date", "datetime.datetime", "datetime.timedelta", "date", "timedelta", + "renpy.style.rebuild", "renpy.image_size"} CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"} # Screen actions and values the player implements (see frontend/src/player/screens.ts) SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index e9321f0..2784aec 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -31,7 +31,7 @@ Node = dict[str, Any] OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"} CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect"} -LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"} +LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar", "mousearea"} POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2} ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"} diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index 09ab110..32276b0 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -79,10 +79,10 @@ def test_screens_styles_and_calls_become_ir(): def test_shown_screens_are_audited_but_unshown_ones_are_not(): - data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n "Hi"\n') + data = parse('screen odd():\n frobnicate:\n pass\nlabel start:\n "Hi"\n') audit_ir(data) require_supported_scripts(data) # never shown: harmless - data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n call screen odd\n') + data = parse('screen odd():\n frobnicate:\n pass\nlabel start:\n call screen odd\n') audit_ir(data) with pytest.raises(CompatibilityError, match="screen"): require_supported_scripts(data) @@ -169,3 +169,12 @@ def test_a_closing_quote_ends_the_word_and_text_pictures_are_understood(): first = data.screens["hud"]["body"][0] assert first["props"]["size"] == {"k": "const", "v": 15} assert data.images["log_line"]["textExpr"]["k"] == "call" + + +def test_mousearea_is_a_leaf_with_hover_actions(): + data = parse('screen s():\n mousearea:\n xysize (100, 50)\n hovered SetVariable("h", True)\n' + ' unhovered SetVariable("h", False)\nlabel start:\n call screen s\n') + audit_ir(data) + require_supported_scripts(data) + node = data.screens["s"]["body"][0] + assert node["w"] == "mousearea" and {"hovered", "unhovered", "xysize"} <= set(node["props"]) diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 17678f9..19ba4cf 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -427,6 +427,17 @@ export class Engine { }); const renpyImpl: Record = { random, + style: { rebuild: () => null }, + image_size: (name: any) => { + const text = pyStr(name); + const resolved = this.resolveImage(text.split(" ")); + const url = this.fileUrl(resolved?.def?.file ?? text); + const probe = url ? new Image() : null; + if (probe) probe.src = url!; + // only a picture the browser already holds can be measured at once; a game catches the error + if (!probe || !probe.complete || !probe.naturalWidth) throw new Error(`IOError: размер ${text} недоступен`); + return [probe.naturalWidth, probe.naturalHeight]; + }, music: music("music"), sound: music("sound"), play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}), @@ -624,6 +635,7 @@ export class Engine { tuple: (xs: any = []) => [...xs], set: (xs: any = []) => [...new Set(xs)], frozenset: (xs: any = []) => [...new Set(xs)], + 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] : []); for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v); diff --git a/frontend/src/player/pymodules.ts b/frontend/src/player/pymodules.ts index dc906fd..68cd727 100644 --- a/frontend/src/player/pymodules.ts +++ b/frontend/src/player/pymodules.ts @@ -123,7 +123,58 @@ function strftime(format: string, d: Date): string { } const now = () => new DateTime(new Date()); +const DAY_MS = 86_400_000; + +/** `datetime.timedelta`: kept in milliseconds. */ +class TimeDelta { + constructor(public ms: number) {} + get days() { return Math.floor(this.ms / DAY_MS); } + get seconds() { return Math.floor((this.ms - this.days * DAY_MS) / 1000); } + total_seconds() { return this.ms / 1000; } + __binop__(op: string, other: any) { + if (op === "+" && other instanceof TimeDelta) return new TimeDelta(this.ms + other.ms); + if (op === "-" && other instanceof TimeDelta) return new TimeDelta(this.ms - other.ms); + if (op === "*" && typeof other === "number") return new TimeDelta(this.ms * other); + throw new TypeError(`timedelta ${op}`); + } +} + +function timedelta(...a: any[]) { + const last = a[a.length - 1]; + const kw = last && typeof last === "object" && last.__kw ? last.__kw : {}; + const unit: Record = { days: DAY_MS, seconds: 1000, minutes: 60_000, hours: 3_600_000, + weeks: 7 * DAY_MS, milliseconds: 1 }; + let ms = typeof a[0] === "number" ? a[0] * DAY_MS : 0; + for (const [name, factor] of Object.entries(unit)) if (typeof kw[name] === "number") ms += kw[name] * factor; + return new TimeDelta(ms); +} + +/** `date(y, m, d)` and `datetime(y, m, d, ...)`: a DateTime at a given moment, with date arithmetic. */ +const fromParts = (...a: any[]) => { + const [y, m, d, h = 0, min = 0, s = 0] = a.filter((x) => typeof x === "number"); + const date = new Date(y, m - 1, d, h, min, s); + if (date.getMonth() !== m - 1 || date.getDate() !== d) throw new Error("ValueError: day is out of range for month"); + return new DateTime(date); +}; const datetimeClass = { now, today: now, utcnow: now }; +const dateClass = Object.assign(fromParts, datetimeClass); +Object.assign(DateTime.prototype, { + __binop__(this: DateTime, op: string, other: any) { + if (other instanceof TimeDelta && (op === "+" || op === "-")) { + const moved = new Date(this.d.getTime()); + moved.setTime(moved.getTime() + (op === "+" ? other.ms : -other.ms)); + // whole days keep the wall-clock time across daylight-saving changes + if (other.ms % DAY_MS === 0) { + const shifted = new Date(this.d.getTime()); + shifted.setDate(shifted.getDate() + (op === "+" ? 1 : -1) * (other.ms / DAY_MS)); + return new DateTime(shifted); + } + return new DateTime(moved); + } + if (other instanceof DateTime && op === "-") return new TimeDelta(this.d.getTime() - other.d.getTime()); + throw new TypeError(`datetime ${op}`); + }, +}); /** Namespaces added to the sandbox's builtins. */ export function pythonModules(): Record { @@ -172,7 +223,7 @@ export function pythonModules(): Record { sleep: () => null, strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()), }, - datetime: Object.assign({ datetime: datetimeClass, date: datetimeClass }, datetimeClass), + datetime: Object.assign({ datetime: dateClass, date: dateClass, timedelta }, datetimeClass), collections: { defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null), OrderedDict: () => new PyDict(), diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 881f0a2..937bc27 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -534,6 +534,7 @@ export class ScreenManager { 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 "bar": case "vbar": return this.bar(node, ctx); } @@ -686,6 +687,26 @@ export class ScreenManager { return el; } + /** 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; diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index 8b3509d..b4798f2 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -89,3 +89,23 @@ test("random generators: choices, sample, seeded Random and frozenset", () => { assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0); } finally { engine.destroy(); } }); + +test("datetime date arithmetic, dir() and style.rebuild", () => { + const { engine } = makeEngine(runtime()); + try { + engine.newStore(); + const { datetime, renpy, dir } = engine.builtins; + const start = datetime.date(2025, 1, 31); + const next = start.__binop__("+", datetime.timedelta({ __kw: { days: 1 } })); + assert.deepEqual([next.year, next.month, next.day], [2025, 2, 1]); + assert.equal(next.weekday(), 5); // Saturday + assert.throws(() => datetime.date(2025, 2, 30), /ValueError/); + const back = next.__binop__("-", datetime.timedelta({ __kw: { days: 32 } })); + assert.deepEqual([back.year, back.month, back.day], [2024, 12, 31]); + assert.equal(next.__binop__("-", start).days, 1); + engine.store.known_name = 1; + assert.ok(dir().includes("known_name")); + assert.equal(renpy.style.rebuild(), null); + assert.throws(() => renpy.image_size("missing"), /IOError/); + } finally { engine.destroy(); } +});