feat: render API shim - a game's renpy.Displayable subclasses are drawn and receive events
CI / frontend (push) Failing after 1m13s
CI / backend (push) Failing after 1m33s

Games write their own displayables with render(), renpy.Render, blit, fill, canvas() and renpy.redraw.
render.ts keeps a tree of placed pieces (child renders, ordinary displayables, fills and drawing
commands) and turns it into DOM elements that stay put between redraws; pointer events reach event()
as pygame-style objects, and a returned value ends the interaction. Shaders and GPU properties are
accepted and ignored. The converter whitelists the calls and methods and accepts `import pygame`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 15:20:35 +03:00
1 parent 6d78b8b847
commit 3f51f06fec
8 files changed
+354 -3

No files matched your search

+16
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import { test } from "node:test";
import { makeEngine } from "./helpers/renpy-engine.mjs";
@@ -167,3 +168,18 @@ test("hashlib, re.fullmatch, type/id/vars, dict spread and small renpy helpers",
assert.equal(typeof b.LiveComposite, "function");
} finally { engine.destroy(); }
});
test("a game's renpy.Displayable subclass renders a tree of pieces through renpy.Render", () => {
const { engine } = makeEngine(runtime());
try {
engine.newStore();
engine.py.exec(JSON.parse(readFileSync(new URL("./fixtures/displayable.ir.json", import.meta.url), "utf8")));
const meter = engine.store.meter;
const tree = engine.screens.renderHost.renderOf(meter, 200, 100, 1.5, 1.5);
assert.deepEqual(tree.get_size(), [200, 20]);
assert.equal(tree.pieces.length, 3); // a fill, drawing commands and the picture
assert.equal(tree.pieces[0].fill, "#fff");
assert.equal(tree.pieces[1].commands[0].kind, "circle");
assert.deepEqual([tree.pieces[2].x, tree.pieces[2].y], [3, 4]);
} finally { engine.destroy(); }
});
+1
View File
@@ -0,0 +1 @@
[{"op":"class","name":"Meter","bases":[{"k":"attr","o":{"k":"name","id":"renpy"},"a":"Displayable"}],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"value","d":null,"kind":"p"},{"n":"kwargs","d":null,"kind":"**"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"call","f":{"k":"name","id":"super"},"a":[{"k":"name","id":"Meter"},{"k":"name","id":"self"}],"kw":{}},"a":"__init__"},"a":[],"kw":{},"ks":[{"k":"name","id":"kwargs"}]}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"value"},"v":{"k":"name","id":"value"}},{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"moves"},"v":{"k":"const","v":0}}],"decos":[]},{"op":"def","name":"render","params":[{"n":"self","d":null,"kind":"p"},{"n":"width","d":null,"kind":"p"},{"n":"height","d":null,"kind":"p"},{"n":"st","d":null,"kind":"p"},{"n":"at","d":null,"kind":"p"}],"body":[{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"renpy"},"a":"redraw"},"a":[{"k":"name","id":"self"},{"k":"const","v":0.1}],"kw":{}}},{"op":"set","t":{"k":"name","id":"rv"},"v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"renpy"},"a":"Render"},"a":[{"k":"name","id":"width"},{"k":"const","v":20}],"kw":{}}},{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"rv"},"a":"fill"},"a":[{"k":"const","v":"#fff"},{"k":"tuple","v":[{"k":"const","v":0},{"k":"const","v":0},{"k":"attr","o":{"k":"name","id":"self"},"a":"value"},{"k":"const","v":20}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"canvas"},"v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"rv"},"a":"canvas"},"a":[],"kw":{}}},{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"canvas"},"a":"circle"},"a":[{"k":"const","v":"#f00"},{"k":"tuple","v":[{"k":"const","v":5},{"k":"const","v":5}]},{"k":"const","v":4}],"kw":{}}},{"op":"eval","e":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"rv"},"a":"blit"},"a":[{"k":"call","f":{"k":"attr","o":{"k":"name","id":"renpy"},"a":"render"},"a":[{"k":"const","v":"pic.png"},{"k":"name","id":"width"},{"k":"name","id":"height"},{"k":"name","id":"st"},{"k":"name","id":"at"}],"kw":{}},{"k":"tuple","v":[{"k":"const","v":3},{"k":"const","v":4}]}],"kw":{}}},{"op":"return","v":{"k":"name","id":"rv"}}],"decos":[]},{"op":"def","name":"event","params":[{"n":"self","d":null,"kind":"p"},{"n":"ev","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"},{"n":"y","d":null,"kind":"p"},{"n":"st","d":null,"kind":"p"}],"body":[{"op":"if","c":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"ev"},"a":"type"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"pygame"},"a":"MOUSEMOTION"}]},"then":[{"op":"aug","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"moves"},"o":"+","v":{"k":"const","v":1}}],"else":[]},{"op":"raise","v":{"k":"call","f":{"k":"attr","o":{"k":"name","id":"renpy"},"a":"IgnoreEvent"},"a":[],"kw":{}}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"meter"},"v":{"k":"call","f":{"k":"name","id":"Meter"},"a":[{"k":"const","v":60}],"kw":{}}}]
+22
View File
@@ -0,0 +1,22 @@
class Meter(renpy.Displayable):
def __init__(self, value, **kwargs):
super(Meter, self).__init__(**kwargs)
self.value = value
self.moves = 0
def render(self, width, height, st, at):
renpy.redraw(self, 0.1)
rv = renpy.Render(width, 20)
rv.fill("#fff", (0, 0, self.value, 20))
canvas = rv.canvas()
canvas.circle("#f00", (5, 5), 4)
rv.blit(renpy.render("pic.png", width, height, st, at), (3, 4))
return rv
def event(self, ev, x, y, st):
if ev.type == pygame.MOUSEMOTION:
self.moves += 1
raise renpy.IgnoreEvent()
meter = Meter(60)