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 <noreply@anthropic.com>
This commit is contained in:
1 parent
52f6e7c18f
commit
a93f33c93d
5 files changed
+181
-12
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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); }
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(); });
|
||||
});
|
||||
Reference in new issue
Block a user