feat: unlock in-game galleries per owned version
CI / frontend (push) Failing after 22s
CI / backend (push) Failing after 42s

This commit is contained in:
mrleo1nid committed 2026-10-27 15:45:22 +03:00
1 parent e14184435c
commit 19924e1c5a
21 files changed
+977 -395

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
"""Gallery support for converted games; never changes scripts or story progress."""
from app.translation import file_json
def available(manifest: dict | None) -> bool:
if not manifest or "game.json" not in manifest.get("files", {}):
return False
game = file_json(manifest, "game.json")
if game.get("engine") not in (None, "renpy") or game.get("runtime"):
return False
screens = game.get("screens", {})
if game.get("build", {}).get("name") == "MIST" and "rewatchgirlselectscreen" in screens:
return True
def uses_gallery(value):
if isinstance(value, dict):
if value.get("k") == "call" and value.get("f", {}).get("k") == "name" and value["f"].get("id") in {"Gallery", "Replay"}:
return True
return any(uses_gallery(item) for item in value.values())
return isinstance(value, list) and any(uses_gallery(item) for item in value)
return uses_gallery(game.get("init", [])) or uses_gallery(screens) or uses_gallery(game.get("defaults", []))
+1
View File
@@ -82,6 +82,7 @@ class GameVersion(Base):
# draft: visible to the owner and reviewers only
visibility: Mapped[str] = mapped_column(String(16), default="published")
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod
gallery_unlocked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
+3 -1
View File
@@ -8,6 +8,7 @@ from starlette.concurrency import run_in_threadpool
from app.blobs import blob_path, put_bytes
from app.deps import DB, CurrentUser, has_perm, require
from app.gallery import available as gallery_available
from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
from app.translation import version_languages
@@ -114,7 +115,8 @@ async def game_detail(game_id: int, user: Player, db: DB) -> dict:
)
).all()
return {**GameOut.model_validate(game).model_dump(),
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)}
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest),
"gallery_available": gallery_available(v.manifest)}
for v in versions]}
+22 -3
View File
@@ -8,10 +8,12 @@ from starlette.requests import ClientDisconnect
from app import translation
from app.deps import DB, CurrentUser, has_perm, require
from app.gallery import available as gallery_available
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game
from app.schemas import (
ForkIn,
GallerySettingsIn,
HistoryOut,
PublishIn,
SaveDetailOut,
@@ -108,8 +110,10 @@ async def upload_version(
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id)
async def version_detail(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
return {**VersionDetailOut.model_validate(v).model_dump(),
"gallery_available": await run_in_threadpool(gallery_available, v.manifest)}
@router.get("/versions/{version_id}/manifest")
@@ -117,7 +121,7 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return {**v.manifest, "revision": v.revision_id, "versionId": v.id}
return {**v.manifest, "revision": v.revision_id, "versionId": v.id, "galleryUnlocked": v.gallery_unlocked}
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
@@ -139,6 +143,7 @@ async def fork_version(version_id: int, body: ForkIn,
status="ready",
visibility="draft",
kind=body.kind,
gallery_unlocked=parent.gallery_unlocked,
manifest=parent.manifest,
base_revision_id=parent.revision_id,
report=parent.report,
@@ -243,6 +248,20 @@ async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db
return v
@router.patch("/versions/{version_id}/gallery", response_model=VersionOut)
async def update_gallery(version_id: int, body: GallerySettingsIn, user: Player, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if v.owner_id != user.id:
raise HTTPException(403, "Открыть галерею можно только в своей версии")
if v.status != "ready":
raise HTTPException(409, "Версия ещё не готова")
if body.gallery_unlocked and not await run_in_threadpool(gallery_available, v.manifest):
raise HTTPException(422, "Галерея этой игры пока не поддерживается")
v.gallery_unlocked = body.gallery_unlocked
await db.commit()
return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
+6
View File
@@ -68,6 +68,8 @@ class OwnerOut(ORM):
class VersionOut(ORM):
gallery_unlocked: bool = False
gallery_available: bool = False
revision_id: str | None = None
base_revision_id: str | None = None
id: int
@@ -141,6 +143,10 @@ class PublishIn(BaseModel):
make_main: bool = False
class GallerySettingsIn(BaseModel):
gallery_unlocked: bool
class HistoryOut(ORM):
id: int
note: str
@@ -0,0 +1,15 @@
"""Opt-in unlocked in-game galleries, scoped to one version."""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = depends_on = None
def upgrade():
op.add_column("game_versions", sa.Column("gallery_unlocked", sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade():
op.drop_column("game_versions", "gallery_unlocked")
+82
View File
@@ -0,0 +1,82 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app import gallery
from app.routers import versions
from app.schemas import ForkIn, GallerySettingsIn
@pytest.mark.parametrize(("game", "expected"), [
({"build": {"name": "MIST"}, "screens": {"rewatchgirlselectscreen": {}}}, True),
({"init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, True),
({"screens": {"replay": {"k": "call", "f": {"k": "name", "id": "Replay"}}}}, True),
({"init": [], "screens": {}}, False),
({"engine": "naninovel", "init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, False),
])
def test_supported_gallery_detection(monkeypatch, game, expected):
monkeypatch.setattr(gallery, "file_json", lambda *_: game)
assert gallery.available({"files": {"game.json": {}}}) is expected
assert not gallery.available(None)
def test_only_owner_can_toggle_gallery_and_scripts_are_not_modified(monkeypatch):
manifest = {"files": {"game.json": {"hash": "unchanged"}}}
v = SimpleNamespace(owner_id=7, status="ready", manifest=manifest, gallery_unlocked=False)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
monkeypatch.setattr(versions, "gallery_available", lambda _: True)
db = SimpleNamespace(commit=AsyncMock())
async def scenario():
body = GallerySettingsIn(gallery_unlocked=True)
with pytest.raises(HTTPException) as error:
await versions.update_gallery(1, body, SimpleNamespace(id=8), db)
assert error.value.status_code == 403
assert not v.gallery_unlocked
result = await versions.update_gallery(1, body, SimpleNamespace(id=7), db)
assert result.gallery_unlocked
assert result.manifest is manifest
await versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=False), SimpleNamespace(id=7), db)
assert not v.gallery_unlocked
assert db.commit.await_count == 2
asyncio.run(scenario())
@pytest.mark.parametrize(("state", "supported", "code"), [("converting", True, 409), ("ready", False, 422)])
def test_gallery_requires_ready_supported_game(monkeypatch, state, supported, code):
v = SimpleNamespace(owner_id=7, status=state, manifest={}, gallery_unlocked=False)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
monkeypatch.setattr(versions, "gallery_available", lambda _: supported)
db = SimpleNamespace(commit=AsyncMock())
with pytest.raises(HTTPException) as error:
asyncio.run(versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=True), SimpleNamespace(id=7), db))
assert error.value.status_code == code
db.commit.assert_not_awaited()
def test_player_manifest_uses_version_flag_instead_of_shared_manifest(monkeypatch):
shared = {"files": {"game.json": {"hash": "unchanged"}}}
v = SimpleNamespace(id=1, status="ready", manifest=shared, revision_id="revision", gallery_unlocked=True)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
result = asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))
assert result["galleryUnlocked"] is True
assert "galleryUnlocked" not in shared
v.gallery_unlocked = False
assert asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))["galleryUnlocked"] is False
def test_fork_inherits_gallery_setting_but_can_change_it_independently(monkeypatch):
parent = SimpleNamespace(id=1, game_id=2, status="ready", gallery_unlocked=True,
manifest={"files": {}}, revision_id="base", report={}, size_bytes=1)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=parent))
monkeypatch.setattr(versions, "has_perm", lambda *_: True)
added = []
db = SimpleNamespace(add=added.append, commit=AsyncMock())
fork = asyncio.run(versions.fork_version(1, ForkIn(name="My fork"), SimpleNamespace(id=7), db))
assert fork.gallery_unlocked is True
fork.gallery_unlocked = False
assert parent.gallery_unlocked is True
assert fork.manifest is parent.manifest
+28
View File
@@ -0,0 +1,28 @@
# Открытая галерея версии
В списке версий владелец готовой игры может включить галочку
«Открыть всю галерею в этой версии». Рядом появляется пометка
«Галерея открыта» и кнопка «Открыть галерею». Пометку и кнопку видят все,
кому доступна эта версия. Галерея также доступна из главного меню плеера
и игрового меню. Отключение галочки возвращает обычные ограничения игры.
Поддерживаются стандартные `Gallery` и `Replay` Ren’Py, а также собственная
галерея MIST 1.0.3. Для других нестандартных галерей нужен отдельный адаптер;
если поддержка не обнаружена, галочка не показывается.
Прямое открытие галереи запускает отдельный просмотр: сюжетные переменные,
просмотренные изображения и persistent-данные основного прохождения
восстанавливаются при закрытии. Запись и загрузка сохранений во время такого
просмотра недоступны. В MIST при завершении сцены снова открывается выбор
галереи, а кнопка возврата из выбора закрывает просмотр.
Настройка хранится в `game_versions.gallery_unlocked` и применяется при
загрузке манифеста; файлы и ревизия сценариев не меняются. Форк наследует
настройку родительской версии, после чего владелец может её отключить.
Публикация перевода в главную версию переносит переводы, а не эту настройку.
Для существующей установки требуется обычное обновление схемы Alembic
до миграции `0007`. Переконвертация игр не нужна. API настройки:
`PATCH /api/versions/{id}/gallery`, тело `{"gallery_unlocked": true}`.
Менять её может только владелец версии, в том числе если другие пользователи
имеют административные права на управление версиями.
+2
View File
@@ -20,6 +20,8 @@ export interface Me extends User {
}
export interface Version {
gallery_unlocked?: boolean;
gallery_available?: boolean;
revision_id: string | null;
base_revision_id: string | null;
id: number;
+16
View File
@@ -167,6 +167,9 @@ export function GamePage() {
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
isOwner={!!me && v.owner?.id === me.id}
onGallery={(enabled) => run(() => api.patch(`/api/versions/${v.id}/gallery`, { gallery_unlocked: enabled }))}
onOpenGallery={() => navigate(`/play/${v.id}?gallery=1`)}
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
@@ -246,12 +249,14 @@ export function GamePage() {
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
function VersionRow(props: {
isOwner: boolean; onGallery: (enabled: boolean) => Promise<void>; onOpenGallery: () => void;
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: boolean;
onPlay: () => void; onPlayLang: (code: string) => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) {
const { v } = props;
const [pendingGallery, setPendingGallery] = useState<boolean | null>(null);
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => {
if (report !== undefined) setReport(undefined);
@@ -268,6 +273,7 @@ function VersionRow(props: {
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
{v.gallery_unlocked && <span className="badge badge-warn">Галерея открыта</span>}
<StatusBadge status={v.status} />
</div>
<div className="muted small version-meta">
@@ -277,6 +283,7 @@ function VersionRow(props: {
</div>
<div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && v.gallery_unlocked && <button className="btn btn-sm" onClick={props.onOpenGallery}>Открыть галерею</button>}
{v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
@@ -305,6 +312,15 @@ function VersionRow(props: {
)}
</div>
</div>
{v.status === "ready" && props.isOwner && v.gallery_available && (
<label className="version-gallery-setting small">
<input type="checkbox" checked={pendingGallery ?? !!v.gallery_unlocked} disabled={pendingGallery !== null} onChange={(event) => {
setPendingGallery(event.target.checked);
void props.onGallery(event.target.checked).finally(() => setPendingGallery(null));
}} />
Открыть всю галерею в этой версии
</label>
)}
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
{report !== undefined && <Report report={report} />}
</div>
+116 -8
View File
@@ -2,9 +2,10 @@
import { AudioSystem } from "./audio";
import { cutscene } from "./cutscene";
import { galleryScreens, isMistGallery, replayEntries } from "./gallery-access";
import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { BlockingCall, JumpSignal, PyDict, PyInstance, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer";
import { cpsPoints, fastPoint, interpolate, parseText, plainText, renderText, TEXT_DISPLAYABLE, TEXT_PARAGRAPH, TEXT_TAG, TEXT_TEXT, textHooks, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
@@ -77,6 +78,12 @@ export class Engine {
private store: Record<string, any> = {};
private builtins: Record<string, any> = {};
private persistent: Record<string, any>;
private gallerySession = false;
private galleryStash: SaveData | null = null;
private galleryPersistent: Record<string, any> | null = null;
private gallerySeen: Set<string> | null = null;
private galleryHistory: { name: string; text: string }[] | null = null;
private galleryRollback: { snap: SaveData; history: { name: string; text: string }[] }[] | null = null;
private py: Python;
private renderer: Renderer;
private audio: AudioSystem;
@@ -177,7 +184,7 @@ export class Engine {
del: (n) => void delete this.store[n],
};
this.py = new Python(scope);
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
this.screens = new ScreenManager(this.screenHost(), galleryScreens(game, !!manifest.galleryUnlocked), game.styles ?? {});
this.py.blockingNames = new Set([
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
@@ -402,11 +409,14 @@ export class Engine {
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
const save = () => saveJSON(this.persistentKey, encodeValue(data));
return new Proxy(data, {
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined
: k === "_clear" ? () => { for (const key of Object.keys(t)) delete t[key]; save(); return null; } : null),
get: (t, k: string) => {
const current = this.galleryPersistent ?? t;
return k in current ? current[k] : k === "toJSON" ? undefined
: k === "_clear" ? () => { for (const key of Object.keys(current)) delete current[key]; if (!this.gallerySession) save(); return null; } : null;
},
set: (t, k: string, v) => {
t[k] = v;
save();
(this.galleryPersistent ?? t)[k] = v;
if (!this.gallerySession) save();
return true;
},
});
@@ -1176,6 +1186,7 @@ export class Engine {
private screenHost(): ScreenHost {
const engine = this;
return {
galleryUnlocked: !!this.manifest.galleryUnlocked,
error: (message) => this.fail(new Error(message)),
get audio() { return engine.audio; },
setMixer: (name, value) => this.setMixerValue(name, value),
@@ -1188,10 +1199,14 @@ export class Engine {
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
stop: (channel) => this.audio.stop(channel, 0),
notify: (message) => this.notify(message),
jump: (label) => this.restart(() => this.guardedJump(label)),
jump: (label) => {
if (this.gallerySession && isMistGallery(this.game) && label === "myroomcontrol" && !truthy(this.store.replaywatch)) {
this.closeUnlockedGallery();
} else this.restart(() => this.guardedJump(label));
},
call: (label) => this.restart(() => this.call(label)),
quit: () => this.onExit(),
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
mainMenu: () => this.gallerySession ? this.closeUnlockedGallery() : this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
openMenu: () => this.openGameMenu(),
screensOn: (layer) => this.renderer.screensOn(layer),
rollback: () => this.rollback(),
@@ -1464,6 +1479,10 @@ export class Engine {
}
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
if (this.gallerySession && isMistGallery(this.game) && target === "myroomcontrol") {
if (truthy(this.store.replaywatch)) target = "rewatchscene";
else { this.closeUnlockedGallery(); return; }
}
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
if (target === "__main_menu__" || target === "__quit__") {
throw new Restart(target);
@@ -2223,6 +2242,7 @@ export class Engine {
}
private async save(slot: string, silent = false) {
if (this.gallerySession) return;
if (!this.running || !this.cur.label) return;
const data = this.snapshotState();
const title = (data.text ?? "").slice(0, 120) || this.cur.label;
@@ -2235,6 +2255,7 @@ export class Engine {
}
private async load(slot: string) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы загрузить сохранение"); return; }
const data = await this.saves.get(slot);
if (!data) {
this.notify("Сохранение не найдено");
@@ -2287,6 +2308,88 @@ export class Engine {
this.history = [];
}
/** Open an isolated gallery session. Unlocks never turn into story progress or saves. */
openUnlockedGallery() {
if (!this.manifest.galleryUnlocked || this.gallerySession) return;
this.galleryStash = this.running && this.cur.label ? this.snapshotState() : null;
this.galleryPersistent = decodeValue(encodeValue(this.persistent));
this.gallerySeen = this.seenImages;
this.seenImages = new Set(this.seenImages);
this.galleryHistory = this.history;
this.galleryRollback = this.rollbackLog;
this.history = [];
this.rollbackLog = [];
this.gallerySession = true;
this.gen++;
this.wakeAll();
this.onAdvance = null;
this.skipTick = null;
this.running = false;
this.setSkip(false);
this.audio.stopAll();
this.renderer.reset();
this.newStore();
this.stack = [];
this.cur = { label: "", path: [0] };
this.textbox.classList.add("pg-hidden");
this.menuEl.classList.add("pg-hidden");
this.clearNvl();
if (isMistGallery(this.game) && this.labels.has("rewatchscene")) {
this.restart(() => this.guardedJump("rewatchscene"));
} else this.showUnlockedGalleryIndex();
}
private closeUnlockedGallery() {
const stash = this.galleryStash;
const history = this.galleryHistory ?? [];
const rollback = this.galleryRollback ?? [];
this.galleryHistory = null;
this.galleryRollback = null;
this.gallerySession = false;
this.galleryPersistent = null;
this.galleryStash = null;
if (this.gallerySeen) this.seenImages = this.gallerySeen;
this.gallerySeen = null;
this.replayStash = null;
this.screens.clear();
if (stash) this.restart(() => {
this.applySave(stash);
this.history = history;
this.rollbackLog = rollback;
});
else {
this.stack = [];
this.cur = { label: "", path: [0] };
this.showMainMenu();
}
}
private showUnlockedGalleryIndex() {
this.running = false;
this.openScreen("Галерея открыта", (body) => {
body.classList.add("pg-gallery-list");
let count = 0;
const add = (name: string, action: any) => {
if (!action) return;
count++;
const button = el("button", "", name);
button.onclick = () => this.screens.run(action, null);
body.append(button);
};
for (const [name, collection] of Object.entries(this.store)) {
if (!(collection instanceof PyInstance) || collection.cls.name !== "Gallery" || !(collection.attrs.buttons instanceof PyDict)) continue;
body.append(el("h3", "", name));
for (const key of collection.attrs.buttons.map.keys()) {
add(pyStr(key), this.py.attrOf(collection, "Action")(key));
}
}
for (const entry of replayEntries(this.game.screens)) {
if (this.labels.has(entry.label) || this.anchors.has(entry.label)) add(entry.label, this.safeEval(entry.expr));
}
if (!count) body.append(el("p", "", "В этой игре не найдены доступные сцены галереи."));
}, () => this.closeUnlockedGallery());
}
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
private startReplay(label: string, scope: Record<string, any> | null) {
if (!this.labels.has(label)) {
@@ -2545,6 +2648,7 @@ export class Engine {
}
private showMainMenu() {
if (this.gallerySession) { this.showUnlockedGalleryIndex(); return; }
this.placeScreen(false);
this.running = false;
this.gen++;
@@ -2567,6 +2671,7 @@ export class Engine {
return b;
};
btn("Начать", () => this.newGame());
if (this.manifest.galleryUnlocked) btn("Галерея открыта", () => this.openUnlockedGallery());
const cont = btn("Продолжить", () => void this.load("auto"));
cont.style.display = "none";
this.saves.list().then((list) => {
@@ -2610,6 +2715,8 @@ export class Engine {
list.append(b);
};
add("Вернуться", () => this.closeScreen());
if (this.gallerySession) add("Закрыть галерею", () => this.closeUnlockedGallery());
else if (this.manifest.galleryUnlocked) add("Открыть галерею", () => this.openUnlockedGallery());
add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
@@ -2640,6 +2747,7 @@ export class Engine {
}
private openSaves(mode: "save" | "load", fromMain = false) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы открыть сохранения"); return; }
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
const grid = el("div", "pg-slots");
body.append(grid);
+34
View File
@@ -0,0 +1,34 @@
import type { GameData, Expr } from "./types";
export const isMistGallery = (game: GameData) => game.build?.name === "MIST" && !!game.screens?.rewatchgirlselectscreen;
/** Only the gallery's view of progress changes. The underlying quest/scene objects stay intact. */
export function galleryScreens(game: GameData, unlocked: boolean) {
const screens = game.screens ?? {};
if (!unlocked || !isMistGallery(game)) return screens;
const names = new Set(["rewatchgirlselectscreen", "galleryheader", "robgallery", "miagallery", "lilgallery", "norgallery", "groupgallery"]);
const flags = new Set(["rscene02watched", ...Array.from({ length: 7 }, (_, i) => `groupscene0${i + 1}watched`)]);
const visit = (value: any): any => {
if (Array.isArray(value)) return value.map(visit);
if (!value || typeof value !== "object") return value;
if (value.k === "name" && flags.has(value.id)) return { k: "const", v: true };
if (value.k === "attr" && ["watched", "is_done"].includes(value.a)) return { k: "const", v: true };
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
};
return Object.fromEntries(Object.entries(screens).map(([name, screen]) => [name, names.has(name) ? visit(screen) : screen]));
}
/** Standard Replay actions can be offered directly, even when their original UI is locked. */
export function replayEntries(screens: GameData["screens"]): { label: string; expr: Expr }[] {
const found = new Map<string, Expr>();
const visit = (value: any) => {
if (!value || typeof value !== "object") return;
if (value.k === "call" && value.f?.k === "name" && value.f.id === "Replay") {
const label = value.a?.[0] ?? value.kw?.label;
if (label?.k === "const" && typeof label.v === "string") found.set(label.v, value);
}
for (const item of Object.values(value)) visit(item);
};
visit(screens);
return [...found].map(([label, expr]) => ({ label, expr }));
}
+2 -1
View File
@@ -6,6 +6,7 @@ interface Picture { displayables: any[]; conditions: Condition[] }
interface Button { pictures: Picture[]; conditions: Condition[] }
export function galleryClass(host: {
unlocked?(): boolean;
seen(name: string): boolean;
evaluate(source: string): any;
show(pictures: any[][], transition: any): void;
@@ -14,7 +15,7 @@ export function galleryClass(host: {
const gallery = new PyClass("Gallery", [OBJECT]);
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) =>
gallery.attrs.set(name, nativeMethod(fn));
const conditions = (items: Condition[]) => items.every((c) => c.kind === "seen"
const conditions = (items: Condition[]) => host.unlocked?.() || items.every((c) => c.kind === "seen"
? c.images.every((name) => host.seen(name)) : truthy(host.evaluate(c.source)));
const get = (self: PyInstance, name: string): Button => {
const buttons = self.attrs.buttons as PyDict;
+2 -1
View File
@@ -56,7 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
(code) => loadTranslator(game, manifest, code),
gameId === null ? `pgvn:audio:version:${versionId}` : `pgvn:audio:game:${gameId}`);
const at = url.get("at");
if (at) engine.startAt(at);
if (url.get("gallery") === "1" && manifest.galleryUnlocked) engine.openUnlockedGallery();
else if (at) engine.startAt(at);
else engine.start();
return engine;
}
+378 -378
View File
@@ -1,378 +1,378 @@
.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-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-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.35em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.15em;
padding: 0.1em 0.3em;
border-radius: 99em;
background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px);
opacity: 0.55;
transition: opacity 0.2s;
}
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button {
background: none;
border: 0;
border-radius: 99em;
color: rgba(255, 255, 255, 0.8);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.25em 0.75em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid 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.88) center / cover;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
animation: pg-fade 0.15s ease-out;
}
@keyframes pg-fade { from { opacity: 0; } }
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; }
.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; }
.pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.5em;
padding: 0.5em 1.1em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
outline: 3px solid var(--pg-accent);
outline-offset: 2px;
}
.pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; }
.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; }
.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); }
.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; }
.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; }
.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; }
.pg-slot b { color: var(--pg-accent); font-weight: 600; }
.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-settings-screen {
--pg-accent: #ff9f43;
--pg-font: 18px;
z-index: 25;
padding: clamp(16px, 4%, 40px);
color: #edf0f7;
font: 16px/1.5 "Segoe UI", system-ui, sans-serif;
background: rgba(11, 14, 23, 0.96);
container-type: inline-size;
color-scheme: dark;
}
.pg-settings-screen .pg-screen-head,
.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; }
.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; }
.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; }
.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; }
.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; }
.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; }
.pg-settings-card:first-child { grid-row: span 2; }
.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; }
.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.pg-setting:last-child { margin-bottom: 0; }
.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pg-setting input[type="range"] {
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
cursor: pointer;
accent-color: var(--pg-accent);
}
.pg-setting input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 99px;
background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress));
}
.pg-setting input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border: 3px solid #191e2a;
border-radius: 50%;
background: var(--pg-accent);
box-shadow: 0 0 0 1px var(--pg-accent);
}
.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; }
.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); }
.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); }
.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; }
.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; }
.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; }
@container (max-width: 620px) {
.pg-settings { grid-template-columns: minmax(0, 1fr); }
.pg-settings-card:first-child { grid-row: auto; }
.pg-settings-card { padding: 18px; }
}
.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.55em;
width: min(26em, 100%);
padding: 2em;
background: rgba(8, 10, 20, 0.72);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; }
.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; }
.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
/* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn {
position: absolute;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
z-index: 20;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
line-height: 1;
color: #fff;
background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
}
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; }
}
/* in-player confirmation */
.pg-dialog-back {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif;
}
.pg-dialog {
width: min(420px, 100%);
padding: 22px;
color: #eceef5;
background: #171b25;
border: 1px solid #2a3042;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px;
}
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; }
.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); }
.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; }
.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; }
.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; }
.pg-sviewport { scrollbar-width: thin; }
/* --- renpy.input / renpy.display_menu ----------------------------------------------- */
.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; }
.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; }
.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; }
/* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */
.pg-fx { display: inline-block; white-space: pre; }
/* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
.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-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-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.35em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.15em;
padding: 0.1em 0.3em;
border-radius: 99em;
background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px);
opacity: 0.55;
transition: opacity 0.2s;
}
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button {
background: none;
border: 0;
border-radius: 99em;
color: rgba(255, 255, 255, 0.8);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.25em 0.75em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid 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.88) center / cover;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
animation: pg-fade 0.15s ease-out;
}
@keyframes pg-fade { from { opacity: 0; } }
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; }
.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; }
.pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.5em;
padding: 0.5em 1.1em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
outline: 3px solid var(--pg-accent);
outline-offset: 2px;
}
.pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; }
.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; }
.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); }
.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; }
.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; }
.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; }
.pg-slot b { color: var(--pg-accent); font-weight: 600; }
.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-settings-screen {
--pg-accent: #ff9f43;
--pg-font: 18px;
z-index: 25;
padding: clamp(16px, 4%, 40px);
color: #edf0f7;
font: 16px/1.5 "Segoe UI", system-ui, sans-serif;
background: rgba(11, 14, 23, 0.96);
container-type: inline-size;
color-scheme: dark;
}
.pg-settings-screen .pg-screen-head,
.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; }
.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; }
.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; }
.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; }
.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; }
.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; }
.pg-settings-card:first-child { grid-row: span 2; }
.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; }
.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.pg-setting:last-child { margin-bottom: 0; }
.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pg-setting input[type="range"] {
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
cursor: pointer;
accent-color: var(--pg-accent);
}
.pg-setting input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 99px;
background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress));
}
.pg-setting input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border: 3px solid #191e2a;
border-radius: 50%;
background: var(--pg-accent);
box-shadow: 0 0 0 1px var(--pg-accent);
}
.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; }
.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); }
.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); }
.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; }
.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; }
.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; }
@container (max-width: 620px) {
.pg-settings { grid-template-columns: minmax(0, 1fr); }
.pg-settings-card:first-child { grid-row: auto; }
.pg-settings-card { padding: 18px; }
}
.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.55em;
width: min(26em, 100%);
padding: 2em;
background: rgba(8, 10, 20, 0.72);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; }
.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; }
.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
/* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn {
position: absolute;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
z-index: 20;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
line-height: 1;
color: #fff;
background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
}
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; }
}
/* in-player confirmation */
.pg-dialog-back {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif;
}
.pg-dialog {
width: min(420px, 100%);
padding: 22px;
color: #eceef5;
background: #171b25;
border: 1px solid #2a3042;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px;
}
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; }
.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); }
.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; }
.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; }
.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; }
.pg-gallery-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; align-content: start; }
.pg-gallery-list h3, .pg-gallery-list p { grid-column: 1 / -1; }
.pg-sviewport { scrollbar-width: thin; }
/* --- renpy.input / renpy.display_menu ----------------------------------------------- */
.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; }
.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; }
.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; }
/* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
/* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
+3 -1
View File
@@ -28,6 +28,7 @@ export interface StyleDef {
}
export interface ScreenHost {
galleryUnlocked?: boolean;
py: Python;
fileUrl(path: string): string | null;
/** Element for an image name, file path or colour. */
@@ -1690,7 +1691,7 @@ export class ScreenManager {
if (isNativeAction(a)) return a.get_sensitive();
if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true);
return !(a instanceof ScreenAction) ||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true);
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? this.host.galleryUnlocked || !truthy(a.kw.locked ?? a.args[2]) : true);
});
}
@@ -2071,6 +2072,7 @@ export class ScreenManager {
out.Image = shape("image");
out.ImageReference = shape("image");
out.Gallery = galleryClass({
unlocked: () => !!this.host.galleryUnlocked,
seen: (name) => truthy(this.host.py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "renpy" }, a: "seen_image" }, a: [{ k: "const", v: name }], kw: {} })),
evaluate: (source) => this.host.py.eval(parseExpression(source)),
show: (pictures, transition) => this.showGallery(pictures, transition),
+1
View File
@@ -148,6 +148,7 @@ export interface GameData {
}
export interface Manifest {
galleryUnlocked?: boolean;
versionId?: number;
revision?: string;
format: string;
+1
View File
@@ -751,6 +751,7 @@ input[type="file"] { padding: 0.4rem; }
.f95-cover-info { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; align-items: flex-start; }
.game-metadata-row { align-items: flex-start; }
.game-gallery { min-width: 0; margin-block: var(--space-3); }
.version-gallery-setting { display: flex; align-items: center; gap: 0.5rem; margin-top: var(--space-3); cursor: pointer; }
.game-gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: var(--space-3); }
.game-gallery-item { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; }
.game-gallery-thumb { width: 100%; aspect-ratio: 16 / 10; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-s); overflow: hidden; background: var(--surface-2); cursor: zoom-in; }
+111
View File
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { makeEngine, until } from "./helpers/renpy-engine.mjs";
const built = await build({ stdin: { contents: `export {parseStatements} from './pyparse'; export {galleryScreens} from './gallery-access';`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { parseStatements, galleryScreens } = await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString("base64")}`);
const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {},
defaults: [], init: parseStatements('g = Gallery()\ng.button("Art")\ng.image("picture")\ng.condition("False")'),
characters: {}, channels: {}, images: { picture: { color: "#fff" } }, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} },
modules: [{ labels: [{ name: "start", body: [{ op: "pause", seconds: null }] }], anchors: {} }] });
test("Gallery conditions stay locked normally and are bypassed only for the opted-in version", () => {
for (const enabled of [false, true]) {
const { engine } = makeEngine(runtime(), { galleryUnlocked: enabled });
try {
engine.newStore();
const action = engine.py.attrOf(engine.store.g, "Action")("Art");
assert.equal(action !== null, enabled);
engine.openUnlockedGallery();
assert.equal(engine.gallerySession, enabled);
} finally { engine.destroy(); }
}
});
test("gallery browsing does not write saves or persistent progress and restores the running story", async () => {
const { engine, root } = makeEngine(runtime(), { galleryUnlocked: true });
let saves = 0;
engine.saves.put = async () => { saves++; };
try {
engine.newStore();
engine.persistent.example = ["story"];
engine.store.counter = 7;
engine.history = [{ name: "Player", text: "Story history" }];
engine.seenImages.add("story");
engine.cur = { label: "start", path: [0] };
engine.running = true;
engine.openUnlockedGallery();
assert.match(root.textContent, /Art/);
engine.persistent.example.push("gallery");
engine.persistent.other = true;
engine.seenImages.add("gallery");
await engine.save("auto");
assert.equal(saves, 0);
engine.closeUnlockedGallery();
assert.deepEqual(engine.persistent.example, ["story"]);
assert.equal(engine.persistent.other, null);
assert.equal(engine.store.counter, 7);
assert.deepEqual(engine.history, [{ name: "Player", text: "Story history" }]);
assert.deepEqual([...engine.seenImages], ["story"]);
assert.equal(engine.gallerySession, false);
} finally { engine.destroy(); }
});
test("MIST unlock modifies only gallery expressions and keeps page and story conditions intact", () => {
const condition = { k: "attr", o: { k: "name", id: "scene" }, a: "watched" };
const page = { k: "cmp", l: { k: "name", id: "curpage" }, ops: ["=="], r: [{ k: "const", v: "Page 1" }] };
const game = { build: { name: "MIST" }, screens: { rewatchgirlselectscreen: {},
robgallery: { condition, page }, story: { condition } } };
const unlocked = galleryScreens(game, true);
assert.deepEqual(unlocked.robgallery.condition, { k: "const", v: true });
assert.deepEqual(unlocked.robgallery.page, page);
assert.equal(unlocked.story, game.screens.story);
assert.deepEqual(game.screens.robgallery.condition, condition);
assert.equal(galleryScreens(game, false), game.screens);
});
test("MIST's actual gallery opens all character and scene buttons without completing quests", {
skip: !process.env.PGAMES_MIST_RUNTIME,
}, async () => {
const original = JSON.parse(readFileSync(process.env.PGAMES_MIST_RUNTIME, "utf8"));
const { engine, root } = makeEngine(original, { galleryUnlocked: true });
engine.fileUrl = (path) => `/mock/${path}`;
engine.audio.play = () => {};
try {
engine.openUnlockedGallery();
await until(() => root.querySelectorAll(".pg-simgbtn").length >= 5);
assert.equal(engine.py.attrOf(engine.store.searchlodge07, "is_done"), false);
const robin = root.querySelectorAll(".pg-simgbtn")[1];
robin.onclick({ stopPropagation() {} });
await until(() => engine.screens.shown.has("robgallery"));
const images = [...engine.screens.shown.get("robgallery").el.querySelectorAll("img")].map((image) => image.src);
assert.ok(images.some((path) => path.includes("g_r01")));
assert.equal(images.some((path) => path.includes("gallocked")), false);
assert.equal(engine.py.attrOf(engine.store.rscene01, "watched"), false);
for (const [name, pages] of [["robgallery", 3], ["miagallery", 2], ["lilgallery", 2], ["norgallery", 2], ["groupgallery", 1]]) {
engine.screens.show(name);
const instance = engine.screens.shown.get(name);
for (let page = 1; page <= pages; page++) {
instance.vars.set("curpage", `Page ${page}`);
engine.screens.render(instance);
const pictures = [...instance.el.querySelectorAll("img")].map((image) => image.src);
assert.equal(pictures.some((path) => path.includes("gallocked")), false, `${name}, page ${page}`);
}
}
engine.store.replaywatch = true;
engine.jump("myroomcontrol");
assert.equal(engine.cur.label, "rewatchscene");
engine.screens.show("rewatchgirlselectscreen");
engine.store.replaywatch = false;
engine.screens.shown.get("rewatchgirlselectscreen").el.querySelector(".pg-simgbtn").onclick({ stopPropagation() {} });
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(engine.gallerySession, false);
assert.equal(engine.running, false);
assert.equal(engine.cur.label, "");
} finally { engine.destroy(); }
});
+2 -2
View File
@@ -7,7 +7,7 @@ const bundle = await build({ entryPoints: [fileURLToPath(new URL("../../src/play
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
Error.stackTraceLimit = 2; // data-URL bundle frames otherwise print the entire bundle on a failure.
export function makeEngine(runtime) {
export function makeEngine(runtime, manifest = {}) {
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
const storage = new Map();
Object.assign(globalThis, {
@@ -19,7 +19,7 @@ export function makeEngine(runtime) {
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
const root = document.createElement("div");
document.body.append(root);
const engine = new Engine(root, structuredClone(runtime.game), { files: {} }, structuredClone(runtime.modules),
const engine = new Engine(root, structuredClone(runtime.game), { files: {}, ...manifest }, structuredClone(runtime.modules),
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
"runtime-test", () => {}, tr, async () => tr);
return { engine, root, window, document };
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
test("the owner can toggle the gallery, everyone sees its badge and direct entry", {
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
}, async () => {
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
const compiled = await build({ stdin: { contents: `
import {createElement as h} from 'react'; import {createRoot} from 'react-dom/client';
import {MemoryRouter, Routes, Route, useLocation} from 'react-router-dom';
import {AuthProvider} from './auth'; import {UiProvider} from './ui/Ui';
import {TranslationRunProvider} from './translator/runner'; import {UploadProvider} from './uploads';
import {GamePage} from './pages/GamePage';
function Destination() {const at = useLocation(); return h('output', null, at.pathname + at.search);}
createRoot(document.querySelector('main')).render(h(MemoryRouter, {initialEntries: ['/games/1']},
h(UiProvider, null, h(AuthProvider, null, h(TranslationRunProvider, null, h(UploadProvider, null,
h(Routes, null, h(Route, {path: '/games/:gameId', element: h(GamePage)}),
h(Route, {path: '/play/:versionId', element: h(Destination)}))))))));`,
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
const version = { id: 1, game_id: 1, parent_id: null, owner: { id: 7, username: "author" }, name: "Моя версия",
status: "ready", kind: "mod", visibility: "draft", created_at: new Date().toISOString(), size_bytes: 1,
gallery_available: true, gallery_unlocked: false, languages: [] };
let userId = 7, fail = false;
const writes = [];
const server = createServer(async (req, res) => {
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
res.setHeader("Content-Type", "application/json");
if (req.url === "/api/auth/me") { res.end(JSON.stringify({ id: userId, username: "author", permissions: ["games.play"] })); return; }
if (req.url === "/api/games/1") { res.end(JSON.stringify({ id: 1, title: "MIST", tags: [], gallery_hashes: [], versions: [version] })); return; }
if (req.url === "/api/versions/1/gallery" && req.method === "PATCH") {
let body = "";
for await (const chunk of req) body += chunk;
const settings = JSON.parse(body);
writes.push(settings);
if (fail) { res.statusCode = 500; res.end(JSON.stringify({ detail: "Не удалось сохранить настройку" })); return; }
version.gallery_unlocked = settings.gallery_unlocked;
res.end(JSON.stringify(version)); return;
}
res.setHeader("Content-Type", "text/html");
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><main></main><script src="/bundle.js"></script>');
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
let browser;
try {
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
page.setDefaultTimeout(5000);
const origin = `http://127.0.0.1:${server.address().port}`;
await page.goto(origin);
const checkbox = page.getByRole("checkbox", { name: "Открыть всю галерею в этой версии" });
await checkbox.check();
await page.getByText("Галерея открыта", { exact: true }).waitFor();
await page.getByRole("button", { name: "Открыть галерею", exact: true }).click();
await page.getByText("/play/1?gallery=1", { exact: true }).waitFor();
await page.reload();
await checkbox.waitFor();
assert.equal(await checkbox.isChecked(), true);
await checkbox.uncheck();
await page.getByText("Галерея открыта", { exact: true }).waitFor({ state: "detached" });
fail = true;
await checkbox.click();
await page.getByText("Не удалось сохранить настройку", { exact: true }).waitFor();
assert.equal(await checkbox.isChecked(), false);
assert.deepEqual(writes, [{ gallery_unlocked: true }, { gallery_unlocked: false }, { gallery_unlocked: true }]);
version.gallery_unlocked = true;
userId = 8;
await page.reload();
await page.getByText("Галерея открыта", { exact: true }).waitFor();
assert.equal(await checkbox.count(), 0);
await page.setViewportSize({ width: 375, height: 800 });
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
} finally {
await browser?.close();
await new Promise((resolve) => server.close(resolve));
}
});
test("the unlocked Gallery viewer opens above its index and closes back to the main menu", {
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
}, async () => {
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
const compiled = await build({ stdin: { contents: `
import {Engine} from './player/engine'; import {parseStatements} from './player/pyparse';
const game = {resolution: [640, 480], entry: 'start', title: 'Gallery test', ui: {}, files: {}, defaults: [],
init: parseStatements('g = Gallery()\\ng.button("Art")\\ng.image("picture")\\ng.condition("False")'),
characters: {}, channels: {}, images: {picture: {color: '#ff00ff'}}, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {}};
const tr = {lang: 'en', renpyName: null, str: s => s, line: (_, s) => s};
const saves = {list: async () => [], get: async () => null, put: async () => {}, remove: async () => {}};
const engine = new Engine(document.querySelector('main'), game, {files: {}, galleryUnlocked: true},
[{labels: [{name: 'start', body: [{op: 'return'}]}], anchors: {}}], saves, 'gallery-browser', () => {}, tr, async () => tr);
engine.start();`, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
const css = readFileSync(new URL("../src/player/player.css", import.meta.url), "utf8");
const server = createServer((req, res) => {
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
res.setHeader("Content-Type", "text/html");
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><style>main{width:800px;height:600px}</style><main></main><script src="/bundle.js"></script>');
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
let browser;
try {
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
page.setDefaultTimeout(5000);
await page.goto(`http://127.0.0.1:${server.address().port}`);
await page.getByRole("button", { name: "Галерея открыта", exact: true }).click();
await page.getByRole("button", { name: "Art", exact: true }).click();
await page.locator(".pg-gallery").waitFor();
const top = await page.locator(".pg-gallery").evaluate((overlay) => {
const box = overlay.getBoundingClientRect();
return !!document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)?.closest(".pg-gallery");
});
assert.equal(top, true, "the gallery image is above the index and receives input");
await page.locator(".pg-gallery").getByRole("button", { name: "Закрыть", exact: true }).click();
await page.getByRole("button", { name: "Art", exact: true }).waitFor();
await page.getByRole("button", { name: "Закрыть", exact: true }).click();
await page.getByRole("button", { name: "Начать", exact: true }).waitFor();
} finally {
await browser?.close();
await new Promise((resolve) => server.close(resolve));
}
});