Add generative garden audio

This commit is contained in:
Andras Schmelczer 2026-05-24 10:57:57 +01:00
commit c2efb33683
45 changed files with 3730 additions and 0 deletions

152
src/page/audio-control.ts Normal file
View 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)
);
}
}