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:
1 parent
35033fb86c
commit
a97b46079e
18 files changed
+3456
-2695
No files matched your search
+118
-118
@@ -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]}`;
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+366
-287
@@ -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
@@ -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
@@ -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)); }
|
||||
}
|
||||
Reference in new issue
Block a user