Initial MVP: local visual novel library with Ren'Py → PGVN converter

- FastAPI backend: admin bootstrap with forced credential change, admin-created
  users with one-time generated passwords, roles as permission sets
- Games and versions with content-addressed blob storage; forks copy only the
  manifest, so mods share assets with the original
- Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python
  subset translated via ast, media converted to WebP/Opus/WebM
- React panel (library search, game page, upload, users, roles) and a
  framework-free web player with saves, history, skip/auto, transitions
- docker compose: postgres, backend, worker, nginx on a single HTTP port

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 02:41:32 +03:00
commit 364b312064
72 files changed
+9707

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots.
export interface Volumes {
music: number;
sound: number;
voice: number;
}
interface Channel {
el: HTMLAudioElement | null;
file: string | null;
loop: boolean;
queue: string[];
}
function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) {
if (seconds <= 0) {
el.volume = to;
done?.();
return;
}
const start = performance.now();
const step = () => {
const t = Math.min(1, (performance.now() - start) / (seconds * 1000));
el.volume = Math.max(0, Math.min(1, from + (to - from) * t));
if (t < 1) requestAnimationFrame(step);
else done?.();
};
requestAnimationFrame(step);
}
export class AudioSystem {
channels = new Map<string, Channel>();
constructor(public volumes: Volumes, private url: (file: string) => string | null) {}
private group(ch: string): keyof Volumes {
if (ch === "music") return "music";
if (ch === "voice") return "voice";
return "sound";
}
private channel(name: string): Channel {
let c = this.channels.get(name);
if (!c) {
c = { el: null, file: null, loop: false, queue: [] };
this.channels.set(name, c);
}
return c;
}
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
ifChanged?: boolean }) {
const c = this.channel(ch);
const loop = opts.loop ?? ch === "music";
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
c.queue = files.slice(1);
c.loop = loop;
this.start(ch, c, files[0], opts.fadein ?? 0);
}
queue(ch: string, files: string[], loop?: boolean | null) {
const c = this.channel(ch);
if (!c.el || c.el.paused) {
this.play(ch, files, { loop });
return;
}
c.queue.push(...files);
if (loop != null) c.loop = loop;
}
private start(ch: string, c: Channel, file: string | undefined, fadein: number) {
if (!file) return;
const src = this.url(file);
if (!src) {
console.warn("[pgvn] нет аудиофайла", file);
return;
}
const el = new Audio(src);
const target = this.volumes[this.group(ch)];
el.volume = fadein > 0 ? 0 : target;
el.loop = c.loop && c.queue.length === 0;
el.onended = () => {
if (c.el !== el) return;
const next = c.queue.shift();
if (next) {
if (c.loop) c.queue.push(next);
this.start(ch, c, next, 0);
} else {
c.el = null;
c.file = null;
}
};
c.el = el;
c.file = file;
el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука"));
if (fadein > 0) fade(el, 0, target, fadein);
}
stop(ch: string, fadeout = 0) {
const c = this.channels.get(ch);
if (!c?.el) return;
const el = c.el;
c.el = null;
c.file = null;
c.queue = [];
fade(el, el.volume, 0, fadeout, () => el.pause());
}
stopAll() {
for (const ch of this.channels.keys()) this.stop(ch);
}
setVolumes(v: Volumes) {
this.volumes = v;
for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)];
}
current(ch: string): { file: string; loop: boolean } | null {
const c = this.channels.get(ch);
return c?.file ? { file: c.file, loop: c.loop } : null;
}
}
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
import "./player.css";
import { Engine, type SaveApi } from "./engine";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) throw new Error(`${r.status} ${url}`);
return r.json();
}
function blob(manifest: Manifest, path: string) {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return `/blobs/${f.hash}`;
}
function saveApi(versionId: number): SaveApi {
const base = `/api/versions/${versionId}/saves`;
return {
list: () => getJSON(base),
async get(slot) {
const r = await fetch(`${base}/${slot}`);
if (r.status === 404) return null;
if (!r.ok) throw new Error(`${r.status}`);
return (await r.json()).data as SaveData;
},
async put(slot, title, data) {
const r = await fetch(`${base}/${slot}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, data }),
});
if (!r.ok) throw new Error(`${r.status}`);
},
async remove(slot) {
await fetch(`${base}/${slot}`, { method: "DELETE" });
},
};
}
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
onProgress: (text: string) => void): Promise<Engine> {
onProgress("Загрузка манифеста");
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit);
engine.start();
return engine;
}
+195
View File
@@ -0,0 +1,195 @@
.pg-root {
position: relative;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
user-select: none;
}
.pg-stage {
--pg-accent: #ff9f43;
--pg-text: #ffffff;
position: absolute;
left: 50%;
top: 50%;
overflow: hidden;
background: #000;
color: var(--pg-text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: var(--pg-font);
transform-origin: center;
cursor: default;
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
.pg-fill { position: absolute; inset: 0; }
.pg-image { position: absolute; will-change: transform, opacity; }
.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; }
.pg-hidden { display: none !important; }
.pg-textbox {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92));
border-top: 2px solid rgba(255, 255, 255, 0.12);
}
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
.pg-textbox.pg-ui-hidden { opacity: 0; }
.pg-namebox {
position: absolute;
top: 0;
transform: translateY(-50%);
padding: 0.15em 0.8em;
font-size: var(--pg-name-font);
font-weight: 600;
color: var(--pg-accent);
background: rgba(10, 12, 24, 0.85);
border-radius: 0.3em;
text-shadow: 0 1px 2px #000;
}
.pg-textbox-image .pg-namebox { background: transparent; }
.pg-text {
position: absolute;
line-height: 1.35;
white-space: pre-wrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; }
@keyframes pg-blink { 50% { opacity: 0.1; } }
.pg-quick {
position: absolute;
bottom: 0.4em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.2em;
}
.pg-quick button {
background: none;
border: 0;
color: rgba(255, 255, 255, 0.55);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.2em 0.6em;
cursor: pointer;
}
.pg-quick button:hover, .pg-quick button.pg-on { color: var(--pg-accent); }
.pg-menu {
position: absolute;
inset: 0 0 26% 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.pg-choice {
min-width: 50%;
max-width: 75%;
padding: 0.45em 1.2em;
font: inherit;
color: #fff;
background: rgba(15, 18, 35, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.3em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
.pg-nvl {
position: absolute;
inset: 0;
padding: 5% 15%;
background: rgba(0, 0, 0, 0.72) center / cover;
display: flex;
flex-direction: column;
gap: 0.6em;
overflow: hidden;
}
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
.pg-nvl-what { white-space: pre-wrap; }
.pg-centered {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 15%;
white-space: pre-wrap;
text-shadow: 0 2px 4px #000;
}
.pg-notify {
position: absolute;
top: 2%;
left: 2%;
padding: 0.4em 1em;
font-size: calc(var(--pg-font) * 0.7);
background: rgba(0, 0, 0, 0.8);
border-left: 4px solid var(--pg-accent);
}
.pg-screen {
position: absolute;
inset: 0;
background: rgba(8, 10, 20, 0.94) center / cover;
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
}
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; }
.pg-screen-head h2 { margin: 0 0 0.6em; color: var(--pg-accent); font-weight: 600; }
.pg-screen button {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.3em;
padding: 0.4em 1em;
cursor: pointer;
}
.pg-screen button:hover:not(:disabled) { border-color: var(--pg-accent); color: var(--pg-accent); }
.pg-screen button:disabled { opacity: 0.4; cursor: default; }
.pg-close { background: none !important; border: 0 !important; font-size: 1.3em !important; }
.pg-screen-body { flex: 1; overflow: auto; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: 30%; }
.pg-history { display: flex; flex-direction: column; gap: 0.6em; max-height: 100%; overflow: auto; }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 5em; }
.pg-slot-title { font-size: 0.8em; opacity: 0.85; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.5; font-size: 0.65em; }
.pg-setting { display: grid; grid-template-columns: 30% 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.7em; }
.pg-setting input { width: 100%; accent-color: var(--pg-accent); }
.pg-setting-val { opacity: 0.7; }
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
.pg-screen.pg-main { background-color: #000; justify-content: center; }
.pg-main-panel {
display: flex;
flex-direction: column;
gap: 0.5em;
width: 32%;
padding: 2em;
background: rgba(0, 0, 0, 0.6);
border-radius: 0.5em;
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
.pg-main-panel button { text-align: left; }
+470
View File
@@ -0,0 +1,470 @@
// A small evaluator for the Python subset emitted by the converter.
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
import type { Expr, PyStmt } from "./types";
export class PyDict {
map = new Map<any, any>();
constructor(entries?: Iterable<[any, any]>) {
if (entries) for (const [k, v] of entries) this.map.set(key(k), v);
}
get(k: any) {
return this.map.get(key(k));
}
set(k: any, v: any) {
this.map.set(key(k), v);
}
has(k: any) {
return this.map.has(key(k));
}
toJSON() {
return { __dict__: [...this.map.entries()] };
}
}
// tuples used as dict keys must compare by value
function key(k: any) {
return Array.isArray(k) ? JSON.stringify(k) : k;
}
export class JumpSignal {
constructor(public kind: "jump" | "call", public target: string) {}
}
class BreakSignal {}
class ContinueSignal {}
export type Builtin = ((...args: any[]) => any) & { __py?: true };
export function truthy(v: any): boolean {
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
if (Array.isArray(v)) return v.length > 0;
if (v instanceof PyDict) return v.map.size > 0;
return true;
}
export function pyEq(a: any, b: any): boolean {
if (a === b) return true;
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]));
if (typeof a === "boolean" || typeof b === "boolean") return Number(a) === Number(b);
return false;
}
export function pyStr(v: any): string {
if (v === null || v === undefined) return "None";
if (v === true) return "True";
if (v === false) return "False";
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
if (v instanceof PyDict)
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
return String(v);
}
function pyRepr(v: any): string {
return typeof v === "string" ? `'${v}'` : pyStr(v);
}
function pyMod(a: number, b: number) {
return ((a % b) + b) % b;
}
function percentFormat(fmt: string, args: any): string {
const list = Array.isArray(args) ? args : [args];
let i = 0;
return fmt.replace(/%(\([^)]*\))?[-+ 0#]*\d*(\.\d+)?([sdifr%])/g, (m, name, prec, conv) => {
if (conv === "%") return "%";
const v = name && args instanceof PyDict ? args.get(name.slice(1, -1)) : list[i++];
if (conv === "d" || conv === "i") return String(Math.trunc(Number(v)));
if (conv === "f") return Number(v).toFixed(prec ? Number(prec.slice(1)) : 6);
void m;
return pyStr(v);
});
}
export function binop(op: string, a: any, b: any): any {
switch (op) {
case "+":
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
if (typeof a === "string" || typeof b === "string") {
if (typeof a !== typeof b) throw new TypeError("str + не-str");
return a + b;
}
return a + b;
case "-":
return a - b;
case "*":
if (typeof a === "string" && typeof b === "number") return a.repeat(Math.max(0, b));
if (Array.isArray(a) && typeof b === "number") return Array.from({ length: b }, () => a).flat();
return a * b;
case "/":
return a / b;
case "//":
return Math.floor(a / b);
case "%":
if (typeof a === "string") return percentFormat(a, b);
return pyMod(a, b);
case "**":
return a ** b;
case "&":
return a & b;
case "|":
return a | b;
case "^":
return a ^ b;
case "<<":
return a << b;
case ">>":
return a >> b;
}
throw new Error(`оператор ${op}`);
}
function contains(container: any, item: any): boolean {
if (typeof container === "string") return container.includes(item);
if (Array.isArray(container)) return container.some((x) => pyEq(x, item));
if (container instanceof PyDict) return container.has(item);
if (container && typeof container === "object") return item in container;
return false;
}
function compare(op: string, a: any, b: any): boolean {
switch (op) {
case "==":
return pyEq(a, b);
case "!=":
return !pyEq(a, b);
case "<":
return a < b;
case "<=":
return a <= b;
case ">":
return a > b;
case ">=":
return a >= b;
case "in":
return contains(b, a);
case "not in":
return !contains(b, a);
case "is":
return a === b || (a == null && b == null);
case "is not":
return !(a === b || (a == null && b == null));
}
throw new Error(`сравнение ${op}`);
}
function index(len: number, i: number) {
return i < 0 ? len + i : i;
}
function methodOf(obj: any, name: string): any {
if (Array.isArray(obj)) {
const m: Record<string, Builtin> = {
append: (x) => void obj.push(x),
extend: (xs) => void obj.push(...xs),
insert: (i, x) => void obj.splice(i, 0, x),
pop: (i = -1) => obj.splice(index(obj.length, i), 1)[0],
remove: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i < 0) throw new Error("list.remove: нет элемента");
obj.splice(i, 1);
},
index: (x) => obj.findIndex((y) => pyEq(y, x)),
count: (x) => obj.filter((y) => pyEq(y, x)).length,
reverse: () => void obj.reverse(),
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
clear: () => void obj.splice(0),
copy: () => [...obj],
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
discard: (x) => {
const i = obj.findIndex((y) => pyEq(y, x));
if (i >= 0) obj.splice(i, 1);
},
};
return m[name];
}
if (obj instanceof PyDict) {
const m: Record<string, Builtin> = {
get: (k, d = null) => (obj.has(k) ? obj.get(k) : d),
keys: () => [...obj.map.keys()],
values: () => [...obj.map.values()],
items: () => [...obj.map.entries()],
pop: (k, d = null) => {
const v = obj.has(k) ? obj.get(k) : d;
obj.map.delete(key(k));
return v;
},
setdefault: (k, d = null) => {
if (!obj.has(k)) obj.set(k, d);
return obj.get(k);
},
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()),
};
return m[name];
}
if (typeof obj === "string") {
const m: Record<string, Builtin> = {
lower: () => obj.toLowerCase(),
upper: () => obj.toUpperCase(),
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
title: () => obj.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase()),
strip: () => obj.trim(),
lstrip: () => obj.trimStart(),
rstrip: () => obj.trimEnd(),
startswith: (p) => obj.startsWith(p),
endswith: (p) => obj.endsWith(p),
replace: (a, b) => obj.split(a).join(b),
split: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
join: (xs) => xs.map(pyStr).join(obj),
format: (...args) => {
let i = 0;
return obj.replace(/\{(\d*)\}/g, (_, n) => pyStr(args[n === "" ? i++ : Number(n)]));
},
count: (s) => obj.split(s).length - 1,
find: (s) => obj.indexOf(s),
isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj),
};
return m[name];
}
return undefined;
}
export interface Scope {
get(name: string): any;
has(name: string): boolean;
set(name: string, value: any): void;
del(name: string): void;
}
export class Python {
locals: Map<string, any>[] = [];
warned = new Set<string>();
constructor(public scope: Scope) {}
warnOnce(msg: string) {
if (!this.warned.has(msg)) {
this.warned.add(msg);
console.warn("[pgvn]", msg);
}
}
lookup(name: string): any {
for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.scope.has(name)) return this.scope.get(name);
this.warnOnce(`неизвестное имя ${name}`);
return null;
}
eval(e: Expr | null | undefined): any {
if (!e) return null;
switch (e.k) {
case "const":
return e.v;
case "name":
return this.lookup(e.id);
case "attr": {
const o = this.eval(e.o);
if (o === null || o === undefined) {
this.warnOnce(`атрибут ${e.a} у None`);
return null;
}
const m = methodOf(o, e.a);
if (m) return m;
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
return v === undefined ? null : v;
}
case "sub": {
const o = this.eval(e.o);
const i = this.eval(e.i);
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
return o?.[i] ?? null;
}
case "slice": {
const o = this.eval(e.o);
const lo = e.lo ? this.eval(e.lo) : undefined;
const hi = e.hi ? this.eval(e.hi) : undefined;
return o.slice(lo, hi);
}
case "bin":
return binop(e.op, this.eval(e.l), this.eval(e.r));
case "bool": {
let v: any = null;
for (const x of e.v) {
v = this.eval(x);
if (e.op === "and" ? !truthy(v) : truthy(v)) return v;
}
return v;
}
case "unary": {
const v = this.eval(e.v);
if (e.op === "not") return !truthy(v);
if (e.op === "-") return -v;
if (e.op === "~") return ~v;
return +v;
}
case "cmp": {
let left = this.eval(e.l);
for (let i = 0; i < e.ops.length; i++) {
const right = this.eval(e.r[i]);
if (!compare(e.ops[i], left, right)) return false;
left = right;
}
return true;
}
case "if":
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
case "list":
case "tuple":
return e.v.map((x: Expr) => this.eval(x));
case "dict":
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
case "fmt":
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
case "comp": {
const out: any[] = [];
const frame = new Map<string, any>();
this.locals.push(frame);
try {
for (const item of iterate(this.eval(e.it))) {
this.bind(e.t, item, frame);
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
}
} finally {
this.locals.pop();
}
return out;
}
case "call": {
const f = this.eval(e.f);
const args = e.a.map((x: Expr) => this.eval(x));
const kw: Record<string, any> = {};
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
if (typeof f !== "function") {
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
return null;
}
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
}
}
this.warnOnce(`неизвестное выражение ${e.k}`);
return null;
}
bind(t: Expr, value: any, frame?: Map<string, any>) {
switch (t.k) {
case "name":
if (frame) frame.set(t.id, value);
else this.scope.set(t.id, value);
return;
case "tuple": {
const items = [...iterate(value)];
t.v.forEach((sub: Expr, i: number) => this.bind(sub, items[i], frame));
return;
}
case "attr": {
const o = this.eval(t.o);
if (o instanceof PyDict) o.set(t.a, value);
else if (o) o[t.a] = value;
return;
}
case "sub": {
const o = this.eval(t.o);
const i = this.eval(t.i);
if (o instanceof PyDict) o.set(i, value);
else if (Array.isArray(o)) o[index(o.length, i)] = value;
else if (o) o[i] = value;
return;
}
}
this.warnOnce(`присваивание в ${t.k}`);
}
exec(stmts: PyStmt[]) {
let guard = 0;
for (const s of stmts) {
switch (s.op) {
case "set":
this.bind(s.t, this.eval(s.v));
break;
case "aug":
this.bind(s.t, binop(s.o, this.eval(s.t), this.eval(s.v)));
break;
case "eval":
this.eval(s.e);
break;
case "if":
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
break;
case "while":
while (truthy(this.eval(s.c))) {
if (++guard > 100000) throw new Error("бесконечный цикл в python");
if (this.loopBody(s.body)) break;
}
break;
case "for":
for (const item of [...iterate(this.eval(s.it))]) {
this.bind(s.t, item);
if (this.loopBody(s.body)) break;
}
break;
case "del":
if (s.t.k === "name") this.scope.del(s.t.id);
else if (s.t.k === "sub") {
const o = this.eval(s.t.o);
const i = this.eval(s.t.i);
if (o instanceof PyDict) o.map.delete(key(i));
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
}
break;
case "break":
throw new BreakSignal();
case "continue":
throw new ContinueSignal();
}
}
}
/** Runs a loop body; returns true on break. */
private loopBody(body: PyStmt[]): boolean {
try {
this.exec(body);
} catch (err) {
if (err instanceof BreakSignal) return true;
if (err instanceof ContinueSignal) return false;
throw err;
}
return false;
}
}
export function* iterate(v: any): Iterable<any> {
if (Array.isArray(v) || typeof v === "string") yield* v;
else if (v instanceof PyDict) yield* v.map.keys();
else if (v && typeof v[Symbol.iterator] === "function") yield* v;
}
/** JSON (de)serialisation of the store, keeping PyDict instances. */
export function encodeValue(v: any): any {
return JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "function" ? undefined : x)));
}
export function decodeValue(v: any): any {
if (Array.isArray(v)) return v.map(decodeValue);
if (v && typeof v === "object") {
if (Array.isArray(v.__dict__))
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
const out: Record<string, any> = {};
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
return out;
}
return v;
}
+287
View File
@@ -0,0 +1,287 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform.
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
};
export interface ResolvedImage {
def: ImageDef;
name: string[];
}
export interface RendererHost {
url(file: string): string | null;
resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null;
}
interface Entry extends ShownImage {
el: HTMLElement;
}
export class Renderer {
readonly stage: HTMLDivElement;
readonly master: HTMLDivElement;
private overlay: HTMLDivElement;
private shown = new Map<string, Entry>();
private committed: HTMLElement | null = null;
fast = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) {
this.stage = document.createElement("div");
this.stage.className = "pg-stage";
this.stage.style.width = `${width}px`;
this.stage.style.height = `${height}px`;
this.master = document.createElement("div");
this.master.className = "pg-layer";
this.overlay = document.createElement("div");
this.overlay.className = "pg-layer pg-transition";
this.stage.append(this.master, this.overlay);
parent.append(this.stage);
}
fit(container: HTMLElement) {
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove();
this.shown.clear();
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? [];
const el = this.build(img.def);
el.classList.add("pg-image");
this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
if (prev) {
prev.el.replaceWith(el);
} else {
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
e.el.remove();
this.shown.delete(tag);
}
isShown(tag: string) {
return this.shown.has(tag);
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null;
}
snapshot(): ShownImage[] {
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
}
// --- displayables ------------------------------------------------------------
private build(def: ImageDef, depth = 0): HTMLElement {
if (depth > 8) return document.createElement("div");
if (def.ref) {
const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
}
if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1);
}
if (def.color) {
const d = document.createElement("div");
d.style.background = def.color;
d.style.width = `${this.width}px`;
d.style.height = `${this.height}px`;
return d;
}
if (def.composite) {
const d = document.createElement("div");
d.style.position = "relative";
if (def.size) {
d.style.width = `${def.size[0]}px`;
d.style.height = `${def.size[1]}px`;
}
for (const layer of def.composite) {
const child = this.build(layer.img, depth + 1);
child.style.position = "absolute";
child.style.left = `${layer.pos[0]}px`;
child.style.top = `${layer.pos[1]}px`;
d.append(child);
}
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;
}
if (def.text !== undefined) {
const d = document.createElement("div");
d.className = "pg-show-text";
d.textContent = def.text;
return d;
}
if (def.file) {
const img = document.createElement("img");
img.draggable = false;
img.decoding = "async";
const src = this.host.url(def.file);
if (src) img.src = src;
else console.warn("[pgvn] нет файла", def.file);
if (def.props?.size) {
img.style.width = `${def.props.size[0]}px`;
img.style.height = `${def.props.size[1]}px`;
}
return img;
}
return document.createElement("div");
}
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of at) {
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
if (spec.props) Object.assign(p, spec.props);
}
normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
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}`;
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})`;
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.filter) el.style.filter = `${p.filter}(1)`;
}
// --- transitions ---------------------------------------------------------------
/** Remember the current picture as the starting point of the next transition. */
commit() {
this.committed = this.master.cloneNode(true) as HTMLElement;
}
async transition(t: Transition | null | undefined): Promise<void> {
const before = this.committed;
this.commit();
if (!t || !before || this.fast) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
this.overlay.append(old);
try {
if (t.t === "fade") {
const color = document.createElement("div");
color.className = "pg-fill";
color.style.background = t.color ?? "#000";
color.style.opacity = "0";
this.overlay.append(color);
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
old.remove();
if (t.hold) await sleep(t.hold);
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
color.remove();
} else if (t.t === "wipe") {
const dir = t.dir ?? "right";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
} else if (t.t === "slide") {
const dir = t.dir ?? "left";
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
}
} finally {
old.remove();
}
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y";
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3);
}
preload(def: ImageDef | null) {
if (def?.file) {
const src = this.host.url(def.file);
if (src) new Image().src = src;
}
}
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
p.xanchor = asFloat(p.xanchor);
p.yanchor = asFloat(p.yanchor);
p.xpos = asFloat(p.xpos);
p.ypos = asFloat(p.ypos);
}
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
function asFloat(v: any): number {
return typeof v === "number" ? v : 0;
}
export function sleep(seconds: number) {
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
}
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
return new Promise((resolve) => {
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve();
a.oncancel = () => resolve();
});
}
+187
View File
@@ -0,0 +1,187 @@
// Ren'Py text tags → styled segments, plus [variable] interpolation.
import { pyStr } from "./python";
export interface Style {
b?: boolean;
i?: boolean;
u?: boolean;
s?: boolean;
color?: string;
size?: string;
}
export type Segment =
| { kind: "text"; text: string; style: Style }
| { kind: "wait"; seconds: number | null } // {w}, {w=1}, {p}
| { kind: "fast" };
export interface ParsedText {
segments: Segment[];
noWait: boolean; // {nw}
length: number; // number of visible characters
}
export function interpolate(text: string, lookup: (expr: string) => any): string {
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
if (m === "[[") return "[";
let src = expr!.trim();
let flags = "";
const bang = src.lastIndexOf("!");
if (bang > 0 && /^[a-z]+$/.test(src.slice(bang + 1))) {
flags = src.slice(bang + 1);
src = src.slice(0, bang);
}
let v = pyStr(lookup(src));
if (flags.includes("u")) v = v.toUpperCase();
if (flags.includes("l")) v = v.toLowerCase();
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1);
return v;
});
}
export function parseText(text: string): ParsedText {
const segments: Segment[] = [];
const stack: Style[] = [{}];
let noWait = false;
let length = 0;
const push = (t: string) => {
if (!t) return;
length += [...t].length;
segments.push({ kind: "text", text: t, style: { ...stack[stack.length - 1] } });
};
let i = 0;
let buf = "";
while (i < text.length) {
const c = text[i];
if (c === "{") {
if (text[i + 1] === "{") {
buf += "{";
i += 2;
continue;
}
const end = text.indexOf("}", i);
if (end < 0) {
buf += text.slice(i);
break;
}
push(buf);
buf = "";
const tag = text.slice(i + 1, end);
i = end + 1;
const [name, value] = splitTag(tag);
const top = stack[stack.length - 1];
if (name.startsWith("/")) {
if (stack.length > 1) stack.pop();
continue;
}
switch (name) {
case "b":
case "i":
case "u":
case "s":
stack.push({ ...top, [name]: true });
break;
case "color":
stack.push({ ...top, color: value });
break;
case "size":
stack.push({ ...top, size: value });
break;
case "w":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
break;
case "p":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
push("\n");
break;
case "nw":
noWait = true;
break;
case "fast":
segments.push({ kind: "fast" });
break;
case "space":
push(" ");
break;
case "vspace":
push("\n");
break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
stack.push({ ...top }); // styling we ignore but must still balance
break;
default:
// {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags
break;
}
continue;
}
buf += c;
i++;
}
push(buf);
return { segments, noWait, length };
}
function splitTag(tag: string): [string, string] {
const eq = tag.indexOf("=");
return eq < 0 ? [tag.trim(), ""] : [tag.slice(0, eq).trim(), tag.slice(eq + 1).trim()];
}
/** Render the first `visible` characters of parsed text into `el`. */
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
el.textContent = "";
let left = visible;
for (const seg of parsed.segments) {
if (seg.kind !== "text") continue;
if (left <= 0) break;
const chars = [...seg.text];
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
left -= chars.length;
const st = seg.style;
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
el.append(shown);
continue;
}
const span = document.createElement("span");
span.textContent = shown;
if (st.b) span.style.fontWeight = "bold";
if (st.i) span.style.fontStyle = "italic";
if (st.u || st.s) span.style.textDecoration = [st.u && "underline", st.s && "line-through"].filter(Boolean).join(" ");
if (st.color) span.style.color = st.color;
if (st.size) span.style.fontSize = sizeValue(st.size, baseSize);
el.append(span);
}
}
function sizeValue(v: string, base: number): string {
if (v.startsWith("+") || v.startsWith("-")) return `${base + Number(v)}px`;
if (v.startsWith("*")) return `${base * Number(v.slice(1))}px`;
return `${Number(v)}px`;
}
/** Character offsets at which {w}/{p} pauses happen, with their durations. */
export function waitPoints(parsed: ParsedText): { at: number; seconds: number | null }[] {
const out: { at: number; seconds: number | null }[] = [];
let pos = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "wait") out.push({ at: pos, seconds: seg.seconds });
}
return out;
}
export function fastPoint(parsed: ParsedText): number {
let pos = 0;
let fast = 0;
for (const seg of parsed.segments) {
if (seg.kind === "text") pos += [...seg.text].length;
else if (seg.kind === "fast") fast = pos;
}
return fast;
}
export function plainText(parsed: ParsedText): string {
return parsed.segments.map((s) => (s.kind === "text" ? s.text : "")).join("");
}
+108
View File
@@ -0,0 +1,108 @@
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
export type Expr = { k: string; [key: string]: any };
export type PyStmt = { op: string; [key: string]: any };
export interface Transition {
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
d?: number;
out?: number;
hold?: number;
in?: number;
color?: string;
dir?: string;
name?: string;
}
export interface ImageDef {
file?: string;
color?: string;
ref?: string;
movie?: string;
expr?: Expr;
switch?: [Expr, ImageDef][];
composite?: { pos: [number, number]; img: ImageDef }[];
size?: [number, number] | null;
text?: string;
props?: Record<string, any>;
}
export interface AtSpec {
pos?: string;
name?: string;
props?: Record<string, any>;
}
export interface CharacterDef {
name?: string | null;
nameExpr?: Expr;
color?: string;
whatColor?: string;
whatPrefix?: string;
whatSuffix?: string;
whoPrefix?: string;
whoSuffix?: string;
image?: string;
nvl?: boolean;
whatItalic?: boolean;
whatBold?: boolean;
}
export type Node = { op: string; [key: string]: any };
export interface Label {
name: string;
body: Node[];
}
export interface ScriptModule {
file: string;
labels: Label[];
}
export interface GameData {
format: string;
title: string | null;
resolution: [number, number];
entry: string;
scripts: string[];
characters: Record<string, CharacterDef>;
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
transitions: Record<string, Transition | null>;
audio: Record<string, string>;
files: Record<string, string>;
defaults: [string, Expr][];
init: PyStmt[];
ui: Record<string, any>;
}
export interface Manifest {
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
label: string;
path: number[];
}
export interface ShownImage {
tag: string;
name: string[];
at: AtSpec[];
zorder: number;
layer?: string;
}
export interface SaveData {
v: 1;
cur: Frame;
stack: Frame[];
store: any;
scene: ShownImage[];
music: { file: string; loop: boolean } | null;
window: boolean;
menuSeen?: string[];
text?: string;
}