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"}
|
||||
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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
Reference in new issue
Block a user