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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d78b8b847
commit
3f51f06fec
8 files changed
+354
-3
No files matched your search
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
Reference in new issue
Block a user