feat: mousearea, datetime dates, dir(), renpy.image_size and style.rebuild for Ren'Py games
CI / frontend (push) Failing after 19s
CI / backend (push) Failing after 40s

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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 03:02:13 +03:00
1 parent 6c10d06d90
commit 4dc9836a8b
7 files changed
+119 -4

No files matched your search

+2
View File
@@ -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
+1 -1
View File
@@ -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"}
+11 -2
View File
@@ -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"])
+12
View File
@@ -427,6 +427,17 @@ export class Engine {
});
const renpyImpl: Record<string, any> = {
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);
+52 -1
View File
@@ -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<string, number> = { 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<string, any> {
@@ -172,7 +223,7 @@ export function pythonModules(): Record<string, any> {
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(),
+21
View File
@@ -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;
+20
View File
@@ -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(); }
});