Converter: cover real Ren'Py games better, add folder import and dry run

Checked against six real games (Ren'Py 7.5 and 8.x):
- nested labels and named menus become jump anchors inside label bodies
- call with arguments and labels with parameters (dynamically scoped)
- frame-by-frame ATL images, Movie frames, layeredimage, camera, Shake,
  custom audio channels via register_channel, show text with expressions
- generator expressions in Python, no SyntaxWarning noise from decompiled code

Also:
- app.import_dir converts an unpacked game folder without changing it
- app.converter.analyze dry-runs the script part of the converter
- imports use their own "importing" status so a worker restart can't hijack them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:23:20 +03:00
1 parent 35033fb86c
commit a97b46079e
18 files changed
+3456 -2695

No files matched your search

+118 -118
View File
@@ -1,118 +1,118 @@
export interface Role {
id: number;
name: string;
permissions: string[];
is_system: boolean;
}
export interface User {
id: number;
username: string;
role: Role;
is_active: boolean;
must_change_password: boolean;
must_change_username: boolean;
created_at: string;
}
export interface Me extends User {
permissions: string[];
}
export interface Version {
id: number;
game_id: number;
parent_id: number | null;
owner: { id: number; username: string } | null;
name: string;
description: string;
status: "uploading" | "queued" | "converting" | "ready" | "failed";
progress: string;
size_bytes: number;
created_at: string;
report?: Record<string, any> | null;
}
export interface Game {
id: number;
title: string;
description: string;
tags: string[];
engine: string;
cover_hash: string | null;
created_at: string;
}
export interface GameDetail extends Game {
versions: Version[];
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
let msg = r.statusText;
try {
const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg);
}
return r.status === 204 ? (undefined as T) : r.json();
}
export const api = {
get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => {
let data: any = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
/* empty */
}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText));
};
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file);
});
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
export interface Role {
id: number;
name: string;
permissions: string[];
is_system: boolean;
}
export interface User {
id: number;
username: string;
role: Role;
is_active: boolean;
must_change_password: boolean;
must_change_username: boolean;
created_at: string;
}
export interface Me extends User {
permissions: string[];
}
export interface Version {
id: number;
game_id: number;
parent_id: number | null;
owner: { id: number; username: string } | null;
name: string;
description: string;
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
progress: string;
size_bytes: number;
created_at: string;
report?: Record<string, any> | null;
}
export interface Game {
id: number;
title: string;
description: string;
tags: string[];
engine: string;
cover_hash: string | null;
created_at: string;
}
export interface GameDetail extends Game {
versions: Version[];
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
let msg = r.statusText;
try {
const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg);
}
return r.status === 204 ? (undefined as T) : r.json();
}
export const api = {
get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => {
let data: any = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
/* empty */
}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText));
};
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file);
});
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
+3 -2
View File
@@ -7,6 +7,7 @@ const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
importing: "импортируется",
ready: "готова",
failed: "ошибка",
};
@@ -32,7 +33,7 @@ export function GamePage() {
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].includes(v.status));
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
useEffect(() => {
if (!busy) return;
const t = setInterval(() => void load(), 2000);
@@ -154,7 +155,7 @@ function VersionRow(props: {
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && v.status !== "converting" && <button className="danger" onClick={props.onDelete}>Удалить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div>
</div>
{report !== undefined && <Report report={report} />}
+126 -124
View File
@@ -1,124 +1,126 @@
// 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;
}
}
// 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 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";
}
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 ?? this.registered[ch]?.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;
}
}
+1357 -1289
View File
File diff suppressed because it is too large. Load diff
+366 -287
View File
@@ -1,287 +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;
}
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();
});
}
// 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();
});
}
+143 -108
View File
@@ -1,108 +1,143 @@
// 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;
}
// 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;
}
+153 -153
View File
@@ -1,153 +1,153 @@
:root {
--bg: #0f1117;
--panel: #171a23;
--panel-2: #1f2330;
--border: #2a2f3e;
--text: #e6e8ef;
--muted: #8b90a0;
--accent: #ff9f43;
--danger: #ef5a5a;
--ok: #4cc38a;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
}
a { color: inherit; text-decoration: none; }
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; }
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
input, select, textarea, button, .button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.7rem;
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button, .button { cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; }
button.danger { color: var(--danger); }
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); }
button.link:hover { color: var(--text); }
.muted { color: var(--muted); }
.small { font-size: 0.85em; }
.error { color: var(--danger); margin: 0.5rem 0; }
.ok { color: var(--ok); }
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; }
.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; }
.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); }
.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; }
.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; }
.inline-form input { flex: 1; min-width: 200px; }
.layout { min-height: 100%; display: flex; flex-direction: column; }
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.7rem 1.5rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; }
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
.topbar nav a { color: var(--muted); }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 0.3rem; }
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
.page.narrow { max-width: 560px; }
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
.game-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); }
.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; }
.cover {
aspect-ratio: 16 / 10;
background: linear-gradient(135deg, #262b3b, #1a1d29);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--muted);
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); }
.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; }
.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; }
.description { white-space: pre-wrap; margin: 0; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.editor { display: flex; flex-direction: column; gap: 0.6rem; }
.versions { display: flex; flex-direction: column; gap: 0.6rem; }
.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; }
.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; }
.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); }
.status-ready { color: var(--ok); }
.status-failed { color: var(--danger); }
.status-queued, .status-converting, .status-uploading { color: var(--accent); }
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; }
.report table { border-collapse: collapse; margin-top: 0.4rem; }
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; }
.report summary { cursor: pointer; margin: 0.3rem 0; }
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; }
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 500; }
.table tr.inactive td { opacity: 0.55; }
.center-cell { text-align: center !important; }
.roles th:not(:first-child) { text-align: center; }
.issued { border-color: var(--accent); margin-bottom: 1rem; }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; }
.play { position: fixed; inset: 0; background: #000; }
.play-host { position: absolute; inset: 0; }
.play-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
color: var(--muted);
pointer-events: none;
}
.play-status button { pointer-events: auto; }
@media (max-width: 640px) {
.page { padding: 1rem; }
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}
:root {
--bg: #0f1117;
--panel: #171a23;
--panel-2: #1f2330;
--border: #2a2f3e;
--text: #e6e8ef;
--muted: #8b90a0;
--accent: #ff9f43;
--danger: #ef5a5a;
--ok: #4cc38a;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
}
a { color: inherit; text-decoration: none; }
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; }
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
input, select, textarea, button, .button {
font: inherit;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.7rem;
}
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button, .button { cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; }
button.danger { color: var(--danger); }
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); }
button.link:hover { color: var(--text); }
.muted { color: var(--muted); }
.small { font-size: 0.85em; }
.error { color: var(--danger); margin: 0.5rem 0; }
.ok { color: var(--ok); }
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; }
.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; }
.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); }
.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; }
.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; }
.inline-form input { flex: 1; min-width: 200px; }
.layout { min-height: 100%; display: flex; flex-direction: column; }
.topbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.7rem 1.5rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; }
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
.topbar nav a { color: var(--muted); }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 0.3rem; }
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
.page.narrow { max-width: 560px; }
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
.game-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); }
.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; }
.cover {
aspect-ratio: 16 / 10;
background: linear-gradient(135deg, #262b3b, #1a1d29);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
color: var(--muted);
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); }
.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; }
.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; }
.description { white-space: pre-wrap; margin: 0; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.editor { display: flex; flex-direction: column; gap: 0.6rem; }
.versions { display: flex; flex-direction: column; gap: 0.6rem; }
.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; }
.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; }
.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); }
.status-ready { color: var(--ok); }
.status-failed { color: var(--danger); }
.status-queued, .status-converting, .status-importing, .status-uploading { color: var(--accent); }
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; }
.report table { border-collapse: collapse; margin-top: 0.4rem; }
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; }
.report summary { cursor: pointer; margin: 0.3rem 0; }
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; }
.table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 500; }
.table tr.inactive td { opacity: 0.55; }
.center-cell { text-align: center !important; }
.roles th:not(:first-child) { text-align: center; }
.issued { border-color: var(--accent); margin-bottom: 1rem; }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; }
.play { position: fixed; inset: 0; background: #000; }
.play-host { position: absolute; inset: 0; }
.play-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
color: var(--muted);
pointer-events: none;
}
.play-status button { pointer-events: auto; }
@media (max-width: 640px) {
.page { padding: 1rem; }
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
}