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 # draft: visible to the owner and reviewers only
visibility: Mapped[str] = mapped_column(String(16), default="published") visibility: Mapped[str] = mapped_column(String(16), default="published")
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod 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="") progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255)) upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) 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.blobs import blob_path, put_bytes
from app.deps import DB, CurrentUser, has_perm, require 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.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
from app.translation import version_languages from app.translation import version_languages
@@ -114,7 +115,8 @@ async def game_detail(game_id: int, user: Player, db: DB) -> dict:
) )
).all() ).all()
return {**GameOut.model_validate(game).model_dump(), 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]} for v in versions]}
+22 -3
View File
@@ -8,10 +8,12 @@ from starlette.requests import ClientDisconnect
from app import translation from app import translation
from app.deps import DB, CurrentUser, has_perm, require 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.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game from app.routers.games import get_game
from app.schemas import ( from app.schemas import (
ForkIn, ForkIn,
GallerySettingsIn,
HistoryOut, HistoryOut,
PublishIn, PublishIn,
SaveDetailOut, SaveDetailOut,
@@ -108,8 +110,10 @@ async def upload_version(
@router.get("/versions/{version_id}", response_model=VersionDetailOut) @router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: async def version_detail(version_id: int, user: Player, db: DB) -> dict[str, Any]:
return await get_version(db, user, version_id) 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") @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) v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest: if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") 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) @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", status="ready",
visibility="draft", visibility="draft",
kind=body.kind, kind=body.kind,
gallery_unlocked=parent.gallery_unlocked,
manifest=parent.manifest, manifest=parent.manifest,
base_revision_id=parent.revision_id, base_revision_id=parent.revision_id,
report=parent.report, report=parent.report,
@@ -243,6 +248,20 @@ async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db
return v 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) @router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
+6
View File
@@ -68,6 +68,8 @@ class OwnerOut(ORM):
class VersionOut(ORM): class VersionOut(ORM):
gallery_unlocked: bool = False
gallery_available: bool = False
revision_id: str | None = None revision_id: str | None = None
base_revision_id: str | None = None base_revision_id: str | None = None
id: int id: int
@@ -141,6 +143,10 @@ class PublishIn(BaseModel):
make_main: bool = False make_main: bool = False
class GallerySettingsIn(BaseModel):
gallery_unlocked: bool
class HistoryOut(ORM): class HistoryOut(ORM):
id: int id: int
note: str 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 { export interface Version {
gallery_unlocked?: boolean;
gallery_available?: boolean;
revision_id: string | null; revision_id: string | null;
base_revision_id: string | null; base_revision_id: string | null;
id: number; id: number;
+16
View File
@@ -167,6 +167,9 @@ export function GamePage() {
const renderTree = (parent: number | null, depth: number): JSX.Element[] => const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [ (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} <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")} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canTranslate={can("translations.edit")} canPublish={can("versions.publish")} canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)} 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: "Мод" }; const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
function VersionRow(props: { function VersionRow(props: {
isOwner: boolean; onGallery: (enabled: boolean) => Promise<void>; onOpenGallery: () => void;
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean; v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: boolean; canTranslate: boolean; canPublish: boolean;
onPlay: () => void; onPlayLang: (code: string) => void; onFork: () => void; onRetry: () => void; onDelete: () => void; onPlay: () => void; onPlayLang: (code: string) => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void; onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) { }) {
const { v } = props; const { v } = props;
const [pendingGallery, setPendingGallery] = useState<boolean | null>(null);
const [report, setReport] = useState<Version["report"] | undefined>(undefined); const [report, setReport] = useState<Version["report"] | undefined>(undefined);
const toggleReport = async () => { const toggleReport = async () => {
if (report !== undefined) setReport(undefined); if (report !== undefined) setReport(undefined);
@@ -268,6 +273,7 @@ function VersionRow(props: {
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>} {props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>} {KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>} {v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
{v.gallery_unlocked && <span className="badge badge-warn">Галерея открыта</span>}
<StatusBadge status={v.status} /> <StatusBadge status={v.status} />
</div> </div>
<div className="muted small version-meta"> <div className="muted small version-meta">
@@ -277,6 +283,7 @@ function VersionRow(props: {
</div> </div>
<div className="actions"> <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" && <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" && <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" && 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>} {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>
</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>} {inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
{report !== undefined && <Report report={report} />} {report !== undefined && <Report report={report} />}
</div> </div>
+116 -8
View File
@@ -2,9 +2,10 @@
import { AudioSystem } from "./audio"; import { AudioSystem } from "./audio";
import { cutscene } from "./cutscene"; import { cutscene } from "./cutscene";
import { galleryScreens, isMistGallery, replayEntries } from "./gallery-access";
import { VolumeStore } from "./volume-store"; import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live"; 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 { 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 { 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"; import { languageName, rememberLanguage, type Translator } from "./i18n";
@@ -77,6 +78,12 @@ export class Engine {
private store: Record<string, any> = {}; private store: Record<string, any> = {};
private builtins: Record<string, any> = {}; private builtins: Record<string, any> = {};
private persistent: 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 py: Python;
private renderer: Renderer; private renderer: Renderer;
private audio: AudioSystem; private audio: AudioSystem;
@@ -177,7 +184,7 @@ export class Engine {
del: (n) => void delete this.store[n], del: (n) => void delete this.store[n],
}; };
this.py = new Python(scope); 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([ this.py.blockingNames = new Set([
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen", "renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []), "narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
@@ -402,11 +409,14 @@ export class Engine {
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {})); const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
const save = () => saveJSON(this.persistentKey, encodeValue(data)); const save = () => saveJSON(this.persistentKey, encodeValue(data));
return new Proxy(data, { return new Proxy(data, {
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined get: (t, k: string) => {
: k === "_clear" ? () => { for (const key of Object.keys(t)) delete t[key]; save(); return null; } : null), 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) => { set: (t, k: string, v) => {
t[k] = v; (this.galleryPersistent ?? t)[k] = v;
save(); if (!this.gallerySession) save();
return true; return true;
}, },
}); });
@@ -1176,6 +1186,7 @@ export class Engine {
private screenHost(): ScreenHost { private screenHost(): ScreenHost {
const engine = this; const engine = this;
return { return {
galleryUnlocked: !!this.manifest.galleryUnlocked,
error: (message) => this.fail(new Error(message)), error: (message) => this.fail(new Error(message)),
get audio() { return engine.audio; }, get audio() { return engine.audio; },
setMixer: (name, value) => this.setMixerValue(name, value), setMixer: (name, value) => this.setMixerValue(name, value),
@@ -1188,10 +1199,14 @@ export class Engine {
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }), play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
stop: (channel) => this.audio.stop(channel, 0), stop: (channel) => this.audio.stop(channel, 0),
notify: (message) => this.notify(message), 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)), call: (label) => this.restart(() => this.call(label)),
quit: () => this.onExit(), 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(), openMenu: () => this.openGameMenu(),
screensOn: (layer) => this.renderer.screensOn(layer), screensOn: (layer) => this.renderer.screensOn(layer),
rollback: () => this.rollback(), rollback: () => this.rollback(),
@@ -1464,6 +1479,10 @@ export class Engine {
} }
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) { 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_screen" || target === "_main_menu_screen") target = "__main_menu__";
if (target === "__main_menu__" || target === "__quit__") { if (target === "__main_menu__" || target === "__quit__") {
throw new Restart(target); throw new Restart(target);
@@ -2223,6 +2242,7 @@ export class Engine {
} }
private async save(slot: string, silent = false) { private async save(slot: string, silent = false) {
if (this.gallerySession) return;
if (!this.running || !this.cur.label) return; if (!this.running || !this.cur.label) return;
const data = this.snapshotState(); const data = this.snapshotState();
const title = (data.text ?? "").slice(0, 120) || this.cur.label; const title = (data.text ?? "").slice(0, 120) || this.cur.label;
@@ -2235,6 +2255,7 @@ export class Engine {
} }
private async load(slot: string) { private async load(slot: string) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы загрузить сохранение"); return; }
const data = await this.saves.get(slot); const data = await this.saves.get(slot);
if (!data) { if (!data) {
this.notify("Сохранение не найдено"); this.notify("Сохранение не найдено");
@@ -2287,6 +2308,88 @@ export class Engine {
this.history = []; 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. */ /** 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) { private startReplay(label: string, scope: Record<string, any> | null) {
if (!this.labels.has(label)) { if (!this.labels.has(label)) {
@@ -2545,6 +2648,7 @@ export class Engine {
} }
private showMainMenu() { private showMainMenu() {
if (this.gallerySession) { this.showUnlockedGalleryIndex(); return; }
this.placeScreen(false); this.placeScreen(false);
this.running = false; this.running = false;
this.gen++; this.gen++;
@@ -2567,6 +2671,7 @@ export class Engine {
return b; return b;
}; };
btn("Начать", () => this.newGame()); btn("Начать", () => this.newGame());
if (this.manifest.galleryUnlocked) btn("Галерея открыта", () => this.openUnlockedGallery());
const cont = btn("Продолжить", () => void this.load("auto")); const cont = btn("Продолжить", () => void this.load("auto"));
cont.style.display = "none"; cont.style.display = "none";
this.saves.list().then((list) => { this.saves.list().then((list) => {
@@ -2610,6 +2715,8 @@ export class Engine {
list.append(b); list.append(b);
}; };
add("Вернуться", () => this.closeScreen()); add("Вернуться", () => this.closeScreen());
if (this.gallerySession) add("Закрыть галерею", () => this.closeUnlockedGallery());
else if (this.manifest.galleryUnlocked) add("Открыть галерею", () => this.openUnlockedGallery());
add("История", () => this.openHistory()); add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); }); add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); }); add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
@@ -2640,6 +2747,7 @@ export class Engine {
} }
private openSaves(mode: "save" | "load", fromMain = false) { private openSaves(mode: "save" | "load", fromMain = false) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы открыть сохранения"); return; }
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => { this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
const grid = el("div", "pg-slots"); const grid = el("div", "pg-slots");
body.append(grid); 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[] } interface Button { pictures: Picture[]; conditions: Condition[] }
export function galleryClass(host: { export function galleryClass(host: {
unlocked?(): boolean;
seen(name: string): boolean; seen(name: string): boolean;
evaluate(source: string): any; evaluate(source: string): any;
show(pictures: any[][], transition: any): void; show(pictures: any[][], transition: any): void;
@@ -14,7 +15,7 @@ export function galleryClass(host: {
const gallery = new PyClass("Gallery", [OBJECT]); const gallery = new PyClass("Gallery", [OBJECT]);
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) => const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) =>
gallery.attrs.set(name, nativeMethod(fn)); 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))); ? c.images.every((name) => host.seen(name)) : truthy(host.evaluate(c.source)));
const get = (self: PyInstance, name: string): Button => { const get = (self: PyInstance, name: string): Button => {
const buttons = self.attrs.buttons as PyDict; 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), (code) => loadTranslator(game, manifest, code),
gameId === null ? `pgvn:audio:version:${versionId}` : `pgvn:audio:game:${gameId}`); gameId === null ? `pgvn:audio:version:${versionId}` : `pgvn:audio:game:${gameId}`);
const at = url.get("at"); 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(); else engine.start();
return engine; return engine;
} }
+378 -378
View File
@@ -1,378 +1,378 @@
.pg-root { .pg-root {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #000; background: #000;
overflow: hidden; overflow: hidden;
user-select: none; user-select: none;
} }
.pg-stage { .pg-stage {
--pg-accent: #ff9f43; --pg-accent: #ff9f43;
--pg-text: #ffffff; --pg-text: #ffffff;
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: var(--pg-text); color: var(--pg-text);
font-family: "Segoe UI", system-ui, sans-serif; font-family: "Segoe UI", system-ui, sans-serif;
font-size: var(--pg-font); font-size: var(--pg-font);
transform-origin: center; transform-origin: center;
cursor: default; cursor: default;
} }
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; } .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-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-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-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; } .pg-transition { pointer-events: none; }
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; } .pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
.pg-fill { position: absolute; inset: 0; } .pg-fill { position: absolute; inset: 0; }
.pg-image { position: absolute; will-change: transform, opacity; } .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-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-hidden { display: none !important; }
.pg-textbox { .pg-textbox {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92)); 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); border-top: 2px solid rgba(255, 255, 255, 0.12);
} }
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; } .pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
.pg-textbox.pg-ui-hidden { opacity: 0; } .pg-textbox.pg-ui-hidden { opacity: 0; }
.pg-namebox { .pg-namebox {
position: absolute; position: absolute;
top: 0; top: 0;
transform: translateY(-50%); transform: translateY(-50%);
padding: 0.15em 0.8em; padding: 0.15em 0.8em;
font-size: var(--pg-name-font); font-size: var(--pg-name-font);
font-weight: 600; font-weight: 600;
color: var(--pg-accent); color: var(--pg-accent);
background: rgba(10, 12, 24, 0.85); background: rgba(10, 12, 24, 0.85);
border-radius: 0.3em; border-radius: 0.3em;
text-shadow: 0 1px 2px #000; text-shadow: 0 1px 2px #000;
} }
.pg-textbox-image .pg-namebox { background: transparent; } .pg-textbox-image .pg-namebox { background: transparent; }
.pg-text { .pg-text {
position: absolute; position: absolute;
line-height: 1.35; line-height: 1.35;
white-space: pre-wrap; white-space: pre-wrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); 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; } .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; } } @keyframes pg-blink { 50% { opacity: 0.1; } }
.pg-quick { .pg-quick {
position: absolute; position: absolute;
bottom: 0.35em; bottom: 0.35em;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
gap: 0.15em; gap: 0.15em;
padding: 0.1em 0.3em; padding: 0.1em 0.3em;
border-radius: 99em; border-radius: 99em;
background: rgba(8, 10, 20, 0.45); background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
opacity: 0.55; opacity: 0.55;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; } .pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button { .pg-quick button {
background: none; background: none;
border: 0; border: 0;
border-radius: 99em; border-radius: 99em;
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
font: inherit; font: inherit;
font-size: calc(var(--pg-font) * 0.55); font-size: calc(var(--pg-font) * 0.55);
padding: 0.25em 0.75em; padding: 0.25em 0.75em;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } .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.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); } .pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); }
.pg-menu { .pg-menu {
position: absolute; position: absolute;
inset: 0 0 26% 0; inset: 0 0 26% 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5em; gap: 0.5em;
} }
.pg-choice { .pg-choice {
min-width: 50%; min-width: 50%;
max-width: 75%; max-width: 75%;
padding: 0.45em 1.2em; padding: 0.45em 1.2em;
font: inherit; font: inherit;
color: #fff; color: #fff;
background: rgba(15, 18, 35, 0.85); background: rgba(15, 18, 35, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25); border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.3em; border-radius: 0.3em;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
} }
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); } .pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
.pg-nvl { .pg-nvl {
position: absolute; position: absolute;
inset: 0; inset: 0;
padding: 5% 15%; padding: 5% 15%;
background: rgba(0, 0, 0, 0.72) center / cover; background: rgba(0, 0, 0, 0.72) center / cover;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.6em; gap: 0.6em;
overflow: hidden; overflow: hidden;
} }
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; } .pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
.pg-nvl-what { white-space: pre-wrap; } .pg-nvl-what { white-space: pre-wrap; }
.pg-centered { .pg-centered {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
padding: 0 15%; padding: 0 15%;
white-space: pre-wrap; white-space: pre-wrap;
text-shadow: 0 2px 4px #000; text-shadow: 0 2px 4px #000;
} }
.pg-notify { .pg-notify {
position: absolute; position: absolute;
top: 2%; top: 2%;
left: 2%; left: 2%;
padding: 0.4em 1em; padding: 0.4em 1em;
font-size: calc(var(--pg-font) * 0.7); font-size: calc(var(--pg-font) * 0.7);
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
border-left: 4px solid var(--pg-accent); border-left: 4px solid var(--pg-accent);
} }
.pg-screen { .pg-screen {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(8, 10, 20, 0.88) center / cover; background: rgba(8, 10, 20, 0.88) center / cover;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 3% 6%; padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85); font-size: calc(var(--pg-font) * 0.85);
z-index: 10; z-index: 10;
animation: pg-fade 0.15s ease-out; animation: pg-fade 0.15s ease-out;
} }
@keyframes pg-fade { from { opacity: 0; } } @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 { 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 { 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-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 { .pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit; font: inherit;
color: #fff; color: #fff;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.5em; border-radius: 0.5em;
padding: 0.5em 1.1em; padding: 0.5em 1.1em;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s; 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: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 { .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: 3px solid var(--pg-accent);
outline-offset: 2px; outline-offset: 2px;
} }
.pg-screen button:disabled { opacity: 0.35; cursor: default; } .pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; } .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-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-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 { 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-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 { 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 { 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-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-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 { 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 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-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-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-settings-screen { .pg-settings-screen {
--pg-accent: #ff9f43; --pg-accent: #ff9f43;
--pg-font: 18px; --pg-font: 18px;
z-index: 25; z-index: 25;
padding: clamp(16px, 4%, 40px); padding: clamp(16px, 4%, 40px);
color: #edf0f7; color: #edf0f7;
font: 16px/1.5 "Segoe UI", system-ui, sans-serif; font: 16px/1.5 "Segoe UI", system-ui, sans-serif;
background: rgba(11, 14, 23, 0.96); background: rgba(11, 14, 23, 0.96);
container-type: inline-size; container-type: inline-size;
color-scheme: dark; color-scheme: dark;
} }
.pg-settings-screen .pg-screen-head, .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-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 { flex-shrink: 0; margin-bottom: 20px; }
.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; } .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-close { min-width: 44px; min-height: 44px; }
.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; } .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 { 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 { 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:first-child { grid-row: span 2; }
.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } .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-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 { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.pg-setting:last-child { margin-bottom: 0; } .pg-setting:last-child { margin-bottom: 0; }
.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .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-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pg-setting input[type="range"] { .pg-setting input[type="range"] {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
display: block; display: block;
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
margin: 0; margin: 0;
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
cursor: pointer; cursor: pointer;
accent-color: var(--pg-accent); accent-color: var(--pg-accent);
} }
.pg-setting input[type="range"]::-webkit-slider-runnable-track { .pg-setting input[type="range"]::-webkit-slider-runnable-track {
height: 6px; height: 6px;
border-radius: 99px; border-radius: 99px;
background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress)); background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress));
} }
.pg-setting input[type="range"]::-webkit-slider-thumb { .pg-setting input[type="range"]::-webkit-slider-thumb {
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-top: -7px; margin-top: -7px;
border: 3px solid #191e2a; border: 3px solid #191e2a;
border-radius: 50%; border-radius: 50%;
background: var(--pg-accent); background: var(--pg-accent);
box-shadow: 0 0 0 1px 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-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-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"]::-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-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-setting select { width: 100%; min-height: 44px; }
.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; } .pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; }
@container (max-width: 620px) { @container (max-width: 620px) {
.pg-settings { grid-template-columns: minmax(0, 1fr); } .pg-settings { grid-template-columns: minmax(0, 1fr); }
.pg-settings-card:first-child { grid-row: auto; } .pg-settings-card:first-child { grid-row: auto; }
.pg-settings-card { padding: 18px; } .pg-settings-card { padding: 18px; }
} }
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; } .pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
.pg-screen.pg-main { background-color: #000; justify-content: center; } .pg-screen.pg-main { background-color: #000; justify-content: center; }
.pg-main-panel { .pg-main-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.55em; gap: 0.55em;
width: min(26em, 100%); width: min(26em, 100%);
padding: 2em; padding: 2em;
background: rgba(8, 10, 20, 0.72); background: rgba(8, 10, 20, 0.72);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em; border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5); 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 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 { 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 { 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); } .pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
/* menu button outside the scaled stage: stays tappable on phones */ /* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn { .pg-hud-btn {
position: absolute; position: absolute;
top: max(10px, env(safe-area-inset-top)); top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right)); right: max(10px, env(safe-area-inset-right));
z-index: 20; z-index: 20;
width: 44px; width: 44px;
height: 44px; height: 44px;
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 22px; font-size: 22px;
line-height: 1; line-height: 1;
color: #fff; color: #fff;
background: rgba(8, 10, 20, 0.55); background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%; border-radius: 50%;
cursor: pointer; cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity 0.2s, background 0.15s; transition: opacity 0.2s, background 0.15s;
} }
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; } .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-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; } .pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) { @media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; } .pg-hud-btn { opacity: 0.6; }
} }
/* in-player confirmation */ /* in-player confirmation */
.pg-dialog-back { .pg-dialog-back {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 30; z-index: 30;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 16px; padding: 16px;
background: rgba(5, 7, 12, 0.7); background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif; font-family: "Segoe UI", system-ui, sans-serif;
} }
.pg-dialog { .pg-dialog {
width: min(420px, 100%); width: min(420px, 100%);
padding: 22px; padding: 22px;
color: #eceef5; color: #eceef5;
background: #171b25; background: #171b25;
border: 1px solid #2a3042; border: 1px solid #2a3042;
border-radius: 14px; border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px; font-size: 16px;
} }
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; } .pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; } .pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; } .pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; } .pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */ /* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; } .pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; } .pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; } .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-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-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; } .pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; } .pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; } .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-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; } .pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; } .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-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; } .pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; } .pg-sadd { pointer-events: none; }
.pg-sviewport { scrollbar-width: thin; } .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; }
/* --- renpy.input / renpy.display_menu ----------------------------------------------- */ .pg-sviewport { scrollbar-width: thin; }
.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%; } /* --- renpy.input / renpy.display_menu ----------------------------------------------- */
.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; } .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%; }
/* checkbox row of the settings screen */ .pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; }
.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); } /* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */ .pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
.pg-fx { display: inline-block; white-space: pre; }
/* SnowBlossom: pictures that fall and drift while the scene is shown */ /* 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; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; } .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); } } @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 { export interface ScreenHost {
galleryUnlocked?: boolean;
py: Python; py: Python;
fileUrl(path: string): string | null; fileUrl(path: string): string | null;
/** Element for an image name, file path or colour. */ /** Element for an image name, file path or colour. */
@@ -1690,7 +1691,7 @@ export class ScreenManager {
if (isNativeAction(a)) return a.get_sensitive(); if (isNativeAction(a)) return a.get_sensitive();
if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true); if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true);
return !(a instanceof ScreenAction) || 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.Image = shape("image");
out.ImageReference = shape("image"); out.ImageReference = shape("image");
out.Gallery = galleryClass({ 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: {} })), 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)), evaluate: (source) => this.host.py.eval(parseExpression(source)),
show: (pictures, transition) => this.showGallery(pictures, transition), show: (pictures, transition) => this.showGallery(pictures, transition),
+1
View File
@@ -148,6 +148,7 @@ export interface GameData {
} }
export interface Manifest { export interface Manifest {
galleryUnlocked?: boolean;
versionId?: number; versionId?: number;
revision?: string; revision?: string;
format: 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; } .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-metadata-row { align-items: flex-start; }
.game-gallery { min-width: 0; margin-block: var(--space-3); } .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-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-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; } .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")}`); 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. 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 { window, document } = parseHTML("<!doctype html><html><body></body></html>");
const storage = new Map(); const storage = new Map();
Object.assign(globalThis, { 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 tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
const root = document.createElement("div"); const root = document.createElement("div");
document.body.append(root); 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 () => {} }, { list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
"runtime-test", () => {}, tr, async () => tr); "runtime-test", () => {}, tr, async () => tr);
return { engine, root, window, document }; 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));
}
});