From 06ff19ac4a1a065e9a4565450ed6f3d9c21edbe3 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:31:29 +0300 Subject: [PATCH] fix: apply audio volume during fades and redesign player settings --- frontend/package.json | 1 + frontend/src/player/audio.ts | 59 +++++++++++++++---- frontend/src/player/engine.ts | 80 +++++++++++++++++++------- frontend/src/player/player.css | 71 ++++++++++++++++++++++- frontend/tests/audio.test.mjs | 100 +++++++++++++++++++++++++++++++++ 5 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 frontend/tests/audio.test.mjs diff --git a/frontend/package.json b/frontend/package.json index e36ce9d..244826c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "version": "0.1.0", "type": "module", "scripts": { + "test": "node --test tests/*.test.mjs", "dev": "vite", "build": "tsc -b && vite build", "typecheck": "tsc -b" diff --git a/frontend/src/player/audio.ts b/frontend/src/player/audio.ts index 1c732e9..f018bd6 100644 --- a/frontend/src/player/audio.ts +++ b/frontend/src/player/audio.ts @@ -13,24 +13,34 @@ interface Channel { queue: string[]; } -function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) { +function fade(from: number, to: number, seconds: number, update: (gain: number) => void, + done?: () => void): () => void { if (seconds <= 0) { - el.volume = to; + update(to); done?.(); - return; + return () => {}; } const start = performance.now(); const step = () => { const t = Math.min(1, (performance.now() - start) / (seconds * 1000)); - el.volume = Math.max(0, Math.min(1, from + (to - from) * t)); - if (t < 1) requestAnimationFrame(step); + update(from + (to - from) * t); + if (t < 1) frame = requestAnimationFrame(step); else done?.(); }; - requestAnimationFrame(step); + let frame = requestAnimationFrame(step); + return () => cancelAnimationFrame(frame); +} + +interface Playback { + channel: string; + gain: number; + cancelFade: () => void; } export class AudioSystem { channels = new Map(); + // Includes outgoing tracks until their fade finishes. + private playing = new Map(); constructor(public volumes: Volumes, private url: (file: string) => string | null, private registered: Record = {}) {} @@ -80,10 +90,13 @@ export class AudioSystem { return; } const el = new Audio(src); - const target = this.volumes[this.group(ch)]; - el.volume = fadein > 0 ? 0 : target; + const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, cancelFade: () => {} }; + this.playing.set(el, playback); + this.applyVolume(el, playback); el.loop = c.loop && c.queue.length === 0; el.onended = () => { + playback.cancelFade(); + this.playing.delete(el); if (c.el !== el) return; const next = c.queue.shift(); if (next) { @@ -97,7 +110,20 @@ export class AudioSystem { c.el = el; c.file = file; el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука")); - if (fadein > 0) fade(el, 0, target, fadein); + if (fadein > 0) this.fadePlayback(el, playback, 1, fadein); + } + + private applyVolume(el: HTMLAudioElement, playback: Playback) { + el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain)); + } + + private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number, + done?: () => void) { + playback.cancelFade(); + playback.cancelFade = fade(playback.gain, to, seconds, (gain) => { + playback.gain = gain; + this.applyVolume(el, playback); + }, done); } stop(ch: string, fadeout = 0) { @@ -107,16 +133,25 @@ export class AudioSystem { c.el = null; c.file = null; c.queue = []; - fade(el, el.volume, 0, fadeout, () => el.pause()); + const playback = this.playing.get(el); + if (playback) this.fadePlayback(el, playback, 0, fadeout, () => { + el.pause(); + this.playing.delete(el); + }); } stopAll() { for (const ch of this.channels.keys()) this.stop(ch); + for (const [el, playback] of this.playing) { + playback.cancelFade(); + el.pause(); + } + this.playing.clear(); } setVolumes(v: Volumes) { - this.volumes = v; - for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)]; + this.volumes = { ...v }; + for (const [el, playback] of this.playing) this.applyVolume(el, playback); } current(ch: string): { file: string; loop: boolean } | null { diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 5734706..7f3c8a0 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -129,7 +129,13 @@ export class Engine { this.imagesByTag.get(tag)!.push({ attrs, key }); } - this.settings = loadJSON("pgvn:settings", DEFAULT_SETTINGS); + this.settings = { ...loadJSON("pgvn:settings", DEFAULT_SETTINGS) }; + for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) { + const value = this.settings[key]; + const [min, max] = key === "cps" ? [0, 150] : key === "autoDelay" ? [0.3, 4] : [0, 1]; + this.settings[key] = typeof value === "number" && Number.isFinite(value) + ? Math.max(min, Math.min(max, value)) : DEFAULT_SETTINGS[key]; + } this.persistent = this.makePersistent(); const scope: Scope = { get: (n) => (n in this.store ? this.store[n] : this.builtins[n]), @@ -1276,7 +1282,6 @@ export class Engine { } private onKey = (ev: KeyboardEvent) => { - if ((ev.target as HTMLElement)?.tagName === "INPUT") return; const screenOpen = !this.screen.classList.contains("pg-hidden"); if (ev.key === "Escape") { if (this.screen.classList.contains("pg-main")) return; @@ -1284,6 +1289,7 @@ export class Engine { else if (this.running) this.openGameMenu(); return; } + if ((ev.target as HTMLElement)?.tagName === "INPUT") return; if (screenOpen) return; if (ev.key === " " || ev.key === "Enter") { ev.preventDefault(); @@ -1306,7 +1312,9 @@ export class Engine { private screenOnClose: (() => void) | null = null; - private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) { + private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void, + unscaled = false) { + this.placeScreen(unscaled); this.screen.textContent = ""; this.screen.classList.remove("pg-main"); this.container.classList.remove("pg-in-main"); @@ -1315,6 +1323,7 @@ export class Engine { const head = el("div", "pg-screen-head"); head.append(el("h2", "", title)); const close = el("button", "pg-close", "✕"); + close.setAttribute("aria-label", "Закрыть"); close.onclick = () => this.dismissScreen(); head.append(close); const body = el("div", "pg-screen-body"); @@ -1335,9 +1344,16 @@ export class Engine { this.screen.classList.remove("pg-main"); this.container.classList.remove("pg-in-main"); this.screen.style.backgroundImage = ""; + this.placeScreen(false); + } + + private placeScreen(unscaled: boolean) { + this.screen.classList.toggle("pg-settings-screen", unscaled); + (unscaled ? this.container : this.renderer.stage).append(this.screen); } private showMainMenu() { + this.placeScreen(false); this.running = false; this.gen++; this.wakeAll(); @@ -1467,39 +1483,63 @@ export class Engine { private openSettings(fromMain = false) { this.openScreen("Настройки", (body) => { const s = this.settings; - const row = (label: string, min: number, max: number, step: number, value: number, + const layout = el("div", "pg-settings"); + body.append(layout); + const section = (title: string, description: string) => { + const card = el("section", "pg-settings-card"); + card.append(el("h3", "", title), el("p", "pg-settings-hint", description)); + layout.append(card); + return card; + }; + const row = (parent: HTMLElement, label: string, min: number, max: number, step: number, value: number, fmt: (v: number) => string, set: (v: number) => void) => { const r = el("label", "pg-setting"); - const out = el("span", "pg-setting-val", fmt(value)); + const heading = el("span", "pg-setting-head"); + const out = el("output", "pg-setting-val", fmt(value)); const input = el("input"); Object.assign(input, { type: "range", min, max, step, value: String(value) }); + input.setAttribute("aria-label", label); + const update = () => { + out.value = fmt(Number(input.value)); + input.setAttribute("aria-valuetext", out.value); + input.style.setProperty("--pg-progress", `${(Number(input.value) - min) / (max - min) * 100}%`); + }; + update(); input.oninput = () => { set(Number(input.value)); - out.textContent = fmt(Number(input.value)); + update(); saveJSON("pgvn:settings", this.settings); this.audio.setVolumes(this.settings); }; - r.append(el("span", "", label), input, out); - body.append(r); + heading.append(el("span", "", label), out); + r.append(heading, input); + parent.append(r); }; - row("Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "мгновенно" : `${v} симв/с`), (v) => (s.cps = v)); - row("Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v)); - row("Музыка", 0, 1, 0.05, s.music, (v) => `${Math.round(v * 100)}%`, (v) => (s.music = v)); - row("Звуки", 0, 1, 0.05, s.sound, (v) => `${Math.round(v * 100)}%`, (v) => (s.sound = v)); - row("Голос", 0, 1, 0.05, s.voice, (v) => `${Math.round(v * 100)}%`, (v) => (s.voice = v)); + const audio = section("Звук", "Громкость меняется сразу. 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)); + 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)); + const display = section("Игра", "Изменения сохраняются автоматически в этом браузере."); const langs = this.languages(); if (langs.length > 1) { const r = el("label", "pg-setting"); - r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {}), el("span", "")); - body.append(r); + r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {})); + display.append(r); } - const fs = el("button", "pg-wide", "Полный экран"); + const fs = el("button", "pg-wide", document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран"); fs.onclick = () => { - if (document.fullscreenElement) void document.exitFullscreen(); - else void this.root.requestFullscreen(); + const action = document.fullscreenElement ? document.exitFullscreen() : this.root.requestFullscreen(); + void action.then(() => { + fs.textContent = document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран"; + }).catch(() => this.notify("Не удалось изменить полноэкранный режим")); }; - body.append(fs); - }, fromMain ? () => this.showMainMenu() : undefined); + fs.hidden = !document.fullscreenEnabled; + display.append(fs); + }, fromMain ? () => this.showMainMenu() : undefined, true); } } diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 0501668..8f674f1 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -201,9 +201,74 @@ .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-setting { display: grid; grid-template-columns: minmax(8em, 30%) 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.8em; } -.pg-setting input { width: 100%; accent-color: var(--pg-accent); min-height: 1.6em; } -.pg-setting-val { opacity: 0.7; } +.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; } diff --git a/frontend/tests/audio.test.mjs b/frontend/tests/audio.test.mjs new file mode 100644 index 0000000..2c052f5 --- /dev/null +++ b/frontend/tests/audio.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { beforeEach, test } from "node:test"; +import ts from "typescript"; + +// Exercise the production module with a deterministic media element and animation clock. +const source = readFileSync(new URL("../src/player/audio.ts", import.meta.url), "utf8"); +const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }); +const { AudioSystem } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`); +let now, frames, elements; +beforeEach(() => { + now = 0; + frames = new Map(); + elements = []; + let id = 0; + globalThis.performance = { now: () => now }; + globalThis.requestAnimationFrame = (fn) => { frames.set(++id, fn); return id; }; + globalThis.cancelAnimationFrame = (key) => frames.delete(key); + globalThis.Audio = class { + volume = 1; + paused = true; + constructor(src) { this.src = src; elements.push(this); } + play() { this.paused = false; return Promise.resolve(); } + pause() { this.paused = true; } + }; +}); +function tick(ms) { + now += ms; + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((fn) => fn()); +} +const volumes = () => ({ music: 0.7, sound: 0.8, voice: 1 }); +const system = (registered = {}) => new AudioSystem(volumes(), (file) => file, registered); + +test("volume changes apply immediately to each mixer and future tracks", () => { + const audio = system({ ambience: { mixer: "sfx", loop: true }, score: { mixer: "music", loop: true } }); + for (const ch of ["music", "sound", "voice", "ambience", "score"]) audio.play(ch, [ch], {}); + audio.setVolumes({ music: 0, sound: 0.25, voice: 0.5 }); + assert.deepEqual(elements.map((el) => el.volume), [0, 0.25, 0.5, 0.25, 0]); + audio.play("music", ["next"], { fadeout: 0 }); + assert.equal(elements.at(-1).volume, 0); +}); + +test("fade-in respects live volume changes and stays muted at zero", () => { + const audio = system(); + audio.play("music", ["track"], { fadein: 2 }); + tick(1000); + assert.equal(elements[0].volume, 0.35); + audio.setVolumes({ ...volumes(), music: 0.2 }); + assert.equal(elements[0].volume, 0.1); + tick(500); + assert.ok(Math.abs(elements[0].volume - 0.15) < 1e-9); + audio.setVolumes({ ...volumes(), music: 0 }); + tick(500); + assert.equal(elements[0].volume, 0); +}); + +test("outgoing tracks also obey the mixer during crossfade", () => { + const audio = system(); + audio.play("music", ["old"], {}); + audio.play("music", ["new"], { fadeout: 2, fadein: 2 }); + tick(500); + audio.setVolumes({ ...volumes(), music: 0 }); + tick(500); + assert.deepEqual(elements.map((el) => el.volume), [0, 0]); + tick(1000); + assert.equal(elements[0].paused, true); +}); + +test("stopping during fade-in cancels the old animation", () => { + const audio = system(); + audio.play("music", ["track"], { fadein: 2 }); + tick(500); + audio.stop("music", 0.5); + tick(500); + tick(1000); + assert.equal(elements[0].volume, 0); + assert.equal(elements[0].paused, true); + assert.equal(frames.size, 0); +}); + +test("stopAll stops outgoing audio and cancels every fade", () => { + const audio = system(); + audio.play("music", ["old"], {}); + audio.play("music", ["new"], { fadeout: 3, fadein: 3 }); + audio.stopAll(); + assert.ok(elements.every((el) => el.paused)); + assert.equal(frames.size, 0); + assert.equal(audio.current("music"), null); +}); + +test("queued tracks use the latest mixer volume", () => { + const audio = system(); + audio.play("sound", ["first", "second"], {}); + audio.setVolumes({ ...volumes(), sound: 0.12 }); + elements[0].onended(); + assert.equal(elements[1].volume, 0.12); + assert.equal(audio.current("sound").file, "second"); +});