fix: apply audio volume during fades and redesign player settings
This commit is contained in:
1 parent
fa82c99a52
commit
06ff19ac4a
5 files changed
+276
-35
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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<string, Channel>();
|
||||
// Includes outgoing tracks until their fade finishes.
|
||||
private playing = new Map<HTMLAudioElement, Playback>();
|
||||
|
||||
constructor(public volumes: Volumes, private url: (file: string) => string | null,
|
||||
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user