Localization stage 1: stable IDs, import of tl/ translations, language picker
- every script node gets a permanent id, say nodes a Ren'Py-style tid - all languages from the game's tl/ are imported at conversion: dialogue blocks matched by their source-location comment or by label + original text, `translate strings` become a per-language strings table - source language from config.language or detected from the dialogue - manifest stores locales/<lang>/<module>.json and strings.json; game.json lists sourceLang and available locales with progress - player: language picker in main menu and settings, fallback to source, __(), [var!t] and _preferences.language - parser: `default` with init priority, and one bad statement no longer drops the whole file - docs/LOCALIZATION.md: design for translations, publishing and mods Line endings of several converter/player files go back to LF: an earlier commit accidentally stored them as CRLF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d9ddb6376
commit
e1d7b29f77
16 files changed
+4280
-3530
No files matched your search
+1412
-1357
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
// Game localisation: per-language line tables (by tid) and a strings table (by source text).
|
||||
|
||||
import type { GameData, Manifest } from "./types";
|
||||
|
||||
export interface LocaleInfo {
|
||||
name: string;
|
||||
renpy: string;
|
||||
modules: Record<string, string>;
|
||||
strings: string;
|
||||
lines: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
t: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
const PREF_KEY = "pgvn:lang";
|
||||
|
||||
export class Translator {
|
||||
constructor(
|
||||
public readonly lang: string, // BCP-47; equals sourceLang when untranslated
|
||||
public readonly renpyName: string | null,
|
||||
private lines: Map<string, string> = new Map(),
|
||||
private strings: Map<string, string> = new Map(),
|
||||
) {}
|
||||
|
||||
static source(game: GameData) {
|
||||
return new Translator(game.sourceLang ?? "und", null);
|
||||
}
|
||||
|
||||
/** Dialogue line by translation id, falling back to the source text. */
|
||||
line(tid: string | undefined, source: string): string {
|
||||
return (tid && this.lines.get(tid)) || source;
|
||||
}
|
||||
|
||||
/** Names, menu choices, `_()` strings: looked up by their source text. */
|
||||
str(source: string): string {
|
||||
return this.strings.get(source) ?? source;
|
||||
}
|
||||
}
|
||||
|
||||
export function preferredLanguage(game: GameData): string {
|
||||
const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})];
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = localStorage.getItem(PREF_KEY);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
if (saved && available.includes(saved)) return saved;
|
||||
// browser languages, matched exactly or by primary subtag (ru-RU -> ru)
|
||||
for (const nav of navigator.languages ?? [navigator.language]) {
|
||||
const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return game.sourceLang ?? "und";
|
||||
}
|
||||
|
||||
export function rememberLanguage(lang: string) {
|
||||
try {
|
||||
localStorage.setItem(PREF_KEY, lang);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise<Translator> {
|
||||
const info = game.locales?.[lang];
|
||||
if (!info) return Translator.source(game);
|
||||
const blob = (path: string) => {
|
||||
const f = manifest.files[path];
|
||||
if (!f) throw new Error(`в версии нет файла ${path}`);
|
||||
return fetch(`/blobs/${f.hash}`).then((r) => {
|
||||
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
||||
return r.json() as Promise<Record<string, Entry>>;
|
||||
});
|
||||
};
|
||||
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
|
||||
const lines = new Map<string, string>();
|
||||
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
|
||||
return new Translator(lang, info.renpy, lines,
|
||||
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])));
|
||||
}
|
||||
|
||||
export function languageName(game: GameData, lang: string): string {
|
||||
if (lang === game.sourceLang) {
|
||||
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
|
||||
es: "Español", ja: "日本語" };
|
||||
return `${known[lang] ?? lang} (оригинал)`;
|
||||
}
|
||||
return game.locales?.[lang]?.name ?? lang;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./player.css";
|
||||
import { Engine, type SaveApi } from "./engine";
|
||||
import { loadTranslator, preferredLanguage } from "./i18n";
|
||||
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
|
||||
|
||||
async function getJSON<T>(url: string): Promise<T> {
|
||||
@@ -44,9 +45,14 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
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 lang = preferredLanguage(game);
|
||||
const [modules, tr] = await Promise.all([
|
||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||
loadTranslator(game, manifest, lang),
|
||||
]);
|
||||
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
||||
`pgvn:persistent:${versionId}`, onExit);
|
||||
`pgvn:persistent:${versionId}`, onExit, tr,
|
||||
(code) => loadTranslator(game, manifest, code));
|
||||
engine.start();
|
||||
return engine;
|
||||
}
|
||||
@@ -193,3 +193,14 @@
|
||||
}
|
||||
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
|
||||
.pg-main-panel button { text-align: left; }
|
||||
.pg-select {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.3em 0.6em;
|
||||
}
|
||||
.pg-select option { background: #151827; }
|
||||
.pg-lang { margin-top: 0.6em; }
|
||||
+366
-366
@@ -1,366 +1,366 @@
|
||||
// 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;
|
||||
text(e: any): string;
|
||||
}
|
||||
|
||||
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 || def.textExpr) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pg-show-text";
|
||||
d.textContent = def.textExpr ? this.host.text(def.textExpr) : def.text!;
|
||||
return d;
|
||||
}
|
||||
if (def.frames?.length) return this.animated(def, depth);
|
||||
if (def.layered) return this.layered(def, depth);
|
||||
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");
|
||||
}
|
||||
|
||||
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
|
||||
private animated(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
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;
|
||||
i = last ? 0 : i + 1;
|
||||
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
||||
tick();
|
||||
}, d * 1000);
|
||||
};
|
||||
tick();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** layeredimage: stack the layers selected by the image's attributes. */
|
||||
private layered(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.style.position = "relative";
|
||||
const sel = new Set(def.selected ?? []);
|
||||
const groupsSelected = new Set(
|
||||
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
|
||||
const imgs: ImageDef[] = [];
|
||||
for (const layer of def.layered!) {
|
||||
if (layer.always) imgs.push(layer.always);
|
||||
else if (layer.cond) {
|
||||
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
|
||||
if (hit) imgs.push(hit[1]);
|
||||
} else if (layer.attr !== undefined) {
|
||||
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
|
||||
if (!on || !layer.img || layer.null) continue;
|
||||
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
|
||||
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
|
||||
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
|
||||
imgs.push(layer.img);
|
||||
}
|
||||
}
|
||||
imgs.forEach((img, n) => {
|
||||
const child = this.build(img, depth + 1);
|
||||
if (n > 0) {
|
||||
child.style.position = "absolute";
|
||||
child.style.left = "0";
|
||||
child.style.top = "0";
|
||||
}
|
||||
box.append(child);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
camera(at: AtSpec[]) {
|
||||
const p: Record<string, any> = {};
|
||||
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
|
||||
const zoom = p.zoom ?? 1;
|
||||
this.master.style.transformOrigin = "50% 50%";
|
||||
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
|
||||
this.cameraAt = at;
|
||||
}
|
||||
|
||||
cameraAt: AtSpec[] = [];
|
||||
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
|
||||
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
|
||||
for (const spec of [...(def.at ?? []), ...at]) {
|
||||
if (spec.effect === "shake") {
|
||||
const dist = spec.dist ?? 20;
|
||||
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
|
||||
{ duration: (spec.d ?? 0.5) * 1000 });
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
text(e: any): string;
|
||||
}
|
||||
|
||||
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 || def.textExpr) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pg-show-text";
|
||||
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
|
||||
return d;
|
||||
}
|
||||
if (def.frames?.length) return this.animated(def, depth);
|
||||
if (def.layered) return this.layered(def, depth);
|
||||
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");
|
||||
}
|
||||
|
||||
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
|
||||
private animated(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
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;
|
||||
i = last ? 0 : i + 1;
|
||||
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
||||
tick();
|
||||
}, d * 1000);
|
||||
};
|
||||
tick();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** layeredimage: stack the layers selected by the image's attributes. */
|
||||
private layered(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.style.position = "relative";
|
||||
const sel = new Set(def.selected ?? []);
|
||||
const groupsSelected = new Set(
|
||||
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
|
||||
const imgs: ImageDef[] = [];
|
||||
for (const layer of def.layered!) {
|
||||
if (layer.always) imgs.push(layer.always);
|
||||
else if (layer.cond) {
|
||||
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
|
||||
if (hit) imgs.push(hit[1]);
|
||||
} else if (layer.attr !== undefined) {
|
||||
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
|
||||
if (!on || !layer.img || layer.null) continue;
|
||||
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
|
||||
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
|
||||
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
|
||||
imgs.push(layer.img);
|
||||
}
|
||||
}
|
||||
imgs.forEach((img, n) => {
|
||||
const child = this.build(img, depth + 1);
|
||||
if (n > 0) {
|
||||
child.style.position = "absolute";
|
||||
child.style.left = "0";
|
||||
child.style.top = "0";
|
||||
}
|
||||
box.append(child);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
camera(at: AtSpec[]) {
|
||||
const p: Record<string, any> = {};
|
||||
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
|
||||
const zoom = p.zoom ?? 1;
|
||||
this.master.style.transformOrigin = "50% 50%";
|
||||
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
|
||||
this.cameraAt = at;
|
||||
}
|
||||
|
||||
cameraAt: AtSpec[] = [];
|
||||
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
|
||||
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
|
||||
for (const spec of [...(def.at ?? []), ...at]) {
|
||||
if (spec.effect === "shake") {
|
||||
const dist = spec.dist ?? 20;
|
||||
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
|
||||
{ duration: (spec.d ?? 0.5) * 1000 });
|
||||
continue;
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -22,7 +22,8 @@ export interface ParsedText {
|
||||
length: number; // number of visible characters
|
||||
}
|
||||
|
||||
export function interpolate(text: string, lookup: (expr: string) => any): string {
|
||||
export function interpolate(text: string, lookup: (expr: string) => any,
|
||||
translate: (s: string) => string = (s) => s): string {
|
||||
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
|
||||
if (m === "[[") return "[";
|
||||
let src = expr!.trim();
|
||||
@@ -33,6 +34,7 @@ export function interpolate(text: string, lookup: (expr: string) => any): string
|
||||
src = src.slice(0, bang);
|
||||
}
|
||||
let v = pyStr(lookup(src));
|
||||
if (flags.includes("t")) v = translate(v);
|
||||
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);
|
||||
|
||||
+145
-143
@@ -1,143 +1,145 @@
|
||||
// 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;
|
||||
textExpr?: Expr;
|
||||
props?: Record<string, any>;
|
||||
frames?: { img: ImageDef; d?: number }[];
|
||||
repeat?: boolean;
|
||||
layered?: Layer[];
|
||||
at?: AtSpec[];
|
||||
selected?: string[]; // attributes chosen for a layered image (set by the engine)
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
always?: ImageDef;
|
||||
attr?: string;
|
||||
group?: string | null;
|
||||
default?: boolean;
|
||||
null?: boolean;
|
||||
img?: ImageDef;
|
||||
if_any?: string[];
|
||||
if_all?: string[];
|
||||
if_not?: string[];
|
||||
cond?: [Expr | null, ImageDef][];
|
||||
autoGroup?: string;
|
||||
}
|
||||
|
||||
export interface AtSpec {
|
||||
pos?: string;
|
||||
name?: string;
|
||||
props?: Record<string, any>;
|
||||
effect?: "shake";
|
||||
d?: number;
|
||||
dist?: number;
|
||||
}
|
||||
|
||||
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[];
|
||||
params?: [string, Expr | null][];
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
label: string;
|
||||
path: number[];
|
||||
params?: [string, Expr | null][] | null;
|
||||
}
|
||||
|
||||
export interface ScriptModule {
|
||||
file: string;
|
||||
labels: Label[];
|
||||
anchors?: Record<string, Anchor>;
|
||||
}
|
||||
|
||||
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>;
|
||||
channels?: Record<string, { mixer: string; loop: boolean }>;
|
||||
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[];
|
||||
// values of label parameters shadowed by a call, restored on return
|
||||
saved?: Record<string, { had: boolean; value: any }>;
|
||||
}
|
||||
|
||||
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;
|
||||
camera?: AtSpec[];
|
||||
menuSeen?: string[];
|
||||
text?: string;
|
||||
}
|
||||
// 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;
|
||||
textExpr?: Expr;
|
||||
props?: Record<string, any>;
|
||||
frames?: { img: ImageDef; d?: number }[];
|
||||
repeat?: boolean;
|
||||
layered?: Layer[];
|
||||
at?: AtSpec[];
|
||||
selected?: string[]; // attributes chosen for a layered image (set by the engine)
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
always?: ImageDef;
|
||||
attr?: string;
|
||||
group?: string | null;
|
||||
default?: boolean;
|
||||
null?: boolean;
|
||||
img?: ImageDef;
|
||||
if_any?: string[];
|
||||
if_all?: string[];
|
||||
if_not?: string[];
|
||||
cond?: [Expr | null, ImageDef][];
|
||||
autoGroup?: string;
|
||||
}
|
||||
|
||||
export interface AtSpec {
|
||||
pos?: string;
|
||||
name?: string;
|
||||
props?: Record<string, any>;
|
||||
effect?: "shake";
|
||||
d?: number;
|
||||
dist?: number;
|
||||
}
|
||||
|
||||
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[];
|
||||
params?: [string, Expr | null][];
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
label: string;
|
||||
path: number[];
|
||||
params?: [string, Expr | null][] | null;
|
||||
}
|
||||
|
||||
export interface ScriptModule {
|
||||
file: string;
|
||||
labels: Label[];
|
||||
anchors?: Record<string, Anchor>;
|
||||
}
|
||||
|
||||
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>;
|
||||
channels?: Record<string, { mixer: string; loop: boolean }>;
|
||||
files: Record<string, string>;
|
||||
defaults: [string, Expr][];
|
||||
init: PyStmt[];
|
||||
ui: Record<string, any>;
|
||||
sourceLang?: string;
|
||||
locales?: Record<string, import("./i18n").LocaleInfo>;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
format: string;
|
||||
files: Record<string, { hash: string; size: number }>;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
label: string;
|
||||
path: number[];
|
||||
// values of label parameters shadowed by a call, restored on return
|
||||
saved?: Record<string, { had: boolean; value: any }>;
|
||||
}
|
||||
|
||||
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;
|
||||
camera?: AtSpec[];
|
||||
menuSeen?: string[];
|
||||
text?: string;
|
||||
}
|
||||
Reference in new issue
Block a user