From 19924e1c5ab8f66f5ef4eb8ee5ac94f6e5dd6020 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:45:22 +0300 Subject: [PATCH] feat: unlock in-game galleries per owned version --- backend/app/gallery.py | 22 + backend/app/models.py | 1 + backend/app/routers/games.py | 4 +- backend/app/routers/versions.py | 25 +- backend/app/schemas.py | 6 + .../versions/0007_version_gallery.py | 15 + backend/tests/test_gallery.py | 82 ++ docs/GALLERY.md | 28 + frontend/src/api.ts | 2 + frontend/src/pages/GamePage.tsx | 16 + frontend/src/player/engine.ts | 124 ++- frontend/src/player/gallery-access.ts | 34 + frontend/src/player/gallery.ts | 3 +- frontend/src/player/index.ts | 3 +- frontend/src/player/player.css | 756 +++++++++--------- frontend/src/player/screens.ts | 4 +- frontend/src/player/types.ts | 1 + frontend/src/styles.css | 1 + frontend/tests/gallery-unlock.test.mjs | 111 +++ frontend/tests/helpers/renpy-engine.mjs | 4 +- .../tests/version-gallery-browser.test.mjs | 130 +++ 21 files changed, 977 insertions(+), 395 deletions(-) create mode 100644 backend/app/gallery.py create mode 100644 backend/migrations/versions/0007_version_gallery.py create mode 100644 backend/tests/test_gallery.py create mode 100644 docs/GALLERY.md create mode 100644 frontend/src/player/gallery-access.ts create mode 100644 frontend/tests/gallery-unlock.test.mjs create mode 100644 frontend/tests/version-gallery-browser.test.mjs diff --git a/backend/app/gallery.py b/backend/app/gallery.py new file mode 100644 index 0000000..29f6a7a --- /dev/null +++ b/backend/app/gallery.py @@ -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", [])) diff --git a/backend/app/models.py b/backend/app/models.py index e6f9b12..b5f70ba 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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) diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py index e2b41de..c88ca1e 100644 --- a/backend/app/routers/games.py +++ b/backend/app/routers/games.py @@ -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]} diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py index eceaeff..ab2a1a7 100644 --- a/backend/app/routers/versions.py +++ b/backend/app/routers/versions.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5587437..8e42b73 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/migrations/versions/0007_version_gallery.py b/backend/migrations/versions/0007_version_gallery.py new file mode 100644 index 0000000..aa39990 --- /dev/null +++ b/backend/migrations/versions/0007_version_gallery.py @@ -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") diff --git a/backend/tests/test_gallery.py b/backend/tests/test_gallery.py new file mode 100644 index 0000000..76b5441 --- /dev/null +++ b/backend/tests/test_gallery.py @@ -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 diff --git a/docs/GALLERY.md b/docs/GALLERY.md new file mode 100644 index 0000000..8a28afb --- /dev/null +++ b/docs/GALLERY.md @@ -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}`. +Менять её может только владелец версии, в том числе если другие пользователи +имеют административные права на управление версиями. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a0ab924..d3e7fff 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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; diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 7ee078e..9887e5c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -167,6 +167,9 @@ export function GamePage() { const renderTree = (parent: number | null, depth: number): JSX.Element[] => (byParent.get(parent) ?? []).flatMap((v) => [ 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 = { original: "", translation: "Перевод", mod: "Мод" }; function VersionRow(props: { + isOwner: boolean; onGallery: (enabled: boolean) => Promise; 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(null); const [report, setReport] = useState(undefined); const toggleReport = async () => { if (report !== undefined) setReport(undefined); @@ -268,6 +273,7 @@ function VersionRow(props: { {props.isMain && главная} {KIND[v.kind] && {KIND[v.kind]}} {v.visibility === "draft" && черновик} + {v.gallery_unlocked && Галерея открыта}
@@ -277,6 +283,7 @@ function VersionRow(props: {
{v.status === "ready" && } + {v.status === "ready" && v.gallery_unlocked && } {v.status === "ready" && props.onPlayLang(e.code)} />} {v.status === "ready" && props.canFork && } {v.status === "ready" && v.kind === "mod" && Редактор мода} @@ -305,6 +312,15 @@ function VersionRow(props: { )}
+ {v.status === "ready" && props.isOwner && v.gallery_available && ( + + )} {inProgress &&
} {report !== undefined && }
diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 0cf96ef..c997e4e 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -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 = {}; private builtins: Record = {}; private persistent: Record; + private gallerySession = false; + private galleryStash: SaveData | null = null; + private galleryPersistent: Record | null = null; + private gallerySeen: Set | 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>(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 }, 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 | 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); diff --git a/frontend/src/player/gallery-access.ts b/frontend/src/player/gallery-access.ts new file mode 100644 index 0000000..830aa99 --- /dev/null +++ b/frontend/src/player/gallery-access.ts @@ -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(); + 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 })); +} diff --git a/frontend/src/player/gallery.ts b/frontend/src/player/gallery.ts index c6b6219..f2cb53f 100644 --- a/frontend/src/player/gallery.ts +++ b/frontend/src/player/gallery.ts @@ -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; diff --git a/frontend/src/player/index.ts b/frontend/src/player/index.ts index c1d8a79..0ede7a4 100644 --- a/frontend/src/player/index.ts +++ b/frontend/src/player/index.ts @@ -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; } diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 056ce35..97079e6 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -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); } } diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 3fe8e08..59ddc1c 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -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), diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index fef02d8..77cdd24 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -148,6 +148,7 @@ export interface GameData { } export interface Manifest { + galleryUnlocked?: boolean; versionId?: number; revision?: string; format: string; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f095b29..279883c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/tests/gallery-unlock.test.mjs b/frontend/tests/gallery-unlock.test.mjs new file mode 100644 index 0000000..d380ed2 --- /dev/null +++ b/frontend/tests/gallery-unlock.test.mjs @@ -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(); } +}); diff --git a/frontend/tests/helpers/renpy-engine.mjs b/frontend/tests/helpers/renpy-engine.mjs index 4214d52..27d6b28 100644 --- a/frontend/tests/helpers/renpy-engine.mjs +++ b/frontend/tests/helpers/renpy-engine.mjs @@ -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(""); 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 }; diff --git a/frontend/tests/version-gallery-browser.test.mjs b/frontend/tests/version-gallery-browser.test.mjs new file mode 100644 index 0000000..71a3401 --- /dev/null +++ b/frontend/tests/version-gallery-browser.test.mjs @@ -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('
'); + }); + 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('
'); + }); + 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)); + } +});