From a93f33c93d84961c91aa47b5357623a5c9db114c Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:27:39 +0300 Subject: [PATCH] feat: shared volume settings with a per-game override - Music, sound and voice volumes are shared by all games by default; a game that has never been launched with its own volumes plays with the shared ones - The sound section of the player settings has a "own volume for this game" switch: turning it on starts from the current values and then the sliders change only this game, turning it off removes the override and returns to the shared volumes - The override belongs to the game (game_id from /api/versions/{id}), so forks and mods share it; it falls back to the version id when the id is unavailable - Reading speed and auto delay stay shared - volume-store.ts keeps the storage logic (damaged data ignored, values clamped, unavailable storage tolerated) and has its own tests Co-Authored-By: Claude Sonnet 5.5 --- frontend/src/player/engine.ts | 75 ++++++++++++++++++++++++---- frontend/src/player/index.ts | 5 +- frontend/src/player/player.css | 4 ++ frontend/src/player/volume-store.ts | 51 +++++++++++++++++++ frontend/tests/volume-store.test.mjs | 58 +++++++++++++++++++++ 5 files changed, 181 insertions(+), 12 deletions(-) create mode 100644 frontend/src/player/volume-store.ts create mode 100644 frontend/tests/volume-store.test.mjs diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 278fea9..5849914 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -1,6 +1,7 @@ // PGVN interpreter + in-game UI. import { AudioSystem } from "./audio"; +import { VolumeStore } from "./volume-store"; import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; import { Renderer, type ResolvedImage } from "./renderer"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; @@ -40,6 +41,10 @@ export class Engine { private waiters: (() => void)[] = []; private screens: ScreenManager; private screenFonts = new Map(); + /** Settings shared by all games; `settings` is what this game actually uses. */ + private sharedSettings: Settings; + private ownVolumes = false; + private volumes: VolumeStore; private replayStash: SaveData | null = null; private seenImages = new Set(); private running = false; @@ -77,7 +82,10 @@ export class Engine { private onExit: () => void, private tr: Translator, private loadLocale: (lang: string) => Promise, + /** Storage key of this game's own volumes; without it the game always uses the shared ones. */ + audioKey: string | null = null, ) { + this.volumes = new VolumeStore(audioKey); for (const mod of modules) { let global = ""; mod.labels.forEach((label, index) => { @@ -93,13 +101,20 @@ export class Engine { this.imagesByTag.get(tag)!.push({ attrs, key }); } - this.settings = { ...loadJSON("pgvn:settings", DEFAULT_SETTINGS) }; + this.sharedSettings = { ...loadJSON("pgvn:settings", DEFAULT_SETTINGS) }; for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) { - const value = this.settings[key]; + const value = this.sharedSettings[key]; const [min, max] = key === "cps" ? [0, 150] : key === "autoDelay" ? [0.3, 4] : [0, 1]; - this.settings[key] = typeof value === "number" && Number.isFinite(value) + this.sharedSettings[key] = typeof value === "number" && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : DEFAULT_SETTINGS[key]; } + // a game that has no volumes of its own (a first launch) plays with the shared ones + this.settings = { ...this.sharedSettings }; + const own = this.volumes.read(); + if (own) { + Object.assign(this.settings, own); + this.ownVolumes = true; + } this.persistent = this.makePersistent(); const scope: Scope = { get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]), @@ -1911,6 +1926,34 @@ export class Engine { }, fromMain ? () => this.showMainMenu() : undefined); } + /** Save a change made in the settings screen: volumes go to this game or to everyone. */ + private persistSettings(kind: "audio" | "reading") { + const { music, sound, voice, cps, autoDelay } = this.settings; + if (kind === "audio" && this.ownVolumes) { + this.volumes.write({ music, sound, voice }); + return; + } + if (kind === "audio") Object.assign(this.sharedSettings, { music, sound, voice }); + else Object.assign(this.sharedSettings, { cps, autoDelay }); + saveJSON("pgvn:settings", this.sharedSettings); + } + + private useOwnVolumes(on: boolean, fromMain: boolean) { + if (!this.volumes.available) return; + const { music, sound, voice } = this.settings; + if (on) { + this.ownVolumes = true; + this.volumes.write({ music, sound, voice }); // starts from what is playing now + } else { + this.ownVolumes = false; + this.volumes.clear(); + Object.assign(this.settings, { music: this.sharedSettings.music, sound: this.sharedSettings.sound, + voice: this.sharedSettings.voice }); + this.audio.setVolumes(this.settings); + } + this.openSettings(fromMain); // redraw the sliders with the values now in effect + } + private openSettings(fromMain = false) { this.openScreen("Настройки", (body) => { const s = this.settings; @@ -1923,7 +1966,7 @@ export class Engine { return card; }; const row = (parent: HTMLElement, label: string, min: number, max: number, step: number, value: number, - fmt: (v: number) => string, set: (v: number) => void) => { + fmt: (v: number) => string, set: (v: number) => void, kind: "audio" | "reading") => { const r = el("label", "pg-setting"); const heading = el("span", "pg-setting-head"); const out = el("output", "pg-setting-val", fmt(value)); @@ -1939,21 +1982,31 @@ export class Engine { input.oninput = () => { set(Number(input.value)); update(); - saveJSON("pgvn:settings", this.settings); + this.persistSettings(kind); this.audio.setVolumes(this.settings); }; heading.append(el("span", "", label), out); r.append(heading, input); parent.append(r); }; - const audio = section("Звук", "Громкость меняется сразу. 0% — выключить канал."); + const audio = section("Звук", this.ownVolumes + ? "Громкость только для этой игры. 0% — выключить канал." + : "Общая громкость всех игр. 0% — выключить канал."); const percent = (v: number) => `${Math.round(v * 100)}%`; - row(audio, "Музыка", 0, 1, 0.01, s.music, percent, (v) => (s.music = v)); - row(audio, "Звуки", 0, 1, 0.01, s.sound, percent, (v) => (s.sound = v)); - row(audio, "Голос", 0, 1, 0.01, s.voice, percent, (v) => (s.voice = v)); + if (this.volumes.available) { + const toggle = el("label", "pg-check"); + const box = el("input"); + Object.assign(box, { type: "checkbox", checked: this.ownVolumes }); + box.onchange = () => this.useOwnVolumes(box.checked, fromMain); + toggle.append(box, el("span", "", "Своя громкость для этой игры")); + audio.append(toggle); + } + row(audio, "Музыка", 0, 1, 0.01, s.music, percent, (v) => (s.music = v), "audio"); + row(audio, "Звуки", 0, 1, 0.01, s.sound, percent, (v) => (s.sound = v), "audio"); + row(audio, "Голос", 0, 1, 0.01, s.voice, percent, (v) => (s.voice = v), "audio"); const reading = section("Чтение", "Настройте темп появления текста и автопрочтения."); - row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v)); - row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v)); + row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading"); + row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading"); const display = section("Игра", "Изменения сохраняются автоматически в этом браузере."); const langs = this.languages(); if (langs.length > 1) { diff --git a/frontend/src/player/index.ts b/frontend/src/player/index.ts index 3d15404..91cbc6a 100644 --- a/frontend/src/player/index.ts +++ b/frontend/src/player/index.ts @@ -43,9 +43,12 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () => if (!load) throw new Error(`Неизвестная среда исполнения игры: ${game.runtime}`); return (await load())({ root, game, manifest, modules, url, tr, onExit, versionId, saveApi: (id) => saveApi(id, cloud) }); } + // volume belongs to the game, not to one of its versions (forks and mods share it) + const gameId = await getJSON<{ game_id: number }>(`/api/versions/${versionId}`).then((v) => v.game_id, () => null); const engine = new Engine(root, game, manifest, modules, saveApi(versionId, cloud), `pgvn:persistent:${versionId}`, onExit, tr, - (code) => loadTranslator(game, manifest, code)); + (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); else engine.start(); diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 3d7113d..5ca6bbe 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -365,3 +365,7 @@ .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); } diff --git a/frontend/src/player/volume-store.ts b/frontend/src/player/volume-store.ts new file mode 100644 index 0000000..d39b9b7 --- /dev/null +++ b/frontend/src/player/volume-store.ts @@ -0,0 +1,51 @@ +// Volumes of a game: shared by all games unless the game has been given its own. + +import type { Volumes } from "./audio"; + +type Store = Pick; + +function usable(v: any): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +const clamp = (v: number) => Math.max(0, Math.min(1, v)); + +export class VolumeStore { + constructor(private key: string | null, private storage: Store = localStorage) {} + + get available() { + return this.key !== null; + } + + /** This game's own volumes, or null when it has none (it then plays with the shared ones). */ + read(): Volumes | null { + if (!this.key) return null; + try { + const data = JSON.parse(this.storage.getItem(this.key) ?? "null"); + if (data && usable(data.music) && usable(data.sound) && usable(data.voice)) { + return { music: clamp(data.music), sound: clamp(data.sound), voice: clamp(data.voice) }; + } + } catch { + /* storage unavailable or damaged: fall back to the shared volumes */ + } + return null; + } + + write({ music, sound, voice }: Volumes) { + if (!this.key) return; + try { + this.storage.setItem(this.key, JSON.stringify({ music, sound, voice })); + } catch { + /* the change still applies for this session */ + } + } + + clear() { + if (!this.key) return; + try { + this.storage.removeItem(this.key); + } catch { + /* nothing to remove */ + } + } +} diff --git a/frontend/tests/volume-store.test.mjs b/frontend/tests/volume-store.test.mjs new file mode 100644 index 0000000..38ef088 --- /dev/null +++ b/frontend/tests/volume-store.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { build } from "esbuild"; + +const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/player/volume-store.ts", import.meta.url))], + bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" }); +const { VolumeStore } = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); + +function storage(initial = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k) => data.get(k) ?? null, + setItem: (k, v) => void data.set(k, v), + removeItem: (k) => void data.delete(k), + }; +} + +test("a game that was never launched has no volumes of its own, so it plays with the shared ones", () => { + const store = new VolumeStore("pgvn:audio:game:7", storage()); + assert.equal(store.read(), null); +}); + +test("own volumes are kept per game and do not touch other games", () => { + const backing = storage(); + new VolumeStore("pgvn:audio:game:7", backing).write({ music: 0.2, sound: 0.3, voice: 1 }); + assert.deepEqual(new VolumeStore("pgvn:audio:game:7", backing).read(), { music: 0.2, sound: 0.3, voice: 1 }); + assert.equal(new VolumeStore("pgvn:audio:game:8", backing).read(), null); +}); + +test("clearing returns the game to the shared volumes", () => { + const backing = storage(); + const store = new VolumeStore("k", backing); + store.write({ music: 0, sound: 0, voice: 0 }); + store.clear(); + assert.equal(store.read(), null); + assert.equal(backing.data.size, 0); +}); + +test("damaged or out-of-range data is ignored or limited, and a missing key means shared volumes only", () => { + assert.equal(new VolumeStore("k", storage({ k: "{not json" })).read(), null); + assert.equal(new VolumeStore("k", storage({ k: JSON.stringify({ music: "loud", sound: 1, voice: 1 }) })).read(), null); + assert.deepEqual(new VolumeStore("k", storage({ k: JSON.stringify({ music: 5, sound: -1, voice: 0.5 }) })).read(), + { music: 1, sound: 0, voice: 0.5 }); + const none = new VolumeStore(null, storage()); + assert.equal(none.available, false); + none.write({ music: 1, sound: 1, voice: 1 }); + assert.equal(none.read(), null); +}); + +test("an unavailable storage does not break playing", () => { + const broken = { getItem: () => { throw new Error("blocked"); }, setItem: () => { throw new Error("blocked"); }, + removeItem: () => { throw new Error("blocked"); } }; + const store = new VolumeStore("k", broken); + assert.equal(store.read(), null); + assert.doesNotThrow(() => { store.write({ music: 1, sound: 1, voice: 1 }); store.clear(); }); +});