Initial MVP: local visual novel library with Ren'Py → PGVN converter
- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
|
||||
import { useAuth } from "./auth";
|
||||
import { ChangeCredentials } from "./pages/ChangeCredentials";
|
||||
import { GamePage } from "./pages/GamePage";
|
||||
import { Library } from "./pages/Library";
|
||||
import { Login } from "./pages/Login";
|
||||
import { NewGame } from "./pages/NewGame";
|
||||
import { Play } from "./pages/Play";
|
||||
import { Roles } from "./pages/Roles";
|
||||
import { Users } from "./pages/Users";
|
||||
|
||||
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
|
||||
const { can } = useAuth();
|
||||
return can(perm) ? children : <div className="page"><p className="muted">Нет доступа к этой странице.</p></div>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { me, loading, logout, can } = useAuth();
|
||||
if (loading) return <div className="center muted">Загрузка…</div>;
|
||||
if (!me) return <Login />;
|
||||
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<div className="layout">
|
||||
<header className="topbar">
|
||||
<NavLink to="/" className="brand">PGames</NavLink>
|
||||
<nav>
|
||||
<NavLink to="/" end>Библиотека</NavLink>
|
||||
{can("games.upload") && <NavLink to="/games/new">Загрузить игру</NavLink>}
|
||||
{can("users.manage") && <NavLink to="/admin/users">Пользователи</NavLink>}
|
||||
{can("roles.manage") && <NavLink to="/admin/roles">Роли</NavLink>}
|
||||
</nav>
|
||||
<div className="user">
|
||||
<NavLink to="/account">{me.username}</NavLink>
|
||||
<span className="muted"> · {me.role.name}</span>
|
||||
<button className="link" onClick={() => void logout()}>Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} />
|
||||
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
|
||||
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
|
||||
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
|
||||
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
|
||||
<Route path="/account" element={<ChangeCredentials />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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]}`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api, type Me } from "./api";
|
||||
|
||||
interface AuthState {
|
||||
me: Me | null;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
setMe: (me: Me | null) => void;
|
||||
logout: () => Promise<void>;
|
||||
can: (perm: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setMe(await api.get<Me>("/api/auth/me"));
|
||||
} catch {
|
||||
setMe(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const onUnauthorized = () => setMe(null);
|
||||
window.addEventListener("pg-unauthorized", onUnauthorized);
|
||||
return () => window.removeEventListener("pg-unauthorized", onUnauthorized);
|
||||
}, [refresh]);
|
||||
|
||||
const logout = async () => {
|
||||
await api.post("/api/auth/logout");
|
||||
setMe(null);
|
||||
};
|
||||
const can = (perm: string) => !!me?.permissions.includes(perm);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ me, loading, refresh, setMe, logout, can }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("AuthProvider missing");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./auth";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
||||
const { me, setMe, logout } = useAuth();
|
||||
const needUsername = !!me?.must_change_username;
|
||||
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (password !== repeat) {
|
||||
setError("Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await api.post<Me>("/api/auth/change-credentials", {
|
||||
current_password: current,
|
||||
new_password: password,
|
||||
new_username: username.trim() || null,
|
||||
});
|
||||
setMe(updated);
|
||||
setDone(true);
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setRepeat("");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const form = (
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
|
||||
{forced && (
|
||||
<p className="muted">
|
||||
{needUsername
|
||||
? "Это первый вход администратора. Задайте собственный логин и пароль."
|
||||
: "Пароль был выдан администратором. Задайте свой пароль."}
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Логин{needUsername && " (новый)"}
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Текущий пароль
|
||||
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
Новый пароль (от 8 символов)
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
</label>
|
||||
<label>
|
||||
Повторите пароль
|
||||
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{done && !forced && <div className="ok">Сохранено</div>}
|
||||
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button>
|
||||
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
|
||||
</form>
|
||||
);
|
||||
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
const STATUS: Record<Version["status"], string> = {
|
||||
uploading: "загружается",
|
||||
queued: "в очереди",
|
||||
converting: "конвертируется",
|
||||
ready: "готова",
|
||||
failed: "ошибка",
|
||||
};
|
||||
|
||||
export function GamePage() {
|
||||
const { gameId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { me, can } = useAuth();
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// poll while something is being converted
|
||||
const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].includes(v.status));
|
||||
useEffect(() => {
|
||||
if (!busy) return;
|
||||
const t = setInterval(() => void load(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [busy, load]);
|
||||
|
||||
if (error) return <div className="page"><div className="error">{error}</div></div>;
|
||||
if (!game) return <div className="page muted">Загрузка…</div>;
|
||||
|
||||
// the backend also lets uploaders edit only games they created
|
||||
const canEdit = can("games.edit") || can("games.upload");
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const fork = (v: Version) => {
|
||||
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
|
||||
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
|
||||
};
|
||||
|
||||
const uploadCover = (file: File) =>
|
||||
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
|
||||
|
||||
const uploadVersion = (file: File) => {
|
||||
const name = prompt("Название версии", file.name);
|
||||
if (name)
|
||||
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
|
||||
file, () => {}));
|
||||
};
|
||||
|
||||
const byParent = new Map<number | null, Version[]>();
|
||||
for (const v of game.versions) {
|
||||
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
|
||||
byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
|
||||
}
|
||||
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
|
||||
(byParent.get(parent) ?? []).flatMap((v) => [
|
||||
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
|
||||
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
|
||||
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
|
||||
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
|
||||
...renderTree(v.id, depth + 1),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Link to="/" className="muted">← Библиотека</Link>
|
||||
<div className="game-head">
|
||||
<div className="cover big">
|
||||
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
|
||||
</div>
|
||||
<div className="game-info">
|
||||
{editing ? (
|
||||
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
|
||||
) : (
|
||||
<>
|
||||
<h1>{game.title}</h1>
|
||||
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
|
||||
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
|
||||
<div className="actions">
|
||||
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
|
||||
{canEdit && (
|
||||
<label className="button">
|
||||
Обложка
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
{can("games.upload") && (
|
||||
<label className="button">
|
||||
Загрузить ещё архив
|
||||
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
{can("games.delete") && (
|
||||
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
|
||||
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<h2>Версии</h2>
|
||||
<div className="versions">{renderTree(null, 0)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRow(props: {
|
||||
v: Version; depth: number; canManage: boolean; canFork: boolean;
|
||||
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
|
||||
}) {
|
||||
const { v } = props;
|
||||
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
|
||||
const toggleReport = async () => {
|
||||
if (report !== undefined) setReport(undefined);
|
||||
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
|
||||
};
|
||||
return (
|
||||
<div className="version" style={{ marginLeft: props.depth * 24 }}>
|
||||
<div className="version-main">
|
||||
<div>
|
||||
<b>{props.depth > 0 && "↳ "}{v.name}</b>
|
||||
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
|
||||
<div className="muted small">
|
||||
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
|
||||
{v.progress && ` · ${v.progress}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
|
||||
{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>}
|
||||
</div>
|
||||
</div>
|
||||
{report !== undefined && <Report report={report} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Report({ report }: { report: Record<string, any> | null }) {
|
||||
if (!report) return <div className="report muted">Отчёта нет</div>;
|
||||
if (report.error) return <div className="report error">{report.error}</div>;
|
||||
return (
|
||||
<div className="report">
|
||||
<div className="report-stats">
|
||||
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
|
||||
<span>Меток: {report.labels}</span>
|
||||
<span>Реплик: {report.stats?.say ?? 0}</span>
|
||||
<span>Меню: {report.stats?.menu ?? 0}</span>
|
||||
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
|
||||
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
|
||||
<span>Время: {report.durationSec} с</span>
|
||||
</div>
|
||||
{report.missingFiles?.length > 0 && (
|
||||
<details>
|
||||
<summary>Не найдены файлы ({report.missingFiles.length})</summary>
|
||||
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
|
||||
</details>
|
||||
)}
|
||||
{report.warnings?.length > 0 && (
|
||||
<details>
|
||||
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
|
||||
<table>
|
||||
<tbody>
|
||||
{report.warnings.map((w: any) => (
|
||||
<tr key={w.message}>
|
||||
<td>{w.count}×</td>
|
||||
<td>{w.message}</td>
|
||||
<td className="muted small">{w.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
|
||||
const [title, setTitle] = useState(game.title);
|
||||
const [description, setDescription] = useState(game.description);
|
||||
const [tags, setTags] = useState(game.tags.join(", "));
|
||||
const [error, setError] = useState("");
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.patch(`/api/games/${game.id}`, {
|
||||
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="editor">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
|
||||
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="actions">
|
||||
<button className="primary" onClick={() => void save()}>Сохранить</button>
|
||||
<button onClick={onDone}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api, type Game } from "../api";
|
||||
|
||||
export function Library() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get("q") ?? "";
|
||||
const [query, setQuery] = useState(q);
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setParams(query ? { q: query } : {}, { replace: true }), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
|
||||
.then((g) => !cancelled && setGames(g))
|
||||
.catch((e) => !cancelled && setError(e.message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="search">
|
||||
<input placeholder="Поиск по названию, описанию или тегу…" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{games === null ? (
|
||||
<p className="muted">Загрузка…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{games.map((g) => (
|
||||
<Link key={g.id} to={`/games/${g.id}`} className="game-card">
|
||||
<div className="cover">
|
||||
{g.cover_hash ? <img src={`/blobs/${g.cover_hash}`} alt="" loading="lazy" /> : <span>{g.title[0]}</span>}
|
||||
</div>
|
||||
<div className="game-card-body">
|
||||
<b>{g.title}</b>
|
||||
<div className="tags">
|
||||
{g.tags.slice(0, 4).map((t) => <span key={t} className="tag">{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export function Login() {
|
||||
const { setMe } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setMe(await api.post<Me>("/api/auth/login", { username, password }));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center">
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>PGames</h1>
|
||||
<p className="muted">Локальная библиотека визуальных новелл</p>
|
||||
<label>
|
||||
Логин
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password" />
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button className="primary" disabled={busy || !username || !password}>Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, uploadRaw, type Game } from "../api";
|
||||
|
||||
export function NewGame() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setError("");
|
||||
setProgress(0);
|
||||
try {
|
||||
const game = await api.post<Game>("/api/games", {
|
||||
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
|
||||
description,
|
||||
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
await uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}`,
|
||||
file, setProgress);
|
||||
navigate(`/games/${game.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page narrow">
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h1>Новая игра</h1>
|
||||
<p className="muted">
|
||||
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его,
|
||||
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники.
|
||||
</p>
|
||||
<label>
|
||||
Архив
|
||||
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} />
|
||||
</label>
|
||||
<label>
|
||||
Теги через запятую
|
||||
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{progress !== null && (
|
||||
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div>
|
||||
)}
|
||||
<button className="primary" disabled={!file || progress !== null}>
|
||||
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { Engine } from "../player/engine";
|
||||
|
||||
export function Play() {
|
||||
const { versionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState("Загрузка…");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let engine: Engine | null = null;
|
||||
let cancelled = false;
|
||||
// the player is a separate chunk: the library UI does not pay for it
|
||||
import("../player").then(({ launch }) =>
|
||||
launch(host.current!, Number(versionId), () => navigate(-1), setStatus),
|
||||
).then((e) => {
|
||||
if (cancelled) e.destroy();
|
||||
else {
|
||||
engine = e;
|
||||
setStatus("");
|
||||
}
|
||||
}).catch((e) => setError((e as Error).message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
engine?.destroy();
|
||||
};
|
||||
}, [versionId, navigate]);
|
||||
|
||||
return (
|
||||
<div className="play">
|
||||
<div ref={host} className="play-host" />
|
||||
{(status || error) && (
|
||||
<div className="play-status">
|
||||
{error ? <span className="error">{error}</span> : status}
|
||||
{error && <button onClick={() => navigate(-1)}>Назад</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type Role } from "../api";
|
||||
|
||||
export function Roles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [perms, setPerms] = useState<Record<string, string>>({});
|
||||
const [newName, setNewName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [r, p] = await Promise.all([api.get<Role[]>("/api/roles"),
|
||||
api.get<Record<string, string>>("/api/roles/permissions")]);
|
||||
setRoles(r);
|
||||
setPerms(p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (role: Role, perm: string) => {
|
||||
const next = role.permissions.includes(perm)
|
||||
? role.permissions.filter((p) => p !== perm)
|
||||
: [...role.permissions, perm];
|
||||
void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Роли и права</h1>
|
||||
<p className="muted">Роль — набор прав. Роль admin всегда имеет все права.</p>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="table-wrap">
|
||||
<table className="table roles">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Право</th>
|
||||
{roles.map((r) => (
|
||||
<th key={r.id}>
|
||||
{r.name}
|
||||
{!r.is_system && (
|
||||
<button className="link danger" title="Удалить роль"
|
||||
onClick={() => confirm(`Удалить роль ${r.name}?`) && void run(() => api.del(`/api/roles/${r.id}`))}>✕</button>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(perms).map(([code, label]) => (
|
||||
<tr key={code}>
|
||||
<td>{label} <span className="muted small">{code}</span></td>
|
||||
{roles.map((r) => (
|
||||
<td key={r.id} className="center-cell">
|
||||
<input type="checkbox" checked={r.permissions.includes(code)} disabled={r.name === "admin"}
|
||||
onChange={() => toggle(r, code)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form className="card inline-form" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] })).then(() => setNewName(""));
|
||||
}}>
|
||||
<input placeholder="Название новой роли" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||
<button className="primary" disabled={!newName.trim()}>Добавить роль</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type Role, type User } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
interface Issued {
|
||||
user: User;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function Users() {
|
||||
const { me } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const [roleId, setRoleId] = useState<number | "">("");
|
||||
const [issued, setIssued] = useState<Issued | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [u, r] = await Promise.all([api.get<User[]>("/api/users"), api.get<Role[]>("/api/roles")]);
|
||||
setUsers(u);
|
||||
setRoles(r);
|
||||
setRoleId((cur) => cur || (r.find((x) => x.name === "player")?.id ?? r[0]?.id ?? ""));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const create = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
setIssued(await api.post<Issued>("/api/users", { username: username.trim(), role_id: roleId }));
|
||||
setUsername("");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Пользователи</h1>
|
||||
<form className="card inline-form" onSubmit={create}>
|
||||
<input placeholder="Логин нового пользователя" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="primary" disabled={username.trim().length < 3}>Создать</button>
|
||||
</form>
|
||||
{issued && (
|
||||
<div className="card issued">
|
||||
<p>
|
||||
Пароль для <b>{issued.user.username}</b> (показывается один раз, передайте его пользователю —
|
||||
при первом входе он задаст свой):
|
||||
</p>
|
||||
<div className="password-box">
|
||||
<code>{issued.password}</code>
|
||||
<button onClick={() => void navigator.clipboard?.writeText(`${issued.user.username} / ${issued.password}`)}>
|
||||
Копировать
|
||||
</button>
|
||||
<button onClick={() => setIssued(null)}>Скрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error">{error}</div>}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
|
||||
<td>
|
||||
{u.username}
|
||||
{u.id === me?.id && <span className="muted"> (вы)</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select value={u.role.id} disabled={u.id === me?.id}
|
||||
onChange={(e) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: Number(e.target.value) }))}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.is_active ? "активен" : "отключён"}
|
||||
{u.must_change_password && <span className="muted"> · ждёт смены пароля</span>}
|
||||
</td>
|
||||
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td className="actions">
|
||||
{u.id !== me?.id && (
|
||||
<>
|
||||
<button onClick={() => void run(async () =>
|
||||
setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)))}>Сбросить пароль</button>
|
||||
<button onClick={() => void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }))}>
|
||||
{u.is_active ? "Отключить" : "Включить"}
|
||||
</button>
|
||||
<button className="danger" onClick={() => confirm(`Удалить ${u.username}?`) &&
|
||||
void run(() => api.del(`/api/users/${u.id}`))}>Удалить</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots.
|
||||
|
||||
export interface Volumes {
|
||||
music: number;
|
||||
sound: number;
|
||||
voice: number;
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
el: HTMLAudioElement | null;
|
||||
file: string | null;
|
||||
loop: boolean;
|
||||
queue: string[];
|
||||
}
|
||||
|
||||
function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) {
|
||||
if (seconds <= 0) {
|
||||
el.volume = to;
|
||||
done?.();
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
const step = () => {
|
||||
const t = Math.min(1, (performance.now() - start) / (seconds * 1000));
|
||||
el.volume = Math.max(0, Math.min(1, from + (to - from) * t));
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
else done?.();
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
export class AudioSystem {
|
||||
channels = new Map<string, Channel>();
|
||||
|
||||
constructor(public volumes: Volumes, private url: (file: string) => string | null) {}
|
||||
|
||||
private group(ch: string): keyof Volumes {
|
||||
if (ch === "music") return "music";
|
||||
if (ch === "voice") return "voice";
|
||||
return "sound";
|
||||
}
|
||||
|
||||
private channel(name: string): Channel {
|
||||
let c = this.channels.get(name);
|
||||
if (!c) {
|
||||
c = { el: null, file: null, loop: false, queue: [] };
|
||||
this.channels.set(name, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
|
||||
ifChanged?: boolean }) {
|
||||
const c = this.channel(ch);
|
||||
const loop = opts.loop ?? ch === "music";
|
||||
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
|
||||
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
|
||||
c.queue = files.slice(1);
|
||||
c.loop = loop;
|
||||
this.start(ch, c, files[0], opts.fadein ?? 0);
|
||||
}
|
||||
|
||||
queue(ch: string, files: string[], loop?: boolean | null) {
|
||||
const c = this.channel(ch);
|
||||
if (!c.el || c.el.paused) {
|
||||
this.play(ch, files, { loop });
|
||||
return;
|
||||
}
|
||||
c.queue.push(...files);
|
||||
if (loop != null) c.loop = loop;
|
||||
}
|
||||
|
||||
private start(ch: string, c: Channel, file: string | undefined, fadein: number) {
|
||||
if (!file) return;
|
||||
const src = this.url(file);
|
||||
if (!src) {
|
||||
console.warn("[pgvn] нет аудиофайла", file);
|
||||
return;
|
||||
}
|
||||
const el = new Audio(src);
|
||||
const target = this.volumes[this.group(ch)];
|
||||
el.volume = fadein > 0 ? 0 : target;
|
||||
el.loop = c.loop && c.queue.length === 0;
|
||||
el.onended = () => {
|
||||
if (c.el !== el) return;
|
||||
const next = c.queue.shift();
|
||||
if (next) {
|
||||
if (c.loop) c.queue.push(next);
|
||||
this.start(ch, c, next, 0);
|
||||
} else {
|
||||
c.el = null;
|
||||
c.file = null;
|
||||
}
|
||||
};
|
||||
c.el = el;
|
||||
c.file = file;
|
||||
el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука"));
|
||||
if (fadein > 0) fade(el, 0, target, fadein);
|
||||
}
|
||||
|
||||
stop(ch: string, fadeout = 0) {
|
||||
const c = this.channels.get(ch);
|
||||
if (!c?.el) return;
|
||||
const el = c.el;
|
||||
c.el = null;
|
||||
c.file = null;
|
||||
c.queue = [];
|
||||
fade(el, el.volume, 0, fadeout, () => el.pause());
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
for (const ch of this.channels.keys()) this.stop(ch);
|
||||
}
|
||||
|
||||
setVolumes(v: Volumes) {
|
||||
this.volumes = v;
|
||||
for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)];
|
||||
}
|
||||
|
||||
current(ch: string): { file: string; loop: boolean } | null {
|
||||
const c = this.channels.get(ch);
|
||||
return c?.file ? { file: c.file, loop: c.loop } : null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
import "./player.css";
|
||||
import { Engine, type SaveApi } from "./engine";
|
||||
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
|
||||
|
||||
async function getJSON<T>(url: string): Promise<T> {
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error(`${r.status} ${url}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function blob(manifest: Manifest, path: string) {
|
||||
const f = manifest.files[path];
|
||||
if (!f) throw new Error(`в версии нет файла ${path}`);
|
||||
return `/blobs/${f.hash}`;
|
||||
}
|
||||
|
||||
function saveApi(versionId: number): SaveApi {
|
||||
const base = `/api/versions/${versionId}/saves`;
|
||||
return {
|
||||
list: () => getJSON(base),
|
||||
async get(slot) {
|
||||
const r = await fetch(`${base}/${slot}`);
|
||||
if (r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
return (await r.json()).data as SaveData;
|
||||
},
|
||||
async put(slot, title, data) {
|
||||
const r = await fetch(`${base}/${slot}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, data }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`${r.status}`);
|
||||
},
|
||||
async remove(slot) {
|
||||
await fetch(`${base}/${slot}`, { method: "DELETE" });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
|
||||
onProgress: (text: string) => void): Promise<Engine> {
|
||||
onProgress("Загрузка манифеста");
|
||||
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
||||
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
||||
onProgress(`Загрузка скриптов (${game.scripts.length})`);
|
||||
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
|
||||
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
||||
`pgvn:persistent:${versionId}`, onExit);
|
||||
engine.start();
|
||||
return engine;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
.pg-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pg-stage {
|
||||
--pg-accent: #ff9f43;
|
||||
--pg-text: #ffffff;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: var(--pg-text);
|
||||
font-family: "Segoe UI", system-ui, sans-serif;
|
||||
font-size: var(--pg-font);
|
||||
transform-origin: center;
|
||||
cursor: default;
|
||||
}
|
||||
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
|
||||
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
|
||||
|
||||
.pg-layer { position: absolute; inset: 0; }
|
||||
.pg-transition { pointer-events: none; }
|
||||
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
|
||||
.pg-fill { position: absolute; inset: 0; }
|
||||
.pg-image { position: absolute; will-change: transform, opacity; }
|
||||
.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; }
|
||||
|
||||
.pg-hidden { display: none !important; }
|
||||
|
||||
.pg-textbox {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92));
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
|
||||
.pg-textbox.pg-ui-hidden { opacity: 0; }
|
||||
|
||||
.pg-namebox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.15em 0.8em;
|
||||
font-size: var(--pg-name-font);
|
||||
font-weight: 600;
|
||||
color: var(--pg-accent);
|
||||
background: rgba(10, 12, 24, 0.85);
|
||||
border-radius: 0.3em;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
}
|
||||
.pg-textbox-image .pg-namebox { background: transparent; }
|
||||
|
||||
.pg-text {
|
||||
position: absolute;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; }
|
||||
@keyframes pg-blink { 50% { opacity: 0.1; } }
|
||||
|
||||
.pg-quick {
|
||||
position: absolute;
|
||||
bottom: 0.4em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 0.2em;
|
||||
}
|
||||
.pg-quick button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font: inherit;
|
||||
font-size: calc(var(--pg-font) * 0.55);
|
||||
padding: 0.2em 0.6em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pg-quick button:hover, .pg-quick button.pg-on { color: var(--pg-accent); }
|
||||
|
||||
.pg-menu {
|
||||
position: absolute;
|
||||
inset: 0 0 26% 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.pg-choice {
|
||||
min-width: 50%;
|
||||
max-width: 75%;
|
||||
padding: 0.45em 1.2em;
|
||||
font: inherit;
|
||||
color: #fff;
|
||||
background: rgba(15, 18, 35, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.3em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
|
||||
|
||||
.pg-nvl {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 5% 15%;
|
||||
background: rgba(0, 0, 0, 0.72) center / cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
|
||||
.pg-nvl-what { white-space: pre-wrap; }
|
||||
|
||||
.pg-centered {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 15%;
|
||||
white-space: pre-wrap;
|
||||
text-shadow: 0 2px 4px #000;
|
||||
}
|
||||
|
||||
.pg-notify {
|
||||
position: absolute;
|
||||
top: 2%;
|
||||
left: 2%;
|
||||
padding: 0.4em 1em;
|
||||
font-size: calc(var(--pg-font) * 0.7);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-left: 4px solid var(--pg-accent);
|
||||
}
|
||||
|
||||
.pg-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(8, 10, 20, 0.94) center / cover;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 3% 6%;
|
||||
font-size: calc(var(--pg-font) * 0.85);
|
||||
z-index: 10;
|
||||
}
|
||||
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.pg-screen-head h2 { margin: 0 0 0.6em; color: var(--pg-accent); font-weight: 600; }
|
||||
.pg-screen button {
|
||||
font: inherit;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.4em 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pg-screen button:hover:not(:disabled) { border-color: var(--pg-accent); color: var(--pg-accent); }
|
||||
.pg-screen button:disabled { opacity: 0.4; cursor: default; }
|
||||
.pg-close { background: none !important; border: 0 !important; font-size: 1.3em !important; }
|
||||
.pg-screen-body { flex: 1; overflow: auto; }
|
||||
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: 30%; }
|
||||
.pg-history { display: flex; flex-direction: column; gap: 0.6em; max-height: 100%; overflow: auto; }
|
||||
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
|
||||
.pg-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6em; }
|
||||
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 5em; }
|
||||
.pg-slot-title { font-size: 0.8em; opacity: 0.85; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.pg-slot small { opacity: 0.5; font-size: 0.65em; }
|
||||
.pg-setting { display: grid; grid-template-columns: 30% 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.7em; }
|
||||
.pg-setting input { width: 100%; accent-color: var(--pg-accent); }
|
||||
.pg-setting-val { opacity: 0.7; }
|
||||
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
|
||||
|
||||
.pg-screen.pg-main { background-color: #000; justify-content: center; }
|
||||
.pg-main-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
width: 32%;
|
||||
padding: 2em;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
|
||||
.pg-main-panel button { text-align: left; }
|
||||
@@ -0,0 +1,470 @@
|
||||
// A small evaluator for the Python subset emitted by the converter.
|
||||
// Python values map to JS: None→null, list/tuple→Array, dict→PyDict, str/num/bool as is.
|
||||
|
||||
import type { Expr, PyStmt } from "./types";
|
||||
|
||||
export class PyDict {
|
||||
map = new Map<any, any>();
|
||||
constructor(entries?: Iterable<[any, any]>) {
|
||||
if (entries) for (const [k, v] of entries) this.map.set(key(k), v);
|
||||
}
|
||||
get(k: any) {
|
||||
return this.map.get(key(k));
|
||||
}
|
||||
set(k: any, v: any) {
|
||||
this.map.set(key(k), v);
|
||||
}
|
||||
has(k: any) {
|
||||
return this.map.has(key(k));
|
||||
}
|
||||
toJSON() {
|
||||
return { __dict__: [...this.map.entries()] };
|
||||
}
|
||||
}
|
||||
|
||||
// tuples used as dict keys must compare by value
|
||||
function key(k: any) {
|
||||
return Array.isArray(k) ? JSON.stringify(k) : k;
|
||||
}
|
||||
|
||||
export class JumpSignal {
|
||||
constructor(public kind: "jump" | "call", public target: string) {}
|
||||
}
|
||||
|
||||
class BreakSignal {}
|
||||
class ContinueSignal {}
|
||||
|
||||
export type Builtin = ((...args: any[]) => any) & { __py?: true };
|
||||
|
||||
export function truthy(v: any): boolean {
|
||||
if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
if (v instanceof PyDict) return v.map.size > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pyEq(a: any, b: any): boolean {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || a === undefined || b === undefined) return a == b;
|
||||
if (Array.isArray(a) && Array.isArray(b))
|
||||
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
|
||||
if (typeof a === "boolean" || typeof b === "boolean") return Number(a) === Number(b);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pyStr(v: any): string {
|
||||
if (v === null || v === undefined) return "None";
|
||||
if (v === true) return "True";
|
||||
if (v === false) return "False";
|
||||
if (typeof v === "number") return Number.isInteger(v) ? String(v) : String(v);
|
||||
if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
|
||||
if (v instanceof PyDict)
|
||||
return "{" + [...v.map.entries()].map(([k, x]) => `${pyRepr(k)}: ${pyRepr(x)}`).join(", ") + "}";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function pyRepr(v: any): string {
|
||||
return typeof v === "string" ? `'${v}'` : pyStr(v);
|
||||
}
|
||||
|
||||
function pyMod(a: number, b: number) {
|
||||
return ((a % b) + b) % b;
|
||||
}
|
||||
|
||||
function percentFormat(fmt: string, args: any): string {
|
||||
const list = Array.isArray(args) ? args : [args];
|
||||
let i = 0;
|
||||
return fmt.replace(/%(\([^)]*\))?[-+ 0#]*\d*(\.\d+)?([sdifr%])/g, (m, name, prec, conv) => {
|
||||
if (conv === "%") return "%";
|
||||
const v = name && args instanceof PyDict ? args.get(name.slice(1, -1)) : list[i++];
|
||||
if (conv === "d" || conv === "i") return String(Math.trunc(Number(v)));
|
||||
if (conv === "f") return Number(v).toFixed(prec ? Number(prec.slice(1)) : 6);
|
||||
void m;
|
||||
return pyStr(v);
|
||||
});
|
||||
}
|
||||
|
||||
export function binop(op: string, a: any, b: any): any {
|
||||
switch (op) {
|
||||
case "+":
|
||||
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
|
||||
if (typeof a === "string" || typeof b === "string") {
|
||||
if (typeof a !== typeof b) throw new TypeError("str + не-str");
|
||||
return a + b;
|
||||
}
|
||||
return a + b;
|
||||
case "-":
|
||||
return a - b;
|
||||
case "*":
|
||||
if (typeof a === "string" && typeof b === "number") return a.repeat(Math.max(0, b));
|
||||
if (Array.isArray(a) && typeof b === "number") return Array.from({ length: b }, () => a).flat();
|
||||
return a * b;
|
||||
case "/":
|
||||
return a / b;
|
||||
case "//":
|
||||
return Math.floor(a / b);
|
||||
case "%":
|
||||
if (typeof a === "string") return percentFormat(a, b);
|
||||
return pyMod(a, b);
|
||||
case "**":
|
||||
return a ** b;
|
||||
case "&":
|
||||
return a & b;
|
||||
case "|":
|
||||
return a | b;
|
||||
case "^":
|
||||
return a ^ b;
|
||||
case "<<":
|
||||
return a << b;
|
||||
case ">>":
|
||||
return a >> b;
|
||||
}
|
||||
throw new Error(`оператор ${op}`);
|
||||
}
|
||||
|
||||
function contains(container: any, item: any): boolean {
|
||||
if (typeof container === "string") return container.includes(item);
|
||||
if (Array.isArray(container)) return container.some((x) => pyEq(x, item));
|
||||
if (container instanceof PyDict) return container.has(item);
|
||||
if (container && typeof container === "object") return item in container;
|
||||
return false;
|
||||
}
|
||||
|
||||
function compare(op: string, a: any, b: any): boolean {
|
||||
switch (op) {
|
||||
case "==":
|
||||
return pyEq(a, b);
|
||||
case "!=":
|
||||
return !pyEq(a, b);
|
||||
case "<":
|
||||
return a < b;
|
||||
case "<=":
|
||||
return a <= b;
|
||||
case ">":
|
||||
return a > b;
|
||||
case ">=":
|
||||
return a >= b;
|
||||
case "in":
|
||||
return contains(b, a);
|
||||
case "not in":
|
||||
return !contains(b, a);
|
||||
case "is":
|
||||
return a === b || (a == null && b == null);
|
||||
case "is not":
|
||||
return !(a === b || (a == null && b == null));
|
||||
}
|
||||
throw new Error(`сравнение ${op}`);
|
||||
}
|
||||
|
||||
function index(len: number, i: number) {
|
||||
return i < 0 ? len + i : i;
|
||||
}
|
||||
|
||||
function methodOf(obj: any, name: string): any {
|
||||
if (Array.isArray(obj)) {
|
||||
const m: Record<string, Builtin> = {
|
||||
append: (x) => void obj.push(x),
|
||||
extend: (xs) => void obj.push(...xs),
|
||||
insert: (i, x) => void obj.splice(i, 0, x),
|
||||
pop: (i = -1) => obj.splice(index(obj.length, i), 1)[0],
|
||||
remove: (x) => {
|
||||
const i = obj.findIndex((y) => pyEq(y, x));
|
||||
if (i < 0) throw new Error("list.remove: нет элемента");
|
||||
obj.splice(i, 1);
|
||||
},
|
||||
index: (x) => obj.findIndex((y) => pyEq(y, x)),
|
||||
count: (x) => obj.filter((y) => pyEq(y, x)).length,
|
||||
reverse: () => void obj.reverse(),
|
||||
sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||
clear: () => void obj.splice(0),
|
||||
copy: () => [...obj],
|
||||
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here
|
||||
discard: (x) => {
|
||||
const i = obj.findIndex((y) => pyEq(y, x));
|
||||
if (i >= 0) obj.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return m[name];
|
||||
}
|
||||
if (obj instanceof PyDict) {
|
||||
const m: Record<string, Builtin> = {
|
||||
get: (k, d = null) => (obj.has(k) ? obj.get(k) : d),
|
||||
keys: () => [...obj.map.keys()],
|
||||
values: () => [...obj.map.values()],
|
||||
items: () => [...obj.map.entries()],
|
||||
pop: (k, d = null) => {
|
||||
const v = obj.has(k) ? obj.get(k) : d;
|
||||
obj.map.delete(key(k));
|
||||
return v;
|
||||
},
|
||||
setdefault: (k, d = null) => {
|
||||
if (!obj.has(k)) obj.set(k, d);
|
||||
return obj.get(k);
|
||||
},
|
||||
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)),
|
||||
clear: () => obj.map.clear(),
|
||||
copy: () => new PyDict(obj.map.entries()),
|
||||
};
|
||||
return m[name];
|
||||
}
|
||||
if (typeof obj === "string") {
|
||||
const m: Record<string, Builtin> = {
|
||||
lower: () => obj.toLowerCase(),
|
||||
upper: () => obj.toUpperCase(),
|
||||
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
|
||||
title: () => obj.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase()),
|
||||
strip: () => obj.trim(),
|
||||
lstrip: () => obj.trimStart(),
|
||||
rstrip: () => obj.trimEnd(),
|
||||
startswith: (p) => obj.startsWith(p),
|
||||
endswith: (p) => obj.endsWith(p),
|
||||
replace: (a, b) => obj.split(a).join(b),
|
||||
split: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)),
|
||||
join: (xs) => xs.map(pyStr).join(obj),
|
||||
format: (...args) => {
|
||||
let i = 0;
|
||||
return obj.replace(/\{(\d*)\}/g, (_, n) => pyStr(args[n === "" ? i++ : Number(n)]));
|
||||
},
|
||||
count: (s) => obj.split(s).length - 1,
|
||||
find: (s) => obj.indexOf(s),
|
||||
isdigit: () => /^\d+$/.test(obj),
|
||||
isalpha: () => /^\p{L}+$/u.test(obj),
|
||||
};
|
||||
return m[name];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
get(name: string): any;
|
||||
has(name: string): boolean;
|
||||
set(name: string, value: any): void;
|
||||
del(name: string): void;
|
||||
}
|
||||
|
||||
export class Python {
|
||||
locals: Map<string, any>[] = [];
|
||||
warned = new Set<string>();
|
||||
|
||||
constructor(public scope: Scope) {}
|
||||
|
||||
warnOnce(msg: string) {
|
||||
if (!this.warned.has(msg)) {
|
||||
this.warned.add(msg);
|
||||
console.warn("[pgvn]", msg);
|
||||
}
|
||||
}
|
||||
|
||||
lookup(name: string): any {
|
||||
for (let i = this.locals.length - 1; i >= 0; i--)
|
||||
if (this.locals[i].has(name)) return this.locals[i].get(name);
|
||||
if (this.scope.has(name)) return this.scope.get(name);
|
||||
this.warnOnce(`неизвестное имя ${name}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
eval(e: Expr | null | undefined): any {
|
||||
if (!e) return null;
|
||||
switch (e.k) {
|
||||
case "const":
|
||||
return e.v;
|
||||
case "name":
|
||||
return this.lookup(e.id);
|
||||
case "attr": {
|
||||
const o = this.eval(e.o);
|
||||
if (o === null || o === undefined) {
|
||||
this.warnOnce(`атрибут ${e.a} у None`);
|
||||
return null;
|
||||
}
|
||||
const m = methodOf(o, e.a);
|
||||
if (m) return m;
|
||||
const v = o instanceof PyDict ? o.get(e.a) : o[e.a];
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
case "sub": {
|
||||
const o = this.eval(e.o);
|
||||
const i = this.eval(e.i);
|
||||
if (o instanceof PyDict) return o.has(i) ? o.get(i) : null;
|
||||
if (Array.isArray(o) || typeof o === "string") return o[index(o.length, i)] ?? null;
|
||||
return o?.[i] ?? null;
|
||||
}
|
||||
case "slice": {
|
||||
const o = this.eval(e.o);
|
||||
const lo = e.lo ? this.eval(e.lo) : undefined;
|
||||
const hi = e.hi ? this.eval(e.hi) : undefined;
|
||||
return o.slice(lo, hi);
|
||||
}
|
||||
case "bin":
|
||||
return binop(e.op, this.eval(e.l), this.eval(e.r));
|
||||
case "bool": {
|
||||
let v: any = null;
|
||||
for (const x of e.v) {
|
||||
v = this.eval(x);
|
||||
if (e.op === "and" ? !truthy(v) : truthy(v)) return v;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
case "unary": {
|
||||
const v = this.eval(e.v);
|
||||
if (e.op === "not") return !truthy(v);
|
||||
if (e.op === "-") return -v;
|
||||
if (e.op === "~") return ~v;
|
||||
return +v;
|
||||
}
|
||||
case "cmp": {
|
||||
let left = this.eval(e.l);
|
||||
for (let i = 0; i < e.ops.length; i++) {
|
||||
const right = this.eval(e.r[i]);
|
||||
if (!compare(e.ops[i], left, right)) return false;
|
||||
left = right;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "if":
|
||||
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
|
||||
case "list":
|
||||
case "tuple":
|
||||
return e.v.map((x: Expr) => this.eval(x));
|
||||
case "dict":
|
||||
return new PyDict(e.v.map(([k, v]: [Expr, Expr]) => [this.eval(k), this.eval(v)]));
|
||||
case "fmt":
|
||||
return e.v.map((p: any) => (typeof p === "string" ? p : pyStr(this.eval(p)))).join("");
|
||||
case "comp": {
|
||||
const out: any[] = [];
|
||||
const frame = new Map<string, any>();
|
||||
this.locals.push(frame);
|
||||
try {
|
||||
for (const item of iterate(this.eval(e.it))) {
|
||||
this.bind(e.t, item, frame);
|
||||
if (e.if.every((c: Expr) => truthy(this.eval(c)))) out.push(this.eval(e.e));
|
||||
}
|
||||
} finally {
|
||||
this.locals.pop();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
case "call": {
|
||||
const f = this.eval(e.f);
|
||||
const args = e.a.map((x: Expr) => this.eval(x));
|
||||
const kw: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(e.kw)) kw[k] = this.eval(v as Expr);
|
||||
if (typeof f !== "function") {
|
||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||
return null;
|
||||
}
|
||||
return Object.keys(kw).length ? f(...args, { __kw: kw }) : f(...args);
|
||||
}
|
||||
}
|
||||
this.warnOnce(`неизвестное выражение ${e.k}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
bind(t: Expr, value: any, frame?: Map<string, any>) {
|
||||
switch (t.k) {
|
||||
case "name":
|
||||
if (frame) frame.set(t.id, value);
|
||||
else this.scope.set(t.id, value);
|
||||
return;
|
||||
case "tuple": {
|
||||
const items = [...iterate(value)];
|
||||
t.v.forEach((sub: Expr, i: number) => this.bind(sub, items[i], frame));
|
||||
return;
|
||||
}
|
||||
case "attr": {
|
||||
const o = this.eval(t.o);
|
||||
if (o instanceof PyDict) o.set(t.a, value);
|
||||
else if (o) o[t.a] = value;
|
||||
return;
|
||||
}
|
||||
case "sub": {
|
||||
const o = this.eval(t.o);
|
||||
const i = this.eval(t.i);
|
||||
if (o instanceof PyDict) o.set(i, value);
|
||||
else if (Array.isArray(o)) o[index(o.length, i)] = value;
|
||||
else if (o) o[i] = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.warnOnce(`присваивание в ${t.k}`);
|
||||
}
|
||||
|
||||
exec(stmts: PyStmt[]) {
|
||||
let guard = 0;
|
||||
for (const s of stmts) {
|
||||
switch (s.op) {
|
||||
case "set":
|
||||
this.bind(s.t, this.eval(s.v));
|
||||
break;
|
||||
case "aug":
|
||||
this.bind(s.t, binop(s.o, this.eval(s.t), this.eval(s.v)));
|
||||
break;
|
||||
case "eval":
|
||||
this.eval(s.e);
|
||||
break;
|
||||
case "if":
|
||||
this.exec(truthy(this.eval(s.c)) ? s.then : s.else);
|
||||
break;
|
||||
case "while":
|
||||
while (truthy(this.eval(s.c))) {
|
||||
if (++guard > 100000) throw new Error("бесконечный цикл в python");
|
||||
if (this.loopBody(s.body)) break;
|
||||
}
|
||||
break;
|
||||
case "for":
|
||||
for (const item of [...iterate(this.eval(s.it))]) {
|
||||
this.bind(s.t, item);
|
||||
if (this.loopBody(s.body)) break;
|
||||
}
|
||||
break;
|
||||
case "del":
|
||||
if (s.t.k === "name") this.scope.del(s.t.id);
|
||||
else if (s.t.k === "sub") {
|
||||
const o = this.eval(s.t.o);
|
||||
const i = this.eval(s.t.i);
|
||||
if (o instanceof PyDict) o.map.delete(key(i));
|
||||
else if (Array.isArray(o)) o.splice(index(o.length, i), 1);
|
||||
}
|
||||
break;
|
||||
case "break":
|
||||
throw new BreakSignal();
|
||||
case "continue":
|
||||
throw new ContinueSignal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a loop body; returns true on break. */
|
||||
private loopBody(body: PyStmt[]): boolean {
|
||||
try {
|
||||
this.exec(body);
|
||||
} catch (err) {
|
||||
if (err instanceof BreakSignal) return true;
|
||||
if (err instanceof ContinueSignal) return false;
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function* iterate(v: any): Iterable<any> {
|
||||
if (Array.isArray(v) || typeof v === "string") yield* v;
|
||||
else if (v instanceof PyDict) yield* v.map.keys();
|
||||
else if (v && typeof v[Symbol.iterator] === "function") yield* v;
|
||||
}
|
||||
|
||||
/** JSON (de)serialisation of the store, keeping PyDict instances. */
|
||||
export function encodeValue(v: any): any {
|
||||
return JSON.parse(JSON.stringify(v, (_k, x) => (typeof x === "function" ? undefined : x)));
|
||||
}
|
||||
|
||||
export function decodeValue(v: any): any {
|
||||
if (Array.isArray(v)) return v.map(decodeValue);
|
||||
if (v && typeof v === "object") {
|
||||
if (Array.isArray(v.__dict__))
|
||||
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
|
||||
const out: Record<string, any> = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = decodeValue(x);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
|
||||
// The stage has the game's native resolution and is scaled with a CSS transform.
|
||||
|
||||
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
|
||||
|
||||
const POSITIONS: Record<string, Record<string, number>> = {
|
||||
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
|
||||
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
|
||||
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
||||
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
||||
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
|
||||
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
|
||||
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
|
||||
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
|
||||
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
|
||||
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
|
||||
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
|
||||
};
|
||||
|
||||
export interface ResolvedImage {
|
||||
def: ImageDef;
|
||||
name: string[];
|
||||
}
|
||||
|
||||
export interface RendererHost {
|
||||
url(file: string): string | null;
|
||||
resolveRef(name: string): ImageDef | null;
|
||||
evalCond(e: any): boolean;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
}
|
||||
|
||||
interface Entry extends ShownImage {
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
readonly stage: HTMLDivElement;
|
||||
readonly master: HTMLDivElement;
|
||||
private overlay: HTMLDivElement;
|
||||
private shown = new Map<string, Entry>();
|
||||
private committed: HTMLElement | null = null;
|
||||
fast = false;
|
||||
|
||||
constructor(parent: HTMLElement, public width: number, public height: number,
|
||||
private host: RendererHost) {
|
||||
this.stage = document.createElement("div");
|
||||
this.stage.className = "pg-stage";
|
||||
this.stage.style.width = `${width}px`;
|
||||
this.stage.style.height = `${height}px`;
|
||||
this.master = document.createElement("div");
|
||||
this.master.className = "pg-layer";
|
||||
this.overlay = document.createElement("div");
|
||||
this.overlay.className = "pg-layer pg-transition";
|
||||
this.stage.append(this.master, this.overlay);
|
||||
parent.append(this.stage);
|
||||
}
|
||||
|
||||
fit(container: HTMLElement) {
|
||||
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
|
||||
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
|
||||
}
|
||||
|
||||
// --- scene state ---------------------------------------------------------
|
||||
|
||||
scene() {
|
||||
for (const e of this.shown.values()) e.el.remove();
|
||||
this.shown.clear();
|
||||
}
|
||||
|
||||
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
|
||||
const prev = this.shown.get(tag);
|
||||
const atSpecs = at.length ? at : prev?.at ?? [];
|
||||
const el = this.build(img.def);
|
||||
el.classList.add("pg-image");
|
||||
this.place(el, img.def, atSpecs);
|
||||
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
|
||||
if (prev) {
|
||||
prev.el.replaceWith(el);
|
||||
} else {
|
||||
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
|
||||
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
|
||||
this.master.insertBefore(el, behind ?? later ?? null);
|
||||
}
|
||||
this.shown.set(tag, entry);
|
||||
}
|
||||
|
||||
hide(tag: string) {
|
||||
const e = this.shown.get(tag);
|
||||
if (!e) return;
|
||||
e.el.remove();
|
||||
this.shown.delete(tag);
|
||||
}
|
||||
|
||||
isShown(tag: string) {
|
||||
return this.shown.has(tag);
|
||||
}
|
||||
|
||||
attrsOf(tag: string): string[] | null {
|
||||
return this.shown.get(tag)?.name ?? null;
|
||||
}
|
||||
|
||||
snapshot(): ShownImage[] {
|
||||
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
|
||||
}
|
||||
|
||||
// --- displayables ------------------------------------------------------------
|
||||
|
||||
private build(def: ImageDef, depth = 0): HTMLElement {
|
||||
if (depth > 8) return document.createElement("div");
|
||||
if (def.ref) {
|
||||
const ref = this.host.resolveRef(def.ref);
|
||||
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
|
||||
}
|
||||
if (def.switch) {
|
||||
const hit = def.switch.find(([c]) => this.host.evalCond(c));
|
||||
if (hit) return this.build(hit[1], depth + 1);
|
||||
}
|
||||
if (def.color) {
|
||||
const d = document.createElement("div");
|
||||
d.style.background = def.color;
|
||||
d.style.width = `${this.width}px`;
|
||||
d.style.height = `${this.height}px`;
|
||||
return d;
|
||||
}
|
||||
if (def.composite) {
|
||||
const d = document.createElement("div");
|
||||
d.style.position = "relative";
|
||||
if (def.size) {
|
||||
d.style.width = `${def.size[0]}px`;
|
||||
d.style.height = `${def.size[1]}px`;
|
||||
}
|
||||
for (const layer of def.composite) {
|
||||
const child = this.build(layer.img, depth + 1);
|
||||
child.style.position = "absolute";
|
||||
child.style.left = `${layer.pos[0]}px`;
|
||||
child.style.top = `${layer.pos[1]}px`;
|
||||
d.append(child);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
if (def.movie) {
|
||||
const v = document.createElement("video");
|
||||
const src = this.host.url(def.movie);
|
||||
if (src) v.src = src;
|
||||
v.autoplay = true;
|
||||
v.loop = true;
|
||||
v.muted = true;
|
||||
v.playsInline = true;
|
||||
return v;
|
||||
}
|
||||
if (def.text !== undefined) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pg-show-text";
|
||||
d.textContent = def.text;
|
||||
return d;
|
||||
}
|
||||
if (def.file) {
|
||||
const img = document.createElement("img");
|
||||
img.draggable = false;
|
||||
img.decoding = "async";
|
||||
const src = this.host.url(def.file);
|
||||
if (src) img.src = src;
|
||||
else console.warn("[pgvn] нет файла", def.file);
|
||||
if (def.props?.size) {
|
||||
img.style.width = `${def.props.size[0]}px`;
|
||||
img.style.height = `${def.props.size[1]}px`;
|
||||
}
|
||||
return img;
|
||||
}
|
||||
return document.createElement("div");
|
||||
}
|
||||
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
|
||||
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
|
||||
for (const spec of at) {
|
||||
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
|
||||
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
|
||||
if (spec.props) Object.assign(p, spec.props);
|
||||
}
|
||||
normalize(p);
|
||||
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
|
||||
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
||||
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
||||
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
||||
const ax = anchor(p.xanchor);
|
||||
const ay = anchor(p.yanchor);
|
||||
el.style.transformOrigin = `${ax} ${ay}`;
|
||||
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
||||
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
||||
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
||||
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
|
||||
if (ax.endsWith("px") || ay.endsWith("px"))
|
||||
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
|
||||
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
||||
if (p.filter) el.style.filter = `${p.filter}(1)`;
|
||||
}
|
||||
|
||||
// --- transitions ---------------------------------------------------------------
|
||||
|
||||
/** Remember the current picture as the starting point of the next transition. */
|
||||
commit() {
|
||||
this.committed = this.master.cloneNode(true) as HTMLElement;
|
||||
}
|
||||
|
||||
async transition(t: Transition | null | undefined): Promise<void> {
|
||||
const before = this.committed;
|
||||
this.commit();
|
||||
if (!t || !before || this.fast) return;
|
||||
if (t.t === "punch") return this.punch(t);
|
||||
const old = before;
|
||||
old.classList.add("pg-old");
|
||||
this.overlay.append(old);
|
||||
try {
|
||||
if (t.t === "fade") {
|
||||
const color = document.createElement("div");
|
||||
color.className = "pg-fill";
|
||||
color.style.background = t.color ?? "#000";
|
||||
color.style.opacity = "0";
|
||||
this.overlay.append(color);
|
||||
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
|
||||
old.remove();
|
||||
if (t.hold) await sleep(t.hold);
|
||||
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
|
||||
color.remove();
|
||||
} else if (t.t === "wipe") {
|
||||
const dir = t.dir ?? "right";
|
||||
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
|
||||
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
|
||||
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
|
||||
} else if (t.t === "slide") {
|
||||
const dir = t.dir ?? "left";
|
||||
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
|
||||
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
|
||||
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
|
||||
} else {
|
||||
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
|
||||
}
|
||||
} finally {
|
||||
old.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private async punch(t: Transition) {
|
||||
const axis = t.dir === "h" ? "X" : "Y";
|
||||
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
|
||||
t.d ?? 0.3);
|
||||
}
|
||||
|
||||
preload(def: ImageDef | null) {
|
||||
if (def?.file) {
|
||||
const src = this.host.url(def.file);
|
||||
if (src) new Image().src = src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(p: Record<string, any>) {
|
||||
if (p.align) [p.xalign, p.yalign] = p.align;
|
||||
if (p.pos) [p.xpos, p.ypos] = p.pos;
|
||||
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
|
||||
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
|
||||
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
|
||||
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
|
||||
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
|
||||
p.xanchor = asFloat(p.xanchor);
|
||||
p.yanchor = asFloat(p.yanchor);
|
||||
p.xpos = asFloat(p.xpos);
|
||||
p.ypos = asFloat(p.ypos);
|
||||
}
|
||||
|
||||
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
|
||||
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
|
||||
function asFloat(v: any): number {
|
||||
return typeof v === "number" ? v : 0;
|
||||
}
|
||||
|
||||
export function sleep(seconds: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
|
||||
}
|
||||
|
||||
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
|
||||
a.onfinish = () => resolve();
|
||||
a.oncancel = () => resolve();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Ren'Py text tags → styled segments, plus [variable] interpolation.
|
||||
|
||||
import { pyStr } from "./python";
|
||||
|
||||
export interface Style {
|
||||
b?: boolean;
|
||||
i?: boolean;
|
||||
u?: boolean;
|
||||
s?: boolean;
|
||||
color?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export type Segment =
|
||||
| { kind: "text"; text: string; style: Style }
|
||||
| { kind: "wait"; seconds: number | null } // {w}, {w=1}, {p}
|
||||
| { kind: "fast" };
|
||||
|
||||
export interface ParsedText {
|
||||
segments: Segment[];
|
||||
noWait: boolean; // {nw}
|
||||
length: number; // number of visible characters
|
||||
}
|
||||
|
||||
export function interpolate(text: string, lookup: (expr: string) => any): string {
|
||||
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
|
||||
if (m === "[[") return "[";
|
||||
let src = expr!.trim();
|
||||
let flags = "";
|
||||
const bang = src.lastIndexOf("!");
|
||||
if (bang > 0 && /^[a-z]+$/.test(src.slice(bang + 1))) {
|
||||
flags = src.slice(bang + 1);
|
||||
src = src.slice(0, bang);
|
||||
}
|
||||
let v = pyStr(lookup(src));
|
||||
if (flags.includes("u")) v = v.toUpperCase();
|
||||
if (flags.includes("l")) v = v.toLowerCase();
|
||||
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1);
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseText(text: string): ParsedText {
|
||||
const segments: Segment[] = [];
|
||||
const stack: Style[] = [{}];
|
||||
let noWait = false;
|
||||
let length = 0;
|
||||
const push = (t: string) => {
|
||||
if (!t) return;
|
||||
length += [...t].length;
|
||||
segments.push({ kind: "text", text: t, style: { ...stack[stack.length - 1] } });
|
||||
};
|
||||
let i = 0;
|
||||
let buf = "";
|
||||
while (i < text.length) {
|
||||
const c = text[i];
|
||||
if (c === "{") {
|
||||
if (text[i + 1] === "{") {
|
||||
buf += "{";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const end = text.indexOf("}", i);
|
||||
if (end < 0) {
|
||||
buf += text.slice(i);
|
||||
break;
|
||||
}
|
||||
push(buf);
|
||||
buf = "";
|
||||
const tag = text.slice(i + 1, end);
|
||||
i = end + 1;
|
||||
const [name, value] = splitTag(tag);
|
||||
const top = stack[stack.length - 1];
|
||||
if (name.startsWith("/")) {
|
||||
if (stack.length > 1) stack.pop();
|
||||
continue;
|
||||
}
|
||||
switch (name) {
|
||||
case "b":
|
||||
case "i":
|
||||
case "u":
|
||||
case "s":
|
||||
stack.push({ ...top, [name]: true });
|
||||
break;
|
||||
case "color":
|
||||
stack.push({ ...top, color: value });
|
||||
break;
|
||||
case "size":
|
||||
stack.push({ ...top, size: value });
|
||||
break;
|
||||
case "w":
|
||||
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
|
||||
break;
|
||||
case "p":
|
||||
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
|
||||
push("\n");
|
||||
break;
|
||||
case "nw":
|
||||
noWait = true;
|
||||
break;
|
||||
case "fast":
|
||||
segments.push({ kind: "fast" });
|
||||
break;
|
||||
case "space":
|
||||
push(" ");
|
||||
break;
|
||||
case "vspace":
|
||||
push("\n");
|
||||
break;
|
||||
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
|
||||
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
|
||||
stack.push({ ...top }); // styling we ignore but must still balance
|
||||
break;
|
||||
default:
|
||||
// {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
buf += c;
|
||||
i++;
|
||||
}
|
||||
push(buf);
|
||||
return { segments, noWait, length };
|
||||
}
|
||||
|
||||
function splitTag(tag: string): [string, string] {
|
||||
const eq = tag.indexOf("=");
|
||||
return eq < 0 ? [tag.trim(), ""] : [tag.slice(0, eq).trim(), tag.slice(eq + 1).trim()];
|
||||
}
|
||||
|
||||
/** Render the first `visible` characters of parsed text into `el`. */
|
||||
export function renderText(el: HTMLElement, parsed: ParsedText, visible: number, baseSize: number) {
|
||||
el.textContent = "";
|
||||
let left = visible;
|
||||
for (const seg of parsed.segments) {
|
||||
if (seg.kind !== "text") continue;
|
||||
if (left <= 0) break;
|
||||
const chars = [...seg.text];
|
||||
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
|
||||
left -= chars.length;
|
||||
const st = seg.style;
|
||||
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
|
||||
el.append(shown);
|
||||
continue;
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
span.textContent = shown;
|
||||
if (st.b) span.style.fontWeight = "bold";
|
||||
if (st.i) span.style.fontStyle = "italic";
|
||||
if (st.u || st.s) span.style.textDecoration = [st.u && "underline", st.s && "line-through"].filter(Boolean).join(" ");
|
||||
if (st.color) span.style.color = st.color;
|
||||
if (st.size) span.style.fontSize = sizeValue(st.size, baseSize);
|
||||
el.append(span);
|
||||
}
|
||||
}
|
||||
|
||||
function sizeValue(v: string, base: number): string {
|
||||
if (v.startsWith("+") || v.startsWith("-")) return `${base + Number(v)}px`;
|
||||
if (v.startsWith("*")) return `${base * Number(v.slice(1))}px`;
|
||||
return `${Number(v)}px`;
|
||||
}
|
||||
|
||||
/** Character offsets at which {w}/{p} pauses happen, with their durations. */
|
||||
export function waitPoints(parsed: ParsedText): { at: number; seconds: number | null }[] {
|
||||
const out: { at: number; seconds: number | null }[] = [];
|
||||
let pos = 0;
|
||||
for (const seg of parsed.segments) {
|
||||
if (seg.kind === "text") pos += [...seg.text].length;
|
||||
else if (seg.kind === "wait") out.push({ at: pos, seconds: seg.seconds });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fastPoint(parsed: ParsedText): number {
|
||||
let pos = 0;
|
||||
let fast = 0;
|
||||
for (const seg of parsed.segments) {
|
||||
if (seg.kind === "text") pos += [...seg.text].length;
|
||||
else if (seg.kind === "fast") fast = pos;
|
||||
}
|
||||
return fast;
|
||||
}
|
||||
|
||||
export function plainText(parsed: ParsedText): string {
|
||||
return parsed.segments.map((s) => (s.kind === "text" ? s.text : "")).join("");
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
|
||||
|
||||
export type Expr = { k: string; [key: string]: any };
|
||||
export type PyStmt = { op: string; [key: string]: any };
|
||||
|
||||
export interface Transition {
|
||||
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
|
||||
d?: number;
|
||||
out?: number;
|
||||
hold?: number;
|
||||
in?: number;
|
||||
color?: string;
|
||||
dir?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ImageDef {
|
||||
file?: string;
|
||||
color?: string;
|
||||
ref?: string;
|
||||
movie?: string;
|
||||
expr?: Expr;
|
||||
switch?: [Expr, ImageDef][];
|
||||
composite?: { pos: [number, number]; img: ImageDef }[];
|
||||
size?: [number, number] | null;
|
||||
text?: string;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AtSpec {
|
||||
pos?: string;
|
||||
name?: string;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CharacterDef {
|
||||
name?: string | null;
|
||||
nameExpr?: Expr;
|
||||
color?: string;
|
||||
whatColor?: string;
|
||||
whatPrefix?: string;
|
||||
whatSuffix?: string;
|
||||
whoPrefix?: string;
|
||||
whoSuffix?: string;
|
||||
image?: string;
|
||||
nvl?: boolean;
|
||||
whatItalic?: boolean;
|
||||
whatBold?: boolean;
|
||||
}
|
||||
|
||||
export type Node = { op: string; [key: string]: any };
|
||||
|
||||
export interface Label {
|
||||
name: string;
|
||||
body: Node[];
|
||||
}
|
||||
|
||||
export interface ScriptModule {
|
||||
file: string;
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
format: string;
|
||||
title: string | null;
|
||||
resolution: [number, number];
|
||||
entry: string;
|
||||
scripts: string[];
|
||||
characters: Record<string, CharacterDef>;
|
||||
images: Record<string, ImageDef>;
|
||||
transforms: Record<string, Record<string, any>>;
|
||||
transitions: Record<string, Transition | null>;
|
||||
audio: Record<string, string>;
|
||||
files: Record<string, string>;
|
||||
defaults: [string, Expr][];
|
||||
init: PyStmt[];
|
||||
ui: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
format: string;
|
||||
files: Record<string, { hash: string; size: number }>;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
label: string;
|
||||
path: number[];
|
||||
}
|
||||
|
||||
export interface ShownImage {
|
||||
tag: string;
|
||||
name: string[];
|
||||
at: AtSpec[];
|
||||
zorder: number;
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
export interface SaveData {
|
||||
v: 1;
|
||||
cur: Frame;
|
||||
stack: Frame[];
|
||||
store: any;
|
||||
scene: ShownImage[];
|
||||
music: { file: string; loop: boolean } | null;
|
||||
window: boolean;
|
||||
menuSeen?: string[];
|
||||
text?: string;
|
||||
}
|
||||
@@ -0,0 +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)); }
|
||||
}
|
||||
Reference in new issue
Block a user