feat: improve RenPy conversion and support MIST runtime
This commit is contained in:
1 parent
f7f7f5b1b3
commit
f9eb5c1212
44 files changed
+2822
-174
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
|
||||
|
||||
import { ColorMatrix } from "./colormatrix";
|
||||
import { callableOf } from "./python";
|
||||
|
||||
export type AtlProps = Record<string, any>;
|
||||
|
||||
@@ -22,7 +23,7 @@ export type AtlStep =
|
||||
| { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] }
|
||||
| { op: "block"; block: AtlBlock }
|
||||
| { op: "on"; events: string[]; block: AtlBlock }
|
||||
| { op: "use"; name: string; args?: any[]; kw?: Record<string, any> }
|
||||
| { op: "use"; name: string; args?: any[]; kw?: Record<string, any>; pause?: any }
|
||||
| { op: "image"; img: any; with: any }
|
||||
| { op: "contains"; img: any }
|
||||
| { op: "function"; f: any }
|
||||
@@ -38,7 +39,7 @@ export interface AtlHost {
|
||||
/** Evaluate a run-time value with the transform's parameters in scope. */
|
||||
eval?(expr: any, vars: Map<string, any>): any;
|
||||
/** `"other.png" with dissolve`: show another picture in the animated element. */
|
||||
image?(img: any, transition: any): void;
|
||||
image?(img: any, transition: any, frame?: Map<string, any>): void;
|
||||
/** `contains:`: add a child picture that runs its own timeline. */
|
||||
contains?(img: any): void;
|
||||
}
|
||||
@@ -258,12 +259,19 @@ export class AtlRunner {
|
||||
if (props) {
|
||||
Object.assign(this.props, props);
|
||||
this.apply();
|
||||
} else if (step.pause !== undefined && this.host.eval) {
|
||||
// not a transform: a number (or a function returning one) the game defines is a wait
|
||||
const wait = Number(this.host.eval(step.pause, this.vars));
|
||||
if (Number.isFinite(wait) && wait > 0) {
|
||||
await this.sleep(wait, token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "image":
|
||||
this.host.image?.(step.img, step.with);
|
||||
this.host.image?.(step.img, step.with, this.vars);
|
||||
return false;
|
||||
case "contains":
|
||||
this.host.contains?.(step.img);
|
||||
@@ -286,8 +294,8 @@ export class AtlRunner {
|
||||
|
||||
/** `function f`: Python moves the element. f(trans, st, at) returns the delay until the next call, or None. */
|
||||
private async callFunction(expr: any, token: number) {
|
||||
const f = this.host.eval ? this.host.eval(expr, this.vars) : null;
|
||||
if (typeof f !== "function") return;
|
||||
const f = callableOf(this.host.eval ? this.host.eval(expr, this.vars) : null);
|
||||
if (!f) return;
|
||||
const props = this.props;
|
||||
const trans = new Proxy({}, {
|
||||
get: (_t, key: string) => props[key] ?? this.current(key),
|
||||
@@ -337,6 +345,15 @@ export class AtlRunner {
|
||||
const target = this.props_(step.props);
|
||||
const from: AtlProps = {};
|
||||
for (const key of Object.keys(target)) from[key] = this.current(key);
|
||||
// `pos (x, y) knot (a, b)`: a Bezier curve from here through the knots (controls) to the end value
|
||||
const splines: Record<string, any[]> = {};
|
||||
for (const [key, to] of Object.entries(target)) {
|
||||
if (to && typeof to === "object" && !Array.isArray(to) && Array.isArray((to as any).spline)) {
|
||||
const points = (to as any).spline.map((p: any) => this.val(p));
|
||||
splines[key] = [from[key], ...points];
|
||||
target[key] = points[points.length - 1];
|
||||
}
|
||||
}
|
||||
const curve = curves[step.warp] ?? curves.linear;
|
||||
const start = performance.now();
|
||||
const duration = seconds * 1000;
|
||||
@@ -345,7 +362,8 @@ export class AtlRunner {
|
||||
this.alive(token);
|
||||
const raw = Math.min(1, (performance.now() - start) / duration);
|
||||
const k = curve(raw);
|
||||
for (const [key, to] of Object.entries(target)) this.props[key] = mix(from[key], to, k);
|
||||
for (const [key, to] of Object.entries(target))
|
||||
this.props[key] = splines[key] ? bezier(splines[key], k) : mix(from[key], to, k);
|
||||
this.apply();
|
||||
if (raw >= 1) return;
|
||||
}
|
||||
@@ -354,6 +372,13 @@ export class AtlRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/** De Casteljau: the point at k of the Bezier curve with these control points. */
|
||||
function bezier(points: any[], k: number): any {
|
||||
let level = points;
|
||||
while (level.length > 1) level = level.slice(1).map((p, i) => mix(level[i], p, k));
|
||||
return level[0];
|
||||
}
|
||||
|
||||
function mix(a: any, b: any, k: number): any {
|
||||
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k;
|
||||
if (a instanceof ColorMatrix && b instanceof ColorMatrix) {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Volumes {
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
el: HTMLAudioElement | null;
|
||||
el: HTMLMediaElement | null;
|
||||
file: string | null;
|
||||
loop: boolean;
|
||||
queue: string[];
|
||||
@@ -42,16 +42,27 @@ interface Playback {
|
||||
export class AudioSystem {
|
||||
channels = new Map<string, Channel>();
|
||||
// Includes outgoing tracks until their fade finishes.
|
||||
private playing = new Map<HTMLAudioElement, Playback>();
|
||||
private playing = new Map<HTMLMediaElement, Playback>();
|
||||
/** Called when a channel runs out of tracks by itself (not when it is stopped). */
|
||||
onFinished = new Map<string, () => void>();
|
||||
private mixers = new Map<string, number>();
|
||||
|
||||
constructor(public volumes: Volumes, private url: (file: string) => string | null,
|
||||
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
|
||||
|
||||
private group(ch: string): keyof Volumes {
|
||||
const mixer = this.registered[ch]?.mixer ?? ch;
|
||||
if (mixer === "music") return "music";
|
||||
if (mixer === "voice") return "voice";
|
||||
return "sound";
|
||||
mixerVolume(name: string): number {
|
||||
if (name === "sfx") name = "sound";
|
||||
if (name === "music" || name === "voice" || name === "sound") return this.volumes[name];
|
||||
return this.mixers.get(name) ?? 1;
|
||||
}
|
||||
|
||||
setMixer(name: string, value: number) {
|
||||
if (!Number.isFinite(value)) throw new Error("Громкость микшера должна быть числом");
|
||||
value = Math.max(0, Math.min(1, value));
|
||||
if (name === "sfx") name = "sound";
|
||||
if (name === "music" || name === "voice" || name === "sound") this.volumes[name] = value;
|
||||
else this.mixers.set(name, value);
|
||||
for (const [el, playback] of this.playing) this.applyVolume(el, playback);
|
||||
}
|
||||
|
||||
private channel(name: string): Channel {
|
||||
@@ -92,7 +103,9 @@ export class AudioSystem {
|
||||
console.warn("[pgvn] нет аудиофайла", file);
|
||||
return;
|
||||
}
|
||||
const el = new Audio(src);
|
||||
// the movie channel shows a picture: the player puts this element on screen (see mediaOf)
|
||||
const el: HTMLMediaElement = ch === "movie" ? document.createElement("video") : new Audio(src);
|
||||
if (ch === "movie") el.src = src;
|
||||
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, volume: c.volume, cancelFade: () => {} };
|
||||
this.playing.set(el, playback);
|
||||
this.applyVolume(el, playback);
|
||||
@@ -108,6 +121,7 @@ export class AudioSystem {
|
||||
} else {
|
||||
c.el = null;
|
||||
c.file = null;
|
||||
this.onFinished.get(ch)?.();
|
||||
}
|
||||
};
|
||||
c.el = el;
|
||||
@@ -116,11 +130,13 @@ export class AudioSystem {
|
||||
if (fadein > 0) this.fadePlayback(el, playback, 1, fadein);
|
||||
}
|
||||
|
||||
private applyVolume(el: HTMLAudioElement, playback: Playback) {
|
||||
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain * playback.volume));
|
||||
private applyVolume(el: HTMLMediaElement, playback: Playback) {
|
||||
const mixer = this.registered[playback.channel]?.mixer ??
|
||||
(playback.channel === "music" || playback.channel === "voice" ? playback.channel : "sfx");
|
||||
el.volume = Math.max(0, Math.min(1, this.mixerVolume(mixer) * playback.gain * playback.volume));
|
||||
}
|
||||
|
||||
private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number,
|
||||
private fadePlayback(el: HTMLMediaElement, playback: Playback, to: number, seconds: number,
|
||||
done?: () => void) {
|
||||
playback.cancelFade();
|
||||
playback.cancelFade = fade(playback.gain, to, seconds, (gain) => {
|
||||
@@ -157,6 +173,38 @@ export class AudioSystem {
|
||||
for (const [el, playback] of this.playing) this.applyVolume(el, playback);
|
||||
}
|
||||
|
||||
/** The element playing on a channel (a <video> on the movie channel). */
|
||||
mediaOf(ch: string): HTMLMediaElement | null {
|
||||
return this.channels.get(ch)?.el ?? null;
|
||||
}
|
||||
|
||||
/** Seconds played of the current track, or null when nothing plays. */
|
||||
position(ch: string): number | null {
|
||||
const el = this.channels.get(ch)?.el;
|
||||
return el ? el.currentTime : null;
|
||||
}
|
||||
|
||||
duration(ch: string): number | null {
|
||||
const el = this.channels.get(ch)?.el;
|
||||
return el && Number.isFinite(el.duration) ? el.duration : null;
|
||||
}
|
||||
|
||||
paused(ch: string): boolean {
|
||||
return this.channels.get(ch)?.el?.paused ?? false;
|
||||
}
|
||||
|
||||
setPaused(ch: string, value: boolean) {
|
||||
const el = this.channels.get(ch)?.el;
|
||||
if (!el) return;
|
||||
if (value) el.pause();
|
||||
else el.play().catch(() => {});
|
||||
}
|
||||
|
||||
seek(ch: string, seconds: number) {
|
||||
const el = this.channels.get(ch)?.el;
|
||||
if (el && Number.isFinite(seconds)) el.currentTime = seconds;
|
||||
}
|
||||
|
||||
current(ch: string): { file: string; loop: boolean } | null {
|
||||
const c = this.channels.get(ch);
|
||||
return c?.file ? { file: c.file, loop: c.loop } : null;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Fullscreen movie interaction. Completion preserves the distinction between skip and end. */
|
||||
export function cutscene(parent: HTMLElement, src: string, options: { delay: number | null; loops: number; volume: number }) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pg-cutscene";
|
||||
overlay.tabIndex = 0;
|
||||
Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "10000", background: "black", display: "grid" });
|
||||
const video = document.createElement("video");
|
||||
Object.assign(video.style, { width: "100%", height: "100%", objectFit: "contain", gridArea: "1 / 1" });
|
||||
video.src = src;
|
||||
video.playsInline = true;
|
||||
video.volume = options.volume;
|
||||
video.loop = options.loops === -1;
|
||||
const play = document.createElement("button");
|
||||
play.textContent = "Воспроизвести";
|
||||
Object.assign(play.style, { gridArea: "1 / 1", placeSelf: "center", zIndex: "1" });
|
||||
play.hidden = true;
|
||||
overlay.append(video, play);
|
||||
parent.append(overlay);
|
||||
let finish: (value: boolean) => void;
|
||||
let fail: (error: Error) => void;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let ended = 0;
|
||||
let settled = false;
|
||||
let clockStarted = false;
|
||||
const finished = new Promise<boolean>((resolve, reject) => { finish = resolve; fail = reject; });
|
||||
const done = (interrupted: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
finish(interrupted);
|
||||
};
|
||||
const start = () => {
|
||||
play.hidden = true;
|
||||
// A blocked autoplay needs a real user gesture; it must not silently skip the movie.
|
||||
Promise.resolve(video.play()).catch(() => { play.hidden = false; });
|
||||
};
|
||||
video.onplaying = () => {
|
||||
if (clockStarted) return;
|
||||
clockStarted = true;
|
||||
if (options.delay !== null && options.delay >= 0) timer = setTimeout(() => done(false), options.delay * 1000);
|
||||
};
|
||||
video.onended = () => {
|
||||
if (options.delay !== null) return;
|
||||
if (options.loops === -1) return;
|
||||
if (ended++ < options.loops) { video.currentTime = 0; start(); }
|
||||
else done(false);
|
||||
};
|
||||
video.onerror = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fail(new Error("Не удалось воспроизвести ролик"));
|
||||
};
|
||||
play.onclick = (event) => { event.stopPropagation(); start(); };
|
||||
overlay.onclick = (event) => { event.stopPropagation(); done(true); };
|
||||
overlay.onkeydown = (event) => {
|
||||
event.stopPropagation();
|
||||
if (["Enter", " ", "Escape"].includes(event.key)) { event.preventDefault(); done(true); }
|
||||
};
|
||||
overlay.focus();
|
||||
start();
|
||||
return { finished, destroy: () => {
|
||||
done(false);
|
||||
clearTimeout(timer);
|
||||
video.onplaying = video.onended = video.onerror = null;
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
overlay.remove();
|
||||
} };
|
||||
}
|
||||
+187
-13
@@ -1,14 +1,16 @@
|
||||
// PGVN interpreter + in-game UI.
|
||||
|
||||
import { AudioSystem } from "./audio";
|
||||
import { cutscene } from "./cutscene";
|
||||
import { VolumeStore } from "./volume-store";
|
||||
import { LiveImages } from "../imager/live";
|
||||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import { ScreenManager, type ScreenHost } from "./screens";
|
||||
import { Displayable, ScreenManager, type ScreenHost } from "./screens";
|
||||
import { musicRoomClass } from "./musicroom";
|
||||
import { pythonModules } from "./pymodules";
|
||||
import { parseExpression, parseStatements } from "./pyparse";
|
||||
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
|
||||
@@ -30,6 +32,8 @@ export class Engine {
|
||||
private labels = new Map<string, LabelRef>();
|
||||
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
||||
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
|
||||
private originalImages: Record<string, ImageDef>;
|
||||
private registeredImages: Record<string, ImageDef> = {};
|
||||
private store: Record<string, any> = {};
|
||||
private builtins: Record<string, any> = {};
|
||||
private persistent: Record<string, any>;
|
||||
@@ -89,6 +93,7 @@ export class Engine {
|
||||
audioKey: string | null = null,
|
||||
) {
|
||||
this.volumes = new VolumeStore(audioKey);
|
||||
this.originalImages = { ...game.images };
|
||||
for (const mod of modules) {
|
||||
let global = "";
|
||||
mod.labels.forEach((label, index) => {
|
||||
@@ -130,6 +135,7 @@ export class Engine {
|
||||
this.py.blockingNames = new Set([
|
||||
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
|
||||
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
|
||||
"ui.interact", "renpy.movie_cutscene",
|
||||
]);
|
||||
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
|
||||
|
||||
@@ -156,6 +162,7 @@ export class Engine {
|
||||
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
|
||||
});
|
||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||||
this.builtins.MusicRoom = musicRoomClass(this.audio);
|
||||
this.buildUI();
|
||||
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
|
||||
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
||||
@@ -223,6 +230,45 @@ export class Engine {
|
||||
return { def: this.game.images[best.key], name: best.key.split(" ") };
|
||||
}
|
||||
|
||||
private indexImage(name: string) {
|
||||
const [tag, ...attrs] = name.split(" ");
|
||||
const entries = this.imagesByTag.get(tag) ?? [];
|
||||
if (!entries.some((entry) => entry.key === name)) entries.push({ attrs, key: name });
|
||||
this.imagesByTag.set(tag, entries);
|
||||
}
|
||||
|
||||
private registerImage(name: string, definition: ImageDef) {
|
||||
Object.defineProperty(this.game.images, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||||
Object.defineProperty(this.registeredImages, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||||
this.indexImage(name);
|
||||
}
|
||||
|
||||
/** Runtime renpy.image declarations become the same serializable image IR as static ones. */
|
||||
private imageDefinition(value: any, depth = 0): ImageDef {
|
||||
if (depth > 8) throw new Error("Слишком глубокое определение изображения");
|
||||
if (typeof value === "string") return /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value }
|
||||
: Object.hasOwn(this.game.images, value) ? { ref: value } : { file: value };
|
||||
if (!(value instanceof Displayable)) throw new Error("Неподдерживаемое значение renpy.image");
|
||||
const [a, b, c] = value.args;
|
||||
switch (value.kind) {
|
||||
case "image": return this.imageDefinition(a, depth + 1);
|
||||
case "scale": {
|
||||
const child = this.imageDefinition(a, depth + 1);
|
||||
return { ...child, props: { ...child.props, size: [b, c] } };
|
||||
}
|
||||
case "transform": {
|
||||
const child = this.imageDefinition(a, depth + 1);
|
||||
return { ...child, props: { ...child.props, ...value.kw } };
|
||||
}
|
||||
case "solid": return { color: pyStr(a) };
|
||||
case "movie": return { movie: pyStr(value.kw.play ?? a), movieMask: value.kw.mask ? pyStr(value.kw.mask) : undefined,
|
||||
movieLoop: value.kw.loop !== false };
|
||||
case "text": return { text: pyStr(a) };
|
||||
case "null": return { composite: [] };
|
||||
}
|
||||
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
|
||||
}
|
||||
|
||||
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
|
||||
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
|
||||
const layers = def.layered!;
|
||||
@@ -318,7 +364,13 @@ export class Engine {
|
||||
},
|
||||
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
|
||||
set_volume: () => {},
|
||||
set_pause: () => {},
|
||||
set_pause: (...a: any[]) => {
|
||||
this.audio.setPaused(kwOf(a).channel ?? ch, truthy(kwless(a)[0] ?? true));
|
||||
return null;
|
||||
},
|
||||
get_pause: (...a: any[]) => this.audio.paused(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||
get_pos: (...a: any[]) => this.audio.position(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||
get_duration: (...a: any[]) => this.audio.duration(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||
set_pan: () => {},
|
||||
get_playing: () => this.audio.current(ch)?.file ?? null,
|
||||
is_playing: () => this.audio.current(ch) !== null,
|
||||
@@ -342,6 +394,9 @@ export class Engine {
|
||||
display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)),
|
||||
with_statement: (transition: any) => new BlockingCall("with", [transition]),
|
||||
call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)),
|
||||
movie_cutscene: (...a: any[]) => new BlockingCall("movie", kwless(a), kwOf(a)),
|
||||
free_memory: () => null, // browser owns the decoded-media cache
|
||||
image: (name: any, value: any) => { this.registerImage(Array.isArray(name) ? name.join(" ") : String(name), this.imageDefinition(value)); return null; },
|
||||
show: (name: any, ...a: any[]) => {
|
||||
const kw = kwOf(a);
|
||||
const parts = String(name).split(" ");
|
||||
@@ -410,6 +465,34 @@ export class Engine {
|
||||
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)),
|
||||
block_rollback: () => null,
|
||||
choice_for_skipping: () => null,
|
||||
// text effects drawn by GPU shaders: the words stay, only the glow is missing
|
||||
register_textshader: () => null,
|
||||
save_persistent: () => null, // persistent data is written on every change
|
||||
mark_label_seen: (label: any) => {
|
||||
const seen: string[] = this.persistent._seen ?? [];
|
||||
if (!seen.includes(String(label))) this.persistent._seen = [...seen, String(label)];
|
||||
return null;
|
||||
},
|
||||
get_game_runtime: () => (performance.now() - this.startedAt) / 1000,
|
||||
translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null),
|
||||
substitute: (text: any) => this.interp(pyStr(text)),
|
||||
return_statement: () => {
|
||||
throw new JumpSignal("return", "");
|
||||
},
|
||||
checkpoint: () => null,
|
||||
start_predict: () => null,
|
||||
start_predict_screen: () => null,
|
||||
stop_predict_screen: () => null,
|
||||
// named slots; the name is limited to what the server accepts
|
||||
save: (name: any) => {
|
||||
void this.save(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32), true);
|
||||
return null;
|
||||
},
|
||||
load: (name: any) => {
|
||||
void this.load(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32));
|
||||
return null;
|
||||
},
|
||||
transition: () => null,
|
||||
};
|
||||
const renpy = new Proxy(renpyImpl, {
|
||||
@@ -423,7 +506,7 @@ export class Engine {
|
||||
return {
|
||||
renpy,
|
||||
persistent: this.persistent,
|
||||
config: {},
|
||||
config: { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1] },
|
||||
// games branch on the Ren'Py language name ("russian"); None means the source language
|
||||
preferences: this.languagePrefs(),
|
||||
_preferences: this.languagePrefs(),
|
||||
@@ -456,6 +539,16 @@ export class Engine {
|
||||
},
|
||||
Borders: (...a: number[]) => a,
|
||||
...pythonModules(),
|
||||
...nativeClasses(),
|
||||
build: { ...this.game.build },
|
||||
ui: { adjustment: () => ({ value: 0, range: 1, page: 0 }),
|
||||
interact: () => new BlockingCall("interact") },
|
||||
divmod: (a: number, b: number) => [Math.floor(a / b), ((a % b) + b) % b],
|
||||
pow: (a: number, b: number, m?: number) => (m ? Number((BigInt(a) ** BigInt(b)) % BigInt(m)) : a ** b),
|
||||
_p: (text: any) => this.tr.str(pyStr(text)).replace(/[ \t]*\n[ \t]*/g, " ").trim(),
|
||||
achievement: this.achievementApi(),
|
||||
// objects the game can reach, as the garbage collector would list them
|
||||
gc: { get_objects: () => liveObjects(this.store), collect: () => 0 },
|
||||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||||
_window_show: () => { this.windowShown = true; return null; },
|
||||
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
|
||||
@@ -597,10 +690,45 @@ export class Engine {
|
||||
this.screens.hideCall(name);
|
||||
return value ?? null;
|
||||
}
|
||||
case "interact": {
|
||||
this.interactionStart();
|
||||
this.screens.refresh();
|
||||
let cancel = () => {};
|
||||
try { return await this.wait<any>((done) => { cancel = this.screens.interact(done); }); }
|
||||
finally { cancel(); }
|
||||
}
|
||||
case "movie":
|
||||
return this.movieCutscene(request.args, request.kw);
|
||||
}
|
||||
throw new Error(`Неизвестный запрос ${request.kind}`);
|
||||
}
|
||||
|
||||
private async movieCutscene(args: any[], kw: Record<string, any>): Promise<boolean> {
|
||||
const filename = this.interp(pyStr(args[0] ?? kw.filename));
|
||||
const src = this.fileUrl(filename);
|
||||
if (!src) throw new Error(`Не найден ролик: ${filename}`);
|
||||
const delay = args[1] ?? kw.delay ?? null;
|
||||
const loops = Number(args[2] ?? kw.loops ?? 0);
|
||||
if ((delay !== null && (!Number.isFinite(delay) || delay < -1)) || !Number.isInteger(loops) || loops < -1)
|
||||
throw new Error("Некорректная длительность или число повторений ролика");
|
||||
const stopMusic = args[3] ?? kw.stop_music ?? true;
|
||||
const paused = this.audio.paused("music");
|
||||
if (stopMusic) this.audio.setPaused("music", true);
|
||||
const restoreScreens = this.screens.suspend();
|
||||
const movie = cutscene(this.container, src, { delay, loops, volume: this.audio.mixerVolume("sfx") });
|
||||
try {
|
||||
const result = await this.wait<{ value?: boolean; error?: any }>((done) => {
|
||||
movie.finished.then((value) => done({ value }), (error) => done({ error }));
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
return result.value!;
|
||||
} finally {
|
||||
movie.destroy();
|
||||
restoreScreens();
|
||||
if (stopMusic) this.audio.setPaused("music", paused);
|
||||
}
|
||||
}
|
||||
|
||||
/** renpy.input(): a text field over the scene. */
|
||||
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
|
||||
this.interactionStart();
|
||||
@@ -658,9 +786,46 @@ export class Engine {
|
||||
}).finally(() => this.menuEl.classList.add("pg-hidden"));
|
||||
}
|
||||
|
||||
private startedAt = performance.now();
|
||||
|
||||
/** Ren'Py's achievement API on top of persistent data (there is no Steam in the browser). */
|
||||
private achievementApi() {
|
||||
const got = (): string[] => this.persistent._achievements ?? [];
|
||||
return {
|
||||
steam: null,
|
||||
register: () => null,
|
||||
sync: () => null,
|
||||
has: (name: any) => got().includes(String(name)),
|
||||
grant: (name: any) => {
|
||||
if (!got().includes(String(name))) {
|
||||
this.persistent._achievements = [...got(), String(name)];
|
||||
this.notify(`Достижение: ${pyStr(name)}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
progress: () => null,
|
||||
clear: (name: any) => {
|
||||
this.persistent._achievements = got().filter((n) => n !== String(name));
|
||||
return null;
|
||||
},
|
||||
clear_all: () => { this.persistent._achievements = []; return null; },
|
||||
};
|
||||
}
|
||||
|
||||
private screenHost(): ScreenHost {
|
||||
const engine = this;
|
||||
return {
|
||||
error: (message) => this.fail(new Error(message)),
|
||||
get audio() { return engine.audio; },
|
||||
setMixer: (name, value) => {
|
||||
this.audio.setMixer(name, value);
|
||||
const key = name === "sfx" ? "sound" : name;
|
||||
if (key in this.settings) {
|
||||
(this.settings as any)[key] = value;
|
||||
this.audio.setVolumes(this.settings);
|
||||
this.persistSettings("audio");
|
||||
}
|
||||
},
|
||||
py: this.py,
|
||||
fileUrl: (path) => this.fileUrl(path),
|
||||
image: (value) => this.screenImage(value),
|
||||
@@ -725,6 +890,10 @@ export class Engine {
|
||||
|
||||
private resetStore() {
|
||||
this.screens.clear();
|
||||
this.game.images = { ...this.originalImages };
|
||||
this.registeredImages = {};
|
||||
this.imagesByTag.clear();
|
||||
for (const name of Object.keys(this.game.images)) this.indexImage(name);
|
||||
this.store = {};
|
||||
try {
|
||||
this.py.exec(this.game.init);
|
||||
@@ -798,11 +967,7 @@ export class Engine {
|
||||
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
|
||||
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
|
||||
if (!this.labels.has(name) && !anchor) {
|
||||
this.notify(`Метка «${target}» не найдена`);
|
||||
console.warn("[pgvn] нет метки", target);
|
||||
if (frame) this.stack.pop();
|
||||
this.advance();
|
||||
return;
|
||||
throw new Error(`Метка «${target}» не найдена`);
|
||||
}
|
||||
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
|
||||
if (params?.length) this.bindParams(params, args, frame);
|
||||
@@ -905,6 +1070,7 @@ export class Engine {
|
||||
} catch (err) {
|
||||
if (err instanceof JumpSignal) {
|
||||
if (err.kind === "jump") this.jump(err.target);
|
||||
else if (err.kind === "return") this.doReturn();
|
||||
else this.call(err.target);
|
||||
} else throw err;
|
||||
}
|
||||
@@ -915,8 +1081,7 @@ export class Engine {
|
||||
if (err.message === "__quit__") this.onExit();
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
this.notify(`Ошибка плеера: ${(err as Error).message}`);
|
||||
this.fail(err);
|
||||
} finally {
|
||||
if (gen === this.gen) this.running = false;
|
||||
}
|
||||
@@ -1073,8 +1238,7 @@ export class Engine {
|
||||
else this.py.exec(node.code);
|
||||
} catch (err) {
|
||||
if (err instanceof JumpSignal) throw err;
|
||||
if (this.game.engine === "naninovel") throw err;
|
||||
console.warn("[pgvn] ошибка python", err, node.code);
|
||||
throw err;
|
||||
}
|
||||
this.advance();
|
||||
return;
|
||||
@@ -1383,6 +1547,14 @@ export class Engine {
|
||||
window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000);
|
||||
}
|
||||
|
||||
private fail(error: any) {
|
||||
this.gen++;
|
||||
this.wakeAll();
|
||||
this.running = false;
|
||||
this.screens.suspend();
|
||||
this.notify(`Ошибка плеера: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
|
||||
// --- session control --------------------------------------------------------------
|
||||
|
||||
private restart(setup: () => void) {
|
||||
@@ -1487,6 +1659,7 @@ export class Engine {
|
||||
store: encodeValue(this.store),
|
||||
screens: encodeValue(this.screens.snapshot()),
|
||||
scene: this.renderer.snapshot(),
|
||||
registeredImages: { ...this.registeredImages },
|
||||
music: this.audio.current("music"),
|
||||
window: this.windowShown,
|
||||
camera: this.renderer.cameraAt,
|
||||
@@ -1535,6 +1708,7 @@ export class Engine {
|
||||
/** Put the game into the state described by a save (also used to come back from a replay). */
|
||||
private applySave(data: SaveData) {
|
||||
this.resetStore();
|
||||
for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition);
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
this.screens.restore(decodeValue(data.screens ?? []));
|
||||
this.renderer.scene();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { action } from "./musicroom";
|
||||
import { OBJECT, PyClass, PyDict, nativeMethod, registerNativeClass, truthy, type PyInstance } from "./python";
|
||||
|
||||
type Condition = { kind: "seen"; images: string[] } | { kind: "expression"; source: string };
|
||||
interface Picture { displayables: any[]; conditions: Condition[] }
|
||||
interface Button { pictures: Picture[]; conditions: Condition[] }
|
||||
|
||||
export function galleryClass(host: {
|
||||
seen(name: string): boolean;
|
||||
evaluate(source: string): any;
|
||||
show(pictures: any[][], transition: any): void;
|
||||
button(image: any, enabled: boolean, action: any, properties: Record<string, any>): any;
|
||||
}): PyClass {
|
||||
const gallery = new PyClass("Gallery", [OBJECT]);
|
||||
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) =>
|
||||
gallery.attrs.set(name, nativeMethod(fn));
|
||||
const conditions = (items: Condition[]) => items.every((c) => c.kind === "seen"
|
||||
? c.images.every((name) => host.seen(name)) : truthy(host.evaluate(c.source)));
|
||||
const get = (self: PyInstance, name: string): Button => {
|
||||
const buttons = self.attrs.buttons as PyDict;
|
||||
if (!buttons.has(name)) throw new Error(`Кнопка галереи «${name}» не определена`);
|
||||
return buttons.get(name);
|
||||
};
|
||||
const pictures = (button: Button) => conditions(button.conditions)
|
||||
? button.pictures.filter((picture) => conditions(picture.conditions)).map((picture) => picture.displayables) : [];
|
||||
const current = (self: PyInstance) => get(self, self.attrs.current_name);
|
||||
const unlockable = (self: PyInstance): Button | Picture => {
|
||||
const button = current(self);
|
||||
return self.attrs.unlockable_index >= 0 ? button.pictures[self.attrs.unlockable_index] : button;
|
||||
};
|
||||
const makeAction = (self: PyInstance, name: string) => {
|
||||
const button = get(self, name);
|
||||
return pictures(button).length ? action(() => host.show(pictures(button), self.attrs.transition),
|
||||
() => false, () => pictures(button).length > 0) : null;
|
||||
};
|
||||
method("__init__", (self) => {
|
||||
self.attrs.buttons = new PyDict();
|
||||
self.attrs.transition = null;
|
||||
self.attrs.locked_button = null;
|
||||
return null;
|
||||
});
|
||||
method("button", (self, name: string) => {
|
||||
const button: Button = { pictures: [], conditions: [] };
|
||||
(self.attrs.buttons as PyDict).set(name, button);
|
||||
self.attrs.current_name = name;
|
||||
self.attrs.unlockable_index = -1;
|
||||
return null;
|
||||
});
|
||||
const image = (self: PyInstance, ...displayables: any[]) => {
|
||||
const button = current(self);
|
||||
const picture: Picture = { displayables, conditions: [] };
|
||||
button.pictures.push(picture);
|
||||
self.attrs.unlockable_index = button.pictures.length - 1;
|
||||
return null;
|
||||
};
|
||||
method("image", image);
|
||||
method("display", image);
|
||||
const unlock = (self: PyInstance, ...images: string[]) => {
|
||||
unlockable(self).conditions.push({ kind: "seen", images });
|
||||
return null;
|
||||
};
|
||||
method("unlock", unlock);
|
||||
method("unlock_image", (self, ...images: string[]) => { image(self, ...images); return unlock(self, ...images); });
|
||||
method("condition", (self, source: string) => {
|
||||
if (typeof source !== "string") throw new Error("Некорректное условие Gallery.condition");
|
||||
unlockable(self).conditions.push({ kind: "expression", source });
|
||||
return null;
|
||||
});
|
||||
method("Action", makeAction);
|
||||
method("make_button", (self, ...given: any[]) => {
|
||||
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||
const name = given[0] ?? kw.name;
|
||||
const unlocked = given[1] ?? kw.unlocked;
|
||||
const locked = given[2] ?? kw.locked ?? self.attrs.locked_button;
|
||||
const a = makeAction(self, name);
|
||||
const props = { ...kw };
|
||||
for (const key of ["name", "unlocked", "locked"]) delete props[key];
|
||||
return host.button(a ? unlocked : locked, a !== null, a, props);
|
||||
});
|
||||
return registerNativeClass(gallery);
|
||||
}
|
||||
@@ -37,6 +37,11 @@ export class Translator {
|
||||
return (tid && this.lines.get(tid)) || source;
|
||||
}
|
||||
|
||||
/** `renpy.translate_string(text, language=...)`: only the loaded language can be applied. */
|
||||
translateIn(source: string, renpyLanguage: string | null): string {
|
||||
return renpyLanguage && renpyLanguage === this.renpyName ? this.str(source) : source;
|
||||
}
|
||||
|
||||
/** Names, menu choices, `_()` strings: looked up by their source text. */
|
||||
str(source: string): string {
|
||||
return this.strings.get(source) ?? source;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Ren'Py uses the mask movie's red channel as opacity, not its alpha channel. */
|
||||
export function applyMovieMask(picture: Uint8ClampedArray, mask: Uint8ClampedArray) {
|
||||
if (picture.length !== mask.length) throw new Error("Размеры кадров маски не совпадают");
|
||||
for (let i = 3; i < picture.length; i += 4) picture[i] = Math.round(picture[i] * mask[i - 3] / 255);
|
||||
}
|
||||
|
||||
export function movieDisplayable(source: string, mask: string | null, loop = true): HTMLElement {
|
||||
const video = document.createElement("video");
|
||||
video.src = source;
|
||||
video.autoplay = true;
|
||||
video.loop = loop;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
if (!mask) return video;
|
||||
const alpha = document.createElement("video");
|
||||
alpha.src = mask;
|
||||
alpha.loop = loop;
|
||||
alpha.muted = true;
|
||||
alpha.playsInline = true;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "pg-masked-movie";
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!ctx || !maskCtx) throw new Error("Не удалось создать поверхность для видео с маской");
|
||||
let last = -1;
|
||||
let failed = false;
|
||||
const draw = () => {
|
||||
if (failed) return;
|
||||
if (!canvas.isConnected) { video.pause(); alpha.pause(); return; }
|
||||
if (video.readyState >= 2 && alpha.readyState >= 2 && video.videoWidth && video.currentTime !== last) {
|
||||
if (Math.abs(alpha.currentTime - video.currentTime) > 0.06) alpha.currentTime = video.currentTime;
|
||||
const w = video.videoWidth, h = video.videoHeight;
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = maskCanvas.width = w;
|
||||
canvas.height = maskCanvas.height = h;
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
maskCtx.drawImage(alpha, 0, 0, w, h);
|
||||
const pixels = ctx.getImageData(0, 0, w, h);
|
||||
applyMovieMask(pixels.data, maskCtx.getImageData(0, 0, w, h).data);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
last = video.currentTime;
|
||||
}
|
||||
if (video.ended && !loop) { video.pause(); alpha.pause(); return; }
|
||||
requestAnimationFrame(draw);
|
||||
};
|
||||
video.onerror = alpha.onerror = () => { failed = true; canvas.dataset.error = "Не удалось загрузить видео или его маску"; video.pause(); alpha.pause(); };
|
||||
void video.play().catch(() => {});
|
||||
void alpha.play().catch(() => {});
|
||||
requestAnimationFrame(draw);
|
||||
return canvas;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Ren'Py's MusicRoom: a playlist with play/stop/next/shuffle actions, as a class games can subclass.
|
||||
|
||||
import type { AudioSystem } from "./audio";
|
||||
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, registerNativeClass, truthy } from "./python";
|
||||
|
||||
/** An action the player runs when a button is used; `selected`/`sensitive` drive the button's look. */
|
||||
export interface NativeAction {
|
||||
__action__: true;
|
||||
__call__: () => null;
|
||||
get_selected: () => boolean;
|
||||
get_sensitive: () => boolean;
|
||||
}
|
||||
|
||||
export function action(run: () => void, selected: () => boolean = () => false,
|
||||
sensitive: () => boolean = () => true): NativeAction {
|
||||
return { __action__: true, __call__: () => { run(); return null; }, get_selected: selected, get_sensitive: sensitive };
|
||||
}
|
||||
|
||||
export function isNativeAction(x: any): x is NativeAction {
|
||||
return !!x && typeof x === "object" && x.__action__ === true;
|
||||
}
|
||||
|
||||
export function musicRoomClass(audio: AudioSystem): PyClass {
|
||||
const room = new PyClass("MusicRoom", [OBJECT]);
|
||||
const set = (name: string, fn: (self: PyInstance, ...a: any[]) => any) => room.attrs.set(name, nativeMethod(fn));
|
||||
|
||||
const unlocked = (self: PyInstance, file: string): boolean => {
|
||||
const check = self.cls.find("is_unlocked")?.value;
|
||||
return truthy(typeof check === "function" ? check(self, file) : null);
|
||||
};
|
||||
const playlist = (self: PyInstance): string[] =>
|
||||
(self.attrs.filenames as string[]).filter((f) => unlocked(self, f));
|
||||
const channel = (self: PyInstance) => String(self.attrs.channel);
|
||||
|
||||
const play = (self: PyInstance, file: string) => {
|
||||
const a = self.attrs;
|
||||
a.current = file;
|
||||
a.heard.push(file);
|
||||
audio.play(channel(self), [file], { loop: false, fadein: a.fadein, fadeout: a.fadeout });
|
||||
audio.onFinished.set(channel(self), () => advance(self));
|
||||
};
|
||||
const stop = (self: PyInstance) => {
|
||||
self.attrs.current = null;
|
||||
audio.onFinished.delete(channel(self));
|
||||
audio.stop(channel(self), self.attrs.fadeout);
|
||||
const stopAction = self.attrs.stop_action;
|
||||
if (stopAction) (callableOf(stopAction) ?? (() => null))();
|
||||
};
|
||||
const step = (self: PyInstance, direction: 1 | -1, wrap: boolean) => {
|
||||
const list = playlist(self);
|
||||
if (!list.length) return;
|
||||
const at = list.indexOf(self.attrs.current);
|
||||
if (self.attrs.shuffle && list.length > 1) {
|
||||
const others = list.filter((f) => f !== self.attrs.current);
|
||||
return play(self, others[Math.floor(Math.random() * others.length)]);
|
||||
}
|
||||
const next = at + direction;
|
||||
if (next >= 0 && next < list.length) return play(self, list[next]);
|
||||
if (wrap) return play(self, list[direction > 0 ? 0 : list.length - 1]);
|
||||
stop(self);
|
||||
};
|
||||
const advance = (self: PyInstance) => {
|
||||
if (self.attrs.single_track) {
|
||||
if (self.attrs.loop) play(self, self.attrs.current);
|
||||
else stop(self);
|
||||
return;
|
||||
}
|
||||
step(self, 1, !!self.attrs.loop);
|
||||
};
|
||||
|
||||
set("__init__", (self, ...given) => {
|
||||
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||
const [chan, fadeout, fadein, loop, single, shuffle, stopAction] = given;
|
||||
Object.assign(self.attrs, {
|
||||
channel: chan ?? kw.channel ?? "music", fadeout: fadeout ?? kw.fadeout ?? 0, fadein: fadein ?? kw.fadein ?? 0,
|
||||
loop: loop ?? kw.loop ?? true, single_track: single ?? kw.single_track ?? false,
|
||||
shuffle: shuffle ?? kw.shuffle ?? false, stop_action: stopAction ?? kw.stop_action ?? null,
|
||||
filenames: [], always: new PyDict(), actions: new PyDict(), heard: [], current: null,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
set("add", (self, file: string, ...rest) => {
|
||||
const kw = rest.length && rest[rest.length - 1]?.__kw ? rest.pop().__kw : {};
|
||||
const always = rest[0] ?? kw.always_unlocked ?? false;
|
||||
self.attrs.filenames.push(file);
|
||||
(self.attrs.always as PyDict).set(file, truthy(always));
|
||||
(self.attrs.actions as PyDict).set(file, rest[1] ?? kw.action ?? null);
|
||||
return null;
|
||||
});
|
||||
set("is_unlocked", (self, file: string) =>
|
||||
truthy((self.attrs.always as PyDict).get(file)) || self.attrs.heard.includes(file));
|
||||
set("get_playlist", (self) => playlist(self));
|
||||
set("Play", (self, file: string | null = null) => {
|
||||
const target = () => file ?? self.attrs.current ?? playlist(self)[0] ?? null;
|
||||
return action(() => { const f = target(); if (f) play(self, f); },
|
||||
() => file !== null && self.attrs.current === file && audio.current(channel(self)) !== null,
|
||||
() => file === null || unlocked(self, file));
|
||||
});
|
||||
set("Stop", (self) => action(() => stop(self), () => audio.current(channel(self)) === null));
|
||||
set("TogglePause", (self) => action(() => audio.setPaused(channel(self), !audio.paused(channel(self))),
|
||||
() => audio.paused(channel(self))));
|
||||
set("Next", (self) => action(() => step(self, 1, true)));
|
||||
set("Previous", (self) => action(() => step(self, -1, true)));
|
||||
set("RandomPlay", (self) => action(() => {
|
||||
const list = playlist(self);
|
||||
if (list.length) play(self, list[Math.floor(Math.random() * list.length)]);
|
||||
}));
|
||||
for (const [name, key] of [["Loop", "loop"], ["SingleTrack", "single_track"], ["Shuffle", "shuffle"]] as const) {
|
||||
set(`Set${name}`, (self, value: any) => action(() => { self.attrs[key] = truthy(value); }, () => truthy(self.attrs[key]) === truthy(value)));
|
||||
set(`Toggle${name}`, (self) => action(() => { self.attrs[key] = !truthy(self.attrs[key]); }, () => truthy(self.attrs[key])));
|
||||
}
|
||||
set("UnlockAll", (self) => action(() => { for (const f of self.attrs.filenames) (self.attrs.always as PyDict).set(f, true); }));
|
||||
set("LockAll", (self) => action(() => { (self.attrs.always as PyDict).map.clear(); self.attrs.heard = []; }));
|
||||
return registerNativeClass(room);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ function key(k: any) {
|
||||
}
|
||||
|
||||
export class JumpSignal {
|
||||
constructor(public kind: "jump" | "call", public target: string) {}
|
||||
constructor(public kind: "jump" | "call" | "return", public target: string) {}
|
||||
}
|
||||
|
||||
class BreakSignal {}
|
||||
@@ -54,7 +54,7 @@ export type Builtin = ((...args: any[]) => any) & { __py?: true };
|
||||
// `class` statements of the game become PyClass objects. Instances keep their data in
|
||||
// `attrs`, so a plain JSON round trip (saves) restores them given the class registry.
|
||||
|
||||
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__"]);
|
||||
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__"]);
|
||||
const MAX_CALL_DEPTH = 150;
|
||||
|
||||
export class PyClass {
|
||||
@@ -98,6 +98,90 @@ class PyDescriptor {
|
||||
export const OBJECT = new PyClass("object", []);
|
||||
const classRegistry = new Map<string, PyClass>();
|
||||
|
||||
export function registerNativeClass(cls: PyClass): PyClass {
|
||||
classRegistry.set(cls.name, cls);
|
||||
return cls;
|
||||
}
|
||||
|
||||
/** A method written in TypeScript: bound to the instance like a game-defined one. */
|
||||
export function nativeMethod<F extends (...args: any[]) => any>(fn: F): F {
|
||||
(fn as any).__method = true;
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes the player provides itself because the game's own version is a Ren'Py Displayable written
|
||||
* against the render API (the converter drops the game's class of the same name).
|
||||
*
|
||||
* ColorPicker keeps the chosen colour and the slot it belongs to; the game's wardrobe uses it as a plain
|
||||
* state holder (`picker.set_color(c)`, `picker.color_slot = slot`) and draws its own sliders.
|
||||
*/
|
||||
export function nativeClasses(): Record<string, PyClass> {
|
||||
const picker = new PyClass("ColorPicker", [OBJECT]);
|
||||
const hsv = (color: any): [number, number, number] | null => {
|
||||
const text = typeof color === "string" ? color : color?.hexcode;
|
||||
const m = typeof text === "string" ? /^#([0-9a-f]{3}|[0-9a-f]{6})/i.exec(text) : null;
|
||||
if (!m) return null;
|
||||
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
|
||||
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const max = Math.max(r, g, b);
|
||||
const d = max - Math.min(r, g, b);
|
||||
const h = d === 0 ? 0 : max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
return [h / 6, max === 0 ? 0 : d / max, max];
|
||||
};
|
||||
picker.attrs.set("__init__", nativeMethod((self: PyInstance, ...given: any[]) => {
|
||||
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||
const [xsize, ysize, startColor, fourCorners, saved, lastSaved] = given;
|
||||
const a = self.attrs;
|
||||
a.xsize = xsize ?? kw.xsize ?? 100;
|
||||
a.ysize = ysize ?? kw.ysize ?? 100;
|
||||
a.saved_colors = saved ?? kw.saved_colors ?? new PyDict();
|
||||
a.last_saved_color = lastSaved ?? kw.last_saved_color ?? null;
|
||||
a.color_slot = a.last_saved_color;
|
||||
a.mouseup_callback = kw.mouseup_callback ?? null;
|
||||
a.four_corners = fourCorners ?? kw.four_corners ?? null;
|
||||
a.hue_rotation = 0;
|
||||
a.dragging = false;
|
||||
const start = startColor ?? kw.start_color ?? (a.four_corners ? a.four_corners[0] : "#f00");
|
||||
(picker.attrs.get("set_color") as any)(self, start);
|
||||
(picker.attrs.get("save_color") as any)(self, a.last_saved_color);
|
||||
return null;
|
||||
}));
|
||||
picker.attrs.set("set_color", nativeMethod((self: PyInstance, color: any) => {
|
||||
const a = self.attrs;
|
||||
a.color = color;
|
||||
a.dragging = false;
|
||||
const h = a.four_corners ? null : hsv(color);
|
||||
a.selector_xpos = h ? h[1] : 1;
|
||||
a.selector_ypos = h ? 1 - h[2] : 0;
|
||||
a.hue_rotation = h ? h[0] : 0;
|
||||
return null;
|
||||
}));
|
||||
picker.attrs.set("save_color", nativeMethod((self: PyInstance, key: any) => {
|
||||
(self.attrs.saved_colors as PyDict).set(key, self.attrs.color);
|
||||
return null;
|
||||
}));
|
||||
picker.attrs.set("set_saved_color", nativeMethod((self: PyInstance, key: any, color: any) => {
|
||||
(self.attrs.saved_colors as PyDict).set(key, color);
|
||||
return null;
|
||||
}));
|
||||
picker.attrs.set("get_color", nativeMethod((self: PyInstance, key: any) => {
|
||||
const saved = self.attrs.saved_colors as PyDict;
|
||||
return saved.has(key) ? saved.get(key) : "#000";
|
||||
}));
|
||||
picker.attrs.set("swap_to_saved_color", nativeMethod((self: PyInstance, key: any) => {
|
||||
const saved = self.attrs.saved_colors as PyDict;
|
||||
(picker.attrs.get("set_color") as any)(self, saved.has(key) ? saved.get(key) : "#000");
|
||||
self.attrs.last_saved_color = key;
|
||||
return null;
|
||||
}));
|
||||
classRegistry.set(picker.name, picker);
|
||||
// base class of the actions a game defines itself (`class Mine(Action): def __call__(self): ...`)
|
||||
const base = new PyClass("Action", [OBJECT]);
|
||||
classRegistry.set(base.name, base);
|
||||
return { ColorPicker: picker, Action: base };
|
||||
}
|
||||
|
||||
/** Builtin exception hierarchy; ``args`` holds the constructor arguments. */
|
||||
export const BASE_EXCEPTION = new PyClass("BaseException", [OBJECT]);
|
||||
BASE_EXCEPTION.attrs.set("__init__", (self: PyInstance, ...args: any[]) => {
|
||||
@@ -160,8 +244,47 @@ export function truthy(v: any): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The function behind a callable value: a function, or an instance whose class defines __call__. */
|
||||
export function callableOf(x: any): ((...args: any[]) => any) | null {
|
||||
if (typeof x === "function") return x;
|
||||
if (x instanceof PyInstance) {
|
||||
const found = x.cls.find("__call__");
|
||||
if (found && typeof found.value === "function") return (...args: any[]) => found.value(x, ...args);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every instance reachable from the game's variables (what `gc.get_objects()` shows a game). */
|
||||
export function liveObjects(root: any): any[] {
|
||||
const seen = new Set<any>();
|
||||
const out: any[] = [];
|
||||
const stack: any[] = [root];
|
||||
while (stack.length) {
|
||||
const value = stack.pop();
|
||||
if (value === null || typeof value !== "object" || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
if (value instanceof PyInstance) {
|
||||
out.push(value);
|
||||
stack.push(...Object.values(value.attrs));
|
||||
} else if (Array.isArray(value)) {
|
||||
stack.push(...value);
|
||||
} else if (value instanceof PyDict) {
|
||||
for (const [k, v] of value.map.entries()) stack.push(k, v);
|
||||
} else if (!(value instanceof PyClass)) {
|
||||
stack.push(...Object.values(value));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function pyEq(a: any, b: any): boolean {
|
||||
if (a === b) return true;
|
||||
for (const [self, other] of [[a, b], [b, a]]) {
|
||||
if (self instanceof PyInstance && activePython) {
|
||||
const found = self.cls.find("__eq__");
|
||||
if (found && typeof found.value === "function") return truthy(found.value(self, other));
|
||||
}
|
||||
}
|
||||
if (a === null || b === null || a === undefined || b === undefined) return a == b;
|
||||
if (Array.isArray(a) && Array.isArray(b))
|
||||
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
|
||||
@@ -287,6 +410,13 @@ function safeProperty(name: any): boolean {
|
||||
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
|
||||
}
|
||||
|
||||
// Interpreted classes keep attributes in isolated maps/records. Names such as
|
||||
// `arguments` are ordinary game data there; host functions retain the stricter guard.
|
||||
function safePythonProperty(object: any, name: any): boolean {
|
||||
return safeProperty(name) || (name === "arguments"
|
||||
&& (object instanceof PyInstance || object instanceof PyClass || object instanceof SuperProxy));
|
||||
}
|
||||
|
||||
function methodOf(obj: any, name: string): any {
|
||||
if (Array.isArray(obj)) {
|
||||
const m: Record<string, Builtin> = {
|
||||
@@ -453,7 +583,7 @@ export class Python {
|
||||
case "attr": {
|
||||
const o = this.eval(e.o);
|
||||
if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) {
|
||||
if (!safeProperty(e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
||||
if (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
||||
const v = this.attrOf(o, e.a);
|
||||
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
||||
return v === undefined ? null : v;
|
||||
@@ -585,6 +715,10 @@ export class Python {
|
||||
}
|
||||
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||
if (f instanceof PyClass) return this.instantiate(f, call);
|
||||
if (f instanceof PyInstance) {
|
||||
const bound = callableOf(f);
|
||||
if (bound) return bound(...call);
|
||||
}
|
||||
if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions
|
||||
if (typeof f !== "function") {
|
||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||
@@ -615,7 +749,7 @@ export class Python {
|
||||
}
|
||||
case "attr": {
|
||||
const o = this.eval(t.o);
|
||||
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
||||
if (!safePythonProperty(o, t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
||||
if (o instanceof PyInstance) {
|
||||
const found = o.cls.find(t.a);
|
||||
if (found?.value instanceof PyDescriptor && found.value.kind === "property") {
|
||||
@@ -1118,6 +1252,7 @@ export class Python {
|
||||
}
|
||||
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||
if (f instanceof PyClass) return yield* this.instantiateG(f, call);
|
||||
if (f instanceof PyInstance) f = callableOf(f) ?? f;
|
||||
if (typeof f !== "function") {
|
||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||
return null;
|
||||
@@ -1320,7 +1455,7 @@ export function userBuiltins(py: Python): Record<string, any> {
|
||||
return false;
|
||||
};
|
||||
const get = (o: any, name: string): any => {
|
||||
if (typeof name !== "string" || (!safeProperty(name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
|
||||
if (typeof name !== "string" || (!safePythonProperty(o, name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
|
||||
if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name);
|
||||
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
|
||||
if (o === null || o === undefined || typeof o === "function") return undefined;
|
||||
@@ -1359,10 +1494,10 @@ export function userBuiltins(py: Python): Record<string, any> {
|
||||
throw new Error(`нет атрибута ${a[1]}`);
|
||||
},
|
||||
setattr: (o: any, name: string, value: any) => {
|
||||
if (!safeProperty(name)) throw new Error("Служебный атрибут недоступен");
|
||||
if (!safePythonProperty(o, name)) throw new Error("Служебный атрибут недоступен");
|
||||
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||
},
|
||||
callable: (x: any) => typeof x === "function" || x instanceof PyClass,
|
||||
callable: (x: any) => typeof x === "function" || x instanceof PyClass || callableOf(x) !== null,
|
||||
map: (f: any, xs: any) => [...iterate(xs)].map((x) => f(x)),
|
||||
filter: (f: any, xs: any) => [...iterate(xs)].filter((x) => (f === null ? truthy(x) : truthy(f(x)))),
|
||||
sorted: (...a: any[]) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
||||
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
||||
import { movieDisplayable } from "./movie";
|
||||
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
||||
|
||||
const POSITIONS: Record<string, Record<string, number>> = {
|
||||
@@ -138,6 +139,9 @@ export class Renderer {
|
||||
|
||||
// --- displayables ------------------------------------------------------------
|
||||
|
||||
/** Parameters of the ATL transform that is building a picture right now. */
|
||||
private frame: Map<string, any> | null = null;
|
||||
|
||||
private build(def: ImageDef, depth = 0): HTMLElement {
|
||||
if (depth > 8) return document.createElement("div");
|
||||
if (def.ref) {
|
||||
@@ -146,7 +150,7 @@ export class Renderer {
|
||||
}
|
||||
if (def.expr) {
|
||||
// an image defined by a Python expression: its value names a picture, evaluated when shown
|
||||
const value = this.host.evalExpr(def.expr);
|
||||
const value = this.host.evalExpr(def.expr, this.frame ?? undefined);
|
||||
if (typeof value === "string") {
|
||||
const ref = this.host.resolveRef(value);
|
||||
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
||||
@@ -165,6 +169,17 @@ export class Renderer {
|
||||
d.style.height = `${this.height}px`;
|
||||
return d;
|
||||
}
|
||||
if (def.alphaMask) {
|
||||
const picture = this.build(def.alphaMask.img, depth + 1);
|
||||
const maskEl = this.build(def.alphaMask.mask, depth + 1);
|
||||
const file = (maskEl instanceof HTMLImageElement ? maskEl : maskEl.querySelector("img"))?.src;
|
||||
if (file) {
|
||||
picture.style.maskImage = `url("${file}")`;
|
||||
picture.style.maskSize = "100% 100%";
|
||||
picture.style.maskRepeat = "no-repeat";
|
||||
}
|
||||
return picture;
|
||||
}
|
||||
if (def.composite) {
|
||||
const d = document.createElement("div");
|
||||
d.style.position = "relative";
|
||||
@@ -182,14 +197,10 @@ export class Renderer {
|
||||
return d;
|
||||
}
|
||||
if (def.movie) {
|
||||
const v = document.createElement("video");
|
||||
const src = this.host.url(def.movie);
|
||||
if (src) v.src = src;
|
||||
v.autoplay = true;
|
||||
v.loop = true;
|
||||
v.muted = true;
|
||||
v.playsInline = true;
|
||||
return v;
|
||||
const mask = def.movieMask ? this.host.url(def.movieMask) : null;
|
||||
if (!src || (def.movieMask && !mask)) throw new Error(`Не найдено видео или маска: ${def.movie}`);
|
||||
return movieDisplayable(src, mask, def.movieLoop ?? true);
|
||||
}
|
||||
if (def.text !== undefined || def.textExpr) {
|
||||
const d = document.createElement("div");
|
||||
@@ -263,7 +274,14 @@ export class Renderer {
|
||||
props: (name) => this.host.transform(name),
|
||||
apply: (p) => this.style(el, { ...p }),
|
||||
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
||||
image: (img, transition) => this.swap(el, img, transition),
|
||||
image: (img, transition, frame) => {
|
||||
this.frame = frame ?? null; // the transform's parameters, for pictures computed from them
|
||||
try {
|
||||
this.swap(el, img, transition);
|
||||
} finally {
|
||||
this.frame = null;
|
||||
}
|
||||
},
|
||||
contains: (img) => {
|
||||
const child = this.build(img);
|
||||
child.style.position = "absolute";
|
||||
@@ -288,16 +306,16 @@ export class Renderer {
|
||||
box.style.position = "relative";
|
||||
const frames = def.frames!;
|
||||
let i = 0;
|
||||
let started = false;
|
||||
box.append(this.build(frames[0].img, depth + 1));
|
||||
const tick = () => {
|
||||
if (box.isConnected) started = true;
|
||||
else if (started) return;
|
||||
const d = frames[i].d ?? 0;
|
||||
const last = i === frames.length - 1;
|
||||
if (!d || (last && !def.repeat)) return;
|
||||
window.setTimeout(() => {
|
||||
if (started && !box.isConnected) return;
|
||||
// The first tick is queued before the caller attaches the element.
|
||||
// By the next tick a detached/replaced preview must stop, even if it
|
||||
// was removed before any timer observed it attached.
|
||||
if (!box.isConnected) return;
|
||||
i = last ? 0 : i + 1;
|
||||
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
||||
tick();
|
||||
@@ -376,15 +394,27 @@ export class Renderer {
|
||||
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
||||
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
||||
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
||||
const ax = anchor(p.xanchor);
|
||||
const ay = anchor(p.yanchor);
|
||||
el.style.transformOrigin = `${ax} ${ay}`;
|
||||
// `size`/`xsize`/`ysize` stretch the picture; a missing side keeps the picture's proportions
|
||||
const [sx, sy] = p.xysize ?? p.size ?? [p.xsize, p.ysize];
|
||||
if (typeof sx === "number") el.style.width = `${sx}px`;
|
||||
if (typeof sy === "number") el.style.height = `${sy}px`;
|
||||
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
||||
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
||||
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
||||
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
|
||||
if (ax.endsWith("px") || ay.endsWith("px"))
|
||||
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
|
||||
const crop = Array.isArray(p.crop) && p.crop.length === 4 ? (p.crop as number[]).map(Number) : null;
|
||||
let ax = anchor(p.xanchor);
|
||||
let ay = anchor(p.yanchor);
|
||||
if (crop) {
|
||||
// only the cropped rectangle is shown, and anchors/positions refer to it
|
||||
const [cx, cy, cw, ch] = crop;
|
||||
const frac = (v: number, size: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? v : v * size);
|
||||
ax = `${cx + frac(p.xanchor, cw)}px`;
|
||||
ay = `${cy + frac(p.yanchor, ch)}px`;
|
||||
el.style.clipPath = `inset(${cy}px calc(100% - ${cx + cw}px) calc(100% - ${cy + ch}px) ${cx}px)`;
|
||||
} else {
|
||||
el.style.clipPath = "";
|
||||
}
|
||||
el.style.transformOrigin = `${ax} ${ay}`;
|
||||
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
||||
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
||||
const filters: string[] = [];
|
||||
if (p.filter) filters.push(`${p.filter}(1)`);
|
||||
|
||||
+334
-14
@@ -3,7 +3,12 @@
|
||||
|
||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
||||
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
|
||||
import { PyDict, pyStr, truthy, type Python } from "./python";
|
||||
import type { AudioSystem } from "./audio";
|
||||
import { isNativeAction } from "./musicroom";
|
||||
import { galleryClass } from "./gallery";
|
||||
import { parseExpression } from "./pyparse";
|
||||
import { movieDisplayable } from "./movie";
|
||||
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
||||
import type { AtSpec, Expr } from "./types";
|
||||
|
||||
export interface ScreenDef {
|
||||
@@ -43,6 +48,10 @@ export interface ScreenHost {
|
||||
setLanguage(code: string | null): void;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
atl(name: string): AtlBlock | null;
|
||||
audio: AudioSystem;
|
||||
/** Set the volume (0..1) of a mixer: "music", "sfx" or "voice". */
|
||||
setMixer(name: string, value: number): void;
|
||||
error?(message: string): void;
|
||||
}
|
||||
|
||||
type Props = Record<string, any>;
|
||||
@@ -62,6 +71,8 @@ export class Displayable {
|
||||
}
|
||||
|
||||
export class BarValue {
|
||||
/** Changes by itself (a playing position): the screen redraws it while it is shown. */
|
||||
live = false;
|
||||
constructor(public value: () => number, public range: () => number, public set?: (v: number) => void) {}
|
||||
}
|
||||
|
||||
@@ -91,7 +102,7 @@ interface Instance {
|
||||
|
||||
interface Ctx {
|
||||
/** How the parent places this child. */
|
||||
layout: "abs" | "stack" | "col" | "row" | "grid";
|
||||
layout: "abs" | "stack" | "col" | "row" | "grid" | "side";
|
||||
prefix: string;
|
||||
inst: Instance;
|
||||
}
|
||||
@@ -108,6 +119,9 @@ export class ScreenManager {
|
||||
private counter = 0;
|
||||
private pending = 0;
|
||||
private rendering: Instance | null = null;
|
||||
private interactionReturn: ((value: any) => void) | null = null;
|
||||
private suspended = false;
|
||||
private galleryClose: (() => void) | null = null;
|
||||
tooltip: any = "";
|
||||
|
||||
constructor(private host: ScreenHost, private screens: Record<string, ScreenDef>,
|
||||
@@ -120,6 +134,25 @@ export class ScreenManager {
|
||||
return name in this.screens;
|
||||
}
|
||||
|
||||
/** ui.interact waits on Return from an already shown screen, without hiding it. */
|
||||
interact(done: (value: any) => void): () => void {
|
||||
if (this.interactionReturn) throw new Error("Уже выполняется ui.interact");
|
||||
const answer = (value: any) => {
|
||||
if (this.interactionReturn !== answer) return;
|
||||
this.interactionReturn = null;
|
||||
done(value);
|
||||
};
|
||||
this.interactionReturn = answer;
|
||||
return () => { if (this.interactionReturn === answer) this.interactionReturn = null; };
|
||||
}
|
||||
|
||||
suspend(): () => void {
|
||||
const previous = this.suspended;
|
||||
this.suspended = true;
|
||||
this.layer.style.visibility = "hidden";
|
||||
return () => { this.suspended = previous; this.layer.style.visibility = previous ? "hidden" : ""; };
|
||||
}
|
||||
|
||||
// --- lifecycle -------------------------------------------------------------------
|
||||
|
||||
show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) {
|
||||
@@ -162,6 +195,10 @@ export class ScreenManager {
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.suspended = false;
|
||||
this.layer.style.visibility = "";
|
||||
this.interactionReturn = null;
|
||||
this.galleryClose?.();
|
||||
for (const inst of [...this.shown.values()]) this.dispose(inst, true);
|
||||
this.tooltip = "";
|
||||
}
|
||||
@@ -198,9 +235,10 @@ export class ScreenManager {
|
||||
|
||||
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
|
||||
refresh() {
|
||||
if (this.pending || !this.shown.size) return;
|
||||
if (this.suspended || this.pending || !this.shown.size) return;
|
||||
this.pending = requestAnimationFrame(() => {
|
||||
this.pending = 0;
|
||||
if (this.suspended) return;
|
||||
for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst);
|
||||
});
|
||||
}
|
||||
@@ -323,10 +361,15 @@ export class ScreenManager {
|
||||
} else if (node.c === "use") {
|
||||
this.use(node, ctx, out);
|
||||
} else if (node.c === "python") {
|
||||
// `renpy.ui.imagebutton(...)` called from game code adds its widget to this screen
|
||||
const outer = this.uiTarget;
|
||||
this.uiTarget = { out, ctx };
|
||||
try {
|
||||
this.host.py.exec(node.code);
|
||||
} catch (err) {
|
||||
this.host.py.warnOnce(`screen python: ${(err as Error).message}`);
|
||||
} finally {
|
||||
this.uiTarget = outer;
|
||||
}
|
||||
} else if (node.c === "key") {
|
||||
this.keyBinding(node, ctx);
|
||||
@@ -341,10 +384,15 @@ export class ScreenManager {
|
||||
}
|
||||
|
||||
private use(node: any, ctx: Ctx, out: HTMLElement[]) {
|
||||
const def = this.screens[node.name];
|
||||
const name = node.nameExpr ? pyStr(this.ev(node.nameExpr)) : node.name;
|
||||
const def = this.screens[name];
|
||||
if (!def) return;
|
||||
const args = node.args.map((a: Expr) => this.ev(a));
|
||||
const kw = Object.fromEntries(Object.entries(node.kw).map(([k, v]) => [k, this.ev(v as Expr)]));
|
||||
for (const spread of node.ks ?? []) {
|
||||
const values = this.ev(spread);
|
||||
if (values && typeof values === "object") Object.assign(kw, values instanceof Map ? Object.fromEntries(values) : values);
|
||||
}
|
||||
const frame = new Map<string, any>();
|
||||
def.params.forEach((p, i) => {
|
||||
if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]);
|
||||
@@ -477,6 +525,8 @@ export class ScreenManager {
|
||||
case "grid": return this.grid(node, ctx);
|
||||
case "viewport": return this.viewport(node, ctx);
|
||||
case "vpgrid": return this.vpgrid(node, ctx);
|
||||
case "side": return this.container(node, ctx, "div", "side", ["side", "fixed"]);
|
||||
case "nearrect": return this.nearRect(node, ctx);
|
||||
case "button": return this.button(node, ctx, "div", ["button"], null);
|
||||
case "textbutton": return this.button(node, ctx, "div", ["button"], this.ev(node.args[0]));
|
||||
case "imagebutton": return this.imageButton(node, ctx);
|
||||
@@ -502,9 +552,19 @@ export class ScreenManager {
|
||||
const gap = num(this.pick(props, "spacing"));
|
||||
if (gap !== null) style.gap = `${gap}px`;
|
||||
if (truthy(this.pick(props, "box_reverse"))) style.flexDirection += "-reverse";
|
||||
if (truthy(this.pick(props, "box_wrap"))) style.flexWrap = "wrap";
|
||||
if (truthy(this.pick(props, "box_wrap"))) {
|
||||
style.flexWrap = "wrap";
|
||||
// between the lines of a wrapped box (`spacing` is between the items of one line)
|
||||
const wrapGap = num(this.pick(props, "box_wrap_spacing"));
|
||||
if (wrapGap !== null) style[layout === "col" ? "columnGap" : "rowGap"] = `${wrapGap}px`;
|
||||
}
|
||||
} else if (layout === "stack") {
|
||||
style.display = "grid";
|
||||
} else if (layout === "side") {
|
||||
// `side "c tl r"`: the children take these places; the centre gets what the edges leave
|
||||
style.display = "grid";
|
||||
style.gridTemplateColumns = "auto 1fr auto";
|
||||
style.gridTemplateRows = "auto 1fr auto";
|
||||
} else if (layout === "grid") {
|
||||
style.display = "grid";
|
||||
const cols = num(this.pick(props, "cols"));
|
||||
@@ -520,10 +580,14 @@ export class ScreenManager {
|
||||
const inner: Ctx = { ...ctx, layout };
|
||||
const children: HTMLElement[] = [];
|
||||
this.nodes(node.body, inner, children);
|
||||
for (const child of children) {
|
||||
const places = layout === "side" ? pyStr(this.ev(node.args[0]) ?? "").split(/\s+/).filter(Boolean) : [];
|
||||
const SIDE_CELLS: Record<string, string> = { tl: "1 / 1", t: "1 / 2", tr: "1 / 3", l: "2 / 1", c: "2 / 2", r: "2 / 3",
|
||||
bl: "3 / 1", b: "3 / 2", br: "3 / 3" };
|
||||
children.forEach((child, i) => {
|
||||
if (layout === "stack") child.style.gridArea = "1 / 1";
|
||||
if (layout === "side" && SIDE_CELLS[places[i]]) child.style.gridArea = SIDE_CELLS[places[i]];
|
||||
el.append(child);
|
||||
}
|
||||
});
|
||||
this.transforms(el, props, ctx);
|
||||
return el;
|
||||
}
|
||||
@@ -557,10 +621,45 @@ export class ScreenManager {
|
||||
return el;
|
||||
}
|
||||
|
||||
/** The widget whose `ToggleFocus` opened a popup, and where it is (in screen coordinates). */
|
||||
private focus: { id: string; rect: { x: number; y: number; w: number; h: number } } | null = null;
|
||||
private activated: HTMLElement | null = null;
|
||||
|
||||
/** A box that appears beside the focused widget: below it, above (`prefer_top`) or on `preferred_side`. */
|
||||
private nearRect(node: any, ctx: Ctx): HTMLElement {
|
||||
const el = this.container(node, ctx, "div", "abs", ["fixed"]);
|
||||
const props = this.resolve(node, ctx, ["fixed"]);
|
||||
const rect = this.focus && this.focus.id === pyStr(this.ev(props.focus)) ? this.focus.rect : null;
|
||||
el.style.position = "absolute";
|
||||
if (!rect) return el;
|
||||
const dx = num(this.pick(props, "xpos")) ?? 0;
|
||||
const dy = num(this.pick(props, "ypos")) ?? 0;
|
||||
const side = pyStr(this.pick(props, "preferred_side") ?? "");
|
||||
let [x, y, shift] = [rect.x + rect.w / 2, rect.y + rect.h, "-50%, 0"];
|
||||
if (truthy(this.pick(props, "prefer_top"))) [x, y, shift] = [rect.x + rect.w / 2, rect.y, "-50%, -100%"];
|
||||
else if (side === "left") [x, y, shift] = [rect.x, rect.y + rect.h / 2, "-100%, -50%"];
|
||||
else if (side === "right") [x, y, shift] = [rect.x + rect.w, rect.y + rect.h / 2, "0, -50%"];
|
||||
el.style.left = `${x + dx}px`;
|
||||
el.style.top = `${y + dy}px`;
|
||||
el.style.transform = `translate(${shift})`;
|
||||
return el;
|
||||
}
|
||||
|
||||
private screenRect(el: HTMLElement | null): { x: number; y: number; w: number; h: number } {
|
||||
const root = el?.closest(".pg-root") as HTMLElement | null;
|
||||
if (!el || !root) return { x: 0, y: 0, w: 0, h: 0 };
|
||||
const box = el.getBoundingClientRect();
|
||||
const area = root.getBoundingClientRect();
|
||||
const scale = root.offsetWidth ? area.width / root.offsetWidth : 1;
|
||||
return { x: (box.left - area.left) / scale, y: (box.top - area.top) / scale, w: box.width / scale, h: box.height / scale };
|
||||
}
|
||||
|
||||
private viewport(node: any, ctx: Ctx): HTMLElement {
|
||||
const el = this.container(node, ctx, "div", "col", ["viewport"]);
|
||||
el.classList.add("pg-sviewport");
|
||||
const props = this.resolve(node, ctx, ["viewport"]);
|
||||
const viewportId = this.pick(props, "id");
|
||||
if (viewportId) el.dataset.viewportId = pyStr(viewportId);
|
||||
const mode = pyStr(this.pick(props, "scrollbars") ?? "");
|
||||
el.style.overflowY = mode.includes("vertical") || this.pick(props, "mousewheel") ? "auto" : "hidden";
|
||||
el.style.overflowX = mode.includes("horizontal") ? "auto" : "hidden";
|
||||
@@ -600,6 +699,51 @@ export class ScreenManager {
|
||||
return el;
|
||||
}
|
||||
|
||||
private showGallery(pictures: any[][], transition: any) {
|
||||
this.galleryClose?.();
|
||||
if (!pictures.length) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pg-gallery";
|
||||
Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "9999", background: "black", display: "grid" });
|
||||
const image = document.createElement("div");
|
||||
Object.assign(image.style, { display: "grid", gridArea: "1 / 1", placeItems: "center" });
|
||||
const controls = document.createElement("div");
|
||||
Object.assign(controls.style, { gridArea: "1 / 1", alignSelf: "end", justifySelf: "center", zIndex: "1" });
|
||||
let index = 0;
|
||||
const close = () => { overlay.remove(); if (this.galleryClose === close) this.galleryClose = null; };
|
||||
const paint = () => {
|
||||
image.replaceChildren();
|
||||
for (const value of pictures[index]) {
|
||||
const element = this.displayable(value);
|
||||
if (element) { element.style.gridArea = "1 / 1"; image.append(element); }
|
||||
}
|
||||
if (transition?.d && image.animate) image.animate([{ opacity: 0 }, { opacity: 1 }], { duration: transition.d * 1000 });
|
||||
};
|
||||
const button = (label: string, run: () => void) => {
|
||||
const element = document.createElement("button");
|
||||
element.textContent = label;
|
||||
element.onclick = (event) => { event.stopPropagation(); run(); };
|
||||
controls.append(element);
|
||||
};
|
||||
button("Назад", () => { index = (index + pictures.length - 1) % pictures.length; paint(); });
|
||||
button("Закрыть", close);
|
||||
const next = () => { if (++index >= pictures.length) close(); else paint(); };
|
||||
button("Далее", next);
|
||||
overlay.onclick = (event) => { event.stopPropagation(); next(); };
|
||||
overlay.onkeydown = (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") close();
|
||||
else if (["Enter", " ", "ArrowRight"].includes(event.key)) { event.preventDefault(); next(); }
|
||||
else if (event.key === "ArrowLeft") { event.preventDefault(); index = (index + pictures.length - 1) % pictures.length; paint(); }
|
||||
};
|
||||
overlay.tabIndex = 0;
|
||||
overlay.append(image, controls);
|
||||
this.layer.append(overlay);
|
||||
this.galleryClose = close;
|
||||
paint();
|
||||
overlay.focus();
|
||||
}
|
||||
|
||||
private bar(node: any, ctx: Ctx): HTMLElement {
|
||||
const el = document.createElement("div");
|
||||
const props = this.resolve(node, ctx, ["bar"]);
|
||||
@@ -616,6 +760,9 @@ export class ScreenManager {
|
||||
value = source.value();
|
||||
range = source.range();
|
||||
}
|
||||
if (source instanceof BarValue && source.live) {
|
||||
window.setTimeout(() => { if (el.isConnected) this.refresh(); }, 250);
|
||||
}
|
||||
const ratio = range > 0 ? Math.max(0, Math.min(1, value / range)) : 0;
|
||||
fill.style[vertical ? "height" : "width"] = `${ratio * 100}%`;
|
||||
el.append(fill);
|
||||
@@ -641,6 +788,11 @@ export class ScreenManager {
|
||||
if (!(value instanceof Displayable)) return this.host.image(pyStr(value));
|
||||
const [a, b] = value.args;
|
||||
switch (value.kind) {
|
||||
case "gallerybutton": {
|
||||
if (!this.rendering) throw new Error("Кнопка Gallery должна находиться в screen");
|
||||
const props = Object.fromEntries(Object.entries(value.kw).map(([key, v]) => [key, { k: "const", v }]));
|
||||
return this.imageButton({ props, args: [], body: [] }, { inst: this.rendering, layout: "abs", prefix: "" });
|
||||
}
|
||||
case "crop": {
|
||||
const child = this.displayable(b, depth + 1);
|
||||
return child && Array.isArray(a) ? this.cropped(child, a) : child;
|
||||
@@ -710,6 +862,45 @@ export class ScreenManager {
|
||||
}
|
||||
return child;
|
||||
}
|
||||
case "movie": {
|
||||
if (value.kw.play !== undefined || typeof a === "string") {
|
||||
const src = this.host.fileUrl(pyStr(value.kw.play ?? a));
|
||||
const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null;
|
||||
if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie");
|
||||
return movieDisplayable(src, mask, value.kw.loop !== false);
|
||||
}
|
||||
// the picture of the channel that plays video (renpy.music.play(..., channel="movie"))
|
||||
const box = document.createElement("div");
|
||||
const size = value.kw.size;
|
||||
box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%";
|
||||
box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%";
|
||||
const channel = String(value.kw.channel ?? "movie");
|
||||
const sync = () => {
|
||||
const video = this.host.audio.mediaOf(channel);
|
||||
if (video instanceof HTMLVideoElement && video.parentElement !== box) {
|
||||
video.style.width = video.style.height = "100%";
|
||||
box.replaceChildren(video);
|
||||
} else if (!video && box.firstChild) box.replaceChildren();
|
||||
if (box.isConnected || !box.dataset.seen) {
|
||||
if (box.isConnected) box.dataset.seen = "1";
|
||||
window.setTimeout(sync, 200);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
return box;
|
||||
}
|
||||
case "alphamask": {
|
||||
const child = this.displayable(a, depth + 1);
|
||||
const mask = this.displayable(b, depth + 1);
|
||||
const url = mask instanceof HTMLImageElement ? mask.src : "";
|
||||
if (child && url) {
|
||||
child.style.maskImage = `url("${url}")`;
|
||||
child.style.maskSize = "100% 100%";
|
||||
child.style.maskRepeat = "no-repeat";
|
||||
}
|
||||
return child;
|
||||
}
|
||||
case "trackcursor": return this.trackCursor(this.displayable(a, depth + 1), Number(b) || 1);
|
||||
case "composite": {
|
||||
const box = document.createElement("div");
|
||||
const size = a;
|
||||
@@ -745,6 +936,39 @@ export class ScreenManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Menu parallax: the picture drifts opposite to the mouse, `damping` times less than the pointer moves. */
|
||||
private trackCursor(child: HTMLElement | null, damping: number): HTMLElement | null {
|
||||
if (!child) return null;
|
||||
const box = document.createElement("div");
|
||||
box.style.position = "absolute";
|
||||
box.style.left = box.style.top = "0";
|
||||
box.append(child);
|
||||
let target = [0, 0];
|
||||
let shown = [0, 0];
|
||||
let last = performance.now();
|
||||
const move = (ev: PointerEvent) => {
|
||||
const area = box.parentElement;
|
||||
if (!area) return;
|
||||
const rect = area.getBoundingClientRect();
|
||||
const scale = area.offsetWidth ? rect.width / area.offsetWidth : 1;
|
||||
target = [-(ev.clientX - rect.left) / scale / damping, -(ev.clientY - rect.top) / scale / damping];
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
const frame = (now: number) => {
|
||||
if (!box.isConnected && now - last > 1000) {
|
||||
window.removeEventListener("pointermove", move);
|
||||
return;
|
||||
}
|
||||
const k = Math.min(1, 1.5 * damping * (now - last) / 1000);
|
||||
shown = [shown[0] + (target[0] - shown[0]) * k, shown[1] + (target[1] - shown[1]) * k];
|
||||
box.style.transform = `translate(${Math.floor(shown[0])}px, ${Math.floor(shown[1])}px)`;
|
||||
last = now;
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
requestAnimationFrame(frame);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** `crop (x, y, w, h)`: show only that part of the picture. */
|
||||
private cropped(el: HTMLElement, rect: any): HTMLElement {
|
||||
if (!Array.isArray(rect) || rect.length !== 4 || rect.some((n) => num(n) === null)) return el;
|
||||
@@ -871,6 +1095,7 @@ export class ScreenManager {
|
||||
const activate = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (!sensitive) return;
|
||||
this.activated = el;
|
||||
if (typeof clickSound === "string") this.host.play("sound", clickSound, false);
|
||||
this.run(action, ctx.inst);
|
||||
};
|
||||
@@ -1088,15 +1313,29 @@ export class ScreenManager {
|
||||
|
||||
private actionSensitive(action: any): boolean {
|
||||
const list = Array.isArray(action) ? action : [action];
|
||||
return list.every((a) => !(a instanceof ScreenAction) ||
|
||||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true));
|
||||
return list.every((a) => {
|
||||
if (isNativeAction(a)) return a.get_sensitive();
|
||||
if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true);
|
||||
return !(a instanceof ScreenAction) ||
|
||||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true);
|
||||
});
|
||||
}
|
||||
|
||||
/** `get_selected` / `get_sensitive` of a game's Action class, when it defines them. */
|
||||
private askAction(action: PyInstance, method: string, fallback: boolean): boolean {
|
||||
const found = action.cls.find(method)?.value;
|
||||
return typeof found === "function" ? truthy(found(action)) : fallback;
|
||||
}
|
||||
|
||||
private actionSelected(action: any): boolean {
|
||||
const list = Array.isArray(action) ? action : [action];
|
||||
return list.some((a) => {
|
||||
if (isNativeAction(a)) return a.get_selected();
|
||||
if (a instanceof PyInstance) return this.askAction(a, "get_selected", false);
|
||||
if (!(a instanceof ScreenAction)) return false;
|
||||
switch (a.kind) {
|
||||
case "ToggleSetMembership": return Array.isArray(a.args[0]) && a.args[0].some((x: any) => pyEq(x, a.args[1]));
|
||||
case "ToggleFocus": return this.focus?.id === pyStr(a.args[0]);
|
||||
case "SelectedIf": return truthy(a.args[0]);
|
||||
case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1];
|
||||
case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1];
|
||||
@@ -1127,6 +1366,7 @@ export class ScreenManager {
|
||||
|
||||
/** Run what a widget's `action` evaluated to. */
|
||||
run(action: any, screen: Instance | null) {
|
||||
if (this.suspended) return;
|
||||
// actions act on the game, not on the screen's own variable scope
|
||||
const { py } = this.host;
|
||||
const savedFn = py.fn;
|
||||
@@ -1134,7 +1374,10 @@ export class ScreenManager {
|
||||
try {
|
||||
this.perform(action, screen);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ActionAbort)) console.warn("[pgvn] действие screen", err);
|
||||
if (!(err instanceof ActionAbort)) {
|
||||
if (this.host.error) this.host.error((err as Error).message);
|
||||
else console.warn("[pgvn] действие screen", err);
|
||||
}
|
||||
} finally {
|
||||
py.fn = savedFn;
|
||||
}
|
||||
@@ -1147,8 +1390,13 @@ export class ScreenManager {
|
||||
for (const item of action) this.perform(item, screen);
|
||||
return;
|
||||
}
|
||||
if (typeof action === "function") {
|
||||
action();
|
||||
if (isNativeAction(action)) {
|
||||
action.__call__();
|
||||
return;
|
||||
}
|
||||
const call = callableOf(action);
|
||||
if (call) { // a game's own Action class (or any function)
|
||||
call();
|
||||
return;
|
||||
}
|
||||
if (!(action instanceof ScreenAction)) return;
|
||||
@@ -1163,6 +1411,10 @@ export class ScreenManager {
|
||||
owner.onReturn(a ?? null);
|
||||
throw new ActionAbort();
|
||||
}
|
||||
if (this.interactionReturn) {
|
||||
this.interactionReturn(a ?? null);
|
||||
throw new ActionAbort();
|
||||
}
|
||||
return;
|
||||
case "SetVariable": this.assign(a, b, owner); return;
|
||||
case "ToggleVariable": {
|
||||
@@ -1203,7 +1455,25 @@ export class ScreenManager {
|
||||
if (typeof a === "function") a(...rest, ...(Object.keys(action.kw).length ? [{ __kw: action.kw }] : []));
|
||||
return;
|
||||
}
|
||||
case "ToggleSetMembership": {
|
||||
const set = a;
|
||||
if (!Array.isArray(set)) return;
|
||||
const at = set.findIndex((x: any) => pyEq(x, b));
|
||||
if (at >= 0) set.splice(at, 1);
|
||||
else set.push(b);
|
||||
return;
|
||||
}
|
||||
case "ToggleFocus":
|
||||
this.focus = this.focus?.id === pyStr(a) ? null : { id: pyStr(a), rect: this.screenRect(this.activated) };
|
||||
return;
|
||||
case "PauseAudio": {
|
||||
const channel = String(a ?? "music");
|
||||
const value = action.args.length > 1 || action.kw.value !== undefined ? (b ?? action.kw.value) : "toggle";
|
||||
this.host.audio.setPaused(channel, value === "toggle" ? !this.host.audio.paused(channel) : truthy(value));
|
||||
return;
|
||||
}
|
||||
case "Play": this.host.play(String(a), String(b), false); return;
|
||||
case "SetMixer": this.host.setMixer(String(a), Number(b)); return;
|
||||
case "Queue": this.host.play(String(a), String(b), false); return;
|
||||
case "Stop": this.host.stop(String(a)); return;
|
||||
case "Notify": this.host.notify(pyStr(a)); return;
|
||||
@@ -1239,12 +1509,39 @@ export class ScreenManager {
|
||||
for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
|
||||
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
|
||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay"]) out[kind] = make(kind);
|
||||
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
||||
"ToggleFocus", "PauseAudio", "SetMixer"]) out[kind] = make(kind);
|
||||
out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null);
|
||||
out.MixerValue = (mixer: any) => {
|
||||
return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v));
|
||||
};
|
||||
out.AudioPositionValue = (...a: any[]) => {
|
||||
const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {};
|
||||
const channel = String(kw.channel ?? (typeof a[0] === "string" ? a[0] : "music"));
|
||||
const value = new BarValue(() => this.host.audio.position(channel) ?? 0, () => this.host.audio.duration(channel) ?? 1,
|
||||
(v) => this.host.audio.seek(channel, v));
|
||||
value.live = true;
|
||||
return value;
|
||||
};
|
||||
|
||||
const shape = (kind: string) => (...args: any[]) => {
|
||||
const last = args[args.length - 1];
|
||||
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw");
|
||||
return new Displayable(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {});
|
||||
};
|
||||
out.Movie = shape("movie");
|
||||
// a bar that scrolls the viewport with this id
|
||||
const scrollValue = (axis: "x" | "y") => (id: any) => {
|
||||
const target = () => document.querySelector<HTMLElement>(`[data-viewport-id="${CSS.escape(pyStr(id))}"]`);
|
||||
const top = axis === "y" ? "scrollTop" : "scrollLeft";
|
||||
const full = (el: HTMLElement) => (axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth);
|
||||
const value = new BarValue(() => target()?.[top] ?? 0, () => Math.max(1, (target() ? full(target()!) : 1)),
|
||||
(v) => { const el = target(); if (el) el[top] = v; });
|
||||
value.live = true;
|
||||
return value;
|
||||
};
|
||||
out.XScrollValue = scrollValue("x");
|
||||
out.YScrollValue = scrollValue("y");
|
||||
out.Crop = shape("crop");
|
||||
out.LiveCrop = shape("crop");
|
||||
out.Solid = shape("solid");
|
||||
@@ -1261,8 +1558,17 @@ export class ScreenManager {
|
||||
};
|
||||
out.Text = shape("text");
|
||||
out.Image = shape("image");
|
||||
out.ImageReference = shape("image");
|
||||
out.Gallery = galleryClass({
|
||||
seen: (name) => truthy(this.host.py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "renpy" }, a: "seen_image" }, a: [{ k: "const", v: name }], kw: {} })),
|
||||
evaluate: (source) => this.host.py.eval(parseExpression(source)),
|
||||
show: (pictures, transition) => this.showGallery(pictures, transition),
|
||||
button: (image, enabled, action, properties) => new Displayable("gallerybutton", [],
|
||||
{ ...properties, idle: image, insensitive: image, sensitive: enabled, action }),
|
||||
});
|
||||
out.Fixed = shape("composite");
|
||||
out.AlphaMask = (base: any) => base;
|
||||
out.AlphaMask = shape("alphamask");
|
||||
out.TrackCursor = shape("trackcursor");
|
||||
out.At = (child: any) => child;
|
||||
Object.assign(out, colorMatrices);
|
||||
out.im = {
|
||||
@@ -1286,9 +1592,23 @@ export class ScreenManager {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Where the imperative `renpy.ui` API puts widgets: the screen code that is running. */
|
||||
private uiTarget: { out: HTMLElement[]; ctx: Ctx } | null = null;
|
||||
|
||||
/** `renpy.show_screen` and friends. */
|
||||
renpyApi(): Record<string, any> {
|
||||
const widget = (kind: string) => (...a: any[]) => {
|
||||
const last = a[a.length - 1];
|
||||
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
||||
const target = this.uiTarget;
|
||||
if (!target) throw new Error(`renpy.ui.${kind} вызван вне screen`);
|
||||
const props = Object.fromEntries(Object.entries(kw).map(([k, v]) => [k, { k: "const", v }]));
|
||||
const built = this.widget({ w: kind, args: [], props }, target.ctx);
|
||||
if (built) target.out.push(built);
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
ui: { imagebutton: widget("imagebutton") },
|
||||
show_screen: (...a: any[]) => {
|
||||
const last = a[a.length - 1];
|
||||
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
||||
|
||||
@@ -19,9 +19,13 @@ export interface ImageDef {
|
||||
color?: string;
|
||||
ref?: string;
|
||||
movie?: string;
|
||||
movieMask?: string;
|
||||
movieLoop?: boolean;
|
||||
expr?: Expr;
|
||||
switch?: [Expr, ImageDef][];
|
||||
composite?: { pos: [number, number]; img: ImageDef }[];
|
||||
/** `AlphaMask(img, mask)`: the picture, visible where the mask is. */
|
||||
alphaMask?: { img: ImageDef; mask: ImageDef };
|
||||
size?: [number, number] | null;
|
||||
text?: string;
|
||||
textExpr?: Expr;
|
||||
@@ -100,6 +104,7 @@ export interface ScriptModule {
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
build?: Record<string, any>;
|
||||
/** Natural image dimensions are divided by this factor after an asset replacement. */
|
||||
assetScale?: Record<string, number>;
|
||||
runtime?: string;
|
||||
@@ -155,6 +160,7 @@ export interface ShownImage {
|
||||
}
|
||||
|
||||
export interface SaveData {
|
||||
registeredImages?: Record<string, ImageDef>;
|
||||
revision?: string;
|
||||
v: 1;
|
||||
cur: Frame;
|
||||
|
||||
@@ -33,6 +33,18 @@ function tick(ms) {
|
||||
const volumes = () => ({ music: 0.7, sound: 0.8, voice: 1 });
|
||||
const system = (registered = {}) => new AudioSystem(volumes(), (file) => file, registered);
|
||||
|
||||
test("custom mixers control only their channels and survive changes to shared volumes", () => {
|
||||
const audio = system({ ambient: { mixer: "ambiant", loop: true }, ambient2: { mixer: "ambiant", loop: true } });
|
||||
audio.play("ambient", ["one"], {});
|
||||
audio.play("sound", ["effect"], {});
|
||||
audio.setMixer("ambiant", 0.3);
|
||||
assert.deepEqual(elements.map((el) => el.volume), [0.3, 0.8]);
|
||||
audio.setVolumes({ music: 0, sound: 0.2, voice: 1 });
|
||||
audio.play("ambient2", ["two"], {});
|
||||
assert.deepEqual(elements.map((el) => el.volume), [0.3, 0.2, 0.3]);
|
||||
assert.equal(audio.mixerVolume("ambiant"), 0.3);
|
||||
});
|
||||
|
||||
test("volume changes apply immediately to each mixer and future tracks", () => {
|
||||
const audio = system({ ambience: { mixer: "sfx", loop: true }, score: { mixer: "music", loop: true } });
|
||||
for (const ch of ["music", "sound", "voice", "ambience", "score"]) audio.play(ch, [ch], {});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import ts from "typescript";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const source = readFileSync(new URL("../src/player/cutscene.ts", import.meta.url), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||
const { cutscene } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||
|
||||
function setup(options = {}, blocked = false) {
|
||||
const { document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
globalThis.document = document;
|
||||
const create = document.createElement.bind(document);
|
||||
let starts = 0;
|
||||
document.createElement = (name) => {
|
||||
const element = create(name);
|
||||
if (name === "video") {
|
||||
element.play = () => { starts++; return blocked ? Promise.reject(new Error("gesture")) : Promise.resolve(); };
|
||||
element.pause = () => {};
|
||||
}
|
||||
return element;
|
||||
};
|
||||
const movie = cutscene(document.body, "/film.webm", { delay: null, loops: 0, volume: 0.5, ...options });
|
||||
return { ...movie, document, video: document.querySelector("video"), starts: () => starts };
|
||||
}
|
||||
|
||||
test("a cutscene waits for its final loop and returns false on natural completion", async () => {
|
||||
const movie = setup({ loops: 1 });
|
||||
movie.video.onended();
|
||||
assert.equal(movie.starts(), 2);
|
||||
movie.video.onended();
|
||||
assert.equal(await movie.finished, false);
|
||||
movie.destroy();
|
||||
assert.equal(movie.document.querySelector("video"), null);
|
||||
});
|
||||
|
||||
test("clicking skips a cutscene and delay -1 holds its last frame", async () => {
|
||||
const movie = setup({ delay: -1 });
|
||||
movie.video.onended();
|
||||
let resolved = false;
|
||||
movie.finished.then(() => { resolved = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, false);
|
||||
movie.document.querySelector(".pg-cutscene").onclick({ stopPropagation() {} });
|
||||
assert.equal(await movie.finished, true);
|
||||
movie.destroy();
|
||||
});
|
||||
|
||||
test("a timed cutscene completes after playback starts", async () => {
|
||||
const movie = setup({ delay: 0 });
|
||||
movie.video.onplaying();
|
||||
assert.equal(await movie.finished, false);
|
||||
movie.destroy();
|
||||
});
|
||||
|
||||
test("blocked autoplay offers a play button, and media errors fail explicitly", async () => {
|
||||
const movie = setup({}, true);
|
||||
await Promise.resolve();
|
||||
assert.equal(movie.document.querySelector("button").hidden, false);
|
||||
const failed = assert.rejects(movie.finished, /Не удалось воспроизвести/);
|
||||
movie.video.onerror();
|
||||
await failed;
|
||||
movie.destroy();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { makeEngine } from "./helpers/renpy-engine.mjs";
|
||||
|
||||
const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {},
|
||||
defaults: [], init: [], characters: {}, channels: {}, images: { background: { file: "background.webp" } },
|
||||
transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} },
|
||||
modules: [{ labels: [{ name: "start", body: [{ op: "return" }] }], anchors: {} }] });
|
||||
|
||||
test("runtime image declarations produce scaled thumbnails and survive saving", () => {
|
||||
const { engine } = makeEngine(runtime());
|
||||
try {
|
||||
engine.newStore();
|
||||
engine.fileUrl = (file) => `/mock/${file}`;
|
||||
const image = engine.builtins.im.Scale(engine.builtins.ImageReference("background"), 384, 216);
|
||||
engine.builtins.renpy.image("thumbnail", image);
|
||||
const element = engine.screenImage("thumbnail");
|
||||
assert.equal(element.style.width, "384px");
|
||||
assert.equal(element.style.height, "216px");
|
||||
assert.equal(element.src, "/mock/background.webp");
|
||||
const saved = structuredClone(engine.snapshotState());
|
||||
engine.newStore();
|
||||
assert.equal(engine.resolveImage(["thumbnail"]), null);
|
||||
engine.applySave(saved);
|
||||
assert.deepEqual(engine.resolveImage(["thumbnail"]).def.props.size, [384, 216]);
|
||||
const before = Object.getPrototypeOf(engine.game.images);
|
||||
engine.builtins.renpy.image("__proto__", "safe.webp");
|
||||
assert.equal(Object.getPrototypeOf(engine.game.images), before);
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("a missing original label stops execution instead of advancing into another branch", () => {
|
||||
const { engine } = makeEngine(runtime());
|
||||
try {
|
||||
engine.cur = { label: "start", path: [0] };
|
||||
assert.throws(() => engine.jump("missing_original_label"), /не найдена/);
|
||||
assert.deepEqual(engine.cur, { label: "start", path: [0] });
|
||||
engine.screens.run(engine.builtins.Jump("missing_original_label"), null);
|
||||
assert.match(engine.notifyEl.textContent, /missing_original_label/);
|
||||
assert.equal(engine.running, false);
|
||||
engine.newStore();
|
||||
assert.equal(engine.screens.layer.style.visibility, "");
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("native Gallery instances keep their methods and registration target after a save", () => {
|
||||
const { engine } = makeEngine(runtime());
|
||||
try {
|
||||
engine.newStore();
|
||||
const cls = engine.builtins.Gallery;
|
||||
const gallery = engine.py.instantiate(cls, []);
|
||||
cls.find("button").value(gallery, "art");
|
||||
cls.find("image").value(gallery, "background");
|
||||
engine.store.gallery = gallery;
|
||||
const saved = structuredClone(engine.snapshotState());
|
||||
engine.applySave(saved);
|
||||
const restored = engine.store.gallery;
|
||||
assert.equal(typeof engine.py.attrOf(restored, "make_button"), "function");
|
||||
cls.find("condition").value(restored, "False");
|
||||
assert.equal(cls.find("Action").value(restored, "art"), null);
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("an animated preview discarded before its first timer does not keep rendering", async () => {
|
||||
const { engine } = makeEngine(runtime());
|
||||
let rendered = 0;
|
||||
try {
|
||||
engine.fileUrl = (file) => { rendered++; return `/mock/${file}`; };
|
||||
engine.renderer.displayable({ repeat: true, frames: [
|
||||
{ img: { file: "a.webp" }, d: 0.01 }, { img: { file: "b.webp" }, d: 0.01 },
|
||||
] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
assert.equal(rendered, 1);
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { build } from "esbuild";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const bundle = await build({ entryPoints: [fileURLToPath(new URL("../../src/player/engine.ts", import.meta.url))],
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
|
||||
Error.stackTraceLimit = 2; // data-URL bundle frames otherwise print the entire bundle on a failure.
|
||||
|
||||
export function makeEngine(runtime) {
|
||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
const storage = new Map();
|
||||
Object.assign(globalThis, {
|
||||
window, document, HTMLElement: window.HTMLElement, HTMLImageElement: window.HTMLImageElement,
|
||||
localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) },
|
||||
ResizeObserver: class { observe() {} disconnect() {} },
|
||||
requestAnimationFrame: (fn) => setTimeout(() => fn(performance.now()), 2), cancelAnimationFrame: clearTimeout,
|
||||
});
|
||||
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const engine = new Engine(root, structuredClone(runtime.game), { files: {} }, structuredClone(runtime.modules),
|
||||
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||
"runtime-test", () => {}, tr, async () => tr);
|
||||
return { engine, root, window, document };
|
||||
}
|
||||
|
||||
export const until = async (condition) => {
|
||||
const deadline = performance.now() + 3000;
|
||||
while (!condition()) {
|
||||
if (performance.now() > deadline) throw new Error("Ожидаемое состояние плеера не наступило");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { makeEngine, until } from "./helpers/renpy-engine.mjs";
|
||||
|
||||
const original = process.env.PGAMES_MIST_RUNTIME;
|
||||
const runtime = original ? JSON.parse(readFileSync(original, "utf8")) : null;
|
||||
const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} });
|
||||
const enter = (engine, label) => engine.restart(() => engine.guardedJump(label));
|
||||
|
||||
test("MIST's actual raise-hand loop accepts screen input and resumes after a save", { skip: !runtime }, async () => {
|
||||
const { engine, root } = makeEngine(runtime);
|
||||
try {
|
||||
engine.newStore();
|
||||
engine.store.keysqte = false;
|
||||
engine.store.last_pressed = "";
|
||||
engine.store.player_name = "Player";
|
||||
enter(engine, "raisehand_label");
|
||||
await until(() => engine.screens.interactionReturn && root.querySelector(".pg-simgbtn"));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
click(root.querySelector(".pg-simgbtn"));
|
||||
await until(() => engine.screens.interactionReturn);
|
||||
}
|
||||
const position = engine.store.object_pos;
|
||||
assert.ok(position < 600 && position > 50);
|
||||
const save = structuredClone(engine.snapshotState());
|
||||
engine.restart(() => engine.applySave(save));
|
||||
await until(() => engine.screens.interactionReturn);
|
||||
assert.equal(engine.store.object_pos, position);
|
||||
for (let i = 0; i < 30 && engine.store.object_pos > 50; i++) {
|
||||
click(root.querySelector(".pg-simgbtn"));
|
||||
await until(() => engine.screens.interactionReturn || engine.store.object_pos === 50);
|
||||
}
|
||||
assert.equal(engine.store.object_pos, 50);
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("MIST's actual QTE timer reaches the loss branch", { skip: !runtime }, async () => {
|
||||
const { engine } = makeEngine(runtime);
|
||||
try {
|
||||
engine.newStore();
|
||||
Object.assign(engine.store, { timer_on: true, keysqte: false, object_pos: 301, lose_pos: 300, finish_pos: 1600, last_pressed: "" });
|
||||
engine.screens.show("game_scr");
|
||||
enter(engine, "game_loop");
|
||||
await until(() => engine.store.timer_on === false);
|
||||
assert.ok(engine.store.object_pos <= 300);
|
||||
assert.equal(engine.cur.label, "qte_lose");
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
|
||||
test("MIST's actual QTE accepts alternating keys and reaches the win branch", { skip: !runtime }, async () => {
|
||||
const { engine, root } = makeEngine(runtime);
|
||||
try {
|
||||
engine.newStore();
|
||||
Object.assign(engine.store, { timer_on: true, keysqte: false, object_pos: 1590, lose_pos: 300, finish_pos: 1600, last_pressed: "q" });
|
||||
engine.screens.show("game_scr");
|
||||
enter(engine, "game_loop");
|
||||
await until(() => engine.screens.interactionReturn && root.querySelectorAll(".pg-simgbtn").length === 2);
|
||||
click(root.querySelectorAll(".pg-simgbtn")[1]);
|
||||
await until(() => engine.store.timer_on === false);
|
||||
assert.ok(engine.store.object_pos >= 1600);
|
||||
assert.equal(engine.cur.label, "qte_win");
|
||||
} finally { engine.destroy(); }
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import ts from "typescript";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
const source = readFileSync(new URL("../src/player/movie.ts", import.meta.url), "utf8");
|
||||
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||
const { applyMovieMask, movieDisplayable } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||
|
||||
test("video opacity uses the mask's red channel and retains existing transparency", () => {
|
||||
const pixels = new Uint8ClampedArray([200, 10, 20, 255, 30, 40, 50, 128]);
|
||||
applyMovieMask(pixels, new Uint8ClampedArray([0, 255, 255, 255, 128, 0, 0, 255]));
|
||||
assert.deepEqual([...pixels], [200, 10, 20, 0, 30, 40, 50, 64]);
|
||||
});
|
||||
|
||||
test("masked movie composites frames, synchronizes the mask and stops when removed", () => {
|
||||
const { document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
globalThis.document = document;
|
||||
const create = document.createElement.bind(document);
|
||||
const videos = [], frames = [], painted = [];
|
||||
let canvases = 0;
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
document.createElement = (name) => {
|
||||
const element = create(name);
|
||||
if (name === "video") {
|
||||
Object.assign(element, { readyState: 2, videoWidth: 2, videoHeight: 1, currentTime: 0, ended: false,
|
||||
play: () => Promise.resolve(), pause: () => { element.paused = true; } });
|
||||
videos.push(element);
|
||||
}
|
||||
if (name === "canvas") {
|
||||
const mask = canvases++ > 0;
|
||||
element.getContext = () => ({ clearRect() {}, drawImage() {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(mask
|
||||
? [0, 0, 0, 255, 128, 128, 128, 255] : [200, 10, 20, 255, 30, 40, 50, 255]) }),
|
||||
putImageData: (image) => painted.push([...image.data]),
|
||||
});
|
||||
}
|
||||
return element;
|
||||
};
|
||||
const canvas = movieDisplayable("/movie.webm", "/mask.webm", false);
|
||||
document.body.append(canvas);
|
||||
videos[1].currentTime = 2;
|
||||
frames.shift()();
|
||||
assert.deepEqual(painted[0], [200, 10, 20, 0, 30, 40, 50, 128]);
|
||||
assert.equal(videos[1].currentTime, 0);
|
||||
canvas.remove();
|
||||
frames.shift()();
|
||||
assert.ok(videos.every((video) => video.paused));
|
||||
assert.equal(frames.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// Optional smoke test for locally exported original game scripts; no game assets in git.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
import { parseHTML } from "linkedom";
|
||||
|
||||
test("original Ren'Py scripts initialize and retain the player's save loader", {
|
||||
skip: !process.env.PGAMES_RENPY_RUNTIME,
|
||||
}, async () => {
|
||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
const storage = new Map();
|
||||
Object.assign(globalThis, {
|
||||
window, document,
|
||||
localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
||||
ResizeObserver: class { observe() {} disconnect() {} },
|
||||
requestAnimationFrame: (fn) => setTimeout(() => fn(performance.now()), 4),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
});
|
||||
const result = await build({
|
||||
entryPoints: [fileURLToPath(new URL("../src/player/engine.ts", import.meta.url))],
|
||||
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
|
||||
});
|
||||
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
|
||||
const { game, modules } = JSON.parse(readFileSync(process.env.PGAMES_RENPY_RUNTIME, "utf8"));
|
||||
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const faults = [];
|
||||
const warn = console.warn;
|
||||
console.warn = (...args) => { faults.push(args.map(String).join(" ")); };
|
||||
let engine;
|
||||
try {
|
||||
engine = new Engine(root, game, { files: {} }, modules,
|
||||
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||
"original-smoke", () => {}, tr, async () => tr);
|
||||
const load = engine.builtins.renpy.load;
|
||||
const room = engine.py.instantiate(engine.builtins.MusicRoom, []);
|
||||
const stop = engine.builtins.MusicRoom.find("Stop").value(room);
|
||||
assert.equal(stop.get_selected(), true, "MusicRoom must capture the initialized audio system");
|
||||
for (let i = 0; i < game.init.length; i++) {
|
||||
try { engine.py.exec([game.init[i]]); }
|
||||
catch (error) { throw new Error(`init[${i}] ${JSON.stringify(game.init[i]).slice(0, 350)}: ${error.message}`); }
|
||||
}
|
||||
engine.newStore();
|
||||
assert.equal(engine.builtins.renpy.load, load, "init must retain the native save loader");
|
||||
assert.deepEqual(faults.filter((s) => /ошибка init|ошибка выражения|неизвестное имя|неизвестная функция/.test(s)), []);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
engine?.destroy();
|
||||
}
|
||||
});
|
||||
@@ -32,6 +32,22 @@ function python(extra = {}) {
|
||||
return { py, store };
|
||||
}
|
||||
|
||||
test("interpreted instances can own arguments without exposing host function properties", () => {
|
||||
const { py, store } = python();
|
||||
Object.assign(store, m.userBuiltins(py));
|
||||
store.entry = new m.PyInstance(new m.PyClass("Entry", [m.OBJECT]));
|
||||
py.exec(parseStatements('entry.arguments = {"x": 1}'));
|
||||
assert.equal(py.eval(parseExpression('entry.arguments["x"]')), 1);
|
||||
assert.equal(py.eval(parseExpression('getattr(entry, "constructor", None)')), null);
|
||||
py.exec(parseStatements('setattr(entry, "arguments", 2)'));
|
||||
assert.equal(py.eval(parseExpression('entry.arguments')), 2);
|
||||
store.host = () => 1;
|
||||
assert.equal(py.eval(parseExpression('host.constructor')), null);
|
||||
assert.equal(py.eval(parseExpression('host.arguments')), null);
|
||||
assert.throws(() => py.exec(parseStatements('entry.__proto__ = 1')), /Служебный/);
|
||||
assert.throws(() => py.exec(parseStatements('entry.constructor = 1')), /Служебный/);
|
||||
});
|
||||
|
||||
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
|
||||
const brighter = colorMatrices.BrightnessMatrix(0.2);
|
||||
const half = colorMatrices.TintMatrix("#808080");
|
||||
|
||||
@@ -69,6 +69,52 @@ test("a screen renders its widgets, conditions, loops and interpolated text", ()
|
||||
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
|
||||
});
|
||||
|
||||
test("ui.interact receives repeated Returns without hiding an existing screen", () => {
|
||||
const { manager, py } = setup();
|
||||
manager.show("hud");
|
||||
const before = manager.snapshot();
|
||||
const values = [];
|
||||
const builtins = manager.builtins();
|
||||
const cancel = manager.interact((value) => values.push(value));
|
||||
manager.run(builtins.Return("tick"), null);
|
||||
cancel();
|
||||
assert.deepEqual(values, ["tick"]);
|
||||
assert.deepEqual(manager.snapshot(), before);
|
||||
manager.interact((value) => values.push(value));
|
||||
manager.run(builtins.Return(0), null);
|
||||
assert.deepEqual(values, ["tick", 0]);
|
||||
const cancelled = manager.interact((value) => values.push(value));
|
||||
cancelled();
|
||||
manager.run(builtins.Return("stale"), null);
|
||||
assert.deepEqual(values, ["tick", 0]);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("gallery buttons honor unlock conditions and close back to the same screen", () => {
|
||||
const { manager, py } = setup();
|
||||
const builtins = manager.builtins();
|
||||
const gallery = py.instantiate(builtins.Gallery, []);
|
||||
const invoke = (name, ...args) => builtins.Gallery.find(name).value(gallery, ...args);
|
||||
invoke("button", "art");
|
||||
invoke("image", "art.png");
|
||||
invoke("condition", "money > 10");
|
||||
const value = invoke("make_button", "art", "thumb.png", "locked.png");
|
||||
assert.equal(value.kw.sensitive, true);
|
||||
manager.show("hud");
|
||||
manager.run(value.kw.action, null);
|
||||
assert.ok(manager.layer.querySelector(".pg-gallery img[src='/f/art.png']"));
|
||||
const close = [...manager.layer.querySelectorAll(".pg-gallery button")].find((b) => b.textContent === "Закрыть");
|
||||
click(close);
|
||||
assert.equal(manager.layer.querySelector(".pg-gallery"), null);
|
||||
assert.equal(manager.snapshot()[0].name, "hud");
|
||||
py.scope.set("money", 0);
|
||||
assert.equal(invoke("Action", "art"), null);
|
||||
const locked = invoke("make_button", "art", "thumb.png", "locked.png");
|
||||
assert.equal(locked.kw.sensitive, false);
|
||||
assert.equal(locked.kw.idle, "locked.png");
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("conditions follow the game state when the screen refreshes", async () => {
|
||||
const { manager, store } = setup();
|
||||
manager.show("hud");
|
||||
|
||||
Reference in new issue
Block a user