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

+6
View File
@@ -120,6 +120,10 @@ 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)
# The render API for games' own displayables (frontend/src/player/render.ts)
CALLS |= {"renpy.Render", "renpy.render", "renpy.redraw", "renpy.displayable", "renpy.IgnoreEvent",
"renpy.display.layout.LiveComposite", "renpy.display.layout.Fixed", "pygame.key.get_mods",
"pygame.mouse.get_pressed", "pygame.mouse.get_pos"}
CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures", CALLS |= {"Matrix", "renpy.has_live2d", "renpy.fetch", "renpy.display.draw.kill_textures",
"renpy.display.render.free_memory", "cache.get_total_size"} "renpy.display.render.free_memory", "cache.get_total_size"}
CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy", CALLS |= {"Move", "absolute", "type", "id", "vars", "Flatten", "LiveComposite", "Composite", "defaultdict", "OrderedDict", "deepcopy",
@@ -169,6 +173,8 @@ METHODS = set("append extend insert pop remove index count reverse sort clear co
METHODS |= NATIVE_METHODS METHODS |= NATIVE_METHODS
# Methods of generators made by `renpy.random.Random()` / `random.Random()` # Methods of generators made by `renpy.random.Random()` / `random.Random()`
METHODS |= {"blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
"add_focus", "circle", "rect", "line", "lines", "polygon"}
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"} METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
+1 -1
View File
@@ -25,7 +25,7 @@ UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports. # Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections", KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
"itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid"} "itertools", "string", "functools", "operator", "json", "gc", "hashlib", "uuid", "pygame"}
class Unsupported(Exception): class Unsupported(Exception):
+12 -2
View File
@@ -10,6 +10,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens"; import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
import { displayableClass, makeRender, pygameNamespace } from "./render";
import { musicRoomClass } from "./musicroom"; import { musicRoomClass } from "./musicroom";
import { pythonModules } from "./pymodules"; import { pythonModules } from "./pymodules";
import { parseExpression, parseStatements } from "./pyparse"; import { parseExpression, parseStatements } from "./pyparse";
@@ -183,6 +184,8 @@ export class Engine {
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)]; if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
}; };
private displayableBase = displayableClass();
private isTransform(name: string): boolean { private isTransform(name: string): boolean {
return Boolean(this.game.transforms?.[name] || this.game.atl?.[name]); return Boolean(this.game.transforms?.[name] || this.game.atl?.[name]);
} }
@@ -457,6 +460,13 @@ export class Engine {
list_files: () => Object.keys(this.game.files ?? {}), list_files: () => Object.keys(this.game.files ?? {}),
get_mouse_pos: () => [...this.mousePos], get_mouse_pos: () => [...this.mousePos],
has_live2d: () => false, has_live2d: () => false,
Displayable: this.displayableBase,
Render: (w: number, h: number) => makeRender(Number(w), Number(h)),
render: (d: any, w: number, h: number, st = 0, at = 0) => this.screens.renderHost.renderOf(d, Number(w), Number(h), st, at),
redraw: (d: any, delay = 0) => { this.screens.renderHost.requestRedraw(d, Number(delay)); return null; },
displayable: (d: any) => d,
IgnoreEvent: () => ({ __ignore: true }),
restart_interaction: () => { this.screens.refresh(); return null; },
// there is no network access from the player: games that fetch news handle the failure themselves // there is no network access from the player: games that fetch news handle the failure themselves
fetch: () => { throw new Error("IOError: сеть недоступна"); }, fetch: () => { throw new Error("IOError: сеть недоступна"); },
display: { draw: { kill_textures: () => null }, render: { free_memory: () => null } }, display: { draw: { kill_textures: () => null }, render: { free_memory: () => null } },
@@ -551,12 +561,10 @@ export class Engine {
Keymap: () => null, Keymap: () => null,
register_shader: () => null, register_shader: () => null,
register_statement: () => null, register_statement: () => null,
redraw: () => null,
set_tag_attributes: () => null, set_tag_attributes: () => null,
stop_predict: () => null, stop_predict: () => null,
map_event: () => null, map_event: () => null,
count_displayables_in_layer: () => 0, count_displayables_in_layer: () => 0,
displayable: (value: any) => value,
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)), variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)), change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
seen_image: (name: any) => this.seenImages.has(String(name)), seen_image: (name: any) => this.seenImages.has(String(name)),
@@ -671,6 +679,7 @@ export class Engine {
set: (xs: any = []) => [...new Set(xs)], set: (xs: any = []) => [...new Set(xs)],
frozenset: (xs: any = []) => [...new Set(xs)], frozenset: (xs: any = []) => [...new Set(xs)],
cache: { get_total_size: () => 0, cache_limit: 1 }, cache: { get_total_size: () => 0, cache_limit: 1 },
pygame: pygameNamespace,
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})), 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] : []);
@@ -947,6 +956,7 @@ export class Engine {
endReplay: () => this.endReplay(), endReplay: () => this.endReplay(),
quickSave: () => void this.save("quick"), quickSave: () => void this.save("quick"),
quickLoad: () => void this.load("quick"), quickLoad: () => void this.load("quick"),
resolution: this.game.resolution,
slotInfo: (slot) => { slotInfo: (slot) => {
if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); } if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); }
const meta = this.slotCache.get(slot); const meta = this.slotCache.get(slot);
+281
View File
@@ -0,0 +1,281 @@
// A small part of Ren'Py's render API for games that write their own displayables:
//
// class Meter(renpy.Displayable):
// def render(self, width, height, st, at):
// cr = renpy.render(self.child, width, height, st, at)
// rv = renpy.Render(cr.width, cr.height)
// rv.blit(cr, (0, 0))
// renpy.redraw(self, 0)
// return rv
//
// A Render here is a tree of placed pieces (other renders, ordinary displayables, filled rectangles and drawing
// commands) that the host turns into DOM elements; there is no software rasteriser. Shaders and GPU properties are
// accepted and ignored: they only change how a picture is drawn.
import { OBJECT, PyClass, PyInstance, nativeMethod, registerNativeClass, truthy, type Python } from "./python";
type Command = { kind: "circle" | "rect" | "line" | "polygon"; color: string; args: any[]; width: number };
/** One piece of a render tree: a child render, an ordinary displayable, a fill or drawing commands. */
interface Piece {
x: number;
y: number;
render?: Render;
leaf?: any;
size?: [number, number];
fill?: string;
commands?: Command[];
}
export interface Render {
width: number;
height: number;
pieces: Piece[];
blit: (source: any, pos: any) => null;
subpixel_blit: (source: any, pos: any) => null;
fill: (color: any, rect?: any) => null;
canvas: () => Record<string, any>;
get_size: () => [number, number];
add_shader: (...a: any[]) => null;
add_uniform: (...a: any[]) => null;
add_property: (...a: any[]) => null;
add_focus: (...a: any[]) => null;
zoom: (...a: any[]) => null;
__render: true;
}
const color = (value: any): string => (typeof value === "string" ? value
: Array.isArray(value) ? `rgba(${value[0]}, ${value[1]}, ${value[2]}, ${(value[3] ?? 255) / 255})` : "#000");
export function makeRender(width: number, height: number): Render {
const pieces: Piece[] = [];
const place = (source: any, pos: any, size?: [number, number]) => {
const [x, y] = Array.isArray(pos) ? pos : [0, 0];
if (source && source.__render) pieces.push({ x: Number(x), y: Number(y), render: source });
else pieces.push({ x: Number(x), y: Number(y), leaf: source, size });
return null;
};
const render: Render = {
width, height, pieces,
blit: (source, pos) => place(source, pos),
subpixel_blit: (source, pos) => place(source, pos),
fill: (value, rect) => {
const [x, y, w, h] = Array.isArray(rect) ? rect : [0, 0, render.width, render.height];
pieces.push({ x, y, size: [w, h], fill: color(value) });
return null;
},
canvas: () => {
const commands: Command[] = [];
pieces.push({ x: 0, y: 0, commands });
const add = (kind: Command["kind"]) => (paint: any, ...args: any[]) => {
const last = args[args.length - 1];
const width = typeof last === "number" && ["circle", "rect"].includes(kind) && args.length > (kind === "circle" ? 2 : 1)
? last : 0;
commands.push({ kind, color: color(paint), args, width });
return null;
};
return { circle: add("circle"), rect: add("rect"), line: add("line"), lines: add("polygon"), polygon: add("polygon") };
},
get_size: () => [render.width, render.height],
add_shader: () => null, add_uniform: () => null, add_property: () => null, add_focus: () => null, zoom: () => null,
__render: true,
};
return render;
}
/** The base class a game's displayables inherit from; the host calls their `render` and `event`. */
export function displayableClass(): PyClass {
const base = new PyClass("Displayable", [OBJECT]);
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) => base.attrs.set(name, nativeMethod(fn));
method("__init__", (self, ..._rest: any[]) => { self.attrs.__st0 = null; return null; });
method("render", () => makeRender(0, 0));
method("event", () => null);
method("visit", () => []);
method("per_interact", () => null);
method("set_style_prefix", () => null);
return registerNativeClass(base);
}
export const isDisplayableInstance = (v: any): v is PyInstance => v instanceof PyInstance && Boolean(v.cls.find("render"));
/** pygame names that displayable code compares event types and keys with. */
export const pygameNamespace = {
QUIT: 256, KEYDOWN: 768, KEYUP: 769, MOUSEMOTION: 1024, MOUSEBUTTONDOWN: 1025, MOUSEBUTTONUP: 1026,
K_RETURN: 13, K_SPACE: 32, K_ESCAPE: 27, K_LEFT: 1073741904, K_RIGHT: 1073741903, K_UP: 1073741906, K_DOWN: 1073741905,
KMOD_SHIFT: 3, KMOD_CTRL: 192, KMOD_ALT: 768,
key: { get_mods: () => 0 },
mouse: { get_pressed: () => [false, false, false], get_pos: () => [0, 0] },
};
export interface RenderHostOptions {
py: Python;
/** An element for an ordinary displayable (an image name, Text, Transform...). */
element(value: any): HTMLElement | null;
/** Size of the area given to a displayable that has no size of its own. */
area(): [number, number];
/** An event handler returned a value: it ends the interaction. */
finish(value: any): void;
}
/** Puts a game's own displayable on the page and keeps it drawn for as long as it is there. */
export class RenderHost {
private cache = new Map<any, HTMLElement>();
private redraw = new WeakMap<object, number>();
private measuring: HTMLElement | null = null;
constructor(private options: RenderHostOptions) {}
/** `renpy.redraw(d, delay)`: draw `d` again after `delay` seconds. */
requestRedraw(d: any, delay: number) {
if (d && typeof d === "object") this.redraw.set(d, performance.now() + Math.max(0, delay) * 1000);
}
/** `renpy.render(d, w, h, st, at)`. */
renderOf(d: any, w: number, h: number, st: number, at: number): Render {
if (isDisplayableInstance(d)) {
const draw = this.options.py.attrOf(d, "render");
const result = draw(w, h, st, at);
if (result && result.__render) return result;
throw new Error("render() должен вернуть renpy.Render");
}
const [width, height] = this.measure(d);
const render = makeRender(width, height);
render.pieces.push({ x: 0, y: 0, leaf: d, size: [width, height] });
return render;
}
private measure(value: any): [number, number] {
const element = this.options.element(value);
if (!element) return [0, 0];
const box = (this.measuring ??= document.createElement("div"));
box.style.cssText = "position:absolute;left:-99999px;top:0;visibility:hidden;";
document.body.append(box);
box.replaceChildren(element);
const size: [number, number] = [element.offsetWidth || (element as HTMLImageElement).naturalWidth || 0,
element.offsetHeight || (element as HTMLImageElement).naturalHeight || 0];
box.replaceChildren();
box.remove();
return size;
}
private hosted = new WeakMap<PyInstance, { root: HTMLElement; kick: () => void }>();
/** The element for a game's displayable; it repaints itself while it stays on the page. */
host(value: PyInstance): HTMLElement {
const known = this.hosted.get(value);
if (known) { known.kick(); return known.root; }
const root = document.createElement("div");
root.className = "pg-sw pg-render";
root.style.position = "relative";
const started = performance.now();
const canvases: HTMLCanvasElement[] = [];
let next = 0;
const paint = () => {
if (!root.isConnected && next > 0) return;
const now = performance.now();
const [w, h] = this.options.area();
const st = (now - started) / 1000;
let tree: Render | null = null;
try {
tree = this.renderOf(value, w, h, st, st);
} catch (error) {
root.dataset.error = (error as Error).message;
}
if (tree) this.draw(root, tree, canvases);
const due = this.redraw.get(value);
next = now;
if (due !== undefined) { this.redraw.delete(value); window.setTimeout(() => requestAnimationFrame(paint), Math.max(0, due - now)); }
else if (!root.isConnected) requestAnimationFrame(paint); // not on the page yet: look again next frame
};
this.events(root, value, started);
const kick = () => { next = 0; requestAnimationFrame(paint); };
this.hosted.set(value, { root, kick });
kick();
return root;
}
private events(root: HTMLElement, value: PyInstance, started: number) {
const handler = this.options.py.attrOf(value, "event");
if (typeof handler !== "function") return;
const send = (type: number, ev: PointerEvent, button = 0) => {
const box = root.getBoundingClientRect();
const scale = box.width && root.offsetWidth ? root.offsetWidth / box.width : 1;
const x = Math.round((ev.clientX - box.left) * scale), y = Math.round((ev.clientY - box.top) * scale);
const event = { type, pos: [x, y], button, key: 0 };
const answer = handler(event, x, y, (performance.now() - started) / 1000);
if (answer !== null && answer !== undefined && !(answer && answer.__ignore)) this.options.finish(answer);
else this.requestRedraw(value, 0);
if (answer !== null && answer !== undefined) this.paintSoon(root, value);
};
root.addEventListener("pointermove", (ev) => send(pygameNamespace.MOUSEMOTION, ev));
root.addEventListener("pointerdown", (ev) => send(pygameNamespace.MOUSEBUTTONDOWN, ev, 1));
root.addEventListener("pointerup", (ev) => send(pygameNamespace.MOUSEBUTTONUP, ev, 1));
}
private paintSoon(_root: HTMLElement, value: PyInstance) {
this.requestRedraw(value, 0);
}
/** Turn a render tree into elements of `root`, keeping elements that were drawn last time. */
private draw(root: HTMLElement, tree: Render, canvases: HTMLCanvasElement[]) {
root.style.width = `${tree.width}px`;
root.style.height = `${tree.height}px`;
const children: HTMLElement[] = [];
let used = 0;
const walk = (render: Render, ox: number, oy: number) => {
for (const piece of render.pieces) {
const x = ox + piece.x, y = oy + piece.y;
if (piece.render) { walk(piece.render, x, y); continue; }
if (piece.fill) {
const box = document.createElement("div");
box.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${piece.size![0]}px;height:${piece.size![1]}px;background:${piece.fill}`;
children.push(box);
} else if (piece.commands) {
const canvas = (canvases[used] ??= document.createElement("canvas"));
used++;
canvas.width = tree.width;
canvas.height = tree.height;
canvas.style.cssText = `position:absolute;left:${ox}px;top:${oy}px;pointer-events:none`;
this.paintCommands(canvas, piece.commands);
children.push(canvas);
} else if (piece.leaf !== undefined) {
const key = typeof piece.leaf === "object" && piece.leaf !== null ? piece.leaf : `s:${String(piece.leaf)}`;
let element = this.cache.get(key);
if (!element) {
element = this.options.element(piece.leaf) ?? undefined;
if (element) this.cache.set(key, element);
}
if (!element) continue;
element.style.position = "absolute";
element.style.left = `${x}px`;
element.style.top = `${y}px`;
children.push(element);
}
}
};
walk(tree, 0, 0);
canvases.length = used;
const current = root.children;
if (current.length !== children.length || children.some((c, i) => current[i] !== c)) root.replaceChildren(...children);
}
private paintCommands(canvas: HTMLCanvasElement, commands: Command[]) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const command of commands) {
ctx.beginPath();
ctx.strokeStyle = ctx.fillStyle = command.color;
ctx.lineWidth = command.width || 1;
const [a, b, c] = command.args;
if (command.kind === "circle") ctx.arc(a[0], a[1], Number(b), 0, Math.PI * 2);
else if (command.kind === "rect") ctx.rect(a[0], a[1], a[2], a[3]);
else if (command.kind === "line") { ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); void c; }
else if (command.kind === "polygon") (Array.isArray(a[0]) ? a : b ?? []).forEach((p: number[], i: number) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));
if (command.kind === "line" || command.kind === "polygon" || command.width) ctx.stroke();
else ctx.fill();
}
}
}
export { truthy };
+15
View File
@@ -8,6 +8,7 @@ import { isNativeAction } from "./musicroom";
import { galleryClass } from "./gallery"; import { galleryClass } from "./gallery";
import { parseExpression } from "./pyparse"; import { parseExpression } from "./pyparse";
import { movieDisplayable } from "./movie"; import { movieDisplayable } from "./movie";
import { RenderHost, isDisplayableInstance } from "./render";
import { strftime } from "./pymodules"; import { strftime } from "./pymodules";
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python"; import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
import type { AtSpec, Expr } from "./types"; import type { AtSpec, Expr } from "./types";
@@ -49,6 +50,8 @@ export interface ScreenHost {
saveSlot(slot: string, confirm: boolean): void; saveSlot(slot: string, confirm: boolean): void;
loadSlot(slot: string): void; loadSlot(slot: string): void;
deleteSlot(slot: string, confirm: boolean): void; deleteSlot(slot: string, confirm: boolean): void;
/** Size of the game picture, for displayables that ask how much room they have. */
resolution?: [number, number];
/** The page of the save/load screen (`persistent._file_page`). */ /** The page of the save/load screen (`persistent._file_page`). */
filePage: string; filePage: string;
startReplay(label: string, scope: Record<string, any> | null): void; startReplay(label: string, scope: Record<string, any> | null): void;
@@ -147,6 +150,17 @@ export class ScreenManager {
window.addEventListener("pointercancel", this.onRelease, true); window.addEventListener("pointercancel", this.onRelease, true);
} }
/** Game displayables written against the render API (renpy.Displayable subclasses). */
private renderHostValue: RenderHost | null = null;
get renderHost(): RenderHost {
return (this.renderHostValue ??= new RenderHost({
py: this.host.py,
element: (value) => this.displayable(value),
area: () => this.host.resolution ?? [1920, 1080],
finish: (value) => this.interactionReturn?.(value),
}));
}
private pressed = false; private pressed = false;
private refreshAfterPress = false; private refreshAfterPress = false;
private onPress = () => { this.pressed = true; }; private onPress = () => { this.pressed = true; };
@@ -912,6 +926,7 @@ export class ScreenManager {
/** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */ /** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */
private displayable(value: any, depth = 0): HTMLElement | null { private displayable(value: any, depth = 0): HTMLElement | null {
if (depth > 8 || value === null || value === undefined) return null; if (depth > 8 || value === null || value === undefined) return null;
if (isDisplayableInstance(value)) return this.renderHost.host(value);
if (!(value instanceof Displayable)) return this.host.image(pyStr(value)); if (!(value instanceof Displayable)) return this.host.image(pyStr(value));
const [a, b] = value.args; const [a, b] = value.args;
switch (value.kind) { switch (value.kind) {
+16
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { makeEngine } from "./helpers/renpy-engine.mjs"; 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"); assert.equal(typeof b.LiveComposite, "function");
} finally { engine.destroy(); } } 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)