Add generative garden audio
This commit is contained in:
parent
f8294934fd
commit
c2efb33683
45 changed files with 3730 additions and 0 deletions
152
src/page/audio-control.ts
Normal file
152
src/page/audio-control.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { appConfig } from '../config';
|
||||
import type GameLoop from '../game-loop/game-loop';
|
||||
import { readBrowserStorage, writeBrowserStorage } from '../utils/browser-storage';
|
||||
import { queryRequiredElement } from '../utils/dom';
|
||||
import { clamp01 } from '../utils/math';
|
||||
|
||||
const clampAudioVolume = (value: number): number => {
|
||||
const { default: defaultVolume, max, min } = appConfig.toolbar.volume;
|
||||
const safeValue = Number.isFinite(value) ? value : defaultVolume;
|
||||
return Math.min(max, Math.max(min, clamp01(safeValue)));
|
||||
};
|
||||
|
||||
const readInitialAudioVolume = (): number => {
|
||||
const storedVolume = readBrowserStorage(appConfig.storage.audioVolumeKey);
|
||||
return storedVolume === null
|
||||
? appConfig.toolbar.volume.default
|
||||
: clampAudioVolume(Number(storedVolume));
|
||||
};
|
||||
|
||||
const formatStoredAudioVolume = (volume: number): string =>
|
||||
clampAudioVolume(volume).toFixed(2);
|
||||
|
||||
const STORED_MUTED_TRUE = '1';
|
||||
const STORED_MUTED_FALSE = '0';
|
||||
|
||||
interface AudioControlOptions {
|
||||
getGame: () => GameLoop | null;
|
||||
hasStarted: () => boolean;
|
||||
startButton: HTMLElement;
|
||||
}
|
||||
|
||||
export class AudioControl {
|
||||
private readonly soundButton = queryRequiredElement(
|
||||
'[data-control="sound"]',
|
||||
HTMLButtonElement
|
||||
);
|
||||
private readonly volumeControl = queryRequiredElement(
|
||||
'.volume-control',
|
||||
HTMLLabelElement
|
||||
);
|
||||
private readonly volumeSlider = queryRequiredElement(
|
||||
'.volume-slider',
|
||||
HTMLInputElement
|
||||
);
|
||||
|
||||
private audioVolume = readInitialAudioVolume();
|
||||
private isMutedState =
|
||||
readBrowserStorage(appConfig.storage.audioMutedKey) === STORED_MUTED_TRUE ||
|
||||
this.audioVolume <= 0;
|
||||
|
||||
public constructor(private readonly options: AudioControlOptions) {
|
||||
this.soundButton.addEventListener('click', this.onToggleMute);
|
||||
this.volumeSlider.addEventListener('input', this.onVolumeInput);
|
||||
|
||||
const passiveCaptureOptions = { capture: true, passive: true } as const;
|
||||
const captureOptions = { capture: true } as const;
|
||||
(
|
||||
[
|
||||
['touchstart', passiveCaptureOptions],
|
||||
['pointerdown', passiveCaptureOptions],
|
||||
['touchend', passiveCaptureOptions],
|
||||
['pointerup', passiveCaptureOptions],
|
||||
['click', captureOptions],
|
||||
['keydown', captureOptions],
|
||||
] satisfies Array<[keyof WindowEventMap, AddEventListenerOptions]>
|
||||
).forEach(([event, opts]) => {
|
||||
window.addEventListener(event, this.onUserGesture, opts);
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
public get isMuted(): boolean {
|
||||
return this.isMutedState || this.audioVolume <= 0;
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.audioVolume = clampAudioVolume(this.audioVolume);
|
||||
const isEffectivelyMuted = this.isMuted;
|
||||
const volumePercent = Math.round(this.audioVolume * 100);
|
||||
|
||||
this.soundButton.classList.toggle('muted', isEffectivelyMuted);
|
||||
this.soundButton.setAttribute('aria-pressed', String(isEffectivelyMuted));
|
||||
const muteLabel = isEffectivelyMuted ? 'Unmute audio' : 'Mute audio';
|
||||
this.soundButton.setAttribute('aria-label', muteLabel);
|
||||
this.soundButton.title = muteLabel;
|
||||
|
||||
this.volumeSlider.min = appConfig.toolbar.volume.min.toString();
|
||||
this.volumeSlider.max = appConfig.toolbar.volume.max.toString();
|
||||
this.volumeSlider.step = appConfig.toolbar.volume.step.toString();
|
||||
this.volumeSlider.value = formatStoredAudioVolume(this.audioVolume);
|
||||
this.volumeSlider.setAttribute(
|
||||
'aria-valuetext',
|
||||
isEffectivelyMuted ? `Muted, ${volumePercent}%` : `${volumePercent}%`
|
||||
);
|
||||
this.volumeControl.classList.toggle('muted', isEffectivelyMuted);
|
||||
this.volumeControl.title = isEffectivelyMuted
|
||||
? `Muted, ${volumePercent}% volume`
|
||||
: `${volumePercent}% volume`;
|
||||
this.volumeControl.style.setProperty('--volume-progress', `${volumePercent}%`);
|
||||
|
||||
const game = this.options.getGame();
|
||||
game?.setAudioVolume(this.audioVolume);
|
||||
game?.setAudioMuted(isEffectivelyMuted);
|
||||
}
|
||||
|
||||
private readonly onToggleMute = () => {
|
||||
const shouldUnmute = this.isMutedState || this.audioVolume <= 0;
|
||||
if (shouldUnmute && this.audioVolume <= 0) {
|
||||
this.audioVolume = appConfig.toolbar.volume.default;
|
||||
}
|
||||
this.isMutedState = !shouldUnmute;
|
||||
this.persist();
|
||||
this.render();
|
||||
if (!this.isMutedState) {
|
||||
this.options.getGame()?.startAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onVolumeInput = () => {
|
||||
this.audioVolume = clampAudioVolume(Number(this.volumeSlider.value));
|
||||
this.isMutedState = this.audioVolume <= 0;
|
||||
this.persist();
|
||||
this.render();
|
||||
if (!this.isMutedState) {
|
||||
this.options.getGame()?.startAudio(true);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onUserGesture = (event: Event) => {
|
||||
if (
|
||||
!this.options.hasStarted() ||
|
||||
this.isMutedState ||
|
||||
(event.target instanceof Node && this.options.startButton.contains(event.target)) ||
|
||||
(event.target instanceof Node && this.soundButton.contains(event.target))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.options.getGame()?.startAudio(true);
|
||||
};
|
||||
|
||||
private persist(): void {
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioMutedKey,
|
||||
this.isMutedState ? STORED_MUTED_TRUE : STORED_MUTED_FALSE
|
||||
);
|
||||
writeBrowserStorage(
|
||||
appConfig.storage.audioVolumeKey,
|
||||
formatStoredAudioVolume(this.audioVolume)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue