From 3f51f06fecf3ba3f8071442d98064ff8ad4831a6 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:20:35 +0300 Subject: [PATCH] feat: render API shim - a game's renpy.Displayable subclasses are drawn and receive events 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 --- backend/app/converter/compatibility.py | 6 + backend/app/converter/pyexpr.py | 2 +- frontend/src/player/engine.ts | 14 +- frontend/src/player/render.ts | 281 ++++++++++++++++++++ frontend/src/player/screens.ts | 15 ++ frontend/tests/engine-behavior.test.mjs | 16 ++ frontend/tests/fixtures/displayable.ir.json | 1 + frontend/tests/fixtures/displayable.py | 22 ++ 8 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 frontend/src/player/render.ts create mode 100644 frontend/tests/fixtures/displayable.ir.json create mode 100644 frontend/tests/fixtures/displayable.py diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 3f9152f..e380c90 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -120,6 +120,10 @@ 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) +# 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", "renpy.display.render.free_memory", "cache.get_total_size"} 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 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"} diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index 4462c54..58d1f64 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -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. 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): diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 9276541..1e8f9f1 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -10,6 +10,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } import { languageName, rememberLanguage, type Translator } from "./i18n"; import { naniText } from "./naninovel"; import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens"; +import { displayableClass, makeRender, pygameNamespace } from "./render"; import { musicRoomClass } from "./musicroom"; import { pythonModules } from "./pymodules"; 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)]; }; + private displayableBase = displayableClass(); + private isTransform(name: string): boolean { return Boolean(this.game.transforms?.[name] || this.game.atl?.[name]); } @@ -457,6 +460,13 @@ export class Engine { list_files: () => Object.keys(this.game.files ?? {}), get_mouse_pos: () => [...this.mousePos], 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 fetch: () => { throw new Error("IOError: сеть недоступна"); }, display: { draw: { kill_textures: () => null }, render: { free_memory: () => null } }, @@ -551,12 +561,10 @@ export class Engine { Keymap: () => null, register_shader: () => null, register_statement: () => null, - redraw: () => null, set_tag_attributes: () => null, stop_predict: () => null, map_event: () => null, count_displayables_in_layer: () => 0, - displayable: (value: any) => value, 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)), seen_image: (name: any) => this.seenImages.has(String(name)), @@ -671,6 +679,7 @@ export class Engine { set: (xs: any = []) => [...new Set(xs)], frozenset: (xs: any = []) => [...new Set(xs)], cache: { get_total_size: () => 0, cache_limit: 1 }, + pygame: pygameNamespace, 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] : []); @@ -947,6 +956,7 @@ export class Engine { endReplay: () => this.endReplay(), quickSave: () => void this.save("quick"), quickLoad: () => void this.load("quick"), + resolution: this.game.resolution, slotInfo: (slot) => { if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); } const meta = this.slotCache.get(slot); diff --git a/frontend/src/player/render.ts b/frontend/src/player/render.ts new file mode 100644 index 0000000..406c258 --- /dev/null +++ b/frontend/src/player/render.ts @@ -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; + 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(); + private redraw = new WeakMap(); + 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 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 }; diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index a00998c..53026f0 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -8,6 +8,7 @@ import { isNativeAction } from "./musicroom"; import { galleryClass } from "./gallery"; import { parseExpression } from "./pyparse"; import { movieDisplayable } from "./movie"; +import { RenderHost, isDisplayableInstance } from "./render"; import { strftime } from "./pymodules"; import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python"; import type { AtSpec, Expr } from "./types"; @@ -49,6 +50,8 @@ export interface ScreenHost { saveSlot(slot: string, confirm: boolean): void; loadSlot(slot: string): 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`). */ filePage: string; startReplay(label: string, scope: Record | null): void; @@ -147,6 +150,17 @@ export class ScreenManager { 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 refreshAfterPress = false; 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. */ private displayable(value: any, depth = 0): HTMLElement | 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)); const [a, b] = value.args; switch (value.kind) { diff --git a/frontend/tests/engine-behavior.test.mjs b/frontend/tests/engine-behavior.test.mjs index 5d22bc3..8f26c05 100644 --- a/frontend/tests/engine-behavior.test.mjs +++ b/frontend/tests/engine-behavior.test.mjs @@ -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(); } +}); diff --git a/frontend/tests/fixtures/displayable.ir.json b/frontend/tests/fixtures/displayable.ir.json new file mode 100644 index 0000000..5ac5387 --- /dev/null +++ b/frontend/tests/fixtures/displayable.ir.json @@ -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":{}}}] \ No newline at end of file diff --git a/frontend/tests/fixtures/displayable.py b/frontend/tests/fixtures/displayable.py new file mode 100644 index 0000000..d6c08e6 --- /dev/null +++ b/frontend/tests/fixtures/displayable.py @@ -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)