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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c10d06d90
commit
4dc9836a8b
7 files changed
+119
-4
No files matched your search
@@ -120,6 +120,8 @@ CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_scre
|
|||||||
"renpy.ui.imagebutton", "renpy.return_statement"}
|
"renpy.ui.imagebutton", "renpy.return_statement"}
|
||||||
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
||||||
CALLS |= set(NATIVE_CLASSES)
|
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"}
|
CALLS |= {"map", "filter", "iter", "hash", "hasattr", "getattr", "setattr", "callable", "super"}
|
||||||
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
|
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
|
||||||
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
|
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Node = dict[str, Any]
|
|||||||
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
||||||
|
|
||||||
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect"}
|
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}
|
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"}
|
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
||||||
|
|
||||||
|
|||||||
@@ -79,10 +79,10 @@ def test_screens_styles_and_calls_become_ir():
|
|||||||
|
|
||||||
|
|
||||||
def test_shown_screens_are_audited_but_unshown_ones_are_not():
|
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)
|
audit_ir(data)
|
||||||
require_supported_scripts(data) # never shown: harmless
|
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)
|
audit_ir(data)
|
||||||
with pytest.raises(CompatibilityError, match="screen"):
|
with pytest.raises(CompatibilityError, match="screen"):
|
||||||
require_supported_scripts(data)
|
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]
|
first = data.screens["hud"]["body"][0]
|
||||||
assert first["props"]["size"] == {"k": "const", "v": 15}
|
assert first["props"]["size"] == {"k": "const", "v": 15}
|
||||||
assert data.images["log_line"]["textExpr"]["k"] == "call"
|
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"])
|
||||||
@@ -427,6 +427,17 @@ export class Engine {
|
|||||||
});
|
});
|
||||||
const renpyImpl: Record<string, any> = {
|
const renpyImpl: Record<string, any> = {
|
||||||
random,
|
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"),
|
music: music("music"),
|
||||||
sound: music("sound"),
|
sound: music("sound"),
|
||||||
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
|
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
|
||||||
@@ -624,6 +635,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)],
|
||||||
|
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] : []);
|
||||||
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
|
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
|
||||||
|
|||||||
@@ -123,7 +123,58 @@ function strftime(format: string, d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = () => new DateTime(new Date());
|
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 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. */
|
/** Namespaces added to the sandbox's builtins. */
|
||||||
export function pythonModules(): Record<string, any> {
|
export function pythonModules(): Record<string, any> {
|
||||||
@@ -172,7 +223,7 @@ export function pythonModules(): Record<string, any> {
|
|||||||
sleep: () => null,
|
sleep: () => null,
|
||||||
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
|
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: {
|
collections: {
|
||||||
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
|
defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null),
|
||||||
OrderedDict: () => new PyDict(),
|
OrderedDict: () => new PyDict(),
|
||||||
|
|||||||
@@ -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 "label": return this.text(node, ctx, ["text", "label_text"], this.ev(node.args[0]), "pg-slabel");
|
||||||
case "add": return this.add(node, ctx);
|
case "add": return this.add(node, ctx);
|
||||||
case "null": return this.nullWidget(node, ctx);
|
case "null": return this.nullWidget(node, ctx);
|
||||||
|
case "mousearea": return this.mouseArea(node, ctx);
|
||||||
case "bar":
|
case "bar":
|
||||||
case "vbar": return this.bar(node, ctx);
|
case "vbar": return this.bar(node, ctx);
|
||||||
}
|
}
|
||||||
@@ -686,6 +687,26 @@ export class ScreenManager {
|
|||||||
return el;
|
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 {
|
private add(node: any, ctx: Ctx): HTMLElement | null {
|
||||||
const value = this.ev(node.args[0]);
|
const value = this.ev(node.args[0]);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
|
|||||||
@@ -89,3 +89,23 @@ test("random generators: choices, sample, seeded Random and frozenset", () => {
|
|||||||
assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0);
|
assert.ok(renpy.random.randrange(0, 10, 5) % 5 === 0);
|
||||||
} finally { engine.destroy(); }
|
} 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(); }
|
||||||
|
});
|
||||||
Reference in new issue
Block a user