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

+116 -8
View File
@@ -2,9 +2,10 @@
import { AudioSystem } from "./audio";
import { cutscene } from "./cutscene";
import { galleryScreens, isMistGallery, replayEntries } from "./gallery-access";
import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { BlockingCall, JumpSignal, PyDict, PyInstance, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer";
import { cpsPoints, fastPoint, interpolate, parseText, plainText, renderText, TEXT_DISPLAYABLE, TEXT_PARAGRAPH, TEXT_TAG, TEXT_TEXT, textHooks, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
@@ -77,6 +78,12 @@ export class Engine {
private store: Record<string, any> = {};
private builtins: Record<string, any> = {};
private persistent: Record<string, any>;
private gallerySession = false;
private galleryStash: SaveData | null = null;
private galleryPersistent: Record<string, any> | null = null;
private gallerySeen: Set<string> | null = null;
private galleryHistory: { name: string; text: string }[] | null = null;
private galleryRollback: { snap: SaveData; history: { name: string; text: string }[] }[] | null = null;
private py: Python;
private renderer: Renderer;
private audio: AudioSystem;
@@ -177,7 +184,7 @@ export class Engine {
del: (n) => void delete this.store[n],
};
this.py = new Python(scope);
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
this.screens = new ScreenManager(this.screenHost(), galleryScreens(game, !!manifest.galleryUnlocked), game.styles ?? {});
this.py.blockingNames = new Set([
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
@@ -402,11 +409,14 @@ export class Engine {
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
const save = () => saveJSON(this.persistentKey, encodeValue(data));
return new Proxy(data, {
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined
: k === "_clear" ? () => { for (const key of Object.keys(t)) delete t[key]; save(); return null; } : null),
get: (t, k: string) => {
const current = this.galleryPersistent ?? t;
return k in current ? current[k] : k === "toJSON" ? undefined
: k === "_clear" ? () => { for (const key of Object.keys(current)) delete current[key]; if (!this.gallerySession) save(); return null; } : null;
},
set: (t, k: string, v) => {
t[k] = v;
save();
(this.galleryPersistent ?? t)[k] = v;
if (!this.gallerySession) save();
return true;
},
});
@@ -1176,6 +1186,7 @@ export class Engine {
private screenHost(): ScreenHost {
const engine = this;
return {
galleryUnlocked: !!this.manifest.galleryUnlocked,
error: (message) => this.fail(new Error(message)),
get audio() { return engine.audio; },
setMixer: (name, value) => this.setMixerValue(name, value),
@@ -1188,10 +1199,14 @@ export class Engine {
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
stop: (channel) => this.audio.stop(channel, 0),
notify: (message) => this.notify(message),
jump: (label) => this.restart(() => this.guardedJump(label)),
jump: (label) => {
if (this.gallerySession && isMistGallery(this.game) && label === "myroomcontrol" && !truthy(this.store.replaywatch)) {
this.closeUnlockedGallery();
} else this.restart(() => this.guardedJump(label));
},
call: (label) => this.restart(() => this.call(label)),
quit: () => this.onExit(),
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
mainMenu: () => this.gallerySession ? this.closeUnlockedGallery() : this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
openMenu: () => this.openGameMenu(),
screensOn: (layer) => this.renderer.screensOn(layer),
rollback: () => this.rollback(),
@@ -1464,6 +1479,10 @@ export class Engine {
}
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
if (this.gallerySession && isMistGallery(this.game) && target === "myroomcontrol") {
if (truthy(this.store.replaywatch)) target = "rewatchscene";
else { this.closeUnlockedGallery(); return; }
}
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
if (target === "__main_menu__" || target === "__quit__") {
throw new Restart(target);
@@ -2223,6 +2242,7 @@ export class Engine {
}
private async save(slot: string, silent = false) {
if (this.gallerySession) return;
if (!this.running || !this.cur.label) return;
const data = this.snapshotState();
const title = (data.text ?? "").slice(0, 120) || this.cur.label;
@@ -2235,6 +2255,7 @@ export class Engine {
}
private async load(slot: string) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы загрузить сохранение"); return; }
const data = await this.saves.get(slot);
if (!data) {
this.notify("Сохранение не найдено");
@@ -2287,6 +2308,88 @@ export class Engine {
this.history = [];
}
/** Open an isolated gallery session. Unlocks never turn into story progress or saves. */
openUnlockedGallery() {
if (!this.manifest.galleryUnlocked || this.gallerySession) return;
this.galleryStash = this.running && this.cur.label ? this.snapshotState() : null;
this.galleryPersistent = decodeValue(encodeValue(this.persistent));
this.gallerySeen = this.seenImages;
this.seenImages = new Set(this.seenImages);
this.galleryHistory = this.history;
this.galleryRollback = this.rollbackLog;
this.history = [];
this.rollbackLog = [];
this.gallerySession = true;
this.gen++;
this.wakeAll();
this.onAdvance = null;
this.skipTick = null;
this.running = false;
this.setSkip(false);
this.audio.stopAll();
this.renderer.reset();
this.newStore();
this.stack = [];
this.cur = { label: "", path: [0] };
this.textbox.classList.add("pg-hidden");
this.menuEl.classList.add("pg-hidden");
this.clearNvl();
if (isMistGallery(this.game) && this.labels.has("rewatchscene")) {
this.restart(() => this.guardedJump("rewatchscene"));
} else this.showUnlockedGalleryIndex();
}
private closeUnlockedGallery() {
const stash = this.galleryStash;
const history = this.galleryHistory ?? [];
const rollback = this.galleryRollback ?? [];
this.galleryHistory = null;
this.galleryRollback = null;
this.gallerySession = false;
this.galleryPersistent = null;
this.galleryStash = null;
if (this.gallerySeen) this.seenImages = this.gallerySeen;
this.gallerySeen = null;
this.replayStash = null;
this.screens.clear();
if (stash) this.restart(() => {
this.applySave(stash);
this.history = history;
this.rollbackLog = rollback;
});
else {
this.stack = [];
this.cur = { label: "", path: [0] };
this.showMainMenu();
}
}
private showUnlockedGalleryIndex() {
this.running = false;
this.openScreen("Галерея открыта", (body) => {
body.classList.add("pg-gallery-list");
let count = 0;
const add = (name: string, action: any) => {
if (!action) return;
count++;
const button = el("button", "", name);
button.onclick = () => this.screens.run(action, null);
body.append(button);
};
for (const [name, collection] of Object.entries(this.store)) {
if (!(collection instanceof PyInstance) || collection.cls.name !== "Gallery" || !(collection.attrs.buttons instanceof PyDict)) continue;
body.append(el("h3", "", name));
for (const key of collection.attrs.buttons.map.keys()) {
add(pyStr(key), this.py.attrOf(collection, "Action")(key));
}
}
for (const entry of replayEntries(this.game.screens)) {
if (this.labels.has(entry.label) || this.anchors.has(entry.label)) add(entry.label, this.safeEval(entry.expr));
}
if (!count) body.append(el("p", "", "В этой игре не найдены доступные сцены галереи."));
}, () => this.closeUnlockedGallery());
}
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
private startReplay(label: string, scope: Record<string, any> | null) {
if (!this.labels.has(label)) {
@@ -2545,6 +2648,7 @@ export class Engine {
}
private showMainMenu() {
if (this.gallerySession) { this.showUnlockedGalleryIndex(); return; }
this.placeScreen(false);
this.running = false;
this.gen++;
@@ -2567,6 +2671,7 @@ export class Engine {
return b;
};
btn("Начать", () => this.newGame());
if (this.manifest.galleryUnlocked) btn("Галерея открыта", () => this.openUnlockedGallery());
const cont = btn("Продолжить", () => void this.load("auto"));
cont.style.display = "none";
this.saves.list().then((list) => {
@@ -2610,6 +2715,8 @@ export class Engine {
list.append(b);
};
add("Вернуться", () => this.closeScreen());
if (this.gallerySession) add("Закрыть галерею", () => this.closeUnlockedGallery());
else if (this.manifest.galleryUnlocked) add("Открыть галерею", () => this.openUnlockedGallery());
add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
@@ -2640,6 +2747,7 @@ export class Engine {
}
private openSaves(mode: "save" | "load", fromMain = false) {
if (this.gallerySession) { this.notify("Закройте галерею, чтобы открыть сохранения"); return; }
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
const grid = el("div", "pg-slots");
body.append(grid);
+34
View File
@@ -0,0 +1,34 @@
import type { GameData, Expr } from "./types";
export const isMistGallery = (game: GameData) => game.build?.name === "MIST" && !!game.screens?.rewatchgirlselectscreen;
/** Only the gallery's view of progress changes. The underlying quest/scene objects stay intact. */
export function galleryScreens(game: GameData, unlocked: boolean) {
const screens = game.screens ?? {};
if (!unlocked || !isMistGallery(game)) return screens;
const names = new Set(["rewatchgirlselectscreen", "galleryheader", "robgallery", "miagallery", "lilgallery", "norgallery", "groupgallery"]);
const flags = new Set(["rscene02watched", ...Array.from({ length: 7 }, (_, i) => `groupscene0${i + 1}watched`)]);
const visit = (value: any): any => {
if (Array.isArray(value)) return value.map(visit);
if (!value || typeof value !== "object") return value;
if (value.k === "name" && flags.has(value.id)) return { k: "const", v: true };
if (value.k === "attr" && ["watched", "is_done"].includes(value.a)) return { k: "const", v: true };
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
};
return Object.fromEntries(Object.entries(screens).map(([name, screen]) => [name, names.has(name) ? visit(screen) : screen]));
}
/** Standard Replay actions can be offered directly, even when their original UI is locked. */
export function replayEntries(screens: GameData["screens"]): { label: string; expr: Expr }[] {
const found = new Map<string, Expr>();
const visit = (value: any) => {
if (!value || typeof value !== "object") return;
if (value.k === "call" && value.f?.k === "name" && value.f.id === "Replay") {
const label = value.a?.[0] ?? value.kw?.label;
if (label?.k === "const" && typeof label.v === "string") found.set(label.v, value);
}
for (const item of Object.values(value)) visit(item);
};
visit(screens);
return [...found].map(([label, expr]) => ({ label, expr }));
}
+2 -1
View File
@@ -6,6 +6,7 @@ interface Picture { displayables: any[]; conditions: Condition[] }
interface Button { pictures: Picture[]; conditions: Condition[] }
export function galleryClass(host: {
unlocked?(): boolean;
seen(name: string): boolean;
evaluate(source: string): any;
show(pictures: any[][], transition: any): void;
@@ -14,7 +15,7 @@ export function galleryClass(host: {
const gallery = new PyClass("Gallery", [OBJECT]);
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) =>
gallery.attrs.set(name, nativeMethod(fn));
const conditions = (items: Condition[]) => items.every((c) => c.kind === "seen"
const conditions = (items: Condition[]) => host.unlocked?.() || items.every((c) => c.kind === "seen"
? c.images.every((name) => host.seen(name)) : truthy(host.evaluate(c.source)));
const get = (self: PyInstance, name: string): Button => {
const buttons = self.attrs.buttons as PyDict;
+2 -1
View File
@@ -56,7 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
(code) => loadTranslator(game, manifest, code),
gameId === null ? `pgvn:audio:version:${versionId}` : `pgvn:audio:game:${gameId}`);
const at = url.get("at");
if (at) engine.startAt(at);
if (url.get("gallery") === "1" && manifest.galleryUnlocked) engine.openUnlockedGallery();
else if (at) engine.startAt(at);
else engine.start();
return engine;
}
+378 -378
View File
@@ -1,378 +1,378 @@
.pg-root {
position: relative;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
user-select: none;
}
.pg-stage {
--pg-accent: #ff9f43;
--pg-text: #ffffff;
position: absolute;
left: 50%;
top: 50%;
overflow: hidden;
background: #000;
color: var(--pg-text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: var(--pg-font);
transform-origin: center;
cursor: default;
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
.pg-fill { position: absolute; inset: 0; }
.pg-image { position: absolute; will-change: transform, opacity; }
.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; }
.pg-hidden { display: none !important; }
.pg-textbox {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92));
border-top: 2px solid rgba(255, 255, 255, 0.12);
}
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
.pg-textbox.pg-ui-hidden { opacity: 0; }
.pg-namebox {
position: absolute;
top: 0;
transform: translateY(-50%);
padding: 0.15em 0.8em;
font-size: var(--pg-name-font);
font-weight: 600;
color: var(--pg-accent);
background: rgba(10, 12, 24, 0.85);
border-radius: 0.3em;
text-shadow: 0 1px 2px #000;
}
.pg-textbox-image .pg-namebox { background: transparent; }
.pg-text {
position: absolute;
line-height: 1.35;
white-space: pre-wrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; }
@keyframes pg-blink { 50% { opacity: 0.1; } }
.pg-quick {
position: absolute;
bottom: 0.35em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.15em;
padding: 0.1em 0.3em;
border-radius: 99em;
background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px);
opacity: 0.55;
transition: opacity 0.2s;
}
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button {
background: none;
border: 0;
border-radius: 99em;
color: rgba(255, 255, 255, 0.8);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.25em 0.75em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); }
.pg-menu {
position: absolute;
inset: 0 0 26% 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.pg-choice {
min-width: 50%;
max-width: 75%;
padding: 0.45em 1.2em;
font: inherit;
color: #fff;
background: rgba(15, 18, 35, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.3em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
.pg-nvl {
position: absolute;
inset: 0;
padding: 5% 15%;
background: rgba(0, 0, 0, 0.72) center / cover;
display: flex;
flex-direction: column;
gap: 0.6em;
overflow: hidden;
}
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
.pg-nvl-what { white-space: pre-wrap; }
.pg-centered {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 15%;
white-space: pre-wrap;
text-shadow: 0 2px 4px #000;
}
.pg-notify {
position: absolute;
top: 2%;
left: 2%;
padding: 0.4em 1em;
font-size: calc(var(--pg-font) * 0.7);
background: rgba(0, 0, 0, 0.8);
border-left: 4px solid var(--pg-accent);
}
.pg-screen {
position: absolute;
inset: 0;
background: rgba(8, 10, 20, 0.88) center / cover;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
animation: pg-fade 0.15s ease-out;
}
@keyframes pg-fade { from { opacity: 0; } }
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; }
.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; }
.pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.5em;
padding: 0.5em 1.1em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
outline: 3px solid var(--pg-accent);
outline-offset: 2px;
}
.pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; }
.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; }
.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); }
.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; }
.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; }
.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; }
.pg-slot b { color: var(--pg-accent); font-weight: 600; }
.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-settings-screen {
--pg-accent: #ff9f43;
--pg-font: 18px;
z-index: 25;
padding: clamp(16px, 4%, 40px);
color: #edf0f7;
font: 16px/1.5 "Segoe UI", system-ui, sans-serif;
background: rgba(11, 14, 23, 0.96);
container-type: inline-size;
color-scheme: dark;
}
.pg-settings-screen .pg-screen-head,
.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; }
.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; }
.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; }
.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; }
.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; }
.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; }
.pg-settings-card:first-child { grid-row: span 2; }
.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; }
.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.pg-setting:last-child { margin-bottom: 0; }
.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pg-setting input[type="range"] {
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
cursor: pointer;
accent-color: var(--pg-accent);
}
.pg-setting input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 99px;
background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress));
}
.pg-setting input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border: 3px solid #191e2a;
border-radius: 50%;
background: var(--pg-accent);
box-shadow: 0 0 0 1px var(--pg-accent);
}
.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; }
.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); }
.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); }
.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; }
.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; }
.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; }
@container (max-width: 620px) {
.pg-settings { grid-template-columns: minmax(0, 1fr); }
.pg-settings-card:first-child { grid-row: auto; }
.pg-settings-card { padding: 18px; }
}
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
.pg-screen.pg-main { background-color: #000; justify-content: center; }
.pg-main-panel {
display: flex;
flex-direction: column;
gap: 0.55em;
width: min(26em, 100%);
padding: 2em;
background: rgba(8, 10, 20, 0.72);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; }
.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; }
.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
/* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn {
position: absolute;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
z-index: 20;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
line-height: 1;
color: #fff;
background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
}
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; }
}
/* in-player confirmation */
.pg-dialog-back {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif;
}
.pg-dialog {
width: min(420px, 100%);
padding: 22px;
color: #eceef5;
background: #171b25;
border: 1px solid #2a3042;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px;
}
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; }
.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); }
.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; }
.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; }
.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; }
.pg-sviewport { scrollbar-width: thin; }
/* --- renpy.input / renpy.display_menu ----------------------------------------------- */
.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; }
.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; }
.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; }
/* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */
.pg-fx { display: inline-block; white-space: pre; }
/* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
.pg-root {
position: relative;
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
user-select: none;
}
.pg-stage {
--pg-accent: #ff9f43;
--pg-text: #ffffff;
position: absolute;
left: 50%;
top: 50%;
overflow: hidden;
background: #000;
color: var(--pg-text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: var(--pg-font);
transform-origin: center;
cursor: default;
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }
.pg-transition > .pg-old { position: absolute; inset: 0; background: #000; }
.pg-fill { position: absolute; inset: 0; }
.pg-image { position: absolute; will-change: transform, opacity; }
.pg-show-text { font-size: calc(var(--pg-font) * 1.5); text-shadow: 0 2px 6px #000; white-space: nowrap; }
.pg-hidden { display: none !important; }
.pg-textbox {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to bottom, rgba(10, 12, 24, 0.55), rgba(10, 12, 24, 0.92));
border-top: 2px solid rgba(255, 255, 255, 0.12);
}
.pg-textbox-image { background-color: transparent; background-size: 100% 100%; border: 0; }
.pg-textbox.pg-ui-hidden { opacity: 0; }
.pg-namebox {
position: absolute;
top: 0;
transform: translateY(-50%);
padding: 0.15em 0.8em;
font-size: var(--pg-name-font);
font-weight: 600;
color: var(--pg-accent);
background: rgba(10, 12, 24, 0.85);
border-radius: 0.3em;
text-shadow: 0 1px 2px #000;
}
.pg-textbox-image .pg-namebox { background: transparent; }
.pg-text {
position: absolute;
line-height: 1.35;
white-space: pre-wrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
.pg-ctc { margin-left: 0.3em; font-size: 0.6em; opacity: 0.7; animation: pg-blink 1s infinite; }
@keyframes pg-blink { 50% { opacity: 0.1; } }
.pg-quick {
position: absolute;
bottom: 0.35em;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.15em;
padding: 0.1em 0.3em;
border-radius: 99em;
background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px);
opacity: 0.55;
transition: opacity 0.2s;
}
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button {
background: none;
border: 0;
border-radius: 99em;
color: rgba(255, 255, 255, 0.8);
font: inherit;
font-size: calc(var(--pg-font) * 0.55);
padding: 0.25em 0.75em;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); }
.pg-menu {
position: absolute;
inset: 0 0 26% 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5em;
}
.pg-choice {
min-width: 50%;
max-width: 75%;
padding: 0.45em 1.2em;
font: inherit;
color: #fff;
background: rgba(15, 18, 35, 0.85);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 0.3em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.pg-choice:hover { background: rgba(40, 45, 80, 0.95); border-color: var(--pg-accent); }
.pg-nvl {
position: absolute;
inset: 0;
padding: 5% 15%;
background: rgba(0, 0, 0, 0.72) center / cover;
display: flex;
flex-direction: column;
gap: 0.6em;
overflow: hidden;
}
.pg-nvl-name { font-weight: 600; margin-right: 0.6em; }
.pg-nvl-what { white-space: pre-wrap; }
.pg-centered {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 15%;
white-space: pre-wrap;
text-shadow: 0 2px 4px #000;
}
.pg-notify {
position: absolute;
top: 2%;
left: 2%;
padding: 0.4em 1em;
font-size: calc(var(--pg-font) * 0.7);
background: rgba(0, 0, 0, 0.8);
border-left: 4px solid var(--pg-accent);
}
.pg-screen {
position: absolute;
inset: 0;
background: rgba(8, 10, 20, 0.88) center / cover;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85);
z-index: 10;
animation: pg-fade 0.15s ease-out;
}
@keyframes pg-fade { from { opacity: 0; } }
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
.pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; }
.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; }
.pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit;
color: #fff;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.5em;
padding: 0.5em 1.1em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
outline: 3px solid var(--pg-accent);
outline-offset: 2px;
}
.pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; }
.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; }
.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); }
.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; }
.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; }
.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; }
.pg-slot b { color: var(--pg-accent); font-weight: 600; }
.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-settings-screen {
--pg-accent: #ff9f43;
--pg-font: 18px;
z-index: 25;
padding: clamp(16px, 4%, 40px);
color: #edf0f7;
font: 16px/1.5 "Segoe UI", system-ui, sans-serif;
background: rgba(11, 14, 23, 0.96);
container-type: inline-size;
color-scheme: dark;
}
.pg-settings-screen .pg-screen-head,
.pg-settings-screen .pg-screen-body { width: 100%; max-width: 960px; margin-inline: auto; box-sizing: border-box; }
.pg-settings-screen .pg-screen-head { flex-shrink: 0; margin-bottom: 20px; }
.pg-settings-screen .pg-screen-head h2 { font-size: 24px; margin: 0; }
.pg-settings-screen .pg-close { min-width: 44px; min-height: 44px; }
.pg-settings-screen .pg-screen-body { min-height: 0; scrollbar-width: thin; }
.pg-settings { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; }
.pg-settings-card { min-width: 0; padding: 24px; border: 1px solid #303646; border-radius: 18px; background: #191e2a; }
.pg-settings-card:first-child { grid-row: span 2; }
.pg-settings-card h3 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
.pg-settings-hint { color: #aab3c7; font-size: 13px; margin: 0 0 22px; }
.pg-setting { display: flex; flex-direction: column; gap: 8px; margin-bottom: 22px; }
.pg-setting:last-child { margin-bottom: 0; }
.pg-setting-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pg-setting-val { color: var(--pg-accent); font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.pg-setting input[type="range"] {
appearance: none;
-webkit-appearance: none;
display: block;
width: 100%;
min-height: 44px;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
box-shadow: none;
cursor: pointer;
accent-color: var(--pg-accent);
}
.pg-setting input[type="range"]::-webkit-slider-runnable-track {
height: 6px;
border-radius: 99px;
background: linear-gradient(to right, var(--pg-accent) var(--pg-progress), #3c4458 var(--pg-progress));
}
.pg-setting input[type="range"]::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border: 3px solid #191e2a;
border-radius: 50%;
background: var(--pg-accent);
box-shadow: 0 0 0 1px var(--pg-accent);
}
.pg-setting input[type="range"]::-moz-range-track { height: 6px; border-radius: 99px; background: #3c4458; }
.pg-setting input[type="range"]::-moz-range-progress { height: 6px; border-radius: 99px; background: var(--pg-accent); }
.pg-setting input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid #191e2a; border-radius: 50%; background: var(--pg-accent); }
.pg-setting input[type="range"]:focus-visible { outline: 2px solid var(--pg-accent); outline-offset: 3px; }
.pg-settings-screen .pg-setting select { width: 100%; min-height: 44px; }
.pg-settings-screen .pg-wide { width: 100%; min-height: 44px; margin: 0; }
@container (max-width: 620px) {
.pg-settings { grid-template-columns: minmax(0, 1fr); }
.pg-settings-card:first-child { grid-row: auto; }
.pg-settings-card { padding: 18px; }
}
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
.pg-screen.pg-main { background-color: #000; justify-content: center; }
.pg-main-panel {
display: flex;
flex-direction: column;
gap: 0.55em;
width: min(26em, 100%);
padding: 2em;
background: rgba(8, 10, 20, 0.72);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; }
.pg-main-panel button { text-align: left; padding: 0.7em 1.2em; }
.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
/* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn {
position: absolute;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
z-index: 20;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
line-height: 1;
color: #fff;
background: rgba(8, 10, 20, 0.55);
backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
}
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
.pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; }
}
/* in-player confirmation */
.pg-dialog-back {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif;
}
.pg-dialog {
width: min(420px, 100%);
padding: 22px;
color: #eceef5;
background: #171b25;
border: 1px solid #2a3042;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px;
}
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; }
.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); }
.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; }
.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; }
.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; }
.pg-gallery-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; align-content: start; }
.pg-gallery-list h3, .pg-gallery-list p { grid-column: 1 / -1; }
.pg-sviewport { scrollbar-width: thin; }
/* --- renpy.input / renpy.display_menu ----------------------------------------------- */
.pg-input { display: flex; flex-direction: column; gap: 12px; align-items: center; pointer-events: auto; }
.pg-input input { font: inherit; font-size: var(--pg-font, 24px); padding: 6px 10px; min-width: 40%; }
.pg-caption { color: var(--pg-text, #fff); opacity: 0.8; text-align: center; padding: 4px 0; }
/* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
/* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
+3 -1
View File
@@ -28,6 +28,7 @@ export interface StyleDef {
}
export interface ScreenHost {
galleryUnlocked?: boolean;
py: Python;
fileUrl(path: string): string | null;
/** Element for an image name, file path or colour. */
@@ -1690,7 +1691,7 @@ export class ScreenManager {
if (isNativeAction(a)) return a.get_sensitive();
if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true);
return !(a instanceof ScreenAction) ||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true);
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? this.host.galleryUnlocked || !truthy(a.kw.locked ?? a.args[2]) : true);
});
}
@@ -2071,6 +2072,7 @@ export class ScreenManager {
out.Image = shape("image");
out.ImageReference = shape("image");
out.Gallery = galleryClass({
unlocked: () => !!this.host.galleryUnlocked,
seen: (name) => truthy(this.host.py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "renpy" }, a: "seen_image" }, a: [{ k: "const", v: name }], kw: {} })),
evaluate: (source) => this.host.py.eval(parseExpression(source)),
show: (pictures, transition) => this.showGallery(pictures, transition),
+1
View File
@@ -148,6 +148,7 @@ export interface GameData {
}
export interface Manifest {
galleryUnlocked?: boolean;
versionId?: number;
revision?: string;
format: string;