feat: shared volume settings with a per-game override
CI / frontend (push) Successful in 41s
CI / backend (push) Successful in 1m41s

- 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 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 18:27:39 +03:00
1 parent 52f6e7c18f
commit a93f33c93d
5 files changed
+181 -12

No files matched your search

+64 -11
View File
@@ -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<string, string>();
/** 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<string>();
private running = false;
@@ -77,7 +82,10 @@ export class Engine {
private onExit: () => void,
private tr: Translator,
private loadLocale: (lang: string) => Promise<Translator>,
/** 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) {
+4 -1
View File
@@ -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();
+4
View File
@@ -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); }
+51
View File
@@ -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<Storage, "getItem" | "setItem" | "removeItem">;
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 */
}
}
}
+58
View File
@@ -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(); });
});