feat: unlock in-game galleries per owned version
This commit is contained in:
1 parent
e14184435c
commit
19924e1c5a
21 files changed
+977
-395
No files matched your search
@@ -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", []))
|
||||
@@ -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)
|
||||
|
||||
@@ -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]}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -68,6 +68,8 @@ class OwnerOut(ORM):
|
||||
|
||||
|
||||
class VersionOut(ORM):
|
||||
gallery_unlocked: bool = False
|
||||
gallery_available: bool = False
|
||||
revision_id: str | None = None
|
||||
base_revision_id: str | None = None
|
||||
id: int
|
||||
@@ -141,6 +143,10 @@ class PublishIn(BaseModel):
|
||||
make_main: bool = False
|
||||
|
||||
|
||||
class GallerySettingsIn(BaseModel):
|
||||
gallery_unlocked: bool
|
||||
|
||||
|
||||
class HistoryOut(ORM):
|
||||
id: int
|
||||
note: str
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Opt-in unlocked in-game galleries, scoped to one version."""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("game_versions", sa.Column("gallery_unlocked", sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("game_versions", "gallery_unlocked")
|
||||
@@ -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
|
||||
@@ -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}`.
|
||||
Менять её может только владелец версии, в том числе если другие пользователи
|
||||
имеют административные права на управление версиями.
|
||||
@@ -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;
|
||||
|
||||
@@ -167,6 +167,9 @@ export function GamePage() {
|
||||
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
|
||||
(byParent.get(parent) ?? []).flatMap((v) => [
|
||||
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
|
||||
isOwner={!!me && v.owner?.id === me.id}
|
||||
onGallery={(enabled) => run(() => api.patch(`/api/versions/${v.id}/gallery`, { gallery_unlocked: enabled }))}
|
||||
onOpenGallery={() => navigate(`/play/${v.id}?gallery=1`)}
|
||||
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
|
||||
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
|
||||
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
|
||||
@@ -246,12 +249,14 @@ export function GamePage() {
|
||||
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
|
||||
|
||||
function VersionRow(props: {
|
||||
isOwner: boolean; onGallery: (enabled: boolean) => Promise<void>; onOpenGallery: () => void;
|
||||
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
|
||||
canTranslate: boolean; canPublish: boolean;
|
||||
onPlay: () => void; onPlayLang: (code: string) => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
|
||||
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
|
||||
}) {
|
||||
const { v } = props;
|
||||
const [pendingGallery, setPendingGallery] = useState<boolean | null>(null);
|
||||
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
|
||||
const toggleReport = async () => {
|
||||
if (report !== undefined) setReport(undefined);
|
||||
@@ -268,6 +273,7 @@ function VersionRow(props: {
|
||||
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
|
||||
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
|
||||
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
|
||||
{v.gallery_unlocked && <span className="badge badge-warn">Галерея открыта</span>}
|
||||
<StatusBadge status={v.status} />
|
||||
</div>
|
||||
<div className="muted small version-meta">
|
||||
@@ -277,6 +283,7 @@ function VersionRow(props: {
|
||||
</div>
|
||||
<div className="actions">
|
||||
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
|
||||
{v.status === "ready" && v.gallery_unlocked && <button className="btn btn-sm" onClick={props.onOpenGallery}>Открыть галерею</button>}
|
||||
{v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />}
|
||||
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
|
||||
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
|
||||
@@ -305,6 +312,15 @@ function VersionRow(props: {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{v.status === "ready" && props.isOwner && v.gallery_available && (
|
||||
<label className="version-gallery-setting small">
|
||||
<input type="checkbox" checked={pendingGallery ?? !!v.gallery_unlocked} disabled={pendingGallery !== null} onChange={(event) => {
|
||||
setPendingGallery(event.target.checked);
|
||||
void props.onGallery(event.target.checked).finally(() => setPendingGallery(null));
|
||||
}} />
|
||||
Открыть всю галерею в этой версии
|
||||
</label>
|
||||
)}
|
||||
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
|
||||
{report !== undefined && <Report report={report} />}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { AudioSystem } from "./audio";
|
||||
import { cutscene } from "./cutscene";
|
||||
import { galleryScreens, isMistGallery, replayEntries } from "./gallery-access";
|
||||
import { VolumeStore } from "./volume-store";
|
||||
import { LiveImages } from "../imager/live";
|
||||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||
import { BlockingCall, JumpSignal, PyDict, PyInstance, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { cpsPoints, fastPoint, interpolate, parseText, plainText, renderText, TEXT_DISPLAYABLE, TEXT_PARAGRAPH, TEXT_TAG, TEXT_TEXT, textHooks, waitPoints } from "./text";
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
@@ -77,6 +78,12 @@ export class Engine {
|
||||
private store: Record<string, any> = {};
|
||||
private builtins: Record<string, any> = {};
|
||||
private persistent: Record<string, any>;
|
||||
private gallerySession = false;
|
||||
private galleryStash: SaveData | null = null;
|
||||
private galleryPersistent: Record<string, any> | null = null;
|
||||
private gallerySeen: Set<string> | null = null;
|
||||
private galleryHistory: { name: string; text: string }[] | null = null;
|
||||
private galleryRollback: { snap: SaveData; history: { name: string; text: string }[] }[] | null = null;
|
||||
private py: Python;
|
||||
private renderer: Renderer;
|
||||
private audio: AudioSystem;
|
||||
@@ -177,7 +184,7 @@ export class Engine {
|
||||
del: (n) => void delete this.store[n],
|
||||
};
|
||||
this.py = new Python(scope);
|
||||
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
|
||||
this.screens = new ScreenManager(this.screenHost(), galleryScreens(game, !!manifest.galleryUnlocked), game.styles ?? {});
|
||||
this.py.blockingNames = new Set([
|
||||
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
|
||||
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
|
||||
@@ -402,11 +409,14 @@ export class Engine {
|
||||
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
|
||||
const save = () => saveJSON(this.persistentKey, encodeValue(data));
|
||||
return new Proxy(data, {
|
||||
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined
|
||||
: k === "_clear" ? () => { for (const key of Object.keys(t)) delete t[key]; save(); return null; } : null),
|
||||
get: (t, k: string) => {
|
||||
const current = this.galleryPersistent ?? t;
|
||||
return k in current ? current[k] : k === "toJSON" ? undefined
|
||||
: k === "_clear" ? () => { for (const key of Object.keys(current)) delete current[key]; if (!this.gallerySession) save(); return null; } : null;
|
||||
},
|
||||
set: (t, k: string, v) => {
|
||||
t[k] = v;
|
||||
save();
|
||||
(this.galleryPersistent ?? t)[k] = v;
|
||||
if (!this.gallerySession) save();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -1176,6 +1186,7 @@ export class Engine {
|
||||
private screenHost(): ScreenHost {
|
||||
const engine = this;
|
||||
return {
|
||||
galleryUnlocked: !!this.manifest.galleryUnlocked,
|
||||
error: (message) => this.fail(new Error(message)),
|
||||
get audio() { return engine.audio; },
|
||||
setMixer: (name, value) => this.setMixerValue(name, value),
|
||||
@@ -1188,10 +1199,14 @@ export class Engine {
|
||||
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
|
||||
stop: (channel) => this.audio.stop(channel, 0),
|
||||
notify: (message) => this.notify(message),
|
||||
jump: (label) => this.restart(() => this.guardedJump(label)),
|
||||
jump: (label) => {
|
||||
if (this.gallerySession && isMistGallery(this.game) && label === "myroomcontrol" && !truthy(this.store.replaywatch)) {
|
||||
this.closeUnlockedGallery();
|
||||
} else this.restart(() => this.guardedJump(label));
|
||||
},
|
||||
call: (label) => this.restart(() => this.call(label)),
|
||||
quit: () => this.onExit(),
|
||||
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
|
||||
mainMenu: () => this.gallerySession ? this.closeUnlockedGallery() : this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
|
||||
openMenu: () => this.openGameMenu(),
|
||||
screensOn: (layer) => this.renderer.screensOn(layer),
|
||||
rollback: () => this.rollback(),
|
||||
@@ -1464,6 +1479,10 @@ export class Engine {
|
||||
}
|
||||
|
||||
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
|
||||
if (this.gallerySession && isMistGallery(this.game) && target === "myroomcontrol") {
|
||||
if (truthy(this.store.replaywatch)) target = "rewatchscene";
|
||||
else { this.closeUnlockedGallery(); return; }
|
||||
}
|
||||
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
|
||||
if (target === "__main_menu__" || target === "__quit__") {
|
||||
throw new Restart(target);
|
||||
@@ -2223,6 +2242,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private async save(slot: string, silent = false) {
|
||||
if (this.gallerySession) return;
|
||||
if (!this.running || !this.cur.label) return;
|
||||
const data = this.snapshotState();
|
||||
const title = (data.text ?? "").slice(0, 120) || this.cur.label;
|
||||
@@ -2235,6 +2255,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private async load(slot: string) {
|
||||
if (this.gallerySession) { this.notify("Закройте галерею, чтобы загрузить сохранение"); return; }
|
||||
const data = await this.saves.get(slot);
|
||||
if (!data) {
|
||||
this.notify("Сохранение не найдено");
|
||||
@@ -2287,6 +2308,88 @@ export class Engine {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/** Open an isolated gallery session. Unlocks never turn into story progress or saves. */
|
||||
openUnlockedGallery() {
|
||||
if (!this.manifest.galleryUnlocked || this.gallerySession) return;
|
||||
this.galleryStash = this.running && this.cur.label ? this.snapshotState() : null;
|
||||
this.galleryPersistent = decodeValue(encodeValue(this.persistent));
|
||||
this.gallerySeen = this.seenImages;
|
||||
this.seenImages = new Set(this.seenImages);
|
||||
this.galleryHistory = this.history;
|
||||
this.galleryRollback = this.rollbackLog;
|
||||
this.history = [];
|
||||
this.rollbackLog = [];
|
||||
this.gallerySession = true;
|
||||
this.gen++;
|
||||
this.wakeAll();
|
||||
this.onAdvance = null;
|
||||
this.skipTick = null;
|
||||
this.running = false;
|
||||
this.setSkip(false);
|
||||
this.audio.stopAll();
|
||||
this.renderer.reset();
|
||||
this.newStore();
|
||||
this.stack = [];
|
||||
this.cur = { label: "", path: [0] };
|
||||
this.textbox.classList.add("pg-hidden");
|
||||
this.menuEl.classList.add("pg-hidden");
|
||||
this.clearNvl();
|
||||
if (isMistGallery(this.game) && this.labels.has("rewatchscene")) {
|
||||
this.restart(() => this.guardedJump("rewatchscene"));
|
||||
} else this.showUnlockedGalleryIndex();
|
||||
}
|
||||
|
||||
private closeUnlockedGallery() {
|
||||
const stash = this.galleryStash;
|
||||
const history = this.galleryHistory ?? [];
|
||||
const rollback = this.galleryRollback ?? [];
|
||||
this.galleryHistory = null;
|
||||
this.galleryRollback = null;
|
||||
this.gallerySession = false;
|
||||
this.galleryPersistent = null;
|
||||
this.galleryStash = null;
|
||||
if (this.gallerySeen) this.seenImages = this.gallerySeen;
|
||||
this.gallerySeen = null;
|
||||
this.replayStash = null;
|
||||
this.screens.clear();
|
||||
if (stash) this.restart(() => {
|
||||
this.applySave(stash);
|
||||
this.history = history;
|
||||
this.rollbackLog = rollback;
|
||||
});
|
||||
else {
|
||||
this.stack = [];
|
||||
this.cur = { label: "", path: [0] };
|
||||
this.showMainMenu();
|
||||
}
|
||||
}
|
||||
|
||||
private showUnlockedGalleryIndex() {
|
||||
this.running = false;
|
||||
this.openScreen("Галерея открыта", (body) => {
|
||||
body.classList.add("pg-gallery-list");
|
||||
let count = 0;
|
||||
const add = (name: string, action: any) => {
|
||||
if (!action) return;
|
||||
count++;
|
||||
const button = el("button", "", name);
|
||||
button.onclick = () => this.screens.run(action, null);
|
||||
body.append(button);
|
||||
};
|
||||
for (const [name, collection] of Object.entries(this.store)) {
|
||||
if (!(collection instanceof PyInstance) || collection.cls.name !== "Gallery" || !(collection.attrs.buttons instanceof PyDict)) continue;
|
||||
body.append(el("h3", "", name));
|
||||
for (const key of collection.attrs.buttons.map.keys()) {
|
||||
add(pyStr(key), this.py.attrOf(collection, "Action")(key));
|
||||
}
|
||||
}
|
||||
for (const entry of replayEntries(this.game.screens)) {
|
||||
if (this.labels.has(entry.label) || this.anchors.has(entry.label)) add(entry.label, this.safeEval(entry.expr));
|
||||
}
|
||||
if (!count) body.append(el("p", "", "В этой игре не найдены доступные сцены галереи."));
|
||||
}, () => this.closeUnlockedGallery());
|
||||
}
|
||||
|
||||
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
|
||||
private startReplay(label: string, scope: Record<string, any> | null) {
|
||||
if (!this.labels.has(label)) {
|
||||
@@ -2545,6 +2648,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private showMainMenu() {
|
||||
if (this.gallerySession) { this.showUnlockedGalleryIndex(); return; }
|
||||
this.placeScreen(false);
|
||||
this.running = false;
|
||||
this.gen++;
|
||||
@@ -2567,6 +2671,7 @@ export class Engine {
|
||||
return b;
|
||||
};
|
||||
btn("Начать", () => this.newGame());
|
||||
if (this.manifest.galleryUnlocked) btn("Галерея открыта", () => this.openUnlockedGallery());
|
||||
const cont = btn("Продолжить", () => void this.load("auto"));
|
||||
cont.style.display = "none";
|
||||
this.saves.list().then((list) => {
|
||||
@@ -2610,6 +2715,8 @@ export class Engine {
|
||||
list.append(b);
|
||||
};
|
||||
add("Вернуться", () => this.closeScreen());
|
||||
if (this.gallerySession) add("Закрыть галерею", () => this.closeUnlockedGallery());
|
||||
else if (this.manifest.galleryUnlocked) add("Открыть галерею", () => this.openUnlockedGallery());
|
||||
add("История", () => this.openHistory());
|
||||
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
|
||||
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
|
||||
@@ -2640,6 +2747,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private openSaves(mode: "save" | "load", fromMain = false) {
|
||||
if (this.gallerySession) { this.notify("Закройте галерею, чтобы открыть сохранения"); return; }
|
||||
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
|
||||
const grid = el("div", "pg-slots");
|
||||
body.append(grid);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -56,7 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
(code) => loadTranslator(game, manifest, code),
|
||||
gameId === null ? `pgvn:audio:version:${versionId}` : `pgvn:audio:game:${gameId}`);
|
||||
const at = url.get("at");
|
||||
if (at) engine.startAt(at);
|
||||
if (url.get("gallery") === "1" && manifest.galleryUnlocked) engine.openUnlockedGallery();
|
||||
else if (at) engine.startAt(at);
|
||||
else engine.start();
|
||||
return engine;
|
||||
}
|
||||
+378
-378
@@ -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); } }
|
||||
@@ -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),
|
||||
|
||||
@@ -148,6 +148,7 @@ export interface GameData {
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
galleryUnlocked?: boolean;
|
||||
versionId?: number;
|
||||
revision?: string;
|
||||
format: string;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const bundle = await build({ entryPoints: [fileURLToPath(new URL("../../src/play
|
||||
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
|
||||
Error.stackTraceLimit = 2; // data-URL bundle frames otherwise print the entire bundle on a failure.
|
||||
|
||||
export function makeEngine(runtime) {
|
||||
export function makeEngine(runtime, manifest = {}) {
|
||||
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||
const storage = new Map();
|
||||
Object.assign(globalThis, {
|
||||
@@ -19,7 +19,7 @@ export function makeEngine(runtime) {
|
||||
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
const engine = new Engine(root, structuredClone(runtime.game), { files: {} }, structuredClone(runtime.modules),
|
||||
const engine = new Engine(root, structuredClone(runtime.game), { files: {}, ...manifest }, structuredClone(runtime.modules),
|
||||
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||
"runtime-test", () => {}, tr, async () => tr);
|
||||
return { engine, root, window, document };
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
test("the owner can toggle the gallery, everyone sees its badge and direct entry", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
import {createElement as h} from 'react'; import {createRoot} from 'react-dom/client';
|
||||
import {MemoryRouter, Routes, Route, useLocation} from 'react-router-dom';
|
||||
import {AuthProvider} from './auth'; import {UiProvider} from './ui/Ui';
|
||||
import {TranslationRunProvider} from './translator/runner'; import {UploadProvider} from './uploads';
|
||||
import {GamePage} from './pages/GamePage';
|
||||
function Destination() {const at = useLocation(); return h('output', null, at.pathname + at.search);}
|
||||
createRoot(document.querySelector('main')).render(h(MemoryRouter, {initialEntries: ['/games/1']},
|
||||
h(UiProvider, null, h(AuthProvider, null, h(TranslationRunProvider, null, h(UploadProvider, null,
|
||||
h(Routes, null, h(Route, {path: '/games/:gameId', element: h(GamePage)}),
|
||||
h(Route, {path: '/play/:versionId', element: h(Destination)}))))))));`,
|
||||
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
|
||||
const version = { id: 1, game_id: 1, parent_id: null, owner: { id: 7, username: "author" }, name: "Моя версия",
|
||||
status: "ready", kind: "mod", visibility: "draft", created_at: new Date().toISOString(), size_bytes: 1,
|
||||
gallery_available: true, gallery_unlocked: false, languages: [] };
|
||||
let userId = 7, fail = false;
|
||||
const writes = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
|
||||
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
if (req.url === "/api/auth/me") { res.end(JSON.stringify({ id: userId, username: "author", permissions: ["games.play"] })); return; }
|
||||
if (req.url === "/api/games/1") { res.end(JSON.stringify({ id: 1, title: "MIST", tags: [], gallery_hashes: [], versions: [version] })); return; }
|
||||
if (req.url === "/api/versions/1/gallery" && req.method === "PATCH") {
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
const settings = JSON.parse(body);
|
||||
writes.push(settings);
|
||||
if (fail) { res.statusCode = 500; res.end(JSON.stringify({ detail: "Не удалось сохранить настройку" })); return; }
|
||||
version.gallery_unlocked = settings.gallery_unlocked;
|
||||
res.end(JSON.stringify(version)); return;
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
|
||||
page.setDefaultTimeout(5000);
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
await page.goto(origin);
|
||||
const checkbox = page.getByRole("checkbox", { name: "Открыть всю галерею в этой версии" });
|
||||
await checkbox.check();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Открыть галерею", exact: true }).click();
|
||||
await page.getByText("/play/1?gallery=1", { exact: true }).waitFor();
|
||||
await page.reload();
|
||||
await checkbox.waitFor();
|
||||
assert.equal(await checkbox.isChecked(), true);
|
||||
await checkbox.uncheck();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor({ state: "detached" });
|
||||
fail = true;
|
||||
await checkbox.click();
|
||||
await page.getByText("Не удалось сохранить настройку", { exact: true }).waitFor();
|
||||
assert.equal(await checkbox.isChecked(), false);
|
||||
assert.deepEqual(writes, [{ gallery_unlocked: true }, { gallery_unlocked: false }, { gallery_unlocked: true }]);
|
||||
version.gallery_unlocked = true;
|
||||
userId = 8;
|
||||
await page.reload();
|
||||
await page.getByText("Галерея открыта", { exact: true }).waitFor();
|
||||
assert.equal(await checkbox.count(), 0);
|
||||
await page.setViewportSize({ width: 375, height: 800 });
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("the unlocked Gallery viewer opens above its index and closes back to the main menu", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
import {Engine} from './player/engine'; import {parseStatements} from './player/pyparse';
|
||||
const game = {resolution: [640, 480], entry: 'start', title: 'Gallery test', ui: {}, files: {}, defaults: [],
|
||||
init: parseStatements('g = Gallery()\\ng.button("Art")\\ng.image("picture")\\ng.condition("False")'),
|
||||
characters: {}, channels: {}, images: {picture: {color: '#ff00ff'}}, transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {}};
|
||||
const tr = {lang: 'en', renpyName: null, str: s => s, line: (_, s) => s};
|
||||
const saves = {list: async () => [], get: async () => null, put: async () => {}, remove: async () => {}};
|
||||
const engine = new Engine(document.querySelector('main'), game, {files: {}, galleryUnlocked: true},
|
||||
[{labels: [{name: 'start', body: [{op: 'return'}]}], anchors: {}}], saves, 'gallery-browser', () => {}, tr, async () => tr);
|
||||
engine.start();`, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||
const css = readFileSync(new URL("../src/player/player.css", import.meta.url), "utf8");
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/bundle.js") { res.setHeader("Content-Type", "text/javascript"); res.end(compiled.outputFiles[0].text); return; }
|
||||
if (req.url === "/style.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/style.css"><style>main{width:800px;height:600px}</style><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, channel: process.env.PGAMES_BROWSER_CHANNEL });
|
||||
const page = await browser.newPage({ viewport: { width: 1000, height: 800 } });
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||
await page.getByRole("button", { name: "Галерея открыта", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Art", exact: true }).click();
|
||||
await page.locator(".pg-gallery").waitFor();
|
||||
const top = await page.locator(".pg-gallery").evaluate((overlay) => {
|
||||
const box = overlay.getBoundingClientRect();
|
||||
return !!document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)?.closest(".pg-gallery");
|
||||
});
|
||||
assert.equal(top, true, "the gallery image is above the index and receives input");
|
||||
await page.locator(".pg-gallery").getByRole("button", { name: "Закрыть", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Art", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Закрыть", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Начать", exact: true }).waitFor();
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user